编码器角度平均:修好了跨零 Bug,为什么还是会算错?
首发于公众号「如是有为」 · 查看原文

前几天饭桌上,有人问了一个看起来很初级的问题:
1° 和 359° 的平均值是多少?
我说,这题你别急着笑。它能把一个编码器项目里所有“凭感觉正确”的前提,全翻出来。
第一遍:当然是 180°
先别管编码器,就把它们当普通数字:
(1° + 359°) / 2 = 180°
小学算术完全正确。
但把两个角度画到编码器圆盘上,你会马上觉得不对:1° 和 359° 明明挨着,平均怎么跑到对面去了?
于是第一次反转来了。
第二遍:不,应该是 0°
如果这两个数表示两个无序、等权的圆周方向,那么它们之间的短弧只有 2°,短弧中点就是 0°。
向量法同样得到 0°:
θ̄ = atan2(sin 1° + sin 359°, cos 1° + cos 359°) = 0°
到这里,多数教程会说:角度不是普通数字,要走短弧,用 wrap 或 atan2。
听起来已经结束了。
其实没有。
第三遍:它也可能是 180°
现在只换一个条件。
这两个数不再是“两个无序方向”,而是编码器按时间读到的两帧:
上一帧:1°
下一帧:359°
真实运动有两种可能。
第一种,反向转了 2°:
1° → −1°
两帧的连续位置平均是:
(1° + (−1°)) / 2 = 0°
第二种,正向转了 358°:
1° → 359°
两帧的连续位置平均变成:
(1° + 359°) / 2 = 180°

同样两个编码器读数,0° 和 180° 都能成立。
不是数学打架,而是两个模 360° 的数字没有告诉你:磁铁到底走了哪条路,也没有告诉你转了几圈。
严格地说:对圆周方向集合,1° 和 359° 的圆均值是 0°;对连续轨迹中的两个模角度观测,如果没有运动路径和圈数约束,轨迹平均位置不能唯一确定。
第四遍:工程现场,它通常又是 0°
那为什么编码器工程师看到 1° 和 359°,几乎都会直接选 0°?
因为代码背后偷偷使用了一个通常没写出来的条件:
Δθ真实
< 180°
也就是:一个采样周期里,机械角不可能转过半圈。
在这个约束下,358° 的正向长路被排除,只剩下 -2° 的短路。
wrap 到底算什么
wrap 不是求平均,也不是专门修补 0°。它只做一件事:
把一个角度差折回到 [-180°, 180°),得到最短有符号角差。
wrap(358°) = -2°
wrap(-358°) = +2°
wrap(181°) = -179°
wrap(-181°) = +179°
例如:
wrap(359° − 1°) = −2°
这并没有证明真实运动一定反向转了 2°。它只是利用“单帧位移小于 180°”这个工程约束,排除了其他分支。

所以更严格的说法是:
wrap 不是在发现真相,而是在已知最大速度和采样周期的前提下,选择唯一允许的角位移。
那么,编码器角度平均到底怎么算?
这时候不能再问“有没有一个万能公式”,而要先问:
你平均的是同一位置的重复测量,还是一段连续运动轨迹?
情况一:磁铁基本没动,只是在同一位置重复采样
例如:
359.9°,0.1°,359.8°,0.2°
这组数据代表同一个真实方向附近的噪声。做法是选一个共同参考角,所有样本都和它比较:
δᵢ = wrap(θᵢ − θref)
再对这些连续的小角差做等权平均:
θ̄ = wrap(θref + (1/N) × Σ δᵢ)
注意,平均的是相对于同一个参考角的角度差,不是把每个绝对角度直接 wrap 后相加。
情况二:磁铁连续运动,可能跨过 360°,甚至走过半圈
例如从 0° 一点点正转到 200°:
0°,1°,2°,……,199°,200°
这时不能把每个样本都相对 0° 取最短路,否则 181° 会被折成 -179°,轨迹会被错误地扳回去。
正确做法是利用时间顺序,先计算每一帧相对上一帧的短角差:
Δᵢ = wrap(θᵢ − θᵢ₋₁)
再累加,恢复连续角度:
Θᵢ = Θᵢ₋₁ + Δᵢ
得到:
0°,1°,2°,……,200°
然后对连续角度做普通平均:
Θ̄ = (1/N) × Σ Θᵢ
如果从 0° 均匀采到 200°,平均位置就是:
Θ̄ = 100°
还有一个很常见、而且真的会算错的递推写法
不少代码会先平均前两个角,再拿旧平均值和第三个角五五开:
mean = midpoint(mean, new_angle);
这不是累计平均。
例如三个普通数 0°、100°、200°:
m₂ = 50°
m₃ = (50° + 200°) / 2 = 125°
但真正等权平均是 100°。
因为第三个样本占了 50% 权重,前两个各占 25%。越晚来的数据,权重越大。
标准累计平均必须让第 n 个样本只占 1/n:
Θ̄ₙ = Θ̄ₙ₋₁ + (Θₙ − Θ̄ₙ₋₁) / n
continuous += wrap(current - previous);
mean += (continuous - mean) / sample_count;
第一行负责恢复轨迹,第二行负责等权累计平均。它们是两件事,不能混成“每次再取一次中点”。最后一翻:有时候根本没有平均角
读到这里,很容易又产生一种安全感:
静止采样用共同参考角;连续轨迹先解包;递推权重用 1/n。终于完整了。
最后再看一组数据:
0°,90°,180°,270°
它们均匀分布在整个圆周上。
向量和为零:
Σ cos θᵢ = 0,Σ sin θᵢ = 0
没有任何一个方向比另一个方向更有代表性。
这时 atan2(0,0) 即使返回了一个数字,那个数字也没有物理意义。
真正成熟的角度平均函数,不能保证任何输入都返回一个角度。它必须允许返回:无唯一解。
最终结论
1° 和 359° 的平均值到底是多少?
如果它们是两个无序、等权的圆周方向,答案是 0°。
如果它们是连续轨迹中的两个模角度观测,又没有给出路径、圈数和最大位移约束,答案不能唯一确定,0°和180°都可能对应某种连续解释。
如果编码器满足单帧真实角位移小于180°,wrap 会排除长路,工程上重新得到 0°。
而如果样本均匀铺满整个圆周,正确答案不是0°、180°或任何别的角度,而是:
这组数据没有唯一平均方向。
所以编码器角度平均真正的标准,不是一条孤立公式,而是:
先定义对象: 方向集合、同一位置测量,还是连续轨迹。 再写出先验: 最大速度、采样周期、是否丢帧、是否保留圈数。 最后才计算: 共同参考、连续解包、等权平均,或者直接返回无解。
很多角度算法不是公式写错,而是把“凭经验成立的条件”藏在了代码外面。
说明:文中所有数学公式均采用 Obsidian 兼容的 LaTeX 表达。单文件 HTML 中图片已内嵌,可离线打开;微信公众号编辑器复制时建议使用浏览器全选正文后粘贴。

关注公众号「如是有为」
微信扫一扫,新文章先在公众号发出。