我写的轮廓追踪器能跑,输出的多边形闭合、点数正常、画出来也像那么回事。唯一的问题是面积——它报告的区域面积比真实值大二十倍。
bug 在终止条件里,而且是一个只在特定形状上才会暴露的顺序错误。
先说清楚追踪器在做什么
输入是一张二值遮罩:某些像素属于目标区域,其余是背景。要把它变成矢量图形,就得把每个连通区域的边界提取成一串有序的点,再连成多边形。
Moore 邻域追踪的思路很朴素,像沿着墙走迷宫:把手一直贴在墙上,绕一圈就回到原地。具体到像素网格上:
先扫描找到区域最上、最左的那个像素作为起点——选这个点是因为它保证在边界上,而且它的左边和上边一定是背景。然后每一步做同一件事:站在当前边界像素上,从刚才进来的方向的下一个位置开始,绕八个邻居顺时针依次检查,遇到的第一个属于该区域的像素就是下一步。
“从进来的方向开始扫”这一点是关键。如果每次都从固定方向(比如正东)开始扫,追踪器会在凹角处走错——它需要知道自己是从哪儿来的,才能贴着墙继续走而不是拐进区域内部。
7 0 1 邻居编号(顺时针)
6 · 2 · 是当前像素
5 4 3
所以每一步的状态是一对值:位置和方向。这个”方向”既是上一步的结果,也是下一步扫描的起点。后面出问题的地方就在这里。
Moore 邻域追踪的终止条件
沿着一个连通区域的边界走一圈,标准做法是 Moore 邻域追踪:站在当前边界像素上,从上一次进入的方向开始,绕八邻域顺时针找下一个属于该区域的像素,走过去,重复。
问题是什么时候停。最朴素的想法是”回到起点就停”,但这个条件不够——有些形状会在中途经过起点,比如一个细颈的哑铃,或者一条一像素宽的线段,追踪器会沿着它走过去再走回来。此时如果直接停,只会得到一半轮廓。
正确的终止条件是 Jacob 准则:不仅要回到起点,还要以第一次离开起点时的同一个方向再次进入它。位置和方向同时匹配,才说明真正绕完了一圈。
我的实现里两个条件都写了。顺序错了。
错在哪
循环体大致是这样:
while (true) {
// 检查是否回到起点且方向一致
if (x === startX && y === startY && dir === startDir) break;
next = findNextBoundaryPixel(x, y, dir);
x = next.x; y = next.y;
dir = next.dir; // 方向在检查之后才更新
points.push([x, y]);
}
检查写在移动之前,用的是上一轮遗留的 dir。也就是说,当追踪器踩到起点的那一刻,它手里的方向是”我上一步是怎么走的”,而不是”我这一步将怎么离开”。
这两个方向在大多数情况下不同。于是条件几乎永远不成立,追踪器不会在第一圈结束时停下——它继续绕第二圈、第三圈,直到撞上循环上限。
为什么面积会大二十倍
这里有个不直观的地方:多绕几圈应该只是点数变多,面积不该变。
关键在于停下来的位置。追踪器最终是被点数上限强行终止的,停在轮廓的任意一个位置,而不是起点。得到的点列因此不闭合——首尾之间隔着一段真实边界。
而下游的面积计算(鞋带公式)会把首尾两点直接连起来当作闭合。这条人造的连线可能横穿整个区域,把大片本不属于该区域的面积圈了进去。区域越细长、缺口越大,虚增越多。
那个二十倍的例子是一个细长的手臂形状:追踪器停在手腕附近,首尾连线直接跨过了躯干。
顺带解释了另一个我一直没想通的现象——某些区域只沿着上边缘走了一趟。那是方向恰好对上、条件提前成立的情况:追踪器刚沿着顶边走完,遗留方向凑巧等于起始方向,于是立刻停了,只留下一条顶边。
修法
把检查移到方向更新之后:
while (true) {
next = findNextBoundaryPixel(x, y, dir);
x = next.x; y = next.y;
dir = next.dir; // 先更新方向
// 再检查:位置和"离开方向"同时匹配才算走完一圈
if (x === startX && y === startY && dir === startDir) break;
points.push([x, y]);
}
改动只是把三行代码换了顺序。改完之后,所有区域的轮廓包围盒和区域自身的包围盒逐一吻合——差一个像素,那是外沿追踪的正常偏移。
鞋带公式为什么会被骗
面积计算用的是鞋带公式:把多边形顶点按顺序两两配对,累加叉积再取一半。
let area = 0;
for (let i = 0; i < pts.length; i++) {
const [x1, y1] = pts[i];
const [x2, y2] = pts[(i + 1) % pts.length]; // 最后一点连回第一点
area += x1 * y2 - x2 * y1;
}
area = Math.abs(area) / 2;
注意那个 % pts.length:它无条件地把最后一个点连回第一个点。这在多边形本来就闭合时是对的,此时首尾相接,那条边长度为零,不贡献面积。
但点列不闭合时,这条边就是一条真实存在的、可能很长的线段。而鞋带公式对多边形的”合理性”没有任何要求——它只是机械地求代数和,你给它什么形状它就算什么形状的面积。
更麻烦的是,如果这条人造边让多边形自相交,鞋带公式算出来的是带符号面积的抵消结果,可能偏大也可能偏小,甚至可能接近正确值而掩盖问题。二十倍只是我这次碰到的数,它本身没有规律。
为什么单元测试没抓住
我为追踪器写过单元测试,用的是矩形和圆形。这两种形状恰好都不会触发这个 bug。
原因是它们的边界足够”胖”:追踪器绕行时不会走进一像素宽的死胡同,起点处的进入方向和离开方向在多绕一圈后容易重合,条件反而歪打正着地成立了。测试全绿。
真正触发问题的是细长带分叉的形状——人物的手臂、头发丝、衣摆的褶皱。这些形状有大量一像素宽的突出部分,追踪器要走进去再走出来,方向序列复杂得多。
教训是几何算法的测试用例必须包含病态形状:一像素宽的线、细颈哑铃、有孔洞的区域、只有单个像素的区域。用矩形和圆形测出来的绿灯,说明的只是”在最容易的情况下能跑”。
洞:鞋带公式的符号刚好帮上忙
上面处理的都是外轮廓。实际的角色图层经常带洞——袖子的空隙、眼睛的镂空、字母 O 的内圈。Moore 邻域追踪只会找到外轮廓,洞需要单独处理,否则算出来的面积会把洞也算进去。
好消息是鞋带公式自带解决方案:它给出的是有符号面积,符号由绕行方向决定。外轮廓按顺时针追踪得到正值,那么把洞按逆时针追踪就会得到负值,两者直接相加就是正确的净面积。
area = shoelace(outer) # 顺时针,正
for hole in holes:
area += shoelace(hole) # 逆时针,负 —— 直接加,不用减
不需要写「如果是洞就减去」这样的分支。让绕行方向携带这个信息,比用一个布尔标记去记它更不容易出错——因为方向是追踪过程自然产生的,而标记要靠人去维护。
相应地,SVG 的 path 也用同一套约定:把外轮廓和洞写进同一个 d 属性,配合 fill-rule="evenodd" 或 nonzero,渲染器会自己把洞挖出来。前者只看穿越次数的奇偶,后者看绕行方向的代数和——如果你的洞方向写反了,evenodd 看起来正常而 nonzero 会把洞填实。这是个很好的自检:两种规则渲染结果不一致,就说明方向有问题。
连通性要选一对互补的,不能两边都用 8 邻域
还有一个在追踪之前就要定下来的选择:判断像素相邻时,用 4 邻域(上下左右)还是 8 邻域(含对角)。
这不是随便挑一个的问题。经典的连通性悖论是这样的:一个 2×2 的棋盘格图案里,两个前景像素只在对角相邻,两个背景像素也只在对角相邻。如果前景和背景都用 8 邻域,那么前景是连通的,背景也是连通的——一条闭合曲线没有把平面分成内外两部分,这在拓扑上是矛盾的。
解决办法是让两者互补:前景用 8 邻域,背景就用 4 邻域(或者反过来)。这样对角相接的前景算连通,而对角相接的背景不算,矛盾消失。
实践上的影响很直接。前景用 8 邻域时,两块只在角上碰到的区域会被当成一块,轮廓追踪会绕着它们跑一圈;用 4 邻域则会得到两个独立轮廓。对于线稿和细笔画,8 邻域几乎是必须的——1 像素宽的斜线在 4 邻域下会被判成一串互不相连的孤立点。
所以配置里应该只有一个开关,另一个由它推出来,而不是给两个独立的参数——后者允许用户配出上面那种矛盾组合,而症状(轮廓偶尔莫名其妙地连成一片或断开)非常难查。
怎么才能早点发现
这个 bug 骗过了我最初的检查,因为它的输出看起来是对的:多边形闭合、点数在合理范围、渲染出来是个连贯的形状。肉眼看轮廓图,看不出问题。
真正能抓住它的检验只有一个,而且非常便宜:把追踪出来的轮廓包围盒,和它所描的那个区域的包围盒对比。
这两个数是独立算出来的——区域包围盒来自连通域标记,轮廓包围盒来自点列。如果轮廓正确,它们必然吻合;如果轮廓跑飞了或者提前结束,立刻就能看出来。
更一般的说法是:几何算法的输出要用另一条路径算出来的不变量去校验,不要用眼睛看。面积、包围盒、点数上下界、闭合性,这些都能自动检查,而”看着像”什么都证明不了。
另外值得记的是那个”只走上边缘”的现象。我当时把它当成一个独立的小毛病,打算之后再查。实际上它和面积虚增是同一个 bug 的两个面——终止条件在不同形状上提前或延后触发。把两个反常现象分开处理,就会错过它们共同的根因。
碎裂的遮罩正是轮廓追踪最容易出问题的输入。想直观看到遮罩是怎么碎的,可以打开软遮罩阈值实验,把噪声推过 0.12 再看连通性。