简介面向正在做鱼眼相机标定的开发者这套PythonOpenCV方案从棋盘格制作与采集讲起完整覆盖了生成棋盘格、调用相机拍摄图像、计算内参与畸变参数、保存参数并用其去畸变的流程适合有Python基础、需解决鱼眼图像校正问题的本科生或工程人员。压缩包共57个文件包含3个可运行Python脚本、51张9x6棋盘格实拍样图、1个yaml标定结果文件、1张棋盘格模板png和1份Markdown说明整体仅4.35MB脚本与图片分开放置便于替换自己的数据直接复现。目前已有2302人学习。读者可利用其中脚本快速生成多种棋盘格支持屏幕显示或打印后拍摄同时示例图片和标定参数也能帮助理解fisheye模型原理为后续去畸变、图像拼接或三维重建提供可修改的起点。1. 鱼眼标定不是换个棋盘格那么简单做视觉的人多少都遇到过这种场面普通相机标定流程跑得行云流水换到鱼眼相机上OpenCV 的cv2.calibrateCamera直接崩了或者算出来的重投影误差大得离谱。原因是鱼眼镜头的视场角通常超过 140°畸变模型不再是针孔模型下的 Brown 模型能描述的——入射光线在进入镜头前已经被大幅折射边缘区域的分辨率急速下降用普通多项式去拟合高阶畸变参数矩阵会严重病态。这篇博客要拆的就是一套完整的鱼眼相机内参和畸变参数标定方案基于 Python OpenCV 的fisheye模块它包含了棋盘格生成与屏幕显示、相机图像采集、参数求解和去畸变四个环节适合正在做全景拼接、车载环视、SLAM 前端的工程师直接抄作业。这套流程的关键在于两个地方一是标定板的采集质量决定了参数上限二是cv2.fisheye模块的四个畸变系数k1, k2, k3, k4和针孔模型的五个系数完全不通用混用必出问题。下文我会把标定板制作、图像拍摄、calibrate求解、initUndistortRectifyMap映射去畸变完整走一遍顺带把我在实际项目中踩过的坑标出来。2. 标定板生成与鱼眼图像采集决定参数上限的前置环节2.1 为什么用棋盘格而不是 AprilTag 或圆点阵鱼眼标定对特征点的亚像素定位精度要求极高。棋盘格的优势在于角点检测的响应值稳定cv2.findChessboardCorners在低分辨率、强畸变条件下依然能给出可靠的整数像素坐标再配合cornerSubPix做亚像素细化。圆点阵在边缘区域的椭圆变形严重圆心拟合误差会随畸变增大AprilTag 虽然鲁棒性好但 OpenCV 的fisheye模块原生配套还是棋盘格流程最顺手。本项目里用的是 9x6 内角点棋盘格chessboard.py负责生成并显示保存为chessboard.png后可打印。代码里关键参数是width9, height6, square_size25单位是毫米这个物理尺寸后续不参与内参计算因为calibrate用的是归一化坐标但如果你要计算外参或者做 3D 重建就必须按实际打印尺寸填写。import cv2 import numpy as np width, height 9, 6 square_size 25 # mm # 生成角点对应的物理坐标z0 平面 objp np.zeros((width * height, 3), np.float32) objp[:, :2] np.mgrid[0:width, 0:height].T.reshape(-1, 2) * square_size # 绘制棋盘格图像边长为 square_size * 10 像素方便屏幕显示 pixel_per_square 10 board_pixels (width * pixel_per_square, height * pixel_per_square) chessboard np.zeros((board_pixels[1], board_pixels[0]), np.uint8) for i in range(height): for j in range(width): if (i j) % 2 0: chessboard[i * pixel_per_square:(i 1) * pixel_per_square, j * pixel_per_square:(j 1) * pixel_per_square] 255 cv2.imwrite(chessboard.png, chessboard) cv2.imshow(chessboard, chessboard) cv2.waitKey(0)逻辑说明objp里存的是棋盘格角点的物理坐标mgrid生成 9x6 的网格坐标并乘以 25mm得到每个角点在标定板坐标系下的位置。绘制部分按 10 像素/格生成图像屏幕显示时保证棋盘格轮廓清晰即可但注意屏幕显示的棋盘格物理尺寸不可控所以用屏幕拍摄时只能用于验证流程正式标定一定要用打印出来的板子否则焦距量纲会乱。chessboard.py中实际还提供了可调节窗口大小的显示逻辑我这里简化为直接imshow。拍摄端的关键约束是棋盘格必须在画面中占 20% 以上面积并且要覆盖边缘和角落区域。2.2 用 Python 调用鱼眼相机拍摄标定图shot.py的作用是调用 USB 或 CSI 接口的鱼眼相机连续拍摄并保存为frame_N.jpg。这里有个容易忽略的细节鱼眼相机一般在 1280x720 或 1920x1080 下畸变最明显但本项目采集的是 480p 图像calib_imgs_480p_9x6目录分辨率降低会减小边缘畸变的数值但不会改变畸变模型只是角点检测更快。建议还是用相机原生最高分辨率标定标定完再降分辨率使用这样camera_matrix的尺度保持准确。import cv2 import os cap cv2.VideoCapture(0) # 根据实际设备号调整 save_dir calib_imgs_480p_9x6 os.makedirs(save_dir, exist_okTrue) count 0 while count 30: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 实时反馈角点检测结果避免拍下无效图 found, corners cv2.findChessboardCorners(gray, (9, 6), None) if found: cv2.drawChessboardCorners(frame, (9, 6), corners, found) cv2.putText(frame, fcaptured: {count}, (20, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow(fisheye_capture, frame) key cv2.waitKey(1) 0xFF # 按空格键保存当前帧按 q 退出 if key ord( ): if found: cv2.imwrite(f{save_dir}/frame_{count}.jpg, frame) count 1 else: print(棋盘格未完全检测到跳过) elif key ord(q): break cap.release() cv2.destroyAllWindows()参数说明cv2.findChessboardCorners的第三个参数传None表示使用默认的检测策略对鱼眼图来说默认策略通常够用如果边缘区域检测不到角点可以尝试传入cv2.CALIB_CB_ADAPTIVE_THRESH cv2.CALIB_CB_NORMALIZE_IMAGE。保存的是原始 BGR 帧而不是灰度图主要考虑如果你后续需要不同的角点检测参数可以重新处理。拍摄规范建议至少采集 2030 张不同位姿的棋盘格图位姿变化要包括前后平移改变尺度、绕 x/y 轴旋转改变倾角、左右移动把棋盘格推向画面边缘。鱼眼相机视场大尤其要保证棋盘格出现在画面最边缘的位置——那里的畸变最大对约束高阶畸变系数 k3、k4 至关重要。只拍中间区域或者只有平移没有旋转标定出的 k3、k4 基本是噪声。2.3 采集质量自检曝光与反光鱼眼镜头视场大很容易把光源拍进去造成棋盘格局部过曝。过曝区域的角点检测响应值下降cornerSubPix迭代收敛位置会发生偏移。我一般会在拍摄时打开一个简单的灰度直方图窗口确认棋盘格白色方块灰度值在 180~235 之间黑色方块在 30~80 之间。如果反光严重调整拍摄角度或者用哑光纸打印。还有一个很多人不重视的点标定板不能是弯曲的。打印在白纸上然后贴在硬纸板上贴的时候要保证没有气泡和褶皱否则物理坐标objp和实际位置偏差会直接折算进畸变系数里表现为重投影误差很大但参数看起来正常。3. 鱼眼相机内参求解cv2.fisheye 模块的参数含义与实现3.1 鱼眼畸变模型与针孔模型的差异OpenCV 的cv2.fisheye模块使用的是等距投影模型Equidistant Model其畸变模型为theta_d theta * (1 k1 * theta^2 k2 * theta^4 k3 * theta^6 k4 * theta^8)其中theta是入射光线与光轴的夹角theta_d是畸变后的角度。像素坐标与theta_d的关系为r f * theta_d。注意这里的r是像素点到畸变中心的距离不是针孔模型中的r f * tan(theta)。鱼眼镜头的设计目标就是让r与theta近似线性这样 180° 视场内的光线都能投射到有限的像面上。普通相机的 Brown 模型用多项式直接修正归一化平面上的坐标畸变系数是(k1, k2, p1, p2, k3)其中p1, p2是切向畸变。鱼眼相机的切向畸变通常很小fisheye模型不显式建模切向畸变而是在calibrate时通过优化camera_matrix的中心点偏移来吸收。如果你硬要用cv2.calibrateCamera去标鱼眼图边缘角点的重投影误差会非常大因为高阶项无法拟合tan与theta之间的非线性差异。3.2 calibrate 的完整调用与参数收敛判断核心代码在calib_fisheye.py读取calib_imgs_480p_9x6下所有frame_*.jpg提取角点后调用cv2.fisheye.calibrate。这个函数内部使用 Levenberg-Marquardt 优化算法迭代求解内参矩阵和畸变系数。import cv2 import numpy as np import glob import yaml chessboard_size (9, 6) square_size 25.0 # mm用于生成物理坐标不参与内参求解 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) * square_size obj_points [] # 3D 物理坐标 img_points [] # 2D 像素坐标 images glob.glob(calib_imgs_480p_9x6/frame_*.jpg) print(f找到 {len(images)} 张标定图) for fname in sorted(images): img cv2.imread(fname) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) found, corners cv2.findChessboardCorners(gray, chessboard_size, None) if not found: 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) obj_points.append(objp) img_points.append(corners_sub) print(f有效标定图数量{len(obj_points)}) # 初始化内参fisheye 标定对初始值敏感不能随便填 K np.zeros((3, 3)) D np.zeros((4, 1)) rvecs [np.zeros((1, 1, 3), dtypenp.float32) for _ in range(len(obj_points))] tvecs [np.zeros((1, 1, 3), dtypenp.float32) for _ in range(len(obj_points))] # 关键参数CALIB_USE_INTRINSIC_GUESS 允许优化初始 K 和 D flags cv2.fisheye.CALIB_RECOMPUTE_EXTRINSIC cv2.fisheye.CALIB_CHECK_COND rms, K, D, rvecs, tvecs cv2.fisheye.calibrate( obj_points, img_points, gray.shape[::-1], K, D, rvecs, tvecs, flags, criteria(cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 100, 1e-5) ) print(fRMS 重投影误差{rms:.4f} 像素) print(f内参矩阵 K\n{K}) print(f畸变系数 D (k1, k2, k3, k4)\n{D.ravel()}) # 保存为 yaml供去畸变和下游使用 data { camera_matrix: K.tolist(), dist_coeffs: D.tolist(), rms: float(rms), } with open(fisheye_param.yaml, w) as f: yaml.dump(data, f, default_flow_styleFalse)逻辑说明obj_points中每个元素形状是(1, 54, 3)img_points中每个元素形状是(1, 54, 1, 2)这个维度格式是fisheye.calibrate硬性要求的和普通calibrateCamera要求的(N, 3)不同写错会直接报错或算错。K和D传入时是先初始化再被函数原地修改rvecs和tvecs也必须预先分配好列表否则函数内部会因尺寸不匹配报错。flags中用到的CALIB_RECOMPUTE_EXTRINSIC表示每次内参迭代后重新计算外参收敛更稳定CALIB_CHECK_COND会检查棋盘格图像的线性相关性如果检测到退化配置所有棋盘格朝向一致直接返回错误码。标准流程建议显式传入CALIB_FIX_SKEW因为鱼眼相机像素通常是正方形不固定 skew 会让优化多一个自由度在弱约束下容易产生非物理结果。RMS 值怎么看小于 0.5 像素说明采集质量优秀0.5~1.0 像素可用大于 1.0 像素建议检查是否有模糊帧、低对比度帧或者棋盘格未覆盖画面边缘。注意鱼眼相机的 RMS 通常会略高于普通相机因为边缘角点的亚像素定位难度更大0.8 像素对 180° 鱼眼来说已经是不错的结果。3.3 采集数量与位姿分布的量化判断我习惯在标定前先对img_points做个简单统计计算每张图中所有角点像素坐标的均值看这些均值点是否覆盖了整个图像平面。如果所有均值点都集中在图像中心附近说明棋盘格没有出现在边缘这样的数据对 k3、k4 的约束严重不足。import numpy as np centers [] for pts in img_points: centers.append(pts.reshape(-1, 2).mean(axis0)) centers np.array(centers) print(角点质心分布范围) print(X:, centers[:, 0].min(), -, centers[:, 0].max()) print(Y:, centers[:, 1].min(), -, centers[:, 1].max())如果 X 范围没有覆盖图像宽度的 15% 以内或者 Y 范围没有覆盖高度的 15% 以内就补拍。另外还需要检查每张图中棋盘格的倾斜程度——计算角点围成的凸包面积面积大的表示棋盘格离相机近或者旋转角度大面积小但检测成功的表示远距离正拍后者对焦距约束有帮助但对畸变约束弱要控制比例在 1/3 以下。4. 去畸变与映射表生成从参数到可用图像的完整链路4.1 initUndistortRectifyMap 与 remap 的含义拿到K和D后去畸变分两步先生成映射表再用remap重采样。映射表本质上是一张(H, W)的查找表每个像素存的是该位置在原始图中的采样坐标。cv2.fisheye.initUndistortRectifyMap根据内参和畸变系数反算出理想像素坐标对应的畸变坐标remap按这些坐标从原图取像素。这里有个关键概念initUndistortRectifyMap的默认参数R是单位矩阵P是新的投影矩阵。如果直接传K作为P去畸变后的图像与原始图像的焦距一致如果你想调整焦距来保留更多边缘内容可以修改P的[0,0]和[1,1]元素。对鱼眼镜头的超广角画面去畸变后边缘原本 180° 的视场会被拉伸到图像边界外因此通常需要配合fisheye.estimateNewCameraMatrixForUndistortRectify自动计算合适的P。import cv2 import numpy as np import yaml with open(fisheye_param.yaml, r) as f: data yaml.safe_load(f) K np.array(data[camera_matrix]) D np.array(data[dist_coeffs]) img cv2.imread(calib_imgs_480p_9x6/frame_0.jpg) h, w img.shape[:2] # 自动计算新内参balance 控制保留原始视场的大小 # balance0 时保留最小视场边缘裁剪最多1 时保留全部视场 new_K cv2.fisheye.estimateNewCameraMatrixForUndistortRectify( K, D, (w, h), np.eye(3), balance1.0 ) map1, map2 cv2.fisheye.initUndistortRectifyMap( K, D, np.eye(3), new_K, (w, h), cv2.CV_32FC1 ) undistorted cv2.remap(img, map1, map2, interpolationcv2.INTER_LINEAR) # 拼接原始图与去畸变图直观对比 compare np.hstack([img, undistorted]) cv2.imwrite(undistort_compare.jpg, compare)参数说明balance取值范围 0~1工程上一般从 0.5 起步。balance0时new_K的焦距最小去畸变后图像的边缘信息全部裁掉适合做后续标准透视投影处理balance1.0时保留尽可能多的原始视场但图像边缘会出现明显的拉伸发虚全景拼接时两种策略都能用关键是保持一致。map1和map2是 float32 类型的映射坐标remap的插值方式对鱼眼边缘放大区域建议用INTER_LINEAR用INTER_CUBIC效果更好但耗时增加约一倍。initUndistortRectifyMap内部的R参数是矫正矩阵。如果只需要去畸变不改变视角传单位矩阵如果配合 IMU 或双目做立体矫正这里要传入由stereoRectify算出的旋转矩阵。fisheye模块也提供了stereoCalibrate和stereoRectify接口接口签名和单目类似但输入输出维度格式必须保持(1, N, 3)风格。4.2 YAML 参数文件的结构与多分辨率适配fisheye_param.yaml保存了camera_matrix和dist_coeffs实际部署时你会发现一个问题在 1920x1080 下标定的参数如果用 640x360 的图去畸变直接把 K 矩阵缩放 1/3 就行。因为 K 的fx, fy, cx, cy都是像素单位缩放分辨率时这四个值按同比例缩放即可D 系数不变。def scale_camera_matrix(K, scale): K_scaled K.copy() K_scaled[0, 0] * scale # fx K_scaled[1, 1] * scale # fy K_scaled[0, 2] * scale # cx K_scaled[1, 2] * scale # cy return K_scaled # 从 1920x1080 缩放到 640x360 scale 640 / 1920 K_640 scale_camera_matrix(K, scale)注意cx, cy在缩放后理论上应该接近新分辨率中心但如果标定时K的cx偏移量很大比如偏离中心超过 5%说明镜头安装有偏心建议重新检查标定板的对称覆盖范围。还有一种做法是直接用新分辨率重新跑一遍calibrate但那样需要重新采集图像工程上大多直接用缩放系数处理。4.3 去畸变效果验证的量化方法肉眼看着不弯了不算完我用三个指标验证第一去畸变后棋盘格图像重新检测角点计算所有角点连线是否呈直线用cv2.HoughLinesP或直接拟合直线看残差第二用标定时留下的测试图不参与标定的图像跑重投影误差确认不是过拟合第三真实场景验证找一扇矩形门或者一片瓷砖墙看边缘线是否恢复平直。# 重新检测去畸变图中的棋盘格角点计算直线残差 gray_u cv2.cvtColor(undistorted, cv2.COLOR_BGR2GRAY) found_u, corners_u cv2.findChessboardCorners(gray_u, (9, 6), None) if found_u: pts corners_u.reshape(-1, 2) # 对每一行角点拟合直线看最大残差 for row in range(6): row_pts pts[row * 9:(row 1) * 9] x row_pts[:, 0] coeffs np.polyfit(x, row_pts[:, 1], 1) y_fit np.polyval(coeffs, x) residual np.max(np.abs(row_pts[:, 1] - y_fit)) print(f行 {row} 最大直线残差{residual:.3f} 像素)残差小于 1 像素说明去畸变质量良好。这里用np.polyfit做一阶线性拟合只能检测水平方向弯曲要检测垂直方向按列拟合x关于y的函数即可。5. 进阶联合优化中的关键参数与常见坑5.1 提升标定精度的三个系数选择如果你发现 RMS 偏大但采集流程没问题先检查criteria中eps是否设得太松。cornerSubPix的迭代终止条件中eps0.001表示亚像素迭代位移小于 0.001 像素即停止这个精度对 480p 图像已经足够但对 4K 图像建议把criteria中的迭代次数从 30 提高到 50因为你初始检测的角点位置可能偏离真值 3~5 个像素。calibrate中的criteria控制 LM 优化的终止条件max_iter100一般够用eps1e-5表示内参更新向量的模小于该值时停。如果标定结果每次跑都不一样检查是否固定了CALIB_FIX_SKEW。鱼眼相机应固定 skew 为 0但CALIB_USE_INTRINSIC_GUESS不加CALIB_FIX_SKEW时skew 参与优化在图像数量不足或位姿退化时会产生非零 skew 来吸收误差导致焦距和中心点偏离真实值。还有一个很多人不知道的CALIB_FIX_PRINCIPAL_POINT的使用场景如果你的相机装配精度很高光心在图像中心附近可以先固定主点标定一次再释放主点标定一次比较两次的 RMS。RMS 变化小于 0.1 像素就固定主点因为自由主点会吸收一部分畸变模型的误差导致畸变系数不纯粹。5.2 YAML 加载时容易踩的坑yaml.safe_load读出来的camera_matrix是 list直接传给cv2.fisheye函数会类型报错。需要先转成np.array而且注意K必须是float64类型。D必须是(4, 1)形状如果存的时候用了D.ravel().tolist()读出来是(4,)在initUndistortRectifyMap中会报维度错误。K np.array(data[camera_matrix], dtypenp.float64) D np.array(data[dist_coeffs], dtypenp.float64).reshape(4, 1)另一个容易踩的坑是gray.shape[::-1]作为图像尺寸传入calibrate。gray.shape是(H, W)[::-1]得到(W, H)这正好是 OpenCV 中函数要求的尺寸格式。直接传(W, H)字面量也可以但很多人下意识写成gray.shape导致标定时图像被转置角点坐标和图像尺寸不匹配内参矩阵的fx, fy会错位。5.3 对不良标定结果的处理策略RMS 大时不要盲目加图先做异常帧筛查。常见做法是逐张计算每个棋盘格角点的重投影误差把误差超过 2 倍中位数的帧剔除。fisheye.calibrate返回的rvecs和tvecs是优化后的值可以用cv2.fisheye.projectPoints把obj_points重新投影和img_points对比得到逐点误差。total_err [] for i in range(len(obj_points)): projected, _ cv2.fisheye.projectPoints( obj_points[i], rvecs[i], tvecs[i], K, D ) err np.linalg.norm(projected.reshape(-1, 2) - img_points[i].reshape(-1, 2), axis1) total_err.extend(err) total_err np.array(total_err) print(fP95 重投影误差{np.percentile(total_err, 95):.3f} 像素)P95 误差大于 0.8 像素时找到误差最大的那几帧检查是不是曝光不均匀或者棋盘格局部被遮挡。相机自动曝光在鱼眼大视场下很容易把边缘压暗导致黑色方块和白色方块对比度降低角点定位偏移。5.4 与 IMU 联合标定时的参数传递近期很多环视项目要求相机内参和 IMU 外参联合标定这里说一个容易出错的地方鱼眼相机去畸变后输出的图像其内参必须替换为新P矩阵而不是原始的K。因为 IMU 外参标定通常基于去畸变后的针孔模型图像进行特征点提取如果你还用原始K去喂特征点坐标投影公式会不一致。estimateNewCameraMatrixForUndistortRectify返回的new_K就是去畸变图的新内参保存fisheye_param.yaml时建议把new_K单独存一段undistorted_camera_matrix避免和原始 K 混淆。6. 用标定参数做环视拼接前的最后一问你的图像该用哪种去畸变策略实际项目里鱼眼去畸变不是只有一种输出。全景环视需要保留 180° 以上视场通常不直接remap而是把鱼眼图像映射到球面或者鸟瞰图而可视化和目标检测任务则更倾向于透视投影矫正。这个项目里给的是基础的undistort流程但你可以只生成一次映射表map1, map2之后每一帧直接remap避免重复计算映射。对高分辨率图像initUndistortRectifyMap耗时约 50~100ms但remap本身在 GPU 上可以做到 2ms 以内所以对实时系统来说一定是预处理映射表而不是每帧都调用initUndistortRectifyMap。我自己在做 1080p 鱼眼实时去畸变时会把map1和map2合并成一张(2, H, W)的图存成 npy 文件用np.load载入再传给cv2.remap。这样省去每次启动时重新生成映射表的开销而且 npy 加载比 YAML 解析快一个数量级。合并方法很简单map_combined np.stack([map1, map2], axis0) np.save(undistort_maps.npy, map_combined) # 使用时 maps np.load(undistort_maps.npy) map1, map2 maps[0], maps[1]但要注意映射表和图像分辨率绑定如果切换分辨率必须重新生成。另外如果你把map1转成CV_16SC2格式即两个 short 类型坐标remap在 CPU 上的速度会更快因为输入是整数像素坐标时可以使用更快速的采样路径但代价是亚像素精度损失约 0.5 像素对视觉效果影响不大对需要精确像素坐标的测量任务不推荐。本文还有配套的精品资源点击获取