多标定板同框Kalibr标定失败?原图复制加遮挡预处理方案三步搞定
接手过一批相机内参标定的活图库里每张图都同时出现了9块标定板。我当时第一反应是怀疑采集数据的人搞错了后来才知道人家是故意的——标定板铺满整个视场尤其是让图像边缘也有角点这样畸变模型才约束得住。结果数据丢给Kalibr之后麻烦来了要么检测报错要么只检测到其中一块板偶尔还会把不同板的角点混在一起出来的内参完全没法看。改Kalibr源码成本高还要跟着版本维护不是首选。后来我试了一圈发现“原图复制遮挡”这套预处理方案能完美绕开这个坑不需要动Kalibr一行代码就能把9块板的内参标定跑完。这篇文章就把这套方案的思路、脚本和踩坑记录完整写出来给同样遇到多标定板同框的朋友一个参考。1. 为什么9块标定板同框会让Kalibr“翻车”1.1 内参标定到底在干什么先把基础对齐。相机内参标定本质上是求解针孔相机模型里的内参矩阵K里面有四个关键量fx、fy归一化焦距、cx、cy主点坐标外加一组畸变系数。理想投影关系是空间点经过外参变换到相机坐标系再透视投影到归一化平面最后乘内参矩阵得到像素坐标。畸变则是在归一化平面上做的非线性扰动常见的有radtan径向切向和equi等距两类模型。标定过程就是让算法不断调整内参和畸变参数使得标定板角点的重投影误差最小。关键点在于单张图像上空间点到像素的映射有严重的耦合关系。比如fx和相机到标定板的距离相互耦合仅靠一张图内参的解空间非常大数值上极其不稳定。所以理论上需要多视角图像让同一个角点在不同视角下反复出现才能把内参唯一确定下来。这就是为什么Kalibr要求输入“一组图像序列”而不是单张图。这也是后面很多问题的根源有人以为一张图里放9块板等效于9个视角其实不是。9块板共享同一个相机位姿它们只是同一视角下的9组不同空间平面对内参的约束虽然比单块板多但远达不到多视角解耦的效果。畸变参数尤其依赖图像边缘的特征9块坂足够覆盖边缘区域这是好事但前提是算法得先把这些板的角点全部、正确地提取出来。1.2 Kalibr检测标定板的机制与多板同框的痛点Kalibr的内参标定流程大致是从rosbag里读图像序列用内置的检测器提取标定板角点然后对检测到的角点做多帧联合BA优化。它的检测器对“单张图像里只有一块完整标定板”的场景优化得比较好因为棋盘格检测和AprilTag检测都依赖于全局的梯度特征搜索。当一张图里同时出现多块板时问题就暴露了。我实测遇到的情况主要有三种第一种Kalibr只检测出其中一块板其他板被忽略相当于9块板只用了1块标定结果覆盖不到图像边缘畸变参数估计自然偏差很大第二种检测器把不同板的角点混在一起当成一个棋盘格去解算位姿结果直接解出个乱七八糟的单应矩阵重投影误差爆表第三种有时候干脆直接检测失败Kalibr报错说这一帧没有检测到target然后跳过或者整个标定流程中断。从原理上讲Kalibr的检测器缺少“多目标筛选与分组”的逻辑。它默认图像里出现的目标就是待标定的那一块板所以面对多目标时它不会做聚类、不会按板分组只会强行用全局特征去匹配一个目标。这不能全怪Kalibr因为它的应用场景里标定数据通常就是单块板在视野中移动多板同框本来就不是它的设计目标。1.3 改源码是下策数据预处理才是上策遇到这个情况很多人第一反应是改Kalibr源码比如增加多板检测的分组逻辑或者修改角点提取的筛选策略。我身边确实有人这么干过效果先不说维护成本就很头疼Kalibr本身更新不算频繁你fork一份源码改完团队里其他人拉代码、编依赖、对齐版本每一步都可能踩坑。而且改检测算法是一个“牵一发动全身”的事棋盘格检测里加一个分组模块AprilTag检测可能又出问题测试工作量不小。我当时选了另一条路不碰Kalibr只在你喂给它的数据上做手脚。具体来说就是“原图复制遮挡”——把一张包含9块板的原始图像复制成9份每一份只保留其中一块标定板其余区域全部遮掉然后把这些处理后的图像组织成序列喂给Kalibr。这个方案不用改任何工具链换个新版本Kalibr照样能用而且思路可以复制到其他标定工具上。2. 原图复制遮挡思路拆解与可行性分析2.1 为什么不能直接把9块板裁成9张图最容易想到的做法是把大图裁剪成9张小图每张小图只包含一块板再分别标定。这个做法看着省事实际上有一个致命问题裁剪会改变图像坐标系。相机内参里的cx、cy是主点在“原始图像分辨率下的像素坐标”。你一旦裁剪图像的左上角原点就变了主点的参考系也跟着漂移。比如一块板位于原图右侧裁剪后它的中心在裁剪图里可能接近图像中心但这块区域实际上离相机光轴很远畸变很大。你用裁剪图标出来的内参是“裁剪后图像”的内参不是原始图像的内参。除非你有像素级的坐标换算关系把裁剪图的主点再映射回原图否则数据直接用不了。同理resize缩放也不行。缩放会改变fx、fy和畸变系数的尺度你标完还得做坐标反算中间任何一步计算出错整个标定结果就废了。所以我后来彻底放弃了裁剪方案转向在原图尺寸上做遮挡。2.2 “原图复制遮挡”解决的真正问题遮挡方案的核心是图像尺寸不变像素坐标系不变只是把不关心的区域涂黑保留下标定板所在区域。这样Kalibr看到的每一帧都是一幅“视野里有大量黑色背景但画面中央有一块干净标定板”的正常标定图像。它不需要知道原本还有另外8块板存在也不需要处理多目标分组问题。具体操作上原图复制是这么用的同一张大图先复制9份第1份遮住第2到第9块板第2份遮住第1和第3到第9块板以此类推。9份合在一起信息总量和原图一样但每一份对Kalibr来说都是一个“单板清晰可见”的干净样本。这样就从数据层面把“多板同框”问题无损转化成了“9组单板标定”问题。这套方案之所以可行还有一个前提Kalibr的优化目标函数只依赖检测到的角点坐标不依赖图像的其他区域。你把背景涂黑只要标定板的角点能被正常提取标定方程完全不受影响。黑色背景相当于没有任何特征点参与优化它只是“不存在”而不是“干扰”。这点和裁剪有本质区别裁剪是改变了坐标参考系遮挡只是删除了不关心的信息。2.3 一个必须讲清楚的边界单张9板图救不了内参这里必须泼一盆冷水如果你的数据只有一张包含9块板的图无论做原图复制还是遮挡都不可能得到可靠的内参。原因回到1.1里说的单视角下内参和场景深度的耦合无法解耦9块板虽然提供了更多角点约束但所有角点都来自同一个相机位姿对焦距、主点的约束依旧是病态的。尤其fx、fy的绝对尺度单张图根本无法唯一确定。所以“原图复制遮挡”解决的是“Kalibr在单帧图像中检测多板失败”的问题而不是“单帧数据标定内参”的问题。合理的数据集必须包含至少十几帧不同视角下的9板大图比如相机绕着板阵转一二十度或者手持相机前后左右移动一小段距离同时保证每帧里9块板都在视野内。有了这样的多视角图集再做原图复制遮挡Kalibr才能在足够的信息支撑下收敛到好结果。我在实际项目中一般会采集20到30帧这样的9板图像序列覆盖角度尽量分散然后整套流程跑下来九组内参的一致性相当好fx、fy的波动在0.2%以内。如果只给一张图别浪费时间直接让采集端补数据。3. 实操全流程从多张9板大图到干净的标定序列3.1 先确认数据满足最低要求开工前先检查数据别一上来就写脚本。你需要确认三件事第一图像数量。至少10到20帧9板大图帧间标定板的位姿要有明显变化。如果拍摄时相机和板阵都固定不动拍再多帧都是重复信息标定还是会退化。第二图像格式。尽量使用PNG或BMP无损格式。JPG压缩会引入块效应和角点附近的伪影棋盘格角点检测对这种噪声很敏感标出来的内参精度会打折扣。第三9块板本身。所有标定板最好是同型号同尺寸。如果混用了不同行列数的棋盘格或者一部分是棋盘格一部分是AprilTag后续处理要分组标定会增加不少工作量。同型号可以一次跑完。顺便说一句标定板的物理尺寸一定要量准。棋盘格就量单个方格的边长AprilTag量每个tag的边长和tag间距这些数值要填进Kalibr的target配置里。差一毫米内参可能差出几个像素。3.2 自动生成遮挡图PythonOpenCV脚本确认数据没问题之后写个脚本自动生成遮挡图。脚本的核心流程是对每帧原始大图检测出9块板的角点位置以角点外接矩形为基准往外扩展一点作为保留区域其他区域涂黑。这里有个细节如果直接用全图做棋盘格检测OpenCV在多板场景下同样可能失败。所以我在脚本里引入了ROI感兴趣区域机制。第一帧先用人工方式标出9块板的粗略矩形位置保存在一个列表里后续帧在这个ROI范围内检测角点。因为相邻帧之间板的位置通常不会大幅跳跃ROI可以框得稍微宽松一点检测成功率很高。代码如下可以直接改着用import cv2 import numpy as np from pathlib import Path # 棋盘格内角点数行x列 BOARD_SIZE (11, 8) # 每块板保留区域向外扩展的像素 PAD 30 # 第一帧9块板的粗略ROI格式为 (x0, y0, x1, y1) # 可以用画图工具打开第一帧图像手动读取像素坐标填进来 rois [ (50, 60, 450, 460), (500, 60, 900, 460), # ... 共9个 ] def detect_in_roi(gray, roi, board_size): x0, y0, x1, y1 roi x0 max(x0, 0) y0 max(y0, 0) x1 min(x1, gray.shape[1]) y1 min(y1, gray.shape[0]) roi_gray gray[y0:y1, x0:x1] flags (cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE) ret, corners cv2.findChessboardCorners(roi_gray, board_size, flagsflags) if not ret: return None # ROI内的角点坐标要加回偏移量还原到全图坐标系 corners corners.reshape(-1, 2) np.array([x0, y0]) return corners def make_masked_image(img, corners): c corners.astype(np.int32) x0, y0 c[:, 0].min(), c[:, 1].min() x1, y1 c[:, 0].max(), c[:, 1].max() x0 max(x0 - PAD, 0) y0 max(y0 - PAD, 0) x1 min(x1 PAD, img.shape[1]) y1 min(y1 PAD, img.shape[0]) masked np.zeros_like(img) masked[y0:y1, x0:x1] img[y0:y1, x0:x1] return masked src_dir Path(images) out_dir Path(masked) image_paths sorted(src_dir.glob(*.png)) # groups[i] 存放第i块板在所有帧中的遮挡图 groups [[] for _ in range(len(rois))] for img_path in image_paths: img cv2.imread(str(img_path)) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) for i, roi in enumerate(rois): corners detect_in_roi(gray, roi, BOARD_SIZE) if corners is None: print(f{img_path.name}: board {i} not found) continue masked make_masked_image(img, corners) out_path out_dir / fboard_{i} / img_path.name out_path.parent.mkdir(parentsTrue, exist_okTrue) cv2.imwrite(str(out_path), masked)说几个脚本里的经验点。第一PAD这个参数建议给30到50像素。保留区域比角点外接矩形稍微大一圈能给角点检测器留出上下文避免棋盘格边缘的角点被截断。太小的话最外圈角点可能贴着黑色边界提取精度会下降。第二如果实际拍摄过程中标定板有旋转固定ROI可能会把板的一部分切掉。我建议在第一帧标完ROI后用上一帧检测到的角点坐标动态生成当前帧的ROI比如以上一帧角点中心为中心扩展一个比较大的搜索范围。这样能扛住帧间的缓慢运动。第三遮挡图的背景色默认是纯黑(0,0,0)。如果发现Kalibr在黑色背景上产生误检比如把黑色背景当成AprilTag的一部分把填充色改成灰色(128,128,128)即可。3.3 把遮挡图按组打包成rosbagKalibr的kalibr_calibrate_cameras命令输入是rosbag格式所以遮挡图生成之后需要打包成bag。这里有一个选择按组打包成9个bag分别标定还是合并成一个bag标一次。我的建议是如果9块板是同一型号直接把所有遮挡图合并到一个bag里跑一次就行。Kalibr每一帧只需要检测到一块完整标定板即可它不关心这一帧的板和上一帧的板是不是同一块物理板。你在不同帧之间交替出现第0块板、第1块板Kalibr会把这些板当成“同一target的不同位姿观测”来优化数学上完全成立。我实测过一次跑完内参收敛正常重投影误差和分组跑几乎一样。如果9块板型号不一致那就只能按组打包成9个bag分别标定。打包脚本如下import rosbag import rospy import cv2 from cv_bridge import CvBridge from sensor_msgs.msg import Image from pathlib import Path bridge CvBridge() board_dirs sorted(Path(masked).glob(board_*)) for board_dir in board_dirs: bag_path f{board_dir.name}.bag img_list sorted(board_dir.glob(*.png)) with rosbag.Bag(bag_path, w) as bag: for idx, img_path in enumerate(img_list): img cv2.imread(str(img_path)) msg bridge.cv2_to_imgmsg(img, encodingbgr8) # 时间戳只要求单调递增具体间隔不影响Kalibr msg.header.stamp rospy.Time.from_sec(idx * 0.2) msg.header.frame_id camera bag.write(/cam0/image_raw, msg, tmsg.header.stamp)如果你不想写代码也可以用现成的录包工具把图像序列按顺序播放一遍录进bag。但脚本方式更可控尤其是要控制topic名称和时间戳格式的时候。3.4 配置Kalibr target并逐个标定打包完成之后写target配置文件。棋盘格和AprilTag的YAML格式不一样别搞混。棋盘格配置target_type: checkerboard pattern_dim: [11, 8] square_size: 0.108AprilTag配置target_type: aprilgrid tag_rows: 6 tag_cols: 6 tag_size: 0.088 tag_spacing: 0.3注意pattern_dim对棋盘格来说填的是内角点数不是格子数。比如一张11x8格子的棋盘格内角点是10x7不对11x8是指内角点为11列8行那么实际方块数是12x9。这里容易填错填错的结果是检测永远失败或者标定结果很离谱。接下来跑Kalibr。如果9块板同型号且合成了一个bag直接跑kalibr_calibrate_cameras \ --bag masked_all.bag \ --topics /cam0/image_raw \ --models pinhole-radtan \ --target aprilgrid.yaml如果按组打包就循环跑9次每次换对应的bag文件名。命令结构一样。相机模型的选择方面普通工业相机、手机摄像头一般用pinhole-radtan广角鱼眼镜头用pinhole-equi或者omni-radtan如果是全景相机可能还要考虑ds模型。拿不准的时候先用pinhole-radtan试试如果重投影误差压不下去再换pinhole-equi对比一下。Kalibr标定完会输出一个名为camchain-bag名.yaml的文件里面就是标定结果包括内参矩阵和畸变系数。同时终端会打印重投影误差的统计信息这个数值在后面融合时要重点看。3.5 九组内参的融合与精度验证如果你按组跑了9次会得到9组内参。理论上它们是同一个相机、同一分辨率下的内参应该非常接近。我在项目里9组fx的分布范围基本在0.2%以内cx、cy的波动在1到2像素以内。如果波动远大于这个范围说明某些组的标定数据有问题需要排查而不是直接平均。融合策略是先看每组标定报告里的重投影误差把误差明显偏大的组剔除再看内参与中位数的偏离程度偏离超过1%的组也剔除。剩下的组取平均畸变系数也取平均。精度验证有两种方式。一种是留出几帧没有参与标定的9板大图分别对每块板生成遮挡图然后用最终内参计算重投影误差。另一种是直接把最终内参代回原始图像用全图所有板的角点做一次重投影看误差分布是否均匀。这里要提醒一句原始图像上9块板的角点全检出来做验证如果你用的检测代码还是那套ROI方案验证脚本和标定脚本尽量独立避免用同一个bug互相验证。4. 常见问题与排查技巧实录4.1 生成遮挡图时检测不到角点怎么办这是最常遇到的问题。ROI内检测不到角点先看ROI是不是把板截断了尤其当相机运动幅度大、板移动快的时候固定ROI很容易偏。对策是把ROI放宽或者用上一帧的角点位置动态生成新的ROI。其次看图像质量。过曝和过暗都会导致角点检测失败棋盘格角点的本质是黑白块的梯度变化图像太暗时梯度被压制太亮时黑白块都变成白色梯度消失。可以先做一下直方图均衡化再检测Kalibr本身对光照有一定鲁棒性但预处理阶段还是要保证图像对比度正常。还有一个容易忽略的点如果标定板本身有污渍、折痕、反光角点局部特征会被破坏。这种情况只能换板或者调整拍摄角度程序上很难补救。4.2 Kalibr跑出来重投影误差很大怎么办重投影误差一般压到0.5像素以内算正常如果跑到1像素以上先别急着改参数按顺序排查。第一检查target配置文件里的尺寸是否和实际标定板一致。这是最容易踩的坑棋盘格边长填错一毫米重投影误差能翻好几倍。第二检查相机模型是否选错。用广角镜头却配pinhole-radtan边缘畸变根本拟合不了误差自然大。先把模型换成pinhole-equi试试。第三检查参与标定的有效帧数量。如果某个bag里只有几帧成功检测到板材标定结果大概率是发散的。我一般要求每个bag有效帧数不低于10最好到20以上。第四看看是不是遮挡图的黑色区域影响了检测。之前提到过如果黑色背景导致AprilTag误检可以换成灰色背景再生成一轮遮挡图。4.3 九组内参不一致说明什么九组内参如果差异比较大通常不是融合算法的问题而是数据本身的问题。常见原因有某一块板在视场边缘畸变变形严重检测到的角点精度本身就低或者某一块板在整段序列里的位姿变化很小比如一直正对相机导致这块板的序列对内参的约束很弱还有一种情况是某一块板打印得不好棋盘格不平整。排查方式是单独看那块板的标定结果如果它的重投影误差明显比其他板块高就先在融合时剔除它。实在不放心可以重新采集该板所在区域的数据或者调整板的位置让它在图像里多出现几种姿态。4.4 只有一张9板图还能抢救吗如果手上真的只有一张9板图坦率讲标不出可靠的内参。网上有各种“单图标定”的土办法比如用多块板模拟多视角甚至有人直接把这9块板当作9个不同的单应约束去求解K理论上可能解出一个结果但极不稳定对噪声非常敏感畸变参数的约束也远不够标出来的内参基本不敢用。最靠谱的补救方法就是补拍数据。哪怕只是手持相机站在原地对着板阵连续拍摄一段几秒钟的视频从中抽取20帧左右满足多视角条件后再用这套遮挡方案处理效果会好很多。4.5 常见问题速查表问题可能原因解决方案遮挡图检测不到角点ROI截断/光照异常/板材破损放大ROI、做对比度增强、更换标定板Kalibr报错无有效帧bag路径/topic名错误、target配置不对核对topic名称与target行列数重投影误差大于1像素尺寸填错、模型选错核对square_size换equi模型九组内参差异过大某块板姿态单一或检测精度差剔除异常组补拍该区域数据标定结果发散有效帧太少保证每个bag有效帧不少于10帧黑色背景误检纯黑背景被当成tag黑块遮挡背景改为灰色填充最后再分享一点个人体会这套“原图复制遮挡”的方案我后来在好几个项目里都用过几乎成了处理Kalibr多板问题的标准手段。最大的感悟是很多标定问题表面看是算法不够强实际是数据组织方式不对。Kalibr不让你一次喂9块板那你就分9次喂给它但前提是别破坏图像的几何坐标系。遮挡和裁剪虽然都能让画面“变干净”一个保留了内参参考系一个改变了它差别就在这个细节上。只要多视角数据充足预处理逻辑正确Kalibr给的标定结果足够放心用。希望这篇记录能帮到正在和9块板较劲的你。