SNS投稿前に顔やナンバープレートを隠したい、スクリーンショットの個人情報をぼかしたい——こうした場面は日常的にある。Photoshopを起動するほどでもないし、オンラインツールに画像をアップロードするのはプライバシーが気になる。
「ブラウザだけで、選択した領域にモザイクやぼかしを入れられるツール」を作った。Canvas APIを使い、画像は一切サーバーに送信しない設計にしている。
この記事では、ぱんだツールズの「部分モザイク・ぼかし処理」ツールの技術的な実装を解説する。Canvas APIでモザイク処理がどう動くのか、ドラッグ領域選択の座標変換はどうするのか、といった話を具体的なコード付きで書いていく。
Canvas APIでモザイクを作る原理:「縮小→拡大」
モザイク処理の本質はピクセレート——画像をブロック状にする処理だ。直感的には「各ブロックの平均色を計算して塗りつぶす」と思うかもしれないが、Canvas APIではもっとシンプルな方法が使える。
一時Canvasに小さく描画し、それを元のサイズに拡大する。 たったこれだけでモザイクが完成する。
function applyMosaicToRegion(
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
region: ImageRegion,
strength: number
) {
const blockSize = strength * 5
// 一時Canvasを作成(元の領域を blockSize 分の1に縮小)
const tempCanvas = document.createElement('canvas')
tempCanvas.width = Math.max(1, Math.floor(region.w / blockSize))
tempCanvas.height = Math.max(1, Math.floor(region.h / blockSize))
const tempCtx = tempCanvas.getContext('2d')!
// ① 元画像の対象領域を一時Canvasに縮小描画
tempCtx.imageSmoothingEnabled = false
tempCtx.drawImage(
img,
region.x, region.y, region.w, region.h, // ソース領域
0, 0, tempCanvas.width, tempCanvas.height // 縮小先
)
// ② 一時Canvasの内容を元のサイズに拡大描画
ctx.imageSmoothingEnabled = false
ctx.drawImage(
tempCanvas,
0, 0, tempCanvas.width, tempCanvas.height, // 縮小された画像
region.x, region.y, region.w, region.h // 元のサイズに拡大
)
ctx.imageSmoothingEnabled = true
}
ポイントは imageSmoothingEnabled = false の設定。これをオフにしないと、ブラウザが補間処理で滑らかにしてしまい、モザイク感が出ない。ニアレストネイバー補間(最近傍補間)にすることで、ピクセルの境界がくっきりしたブロック状の見た目になる。
強度(strength)は 1〜20 の範囲で、blockSize = strength * 5 で変換される。強度1なら5pxブロック、強度20なら100pxブロックだ。個人情報の隠蔽には強度10以上(50pxブロック)が推奨される。
ガウシアンブラーの実装
ぼかし(Gaussian blur)のほうは、Canvas 2D APIの filter プロパティを使う。
function applyBlurToRegion(
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
region: ImageRegion,
strength: number
) {
ctx.save()
// 対象領域をクリップパスで制限
ctx.beginPath()
ctx.rect(region.x, region.y, region.w, region.h)
ctx.clip()
// CSSフィルター構文でガウシアンブラーを適用
ctx.filter = `blur(${strength * 2}px)`
ctx.drawImage(img, 0, 0)
ctx.filter = 'none'
ctx.restore()
}
ctx.clip() で描画範囲を矩形に制限してから、ぼかしフィルター付きで画像全体を描画する。クリップ外には影響しないので、指定領域だけにぼかしがかかる。
ブラーの半径は strength * 2 ピクセル。強度5で10px、強度20で40pxのブラー半径になる。CSSの filter プロパティを使うことで、ピクセル単位の畳み込み演算を自前で実装する必要がない。
ドラッグによる領域選択と座標変換
部分モザイクツールの肝は「画像のどこにモザイクをかけるか」をユーザーがドラッグで指定する部分だ。ここで厄介なのが、CSSで表示されるサイズとCanvasの内部解像度が異なるという問題。
CSSピクセル→Canvasピクセル変換
Canvasは width="3000" height="2000" のような内部解像度を持つが、CSS上は width: 100% で表示している。ユーザーがクリックした座標(CSSピクセル)をCanvasの座標に変換する必要がある。
function getCanvasPoint(
e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>
): { x: number; y: number } {
const canvas = canvasRef.current!
const rect = canvas.getBoundingClientRect()
// CSS表示サイズとCanvas内部サイズの比率
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
// マウスまたはタッチの座標を取得
let clientX: number, clientY: number
if ('touches' in e) {
clientX = e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0
clientY = e.touches[0]?.clientY ?? e.changedTouches[0]?.clientY ?? 0
} else {
clientX = e.clientX
clientY = e.clientY
}
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY,
}
}
getBoundingClientRect() でCanvasのCSS上の位置とサイズを取得し、canvas.width / rect.width でスケール係数を計算する。Retinaディスプレイでもこの方式なら正しく動く。CSS表示サイズとCanvas内部解像度の比率で座標を変換しているため、表示サイズが変わっても座標が正しく対応する。
タッチイベントとマウスイベントの両方に対応するため、'touches' in e で分岐している。タッチ操作では e.preventDefault() を呼んでスクロールの誤動作を防いでいる。
複数領域の管理
選択領域は ImageRegion[] の配列で管理する。
interface ImageRegion {
x: number
y: number
w: number
h: number
}
ドラッグ完了時に3つのユーティリティ関数を通して領域を確定する。
function commitDrag(drag: DragState) {
// ① 開始・終了座標から正規化(右上→左下にドラッグしても正しく処理)
const normalized = normalizeRegion(drag.startX, drag.startY, drag.endX, drag.endY)
// ② Canvas境界にクランプ(はみ出し防止)
const clamped = clampRegion(normalized, canvas.width, canvas.height)
// ③ 最小サイズ(5px)チェック
if (isValidRegion(clamped)) {
setRegions((prev) => [...prev, clamped])
}
}
normalizeRegion は Math.min / Math.abs で開始・終了座標を正規化する。ユーザーがどの方向にドラッグしても x, y が左上、w, h が正の値になる。clampRegion はCanvas境界を超えた分を切り詰め、isValidRegion は幅・高さが5px未満の誤クリックを弾く。
これらは src/lib/image/regionUtils.ts に純粋関数として切り出されており、UIコンポーネントから独立してテスト可能な設計になっている。
リアルタイムプレビューの仕組み
ドラッグ中はリアルタイムで選択領域を可視化する必要がある。redrawCanvas 関数が、3つのレイヤーを重ねて描画する。
const redrawCanvas = useCallback((currentDrag?: DragState | null) => {
const ctx = canvas.getContext('2d')!
// レイヤー1: 元画像
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
// レイヤー2: 確定済み選択領域(青い半透明の矩形)
ctx.setLineDash([6, 4])
for (const region of regions) {
ctx.fillStyle = 'rgba(59, 130, 246, 0.25)'
ctx.strokeStyle = 'rgb(37, 99, 235)'
ctx.fillRect(region.x, region.y, region.w, region.h)
ctx.strokeRect(region.x, region.y, region.w, region.h)
}
// レイヤー3: ドラッグ中の一時領域(赤い半透明の矩形)
if (currentDrag) {
const r = normalizeRegion(currentDrag.startX, currentDrag.startY, currentDrag.endX, currentDrag.endY)
ctx.fillStyle = 'rgba(220, 38, 38, 0.2)'
ctx.strokeStyle = 'rgb(220, 38, 38)'
ctx.fillRect(r.x, r.y, r.w, r.h)
ctx.strokeRect(r.x, r.y, r.w, r.h)
}
}, [regions])
確定済み領域は青、ドラッグ中は赤で表示する。破線ボーダー(setLineDash([6, 4]))で「選択中」であることを視覚的に伝えている。
onMouseMove や onTouchMove のたびにこの関数が呼ばれるが、Canvas 2D APIの描画は十分高速なので、通常の画像サイズであればラグは感じない。
設計上の工夫
ブラウザ完結のプライバシー保護
このツール最大の特徴は、画像がサーバーに送信されないこと。処理フローは以下のとおり。
-
URL.createObjectURL(file)でローカルファイルのURLを生成 -
HTMLImageElementに読み込み - Canvas APIで描画・加工
-
canvas.toBlob()でPNG形式のBlobを生成 -
URL.createObjectURL(blob)でダウンロードURLを生成
すべてブラウザのメモリ内で完結する。個人情報を含むスクリーンショットや、顔写真を扱う場面でも、外部にデータが出ることはない。ネットワーク通信はゼロだ。
外部ライブラリ不要
モザイク・ぼかし処理に外部ライブラリは使っていない。Canvas 2D APIの標準機能だけで実装している。package.json の依存に画像処理専用ライブラリ(OpenCV.js等)は含まれておらず、バンドルサイズへの影響はほぼゼロ。
メモリ管理
処理中に生成される ObjectURL は、不要になったタイミングで URL.revokeObjectURL() を呼んで解放している。コンポーネントのアンマウント時にもクリーンアップが走る。画像処理ツールはメモリリークしやすいポイントなので、ここは意識的にケアしている。
まとめ
- Canvas APIのモザイク処理は「一時Canvasに縮小→
imageSmoothingEnabled = falseで拡大」というシンプルな原理で実現できる - ガウシアンブラーは
ctx.filter+ctx.clip()で領域限定のぼかしをかけられる - ドラッグの座標変換は
getBoundingClientRect()でCSS表示サイズとCanvas内部サイズの比率を計算するのがポイント - 領域の正規化・クランプ・バリデーションを純粋関数に切り出すことで、テスタビリティと可読性を両立できる
- 外部ライブラリなし・サーバー通信なしで、プライバシーに配慮した画像処理ツールが作れる
ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理などの開発者向けツールを公開中。すべて無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。