ステータス: ドラフト (2026-10-26)、実測値を反映。 構造は2026-10-25時点のリポジトリコードから読み取り、数値は我々が直接測定したものであり、表ごとに日付と条件を付記しています。インターネットを経由する実際のユーザー経路における遅延と、携帯電話の現場での精度は未実測です。 執筆ルール: 実測データを基本とし、測定できなかったものは「未実測」と表示します。
目次
- 1. 得点板1つにいくつマシンが接続されているか
- 2. 4つの領域のマップ
- 3. 自動得点を有効にすると起こること
- 4. デプロイ: コードと重みをモデルリポジトリから読み込む
- 5. 何が機器の外に出て、何が出ないのか
- 6. まとめ
- 参考文献
- あわせて読みたい
1. 得点板1つにいくつマシンが接続されているか
卓球場でTT Labを使用する際の様子はシンプルです。携帯電話を卓球台の脇に立てかけ、 /tt/play/cam を開いて「カメラをオンにして開始」をタップします。画面にはカメラ映像の上に大きな得点板が表示され、得点した側の画面半分をタップすると得点が入ります。サーブの順番、デュース、ゲーム終了時のコートチェンジは、得点板がルール通りに案内してくれます。
ここまででは マシンは携帯電話が1台のみです。得点ルール、履歴、得点読み上げ、録画のすべてが携帯電話のブラウザ内で動作します。サーバーの処理が必要になるのは、ユーザーが次の3つのうちいずれかを選択したときです。
- 自動得点を有効にする。カメラフレームをサーバーのGPUワーカーに送信し、ラリーの勝者の提案を受け取る。
- ライブ共有を行う。得点イベントのみをサーバーに送信し、他の人がリアルタイムで視聴する。
- この試合を記録するをタップする。終了した試合のゲームスコアをサーバーに残し、対戦相手の確認を経てレーティングに反映させる。
録画を個別にアップロードして解析してもらう方法(/tt/videos)もありますが、それはユーザーが動画ファイルを直接アップロードする別機能です。
この記事ではこの構造を1枚のマップとして描き、領域と領域の間で何がやり取りされるか、特に 何が機器の外に出て何が出ないのかを記載します。判定ルールの内側は第2・3編、モデルの原理は第4編、学習は第5編、記録は第6編で解説します。
2. 4つの領域のマップ
| 領域 | 役割 | 稼働場所 | コード |
|---|---|---|---|
| ① 携帯電話ブラウザ | 得点ルール・履歴・取り消し、得点読み上げ、録画と休憩時間の除外、自動得点チップ、フレームの送信 | ユーザーの携帯電話 | backend/static/tt/play/ (ビルド不要のESモジュール) |
| ② ウェブサーバー | ログイン、自動得点中継(WebSocket)、ライブ視聴、試合記録・レーティング、修正済み記録の保存 | ウェブサーバー2台(同一コード) | backend/app/tt_*.py (FastAPI) |
| ③ リアルタイム判定ワーカー | ボールの検出、卓球台の検出・追跡、イベント抽出、「確認要」の判断 | サーバーGPU(24GBクラス)1枚の時間スライス | live/(GPU部)、 ttscore/(純粋Pythonエンジン) |
| ④ 録画解析ワーカー | アップロードされた動画を同じエンジンで解析し、試合記録とハイライトを作成する | 同サーバーGPUのスライス1つ(同時処理1) | エンジンフォルダのbatch部分 |
なぜエンジンを2つに分割したのか
③の中を改めて見ると2層構造になっています。GPUとOpenCVが必要な部分(live/)と、ボールの座標・イベントのみを受け取る 純粋Pythonエンジン(ttscore/)です。エンジンはGPUもOpenCVも必要としない関数の集まりであるため、CIにおいて動画なしの合成軌跡のみでテストされます。ボールモデルを差し替えても、審判ルールはそのままテストを通過しなければなりません。この分離については前シリーズの 第1編で「認識・イベント・審判・スロー確認」の4層として詳しく説明しました。
試合規則はもう一度複製されています。携帯電話の画面はサーバーを呼び出さずに得点をカウントする必要があるため、Pythonルールエンジン(ttscore/rules.py)をJavaScriptに移植した backend/static/tt/play/lib/scorekeeper.mjs を使用しています。ライブ視聴サーバー(backend/app/tt_watch.py)にも同じルールの3つ目のコピーが存在します。コピーが3つあるといずれ乖離が生じるため、PythonのテストとNodeのテストが 同じケースファイル(backend/static/tt/play/data/rules-cases.json)を読み込むようにしています。一方を修正すると、他方のテストが失敗します。
携帯電話側: ビルド不要のモジュールとサービスワーカー
画面コードは、バンドラーなしでブラウザが直接読み込むESモジュールです。モジュールのアドレスにはコンテンツハッシュ(?v=)を付加したimport mapを使用し、コードが変更されるとアドレスが変わり、古いキャッシュと混ざらないようにしています。卓球エリアにはサービスワーカー(/tt/sw.js、スコープ /tt/)があり、画面コードはキャッシュしますが、APIや記録画面はキャッシュしません。卓球場のWi-Fiが切れても手動得点板は動作し続けなければならず、得点記録は常にサーバーの最新値を参照する必要があるためです。
/tt/play/cam の最初の画面は、重いコンポーネント(自動得点、録画、ライブ)を初期時には読み込まず、使用時に読み込みます。手動得点板のみを使用するユーザーが大半を占めるためです。
3. 自動得点を有効にすると起こること
自動得点はこの構造の中で最も長い経路です。1つのフレームが得点提案として返されるまでの流れを順番にたどります。
- ブラウザがフレームを縮小して送信する。 カメラ映像を512×288に縮小し、JPEG品質0.7で圧縮して毎秒最大30フレーム送信します(
backend/static/tt/play/cam-auto.js,lib/live.mjs)。画面上では鏡のように反転して表示されていても、送信するのは反転していないオリジナルです。フレームの前方には8バイトのヘッダー(連番、撮影時刻ms)が付加されます。応答を受信していないフレームが3枚たまると、新しいフレームは送信されません。低速回線でキューが無限に長くなるのを防ぐ仕組みです。 - ウェブサーバーがゲートを管理する。 WebSocket中継(
backend/app/tt_live.py)はGoogleログインユーザーのみを受け付け、席数と時間枠をカウントします。フレームは通過するだけです。 - ワーカーがフレームをボール座標に変換する。 輝度強調 → ボールモデル(BlurBall) → 直前のボール周辺の候補選択 → 直近2.5秒のボール軌跡維持 → 短い空白の補間 → イベント抽出 → 学習済みのバウンド除外(veto)の順序です(
live/core.py). - エンジンが判定を下す。 「試合開始」シグナルを受信した後でのみ審判が動作し、ラリーが終了すると勝者・理由・信頼度、および「確認要」かどうかの判定を出力します(第3編)。
- ブラウザがチップで確認を求める。 判定はすぐ得点板に反映されず、チップとして表示されます。人間が応答しない場合、確実な判定は4秒後、確認が必要な判定は9秒後にそのまま適用されます(第3編)。
卓球台はこのフローの外で別途検出します。試合開始前に一度「卓球台検出」を行い(ボールモデルよりもはるかに重いセグメンテーションモデルを使用)、その後はオプティカルフローにより毎フレーム追跡します(第4編)。
時間枠と制限
GPUは高価であり、共有リソースです。そのため制限が多重にかかっています。以下はコード上のデフォルト値です(運用設定ファイルに別途値が存在するかどうかは未確認)。
| 制限 | 値 | スコープ |
|---|---|---|
| 1回のセッションで利用可能な時間 | 20分 | ウェブ中継 |
| 1人あたりの1日の割り当て | 40分 | ウェブ中継 |
| サイト全体の1日の割り当て | 360分 | ウェブ中継 |
| 同時利用可能な枠 | 3 | ウェブ中継・ワーカー両方 |
| 1フレームのサイズ | 120,000バイト | ウェブ中継 |
| フレームレート | 36fps(瞬間的に10fps増) | ウェブ中継 |
| データが途絶えた場合の切断 | 15秒(ワーカーは20秒) | ウェブ中継・ワーカー |
| 卓球台の事前検出 | 1人あたり1時間につき30回 | ウェブ中継 |
接続が切断された場合、ブラウザは1・2・4・8・15秒の間隔で最大5回再接続を試みます。ログインが必要な場合や割り当てを使い切った場合など、再接続しても意味のない切断(終了コード4401・4429・4430・4431など)の場合は再接続しません。
実測: 同時セッションが増えると
1つのワーカーが最大3セッションまで処理します。3セッションが同時に接続された場合、1枚のGPUの時間スライスを3者で分割して使用します。以下は2026-10-11に、同じGPUを音声合成サービスと共有している状態で測定した値です(docs/tt/PERFORMANCE.md 4.2).
| 同時セッション | セッションごとの処理fps | フレームの往復時間 |
|---|---|---|
| 1 | 29.4 | p99 約32ms |
| 3 | 16–20 | p50 70–95ms、p99 約127ms |
3セッションの場合、送信されたフレームの21〜30%が処理される前に新しいフレームに上書きされて破棄されました。ワーカーのGPUループは1つであり、ループが回るたびに各セッションの 最新のウィンドウのみを1つのバッチにまとめて処理し、遅れたウィンドウは新しいウィンドウで上書きされるように設計されているためです(live/server.py の冒頭、「遅延優先」)。得点判定においては、フレームを1枚も落とさないことよりも 遅れないことが重要です。その代償として、ボールの動きが速いラリーでは30fpsが実際には16〜20fpsになり、前シリーズの 第5編で示したように、このエンジンはフレームレートが低いほど「確認なしの間違い」が増加します。
実測: ラリーが終了してから得点が表示されるまで
目標は「ラリー終了 → 1.5秒以内の判定」です(研究責任者のリアルタイム予算)。エンジンは最後のバウンドから1.5秒間新しいバウンドがないか、またはボールが1秒間見失われた場合にラリー終了とみなします。つまり、予算の大部分は 「本当に終了したか」を待つ時間です。
| 測定 | 条件 | 値 |
|---|---|---|
| 判定遅延(ラリー終了 → 判定) | 公開映像テスト7試合、クリーンな条件、リアルタイムパスの模擬(2026-10-13・14、責任者E00・E07) | p90 1.31秒 |
| 判定遅延 | 撮影条件8種類E2E(2026-10-14–15、PERFORMANCE 2.16) | 中央値 0.67–0.90秒 |
| 卓球台の提案が表示されるまで | カメラ得点板E2E(2026-10-14、PERFORMANCE 2.13) | 2.8–3.4秒 |
| 卓球台検出1回あたり | 稼働中のリアルタイムワーカーでテスト画像211回、サーバーGPU(24GBクラス)、ウォームアップ後(2026-10-25–26、責任者E44) | 中央値 1.73秒、p95 2.14秒 |
| 卓球台検出の初回呼び出し | 同条件、モデルをメモリにロードする初回 | 12.9秒 |
| インターネットを経由する実際のユーザー経路 | — | 未実測 |
終了の閾値を下げて遅延を短縮する試み(ボールが消えたらより早く終了させるなど)は何度行いましたが、すべて却下されました(責任者E03・E10・E24・E28)。早めに終了させてしまうと、長く続く守備的なラリーで得点が早すぎると判定されてしまいます。
4. デプロイ: コードと重みをモデルリポジトリから読み込む
ウェブとワーカーではデプロイ方式が異なります。この違いが実際に一度障害を引き起こしたため、詳しく記載します。
ウェブはイメージで
ウェブサーバーは一般的な方式を採用しています。リポジトリにプッシュするとCIがテストを実行し、コンテナイメージをビルドします。GitOpsリポジトリのイメージタグを変更すると、ArgoCDが開発・本番環境に自動的に同期します。画面コード(backend/static/tt/)もこのイメージ内に含まれています。
ワーカーはイメージではなく共有ボリュームで
GPUワーカーは 新しいイメージをビルドしません。 同じGPUサーバーですでに使用していたAIサービス用イメージ(PyTorch 2.9、Python 3.11)をそのまま使い、エンジンコード、BlurBallモデルコード、重み、OpenCVのホイールを モデルリポジトリ(共有ボリューム) から読み込んで配置します。エンジンコードは頻繁に変更されますが、PyTorchのような重い依存関係はほとんど変更されないため、コードを修正するたびに数GBもあるCUDAイメージを再ビルドする必要がありません。その代わり、イメージのPythonのバージョンが固定されるという代償を払いますが、その内容については第4話のSAM 3のストーリーで改めて触れます。
コードと重みを配置するスクリプト(live/seed-pvc.sh)は次のように動作します。
- 重みのsha256をコードに記載されている値と照合します。ボールモデルの基本重みは
3545206c…、卓球台モデル(SAM 2.1・EfficientTAM)は公式配布パッケージを受け取ってsha256を照合した後に展開します。 - 新しいファイルを
.newフォルダーにまずダウンロードし、ダウンロードが完了してから一度に置き換えます。途中でダウンロードが中断されたファイルをワーカーが読み込んでしまうのを防ぎます。 - ワーカーを再起動します。これこそがデプロイです。
ワーカーは重みを読み込む際にもsha256を再度照合し、PyTorchの weights_only=True でのみ読み込みます(live/blurball.py)。重みファイルはPickle形式であるため任意のコードを実行できる可能性がありますが、このオプションによりテンソル以外のオブジェクトが作成されないようにブロックします。
機能は環境変数スイッチで
新機能はほとんどすべて コードのデフォルト値はオフ、環境変数でオン という方式をとっています。2026-10-25の時点でリアルタイムワーカーの設定に有効になっているスイッチは次のとおりです。
| スイッチ | 変更内容 | 該当話 |
|---|---|---|
TTSCORE_BOUNCE_VETO |
学習済み1D CNNがルールによって生成された無効なバウンドを破棄 | 第3・4話 |
TTSCORE_BRIGHTEN |
ボールを検出する前にフレームの明るさを増加 | 第4話 |
TTSCORE_REVIEW_CAL |
「要確認」を信頼度0.6の代わりに学習済み回帰で決定 | 第3・4話 |
TTSCORE_DIRECT_MISS |
サーバー側の最初のバウンドが検出されなかったサブの判定方向 | 第3話 |
TTSCORE_EDGE_REVIEW |
エンドライン付近の着地は要確認とする | 第3話 |
TT_TABLE_FINDERS |
卓球台検出モデルの順序 | 第4話 |
TT_TABLE_KP |
学習済み頂点モデルを卓球台検出に統合(2026-10-25オン) | 第4話 |
逆に微調整を行ったボールの重み(TTSCORE_BALL_WEIGHTS)と弾道の補間(TTSCORE_BALLISTIC_FILL)は実験を終えたものの有効化の条件を満たしていないため、設定には含まれていません(オフ)。スイッチ方式のメリットは 簡単にオフにできる点です。コードと重みはそのままで、設定の行を1行削除するだけで以前の動作に戻すことができます。
この方式によって発生したインシデント
デメリットも実際に経験しました。ワーカーの設定はWebとは異なり自動デプロイの外部にあるため人間が適用し、コードは別途配置します。そのため、 設定とコードが別々に動くことがあります。 2026-10-18には、設定で TTSCORE_EDGE_REVIEW=1 が有効になっているにもかかわらず、モデルリポジトリのエンジンコードにはその機能がまだ存在しないという状態が現実に発生しました(台帳E41)。スイッチはオンになっていましたが何も動作せず、エラーも発生しませんでした。コードを再配置して解決し、その後は機能を有効にする際に「設定の適用 → コードの配置 → 再起動 → ワーカー内のコードにその機能が存在するか確認」という順序を厳守しています。
同様の理由で2026-10-25に頂点モデル(TT_TABLE_KP)を有効にした際も、設定ファイルだけを見るのではなく、テスト画像を実際の稼働ワーカーに送信してレスポンスに頂点モデルの痕跡が含まれているかを確認しました。212件すべてのレスポンスに頂点モデルが実行されたという記録が残っていました(台帳E44、第4話)。
コンテナ間のフェンス
ワーカーはユーザーの動画を受け取る場所であるため、多重にフェンスを設けています。
- AIワーカーが動作するゾーンは、インロードの接続を原則としてすべて遮断し、Webサーバーのように許可リストに名前が登録されたポッドのみがワーカーの特定のポートにアクセスできるようにしています。
- 録画の分析を行うワーカーは、 DBとストレージのキーを持ちません。 Webの内部APIのみを使用し、それもサービストークンとタスクごとに発行される賃借トークンの両方が揃っている必要があります。アウトバウンドの接続はDNSとWebサーバーのみです。
- ワーカーにはKubernetesのサービスアカウントトークンを付与せず、Linuxの機能をすべて削除した状態で実行します。
- ワーカーのログには動画、ボールの座標、個人情報を残しません(
live/server.py).
5. 何がデバイスの外に出て、何が出ないのか
TT Labの卓球画面の原則は、 backend/static/tt/play/cam.js 冒頭に1行で記載されています。 カメラの映像はデバイスの外に出ない。 機能を整理すると次のようになります。
| 機能 | スマートフォン内に留まるもの | サーバーに送信されるもの | サーバーに残るもの |
|---|---|---|---|
| 手動スコアボード | 試合履歴全体(localStorage)、設定 | なし | なし |
| スコアの音声読み上げ | ブラウザの音声合成(speechSynthesis) |
なし | なし |
| 録画・休憩時間の切り出し | 録画ファイル(スマホに保存)、動きの表示 | なし | なし |
| ライブ共有 | 動画 | 得点・やり直し・終了イベント(順番を付与) | ライブの試合とイベントのストリーム |
| この試合の記録 | ラリーごとの履歴 | ゲームスコア、試合時間、対戦相手 | 試合の記録、レーティング |
| 自動採点 | オリジナル解像度の動画 | 512×288 JPEGフレーム(1秒あたり最大30枚) | フレームは 保存しない。修正された判定記録(同意の表示を含む、動画なし) |
| 録画のアップロードによる分析 | — | ユーザーが選択した動画ファイル(ブラウザで360p60 500kbpsに縮小してアップロード) | オリジナルは30日後に削除、分析結果 |
自動採点はこの原則の 例外です。ボールを検出するにはGPUが映像を確認する必要があり、現在はそのGPUがサーバーにあるためです。そのため、例外を厳格に限定しています。
- ユーザーが有効にした場合のみ送信します。有効にする前と「試合開始」の前は判定を行いません。
- 解像度を512×288に縮小して送信します。ボールモデルの入力サイズがそれであるため、これ以上送信する理由はありません。
- Web中継はフレームを通過させるだけであり、ワーカーもボールモデルが参照する3枚のウィンドウのみをメモリ上に保持します。ディスクには書き込みません(
backend/app/tt_live.py冒頭の「映像は保存しない」)。 - 人間が判定を修正した記録は残りますが映像は含まれず、学習への利用に同意したデータのみをエクスポートできます(第6話)。
録画の分析はユーザーがファイルを選択してアップロードする機能であるため、性質が異なります。オリジナルは30日後に削除され、毎未明にクリーンアップ処理が実行されます。
長期計画:GPUをスマートフォンへ
自動採点を原則の内に戻す方法は、ボールの検出をスマートフォン内で実行することです。ボールモデルは149万パラメータと小さいものの、スマートフォンのブラウザで60fpsを達成できるかどうかはまだ実測されていません。現在は「サーバーに送信するが保存はしない」という現実的な妥協点となっています。
6. まとめ
- 手動スコアボードは スマートフォン1台で完結するプロダクトです。サーバーは自動採点、ライブ配信、記録を選択した場合にのみ登場します。
- 自動採点はフレームをサーバーに送信する唯一の経路であり、サーバーはフレームを保存しません。
- エンジンは純粋なPythonとして切り出してテストし、ルールはPython、JavaScript、ライブサーバーの3つのコピーを同一のケースファイルで統合しました。
- ワーカーはコードと重みをモデルリポジトリからsha256を照合して読み込み、機能はスイッチで有効化・無効化します。設定とコードが別々に動作するインシデントが実際に発生したため、有効にする際はワーカー内のコードまで確認します。
TTの一言。 スコアボードはもともとスマホ1台あれば十分です。サーバーは「ちょっとボールを一緒に見てよ」と頼む時だけに呼ぶ友達です。その友達も、見た場面を日誌に書き留めないと約束してくれています。
参考文献
- T. Gossard, F. Radovic, A. Ziegler, A. Zell. "BlurBall: Joint Ball and Motion Blur Estimation for Table Tennis Ball Tracking." CVPR Workshops (CVsports), 2026. arXiv:2509.18387.
- N. Ravi 他. "SAM 2: Segment Anything in Images and Videos." ICLR, 2025. arXiv:2408.00714.
- Y. Xiong 他. "Efficient Track Anything." ICCV, 2025. arXiv:2411.18933.
- 自社測定:リポジトリ
docs/tt/PERFORMANCE.md2.13・2.16・4.2、研究台帳E00・E07・E41・E44。
関連記事
- 本シリーズの 第2話:手動スコアボードと試合ルールエンジン, 第3話:自動判定, 第6話:試合記録とプライバシー.
- 前シリーズの TT Scoreモデル解剖 第1話:4つのレイヤーと時間的予算:フレーム予算16.7msをボール検出とエンジンがどのように分割使用するか。
- ビジョンモデルの読み方 第3回:リアルタイムでの実行:遅延予算、バッチ処理、デバイス内推論。