MediaPipe動画・カメラ入門:detectForVideoと停止処理を動かす
静止画の顔検出が動いたら、次は動画です。動画でも毎回の入力は画像ですが、時間の扱いと連続処理の制御が加わります。この教材では、プロジェクト内のAI人物写真から音声なしの短い動画をブラウザ内で作り、その動画要素に対して実際のdetectForVideo()を実行します。
1. この記事のゴールと使用する版
IMAGEモードとVIDEOモードの違いを理解し、動画フレームの推論・結果の描画・停止まで動かせること。カメラを使う場合も、利用者の操作とブラウザの許可を起点にし、使い終わったカメラを停止できることを目指します。
前提はHTMLの要素、JavaScriptの変数・関数・イベント、async/awaitの基本です。モデルを自分で学習させる知識は不要です。CDN版のSDKを使用するため、このサンプルへMediaPipeをnpmで追加インストールする必要はありません。SDKとWASMは同じパッケージ・同じ版に固定します。
教材の構成:@mediapipe/tasks-vision 0.10.34。モデルはBlazeFace full range、float16、モデル版1。runningModeはVIDEOです。
このサイトのAI画像ツールで使うSDKと配信方法、旧Solutionsとの違いは概要記事の現在のサイト構成に整理しています。音声・テキスト編は追加の学習教材です。今回のモデルは公式ストレージの版が分かるURLを指定し、コードの先頭で確認できます。
2. サンプルを試し、自分のPCで起動する
VS Codeでフォルダーを開く方法や、app.jsの編集・保存が初めてなら、導入記事のエディター準備と「保存 → ブラウザ更新 → 実行」の手順を先に確認してください。下では、このサンプルの起動に必要な操作を示します。
「写真からテスト動画を作って検出」を押します。最初にSDKとモデルを準備し、人物写真を少し移動させながら約4秒記録します。その後、生成した動画を再生して顔検出します。入力動画と緑の枠、フレーム数とJSONを確認してください。このボタンはカメラやマイクを使いません。

今回の実行で確認できたこと
テスト動画を最後まで処理し、JSONには5フレームの記録が残りました。最後のフレームで顔6件、推論時間37.20 msを確認しました。設定の100 ms間隔は推論頻度を抑える条件で、この実行で毎秒10回を達成したことを示しません。画面更新や処理負荷などで実際の回数は変わります。
この確認は、写真由来の動画要素に対して時刻付きの推論を繰り返せたことの検証です。実カメラの許可・撮影、自然な表情や動き、スマホ実機での精度は検証していません。
ZIPを展開して、HTMLを直接開かずHTTPで起動する
- ZIPを保存し、エクスプローラーの「すべて展開」で中身を展開します。ZIPの中を閲覧している状態で実行しません。
index.htmlとapp.jsが見えるフォルダーで右クリックし、ターミナルを開きます。README.txtにも手順があります。- 下の最初の2行を1行ずつ実行します。バージョンが表示されればNode.jsとnpmを利用できます。「見つからない」ならNode.js公式サイトのLTS版を導入し、ターミナルを開き直します。
- 3行目を実行します。
http-serverの初回取得には通信が必要です。サーバーが起動したらターミナルは開いたままにします。 - ブラウザのアドレス欄へ
http://localhost:8080/を入力します。サンプルが表示されたら上の操作を試します。 - 編集後は保存してブラウザを再読み込みします。終了はターミナルでCtrl+Cです。
node --version
npm.cmd --version
npx.cmd --yes http-server . -p 8080 Windows PowerShellではnpm.cmdとnpx.cmdを使うと、スクリプト実行ポリシーによるつまずきを避けやすくなります。macOS・Linuxはnpm・npxで実行します。8080が使用中なら-p 8081へ変え、ブラウザ側もhttp://localhost:8081/に合わせます。
初回のボタン操作でSDK・WASM・モデルを取得するので、しばらく待つ場合があります。HTTPサーバーはHTMLとJavaScriptを配信するためのもので、AI推論をサーバー側で行う構成ではありません。file://での直接起動は使いません。基本操作の確認はPCのブラウザで行い、スマホは画面幅の確認と実機での動作検証を区別します。
3. 動画にはVIDEOモードを使う
FaceDetectorには静止画を処理するdetect()と、動画フレームを処理するdetectForVideo()があります。今回の検出器は作成時にrunningMode: "VIDEO"を指定します。静止画入門の検出器へメソッド名だけを変えて呼び出すのではなく、モードも合わせます。
const detector = await FaceDetector.createFromOptions(files, {
baseOptions: { modelAssetPath: MODEL, delegate: "GPU" },
runningMode: "VIDEO",
minDetectionConfidence: 0.5,
});動画用APIへ渡す入力は、このサンプルではHTMLVideoElementです。画像幅はvideo.videoWidth、高さはvideo.videoHeightで取得します。CSSの表示寸法を使うと、顔の矩形と描画の座標が合わなくなります。結果のCanvasを元の動画と同じ内部寸法にして、動画フレームと検出枠を順に描いています。
生成するテスト動画は、AI人物写真をCanvasへ描き、captureStream()とMediaRecorderで記録したものです。猫の写真も教材フォルダーにありますが、人の顔検出用なので標準のテストには人物を使用します。この方式は、顔の向きや表情の変化、被写体の動きへの精度を評価する実験ではありません。
4. 推論へ渡す時刻はミリ秒・単調増加
第2引数の時刻は、動画処理の入力を時間順に渡すための値です。フレームごとに同じ0を渡したり、秒の値をミリ秒として使ったりしないようにします。サンプルではperformance.now()を使い、同じページの実行中に単調に増える値を渡します。
const timestampMs = performance.now();
const result = detector.detectForVideo(video, timestampMs);動画のcurrentTimeは秒で、再生位置です。別の役割として、前回と同じ動画フレームを繰り返し推論しないために比較します。さらに前回の推論から約100 ms以上経過した場合だけ実行し、処理頻度を約10回/秒に抑えています。画面が毎秒60回更新されても、AIを60回呼ぶ必要はありません。
デモのJSONには、動画の再生位置、推論へ渡した時刻、推論時間、検出数を保存します。推論時間の単位はmsです。記録は最近の最大120フレームまでに制限し、長くカメラを使っても履歴が無制限に増えないようにしています。動画のフレーム数と、実際に推論した回数は一致しなくてよいことも確認しましょう。
5. カメラは専用ボタンから開始する
カメラを試したい場合は「自分のカメラで試す」を押します。そこで初めてgetUserMedia()を呼び、ブラウザの許可画面が出ます。音声はaudio:falseなのでマイクは要求しません。ページを開いただけでカメラが始まる構成にはしていません。
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 640 }, height: { ideal: 480 } },
audio: false,
});
video.srcObject = stream;
await video.play();HTTPSまたはlocalhostなどの安全なコンテキストが必要です。PCのローカルサーバーをスマホからhttp://192.168...で開いた場合、普通のHTTPではカメラAPIが利用できないことがあります。静止画のテストができることと、カメラが使えることは別の条件です。スマホへ公開して試す場合はHTTPSの環境を用意します。
許可を拒否した、他のアプリがカメラを使用中、端末にカメラがない、といった状態もあります。エラーを表示し、テスト動画で学習を続けられる構成にします。facingMode:'user'は前面カメラの希望です。希望した幅・高さが必ず採用されるわけではないため、実際のvideoWidthとvideoHeightを確認します。
6. 推論・再生・カメラをそれぞれ停止する
「停止する」では、繰り返し推論の予約を解除し、動画を一時停止し、カメラのトラックを停止します。検出器は再実行に備えて保持し、ページから離れるときにclose()します。次のコードは、それぞれの停止先をまとめた概念例です。完全コードでは録画中のタイマー、Object URL、非同期処理の古い結果も整理しています。
cancelAnimationFrame(frameRequest);
video.pause();
stream.getTracks().forEach(track => track.stop());
video.srcObject = null;
detector.close();推論のループを止めるだけでは、MediaStreamが生きたまま残ります。カメラを使うアプリでは、明確な停止ボタンと、画面が非表示になった場合の停止が大切です。サンプルはタブが非表示になるとカメラを止め、カメラ使用中のまま放置されないようにしています。
また、SDK準備中や録画中に停止した後、古い処理が完了して勝手に再開しないように、操作番号を比較します。この考え方は画像選択を素早く切り替えるアプリでも共通です。初心者のうちは、モデルの処理だけでなく「開始・停止・再開始」の3操作をセットで確かめると不具合を見つけやすくなります。
このサイトの静止画ツールをカメラ対応へ広げるときは、入力の取得と繰り返し実行の部分を加えることになります。顔の枠を描く仕組みは静止画編と共通です。ただし、この教材のカメラ機能が既存ツールへ導入済みという意味ではありません。掲載検証は写真由来のテスト動画で行い、実カメラ・スマホ実機の結果は掲載していません。
7. 完全コードを確認する
以下はダウンロードZIPと同じソースです。index.htmlが画面と操作ボタン、app.jsがSDKの初期化・入力の準備・推論・出力表示を担当します。コードを途中だけコピーすると必要な要素が欠けるので、最初はZIPの完全版から起動してください。慣れたら設定を1つずつ変更し、JSONと画面の変化を見ます。
index.html:画面の完全コード
<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>MediaPipeで動画の顔検出を動かす | 猫の手ツール</title><style>:root{font-family:system-ui,sans-serif;color:#17243c;background:#eef2f8}*{box-sizing:border-box}body{margin:0}main{max-width:1080px;margin:auto;padding:28px 20px}h1{font-size:clamp(1.7rem,5vw,2.4rem)}p{line-height:1.8}button,input,select,textarea{font:inherit}button{background:#4338ca;color:white;border:0;border-radius:8px;padding:12px 18px;cursor:pointer}button:disabled{opacity:.55;cursor:wait}label{display:block;font-weight:650;margin:12px 0 6px}textarea{width:100%;min-height:100px;padding:12px;border:1px solid #aab7ce;border-radius:8px}select,input[type=file]{max-width:100%;padding:8px}section,.box{background:white;border:1px solid #dbe1eb;border-radius:14px;padding:18px;margin:18px 0}.controls{display:flex;flex-wrap:wrap;gap:12px;align-items:center}#status{border-left:4px solid #4338ca;padding:14px;background:white;min-height:54px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}canvas,video{max-width:100%;height:auto;display:block;border-radius:8px;background:#14243b}table{border-collapse:collapse;width:100%;font-size:.9rem}th,td{text-align:left;border-bottom:1px solid #dbe1eb;padding:10px}pre{background:#12203a;color:#e0e8ff;padding:16px;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:420px;overflow:auto}.scroll{overflow:auto}meter{width:160px;max-width:100%}a{color:#4338ca}small{color:#53637d}button:focus-visible,a:focus-visible{outline:3px solid #e99a23;outline-offset:3px}@media(max-width:640px){.grid{grid-template-columns:1fr}main{padding:18px 12px}section{padding:14px}}</style></head><body><main><p><a href="./README.txt">起動手順を開く</a></p><h1>MediaPipeで動画の顔検出を動かす</h1><p>AI生成の人物写真からブラウザ内でテスト動画を作成し、実際の動画要素に対して顔検出を実行します。カメラは専用ボタンを押した場合だけ使用します。</p><section><h2>実行する</h2><div class="controls"><button id="run-button">写真からテスト動画を作って検出</button><button id="camera-button">自分のカメラで試す</button><button id="stop-button" disabled>停止する</button></div><p><small>テスト動画は音声なし・約4秒です。カメラはHTTPSまたはlocalhostで、ブラウザの許可が必要です。</small></p></section><p id="status" role="status" aria-live="polite">準備できました。ボタンを押すとSDK・モデルを読み込みます。</p><div class="grid"><section><h2>入力動画</h2><video id="input-video" muted playsinline controls width="640" height="480"></video></section><section><h2>検出結果</h2><canvas id="result-canvas" width="640" height="480"></canvas></section></div><section><h2>フレームの記録</h2><p id="metrics">未実行</p></section><section><h2>実際の出力JSON・検証条件</h2><pre id="json-output">まだ実行していません。</pre></section><p><small>推論はブラウザ内で行います。SDK・モデル取得のためCDN / Googleの公開ストレージへ接続します。入力データを送信する処理はありません。記事の検証日:2026年10月12日。</small></p></main><script type="module" src="./app.js"></script></body></html>app.js:処理の完全コード
const VERSION = '0.10.34';
const BASE = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${VERSION}`;
const MODEL = 'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_full_range/float16/1/blaze_face_full_range.tflite';
const run = document.querySelector('#run-button');
const camera = document.querySelector('#camera-button');
const stop = document.querySelector('#stop-button');
const status = document.querySelector('#status');
const video = document.querySelector('#input-video');
const canvas = document.querySelector('#result-canvas');
const context = canvas.getContext('2d');
const metrics = document.querySelector('#metrics');
const json = document.querySelector('#json-output');
let detector, delegate, sourceStream, recordingStream, recordingTimer, recorder, videoUrl;
let frameRequest = 0, lastVideoTime = -1, active = false, token = 0, frames = [];
let cameraOperation = null;
async function prepare() {
if (detector) return;
const { FaceDetector, FilesetResolver } = await import(`${BASE}/vision_bundle.mjs`);
const files = await FilesetResolver.forVisionTasks(`${BASE}/wasm`);
const create = choice => FaceDetector.createFromOptions(files, {
baseOptions: { modelAssetPath: MODEL, delegate: choice }, runningMode: 'VIDEO', minDetectionConfidence: .5,
});
try { detector = await create('GPU'); delegate = 'GPU'; }
catch { detector = await create('CPU'); delegate = 'CPU'; }
}
function setControls(running) { run.disabled = camera.disabled = running; stop.disabled = !running; }
function stopTracks(stream) { stream?.getTracks().forEach(track => track.stop()); }
function finish(message = '停止しました。最後の結果を表示しています。') {
active = false; ++token;
cameraOperation = null;
cancelAnimationFrame(frameRequest); clearInterval(recordingTimer);
if (recorder?.state === 'recording') recorder.stop();
stopTracks(recordingStream); recordingStream = null;
stopTracks(sourceStream); sourceStream = null;
video.pause(); video.srcObject = null;
setControls(false); status.textContent = message;
}
async function makeTestVideo(operation) {
if (!window.MediaRecorder || !HTMLCanvasElement.prototype.captureStream) {
throw new Error('このブラウザはCanvasからテスト動画を作るAPIに対応していません');
}
const image = new Image(); image.src = './person.jpg'; await image.decode();
if (operation !== token) throw new Error('処理を停止しました');
const source = document.createElement('canvas'); source.width = 640; source.height = 480;
const ctx = source.getContext('2d');
let frame = 0;
const draw = () => {
ctx.fillStyle = '#112240'; ctx.fillRect(0, 0, 640, 480);
const scale = Math.min(590 / image.naturalWidth, 430 / image.naturalHeight);
const w = image.naturalWidth * scale, h = image.naturalHeight * scale;
ctx.drawImage(image, (640 - w) / 2 + Math.sin(frame++ / 8) * 18, (480 - h) / 2, w, h);
ctx.fillStyle = 'white'; ctx.font = '18px sans-serif'; ctx.fillText('AI人物写真から作ったテスト動画 / 音声なし', 14, 466);
};
draw(); recordingStream = source.captureStream(20);
const mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/mp4', 'video/webm']
.find(type => MediaRecorder.isTypeSupported(type));
recorder = new MediaRecorder(recordingStream, mimeType ? { mimeType } : undefined);
const chunks = [];
recorder.addEventListener('dataavailable', event => { if (event.data.size) chunks.push(event.data); });
const blob = await new Promise((resolve, reject) => {
recorder.addEventListener('stop', () => {
clearInterval(recordingTimer); stopTracks(recordingStream); recordingStream = null;
resolve(new Blob(chunks, { type: recorder.mimeType }));
}, { once: true });
recorder.addEventListener('error', event => reject(event.error || new Error('録画に失敗しました')), { once: true });
recorder.start(); recordingTimer = setInterval(draw, 50);
const currentRecorder = recorder;
setTimeout(() => { if (currentRecorder.state === 'recording') currentRecorder.stop(); }, 4000);
});
if (operation !== token) throw new Error('処理を停止しました');
return blob;
}
function inferFrame() {
if (!active) return;
// 同じ動画フレームは重ねて処理せず、約10回/秒に間引きます。
const now = performance.now();
const lastTimestamp = frames.at(-1)?.timestampMs ?? -Infinity;
if (video.readyState >= 2 && video.currentTime !== lastVideoTime && now - lastTimestamp >= 100) {
lastVideoTime = video.currentTime;
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
const start = performance.now();
let result;
try { result = detector.detectForVideo(video, now); }
catch (error) { console.error(error); finish(`動画の推論に失敗しました:${error.message}`); return; }
const elapsedMs = performance.now() - start;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
context.strokeStyle = '#2af79c'; context.lineWidth = 3;
for (const detection of result.detections) {
const b = detection.boundingBox; if (b) context.strokeRect(b.originX, b.originY, b.width, b.height);
}
frames.push({ videoTimeSeconds: video.currentTime, timestampMs: now, elapsedMs,
faceCount: result.detections.length });
if (frames.length > 120) frames.shift();
metrics.textContent = `${delegate} / 記録 ${frames.length} フレーム / 顔 ${result.detections.length} 件 / 最新 ${elapsedMs.toFixed(2)} ms`;
json.textContent = JSON.stringify({ sdkVersion: VERSION, model: MODEL, delegate,
videoSize: [video.videoWidth, video.videoHeight], userAgent: navigator.userAgent,
recentFrames: frames, lastResult: result }, null, 2);
}
frameRequest = requestAnimationFrame(inferFrame);
}
async function start(useCamera) {
finish(); const operation = ++token; setControls(true); frames = []; lastVideoTime = -1;
cameraOperation = useCamera ? operation : null;
try {
status.textContent = 'SDK・WASM・モデルを準備しています。'; await prepare();
if (operation !== token) return;
if (useCamera && document.hidden) { finish('画面が非表示のためカメラを開始しません。'); return; }
if (videoUrl) { URL.revokeObjectURL(videoUrl); videoUrl = null; }
if (useCamera) {
if (!navigator.mediaDevices?.getUserMedia) throw new Error('HTTPSまたはlocalhostで開いてください');
status.textContent = 'カメラの許可を確認しています。音声は取得しません。';
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, audio: false });
// 許可画面を待つ間に停止・タブ切替された場合も、取得したカメラを残しません。
if (operation !== token || document.hidden) {
stopTracks(stream);
if (operation === token) finish('画面が非表示になったためカメラを停止しました。');
return;
}
sourceStream = stream; video.removeAttribute('src'); video.srcObject = stream;
} else {
status.textContent = '人物写真から約4秒のテスト動画を作っています(カメラは使いません)。';
const blob = await makeTestVideo(operation); videoUrl = URL.createObjectURL(blob); video.src = videoUrl;
}
await video.play(); if (operation !== token) return;
active = true; status.textContent = '動画を再生して、約10回/秒で顔検出しています。'; inferFrame();
} catch (error) {
console.error(error); if (operation === token) finish(`実行できませんでした:${error.message}`);
}
}
run.addEventListener('click', () => start(false));
camera.addEventListener('click', () => start(true));
stop.addEventListener('click', () => finish());
video.addEventListener('ended', () => finish(`テスト動画を最後まで処理しました。記録 ${frames.length} フレーム。`));
document.addEventListener('visibilitychange', () => {
if (document.hidden && (sourceStream || cameraOperation !== null)) {
finish('画面が非表示になったためカメラの開始・使用を停止しました。');
}
});
window.addEventListener('pagehide', () => { finish(); detector?.close(); detector = null; if (videoUrl) URL.revokeObjectURL(videoUrl); });
検出数とフレームごとの記録はtextContentで表示します。動画の時刻、前回処理したフレーム、開始・停止の状態を管理する部分を追ってください。連続推論を止めることと、カメラのトラックを停止することを別々に扱うのが、このサンプルの重要な点です。
8. 困ったときの確認表
| 症状 | 確認 |
|---|---|
| モデルは読めるが動画推論で失敗 | VIDEOモードとdetectForVideo()の組み合わせ、増加する時刻を確認します。 |
| カメラのボタンで許可できない | HTTPS/localhost、サイトのカメラ権限、他アプリの使用を確認します。 |
| テスト動画を作れない | MediaRecorderとCanvas.captureStream()の対応を確認。対応ブラウザで試します。 |
| 枠が左右反対になる | 入力表示だけを左右反転していないか確認し、描画も同じ座標系にします。 |
| SDK・モデルを読み込めない | HTTP起動か、通信できるか、固定URLと版が揃っているか確認します。開発者ツールのConsoleとNetworkで、最初のエラーと失敗した取得先を見ます。 |
問い合わせや自分のメモでは「動かない」だけでなく、使用OS・ブラウザ、選んだ入力、画面の状態、最初のエラー、SDK・モデルの版を残すと切り分けやすくなります。入力内容や私的なファイル名を公開する必要はありません。この記事のキャプチャは実行したサンプルの観察で、すべての端末・入力への品質を保証するものではありません。
参考資料
資料確認日:2026年10月12日。SDKとモデルは別々に更新されます。更新する際は固定した版のサンプルを残し、変更後の入力・結果・実行条件を比べてください。