做视觉测量的人应该都有这个经历:二值化、找轮廓之后,第一步是想把目标框出来。我用boundingRect()用了一年多,直到有一个工件斜着放,才发现矩形框把大片背景算进去了,测量尺寸偏得离谱。换minAreaRect()之后长度对了,但遇到圆形端盖又发现旋转矩形表达不了圆度。后来补上fitEllipse(),才把三类目标都吃透。
这篇文章就从Rect、RotatedRect这两个容器类讲起,把boundingRect()、minAreaRect()、fitEllipse()三个函数的原理、返回结构、绘制方式、典型坑一次说清楚。适合正在学 OpenCV 轮廓处理、写零件测量或目标定位 Demo 的读者,也适合被角度问题绕晕的同学参考。
1. Rect类:先搞懂那个最基本的(x, y, w, h)
1.1 四个字段和面积计算
Rect本质上是 OpenCV 里表示矩形的一个简单容器,直接存四个值。C++ 里叫cv::Rect,Python 接口里通常以(x, y, width, height)四元组的形式返回。很多初学者以为Rect是一个带有中心点的结构,其实x、y是矩形左上角的坐标,不是中心。宽和高用像素表示,必然是正数,面积就是w * h。
rect = (10, 20, 100, 50) x, y, w, h = rect area = w * h print(x, y, area) # 10 20 5000这里有一个习惯很重要:如果把矩形当成中心点表示的包围盒,在做偏移、缩放时容易错位。我一般会封装两个转换函数,一个把(x, y, w, h)转成中心形式,一个转回来。偏移时对x、y做加减,缩放时把w、h乘系数。这些逻辑用Rect的字段做就足够,不需要再引入别的类型。
还要注意空矩形的判断。如果某个轮廓检测失败,可能出现w或h小于等于 0 的情况,这时候area会变成负数或者 0,直接拿去做排序、过滤会出现莫名其妙的结果。防御性写法是先判断w > 0 and h > 0,再继续后续逻辑。这个细节在批量处理多张图像时尤其重要,因为一张坏图就能拖垮整条流程。
1.2 交集、并集与包含判断
Rect在 C++ 中提供了operator &、operator |、contains这些方法。在 Python 中没这么方便,需要自己实现,但几何原理是一样的。两个矩形相交,其实就是在水平方向和垂直方向分别取重叠区间;如果重叠区间宽度或高度小于等于 0,说明没有交集。
def rect_intersect(r1, r2): x1, y1, w1, h1 = r1 x2, y2, w2, h2 = r2 left = max(x1, x2) top = max(y1, y2) right = min(x1 + w1, x2 + w2) bottom = min(y1 + h1, y2 + h2) if right <= left or bottom <= top: return None return (left, top, right - left, bottom - top)实际项目里“两个检测框是否重叠”就是用它。比如目标跟踪时判断前后帧的候选框是否命中,或者做非极大值抑制之前计算 IoU,都需要先求交集矩形。另一个常见操作是矩形合并:手写一个rect_union,把两个框的最小外接轴对齐矩形求出来,一般用于把拆分过近的小框合成一个大区域。
这里要特别提醒:Rect只能表示轴对齐的矩形,不能和RotatedRect直接做交集运算。如果场景里存在旋转目标,先把RotatedRect转成四个顶点,再用多边形交并来判断。直接用boundingRect()把它转成轴对齐矩形再做交集,会引入大量背景区域,IoU 计算就会失真。
1.3 Python 里没有 Rect 类怎么办
Python 的cv2绑定不直接暴露cv::Rect类,而用元组。所以不能写rect.area()或rect.contains(pt),否则会报AttributeError。这一点和 C++ 差别很大,从 C++ 转 Python 或者从 Python 转 C++ 的同学经常在这里卡住。
| 操作 | C++ 写法 | Python 等价写法 |
|---|---|---|
| 面积 | rect.area() | w * h |
| 包含点 | rect.contains(pt) | x <= px < x + w and y <= py < y + h |
| 交集 | rect1 & rect2 | 自己实现rect_intersect |
| 并集 | rect1 | rect2 | 自己实现rect_union |
我建议在项目里统一写一个RectUtils模块,把这些基础操作集中在一起。虽然代码量不大,但能让后续测量、绘制、日志输出都保持一致,减少低级错误。
2. RotatedRect类:角度为什么总是“看着不对”
2.1 三个核心字段
RotatedRect表示一个带旋转角度的矩形,和Rect最大的区别是它不再是轴对齐的。C++ 里叫cv::RotatedRect,Python 里大多时候表现为一个三元组Box2D:((cx, cy), (w, h), angle)。其中center是中心坐标,size是矩形的宽和高,angle是旋转角。
rr = ((100, 100), (200, 80), 30.0) # 中心、宽高、角度因为多了角度,RotatedRect无法用简单的x、y字段描述。很多同学拿到minAreaRect()的返回值后,想当然地把它当成普通矩形去取左上角,结果画出来的框位置全偏了。正确做法是通过cv2.boxPoints()获取四个顶点,再基于顶点做后续计算。
2.2 angle 角度的约定
这是最容易翻车的地方。minAreaRect()返回的angle范围是[-90°, 0°),表示矩形宽边(width)与图像水平 X 轴的夹角。注意,OpenCV 的图像坐标系 Y 轴向下,所以负角度在视觉上是顺时针倾斜。一个水平矩形angle = 0;向右下倾斜 30° 的矩形,angle = -30。
当矩形接近竖直时,宽和高会互换,angle又会回到-89左右,而不是把角度显示为60。这就是很多人发现“用minAreaRect画出来的框会突然跳变”的根本原因。根本原因在于 OpenCV 定义角度时固定以width边为基准,当目标的“宽”和“高”在某种姿态下互换,角度自然就跳了 90 度。
rrect = cv2.minAreaRect(contour) (cx, cy), (w, h), angle = rrect if w < h: w, h = h, w angle += 90 # 把角度调整到长边方向这段代码的意思是:如果返回的height比width大,说明宽高已经互换,就把角度加 90 度修正到长边方向。实际测量中,我几乎总是先统一长边和短边,再记录角度,否则后续做旋转矫正时方向很容易反。
2.3 四个顶点怎么取、怎么画
Python 中要拿到旋转矩形的四个角点,用cv2.boxPoints()。注意,boxPoints返回的是 float32 的 4×2 数组,画图前通常要转成整数。绘制时要用polylines,并且闭合。
box = cv2.boxPoints(rrect) box = np.int32(box) cv2.polylines(img, [box], isClosed=True, color=(0, 255, 0), thickness=2)这个坑我踩过:直接用cv2.rectangle(img, rrect, ...)是画不出旋转效果的,它只会画一个轴对齐框。另外,boxPoints的顶点顺序是固定闭环的,但“哪个点排在第一个”不一定是左上角。做透视矫正的时候,如果按固定索引取顶点,可能拿到不同顺序的四个角,建议先按 x 坐标排序再按 y 坐标排序,或者用凸包顺序统一。
3. boundingRect、minAreaRect、fitEllipse:三种“外接形状”怎么选
3.1 boundingRect:快但轴对齐
cv2.boundingRect(contour)的输入是点集,输出是(x, y, w, h)。它找到的是包含所有点的最小轴对齐矩形,无论目标怎么旋转,矩形四条边永远平行于图像坐标轴。这个函数最大的优点是快,计算量小,代码简单。
适用场景很明确:OCR 文本行检测、运动目标粗略定位、深度学习检测框的候选筛选。这些场景里目标本身大多是水平的,不需要倾斜框。如果目标斜放,boundingRect的面积会明显大于目标实际面积,测量结果自然不准。我通常只在“框出来看看大概位置”的时候用它,一旦涉及到尺寸测量,立刻换minAreaRect。
还有一个不太起眼但很实用的重载:boundingRect可以直接接收一个RotatedRect,返回它的轴对齐外接矩形。这个特性偶尔用于把旋转框转成普通框,比如和深度学习模型输出的轴对齐框做匹配。代价是如果目标倾斜较大,外接矩形会含很多背景。
3.2 minAreaRect:最小外接旋转矩形
cv2.minAreaRect(points)返回RotatedRect。内部思想是:先求点集的凸包,然后在凸包上寻找使面积最小的外接矩形,相当于“旋转卡壳”的应用。它找到的矩形可以任意旋转,所以能紧紧包住斜放的长条形目标。
这个方法最适合斜工件测量、车牌矫正、车辆方向估计等场景。执行速度比boundingRect慢一些,但对于一般轮廓点数来说几乎可以忽略。真正的坑在于角度跳变和宽高互换,上一章已经详细说过。
rrect = cv2.minAreaRect(cnt) (cx, cy), (w, h), angle = rrect if w < h: w, h = h, w angle += 90这里要继续补充一个点:minAreaRect返回的宽高并不保证谁大谁小,所以记录“长边”和“短边”时一定要先统一。不然两个不同姿态的同一个工件,一个记录长边为 width,一个记录长边为 height,后续统计会出现抖动。
3.3 fitEllipse:用椭圆表达轮廓
cv2.fitEllipse(points)同样返回RotatedRect,但含义不同:size的两个值对应椭圆的长轴和短轴,angle对应椭圆的旋转角。它最适合表达圆形、椭圆形、胶囊状目标,比如芯片圆点、细胞、表盘刻度。
和minAreaRect的关键差异是:fitEllipse不是“外接”,而是用最小二乘法拟合一个椭圆,让轮廓上的点尽量落在椭圆上。所以它不需要包含所有点,也不会因为某个噪声点把椭圆撑大。但这也意味着它对轮廓质量更敏感,轮廓缺损、毛刺都会让椭圆参数明显偏移。
还有一个硬性要求:输入点集至少 5 个点,而且不能共线。实际使用中,如果轮廓太小或者轮廓退化,fitEllipse会直接抛异常。后面我专门有一节讲这个坑。
3.4 三选一判断表
| 函数 | 输出类型 | 是否旋转 | 最少点数 | 典型用途 | 主要坑 |
|---|---|---|---|---|---|
boundingRect | (x, y, w, h) | 否 | 任意 | 文本区域、水平目标 | 斜目标框偏大 |
minAreaRect | RotatedRect | 是 | 任意(建议凸包) | 斜工件测量、车牌矫正 | 角度跳变、宽高互换 |
fitEllipse | RotatedRect | 是 | 至少 5 个点 | 圆形/椭圆拟合 | 点数不足会异常,不包含所有点 |
我的选型逻辑很简单:目标是矩形、需要测量长边方向,就minAreaRect;目标是圆形、需要判断圆度,就fitEllipse;只是拿来做显示或初筛,就boundingRect。三者不是替代关系,而是互补关系。
4. 实战:从轮廓到尺寸测量的一次完整记录
4.1 预处理与轮廓筛选
搭一个完整的测量流程,第一步永远是预处理。以一张零件图为例:读取灰度图,高斯滤波去噪,再用 Otsu 找到阈值。注意,如果目标是亮色而背景暗,用THRESH_BINARY;如果目标是暗色而背景亮,用THRESH_BINARY_INV。方向选反,轮廓就跑到背景上去了。
import cv2 import numpy as np img = cv2.imread("part.jpg") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (5, 5), 0) _, th = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) contours, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours = [c for c in contours if cv2.contourArea(c) > 500]面积过滤这一步非常关键。不先过滤,二值化图像里的噪声点、光斑都会被当成目标,后面测量全乱套。我一般先用面积阈值过滤掉太小的杂点,再根据宽高比、长宽范围做二次筛选,这样能过滤掉大部分干扰。另一个容易被忽略的点是使用RETR_EXTERNAL只取最外层轮廓,如果目标内部有文字或孔洞,内部轮廓也要保留,就要换RETR_CCOMP或RETR_TREE。
4.2 用 minAreaRect 测长宽并绘制
拿到轮廓之后,对每个候选轮廓计算最小外接旋转矩形。为了统一测量结果,比较宽高后把大的作为 length,小的作为 width。旋转角度也统一按长边计算。
for cnt in contours: rrect = cv2.minAreaRect(cnt) (cx, cy), (w, h), angle = rrect if w < h: w, h = h, w angle += 90 print(f"center=({cx:.1f},{cy:.1f}), size=({w:.1f}x{h:.1f}), angle={angle:.1f}") box = cv2.boxPoints(rrect) box = np.int32(box) cv2.polylines(img, [box], True, (0, 255, 0), 2)这时观察打印结果会发现,即使目标轻微倾斜,angle也能稳定反映姿态,测量出的长宽比boundingRect准很多。但要注意:minAreaRect是“外接”,它要求矩形完全包含所有轮廓点。如果目标边缘有尖刺,外接矩形会被刺撑大,长宽测量结果偏高。这种情况先用凸包或者多边形逼近把轮廓“磨平”,效果会更好。
4.3 用 fitEllipse 区分圆/椭圆目标
同样一个轮廓,也可以用椭圆拟合判断它到底有多“圆”。比如目标是圆形端盖,fitEllipse返回的长轴短轴应该非常接近;如果是拉长的异形件,长短轴比就会很大。
ellipse = cv2.fitEllipse(cnt) (ecx, ecy), (ew, eh), eangle = ellipse ratio = max(ew, eh) / (min(ew, eh) + 1e-6) if ratio < 1.1: label = "circle" else: label = "ellipse" cv2.ellipse(img, ellipse, (255, 0, 0), 2)这个长短轴比判断法,比直接用面积和周长判断更稳定。因为在一个轮廓同时有轻微形变和噪声的情况下,椭圆拟合对整体形状的响应比较平滑。实际项目里,我会把ratio阈值设为 1.1 到 1.2 之间,按光照稳定程度调整。如果光照波动大,椭圆拟合结果会抖,阈值就要放宽。
5. 我踩过的几个坑(角度、顶点、点数不足)
5.1 用 rectangle() 画 RotatedRect 画成水平框
这个错误太经典了。拿到minAreaRect的返回值之后,很多人直接写cv2.rectangle(img, rrect, ...),想着以元组形式传进去就行。结果要么因为类型不对报错,要么画出来一个轴对齐的矩形,角度完全被忽略。
正确做法还是两步走:先用cv2.boxPoints()拿到四个顶点,再用polylines画线。如果想把旋转框内部填充半透明颜色,也可以用fillPoly。这里没有捷径,直接记下来就好。
另一个相关问题是打印调试。很多同学打印rrect只看前两位坐标,觉得中心点对不上就怀疑算法有问题。其实center确实应该是轮廓的中心,但如果轮廓本身不规则,外接矩形的中心和视觉中心可能不完全重合。调试时建议把轮廓点和外接矩形一起画出来,肉眼判断更可靠。
5.2 fitEllipse 在轮廓点太少时直接报错
fitEllipse至少需要 5 个点,而且这些点不能全部共线。二值化之后的一个小噪声块,可能只有几个像素点,直接调用一定崩。我在一个批处理任务里就被这个问题坑过,一张图崩了整个程序。
防御性代码很简单:
if len(cnt) >= 5: try: ellipse = cv2.fitEllipse(cnt) except cv2.error: continue除了数量,轮廓退化也要注意。如果轮廓经过approxPolyDP被压缩成一条直线或三角形,共线点的比例过高,椭圆拟合结果也可能无效。我的经验是先做面积过滤,再用convexHull得到凸包,可以让轮廓点更平滑,拟合结果也更稳定。
5.3 minAreaRect 的角度和 fitEllipse 的角度不能混用
两个函数都返回RotatedRect,乍一看结构完全一样,但角度的语义在实际使用中有细微差别。minAreaRect的 angle 是基于外接矩形的width边,fitEllipse的 angle 是基于椭圆的主轴,同一份轮廓数据算出来的两个角度可能相差 90 度,或者方向相反。
如果需要同时使用这两种结果,最稳妥的方法不是记忆文档,而是把两个框都画出来,肉眼确认长轴方向对齐,再进入下一步。我在做视觉引导抓取时,会统一转换成“角度取绝对值较小的长边方向”,再参与机器人坐标换算。
5.4 扩展:凸包、多边形逼近和外接圆
轮廓处理不是只有这三种函数。做尺寸测量时,convexHull和approxPolyDP也经常一起用。convexHull能去掉凹点带来的干扰,approxPolyDP能把轮廓简化成多边形,方便判断目标边数。比如要区分三角形、四边形、五边形工件,approxPolyDP配合顶点数统计比椭圆拟合更直接。
hull = cv2.convexHull(cnt) approx = cv2.approxPolyDP(hull, 0.02 * cv2.arcLength(hull, True), True)还有一种情况是寻找圆形目标时用minEnclosingCircle,它返回圆心和半径,比fitEllipse更适合检测螺栓、轴承这类圆形结构。选择太多确实容易乱,我的建议是:遇到一个需求,先明确“要不要角度”,不要角度优先boundingRect,要角度且目标是矩形优先minAreaRect,目标是圆先试fitEllipse,目标是螺栓这类严格的圆再用minEnclosingCircle。
我现在处理轮廓外接形状时,固定这么一套流程:按面积过滤轮廓,需要倾斜测量就minAreaRect,需要圆度判断就fitEllipse,需要快速候选框就boundingRect。这几个函数看着简单,真正用起来还是要把Rect和RotatedRect的数据结构吃透。如果读到这里你觉得还有哪一步可以优化,先从画出RotatedRect的四个顶点开始验证,大多数问题都会自己暴露出来。