简介:相机内参标定是计算机视觉的基础环节,直接影响图像畸变校正与三维测量精度。这份练习包面向需要快速上手单目相机标定的学生、研究者或开发者,定位明确:通过棋盘格图像与Python脚本,解决相机焦距、主点坐标及畸变系数的求解问题。包内文件共6个,包含5张不同角度与距离拍摄的棋盘格PNG图像和1个Python标定脚本,压缩包整体约1.12MB,轻量易用,适合本地运行或二次修改,已有1063人学习/下载。使用者可替换图像、调整棋盘格尺寸,借助OpenCV完成角点检测、内参矩阵计算与畸变校正,直观理解成像模型与标定流程。资源可作为课程实验、毕业设计或入门项目的起点,衔接理论公式与代码实现,为后续三维重建、视觉测量等任务打下基础。
1. 棋盘格标定:把相机从“能成像”变成“能量测”
刚接触 camera 的工程师,往往先问“如何标定相机内参”;拿到一套棋盘格标定代码后,真正影响结果的又不只是代码本身。相机内参标定要回答的核心问题是:像素坐标怎么换回物理坐标?棋盘格标定则是实践中流传最广、门槛最低的解法——一张打印出来的黑白棋盘,贴在平整表面,用 OpenCV 就能同时得到内参矩阵和畸变系数。这件事看起来简单,坑却不少:图像拍得好看不代表标得准,张数堆得多也不代表误差低。我把棋盘格标定的几何依据、采集姿势、OpenCV 参数和评价方法拆开讲,新手能照着一个流程跑通,老手可以挑畸变模型、图像分布和单帧误差这些小节看。
2. 相机内参标定背后的几何:内参矩阵与畸变模型
2.1 针孔模型里的 fx、fy、cx、cy 到底表示什么
针孔相机模型把三维点 X=[X, Y, Z] 投影到像素坐标 u=[u, v]。忽略畸变时,投影写成齐次形式:λ[u, v, 1]^T = K[R|t][X, Y, Z, 1]^T。其中 K 是内参矩阵,R 和 t 是当前图像的外参,λ 是深度比例因子。棋盘格标定要解出的就是 K 和畸变系数,而不是 R、t。
内参矩阵 K 的具体形式是 [fx, 0, cx; 0, fy, cy; 0, 0, 1]。fx 是焦距在 x 方向上的像素长度,fy 是 y 方向上的像素长度。它们不是物理焦距 f,而是 f 除以单个像素在 x、y 方向的物理尺寸。现代标定算法不关心传感器像素尺寸,直接优化 fx、fy。cx、cy 是光轴穿过成像面的像素坐标,称为主点。很多人误认为 cx、cy 一定等于图像中心,实际上镜头安装偏心、传感器切割误差都会让主点偏离中心几十个像素。做测量时如果固定主点为图像中心,外参计算和高精度测距都可能出现系统性偏差。
2.2 径向畸变与切向畸变
内参矩阵之外,镜头还会引入畸变。OpenCV 默认使用五参数畸变模型:dist = [k1, k2, p1, p2, k3]。其中 k1、k2、k3 是径向畸变系数,描述图像点从理想位置沿半径方向移动的量。低端摄像头常见桶形畸变,长焦镜头则可能出现枕形畸变。k3 是为了补偿视场角很大的镜头边缘,广角和鱼眼镜头必须估计 k3,普通工业镜头可以先用 0 初始化再看误差。
p1、p2 是切向畸变系数,由镜头与传感器平面不平行引起。棋盘格标定里,切向畸变通常比径向畸变小一个数量级,但这不代表可以忽略。工业相机如果安装时镜头座有轻微倾斜,p1、p2 仍然会显著影响标定板边缘的角点位置。OpenCV 的坐标系原点在图像左上角,x 向右,y 向下,z 朝相机前方。很多人在后期做手眼标定时把坐标轴方向弄反,往往不是外参错了,而是从内参标定的坐标系沿用时就带错了方向。
2.3 棋盘格给标定提供了什么约束
棋盘格上的黑白角点具有明确的空间约束:相邻角点间距固定,所有角点位于同一平面。操作时把标定板平面设为 Z=0,则每个角点的世界坐标可以人为指定,例如第 i 行第 j 列角点坐标为 (j * square_size, i * square_size, 0)。如果以格子边长为单位,fx、fy 的单位就是“像素每格”;如果以毫米为单位,后续所有三维重建结果都会以毫米为尺度。这个选择影响几何尺度,不影响像素比例,但会直接干扰与机械臂坐标系的统一。
单张棋盘格图像中,三维 Z=0 平面到像素坐标的映射是一个单应矩阵 H,H 有 8 个自由度。一个 9×6 的棋盘格提供 54 个角点约束,足以解出单应。但单应中包含内参和外参的耦合,只有让标定板在多个位置、多个角度出现,内参和外参才能被分开约束。这也是为什么单张棋盘格照片无法标定出完整内参,而多张不同姿态的图像可以。棋盘格的好处在于角点检测精度高,且角点坐标的对应关系完全确定,不像圆形标定板还涉及椭圆中心偏移补偿。
一个常见做法是在标定脚本里先生成世界坐标数组:
import numpy as np pattern_size = (9, 6) # 内角点数量:列数-1,行数-1 square_size = 0.025 # 单个格子的边长,单位:米 objp = np.zeros((np.prod(pattern_size), 3), dtype=np.float32) objp[:, :2] = np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2) objp[:, :2] *= square_size print(objp[:5])这段代码生成的数组顺序是 x 方向优先,也就是从左到右、从上到下,和 cv2.findChessboardCorners 返回的角点顺序一致。如果写成 mgrid[0:rows, 0:cols] 然后 reshape,行和列的顺序就会颠倒,标定时会得到一组完全错误的 R、t,而内参却看起来基本正常。这是棋盘格标定脚本里最隐蔽的坑之一。
3. OpenCV 棋盘格标定从采集到参数导出的完整命令
3.1 采集前先确认 patternSize 与角点检测参数
网上很多标定代码抄下来跑不通,一半是棋盘格图片本身的问题,另一半是 patternSize 传错。cv2.findChessboardCorners 的第一个参数是灰度图,第二个参数必须传“内角点数量”。如果打印出来的棋盘是 10×7 个格子,那么内角点就是 9×6,也就是(x方向有9个角点,y方向有6个角点)。传成 (10, 7) 会一直检测失败,或偶尔检出一个排错后的错误角点。
检测 flags 建议显式指定。对于光照不均匀的场景,使用 CALIB_CB_ADAPTIVE_THRESH 和 CALIB_CB_NORMALIZE_IMAGE 会明显提高检出率。不要在生产采集流程里用 CALIB_CB_FAST_CHECK,它为了提速会跳过候选区域,导致一帧中棋盘格稍大或稍偏就漏检。正式标定的每一帧都值得用完整检测,哪怕单帧处理时间多几十毫秒。
检测到角点之后还要做亚像素精化。findChessboardCorners 返回的角点坐标是整数级精度,不足以支撑高质量标定。cornerSubPix 会在局部窗口内利用灰度梯度迭代优化角点位置。常用窗口是 (11, 11),零区域设为 (-1, -1) 表示不屏蔽邻域,迭代条件设为 MAX_ITER + EPS,最大迭代 30 次,精度 0.01。如果角点整体较小,可以把窗口缩小到 (5, 5) 防止窗口跨过多个角点。
import cv2 pattern = (9, 6) img = cv2.imread("chess_01.jpg") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ok, corners = cv2.findChessboardCorners( gray, pattern, flags=cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE ) if ok: criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.01) corners_refined = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) cv2.drawChessboardCorners(img, pattern, corners_refined, ok)这一步里,findChessboardCorners 的输出 corners_refined 是 N×1×2 的数组,而 cornerSubPix 的输入输出结构完全一致,不能改成 (N, 2) 传入。OpenCV 很多函数同时接受这两种形状,但 calibrateCamera 内部期望的是 list of arrays,每个数组是 N×1×2。建议从检测到标定全程保持 OpenCV 角点数组的原生形状,不要在中间做 reshape。
3.2 最少拍多少张,角度和位置怎么铺
棋盘格标定的有效图像数量建议在 20 到 30 张之间。少于 15 张时,图像之间的姿态相关性往往过高,解出来的内参容易过拟合到当前拍摄范围;多于 40 张时,如果某几张标定板弯曲或反光,反而拉低整体精度。重点是张数之外的角度覆盖:棋盘格应占据画面的 1/4 到 3/4,不要只在画面正中央出现;前后移动改变距离,让角点尺寸覆盖较大的像素尺度区间;左右上下平移,让角点出现在画面四角和边缘;绕相机的 X 轴和 Y 轴方向倾斜 10 到 30 度,让标定板平面与成像平面不平行。
一个常见的错误是绕 Z 轴旋转棋盘格连续拍很多张,看起来角度很多,但所有图像中标定板都正对相机,畸变约束和焦距约束都不充分。更好的拍摄顺序是:固定相机曝光和焦距,先拍三张正对远距离,再拍倾斜上、下、左、右各两张,再拍近距离四角边缘,最后补几张中等距离、不同旋转角度的。每拍一张就检测一次,有角点漏检就调整光照重新拍,不要用模糊或过曝的图像硬标。
3.3 calibrateCamera 参数与返回值的实际含义
OpenCV 的标定入口是 cv2.calibrateCamera,完整调用如下:
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( object_points, image_points, gray.shape[::-1], None, None )object_points 是一个 Python list,每个元素对应一帧棋盘格的三维世界坐标;image_points 是同样长度的 list,每个元素是该帧亚像素精化后的角点数组;gray.shape[::-1] 得到的是 (width, height),不是 (height, width),写反会导致图像宽高互换,畸变模型里的归一化坐标计算错误。cameraMatrix 和 distCoeffs 传 None 时,OpenCV 使用默认初始值和内参矩阵估计。
返回值里,ret 是重投影误差的 RMS,mtx 是 3×3 内参矩阵,dist 是畸变系数数组,rvecs 和 tvecs 是每帧的外参。对只做图像矫正的人,外参可以暂时不关心,但后续如果做手眼标定或二维码定位,rvecs、tvecs 可以直接复用。下表列出关键参数的常见选择:
| 参数/输出 | 类型 | 说明 |
|---|---|---|
| object_points | list[np.ndarray] | 每帧棋盘格的世界坐标,所有帧可用同一个 object_points |
| image_points | list[np.ndarray] | 每帧检测并精化后的角点,顺序要和 object_points 一一对应 |
| imageSize | tuple | (width, height),来自灰度图的 shape 反序 |
| flags | int | 默认 0;需要固定主点时传 CALIB_FIX_PRINCIPAL_POINT |
| mtx | numpy.ndarray | 内参矩阵 [fx, 0, cx; 0, fy, cy; 0, 0, 1] |
| dist | numpy.ndarray | 5 个畸变系数 [k1, k2, p1, p2, k3] 或按 flags 截断 |
| ret | float | 所有角点的总体重投影误差,单位是像素 |
flags 参数在多相机标定时很有用。CALIB_FIX_PRINCIPAL_POINT 会把 cx、cy 固定在图像中心,适用于视角很小且主点偏移不明显的镜头;CALIB_ZERO_TANGENT_DIST 会把 p1、p2 固定为 0,适合确认镜头没有切向畸变后的快速回归;CALIB_FIX_K3 则固定 k3 为 0,普通 60 度视场角镜头可以先固定 k3,减少自由参数。对手机广角镜头或带畸变的运动相机,不要轻易固定 k3。
3.4 把内参和畸变系数导出成 JSON 标定文件
标定完成后,代码进程一结束,mtx 和 dist 如果没有落盘就白做了。常见做法是保存为 JSON,方便后续 Python、C++ 或部署脚本读取。保存时不要把 arrays 直接写入,必须调用 tolist() 转换成原生 list。
import json with open("camera_calib.json", "w") as f: json.dump({ "mtx": mtx.tolist(), "dist": dist.tolist(), "image_width": int(gray.shape[1]), "image_height": int(gray.shape[0]), "pattern": list(pattern), "square_size": square_size, "rms": ret, }, f, indent=2)这里除了内参和畸变系数,还把 image_width、image_height、pattern、square_size 一起保存。原因是同一台相机切换分辨率后内参会变,换一个格式或裁剪区域后,原来的标定结果不能直接用。保存 imageSize 可以防止下次加载时拿 1280×720 的标定文件去矫正 1920×1080 的图像;保存 pattern 和 square_size 则是为了后续用同一块标定板做重投影验证。
4. 标定结果调优:角点质量、图像覆盖与参数选择的几个坑
4.1 直接用原始角点标定,误差会偏大
findChessboardCorners 返回的角点经过棋盘内部几何约束的粗定位,但没有亚像素精化。直接用这些整数坐标做 calibrateCamera,重投影误差通常落在 0.5 到 1.5 像素。cornerSubPix 能把误差压到 0.1 到 0.3 像素。对于需要毫米级定位的视觉引导场景,0.5 像素的原始误差在物体距离 500mm 时会放大成数毫米的位置偏差。
也可以使用 OpenCV 4.5 以后提供的 findChessboardCornersSB,它基于 CLAHE 和迭代拟合,对光照渐变、运动模糊的鲁棒性更好,并且内部已经包含亚像素精化。如果使用 SB 函数,后面不要再调用 cornerSubPix,否则会把已经精化的角点再迭代一次,部分情况下会引入轻微抖动。我在处理反光明显的包装线时,会优先尝试 findChessboardCornersSB,但它的速度比默认慢约一倍,适合标定这种离线场景,不适合在线找棋盘格。
4.2 图像覆盖不到位,RMS 再小也不可信
一个常见的误判是:20 张图像全部正对棋盘格,calibrateCamera 返回的 RMS 只有 0.2 像素,就认为标定完事了。实际上这种图像分布下,径向畸变和焦距之间存在强耦合,解出的 fx、fy 和 k1、k2 可能同时偏大或偏小,但投影误差仍然很小。内参标定不仅要误差小,还要参数唯一。唯一的条件是标定板在不同深度、不同倾斜角度下提供足够的独立约束。
判断覆盖是否充分,可以画一张“棋盘格角点在图像坐标系中的散点图”,把所有帧的角点画在同一坐标系里。如果散点集中在图像中央一个圆形区域,说明覆盖不足;如果散点覆盖了四角和边缘,同时对每个区域都有倾斜拍摄,才是比较健康的分布。另外,标定板所在平面和成像平面夹角超过约 45 度时,角点检测精度会快速下降,因此不要为了“角度丰富”而强行拍接近 90 度的侧视图。
4.3 畸变模型选 3 项还是 5 项:先跑一次 5 项再看数值
OpenCV 的 calibrateCamera 默认估计 5 个畸变参数,但很多工程遗留代码只用 4 个或 3 个参数。不能简单认为“参数越少越稳定”,关键是这些参数是否真实存在于镜头中。下表是我在实际项目中常用的选择方式:
| 模型 | 得到的系数 | 适用场景 |
|---|---|---|
| 3 项 | [k1, k2, k3],p1、p2 固定为 0 | 镜头和传感器严格平行,且已经验证切向畸变可忽略 |
| 4 项 | [k1, k2, p1, p2],k3 固定为 0 | 小视场角工业镜头,边缘畸变未明显到需要 k3 |
| 5 项 | [k1, k2, p1, p2, k3] | 默认推荐;广角镜头、手机摄像头、运动相机 |
如果一开始不确定,先用 5 项标定,观察 dist 数组里的 k3 和 p1、p2。k3 的数值如果接近 0,说明镜头边缘畸变不强,可以在下一次标定中用 CALIB_FIX_K3 固定它;p1、p2 如果接近 0,也可以固定。但 p1、p2 数值大并不意味着镜头坏了,在很多 C 口镜头里,切向畸变是真实存在的,直接固定会损失精度。稳妥做法是保留 5 项,除非你明确知道镜头型号和安装方式。
4.4 重投影误差不是唯一指标,单帧最大误差才值得查
calibrateCamera 返回的 ret 是所有角点的总体 RMS,它会把误差平均掉。假设 29 张图误差都在 0.1 像素,唯一 1 张图的某几个角点误差高达 1.5 像素,总体 RMS 可能只上升到 0.2 像素,看起来仍然正常。要找到问题帧,必须逐帧重新投影。
import numpy as np errors = [] for i in range(len(object_points)): proj, _ = cv2.projectPoints( object_points[i], rvecs[i], tvecs[i], mtx, dist ) err = cv2.norm(image_points[i], proj, cv2.NORM_L2) / len(proj) errors.append(err) errors = np.array(errors) print("mean:", errors.mean(), "max:", errors.max(), "frame:", errors.argmax())这段代码利用标定结果反算第 i 帧角点的投影位置,再和检测到的角点求平均距离。如果最大帧误差超过总体均值 3 倍,优先检查那一帧有没有局部反光、标定板是否弯曲、角点排序是否出现跳变。把明显偏大的坏帧从 object_points 和 image_points 里删掉后重新标定,通常比单纯增加新图更有效。
5. 标定完不重训:用同一块棋盘格快速验证内参是否可靠
5.1 用 solvePnP 对“没见过”的图像做增量验证
标定过程里参与计算的那些图像,内参会向它们“妥协”,所以训练集 RMS 只能证明优化收敛,不能证明内参对没见过的图像同样准确。我会在每轮标定结束后,另拍一张新的棋盘格照片,只检测角点,不做筛选,然后用 solvePnP 估计该帧外参并计算重投影误差。如果这个误差和整体 RMS 在同一量级,说明内参具备泛化能力;如果高出太多,说明采集覆盖有问题或标定板本身不平。
objp = object_points[0].copy() ok, corners = cv2.findChessboardCorners(gray2, pattern, None) if ok: corners = cv2.cornerSubPix(gray2, corners, (11, 11), (-1, -1), criteria) ok_pnp, rvec, tvec = cv2.solvePnP(objp, corners, mtx, dist) proj, _ = cv2.projectPoints(objp, rvec, tvec, mtx, dist) validate_err = cv2.norm(corners, proj, cv2.NORM_L2) / len(objp) print("validation error:", validate_err)这里必须用同一块棋盘格,并且 world coordinate 的 square_size 要和标定时一致。验证时不要使用标定训练集里的图像,否则误差会被人为压低。如果验证误差稳定在 0.2 像素以内,内参可以用于测量;如果超过 0.5 像素,先补拍几张四角和倾斜图像,再重新标定,而不是反复调 flags。
5.2 标定文件里还应该记下这些现场信息
JSON 里除了 mtx、dist、imageSize 和 pattern,建议再加三个字段:相机型号、镜头焦距、标定板编号。相机内参随传感器批次和镜头安装会有细微差异,同型号的另一台相机不能直接共用标定文件;变焦镜头在任意焦距变化后都必须重新标定;标定板被弯曲或污染过也要重新标定。保存这些现场信息,能避免三个月后拿一份旧标定文件给新相机用而查不出问题。
最后再补一个实用技巧:每次标定结束,把当时的标定板照片和 JSON 文件放到同一个目录,目录名用“日期_相机型号_分辨率”命名。这样后续不管是做图像矫正还是手眼标定,都能快速回溯“这套内参到底是哪台相机、哪个分辨率、哪块板子标出来的”。
本文还有配套的精品资源,点击获取