☰
OpenCV图像矩与点多边形距离测试:轮廓分析两大利器实战
2026/10/15 7:01:52 网站建设 项目流程

做图像处理做得久了,你会慢慢发现一个规律:很多看起来复杂的功能,底层往往都是几个数学上并不花哨的基础算子。图像矩和点多边形距离测试,就是OpenCV里两个很典型的基础工具。单独拎出来,公式不难、代码几行;可真到了做轮廓分析、ROI判断、形状匹配项目的时候,这两个玩意儿实用性极高,而且经常需要组合着用。

这篇是我OpenCV学习笔记的第十二篇。我不打算按官方文档的目录把函数全部抄一遍,而是围绕两件事来写:图像矩到底能帮你干哪些活,点多边形距离测试该怎么用、什么时候用。我会把原理、参数、代码和我在实践中踩到的坑都放进来。适合刚学会找轮廓、准备进一步做特征分析的朋友,也适合已经写过一些检测代码、但对原理细节还不太清楚的读者。

1. 图像矩到底是什么,为什么轮廓分析绕不开它

1.1 一句话解释:把轮廓当成一个有质量的平板

矩这个概念,最早是从物理和统计里借过来的。学过概率论的话,你对“期望”“方差”应该不陌生,图像的矩其实做的事情差不多:把图像里每个像素的灰度值当成“质量”,然后统计这些质量在空间上的分布情况。

如果你做过二值图分析,把前景像素都当成是均匀密度的纸板,那么一个目标轮廓内部就是一块形状不规则的薄板。图像矩就是在回答这类问题:这块薄板的重心在哪里?它是扁的还是圆的?朝哪个方向伸展?形状大概接近三角形还是接近圆?这些问题,全部可以统一用矩来计算。

正因为图像矩是从全局分布角度描述目标,它天然对轮廓上的局部噪声不那么敏感。你轮廓提取时少了一个锯齿点,对整体的矩影响很小。这一点,在很多实时项目里非常关键,因为采集到的轮廓不可能每次都那么完美。

1.2 从零阶矩到高阶矩:公式很简单,含义不简单

图像矩的定义形式是这样的:对于一张图像 (I(x,y)),它的 (p+q) 阶几何矩是:

m_pq = Σ_x Σ_y I(x,y) * x^p * y^q

如果输入的是二值图,(I(x,y)) 只有 0 或 255 两种取值,那么非零像素就贡献了质量。如果输入的是轮廓点集,OpenCV内部会把轮廓当成封闭区域来计算,也就是说,它计算的是这个轮廓内部所有像素的矩,而不是一串散点的矩。

这里几个常用阶数的含义非常直观:

  • 零阶矩 m00:非零像素的总量。对于二值图来说,它直接等于目标的面积。找轮廓之后顺手算一下 m00,就能按面积过滤掉一堆小噪点,这是最基础也最常用的过滤手段。
  • 一阶矩 m10、m01:分别表示所有像素在 x 方向和 y 方向上的质量累计。用它们除以 m00,就得到质心坐标。注意,质心和一般说的“几何中心”不是一回事,质心是按灰度加权后的中心,但二值图里灰度均匀,所以二者一致。
  • 二阶矩 m20、m02、m11:反映目标的伸展情况和方向。m20 描述 x 方向的方差,m02 描述 y 方向的方差,m11 描述 x 和 y 的协方差。它们组合起来可以算出目标的主轴方向角。
  • 三阶矩 m30、m03、m12、m21:用于描述偏斜等细节形状信息,是Hu矩的核心输入。

从工程角度,你不用记太多数学推导,但得清楚每阶矩对应什么物理含义。不然拿到返回的字典,看着一大堆字段,根本不知道哪些有用。

1.3 归一化中心矩带来的“尺度不变”

直接算出来的原始矩有一个很明显的问题:它会随目标在图像中的位置、大小变化而变化。同一个形状,往右上角挪了 500 像素,原始矩立刻全变了。为了解决位置影响,我们用中心矩;为了解决尺度影响,我们再对中心矩做归一化。

中心矩的定义是把坐标原点移到质心:

mu_pq = Σ_x Σ_y I(x,y) * (x - cx)^p * (y - cy)^q

其中 cx、cy 就是质心坐标。这一步操作,相当于把坐标系原点绑定到目标自身,因此矩不再受平移影响。

归一化中心矩进一步去掉尺度的影响,公式是:

nu_pq = mu_pq / m00^((p+q)/2 + 1)

这里 m00 的指数不是随便给的,它是为了抵消单位尺度变化带来的缩放效应。做完这一步,同一个形状不管放大两倍还是缩小一半,归一化中心矩基本保持一致。这正是后面形状匹配能够成立的前提。

注意:归一化中心矩理论上可以用于尺度不变识别,但它对离散化误差和轮廓残缺仍然敏感。实际项目里,如果目标在图像里尺寸变化过大,建议还是先把目标缩放到统一尺寸,再做矩计算,效果更稳。

2. OpenCV里怎么取矩,取完能干什么

2.1 cv2.moments()返回的字典到底装了啥

OpenCV里取矩的函数非常简单,一句话就能调用:

m = cv2.moments(contour_or_binary_image)

传入的可以是二值图,也可以是单个轮廓。函数返回一个 Python 字典,里面同时包含原始矩、中心矩和归一化中心矩三个部分。

返回字典的完整字段是这样:

类型字段
原始矩m00、m10、m01、m20、m11、m02、m30、m21、m12、m03
中心矩mu20、mu11、mu02、mu30、mu21、mu12、mu03
归一化中心矩nu20、nu11、nu02、nu30、nu21、nu12、nu03

需要注意,原始矩只计算到 p+q=3,更高阶的矩OpenCV默认不输出。对于绝大多数轮廓分析场景,三阶以内已经完全够用。如果你确实需要更高阶矩,可以基于原始定义自己写一个通用函数,但实际工程中很少用到。

另一个最容易忽略的细节是:cv2.moments()既可以传轮廓点集,也可以传二值图像。但两者算法路径不同。传入轮廓时,OpenCV内部用的是Green公式,对轮廓包围的区域做数值积分,而不是简单地把轮廓上的散点当成像素质量。这意味着,就算同一个物体轮廓和二值图面积看起来一样,最终矩的值也会有微小差异,因为离散化方式不同。平时自己测试,用轮廓提取的方式做就够了。

2.2 用一阶矩求质心,用二阶矩估计主轴方向

质心的计算是图像矩最直白的应用:

if m['m00'] != 0: cx = m['m10'] / m['m00'] cy = m['m01'] / m['m00']

m00不为0的判断一定要写。因为空轮廓或者面积接近0的轮廓,会出现除零错误或得到毫无意义的巨大坐标。

主轴方向是二阶矩最有价值的应用。方向角的公式是:

theta = 0.5 * atan2(2 * mu11, mu20 - mu02)

注意这里是atan2,不是atan。它的好处是能够根据两个参数的符号自动判断角度所在象限,输出范围在 -π 到 π 之间,避免手动修正象限错误。这一点在画方向箭头的时候特别省心。

角度算出来之后,如果想画一条代表主轴的直线,可以这样:

import math angle = 0.5 * math.atan2(2 * m['mu11'], m['mu20'] - m['mu02']) length = 50 x_end = int(cx + length * math.cos(angle)) y_end = int(cy + length * math.sin(angle)) cv2.line(img, (int(cx), int(cy)), (x_end, y_end), (0, 255, 0), 2)

我做过一个工件位姿估计的小项目,目标在传送带上的角度经常变化,就是靠这个角度来做旋转补偿。实测下来,轮廓稳定时角度抖动幅度基本在1度以内。相比直接用最小外接矩形的角度,这种方法更快,也更能稳定反映轮廓整体的伸展方向。不过要提醒一点:如果目标是圆形或正方形,也就是 mu20 和 mu02 非常接近时,角度会变得很不稳定,这是数学上的退化情况,不是代码问题。遇到这类目标,别依赖矩的方向角。

2.3 用Hu矩做形状匹配之前的必做预处理

Hu矩是一个由归一化中心矩组合出来的7维向量,号称具有平移、缩放、旋转不变性。OpenCV里计算Hu矩的方法:

hu = cv2.HuMoments(m).flatten()

注意返回的形状是 (7,1),不是 (7,),所以一般会先 flatten 一下。Hu矩的前6个分量对平移、缩放和旋转都保持不变,第7个分量还会对镜像翻转敏感。

Hu矩七个分量的计算公式虽然长,但本质就是几个归一化中心矩的组合,比如第一维是nu20 + nu02,基本上等于目标在x和y方向上的总体方差,第二维是(nu20 - nu02)^2 + 4*nu11^2,反映的是各向异性程度。后面几维主要靠三阶矩组合,刻画形状的复杂结构。

直接使用Hu矩时有个实际问题:数值跨度极大,常见都是 1e-2 到 1e-7 这样差好几个数量级。所以大多数教程和项目里都会取对数压缩:

log_hu = -1 * np.sign(hu) * np.log10(np.abs(hu) + 1e-10)

加上 1e-10 是为了防止 hu 里某一位恰好等于0时取对数报错。匹配的时候,对两个轮廓的Hu矩向量算欧氏距离或余弦相似度,距离越小越相似。但我的实际体会是:Hu矩适合作为粗特征,把明显不同类型的形状先分开;如果要做精细分类,比如区分相似程度很高的几种轮廓,单纯靠Hu矩是不够的,要配合轮廓长度、面积占比、凸包参数等其他特征一起做多维度判断。

3. 点多边形距离测试的原理和使用姿势

3.1 一个很常见的需求:判断点是否在轮廓里

图像处理的很多场景最后都会落到一个朴素问题上:这个点到底在不在目标区域里?比如点击屏幕选中目标,你需要判断鼠标点击位置是否落在检测到的工件多边形内部;机器人的抓取范围需要判断目标点是否处于障碍物多边形围成的禁区内;医学切片里要判断某个采样点是否落在病变区域内。

如果只想判断在不在内部,最简单直观的办法是cv2.pointPolygonTest。函数名里的 Polygon 说明了它的处理对象是多边形轮廓,但OpenCV里传单个轮廓点集进去就行。这个函数的官方原型是:

retval = cv2.pointPolygonTest(contour, point, measureDist)
  • contour:轮廓点集,一般是cv2.findContours返回的轮廓。
  • point:要测试的点,类型是(x, y)元组。
  • measureDist:布尔值,决定返回值是有符号距离,还是简单的内外部标志。

3.2 pointPolygonTest参数细说,顺便看返回值逻辑

当measureDist=False时,返回值只有三种:

  • 1:点在轮廓内部
  • 0:点在轮廓边界上
  • -1:点在轮廓外部

当measureDist=True时,返回的是有符号欧氏距离:

  • 正数:点在内部,绝对值大小是到最近边界边的距离
  • 负数:点在外部,绝对值大小同样是到最近边界边的距离
  • 0:点理论上在边界上

实际测试中,由于轮廓点和像素都有离散误差,恰好返回0的概率很低。你很少会碰到一个点距离边界精确等于0的情况。所以判断边界时,一般用一个小范围的容差,比如abs(dist) < 0.5就认为靠近边界。

3.3 为什么有符号距离比+1/-1更好用

一开始我习惯只传 False,图省事,直接用正负判断内外。但后来做触摸屏上的目标选中功能时发现,光知道“内外”远远不够,因为用户点击的位置经常是偏的,离目标边缘只有几个像素。这时候如果只看内外标志,结果就会在 1 和 -1 之间来回跳,体验特别差。

改成measureDist=True之后,问题一下子解决了。点击点离轮廓边界越近,返回的绝对值越小,我们可以拿这个值做“置信度”:距离大于5个像素,就断定用户想选这个目标;距离小于2个像素,则认为处于模糊地带,可以进一步做邻近目标竞争判断。

所以我的建议是:绝大多数场景直接用True,把有符号距离拿回来,既能判断内外,又能拿到连续变化的度量。只有当你对性能极其敏感,或者只需要二值判断时,才用False。

这里多说一句底层实现。OpenCV的内部算法是基于射线法判断点在多边形内部,思路是:从测试点向任意方向发一条射线,统计它与多边形边的交点个数。交点数为奇数则点在内部,偶数则点在外部。在此基础上,再计算点到各条边的距离,取最小值作为最终距离。这个算法对凹多边形也是正确的,所以你可以放心用在形状复杂的轮廓上。

需要特别注意:pointPolygonTest要求轮廓是单条闭合曲线,不能传入包含多个独立轮廓的数组。如果你想对多个轮廓做测试,需要循环处理每个轮廓。

4. 完整实操:从加载图片到矩和距离测试一次打通

4.1 准备工作:环境、数据、看清任务

为了保证你能直接跑通,这里我构造一个很常见的实验场景:一张图上有几个不同形状的目标,我们先把它们分别提取出来,计算各自的质心、面积和主轴方向,然后判断图像上给定的几个点分别落在哪个目标内部,并输出点到轮廓的最小距离。

实验环境很简单:

  • Python 3.8+
  • OpenCV 4.x
  • NumPy

我用一个示例图像来说明实现流程。你也可以用自己的图片,核心逻辑不变。

4.2 预处理:灰度、二值化、找轮廓

轮廓提取的质量,直接决定了后面矩的稳定性。我自己的习惯是先做灰度转换,再用大津法做二值化:

import cv2 import numpy as np import math img_color = cv2.imread('shapes.png') if img_color is None: raise ValueError('图片加载失败,请检查路径') gray = cv2.cvtColor(img_color, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

这里用了THRESH_BINARY_INV是因为示例图片背景是白色、目标是深色。如果你的图片背景是深色目标亮色,就改成THRESH_BINARY。二值化时加上THRESH_OTSU可以自动计算阈值,省去手动调参。RETR_EXTERNAL表示只提取最外层轮廓,对多数目标分析已经够用;如果你需要内孔信息,才需要用RETR_LIST或RETR_TREE。

4.3 矩计算与可视化:把质心和方向画出来

轮廓找好之后,遍历每个轮廓计算矩,并把质心、主轴画出来:

for c in contours: area = cv2.contourArea(c) if area < 200: continue m = cv2.moments(c) if m['m00'] == 0: continue cx = m['m10'] / m['m00'] cy = m['m01'] / m['m00'] angle = 0.5 * math.atan2(2 * m['mu11'], m['mu20'] - m['mu02']) cv2.circle(img_color, (int(cx), int(cy)), 4, (0, 0, 255), -1) length = min(60, area / 10) ex = int(cx + length * math.cos(angle)) ey = int(cy + length * math.sin(angle)) cv2.line(img_color, (int(cx), int(cy)), (ex, ey), (0, 255, 0), 2) cv2.putText(img_color, f'area={int(area)}', (int(cx), int(cy) - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 0), 1) cv2.imshow('moments', img_color) cv2.waitKey(0)

这个代码里面积过滤用的是cv2.contourArea,它比 m00 更直观,但在轮廓较小时两者数值会有一点差异。工程上我更信任contourArea来过滤面积,m00 仅用来计算质心坐标,分工明确。

另外注意方向箭头的长度,我用面积除以10做了一个自适应缩放,避免大目标箭头看起来太短、小目标箭头伸出轮廓外部。这一招在调试多目标场景时非常有用,可以一眼看出每个目标的朝向是否合理。

4.4 用点多边形距离做ROI判断和最近点估计

现在假设我们有几个候选点,想知道每个点落在哪个轮廓内部。这时候就可以把前面提取到的轮廓和距离测试组合起来用:

test_points = [(120, 180), (300, 260), (400, 150), (220, 80)] for pt in test_points: best_index = -1 best_dist = float('inf') for idx, c in enumerate(contours): if cv2.contourArea(c) < 200: continue dist = cv2.pointPolygonTest(c, pt, True) if dist < 0: continue if dist < best_dist: best_dist = dist best_index = idx if best_index >= 0: print(f'点 {pt} 落在第 {best_index} 个目标内部,距离边界约 {best_dist:.2f} 像素') cv2.circle(img_color, pt, 6, (0, 255, 0), 2) else: print(f'点 {pt} 在所有目标外部,最近距离需单独计算')

这段代码的思路是:先判断点是否在轮廓内部,如果是,就保留距离边界最小的一次结果。因为一个点最多只能落在一个非重叠轮廓内部,所以直接选距离最小的轮廓即可。

有一种更细致的需求是:即使点不在任何轮廓内部,也想得到它到所有目标边界的最近距离。这可以通过不断计算cv2.pointPolygonTest(c, pt, True),取负值绝对值中的最小值实现。距离值越小,说明点离目标边界越近,有时候这个值比简单的内外判断更有意义,比如在目标边缘吸附、磁吸效果里,我会把“距离小于阈值”作为吸附触发的条件。

5. 常见问题、坑点与我的经验

5.1 千万别把矩用在一整张大图上

有朋友问过我一个奇怪的问题:直接用cv2.moments(binary)计算整张二值图的矩,算出来的质心为什么总是接近图像中心。原因很简单,因为整张图里除了目标,还有大量背景噪点。二值图里只要有一点点前景噪声,m00 和 m10 都会被这些噪声影响,导致质心偏移。

正确做法永远是先findContours把目标轮廓摘出来,然后逐轮廓计算矩。除非你的图片经过预处理后非常干净,前景只剩一个目标,否则不要直接把整图丢给 moments。

我的个人习惯是,先用面积过滤一遍轮廓,再用矩计算特征,这两个步骤前后顺序不要反。

5.2 binaryImage参数何时该打开

cv2.moments()的第二个参数是binaryImage,默认 False。官方解释是,当它为 True 时,输入图像会被当成二值图处理,所有非零像素的灰度值都会被当成1来计算。

这个参数只有在传入图像的时候才有意义。比如你传入一张灰度图,目标是白色区域,但白色区域不同位置的灰度值有差别,比如 200 和 255 混在一起,你希望把它们都当成同样的质量,那就应该把binaryImage=True打开。如果传的是轮廓点集,这个参数没啥影响,我平时通常不写第二个参数,直接用默认值。

5.3 轮廓闭合对点测试结果的影响

pointPolygonTest在处理不闭合轮廓时可能会给出让你意外的结果。OpenCV的许多轮廓算法,比如findContours返回的轮廓,天然是闭合的,所以一般不会出问题。但如果你自己从外部导入点集,或者经过某些点筛选操作后点连不成闭合环,那么这个函数就无法正常判断内外部关系。

遇到这种情况,可以在测试前对轮廓做一个闭合处理,最简单的是用cv2.convexHull得到凸包,或者用cv2.approxPolyDP先做多边形逼近,然后再做点测试。我经常用approxPolyDP来平滑轮廓上的锯齿点,顺便减少点距测试的计算量。逼近的精度参数一般取轮廓周长的 1% 到 2%,效果比较温和。

5.4 数值稳定性和图像坐标系方向问题

图像矩的数值范围比较大,尤其高阶矩,可能存在超过浮点数安全范围的情况,虽然不常见,但在轮廓坐标数值很大(比如 4K 分辨率)时还是要注意。一个简单的预防措施是对轮廓点做归一化后再计算矩,也就是先把所有点坐标减去质心,再除以一个尺度因子,最后把算出来的矩乘以相应倍数还原。

图像坐标系还有一个常见的坑:图像里 y 轴是向下增长的,所以算出来的角度是顺时针方向,而数学里常见的坐标系是逆时针。在显示方向箭头时不觉得有什么问题,但如果你想把这个角度传给机械臂等外部设备,就需要做坐标翻转换算。每次做这类转换,我都会写一个明确的带注释的辅助函数,而不是临时推公式,避免下次再看代码的时候怀疑人生。

最后分享一个我长期用的组合拳:先找轮廓,再用面积过滤,再用矩算出质心和方向作为目标状态,最后用点多边形距离测试处理点击选中或邻近判断。这套流程从轮廓提取到特征计算闭环,逻辑清楚,调试方便,很多项目里都是直接复用这套骨架。图像矩和点多边形距离测试这两样东西,看起来都不是那种能单独发论文的高深算法,但用对了地方,真的能帮你省下大量时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询