#!/usr/bin/env python3 """手眼标定路点执行器。 逐个移动到每个关节路点,等到位后停留一段时间, 让相机(或操作者)采集一张标定图像。 capture_mode: auto — 停留 dwell_sec 后进入下一个位姿 keypress — 在终端按 Enter 继续 preview — OpenCV 实时相机 + Enter/Space 确认(示教时推荐) 处于 ``preview`` 模式时,可在预览窗口打开期间用示教器的 **Free Drive** 拖拽微调(ros2_control 保持 AUTO Remote;拖拽经由示教器,而非 RDK)。 """ from __future__ import annotations import csv import array import sys import threading import time from datetime import datetime, timezone from pathlib import Path from typing import List, Optional import numpy as np import rclpy from rclpy.action import ActionClient from rclpy.node import Node from rclpy.qos import qos_profile_sensor_data from sensor_msgs.msg import Image from std_msgs.msg import String from arm_application.handeye_realsense import ( is_realsense_source, resolve_realsense_topics, ) from arm_application.site_io import joint_prefix_default from arm_msgs.action import FollowJointTrajectory from arm_msgs.srv import GetArmState from arm_application.ros_node import TolerantNode try: import cv2 except Exception: # noqa: BLE001 cv2 = None # type: ignore DEFAULT_JOINTS = [ 'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7', ] DEFAULT_TIMES = [4.0] * 12 DEFAULT_POSITIONS = [ 0.00, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00, 0.35, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00, -0.35, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00, 0.00, -0.90, 0.00, 1.70, 0.00, 0.85, 0.00, 0.00, -0.50, 0.00, 1.40, 0.00, 0.55, 0.00, 0.20, -0.70, 0.15, 1.57, 0.40, 0.70, 0.30, -0.20, -0.70, -0.15, 1.57, -0.40, 0.70, -0.30, 0.25, -0.80, 0.10, 1.65, 0.25, 0.90, -0.40, -0.25, -0.80, -0.10, 1.65, -0.25, 0.90, 0.40, 0.15, -0.60, 0.20, 1.50, -0.35, 0.60, 0.50, -0.15, -0.60, -0.20, 1.50, 0.35, 0.60, -0.50, 0.00, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00, ] PREVIEW_HELP_GUI = """ === 位姿预览 / 拖拽调整(OpenCV 窗口)=== 1. 示教器:Free Drive + 按住使能键拖拽微调 2. 预览窗口:确认棋盘在画面内且清晰 3. 键盘:Enter / Space = 下一个位姿;q / Esc = 退出 """ PREVIEW_HELP_HEADLESS = """ === 位姿预览 / 拖拽调整(无 OpenCV 窗口)=== 1. 另开终端查看画面: ros2 run rqt_image_view rqt_image_view /handeye/preview_image 2. 示教器:Free Drive + 按住使能键拖拽微调 3. 本终端:Enter = 确认下一个位姿;输入 q 回车 = 退出 """ def _cv_gui_available() -> bool: if cv2 is None: return False try: cv2.namedWindow('_handeye_gui_probe', cv2.WINDOW_NORMAL) cv2.destroyWindow('_handeye_gui_probe') return True except Exception: # noqa: BLE001 return False def _image_to_bgr(msg: Image) -> np.ndarray: h, w = int(msg.height), int(msg.width) enc = (msg.encoding or '').lower() data = np.frombuffer(msg.data, dtype=np.uint8) if enc in ('bgr8', 'rgb8'): img = data.reshape(h, w, 3) return img[:, :, ::-1].copy() if enc == 'rgb8' else img if enc in ('mono8', '8uc1'): gray = data.reshape(h, w) return np.stack([gray, gray, gray], axis=-1) if enc in ('bgra8', 'rgba8'): img = data.reshape(h, w, 4) bgr = img[:, :, :3] return bgr[:, :, ::-1].copy() if enc == 'rgba8' else bgr raise RuntimeError(f'不支持的图像编码:{msg.encoding!r}') class HandeyeWaypoints(TolerantNode): def __init__(self) -> None: super().__init__('handeye_waypoints') self.declare_parameter('joint_prefix', joint_prefix_default()) self.declare_parameter('joint_names', DEFAULT_JOINTS) self.declare_parameter('velocity_scale', 0.12) self.declare_parameter('action_name', 'follow_joint_trajectory') self.declare_parameter('waypoint_times', DEFAULT_TIMES) self.declare_parameter('waypoint_positions', DEFAULT_POSITIONS) self.declare_parameter('dwell_sec', 2.5) # auto | keypress | preview self.declare_parameter('capture_mode', 'auto') self.declare_parameter('start_index', 0) self.declare_parameter('log_csv', '') self.declare_parameter('capture_topic', 'handeye/capture') self.declare_parameter('camera_source', 'd405') self.declare_parameter('camera_namespace', '') self.declare_parameter('image_topic', '') self.declare_parameter('preview_window', True) self.declare_parameter('append_poses_file', '/tmp/handeye_taught_poses.txt') prefix = str(self.get_parameter('joint_prefix').value or '') base = list( self.get_parameter('joint_names').get_parameter_value().string_array_value or DEFAULT_JOINTS ) self.joint_names = [f'{prefix}{n}' for n in base] self.velocity_scale = float(self.get_parameter('velocity_scale').value) self.action_name = str(self.get_parameter('action_name').value) self.dwell_sec = float(self.get_parameter('dwell_sec').value) self.capture_mode = str(self.get_parameter('capture_mode').value).strip().lower() self.start_index = max(0, int(self.get_parameter('start_index').value)) self.log_csv = str(self.get_parameter('log_csv').value or '').strip() topic = str(self.get_parameter('capture_topic').value) self.preview_window = bool(self.get_parameter('preview_window').value) self.append_poses_file = str( self.get_parameter('append_poses_file').value or '' ).strip() cam_src = str(self.get_parameter('camera_source').value).strip().lower() img_topic = str(self.get_parameter('image_topic').value or '').strip() ns = str(self.get_parameter('camera_namespace').value or '').strip() if is_realsense_source(cam_src) and not img_topic: img_topic, _ = resolve_realsense_topics( cam_src, camera_namespace=ns, image_topic='', camera_info_topic='' ) self.image_topic = img_topic self._client = ActionClient(self, FollowJointTrajectory, self.action_name) self._get_state = self.create_client(GetArmState, 'get_arm_state') self._capture_pub = self.create_publisher(String, topic, 10) self._preview_pub = self.create_publisher(Image, 'handeye/preview_image', 10) self._latest_bgr: Optional[np.ndarray] = None self._cv_gui = _cv_gui_available() and self.preview_window if self.capture_mode == 'preview' and not self._cv_gui: self.get_logger().warn( 'OpenCV 没有 GUI(无头环境)。请在 /handeye/preview_image 上预览,' '在本终端按 Enter 确认。' ) if self.image_topic and self.capture_mode == 'preview': self.create_subscription( Image, self.image_topic, self._on_image, qos_profile_sensor_data ) self.get_logger().info(f'预览图像:{self.image_topic}') def _on_image(self, msg: Image) -> None: try: self._latest_bgr = _image_to_bgr(msg) except Exception: # noqa: BLE001 pass def run(self) -> int: if self.capture_mode == 'preview': if cv2 is None: self.get_logger().error('preview 模式需要 OpenCV(cv2)') return 1 if not self.image_topic: self.get_logger().error('preview 模式需要 image_topic 或 camera_source') return 1 times = list(self.get_parameter('waypoint_times').value) positions = list(self.get_parameter('waypoint_positions').value) n_j = len(self.joint_names) n_pts = len(times) if n_pts < 1: self.get_logger().error('waypoint_times 为空') return 1 if len(positions) != n_pts * n_j: self.get_logger().error( f'waypoint_positions 长度 {len(positions)} != {n_pts}*{n_j}' ) return 1 if self.start_index >= n_pts: self.get_logger().error( f'start_index {self.start_index} >= 路点总数 {n_pts}' ) return 1 if not self._client.wait_for_server(timeout_sec=15.0): self.get_logger().error( f'Action [{self.action_name}] 不可用。' '请先启动整套栈:bash scripts/run_real.sh' ) return 2 csv_file = None csv_writer: Optional[csv.writer] = None if self.log_csv: path = Path(self.log_csv).expanduser() path.parent.mkdir(parents=True, exist_ok=True) csv_file = path.open('w', newline='', encoding='utf-8') csv_writer = csv.writer(csv_file) csv_writer.writerow( ['index', 'utc_iso', 'move_duration_s'] + [f'target_{n}' for n in self.joint_names] + [f'measured_{n}' for n in self.joint_names] ) self.get_logger().info(f'正在记录采集数据 → {path}') if self.capture_mode == 'preview': print(PREVIEW_HELP_GUI if self._cv_gui else PREVIEW_HELP_HEADLESS) self.get_logger().info( f'手眼标定:{n_pts} 个位姿,start={self.start_index},' f'v_scale={self.velocity_scale},dwell={self.dwell_sec}s,' f'mode={self.capture_mode}' ) self.get_logger().warn( '清理工作空间;让棋盘保持在视野内。Ctrl+C 可中止。' ) win = 'handeye_preview' try: for i in range(self.start_index, n_pts): q = positions[i * n_j : (i + 1) * n_j] move_t = max(1.0, float(times[i])) self.get_logger().info( f'[{i + 1}/{n_pts}] 移动中(约 {move_t:.1f}s)...' ) code = self._move_one(q, move_t) if code != 0: return code measured = self._read_joints() self._announce_capture(i, n_pts, q, measured) if csv_writer is not None and csv_file is not None: csv_writer.writerow( [i, datetime.now(timezone.utc).isoformat(), move_t] + list(q) + list(measured if measured else [float('nan')] * n_j) ) csv_file.flush() if not self._wait_capture(i, n_pts): self.get_logger().info('用户已中止') return 0 # 在 preview 模式下确认后,保存示教关节 if self.capture_mode == 'preview': taught = self._read_joints() if taught: self._save_taught_pose(i, taught) finally: if csv_file is not None: csv_file.close() if cv2 is not None: try: cv2.destroyWindow(win) except Exception: # noqa: BLE001 pass self.get_logger().info('手眼标定路点序列执行完毕') if self.append_poses_file and self.capture_mode == 'preview': self.get_logger().info( f'示教位姿已保存 → {self.append_poses_file}' ) return 0 def _save_taught_pose(self, index: int, joints: List[float]) -> None: line = ', '.join(f'{v:.6f}' for v in joints[:7]) + ',' print(f'# pose {index}') print(line) if not self.append_poses_file: return path = Path(self.append_poses_file).expanduser() path.parent.mkdir(parents=True, exist_ok=True) with path.open('a', encoding='utf-8') as f: f.write(f'# pose {index}\n') f.write(line + '\n') self.get_logger().info(f'已保存位姿 {index} → {path}') def _move_one(self, positions: List[float], duration: float) -> int: goal = FollowJointTrajectory.Goal() goal.joint_names = self.joint_names goal.num_points = 1 goal.velocity_scale = self.velocity_scale goal.positions = list(positions) goal.time_from_start = [float(duration)] send = self._client.send_goal_async(goal) if not self._wait_future(send, 10.0, '发送目标'): return 3 handle = send.result() if handle is None or not handle.accepted: self.get_logger().error('目标被拒绝') return 4 res_fut = handle.get_result_async() if not self._wait_future(res_fut, max(60.0, duration * 3.0), '结果'): return 5 result = res_fut.result().result if not result.success: self.get_logger().error(f'移动失败:{result.message}') return 6 return 0 def _wait_future(self, fut, timeout: float, label: str) -> bool: deadline = time.monotonic() + timeout while rclpy.ok() and not fut.done(): rclpy.spin_once(self, timeout_sec=0.05) if time.monotonic() > deadline: self.get_logger().error(f'等待 {label} 超时') return False return fut.done() def _read_joints(self) -> Optional[List[float]]: if not self._get_state.wait_for_service(timeout_sec=0.5): return None fut = self._get_state.call_async(GetArmState.Request()) if not self._wait_future(fut, 2.0, 'get_arm_state'): return None res = fut.result() if res is None or not res.success: return None return list(res.joint_positions) def _announce_capture( self, index: int, n_pts: int, target: List[float], measured: Optional[List[float]], ) -> None: msg = String() msg.data = f'capture:{index}:{n_pts}' self._capture_pub.publish(msg) q_str = ', '.join(f'{v:.4f}' for v in (measured or target)) if self.capture_mode == 'preview': self.get_logger().info( f'[{index + 1}/{n_pts}] 已到位 —— 用 Free Drive 调整,然后按 Enter/Space' ) else: self.get_logger().info( f'[{index + 1}/{n_pts}] 已到位 —— 现在采集 q=[{q_str}]' ) def _wait_capture(self, index: int, n_pts: int) -> bool: if self.capture_mode == 'preview': return self._wait_preview(index, n_pts) if self.capture_mode in ('key', 'keypress', 'manual'): self.get_logger().info( '采集完成后按 Enter(q+Enter 退出)...' ) for _ in range(5): rclpy.spin_once(self, timeout_sec=0.05) line = sys.stdin.readline() if not line or line.strip().lower() in ('q', 'quit', 'exit'): return False return True end = time.monotonic() + max(0.0, self.dwell_sec) while rclpy.ok() and time.monotonic() < end: rclpy.spin_once(self, timeout_sec=0.05) return rclpy.ok() def _annotate_preview( self, bgr: np.ndarray, index: int, n_pts: int ) -> np.ndarray: vis = bgr.copy() if cv2 is None: return vis cv2.putText( vis, f'位姿 {index + 1}/{n_pts}', (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2, ) cv2.putText( vis, 'Free Drive 拖拽 | Enter=下一个 q=退出', (12, 56), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2, ) return vis def _publish_preview(self, bgr: np.ndarray) -> None: msg = Image() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'camera' msg.height, msg.width = bgr.shape[0], bgr.shape[1] msg.encoding = 'bgr8' msg.is_bigendian = 0 msg.step = msg.width * 3 msg.data = array.array('B', bgr.tobytes()) # 快速路径;用 bytes 会多花约 40 ms self._preview_pub.publish(msg) def _wait_preview(self, index: int, n_pts: int) -> bool: if self._cv_gui: return self._wait_preview_opencv(index, n_pts) return self._wait_preview_terminal(index, n_pts) def _wait_preview_terminal(self, index: int, n_pts: int) -> bool: confirmed = threading.Event() quit_req = threading.Event() def _stdin_reader() -> None: try: line = sys.stdin.readline() except Exception: # noqa: BLE001 quit_req.set() return if not line or line.strip().lower() in ('q', 'quit', 'exit'): quit_req.set() else: confirmed.set() self.get_logger().info( f'[{index + 1}/{n_pts}] 用 Free Drive 调整,然后按 Enter ' '(q+Enter 退出)' ) reader = threading.Thread(target=_stdin_reader, daemon=True) reader.start() no_frame_warn = False while rclpy.ok(): rclpy.spin_once(self, timeout_sec=0.05) if confirmed.is_set(): return True if quit_req.is_set(): return False if not reader.is_alive() and not confirmed.is_set() and not quit_req.is_set(): return False bgr = self._latest_bgr if bgr is None: if not no_frame_warn: self.get_logger().warn( f'正在等待 {self.image_topic} ...', throttle_duration_sec=3.0, ) no_frame_warn = True continue vis = self._annotate_preview(bgr, index, n_pts) self._publish_preview(vis) return False def _wait_preview_opencv(self, index: int, n_pts: int) -> bool: assert cv2 is not None win = 'handeye_preview' cv2.namedWindow(win, cv2.WINDOW_NORMAL) no_frame_warn = False while rclpy.ok(): rclpy.spin_once(self, timeout_sec=0.02) bgr = self._latest_bgr if bgr is None: if not no_frame_warn: self.get_logger().warn( f'正在等待 {self.image_topic} ...', throttle_duration_sec=3.0, ) no_frame_warn = True key = cv2.waitKey(30) & 0xFF else: vis = self._annotate_preview(bgr, index, n_pts) self._publish_preview(vis) cv2.imshow(win, vis) key = cv2.waitKey(30) & 0xFF if key in (13, 10, 32): return True if key in (27, ord('q'), ord('Q')): return False return False def main(args=None) -> None: rclpy.init(args=args) node = HandeyeWaypoints() code = 1 try: code = node.run() except KeyboardInterrupt: code = 0 finally: node.destroy_node() if rclpy.ok(): rclpy.shutdown() raise SystemExit(code) if __name__ == '__main__': main()