鱼眼摄像头畸变矫正这件事,说难不难,说简单也处处是坑。我用 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 度都能吃 |
| 标定 API | cv2.calibrateCamera | cv2.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_ok=True) 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, f"saved: {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, flags=cv2.CALIB_CB_EXHAUSTIVE | cv2.CALIB_CB_ACCURACY) if ok: return True, corners ok, corners = cv2.findChessboardCorners( gray, BOARD, flags=cv2.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(f"usable {ok_count}/{len(files)}")提示:如果可用图少于 15 张,先别急着标定,回去补拍。图不够的时候求解器会用少量样本硬凑参数,出来的结果在训练集上看着还行,换个场景立刻崩。我一般要求可用率不低于 70%。
3. 方法一:针孔模型 + 畸变系数的高效矫正
3.1 calibrateCamera 求解全过程拆解
针孔这条路的核心就一个函数:cv2.calibrateCamera。但它的输入组织方式值得单独说清楚,因为这里搞错了后面全是玄学。
你需要准备两组点:objectPoints 是世界坐标系下的三维点,因为标定板是平面,通常把 Z 设成 0,X、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, flags=cv2.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, flags=cv2.CALIB_RATIONAL_MODEL, # 启用 k4/k5/k6,宽视场更稳 criteria=criteria) 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, interpolation=cv2.INTER_LINEAR)关于 cv2.CV_16SC2 这个类型,值得多讲一句。它会让 map1 存成双通道 16 位定点数,map2 存插值权重,整套表的内存占用只有 CV_32FC1 全浮点版本的一半左右,remap 时的读取效率也更高。1080p 的图,全浮点映射表要 8 兆多,定点版四兆多,在嵌入式设备上这点内存差别很实在。
在 x86 上跑 1080p,cv2.remap 单帧大概 3 到 5 毫秒;在常见的 ARM 板上会到 15 到 25 毫秒,这时候就得考虑降分辨率或者多线程了。我一般先看整个流水线里 remap 占多少,如果不到 30%,就先不动它,优先优化上游的推理或检测环节。cv2.setNumThreads 也会影响 remap 的速度,默认它会用满多核,但如果你自己还开了线程池,两边抢核反而更慢,这时候把它固定成 1 到 2 更合适。
3.3 两个工程化细节:alpha 裁切与 ROI 复用
cv2.getOptimalNewCameraMatrix 这个函数很多人直接 alpha=0 就过去了,其实参数很有用。alpha 取 0 的时候,算法会裁掉所有黑边,只保留原始图像有有效像素覆盖的区域,代价是丢掉一圈视野;alpha 取 1 会保留全部原始画面,但四周出现大量黑边。我的习惯是取 0.3 到 0.5 之间,既保住中间一块干净画面,又不至于把边缘有信息的区域一裁了之。
顺带说一个实际项目里的省事做法:把 getOptimalNewCameraMatrix 返回的 roi 存进配置文件,运行时直接用这个 ROI 做裁剪,而不是每帧重新算。因为内参一旦标定完就不再变,newK 和 roi 都是常量。这个优化在原来那个项目上直接把预处理耗时削掉了将近一半,因为省掉了每帧一次 getOptimalNewCameraMatrix 调用和一次全图处理。
newK, roi = cv2.getOptimalNewCameraMatrix(K, dist, (w, h), alpha=0.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:y+rh, x:x+rw]4. 方法二:鱼眼四参数模型的宽视场矫正
4.1 Kannala-Brandt 模型与 OpenCV 的四参数约定
这套模型的投影过程是这么走的:先把三维点归一化得到 a = X/Z,b = Y/Z,算出 r = sqrt(a² + b²),再取入射角 θ = atan(r)。然后按 θ_d = θ(1 + k1θ² + k2θ⁴ + k3θ⁶ + k4θ⁸) 得到畸变后的角度,最后 x' = (θ_d / r)·a,y' = (θ_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, flags=cv2.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, flags=flags, criteria=criteria, flags_check=cv2.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, flags=flags, criteria=criteria) 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), balance=0.2, new_size=dim, fov_scale=1.0) map1, map2 = cv2.fisheye.initUndistortRectifyMap( K, D, np.eye(3), Knew, dim, cv2.CV_16SC2) undist = cv2.remap(frame, map1, map2, interpolation=cv2.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"], dtype=np.float64) D = np.array(cfg["D"], dtype=np.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), balance=0.2, new_size=dim, fov_scale=1.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, interpolation=cv2.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,有时候反过来,跟模组的具体光学设计关系很大。多花半小时做对比,比上线之后返工划算得多。