3. 畸变模型

说到相机标定,畸变模型是绕不开的核心内容。我刚开始做视觉SLAM时,总觉得畸变校正是个“锦上添花”的步骤——直到有一次在工厂现场调试双目测距系统,发现远处物体的测量误差竟然达到了5%。排查了整整两天,最后发现是畸变参数没给对。嗯,从那以后我再也不敢小看这个环节了。

说白了,畸变就是镜头把直线拍弯了。你想想看,理想的针孔模型假设光线完美穿过光心,但真实镜头有厚度、有曲率,光线经过时必然会发生偏折。这种偏折在数学上可以分解为两种:径向畸变和切向畸变。

径向畸变:镜头曲率带来的“桶”与“枕”

径向畸变是最常见的一种。光线离光心越远,偏折越明显。我习惯把它想象成镜头在“拉扯”图像边缘的像素。

  • 桶形畸变:图像边缘向外凸出,像木桶的弧面。广角镜头容易出现。
  • 枕形畸变:图像边缘向内凹陷,像枕头。长焦镜头常见。

数学上怎么描述?用泰勒展开。归一化平面上的点 (x, y),畸变后的坐标 (x_d, y_d) 可以写成:

x_d = x * (1 + k1*r² + k2*r⁴ + k3*r⁶)
y_d = y * (1 + k1*r² + k2*r⁴ + k3*r⁶)

其中 r² = x² + y²,k1、k2、k3 就是径向畸变系数。为什么用偶次项?因为畸变关于光心对称,奇次项会破坏对称性。

我的经验:大多数情况下只用 k1 和 k2 就够了。k3 主要针对鱼眼镜头这类大畸变场景。我曾经在一个项目中偷懒只标了 k1,结果边缘误差始终降不下来——加上 k2 后,重投影误差直接砍了一半。

切向畸变:装配误差带来的“倾斜”

切向畸变就没那么直观了。它主要来自镜头和成像平面不平行——说白了就是装配时镜头装歪了。我在产线上见过不少这样的案例,尤其是低成本模组,切向畸变往往比径向畸变更明显。

数学表达式也不复杂:

x_d = x + [2*p1*x*y + p2*(r² + 2*x²)]
y_d = y + [p1*(r² + 2*y²) + 2*p2*x*y]

p1、p2 就是切向畸变系数。注意这里不是简单的缩放,而是坐标的耦合偏移——x 方向的畸变受 y 影响,反之亦然。

避坑指南:我曾经在标定一个双目系统时,发现左右图像的畸变参数差异很大。排查了很久才发现,是其中一个摄像头的 CMOS 芯片在焊接时偏了 0.5 度。切向畸变参数 p1、p2 直接飙到了 0.01 以上。如果你的 p1、p2 超过 0.01,建议先检查硬件装配。

完整畸变模型:把两个合起来

实际应用中,我们通常把径向和切向畸变合并成一个模型。OpenCV 用的就是这种形式:

x_d = x * (1 + k1*r² + k2*r⁴ + k3*r⁶) + [2*p1*x*y + p2*(r² + 2*x²)]
y_d = y * (1 + k1*r² + k2*r⁴ + k3*r⁶) + [p1*(r² + 2*y²) + 2*p2*x*y]

畸变参数向量就是 [k1, k2, p1, p2, k3]。注意顺序——OpenCV 里 k3 放在最后,不是按 k1、k2、k3、p1、p2 排的。这个顺序我记错过一次,结果标定出来的图像全是扭曲的。

核心要点:畸变模型是在归一化平面上定义的,不是在像素坐标系。先做畸变校正,再做内参映射到像素坐标。顺序搞反了,校正结果就是错的。

OpenCV 去畸变函数详解

OpenCV 提供了两个核心函数:cv::undistort 和 cv::initUndistortRectifyMap。我个人更推荐后者,尤其是在实时系统中。

1. cv::undistort —— 一步到位

这个函数最简单,输入畸变图像,直接输出校正后的图像。内部帮你完成了所有计算。

cv::Mat distorted_img = cv::imread("distorted.jpg");
cv::Mat undistorted_img;
cv::undistort(distorted_img, undistorted_img, camera_matrix, dist_coeffs);

参数说明:

参数 含义 维度
camera_matrix 相机内参矩阵 3×3
dist_coeffs 畸变系数向量 1×5 或 1×8
什么时候用这个?调试阶段、单张图片处理。但如果你要做视频流或实时 SLAM,千万别用——它每帧都会重新计算映射表,性能很差。

2. cv::initUndistortRectifyMap + cv::remap —— 性能优先

这是工业级应用的标准做法。先计算一次映射表,然后每帧只用 remap 查表。我在嵌入式设备上做过测试,性能差距能有 5 倍以上。

cv::Mat map1, map2;
cv::initUndistortRectifyMap(
    camera_matrix, dist_coeffs, 
    cv::Mat(), camera_matrix, 
    image_size, CV_32FC1, map1, map2
);

// 每帧调用 remap
cv::remap(distorted_frame, undistorted_frame, map1, map2, cv::INTER_LINEAR);

这里有个细节:map1 和 map2 的类型是 CV_32FC1,分别存储 x 和 y 方向的映射坐标。如果你用 CV_16SC2 可以进一步压缩内存,但精度会损失一点。

我曾经踩过的坑:在 Jetson Nano 上做实时去畸变,一开始用 CV_32FC1 发现内存占用偏高。后来改成 CV_16SC2,内存降了 40%,但边缘出现了一些锯齿。最后折中方案:对中心区域用高精度,边缘用低精度——当然这是针对特定场景的优化,通用场景不建议这么搞。

畸变校正的完整流程

我习惯把去畸变分成三步走:

  1. 标定获取参数:用棋盘格标定板,采集 15-20 张不同角度的图片,调用 cv::calibrateCamera 得到内参和畸变系数。
  2. 生成映射表:用 cv::initUndistortRectifyMap 生成 map1 和 map2。
  3. 实时查表:每帧调用 cv::remap 完成校正。

这里我画了一张流程图,帮你理清整个逻辑:

畸变校正流程 输入畸变图像 标定参数(内参+畸变系数) initUndistortRectifyMap remap 查表校正 输出校正图像 关键点 映射表只需生成一次 remap 是性能瓶颈 插值方式影响精度 常见插值方式 INTER_LINEAR:默认 INTER_CUBIC:更平滑 INTER_NEAREST:最快

畸变系数与内参的耦合关系

这里有个容易忽略的点:畸变系数和内参矩阵是耦合的。你单独标定畸变系数而不更新内参,结果往往不对。我见过有人从网上下载了一个相机标定文件,只换了畸变系数,内参还是用默认值——校正出来的图像边缘全是扭曲的。

正确的做法是:每次标定都同时输出内参和畸变系数,并且用同一组数据做重投影误差验证。如果重投影误差超过 0.5 像素,建议重新标定。

经验阈值:
  • 普通网络摄像头:重投影误差 < 0.3 像素
  • 工业相机:重投影误差 < 0.1 像素
  • 鱼眼镜头:重投影误差 < 0.5 像素(因为边缘畸变太大)

畸变校正的边界问题

校正后的图像边缘会出现黑色区域——因为原始图像的边缘像素被“拉”到了更远的位置。我习惯用 cv::getOptimalNewCameraMatrix 来调整内参,在保留更多有效像素和减少畸变之间做权衡。

cv::Mat new_camera_matrix;
cv::Rect valid_roi;
new_camera_matrix = cv::getOptimalNewCameraMatrix(
    camera_matrix, dist_coeffs, 
    image_size, alpha, image_size, &valid_roi
);

alpha 参数控制保留多少畸变像素:

alpha 值 效果 适用场景
0 裁剪所有黑色区域 需要最大视场角
0.5 保留部分边缘 通用场景
1 保留全部原始像素 需要完整图像信息

我个人习惯用 alpha=0.5,既保留了大部分有效像素,又不会让黑色区域太显眼。当然,如果你做的是异构摄像头融合,不同摄像头的 alpha 值最好保持一致,否则融合后的图像会有明显的拼接缝。