重複を嫌った。人物だけを拾う。YOLOv11nをブラウザで走らせて、展示現場で手放し運用したい話です。
01.mp4の動画も用意しました。冒頭で雰囲気を掴んでから読み進めてください。
対象読者
- Webカメラベースの人物検出をブラウザ単体で安定させたいフロントエンジニア
- TensorFlow.js(ブラウザ上で機械学習を実行するライブラリ)の推論をUI(ユーザーインターフェース)連携込みでチューニングしたい方
- 既存の
midori223を引き継ぎつつ重複検出や履歴管理を強化したい制作者
記事に書いてあること
- YOLOv11n(物体検出用の軽量なAIモデル)ベースの人物検出で重複を抑制する三段構えの類似度計算
- IndexedDB(ブラウザ内のデータベース)を5秒周期で自動同期させる履歴管理の実装と検証
- 映像ソース切り替え(Webカメラ / URL / 画面共有)を安定させる対策と失敗談
- Three.js(3Dグラフィックスライブラリ)背景と処理速度プリセットを組み合わせたUI(ユーザーインターフェース)/UX(ユーザー体験)の仕立て直し
前提知識
- TensorFlow.js(ブラウザ上で機械学習を実行するライブラリ): ブラウザでYOLOv11nモデル(物体検出用の軽量なAIモデル)を推論するための基盤
- IndexedDB(ブラウザ内のデータベース): 検出結果を永続化し、履歴カードを描画するローカルDB
- Three.js(3Dグラフィックスライブラリ): UI(ユーザーインターフェース)背景を支えるWebGL(Web Graphics Library、ブラウザで3D描画を行う技術)レイヤー。
midori228の改良版設定を流用
改良したかった理由と全体像
前作midori223は高速だったものの、同じ人物を連続で保存してしまい履歴が膨らみました。展示で使うなら重複はノイズ。だから判定ロジックを作り直しました。
updateDetectionStatistics()で検出数・平均信頼度・FPS・処理時間を秒間モニタリングしつつ、保存対象かどうかを逐次判断しています。
// libs/prediction-component.js(抜粋)
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 = '-';
}
// FPSと処理時間の更新は省略
}
重複検出を潰すための似度評価
失敗。最初はIoU(重なり具合の指標)だけで判定したら、同じ人物を角度違いで拾ってしまいました。これはマズい。
現在はIoU・色ヒストグラム・検出領域サイズの三項目を0.4 / 0.3 / 0.3で合成し、0.75を越えたら保存をスキップします。
// libs/prediction-component.js(抜粋)
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 }
};
実際にログへ流した数値はこうです。demo2-actual-similarity-gate.htmlでも再現できます。
| IoU | 色ヒストグラム | サイズ比 | 合成スコア | 判定 |
| --- | --- | --- | --- | --- |
| 0.38 | 0.82 | 0.71 | 0.611 | 保存 |
| 0.52 | 0.77 | 0.66 | 0.637 | 保存 |
| 0.21 | 0.64 | 0.58 | 0.450 | 保存 |
しきい値を0.75に上げた結果、連続保存は止まりました。ヨシ。類似検出をスキップしたときだけコンソールに類似の検出結果のため、保存をスキップしましたが出ます。
意外だ。色ヒストグラムを32×32/8ビンに落としただけでも効果が大きい。demo3-code-explain-histogram.htmlでビン配分を触ってみてください。
IndexedDB履歴の耐久テスト
履歴ビューはdetection-historyカスタム要素で再構築しました。5秒ごとにloadDetections()が走り、detection-savedイベントを受け取るとページングをリセットします。
20pxのパディングを周囲に足して人物を切り抜く処理も省略せず実装。下ではIndexedDB読み出し部分を抜粋しています。
// libs/detection-history.js(抜粋)
const transaction = this.db.transaction(['detections'], 'readonly');
const store = transaction.objectStore('detections');
const index = store.index('timestamp');
this.detections = await new Promise((resolve, reject) => {
const request = index.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
this.detections.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
demo4-code-explain-history-refresh.htmlでは、同期→重複スキップ→ページ描画を時系列で追えます。
履歴をJSONとして保存・読み込みできるのも展示現場で助かります。store.add(detectionData)に失敗したときは警告を出しつつ処理継続するようにしています。
映像ソース切り替えの泥臭い対策
Webカメラ・URL・画面共有をタブで切り替える構成です。navigator.mediaDevices.getDisplayMedia()はAbortErrorをよく吐くので、ensureProcessingVideoPlaying()で3回までリトライする仕組みを入れました。
// libs/prediction-component.js(抜粋)
async ensureProcessingVideoPlaying(retryCount = 0) {
if (!this.processingVideoElement || !this.processingVideoElement.srcObject) return;
try {
await this.processingVideoElement.play();
} catch (error) {
if (error && error.name === 'AbortError' && retryCount < 3) {
setTimeout(() => this.ensureProcessingVideoPlaying(retryCount + 1), 100);
}
}
}
demo6-interactive-source-switcher.htmlでタブの切り替えとログ出力を再現しています。
getDisplayMediaのtrack.endedイベントでstopScreenCapture()を呼び、UI表示とストリームを即座に片付けるよう調整しました。
GPU背景とUIの整備
Three.js側はsetupDeviceOptimization()でデバイスごとにFOVやpixelRatioを出し分けています。midori228で試した設定を再利用しつつ、OrbitControlsのtouchDampingFactorを0.1に固定しました。
処理速度プリセットはfast(15fps)、normal(8fps)、accurate(4fps)の3段構え。demo5-interactive-processing-mode.htmlで数値を確認できます。
| モード | 目標FPS | 1フレーム目標時間 | 推奨閾値 |
| --- | --- | --- | --- |
| 高速 | 15fps | 約66ms | 45% |
| 標準 | 8fps | 約125ms | 50% |
| 高精度 | 4fps | 約250ms | 60% |
midori224では標準モードを初期値にしていますが、夜間の監視では高精度モードに切り替えると誤検出が減ることを現地で確認しました。demo7-playful-system-log.htmlではログUIの雰囲気もチェックできます。
旧構成との比較は../midori223/index.html、Annote連携版は../midori225/index.htmlにも記録しています。
VideoStreamと解像度切り替えで得たもの
WebカメラはVideoStreamクラスで抽象化しています。デフォルトの640×480から始め、選択肢を1280x720と1920x1080に絞りました。
changeResolution()を呼ぶと現在のトラックを停止し、同じdeviceIdで再接続します。ストリームが存在しないときは早期リターンする防御も入れました。
// libs/video-stream.js(抜粋)
async changeResolution(newResolution) {
if (!this.stream) {
console.error('ストリームが存在しません。解像度を変更できません。');
return;
}
const deviceId = this.stream.getVideoTracks()[0].getSettings().deviceId;
this.stop();
this.resolution = newResolution;
return await this.start(deviceId);
}
1280×720に切り替えるとfpsは二桁付近で推移し、1920×1080ではgetSettings().frameRateが一桁まで落ち込む機材もありました。demo5で推論モードを変えた後にresolutionSelectを切り替えると処理時間カードの変化が見えます。
webcam-componentはwebcam-readyイベントでprediction-componentへ渡すので、解像度再接続後もupdateDetectionStatistics()が止まりません。展示会中の「誰かがフルHDにして止まった」トラブルをここで塞ぎました。
使ってみて
実際に手で触ると実装の粒度がつかめます。
ポイントは以下の3つです。
- 類似度0.75超でスキップする設計が履歴のノイズを削減
- IndexedDBとの同期をイベント駆動にしたことで履歴が2秒以内に追随
- 画面共有のAbortErrorをリトライで吸収し、運用トラブルを抑制
現場で確かめたいときは上のデモを全画面にして、ブラウザだけでチェックしてください。
今回のハイライト
- YOLOv11n(物体検出用の軽量なAIモデル)検出にIoU(重なり具合の指標)/色ヒストグラム/サイズの三要素を掛け合わせ、重複保存を排除
- 検出履歴を管理するコンポーネントで履歴JSONの保存・読込・自動更新を実装
- Webカメラ/URL/画面共有の切り替えでAbortError(中断エラー)をリトライし、推論ループを維持
- Three.js(3Dグラフィックスライブラリ)背景と処理速度プリセットでGPU(グラフィックス処理装置)負荷とUI(ユーザーインターフェース)のバランスを最適化
さらに深く学ぶには
最後まで読んでくださり、本当にありがとうございました。制作現場での安定運用に、今回の調整が少しでも役立てば嬉しいです。 同じようにブラウザだけでAIを回す方の参考になれば幸いです。