Reactで、画像の一部にモザイクをかける方法

​​React​Canvas API,Playwright,TypeScript

画像の一部にモザイクをかけるには、canvas から範囲の画素を getImageData() で取り出し、決めた大きさのマスごとに中の画素の平均の色で塗りつぶして、putImageData() で書き戻します。モザイクにした画素は canvas そのものに書き込まれるので、保存した画像にも元の画素は残りません。

ここでは、範囲をモザイクにする pixelate、ドラッグの位置を画像の座標に直す toCanvasPoint、画像の上をドラッグして範囲を足していく App を作りました。気をつけたいのは、canvas を CSS で縮めて表示しているときのドラッグの位置です。画面上の座標をそのまま使うと、モザイクがドラッグした場所からずれます。

補足

検証環境は 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 で描いた 601×401 の不透明な PNG です。Firefox と Safari、スマートフォンのブラウザとタッチ操作、透明な部分を含む画像、JPEG での保存は試していません。

名札の部分と縞の部分をドラッグすると、それぞれの範囲がモザイクになる。マスの大きさを 8px にするとモザイクが細かくなり、「保存する」を押すと sample-mosaic.png がダウンロードされる
2か所をドラッグしてモザイクにし、マスの大きさを変えて保存する様子。変わった表示とファイル名は、その瞬間だけピンクにしている。カーソル、操作の表示、ダウンロードしたファイル名は GIF 用に重ねたもの。名札の番号のピンクは見本の画像の色

マスごとに、中の画素の平均の色で塗る

モザイクの処理は、範囲を size × size のマスに分け、マスごとに平均の色を求めて、マスの中をその色で塗るだけです。getImageData() で取り出した data には、左上の画素から行ごとに、1画素につき R・G・B・A の4つの値が並んでいます。範囲の中の (xx, yy) の画素は、(yy * w + xx) * 4 番目から始まります。

ts
export type Point = { x: number; y: number };
export type Rect = { x: number; y: number; w: number; h: number };

// 範囲の中を size × size のマスに分け、マスごとに中の画素の平均の色で塗りつぶす
export function pixelate(ctx: CanvasRenderingContext2D, { x, y, w, h }: Rect, size: number) {
  if (w === 0 || h === 0) return;
  const image = ctx.getImageData(x, y, w, h);
  const d = image.data; // 1画素につき R, G, B, A の4つの値が並んでいる
  for (let by = 0; by < h; by += size) {
    for (let bx = 0; bx < w; bx += size) {
      // 範囲の右端と下端では、マスが size より小さくなる
      const bw = Math.min(size, w - bx);
      const bh = Math.min(size, h - by);
      const sum = [0, 0, 0, 0];
      for (let yy = by; yy < by + bh; yy++) {
        for (let xx = bx; xx < bx + bw; xx++) {
          const i = (yy * w + xx) * 4;
          for (let c = 0; c < 4; c++) sum[c] += d[i + c];
        }
      }
      const avg = sum.map((s) => Math.round(s / (bw * bh)));
      for (let yy = by; yy < by + bh; yy++) {
        for (let xx = bx; xx < bx + bw; xx++) {
          const i = (yy * w + xx) * 4;
          for (let c = 0; c < 4; c++) d[i + c] = avg[c];
        }
      }
    }
  }
  ctx.putImageData(image, x, y);
}

範囲の幅や高さがマスの大きさで割り切れないときは、右端と下端のマスを小さくしています。最後に putImageData() で、取り出したのと同じ位置に書き戻します。

範囲は配列で持ち、変わるたびに元の画像から描き直す

モザイクをかけた範囲は、Rect の配列として state に持たせます。範囲が増えたときやマスの大きさを変えたときは、canvas に元の画像を描き直してから、すべての範囲を順にモザイクにします。描き直す関数は次のとおりです。

ts
// 元の画像を描き直してから、範囲を順にモザイクにする
export function render(canvas: HTMLCanvasElement, image: ImageBitmap, rects: Rect[], size: number) {
  canvas.width = image.width;
  canvas.height = image.height;
  const ctx = canvas.getContext('2d', { willReadFrequently: true });
  if (!ctx) throw new Error('canvas が使えません');
  ctx.drawImage(image, 0, 0);
  for (const r of rects) pixelate(ctx, r, size);
}

モザイクにした canvas の上に重ねてモザイクをかけていくと、マスの大きさを変えたり、かけた範囲を取り消したりできません。元の画像から描き直す形にしておくと、「1つ戻す」は配列の最後を取り除くだけで済みます。getContext() の willReadFrequently は、getImageData() で何度も読み出すことをブラウザに伝える指定です。

ドラッグの位置は、表示の大きさから画像の座標に直す

canvas の画素の数(width・height 属性)と、画面に表示される大きさは別です。検証では、幅 601px の画像を幅 420px の画面に表示したところ、canvas は CSS の max-width: 100% で縮められて 387.98×258.88px で表示されました。そこで、getBoundingClientRect() で canvas の表示位置と大きさを取り、ポインターの位置から canvas の左上の位置を引きます。それに、画素の数を表示の大きさで割った比をかけて、画像の座標に直します。

ts
// 画面上のポインターの位置を、canvas の画素の座標に直す(CSS で縮めて表示していても合うようにする)
export function toCanvasPoint(canvas: HTMLCanvasElement, clientX: number, clientY: number): Point {
  const box = canvas.getBoundingClientRect();
  return {
    x: ((clientX - box.left) * canvas.width) / box.width,
    y: ((clientY - box.top) * canvas.height) / box.height,
  };
}

// ドラッグの始点と終点から、画像の内側に収まる長方形を作る(右下から左上へのドラッグでも同じ形になる)
export function rectFromPoints(a: Point, b: Point, width: number, height: number): Rect {
  const clamp = (v: number, max: number) => Math.min(Math.max(Math.round(v), 0), max);
  const x1 = clamp(Math.min(a.x, b.x), width);
  const y1 = clamp(Math.min(a.y, b.y), height);
  const x2 = clamp(Math.max(a.x, b.x), width);
  const y2 = clamp(Math.max(a.y, b.y), height);
  return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
}

rectFromPoints は、始点と終点の小さいほうを左上にし、画像の外に出た分は画像の端までに切り詰めます。

比べるために、画像の座標で (40, 40) から (320, 160) にあたる場所をドラッグし、画面上の座標をそのまま使った場合の範囲を計算しました。

text
同じドラッグで、画面の座標をそのまま使ったときの範囲: {"x":26,"y":26,"w":181,"h":77}
text
naive.png(画面の座標をそのまま使った): 元と色が違う画素 11297、それが収まる範囲 x 26〜206, y 26〜102

ドラッグしたのは幅 280px・高さ 120px の範囲ですが、モザイクは左上に寄った、幅 181px・高さ 77px の範囲にかかりました。表示が 601px から約 388px に縮んだ割合で、範囲も縮んでいます。toCanvasPoint で直した場合は、ドラッグ中に表示する枠も、モザイクの範囲も (40, 40) から幅 280・高さ 120 になりました。

画像の上をドラッグして範囲を足すコンポーネント

App では、選んだファイルを createImageBitmap() で読み込み、canvas の上でのドラッグを Pointer Events で受け取ります。ボタンを押したときに setPointerCapture() を呼んでおくと、ポインターが canvas の外へ出ても、離すまでのイベントが canvas に届きます。

tsx
import { useEffect, useRef, useState } from 'react';
import type { ChangeEvent, PointerEvent } from 'react';
import { render, rectFromPoints, toCanvasPoint } from './mosaic';
import type { Point, Rect } from './mosaic';

type Loaded = { bitmap: ImageBitmap; name: string };

export function App() {
  const [image, setImage] = useState<Loaded | null>(null);
  const [rects, setRects] = useState<Rect[]>([]);
  const [size, setSize] = useState(16);
  const [drag, setDrag] = useState<{ start: Point; now: Point } | null>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  // 範囲やマスの大きさが変わるたびに、元の画像から描き直す
  useEffect(() => {
    if (image && canvasRef.current) render(canvasRef.current, image.bitmap, rects, size);
  }, [image, rects, size]);

  async function handleFile(e: ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    const bitmap = await createImageBitmap(file);
    image?.bitmap.close();
    setRects([]);
    setImage({ bitmap, name: file.name });
  }

  function handlePointerDown(e: PointerEvent<HTMLCanvasElement>) {
    // canvas の外へ出てもドラッグを続けられるように、ポインターを捕まえておく
    e.currentTarget.setPointerCapture(e.pointerId);
    const p = toCanvasPoint(e.currentTarget, e.clientX, e.clientY);
    setDrag({ start: p, now: p });
  }

  function handlePointerMove(e: PointerEvent<HTMLCanvasElement>) {
    if (!drag) return;
    setDrag({ ...drag, now: toCanvasPoint(e.currentTarget, e.clientX, e.clientY) });
  }

  function handlePointerUp(e: PointerEvent<HTMLCanvasElement>) {
    if (!drag || !image) return;
    const end = toCanvasPoint(e.currentTarget, e.clientX, e.clientY);
    const r = rectFromPoints(drag.start, end, image.bitmap.width, image.bitmap.height);
    if (r.w > 0 && r.h > 0) setRects([...rects, r]);
    setDrag(null);
  }

  function handleSave() {
    const canvas = canvasRef.current;
    if (!image || !canvas) return;
    canvas.toBlob((blob) => {
      if (!blob) return;
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `${image.name.replace(/\.[^.]+$/, '')}-mosaic.png`;
      a.click();
      URL.revokeObjectURL(url);
    }, 'image/png');
  }

  // ドラッグ中の枠は canvas には描かず、上に重ねた要素で見せる(保存する画像に枠が入らないように)
  const selection = drag && image && rectFromPoints(drag.start, drag.now, image.bitmap.width, image.bitmap.height);

  return (
    <main>
      <input type="file" accept="image/*" aria-label="画像を選ぶ" onChange={handleFile} />
      {image && (
        <>
          <div className="stage">
            <canvas
              ref={canvasRef}
              className="preview"
              role="img"
              aria-label="モザイクをかけた画像のプレビュー"
              onPointerDown={handlePointerDown}
              onPointerMove={handlePointerMove}
              onPointerUp={handlePointerUp}
            />
            {selection && (
              <div
                className="selection"
                style={{
                  left: `${(selection.x / image.bitmap.width) * 100}%`,
                  top: `${(selection.y / image.bitmap.height) * 100}%`,
                  width: `${(selection.w / image.bitmap.width) * 100}%`,
                  height: `${(selection.h / image.bitmap.height) * 100}%`,
                }}
              />
            )}
          </div>
          <label className="size">
            マスの大きさ:{size}px
            <input type="range" min={4} max={40} step={4} value={size} onChange={(e) => setSize(Number(e.target.value))} />
          </label>
          <p className="info">モザイクの範囲:{rects.length}か所</p>
          <div className="toolbar">
            <button type="button" disabled={rects.length === 0} onClick={() => setRects(rects.slice(0, -1))}>1つ戻す</button>
            <button type="button" className="save" onClick={handleSave}>保存する</button>
          </div>
        </>
      )}
    </main>
  );
}

ドラッグ中の枠は、canvas に線を描くのではなく、canvas の上に重ねた div で見せています。保存するのは canvas の中の画像なので、canvas に線を描くと、その線も保存する画像に入ります。枠の位置は画像の座標を割合(%)にして指定しています。CSS で縮めて表示した状態でも、枠は画像の (40, 40) から幅 280・高さ 120 の位置に重なりました。保存は、canvas を toBlob() で PNG にし、download 属性を付けたリンクをクリックしてダウンロードさせます。

canvas と枠には、次のCSSを当てています。touch-action: none は、タッチ操作のドラッグでページがスクロールしないようにする指定です。

css
/* 枠を canvas の上に重ねるため、canvas を囲む要素を基準にする */
.stage { position: relative; display: inline-block; max-width: 100%; margin-top: 12px; line-height: 0; }
.preview { display: block; max-width: 100%; height: auto; touch-action: none; cursor: crosshair; }
.selection { position: absolute; border: 2px dashed #067a98; box-sizing: border-box; pointer-events: none; }

保存した画像を、Python で同じ計算をした画像と比べる

Playwright で、次の3つのドラッグをしてから保存しました。名札の部分を左上から右下へ、縞の部分を右下から左上へ、円の中から canvas の右下の外(右へ 30px、下へ 40px)までです。そのあと、マスの大きさを 8px にして保存し、「1つ戻す」を押してもう一度保存しました。保存したファイルを、Python で同じ範囲・同じマスの大きさで平均の色を計算した画像と比べた結果です。

text
範囲1(名札) (40, 40, 280, 120) / 範囲2(縞) (380, 40, 190, 120) / 範囲3(円から右下の外まで) (130, 260, 471, 141)
size16.png: PNG 601x401 RGBA | 正解(範囲3か所、マス 16px)と比べて 色が違う画素 0 / 241001
size8.png: PNG 601x401 RGBA | 正解(範囲3か所、マス 8px)と比べて 色が違う画素 0 / 241001
size8-undo.png: PNG 601x401 RGBA | 正解(範囲2か所、マス 8px)と比べて 色が違う画素 0 / 241001
size16.png で、3つの範囲の外にある画素のうち元と色が違うもの: 0

3つのファイルとも、色が違う画素は0でした。右下から左上へのドラッグも、左上から右下へのドラッグと同じ形の範囲になり、canvas の外まで引っぱった範囲は画像の右下の端までで止まりました。範囲の外の画素は元のままで、枠の線も入っていませんでした。動かさずにクリックしただけのときは、幅が0になるので範囲は増えませんでした。

マスを小さくすると、隠したい文字が読めてしまうことがあります。マスの大きさを 16px と 8px にして保存した画像で、名札の部分を見比べました。16px では灰色とピンクの四角が並ぶだけで、文字は読めませんでした。8px では、大きさ 44px の文字で書いた「見本 太郎」と「012-3456」が、ほぼ読めました。文字を隠すためにモザイクをかけるときは、マスの大きさを決めたあと、保存した画像で文字が読めないことを確かめてください。

縮めて引き伸ばす書き方では、マスの色が平均にならない

モザイクは、範囲を小さな canvas に縮めて描き、それを imageSmoothingEnabled を false にして元の大きさに引き伸ばす書き方でも作れます。画素を1つずつ足す処理を書かずに済む書き方です。

ts
// 比べるための書き方:範囲を小さな canvas に縮めて描き、それを元の大きさに引き伸ばして描き戻す
import type { Rect } from './mosaic';

export function pixelateByScaling(ctx: CanvasRenderingContext2D, { x, y, w, h }: Rect, size: number, smoothShrink: boolean) {
  const small = document.createElement('canvas');
  small.width = Math.ceil(w / size);
  small.height = Math.ceil(h / size);
  const sctx = small.getContext('2d')!;
  sctx.imageSmoothingEnabled = smoothShrink;
  sctx.drawImage(ctx.canvas, x, y, w, h, 0, 0, small.width, small.height);
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(small, 0, 0, small.width, small.height, x, y, w, h);
  ctx.imageSmoothingEnabled = true;
}

名札と、1画素おきに白と黒が並ぶ縞を含む範囲(541×141px)を、マス 16px で3つの書き方でモザイクにして比べました。縮めるときのスムージングは、あり(imageSmoothingEnabled が true)となしの両方を試しています。

上から、平均の色で塗った場合、縮めて引き伸ばした場合(縮めるときのスムージングあり)、縮めて引き伸ばした場合(スムージングなし)。平均の色で塗ると、名札の文字はぼやけた灰色やピンクになり、縞は灰色1色になる。縮めて引き伸ばすと、名札の枠が消えて文字の一部がはっきりした色のまま残り、縞はスムージングありでは濃さの違う灰色の帯が並び、なしでは白い地に黒い帯が1本だけ残る
同じ範囲を、マス 16px で3つの書き方でモザイクにした結果

次の出力の「実際のマスの区切り」は、引き伸ばしたあとのマスの区切りのことです(このあとの段落で説明します)。「参考」の行の「平均の色からのずれ」は、平均の色で塗った画像と1画素ずつ比べたときの、色の差の最大です。

text
scaled-smooth.png: 実際のマスの区切りで数えると、マス 306 個のうち、中の色が1色にそろっていないマス 0 | 実際のマスごとの、元の画素の平均とマスの色の差の最大 175.2(0〜255)
scaled-smooth.png: (参考:平均の色で塗った画像と1画素ずつ比べた場合)平均の色からのずれの最大 224(0〜255) | 縞の部分の色: [(127, 127, 127), (159, 159, 159), (175, 175, 175), (191, 191, 191)] ほか(平均で塗った場合: [(128, 128, 128), (136, 137, 137)])
scaled-nosmooth.png: 実際のマスの区切りで数えると、マス 306 個のうち、中の色が1色にそろっていないマス 0 | 実際のマスごとの、元の画素の平均とマスの色の差の最大 175.2(0〜255)
scaled-nosmooth.png: (参考:平均の色で塗った画像と1画素ずつ比べた場合)平均の色からのずれの最大 237(0〜255) | 縞の部分の色: [(0, 0, 0), (255, 255, 255)](平均で塗った場合: [(128, 128, 128), (136, 137, 137)])
scaled-smooth.png: 範囲の中の色 23 種類のうち、元の範囲の中にもある色 13 種類
scaled-nosmooth.png: 範囲の中の色 13 種類のうち、元の範囲の中にもある色 13 種類

平均の色で塗った場合、縞は白と黒の平均にあたる灰色 (128, 128, 128) になりました。縮めて引き伸ばした場合は、どちらもマスの色が平均の色から大きくずれ、スムージングなしでは縞が白と黒だけになりました。スムージングなしの場合、モザイクにした範囲の 13 色は、すべて元の範囲の中にある色そのままでした。元の範囲にない色は、1つもありませんでした。スムージングありでも、23 色のうち 13 色は元の範囲にある色でした。図のとおり、どちらの書き方でも名札の枠が消え、文字の一部が元のはっきりした色のまま残りました。

縮めた canvas は横 34 マス・縦 9 マス(Math.ceil(541 / 16)・Math.ceil(141 / 16))なので、引き伸ばしたマスの大きさは 16px ちょうどにはなりません。Chromium で実際の区切りを調べると、横 34 マスのうち 3 マスは幅 15px、残りは 16px でした。縦も、9 マスのうち 3 マスは高さ 15px でした。この区切りで数えると、どちらの書き方でも、306 個のマスはすべて中が1色でした。ただし、その色は、元の画像でそのマスにあった画素の平均と、最大で 175.2(0〜255)違っていました。マスの色を平均にしたいときは、画素を足して平均を求める pixelate の書き方が確実です。

​Posted by 86