第一次做相机畸变校准的时候,我踩了几乎所有新手都会踩的坑:拿着棋盘格怼在镜头正前方,咔咔拍了二十多张“标准证件照”,然后用标定脚本一跑,重投影误差直接飙到2个像素以上。后来才明白,畸变校准的难点根本不在算法本身,而在于你有没有把标定板放到该放的位置、有没有控制好拍摄质量。相机畸变校准解决的问题非常直接:让镜头拍出来的直线重新变直,让图像里的像素坐标和真实世界的物理坐标能够准确对应。无论是做机器视觉测量、三维重建、AR叠加,还是单纯想把广角照片的桶形畸变修掉,这一关都绕不开。这篇文章我会把从原理、采图、标定到结果验证的完整流程写清楚,适合刚入门的机器视觉开发者,也适合被标定误差折磨过的老手。
1. 畸变校准到底在解决什么问题
1.1 畸变的两大门类:径向畸变和切向畸变
先说清楚畸变是怎么来的。理想情况下,针孔相机模型假设光线沿直线穿过小孔映射到传感器上,但真实镜头是一组透镜,光线经过透镜时会发生折射,越靠近镜头边缘,折射和成像面的位置偏差就越明显。这种呈放射状分布、越靠画面边缘越严重的畸变,叫径向畸变。
径向畸变又分两种典型形态。最常见的桶形畸变,画面边缘向外膨胀,直线在边缘变成向外弯的弧线,拍广角人像时人脸边缘会“拉宽”,拍建筑时楼体会变成弧形;还有一种枕形畸变,画面边缘向中心收缩,直线向内弯,常见于长焦镜头。它们的数学表达很统一,标准的布朗-康拉迪(Brown-Conrady)模型用三阶多项式描述:
x_distorted = x * (1 + k1 * r² + k2 * r⁴ + k3 * r⁶) y_distorted = y * (1 + k1 * r² + k2 * r⁴ + k3 * r⁶)
其中 r 是像素点到主点的距离,k1、k2、k3 就是径向畸变系数。大多数常规镜头只需要 k1、k2 就够用了,k3 主要给大广角或者鱼眼镜头做高阶修正。为什么是偶数次项?因为畸变沿径向左右对称,奇数次项会在正负方向产生不对称的偏移,不符合透镜旋转对称的物理特性。
另一类是切向畸变,用 p1、p2 两个参数表示。它主要不是镜头光学设计导致的,而是镜头模组装配工艺不完美——比如镜头镜片的光轴和传感器平面不完全垂直,或者镜片本身存在极其微小的偏心。结果就是画面某些局部区域的点会同时沿 x 和 y 方向产生额外偏移,表现为“梯形”或“旋转”样式的变形。
1.2 不校畸变会带来什么麻烦
如果把畸变问题抛到脑后,直接用带畸变的图像去做后续处理,后果是实打实的。举几个我实际碰过的例子:
第一个是测量。在某生产线的零件尺寸检测项目里,相机视野宽度大约200毫米,镜头边缘处的桶形畸变让一个实际长50毫米的零件在画面边缘被测成了48毫米,误差4%,完全无法用于品控。这种误差不是算法能弥补的,必须在尺寸换算之前先把畸变修掉。
第二个是视觉定位与导航。SLAM、机器人抓取这类场景依赖像素坐标反算空间坐标,畸变模型不准,前方测距误差会随视场角增大而指数放大。AR叠加也会出现“贴不住”的现象——虚拟物体明明对准了真实物体的位置,镜头一转就漂。畸变校准好之后,这些漂移问题能减轻一大半。
第三个是三维重建。多视角重建需要精确的特征匹配和三角化,畸变不校正会导致同一个三维点在两个视图里的投影位置系统性偏离,重建出来的表面鼓包、拉丝,点云后期怎么清洗都救不回来。
所以畸变校准不是“可做可不做的优化”,而是所有依赖像素坐标与空间坐标关系的视觉系统的基础设施。它要输出的最终结果,就是一套相机内参矩阵和畸变系数,让后续所有算法能在一个“无畸变的虚拟理想相机”下工作。
2. 动手前第一课:标定板与采图技巧
2.1 棋盘格标定板怎么选、怎么制作
最常见的标定板是棋盘格,因为它角点特征明确、检测算法成熟。选棋盘格时要注意三个参数:内角点数、方格边长、表面平整度。
棋盘格标注的尺寸必须用“内角点”而不是格数。比如一个9x6的棋盘格,指的是每行9个黑白色块交界的内角点、每列6个内角点,而不是整个棋盘有9行6列方块。搞混这一点是新手最容易翻车的地方,后续找角点全部失败。
方格边长建议根据工作距离和相机分辨率选。视野内棋盘格占比越大、单个方格在图像里占的像素越多,角点定位越准。一般保证每个方格在图像里至少占20到30个像素。方块边长用游标卡尺精确测量,记录下来,单位是毫米,因为标定算法需要真实物理尺寸来计算尺度信息。很多人打印完直接拿尺子量个大概,其实这里差一毫米,测量结果就整体差一截。
制作标定板时,最关键的是“平”。我早期省钱,用A4纸打印棋盘格贴在纸箱板上面,结果纸面有细微起伏,角点检测倒没问题,但标定出来的焦距和畸变参数总是不稳定,重投影误差一直在1像素以上徘徊。后来换成了照片级打印加2到3毫米铝板,用双面胶或专用压板固定,一次标定误差就降下来了。工业场景可以买陶瓷或玻璃基底的光学标定板,精度更高,但入门阶段自己打印加铝板完全够用。
如果相机视场角特别大(鱼眼镜头),或者应用要求自动检测标定板,还可以用圆形阵列标定板或 ChArUco 板。圆形阵列在极端视角下仍能可靠检测,ChArUco 板结合了棋盘格和ArUco标记的优点,可以只靠部分视野里的标记完成位姿估计,但基础畸变校准时棋盘格仍然是首选。
2.2 采图数量与角度分布的黄金法则
标定算法本质上是解一个方程组,方程组要唯一可解,需要棋盘格在不同位置、不同姿态下提供足够多且独立的约束。所以采图不是越多越好,而是覆盖度越高越好。
我个人的经验准线是这样的:标准视野的镜头至少拍20张,大广角镜头建议25到30张。数量不足时,求解出的内参容易陷入局部最优,换一组图标定结果就明显变化。
角度分布上,最容易犯的错是“所有照片都让棋盘格正对相机”,这样所有图像约束高度相似,相当于只提供了同一个视角的重复信息。正确做法是让标定板在画面中形成丰富的姿态:
- 覆盖画面中央、四个角落、四条边的中点,确保畸变最严重的边缘区域也有角点数据。
- 让标定板相对镜头分别做俯仰、偏转、旋转,倾角控制在15到45度之间,既能提供足够的透视变换信息,又不至于让角点检测失败。
- 最好在多个距离上采集,比如近景一组、中景一组,让 r² 的高阶项也能被可靠约束。
- 绕光轴旋转棋盘格,90度一组轮换,这样能同时约束切向畸变。
我当时有个习惯是把拍好的图片全部输进一个脚本,给每张图标注棋盘所在区域,如果20张图里18张都在画面中心,就重新补拍边缘位置的素材。后来发现这一步对最终误差的贡献丝毫不亚于算法本身。
2.3 成像质量对标定结果的直接影响
标定用的是棋盘格角点坐标,角点定位精度直接决定了标定精度。所以拍标定图时,成像质量必须比平时拍照更苛刻:
一是对焦。轻微失焦会让角点周围出现模糊梯度,亚像素角点提取会产生系统性偏差,表现为重投影误差整体偏高且难以收敛。拍摄时把对焦方式改为手动,锁定在标定板位置,不要让自动对焦来回跳动。
二是光照。避免强光直接打在棋盘格上产生反光或镜面高光,也避免半边亮半边暗的极端光照。均匀、柔和的正面光最好。如果环境光不够,加一盏白炽灯或LED灯斜向补光,但注意不要让反光掩盖白色方格。
三是快门速度。手持拍摄时快门太慢会导致运动模糊,角点位置被“拖”歪。我看到很多人图省事直接手持拍,结果一半图片用了又能用但精度差。建议把相机放在三脚架上,或者至少保证快门速度在焦距倒数的两倍以上。
四是镜头参数锁定。这是特别容易被忽略的一点:变焦镜头在每张照片之间如果改变了焦距,相机内参就变了,所有照片相当于来自“不同的相机”,一起送去标定必然出错。所以拍摄前把焦距固定、光圈固定,并将镜头上的对焦环和变焦环用胶带固定,一整套照片保持同一设置。
3. 一次完整的标定流程实操
3.1 标定算法的基本原理,一句话版
整套标定算法最主流的根基是张正友标定法。核心思路是:把已知尺寸的平面棋盘格放在相机前方,相机拍摄它;棋盘格的每个角点在棋盘格坐标系里坐标是精确已知的,在图像里的像素坐标也是检测得到的;这样每一张照片都能建立一个从平面到图像的“单应变换”,单应矩阵里同时包含相机内参和这块棋盘格相对相机的位姿。
多张不同姿态的照片,提供了多组单应矩阵的约束,联立起来可以唯一解出相机内参矩阵。内参解出后,畸变系数再用非线性最小二乘优化迭代求解。所以标定结果的可靠性,本质上取决于两张要素:一张棋盘格照片是否提供一个强约束,以及所有照片的约束是否足够多样化。
3.2 基于开源视觉库的完整标定代码
下面这段代码是我在实际项目里反复使用的流程,用的是 Python 与开源计算机视觉库 OpenCV。先做角点检测,再做亚像素精化,最后送入标定函数。
import cv2 import numpy as np import glob # 棋盘格内角点尺寸:9列 x 6行 pattern_size = (9, 6) square_size = 25.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 objpoints = [] # 3D 点 imgpoints = [] # 2D 图像点 images = glob.glob("calib_images/*.jpg") for fname in images: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 检测棋盘格角点 ret, corners = cv2.findChessboardCorners(gray, pattern_size, None) if not ret: print(f"检测失败: {fname}") continue # 亚像素精化,让角点定位更准 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners_sub = cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria) objpoints.append(objp) imgpoints.append(corners_sub) # 标定主函数 ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( objpoints, imgpoints, gray.shape[::-1], None, None ) print("重投影误差 RMS:", ret) print("内参矩阵:\n", mtx) print("畸变系数:", dist.ravel())拿到内参和畸变参数之后,去畸变的工程代码也很简单。有两种方式:直接调用 undistort,或者先算映射表再用 remap。后者的好处是映射表只算一次、以后每帧直接查表,性能远高于每次重新计算,适合实时视频。
# 方式一:直接去畸变,注意 alpha 参数控制图像边缘保留范围 h, w = img.shape[:2] newcameramtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), alpha=1, newImgSize=(w, h)) dst = cv2.undistort(img, mtx, dist, None, newcameramtx) # 方式二:生成映射表 + remap,适合实时视频处理 mapx, mapy = cv2.initUndistortRectifyMap( mtx, dist, None, newcameramtx, (w, h), cv2.CV_32FC1 ) dst2 = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR)大广角镜头如果标定效果不理想,可以换鱼眼模型试试。OpenCV 的 fisheye 模块提供了对应的 calibrate、initUndistortRectifyMap 接口,参数含义略有差异,但流程完全一致。通俗地讲,标准针孔模型和鱼眼模型的区别就像一个用多项式去拟合畸变,另一个用更贴合超大视场折射规律的数学模型去拟合,视场越大、越接近180度,鱼眼模型越有优势。
3.3 标定结果怎么读
标定完成后,控制台会输出内参矩阵和畸变系数。很多人得到这一堆数字就直接拿去用了,其实应该看懂每一项的含义,这样才能在后续发现问题时快速定位。下面这张表是我常用的速查表:
| 参数 | 含义 | 单位 |
|---|---|---|
| fx, fy | 归一化焦距,实际是焦距除以单个像素物理尺寸 | 像素 |
| cx, cy | 主点坐标,光轴与成像平面的交点 | 像素 |
| k1, k2, k3 | 径向畸变系数 | 无量纲 |
| p1, p2 | 切向畸变系数 | 无量纲 |
| RMS | 重投影误差的均方根,衡量角点检测坐标与重投影坐标的平均差距 | 像素 |
这里有一个常见疑惑:fx、fy 看着像是“焦距”,但和镜头外包装上写的“8mm镜头”对不上。原因在于 fx 的单位是像素,两者的换算关系是 f_mm = fx * sensor_width_mm / image_width_px。比如图像宽度 1920 像素、传感器物理宽度 6.4 毫米、fx 为 1200 像素,那么真实焦距就是 1200 * 6.4 / 1920 = 4 毫米。这个换算在计算视场角时非常有用。
重投影误差 RMS 是最重要的质量指标。我的经验标准是:常规工业镜头小于0.3像素属于优秀,0.3到0.5像素属于良好,超过0.7像素就需要排查问题;手机镜头、车载广角这类小模组可以适当放宽,但超过1像素也要警惕。
3.4 验证标定效果的三种土办法
标定算法说“误差合格”不代表实际使用效果好,我每次标定完都会做三种验证:
第一种是直线验证。找一面有明显门框、墙角、瓷砖接缝的墙面,拍一张照片并去畸变,然后在去畸变后的图像里用画线工具把原本应该笔直的边缘连起来,看边缘是否完全落在直线上。边缘在图像中央没问题,边缘尤其在四条边缘区域出现弓起,说明畸变系数偏小,需要重新标定。
第二种是重投影验证。把棋盘格放在地面上用标号笔固定位置,重新采集几张没参与标定的图片,用刚才的内参和畸变系数检测角点,再投影出棋盘格的3D网格,计算投影误差。使用“未见过的图”来验证更贴近真实部署,能暴露标定过拟合的问题。
第三种是尺寸验证。测一张已知物理尺寸的标定板或量块在图像里的尺寸,把像素距离换算成毫米,和实际尺寸比较。这一步能在工程层面直接验证整个像素-物理映射链路的可靠性,很多标定误差只有到这一步才暴露出来。
4. 标定过程中最常见的五个坑
4.1 角点检测失败或顺序错乱
findChessboardCorners 返回 False 通常有三个原因:pattern_size 填错、棋盘格不完整、图片太小或压缩太狠。pattern_size 必须填内角点数,11x8 的棋盘格对应 pattern_size = (10, 7),填反或者填成格子数都会失败。
部分棋盘被物体遮挡或超出画面边缘,会导致检测失败,这种图直接弃用,不要裁剪后强行检测,因为裁切后的图像分辨率变化会影响标定结果。图片太小的问题,尽量用原始分辨率拍,不要用微信传输的压缩图来标定——我做项目时接过一次用聊天软件传过来的标定图,怎么测都不对,后来检查发现是图片被压缩到原来的四分之一分辨率。
如果角点顺序错乱,说明棋盘格存在严重不对称或极端透视,一般是因为倾角过大或者棋盘格平面弯折。可以先降低拍摄角度,让棋盘更平正地出现在画面里,保证检测通过后再逐步增加姿态的多样性。
4.2 重投影误差降不下去
误差降不下去,第一嫌疑是标定板不平。我在文章前面提过的纸板问题就是典型,角点定位亚像素精度很高,但棋盘物理坐标模型假设每个角点严格共面,纸面一弯,这个假设就崩了,误差自然压不下去。解决方案是更换刚性基底。
第二嫌疑是失焦。很多人觉得标定图“看着清楚”就行,实际上棋盘格边缘是否锐利非常关键。可以把棋盘格的局部放大到200%检查,如果白格边缘有一圈灰色过渡带,说明焦点没对准,要重拍。
第三嫌疑是畸变模型不匹配。几百度的超广角镜头用普通 pinhole 模型跑,高阶畸变拟合不充分,误差必然大。这种情况要切换到 fisheye 模块的模型进行标定。
另外还有一个隐蔽原因:标定板表面反射强烈,部分角点在高光区域,亚像素定位出现偏差。处理方法是改变光源角度或喷砂处理表面,让黑白格呈现漫反射。
4.3 去畸变后画面边缘出现拉伸或裁切
去畸变本质上是一个重映射过程,把边缘的点往中心“掰直”之后,图像边缘会产生两种结果:要么出现大片黑色缺失区域,要么被拉伸得变形。OpenCV 的 getOptimalNewCameraMatrix 里的 alpha 参数就是控制这个权衡的键:alpha=0 表示裁剪掉所有黑色区域,图像被放大以填满画面;alpha=1 表示保留所有原始像素,边缘出现黑边。
实际工程中,我通常对测量类应用用 alpha=0,因为输出图像不需要保留黑边;对视觉展示类或者需要和原始视场匹配的应用,先用 alpha=1 看看黑边范围,再手动设置 ROI 裁掉固定的边界。另外注意去畸变后的主点也会发生变化,如果后续要计算像素与角度的对应关系,需要使用 newcameramtx 而不是原始 mtx。
4.4 拍摄时改了焦距或光圈,标定结果漂移
这个坑在变焦镜头上尤其严重。变焦镜头在不同焦距下内参完全不同,光圈变化也会轻微影响畸变(热胀冷缩导致镜片间距变化)。所以拍摄全套标定图之前,务必先确认镜头工作在目标焦距和光圈,然后用胶带锁死变焦环和对焦环。
工业定焦镜头虽然没有变焦问题,但不同工作距离下镜头的畸变量也会有细微差异。普通应用可以忽略,但精密测量的项目最好在工作距离附近进行标定,不要拿最近距离拍的标定图去处理最远距离的图像。还有一个细节:相机和镜头发热后,内部温度变化会让焦距漂移几个像素,长时间运行的视觉设备,最好每隔一段时间重新标定一次。
4.5 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| findChessboardCorners 一直失败 | pattern_size 填错、棋盘出画、分辨率太低 | 核对内角点数,保证棋盘完整入画,使用原图 |
| RMS 大于1像素,且图图差异大 | 标定板弯曲、失焦、反光 | 换刚性基底,手动对焦锁环,调整光源 |
| 去畸变后边缘严重变形 | 模型不适合超广角 | 改用 fisheye 模型标定 |
| 去畸变后黑边太多 | alpha 参数偏大 | 降低 alpha 或设置 ROI 裁剪 |
| 今天标定和昨天标定结果差很多 | 焦距/光圈被改变,或温度漂移 | 锁死镜头参数,定期重标 |
| 标定结果“看起来正常”但尺寸测量不对 | 方格实际边长测量不准 | 用游标卡尺多次测量取平均值 |
5. 进阶:从单目标定到工程化落地
5.1 标定模型的适用边界
很多工程师拿着同一套代码去标定鱼眼镜头,发现误差大、结果不稳定,就以为自己的硬件有问题。其实是模型选错了。普通 pinhole 加 Brown-Conrady 模型适合视场角120度以内的常规镜头,即使稍微超出也勉强能用;视场角超过120度甚至接近180度的鱼眼镜头,必须使用 fisheye 模型。fisheye 模型用等距投影或其他投影模型描述光线,k1、k2、k3、k4 四个参数能拟合更剧烈的畸变。
换个更直观的说法:普通镜头畸变像是把一个略有弹性的纸面轻微弯折,pinhole 模型用几个多项式就能把纸面重新“抚平”;鱼眼镜头则像把一个半球强行压平到圆形图像上,多项式模型根本拟合不出这种剧烈变化,只有贴合半球投影规律的数学模型才能处理。
如果目标场景对精度要求极高,比如摄影测量、高精度三维扫描,还要考虑镜头的色差、像平面倾斜、甚至非对称畸变,这时候需要更复杂的标定实现,或者使用商业级标定程序。那已经超出一般畸变校准的范畴,属于摄影测量学领域了。
5.2 把标定结果高效用起来
标定完成后,处理单张图片毫无压力,但一旦要处理视频流,性能问题就来了。undistort 函数内部会重新计算映射表,每帧都算一遍非常浪费。工程上的标准做法是只用 initUndistortRectifyMap 算一次 mapx、mapy,然后每帧直接走 remap。映射表的数据类型建议用 CV_32FC1,内存占用和精度比较平衡;需要极致性能时,可以在 GPU 上运行 remap,或者提前用最近邻插值生成整型查找表。
分辨率变化也是常见坑。标定是在1920x1080下做的,部署时为了帧率把视频降到1280x720,此时原来的 fx、fy、cx、cy 都要按比例换算,不能直接拿原参数去畸变。换算规则很简单:新fx = 旧fx * 新宽 / 旧宽,新cx同理。
另外,标定完成后最好把内参存成标准配置文件(YAML 或 JSON),里面记录好图像分辨率、采集日期、镜头型号、拍摄距离,方便后续复现和排错。我习惯把每次标定的重投影误差也写进去,下次重标定后可以对比,判断硬件有没有松动或损伤。
5.3 畸变校准之后还能做的事
单目畸变校准是整个相机标定体系里最基础的一块砖。做完这一步,后面可以继续做双目立体标定——左右相机各做一次单目标定后,再用 stereoCalibrate 求解两个相机之间的旋转平移关系;也可以向机器人领域延伸做手眼标定,把相机坐标系和机械臂基座坐标系的变换关系求出来;还可以在视觉SLAM里用标定好的内参初始化算法状态,让前端跟踪从第一帧起就不受畸变干扰。
我自己的经验是,畸变校准是最容易提升整个视觉系统稳定性的单项工作。很多项目前期用默认内参跑得通,但一到边缘区域就出问题,把畸变校准补上之后,很多“幽灵bug”会自动消失。磨刀不误砍柴工,这句话放在相机标定上再合适不过。
最后再分享一个小技巧。标定图集不要一次拍完就删,我通常会把原始图片按镜头、光圈、工作距离分目录保存,后面如果系统改动导致标定失效,可以快速回顾是硬件变了还是参数变了。有一次项目里相机从横向安装改成竖向安装,我一开始没意识到畸变系数的坐标方向跟着传感器旋转发生了关联变化,排查了很久,最后翻出当时的标定记录才发现是安装方向变了。畸变校准这种看似一次性的工作,其实在整个设备生命周期里会反复用到,把过程记录做规范,比追求一次标就永不重标要靠谱得多。