顔の見逃しを減らしたい。YOLO11n(物体検出用の軽量なAIモデル)をTensorFlow.js(ブラウザ上で機械学習を実行するライブラリ)で走らせて、検出履歴まで含めたUI(ユーザーインターフェース)を総点検した。重複判定の閾値が甘く履歴が過剰に増えたため、閾値計算とログ体験を両方やり直した。
対象読者
- ブラウザだけで高性能な顔検出パイプラインを組みたいフロントエンドエンジニア
- IndexedDB(ブラウザ内のデータベース)に推論結果を積んで運用する方法を探している方
- Webカメラと画面共有を切り替えたいが、UI(ユーザーインターフェース)の状態管理に悩んでいるデザイナー兼実装者
記事に書いてあること
- YOLO11n(物体検出用の軽量なAIモデル)をTensorFlow.js(ブラウザ上で機械学習を実行するライブラリ)で動かす際の推論間隔(66ms/125ms/250ms)と負荷バランス
- 類似度スコアを0.4:0.3:0.3で重み付けして検出履歴を重複保存しない方法
- Webカメラ・URL動画・画面共有の三段切り替えをどの順番で初期化するか
前提知識メモ
- TensorFlow.js(ブラウザ上で機械学習を実行するライブラリ)のGraphModel(グラフモデル)をブラウザで扱った経験(推論中のメモリ管理運用まで)
- Canvas API(HTML5の描画機能)でのラスタ書き込みと、roundRect(角丸矩形)を使ったマスク描画
- IndexedDB(ブラウザ内のデータベース)でObjectStore(オブジェクトストア)を操作した経験(キーを自動増分にする想定)
今回の仕上がり
WebカメラからYOLO11n(物体検出用の軽量なAIモデル)に直接流し込み、HTML側でハイライトを描画しながら、顔ごとの切り抜きをIndexedDB(ブラウザ内のデータベース)に保存する構成に落ち着いた。大きな変更は統計ダッシュボードと重複判定、それにログの表現だ。動画はヘッダーに自動配置されるので、ここではデモを一気に置く。
この仕組みが何をしているのか、ざっくり整理しておく。YOLO11nはカメラ映像から顔の場所をリアルタイムで描き出すモデルだ。TensorFlow.js(ブラウザ上で機械学習を実行するライブラリ)ならブラウザで完結する。検出した顔をそのまま保存するのではなく、似たカットを弾きながら履歴として積む。つまり「同じ顔が連続で並んで眩暈がする」状態を避けたい。UI(ユーザーインターフェース)もセットで整えることで、普段の配信や記録がぐっと扱いやすくなる。
一般的な利用シナリオを3つ想定した。配信中に誰がフレームインしているかを可視化したい人。安全カメラまでは要らないが、来客の履歴を残しておきたい人。画面共有とカメラを頻繁に切り替える講師。どれもブラウザだけで完結できるよう、設定の手数を出来る限り減らした。
統計ダッシュボードで掴んだこと
推論の粒度は数字で管理しないとすぐ感覚で迷う。ダッシュボードを作って平均信頼度、FPS、処理時間を全てログに流した。連続で枠を送ると、fastモードが15fpsに貼りつくのを見て安心した。逆に連続8フレームを投げると平均FPが11.2まで落ち込んだ瞬間があった。意外でした。そこで統計更新関数の粒度を改めて確認した。
// midori226/index.html 抜粋(367-409行)
function updateStatistics(detections, processingTime) {
document.getElementById('detectionCount').textContent = detections.length;
if (detections.length > 0) {
const avgConf = detections.reduce((sum, d) => sum + (d.confidence || 0), 0) / detections.length;
document.getElementById('avgConfidence').textContent = (avgConf * 100).toFixed(1) + '%';
} else {
document.getElementById('avgConfidence').textContent = '-';
}
detectionStats.frameCount++;
const now = Date.now();
const elapsed = (now - detectionStats.lastUpdateTime) / 1000;
if (elapsed >= 1.0) {
const fps = detectionStats.frameCount / elapsed;
document.getElementById('fpsCounter').textContent = fps.toFixed(1);
detectionStats.frameCount = 0;
detectionStats.lastUpdateTime = now;
}
if (processingTime) {
document.getElementById('processingTime').textContent = processingTime.toFixed(0) + 'ms';
}
}
この仕組みをデモで回しっぱなしにしたところ、標準モードでは1秒あたりの推論数が7.9〜8.1で安定した。処理時間は平均94msで上下したので、Fast/Accurateの切り替えCTAをユーザー側にも出す判断をした。
デモを全画面で開く(統計ループの再計測)
類似判定を強化した理由
履歴をIndexedDB(ブラウザ内のデータベース)に保存するとき、類似のフレームを弾く計算が想定が甘かった。以前はIoU(重なり具合の指標)しか見ていなくて、連続するショットがすべて履歴に乗ってしまった。今回の改修では面積比と色ヒストグラムを混ぜた0.4:0.3:0.3の重みを導入した。色の比重を0.25に落とすテストもしたが、IoU 0.68 / 色0.82 / 面積0.76で合計0.746になり、便利なシーンでも保存されてしまう。この設定では保存対象が広がりすぎるため、既存の重みを維持した。
// midori226/libs/prediction-component.js 抜粋(455-470行)
const weights = {
iou: 0.4,
color: 0.3,
size: 0.3
};
const combinedSimilarity =
weights.iou * maxIoU +
weights.color * colorSimilarity +
weights.size * sizeSimilarity;
return {
isSimilar: combinedSimilarity > this.similarityThreshold.combined,
scores: {
iou: maxIoU,
color: colorSimilarity,
size: sizeSimilarity,
combined: combinedSimilarity
}
};
デモではIoU(重なり具合の指標)0.74 / 色0.86 / 面積0.79で合計0.791になり保存をブロックできることを確認した。逆にIoU 0.68 / 色0.82 / 面積0.76の組み合わせなら0.746となり保存に回る。これで履歴カードを1/3に削減できた。
オーバーレイの描画を磨く
バウンディングボックスを角丸にして、グローを付け、ラベルを置く。順序が狂うと、テキストがぼやけたり影が二重になったりする。CanvasにroundRectが使えるので、描画シーケンスを固定化した。
デモで段階的に描画を追うと、ラベル背景→テキスト→元に戻す流れがUI全体で統一される。以前はhover状態でshadowBlurを戻し忘れてラベルが霞んだことがあった。この順番なら再現性が高い。
検出履歴が読みやすい仕組み
履歴カードは顔の周辺が切れないようにパディングを20px足している。以前は余白ゼロで切り取っていたので、フレームの端ギリギリに写った顔が欠けた。意外でした。実際には20pxでも足りない場合があったが、テストでは上下左右で30pxに増やすと背景を取りすぎた。20pxが妥協点になった。
// midori226/libs/detection-history.js 抜粋(475-506行)
const padding = 20;
const adjustedY1 = Math.max(0, y1 - padding);
const adjustedX1 = Math.max(0, x1 - padding);
const adjustedY2 = Math.min(img.height, y2 + padding);
const adjustedX2 = Math.min(img.width, x2 + padding);
const width = adjustedX2 - adjustedX1;
const height = adjustedY2 - adjustedY1;
このパディング処理を入れたあとは、履歴タイムラインに置いた顔サムネイルに背景情報が残るので判断がしやすくなった。
誰かがフレームの端に一瞬だけ映ったときでも、頬や耳がちゃんと残っている。イベントの集合写真で「この場面をもう少し広く見たい」と思う瞬間が減った。細かいけれど、見返す人のストレスが大きく変わる。
処理モードの数字を追う
セレクタにFast/Normal/Accurateを置いただけではユーザーが違いを理解しにくかった。数字で伝えるために推論間隔と負荷目安をその場で計算して見せる。Fastは66msで推論が走り、標準は125ms、Accuracyは250ms。中間の8fpsが最もバランスが良かった。旧バージョンではHighモードを設けて18fpsにしたが、ブラウザが悲鳴を上げた。
Fastで回してもfpsが13〜15に落ち着くので、通常はNormalに誘導するポップを出した。他の記事でも同じ表現を使いたいのでテンプレ化した。
ソース切り替えの設計
ウェブカメラ、URL、画面共有の切り替えは見た目以上に状態が多い。以前はURLタブに移った瞬間に画面共有のストリームが残留してしまった。性能劣化の原因になった。今回の実装ではタブをクリックするだけでdisplayを統一し、画面共有タブに入ったときだけgetDisplayMediaを呼ぶ順番にした。
Webカメラ→画面共有→URL→画面共有に戻る動きでも、ストリームの停止ログが確実に走っているのを確認した。旧バージョンで停止イベントを書き忘れていたため、配信が二重に走ってCPUが跳ね上がった教訓をここで活かした。
ログで心をほぐす
トップバーのシステムログは単なるテキストだった。エラーを見逃さないように、アイコン・色・発光アニメーションを追加した。遊び心のデモでは、エラー時だけオレンジのグローが走る。
ログを飾りすぎるとノイズになるが、成功・警告・失敗ごとにサウンドを分ける案も試した結果、音は外した。視線を奪いすぎるからだ。
フロー全体を整理する
最後にパイプラインを図でまとめた。Webカメラ→モデル→描画→保存までを一望すると、どのステップでバグが起きても追いやすい。
prediction-componentを中心に据えたのは、他の記事(例えば midori225: YOLOで顔を試す)と統一したかったから。今回の高性能版はモデルを差し替えただけでなく、パイプライン全体を整理し直している。
実際に試したシナリオ
- Webカメラを1280×720に固定し、Normalモードで10分稼働。平均信頼度は72.4%、履歴保存は41件。
- YouTubeを画面共有に切り替え、Accuracyモードで5分間実験。Combinedスコア0.78を超えたフレームは除外された。
- 旧モデル(midori222)と比較して、Fastモードの平均FPSが13→15に回復。ログに成功アイコンが連続した。
使ってみて
実際のUIをいじると、この高性能版の温度感がつかめるはずです。
デモを全画面で開く(処理モードの挙動)
押さえたいポイントは3つ。
- Fast/Normal/Accurateで推論間隔が66ms→125ms→250msになる
- 類似度スコアが0.75を超えると履歴の保存が止まる
- 画面共有の停止イベントでストリームを必ず解放する
数字とログを抱き合わせにしたので、テストの指標を揃えやすいはず。同じような構成で悩んでいる方のヒントになれば嬉しい。
振り返りメモ
- 統計ダッシュボードでfps(1秒間のフレーム数)と処理時間を常時表示し、Fast/Normal/Accurateの違いを明確化
- IoU(重なり具合の指標)・色ヒストグラム・面積の重みを0.4:0.3:0.3で固定し、Combinedが0.75超なら保存スキップ
- 履歴の切り抜きに20pxのパディングを入れて顔の周囲コンテキストを確保
- Webカメラと画面共有を切り替える際にはストリーム停止を先に処理してメモリリークを防止
もっと掘り下げるなら
感謝のひとこと
最後まで読んでくださり、本当にありがとうございました。ログや履歴まわりの工夫がどこかで役立ちますように。