Reactで、画像の色数を減らしてポスターのようにする方法

​​React​Canvas API,Playwright,SVG,TypeScript

画像をポスターのような見た目にするには、画素の R・G・B の値をそれぞれ数段階にそろえ、使う色の数を減らします。canvas で画素の値を取り出し、0〜255 の値を「4段階なら 0・85・170・255 のどれか」のように置き換えれば、なめらかに色が変わる部分が、くっきりした色の帯に変わります。

ここでは、色を減らす関数 posterize と、段階の数を選んで結果を表示し、PNG で保存できるコンポーネント App を作りました。表示するだけでよいときに使える、SVG のフィルターで同じ色にする書き方も試しています。SVG のほうは、属性を1つ指定し忘れると色の分かれ目がずれます。どちらも、画素を数えた結果で説明します。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。Playwright で Chromium を動かして画像を選び、表示された結果を、Python(Pillow 10.2.0)で同じ計算をした画像と1画素ずつ比べました。画像は写真ではなく、Pillow で描いた 600×400 の不透明な画像(1,021 色)です。Firefox と Safari、スマートフォンのブラウザ、透明な部分を含む画像、写真のような大きな画像での処理の速さは試していません。

1色ごとに、値を決まった数の段階にそろえる

色を減らす処理では、R・G・B のそれぞれを同じ規則で置き換えます。0〜255 の値を段階の数だけ同じ幅の区間に分け、区間ごとに1つの値にそろえます。4段階なら、0〜63 は 0、64〜127 は 85、128〜191 は 170、192〜255 は 255 です。そろえる先の値は、0 から 255 までを等しく分けた値にしています。

値は 256 通りしかないので、先に対応表を作っておき、画素ごとには表を引くだけにしました。

ts
// 0〜255 の値を levels 段階に減らす対応表を作る。
// 0〜255 を levels 個の同じ幅の区間に分け、k 番目の区間の値は k * 255 / (levels - 1) にそろえる
export function makeTable(levels: number): Uint8ClampedArray {
  const table = new Uint8ClampedArray(256);
  for (let v = 0; v < 256; v++) {
    const k = Math.min(levels - 1, Math.floor((v / 255) * levels));
    table[v] = Math.round((k * 255) / (levels - 1));
  }
  return table;
}

// R・G・B をそれぞれ levels 段階に減らした画像を作る。A(不透明度)はそのまま
export function posterize(src: ImageData, levels: number): ImageData {
  const table = makeTable(levels);
  const out = new ImageData(src.width, src.height);
  const a = src.data;
  const b = out.data;
  for (let i = 0; i < a.length; i += 4) {
    b[i] = table[a[i]];
    b[i + 1] = table[a[i + 1]];
    b[i + 2] = table[a[i + 2]];
    b[i + 3] = a[i + 3];
  }
  return out;
}

// 画像の中で使われている色(R・G・B の組み合わせ)の数を数える
export function countColors(img: ImageData): number {
  const colors = new Set<number>();
  const d = img.data;
  for (let i = 0; i < d.length; i += 4) {
    colors.add((d[i] << 16) | (d[i + 1] << 8) | d[i + 2]);
  }
  return colors.size;
}

// 選んだファイルを読み込み、画素の値を取り出す
export async function loadImageData(file: File): Promise<ImageData> {
  const bitmap = await createImageBitmap(file);
  const canvas = document.createElement('canvas');
  canvas.width = bitmap.width;
  canvas.height = bitmap.height;
  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('canvas が使えません');
  ctx.drawImage(bitmap, 0, 0);
  bitmap.close();
  return ctx.getImageData(0, 0, canvas.width, canvas.height);
}

ImageData の data には、左上の画素から行ごとに、1画素につき R・G・B・A の4つの値が順に並んでいます。そのため、4つずつ進みながら R・G・B だけを表で置き換え、A は元の値を写しています。画素の値は、createImageBitmap() で読み込んだ画像を画面に出さない canvas に描き、getImageData() で取り出します。

1色あたり4段階なら、色の組み合わせは最大で 4×4×4=64 色です。ただし、実際に使われる色はそれより少なくなります。検証に使った画像では、元の 1,021 色が 20 色になりました。countColors は、R・G・B を1つの数にまとめて Set に入れ、使われている色の数を数えています。

上に元の画像、下に1色あたり4段階にした結果。元の画像は、青から薄いオレンジへなめらかに変わる空、中心から外へ色が変わる太陽、左から右へ明るくなる緑の地面、黒から白へ変わる灰色の帯でできている。4段階にした結果では、空が青・水色・青緑・灰色・薄い黄色の帯に分かれ、太陽は3重の輪、地面は緑とオリーブ色の5つの区画、灰色の帯は黒・濃い灰色・薄い灰色・白の4区画になっている
元の画像(上)と、1色あたり4段階にした結果(下)

段階を選んで表示し、PNG で保存するコンポーネント

App は、元の画像の ImageData と段階の数を state に持ちます。色を減らした結果と色の数は useMemo で計算するので、画像か段階の数が変わったときだけ計算し直します。結果は、useEffect の中で putImageData() を使って canvas に描きます。

tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ChangeEvent } from 'react';
import { countColors, loadImageData, posterize } from './posterize';

export function App() {
  const [src, setSrc] = useState<ImageData | null>(null);
  const [levels, setLevels] = useState(4);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  // 画像か段階の数が変わったときだけ、色を減らし直す
  const result = useMemo(() => (src ? posterize(src, levels) : null), [src, levels]);
  const colors = useMemo(() => (result ? countColors(result) : 0), [result]);

  // 色を減らした画像を canvas に描く
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!result || !canvas) return;
    canvas.width = result.width;
    canvas.height = result.height;
    canvas.getContext('2d')?.putImageData(result, 0, 0);
  }, [result]);

  async function pick(e: ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (file) setSrc(await loadImageData(file));
  }

  // canvas の中身を PNG にして保存する
  function save() {
    canvasRef.current?.toBlob((blob) => {
      if (!blob) return;
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `poster-${levels}.png`;
      a.click();
      URL.revokeObjectURL(url);
    }, 'image/png');
  }

  return (
    <main>
      <label className="pick">
        画像を選ぶ
        <input type="file" accept="image/*" onChange={pick} />
      </label>
      <label className="levels">
        1色あたりの段階:{levels}
        <input type="range" min={2} max={8} step={1} value={levels} onChange={(e) => setLevels(Number(e.target.value))} />
      </label>
      {result && (
        <>
          <p className="info">
            使われている色:{colors.toLocaleString()} 色(最大 {(levels ** 3).toLocaleString()} 色)
          </p>
          <canvas ref={canvasRef} className="result" role="img" aria-label="色の数を減らした画像" />
          <button type="button" onClick={save}>
            PNG で保存
          </button>
        </>
      )}
    </main>
  );
}

保存ボタンを押すと、toBlob() で canvas の中身を PNG の Blob にし、URL.createObjectURL() で作った URL を download 属性を付けたリンクに設定してクリックし、ダウンロードさせます。検証では、4段階で保存ボタンを押すと poster-4.png という名前でダウンロードされ、その中身は表示していた canvas と全画素が同じでした。

CSS は次のとおりです。結果の canvas は max-width: 100% で画面の幅に収めています。幅 420px の画面では、600×400 の結果が 389.98×260.66px に縮んで表示されました。

css
body { margin: 0; font-family: "Noto Sans CJK JP", sans-serif; color: #1f2a30; background: #fff; }
main { padding: 16px; max-width: 620px; }
.pick, .levels { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 14px; }
.info { font-size: 14px; margin: 0 0 8px; }
.result { display: block; max-width: 100%; height: auto; border: 1px solid #9aa5ab; margin: 0 0 10px; }
button { font: inherit; font-size: 14px; padding: 6px 14px; }

次の GIF は、画像を選んだあと、段階を 4 から 2、2 から 8 に変えたときの様子です。つまみは 1 刻みなので、途中の段階も通ります。2段階では、R・G・B がそれぞれ 0 か 255 のどちらかになるので、空は青と水色と白、地面は黒と緑と黄色だけになりました。8段階にすると帯が細かくなり、元の画像に近づきます。

選んだ画像は sample.png。最初は段階 4 で、使われている色は 20 色(最大 64 色)。つまみを左へ動かし、3 を通って 2 にすると、色は 7 色(最大 8 色)になり、空は青・水色・白、太陽は白い中心と黄色、赤い輪、地面は黒・緑・黄色だけになる。次につまみを右端まで動かし、3〜7 を通って 8 にすると、40 色(最大 512 色)になり、空や地面が細い帯に分かれる
段階を 4 から 2、2 から 8 に変える様子。変わった数字は、その瞬間だけピンクにしている。カーソル、操作の表示、選んだ画像のファイル名は GIF 用に重ねたもので、ファイルの入力欄と保存ボタンは GIF では隠している

表示した結果を、Python で同じ計算をした画像と比べる

Playwright で画像を選び、段階を変えるごとに、結果の canvas を PNG にして保存しました。保存した PNG を、Python で同じ対応表を使って計算した画像と1画素ずつ比べると、次のようになりました。

text
元の画像の色の数: 1021
段階 4 の対応表の値: [0, 85, 170, 255]
canvas-2.png: 正解と違う画素 0 / 240000, 不透明 True, 色の数 7
canvas-3.png: 正解と違う画素 0 / 240000, 不透明 True, 色の数 13
canvas-4.png: 正解と違う画素 0 / 240000, 不透明 True, 色の数 20
canvas-6.png: 正解と違う画素 0 / 240000, 不透明 True, 色の数 29
canvas-8.png: 正解と違う画素 0 / 240000, 不透明 True, 色の数 40
download-4.png と canvas-4.png: 違う画素 0, 差の最大 0

どの段階でも、Python で計算した画像と違う画素は 0 でした。画面に表示した色の数(7、13、20、29、40)も、Python で数えた数と同じです。

表示するだけなら、SVG のフィルターでも同じ色にできる

画像を保存する必要がなく、画面に表示するだけでよいなら、canvas を使わずに SVG のフィルターでも色を減らせます。<feComponentTransfer> の中の <feFuncR>・<feFuncG>・<feFuncB> に type="discrete" を指定すると、値を段階ごとに置き換えられます。仕様の discrete の式では、0〜1 の値を tableValues の個数と同じ数の区間に分け、k 番目の区間を k 番目の値に置き換えます。これは、先ほどの makeTable と同じ分け方です。

tsx
import { useId } from 'react';

type Props = { src: string; levels: number; alt: string; linear?: boolean };

// SVG のフィルターで、表示するときだけ色の段階を減らす(画素の値は取り出さない)
export function SvgPosterize({ src, levels, alt, linear = false }: Props) {
  const id = useId();
  // 段階が 4 なら "0 0.3333333333333333 0.6666666666666666 1"
  const table = Array.from({ length: levels }, (_, k) => String(k / (levels - 1))).join(' ');
  return (
    <>
      <svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden="true">
        <filter id={id} colorInterpolationFilters={linear ? 'linearRGB' : 'sRGB'}>
          <feComponentTransfer>
            <feFuncR type="discrete" tableValues={table} />
            <feFuncG type="discrete" tableValues={table} />
            <feFuncB type="discrete" tableValues={table} />
          </feComponentTransfer>
        </filter>
      </svg>
      <img src={src} alt={alt} style={{ filter: `url(#${id})` }} />
    </>
  );
}

フィルターの id は useId で作り、<img> の CSS の filter に url() で指定しています。同じページにこのコンポーネントを複数置いても、id が重なりません。検証では、filter の値は url("#_r_0_") のようになり、フィルターがかかりました。

このコンポーネントで表示した画像を等倍でスクリーンショットし、canvas で計算した結果と比べました。

text
svg-4-srgb.png: (600, 400) canvas の結果(正解)と違う画素 0 / 240000, 差の最大 0, 色の数 20
svg-2-srgb.png: (600, 400) canvas の結果(正解)と違う画素 0 / 240000, 差の最大 0, 色の数 7
svg-8-srgb.png: (600, 400) canvas の結果(正解)と違う画素 186778 / 240000, 差の最大 1, 色の数 40

2段階と4段階では、全画素が canvas の結果と同じでした。8段階では、色の数は同じ 40 色でしたが、canvas の結果と値が 1 だけ違う画素がありました。灰色の帯で値を見ると、値が変わる位置は canvas と同じで、canvas で 73・146・219 になる区間が、SVG のフィルターでは 72・145・218 でした。

text
canvas-8 の灰色の帯(x と値): 0:0 74:36 150:73 225:109 300:146 375:182 450:219 526:255
svg-8-srgb の灰色の帯(x と値): 0:0 74:36 150:72 225:109 300:145 375:182 450:218 526:255

tableValues に書く値も、結果に関わります。4段階の値を小数4桁に丸めて 0 0.3333 0.6667 1 と書くと、canvas で 85 になる区間が 84 になりました。そのため、コンポーネントでは k / (levels - 1) を丸めずに文字列にしています。

text
svg-4-srgb-4digits の灰色の帯(x と値): 0:0 150:84 300:170 450:255

SVG のフィルターは、表示される見た目を変えるだけです。色を減らした画像をファイルとして保存したいときは、canvas で計算する書き方を使います。

color-interpolation-filters を指定しないと、色の分かれ目がずれる

<filter> に color-interpolation-filters="sRGB" を指定しているのは、この属性の初期値が linearRGB だからです。<feComponentTransfer> も、指定がなければ linearRGB の色空間で値を扱います。linearRGB のまま4段階にすると、canvas の結果とはまったく違う画像になりました。

text
svg-4-linear.png: (600, 400) canvas の結果(正解)と違う画素 231120 / 240000, 差の最大 170, 色の数 17
上に canvas で計算した4段階の結果、下に color-interpolation-filters を linearRGB のままにした SVG フィルターの結果。下は、空のいちばん上の4分の1ほどが濃い青に、地面の左の3分の1ほどが黒になり、灰色の帯は左半分以上が黒で、右に灰色・薄い灰色・白が短く続く。太陽の外側の輪は赤くなっている
canvas で計算した4段階の結果(上)と、linearRGB のままの SVG フィルターの結果(下)

灰色の帯を見ると違いがはっきりします。canvas の結果では、横幅 600px の帯が 150px ずつの4区画に分かれました。linearRGB のままでは、黒の区画が左端から 321px 分続き、そのあとの値は 156・213・255 でした。暗い側の区画が黒にまとまります。

text
canvas-4 の灰色の帯(x と値): 0:0 150:85 300:170 450:255
svg-4-linear の灰色の帯(x と値): 0:0 321:156 441:213 528:255

画像ファイルの R・G・B の値をそのまま等しく分けたいなら、color-interpolation-filters="sRGB" を指定します。React の JSX では、colorInterpolationFilters と書きます。

​Posted by 86