#!/usr/bin/env python3 """真机 ROS 录制:订 control_cmd + hand_state,画与仿真相同的三曲线(deg / deg/s / current)。 配合 step 指令(先握中指再张开): python3 tools/ros_real_record_qvt.py --hand left --finger middle --auto-step 手动:终端1 本脚本;终端2 发 ros2 topic pub ... """ from __future__ import annotations import argparse import json 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 range_to_arc_left, range_to_arc_right, ) FINGER_CFG = { "index": {"ros_idx": 2, "mcp": "index_mcp_pitch", "lo": 0.0, "hi": 1.60}, "middle": {"ros_idx": 3, "mcp": "middle_mcp_pitch", "lo": 0.0, "hi": 1.60}, "ring": {"ros_idx": 4, "mcp": "ring_mcp_pitch", "lo": 0.0, "hi": 1.60}, "pinky": {"ros_idx": 5, "mcp": "pinky_mcp_pitch", "lo": 0.0, "hi": 1.60}, } def u8_to_rad(u8: float, hand: str, ros_idx: int) -> float: pos = [255.0] * 6 pos[ros_idx] = float(u8) if hand == "left": arc = range_to_arc_left(pos, "O6") else: arc = range_to_arc_right(pos, "O6") return float(arc[ros_idx]) 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("--state-topic", default="") parser.add_argument("--info-topic", default="") parser.add_argument("--out", type=Path, default=None) parser.add_argument("--label", default="ros_real") parser.add_argument("--hz", type=float, default=100.0) parser.add_argument( "--auto-step", action="store_true", help="自动发 255..255,0,255..255 再张开,录完退出", ) parser.add_argument("--pre-wait", type=float, default=2.0, help="开录后等待(s)") parser.add_argument("--hold", type=float, default=3.0, help="每步保持(s)") parser.add_argument("--record-after", type=float, default=1.0, help="第二步后多录(s)") parser.add_argument( "--duration", type=float, default=0.0, help=">0 时录满该秒数自动结束(配合外部 middle_step_cmd_pub)", ) args = parser.parse_args() cfg = FINGER_CFG[args.finger] ros_i = cfg["ros_idx"] cmd_topic = args.cmd_topic or f"/cb_{args.hand}_hand_control_cmd" state_topic = args.state_topic or f"/cb_{args.hand}_hand_state" info_topic = args.info_topic or f"/cb_{args.hand}_hand_info" out_dir = args.out or (ROOT / f"reports/O6_{args.finger}_ros") out_dir.mkdir(parents=True, exist_ok=True) import rclpy from rclpy.node import Node from sensor_msgs.msg import JointState from std_msgs.msg import String logs = { "t": [], "cmd_u8": [], "joint_u8": [], "cmd_rad": [], "q_rad": [], "v_rad_s": [], "current": [], } t0 = time.perf_counter() running = True state_lock = threading.Lock() last_cmd_u8 = [255.0] * 6 last_joint_u8 = [float("nan")] * 6 last_current = [float("nan")] * 6 have_state = False have_current = False class Recorder(Node): def __init__(self): super().__init__("ros_real_record_qvt") self.pub = self.create_publisher(JointState, cmd_topic, 10) self.create_subscription(JointState, cmd_topic, self.on_cmd, 50) self.create_subscription(JointState, state_topic, self.on_state, 50) self.create_subscription(String, info_topic, self.on_info, 10) self.create_timer(1.0 / max(1.0, args.hz), self.on_timer) self.get_logger().info( f"real record | cmd={cmd_topic} | state={state_topic} | " f"finger={args.finger} idx={ros_i}" ) def on_cmd(self, msg: JointState): nonlocal last_cmd_u8 pos = list(msg.position) if len(pos) >= 6: with state_lock: last_cmd_u8 = [float(x) for x in pos[:6]] def on_state(self, msg: JointState): nonlocal have_state, last_joint_u8, have_current, last_current with state_lock: pos = list(msg.position) if len(pos) >= 6 and all(float(x) >= 0 for x in pos[:6]): have_state = True last_joint_u8 = [float(x) for x in pos[:6]] if msg.effort and any(abs(float(x)) > 1e-6 for x in msg.effort): have_current = True last_current = [float(x) for x in msg.effort[:6]] def on_info(self, msg: String): nonlocal have_current, last_current try: info = json.loads(msg.data) cur = info.get("current") if isinstance(cur, list) and len(cur) >= 6: if any(float(x) >= 0 for x in cur[:6]): with state_lock: have_current = True last_current = [float(x) for x in cur[:6]] except json.JSONDecodeError: pass def publish_pose(self, middle_u8: float): nonlocal last_cmd_u8 m = JointState() m.position = [255.0, 255.0, 255.0, 255.0, 255.0, 255.0] m.position[ros_i] = float(middle_u8) with state_lock: last_cmd_u8 = list(m.position) self.pub.publish(m) def hold_pose(self, middle_u8: float, duration: float, rate_hz: float = 10.0): """持续重发控制帧(SDK 只响应订阅到的 cmd;单发 --once 可能丢)。""" end = time.perf_counter() + duration dt = 1.0 / max(1.0, rate_hz) while time.perf_counter() < end and running: self.publish_pose(middle_u8) time.sleep(dt) def on_timer(self): nonlocal running if not running: return with state_lock: joint_u8 = list(last_joint_u8) current = list(last_current) cmd_u8 = float(last_cmd_u8[ros_i]) if have_state and not np.isnan(joint_u8[ros_i]): q_u8 = float(joint_u8[ros_i]) else: q_u8 = cmd_u8 t = time.perf_counter() - t0 q_rad = u8_to_rad(q_u8, args.hand, ros_i) cmd_rad = u8_to_rad(cmd_u8, args.hand, ros_i) cur = current[ros_i] if len(current) > ros_i else float("nan") logs["t"].append(t) logs["cmd_u8"].append(cmd_u8) logs["joint_u8"].append(q_u8) logs["cmd_rad"].append(cmd_rad) logs["q_rad"].append(q_rad) logs["current"].append(cur) if len(logs["t"]) >= 2: dt = logs["t"][-1] - logs["t"][-2] if dt > 1e-9: v = (logs["q_rad"][-1] - logs["q_rad"][-2]) / dt else: v = 0.0 else: v = 0.0 logs["v_rad_s"].append(v) rclpy.init() node = Recorder() def spin(): while running and rclpy.ok(): rclpy.spin_once(node, timeout_sec=0.01) threading.Thread(target=spin, daemon=True).start() if args.auto_step: deadline = time.perf_counter() + max(5.0, args.pre_wait) while time.perf_counter() < deadline and not have_state: time.sleep(0.05) if not have_state: node.get_logger().warn( "仍未收到 hand_state(可能 SDK 未跑 / Domain 不对 / 全是 -1)" ) time.sleep(max(0.5, args.pre_wait)) node.get_logger().info("step1: middle close (u8=0)") node.hold_pose(0.0, args.hold) node.get_logger().info("step2: middle open (u8=255)") node.hold_pose(255.0, args.hold + args.record_after) running = False else: node.get_logger().info( "listen mode: 外部发令 (middle_step_cmd_pub) 或 Ctrl+C 结束" ) t_end = ( time.perf_counter() + args.duration if args.duration > 0 else None ) try: while running: if t_end is not None and time.perf_counter() >= t_end: break time.sleep(0.05) except KeyboardInterrupt: running = False time.sleep(0.2) node.destroy_node() if rclpy.ok(): rclpy.shutdown() if len(logs["t"]) < 2: print("no samples") return 1 for k in logs: logs[k] = np.asarray(logs[k], dtype=float) if len(logs["v_rad_s"]) >= 5: logs["v_rad_s"] = np.convolve( logs["v_rad_s"], np.ones(5) / 5.0, mode="same" ) 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["joint_u8"], logs["cmd_rad"], logs["q_rad"], logs["v_rad_s"], logs["current"], ] ), delimiter=",", header="t_s,cmd_u8,joint_u8,cmd_rad,q_rad,v_rad_s,current", 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 mcp_lo, mcp_hi = cfg["lo"], cfg["hi"] fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True) fig.suptitle( f"ROS->Real {args.hand} {cfg['mcp']} " f"range=[{mcp_lo:.2f}, {mcp_hi:.2f}] rad " f"(255=open/0deg, 0=close/~92deg)", fontsize=11, ) 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_rad"]), "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_rad_s"]), "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) if have_current and np.any(np.isfinite(logs["current"])): axes[2].plot( logs["t"], logs["current"], "C3", lw=1.6, label="current (hand_info)" ) axes[2].set_ylabel("current") else: axes[2].text( 0.5, 0.5, "no current (subscribe hand_info or state.effort)", ha="center", va="center", transform=axes[2].transAxes, ) axes[2].set_ylabel("current") 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'])} state={'yes' if have_state else 'NO'}") cmd_span = float(np.max(logs["cmd_u8"]) - np.min(logs["cmd_u8"])) joint_span = float(np.max(logs["joint_u8"]) - np.min(logs["joint_u8"])) print(f"cmd_u8 span={cmd_span:.1f} joint_u8 span={joint_span:.1f}") if cmd_span < 1.0: print("[warn] cmd 全程几乎不变 → 控制指令可能没发出去或未写入录制") if not have_state: print("[warn] 未收到有效 state → q 只能用 cmd 代替,且真机可能没动") if cmd_span >= 1.0 and joint_span < 3.0 and have_state: print("[warn] 有 cmd 变化但 joint 几乎不动 → 查 CAN / SDK / 力矩限制") return 0 if __name__ == "__main__": raise SystemExit(main())