畸变模型:径向畸变与切向畸变的数学表达,OpenCV去畸变函数详解
说到相机标定,畸变模型是绕不开的核心内容。我刚开始做视觉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 就是径向畸变系数。为什么用偶次项?因为畸变关于光心对称,奇次项会破坏对称性。
切向畸变:装配误差带来的“倾斜”
切向畸变就没那么直观了。它主要来自镜头和成像平面不平行——说白了就是装配时镜头装歪了。我在产线上见过不少这样的案例,尤其是低成本模组,切向畸变往往比径向畸变更明显。
数学表达式也不复杂:
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 影响,反之亦然。
完整畸变模型:把两个合起来
实际应用中,我们通常把径向和切向畸变合并成一个模型。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 |
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 可以进一步压缩内存,但精度会损失一点。
CV_32FC1 发现内存占用偏高。后来改成 CV_16SC2,内存降了 40%,但边缘出现了一些锯齿。最后折中方案:对中心区域用高精度,边缘用低精度——当然这是针对特定场景的优化,通用场景不建议这么搞。
畸变校正的完整流程
我习惯把去畸变分成三步走:
- 标定获取参数:用棋盘格标定板,采集 15-20 张不同角度的图片,调用
cv::calibrateCamera得到内参和畸变系数。 - 生成映射表:用
cv::initUndistortRectifyMap生成 map1 和 map2。 - 实时查表:每帧调用
cv::remap完成校正。
这里我画了一张流程图,帮你理清整个逻辑:
畸变系数与内参的耦合关系
这里有个容易忽略的点:畸变系数和内参矩阵是耦合的。你单独标定畸变系数而不更新内参,结果往往不对。我见过有人从网上下载了一个相机标定文件,只换了畸变系数,内参还是用默认值——校正出来的图像边缘全是扭曲的。
正确的做法是:每次标定都同时输出内参和畸变系数,并且用同一组数据做重投影误差验证。如果重投影误差超过 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 值最好保持一致,否则融合后的图像会有明显的拼接缝。