Files
orisys_arm/handeye/src/handeye_waypoints.py
T
2026-10-16 17:27:30 +08:00

526 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()