7 轮廓矢量化
✅ 已验证 · 代码 code/contour.py · 自检 python3 code/contour.py
为什么必须是曲线
旧路线的边界一路是「1x 硬标签图 → SDF → bicubic 放大 8 倍」。SDF 的零等值线本身就建在 1x 的台阶上,放大只是把台阶放大。高斯平滑 SDF 能把 K90(边缘曲率,锯齿的直接度量)从 0.31 压到 0.05,但那是把台阶抹圆,不是换成曲线——形状信息在 1x 那一步就丢了。
矢量曲线是分辨率无关的,8x / 16x / 100x 都一样光滑。而且 Krita 的矢量层渲染是解析级的:同一条曲线,矢量层与解析真值的边界带偏差 0.083,paintPolygon 0.498,select_mask 0.331(第 27 章)。
四步
contour.trace(mask, smooth=0.8, eps=0.30, expand=0.0):
- 标量场
level_field():内正外负,零点落在真实边界上。传 α 场时,边界带用α − 0.5,远处用 SDF 提供全局结构。单位统一成像素,这样expand的偏移在两个区域对得上。 - 等值线:高斯平滑
smooth=0.8之后,取−expand等值线。用 matplotlib 的contour(在解析圆的 SDF 上实测半径 mean 19.9988 / std 0.0015,真值 20)。 - 简化
lineart.rdp(eps=0.30):太小把台阶原样保留成折点,太大啃掉真的尖角。 - 闭合 Catmull-Rom → 三次贝塞尔
_catmull_closed()。开口版首尾会有折角,闭合环要绕回去取邻居。
产出 SVG <path>,fill-rule="evenodd" 让洞自动挖掉。
+0.5 —— 一个半像素的坐标约定
matplotlib 的 contour 返回数组下标空间的坐标:下标 (0,0) 是第一格的中心。而 PIL / Krita / SVG 里像素 (0,0) 覆盖 [0,1)×[0,1),中心在 (0.5, 0.5)。差这半格不报错、形状照样光滑,只是整条曲线系统性偏移半个像素。
实测不加 +0.5 时:控制点到解析曲线 mean 0.442 / max 0.783 px,降回 1x 的形状偏差 0.302——而光栅化本身的地板只有 0.007。一个纯粹的坐标约定错误,差点被读成「矢量化不如 coverage()」。
自检:v4 与 v5 同题对比
一个解析形状 → 1x 二值 mask → 两条路各自重建 8x → 与 8x 解析真值比:
| 路子 | K90(8x) | aliased% | 形状偏差 | 面积 |
|---|---|---|---|---|
| 解析抗锯齿(锚点) | 0.0642 | 3.6 | — | — |
v4 coverage(smooth=0.8) 最佳 | 0.1475 | 30.0 | 0.1521 | +0.43% |
v5 二值 mask(smooth=1.5 eps=0.30) | 0.0448 | 2.8 | 0.0715 | +0.01% |
v5 α 场(smooth=0.8 eps=0.30,默认) | 0.0422 | 2.6 | 0.0485 | +0.01% |
三项同时赢:边更平(K90 好 3.5 倍)、更抗锯齿(好 11 倍)、形状还更准(好 3.1 倍)。v5 的 K90 甚至低于解析锚点——曲线本来就比像素化的真值光滑。
判据是配对的,缺一不可
- K90(8x 覆盖率的 level-set 曲率)→ 边平不平
- 降回 1x 与 1x 真值比 → 形状有没有被抹坏
只看 K90 会奖励「抹圆一切」。而且 1x 真值必须由 8x 真值降采样得到,不能另画一张——另画的那张与 8x 那张之间有约 1/8 像素的系统偏移,锚点读出 0.1245 而不是 0。
一个失败的判据
最初用的是 probe_fill 的判据——「8x 真值抗锯齿带里的 |覆盖率−真值|」。在这里它是坏的:那条带只有 1px 宽,而从 1x 二值 mask 恢复不出 8x 的亚像素位置,结果 v4 四条、v5 十二条全部读出 0.47±0.02,毫无区分度。它量的是「边在不在原位」,而这里要问的是「边是曲线还是台阶」——两个不同的性质。probe_fill 里它能用,是因为那里矢量和真值来自同一条解析曲线,天然对齐。
在真实图上
origin 的 15 个部件:5,043 个控制点(只有 Flat)/ 20,495 个(含色阶),5.5s / 26.7s。K90 0.056–0.11,全部好于 v4 的 0.15。
expand:各部件独立矢量化,边界曲线不会正好贴合。躯干那块实测 expand=0 时 0.935% 的主体像素漏出底色,expand=0.6 压到 0.41%。剪影外鼓出来的那圈由第 14 章的组 clip 切掉。
8K 1:1 眼部,只有矢量层。边是曲线,一个台阶都没有。这一张归一到 2508px 后梯度 p99 = 78.9,高于参考画区间。