はじめに
- Googleが2026/7/9にLiteRT.jsというブラウザ向けの推論ランタイムを出しました。最近よく見かける気がするWebGPUでなんやかんやできるらしいので試したい!
- せっかくのWebなら公開してみたさはあるがサーバー代をかけるほどのモチベと余裕はない!
- 姿勢推定のデモはよく見るし、この間公開された(2026/6)RF-DETR Keypoints使ってみるか!
という内容になります。
結局LiteRT.jsでRF-DETR Keypointsまでは至らなかったので、onnxruntime-webでお茶を濁しました。。。やる前にtfliteって見た瞬間から嫌な予感はしてはいました。。。
対象読者は、RF-DETRやブラウザ内推論ランタイムを触ったことはないが、Pythonでの機械学習モデルの扱いとJavaScriptでのWeb開発には一通り慣れている人を想定します。
この記事でやること
サーバー側には静的ファイルを置くだけにして、姿勢推定の計算そのものは利用者のブラウザ(できればGPU)にやらせます。この方針自体は最後まで変わりませんが、それを実現するランタイムは途中でLiteRT.jsからonnxruntime-webに変わりました。
最初に選んだLiteRT.js経由では、RF-DETRのkeypointモデルをTFLiteに変換した時点で、Python側の標準的なランタイム(ai_edge_litert)では読み込みに失敗するバグを踏みました。ブラウザのLiteRT.js(CPU/Wasm)では動いたものの、GPU(WebGPU)はモデル構造そのものが壁になり、原因を特定してもなお動かせませんでした。そこでLiteRT.js(および経由するTFLite形式)自体を諦め、ONNXファイルをそのまま読み込めるonnxruntime-webに切り替えたところ、WebGPUがそのまま動き、CPU実行の10倍以上速くなりました。
最終的にできあがったものは、実際にGitHub Pagesにデプロイして公開しています。
デモ: https://ys-dirard.github.io/rf-detr-keypoint-web/
全体の構成は次のようになります。
[ブラウザ]
画像入力(canvas)
-> テンソル化
-> ブラウザ内推論ランタイムでモデルを推論
(LiteRT.js + .tflite → 最終的にonnxruntime-web + .onnx へ切り替え)
-> 出力テンソルをデコードして座標、信頼度、不確実性楕円を計算
-> canvasに重ね描き
[GitHub Pages]
index.html とモデルファイルを静的配信するだけ
推論はすべてブラウザ側で完結し、サーバーは何も計算しない
検証環境は次のとおりです。
| 項目 | 値 |
|---|---|
| OS | Windows 11 Pro 10.0.26200 |
| Python(RF-DETR推論用) | 3.10.11 |
| Python(TFLiteエクスポート用) | 3.12.10 |
| rfdetr | 1.8.3 |
| torch | 2.13.0+cpu |
| onnx / onnxruntime / onnx2tf / tensorflow | 1.20.1 / 1.26.0 / 2.5.2 / 2.21.0 |
| Node.js | v20.20.0 |
| ブラウザ検証 | Playwright経由のChromium(headless、WebGPU計測時はheaded) |
| GPU(WebGPU計測時) | NVIDIA GeForce RTX 4060 Laptop GPU(Ada Lovelaceアーキテクチャ、ドライバ591.74) |
| @litertjs/core / @litertjs/tfjs-interop | 2.4.0 / 6.0.0(いずれもjsdelivr CDN) |
| onnxruntime-web | 1.20.1(jsdelivr CDN) |
| 確認日 | 2026-07-10 |
なぜRF-DETR keypointをブラウザで動かしたいのか
RF-DETRはRoboflowが公開している、リアルタイム物体検出向けのDETR系モデルです。2026年6月16日リリースのv1.8.0で、RFDETRKeypointPreviewというクラスが追加されました1。COCOの17点人物キーポイントで学習済みで、GroupPose系のキーポイントヘッドを検出Transformerに直接組み込み、1回のforward passでバウンディングボックスとキーポイントを同時に出力します。名前が示すとおり、まだプレビュー(実験的)機能です。
このモデルの特徴は、各キーポイントの座標だけでなく、その位置の不確実性を2×2の共分散行列として出力する点にあります。公式デモでは、この不確実性を楕円として描画しており、丸い楕円は全方向に同程度の不確実性があることを、細長い楕円は特定の方向に不確実性が偏っていることを表します。
※デモ動画で見て面白かったので手元でもそれっぽく再現したい!
ブラウザで推論を完結させる場合、最初に候補になるのはGoogleのLiteRT.jsでした。2026年7月9日に公開されたばかりのブラウザ向けオンデバイス推論ランタイムで2、.tflite(LiteRT)形式のモデルを直接読み込み、CPUはXNNPack経由のWasm、GPUはWebGPU、NPUはWebNNをバックエンドとして使います。公式ブログは「サーバーコストゼロ」を掲げており、ブラウザだけで推論を完結させる用途を明確に想定しています。RF-DETRのkeypoint機能自体にもTFLiteへのエクスポート機能があるため、この2つを組み合わせれば目的が達成できるはずだと考えました。
ただし、RF-DETRのkeypoint機能もLiteRT.jsも登場してから日が浅く、この組み合わせが実際に動くかどうかを示す一次情報は見当たりませんでした。
事前調査:公式情報だけでは接続が確認できなかった
執筆前に、公式ドキュメントとGitHubリポジトリを確認しました。個々の要素はどれも実在しました。RF-DETRのkeypoint対応、TFLiteへのエクスポート機能、LiteRT.js自体です。
ただし、RF-DETRの公式エクスポートガイドが示すTFLite変換の例は物体検出とセグメンテーションのみで、keypointモデル固有の手順や動作実績は記載がありませんでした。エクスポート自体もドキュメント上「実験的」と明記されており、Google公式の変換ツール(litert-torch)ではなく、サードパーティ製のonnx2tfを経由する独自の変換経路を使っていました。LiteRT.js側の公式デモも、物体検出や深度推定はあるが、keypoint(姿勢推定)の事例は見当たりませんでした。
つまり、部品はすべて実在するが、それらが実際に繋がる保証はどこにも書かれていません。ここから先は、実際に手を動かして確かめるしかありません。
動かす1:RF-DETRでkeypoint推論する(Python)
まずPython環境でkeypoint推論そのものが動くかを確認します。仮想環境を作り、rfdetrをインストールします。
python -m venv .venv
.venv/Scripts/activate
pip install rfdetr
RFDETRKeypointPreviewをロードし、人物が写った画像に対して推論します。
from rfdetr import RFDETRKeypointPreview
import supervision as sv
import cv2
model = RFDETRKeypointPreview(num_classes=1)
image = cv2.imread("test_person.jpg")
result = model.predict(image, threshold=0.6)
edge_annotator = sv.EdgeAnnotator(color=sv.Color.GREEN, thickness=2)
vertex_annotator = sv.VertexAnnotator(color=sv.Color.RED, radius=4)
annotated = edge_annotator.annotate(scene=image.copy(), key_points=result)
annotated = vertex_annotator.annotate(scene=annotated, key_points=result)
cv2.imwrite("test_person_keypoints.jpg", annotated)
テスト画像には、姿勢推定のデモでよく使われるbus.jpg(バス停に立つ複数の人物が写った画像)を使いました。初回実行時に学習済み重み(156MB)が自動ダウンロードされ、実行結果は次のようになりました。
骨格は視覚的に妥当な位置に乗っています。ただし、モデルの初期化時に次の警告が出ていました。
WARNING rf-detr - load_pretrain_weights: temporarily resizing keypoint schema from [17] to checkpoint schema [0, 17].
WARNING rf-detr - Pretrained weights at '...' loaded only partially — this typically produces lower accuracy.
4 checkpoint key(s) not consumed by model: [keypoint_head.keypoint_proj.0.weight, keypoint_head.keypoint_proj.0.bias, ...]
num_classes=1を明示してもこの警告は消えません。公式ドキュメントにも具体的な説明はなく、プレビュー機能ゆえにモデル定義とチェックポイントのスキーマがずれていると考えられます。警告が出ていても、可視化した骨格そのものは破綻していなかったため、このまま先に進めることにしました。
動かす2:TFLiteへのエクスポート
次に、このモデルをTFLiteに変換します。rfdetrにはエクスポート用のオプション(extra)があり、pip install "rfdetr[tflite]"でTFLite変換に必要な依存関係が入る想定になっています。
pip install "rfdetr[tflite]"
python -c "
from rfdetr import RFDETRKeypointPreview
model = RFDETRKeypointPreview(num_classes=1)
model.export(output_dir='export_out', format='tflite')
"
ところが、さきほどのPython 3.10環境ではこれが失敗します。pip install "rfdetr[tflite]"を実行しても新規パッケージが1つもインストールされず、export()はModuleNotFoundError: No module named 'onnx'で止まります。
原因はパッケージのメタデータにありました。rfdetrのTFLite関連の依存(onnx2tf、tensorflow、tf-kerasなど)は、次のようにPython 3.12専用の条件が付いています。
onnx2tf<3.0.0,>=2.4.0; (python_version >= "3.12" and python_version < "3.13") and extra == "tflite"
tensorflow>=2.16.0; (python_version >= "3.12" and python_version < "3.13") and extra == "tflite"
エラーも出さずに依存解決だけが静かにスキップされるため、原因に気づきにくくなっています。pyenvでPython 3.12.10の仮想環境を別に作り、そこでpip install "rfdetr[tflite]"を実行し直すと、onnx、onnxruntime、onnx2tf、tensorflowが一通りインストールされました。
この環境でexport()を実行すると、ONNX(約147MB)を経由してfloat32(148MB)とfloat16(74.6MB)の.tfliteが生成され、ログにもSuccessfully exported TFLite model to: ...と表示されました。エクスポート自体はここまで成功したように見えます。
動かす3:TFLiteをブラウザで動かす、しかしWebGPUは動かない
結論を先に書きます。エクスポートした.tfliteは、Python用の標準ランタイムでは読み込めません。ブラウザのLiteRT.jsでは、CPU(Wasm)なら動くが、目的だったGPU(WebGPU)はどうやっても動かせませんでした。ここから、その経緯を順に追います。
まず、生成された.tfliteが実際に読み込めるかを、Python用のLiteRTランタイムで確認します。
import ai_edge_litert.interpreter as interpreter_lib
interpreter = interpreter_lib.Interpreter(model_path="export_out/rfdetr-keypoint-preview_float16.tflite")
interpreter.allocate_tensors()
float32、float16のどちらのファイルでも、次のエラーで失敗しました。
RuntimeError: tflite/kernels/strided_slice.cc StridedSlice op only supports 1D-5D input arrays.
Node number 1356 (STRIDED_SLICE) failed to prepare.
CLIのログでは「成功」と表示されていたエクスポートが、実際には読み込めないファイルを生成していたことになります。原因を探るため、RoboflowのGitHubリポジトリでこの症状に関連するIssueとPull Requestを検索したところ、まさにこの問題を修正する未マージのPRが見つかりました3。
PRの説明によれば、変形可能注意機構(MSDeformAttn)内で参照点をスライスする処理が、ブロードキャスト後(6階テンソル)に対して行われており、TFLiteのネイティブStridedSliceカーネルが対応する1〜5階の上限を超えていました。デスクトップのtf.lite.Interpreter(フルTensorFlow版)はこの形を許容してしまうため、この問題は「standalone/mobile向けの軽量なLiteRTランタイム」で初めて表面化する、とPR本文に明記されています。
PRの説明が実際のエラーと本当に一致しているかを、憶測のままにせず確かめます。tensorflow.lite.python.schema_py_generatedを使い、.tfliteファイル自体をflatbufferとして直接解析し、エラーメッセージにあった「Node number 1356」の実体を調べました。
import tensorflow as tf
from tensorflow.lite.python import schema_py_generated as schema_fb
buf = open("export_out/rfdetr-keypoint-preview_float32.tflite", "rb").read()
model = schema_fb.Model.GetRootAsModel(buf, 0)
subgraph = model.Subgraphs(0)
op = subgraph.Operators(1356)
# opの入出力テンソルの形状を取得(詳細は省略)
結果、ノード1356はSTRIDED_SLICEで、入力形状[1, 100, 1, 1, 1, 4](6階テンソル)を[..., :2]でスライスし[1, 100, 1, 1, 1, 2]を出力していました。これは、PRが説明する「ブロードキャストで拡張された参照点(box座標4値)を、xy(先頭2つ)とwh(残り2つ)にスライスする」パターンと、形状、スライス位置の両方で一致します。グラフ全体を走査すると、STRIDED_SLICEは72個あるうち、入力が6階以上のものはちょうど4個(2箇所×xy/whの2スライス)で、PRが説明する箇所の数とも一致しました。
つまり、rfdetr 1.8.3の時点では、keypointモデルのTFLiteエクスポートはCLI上成功しても、軽量なLiteRTランタイムでは読み込めません。この原因はGitHub上の関連しそうなPRを見つけたという間接証拠だけでなく、実際に失敗したノードの入出力形状を直接確認し、PRの説明と形状レベルで一致することまで確かめました。修正PR自体は存在するが、検証時点ではまだリリースに取り込まれていません。
ブラウザのCPU/Wasmでは動く
ここまでの結果からは、ブラウザでの実行も同様に失敗すると予想するのが自然です。実際に試してみます。
静的なHTMLファイル1枚に、LiteRT.jsをjsdelivrのCDNからESモジュールとして直接読み込む構成にしました。ビルドツールは使いません。
<script type="module">
const { loadLiteRt, loadAndCompile } = await import(
"https://cdn.jsdelivr.net/npm/@litertjs/core@2.4.0/+esm"
);
await loadLiteRt("https://cdn.jsdelivr.net/npm/@litertjs/core@2.4.0/wasm/");
const model = await loadAndCompile("./model.tflite", { accelerator: "wasm" });
</script>
この構成にたどり着くまでに、2つのつまずきがありました。
1つ目は、@litertjs/coreをバージョン指定なしで読み込むと、後述の@litertjs/tfjs-interopが内部で依存する@litertjs/core(バージョン2.4.0)と、こちらが明示的に初期化した@litertjs/core(最新の2.5.2)が別インスタンスとして扱われ、Error: LiteRT is not initialized yetで失敗したことです。CDN経由でESモジュールを直接読み込む構成では、相互依存するパッケージのバージョンを揃える必要があります。上記のように両方を@2.4.0に固定して解消しました。
2つ目は、@tensorflow/tfjs-coreだけを読み込むとError: No backend found in registryで失敗することです。@tensorflow/tfjs-backend-cpuを追加で読み込み、tf.setBackend("cpu")を呼ぶ必要がありました。
この2点を解消したうえで、float16の.tflite(74.6MB)をCPU/XNNPACKバックエンドでロードすると、モデルは正常に読み込まれました。入力形状は[1, 576, 576, 3]、出力は3つのテンソル(labels: [1,100,2]、dets: [1,100,4]、keypoints: [1,100,34,8])でした。実際に画像を576×576にリサイズしてCanvas経由でテンソル化し、runWithTfjsTensorsで推論を実行したところ、約3.4秒(CPU/XNNPACKバックエンド、Playwrightのheadless Chromium)でエラーなく完走し、出力にNaNは含まれず、検出ボックスも画像内の人物として違和感のない座標でした。
Python側のai_edge_litert(2.1.2)では読み込めなかった同じファイルが、ブラウザの@litertjs/core(2.4.0)が内部で使うWASM版ランタイムでは問題なく動いたことになります。これは一見、前節で確認したPR #1144の説明(「standalone/mobile向けの軽量なLiteRTランタイムで失敗する」)と矛盾するように見えます。LiteRT.jsはまさにモバイルやブラウザ向けの軽量ランタイムを謳っているからです。
この矛盾は、後述するWebGPUバックエンドでの検証によって解消します。先に結論だけ書くと、LiteRT.js全体が緩いのではなく、LiteRT.jsの中でもCPU/XNNPACK(wasm)デリゲートは通るが、WebGPUデリゲートは同じrank-6のStridedSliceで失敗します。PRが言う「standalone/mobile向けランタイム」に近い挙動をするのはCPUデリゲートではなくGPUデリゲートの方でした。
不確実性の楕円をブラウザだけで再現する
ここまでで「動く」ことは確認できたが、RF-DETR keypointの見せ場は、各関節の不確実性を楕円で表現する機能にあります。これをブラウザだけで再現できるか、rfdetrのソースコードを読んで試しました。
rfdetr/models/postprocess.pyとrfdetr/utilities/keypoints.pyを確認すると、出力テンソルの構造が次のようにわかりました。
-
keypointsテンソルの34というスロット数は、「クラス数(背景+person = 2)× クラスあたり最大キーポイント数(17)」のクラスパディング構造です。先頭17スロットは背景クラス用のダミーで、実際のpersonキーポイントはスロット17〜33に入ります。 - 8チャンネルのうち、実際に使われるのはch0(x、正規化座標)、ch1(y、正規化座標)、ch2(信頼度のlogit、sigmoidを掛ける)、ch4〜6(precision-Choleskyパラメータ
log_l11、l21、log_l22)です。ch3とch7は今回確認した範囲のコードでは使われていませんでした。 - precision-Choleskyから2×2共分散行列への変換は閉形式で計算できます。
l11 = exp(log_l11)、l22 = exp(log_l22)とすると、det = (l11 × l22)²、cov00 = (l21² + l22²) / det、cov01 = -l11 × l21 / det、cov11 = l11² / detとなり、これを画像の幅と高さでピクセル座標系にスケールします。
この計算をそのままJavaScriptで実装しました。
const l11 = Math.exp(logL11), l22 = Math.exp(logL22);
const det = (l11 * l11) * (l22 * l22);
const cov00 = (l21 * l21 + l22 * l22) / det;
const cov01 = (-l11 * l21) / det;
const cov11 = (l11 * l11) / det;
const px00 = W * W * cov00, px01 = W * H * cov01, px11 = H * H * cov11;
// 2x2対称行列の閉形式固有値分解で楕円の向きと半軸長を求める
const tr = px00 + px11, diff = px00 - px11;
const disc = Math.sqrt(diff * diff / 4 + px01 * px01);
const angle = 0.5 * Math.atan2(2 * px01, diff);
const a = Math.sqrt(Math.max(tr / 2 + disc, 1e-6));
const b = Math.sqrt(Math.max(tr / 2 - disc, 1e-6));
公式デモの動画では、この楕円の内側から外側に向けて緑から赤へと色が変わるグラデーションで塗られています。同じ見た目にするため、canvasのcreateRadialGradientを使い、中心を緑に、外周を赤にした円をscale変換で楕円形に引き伸ばして塗りつぶす方式にしました。
ctx.save();
ctx.translate(px, py);
ctx.rotate(angle);
ctx.scale(a, b); // 半径1の円をscaleで楕円に変形する
const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
grad.addColorStop(0, "rgba(0,220,0,0.75)"); // 中心: 不確実性が小さい
grad.addColorStop(0.6, "rgba(220,220,0,0.5)");
grad.addColorStop(1, "rgba(230,0,0,0.15)"); // 外周: 不確実性が大きい
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(0, 0, 1, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
さらに、test_person_keypoints_ellipse.jpg(Python側の可視化)と同じように、隣接する関節を線でつないでスケルトンとして表示する処理と、楕円表示とポイントのみ表示をチェックボックスで切り替えられるUIも加えました。関節同士のつなぎ方はCOCOの17点で標準的に使われる19本のペアをそのまま使っています。
labels出力をsigmoidしてperson信頼度が0.5を超えるクエリを選び、そのクエリのスロット17〜33から17点分のx、y、信頼度、楕円を計算してcanvasに描画した結果が次の画像です。
チェックボックスを外すと、楕円を消してスケルトン線と点だけの表示に切り替わります。
比較のため、同じ考え方でPython側(supervision.KeyPointsが返すcovarianceをそのまま使う)で描画した結果も載せます。
どちらも、信頼度の高い顔まわりのキーポイントは緑寄りの小さい楕円に、腕や足先など不確実性の高い点は赤みが強く引き伸ばされた楕円になっており、公式デモが説明する挙動と一致します。サーバーを介さず、ブラウザ内のLiteRT.js推論だけでこの表現を再現できることを確認しました。
ただし、ブラウザ側で復元した座標や信頼度がPython側とピクセル単位でどこまで一致するかという定量的な比較は行っていません。今回確認したのは、見た目の傾向が一致することまでです。
WebGPUは動かない
ここまでの推論は、すべてCPU/XNNPACKバックエンド(accelerator: "wasm")で行っており、1回あたり約3.4〜3.9秒かかっています。もともとの目的はGPU(WebGPU)で高速に動かすことだったので、これを実際に計測しました。
最初に使っていたheadless Chromiumの環境ではNo GPU adapter foundとなりWebGPU自体が使えなかったため、Playwrightをheadless: false(画面を表示するモード)で起動し直しました。するとnavigator.gpu.requestAdapter()が実機のNVIDIA GPU(Lovelaceアーキテクチャ)を正しく返しました。この環境でaccelerator: "wasm"のまま5回連続推論を計測すると、モデルのロードとコンパイルに326.7ms、各推論は3751〜3903ms(平均約3.8秒)で安定していました。
続けてaccelerator: "webgpu"に変えて同じ手順を試したところ、推論どころかloadAndCompile自体が失敗しました。
ERROR: third_party/tensorflow/lite/kernels/strided_slice.cc StridedSlice op only supports 1D-5D input arrays.
ERROR: Node number 1868 (STRIDED_SLICE) failed to prepare.
これは「動かす3」でPython側のai_edge_litertが失敗したのとまったく同じ、rank-6のStridedSliceが1〜5階制限を超えるエラーです。つまり、LiteRT.jsは推論バックエンドによって明暗が分かれており、CPU/XNNPACKデリゲートは今回のモデルを問題なく実行できる一方、WebGPUデリゲートは同じ理由でロードにすら失敗します。
これで、前節で保留にした「PR #1144が言う“standalone/mobile向けランタイム”にLiteRT.jsが含まれるのか」という疑問に答えられます。standalone/mobile向けの挙動に近いのはCPUデリゲートではなくGPUデリゲートの方でした。
PR #1144を実際に当ててみる
ここまでの結果を見て、「PR #1144を実際に取り込めばWebGPUも動くのではないか」と考えました。差分はsrc/rfdetr/models/ops/modules/ms_deform_attn.py1ファイルだけで、参照点(reference_points)をブロードキャストの前にスライスするよう並べ替える、数学的に等価な書き換えです。GitHub API経由で差分を取得し、.venv312にインストール済みのrfdetrパッケージへ直接パッチとして適用しました。
elif reference_points.shape[-1] == 4:
+ reference_points_xy = reference_points[..., :2]
+ reference_points_wh = reference_points[..., 2:]
sampling_locations = (
- reference_points[:, :, None, :, None, :2]
- + sampling_offsets / self.n_points * reference_points[:, :, None, :, None, 2:] * 0.5
+ reference_points_xy[:, :, None, :, None, :]
+ + sampling_offsets / self.n_points * reference_points_wh[:, :, None, :, None, :] * 0.5
)
パッチ後にexport()を再実行し、TFLiteを作り直しました。Python側のai_edge_litertで読み込むと、今度はallocate_tensors()が成功しました。検証3で見つかった問題は、PR #1144の修正で実際に解消することを確認しました。
続けて、このパッチ後のモデルをブラウザのaccelerator: "webgpu"でロードすると、以前のStridedSliceエラーは出なくなり、loadAndCompile自体は488.8msで成功しました。ただしログには「58 operations will run on the GPU, and the remaining 3147 operations will run on the CPU」と表示され、大半の演算はGPUではなくCPUに回されていました。そして実際に推論を実行すると、今度は別のエラーで失敗しました。
TypeError: Cannot read properties of undefined (reading 'writeBuffer')
at _wgpuQueueWriteBuffer (...)
ログには他にも、SCATTER_ND: Operation is not supported.、RESHAPE: ...has bad input dims size: 6.、TRANSPOSE: ...has bad input dims size: 5.、Tensor type(INT64) is not supported.といった、WebGPUデリゲートが対応していない演算やデータ型の警告が多数出ていました。なお、パッチ後のモデルをCPU/Wasmで実行した場合は、速度も成功可否も変化はなく、既存の動作を壊してはいませんでした。
つまり、PR #1144が直すStridedSliceの問題は実在し、修正も確認できたが、それを直しただけではWebGPUは動きません。keypointモデルの構造(クラスごとにキーポイントをパディングするためのScatterND、多頭の変形可能注意機構が使う高階のテンソル操作)そのものが、現時点のLiteRT.jsのWebGPUデリゲートが対応する演算の範囲を大きく超えています。1つのバグ修正で解決するような話ではなく、WebGPUデリゲート側の対応が広がるのを待つか、エクスポートするモデルの構造自体をWebGPUに対応した演算だけで組み直す必要があります。
デモ画像とWebカメラを切り替える
ここまでは静止画1枚に対する推論だったが、実際のアプリとしては、手元のWebカメラでも試せた方が使いやすくなります。ラジオボタンで「デモ画像」と「Webカメラ」を切り替えられるようにしました。Webカメラを選ぶとnavigator.mediaDevices.getUserMedia()で映像を取得し、<video>要素に流し込んで、フレームを1枚取得しては推論する処理を繰り返します。
async function startWebcam() {
webcamActive = true;
stream = await navigator.mediaDevices.getUserMedia({ video: { width: W, height: H } });
video.srcObject = stream;
await new Promise((res) => { video.onloadedmetadata = res; });
while (webcamActive) {
await inferOnce(video);
await new Promise((res) => setTimeout(res, 0));
}
}
1回の推論に約4秒かかるため、動画のようになめらかには動かず、数秒おきに姿勢が更新される表示になります。
実機のカメラを持たない検証環境だったため、Chromiumの--use-fake-device-for-media-stream(合成テストパターンを返す疑似カメラ)をPlaywright経由で使い、getUserMediaからフレームごとの推論までの一連の流れが実際に動くことを確認しました。
この実装で、もう1つのつまずきがありました。Webカメラモードに切り替えた後、「デモ画像」のラジオボタンをクリックしても、ページが反応しなくなる現象が起きました。原因は、上のコードでawait new Promise((res) => setTimeout(res, 0));を入れる前の版にあります。while (webcamActive) { await inferOnce(video); }のようにマクロタスクへの明示的な譲歩なしにawaitだけを繰り返すループは、Promiseの解決がマイクロタスクの範囲で完結してしまい、ブラウザがクリックのようなUIイベント(マクロタスク)を処理する隙を作りません。setTimeoutを挟んで明示的にマクロタスクへ処理を戻すことで、ループを回しながらでもラジオボタンの切り替えが効くようになりました。ブラウザ内で推論をタイトなループで回す実装全般に共通する落とし穴だと考えられます。
LiteRT.js経由でのここまでの結論をまとめます。CPU/Wasmでは動くが、1推論あたり約4秒かかります。WebGPUは、rank-6 StridedSliceのバグを実際に修正しても、ScatterNDやINT64といった別の非対応に阻まれて動きません。目的は「サーバーを軽量にし、計算はブラウザ側、できればGPUにやらせる」ことだったので、GPUが使えないままではこの目的を半分しか達成できていません。
動かす4:LiteRT.jsを諦め、onnxruntime-web + WebGPUに切り替える
WebGPUデリゲートが対応する演算の範囲を広げるのを待つか、モデルの構造自体を変えるかしない限り、LiteRT.js経由でのWebGPU利用には見通しが立ちません。そこで、TFLiteへの変換自体をやめ、RF-DETRがPyTorchから直接書き出せる.onnxファイルを、Microsoftのonnxruntime-webでそのまま実行する構成に切り替えました。
onnxruntime-webも、LiteRT.jsと同じくCDN経由のESモジュールとしてビルドツールなしに読み込めます。「サーバーは静的ファイルを配るだけ、計算はブラウザ側」という方針そのものは変えていません。変えたのは、モデルのファイル形式(.tfliteから.onnxへ)と、それを読み込むランタイムだけです。
<script type="module">
const ort = await import(
"https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/ort.webgpu.min.mjs"
);
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/";
const session = await ort.InferenceSession.create("./model.onnx", {
executionProviders: ["webgpu"],
});
</script>
使うONNXファイルは、rfdetrのexport()がTFLiteへの変換前に生成する中間ファイル(export_out/rfdetr-keypoint-preview.onnx、約147MB)そのものです。PR #1144のパッチは当てていません。ここではまずローカルのサーバーで./model.onnxを同一オリジンから配信して検証しており、実際にGitHub Pagesへ公開する際にはモデルの読み込み方法を変える必要がありました。その経緯は後述の「GitHub Pagesでの配布構成」にまとめます。「動かす3」で問題になったrank-6のStridedSliceは、TFLiteのネイティブカーネルが課す1〜5階という制限であり、ONNXのSlice演算やonnxruntimeのカーネルには同じ制限がありません。つまり、TFLiteへの変換を経由しなければ、そもそもこの種の変換時の非互換に遭遇しません。
まずPython側のonnxruntime(CPU)で、このONNXファイルが動くことを確認しました。
import onnxruntime as ort
import numpy as np
from PIL import Image
img = Image.open("test_person.jpg").convert("RGB").resize((576, 576))
arr = np.asarray(img).astype(np.float32) / 255.0
arr = arr.transpose(2, 0, 1)[None] # NCHW
sess = ort.InferenceSession("export_out/rfdetr-keypoint-preview.onnx", providers=["CPUExecutionProvider"])
outs = sess.run(None, {"input": arr})
出力のdets[0](先頭クエリのボックス)は[0.1796, 0.6034, 0.2400, 0.4692]で、「動かす3」でLiteRT.js(ブラウザ)から得た値([0.179, 0.603, 0.240, 0.470])とほぼ一致しました。ONNXへの書き出しと、そこからのTFLite変換のどちらも、モデルの計算内容自体は変えていないことの裏付けになります。
ブラウザでは、CPU(executionProviders: ["wasm"])とWebGPU(executionProviders: ["webgpu"])の両方を試しました。CPUでの推論は3回とも4657〜4681ms(平均約4.67秒)で、LiteRT.jsのCPU/Wasm(約3.8秒)と近い値でした。出力値もPythonとLiteRT.jsの双方と一致しました。
WebGPUに切り替えると、StridedSliceのようなエラーは一切出ずに動きました。1回目の推論は2380ms(WebGPUのシェーダコンパイル待ちを含む)だが、2回目以降は345ms、264msと下がり、定常状態で約250〜300msに落ち着きました。CPU実行(約4.67秒)と比べて、15倍前後速いという結果です。
不確実性楕円のデコード処理は、「動かす3」でLiteRT.js向けに書いたものをそのまま使えました。出力テンソルの構造(34スロットのクラスパディング、8チャンネルの意味、precision-Choleskyから共分散への変換)はモデル自体の出力形式であり、ランタイムを変えても変わりません。変わったのは入力テンソルの並び順で、ONNXの入力は[1, 3, 576, 576](NCHW)、TFLite側は[1, 576, 576, 3](NHWC)でした。
デモ画像とWebカメラの切り替え、楕円表示のオンとオフも、LiteRT.js版と同じ構成でそのまま移植しました。ただし、ここでもう1つのつまずきがありました。Webカメラモードで推論ループを回している最中に「デモ画像」に切り戻すと、Session already startedというエラーで失敗することがありました。LiteRT.js版で対策したはずのマクロタスク譲歩(setTimeout)を入れていたにもかかわらず起きた現象で、原因は次のとおりです。stopWebcam()はフラグを立てるだけで、ループが今まさに実行中のsession.run()の完了を待ちません。WebGPUは1回あたり約0.3秒と速いため、フラグを立てた直後にもう次のモードのinferOnce(img)が呼ばれ、同じsessionに対して2つのrun()が同時に走ってしまいます。LiteRT.js版(1回約4秒)ではテストの時間内にこの競合が起きなかっただけでした。stopWebcam()をasyncにし、直前のループのPromiseをawaitしてから次のモードに進むよう修正して解消しました。
let webcamLoopPromise = Promise.resolve();
async function stopWebcam() {
webcamActive = false;
await webcamLoopPromise; // ループの現在の反復が終わるまで待つ
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
}
修正後の状態で、デモ画像、Webカメラ、楕円表示の切り替えをすべて確認した結果が次の画像です。
とりあえず無事に動いてよかった!!!!!
推論が速くなったことで、それまで潜在していた非同期処理の不備が表面化した、という点は記録しておく価値があります。速いランタイムに切り替えるほど、こうした競合には注意が必要になります。
GitHub Pagesでの配布構成
ここまでの構成であれば、サーバー側に必要なのはindex.htmlとモデルファイルを配信することだけであり、GitHub Pagesでそのまま完結する、はずでした。実際にデプロイしてみると、モデルファイルのサイズ(約147MB)そのものが壁になりました。
GitHubは1ファイル100MBを超えるとリポジトリへの直接コミットを拒否します。ONNXファイルをそのままgit pushすることはできません。最初に試したのはGitHub Releasesです。Releasesはアセットとして2GBまでのファイルを扱えるため、モデルをリリースアセットとしてアップロードし、index.htmlからfetchする構成にしました。
const session = await ort.InferenceSession.create(
"https://github.com/<user>/<repo>/releases/download/v1.0.0/model.onnx",
{ executionProviders: ["webgpu"] },
);
ところが、これは実際にデプロイしたブラウザで動かすと失敗します。
Access to fetch at 'https://github.com/.../releases/download/...'
from origin 'https://<user>.github.io' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
github.com/.../releases/download/...のURLは実体(release-assets.githubusercontent.com)への302リダイレクトを返すが、このリダイレクト応答自体にAccess-Control-Allow-Originヘッダーが付いていません。ブラウザのfetchはリダイレクトの各ホップでCORSを検証するため、最初のホップで止まってしまいます。curlで直接アクセスする分には問題なく、ローカルのPlaywright検証(モデルを同一オリジンで配信していた)でも再現しませんでした。実際に公開環境へデプロイして初めて表面化した問題です。
次に、ファイルサイズをGitの制限内(100MB未満)に収めて同一オリジンで配信すれば、CORS自体が問題にならないと考え、ONNXのfloat16量子化を試しました。onnxconverter-commonのconvert_float_to_float16で変換すると、ファイルサイズは74MBまで縮んだが、生成されたONNXはonnxruntimeで読み込めませんでした。
Type Error: Type (tensor(float16)) of output arg (/Gather_33_output_0) of node (/Gather_33)
does not match expected type (tensor(float)).
Gatherノードの出力型が変換後も一致しません。shape情報を作り直してから変換する、GatherやScatterNDを変換対象から除外する、といった対策を試しましたが、いずれも同じノードで型不整合が解消しませんでした。RF-DETR keypointモデルの構造(クラスパディング用のScatterNDなど、ここまでの検証で何度も壁になってきたのと同じ部分)が、自動的なfloat16変換とも相性が悪いと考えられます。深追いはせず、float32のまま配布する方針に切り替えました。
最終的に採用したのは、fetchによるダウンロードをやめ、ブラウザの通常のダウンロード機能とファイル選択(<input type="file">)を組み合わせる方法です。
<a
href="https://github.com/<user>/<repo>/releases/download/v1.0.0/model.onnx"
download
>
model.onnx をダウンロード
</a>
<input type="file" id="modelFile" accept=".onnx" />
modelFileInput.addEventListener("change", async () => {
const file = modelFileInput.files[0];
const modelBytes = new Uint8Array(await file.arrayBuffer());
const session = await ort.InferenceSession.create(modelBytes, {
executionProviders: ["webgpu"],
});
// ...
});
<a download>によるファイルダウンロードはブラウザのナビゲーション機能であり、fetchのようなスクリプトからの読み取りではないため、CORSの制約を受けません。ダウンロードしたファイルは利用者が明示的に選択し、File.arrayBuffer()で読み取ったバイト列をそのままInferenceSession.create()に渡します。onnxruntime-webはURLだけでなくUint8Arrayからもモデルを読み込めます。この方式であれば、外部のホスティングサービスを新たに用意する必要はなく、GitHub(Pages本体とReleases)だけで完結します。ただし「サイトを訪れるだけで動く」体験ではなくなり、利用者に「ダウンロードしてから選択する」という2手間を追加で求めることにはなります。
最終的なリポジトリの構成は次のようになります。
repo/
├── index.html # 推論、描画、ファイル選択のロジックをすべてここに書く
└── test_person.jpg # デモ用画像
モデル本体(model.onnx、約147MB)はリポジトリには含めず、同じリポジトリのGitHub Releasesにアセットとして置いています。npm installやバンドラの設定は不要で、onnxruntime-webはCDNから<script type="module">の中でimportするだけでよいです。
実機のブラウザで試して、さらに3つの問題が見つかった
Playwrightによる検証は、headless(または疑似カメラを使ったheaded)Chromiumというコントロールされた環境で行っています。公開したデモを実際のノートPC(RTX 4060 Laptop GPU、Chrome最新版)で開いたところ、Playwrightの検証では出てこなかった問題が3つ見つかりました。
1つ目は、WebGPUが有効にならないケースがあることです。このノートPCは物理的にRTX 4060 Laptop GPUを積んでいるにもかかわらず、chrome://gpuを確認すると「WebGPU: Software only, hardware acceleration unavailable」と表示されていました。原因はGPUドライバのブロックリスト判定など、ブラウザ側の判断によるもので、GPUの有無だけでは決まりません。この場合、index.htmlに組み込んだフォールバック処理が働き、CPU/Wasm実行(1推論あたり約4.7秒)に自動で切り替わりました。動作自体は想定どおりですが、「WebGPUで約250〜300ms」という速度は、GPUを積んだ実機であっても保証されるものではありません。読者が自分の環境で試す場合は、chrome://gpuでWebGPUの行を確認してみてください。
2つ目は、Webカメラの映像が真っ黒になる現象です。<video>要素にautoplay属性を付けていましたが、実機のブラウザではvideo.pausedがtrueのまま、つまり実際には再生が始まっていませんでした。Canvasから直接ピクセル値を読み出して確認したところ、値がすべて0でした。
// video.srcObject = stream; のあと
await new Promise((res) => {
video.onloadeddata = res;
});
await video.play(); // これを呼ばないと、autoplay属性だけでは再生が始まらない場合がある
video.play()を明示的に呼ぶことで解消しました。合わせて、ノートPCではWindows Hello用の赤外線(IR)カメラが別デバイスとして存在することが多く、getUserMediaがそちらを選んでしまうと(IRカメラは可視光の映像を返さないため)映像が映らないことがあります。facingMode: 'user'で通常のカメラを優先しつつ、複数のカメラが見つかった場合は選び直せるドロップダウンも追加しました。
3つ目は、WebGPUが実際に有効になった環境で、Webカメラのキーポイント表示がちらつく現象です。ユーザーからのログでは推論は1回あたり約240〜310ms(WebGPUの速度)で安定して成功していたにもかかわらず、「ほとんどのフレームでキーポイントが表示されていないように見える」という報告でした。原因はinferOnce()の実装にありました。
async function inferOnce(sourceEl) {
ctx.drawImage(sourceEl, 0, 0, W, H); // (A) 表示用canvasに生フレームを描く
const { data } = ctx.getImageData(0, 0, W, H);
// ...(入力テンソルの構築)...
const results = await session.run({ input: inputTensor }); // (B) ここで約250〜300ms待つ
// ...(キーポイントのデコード)...
redraw(toggle.checked); // (C) キーポイント付きで再描画
}
(A)の時点で、表示用の<canvas>にはまだキーポイントの乗っていない生フレームが描かれます。WebGPUのsession.run()は実際にGPUへ処理を投げて結果を待つため、(B)の待ち時間中はJavaScriptのメインスレッドが空きます。ブラウザはこの空いた時間に画面の再描画(ペイント)を行うので、(A)で描いた「キーポイントの無い状態」がそのまま約250〜300ms間、画面に表示され続けます。その後(C)でキーポイント付きの状態に切り替わりますが、これは次のフレームの(A)がすぐに上書きするまでの、ごく短い間しか続きません。結果として、1サイクルのうち大半の時間はキーポイントが見えず、ごく一瞬だけ見える、という「ちらつき」になっていました。
この挙動は、CPU/Wasm実行(1回あたり約4.7秒で、WebAssemblyの実行がメインスレッドを長く占有し続ける)では気づきにくくなります。ちらつきの周期がその分だけ長くなるだけで、ちらつき自体は原理上起こるはずですが、GPUに処理が渡りメインスレッドが速やかに空くWebGPU実行のほうが、(A)の「無地の状態」がブラウザに描画される頻度と長さの両面で目立ちやすいと考えられます。実際、この報告はWebGPUが有効な環境からのものでした。
修正は、表示用の<canvas>をredraw()以外の場所では一切触らないようにすることでした。推論用の入力フレームは、画面には出さない別のオフスクリーンcanvasに描いて確保し、表示用canvasの更新は「画像とキーポイントをセットで描き終えたとき」だけに限定します。
// 表示用canvasはredraw()以外では触らない
const captureCanvas = document.createElement("canvas");
captureCanvas.width = W;
captureCanvas.height = H;
const captureCtx = captureCanvas.getContext("2d", { willReadFrequently: true });
async function inferOnce(sourceEl) {
captureCtx.drawImage(sourceEl, 0, 0, W, H); // オフスクリーンに凍結
const { data } = captureCtx.getImageData(0, 0, W, H);
// ...
const results = await session.run({ input: inputTensor });
// ...
lastFrame = captureCanvas; // 凍結したフレームを使う(ライブの video ではなく)
redraw(toggle.checked);
}
これにより、表示用canvasの内容が変わるのは「画像+キーポイントの組」が揃った瞬間だけになり、無地のフレームが単独で画面にペイントされる余地がなくなります。副次的な効果として、キーポイントの位置が常に「そのキーポイントを計算した元の画像」と一致するようになりました(修正前は、redraw()がその時点の<video>の最新フレームを描き直していたため、動きの速い場面では画像とキーポイントの位置がわずかにずれることもありました)。
いずれも、Playwrightによる自動検証(疑似カメラでの動作確認や、GPUパススルーのある環境での計測)だけでは見つかりませんでした。実機の、実際のブラウザで、実際に触って確かめる工程を省略しないことの大切さを、この記事の検証全体を通じて最後にもう一度思い知らされました。
まとめ:LiteRT.jsからonnxruntime-webへ、方針は変えずにランタイムを変えた
やりたかったことは最初から一つで、サーバーは静的ファイルを配るだけにし、姿勢推定の計算はブラウザ側(できればGPU)にやらせることでした。この方針自体は最後まで変えていません。変えたのは、その方針を実現する手段です。
LiteRT.js経由の構成は、2026年7月10日時点で、CPU/Wasmでは実際に動きました。ただしこれは、keypointモデル自体がプレビュー機能であること、TFLiteエクスポートが実験的機能であること、エクスポートしたファイルが標準的なPython用ランタイムでは読み込めないバグを抱えていること(PR #1144で修正は確認できたが未マージ)、そのバグを直してもなおWebGPUバックエンドは動かないことが積み重なった上での「CPUでだけ動く」という結果でした。1推論あたり約4秒という速度のまま本番のプロダクトに使うのは、私は現時点では時期尚早だと考えています。
一方、onnxruntime-web + WebGPUへ切り替えた構成は、TFLiteへの変換自体を経由しないため、rank-6 StridedSliceのような変換時の非互換にそもそも遭遇せず、GPUで約250〜300ms(CPU比で15倍前後速い)という、実用に近い速度で動きました。同じ「サーバーは軽量、計算はクライアント」という構成のまま、ランタイムをGoogleのLiteRT.js(TFLite向け)からMicrosoftのonnxruntime-web(ONNX向け)に切り替えるだけで、この結果が得られたことになります。個人の検証や実験用途はもちろん、この速度であれば、もう少し実用に近い用途にも足を伸ばせると考えています。
実際にGitHub Pagesへデプロイしてみると、ローカルでの検証だけでは見えなかった問題(モデルファイルのサイズとCORS)にも遭遇しました。この経緯は「GitHub Pagesでの配布構成」に書いたとおりで、最終的にはfetchをやめてブラウザの通常のダウンロードとファイル選択を組み合わせる方法に落ち着きました。ローカルでの動作確認と、実際に公開してからの動作確認は別物であり、両方を行って初めて「動く」と言えることを、この検証を通じて改めて確認した形になります。
この検証にかかった費用は0円です。すべてローカルの計算資源(GPU搭載のWindows機)で完結し、課金の発生するクラウドリソースは使っていません。
最後に、つまずいた点を一覧にしておきます。
- RF-DETR keypointモデルの初期化時に「重みが部分的にしかロードされていない」警告が出ます(未解消。ただし可視化結果は妥当でした)
-
pip install "rfdetr[tflite]"は、Python 3.10環境では何も追加インストールせず、後続のエクスポートが原因不明のまま失敗します(TFLite関連の依存がPython 3.12専用に固定されているため) - エクスポートしたTFLiteファイルは、CLIが成功と表示していても、Python用の軽量なLiteRTランタイムでは読み込めないことがあります(rf-detr側の既知のバグ。PR #1144を実際に適用して修正を確認したが、記事執筆時点で未マージ)
- CDN経由でLiteRT.js関連のパッケージをESモジュールとして読み込む場合、相互依存するパッケージのバージョンを揃えないと初期化に失敗します
- ブラウザ内推論をマクロタスクへの譲歩なしにタイトなループで回すと、UIイベント(クリックなど)がいつまでも処理されずページが無反応になります
- WebGPUバックエンドは、Python側の
ai_edge_litertと同じrank-6のStridedSliceエラーでloadAndCompile自体が失敗します。PR #1144を適用してこの問題を解消しても、ScatterND未対応やINT64非対応など別の壁があり、依然として動きません(CPU/XNNPACKバックエンドだけが今回のモデルを実行できました) - GitHub Releasesのアセットは、ブラウザの
fetchからはAccess-Control-Allow-OriginヘッダーがなくCORSでブロックされます(github.comのリダイレクト応答自体にヘッダーが付いていないため)。curlや通常のダウンロードリンクでは問題なく、ローカル検証でも同一オリジン配信だったため再現しませんでした -
onnxconverter-commonによるONNXのfloat16変換は、RF-DETR keypointモデルではGatherノードの出力型が変換後も一致しないエラーで失敗します。shape再計算や対象演算の除外を試したが解消しませんでした - onnxruntime-webでは、ONNXの入力レイアウトがTFLiteと異なります(
[1,3,H,W]のNCHW。TFLite側は[1,H,W,3]のNHWC)。Canvasから取得したピクセルデータを並べ替える処理を書く必要があります - WebGPU実行は1推論あたり約250〜300msまで速くなる一方、それによって「デモ画像/Webカメラ切り替え」のような非同期処理の不備(前の推論の完了を待たずに次のモードへ進んでしまう競合)が表面化しやすくなります。ランタイムを速くするほど、こうした競合状態には注意が必要になります
- GPUを搭載した実機であっても、WebGPUのハードウェアアクセラレーションが無効なことがあります(
chrome://gpuで確認できます)。原因はブラウザ側のドライバブロックリスト判定などで、こちらのコードでは制御できません。CPU/Wasmへのフォールバックが正しく働くかどうかが、GPUの速度そのものより重要になります -
<video>要素のautoplay属性だけでは、実機のブラウザで再生が始まらないことがあります(video.pausedがtrueのまま)。video.play()を明示的に呼んで解消しました。ノートPCではWindows Hello用のIRカメラが誤って選ばれることもあるため、カメラを選び直せるドロップダウンも合わせて用意しました - Webカメラのキーポイント表示がちらつく(ほとんどのフレームで見えない)。原因は、表示用canvasに推論前の生フレームを直接描いていたため、WebGPUの
session.run()がメインスレッドを解放する約250〜300msの間、キーポイントの無い状態がそのまま画面に描画され続けていたことです。推論用の入力フレームを別のオフスクリーンcanvasに凍結し、表示用canvasは画像とキーポイントが揃った瞬間にしか更新しないよう変更して解消しました
検証に使ったコード全文(LiteRT.js版、CPU/Wasmのみ動作)
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>LiteRT.js keypoint test</title>
</head>
<body>
<div>
<label
><input type="radio" name="source" value="image" checked />
デモ画像</label
>
<label
><input type="radio" name="source" value="webcam" /> Webカメラ</label
>
<label style="margin-left:1em"
><input type="checkbox" id="toggleEllipse" checked />
不確実性の楕円を表示(オフでポイントのみ)</label
>
</div>
<canvas id="canvas" width="576" height="576"></canvas>
<img
id="img"
src="./test_person.jpg"
style="display:none"
crossorigin="anonymous"
/>
<video
id="video"
autoplay
playsinline
muted
style="display:none"
width="576"
height="576"
></video>
<div id="status">loading...</div>
<script type="module">
const statusEl = document.getElementById("status");
function log(msg) {
console.log(msg);
statusEl.textContent += "\n" + msg;
}
// COCO 17点の標準スケルトン(隣接関節のペア)
const SKELETON = [
[15, 13],
[13, 11],
[16, 14],
[14, 12],
[11, 12],
[5, 11],
[6, 12],
[5, 6],
[5, 7],
[6, 8],
[7, 9],
[8, 10],
[1, 2],
[0, 1],
[0, 2],
[1, 3],
[2, 4],
[3, 5],
[4, 6],
];
function drawEllipse(ctx, px, py, angle, a, b) {
ctx.save();
ctx.translate(px, py);
ctx.rotate(angle);
ctx.scale(Math.max(a, 1e-3), Math.max(b, 1e-3));
// 中心(不確実性が小さい)を緑、外周(不確実性が大きい)を赤にするグラデーション
const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
grad.addColorStop(0, "rgba(0,220,0,0.75)");
grad.addColorStop(0.6, "rgba(220,220,0,0.5)");
grad.addColorStop(1, "rgba(230,0,0,0.15)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(0, 0, 1, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
try {
const { loadLiteRt, loadAndCompile } =
await import("https://cdn.jsdelivr.net/npm/@litertjs/core@2.4.0/+esm");
const tf =
await import("https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core/+esm");
await import("https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-cpu/+esm");
const { runWithTfjsTensors } =
await import("https://cdn.jsdelivr.net/npm/@litertjs/tfjs-interop@6.0.0/+esm");
await tf.setBackend("cpu");
await tf.ready();
log("modules loaded, tfjs backend=" + tf.getBackend());
await loadLiteRt(
"https://cdn.jsdelivr.net/npm/@litertjs/core@2.4.0/wasm/",
);
log("wasm runtime initialized");
const model = await loadAndCompile("./model.tflite", {
accelerator: "wasm",
});
log("MODEL LOADED OK");
const img = document.getElementById("img");
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
if (img.complete) res();
});
const video = document.getElementById("video");
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const W = 576,
H = 576;
const numSlots = 34,
numCh = 8;
const classOffset = 1 * 17; // schema [0,17]: slot0=背景, slot1=person
const sigmoid = (x) => 1 / (1 + Math.exp(-x));
const THRESH = 0.5;
let decoded = [];
let lastSource = img;
// 画像/動画フレーム1枚に対して推論し、decodedを更新してredrawする
async function inferOnce(sourceEl) {
ctx.drawImage(sourceEl, 0, 0, W, H);
const imageData = ctx.getImageData(0, 0, W, H);
const inputTensor = tf.tidy(() => {
let t = tf.browser.fromPixels(imageData);
t = tf.cast(t, "float32");
t = tf.div(t, 255.0);
return tf.expandDims(t, 0);
});
const start = performance.now();
const outputs = await runWithTfjsTensors(model, [inputTensor]);
const elapsed = performance.now() - start;
const detsOut = outputs.find(
(o) => o.shape.length === 3 && o.shape[2] === 4,
);
const labelsOut = outputs.find(
(o) => o.shape.length === 3 && o.shape[2] === 2,
);
const kpOut = outputs.find((o) => o.shape.length === 4);
const dets = await detsOut.data();
const labels = await labelsOut.data();
const kp = await kpOut.data();
const scored = [];
for (let q = 0; q < 100; q++) {
scored.push({ q, score: sigmoid(labels[q * 2 + 1]) });
}
scored.sort((a, b) => b.score - a.score);
const picked = scored.filter((s) => s.score > THRESH).slice(0, 10);
decoded = picked.map(({ q, score }) => {
const box = {
cx: dets[q * 4 + 0] * W,
cy: dets[q * 4 + 1] * H,
w: dets[q * 4 + 2] * W,
h: dets[q * 4 + 3] * H,
};
const points = [];
for (let k = 0; k < 17; k++) {
const base = (q * numSlots + (classOffset + k)) * numCh;
const xFrac = kp[base + 0],
yFrac = kp[base + 1];
const confLogit = kp[base + 2];
const logL11 = kp[base + 4],
l21 = kp[base + 5],
logL22 = kp[base + 6];
const px = xFrac * W,
py = yFrac * H;
const conf = sigmoid(confLogit);
const l11 = Math.exp(logL11),
l22 = Math.exp(logL22);
const det = l11 * l11 * (l22 * l22);
const cov00 = (l21 * l21 + l22 * l22) / det;
const cov01 = (-l11 * l21) / det;
const cov11 = (l11 * l11) / det;
const px00 = W * W * cov00,
px01 = W * H * cov01,
px11 = H * H * cov11;
const tr = px00 + px11,
diff = px00 - px11;
const disc = Math.sqrt((diff * diff) / 4 + px01 * px01);
const angle = 0.5 * Math.atan2(2 * px01, diff);
const a = Math.sqrt(Math.max(tr / 2 + disc, 1e-6));
const b = Math.sqrt(Math.max(tr / 2 - disc, 1e-6));
points.push({ px, py, conf, angle, a, b });
}
return { q, score, box, points };
});
lastSource = sourceEl;
redraw(toggle.checked);
log(
"infer ok, elapsed ms=" +
elapsed.toFixed(1) +
", detections=" +
decoded.length,
);
}
function redraw(showEllipses) {
ctx.clearRect(0, 0, W, H);
ctx.drawImage(lastSource, 0, 0, W, H);
for (const { box, points } of decoded) {
ctx.strokeStyle = "lime";
ctx.lineWidth = 2;
ctx.strokeRect(
box.cx - box.w / 2,
box.cy - box.h / 2,
box.w,
box.h,
);
// スケルトン線(隣接関節を緑の線でつなぐ)
ctx.strokeStyle = "rgba(0,255,0,0.9)";
ctx.lineWidth = 2;
for (const [i, j] of SKELETON) {
ctx.beginPath();
ctx.moveTo(points[i].px, points[i].py);
ctx.lineTo(points[j].px, points[j].py);
ctx.stroke();
}
for (const p of points) {
if (showEllipses) drawEllipse(ctx, p.px, p.py, p.angle, p.a, p.b);
ctx.fillStyle = "red";
ctx.fillRect(p.px - 1.5, p.py - 1.5, 3, 3);
}
}
}
const toggle = document.getElementById("toggleEllipse");
toggle.addEventListener("change", () => redraw(toggle.checked));
// Webカメラ用のフレームループ。推論(数秒)が終わるたびに次のフレームを取り直す。
// 30fpsのようなリアルタイム性はなく、数秒おきの更新になる。
let webcamActive = false;
let stream = null;
async function startWebcam() {
webcamActive = true;
stream = await navigator.mediaDevices.getUserMedia({
video: { width: W, height: H },
});
video.srcObject = stream;
await new Promise((res) => {
video.onloadedmetadata = res;
});
log(
"webcam stream started: " +
video.videoWidth +
"x" +
video.videoHeight,
);
while (webcamActive) {
await inferOnce(video);
// setTimeoutでマクロタスクへ明示的に譲歩する。これがないと、推論のPromiseが
// マイクロタスクだけで解決され続け、ラジオボタンのクリックなどのUIイベントが
// いつまでも処理されずページが無反応になる。
await new Promise((res) => setTimeout(res, 0));
}
}
function stopWebcam() {
webcamActive = false;
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
}
const sourceRadios = document.querySelectorAll('input[name="source"]');
for (const radio of sourceRadios) {
radio.addEventListener("change", async () => {
if (radio.value === "webcam" && radio.checked) {
stopWebcam();
try {
await startWebcam();
} catch (e) {
log(
"WEBCAM ERROR: " + (e && e.message ? e.message : String(e)),
);
}
} else if (radio.value === "image" && radio.checked) {
stopWebcam();
await inferOnce(img);
}
});
}
// 初期状態はデモ画像で1回推論する
await inferOnce(img);
log("DONE");
} catch (e) {
log("ERROR: " + (e && e.message ? e.message : String(e)));
log("STACK: " + (e && e.stack ? e.stack : "no stack"));
}
</script>
</body>
</html>
検証に使ったコード全文(onnxruntime-web版、実際にデプロイしたもの)
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>RF-DETR keypoint + onnxruntime-web (WebGPU)</title>
</head>
<body>
<div id="loadStep">
<p>1. モデルファイル(約147MB、ONNX形式)をダウンロードしてください。</p>
<p>
<a
href="https://github.com/ys-dirard/rf-detr-keypoint-web/releases/download/v1.0.0/rfdetr-keypoint-preview.onnx"
download
>rfdetr-keypoint-preview.onnx をダウンロード</a
>
</p>
<p>
2.
ダウンロードしたファイルを選択してください(ブラウザだけで読み込み、どこにも送信しません)。
</p>
<p><input type="file" id="modelFile" accept=".onnx" /></p>
</div>
<div id="app" style="display:none">
<div>
<label
><input type="radio" name="source" value="image" checked />
デモ画像</label
>
<label
><input type="radio" name="source" value="webcam" /> Webカメラ</label
>
<label style="margin-left:1em"
><input type="checkbox" id="toggleEllipse" checked />
不確実性の楕円を表示(オフでポイントのみ)</label
>
<label style="margin-left:1em; display:none" id="cameraSelectWrap"
>カメラ:
<select id="cameraSelect"></select
></label>
</div>
<canvas id="canvas" width="576" height="576"></canvas>
<img
id="img"
src="./test_person.jpg"
style="display:none"
crossorigin="anonymous"
/>
<video
id="video"
autoplay
playsinline
muted
style="position:absolute; left:-9999px; top:-9999px"
width="576"
height="576"
></video>
</div>
<div id="status">モデルファイルを選択してください。</div>
<script type="module">
const statusEl = document.getElementById("status");
function log(msg) {
console.log(msg);
statusEl.textContent += "\n" + msg;
}
const SKELETON = [
[15, 13],
[13, 11],
[16, 14],
[14, 12],
[11, 12],
[5, 11],
[6, 12],
[5, 6],
[5, 7],
[6, 8],
[7, 9],
[8, 10],
[1, 2],
[0, 1],
[0, 2],
[1, 3],
[2, 4],
[3, 5],
[4, 6],
];
function drawEllipse(ctx, px, py, angle, a, b) {
ctx.save();
ctx.translate(px, py);
ctx.rotate(angle);
ctx.scale(Math.max(a, 1e-3), Math.max(b, 1e-3));
const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
grad.addColorStop(0, "rgba(0,220,0,0.75)");
grad.addColorStop(0.6, "rgba(220,220,0,0.5)");
grad.addColorStop(1, "rgba(230,0,0,0.15)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(0, 0, 1, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
const ort =
await import("https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/ort.webgpu.min.mjs");
ort.env.wasm.wasmPaths =
"https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/";
const modelFileInput = document.getElementById("modelFile");
modelFileInput.addEventListener("change", async () => {
const file = modelFileInput.files[0];
if (!file) return;
try {
await runApp(file);
} catch (e) {
log("ERROR: " + (e && e.message ? e.message : String(e)));
log("STACK: " + (e && e.stack ? e.stack : "no stack"));
}
});
async function runApp(file) {
log(
"loaded onnxruntime-web, reading selected file (" +
(file.size / 1e6).toFixed(1) +
"MB)...",
);
const modelBytes = new Uint8Array(await file.arrayBuffer());
const loadStart = performance.now();
let session;
try {
session = await ort.InferenceSession.create(modelBytes, {
executionProviders: ["webgpu"],
});
log(
"session created (webgpu), ms=" +
(performance.now() - loadStart).toFixed(1),
);
} catch (e) {
log(
"webgpu unavailable, falling back to wasm: " +
(e && e.message ? e.message : String(e)),
);
session = await ort.InferenceSession.create(modelBytes, {
executionProviders: ["wasm"],
});
log(
"session created (wasm), ms=" +
(performance.now() - loadStart).toFixed(1),
);
}
document.getElementById("loadStep").style.display = "none";
document.getElementById("app").style.display = "";
const img = document.getElementById("img");
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
if (img.complete) res();
});
const video = document.getElementById("video");
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const W = 576,
H = 576;
// 表示用canvasはredraw()以外では絶対に描かない。推論中(session.run()の待ち時間)に
// キーポイントの無い生フレームだけが表示され続ける「ちらつき」を防ぐため、
// 推論に使うフレームはこの非表示canvasに凍結してから読む。
const captureCanvas = document.createElement("canvas");
captureCanvas.width = W;
captureCanvas.height = H;
const captureCtx = captureCanvas.getContext("2d", {
willReadFrequently: true,
});
const numSlots = 34,
numCh = 8;
const classOffset = 1 * 17; // schema [0,17]: slot0=背景, slot1=person
const sigmoid = (x) => 1 / (1 + Math.exp(-x));
const THRESH = 0.5;
let decoded = [];
let lastFrame = img;
async function inferOnce(sourceEl) {
captureCtx.drawImage(sourceEl, 0, 0, W, H);
const { data } = captureCtx.getImageData(0, 0, W, H);
// HWC uint8 -> NCHW float32 [0,1](ONNXの入力レイアウト)
const chw = new Float32Array(3 * W * H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const idx = (y * W + x) * 4;
const pix = y * W + x;
chw[0 * W * H + pix] = data[idx] / 255;
chw[1 * W * H + pix] = data[idx + 1] / 255;
chw[2 * W * H + pix] = data[idx + 2] / 255;
}
}
const inputTensor = new ort.Tensor("float32", chw, [1, 3, H, W]);
const start = performance.now();
const results = await session.run({ input: inputTensor });
const elapsed = performance.now() - start;
const dets = results["dets"].data;
const labels = results["labels"].data;
const kp = results["keypoints"].data;
const scored = [];
for (let q = 0; q < 100; q++)
scored.push({ q, score: sigmoid(labels[q * 2 + 1]) });
scored.sort((a, b) => b.score - a.score);
const picked = scored.filter((s) => s.score > THRESH).slice(0, 10);
decoded = picked.map(({ q, score }) => {
const box = {
cx: dets[q * 4 + 0] * W,
cy: dets[q * 4 + 1] * H,
w: dets[q * 4 + 2] * W,
h: dets[q * 4 + 3] * H,
};
const points = [];
for (let k = 0; k < 17; k++) {
const base = (q * numSlots + (classOffset + k)) * numCh;
const xFrac = kp[base + 0],
yFrac = kp[base + 1];
const confLogit = kp[base + 2];
const logL11 = kp[base + 4],
l21 = kp[base + 5],
logL22 = kp[base + 6];
const px = xFrac * W,
py = yFrac * H;
const conf = sigmoid(confLogit);
const l11 = Math.exp(logL11),
l22 = Math.exp(logL22);
const det = l11 * l11 * (l22 * l22);
const cov00 = (l21 * l21 + l22 * l22) / det;
const cov01 = (-l11 * l21) / det;
const cov11 = (l11 * l11) / det;
const px00 = W * W * cov00,
px01 = W * H * cov01,
px11 = H * H * cov11;
const tr = px00 + px11,
diff = px00 - px11;
const disc = Math.sqrt((diff * diff) / 4 + px01 * px01);
const angle = 0.5 * Math.atan2(2 * px01, diff);
const a = Math.sqrt(Math.max(tr / 2 + disc, 1e-6));
const b = Math.sqrt(Math.max(tr / 2 - disc, 1e-6));
points.push({ px, py, conf, angle, a, b });
}
return { q, score, box, points };
});
lastFrame = captureCanvas;
redraw(toggle.checked);
log(
"infer ok, elapsed ms=" +
elapsed.toFixed(1) +
", detections=" +
decoded.length,
);
}
function redraw(showEllipses) {
ctx.clearRect(0, 0, W, H);
ctx.drawImage(lastFrame, 0, 0, W, H);
for (const { box, points } of decoded) {
ctx.strokeStyle = "lime";
ctx.lineWidth = 2;
ctx.strokeRect(
box.cx - box.w / 2,
box.cy - box.h / 2,
box.w,
box.h,
);
ctx.strokeStyle = "rgba(0,255,0,0.9)";
ctx.lineWidth = 2;
for (const [i, j] of SKELETON) {
ctx.beginPath();
ctx.moveTo(points[i].px, points[i].py);
ctx.lineTo(points[j].px, points[j].py);
ctx.stroke();
}
for (const p of points) {
if (showEllipses) drawEllipse(ctx, p.px, p.py, p.angle, p.a, p.b);
ctx.fillStyle = "red";
ctx.fillRect(p.px - 1.5, p.py - 1.5, 3, 3);
}
}
}
const toggle = document.getElementById("toggleEllipse");
toggle.addEventListener("change", () => redraw(toggle.checked));
// Webカメラ用の推論ループ。WebGPUなら1回あたり約0.3秒なので、CPU/Wasm実行より
// はるかに実時間に近い更新になる。
let webcamActive = false;
let stream = null;
let webcamLoopPromise = Promise.resolve();
const cameraSelect = document.getElementById("cameraSelect");
const cameraSelectWrap = document.getElementById("cameraSelectWrap");
// ノートPCではWindows Hello用の赤外線(IR)カメラが別デバイスとして存在することが多く、
// それを選んでしまうと映像が真っ黒になる。facingMode: 'user'で通常のカメラを優先しつつ、
// 複数デバイスがあれば選び直せるようにする。
async function listCameras() {
const devices = await navigator.mediaDevices.enumerateDevices();
const cams = devices.filter((d) => d.kind === "videoinput");
cameraSelect.innerHTML = "";
cams.forEach((c, i) => {
const opt = document.createElement("option");
opt.value = c.deviceId;
opt.textContent = c.label || "カメラ " + (i + 1);
cameraSelect.appendChild(opt);
});
cameraSelectWrap.style.display = cams.length > 1 ? "" : "none";
}
async function startWebcam() {
webcamActive = true;
const deviceId = cameraSelect.value || undefined;
const videoConstraints = deviceId
? { deviceId: { exact: deviceId }, width: W, height: H }
: { facingMode: "user", width: W, height: H };
stream = await navigator.mediaDevices.getUserMedia({
video: videoConstraints,
});
video.srcObject = stream;
// loadedmetadataは寸法が分かった時点で発火し、実際の映像フレームがまだ来ていないことがある。
// 最初のフレームが描画可能になるloadeddataまで待ち、黒画面のまま推論するのを避ける。
await new Promise((res) => {
video.onloadeddata = res;
});
// autoplay属性だけでは再生が始まらない場合がある(video.pausedがtrueのまま)。
// 明示的にplay()を呼び、実際に再生中であることを保証する。
await video.play();
log(
"webcam stream started: " +
video.videoWidth +
"x" +
video.videoHeight,
);
await listCameras(); // 権限付与後はlabelが取得できるようになるため、許可後に再取得する
while (webcamActive) {
await inferOnce(video);
await new Promise((res) => setTimeout(res, 0)); // UIイベントに処理を戻す
}
}
// stopWebcam()はフラグを立てるだけで、ループの現在の反復(session.run()の実行中)
// を中断しない。WebGPUは1回あたり数百msと速いため、フラグだけでは次のモードの
// inferOnce()呼び出しとsession.run()が重なり、"Session already started"エラーに
// なることがある。ループが実際に終わるまで待つ必要がある。
async function stopWebcam() {
webcamActive = false;
await webcamLoopPromise;
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
}
const sourceRadios = document.querySelectorAll('input[name="source"]');
for (const radio of sourceRadios) {
radio.addEventListener("change", async () => {
if (radio.value === "webcam" && radio.checked) {
await stopWebcam();
webcamLoopPromise = startWebcam().catch((e) => {
log(
"WEBCAM ERROR: " + (e && e.message ? e.message : String(e)),
);
});
} else if (radio.value === "image" && radio.checked) {
await stopWebcam();
await inferOnce(img);
}
});
}
cameraSelect.addEventListener("change", async () => {
if (
document.querySelector('input[name="source"][value="webcam"]')
.checked
) {
await stopWebcam();
webcamLoopPromise = startWebcam().catch((e) => {
log("WEBCAM ERROR: " + (e && e.message ? e.message : String(e)));
});
}
});
await inferOnce(img); // ウォームアップを兼ねた初回推論(1回目はシェーダコンパイル待ちが入る)
await inferOnce(img); // 定常状態の速度を見るための2回目
log("DONE");
}
</script>
</body>
</html>
-
GitHubのリリースページでv1.8.0の公開日(2026-06-16)を確認しました。機能自体の解説はRoboflowブログ「Real-Time Keypoint Detection with RF-DETR」(2026-06-17公開)を参照しました。 ↩
-
Google公式ブログ「LiteRT.js, Google's high performance Web AI Inference」(2026-07-09公開) ↩
-
roboflow/rf-detr Pull Request #1144(2026-06-20作成、2026-07-10時点で
openかつ未マージ) ↩