今天在 StackOverflow 上看到这么个问题:想把原图像中的一个点 ​\textstyle g,通过内参和畸变参数映射到去畸变图像中的点 ​\textstyle p,并获取这个点的坐标。

你以为我接下来会说,xxx 和我想的一样,但是我想错了,其实并不是这样。

现在,我想说的只有其实并不是这样,因为我连 map1 和 map2 是啥我都不知道,我只会无脑用函数(抱歉)。

remap

像素重映射(Pixel Remapping)是指将图像从一个坐标系映射到另一个坐标系的过程。在 OpenCV 中,cv::remap() 函数可以用来实现像素重映射。

void cv::remap(
    InputArray src,          // 输入图像
    OutputArray dst,         // 输出图像
    InputArray map1,         // 1. (x, y) 映射 2. x 映射
    InputArray map2,         // 1. 空映射    2. y 映射
    int interpolation,       // 插值方法
    int borderMode = BORDER_CONSTANT,  // (可选)边界模式,用于处理超出图像边界的像素
    const Scalar& borderValue = Scalar()  // (可选)边界值,用于设置超出图像边界的像素值
);

函数通过对 x 和 y 的映射来实现对 src 图像的转换:

\textstyle dst(x,y) = src[map_x(x,y), map_y(x,y)]

其中,dst 表示目标图像,其与 src 有相同的大小和类型,这里的 x 和 y 都是输出图像 dst 中的像素坐标

在我之前的认知中,我以为 map1 和 map2 中存储的是 src(x,y) 去畸变后在 dst 图像中的 x 和 y 坐标,但实际并不是这样。

根据 OpenCV 官方文档 Geometric Image Transformations 中的说明:

In fact, to avoid sampling artifacts, the mapping is done in the reverse order, from destination to the source.

也就是说,映射是从 dst 到 src 反向进行的。因此,map1 和 map2 的作用是查找 dst 中每个像素应该从 src 中哪个位置采样,即过程是 ​\textstyle dst \rightarrow src,也被称为反向映射。


寻找 src 在 dst 中的对应点

如果已经得到了 map1 和 map2,想在此基础上找到 src 在 dst 中的对应点,有两种方法。

  1. 使用 OpenCV 的 undistortPoints() 函数

如果问题本身是相机去畸变,例如想把原图中的畸变点转换到去畸变图像坐标系中,推荐优先使用 OpenCV 提供的 undistortPoints()。不过需要注意,这个函数适用于相机畸变校正这类特定几何模型;如果是任意 remap() 变换,这个函数不一定适用。

cv::Mat distortedPoints = (cv::Mat_<cv::Point2f>(4, 1)
    << cv::Point2f(877, 403),
       cv::Point2f(877, 403),
       cv::Point2f(877, 403),
       cv::Point2f(877, 403));
cv::Mat undistortedPoints;

auto start = std::chrono::high_resolution_clock::now();
cv::fisheye::undistortPoints(distortedPoints, undistortedPoints, K, D);

double fx = K.at<double>(0, 0); // 焦距 x
double fy = K.at<double>(1, 1); // 焦距 y
double cx = K.at<double>(0, 2); // 主点 x
double cy = K.at<double>(1, 2); // 主点 y

cv::Mat undistortedPointsPix;
undistortedPoints.convertTo(undistortedPointsPix, CV_32F); // 确保坐标是浮点数

for (int i = 0; i < undistortedPointsPix.rows; ++i) {
    undistortedPointsPix.at<cv::Point2f>(i).x =
        fx * undistortedPointsPix.at<cv::Point2f>(i).x + cx;
    undistortedPointsPix.at<cv::Point2f>(i).y =
        fy * undistortedPointsPix.at<cv::Point2f>(i).y + cy;
}

auto end = std::chrono::high_resolution_clock::now();

// 输出校正后的点
std::cout << "Distorted Points:" << std::endl << distortedPoints << std::endl;
std::cout << "Undistorted Points:" << std::endl << undistortedPointsPix << std::endl;

auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
std::cout << "Execution time: " << duration.count() << " microseconds" << std::endl;

这里使用的是 cv::fisheye::undistortPoints()。默认情况下,它返回的是归一化坐标,所以文中又手动乘以 fx/fy 并加上 cx/cy,将结果转换回像素坐标。

另一种写法是给 undistortPoints() 传入投影矩阵 ​\text {P = K},让函数直接输出像素坐标。

  1. 查表

另一种方式是直接在 map1 和 map2 中查找最接近原始点的位置。因为 map1 和 map2 描述的是 ​dst -> src,所以查表过程本质上是在找哪个 dst 像素采样到了给定的 src 坐标附近。

std::pair<int, int> mapLookUp(
    const cv::Mat& map1,
    const cv::Mat& map2,
    int u,
    int v
) {
    float min_cost = 1e5;
    int min_u = 0;
    int min_v = 0;

    int height = map1.rows;
    int width = map1.cols;

    for (int i = 0; i < height; i++) {
        for (int j = 0; j < width; j++) {
            float u1 = map1.at<float>(i, j);
            float v1 = map2.at<float>(i, j);

            float dist = std::abs(u1 - static_cast<float>(u)) +
                         std::abs(v1 - static_cast<float>(v));

            if (min_cost > dist) {
                min_cost = dist;
                min_u = j;
                min_v = i;
            }
        }
    }

    return std::make_pair(min_u, min_v);
}

这里需要注意,示例中的 map1.at<float>map2.at<float> 只适用于 map1、map2 分别存储 x、y 浮点映射的情况。如果当前 map 是其他格式,需要先转换,或者按照实际的 cv::Mat 类型读取。

另外,查表循环建议按照 map1.rowsmap1.cols 遍历,因为 map1、map2 的尺寸对应的是 dst 图像,而不是严格由 src 决定。

两种方式能取得非常接近的结果,但是第二种遍历查表的方法效率比较低。在 i7-11700 上测试,运行时间超过 1000 ms,而 OpenCV 的函数就很快,运行时间在几十 ms。


参考

[1] cv.remap() 重映射学习笔记/map1 map2 易混点

[2] Can I get the point position using remap in OpenCV