ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

启元机器人 Q1 MuJoCo 无桌面仿真实践:从离屏渲染到远程交互

启元机器人 Q1 MuJoCo 无桌面仿真实践:从离屏渲染到远程交互

手里的设备是一台 Apple Silicon Mac 和一台运行在虚拟机里的 Ubuntu。前面已经在 Ubuntu 中把 Q1 的 MuJoCo 模型加载起来,也验证过无界面步进和 EGL 离屏渲染。接下来我想做一个更接近开发工作的场景:Ubuntu 不安装桌面,只负责跑仿真;Mac 负责打开交互窗口;两边通过网络传输状态,再从 Mac 发一条受限制的执行器输入。

这个过程里有两个问题必须分开处理。第一,Mac 窗口里的机器人是不是 Ubuntu 仿真的状态,而不是 Mac 自己又跑了一套动力学。第二,远程输入能不能经过模型范围和演示策略检查,并在到期后自动清零。最后我又把 Ubuntu 服务停掉再重启,验证客户端能不能明确告诉我“现在断了”,以及新服务起来后能不能恢复。

这篇记录的是模型侧和通信侧的实践,不把它写成 Q1 真机控制结果。没有实体机器人时,先把状态来源、版本一致性、输入边界和连接恢复做实,后面接 SDK 或真机才不会一上来就把问题混在一起。

先把两台机器的职责固定下来

这次使用的 Q1 模型来自启元机器人 AtomGit 的 q1-urdf 仓库。我没有直接跟着仓库最新分支跑,而是固定到提交562b764a18613d459140ed9b255fa30434e346ef,模型文件是xml/q1_v3_mc.xml。固定提交的意义很实际:Mac 和 Ubuntu 必须加载同一份 XML 和 mesh,状态数组才有可比性。

两端环境如下:

位置作用环境
Ubuntu 虚拟机加载 Q1 模型、推进 MuJoCo、提供 HTTP 状态和受限输入接口Ubuntu 22.04.5 LTS、ARM64、Python 3.10、MuJoCo 3.16.0、无桌面会话
Mac加载同一份模型、显示远端qpos/qvel、发送演示输入Apple Silicon、macOS 15.4.1、Python 3.10、MuJoCo 3.16.0

Ubuntu 使用虚拟机私网地址10.211.55.4,服务监听8766端口。Mac 端先把本地模型仓库切到同一个提交,再计算 XML 的 SHA-256。启动服务和客户端时,代码都会检查 MuJoCo 版本、提交号、XML 哈希以及nq/nv/nu维度;任何一项不一致,客户端就不继续接收状态。

我先在 Mac 上单独打开模型,确认本地 Viewer 和模型文件都正常。这里窗口里的 Q1 还是 Mac 本地MjData经过mj_forward后的结果,尚未接入 Ubuntu。

图 1:Mac 端显示了固定提交号,并确认q1_v3_mc.xml与 mesh 文件存在。

直接打开 MuJoCo Viewer 能看出模型是否能被加载,但这一步还没有回答“状态到底由谁推进”。为了让两端职责变得可检查,我又用 Mac 的mjpython打开本地 Viewer,打印模型维度和 MuJoCo 版本。这个窗口只是确认 Mac 的图形环境和模型资源能正常工作;它不是后面 Ubuntu 状态服务的窗口,也没有通过网络接收状态。先把本地显示这一层单独排除,出现问题时才知道该查 Viewer、模型还是通信。

图 2:Mac 端使用 MuJoCo 3.16.0 加载 Q1 模型,nq=29、nv=28、执行器数量为 22。

Ubuntu 不装桌面,只运行状态服务

Ubuntu 端的核心脚本是scripts/ubuntu_state_server.py。它创建一份MjModel和一份MjData,在后台线程里按照模型步长调用mujoco.mj_step()。服务端保留这份状态的唯一所有权,HTTP 处理线程只在锁内读取快照或更新控制值。

先进入 AtomGit 项目克隆目录,再启动服务:

source~/q1-mujoco-venv/bin/activatecd~/q1-mujoco-remote-viewer python3 scripts/ubuntu_state_server.py\--model~/q1-mujoco/official-q1-urdf/xml/q1_v3_mc.xml\--host10.211.55.4\--port8766

服务启动后,日志给出了这次实际使用的版本和模型维度:MuJoCo3.16.0、提交562b764a18613d459140ed9b255fa30434e346ef、nq=29、nv=28、nu=22,模型步长是0.001 s。初版服务只提供/api/v1/health和/api/v1/state,后面加控制时才注册/api/v1/control。服务没有身份认证,所以我只让它监听虚拟机私网地址,没有把它开放到公网。

图 3:仿真状态在没有桌面会话的 Ubuntu 中持续推进,服务绑定虚拟机私网地址。

Mac 端先不打开远程 Viewer,而是执行一个只读探针,连续读取五次状态。探针会校验提交号、XML 哈希、MuJoCo 版本和状态数组长度,再检查sequence与simulation_time是否递增。实际一轮读数从sequence=737656增加到738699,模型时间从737.656 s增加到738.699 s,root_z约为0.0445 m。本次读取只用 HTTP GET,没有向服务端发送控制值。先验证数据能稳定返回,比直接看窗口里的动作更容易定位错误:版本不一致、字段缺失或数组长度不对,探针都会在 Viewer 启动前报出来。

图 4:Mac 端确认本地和远端 XML SHA-256 一致,qpos/qvel维度一致,并连续收到递增状态。

这里的root_z也暴露了当前仿真的一个事实:没有施加站立控制时,Q1 会落到地面附近。这个结果很正常,但不能把“模型加载成功”和“机器人已经能站立”混成一个结论。当前项目验证的是状态链路,不是步态控制器。

nq=29、nv=28也不是校验冲突。模型有一个 floating base,位置状态用三维坐标和四元数表示,速度状态则是线速度与角速度;自由基座在两类向量中的维度本来就不同。客户端分别按nq检查qpos、按nv检查qvel,没有假设两个数组长度相等。

Mac Viewer 只做显示,不再推进第二套动力学

远程 Viewer 的做法比较克制。Mac 端仍然加载本地同一份q1_v3_mc.xml,但不调用mj_step()。后台线程从 Ubuntu 的/api/v1/state读取状态,主线程拿到新序号后,把远端qpos、qvel写入本地MjData,再调用mj_forward()更新几何显示。

这样做有一个好处:窗口只负责把远端状态画出来,Ubuntu 才是仿真时间和动力学状态的来源。窗口左上角还叠加了状态序号、仿真时间、状态年龄、模型提交号,以及Mac dynamics: disabled。如果这里误用了mj_step(),Mac 和 Ubuntu 就会各自往前算,画面看起来可能仍然在动,却已经不是同一个仿真状态。

在项目根目录运行下面的命令:

source~/q1-mujoco-mac-venv/bin/activate mjpython scripts/mac_remote_viewer.py\--urlhttp://10.211.55.4:8766/api/v1/state

远程 Viewer 连续运行时,GIF 中的序号从1518802增加到1520757,仿真时间从1518.804 s增加到1520.757 s,状态年龄约为0.03 s。Q1 姿态随 Ubuntu 状态变化,左上角明确标注 Mac 没有运行动力学。

图 5:Mac 窗口里的模型由 Ubuntu 发来的qpos/qvel更新,Mac 端没有调用mj_step()。

先做一条小输入,把控制边界写进服务

只读状态跑通后,我没有马上开放所有执行器,而是只开放motor_left_elbow。模型里它对应left_elbow_joint,模型ctrlrange是[-22, 22],gear 为1。服务端又增加了一层更窄的演示限制:输入绝对值不超过2.0,一次最长持续0.25 s,到期后把该执行器的控制值重置为0,同一时间不接受重叠命令。

这组参数属于本地 MuJoCo 演示策略,不是 Q1 实机控制建议。把它写进接口而不是靠调用者自觉遵守,主要是为了让测试有明确的失败条件。

更新后的服务日志还会打印实例 ID。每次进程启动都生成一个新的server_instance_id,状态响应会带着它一起返回。

图 6:服务只开放一个左肘执行器,并把输入值和持续时间限制在演示范围内。

输入不会由 Mac 直接写入模型数组,而是交给 Ubuntu 服务统一检查并应用。这样控制范围、单次时长和到期清零都由持有仿真状态的一端负责,Mac 端只发请求并读取处理结果。

Mac 端发送的 JSON 只有三个字段:

{"actuator":"motor_left_elbow","value":2.0,"duration_s":0.25}

在 Mac 项目目录执行下面的命令发送这条请求:

python3 scripts/mac_send_control.py--value2.0--duration0.25

调用脚本会先读取输入前状态,再发送一次 HTTP POST,等待 Ubuntu 的仿真时间越过到期点,最后读取输入后状态。一次实际运行返回 HTTP202,输入前left_elbow=1.313411 rad,到期后读到1.661970 rad,观测差值为+0.348559 rad,active_control已经变成none。

图 7:发送输入期间,远程 Viewer 与终端同时运行,窗口中的左肘动作可以直接观察到。

服务端返回 HTTP202只代表请求被接受,关节状态还要等后续读取才能确认。我把 Viewer 录屏和命令行读数放在一起看:前者能看到模型动作,后者能说明输入值、仿真时间以及到期状态,避免单凭动画判断请求是否按预期结束。

终端返回的控制记录里还能看到模型范围[-22.0, 22.0]、演示范围[-2.0, 2.0]、持续时间0.25 s和到期仿真时间。GIF 里的 Viewer 与发送脚本同时运行,左肘随 Ubuntu 仿真状态变化;这比单独看 HTTP 返回码更直观,但角度差还需要结合终端读数解释。输入结束后,服务端主动清零,客户端没有继续发送下一条保持命令。

图 8:HTTP202代表请求被服务接受;到期后活动控制为空,前后角度读数和观测差值由实际运行输出。

value是写入 actuator 的控制量,left_elbow则是从qpos里读取的关节位置;两者不是同一个字段,也不是目标位置和实际位置的跟踪误差。仿真本身仍在向前运行,所以前后读数要按观测值记录。

我又补了一次value=0.0的输入。它同样返回 HTTP202,到期后活动控制为空,左肘读数从1.324856 rad变为1.324855 rad,观测差值是-0.000001 rad。仿真在这段时间没有暂停,因此零值输入也不能理解为“把模型冻结在当前位置”。这只能说明零值请求经过了同样的接受、到期和清除路径,不能把它写成关节保持性能测试。

图 9:零输入也会走完整的限时控制流程,服务到期后清除活动控制。

零值正常返回和越界拒绝各自验证一条不同路径,不能拿其中一条代替另一条。服务端还会拒绝非有限数字、未知 actuator 和正在执行时的重叠命令。

范围检查也必须单独测。把输入改成23.0后,服务返回 HTTP400,错误为value 23 is outside model ctrlrange [-22, 22],状态为rejected。服务端先按模型ctrlrange检查,再应用更窄的演示值限制;这条请求在写入 MuJoCo 执行器之前就被拒绝了。错误响应保留了具体超限原因,后面排查时不需要从 Viewer 动作猜测请求有没有生效。

图 10:超过模型ctrlrange的值不会进入仿真状态,接口明确返回拒绝原因。

两种输入结果都正常还不够,网络断开时客户端也得改变状态。HTTP 返回码只能描述某一次请求,不能替代 Viewer 自己的过期判断;我因此把服务停机和恢复单独当成一个场景验证。

断线时保留最后一帧,但不继续假装在线

远程窗口最容易被忽略的问题不是“能不能显示”,而是服务断掉后画面会不会继续给人一种实时运行的错觉。我的客户端把状态年龄作为单独指标:超过一秒没有收到新状态,左上角就改成STALE / DISCONNECTED,同时保留最后一次有效的qpos/qvel,并把Connection refused写到状态栏。

我在 Viewer 运行时按下 Ubuntu 服务的Ctrl+C。断线 GIF 中,Q1 保持在最后收到的姿态,状态年龄持续增加,网络线程反复得到连接拒绝。这个表现比直接关闭窗口更有用,因为它同时保留了最后一帧和连接错误,能够看出“画面还在”不等于“仿真还在更新”。

图 11:服务停止后,Viewer 标记为STALE / DISCONNECTED,状态年龄增长,最后一帧不再被标成实时状态。

断线时 Viewer 没有清空机器人模型,而是保留最后一份有效状态。这一点有利于观察断线前姿态,但状态栏会明确标记数据已经过期,避免把静止的窗口当成实时仿真画面。

这里还遇到了一个重启后的细节。服务进程重新启动时,sequence会从较小的值重新开始。如果客户端只比较序号,就会把新服务的第一条状态误判成旧服务回退。于是我给协议加了server_instance_id:同一个实例内要求序号递增,实例 ID 变化时允许序号重新开始,并在终端记录切换。

图 12:每次 Ubuntu 服务启动生成唯一实例 ID,客户端可以区分“同一服务序号异常”和“服务已重启”。

重启服务后,Mac 端先收到新实例的低序号,再输出Ubuntu simulation instance changed和Ubuntu state connection recovered.,Viewer 状态回到CONNECTED。客户端分别看实例 ID 和实例内序号,前者决定是否开始新的一轮计数,后者用于检查同一服务进程的数据有没有停滞或倒退。

新 Ubuntu 进程创建的是一份新的MjData,所以仿真时间和状态序号会从头开始;这不是旧进程状态的持久化恢复。Viewer 接受的是新进程后续发来的状态,而不是把旧状态写回新进程。把这两种恢复区分开,才能准确描述这里验证的是“连接恢复”和“新状态流接入”。

图 13:服务重启后,Mac 识别到实例变化,接受新序号,窗口恢复为CONNECTED。

这次实践真正验证了什么

在这套 Mac + Ubuntu 虚拟机环境里,我完成了几个可以复现的检查:

  1. 固定提交的 Q1 MuJoCo 模型能够在 Ubuntu 无桌面环境中持续推进,模型维度为nq=29、nv=28、nu=22,步长为0.001 s。
  2. Ubuntu 持有唯一仿真状态,Mac 通过 HTTP 接收qpos/qvel,并用同一份模型做显示;Mac 端没有再调用mj_step()。
  3. motor_left_elbow=2.0、持续0.25 s的输入返回202,到期后自动清零;value=23.0因超出模型ctrlrange返回400。
  4. 服务停止时,Viewer 能显示状态过期和连接错误;服务重启后,客户端通过实例 ID 接受新的序号流并恢复连接。

这些结果还没有覆盖站立、行走、轨迹跟踪、闭环控制、ROS 2 控制接口或真机通信。远程 Viewer 也不是把 Mac 变成了第二个仿真器,它只是把 Ubuntu 的状态画出来。至于左肘前后角度的变化,仿真一直在推进,输入前后读数是运行区间内的观测值,不能全部归因于这一条输入。

对我来说,这个架构的意义在于把问题拆开了:Ubuntu 负责可重复的仿真状态,Mac 负责可视化和操作入口,协议负责版本校验、输入边界和连接状态。后面即使把 HTTP 换成 ROS 2 Topic 或 Service,也可以沿用这几条检查原则。没有实体机器人时,先把这条链路跑通,比做一个看起来会动但说不清状态来源的窗口更有价值。

参考资料与代码

  • 实验代码:q1-mujoco-remote-viewer(AtomGit)。仓库只包含服务端、Mac 客户端、依赖和复现说明,Q1 模型文件从官方模型仓库单独获取。
  • Q1 官方模型:启元 Q1 V3.0 q1-urdf AtomGit 仓库,实际使用提交562b764a18613d459140ed9b255fa30434e346ef。
  • MuJoCo Python 接口:MuJoCo Python bindings。
  • MuJoCo 仿真计算说明:Computation。
  • MuJoCo 可视化说明:Visualization。

Q1 模型、SDK 和开发资料可访问启元机器人开发者社区。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进