资讯动态

Kalibr多板标定失败?原图复制加遮挡预处理轻松搞定相机内参

发布时间:2026/9/10 11:18:07 来源:尧图企业网站定制
标定这件事做视觉的迟早都会碰上。前阵子帮人处理一台140度广角相机的内参标定为了把画面边缘也覆盖到现场直接在镜头前摆了一整面9块Aprilgrid标定板阵列每块板子有6×6个tag拍出来的画面整整齐齐一大片黑白格子。听起来很美结果Kalibr跑起来直接罢工要么Apriltag检测阶段疯狂刷错误日志要么好不容易出了报告重投影误差大得没法看。折腾了一下午最后我劝住他别去改Kalibr源码也别再去调一堆检测参数我们把原始数据做一次预处理就好。方案其实就是标题里这八个字——原图复制加遮挡把一张9板图复制成9张单板图每张只保留其中一块板子其余区域用统一底色填掉然后照常喂给Kalibr。二十分钟后内参出来了重投影RMS压到了0.15像素以内。这个方案听起来像是投机取巧但背后其实踩中了Kalibr内部的一个关键不对称它的检测器是单板思维优化器却是多板受益。检测阶段一次看到9块板子会逻辑混乱优化阶段反而巴不得你有更多分布均匀的角点。既然这两条腿对多板的容忍度不一样我们就在数据层把多板图拆成单板图让检测器舒服让优化器继续干活。整个过程不需要动一行算法源码也不需要昂贵的计算资源只要写几十行Python脚本。这篇文章把完整思路、踩坑记录和可直接抄走的代码都整理出来了。1. 为什么Kalibr一见到9块板子就罢工1.1 检测器的“单板思维”有多顽固先拆一下Kalibr做相机内参标定的内部流程它大体是两步第一步是标定板检测第二步是优化求解。检测这一步Kalibr用的是Aprilgrid检测器如果用棋盘格就是棋盘格检测器。Aprilgrid本质上是一堆带二进制编码的Apriltag每个tag有唯一的id检测器要做的事情是先找到画面里所有的黑色方块候选再解出每个tag的id然后把所有tag按照拓扑关系拼成一个完整的grid结构。问题就出在这个“拼”字上。Kalibr内部的grid恢复逻辑默认当前画面里只存在一块标定板也就是说它假设所有检测出来的tag都属于同一个平面网格。当你的画面里有9块独立的标定板时每一块板子局部都能解出正确的tag和id但一旦进入“把所有tag拼成一个大网格”的阶段检测器就会强行把9块板子的tag往同一个平面网格上凑。9块板子显然不在同一个平面上或者说即使在同一平面它们之间的距离和顺序也不满足单块板的网格规律于是大量tag在拼接失败后被当成异常值丢掉严重的时候整个pattern识别直接返回失败。这就是为什么Kalibr遇到多板场景要么检测不到东西要么检测结果支离破碎。棋盘格就更是重灾区了。Aprilgrid好歹每个tag还有独立id可以区分归属棋盘格完全没有编码信息光滑的角点阵列一旦被多块板子打乱检测器会把几块板子的角点错误地连成一个巨大的、变形的“伪棋盘”角点顺序错乱之后后续所有优化都不可能收敛。1.2 优化器反而不挑食多板还是好事但Kalibr的另一条腿——优化求解态度完全相反。标定内参本质上是在解一个重投影误差最小化问题已知标定板上的3D点坐标已知这些3D点对应的2D像素观测求解相机内参K、畸变系数D以及每一帧图像对应的相机外参位姿。BA优化器根本不关心画面里是有1块板子还是9块板子它只关心在每一帧图像中检测出来的那些2D点和对应的3D点是否能被一套内参模型合理解释。假如检测器能完美地把9块板子的2D-3D对应关系全部输出优化器甚至会更高兴因为角点在整个画面里的分布更均匀尤其对畸变参数的约束更强。广角相机标定最怕的就是角点都集中在画面中心边缘缺乏约束畸变参数很容易飘。多板阵列恰好能一次性把角点铺满整个画面从中心到边缘都被照顾到。所以问题的本质就浮出水面了多板场景在数据约束上非常好但在特征提取阶段无法通过检测器这一关。既然两条腿对“画面里有多块板子”这件事的态度完全相反可行方案就变成了在中间做一层数据形态转换——保证检测器看到的是单板图但最终进入优化器的仍然是原图尺寸、原图分辨率、原像素坐标的观测。这就是“原图复制遮挡”方案能成立的根本原因。2. 不裁剪、不改代码靠“原图复制遮挡”拿到干净输入2.1 裁剪方案为什么被我否掉图像尺寸就是内参的坐标系很多人遇到多板图的第一反应是裁剪把每一块板子从原图里裁出来变成9张独立的小图。这个思路直观但有一个致命问题图像尺寸变了内参坐标系也跟着变了。相机内参K矩阵里的f_x、f_y、c_x、c_y都是相对于完整图像传感器平面的像素坐标定义的。c_x和c_y是光心在像素坐标系里的位置它是相对于整张图像左上角的偏移。裁剪之后新图像的左上角不再是原始像素坐标系的原点光心的偏移量就变了。更麻烦的是畸变模型里的各项系数是定义在归一化坐标下的归一化坐标需要知道图像中心在哪裁剪后图像中心被移动畸变参数和裁剪偏移之间会引入耦合误差后处理换算非常容易出错。“原图复制遮挡”就完全绕开了这个问题。处理后的每一张图长宽像素和原始图像完全一致所有被遮挡的区域只是被填成了均匀底色保留区域的像素坐标仍然对应原始相机的完整感光面。Kalibr读取这些图时它看到的坐标系和真实相机没有任何偏差标定出来的内参可以直接用于原始图像不需要任何换算。2.2 遮挡填充选什么背景色黑、白、灰对比实测填什么底色这个细节看起来不起眼实际影响检测稳定性。我的测试结论是填白色最稳填黑色次之填灰色可以用但不是最优。原因跟Aprilgrid的检测机制有关。Apriltag检测器的核心是找黑白方块的边界它内部会做局部二值化或者基于梯度的边缘分析。如果你把遮挡区域填成纯黑色画面里会出现一块巨大的黑色背景板子周围会形成一圈连续的长直强边缘这种边缘很容易干扰检测器的边缘分组和候选区域筛选偶发情况下会把板子外围的部分黑色区域也当成tag候选。填白色时板子周围的白色和背景白色融为一体检测器根本不需要区分哪里是板子边界、哪里是遮挡边界因为它只需要在白色大背景里找到由黑色方块构成的图案。白色背景对Aprilgrid来说等于把“画面里只有一块板”这个假设从视觉上做实了。如果你用的是棋盘格标定板我依然建议填白色理由是一样的棋盘格角点检测也依赖黑白交错形成的梯度白色背景比黑色背景更不容易产生干扰边缘。2.3 为什么不需要改Kalibr源码把问题前移到数据层我的工程习惯一直是能用输入数据解决的问题就不要去动代码逻辑。改Kalibr源码去支持多板检测表面看是治本实际操作中却要面对一堆现实问题Kalibr源码老、依赖多、编译重Aprilgrid检测逻辑嵌得很深改检测器动辄就要重新做整条pipeline的回归验证没有两三天根本下不来。“原图复制遮挡”的本质是把“多板检测失败”这个下游问题通过改变输入数据形态的方式消解掉。Kalibr一行代码不动检测器看到的仍然是它熟悉的单板场景优化器仍然拿到的是原图尺寸的观测。这个思路可以推广到很多视觉工具链里当工具和你定义的输入假设冲突时除了改代码还有一种低成本选项是让数据迁就工具的假设。这种方案的优点是可控性强可回滚出问题一眼就能定位到预处理环节。3. 手把手实操从多板图像到Kalibr可用的bag3.1 第1步在原始图上标出9块板的四边形区域整个流程的第一步是拿到每块板子在原始图像中的大致四边形坐标。如果你手里有原始多板图像可以用OpenCV写一个简单的鼠标标注脚本在图上依次点击9块板子的四个角把坐标保存下来。import cv2 import numpy as np img cv2.imread(frame_0001.png) img_show img.copy() polygons [] current_poly [] def on_mouse(event, x, y, flags, param): global img_show if event cv2.EVENT_LBUTTONDOWN: current_poly.append((x, y)) cv2.circle(img_show, (x, y), 4, (0, 0, 255), -1) cv2.imshow(annotate, img_show) print(point %d: (%d, %d) % (len(current_poly), x, y)) cv2.imshow(annotate, img_show) cv2.setMouseCallback(annotate, on_mouse) while len(polygons) 9: current_poly [] while True: key cv2.waitKey(10) if key ord(q): break if len(current_poly) 4: polygons.append(np.array(current_poly, dtypenp.int32)) cv2.polylines(img_show, [polygons[-1]], True, (0, 255, 0), 2) cv2.imshow(annotate, img_show) print(polygon %d saved % len(polygons)) break cv2.destroyAllWindows() np.save(board_polygons.npy, np.array(polygons))这一段脚本的逻辑是每张图上按顺序点四个点点满4个点就自动保存一个四边形继续下一块板直到存满9个点对。标注的时候不用特别精确因为后面会对外扩区域做处理哪怕多边形把板子外围多包进去几个像素都没关系但不能少板子的完整区域必须在多边形内部。3.2 第2步批量生成单板图拿到9个四边形坐标之后就可以对图像序列做批量处理了。这里的核心代码是生成掩膜、按四边形填充、然后对非保留区域填充白色背景。import cv2 import numpy as np import glob import os polygons np.load(board_polygons.npy) os.makedirs(frames_single, exist_okTrue) img_files sorted(glob.glob(frames_original/*.png)) for img_path in img_files: img cv2.imread(img_path) h, w img.shape[:2] basename os.path.splitext(os.path.basename(img_path))[0] for idx, poly in enumerate(polygons): mask np.zeros((h, w), dtypenp.uint8) cv2.fillPoly(mask, [poly.astype(np.int32)], 255) kernel np.ones((7, 7), np.uint8) mask cv2.dilate(mask, kernel, iterations1) masked img.copy() masked[mask 0] 255 out_path frames_single/%s_view%02d.png % (basename, idx) cv2.imwrite(out_path, masked)这段代码做了三件事第一用fillPoly把当前板子的四边形区域填成255第二用一个7×7的膨胀核把掩膜外扩一圈目的是吃掉多边形边缘可能残留的半影区域或者标注误差第三把非掩膜区域全部填充为白色。这里有一个细节要提醒掩膜外扩不是越宽越好。如果9块板子之间的间距本来就小你把掩膜膨胀得太大会把相邻板子的边缘也包进来导致mask区域里同时出现两块板子这就失去了遮挡的意义。我一般用7×7膨胀核实际效果比较稳如果你板子摆得特别密可以改成5×5。3.3 第3步把单板图序列打包成rosbagKalibr的输入接口是rosbag它不从文件夹直接读取图像文件。所以我们需要把处理后的单板图写成一个bag话题名建议统一用/cam0/image_raw图像编码用灰度图mono8因为Kalibr内部会自己处理灰度你传彩色图它也会转不如在源头就转好。import rosbag import rospy from sensor_msgs.msg import Image from cv_bridge import CvBridge import cv2 import glob bridge CvBridge() with rosbag.Bag(single_board.bag, w, compressionnone) as bag: t rospy.Time(1600000000) dt rospy.Duration.from_sec(0.1) for path in sorted(glob.glob(frames_single/*.png)): img cv2.imread(path, cv2.IMREAD_GRAYSCALE) msg bridge.cv2_to_imgmsg(img, encodingmono8) msg.header.stamp t msg.header.frame_id cam0 bag.write(/cam0/image_raw, msg, t) t dt这里把帧率设成了10Hz也就是每帧间隔0.1秒。Kalibr对时间戳的绝对起点没有要求但要求时间戳必须是严格递增的而且帧率最好均匀不要一会儿10帧一会儿2帧。如果采集原始数据时是按照30fps连拍的建议也统一重采样到这个10Hz省得Kalibr在时间戳处理上出现诡异的非线性。3.4 第4步运行kalibr_calibrate_cameras并解读结果bag准备好之后还需要确认标定板描述文件。一张标准的Aprilgrid描述文件长这样target_type: aprilgrid tag_cols: 6 tag_rows: 6 tag_size: 0.021 tag_spacing: 0.3这里的tag_cols和tag_rows是你每块板子上的tag数量tag_size是每个tag黑色方块的实际边长单位是米tag_spacing是tag之间白色间隙相对于tag尺寸的比例。这三个参数如果填错Kalibr检测阶段大概率直接挂掉或者输出一个完全不可用的内参。然后执行标定命令kalibr_calibrate_cameras \ --target april_6x6.yaml \ --bag single_board.bag \ --models pinhole-radtan \ --topics /cam0/image_raw \ --show-extraction--models pinhole-radtan是常用的针孔相机模型加径向切向畸变模型适合大部分非鱼眼镜头。你如果标的是鱼眼或者超广角可以换fisheye-equi或者ds模型。加上--show-extraction参数可以在检测阶段实时看到每一帧图像里被识别出的角点很有用可以在全量运行前快速判断预处理效果。标定结束后Kalibr会输出一份cam0.yaml和一份PDF报告。cam0.yaml里包含的就是最终的内参K和畸变系数DPDF报告里有每一帧图像的平均重投影误差。一般RMS小于0.3像素就是很干净的结果如果RMS超过0.5甚至到了1像素以上大概率是板子的物理尺寸量错了或者遮挡区域引入了干扰。4. 数据和时间戳这两个坑90%的人会踩4.1 为什么“复制9份”不等于“多了9个视角”我见过不止一个人把方案理解成“一张图复制9份就相当于多了8个新视角”这是个很危险的误区。单目视觉里内参的估计是靠多个不同视角下观测同一组3D点来实现的。如果你只有一张原始多板图无论你把这张图复制成9份还是90份它本质上还是同一个视角的观测。复制出来的9张图虽然保留了不同的板子区域但这些2D观测都来自同一个相机位姿没有引入新的外参约束内参的退化问题依然存在。你需要的不是“一张图的9块板子”而是“多张不同类型的图每张图里都有9块板子并且这些图的相机位姿各不相同”。正确的数据采集方式是让相机在标定板阵列前移动起来变换高度、角度、旋转姿态录制至少几十帧画面。每一帧画面里都可以有9块板子对每一帧执行复制遮挡最终得到的数据量是“原始帧数×9”。比如你录了30帧处理后就得到270张单板图其中有30个不同的真实视角每个视角贡献9个不同位置的板子观测。这样的数据对Kalibr来说才是充分且良性的。4.2 复制出的同一时刻多张图内参标定能忍IMU联合标定不能忍这个问题是我后来做相机IMU联合标定时踩到的。如果你按照前面的方法把每一帧图像复制成9份并写入bag那么这个bag里有9张图像的时间戳是同一个值只是图像内容不同。在内参标定这个场景下Kalibr的优化器会把每一帧都当作独立观测时间戳重复最多就是让某些帧被重复利用结果还能接受。但如果你接下来要跑kalibr_calibrate_imu_camera做IMU联合标定同一个时间戳出现9张图像就是灾难。联合标定的核心是用IMU轨迹辅助约束相机位姿它严格要求每一帧图像对应一个唯一的时间戳同一时刻出现多帧图像会让状态估计器无法判断该用哪一帧基本上直接报错或者结果发散。所以我单独提醒一句如果后续要做IMU联合标定不要用“复制”策略改用“轮换遮挡”策略。具体做法是第1帧原始多板图只保留第1块板子第2帧只保留第2块板子以此类推循环往复。这样每帧只输出一张单板图时间戳和原始帧一一对应既避开了多板检测问题又保证了IMU联合标定要求的时间戳唯一性。代价是单块板子的观测帧数比复制策略少数据采集时建议多录一些帧。4.3 常见问题速查表现象可能原因应对办法Kalibr完全检测不到板子target yaml里的行列数、tag_size、tag_spacing有误逐项核对板子实际规格尤其是tag_spacing是比例值不是像素值检测到了但重投影误差很大板子物理尺寸量错用游标卡尺重新测量注意毫米和米的单位换算偶发出现误检遮挡区域填充黑色导致边界干扰改为填充白色检查掩膜外扩是否过大标定结果每次跑都不一样数据视角不足增加拍摄帧数确保多角度、多距离、多旋转bag里时间戳乱写入bag时使用了重复时间戳改成严格递增的伪时间戳固定10Hz或20HzIMU联合标定报错同一时间戳存在多帧图像改用轮换遮挡策略不要复制全部板子5. 这套思路还能顺手解决哪些场景5.1 多相机内参标定里的多板场景除了单相机这套预处理方法也能用在多相机系统里。比如双目相机两个相机同时拍摄同一个9板阵列画面两个镜头的观测数据可以分别用复制遮挡策略处理成各自的单板图序列然后先单独标定左目和右目的内参再通过Kalibr做双目标定求外参。好处是两个相机标定时使用的是同一套板子布局外参标定的基础坐标系保持一致不会因为分别标定时的板子摆放不同而产生额外的坐标系偏差。5.2 别把九点标定和相机内参标定混在一起这里再顺手聊一下经常被混淆的“九点标定”和“标定板标定”。九点标定通常指在平面上移动末端执行器到9个已知位置用来求解像素坐标到机器人基础坐标系的2D单应变换它本质上是在求一个平面映射矩阵解决的是“图像坐标怎么换算成机械臂坐标”的问题不需要相机内参也不需要镜头畸变模型。而Kalibr标定板标定解决的是“三维空间点怎么投影到像素坐标”的问题它是相机模型本身的事情。很多做视觉抓取项目的朋友会把这两个概念混在一起遇到项目的时候先分清楚你要标的是哪个不然很容易把数据格式搞错。5.3 自动化pipeline扩展如果你经常要做这类标定完全可以把前面三步串成一个自动化脚本输入是多板图像序列和标定板坐标文件输出是Kalibr能直接消费的bag。后续换标定板布局、换相机分辨率、换板子数量只要更新坐标文件和参数配置文件就行。我在实际项目中会把这个pipeline做成一键执行命令团队里其他同事不需要理解Aprilgrid检测细节也能完成标定。这也是数据处理思路的一个典型收益把复杂问题标准化之后工程成本会大幅下降。最后再分享一个经验无论预处理做得多漂亮动手跑全量Kalibr之前一定要抽几帧处理后的单板图用眼睛确认一下画面里确实只剩一块板子、板子区域完整、没有相邻板子的边缘残留。这一步花不了两分钟却能帮你省下反复调Kalibr参数的半小时。工具罢工的时候先检查输入数据是不是符合工具的“世界观”这永远是性价比最高的排障路径。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价