状态:草稿(2026-10-26),已反映实测数据。 结构基于 2026-10-25 的仓库代码读取,数据为我们直接测量所得,每个表格均附有日期和条件。经由互联网的实际用户路径延迟与手机现场准确率暂未实测。 编写规则:以实测数据为基础,未测出的内容标记为“未实测”。
目录
1. 一个记分牌上挂载了多少台机器
在乒乓球馆使用 TT Lab 的过程非常简单。将手机架设在球台旁边并打开 /tt/play/cam 点击“开启摄像头并开始”。屏幕上的摄像头画面上方悬浮着一个大记分牌,点击得分方一侧的屏幕半区即可增加分数。发球轮次、默频、局末换边等规则均由记分牌按规则提示。
到这里为止, 只有一台手机。记分规则、历史记录、读分播报以及录像全部在手机浏览器内部运行。服务器开始介入的情况仅限用户选择以下三项之一时:
- 开启自动计分。将摄像头帧发送至服务器的 GPU 工作进程以获取回合胜者建议。
- 进行实时共享。仅将得分事件发送至服务器供他人实时观看。
- 点击记录本场比赛。将已结束比赛的局分留在服务器上,经对手确认后计入积分。
虽然还有通过单独上传录像文件进行分析的途径(/tt/videos),但这属于用户直接上传视频文件的独立功能。
本文将这一结构绘制成一张导图,记录各部分之间传输的内容,尤其是 哪些数据流出了设备,哪些没有流出。判定规则的内部原理将在第 2·3 篇中展开,模型的原理在第 4 篇,训练在第 5 篇,记录在第 6 篇。
2. 四个部分的导图
| 部分 | 职责 | 运行环境 | 代码 |
|---|---|---|---|
| ① 手机浏览器 | 记分规则·历史·撤销,读分,录像与休息时间剔除,自动计分芯片,发送视频帧 | 用户手机 | backend/static/tt/play/ (无构建的 ES 模块) |
| ② Web 服务器 | 登录,自动计分中继(WebSocket),实时观赛,比赛记录与积分,保存修正记录 | 两台 Web 服务器(相同代码) | backend/app/tt_*.py (FastAPI) |
| ③ 实时判定工作进程 | 寻球,寻找并追踪球台,提取事件,裁判,“需要确认”判断 | 服务器 GPU(24GB 级)的一个时间切片 | live/(GPU 部分), ttscore/(纯 Python 引擎) |
| ④ 录像分析工作进程 | 用相同引擎分析上传的视频以生成比赛记录和集锦 | 同一服务器 GPU 的一个切片(并发任务 1) | 引擎文件夹的 batch 部分 |
为什么把引擎分成两块
观察 ③ 的内部可以发现它分为两层。需要 GPU 和 OpenCV 的部分(live/)以及仅接收球坐标与事件的 纯 Python 引擎(ttscore/。由于引擎是不需要 GPU 也不需要 OpenCV 的函数集合,因此在 CI 中仅通过合成轨迹来测试视频。即使更换球模型,裁判规则也必须照常通过测试。这种分离在前序系列 第 1 篇中作为“识别·事件·裁判·延迟确认”的四层结构进行了详细说明。
比赛规则被额外复制了一份。由于手机屏幕必须在不呼叫服务器的情况下计分,因此使用了将 Python 规则引擎(ttscore/rules.py)移植为 JavaScript 的 backend/static/tt/play/lib/scorekeeper.mjs 。实时观赛服务器(backend/app/tt_watch.py)上也存在相同规则的第三份副本。如果存在三份副本,它们迟早会产生偏差,因此我们让 Python 测试和 Node 测试读取 相同的案例文件(backend/static/tt/play/data/rules-cases.json。修改其中一方就会导致另一方的测试失败。
手机端:无构建的模块与 Service Worker
屏幕代码是不经打包器处理、由浏览器直接读取的 ES 模块。模块地址使用了带有内容哈希(?v=)的 import map,确保代码更改时地址随之改变,不会与旧缓存混淆。乒乓球区域设有 Service Worker(/tt/sw.js,范围 /tt/),它会缓存屏幕代码,但不会缓存 API 和记录界面。这是因为即使乒乓球馆的 Wi-Fi 断开,手工记分牌也必须持续运行,且分数记录必须始终查看服务器上的最新值。
/tt/play/cam 的首屏不会在一开始就加载沉重的区块(自动计分、录像、观赛),而是在使用时才加载。因为绝大多数人只使用手工记分牌。
3. 开启自动计分时发生的事情
自动计分是该结构中最长的链路。下面按顺序追踪单个视频帧转化为记分建议的全过程。
- 浏览器压缩并发送视频帧。 将摄像头画面缩小至 512×288 并以 0.7 的 JPEG 质量进行压缩,最高以每秒 30 张的速度发送(
backend/static/tt/play/cam-auto.js,lib/live.mjs)。虽然屏幕上以镜像显示,但发送的是未翻转的原图。帧的前面附带有 8 字节的头部(序号,拍摄时间戳 ms)。如果累积 3 天未收到答复的帧,则不再发送新帧。这是一种防止在低速网络中队列无限变长的机制。 - Web 服务器把守大门。 WebSocket 中继(
backend/app/tt_live.py)仅接收 Google 登录会员,并计算席位与时间配额。视频帧仅负责透传。 - 工作进程将视频帧转换为球坐标。 顺序为:增加亮度 → 球模型(BlurBall) → 选择上一球附近的候选 → 维持最近 2.5 秒的球轨迹 → 填补短时空白 → 提取事件 → 过滤学习到的反弹(veto)(
live/core.py). - 引擎进行判定。 只有在收到“比赛开始”信号后裁判才会启动,回合结束后会输出胜者、原因、置信度以及是否“需要确认”(第 3 篇)。
- 浏览器以芯片形式询问。 判定结果不会直接写入记分牌,而是以芯片形式弹出。如果无人响应,确定的判定结果会在 4 秒后、需要确认的判定结果会在 9 秒后直接写入(第 3 篇)。
球台在此流程之外被单独搜寻。在比赛开始前进行一次“寻找球台”(使用比球模型重得多的分割模型),此后每帧通过光流法进行追踪(第 4 篇)。
时间配额与限制
GPU 价格昂贵且属于共享资源。因此存在多重限制。以下是代码的默认值(运营配置文件中是否有单独的值尚未确认)。
| 限制 | 数值 | 席位 |
|---|---|---|
| 单次开启可使用时长 | 20分钟 | Web 中继 |
| 单人每日配额 | 40分钟 | Web 中继 |
| 站点整体每日配额 | 360分钟 | Web 中继 |
| 并发使用席位 | 3 | Web 中继与工作进程皆有 |
| 单帧大小 | 120,000 字节 | Web 中继 |
| 帧率 | 36张/秒(瞬间可多10张) | Web 中继 |
| 无数据传输时的断开时间 | 15秒(工作进程为20秒) | Web 中继与工作进程 |
| 预先寻找球台 | 每人每小时 30 次 | Web 中继 |
当连接断开时,浏览器会以 1、2、4、8、15 秒的间隔最多重新连接五次。对于重新连接也无济于事的关闭情况(如需要登录或配额耗尽等关闭代码 4401、4429、4430、4431 等),则不会重新连接。
实测:当并发会话增加时
单个工作进程最多接收三个会话。当三个会话同时接入时,三者平分一块 GPU 的时间切片。以下是在 2026-10-11 与语音合成服务共享同一 GPU 状态下的实测值(docs/tt/PERFORMANCE.md 4.2).
| 并发会话数 | 单会话处理 fps | 帧往返时间 |
|---|---|---|
| 1 | 29.4 | p99 约 32ms |
| 3 | 16–20 | p50 70–95ms,p99 约 127ms |
当处于三个会话时,21%–30% 发送的帧在处理前就被新帧覆盖而丢弃。这是因为工作进程的 GPU 循环只有一个,每次循环时仅将各会话的 最新窗口打包成一个批次进行处理,并使积压的窗口被新窗口覆盖(live/server.py 抬头,“延迟优先”)。对于记分判定而言, 不迟到比不遗漏任何一帧更重要。代价是当球速较快时,30fps 实际上会降至 16–20fps,正如前序系列 第 5 篇中所展示的那样,该引擎的帧率越低,“无需确认即出错”的情况就会增加。
实测:从回合结束到分数显示
目标是“回合结束 → 判定在 1.5 秒内”(研究负责人的实时预算)。引擎在最后一次反弹后 1.5 秒内如果没有新反弹或球持续 1 秒不可见,则判定回合结束。也就是说,预算的相当一部分消耗在 “是否真的结束了”的等待时间上。
| 测量项目 | 条件 | 数值 |
|---|---|---|
| 判定延迟(回合结束 → 判定) | 公开视频测试 7 场比赛,清晰条件,模拟实时路径(2026-10-13·14,负责人 E00·E07) | p90 1.31秒 |
| 判定延迟 | 八种拍摄条件 E2E(2026-10-14–15,PERFORMANCE 2.16) | 中位数 0.67–0.90秒 |
| 直至球台建议弹出 | 摄像头记分牌 E2E(2026-10-14,PERFORMANCE 2.13) | 2.8–3.4秒 |
| 单次寻找球台 | 运营实时工作进程测试图 211 次,服务器 GPU(24GB 级),预热后(2026-10-25–26,负责人 E44) | 中位数 1.73秒,p95 2.14秒 |
| 寻找球台首次调用 | 相同条件,将模型加载至内存的第一次 | 12.9秒 |
| 经由互联网的实际用户路径 | — | 未实测 |
尽管曾多次尝试通过缩短结束阈值来降低延迟(例如球消失时更快结束等),但均被驳回(负责人 E03·E10·E24·E28)。如果过早结束,会在长回合的防守中过早判定得分。
4. 部署:从模型仓库读取代码与权重
Web 端和工作进程的部署方式有所不同。由于这种差异曾导致过一次实际事故,在此详细记录。
Web 端采用镜像方式
Web 服务器采用常规方式。提交至仓库后,CI 运行测试并构建容器镜像,修改 GitOps 仓库中的镜像标签后,ArgoCD 会自动将其同步至开发和运营环境。屏幕代码(backend/static/tt/)也包含在该镜像内部。
工作进程使用共享卷而非镜像
GPU 工作进程 不会重新构建新镜像。 它们直接使用同一台 GPU 服务器上已经在运行的 AI 服务镜像(PyTorch 2.9、Python 3.11),并从 模型仓库(共享卷) 中读取并加载引擎代码、BlurBall 模型代码、权重以及 OpenCV wheel。引擎代码经常变动,但像 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) 虽然完成了实验,但由于未达到开启条件而不在配置中(处于关闭状态)。开关方式的好处在于 关闭起来非常容易。只需保持代码和权重不变,删掉一行配置即可恢复到以往的行为。
这种方式引发的事故
我们也确实经历过它的缺点。与网页端不同,工作进程的配置在自动部署之外,需要由人工应用,而代码则是另外注入的。因此, 配置和代码可能会出现脱节。 在 2026-10-18 时,确实曾出现过配置中开启了 TTSCORE_EDGE_REVIEW=1 ,但模型仓库的引擎代码中尚无该功能的实际状态(研发日志 E41)。开关虽然开着,但没有执行任何操作,也没有报错。通过重新注入代码解决了该问题,此后在开启功能时,会严格遵循「应用配置 → 注入代码 → 重启 → 确认工作进程内的代码包含该功能」的顺序。
出于同样的原因,在 2026-10-25 开启角点模型(TT_TABLE_KP) 时,我们不仅查看了配置文件,还将测试图像实际发送给运行中的工作进程,以检查响应中是否存在角点模型的痕迹。全部 212 个响应中都有角点模型运行的记录(研发日志 E44,第 4 篇)。
格子之间的藩篱
由于工作进程负责接收用户的视频,因此我们为其设置了多层防护网。
- AI 工作进程所在的区域默认拒绝所有入站连接,仅允许白名单中列出的 Pod(如 Web 服务器)连接到工作进程的一个端口。
- 录像分析工作进程 不具备访问数据库和存储的密钥。 它仅使用 Web 的内部 API,并且必须同时具备服务令牌和每次任务下发的租用令牌。出站连接仅限于 DNS 和 Web 服务器。
- 工作进程不附加 Kubernetes 服务账户令牌,并在剥离了所有 Linux capability 的情况下运行。
- 工作进程的日志中不保留视频、球坐标以及人员信息(
live/server.py).
5. 哪些数据会离开设备,哪些不会
TT Lab 乒乓球画面的原则在 backend/static/tt/play/cam.js 前言中有一行简述: 相机视频绝不离开设备。 按功能整理如下:
| 功能 | 保留在手机内的数据 | 发送至服务器的数据 | 留在服务器上的数据 |
|---|---|---|---|
| 手势记分牌 | 完整比赛历史(localStorage)、设置 | 无 | 无 |
| 语音播报比分 | 浏览器语音合成(speechSynthesis) |
无 | 无 |
| 录制与剔除休息时间 | 录制文件(保存于手机)、动作指示 | 无 | 无 |
| 实时共享 | 视频 | 得分·撤销·结束事件(附带序号) | 直播比赛与事件队列 |
| 记录本场比赛 | 各回合历史记录 | 局分、比赛时长、对手 | 比赛记录、积分 |
| 自动判分 | 原始分辨率视频 | 512×288 JPEG 帧(最高每秒 30 张) | 帧数据 不予保存。修正后的判罚记录(包含同意标识,无视频) |
| 上传录像分析 | — | 用户选择的视频文件(由浏览器压缩至 360p60 500kbps 后上传) | 原始文件保留 30 天,分析结果 |
自动判分是该原则的 例外。因为寻球需要 GPU 观察视频,而目前该 GPU 位于服务器上。因此,我们将例外范围缩小了。
- 仅在用户开启时发送。在开启前以及「开始比赛」前不进行判定。
- 分辨率缩减至 512×288 后发送。由于这是球模型的输入尺寸,没有必要发送更多内容。
- Web 转播仅让视频帧流经,工作进程也只在内存中保留球模型所看的 3 张图窗口,不会写入磁盘(
backend/app/tt_live.py前言「视频绝不保存」)。 - 人工修正判罚的记录会被保留,但不包含视频,且仅有同意用于训练的行才可被导出(第 6 篇)。
录像分析是一项由用户直接选择并上传文件的功能,其性质有所不同。原始文件会在 30 天后删除,并有每日凌晨运行的清理任务。
长期计划:将 GPU 搬到手机上
将自动判分回归到原则之内的途径,是将寻球功能移至手机端运行。尽管球模型很小,只有 149 万个参数,但它能否在手机浏览器中跑满 60fps 尚未经过实测。目前「发送至服务器但不保存」是一个现实的妥协。
6. 总结
- 手势记分牌是 单台手机即可完成的产品。服务器仅在选择自动判分、直播和记录时才会介入。
- 自动判分是将视频帧发送到服务器的唯一途径,且服务器不会保存视频帧。
- 引擎使用纯 Python 进行独立测试,规则则将 Python、JavaScript 和直播服务器的三个副本与同一个用例文件绑定。
- 工作进程通过比对 sha256 从模型仓库读取代码和权重,通过开关开启或关闭功能。由于曾发生过配置与代码脱节的事故,因此在开启功能时,会同时检查工作进程内部的代码。
TT 的肺腑之言。 记分牌本来有一台手机就足够了。服务器只是在你想要「帮我一起看下球」时才被召唤的朋友。而我们或许诺过那位朋友,绝对不在日记本里记下它看到的画面。
参考文献
- 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 篇:四层架构与时间预算:寻球与引擎如何分配 16.7ms 的帧预算。
- 视觉模型解读 3:实时运行:延迟预算、批处理、端侧推理。