仿真环境里做视觉大家最开始都是把相机内参当成已知值直接用真机换成标定板咔咔一通拍到了 MuJoCo 里反而觉得没必要。我第一次在 MuJoCo 里做机械臂抓取仿真时就是这么想的相机位置我设置的fovy 我设置的渲染分辨率我设置的那内参矩阵不就是按针孔模型手算一下的事吗直到我打算把同样的感知模型从仿真搬到真机才发现真机用的内参是 OpenCV 标出来的仿真里如果直接手算一套不相干的参数两个域的图像数据根本对不上。所以我干脆在 MuJoCo 里把整个相机标定流程老老实实走了一遍生成棋盘格、布置多视角相机、渲染图像、OpenCV 找角点、标定内参最后和理论值做对比。事实证明这件事没有想象中那么多此一举因为仿真虽然不存在镜头畸变但渲染管线的投影细节、纹理贴图的方向、分辨率与 fovy 的换算关系任何一环出问题你标出来的内参都会和设计值差一截。这篇文章把完整方法和代码整理出来适合做视觉抓取、机械臂手眼标定、sim-to-real 数据生成或者单纯想验证 MuJoCo 相机模型的朋友直接照抄。1. 为什么要在 MuJoCo 仿真里做相机标定1.1 仿真相机内参是算出来的不是标出来的MuJoCo 的camera标签里没有焦距这个字段也没有主点这个字段。它只给你定义一个视场角 fovy以及相机的位姿。渲染器内部用透视投影把三维世界投到二维图像上最终图像每个像素对应的射线完全由 fovy 和图像分辨率决定。如果要得到一个 OpenCV 风格的内参矩阵K [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]必须自己换算。对于理想针孔相机主点就是图像中心即cx width / 2cy height / 2。像素焦距和垂直视场角的关系是fy (height / 2) / tan(fovy / 2)MuJoCo 的像素是正方形所以fx fy。举个例子渲染分辨率 640x480fovy 设成 60 度那么fy 240 / tan(30°) 415.69。如果你在代码里用水平视场角去算或者把 width 当成高度结果就会完全不同。这个手算值在理论上是真值但渲染管线是否严格按这个模型走只有标定之后才能确认。1.2 哪些场景必须做一遍仿真标定如果你只是拿仿真画面做可视化、看机械臂运动轨迹那确实不必标定。但下面几类需求我建议老老实实跑一遍第一类是 sim-to-real 数据生成。你在真机上用棋盘格标定得到了内参训练感知模型时输入的图像是真实相机采集的。如果仿真的相机内参不设为同一套模型在仿真里学到的尺度、投影关系搬到真机就会失效。第二类是抓取位姿估计的预研。许多抓取算法需要把图像坐标反投影到相机坐标系或者做 3D-2D 对应内参错一像素在 1 米距离外的抓取点可能偏好几毫米。第三类是加入了模拟畸变之后的验证。仿真相机本身没有畸变但你可以用 OpenCV 给渲染图像叠加真机标定的畸变系数叠加完之后你需要再标定一次确认注入的畸变和真机一致。还有一个容易被忽视的场景团队里多人使用同一套仿真环境但有人改了camera的 fovy 或者渲染分辨率别人不知道。这种改动不会报错训练出来的模型却会莫名其妙地性能下降。定期用棋盘格标定一次让内参数值说话比翻模型配置更直接。1.3 仿真标定结果能说明哪些问题标定完会得到内参矩阵和畸变系数两组数据。在仿真理想条件下畸变系数应当无限接近零。如果你标出k1 0.1甚至更大说明两条链路里至少有一条出了问题要么渲染图像经过了未知的缩放或重投影要么纹理、后处理环节引入了非线性变形。此时先别急着用标定结果而是回头排查渲染链路。重投影误差也很关键。真实相机标定重投影误差在 0.1~0.3 像素属于优秀水平仿真渲染因为没有噪声理论上应该低于 0.1 像素。如果仿真标定的重投影误差高于 0.5通常不是标定算法的问题而是角点检测精度不够或者贴图方向导致角点顺序和世界坐标不匹配。前者提高渲染分辨率和棋盘纹理清晰度就能解决后者往往需要可视化排查。2. 棋盘格场景设计与相机排布思路2.1 棋盘格建模的两种方式在 MuJoCo 里让一个平面上出现棋盘格有两条路。一条是用内置的 checker 纹理代码最简洁texture namechess type2d builtinchecker width256 height256 rgb10.95 0.95 0.95 rgb20.05 0.05 0.05/ material namechess_mat texturechess texrepeat4 3 texuniformtrue/这里有个关键点MuJoCo 的 builtin checker 纹理一个 repeat 周期在 x 和 y 方向各包含黑白两个小格也就是 2x2 的格子。所以texrepeat4 3实际生成的是 8 列 x 6 行小方格。想得到更多或者更少格子直接改 repeat 的数值就行但必须保证两个方向的格子数都是偶数否则 repeat 没法是整数边缘会出现半格。另一条路是自己用 OpenCV 或 NumPy 生成一张棋盘格 PNG再把图片作为纹理贴图。这种方式完全不受奇偶限制想排 9x6、10x7 都可以而且可以在图片里加边框、加方向标记方便排查贴图方向问题。缺点是模型依赖外部图片文件代码里要处理好路径。我个人推荐第二种不是因为内置纹理不好而是标定场景下你通常需要精确控制方格数量、纹理方向和几何尺寸用 PNG 更可控。2.2 角点数量、方格数量与物理尺寸对应关系OpenCV 的findChessboardCorners接收的patternSize是内角点数量不是小方格数量。比如物理棋盘有 8 列 x 6 行小方格内角点就是 7 x 5。这两个数字写反或者写错角点检测大概率直接失败。同时objectPoints里的世界坐标必须按实际物理尺寸设置。我在代码里设定每个小方格边长为 0.05 米那么棋盘点阵坐标就是(0, 0, 0) (0.05, 0, 0) (0.10, 0, 0) ... (0, 0.05, 0) (0.05, 0.05, 0) ...这个细节特别容易踩坑。我见过有人把整块棋盘当成长宽 1 的平面坐标直接写np.mgrid[0:7, 0:5]然后不乘任何尺度这样标定出来的焦距会等比例缩小其实单位还是格而不是米。还有一个隐藏的坑我给棋盘生成 PNG 时一开始加了白色边框结果纹理映射到几何平面后每个格子的物理尺寸不再是 0.05 米因为边框也占了一部分纹理像素。第一次跑出来的焦距差了一大截排查了很久才意识到。解决方法是生成无边框的纯棋盘 PNG或者反过来把边框的像素尺寸也换算进小格边长。2.3 多视角相机排布与 xyaxes 推导标定需要棋盘格以不同姿态出现在图像里真机靠人工移动棋盘仿真里我更愿意直接布置多个固定相机一劳永逸。理论上 12~20 个视角就足够视角覆盖方位角 -60 度到 60 度、俯仰角 25 度到 55 度、距离 0.65 米到 1.0 米。这种覆盖能同时保证小格在图像中的投影足够清晰也让主点估计更稳定。关键问题是怎么让每个相机的朝向都对准棋盘。MuJoCo 的相机在 XML 里用pos指定位置用xyaxes指定相机局部 x 轴和 y 轴在世界坐标系的投影。好消息是不用去算四元数只需要根据相机位置和观察目标构造一个旋转矩阵。我把这个过程封装成一个函数def look_at_xyaxes(pos, at): up np.array([0.0, 0.0, 1.0]) # 世界 z 轴向上 pos np.array(pos, dtypefloat) at np.array(at, dtypefloat) z_cam pos - at # 相机 local z 指向后方 z_cam z_cam / np.linalg.norm(z_cam) x_cam np.cross(up, z_cam) # local x 轴水平向右 x_cam x_cam / np.linalg.norm(x_cam) y_cam np.cross(z_cam, x_cam) # local y 轴向上 return np.concatenate([x_cam, y_cam])理解这个函数的关键是明白 MuJoCo 相机的观察方向沿 local -z 轴。因此为了让相机看向棋盘中心local z 轴应该指向相机位置的后方也就是pos - at方向。local x 轴要与世界向上的方向正交可以用世界 up 向量和 local z 做叉积得到。local y 轴再通过 local z 和 local x 叉积补全。如果相机恰好在这种姿态下x_cam长度为零比如相机在目标正上方需要特殊处理但我们的视角排布不会触发这种情况。从 20 个相机里取一个打印结果az np.deg2rad(0) elev np.deg2rad(40) dist 0.8 at [0.0, 0.0, 0.01] pos [ dist * np.cos(elev) * np.cos(az) at[0], dist * np.cos(elev) * np.sin(az) at[1], dist * np.sin(elev) at[2], ] xyaxes look_at_xyaxes(pos, at)这样算出来的位置分布在棋盘上方半球面视角确实覆盖了从斜上方到近乎平视的范围。对标定来说这种分布比只有俯视或者只有侧视要稳得多。3. 核心代码实现3.1 环境依赖我用的是 Python 3.10配合mujoco 3.1.x、opencv-python 4.9.x、numpy。安装很简单pip install mujoco opencv-python numpyMuJoCo Python 包自带离屏渲染功能不需要额外装 glfw 或者别的窗口库。OpenCV 只需要最基础的模块连 contrib 都不用。3.2 生成棋盘格 PNG我选择了 8 列 x 6 行的小方格每格像素设成 100这样整张图是 800x600不会太大渲染出来也足够清晰。注意这里不要加白边原因前面已经说过加了边框会导致小格物理尺寸和世界坐标对不上。import numpy as np import cv2 cols, rows 8, 6 cell 100 H rows * cell W cols * cell img np.full((H, W), 255, dtypenp.uint8) for i in range(rows): for j in range(cols): if (i j) % 2 1: img[i * cell:(i 1) * cell, j * cell:(j 1) * cell] 0 cv2.imwrite(chess.png, img)生成之后用 OpenCV 看一眼确认第一个角在左上、黑格白格分明。检测内角点数量为(cols - 1, rows - 1) (7, 5)方格物理边长为0.05米。3.3 构建 MuJoCo 场景和相机先准备完整 XML 字符串。棋盘用一个薄盒子的几何体尺寸设置为 0.20 米 x 0.15 米这样对应 8 列 x 6 行、每格 0.05 米的棋盘刚好是 0.40 米 x 0.30 米几何 size 取一半。棋盘中心放在世界坐标原点附近z 方向为 0.005 米让棋盘平面略微高于地面。纹理引用必须确保chess.png路径正确。如果你用from_xml_string默认相对路径是当前工作目录建议直接写成绝对路径或者调用前os.chdir到图片所在目录。import os import numpy as np import mujoco def look_at_xyaxes(pos, at): up np.array([0.0, 0.0, 1.0]) pos np.array(pos, dtypefloat) at np.array(at, dtypefloat) z_cam pos - at z_cam z_cam / np.linalg.norm(z_cam) x_cam np.cross(up, z_cam) x_cam x_cam / np.linalg.norm(x_cam) y_cam np.cross(z_cam, x_cam) return np.concatenate([x_cam, y_cam]) n_views 20 camera_xml for i in range(n_views): az np.deg2rad(-60 i * (120 / (n_views - 1))) elev np.deg2rad(np.random.uniform(25, 55)) dist np.random.uniform(0.65, 1.0) at [0.0, 0.0, 0.01] pos [ dist * np.cos(elev) * np.cos(az) at[0], dist * np.cos(elev) * np.sin(az) at[1], dist * np.sin(elev) at[2], ] xyaxes look_at_xyaxes(pos, at) cam_name fcalib_cam_{i} camera_xml ( fcamera name{cam_name} pos{pos[0]:.4f} {pos[1]:.4f} {pos[2]:.4f} fxyaxes{ .join(f{v:.4f} for v in xyaxes)} fovy60/ ) xml f mujoco modelchess_calib asset texture namechess_tex type2d filechess.png/ material namechess_mat texturechess_tex texrepeat1 1 texuniformtrue/ /asset worldbody light nametop_light pos0 0 3 dir0 0 -1/ geom nameboard typebox size0.20 0.15 0.005 pos0 0 0.005 materialchess_mat/ {camera_xml} /worldbody /mujoco model mujoco.MjModel.from_xml_string(xml) data mujoco.MjData(model) mujoco.mj_forward(model, data)这里我把每个相机的 fovy 都设为 60 度方便后面和理论值对比。你如果改成 45 度公式里同步调整即可。20 个相机全部一次放入模型之后不需要再动相机位姿这比运行时通过data.cam_quat控制要省心。3.4 渲染采样图像创建mujoco.Renderer时注意参数顺序是height和width不是width和height。渲染出来的图像是 RGB 三通道数组OpenCV 默认用 BGR所以存图前要反转通道。save_dir calib_imgs os.makedirs(save_dir, exist_okTrue) renderer mujoco.Renderer(model, height480, width640) for i in range(n_views): cam_id mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_CAMERA, fcalib_cam_{i}) if cam_id 0: continue renderer.update_scene(data, cameracam_id) img_rgb renderer.render() img_bgr img_rgb[:, :, ::-1].copy() cv2.imwrite(os.path.join(save_dir, fview_{i:02d}.png), img_bgr) gray cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) found, corners cv2.findChessboardCorners(gray, pattern_size) if found: vis cv2.drawChessboardCorners(img_bgr.copy(), pattern_size, corners, found) cv2.imwrite(os.path.join(save_dir, fview_{i:02d}_corners.png), vis) else: print(f[warn] view {i}: corners NOT found) renderer.close()每一张我都顺手画了角点标注图这是排查问题的关键。渲染完成后renderer.close()释放资源防止在循环里频繁创建渲染器导致内存泄漏。3.5 OpenCV 角点检测与标定标定阶段重新读取刚才保存的图片。先用findChessboardCorners初检再用cornerSubPix做亚像素精化。仿真图像虽然干净但棋盘在图像中占的像素有限亚像素优化能让焦距估计更接近理论值。pattern_size (cols - 1, rows - 1) # (7, 5) square_size 0.05 objp np.zeros((np.prod(pattern_size), 3), np.float32) objp[:, :2] np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2) objp * square_size criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 50, 1e-6) objpoints [] imgpoints [] gray_shape None for fname in sorted(os.listdir(save_dir)): if not fname.endswith(.png) or _corners in fname: continue img cv2.imread(os.path.join(save_dir, fname)) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray_shape gray.shape[::-1] found, corners cv2.findChessboardCorners(gray, pattern_size, None) if not found: print(fskip {fname}: no corners) continue corners2 cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) objpoints.append(objp) imgpoints.append(corners2) print(fvalid images: {len(objpoints)} / {n_views}) ret, mtx, dist, rvecs, tvecs cv2.calibrateCamera( objpoints, imgpoints, gray_shape, None, None )这里有个小细节np.mgrid[0:7, 0:5]生成的是 (x, y) 坐标和 OpenCV 角点顺序是否一致其实不影响标定结果因为objectPoints和imagePoints只要一一对应解算的只是同一个刚体在不同视角下投影的一致性。3.6 标定结果验证标定完成后做两件事计算重投影误差对比理论焦距。total_err 0.0 for i in range(len(objpoints)): proj_pts, _ cv2.projectPoints(objpoints[i], rvecs[i], tvecs[i], mtx, dist) err cv2.norm(imgpoints[i], proj_pts, cv2.NORM_L2) / len(proj_pts) total_err err mean_err total_err / len(objpoints) H, W 480, 640 fov_deg 60.0 expected_f (H / 2) / np.tan(np.deg2rad(fov_deg / 2)) print(mean reprojection error:, mean_err) print(camera matrix:\n, mtx) print(distortion coeffs:, dist.ravel()) print(expected focal:, expected_f) print(expected principal point:, W / 2, H / 2)按照我的配置期望焦距是240 / tan(30°) 415.69主点是(320, 240)。实际跑下来通常fx和fy都在 415 附近相差不超过 1 个像素cx、cy在 320、240 附近畸变系数小到 1e-3 量级。重投影误差如果稳定在 0.1 像素以内说明整个渲染链路和 OpenCV 的标定模型是吻合的。4. 常见问题与排查技巧4.1 findChessboardCorners 一直找不到角点仿真图像理论上很干净找不到角点基本是四种原因。第一棋盘在画面里占比太小内角点之间的像素距离少于 8~10 个像素检测就会不稳定。解决方法是把相机拉近、把 fovy 改小、或者提高渲染分辨率。第二光照太亮导致白色格过曝黑色格反光。给棋盘材质关闭高光效果或者把灯放在正上方让光照均匀。第三PNG 纹理的宽高比和几何体不一致渲染出来棋盘格不是正方形角点发生了明显拉伸。第四棋盘格本身清晰度不够比如 PNG 分辨率太低投影后格线模糊。我最开始用texrepeat4 3的内置纹理时渲染出来棋盘方向是我的预期方向但用 PNG 之后方向就会受到纹理坐标系的影响。强烈建议每一张视角都保存角点标注图一旦检测失败看图片就能判断是棋盘太小、光照问题还是纹理问题不用猜。4.2 标定内参和理论值偏差大如果标出来的焦距和手算值明显不一致先检查三点。第一fovy 是垂直视场角不是水平视场角。MuJoCo 文档里fovy指的就是垂直方向的视场角理论公式里的 height 是图像高度不是宽度。把 640 当成 480 代入算焦距就会差三分之一。第二objectPoints的单位必须是米。如果你忘乘square_size标定出的焦距会变成原来的 1/0.05也就是放大 20 倍。第三纹理物理尺寸和世界坐标是否一致前面提到的白边问题就是一个典型。还有一个容易忽略的点如果 20 张图几乎全是正对棋盘、姿态变化很小cx和cy会不稳定。仿真里不会因为手抖导致视角偏差但你可以主动增加俯仰角范围。我观察到只绕一圈、高度固定的数据集标出来的主点会漂移 3~5 个像素加上高低俯仰变化后主点立刻收敛到理论值附近。4.3 纹理方向导致角点顺序镜像这个坑在真机标定里不会出现因为物理棋盘的方向由你手里的板子决定你总能保证棋盘正对相机。但在 MuJoCo 里纹理映射到 box 顶面时可能被旋转、翻转。一旦棋盘在渲染图中是镜像的findChessboardCorners依然能检测出所有角点但检测顺序对应的物理位置和你设置的objectPoints不一致。这时候标定照样能跑完但重投影误差会比较高内参也会偏。排查方法是打印带角点的标注图肉眼核对棋盘方向和检测顺序。更稳的做法是在棋盘旁边放一个非对称标记物比如在第一个小角点附近放一个红色小球再用mj_name2id查它的位置。只要确认渲染图中的棋盘方向和模拟器里的棋盘方向一致标定结果才可靠。4.4 运行时修改相机位姿不生效如果你不想在 XML 里放 20 个相机而是运行时通过data.cam_pos和data.cam_quat控制同一个相机有几个细节必须注意。data.cam_pos对固定相机是相对 XML 中pos的偏移量不是世界绝对坐标。修改后要执行mujoco.mj_forward(model, data)再调用renderer.update_scene()。四元数顺序是(w, x, y, z)如果你用 scipy 的Rotation.as_quat()拿到的是(x, y, z, w)必须做换序。我实际测试过运行时控制确实可行但代码里要维护位姿列表和四元数转换逻辑上比一次性生成多个固定相机复杂。标定这种一次性任务我更推荐 XML 预置相机方案。4.5 仿真标出来的畸变系数为什么要认真看理想仿真无畸变但你标出来的畸变系数不会是绝对零而是一些小量比如k1-0.0012、k20.0008。这是角点检测的亚像素噪声和标定优化过程的正常扰动。只要数量级在 1e-2 以下可以认为没有畸变。但做 sim-to-real 时我不建议把这一串小畸变系数直接当真值写进算法。仿真本身没有镜头畸变这些小量没有物理意义写入感知模型反而可能引入偏差。正确做法是内参用标定出的fx/fy/cx/cy畸变系数全部置零。如果你想模拟真机畸变应该从真机标定里拿真实畸变系数再用cv2.remap叠加到仿真渲染图上。5. 从内参标定往外拓展5.1 单目标定之后再做什么内参标定做完最自然的下一步是外参验证。虽然在 MuJoCo 里相机外参可以直接从模型读取但如果你想模拟真机不知道相机外参的情况可以用同一批棋盘格图像跑solvePnP把求出的外参和 MuJoCo 模型真值对比。误差通常能控制在 0.5 厘米以内这对手眼标定预研很有参考价值。另外MuJoCo 渲染器还能输出深度图。同一个视角下同时渲染 RGB 和 depth就能生成一套带深度标注的感知数据。此时相机内参是否准确直接决定深度图能不能正确投影为点云。标定完内参后用cv2.projectPoints把已知的三维点投影回图像跟 2D 检测点对比如果差异小于几个像素整条渲染链路就值得信任了。如果你需要在仿真里模拟真机畸变流程是先用真机标定得到畸变系数再用cv2.initUndistortRectifyMap和cv2.remap处理仿真渲染图。加完畸变后再跑一遍棋盘格标定确认输出的畸变系数与你注入的真机畸变一致。这个闭环能帮你判断畸变注入流程是否出了问题。5.2 我踩过的几个坑和习惯最后分享几个实操习惯。第一棋盘贴图生成的物理尺寸核对我会先渲染一张正对棋盘的图然后手动量一下图像中棋盘横向跨度对应的像素再按 fov 反推距离确认纹理没有拉伸。这个验证只要十秒钟能省下后面排查的半天时间。第二每次标定都保留带角点标注的可视化图不覆盖保存。一旦重投影误差异常我可以回看是哪一张图出了问题。第三视角数量不必盲目贪多。我在仿真里测试过 8 张、12 张、20 张三组数据12 张以上结果已经非常稳定关键是视角覆盖要分散不要集中在一个小扇区。有一次我标出来的焦距和理论值差了 20%排查了很久才发现是棋盘 PNG 加了白边导致每个小格的物理尺寸不是想象中的 0.05 米。从那次以后我做仿真标定都会先用代码打印纹理总像素 - 几何总尺寸 - 单个格子物理尺寸的换算关系确认无误再进入采集环节。MuJoCo 渲染加 OpenCV 标定这条链路代码量不大但它把仿真世界里的相机参数和真实视觉处理流程打通了。无论你是做抓取、导航还是纯数据生成这套验证方法都能帮你少走不少弯路。
阅读完成 · 觉得有帮助?