パフォーマンス作品 Adaptive Generative Output Performance 2024 の開発に関わる中で,最初のプロトタイプとして YOLOv8 のリアルタイム物体検出をブラウザ上で動かす Webアプリを開発しました。


なぜブラウザで動かすのか
パフォーマーが使うアプリをリモートで継続的に提供するには,インストール不要で,URLを開くだけで使えるWebアプリ が最適だった。
- ネイティブアプリだとビルドの配布やバージョン管理が煩雑になる
- Webアプリであればサーバ側を更新するだけでパフォーマー全員に即時反映できる
- カメラへのアクセスはブラウザのMediaDevices APIで対応可能
物体検出モデルの推論はサーバへのリクエストなしにクライアントサイドで完結させたかった。レイテンシの問題と,パフォーマンス環境でのネットワーク不安定リスクを避けるためだ。そこで注目したのが WebAssembly (WASM) を使ったブラウザ内推論だ。
技術スタック
| 要素 | 採用技術 |
|---|---|
| フレームワーク | React + TypeScript |
| モデル | YOLOv8n (Ultralytics) |
| モデル形式 | ONNX |
| 推論エンジン | onnxruntime-web |
| バックエンド | WebAssembly (wasm) |
YOLOv8 モデルを ONNX 形式にエクスポートする
Ultralytics の Python パッケージを使えばワンライナーでエクスポートできる。
1from ultralytics import YOLO 2 3model = YOLO("yolov8n.pt") 4model.export(format="onnx", imgsz=640, opset=12) 5# => yolov8n.onnx が生成される
opset=12 を指定しているのは,onnxruntime-web がサポートするオペレーターセットに合わせるためだ。生成された yolov8n.onnx をそのままプロジェクトの public/ ディレクトリに配置する。
onnxruntime-web で ONNX モデルをロードして推論する
onnxruntime-web は Microsoft が提供する ONNX Runtime の JavaScript/WebAssembly 実装だ。バックエンドとして wasm を指定するとブラウザ内の WebAssembly で推論が走る。
1npm install onnxruntime-web
1import * as ort from "onnxruntime-web"; 2 3// WASMバックエンドを明示的に指定 4ort.env.wasm.wasmPaths = "/ort-wasm/"; 5 6const session = await ort.InferenceSession.create("/yolov8n.onnx", { 7 executionProviders: ["wasm"], 8});
wasmPaths には node_modules/onnxruntime-web/dist/ 以下の .wasm ファイルを静的ファイルとして配信するパスを設定する。
カメラ映像をモデルに入力する
getUserMedia でカメラ映像を取得し,<video> 要素から <canvas> 経由でフレームを切り出す。
1const stream = await navigator.mediaDevices.getUserMedia({ video: true }); 2videoRef.current!.srcObject = stream;
フレームの前処理(リサイズ・正規化・NCHW変換)を行い,Float32Array のテンソルを作る。
1const preprocess = ( 2 canvas: HTMLCanvasElement, 3 modelWidth: number, 4 modelHeight: number, 5): [ort.Tensor, number, number] => { 6 const ctx = canvas.getContext("2d")!; 7 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); 8 const { data, width, height } = imageData; 9 10 const input = new Float32Array(modelWidth * modelHeight * 3); 11 const xRatio = width / modelWidth; 12 const yRatio = height / modelHeight; 13 14 for (let y = 0; y < modelHeight; y++) { 15 for (let x = 0; x < modelWidth; x++) { 16 const srcX = Math.floor(x * xRatio); 17 const srcY = Math.floor(y * yRatio); 18 const srcIdx = (srcY * width + srcX) * 4; 19 // NCHW形式に変換 (R, G, B チャンネルを分離) 20 input[y * modelWidth + x] = data[srcIdx] / 255.0; // R 21 input[modelWidth * modelHeight + y * modelWidth + x] = 22 data[srcIdx + 1] / 255.0; // G 23 input[2 * modelWidth * modelHeight + y * modelWidth + x] = 24 data[srcIdx + 2] / 255.0; // B 25 } 26 } 27 28 const tensor = new ort.Tensor("float32", input, [ 29 1, 30 3, 31 modelHeight, 32 modelWidth, 33 ]); 34 return [tensor, xRatio, yRatio]; 35};
推論と後処理
推論結果は [1, 84, 8400] の形状のテンソルとして出力される(YOLOv8 の場合,84 = 4座標 + 80クラス)。Non-Maximum Suppression (NMS) を適用して最終的な検出結果を得る。
1const runInference = async ( 2 session: ort.InferenceSession, 3 tensor: ort.Tensor, 4) => { 5 const feeds = { images: tensor }; 6 const results = await session.run(feeds); 7 const output = results[session.outputNames[0]].data as Float32Array; 8 9 // output shape: [1, 84, 8400] 10 const [boxes, scores, classIds] = postprocess(output, xRatio, yRatio); 11 return { boxes, scores, classIds }; 12};
後処理では信頼度スコアでフィルタリングし,NMS を適用する。最終的な結果を <canvas> にバウンディングボックスとして描画する。
推論速度
M1 MacBook Pro の Chrome 上では 約 30ms/フレーム 程度で推論できた(YOLOv8n, 640×640 入力)。パフォーマーのマシンスペックにもよるが,リアルタイム性として十分実用的な速度だ。
WebGL バックエンド(executionProviders: ["webgl"])に切り替えるとさらに高速化できる場合があるが,モデルのオペレーター互換性の問題から今回は WASM を採用した。
まとめ
- YOLOv8 を ONNX 形式でエクスポートし,onnxruntime-web の WASM バックエンドでブラウザ上推論を実現した
- URLを開くだけで動く Webアプリにすることで,パフォーマーへの継続的なアプリ提供が容易になった
- サーバ通信なしのクライアントサイド推論によって低レイテンシかつオフライン環境でも動作する