恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
鱼眼相机去畸变全解析:从畸变模型到OpenCV标定与映射表实战
首页
资讯中心
/
鱼眼相机去畸变全解析:从畸变模型到OpenCV标定与映射表实战
鱼眼相机去畸变全解析:从畸变模型到OpenCV标定与映射表实战
发布时间:2026/9/1 7:05:25
简介面向计算机视觉与图像处理学习者的鱼眼相机去畸变代码包专注解决鱼眼镜头广视角带来的图像畸变问题。包内覆盖经纬度矫正法与棋盘格矫正法两类主流思路前者基于几何映射将鱼眼图像展开后者通过棋盘格标定获取内参和畸变系数并结合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 和 DK 负责描述相机本身的投影关系D 负责描述偏离理想投影的程度。标定过程需要用棋盘格角点的世界坐标和像素坐标建立对应关系然后通过非线性优化最小化重投影误差。3. 标定实操从棋盘格拍摄到内参求解的完整链路3.1 拍摄棋盘格的10条经验标定算法再强数据不行也白搭。我踩过不少坑之后总结了这几条拍摄经验用 6×9 或者 8×11 这种行列数不相等的棋盘格避免方向歧义。至少拍 15 张最好 20 到 30 张。注意不是同一个位置连拍而是每张都改变棋盘格的姿态和位置。棋盘格不要贴满整个画面四周留出至少 10% 的边距防止角点检测在边缘失效。一定要让棋盘格出现在画面边缘区域因为鱼眼畸变最严重的地方就是边缘如果只在中心区域拍畸变信息根本没被采集到。避免强反光和运动模糊。棋盘格最好用哑光纸打印贴在平整的硬板上。远近距离都要覆盖。近距离让棋盘格占据画面大半远距离让棋盘格只占一小块这样标定出的内参对距离变化更鲁棒。镜头必须是定焦或者手动对焦锁死状态标定过程中绝对不允许变焦。每次拍摄时让棋盘格相对相机有明显倾斜角不要一直正对相机不同倾角有助于估计外参和畸变。拍摄环境光线要均匀避免棋盘格上出现大面积高光或者阴影。拍完先人工筛选一遍模糊、反光、棋盘格不完整的照片直接删掉不要留着拖累优化。3.2 角点检测与亚像素细化棋盘格角点检测直接用 OpenCV 的 findChessboardCorners。需要注意 patternSize 参数传递的是内角点数量而不是棋格数量。比如棋盘格每行有 9 个方格、每列有 6 个方格那么内角点是 8×5patternSize 应该传 (8, 5)。这个顺序是先列后行也就是 (width, height)很多人在这里传反导致检测失败。检测到角点后要再用 cornerSubPix 做亚像素细化这一步能显著提升标定精度。细化窗口我一般用 (11, 11)迭代终止条件设置为最大迭代 30 次、精度 0.001 像素。3.3 标定代码与结果判定下面是我项目中实际在用的标定代码基于 Python 和 OpenCV 4.ximport 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), dtypenp.float64) for _ in range(N_OK)] tvecs [np.zeros((1, 1, 3), dtypenp.float64) for _ in range(N_OK)] rms, K, D, rvecs, tvecs cv2.fisheye.calibrate( objpoints, imgpoints, gray.shape[::-1], K, D, rvecs, tvecs, flagscv2.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, KK, DD, DIMgray.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 路线Afisheye.undistortImage 一行搞定拿到 K 和 D 之后最直接的去畸变方式是调用 cv2.fisheye.undistortImageimport 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), balance0.5 ) undistorted cv2.fisheye.undistortImage(img, K, D, Knewnew_K) cv2.imwrite(undistorted_sample.jpg, undistorted)这个路线适合快速验证算法效果。estimateNewCameraMatrixForUndistortRectify 会根据 balance 参数重新计算一个新的内参矩阵 new_K去畸变时用 new_K 而不是原始 K能够自动缩放焦距让输出图像裁掉畸变严重的边缘区域。4.2 路线BinitUndistortRectifyMap 加 remap工程首选生产环境我建议用映射表方案。先一次性生成两幅映射表 map1、map2后续每一帧只需要执行 remapdef build_undistort_maps(K, D, dim, balance0.5): new_K cv2.fisheye.estimateNewCameraMatrixForUndistortRectify( K, D, dim, np.eye(3), balancebalance ) 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, interpolationcv2.INTER_LINEAR, borderModecv2.BORDER_CONSTANT) dim (1280, 800) # 你的输出尺寸 map1, map2 build_undistort_maps(K, D, dim, balance0.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×654 个。如果你把 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 带偏。本文还有配套的精品资源点击获取