Reactで、2枚の画像の違う部分を色で示す方法

​​React​Canvas API,Playwright,TypeScript

2枚の画像の違う部分を色で示すには、canvas で2枚の画素の値を取り出し、1画素ずつ R・G・B の差を調べます。差が決めた値(しきい値)を超えた画素をピンクで塗り、それ以外の画素は元の画像を薄い灰色にして描けば、どこが変わったのかが一目で分かる画像になります。

ここでは、2枚を比べて結果の画像を作る diffImageData、選んだファイルから画素の値を取り出す loadImageData、2枚の画像を選んでしきい値を動かせる App を作りました。しきい値を上げると、小さな色の変化は見えなくなります。また、JPEG で保存した画像と比べると、変えていない部分の画素まで「違う」と判定されます。どちらも、実際に数えた結果で説明します。

補足

検証環境は 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 を動かして2枚の画像を選び、表示された結果を、Python(Pillow 10.2.0)で同じ計算をした画像と1画素ずつ比べました。画像は写真ではなく、Pillow で描いた 600×400 の不透明な画像です。Firefox と Safari、スマートフォンのブラウザ、透明な部分を含む画像、写真のような大きな画像での処理の速さは試していません。

検証には、次の2枚を使いました。変更後の画像は、変更前の画像から4か所だけを変えています。値段の「1,280円」を「1,480円」にし、丸を右へ 6px ずらし、右下に 3×3px の黒い点を足しました。左の四角は、色を (0, 200, 232) から (0, 200, 212) に変えています。青の値が 20 違うだけなので、並べて見ても違いはほとんど分かりません。

上に変更前の画像、下に変更後の画像。どちらも薄い水色の地に、「見本の商品」と値段の入った枠、左に水色の四角、右に黄色の丸、中央下に横線の束がある。変更後は値段が 1,480円 になり、丸が少し右にずれ、右下に小さな黒い点がある
検証に使った変更前(before.png)と変更後(after.png)の画像

選んだファイルから、画素の値を取り出す

画像のファイルから画素の値を取り出すには、createImageBitmap() で読み込んだ画像を canvas に描き、getImageData() で取り出します。この canvas は画面には出さず、値を取り出すためだけに使います。

ts
// 選んだファイルを読み込み、画素の値を取り出す
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つの値が順に並んでいます。読み込みが終わった ImageBitmap は、close() で解放しています。

1画素ずつ比べて、違う画素をピンクで塗る

比べる処理では、2枚の data を4つずつ進みながら、R・G・B それぞれの差の大きさを求め、そのうち最も大きいものをその画素の差にします。差がしきい値を超えたらピンク、超えなければ、変更前の画像を白に近づけた薄い灰色にします。結果は、new ImageData() で作った同じ大きさの ImageData に書き込みます。

ts
export type DiffResult = { image: ImageData; changed: number };

// 違う画素を塗る色(R, G, B)
const MARK = [216, 88, 120];

// 2枚の画像を1画素ずつ比べる。R・G・B の差のうち最も大きいものが threshold を超えた画素をピンクに塗り、
// それ以外の画素は、変更前の画像の明るさを薄くした灰色にする
export function diffImageData(before: ImageData, after: ImageData, threshold: number): DiffResult {
  if (before.width !== after.width || before.height !== after.height) {
    throw new Error('2枚の画像の大きさが違います');
  }
  const a = before.data;
  const b = after.data;
  const image = new ImageData(before.width, before.height);
  const out = image.data;
  let changed = 0;
  for (let i = 0; i < a.length; i += 4) {
    const d = Math.max(Math.abs(a[i] - b[i]), Math.abs(a[i + 1] - b[i + 1]), Math.abs(a[i + 2] - b[i + 2]));
    if (d > threshold) {
      out[i] = MARK[0];
      out[i + 1] = MARK[1];
      out[i + 2] = MARK[2];
      changed++;
    } else {
      // 明るさ(0〜255)を求め、白に近づけて薄く見せる
      const lum = 0.299 * a[i] + 0.587 * a[i + 1] + 0.114 * a[i + 2];
      const v = Math.round(255 - (255 - lum) * 0.25);
      out[i] = out[i + 1] = out[i + 2] = v;
    }
    out[i + 3] = 255;
  }
  return { image, changed };
}

同じ部分を真っ白にせず薄い灰色で残しているのは、ピンクの画素が画像のどこにあるのかを分かるようにするためです。灰色の濃さは、R・G・B から求めた明るさを、白に近づけて決めています。2枚の大きさが違うときは、同じ位置の画素どうしを比べられないので、比べずにエラーにします。

しきい値と違う画素の数を表示するコンポーネント

App は、2枚の ImageData としきい値を state に持ち、比べた結果を useMemo で計算します。画像としきい値が変わらないかぎり、再レンダーのたびに全画素を比べ直すことはありません。結果の画像は、useEffect の中で putImageData() を使って canvas に描きます。

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

export function App() {
  const [before, setBefore] = useState<ImageData | null>(null);
  const [after, setAfter] = useState<ImageData | null>(null);
  const [threshold, setThreshold] = useState(0);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  // 2枚がそろい、大きさが同じときだけ比べる
  const sameSize = before && after && before.width === after.width && before.height === after.height;
  const result = useMemo(
    () => (before && after && sameSize ? diffImageData(before, after, threshold) : null),
    [before, after, sameSize, threshold],
  );

  // 比べた結果を canvas に描く
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!result || !canvas) return;
    canvas.width = result.image.width;
    canvas.height = result.image.height;
    canvas.getContext('2d')?.putImageData(result.image, 0, 0);
  }, [result]);

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

  const total = result ? result.image.width * result.image.height : 0;

  return (
    <main>
      <label className="pick">
        変更前の画像
        <input type="file" accept="image/*" onChange={pick(setBefore)} />
      </label>
      <label className="pick">
        変更後の画像
        <input type="file" accept="image/*" onChange={pick(setAfter)} />
      </label>
      <label className="threshold">
        しきい値:{threshold}
        <input type="range" min={0} max={64} step={8} value={threshold} onChange={(e) => setThreshold(Number(e.target.value))} />
      </label>
      {before && after && !sameSize && (
        <p className="error" role="alert">
          画像の大きさが違います({before.width}×{before.height} と {after.width}×{after.height})
        </p>
      )}
      {result && (
        <>
          <p className="info">
            違う画素:{result.changed.toLocaleString()} 個(全体の {((result.changed / total) * 100).toFixed(2)}%)
          </p>
          <canvas ref={canvasRef} className="result" role="img" aria-label="違う部分をピンクで示した画像" />
        </>
      )}
    </main>
  );
}

2枚の大きさが違うときは、比べずに「画像の大きさが違います(600×400 と 601×401)」のように両方の大きさを表示します。検証では、600×400 の画像と 601×401 の画像を選ぶと、この表示が出て、結果の 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, .threshold { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 14px; }
.info { font-size: 14px; margin: 0 0 8px; }
.error { color: #d85878; font-size: 14px; }
.result { display: block; max-width: 100%; height: auto; border: 1px solid #9aa5ab; }

次の GIF は、変更前と変更後の画像を選んだあと、しきい値を 0 から 16、16 から 24 に上げたときの様子です。つまみは 8 刻みなので、1回目の操作では途中で 8 を通ります。しきい値が 0 と 16 のときは四角全体がピンクですが、24 にすると四角はピンクでなくなり、違う画素の数は 42,831 個から 2,417 個に減りました。

比べる画像は before.png と after.png。しきい値 0 では、値段の数字、丸の左右の細い部分、右下の点、左の四角全体がピンクになっている。しきい値のつまみを動かし、8 を通って 16 にすると違う画素は 42,831 個になり、四角はピンクのまま。24 に動かすと 2,417 個になり、四角だけが灰色に変わる
しきい値を 0 から 16、16 から 24 に上げる様子。変わった数字は、その瞬間だけピンクにしている。カーソル、操作の表示、比べる画像のファイル名は GIF 用に重ねたもので、ファイルの入力欄は GIF では隠している

丸は、ずらす前と後で重ならない左右の細い部分だけがピンクになりました。塗りの色が同じなので、重なっている部分の画素は変わっていないためです。

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

Playwright で2枚の画像を選び、しきい値を変えるごとに、結果の canvas を PNG にして保存しました。次の出力は、保存した PNG を、Python で同じ計算をした画像と1画素ずつ比べ、ピンクの画素を変更した4か所とそれ以外に分けて数えた結果です。

text
after-png-t0.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 372, 丸 2088, 黒い点 9, 四角の色 40401, それ以外 0
after-png-t16.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 333, 丸 2088, 黒い点 9, 四角の色 40401, それ以外 0
after-png-t24.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 320, 丸 2088, 黒い点 9, 四角の色 0, それ以外 0
after-png-t64.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 289, 丸 2088, 黒い点 9, 四角の色 0, それ以外 0

どのしきい値でも、Python で計算した画像と違う画素は 0 でした。ピンクの画素は変更した4か所の範囲の中だけにあり、それ以外の場所には1つもありませんでした。同じ画像どうしを比べたときは、違う画素は 0 個でした。

text
before-png-t0.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 0, 丸 0, 黒い点 0, 四角の色 0, それ以外 0

しきい値を上げると、小さな色の変化が見えなくなる

変更前と変更後の画像で、変更した4か所の差を調べると、次のようになりました。差は、R・G・B の差のうち最も大きいものです。

text
値段の文字: 違う画素 372、差の最小 1、最大 249
丸: 違う画素 2088、差の最小 173、最大 224
黒い点: 違う画素 9、差の最小 253、最大 253
四角の色: 違う画素 40401、差の最小 20、最大 20

四角の色の差は、どの画素も 20 です。そのため、しきい値が 16 までは四角全体の 40,401 画素がピンクになり、24 では1つもピンクになりませんでした。しきい値は「この値を超えたら違うとみなす」値なので、差がしきい値以下の色の変化は、結果の画像に出ません。

値段の文字は、差が 1 しかない画素から 249 の画素までありました。しきい値を 0、16、24、64 と上げると、ピンクの画素は 372、333、320、289 と減りました。GIF のとおり、しきい値 24 でも「2」と「4」の形はピンクで残っています。丸と黒い点は差が 173 以上あるので、しきい値を 64 にしても、すべての画素がピンクのままでした。

PNG どうしのように、変えていない画素の値がまったく同じ画像を比べるなら、しきい値は 0 にしておけば、どんなに小さな色の変化も見落としません。

JPEG と比べると、変えていない部分まで違う画素になる

変更後の画像を JPEG(品質 85)で保存し、変更前の PNG と比べると、しきい値 0 では全体の 94.98% の画素がピンクになりました。変えていない地の色の部分まで、ほとんどピンクです。JPEG は、画像のデータの一部を捨てて容量を小さくする(非可逆の)圧縮の形式です。変更後の PNG と、それを保存した JPEG を比べると、240,000 画素のうち 227,956 画素の値が違っていて、差は最大で 92 ありました。

text
after.png と after.jpg: 違う画素 227956 、差の最大 92 、差が 64 を超える画素 33

しきい値を上げていくと、変えていない部分のピンクは減ります。ただし、四角の色の変化も同時に消えていきます。

text
after-jpg-t0.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 10562, 丸 16375, 黒い点 9, 四角の色 40401, それ以外 160616
after-jpg-t8.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 3068, 丸 3223, 黒い点 9, 四角の色 40399, それ以外 9233
after-jpg-t16.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 1936, 丸 2477, 黒い点 9, 四角の色 40286, それ以外 5815
after-jpg-t24.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 1051, 丸 2335, 黒い点 9, 四角の色 1200, それ以外 3187
after-jpg-t32.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 543, 丸 2268, 黒い点 9, 四角の色 804, それ以外 1386
after-jpg-t48.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 325, 丸 2154, 黒い点 9, 四角の色 16, それ以外 399
after-jpg-t64.png: 600x400 正解と違う画素 0 / 240000, 不透明 True | ピンクの画素 値段の文字 292, 丸 2100, 黒い点 9, 四角の色 4, それ以外 1
JPEG と比べた結果を、しきい値 0、32、64 で縦に並べた図。しきい値 0 では、画像のほぼ全体がピンクで、灰色で残るのは文字や枠の線の一部だけ。32 では、値段の数字、丸の輪郭、右下の点に加えて、文字や枠や四角の輪郭に細いピンクの線や点が残る。64 では、値段の数字、丸の左右、右下の点だけがピンクで、四角は灰色になっている
変更前の PNG と変更後の JPEG を、しきい値 0・32・64 で比べた結果

しきい値 32 では、変更していない場所のピンクは 1,386 画素に減りましたが、四角でピンクになったのは輪郭の 804 画素だけで、四角全体の色の変化は見えなくなりました。64 では、変更していない場所のピンクは 1 画素だけになりましたが、四角でピンクの画素は 4 個でした。この組み合わせでは、JPEG で保存したことで変わった画素だけを消し、四角の色の変化を残すしきい値はありませんでした。

小さな色の変化まで見つけたいときは、2枚とも PNG のような可逆圧縮の形式で保存した画像を、しきい値 0 で比べます。JPEG を比べるしかないときは、しきい値を上げると、その値以下の色の変化は結果に出なくなることを承知したうえで、値を決めてください。

​Posted by 86