feat: 添加双臂 MuJoCo 显示节点

This commit is contained in:
2026-08-04 13:36:39 +08:00
parent f5790a8c77
commit 826b929d97
4 changed files with 133 additions and 0 deletions
@@ -0,0 +1,4 @@
# 双 RM75 MuJoCo 运动学显示参数。初始姿态和控制限制仍由 dual_arm_rm75.yaml 管理。
dual_arm_simulator:
ros__parameters:
render_rate_hz: 60.0
+5
View File
@@ -20,4 +20,9 @@ setup(
description="MuJoCo kinematic visualization for the dual RM75 platform.", description="MuJoCo kinematic visualization for the dual RM75 platform.",
license="Apache-2.0", license="Apache-2.0",
tests_require=["pytest"], tests_require=["pytest"],
entry_points={
"console_scripts": [
"dual_arm_simulator = xr_rm_mujoco.dual_arm_simulator:main",
],
},
) )
@@ -15,6 +15,9 @@ URDF_PATH = (
SRC_DIR / "xr_rm_teleop" / "models" / "dual_rm75" / "Dual_arm.urdf" SRC_DIR / "xr_rm_teleop" / "models" / "dual_rm75" / "Dual_arm.urdf"
) )
DUAL_CONFIG_PATH = SRC_DIR / "xr_rm_bringup" / "config" / "dual_arm_rm75.yaml" DUAL_CONFIG_PATH = SRC_DIR / "xr_rm_bringup" / "config" / "dual_arm_rm75.yaml"
MUJOCO_CONFIG_PATH = (
SRC_DIR / "xr_rm_bringup" / "config" / "dual_arm_mujoco.yaml"
)
def test_dual_urdf_loads_with_expected_joint_mapping() -> None: def test_dual_urdf_loads_with_expected_joint_mapping() -> None:
@@ -60,6 +63,13 @@ def test_yaml_initial_poses_populate_both_arms() -> None:
assert simulation.ready assert simulation.ready
def test_mujoco_config_contains_only_render_parameters() -> None:
with MUJOCO_CONFIG_PATH.open(encoding="utf-8") as stream:
parameters = yaml.safe_load(stream)["dual_arm_simulator"]["ros__parameters"]
assert parameters == {"render_rate_hz": 60.0}
@pytest.mark.parametrize( @pytest.mark.parametrize(
("names", "positions", "match"), ("names", "positions", "match"),
[ [
@@ -4,14 +4,23 @@ from __future__ import annotations
import math import math
from pathlib import Path from pathlib import Path
from typing import Callable
import mujoco import mujoco
import mujoco.viewer
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
ARM_JOINT_NAMES = { ARM_JOINT_NAMES = {
"left": tuple(f"scissor_joint_{index}" for index in range(1, 8)), "left": tuple(f"scissor_joint_{index}" for index in range(1, 8)),
"right": tuple(f"omnipic_joint_{index}" for index in range(1, 8)), "right": tuple(f"omnipic_joint_{index}" for index in range(1, 8)),
} }
STATE_TOPICS = {
"left": "/xr_rm/left_rm75/joint_states",
"right": "/xr_rm/right_rm75/joint_states",
}
class DualArmKinematicModel: class DualArmKinematicModel:
@@ -84,3 +93,108 @@ class DualArmKinematicModel:
float(self.data.qpos[self._qpos_addresses[arm][name]]) float(self.data.qpos[self._qpos_addresses[arm][name]])
for name in ARM_JOINT_NAMES[arm] for name in ARM_JOINT_NAMES[arm]
] ]
class DualArmSimulator(Node):
"""订阅左右关节反馈并刷新一个 MuJoCo 双臂 viewer。"""
def __init__(
self,
viewer_factory: Callable = mujoco.viewer.launch_passive,
) -> None:
super().__init__("dual_arm_simulator")
self.declare_parameter("robot_urdf_path", "")
self.declare_parameter("render_rate_hz", 60.0)
render_rate_hz = float(self.get_parameter("render_rate_hz").value)
if not math.isfinite(render_rate_hz) or render_rate_hz <= 0.0:
raise ValueError("render_rate_hz must be finite and > 0")
self._kinematics = DualArmKinematicModel(
str(self.get_parameter("robot_urdf_path").value)
)
self._viewer_factory = viewer_factory
self._viewer = None
self._subscriptions = [
self.create_subscription(
JointState,
topic,
lambda message, selected_arm=arm: self._on_joint_state(
selected_arm,
message,
),
10,
)
for arm, topic in STATE_TOPICS.items()
]
self.create_timer(1.0 / render_rate_hz, self._render)
self.get_logger().info(
"MuJoCo 双臂节点已启动,等待左右关节状态,"
f"render_rate_hz={render_rate_hz:.1f}"
)
def _on_joint_state(self, arm: str, message: JointState) -> None:
try:
if self._viewer is None:
self._kinematics.apply_arm_state(
arm,
list(message.name),
list(message.position),
)
else:
with self._viewer.lock():
self._kinematics.apply_arm_state(
arm,
list(message.name),
list(message.position),
)
except (RuntimeError, ValueError) as exc:
self.get_logger().warn(
f"拒绝 {arm} 关节状态:{exc}",
throttle_duration_sec=1.0,
)
def _render(self) -> None:
for topic in STATE_TOPICS.values():
publisher_count = self.count_publishers(topic)
if publisher_count > 1:
self.get_logger().warn(
f"关节状态话题存在多个发布者:{topic}, count={publisher_count}",
throttle_duration_sec=5.0,
)
if not self._kinematics.ready:
return
if self._viewer is None:
self._viewer = self._viewer_factory(
self._kinematics.model,
self._kinematics.data,
)
if not self._viewer.is_running():
self.get_logger().info("MuJoCo viewer 已关闭。")
rclpy.shutdown()
return
self._viewer.sync()
def close_viewer(self) -> None:
if self._viewer is not None:
self._viewer.close()
self._viewer = None
def main(args=None) -> None:
rclpy.init(args=args)
node = None
try:
node = DualArmSimulator()
rclpy.spin(node)
finally:
if node is not None:
node.close_viewer()
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
main()