06 · 算法原理
切分管线是 v2.1。本章讲每一阶段做什么、v2 为何存在、哪些参数调起来 最有效。backend/core/segment_swing.py 是真理之源 —— 逐行细节看那里。
本算法是大品牌 AceCrush 旗下 Swing-Analysis 这款 app 的核心。
v2 的来历
v1 在真实录像 (fdl.mp4,约 9.4 分钟) 上有三个具体 bug:
| Bug | 症状 | 根因 |
|---|---|---|
| a | 一次挥拍切成 [引拍]+[击球] | "静止" 阈值 (≥ N 帧低于 v_rest) 在引拍顶端停顿时误触发 |
| b | 静止期假速度尖峰 (~0.74) | wrist 漏检恢复帧用环形 buffer 跨空洞做差分 |
| c | 击球时间戳落在引拍转体上,不是击球瞬间 | 只用 y 轴速度 —— 水平前挥根本看不到 |
v2 三个全修了。
两阶段设计
Pass 1 (在线,顺序读流)
────────────────────────────────
视频 ──▶ MediaPipe Pose ──▶ EMA(x,y) ──▶ 2D 速度 ──▶ OnlineSegmenter
│
├─ emit 时 → 后台抽 clip
└─ emit 时 → WS `segment.emitted`
Pass 1.5 (离线,Pass 1 完跑)
────────────────────────────────
原始 (x,y) ──▶ bridge_gaps (≤max_lost) ──▶ EMA ──▶ 2D 速度 ──▶ segment_cycles
│
└─ 写 segments.json为什么两阶段?Pass 1 必须顺序读 (MediaPipe VIDEO 模式有状态 —— 每帧的 ROI 跟踪依赖上一帧),但正确的切分需要"看未来" (漏检区间到底有多 长、挥拍到底从哪开始)。Pass 1 给用户即时反馈;Pass 1.5 给权威最终结果。
Pass 1 — OnlineSegmenter
class OnlineSegmenter:
def update(self, frame_idx: int, v: Optional[float]) -> Optional[SwingSegment]:
...- 状态:
active、run_start、run_end、peak_v、peak_frame、gap_count - 每帧:
- 若
v > v_swing且之前 inactive,看上一段 run 已经 inactive 多久, 若 >gap_merge_sec就 emit - 若
v > v_swing,延展当前 run;更新 peak - 若
v <= v_swing且之前 active,开始计 gap - 若
v <= v_swing且 gap 超gap_merge_sec,emit
- 若
- 流末调
flush(last_frame_idx),把还没关的 run 用buf_after帧补 上再 emit
精度比 Pass 1.5 低 (无漏检分类、无未知间隔处理),但每次 emit 都是真实 的段。
Pass 1.5 — segment_cycles
离线函数,接完整速度数组,吐最终 segments。核心思想:两种间隔。
| 间隔类型 | 怎么识别 | 合并阈值 |
|---|---|---|
| 推断为休息 | 区间里有速度样本,全低于 v_swing | gap_merge_sec (默认 1.5s) |
| 漏检 | 没有速度样本 (整段 wrist 全丢) | max_bridge_sec (默认 1.5s) |
为什么分两类?fdl.mp4 上,真实动作间隔 ≥ 3s,单次动作里的停顿 (发球抛球)≤ 1s。混在一起用同一阈值,要么把多次动作链成一长段,要么把一 次动作切两半。两个阈值独立控制。
然后:
- 丢弃
peak_v < min_peak的周期 (过滤非挥拍:捡球、走位回中) - 丢弃时长 <
min_dur的周期 - 时长 >
max_dur的周期保留但标over_long: true(通常意味着两次挥 拍被链起来) - 每个存活的周期,算四个相位:
ready:active_start_frame前的 bufferwindup:active_start_frame→ 击球窗口contact:击球瞬间 (peak-speed 帧) ± 0.12sfollow_through:击球窗口结束 →active_end_frame
调参指南
下列数字来自 30fps 网球录像 fdl.mp4。按你的素材、帧率、选手风格调。
| 目标 | 参数 | 方向 |
|---|---|---|
| 捕获被丢的慢速挥拍 | --min-peak | 调低 (0.20 → 0.10) |
| 跳掉小动作 (捡球) | --min-peak | 调高 (0.40 → 0.50) |
| 把相邻两次挥拍链一起 | --gap-merge | 调高 (2.0 → 3.0) |
| 把慢节奏挥拍切两半 | --gap-merge | 调低 (1.0 → 0.6) |
| 处理长段漏检 | --max-bridge | 调高 (2.5) |
| 漏检合并太激进 | --max-bridge | 调低 (0.8) |
| 降低速度信号噪声 | --smooth-alpha | 调低 (0.4 → 0.5) |
| 跟手快速手腕翻动 | --smooth-alpha | 调高 (0.8) |
| 不要那么多 clip 头尾 buffer | --buf-before / --buf-after | 调低 (0.5) |
| 只处理视频前段 | --max-frames | 任一非零值 |
影响最大的是 --min-peak 和 --gap-merge。它们处理最常见的切错。
"算法" 究竟是什么?
backend/core/segment_swing.py (952 行)。公开 API:
# 信号处理
bridge_gaps(series, valid, max_lost) -> List[Optional[float]]
ema_smooth(series, alpha) -> List[Optional[float]]
compute_velocity_2d(xs, ys, fps) -> List[Optional[float]]
# 切分
segment_cycles(v, fps, *, v_swing, gap_merge_sec, max_bridge_sec,
min_peak, min_dur, max_dur, buf_before, buf_after) -> List[SwingSegment]
# 流式
class OnlineSegmenter: ...
class PoseRunner: ... # 包装 MediaPipe
def extract_one_clip(in_path, seg, clips_dir, fps, w, h): ...
def phase_timeline(seg, fps) -> List[Tuple[str, int, int]]: ...backend/service/pipeline.py 是唯一调用方。如果底层源加新函数 (比 如 dedup_overlapping 或 score_swing_quality),拷进来就行;其它地方 不用知道。
跑在哪些模型上
三个 ONNX / TFLite 模型都在 backend/models/ 下 (都已入库):
| 模型 | 大小 | 谁用 | 何时 |
|---|---|---|---|
pose_landmarker_lite.task | 5.5 MB | segmentation 右手腕信号 | 总在跑 (Pass 1) |
rtmdet-m-487628.onnx | 104 MB | clip bbox 叠加 | 开了 clip_bbox (或 --bbox) |
rtmpose-m-27c0e6.onnx | 52 MB | clip COCO-13 骨架 | 开了 clip_skel 且 --skel-backend rtmpose |
切分算法本身 (backend/core/segment_swing.py) 只 import MediaPipe Pose 模型。RTMDet 和 RTMPose 仅供 ClipAnnotator 给已切好的 clip 做增强, 不参与 segmentation。这让算法库保持不变,vendor 规矩("更新就重 拷")继续适用。
从底层源同步
# 一次同步三个 vendored 算法脚本
cp <新-segment_swing.py> backend/core/segment_swing.py
cp <新-analyze_swing.py> backend/core/analyze_swing.py
cp <新-gen_skeleton_anim.py> backend/core/gen_skeleton_anim.py
git add backend/core/
git commit -m "vendor: 从底层源同步 @ <hash>"完事。不用审"本地有没有人偷偷改过",因为文件就是 verbatim 提交的。
另外两个 vendored 算法
backend/core/ 除了 segment_swing.py,还有两个独立的算法:
analyze_swing.py—— MediaPipe 一次推理的 33 点分析。wrist 喂 同一个OnlineSegmenter,33 点按帧缓存,clip 叠加和整段viz.mp4与切分列表保证 1:1。不会再有"5 vs 11 段"那种在线/离线 racegen_skeleton_anim.py—— RTMDet (bbox) + RTMPose / MediaPipe (骨架) 四象限合成器,带智能裁剪放大。要做一段独立的骨架叠加视频 时用它,跟切分无关
两个都能独立跑 CLI —— 参数和产物形状见 03 · CLI 用法。