1. 从“像素堆”到“可理解对象”:表示与描述到底在解决什么问题
很多人学数字图像处理,前面几章做滤波、边缘检测、阈值分割,跑出来的结果是一张二值图或者一堆边缘点,看起来挺像那么回事。但一旦要回答“图里有几个目标”“每个目标是什么形状”“这两个目标是不是同一个东西”,就卡住了。原因很简单:分割只是把像素分成了“前景”和“背景”,但计算机并不知道前景里那一坨像素到底代表什么。表示和描述这一章,干的就是把“一坨像素”变成“一个可以被算法比较、匹配、识别的对象”这件事。
我习惯把这一章理解成图像处理流水线里的“翻译层”。前面几章是“把图变干净”,这一章是“把干净的东西说清楚”。说清楚分两步走:第一步叫表示,解决“用什么数据结构来存这个目标”;第二步叫描述,解决“用什么特征向量来刻画这个目标”。表示是骨架,描述是血肉,两者缺一不可。你只做表示不做描述,那目标就只是一串坐标,没法比较;你只做描述不做表示,那特征没有依附的载体,算出来的数也不知道对应哪个区域。
这一章适合谁看?如果你正在做缺陷检测、字符识别、细胞计数、零件分拣、遥感地块提取这类任务,那这一章就是你的核心工具箱。如果你只是做图像增强、去噪、超分,那这一章可能暂时用不上,但一旦你的任务从“改善图像”变成“理解图像”,表示和描述就是绕不过去的坎。我见过太多项目,分割做得漂漂亮亮,最后卡在“怎么判断这个缺陷是划痕还是凹坑”上,根子就在这一章没吃透。
提示:这一章的内容和“图像分割”“形态学处理”是强耦合的。如果你的分割结果本身有毛刺、有孔洞、有粘连,后面表示和描述做得再精细也是白搭。先把分割质量稳住,再来谈特征提取。
2. 表示方法选型:链码、多边形、骨架,到底该用哪个
2.1 链码表示:最轻量的边界编码方案
链码的核心思想特别朴素:既然边界是一串相邻的像素点,那我从某个起点出发,每一步记录“往哪个方向走”,就能把整条边界压缩成一串方向数字。四方向链码用0到3表示右、下、左、上,八方向链码用0到7表示东、东南、南、西南、西、西北、北、东北。举个例子,一条从(0,0)走到(2,0)再走到(2,2)的边界,八方向链码就是0,0,2,2,四个数字搞定,比存六个坐标点省事得多。
链码最大的好处是平移不变。你把目标整体挪十个像素,链码一个数字都不变,因为方向没变。但它对旋转和缩放敏感,目标转45度,链码全乱。所以实际用的时候,通常要做归一化:旋转归一化就是找链码的循环最小值,缩放归一化就是按目标周长重新采样。我实测下来,链码适合做形状检索的粗筛,速度快、存储小,但精细匹配还得靠后面的描述子。
# 八方向链码的简单实现思路 # 方向编码:0=东, 1=东北, 2=北, 3=西北, 4=西, 5=西南, 6=南, 7=东南 directions = [(1,0), (1,1), (0,1), (-1,1), (-1,0), (-1,-1), (0,-1), (1,-1)] def boundary_chain_code(boundary_points): code = [] for i in range(len(boundary_points)-1): dx = boundary_points[i+1][0] - boundary_points[i][0] dy = boundary_points[i+1][1] - boundary_points[i][1] code.append(directions.index((dx, dy))) return code注意:链码的起点选择会影响编码结果。做匹配之前一定要做起点归一化,否则同一个形状因为起点不同会得到完全不同的链码序列。我一般用“链码循环移位后字典序最小”作为归一化标准。
2.2 多边形逼近:用最少的顶点保住形状
链码是逐像素走的,粒度太细,噪声一多链码就抖得厉害。多边形逼近的思路是:我用少量直线段去拟合边界,只要拟合误差在容忍范围内,就用这些线段的端点来代表整个边界。最经典的算法是Douglas-Peucker算法,思路是递归找离当前直线最远的点,如果距离超过阈值就保留这个点,把线段一分为二,继续递归。
这个方法的参数就一个:逼近精度阈值。阈值设大了,顶点少但形状失真;阈值设小了,顶点多但保留了细节。我的经验是,对于尺寸在200×200左右的目标,阈值取2到3个像素比较稳。如果你做的是工业零件匹配,阈值可以再小一点,因为零件边缘本身就很规整;如果你做的是自然场景分割,阈值可以放宽,反正边缘本来就不规则。
多边形逼近的好处是抗噪,因为噪声引起的微小凸起会被阈值过滤掉。而且顶点序列可以直接当特征用,比如计算相邻边的夹角、边长比,这些对旋转和缩放都有一定的鲁棒性。
2.3 骨架表示:把“粗”目标压成“细”线条
骨架,也叫中轴变换,思路是:我把一个有一定宽度的区域,反复腐蚀直到只剩一条单像素宽的线,这条线就是骨架。骨架保留了目标的拓扑结构——有几个分支、有没有环、端点在哪——但丢掉了宽度信息。所以骨架特别适合做字符识别和血管提取这类任务,因为你关心的是“笔画怎么走”,而不是“笔画有多粗”。
骨架的坑在于毛刺。由于边界的不规则,腐蚀到最后经常会在骨架末端长出很多小分支,这些分支不是真实结构,是噪声引起的。处理办法是在骨架化之前先做平滑,或者在骨架化之后做剪枝,把长度小于某个阈值的分支砍掉。剪枝阈值一般取目标主体长度的5%到10%,具体看你的目标形态。
| 表示方法 | 存储开销 | 平移不变 | 旋转不变 | 抗噪性 | 适用场景 |
|---|---|---|---|---|---|
| 链码 | 低 | 是 | 否 | 弱 | 形状检索粗筛 |
| 多边形逼近 | 中 | 是 | 部分 | 强 | 工业零件匹配 |
| 骨架 | 中 | 是 | 否 | 中 | 字符、血管、笔画 |
| 边界跟踪点列 | 高 | 是 | 否 | 弱 | 通用边界分析 |
3. 描述子实战:从边界特征到区域特征的完整拆解
3.1 边界描述子:周长、直径、偏心率怎么算才靠谱
边界描述子是最直观的一类特征。周长就是边界像素的个数,但这里有个坑:八方向链码里,水平垂直走一步算1,对角走一步算根号2,如果你直接数像素个数,周长会被高估。正确做法是按链码方向加权求和。直径是边界上任意两点间的最大距离,暴力算法是O(n²),但实际用的时候可以先用凸包把边界点压缩,再在凸包顶点上算,能快一个数量级。
偏心率描述的是边界的“细长程度”,通常用边界拟合椭圆的短轴和长轴之比来算。偏心率接近1说明形状接近圆,接近0说明形状很扁。这个特征在细胞识别里特别有用,因为正常细胞和病变细胞的偏心率分布有明显差异。我试过在同一个数据集上,光靠偏心率一个特征就能把两类细胞分开到85%的准确率,加上周长和面积后能到93%。
3.2 区域描述子:面积、紧凑度、不变矩的工程细节
区域描述子比边界描述子更稳,因为它用的是整个区域的像素统计,对边界噪声不敏感。面积就是区域内的像素总数,这个没什么好说的,但要注意:如果你的区域是用多边形逼近表示的,面积要用鞋带公式算,不能数像素。
紧凑度的定义是周长平方除以面积,这个值对圆是最小的,形状越复杂值越大。紧凑度的好处是无量纲,不受缩放影响。一个圆不管放大多少倍,紧凑度都是4π。我经常用紧凑度做第一道过滤:如果紧凑度超过某个阈值,直接判定为“非目标”,省得后面算复杂特征。
不变矩是这一章的重头戏。Hu在1962年提出的七个不变矩,对平移、旋转、缩放都保持不变,是形状匹配的经典工具。但实际用的时候有几个坑:第一,不变矩的数值范围跨度极大,直接拿来做距离计算会被大数值主导,必须先做对数变换;第二,不变矩对噪声敏感,尤其是高阶矩,所以一般只用前四个;第三,不变矩描述的是全局形状,对局部形变不敏感,所以做精细匹配时还得配合其他特征。
import numpy as np import cv2 def hu_moments_features(binary_image): moments = cv2.moments(binary_image, binaryImage=True) hu = cv2.HuMoments(moments).flatten() # 对数变换,压缩数值范围 hu_log = -np.sign(hu) * np.log10(np.abs(hu) + 1e-30) return hu_log提示:Hu不变矩的第七个矩在有些实现里符号不稳定,做匹配时建议只用前六个。另外,不变矩是在连续域推导的,离散图像上只有近似不变性,别指望它能做到完全不变。
3.3 纹理描述子:当形状特征不够用时怎么办
有些目标,形状差不多但纹理不一样,比如布匹上的不同织法、金属表面的不同处理工艺。这时候光靠边界和区域描述子分不开,得上纹理描述子。最常用的三个:灰度共生矩阵、局部二值模式、Gabor滤波响应。
灰度共生矩阵的思路是统计“相隔某个距离的两个像素,灰度分别是i和j的概率”,从这个矩阵里可以算出对比度、相关性、能量、同质性四个指标。局部二值模式更简单,对每个像素,比较它和周围八个像素的大小,大于等于记1,小于记0,得到一个八位二进制数,这个数的直方图就是纹理特征。Gabor滤波则是用一组不同方向和频率的滤波器去卷图像,响应强度就是纹理特征。
我的经验是:灰度共生矩阵适合周期性纹理,比如织物、网格;局部二值模式适合微观纹理,比如粗糙度、颗粒感;Gabor适合方向性纹理,比如木纹、指纹。选哪个取决于你的目标纹理是什么类型,没有万能方案。
4. 主成分分析降维:特征太多反而不好用
4.1 为什么需要降维
你辛辛苦苦提取了二十个特征,结果发现分类器训练不动,或者训练出来了但在测试集上表现很差。这大概率是维度灾难:特征太多,样本相对太少,分类器在高维空间里找不到可靠的决策边界。而且很多特征之间是相关的,比如周长和面积高度相关,偏心率和不变矩也有相关性,冗余特征不仅不提供新信息,还会引入噪声。
主成分分析的做法是:把原始特征投影到一组新的正交基上,让投影后的第一个维度方差最大,第二个维度方差次大,以此类推。你保留前几个主成分,就能用很少的维度保住大部分信息。我一般看累计方差贡献率,达到90%到95%就截断。
4.2 实操中的参数选择与踩坑记录
主成分分析的参数就一个:保留几个主成分。但这个参数不能拍脑袋定。我的做法是画碎石图,看方差贡献率的下降曲线,拐点在哪就取到哪。另一个坑是标准化:如果你的特征量纲不一样,比如面积是几千,偏心率是零点几,不做标准化的话,面积会主导主成分方向,偏心率的信息就被淹没了。所以做PCA之前一定要做z-score标准化。
还有一个容易被忽略的点:PCA是在训练集上拟合的,测试集必须用同样的均值和标准差做变换,不能重新拟合。我见过有人把训练集和测试集拼在一起做PCA,然后说效果特别好,那是数据泄露,实际部署的时候性能会崩。
| 特征数量 | 样本数量 | 建议保留主成分数 | 累计方差贡献率 |
|---|---|---|---|
| 10 | 200 | 4-6 | 90% |
| 20 | 500 | 6-10 | 92% |
| 50 | 1000 | 10-15 | 95% |
5. 关系描述:当目标不止一个时怎么组织信息
5.1 字符串描述与树结构描述
前面讲的都是单个目标的表示和描述。但实际场景里,一张图里往往有多个目标,而且目标之间有空间关系。比如“A在B的左边”“C在D的上面”“E被F包围”。这些关系怎么编码?最常用的两种结构是字符串和树。
字符串描述适合线性排列的目标,比如一行文字、一列零件。你把每个目标的特征按顺序排成一串,就得到了字符串描述。匹配的时候用字符串编辑距离,能容忍目标缺失和顺序微调。树结构适合层次关系,比如一个电路板上有多个芯片,每个芯片有多个引脚,引脚和芯片是父子关系,芯片和电路板也是父子关系。树匹配用子树同构算法,能处理嵌套结构。
5.2 关系描述的适用边界与性能权衡
关系描述的好处是表达能力强,能编码复杂的空间约束。但代价是匹配复杂度高。字符串匹配是O(n²),树匹配是指数级的,目标一多就跑不动。所以实际用的时候,我一般先用单目标特征做粗筛,把候选目标压到几个,再用关系描述做精匹配。这样既保住了表达能力,又控制了计算量。
另一个坑是关系定义的模糊性。“左边”到底是指目标中心点的左边,还是目标最右像素的左边?不同定义会导致完全不同的匹配结果。我的做法是在项目文档里把每个关系的定义写死,比如“A的中心点x坐标小于B的中心点x坐标,则称A在B左边”,避免歧义。
6. 常见问题与排查技巧实录
6.1 特征提取结果不稳定怎么办
这是最常见的问题:同一类目标,不同样本提取出来的特征值波动很大。排查思路分三步走。第一步,检查分割质量,把二值图叠加到原图上,看边界是不是贴合真实边缘。如果分割本身就不稳,后面特征肯定不稳。第二步,检查特征计算实现,特别是周长和面积这种基础特征,用已知形状(比如一个标准圆)验证一下数值对不对。第三步,检查归一化,如果你的目标尺寸差异很大,不做尺度归一化的话,面积特征会直接反映尺寸而不是形状。
6.2 不变矩数值溢出或精度丢失
Hu不变矩的高阶矩在计算过程中会出现很大的中间值,float64都可能溢出。解决办法有两个:一是用对数域计算,把乘法变成加法;二是先对图像做归一化,把像素坐标缩放到0到1之间。另外,不变矩的第七个矩在离散情况下符号不稳定,做匹配时建议只用前六个。
6.3 主成分分析后分类效果反而变差
这通常是因为保留的主成分太少,把有判别力的信息丢掉了。PCA是无监督的,它保留的是方差最大的方向,但方差大不等于判别力强。如果两类目标的差异正好在方差小的方向上,PCA会把这个方向丢掉。解决办法是改用线性判别分析,它是有监督的,会主动找类间差异最大的方向。或者干脆不降维,改用带正则化的分类器。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 特征值波动大 | 分割不稳 | 叠加二值图看边界 | 改进分割算法 |
| 不变矩溢出 | 高阶矩数值大 | 打印中间值 | 对数域计算 |
| PCA后效果差 | 丢失判别信息 | 对比降维前后 | 改用LDA |
| 匹配速度慢 | 特征维度高 | 统计计算耗时 | 先粗筛再精匹配 |
| 旋转后匹配失败 | 特征不具旋转不变性 | 旋转测试 | 用不变矩或归一化 |
提示:做形状匹配时,我习惯先用紧凑度和偏心率做第一道过滤,这两个特征计算快、鲁棒性强,能过滤掉大部分明显不匹配的候选。剩下的再用不变矩做精细匹配,这样整体速度能提升三到五倍。
7. 一个完整的形状匹配流程:从分割到识别
7.1 流程设计与参数配置
假设你要做一个零件分拣系统,传送带上随机摆放几种零件,相机拍图后要判断每个零件属于哪一类。完整流程是这样的:第一步,灰度化加高斯滤波,去掉传感器噪声;第二步,Otsu阈值分割,把零件从背景里分出来;第三步,形态学开闭运算,去掉毛刺和填平小孔;第四步,连通域分析,给每个零件分配独立标签;第五步,对每个连通域提取边界,计算链码和多边形逼近;第六步,计算区域特征,包括面积、紧凑度、偏心率、前六个Hu不变矩;第七步,特征标准化后做PCA降维到五维;第八步,用最近邻分类器匹配到已知类别。
参数配置上,高斯核取5×5,sigma取1.5;Otsu分割后做一次3×3的开运算;多边形逼近阈值取2.5像素;PCA保留累计方差95%的主成分。这套参数在我做过的几个零件分拣项目里都跑得比较稳,但具体数值还得根据你的图像分辨率和零件尺寸微调。
7.2 效果验证与迭代优化
验证的时候不能只看准确率,还要看混淆矩阵。如果某两类零件经常互相误判,说明它们的特征空间有重叠,需要加新特征或者换分类器。我遇到过一个案例,两种螺丝外形几乎一样,只有螺纹间距不同,形状特征完全分不开,最后加了纹理特征才解决。所以特征提取不是一劳永逸的,得根据混淆矩阵反馈来迭代。
迭代的方向有三个:一是加特征,比如加纹理、加颜色;二是换分类器,从最近邻换成支持向量机或者随机森林;三是改分割,有时候误判的根源在分割阶段,两个零件粘在一起被当成一个目标了。我的经验是,先排查分割,再排查特征,最后才动分类器,因为前两者的影响更大。
8. 工程落地中的性能优化与经验沉淀
8.1 计算加速的几种实用手段
链码和多边形逼近的计算量都不大,真正耗时的是不变矩和PCA。不变矩的加速可以用积分图,把矩的计算从O(n²)降到O(1)。PCA的加速可以用随机化SVD,在样本量大的时候比标准SVD快很多。另外,如果你的目标是实时处理,建议把特征提取写成C++扩展或者用CUDA加速,Python循环算矩确实慢。
还有一个容易被忽略的点:边界跟踪的起点选择。如果每次跟踪都从最左上角的边界点开始,链码的起点就是固定的,省去了起点归一化的步骤。这个技巧在批量处理时能省不少时间。
8.2 从项目里攒下来的几条硬经验
第一条,特征不是越多越好。我见过有人提取了五十个特征,结果分类器过拟合严重,测试集准确率还不如只用五个特征。特征选择比特征提取更重要。
第二条,归一化要统一。训练集和测试集必须用同一套归一化参数,这个参数要在训练集上算,然后存下来给测试集用。我习惯把归一化参数和分类器模型一起序列化保存,部署的时候直接加载。
第三条,可视化调试。把提取到的边界、多边形、骨架画到原图上,肉眼看一下对不对。很多bug肉眼一看就发现了,比看代码快得多。
第四条,留出验证集。不要用测试集调参数,否则你调出来的参数只对测试集好,换个数据集就崩。我一般按6:2:2划分训练、验证、测试,验证集用来调参,测试集只在最后跑一次。
这个内容后续还可以这样扩展:把表示和描述跟深度学习结合起来,用卷积网络自动学特征,替代手工设计的描述子。但手工特征在样本少、可解释性要求高的场景下依然有优势,两者不是替代关系,是互补关系。