MuJoCo 四旋翼仿真与 ROS2 桥接底座
本模块在 MuJoCo 3.12.0 中构建四旋翼仿真,把物理与多路传感器以标准 ROS2 Humble 话题 对外暴露,并完成作业任务①:通过键盘对无人机进行运动控制。
它同时是后续五个无人机模块(神经残差控制、神经 VIO/SLAM、语义建图导航、深度图端到端竞速、 神经安全过滤器)的共同底座——这五个模块都通过同一套话题与本模块交互,彼此不重复造轮子。
不使用 Gazebo
仿真器为作业允许列表中的 MuJoCo 3.12.0,纯 CPU 运行,不需要独立显卡, 也不需要 Unreal / CUDA。本机(Intel 核显笔记本)实测控制回路 3400 Hz。
1. 与已有模块的关系
课程仓库 src/air/ 下已有 8 个无人机模块,本模块与它们是并列且互补的关系:
| 已有模块 | 它解决什么 | 本模块做什么 |
|---|---|---|
carlair_ros_bridge |
CarlaAir/AirSim ↔ ROS 的桥接 | 用 MuJoCo 实现同一套话题契约,换仿真器不改算法 |
uav_keyboard_control |
键盘遥控 | 同样提供键盘遥控,但底层是 MuJoCo + ROS2 |
uav_ppo_nav |
PPO 导航策略 | 提供它需要的 /lidar/points、/uav/odom |
octree_uav_3d_pathfinding |
八叉树建图寻路 | 提供它需要的 /lidar/points(世界系点云) |
air_learning |
视觉行为克隆 | 提供它需要的 /camera/image_raw |
关键设计:对外话题名、消息类型、坐标系、量纲与 carlair_ros_bridge 逐字段对齐。
因此上游任何算法模块都能不改一行地切到本模块;反过来,本模块也可以把仿真器换成
AirSim / CarlaAir 而算法代码不动。
2. 系统架构
2.1 为什么必须是两个进程
虚拟机里的 ROS2 Humble 是为 Python 3.8 编译的(rclpy 只有 Python 3.8 绑定),
而 MuJoCo 3.12.0 要求 Python ≥ 3.10,两者无法共存于同一个解释器。
因此按课程既有 carlair_ros_bridge 的架构拆成两个进程,中间走 TCP:
四旋翼 + 5 个场景"] S["传感器
RGB / 深度 / 语义分割
激光雷达 / IMU"] W["独占工作线程
(OpenGL 上下文绑定)"] M --> S --> W end subgraph ROS["ROS2 进程(Python 3.8 + rclpy)"] B["uav_sim_bridge"] K["任务① 键盘遥控节点"] end W -- "TCP 41460
控制通道 50 Hz" --> B W -- "TCP 41460
感知通道 10 Hz" --> B B --> T1["/uav/odom /tf /uav/status"] B --> T2["/camera/image_raw /camera/depth
/camera/seg /lidar/points"] K -- "/uav/cmd_vel" --> B
两条独立 TCP 连接是必须的,不是优化:控制回路每次只要 5 ms(纯状态,几十字节), 而一次带 RGB+深度的感知请求要 66 ms(载荷 500+ KB)。如果共用一个定时器, 50 Hz 的控制回路会被拖到 3.8 Hz。拆开之后控制回路稳定在 32 Hz、感知 9 Hz。
2.2 三种部署方式
| 方式 | 仿真进程 | ROS 进程 | 说明 |
|---|---|---|---|
| A(推荐) | Windows 宿主机 | Ubuntu 虚拟机 | 宿主机有图形加速,虚拟机只跑 ROS。桥接传 sim/host:=10.0.2.2 |
| B | 虚拟机内 | 虚拟机内 | 无网络开销,但虚拟机无 GPU 走软件光栅化,RGB 只有约 7 Hz |
| C | 同一台 Linux | 同一台 Linux | 需要 Python ≥3.10 与 ROS 兼容;用法相同 |
推荐方式 A:Windows 上 RGB 320×240 可达 151 Hz,虚拟机只负责 ROS 逻辑,负载很轻。
3. 运行环境
| 项目 | 要求 |
|---|---|
| ROS | ROS2 Humble(Ubuntu 20.04 / 22.04 均可) |
| 仿真侧 Python | ≥ 3.10(MuJoCo 3.12.0 的硬性下限) |
| 显卡 | 不需要独立显卡,核显或纯 CPU 均可 |
| 内存 | ≥ 4 GB |
| 其他 | 不需要 Gazebo、不需要 Unreal、不需要 CUDA |
实测运行平台:Intel Core 5 220H(12 核 16 线程,Intel 核显)、23.7 GB 内存。
4. 安装
4.1 仿真侧(一次性)
# 安装 uv(只装到 ~/.local/bin,不动系统 Python)
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
# 建独立 Python 3.12 环境并安装 MuJoCo
uv venv ~/uav_ws/.venv --python 3.12
uv pip install --python ~/uav_ws/.venv/bin/python "mujoco==3.12.0" numpy
注意
这里不修改系统 Python。虚拟机里可能还有别的项目依赖 Python 3.8, 动系统解释器会波及它们。
4.2 ROS 侧
source /opt/ros/humble/setup.bash
mkdir -p ~/uav_ws/ros2_ws/src && cd ~/uav_ws/ros2_ws
# 把本模块放到 src/ 下后:
colcon build --symlink-install --packages-select uav_mujoco_sim
source install/setup.bash
4.3 验证安装
cd ~/uav_ws/ros2_ws/src/uav_mujoco_sim
~/uav_ws/.venv/bin/python main.py --selftest
期望输出 通过 20/20。自检不需要 ROS、不需要显示器,可在任意机器上先跑通它,
再排查 ROS 侧的问题。
5. 仿真步骤
5.1 启动(方式 A:仿真在 Windows,ROS 在虚拟机)
第 1 步:Windows 宿主机启动仿真服务端
cd <path>\uav_mujoco_sim
python main.py --server --host 0.0.0.0 --port 41460 --scene urban_block
看到下面这行说明启动成功:
[uav_mujoco_sim] 仿真服务已启动 0.0.0.0:41460
[uav_mujoco_sim] 场景=urban_block 控制周期 dt=0.01s 相机 640x480
第 2 步:确认虚拟机能看到宿主机
VirtualBox NAT 下宿主机的地址固定是 10.0.2.2:
ping -c 2 10.0.2.2
第 3 步:虚拟机启动桥接
source /opt/ros/humble/setup.bash
source ~/uav_ws/ros2_ws/install/setup.bash
ros2 run uav_mujoco_sim bridge --ros-args \
-p sim/host:=10.0.2.2 -p sim/port:=41460
桥接会先连接仿真端,成功后打印:
[uav_sim_bridge]: 已连接。场景=urban_block 初始位置=[-28.0, 0.0, 3.0]
[uav_sim_bridge]: 桥接已就绪:控制 50 Hz,图像 10 Hz,雷达 10 Hz,坐标=机体系
之后每 10 秒会打印一次实测速率:
[uav_sim_bridge]: 实测速率:控制 31.9 Hz(目标 50),感知 9.0 Hz(目标 10);
感知分项 RPC 66 ms + 发布 3 ms(n=90)
第 4 步:确认话题
新开一个终端:
source /opt/ros/humble/setup.bash
ros2 topic list
应当看到:
/camera/depth /camera/image_raw /lidar/points
/tf /uav/cmd_vel /uav/goal
/uav/odom /uav/status
5.2 一键启动(ros2 launch)
如果能接受仿真也跑在虚拟机里(方式 B),可以一条命令拉起全部:
export DISPLAY=:0 MUJOCO_GL=glfw
ros2 launch uav_mujoco_sim main.launch.py scene:=obstacle_field
launch 会自动:探测仿真侧 Python → 启动 MuJoCo 服务端 → 启动桥接节点。 键盘遥控节点默认不启动(见 5.3)。
常用参数:
ros2 launch uav_mujoco_sim main.launch.py scene:=urban_block
ros2 launch uav_mujoco_sim main.launch.py keyboard:=true # 键盘遥控,见 5.3
ros2 launch uav_mujoco_sim main.launch.py quality:=full # 开阴影,录演示动图用
ros2 launch uav_mujoco_sim main.launch.py headless:=true # 不渲染,纯控制/规划
5.3 任务①:键盘遥控

(动图:在障碍场中依次按住 W 前进、A 左移、R 上升、Q 右转、F 下降,
松开所有键后无人机自动悬停。右下角小窗是同一时刻的第一视角 /camera/image_raw。)
键盘节点从控制终端 /dev/tty 读键,不读 stdin(ros2 launch 把子进程的 stdin
接成了管道)。终端只把输入交给前台进程组,而 launch 拉起的子进程不在其中,
读终端拿到 EIO——节点在跑、日志正常、按键没反应。所以键盘节点默认不随 launch
启动,遥控时另开一个终端:
source /opt/ros/humble/setup.bash
source ~/uav_ws/ros2_ws/install/setup.bash
ros2 run uav_mujoco_sim keyboard
该终端要让程序留在前台——别用管道、重定向、nohup、& 启动。若顺手写了
keyboard:=true,节点会打印一条"取不到可读的终端(not foreground...)"。
按键映射(与上游 uav_keyboard_control 一致):
| 按键 | 动作 | 速度分量 | 按键 | 动作 | 速度分量 |
|---|---|---|---|---|---|
W / S |
前进 / 后退 | linear.x = ±1.0 |
A / D |
左移 / 右移 | linear.y = ±1.0 |
R / F |
上升 / 下降 | linear.z = ±0.6 |
Q / E |
右转 / 左转 | angular.z = ±0.6 |
| 松开所有键 | 悬停 | 全零 | ESC / Ctrl-C |
退出 | — |
若只想看效果、不想装 ROS,可以直接跑独立演示:
~/uav_ws/.venv/bin/python main.py --demo --scene obstacle_field --viewer
5.4 用命令行控制(不开键盘)
# 让无人机以 1.5 m/s 前进(持续 5 秒)
ros2 topic pub -r 20 /uav/cmd_vel geometry_msgs/msg/Twist "{linear: {x: 1.5}}"
# 飞向世界系目标点 (5, 0, 2),到达后自动悬停
ros2 topic pub --once /uav/goal geometry_msgs/msg/Point "{x: 5.0, y: 0.0, z: 2.0}"
# 查看状态(READY/HOVER/VELOCITY/GOAL/GOAL_DONE)
ros2 topic echo /uav/status
6. 计算原理
6.1 坐标系约定
| 坐标系 | 定义 | 用途 |
|---|---|---|
| 世界系 | ENU: 东、 北、 上 | /uav/odom、/uav/goal、/lidar/points |
| 机体系 | FLU: 前、 左、 上 | /uav/cmd_vel |
与 carlair_ros_bridge 完全一致,因此不需要做 NED↔ENU 换算。
四元数按 MuJoCo 约定为 。
6.2 旋翼布置与混控
X 型四旋翼,四个旋翼位于 ,其中 , 为轴距。
对角两桨必须同向
X 型配置下对角旋翼同向、相邻旋翼反向。若标错,偏航通道与滚转通道会 线性相关,混控矩阵奇异,无人机无法独立控制偏航。本模型的取向为: 旋翼 1(前右)与 2(后左)逆时针,旋翼 3(前左)与 4(后右)顺时针。
设第 个旋翼推力为 (沿机体 ),则机体的合推力与力矩为
其中 为反扭矩系数(量纲为长度,约等于桨叶半径尺度;本模型取 )。
逆解(由期望的 求四个推力)——记 , ,:
6.3 饱和处理(这一步不能省)
四个推力都可写成 ,因此全部落在 内的充要条件是
不满足时整体等比例缩小 ,而不是对四个推力逐个裁剪。
逐个裁剪会破坏力矩方向
逐个裁剪会让四个推力各自撞到上下限,力矩的相对比例被破坏,合力矩方向完全失真。 实测表现:姿态环要求稍大的力矩时控制器瞬间发散, 圆形轨迹跟踪误差从 17 mm 直接炸到 474 m。 等比缩小则保持方向、只降低幅值,并优先保住合推力 (高度不掉),牺牲力矩—— 这才是物理上合理的行为。
6.4 几何控制器
外环(位置)由位置/速度误差算期望推力矢量:
再由 的方向与期望偏航角 构造期望姿态 :
内环(姿态)用几何误差做 PD:
合推力取 在机体 轴上的投影 。
默认增益由实验扫描选定(见 §9.4 图 4):
(负号来自误差定义取 ,便于统一调参。)
6.5 速度指令如何变成飞行
/uav/cmd_vel 给的是机体系速度,实现方式是积分成一条参考轨迹再用位置环跟踪:
其中 是 经一阶低通后的结果:
为什么要滤这一下
若把指令直接灌进参考轨迹,松键瞬间参考点会"急停",而机身还带着 1 m/s 的速度, 位置环会把它拽回旧参考点,表现为明显的甩尾回弹(实测松键后漂移 18 cm)。 加 0.12 s 时间常数后,参考速度平滑衰减到零,机身自然减速停在附近, 松键漂移降到 1.95 cm。
参考速度同时作为前馈 交给几何控制器;若不给前馈, 参考点以 1 m/s 跑掉而控制器却按"期望速度为 0"去刹车,会有一大截跟随滞后。
7. 算法流程
7.1 主循环(50 Hz 控制 + 10 Hz 感知)
7.2 服务端线程模型
连接线程"] --> Q["任务队列"] C2["客户端 2
连接线程"] --> Q C3["客户端 N
连接线程"] --> Q Q --> W["独占工作线程
SimCore:物理 + 渲染"] W --> R["结果"] R --> C1 R --> C2 R --> C3 style W fill:#ffe6cc,stroke:#d79b00
为什么必须独占一个线程
OpenGL 的上下文是线程绑定的。mujoco.Renderer 在 A 线程创建后,
在 B 线程调用 render() 会走到 glfw.make_context_current,在 WGL 下直接失败
(WGL: Failed to make context current),随后用错误的上下文渲染出垃圾像素,
分割图里出现天文数字的对象 id,最终抛出:
IndexError: index 4188928 is out of bounds for axis 0 with size 59
这个 bug 只在第二个客户端连上来之后才出现(服务端每连接一线程),
单客户端测试永远复现不了。因此用一把大锁再加一层"独占线程"来根治,
而不是只加锁。回归测试见 test/test_concurrency.py。
8. 源代码解析
8.1 目录结构
uav_mujoco_sim/
├── main.py # 统一入口(作业要求入口以 main 开头)
├── launch/main.launch.py # ros2 launch 入口
├── package.xml / setup.py # ament_python 包定义
├── config/sim.yaml # 桥接参数(launch 可覆盖)
├── models/quadrotor.xml # 四旋翼模型(唯一的机体定义)
├── data/sample/ # 示例数据(离线复现用)
├── test/ # 冒烟测试 + 并发回归测试
└── uav_mujoco_sim/
├── cli.py # 入口实现
├── selftest.py # 内置自检(20 项)
├── demo.py # 无 ROS 演示 / 录制 / 回放
├── core/ # ← 纯 Python,不依赖 ROS
│ ├── quadrotor.py # 模型、混控、几何控制器
│ ├── scenes.py # 场景库(5 个场景)
│ ├── sensors.py # 相机/深度/语义分割/雷达/IMU
│ ├── teleop.py # 按键映射
│ ├── terminal.py # 终端原始按键读取
│ └── paths.py # 资源定位(源码布局 & 安装布局)
├── sim/ # ← TCP 服务端与客户端
│ ├── protocol.py # 线协议
│ ├── server.py # MuJoCo 服务端 + 独占工作线程
│ └── client.py # 客户端
└── ros/ # ← ROS2 节点层(Python 3.8 兼容)
├── bridge_node.py # 桥接节点
├── keyboard_node.py # 键盘遥控节点(任务①)
└── conversions.py # numpy ↔ ROS 消息
core/ 与 sim/ 不依赖 ROS,这是"别人电脑也能跑"的第一道保险:
即使对方没有装 ROS,也能复现全部仿真与算法结果。
8.2 混控与饱和处理
core/quadrotor.py 中的 mixer():
def mixer(T: float, tau: np.ndarray, p: QuadParams) -> np.ndarray:
u = tau[0] / p.arm
v = tau[1] / p.arm
w = tau[2] / p.k_m
# 合推力本身受限于四个电机
T = float(np.clip(T, 0.0, 4.0 * p.max_thrust))
# ★ 饱和处理:可行性条件是 |u|+|v|+|w| ≤ min(4·T_max − T, T)
budget = min(4.0 * p.max_thrust - T, T)
spread = abs(u) + abs(v) + abs(w)
if spread > budget and spread > 1e-12:
k = budget / spread # 等比缩小,保持力矩方向
u, v, w = u * k, v * k, w * k
out = np.array([(T - u - v - w) * 0.25,
(T + u + v - w) * 0.25,
(T + u + w - v) * 0.25,
(T + v + w - u) * 0.25])
return np.clip(out, 0.0, p.max_thrust)
8.3 质量必须取子树总质量
# ✗ 错误:这只是机体自身的质量,不含 4 个旋翼子刚体
# body_mass = float(m.body_mass[self.body_id])
# ✓ 正确:子树总质量
body_mass = float(m.body_subtreemass[self.body_id])
这是一条真实踩过的坑
用 body_mass 会只取到 0.85 kg,而实际总重 0.95 kg(4 个旋翼各 0.025 kg)。
控制器低估重量 11%,稳态直接下坠 1.29 m。
理论下坠量可解析求出: 给出的 与实测 完全吻合,
这也是当初定位这个 bug 的关键线索。
8.4 传感器:通道顺序与自遮挡
# 语义分割:MuJoCo 返回的两个通道是 (对象id, 对象类型),id 在前
is_geom = seg[..., 1] == int(mujoco.mjtObj.mjOBJ_GEOM) # mjOBJ_GEOM == 5
gid = seg[..., 0]
两个必踩的坑
其一:通道顺序是 (对象id, 对象类型) 而不是 (类型, id)。写反了不会报错,
只会得到几乎全背景的分割图(实测只识别出 1 个类别)。
其二:mj_multiRay 必须设置 bodyexclude 指向机体,
否则射线会立刻打中自己的机臂/桨叶返回接近 0 的距离,
下游会以为是"贴脸障碍"。另外 MjData 构造后必须先 mj_forward 一次,
否则 site_xpos 全零,射线原点会跑到世界原点。
8.5 视觉渲染的性能开关
def _ensure(self, mode: str) -> mujoco.Renderer:
if self._renderer is None:
self._renderer = mujoco.Renderer(self.model, height=..., width=...)
self._apply_quality(self._renderer)
# ★ 切换模式只能改标志位,绝不能 close 掉重建
if mode != self._mode:
r = self._renderer
if mode == "rgb":
r.disable_depth_rendering()
r.disable_segmentation_rendering()
elif mode == "depth":
r.enable_depth_rendering()
elif mode == "seg":
r.enable_segmentation_rendering()
self._mode = mode
return self._renderer
| 做法 | 代价 | 后果 |
|---|---|---|
每次切换 close() + 重建 |
200~400 ms/次 | 同帧取 RGB+深度时帧率掉到 3 Hz |
| 改标志位(现方案) | ≈ 0 | 三路传感器可以同帧取 |
关阴影 quality="fast" |
≈ 0 | RGB 320×240 从 42 ms → 6.6 ms(6 倍) |
阴影之所以这么贵,是因为 ShadowMap 每帧要把整个场景再渲染一遍。
深度与语义分割本来就不用阴影,因此 quality="fast" 对下游网络几乎没有影响。
8.6 ROS 侧:一个 1000 倍的性能陷阱
ros/conversions.py:
def _as_byte_array(buf):
"""bytes → array.array('B')。这一层封装是性能关键,不是多余的抽象。"""
a = array.array("B")
a.frombytes(buf)
return a
# 发布图像时:
m.data = _as_byte_array(a.tobytes()) # ✓ 0.08 ms
# m.data = a.tobytes() # ✗ 84 ms(230 KB)
rclpy 的 uint8[] 字段收到 bytes 会逐元素转换
实测把 230 KB 的 bytes 赋给 Image.data 要 84 ms,而赋
array.array('B') 只要 0.08 ms——整整 1000 倍。
这条差异直接决定了整个桥接能否实时:两条图像(RGB+深度约 540 KB) 按前者每帧要花 200+ ms,感知回路只能跑到 2 Hz;按后者可以跑满设定的 10 Hz。
publish() 本身只要 0.2 ms——慢的从来不是发送,而是这次赋值。
9. 性能评价
9.1 仿真与控制(Windows 宿主机,Intel 核显)

| 项目 | 实测 | 说明 |
|---|---|---|
| 物理仿真 + 控制律 | 3400 Hz 控制回路 | 要求 ≥100 Hz,余量 34 倍 |
| RGB 320×240 | 151 Hz | quality=fast(关阴影) |
| 深度 320×240 | 65 Hz | |
| 语义分割 320×240 | 59 Hz | |
| 72 线激光雷达 | 519 Hz | mj_multiRay 一次投出全部射线 |
| 四路传感器合计 | 21 Hz | 含 TCP 传输 |
9.2 ROS2 话题(仿真在 Windows、ROS 在虚拟机)
| 话题 | 实测 | 配置目标 |
|---|---|---|
/uav/odom |
31.9 Hz | 50 Hz |
/camera/image_raw |
9.0 Hz | 10 Hz |
/camera/depth |
8.9 Hz | 10 Hz |
/lidar/points |
7.7 Hz | 10 Hz |
参照系
课程既有 carlair_ros_bridge 的文档记录:640×480 下 RGB+深度同发
实测吞吐约 7.3 Hz。本模块在 320×240 下达到 9 Hz,且控制回路独立不受影响。
9.3 控制精度

| 场景 | 指标 |
|---|---|
| 定点悬停 | 位置误差 0.00 mm,速度误差 0.000 cm/s,姿态误差 0.000° |
| 阶跃 (0,0,1.5)→(2,1.5,2.0) | 终点误差 0.000 mm,稳定时间 1.79 s,稳定后最大偏差 49.9 mm |
| 圆周轨迹 R=2 m, ω=0.5 rad/s | RMSE 10.5 mm |
| 速度指令 1 m/s 持续 1 s | 位移 0.860 m,停稳后残余速度 0.0036 m/s |
| 松键后漂移(2 s 内) | 1.95 cm |
关于 ±35° 倾角限制
限幅作用在期望推力矢量的方向上(即指令倾角),而不是直接钳位实际姿态。 因此内环姿态动态在阶跃时会小幅超调(实测约 40°,超调 14%), 1.6 s 内收敛回零。这是正常的串级控制行为,不是限幅失效。
9.4 增益如何选定

用脚本扫描 5 组增益、每组测 4 项指标(速度响应位移、松键滑行距离、 停稳残余速度、圆周轨迹 RMSE),用数据选,而不是靠手感:
| 增益组 | 1 s 位移 | 松键滑行 | 残余速度 | 圆周 RMSE |
|---|---|---|---|---|
| 软 (旧默认) | 0.499 m | 0.572 m | 0.178 m/s | 17.0 mm |
| 中 | 0.790 m | 0.207 m | 0.010 m/s | 11.4 mm |
| 硬 (现默认) | 0.860 m | 0.129 m | 0.0036 m/s | 10.5 mm |
| 很硬 | 0.888 m | 0.092 m | 0.0008 m/s | 发散 |
超出 100 Hz 控制周期的稳定裕度会发散,因此取 。
10. 场景库

| 场景 | 内容 | 用途 |
|---|---|---|
open_ground |
开阔平地 | 控制类实验、悬停与轨迹跟踪 |
obstacle_field |
20 个随机障碍(种子可调) | 避障、导航、安全过滤器 |
corridor |
室内走廊 + 立柱 | 视觉里程计、SLAM |
gate_track |
8 个穿越门 | 端到端竞速 |
urban_block |
14 栋建筑 + 10 棵树 + 6 辆车 | 语义建图与语义导航 |
所有场景都支持 --seed 随机化,保证实验可重复。
语义类别由命名约定决定
几何体的名字前缀决定它在语义分割里的类别:
floor_* → 地面、building_* → 建筑、tree_* → 植被、car_* → 车辆、
gate_* → 门、wall_* → 墙、obstacle_* → 障碍。
因此新增场景只要遵守命名规则,语义分割自动生效,不需要改任何代码。
11. 传感器输出

三路视觉传感器来自同一个渲染器、同一次 update_scene,因此天然时间同步,
不需要 message_filters 去对齐时间戳。
| 话题 | 类型 | 编码 | 说明 |
|---|---|---|---|
/camera/image_raw |
sensor_msgs/Image |
bgr8 |
前视彩色图 |
/camera/depth |
sensor_msgs/Image |
32FC1 |
深度,单位米,量程 0.2~10 m,超量程置 0 |
/camera/seg |
sensor_msgs/Image |
bgr8 |
语义分割伪彩色(默认关闭) |
/lidar/points |
sensor_msgs/PointCloud2 |
XYZ float32 | 相对机体中心的点云(ENU) |
深度图超出量程的像素置 0,是模拟真实深度相机(如 RealSense D435,有效量程约 0.2~10 m) 的无效像素行为,而不是"一直看到 130 m 外的天空"。
彩色图为什么发 bgr8 而不是 rgb8
MuJoCo 渲染出来的原始数据是 RGB,但课程既有的 carlair_ros_bridge 发布的是
bgr8(AirSim 返回 BGRA)。本模块的设计目标是"话题契约逐字段对齐、
上游模块零改动迁移",因此桥接在发布前做一次通道翻转,让 encoding 字段
名副其实。否则按 BGR 处理的上游代码拿到的图会红蓝反色。
这次翻转是视图切片([..., ::-1])加一次实化拷贝,320×240 下开销约 0.2 ms,
相对 66 ms 的感知往返可以忽略。
12. 常见问题
12.1 桥接报"30 s 内连不上仿真服务端"
按顺序检查:
- 仿真服务端是否在跑?Windows 上
Get-NetTCPConnection -LocalPort 41460, Linux 上ss -lntp | grep 41460 sim/host填对了吗?VirtualBox NAT 下宿主机是10.0.2.2; VMware NAT 常见192.168.94.1。不要用 41451 端口——那是 AirSim 的。- 宿主机防火墙是否放行了 41460 的入站连接。
12.2 键盘节点收不到按键
键盘节点读 /dev/tty,要独占一个前台终端。按顺序查:
- 是
ros2 launch ... keyboard:=true起的吗?launch 的子进程不在终端前台进程组, 终端不把输入交给它。另开一个终端跑ros2 run uav_mujoco_sim keyboard。 - 启动日志里的
取键自是什么?none表示没拿到可读终端,同一行的括号里是原因 (not foreground=进程组不对;no tty=/dev/tty与 stdin 都不是终端, 如nohup、systemd、容器里没有tty)。 - 桥接起了吗?
ros2 topic echo /uav/cmd_vel看有没有速度指令——按键有反应 但无人机不动,问题在桥接或仿真端,不在键盘。
12.3 虚拟机里渲染报 GL 错误
虚拟机没有 GPU,走软件光栅化,需要指定后端与显示:
export DISPLAY=:0 MUJOCO_GL=glfw
无显示环境(服务器 / 容器):
export MUJOCO_GL=osmesa # 需先 apt install libosmesa6-dev
# 或
xvfb-run -a python main.py --selftest
12.4 colcon build 报 "does not contain CMakeLists.txt"
package.xml 里少了一行 <export><build_type>ament_python</build_type></export>。
没有它,colcon 只能靠"有没有 CMakeLists.txt"去猜构建类型,
会把纯 Python 包误判成 catkin 包,报出一个指向完全错误方向的错误。
13. 复现与离线验证
课程要求"提交的模块在别人的机器上也能运行"。本模块为此准备了三道保险:
| 保险 | 命令 | 需要什么 |
|---|---|---|
| ① 内置自检 | python main.py --selftest |
只需要 Python + MuJoCo,不需要 ROS |
| ② 单元测试 | python -m pytest test/ |
同上 |
| ③ 离线回放 | python main.py --replay data/sample/demo_flight.npz |
只需要 numpy,连 MuJoCo 都不需要 |
示例数据 data/sample/demo_flight.npz(1.84 MB,15 s)包含完整的状态序列、
IMU、控制指令以及周期性的 RGB/深度/分割/雷达帧,供没有仿真器的机器复现数据链路:
状态帧数 : 1500 时长 15.00s
位置范围 : x[0.00,2.07] y[0.00,3.65] z[1.30,2.56]
飞行路程 : 7.59 m 最大速度: 1.15 m/s
图像 : rgb(75,120,160,3) depth(75,120,160) seg(75,120,160)
雷达 : 3703 点
重新录制:
python main.py --record my_flight.npz --scene obstacle_field --seed 3 --seconds 15
14. 话题接口总表
| 方向 | 话题 | 类型 | 说明 |
|---|---|---|---|
| 发布 | /uav/odom |
nav_msgs/Odometry |
位姿与速度(ENU) |
| 发布 | /tf |
tf2_msgs/TFMessage |
world → base_link |
| 发布 | /uav/status |
std_msgs/String |
READY / HOVER / VELOCITY / GOAL / GOAL_DONE / ROTOR |
| 发布 | /camera/image_raw |
sensor_msgs/Image |
bgr8 |
| 发布 | /camera/depth |
sensor_msgs/Image |
32FC1,米 |
| 发布 | /camera/seg |
sensor_msgs/Image |
bgr8(默认关闭) |
| 发布 | /lidar/points |
sensor_msgs/PointCloud2 |
XYZ float32 |
| 订阅 | /uav/cmd_vel |
geometry_msgs/Twist |
速度指令,默认机体系 |
| 订阅 | /uav/goal |
geometry_msgs/Point |
目标点(ENU) |
安全保护:速度自动限幅(水平 5 m/s、垂直 3 m/s、偏航 1 rad/s);
超过 control/cmd_timeout(默认 0.5 s)没有新指令即自动悬停。
15. 已知限制
- 默认
quality:=fast会关闭阴影/反射/天空盒。这只影响观感,不影响深度与语义分割, 但训练与推理应保持一致,不要一边用fast采数据、一边用full演示。 - 深度图量程默认 10 m,超过即置 0。
- 场景几何用的是基本体(盒/柱/球),不含高保真网格。这对本课程的感知与控制算法足够, 但若要研究基于纹理的视觉任务,需要换用带贴图的模型。
- 仿真的气动模型是简化的(不含桨叶挥舞、地面效应、复杂风场)。 后续"神经残差控制"模块正是以这些未建模动力学作为学习目标。