首页 / 资讯中心 / 文章详情

OpenCV鱼眼相机畸变矫正:针孔模型与fisheye四参数实战

OpenCV鱼眼相机畸变矫正:针孔模型与fisheye四参数实战 ★ FEATURED ARTICLE
鱼眼摄像头畸变矫正这件事说难不难说简单也处处是坑。我用 Python OpenCV 做鱼眼摄像头畸变矫正前后大概有四五年时间从最初拿 cv2.undistort 一把梭、结果画面边缘糊成一团到后来能把 190 度视场的模组校到边缘直线度误差低于 2 个像素中间踩过的坑基本能写满半本笔记。这篇东西想做的事很直接把两条我实际在项目里跑通的路线掰开揉碎讲清楚一条是基于棋盘格的针孔模型加畸变系数一条是 OpenCV 自带的 fisheye 四参数模型。前者生态成熟、上手快适合视场不算夸张的广角模组后者能吃下 180 度甚至更大的视场代价是参数细节多、初始化敏感。如果你手上正好有一台鱼眼相机想把画面里的弯线拉直或者做视觉测量、SLAM 前端、机器人避障、产线检测需要一个能用的无畸变图像源那这篇内容就是给你准备的。哪怕你只会写一点点 Python、知道什么叫内参矩阵跟着走也能出结果。我会把每个参数为什么这么选、每个报错大概是什么原因都交代清楚代码是可以直接粘过去跑的那种。1. 两条矫正路线的底层逻辑与选型依据1.1 鱼眼镜头把直线压弯的数学原因普通镜头追求的是像高正比于入射角的正切也就是 r f·tan(θ)。这个关系在视场角小的时候没问题一旦 θ 逼近 90 度tan 就炸了像高趋向无穷根本没法落在一个有限尺寸的传感器上。鱼眼镜头的思路完全不同它允许入射光线大幅折一下用一种人为压缩的映射关系把整片半球视野挤进一块 1/2.3 英寸的芯片上。常见的映射有几种等距投影 r f·θ、等立体角投影 r 2f·sin(θ/2)、正交投影 r f·sin(θ)工业模组里最普遍的近似是等距投影因为它在数学上最线性、最好标。问题就出在这个压缩上。现实世界里一条笔直的墙缝经过压缩映射之后在图像里就成了一条圆弧越靠边弯得越厉害。这个弯曲不是镜头做工不好而是成像模型的必然结果。做矫正的本质就是把这个已知的非线性映射反向解出来重新把每个像素搬回到它本该在的位置。理解这一点很关键因为它决定了后面很多工程决策。比如为什么矫正之后画面边缘会明显变糊——因为那个区域的原始像素被稀稀拉拉地铺到了更大的面积上信息密度不够再比如为什么矫正后的有效视野总比原图小一圈——因为你把弯曲的边角拉直之后矩形框装不下那么多东西了。搞清楚映射是人为压缩的你就不会指望有什么算法能把边缘细节变回来。1.2 针孔模型与鱼眼模型的分野OpenCV 里处理镜头畸变有两套完全独立的代码坑就在这。一套在 cv2 命名空间下是经典的针孔模型加 Brown-Conrady 畸变多项式先按小孔成像把三维点投到归一化平面再叠加上径向畸变 k1、k2、k3高阶还有 k4、k5、k6和切向畸变 p1、p2。这套模型的径向项是 r 的幂级数在 r 比较小的时候拟合能力很好但 r 一大多项式就会失控拟合出来的曲线和真实投影关系对不上。另一套在 cv2.fisheye 命名空间下用的是 Kannala-Brandt 四参数模型畸变多项式是建立在入射角 θ 上的θ_d θ(1 k1θ² k2θ⁴ k3θ⁶ k4θ⁸)。注意这里自变量是角度而不是归一化半径这个差别是本质性的。角度被限制在 0 到 π/2 之间多项式永远在一个有界区间里工作不会像针孔模型那样在边缘直接发散。所以 180 度以上的模组必须走 fisheye 这条路。还有一个容易忽略的点两套 API 的内参矩阵含义不完全一样。fisheye 的 K 里 fx、fy 是把角度映射到像素的尺度因子量纲上是像素每弧度针孔模型的 fx 是像素乘焦距除以像素尺寸物理上对应小孔到像面的距离。数值上它们可能差好几倍所以千万不要把 cv2.calibrateCamera 算出来的 K 直接喂给 cv2.fisheye.undistortImage反过来也不行。我见过有人这么干出来的图要么全黑要么像哈哈镜排查半天以为是标定板有问题。1.3 选型决策表什么场景用哪条路下面这张表是我自己在项目里用来快速判断的放在这里供你对照。判断的核心依据其实就两条模组的实际视场角以及你对边缘精度的要求。判断维度针孔模型 畸变系数fisheye 四参数模型适用视场角大致 120 度以内比较稳150 度勉强150 度以上180 度、190 度都能吃标定 APIcv2.calibrateCameracv2.fisheye.calibrate畸变参数个数4 到 8 个k1k2p1p2k3...固定 4 个k1 到 k4典型重投影误差0.1 到 0.3 像素0.2 到 0.6 像素边缘直线恢复质量视场大时明显变差边缘仍然可控生态与资料极多各种教程都能套相对少API 细节坑多实时性能remap 后 1080p 几毫秒量级同一套 remap性能相当我的建议广角监控、普通 USB 摄像头、视场不大车载、机器人环视、全景、超大视场实话说如果你的模组标称视场在 120 度以内别折腾 fisheye针孔模型配合 CALIB_RATIONAL_MODEL 已经足够而且网上能查到的资料多十倍出问题好找答案。只有当你在矫正图上明显看到画面四角的直线还是弯的、重投影误差怎么调都下不来的时候才是切到 fisheye 模型的信号。2. 标定前的准备工作一张板子决定成败2.1 标定板规格与打印精度的取舍标定板这块我吃过最大的亏是早期图省事用普通 A4 纸打印棋盘格贴在硬纸板上就拿去标定。结果重投影误差一直在 0.8 像素上下浮动反复重标十几次都下不来。后来换成铝基板覆膜的标定板同一个镜头、同样的采集流程误差直接掉到 0.25。原因很朴素纸会翘、会受潮、贴不平面平面度误差在鱼眼这种大视场下会被放大得非常明显。规格选择上我的经验是按视场来配。标定板在图像里占据的像素面积太小角点定位精度不够占得太满棋盘格跑到图像最边缘的极端畸变区角点检测又容易失败。比较舒服的状态是让标定板在画面里占 1/4 到 1/2 的面积并且能在不同姿态下被完整看到。对于 1280×720 分辨率的鱼眼模组我一般用内角点 9×6、方格边长 20 毫米左右的板子如果镜头特别广视场超过 190 度会换成 7×5、边长 25 毫米的小板方便往边缘凑。注意方格边长这个物理尺寸只影响外参平移向量的量纲不影响内参。但填错了会导致后面做多相机标定时尺度全错所以一定要和实际板子对上。另外 cv2.findChessboardCorners 传的是内角点数9×6 的板子指的是内部黑白交点的行列数不是方格数这个数错一位就永远检测不到。2.2 采集脚本与覆盖姿态清单采集是最枯燥但最决定成败的一步。我现在的习惯是写一个极简的采集脚本按空格存图、按 ESC 退出边移动板子边看屏幕上的计数。下面这段可以直接用。import cv2 import os save_dir calib_images os.makedirs(save_dir, exist_okTrue) cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) idx 0 while True: ok, frame cap.read() if not ok: break cv2.putText(frame, fsaved: {idx}, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0), 2) cv2.imshow(capture, frame) key cv2.waitKey(1) 0xFF if key 32: # 空格存图 cv2.imwrite(f{save_dir}/img_{idx:03d}.png, frame) idx 1 elif key 27: # ESC 退出 break cap.release() cv2.destroyAllWindows()姿态清单我固定要求至少 20 张覆盖下面这几类每类 3 到 5 张板子正对镜头居中板子左右各平移一次让它靠到画面左右边缘上下各平移一次绕 X 轴倾斜正负 20 到 30 度绕 Y 轴倾斜同样幅度绕 Z 轴旋转 45 度左右最后是板子放在画面四个角落附近的大倾角姿态。这里有个反直觉的点很多人拍标定图喜欢把板子摆得端端正正觉得这样标准。但从求解的角度看正对的姿态对解畸变系数的贡献几乎为零因为中心区域的畸变本来就小。真正约束 k1 到 k4 的信息全在画面边缘的极端姿态里。我一般的配比是中心正对 3 张、边缘和大倾角占 15 张以上。实测下来这个配比能让 fisheye 的重投影误差从 0.7 降到 0.35 左右。2.3 角点检测的三种写法与稳定性对比角点检测这块OpenCV 4.x 给了三个选择稳定性差别很大。最老的是 cv2.findChessboardCorners速度快但在畸变严重的图上失败率肉眼可见地高。中间档是加 flags 的组合比如 CALIB_CB_ADAPTIVE_THRESH 加 CALIB_CB_NORMALIZE_IMAGE能改善光照不均的情况。真正好用的是 cv2.findChessboardCornersSB基于半色调模板匹配在鱼眼这种弯曲变形的棋盘格上成功率明显更高而且它内部已经做了亚像素细化不需要再调 cv2.cornerSubPix。我现在的写法是 SB 优先、经典算法兜底两个都失败就跳过这张图并打印文件名方便回头补拍。import cv2 import numpy as np import glob BOARD (9, 6) # 内角点列数、行数 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 50, 1e-4) def detect_corners(gray): ok, corners cv2.findChessboardCornersSB( gray, BOARD, flagscv2.CALIB_CB_EXHAUSTIVE | cv2.CALIB_CB_ACCURACY) if ok: return True, corners ok, corners cv2.findChessboardCorners( gray, BOARD, flagscv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE) if ok: corners cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria) return ok, corners files sorted(glob.glob(calib_images/*.png)) ok_count 0 for f in files: gray cv2.cvtColor(cv2.imread(f), cv2.COLOR_BGR2GRAY) ok, _ detect_corners(gray) if ok: ok_count 1 else: print(fail:, f) print(fusable {ok_count}/{len(files)})提示如果可用图少于 15 张先别急着标定回去补拍。图不够的时候求解器会用少量样本硬凑参数出来的结果在训练集上看着还行换个场景立刻崩。我一般要求可用率不低于 70%。3. 方法一针孔模型 畸变系数的高效矫正3.1 calibrateCamera 求解全过程拆解针孔这条路的核心就一个函数cv2.calibrateCamera。但它的输入组织方式值得单独说清楚因为这里搞错了后面全是玄学。你需要准备两组点objectPoints 是世界坐标系下的三维点因为标定板是平面通常把 Z 设成 0X、Y 按方格边长生成imagePoints 是对应图像上的二维像素点顺序必须和 objectPoints 严格一致。findChessboardCorners 返回的角点顺序是从左上角开始按行扫描的所以 objp 也要用同样的顺序生成。顺序一旦错位标定不是报错而是给你一堆看起来很合理但完全错误的参数这种坑最难查。import cv2 import numpy as np import glob BOARD (9, 6) SQUARE 20.0 # 毫米只影响外参量纲 objp np.zeros((BOARD[0] * BOARD[1], 3), np.float32) objp[:, :2] np.mgrid[0:BOARD[0], 0:BOARD[1]].T.reshape(-1, 2) * SQUARE obj_points, img_points [], [] criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 50, 1e-4) for f in sorted(glob.glob(calib_images/*.png)): gray cv2.cvtColor(cv2.imread(f), cv2.COLOR_BGR2GRAY) ok, corners cv2.findChessboardCornersSB( gray, BOARD, flagscv2.CALIB_CB_EXHAUSTIVE | cv2.CALIB_CB_ACCURACY) if not ok: continue img_points.append(corners.reshape(-1, 1, 2).astype(np.float32)) obj_points.append(objp.reshape(-1, 1, 3)) h, w gray.shape[:2] ret, K, dist, rvecs, tvecs cv2.calibrateCamera( obj_points, img_points, (w, h), None, None, flagscv2.CALIB_RATIONAL_MODEL, # 启用 k4/k5/k6宽视场更稳 criteriacriteria) print(rms:, ret) print(K:\n, K) print(dist:, dist.ravel())flags 的选择是有讲究的。默认只解 k1、k2、p1、p2、k3 五个参数视场一大就压不住。CALIB_RATIONAL_MODEL 会把 k4、k5、k6 也放进来等于给径向畸变多项式加了更高阶的项在 140 度左右的模组上效果立竿见影。但阶数上去之后过拟合风险也上来了所以我通常还会跑一遍带 CALIB_FIX_K3 的版本做对比看哪个的验证误差更低。3.2 undistort 与 remap 的性能差在哪拿到 K 和 dist 之后矫正有两种写法。cv2.undistort 一行搞定但它在内部每次都重新算一遍映射表静态图处理无所谓跑视频流就浪费了。反过来cv2.initUndistortRectifyMap 把映射表算一次存下来之后每帧只做 cv2.remap 的采样快得很明显。# 静态图一次性处理 undist cv2.undistort(img, K, dist, None, newK) # 视频流映射表只算一次 map1, map2 cv2.initUndistortRectifyMap( K, dist, None, newK, (w, h), cv2.CV_16SC2) while True: ok, frame cap.read() if not ok: break undist cv2.remap(frame, map1, map2, interpolationcv2.INTER_LINEAR)关于 cv2.CV_16SC2 这个类型值得多讲一句。它会让 map1 存成双通道 16 位定点数map2 存插值权重整套表的内存占用只有 CV_32FC1 全浮点版本的一半左右remap 时的读取效率也更高。1080p 的图全浮点映射表要 8 兆多定点版四兆多在嵌入式设备上这点内存差别很实在。在 x86 上跑 1080pcv2.remap 单帧大概 3 到 5 毫秒在常见的 ARM 板上会到 15 到 25 毫秒这时候就得考虑降分辨率或者多线程了。我一般先看整个流水线里 remap 占多少如果不到 30%就先不动它优先优化上游的推理或检测环节。cv2.setNumThreads 也会影响 remap 的速度默认它会用满多核但如果你自己还开了线程池两边抢核反而更慢这时候把它固定成 1 到 2 更合适。3.3 两个工程化细节alpha 裁切与 ROI 复用cv2.getOptimalNewCameraMatrix 这个函数很多人直接 alpha0 就过去了其实参数很有用。alpha 取 0 的时候算法会裁掉所有黑边只保留原始图像有有效像素覆盖的区域代价是丢掉一圈视野alpha 取 1 会保留全部原始画面但四周出现大量黑边。我的习惯是取 0.3 到 0.5 之间既保住中间一块干净画面又不至于把边缘有信息的区域一裁了之。顺带说一个实际项目里的省事做法把 getOptimalNewCameraMatrix 返回的 roi 存进配置文件运行时直接用这个 ROI 做裁剪而不是每帧重新算。因为内参一旦标定完就不再变newK 和 roi 都是常量。这个优化在原来那个项目上直接把预处理耗时削掉了将近一半因为省掉了每帧一次 getOptimalNewCameraMatrix 调用和一次全图处理。newK, roi cv2.getOptimalNewCameraMatrix(K, dist, (w, h), alpha0.4) x, y, rw, rh roi map1, map2 cv2.initUndistortRectifyMap(K, dist, None, newK, (w, h), cv2.CV_16SC2) undist cv2.remap(frame, map1, map2, cv2.INTER_LINEAR)[y:yrh, x:xrw]4. 方法二鱼眼四参数模型的宽视场矫正4.1 Kannala-Brandt 模型与 OpenCV 的四参数约定这套模型的投影过程是这么走的先把三维点归一化得到 a X/Zb Y/Z算出 r sqrt(a² b²)再取入射角 θ atan(r)。然后按 θ_d θ(1 k1θ² k2θ⁴ k3θ⁶ k4θ⁸) 得到畸变后的角度最后 x (θ_d / r)·ay (θ_d / r)·b乘上焦距加主点得到像素坐标。对比一下针孔模型的公式就能看出区别有多本质。针孔模型的畸变项是加在归一化平面半径上的多项式修正本质上是小孔成像 一个小修正而这里的 θ_d 直接替换了整个投影关系没有小孔成像在打底。所以 fisheye 模型不需要假设成像接近小孔它的适用范围天然覆盖整个半球。四个参数的含义也要理解清楚k1 通常是负的因为鱼眼镜头的实际投影比理想等距投影更压缩一点k2 到 k4 是更高阶的修正项量级依次减小。标定出来的 k1 大概在 -0.05 到 -0.3 之间属于正常如果跑出来是 -3 或者 2 这种数量级基本可以判定标定数据有问题别往下走。4.2 fisheye.calibrate 的初始化陷阱与参数组合fisheye 版标定最大的坑是初始化。cv2.fisheye.calibrate 对初始内参非常敏感如果不给初值它会拿图像中心当主点、拿一个粗略估计当焦距直接开始迭代很容易掉进局部极小值。我现在的标准做法是用 CALIB_USE_INTRINSIC_GUESS手动给一个基于图像尺寸的初值fy 取图像宽度的 1/3 左右主点取几何中心。import cv2 import numpy as np import glob BOARD (9, 6) SQUARE 20.0 objp np.zeros((1, BOARD[0] * BOARD[1], 3), np.float64) objp[0, :, :2] np.mgrid[0:BOARD[0], 0:BOARD[1]].T.reshape(-1, 2) * SQUARE obj_points, img_points [], [] for f in sorted(glob.glob(calib_images/*.png)): gray cv2.cvtColor(cv2.imread(f), cv2.COLOR_BGR2GRAY) ok, corners cv2.findChessboardCornersSB( gray, BOARD, flagscv2.CALIB_CB_EXHAUSTIVE | cv2.CALIB_CB_ACCURACY) if not ok: continue img_points.append(corners.reshape(1, -1, 2).astype(np.float64)) obj_points.append(objp) h, w gray.shape[:2] K np.zeros((3, 3), np.float64) K[0, 0] w / 3.0 K[1, 1] w / 3.0 K[0, 2] w / 2.0 K[1, 2] h / 2.0 D np.zeros((4, 1), np.float64) flags (cv2.fisheye.CALIB_RECOMPUTE_EXTRINSIC | cv2.fisheye.CALIB_FIX_SKEW | cv2.fisheye.CALIB_CHECK_COND | cv2.fisheye.CALIB_USE_INTRINSIC_GUESS) criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 200, 1e-6) rvecs [np.zeros((1, 1, 3), np.float64) for _ in obj_points] tvecs [np.zeros((1, 1, 3), np.float64) for _ in obj_points] ret, K, D, rvecs, tvecs cv2.fisheye.calibrate( obj_points, img_points, (w, h), K, D, flagsflags, criteriacriteria, flags_checkcv2.CALIB_CHECK_COND)四个 flags 各管一摊CALIB_RECOMPUTE_EXTRINSIC 让每轮迭代结束后重新解外参能显著改善收敛CALIB_FIX_SKEW 把内参矩阵里的斜切项固定为 0避免它在数据不足时乱跑CALIB_CHECK_COND 是安全阀检查方程组条件数太病态就直接抛异常而不是返回垃圾结果CALIB_USE_INTRINSIC_GUESS 就是前面说的给初值。CALIB_CHECK_COND 抛异常这件事我的处理方式是用 try/except 包住把出问题的那张图单独剔除再重标。它抛异常通常说明这批数据里某个姿态的角点分布太差比如板子几乎正对镜头或者角点全挤在一条线上。别去改阈值硬压那是把问题藏起来。try: ret, K, D, rvecs, tvecs cv2.fisheye.calibrate( obj_points, img_points, (w, h), K, D, flagsflags, criteriacriteria) except cv2.error as e: print(标定失败建议剔除异常姿态后重试:, e)4.3 用 balance 与 fov_scale 控制输出视场fisheye 的矫正输出必须显式指定目标内参矩阵 Knew这是它和针孔模型最大的使用差别。cv2.fisheye.estimateNewCameraMatrixForUndistortRectify 就是用来算这个 Knew 的关键参数是 balance 和 fov_scale。balance 控制的是保留多少原始视野和黑边多少之间的平衡。取 0 的时候算法会选一个刚好让画面没有黑边的焦距此时输出图的所有像素都有源可查但代价是只保留了原始视野中间的一块取 1 的时候焦距被压到最小原始图像的所有像素都能被映射进来四周会出现大面积黑边。我一般取 0 到 0.3 之间因为这个范围黑边可控、有效像素密度也还行。fov_scale 是焦距的倍数系数。大于 1 相当于把焦距放大、视场往中间收画面看起来像放大了小于 1 则视场外扩、黑边增加。这个参数在同一个镜头要输出两路不同缩放画面的时候很好用比如一路给检测算法用中等视场另一路给人眼观察用大视场。dim (w, h) Knew cv2.fisheye.estimateNewCameraMatrixForUndistortRectify( K, D, dim, np.eye(3), balance0.2, new_sizedim, fov_scale1.0) map1, map2 cv2.fisheye.initUndistortRectifyMap( K, D, np.eye(3), Knew, dim, cv2.CV_16SC2) undist cv2.remap(frame, map1, map2, interpolationcv2.INTER_LINEAR)注意cv2.fisheye 命名空间下没有 cv2.fisheye.undistort 这种单次调用要么用 cv2.fisheye.undistortImage内部自己算映射慢要么自己 initUndistortRectifyMap 加 remap推荐。别在循环里调 undistortImage那等于每帧重算一遍表。4.4 实时视频流的映射表复用写法把上面这些拼起来一套完整的实时矫正流程大概长这样启动时读标定文件、算一次 newK、算一次映射表然后进入主循环只做 remap。整个初始化部分耗时不到 100 毫秒运行起来几乎没有额外开销。import cv2 import numpy as np import json with open(fisheye_calib.json) as fp: cfg json.load(fp) K np.array(cfg[K], dtypenp.float64) D np.array(cfg[D], dtypenp.float64) cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) ok, frame cap.read() h, w frame.shape[:2] dim (w, h) Knew cv2.fisheye.estimateNewCameraMatrixForUndistortRectify( K, D, dim, np.eye(3), balance0.2, new_sizedim, fov_scale1.0) map1, map2 cv2.fisheye.initUndistortRectifyMap( K, D, np.eye(3), Knew, dim, cv2.CV_16SC2) while True: ok, frame cap.read() if not ok: break frame cv2.remap(frame, map1, map2, interpolationcv2.INTER_LINEAR) cv2.imshow(undistorted, frame) if cv2.waitKey(1) 0xFF 27: break cap.release() cv2.destroyAllWindows()这里有个我踩过的坑值得提醒标定结果一定要落盘存成文件别每次跑程序都重标。内参是镜头的固有属性只要镜头和传感器的相对位置不动、对焦环没被碰过标一次就够用。我用的是 json 存 K 和 D顺手把标定时的分辨率、标定板规格、重投影误差也写进去半年后回头看还能知道这份参数是什么条件下得到的。有些团队会把标定数据存成 npz也行关键在于把元信息一起存下来。5. 参数怎么算从焦距到视场角的换算实例5.1 用等距投影反推半视场角标完之后怎么判断结果靠不靠谱我的第一招是用等距投影反推视场角和模组标称值对一对。拿一台 1280×720 的鱼眼模组举例标称对角视场 190 度。先算图像半对角线长度R sqrt(640² 360²) ≈ 733.7 像素。按等距投影 r f·θ半视场 θ_max 190/2 95 度 1.658 弧度反推 f ≈ 733.7 / 1.658 ≈ 442 像素。这时候去看标定出来的 K[0,0]。如果落在 420 到 460 这个区间说明模型和数据的整体关系是自洽的标定大概率没问题。如果跑出来 fx 是 700 多那基本可以断定要么这张图根本没覆盖到画面边缘采集姿态太保守要么这压根不是鱼眼模组而是普通广角。反过来 fx 只有 200 出头那说明畸变系数在硬撑k1 可能被解到了一个离谱的值。这一步检查只要半分钟但能提前挡掉大部分无效标定。5.2 输出分辨率的确定输出分辨率这个参数常年被忽视但它直接影响观感和后处理效率。矫正之后边缘像素被拉伸原本在图像边缘挤成一团的少量像素被铺开到很大面积分辨率保持原样只会放大这份模糊没有任何信息增益。我的做法是如果输出只用于人眼观察直接降到原始分辨率的 70% 到 80%画面看起来反而更干净如果输出要喂给检测或测量算法就保持原始分辨率让算法自己去处理边缘的模糊不要在最前面就损失精度。对于 720p 的模组矫正输出用到 960×540 是个不错的折中实测边缘的块状感明显减轻remap 耗时也降了三成多。另外矫正后的有效视场比原图小一圈这件事要有心理准备。原图对角线 190 度矫正之后能完整看到的大概只有 150 到 160 度因为最边上那一圈被拉直之后超出了矩形画幅。如果下游任务需要用到边缘视野就得把 newK 的焦距调小、接受黑边或者用更大的输出画幅。5.3 真值验证用直线度打分重投影误差是标定集上的指标它说明模型拟合得好但不能完全代表矫正效果。我另外会做一个直线度检查方法很朴素找一面有明确直线边缘的墙或者地砖缝拍图、矫正然后在这条线上取若干个点拟合一条直线算最大偏差。在一张 1280×720 的矫正图上我的验收阈值是这样的对比度良好的直线边缘最大偏差控制在 2 像素以内算合格1 像素以内算优秀如果是画面最边缘、对比度又差的区域放到 3 到 4 像素也能接受。超过 5 像素基本说明标定有问题或者这条线本来就不是很直。这个检查的价值在于它是端到端的把标定、模型、映射表、插值全串起来验证了一遍。我遇到过重投影误差 0.2 像素、但实测直线偏差却有 6 像素的情况最后查出来是映射表的 CV_16SC2 在极端坐标处溢出换成 CV_32FC1 就好了。这种问题光看误差数值永远发现不了。6. 常见故障排查与踩坑记录6.1 标定误差居高不下的五类根因这几年帮别人看过的标定问题原因翻来覆去就那么几类按出现频率排一下。第一类是标定板不平面或者打印精度不够占比最高。纸翘边、亚克力板变形、喷墨打印的墨点扩散导致黑白边界糊都会让角点亚像素定位产生系统性偏差。换成铝基板或者覆膜的高精度板通常立刻见效。第二类是采集姿态太单一。20 张图全在画面中心、全是正对姿态畸变参数根本没有约束求解器只能给个近似 0 的值糊弄过去。表现出来就是误差看着还行因为中心本来畸变小但一矫正边缘全歪。第三类是图像数量不足或者质量差。图少于 15 张、有运动模糊、有强反光都会让求解不稳定。我一般在采集阶段就把模糊的图直接删掉宁可少几张也不要烂数据。第四类是模型选错。用针孔模型硬解 180 度的模组误差怎么调都在 1 像素以上不下来这就是模型根本表达不了这种投影关系换 fisheye 立刻解决。第五类是内参初值给得太离谱。fisheye 标定时如果初值的 fx 差了三四倍迭代可能收敛到局部极小值出来的 D 里 k1 是个异常值。这个从数值上能看出来k1 绝对值大于 0.5 就要警惕。6.2 矫正后模糊、黑边、拉扯的处理边缘模糊是矫正的固有代价没法根除但能缓解。第一招是前面说的降输出分辨率第二招是给 remap 换成 cv2.INTER_CUBIC 插值边缘会平滑一些代价是耗时增加三成左右第三招是对矫正后的图做一次轻微的非锐化掩模把被拉伸导致的软边收一收注意强度别太大否则边缘会出现光晕。我一般用 3×3 的核、强度 0.3 左右。黑边问题取决于你要什么。如果黑边区域完全不参与后续处理最简单的是用 getOptimalNewCameraMatrix 的 roi 或者自己算一个内接矩形裁掉如果黑边区域偶尔有目标出现不能裁那就把下游算法里的 ROI 设置好让它在处理时忽略这些区域避免在纯黑区域上跑出一堆假检测。拉扯变形集中在画面四角本质是映射函数在极大角度处的雅可比矩阵接近奇异。这在数学上无解只能靠限制输出视场规避。我的做法是把 balance 和 fov_scale 组合起来宁可少保留 10 度视野也不要那圈被拉得面目全非的角落。6.3 问题速查表下面这张表是我打印出来贴在工位旁边的省得每次从头想。现象最可能的原因处理方式findChessboardCorners 频繁失败畸变过大、光照不均换 findChessboardCornersSB重投影误差 0.1 但矫正边缘仍弯采集姿态集中在中心补拍大倾角、边缘姿态fisheye.calibrate 抛异常数据病态、姿态共线try/except 剔图后重标矫正图整体偏移、主点不对鱼眼光心不在图像中心让初始主点给几何中心并放开优化矫正图四角严重拉丝视场超出模型有效范围减小 balance 或调大 fov_scale边缘出现彩色条纹CV_16SC2 坐标溢出换 CV_32FC1 映射表视频流帧率掉一半每帧重复算映射表映射表提前算一次复用标定参数换台机器结果变了分辨率或读取方式不一致锁死采集分辨率并记录到配置文件提示主点这个参数要特别注意。鱼眼镜头的实际光心经常偏离图像几何中心几十个像素这不是装配问题是设计使然。标定时如果把主点固定死用 FIX_PRINCIPAL_POINT出来的结果在中心区域会有一个系统性偏移表现为矫正后画面像被整体推了一点。除非你非常确信主点位置否则别固定它。7. 我在实际项目里固化的几条习惯第一条是标定数据必须版本化。我会把每次标定的图像文件夹、生成的 json、当时的重投影误差、标定板规格、甚至当天的环境温度一起记进一个说明文件。听起来啰嗦但真到了要回溯为什么三月份的效果比现在好的时候这些信息就是救命的。第二条是永远保留一条回退路径。上线前我会拿一张标准的、带直线参照物的测试图把两套方法的输出都存下来作为基线。后面任何一次参数调整都跟这张基线图对比肉眼能看出来的差异一律不接受。这条规矩帮我挡住过好几次改了一个参数顺手把别的搞坏了的事故。第三条是别迷信重投影误差这一个数。它只是训练集上的拟合优度我要看的是三个数重投影误差、实测直线偏差、以及换一个完全没参与标定的场景之后的表现。三个都过关才算可用。单看一个数被过拟合骗过去的概率很高。第四条关于选型遇到 130 到 150 度这个模糊地带的模组两套方法都跑一遍用实测直线偏差决定别拍脑袋。同一颗镜头在 140 度时针孔模型配 RATIONAL 有时候能打得过 fisheye有时候反过来跟模组的具体光学设计关系很大。多花半小时做对比比上线之后返工划算得多。
阅读完成 · 觉得有帮助?
咨询建站