0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

画像の差分比較をブラウザだけで実装する — Canvas getImageDataでピクセル単位のdiffを可視化する

0
Posted at

3行まとめ

  • 2枚の画像をピクセル単位で比較して、差分箇所を赤くハイライトするツールをブラウザ完結で作った。ライブラリ不使用、Canvas API の getImageData() だけで実装している
  • 差分判定は RGB 各チャンネルの差の合計としきい値の比較。しきい値スライダーで「JPEG 圧縮ノイズは無視して、意味のある変更だけ拾う」を調整できる
  • 見せ方が肝で、差分ピクセルは赤、一致ピクセルは半透明グレースケールに落とす。背景が退いて変更箇所だけが浮かび上がる

デザイン修正の前後でスクリーンショットを見比べて「どこが変わった?」をやる場面は多い。ビジュアルリグレッションのチェック、デザインレビュー、契約書 PDF の改訂確認。目視の間違い探しは限界があるので、機械にピクセル単位で比較させたい。

ぱんだツールズの画像差分比較ツールは、2枚の画像をドロップすると差分箇所を赤くハイライトした差分マップと、差分ピクセル数・差分率を表示する。処理はすべてブラウザ内で完結するので、社外に出せないデザインカンプや図面も安心して比較できる。

実装は Canvas API の getImageData() を軸にした素朴なピクセル比較だが、「サイズが違う画像をどう扱うか」「しきい値をどう設計するか」「差分をどう見せるか」に設計ポイントがある。順に解説する。

画像ファイルからピクセル配列を取り出す

比較の前段として、File オブジェクトから RGBA のピクセル配列(Uint8ClampedArray)を取り出す。定番の Canvas 経由ルートで、<img> に読み込んで等倍で Canvas に描き、getImageData() で吸い出す。

async function readImageData(file: File): Promise<{ imageData: ImageData; width: number; height: number }> {
  return new Promise((resolve, reject) => {
    const url = URL.createObjectURL(file)
    const img = new Image()
    img.onload = () => {
      const canvas = document.createElement('canvas')
      canvas.width = img.naturalWidth
      canvas.height = img.naturalHeight
      const ctx = canvas.getContext('2d')
      ctx.drawImage(img, 0, 0)
      resolve({ imageData: ctx.getImageData(0, 0, canvas.width, canvas.height), ... })
      URL.revokeObjectURL(url)
    }
    img.onerror = () => {
      URL.revokeObjectURL(url)
      reject(new Error('画像を読み込めませんでした'))
    }
    img.src = url
  })
}

ポイントは2つ。

  • naturalWidth / naturalHeight を使う。表示サイズではなく画像の実寸で Canvas を作らないと、ブラウザの拡縮が混入して比較にならない
  • URL.revokeObjectURL() を成功・失敗の両パスで呼ぶ。Object URL は明示的に解放しない限りメモリに残る。画像を差し替えるツールでは忘れると確実にリークしていく

この関数を Promise.all で2枚並行に走らせて、両方のピクセル配列が揃ってから比較に進む。

デコードはブラウザに任せているので、対応形式は「ブラウザが表示できるもの全部」(JPEG / PNG / WebP / GIF / BMP / SVG …)になる。フォーマットごとのデコーダを書かなくていいのが Canvas 経由の一番の利点だ。

比較ロジックは純関数に切り出す

差分計算の本体は UI から切り離して、src/lib/image/computeImageDiff.ts の純関数にしてある。入力はピクセル配列2組とサイズとしきい値、出力は差分統計と描画用の RGBA 配列。DOM にも Canvas にも依存しないのでテストが書きやすい。

export function computeImageDiff(input: ImageDiffInput): ImageDiffOutput {
  const width = Math.max(widthA, widthB)
  const height = Math.max(heightA, heightB)
  const out = new Uint8ClampedArray(width * height * 4)
  let diffPixels = 0

  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const i = (y * width + x) * 4
      const inA = x < widthA && y < heightA
      const inB = x < widthB && y < heightB

      const [rA, gA, bA] = samplePixel(dataA, x, y, widthA, heightA)
      const [rB, gB, bB] = samplePixel(dataB, x, y, widthB, heightB)

      const isDiff = !inA || !inB ||
        Math.abs(rA - rB) + Math.abs(gA - gB) + Math.abs(bA - bB) > threshold * 3
      // ...
    }
  }
}

サイズ違いは「はみ出し=全部差分」で扱う

2枚の解像度が一致しない場合にどうするかは設計判断が要る。エラーにする・小さい方に切り詰める・大きい方に合わせる、の選択肢のうち、このツールは大きい方に合わせて比較し、片方にしか存在しない領域はすべて差分扱いにした。

キャンバスサイズを Math.max で取り、各ピクセルで「A の範囲内か」「B の範囲内か」を判定(inA / inB)。どちらかが範囲外なら色を見るまでもなく差分(!inA || !inB)になる。「After で下に要素が追加されて縦に伸びた」ようなケースで、追加領域がまるごと赤く出るのは直感に合う挙動だと思う。

しきい値は「1チャンネルあたりの許容差」

差分判定の式はシンプルで、RGB 各チャンネルの差の絶対値の合計を threshold * 3 と比較する。

Math.abs(rA - rB) + Math.abs(gA - gB) + Math.abs(bA - bB) > threshold * 3

* 3 は3チャンネル分という意味で、スライダーの値(0〜100)が**「1チャンネルあたり平均で何段階の色差まで同一とみなすか」**になるように設計している。ユーザーに見せる数字を「合計 300 のうちの…」ではなく「チャンネルあたり」にスケールしておくと、感覚と合いやすい。

しきい値が必要な理由は JPEG にある。JPEG は非可逆圧縮なので、見た目が同一でも保存し直すだけでピクセル値が微妙に揺れる。しきい値 0 で比較すると画面全体が差分まみれになる。スクリーンショット比較なら 10〜20、印刷物のチェックなら 5 以下、というのがツール上の推奨値にしてある。

なお比較に使うのは RGB のみで、アルファチャンネルは見ていない。透過 PNG の透過度だけが変わったケースは拾えないが、スクリーンショットや写真の比較という主用途では問題にならない割り切りだ。

差分の見せ方 — 赤 vs 半透明グレースケール

差分マップの視認性を決めるのが「一致した部分をどう描くか」。差分だけ赤くして残りを原色のまま出すと、元画像がカラフルな場合に赤が埋もれる。そこで一致ピクセルはグレースケール化 + 半透明に落とす。

if (isDiff) {
  diffPixels++
  out[i] = 255; out[i + 1] = 30; out[i + 2] = 30; out[i + 3] = 255  // 赤・不透明
} else {
  const gray = Math.round(rA * 0.299 + gA * 0.587 + bA * 0.114)     // BT.601
  out[i] = gray; out[i + 1] = gray; out[i + 2] = gray; out[i + 3] = 200  // グレー・半透明
}

グレースケール変換は BT.601 の輝度係数(R×0.299 + G×0.587 + B×0.114)。単純平均でなく輝度係数を使うと、人間の目の感度に沿った自然な濃淡になる。彩度と不透明度を落とした背景の上に不透明な赤(255, 30, 30)が乗るので、差分が1ピクセル幅でも視認できる。

「元画像がうっすら見えている」ことも重要で、真っ黒背景に赤点だけ出すと「どこの差分なのか」が分からない。位置の文脈は残しつつ、注意は差分に集める——このバランスがグレースケール + アルファ 200 という組み合わせに落ち着いた理由になる。

出力の Uint8ClampedArrayputImageData() で Canvas に書き戻し、toDataURL('image/png') で画像化する。そのまま <img> で表示し、<a download> で PNG ダウンロードにも使い回せる。

const imageData = ctx.createImageData(width, height)
imageData.data.set(outputRgba)
ctx.putImageData(imageData, 0, 0)
return canvas.toDataURL('image/png')

pixelmatch がやっていて、このツールがやっていないこと

ビジュアルリグレッション界隈の定番ライブラリ pixelmatch は、YIQ 色空間での知覚的な色差計算や、アンチエイリアシング検出(フォントレンダリングの縁の1ピクセル揺れを差分から除外する)まで実装している。

このツールの実装はそこまでやらず、RGB 差の合計としきい値だけ。そのぶんアンチエイリアス由来の細かい縁が差分に出やすいが、しきい値スライダーを上げることでかなり吸収できるし、ロジックが「各チャンネルの差が N を超えたら差分」の一行で説明できる分かりやすさがある。CI に組み込んで自動判定するなら pixelmatch、目視確認のお供に差分を浮かび上がらせるなら単純比較でも十分、という住み分けだと考えている。

処理はメインスレッドの素朴な二重ループで、フル HD(約200万ピクセル)なら体感で待たされることはない。4K 超になると数秒かかることがあり、そのあたりが Worker 化を検討するラインになる。

まとめ

ブラウザ完結の画像差分比較で押さえるポイント。

  • ピクセル取得は <img> → Canvas 等倍描画 → getImageData()。デコードをブラウザに任せれば対応形式は勝手に広がる
  • サイズ違いは Math.max キャンバス + はみ出し全差分。切り捨てるより「追加された領域が赤く出る」方が直感に合う
  • しきい値は threshold * 3(チャンネルあたり換算)。JPEG 圧縮ノイズを無視するために必須のノブ
  • 見せ方は 差分=不透明の赤、一致=BT.601 グレースケール + アルファ 200。位置の文脈を残しつつ差分に注意を集める
  • 比較ロジックは Canvas 非依存の純関数に切り出すとテストできる

getImageData() でピクセルが触れるようになると、差分比較に限らず画像処理ツールは大体ブラウザだけで作れてしまう。サーバーにファイルを送らない設計は、実装が楽なだけでなく「機密画像も安心して使える」というユーザーメリットに直結する。

ぱんだツールズ では他にも画像圧縮・一括形式変換・モザイク・EXIF 削除など、画像系のブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?