Kalibr多板标定失败?图像复制遮挡预处理一步解决
2026/9/10 11:08:11 网站建设 项目流程

前几天现场调试,同事丢给我一袋图,说“这是新产线的相机,画面里摆了 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( families="tag36h11", nthreads=4, quad_decimate=2.0, quad_sigma=0.0, refine_edges=True, decode_sharpening=0.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_clusters=BOARD_NUM, random_state=0).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, dtype=np.uint8) cv2.fillPoly(mask, [polygon], 255) kernel = np.ones((15, 15), np.uint8) mask = cv2.dilate(mask, kernel) out = cv2.bitwise_and(img, img, mask=mask) save_dir = f"processed/board_{board_id}" os.makedirs(save_dir, exist_ok=True) 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, mask=mask) 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 都填 6,tag_spacing 填 0.3,示例:

target_type: 'aprilgrid' tag_cols: 6 tag_rows: 6 tag_size: 0.024 tag_spacing: 0.3

processed/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 数量应该等于 36(6x6 板子),少于 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 个单板文件夹,下次碰到类似场景直接一行命令搞定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询