十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Kalibr多板标定失败?图像复制遮挡预处理一步解决

Kalibr多板标定失败?图像复制遮挡预处理一步解决 前几天现场调试同事丢给我一袋图说“这是新产线的相机画面里摆了 9 块 Aprilgrid帮我标下内参”。我打开一看每张图三个方向各三块板子铺得满满当当。按惯例直接丢给 Kalibr 跑结果这个老伙计不太领情不是报一堆 corner 检测错误就是报告里重投影误差大得没法看。后来我试出了一个不碰一行源码、靠预处理就能解决的土办法——把一张多板图“复制”成 9 张单板图再用遮挡把不需要的板子全部盖掉Kalibr 瞬间就正常了。这个方案对不想改 Kalibr、又想快速出内参的场景特别适用尤其是产线验收、多相机工位、大视野标定这类环境。你不需要写 C 去替换 Kalibr 的 target_detector也不用去啃相机内参模型源码只要有一份能跑 Python OpenCV 的环境再用一个多板场景的原图就能完成整套标定。下面我把整套思路、脚本、参数和踩过的坑都整理出来。1. 为什么 Kalibr 看到 9 块板子会直接罢工1.1 Aprilgrid 检测机制的“单目标假设”Kalibr 的相机标定流程里图像检测部分用的是 Aprilgrid 检测器它对标定板的描述是“一个由 tag36h11 家族标记组成的、行列数固定的网格”。你在 target.yaml 里写清楚行列数、tag_size、tag_spacing 之后Kalibr 就会拿着这个期望尺寸去图里找对应 tag 集合。这里的关键问题是Kalibr 的 detector 内部是围绕“单目标”设计的它从一张图里找齐所有 tag 之后会尝试把这些 tag 按照 ID 的自增规律拼成一个 grid。一旦画面里出现多块板子不同板子的 tag ID 区间会混在一起detector 仍然只会去寻找“看起来像是一个 target”的组合结果非常容易产生两种错误一是找不到完整 grid直接返回空检测结果二是把两块板的边界 tag 误拼到同一个 grid 里然后因为角度或者间距不匹配而抛出异常。我在实际使用中还遇到过一种情况检测器明明找全了所有 tag却在内部 target 对齐阶段因为 tag 之间的相对位置关系太乱而判定失败最终在报告里输出 “Detection failed” 或者直接跳过这一帧。这个不是 Kalibr 坏了而是它的逻辑本身就不支持多板输入。1.2 多板场景失败的具体表现具体到命令行输出常见的翻车现象有几种。第一种是显示找不到角点比如类似 “Found no corners in image ...”这种最直接说明 tag 检测出来但没有整合成完整标定板。第二种是能检测出一些 corner但数量不对比如 6x6 的 Aprilgrid 应该输出 49 个角点结果只输出了 20 多个这种多半是 detector 把多块板的 tag 混在一起计算了。第三种更隐蔽标定能跑通但报告里的重投影误差高达 2 像素以上这种可能是因为检测器在部分帧里选中了错误的 tag 组合导致送入优化器的 2D-3D 匹配点本身就有误。实际上不需要把 Kalibr 内部的源码全部读一遍才能理解这个问题。你只需要记住一个判断标准Kalibr 的存在前提是“一图一板”它把标定板当作一个刚性平面物体来处理检测、匹配、优化都建立在这个前提上。一旦这个前提被破坏你就得从输入侧想办法。1.3 什么产线场景会同时出现 9 块板子可能有人会问正常标定一张图放一块板不就完事了为什么会摆 9 块我遇到的需求主要有三类第一类是产线上需要一次拍摄覆盖大视野相机固定在高处想要一个空间里所有区域都有足够的特征点约束于是放了 3x3 的板阵第二类是做多相机联合标定不同相机之间存在重叠视野一张图里放多块板可以让相邻相机的共同视野覆盖更完整第三类是想要提高单相机标定效率一次性录制一段包含 9 块板的视频后续再离线拆开使用。这三种场景在数据采集阶段都挺爽的拍一张顶九张。但到了标定环节就会撞上 Kalibr 的单目标假设所以才需要下面这套预处理方案。2. 不改代码的预处理思路原图复制加遮挡2.1 为什么不建议直接改 Kalibr 源码有些做法是直接去改 Kalibr 的 target_detector 源码让它可以同时检测多个 target。这条路不是不行但维护成本很高。Kalibr 的源码依赖 OpenCV、ROS、apriltag 库的多个版本改了以后还要重新编译如果现场机器没有编译环境或者标定用的电脑不是同一台你就得在每台机器上重新来一遍。而且 Kalibr 的检测逻辑和后面的优化器是耦合的你在检测器里动了数据结构很可能牵一发而动全身。另一个常见思路是用 OpenCV 的多棋盘格检测再去替换 Kalibr 的检测结果这个也复杂。你需要把 OpenCV 检测到的角点格式转换成 Kalibr 内部的 target observation 格式还要保证时间戳、图像坐标系完全对齐工程量不小。相比之下在输入数据上做文章是最省事、最不破坏原有工具链的方案。2.2 原图复制加遮挡的核心原则这个方案说白了就是一句话把一张多板图复制成多张单板图每张图只保留其中一块板其余部分全部用纯色遮挡。这样 Kalibr 看到的永远是“一图一板”它的检测逻辑完全不需要改变标定结果直接可读。这里有两条原则必须守住。第一遮挡区域要使用纯黑色或者与目标板背景完全隔离的颜色我在实操中推荐纯黑因为 Aprilgrid 检测器会对图像做二值化处理黑色区域不会产生任何虚假的角点响应。第二处理后的图像分辨率必须与原图保持一致因为相机内参中的焦距 f 是以像素为单位描述的如果把图像裁剪成一个小图再标定标出来的 fx 和 fy 是针对裁剪后的分辨率的和真实相机不匹配。根据我自己试过的结果这两条原则只要守住了后面 Kalibr 的报错率会大幅下降重投影误差也能稳定控制在 0.3 像素以内。2.3 全尺寸遮挡比 ROI 裁剪更稳的原因第一反应大家都会想到既然只要一块板那我直接用 OpenCV 切一个 ROI 不就行了这个方法表面可行但坑很多。最核心的问题是ROI 裁剪把图像尺寸改变了内参模型随之改变你标出来的图像中心点 cx 和 cy 也变了。虽然你可以通过公式换算回原始分辨率但换算过程中会引入插值误差而且在畸变较大的边缘区域裁剪带来的误差会更明显。全尺寸遮挡则没有这个问题。图像还是那张图像分辨率不变只是除目标板以外的像素全部变黑。Kalibr 标出来的内参直接就是相机在原始分辨率下的真实内参不需要任何后处理换算。额外还有一个好处全尺寸遮挡保留了板子在画面中的真实位置包括畸变在边缘的影响这比把板子硬生生移到图像中心去标定更符合实际成像模型。另外用全尺寸遮挡也方便做“单板单独标定”。9 块板子可以分别标出 9 组内参然后对比这 9 组结果的焦距和中心点判断板子摆放平面的平整度以及相机成像的稳定性。这是 ROI 裁剪拼接方案做不到的。3. 从一张多板图到一批单板图的完整实操3.1 场景搭建和数据采集注意事项先讲数据采集。9 块板子建议按 3x3 摆成规则网格板间距至少要有一块板的尺寸方便后续用聚类算法把不同板子的 tag 分开。每块板尽量贴平不要有翘曲最好用亚克力板或者硬纸板衬底。相机采集时不要一直正对着板阵要变换姿态包括左右偏摆、上下俯仰、以及远近移动这样才能给内参优化提供足够的约束。我一般每个姿态停留 0.5 秒以上一共采集 30~50 帧覆盖画面中心和边缘。光这一条就比单板标定高效太多。单板标定你需要在不同角度拍几十张多板阵一次曝光就能覆盖 9 个位置后面预处理之后相当于同时拿到了 9 x 30 270 张单板图数量非常充裕。采集时还有一个细节镜头不要对着强光源Aprilgrid 的黑白 tag 很容易因为反光出现高光点黑色区域一旦反光变成白色检测器就会多出一堆假角点。这一点尤其是在产线现场顶灯多的情况下很容易翻车。3.2 识别聚类把同一块板的 tag 聚在一起拿到原始图像序列之后第一步是把每一帧图里的所有 tag 都检测出来然后把属于同一块板的 tag 归到一组。因为 9 块板在空间上是分隔开的所有 tag 的中心点在像素坐标下也会自然形成 9 个簇直接做聚类就行。我用的方案是 KMeans簇数量设成 9。如果某个板子因为大角度视野被部分遮挡导致该帧只检测到 5 块板子的 tag那这一帧直接跳过不要勉强。代码如下import cv2 import numpy as np import glob import os from sklearn.cluster import KMeans from scipy.spatial import ConvexHull from apriltag import Detector, DetectorOptions opts DetectorOptions( familiestag36h11, nthreads4, quad_decimate2.0, quad_sigma0.0, refine_edgesTrue, decode_sharpening0.25 ) detector Detector(opts) BOARD_NUM 9 image_paths sorted(glob.glob(raw/*.png)) for frame_id, img_path in enumerate(image_paths): img cv2.imread(img_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) detections detector.detect(gray) if len(detections) BOARD_NUM * 4: print(f[skip] {img_path}: only {len(detections)} tags detected) continue centers np.array([tag.center for tag in detections]) kmeans KMeans(n_clustersBOARD_NUM, random_state0).fit(centers) for board_id in range(BOARD_NUM): pts [] for tag, label in zip(detections, kmeans.labels_): if label board_id: corners tag.corners pts.extend(corners.tolist()) pts np.array(pts) hull ConvexHull(pts) polygon pts[hull.vertices].astype(np.int32) # 生成掩膜并膨胀留出安全边距 mask np.zeros(gray.shape, dtypenp.uint8) cv2.fillPoly(mask, [polygon], 255) kernel np.ones((15, 15), np.uint8) mask cv2.dilate(mask, kernel) out cv2.bitwise_and(img, img, maskmask) save_dir fprocessed/board_{board_id} os.makedirs(save_dir, exist_okTrue) cv2.imwrite(f{save_dir}/{frame_id:06d}.png, out)这段代码有几个参数需要根据实际情况调。quad_decimate是降采样倍数画面中板子很小的时候可以设成 1.0 或者不降采样这样检测精度高但速度会慢一些。kernel的大小决定了掩膜外扩的范围一般 15x15 在 1920x1080 分辨率下够用如果板子较小、边缘角点离边界太近可以加大到 25x25。3.3 掩膜填充与批量输出的关键参数掩膜填充这一步是整个方案的“遮挡”主体。我使用的是凸包加膨胀的方式而不是直接画一个外接矩形这是因为 Aprilgrid 板子在画面中会有透视变形外接矩形会把太多无关背景框进来而这些背景区域如果是白墙或者有花纹可能会被检测器误判为 tag 的边造成伪角点。凸包方案可以紧贴板子轮廓外扩的 15 像素安全边距足以覆盖板子边缘的裁剪误差。这里还有个细节bitwise_and之后掩膜外的区域直接变黑但掩膜边缘会有抗锯齿效果残留的灰度值。在工程上可以再加一步把掩膜外的区域显式赋值纯黑避免 JPEG 压缩或图像缩放带来的边界过渡out cv2.bitwise_and(img, img, maskmask) out[mask 0] 0如果是黑白相机就把三通道图转成单通道再输出。Kalibr 对彩色图和灰度图都支持但保持一致比较好我一般统一存成 PNG 灰度图避免 JPEG 有损压缩带来的角点定位误差。输出的文件名建议包含足够多的补零位数保证按字典序排序时时间顺序不乱。Kalibr 读取图像文件夹时是按文件名顺序加载的如果出现frame_10排在frame_2前面的情况整个序列的顺序就乱了。3.4 将处理好的图片喂给 Kalibr 标定图片处理完之后有两种方式喂给 Kalibr一种是用图像文件夹模式一种是把图片打包成 rosbag。如果你只是单目相机内参标定优先用图像文件夹模式命令最简单kalibr_calibrate_cameras \ --target april_6x6.yaml \ --image-folder processed/board_0 \ --models pinhole-radtan \ --dont-show-report这里的april_6x6.yaml是标定板描述文件注意 tag_size 要和实际打印尺寸一致。如果你用的是 6x6 的 Aprilgrid那么 tag_cols 和 tag_rows 都填 6tag_spacing 填 0.3示例target_type: aprilgrid tag_cols: 6 tag_rows: 6 tag_size: 0.024 tag_spacing: 0.3processed/board_0是刚才脚本生成的第一个板子的文件夹。9 块板子可以分别跑 9 次也可以选其中一块板子跑一次。我建议先跑board_0验证流程确认无误后再并行跑其他板子最后对比内参是否一致。如果需要做双目标定或者相机 IMU 联合标定就绕不开 rosbag。你把处理好的图片用 cv_bridge 写入 bag保持图像 topic 名和原始采集一致即可。这里一定要注意如果你只是标单目不需要关心时间戳一旦要联合标定就必须用原始采集的时间戳不能自己重新编时间否则 IMU 数据对齐会错位。3.5 标定结果怎么看重投影误差与内参合理性Kalibr 跑完之后会生成一个cam0.yaml和一份report-cam0.pdf。最需要看的是report中的重投影误差图。正常的单目标定绝大多数角点的重投影误差应该落在正负 0.5 像素以内如果偏差超过 1 像素先不要急着用这份标定文件回过去检查脚本的掩膜是不是把板子边缘切掉了。还要看焦距数量级是否合理。以 1920x1080 分辨率为例水平视场角如果是 90 度那么 fx 大致在 960 左右中心点 cx 接近 960。如果标出来的 fx 明显偏离经验值比如只有 300多半是图像分辨率和处理过程中出现了不一致检查一下是不是意外把图片缩小了。我还习惯把 9 块板子的结果横向对比一遍。正常情况下 9 组内参的 fx 差异应该在 1% 以内如果某一块板子的结果明显偏大或者偏小说明这块板子在采集过程中可能没有贴平或者掩膜生成时带了太多背景噪声。这个检查方法是单板标定做不到的算是多板方案的一个额外福利。4. 常见问题与排查技巧实录4.1 某块板始终检测不到怎么排查预处理脚本跑完以后打开processed/board_X目录如果发现里面图片数量明显少于其他板子或者干脆是空的先怀疑聚类参数的问题。KMeans 默认会把所有检测到的 tag 平均分到 9 个簇里如果某一帧只检测到 7 块板子的 tag那么聚类后某些簇会混入其他板子的 tag导致凸包范围异常。解决办法是加一个过滤条件每个簇内 tag 数量应该等于 366x6 板子少于 30 的簇直接丢弃。还有可能是某块板子一直贴在不平整的表面上反光严重导致检测器丢 tag这种情况需要回到采集阶段重新拍代码层面很难救回来。另外提醒一句检测器对太小的板子也不友好如果 9 块板在画面中只占很小面积可以尝试把quad_decimate改成 1.0 再试。4.2 遮罩边缘出现伪角点导致误差变大遮罩边缘是最容易出问题的地方。因为黑色区域和板子白色网格的交界处对比度极高图像二值化后会出现很多虚假的边缘响应。这种伪角点通常集中在板子边缘的一圈如果你看的 report 图上误差大的点都分布在边缘区域就基本可以判断是这个原因。解决办法有两个方向一是把掩膜外扩范围加大让黑色区域离板子角点足够远避免影响二值化阈值二是输出前对图像做一次高斯模糊但这个操作会把真实角点也磨掉我不推荐。更稳妥的做法是在cv2.fillPoly之后对掩膜再做一次高斯模糊让黑色区域和板子边缘之间的过渡更平滑但注意不要模糊到板子内部的 tag 区域。4.3 标出的焦距与厂家标称差很多怎么办有时候处理流程全对标出来的 fx 和传感器标称焦距差了 5% 以上。这时先排查采集姿态。如果所有图片都是正对板阵拍的没有任何倾斜视角那内参优化就缺少足够的约束焦距的可观测性会变差标定结果自然不稳定。多板阵方案容易让人偷懒觉得一下拍 9 块板子就够了实际上依然要保证有 20% 以上的图片带有明显旋转视角。其次是检查掩膜外扩是否把板子的完整边界切掉。Aprilgrid 的外圈 tag 如果被黑色区域吃掉了半个角点位置会偏移标出来的畸变系数会异常偏大焦距也会跟着扭曲。4.4 做相机 IMU 联合标定时要注意什么如果最终目标是相机 IMU 联合标定这套图像预处理同样可以用但时间戳问题绕不开。前面提到过处理后的图片必须保留原始采集时间戳不能重新生成时间所以打包 bag 的时候要把原始图像时间戳记录下来逐帧写入新 bag。千万不要用 cv_bridge 的to_msg自动赋当前时间这样联合标定时会发现 IMU 数据的相对时间关系全部错位报“Invalid time interval”之类的错误。另外联合标定需要相机和 IMU 之间的激励足够丰富板阵场景下一定要做充分的旋转运动不能只是平移。平移对 IMU 陀螺仪的角速度约束几乎没有Kalibr 优化时陀螺仪 bias 会发散最终结果会表现为相机和 IMU 的外参平移量很奇怪比如 z 轴偏移几十厘米这种明显不合理的值。我个人在实际操作中的体会是这套原图复制加遮挡的方案本质是把“多目标检测”问题转换成“单目标检测”问题没有改任何标定核心逻辑所以它永远能跟上 Kalibr 的版本迭代。你只需要一份能跑 Python 脚本的预处理环境几行代码就能让老工具重新干活。后期如果你想偷懒还可以把这个脚本封装成一个函数输入多板图文件夹自动输出 9 个单板文件夹下次碰到类似场景直接一行命令搞定。
返回列表