3. 畸变模型与校正:从理论到OpenCV实战

聊到镜头畸变,我得先坦白一件事。刚入行那会儿,我以为标定就是算个内参矩阵完事。结果第一次做广角模组的图像拼接,接缝处怎么都对不齐。折腾了两天,才发现是畸变校正没做干净。嗯,从那以后,我再也不敢小看这几个畸变系数了。

说白了,畸变就是镜头这个物理器件不完美。光线穿过透镜时,走的路径跟理想的小孔成像模型有偏差。这种偏差主要分两类:径向畸变和切向畸变。今天咱们就把它们彻底讲透。

核心结论:畸变校正是标定流程中承上启下的一步。内参告诉你“像素对应什么方向”,畸变系数告诉你“这个方向偏了多少”。两者缺一不可。

3.1 径向畸变:桶形与枕形

径向畸变,顾名思义,是沿着镜头半径方向发生的变形。光线离光心越远,偏折越厉害。你想想看,这其实是个物理规律——透镜边缘的折射率跟中心不一样。

具体表现有两种:

  • 桶形畸变:画面中心向外膨胀,直线向外弯曲。常见于广角镜头、鱼眼镜头。我做过一个行车记录仪项目,原图拍出来像哈哈镜,就是典型的桶形畸变。
  • 枕形畸变:画面中心向内收缩,直线向内弯曲。常见于长焦镜头、变焦镜头的长焦端。做安防监控的同行应该深有体会。

数学上怎么描述?用泰勒展开。径向畸变模型通常取前两项或前三项:

x_distorted = x * (1 + k1 * r² + k2 * r⁴ + k3 * r⁶)
y_distorted = y * (1 + k1 * r² + k2 * r⁴ + k3 * r⁶)

其中 r 是像素点到光心的归一化距离。k1、k2、k3 就是径向畸变系数。k1 主导畸变趋势——k1 为正时是枕形,k1 为负时是桶形。k2、k3 用来补偿高阶误差,尤其是鱼眼镜头,k3 几乎必用。

我的经验:普通手机镜头用 k1、k2 就够了。鱼眼或超广角必须加 k3。我曾经在 120° 视场角的模组上只用了 k1、k2,校正后边缘还是有 3-5 个像素的残余误差,加上 k3 才压到 1 像素以内。

3.2 切向畸变:镜头装配的“原罪”

切向畸变,说白了就是镜头没装正。CMOS 传感器平面跟透镜光轴不垂直,或者透镜组内部镜片有偏心。这种畸变会让图像产生“梯形”或“平行四边形”的拉伸感。

数学模型长这样:

x_distorted = x + [2 * p1 * x * y + p2 * (r² + 2 * x²)]
y_distorted = y + [p1 * (r² + 2 * y²) + 2 * p2 * x * y]

p1、p2 就是切向畸变系数。注意,切向畸变跟径向畸变是耦合的,标定时要一起优化。

我遇到过最夸张的一次——某款车载模组,产线上装偏了 0.5 度。标定出来的 p1 值比正常大了 10 倍。校正后画面才恢复正常。所以切向畸变系数其实也是个“质检指标”,数值异常大,基本可以判定装配有问题。

避坑指南:切向畸变校正会轻微改变图像的宽高比。如果你做的是测量类应用(比如双目测距),校正后一定要重新计算内参,不能直接用原始内参。

3.3 畸变系数总览

OpenCV 标定后返回的畸变系数向量 dist_coeffs 通常有 5 个元素:

参数 含义 典型范围 备注
k1 径向畸变一阶系数 -0.5 ~ 0.5 主导畸变方向
k2 径向畸变二阶系数 -0.3 ~ 0.3 补偿高阶误差
p1 切向畸变一阶系数 -0.05 ~ 0.05 反映装配偏心
p2 切向畸变二阶系数 -0.05 ~ 0.05 同上
k3 径向畸变三阶系数 -1.0 ~ 1.0 鱼眼镜头必用

注意,有些标定算法会输出 8 个参数(k1~k6 + p1~p2),但 5 参数模型已经能覆盖 99% 的消费级模组。

3.4 畸变校正的核心逻辑

校正过程其实是个“反向映射”。你想想看:我们已知畸变图上的每个像素是怎么偏过来的,现在要把它拉回正确位置。但直接正向计算会有空洞,所以 OpenCV 用的是反向映射 + 插值。

流程如下:

  1. 对校正图上的每个像素 (u, v),用内参反算到归一化坐标 (x, y)。
  2. 用畸变模型计算畸变后的坐标 (x', y')。
  3. 再用内参投影回畸变图上的像素位置 (u', v')。
  4. 在 (u', v') 周围做双线性插值,得到 (u, v) 的像素值。

这个流程看起来简单,但计算量不小。一张 1080p 的图像,逐像素算一遍,在 ARM 平台上可能要几十毫秒。所以工业上常用查表法——提前算好映射表,运行时直接查。

下面这张图展示了整个畸变校正的流程:

畸变校正核心流程 原始畸变图 含桶形/枕形畸变 标定参数 内参矩阵 + k1~k3, p1~p2 反向映射 校正图→畸变图坐标 插值 双线性 校正后图像 💡 关键点:反向映射 + 插值是校正的核心。直接正向映射会产生空洞,必须用反向映射保证每个校正像素都有值。 查表法可大幅提升速度,适合嵌入式实时场景。

3.5 OpenCV 去畸变实战

理论讲完了,上代码。OpenCV 提供了两个核心函数:cv::undistort 和 cv::initUndistortRectifyMap + cv::remap。前者一步到位,适合调试;后者分两步,适合生产环境。

3.5.1 一步到位:undistort

#include <opencv2/opencv.hpp>

cv::Mat img = cv::imread("distorted.jpg");
cv::Mat cameraMatrix = (cv::Mat_<double>(3,3) <<
    800, 0, 640,
    0, 800, 480,
    0, 0, 1);
cv::Mat distCoeffs = (cv::Mat_<double>(1,5) <<
    -0.25, 0.05, 0.001, 0.002, 0.0);

cv::Mat undistorted;
cv::undistort(img, undistorted, cameraMatrix, distCoeffs);

cv::imwrite("undistorted.jpg", undistorted);

这段代码看起来简单,但有个坑——undistort 内部会重新计算一个内参矩阵,默认是跟原内参一样。如果你希望保留更多边缘视野,可以传一个新的内参进去。

我的习惯:调试阶段用 undistort,快速看效果。但上线前一定会换成 initUndistortRectifyMap + remap,因为后者可以提前算好映射表,运行时只做查表和插值,性能好很多。

3.5.2 生产级方案:initUndistortRectifyMap + remap

cv::Mat map1, map2;
cv::Size imageSize = img.size();

// 计算映射表
cv::initUndistortRectifyMap(
    cameraMatrix, distCoeffs, cv::Mat(),
    cameraMatrix, imageSize, CV_32FC1, map1, map2);

// 应用映射表
cv::Mat undistorted;
cv::remap(img, undistorted, map1, map2, cv::INTER_LINEAR);

// 后续帧只需调用 remap,map1/map2 不变
for (int i = 0; i < 100; i++) {
    cv::Mat frame = captureFrame();
    cv::Mat corrected;
    cv::remap(frame, corrected, map1, map2, cv::INTER_LINEAR);
    processFrame(corrected);
}

注意 CV_32FC1 这个类型。map1 存 x 方向的映射坐标,map2 存 y 方向。用浮点精度,插值才准确。有些同学为了省内存用 CV_16SC2,精度会损失,边缘容易出现锯齿。

3.5.3 参数调优建议

校正效果好不好,关键看几个参数:

参数 影响 调优方向
alpha 控制校正后图像的裁剪范围 alpha=0 全裁剪(无黑边),alpha=1 保留全部像素(有黑边)
插值方法 影响图像清晰度 INTER_LINEAR 通用,INTER_CUBIC 更锐但慢,INTER_NEAREST 最快但有锯齿
newCameraMatrix 校正后的虚拟内参 可用 getOptimalNewCameraMatrix 自动计算

我曾经踩过的坑:alpha 设成 0 时,虽然画面没有黑边,但边缘区域会被严重拉伸,导致分辨率下降。做测量应用时,建议 alpha 设 0.5 以上,保留更多原始信息。做视觉美化(比如美颜相机),alpha 设 0 反而更讨喜。

3.6 验证校正效果

校正做没做好,不能光靠眼睛看。我一般用三个指标量化:

  1. 直线度检测:用棋盘格或直线测试卡,校正后检测直线上的点偏离拟合直线的 RMS 误差。好的校正应该 < 0.5 像素。
  2. 重投影误差:用标定时的角点,计算校正前后位置偏差。这个值在标定阶段就应该 < 0.1 像素。
  3. 边缘分辨率:校正后边缘区域的 MTF 不应下降超过 10%。如果下降太多,说明畸变模型阶数不够,或者插值方法太粗糙。

嗯,到这里,畸变模型和校正的核心内容就讲完了。记住一句话:畸变校正是标定流程的“临门一脚”,参数对了,后面所有图像处理才能站得稳。