做机器视觉的同行应该都有同感:从照片里“看清一个东西”容易,但要说清楚“这东西到底多长多宽”,项目难度一下就上来了。这不是单纯在图上框选的事——OpenCV 图像目标尺寸检测,本质是把二维像素坐标映射回物理世界的毫米坐标。我最近整理这套流程时发现,不少初学者卡在同一个地方:代码跑通了,轮廓也画出来了,可就是不知道 boundingRect 给出的 w 和 h 到底等于多少毫米。
这个项目能做什么?产线上工件长宽尺寸复核、快递包裹体积预估的草图版本、照片里零件分类前先量一下尺寸,都属于这类需求。它特别适合刚学完 OpenCV 基本操作、想做一个完整图像处理小项目的人,也适合打算进入机器视觉尺寸测量方向、想搞清楚单目测量原理的初学者。我按自己实际写过的方案,把整套流程完整拆一遍,代码可以直接抄,但更建议你先理解每一步为什么这么做。
很多人以为尺寸检测的重点是“检测”,其实真正花时间的是“标定与换算”。一张图片拿过来,轮廓检测只能告诉你“有几个目标、每个目标占多少像素”,像素和毫米之间没有天然关系。要让计算机说出“这个零件的宽度是 32.5mm”,你必须先给它一个明确的参照系。下面我按项目拆解、原理梳理、代码实现、踩坑记录四个部分展开。
1. 项目拆解:尺寸检测到底在解决什么问题
1.1 单目视觉的先天限制,以及参考物的作用
先说一个绕不开的事实:普通摄像头只有单个镜头,拍出来的是二维投影,深度信息已经在成像时丢掉了。同样一个杯子,放在相机前 20cm 拍出来可能占满画面,放到 50cm 外就只占一小块。只靠单张图像,算法无法区分“物体很大但离得远”和“物体很小但离得近”。
想解决这个问题,常见路线有三种:一是用双目相机做立体匹配,通过左右图像视差恢复深度,精度高但设备成本高、标定流程复杂;二是用深度相机直接取深度图,硬件和算力要求都不低;三就是本项目采用的方式——在场景里放置一个已知尺寸的参考物,利用它建立像素和真实尺寸的换算比例。
参考物方案本质上是“用已知推未知”。既然我知道这张身份证的真实宽度是 85.6mm,在图像里又量出了它的像素宽度,那每个像素代表多少毫米就算出来了。这个比例一旦建立,同一平面上其他物体的尺寸就能换算出来。它的成本几乎为零,只需要一个尺寸已知的物品和一把尺子,精度对大多数入门级场景完全够用。
1.2 完整技术链路:从图像到毫米数值的五步流程
这个项目的处理链路非常清晰,我习惯拆成五步:
- 图像预处理:读取图像,转灰度、高斯模糊去噪、Canny 边缘检测,得到干净的边缘图。
- 轮廓提取与排序:用 findContours 找到所有轮廓,按面积排序。面积最大的通常是参考物,也可能是被测物,后面我会讲怎么区分。
- 参考物识别与透视变换:拿到参考物的四个顶点,做透视变换,把倾斜拍摄的视角矫正为正视图。这一步直接决定测量精度。
- 比例尺计算:在矫正后的图像里,用参考物的像素尺寸除以真实尺寸,得到 pixelsPerMetric 比例因子。
- 目标测量:检测目标轮廓,取外接矩形或最小外接矩形,用像素宽高除以比例因子,输出毫米数值。
五步走下来,输入是一张图,输出是一串带单位的尺寸。整个过程没有特别复杂的算法,最大的工程量和调试精力其实都放在“让轮廓稳定精确地提取出来”这一步。
1.3 适用场景和测量边界
参考物方案适合的场景有一个共同特征:被测物体和参考物基本处于同一平面,且相机光轴与这个平面接近垂直。典型例子包括:
- 皮带线上的零件尺寸抽检,零件平放,相机固定在上方。
- 照片里的平面物体测量,比如 PCB 板、纸张、标签、木板的长宽。
- 手机拍身份证、名片,需要估计实体尺寸的场景。
不适合的场景也很明确:物体表面高度差很大的、物体是柔性会弯曲的、需要测量厚度或高度这类第三维信息的。遇到这些情况,老老实实上双目视觉或结构光方案,别在一个 2D 项目里硬凹 3D 功能。搞清楚边界,项目才不会做一半翻车。
2. 关键技术细节:坐标系、透视与误差来源
2.1 OpenCV 图像坐标系:为什么必须讲清楚
很多初学者忽略坐标系,直接照着网上的代码写,一旦需要手动处理坐标就会懵。OpenCV 的图像坐标系是:原点在左上角,x 轴向右,y 轴向下,单位是像素。cv2.boundingRect 返回的 (x, y, w, h) 中,x、y 是外接矩形左上角的像素坐标,w 是矩形宽度,h 是矩形高度。
这个坐标系和数学里习惯的坐标系最明显的区别就是 y 轴朝下。做透视变换、画 ROI、裁剪图像时,所有坐标都要按这个规则来。比如你把参考物放在画面左下角,它的 y 值会比画面中心的 y 值大,因为越往下越接近图像底部,这在图像坐标系里是完全正常的。
另外要注意,cv2.findContours 返回的轮廓点坐标也是基于这个图像坐标系。如果你想在轮廓里取最大最小值判断目标位置,千万别把 x 和 y 搞混,我见过不少人写代码时把坐标当成数学坐标系去算,结果检测框整个歪掉。
2.2 透视变换:让像素比例真正“公平”
如果相机正对着被测平面,像素比例自然均匀,直接用全局比例尺就能算。但实际拍摄时,相机很难做到绝对垂直,总会有一点俯仰角度。一旦平面倾斜,画面里的物体就会“近大远小”,远端像素被压缩,这时再用同一个比例尺去算尺寸,误差会非常大。
透视变换要解决的就是这个问题。它的原理是通过检测参考物矩形的四个顶点,把四边形映射成一个正矩形。通俗点说,就是把一张歪着拍的发票,变换成一张正面视角的发票图像。
实现上,我先用 approxPolyDP 把参考物轮廓拟合成四边形,拿到四个顶点。然后按左上、右上、右下、左下的顺序排列顶点,再用 cv2.getPerspectiveTransform 算出变换矩阵,最后用 cv2.warpPerspective 生成矫正后的图像。矫正之后,整个参考物平面变成了正视视角,在这个平面上测任何物体的尺寸,像素比例都是一致的。
这个操作对精度的影响很大。我做过对比实验,同样的场景,固定相机高度,仅仅让拍摄角度偏斜十度左右,不做透视矫正直接测,误差能到百分之十几;做了矫正之后能压到百分之二到三以内。所以只要你的相机不是严格垂直安装,透视变换这一步建议不要省。
2.3 参考物怎么选,测量精度就怎么定
参考物是整个测量体系的“标准答案”,它错了,后面全错。选参考物的原则有三条:
- 尺寸精确且已知:最好用身份证、银行卡、硬币、标准量块这类尺寸有官方标准的物品。我记得身份证宽度 85.6mm、高度 54mm,这个数值是固定的,不用临时拿尺子量。
- 边缘清晰、对比度高:参考物与背景要有明显反差,方便轮廓提取。深色桌面配浅色卡片,或浅色桌面配深色卡片,效果最好。
- 平整且贴合测量平面:参考物必须和被测物放在同一个平面上。如果被测物放在桌面上,参考物就不能悬空举着,否则透视变换的平面就不对了。
具体到我自己的习惯,用身份证做参考物最多。它尺寸标准、随身携带、表面平坦,而且颜色和大多数桌面都有区分度。用硬币也可以,但要注意不同面额直径不同,不同国家的硬币尺寸也不同,代码里记得写对数值。如果你要做的项目是长期固定场景,建议做一块黑色哑光背景板,中间贴一张白色已知尺寸的标签纸,这样轮廓提取最稳定,受环境光影响也最小。
3. 实操落地:从环境搭建到代码实现
3.1 环境准备:别在装环境上内耗
这个项目的依赖非常简单:Python 3.8 以上,安装 OpenCV 和 NumPy 就够跑通了。我自己最推荐的方式是用 Anaconda 建一个独立虚拟环境,避免和系统 Python 的包互相打架。
conda create -n measure python=3.9 -y conda activate measure pip install opencv-python numpy如果你需要用到 SIFT、ORB 这类专利算法模块,再装一个 opencv-contrib-python。注意 opencv-python 和 opencv-contrib-python 不能同时存在,装之前先卸载另一个。PyCharm 用户直接在 Settings 里把解释器指到 conda 环境即可,Visual Studio Code 用户则在 Python 插件里选择同一个解释器。
如果你是 C++ 项目,在 VS2022 里配置 OpenCV 主要做三件事:项目属性里配置 include 目录指向 opencv 的 include 文件夹;配置 lib 目录指向 x64/vc15/lib;附加依赖项里加上 opencv_world470.lib 这类库文件名;最后别忘了把 opencv 的 bin 目录加到系统 PATH,否则运行时找不到 DLL。这个流程和 Python 版本在算法上没有任何区别,只是 API 用起来更繁琐一点,新手我更推荐先用 Python 把逻辑跑通再迁移。
3.2 核心代码:四步完成目标尺寸测量
下面是一份完整的实现代码,我按功能拆成几个函数。先写四个顶点的排序和透视变换部分:
import cv2 import numpy as np def order_points(pts): # 按 左上、右上、右下、左下 的顺序排列四个顶点 rect = np.zeros((4, 2), dtype="float32") # 左上角点 x+y 最小,右下角点 x+y 最大 s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] rect[2] = pts[np.argmax(s)] # 右上角点 diff(y-x) 最大,左下角点 diff(y-x) 最小 diff = np.diff(pts, axis=1) rect[1] = pts[np.argmin(diff)] rect[3] = pts[np.argmax(diff)] return rect def four_point_transform(image, pts): rect = order_points(pts) (tl, tr, br, bl) = rect width_top = np.linalg.norm(tr - tl) width_bottom = np.linalg.norm(br - bl) max_width = max(int(width_top), int(width_bottom)) height_left = np.linalg.norm(tl - bl) height_right = np.linalg.norm(tr - br) max_height = max(int(height_left), int(height_right)) dst = np.array([ [0, 0], [max_width - 1, 0], [max_width - 1, max_height - 1], [0, max_height - 1]], dtype="float32") M = cv2.getPerspectiveTransform(rect, dst) warped = cv2.warpPerspective(image, M, (max_width, max_height)) return warped然后是主流程。这里我用一张俯拍的测试图,参考物是一张身份证,被测物是旁边的几个小零件。
def measure_object_size(image_path, ref_real_width, ref_real_height): image = cv2.imread(image_path) if image is None: raise ValueError("读取图片失败,检查路径") orig = image.copy() # 1. 预处理:灰度、高斯模糊、Canny gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0) edged = cv2.Canny(blurred, 50, 150) # 2. 轮廓提取,按面积从大到小排序 contours, _ = cv2.findContours(edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours = sorted(contours, key=cv2.contourArea, reverse=True) # 3. 取最大轮廓作为参考物(身份证),拟合成四边形 ref_contour = contours[0] peri = cv2.arcLength(ref_contour, True) approx = cv2.approxPolyDP(ref_contour, 0.02 * peri, True) if len(approx) != 4: print("参考物轮廓不是四边形,请检查图片") return # 4. 透视变换,得到正视图 warped = four_point_transform(image, approx.reshape(4, 2)) warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) warped_edged = cv2.Canny(warped_gray, 50, 150) warped_contours, _ = cv2.findContours(warped_edged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 5. 在正视图里再次定位参考物,计算比例尺 ref_contour_warped = max(warped_contours, key=cv2.contourArea) rx, ry, rw, rh = cv2.boundingRect(ref_contour_warped) # 这里用宽度计算比例尺,因为身份证宽度是标准值 85.6mm pixels_per_metric = rw / ref_real_width # 6. 遍历其余轮廓,输出每个目标的实际尺寸 for c in warped_contours: area = cv2.contourArea(c) if area < 500: # 过滤小的噪点轮廓 continue x, y, w, h = cv2.boundingRect(c) real_w = w / pixels_per_metric real_h = h / pixels_per_metric print(f"目标尺寸: 宽 {real_w:.2f} mm, 高 {real_h:.2f} mm") cv2.rectangle(warped, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.putText(warped, f"{real_w:.1f} x {real_h:.1f} mm", (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2) # 调整显示大小后展示 show = cv2.resize(warped, (800, int(warped.shape[0] * 800 / warped.shape[1]))) cv2.imshow("Result", show) cv2.waitKey(0) cv2.destroyAllWindows() if __name__ == "__main__": measure_object_size("test.jpg", 85.6, 54.0)代码里面有两个细节值得展开。
第一个是 approxPolyDP 的 epsilon 参数。我写的 0.02 * peri,意思是把轮廓周长乘以 0.02 作为近似精度阈值。这个值越大,拟合出的多边形顶点越少、形状越粗糙。对于矩形参考物,0.02 倍周长通常能稳定拟合成四点;如果物体本身边缘有锯齿或者拍摄模糊,可以适当放宽到 0.03 或 0.04。太小了拟合出来可能不止四个点,太大了矩形会被“削”掉角。
第二个是比例尺的计算基准。身份证是横着放置的情况下,轮廓外接矩形的宽度方向对应的是身份证的宽 85.6mm。代码里用 rw / ref_real_width 计算比例因子,含义是“每个像素等于多少毫米”。如果你的参考物是竖着放的,记得改用 rh 除以 54.0。这个 bug 我踩过一次,算出来的尺寸整体偏差接近 1.6 倍,排查了半天才发现是宽高基准搞反了。
3.3 从单张图片到实时相机测量
做这个项目,很多人的最终需求是接摄像头实时检测,而不是翻来覆去测一张图。实时版本其实很简单,把上面的主流程封装成一个 process_frame(frame) 函数,然后套一个 VideoCapture 的读取循环。
cap = cv2.VideoCapture(0) # 默认摄像头,CSI 摄像头在 Jetson 上用 nvarguscamerasrc 初始化 while True: ret, frame = cap.read() if not ret: break result = process_frame(frame) cv2.imshow("Live Measure", result) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()关键点是,实时场景下摄像头和参考物的位置必须固定。第一次运行可以先检测参考物并算出比例尺,后续帧可以跳过参考物检测,直接复用上一次计算的 pixels_per_metric,能省下不少运算时间。如果环境光照稳定、相机没有移动,这个预处理结果能一直用下去。
摄像头调用原理其实很简单:VideoCapture 对象负责从驱动层读视频帧,read() 每次返回一帧 BGR 图像。真正决定检测质量的不是读取速度,而是分辨率、焦距、曝光参数。我实测下来,分辨率至少 1280x720,如果能到 1920x1080 更好,像素越密,每个像素对应的毫米数越小,测量精度上限越高。焦距也是一个被忽视的因素,广角镜头边缘畸变更严重,尺寸检测尽量用略长焦一点的镜头,或者让被测物体尽量靠近画面中心。
4. 常见问题排查与进阶方向
4.1 高频踩坑记录与解决方案
做这个项目最容易出的问题,我整理成一张速查表,都是实际调试中反复遇到的:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 轮廓检测不全,目标被框了一半 | 目标与背景颜色接近,边缘提取失败 | 改用 HSV 色彩空间做颜色分割,或换背景板增加对比度 |
| 参考物边缘锯齿严重,拟合不出四边形 | 图像模糊、分辨率太低 | 调高相机分辨率,增强光照;增大 approxPolyDP 的 epsilon 参数 |
| 画面倾斜但不做矫正,测量误差大 | 相机光轴不垂直于被测平面 | 增加透视变换环节,用参考物四顶点做矫正 |
| 多个被测物连在一起,轮廓粘连 | 物体间距太近,边缘在 Canny 里连成一条 | 用形态学腐蚀操作断开粘连;或改用分水岭算法做分割 |
| 比例尺基准弄反,尺寸整体偏大/偏小 | 参考物横竖放置和代码假设不一致 | 确认 boundingRect 的宽高对应参考物的哪个真实尺寸 |
| 环境光变化,同一物体尺寸越测越偏 | Canny 阈值固定,对亮度敏感 | 改用自适应阈值 cv2.adaptiveThreshold,或在固定光源下作业 |
| 代码报错 findContours 参数问题 | OpenCV 版本不同,返回值的个数不一致 | 新版 OpenCV 用 contours, hierarchy = cv2.findContours(...),老版本是三个返回值 |
| 一个物体测量结果忽大忽小 | 轮廓边缘在 Canny 里不稳定,逐帧抖动 | 对测量结果做滑动平均滤波,或先做膨胀闭运算让边缘更连续 |
这里面我想单独强调一下参考物识别的问题。网上很多教程都默认“面积最大的轮廓就是参考物”,但实际项目中被测物可能比参考物大得多。更稳妥的做法有两个:一是给参考物固定位置,比如画面左上角永远贴一张标签纸,检测时直接取左上角 ROI 区域内的轮廓;二是给参考物赋予特征颜色,比如亮蓝色的标签纸,检测时先做 HSV 颜色过滤,找到蓝色区域再拟合轮廓,这样就算被测物比参考物大,也不会认错。
还有一个经常被忽略的细节:参考物的面积占比。如果参考物在画面里太小,比如只占几万个像素,比例尺的分母大了,量化误差就会被放大。我一般会让参考物占画面五分之一左右,这样像素比例精确到小数点后四到五位,最终毫米精度才靠谱。相机分辨率提高之后,参考物占的比例可以适当缩小。
4.2 继续往前走:相机标定与三维测量
当你的精度要求超过“差不多能看”这个阶段,就要接触相机标定了。镜头天然存在畸变,尤其是广角镜头,画面边缘的直线会变弯,这时候算出来的物体尺寸在边缘区域误差很大。cv2.calibrateCamera 的原理是拍摄十几张不同角度的棋盘格标定板,软件算出相机的内参矩阵和畸变系数,再用 cv2.undistort 对每一帧做畸变矫正。这套流程并不复杂,但对精度提升非常明显,特别是当你需要测量整个画面里多个位置的物体时。
再往后走就是三维测量。单目加参考物的方案只能测平面内的长宽,没法测高度和厚度。想要测三维尺寸,常规路线是双目立体视觉:两颗相机从不同视角拍同一物体,通过双目标定得到两相机的位置关系,再用立体匹配算法计算视差,最后恢复每个点的深度信息。这个方案在 OpenCV 里也有完整模块,从 stereoCalibrate 到 stereoBM、StereoSGBM 都有现成 API。如果你关注过近年大火的 3D 重建(比如 3DGS),基础也离不开相机的内外参标定和特征点三角化,本质上和这个项目是同一个知识树上的不同分支。学完今天这套平面尺寸检测,再去啃双目标定的立体匹配,理解成本会低非常多。
最后分享一个我做这个项目时养成的习惯。调试阶段我不会盯着最终毫米数看,而是先在图上把轮廓可视化画出来,确认边缘检测和轮廓提取的结果是稳定的,再去看比例尺换算。轮廓没画对,后面的数值再精确也没有意义。按这个顺序排查问题,整个项目推进起来会顺很多。
如果你准备在自己的场景里复现这套流程,建议第一步别急着写代码,先拿手机拍一张包含参考物和被测物的照片,用画图软件看看目标边缘到底清不清晰、背景反差不大于不够。预处理做不好,再好的算法也白搭。等到图片这一关过了,再接摄像头做实时检测,会少走很多弯路。