十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

双目相机标定全流程:从数据采集到立体校正

双目相机标定全流程:从数据采集到立体校正 简介面向三维建模与相机标定入门者这套双目相机数据采集与标定压缩包提供了完整的标定工程与样本数据。包内包含40张不同视角的标定图片配合左右视图文件列表可直接用于双目相机参数求解同时提供C源码、Qt界面设计文件及工程配置文件便于在Windows环境下运行和二次开发。资源总计49个文件压缩包大小仅3.82MB主要文件类型为jpg图片、cpp/h源码、txt文本和ui界面文件结构紧凑适合快速上手。目前已有164人浏览学习适合需要完成双目视觉实验、三维重建预处理或相机标定课程设计的同学参考。通过对比图片与代码逻辑可以理解棋盘格采集、角点检测、内外参计算与去畸变的完整流程还能借助工程文件规避中文乱码等常见环境问题是一份实操性较强的入门资料。1. 双目相机采集标定难点不在标定算法而在采集前的细节先说一个反直觉的结论双目相机的重投影误差压不下去多数时候不是 OpenCV 的参数没选对而是数据采集阶段把曝光、角点清晰度和左右目同步这几个基础条件破坏了。双目相机数据采集与标定核心要做两件事一是算清左右相机各自的内参和畸变系数二是算出右相机相对左相机的旋转矩阵 R 和平移向量 T。只要这两组数准确后续的立体校正、视差图和三维坐标都能站得住。很多人拿到《双目相机数据采集、标定.zip》这样的工程包第一反应是找源码里有没有现成的标定脚本跑完就开始测视差图结果发现误差忽大忽小。问题通常不在脚本而在采集的图像是否“可靠”标定板有没有铺满画面、左右目亮度是否一致、有没有运动模糊、角点是否被反光吃掉了。这件事适合正在做机器人抓取、三维重建、深度估计或者刚拿到一台双目相机的工程师标定的成败其实在按下快门之前就已经决定了大半。2. 双目相机数据采集OpenCV录制脚本、参数表与合格样本判断2.1 为什么标定前要固定曝光并确认左右目同步双目相机标定的前提是左右目看到的是同一个静止标定板并且两边的图像质量足够稳定。常见的做法有两种一种是直接用自带同步输出的双目相机比如 D435 这类设备左右图像由硬件触发时间戳天然对齐另一种是用两个独立 USB 摄像头插在同一台主机上软件逐帧读取这种情况下左右传感器并不是在物理同一时刻曝光。软件同步的误差通常在几十毫秒只要场景里没有高速运动物体对标定几乎没有影响。真正有影响的是自动曝光。自动曝光会让左右目在不同亮度条件下各自调整增益拍出的棋盘格亮度不一致角点检测的亚像素精度就会下降。所以采集脚本里必须把曝光固定成手动模式同时把白平衡、对比度这些能改的参数也尽量固定下来。这样左右目图像才有可比性后续找角点时不会出现“左边找到了、右边找不到”的尴尬。2.2 用 Python 和 OpenCV 录制左右目图像的最小脚本下面这段脚本按空格保存一帧图像按 Esc 退出。它把左右目图像分别保存到两个目录同时写一个 CSV 记录时间戳。保存时间戳不是为了标定本身而是为了排查采集过程中是否有丢帧或错位。import cv2 import numpy as np import csv import time import os left_id 0 # 左目设备号不同机器可能不同 right_id 1 # 右目设备号 caps [] for cid in [left_id, right_id]: cap cv2.VideoCapture(cid) # 左右目必须使用相同的分辨率 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) # 手动曝光避免自动曝光导致左右亮度不一致 cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25) # 手动白平衡部分摄像头不支持时会被忽略 cap.set(cv2.CAP_PROP_WB_TEMPERATURE, 4000) caps.append(cap) os.makedirs(frames_left, exist_okTrue) os.makedirs(frames_right, exist_okTrue) idx 0 with open(capture_timestamps.csv, w, newline) as fp: writer csv.writer(fp) writer.writerow([idx, timestamp_s, left_file, right_file]) while True: ok_left, left caps[0].read() ok_right, right caps[1].read() if not ok_left or not ok_right: print(一帧读取失败继续等待) continue preview np.hstack([left, right]) cv2.imshow(stereo capture, preview) key cv2.waitKey(1) 0xFF if key 32: # 空格保存 ts time.time() left_name fframes_left/{idx:04d}.png right_name fframes_right/{idx:04d}.png cv2.imwrite(left_name, left) cv2.imwrite(right_name, right) writer.writerow([idx, ts, left_name, right_name]) idx 1 print(f已保存第 {idx} 对) elif key 27: # Esc 退出 break cv2.destroyAllWindows()这段代码只做了一件事循环读取左右目图像空闲时预览按下空格才写盘。保存 PNG 是因为它是无损格式如果用 JPEG 保存棋盘格压缩伪影会轻微影响角点亚像素定位。CSV 里的时间戳可以作为后续核对是否丢帧的依据比如相邻两帧时间差如果突然超过 200ms说明系统卡顿过最好重新采集。关于CAP_PROP_AUTO_EXPOSURE不同摄像头驱动对这个参数的解释并不统一。有的驱动用 0.25 表示手动曝光有的用 1 表示自动还有的用布尔值控制。更稳妥的做法是在采集前打开摄像头属性确认实际值或者直接通过相机制造商 SDK 设置。对普通 UVC 摄像头必须在系统层面先关掉自动曝光再跑这段脚本。2.3 采集样本的数量、姿态分布与合格样本的 5 个标准双目标定不是拍得越多越好。常见做法是采集 15 到 30 对图像每对图像都要包含同一个棋盘格。真正影响精度的是姿态分布标定板要出现在画面的四个角和中心倾斜角度从平视到约 45 度距离从近处到远处都要覆盖。同一个位置连续拍 10 张没有任何意义因为姿态没有变化重复信息不会让标定结果变得更好。合格样本可以从 5 个角度判断左右目在同一帧都能检测到完整棋盘格棋盘格在画面中占的面积不少于画面宽度的三分之一角点边缘清晰没有明显的运动模糊棋盘格表面没有强反光或高光溢出同一姿态不连续重复保存超过 2 次。下面的参数表是采集脚本和样本质量控制的常用设置具体数值以你的相机实际情况为准。参数建议值说明分辨率1280x720 或相机原分辨率左右目必须一致角点区域应足够大帧率10 FPS 左右即可标定板静止不需要高帧率曝光模式手动固定关键参数自动曝光会让左右目亮度不一致图像格式PNG避免有损压缩影响角点亚像素精度保存计数15 到 30 对重在不重复姿态而非单纯增加数量棋盘格尺寸内角点 9x6格边长 20mm需要记录物理边长用于平移向量标定如果左右目图像明显一明一暗先别急着保存。最直接的做法是在预览画面里观察棋盘格灰度直方图或者分别对两幅图计算平均亮度差 10% 以上就要考虑单个摄像头的自动增益没关干净。3. 双目标定前的单目标定张正友法、角点提取与剔除不合格角点帧3.1 张正友法解决什么单目标定的输出是什么张正友标定法是目前最常用的相机标定方法它只需要一块平面棋盘格拍摄多个不同姿态的图像就能求解相机的内参矩阵 K、畸变系数 k1、k2、p1、p2以及每张图的外参。双目标定不能跳过这一步。stereoCalibrate需要左右目内参作为输入如果没有一个足够好的初值双目标定很容易收敛到错误的外参。单目标定的数学模型本质上是一个单应矩阵。棋盘格上的角点都在一个平面平面上的点通过单应变换映射到像素坐标系。多个姿态的单应矩阵联立起来可以解出焦距、主点坐标和畸变系数。这个原理决定了采集时的一个硬约束棋盘格不能完全平行于相机成像面否则姿态变化退化成相似变换单应矩阵的约束不足焦距会标不准。我一般会把单目标定当作双目标定的“预筛”环节先对左目和右目分别跑一次单目标定计算每张图的单张重投影误差把误差偏高的图直接剔除再用剩下的图做双目标定。这比把脏数据直接喂给stereoCalibrate再回头排查要快得多。3.2 棋盘格角点提取与亚像素细化OpenCV 里找到棋盘格角点的方法是findChessboardCorners但默认只负责“找到”不做高精度定位。标定输入的是亚像素级角点必须在找到之后调用cornerSubPix做细化。否则角点本身带明显量化误差后续标定结果会差出不少。import cv2 import numpy as np pattern_size (9, 6) # 棋盘格内角点列数 x 行数 square_size 20.0 # 棋盘格每个格子的物理边长单位 mm criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) flags (cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE | cv2.CALIB_CB_FILTER_QUADS) def find_corners(image): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) ok, corners cv2.findChessboardCorners(gray, pattern_size, flagsflags) if not ok: return False, None # 亚像素细化提升角点坐标精度 corners cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria) return True, cornersCALIB_CB_ADAPTIVE_THRESH会对图像做自适应二值化处理光照不均很有效CALIB_CB_NORMALIZE_IMAGE在检测前做直方图归一化适合左右目亮度有差异的图CALIB_CB_FILTER_QUADS会过滤掉形状明显不对的四边形减少误检。这三个 flag 不是越多越好如果检测不到角点可以先只保留CALIB_CB_ADAPTIVE_THRESH再排查。需要注意pattern_size是内角点的数不是棋盘格的方格数。比如一张 10x7 的棋盘格内角点数为 9x6。在代码里对应的是(9, 6)顺序是列数在前、行数在后。这个顺序如果填反检测阶段不会报错但标定结果和后面的立体校正都会乱套。3.3 用单张重投影误差剔除不合格角点帧单目标定完成后OpenCV 会返回所有图像的整体 RMS但同时每一张图也有自己的单张误差。这个误差可以通过投影棋盘格三维角点到像素坐标系再与提取到的角点坐标做差计算出来。以下是提取角点帧并计算单张误差的代码。objp np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32) objp[:, :2] np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 1) * square_size obj_points [] left_points [] right_points [] for i in range(total_pairs): left_img cv2.imread(fframes_left/{i:04d}.png) right_img cv2.imread(fframes_right/{i:04d}.png) ok_l, corners_l find_corners(left_img) ok_r, corners_r find_corners(right_img) if not ok_l or not ok_r: print(f第 {i} 对未同时检测到角点跳过) continue obj_points.append(objp) left_points.append(corners_l) right_points.append(corners_r) # 先对左目做单目标定得到内参和外参 ret_left, K_left, d_left, rvecs_left, tvecs_left cv2.calibrateCamera( obj_points, left_points, left_img.shape[:2][::-1], None, None)objp是棋盘格上的三维角点坐标z 方向全部为 0。这里最关键的是square_size必须写真实的物理尺寸否则标定出的平移向量 T 会全是“无量纲”的 1、2、3 这种相对值无法用到真实的三维测量里。拿到ret_left之后需要逐张投影看每一张图的平均误差。errors [] for i in range(len(obj_points)): projected, _ cv2.projectPoints( obj_points[i], rvecs_left[i], tvecs_left[i], K_left, d_left) err np.mean(np.sqrt(np.sum((projected - left_points[i]) ** 2, axis2))) errors.append(err) print(单张平均重投影误差:, [round(e, 3) for e in errors])单张误差过大的图通常不是角点检测错位就是棋盘格有弯曲或反光。常见做法是把单张误差大于整体 RMS 的 2 倍、或者绝对值大于 0.2 像素的帧剔除。剔除后重新标定一次RMS 通常会下降。这个过程要保留剔除记录方便后面分析哪些姿态容易引入误差。关于阈值选择我整理了一个参考表。图像分辨率可接受 RMS剔除阈值640x4800.15 像素以下0.3 像素1280x7200.12 像素以下0.25 像素1920x10800.10 像素以下0.2 像素这个阈值不是绝对的和镜头质量、棋盘格平整度都有关系。但一个基本规律是如果所有图片的单张误差都超过 0.3 像素多半不是个别图片的问题而是棋盘格平面度不够或者镜头明显畸变但样本姿态覆盖不足。4. 双目相机标定与立体校正R、T 矩阵设置与 remap 参数4.1 stereoCalibrate 的参数和 R、T 的含义单目标定完成后左右目各自有内参K1, d1和K2, d2。双目标定要解决的问题是右相机坐标系相对左相机坐标系的位置关系。这个关系由旋转矩阵 R 和平移向量 T 描述。stereoCalibrate的输入就是前面已经筛过的、左右目同时检测到角点的图像帧。常见做法是把单目标定得到的内参作为双目标定的固定初值使用CALIB_FIX_INTRINSIC。这个 flag 告诉 OpenCV内部参数不再变化只优化左右目之间的外参 R、T。它的好处是稳定不容易出现左右内参互相“补偿”导致的外参漂移。如果标定板数量足够、姿态分布也好也可以去掉这个 flag 做一次联合优化但 RMS 不会下降太明显。4.2 双目标定完整代码# obj_points, left_points, right_points 来自第 3 节筛选后的数据 # K_left, d_left, K_right, d_right 来自单目标定 # img_size 必须和采集分辨率一致例如 (1280, 720) criteria (cv2.TERM_CRITERIA_MAX_ITER cv2.TERM_CRITERIA_EPS, 100, 1e-6) ret_stereo, K1, d1, K2, d2, R, T, E, F cv2.stereoCalibrate( obj_points, left_points, right_points, K_left, d_left, K_right, d_right, img_size, criteriacriteria, flagscv2.CALIB_FIX_INTRINSIC, ) print(f双目标定 RMS: {ret_stereo:.4f}) print(R: , R) print(T: , T)代码里K1和K2在CALIB_FIX_INTRINSIC模式下会等于传入的单目内参。E是本质矩阵F是基础矩阵实际工程里一般不太有人直接读取这两个矩阵它们主要用于理论验证。真正要关心的是R和T。两个常见误用需要注意。第一T不是像素单位而是世界单位。如果棋盘格square_size写的是 mm那么T的单位就是 mm。第二R、T描述的是右相机相对于左相机的坐标变换不要和立体校正输出的R1、R2搞混。R1、R2是校正旋转是把左右目图像校正成严格共面平行的旋转不是相机间的相对位姿。4.3 stereoRectify 与 remap 的映射参数双目标定算出来的 R 和 T 只能说明两相机相对位置还不能直接用于视差计算。要让左右图像行对齐需要用stereoRectify把两个相机的图像投影到同一个虚拟平面上。这个过程就是立体校正左图和右图都做一次旋转使得极线水平这样视差计算只需在同一行搜索对应点。alpha 0.0 # 0 表示裁剪掉无效边缘区域1 表示保留所有像素 R1, R2, P1, P2, Q, roi_left, roi_right cv2.stereoRectify( K1, d1, K2, d2, img_size, R, T, alphaalpha, flagscv2.CALIB_ZERO_DISPARITY, ) left_map1, left_map2 cv2.initUndistortRectifyMap( K1, d1, R1, P1, img_size, cv2.CV_32FC1) right_map1, right_map2 cv2.initUndistortRectifyMap( K2, d2, R2, P2, img_size, cv2.CV_32FC1) rectified_left cv2.remap(left, left_map1, left_map2, cv2.INTER_LINEAR) rectified_right cv2.remap(right, right_map1, right_map2, cv2.INTER_LINEAR)alpha是一个容易被忽略的参数。alpha0时OpenCV 会尽可能裁剪校正后图像的无效区域得到的结果干净但会损失一部分视野。alpha1会保留原始图像的所有像素边缘会出现黑色无效区域。对于深度测量应用通常用alpha0因为无效区域会在后续的视差图里产生大量黑色噪点。如果只是想做可视化alpha1更容易看出校正前后的差异。initUndistortRectifyMap输出的是单通道浮点映射表每个像素对应的源图坐标。CV_32FC1是固定参数不要改成整数类型否则重采样精度会下降。映射表计算好后可以保存成.npz或.yaml运行时直接加载避免每次启动都重新算一遍。标定结果和映射表的保存格式常见的做法是把 K1、d1、K2、d2、R、T、Q 等变量一起存到stereo_params.yaml方便后续和相机驱动解耦。5. 标定结果质量验证重投影误差、极线约束与泛化指标5.1 RMS、单帧重投影误差和基础矩阵的关系双目标定输出的 RMS 是每一对图像的左目投影误差和右目投影误差的总误差。这个值在 0.1 像素以内通常认为不错0.1 到 0.2 像素对普通应用也够用超过 0.3 像素就要回到数据采集阶段检查。但 RMS 低不代表双目标定一定可靠因为 RMS 反映的是标定板图像上的重投影误差它只说明算法对“这组图像”拟合得好不能说明真实场景里两个相机的距离关系完全正确。更可靠的交叉验证手段是基础矩阵 F。理论上左右目图像中匹配的像素点应该满足极线约束左图点 p 在右图中对应的极线是 F 乘以 p。立体校正后这个约束退化为“左右匹配点的 y 坐标相等”。因此极线校正效果直接可以用 y 坐标差来检验。5.2 极线校正效果验证画水平线和角点 y 坐标差最直接的验证方式是在校正后的左右图上检测同一个棋盘格然后比较每个角点的 y 坐标差。如果校正正确所有对应角点的 y 坐标差应该接近于零。# rect_left 和 rect_right 是经过 remap 校正后的图像 gray_l cv2.cvtColor(rect_left, cv2.COLOR_BGR2GRAY) gray_r cv2.cvtColor(rect_right, cv2.COLOR_BGR2GRAY) ok_l, corners_l cv2.findChessboardCorners(gray_l, pattern_size) ok_r, corners_r cv2.findChessboardCorners(gray_r, pattern_size) if ok_l and ok_r: # corners_l 和 corners_r 对应同一棋盘格角点 dy np.abs(corners_l[:, 0, 1] - corners_r[:, 0, 1]) print(f最大 y 坐标差: {dy.max():.3f} 像素) print(f平均 y 坐标差: {dy.mean():.3f} 像素)这个验证必须在校正后的图像上做而不是原始图像。如果平均 y 坐标差在 0.5 像素以内说明极线校正基本合格如果超过 1 像素可能是双目标定外参不准确或者左右目分辨率不一致导致 remap 映射表错位。别忘了检查roi_left和roi_right在 ROI 边缘处的角点误差本来就大比较时应去掉靠近边界的那一圈角点。5.3 用独立场景做泛化验证而不是只在标定板上自证标定板验证只能证明标定对“棋盘格姿态”是准确的不能证明对真实场景的深度测量是准的。因此我通常会在标定完成后拍一段完全独立的实际场景计算视差图再看目标物体边缘是否平滑、左右目视差是否稳定。常见做法是找一个有明显深度差的物体比如桌面上的盒子然后用 stereoRectify 校正后的图像跑一次stereoSGBM。stereo cv2.StereoSGBM_create( minDisparity0, numDisparities64, blockSize11, uniquenessRatio10, speckleWindowSize100, speckleRange2, ) disparity stereo.compute(rect_left, rect_right).astype(np.float32) / 16.0对同一场景连续取 10 帧观察固定点视差的标准差如果在 2 像素以内说明整个链路稳定如果抖动很大先怀疑光线变化影响角点定位再怀疑立体校正映射表没有正确加载。泛化验证还有一个很容易踩的坑新场景图像分辨率必须和标定时一致如果采集脚本在标定后用成了另一个分辨率remap 映射表会自动失效。6. 将双目相机采集与标定打包成 Zip 交付文件组织与复跑技巧6.1 一个便于复跑的目录结构标题里的 zip 包如果让我来组织我会把采集、标定、配置和结果分开放并在 README 里写清楚左右目顺序。一个常见的目录结构如下stereo_capture_calib/ ├── README.md ├── capture.py ├── calibrate.py ├── config.yaml ├── board/ │ └── chess_9x6_20mm.pdf ├── images/ │ ├── left/ │ └── right/ └── output/ ├── stereo_params.yaml └── remap_maps.npzconfig.yaml保存分辨率、棋盘格内角点尺寸、左右目设备号。这样别人拿到包以后不需要改 Python 代码就能重新标定。棋盘格 PDF 也值得放进去因为很多人打开包时根本找不到标定板现场打印的棋盘格尺寸和代码里写的不一致标出来的 T 直接无法使用。6.2 打包时排除大文件和无效图像采集图像动辄几百 MB直接全部塞进 zip 会让人下载得很痛苦。常见做法是只保留标定时用到的有效帧或者干脆不打包 images而是给一个分批压缩的脚本。打包命令里可以用-x排除中间结果。cd stereo_capture_calib zip -r ../stereo_capture_calib.zip . -x images/*如果接收方需要完整复现把 images 单独压缩成 tar.gz 分开发主包保持小体积。标定结果output/remap_maps.npz是二进制浮点映射表不能在 README 里用文本贴出来必须随包一起发。很多工程出问题不是标定代码错了而是别人解压后没有重跑 remap直接拿原始图像去算视差。如果要给 README 写一句最重要的提示我会写这一句左目是设备号 0右目是设备号 1装反等于重新标定。本文还有配套的精品资源点击获取
返回列表