☰
单目相机标定全攻略:从针孔模型到鱼眼畸变校正
2026/10/19 16:44:42 网站建设 项目流程

1. 单目标定到底在解什么难题

1.1 从三维空间到二维图像,相机做了什么

摄像头成像的本质,是把三维世界里一个点的坐标,通过透镜投影到二维传感器平面上。这个过程中信息丢了一个维度,但我们希望在数学上建立一个尽可能精确的模型:知道一个像素坐标,能反推它在三维空间中对应的射线;反过来,知道三维坐标也能算出它投影到图像里的准确位置。

整个投影过程可以拆成几段来理解。第一步是对应关系,三维点先通过外参(旋转矩阵和平移向量)从世界坐标系变换到相机坐标系;第二步是投影,相机坐标系下的点经透镜成像到归一化成像平面;第三步是采样映射,从成像平面坐标变成最终像素坐标,这一步涉及传感器像元尺寸、主点位置等因素。单目标定要做的,就是把中间所有未知量估计出来:相机的焦距、主点位置、畸变系数,以及每张标定图像对应的外参。

这里有个特别容易忽视的点,内参矩阵里面的fx、fy和主点坐标,虽然叫内参,但并不是纯物理属性。同一颗镜头搭配不同分辨率的传感器,或者图像经过裁剪缩放,内参数值都会变。我之前见过有人直接把别人标定好的内参拿过来用,结果投影位置偏得离谱,原因就是图像分辨率对不上,内参矩阵单位是像素,分辨率一变数值就要重新算。所以任何标定结果都必须和具体分辨率绑定记录,这一点后面实操部分还会单独强调。

1.2 普通镜头与鱼眼镜头的模型分水岭

普通镜头(视角大概六十到九十度)的成像行为非常接近针孔模型。针孔模型的本质是假设光线沿直线穿过一个小孔投射到平面上,物和像按相似三角形的比例缩小。真实镜头因为透镜结构,会产生畸变,但畸变量相对较小,用几个多项式系数就能修正回来。OpenCV里最经典的是径向畸变加切向畸变的组合:k1、k2、k3描述径向拉伸或挤压,p1、p2描述透镜组装配时与传感器平面不平行带来的斜向错位。

鱼眼镜头则是完全不同的设计思路。它要做到一百六十度甚至超过两百度的视场角,靠的不是简单放大,而是刻意让入射光在透镜内部发生强烈弯曲,把大角度的光线强行压缩到传感器上。这时候再套针孔模型就不成立了,因为投影关系已经不是相似三角形,而是接近等距投影、等立体角投影这类非线性映射。如果把普通镜头的畸变模型硬套在鱼眼上,参数收敛会非常差,畸变系数被拟合得巨大,边缘区域校正后依然一片混乱。

所以标定之前,一定要先判断手里的镜头属于哪一类。普通镜头是五个畸变系数加针孔模型,鱼眼镜头是四个畸变系数加专门的鱼眼投影模型,两者的数学框架完全不同。这也是为什么不少人拿普通标定代码去跑鱼眼镜头,调了一整天重投影误差始终压不下来,根本原因是模型选错了。

2. 标定板、相机与采集姿势的选择

2.1 标定板怎么选、怎么做

标定板的选择决定标定结果的上限。我使用最多的是棋盘格,原因在于OpenCV对棋盘格的支持最完善,角点检测稳定性好,而且棋盘格的交点是黑白格边界的交叉位置,亚像素定位精度高。

棋盘格有两个核心参数:内角点数和格子边长。内角点数是黑白格交叉点的数量,比如一张物理上是7行9列方格的棋盘,内角点数是6行8列,代码里传入的尺寸是(6, 8)。内角点数宁大勿小,因为角点越多,每张图能提取的特征约束越多,标定优化时的方程组越稳定。我常用的规格是内角点7x9或9x11,格子边长二十到四十毫米。格子边长不能随便定,它决定了工作距离和成像比例的关系,如果格子太小,低分辨率摄像头拍出来边缘模糊,角点检测精度会明显下降。

标定板必须高度平整。有人为了省事直接把棋盘格贴在纸板上,纸板稍微受潮弯曲,标定出来的焦距就会系统性偏大,重投影误差看着不大,但后续做三维测量时误差非常明显。我自己是把棋盘格贴在亚克力板或者玻璃板上,贴的时候用胶带把四角拉平,表面不留气泡。打印环节也别省,精度差的打印机打出来的格子边缘带锯齿,角点检测精度会打折扣。

如果条件允许,也可以用圆点阵列标定板,圆点标定板对非中心对称畸变的鲁棒性好一些,但OpenCV对它的检测函数没有棋盘格那么方便,用于普通镜头时优势不算大。我的建议是,除非有特殊需要,先用棋盘格,稳。

2.2 图片采集的数量、角度与位置要求

很多人第一次标定随手拍三五张就开始跑,结果输出误差一堆,还说OpenCV不够好。其实问题不在工具,而在约束不够。标定本质上是光学参数的拟合,约束信息全部来自标定板在不同姿态下的影像。如果姿态太单一,比如板子总是正面朝向镜头、放在画面中心,每个角点在图像上的分布高度相似,方程组就退化,焦距和主点之间的耦合无法分离。

我的经验是,普通镜头至少拍十五到二十张,鱼眼镜头建议拍到二十五到三十张。拍摄时姿态要拉开,左右倾斜、上下倾斜、绕相机光轴旋转都要有,远处近处交替,板子放在画面中央和边缘都拍。尤其是鱼眼镜头,边缘畸变非常大,如果标定板只在中心区域活动,边缘畸变信息缺失,标定出的畸变系数并不可靠。

采集时图像质量是第一位的。标定板不能过曝,白色格子一旦过曝成一片白板,角点检测很容易失效。反光也要避免,反光会造成局部亮度突变,角点位置被带偏。这里分享一个自检技巧,采集完一组图像后,先跑一遍角点检测,把检测失败的图单独挑出来重新拍。检测失败的表现通常是找不到角点,或者角点排列顺序错乱,这种图混进标定流程会影响最终结果。

2.3 如何判断一组图像是否合格

一个实用的判断标准是,标定板在所有图像里的投影面积应该覆盖整个图像空间。可以想象把图像分成九宫格,如果标定板在左上、右上、左下、右下这些区域里从来没出现过,那对应位置的畸变参数就缺乏约束。采集完成后我会快速浏览所有图片,确认四个角落区域里标定板至少都出现过一次。

姿态变化幅度也需要检查。一张合格的标定图,标定板平面和镜头光轴之间的夹角最好在三十度到九十度的大范围里分布。夹角很小时,板子基本正面朝向镜头,对估计焦距几乎没有区分度。从数学角度看,多个姿态的关键是让角点在图像上产生足够大的非线性位移,这样才能把畸变参数和射影关系解耦。如果你发现标定出的畸变系数符号和之前版本相反,或者多次标定结果符号不稳定,很大概率是姿态多样性不够。

3. 普通镜头标定:从角点检测到参数求解

3.1 基于OpenCV的标定流程代码

普通镜头的标定流程已经高度标准化,核心库是OpenCV。下面这段代码是我在多个项目里稳定使用的流程,可以直接参考。

import cv2 import numpy as np import glob CHESSBOARD_SIZE = (7, 9) SQUARE_SIZE_MM = 30.0 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 = [] 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, CHESSBOARD_SIZE, None) if not ret: print(f'角点检测失败: {fname}') continue corners_sub = cv2.cornerSubPix( gray, corners, (11, 11), (-1, -1), criteria=(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) ) obj_points.append(objp) img_points.append(corners_sub) rms, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) print(f'重投影误差: {rms:.4f}') print(f'内参矩阵:\n{mtx}') print(f'畸变系数: {dist.ravel()}')

这里有个步骤很多人会忽略,就是cornerSubPix亚像素细化。findChessboardCorners返回的角点精度已经够用,但还停留在像素级,而标定是高度依赖角点精度的优化过程,像素级误差会在反投影时被放大。cornerSubPix通过局部梯度拟合,把角点坐标细化到亚像素精度,这一步能显著降低最终的重投影误差。我实测过,跳过这一步时误差可能在0.3像素左右,做了之后能压到0.1甚至更低。

关键是,保存角点的时候一定要跟obj_points一一对应,这里说两个很常见的错误。第一,findChessboardCorners检测成功的图才往列表里添加,失败的要跳过去;第二,obj_points里的三维点坐标要和检测到的corner顺序完全一致,如果混入一个没有对应关系的点,后面整个优化都会错乱。

3.2 内参矩阵和畸变系数怎么看

calibrateCamera返回四个关键结果:重投影误差rms、内参矩阵mtx、畸变系数dist、每张图的外参rvecs和tvecs。

内参矩阵是一个3x3矩阵,结构一目了然。

fx和fy是归一化焦距,单位是像素,它们与真实物理焦距的关系是fx等于焦距除以像元宽度。同一颗镜头,传感器分辨率越高,fx就越大,这是正常现象。cx和cy是主点,也就是光轴与传感器平面的交点,理论上应该在图像中心,但实际装配误差会让它偏移几十个像素,这属于正常范围。

畸变系数dist在普通标定里一般包含五个数:k1、k2、p1、p2、k3。注意k3存放在向量的最后,它表示六阶径向畸变的量。这些系数本身的大小没有绝对的"正常值",不同镜头差异很大,但要关注它们的稳定性。同一个镜头重复标定几次,如果畸变系数出现正负号翻转或数量级剧烈跳动,说明标定数据有问题。

重投影误差是衡量整体精度的核心指标。它的含义是,把所有检测到的角点用标定出的内外参重新投影回图像平面,然后计算投影点和原始检测点之间的平均像素距离。通常认为误差小于0.1像素是优秀,小于0.3像素可用,超过0.5像素就需要回头检查采集过程。误差不是越低越传奇,但如果某次结果从0.15突然跳到0.8,几乎可以肯定是混入了不合格的图像。

3.3 畸变校正的两种实现方式

拿到内参和畸变系数后,最常见的目标是把原始图像校正成畸变较小的理想图像。OpenCV提供两条路线。

第一种最简单,直接调用cv2.undistort,输入原始图像、内参矩阵和畸变系数,得到校正图。优点是代码量少,缺点是每次调用都要重新计算整幅图像的映射坐标,如果放在实时视频流处理里,白白消耗CPU。

第二种性能更好,先用cv2.initUndistortRectifyMap计算出一张映射表,之后每一帧只用cv2.remap重映射。

h, w = gray.shape[:2] newcameramtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), alpha, (w, h)) mapx, mapy = cv2.initUndistortRectifyMap(mtx, dist, None, newcameramtx, (w, h), cv2.CV_32FC1) undistorted = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR)

这里alpha参数值得多说几句。alpha取0时,校正后会裁掉所有黑色冗余区域,视野变小,边缘信息被丢弃;alpha取1时,尽量保留原始像素,但四周会出现黑边。实际项目如果对视野有要求,我通常用alpha等于0.8到1.0,然后自己裁剪一块有效区域。如果alpha直接设成0,校正后的视角变化可能很大,给后续特征匹配带来额外的尺度不一致问题。

4. 鱼眼/广角镜头标定:换个模型再玩一次

4.1 鱼眼镜头为什么不能用普通模型

鱼眼镜头的光学设计目标是用极大的视角覆盖场景,它的投影关系与针孔模型之间不是简单加个畸变项那么简单。鱼眼投影函数本身就不同于针孔模型,常见的鱼眼投影包括等距投影、等立体角投影、体视投影和正交投影。一百多度的入射角,在等距投影下像高r与入射角θ满足r等于f乘以θ,而在针孔模型下满足r等于f乘以tan(θ)。

当θ很大的时候tan(θ)会迅速趋向无穷,普通镜头为了维持近似线性关系,只能把视角限制在几十度。鱼眼用等距这种近似线性的关系,在数学上天然允许大角度入射。如果拿针孔模型的畸变多项式去逼近鱼眼的曲线,需要非常高阶的多项式才勉强拟合,而高次多项式带来的数值不稳定性又非常大。所以正确做法是采用专门的鱼眼相机模型来建模。

OpenCV的fisheye模块使用的模型基于Kannala-Brandt模型,它的畸变参数最多四个,分别是k1、k2、k3、k4,在实现上通过多项式来逼近某种投影函数,配合内参矩阵一起求解。对市面上绝大多数鱼眼镜头来说,这个模型足够应付了。

fisheye模块的API和普通标定模块很接近,但它是独立的一套函数。最容易踩的坑是fisheye.calibrate返回的畸变系数D向量长度为4,而普通calibrateCamera返回的是5个。如果两个搞混,后续调用fisheye相关函数时会报长度不匹配的错误。

4.2 fisheye模块的实操流程

import cv2 import numpy as np import glob CHESSBOARD_SIZE = (7, 9) SQUARE_SIZE_MM = 20.0 objp = np.zeros((1, CHESSBOARD_SIZE[0] * CHESSBOARD_SIZE[1], 3), np.float32) objp[0, :, :2] = np.mgrid[0:CHESSBOARD_SIZE[0], 0:CHESSBOARD_SIZE[1]].T.reshape(-1, 2) objp *= SQUARE_SIZE_MM obj_points = [] img_points = [] images = glob.glob('fisheye_calib/*.jpg') for fname in images: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, CHESSBOARD_SIZE, None) if not ret: print(f'角点检测失败: {fname}') continue corners_sub = cv2.cornerSubPix( gray, corners, (11, 11), (-1, -1), criteria=(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) ) img_points.append(corners_sub.reshape(1, -1, 2)) obj_points.append(objp) K = np.zeros((3, 3)) D = np.zeros((4, 1)) rvecs = [np.zeros((1, 1, 3), dtype=np.float64) for _ in range(len(obj_points))] tvecs = [np.zeros((1, 1, 3), dtype=np.float64) for _ in range(len(obj_points))] rms, K, D, rvecs, tvecs = cv2.fisheye.calibrate( obj_points, img_points, gray.shape[::-1], K, D, rvecs, tvecs, flags=cv2.fisheye.CALIB_RECOMPUTE_EXTRINSIC + cv2.fisheye.CALIB_FIX_SKEW ) print(f'鱼眼重投影误差: {rms:.4f}') print(f'鱼眼内参矩阵:\n{K}') print(f'鱼眼畸变系数: {D.ravel()}')

注意鱼眼版的obj_points和img_points维度是(1, N, 3)和(1, N, 2),和普通版本不一样,这是新手最容易写错的地方。另一个重点参数是CALIB_FIX_SKEW,它的作用是固定fx与fy的比值,强制像素为方形。对鱼眼镜头来说,制造上通常保证像元是方形的,使用这个flag可以显著减少优化参数数量,让畸变求解更稳定。除非项目对x和y方向焦距有独立测量需求,我建议加上这个flag。

鱼眼镜头采集图像时还有一个额外要求,标定板要尽量靠近画面边缘,因为鱼眼的畸变集中在边缘区域,如果只拍中心区域,边缘畸变系数完全无法约束。这跟普通镜头标定不太一样,普通镜头即使板子大部分在中间,边缘畸变影响也没那么显著,鱼眼镜头则是中心区域几乎看不出畸变,只有让板子出现在图像边缘,才能把畸变参数约束住。

4.3 鱼眼标定结果的校正差异

鱼眼镜头的畸变校正效果和普通镜头完全不同。普通镜头校正后是标准的透视投影,直线变直,视角维持原样。鱼眼校正后通常有两种选择。一种是输出一个窄视角的透视投影,把视角缩小到可接受范围,比如90度,此时画面中心区域被放大,边缘弯曲消失,视觉上像一个普通镜头。另一种是保持大视角,用柱面投影或立体视图方式呈现,但那已经超出单目标定的范畴了。

实现鱼眼校正的方式和普通镜头类似,同样用initUndistortRectifyMap加remap,只是换成fisheye模块的函数。

h, w = gray.shape[:2] newK = cv2.fisheye.estimateNewCameraMatrixForUndistortRectify(K, D, (w, h), np.eye(3), None, balance=0.8) mapx, mapy = cv2.fisheye.initUndistortRectifyMap(K, D, np.eye(3), newK, (w, h), cv2.CV_32FC1) undistorted = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR)

balance参数的含义和普通镜头里的alpha类似,balance越大,校正后保留的原始视场越多,黑边越明显;balance接近0时,裁切越激进,视场变小,中心区域放大更多。如果后续做目标检测,我通常把balance控制在0.6到0.9,既保留足够的环境信息,又让画面边缘不再严重扭曲,检测框的稳定性会明显提升。

5. 标定过程中的常见问题与排查实录

5.1 重投影误差居高不下的排查思路

重投影误差大是我遇到最多的问题。误差偏大时的系统排查顺序如下。

第一步检查是否有图像模糊。标定板轻微失焦时,人眼可能觉得还能看,但角点亚像素检测已经不准了。手持标定板晃动时特别容易拍出局部模糊,尤其是棋盘格边缘。这种情况用三脚架固定相机,或者提高快门速度,能明显改善。

第二步检查是否有反光。标定板表面局部反光会让角点检测把反射造成的亮度梯度当作参考,角点位置偏移几个像素。发现这个问题,调整光源位置或者换一个拍摄角度重新拍。

第三步检查标定板是否平整。纸板受潮变形、玻璃板边缘固定不牢导致轻微弯曲,都会让标定结果失真。翻过板子看看,变形了就换一块。

第四步检查姿态覆盖度。如果重投影误差集中体现在某几张图上,逐张查看那几张的板子位置和角度,很可能姿态太过单一。把姿态分散开后重新跑一遍,误差通常会明显下降。

5.2 内参数值异常的情况

有一次标定结果主点偏移到了画面宽度五分之一的位置,明显不合理。排查后发现问题在于混入了一张标定板只露出一半的图像。这张图里角点检测成功了,但标定板信息严重残缺,导致整体优化被带偏。剔除这张图重新标定,主点立刻恢复正常。

另一个常见异常是fx和fy相差超过百分之十。在没有使用CALIB_FIX_SKEW的情况下,这有时候是真实存在的,比如传感器像元不是正方形。但如果同时畸变系数非常大且不稳定,更可能是模型不匹配。比如把鱼眼镜头用普通模型标定,fx和fy经常会有巨大差异,多次标定数值波动也大。这时候应该换模型重新标定。

5.3 常见问题速查表

症状可能原因处理方案
重投影误差大于0.5图像模糊、反光、板不平整重拍合格图像,剔除问题图
某张图角点检测失败标定板过曝或超出视场调整曝光,减少超出部分
主点cx、cy明显偏移混入不完整标定板图像剔除该图,重新标定
多次标定内参波动大图像数量不足或姿态单一增加图片,分散姿态
鱼眼镜头普通模型误差大模型不匹配改用fisheye模块重新标定
校正后黑边太多balance或alpha设置过大降低参数值,重新裁剪ROI

这张速查表覆盖了大多数标定坑。每个项目可能还会遇到边界情况,但排查思路是一致的,先检查数据质量,再检查模型选择,最后检查参数设置。

6. 标定完成后如何验证结果

6.1 用直线变直的程度做直观验证

标定的最终目的是让校正后图像符合理想投影关系。最简单直观的验证方法是找矩形物体,比如门框、瓷砖缝、屏幕边框,拍一张原始图,再用标定参数做校正,观察图像中物体的直线边缘是否变直。这个验证尤其适合鱼眼镜头,如果校正后的门框还是明显弯曲,说明畸变参数没拟合到位。

有个经验之谈是,即使是普通镜头,校正后边缘区域直线也可能因为插值产生轻微锯齿,这是正常的,不影响精度。但如果在校正后,图像边缘的直线仍有肉眼可见的弧线,就要回到第5节的排查流程重新检查。

6.2 用三维反投影验证几何精度

如果项目目标是测距、定位这类几何任务,建议额外做一次精度验证。在现实场地里放置几个已知间距的点,用校正后图像或者直接用内参矩阵解算像素位置的归一化射线方向,再比较射线方向与真实方向的夹角误差。夹角误差能直观反映标定参数对空间几何关系的拟合质量。

具体做法是,在地面或标定板上粘贴多个已知位置标记点,拍一张图,检测标记点像素坐标,利用内参矩阵求每个点的归一化射线方向。由于单目相机缺少深度信息,只能得到射线方向,但可以通过射线之间的夹角或者射线与已知平面的交点来验证。这个方法在平面测距、机器人定位场景里非常实用。

6.3 标定参数的存档与复现

标定参数是一次性得出的,但项目迭代时经常需要重新标定。我的习惯是每次标定结束保存一份带元数据的参数文件,记录图像数量、重投影误差、标定板规格、flags设置,以及镜头型号和分辨率。

{ "camera_model": "fisheye", "resolution": [1920, 1080], "calib_images_count": 28, "rms_error": 0.08, "fx": 512.3, "fy": 512.3, "cx": 960.5, "cy": 540.2, "dist": [-0.012, 0.003, -0.001, 0.0005] }

有了这份档案,换相机或者改分辨率后,能很快判断哪些参数可以复用,哪些需要重新标定。我之前有个项目因为画面裁剪导致分辨率变化,以为内参可以简单等比缩放,结果发现畸变系数的行为并不按比例变化,最后重新标定才解决。这个教训让我学到一个原则:分辨率一旦变化,最稳妥的方案永远是重新标定。

做单目摄像头标定这件事,最磨人的其实不是算法本身,而是对数据质量的把控。普通镜头和鱼眼镜头虽然模型不同,但底层逻辑是一样的:用足够多样、足够清晰的标定板图像,把一个精确的几何模型拟合出来。只要模型选对、图片拍够、误差控制在合理范围,后续做畸变校正、三维重建、目标定位都会轻松很多。我个人的体会是,遇到内参不稳或者误差偏大的情况,先别急着调代码,回去重新审视标定板的物理状态和采集过程,问题往往藏在最不起眼的细节里。最后再分享一个经验:如果鱼眼镜头和普通镜头在同一个项目里混用,尽量使用同一块维护良好的标定板,保持相机分辨率一致,这会省掉大量适配的麻烦。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询