ARTICLE DETAIL

资讯详情

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

单目相机标定原理与OpenCV实现:从张正友法到工程落地

单目相机标定原理与OpenCV实现:从张正友法到工程落地 我最早接触相机标定是因为一个挺打脸的项目客户拿了一个固定式扫码相机说要测传送带上零件的长宽我拿着卷尺在旁边比了半天像素距离换算出来的尺寸就是差那么几个毫米。后来才明白问题不在测量公式而在相机自己“看”到的世界本来就是扭曲的。你按理想针孔模型去换算越靠图像边缘误差越大。从那以后我就养成了一个习惯不管用什么相机干活先标定再谈后面的事。单目相机标定说简单也简单本质就是算出相机的内参焦距、主点、畸变系数和外参相机在世界坐标中的位姿。说复杂也复杂因为背后牵扯到成像模型、坐标系变换、非线性优化一堆概念。这篇我尽量用“能上手干活”的方式来讲先把原理讲到够用的程度再给一套基于OpenCV的完整实现流程最后把我踩过的一些坑也一并列出来。适合刚接触视觉、准备做测量/识别/定位项目、或者被标定精度折磨过的同学参考。1. 相机成像模型与畸变为什么要给镜头“配眼镜”1.1 从针孔模型说起三维坐标是怎么变成二维像素的理解标定之前先得建立一条完整链路真实世界里的一个三维点到底经历了什么才最终落在传感器上变成一个像素。经典的小孔成像模型是这样描述的假设光线通过一个无限小的孔在后方成像平面上留下倒像。数学上简化为“中心投影”一个三维点 (P_w (X_w, Y_w, Z_w)) 经过相机光心投影到成像平面上的位置。这个过程可以拆成四步坐标变换世界坐标系→相机坐标系用旋转矩阵 (R) 和平移向量 (t) 描述相机在世界中的位姿也就是外参。相机坐标系→归一化平面把三维点除以 (Z_c)得到归一化坐标 ((x, y) (X_c/Z_c, Y_c/Z_c))。归一化平面→图像物理坐标乘上焦距 (f)得到 ((f\cdot x, f\cdot y))。图像物理坐标→像素坐标加上主点偏移 ((c_x, c_y))再分别乘上 (x)、(y) 方向单位距离对应的像素数。整个投影过程用一个 3x4 的投影矩阵就能写清楚[ s \begin{bmatrix} u \ v \ 1 \end{bmatrix} \begin{bmatrix} f_x 0 c_x \ 0 f_y c_y \ 0 0 1 \end{bmatrix} \begin{bmatrix} r_{11} r_{12} r_{13} t_1 \ r_{21} r_{22} r_{23} t_2 \ r_{31} r_{32} r_{33} t_3 \end{bmatrix} \begin{bmatrix} X_w \ Y_w \ Z_w \ 1 \end{bmatrix} ]其中中间的 3x3 矩阵就是内参矩阵 (K)习惯上叫它 (K)。(f_x、f_y) 是焦距的像素表达实际是 (f/dx、f/dy)也就是焦距除以单个像素的物理尺寸(c_x、c_y) 是主点——理论上应该在图像正中心实际工艺会导致偏差几个像素这个偏差不标定是猜不准的。注意内参矩阵描述的是“相机自身的光学属性”只要镜头和传感器相对位置不变内参就不变。变的是外参因为每次移动相机(R) 和 (t) 都会跟着变。OpenCV的calibrateCamera能同时输出内参和每张图的外参但工程上我们通常只留内参和畸变系数外参用solvePnP按需实时解算。1.2 镜头畸变让直线变弯的罪魁祸首针孔模型是理想状态现实中的镜头是一组透镜光线穿过透镜会发生折射不可能完美地汇聚到理想位置。于是照片就产生了畸变主要分两类。径向畸变图像边缘的直线向内或向外弯曲。向内弯叫枕形畸变向外弯叫桶形畸变广角镜头尤其明显。原因是透镜中央和边缘的放大率不一致。数学上用 (k_1、k_2、k_3) 三个系数来修正修正公式是[ \begin{aligned} x_{corrected} x(1 k_1 r^2 k_2 r^4 k_3 r^6) \ y_{corrected} y(1 k_1 r^2 k_2 r^4 k_3 r^6) \end{aligned} ]其中 (r) 是点到主点的距离。可以看到距离主点越远(r) 越大畸变修正量越大所以图像四角的变形最明显。切向畸变镜头装配偏差导致透镜与传感器平面不完全平行造成的会让直线在某个方向上产生横向偏移用 (p_1、p_2) 两个系数修正[ \begin{aligned} x_{corrected} x [2p_1 xy p_2(r^2 2x^2)] \ y_{corrected} y [p_1(r^2 2y^2) 2p_2 xy] \end{aligned} ]所以标定输出的畸变系数完整是五元组 ((k_1, k_2, p_1, p_2, k_3))OpenCV的calibrateCamera默认返回5个值。有些精度要求不高的场景只用前两个径向畸变系数也能凑合但正式项目我还是建议5个全求。1.3 畸变系数到底在修正什么很多人看完公式会问这些系数不就是把图像重新扭曲一遍吗对也不全对。畸变系数的本质是建立一条“真实光线方向→像素位置”的映射曲线。你去畸变的时候不是简单地把像素挪一挪而是对整幅图像做重映射remap每个像素都根据它离主点的距离重新采样。OpenCV里的undistort和remap就是这个原理。我曾经有个项目用的是120度的广角工业相机不标定时画面边缘的直线明显弯曲用undistort处理之后整幅图看起来“正常”多了。但这个“正常”是有代价的去畸变后的图像边缘会有黑边或裁切有效视野变小。所以后来的项目里我宁愿保留畸变图像做识别只在需要测量/定位的环节才做去畸变有时候直接用畸变系数把检测到的像素坐标反向修正效率比全图重映射高得多。2. 张正友标定法一张棋盘格如何算出相机全部参数2.1 为什么是棋盘格角点提取的天然优势提到标定就绕不开张正友标定法它解决的问题是在没有昂贵3D标定物的情况下怎么用平面棋盘格求出相机的内外参。这篇论文1998年发表至今仍是OpenCV、MATLAB标定工具箱的底层核心。棋盘格之所以成为标定板的绝对主流是因为它的角点特征极其稳定。黑白格子交替排列角点处灰度变化剧烈findChessboardCorners这类算法能够精准定位亚像素角点。而且棋盘格是平面结构所有角点都在同一个平面上这个约束给求解提供了极大便利。实际操作中用到的棋盘格通常是 9x6 或者 7x10这里的数字指的是内部角点数不是格子数。比如 9x6 的棋盘格实际上有10列7行格子角点是格子与格子之间的交点。这一点经常有人搞混用OpenCV时findChessboardCorners的patternSize参数填的就是内部角点数 ((9, 6))填错就检测不出来。2.2 单应性矩阵里藏着什么张正友法的核心是单应性矩阵。三维空间中的平面点投影到图像平面是一个从平面坐标系到像素坐标系的齐次变换用 3x3 的单应矩阵 (H) 表示[ s \begin{bmatrix} u \ v \ 1 \end{bmatrix} K [r_1 \quad r_2 \quad t] \begin{bmatrix} X \ Y \ 1 \end{bmatrix} H \begin{bmatrix} X \ Y \ 1 \end{bmatrix} ]看这个式子(H) 包含了内参 (K)、旋转矩阵的前两列 (r_1、r_2) 和平移向量 (t)。棋盘格上每个角点的世界坐标 ((X, Y)) 是已知的我们设定它比如角点间距30mm像素坐标 ((u, v)) 是检测出来的一组点就能建立一个方程组一对 ((X, Y)) 对应一个 ((u, v))可以提供2个约束方程所以至少4个对应点就能解出 (H)。而一张棋盘格上有几十个角点(\textbf{H}) 的求解是超定问题用最小二乘就能稳定输出。2.3 从多张照片联立内参的数学思路一张棋盘格照片只能提供一个单应矩阵 (H)而 (H) 里同时混着内参和外参。要单独把内参 (K) 解出来需要利用旋转矩阵的正交约束。旋转矩阵的每一列是单位向量且两两正交。所以 (r_1、r_2) 满足 (r_1^T r_2 0) 且 (|r_1| |r_2|)。这两个约束反映到 (H [h_1 \quad h_2 \quad h_3]) 上就变成了关于内参 (K) 的两个方程。每个方程本质上是对 (K^{-T}K^{-1}) 这个对称矩阵的元素做线性约束所以一张图给2个约束内参矩阵有4个未知量fx, fy, cx, cy需要考虑畸变后再定不考虑畸变时理论上2张图就能解出内参但实际中为了数值稳定和精度至少拍10~20张不同角度的图。这就是为什么OpenCV标定也好MATLAB标定App也好都要求多角度采集。不是算法需要那么多是噪声、光照、检测误差都需要数据量来摊平。2.4 非线性优化让参数更准的最后一步线性求解出来的内外参只是个初值因为上面所有的推导都忽略了畸变而且角点检测本身有亚像素噪声。所以张正友法的最后一步是非线性最小二乘优化工具上对应OpenCV的calibrateCamera里内置的Levenberg-Marquardt迭代。优化的目标函数是重投影误差的平方和[ \min_{K, D, R_i, t_i} \sum_{i1}^{N} \sum_{j1}^{M} \left| p_{ij} - \hat{p}(K, D, R_i, t_i, P_j) \right|^2 ]翻译成人话把第 (i) 张图里第 (j) 个角点的三维坐标 (P_j)用当前的内外参重新投影到像平面上得到预测位置 (\hat{p})和实际检测到的像素位置 (p_{ij}) 求距离。所有图所有角点的距离平方求和然后不断迭代参数让这个总误差最小化。这一步也是为什么OpenCV标定之后会返回一个RMS值重投影误差均方根我通常把它当作标定质量的第一道检验指标经验值后面会细说。3. 基于OpenCV的标定全流程从拍照到参数落地3.1 标定板准备规格选择与打印要注意的事标定板网上有现成的PDF可以直接打印。选型我总结了几条经验精度要求0.1mm以上的项目别用A4纸打印用亚克力或玻璃基底的工业标定板。纸张热胀冷缩贴在不平桌面上会局部变形角点位置偏移直接传导到内参上。我见过最夸张的一次打印纸受潮翘边标定出来fx差了2%。格子尺寸用游标卡尺实测别信打印参数。打印机的缩放误差不定直接量实际边长填进去比用理论值准得多。棋盘格内部角点数量选9x6比较均衡角点太多在边缘视野里容易被裁掉太少约束不够。如果一定要自己打印应急最好贴在硬质平板铝板、亚克力板上拍的时候尽量用手指按压四边确保平整。3.2 图像采集影响精度的几个关键习惯图像采集是整个标定流程里最容易被低估的环节。代码写得再漂亮输入数据烂输出参数就不可能好。我总结的采集标准如下表项目建议值原因图片数量15~20张太少约束不足太多增加计算耗时且边际收益递减标定板占画面比例1/4 ~ 3/4太小角点不清晰太大超出视野角度变化与相机光轴夹角15°~30°张正友法需要非平面角度全正对着拍会退化成病态问题覆盖范围画面九个区域都要拍到边缘畸变信息必须靠标定板出现在边缘来激励光照均匀、无强烈反光反光会让角点检测失效相机设置固定光圈、固定焦距手动对焦自动对焦/自动光圈会让内参变化标定结果失去意义另外拍照时标定板尽量保持静止避免运动模糊。手持也行但快门速度要够。我用工业相机拍摄时习惯把曝光固定在一个不过曝的值然后连拍几十张再筛选比一张张摆姿势节约时间。3.3 完整标定代码与参数保存下面是基于OpenCV的完整标定代码我用了PythonC思路完全一样。代码接受一个文件夹作为输入读取里面所有jpg图片检测9x6棋盘格角点然后标定并保存结果。import cv2 import numpy as np import glob import json # 配置参数 CHESSBOARD_SIZE (9, 6) # 内部角点数 SQUARE_SIZE 30.0 # 实际格子边长单位mm image_folder ./calib_images/ # 图像文件夹 output_file ./camera_params.json # 1. 准备标定板物理坐标 objp np.zeros((CHESSBOARD_SIZE[0] * CHESSBOARD_SIZE[1], 3), np.float32) objp[:, :2] np.mgrid[0:CHESSBOARD_SIZE[0], 0:CHESSBOARD_SIZE[1]].T.reshape(-1, 2) objp * SQUARE_SIZE # 2. 遍历图像收集角点 obj_points [] # 3D 物理坐标 img_points [] # 2D 像素坐标 image_size None images glob.glob(image_folder *.jpg) valid_count 0 for fname in images: img cv2.imread(fname) if img is None: continue gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if image_size is None: image_size gray.shape[::-1] # 查找棋盘格角点 ret, corners cv2.findChessboardCorners(gray, CHESSBOARD_SIZE, None) if ret: # 亚像素精化 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners_refined cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners_refined) valid_count 1 # 可视化确认 vis cv2.drawChessboardCorners(img, CHESSBOARD_SIZE, corners_refined, ret) cv2.imshow(check, vis) cv2.waitKey(100) else: print(f角点检测失败: {fname}) cv2.destroyAllWindows() print(f有效图像数: {valid_count} / {len(images)}) if valid_count 5: raise RuntimeError(有效图像太少标定结果不可靠) # 3. 执行标定 rms, camera_matrix, dist_coeffs, rvecs, tvecs cv2.calibrateCamera( obj_points, img_points, image_size, None, None ) print(RMS 重投影误差:, rms) print(内参矩阵:\n, camera_matrix) print(畸变系数:\n, dist_coeffs.ravel()) # 4. 保存结果 result { rms: float(rms), camera_matrix: camera_matrix.tolist(), dist_coeffs: dist_coeffs.ravel().tolist(), image_size: list(image_size), square_size_mm: SQUARE_SIZE, } with open(output_file, w, encodingutf-8) as f: json.dump(result, f, indent2, ensure_asciiFalse) print(标定结果已保存到:, output_file)代码逻辑不复杂但有几个点值得强调objp的 z 坐标全为0因为标定板是平面。这个平面就是世界坐标系的 XY 平面。cornerSubPix的亚像素精化很重要直接决定角点精度。窗口大小(11, 11)足够太大反而容易引入邻近格子的干扰。calibrateCamera一次输出内参、畸变系数、每张图的外参旋转向量和平移向量。外参如果要转4x4齐次矩阵用cv2.Rodrigues把旋转向量转成旋转矩阵。3.4 验证标定结果去畸变与重投影误差拿到参数不要急着用先做两项验证。第一项看RMS。calibrateCamera返回的rms单位是像素。经验值如下RMS值评价建议 0.1优秀高精度测量项目可用0.1 ~ 0.3良好大多数视觉定位/识别项目可用0.3 ~ 0.5及格简单手势识别/粗略定位可用 0.5差检查角点检测和图像质量重新标定第二项看实际去畸变效果。用棋盘格和标定后的参数做去畸变观察画面边缘的直线是否恢复平直。import cv2 import numpy as np import json # 读取标定结果 with open(./camera_params.json, r, encodingutf-8) as f: calib json.load(f) camera_matrix np.array(calib[camera_matrix]) dist_coeffs np.array(calib[dist_coeffs]) image_size tuple(calib[image_size]) # 计算去畸变映射表 new_camera_matrix, roi cv2.getOptimalNewCameraMatrix( camera_matrix, dist_coeffs, image_size, alpha0, newImgSizeimage_size ) mapx, mapy cv2.initUndistortRectifyMap( camera_matrix, dist_coeffs, None, new_camera_matrix, image_size, cv2.CV_32FC1 ) # 加载一张标定测试图做验证 img cv2.imread(./calib_images/verify.jpg) if img is not None: undistorted cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR) compare np.hstack([img, undistorted]) cv2.imshow(before | after, compare) cv2.waitKey(0) cv2.destroyAllWindows()getOptimalNewCameraMatrix里有个alpha参数注意它的含义alpha0所有像素都保留但会有黑边视野最小。alpha1裁掉所有无效区域无黑边视野稍微变小。alpha0.5折中。去畸变本质是重新采样所以只需要计算一次映射表之后每一帧直接用remap比每帧调用undistort更快。工控机上实测1080p图像单次remap大概3~5ms够实时。4. 标定实操中的坑光照、角点检测失败与精度自查4.1 角点检测失败的常见原因与解决最早用findChessboardCorners的时候我总以为只要把棋盘格放在镜头前就一定能检测出来结果实际拍摄中经常检测失败尤其是边缘位置、反光区域。逐条排查下来原因基本集中在下面几类反光亚克力标定板在强光下会出现高光斑块角点被亮斑吞掉。处理办法是调整光源角度用柔光或者用哑光面朝相机。过曝/欠曝智能手机拍的图自动曝光导致黑白格子反差变小。工业相机就固定曝光时间让白色格子亮度在200~230之间黑色在30~60之间对比度最大检测率最高。棋盘格太小角点占的像素太少亚像素精化起不到作用。我一般要求格子边长的成像宽度至少在15像素以上。棋盘格太满占满整个画面且偏斜时边缘格子容易被裁掉一半内部角点计数对不上。回到3.2的“占画面1/4~3/4”建议。一旦某张图检测失败我会在采集阶段就当场删除而不是后面靠代码筛选。代码里加个可视化窗口每拍一张就画上角点给人眼确认这种“人工质检”比任何自动筛选都高效。4.2 拍了一堆图误差却很大的排查思路有一次我帮同事标定一个鱼眼镜头改装的工业相机采集了20张图RMS飙到0.8。排查过程值得分享先看是不是某张图拖后腿。OpenCV的calibrateCamera不直接输出逐图误差但可以用projectPoints自己算。把每张图的平均重投影误差打印出来定位到误差异常大的图。看那张图是不是标定板太偏。结果显示所有误差大的图标定板都出现在画面左下角边缘而且在极端倾斜角度。边缘畸变严重时角点检测精度本来就下降极端角度又让部分角点在亚像素层面对不齐。剔除极端图重标。砍掉那几张误差最大的图RMS立刻降到0.15。这个经验后面变成了标准流程先粗标定再逐图误差分析剔除异常图再精标定一次。别想着一把过标定本身就是迭代活。附一个逐图误差计算的代码片段def per_image_error(obj_points, img_points, rvecs, tvecs, camera_matrix, dist_coeffs): errors [] for i in range(len(obj_points)): proj_points, _ cv2.projectPoints( obj_points[i], rvecs[i], tvecs[i], camera_matrix, dist_coeffs ) err np.mean(np.linalg.norm(img_points[i].reshape(-1, 2) - proj_points.reshape(-1, 2), axis1)) errors.append(err) return errors4.3 不同场景要不要重新标定很多人以为标定一次一劳永逸实际上有几个硬性条件拧过镜头就要重新标定。镜头焦距环、光圈环一旦变动内参全变。工业镜头锁紧后一般不会动但运输震动可能导致松动。换相机重新标定。甚至同型号不同个体也有细微差异严格来说每台相机都有自己的内参。温度变化大的环境要留意。工业现场温度从-10°C到50°C金属镜筒热胀冷缩会让内参漂移。高精度项目要考虑在工作温度下标定甚至定期复标。我见过一个仓储测量项目相机装在升降机上每天升降震动半年后测量误差从1mm飘到5mm最后排查发现是镜头松了重新标定加镜头胶固定解决。标定是手段不是目的维护好相机机械结构同样重要。5. 标定结果在项目中的落地与扩展方向5.1 内参标定和九点标定、手眼标定不是一回事这个话题我每次在交流群里都得解释一遍。相机内参标定解决的是“像素坐标和相机坐标系下真实方向之间的关系”。而工程上经常听到的九点标定解决的是“像素坐标和执行机构坐标比如机械臂基座坐标之间的仿射变换”。九点标定通常在平面上进行让机械臂带着针尖或者用激光点走9个已知位置同时在图像里记录像素坐标然后算一个3x3的仿射/透视变换矩阵。这个过程没有“相机内参”的概念直接把像素坐标和机器人坐标之间建立映射。如果你的项目是平面抓取相机垂直安装九点标定确实够用。一旦相机倾斜安装或者目标不在同一平面九点标定就不灵了这时候才需要走“内参标定 solvePnP求位姿 手眼标定”的完整链路。手眼标定则是求解相机坐标系和机械臂末端坐标系之间的固定变换分eye-in-hand相机装在机械臂末端和eye-to-hand相机固定在外界两种。它依赖内参标定结果作为输入属于外参层面的事。单目相机标定只是第一步后面要做空间抓取手眼标定绕不开。5.2 从单目到双目外参标定与深度恢复单目相机标定完只能得到像素对应的射线方向没有深度信息。双目相机则通过左右两幅图像的视差恢复深度这时的标定就要升级了先分别标定左右相机的内参和畸变。再做双目立体标定求解左右相机坐标系之间的旋转矩阵 (R) 和平移向量 (t)也就是外参。用stereoRectify做极线校正让左右图像行对齐。OpenCV里有对应接口stereoCalibrate和stereoRectify原理和单目标定一脉相承只是把约束条件扩展到了双目之间的几何关系。从单目到双目的升级最核心的就是把“单相机内参”这个地基打牢否则后面立体匹配全是歪的。如果你用zed这类自带出厂标定的双目相机官方给的参数一般够用但出厂标定是在特定温度和机械状态下做的高精度场合建议自己重新标一遍。5.3 和SLAM、视觉导航结合的典型用法这几年视觉SLAM火ORB-SLAM、VINS-Mono这类开源方案里相机内参都是第一道输入。SLAM系统初始化时要用内参把像素坐标反投影成归一化平面坐标再进行特征三角化和位姿估计。内参偏一点三角化出来的深度就偏一点累积下去轨迹就飘了。我实测过一个项目用同一组图像分别用校准过的内参和直接按厂商标称内参跑VINS-Mono轨迹误差差了将近30%。厂商标称值只能作为初值参考工程落地必须实测标定。在视觉导航这个方向上标定结果通常还要和IMU的外参联合标定也就是热词里常见的imu雷达外参标定、lidar imu标定这条路。IMU给出加速度和角速度相机给出视觉观测两者融合需要知道相对位姿。这一块有现成工具如Kalibr、livox_camera_lidar_calibration但前置条件都是相机内参已经标准了。基础不牢上层融合全白搭。顺带提一句汽车电子圈说的“canoe标定、canape标定”是ECU参数标定和相机标定完全是两码事别混为一谈。做视觉的人听到“标定”二字先分清是哪一层相机内参手眼外参还是传感器联合外参对应的方法论和工具完全不同。我个人现在的习惯是新相机到手先做一次完整标定把参数存成json在任何项目里都以这份参数为基准隔三个月或仪器动过之后重新花10分钟复标一次。标定这件事投入产出比极高十分钟换来的是一劳永逸的坐标可信度。希望这篇能把大家领进门少走我当初走过的弯路。
返回列表