多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python图像中心线提取实战:OpenCV预处理与skimage骨架化

Python图像中心线提取实战:OpenCV预处理与skimage骨架化 很多做视觉和GIS的朋友第一次提中心线提取这个需求时往往手里已经拿着一张二值化好的图却不知道下一步该调用哪个函数。我第一次做这个任务是在处理道路掩膜数据时想从一块带宽度的路面区域里抽出那条中心线接着去算里程。当时先翻了OpenCV文档发现它没有现成的skeletonize函数只有morphologyEx、distanceTransform这些零件硬拼出来的结果又毛又乱。后来切到skimage的skeletonize才真正理解OpenCV和skimage在这个问题上的分工。这篇文章不绕弯子直接讲清楚在Python里用OpenCV做前期的二值分析与形态学清理再用skimage.morphology.skeletonize提取中心线最后把骨架落回到坐标和业务数据里。适合刚入门图像处理的同学也适合在GIS、测量、医疗影像场景里被提取中心线折磨过的开发者。1. 先搞清需求中心线和边缘不是一回事别用错算法1.1 中心线的本质是什么中心线在图像处理里通常指形状的骨架skeleton或中轴medial axis。你可以把它理解成沿着一个形状内部最中间的地方画一条线这条线保留了形状的拓扑结构和大致走向但把宽度信息压缩掉了。注意这跟边缘检测完全是两码事。边缘检测找的是灰度突变的位置输出的是形状外轮廓中心线提取要的是形状内部的主干。举个直观的例子——一张道路掩膜图道路有3个像素宽、1000个像素长。边缘检测给你的是道路两侧的边界线中心线提取给你的是那条沿着道路走向的、单像素宽的折线。前者适合做目标定位、轮廓绘制后者适合做长度测量、走势分析和路径规划。把这两个概念混在一起最容易导致的结果是你拿着边缘检测的结果去量长度量出来的是轮廓周长完全偏离需求。1.2 工程上常见的三种中心线场景我把实际项目里遇到的需求分成三类它们的算法侧重完全不同别上来就skeletonize一把梭GIS带状要素中心线比如从缓冲区多边形、河流面状数据中提取中心线。这类数据通常没有噪声但形状不规则而且结果要求光滑、能转成矢量要素。用骨架化后再做折线简化比较合适。细长结构测量如血管、电线、道路网的骨架提取后续要量长度、算分支、分析连通性。这类场景对拓扑正确性要求极高分叉和断裂都是致命问题常用skeletonize或者Zhang-Suen细化配合skan这类工具做骨架网络分析。路径规划与路径生成如自动导引车路径规划、3D打印填充路径、笔迹识别。这类场景除了位置往往还要求骨架曲线的顺序关系需要把骨架点串成有序曲线再使用。1.3 什么情况下不该用骨架化我见过不少把骨架化当万能方案的结果在某些场景里方案并不成立。比如形状本身非常宽、内部的中心并不是唯一的或者输入图像有大量噪声、除了主结构还有一堆碎片此时骨架化会被碎片点带偏。遇到这两种情况应该先做连通域分析、按面积筛掉小轮廓再做形态学开运算平滑边界最后才骨架化。顺序搞反了后续所有处理都是给噪声擦屁股骨架网络会变得极其混乱。还有一种情况是你要的是带状区域中间一条光滑的线段区域本身并不细长而是像湖泊、广场这样的面状地物。骨架化出来的往往不是一条线而是一堆复杂的放射状结构这在形态学上是很正常的。这时候你应该考虑用中轴线的几何算法或者直接用最大内切圆圆心连线的思路做后处理而不是硬把骨架剪成一条线。2. 原理不搞懂参数全靠猜骨架化的两种数学视角2.1 视角一最大内切圆的圆心轨迹中轴变换的定义是对于一个形状区域找到所有能够被完全包含在区域内部的最大内切圆这些圆的圆心连起来就是中轴。你可以想象在一个不规则多边形里塞很多大小不一的圆圆必须贴在区域内、不能被边界挡住每个圆都有自己最大的半径所有圆心的集合构成了中轴。这个定义的好处是直观而且它天然携带一个附加信息每个中轴点对应一个内切圆半径这个半径就是该处形状的局部半宽度。后面我们用distanceTransform求宽度原理就是基于这个定义。正因为中轴点携带了局部宽度信息它才能在图斑细长变化明显的场景下依然保持中心位置正确而不是简单地把形状中线往某个方向拉偏。2.2 视角二逐层剥离也就是形态学细化另一个视角更符合骨架化这个直观过程把形状想象成一棵卷心菜一层一层把最外层的叶子剥掉只保留最后剩下的一像素宽的核心就是骨架。在形态学里这对应的是反复进行腐蚀操作同时保证结构不断裂、不消失。具体算法里有个经典细则是Zhang-Suen它每次迭代检查像素的8邻域判断哪些边界像素可以删除而不破坏连通性直到没有像素可删为止。skimage.morphology.skeletonize默认用的就是这个算法输入二值图输出布尔骨架图。逐层剥离视角的一个关键价值是它解释了为什么骨架能够保持拓扑。腐蚀一次形状缩小一圈但只要你不删掉那些桥接像素整体连通结构就不会断开。这也是骨架化与单纯腐蚀的本质区别——单纯腐蚀会把细长结构直接腐蚀没而细化算法每一步都在检查连通性保证最后剩下的核心仍然代表原来的拓扑关系。2.3 skeletonize和medial_axis怎么选skimage里还有medial_axis基于distance transform的局部极值提取。两者的输出经常很像但语义不同skeletonize强调拓扑保持速度快适合细长结构、需要保持分支连通性的场景血管、道路网medial_axis强调中轴位置会保留更多与半径相关的信息同时也更容易产生毛刺适合需要精确中轴位置、后续要做宽度分析的场景。我会在实践里给出一个选型倾向大部分中心线提取需求先用skeletonize因为它稳定如果发现中轴位置肉眼可见地偏再换medial_axis对比。这两个函数输入都是布尔数组切换成本只有一行代码完全可以把两种结果都算出来叠在图上肉眼对比再做决定。不要迷信哪个一定好数据说了算。3. OpenCV预处理 skimage骨架化一键可跑的完整代码3.1 环境准备开箱即用的组合是opencv-python scikit-image numpy matplotlib。安装命令pip install opencv-python scikit-image numpy matplotlib如果你在用Anaconda也可以写成conda install opencv scikit-image numpy matplotlib版本方面OpenCV 4.x和skimage 0.19在实际表现上没什么冲突我目前用下来的组合是opencv-python 4.8 scikit-image 0.22运行稳定。如果你在Linux服务器上跑注意OpenCV的GUI依赖可能缺失但本文的代码只用imread和imshow不依赖高亮窗口问题不大。3.2 完整流程与代码整个流程分五步读图 → 灰度化 → 二值化 → 形态学清理 → 骨架化。为什么要把OpenCV放在前面因为skimage更擅长形态学和骨架算法但读图、阈值处理和轮廓分析这几个环节OpenCV更齐全、性能也更好。下面是完整可跑的代码import cv2 import numpy as np import matplotlib.pyplot as plt from skimage.morphology import skeletonize # 1. 读取图像并转为灰度 img_bgr cv2.imread(sample.png) if img_bgr is None: raise FileNotFoundError(图像读取失败请检查路径) gray cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) # 2. 二值化优先使用Otsu自动阈值 # 如果希望前景是白色物体用THRESH_BINARY_INV还是THRESH_BINARY # 取决于你的输入深色背景亮目标用BINARY亮背景深目标用BINARY_INV _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 3. 形态学开运算先腐蚀后膨胀去掉细小噪声、平滑边缘 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) clean cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations2) # 4. 从2值图到骨架 # skeletonize要求前景为True背景为False foreground clean 0 skeleton skeletonize(foreground) # 5. 骨架是布尔数组转成方便叠加显示的uint8 skel_img (skeleton * 255).astype(np.uint8) # 可视化叠加显示原掩膜与骨架 plt.figure(figsize(12, 4)) plt.subplot(1, 3, 1) plt.imshow(gray, cmapgray) plt.title(Original) plt.subplot(1, 3, 2) plt.imshow(clean, cmapgray) plt.title(Binary Clean) plt.subplot(1, 3, 3) plt.imshow(img_bgr[..., ::-1]) plt.imshow(skel_img, cmapgray, alpha0.7) plt.title(Skeleton Overlay) plt.tight_layout() plt.show()3.3 几个容易翻车的细节第一二值化的方向。skeletonize假设前景是True、背景是False。如果你把背景当成了前景骨架会提取到图像四周边框输出完全没意义。拿到掩膜图后先打印一下它的像素统计np.unique(binary)看看到底是0和255还是0和1。如果是0和255一定要做clean 0把它变成布尔值千万别直接拿255去算skel skeletonize(clean)这样会把所有非零区域当成前景方向就对了但类型不对容易在某些旧版本skimage里报警告。第二开运算的核选择。MORPH_ELLIPSE椭圆核比矩形核更平滑处理道路、血管这类自然曲线更合适。核大小和迭代次数要按你的噪声颗粒度调整我习惯从5×5开始效果不理想再加到7×7或9×9。迭代次数太多会把细的分支提前腐蚀掉让骨架拓扑不完整这个要根据实际效果来。第三skeletonize的输出是bool直接乘255才能显示为0/255图。很多新手在显示时得到一片黑或一片白基本都是数据类型转换的问题。用plt.imshow显示时bool数组可以直接显示但导出PNG时一定要转成uint8再存。第四如果原始图像不是干净的掩膜而是带有颜色的栅格图直接灰度化再二值化经常会有阴影噪声。建议在二值化之前先做一个彩色分割或基于HSV的掩膜提取把目标区域干净地分离出来。灰度化只处理亮度差异色相差异下效果很差这个问题在植被、水体这类地物分割里尤其常见。4. 骨架图直出没法用毛刺、分叉、断裂的实战修复4.1 毛刺问题骨架线旁边的多余小分支skeletonize输出之后你会发现几乎每个真实案例的骨架上都有很多细小的毛刺也就是从主骨架伸出去的很短分支。毛刺的来源是原始掩膜边界上的突起这些突起在层层剥离时留下了自己的小主心骨。边界越粗糙毛刺越多。比如从无人机影像里分割出来的道路掩膜边缘常常带有植被或阴影的锯齿骨架化之后的毛刺数量非常可观。处理毛刺的思路是剪枝识别骨架的端点沿着端点往内走如果一段分支短于设定阈值就把这段删掉。判断某个像素是不是端点可以统计它8邻域里骨架像素的个数等于1就是端点。判断是不是分叉点等于3以上是分叉点。下面是剪枝代码def is_endpoint_or_branch(skel, y, x): 统计8邻域内骨架像素数量1为端点3及以上为分叉点 count 0 for dy in (-1, 0, 1): for dx in (-1, 0, 1): if dx 0 and dy 0: continue ny, nx y dy, x dx if 0 ny skel.shape[0] and 0 nx skel.shape[1]: if skel[ny, nx]: count 1 return count def prune_skeleton(skel, min_branch_len10): 剪掉长度低于阈值的骨架分支 skel skel.copy() h, w skel.shape changed True while changed: changed False endpoints [] for y in range(1, h - 1): for x in range(1, w - 1): if skel[y, x] and is_endpoint_or_branch(skel, y, x) 1: endpoints.append((y, x)) for y, x in endpoints: branch [(y, x)] cy, cx y, x while True: prev branch[-1] neighbors [] for dy in (-1, 0, 1): for dx in (-1, 0, 1): if dx 0 and dy 0: continue ny, nx cy dy, cx dx if 0 ny h and 0 nx w and skel[ny, nx]: neighbors.append((ny, nx)) # 去掉上一个点后剩余的邻居数量 nxt [n for n in neighbors if n ! prev] if len(nxt) 1: branch.append(nxt[0]) cy, cx nxt[0] else: # 要么到了末端无路可走要么遇到了分叉点 break if len(branch) min_branch_len: for by, bx in branch: skel[by, bx] 0 changed True return skel这段代码按端点沿分支走到分叉或末端的思路做剪枝。它的假设是骨架网络是正常连通图如果你的骨架本身断裂严重效果会打折得先做断点连接。另外剪枝阈值min_branch_len需要根据图像分辨率调。如果你只关心主干可以把阈值设成50甚至100像素如果你关心精细分支比如血管末梢那阈值设成5就够了。4.2 分叉问题哪些分叉该保留分叉本身不是错误。道路网、血管网天然有交叉和分叉骨架保持这些分叉恰恰是优点。问题在于那些不规则的短小分叉以及分叉点过多导致折线难以拟合。处理分叉时我的经验是先通过剪枝去掉短分叉剩下的分叉就是真实拓扑的一部分交给下游的skan库做骨架网络分析而不是自己硬删。如果你只是想要一条中心线而不是网络比如一条独立路段的中心线那就更简单先把道路掩膜切成单条道路段再分别提取骨架最终天然没有分叉。切分的时候可以用OpenCV的connectedComponentsWithStats按连通域拆开每个连通域单独做骨架化这样输出就变成一组无分叉的单线骨架后续拟合也简单得多。拆开之后每个连通域的长度、宽度、走向都能独立统计这在批量处理大量道路段时非常实用。4.3 断裂问题骨架中间为什么断了一截断裂的常见原因是掩膜本身有断裂比如原始图像里道路被树木遮挡二值化后道路掩膜断开了。预处理阶段可以用形态学闭运算把小的断裂接上再用连通域分析判断哪些区域应该连接。具体做法是先用较大的闭运算核比如11×11把掩膜缺口补上再重新骨架化。但这会带来风险——闭运算可能把原本不该连在一起的结构黏一起所以核大小需要反复试。断裂还有一种原因是骨架自身的伪影比如在交叉路口附近细化算法可能因为边界像素竞争而把一个连通域拆成几个分支。绝大多数断裂都来自输入二值图的连通性问题而不该归咎于算法本身。我先做连通域分析打印每一块掩膜的面积num_labels, labels, stats, _ cv2.connectedComponentsWithStats(clean, connectivity8) for i in range(1, num_labels): x, y, w, h, area stats[i] if area 500: clean[labels i] 0这就把面积小于500像素的小碎片清掉了。碎片清掉之后骨架的断裂情况会大幅改善。如果清完碎片仍然断裂还可以尝试一种后处理办法对骨架二值图提取所有端点如果两个端点的空间距离小于某个阈值比如20像素且方向角接近就直接用直线把这两个端点连起来。端点方向可以用局部切线近似——顺着端点往回数几个骨架点计算这些点的主方向。这个方法在道路被树冠遮挡的场景下非常实用连完线之后整条骨架就通了。5. 从像素到坐标中心线测量、拟合与GIS场景落地5.1 长度怎么算逐段累加与几何修正骨架是离散的像素点计算长度时不能简单数像素。一个像素沿水平或垂直方向代表1个单位长度沿对角线方向则代表约1.414个单位。如果直接用np.sum(skeleton)误差会非常大。正确做法是提取骨架点沿连通路径逐点用欧氏距离累加。具体来说每个骨架点只算它和下一个骨架点之间的欧氏距离然后全部加起来。在有分叉的骨架里更稳的做法是用skan库pip install skanfrom skan import Skeleton, summarize skel_obj Skeleton(skeleton) summary summarize(skel_obj) print(summary.head())skan会把骨架分解成路径path segments每个路径的length-px列直接给出按像素距离修正后的长度基本上不需要自己重写路径遍历。它还额外提供分支角度、路径平均宽度等信息对于血管网络、道路网络这类有大量分叉的数据skan比手写遍历强太多。我最初不知道这个库时自己花了一整天写DFS遍历骨架点算长度效果还经常被分叉绕晕后来换成skan三行代码解决。5.2 中心线怎么变成平滑曲线折线简化与拟合骨架是单像素线但在放大看时全是锯齿。如果后续要转成矢量数据或者算曲率需要做平滑。最常用的方法是Douglas-Peucker折线简化OpenCV里对应cv2.approxPolyDP。但approxPolyDP输入的是轮廓点集用于中心线时得先把骨架点按顺序排好。对于不具备严格顺序的骨架网络我通常先提取端点再从端点开始深度优先遍历DFS得到有序坐标序列。简化示例def skeleton_to_polyline(skel, min_dist2.0): 把单段无分叉的骨架转成简化折线 pts np.column_stack(np.where(skel 0)) # 这里只处理单段线多段线需要先分离连通域 contour pts[:, ::-1].astype(np.float32) # 转成(x, y) epsilon min_dist approx cv2.approxPolyDP(contour, epsilon, closedFalse) return approx.reshape(-1, 2)需要注意的是这条代码对无分叉的单条路径有效一旦碰到分叉网络必须先分段。实际项目中我往往先按连通域拆骨架再用skan把每个路径单独提出来最后逐路径做折线简化。简化参数epsilon代表最大允许偏移距离单位是像素。做地图制图时这个值可以设得大一些比如2~5像素让线更光滑做精密测量时设成0.5像素左右保证几何精度。5.3 骨架到地理坐标栅格坐标与世界坐标的映射在GIS场景中栅格图像通常带有地理参考六参数或仿射变换矩阵。如果图像来自GDAL可以用GeoTransform把像素行列号映射成经纬度或投影坐标from osgeo import gdal ds gdal.Open(mask.tif) gt ds.GetGeoTransform() # gt (左上角x, 像元宽度, 旋转0, 左上角y, 0, 像元高度(负值)) def pixel_to_world(col, row): x gt[0] col * gt[1] row * gt[2] y gt[3] col * gt[4] row * gt[5] return x, yskeletonize得到的是行列号经过这个映射就能得到矢量中心线坐标集合再组装成LineString写入GeoJSON或Shapefile就能进QGIS/ArcGIS了。这里有个经验骨架坐标是行列号放到GeoTransform里时row对应y方向如果搞反了导出的线会跑到完全错误的位置在图上根本看不出和原图的关系。建议写完映射函数后先拿几个已知坐标的角点做验证再批量转换。导出GeoJSON的示例import json features [] for line_id, line_coords in enumerate(paths): coords [[float(x), float(y)] for x, y in line_coords] features.append({ type: Feature, properties: {id: line_id, length_px: length_px}, geometry: {type: LineString, coordinates: coords} }) geojson {type: FeatureCollection, features: features} with open(centerline.geojson, w, encodingutf-8) as f: json.dump(geojson, f, ensure_asciiFalse)有了GeoJSON文件后续可以直接用GeoPandas读入做空间分析也可以拖进QGIS里做可视化检查和手动修正。这一步是栅格骨架和矢量业务之间的桥梁。5.4 用距离变换算局部宽度中轴定义里每个骨架点都对应一个最大内切圆半径。OpenCV的距离变换distanceTransform给的就是每个前景像素到最近背景像素的距离也就是内切圆半径的离散近似。所以在算完骨架后对同一张掩膜调用一次距离变换然后取骨架像素位置对应的距离值就能得到沿线宽度/2dist cv2.distanceTransform(clean, cv2.DIST_L2, 5) widths_on_skeleton dist[skeleton] * 2 # 直径单个像素的距离单位受图像分辨率影响如果每个像素代表0.5米那最后乘上0.5就是实际米制宽度。这一步在道路宽度估算、河道宽窄分析里非常实用。比如你有一段河流骨架线沿着骨架每10米采样一个点取对应的局部宽度画出来就是整条河的宽度变化曲线。这个信息在很多水利GIS项目里都是刚需。需要注意distanceTransform的输入clean必须是单通道8位图背景为0前景非0。距离类型DIST_L2对应欧氏距离maskSize填5精度已经足够。6. 图像大了怎么办性能优化与替代库选型6.1 大图骨架化的卡顿与分块方案skimage.skeletonize的时间复杂度不算低如果图像达到几千×几千像素跑起来有明显卡顿。最直接的办法是降采样在保证形状特征的条件下把图像缩小到长边1200像素以内骨架化完成后再用坐标放大的方式估算原始位置的骨架点。尺度变化会带来一定的位置偏差用于可视化没问题用于精密测量要小幅度缩放并做坐标补偿。如果纹理和掩膜本身是大尺寸的分块就是另一个思路。把大图切成小块每个小图单独骨架化最后拼回去。但块与块边界处会有断裂和重影。我用过的最简单方案是块与块之间加overlap重叠区比如128像素重叠骨架化之后保留重叠区中轴部分两边丢弃重叠区边缘再用形态学闭运算修一下断点。分块数量不要太多否则拼接逻辑会越来越复杂。一般来说图幅不超过1万×1万像素时直接整图骨架化加上合理采样也够用超过这个规模才考虑分块。6.2 如果不想用skimageOpenCV contrib的thinningOpenCV主包没有骨架化函数但opencv-contrib-python里有ximgproc.thinning实现的是Zhang-Suen细化。如果你不想额外引skimage可以在安装opencv时带上contrib版pip install opencv-contrib-python然后thinned cv2.ximgproc.thinning(clean, cv2.ximgproc.THINNING_ZHANGSUEN)它的输出和skimage.skeletonize非常接近但输入输出类型是uint8更贴近OpenCV的生态。区别在于skimage还有medial_axis、skeletonize_3d等一揽子方案灵活性更高。如果你整个项目都已经基于OpenCV不想再依赖太多库用contrib版更方便如果你需要做三维骨架或者其他形态学衍生功能skimage更全面。6.3 替代方案对比与选型表方案输入输出拓扑保持典型场景备注skimage.morphology.skeletonize2D bool2D bool良好血管、道路、网络默认Zhang-Suenskimage.morphology.medial_axis2D bool2D bool 距离中等中轴与宽度分析更贴近中轴cv2.ximgproc.thinning8位单通道8位单通道良好OpenCV生态内使用需要contrib包skan库skeleton输出骨架网络统计良好分支网络测量自动算长度/角度CGAL/ArcGIS 中心线矢量面矢量线较好GIS专业场景形态学之外的几何算法表格之外的另一个重要选项是geopandas shapely里对多边形做负缓冲迭代来逼近中心线但这更像矢量几何方案不在本文的图像处理链条内。如果你业务数据本身就是矢量面可以直接去研究ArcGIS的Polygon To Centerline工具或者一些开源的中轴线提取算法输出比纯栅格骨架更光滑还避免了像素化造成的锯齿。栅格骨架和矢量中轴是两条路线选择哪个取决于上下游数据形态。6.4 三维数据与更高维度的扩展skeletonize_3d可以处理三维体数据输入是3D布尔体输出是3D骨架。我在处理血管CT体数据时用过三维骨架在体素网格上保留血管树的拓扑接着用skan的Skeleton扩展支持3D路径分析。如果你做医疗影像或者材料科学里的孔道网络分析这个方向可以直接延伸。三维骨架化对内存的消耗比二维大一个量级一个512×512×512的体数据布尔数组就占128MBskeletonize_3d跑起来可能要几分钟这时候分块和降采样策略就更重要了。最后分享一点我自己的体会中心线提取这个需求90%的难度不在提取这一步而在前面二值化的质量与后面工程数据的衔接。骨架化只是一个1毫秒到几百毫秒不等的函数调用真正花时间的往往是调阈值、清噪声、断点修复、坐标映射。我踩过最大的坑就是拿到一张脏掩膜直接skeletonize结果骨架网络长得像一团乱麻后面花了两天才把毛刺清干净。现在我的固定流程是先连通域分析筛面积再开运算平滑确认掩膜质量之后才进骨架化整个过程有了预检环节返工率大大降低。你也别指望一套参数走天下——不同的图像来源、分辨率、噪声类型都会导致完全不一样的结果。建议把预处理做成可调试的小函数参数暴露出来慢慢试这样才能真正把中心线提取从玄学变成手艺。
返回列表