摘要:本文介绍如何复用 HALCON 标定得到的相机内外参与位姿,直接处理左右物体图像并输出拼接结果。核心流程包括:读取固化的 division 相机参数与两台相机位姿,为每个物体场景施加局部平移,通过buildDivisionWorldPlaneMap生成世界平面反向映射表,再依次调用remapWithMask完成带有效域掩膜的重映射、stitchLikeHalcon做带掩膜约束的拼接,最后经rotateCropMirror旋转、裁剪与镜像,输出result_01.png到result_04.png。文章还详细解析了位姿与投影几何、映射生成、拼接后处理、对象处理主流程以及标定参数与关键常量,帮助读者理解如何用 C++ 与 OpenCV 近似还原 HALCON 的 region/domain 语义。
总结:使用 HALCON 标定好的内外参和相机位姿。
只做一件事:复用 HALCON 标定得到的相机参数与位姿,直接处理物体图像,输出拼接结果。
实际的执行链路:
- 读取写死的相机 division 参数。
- 读取写死的两台相机 pose。
- 针对每个物体场景,为左右相机施加一个局部平移,定义物体在世界平面中的放置位置。
- 调用
buildDivisionWorldPlaneMap为左右相机生成世界平面的反向映射表。 - 读取左右物体图像。
- 调用 `remapWithMask` 完成重映射并生成有效域掩膜。
- 调用 `stitchLikeHalcon` 做带掩膜约束的拼接。
- 调用 `rotateCropMirror` 做旋转、裁剪和镜像。
- 输出 `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"`),用于拼出文件名。
位姿与投影几何
1. 位姿的定义:世界坐标系 → 相机坐标系的刚体变换。
struct Pose { cv::Matx33d R; // 3x3 旋转矩阵 cv::Vec3d t; // 3x1 平移向量 };2.这里的位姿就是外参(extrinsics),也叫 world-to-camera 矩阵。
它回答的问题是:“世界坐标系(标定板)相对于相机,处在什么位置、什么朝向?”
3.位姿在流程中起什么作用
作用一:提供“映射到世界平面”的基准
相机标定完成后,位姿把“图像像素”和“真实世界平面”联系起来。物体躺在与标定板同一平面上,所以这张标定板平面就是重映射目标平面(rectification plane):
// 把世界平面上的点投影回各相机原图,生成 remap 表
buildDivisionWorldPlaneMap(worldPose1, cam1, ...);
world → 相机 → 像素 这条链就靠位姿完成。
作用二:set_origin_pose / translateLocal 修改位姿原点
所谓“修正位姿”,本质是平移世界坐标系原点(不改变朝向):
Pose translateLocal(const Pose& pose, const cv::Vec3d& local) {
return {pose.R, pose.R * local + pose.t}; // t' = R·local + t
}
这样能在不重新标定的情况下,把世界平面原点挪到希望的位置(如对齐、按物体厚度抬升)。
作用三:区分左右相机——相对位姿合成
HALCON 里第二台相机的位姿不是单独标出来的,而是由相对位姿合成:
get_calib_data (CalibDataID, 'camera', 1, 'pose', RelPose2) % 相机2相对相机1
pose_invert (RelPose2, RelPose2Inverted)
pose_compose (RelPose2Inverted, Pose1, Pose2) % 得到相机2的绝对位姿
// 位姿与投影几何:完成旋转平移组合,以及 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 < targetHeight; ++row) { float* mx = mapX.ptr<float>(row); float* my = mapY.ptr<float>(row); for (int col = 0; col < targetWidth; ++col) { ///////【lambda】中形参`world` 就是这里直接构造出来的: //// `cv::Vec3d(col * scale, row * scale, 0.0)` 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, //lambda 形参world 来源??? [&](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 < mapX.rows; ++row) { const float* mx = mapX.ptr<float>(row); const float* my = mapY.ptr<float>(row); uchar* dst = domain.ptr<uchar>(row); for (int col = 0; col < mapX.cols; ++col) { const float x = mx[col]; const float y = my[col]; dst[col] = (x >= 0.0f && x < maxX && y >= 0.0f && y < 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`:遍历高度修正值,对每个场景处理两张图像并写出结果。
标定参数与入口
// 固定标定参数:直接复用 HALCON 脚本中的相机参数和参考位姿。 // 返回第一台相机的 division 模型内参及图像尺寸。 DivisionCamera calibratedCamera1() { return {0.00908106, -1802.23, 4.40189e-06, 4.4e-06, 599.937, 461.74, 1218, 926}; } // 返回第二台相机的 division 模型内参及图像尺寸。 DivisionCamera calibratedCamera2() { return {0.00908046, -1739.53, 4.40195e-06, 4.4e-06, 625.528, 493.833, 1218, 926}; }- `calibratedCamera1` / `calibratedCamera2`:返回固化的 division 内参。
// 返回第一台相机的离线标定旋转矩阵和平移向量。 Pose calibratedPose1() { return { cv::Matx33d( -0.976699, -0.194927, -0.0897949, 0.194807, -0.980789, 0.010188, -0.0900557, -0.00754205, 0.995908), cv::Vec3d(0.0319481, 0.00796256, 0.265295)}; } // 返回第二台相机的离线标定旋转矩阵和平移向量。 Pose calibratedPose2() { return { cv::Matx33d( -0.976714, -0.195053, 0.0893522, 0.196746, -0.980399, 0.0104679, 0.085559, 0.0278039, 0.995945), cv::Vec3d(-0.0424622, 0.00764408, 0.264491)}; }- `calibratedPose1` / `calibratedPose2`:返回相机相对标定板世界坐标系的位姿。
// 对外入口:使用 HALCON 标定参数完成整套校正、拼接和输出。 void processObjectsWithReusedCalibration(const fs::path& imageDir, const fs::path& outputDir) { constexpr int targetWidth = 1340; constexpr int targetHeight = 800; constexpr double scale = 0.0002; const std::array<double, 2> heightCorrections = {-0.0025, 0.0}; const DivisionCamera cam1 = calibratedCamera1(); const DivisionCamera cam2 = calibratedCamera2(); const Pose pose1 = calibratedPose1(); const Pose pose2 = calibratedPose2(); std::cout << "Using calibrated camera parameters from two_camera_calibrated_mosaicking.hdev calibration stage." << std::endl; std::cout << "Camera 1 division params: f=" << cam1.focus << ", kappa=" << cam1.kappa << ", cx=" << cam1.cx << ", cy=" << cam1.cy << std::endl; std::cout << "Camera 2 division params: f=" << cam2.focus << ", kappa=" << cam2.kappa << ", cx=" << cam2.cx << ", cy=" << cam2.cy << std::endl; processObjectImages(imageDir, outputDir, heightCorrections, [&](double heightCorrection) { const Pose worldPose1 = translateLocal(pose1, cv::Vec3d(-0.14, -0.07, heightCorrection)); const Pose worldPose2 = translateLocal(pose2, cv::Vec3d(-0.14, -0.07, heightCorrection)); ObjectMaps maps; buildDivisionWorldPlaneMap(worldPose1, cam1, targetWidth, targetHeight, scale, maps.map1x, maps.map1y); buildDivisionWorldPlaneMap(worldPose2, cam2, targetWidth, targetHeight, scale, maps.map2x, maps.map2y); return maps; }); }- `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 双相机拼接
标定相关知识:
相机内参、外参标定完整获取方法
一句话区分:内参是相机本身固有参数(镜头 + 感光芯片),只跟相机有关,固定不变;外参是相机在世界坐标系下的位姿(旋转 R + 平移 t),相机一动,外参就变。
一、相机内参(Intrinsic)
2. 怎么得到内参(最常用:棋盘格标定)
原理:拍摄不同角度、不同距离的棋盘格标定板,提取每张图棋盘角点,通过最小二乘优化求解 K 和畸变。
工具方案
- OpenCV(最常用)
- 打印棋盘格(比如 9×6 内角点)
- 多角度拍摄≥10~15 张,不要全正面,倾斜、远近都要有
findChessboardCorners找角点 +calibrateCamera求解内参 + 畸变
- Matlab 标定工具箱 / OpenCV 标定 GUI / Kalibr(支持鱼眼、双目)
- 工业相机配套标定软件(海康、Basler 等 SDK 自带)
✅ 要点:标定内参时相机固定,只动标定板;标定完之后,只要镜头不拆、不调焦距,内参基本不变。
二、相机外参(Extrinsic)
外参 =旋转矩阵 R (3×3) + 平移向量 t (3×1)作用:把世界坐标系下的三维点,转到相机坐标系:
两种获取场景
场景 1:已知内参,拍一张标定板求外参(单张)
相机内参已经标定好,拍一张棋盘格,用solvePnP直接算出这张图对应的 R、t,就是这一瞬间相机相对于棋盘格世界坐标系的外参。
- 世界坐标系一般定义:棋盘格平面为 Z=0,角点坐标预先给定。
注意:换位置拍,R 和 t 就变了,外参只对这张拍摄位置有效。
场景 2:双目相机外参(左右相机之间的相对外参)
双目标定,同时采集左右相机棋盘格图像,stereoCalibrate得到左相机到右相机的 R、T(两个相机之间固定相对位姿,只要相机刚性固定,这个相对外参不变)。
三、完整标定流程(最简版)
- 打印棋盘格标定板,贴在平整硬板上
- 标定内参:相机不动,移动标定板,多角度拍照,OpenCV
calibrateCamera→ 得到 K、畸变系数 - 标定外参:保持内参不变,拍摄标定板,
solvePnP→ 得到当前相机相对于标定板的 R、t(外参)
四、常见坑
- 图片太少、角度单一:内参误差大,重投影误差要尽量 < 0.5 像素
- 标定板弯曲、反光:角点提取失败
- 调焦、拆装镜头:必须重新标定内参
- 外参是相对坐标系的!你定义世界原点在哪,外参就对应这个原点,不是绝对坐标