简介:面向计算机视觉与图像处理学习者的鱼眼相机去畸变代码包,专注解决鱼眼镜头广视角带来的图像畸变问题。包内覆盖经纬度矫正法与棋盘格矫正法两类主流思路:前者基于几何映射将鱼眼图像展开,后者通过棋盘格标定获取内参和畸变系数,并结合OpenCV fisheye模块实现矫正,同时给出立体标定与双经度模型的拓展说明,便于对比不同算法的适用场景。
压缩包内共18个文件、大小4.89MB,以Python脚本为核心实现(5个py),配套HTML可视化页面(4个html)、样例效果图(3张jpg),以及md/txt文档说明,目录结构清晰,适合直接运行与二次学习。已有141人学习,适合计算机视觉初学者入门实践,也可供研究者在实际项目中评估并选用合适的矫正方案。 第一次把鱼眼相机画面拉直的时候,我是相当兴奋的。车载环视接入的鱼眼镜头,视场角能到190度,车头两侧的行人被拉成圆弧形,直接送进目标检测模型,检测率掉得没法看。接这个项目之前,我以为去畸变就是调用OpenCV一个函数的事,真正做完才发现:标定板的拍法、内参的稳定性、映射表的生成时机,每一个环节都有坑。这篇文章把我从原理到代码完整踩过一遍的路整理出来,内容包括畸变模型是怎么来的、OpenCV fisheye模块标定与去畸变两条路线的完整代码,以及我在实际项目中排查过的几个典型问题,适合正在接鱼眼相机预处理、想做全景拼接或3D重建的开发者参考。
1. 鱼眼相机的畸变从哪来:为什么普通相机模型救不了它
1.1 鱼眼镜头的光学设计:视场角是靠“压缩边缘”换来的
普通镜头的视场角一般在50到90度之间,传感器上的成像关系基本符合小孔成像模型,画面边缘虽然有畸变,但幅度有限。鱼眼镜头就不一样了,它的前组镜片做得特别鼓,光线进入后经过多次折射,最终被强行压到传感器上,视场角能做到120度、180度甚至220度。代价就是:离画面中心越远,像素被压缩得越厉害。车尾的一个锥桶,在鱼眼图中心区域还是圆形,到了画面边缘就变成一条弯弯的弧线。
这种压缩不是随机的,它遵循某种投影规律。如果我们把真实世界的一个点 P 投影到鱼眼图像上,可以想象一条从相机光心出发、与光轴夹角为 θ 的光线,最终落在传感器上的位置离主点(图像中心)距离为 r。不同镜片设计会让 r 与 θ 呈现不同关系,典型的有等距投影 r = fθ、等立体角投影 r = 2f·sin(θ/2)、体视投影 r = 2f·tan(θ/2)。大多数鱼眼镜头在理想情况下近似等距投影,这也是为什么后面要重点围绕这个模型展开。
1.2 针孔模型的局限性:k1/k2/p1/p2 在鱼眼图上为什么不够用
普通相机去畸变用的是针孔模型加畸变多项式,径向畸变系数 k1、k2、k3,切向畸变系数 p1、p2,公式里大量出现 r²、r⁴、r⁶ 项。问题在于,r 是归一化坐标到主点的距离,当视场角超过120度时,边缘像素的 r 可以非常大,多项式的高次项在 r 较大时直接发散,硬套普通模型的结果就是中心拟合得不错,边缘依旧弯成曲线,或者内参被带偏,出现负焦距之类的荒唐值。
OpenCV 在普通 calibrateCamera 接口里也提供了 CALIB_RATIONAL_MODEL 选项,能支持更多畸变系数,但对于超广角镜头仍然不稳定。fisheye 模块用的是 Kannala-Brandt 模型,它的畸变多项式作用在入射角 θ 上,而不是直接作用在 r 上,这样不管视场角多大,θ 的范围始终被限制在 0 到 π/2 之间,多项式不会发散。这是鱼眼相机去畸变必须用专用模型的核心原因。
1.3 工程上真正麻烦的是“边缘像素被压缩”
理解了光学原理,你就会明白去畸变本质上是一个重采样过程。鱼眼图中心区域的像素表现得像普通镜头,边缘区域的像素被压缩在一起,去畸变就是把边缘被压缩的像素重新摊开,同时把中心像素稍微收拢。摊开之后,原来一个像素的位置现在需要两个甚至三个像素去填充,这些新像素没有真实信息,只能靠插值。所以去畸变后的图像边缘通常比中心模糊,这是物理限制,不是算法问题。
2. 去畸变的数学底子:等距投影与多项式模型
2.1 理想投影模型与 Kannala-Brandt 多项式
先说理想情况。等距投影的公式是:
r = f * θ其中 θ 是入射光线与光轴的夹角,f 是焦距,r 是像点到主点的距离。这个模型简单优雅,但真实镜头不可能完全符合,因为镜片加工、装配误差都会带来偏差。OpenCV fisheye 模块采用 Kannala-Brandt 模型对实际畸变进行拟合,畸变后的角度 θ_d 与理想入射角 θ 的关系是:
θ_d = θ * (1 + k1*θ² + k2*θ⁴ + k3*θ⁶ + k4*θ⁸)这里只需要 4 个畸变系数 k1 到 k4,相比普通模型的 5 个甚至更多系数,参数更少但拟合效果更好。把 θ_d 计算出来后,再通过缩放因子 θ_d / r 把归一化坐标 (x, y) 映射到畸变坐标 (x_d, y_d),最后用内参矩阵 K 转成像素坐标。整个链路是:三维点 → 相机坐标 → 归一化坐标 → 计算 r 和 θ → 计算 θ_d → 得到畸变像素坐标。
2.2 正映射与反映射:为什么实际去畸变要“反着算”
如果从源图像出发,把每个源像素按照畸变模型送到目标位置,会留下大量空洞,因为边缘像素被摊开,中心像素被收拢,目标图有些像素一辈子也等不到源像素来填充。正确做法是反向映射:遍历目标图像的每个像素,用反向公式去源图像里找对应的像素位置,然后插值采样。这样目标图像的每个像素都有唯一来源,不会出现空洞。
用生活化的类比:正映射就像你去查“从家出发能到哪些地方”,反映射更像是拿到一个目的地地址,然后去查“怎么从家到这个地址”。去畸变场景里,目的地是目标图像素坐标,源地址是畸变图像素坐标,先把源地址算出来,再决定怎么把像素搬过去。
2.3 公式里的参数分别代表什么
K 矩阵是 3×3 内参矩阵,包含 fx、fy(焦距,单位像素)、cx、cy(主点坐标,通常接近图像中心)。D 向量是 4 个畸变系数。标定的任务就是同时求出 K 和 D,K 负责描述相机本身的投影关系,D 负责描述偏离理想投影的程度。标定过程需要用棋盘格角点的世界坐标和像素坐标建立对应关系,然后通过非线性优化最小化重投影误差。
3. 标定实操:从棋盘格拍摄到内参求解的完整链路
3.1 拍摄棋盘格的10条经验
标定算法再强,数据不行也白搭。我踩过不少坑之后,总结了这几条拍摄经验:
- 用 6×9 或者 8×11 这种行列数不相等的棋盘格,避免方向歧义。
- 至少拍 15 张,最好 20 到 30 张。注意不是同一个位置连拍,而是每张都改变棋盘格的姿态和位置。
- 棋盘格不要贴满整个画面,四周留出至少 10% 的边距,防止角点检测在边缘失效。
- 一定要让棋盘格出现在画面边缘区域,因为鱼眼畸变最严重的地方就是边缘,如果只在中心区域拍,畸变信息根本没被采集到。
- 避免强反光和运动模糊。棋盘格最好用哑光纸打印,贴在平整的硬板上。
- 远近距离都要覆盖。近距离让棋盘格占据画面大半,远距离让棋盘格只占一小块,这样标定出的内参对距离变化更鲁棒。
- 镜头必须是定焦或者手动对焦锁死状态,标定过程中绝对不允许变焦。
- 每次拍摄时让棋盘格相对相机有明显倾斜角,不要一直正对相机,不同倾角有助于估计外参和畸变。
- 拍摄环境光线要均匀,避免棋盘格上出现大面积高光或者阴影。
- 拍完先人工筛选一遍,模糊、反光、棋盘格不完整的照片直接删掉,不要留着拖累优化。
3.2 角点检测与亚像素细化
棋盘格角点检测直接用 OpenCV 的 findChessboardCorners。需要注意 patternSize 参数传递的是内角点数量,而不是棋格数量。比如棋盘格每行有 9 个方格、每列有 6 个方格,那么内角点是 8×5,patternSize 应该传 (8, 5)。这个顺序是先列后行,也就是 (width, height),很多人在这里传反,导致检测失败。
检测到角点后要再用 cornerSubPix 做亚像素细化,这一步能显著提升标定精度。细化窗口我一般用 (11, 11),迭代终止条件设置为最大迭代 30 次、精度 0.001 像素。
3.3 标定代码与结果判定
下面是我项目中实际在用的标定代码,基于 Python 和 OpenCV 4.x:
import cv2 import numpy as np import glob CHECKERBOARD = (8, 5) # 内角点数,先列后行 square_size = 1.0 # 棋盘格边长,单位任意,标定结果是相对尺度 objp = np.zeros((1, CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32) objp[0, :, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2) objp[0, :, :2] *= square_size objpoints = [] imgpoints = [] images = glob.glob('calib_imgs/*.jpg') for fname in images: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners = cv2.findChessboardCorners(gray, CHECKERBOARD, None) 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) objpoints.append(objp) imgpoints.append(corners) cv2.drawChessboardCorners(img, CHECKERBOARD, corners, ret) cv2.imshow('corners', cv2.resize(img, (1024, 768))) cv2.waitKey(50) cv2.destroyAllWindows() N_OK = len(objpoints) K = np.zeros((3, 3)) D = np.zeros((4, 1)) rvecs = [np.zeros((1, 1, 3), dtype=np.float64) for _ in range(N_OK)] tvecs = [np.zeros((1, 1, 3), dtype=np.float64) for _ in range(N_OK)] rms, K, D, rvecs, tvecs = cv2.fisheye.calibrate( objpoints, imgpoints, gray.shape[::-1], K, D, rvecs, tvecs, flags=cv2.fisheye.CALIB_RECOMPUTE_EXTRINSIC | cv2.fisheye.CALIB_CHECK_COND | cv2.fisheye.CALIB_FIX_SKEW ) print("RMS:", rms) print("K:", K) print("D:", D.ravel()) np.savez('calib_params.npz', K=K, D=D, DIM=gray.shape[::-1])几个参数的说明:
- gray.shape[::-1] 得到的是 (width, height),OpenCV 的 fisheye.calibrate 第二个参数要求图像尺寸必须是宽高顺序。
- CALIB_RECOMPUTE_EXTRINSIC 让优化过程每次迭代后重新估计外参,整体收敛更稳定。
- CALIB_CHECK_COND 会检查数据矩阵条件数,条件数异常时会返回错误,能帮你筛掉烂数据。
- CALIB_FIX_SKEW 固定像素的 skew 为 0,也就是认为像素是矩形而非平行四边形,对普通 CMOS 相机来说这是合理约束,还能减少自由度,提高内参稳定性。
RMS 是重投影误差,单位是像素。鱼眼标定 RMS 在 0.1 到 0.5 之间都属于正常,超过 1 的话基本可以判定标定数据里有问题,要么是模糊图没删干净,要么是棋盘格数量不够。
4. 去畸变的两条OpenCV路线:选对API少走一半弯路
4.1 路线A:fisheye.undistortImage 一行搞定
拿到 K 和 D 之后,最直接的去畸变方式是调用 cv2.fisheye.undistortImage:
import cv2 import numpy as np calib = np.load('calib_params.npz') K = calib['K'] D = calib['D'] img = cv2.imread('fisheye_sample.jpg') dim = img.shape[:2][::-1] # (width, height) new_K = cv2.fisheye.estimateNewCameraMatrixForUndistortRectify( K, D, dim, np.eye(3), balance=0.5 ) undistorted = cv2.fisheye.undistortImage(img, K, D, Knew=new_K) cv2.imwrite('undistorted_sample.jpg', undistorted)这个路线适合快速验证算法效果。estimateNewCameraMatrixForUndistortRectify 会根据 balance 参数重新计算一个新的内参矩阵 new_K,去畸变时用 new_K 而不是原始 K,能够自动缩放焦距,让输出图像裁掉畸变严重的边缘区域。
4.2 路线B:initUndistortRectifyMap 加 remap,工程首选
生产环境我建议用映射表方案。先一次性生成两幅映射表 map1、map2,后续每一帧只需要执行 remap:
def build_undistort_maps(K, D, dim, balance=0.5): new_K = cv2.fisheye.estimateNewCameraMatrixForUndistortRectify( K, D, dim, np.eye(3), balance=balance ) map1, map2 = cv2.fisheye.initUndistortRectifyMap( K, D, np.eye(3), new_K, dim, cv2.CV_16SC2 ) return map1, map2 def apply_undistort(img, map1, map2): return cv2.remap(img, map1, map2, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT) dim = (1280, 800) # 你的输出尺寸 map1, map2 = build_undistort_maps(K, D, dim, balance=0.5) out = apply_undistort(img, map1, map2)为什么推荐路线B?原因有三个:
第一,映射表可以离线算好,运行时 remap 的耗时远小于每次调用 undistortImage 内部重新计算映射表的时间。在嵌入式设备上,这个差距可能达到每帧几毫秒到十几毫秒。
第二,使用 CV_16SC2 类型存储映射表,内存占用比浮点类型小一半,对 Jetson 这类内存敏感设备很重要。
第三,你可以自由控制输出尺寸。比如只需要去畸变后的中心区域,可以直接把 dim 设置成更小的值,输出一张裁剪后的图。这在深度模型推理阶段能直接减少计算量。
4.3 手动实现一版去畸变,理解公式的本质
如果你想把原理完全吃透,可以手动实现一个简化版本。下面这段代码不是生产方案,但能帮助你理解反向映射的每一步:
def undistort_manual(gray, K, D): h, w = gray.shape[:2] fx, fy = K[0, 0], K[1, 1] cx, cy = K[0, 2], K[1, 2] k1, k2, k3, k4 = D.ravel() out = np.zeros_like(gray) for v in range(h): for u in range(w): # 归一化坐标 x = (u - cx) / fx y = (v - cy) / fy r = np.sqrt(x * x + y * y) theta = np.arctan(r) theta_d = theta * (1 + k1 * theta**2 + k2 * theta**4 + k3 * theta**6 + k4 * theta**8) scale = theta_d / r if r > 1e-8 else 1.0 xd = scale * x yd = scale * y ud = xd * fx + cx vd = yd * fy + cy if 0 <= ud < w - 1 and 0 <= vd < h - 1: out[v, u] = gray[int(vd), int(ud)] return out这段代码遍历目标图像的每个像素,反算它在原始畸变图像中的位置,然后取最近邻像素值。理解它之后你会发现:去畸变的本质就是“查表”,而 OpenCV 的 remap 就是一张提前算好的大规模查找表。
5. 实际项目里最容易踩的坑:从棋盘格误检到边缘裁切
5.1 内角点数量定义错:最典型的低级失误
我见过太多人栽在这个地方。一张 10×7 的棋盘格,总共有 70 个方格,但内角点只有 9×6=54 个。如果你把 patternSize 写成 (10, 7),角点检测大概率失败,因为 OpenCV 找的实际上是黑色方块和白色方块的交点,最外圈的角点根本不属于“内部交点”。
更隐蔽的问题是顺序。patternSize 接受的是 (cols, rows),即列数在前、行数在后。如果你在其他地方看到过 (rows, cols) 的写法,不要照抄,以官方文档为准。拍脑袋想一下:图像坐标系是(x, y)对应(宽, 高),patternSize 的语义就是 (宽方向内角点数, 高方向内角点数)。
5.2 标定板不要只拍正面
只要棋盘格大致正对相机,角点检测几乎不会失败,但这会严重削弱标定的鲁棒性。因为正对姿态下,外参的旋转分量变化极小,优化算法很难把所有约束稳定地分配给内参和畸变系数。我实测过一组全部为正对姿态的 20 张图,标定出的 K 矩阵的 cx、cy 偏移量明显异常,RMS 虽然只有 0.6,但去畸变后的图像边缘出现了肉眼可见的波浪形畸变。
正确做法是让棋盘格以 30 度到 60 度的倾角出现在画面不同位置,并且交替出现“棋盘格在画面中心”和“棋盘格在画面边缘”两种构图。
5.3 K矩阵跑到飞:内参出现负值或异常大
如果标定出的 fx、fy 出现负值,或者某个值大于图像宽度的好几倍,基本可以判断标定失败了。常见原因包括:
- 有效图像数量太少,少于 10 张时优化容易落入局部极小值。
- 棋盘格在画面中占比太大或者太小。占比太大会导致几乎所有角点都在中心区域,边缘畸变信息为零;占比太小则角点检测精度下降。
- 传给 calibrate 的图像尺寸是 (height, width) 而不是 (width, height),校准过程不会报警,但结果完全不可用。
- 用 Lowesalt 方案时初始 K 给了错误的估计值。fisheye.calibrate 允许传入全零 K,它内部会自动初始化;如果你自己给了一个不靠谱的初值,反而可能把优化带偏。
出现负焦距时,不要尝试微调参数挽救,直接倒查数据质量,把模糊图、重复构图图删掉,重新拍摄一组高质量数据。
5.4 去畸变后的视野变小:balance 参数怎么选
去畸变说起来是“拉直”图像,但代价是视野变小。原因很简单:畸变图边缘被压缩的像素在拉直之后需要更多的空间来展示,于是边缘部分被推出画面之外。balance 参数控制保留视野的比例:
| balance | 效果 | 适用场景 |
|---|---|---|
| 0.0 | 保留中心无畸变区域,边缘裁剪最多,画面最自然 | 目标检测、人脸识别 |
| 0.5 | 视野和画质折中,仍有少量边缘拉伸 | 车载环视、全景拼接 |
| 1.0 | 保留最大视野,但边缘拉伸明显 | 大范围监控、VSLAM |
我实测下来,车载环视场景用 0.3 到 0.5 比较合适。如果你追求边缘目标看得更多,可以试 0.7 以上,但要接受行人在画面边缘被明显拉宽。项目初期别急着定死,把 balance 做成可配置参数,方便后续根据实际算法效果调整。
5.5 换镜头、变焦后必须重新标定
鱼眼相机标定结果绑定的是“这个镜头 + 这个CMOS + 这个对焦状态”。很多一体机出厂标定好的参数,经过物流颠簸、镜头锁紧环松动、甚至温度变化引起的镜组位移,内参都会发生漂移。项目落地时一定要在目标设备上重新标定一次,不能直接拿厂家给的出厂参数。
我之前处理过一台车载环视相机,出厂标定参数 RMS 只有 0.2,但装车后去畸变效果明显变差,后来发现是运输过程中镜头的固定环松了半圈,导致后焦距发生变化。把镜头重新固定后,参数恢复稳定。所以如果你的去畸变效果“突然”变差,先检查机械结构,再怀疑算法。
5.6 圆形鱼眼图与全帧鱼眼图的处理差异
有些鱼眼镜头成像是一个完整的圆,图像四角是全黑的。这种情况下,去畸变时如果直接对全图做 remap,会浪费大量计算量。标准做法是先通过阈值分割得到有效圆形区域掩膜,然后只对掩膜范围内的像素做后续处理,或者在输出图像上根据掩膜裁掉无信息区域。
反过来说,如果图像四角有像素但畸变严重,说明镜头视场角还没到180度,属于全帧鱼眼。这类图像去畸变后四角通常会出现明显的拉伸和模糊,直接用 ROI 裁掉边缘一圈,效果会好很多。
6. 去畸变之后还能做点什么:以3D重建和目标检测为例
6.1 目标检测:先拉直再推理,还是直接训练鱼眼模型
做目标检测时,直接把鱼眼图丢给 YOLO 这类模型,边缘目标变形严重,检测框回归会不准。一个常见的工程方案是:先去畸变,再用已有的普通模型做推理。这个方案的优势是可以用已经训练好的开源模型,不需要收集大量鱼眼图重新训练。
但代价也很明显:去畸变引入重采样模糊,而且裁掉了一部分视野。如果你是做自动驾驶的,边缘视角恰恰是你最关心的区域,裁掉边缘等于丢了信息。这时候可以考虑第二种方案:保留鱼眼原图,在训练阶段用随机 crop、随机透视变换模拟畸变,让模型自己适应鱼眼特征。实际项目里我更喜欢混合方案——对输入图像做一次“视野可调”的去畸变,参数 balance 在 0.3 到 0.7 之间随机采样,相当于做了数据增强,既保留一定的边缘信息,又让模型接触到多种程度的畸变状态。
6.2 全景环视拼接与鸟瞰图生成
多路鱼眼相机做全景环视,最核心的流程是:每个相机单独标定去畸变,然后再做多相机外参标定,把各相机图像投影到统一的鸟瞰图坐标。这里的坑在于,内参标定误差会被外参标定放大。如果某个相机的 K 矩阵偏差了 1%,最终拼接缝可能错开好几个像素。
实操中,除了用棋盘格标定内参,我还会额外做一个“验证标定”:用同一块棋盘格放在相邻两个相机的重叠区域,同时出现在两张图中,通过手工选点计算两个相机之间的相对位姿,与纯棋盘格标定结果对比。偏差超过一定阈值就重新标定。这个双保险在量产项目中非常有用。
6.3 球面与全景模型:鱼眼图作为3D重建的入口
鱼眼镜头本质上把半球面的信息投影到圆面,所以鱼眼图和全景图之间天然存在映射关系。如果你想把鱼眼图变成等距柱状投影全景图,只需要把目标图像的像素坐标转换成经纬度,再用鱼眼模型反查源图像坐标,原理和去畸变完全一致。很多 VR 相机就是这么工作的。
在 3D 重建场景里,鱼眼相机的大视场角往往比普通镜头更适合做室内重建,因为一张图能覆盖更多墙面和天花板。但要注意,去畸变之后的图像再用于特征点匹配时,边缘区域的重采样模糊可能导致特征点提取质量下降,所以不少 SLAM 方案会直接在畸变图像上提取特征,然后在特征点坐标层面做去畸变,跳过了整图重采样的步骤。这个思路值得借鉴。
6.4 性能优化:映射表离线化与嵌入式部署
最后分享一个嵌入式部署的优化思路。在 Jetson 或者手机 SoC 上,去畸变的性能瓶颈几乎全部在 remap 阶段。你可以把映射表提前生成并保存为二进制文件,运行时直接加载;如果内存允许,用 CV_16SC2 表比浮点表省一半内存。另外,OpenCV 的 CUDA 版 remap 在 GPU 上速度非常快,Jetson 上实测 1280×720 的图单次 remap 只要 0.5 毫秒左右,几乎是零成本操作。
注意:如果你的下游算法只需要画面中心区域,比如车道线检测只看画面下方几行,那完全不需要对全图做去畸变,只对 ROI 区域生成映射表,然后 remap 时传入 ROI 坐标。这个优化在实时系统里的收益非常可观。
在项目中,我习惯把标定参数文件命名为 camera_{硬件序列号}.npz,并把拍摄日期、镜头焦距、分辨率都写进注释。这样出了问题能快速定位是哪台设备、哪个版本的参数。去畸变没有完美的答案,balance 的选择永远是在视野和真实度之间取舍,先理解这个本质,再根据场景去调参,就不会被各种花哨的 trick 带偏。
本文还有配套的精品资源,点击获取