opencv复现 Halcon 双相机拼接 摘要本文介绍如何复用 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, Z0 ▼ 世界平面点 (X, Y, 0) │ projectorR,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相对相机1pose_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_castlt;floatgt;(pixel.x), static_castlt;floatgt;(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.ptrlt;floatgt;(row); float* my mapY.ptrlt;floatgt;(row); for (int col 0; col lt; 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_castfloat(sourceSize.width - 1); const float maxY static_castfloat(sourceSize.height - 1); for (int row 0; row lt; mapX.rows; row) { const float* mx mapX.ptrlt;floatgt;(row); const float* my mapY.ptrlt;floatgt;(row); uchar* dst domain.ptrlt;uchargt;(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遍历高度修正值对每个场景处理两张图像并写出结果。标定参数与入口// 固定标定参数直接复用 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::arraydouble, 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 中固化的几个关键参数常量值含义targetWidth1340输出世界平面宽度像素targetHeight800输出世界平面高度像素scale0.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相机一动外参就变。一、相机内参Intrinsic2. 怎么得到内参最常用棋盘格标定原理拍摄不同角度、不同距离的棋盘格标定板提取每张图棋盘角点通过最小二乘优化求解 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就是这一瞬间相机相对于棋盘格世界坐标系的外参。世界坐标系一般定义棋盘格平面为 Z0角点坐标预先给定。注意换位置拍R 和 t 就变了外参只对这张拍摄位置有效。场景 2双目相机外参左右相机之间的相对外参双目标定同时采集左右相机棋盘格图像stereoCalibrate得到左相机到右相机的 R、T两个相机之间固定相对位姿只要相机刚性固定这个相对外参不变。三、完整标定流程最简版打印棋盘格标定板贴在平整硬板上标定内参相机不动移动标定板多角度拍照OpenCVcalibrateCamera→ 得到 K、畸变系数标定外参保持内参不变拍摄标定板solvePnP→ 得到当前相机相对于标定板的 R、t外参四、常见坑图片太少、角度单一内参误差大重投影误差要尽量 0.5 像素标定板弯曲、反光角点提取失败调焦、拆装镜头必须重新标定内参外参是相对坐标系的你定义世界原点在哪外参就对应这个原点不是绝对坐标