张正友标定法详解:棋盘格照片如何算出相机内参与畸变
简介:一套面向计算机视觉入门与进阶学习者的张正友标定法实现资料包。资源围绕单平面棋盘格模板,系统呈现摄像机标定的完整技术路线:从针孔模型与内参矩阵出发,经特征点检测、单应性矩阵求解、内外参数估计,再到畸变系数计算与图像校正。内容涵盖摄像机模型、角点检测、参数求解与畸变校正等核心环节,配有m脚本、测试数据及说明文档,可帮助读者理清从模板标定到实际应用的流程。压缩包共27个文件,以10个m脚本为主,辅以9个txt数据/说明、7个asv备份及1个caj论文,整体仅85KB,便于查阅;已有501人学习下载,适合代码研读与算法复现。除可直接运行的脚本函数外,还包含多组测试数据、旋转与单应性计算工具、点归一化函数以及张正友原始文献,便于对照理论逐行验证,是从理论过渡到动手实践的高性价比参考资料。
1. 张正友标定法是什么:为什么棋盘格拍几张照片就能算出相机内参
做视觉测量、机器人抓取或者三维重建的工程师,多半绕不开相机标定。张正友标定法,就是目前最主流的“用棋盘格照片求相机内参和畸变系数”的办法,OpenCV 里calibrateCamera的默认算法就是它。它的价值在于:不需要昂贵的标定设备,只需要打印一张棋盘格,从不同角度拍 10 到 20 张照片,就能得到焦距、主点、畸变系数这些参数。刚接触的人会觉得这是“玄学”——明明每张照片里的角点位置都不一样,为什么算出来的内参是固定的?答案藏在单应矩阵分解里。这篇笔记适合两类人:一是刚入行要自己标定相机的,二是标定结果不稳定想排查原因的,我会把原理、命令、参数和几个拿时间换来的坑一次讲完。
2. 标定前的准备:棋盘格规格、拍摄姿态与采集脚本
2.1 棋盘格的物理尺寸与角点数量怎么选
张正友标定法的输入是棋盘格的“角点”,不是棋盘格的“格子”。OpenCV 里的findChessboardCorners找的是黑白格交界的内部角点。比如一张 9×6 的棋盘格,指的不是 9 行 6 列格子,而是内角点数为 9×6,对应格子数量实际上是 10×7。打印的时候要区分PatternSize和SquareSize两个参数:
PatternSize:内角点数,例如(9, 6),这是代码里检测的目标数量。SquareSize:每个格子的物理边长,单位毫米,例如30,这是后续标定时世界坐标系下角点坐标的间隔。
物理尺寸的选择要看你相机的视野。一般原则是在画面里每个格子至少占 15 到 20 个像素,否则角点检测不稳定。我用过的组合里,500 万像素相机配 30mm 格子的 9×6 棋盘格,在距镜头 0.5 到 1 米范围内效果最好。如果棋盘格太小,角点会被混叠吃掉;如果太大,近距离拍摄时边缘格子出画,内角点数量不足,直接失败。
另一个容易被忽略的点是:棋盘格必须打印在平面上。普通的 A4 纸会翘边,建议贴在硬纸板或者铝塑板上。不要用覆膜反光的材料,高光会导致局部过曝,角点附近的梯度被抹平,检测结果会跳。
2.2 拍摄时覆盖视场与姿态变化的实用规则
采集照片是整个标定里最影响成败的一步。经验法则是:
- 拍 15 到 20 张,不要少于 10 张。
- 棋盘格要出现在画面的不同位置:左上、右上、中心、下边缘,都要覆盖。
- 姿态要有变化:倾斜、俯仰、旋转都要有,不能全是正对相机。
- 保证棋盘格至少占画面 1/3 以上,最好 1/2。
- 对焦要清晰,特别是边角处的格子不能糊。
为什么姿态变化这么重要?因为张正友标定法是从每张图的单应矩阵里解出内参的,如果所有棋盘格都正对相机,单应矩阵退化,内参解不出来。我见过不少工程师拍了一堆“很完美”的正对照片,标定结果焦距乱跳,就是这个原因。
采集时还要注意不要移动相机。标定求的是相机内参,不是位姿,相机应该保持固定,只移动棋盘格。如果你用手持相机对着固定棋盘格变换角度,拍出来的图也能标定,但结果一致性会比“相机固定、棋盘格动”要差一些,因为手持抖动引入了额外的运动模糊。
2.3 用 OpenCV 的 findChessboardCorners 做初筛:一段可复用的采集检查脚本
拍完照片不要直接丢给标定函数,先跑一遍角点检测,把不合格的照片剔除。这个初筛能省掉后面调试的很多时间。我一般会写一个检查脚本,批量读图、检测、绘制并保存结果:
import cv2 import glob pattern_size = (9, 6) # 内角点数 (宽, 高) image_paths = sorted(glob.glob("calib_images/*.jpg")) valid_images = [] for path in image_paths: img = cv2.imread(path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) found, corners = cv2.findChessboardCorners( gray, pattern_size, flags=cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE ) if not found: print(f"跳过: {path} 未找到完整棋盘") continue # 亚像素细化,提升角点位置精度 corners_refined = cv2.cornerSubPix( gray, corners, (11, 11), (-1, -1), (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) ) valid_images.append((path, corners_refined)) cv2.drawChessboardCorners(img, pattern_size, corners_refined, found) out_path = "check/" + path.split("/")[-1] cv2.imwrite(out_path, img) print(f"合格图片数: {len(valid_images)} / {len(image_paths)}")这段脚本的逻辑很简单:先做粗检测,再用cornerSubPix做亚像素细化。注意findChessboardCorners的flags里我加了ADAPTIVE_THRESH和NORMALIZE_IMAGE,这两个 flag 能处理光照不均和过曝场景,代价是速度稍慢,但标定场景不在乎这点时间。(11, 11)是亚像素搜索窗口的一半尺寸,如果棋盘格在画面里很小,可以改成(5, 5);如果很大,(21, 21)会慢但更稳。TERM_CRITERIA表示迭代 30 次或精度到 0.001 像素就停。
跑完这个脚本,如果合格图片数少于 10 张,别急着标定,回到拍摄阶段补拍。检查绘制图片时重点看角点是否贴在内角中心,如果有一排角点偏移明显,说明图像模糊或者畸变太重,这张图建议丢掉。
3. 从单应矩阵到内参:张正友标定法的核心原理与数学脉络
3.1 为什么把世界坐标系放在棋盘格平面上
张正友标定法的一大聪明之处是:它不需要你知道棋盘格在空间里的具体位姿,只需要假设棋盘格平面是世界坐标系的 Z=0 平面。这样一来,棋盘格上每个角点的世界坐标(X, Y, 0)可以直接用SquareSize乘以索引得到,比如第(i, j)个角点就是(i*30, j*30, 0)。
相机成像模型把三维点投影到像素坐标,公式是:
s * [u, v, 1]^T = A * [R|t] * [X, Y, 0, 1]^T因为 Z=0,旋转矩阵的第三列被消掉,原来的 3×4 外参矩阵变成了 3×3 的单应矩阵 H。每张棋盘格照片能估计出一个 H,而 H 同时包含内参 A 和外参(旋转和平移)。这里的关键是:不同照片的外参不同,但内参 A 是同一个。用多张照片的 H 联立解方程,就能把 A 分离出来。
这是整个方法的核心思想:用平面标定物,把非线性的相机标定问题转化成一组线性方程,先求闭式解,再用最大似然优化精化。这也是为什么它比传统 DLT 方法更稳定,因为平面棋盘格的角点提取容易做到亚像素精度。
3.2 单应矩阵 H 如何分解出内参矩阵 A
单应矩阵 H 是 3×3 的,它把棋盘格平面上的点映射到图像平面。设 H 的三列是[h1, h2, h3],内参矩阵 A 是上三角矩阵,包含焦距 fx、fy 和主点 cx、cy。根据旋转向量的正交性和模长相等约束,可以得到两个关于 A 的基本方程:
h1^T * (A^{-T} * A^{-1}) * h2 = 0 h1^T * (A^{-T} * A^{-1}) * h1 = h2^T * (A^{-T} * A^{-1}) * h2这里的A^{-T} * A^{-1}是一个对称矩阵,张正友用B来表示它。因为 B 是对称的,只有 6 个未知数,所以一张照片能贡献两个方程。理论上 3 张照片就能解出 6 个未知数,实际通常拍 10 张以上是为了抵抗噪声和数值误差。
解出 B 之后,再对 B 做 Cholesky 分解或者直接按元素反解,就能得到内参矩阵 A。这一步在 OpenCV 里由cv2.calibrateCamera内部完成,不需要你手写。但理解这个脉络能帮你排查问题:如果照片数量少或者姿态单一,B 的方程是欠约束的,求出来的焦距自然不可信。
3.3 畸变系数的引入与非线性优化:让重投影误差说话
线性求解出来的内参只是初始值,因为没有考虑镜头畸变。张正友标定法使用了一个常用的畸变模型,包含径向畸变 k1、k2(以及可选 k3)和切向畸变 p1、p2:
x_distorted = x * (1 + k1*r^2 + k2*r^4 + k3*r^6) + 2*p1*x*y + p2*(r^2 + 2*x^2) y_distorted = y * (1 + k1*r^2 + k2*r^4 + k3*r^6) + p1*(r^2 + 2*y^2) + 2*p2*x*y其中(x, y)是归一化相机坐标系下的坐标,r^2 = x^2 + y^2。畸变系数先用最小二乘估计初始值,然后把内参、外参、畸变系数一起扔进非线性优化器,优化的目标是最小化所有角点的重投影误差:
sum( || m_ij - project(A, k, R_i, t_i, M_j) ||^2 )OpenCV 默认用 Levenberg-Marquardt 算法做这个优化。最终输出的retval就是每个角点的平均重投影误差,单位是像素。这个值通常应该小于 0.5,如果超过 1,说明标定输入有问题。
这一节想强调的是:重投影误差是衡量标定质量的唯一直接指标,但它不是万能的。误差小只代表“这些照片的内部一致性”好,不代表真实世界精度高。后面避坑章节里我会讲一个误差很小但实际效果翻车的案例。
4. 用 OpenCV 跑通最小标定流程:从图片目录到相机参数文件
4.1 calibrateCamera 的完整调用与参数含义
有了合格角点,标定主流程就很简单。下面是完整代码,它读取上一步保存的角点数据,计算世界坐标,调用标定函数并打印结果:
import numpy as np import cv2 import json # 上面第 2 节筛选出来的角点列表,每个元素是 (image_path, corners_refined) # 这里直接构造标准输入格式 square_size = 30.0 # 棋盘格边长,单位 mm pattern_size = (9, 6) # 与角点检测时一致 obj_points = [] # 世界坐标点集 img_points = [] # 像素坐标点集 for img_path, corners in valid_images: # 生成棋盘格世界坐标:z=0 平面,单位 mm 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, 2) * square_size obj_points.append(objp) img_points.append(corners.reshape(-1, 1, 2)) # 相机分辨率,可以从图片读取 img_sample = cv2.imread(valid_images[0][0]) h, w = img_sample.shape[:2] # 标定 ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, (w, h), cameraMatrix=None, distCoeffs=None, flags=cv2.CALIB_RATIONAL_MODEL # 启用 k1-k3, p1-p2 共 5 个畸变系数 ) print(f"重投影误差: {ret:.4f} 像素") print("内参矩阵:\n", mtx) print("畸变系数:\n", dist.ravel()) # 保存为 JSON,方便后续程序读取 calib_data = { "image_width": w, "image_height": h, "camera_matrix": mtx.tolist(), "dist_coeffs": dist.ravel().tolist(), "rms_error": ret } with open("camera_params.json", "w") as f: json.dump(calib_data, f, indent=2)参数说明:calibrateCamera的第一个参数是每张图的世界坐标点列表,第二个是像素坐标点列表,这两个列表长度必须一致。第三个是图像尺寸(w, h),注意 OpenCV 里宽在前。cameraMatrix=None表示让算法自动估计内参,如果你想固定主点位置,可以传入一个初始矩阵并配合CALIB_FIX_PRINCIPAL_POINT使用,但新手不建议这么做。CALIB_RATIONAL_MODEL会估计 5 个畸变系数,普通镜头用None或者CALIB_RATIONAL_MODEL都行,但如果镜头畸变很轻,用 5 参数模型可能过拟合,这时建议用默认 4 参数模型,即去掉这个 flag。
4.2 保存标定结果为 YAML/JSON:内参矩阵与畸变系数怎么用
保存成 JSON 是为了让其他程序方便加载。内参矩阵mtx的排布是:
[fx, 0, cx] [ 0, fy, cy] [ 0, 0, 1]其中fx = f / dx,fy = f / dy,dx、dy是像素尺寸。如果你的像素是正方形,fx和fy应该非常接近。如果两者相差超过 5%,第一反应不是镜头问题,而是标定板姿态覆盖不够,或者照片里有严重的透视变形没被模型解释掉。
畸变系数dist是个 1×5 的矩阵,顺序是[k1, k2, p1, p2, k3]。在用 OpenCV 去畸变时,直接传给cv2.undistort即可。注意不要弄混顺序,我曾经把[k1, k2, k3, p1, p2]当成 OpenCV 的顺序传进去,结果图像边缘扭曲,换了 5 分钟才意识到。
4.3 角点检测不完整时的处理:亚像素参数与CALIB_CB_EXHAUSTIVE
实战里经常遇到findChessboardCorners检测不到完整棋盘的情况。除了重拍,还有两个可调的偏方。
第一个是降低pattern_size的要求,比如检测 8×5 的内角点,如果完整 9×6 检测失败。这个做法能用,但代价是标定板外圈的信息被丢弃,导致视场边缘的畸变估计不准。适合应急,不适合追求精度的场景。
第二个是启用CALIB_CB_EXHAUSTIVE参数。OpenCV 4.x 里这个 flag 会做更密集的角点搜索,速度慢但能提高检出率。代价是偶尔会误检,跑完初筛脚本后一定要人眼检查绘制图。
亚像素细化的窗口大小也要根据分辨率调整。500 万像素图里棋盘格单格约 30 像素,窗口(11, 11)够用;如果是 1200 万像素,建议(21, 21)。窗口太小,角点位置会偏;窗口太大,边缘处的非角点容易被拉进来。这个参数没有绝对最优,我通常会在保存结果前用cornerSubPix反复细化两次,把maxIter从 30 提到 50,能换回零点几个像素的精度。
5. 张正友标定法常见问题排查:5 个让标定翻车的真实案例
5.1 现象:重投影误差小于 0.3,但去畸变后图像边缘扭曲严重
这是最隐蔽的坑。误差小通常意味着标定模型拟合得很好,但边缘扭曲说明畸变模型没有覆盖真实镜头的变形,或者边缘的角点参与太少了。原因是畸变系数主要由画面边缘的棋盘格决定,如果你拍的图里棋盘格只在画面中心区域移动,边缘畸变没有被充分激励,算法就会低估畸变系数。
解决方法是回到采集阶段:画面边缘四角都要让棋盘格出现,即使只有一半棋盘在画框内也可以。如果某些姿态下无法检测完整角点,可以用 7×5 或者更小的内角点数量重新标,但需要注意不同图像使用不同 pattern_size 可能需要分开处理。
5.2 现象:不同图片组合标出的焦距波动超过 5%
同一台相机,用 10 张图标定焦距是 1200,换另外 10 张变成 1280,这种波动说明标定输入不稳定。常见原因有两个:一是照片中有运动模糊,角点亚像素位置偏移;二是姿态覆盖不够,特别是绕光轴的旋转角度变化太少。
处理办法是做一个滑窗测试:从照片集里随机抽 2/3 的子集各标定 5 次,看焦距的标准差。标准差如果大于焦距值的 1%,就回到采集步骤补拍。这个测试没必要写复杂脚本,手动多跑几次calibrateCamera就行。
5.3 现象:findChessboardCorners 明明能检测,却报“Chessboard detection failed”
这类错误通常发生在循环批量处理图片时。原因多半是图片列表里有非标定板照片、灰度图通道数不对、或者pattern_size和实际棋盘不匹配。我犯过的错是:拍摄时把棋盘格照片里的某一张换成了不同格数的棋盘,检测脚本的pattern_size没变,结果有的图能检测、有的不能。
解决方法是每张图检测前打印image.shape和检测状态,发现异常先看一眼图片内容。另外要确保所有图片都是从同一块棋盘拍摄的,不要混用不同规格的标定板。
5.4 现象:棋盘格只出现在画面中心时,结果不稳定
中心区域是畸变影响最小的区域,也是单应矩阵条件数最差的区域。因为当棋盘格很小时,单应矩阵对姿态角的变化不敏感,内参解算的数值稳定性变差。从数学上看,单应矩阵 H 的估计误差会被放大到内参上。
解决办法是老规矩:让棋盘格在画面不同位置倾斜,尤其是画面四角。还有一个技巧:拍摄时让棋盘格平面与相机光轴成 45 度左右的夹角,这样角点分布能撑满更宽的视场范围,观察到的畸变分量更丰富。
5.5 现象:鱼眼镜头直接套用 calibrateCamera 失败或误差巨大
普通针孔模型加 5 参数畸变不能描述鱼眼镜头的强畸变。张正友标定法本身适用于普通镜头和轻微畸变的广角镜头,鱼眼要用cv2.fisheye.calibrate,它使用等距投影模型。如果你手上只有普通标定函数,可以考虑缩小棋盘格在画面中的占比,只标定中心区域,但这样边缘畸变就不要想了。
我遇到过一个更平常的问题:手机前置摄像头外参矩阵在标定时出现负的 tz,代表标定板在相机后面的伪解。这个需要检查图片是否有镜像翻转。OpenCV 的cv2.imread读出来是正常的,但如果你的图集被处理过做了水平翻转,角点坐标就和世界坐标对不上,结果就翻车。检查方法是看第一张图的角点顺序是不是从左到右、从上到下排列。
6. 让标定结果更可靠的一个技巧:用重投影误差做质量门禁与自动化筛选
最后分享一个我每次标定都会做的技巧:不只输出一个总误差,而是输出每一张图片的单独重投影误差,用这个误差做质量门禁,自动剔除贡献大的“坏图”。很多人只盯着calibrateCamera返回的总ret,但总误差是平均过的,一张坏图可能被其他好图平均掉。
做法是在标定后对每个角点重新投影,计算每张图的均方根误差:
def per_image_error(obj_points, img_points, rvecs, tvecs, mtx, dist): errors = [] for i in range(len(obj_points)): proj, _ = cv2.projectPoints(obj_points[i], rvecs[i], tvecs[i], mtx, dist) err = np.mean(np.linalg.norm(proj[:, 0, :] - img_points[i][:, 0, :], axis=1)) errors.append(err) return errors errs = per_image_error(obj_points, img_points, rvecs, tvecs, mtx, dist) for idx, e in enumerate(errs): print(f"图像 {idx}: 误差 {e:.3f} 像素")逻辑说明:projectPoints用当前标定结果的内参、外参把世界坐标重新投影到图像,再和检测到的像素坐标做差。误差大的图片说明要么角点提取有偏差,要么该视角下模型拟合不好。一般以 0.8 像素作为门禁,超过就剔除,然后重新标定。
这个技巧的另一个用法是评估新增照片的价值。标定完成后如果你还想补拍照片提高精度,先拿新增照片算一下误差,如果误差和当前平均水平差不多,就加进去;如果特别大,说明姿态太极端或者有遮挡,补了反而拉低整体精度。这比盲目加照片高效得多。
另外,验证标定质量不能只看重投影误差。一个更贴近实际需求的验证是在真实场景里测量:用标定结果去畸变一张包含已知长度物体的图像,用像素距离乘以比例算出物理长度,和真实长度对比。这个做法的好处是能暴露“摄影测量意义上的精度”问题,而重投影误差只能说明对棋盘格自身的拟合程度。我吃过亏的情况是:重投影误差 0.2 像素,但用去畸变图像做 0.5 米距离的尺寸测量,误差到了 3 毫米,最后查下来是棋盘格贴纸本身不平整,角点世界坐标引入了几毫米偏差。所以标定板平面的平整度永远是最值得投资的硬件前提。
我的习惯是每一批标定都保留原始图片、筛选后的角点、以及最终参数三个文件,这样后续发现问题还能回头排查。这套流程从采集到出参数,熟练后十分钟内能跑完,但中间沉淀下来的这些边界条件和参数取舍,才是真正决定标定质量的东西。希望这篇笔记能帮你少走几步弯路。
本文还有配套的精品资源,点击获取