简介:面向计算机视觉初学者与机器人、三维重建等领域的开发者,这份相机标定Python程序提供了完整可用的内参求解方案。资源自带11×10规格的正友棋盘格图片,既可打印后拍摄,也可直接放在显示器上配合附带程序使用,大幅降低了采集门槛。包内共14个文件,包括1个主程序main.py、1个图片预览脚本ChessboardView.py、10张JPG标定样例图、1张PNG棋盘格模板及1个TXT说明文档,压缩包仅2.19MB,目录结构清晰。使用时只需修改图像文件夹路径与实际棋盘格边长,即可运行并得到相机内参,方便后续进行畸变矫正、三维重建等任务。目前已有907人学习下载,适合希望快速上手标定流程的入门及进阶用户,拿到后既可对照样例复现,也可替换为自己的拍摄图片。
1. 从棋盘格到内参矩阵:一套能直接落地的 Camera calibration Python 方案
如果你手里有一台相机、一张打印的棋盘格,想算出它的内参矩阵和畸变系数,那么你需要的不是 Ros 里那套重量级标定流程,而是一个能在本地用 Python 直接跑通的最小实现。这就是本标题要解决的事:不依赖 ROS、不依赖 MATLAB 工具箱,只靠 OpenCV 的 findChessboardCorners 和 calibrateCamera 两个核心函数,就能从一组棋盘格照片里得到相机内参。这套方案适合算法工程师验证镜头畸变、机器视觉项目里估算像素当量,也适合刚入门相机模型的人理解内参到底在解什么方程。关键是,代码可以复制下来直接改路径运行,棋盘格用 A4 纸打印即可。
2. 先弄懂内参在解什么:从像素坐标到相机坐标的映射关系
2.1 针孔模型里的 4 个内参和 5 个畸变系数
常见做法是先把相机当成针孔模型,空间里一个点(X, Y, Z)投影到像素平面(u, v)的过程,可以写成
s * [u, v, 1]^T = K * [R|t] * [X, Y, Z, 1]^T其中K就是内参矩阵,展开后是
K = [fx, 0, cx; 0, fy, cy; 0, 0, 1]fx和fy是 x 和 y 方向的等效焦距,单位是像素;cx和cy是光心在像素坐标系里的坐标,也叫主点。除了这 4 个参数,真实镜头还有径向畸变和切向畸变,OpenCV 用 5 个系数表达:k1, k2, p1, p2, k3。其中k1, k2, k3负责径向畸变,p1, p2负责切向畸变。标定棋盘格,本质就是利用棋盘格的角点坐标已知这个约束,反解出K和畸变系数。
你可能注意到cx, cy并不一定等于图像分辨率的一半。如果你拿到的内参里主点位置明显偏离图像中心,不要急着认为标定失败了,可能是相机感光芯片装配偏移,也可能是标定板只覆盖了画面局部区域。
2.2 棋盘格为什么比圆点阵列更适合做标定
棋盘格的角点是二阶梯度上的鞍点,OpenCV 的findChessboardCorners算法对这类角点的定位精度能达到亚像素。相比之下,圆点阵列的圆心拟合精度受镜头畸变影响更大,而且圆点阵列通常需要额外的编码信息来保证方向唯一性。
棋盘格的另一个优势是角点数量可控。你只需要知道每行每列的内角点数(不是格数),算法就能按照顺序把所有角点排列好,不需要你在标定板上预先布置任何朝向标记。当然,OpenCV 从 4.5 之后也支持CALIB_CB_MARKER标志让棋盘格带上 ArUco 标记来保证方向,但对大多数场景来说,普通棋盘格已经够了。
2.3 角点顺序和世界坐标的对应规则
标定算法要求你把图像上的角点坐标与一个已知的世界坐标对应起来。常见做法是以棋盘格左上角第一个内角点为原点,格子边长作为单位长度,建立一个平面坐标系:
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)这里pattern_size是(列数, 行数)的内角点数量。注意mgrid生成的顺序是 x 方向从 0 到cols-1,y 方向从 0 到rows-1,这个顺序必须和findChessboardCorners返回的角点顺序一致。如果你用的是(9, 6)的棋盘格,那么实际是 10x7 的格子,内角点是 9x6。
我见过不少人在这里把pattern_size填成(10, 7),导致所有棋盘格照片都找不到角点。判断标准很简单:看打印出来的棋盘格,数一数黑白格子交界点的个数,不是格子本身的个数。
3. 用 Python 直接跑通棋盘格标定的最小实现
3.1 安装依赖与准备标定板
开发环境建议用 Python 3.8 及以上版本,核心依赖只有两个:
pip install opencv-python numpy标定板直接用 A4 纸打印即可。如果你要标定的是工业相机或者长焦镜头,最好把棋盘格贴在硬纸板或者铝板上,避免纸张弯曲。格子边长建议在 20mm 到 50mm 之间,边长大小不参与内参计算,因为内参和畸变跟实际物理尺寸无关,只和像素坐标有关。但如果你之后想把内参与位姿估计结合,需要知道实际边长,这时才需要精确测量。
拍摄时注意三点:一是棋盘格要覆盖图像的四个角落和中心区域,否则畸变系数估计会不准;二是每张照片里棋盘格的角度要有变化,倾斜、旋转、远近都要有;三是保证角点清晰,不要有运动模糊。
提示:如果你用的是摄像头而不是工业相机,先手动固定曝光和焦距,不要在采集过程中让自动对焦反复变动,否则标定结果会漂移。
3.2 角点提取与亚像素精化
import cv2 import numpy as np def find_corners(image, pattern_size=(9, 6)): gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, pattern_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) return corners_refined return None这段代码先转灰度,再调用findChessboardCorners找角点,找到后用cornerSubPix做亚像素精化。criteria里的30是最大迭代次数,0.001是角点位置变化的精度阈值。窗口大小(11, 11)表示在角点周围 11x11 的区域内搜索,这个值越大定位越平滑,但太小棋盘格纹理复杂的区域容易跑偏。
如果你发现某张照片的角点像是「跳格」了,也就是同一行的角点突然偏移到另一行,通常是棋盘格部分被遮挡或者反光。直接丢弃这张,不要手动修正,因为畸变拟合对异常点的敏感度很高。
3.3 调用 calibrateCamera 求解内参
def calibrate(img_files, pattern_size=(9, 6), square_size=1.0): 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) objp *= square_size obj_points = [] img_points = [] for f in img_files: img = cv2.imread(f) corners = find_corners(img, pattern_size) if corners is not None: obj_points.append(objp) img_points.append(corners) ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, img.shape[:2][::-1], None, None ) return mtx, dist, rvecs, tvecscalibrateCamera的输入有三组关键内容:世界坐标点列表、图像角点列表、图像分辨率。输出中mtx是 3x3 内参矩阵,dist是 5 个畸变系数,rvecs和tvecs是每张图的外参,也就是棋盘格相对相机的旋转和平移向量。
这里有个容易忽视的参数:img.shape[:2][::-1]把图像的(height, width)转成(width, height)传给 OpenCV。如果你直接传img.shape,在非方形图像上标定出来的cx和cy会出现系统性的偏差。
3.4 标定质量的快速验证:重投影误差
OpenCV 在calibrateCamera返回值里已经给出了ret,这个值就是所有角点的均方根重投影误差。经验阈值是小于 0.5 像素,工业场景最好小于 0.2 像素。但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 = cv2.norm(img_points[i], proj, cv2.NORM_L2) / len(proj) errors.append(err) return errors把每张图的误差打印出来,如果某张图的误差明显大于其他图(比如超过 2 倍),优先检查这张图是否拍摄角度过于极端,或者棋盘格边缘被镜头畸变拉得过于模糊。剔除后重新标定,通常整体误差会明显下降。
4. 参数设置与常见误用的边界
4.1 不同畸变模型怎么选
calibrateCamera有一个flags参数控制畸变模型的复杂度,默认None表示使用 5 个畸变系数(k1, k2, p1, p2, k3)。如果你的镜头是鱼眼或者超广角,5 参数模型拟合不了边缘的大畸变,这时要用cv2.fisheye模块。
| flags 取值 | 含义 | 适用场景 |
|---|---|---|
| 默认 None | 使用 5 参数畸变模型 | 普通工业镜头、手机主摄 |
| CALIB_ZERO_TANGENT_DIST | 切向畸变强制为 0 | 镜头装配工艺好、只需径向修正 |
| CALIB_FIX_K1-K3 | 固定部分径向系数 | 已知镜头工厂标定值、只标主点 |
| CV_CALIB_USE_INTRINSIC_GUESS | 用给定的初始内参迭代 | 相机内参已知、标定外参 |
我在实际项目里一般先用默认 5 参数标一遍,如果发现dist里k3接近 0 且不改善重投影误差,就改用CALIB_FIX_K3重新标定,这样能减少过拟合。
4.2 照片数量和位姿分布的硬性要求
最少需要多少张照片?理论上是 3 张不同姿态的棋盘格就能解出内参,但实际经验是至少 10 到 15 张。这里的关键不是数量,而是位姿多样性。如果你把 20 张照片都拍成棋盘格正对相机、只有远近变化,那么你的标定结果在画面中心区域是准的,但边缘畸变系数会很不稳。
推荐的位置分布是:正面 3 到 4 张,左右旋转各 2 张,上下俯仰各 2 张,四个角落各 1 张,再加上略微倾斜的 2 张。每张照片里棋盘格面积不要小于画面的十分之一,否则角点提取虽然成功,但亚像素定位精度会下降。
# 采集时可以保存为 jpg,但不要用手机自带的美化或者 HDR 模式 # 如果使用工业相机,建议保存为无损 PNG 或 BMP4.3 棋盘格角点提取失败的六个常见原因
角点提取是整套流程里最容易出问题的环节。我把踩过的坑按出现频率排了个序:
- 分辨率太低:棋盘格在画面里太小,角点之间的像素距离不足 10 个像素,提取失败率极高。
- 强反光:打印的棋盘格纸张反光时,黑白格的灰度对比会反转,
findChessboardCorners找不到鞍点。 - 运动模糊:手持相机快门太慢,角点变成椭圆,亚像素定位失效。
- 图案方向:棋盘格不是纯平面,弯折的纸张会让世界坐标假设失真。
- 亮度过曝:白色格子被过曝成一片纯白,黑色格子的边缘梯度信息丢失。
- 背景干扰:棋盘格周围有大量类似棋盘格纹理的物体,算法会找错角点区域。
提示:如果你用的是 Python 的
cv2.imread读取灰度图,注意 OpenCV 默认通道顺序是 BGR。转灰度时用cv2.cvtColor,不要直接读成灰度图再用。
4.4 标定结果里最容易被误读的三个量
fx和fy不是物理焦距,而是焦距除以像素尺寸后得到的值。对于像素尺寸接近正方形的传感器,fx和fy应当非常接近。如果两者相差 5% 以上,检查一下图像是否被缩放或裁剪过。
cx和cy是主点坐标,正常情况下接近图像中心,但并非必须等于中心。标定出来的主点偏离中心几十像素是正常现象。如果偏离几百像素,基本可以判定某张图的角点顺序错了。
dist的顺序是(k1, k2, p1, p2, k3),不是(p1, p2, k1, k2, k3)。用的时候如果直接喂给undistort不会出错,因为 OpenCV 内部有对应关系;但如果你自己写畸变公式,顺序就会搞错。
5. 内参的应用与验证:用 undistort 和 imu 内参标定做交叉验证
5.1 用 undistort 验证去畸变效果
拿到内参和畸变系数后,最容易上手的验证方式是直接对一张未参与标定的照片做去畸变:
def undistort_image(image, mtx, dist): h, w = image.shape[:2] newcameramtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), 1, (w, h)) dst = cv2.undistort(image, mtx, dist, None, newcameramtx) x, y, w_roi, h_roi = roi dst_cropped = dst[y:y + h_roi, x:x + w_roi] return dst_croppedgetOptimalNewCameraMatrix的第四个参数alpha=1表示保留所有原始像素,去畸变后图像边缘会出现黑色区域;alpha=0表示裁剪掉黑色区域,但会损失部分视场角。实际项目里,如果你需要保留完整视场角做测量,常用alpha=1,然后根据 ROI 裁剪掉黑边。如果是做人脸或者物体识别,alpha=0可以让图像更干净。
5.2 内参结果与 imu 内参标定的关联
在机器人和视觉 SLAM 场景里,相机内参标定往往只是第一步,后面跟着的是相机与 IMU 之间的外参标定。这里的「imu内参标定」通常指 IMU 自身的零偏和尺度因子,而不只是相机内参。但两者之间有一个容易混淆的点:IMU 内参标定需要用到相机的内参结果来解算相机位姿,进而估计 IMU 轨迹。
如果你看到「visionmaster进行相机内参标定」这类工作流,它本质上是把棋盘格标定嵌入到更复杂的多传感器标定流程里,但第一步仍然是findChessboardCorners加calibrateCamera。棋盘格标定得到的重投影误差,会直接影响后续外参标定的精度。如果相机内参标定误差超过 0.5 像素,IMU 与相机之间的旋转估计就会明显产生漂移。
5.3 用 Remap 做离线去畸变
对于视频流,逐帧调用undistort性能很差。正确做法是先算一次映射表,然后用remap处理每一帧:
def create_undistort_maps(mtx, dist, size): newcameramtx, _ = cv2.getOptimalNewCameraMatrix(mtx, dist, size, 1, size) mapx, mapy = cv2.initUndistortRectifyMap( mtx, dist, None, newcameramtx, size, cv2.CV_32FC1 ) return mapx, mapy # 对每一帧: # undistorted = cv2.remap(frame, mapx, mapy, cv2.INTER_LINEAR)initUndistortRectifyMap生成两个浮点映射表,mapx和mapy分别表示目标图像每个像素在原始图像中的采样坐标。用CV_32FC1精度足够,不用CV_16SC2,后者在插值时会有轻微精度损失。生成一次映射表后,视频流里的每帧处理就只是一个查表加插值操作,4088x3072 分辨率的图像也能实时跑。
5.4 标定结果保存与格式约定
把内参和畸变系数保存为 NumPy 格式,方便后续加载:
np.savez('camera_calib.npz', mtx=mtx, dist=dist, newcameramtx=newcameramtx) with np.load('camera_calib.npz') as data: mtx_loaded = data['mtx'] dist_loaded = data['dist']如果你要在 C++ 或者其他语言里复用,直接导出为 JSON 或 YAML 更通用。注意保存时保留原始图像分辨率,否则换分辨率之后内参矩阵里的fx, fy, cx, cy都不能直接沿用,需要按比例缩放。缩放公式是fx_new = fx * new_width / old_width,主点同理。
6. 进阶验证技巧:用棋盘格反投影检查标定板平面度
标定做完后,除了重投影误差,还有一个更直接的验证方法:把每张标定照片里的棋盘格角点用标定得到的外参反投影回三维空间,检查这些点是否落在同一个平面上。这个技巧能暴露一个重投影误差无法发现的问题——标定板本身不平时,内参和畸变系数会被棋盘格的弯曲补偿掉,但你换一个场景拍摄,去畸变效果就会劣化。
def check_planarity(obj_points, rvecs, tvecs, mtx, dist): for i in range(len(obj_points)): world_points = obj_points[i].squeeze() # 用该图像的外参把局部坐标转到相机坐标 rot_mat, _ = cv2.Rodrigues(rvecs[i]) cam_coords = (rot_mat @ world_points.T).T + tvecs[i].ravel() # 拟合平面并计算点到平面的最大距离 centroid = cam_coords.mean(axis=0) centered = cam_coords - centroid _, _, vh = np.linalg.svd(centered) normal = vh[-1] distances = np.abs((cam_coords - centroid) @ normal) print(f"image {i}: max plane deviation = {distances.max():.4f} mm")这段代码的思路是:棋盘格上所有角点在理想情况下共面,所以先把角点通过外参变换到相机坐标系,然后做 SVD 拟合平面,最后计算每个角点到拟合平面的距离。如果最大偏差超过 0.1 个格子边长,说明标定板有弯曲或者外参解算不稳定。这个检查不需要额外采集数据,直接用标定时的中间结果就能跑。
另一个容易被忽略的验证点是:检查去畸变后的图像里,画面边缘的直线是否变直。你把一张有直尺、墙面线条或任何直线结构的照片做去畸变,然后在图像编辑器里拉一条参考直线对比。如果边缘直线仍然弯曲,说明畸变系数没有完全拟合,可能原因是你拍的照片里棋盘格没有覆盖到画面边缘。重新采集时,把棋盘格尽量贴近画面四角。
最后说一下我的习惯:每轮标定我都会固定随机种子采集照片顺序,然后在同一台相机上重复标定 3 次,比较 3 次内参矩阵的差异。如果fx的波动超过 0.5%,说明采集数据里有不可靠的照片,需要筛选重拍。这套流程走完,你手里的camera_calib.npz才算是真正能直接用于后续开发和验证的产物。
本文还有配套的精品资源,点击获取