做图像处理做得久了你会慢慢发现一个规律很多看起来复杂的功能底层往往都是几个数学上并不花哨的基础算子。图像矩和点多边形距离测试就是OpenCV里两个很典型的基础工具。单独拎出来公式不难、代码几行可真到了做轮廓分析、ROI判断、形状匹配项目的时候这两个玩意儿实用性极高而且经常需要组合着用。这篇是我OpenCV学习笔记的第十二篇。我不打算按官方文档的目录把函数全部抄一遍而是围绕两件事来写图像矩到底能帮你干哪些活点多边形距离测试该怎么用、什么时候用。我会把原理、参数、代码和我在实践中踩到的坑都放进来。适合刚学会找轮廓、准备进一步做特征分析的朋友也适合已经写过一些检测代码、但对原理细节还不太清楚的读者。1. 图像矩到底是什么为什么轮廓分析绕不开它1.1 一句话解释把轮廓当成一个有质量的平板矩这个概念最早是从物理和统计里借过来的。学过概率论的话你对“期望”“方差”应该不陌生图像的矩其实做的事情差不多把图像里每个像素的灰度值当成“质量”然后统计这些质量在空间上的分布情况。如果你做过二值图分析把前景像素都当成是均匀密度的纸板那么一个目标轮廓内部就是一块形状不规则的薄板。图像矩就是在回答这类问题这块薄板的重心在哪里它是扁的还是圆的朝哪个方向伸展形状大概接近三角形还是接近圆这些问题全部可以统一用矩来计算。正因为图像矩是从全局分布角度描述目标它天然对轮廓上的局部噪声不那么敏感。你轮廓提取时少了一个锯齿点对整体的矩影响很小。这一点在很多实时项目里非常关键因为采集到的轮廓不可能每次都那么完美。1.2 从零阶矩到高阶矩公式很简单含义不简单图像矩的定义形式是这样的对于一张图像 (I(x,y))它的 (pq) 阶几何矩是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^((pq)/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需要注意原始矩只计算到 pq3更高阶的矩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参数细说顺便看返回值逻辑当measureDistFalse时返回值只有三种1点在轮廓内部0点在轮廓边界上-1点在轮廓外部当measureDistTrue时返回的是有符号欧氏距离正数点在内部绝对值大小是到最近边界边的距离负数点在外部绝对值大小同样是到最近边界边的距离0点理论上在边界上实际测试中由于轮廓点和像素都有离散误差恰好返回0的概率很低。你很少会碰到一个点距离边界精确等于0的情况。所以判断边界时一般用一个小范围的容差比如abs(dist) 0.5就认为靠近边界。3.3 为什么有符号距离比1/-1更好用一开始我习惯只传 False图省事直接用正负判断内外。但后来做触摸屏上的目标选中功能时发现光知道“内外”远远不够因为用户点击的位置经常是偏的离目标边缘只有几个像素。这时候如果只看内外标志结果就会在 1 和 -1 之间来回跳体验特别差。改成measureDistTrue之后问题一下子解决了。点击点离轮廓边界越近返回的绝对值越小我们可以拿这个值做“置信度”距离大于5个像素就断定用户想选这个目标距离小于2个像素则认为处于模糊地带可以进一步做邻近目标竞争判断。所以我的建议是绝大多数场景直接用True把有符号距离拿回来既能判断内外又能拿到连续变化的度量。只有当你对性能极其敏感或者只需要二值判断时才用False。这里多说一句底层实现。OpenCV的内部算法是基于射线法判断点在多边形内部思路是从测试点向任意方向发一条射线统计它与多边形边的交点个数。交点数为奇数则点在内部偶数则点在外部。在此基础上再计算点到各条边的距离取最小值作为最终距离。这个算法对凹多边形也是正确的所以你可以放心用在形状复杂的轮廓上。需要特别注意pointPolygonTest要求轮廓是单条闭合曲线不能传入包含多个独立轮廓的数组。如果你想对多个轮廓做测试需要循环处理每个轮廓。4. 完整实操从加载图片到矩和距离测试一次打通4.1 准备工作环境、数据、看清任务为了保证你能直接跑通这里我构造一个很常见的实验场景一张图上有几个不同形状的目标我们先把它们分别提取出来计算各自的质心、面积和主轴方向然后判断图像上给定的几个点分别落在哪个目标内部并输出点到轮廓的最小距离。实验环境很简单Python 3.8OpenCV 4.xNumPy我用一个示例图像来说明实现流程。你也可以用自己的图片核心逻辑不变。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, farea{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 混在一起你希望把它们都当成同样的质量那就应该把binaryImageTrue打开。如果传的是轮廓点集这个参数没啥影响我平时通常不写第二个参数直接用默认值。5.3 轮廓闭合对点测试结果的影响pointPolygonTest在处理不闭合轮廓时可能会给出让你意外的结果。OpenCV的许多轮廓算法比如findContours返回的轮廓天然是闭合的所以一般不会出问题。但如果你自己从外部导入点集或者经过某些点筛选操作后点连不成闭合环那么这个函数就无法正常判断内外部关系。遇到这种情况可以在测试前对轮廓做一个闭合处理最简单的是用cv2.convexHull得到凸包或者用cv2.approxPolyDP先做多边形逼近然后再做点测试。我经常用approxPolyDP来平滑轮廓上的锯齿点顺便减少点距测试的计算量。逼近的精度参数一般取轮廓周长的 1% 到 2%效果比较温和。5.4 数值稳定性和图像坐标系方向问题图像矩的数值范围比较大尤其高阶矩可能存在超过浮点数安全范围的情况虽然不常见但在轮廓坐标数值很大比如 4K 分辨率时还是要注意。一个简单的预防措施是对轮廓点做归一化后再计算矩也就是先把所有点坐标减去质心再除以一个尺度因子最后把算出来的矩乘以相应倍数还原。图像坐标系还有一个常见的坑图像里 y 轴是向下增长的所以算出来的角度是顺时针方向而数学里常见的坐标系是逆时针。在显示方向箭头时不觉得有什么问题但如果你想把这个角度传给机械臂等外部设备就需要做坐标翻转换算。每次做这类转换我都会写一个明确的带注释的辅助函数而不是临时推公式避免下次再看代码的时候怀疑人生。最后分享一个我长期用的组合拳先找轮廓再用面积过滤再用矩算出质心和方向作为目标状态最后用点多边形距离测试处理点击选中或邻近判断。这套流程从轮廓提取到特征计算闭环逻辑清楚调试方便很多项目里都是直接复用这套骨架。图像矩和点多边形距离测试这两样东西看起来都不是那种能单独发论文的高深算法但用对了地方真的能帮你省下大量时间。