Initial import of ORISYS arm workstation

This commit is contained in:
cheng
2026-10-16 17:27:30 +08:00
commit 2b74c50ba8
745 changed files with 1199101 additions and 0 deletions
+771
View File
@@ -0,0 +1,771 @@
#!/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()