iPhone Safari + getUserMedia + onnxruntime-web で実施した実機牌認識テストのスクリプト(539行)を、5系統監査 → 改善統合 → 3レンズ敵対的検証(29クレーム) → 確定計画の全工程で検証。
検証中に本番モデル欠落(約1ヶ月間の無検知停止)を発見し、復旧まで完了した。
models/mahjong_yolov8n.onnx は約1ヶ月前から欠落しており、camera-ai.html は新規アクセスで全停止状態だった。text/html(SPA フォールバック)を返すため、HTTP コード監視では検出不能。onnxruntime は「protobuf parsing failed」を出し「カメラ開始」は永久に無効のままだった。
| 項目 | 事実(デプロイメント履歴の実査で確定) |
|---|---|
| 最後の正常配信 | デプロイ bb8bf7a0(コミット 9c0b7f4「CFデモ堅牢化」・約1ヶ月前)— 10,572,743 bytes / application/octet-stream |
| 欠落発生 | 同日の後続デプロイ 03036a0a 以降。原因: *.onnx が .gitignore 対象でローカル docs-site/models/ に実体がなく、wrangler pages deploy(ローカル直アップロード)がモデル無しのマニフェストを配信した(本日 2026-07-26 のレポート追加デプロイ 2 回も同様に欠落を継承) |
| 実機テストへの含意 | 直近1ヶ月内のテストで認識が動いていた場合はブラウザキャッシュの旧モデルによるもの。新しい端末・シークレットモード・キャッシュ削除後は一切動かない状態だった。テスト結果の解釈時はこの点を考慮すること |
| 復旧(2026-07-26 実施済) | 過去デプロイ bb8bf7a0 から ONNX 回収(SHA256 907d12d5…ba440429・先頭バイト 0x08=protobuf 確認)→ docs-site/models/ 配置 → 再デプロイ → 本番が application/octet-stream 10.6MB を返すことを確認 |
| 再発防止(P0-H) | ①日次ヘルスチェック: content-type が text/html なら notify.py 経由で LINE/デスクトップ通知 ②pdca-cycle.py の a1 自動デプロイ直前に docs-site/models/*.onnx 実体 assert(無ければ中断+通知) ③デプロイ検証を curl -w "%{content_type}" 方式に(HTTP コードでは検出不能のため)。mercari-text-copy の18日間無検知インシデントと同型の穴 |
この欠落は監査(INF-5)が構造リスクとして予言し、敵対的検証(impact レンズ)が「現在すでに停止中」と現認、確定計画で P0-0(即時復旧)に格上げされた — 多段検証が機能した実例。
getUserMedia({facingMode:'environment', width:1280, height:720}) … L455(720p固定)
→ video(playsinline muted) → ガイド枠ROI切り出し(gw/13*1.7 の横長帯) … L278
→ 512×512 letterbox → onnxruntime-web 1.20.1(jsdelivr CDN・webgpu→wasm・numThreads=1)… L157/L266-275
→ decode(38クラス [1,42,5376] 決め打ち)→ NMS(IoU 0.45)… L293-303
→ 3モード: フリー / 13枠KEEP投票(minVotes6・agree0.62・conf0.48・streak4)/ 6枚スキャン蓄積 … L178/L188
→ 和了牌タップ → tenpai/score.js 役判定(ツモ・門前・子・東場東家 固定)… L259/L331/L388
駆動: rAF + setTimeout 20ms の常時全速ループ(静止検知なし)… L432-453
正しく実装されている点(検証で confirmed): ROI の videoWidth 基準座標換算、object-fit:cover のタップ逆変換(L470-475)、loopToken による多重 rAF 防止、pre canvas の willReadFrequently、NMS の隣接牌誤抑制が起きにくい設計、conf スライダー。土台は健全で、問題は iOS 実機運用への耐性・テスト計測・フィードバック還流の3領域に集中している。
| # | 事実 | camera-ai への影響 |
|---|---|---|
| WEB-1 | iOS 26 Safari で WebGPU がデフォルト有効(WWDC25 発表・2025年秋出荷)。iOS 18 系は Feature Flag 止まりで既定無効 | ユーザーの iPhone が iOS 26 か 18 かで EP(webgpu/wasm)も速度も別物になる。テスト記録に OS バージョンと EP を必ず残す必要 |
| WEB-2 | iOS 26.3 Safari の WebGPU は連続約500推論でページごとクラッシュする既知 issue(onnxruntime#27584。YOLO のカメラ検出で再現・Chrome では再現せず) | 20ms 間隔の常時ループでは数分で500回に到達。「しばらく使うと落ちる」現象の第一容疑。スロットリング+定期セッション再生成が必要(有効性は実機ゲートで判定) |
| WEB-3 | onnxruntime-web の最新は 1.27.0(2026-06)。使用中の 1.20.1 は約7バージョン遅れ。さらに WebKit 26 では JSEP ビルドの wasm モードが CPU 400%+/メモリ14GB+ に暴走する既知 issue(#26827・1.20.0〜1.23.2 で再現、非JSEP の ort.min.js は正常) | 現構成は ort.webgpu.min.js(JSEP)で wasm フォールバックも同バンドル — iOS 26 で WebGPU 初期化に失敗して wasm に落ちた端末がこのバグを踏む。navigator.gpu の有無で webgpu/非JSEP を出し分ける二段ロードへ変更すべき |
| WEB-4 | WebNN の Safari 実装は見込みなし(WebKit の立場は No signal)。ANE(Neural Engine)はブラウザから使えない | 高速化投資は WebGPU 安定化に集中。ANE が必要ならネイティブ化(親仕様の Unity アプリ)という判断材料 |
| WEB-5 | iOS 16.3+ で背面カメラ(広角/超広角/望遠)を enumerateDevices で個別選択可。zoom は Safari 17+ 対応。torch/focusMode は実用上期待不可。「720p 上限」情報は 2018 年由来で古く、実上限は track.getCapabilities().width.max の実測が確実 | 現在の 1280×720 固定(L455)は引き上げ余地。超広角は接写に強く「6枚寄せスキャン」と相性が良い。iPhone 主カメラの最短撮影距離(約15〜20cm)より寄るとピンボケ — ズーム活用で「離して寄せる」へ |
| WEB-6 | requestVideoFrameCallback は iOS 15.4+ で利用可 | setTimeout+rAF ループより適切(同一フレームの二重推論を排除、フレームドロップ計測も可能) |
| WEB-7 | Cloudflare Pages の _headers で COOP/COEP を配れば SharedArrayBuffer が有効化でき wasm マルチスレッド化(numThreads 4)が可能。ただし CDN スクリプトは CORP 制約に掛かるため ort 一式の自ホストが前提 | wasm 推論 200〜400ms → 最大3倍高速化の余地(要実機 A/B)。スコープは /camera-ai.html と /vendor/* に限定(全パス適用は train-capture 等の外部リソースを壊す — 検証で確定) |
| WEB-8 | iPhone ブラウザでの YOLO 実測ベンチ公開事例はほぼ皆無 | 外部データに頼れない — 自前テレメトリ(EP・median ms・UA を feedback 送信)が唯一の道。field_val_map50=null の解消と同根 |
| ID | 指摘 | 根拠(行) |
|---|---|---|
| INF-5 | モデルがローカル不存在+gitignore で再デプロイ即404(→実際に本番停止が現実化・復旧済)。404時も protobuf 生エラー表示のみで復帰不能。webgpu 失敗時に 12MB を二重フェッチ | L269-275 / .gitignore:26 / models/ 実査 |
| CAM-1 | jsdelivr CDN が単一障害点 — ort 読込失敗で module 全体が ReferenceError 死し、サンプル・オンボーディング含む全ボタンが無反応(HUD は「モデル読込中…」のまま無言) | L157 / L266 / L486-536(未到達) |
| WEB-1 | iOS 26 で WebGPU デフォルト有効(→ EP が OS 依存で分岐。テスト記録に必須) | webkit.org WWDC25 |
| WEB-2 | iOS 26.3 WebGPU の連続約500推論クラッシュ(→常時ループでは数分で到達) | onnxruntime#27584 |
| POST-1 | KEEP 校正が「ちょうど13牌検出フレーム」を要求 — 実機の低 recall では永久に固定されず、12真+1誤検出の瞬間に誤校正が恒久汚染 | L217 |
| POST-2 | 誤ロックしたスロットの個別訂正手段がない(全リセットのみ)— テスト完走率を直撃 | L228 / L244 / L530-533 |
| UX-1 | feedback-worker への送信が皆無 — 誤認識データが PDCA(新規feedback≥30 で再学習)に一切還流せず、field_val_map50=null が永遠に埋まらない | 全539行に fetch なし / feedback-worker/src/index.js は受け皿実装済 |
| UX-2 | スキャン蓄積に牌クラスの訂正 UI がない(✕削除のみ)— モデルが系統的に誤読する牌が出るとテストが13枚完成前に破綻 | L523-528 / L397-403 |
| 領域 | 主要指摘 |
|---|---|
| iOS ライフサイクル(CAM-2/3/4/5) | 画面ロック・アプリ切替からの復帰処理ゼロ(visibilitychange / track ended / Wake Lock すべて不使用)→ 復帰後は凍結映像に「認識中」表示のまま。「📸確定」が video.pause() を呼ばず枠と映像がズレて和了牌タップが不正確。getUserMedia エラーは英語生文言のみで復旧手順に辿り着けない(旧 live-demo.html にあったガードの退行) |
| カメラ品質(CAM-6/7) | 「6枚に寄せて」誘導が主カメラの最短撮影距離(約15〜20cm)と衝突しピンボケ入力を誘発(zoom capability 未活用)。ガイド帯は 13枚前提固定(gw/13*1.7)で、6枚スキャン時は牌の上下約44%が ROI 外にクロップされ「寄せるほど悪化」する逆説(512×512 入力の有効面積はわずか13%) |
| 推論性能(INF-3/4/6) | wasm はメインスレッド直列実行(proxy 未設定)で 1推論 200〜400ms の UI ブロック+発熱→スロットリング。webgpu の実行時故障(run 時例外・誤出力)にフォールバックなし。COOP/COEP 未配備で numThreads=1 固定(2.5〜3倍の高速化余地が未着手) |
| 投票・後処理(POST-3〜8) | x座標のみの枠割当は手ブレ・パンで全枠が隣にズレて投票汚染。投票に減衰なし(初期誤投票が永続)。KEEP.conf=0.48 が UI スライダーと独立の隠し閾値。赤5(0m)⇔通常5(5m)のフリッカーで投票キーが分裂しロック不能。SCAN_CONF=0.5 は参照ゼロの死に定数で安定判定(5フレーム)も➕ボタン活性に反映されず誤追加し放題。class-agnostic NMS のみで同一牌の重複箱が二重カウント |
| テスト運用(UX-3/4/5) | EP・モデル版・conf% が画面に出ず「どのモデルで何%正解したか」を記録できない。正解率の記録手段ゼロ。点数計算はツモ・門前・子・東場東家固定で実戦と一致しない(「東の刻子は2飜つくのに子計算」という矛盾既定も) |
MINOR 16件は確定計画の P2 群(0z 可視化・dims 検証・セーフエリア・✕44px 化・fps EMA・サンプルデモの蓄積保護 等)に反映。全指摘は行番号付きでワークフロー成果物に保存済み。
改善バックログを「実装可能性」「効果」「後方互換・リスク」の3レンズで検証。confirmed 14 / needs_fix 15(refuted 0) — 全 needs_fix を確定計画に反映した。主要な修正:
| レンズ | 指摘 | 反映 |
|---|---|---|
| impact | 「本番は現在すでに停止中」を現認。models URL は 200+text/html を返し、res.ok でも curl の HTTP コードでも検出できない | P0-0(即時復旧)に格上げ・実施済み。検出は content-type + 先頭バイト検査に変更 |
| impact | ヘルスチェックと PDCA 自動デプロイ(a1)のモデル同梱保証が欠落 — a1 が走るたびモデルを消す構造 | P0-H 新設(日次 content-type 監視+デプロイ前 assert) |
| feasibility | 復帰処理のスニペットが running=true を戻さず「恒久凍結」を作り込む/frozen 中の復帰で pause が解除され確定状態が壊れる | userStarted フラグ分離+frozen 維持分岐に書き直し(§6 #3) |
| feasibility | 150ms スロットリングは500回到達を約100秒に延ばすだけで対策にならない。セッション再生成での回避も未実証 | 10分実機検証を「有効性判定ゲート」に格上げ。無効時の代替(wasm 張替え/再読込誘導)を計画に内包 |
| feasibility | wasm proxy は WebGPU EP と併用不可(ORT 公式)。テンソル再利用と transfer が非互換(2フレーム目に detached) | proxy=true は ep==='wasm' 確定後のみ+テンソル再利用と排他制御 |
| feasibility / regression | _headers /* 全適用は train-capture.html(Google Fonts)・live-demo.html(jsdelivr)・yolo-roboflow.html(mermaid CDN)を COEP で破壊 | スコープを /camera-ai.html と /vendor/* に限定+他ページ回帰確認を検証項目化 |
| impact / regression | フィードバック自動送信の自己ラベル(モデル予測)が pull-feedback.py の 20% field-val 恒久昇格を汚染=精度計測の自作自演。L143「端末内処理・送信なし」の約束を破る後方非互換。worker は meta を黙って破棄 | payload に auto:true 必須+auto の val 昇格除外。送信はオプトイン UI(既定 OFF・初回確認)+文言更新。worker の meta パススルー改修を工数に内包 |
| regression | KEEP を14枠化すると maybeKeepScore が15枚を scoreHand に渡し採点全損(CSS も13列固定) | slots は13本維持、余剰1枚は割当除外して和了牌候補として提示のみ(UI 無改修設計に固定) |
| regression | 点数条件バーで seatWind=東+isDealer 自動セットにするとサンプルデモの点数ラベルが親計算に化ける | sampleDemo は子・ツモ固定 opts、UI 既定は子、自動セットは明示操作時のみ |
| # | 項目 | 工数 |
|---|---|---|
| P0-0 | 本番モデル欠落の復旧 — ✅ 2026-07-26 実施済み(bb8bf7a0 から回収→配置→再デプロイ→content-type 検証) | 0.5h(済) |
| P0-H | 再発防止: 日次 content-type ヘルスチェック(notify.py 連携)+ pdca-cycle a1 デプロイ前の *.onnx 実体 assert | 0.7h |
| P0-1 | fetch 読込+content-type/先頭バイト検査+session.inputNames[0] 使用+二重フェッチ排除 | 1.5h |
| P0-2 | ort undefined ガード(CDN 死亡時もサンプル/オンボーディング生存)→ 本修正は vendor/ 自ホスト | 0.2h+2h |
| P0-3 | カメラライフサイクル: wakeLock+visibilitychange 復帰(running 復元・frozen 維持・userStarted 分離)+track ended 監視+連打ガード | 1h |
| P0-4 | 150ms スロットリング+400回ごとセッション再生成(iOS 26 実機10分の有効性判定ゲート付き) | 1h |
| P0-5 / P0-6 | 確定時 video.pause()(タップ座標ズレ解消)/ getUserMedia ガード+e.name 別の日本語復旧案内 | 0.5h |
| P0-7 | guideRect の scanMode 分岐(6枚時の上下44%クロップ解消)+縦持ち下限 | 0.3h |
| P0-8 | KEEP 校正の複数フレーム化(直近10フレーム最頻値)+14検出許容(slots 13本維持・余剰は和了牌候補) | 1.5h |
| P0-9 | フィードバック送信(worker meta パススルー改修+オプトイン UI+auto:true+val 昇格除外+文言更新)— Phase D(jitaku 日次タスク)完了が Do 着火の前提 | 3.5h |
| P0-10 | model-meta.json+version/EP 常時表示+cache-bust | 1h |
| P0 計 | 約13.5h | |
NMS 2段閾値+IoA 包含抑制 / KEEP.conf の confThr 連動+保留理由表示 / 投票リングバッファ化(N=20)/ 枠割当のグローバルシフト補正+1対1マッチング / スキャン品質ゲート(SCAN_CONF を confThr 連動で復活+除外枚数表示)/ ウォームアップ推論+実行時 wasm フォールバック / wasm proxy 化(ep 確定後のみ)/ ort 1.27.0 化+非JSEP/webgpu 二段ロード / COOP/COEP(スコープ限定)/ zoom スライダー+1080p / requestVideoFrameCallback 化 / テンソル再利用 / imageSmoothingQuality A/B
P2: ロック済みスロット個別訂正+手動パレット(訂正ペアは最高品質の学習データとして送信)/ conf%+実効 fps EMA+セッションサマリ JSON(log-ml-progress.py 直貼り形式)/ 点数条件バー(ツモ/ロン・親/子・リーチ・ドラ)/ 0z 可視化 / dims 検証 / セーフエリア / ✕44px 化 / サンプルデモ蓄積保護。 P3: 2段ガイド(7+7)/ レンズ切替 / torch / camera-core.js 切り出し(グラス版と共通化)/ OffscreenCanvas。
モデル復旧は完了。残り: pdca-cycle.py a1 直前の docs-site/models/*.onnx 実体 assert+日次ヘルスチェック(P0-H・0.7h)。
let ep='wasm', modelBuf=null, META={version:'unknown'};
(async()=>{
try{ META=await (await fetch('models/model-meta.json',{cache:'no-store'})).json(); }catch(e){}
const res=await fetch(`models/mahjong_yolov8n.onnx?v=${META.version}`);
const ct=res.headers.get('content-type')||'';
modelBuf=new Uint8Array(await res.arrayBuffer());
if(!res.ok || ct.includes('text/html') || modelBuf[0]===0x3C /* '<'=HTML */ || modelBuf.length<1e6){
hud.textContent=`モデル未配置(${ct||'不明'})— models/ 配置後に再デプロイしてください`; return; }
try{ session=await ort.InferenceSession.create(modelBuf,{executionProviders:['webgpu']}); ep='webgpu'; }
catch(e){ session=await ort.InferenceSession.create(modelBuf,{executionProviders:['wasm']}); }
hud.textContent='準備OK ('+ep+')'; document.getElementById('start').disabled=false;
})();
// L439: session.run({[session.inputNames[0]]: meta.tensor})
// L451: `${dt.toFixed(0)}ms | ${META.version} | ${ep}` を常設表示
L266 以降の ort 依存コードを if(typeof ort==='undefined') で包み、CDN 死亡時も L486-536 の UI バインド(サンプル/オンボーディング)を生存させる。本修正(vendor/ 自ホスト)は次サイクル。
let track=null, wl=null, userStarted=false;
async function reqWL(){ try{ wl=await navigator.wakeLock.request('screen'); }catch(e){} }
async function startCam(){
if(running) return; // 連打ガード
if(!navigator.mediaDevices?.getUserMedia){ hud.textContent='カメラAPI非対応(httpsで開いてください)'; return; }
try{ const s=await navigator.mediaDevices.getUserMedia({...});
track=s.getVideoTracks()[0];
track.addEventListener('ended',()=>{ running=false;
hud.textContent='カメラが停止しました — カメラ開始を再タップ';
document.getElementById('start').textContent='カメラ開始'; });
video.srcObject=s; await video.play(); running=true; frozen=false; userStarted=true; reqWL();
/* 既存 UI 更新 */ startLoop();
}catch(e){ /* #5 の e.name 分岐 */ }
}
document.addEventListener('visibilitychange', async()=>{
if(document.hidden){ running=false; return; } // 推論のみ停止
if(!userStarted) return;
await reqWL(); // 非表示で自動解放されるため再取得
if(!track || track.readyState==='ended'){ await startCam(); return; }
if(frozen) return; // 確定状態は維持(pause も解除しない)
await video.play().catch(()=>0);
running=true; startLoop(); // ★復元してから再開(恒久凍結の防止)
});
L452 の 20 → 150。推論成功側に 400 回ごとの session.release() → 再 create(失敗時は wasm 張替え)。iOS 26 実機で10分連続稼働してクラッシュ回避を判定 — 無効なら (a) 400回ごと wasm 張替え (b) 再読み込み誘導へ即切替(スロットリング単体は500回到達を約100秒に延ばすだけ)。
toggleFreeze に frozen ? video.pause() : video.play().catch(()=>0)。catch は e.name で分岐: NotAllowedError→「アドレスバー左の「ぁあ」→Webサイトの設定→カメラ→許可 後に再読み込み」/ NotReadableError→「他のアプリがカメラ使用中」。
function guideRect(W,H){ const gw=W*0.94, per=scanMode?6:13;
let gh=Math.min(H*0.62, gw/per*1.7);
if(H>W) gh=Math.max(gh, H*0.25); // 縦持ち下限
return [(W-gw)/2,(H-gh)/2,gw,gh]; }
6枚スキャン時の牌上下約44%クロップを解消。修正前後で平均 conf を比較記録。
feedback-worker/src/index.js:33 に meta / flag パススルー追加、flag==='miss' 時は boxes 空を許容 → wrangler deploymeta.auto===true(人手未検証)は field-val 20% 昇格から除外(レビュー待ちキューへ)— 精度計測の自作自演を防止flag:'wrong')に pre canvas を JPEG 化して送信。payload: {boxes, names, meta:{ep, ua, modelVersion, medianMs, videoW, videoH, auto:true}, flag}次点(KEEP を本格テストするなら先行必須): P0-8 — 直近10フレーム最頻値校正+14検出時は slots 13本のまま余剰1枚を和了牌候補として提示(1.5h)。
| 項目 | 規定 |
|---|---|
| 端末 | 機種名・iOS バージョンを毎回記録(iOS 26=webgpu / 18=wasm で速度が別物) |
| 牌 | 同一セットの実物牌。基準手牌3種を固定: A=タンヤオ形(萬筒中心)/ B=索子7枚以上(1s・9s 含む=既知の弱点)/ C=字牌4種以上 |
| 並べ方 | 緑マット上・理牌済み・牌同士は接触・1列水平・ガイド枠内 |
| 照明 | 室内天井灯(逆光・直射・影かぶりなし)。毎回同じ部屋・同じ時間帯。低照度テストは別枠で明示 |
| 距離 | スキャン蓄積=牌から約20cm(それ以上寄るとピンボケ=最短撮影距離割れ)/ KEEP 13枚=約35〜40cm。手持ちは肘を机につけて固定 |
| 事前確認 | HUD のモデル version と EP を読み上げ記録(旧キャッシュなら無効テスト — リロードで更新確認)。フィードバック送信トグル ON |
有効テストの3条件: version が最新・EP 記録あり・feedback 送信成功 — を満たすもののみ Δ比較に採用。満たさないものは理由付きで参考値ログに。
この iPhone Web テストは、親仕様(スマートグラス×リアルタイム役判定 リリース仕様書)の Phase 0「一人称 field mAP 初計測」の前段をなす実機評価装置である。以下の改善は将来のグラス版(Unity/C#)にそのまま持ち越される共通資産になる:
| 本レポートの改善 | グラス版仕様での対応物 |
|---|---|
| SG共通 静止検知ゲート+推論間引き(P1-7 サブ項目) | イベント駆動推論(§4.2 — 電力 1/30 削減の中核) |
| SG共通 KEEP 投票の再設計(複数フレーム校正・リングバッファ・2次元割当) | バースト7フレーム多数決+KEEP 再チューニング(M-2) |
| SG共通 誤認識の訂正 UX(スロット個別訂正・クラスパレット) | 誤判定時 UX(§7 — 「?」マーカー+訂正) |
| SG共通 計測基盤(EP/version/median ms のテレメトリ) | 性能目標の実測ゲート(T-1/T-6/T-7) |
| SG共通 フィードバック送信(auto フラグ+訂正ペア) | PDCA 還流(§7 — 30件で再学習トリガ) |
| guideRect のモード分岐 → P3 の 2段ガイド(7+7) | 手牌 ROI 抽出の一人称対応(M-13 の画角整合) |