opencv复现 Halcon 双相机拼接

摘要: 本文介绍如何复用 HALCON 标定得到的相机内外参与位姿,直接处理左右物体图像并输出拼接结果。核心流程包括:读取固化的 division 相机参数与两台相机位姿,为每个物体场景施加局部平移,通过 buildDivisionWorldPlaneMap 生成世界平面反向映射表,再依次调用 remapWithMask 完成带有效域掩膜的重映射、stitchLikeHalcon 做带掩膜约束的拼接,最后经 rotateCropMirror 旋转、裁剪与镜像,输出 result_01.png 到 result_04.png。文章还详细解析了位姿与投影几何、映射生成、拼接后处理、对象处理主流程以及标定参数与关键常量,帮助读者理解如何用 C++ 与 OpenCV 近似还原 HALCON 的 region/domain 语义。

摘要:使用 HALCON 标定好的内外参和相机位姿。

只做一件事:复用 HALCON 标定得到的相机参数与位姿,直接处理物体图像,输出拼接结果。

实际的执行链路:

  1. 读取写死的相机 division 参数。
  2. 读取写死的两台相机 pose。
  3. 针对每个物体场景,为左右相机施加一个局部平移,定义物体在世界平面中的放置位置。
  4. 调用 buildDivisionWorldPlaneMap 为左右相机生成世界平面的反向映射表。
  5. 读取左右物体图像。
  6. 调用 `remapWithMask` 完成重映射并生成有效域掩膜。
  7. 调用 `stitchLikeHalcon` 做带掩膜约束的拼接。
  8. 调用 `rotateCropMirror` 做旋转、裁剪和镜像。
  9. 输出 `result_01.png` 到 `result_04.png`。

整体数据流:

复制代码
目标图像像素 (col,row)
   │  × scale, Z=0
   ▼
世界平面点 (X, Y, 0)
   │  projector:R,t + 针孔 + division 畸变 + 像素换算
   ▼
源图像像素 (u, v)
   │  写入 mapX, mapY
   ▼
cv::remap(src, dst, mapX, mapY, INTER_LINEAR)

`remapWithMask` 先做 `cv::remap`,再根据 `mapX/mapY` 生成有效域掩膜。

这样做的原因是:

  1. OpenCV 双线性插值在边界外半个像素附近,可能仍混入一些非零灰度。

  2. HALCON 的 `map_image` 会结合 domain 排除这些域外像素。

  3. 这里显式把域外区域清零,可以减少拼接时的黑线和脏边。

`stitchLikeHalcon` 的处理逻辑是:

  1. 先求两张图有效域的交集。

  2. 对交叠区域做一个小尺度闭运算,修补针孔。

  3. 在交叠区域内优先去掉 `world1` 的内容。

  4. 再把 `world2` 叠加上去。

这是一种近似 HALCON region/domain 语义的实现,目的不是做复杂融合,而是让覆盖关系和边界效果更接近原示例。

最后的后处理:

  1. 将拼接后的世界平面图旋转 12 度。

  2. 裁剪固定矩形区域。

  3. 先做上下翻转,再做左右翻转。

内部函数解析:

基础工具

  • `readImageOrThrow`:按彩色方式读取图像,读不到直接抛异常。

  • `twoDigit`:把索引格式化成两位字符串(`1` -> `"01"`),用于拼出文件名。

位姿与投影几何

复制代码
// 位姿与投影几何:完成旋转平移组合,以及 HALCON division 相机投影。
// 将局部平移旋转到世界坐标系,并叠加到位姿平移量。
Pose translateLocal(const Pose& pose, const cv::Vec3d& local) {
    return {pose.R, pose.R * local + pose.t};
}
  • `translateLocal`:在相机自身坐标系下做局部平移,得到新的世界位姿,满足 `t' = R * local + t`。

    // 将传感器物理坐标换算为图像像素坐标。
    cv::Point2d pixelFromSensor(const DivisionCamera& camera, const cv::Point2d& sensor) {
    // 传感器上相邻两个像素中心的物理间距是 sx(水平)、sy(垂直) 单位换算:把「米」换算成「像素个数」
    // 水平像素偏移 = sensor.x / sx
    // 垂直像素偏移 = sensor.y / sy

    /// 加上主点偏移 :sensor.x = 0 的位置是光轴与传感器平面的交点(主点),
    // 但它在图像里并不一定对应像素 (0, 0),而是对应像素 (cx, cy)
    // cx, cy 就是主点在图像坐标系中的落点,理想情况下接近图像中心 (width/2, height/2),但实际会有偏移,标定时会估计出来。
    return {
    sensor.x / camera.sx + camera.cx,
    sensor.y / camera.sy + camera.cy,
    };
    }

  • `pixelFromSensor`:把传感器物理坐标按 `sx/sy` 缩放并加上主点 `cx/cy`,得到像素坐标。

    // 数据模型:保存复用 HALCON 标定结果所需的相机模型、位姿和映射表。
    struct DivisionCamera {
    double focus;
    double kappa;
    double sx;
    double sy;
    double cx;
    double cy;
    int width;
    int height;
    };
    // 按 HALCON division 模型将理想传感器坐标转换为畸变后的坐标。
    //u = 2U / (1 + sqrt(1 - 4·Kappa·(U² + V²)))
    //v = 2V / (1 + sqrt(1 - 4·Kappa·(U² + V²)))
    cv::Point2d distortDivisionSensor(const DivisionCamera& camera, const cv::Point2d& ideal) {
    const double radiusSquared = ideal.x * ideal.x + ideal.y * ideal.y;
    const double discriminant = 1.0 - 4.0 * camera.kappa * radiusSquared;
    if (discriminant <= 0.0) {
    return {-1.0, -1.0};
    }
    const double scale = 2.0 / (1.0 + std::sqrt(discriminant));
    return {ideal.x * scale, ideal.y * scale};
    }

  • `distortDivisionSensor`:按 HALCON division 模型给理想像点加入径向畸变,判别式小于等于 0 时返回无效点 `(-1, -1)`。

    // 将世界坐标点变换到相机坐标系,执行透视投影和畸变校正。
    cv::Point2f projectDivisionPoint(const cv::Vec3d& world, const Pose& pose, const DivisionCamera& camera) {
    const cv::Vec3d cameraPoint = pose.R * world + pose.t;
    if (cameraPoint[2] <= 1e-12) {
    return {-1.0f, -1.0f};
    }
    const cv::Point2d idealSensor(
    camera.focus * cameraPoint[0] / cameraPoint[2],
    camera.focus * cameraPoint[1] / cameraPoint[2]);
    const cv::Point2d distortedSensor = distortDivisionSensor(camera, idealSensor);
    const cv::Point2d pixel = pixelFromSensor(camera, distortedSensor);
    return cv::Point2f(static_cast<float>(pixel.x), static_cast<float>(pixel.y));
    }

  • `projectDivisionPoint`:把世界点依次做位姿变换、透视除法、畸变和像素换算,得到投影像素;相机后方点返回无效值。

映射生成

复制代码
// 世界平面映射:把目标平面上的每个像素反投影回相机原图。
template <typename Projector>
void buildPlaneMap(
    int targetWidth,
    int targetHeight,
    double scale,
    Projector projector,
    cv::Mat& mapX,
    cv::Mat& mapY) {
    mapX.create(targetHeight, targetWidth, CV_32FC1);
    mapY.create(targetHeight, targetWidth, CV_32FC1);
for (int row = 0; row &lt; targetHeight; ++row) {
    float* mx = mapX.ptr&lt;float&gt;(row);
    float* my = mapY.ptr&lt;float&gt;(row);
    for (int col = 0; col &lt; targetWidth; ++col) {
        const cv::Point2f point = projector(cv::Vec3d(col * scale, row * scale, 0.0));
        mx[col] = point.x;
        my[col] = point.y;
    }
}
}
  • `buildPlaneMap`:模板函数,遍历输出平面上的每个像素,调用投影器填入 `mapX/mapY`。

    // 使用 division 相机模型将目标世界平面投影到相机原图,生成 remap 所需的 x/y 坐标表。
    void buildDivisionWorldPlaneMap(
    const Pose& pose,
    const DivisionCamera& camera,
    int targetWidth,
    int targetHeight,
    double scale,
    cv::Mat& mapX,
    cv::Mat& mapY) {
    buildPlaneMap(
    targetWidth,
    targetHeight,
    scale,
    [&](const cv::Vec3d& world) { return projectDivisionPoint(world, pose, camera); },
    mapX,
    mapY);
    }

  • `buildDivisionWorldPlaneMap`:用 division 相机模型把目标世界平面投影到相机原图,生成 `cv::remap` 所需的 x/y 坐标表,是反向映射的核心。

拼接后处理

复制代码
// 图像拼接后处理:生成有效域、重映射图像,并模拟 HALCON 的拼接结果。
// 根据映射坐标是否落在源图像范围内生成有效像素掩码。
cv::Mat buildFloatMapDomain(const cv::Mat& mapX, const cv::Mat& mapY, const cv::Size& sourceSize) {
    cv::Mat domain(mapX.size(), CV_8UC1, cv::Scalar(0));
    const float maxX = static_cast<float>(sourceSize.width - 1);
    const float maxY = static_cast<float>(sourceSize.height - 1);
for (int row = 0; row &lt; mapX.rows; ++row) {
    const float* mx = mapX.ptr&lt;float&gt;(row);
    const float* my = mapY.ptr&lt;float&gt;(row);
    uchar* dst = domain.ptr&lt;uchar&gt;(row);
    for (int col = 0; col &lt; mapX.cols; ++col) {
        const float x = mx[col];
        const float y = my[col];
        dst[col] = (x &gt;= 0.0f &amp;&amp; x &lt; maxX &amp;&amp; y &gt;= 0.0f &amp;&amp; y &lt; maxY) ? 255 : 0;
    }
}
return domain;
}
  • `buildFloatMapDomain`:根据 `mapX/mapY` 判断采样点是否落在原图范围内,生成有效域掩膜。

    // 对图像执行双线性重映射,并用有效域掩码清除无效区域。
    cv::Mat remapWithMask(const cv::Mat& image, const cv::Mat& mapX, const cv::Mat& mapY, cv::Mat& mask) {
    cv::Mat mapped;
    //remap 要求 map 给出的是 目标像素 → 源像素 的对应,也就是逆向重采样
    cv::remap(image, mapped, mapX, mapY, cv::INTER_LINEAR, cv::BORDER_CONSTANT, cv::Scalar::all(0));

    mask = buildFloatMapDomain(mapX, mapY, image.size());
    mapped.setTo(cv::Scalar::all(0), ~mask);
    return mapped;
    }

  • `remapWithMask`:先 `cv::remap`,再用有效域掩膜把域外像素清零,避免边界黑线。

  • `stitchLikeHalcon`:对两张图的有效域求交集并做闭运算,在交集内清空 `world1`,再叠加 `world2`。

  • `rotateCropMirror`:旋转 12 度、裁剪固定矩形,再分别做上下翻转和左右翻转。

对象处理主流程

  • `buildResultImage`:读取一对左右物体图像,完成重映射、拼接和后处理。

  • `writeResultImage`:把结果写为 `result_XX.png` 并打印尺寸。

  • `processObjectImages`:遍历高度修正值,对每个场景处理两张图像并写出结果。

标定参数与入口

  • `calibratedCamera1` / `calibratedCamera2`:返回固化的 division 内参。

  • `calibratedPose1` / `calibratedPose2`:返回相机相对标定板世界坐标系的位姿。

  • `processObjectsWithReusedCalibration`:对外入口,组装上述参数并驱动整套流程。

关键常量

`processObjectsWithReusedCalibration` 中固化的几个关键参数:

| 常量 | 值 | 含义 |

| --- | --- | --- |

| `targetWidth` | `1340` | 输出世界平面宽度(像素) |

| `targetHeight` | `800` | 输出世界平面高度(像素) |

| `scale` | `0.0002` | 每个输出像素对应的物理尺寸(米/像素) |

| `heightCorrections` | `{-0.0025, 0.0}` | 两个物体场景的高度修正量(米) |

| 局部平移 `local` | `(-0.14, -0.07, heightCorrection)` | 物体在相机坐标系下的摆放位置 |

因此每个场景会按 `2 * objectIndex + imageIndex` 生成索引,最终输出 `result_01.png` 到 `result_04.png`。

结果图:

拼接细节和halcon 完全一致。

源码如下:

Opencv 复现 Halcon 双相机拼接

相关推荐
名不经传的养虾人5 小时前
从0到1:企业级AI项目迭代日记 Vol.117|学会忘记,学会证明,学会设边界
大数据·人工智能·算法·企业ai·多agent协作
百胜软件@百胜软件5 小时前
百胜软件亮相华为全联接大会2026,共赴零售AI新生态
大数据·人工智能
独隅5 小时前
Claude Code 离线部署实战:企业内网本地化安装与依赖打包全指南效果展示
人工智能
黑妹天下第一乖5 小时前
小智改造实战解读-首 token 延迟去哪了:云端与端侧大模型的分段对照
开发语言·人工智能·python·嵌入式硬件·自然语言处理·iot
布吉岛的石头5 小时前
AI代做PPT报价:50元档和300元档差在哪
人工智能·副业·办公·兼职·ppt代做
数字游民95275 小时前
自由职业和探索AI的这两年时间,分享我的真实经历和感受
人工智能
User_芊芊君子6 小时前
第十五届中国创新创业大赛
人工智能
向哆哆6 小时前
农场畜牧目标检测数据集:5类别、15,000张图像 | 目标检测
人工智能·目标检测·目标跟踪