4、畸变校正算法实现:cv2.undistort() 与 cv2.initUndistortRectifyMap()

好,咱们进入正题。前面聊了畸变模型和标定,现在该动手实现了。OpenCV 给了我们两条路:一条是懒人专用,一条是性能优先。我个人习惯,在原型阶段用前者,到了嵌入式部署,必须用后者。

4.1 两条路:cv2.undistort() vs cv2.initUndistortRectifyMap()

先看最简单的用法。你标定完了,拿到了相机矩阵和畸变系数,直接一行代码就能出结果:

import cv2
import numpy as np

# 假设你已经有了这些参数
# mtx: 相机内参矩阵, dist: 畸变系数
undistorted_img = cv2.undistort(img, mtx, dist, None, mtx)

嗯,就这么简单。但这里有个坑——cv2.undistort() 每调用一次,内部就会重新计算一次映射表。你想想看,如果你在视频流里每帧都这么干,CPU 直接拉满。我在一个嵌入式项目里就吃过这个亏,当时用 Jetson Nano 跑 30fps 的视频流,直接掉到 8fps。

所以,工程化的做法是:

  1. 先算好映射表(只算一次)
  2. 然后用 remap() 去查表(每帧执行)

这就是 cv2.initUndistortRectifyMap() 的用武之地:

# 计算映射表
map1, map2 = cv2.initUndistortRectifyMap(
    mtx, dist, None, mtx,
    (img.shape[1], img.shape[0]),
    cv2.CV_32FC1  # 映射表精度
)

# 每帧只需要执行 remap
undistorted_img = cv2.remap(img, map1, map2, cv2.INTER_LINEAR)

核心区别一句话:cv2.undistort() 是「计算+查表」打包在一起,适合单张图片调试;cv2.initUndistortRectifyMap() + cv2.remap() 是「预计算+运行时查表」分离,适合视频流和嵌入式部署。

4.2 映射表生成原理

说白了,映射表就是一张「像素搬家地图」。它告诉算法:输出图像上 (u, v) 位置的像素,应该从输入图像的哪个位置取。

生成过程分三步:

  1. 归一化:把输出图像的像素坐标 (u, v) 转换到归一化平面 (x, y)
  2. 加畸变:根据畸变模型,计算畸变后的归一化坐标 (x_dist, y_dist)
  3. 反投影:把畸变后的坐标映射回输入图像的像素坐标 (u_src, v_src)

我画了一张图,帮你理解这个流程:

映射表生成流程 输出像素 (u, v) 归一化 (x, y) 加畸变 (x_d, y_d) 反投影 💡 关键点:映射表存储的是「源图像坐标」,通常是浮点数。 map1 存 x 坐标,map2 存 y 坐标,类型为 CV_32FC1 或 CV_16SC2。 map1: 源图像 x 坐标 map2: 源图像 y 坐标

你可能会问:为什么映射表里存的是浮点数?因为畸变后的坐标几乎不可能刚好落在整数像素上。这就引出了下一个问题——怎么取像素值?

4.3 双线性插值与最近邻插值

映射表给了你一个浮点坐标,比如 (23.4, 56.7)。但图像里只有整数坐标的像素。怎么办?插值。

OpenCV 的 cv2.remap() 支持多种插值方式,最常用的就两种:

插值方法 速度 质量 适用场景
cv2.INTER_NEAREST(最近邻) 极快 低(有锯齿) 实时预览、低功耗设备
cv2.INTER_LINEAR(双线性) 较快 高(平滑) 大多数场景,默认选项

4.3.1 最近邻插值

说白了就是「四舍五入」。坐标 (23.4, 56.7) → 取 (23, 57) 的像素值。简单粗暴,速度快,但图像边缘会有明显的锯齿。

我的经验:在嵌入式设备上做实时畸变校正时,如果帧率不够,可以先切到最近邻插值看看效果。我曾经在一个 ARM Cortex-A72 的项目里,双线性插值只能跑 15fps,换成最近邻直接飙到 28fps。虽然画质差了点,但客户验收时更看重流畅度。

4.3.2 双线性插值

这个就聪明多了。它取目标点周围 4 个像素,按距离加权平均。公式不复杂:

假设目标点 (x, y),周围四个像素:
Q11 = (floor(x), floor(y))
Q12 = (floor(x), ceil(y))
Q21 = (ceil(x), floor(y))
Q22 = (ceil(x), ceil(y))

先沿 x 方向插值两次:
f(x, y1) = (x2 - x)/(x2 - x1) * Q11 + (x - x1)/(x2 - x1) * Q21
f(x, y2) = (x2 - x)/(x2 - x1) * Q12 + (x - x1)/(x2 - x1) * Q22

再沿 y 方向插值一次:
f(x, y) = (y2 - y)/(y2 - y1) * f(x, y1) + (y - y1)/(y2 - y1) * f(x, y2)

嗯,看着公式有点长,但 OpenCV 底层用 SIMD 优化过了,实际跑起来很快。

注意:双线性插值会引入轻微的模糊,相当于一个低通滤波器。如果你的图像本身就很锐利,或者要做后续的特征点提取,可以考虑用 cv2.INTER_CUBIC(三次插值),但速度会慢不少。嵌入式设备上慎用。

4.4 映射表的数据类型选择

这里有个容易被忽略的细节——映射表的数据类型。OpenCV 支持两种:

类型 精度 内存占用 速度
CV_32FC1(两通道浮点) 高 8 字节/像素 较慢
CV_16SC2(两通道短整型) 中 4 字节/像素 较快

我建议:

  • PC 端原型验证:用 CV_32FC1,精度高,省心
  • 嵌入式部署:用 CV_16SC2,内存减半,查表速度更快

曾经我在一个内存只有 256MB 的摄像头模组上做校正,映射表占了将近 30MB 内存。换成 CV_16SC2 后,直接降到 15MB,系统终于不 OOM 了。

4.5 完整代码示例

最后,给你一个完整的工程化代码模板:

import cv2
import numpy as np

class DistortionCorrector:
    def __init__(self, mtx, dist, img_size, map_type=cv2.CV_16SC2):
        self.mtx = mtx
        self.dist = dist
        self.img_size = img_size
        
        # 预计算映射表
        self.map1, self.map2 = cv2.initUndistortRectifyMap(
            mtx, dist, None, mtx,
            img_size, map_type
        )
    
    def correct(self, img, interpolation=cv2.INTER_LINEAR):
        return cv2.remap(img, self.map1, self.map2, interpolation)

# 使用示例
cap = cv2.VideoCapture(0)
corrector = DistortionCorrector(mtx, dist, (640, 480))

while True:
    ret, frame = cap.read()
    if not ret:
        break
    
    # 畸变校正
    corrected = corrector.correct(frame)
    
    cv2.imshow('Corrected', corrected)
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()

工程化要点总结:

  • 映射表只算一次,放在 __init__ 里
  • remap() 放在循环里,每帧只查表不计算
  • 嵌入式环境用 CV_16SC2 节省内存
  • 帧率不够时先降级到最近邻插值

嗯,这一章的内容就到这。映射表生成和插值选择,说白了就是「空间换时间」和「精度换速度」的权衡。你在实际项目中多试几次,自然就找到感觉了。