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 这一段。 两个坑叠在一起:
人容易把自己的左/右和屏幕的左/右搞混("我往左偏"到底是看向屏幕左还是看向屏幕右?);
动作节奏和页面不同步时,采样窗会落进相邻段——只要错位 ±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 次中招。
四处修法:
# | 改动 | 为什么 |
|---|---|---|
① | 分段改按运动能量判别( | 与"极值落在哪"彻底解耦;不限定范围时该判据有退化最大值(切在末尾→后段为空→惩罚归零) |
② | 极值改「最极端 k 个样本的中位」(k = 0.4 s) | 单帧尖峰只占 1/k 权重,天然就是平台法 |
③ | 锚点取「最早的安静 0.4 s 窗」 | 取"极差最小"会在噪声下随机跳(两个候选只差 2 px,纯看运气),而"人开始动"永远晚于真正的中性段 |
④ | 收尾判据加「纵摆两侧各 ≥0.8 s」 | 与几何无关——摄像头倒装/镜像会让符号整体翻转,任何带符号的判据都会在某种挂装下失效 |
配套:分轴采纳(某轴不合格就沿用上次的值,而不是退回默认行程—— 后者会把一次好标定用默认值覆盖掉,越标越差);延长上限 20 s → 14 s。
最有价值的改动其实不是算法,是那句诊断:失败时左下角常驻一行 「哪个方向没扫出来 + 实测数字」。因为下次失败时,我不在现场。
标定结果 不可用 | 帧数 231 | Δ左 -12 右 3 上 -8 下 5 | 横段 118 纵段 113 | 纵两侧 0.2/0.0 s
失败原因: 纵摆两侧时长不足(需各 ≥0.8 s)
实测: 横段 118帧/spanH 19px 纵段 113帧/spanV 7px 切点 0.51有这行字,第二次就能一次调准;没有它,只能继续猜。
回归结果(13 用例 × 40 次,只统计"报失败"或"给出反向值"两种坏结果):
旧版 295 个坏结果(67%) → 新版 05 坑四:抖
「基本功能完成了,现在就是抖动太厉害」。
抖动的第一反应是"加滤波"。但在加之前我先量了一下,量完发现思路得改:
人停住时 检测噪声 σ ≈ 1.1 px ← 极小
人移动时 球位毛刺 σ ≈ 47.9 px/帧 ← 就是它抖不在"停住时",在"移动路径上"。 而且放大器是增益:
横向行程 209 px → K ≈ 6.1
纵向行程 32 px → K ≈ 22.0 ← 放大倍数是横向的 3.7 倍纵向天生更抖,不是参数没调好,是「头上下可动范围小、却要覆盖整屏高度」这条物理约束决定的。
图 8:关键不是"能降多少",而是这两根柱子的差距。纯白噪声场景平滑能降 70%, 真实数据只降 40%——说明约六成的抖动根本不是噪声,是"人保持偏头时头本身的低频微颤" 被高增益放大。要吃它得付 0.5 s 以上的延迟,得不偿失。
修了三处:
分轴平滑:因果中位 + EMA,横纵独立设档(纵向用更长的窗、更小的 α)。 平滑器必须用因果窗——我第一版跑了"居中窗",那是偷看未来 2 帧的假优势,实时做不到。
死区由「置零」改「扣除」:
输出 = sign(d)·max(0, |d| − band)。 置零写法在|d|于门限附近抖动时,目标会在0与band·K之间来回跳(≈67 px)。去掉输出级的二次低通:它会污染"平稳档"的语义(档 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 ≈ 增益 × 过渡期 × 偏差于是两个致命后果:
摆得越慢走得越少("我慢慢把头转过去,球就只走一点");
与标定完全无关——增益 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