1. 从一张畸变照片说起:相机标定到底在标什么
你拍过那种边缘明显弯曲的照片吗?用广角镜头拍建筑,楼是歪的;用手机拍文档,纸是鼓的。很多人第一反应是“镜头不行”,但真相是:镜头本身没问题,问题在于我们没有告诉软件“这个镜头是怎么看世界的”。相机标定与图像校准,干的就是这件事——给相机建立一套数学描述,让它知道自己拍出来的像素和真实世界之间的对应关系。
我最早接触这个方向,是因为一个工业检测的小项目。当时用普通USB摄像头拍零件,想测量尺寸,结果同一块金属片,放在画面正中和放在角落,测出来的长度差了将近百分之三。三毫米的误差,在精密加工里就是废品。后来花了两天时间做标定,误差直接压到零点零几个像素。从那以后我就明白,任何用相机做定量分析的场景,标定都是绕不过去的第一道坎。
这篇文章适合谁看?如果你在做机器视觉、三维重建、AR/VR、自动驾驶感知、甚至只是想把手机拍的文档扫描得平整一点,那相机标定和图像校准就是你迟早要啃的骨头。我会从核心原理讲到实操步骤,把参数怎么算、代码怎么写、坑怎么避,全部摊开来说。不需要你有深厚的数学背景,但需要你愿意动手试。
2. 核心原理拆解:针孔模型、畸变与内外参
2.1 为什么用针孔模型来描述相机
相机标定的理论基础是针孔成像模型。你可以把相机想象成一个暗盒,前面扎一个小孔,光线从小孔穿过,在后面的感光平面上成像。这个模型虽然简单,但它抓住了核心:三维空间中的点,通过一个投影变换,映射到二维图像平面上。
数学上,这个投影过程可以写成:
s * [u, v, 1]^T = K * [R | t] * [X, Y, Z, 1]^T其中[u, v]是图像上的像素坐标,[X, Y, Z]是世界坐标系下的三维点,K是内参矩阵,[R | t]是外参矩阵,s是尺度因子。这个公式看起来吓人,但拆开看就三件事:内参决定相机怎么投影,外参决定相机在哪里、朝哪看,尺度因子负责把齐次坐标还原成像素坐标。
为什么针孔模型够用?因为绝大多数现代镜头的设计目标就是逼近这个理想模型。镜片组再复杂,最终目的都是让光线尽量按照针孔的方式汇聚。所以标定的本质,就是找到那个最接近真实成像行为的针孔模型参数。
2.2 内参矩阵里每个数代表什么
内参矩阵K通常写成:
K = [fx 0 cx] [0 fy cy] [0 0 1]fx和fy是焦距,单位是像素。注意,不是毫米,是像素。为什么用像素?因为我们要建立的是“世界坐标到像素坐标”的映射,焦距在图像上的体现就是“一个单位物理长度对应多少个像素”。cx和cy是主点,也就是光轴与图像平面的交点,理想情况下在图像正中心,但实际装配总会有偏差。
我见过很多人标定完只看重投影误差,误差小就以为万事大吉,结果做三维测量时发现尺度不对。问题往往出在fx和fy上。如果标定板的格子尺寸输入错了,或者标定板不够平整,fx和fy就会偏。内参的物理意义非常明确:它们决定了像素和真实长度之间的换算比例。你后面做任何测量,都依赖这个比例。
2.3 畸变:径向畸变和切向畸变
理想针孔模型假设光线是直线传播,但真实镜头有厚度、有曲率,光线经过镜片会发生弯曲,导致图像产生畸变。最常见的两种:
- 径向畸变:由镜片形状引起,表现为图像边缘向外鼓(桶形畸变)或向内缩(枕形畸变)。数学上用
k1, k2, k3等系数描述,通常只取前两个就够用。 - 切向畸变:由镜片与传感器不平行引起,表现为图像在一个方向上被拉伸。用
p1, p2描述。
畸变校正的公式一般是:
x_corrected = x * (1 + k1*r^2 + k2*r^4 + k3*r^6) + 2*p1*x*y + p2*(r^2 + 2*x^2) y_corrected = y * (1 + k1*r^2 + k2*r^4 + k3*r^6) + p1*(r^2 + 2*y^2) + 2*p2*x*y其中r^2 = x^2 + y^2,x, y是归一化后的图像坐标。这个公式不需要背,但你要理解:畸变系数是在标定过程中和内参一起优化出来的。标定板的作用,就是给优化算法提供足够多的“已知点”,让它能同时解出内参和畸变系数。
注意:畸变系数不是越多越好。如果你只用普通网络摄像头,取
k1, k2, p1, p2四个就够了。强行加k3甚至k4,反而会因为过拟合导致校正后的图像出现奇怪的波浪形。
2.4 外参:相机在世界中的位姿
外参矩阵[R | t]描述的是相机坐标系和世界坐标系之间的刚体变换。R是旋转矩阵,t是平移向量。如果你把标定板固定在世界某个位置,那么每次拍摄时相机的位置和角度不同,外参就不同。
这里有个容易混淆的点:内参是相机的固有属性,标定一次就够了;外参是每次拍摄都可能变化的。很多人做双目视觉时,把两个相机之间的外参也叫做“标定”,其实那是立体标定,目的是求两个相机之间的相对位姿。单目标定只求内参和畸变,外参是副产品。
3. 标定实操全流程:从打印标定板到验证精度
3.1 标定板的选择与制作
标定板是标定的“尺子”,它的精度直接决定标定结果的上限。常见的有棋盘格、圆点阵列、ArUco码等。棋盘格最常用,因为角点检测精度高,算法成熟。
制作标定板有几个硬性要求:
- 平整度:必须贴在硬质平板上,比如铝板或玻璃板。我试过直接打印在A4纸上,纸稍微有点翘,标定出来的畸变系数就飘了。
- 格子尺寸精度:打印时要用高分辨率,打印完用卡尺量一下实际格子边长。比如你设计的是25毫米,实际打印出来可能是24.8毫米,这个误差要代入计算。
- 黑白对比度:不要用反光纸,也不要用哑光纸,普通打印纸就行,但光线要均匀。
实操心得:如果预算允许,直接买一块陶瓷标定板。陶瓷的热膨胀系数低,平整度好,用几年都不会变形。自己打印的标定板,每次用之前都要检查有没有折痕或污渍。
3.2 拍摄采集:角度、数量和覆盖范围
拍标定板照片不是随便拍几张就行。我总结了一个“三三制”原则:
- 三个以上不同角度:正面、左倾、右倾、上仰、下俯,至少覆盖五个方向。
- 三个以上不同距离:近、中、远都要有,让标定板在画面中占据不同大小的区域。
- 三个以上不同位置:画面中心、四个角、边缘都要覆盖到。
为什么这么讲究?因为畸变在画面边缘最明显,如果所有照片都只拍画面中心,优化算法就“看不到”边缘的畸变,解出来的系数就不准。标定板要尽量出现在画面的每个区域,尤其是四个角。
数量上,一般15到30张就够了。太少,约束不够;太多,计算慢,而且如果有些照片质量差,反而拖累结果。我通常拍25张左右,然后手动剔除模糊的、标定板不完整的。
3.3 角点检测与参数初值估计
拍完照片,下一步是检测棋盘格角点。以OpenCV为例,核心函数是findChessboardCorners和cornerSubPix。前者做粗检测,后者做亚像素级精化。
import cv2 import numpy as np # 棋盘格内角点数量,比如 9x6 pattern_size = (9, 6) 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 # 格子实际物理尺寸,单位毫米 objpoints = [] # 三维点 imgpoints = [] # 二维点 for fname in image_list: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, pattern_size, None) if ret: corners_refined = cv2.cornerSubPix( gray, corners, (11, 11), (-1, -1), criteria=(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) ) objpoints.append(objp) imgpoints.append(corners_refined)cornerSubPix的窗口大小(11, 11)是个经验值。窗口太小,精化不够;窗口太大,容易受噪声影响。我试过(5,5)到(21,21),(11,11)在大多数情况下最稳。
3.4 标定计算与重投影误差评估
有了角点对,就可以调用calibrateCamera求解:
ret, K, dist, rvecs, tvecs = cv2.calibrateCamera( objpoints, imgpoints, gray.shape[::-1], None, None )ret就是重投影误差,单位是像素。它的含义是:用标定出来的参数,把三维点重新投影到图像上,和实际检测到的角点之间的平均距离。这个值越小,说明标定参数越能解释实际成像。
一般来说:
- 小于0.5像素:优秀
- 0.5到1.0像素:可接受
- 大于1.0像素:需要检查标定板质量或拍摄过程
但注意,重投影误差小不代表标定一定准。如果所有照片都是同一个角度,误差可能很小,但外参约束不够,内参可能不准。要结合拍摄多样性来判断。
3.5 图像校准:去畸变与透视校正
标定完成后,用undistort就能校正图像:
undistorted = cv2.undistort(img, K, dist, None, new_K)new_K是校正后的新内参矩阵,可以用getOptimalNewCameraMatrix计算。这个函数有个alpha参数:alpha=0会裁掉所有无效像素,alpha=1保留所有像素但边缘会有黑边。我一般取alpha=0.5到0.8之间,平衡视野和黑边。
如果要做透视校正,比如把倾斜拍摄的文档拉正,还需要计算单应矩阵:
src_points = np.float32([[x1,y1], [x2,y2], [x3,y3], [x4,y4]]) dst_points = np.float32([[0,0], [w,0], [w,h], [0,h]]) H = cv2.getPerspectiveTransform(src_points, dst_points) warped = cv2.warpPerspective(img, H, (w, h))这一步在文档扫描、车牌识别里非常常用。先做去畸变,再做透视校正,顺序不能反。因为畸变是非线性的,透视变换是线性的,先线性后非线性会引入额外误差。
4. 常见问题与排查技巧实录
4.1 重投影误差大,从哪里查起
重投影误差大是最常见的问题。我一般按以下顺序排查:
| 排查项 | 可能原因 | 解决方法 |
|---|---|---|
| 标定板尺寸 | 输入的格子尺寸和实际不符 | 用卡尺实测,重新输入 |
| 标定板平整度 | 纸张翘曲、有折痕 | 换硬质标定板,或压平后重拍 |
| 角点检测 | 图像模糊、光照不均 | 剔除模糊照片,改善照明 |
| 拍摄角度 | 所有照片角度太接近 | 补充不同角度和距离的照片 |
| 畸变系数 | 系数过多导致过拟合 | 减少k3等高阶项 |
我遇到过一次,误差始终在1.5像素以上,换了标定板、重拍了三次都没用。后来发现是镜头保护膜没撕。那层膜虽然透明,但引入了额外的折射,导致畸变模型怎么都拟合不好。撕掉膜之后,误差直接降到0.3像素。
4.2 校正后图像边缘出现黑边或拉伸
这是getOptimalNewCameraMatrix的alpha参数没调好。alpha=0时,函数会找一个能包含所有有效像素的最大矩形,裁掉黑边,但会损失视野。alpha=1时,保留所有像素,但边缘会有黑边和拉伸。
我的经验是:如果做测量,用alpha=0,保证每个像素都是有效的;如果做显示,用alpha=0.7左右,兼顾视野和美观。另外,校正后的图像分辨率会变化,如果后续要做像素级测量,记得用new_K重新计算像素和物理尺寸的对应关系。
4.3 双目视觉中标定结果不一致
双目视觉需要先分别标定两个相机,再做立体标定。常见问题是左右相机的重投影误差差异很大。原因通常是:
- 一个相机拍得好,另一个拍得差
- 两个相机的标定板照片不是同一时间拍的,标定板有移动
- 立体标定时,左右视图的角点匹配错误
解决办法:用同一个标定板,同时拍左右相机,确保标定板在两个画面中都清晰完整。立体标定用stereoCalibrate,它会同时优化两个相机的内参和它们之间的外参。如果左右误差差异大,先单独检查每个相机的标定结果。
4.4 标定结果在不同分辨率下不通用
内参矩阵是和图像分辨率绑定的。如果你用1920x1080标定,然后换成1280x720拍摄,内参不能直接用。需要按比例缩放:
fx_new = fx_old * (new_width / old_width) fy_new = fy_old * (new_height / old_height) cx_new = cx_old * (new_width / old_width) cy_new = cy_old * (new_height / old_height)畸变系数不变,因为它们是归一化坐标下的参数。但注意,如果分辨率变化涉及裁剪而不是缩放,主点位置需要重新计算。我一般建议在最终使用的分辨率下做标定,避免换算带来的误差。
4.5 温度变化对标定的影响
这个坑很多人不知道。镜头和相机传感器都会热胀冷缩,温度变化几度,焦距就可能偏移零点几个像素。在精密测量场景,比如半导体检测,温度变化一度都可能超差。
我的做法是:在恒温环境下标定,并且在使用前预热相机至少30分钟。如果工作环境温度变化大,可以考虑定期重新标定,或者用温度传感器做补偿。普通消费级应用不用这么讲究,但工业级必须考虑。
5. 进阶话题:从单目到多目,从标定到自标定
5.1 单目标定的局限与多目扩展
单目标定只能得到内参和畸变,无法得到绝对尺度。也就是说,你从图像里测出一个物体长100像素,但不知道它实际是10毫米还是10米。要得到绝对尺度,要么在场景里放一个已知尺寸的参照物,要么用多目视觉。
双目视觉通过两个相机的视差来计算深度,基线长度决定了深度测量的精度。基线越长,远处精度越高,但近处盲区越大。我做过一个项目,基线从10厘米增加到30厘米,三米外的深度误差从5毫米降到1.5毫米。但代价是,一米内的物体经常只有一个相机能看到。
多目视觉(三个以上相机)可以解决遮挡问题,但标定复杂度指数上升。每增加一个相机,就要多标定一次它和参考相机的外参。如果做实时系统,还要考虑多相机同步触发。
5.2 自标定:没有标定板怎么办
有些场景没法放标定板,比如监控摄像头、无人机航拍。这时候可以用自标定技术,利用场景中的直线、 vanishing point 等几何特征来估计内参。
自标定的精度通常不如传统标定,因为它依赖场景特征的质量。但在某些场景下是唯一选择。OpenCV里没有现成的自标定函数,需要自己实现。我一般用 vanishing point 方法:找三组互相垂直的平行线,它们的消失点可以约束内参。
注意:自标定对噪声非常敏感。如果图像有畸变,消失点检测会偏,导致内参估计不准。通常需要先做粗略的畸变校正,再自标定,迭代几次。
5.3 标定结果的保存与复用
标定一次不容易,结果要妥善保存。我通常存成YAML或JSON格式,包含:
- 图像分辨率
- 内参矩阵
K - 畸变系数
dist - 重投影误差
- 标定日期和温度
calibration: date: "2025-01-15" temperature: 23.5 image_size: [1920, 1080] K: [[1200.5, 0, 960.2], [0, 1200.3, 540.1], [0, 0, 1]] dist: [0.12, -0.05, 0.001, 0.002, 0.0] reprojection_error: 0.32下次用的时候直接读取,不用重新标定。但如果相机摔过、换过镜头、或者温度变化超过10度,建议重新标定。
6. 我踩过的那些坑与最后分享的几个技巧
第一个坑:标定板打印后没有实测尺寸。我按设计值25毫米输入,实际打印出来是24.6毫米。结果所有测量都偏小1.6%。后来养成习惯,每次打印完都用卡尺量三个格子,取平均值。
第二个坑:用自动对焦镜头标定。自动对焦会改变镜头组位置,导致焦距变化。标定完,换个距离拍摄,对焦一变,内参就失效了。标定必须用固定焦距镜头,或者把自动对焦锁死。
第三个坑:忽略图像噪声。在暗光下拍摄,角点检测会偏。我试过用同一组标定板,在暗光和亮光下各标一次,内参差了将近2%。所以标定时要保证光照充足且均匀。
最后分享一个小技巧:如果你没有标定板,可以用手机屏幕显示棋盘格。把手机亮度调到最高,显示一个标准棋盘格图像,然后放在硬质平板上。虽然精度不如专业标定板,但应急足够用。我出差时经常这么干,标定出来的误差也能控制在0.5像素以内。
还有一个技巧:标定完成后,用一张没参与标定的照片做验证。拍一张标定板放在新位置的照片,用标定参数去畸变,然后检测角点,看直线是否真的直了。这比只看重投影误差更直观。
这个方向后续还可以往在线标定扩展,也就是在系统运行过程中,利用自然场景特征持续修正标定参数。自动驾驶和机器人领域已经在用这种方法,因为车辆震动会导致相机位姿缓慢变化,离线标定不够用。如果你对这个感兴趣,可以先从光流法估计相机运动入手,再结合BA优化,逐步深入。