#!/usr/bin/env python3 """完整手眼标定流水线:移动 → 棋盘稳定判定 → 采集 → 求解 → 清理。 支持 eye_in_hand / eye_to_hand。 相机内参优先级: 1) ROS 的 CameraInfo 话题(若正在发布) 2) 通过 pyrealsense2 读取 RealSense D405/D435 的 EEPROM(camera_source) 3) YAML 中手写的 camera_fx/fy/cx/cy 棋盘尺寸(board_cols / board_rows / square_size_m)可由用户配置。 """ from __future__ import annotations import json import shutil import time from datetime import datetime, timezone from pathlib import Path from typing import List, Optional, Tuple import numpy as np import rclpy from geometry_msgs.msg import PoseStamped from rclpy.action import ActionClient from rclpy.node import Node from rclpy.qos import qos_profile_sensor_data from sensor_msgs.msg import CameraInfo, Image, JointState from std_msgs.msg import String from arm_application.handeye_lib import ( BoardConfig, CameraIntrinsics, ChessboardStabilizer, T_from_flexiv_tcp, calibrate_hand_eye, cam_to_flange_via_tool, detect_chessboard, format_matrix_yaml, format_transform_yaml, is_pose_filled, pose_to_Rt, solve_board_pose, ) from arm_application.handeye_realsense import ( is_realsense_source, query_realsense_color_intrinsics, 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 DEFAULT_JOINTS = [ 'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7', ] 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}') def _camera_info_to_intrinsics(msg: CameraInfo) -> CameraIntrinsics: K = np.array(msg.k, dtype=float).reshape(3, 3) dist = np.array(msg.d, dtype=float).reshape(-1, 1) if msg.d else np.zeros((5, 1)) return CameraIntrinsics(K=K, dist=dist) class HandeyeCalibrate(TolerantNode): def __init__(self) -> None: super().__init__('handeye_calibrate') self.declare_parameter('mode', 'eye_in_hand') # eye_in_hand | eye_to_hand self.declare_parameter('joint_prefix', joint_prefix_default()) self.declare_parameter('joint_names', DEFAULT_JOINTS) self.declare_parameter('velocity_scale', 0.10) self.declare_parameter('action_name', 'follow_joint_trajectory') self.declare_parameter('move_time_sec', 4.0) self.declare_parameter('start_index', 0) self.declare_parameter('num_poses', 12) self.declare_parameter('waypoint_positions', [0.0] * (12 * 7)) self.declare_parameter('min_poses', 5) # 相机来源:manual | d405 | d435 self.declare_parameter('camera_source', 'manual') self.declare_parameter('camera_serial', '') self.declare_parameter('camera_namespace', '') self.declare_parameter('realsense_width', 1280) self.declare_parameter('realsense_height', 720) self.declare_parameter('image_topic', '') self.declare_parameter('camera_info_topic', '') self.declare_parameter('camera_fx', 0.0) self.declare_parameter('camera_fy', 0.0) self.declare_parameter('camera_cx', 0.0) self.declare_parameter('camera_cy', 0.0) self.declare_parameter('camera_dist', [0.0, 0.0, 0.0, 0.0, 0.0]) # 棋盘(可自定义) # 默认值必须与 site.yaml 的 handeye.board_* 及结果文件头里的实际板子一致。 # 真实使用的板:A5 内角点 11x8,方格 15mm(site.yaml handeye.boards.eye_in_hand)。 # 这里原先写的是 9x6 / 0.025,后来是 8x11;漏配就会静默用错尺寸。 self.declare_parameter('board_cols', 11) # 内角点 self.declare_parameter('board_rows', 8) self.declare_parameter('square_size_m', 0.015) self.declare_parameter('stable_window', 8) self.declare_parameter('stable_pixel_std', 0.45) self.declare_parameter('stable_timeout_sec', 25.0) self.declare_parameter('settle_sec', 0.4) # 到位后的"真的停住了"判据 —— 直接看关节速度,比固定 sleep 可靠。 # 这几个参数原先 site.yaml 和 run_handeye.sh 一直在传,但本节点从未声明, # ROS 2 会静默忽略未声明的覆盖参数,于是"等静止再拍照"这件事其实没实现, # 实际只靠一次固定 settle_sec。现在补上。 self.declare_parameter('settle_qd_max', 0.015) # |qd|_max [rad/s] 低于此值算静 self.declare_parameter('settle_stable_sec', 0.6) # 需要连续静多久 self.declare_parameter('settle_timeout_sec', 8.0) # 等静止的总超时 self.declare_parameter('pin_holds', 2) # 静止后再保持几拍 self.declare_parameter('pin_hold_sec', 0.45) # 每拍多长 self.declare_parameter('skip_unstable_poses', True) from arm_application.site_io import ros2_root self.declare_parameter('output_dir', str(ros2_root() / 'handeye' / 'tmp')) self.declare_parameter('result_file', '') self.declare_parameter('cleanup_images', True) # 眼在手上:AX=XB 的 gripper 用 Studio 工具 TCP,再折成 cam→法兰写 T_4x4。 # 空 = 旧口径(ee_pose 当 gripper,T_4x4 原样写出)。配了就必须带 TCP。 self.declare_parameter('gripper_tool', '') self.declare_parameter('gripper_tcp_xyz', [0.0, 0.0, 0.0]) self.declare_parameter('gripper_tcp_qwxyz', [1.0, 0.0, 0.0, 0.0]) 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.mode = str(self.get_parameter('mode').value).strip().lower() self.velocity_scale = float(self.get_parameter('velocity_scale').value) self.action_name = str(self.get_parameter('action_name').value) self.move_time_sec = float(self.get_parameter('move_time_sec').value) self.start_index = max(0, int(self.get_parameter('start_index').value)) self.min_poses = int(self.get_parameter('min_poses').value) self.cleanup_images = bool(self.get_parameter('cleanup_images').value) self.gripper_tool = str(self.get_parameter('gripper_tool').value or '').strip() tcp_xyz = list( self.get_parameter('gripper_tcp_xyz').get_parameter_value().double_array_value or [0.0, 0.0, 0.0] ) tcp_q = list( self.get_parameter('gripper_tcp_qwxyz').get_parameter_value().double_array_value or [1.0, 0.0, 0.0, 0.0] ) self.T_flange_to_tool = T_from_flexiv_tcp(tcp_xyz[:3], tcp_q[:4]) if self.gripper_tool and float(np.linalg.norm(self.T_flange_to_tool[:3, 3])) < 1e-4: raise RuntimeError( f'gripper_tool={self.gripper_tool!r} 但 gripper_tcp_xyz 几乎是零:' '等于没把 Studio TCP 传进来。不许按法兰口径假装在用工具系。' ) self.camera_source = str(self.get_parameter('camera_source').value).strip().lower() self.camera_serial = str(self.get_parameter('camera_serial').value or '').strip() self.camera_namespace = str( self.get_parameter('camera_namespace').value or '' ).strip() self.rs_width = int(self.get_parameter('realsense_width').value) self.rs_height = int(self.get_parameter('realsense_height').value) self.board = BoardConfig( cols=int(self.get_parameter('board_cols').value), rows=int(self.get_parameter('board_rows').value), square_size=float(self.get_parameter('square_size_m').value), ) self.stabilizer = ChessboardStabilizer( window=int(self.get_parameter('stable_window').value), max_pixel_std=float(self.get_parameter('stable_pixel_std').value), ) self.stable_timeout = float(self.get_parameter('stable_timeout_sec').value) self.settle_sec = float(self.get_parameter('settle_sec').value) self.settle_qd_max = float(self.get_parameter('settle_qd_max').value) self.settle_stable_sec = float(self.get_parameter('settle_stable_sec').value) self.settle_timeout = float(self.get_parameter('settle_timeout_sec').value) self.pin_holds = max(0, int(self.get_parameter('pin_holds').value)) self.pin_hold_sec = max(0.0, float(self.get_parameter('pin_hold_sec').value)) self.skip_unstable = bool(self.get_parameter('skip_unstable_poses').value) out = Path(str(self.get_parameter('output_dir').value)).expanduser() self.output_dir = out self.images_dir = out / 'images' rf = str(self.get_parameter('result_file').value or '').strip() self.result_file = Path(rf).expanduser() if rf else (out / 'handeye_result.yaml') 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, 'handeye/capture', 10) self._latest_image: Optional[Image] = None self._latest_info: Optional[CameraInfo] = None self._latest_js: Optional[JointState] = None self._intrinsics: Optional[CameraIntrinsics] = None img_topic = str(self.get_parameter('image_topic').value or '').strip() info_topic = str(self.get_parameter('camera_info_topic').value or '').strip() if is_realsense_source(self.camera_source): img_topic, info_topic = resolve_realsense_topics( self.camera_source, camera_namespace=self.camera_namespace, image_topic=img_topic, camera_info_topic=info_topic, ) self._info_topic = info_topic self.create_subscription(JointState, '/joint_states', self._on_js, 10) if not img_topic: self.get_logger().error( 'image_topic 为空。请设置 camera_source:=d405|d435,或填写 image_topic。' ) else: self.create_subscription( Image, img_topic, self._on_image, qos_profile_sensor_data ) self.get_logger().info(f'已订阅图像:{img_topic}(BEST_EFFORT)') if info_topic: self.create_subscription( CameraInfo, info_topic, self._on_info, qos_profile_sensor_data ) self.get_logger().info(f'已订阅 camera_info:{info_topic}(BEST_EFFORT)') def _on_js(self, msg: JointState) -> None: self._latest_js = msg def _on_image(self, msg: Image) -> None: self._latest_image = msg def _on_info(self, msg: CameraInfo) -> None: self._latest_info = msg def run(self) -> int: if self.mode not in ('eye_in_hand', 'eye_to_hand'): self.get_logger().error( f'未知的 mode={self.mode!r};请使用 eye_in_hand 或 eye_to_hand' ) return 1 poses = self._load_poses() if len(poses) < self.min_poses: self.get_logger().error( f'只有 {len(poses)} 个已填写位姿(需要 >= {self.min_poses})。' '示教命令:bash ~/ros2/scripts/run_handeye.sh teach && merge。' ) return 1 if self.start_index >= len(poses): self.get_logger().error( f'start_index {self.start_index} >= 已填写位姿数 {len(poses)}' ) return 1 if not self._resolve_intrinsics(): return 1 if not self._client.wait_for_server(timeout_sec=15.0): self.get_logger().error( f'Action [{self.action_name}] 不可用。' '请启动:bash ~/ros2/scripts/run_real.sh' ) return 2 self.output_dir.mkdir(parents=True, exist_ok=True) self.images_dir.mkdir(parents=True, exist_ok=True) eye_to_hand = self.mode == 'eye_to_hand' grip = self.gripper_tool or '(ee_pose 原样,无工具折算)' self.get_logger().info( f'手眼标定 [{self.mode}] camera_source={self.camera_source}:' f'{len(poses)} 个位姿,棋盘={self.board.cols}x{self.board.rows} ' f'方格={self.board.square_size}m,v={self.velocity_scale} ' f'gripper_tool={grip}' ) self.get_logger().warn('清理工作空间;确认急停就绪。Ctrl+C 可中止。') Rg: List[np.ndarray] = [] tg: List[np.ndarray] = [] Rc: List[np.ndarray] = [] tc: List[np.ndarray] = [] meta = [] try: for i in range(self.start_index, len(poses)): q = poses[i] self._log_progress(i, len(poses), '移动中...') code = self._move_one(q, self.move_time_sec) if code != 0: return code if not self._verify_joints(q, tol=0.08): self.get_logger().error( f'[{i + 1}] 机械臂未到达目标关节位置 ' '(控制器报告成功,但关节没有变化)。' '请重启 run_real.sh;示教器切 AUTO Remote;清除故障。' '不要相信这次标定结果。' ) return 10 end = time.monotonic() + max(0.0, self.settle_sec) while rclpy.ok() and time.monotonic() < end: rclpy.spin_once(self, timeout_sec=0.05) # 固定 settle_sec 之后再看关节速度:真停了才算数,然后 pin 一拍。 self._wait_settled() self._pin_hold() ok, std, corners, pattern, bgr = self._wait_stable() if not ok or corners is None or bgr is None: msg = f'[{i + 1}] 棋盘不稳定(std={std:.3f})' if self.skip_unstable: self.get_logger().warn(f'{msg} —— 跳过') continue self.get_logger().error(f'{msg}。中止。') return 7 ee = self._read_ee_pose() if ee is None: self.get_logger().error('读取 ee_pose 失败') return 8 assert self._intrinsics is not None R_t2c, t_t2c = solve_board_pose( corners, self.board, self._intrinsics, pattern=pattern ) xyz = [ee.pose.position.x, ee.pose.position.y, ee.pose.position.z] quat = [ ee.pose.orientation.x, ee.pose.orientation.y, ee.pose.orientation.z, ee.pose.orientation.w, ] R_g2b, t_g2b = pose_to_Rt(xyz, quat) stamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') img_path = self.images_dir / f'pose_{i:02d}_{stamp}.png' self._save_png(bgr, img_path) msg = String() msg.data = f'capture:{i}:{len(poses)}' self._capture_pub.publish(msg) Rg.append(R_g2b) tg.append(t_g2b) Rc.append(R_t2c) tc.append(t_t2c) meta.append( { 'index': i, 'joints': list(q), 'ee_xyz': xyz, 'ee_quat_xyzw': quat, 'image': str(img_path), 'corner_std_px': std, } ) self.get_logger().info( f'[{i + 1}/{len(poses)}] 已采集 ' f'(corner_std={std:.3f}px) -> {img_path.name}' ) except KeyboardInterrupt: self.get_logger().info('已中断') return 0 if len(Rg) < self.min_poses: self.get_logger().error( f'只采集到 {len(Rg)} 次;求解至少需要 {self.min_poses} 次' ) return 9 self.get_logger().info(f'用 {len(Rg)} 个视角求解手眼标定...') R, t = calibrate_hand_eye(Rg, tg, Rc, tc, eye_to_hand=eye_to_hand) extra = '' if eye_to_hand: name = 'T_cam_to_base (eye_to_hand)' yaml_txt = format_transform_yaml(R, t, name) if self.gripper_tool: extra = ( f'gripper_tool: {self.gripper_tool}\n' + format_matrix_yaml(self.T_flange_to_tool, 'T_flange_to_tool') ) self.get_logger().info( f'gripper={self.gripper_tool}:AX=XB 的手是 TCP;' f'T_4x4 是 cam→基座,不折 t={t.reshape(3)*1000} mm' ) elif self.gripper_tool: T_cam_tool = np.eye(4) T_cam_tool[:3, :3] = R T_cam_tool[:3, 3] = t.reshape(3) T_cam_flange = cam_to_flange_via_tool(T_cam_tool, self.T_flange_to_tool) delta = float(np.linalg.norm(T_cam_flange[:3, 3] - T_cam_tool[:3, 3])) tool_len = float(np.linalg.norm(self.T_flange_to_tool[:3, 3])) if tool_len > 0.05 and delta < 1e-4: self.get_logger().error( 'T_cam_to_flange 与 T_cam_to_tool 平移几乎相同,' '折算没生效。结果作废。' ) return 11 # 腕部相机在法兰前方(法兰 +Z)。乘反了 Z 会落到 −200 mm 一带。 if ( self.T_flange_to_tool[2, 3] > 0.05 and float(T_cam_flange[2, 3]) < 0.0 ): self.get_logger().error( f'T_cam_to_flange tz={float(T_cam_flange[2, 3])*1000:.1f} mm ' '在法兰后方,乘序反了。结果作废。' ) return 12 R, t = T_cam_flange[:3, :3], T_cam_flange[:3, 3].reshape(3, 1) name = 'T_cam_to_flange (eye_in_hand,由工具 TCP 折算)' yaml_txt = format_transform_yaml(R, t, name) extra = ( f'gripper_tool: {self.gripper_tool}\n' + format_matrix_yaml(self.T_flange_to_tool, 'T_flange_to_tool') + format_matrix_yaml(T_cam_tool, 'T_cam_to_tool') ) self.get_logger().info( f'gripper={self.gripper_tool}:' f'T_cam_to_tool t={T_cam_tool[:3, 3]*1000} mm → ' f'T_cam_to_flange t={T_cam_flange[:3, 3]*1000} mm' ) else: name = 'T_cam_to_gripper (eye_in_hand)' yaml_txt = format_transform_yaml(R, t, name) header = ( f'# 手眼标定结果\n' f'# mode: {self.mode}\n' f'# camera_source: {self.camera_source}\n' f'# views: {len(Rg)}\n' f'# utc: {datetime.now(timezone.utc).isoformat()}\n' f'# board: {self.board.cols}x{self.board.rows} ' f'square_m={self.board.square_size}\n' ) if self.gripper_tool and eye_to_hand: header += ( f'# gripper_tool: {self.gripper_tool}\n' f'# T_4x4 = cam→基座(AX=XB 的手是 TCP,不折到法兰)\n' f'# 旧结果备份在 results/backup/\n' ) elif self.gripper_tool: header += ( f'# gripper_tool: {self.gripper_tool}\n' f'# T_4x4 = cam→法兰(AX=XB 在 TCP 上解,再左乘 T_flange_to_tool)\n' f'# T_cam_to_tool 是原始求解;旧的法兰/tool1 备份在 results/backup/\n' ) self.result_file.parent.mkdir(parents=True, exist_ok=True) self.result_file.write_text(header + extra + yaml_txt, encoding='utf-8') (self.output_dir / 'captures.json').write_text( json.dumps(meta, indent=2), encoding='utf-8' ) self.get_logger().info('======== 手眼标定结果 ========') for line in (header + yaml_txt).strip().splitlines(): self.get_logger().info(line) self.get_logger().info(f'已保存 -> {self.result_file}') if self.cleanup_images: if self.images_dir.exists(): shutil.rmtree(self.images_dir, ignore_errors=True) self.get_logger().info('标定图像已删除') else: self.get_logger().info(f'图像保留在 {self.images_dir}') return 0 def _load_poses(self) -> List[List[float]]: flat = list(self.get_parameter('waypoint_positions').value) n_j = len(self.joint_names) n_cfg = int(self.get_parameter('num_poses').value) if n_cfg < 1: return [] need = n_cfg * n_j if len(flat) < need: self.get_logger().warn( f'waypoint_positions 长度 {len(flat)} < num_poses*{n_j}={need};' '用零补齐' ) flat = flat + [0.0] * (need - len(flat)) poses: List[List[float]] = [] for i in range(n_cfg): q = [float(v) for v in flat[i * n_j : (i + 1) * n_j]] if is_pose_filled(q): poses.append(q) else: self.get_logger().warn(f'位姿槽 {i} 为空(全零)—— 已跳过') return poses def _resolve_intrinsics(self) -> bool: # 1) CameraInfo deadline = time.monotonic() + 3.0 while rclpy.ok() and self._latest_info is None and time.monotonic() < deadline: rclpy.spin_once(self, timeout_sec=0.1) if self._latest_info is not None: self._intrinsics = _camera_info_to_intrinsics(self._latest_info) K = self._intrinsics.K self.get_logger().info( f'内参来自 CameraInfo({self._info_topic}):' f'fx={K[0, 0]:.2f} fy={K[1, 1]:.2f} cx={K[0, 2]:.2f} cy={K[1, 2]:.2f}' ) return True # 2) RealSense EEPROM if is_realsense_source(self.camera_source): try: intr = query_realsense_color_intrinsics( self.camera_source, serial=self.camera_serial, width=self.rs_width, height=self.rs_height, ) except Exception as exc: # noqa: BLE001 self.get_logger().error(f'RealSense 内参查询失败:{exc}') else: self._intrinsics = CameraIntrinsics(K=intr.as_K(), dist=intr.as_dist()) self.get_logger().info( f'内参来自 RealSense {intr.model.upper()} ' f'serial={intr.serial} {intr.width}x{intr.height}:' f'fx={intr.fx:.2f} fy={intr.fy:.2f} ' f'cx={intr.cx:.2f} cy={intr.cy:.2f}' ) if (intr.width, intr.height) != (self.rs_width, self.rs_height): self.get_logger().warn( f'请求 {self.rs_width}x{self.rs_height},' f'实际得到 {intr.width}x{intr.height}(最接近的 profile)' ) return True # 3) Manual YAML fx = float(self.get_parameter('camera_fx').value) fy = float(self.get_parameter('camera_fy').value) cx = float(self.get_parameter('camera_cx').value) cy = float(self.get_parameter('camera_cy').value) if fx <= 0 or fy <= 0: self.get_logger().error( '没有内参。请设置 camera_source:=d405|d435,' '或提供 camera_info_topic,或填写 camera_fx/fy/cx/cy。' ) return False dist = np.array( list(self.get_parameter('camera_dist').value), dtype=float ).reshape(-1, 1) K = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]], dtype=float) self._intrinsics = CameraIntrinsics(K=K, dist=dist) self.get_logger().info( f'内参来自 YAML:fx={fx:.2f} fy={fy:.2f} cx={cx:.2f} cy={cy:.2f}' ) return True def _log_progress(self, i: int, total: int, suffix: str = '') -> None: """带进度条的日志。 自动标定是无人值守的长流程(十几个点位 × 移动+稳定+拍照), 单看 `[3/12]` 很难有"还要多久"的直觉,加一条可视化进度条。 """ frac = (i + 1) / max(1, total) width = 24 filled = int(round(frac * width)) bar = '█' * filled + '░' * (width - filled) self.get_logger().info( f'[{i + 1}/{total}] [{bar}] {frac * 100:5.1f}% {suffix}' ) def _wait_settled(self) -> bool: """等机械臂真的停下来,再进入拍照。 判据是关节速度 |qd|_max 连续 ``settle_stable_sec`` 低于 ``settle_qd_max``。 为什么不用固定 sleep:`settle_sec` 是拍脑袋的时长,臂负载/姿态不同, 刹车距离差别很大 —— 等太短会在臂还在动时拍照(棋盘虚 → 角点抖动), 等太长则整轮标定被无谓拉长。直接看速度更准。 超时不算致命:记一条警告后继续,让后面的棋盘稳定性检查去把关。 """ if self.settle_qd_max <= 0.0: return True deadline = time.monotonic() + self.settle_timeout stable_since: Optional[float] = None while rclpy.ok() and time.monotonic() < deadline: rclpy.spin_once(self, timeout_sec=0.05) js = self._latest_js if js is None or not list(js.velocity): continue try: vmax = float(np.max(np.abs(np.asarray(js.velocity, dtype=float)))) except Exception: # noqa: BLE001 continue now = time.monotonic() if vmax <= self.settle_qd_max: if stable_since is None: stable_since = now elif now - stable_since >= self.settle_stable_sec: return True else: stable_since = None # 又动了,重新计时 self.get_logger().warn( f'wait_settled 超时 ({self.settle_timeout:.1f}s),臂可能仍在微动;' '交给棋盘稳定性检查兜底' ) return False def _pin_hold(self) -> None: """静止后再保持几拍,抑制到位后的微小回弹/漂移。 语义取自 site.yaml 的注释"零速 pin 后再拍照":不是重新规划轨迹, 而是纯粹等一段时间让机械臂自己稳下来(控制器在到位后仍会做少量补偿)。 """ total = self.pin_holds * self.pin_hold_sec end = time.monotonic() + total while rclpy.ok() and time.monotonic() < end: rclpy.spin_once(self, timeout_sec=0.05) def _wait_stable( self, ) -> Tuple[bool, float, Optional[np.ndarray], Optional[np.ndarray]]: self.stabilizer.reset() deadline = time.monotonic() + self.stable_timeout last_std = float('inf') last_corners = None last_pattern = None last_bgr = None last_log = 0.0 while rclpy.ok() and time.monotonic() < deadline: rclpy.spin_once(self, timeout_sec=0.05) if self._latest_image is None: continue try: bgr = _image_to_bgr(self._latest_image) except Exception as exc: # noqa: BLE001 self.get_logger().error(f'图像转换失败:{exc}') return False, last_std, None, None, None detected = detect_chessboard(bgr, self.board) corners = detected[0] if detected is not None else None pattern = detected[1] if detected is not None else None ok, std = self.stabilizer.push(corners) last_std = std if corners is not None: last_corners = corners last_pattern = pattern last_bgr = bgr now = time.monotonic() if now - last_log > 1.0: seen = '有' if corners is not None else '无' self.get_logger().info( f' 棋盘={seen} 角点标准差=' f'{std if std < 1e8 else float("nan"):.3f}px' ) last_log = now if ok: return True, std, last_corners, last_pattern, last_bgr return False, last_std, last_corners, last_pattern, last_bgr def _verify_joints(self, target: List[float], tol: float = 0.08) -> bool: """确认 ``joint_states`` 真的走到了目标位置。 用来识破"假的成功":有些执行接口会立刻返回成功,但关节其实没动 (或只动了一半)。这里在 2 秒内轮询关节角,逐轴比对容差 ``tol``(弧度), 只有全部落在容差内才算真到位。 """ deadline = time.monotonic() + 2.0 while rclpy.ok() and time.monotonic() < deadline: rclpy.spin_once(self, timeout_sec=0.05) if self._latest_js is None: continue name_to_q = dict(zip(list(self._latest_js.name), list(self._latest_js.position))) actual = [] for n in self.joint_names: if n not in name_to_q: break actual.append(float(name_to_q[n])) if len(actual) != len(target): continue err = float(np.sqrt(np.mean([(a - b) ** 2 for a, b in zip(actual, target)]))) self.get_logger().info( f' 关节相对目标的 RMSE:{err:.4f} rad(tol={tol})' ) return err <= tol self.get_logger().error('校验运动时没有 joint_states') return False 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_ee_pose(self) -> Optional[PoseStamped]: if not self._get_state.wait_for_service(timeout_sec=2.0): 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 res.ee_pose def _save_png(self, bgr: np.ndarray, path: Path) -> None: import cv2 if not cv2.imwrite(str(path), bgr): raise RuntimeError(f'cv2.imwrite 失败:{path}') def main(args=None) -> None: rclpy.init(args=args) node = HandeyeCalibrate() 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()