1. 写在前面:为什么要折腾这两个冷门接口
先说个场景。我在做一个零件尺寸检测的项目,摄像头俯拍传送带上的垫片,需要判断它是不是正圆、有没有划痕缺口、位置有没有跑偏。轮廓提取出来之后,第一反应是算面积、算周长,用最小外接圆去拟合,这几招能解决80%的问题。但真遇到形状歪歪扭扭、不好直接比对的特征时,基础几何参数就不够用了。这时候要么上形状匹配,要么用矩来描述轮廓的“整体样子”。也就在那个时间段,我认真把图像矩(Image Moments)和点多边形距离测试(Point Polygon Distance Test)这两个接口啃了一遍。
这两块内容在大部分OpenCV教程里都容易被带过:矩就是那七个数字,距离测试就是一个函数调用。但用到现在,我得说,这俩是轮廓分析里非常值得长期持有的工具。矩不仅能把轮廓压缩成一组稳定的统计特征,而且在平移、旋转、缩放变化下,部分矩还能保持不变;点多边形距离测试看起来简单,但它背后是判断点与轮廓位置关系、做碰撞检测、做轮廓内点筛选的底层逻辑,很多高级功能最终都会落到这个接口上。
这篇文章我打算按自己的学习路径来写:先讲清楚矩是个什么东西、怎么算、怎么用;再讲点多边形距离测试的原理和实际场景;最后给出一段可运行的综合示例,把两个功能串起来。读完之后,你至少能知道什么时候该用哪个接口,遇到轮廓要用特征比对时,不会只想到宽高比和面积。
2. 图像矩:用数字描述轮廓形状的底层思路
2.1 矩到底在算什么
如果你是第一次接触图像矩,可以先把它理解成“把一个二值图像或轮廓当成一堆质量均匀的点,然后算这堆点的分布特征”。学过概率统计的朋友可能马上会浮现一个画面:随机变量的矩。没错,图像里的矩和概率里的矩在数学形式上完全同构,只是把概率密度换成了像素值或者轮廓点的分布。
OpenCV里的moments()函数算的是空间矩和中心矩。空间矩的公式是:
m_ji = sum(I(x, y) * x^j * y^i)
其中I(x, y)是像素灰度,在轮廓矩的计算中,相当于轮廓区域内所有点。m00就是所有像素值的和,对二值图像来说就是区域面积;m10和m01合并起来可以算出轮廓的质心坐标:cx = m10 / m00,cy = m01 / m00。
中心矩就是把坐标系原点移到质心上再算:
mu_ji = sum(I(x, y) * (x - cx)^j * (y - cy)^i)
为什么要移到质心?因为这样算出来的矩对平移不敏感。你轮廓往左挪了一百个像素,空间矩全变了,但中心矩因为跟着质心一起走,数值不变。这一点在实际项目中特别重要,因为检测目标在图像里的位置经常是飘忽不定的。
再往上一步,归一化中心矩就是把中心矩缩放到尺度无关:
nu_ji = mu_ji / (m00^((i+j)/2 + 1))
这样做的意义是,目标放大缩小、离相机远近不同,得到的矩依然可比。
那一堆公式看着眼花,但OpenCV里一句话就全算出来了:
import cv2 mu = cv2.moments(contour)返回的字典里有m00、m10、m01、mu20、mu02、mu11、nu20、nu02、nu11等一堆键值。不需要自己写循环去累加这些值,但理解公式能帮你搞清楚返回的每个数到底在描述什么。
2.2 七个经典不变矩的含义
讲到图像矩,绕不开的就是关于七个归一化中心矩组合出的Hu矩。Hu矩通过对nu02、nu20、nu11这些归一化中心矩做组合运算,构造出七个对平移、缩放、旋转都不敏感的式子。它的出现目的很纯粹:用一组标量来代表一个轮廓的形状,然后拿这组标量去跟库里的模板比对,从而判断“这俩形状像不像”。
具体来说,Hu矩的七个数,每个数强调的形状特征不太一样。前几个矩主要反映整体的分布宽度、倾斜程度,后几个矩对细节纹理和扭曲更敏感。实际使用中,最常用的是前四个,因为后三个对噪声敏感,而且数值量级容易飘。我自己的经验是:在光照稳定、轮廓质量较好的工业场景下,七个Hu矩拿来粗筛完全可以;如果轮廓有一点断裂或者杂点,最好先做形态学处理,再用前四个矩做匹配。
OpenCV提供了现成接口:
hu = cv2.HuMoments(cv2.moments(contour)).flatten()算出来是一维数组,长度是7。很多入门项目直接用欧式距离去算两组Hu矩的差异,这个方法能用,但效果不稳定。因为不同阶矩的量级差很大,hu[0]可能是10的负二次方级别,hu[3]可能到了负五次方级别,直接算欧式距离的话,高阶矩几乎不参与决策。稳妥的做法是先把每个值取对数再比较:
import numpy as np def compare_hu(hu1, hu2): log_hu1 = -np.sign(hu1) * np.log10(np.abs(hu1) + 1e-10) log_hu2 = -np.sign(hu2) * np.log10(np.abs(hu2) + 1e-10) return np.sqrt(np.sum((log_hu1 - log_hu2) ** 2))这里加1e-10是为了防止出现0值导致log10爆掉。取负号是为了让数值大的矩在对数域里也别把数值小的矩完全盖过去。
2.3 矩的实际应用:从质心定位到形状匹配
矩在工程里最经典也最被我依赖的用途之一,就是计算质心。以前我用findContours提取轮廓之后,想找目标中心点,第一反应是cv2.minEnclosingCircle或者cv2.boundingRect然后取中点。但这两个方法对异形轮廓都不准:外接圆会被长条状的轮廓拉偏,外接矩形也会因为轮廓旋转导致中心点偏移。用矩算质心,是真正按照轮廓的像素分布来求“重心”,不管轮廓多不规则,结果都跟人对该形状中心的直觉感受基本一致。
另一个重要用途是形状匹配。OpenCV里有个接口专门干这事:
ret = cv2.matchShapes(contour1, contour2, cv2.CONTOURS_MATCH_I2, 0)参数中的I1、I2、I3对应三种不同的距离公式,内部都是基于Hu矩的某种归一化比较。返回值是一个浮点数,越接近0说明两个轮廓越相似。我在实际测试里发现,matchShapes对缩放相对稳定,对轮廓的轻微残缺也还算宽容,但它对旋转的鲁棒性并不是所有场景都够用。如果目标会发生90度倍数旋转,比如矩形垫片横放和竖放,matchShapes依然能匹配上,因为Hu矩对旋转是敏感的(能区分方向和角度),但对90度这类绝对角度变化仍有稳定性;如果目标是任意角度旋转,还是建议在使用前把轮廓方向归一化,或者直接用轮廓的旋转外接矩形角度去校正。
如果想把矩用在更复杂的场合,比如把一组轮廓的特征向量喂给分类器,建议把标准轮廓的Hu矩、面积、周长、宽高比、圆形度这些特征拼在一起,形成一个特征向量。矩的特征表达力很强,但单靠七个数去区分细微差异依然不够,特征组合才是工程正道。
3. 点多边形距离测试:一个被低估的几何利器
3.1 这个函数到底能干什么
cv2.pointPolygonTest的接口极短:
ret = cv2.pointPolygonTest(contour, (px, py), measureDist)参数只有三个:轮廓点集、一个点坐标、以及measureDist是否返回距离值。如果measureDist设为False,返回值只有三个可能:1表示点在轮廓内部,0表示在轮廓上,-1表示在轮廓外部。如果设为True,返回值就是带符号的距离值:点在轮廓内部为正,外部为负,轮廓上为零。
这个函数字面意思是“测试一个点在多边形内部还是外部”,但因为OpenCV的轮廓本质上也是用一个个点连成的多边形,所以它可以直接用于判断任意轮廓与点的位置关系。我第一次写碰撞检测代码时,用了一段复杂的射线法去实现点是否在轮廓内,写完一大段之后才想起来有这么个现成接口,直接把那两百行代码换成了三行调用。所以先记住结论:任何需要判断“点是否位于不规则形状内部”的场景,第一优先级就是这个函数。
3.2 背后的判定思路
先说内部测试。OpenCV内部用射线法来判断点在多边形里:从该点朝一个固定方向发出一条射线,统计和多边形边的交点个数。如果交点数是奇数,说明点在多边形内部;如果是偶数,则在外部。这个原理说起来简单,但实现起来有边界情况要处理,比如点刚好落在线段延长线上、射线刚好穿过顶点、点就在边上的情况。OpenCV把这些都封装好了,不需要自己处理那些头疼的边界case。
再把measureDist设为True,函数还会额外返回点与轮廓的最短距离。这里计算方式是把点到轮廓所有线段的最短距离都算一遍,然后取最小,同时根据内外符号给出正负。注意,它返回的是到轮廓线段的最短距离,不是到质心的距离,也不是到某个采样点的距离。这个“距离”在很多几何处理中是很有用的:可以做轮廓外扩、可以评估一个点离目标区域有多远、可以做边缘到点的间距测量,而这些用法在视觉测量项目里都是高频操作。
3.3 最实用的几个场景
第一个场景是抓取点的避让判断。我做机械臂抓取的时候,会预先标定一个环形禁止区域,目标点落到这个区域就不执行抓取。以前的写法是用多个矩形的交集去拟合这个环形区域,又啰嗦又容易漏判。后来直接把环形区域的外轮廓存下来,用pointPolygonTest去判断,一条语句搞定,而且精度更高,区域边界的形状任意。
第二个场景是轮廓外扩和距离测量。假设你提取了一个硅胶密封圈的轮廓,要判断某个喷胶点位是否离边缘太近,如果太近胶水会溢出,太远又覆盖不到。用pointPolygonTest(contour, point, True),正数就是点到轮廓的最小距离,如果值小于你设定的阈值,就可以直接判定不合格。这个思路用于边缘距离质检非常顺手。
第三个场景是动态ROI裁剪。有时候你只需要处理轮廓内部的图像区域,可以用pointPolygonTest遍历mask中每个点,标记出内外关系,然后生成一个精确的轮廓内掩膜。虽然也可以用cv2.fillPoly直接生成掩膜,但如果你已经有一个轮廓数组,用距离测试的方式可以顺便拿到每个点到边界的距离,后续做像素值的距离加权统计时,这一份距离信息就很有价值。
3.4 性能注意事项
pointPolygonTest的时间复杂度不算特别低,尤其是当轮廓点数特别多、待测点也特别多的时候。如果你要对整幅图像的每个像素做判断,不建议一层层调用这个函数,那样会非常慢。比较高效的做法是:先用cv2.boundingRect把轮廓的外接矩形取出来,只在外接矩形范围内做判断,这样能跳过大量背景点,速度能快一个数量级。如果轮廓是带孔的,比如一个圆环区域,单靠一个轮廓点集是判断不了孔内区域的,这时候要么把内外轮廓分别提取出来,做两次判断,要么先构建一个掩膜图像,直接在掩膜上做像素级判断。
4. 代码实操:从轮廓提取到矩与距离测试的综合示例
4.1 环境准备与材料说明
我用的是OpenCV 4.x + Python 3.8,图像是一张带有一个不规则白色目标区域的模拟图。下面代码的目标是:提取目标轮廓,计算其面积、质心、Hu矩;再选取若干测试点,通过pointPolygonTest判断它们与轮廓的位置关系,并输出具体距离。
先贴出整体代码:
import cv2 import numpy as np # 生成一张灰度图,画一个不规则五边形区域 img = np.zeros((400, 400), dtype=np.uint8) pts = np.array([[120, 80], [300, 60], [360, 220], [260, 330], [90, 260]], dtype=np.int32) cv2.fillPoly(img, [pts], 255) # 二值化并提取轮廓 _, binary = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY) contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contour = contours[0] # 计算矩 m = cv2.moments(contour) area = m['m00'] cx = m['m10'] / m['m00'] cy = m['m01'] / m['m00'] hu = cv2.HuMoments(m).flatten() print("轮廓面积: ", area) print("质心: ({:.2f}, {:.2f})".format(cx, cy)) print("Hu矩: ", hu) # 点多边形距离测试 test_points = [(200, 200), (50, 50), (100, 100)] for px, py in test_points: inside_val = cv2.pointPolygonTest(contour, (px, py), False) dist_val = cv2.pointPolygonTest(contour, (px, py), True) print("点 ({}, {}): 内部标记={}, 距离={:.2f}".format(px, py, inside_val, dist_val))运行这部分代码,你会看到一个比较完整的输出:面积大约是几万像素级别,质心在五边形内部偏中,Hu矩第一项数值在较小数量级。测试点里,(200, 200)预计在轮廓内部,距离是正的;(50, 50)在外部,距离为负;(100, 100)可能在边界附近,需要实际数据确认。
4.2 轮廓提取的细节问题
这个例子用了fillPoly直接生成一个实心区域,所以提取轮廓时,直接用RETR_EXTERNAL只取最外层轮廓即可。但实际项目中的图像往往有噪声,不是这么干净的。常见的处理流程是:滤波->二值化->形态学开运算->findContours。开运算可以去掉二值图中细小的白色噪点,也能断开一些因为噪声粘连在一起的区域。如果不去除噪点,提取出来的轮廓可能是一堆小碎片,后续算矩、算距离都容易跑偏。
另外一个小细节是CHAIN_APPROX_SIMPLE。这个参数会把轮廓线在水平、垂直、对角线方向上的冗余点压缩掉,只保留端点。这样轮廓数组点数会少很多,算pointPolygonTest时性能会好。如果你需要轮廓的密集点来做高精度距离计算,可以改用CHAIN_APPROX_NONE,但点数多、计算代价高,大部分场景不必要。
4.3 矩的计算细节与数值注意事项
用moments()计算时,返回的字典键里有m00、m10、m01、m20、m02、m11等,以及对应的中心矩和归一化中心矩。在Python里访问时要注意KeyError,因为OpenCV的moments()返回的是dict-like对象,键名很规律,但如果你直接访问mu20,要确保轮廓非空。
轮廓为空时,m00为零,此时计算cx = m10 / m00就会直接抛异常或者得到nan。我在代码里通常会加一个空轮廓判断:
if cv2.contourArea(contour) == 0: print("空轮廓,跳过") continue面积为零的轮廓,不仅会导致矩计算无意义,还会让pointPolygonTest行为异常。这类脏数据在做批处理时非常常见,值得花两行代码做好防护。
4.4 距离测试的语义验证
把measureDist设为True时,返回值的绝对单位是像素,符号表示内外关系。这意味着你可以把“点到轮廓的最短距离”当成一种度量值来用,比如判断点是否在轮廓的“安全缓冲带”内。假如外扩安全距离是10像素,那么只要dist_val > 10,就认为点在安全区内部。
这里还有一个很容易踩的坑:轮廓的坐标类型。pointPolygonTest要求轮廓是整数坐标点集,如果之前对轮廓做了缩放、仿射变换,坐标可能变成浮点数,直接传入会报错或者结果不准确。遇到这种情况,需要先用np.round再转回int32:
contour_int = np.round(contour_scaled).astype(np.int32) ret = cv2.pointPolygonTest(contour_int, (px, py), True)我自己就因为在透视变换之后忘了转整数,结果距离判断全部偏了一两个像素,对高精度测量场景来说已经足够致命了。
5. 常见问题与排查实录:这几类坑我基本都踩过
5.1 问题一:Hu矩匹配结果不理想
某次做模具字符比对,同一个字符因为拍摄角度和位置不同,Hu矩的匹配分数总是忽高忽低,导致误检率极高。排查后发现问题出在二值化阶段,光照变化导致字符底部的白色区域出现了断裂,轮廓形状已经变了,矩自然跟着变。
解决思路是加强预处理:用高斯滤波之后,再做一次自适应阈值,而不是固定阈值。这样二值化结果更稳定。另外,做匹配前先把轮廓按面积过滤掉太小的碎块。改完这两处之后,匹配分数的波动明显变小。
还有一次是比对不同规格的垫片,尺寸差很多,直接用matchShapes也匹配得不好。后来我把轮廓先resize到固定大小再算Hu矩,效果才稳定下来。因为Hu矩虽然理论上对缩放不敏感,但实际中二值化后的轮廓边缘锯齿会引入量化误差,缩放差异越大,误差越明显。
5.2 问题二:pointPolygonTest结果全员都是-1
有朋友问过我,为什么他的轮廓明明很大,但随机点测出来全部都在外部。后来查了下代码,发现他把轮廓坐标当成浮点数传进来,而findContours返回的是int32,他做了一次轮廓平滑处理,变成了float32,然后没转回去。OpenCV的pointPolygonTest内部对float坐标的处理可能不是预期行为,导致判定失败。
解决方案很简单:转回int32。另外,也遇到过轮廓点顺序混乱的情况,某些提取逻辑会把轮廓点打乱,导致多边形变成自交的怪异形状,距离测试也就没有意义了。这时候可以先用cv2.convexHull(contour)看看凸包形状是否正常,或者用cv2.approxPolyDP重新拟合轮廓。
5.3 问题三:性能太慢,批量判断卡到无法接受
一次要对整幅图的二值目标做边缘距离检测,图像是2000x2000,待检点有几十万个。直接逐点调用pointPolygonTest,一帧要好几秒,完全没法实时。
最后优化方案分三步:
- 先在外接矩形内建立网格索引,跳过背景区域;
- 对轮廓做多边形逼近,用cv2.approxPolyDP把轮廓点数从几百个压缩到几十个,距离计算量大幅下降;
- 如果允许,把距离值预先算到一张距离变换图上,后续逐点查询变成查表,速度提升非常明显。
第三步里的cv2.distanceTransform是一个物美价廉的替代方案,它可以直接生成每个像素到最近轮廓边的距离图,而且速度极快。特别适合“批量计算大量点到轮廓的最短距离”的场景。唯一的缺点是它需要先生成一张掩膜图,不能直接输入轮廓点集。
5.4 问题四:矩的值相差好几个数量级,比对时合不合理
Hu矩的七个数里,第一个和后面几个可能差好几个数量级。很多人直接算np.linalg.norm(hu1 - hu2)当相似度,结果数值几乎完全被第一个吃掉,后几个等于白算。前面说过对数变换,这是最常用的修正方案。此外,也可以把每个值做归一化到0到1之间再组合。但要注意,不同的归一化方法对最终匹配效果影响很大,最好用自己业务的真实数据做AB测试,不要盲从一个固定的“最优方案”。
5.5 问题五:轮廓带孔洞,距离测试在孔洞区域失效
pointPolygonTest是单轮廓判断的,如果轮廓是外轮廓,它不会知道内部还有一个孔洞。假如要判断点是否落在“环形区域”里,就需要同时拿到外轮廓和孔洞轮廓。先用pointPolygonTest判断点在外轮廓内部,再用同一个函数判断点在孔洞轮廓的外部,两个条件同时满足才算在环形区域内。
OpenCV里findContours配合RETR_CCOMP或者RETR_TREE会返回层级关系,可以从hierarchy里找出孔洞所属的外轮廓索引,再做嵌套判断。这个方法虽然多了一次函数调用,但逻辑直观、不容易出错。
6. 把两项技术组合起来的三个经验场景
6.1 异形工件定位:先用矩求质心,再用距离测试筛选点位
我之前处理过一个异形塑料件的视觉定位项目,工件形状不规则,上面有若干个需要打胶的点位。系统要做的事有两件:确定工件的中心基准,以及判断计划打胶的点位是否落在工件有效区域内。
第一步用moments算质心,得到一个稳定的中心基准点。第二步把预设点位逐个用pointPolygonTest(contour, (px, py), True)判断,距离为负则说明点位在轮廓外,直接报警提示点位偏出。这个方案在测试中表现非常稳定,处理速度也快,因为每次只需要对几个点做距离测试,成本极低。
6.2 轮廓粗分类:把Hu矩做成特征向量的实践套路
做形状分类时,我习惯把特征分成三个组:基础几何特征、矩特征、轮廓分布特征。基础几何特征包括面积、周长、圆形度(4piarea/perimeter^2)、宽高比、凸包占比。矩特征就取Hu的七个值。轮廓分布特征可以取轮廓点到质心距离的均值、方差。把这些拼接成一个特征向量,喂给分类器。这个组合方式,在形状差异比较明显的场景下准确率基本都能做到比较理想。
矩特征里要注意业务含义的一致。比如圆形度是圆的特征,如果你分类的都是矩形件,这个特征区分度就小,但是加上Hu矩之后,形状的差别会被放大。我的经验是:宽高比和圆形度适合做粗筛,Hu矩适合做细比对,两者互补而不是替代。
6.3 mask局部处理:用pointPolygonTest生成轮廓内掩膜
有时候需要针对轮廓内部区域做处理,我试过直接用cv2.fillPoly生成掩膜,简单粗暴。但更精细的需求是:只保留离边界一定距离的内部点。比如检测一个柔性电路板上的线路,希望忽略靠近边缘的干扰信号,只统计中心区域的信号特征。这时候可以在遍历像素时用pointPolygonTest拿到每个候选点到边界的距离,然后按距离阈值做一个加权mask。
这种做法的好处是掩膜带有距离梯度,不只是二值的0和1,而是每个像素都携带与边界的距离信息,后续做信号加权、形态分析都更方便。代价是计算量偏大,所以尽量只在ROI范围内操作,不要全图遍历。
7. 最后再说一点个人体会
这部分内容在官方文档里其实只占很小篇幅,但实际用起来,能延伸出的东西特别多。图像矩不仅是轮廓分析工具,更是一种把几何形状数学化的思路;点多边形距离测试则是一个高度封装、边界情况处理完善的几何判断工具。很多初学者觉得它们不如边缘检测、特征点匹配那样“看得见效果”,但恰恰是这些不起眼的基础接口,在真实的工程问题里帮我省掉过大量自造轮子的时间。
如果这篇文章对你的OpenCV学习有一点帮助,那么我建议你至少亲手跑一遍综合示例,改一改轮廓形状和测试点坐标,看看矩和距离值的变化规律。这个过程比单纯读十篇笔记更能加深理解。另外,如果你在实践里发现了其他有趣的用法,欢迎回来一起交流。