集合写真をSNSに上げる前、通行人や同意のない他人の顔を一括でモザイクにしたい。これを 画像をクラウドに送らず、ブラウザ内のAIだけで完結 させようとすると、ライブラリ選定とモデル配布で工夫が要る。
ぱんだツールズに「顔自動モザイク」を作ったので、@vladmandic/face-api の TinyFaceDetector(約200KB)でブラウザ完結の顔検出を回し、Canvas でモザイク・ぼかし・黒塗りを適用するまでの実装と、ハマったところをまとめる。
なぜ「ブラウザ完結」にこだわるか
顔検出系のクラウドAPI(Google Cloud Vision、AWS Rekognition等)は精度が高い。一方で 画像をアップロードする時点で「サードパーティに人の顔写真を送っている」 ことになる。プライバシー保護目的のツールが、そのために他社のサーバーに画像を送らせる設計はそもそも本末転倒。
ブラウザ内 AI なら以下が成立する:
- 画像が端末から外に出ない
- モデルファイルすら自サイトから配信すれば、Google/AWS への問い合わせも発生しない
- ユーザーが「サーバーにアップロードしてない」をネットワークタブで自分で確認できる
精度はクラウド型に劣る。ただ「絶対送らない保証」が立つなら、その差を埋めて余りある価値がある。
ライブラリ選定:vladmandic 版 face-api.js
face-api.js 系で選択肢が2つある:
| パッケージ | 状態 |
|---|---|
face-api.js(justadudewhohacks 版) |
元祖。メンテ停止状態(最終更新2020年) |
@vladmandic/face-api(vladmandic 版) |
fork して活発にメンテ。TF.js v4対応済み、TypeScript 型完備 |
face-api.js は今でもサンプルが多いが、TensorFlow.js 新バージョンとの相性問題が出るので、新規で組むなら @vladmandic/face-api 一択。
モデルファイルは public/ に置いて self-host する
face-api.js は内部で TensorFlow.js を使い、訓練済みモデルファイル(重みファイル + manifest)をネットワーク経由でロードする。デフォルト挙動だと どこかのCDNから取りにいくので、これを自サイトから配信するように差し替える。
public/
models/
face-api/
tiny_face_detector_model-weights_manifest.json
tiny_face_detector_model.bin
このファイルは vladmandic/face-api リポジトリの model/ ディレクトリからコピーしてくる。tiny_face_detector_model.bin の実体が約189KB、manifest 込みで約192KB。本記事ではざっくり「約200KB」と表記している。
ロード時は URI を指定:
const mod = await import('@vladmandic/face-api')
await mod.nets.tinyFaceDetector.loadFromUri('/models/face-api')
これで 外部CDNに一切問い合わせず、自サイト内で完結する。Cloudflare Pages 配信なので CDN キャッシュも勝手に効く。初回のみ約200KBダウンロード、以降はブラウザキャッシュから即起動。
dynamic import で SSR を避ける
face-api.js は内部で window や navigator を参照するため、Next.js の SSR でトップレベル import するとビルドが通らない。必ず dynamic import で 'use client' 内から呼ぶ:
'use client'
const faceApiRef = useRef<typeof import('@vladmandic/face-api') | null>(null)
const loadModel = useCallback(async () => {
if (faceApiRef.current) return faceApiRef.current
const mod = await import('@vladmandic/face-api')
await mod.nets.tinyFaceDetector.loadFromUri('/models/face-api')
faceApiRef.current = mod
return mod
}, [])
useRef でモジュール参照をキャッシュしておくと、2回目以降の検出時にロードをスキップできる。
ただし型情報の import は別。type だけは静的にOK:
// 型のみ import(バンドルに含まれない)
interface FaceDetectionLike {
box: { x: number; y: number; width: number; height: number }
}
face-api.js 自体の型をモジュールから引っ張ろうとすると SSR 時に評価が走るので、最低限の型定義を手動で書くのが無難だった。
検出処理:detectAllFaces で複数顔を一気に取る
検出は detectAllFaces 一発:
const options = new mod.TinyFaceDetectorOptions({
inputSize: 416,
scoreThreshold: 0.5,
})
const detections = await mod.detectAllFaces(img, options) as unknown as FaceDetectionLike[]
オプションの意味:
-
inputSize: 推論時の入力解像度。224 / 320 / 416 / 512 / 608 から選ぶ(32の倍数)。大きいほど精度↑、速度↓。416 がデフォルトでバランス良い -
scoreThreshold: 顔として認識する確信度の閾値。0.5 で「半信半疑以上」。下げると検出漏れが減る代わりに誤検出(背景の模様を顔と判断する)が増える
返ってくる detections[i].box が 画像座標系の矩形 { x, y, width, height }。これがそのまま Canvas での描画座標に使える。
検出した矩形をそのまま使うと耳・髪が覆えない
ここが地味にハマったポイント。TinyFaceDetector が返す矩形は「目・鼻・口」を中心とした顔のコア部分で、耳や髪、あご下まではカバーしてくれない。そのまま矩形にモザイクを掛けると、髪や耳から本人特定情報がはみ出す。
解決策として、矩形を一定割合だけ拡張する関数を入れている:
export function expandRegion(
region: ImageRegion,
ratio: number,
maxWidth: number,
maxHeight: number,
): ImageRegion {
const padX = region.w * ratio
const padY = region.h * ratio
const x = Math.max(0, Math.floor(region.x - padX))
const y = Math.max(0, Math.floor(region.y - padY))
const right = Math.min(maxWidth, Math.ceil(region.x + region.w + padX))
const bottom = Math.min(maxHeight, Math.ceil(region.y + region.h + padY))
return { x, y, w: right - x, h: bottom - y }
}
ratio 0.2(20%)をデフォルトにしている。耳・髪・あごまで確実に隠したいときは 30〜50% に増やせるよう UI でスライダー化した。Math.max(0, ...) と Math.min(maxWidth, ...) で 画像境界からはみ出さないクランプ を入れるのが地味に重要。
検出した矩形にどうエフェクトを適用するか
検出結果の矩形に対して、モザイク・ぼかし・黒塗りの3種類を選択できるようにしている。
それぞれの実装パターン(縮小→拡大の擬似ピクセレート、ImageDataベースの自前 gaussianBlur、fillRect 黒塗り)は別記事「モザイクとぼかしの違いを実装で理解する」で詳しく書いているので、本記事では face-api.js の検出矩形と組み合わせる部分だけ要点をまとめる。
// 検出ループ → エフェクト適用
for (const det of detections) {
const region = boxToRegion(det, canvas.width, canvas.height) // 矩形拡張済み
if (effect === 'mosaic') applyMosaicToRegion(ctx, img, region, strength)
else if (effect === 'blur') applyBlurToRegion(ctx, region, strength)
else applyBlackoutToRegion(ctx, region)
}
face-api.js 固有のポイントは以下:
-
ctx.filter = 'blur(...)'は使わない: Safari 18 未満が未対応のため、ImageDataベースの自前 gaussianBlur(box blur 3回近似)をsrc/lib/image/gaussianBlur.tsに切り出して使っている - 黒塗りは「絶対に復元させたくない」用: モザイクは強度が低いと AI で復元される可能性がゼロではないので、法的・倫理的に強めの隠蔽が必要なら黒塗りを選ばせる
-
強度パラメータは検出した矩形サイズに対してスケール: 顔の大きさが画像内でバラついても、
strength * 5(モザイクのブロック)やstrength * 2(ぼかしの半径)が矩形相対で効くようにしている
検出漏れへの逃げ道を必ず用意する
TinyFaceDetector はあくまで約200KBの軽量モデル。以下のケースは そもそも検出できない:
- 真横のプロフィール
- マスク・サングラスで大部分隠れた顔
- 低解像度・ぼけ・暗い画像
- 集合写真の奥に小さく写った顔
「自動」を売りにしておきながら検出漏れがあると、ユーザーは詰む。なので 検出0件のときに別ツール(手動範囲指定)への導線を必ず出す ようにしている(以下は実装からの抜粋。実際は detectedCount を useState<number | null>(null) で扱っており、初期状態と処理中を弾くため detectedCount !== null && detectedCount === 0 && !isProcessing の条件を付けている):
{detectedCount !== null && detectedCount === 0 && !isProcessing && (
<div className="...">
顔が検出されませんでした。
手動で範囲指定したい場合は <Link href="/tools/image-partial-blur">部分モザイク・ぼかし</Link> へ
</div>
)}
「自動検出 → 漏れたら手動」という二段構えで、ユーザーが詰まないように設計するのが体験として大事だった。
モバイル端末のパフォーマンス
実機(iPhone 13)で 4000px 級の写真を流すと、推論に5〜10秒、Canvas処理に2〜3秒かかる。「処理中…」表示なしだとフリーズしたように見えるので、ボタンの状態と進捗ラベルは欠かさない。
事前リサイズを案内するのも有効:
高解像度画像(4000px超)は推論に時間がかかります。
画像リサイズで 2000px 程度に縮小してからのご利用を推奨。
ちなみに inputSize: 416 を 320 に下げれば推論は速くなるが、奥に映る小さい顔の検出率が落ちるので痛し痒し。デフォルト 416 のままにして、ユーザーには事前リサイズで対応してもらう設計にしている。
まとめ
face-api.js(vladmandic 版)+ Canvas でブラウザ完結の顔自動モザイクは、約200KBのモデル1個ロードするだけで動く。クラウドAPI 並みの精度は出ないが、「画像を絶対サーバーに送らない」「外部CDNにすら問い合わせない」設計が成立する。
押さえたポイント:
-
@vladmandic/face-apiを使う(元祖はメンテ停止) - モデルは public/ に self-host、loadFromUri で読み込む
- dynamic import + useRef キャッシュ で SSR 回避と再ロード防止
- 検出矩形の拡張(耳・髪・あごをカバー)
- ぼかしは ImageData 自前実装(Safari 18 未満互換)
- 検出漏れは手動指定ツールへの導線で逃がす
「ブラウザ完結 AI」のユースケースとしては、顔検出は分かりやすく価値が出る部類。テキスト系(OCR・要約)はクラウドの方が精度が圧倒的だが、画像系は軽量モデルでも実用に届く。
ぱんだツールズ ではこの他にも 部分モザイク・画像ぼかし・PDF処理・CSV処理などブラウザ完結ツールを80本以上公開している。全部無料・登録不要・サーバー送信なし。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。