Add project sources for Mujoco LinkerHand ROS2

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
sunxianghui
2026-07-23 17:55:52 +08:00
parent 346a5c50d6
commit 1647241649
400 changed files with 35707 additions and 0 deletions
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""ROS 控制指令 → 本地 MuJoCo → 记录并绘制 角度/速度/力矩。
订 /cb_<hand>_hand_control_cmd(0~255),用与仿真节点相同的 O6 映射写 ctrl,
从 MuJoCo 读 q / v / actuator_force,Ctrl+C 存 CSV + 三曲线图。
用法:
# 终端1(可先不开 run_sim.sh,本脚本自带 MuJoCo)
python3 tools/ros_mujoco_record_qvt.py --hand left --finger middle --viewer
# 终端2 发 ROS 指令,或加 --self-sweep 自动发中指慢扫
python3 tools/ros_mujoco_record_qvt.py --hand left --finger middle --viewer --self-sweep
"""
from __future__ import annotations
import argparse
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
PKG = (
ROOT
/ "src/linkerhand-sim/linker_hand_mujoco_ros2/linker_hand_mujoco_ros2"
)
sys.path.insert(0, str(PKG.parent))
from linker_hand_mujoco_ros2.utils.mapping import ( # noqa: E402
O6_JOINT_MAP,
O6_MIMIC,
apply_mimic,
range_to_arc_left,
range_to_arc_right,
)
FINGER_CFG = {
"index": {
"mcp": "index_mcp_pitch",
"mcp_act": "index_mcp_pitch_pos",
"ros_idx": 2,
},
"middle": {
"mcp": "middle_mcp_pitch",
"mcp_act": "middle_mcp_pitch_pos",
"ros_idx": 3,
},
"ring": {
"mcp": "ring_mcp_pitch",
"mcp_act": "ring_mcp_pitch_pos",
"ros_idx": 4,
},
"pinky": {
"mcp": "pinky_mcp_pitch",
"mcp_act": "pinky_mcp_pitch_pos",
"ros_idx": 5,
},
}
def map_position_array(position, joint_map):
mapped = [0.0] * len(joint_map)
for target_idx, source_idx in joint_map.items():
if source_idx < len(position):
mapped[target_idx] = position[source_idx]
return mapped
def u8_to_ctrl(position_u8, hand_type: str):
if hand_type == "left":
tmp = range_to_arc_left(position_u8, "O6")
else:
tmp = range_to_arc_right(position_u8, "O6")
res = map_position_array(tmp, O6_JOINT_MAP)
from linker_hand_mujoco_ros2.utils.mapping import O6_JOINT_ARC
res = apply_mimic(res, O6_MIMIC, O6_JOINT_ARC)
return res
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--hand", choices=["left", "right"], default="left")
parser.add_argument("--finger", choices=list(FINGER_CFG.keys()), default="middle")
parser.add_argument("--cmd-topic", default="")
parser.add_argument("--out", type=Path, default=None)
parser.add_argument("--viewer", action="store_true")
parser.add_argument(
"--self-sweep",
action="store_true",
help="本进程自动往控制话题发 255→0→255 慢扫(方便自测)",
)
parser.add_argument(
"--exit-after-sweep",
action="store_true",
help="配合 --self-sweep:扫完自动结束并画图",
)
parser.add_argument("--duration", type=float, default=8.0, help="self-sweep 单向时长")
parser.add_argument(
"--record-duration",
type=float,
default=0.0,
help=">0 时录满该秒数自动结束(配合 middle_step_cmd_pub)",
)
parser.add_argument("--label", default="ros_sim")
args = parser.parse_args()
if args.self_sweep and not args.exit_after_sweep:
# 自测默认扫完就退出,避免一直挂着
args.exit_after_sweep = True
cfg = FINGER_CFG[args.finger]
cmd_topic = args.cmd_topic or f"/cb_{args.hand}_hand_control_cmd"
out_dir = args.out or (ROOT / f"reports/O6_{args.finger}_ros")
out_dir.mkdir(parents=True, exist_ok=True)
xml = (
PKG
/ f"urdf/O6/linker_hand_o6_{args.hand}/linker_hand_o6_{args.hand}.xml"
)
import mujoco
import mujoco.viewer
model = mujoco.MjModel.from_xml_path(str(xml))
data = mujoco.MjData(model)
model.opt.disableflags = 0
mcp_jnt = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, cfg["mcp"])
mcp_act = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, cfg["mcp_act"])
if mcp_jnt < 0 or mcp_act < 0:
raise RuntimeError(f"joint/actuator not found for {args.finger}")
qadr = int(model.jnt_qposadr[mcp_jnt])
dadr = int(model.jnt_dofadr[mcp_jnt])
mcp_lo, mcp_hi = map(float, model.jnt_range[mcp_jnt])
ctrl_lock = threading.Lock()
ctrl_values = np.zeros(model.nu)
last_cmd_u8 = [255.0] * 6
running = True
sweep_done = threading.Event()
logs = {"t": [], "cmd_rad": [], "q": [], "v": [], "tau": [], "cmd_u8": []}
t0 = time.perf_counter()
t_stop = (
t0 + args.record_duration if args.record_duration > 0 else None
)
import signal
def _stop(*_):
nonlocal running
running = False
signal.signal(signal.SIGINT, _stop)
signal.signal(signal.SIGTERM, _stop)
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
class CmdNode(Node):
def __init__(self):
super().__init__("ros_mujoco_record_qvt")
self.create_subscription(JointState, cmd_topic, self.on_cmd, 50)
self.get_logger().info(
f"MuJoCo+ROS record | topic={cmd_topic} | finger={args.finger} | "
f"joint={cfg['mcp']} range=[{mcp_lo:.2f},{mcp_hi:.2f}]"
)
self.get_logger().info("Ctrl+C to stop and plot")
def on_cmd(self, msg: JointState):
nonlocal last_cmd_u8
pos = list(msg.position)
if len(pos) < 6:
pos = pos + [255.0] * (6 - len(pos))
last_cmd_u8 = pos[:6]
mapped = u8_to_ctrl(last_cmd_u8, args.hand)
with ctrl_lock:
n = min(len(mapped), model.nu)
ctrl_values[:n] = mapped[:n]
rclpy.init()
node = CmdNode()
def spin_ros():
while running and rclpy.ok():
rclpy.spin_once(node, timeout_sec=0.01)
ros_thread = threading.Thread(target=spin_ros, daemon=True)
ros_thread.start()
pub = None
if args.self_sweep:
pub = node.create_publisher(JointState, cmd_topic, 10)
time.sleep(0.3)
def sweep_pub():
nonlocal running
half = args.duration
ros_i = cfg["ros_idx"]
# 255 -> 0
t_start = time.time()
while running and time.time() - t_start < half:
u = (time.time() - t_start) / half
mid = 255.0 * (1.0 - u)
m = JointState()
m.position = [255.0, 255.0, 255.0, 255.0, 255.0, 255.0]
m.position[ros_i] = mid
pub.publish(m)
time.sleep(0.01)
# 0 -> 255
t_start = time.time()
while running and time.time() - t_start < half:
u = (time.time() - t_start) / half
mid = 255.0 * u
m = JointState()
m.position = [255.0, 255.0, 255.0, 255.0, 255.0, 255.0]
m.position[ros_i] = mid
pub.publish(m)
time.sleep(0.01)
print("self-sweep finished", flush=True)
sweep_done.set()
if args.exit_after_sweep:
running = False
threading.Thread(target=sweep_pub, daemon=True).start()
viewer = None
if args.viewer:
viewer = mujoco.viewer.launch_passive(model, data)
try:
while running:
if viewer is not None and not viewer.is_running():
break
if t_stop is not None and time.perf_counter() >= t_stop:
break
with ctrl_lock:
data.ctrl[:] = ctrl_values
mujoco.mj_step(model, data)
if viewer is not None:
viewer.sync()
t = time.perf_counter() - t0
q = float(data.qpos[qadr])
v = float(data.qvel[dadr])
tau = float(data.actuator_force[mcp_act])
cmd_rad = float(ctrl_values[mcp_act]) if mcp_act < len(ctrl_values) else 0.0
logs["t"].append(t)
logs["cmd_rad"].append(cmd_rad)
logs["q"].append(q)
logs["v"].append(v)
logs["tau"].append(tau)
logs["cmd_u8"].append(float(last_cmd_u8[cfg["ros_idx"]]))
time.sleep(0.001)
except KeyboardInterrupt:
pass
finally:
running = False
if viewer is not None:
viewer.close()
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if len(logs["t"]) < 2:
print("no samples")
return
for k in logs:
logs[k] = np.asarray(logs[k], dtype=float)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
stem = f"{args.label}_{args.finger}_{args.hand}_{stamp}"
csv_path = out_dir / f"{stem}.csv"
png_path = out_dir / f"{stem}_qvt.png"
np.savetxt(
csv_path,
np.column_stack(
[
logs["t"],
logs["cmd_u8"],
logs["cmd_rad"],
logs["q"],
logs["v"],
logs["tau"],
]
),
delimiter=",",
header="t_s,cmd_u8,cmd_rad,q_rad,v_rad_s,tau_Nm",
comments="",
)
import os
os.environ.setdefault("MPLCONFIGDIR", str(ROOT / ".mplconfig"))
Path(os.environ["MPLCONFIGDIR"]).mkdir(parents=True, exist_ok=True)
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True)
fig.suptitle(
f"ROS->MuJoCo {args.hand} {cfg['mcp']} "
f"range=[{mcp_lo:.2f}, {mcp_hi:.2f}] rad",
fontsize=12,
)
axes[0].plot(logs["t"], np.rad2deg(logs["cmd_rad"]), "k--", lw=1.2, label="cmd")
axes[0].plot(logs["t"], np.rad2deg(logs["q"]), "C0", lw=1.6, label="q")
axes[0].set_ylabel("angle (deg)")
axes[0].legend(loc="best")
axes[0].grid(True, alpha=0.3)
axes[1].plot(logs["t"], np.rad2deg(logs["v"]), "C1", lw=1.6, label="v")
axes[1].set_ylabel("velocity (deg/s)")
axes[1].legend(loc="best")
axes[1].grid(True, alpha=0.3)
axes[2].plot(logs["t"], logs["tau"], "C3", lw=1.6, label="tau (actuator_force)")
axes[2].set_ylabel("torque (N·m)")
axes[2].set_xlabel("time (s)")
axes[2].legend(loc="best")
axes[2].grid(True, alpha=0.3)
fig.tight_layout()
fig.savefig(png_path, dpi=140)
plt.close(fig)
print(f"CSV -> {csv_path}")
print(f"plot -> {png_path}")
print(f"samples={len(logs['t'])}")
if __name__ == "__main__":
main()