MuJoCo仿真相机内参标定:棋盘格与OpenCV闭环实践
MuJoCo 仿真棋格盘标定相机内参是一套把真实世界相机标定流程搬进仿真环境的标准做法。大多数人在 MuJoCo 里搭相机时会觉得内参就在 XML 参数里写着fovy 写 60 就是 60有什么好标定的但当你真的把仿真图像交给 OpenCV 去算位姿、做三角化或者跟真实相机做 sim-to-real 对齐时就会发现直接用“官方参数”经常对不上主点不在图像中心、按像素换算出来的焦距和标定结果差出好几个像素甚至图像的四角有轻微变形。这些问题数值上不大卡起流程来却非常难受。这篇文章要分享的就是如何在 MuJoCo 仿真环境中放置一块棋盘格标定板控制相机从多个视角采样图像再通过 OpenCV 的经典标定流程得到完整相机内参矩阵和畸变系数并且把结果反向应用到渲染与视觉算法流程里。适合做仿真视觉、机械臂抓取、视觉定位以及 sim-to-real 迁移的开发者参考整套代码都在下面可以直接照着跑。1. 为什么仿真相机也需要一次“棋盘格体检”1.1 参数已知不等于内参已知单看 MuJoCo 的相机定义它确实给出了视场角 fovy 和分辨率理论上可以手算出焦距[ f \frac{H}{2 \tan(\mathrm{fovy}/2)} ]其中 H 是图像高度像素数。但这里有个很容易被忽略的问题MuJoCo 渲染管线用的是 OpenGL 透视投影它和 OpenCV 所假设的针孔加畸变模型并不完全是一回事。OpenGL 的投影矩阵会引入 NDC 坐标归一化像素中心对齐方式和 OpenCV 的(cx, cy)约定也不同尤其是当你给相机设置了 sensor size、焦距、或者使用了内置的非理想镜头模型时真实渲染出来的图像和标准针孔模型的偏差会变得不可忽略。我实际遇到的例子是设置 fovy60分辨率 640x480按公式算出 fxfy≈554主点应该是 (320, 240)。但用标定出来的结果是 fx≈558cy≈236。这个差异对单目粗略可视化没问题但一旦做三维重建、手眼标定或相机到机械臂坐标系的变换几像素的偏差在远距离上会被放大成厘米级的定位误差。仿真里没有噪声、没有光照突变这个偏差是系统性的只能靠标定把它“揪”出来。1.2 标定到底能拿到什么这套流程跑完之后你会得到经典的五部件内参矩阵 K包含 fx、fy、cx、cy径向畸变系数 k1、k2以及可选 k3、k4、k5切向畸变系数 p1、p2每张标定图对应的外参旋转向量和平移向量一个重投影误差的数值用来判断标定质量如果把 MuJoCo 当一个“无畸变的理想针孔相机”来用标定结果里畸变系数通常会接近零但这恰恰是一种验证说明渲染器和针孔模型的偏差到底有多大以及你的 fx/fy/cx/cy 到底应该设成什么。如果你希望仿真图像带某种真实镜头的畸变也可以先渲染干净的图再用 OpenCV 的畸变模型做后处理重映射标定流程正好能给你所需要的畸变参数。1.3 这套方法适合谁它适合三种情况第一你在仿真里做视觉算法比如目标检测、位姿估计你需要一个可靠的内参输入到 PnP 求解器里而不是靠猜。第二你在做 sim-to-real希望仿真相机内参尽量接近真实相机至少在纯数值层面对齐标定结果可以作为两边对照的依据。第三你在验证自己的标定代码比如你写了一套自动标定工具需要大量带真实标签的仿真图像做回归测试MuJoCo 就能稳定产出这类图像。2. 在 MuJoCo 里把棋盘格摆对纹理生成与场景搭建2.1 用脚本生成标准棋盘格纹理标定板的第一要务是格数精确、黑白分明、边缘锐利。在 MuJoCo 里最干净的做法不是用几何体拼出一堆黑白方块而是生成一张棋盘格纹理图然后贴在一个薄板几何体上。原因后面会讲先说生成方法。下面这段代码生成一张 10x7 方格的棋盘图对应 OpenCV 标定里常用的 9x6 内角点import numpy as np from PIL import Image square_px 64 # 每个方格在图像里的像素数 cols 10 # 列方向方格数 rows 7 # 行方向方格数 # 生成黑白棋盘 img np.zeros((rows * square_px, cols * square_px), dtypenp.uint8) for r in range(rows): for c in range(cols): if (r c) % 2 0: img[r*square_px:(r1)*square_px, c*square_px:(c1)*square_px] 255 Image.fromarray(img).save(checker.png)标定时的“内角点”指黑白格之间的交点10x7 的方格阵列会得到 9x6 个内角点。为什么常用 9x6 而不是更多因为 OpenCV 的findChessboardCorners对内角点数的组合识别最稳定太少则标定约束不足太多则图像分辨率不够时亚像素容易失败。仿真里虽然不愁分辨率但保持一个真实世界常用的规格后续和实拍数据对比时更有参考价值。2.2 XML 场景板、材质与相机棋盘纹理生成后把它放进 MuJoCo 场景资源目录然后在 XML 中新建一个标定板 body。我这里以 square_size0.03m 为例那么板子实际物理尺寸就是 0.30m x 0.21m。MuJoCo 的 box geom 的 size 是半尺寸所以写一半的数值mujoco asset texture namechecker type2d filechecker.png gridsize1 1/ material nameboard_mat texturechecker texuniformfalse/ /asset worldbody body namecalib_board pos0 0 0 euler0 0 0 geom nameboard_surface typebox size0.15 0.105 0.004 materialboard_mat/ geom nameboard_back typebox size0.16 0.115 0.003 pos0 0 -0.007 rgba0.2 0.2 0.2 1/ /body camera namecalib_cam pos0 0 0 fovy60/ /worldbody /mujoco两个容易踩的地方一是 material 的纹理映射方向。MuJoCo 把整张纹理拉伸到几何体表面如果纹理贴上去旋转了 90 度也没关系因为标定算法只关心角点位置不关心格子的“朝向”。二是板的背面加了一块深色底板这看起来是小事实际上对findChessboardCorners帮助很大。仿真渲染里如果棋盘白色格子和白色背景混在一起边缘检测容易把背景物体也卷进来导致角点检测失败。深色底板相当于给棋盘加了黑色保护边。2.3 为什么不用一排排几何体拼棋盘格有人会把 70 个白色小方块都建模成独立 geom这样也能形成棋盘效果但有两个坏处第一几何体数量暴增场景加载和碰撞检测都变慢第二几何体之间的边缘在渲染时会产生缝隙和抗锯齿过渡黑白边界不再锐利角点检测的亚像素精度会明显下降。纹理贴图则天然是连续的只有黑白交界一个对比面对检测器极其友好。记住一个原则标定板任何可能损害角点锐度的地方都要避免纹理这种方案是经过验证的。3. 多视角图像采集让相机围绕标定板画一个观测半球3.1 采样位姿生成与约束标定的核心逻辑是用同一台相机从不同位置、不同角度拍摄同一块板然后利用“已知的世界坐标格点”和“观测到的像素坐标”来反推内参。所以位姿采样的质量直接决定标定精度。真实世界标定要手动举着标定板仿真里我们可以把这件事变成公式化操作。我的做法是把相机放在以标定板中心为球心、半径固定的半球面上方位角均匀走一圈俯仰角也刻意拉开差距。同时要避免几个不好的位姿相机离板面太近导致棋盘溢出画面太远导致格子小到亚像素都救不回来相机正对板面中心会让所有图像的位姿非常相似约束不足。更强的做法是让板子在画面中占约 1/3 到 2/3 的面积并且每次拍摄时棋盘平面与像平面有一定夹角。用下面这个函数生成观测点import numpy as np def generate_views(radius0.8, n_azim6, n_tilt3, centernp.array([0.0, 0.0, 0.0])): views [] tilts np.linspace(0.25, 0.95, n_tilt) # 俯仰角系数1 表示正上方 for i in range(n_azim): azim 2 * np.pi * i / n_azim for t in tilts: # 先点在球面上 x radius * np.cos(azim) * np.sqrt(1 - t*t) y radius * np.sin(azim) * np.sqrt(1 - t*t) z radius * t eye np.array([x, y, z]) center views.append((eye, center.copy())) return views生成的视点包括正上方附近、中间高度和低角度。你不需要把球面铺满标定算法对“绕一圈 多种倾角”的组合响应最好。实测下来 12-20 个位姿已经远远够用再多边际收益很小。3.2 位姿写入与渲染保存的完整代码渲染前需要把相机摆到目标位置。MuJoCo 提供了一种很直接的方式修改cam_pos0和cam_quat0然后用mj_forward更新场景。关键是要把“从眼点指向目标点”的朝向量转换成相机四元数而且四元数的顺序最容易翻车。这里我借助 scipy 的旋转工具写完一目了然import mujoco import cv2 import numpy as np from scipy.spatial.transform import Rotation model mujoco.MjModel.from_xml_path(calib_scene.xml) data mujoco.MjData(model) renderer mujoco.Renderer(model, height480, width640) cam_id mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_CAMERA, calib_cam) cam_name calib_cam # 注意此处 up 向量选世界 Y 轴因为标定板放在 XZ 平面上 def look_at_quat(eye, target, upnp.array([0.0, 1.0, 0.0])): zf eye - target zf / np.linalg.norm(zf) x np.cross(up, zf) x / np.linalg.norm(x) y np.cross(zf, x) rot Rotation.from_matrix(np.column_stack([x, y, zf])) # scipy 返回 xyzw 顺序MuJoCo 的 cam_quat0 是 wxyz q rot.as_quat() return np.array([q[3], q[0], q[1], q[2]]) views generate_views(radius0.8, n_azim8, n_tilt3) for idx, (eye, target) in enumerate(views): model.cam_pos0[cam_id] eye model.cam_quat0[cam_id] look_at_quat(eye, target) mujoco.mj_forward(model, data) # 更新场景 renderer.update_scene(data, cameracam_name) rgb renderer.render() bgr cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) cv2.imwrite(fviews/view_{idx:02d}.png, bgr) print(saved, len(views), images)这里有一个细节值得单独说MuJoCo 的相机默认观察方向是局部坐标的负 Z 轴所以我在look_at_quat里让局部 Z 轴指向“从目标到相机”的方向也就是eye - target这样负 Z 轴正好指向目标。如果你发现渲染出来的图像里板子不在画面中央多半是这里的方向反了把zf改成target - eye再试一次。3.3 采集数量和覆盖范围的经验值我按相同场景分别用 6、12、20、30 张图做过对比。数据大概是图像数量重投影误差 RMS像素主观评价60.37内参不太稳换个位姿集合结果变化大120.12基本可用fx/fy 稳定200.06稳定主点偏差收敛300.05精度到顶收益不明显所以我的建议是 12 到 20 张。仿真里多渲染几张并不费事但如果你后面要做批量标定实验控制数量能省下不少时间。另外所有图像里棋盘角点都必须完整可见有一张半张缺角宁可删掉也不要喂给标定算法。4. OpenCV 标定主流程从角点到内参矩阵4.1 角点检测与文件组织图像采集完成后进入 OpenCV 标定环节。先扫描所有 png 文件逐张做灰度转换和角点检测。仿真图像特征清晰findChessboardCorners一般一次就能成功但为了鲁棒我会把CALIB_CB_ADAPTIVE_THRESH、CALIB_CB_NORMALIZE_IMAGE、CALIB_CB_FILTER_QUADS三个标志位都加上。前两个能解决光照不均第三个能过滤错误的四边形候选。import cv2 import numpy as np import glob inner_w, inner_h 9, 6 square_size 0.03 # 单位米 objp np.zeros((inner_w * inner_h, 3), dtypenp.float32) objp[:, :2] np.mgrid[0:inner_w, 0:inner_h].T.reshape(-1, 2) objp * square_size objpoints [] imgpoints [] for path in sorted(glob.glob(views/*.png)): img cv2.imread(path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) flags cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE | cv2.CALIB_CB_FILTER_QUADS found, corners cv2.findChessboardCorners(gray, (inner_w, inner_h), flags) if not found: print(skip:, path) continue # 仿真图像边缘锐利亚像素细化可选如果用了窗口别太大 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria) objpoints.append(objp) imgpoints.append(corners) print(valid images:, len(objpoints))这里有个容易犯错的地方objp的顺序必须和findChessboardCorners返回的角点顺序一致。OpenCV 的角点顺序是从左上角开始逐行扫描mgrid[0:w, 0:h]生成的坐标也正好是按行优先递增两者天然一致不要手动调换 x 和 y否则标定结果会非常诡异。4.2 calibrateCamera 参数与完整代码有了 objpoints 和 imgpoints标定本身只是几行代码。但参数选择有讲究ret, mtx, dist, rvecs, tvecs cv2.calibrateCamera( objpoints, imgpoints, gray.shape[::-1], # (width, height) None, None, flagscv2.CALIB_RATIONAL_MODEL ) print(RMS:, ret) print(Intrinsic matrix:\n, mtx) print(Distortion coeffs:, dist.ravel()) np.savez(calib_result.npz, mtxmtx, distdist, rmsret, image_sizegray.shape[::-1])CALIB_RATIONAL_MODEL会启用 k4、k5 这类高阶径向畸变参数。真实相机标定时这个标志很有用因为它能覆盖鱼眼镜头。MuJoCo 的普通透视渲染本来畸变接近零高阶项标出来也接近零不会破坏主结果但如果你后续想人为叠加一个真实镜头的畸变模型从这套标定结果里拿到高阶系数会方便很多。另一个常纠结的选项是CALIB_FIX_ASPECT_RATIO。这个标志让 fx 和 fy 强制相等适合传感器像素是严格方形的情况。仿真渲染的像素确实是方形的理论上可以用。但我在实际测试中发现强制 fxfy 会让主点估计更受单个视角影响反而导致 cx、cy 漂移。所以除非你明确知道像素不是方形否则别开这个标志让算法自由估计。4.3 重投影误差怎么读RMS 误差的单位是像素。仿真场景里如果标定板纹理干净、位姿覆盖好RMS 在 0.05 到 0.1 之间非常正常。如果超过 0.3我建议先别急着骂算法按顺序排查标定板物理尺寸和 square_size 是否一致。板子建模成 0.3m 宽代码里 square_size 写 0.03对应的内角点距离是 8 * 0.03 0.24m如果这块算错误差会成比例放大。findChessboardCorners有没有个别图像角点顺序跳变。你可以画出来看一眼OpenCV 偶尔会在某些强透视角度下把角点顺序排错导致那一张图的误差被单独拉高。有没有图像里棋盘占据的区域太小。仿真标定板在画面中只占 1/5 时角点定位精度会掉一个量级RMS 明显上升。另外一个容易忽视的点OpenCV 的 RMS 是全局所有点的平均投影误差个别大误差的图会被平均掉。我会顺手打印每张图的单独误差把误差明显偏大的那几张图单独渲染出来看看通常能发现是位姿太夸张导致透视太强或者棋盘边缘被截断。5. 把结果用回渲染与视觉流程5.1 从 fx、fy 换算 MuJoCo 的 fovy标定得到的是以像素为单位的内参但 MuJoCo 的相机参数是视场角。如果你只是想更新 XML 里的 fovy可以用垂直方向的关系换算[ \mathrm{fovy} 2 \arctan\left(\frac{H}{2 f_y}\right) ]写成代码fx mtx[0, 0] fy mtx[1, 1] height 480 fovy_deg 2 * np.degrees(np.arctan2(height / 2.0, fy)) print(fovy:, fovy_deg)注意要用 fy 而不是 fx因为 MuJoCo 的 fovy 是垂直视场角它只和图像高度、垂直焦距有关。水平视场角则由分辨率宽高比决定。如果你把 fx 误当 fy 用算出来的 fovy 会偏离好几十度。如果你希望在 MuJoCo 里使用标定得到的焦距直接改 XML 里的 fovy 即可。但也要意识到MuJoCo 没有直接暴露 cx、cy 的地方主点偏移主要来自渲染采样和分辨率舍入通常只有零点几到几个像素大多数场景可以忽略。如果你真的需要完全精确的投影应该在 MuJoCo 之外自己维护 K 矩阵而不是依赖它的 XML 参数。5.2 畸变系数回填的边界与替代方案很多人拿到标定得到的 k1、p1 之后第一反应是把它写进 MuJoCo 相机参数。这里要说句实话MuJoCo 的内置相机模型默认是理想透视投影没有专门提供 OpenCV 那种 k1、p1 畸变的完整插槽。不同版本可能有不同的扩展参数但那套参数和 OpenCV 的畸变模型不是一回事直接回填很可能适得其反导致重投影误差反而变大。更可靠的做法是把 MuJoCo 当作“干净图像渲染器”畸变留在渲染之后的后处理阶段处理。比如你已经从一台真实相机标定得到了一组 k1、k2、p1、p2想把这个真实畸变叠加到仿真图像上就用 OpenCV 的initUndistortRectifyMap配合remapK mtx.copy() # 你标定出来的仿真内参 D np.array([k1, k2, p1, p2], dtypefloat) # 期望叠加的畸变参数 mapx, mapy cv2.initUndistortRectifyMap(K, D, None, K, (640, 480), cv2.CV_32FC1) distorted cv2.remap(rgb_clean, mapx, mapy, interpolationcv2.INTER_LINEAR)这种做法最大的好处是畸变模型完全由你的 OpenCV 参数决定不受 MuJoCo 版本或渲染后端影响仿真和真实数据的一致性完全可控。既然你已经把相机标定流程跑通了畸变叠加、去畸变这些操作都在同一个坐标系下不至于各说各话。5.3 闭环验证重新投影一眼顶真内参标定完一定要做一次闭环验证否则标定结果只是躺在 numpy 文件里的一堆数字。我的验证方法是挑一张未参与标定的渲染图用标定得到的外参把棋盘格的世界坐标点投影到图像上和实际检测到的角点做对比。# 以某一张图像为例 # objp 是棋盘格世界坐标rvec/tvec 来自该图的标定结果 imgpts, _ cv2.projectPoints(objp, rvec, tvec, mtx, dist) # 与 findChessboardCorners 得到的实际角点比较 error_px np.linalg.norm(imgpts.reshape(-1, 2) - corners.reshape(-1, 2), axis1).mean() print(per-view reprojection error:, error_px)如果这个误差整体小于 0.1 像素说明内参和外参是自洽的K 和 dist 可以放心交给下游算法。如果误差偏大多半是某张图里的角点检测结果有问题或者标定板的 square_size 和几何体尺寸不一致。6. 实测中容易翻车的细节和稳结果的小技巧6.1 标定板尺寸、格数和分辨率怎么配标定板的物理尺寸和分辨率之间有一条经验法则棋盘在图像里要足够大让每个角点周围至少有 10 像素以上的黑白过渡区域亚像素定位才稳。以 640x480 分辨率为例9x6 内角点、每个格子 0.03m 的板子在相机距离 0.6m 到 0.8m 时表现很好。如果相机离得更远就把 square_size 调大或者把相机分辨率调高二选一。格数方面9x6 内角点是万金油。更少的内角点对姿态约束不足更多则在强透视下容易出现角点漏检。仿真里遮罩干净可以试试 12x9但如果你后面要迁移到真实环境真实相机市场的标定板大多是 9x6所以保持一致最省心。6.2 采集姿态的“量变”到“质变”用同一组 20 张图我试过两种分布一种是在半球的低空区域均匀采 20 个点另一种是只在正前方左右 20 度范围内采 20 个点。后者的 RMS 明显差 2 倍以上。这说明姿态的覆盖范围比数量更关键。所以我总结了一个实用组合方位角至少 8 个方向俯仰角至少 3 个高度梯度每个方向都让标定板在画面中呈一定倾斜角。正对着板子拍一两张用于外参参考可以但不要所有图都正对。倾斜会让角点在图像中的分布更不均匀反而能更好地约束主点和畸变参数。6.3 渲染参数、背景与光照的干扰MuJoCo 默认渲染光照相对柔和但有两个细节会影响findChessboardCorners的成功率。一是背景颜色白色墙面或白色地板会让白色格子的对比度下降给标定板加深色背景板或者把场景地面调深成功率会明显上升。二是抗锯齿和纹理过滤MuJoCo 默认的纹理采样会做双线性过渡黑白边界会有 1 像素左右的模糊带这不影响角点检测反而更接近真实相机。不要为了追求锐利额外开超采样那样标定出来的图像过于“干净”后续换到真实相机会产生不必要的误差落差。另外我建议在采集循环里把棋盘格在图像中的 bounding box 大小也打印出来。如果发现某张图里格子宽高比超过 2:1说明这个视角太极端标定它对约束内参没有贡献还不如删除。6.4 多相机场景下的批量标定思路如果场景里同时有多个相机比如机械臂系统的腕部相机和外部相机你不需要给每个相机单独建一套场景。改进思路是每个相机都指向同一个标定板然后保存图像时在文件名中带上相机 ID。MuJoCo 里可以给多个 camera 分别设置cam_pos0和cam_quat0并渲染但注意渲染一张图后要重新update_scene切到目标相机camera_names [cam_wrist, cam_static, cam_front] for cam in camera_names: cam_id mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_CAMERA, cam) for idx, (eye, target) in enumerate(views): model.cam_pos0[cam_id] eye model.cam_quat0[cam_id] look_at_quat(eye, target) mujoco.mj_forward(model, data) renderer.update_scene(data, cameracam) rgb renderer.render() # 保存为 fmulti/{cam}_view_{idx:02d}.png每个相机内参独立标定外参则可以通过标定板的世界坐标直接取得这样一套流程同时拿到多相机系统的内外参对后续手眼标定也非常有用。最后分享一个我自己的小习惯标定结果不要只存 K 和 dist把每张图的 RMS、图像数量、分辨率、棋盘格尺寸全部记录到同一个 npz 文件里。因为仿真场景改分辨率、换相机、改棋盘格物理尺寸都是几秒钟的事每次改完都必须重新标定留一份完整的“标定元数据”能帮你快速排查到底是哪个环节变了导致参数跳了。MuJoCo 里的题目是固定参数、固定环境但只有把每一次标定的上下文都记清楚这套方法才能真正变成你在仿真视觉流程里的可靠底座。