写这篇东西的起因,是我前阵子拿一台老相机做三维重建,重建出来的点云距离一量,歪得离谱。排查了半天,发现连最基础的内参都是凭感觉填的,压根没正经标定过。后来把标定这块踏踏实实补上,重投影误差从好几个像素降到0.1以内,后续的测距、重建才真正能看。这篇就分享一下我获取内参的完整流程和踩过的坑,适合刚接触计算机视觉、SLAM、三维重建,或者单纯想把相机测距做准的开发者。
1. 为什么每台相机都需要自己的"身份证"——内参到底在解决什么问题
1.1 从一张照片里的"平行线"说起
用过广角镜头拍建筑的人都有体会:明明笔直的墙边线,拍出来是弯的,越靠近画面边缘弯得越厉害。这不是镜头坏了,而是所有光学镜头都存在的物理现象。镜头把三维世界投影到二维传感器上,本身就是一个有损的、带变形的过程。
我们做视觉测量时,真正想要的是"从图像像素坐标反推空间方向"。但图像上的一个点,和真实世界里那个点的几何关系,被两件事扭曲了:一是镜头本身的透视投影参数,也就是焦距、光心这些;二是镜头畸变,也就是桶形、枕形变形。这两类东西合在一起,就是相机内参。
引用一句业内的老话:相机标定就是把相机的"脾气"摸清楚。没有这份参数,你拍到的棋盘格在图像里是弯的,程序却把它当直线处理,后面所有计算自然全错。
1.2 内参矩阵K和畸变系数到底长什么样
内参通常分两部分。第一部分是内参矩阵K:
K = [fx 0 cx] [ 0 fy cy] [ 0 0 1]fx、fy是归一化焦距,单位是像素,它把物理尺度(比如毫米)映射成像素尺度。cx、cy是光心位置,一般接近图像中心,但不完全等于图像宽高的一半,因为镜头装歪、传感器切割都会带来偏移。
第二部分是畸变系数,OpenCV最常见的是五参数模型(也称Plumb Bob模型):
径向畸变:k1, k2, k3 切向畸变:p1, p2k1、k2负责解决画面边缘的桶形或枕形变形,k3一般配合标定板大幅面使用。p1、p2解决的是镜头与传感器不平行带来的"梯形"变形。在某些标定工具里还支持更多高阶项,但对绝大多数场景,五参数足够。
1.3 生活化类比:内参就像眼镜的验光单
如果你近视加散光,配眼镜需要一张验光单,上面写着球镜、柱镜、轴位,镜片师傅靠这张单子磨出适合你的镜片。相机内参就是这张"验光单":焦距告诉你"这台相机看世界放大了多少",畸变系数告诉你"边缘变形该怎么补偿"。没有它,图像就是"裸眼"状态,计算机拿到的是一张变形的、不可信的图。
搞懂了这个逻辑,你就明白我踩的第一个坑:当时为了省事,直接拿厂商给的标称焦距当fx用,结果重建出来的点云全都向内塌陷。因为厂商给的是设计值,实际装配、热胀冷缩、对焦环位置都会让真实值偏离设计值。
2. 采集照片之前,先把这些准备工作做扎实
2.1 标定板的选择:精度和易用性怎么平衡
获取内参第一步,不是写代码,而是准备一套靠谱的标定板。常见选择有棋盘格、圆点阵列板、Charuco板。
棋盘格是入门最常用的,原因很直接:
- 角点检测算法成熟稳定,OpenCV直接内置找角点API
- 角点位置精度高,适合做亚像素细化
- 打印成本低,一张A4纸就行
圆点阵列板(如不对称圆点图案,常用在工业标定里)的优势是圆心检测精度高,且对运动模糊、光照变化更鲁棒,因为圆心的椭圆拟合不受透视影响。缺点是OpenCV没有内置直接的检测器,需要自己处理轮廓拟合。
Charuco板是ArUco和棋盘格的结合,好处是部分遮挡也能标定,且每块板自带ID,适合视频流标定。但对第一次做内参标定的人来说,信息量偏大,建议先拿标准棋盘格跑通全流程再说。
我的建议:先打印一张9x6的棋盘格,每格边长30mm或者更小,适用于普通场景;如果是工业相机+微距镜头,建议用高精度玻璃基板或陶瓷基板,打印纸的平整度在大倍率下会直接毁掉标定结果。
2.2 打印和固定:细节决定成败
打印棋盘格有几个容易犯的错,我逐一踩过:
- 格子数量搞错:市面上说的"9x6棋盘格",指的是内角点9列6行,也就是内部交点有9x6=54个,不是棋盘格本身有9x6个格子。检测时传给OpenCV的是内角点的行列数,错了直接全盘失败。
- 黑白格方向混乱:打印时要保证最外层是黑格或白格其实无所谓,但必须在程序中明确知道内角点行列数,且检测结果的行列顺序要一致。
- 纸张不平整:这是新手最容易忽略的问题。A4纸打印的棋盘格如果贴在不平的桌面上、或者用胶带只粘一角,整张板就是扭曲的。标定算法假设标定板是理想平面,板一旦变形,内参就会被这个误差带偏。
- 尺寸测量不准:角落那点距离差1mm,对普通网络摄像头影响不大,但对高分辨率工业相机,误差会直接进fx、fy。
正确的固定方法:把棋盘格平整地贴在硬纸板或铝塑板上,用重物压一晚再裁边。如果条件允许,打印时选哑光纸,避免反光。
2.3 拍摄姿势与数量:"多角度、全覆盖"不是口号
采集图像是整个标定流程里最影响最终质量的环节,没有之一。网上的教程常说"拍15-20张",但没说清楚这些照片该怎么拍。我总结出一套可行的采集规则:
- 张数:15-25张之间,太少解不出稳定内参,太多也不会明显提升精度,反而增加处理时间。
- 角度:棋盘格平面与相机光轴之间的夹角要尽量变化,从正面平视、左右倾斜45度左右、上下俯仰,都要覆盖。如果所有照片都是正面拍的,标定算法无法解出畸变参数,结果会非常虚。
- 位置:让棋盘格在画面里到处移动,四角和中心都要放。畸变在画面边缘最明显,只在中心拍,标定出来的畸变系数基本没有参考意义。
- 距离:板子离相机要远近结合。远距离覆盖全画面,近距离让格子占画面的一半以上。
- 光照:保持光线均匀,不要让板的表面出现高光反射或阴影遮挡。用手持补光灯或自然光均匀照明,效果最好。
这里有一个很多教程不会提的关键点:拍标定照片时,焦点和对焦模式必须固定。如果你用手机或者带自动对焦的镜头,每张照片对焦位置都变,等价于焦距在变,那你还标定什么呢?我的做法是把相机设成手动对焦,先用标定板在目标工作距离上对焦,然后锁定对焦,再开始采集。
2.4 采集时最常见的"我以为是"
以下几条来自我真实验证过的教训,每条都真金白银花过时间:
- 不要站在强光下拍,尤其是板子反光,角点检测会把高光误判为白色格子的中心。
- 不要手抖,长曝光下棋盘格轻微运动,角点提取就会糊。
- 不要在画面里出现多个棋盘格干扰物(比如背后墙上贴的格子海报),检测器可能选错板。
- 不要用变焦镜头在不同焦段乱拍,内参只对单一焦段有效。
3. 张正友法的工作原理:一张棋盘格如何反推出相机参数
3.1 平面靶标怎么变成一张数学方程
采集够了图片,下一步要理解算法干了一件事:通过三维世界里的一个平面(标定板)和它在图像上的投影,解出相机模型参数。经典方法是张正友在1998年提出的平面标定法,几乎所有主流库(OpenCV、MATLAB、ROS camera_calibration)底层都是它。
数学上,一个三维点P在棋盘格平面上可以被表示成P=[X, Y, 0, 1],也就是Z坐标为0。投影方程简化后变成:
s * [u, v, 1]^T = K * [r1, r2, t] * [X, Y, 1]^T这里的[r1, r2]是旋转矩阵前两列,t是平移向量,三者合一构成一个3x3的单应矩阵H。对每一幅图像,我们都能通过角点对应关系解出一个H。这个H里既包含内参K,也包含该幅图像的外参(旋转和平移)。通过多幅图像、多个H,把K里五个未知数(fx, fy, cx, cy再加一个倾斜项)的约束叠加起来,就能解出闭环解。
3.2 从闭合解到精细优化:标定是两步走
解出闭式解之后,张正友法还会做一步极大似然估计:把刚才求到的内参、外参作为初始值,再用所有角点的重投影误差作为代价函数,对所有参数做非线性优化。OpenCV里用的是Levenberg-Marquardt算法,目标是最小化:
sum(||x_i - K[R|t] X_i||^2)也就是让"棋盘格角点的检测位置"与"用当前参数投影回来的位置"之间的误差最小。
3.3 为什么棋盘格这么受欢迎
棋盘格受欢迎不是偶然。对于平面标定,我们需要精确知道角点在图像上的亚像素坐标。棋盘格角点天然具备两个优势:正交梯度结构让角点定位更稳定,双色对比强烈让检测不受光照线性变化影响。相比之下,纯圆点板需要拟合椭圆并做透视校正,流程更复杂。
补充一个冷知识:我们拍到的棋盘格在图像里往往是畸变的,也就是格子的边缘不是直线。标定算法做角点检测时并不要求格子是直的,因为它的目标恰恰是利用这种"变形"来反推畸变系数。这正是为什么畸变参数必须用边缘图像来激活。
4. 基于OpenCV的完整标定流程与参数含义
4.1 代码前的准备:确定你的目标相机与工作场景
开始写代码前先确认三件事:
- 这台相机是固定使用的,还是每次都要拆装?如果是可拆装的工业相机,建议每次重新安装镜头后都重新标定。
- 标定时用的分辨率要和实际使用时一致。如果你采集时是1920x1080,用了1280x720去做标定,内参完全不适用。
- 对焦距离要接近你的典型工作距离。微距相机在10cm处标定的内参,放到2米处就是错的。
4.2 完整的Python脚本与注释
我用的是Python + OpenCV,代码结构简单清晰,适合初学者跑通全流程:
import cv2 import numpy as np import glob # 1. 参数设置 CHESSBOARD_SIZE = (9, 6) # 内角点行列数 SQUARE_SIZE_MM = 30.0 # 每格边长,单位mm # 2. 生成世界坐标系中的角点 # 标定板所在平面Z=0,角点坐标以mm为单位 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_MM obj_points = [] # 所有图像对应的世界坐标点 img_points = [] # 所有图像上检测到的像素坐标点 # 3. 读取图像并检测角点 images = glob.glob('calib_images/*.jpg') print(f"共找到 {len(images)} 张标定图像") for fname in sorted(images): img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 查找棋盘格角点。返回是否找到以及角点坐标 ret, corners = cv2.findChessboardCorners( gray, CHESSBOARD_SIZE, flags=cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_FAST_CHECK + cv2.CALIB_CB_NORMALIZE_IMAGE ) if ret: # 亚像素精细化角点坐标 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners) # 可视化:在图上画出角点便于检查 cv2.drawChessboardCorners(img, CHESSBOARD_SIZE, corners, ret) cv2.imshow('Chessboard', img) cv2.waitKey(100) cv2.destroyAllWindows() print(f"有效标定图像数量: {len(obj_points)}") # 4. 执行相机标定 ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) print("重投影误差 RMS:", ret) print("内参矩阵 K:") print(mtx) print("畸变系数 [k1, k2, p1, p2, k3]:") print(dist)这段代码里有几个参数需要认真解释:
findChessboardCorners的flags参数我开了三个选项。CALIB_CB_ADAPTIVE_THRESH是对图像做自适应阈值,能抵抗光照不均;CALIB_CB_NORMALIZE_IMAGE进一步做直方图均衡,两者配合能让低对比度图像里的角点更容易被找到;CALIB_CB_FAST_CHECK是快速预判断,能跳过明显不含棋盘格的图像,节省时间。
cornerSubPix做亚像素细化,窗口大小(11,11)表示在该范围内搜索梯度与向量点积最小的位置。窗口太小细化效果差,太大会跨越到相邻格子。对于常见1080p图像,(11,11)是稳妥的起点。
calibrateCamera返回四个值:RMS误差、内参矩阵、畸变系数、每张图的外参。外参(rvecs,tvecs)里包含旋转向量和平移向量,有兴趣可以做后续位姿估计,但内参标定阶段先不看。
4.3 calibrateCamera里的关键参数与flags
OpenCV的calibrateCamera参数里,除了最基本的obj_points, img_points, image_size,还有一个可选参数flags,很多教程不提,但它直接决定模型复杂度:
| 标志位 | 作用 | 适用场景 |
|---|---|---|
| 默认(0) | 使用fx, fy, cx, cy + k1, k2, p1, p2,共8个参数 | 大多数普通镜头 |
| CALIB_FIX_K3 | 固定k3=0不优化 | 通用镜头不需要第三径向项 |
| CALIB_ZERO_TANGENT_DIST | 强制p1=p2=0 | 镜头装配较正时 |
| CALIB_FIX_PRINCIPAL_POINT | 光心固定为图像中心 | 传感器平移误差极小时 |
| CALIB_SAME_FOCAL_LENGTH | fx=fy | 像素是正方形的相机 |
对于第一次做标定,我建议使用默认flags,让所有参数都参与优化。如果发现k3标出来特别小(小于0.01),或者RMS没改善,再尝试加CALIB_FIX_K3,通常能让整体求解更稳定。
4.4 标定结果如何存盘与加载
标定结果要存成文件,方便后续使用。推荐的方式是把K和dist存成NumPy的.npz:
np.savez('camera_intrinsics.npz', mtx=mtx, dist=dist) # 下次使用时 with np.load('camera_intrinsics.npz') as data: mtx = data['mtx'] dist = data['dist']如果你用的是ROS,常用的是sensor_msgs/CameraInfo消息格式,可以用camera_calibration_parsers包转换。但大部分独立项目用.npz够用。
5. 重投影误差和畸变系数:如何判断标定结果靠不靠谱
5.1 RMS到底多少才算"好"
calibrateCamera返回值ret就是RMS重投影误差,单位是像素,含义是所有角点的投影误差的均方根。很多人拿到0.5就觉得完了,拿到0.05就觉得稳了。其实RMS是相对值,需要结合图像分辨率和应用场景看。
经验法则:
- RMS < 0.1像素:非常理想,通常是硬质标定板 + 光照均匀 + 足够多的高质量图像。
- 0.1~0.3像素:合格,普通打印棋盘格、真实相机抖动下常见水平。
- 0.3~0.5像素:勉强可用,适合精度要求不高的定性分析,不适合精密测距。
0.5像素:需要排查问题,大概率是标定板不平、对焦漂移、或角点检测选错了。
但要注意,RMS低不代表内参一定准。比如所有标定图像都是正面拍摄,畸变参数可能解出一个"假的最小值",RMS照样很低,但真实畸变完全没被纠正。反过来RMS略高但图像覆盖全面,内参可能比前者更可信。这就是为什么要强调采集时覆盖全面的原因。
5.2 检查fx、fy、cx、cy的物理合理性
拿到内参矩阵后,别急着高兴,先用常识做一个"体检":
- cx、cy应该非常接近图像中心。比如1920x1080的图像,cx应该在960左右,cy在540左右。偏差超过20像素说明镜头光心偏移大,检查是否装配异常。
- fx和fy应该接近。对于方形像素的传感器,两者相差在1%以内都算正常。如果相差超过5%,需要考虑传感器是否压缩、图像是否被拉伸。
- 焦距像素值可以反算物理焦距:
f_mm = fx * sensor_width_mm / image_width_px。比如传感器宽度6.4mm、图像宽度1920px、fx=1200px,物理焦距约4mm。这和镜头上标注值对得上,说明结果可信。
我当时标出来的fx比标称值多了约3%,起初还以为是算法错了,后来检查才知道是镜头自己在不同距离对焦时,焦距确实在细微变化。
5.3 用畸变系数做进一步验证
畸变系数的符号和大小也有规律:
- 径向畸变k1通常为负,对应常见的桶形畸变(图像边缘向外膨胀)。如果k1为正,说明是枕形畸变。
- k2一般比k1小一个数量级,如果k2比k1大,说明模型选择可能有误,尝试增加或减少畸变项。
- p1、p2的值通常非常小(接近0),如果它们大到和k1一个量级,可能是镜头存在明显的切向偏移,注意检查镜头卡口。
5.4 逐图重投影误差分析:找出"坏图"
整体RMS达标,不代表每张图都合格。有一招很实用:把每张图的角点通过projectPoints投影回图像,算单图误差。
# 逐图计算重投影误差 for i in range(len(obj_points)): imgpts, _ = cv2.projectPoints(obj_points[i], rvecs[i], tvecs[i], mtx, dist) error = cv2.norm(img_points[i], imgpts, cv2.NORM_L2) / len(imgpts) print(f"第{i+1}张图误差: {error:.3f} px")通常一两张图误差明显高于其他图(比如某张角度太极端、有反光),可以直接删掉重新标定。删掉那些误差最高的图,整体RMS常常能下降一半。这个方法在很多教程里都没有,但实际排查非常有效。
5.5 用一张新图做"盲测"
最后一步,我自己一定会做:拍一张新的、没有参与标定的棋盘格图像,用它做位姿估计或畸变校正,视觉上确认棋盘格直线回归直线。
# 选择一张新图像 img = cv2.imread('test_image.jpg') h, w = img.shape[:2] # 获取优化后的内参(考虑全部像素,OpenCV会自动微调fx/fy来适配新图像尺寸) newcameramtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), 1, (w, h)) # 畸变校正 undistorted = cv2.undistort(img, mtx, dist, None, newcameramtx)校正后的图像如果棋盘格边缘都变成平直线条,标定基本可信。这一步如果看到边缘还有明显的弧线,说明畸变模型没搞对,回到采集环节重新检查。
6. 实战踩坑清单:从采集到结果的全流程排错
6.1 坑一:只拍正面图,"完美"的RMS是个陷阱
我第一次标定时,老老实实拍了15张,但都是把棋盘格正对着相机的。结果RMS=0.08,特别漂亮,我还挺开心,结果测距一测就翻车。因为畸变参数在正面画面下几乎不可观,kk1直接解成了0附近,程序拿着"无畸变"的错误模型去做真实畸变图像,自然准不了。
教训:RMS达标一定要和"采集角度覆盖度"放在一起看。算法不会告诉你它没见过的参数是不可信的,它只会告诉你在它见过的数据里拟合得很好。
6.2 坑二:角点检测"串位"或漏检
有几次findChessboardCorners返回False,一查原因是棋盘格轮廓之一被反光切断了,角点结构不完整。解决方法是换拍摄角度,或者给棋盘格补光消除光斑。
还有一种情况:返回的角点顺序对,但detected数量不对,比如9x6的板子检测成了9x5。这通常是因为棋盘格的一个边缘被图像边界裁掉了。拍摄时让棋盘格四周留出足够空白,别正好顶到画面边缘。
6.3 坑三:标定完发现fx或fy是负数或畸形
这个一般不是算法问题,而是object_points的顺序和img_points的顺序没对齐。比如某一幅图的角点顺序是反的(棋盘格旋转了180度),当所有图像一起优化时,这一幅会把其他图拉偏。排查方式就是逐图打印rvecs/tvecs,检查平移向量的z值是否都为正(标定板在相机前方)。
6.4 坑四:标定结果"飘",每次重标差很多
如果你同一台相机,每次标出来的fx/fy差超过1%,先别怀疑算法,检查这三件事:
- 镜头锁环有没有锁紧?很多C口镜头没有锁紧装置,轻轻一碰焦距就变。
- 采集时相机温度是否变了?工业相机从冷启动到稳定运行,焦距会随温度漂移,等相机热机半小时再标。
- 标定板是不是换了?每次用不同尺寸的板子,SQUARE_SIZE_MM填错,fx/fy就会被等比缩放。
6.5 坑五:使用了错误的分辨率拍摄
有人会用相机最高分辨率做标定,但实际跑算法时改用了低分辨率裁剪。内参矩阵里的fx/fy是像素单位,分辨率变了,数值必须按比例缩放,不能直接沿用。最稳妥的方式就是"标定分辨率=使用分辨率"。
6.6 几个实操小技巧
分享几个我后来稳定下来的习惯:
- 每轮标定前,先删掉那些棋盘格占画面比例过小的图。棋盘格太小,角点定位精度会打折。
- 用
cv2.cornerSubPix之前先放大图像检查角点,肉眼看到的"对齐了"和算法看到的"模糊了"是两回事。 - 标定完一定要做一次
undistort可视化测试,不要只看数字。 - 如果在一批标定图里总有一两张让整体RMS变差,直接删,标定这件事宁可少而精,不要多而杂。
结尾
关于内参获取,我最想强调的一点是:标定不是一次性任务,而是一个需要周期性重做的保养工作。相机的温度变化、镜头拆装、对焦环位移、甚至运输途中的震动,都会让内参发生细微漂移。我在实际项目中养成的习惯是:每次项目开工前花十分钟重标一次,凡是要求高精度的测量任务,绝不直接用上一次的旧参数。
另外,标定代码本身并不复杂,真正影响结果的是采集端--光照、角度、板面平整度、相机稳定度,这些靠机器无法帮你自动规避,需要你在现场多观察、多思考。希望这篇实践笔记能帮你少走一些弯路,一次就把内参拿到位。