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、焦距、或者使用了内置的非理想镜头模型时,真实渲染出来的图像和标准针孔模型的偏差会变得不可忽略。
我实际遇到的例子是:设置 fovy=60,分辨率 640x480,按公式算出 fx=fy≈554,主点应该是 (320, 240)。但用标定出来的结果是 fx≈558,cy≈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), dtype=np.uint8) for r in range(rows): for c in range(cols): if (r + c) % 2 == 0: img[r*square_px:(r+1)*square_px, c*square_px:(c+1)*square_px] = 255 Image.fromarray(img).save("checker.png")标定时的“内角点”指黑白格之间的交点,10x7 的方格阵列会得到 9x6 个内角点。为什么常用 9x6 而不是更多?因为 OpenCV 的findChessboardCorners对内角点数的组合识别最稳定,太少则标定约束不足,太多则图像分辨率不够时亚像素容易失败。仿真里虽然不愁分辨率,但保持一个真实世界常用的规格,后续和实拍数据对比时更有参考价值。
2.2 XML 场景:板、材质与相机
棋盘纹理生成后,把它放进 MuJoCo 场景资源目录,然后在 XML 中新建一个标定板 body。我这里以 square_size=0.03m 为例,那么板子实际物理尺寸就是 0.30m x 0.21m。MuJoCo 的 box geom 的 size 是半尺寸,所以写一半的数值:
<mujoco> <asset> <texture name="checker" type="2d" file="checker.png" gridsize="1 1"/> <material name="board_mat" texture="checker" texuniform="false"/> </asset> <worldbody> <body name="calib_board" pos="0 0 0" euler="0 0 0"> <geom name="board_surface" type="box" size="0.15 0.105 0.004" material="board_mat"/> <geom name="board_back" type="box" size="0.16 0.115 0.003" pos="0 0 -0.007" rgba="0.2 0.2 0.2 1"/> </body> <camera name="calib_cam" pos="0 0 0" fovy="60"/> </worldbody> </mujoco>两个容易踩的地方:
一是 material 的纹理映射方向。MuJoCo 把整张纹理拉伸到几何体表面,如果纹理贴上去旋转了 90 度也没关系,因为标定算法只关心角点位置,不关心格子的“朝向”。二是板的背面加了一块深色底板,这看起来是小事,实际上对findChessboardCorners帮助很大。仿真渲染里如果棋盘白色格子和白色背景混在一起,边缘检测容易把背景物体也卷进来,导致角点检测失败。深色底板相当于给棋盘加了黑色保护边。
2.3 为什么不用一排排几何体拼棋盘格
有人会把 70 个白色小方块都建模成独立 geom,这样也能形成棋盘效果,但有两个坏处:
第一,几何体数量暴增,场景加载和碰撞检测都变慢;第二,几何体之间的边缘在渲染时会产生缝隙和抗锯齿过渡,黑白边界不再锐利,角点检测的亚像素精度会明显下降。纹理贴图则天然是连续的,只有黑白交界一个对比面,对检测器极其友好。记住一个原则:标定板任何可能损害角点锐度的地方都要避免,纹理这种方案是经过验证的。
3. 多视角图像采集:让相机围绕标定板画一个观测半球
3.1 采样位姿生成与约束
标定的核心逻辑是:用同一台相机,从不同位置、不同角度拍摄同一块板,然后利用“已知的世界坐标格点”和“观测到的像素坐标”来反推内参。所以位姿采样的质量直接决定标定精度。真实世界标定要手动举着标定板,仿真里我们可以把这件事变成公式化操作。
我的做法是:把相机放在以标定板中心为球心、半径固定的半球面上,方位角均匀走一圈,俯仰角也刻意拉开差距。同时要避免几个不好的位姿:相机离板面太近导致棋盘溢出画面,太远导致格子小到亚像素都救不回来;相机正对板面中心会让所有图像的位姿非常相似,约束不足。更强的做法是让板子在画面中占约 1/3 到 2/3 的面积,并且每次拍摄时棋盘平面与像平面有一定夹角。
用下面这个函数生成观测点:
import numpy as np def generate_views(radius=0.8, n_azim=6, n_tilt=3, center=np.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, height=480, width=640) 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, up=np.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(radius=0.8, n_azim=8, n_tilt=3) 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, camera=cam_name) rgb = renderer.render() bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) cv2.imwrite(f"views/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(像素) | 主观评价 |
|---|---|---|
| 6 | 0.37 | 内参不太稳,换个位姿集合结果变化大 |
| 12 | 0.12 | 基本可用,fx/fy 稳定 |
| 20 | 0.06 | 稳定,主点偏差收敛 |
| 30 | 0.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), dtype=np.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, flags=cv2.CALIB_RATIONAL_MODEL ) print("RMS:", ret) print("Intrinsic matrix:\n", mtx) print("Distortion coeffs:", dist.ravel()) np.savez("calib_result.npz", mtx=mtx, dist=dist, rms=ret, image_size=gray.shape[::-1])CALIB_RATIONAL_MODEL会启用 k4、k5 这类高阶径向畸变参数。真实相机标定时这个标志很有用,因为它能覆盖鱼眼镜头。MuJoCo 的普通透视渲染本来畸变接近零,高阶项标出来也接近零,不会破坏主结果;但如果你后续想人为叠加一个真实镜头的畸变模型,从这套标定结果里拿到高阶系数会方便很多。
另一个常纠结的选项是CALIB_FIX_ASPECT_RATIO。这个标志让 fx 和 fy 强制相等,适合传感器像素是严格方形的情况。仿真渲染的像素确实是方形的,理论上可以用。但我在实际测试中发现,强制 fx=fy 会让主点估计更受单个视角影响,反而导致 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配合remap:
K = mtx.copy() # 你标定出来的仿真内参 D = np.array([k1, k2, p1, p2], dtype=float) # 期望叠加的畸变参数 mapx, mapy = cv2.initUndistortRectifyMap(K, D, None, K, (640, 480), cv2.CV_32FC1) distorted = cv2.remap(rgb_clean, mapx, mapy, interpolation=cv2.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), axis=1).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, camera=cam) rgb = renderer.render() # 保存为 f"multi/{cam}_view_{idx:02d}.png"每个相机内参独立标定,外参则可以通过标定板的世界坐标直接取得,这样一套流程同时拿到多相机系统的内外参,对后续手眼标定也非常有用。
最后分享一个我自己的小习惯:标定结果不要只存 K 和 dist,把每张图的 RMS、图像数量、分辨率、棋盘格尺寸全部记录到同一个 npz 文件里。因为仿真场景改分辨率、换相机、改棋盘格物理尺寸都是几秒钟的事,每次改完都必须重新标定,留一份完整的“标定元数据”能帮你快速排查到底是哪个环节变了导致参数跳了。MuJoCo 里的题目是固定参数、固定环境,但只有把每一次标定的上下文都记清楚,这套方法才能真正变成你在仿真视觉流程里的可靠底座。