MediaPipeをスマホで動かす:処理時間の測り方とメモリ管理
PCで動いたAI画像ツールをスマホで開くと、読み込みが長かったり操作が止まったりすることがあります。「スマホだから遅い」で終わらせず、どの処理を、どの条件で測るのかを決めましょう。ここではプロジェクトのAI人物画像を使い、入力サイズを変えて顔検出を実行する計測ページを作ります。
1. この記事のゴールと使用する版
モデル準備と推論を別々に測り、画像サイズごとの結果をJSONで保存できること。さらに、画面上の見た目と推論に渡すピクセル数を区別し、実端末で同じ手順を再現できることを目指します。
前提はHTMLの要素、JavaScriptの変数・関数・イベント、async/awaitの基本です。モデルを自分で学習させる知識は不要です。CDN版のSDKを使用するため、このサンプルへMediaPipeをnpmで追加インストールする必要はありません。SDKとWASMは同じパッケージ・同じ版に固定します。
教材の構成:@mediapipe/tasks-vision 0.10.34。モデルはBlazeFace full range、float16、モデル版1。GPUで初期化し、失敗した場合はCPUで再試行します。
このサイトのAI画像ツールで使うSDKと配信方法、旧Solutionsとの違いは概要記事の現在のサイト構成に整理しています。音声・テキスト編は追加の学習教材です。今回のモデルは公式ストレージの版が分かるURLを指定し、コードの先頭で確認できます。
2. サンプルを試し、自分のPCで起動する
VS Codeでフォルダーを開く方法や、app.jsの編集・保存が初めてなら、導入記事のエディター準備と「保存 → ブラウザ更新 → 実行」の手順を先に確認してください。下では、このサンプルの起動に必要な操作を示します。
AI人物または愛猫を選び、「画像サイズ別に測定する」を押します。長辺320・640・960 pxを順に処理し、各サイズで最初の1回と、続く5回の推論を測ります。最後に「測定JSONを保存」を押すと、入力寸法・SDK・モデル・delegate・ブラウザ・画面幅などを残せます。

今回の計測で確認できたこと
320×175 pxは最初の推論が593.30 ms、続く5回の平均が26.84 msでした。640×349 pxの5回平均は22.56 ms、960×524 pxは19.08 msで、各サイズとも顔は6件でした。今回は大きい入力の方が平均値が小さくなっていますが、「大きくすると速くなる」という結論にはできません。
320→640→960の順で同じ検出器を使うため、GPUの初回処理・ウォームアップ、実行順、測定ごとの変動を切り分けていません。サイズを比較するなら、ウォームアップ条件と順序を揃える追加測定が必要です。「小さければ常に速い」とも決めつけず、目的の端末で検出数と時間を一緒に確認します。
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. 準備時間と推論時間を分ける
AIを使う最初の操作には、JavaScriptのSDK取得、WASMの取得・初期化、モデルの取得・初期化、画像の読み込み、推論、結果の描画が含まれます。利用者が待つ総時間と、モデルが画像を調べる時間は同じではありません。本サンプルでは「SDK・WASM・モデル準備」と「detect()の呼び出し」を別に測っています。
const start = performance.now();
const result = detector.detect(canvas);
const elapsedMs = performance.now() - start;performance.now()の差はミリ秒です。ここで計測区間に入るのは顔検出APIの呼び出しだけで、描画や画像取得は入りません。一方、準備の値には通信と初期化が含まれるため、同じPCでもキャッシュの状態に左右されます。再度ボタンを押したときは検出器を使い回し、「再読み込みなし」と表示します。
「初回」と呼ぶ範囲も明示します。表の1回目はその画像サイズへ切り替えた後の最初の推論です。640 pxの行では320 pxの推論がすでに終わっているため、各行が独立した冷たい起動の比較にはなりません。入力サイズだけの影響を厳密に分けたい場合は、順序を入れ替え、ページを再読み込みする測定も加えます。
4. CSSの表示幅と入力ピクセル数は別
スマホで画像を横幅360 pxに表示しても、元の画像が4000×3000 pxなら、その画像要素をそのまま推論に渡すと大きな入力のままです。CSSのwidth:100%は画面の見た目を調整します。本サンプルはCanvasのwidthとheightも変更し、縮小した実際のピクセルを推論へ渡します。
const scale = Math.min(1, maxEdge / Math.max(image.naturalWidth, image.naturalHeight));
canvas.width = Math.round(image.naturalWidth * scale);
canvas.height = Math.round(image.naturalHeight * scale);
context.drawImage(image, 0, 0, canvas.width, canvas.height);この縮小は元の写真の縦横比を保ち、小さい写真を無理に拡大しません。320・640・960は教材の比較条件で、すべての画像に対する推奨値ではありません。小さい顔が複数ある写真では縮小によって検出数が変わる可能性があります。時間だけを見ず、入力寸法・対象の大きさ・検出数も確認してください。
猫の写真へ切り替えるときは、モデルの対象も確認します。BlazeFaceは人の顔用です。猫で0件になっても、それだけでSDKの読み込み失敗とは判断できません。「AIが処理を終えたか」と「目的に合うモデルか」を別々に考える習慣は、速度の調査にも役立ちます。
5. スマホのタップ座標をCanvasへ合わせる
顔に手動でモザイクを追加するような画面では、スマホのタップした位置が必要です。画面の座標をそのままCanvasへ渡すと、CSSで縮小した分だけずれます。表示上の長さから内部のピクセル数へ、比率で変換します。次の式は、枠線・内側余白を付けていないCanvasを前提とします。
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * canvas.width / rect.width;
const y = (event.clientY - rect.top) * canvas.height / rect.height;例えば内部幅960 px、表示幅320 pxなら横方向の倍率は3です。clientXから表示左端を引き、3倍して描画座標にします。マウスとタッチを一つの流れで扱うならPointer Eventsを使うと整理しやすくなります。画像だけを左右反転して表示する場合は、推論座標の反転も必要です。
高DPI端末のdevicePixelRatioと画像推論の解像度は目的が異なります。文字や線を鮮明にするためのCanvas設定を、そのままAIの入力サイズへ適用すると、必要以上に重くなることがあります。描画用と推論用のCanvasを分ける設計も検討します。
6. 速さの判断とメモリの解放
5回の平均は、短い動作確認の目安です。たまたま他の処理が走った1回が平均を押し上げることもあるため、最小〜最大も表示しています。端末の発熱、電池、他のタブ、ブラウザ、通信、CPU/GPUの選択まで含めて条件を記録し、同じ条件で複数回測ってから設計を決めましょう。GPUが必ず速いとは限りません。
画面更新を挟むrequestAnimationFrame()は、進捗表示を見せるための待機です。推論自体を非同期の別スレッドへ移す仕組みではありません。画像の推論中に操作が止まるなら、まず入力を小さくする・操作ごとに1回だけ実行する・動画の処理頻度を下げる方法を試します。それでも足りなければWeb Workerへ処理を移す設計へ進みます。
モデルは画像ごとに作り直さず使い回し、ページを離れるときはclose()で解放します。ダウンロードのために作ったObject URLは使用後にURL.revokeObjectURL()で解放します。カメラを使う画面では、推論を止めるだけではカメラは止まりません。MediaStreamの各トラックも停止します。
猫の手ツールでは、アップロードした高解像度画像をそのまま処理しない設計、スマホの表示と座標を合わせる設計へつながります。実機の計測は、iPhoneのSafari、AndroidのChromeなど対象を決め、端末名・OS・ブラウザ・画像を揃えて追加します。掲載キャプチャは作成環境のブラウザ検証で、スマホ実機の測定は掲載していません。
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><label for="sample">入力画像</label><select id="sample"><option value="person.jpg">AI生成の人物</option><option value="cat.jpg">運営者の愛猫</option></select><div class="controls"><button id="run-button">画像サイズ別に測定する</button><button id="download-button" disabled>測定JSONを保存</button></div></section><p id="status" role="status" aria-live="polite">準備できました。ボタンを押すとSDK・モデルを読み込みます。</p><section><h2>入力画像</h2><canvas id="input-canvas" width="640" height="480"></canvas></section><section><h2>実測値(ms)</h2><p id="setup-time">SDK・WASM・モデル準備:未実行</p><div class="scroll"><table><thead><tr><th>入力 px</th><th>検出数</th><th>サイズ変更後の1回目</th><th>続く5回の平均</th><th>最小〜最大</th></tr></thead><tbody id="results-body"></tbody></table></div><p><small>1回目は各サイズの最初の推論です。SDK準備の時間は含みません。測定をやり直すとAIは使い回されます。</small></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:処理の完全コード
// SDKとWASMは同じ固定バージョンを使います。
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 button = document.querySelector('#run-button');
const select = document.querySelector('#sample');
const status = document.querySelector('#status');
const canvas = document.querySelector('#input-canvas');
const context = canvas.getContext('2d');
const body = document.querySelector('#results-body');
const json = document.querySelector('#json-output');
const download = document.querySelector('#download-button');
let detector;
let delegate;
let lastReport;
let hasStartedMeasurement = false;
const paint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
async function prepare() {
if (detector) return { reused: true, setupMs: 0 };
const start = performance.now();
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: 'IMAGE',
minDetectionConfidence: .5,
});
try { detector = await create('GPU'); delegate = 'GPU'; }
catch { detector = await create('CPU'); delegate = 'CPU'; }
return { reused: false, setupMs: performance.now() - start };
}
function showImage(image, maxEdge) {
const scale = Math.min(1, maxEdge / Math.max(image.naturalWidth, image.naturalHeight));
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
context.drawImage(image, 0, 0, canvas.width, canvas.height);
}
button.addEventListener('click', async () => {
hasStartedMeasurement = true;
button.disabled = select.disabled = true;
download.disabled = true;
body.replaceChildren();
try {
const image = new Image(); image.src = `./${select.value}`; await image.decode();
status.textContent = 'SDK・WASM・モデルを準備しています。'; await paint();
const setup = await prepare();
document.querySelector('#setup-time').textContent = setup.reused
? 'SDK・WASM・モデル準備:前回のAIを再利用(再読み込みなし)'
: `SDK・WASM・モデル準備:${setup.setupMs.toFixed(1)} ms(通信・初期化を含む)`;
const measurements = [];
for (const edge of [320, 640, 960]) {
showImage(image, edge); status.textContent = `長辺${edge} pxの推論を測定中…`; await paint();
let start = performance.now();
const first = detector.detect(canvas);
const firstMs = performance.now() - start;
const repeats = [];
for (let i = 0; i < 5; i++) {
await paint();
start = performance.now(); detector.detect(canvas); repeats.push(performance.now() - start);
}
const mean = repeats.reduce((sum, value) => sum + value, 0) / repeats.length;
const measurement = { targetMaxEdge: edge, width: canvas.width, height: canvas.height,
faceCount: first.detections.length, firstMs, repeatMs: repeats, meanMs: mean,
minMs: Math.min(...repeats), maxMs: Math.max(...repeats) };
measurements.push(measurement);
const row = body.insertRow();
for (const value of [`${canvas.width} × ${canvas.height}`, measurement.faceCount,
firstMs.toFixed(2), mean.toFixed(2), `${measurement.minMs.toFixed(2)}〜${measurement.maxMs.toFixed(2)}`]) {
row.insertCell().textContent = String(value);
}
}
lastReport = { capturedAt: new Date().toISOString(), sdkVersion: VERSION, model: MODEL,
delegate, sample: select.value, originalSize: [image.naturalWidth, image.naturalHeight],
userAgent: navigator.userAgent, viewport: [innerWidth, innerHeight], devicePixelRatio,
setup, measurements, note: '同じモデルを各サイズで1回+5回実行。CPU/GPU・端末・実行順・キャッシュ・負荷の影響を含みます。' };
json.textContent = JSON.stringify(lastReport, null, 2); download.disabled = false;
status.textContent = `測定完了:${delegate}。この実行条件の値として確認してください。`;
} catch (error) {
console.error(error); status.textContent = `実行できませんでした:${error.message}。HTTP起動・通信・ブラウザを確認してください。`;
} finally { button.disabled = select.disabled = false; }
});
download.addEventListener('click', () => {
if (!lastReport) return;
const url = URL.createObjectURL(new Blob([JSON.stringify(lastReport, null, 2)], { type: 'application/json' }));
const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'mediapipe-benchmark.json'; anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
const preview = new Image(); preview.src = './person.jpg';
preview.decode().then(() => {
// 遅れて完了した初期プレビューで、別画像や測定中のCanvasを上書きしません。
if (!hasStartedMeasurement && select.value === 'person.jpg') showImage(preview, 640);
}).catch(() => {});
window.addEventListener('pagehide', () => { detector?.close(); detector = null; });
測定値とJSONはtextContentで表示します。画像サイズごとに同じ検出器を使い、detect()の呼び出しだけを測る箇所と、結果を描く箇所を分けて読みます。進捗を表示するためのasync関数やrequestAnimationFrame()は、推論を別スレッドへ移すものではありません。
8. 困ったときの確認表
| 症状 | 確認 |
|---|---|
| スマホだけ読み込みが長い | 準備時間と推論時間を区別。通信・キャッシュと入力サイズを確認します。 |
| 小さい画像で検出数が減る | 小さな顔が縮小で失われる可能性。時間と検出数をセットで比較します。 |
| タップした位置と描画がずれる | Canvasの表示幅と内部幅を確認し、座標を比率で変換します。 |
| 2回目の準備時間がない | 同じ検出器を再利用する仕様です。ページ再読み込みと区別します。 |
| SDK・モデルを読み込めない | HTTP起動か、通信できるか、固定URLと版が揃っているか確認します。開発者ツールのConsoleとNetworkで、最初のエラーと失敗した取得先を見ます。 |
問い合わせや自分のメモでは「動かない」だけでなく、使用OS・ブラウザ、選んだ入力、画面の状態、最初のエラー、SDK・モデルの版を残すと切り分けやすくなります。入力内容や私的なファイル名を公開する必要はありません。この記事のキャプチャは実行したサンプルの観察で、すべての端末・入力への品質を保証するものではありません。
参考資料
資料確認日:2026年10月12日。SDKとモデルは別々に更新されます。更新する際は固定した版のサンプルを残し、変更後の入力・結果・実行条件を比べてください。