为什么选这场:它是六场跑通全管线的素材里最接近 App 拍摄引导教的机位(场地正后方、固定、单打、离人近),也是产品真正要面对的画质,不是转播级。它的中间产物缓存都还在,每一步都能拿到真实输出。
怎么读:每一步先说"做什么",再给"进什么、出什么",然后是这一步的真实画面和数字,最后是它会在哪里出错。页尾有一张"每个指标怎么算出来的"总表和分步耗时。
视频进来后先做体检:时长在一小时以内、能解码、画面里有人。任何一项不过就当场退回,不进管线。

在全片抽九个时刻,比较相邻画面的整体位移。手机被碰过、有人挪过三脚架,这里会看出来。晃动大的视频后面的位置类数字都不可信,所以先判。
已知边界:这一步按固定时距抽样,视频越长越粗;剪辑过的集锦会在切换点上被判"晃",这是正确行为,用户传原片没有这个问题。
这是最花时间的一步。每秒取一帧,跑一遍人体姿态模型,得到每个人 17 个关节点。然后按"脚在画面下半部、身高像素够大"把近端球员挑出来,其余算远端或路人。

把第 2 步得到的逐秒脚点直接叠在画面上:近端球员一种颜色、远端一种,待得久的地方更亮。不做球场标定,所以它是"画面上的热点",不是俯视图。
assets/heatmap.png
"跑动距离"本该在这一步顺手算出来,但需要场地标定把像素变成米,目前引擎输出为空,所以战报里跑动那一格是横杠。
把音轨降到 8 千赫,取能量包络,找出"突然响一下"的瞬态,每个瞬态记为一次击球声。瞬态密集的一段是一个回合,长时间没响声就是捡球和休息。回合边界附近的孤立响声会把边界向外延伸。

| 回合 | 起止 | 拍数 | 边界 | 击球点测点数 |
|---|---|---|---|---|
| 1 | 0.7 – 15.7 秒 | 24 | ok | 8 |
| 2 | 41.1 – 90.3 秒 | 24 | ok | 8 |
| 3 | 96.9 – 105.9 秒 | 7 | ok | 7 |
| 4 | 114.2 – 120.2 秒 | 3 | ok | 3 |
| 5 | 127.0 – 143.5 秒 | 4 | extended | 4 |
| 6 | 188.3 – 200.1 秒 | 4 | ok | 4 |
| 7 | 209.9 – 226.3 秒 | 5 | ok | 5 |
| 8 | 231.6 – 250.8 秒 | 7 | extended | 7 |
| 9 | 250.8 – 292.4 秒 | 17 | extended | 8 |
每个回合最多取 8 个击球声时刻,每个时刻看前后共 3 帧,找出近端球员,量"持拍手腕离地多高"。尺子不是球场线,是人自己:假设身高 1.75 米,鼻子到脚的像素距离就对应 1.645 米(0.94 是鼻到脚占身高的比例)。然后给每个测点打质量标签,手臂关节点不清楚的标"受限"并降权,不删除。


按第 4 步的起止时间,把每个回合从原片里剪成独立短片,缩到 960 宽以便手机流式播放。这是"每个数字都能点开看画面"的底层素材。
assets/rally_00.mp4 到 rally_08.mp4
先按规则挑出 4 个候选瞬间:全场最高的可信击球点做"签名瞬间"(慢放)、拍数多的回合收尾做"多拍激战"(最多 2 个)、不够就按回合时长补位,同一回合最多一个。每个候选瞬间前后半秒跑一次球检测,看球是不是真的飞进了挥拍者附近并且在动,不是的话淘汰换下一个。通过的瞬间再按人和球的位置裁成竖版、触球前后放 0.5 倍慢、把球的轨迹画上去,最后把人声压掉保留击打声,拼成一条。
highlight.mp4 与清单 highlight_manifest.json

| 顺序 | 标签 | 来源 | 选材规则 | 慢放 | 验球 | 时长 |
|---|---|---|---|---|---|---|
| 1 | 多拍激战 · 第 2 回合约 24 拍 | 88.8 秒 | 拍数多的回合收尾,锚在最后一次实测击球 | 否 | 通过 | 4.5 秒 |
| 2 | 击球点 1.96m · 估算值 | 103.3 秒 | 全场最高可信击球点 = 签名瞬间 | 0.5× | 通过 | 4.3 秒 |
| 3 | 精彩回合 · 第 8 回合 | 242.0 秒 | 按回合时长补位 | 否 | 通过 | 4.5 秒 |
| 4 | 多拍激战 · 第 9 回合约 17 拍 | 292.4 秒 | 拍数多的回合收尾 | 否 | 通过 | 4.5 秒 |
取全场最高的可信击球点那一帧,存成图片,同时算出手腕在画面里的相对位置,让 App 能把"1.96 米"的高度线压在手腕上。
hero.jpg + 手腕纵向位置 0.51、脚点横向位置 0.29
前面八步的结果写成一个数据文件 match.json,页面直接读它。战报上一些数字是页面当场算的,不是管线算的:高位击球占比、场均击球点、活跃度都在这一层。




| 指标 | 算法一句话 | 这一场的值 | 来自第几步 | 会错在哪 |
|---|---|---|---|---|
| 回合数 | 击打声瞬态成簇,簇与簇之间的安静段是分界 | 9 | 4 | 邻场噪音会多切或少切;从未和人工对账 |
| 拍数 | 回合内的瞬态个数 | 95 | 4 | 任何"响一下"都算一拍;嘈杂馆虚高 |
| 最长回合 | 拍数最多的回合 | 24 拍 | 4 | 同上 |
| 最高击球点 | 所有"可信"测点里的最大手腕高度,身高比例尺换算 | 1.96 m | 5 | 身高假设 ±6%;击球帧差一帧 5 到 8 厘米 |
| 场均击球点 | 页面当场算:可信测点的平均值 | 0.91 m | 5 → 9 | 混进了非高手位时刻,对高手偏低 |
| 高位击球占比 | 页面当场算:可信测点里 ≥1.5 米的比例,可信测点少于 5 个不算 | 3% | 5 → 9 | 同上,口径待改为"只看过肩挥拍" |
| 活跃度 | 回合数 ÷ 视频时长,折成每小时;视频短于 300 秒不算 | — 296.6 秒,差 3.4 秒 | 9 | 短片外推会离谱,所以设了 300 秒门槛 |
| 跑动 | 逐秒脚点换算米数,需要场地标定 | — 引擎输出为空 | 3 | 目前所有场次都是空 |
| 战力分 | 击球点分 + 跑动分 + 活跃度分加权;击球点 1.60 米记 30 分、2.20 米记 60 分、2.90 米记 95 分,线性插值;有效项少于 2 个判"数据不足" | — 击球点 47.9 分,但只有这一项 | 5、3、9 | 只有一项时不出分,防止单项虚高 |
| 最快杀球 | 没有算法,文案写死"待 60 帧" | — | 无 | 见系统全景 4.2:先做飞行时间分档 |
这一场把三道闸门全都触发了:活跃度差 3.4 秒没过时长门,跑动引擎为空,战力分因此只剩一项而判"数据不足"。所以战报上有三个横杠。这恰好说明系统宁可不出数也不编数,但对用户来说横杠就是横杠。
为了拿到分步时间,我把同一段视频用现行代码在空闲的 Mac 上重新跑了一遍,每三秒记录一次后台阶段。结果比 8 月 31 日原始那次的 465 秒长了近两倍:合成集锦一步就占了八成。原因是 8 月 31 日之后集锦线加上了整段球标注、跟踪取景和人声清洁,这三样都要逐帧跑重模型。这印证了系统全景 4.4 的判断:60 帧视频慢,主要慢在集锦,不在战报。
| 阶段(后台真实名称) | 开始于第几秒 | 本阶段用时 | 占比 |
|---|---|---|---|
| 检查画面稳不稳 | 0 秒 | 3 秒 | 0.2% |
| 找人、算走位(每秒一帧,296 帧) | 3 秒 | 158 秒 | 12% |
| 按球轨迹或声音切回合 | 161 秒 | 3 秒 | 0.2% |
| 测量击球点(54 个测点 × 3 帧) | 164 秒 | 80 秒 | 6% |
| 剪 9 个回合的回放 | 244 秒 | 9 秒 | 0.7% |
| 合成集锦(验球 + 整段球标注 + 跟踪取景 + 人声清洁 + 渲染)与导出定格帧 | 253 秒 | 1065 秒 | 81% |
| 合计(现行代码,空闲的 M1 Pro,纯 CPU) | — | 1316 秒 | 4.4 倍视频时长 |