1. 从零搭建一套物体尺寸实时测量系统的整体思路
1.1 为什么选D435i而不是普通USB摄像头
普通USB摄像头只能拿到二维彩色图像,想从一张图里算出物体的真实物理尺寸,必须依赖参照物标定或者已知距离,一旦物体前后移动,测量结果就全废了。D435i是Intel RealSense系列里的深度相机,它同时输出彩色图和深度图,深度图里每个像素都带着到相机平面的距离值(单位毫米),这就把“像素尺寸换算成物理尺寸”这件事变得非常直接。
核心原理其实就一句话:物理尺寸 = 像素尺寸 × 深度值 / 焦距。焦距是相机内参,标定一次就能固定下来;深度值由相机实时给出;像素尺寸就是物体在图像里占了多少个像素。三个量凑齐,尺寸就能实时算出来。
D435i相比D415、D455这些型号,优势在于它自带IMU,做机械臂抓取或者移动平台上的测量时可以做姿态补偿。不过如果只是固定位置测尺寸,D415的精度其实更高,因为它的最小深度距离更近、分辨率下的基线更长。选D435i更多是考虑通用性和后续扩展。
1.2 整体方案拆解:从图像到尺寸的完整链路
整套系统我把它拆成五步:
- 相机初始化与对齐:启动D435i,把深度图对齐到彩色图坐标系,这样彩色图上每个像素都能查到对应的深度值。
- 物体检测与分割:用颜色阈值、轮廓检测或者简单的背景减除,把目标物体从画面里抠出来。
- 像素尺寸提取:对分割出的轮廓求最小外接矩形,拿到长和宽的像素值。
- 深度值获取:在物体区域内取深度中位数,避免边缘噪声和空洞影响。
- 物理尺寸换算与显示:用内参焦距做换算,把结果显示在画面上。
这个链路里最容易出问题的是第2步和第4步。分割不准,像素尺寸就偏;深度取值不对,换算结果能差出好几厘米。后面我会详细讲怎么处理。
1.3 环境准备:Python、pyrealsense2和OpenCV的安装
先说Python环境。我建议用Python 3.8到3.10,太新的版本有时候pyrealsense2的wheel还没跟上。安装方式用conda或者venv都行,我个人习惯用conda,因为科学计算相关的包管理起来省心。
conda create -n realsense python=3.9 conda activate realsense pip install pyrealsense2 opencv-python numpypyrealsense2是Intel官方提供的Python绑定,直接pip就能装。如果装不上,去Intel RealSense的GitHub release页面找对应Python版本的whl文件手动装。OpenCV用opencv-python就够了,不需要contrib版本。
注意:pyrealsense2和librealsense的版本要匹配。如果你之前装过librealsense的SDK,pip装的pyrealsense2可能会和系统里的库冲突。最稳妥的做法是在干净的虚拟环境里只装pip包,不要额外装系统级SDK。
验证安装是否成功:
import pyrealsense2 as rs import cv2 import numpy as np print(rs.__version__) print(cv2.__version__)能打印出版本号就说明环境没问题。如果报No module named 'pyrealsense2',检查一下是不是在正确的虚拟环境里。
1.4 相机内参标定:焦距到底怎么拿
D435i出厂时已经做了标定,内参直接可以从相机里读出来,不需要自己拿棋盘格去标。读取方式:
pipeline = rs.pipeline() config = rs.config() config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30) config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30) profile = pipeline.start(config) color_profile = profile.get_stream(rs.stream.color) intrinsics = color_profile.as_video_stream_profile().get_intrinsics() print(f"fx={intrinsics.fx}, fy={intrinsics.fy}") print(f"cx={intrinsics.ppx}, cy={intrinsics.ppy}")fx和fy就是焦距(单位是像素),cx和cy是主点坐标。640x480分辨率下,D435i的fx通常在600左右。这个值就是换算公式里的分母。
如果你要自己标定,用OpenCV的calibrateCamera配合棋盘格也行,但说实话没必要,出厂内参的精度已经足够做厘米级测量了。自己标定反而容易因为棋盘格不平、拍摄角度不够多而引入更大误差。
2. 核心细节解析与实操要点
2.1 深度图对齐彩色图:为什么必须做
D435i的深度传感器和彩色传感器是分开的,物理位置不同,所以同一时刻拍到的画面视角有偏差。如果不做对齐,你在彩色图上看到的物体位置,和深度图上同一像素位置对应的深度值,可能根本不是同一个点。
对齐操作:
align_to = rs.stream.color align = rs.align(align_to) frames = pipeline.wait_for_frames() aligned_frames = align.process(frames) color_frame = aligned_frames.get_color_frame() depth_frame = aligned_frames.get_depth_frame()对齐之后,彩色图上像素(u, v)对应的深度值就是depth_frame.get_distance(u, v)。这一步不做,后面所有测量都是错的。
实操心得:对齐会消耗一定的计算资源,帧率会从30fps降到大概25fps左右。如果对实时性要求特别高,可以考虑只在深度图上做检测,然后用深度图的内参换算,但那样就看不到彩色画面了,调试起来不方便。
2.2 物体分割:颜色阈值、轮廓检测与背景减除怎么选
分割方法取决于你的应用场景。我按难度从低到高列三种:
颜色阈值法:适合物体颜色和背景差异大的场景。比如传送带上的红色零件,背景是灰色。用HSV空间做阈值比RGB更稳,因为HSV对光照变化没那么敏感。
hsv = cv2.cvtColor(color_image, cv2.COLOR_BGR2HSV) lower = np.array([0, 100, 100]) upper = np.array([10, 255, 255]) mask = cv2.inRange(hsv, lower, upper)轮廓检测法:在mask基础上找轮廓,取面积最大的那个作为目标。
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: target = max(contours, key=cv2.contourArea) rect = cv2.minAreaRect(target) box = cv2.boxPoints(rect) box = np.int0(box) cv2.drawContours(color_image, [box], 0, (0, 255, 0), 2)背景减除法:适合固定相机、背景不变的场景。先拍一张没有物体的背景图,之后每帧和背景做差。
diff = cv2.absdiff(background, current) gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY) _, mask = cv2.threshold(gray, 30, 255, cv2.THRESH_BINARY)三种方法没有绝对优劣,看场景。颜色阈值最快但受光照影响大;背景减除最稳但要求背景固定;轮廓检测通常和其他方法配合使用。
2.3 最小外接矩形:长宽像素值怎么取
cv2.minAreaRect返回的是一个旋转矩形,包含中心点、宽高和旋转角度。注意这里的宽高是相对于矩形自身坐标系的,不是图像坐标系的。对于尺寸测量来说,我们关心的是物体的实际长和宽,所以直接用rect的宽高就行。
(cx, cy), (w, h), angle = rect pixel_width = max(w, h) pixel_height = min(w, h)取max和min是为了让长边始终对应“长度”,短边对应“宽度”,这样显示结果时不会因为物体旋转而长宽互换。
注意:minAreaRect对轮廓的噪声很敏感。如果mask边缘有毛刺,算出来的矩形会偏大。建议在findContours之前先做一次形态学开运算,去掉小噪点。
kernel = np.ones((5, 5), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)2.4 深度值取值:为什么用中位数而不是平均值
物体表面不是完全平的,深度值会有波动。如果直接取物体区域内所有像素的平均深度,边缘像素可能落在背景上,把平均值拉偏。中位数对异常值更鲁棒。
depth_image = np.asanyarray(depth_frame.get_data()) mask_roi = depth_image[cy-10:cy+10, cx-10:cx+10] valid_depths = mask_roi[mask_roi > 0] if len(valid_depths) > 0: depth_value = np.median(valid_depths) * depth_scaledepth_scale是深度单位换算系数,D435i默认是0.001,也就是深度图里的值乘以0.001得到米。
取中心区域而不是整个物体区域,是为了避免边缘深度不准的问题。D435i在物体边缘处深度值容易跳变,取中心20x20的区域最稳。
3. 完整实操流程与代码实现
3.1 主循环代码结构
把前面所有步骤串起来,主循环大概长这样:
import pyrealsense2 as rs import numpy as np import cv2 pipeline = rs.pipeline() config = rs.config() config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30) config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30) profile = pipeline.start(config) align = rs.align(rs.stream.color) depth_scale = profile.get_device().first_depth_sensor().get_depth_scale() intrinsics = profile.get_stream(rs.stream.color).as_video_stream_profile().get_intrinsics() fx = intrinsics.fx fy = intrinsics.fy try: while True: frames = pipeline.wait_for_frames() aligned = align.process(frames) color_frame = aligned.get_color_frame() depth_frame = aligned.get_depth_frame() if not color_frame or not depth_frame: continue color_image = np.asanyarray(color_frame.get_data()) depth_image = np.asanyarray(depth_frame.get_data()) hsv = cv2.cvtColor(color_image, cv2.COLOR_BGR2HSV) lower = np.array([0, 100, 100]) upper = np.array([10, 255, 255]) mask = cv2.inRange(hsv, lower, upper) kernel = np.ones((5, 5), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: target = max(contours, key=cv2.contourArea) if cv2.contourArea(target) > 500: rect = cv2.minAreaRect(target) (cx, cy), (w, h), angle = rect box = cv2.boxPoints(rect) box = np.int0(box) cv2.drawContours(color_image, [box], 0, (0, 255, 0), 2) cx, cy = int(cx), int(cy) roi = depth_image[max(0,cy-10):cy+10, max(0,cx-10):cx+10] valid = roi[roi > 0] if len(valid) > 0: z = np.median(valid) * depth_scale if z > 0: real_w = max(w, h) * z / fx real_h = min(w, h) * z / fy cv2.putText(color_image, f"W: {real_w*100:.1f}cm", (cx-50, cy-20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.putText(color_image, f"H: {real_h*100:.1f}cm", (cx-50, cy+10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.putText(color_image, f"Z: {z*100:.1f}cm", (cx-50, cy+40), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 2) cv2.imshow("Size Measurement", color_image) if cv2.waitKey(1) & 0xFF == ord('q'): break finally: pipeline.stop() cv2.destroyAllWindows()这段代码可以直接跑。把红色物体放在相机前,画面上会实时显示物体的长、宽和距离。
3.2 参数调优:阈值、面积过滤和深度范围
颜色阈值不是固定的,取决于你的物体颜色。调阈值的方法:先把物体放在画面里,打印出物体区域中心点的HSV值,然后以这个值为中心上下浮动。
center_hsv = hsv[cy, cx] print(f"H={center_hsv[0]}, S={center_hsv[1]}, V={center_hsv[2]}")比如打印出来是H=5, S=200, V=180,那lower可以设[0, 150, 120],upper设[15, 255, 255]。
面积过滤的阈值cv2.contourArea(target) > 500是为了排除噪点。500这个值在640x480分辨率下大概对应一个20x25像素的区域,太小的轮廓直接忽略。
深度范围也要限制。D435i的有效深度范围大概是0.3米到3米,超出这个范围深度值会不准。可以在代码里加一个判断:
if z < 0.3 or z > 3.0: continue3.3 测量精度验证:用已知尺寸物体做校准
拿一个已知尺寸的物体,比如A4纸(29.7cm x 21cm),放在不同距离下测量,看误差有多大。我实测下来,在50cm距离下,D435i测A4纸的长边误差大概在±0.5cm以内,短边误差±0.3cm以内。距离越远误差越大,1米以外误差可能到±1cm。
误差来源主要有三个:深度值的噪声、边缘像素的模糊、内参的微小偏差。深度噪声可以通过多帧平均来降低:
depth_buffer = [] # 每帧往buffer里塞一个值,取最近10帧的中位数 depth_buffer.append(z) if len(depth_buffer) > 10: depth_buffer.pop(0) z_smooth = np.median(depth_buffer)这样处理之后,测量值的抖动会明显减小。
4. 常见问题与排查技巧实录
4.1 深度图大面积空洞怎么办
D435i用的是红外结构光加双目立体匹配,对某些材质特别不友好。黑色物体、反光表面、透明物体,深度图上一大片0值。解决办法:
- 黑色物体:打一盏补光灯,或者把物体放在白色背景上,利用背景的深度值做参考。
- 反光表面:改变相机角度,避免镜面反射直接回到相机。或者喷一层显影剂(工业上常用的做法)。
- 透明物体:基本无解,D435i测不了玻璃。只能换其他原理的传感器。
如果空洞只是零星的,可以用深度图的get_distance在物体中心多采几个点,取有效值的中位数。
4.2 测量值跳动厉害怎么处理
跳动主要来自深度噪声。除了前面说的多帧中位数滤波,还可以做时域滤波。RealSense SDK自带后处理滤波器:
temporal_filter = rs.temporal_filter() spatial_filter = rs.spatial_filter() depth_frame = temporal_filter.process(depth_frame) depth_frame = spatial_filter.process(depth_frame)temporal_filter做时域平滑,spatial_filter做空域平滑。两个一起用,深度图会稳很多,但会引入一定的延迟。如果对实时性要求高,只用temporal_filter就够了。
4.3 物体旋转后长宽互换怎么解决
minAreaRect返回的宽高是相对于矩形自身的,物体旋转90度,宽高就互换了。如果你需要固定长边为“长度”,用max/min就行。但如果你需要区分物体的“实际长边”和“实际短边”,比如测量一个长方形的零件,那就需要额外逻辑。
我的做法是:如果物体有明确的朝向特征(比如有一个缺口或者标记),用特征点来确定长边方向。如果没有,就用max/min,然后在显示时标注“长边”和“短边”。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 深度图全黑 | 相机未启动或USB供电不足 | 换USB 3.0接口,检查pipeline是否start成功 |
| 测量值偏大 | 深度值取到了背景 | 缩小ROI区域,只取物体中心 |
| 测量值偏小 | 物体边缘被算进了轮廓 | 做形态学腐蚀,或者用轮廓面积过滤 |
| 帧率低 | 对齐和后处理消耗资源 | 降低分辨率到424x240,或者关闭后处理 |
| 颜色分割不稳定 | 光照变化 | 改用HSV空间,或者加补光灯 |
| 物体移动时测量不准 | 深度和彩色帧不同步 | 用align.process确保对齐,开启帧同步 |
独家避坑技巧:D435i的USB线质量对稳定性影响巨大。原装线大概1米,如果你需要更长的线,一定要买带信号放大的主动式USB 3.0延长线。普通延长线会导致深度图丢帧甚至相机掉线。这个坑我踩过,换了三根线才找到能稳定跑的。
4.5 进阶扩展:多物体测量与机械臂抓取
如果画面里有多个物体,把findContours的结果遍历一遍,对每个轮廓分别做测量就行。但要注意深度值要分别取,不能混。
for cnt in contours: if cv2.contourArea(cnt) > 500: rect = cv2.minAreaRect(cnt) # 对每个rect单独取深度和计算尺寸如果要做机械臂抓取,测量出物体的中心像素坐标和深度值之后,可以用rs2_deproject_pixel_to_point把像素坐标反投影到三维空间,得到物体在相机坐标系下的XYZ坐标,再通过手眼标定转换到机械臂坐标系。
point = rs.rs2_deproject_pixel_to_point(intrinsics, [cx, cy], z) # point[0], point[1], point[2] 就是相机坐标系下的XYZ这一步是D435i在机械臂应用里最核心的价值——不仅知道物体多大,还知道物体在哪。
4.6 性能优化:从25fps到60fps的调优记录
默认配置下,640x480分辨率加对齐加后处理,帧率大概在20到25fps。如果想提到更高:
- 降低深度分辨率到424x240,彩色保持640x480。深度图只用来取距离值,不需要高分辨率。
- 关闭spatial_filter,只保留temporal_filter。
- 把颜色分割和轮廓检测放到单独的线程里,和相机取流并行。
我实测下来,424x240深度加640x480彩色,不开后处理,能跑到55到60fps。对于大多数实时测量场景,这个帧率完全够用了。
最后分享一个小技巧:调试的时候把深度图也显示出来,用
cv2.applyColorMap上色,能直观看到哪些区域深度值缺失。很多测量问题一眼就能从深度图上看出原因。
depth_colormap = cv2.applyColorMap(cv2.convertScaleAbs(depth_image, alpha=0.03), cv2.COLORMAP_JET) cv2.imshow("Depth", depth_colormap)这套方案我从头跑通大概花了两天,大部分时间花在调颜色阈值和深度滤波参数上。硬件本身很成熟,软件层面pyrealsense2的文档也够全,真正的门槛在于理解深度相机的物理限制——它能测什么、不能测什么、在什么条件下测不准。把这些边界摸清楚了,剩下的就是调参的事。