视线跟随原型:从"球几乎不动"到"可以接受"

原创2026-09-23 16:33:25浏览3
3
Star

1 目标:头往哪偏,球就往哪走

需求只有一句话:头往左偏,球往左走并停住;头回中,球回中。

信号链短得不像个项目:

摄像头 → NPU 检测 → 人脸框 + 3 个关键点 → 9 维特征
        取「脸心 x, y」两个数 → 标定 → 死区 → 平滑 → 增益 → 球位

真的用到的只有两个数。复杂度全在最后那一段"取舍"上——

做之前我以为的难点:怎么把脸检测准、怎么标定行程。 实际的难点球位 = f(头姿) 这个函数该怎么写,以及它坏了的时候你怎么知道它坏了

图 6:六轮反馈。上面是操作者看到的现象,下面是最后改了什么。 注意这六条里,只有第 1 条和第 6 条是"控制律写错了",中间四条全是"我判断错了方向"。

2 坑一:球几乎不动

第一次上机,反馈是「球几乎不随头摆动」,1 秒位移中位只有 0.13% 屏宽——人眼看不见。

我的第一反应是"增益太小",于是把增益调大。没用。 因为问题不在增益:

js// 旧代码(错的那一行)
deadX = deadX ? (Math.abs(d) > band * 0.6) : (Math.abs(d) < band);

这行的意思是:

  • 当前不在死区 → 只要 |d| < band 就进入死区;

  • 当前死区 → 只要 |d| > 0.6·band 就继续保持死区

也就是说:要脱离死区,你得先把自己挪回更靠近中心的位置|d| ≤ 0.6·band)—— 而人一坐正就必然落进死区,之后无论头怎么动,|d| 都还在 0.6·band 以上, 于是永远判死区,球永远不动。实测打点确认:45 px 偏移下 deadX 仍然是 true

图 7:迟滞的本意是"进门门槛高、出门门槛低"。写反之后变成"进门容易、出门必须缩回更里面", 一旦进入就锁死。

次因:当时的参考量是「最近 0.5 s 的头姿中位」——这等于一个高通滤波, "转头并保持"这个动作被它整个吃掉了,净位移≈0。

正解是放弃积分,改成位移映射

js// 球位是头姿的直接函数:无积分、无漂移累积
球位 = 基准 + K × (当前头姿 − 锚点)
// K = reach × 0.5 / 该方向的标定行程

改完之后:r(球X, 头姿)0.02 → 0.94,1 秒位移 0.13% → 5.6% 屏宽(40 倍)

教训|d| 和门限的比较里,"当前状态"不能参与判断方向。 门限比较只有两种正确写法——要么纯 abs 比较,要么进出门限不同且出门更宽松。 写成"进门严、出门也严",就是锁死。

3 坑二:两轴都反了

修好之后反馈变成「效果已经很好了,但 X、Y 两轴都反」。

两轴同时反,只可能是判向错了,不可能两个增益都配错。果不其然:

旧标定指令是「头转向你的左边 / 右边」,然后按固定 1.6 秒切段、只采样 0.75~1.6 s 这一段。 两个坑叠在一起:

  1. 人容易把自己的左/右和屏幕的左/右搞混("我往左偏"到底是看向屏幕左还是看向屏幕右?);

  2. 动作节奏和页面不同步时,采样窗会落进相邻段——只要错位 ±0.4 秒, 左右就互换了;上下同理。于是"两轴同反"。

而更隐蔽的是:某一段有效帧不足 4 帧时,代码会静默沿用出厂默认行程—— 这是第二种"同反",而且它连警告都不给。

三条修法

① 指令改用「屏幕方位」。 "看屏幕左边 / 右边 / 上边 / 下边"是客观的, 不需要任何左右翻译。判据也随之变成纯屏幕语义:

看屏幕左 ⇒ 球该到屏幕左 ⇒ dirx × (hx_屏左 − anchor) 必须 < 0
看屏幕下 ⇒ 球该到屏幕下 ⇒ diry × (hy_屏下 − anchor) 必须 > 0

② 不按时钟切段,按极值出现的先后配对。 全程 10 秒记轨迹, 先出现的横摆极值=屏幕左,后出现的=屏幕右。这样对快慢完全免疫。

③ 保留一键反向(Z / V 键)。 任何"自动判向"都会有边界情况, 给人一个立即生效、不必重标定的兜底开关,比追求 100% 自动更实用。 反向只翻最终符号,不动增益的左右配对(否则会把左右行程也用反)。

4 坑三:标定总是失败

再下一轮:「标定几次均失败了,重新看下逻辑」。

我的第一反应是"操作者动作没做对"。又错了。 用他的真实录制量化之后, 病根在纵向串台:

旧代码拿「横轴极值点的位置」当纵摆段的起点:

jsconst tail = sw.filter(p => p.t > sw[i2].t + CAL_PLAT);   // i2 = 横轴极值点

低头/抬头会顺带把横轴拖走。对 2.3 万真实帧量化:

纵向大动作帧的 |Δhx| 中位  3.83 px/帧
横向正常摆动    |Δhx| 中位  1.91 px/帧      ← 纵向是横向的 2

于是横轴极值会漂到纵摆段里去,tail 被越挤越空:

横摆幅度

低头漂移

纵摆段剩几帧

结果

±200 px

+250 px

13 帧

报「纵摆段有效帧太少」

±70 px

+80 px

19 帧

同上

±200 px

−250 px

0 帧

同上

最毒的是它的自我恶化:结构不合格 → 代码自动把标定从 10 秒延长到 20 秒 → 而延长出来的那段正好又是纵摆动作 → 极值更靠后 → 下一轮更糟。 "试了几次都失败"因此是必然的。

还有一类不报错但方向全反的:结构不合格时旧代码照样接受结果、界面还打 ✓, 但方向取到了 (1,1) / (-1,-1)。回归里 40 次有 34 次中招。

四处修法

#

改动

为什么

分段改按运动能量判别(splitHV),切点搜索限定在 [30%, 72%]

与"极值落在哪"彻底解耦;不限定范围时该判据有退化最大值(切在末尾→后段为空→惩罚归零)

极值改「最极端 k 个样本的中位」(k = 0.4 s)

单帧尖峰只占 1/k 权重,天然就是平台法

锚点取「最早的安静 0.4 s 窗」

取"极差最小"会在噪声下随机跳(两个候选只差 2 px,纯看运气),而"人开始动"永远晚于真正的中性段

收尾判据加「纵摆两侧各 ≥0.8 s」

与几何无关——摄像头倒装/镜像会让符号整体翻转,任何带符号的判据都会在某种挂装下失效

配套:分轴采纳(某轴不合格就沿用上次的值,而不是退回默认行程—— 后者会把一次好标定用默认值覆盖掉,越标越差);延长上限 20 s → 14 s

最有价值的改动其实不是算法,是那句诊断:失败时左下角常驻一行 「哪个方向没扫出来 + 实测数字」。因为下次失败时,我不在现场

标定结果 不可用 | 帧数 231 | Δ左 -123 上 -85 | 横段 118 纵段 113 | 纵两侧 0.2/0.0 s
   失败原因: 纵摆两侧时长不足(需各 ≥0.8 s)
   实测: 横段 118帧/spanH 19px 纵段 113帧/spanV 7px 切点 0.51

有这行字,第二次就能一次调准;没有它,只能继续猜。

回归结果(13 用例 × 40 次,只统计"报失败"或"给出反向值"两种坏结果):

旧版 295 个坏结果(67%)    →    新版 0

5 坑四:抖

基本功能完成了,现在就是抖动太厉害」。

抖动的第一反应是"加滤波"。但在加之前我先量了一下,量完发现思路得改

人停住时    检测噪声 σ ≈ 1.1 px         ← 极小
人移动时    球位毛刺 σ ≈ 47.9 px/帧      ← 就是它

抖不在"停住时",在"移动路径上"。 而且放大器是增益:

横向行程 209 px  →  K ≈ 6.1
纵向行程  32 px  →  K ≈ 22.0      ← 放大倍数是横向的 3.7

纵向天生更抖,不是参数没调好,是「头上下可动范围小、却要覆盖整屏高度」这条物理约束决定的。

图 8:关键不是"能降多少",而是这两根柱子的差距。纯白噪声场景平滑能降 70%, 真实数据只降 40%——说明约六成的抖动根本不是噪声,是"人保持偏头时头本身的低频微颤" 被高增益放大。要吃它得付 0.5 s 以上的延迟,得不偿失。

修了三处

  1. 分轴平滑:因果中位 + EMA,横纵独立设档(纵向用更长的窗、更小的 α)。 平滑器必须用因果窗——我第一版跑了"居中窗",那是偷看未来 2 帧的假优势,实时做不到。

  2. 死区由「置零」改「扣除」输出 = sign(d)·max(0, |d| − band)。 置零写法在 |d| 于门限附近抖动时,目标会在 0band·K 之间来回跳(≈67 px)。

  3. 去掉输出级的二次低通:它会污染"平稳档"的语义(档 0 也不是真直通),还白送 40 ms 延迟。

顺带排除的两个嫌疑(都落数据)

  • 丢检造成的台阶?最长连续段(5857 帧 / 233.6 s)检出率 100%,只丢 2 帧; 用"丢检帧速度外推补位"跑同一段,毛刺一个数都没变(163.6 → 163.6)⇒ 外推没有收益,不做。

  • 画面颠倒?5 点关键点判正立,含本场三场皆 100% 正立。

教训:任何"要不要加滤波"的问题,先分成停住时移动中两段测。 本项目里停住时只有 1 px,移动中 48 px——如果只测停住时,会得出"噪声很小、不用处理"的错误结论。

6 坑五:幅度不够

最后一轮:「在速度模式下,移动的平滑度很好,就是幅度不够大—— 我头已经在最左侧了,但是球还没到左侧,或刚到左侧」。

注意前半句:平滑度很好。也就是说不能靠"加平滑/减平滑"来换幅度—— 这条路在第一轮已经证明是死路。

旧速度模式的控制律是这样:

jsconst rx = median(最近 15 帧的 hx);      // ← 参考 = 头姿自己的 0.5s 滑动中位
const ox = hx - rx;                      // 相对参考的偏差
S.ball.x += -Math.sign(ox) * 10 * (Math.abs(ox) - band * 0.6) * dt;   // 速度积分

它没有"目标位置"这个概念。 rx 是头姿自己的滑动中位——手一停,中位就爬上来了 → ox → 0 → 速度归零 → 球停在中途。净位移只由"参考追上来的那 0.3~0.5 秒"决定:

净位移 ≈ 增益 × Σ|ox|·dt ≈ 增益 × 过渡期 × 偏差

于是两个致命后果:

  1. 摆得越慢走得越少("我慢慢把头转过去,球就只走一点");

  2. 与标定完全无关——增益 10/20 是写死的常量,方向取 sign(hx)行程比滑条和 Z/V 反向全都作用不到它。标定出"看屏幕左 = 166 px"这件事,它根本不知道。

图 9:把他本人 366 秒的录制整段重放,按「|头偏| / 标定行程」分档看球位中位。 旧的红色那列根本不单调——头几乎不动时球在 +372 px,头转到满幅时球只有 −201 px(≈屏边 1/3)。 球的位置由历史积累决定,跟"现在头在哪"无关。绿色是新版,单调,满幅那一档中位 −599 px、 p05 直接 −640 px(到边)。

7 解法:把「平滑」和「幅度」拆成两件事

到这里出现了本次最有价值的认识:

在旧实现里,"平滑"和"幅度"是互斥的——平滑靠低通、幅度靠积分时长,加平滑必然减幅度。 而操作者的验收标准恰恰是两条同时成立平滑度很好 幅度要够

所以不能调参,只能换结构:让两件事由两个独立的量各自负责。

图 10:左边是旧思路——跷跷板两头,此消彼长。右边是 v2.4:幅度交给标定(目标位置 = reach × 0.5 屏,满幅头姿正好到边),平滑交给限速(只管怎么走过去,不管终点)。

新版速度模式只有一行核心:

js// 目标位置 target 与位移映射同源(共用同一套标定、死区、分轴增益、反向)
const vx = clamp(VEL_KP * (target.x - S.ball.x), -vmax_x, vmax_x);
S.ball.x = clamp(S.ball.x + vx * dt, 0, 1);
// vmax 挂在「平稳档」上:2.6 / 2.0 / 1.5 / 1.1 / 0.80 屏·s⁻¹
  • 幅度由标定保证:满幅头姿 ⇒ target = 屏边,走到哪与快慢无关;

  • 平滑由限速保证:速度连续、无跳变,近目标自动减速不过冲。

同一个脚本、同一份输入,前后对比:

用例(满幅摆头保持 3 s,期望 640 px)

左·快摆 / 慢摆 1.5 s

92% / 80%

100% / 100%(精确贴边)

右·快摆 / 慢摆

83% / 71%

100% / 100%

上·满幅

球跑到了下方(方向反)

100%(贴边)

真实重放·满幅那档球位中位

−201 px

−599 px(p05 直接到边)

可复用的结论:任何"要平滑又要幅度"的控制需求,都别在同一个参数上折中。 让"目标在哪"和"走得多快"由两个独立的量负责,矛盾就消失了。 判断你的实现有没有掉进这个坑,问一句:我这个控制律里,有没有一个"目标位置"? 如果最终位置是由"过程积分"决定的,它一定会受速度影响。

新控制律   84 通过 / 0 失败
旧控制律   74 通过 / 10 失败     ← 说明这 10 条真的钉住了这个 bug