ARTICLE DETAIL

资讯详情

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

OpenCV棋盘格相机标定全流程:原理、采图、代码与验证

OpenCV棋盘格相机标定全流程:原理、采图、代码与验证 标定这件事我在视觉项目里几乎每次都要碰一遍。最早做视觉引导抓取的时候我对着相机拍了二十来张棋盘格照片用OpenCV自带的例程跑了一遍看到控制台输出RMS只有0.3像素以为万事大吉。结果机械臂一抓就偏了几毫米回头检查才发现是标定板没贴平、照片角度又太单一内参矩阵看着正常实际投影全歪了。从那以后我养成了一个习惯标定从来不看单个输出值而是从数据采集开始就层层把关。这篇文章就把OpenCV棋盘格相机校准这条链路完整拆开讲清楚包括针孔模型和畸变到底是怎么回事、棋盘格照片怎么拍才能喂出好结果、findChessboardCorners到calibrateCamera的完整调用逻辑、以及标定完怎么验证、怎么把内参矩阵用到实际的去畸变和像素坐标换算里。适合刚接触相机标定、或者标定结果总是不稳定想排查问题的朋友我会把容易踩的坑和排错思路一并写出来。1. 标定到底在解什么方程从针孔模型到畸变补偿1.1 针孔成像与相机内参矩阵先把模型摆清楚。理想情况下相机就是一个针孔成像装置三维空间里的点通过光心投影到成像平面上。OpenCV里用的标准投影公式长这样s * [u, v, 1]^T K * [R | t] * [X, Y, Z, 1]^T其中[X, Y, Z, 1]是世界坐标下的物方点[R | t]是外参把世界坐标变换到相机坐标K是内参矩阵把相机坐标投影到像素平面s是尺度因子因为像素坐标是齐次坐标缩放多少倍都表示同一个点。内参矩阵具体展开是K [[fx, 0, cx], [ 0, fy, cy], [ 0, 0, 1]]fx和fy是焦距的像素表达等于物理焦距除以单个像素的尺寸注意如果像素不是正方形fx和fy会有差异cx和cy是主点理想情况下位于图像正中心实际制造和装配误差会导致它偏移几十个像素都很正常。外参描述的是棋盘格在世界坐标系中的位置和姿态每拍一张照片就有一组不同的R和t。标定过程本质上就是喂进去几十组已知三维坐标 检测到的二维像素坐标的对应关系通过最小化重投影误差同时求解内参和每张图的外参。棋盘格的物方点坐标是我们自己定义的这也是棋盘格标定的核心优势——标定板给了我们精确的已知几何。1.2 径向畸变与切向畸变的物理来源真实镜头不是完美针孔。首先最常见的径向畸变来自镜片的曲面形状光经过透镜边缘时折射角度和理想模型差了表现为画面里的直线向里凹桶形畸变或向外凸枕形畸变。OpenCV里用k1, k2, k3三个系数描述归一化坐标下的畸变公式是x_distorted x * (1 k1*r^2 k2*r^4 k3*r^6) y_distorted y * (1 k1*r^2 k2*r^4 k3*r^6)r是像素点到主点的归一化距离。可以看到畸变是距离主点越远越明显所以画面边缘的直线弯得最厉害。广角和超广角镜头高阶项影响很大一般需要标定到k3甚至更多项这时候就要给calibrateCamera加CALIB_RATIONAL_MODEL标志才能解出k4, k5, k6。切向畸变则源于镜头和成像平面不平行多是因为装配工序里镜片倾斜或者传感器贴片歪了用p1, p2两个系数描述。普通镜头切向畸变通常很小但如果你的相机是拆过机、换过镜头座的p1, p2可能会异常大这时候就别加CALIB_ZERO_TANGENT_DIST去强制归零。这里有个容易误解的地方畸变模型是在归一化坐标上计算的不是直接在像素坐标上套公式。calibrateCamera内部会先把像素坐标通过内参逆变换到归一化平面应用畸变再投影回去。我们写代码时不需要手动做这一步但理解了这个流程后面看undistortPoints的行为就会很清楚。1.3 为什么要用棋盘格而不是别的图案棋盘格能成为标定届的标准答案是因为角点检测极其稳定。黑白方块交界处的角点在图像里是一个鞍点周围像素梯度方向明确亚像素级精确定位非常可靠。相比圆点阵列棋盘格角点没有遮挡歧义即使图像旋转180度角的特征还是那个角。另一个关键点是棋盘格的物方坐标天生简单。建立一个以棋盘格平面为Z0的世界坐标系所有角点的三维坐标就是(列索引 * 方格边长, 行索引 * 方格边长, 0)一行代码就能构造出来。这也是为什么标定板上方格的物理尺寸必须量准因为它直接决定了物方坐标的尺度。要注意的是这个尺度的数值不会影响内参fx, fy是像素单位它影响的只是外参中的平移向量和后续三维重建的尺度。2. 采集棋盘格照片标定成败的80%在这里2.1 棋盘格打印与粘贴的细节很多人标定结果不理想问题根本不在代码而在那张棋盘格标定板。我的建议是别用A4纸直接打印了贴在桌上拍纸张一旦受潮或没有完全贴平角点位置就会有亚毫米级的起伏这些误差会直接进入标定结果。要打印也至少贴在硬纸板或者亚克力板上确保整个棋盘格是一个严格的平面。棋盘格的行列数选择也有讲究。OpenCV的findChessboardCorners参数是内部角点数不是方块数。比如标定板图案是10×7个方块内部角点就是9×6代码里写(9, 6)。这个顺序不要搞反(9, 6)表示水平方向9个角点、垂直方向6个角点和图像里棋盘格是横放还是竖放有关。很多新手在这一步反复报错检测不到角点多半就是这个元组和实际图案对不上。单元格边长用卡尺量不要相信打印设置里写的理论尺寸。实际打印会有缩放我见过打印出来的格子比设计值小了1%的标定单目内参问题不大但如果要做双目或者结构光三维重建这个误差就会变成深度方向的系统性偏差。角点数量建议至少8×6太少的话角点约束不足太多对焦和分辨率要求又高9×6、10×7是比较稳的区间。2.2 拍摄数量和位姿的黄金准则标定板照片的理想数量我的经验是15到25张。少于10张基本不够覆盖视场各区域的畸变信息多于30张边际收益就很有限了反而增加处理时间。比数量更重要的是位姿的多样性。畸变参数和主点位置要想被约束住棋盘格必须出现在画面的各个位置和角度。我在实际采集中会刻意覆盖下面这几种位姿棋盘格在画面中央、正对相机拍两三张作为基准棋盘格分别出现在画面左上、右上、左下、右下、边缘区域每处一两张棋盘格绕水平轴倾斜30到45度拍几张绕垂直轴倾斜拍几张棋盘格绕光轴旋转不同的角度避免角点排列方向单一远近交替让棋盘格在画面中占比约三分之一到三分之二之间变化有个新手常犯的错误把所有照片都拍成棋盘格在中间、只是远近不同地移动这在算法眼里等于只有一组外参的微小变化内参和畸变约束不足标定结果虚高但实际很脆弱。另一种极端是倾斜角度太大比如接近90度角点检测容易失败或者棋盘格部分出画直接丢弃。画面的占比也很重要。棋盘格太小角点亚像素定位的精度不够太大可能超出镜头清晰成像范围导致边缘模糊一般让棋盘格最长边占画面宽度的50%到70%比较合适。2.3 容易毁掉整个数据集的五个操作这些坑我基本都踩过逐个说每一条都值得在采集前确认一遍第一对焦没有锁定。自动对焦在每次拍摄之间改变焦点镜头内部镜片位置变了内参焦距也跟着变标定结果必然不准。用工业相机就手动固定对焦环用手机或USB摄像头就选固定焦距模式确定后再拍中间不要碰镜头。第二运动模糊。手持相机边晃边拍角点检测虽然大多能通过但亚像素定位精度大幅下降。我一般把快门速度提高到至少1/250秒使用足够亮的均匀光源必要时上三脚架。第三反光和阴影。强光在棋盘格表面上形成镜面反射高光区域角点会检测偏移不均匀阴影会让二值化阈值失效。拍摄环境最好是柔和的均匀光如果反光严重稍微转动标定板角度避开。第四同一批标定数据集混入不同分辨率或不同裁切方式的图像。OpenCV的calibrateCamera要求所有图像尺寸一致混了不同分辨率不仅报错或者干脆得到垃圾内参而且很难排查。我见过有人把相机横拍竖拍的素材混在一起标定fx和fy的符号都乱了。第五标定板在画面里被遮挡或不完整。只要棋盘格有一个角被手指或物体挡住整张图就要重拍。检测的时候OpenCV对不完整棋盘格要么报错要么检测出一堆错误的角点后一种情况尤其阴险。我会在采集后统一跑一遍角点检测把每张图的检测结果画出来人工扫一眼。3. 核心代码链路角点检测到 calibrateCamera 的完整调用3.1 构建物方点为什么三维坐标只在XY平面前面说过物方点坐标系直接建在棋盘格平面上Z坐标恒为0。这段代码把索引网格转成实际物理坐标import numpy as np import cv2 import glob CHECKERBOARD (9, 6) # 内部角点数: (水平方向, 垂直方向) SQUARE_SIZE 0.025 # 方格边长, 单位米 objp np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32) objp[:, :2] np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2) objp * SQUARE_SIZEnp.mgrid生成的是两个网格矩阵.T转置后再reshape得到的是从左到右、从上到下排列的角点索引。这个顺序必须和findChessboardCorners检测回来的角点顺序一致OpenCV检测角点时默认是按行从左上角开始的所以用这个标准构造方式就不会对应错。objp * SQUARE_SIZE这一步把索引值变成米制坐标。这里有个值得说的细节对内参标定而言物方坐标的单位其实不影响内参矩阵因为尺度信息被吸收到了外参的平移向量里。但建议还是老老实实填真实单位一方面重投影误差的数字比较直观单位一致误差才是像素级另一方面后续如果要做位姿估计或者三维重建外参直接用就是真实尺度。3.2 findChessboardCorners 与 cornerSubPix 的配合逻辑检测角点分两步第一步是粗定位第二步是亚像素精化images sorted(glob.glob(calib_images/*.jpg)) print(f找到 {len(images)} 张图像) objpoints [] # 三维物方点列表 imgpoints [] # 二维像素角点列表 valid_images 0 for fname in images: img cv2.imread(fname) if img is None: print(f无法读取: {fname}) continue gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 粗定位 ret, corners cv2.findChessboardCorners(gray, CHECKERBOARD, None) if ret: # 亚像素精化 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) corners cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) objpoints.append(objp) imgpoints.append(corners) valid_images 1 # 可视化, 确认角点顺序和位置 vis cv2.drawChessboardCorners(img.copy(), CHECKERBOARD, corners, ret) cv2.imshow(corners, vis) cv2.waitKey(50) else: print(f未检测到角点: {fname}) cv2.destroyAllWindows() print(f有效图像 {valid_images} 张)findChessboardCorners默认用全局二值化阈值找角光照不均时会失败可以加cv2.CALIB_CB_ADAPTIVE_THRESH标志启用自适应阈值如果只是想做快速筛选加cv2.CALIB_CB_FAST_CHECK会先快速判断图像里有没有可能的角点能省不少时间但偶尔会漏检正式标定我建议不开这个。cornerSubPix的作用是利用角点邻域的灰度梯度把整数像素坐标精化到亚像素。窗口大小(11, 11)是搜索邻域(-1, -1)表示不设零区域终止条件是达到30次迭代或者误差小于1e-6。亚像素精化这一步对最终标定精度提升非常明显千万不要省略。3.3 calibrateCamera 返回值拆解与常见flags角点收集完毕一行代码就能跑标定ret, mtx, dist, rvecs, tvecs cv2.calibrateCamera( objpoints, imgpoints, gray.shape[::-1], None, None ) print(fRMS重投影误差: {ret:.4f}) print(f相机内参矩阵:\n{mtx}) print(f畸变系数:\n{dist.ravel()})gray.shape[::-1]传的是图像尺寸格式是(width, height)这个顺序极其容易搞错。传入的灰度图是(height, width)所以要用倒序切片。如果摄像头分辨率是1280×720这里必须传(1280, 720)。返回值逐个说ret所有角点重投影误差的均方根RMS单位像素。这是标定质量最直观的指标后面单独展开讲。mtx3×3内参矩阵。dist畸变系数标准模型下是(k1, k2, p1, p2, k3)按这个顺序排布。如果只解4个系数就是(k1, k2, p1, p2)。rvecs和tvecs每张图对应的旋转向量和平移向量是世界坐标到相机坐标的外参。rvecs[i]用cv2.Rodrigues可以转成3×3旋转矩阵。有时候标定结果里内参会变得很奇怪比如fx和fy差得离谱或者主点跑到图像外面。除了数据本身的问题可以试试给calibrateCamera加标志位约束。常用的情况cv2.CALIB_FIX_ASPECT_RATIO固定fx/fy的比值适合方形像素的相机可以减少未知参数数量。cv2.CALIB_ZERO_TANGENT_DIST强制切向畸变系数为0对于装配良好的镜头可以加但如果切向畸变真实存在加了反而劣化结果。cv2.CALIB_FIX_PRINCIPAL_POINT把主点固定在图像中心只在图像数很少、怀疑主点解算不稳的时候用。cv2.CALIB_RATIONAL_MODEL启用k4, k5, k6高阶径向畸变模型广角镜头需要。这些flags不是越多越好。我的经验是普通镜头默认参数标出来就很稳定只有当结果异常时才针对性地加约束。3.4 保存与加载标定结果标定是一次性的工作但结果要在每次启动程序时复用。我习惯把内参和畸变系数存成npz文件np.savez(calib_result.npz, mtxmtx, distdist, new_mtxnew_mtx, roiroi)读取时data np.load(calib_result.npz) mtx data[mtx] dist data[dist]一个细节如果用了getOptimalNewCameraMatrix算过新的内参矩阵和有效区域我建议把new_mtx和roi一并存下来因为去畸变时需要用到省得每次重新计算。文件命名里带上相机型号和焦距比如cameraA_12mm.npz因为你可能有好几台相机、好几个镜头混用标定结果是灾难。4. 标定结果怎么看重投影误差与逐图排查4.1 RMS误差的合理范围calibrateCamera返回的ret是RMS重投影误差意思是说把所有检测到的角点用求出来的内外参重新投影回图像平面和原始检测位置的像素距离的均方根。这个数字怎么看我的经验值RMS数值质量评价说明 0.1 像素非常好数据质量很高通常出现在高分辨率工业相机理想光照固定工装0.1 ~ 0.3 像素正常可用大多数实际项目在这个范围足够做测量和位姿估计0.3 ~ 0.5 像素勉强能用需要检查数据可能有轻微反光或平面度问题 0.5 像素不合格必须排查数据采集问题直接用于测量项目会出偏差需要提醒的是RMS小并不绝对等于标定准。如果拍的照片位姿过于单一算法可能把误差吸收到内参的错误估计里RMS依然很小。所以RMS只是一个必要不充分条件还要配合后面的内参合理性检查和实际验证。4.2 逐张图片的误差分析代码全局RMS只能告诉你整体情况要定位是哪张图拖累了精度得逐图计算。用cv2.projectPoints把物方点投影回去和检测到的像素点比距离total_error 0 for i in range(len(objpoints)): proj_points, _ cv2.projectPoints( objpoints[i], rvecs[i], tvecs[i], mtx, dist ) err cv2.norm(imgpoints[i], proj_points, cv2.NORM_L2) / len(proj_points) total_error err print(f图 {i}: 平均重投影误差 {err:.4f} px) print(f全部图像平均误差: {total_error / len(objpoints):.4f} px)运行这段代码很快就能发现某些图像误差明显高于其他图。我遇到的情况大多是某张图棋盘格发生了轻微运动模糊、某张图的棋盘格被手挡住半边、或者某张图的棋盘格贴的纸面翘起了一个角。把误差过大的图像直接剔除重新标定RMS通常能显著改善。这种逐图排查在团队协作时尤其有用。别人递过来一套标定素材直接跑这个脚本一眼就能判断哪些图能用哪些不能用不用盲猜。4.3 内参矩阵的合理性检查拿到内参矩阵后先别急着拿去用做一个直观的合理性检查h, w gray.shape[:2] fx, fy mtx[0, 0], mtx[1, 1] cx, cy mtx[0, 2], mtx[1, 2] print(f图像尺寸: {w} x {h}) print(ffx{fx:.2f}, fy{fy:.2f}) print(f主点({cx:.2f}, {cy:.2f})) print(ffx/fy 比值: {fx / fy:.6f})正常情况下fx和fy应该非常接近正方形像素且为正值fx/fy的偏差在千分位量级。如果比值偏差超过了几个百分点要么是像素确实非正方形比如某些模拟摄像头采集卡要么标定数据有问题。cx应该在图像宽度一半附近cy在高度一半附近偏差几十像素正常偏差几百像素就要警惕了。焦距的物理量级也可以估算验证如果传感器像素尺寸是p微米焦距f毫米那么fx f / (p / 1000)。比如5mm镜头配2.2μm像素fx ≈ 2273如果标定出来差了一个数量级那一定哪里出了问题。畸变系数也有个经验范围普通镜头k1通常在-0.1到0.1之间k2比k1小一个量级p1, p2一般在0.01以下。如果标定出k1 -5这种天文数字基本可以确定数据有问题别信。不过鱼眼镜头除外鱼眼模型的畸变系数不在这个范围需要用cv2.fisheye模块单独处理。5. 去畸变与像素坐标反投影标定成果的工程落地5.1 三种去畸变方式的适用场景标定只是为了拿到内参和畸变系数最终目的是消除畸变对实际应用的影响。OpenCV提供了几种不同的用法选错会在性能和效果上出问题。方法一cv2.undistort直接去畸变undistorted cv2.undistort(img, mtx, dist)最简单适合单张图片的离线处理。缺点是没有考虑去畸变后图像边缘会产生黑边和无效区域而且每次都重新计算映射视频场景会比较慢。方法二getOptimalNewCameraMatrix配合undistorth, w img.shape[:2] new_mtx, roi cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), 1, (w, h)) undistorted cv2.undistort(img, mtx, dist, None, new_mtx) # 裁掉黑边区域 x, y, roi_w, roi_h roi undistorted undistorted[y:y roi_h, x:x roi_w]getOptimalNewCameraMatrix的alpha参数控制保留多少原图像素alpha1表示保留全部像素图像四角会有黑色区域alpha0表示只保留有效区域输出图像会被裁切放大等效视场角变小。实际项目里我一般设alpha1保留完整视场然后在后续算法里自行处理边缘这样不会丢失画面两侧的目标。方法三initUndistortRectifyMapremapmapx, mapy cv2.initUndistortRectifyMap( mtx, dist, None, new_mtx, (w, h), cv2.CV_32FC1 ) # 对每一帧 undistorted cv2.remap(frame, mapx, mapy, cv2.INTER_LINEAR)这是视频和实时应用的正解。映射表只需要计算一次之后每帧只是查表重映射比逐帧调用undistort快得多。注意映射表用CV_32FC1保存浮点坐标精度高内存占用也可接受。方法四只校正点坐标不校正图像pts np.array([[[u, v]]], dtypenp.float32) corrected cv2.undistortPoints(pts, mtx, dist, Pnew_mtx)如果应用只需要角点、圆心这类特征点的精确坐标完全不需要对整幅图像去畸变直接用undistortPoints把点坐标矫正过来就行省掉的图像变换时间非常可观。这个函数输出去畸变后的归一化坐标带P参数时可以同时转换到像素坐标。5.2 像素坐标转世界坐标单目测距思路标定结果最常见的落地需求之一是给定一个像素坐标反算它对应的空间位置。单目相机只有一张图深度信息天然缺失所以需要额外假设——最常见的是假设目标在某个已知高度的平面上或者目标深度已知。内参矩阵提供了针孔模型的逆投影关系在深度Z已知的情况下X (u - cx) * Z / fx Y (v - cy) * Z / fy这个公式我自己手推过好几遍关键点在于像素坐标先减去主点像素差乘以深度Z、除以焦距fx得到的就是以相机光心为原点的实际横向偏移。用这个公式可以实现单目测距、固定平面上目标定位、或者结合地面假设做车辆/行人的距离估算。举个例子如果标定得到fx 1000, cx 640目标中心在像素坐标(840, 480)已知目标在Z 2米的平面上那么X (840 - 640) * 2 / 1000 0.4 米 Y (480 - 480) * 2 / 1000 0 米目标在相机正前方偏右0.4米处。这个计算的精度依赖三件事内参标定准不准、深度假设准不准、以及像素坐标本身准不准。我之前一个抓取项目里因为忽略了目标物体不在同一平面的事实直接用平面假设算位置误差随着物体高度变化越来越大后来加了深度传感器才解决。5.3 我踩过的坑换镜头、变焦距后忘了重新标定标定结果只对那一台相机 那一个镜头 那一档焦距有效这可能是最容易被忽略的一点。我有个项目曾经排查了整整两天机械臂抓取偶尔偏重投影误差看着也正常最后发现是同事调试时把镜头的变焦环拧了一下。变焦改变的是镜片组位置焦距和畸变全部变了原来的标定参数自然全部失效。所以现在我的工程规范是镜头固定后打上标记点物理上防止误拧任何涉及镜头、相机模组的拆卸或调整都必须重新标定工业现场如果用了自聚焦镜头直接放弃标定的静态假设改用自动对焦触发时的动态标定方案或者干脆换固定焦距镜头标定记录要留档包括日期、相机型号、镜头型号、分辨率这样出问题能快速对比定位另一个容易忽略的是温度变化。工业现场冷启动和运行稳定后相机温度差上二三十度金属镜筒热胀冷缩会导致焦距轻微漂移对高精度测量项目影响很实际。如果你的项目对精度要求严苛可以考虑在系统热稳定后再做一次标定用热稳定后的参数。说实话OpenCV的棋盘格标定流程本身门槛并不高难的是对每一步结果保持怀疑和验证的习惯。我见过太多人跑出RMS看了个数字就收工结果后续应用里误差大得离谱才回头排查数据。标定这件事数据采集花的时间应该占七成代码反而是最容易的部分。如果你准备开始标定先别急着写代码把棋盘格贴平、把拍摄计划列好、把对焦锁定这几件事做到位后面基本都是一次成功。
返回列表