在自动驾驶和机器人领域,鱼眼相机的 FOV 计算是标定工具、可视化系统和感知算法的基础。但网上大部分公式只适用于针孔相机,鱼眼相机该怎么算?本文给你完整答案。
前言
在开发智驾标注工具和机器人可视化系统时,我经常遇到一个问题:如何根据相机内参精确计算鱼眼相机的水平视场角(HFOV)?
普通针孔相机的 FOV 计算很简单:
但这个公式在鱼眼相机上完全失效 。原因在于鱼眼镜头采用了非线性投影模型,光线入射角与图像半径之间不是简单的正切关系 。
今天我们就来深入剖析两种主流鱼眼模型的 FOV 计算方法,并给出生产级代码实现。
一、为什么鱼眼相机 FOV 计算更复杂?
1.1 投影模型的差异
| 相机类型 | 投影模型 | FOV 范围 | 公式特点 |
|---|---|---|---|
| 针孔相机 | 透视投影 | < 95° | 线性可解 |
| 鱼眼相机 | Kannala-Brandt | 115°~180° | 多项式畸变 |
| 全向相机 | Mei 模型 | 180°~360° | 球面投影+畸变 |
鱼眼镜头的设计初衷就是为了突破成像视角的局限,其视场角可达到 180° 甚至 220° 。为了将尽可能大的场景投影到有限的图像平面内,鱼眼相机将畸变作为成像特征而非误差 。
1.2 核心难点
计算 FOV 的本质是:从图像边缘像素反推光线入射角。
像素坐标 (u,v) → 归一化坐标 (xd,yd) → 去畸变 → 入射角 θ → FOV
问题在于:畸变模型的反函数没有解析解,必须用数值方法求解 。
二、Kannala-Brandt 鱼眼模型 FOV 计算
2.1 模型原理
Kannala-Brandt 模型是 OpenCV fisheye 模块采用的标准模型 。其畸变公式为:
其中:
- :畸变图像上的归一化半径
- :光线入射角(我们要求的)
- :畸变系数
2.2 计算思路
已知图像边缘像素,求 FOV 的步骤:
- 计算左右边缘的归一化坐标
- 牛顿迭代法求解 (因为反函数无解析解)
- 左右角度相加得到 HFOV
2.3 完整代码实现
function computeHFOVFisheye(
fx: number,
cx: number,
width: number,
D: [number, number, number, number]
) {
if (fx <= 0 || width <= 1) return 0;
const uRight = width - 1;
const uLeft = 0;
// 计算归一化平面上的畸变坐标
const xdRight = Math.abs((uRight - cx) / fx);
const xdLeft = Math.abs((uLeft - cx) / fx);
const [k1, k2, k3, k4] = D;
// 牛顿迭代求 theta:已知 xd, 求 theta
function invertThetaFromXd(xd: number): number | null {
let t = xd; // 初始猜测
for (let i = 0; i < 30; i++) {
const t2 = t * t;
const t4 = t2 * t2;
const t6 = t4 * t2;
const t8 = t4 * t4;
const radial = 1 + k1 * t2 + k2 * t4 + k3 * t6 + k4 * t8;
const f = t * radial - xd;
// 导数计算
const df = 1 + 3 * k1 * t2 + 5 * k2 * t4 + 7 * k3 * t6 + 9 * k4 * t8;
const dfSafe = Math.abs(df) < 1e-12 ? 1e-12 : df;
const step = f / dfSafe;
const tNext = t - step;
if (!isFinite(tNext)) return null;
if (Math.abs(step) < 1e-9) return Math.abs(tNext);
t = tNext;
}
return null; // 未收敛
}
const thetaRight = invertThetaFromXd(xdRight);
const thetaLeft = invertThetaFromXd(xdLeft);
if (thetaRight === null || thetaLeft === null) {
console.warn("Fisheye FOV calculation did not converge");
return 0;
}
const hfovRad = thetaLeft + thetaRight;
return (hfovRad * 180) / Math.PI;
}
2.4 关键要点
| 要点 | 说明 |
|---|---|
| 初始值选择 | 直接用 xd 作为初始猜测,因为畸变通常不会太夸张 |
| 收敛判断 | 步长 < 1e-9 即可,对于角度计算精度足够 |
| 除零保护 | 导数接近 0 时要限制,防止步长爆炸 |
| 最大迭代 | 30 次是安全值,通常 5-10 次就收敛了 |
三、Mei 全向相机模型 FOV 计算
3.1 模型原理
Mei 模型(Unified Spherical Model)在鱼眼基础上增加了球面投影参数 。这是 OpenCV omnidir 模块和 Kalibr 标定工具采用的模型 。
投影过程:
- 3D 点投影到单位球面
- 球面点投影到归一化平面(受 影响)
- 平面点施加径向畸变
3.2 角度反算公式
这是最容易出错的地方。网上很多实现用的公式是:
// ❌ 不推荐(数值稳定性差)
theta = atan(xu) + asin(xi * xu / sqrt(1 + xu*xu))
推荐用 OpenCV 标准公式 :
// ✅ 推荐(兼容性好)
theta = atan2(xu, xi + sqrt(1 + (1 - xi*xi)*xu*xu))
3.3 完整代码实现
function computeHFOVOmnidir(
fx: number,
cx: number,
width: number,
xi: number,
D: [number, number, number, number]
) {
if (fx <= 0 || width <= 1) return 0;
const uRight = width - 1;
const uLeft = 0;
const xdRight = Math.abs((uRight - cx) / fx);
const xdLeft = Math.abs((uLeft - cx) / fx);
const [k1, k2, k3, k4] = D;
// 牛顿迭代去畸变
function invertXuFromXd(xd: number): number | null {
let x = xd;
for (let i = 0; i < 30; i++) {
const x2 = x * x;
const x4 = x2 * x2;
const x6 = x4 * x2;
const x8 = x4 * x4;
const radial = 1 + k1 * x2 + k2 * x4 + k3 * x6 + k4 * x8;
const f = x * radial - xd;
const df = 1 + 3 * k1 * x2 + 5 * k2 * x4 + 7 * k3 * x6 + 9 * k4 * x8;
const dfSafe = Math.abs(df) < 1e-12 ? 1e-12 : df;
const step = f / dfSafe;
const xNext = x - step;
if (!isFinite(xNext)) return null;
if (Math.abs(step) < 1e-9) return xNext;
x = xNext;
}
return null;
}
// 角度反算(OpenCV 标准公式)
function thetaFromXuXi(xu: number, xi: number): number {
const xi2 = xi * xi;
const xu2 = xu * xu;
const insideSqrt = 1 + (1 - xi2) * xu2;
if (insideSqrt < 0) return Math.atan(xu);
const theta = Math.atan2(xu, xi + Math.sqrt(insideSqrt));
return Math.abs(theta);
}
const xuR = invertXuFromXd(xdRight);
const xuL = invertXuFromXd(xdLeft);
if (xuR === null || xuL === null) {
console.warn("Omnidir FOV calculation did not converge");
return 0;
}
const thetaRight = thetaFromXuXi(xuR, xi);
const thetaLeft = thetaFromXuXi(xuL, xi);
const hfovRad = thetaLeft + thetaRight;
return (hfovRad * 180) / Math.PI;
}
四、牛顿迭代法详解
4.1 为什么必须用迭代法?
因为我们要解的方程是这样的:
这是一个高次多项式方程,没有通用的代数解法 。牛顿迭代法的核心思想是:
1. 猜一个初始值 x₀
2. 在 (x₀, f(x₀)) 处做切线
3. 切线与 X 轴交点作为 x₁
4. 重复直到收敛
迭代公式:
4.2 收敛性保障
| 风险 | 解决方案 |
|---|---|
| 初始值太远 | 用 xd 作为初始猜测(物理意义合理) |
| 导数为零 | 设置 dfSafe 下限保护 |
| 迭代发散 | 检查 isFinite(),超限返回 null |
| 不收敛 | 设置最大迭代次数 30 次 |
五、实战注意事项
5.1 标定工具一致性
务必确认你的参数来源 :
| 标定工具 | 模型 | 用哪个函数 |
|---|---|---|
OpenCV fisheye::calibrate | Kannala-Brandt | computeHFOVFisheye |
OpenCV omnidir::calibrate | Mei | computeHFOVOmnidir |
| Kalibr (camchain) | Mei | computeHFOVOmnidir |
| OCamCalib | 多项式直接拟合 | ❌ 都不适用 |
5.2 性能优化建议
如果这个函数在 WebGL/Three.js 的渲染循环中调用:
// ❌ 每帧计算(性能浪费)
function render() {
const hfov = computeHFOVFisheye(fx, cx, width, D);
// ...
}
// ✅ 缓存结果(推荐)
const cameraParams = {
hfov: computeHFOVFisheye(fx, cx, width, D),
// ...
};
function render() {
const hfov = cameraParams.hfov; // 直接使用
// ...
}
相机内参通常不会每帧变化,FOV 计算只需在初始化时计算一次。
5.3 验证方法
计算完成后,可以用以下方法验证:
- 可视化验证:在 Three.js 中用计算的 FOV 创建相机,看是否与图像匹配
- 边界测试:无畸变时(D=[0,0,0,0]),结果应接近针孔相机公式
- 对称性测试:cx 在图像中心时,左右角度应大致相等
六、总结
| 模型 | 适用场景 | 关键参数 | 精度 |
|---|---|---|---|
| Kannala-Brandt | 普通鱼眼相机 | D (4 个系数) | ±0.1° |
| Mei | 全向/全景相机 | D + ξ | ±0.1° |
核心要点回顾:
- 鱼眼相机 FOV 不能用针孔相机公式
- 必须用牛顿迭代法求解畸变逆模型
- Mei 模型的角度公式要用 OpenCV 标准版本
- 标定工具与模型必须匹配
- 生产环境要加收敛性检查和缓存优化
参考资料
- Kannala J, Brandt S S. A generic camera model and calibration method
- OpenCV Omnidirectional Camera Calibration Documentation
- 鱼眼成像标定与算法分析 - 知乎专栏
- Camera Field of View Calculator - Omni Calculator
- 相机模型 - 鱼眼模型/鱼眼镜头标定基本原理
如果你觉得这篇文章有帮助,欢迎点赞收藏👍 有问题可以在评论区交流!
作者:红波 | 专注智驾、机器人标注工具与可视化开发 | 技术栈:TS/Vue/WebGL/ThreeJS/Go/Rust