☰
深度相机与双目相机:视差精度、标定流程与角点剔除实战
2026/10/7 7:21:51 网站建设 项目流程

上个月帮朋友看一个机械臂抓取料框的项目,需求写得很干脆:识别框里散乱摆放的工件,输出抓取位姿。选型会开场第一句话就是——上深度相机还是上双目相机?这个问题我这两年被问过不下十次,每次都得先花二十分钟把两条技术路线的边界讲清楚,才能往下聊正事。因为太多人把「深度相机」当成一个具体的东西,实际上它是一类输出结果的统称;而「双目相机」说的是硬件形态,是被包含在深度相机这个大筐里的。概念没理顺,后面选型基本就是碰运气。

我自己是从双目起手的,后来陆续拆过结构光模组、试过 ToF 相机、也拿主动红外双目做过料框抓取,中间踩过的坑足够写一本小册子。这篇就把这些经验铺开讲:三条深度获取路线的原理和边界在哪,双目相机的视差公式该怎么算精度账,标定流程里那些文档不会写的细节,以及一个特别容易被跳过、但对最终精度影响巨大的环节——双目相机标定里剔除不合格角点。适合刚入行做三维视觉的、要选型的硬件工程师、以及实验室里第一次搭双目平台的同学。看完至少能少走两个月弯路。

1. 把深度相机和双目相机摆到一张桌子上比

1.1 名字里藏着的分类陷阱

先说清楚一件事:深度相机是按输出定义的,只要它最终能给你一张每个像素带距离值的深度图,它就叫深度相机。双目相机是按硬件形态定义的,指的是用两个相机从不同视角看同一个场景。这两者的关系是包含,不是并列。市面上绝大多数双目相机出厂时并不带深度输出,你得自己标定、自己跑立体匹配,它才变成一台深度相机。

麻烦的地方在于厂商的宣传口径。有些产品叫「双目结构光」,有些叫「主动双目」,有些叫「双目立体视觉相机」,名字听着像一家人,拆开看里面的东西差得远。我见过一款标着「双目」的模组,拆开发现投影器是标配、两个红外相机只做辅助,本质上是结构光方案披了件双目的外套。反过来也有人把散斑投影的双目当成结构光卖,这两条路线的失效场景其实完全不同。

所以拿到一个产品页,我习惯先干一件事:找它的失效条件章节。结构光怕强光,ToF 怕多路径和反射率,双目怕无纹理,这些在规格书里通常藏在很不起眼的角落,但恰恰决定了你的项目能不能落地。规格书上标的那句「0.2 米到 1.5 米工作范围」,往往是在白墙、室内、无阳光的理想条件下测出来的。

1.2 从输出形式倒推技术路线

判断一台设备走的是哪条路线,最直接的办法是看它给了你什么数据接口。只给深度图的,多半是结构光或者 ToF;同时给左右两张红外图加一张深度图的,基本是双目类方案;只给左右两张彩色图的,那就是纯被动双目,深度得你自己算。

还有一个更快的判定方法:看它有没有红外投影口。结构光和主动红外双目一定有发射窗口,用手电筒照一下能看到暗红色的滤光片,或者拿手机前置摄像头(很多手机前置没有红外滤光片)对着看一眼,能看到投影器微微发亮。ToF 的发射口和接收口通常是分开的两个窗口,结构光的发射和接收也分窗口,靠这个很难区分,还是得看数据接口。

我一般建议新手先从形态判断入手,再通过数据接口确认,最后翻一下 SDK 里有没有投影器的开关控制。有投影器开关控制的基本可以确定是主动方案,这种模组在室外强光下通常可以关掉投影走被动模式,但代价是深度图会出现大片空洞。

1.3 一张对比表先看全局

下面这张表是我自己在选型会上常用的速查版本,数字是几款常见同类产品的综合区间,不是某一台的具体值,实际以规格书为准。

维度结构光飞行时间 ToF主动红外双目被动双目
测距依据投影图案的形变或相位光往返时间或调制相位双目视差加散斑纹理纯双目视差
近距离精度高,亚毫米到毫米中,毫米到厘米中高,毫米级依赖纹理,波动大
典型有效距离0.2 到 1.5 米0.5 到 5 米0.3 到 3 米随基线延长
室外强光表现差中中好
纹理依赖无无弱强
材质敏感度黑、镜面、透明失效反射率影响相位相对宽容无纹理直接失效
帧率与运动中高中取决于算法
多机干扰明显,散斑串扰有一定概率较低无
功耗中高中低
成本中高中低

这张表里最值得记住的一行是「多机干扰」。如果你的产线上要摆十几台相机同时工作,结构光方案一定要考虑分时触发,否则投影的散斑互相打架,深度图会出现随机的大面积错误,而且这种错误在调试时特别难复现。

2. 深度相机的三条主流路线拆解

2.1 结构光:用已知图案的形变反推三维

结构光的思路很直观。你拿一个投影器往场景里投一张已知的图案,图案落到物体表面会随表面形状发生扭曲,相机拍下扭曲后的图案,通过图案和投影器的对应关系,就能用三角测量算出每个点的深度。投影图案的种类很多,最早期是格雷码加相移,后来有正弦条纹,现在消费级产品里最常见的是随机散斑,因为散斑只需要一张图就能算出深度,适合做高帧率。

它的强项是近距离精度。因为投影图案的变形量在近距离下非常敏感,稍远一点精度就迅速掉下来。我实测过的一款散斑结构光模组,在 50 厘米处深度噪声能压到 1 毫米以内,但到 1.5 米就退化到 1 厘米以上了。这个特性决定了它很难做远距离应用。

失效场景也很明确。第一是强红外环境,太阳光里的红外成分会把投影散斑淹没,室外中午基本不能用,这是硬伤,不是调参能解决的。第二是黑色吸光材质,散斑投上去反射不回来,深度直接空洞。第三是镜面和透明体,反射方向不确定,算出来的深度是乱的。第四是多台设备同时工作,散斑相互干扰,需要做分时或者编码区分。

投影器工作时别让眼睛正对发射口看,尤其是给小孩和宠物演示的时候注意一下。这类模组的发射功率通常符合安全等级要求,但近距离直视仍然不建议。

2.2 飞行时间:直接数光跑了个来回

ToF 的逻辑是测量光从发射到返回的时间。连续波调制的方式更常见,发射一束经过高频调制的光,接收端测出返回光和发射光之间的相位差,用相位差反推距离。因为相位是周期性重复的,单一频率下存在距离模糊,所以实际产品会用多个调制频率组合来解算真实距离,这也是为什么 ToF 的标定参数里经常会看到一列频率。

它的优势是整幅图像同步曝光,帧率高,对纹理完全没有依赖,白色墙面和黑布都能出深度。中远距离的稳定性比结构光好很多,1 到 3 米是它的舒适区。缺点是近距离精度不如结构光,而且有一类特有的错误叫多路径干扰,就是光打到墙角后反射了一圈才回到接收器,算出来的距离比真实值大。这类错误在直角走廊、箱体内部特别容易出现,表现是深度图上出现奇怪的斜面。

还有一类错误叫飞点。物体边缘的像素里混进了前景和背景两种深度的回波,最终解算出一个介于两者之间的值,看起来深度图上物体轮廓外有一圈悬空的噪点。这在做抓取的时候很致命,因为点云里会多出一堆不存在的点,得靠边缘膨胀加深度一致性过滤来清掉。

2.3 主动红外双目:给双目装了把手电筒

主动红外双目可以理解成给传统双目加了一个红外散斑投影器。它的深度依然靠视差算,投影器的工作只是把纹理「画」到无纹理表面上,让匹配算法有东西可以匹配。这个设计很巧妙,既保留了双目在室外可以关掉投影走被动模式的灵活性,又解决了室内白墙匹配不上的问题。

它和结构光的本质区别在于:结构光是靠图案本身携带的编码信息来解算深度,投影器和相机之间需要严格标定;主动红外双目是靠左右两个相机的视差来解算深度,投影器只是个辅助光源,不参与几何计算。所以你切换投影器开关,深度的绝对尺度不会变,只会影响有效像素的比例。

这个特性带来的好处是,投影器的标定漂移不影响精度,维护成本低。坏处是它的精度上限受双目基线限制,做不到结构光那种亚毫米级的近距离精度。我一般把它定位成「通用场景的万金油」,室内外都能用,价格适中,SDK 生态成熟,是入门三维视觉最不容易翻车的选择。

2.4 参数与实测对比

社区里这几年被反复提到的「大白深度相机」,本质上就是入门价位的小体积深度模组,大家在创客圈里习惯这么叫。这类模组的价格和体积都对个人开发者很友好,适合做原型验证。但有个问题必须提醒:不同批次的一致性、出厂标定的质量参差不齐,如果你的应用需要毫米级测量,千万别指望出厂参数直接可用,拿到手第一件事就是自己做一次校正和验证。

我整理了几类方案在我手上实测的感受,这里的数字只是方向性参考,不同产品差异很大:

  • 近距离精细测量,比如小零件高度检测,选结构光,但要把工作距离限制在它最舒服的那一档。
  • 中距离场景理解,比如服务机器人避障,选 ToF,功耗换稳定性。
  • 室内外切换、抓取、体积测量,选主动红外双目,容错率高,维护简单。
  • 纯室外阳光下的测量,被动双目或者激光方案更稳。

一个很实在的判据是:如果你的场景里同时存在无纹理表面和强环境光,那基本没有单一方案能完美覆盖,需要做方案组合或者接受一部分无效像素。我在一个户外料堆体积测量的项目里最后就是用了双目加补光的方式,靠场景本身的地面纹理撑住匹配,投影器只在阴影区打开。

3. 双目相机的核心:视差、三角测量与精度账

3.1 视差公式与它的物理含义

双目测深度的核心公式只有一个:距离等于焦距乘以基线再除以视差。写成公式是 Z = f × B / d,其中 f 是相机的等效焦距,单位是像素;B 是左右两个相机光心之间的距离,也就是基线,单位是毫米;d 是同一个物点在左右图像上的横坐标差值,也就是视差,单位是像素。

这个公式的物理含义很值得琢磨。基线越大,同样的距离产生的视差越大,测量越灵敏;焦距越长,视差也越大;视差越小,说明物体越远,而且距离和视差是反比关系,这条曲线在近处陡、远处平,意味着远处的深度分辨率会急剧下降。

举个数:一台 1280 乘 720 的相机,像元尺寸 3.45 微米,镜头焦距 4 毫米,那么等效焦距 f 等于 4 毫米除以 3.45 微米,大约是 1159 像素。基线 60 毫米。一个距离 1 米的物点,视差是 1159 乘 60 除以 1000,约等于 69.5 像素。这就是为什么双目标定一定要做到亚像素精度,因为一点点视差误差就会放大成明显的距离误差。

3.2 基线、焦距、像素分辨率怎么配

这三个参数不是随便定的,互相之间有硬约束。先看最近可测距离,它受最大视差搜索范围限制。如果算法把视差搜索范围限制在 0 到 128 像素,那么最近可测距离就是 f 乘 B 除以 128,代入刚才的数字是 1159 乘 60 除以 128,约等于 543 毫米。也就是说,物体离相机近了,视差会超出搜索范围,直接匹配失败,深度图上出现大片空洞。

反过来,最远可测距离受最小可分辨视差限制。假设系统能稳定分辨 0.2 像素的视差,那么理论最远距离是 1159 乘 60 除以 0.2,约等于 347 米,但这是纯理论值,实际受标定误差、图像噪声、匹配算法鲁棒性影响,通常有效距离是理论值的十分之一都不到。我手上这套 60 毫米基线的模组,稳定出深度的距离大概到 8 米左右,再远就开始大面积噪点了。

基线也不是越大越好。基线变大,近距离的遮挡区域会明显增大。所谓遮挡,是指某个物点能被左相机看到,但被右相机被前景挡住了,反过来也一样。这部分区域在深度图上一定是空洞,而且空洞宽度近似和基线成正比。基线从 60 毫米加到 120 毫米,精度提升一倍,但近处盲区和空洞也差不多翻倍。

基线焦距搜索范围最近可测最远可靠范围1 米处视差
60 mm1159 px0 到 128 px约 0.54 m约 6 到 8 m69.5 px
60 mm1159 px0 到 256 px约 0.27 m约 6 到 8 m69.5 px
120 mm1159 px0 到 256 px约 0.54 m约 12 m139 px
120 mm2300 px0 到 256 px约 1.08 m约 20 m276 px

这张表能看出一个反直觉的结论:加大焦距和加大基线的效果类似,但加大焦距会让视场变窄、最近可测距离变远,而加大基线只影响最近可测距离和遮挡。所以我一般优先调基线,实在不行再考虑长焦,长焦最后才动。

3.3 精度账要算在项目启动前

深度误差的传播关系是:深度误差等于距离的平方乘以视差误差,再除以焦距和基线的乘积。写成式子就是 ΔZ = Z² × Δd / (f × B)。这个平方项是最要命的,距离翻倍,同样的视差误差导致的深度误差变成四倍。

拿一组数字算给你看。f 是 1159 像素,B 是 60 毫米,假设匹配算法能做到 0.2 像素的视差精度:

距离 Z视差 d深度误差
0.5 m139.1 px约 0.72 mm
1 m69.5 px约 2.9 mm
2 m34.8 px约 11.5 mm
4 m17.4 px约 46 mm
8 m8.7 px约 184 mm

看到最后一行就知道为什么远距离的三维重建必须上别的方案了。8 米处接近 20 厘米的深度误差,做粗定位可以,做精细抓取完全没戏。我见过一些项目在需求里写「要能测 10 米外的物体,精度 1 厘米」,这种需求用双目基本是不可能完成的,得换成激光或者别的测距方式。

所以每次立项,我都会把这张表算一遍,然后反过来问:这个精度要求,在目标距离上需要多少像素的视差精度?如果算出来要求 0.05 像素,那就要考虑是不是该换方案了,因为亚像素匹配做到 0.05 像素非常困难,尤其是纹理弱的表面。

3.4 双目绕不开的纹理依赖

视差匹配的本质是在左右图里找同一个物点,靠的是局部图像特征的相似性。如果一块区域在图像里是纯白一片,没有任何灰度变化,匹配算法就找不到对应点,深度就是空的。这是双目的天然限制,所有改进算法都只是在缓解,不能根除。

缓解手段有几个方向。第一是加主动散斑投影,也就是前面说的主动红外双目,把纹理「造」出来。第二是加额外的结构光模块做辅助。第三是用深度学习匹配网络,这类方法在弱纹理区域能靠上下文推断出一部分深度,但代价是算力需求高,而且推断出来的深度不一定可靠,做测量要谨慎。

如果场景里同时存在大面积的白色墙面和大面积的黑色地毯,双目方案就需要认真评估了。我一般会建议客户先拍一组现场图,用简易的立体匹配算法跑一遍看看空洞率,空洞率超过三成就要考虑换路线或者加补光。

4. 双目相机标定实操全流程

4.1 靶标与硬件准备

标定板的选择直接决定标定质量上限。棋盘格是最常用的,OpenCV 支持完善,角点提取稳定。ChArUco 板适合需要局部遮挡的场景,因为它每个标记都能独立识别,但代价是角点提取的精度在边缘不如棋盘格。圆点板对模糊和光照更鲁棒,但圆心提取存在偏心误差,需要额外的畸变校正,入门阶段不推荐。

材质上,纸打印的板子只适合验证流程。真要出精度的,建议直接买氧化铝或者陶瓷基底的标定板,平整度好、不易变形。如果预算有限,用激光打印后贴在浮法玻璃或者厚铝板上,贴的时候用喷胶均匀压平,别用双面胶,双面胶的厚度不均匀。

还有一件很容易被忽略的事:打印机有缩放。我见过同一次打印出来的板子,实测方格边长比标称值小了百分之二,官方规格写 25 毫米,实测 24.5 毫米。这百分之二的尺度误差会直接变成深度图的系统性比例偏差,1 米处就差 2 厘米。所以拿到标定板第一件事是用卡尺量十个格子的总长度,算出实际边长填进程序里。

标定板的平整度要求通常在 0.05 毫米以内,用手按一下能感觉到弯曲的板子基本不能用。我吃过这个亏,一块亚克力板在阳光下晒了一下午,变形后标出来的参数怎么调都不对。

4.2 采集阶段的姿态与覆盖要求

采集这步是整个标定里最花时间、也最考验耐心的。规则说起来简单:让标定板在图像里尽可能覆盖多的位置和姿态,但真做起来很容易偷懒。

我的经验是分三轮采集。第一轮让标定板在图像中心区域,绕两个倾斜轴各摆大约正负 20 度到 40 度,同时绕自身法线转 0 度、45 度、90 度,每一组拍一张,大概 12 到 15 张。第二轮把标定板移到图像的四个角和四条边,每个位置拍两三个姿态,重点是把畸变最严重的边缘区域覆盖到,这部分对畸变系数的估计贡献最大。第三轮拍几个极端姿态,比如大角度倾斜、贴近相机、远离相机,用来约束焦距和主点。

双目还有额外要求。左右图像必须同时拍到完整的标定板,不能一边清楚一边模糊。两台相机的曝光要固定,最好用硬件同步触发,软触发在运动场景下抖动能有几毫秒,标定板上如果有运动就废了。如果标定板是静止的、相机也是静止的,软同步问题不大,但曝光必须手动锁定,自动曝光会让左右图的亮度不一致,直接影响匹配。

模糊是另一个杀手。手抖、对焦不实、标定板移动中抓拍,都会导致角点定位偏差。我一般会在采集时放大看角点位置,如果角点周围有明显的拖影,这张图就直接丢。宁可多拍二十张,也不要留一张模糊的。

4.3 标定代码骨架与参数输出

下面是 OpenCV 的标定流程骨架,我加了注释说明每一步在干什么。

import cv2 import numpy as np import glob PATTERN = (9, 6) # 内角点的列数、行数,不含边框 SQUARE = 24.6 # 实测方格边长,单位 mm criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 40, 1e-4) # 构造标定板在自身坐标系下的三维点,Z 恒为 0 objp = np.zeros((PATTERN[0] * PATTERN[1], 3), np.float32) objp[:, :2] = np.mgrid[0:PATTERN[0], 0:PATTERN[1]].T.reshape(-1, 2) * SQUARE obj_points, img_points_l, img_points_r = [], [], [] left_files = sorted(glob.glob('left/*.png')) right_files = sorted(glob.glob('right/*.png')) for lf, rf in zip(left_files, right_files): img_l = cv2.imread(lf, cv2.IMREAD_GRAYSCALE) img_r = cv2.imread(rf, cv2.IMREAD_GRAYSCALE) flags = cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE ok_l, cor_l = cv2.findChessboardCorners(img_l, PATTERN, flags) ok_r, cor_r = cv2.findChessboardCorners(img_r, PATTERN, flags) if not (ok_l and ok_r): continue # 有一边没找全,直接丢帧 cor_l = cv2.cornerSubPix(img_l, cor_l, (11, 11), (-1, -1), criteria) cor_r = cv2.cornerSubPix(img_r, cor_r, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points_l.append(cor_l) img_points_r.append(cor_r) size = img_l.shape[::-1] # 单目标定取内参初值 _, K1, D1, _, _ = cv2.calibrateCamera(obj_points, img_points_l, size, None, None) _, K2, D2, _, _ = cv2.calibrateCamera(obj_points, img_points_r, size, None, None) # 立体标定,固定内参只优化外参 ret, K1, D1, K2, D2, R, T, E, F = cv2.stereoCalibrate( obj_points, img_points_l, img_points_r, K1, D1, K2, D2, size, criteria=criteria, flags=cv2.CALIB_FIX_INTRINSIC ) print('stereo rms =', ret) print('baseline mm =', np.linalg.norm(T))

这里有个选择值得说:flags 用 CALIB_FIX_INTRINSIC 还是全优化。固定内参的好处是结果稳定、可复现,缺点是单目标定的误差会被带进外参。全优化的好处是整体一致性更好,缺点是容易过拟合,尤其是标定图数量少的时候。我一般先用固定内参跑一遍看 RMS,如果超过 0.5 像素,再改成全优化对比一下,如果全优化也没明显改善,问题多半出在图像质量本身,不是优化策略。

标定完之后还要做极线校正,把左右图像映射到共面且极线水平的位置,这样立体匹配就只需要在水平方向搜索了。

R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify( K1, D1, K2, D2, size, R, T, alpha=0 ) map1x, map1y = cv2.initUndistortRectifyMap(K1, D1, R1, P1, size, cv2.CV_32FC1) map2x, map2y = cv2.initUndistortRectifyMap(K2, D2, R2, P2, size, cv2.CV_32FC1)

alpha 参数控制校正后的视野范围,取 0 会裁剪掉黑边,视野变窄但有效像素利用率高;取 1 保留全部像素但有黑边。我一般取 0,然后在机械安装上留出视野余量。

4.4 重投影误差怎么读

cv2.stereoCalibrate 返回的那个 RMS 是整体重投影误差,单位是像素。它统计的是三维标定点投影回图像后,和实际检测到的角点之间的平均距离。这个值越小越好,但也不是越小越对。

我的判断标准是这样的:低于 0.3 像素,优秀,可以放心用;0.3 到 0.5 像素,合格,大多数应用够用;0.5 到 1.0 像素,勉强,需要检查是不是有坏帧或者靶标问题;超过 1.0 像素,基本不能用,必须重标。

但只看整体 RMS 是不够的,还得看分布。下面这段代码把每一帧的误差单独算出来,能快速定位问题帧。

def per_frame_error(obj_points, img_points, K, D, R, T): errs = [] for op, ip in zip(obj_points, img_points): proj, _ = cv2.projectPoints(op, R, T, K, D) errs.append(cv2.norm(ip, proj, cv2.NORM_L2) / len(proj)) return np.array(errs)

理想情况下每帧误差应该在均值附近小幅波动。如果某一帧的误差是其他帧的三倍以上,那这一帧大概率有问题:可能是标定板边缘被截断,可能是轻微模糊,也可能是角点提取时被反光区域带偏了。我一般会把误差最大的两三帧画出来人工看一眼,多数时候能一眼看出问题。

还有一点:RMS 低不代表深度一定准。RMS 反映的是重投影一致性,如果标定板本身不平、或者棋盘格尺寸测量有系统偏差,RMS 可能很漂亮,但深度尺度是错的。所以我每次标定完都会做一个验证:拿一个已知尺寸的物体,比如一根校准过的量块,放在工作距离上测一下它的尺寸,误差在百分之一以内才算通过。

5. 标定不合格角点的识别与剔除

5.1 哪些角点属于不合格

这里要先破除一个误区。很多人以为角点提取是确定性的,找到就是找到了,找不到就是找不到。实际上 findChessboardCorners 返回的是「我找到了 54 个角点」,至于这 54 个点各自有多可信,它基本不告诉你。cornerSubPix 在此基础上做亚像素细化,但它也只是朝局部梯度最大的方向迭代,如果初始位置偏了,它会收敛到一个错误的局部极值上,而且收敛结果看起来很合理。

我在实际项目里遇到的不合格角点大致分三类。第一类是成像质量导致的定位偏差,比如过曝区域的角点周围全是白的,梯度信息几乎为零,亚像素细化会随机漂移;欠曝区域同理,噪点会干扰梯度方向;运动模糊的角点会拉长成一个椭圆,细化结果偏向模糊方向。

第二类是几何异常导致的偏差,比如标定板被手指挡住一角、板子边缘超出图像、板子弯曲或反光导致某几行角点整体偏移。这类问题会表现为棋盘格的行列不再是一条直线,用直线拟合残差能很好地量化。

第三类是左右视图配合问题,比如左右曝光差太多导致一边角点位置偏移、软触发不同步导致左右帧的内容不是同一时刻的、匹配时把一个点配到了相邻的点上。这类问题在单目里看不出来,只有做立体约束才会暴露。

第三类问题尤其隐蔽。我见过一次标定结果怎么调都不理想,最后发现是左右相机曝光差了将近两档,右图的角点在暗区里整体往亮的方向偏了半个像素,单看右图的重投影误差只有 0.2 像素,但立体标定的 RMS 一直卡在 0.9 像素下不来。

5.2 三道筛选关卡的具体实现

我的做法是给每个角点算三个指标,任意一个超标就标记为不合格。第一个指标是对比度,衡量角点周围的明暗对比是否足够。棋盘格角点有个很好的特性:它周围的四个象限是两两对角的明暗分布,相邻象限的灰度差应该很大,对角象限的灰度应该接近。

def corner_contrast(gray, pt, half=5): """用四象限最小灰度差衡量角点锐度,单位是灰度级""" patch = cv2.getRectSubPix(gray, (2 * half + 1, 2 * half + 1), (float(pt[0]), float(pt[1]))).astype(np.float32) q1 = patch[0:half, 0:half].mean() q2 = patch[0:half, half + 1:].mean() q3 = patch[half + 1:, 0:half].mean() q4 = patch[half + 1:, half + 1:].mean() # 四个相邻象限对,都应该有明显差异,取最小值最保守 return min(abs(q1 - q2), abs(q2 - q4), abs(q4 - q3), abs(q3 - q1))

第二个指标是网格直线性残差。同一行的角点在理想情况下应该共线,同一列也是。用最小二乘拟合出行方向和列方向的直线,算每个点到直线的最大距离,这个值能很好地反映局部畸变和遮挡。

def grid_residual(corners, pattern): """返回每个角点在行列拟合直线上的最大偏差,单位像素""" pts = corners.reshape(pattern[1], pattern[0], 2).astype(np.float32) res = np.zeros((pattern[1], pattern[0]), np.float32) def dist_to_line(line_pts, query_pts): vx, vy, x0, y0 = cv2.fitLine(line_pts, cv2.DIST_L2, 0, 0.01, 0.01).flatten() n = np.hypot(vx, vy) vx, vy = vx / n, vy / n return np.abs((query_pts[:, 0] - x0) * vy - (query_pts[:, 1] - y0) * vx) for r in range(pattern[1]): res[r] = np.maximum(res[r], dist_to_line(pts[r], pts[r])) for c in range(pattern[0]): res[:, c] = np.maximum(res[:, c], dist_to_line(pts[:, c], pts[:, c])) return res.reshape(-1)

第三个指标是极线残差,只在立体标定里用。用左右角点算一个基础矩阵,然后把左图角点投影成右图上的极线,看对应的右图角点离这条极线有多远。距离过大说明这对点要么是误匹配,要么是时间不同步,或者畸变没校正好。

def epipolar_residual(F, pts_l, pts_r): """每对匹配点到极线的距离,单位像素""" lines = cv2.computeCorrespondEpilines( pts_l.reshape(-1, 1, 2), 1, F).reshape(-1, 3) res = [] for (a, b, c), (x, y) in zip(lines, pts_r.reshape(-1, 2)): res.append(abs(a * x + b * y + c) / np.hypot(a, b)) return np.array(res)

三个指标算完,合成一个布尔掩码:

def check_frame(gray_l, gray_r, cor_l, cor_r, pattern, F=None, contrast_th=12.0, grid_th=0.8, epi_th=1.5): pts_l = cor_l.reshape(-1, 2) pts_r = cor_r.reshape(-1, 2) c_l = np.array([corner_contrast(gray_l, p) for p in pts_l]) c_r = np.array([corner_contrast(gray_r, p) for p in pts_r]) g_l = grid_residual(cor_l, pattern) g_r = grid_residual(cor_r, pattern) bad = (c_l < contrast_th) | (c_r < contrast_th) \ | (g_l > grid_th) | (g_r > grid_th) if F is not None: e = epipolar_residual(F, pts_l, pts_r) bad |= (e > epi_th) return bad, 1.0 - bad.mean()

在主循环里这样用:

gray_l = cv2.imread(lf, cv2.IMREAD_GRAYSCALE) ok, bad, ratio = True, None, 1.0 F, _ = cv2.findFundamentalMat(cor_l, cor_r, cv2.FM_RANSAC, 1.0, 0.999) bad, ratio = check_frame(gray_l, gray_r, cor_l, cor_r, PATTERN, F) if ratio < 0.85: # 保留率太低,整帧丢弃 continue keep = ~bad obj_points.append(objp[keep]) img_points_l.append(cor_l.reshape(-1, 2)[keep].reshape(-1, 1, 2)) img_points_r.append(cor_r.reshape(-1, 2)[keep].reshape(-1, 1, 2))

注意 OpenCV 的标定接口允许每一帧的点数不同,所以直接删掉不合格点是安全的,不需要补齐。

5.3 剔除强度的取舍与效果验证

阈值定在哪里,是个需要实测的问题。定太松,坏点留下来了,标定精度上不去;定太紧,好点被误删,样本量不足反而让参数估计不稳定。我在一套 60 毫米基线的红外双目模组上做过一轮阈值扫描,记录如下,可以作为调参起点。

对比度阈值保留率立体 RMS1 米处深度噪声
不剔除100%0.51 px约 8 mm
897%0.34 px约 5.2 mm
1292%0.24 px约 3.5 mm
2078%0.26 px约 3.8 mm
3055%0.45 px约 7.1 mm

阈值从 12 抬到 20,保留率掉到 78%,RMS 反而略微变差,说明这时候删掉的主要是正常点,样本量不足导致的方差上升超过了坏点减少的收益。阈值 30 那档更明显,RMS 直接退化到 0.45 像素,因为很多帧的角点数已经少到不足以约束畸变系数了。

所以我的建议是把对比度阈值设在 10 到 15 之间,网格残差阈值设在 0.6 到 1.0 像素之间,极线阈值设在 1.0 到 2.0 像素之间,然后通过保留率来交叉验证。经验上,剔除后整体保留率在 85% 到 95% 之间是比较健康的,低于 80% 说明采集质量本身有问题,应该回去重新采集,而不是硬靠算法救。

剔除后的效果验证不能只看 RMS,还要看几个实际指标。一是基线的重复性,也就是把同一套数据分成两半分别标定,比较两次得到的基线长度差多少。我这边剔除前是正负 0.9 毫米,剔除后压到正负 0.15 毫米。二是深度噪声,用固定距离的平面做一百帧,看深度的标准差。三是视差图的一致性,看无纹理区域之外的像素是否还有大量孤立噪点。这三个指标一起看,才能确认标定是真的改善了,而不是 RMS 变好看了。

6. 常见问题与排查速查表

6.1 深度图异常问题定位

深度图大面积空洞是最常见的问题,成因按概率排下来大概是:无纹理或低反射率表面、超出有效距离、视差超出搜索范围、曝光不当。排查顺序我一般是从近到远:先把物体放到半米处看看有没有深度,如果有,说明硬件和标定没问题,是距离导致的;如果没有,换一张有丰富纹理的纸看看,如果这时候有深度了,说明是纹理问题。

深度图上物体边缘有一圈悬空的噪点,也就是飞点,这多半是匹配窗口跨越了深度不连续区域导致的。解决办法有三个方向:加边缘膨胀操作,把前景往外扩几个像素再做深度过滤;用左右一致性检查,也就是把左图的视差投射到右图再反投回来,不一致的点标为无效;用子像素插值加置信度过滤,把置信度低的点直接丢。

深度图上出现周期性的条纹或者波纹,通常是投影图案和相机采样频率之间的干涉,学名叫莫尔条纹。调整投影图案的周期、加轻微散焦让图案变模糊一点,或者做多频投影融合,都能缓解。这类问题在结构光方案里比在双目方案里更常见。

深度值整体偏大或偏小,也就是尺度不对,基本可以锁定是标定的问题。最常见的原因就是标定板方格的实际边长和程序里填的值不一致,其次是基线在标定后被机械结构改变了。所以标定完我会在机构上打防松标记,任何拆装之后都要重新验证。

6.2 标定与匹配类问题定位

标定 RMS 一直降不下来,按下面这个顺序查:先看有没有明显模糊的帧,全部人工过一遍;再确认左右曝光是否一致,把两张图的亮度均值打印出来对比;再检查标定板的姿态覆盖,是不是所有帧都集中在图像中心;最后确认方格边长是否实测过,打印机缩放是最容易被忽略的一项。

立体匹配出来的视差图整体偏移,表现为点云比实际物体厚或者薄一层,这通常是极线校正没做好,或者左右图的时间戳对不齐。如果是静止场景,时间戳问题不大;如果有运动,一定要上硬件同步。

双目匹配在某个区域总是匹配失败,而左右图看起来纹理都正常,这时候要检查那块区域是不是落在视差搜索范围之外。把视差图的可视化打开,看看失败区域对应的视差值是不是贴着搜索范围的上限或者下限。是的话就要调整范围,或者接受这块区域无效。

还有一个容易被忽略的问题:镜头畸变模型选错。普通镜头用五参数模型够了,广角镜头需要八参数,鱼眼镜头必须用专门的鱼眼模型。用错模型的典型表现是图像边缘的角点拟合不上,重投影误差在图像中心很小、在角落很大,呈现明显的径向分布。这时候换个模型重标,往往能立竿见影。

6.3 速查表

现象最可能的原因快速验证方法处理方向
深度图大面积空洞无纹理或超出距离换带纹理的纸在 0.5 m 处测加散斑投影或缩短距离
边缘悬空噪点匹配窗口跨深度不连续放大看边缘点云一致性检查加边缘膨胀
深度周期性条纹投影与采样干涉变换投影周期观察多频融合或轻微散焦
深度整体比例偏差标定板尺寸填错量块实测尺寸对比重测方格边长重标
RMS 卡在 0.5 px 以上曝光不一致或模糊帧打印每帧亮度与误差锁曝光,剔除坏帧
边缘误差大中心正常畸变模型不匹配看残差是否径向分布换八参数或鱼眼模型
匹配区域整体偏移极线校正或同步问题静止与运动场景对比重做校正或加硬触发
同一场景重复测不一致温度漂移或机构松动打表测基线变化加温补或结构加固

7. 选型建议与踩坑体会

7.1 按场景对照选型

把前面这些拼起来,选型其实可以简化成几个问题。工作距离在 1 米以内、要求亚毫米精度、环境光可控,直接上结构光,这是它的绝对主场。工作距离在 1 到 3 米、需要高帧率、场景里有大量无纹理表面,ToF 更合适,代价是功耗和成本。要在室内外切换、预算有限、希望一套设备吃多个场景,主动红外双目是最省心的选择。

如果场景是纯室外、阳光充足、表面有自然纹理,被动双目成本最低。但要注意,室外场景的深度图质量受天气影响极大,晴天和阴天的效果能差一倍,做产品要把这个波动算进指标里。我在一个户外项目里就吃过亏,实验室调好的参数,到了现场中午完全不能用,最后靠加装遮光罩和调整工作时间窗口解决的。

还有一个维度是维护成本。结构光的投影器和相机之间的相对位置必须保持稳定,一旦拆装就要重标;双目只需要保证两个相机之间的相对位置,投影器坏了不影响几何精度,换一个就能用;ToF 是一体化模组,基本免维护但坏了要整换。产线上批量部署的话,这个差异会显著影响长期成本。

7.2 几个我不想再踩的坑

第一个坑是盲信出厂标定。几乎所有低价模组的出厂标定都只能算「能用」,做演示没问题,做测量差得远。我现在拿到任何一台新设备,第一件事都是用自己的标定板跑一遍,和出厂参数对比,如果基线差超过 0.5 毫米或者焦距差超过百分之一,就以自己标的为准。

第二个坑是标定时忘了锁曝光。自动曝光在标定过程中会根据标定板的白色区域调整增益,导致每张图的亮度都不一样,角点提取的系统偏差也跟着变。锁曝光之后我的 RMS 从 0.6 降到 0.3,就是这么直接。

第三个坑是忽略温度漂移。铝制结构的支架在温差十几度的环境里,基线能有零点几毫米的变化,对应到 1 米处就是几毫米的深度偏差。高精度应用要么做温补,要么用低膨胀材料,要么就接受这个误差并把工作温度范围写进规格。

第四个坑是只盯着 RMS 不看实际效果。RMS 是重投影一致性指标,它不检查绝对尺度。我见过 RMS 0.18 像素但深度尺度错了百分之三的标定结果,原因是标定板尺寸填错了。所以务必用已知尺寸的量块做端到端验证,这一步花十分钟,能省掉后面几天的排查。

第五个坑是把角点剔除当成万能药。剔除确实有用,能把我手上这套模组的 RMS 从 0.5 降到 0.24,但它的作用是「把已经采好的数据里的坏点挑出来」,如果采集阶段本身就拍糊了、曝光乱了、姿态覆盖不全,再好的剔除算法也救不回来。采集质量决定上限,算法只能逼近这个上限。

最后分享一个我一直在用的小习惯:给每次标定建一个目录,把原始图、标定板实测尺寸、曝光参数、标定脚本、输出参数、验证量块的测量结果全部存进去,目录名带上日期和版本号。三维视觉的项目里,标定参数是会随硬件状态漂移的,出问题的时候能翻出上一次的完整记录来对比,排查效率能高好几倍。这个习惯看起来笨,但真到关键时刻,它比任何调试技巧都管用。

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

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

立即咨询