做图像处理的人迟早会撞上“细化骨架”这道坎:手里一张二值图,线条粗细不均、有毛刺、有断口,想量宽度得按列一行行扫,想找交点得靠模板硬怼,稍微转个角度规则全废。细化骨架(thinning / skeletonization)就是把这些“胖线条”压成单像素宽的中心线,让宽度归一、拓扑保留、后续的测量和识别有统一的坐标基准。它不改变图形的大致形状,只剥掉多余的外层像素,剩下的那条“脊梁”承载了长度、走向、分支、端点这些真正有用的信息。这篇内容我会从算法内核讲到工程落地,包含可直接跑的代码、参数怎么定、坑在哪,适合已经会写几行 OpenCV、但被细化结果里满屏毛刺和断裂折磨过的同学,也适合刚接触形态学、想弄清楚“为什么删这个像素不删那个”的新手。
1. 先把“细化骨架”这件事想明白
1.1 骨架到底在替我们解决什么问题
原始二值图里的线条是有宽度的,宽度本身携带噪声:打印抖一点、扫描糊一点、阈值偏一点,同一条笔画这头 7 像素那头 11 像素。你拿这样的图去做长度统计,误差直接跟着宽度走;去做模板匹配,字体一放大就崩;去做直线拟合,边缘两侧的像素互相拉扯。骨架化的本质是降维——把二维的面状目标压成一维的线状目标,宽度信息被丢弃,只留中心位置。
这里有个容易被忽略的点:骨架不是“随便找条中线”。数学上严格定义的骨架来自中轴变换,某个像素属于骨架,当且仅当以它为圆心的最大内切圆至少与目标边界有两个接触点。这个定义决定了骨架的三个天然属性:单像素宽、与原目标同伦(该连的还连着,该有的洞还在)、位置居中。工程上我们用的迭代模板法只是这个定义的近似实现,所以才会出现毛刺、斜线锯齿、末端多出一小截这类“不完美”,后面所有后处理都是在给这个近似擦屁股。
我见过不少项目在这上面翻车,原因往往是期望值错了:以为细化完就是一条干净漂亮的线。实际拿到的第一版结果,十个里有八个是“主干挺好,边上全是小刺”。接受这个前提,后面的工作才好安排。
1.2 三条主流路线,选错了后面全是补丁
把线条压成单像素,业界有三条路,各自的脾气完全不同,选之前先想清楚下游要什么。
| 路线 | 代表实现 | 核心思路 | 优点 | 短板 | 适用场景 |
|---|---|---|---|---|---|
| 迭代模板细化 | Zhang-Suen、Guo-Hall、Hilditch | 反复扫描,按 8 邻域模板逐层剥皮 | 结果拓扑稳定、实现简单、可控 | 迭代次数随线宽增长、斜线有锯齿、毛刺多 | 字符笔画、走线、裂纹、通用二值图 |
| 形态学腐蚀骨架 | 开运算/腐蚀组合 | 用结构元反复腐蚀再取残差 | 速度快、纯形态学算子 | 断裂严重、位置偏移、几乎不可用于测量 | 快速预览、粗定位 |
| 距离变换脊线 | medial_axis、中轴 | 算每个前景点到背景的距离,取局部极大 | 保留半径信息、管径可直接读 | 有孔洞时生成环、结果对噪声极敏感 | 血管管径、颗粒尺寸、需要半径的场合 |
我的默认选择是第一条。原因很实在:绝大多数下游任务要的是“一条能追踪的路径”,而不是“精确的内切圆半径”。迭代模板法在拓扑保持上做得最好,单像素宽度也有保证,唯一的代价是慢,而慢可以用 ROI 裁剪和索引表优化掉。
形态学腐蚀那条路我只在一种情况下用:需要极快的粗筛,比如先看看图里有没有长条状目标,有的话再走精细流程。它的断裂问题不是调参能救的,腐蚀到只剩一线的时候,稍微细一点的位置早就断了。
距离变换那条路适合“测量”类任务。举个具体例子,血管分割后要报管径,medial_axis 返回的骨架每个点带着到边界的距离,直径就是两倍距离值,一步到位。但你得有心理准备:只要目标内部有个小孔,骨架就会绕着孔长出一个闭环,后面又得写代码去环。
1.3 我给“好骨架”定的四条验收标准
调参之前先立标准,不然永远在“看着差不多”和“好像又差一点”之间摇摆。我一般用这四条卡:
- 单像素性:骨架里不应该存在 2×2 的实心块,任意骨架像素的 8 邻域内前景点数量应尽量不超过 2(分支点除外)。
- 连通性一致:细化前后的 8 连通分量数量应当相同。细化把一个连通域拆成两半,说明算法在这一段判错了。
- 分支合理:分支点的数量和位置要能和原图对得上。原图是个十字,骨架就该有个四叉;原图是一条直线,骨架就不该冒出分叉。
- 端点收敛:端点数量不能爆炸。一条直线细化出七八个端点,说明毛刺没清干净。
这四条里,第二条最硬。前三条可以靠肉眼,第二条必须用代码数,因为人眼对“断了 3 个像素”这种事实在不敏感。
2. 算法内核:模板怎么判、连通性怎么保
2.1 八邻域编码是所有细化算法的公共语言
所有迭代细化算法都建立在同一套记号上。对当前像素 P1,按顺时针把周围八个邻居编号 P2 到 P9:
P9 P2 P3 P8 P1 P4 P7 P6 P5基于这套编号,有两个量反复出现。一个是B(P1),八个邻居里前景像素的个数,它衡量这个点周围有多“厚”。另一个是A(P1),沿 P2→P3→…→P9→P2 这个环形序列走一圈,出现“0 变 1”的次数,它衡量这个点是不是连接枢纽。
A 的物理意义值得多说两句。A 等于 1,说明绕这个点转一圈,前景只起了一次头,这个点是个“过路点”,删掉它不会把两侧断开。A 大于 1,说明前景起了好几回头,这个点是多条线的交汇处或者拐角,删了就会撕裂。所以几乎所有算法的核心判据都是A == 1,这一条就是拓扑保持的保险丝。
至于位权编码,是把八个邻居按1,2,4,8,16,32,64,128打包成一个 0 到 255 的整数。这个技巧在索引表法里是主角,后面细说。
2.2 Zhang-Suen 两遍扫描,为什么非要分两遍
Zhang-Suen 是最常被拿来入门的版本,它的规则可以压缩成一句话:在满足 A==1 且 2≤B≤6 的前提下,分两个子迭代,各砍掉一部分边界点。
第一子迭代的附加条件是 P2·P4·P6=0 且 P4·P6·P8=0,第二子迭代换成 P2·P4·P8=0 且 P2·P6·P8=0。这两组条件看起来像天书,其实是在限制“只能砍东南方向”和“只能砍西北方向”。
为什么非要分两遍?因为单遍扫描会出问题。假设有一条宽 2 像素的斜线,如果一次性把所有满足 A==1 的边界点全删掉,整条线可能被削成一条对角线上的孤立点,甚至直接消失。分两遍相当于给每次删除加了方向约束,第一遍只削一侧,第二遍再削另一侧,剩余的部分还能撑住结构。
这个设计带来一个副作用:结果是顺序相关的。同一张图,正着扫和反着扫,结果会有细微差别,通常表现为末端的形状不同。做像素级对比测试的时候别拿这个当 bug。
B 的下界设成 2 也有讲究。B==1 说明这个点本身就是端点,删了就少一截;B==0 是孤立点。上界 6 是为了保护粗线内部,太厚的点周围邻居多,还没轮到它。
2.3 索引表法:把逐点判断压成一次查表
纯 Python 写 Zhang-Suen,一张 2000×2000 的图能跑到分钟级,原因是每个像素每轮都要做十几次条件判断,而迭代轮数又跟线宽成正比。优化思路很直接:八个邻居的组合只有 256 种,那就预先算好哪 256 种组合可以删,运行时只做一次位运算拼索引、一次数组查表。
import numpy as np def build_zs_lut(): lut = np.zeros(256, dtype=np.uint8) for code in range(256): # 按 P2..P9 顺序取位,位权递增 p = [(code >> i) & 1 for i in range(8)] B = sum(p) if B < 2 or B > 6: continue seq = p + [p[0]] A = sum(1 for i in range(8) if seq[i] == 0 and seq[i + 1] == 1) if A != 1: continue p2, p3, p4, p5, p6, p7, p8, p9 = p # 第一子迭代条件 if p2 * p4 * p6 == 0 and p4 * p6 * p8 == 0: lut[code] |= 1 # 第二子迭代条件 if p2 * p4 * p8 == 0 and p2 * p6 * p8 == 0: lut[code] |= 2 return lut建好表之后,每轮循环只做卷积式的邻域取值和查表,用 numpy 切片就能向量化,速度能提一到两个数量级。我实测过一张 1600×1200 的工程图纸,纯循环版本跑 40 秒往上,索引表加向量化压到 1 秒出头,差距主要在这一步。
注意:拼索引的时候位序必须和建表时完全一致。P2 到 P9 顺时针,位权 1 到 128,写错一位,结果就是“看起来还在细化,实际删错一堆像素”,而且很难从输出图上直接看出来,只能靠连通性测试抓。
2.4 Guo-Hall 与 Hilditch:什么时候值得换算法
Zhang-Suen 的毛病是斜线锯齿明显。一条 45 度的宽线细化完,骨架上会出现规律的台阶。Guo-Hall 用四个条件替换,判据更细,斜向的支持更好,结果通常更平滑,而且子迭代次数固定为两次,收敛更快。
Hilditch 是更老的经典方法,它引入“并行”和“串行”的混合判定,考虑邻居在本次迭代中是否已被标记删除。这个特性让它在某些手工笔画上表现更好,但实现复杂度高,而且结果对扫描顺序敏感,我在工程里用得不多。
实际选择上我的经验是这样:字符识别、笔画分析这类对骨架平滑度敏感的,优先试 Guo-Hall;通用二值目标做长度和分支统计,Zhang-Suen 够用;图像里既有粗线又有大量细弱连接,先别急着换算法,去检查预处理,八成是二值化把细节吃掉了。换算法是最后的手段,因为换完之后后处理的参数全要重调。
3. 实操:从一张脏图到一条干净骨架
3.1 预处理决定八成成败
细化是放大器,输入有多脏,输出就有多脏。噪声点被细化成短线段,边缘毛刺被细化成分叉,小孔洞被细化成闭环。所以顺序上,所有清理工作都必须放在细化之前,细化之后再补救,成本翻好几倍。
我固定的预处理链是这样:灰度化 → 中值滤波 → 背景校正(可选)→ 二值化 → 形态学开运算 → 面积过滤 → 细化。
中值滤波核取 3 或 5,视噪声颗粒大小。这里要提醒一句,别用高斯,高斯会把边缘模糊,二值化之后再细化,末端会缩成圆头,长度统计偏短。中值对椒盐噪声的效果好,而且保边。
开运算的核大小按你预估的最小线宽来定,一般是线宽的三分之一左右。线宽 9 像素,核取 3×3 就够;核一大,细线直接消失。开运算的目的是去掉孤立的凸起和细小的粘连,让后续细化少长几根刺。
面积过滤这一步很多人省掉,我觉得不能省。二值化之后边缘常有一堆十几像素的小碎块,细化后每条碎块都变成一根小短棍,全混进结果里,分支点统计直接废掉。用cv2.connectedComponentsWithStats按面积筛一遍,阈值取预估最小目标面积的十分之一,效果立竿见影。
3.2 二值化参数怎么定,别迷信一种方法
| 方法 | 关键参数 | 适用条件 | 坑 |
|---|---|---|---|
| 固定阈值 | threshold 值 | 光照稳定、背景纯净 | 换一张图就废,别用在批量流程 |
| Otsu | 无 | 直方图双峰明显 | 前景占比极小(<5%)时阈值偏移严重 |
| 自适应阈值 | blockSize(奇数)、C | 光照不均、背景渐变 | blockSize 过小会引入块状噪声 |
| Sauvola 类 | 窗口、k | 文档、低对比度 | 计算量大,参数需按图调 |
我最常用的是“自适应 + Otsu 兜底”的组合:先算 Otsu,看前景占比是否落在 3% 到 40% 这个区间,落在里面就用 Otsu;落外面说明这张图光照或对比度有问题,切自适应。自适应阈值的 blockSize 取图中典型线宽的 3 到 5 倍并强制为奇数,C 取 5 到 10,实测在多数工业图上都稳。
这里有个细节值得说:二值化后要做一次连通性方向的一致性检查。如果目标的细线在二值化后出现大量单像素断口,细化后就是一堆断头骨架。补救办法是在细化前做一次 3×3 的闭运算,把断口先接上。宁可接错几个像素,也别留断口,因为断口在骨架阶段几乎无法区分“真断”和“假断”。
3.3 细化实现:三行调用与手写版本
如果只是要结果,OpenCV 的 contrib 模块和 scikit-image 都能直接用:
import cv2 import numpy as np from skimage.morphology import skeletonize, medial_axis # 前提:binary 为 uint8,前景 255,背景 0 binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1] # 方案一:OpenCV contrib,速度快,返回 0/255 skel_cv = cv2.ximgproc.thinning(binary, thinningType=cv2.ximgproc.THINNING_ZHANGSUEN) # 方案二:scikit-image,输入需为 bool,返回 bool skel_sk = skeletonize(binary > 0, method='zhang') # 方案三:需要半径信息时用 medial_axis skel_ma, dist = medial_axis(binary > 0, return_distance=True)三个方案的差异值得记一下:ximgproc.thinning支持 Guo-Hall,速度最快;skeletonize的接口干净,返回的 bool 数组做后续逻辑运算方便;medial_axis多返回一个距离图,做管径、颗粒尺寸测量时直接用它,省掉一步。
手写版本用前面建的索引表,向量化实现大概是这样:
def zhang_suen_fast(binary, max_iter=200): img = (binary > 0).astype(np.uint8) img = np.pad(img, 1, mode='constant') lut = build_zs_lut() for _ in range(max_iter): changed = False for phase in (0, 1): # 两个子迭代 p2 = img[:-2, 1:-1]; p3 = img[:-2, 2:]; p4 = img[1:-1, 2:] p5 = img[2:, 2:]; p6 = img[2:, 1:-1]; p7 = img[2:, :-2] p8 = img[1:-1, :-2]; p9 = img[:-2, :-2] p1 = img[1:-1, 1:-1] code = (p2.astype(np.uint16) | (p3.astype(np.uint16) << 1) | (p4 << 2) | (p5 << 3) | (p6 << 4) | (p7 << 5) | (p8 << 6) | (p9 << 7)) removable = (lut[code] & (1 << phase)) > 0 hit = removable & (p1 == 1) if hit.any(): p1[hit] = 0 changed = True if not changed: break return (img[1:-1, 1:-1] * 255).astype(np.uint8)max_iter设 200 是保险值。理论上轮数约等于最粗处的半宽,实际因为两个子迭代一轮各删一层,粗 40 像素的线大概 20 轮内就收敛。留足余量是为了防止某张图有异常粗的块导致提前退出。
提示:
ximgproc需要装opencv-contrib-python,只装基础包会报module 'cv2' has no attribute 'ximgproc'。这是新手最常见的第一个拦路虎。
3.4 后处理:剪枝、去毛刺、补断口
细化出来的第一版,通常长这样:主干清楚,但每隔几个像素就伸出一根两三个像素的小刺。这些小刺的来源是原图边缘的微小起伏,本质上是中轴变换对边界噪声的正常反应,不是算法错了。
剪枝的标准做法是迭代删除端点:找 8 邻域只有 1 个前景点的像素,删掉,重复 N 次。N 取多大会直接决定结果——N 太小刺还在,N 太大会把真实的短枝也吃掉。
def prune(skel, max_len=5): skel = (skel > 0).astype(np.uint8) for _ in range(max_len): kernel = np.array([[1, 1, 1], [1, 10, 1], [1, 1, 1]], dtype=np.uint8) # 邻域和等于 11 表示中心为前景且只有一个前景邻居(端点) nb = cv2.filter2D(skel, -1, kernel) endp = ((skel == 1) & (nb == 11)).astype(np.uint8) if endp.sum() == 0: break skel[endp == 1] = 0 return skel那个 10 的技巧值得解释一下:中心权重设 10,八个邻居各 1,中心为 1 时总和至少 11。邻域和正好 11,说明中心是前景且周围只有一个前景点,那就是端点。这个方法比手动数邻居快得多。
N 的取值我一般按线宽来:线宽的 0.5 到 1 倍。线宽 10 像素,N 取 5 到 8。但别全局一刀切,如果图里既有粗线又有细线,最好按连通域分别处理,细线的刺短,用同一个 N 会把细线的真实末端削掉。
补断口是另一件事。骨架断裂的根源在二值化,但细化之后也能救:找所有端点,两两配对,距离小于阈值且方向大致相反的就连一条直线。阈值取预估最大断口宽度的 2 倍,方向判据用端点处的局部走向,夹角超过 60 度就别连。
def find_endpoints_and_branches(skel): s = (skel > 0).astype(np.uint8) kernel = np.ones((3, 3), np.uint8) nb = cv2.filter2D(s, -1, kernel) - s # 只数邻居 endpoints = np.argwhere((s == 1) & (nb == 1)) branches = np.argwhere((s == 1) & (nb >= 3)) return endpoints, branches端点数量是个很好的健康指标。我一般会把它打到日志里,同一批图里某张图的端点数突然翻三倍,基本可以判定那张图的二值化出了问题,回去查比在骨架阶段硬修划算得多。
3.5 骨架转路径:从像素集合到可用的线
骨架是像素集合,下游要的往往是“一条从 A 到 B 的点序列”或者“线段列表”。转换的关键是处理分支点:遇到分支就切段。
做法是从端点出发做深度优先遍历,走到分支点就停,把走过的路径记下来,然后从分支点继续往未访问的邻居走。每段路径存成点列表,附带起止点坐标和长度。这套结构拿去做直线拟合、做长度统计、做拓扑图分析都方便。
def trace_paths(skel): s = (skel > 0).astype(np.uint8) kernel = np.ones((3, 3), np.uint8) nb = cv2.filter2D(s, -1, kernel) - s ends = [tuple(p) for p in np.argwhere((s == 1) & (nb == 1))] visited = np.zeros_like(s) paths = [] for start in ends: if visited[start] == 1: continue path = [start] visited[start] = 1 cur = start while True: y, x = cur nxt = None for dy in (-1, 0, 1): for dx in (-1, 0, 1): if dy == 0 and dx == 0: continue ny, nx = y + dy, x + dx if 0 <= ny < s.shape[0] and 0 <= nx < s.shape[1] \ and s[ny, nx] == 1 and visited[ny, nx] == 0: nxt = (ny, nx) break if nxt: break if nxt is None: break path.append(nxt) visited[nxt] = 1 cur = nxt if nb[cur] >= 3: # 分支点,切段 paths.append(path) path = [cur] if len(path) > 1: paths.append(path) return paths这段代码没有处理“分支点重复入栈”的边界情况,实际工程里建议加一个分支点访问计数,允许一个分支点被多条路径共享。我第一版就是漏了这个,结果交叉的十字只追出一条边就停了,排查了半天。
4. 性能与工程化:大图、批量、实时
4.1 复杂度从哪来,加速从哪下手
细化算法的复杂度是O(N × W),N 是像素总数,W 是最大半宽对应的迭代轮数。这个式子说明两件事:图越大越慢,线越粗越慢。加速就三个方向。
降像素。如果目标只占图的一小块,先做 ROI 裁剪再细化。工程图纸这种大面积留白的场景,裁完能省 80% 的时间。裁剪前记得把 ROI 边界外扩几个像素,避免目标被切断。
降轮数。线宽如果普遍偏粗,先做一次腐蚀把线削细再细化,能显著减少迭代轮数。但要小心,腐蚀会改拓扑,细的地方直接断。只有当线宽普遍大于 6 像素、且细线不是关注对象时才敢这么干。
向量化与索引表。前面讲过,把逐像素判断换成 numpy 邻域运算加 256 项查表,速度差一个数量级。这是收益最高的一步,做完之后一般不需要再考虑 C++ 重写。
我做过一个粗略对比,同一张 2400×1800 的走线图,纯 Python 双循环约 55 秒,numpy 向量化约 3.5 秒,向量化加索引表约 1.2 秒,OpenCV 的ximgproc.thinning约 0.4 秒。可见编译好的实现还是最快,自研版本的价值在于能改条件、能加约束,比如只允许特定方向的删除。
4.2 批处理与内存:别一次全读进来
批量跑图的时候,最常见的翻车是内存。几百张图一次性读成数组,加上中间变量,几百兆就上去了。我的做法是单张处理完立刻释放,结果只保留骨架的坐标列表和统计量,原始数组不保留。
中间变量的类型也要注意。astype(np.uint8)和astype(np.uint16)在拼接邻域索引时必须有区别,索引会超过 255,用 uint8 会静默溢出,表现是“某些像素莫名被删”。这个 bug 我踩过,排查了两天才定位到。
还有一点:OpenCV 的filter2D对 bool 数组不支持,得先转 uint8。scikit-image 的输入输出都是 bool,跟 OpenCV 混用的时候类型转换别忘了,一忘就报维度不匹配。
4.3 对接下游:不同任务对骨架的要求不一样
字符与笔画分析。传统 OCR 流程里细化过骨架,用来统计笔画方向和宽度变换。现在深度学习方案大多直接用灰度图,细化反而会丢信息,尤其在低分辨率下。但如果要做模板匹配、要算笔画的细节点,骨架依然是基础。
走线与裂纹检测。骨架直接给出“长度”这个指标,不用再折算宽度。分支点就是分叉位置,端点就是断口位置。裂纹检测里,一条裂纹的骨架总长就是它的长度,分支数就是分叉数,比在原图上做区域统计稳得多。
血管与管状目标。这类任务必须用距离变换那条路,因为要报管径。medial_axis的返回值里带着每个骨架点的内切半径,直径取两倍,位置取骨架坐标,一次搞定。用迭代模板法也能做,但需要额外算距离图去查,多一步。
颗粒与纤维。纤维长度、弯曲度都是骨架上的量。这里要注意细化前必须做足去噪,纤维边缘的一点毛毛细化后就是一根分叉,直接影响弯曲度的计算。
5. 踩坑实录与排查速查表
5.1 典型故障对照表
| 现象 | 最可能的原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 骨架大面积断裂 | 二值化阈值偏高,细线先没了 | 回看二值图,量细线处前景宽度 | 降阈值或改自适应,细化前闭运算 |
| 主干上密集短刺 | 边缘噪声、未做开运算 | 看原图边缘是否毛糙 | 加大中值核,加开运算,剪枝 N 调大 |
| 交叉处变成糊团 | 交叉区域太粗,迭代不收敛 | 看交叉处原始宽度 | 加大 max_iter,或对交叉 ROI 单独处理 |
| 出现闭环(原图无孔) | 线宽不一致,细处断开后绕回 | 检查是否存在环形细结构 | 别急着去环,先看是不是真实孔洞 |
| 整图消失或只剩点 | 输出了 0/1 被当成 0/255 显示 | 打印像素值范围 | 细化后统一乘 255 |
| 端点数量异常多 | 噪点碎块被保留 | 统计连通域面积分布 | 细化前加面积过滤 |
| 结果和上次不一样 | 算法本身顺序相关,或输入有变化 | 固定输入做回归测试 | 记录算法版本与参数,别裸跑 |
| 提速后结果变差 | 位序拼错或类型溢出 | 与参考实现对拍,逐像素 diff | 检查 uint8/uint16 与位权顺序 |
5.2 剪枝阈值到底怎么定
这是被问得最多的问题。我的答案永远是:先量线宽,再定阈值。具体做法是在二值图上随机取若干行,统计每段前景的连续长度,取中位数当线宽。剪枝长度取线宽的 0.5 到 1 倍,这是经验值,原理是真实分支的长度通常大于线宽,而毛刺的长度通常小于线宽。
但如果图里线宽差异大,一刀切必然误伤。这时候按连通域分别统计线宽,各自剪各自的。代码上就是connectedComponentsWithStats加循环,不复杂,但能把误剪率降下来。
还有一个更聪明的做法:不做长度剪枝,改成按分支的“深度”剪。从每个端点往回走,如果走不到一个长度超过阈值的主干就停,说明这是根悬空的短枝,整根删掉;如果能走到主干,说明是真分支,保留。这个判据比单纯数端点准确得多,代价是要多写几十行追踪逻辑。
5.3 几条不那么容易查到的经验
第一条,先在灰度域做背景校正,别在二值域硬修。光照不均导致的粗细不均,二值化怎么调都调不好,用大核形态学做一次背景估计再相减,效果比调十次阈值强。
第二条,别用骨架去算面积。骨架是线,面积是面,这俩没有对应关系。要面积就老老实实在二值图上数前景像素。
第三条,细化前后都要存中间结果。出问题的时候,能一眼看出是二值化错了还是细化错了,省掉大量重跑时间。我现在的流程固定输出四张图:原图、二值图、细化图、剪枝后图,命名带批次号。
第四条,回归测试用固定的几张“刁钻图”。我手上常备五张:一条 45 度斜线、一个十字交叉、一条 2 像素细线、一个带小孔的区域、一张光照不均的实拍。改了参数先跑这五张,肉眼过了再上批量。这五张图能覆盖八成的问题。
第五条,滑动窗口的处理别忘补边。大图切块处理时,块与块的接缝处骨架必断。解决办法是块之间重叠线宽的 3 倍以上,拼回来看重叠区,重合的保留,不重合的按距离就近连。这个细节不做,整张图会呈现规整的网格状断裂,非常难看。
6. 验收与量化评估:别只靠眼睛
6.1 单像素宽度怎么量
最直接的方法是统计每个骨架像素的 8 邻域前景数。理想情况下,直线段上的点邻域数为 2,端点数为 1,分支点数大于等于 3。定义单像素率为邻域数小于等于 2 的骨架点占比,这个值在 0.85 以上算合格。低于 0.8 说明存在大量 2×2 残留块,通常是算法没收敛或者max_iter设小了。
还有一个指标是 2×2 块的数量。用四个方向的 2×2 窗口卷积一遍,统计全 1 的窗口数,理想值是 0。实际工程里能压到个位数就很好了,因为某些交叉点确实会残留。
6.2 拓扑保持怎么测
连通分量数量用cv2.connectedComponents(binary, connectivity=8)数,细化前后对比。差值大于 0 说明断了,小于 0 说明连了不该连的。这两个方向都要报警。
孔洞数量用欧拉数或者简单地对背景做连通分量统计。原图有几个洞,骨架就应该有几个环。这条在裂纹、血管这类任务里特别重要,因为环对应的是真实的组织间隙或者血管分叉。
分支点的数量不需要绝对一致,但变化率应该控制在 20% 以内。超过这个数,回去看剪枝是不是剪狠了,或者预处理把细小结构吃掉了。
6.3 一张参数自查表
| 参数 | 作用 | 典型取值 | 调整方向 |
|---|---|---|---|
| 中值核 | 去噪保边 | 3 或 5 | 噪声大往大调,细线多往小调 |
| 开运算核 | 去凸起 | 线宽/3 的奇数 | 毛刺多调大,细线消失调小 |
| 面积阈值 | 去碎块 | 最小目标面积/10 | 目标多且小就调小 |
| 自适应 blockSize | 局部阈值窗口 | 线宽×3 到 5,奇数 | 光照渐变快调小,块状噪声调大 |
| max_iter | 细化轮数上限 | 200 | 异常粗块多就调大 |
| 剪枝长度 N | 去毛刺 | 线宽×0.5 到 1 | 刺残留调大,真分支被削调小 |
| 断口连接距离 | 补断 | 最大断口宽×2 | 连错就调小 |
| 分支夹角门限 | 断口方向校验 | 60 度 | 交叉结构多就调大 |
这张表我贴在自己的便签上,每次开新项目先按表填一遍初值,再根据输出微调。比起从零试参数,能省不少时间。
提醒一句,参数之间是有耦合的。开运算核调大,线宽统计就变小,剪枝阈值跟着要调小;自适应窗口调小,二值图噪声变多,面积阈值就得跟着调大。别孤立地看单个参数,改一个至少回归测三项。
我个人在实际项目里的体会是,细化骨架这活儿的难点从来不在算法本身,那几十行代码网上一搜一堆。真正花时间的是预处理链的设计和后处理阈值的拿捏,而这两块的答案只能从自己那批图里长出来。所以别急着抄参数,先把手上图的线宽分布、噪声水平、断口比例这几个数摸清楚,剩下的就是按图填空。我现在的习惯是先拿十张有代表性的图跑一遍全流程,把每个环节的中间结果和统计量都打出来,形成一份“基线档案”,后面所有改动都对着它比。这份档案比任何参数表都值钱。