状态:草稿(2026-10-26),已反映实测数据。 规则与行为读取自 2026-10-25 的仓库代码,录制剪辑的数字是通过 2026-10-13 公开比赛视频直接测得的值。手机上的录制成本(电池与发热)以及人工点击得分的延迟未进行实测。 编写规则:以实测数据为准,未能测得的内容标注为“未实测”。
目录
- 1. 为什么从手动记分牌开始
- 2. 规则引擎:将规则手册条款转化为代码
- 3. 准备界面:赛前确定的事项
- 4. 不存状态存历史
- 5. 防止误触与遥控器
- 6. 语音报分
- 7. 界面:全屏、记分牌大小与常亮
- 8. 录制与剔除休息时间
- 9. 双打支持到什么程度
- 10. 总结
- 参考文献
- 延伸阅读
1. 为什么从手动记分牌开始
TT Lab 的代表功能是相机记分牌,但在其中使用最频繁的并非自动判决,而是 人工点击的记分牌。原因很简单。自动判决需要服务器 GPU、登录以及时间成本,在条件不佳时经常弹出“需要确认”。而手动记分牌仅凭一台手机即可随时运行。
因此,该记分牌即使没有自动判决也必须是一个完整的产品,而自动判决的设计应当作为“建议”叠加在其上(第三篇)。本文将介绍作为基础的规则引擎和手动记分功能。
2. 规则引擎:将规则手册条款转化为代码
依据文档
规则遵循 ITTF 规则手册(Statutes 2026,2026-01-01 生效)第二章“The Laws of Table Tennis”[1]。条款编号经过查阅原文确认,下方简述为中文。
| 条款 | 内容(摘要) | 代码 |
|---|---|---|
| 2.11.1 | 先得 11 分的一方赢得该局。双方比分达到 10 分平手后,先领先 2 分的一方获胜 | award() 的“大于等于 11 分且分差为 2” |
| 2.12.1 | 比赛采用五局三胜或七局四胜制,赢得过半局数的一方获胜 | games_to_win 2·3·4 = 3·5·7 局赛制 |
| 2.13.3 | 每得 2 分更换发球方。比分达到 10:10 时,发球顺序保持不变,但每人轮流发 1 分球 | turnIndex() |
| 2.13.6 | 在一局比赛中先发球的一方,在下一局中先接发球 | servingTeam() |
| 2.13.7 | 一局比赛结束后交换场地。在决胜局中,当一方先得到 5 分时交换场地 | leftPlayer(), award() 的 decider |
| 2.13.4–2.13.6 | 双打的第一发球员、接发球员、轮转顺序,以及决胜局中接发球顺序的对调 | cycleFor(), dswap |
| 2.14.3 | 如果发球、接发球或场地顺序出现错误,一经发现应立即纠正,在此之前的所有得分均有效 | “直接指定发球权”(第 4 节) |
有一个容易混淆的地方。 局间换边为 2.13.7 ,而紧邻前方的 2.13.6 是发球与接发球顺序。由于编号相连,很容易混淆引用。
也有未收录的内容。促进规则(2.15,若 10 分钟后比赛未结束,则轮流发球,接发球员合法回击 13 次后得判得分)未加入记分牌中,仅存在于网站的规则问答中。暂停和局间休息时间因未能与规则原文核对而未加入。
Python 原型,JavaScript 副本
规则的原型来自于自动判决引擎的 ttscore/rules.py 。由于手机端需要在无服务器的情况下计分,因此采用了将相同规则移植到 JavaScript 的 backend/static/tt/play/lib/scorekeeper.mjs 。两个实现读取相同的测试用例文件(backend/static/tt/play/data/rules-cases.json)。由于 Python 测试使用引擎、Node 测试使用 JavaScript 运行相同的用例,因此修改一端就会导致另一端测试失败。网站规则说明文档中出现的数字(11 分制、2 分分差、5 分换边等)也集中存放在一处,并通过测试与引擎进行核对。
发球轮次:一行表达式
发球轮次仅凭“当前局的总得分”即可确定。
// backend/static/tt/play/lib/scorekeeper.mjs turnIndex() 에서
const total = points.A + points.B;
const deuceAt = 2 * (points_to_win - 1); // 11점 게임이면 20 = 10:10
return total < deuceAt ? Math.floor(total / 2) // 2점마다 한 차례
: deuceAt / 2 + (total - deuceAt); // 10:10 부터 1점마다
如果轮次编号为偶数,则由该局的首发球员发球;若为奇数,则由对方发球。若为奇数局,该局首发球员为最初指定的一方;若为偶数局,则为对方(2.13.6)。记分牌不单独存储“发球状态”,这一点非常重要。发球是 根据分数和首发球员计算出的值,因此一旦比分改变,发球方自动正确。这一特性使得第 4 节的“历史”设计成为可能。
换边:只需计算次数
谁站在屏幕左侧也是计算出来的。
// leftPlayer() 에서 줄임
const swaps = (game_no - 1) + (decider_swapped ? 1 : 0);
return swaps % 2 === 0 ? start : other(start);
每局进行一次换边,在决胜局达到 5 分的瞬间再换一次。决胜局通过 game_no === 2 × games_to_win − 1 判断,5 分通过 floor((points_to_win + 1) / 2) − 1 计算。如果是 11 分制则为 5。如果在决胜局中发生了 5 分换边, award() 将返回 endChange ,屏幕上会显示“决胜局 5 分 — 交换场地!”。
比赛结束时
当获胜局数达到 games_to_win 时,胜负即告确定 winner ,之后的得分将被忽略。已结束比赛的比分(例如 11:9、12:10)会保留在 finished 列表中并传递至记录中(第 6 篇)。
3. 准备屏幕:赛前设定
/tt/play/cam 在的“比赛准备”中可选择以下选项:backend/static/tt/play/cam.js).
- 比赛长度:3、5、7 局制(默认 5 局制)。
- 昵称:最多 12 个字符。输入框旁附有“请用昵称代替真实姓名”的提示(第 6 篇)。
- 优先发球方, 屏幕左侧选手, 我是哪一方.
- 防止误触(第 5 节)。
- “开启摄像头并开始”或“无摄像头仅计分板”。
开启摄像头后比赛不会立即开始,而是进入 热身状态。在热身期间点击计分区域不会增加分数,而是会通过晃动“开始比赛”按钮来予以提示。热身期间唯一可进行的调整是指定发球权以及交换左右场地。为什么需要这种状态与自动判罚交织在一起,这将在第 3 篇中详细探讨。仅从手动计分板的角度来看,它就是“为了不把热身拉力赛算作得分的门槛”。
4. 记录而非状态
问题:很久之后才发现的失误
手动计分板最常见的失误是 很久之后才发现按错了分数。手机放在距离球台几米外的三脚架上,选手也不会在每球结束后都看计分板。过了大约三分后才发现“刚才那一分其实是我的”。
如果计分板只保存 当前状态(7:5、B 发球、A 在左侧),那么这个失误将很难修正。如果将 7:5 改为 6:6,那发球轮次该怎么办?如果这期间比赛已经结束了呢?一旦手动修改状态,就会产生与规则冲突的隐患。
解决方法:保存历史记录并回滚
摄像头计分板的比赛对象(位于backend/static/tt/play/lib/camscore.mjs 的 CamMatch)存储的不是状态,而是 历史记录。历史记录中的一项可以是“A 得分”、“B 得分”这样的得分事件,或者是“更改发球权”这样的调整。屏幕上显示的分数永远是 从空白计分板开始重新执行历史记录的结果。
// camscore.mjs 에서 줄임
remove(i) { // i 번째 칸을 지우고 뒤를 다시 돌린다
const [e] = this.log.splice(i, 1);
this._replay(); // 새 Scoreboard 에 남은 칸을 처음부터 넣는다
return e;
}
通过这种方式,你可以在“调整分数”表格的历史记录列表(最近 60 项)中删除任意一项,删除后其后续的发球轮次、换边、比赛结束都会 自动按照规则 重新匹配。无需担心重新计算的开销。一场比赛最多也就几百条记录,添加一项不过是做几个整数加法。这一原则在自动判罚的人工确认设计中也是相同的(第 3 篇)。
调整也是历史记录中的一项
在分数调整表格中,你可以执行以下操作:增加或减少分数、局数,直接指定发球权,“重新开始本局(0:0)”,交换选手左右场地,以及删除历史记录项。其中有两项的设计非常有趣。
- 直接指定发球权等同于“颠倒第一发球方”。 正如第 2 节所见,发球是根据分数和第一发球方计算得出的值,因此没有单独覆盖的空间。因此,如果修改为“现在的发球方是 B”,代码就会插入一个颠倒本局第一发球方的调整项。此后也会每 2 分钟(从 10:10 开始每 1 分钟)按规则正常轮转。之前的分数保持不变。这与规则 2.14.3 的运行机制相同,即即使发球顺序错了,之前的得分也全部有效。
- 手动增加的局数会从记录中剔除。 如果手动增加了一个局数,计分板并不知道该局的具体比分是多少。代码会在已结束比赛列表中插入带有
adj标记的项(获胜方 11:0),以保持比赛流程的连贯性,但在将比赛传递到记录中时会排除该局,并要求人工直接填写。因为捏造的 11:0 不应计入等级分中。
撤销按钮等同于删除历史记录的最后一项。“重新开始”在获得确认后会清空历史记录并返回热身状态。
保存:每次变动时保存至手机
历史记录在每次变动时都会保存到浏览器的 localStorage(ttplay:cam)中。即使不小心关闭了屏幕或接听电话后回来,比赛状态依然完好。设置会单独(ttplay:cam-prefs)保存。服务器上不会发送任何数据。
5. 防止误触与遥控器
得分区域为整个屏幕的一半
很难精确点击放置在远处的手机。因此,得分区域不是一个小按钮,而是 整个屏幕的一半。点击任何地方都算作该名选手的得分。不过,路过时不小心触碰的情况也会增加,因此可以在准备屏幕中选择防护方式。
| 方式 | 动作 | 常量 |
|---|---|---|
| 无 | 点击一次即生效 | — |
| 两次 | 必须在 1.5 秒内连续点击同一侧两次才生效。第一次仅显示“再按一次”的提示 | DOUBLE_MS = 1500 |
| 长按 | 必须按住 0.5 秒以上才生效 | HOLD_MS = 500 |
遥控器与自拍杆
为了应对无法触及手机的情况,程序也支持按键输入。
| 动作 | 默认按键 |
|---|---|
| 左侧选手得分 | ←, PageUp, 调高音量 |
| 右侧选手得分 | →, PageDown, 调低音量 |
| 撤销 | Backspace, u, z |
| 开始比赛 | Enter, 播放/暂停, s |
演示遥控器通常会发送 ←·→ 或 PageUp·PageDown,而蓝牙自拍遥控器通常会发送增大音量或 Enter(代码中有注释)。问题在于,Android 和 iOS 浏览器往往不会将音量键传递给页面,而是直接改变音量大小。因此,我们设置了“按键学习”功能,按下一次遥控器按钮即可将实际收到的按键绑定到该动作。如果一个按键被绑定到两个动作,则会丢弃后面的那个。
“开始比赛”键 仅在热身期间 生效。这是为了防止在比赛过程中因误按遥控器而导致比赛回到起点。比赛中如需重新开始,必须在屏幕上进行确认。
6. 比分语音播报
每次得分时都会语音播报比分(使用backend/static/tt/play/lib/cam-speech.mjs).
- 浏览器的语音合成(Speech Synthesis)
speechSynthesis功能)。不会发送到服务器。 - 支持韩语、英语和日语,语速分为正常(1.0)和快速(1.35)两种。
- 就像真正的裁判一样, 会先播报服务器端的比分 。如果在决胜局(Deuce)后领先一分,会附加播报“优势(Advantage)”;如果发球权发生交换,则会附加播报“交换发球(Change of serve)”。
代码中还隐藏着一个小陷阱。相机记分牌为了让首屏加载更轻量,会在调用繁重功能时才去加载,但唯独语音播报函数没有延迟加载,而是留在了首屏代码中。因为 iOS Safari 要求必须在 用户点击的那个瞬间 发声一次,之后才能正常发出声音,如果等到模块加载完毕再发声就已经迟了。
7. 画面:全屏、记分牌大小与防息屏
- 全屏(
lib/fullview.mjs): 如果支持 Fullscreen API,则通过它连地址栏一起隐藏。iPhone Safari 没有针对普通元素的请求全屏函数(仅适用于 video 元素),因此这种情况下会用铺满屏幕的固定布局来代替,并引导用户点击一次“添加到主屏幕”。 - 记分牌大小与位置(
lib/boardview.mjs): 提供常规、大、隐藏三种模式,大小有 0.8 倍、1 倍、1.3 倍可选,位置共有五个(左上、中、右上,左下、右下)。因为屏幕和录像会 读取相同的设置,所以录像中也会嵌入在屏幕上看到的完全一样的记分牌。 - 横屏锁定与防止屏幕熄灭): 尝试锁定横屏方向,并通过 Wake Lock 防止比赛期间屏幕熄灭。
8. 录像与去除休息时间
在手机内录像,在手机内剪辑
录像会将记分牌通过 canvas 合成到相机画面上,并 保存到手机中(cam-rec.js, lib/cam-record.mjs)。不会上传到服务器。如果支持 WebCodecs,编码器会直接用它生成 H.264,否则使用 MediaRecorder。
如果任由比赛录像原样保存,其中有一半以上都是捡球、拿毛巾和对话。因此,在停止录像时,会在手机内制作一份 仅保留多拍对打(Rally)的剪辑版(默认开启)。问题在于,如何知道“多拍发生在哪个时间段”?
录像期间记录的三种标记
lib/breakedit.mjs 中的 MarkLog 会在录像时间轴上记录三种数据。
- 手动得分时刻。 即人工点击得分栏的时刻。不包含自动判决芯片响应的内容(与第 2 点重叠)。
- 自动判决到达时刻与多拍长度。 仅在开启自动比分时存在。
- 画面移动。 以每秒 8 次、缩减为 128×36 灰度图的画面,测量球台周围区域(横向 10–90%,纵向 33–72%)发生变化的像素比例。
保留区间的计算规则
keepSpans() 中的规则如下。
| 标记 | 保留区间 | 依据 |
|---|---|---|
| 手动得分(点击时刻 t) | [t − 10秒, t] | 人工计数时唯一知道的只有点击时刻,而多拍在那个时刻 之前 已经结束。10秒基于下文实测 |
| 自动判决(到达时刻 a,多拍长度 d) | [a − d − 1.0 − 1.2, a + 1.5] | 由于网络延迟和处理原因,判决到达较晚,因此要多保留相当于该延迟(1.0秒,估计值)的时间。前留 1.2 秒、后留 1.5 秒用于发球姿势和得分反应(与上传剪辑版数值相同) |
| 画面移动 | 如果手动得分窗口的起点位于移动的正中间,则 仅延长 | 到移动开始的地方,最多 30 秒。挽救长于 10 秒的多拍(约 9%)。不会缩短 — 因为发球准备时很安静,如果只凭画面移动来裁剪,发球部分会被切掉 |
| 公共 | 前后预留 1 秒余量,间隔在 0.5 秒以内的区间会合并 | — |
核心在于 画面移动不会创建新的区间这一原则。没有得分或判决标记的时间段(即热身和休息时间),无论画面移动多么频繁都不会被保留。因为从画面移动来看,热身时的多拍和正式比赛一模一样。
有时也不会制作剪辑版。如果没有一个标记(既没有手动比分也没有自动判决),或者缩减后短于 5 秒、不足原片的 10%,则不制作。因为前面还要加上 2.5 秒的标题卡,反而可能会变长。
实测:为什么是 10 秒
我们未能测量出人们在多拍结束后多少秒才点击(d 未实测)。因此,我们通过将 d 设为 1、2、3 秒进行了模拟。在 12 段公开比赛视频的 275 个多拍(从发球击球到结束标签)中加入“在结束 d 秒后按下”,测量了从发球前 1.05 秒到结束完整落入一个区间的比例,以及剪辑版长度占比赛区间的比例(2026-10-13, docs/tt/PERFORMANCE.md 2.11).
| 点击前 N 秒 | d=1秒:完整 · 剪辑长度 | d=2秒:完整 · 剪辑长度 | d=3秒:完整 · 剪辑长度 |
|---|---|---|---|
| 6秒 | 78.5% · 42.4% | 65.8% · 42.4% | 44.0% · 42.5% |
| 8秒 | 90.2% · 53.7% | 82.9% · 53.9% | 78.9% · 54.1% |
| 10秒(选中) | 93.1% · 63.3% | 91.3% · 63.5% | 90.5% · 63.7% |
| 12秒 | 95.3% · 70.7% | 94.2% · 70.9% | 93.1% · 71.2% |
| 15秒 | 96.0% · 78.2% | 95.6% · 78.4% | 95.3% · 78.7% |
10秒是 无论 d 是 1、2 还是 3 秒都能超过 90% 的最短数值。相同多拍的长度中位数为 2.2 秒,90 分位为 5.9 秒,95 分位为 9.6 秒,99 分位为 29.3 秒,而多拍之间的间隔中位数为 10.3 秒。也就是说,10 秒的窗口既能容纳绝大多数多拍,又通常能很好地切掉多拍之间的休息时间。少数较长的多拍则通过画面移动来延长。
通过对同一段视频(140秒,包含 7 个正确多拍)进行完整测试,剪辑版将 139 秒的录像缩减到了约 95 秒,7 个多拍中有 6 个从发球姿势开始得以保留。漏掉的一个是长达 24 秒的区间,期间选手走出了画面导致画面移动中断。由于仅为单个视频,效果的规模无法推广泛化。
成本
这是在同一笔记本电脑 CPU 上使用 Chrome 测得的相对成本(2026-10-13,由于当时有其他任务在同时运行,仅参考比例)。
| 项目 | WebCodecs(硬件 H.264) | MediaRecorder |
|---|---|---|
| 录像增加的 CPU(相比仅开启相机) | 约 10 CPU秒/分钟 | 约 15 CPU秒/分钟 |
| 停止后的剪辑(49秒剪辑版) | 0.4 CPU秒, 0.56秒 | 22–26 CPU秒, 49秒 |
| 1分钟存储容量 | 16.7MB | 约 18MB |
WebCodecs 这一侧的剪辑几乎不费力的原因是 不需要重新编码 。如果在录像时每隔 1 秒插入一个关键帧,那么每秒钟都会产生一个可以剪切的点,剪辑时只需将需要保留的帧复制打包到新文件中(remux)即可。而 MediaRecorder 则是通过重新播放剪辑版并录制的方式,因此会消耗与剪辑版长度相当的时间。
手机端未进行测量。 电池续航、发热以及超过一小时录像的内存消耗均未实测,且没有依据表明笔记本电脑的硬件编码器和手机编码器会保持相同的比例。
9. 双打支持到什么程度
双打规则 已在 JavaScript 规则引擎中实现。 从选择第一位发球员和接球员、四个人的发球轮转、决定下一局第一位接球员(2.13.5),到决胜局中一方得到 5 分时交换接球顺序(2.13.6)均已实现,可在没有相机的“发球与比分助手”(/tt/play/scorekeeper)中使用。
但其余功能仅限于单打。
| 功能 | 双打 |
|---|---|
| JavaScript 规则引擎、发球与比分助手 | 支持 |
| 相机记分牌 | 不支持(仅限单打) |
自动判决引擎(rules.py) |
不支持(仅限单打) |
| 实时共享 | 不支持(不发送双打比赛) |
| 比赛记录与等级分(Rating) | 不支持 |
自动判决这一侧还有一个额外的难点。双打比赛中同队的两位选手必须轮流击球,但正如第 3 篇所示,我们的裁判并不去辨别“是谁击的球”,而仅凭弹跳顺序来判断。要在双打中抓出顺序错误的击球,就必须区分不同的人。
10. 总结
- 比分规则与规则手册的条款一一对应,并将 Python 原版与 JavaScript 副本绑定到了相同的用例文件。
- 发球和场上位置不会被保存,而是 根据比分和第一位发球员进行计算。因此,比赛可以作为历史记录保存,即使删除了很久以前的一分,后面的内容也会按照规则自动重新对齐。
- 录像中去除休息时间只需“仅由得分和判决标记创建区间”这一条规则即可过滤掉热身和休息时间。10 秒窗口是通过模拟 275 个多拍选出来的,而人工点击的延迟以及手机端的成本目前尚未实测。
乒乓小结。 记分牌不会去记“现在是多少比多少”。它记的是“谁在第几分赢了”。因此,即使删除了三个球前的失误,发球权和站位也会自动重新正确匹配。
参考文献
- International Table Tennis Federation. ITTF Statutes 2026 (effective 1 January 2026), Chapter 2: The Laws of Table Tennis, §§2.10–2.15. https://db.ittf.com/document
- 我们的测量:代码仓库
docs/tt/PERFORMANCE.md2.11. 代码backend/static/tt/play/lib/scorekeeper.mjs·camscore.mjs·breakedit.mjs·cam-speech.mjs·fullview.mjs.
延伸阅读
- 本系列 第 3 篇:自动判决:为什么热身状态在自动判决中至关重要,以及人工介入成为芯片答案的规则。
- 本系列 第 6 篇:比赛记录与个人隐私:已结束比赛的局分如何转化为战绩记录和等级分(Rating)。
- 前作系列 TT Score 模型剖析第 5 篇:事件与裁判:自动判决引擎采用相同规则的方式。