注释与提示信息全面中文化:源码 66 个 .py + 脚本/配置 45 个文件
背景:现场读代码的人只读中文。此前代码里的 docstring 是英文、行内注释是中文,
一半英文一半中文反而更难看懂 —— 新手打开一个 .py 不知道这个模块为什么存在。
范围(本仓库自己的代码,厂商上游不动)
src/arm_application/** 37 个文件:servo_node / perception_node / handeye_* / dh_pgea /
yolo_detector / kalman_filter / goto_ee_plane / dual_gripper …
src/arm_interfaces/** 11 个文件:paths / manifest / loader / config_guard / errors …
src/arm_control/** 5 个文件
src/arm_bringup/** 3 个 launch
scripts/** 6 个 .py + 29 个 .sh(scripts/ 是"唯一操作入口",用户屏幕上
看到的就是这些提示,所以提示信息也一并中文化)
config/ handeye/ devices/ src/arm_*/config/ 17 个 .yaml/.xml/.xacro/.cfg
口径(写进 docs/FRAMEWORK_RULES.md 新增的 R6.5,作为长期约束)
注释、docstring、print/日志/异常里给人看的句子 = 中文;
标识符、API 名、话题名、参数名、字段名、命令名 = 保留英文原文(用 `` `` 包起来);
单位、十六进制、协议报文、CRC 值、命令示例 = 原样保留;
src/flexiv_*/ = 厂商上游代码,不许改也不许翻译(R4.4)。
翻译保原意与原有详细程度:那些"为什么不能用另一种做法""踩过什么坑"的说明
全部完整保留 —— 它们才是这份代码里最贵的部分。
新增 scripts/check_text_only.py:只改文字时的自证工具
人工 review 靠不住(115 个文件、3000 多行 diff)。这个脚本把改动前后的源码解析成
语法树,删掉 docstring、抹平字符串常量、把 f-string 只留"插值了几次 + 插值是什么",
再比对:完全相同 = 改动范围内没有逻辑变化。
留了一个口子:f-string 的插值按内容排序后再比 —— 因为中文语序常常要和英文反着来
(f"on {port} read {idx} failed" → f"在 {port} 上读 {idx} 失败"),只换显示顺序不该判失败;
但多插一个、少插一个、换掉被插入的表达式,立刻会报出来。
本次 66 个 .py 全部通过该闸门。
验收
改动的 66 个 .py:scripts/check_text_only.py 全部通过(零逻辑变化)
45 个非 .py 文件:去注释后与 HEAD 逐字节相同(零配置变化)
docstring 总数:没有一个文件减少(不存在"翻译时顺手删了说明")
单测:arm_application 233 / arm_driver_flexiv 69 / arm_interfaces 46 /
arm_kinematics 24 / arm_sim_gazebo 236 = 608 passed,与改动前一致
全部 .sh:bash -n 通过;并额外实际执行了 --help 验证(见下一个提交:
bash -n 对 CRLF 脚本会漏报,只有真跑才暴露)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,32 +1,32 @@
|
||||
"""arm_application — field nodes for Flexiv Rizon + D405 + DH grippers.
|
||||
"""arm_application —— Flexiv Rizon + D405 + DH 夹爪的现场节点包。
|
||||
|
||||
Public ROS executables (see setup.py console_scripts)
|
||||
----------------------------------------------------
|
||||
Camera: realsense_color_pub
|
||||
Perception: perception_node
|
||||
Servo: servo_node
|
||||
Hand-eye: handeye_teach, handeye_waypoints, handeye_calibrate
|
||||
Gripper: dh_pgea, dh_pgea_cli, dual_gripper, dual_gripper_cli
|
||||
Hole detect: hole_detect
|
||||
Tactile: tactile, tactile_cli
|
||||
Helpers: goto_ee_plane, demo_named_poses, run_joint_trajectory
|
||||
对外可直接运行的 ROS 程序(见 ``setup.py`` 的 ``console_scripts``)
|
||||
------------------------------------------------------------------
|
||||
相机: realsense_color_pub
|
||||
感知: perception_node
|
||||
伺服: servo_node
|
||||
手眼: handeye_teach、handeye_waypoints、handeye_calibrate
|
||||
夹爪: dh_pgea、dh_pgea_cli、dual_gripper、dual_gripper_cli
|
||||
入孔检测: hole_detect
|
||||
触觉: tactile、tactile_cli
|
||||
辅助: goto_ee_plane、demo_named_poses、run_joint_trajectory
|
||||
|
||||
Reusable Python modules (import from your own nodes)
|
||||
----------------------------------------------------
|
||||
yolo_detector YOLO / TensorRT inference + pick_best
|
||||
box_tracker pixel-space constant-velocity box coast
|
||||
plane_feature stereo plane centroid inside a YOLO box
|
||||
dh_pgea official Modbus RTU client for DH PGEA
|
||||
dh_official DH Modbus client used by dual_gripper
|
||||
dual_gripper PGEA left + RGI right
|
||||
hole_detector yolo / hough / two_stage hole centers
|
||||
orisys_tactile Orisys visuo-tactile adapter
|
||||
geom_ik Rizon geometric Jacobian / DLS / hand-eye load
|
||||
handeye_lib chessboard + OpenCV calibrateHandEye
|
||||
handeye_realsense topic / serial helpers for D405
|
||||
pid_controller PID with integral separation
|
||||
kalman_filter 3D constant-velocity KF
|
||||
可复用的 Python 模块(可以在你自己的节点里 import)
|
||||
------------------------------------------------------------------
|
||||
``yolo_detector`` YOLO / TensorRT 推理 + ``pick_best`` 挑最优框
|
||||
``box_tracker`` 像素平面内的匀速模型框预测(丢帧时"滑行"用)
|
||||
``plane_feature`` 在 YOLO 框内取立体平面的质心
|
||||
``dh_pgea`` 大寰 PGEA 的官方 Modbus RTU 客户端
|
||||
``dh_official`` ``dual_gripper`` 用的 DH Modbus 客户端
|
||||
``dual_gripper`` 左 PGEA + 右 RGI
|
||||
``hole_detector`` yolo / hough / two_stage 三种孔心检测
|
||||
``orisys_tactile`` Orisys 视触觉适配器
|
||||
``geom_ik`` Rizon 几何雅可比 / DLS / 手眼加载
|
||||
``handeye_lib`` 棋盘格 + OpenCV ``calibrateHandEye``
|
||||
``handeye_realsense`` D405 的话题 / 序列号辅助
|
||||
``pid_controller`` 带积分分离的 PID
|
||||
``kalman_filter`` 三维匀速模型卡尔曼滤波
|
||||
|
||||
Operators should use ``scripts/*.sh`` and ``config/site.yaml``, not these
|
||||
imports. See ``docs/INTERFACES.md``.
|
||||
现场操作请走 ``scripts/*.sh`` 和 ``config/site.yaml``,不要直接 import 这些模块。
|
||||
接口契约见 ``docs/INTERFACES.md``。
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Constant-velocity tracker for a YOLO xyxy box (pixel space).
|
||||
"""针对 YOLO ``xyxy`` 框(像素坐标系)的匀速跟踪器。
|
||||
|
||||
Used so fast workpiece motion does not drop the lock: the next-frame gate
|
||||
is the *predicted* box, and brief YOLO misses coast the overlay.
|
||||
用于避免工件快速运动时丢失锁定:下一帧的闸门使用*预测*框,
|
||||
短暂的 YOLO 漏检则由惯性滑行(coast)维持叠加显示。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -38,7 +38,7 @@ class BoxTracker:
|
||||
self.v_lpf = float(np.clip(v_lpf, 0.05, 1.0))
|
||||
self.max_dt = float(max_dt)
|
||||
self.xyxy: Optional[np.ndarray] = None
|
||||
self.v = np.zeros(2, dtype=float) # center px/s
|
||||
self.v = np.zeros(2, dtype=float) # 中心点 px/s
|
||||
self.t: Optional[float] = None
|
||||
|
||||
def reset(self) -> None:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Demo: cycle named poses via /set_named_pose (works for any backend)."""
|
||||
"""演示:通过 ``/set_named_pose`` 循环切换命名位姿(对任何后端都适用)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -31,10 +31,10 @@ class DemoNamedPoses(Node):
|
||||
|
||||
def run(self) -> int:
|
||||
if not self._cli.wait_for_service(timeout_sec=15.0):
|
||||
self.get_logger().error('/set_named_pose not available')
|
||||
self.get_logger().error('``/set_named_pose`` 服务不可用')
|
||||
return 1
|
||||
for loop in range(max(self.loops, 1)):
|
||||
self.get_logger().info(f'loop {loop + 1}/{self.loops}')
|
||||
self.get_logger().info(f'第 {loop + 1}/{self.loops} 轮')
|
||||
for pose in self.poses:
|
||||
req = SetNamedPose.Request()
|
||||
req.pose_name = pose
|
||||
@@ -45,17 +45,17 @@ class DemoNamedPoses(Node):
|
||||
while rclpy.ok() and not fut.done():
|
||||
rclpy.spin_once(self, timeout_sec=0.05)
|
||||
if time.monotonic() > deadline:
|
||||
self.get_logger().error(f'timeout on {pose}')
|
||||
self.get_logger().error(f'{pose} 调用超时')
|
||||
return 2
|
||||
res = fut.result()
|
||||
if res is None or not res.success:
|
||||
self.get_logger().error(
|
||||
f'{pose} failed: {res.message if res else "no response"}'
|
||||
f'{pose} 执行失败:{res.message if res else "无响应"}'
|
||||
)
|
||||
return 3
|
||||
self.get_logger().info(f'{pose}: {res.message}')
|
||||
self.get_logger().info(f'{pose}:{res.message}')
|
||||
time.sleep(self.dwell_sec)
|
||||
self.get_logger().info('demo done')
|
||||
self.get_logger().info('演示结束')
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -20,34 +20,34 @@ from typing import Iterable
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DH-Robotics Modbus register map
|
||||
# DH-Robotics Modbus 寄存器映射
|
||||
# =============================================================================
|
||||
|
||||
# Basic control registers (holding registers, function code 03/06/10)
|
||||
REG_INIT = 0x0100 # 0x01 initialize, 0xA5 fully initialize
|
||||
REG_FORCE = 0x0101 # gripper closing force, 20~100 %
|
||||
REG_POSITION = 0x0103 # finger reference position, 0~1000 per-mille
|
||||
REG_SPEED = 0x0104 # finger open/close speed, 1~100 %
|
||||
# 基础控制寄存器(保持寄存器,功能码 03/06/10)
|
||||
REG_INIT = 0x0100 # 0x01 初始化,0xA5 完全初始化
|
||||
REG_FORCE = 0x0101 # 夹爪闭合力,20~100 %
|
||||
REG_POSITION = 0x0103 # 手指参考位置,0~1000 千分比
|
||||
REG_SPEED = 0x0104 # 手指开合速度,1~100 %
|
||||
|
||||
# RGI rotation control registers
|
||||
REG_ROTATION_ANGLE = 0x0105 # target rotation angle, signed degree value
|
||||
REG_ROTATION_SPEED = 0x0107 # rotation speed, 1~100 %
|
||||
REG_ROTATION_FORCE = 0x0108 # rotation force/torque, 20~100 %
|
||||
# RGI 旋转控制寄存器
|
||||
REG_ROTATION_ANGLE = 0x0105 # 目标旋转角度,有符号角度值
|
||||
REG_ROTATION_SPEED = 0x0107 # 旋转速度,1~100 %
|
||||
REG_ROTATION_FORCE = 0x0108 # 旋转力/力矩,20~100 %
|
||||
|
||||
# Feedback registers
|
||||
REG_INIT_STATE = 0x0200 # 0 uninitialized, 1 initialized, 2 initializing
|
||||
REG_GRIPPER_STATE = 0x0201 # 0 moving, 1 reached, 2 object caught, 3 object dropped
|
||||
REG_CURRENT_POSITION = 0x0202 # current finger position, per-mille
|
||||
REG_ROTATION_ANGLE_FB = 0x0208 # current rotation angle, signed degree value
|
||||
REG_ROTATION_INIT_STATE = 0x020A # 0 uninitialized, 1 initialized, 2 initializing
|
||||
REG_ROTATION_STATE = 0x020B # 0 moving, 1 reached, 2 blocking, 3 had been blocked
|
||||
# 反馈寄存器
|
||||
REG_INIT_STATE = 0x0200 # 0 未初始化,1 已初始化,2 初始化中
|
||||
REG_GRIPPER_STATE = 0x0201 # 0 运动中,1 已到位,2 已夹到物体,3 物体掉落
|
||||
REG_CURRENT_POSITION = 0x0202 # 当前手指位置,千分比
|
||||
REG_ROTATION_ANGLE_FB = 0x0208 # 当前旋转角度,有符号角度值
|
||||
REG_ROTATION_INIT_STATE = 0x020A # 0 未初始化,1 已初始化,2 初始化中
|
||||
REG_ROTATION_STATE = 0x020B # 0 运动中,1 已到位,2 受阻,3 曾经受阻
|
||||
|
||||
# Configuration registers
|
||||
REG_SAVE_PARAMS = 0x0300 # write 1 to save all parameters to flash
|
||||
# 配置寄存器
|
||||
REG_SAVE_PARAMS = 0x0300 # 写入 1 可把所有参数保存到 flash
|
||||
|
||||
|
||||
class DHModbusError(RuntimeError):
|
||||
"""DH Modbus communication or protocol error."""
|
||||
"""DH Modbus 通信或协议错误。"""
|
||||
|
||||
|
||||
def _clamp_int(value: float | int, lo: int, hi: int) -> int:
|
||||
@@ -55,9 +55,9 @@ def _clamp_int(value: float | int, lo: int, hi: int) -> int:
|
||||
|
||||
|
||||
def _encode_signed(value: float | int) -> int:
|
||||
"""Encode signed degree value as DH one's-complement register value.
|
||||
"""把有符号角度值编码为 DH 的反码(one's complement)寄存器值。
|
||||
|
||||
RGI manual example: +360 -> 0x0168, -360 -> 0xFE97.
|
||||
RGI 手册示例:+360 -> 0x0168,-360 -> 0xFE97。
|
||||
"""
|
||||
value = _clamp_int(value, -32767, 32767)
|
||||
if value >= 0:
|
||||
@@ -66,7 +66,7 @@ def _encode_signed(value: float | int) -> int:
|
||||
|
||||
|
||||
def _decode_signed(raw: int) -> int:
|
||||
"""Decode DH one's-complement register value to signed integer."""
|
||||
"""把 DH 的反码(one's complement)寄存器值解码为有符号整数。"""
|
||||
raw = int(raw) & 0xFFFF
|
||||
if raw & 0x8000:
|
||||
mag = (~raw) & 0x7FFF
|
||||
@@ -75,7 +75,7 @@ def _decode_signed(raw: int) -> int:
|
||||
|
||||
|
||||
class DHModbusClient:
|
||||
"""Minimal thread-safe Modbus RTU client for DH-Robotics grippers."""
|
||||
"""面向 DH-Robotics 夹爪的最小线程安全 Modbus RTU 客户端。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -91,7 +91,7 @@ class DHModbusClient:
|
||||
try:
|
||||
import minimalmodbus
|
||||
import serial
|
||||
except ImportError as exc: # pragma: no cover - depends on runtime env
|
||||
except ImportError as exc: # pragma: no cover - 取决于运行时环境
|
||||
raise DHModbusError(
|
||||
"缺少 minimalmodbus/pyserial。请先安装:pip install minimalmodbus pyserial"
|
||||
) from exc
|
||||
@@ -124,7 +124,7 @@ class DHModbusClient:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Low-level register access
|
||||
# 底层寄存器读写
|
||||
# ------------------------------------------------------------------
|
||||
def write_register(self, address: int, value: int) -> None:
|
||||
value = _clamp_int(value, 0, 0xFFFF)
|
||||
@@ -170,10 +170,10 @@ class DHModbusClient:
|
||||
return _decode_signed(self.read_register(address))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Common PGE/PGEA/RGI control
|
||||
# PGE/PGEA/RGI 通用控制
|
||||
# ------------------------------------------------------------------
|
||||
def initialize(self, *, full: bool = False, rotation: bool = False, timeout: float = 10.0) -> None:
|
||||
"""Initialize/homing the gripper and wait until ready."""
|
||||
"""初始化/回零夹爪,并等待其就绪。"""
|
||||
self.write_register(REG_INIT, 0xA5 if full else 0x01)
|
||||
start = time.monotonic()
|
||||
while time.monotonic() - start < timeout:
|
||||
@@ -229,11 +229,11 @@ class DHModbusClient:
|
||||
return self.read_register(REG_INIT_STATE)
|
||||
|
||||
def hold_current_position(self) -> None:
|
||||
"""Best-effort stop/hold for DH grippers without a dedicated stop register."""
|
||||
"""在没有专用停止寄存器的 DH 夹爪上尽力停止/保持。"""
|
||||
self.set_position_per_mille(self.get_position_per_mille())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RGI rotation control
|
||||
# RGI 旋转控制
|
||||
# ------------------------------------------------------------------
|
||||
def set_rotation_speed_pct(self, speed_pct: float | int) -> int:
|
||||
value = _clamp_int(speed_pct, 1, 100)
|
||||
@@ -263,7 +263,7 @@ class DHModbusClient:
|
||||
return self.read_register(REG_ROTATION_STATE)
|
||||
|
||||
def hold_current_rotation(self) -> None:
|
||||
"""Stop rotation by re-commanding the current feedback angle."""
|
||||
"""重新下发当前反馈角度来停止旋转。"""
|
||||
self.write_register(REG_ROTATION_ANGLE, self.get_rotation_angle_raw())
|
||||
|
||||
def hold_current_all(self) -> None:
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DH Robotics PGE / PGEA Modbus RTU client.
|
||||
"""DH Robotics PGE / PGEA Modbus RTU 客户端。
|
||||
|
||||
Port of the official C++ driver:
|
||||
移植自官方 C++ 驱动:
|
||||
DH-Robotics/dh_gripper_ros → DH_Modbus_Gripper
|
||||
(factory maps model names containing ``PGE`` / ``PGEA`` onto this class)
|
||||
(工厂会按型号名里是否包含 ``PGE`` / ``PGEA`` 映射到本类)
|
||||
|
||||
Flexiv RDK ``Gripper`` cannot drive this claw: it is not a Studio Device.
|
||||
Talk RS485 (USB–UART) with the same registers the DH ROS driver uses.
|
||||
Flexiv RDK 的 ``Gripper`` 驱动不了这个夹爪:它不是 Studio Device。
|
||||
改用 RS485(USB–UART)通信,寄存器与 DH 的 ROS 驱动完全一致。
|
||||
|
||||
Registers (holding, FC03 read / FC06 write, CRC-16/Modbus LE):
|
||||
0x0100 init write 0xA5
|
||||
0x0101 force 20–100 %
|
||||
0x0103 position 0–1000 ‰
|
||||
0x0104 speed 1–100 %
|
||||
0x0200 init state 0=need 1=done 2=doing
|
||||
0x0201 grip state 0=moving 1=arrived 2=caught 3=dropped
|
||||
0x0202 current position
|
||||
寄存器(保持寄存器,FC03 读 / FC06 写,CRC-16/Modbus 小端):
|
||||
0x0100 初始化,写 0xA5
|
||||
0x0101 力 20–100 %
|
||||
0x0103 位置 0–1000 ‰
|
||||
0x0104 速度 1–100 %
|
||||
0x0200 初始化状态 0=需要 1=完成 2=进行中
|
||||
0x0201 夹持状态 0=运动中 1=已到位 2=已夹住 3=已掉落
|
||||
0x0202 当前位置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,11 +31,11 @@ try:
|
||||
import serial
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"pyserial missing. In conda ros_flexiv: pip install pyserial"
|
||||
"缺少 pyserial。在 conda 环境 ros_flexiv 里执行:pip install pyserial"
|
||||
) from exc
|
||||
|
||||
|
||||
# Official CRC-16/Modbus (same table as dh_modbus_gripper.cpp).
|
||||
# 官方 CRC-16/Modbus(与 dh_modbus_gripper.cpp 使用同一张表)。
|
||||
_CRC_TABLE = (
|
||||
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
|
||||
0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
|
||||
@@ -105,7 +105,7 @@ INIT_STATE_NAME = {
|
||||
|
||||
|
||||
def crc16_modbus(data: bytes) -> int:
|
||||
"""Official DH CRC16 (Modbus, little-endian on the wire)."""
|
||||
"""官方 DH CRC16(Modbus,线路上按小端发送)。"""
|
||||
crc = 0xFFFF
|
||||
for byte in data:
|
||||
crc = (crc >> 8) ^ _CRC_TABLE[(crc ^ byte) & 0xFF]
|
||||
@@ -176,7 +176,7 @@ WIRING_HINT = (
|
||||
|
||||
|
||||
class DhPgeaGripper:
|
||||
"""Thread-safe Modbus RTU client for PGE / PGEA (official register map)."""
|
||||
"""面向 PGE / PGEA 的线程安全 Modbus RTU 客户端(官方寄存器映射)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -196,7 +196,7 @@ class DhPgeaGripper:
|
||||
if mode in ("", "off", "none", "false", "0"):
|
||||
mode = ""
|
||||
if mode not in ("", "tx", "tx_inv"):
|
||||
raise DhPgeaError(f"rs485_rts must be '', tx, or tx_inv, got {rs485_rts!r}")
|
||||
raise DhPgeaError(f"rs485_rts 只能是 ''、tx 或 tx_inv,实际为 {rs485_rts!r}")
|
||||
self.rs485_rts = mode
|
||||
self._ser: Optional[serial.Serial] = None
|
||||
self._lock = threading.Lock()
|
||||
@@ -220,8 +220,8 @@ class DhPgeaGripper:
|
||||
)
|
||||
except Exception as exc:
|
||||
raise DhPgeaError(
|
||||
f"cannot open {self.port}: {exc}. "
|
||||
"Need 24 V DC + RS485, and user in group dialout."
|
||||
f"无法打开 {self.port}:{exc}。"
|
||||
"需要 24 V DC 供电 + RS485 接线,且当前用户在 dialout 组内。"
|
||||
) from exc
|
||||
try:
|
||||
self._ser.dtr = True
|
||||
@@ -248,7 +248,7 @@ class DhPgeaGripper:
|
||||
self.close()
|
||||
|
||||
def write_register(self, index: int, value: int, retries: int = 3) -> bool:
|
||||
"""FC06 write, wait for echo (official WriteRegisterFunc)."""
|
||||
"""FC06 写,等待回显(对应官方 WriteRegisterFunc)。"""
|
||||
frame = _frame(self.slave_id, 0x06, index, int(value) & 0xFFFF)
|
||||
with self._lock:
|
||||
for _ in range(max(1, retries)):
|
||||
@@ -258,7 +258,7 @@ class DhPgeaGripper:
|
||||
return False
|
||||
|
||||
def read_register(self, index: int, retries: int = 8) -> int:
|
||||
"""FC03 read one holding register (official ReadRegisterFunc)."""
|
||||
"""FC03 读一个保持寄存器(对应官方 ReadRegisterFunc)。"""
|
||||
req = _frame(self.slave_id, 0x03, index, 1)
|
||||
last: Optional[bytes] = None
|
||||
with self._lock:
|
||||
@@ -275,13 +275,13 @@ class DhPgeaGripper:
|
||||
return (rsp[3] << 8) | (rsp[4] & 0xFF)
|
||||
hint = f" last={last.hex() if last else '(empty)'}"
|
||||
raise DhPgeaError(
|
||||
f"Modbus read 0x{index:04X} failed on {self.port} "
|
||||
f"(no valid reply{hint}). baud={self.baudrate} slave={self.slave_id} "
|
||||
f"在 {self.port} 上 Modbus 读 0x{index:04X} 失败 "
|
||||
f"(无有效应答{hint})。baud={self.baudrate} slave={self.slave_id} "
|
||||
f"rts={self.rs485_rts or 'off'}"
|
||||
)
|
||||
|
||||
def probe(self) -> int:
|
||||
"""One short read of init-state. Raises if the claw is silent."""
|
||||
"""短读一次初始化状态。夹爪无应答时会抛异常。"""
|
||||
return self.read_register(REG_INIT_STATE, retries=2)
|
||||
|
||||
def _set_tx(self, tx: bool) -> None:
|
||||
@@ -297,7 +297,7 @@ class DhPgeaGripper:
|
||||
|
||||
def _transact(self, frame: bytes, expect: int) -> Optional[bytes]:
|
||||
if self._ser is None or not self._ser.is_open:
|
||||
raise DhPgeaError("serial not open")
|
||||
raise DhPgeaError("串口未打开")
|
||||
bit_s = 10.0 / max(1, self.baudrate)
|
||||
self._set_tx(True)
|
||||
if self.rs485_rts:
|
||||
@@ -320,12 +320,12 @@ class DhPgeaGripper:
|
||||
return self.write_register(REG_INIT, INIT_CMD)
|
||||
|
||||
def initialize(self, timeout_s: float = 20.0, poll_s: float = 0.2) -> bool:
|
||||
"""Write 0xA5 then wait until init state == finished (official bringup)."""
|
||||
"""写 0xA5,然后等到初始化状态 == 完成(对应官方上电流程)。"""
|
||||
state = self.get_init_state()
|
||||
if state == S_INIT_FINISHED:
|
||||
return True
|
||||
if not self.initialization():
|
||||
raise DhPgeaError("init write 0x0100=0xA5 failed (no Modbus echo)")
|
||||
raise DhPgeaError("初始化写 0x0100=0xA5 失败(无 Modbus 回显)")
|
||||
deadline = time.monotonic() + float(timeout_s)
|
||||
while time.monotonic() < deadline:
|
||||
state = self.get_init_state()
|
||||
@@ -333,8 +333,8 @@ class DhPgeaGripper:
|
||||
return True
|
||||
time.sleep(poll_s)
|
||||
raise DhPgeaError(
|
||||
f"init timeout after {timeout_s:.0f}s (state={state}). "
|
||||
"Fingers should home once on first power-up."
|
||||
f"初始化在 {timeout_s:.0f}s 后超时(state={state})。"
|
||||
"首次上电时手指本应先回零一次。"
|
||||
)
|
||||
|
||||
def set_target_force(self, force_pct: int) -> bool:
|
||||
@@ -427,11 +427,11 @@ class DhPgeaGripper:
|
||||
|
||||
|
||||
def _selfcheck_crc() -> None:
|
||||
"""Official short-manual example: set position 500."""
|
||||
"""官方简短手册示例:设置位置 500。"""
|
||||
frame = _frame(1, 0x06, 0x0103, 500)
|
||||
expect = bytes([0x01, 0x06, 0x01, 0x03, 0x01, 0xF4, 0x78, 0x21])
|
||||
if frame != expect:
|
||||
raise AssertionError(f"CRC mismatch: {frame.hex()} != {expect.hex()}")
|
||||
raise AssertionError(f"CRC 不匹配:{frame.hex()} != {expect.hex()}")
|
||||
|
||||
|
||||
def _site_defaults() -> dict:
|
||||
@@ -465,11 +465,11 @@ def _site_defaults() -> dict:
|
||||
|
||||
|
||||
def _scan_bus(port: str) -> int:
|
||||
"""Try baud × slave × RTS until one combination answers."""
|
||||
"""遍历波特率 × 从站号 × RTS,直到某个组合有应答。"""
|
||||
bauds = (115200, 57600, 38400, 19200, 9600)
|
||||
slaves = (1, 2, 3)
|
||||
rts_modes = ("", "tx", "tx_inv")
|
||||
print(f"scan {port} (stop the ROS node first — port is exclusive)")
|
||||
print(f"扫描 {port}(请先停掉 ROS 节点 —— 该串口是独占的)")
|
||||
for baud in bauds:
|
||||
for slave in slaves:
|
||||
for rts in rts_modes:
|
||||
@@ -492,7 +492,7 @@ def _scan_bus(port: str) -> int:
|
||||
print(f" miss {label}")
|
||||
finally:
|
||||
g.close()
|
||||
print("no reply on any combination.", file=sys.stderr)
|
||||
print("所有组合都无应答。", file=sys.stderr)
|
||||
print(WIRING_HINT, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@@ -500,24 +500,24 @@ def _scan_bus(port: str) -> int:
|
||||
def _cli(argv: Optional[list[str]] = None) -> int:
|
||||
site = _site_defaults()
|
||||
ap = argparse.ArgumentParser(
|
||||
description="DH PGEA Modbus CLI (official register map, not Flexiv RDK)"
|
||||
description="DH PGEA Modbus 命令行工具(官方寄存器映射,不用 Flexiv RDK)"
|
||||
)
|
||||
ap.add_argument("--port", default=site["port"])
|
||||
ap.add_argument("--baud", type=int, default=site["baudrate"])
|
||||
ap.add_argument("--slave", type=int, default=site["slave_id"])
|
||||
ap.add_argument("--force", type=int, default=site["force"])
|
||||
ap.add_argument("--speed", type=int, default=site["speed"])
|
||||
ap.add_argument("--rts", default=site.get("rs485_rts") or "", help="RS485 DE: empty | tx | tx_inv")
|
||||
ap.add_argument("--rts", default=site.get("rs485_rts") or "", help="RS485 DE:空 | tx | tx_inv")
|
||||
ap.add_argument(
|
||||
"cmd",
|
||||
choices=["probe", "init", "status", "open", "close", "pos", "selfcheck", "scan"],
|
||||
)
|
||||
ap.add_argument("value", nargs="?", default="", help="position 0–1000 for 'pos'")
|
||||
ap.add_argument("value", nargs="?", default="", help="'pos' 命令的位置,0–1000")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.cmd == "selfcheck":
|
||||
_selfcheck_crc()
|
||||
print("CRC self-check OK (01 06 01 03 01 F4 78 21)")
|
||||
print("CRC 自检通过(01 06 01 03 01 F4 78 21)")
|
||||
return 0
|
||||
if args.cmd == "scan":
|
||||
return _scan_bus(args.port)
|
||||
@@ -532,7 +532,7 @@ def _cli(argv: Optional[list[str]] = None) -> int:
|
||||
try:
|
||||
grip.open()
|
||||
except DhPgeaError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
@@ -541,7 +541,7 @@ def _cli(argv: Optional[list[str]] = None) -> int:
|
||||
print(st.summary())
|
||||
return 0
|
||||
if args.cmd == "init":
|
||||
print("initializing (fingers home)…")
|
||||
print("正在初始化(手指回零)…")
|
||||
grip.initialize()
|
||||
print(grip.get_run_states().summary())
|
||||
return 0
|
||||
@@ -550,25 +550,25 @@ def _cli(argv: Optional[list[str]] = None) -> int:
|
||||
return 0
|
||||
if args.cmd == "open":
|
||||
if not grip.open_jaw(args.force, args.speed):
|
||||
raise DhPgeaError("open write failed")
|
||||
raise DhPgeaError("张开写入失败")
|
||||
print(grip.wait_settle(1000).summary())
|
||||
return 0
|
||||
if args.cmd == "close":
|
||||
if not grip.close_jaw(args.force, args.speed):
|
||||
raise DhPgeaError("close write failed")
|
||||
raise DhPgeaError("闭合写入失败")
|
||||
print(grip.wait_settle(0).summary())
|
||||
return 0
|
||||
if args.cmd == "pos":
|
||||
if args.value == "":
|
||||
print("usage: pos <0-1000>", file=sys.stderr)
|
||||
print("用法:pos <0-1000>", file=sys.stderr)
|
||||
return 2
|
||||
pos = int(float(args.value))
|
||||
if not grip.move(pos, args.force, args.speed):
|
||||
raise DhPgeaError("position write failed")
|
||||
raise DhPgeaError("位置写入失败")
|
||||
print(grip.wait_settle(pos).summary())
|
||||
return 0
|
||||
except DhPgeaError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
print(WIRING_HINT, file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ROS 2 node for DH PGEA (official Modbus RTU, not Flexiv gripper).
|
||||
"""DH PGEA 的 ROS 2 节点(官方 Modbus RTU,不用 Flexiv 夹爪)。
|
||||
|
||||
Topics (layout matches DH-Robotics/dh_gripper_ros):
|
||||
话题(布局与 DH-Robotics/dh_gripper_ros 一致):
|
||||
/gripper/ctrl std_msgs/Float32MultiArray
|
||||
data: [initialize, position, force, speed]
|
||||
initialize>0.5 → write 0xA5; else set force/speed/position
|
||||
initialize>0.5 → 写 0xA5;否则设置力/速度/位置
|
||||
/gripper/states std_msgs/Float32MultiArray
|
||||
data: [initialized, grip_state, position, target_pos, target_force]
|
||||
/gripper/joint_states sensor_msgs/JointState
|
||||
|
||||
Services (std_srvs/Trigger):
|
||||
服务(std_srvs/Trigger):
|
||||
/gripper/initialize /gripper/open /gripper/close
|
||||
|
||||
Do not set Flexiv bringup load_gripper:=true — that is Flexiv-GN01 only.
|
||||
不要设置 Flexiv bringup 的 load_gripper:=true —— 那只是 Flexiv-GN01 用的。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -36,7 +36,7 @@ from arm_application.dh_pgea import (
|
||||
|
||||
|
||||
def _param_as_str(node: Node, name: str, default: str = "") -> str:
|
||||
"""String param that also accepts numeric CLI overrides (e.g. SN 26610799)."""
|
||||
"""字符串参数,同时允许命令行用数字覆盖(例如 SN 26610799)。"""
|
||||
node.declare_parameter(name, default, ParameterDescriptor(dynamic_typing=True))
|
||||
val = node.get_parameter(name).value
|
||||
if val is None or val == "":
|
||||
@@ -105,7 +105,7 @@ class DhPgeaNode(Node):
|
||||
self.get_logger().info(
|
||||
f"DH {self._model} SN={self._sn or '?'} "
|
||||
f"{self._port} @{self._baud} slave={self._slave} rts={self._rts or 'off'} "
|
||||
f"(official Modbus RTU; Flexiv RDK gripper API unused)"
|
||||
f"(官方 Modbus RTU;未使用 Flexiv RDK 夹爪 API)"
|
||||
)
|
||||
try:
|
||||
self._grip.open()
|
||||
@@ -121,14 +121,14 @@ class DhPgeaNode(Node):
|
||||
self.get_logger().error(str(exc))
|
||||
for line in WIRING_HINT.splitlines():
|
||||
self.get_logger().error(line)
|
||||
self.get_logger().error("will keep retrying; open/close will fail until the bus answers")
|
||||
self.get_logger().error("会持续重试;在总线恢复应答前 open/close 都会失败")
|
||||
|
||||
if self._auto_init and self._online:
|
||||
try:
|
||||
self._grip.initialize()
|
||||
self.get_logger().info("gripper initialized")
|
||||
self.get_logger().info("夹爪已初始化")
|
||||
except DhPgeaError as exc:
|
||||
self.get_logger().error(f"auto_init failed: {exc}")
|
||||
self.get_logger().error(f"auto_init 失败:{exc}")
|
||||
self._online = False
|
||||
|
||||
period = 1.0 / max(1.0, hz)
|
||||
@@ -150,7 +150,7 @@ class DhPgeaNode(Node):
|
||||
if speed is not None:
|
||||
self._speed = s
|
||||
if not self._grip.move(int(position), f, s):
|
||||
raise DhPgeaError("move write failed")
|
||||
raise DhPgeaError("移动写入失败")
|
||||
return f"pos={int(position)} force={f}% speed={s}%"
|
||||
|
||||
def _on_ctrl(self, msg: Float32MultiArray) -> None:
|
||||
@@ -158,7 +158,7 @@ class DhPgeaNode(Node):
|
||||
try:
|
||||
if initialize > 0.5:
|
||||
self._grip.initialize()
|
||||
self.get_logger().info("initialize via /gripper/ctrl")
|
||||
self.get_logger().info("通过 /gripper/ctrl 触发初始化")
|
||||
return
|
||||
f = int(force) if force > 0 else self._force
|
||||
s = int(speed) if speed > 0 else self._speed
|
||||
@@ -210,7 +210,7 @@ class DhPgeaNode(Node):
|
||||
self._state = st
|
||||
if not self._online:
|
||||
self._online = True
|
||||
self.get_logger().info("gripper bus recovered")
|
||||
self.get_logger().info("夹爪总线已恢复")
|
||||
except DhPgeaError as exc:
|
||||
self._online = False
|
||||
self.get_logger().warn(str(exc), throttle_duration_sec=5.0)
|
||||
@@ -227,7 +227,7 @@ class DhPgeaNode(Node):
|
||||
js = JointState()
|
||||
js.header.stamp = self.get_clock().now().to_msg()
|
||||
js.name = ["gripper_finger1_joint"]
|
||||
# Official ROS driver maps 0–1000 → ~0–0.637 rad; we publish mm as well.
|
||||
# 官方 ROS 驱动把 0–1000 映射到约 0–0.637 rad;我们这里也发布 mm。
|
||||
js.position = [float(st.width_mm) * 0.001]
|
||||
js.effort = [float(st.target_force)]
|
||||
self._pub_js.publish(js)
|
||||
|
||||
@@ -750,7 +750,7 @@ def load_dual_arm_gripper_config(path: str | Path) -> DualArmGripperConfig:
|
||||
|
||||
|
||||
def load_from_site(data: dict) -> DualArmGripperConfig:
|
||||
"""Build DualArmGripperConfig from ~/ros2/config/site.yaml."""
|
||||
"""从 ``~/ros2/config/site.yaml`` 构建 ``DualArmGripperConfig``。"""
|
||||
robots = list((data.get("robot") or {}).get("robots") or [])
|
||||
left = next((r for r in robots if str(r.get("arm") or "").lower() == "left"), {})
|
||||
right = next((r for r in robots if str(r.get("arm") or "").lower() == "right"), {})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI for DH PGEA (left) and RGI (right). Loads ports from site.yaml."""
|
||||
"""DH PGEA(左臂)与 RGI(右臂)的命令行工具。从 site.yaml 读取串口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -39,67 +39,67 @@ def _normalize_arm(raw: str) -> str:
|
||||
return "right"
|
||||
if t in ("both", "dual", "all"):
|
||||
return "both"
|
||||
raise SystemExit(f"unknown --arm {raw!r} (use 89|91|left|right|both)")
|
||||
raise SystemExit(f"未知的 --arm {raw!r}(可用 89|91|left|right|both)")
|
||||
|
||||
|
||||
def test_parallel(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
|
||||
gripper = manager.left
|
||||
print(f"=== PGEA parallel ({gripper.cfg.port}) ===")
|
||||
print(f"=== PGEA 平行夹爪({gripper.cfg.port})===")
|
||||
gripper.connect()
|
||||
_print_status("connected", gripper.get_status())
|
||||
_print_status("已连接", gripper.get_status())
|
||||
if args.test in ("init", "probe", "all"):
|
||||
if args.test != "probe":
|
||||
gripper.initialize(full=args.init_full)
|
||||
_print_status("init", gripper.get_status())
|
||||
_print_status("初始化", gripper.get_status())
|
||||
if args.test in ("open", "all"):
|
||||
_print_status("open", gripper.open(force=args.force, speed=args.speed))
|
||||
_print_status("张开", gripper.open(force=args.force, speed=args.speed))
|
||||
if args.test in ("close", "all"):
|
||||
_print_status("close", gripper.close(force=args.force, speed=args.speed))
|
||||
_print_status("闭合", gripper.close(force=args.force, speed=args.speed))
|
||||
if args.test in ("move", "pos") and args.pos is not None:
|
||||
_print_status("move", gripper.move_to(args.pos, force=args.force, speed=args.speed))
|
||||
_print_status("移动", gripper.move_to(args.pos, force=args.force, speed=args.speed))
|
||||
if args.test in ("status", "probe"):
|
||||
_print_status("status", gripper.get_status())
|
||||
_print_status("状态", gripper.get_status())
|
||||
|
||||
|
||||
def test_rotary(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
|
||||
gripper = manager.right
|
||||
print(f"=== RGI rotary ({gripper.cfg.port}) ===")
|
||||
print(f"=== RGI 旋转夹爪({gripper.cfg.port})===")
|
||||
gripper.connect()
|
||||
_print_status("connected", gripper.get_status())
|
||||
_print_status("已连接", gripper.get_status())
|
||||
if args.test in ("init", "all"):
|
||||
gripper.initialize(full=args.init_full)
|
||||
_print_status("init", gripper.get_status())
|
||||
_print_status("初始化", gripper.get_status())
|
||||
if args.test in ("open", "all"):
|
||||
_print_status("open", gripper.open_fingers(force=args.force, speed=args.speed))
|
||||
_print_status("张开", gripper.open_fingers(force=args.force, speed=args.speed))
|
||||
if args.test in ("close", "all"):
|
||||
_print_status("close", gripper.close_fingers(force=args.force, speed=args.speed))
|
||||
_print_status("闭合", gripper.close_fingers(force=args.force, speed=args.speed))
|
||||
if args.test in ("move", "pos") and args.pos is not None:
|
||||
_print_status("fingers", gripper.move_fingers(int(args.pos), force=args.force, speed=args.speed))
|
||||
_print_status("手指", gripper.move_fingers(int(args.pos), force=args.force, speed=args.speed))
|
||||
if args.test in ("rotate", "rotate_to", "all"):
|
||||
if args.revolutions is not None:
|
||||
_print_status(
|
||||
"revolutions",
|
||||
"圈数",
|
||||
gripper.rotate_revolutions(args.revolutions, speed=args.speed, torque=args.force),
|
||||
)
|
||||
else:
|
||||
angle = args.angle if args.angle is not None else 90.0
|
||||
_print_status("rotate", gripper.rotate_to(angle, speed=args.speed, torque=args.force))
|
||||
_print_status("旋转", gripper.rotate_to(angle, speed=args.speed, torque=args.force))
|
||||
if args.test == "rotate_by":
|
||||
delta = args.angle if args.angle is not None else 45.0
|
||||
_print_status("rotate_by", gripper.rotate_by(delta, speed=args.speed, torque=args.force))
|
||||
_print_status("相对旋转", gripper.rotate_by(delta, speed=args.speed, torque=args.force))
|
||||
if args.test in ("status", "probe"):
|
||||
_print_status("status", gripper.get_status())
|
||||
_print_status("状态", gripper.get_status())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="DH PGEA / RGI CLI (site.yaml ports)")
|
||||
parser = argparse.ArgumentParser(description="DH PGEA / RGI 命令行工具(串口来自 site.yaml)")
|
||||
parser.add_argument("--arm", default=os.environ.get("ROBOT_ARM") or os.environ.get("ROBOT_SN") or "89")
|
||||
parser.add_argument(
|
||||
"--test",
|
||||
default="status",
|
||||
help="probe|init|open|close|move|pos|rotate|rotate_by|status",
|
||||
)
|
||||
parser.add_argument("--pos", type=float, default=None, help="PGEA mm or RGI finger 0-1000")
|
||||
parser.add_argument("--pos", type=float, default=None, help="PGEA 为 mm;RGI 为手指 0-1000")
|
||||
parser.add_argument("--angle", type=float, default=None)
|
||||
parser.add_argument("--revolutions", type=float, default=None)
|
||||
parser.add_argument("--speed", type=int, default=25)
|
||||
@@ -110,7 +110,7 @@ def main() -> int:
|
||||
|
||||
site = Path(args.site).expanduser() if args.site else _site_path()
|
||||
if not site.is_file():
|
||||
print(f"ERROR: site.yaml missing: {site}", file=sys.stderr)
|
||||
print(f"错误:site.yaml 缺失:{site}", file=sys.stderr)
|
||||
return 1
|
||||
data = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
|
||||
manager = DualArmGripperManager(load_from_site(data))
|
||||
@@ -126,11 +126,11 @@ def main() -> int:
|
||||
test_parallel(manager, args)
|
||||
test_rotary(manager, args)
|
||||
except KeyboardInterrupt:
|
||||
print("interrupt — stop")
|
||||
print("收到中断 —— 停止")
|
||||
manager.emergency_stop()
|
||||
return 130
|
||||
except Exception as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
manager.disconnect_all()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""ROS 2 dual DH gripper node (PGEA left + RGI right). Uses arm_msgs, not Flexiv RDK."""
|
||||
"""ROS 2 双臂 DH 夹爪节点(左臂 PGEA + 右臂 RGI)。使用 arm_msgs,而非 Flexiv RDK。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -61,7 +61,7 @@ class DualGripperNode(Node):
|
||||
site = _site_path()
|
||||
data = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
|
||||
self._manager = DualArmGripperManager(load_from_site(data))
|
||||
self.get_logger().info(f"gripper config from site.yaml: {site}")
|
||||
self.get_logger().info(f"夹爪配置来自 site.yaml:{site}")
|
||||
|
||||
self._connect_errors: dict[str, str] = {}
|
||||
if _as_bool(self.get_parameter("auto_connect").value, True):
|
||||
@@ -112,12 +112,12 @@ class DualGripperNode(Node):
|
||||
continue
|
||||
try:
|
||||
gripper.connect()
|
||||
self.get_logger().info(f"{side} gripper connected: {gripper.cfg.port}")
|
||||
self.get_logger().info(f"{side} 夹爪已连接:{gripper.cfg.port}")
|
||||
except Exception as exc:
|
||||
self._connect_errors[side] = str(exc)
|
||||
self.get_logger().error(f"{side} gripper connect failed: {exc}")
|
||||
self.get_logger().error(f"{side} 夹爪连接失败:{exc}")
|
||||
if self._connect_errors and not _as_bool(self.get_parameter("allow_offline").value, True):
|
||||
raise RuntimeError(f"gripper connect failed: {self._connect_errors}")
|
||||
raise RuntimeError(f"夹爪连接失败:{self._connect_errors}")
|
||||
|
||||
def _gripper(self, side: str):
|
||||
return self._manager.left if side == "left" else self._manager.right
|
||||
@@ -245,14 +245,14 @@ class DualGripperNode(Node):
|
||||
|
||||
status = gripper.get_status()
|
||||
status.state = GripperState.ERROR
|
||||
status.error_msg = f"unsupported command {command!r} (side={side})"
|
||||
status.error_msg = f"不支持的命令 {command!r}(side={side})"
|
||||
return status
|
||||
|
||||
def _on_command(self, side: str, msg: GripperCommandMsg) -> None:
|
||||
with self._locks[side]:
|
||||
status = self._execute(side, msg)
|
||||
if status.state == GripperState.ERROR:
|
||||
self.get_logger().warn(f"{side} command {msg.command!r} failed: {status.error_msg}")
|
||||
self.get_logger().warn(f"{side} 命令 {msg.command!r} 失败:{status.error_msg}")
|
||||
|
||||
def _on_execute(self, side: str, request: GripperExecute.Request, response: GripperExecute.Response):
|
||||
with self._locks[side]:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Startup return-to-initial before perception/servo starts.
|
||||
"""感知/伺服启动前的开机回初始位流程。
|
||||
|
||||
Sequence:
|
||||
[A] if the flange is below servo.safe_z_m, lift it STRAIGHT UP first
|
||||
(closed-loop Z hops) so the home move can't sweep walls/fixtures;
|
||||
[B] call /set_named_pose (Flexiv named pose, default "home") — the arm
|
||||
returns to the Flexiv initial pose with Flexiv's own planner;
|
||||
[C] hold (pin) so the servo takes over from a clean, stationary pose.
|
||||
流程:
|
||||
[A] 若法兰低于 ``servo.safe_z_m``,先让它竖直上升(闭环 Z 小步跳),
|
||||
以免回 home 时扫到墙面/工装;
|
||||
[B] 调用 ``/set_named_pose``(Flexiv 命名位姿,默认 "home")—— 由 Flexiv
|
||||
自己的规划器把机械臂送回 Flexiv 初始位姿;
|
||||
[C] 保持(pin)住,让伺服从一个干净、静止的位姿接手。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -49,13 +49,13 @@ def _load_ee_cfg() -> dict:
|
||||
"base": str(robot.get("base_frame") or "Rizon4-063989_base_link"),
|
||||
"ee": str(robot.get("ee_frame") or "Rizon4-063989_flange"),
|
||||
"z": ee_z,
|
||||
# Lift straight up to this Z before the home move (avoid hitting
|
||||
# walls/fixtures); then return to the Flexiv named initial pose.
|
||||
# 回 home 前先竖直升到这个 Z(避免撞到墙面/工装);
|
||||
# 然后再回到 Flexiv 的命名初始位姿。
|
||||
"safe_z": float(servo.get("safe_z_m") or max(0.40, ee_z + 0.10)),
|
||||
"home_pose": str(servo.get("home_pose_name") or "home"),
|
||||
"home_speed": float(servo.get("home_pose_speed") or 0.30),
|
||||
"v_z": float(servo.get("v_max_z") or 0.04),
|
||||
# only used by the (currently unused) verticalize planner path
|
||||
# 仅用于(目前未使用的)verticalize 规划路径
|
||||
"kp": float(servo.get("ee_hold_kp") or 2.0),
|
||||
"w_max": float(servo.get("ee_w_max") or 0.50),
|
||||
}
|
||||
@@ -170,7 +170,7 @@ class GotoEePlane(Node):
|
||||
self._spin(0.05)
|
||||
handle = fut.result() if fut.done() else None
|
||||
if handle is None or not handle.accepted:
|
||||
print(" ERROR: JTC rejected the height trajectory", flush=True)
|
||||
print(" 错误:JTC 拒绝了抬升轨迹", flush=True)
|
||||
return False
|
||||
res = handle.get_result_async()
|
||||
t0 = time.monotonic()
|
||||
@@ -183,13 +183,13 @@ class GotoEePlane(Node):
|
||||
if Tee is not None:
|
||||
p = Tee[:3, 3]
|
||||
print(
|
||||
f" moving ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f}) "
|
||||
f" 运动中 ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f}) "
|
||||
f"z_err={(self.cfg['z'] - p[2]) * 1000:+.0f}mm",
|
||||
flush=True,
|
||||
)
|
||||
last = now
|
||||
if not res.done():
|
||||
print(" WARN: JTC result timeout — checking TF anyway", flush=True)
|
||||
print(" 警告:JTC 结果超时 —— 仍然继续检查 TF", flush=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -203,25 +203,24 @@ class GotoEePlane(Node):
|
||||
max_seg: float = 0.08,
|
||||
v: float = 0.05,
|
||||
) -> bool:
|
||||
"""Plan and execute ONE short cartesian segment (≤ max_seg) toward
|
||||
target. Long moves are handled by the caller in closed-loop hops,
|
||||
because the Jacobian is frozen at the current pose — over >10 cm the
|
||||
linearization error makes single-shot plans land far off target."""
|
||||
"""规划并执行一段朝向 target 的短笛卡尔位移(≤ max_seg)。
|
||||
长距离移动由调用方以闭环小步跳的方式完成,因为雅可比是在当前位姿
|
||||
处冻结的 —— 超过 10 cm 后线性化误差会让一次性规划严重偏离目标。"""
|
||||
p = Tee[:3, 3].copy()
|
||||
R = Tee[:3, :3]
|
||||
dp, ang, _ = self._target_err(p, R, target, verticalize)
|
||||
dist = float(np.linalg.norm(dp))
|
||||
if dist > max_seg: # clamp this hop; caller loops with fresh TF
|
||||
if dist > max_seg: # 限制本次小步跳的长度;调用方会用最新的 TF 循环执行
|
||||
dp = dp * (max_seg / dist)
|
||||
dist = max_seg
|
||||
print(
|
||||
f" plan {tag} Δxyz=({dp[0]*1000:+.0f},{dp[1]*1000:+.0f},{dp[2]*1000:+.0f})mm "
|
||||
f"tilt={ang * 180 / np.pi:.1f}deg",
|
||||
f" 规划 {tag} Δxyz=({dp[0]*1000:+.0f},{dp[1]*1000:+.0f},{dp[2]*1000:+.0f})mm "
|
||||
f"倾角={ang * 180 / np.pi:.1f}deg",
|
||||
flush=True,
|
||||
)
|
||||
J = self._jacobian(p)
|
||||
if J is None:
|
||||
print("ERROR: missing link TF for Jacobian", flush=True)
|
||||
print("错误:缺少用于雅可比的 link TF", flush=True)
|
||||
return False
|
||||
|
||||
q = q0.copy()
|
||||
@@ -253,14 +252,14 @@ class GotoEePlane(Node):
|
||||
planned = dist - float(np.linalg.norm(remain))
|
||||
if len(waypoints) < 2 or planned < 0.4 * dist:
|
||||
print(
|
||||
f" WARN {tag}: planner stalled "
|
||||
f"(planned {planned*1000:.0f}/{dist*1000:.0f}mm) — skip hop",
|
||||
f" 警告 {tag}:规划器停滞 "
|
||||
f"(已规划 {planned*1000:.0f}/{dist*1000:.0f}mm)—— 跳过本次小步跳",
|
||||
flush=True,
|
||||
)
|
||||
return False
|
||||
# joint-excursion sanity gate: refuse wild plans
|
||||
# 关节偏移量的合理性闸门:拒绝过于夸张的规划
|
||||
if float(np.max(np.abs(waypoints[-1] - q0))) > 1.0:
|
||||
print(f" ERROR {tag}: joint excursion >1 rad — abort hop", flush=True)
|
||||
print(f" 错误 {tag}:关节偏移 >1 rad —— 放弃本次小步跳", flush=True)
|
||||
return False
|
||||
|
||||
duration = max(1.6, dist / max(min(v, 0.06), 1e-3))
|
||||
@@ -278,10 +277,10 @@ class GotoEePlane(Node):
|
||||
)
|
||||
qdot = np.clip(qdot, -0.40, 0.40)
|
||||
traj.points.append(_pt(qi, qdot, t))
|
||||
# force zero velocity at the last point
|
||||
# 强制最后一个点的速度为零
|
||||
traj.points[-1].velocities = [0.0] * 7
|
||||
print(
|
||||
f" send JTC action {tag} {n} steps T={duration:.1f}s",
|
||||
f" 发送 JTC action {tag},{n} 步 T={duration:.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
return self._send_action(traj, wait_s=duration + 6.0)
|
||||
@@ -298,9 +297,8 @@ class GotoEePlane(Node):
|
||||
v: float,
|
||||
max_hops: int = 8,
|
||||
) -> Tuple[bool, np.ndarray, np.ndarray]:
|
||||
"""Closed-loop point-to-point: plan ≤8 cm hop → execute → re-measure
|
||||
TF → repeat, until within tolerance. Robust to Jacobian linearization
|
||||
error over long distances."""
|
||||
"""闭环点到点:规划 ≤8 cm 的小步跳 → 执行 → 重新测量 TF → 重复,
|
||||
直到落在容差内。这样对长距离下的雅可比线性化误差更鲁棒。"""
|
||||
for hop in range(max_hops):
|
||||
p = Tee[:3, 3]
|
||||
dp, ang, _ = self._target_err(p, Tee[:3, :3], target, verticalize)
|
||||
@@ -308,8 +306,8 @@ class GotoEePlane(Node):
|
||||
if dist < tol_m and ((not verticalize) or ang < tol_tilt):
|
||||
return True, q, Tee
|
||||
print(
|
||||
f" {tag} hop{hop + 1} remain={dist*1000:.0f}mm "
|
||||
f"tilt={ang * 180 / np.pi:.1f}deg",
|
||||
f" {tag} 第 {hop + 1} 跳 remain={dist*1000:.0f}mm "
|
||||
f"倾角={ang * 180 / np.pi:.1f}deg",
|
||||
flush=True,
|
||||
)
|
||||
if not self._plan_and_send(q, Tee, target, verticalize, tag, v=v):
|
||||
@@ -319,7 +317,7 @@ class GotoEePlane(Node):
|
||||
self._spin(0.05)
|
||||
q2, Tee2 = self._wait_state(2.0)
|
||||
if q2 is None or Tee2 is None:
|
||||
print("ERROR: lost state during move", flush=True)
|
||||
print("错误:移动过程中丢失状态", flush=True)
|
||||
return False, q, Tee
|
||||
q, Tee = q2, Tee2
|
||||
p = Tee[:3, 3]
|
||||
@@ -340,10 +338,10 @@ class GotoEePlane(Node):
|
||||
self._send_action(traj, wait_s=3.0)
|
||||
|
||||
def _named_pose(self, name: str, speed: float) -> bool:
|
||||
"""Call Flexiv /set_named_pose and wait until the arm is stationary."""
|
||||
"""调用 Flexiv 的 ``/set_named_pose``,并等待机械臂静止。"""
|
||||
cli = self.create_client(SetNamedPose, "/set_named_pose")
|
||||
if not cli.wait_for_server(timeout_sec=8.0):
|
||||
print("ERROR: /set_named_pose unavailable — start run_real.sh", flush=True)
|
||||
print("错误:``/set_named_pose`` 不可用 —— 请启动 run_real.sh", flush=True)
|
||||
return False
|
||||
req = SetNamedPose.Request()
|
||||
req.pose_name = name
|
||||
@@ -355,16 +353,16 @@ class GotoEePlane(Node):
|
||||
while not fut.done() and time.monotonic() - t0 < 90.0:
|
||||
self._spin(0.1)
|
||||
if not fut.done():
|
||||
print("ERROR: set_named_pose timeout", flush=True)
|
||||
print("错误:set_named_pose 超时", flush=True)
|
||||
return False
|
||||
resp = fut.result()
|
||||
print(
|
||||
f" [B] resp success={resp.success} msg={resp.message}",
|
||||
f" [B] 响应 success={resp.success} msg={resp.message}",
|
||||
flush=True,
|
||||
)
|
||||
if not resp.success:
|
||||
return False
|
||||
# wait until the flange stops moving (Δp < 1 mm over ~0.8 s)
|
||||
# 等待法兰停止运动(约 0.8 s 内 Δp < 1 mm)
|
||||
last = None
|
||||
still = 0.0
|
||||
t0 = time.monotonic()
|
||||
@@ -380,57 +378,57 @@ class GotoEePlane(Node):
|
||||
else:
|
||||
still = 0.0
|
||||
last = p.copy()
|
||||
print(" WARN: still-moving wait timeout — continue anyway", flush=True)
|
||||
print(" 警告:等待静止超时 —— 仍然继续", flush=True)
|
||||
return True
|
||||
|
||||
def run(self, timeout_s: float = 25.0) -> int:
|
||||
cfg = self.cfg
|
||||
q, Tee = self._wait_state(4.0)
|
||||
if q is None or Tee is None:
|
||||
print("ERROR: no joint_states / TF — start run_real.sh", flush=True)
|
||||
print("错误:没有 joint_states / TF —— 请启动 run_real.sh", flush=True)
|
||||
return 2
|
||||
if not self._cli.wait_for_server(timeout_sec=8.0):
|
||||
print("ERROR: JTC action unavailable — start run_real.sh", flush=True)
|
||||
print("错误:JTC action 不可用 —— 请启动 run_real.sh", flush=True)
|
||||
return 3
|
||||
|
||||
p = Tee[:3, 3]
|
||||
safe_z = float(cfg["safe_z"])
|
||||
print(
|
||||
f"=== return to Flexiv initial pose '{cfg['home_pose']}' "
|
||||
f"=== 回到 Flexiv 初始位姿 '{cfg['home_pose']}' "
|
||||
f"safe_z={safe_z*1000:.0f}mm ===",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
f" now ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
|
||||
f" 当前 ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# --- Phase A: pure-Z lift to the safe height before the home move ---
|
||||
# --- 阶段 A:回 home 前先沿纯 Z 方向升到安全高度 ---
|
||||
if p[2] < safe_z - 0.01:
|
||||
lift_tgt = np.array([p[0], p[1], safe_z], dtype=float)
|
||||
print(f" [A] lift Z {p[2]*1000:.0f} → {safe_z*1000:.0f}mm", flush=True)
|
||||
print(f" [A] 抬升 Z {p[2]*1000:.0f} → {safe_z*1000:.0f}mm", flush=True)
|
||||
ok, q, Tee = self._goto(
|
||||
q, Tee, lift_tgt, False, 0.010, 0.0, "[A]lift", v=0.05
|
||||
)
|
||||
if not ok:
|
||||
print("ERROR: lift phase failed — pin here", flush=True)
|
||||
print("错误:抬升阶段失败 —— 就地 pin 住", flush=True)
|
||||
self._pin(q)
|
||||
return 4
|
||||
|
||||
# --- Phase B: Flexiv named pose → real initial pose, good posture ---
|
||||
# --- 阶段 B:Flexiv 命名位姿 → 真正的初始位姿,姿态良好 ---
|
||||
if not self._named_pose(cfg["home_pose"], cfg["home_speed"]):
|
||||
print("ERROR: named-pose return failed — pin here", flush=True)
|
||||
print("错误:回命名位姿失败 —— 就地 pin 住", flush=True)
|
||||
self._pin(q)
|
||||
return 4
|
||||
|
||||
# --- Phase C: hold so the servo takes over from a stationary pose ---
|
||||
# --- 阶段 C:保持住,让伺服从一个静止位姿接手 ---
|
||||
q, Tee = self._wait_state(2.0)
|
||||
if q is not None:
|
||||
self._pin(q)
|
||||
if Tee is not None:
|
||||
p = Tee[:3, 3]
|
||||
print(
|
||||
f" at initial pose ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
|
||||
f" 已到初始位姿 ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
|
||||
flush=True,
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Full hand-eye pipeline: move -> stabilize chessboard -> capture -> solve -> cleanup.
|
||||
"""完整手眼标定流水线:移动 → 棋盘稳定判定 → 采集 → 求解 → 清理。
|
||||
|
||||
Supports eye_in_hand / eye_to_hand.
|
||||
支持 eye_in_hand / eye_to_hand。
|
||||
|
||||
Camera intrinsics priority:
|
||||
1) ROS CameraInfo topic (if publishing)
|
||||
2) RealSense D405/D435 EEPROM via pyrealsense2 (camera_source)
|
||||
3) Manual camera_fx/fy/cx/cy in YAML
|
||||
相机内参优先级:
|
||||
1) ROS 的 CameraInfo 话题(若正在发布)
|
||||
2) 通过 pyrealsense2 读取 RealSense D405/D435 的 EEPROM(camera_source)
|
||||
3) YAML 中手写的 camera_fx/fy/cx/cy
|
||||
|
||||
Chessboard size (board_cols / board_rows / square_size_m) is user-configurable.
|
||||
棋盘尺寸(board_cols / board_rows / square_size_m)可由用户配置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -68,7 +68,7 @@ def _image_to_bgr(msg: Image) -> np.ndarray:
|
||||
img = data.reshape(h, w, 4)
|
||||
bgr = img[:, :, :3]
|
||||
return bgr[:, :, ::-1].copy() if enc == 'rgba8' else bgr
|
||||
raise RuntimeError(f'Unsupported image encoding: {msg.encoding!r}')
|
||||
raise RuntimeError(f'不支持的图像编码:{msg.encoding!r}')
|
||||
|
||||
|
||||
def _camera_info_to_intrinsics(msg: CameraInfo) -> CameraIntrinsics:
|
||||
@@ -93,7 +93,7 @@ class HandeyeCalibrate(Node):
|
||||
self.declare_parameter('waypoint_positions', [0.0] * (12 * 7))
|
||||
self.declare_parameter('min_poses', 5)
|
||||
|
||||
# Camera: manual | d405 | d435
|
||||
# 相机来源:manual | d405 | d435
|
||||
self.declare_parameter('camera_source', 'manual')
|
||||
self.declare_parameter('camera_serial', '')
|
||||
self.declare_parameter('camera_namespace', '')
|
||||
@@ -107,12 +107,12 @@ class HandeyeCalibrate(Node):
|
||||
self.declare_parameter('camera_cy', 0.0)
|
||||
self.declare_parameter('camera_dist', [0.0, 0.0, 0.0, 0.0, 0.0])
|
||||
|
||||
# Chessboard (customizable)
|
||||
# 棋盘(可自定义)
|
||||
# 默认值必须与 site.yaml 的 handeye.board_* 及结果文件头里的实际板子一致。
|
||||
# 真实使用的板:内角点 8x11,方格 15mm(见 handeye/results/*.yaml 的头部注释)。
|
||||
# 这里原先写的是 9x6 / 0.025(另一张早期的板),一旦 site.yaml 漏配就会被
|
||||
# 静默启用 —— 症状是"标定能顺利跑完,但结果明显不对",极难排查。
|
||||
self.declare_parameter('board_cols', 8) # inner corners
|
||||
self.declare_parameter('board_cols', 8) # 内角点
|
||||
self.declare_parameter('board_rows', 11)
|
||||
self.declare_parameter('square_size_m', 0.015)
|
||||
|
||||
@@ -205,18 +205,18 @@ class HandeyeCalibrate(Node):
|
||||
|
||||
if not img_topic:
|
||||
self.get_logger().error(
|
||||
'image_topic empty. Set camera_source:=d405|d435 or fill image_topic.'
|
||||
'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'Subscribed image: {img_topic} (BEST_EFFORT)')
|
||||
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'Subscribed camera_info: {info_topic} (BEST_EFFORT)')
|
||||
self.get_logger().info(f'已订阅 camera_info:{info_topic}(BEST_EFFORT)')
|
||||
|
||||
def _on_js(self, msg: JointState) -> None:
|
||||
self._latest_js = msg
|
||||
@@ -230,20 +230,20 @@ class HandeyeCalibrate(Node):
|
||||
def run(self) -> int:
|
||||
if self.mode not in ('eye_in_hand', 'eye_to_hand'):
|
||||
self.get_logger().error(
|
||||
f'Unknown mode={self.mode!r}; use eye_in_hand or eye_to_hand'
|
||||
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'Only {len(poses)} filled poses (need >= {self.min_poses}). '
|
||||
'Teach with: bash ~/ros2/scripts/run_handeye.sh teach && merge.'
|
||||
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} >= filled poses {len(poses)}'
|
||||
f'start_index {self.start_index} >= 已填写位姿数 {len(poses)}'
|
||||
)
|
||||
return 1
|
||||
|
||||
@@ -252,8 +252,8 @@ class HandeyeCalibrate(Node):
|
||||
|
||||
if not self._client.wait_for_server(timeout_sec=15.0):
|
||||
self.get_logger().error(
|
||||
f'Action [{self.action_name}] unavailable. '
|
||||
'Start: bash ~/ros2/scripts/run_real.sh'
|
||||
f'Action [{self.action_name}] 不可用。'
|
||||
'请启动:bash ~/ros2/scripts/run_real.sh'
|
||||
)
|
||||
return 2
|
||||
|
||||
@@ -262,11 +262,11 @@ class HandeyeCalibrate(Node):
|
||||
|
||||
eye_to_hand = self.mode == 'eye_to_hand'
|
||||
self.get_logger().info(
|
||||
f'Hand-eye [{self.mode}] camera_source={self.camera_source}: '
|
||||
f'{len(poses)} poses, board={self.board.cols}x{self.board.rows} '
|
||||
f'square={self.board.square_size}m, v={self.velocity_scale}'
|
||||
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}'
|
||||
)
|
||||
self.get_logger().warn('Clear workspace; E-Stop ready. Ctrl+C aborts.')
|
||||
self.get_logger().warn('清理工作空间;确认急停就绪。Ctrl+C 可中止。')
|
||||
|
||||
Rg: List[np.ndarray] = []
|
||||
tg: List[np.ndarray] = []
|
||||
@@ -277,17 +277,17 @@ class HandeyeCalibrate(Node):
|
||||
try:
|
||||
for i in range(self.start_index, len(poses)):
|
||||
q = poses[i]
|
||||
self._log_progress(i, len(poses), 'moving...')
|
||||
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}] arm did not reach target joints '
|
||||
'(controller reported OK but joints unchanged). '
|
||||
'Restart run_real.sh; pendant AUTO Remote; clear fault. '
|
||||
'Do NOT trust this calibration.'
|
||||
f'[{i + 1}] 机械臂未到达目标关节位置 '
|
||||
'(控制器报告成功,但关节没有变化)。'
|
||||
'请重启 run_real.sh;示教器切 AUTO Remote;清除故障。'
|
||||
'不要相信这次标定结果。'
|
||||
)
|
||||
return 10
|
||||
|
||||
@@ -301,16 +301,16 @@ class HandeyeCalibrate(Node):
|
||||
|
||||
ok, std, corners, pattern, bgr = self._wait_stable()
|
||||
if not ok or corners is None or bgr is None:
|
||||
msg = f'[{i + 1}] chessboard not stable (std={std:.3f})'
|
||||
msg = f'[{i + 1}] 棋盘不稳定(std={std:.3f})'
|
||||
if self.skip_unstable:
|
||||
self.get_logger().warn(f'{msg} — skip')
|
||||
self.get_logger().warn(f'{msg} —— 跳过')
|
||||
continue
|
||||
self.get_logger().error(f'{msg}. Abort.')
|
||||
self.get_logger().error(f'{msg}。中止。')
|
||||
return 7
|
||||
|
||||
ee = self._read_ee_pose()
|
||||
if ee is None:
|
||||
self.get_logger().error('Failed to read ee_pose')
|
||||
self.get_logger().error('读取 ee_pose 失败')
|
||||
return 8
|
||||
|
||||
assert self._intrinsics is not None
|
||||
@@ -349,20 +349,20 @@ class HandeyeCalibrate(Node):
|
||||
}
|
||||
)
|
||||
self.get_logger().info(
|
||||
f'[{i + 1}/{len(poses)}] CAPTURED '
|
||||
f'[{i + 1}/{len(poses)}] 已采集 '
|
||||
f'(corner_std={std:.3f}px) -> {img_path.name}'
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
self.get_logger().info('Interrupted')
|
||||
self.get_logger().info('已中断')
|
||||
return 0
|
||||
|
||||
if len(Rg) < self.min_poses:
|
||||
self.get_logger().error(
|
||||
f'Only {len(Rg)} captures; need >= {self.min_poses} to solve'
|
||||
f'只采集到 {len(Rg)} 次;求解至少需要 {self.min_poses} 次'
|
||||
)
|
||||
return 9
|
||||
|
||||
self.get_logger().info(f'Solving hand-eye with {len(Rg)} views...')
|
||||
self.get_logger().info(f'用 {len(Rg)} 个视角求解手眼标定...')
|
||||
R, t = calibrate_hand_eye(Rg, tg, Rc, tc, eye_to_hand=eye_to_hand)
|
||||
name = (
|
||||
'T_cam_to_base (eye_to_hand)'
|
||||
@@ -371,7 +371,7 @@ class HandeyeCalibrate(Node):
|
||||
)
|
||||
yaml_txt = format_transform_yaml(R, t, name)
|
||||
header = (
|
||||
f'# Hand-eye result\n'
|
||||
f'# 手眼标定结果\n'
|
||||
f'# mode: {self.mode}\n'
|
||||
f'# camera_source: {self.camera_source}\n'
|
||||
f'# views: {len(Rg)}\n'
|
||||
@@ -385,17 +385,17 @@ class HandeyeCalibrate(Node):
|
||||
json.dumps(meta, indent=2), encoding='utf-8'
|
||||
)
|
||||
|
||||
self.get_logger().info('======== Hand-eye result ========')
|
||||
self.get_logger().info('======== 手眼标定结果 ========')
|
||||
for line in (header + yaml_txt).strip().splitlines():
|
||||
self.get_logger().info(line)
|
||||
self.get_logger().info(f'Saved -> {self.result_file}')
|
||||
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('Calibration images deleted')
|
||||
self.get_logger().info('标定图像已删除')
|
||||
else:
|
||||
self.get_logger().info(f'Images kept in {self.images_dir}')
|
||||
self.get_logger().info(f'图像保留在 {self.images_dir}')
|
||||
return 0
|
||||
|
||||
def _load_poses(self) -> List[List[float]]:
|
||||
@@ -407,8 +407,8 @@ class HandeyeCalibrate(Node):
|
||||
need = n_cfg * n_j
|
||||
if len(flat) < need:
|
||||
self.get_logger().warn(
|
||||
f'waypoint_positions length {len(flat)} < num_poses*{n_j}={need}; '
|
||||
'padding with zeros'
|
||||
f'waypoint_positions 长度 {len(flat)} < num_poses*{n_j}={need};'
|
||||
'用零补齐'
|
||||
)
|
||||
flat = flat + [0.0] * (need - len(flat))
|
||||
poses: List[List[float]] = []
|
||||
@@ -417,7 +417,7 @@ class HandeyeCalibrate(Node):
|
||||
if is_pose_filled(q):
|
||||
poses.append(q)
|
||||
else:
|
||||
self.get_logger().warn(f'Pose slot {i} empty (all-zero) — skipped')
|
||||
self.get_logger().warn(f'位姿槽 {i} 为空(全零)—— 已跳过')
|
||||
return poses
|
||||
|
||||
def _resolve_intrinsics(self) -> bool:
|
||||
@@ -430,7 +430,7 @@ class HandeyeCalibrate(Node):
|
||||
self._intrinsics = _camera_info_to_intrinsics(self._latest_info)
|
||||
K = self._intrinsics.K
|
||||
self.get_logger().info(
|
||||
f'Intrinsics from CameraInfo ({self._info_topic}): '
|
||||
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
|
||||
@@ -445,19 +445,19 @@ class HandeyeCalibrate(Node):
|
||||
height=self.rs_height,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f'RealSense intrinsics query failed: {exc}')
|
||||
self.get_logger().error(f'RealSense 内参查询失败:{exc}')
|
||||
else:
|
||||
self._intrinsics = CameraIntrinsics(K=intr.as_K(), dist=intr.as_dist())
|
||||
self.get_logger().info(
|
||||
f'Intrinsics from RealSense {intr.model.upper()} '
|
||||
f'serial={intr.serial} {intr.width}x{intr.height}: '
|
||||
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'Requested {self.rs_width}x{self.rs_height}, '
|
||||
f'got {intr.width}x{intr.height} (closest profile)'
|
||||
f'请求 {self.rs_width}x{self.rs_height},'
|
||||
f'实际得到 {intr.width}x{intr.height}(最接近的 profile)'
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -468,8 +468,8 @@ class HandeyeCalibrate(Node):
|
||||
cy = float(self.get_parameter('camera_cy').value)
|
||||
if fx <= 0 or fy <= 0:
|
||||
self.get_logger().error(
|
||||
'No intrinsics. Set camera_source:=d405|d435, '
|
||||
'or provide camera_info_topic, or fill camera_fx/fy/cx/cy.'
|
||||
'没有内参。请设置 camera_source:=d405|d435,'
|
||||
'或提供 camera_info_topic,或填写 camera_fx/fy/cx/cy。'
|
||||
)
|
||||
return False
|
||||
dist = np.array(
|
||||
@@ -478,7 +478,7 @@ class HandeyeCalibrate(Node):
|
||||
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'Intrinsics from YAML: fx={fx:.2f} fy={fy:.2f} cx={cx:.2f} cy={cy:.2f}'
|
||||
f'内参来自 YAML:fx={fx:.2f} fy={fy:.2f} cx={cx:.2f} cy={cy:.2f}'
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -561,7 +561,7 @@ class HandeyeCalibrate(Node):
|
||||
try:
|
||||
bgr = _image_to_bgr(self._latest_image)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f'Image convert failed: {exc}')
|
||||
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
|
||||
@@ -574,9 +574,9 @@ class HandeyeCalibrate(Node):
|
||||
last_bgr = bgr
|
||||
now = time.monotonic()
|
||||
if now - last_log > 1.0:
|
||||
seen = 'yes' if corners is not None else 'NO'
|
||||
seen = '有' if corners is not None else '无'
|
||||
self.get_logger().info(
|
||||
f' board={seen} corner_std='
|
||||
f' 棋盘={seen} 角点标准差='
|
||||
f'{std if std < 1e8 else float("nan"):.3f}px'
|
||||
)
|
||||
last_log = now
|
||||
@@ -586,7 +586,12 @@ class HandeyeCalibrate(Node):
|
||||
|
||||
|
||||
def _verify_joints(self, target: List[float], tol: float = 0.08) -> bool:
|
||||
"""Confirm joint_states reached target (detect fake trajectory success)."""
|
||||
"""确认 ``joint_states`` 真的走到了目标位置。
|
||||
|
||||
用来识破"假的成功":有些执行接口会立刻返回成功,但关节其实没动
|
||||
(或只动了一半)。这里在 2 秒内轮询关节角,逐轴比对容差 ``tol``(弧度),
|
||||
只有全部落在容差内才算真到位。
|
||||
"""
|
||||
deadline = time.monotonic() + 2.0
|
||||
while rclpy.ok() and time.monotonic() < deadline:
|
||||
rclpy.spin_once(self, timeout_sec=0.05)
|
||||
@@ -602,10 +607,10 @@ class HandeyeCalibrate(Node):
|
||||
continue
|
||||
err = float(np.sqrt(np.mean([(a - b) ** 2 for a, b in zip(actual, target)])))
|
||||
self.get_logger().info(
|
||||
f' joint RMSE vs target: {err:.4f} rad (tol={tol})'
|
||||
f' 关节相对目标的 RMSE:{err:.4f} rad(tol={tol})'
|
||||
)
|
||||
return err <= tol
|
||||
self.get_logger().error('No joint_states while verifying motion')
|
||||
self.get_logger().error('校验运动时没有 joint_states')
|
||||
return False
|
||||
|
||||
def _move_one(self, positions: List[float], duration: float) -> int:
|
||||
@@ -617,18 +622,18 @@ class HandeyeCalibrate(Node):
|
||||
goal.time_from_start = [float(duration)]
|
||||
|
||||
send = self._client.send_goal_async(goal)
|
||||
if not self._wait_future(send, 10.0, 'send 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('Goal rejected')
|
||||
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), 'result'):
|
||||
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'Move failed: {result.message}')
|
||||
self.get_logger().error(f'移动失败:{result.message}')
|
||||
return 6
|
||||
return 0
|
||||
|
||||
@@ -637,7 +642,7 @@ class HandeyeCalibrate(Node):
|
||||
while rclpy.ok() and not fut.done():
|
||||
rclpy.spin_once(self, timeout_sec=0.05)
|
||||
if time.monotonic() > deadline:
|
||||
self.get_logger().error(f'Timeout waiting for {label}')
|
||||
self.get_logger().error(f'等待 {label} 超时')
|
||||
return False
|
||||
return fut.done()
|
||||
|
||||
@@ -656,7 +661,7 @@ class HandeyeCalibrate(Node):
|
||||
import cv2
|
||||
|
||||
if not cv2.imwrite(str(path), bgr):
|
||||
raise RuntimeError(f'cv2.imwrite failed: {path}')
|
||||
raise RuntimeError(f'cv2.imwrite 失败:{path}')
|
||||
|
||||
|
||||
def main(args=None) -> None:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hand-eye helpers: chessboard stabilize + AX=XB solve.
|
||||
"""手眼标定辅助工具:棋盘稳定判定 + AX=XB 求解。
|
||||
|
||||
Uses ``cv2.calibrateHandEye`` when present; otherwise Tsai-Lenz (NumPy).
|
||||
Chessboard detection always needs OpenCV.
|
||||
有 ``cv2.calibrateHandEye`` 时用它,否则用 Tsai-Lenz(NumPy 实现)。
|
||||
棋盘检测始终依赖 OpenCV。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,9 +21,9 @@ except Exception: # noqa: BLE001
|
||||
|
||||
@dataclass
|
||||
class BoardConfig:
|
||||
cols: int # inner corners (width)
|
||||
rows: int # inner corners (height)
|
||||
square_size: float # meters
|
||||
cols: int # 内角点数(宽)
|
||||
rows: int # 内角点数(高)
|
||||
square_size: float # 米
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -78,9 +78,9 @@ def board_object_points(cfg: BoardConfig) -> np.ndarray:
|
||||
def detect_chessboard(
|
||||
image_bgr: np.ndarray, cfg: BoardConfig
|
||||
) -> Optional[Tuple[np.ndarray, Tuple[int, int]]]:
|
||||
"""Return (corners, pattern_size) or None. pattern is (cols, rows) used."""
|
||||
"""返回 (角点, pattern_size) 或 None。pattern 为实际使用的 (cols, rows)。"""
|
||||
if cv2 is None:
|
||||
raise RuntimeError('OpenCV (cv2) is required for chessboard detection')
|
||||
raise RuntimeError('棋盘检测需要 OpenCV(cv2)')
|
||||
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
|
||||
pattern = (int(cfg.cols), int(cfg.rows))
|
||||
flags = cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE
|
||||
@@ -105,7 +105,7 @@ def solve_board_pose(
|
||||
pattern: Optional[Tuple[int, int]] = None,
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
if cv2 is None:
|
||||
raise RuntimeError('OpenCV (cv2) is required for solvePnP')
|
||||
raise RuntimeError('solvePnP 需要 OpenCV(cv2)')
|
||||
if pattern is None:
|
||||
pattern = (cfg.cols, cfg.rows)
|
||||
board = BoardConfig(pattern[0], pattern[1], cfg.square_size)
|
||||
@@ -114,7 +114,7 @@ def solve_board_pose(
|
||||
obj, corners, cam.K, cam.dist, flags=cv2.SOLVEPNP_ITERATIVE
|
||||
)
|
||||
if not ok:
|
||||
raise RuntimeError('solvePnP failed')
|
||||
raise RuntimeError('solvePnP 失败')
|
||||
R, _ = cv2.Rodrigues(rvec)
|
||||
return R.astype(float), np.asarray(tvec, dtype=float).reshape(3, 1)
|
||||
|
||||
@@ -172,10 +172,10 @@ def calibrate_hand_eye_tsai(
|
||||
R_target2cam: List[np.ndarray],
|
||||
t_target2cam: List[np.ndarray],
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""Tsai-Lenz. Returns R_cam2gripper, t_cam2gripper."""
|
||||
"""Tsai-Lenz 方法。返回 R_cam2gripper、t_cam2gripper。"""
|
||||
n = len(R_gripper2base)
|
||||
if n < 3:
|
||||
raise ValueError('Need >= 3 poses')
|
||||
raise ValueError('至少需要 3 个位姿')
|
||||
|
||||
Hg = [Rt_to_T(R, t) for R, t in zip(R_gripper2base, t_gripper2base)]
|
||||
Hc = [Rt_to_T(R, t) for R, t in zip(R_target2cam, t_target2cam)]
|
||||
@@ -220,8 +220,8 @@ def calibrate_hand_eye(
|
||||
eye_to_hand: bool = False,
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
eye_in_hand -> X = cam -> gripper (mounted on flange)
|
||||
eye_to_hand -> X = cam -> base (fixed camera)
|
||||
eye_in_hand -> X = cam -> gripper(相机装在法兰上)
|
||||
eye_to_hand -> X = cam -> base(相机固定安装)
|
||||
"""
|
||||
Rg = list(R_gripper2base)
|
||||
tg = list(t_gripper2base)
|
||||
@@ -240,7 +240,7 @@ def calibrate_hand_eye(
|
||||
)
|
||||
return np.asarray(R, float), np.asarray(t, float).reshape(3, 1)
|
||||
except Exception:
|
||||
pass # fall back to NumPy Tsai
|
||||
pass # 退回 NumPy 版 Tsai
|
||||
return calibrate_hand_eye_tsai(Rg, tg, R_target2cam, t_target2cam)
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ def format_transform_yaml(R: np.ndarray, t: np.ndarray, name: str) -> str:
|
||||
|
||||
|
||||
def is_pose_filled(joints: Sequence[float]) -> bool:
|
||||
"""False if all zeros / NaN placeholder."""
|
||||
"""若全为零 / NaN 占位则返回 False。"""
|
||||
arr = np.asarray(joints, dtype=float)
|
||||
if arr.size != 7:
|
||||
return False
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Query RealSense (D405 / D435) factory color intrinsics via pyrealsense2."""
|
||||
"""通过 pyrealsense2 查询 RealSense(D405 / D435)的出厂彩色内参。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,7 +19,7 @@ class RealsenseIntrinsics:
|
||||
fy: float
|
||||
cx: float
|
||||
cy: float
|
||||
dist: List[float] # OpenCV order, length 5
|
||||
dist: List[float] # OpenCV 顺序,长度 5
|
||||
|
||||
def as_K(self) -> np.ndarray:
|
||||
return np.array(
|
||||
@@ -32,7 +32,7 @@ class RealsenseIntrinsics:
|
||||
return np.array(d[:5], dtype=float).reshape(-1, 1)
|
||||
|
||||
|
||||
# Default ROS topic namespaces when camera_source is d405/d435.
|
||||
# camera_source 为 d405/d435 时的默认 ROS 话题命名空间。
|
||||
REALSENSE_TOPIC_DEFAULTS = {
|
||||
'd405': {
|
||||
'image_topic': '/d405/color/image_raw',
|
||||
@@ -55,13 +55,13 @@ def _norm_model(model: str) -> str:
|
||||
|
||||
|
||||
def list_realsense_devices() -> List[Tuple[str, str]]:
|
||||
"""Return [(name, serial), ...]."""
|
||||
"""返回 [(name, serial), ...]。"""
|
||||
try:
|
||||
import pyrealsense2 as rs
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(
|
||||
'pyrealsense2 not installed. '
|
||||
'Install in ros_flexiv: pip/conda install pyrealsense2'
|
||||
'未安装 pyrealsense2。'
|
||||
'请在 ros_flexiv 环境中安装:pip/conda install pyrealsense2'
|
||||
) from exc
|
||||
out: List[Tuple[str, str]] = []
|
||||
for d in rs.context().devices:
|
||||
@@ -79,27 +79,27 @@ def query_realsense_color_intrinsics(
|
||||
height: int = 720,
|
||||
) -> RealsenseIntrinsics:
|
||||
"""
|
||||
Read on-device color intrinsics for D405/D435 at the given resolution.
|
||||
在给定分辨率下读取 D405/D435 设备上的彩色内参。
|
||||
|
||||
Does not start a streaming pipeline (uses profile metadata).
|
||||
不会启动流管道(只读取 profile 元数据)。
|
||||
"""
|
||||
try:
|
||||
import pyrealsense2 as rs
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(
|
||||
'pyrealsense2 not installed. '
|
||||
'Install in ros_flexiv: pip/conda install pyrealsense2'
|
||||
'未安装 pyrealsense2。'
|
||||
'请在 ros_flexiv 环境中安装:pip/conda install pyrealsense2'
|
||||
) from exc
|
||||
|
||||
want = _norm_model(model)
|
||||
if want not in ('d405', 'd435'):
|
||||
raise ValueError(f'Unsupported model {model!r}; use d405 or d435')
|
||||
raise ValueError(f'不支持的型号 {model!r};请使用 d405 或 d435')
|
||||
|
||||
hint = want.upper() # D405 / D435
|
||||
serial = (serial or '').strip()
|
||||
devices = list(rs.context().devices)
|
||||
if not devices:
|
||||
raise RuntimeError('No RealSense device connected')
|
||||
raise RuntimeError('未连接任何 RealSense 设备')
|
||||
|
||||
chosen = None
|
||||
for d in devices:
|
||||
@@ -119,14 +119,14 @@ def query_realsense_color_intrinsics(
|
||||
for d in devices
|
||||
)
|
||||
raise RuntimeError(
|
||||
f'No RealSense matching model={want!r} serial={serial!r}. '
|
||||
f'Connected: {avail or "none"}'
|
||||
f'没有匹配 model={want!r} serial={serial!r} 的 RealSense。'
|
||||
f'已连接的设备:{avail or "无"}'
|
||||
)
|
||||
|
||||
name = chosen.get_info(rs.camera_info.name)
|
||||
sn = chosen.get_info(rs.camera_info.serial_number)
|
||||
|
||||
best = None # (score, width, height, fx, fy, cx, cy, coeffs)
|
||||
best = None # (评分, width, height, fx, fy, cx, cy, 畸变系数)
|
||||
for s in chosen.sensors:
|
||||
for p in s.profiles:
|
||||
if p.stream_type() != rs.stream.color:
|
||||
@@ -155,7 +155,7 @@ def query_realsense_color_intrinsics(
|
||||
best = cand
|
||||
|
||||
if best is None:
|
||||
raise RuntimeError(f'No color stream profile on {name} ({sn})')
|
||||
raise RuntimeError(f'{name} ({sn}) 上没有彩色流 profile')
|
||||
|
||||
_, w, h, fx, fy, cx, cy, coeffs = best
|
||||
while len(coeffs) < 5:
|
||||
@@ -174,7 +174,7 @@ def query_realsense_color_intrinsics(
|
||||
|
||||
|
||||
def is_realsense_source(name: str) -> bool:
|
||||
"""True for d405 / d435 and namespaced aliases (d405_left, d405_right)."""
|
||||
"""对 d405 / d435 及带命名空间的别名(d405_left、d405_right)返回 True。"""
|
||||
return _norm_model(name or "") in ("d405", "d435")
|
||||
|
||||
|
||||
@@ -185,9 +185,9 @@ def resolve_realsense_topics(
|
||||
image_topic: str = '',
|
||||
camera_info_topic: str = '',
|
||||
) -> Tuple[str, str]:
|
||||
"""Fill default image/camera_info topics for d405/d435 if empty.
|
||||
"""为空时,为 d405/d435 填充默认的 image/camera_info 话题。
|
||||
|
||||
``model`` may be a topic namespace such as ``d405_left`` / ``d405_right``.
|
||||
``model`` 可以是 ``d405_left`` / ``d405_right`` 这类话题命名空间。
|
||||
"""
|
||||
ns = (camera_namespace or model or 'd405').strip().strip('/')
|
||||
img = (image_topic or '').strip()
|
||||
@@ -210,7 +210,7 @@ def apply_realsense_exposure(
|
||||
gain: float | None = None,
|
||||
log=None,
|
||||
) -> list[str]:
|
||||
"""Cap AE / kill projector. Wrist D405 overexposure is usually long AE + emitter."""
|
||||
"""限制自动曝光 / 关闭投影器。腕部 D405 过曝通常是自动曝光过长 + 发射器导致。"""
|
||||
import pyrealsense2 as rs
|
||||
|
||||
notes: list[str] = []
|
||||
@@ -230,7 +230,7 @@ def apply_realsense_exposure(
|
||||
laser_opt = getattr(rs.option, "laser_power", None)
|
||||
if laser_opt is not None and not emitter_enabled:
|
||||
_set(sensor, laser_opt, float(laser_power))
|
||||
# Must disable AE *before* writing exposure; D405 firmware ignores exposure while AE is on.
|
||||
# 必须在写曝光*之前*关闭自动曝光;D405 固件在自动曝光开启时会忽略曝光设置。
|
||||
if _set(sensor, rs.option.enable_auto_exposure, 1.0 if auto_exposure else 0.0):
|
||||
notes.append(f"ae={int(bool(auto_exposure))}")
|
||||
pri = getattr(rs.option, "auto_exposure_priority", None)
|
||||
@@ -247,5 +247,5 @@ def apply_realsense_exposure(
|
||||
if gain is not None and _set(sensor, rs.option.gain, float(gain)):
|
||||
notes.append(f"gain={gain}")
|
||||
if log and notes:
|
||||
log("RealSense exposure: " + ", ".join(dict.fromkeys(notes)))
|
||||
log("RealSense 曝光设置:" + ", ".join(dict.fromkeys(notes)))
|
||||
return notes
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hand-eye teach: live preview + Free Drive + Enter to photo/record joints.
|
||||
"""手眼示教:实时预览 + Free Drive + 按 Enter 拍照/记录关节。
|
||||
|
||||
Two backends
|
||||
两种后端
|
||||
------------
|
||||
ros (default, stack running):
|
||||
- Joints from /get_arm_state
|
||||
- Drag: teach pendant Free Drive + hold enable
|
||||
Keep: bash ~/ros2/scripts/run_real.sh
|
||||
ros(默认,整套栈正在运行):
|
||||
- 关节来自 ``/get_arm_state``
|
||||
- 拖拽:示教器 Free Drive + 按住使能键
|
||||
需保持:bash ~/ros2/scripts/run_real.sh
|
||||
|
||||
rdk (software Free Drive):
|
||||
- Stop run_real.sh first (RDK exclusive)
|
||||
- Press 'd' to enable FloatingJoint drag via RDK
|
||||
- Joints from RDK robot.states
|
||||
rdk(软件 Free Drive):
|
||||
- 先停掉 run_real.sh(RDK 独占)
|
||||
- 按 'd' 通过 RDK 启用 FloatingJoint 拖拽
|
||||
- 关节来自 RDK 的 robot.states
|
||||
|
||||
source ~/ros2/scripts/env.sh
|
||||
ros2 run arm_application handeye_teach --ros-args -p backend:=rdk
|
||||
|
||||
UI keys (preview window)
|
||||
Enter / Space photo + record joints
|
||||
d enable Free Drive (rdk) / show pendant tip (ros)
|
||||
s stop Free Drive (rdk) / hold
|
||||
q / Esc finish
|
||||
界面按键(预览窗口)
|
||||
Enter / Space 拍照 + 记录关节
|
||||
d 启用 Free Drive(rdk)/ 显示示教器提示(ros)
|
||||
s 停止 Free Drive(rdk)/ 保持
|
||||
q / Esc 结束
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -40,21 +40,21 @@ from rclpy.node import Node
|
||||
|
||||
HELP = """
|
||||
================================================
|
||||
Hand-eye TEACH: preview -> drag -> photo+pose
|
||||
手眼示教 TEACH:预览 -> 拖拽 -> 拍照+位姿
|
||||
================================================
|
||||
Window keys:
|
||||
Enter / Space = take photo + save joints
|
||||
d = Free Drive ON
|
||||
s = Free Drive OFF / hold
|
||||
q / Esc = finish
|
||||
Tip: make board clear & fill ~30-70% of image
|
||||
Prefer 8-12 poses with varied angles
|
||||
窗口按键:
|
||||
Enter / Space = 拍照 + 保存关节
|
||||
d = 开启 Free Drive
|
||||
s = 关闭 Free Drive / 保持
|
||||
q / Esc = 结束
|
||||
提示:让棋盘清晰,并占到画面的约 30-70%
|
||||
建议采 8-12 个角度有变化的位姿
|
||||
================================================
|
||||
"""
|
||||
|
||||
|
||||
def _setup_matplotlib_font():
|
||||
"""Avoid Chinese mojibake: prefer CJK font if present."""
|
||||
"""避免中文乱码:优先选用存在的中日韩(CJK)字体。"""
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use('QtAgg')
|
||||
@@ -77,7 +77,7 @@ def _setup_matplotlib_font():
|
||||
chosen = name
|
||||
break
|
||||
if chosen is None:
|
||||
# try by file path
|
||||
# 按文件路径再试一次
|
||||
for path in (
|
||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc',
|
||||
@@ -104,7 +104,7 @@ def _param_as_str(node: Node, name: str, default: str = '') -> str:
|
||||
|
||||
|
||||
class RdkBridge:
|
||||
"""Talk to Flexiv RDK via rdk_py310 subprocess (Python 3.10 ABI)."""
|
||||
"""通过 rdk_py310 子进程(Python 3.10 ABI)与 Flexiv RDK 通信。"""
|
||||
|
||||
def __init__(self, robot_sn: str, logger=None) -> None:
|
||||
import json
|
||||
@@ -120,9 +120,9 @@ class RdkBridge:
|
||||
if not bridge.is_file():
|
||||
bridge = Path('/home/cc/ros2/scripts/rdk_drag_bridge.py')
|
||||
if not bridge.is_file():
|
||||
raise FileNotFoundError(f'rdk_drag_bridge.py not found: {bridge}')
|
||||
raise FileNotFoundError(f'找不到 rdk_drag_bridge.py:{bridge}')
|
||||
|
||||
# Only Flexiv libs — do not inherit conda LD_LIBRARY_PATH (can hang RDK).
|
||||
# 只保留 Flexiv 的库 —— 不要继承 conda 的 LD_LIBRARY_PATH(会让 RDK 卡死)。
|
||||
flexiv_lib = os.path.expanduser('~/flexiv_install/lib')
|
||||
env = {
|
||||
'HOME': os.environ.get('HOME', ''),
|
||||
@@ -130,9 +130,9 @@ class RdkBridge:
|
||||
'LD_LIBRARY_PATH': flexiv_lib,
|
||||
'LANG': 'C.UTF-8',
|
||||
}
|
||||
# Do NOT inherit conda PYTHONPATH.
|
||||
# IMPORTANT: do not use PIPE for stderr — RDK logs fill the pipe and
|
||||
# deadlock the bridge (stdout never replies). Log to a file instead.
|
||||
# 不要继承 conda 的 PYTHONPATH。
|
||||
# 重要:stderr 不能用 PIPE —— RDK 日志会填满管道并让桥接进程死锁
|
||||
# (stdout 永远不返回)。改成写入日志文件。
|
||||
log_path = Path('/tmp/rdk_drag_bridge.log')
|
||||
self._err_file = open(log_path, 'w', encoding='utf-8')
|
||||
self._proc = subprocess.Popen(
|
||||
@@ -145,14 +145,14 @@ class RdkBridge:
|
||||
env=env,
|
||||
)
|
||||
if logger:
|
||||
logger.info(f'RDK bridge started; log → {log_path}')
|
||||
logger.info(f'RDK 桥接已启动;日志 → {log_path}')
|
||||
pong = self._ask({'cmd': 'ping'}, timeout=8.0)
|
||||
if not pong.get('ok'):
|
||||
raise RuntimeError(
|
||||
f'RDK bridge ping failed: {pong}. See {log_path}'
|
||||
f'RDK 桥接 ping 失败:{pong}。参见 {log_path}'
|
||||
)
|
||||
if logger:
|
||||
logger.info('RDK bridge ping OK, connecting to robot...')
|
||||
logger.info('RDK 桥接 ping 正常,正在连接机器人...')
|
||||
conn = self._ask(
|
||||
{
|
||||
'cmd': 'connect',
|
||||
@@ -163,11 +163,11 @@ class RdkBridge:
|
||||
)
|
||||
if not conn.get('ok'):
|
||||
raise RuntimeError(
|
||||
f'RDK connect failed: {conn.get("error")}. '
|
||||
f'Stop run_real.sh first; AUTO Remote + clear fault. See {log_path}'
|
||||
f'RDK 连接失败:{conn.get("error")}。'
|
||||
f'请先停掉 run_real.sh;切 AUTO Remote 并清除故障。参见 {log_path}'
|
||||
)
|
||||
if logger:
|
||||
logger.info(f'RDK bridge OK (py={pong.get("py")}) sn={robot_sn}')
|
||||
logger.info(f'RDK 桥接就绪(py={pong.get("py")})sn={robot_sn}')
|
||||
q0 = conn.get('q') or []
|
||||
if len(q0) >= 7:
|
||||
self._last_q = [float(v) for v in q0[:7]]
|
||||
@@ -175,9 +175,9 @@ class RdkBridge:
|
||||
def _ask(self, msg: dict, timeout: float = 10.0) -> dict:
|
||||
import select
|
||||
|
||||
# One in-flight command: camera/GUI must never share this pipe.
|
||||
# 同一时刻只允许一条在途命令:相机/GUI 绝不能共用这个管道。
|
||||
if not self._io.acquire(timeout=max(timeout, 0.2)):
|
||||
return {'ok': False, 'error': 'bridge busy'}
|
||||
return {'ok': False, 'error': '桥接忙'}
|
||||
try:
|
||||
assert self._proc.stdin and self._proc.stdout
|
||||
self._proc.stdin.write(self._json.dumps(msg) + '\n')
|
||||
@@ -188,7 +188,7 @@ class RdkBridge:
|
||||
tip = '/tmp/rdk_drag_bridge.log'
|
||||
return {
|
||||
'ok': False,
|
||||
'error': f'bridge exited rc={self._proc.returncode}; see {tip}',
|
||||
'error': f'桥接已退出 rc={self._proc.returncode};参见 {tip}',
|
||||
}
|
||||
ready, _, _ = select.select([self._proc.stdout], [], [], 0.05)
|
||||
if not ready:
|
||||
@@ -202,8 +202,8 @@ class RdkBridge:
|
||||
try:
|
||||
return self._json.loads(line)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {'ok': False, 'error': f'bad reply: {exc} {line!r}'}
|
||||
return {'ok': False, 'error': 'bridge timeout'}
|
||||
return {'ok': False, 'error': f'应答异常:{exc} {line!r}'}
|
||||
return {'ok': False, 'error': '桥接超时'}
|
||||
finally:
|
||||
self._io.release()
|
||||
|
||||
@@ -296,14 +296,14 @@ class HandeyeTeach(Node):
|
||||
self._start_gui()
|
||||
print(HELP)
|
||||
if self.backend == 'rdk':
|
||||
print('[backend=rdk] Press d = soft-drag (works in AUTO Remote, no Floating license)')
|
||||
print(' run_real.sh must be STOPPED.')
|
||||
print('[backend=rdk] 按 d = 软拖拽(在 AUTO Remote 下可用,无需 Floating 许可)')
|
||||
print(' run_real.sh 必须已停止。')
|
||||
print(' 拖着就能按 Enter 拍照,不必先按 s')
|
||||
else:
|
||||
print('[backend=ros] Drag with pendant Free Drive + hold enable.')
|
||||
print(' Or stop run_real.sh and use: -p backend:=rdk')
|
||||
print('[backend=ros] 用示教器 Free Drive + 按住使能键来拖拽。')
|
||||
print(' 或停掉 run_real.sh 后改用:-p backend:=rdk')
|
||||
if font:
|
||||
self.get_logger().info(f'UI font: {font}')
|
||||
self.get_logger().info(f'界面字体:{font}')
|
||||
self.create_timer(1.0 / max(1, self._fps), self._tick)
|
||||
|
||||
def _init_ros_state(self) -> None:
|
||||
@@ -312,15 +312,15 @@ class HandeyeTeach(Node):
|
||||
self._get_state = self.create_client(GetArmState, 'get_arm_state')
|
||||
if not self._get_state.wait_for_service(timeout_sec=10.0):
|
||||
raise RuntimeError(
|
||||
'get_arm_state unavailable. Start run_real.sh, '
|
||||
'or use: -p backend:=rdk (after stopping the stack)'
|
||||
'get_arm_state 不可用。请启动 run_real.sh,'
|
||||
'或改用:-p backend:=rdk(需先停掉整套栈)'
|
||||
)
|
||||
|
||||
def _init_rdk(self) -> None:
|
||||
self.get_logger().info(f'Connecting RDK bridge to {self.robot_sn} ...')
|
||||
self.get_logger().info(f'正在把 RDK 桥接连到 {self.robot_sn} ...')
|
||||
self._rdk = RdkBridge(self.robot_sn, logger=self.get_logger())
|
||||
self._robot = True # marker: rdk backend ready
|
||||
self.get_logger().info('RDK connected. Press d for Free Drive.')
|
||||
self._robot = True # 标记:RDK 后端已就绪
|
||||
self.get_logger().info('RDK 已连接。按 d 启用 Free Drive。')
|
||||
|
||||
def _start_camera(self) -> None:
|
||||
import pyrealsense2 as rs
|
||||
@@ -342,7 +342,7 @@ class HandeyeTeach(Node):
|
||||
cfg.enable_stream(rs.stream.color, ww, hh, rs.format.bgr8, ff)
|
||||
profile = pipe.start(cfg)
|
||||
self._fps = ff
|
||||
self.get_logger().info(f'Camera {ww}x{hh}@{ff}Hz')
|
||||
self.get_logger().info(f'相机 {ww}x{hh}@{ff}Hz')
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_err = exc
|
||||
@@ -353,7 +353,7 @@ class HandeyeTeach(Node):
|
||||
pipe = rs.pipeline()
|
||||
if profile is None:
|
||||
raise RuntimeError(
|
||||
f'Cannot open D405 — stop realsense_color_pub first. ({last_err})'
|
||||
f'无法打开 D405 —— 请先停掉 realsense_color_pub。({last_err})'
|
||||
)
|
||||
self._pipe = pipe
|
||||
try:
|
||||
@@ -371,7 +371,7 @@ class HandeyeTeach(Node):
|
||||
log=self.get_logger().info,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f'exposure apply: {exc}')
|
||||
self.get_logger().warn(f'曝光设置应用失败:{exc}')
|
||||
self._cam_thread = threading.Thread(target=self._cam_loop, name='d405', daemon=True)
|
||||
self._cam_thread.start()
|
||||
|
||||
@@ -395,7 +395,7 @@ class HandeyeTeach(Node):
|
||||
self._plt = plt
|
||||
self._fig, self._ax = plt.subplots(1, 1, figsize=(11, 7))
|
||||
self._fig.canvas.manager.set_window_title(
|
||||
'handeye_teach | Enter=photo d=drag s=stop q=quit'
|
||||
'handeye_teach | Enter=拍照 d=拖拽 s=停止 q=退出'
|
||||
)
|
||||
self._im = None
|
||||
self._status = self._ax.text(
|
||||
@@ -415,21 +415,21 @@ class HandeyeTeach(Node):
|
||||
plt.show(block=False)
|
||||
|
||||
def _status_text(self) -> str:
|
||||
drag = 'DRAG ON' if self._drag_on else 'drag off'
|
||||
drag = '拖拽开启' if self._drag_on else '拖拽关闭'
|
||||
if self.backend == 'rdk':
|
||||
tip = 'd=soft-drag s=hold'
|
||||
tip = 'd=软拖拽 s=保持'
|
||||
else:
|
||||
tip = 'Pendant FreeDrive+Enable'
|
||||
tip = '示教器 FreeDrive+使能'
|
||||
return (
|
||||
f'captured:{len(self._captures)} [{drag}] '
|
||||
f'{tip} | Enter=photo q=quit'
|
||||
f'已采集:{len(self._captures)} [{drag}] '
|
||||
f'{tip} | Enter=拍照 q=退出'
|
||||
)
|
||||
|
||||
def _on_key(self, event) -> None:
|
||||
key = (event.key or '').lower()
|
||||
if key in ('q', 'escape'):
|
||||
self._quit = True
|
||||
self.get_logger().info('Quit requested')
|
||||
self.get_logger().info('收到退出请求')
|
||||
return
|
||||
if self._cmd_pending:
|
||||
return
|
||||
@@ -446,7 +446,7 @@ class HandeyeTeach(Node):
|
||||
elif key == 's':
|
||||
self._disable_drag()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f'key {key}: {exc}')
|
||||
self.get_logger().error(f'按键 {key}:{exc}')
|
||||
finally:
|
||||
self._cmd_pending = False
|
||||
|
||||
@@ -454,34 +454,34 @@ class HandeyeTeach(Node):
|
||||
if self.backend != 'rdk':
|
||||
self._drag_on = True
|
||||
self.get_logger().warn(
|
||||
'ROS backend: use teach pendant Free Drive + hold enable button. '
|
||||
'For software drag: stop run_real.sh, then '
|
||||
'ROS 后端:请用示教器的 Free Drive + 按住使能键。'
|
||||
'想用软件拖拽:先停掉 run_real.sh,然后执行 '
|
||||
'ros2 run arm_application handeye_teach --ros-args -p backend:=rdk'
|
||||
)
|
||||
self._status.set_text(self._status_text())
|
||||
return
|
||||
self.get_logger().info('starting soft-drag (preview stays live) ...')
|
||||
# soft = joint impedance drag under AUTO Remote (no Floating license)
|
||||
self.get_logger().info('正在启动软拖拽(预览保持实时刷新)...')
|
||||
# soft = AUTO Remote 模式下的关节阻抗拖拽(不需要 Floating 许可)
|
||||
res = self._rdk.drag_on(self.drag_primitive, mode='soft')
|
||||
if res.get('ok'):
|
||||
self._drag_on = True
|
||||
self.get_logger().info(
|
||||
f'Drag ON mode={res.get("mode")} — push the arm, Enter=photo (s=hold optional)'
|
||||
f'拖拽已开启 mode={res.get("mode")} —— 推着机械臂走,Enter=拍照(s=保持,可选)'
|
||||
)
|
||||
if res.get('hint'):
|
||||
self.get_logger().info(str(res.get('hint')))
|
||||
else:
|
||||
self._drag_on = False
|
||||
self.get_logger().error(f'Drag failed: {res.get("error")}')
|
||||
# status text is refreshed by _tick on the GUI thread
|
||||
self.get_logger().error(f'拖拽失败:{res.get("error")}')
|
||||
# 状态文字由 GUI 线程里的 _tick 刷新
|
||||
|
||||
def _disable_drag(self) -> None:
|
||||
if self.backend == 'rdk' and getattr(self, '_rdk', None) is not None:
|
||||
res = self._rdk.drag_off()
|
||||
if not res.get('ok'):
|
||||
self.get_logger().warn(f'Stop drag: {res.get("error")}')
|
||||
self.get_logger().warn(f'停止拖拽:{res.get("error")}')
|
||||
self._drag_on = False
|
||||
self.get_logger().info('Free Drive OFF / hold')
|
||||
self.get_logger().info('Free Drive 已关闭 / 保持')
|
||||
|
||||
def _tick(self) -> None:
|
||||
if self._quit:
|
||||
@@ -510,7 +510,7 @@ class HandeyeTeach(Node):
|
||||
if self.backend == 'rdk' and getattr(self, '_rdk', None) is not None:
|
||||
q = self._rdk.joints()
|
||||
if q is None:
|
||||
self.get_logger().error('RDK joints failed')
|
||||
self.get_logger().error('读取 RDK 关节失败')
|
||||
return q
|
||||
from arm_msgs.srv import GetArmState
|
||||
|
||||
@@ -532,11 +532,11 @@ class HandeyeTeach(Node):
|
||||
with self._frame_lock:
|
||||
bgr = None if self._latest_bgr is None else self._latest_bgr.copy()
|
||||
if bgr is None:
|
||||
self.get_logger().warn('No camera frame yet')
|
||||
self.get_logger().warn('还没有相机帧')
|
||||
return
|
||||
q = self._read_joints()
|
||||
if q is None:
|
||||
self.get_logger().error('Failed to read joints')
|
||||
self.get_logger().error('读取关节失败')
|
||||
return
|
||||
|
||||
idx = len(self._captures)
|
||||
@@ -569,7 +569,7 @@ class HandeyeTeach(Node):
|
||||
if target > 0:
|
||||
if n < target:
|
||||
self.get_logger().info(
|
||||
f'[{n}/{target}] saved photo+joints —— 还差 {target - n} 个点'
|
||||
f'[{n}/{target}] 已保存照片+关节 —— 还差 {target - n} 个点'
|
||||
)
|
||||
elif n == target:
|
||||
self.get_logger().info(
|
||||
@@ -579,18 +579,18 @@ class HandeyeTeach(Node):
|
||||
)
|
||||
else:
|
||||
self.get_logger().info(
|
||||
f'[{n}/{target}] saved photo+joints —— 已超出目标 '
|
||||
f'[{n}/{target}] 已保存照片+关节 —— 已超出目标 '
|
||||
f'{n - target} 个点(会用全部点求解)'
|
||||
)
|
||||
else:
|
||||
self.get_logger().info(f'[{n}] saved photo+joints')
|
||||
self.get_logger().info(f'[{n}] 已保存照片+关节')
|
||||
self._write_yaml_snippet()
|
||||
finally:
|
||||
self._busy = False
|
||||
|
||||
def _write_yaml_snippet(self) -> None:
|
||||
lines = [
|
||||
'# Paste into handeye_calibrate.yaml -> waypoint_positions',
|
||||
'# 粘贴到 handeye_calibrate.yaml -> waypoint_positions',
|
||||
f'# num_poses: {len(self._captures)}',
|
||||
'waypoint_positions: [',
|
||||
]
|
||||
@@ -612,17 +612,17 @@ class HandeyeTeach(Node):
|
||||
except Exception:
|
||||
pass
|
||||
n = len(self._captures)
|
||||
print('\n========== TEACH DONE ==========')
|
||||
print(f'poses: {n}')
|
||||
print(f'joints: {self.poses_file}')
|
||||
print('\n========== 示教完成 ==========')
|
||||
print(f'位姿: {n}')
|
||||
print(f'关节: {self.poses_file}')
|
||||
print(f'yaml: {self.yaml_file}')
|
||||
print(f'images: {self.images_dir}')
|
||||
print(f'图像: {self.images_dir}')
|
||||
if n >= 1:
|
||||
print('\n--- paste into handeye_calibrate.yaml ---\n')
|
||||
print('\n--- 粘贴到 handeye_calibrate.yaml ---\n')
|
||||
print(self.yaml_file.read_text(encoding='utf-8'))
|
||||
print('Also set num_poses:', n)
|
||||
print('同时设置 num_poses:', n)
|
||||
if n < 5:
|
||||
self.get_logger().warn(f'Only {n} poses; need >=5 (prefer 8-12).')
|
||||
self.get_logger().warn(f'只有 {n} 个位姿;至少需要 5 个(建议 8-12 个)。')
|
||||
|
||||
|
||||
def main(args=None) -> None:
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hand-eye calibration waypoint runner.
|
||||
"""手眼标定路点执行器。
|
||||
|
||||
Moves to each joint waypoint one-by-one, waits for arrival, then dwells so a
|
||||
camera (or operator) can capture a calibration image.
|
||||
逐个移动到每个关节路点,等到位后停留一段时间,
|
||||
让相机(或操作者)采集一张标定图像。
|
||||
|
||||
capture_mode:
|
||||
auto — dwell_sec then next pose
|
||||
keypress — terminal Enter to continue
|
||||
preview — OpenCV live camera + Enter/Space confirm (recommended for teaching)
|
||||
auto — 停留 dwell_sec 后进入下一个位姿
|
||||
keypress — 在终端按 Enter 继续
|
||||
preview — OpenCV 实时相机 + Enter/Space 确认(示教时推荐)
|
||||
|
||||
During ``preview`` mode, use the teach pendant **Free Drive** to drag-adjust
|
||||
while the preview window is open (ros2_control keeps AUTO Remote; drag is
|
||||
via pendant, not RDK).
|
||||
处于 ``preview`` 模式时,可在预览窗口打开期间用示教器的 **Free Drive**
|
||||
拖拽微调(ros2_control 保持 AUTO Remote;拖拽经由示教器,而非 RDK)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -106,7 +105,7 @@ def _image_to_bgr(msg: Image) -> np.ndarray:
|
||||
img = data.reshape(h, w, 4)
|
||||
bgr = img[:, :, :3]
|
||||
return bgr[:, :, ::-1].copy() if enc == 'rgba8' else bgr
|
||||
raise RuntimeError(f'Unsupported image encoding: {msg.encoding!r}')
|
||||
raise RuntimeError(f'不支持的图像编码:{msg.encoding!r}')
|
||||
|
||||
|
||||
class HandeyeWaypoints(Node):
|
||||
@@ -165,14 +164,14 @@ class HandeyeWaypoints(Node):
|
||||
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 has no GUI (headless). Preview on /handeye/preview_image; '
|
||||
'confirm with Enter in this terminal.'
|
||||
'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'Preview image: {self.image_topic}')
|
||||
self.get_logger().info(f'预览图像:{self.image_topic}')
|
||||
|
||||
def _on_image(self, msg: Image) -> None:
|
||||
try:
|
||||
@@ -183,10 +182,10 @@ class HandeyeWaypoints(Node):
|
||||
def run(self) -> int:
|
||||
if self.capture_mode == 'preview':
|
||||
if cv2 is None:
|
||||
self.get_logger().error('preview mode needs OpenCV (cv2)')
|
||||
self.get_logger().error('preview 模式需要 OpenCV(cv2)')
|
||||
return 1
|
||||
if not self.image_topic:
|
||||
self.get_logger().error('preview mode needs image_topic or camera_source')
|
||||
self.get_logger().error('preview 模式需要 image_topic 或 camera_source')
|
||||
return 1
|
||||
|
||||
times = list(self.get_parameter('waypoint_times').value)
|
||||
@@ -194,23 +193,23 @@ class HandeyeWaypoints(Node):
|
||||
n_j = len(self.joint_names)
|
||||
n_pts = len(times)
|
||||
if n_pts < 1:
|
||||
self.get_logger().error('waypoint_times is empty')
|
||||
self.get_logger().error('waypoint_times 为空')
|
||||
return 1
|
||||
if len(positions) != n_pts * n_j:
|
||||
self.get_logger().error(
|
||||
f'waypoint_positions length {len(positions)} != {n_pts}*{n_j}'
|
||||
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} >= num waypoints {n_pts}'
|
||||
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}] not available. '
|
||||
'Start stack first: bash scripts/run_real.sh'
|
||||
f'Action [{self.action_name}] 不可用。'
|
||||
'请先启动整套栈:bash scripts/run_real.sh'
|
||||
)
|
||||
return 2
|
||||
|
||||
@@ -226,18 +225,18 @@ class HandeyeWaypoints(Node):
|
||||
+ [f'target_{n}' for n in self.joint_names]
|
||||
+ [f'measured_{n}' for n in self.joint_names]
|
||||
)
|
||||
self.get_logger().info(f'Logging captures → {path}')
|
||||
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'Hand-eye: {n_pts} poses, start={self.start_index}, '
|
||||
f'v_scale={self.velocity_scale}, dwell={self.dwell_sec}s, '
|
||||
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(
|
||||
'Clear workspace; keep board in FOV. Ctrl+C aborts.'
|
||||
'清理工作空间;让棋盘保持在视野内。Ctrl+C 可中止。'
|
||||
)
|
||||
|
||||
win = 'handeye_preview'
|
||||
@@ -246,7 +245,7 @@ class HandeyeWaypoints(Node):
|
||||
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}] moving (~{move_t:.1f}s)...'
|
||||
f'[{i + 1}/{n_pts}] 移动中(约 {move_t:.1f}s)...'
|
||||
)
|
||||
code = self._move_one(q, move_t)
|
||||
if code != 0:
|
||||
@@ -264,10 +263,10 @@ class HandeyeWaypoints(Node):
|
||||
csv_file.flush()
|
||||
|
||||
if not self._wait_capture(i, n_pts):
|
||||
self.get_logger().info('Aborted by user')
|
||||
self.get_logger().info('用户已中止')
|
||||
return 0
|
||||
|
||||
# After confirm in preview mode, save taught joints
|
||||
# 在 preview 模式下确认后,保存示教关节
|
||||
if self.capture_mode == 'preview':
|
||||
taught = self._read_joints()
|
||||
if taught:
|
||||
@@ -281,10 +280,10 @@ class HandeyeWaypoints(Node):
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
self.get_logger().info('Hand-eye waypoint sequence done')
|
||||
self.get_logger().info('手眼标定路点序列执行完毕')
|
||||
if self.append_poses_file and self.capture_mode == 'preview':
|
||||
self.get_logger().info(
|
||||
f'Taught poses saved → {self.append_poses_file}'
|
||||
f'示教位姿已保存 → {self.append_poses_file}'
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -299,7 +298,7 @@ class HandeyeWaypoints(Node):
|
||||
with path.open('a', encoding='utf-8') as f:
|
||||
f.write(f'# pose {index}\n')
|
||||
f.write(line + '\n')
|
||||
self.get_logger().info(f'Saved pose {index} → {path}')
|
||||
self.get_logger().info(f'已保存位姿 {index} → {path}')
|
||||
|
||||
def _move_one(self, positions: List[float], duration: float) -> int:
|
||||
goal = FollowJointTrajectory.Goal()
|
||||
@@ -310,19 +309,19 @@ class HandeyeWaypoints(Node):
|
||||
goal.time_from_start = [float(duration)]
|
||||
|
||||
send = self._client.send_goal_async(goal)
|
||||
if not self._wait_future(send, 10.0, 'send 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('Goal rejected')
|
||||
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), 'result'):
|
||||
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'Move failed: {result.message}')
|
||||
self.get_logger().error(f'移动失败:{result.message}')
|
||||
return 6
|
||||
return 0
|
||||
|
||||
@@ -331,7 +330,7 @@ class HandeyeWaypoints(Node):
|
||||
while rclpy.ok() and not fut.done():
|
||||
rclpy.spin_once(self, timeout_sec=0.05)
|
||||
if time.monotonic() > deadline:
|
||||
self.get_logger().error(f'Timeout waiting for {label}')
|
||||
self.get_logger().error(f'等待 {label} 超时')
|
||||
return False
|
||||
return fut.done()
|
||||
|
||||
@@ -359,11 +358,11 @@ class HandeyeWaypoints(Node):
|
||||
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}] ARRIVED — Free Drive adjust, then Enter/Space'
|
||||
f'[{index + 1}/{n_pts}] 已到位 —— 用 Free Drive 调整,然后按 Enter/Space'
|
||||
)
|
||||
else:
|
||||
self.get_logger().info(
|
||||
f'[{index + 1}/{n_pts}] ARRIVED — CAPTURE NOW q=[{q_str}]'
|
||||
f'[{index + 1}/{n_pts}] 已到位 —— 现在采集 q=[{q_str}]'
|
||||
)
|
||||
|
||||
def _wait_capture(self, index: int, n_pts: int) -> bool:
|
||||
@@ -372,7 +371,7 @@ class HandeyeWaypoints(Node):
|
||||
|
||||
if self.capture_mode in ('key', 'keypress', 'manual'):
|
||||
self.get_logger().info(
|
||||
'Press Enter after capturing (q+Enter to quit)...'
|
||||
'采集完成后按 Enter(q+Enter 退出)...'
|
||||
)
|
||||
for _ in range(5):
|
||||
rclpy.spin_once(self, timeout_sec=0.05)
|
||||
@@ -394,7 +393,7 @@ class HandeyeWaypoints(Node):
|
||||
return vis
|
||||
cv2.putText(
|
||||
vis,
|
||||
f'Pose {index + 1}/{n_pts}',
|
||||
f'位姿 {index + 1}/{n_pts}',
|
||||
(12, 28),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.7,
|
||||
@@ -403,7 +402,7 @@ class HandeyeWaypoints(Node):
|
||||
)
|
||||
cv2.putText(
|
||||
vis,
|
||||
'Free Drive drag | Enter=next q=quit',
|
||||
'Free Drive 拖拽 | Enter=下一个 q=退出',
|
||||
(12, 56),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.6,
|
||||
@@ -420,7 +419,7 @@ class HandeyeWaypoints(Node):
|
||||
msg.encoding = 'bgr8'
|
||||
msg.is_bigendian = 0
|
||||
msg.step = msg.width * 3
|
||||
msg.data = array.array('B', bgr.tobytes()) # fast path; bytes costs ~40 ms
|
||||
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:
|
||||
@@ -444,8 +443,8 @@ class HandeyeWaypoints(Node):
|
||||
confirmed.set()
|
||||
|
||||
self.get_logger().info(
|
||||
f'[{index + 1}/{n_pts}] Adjust with Free Drive, then press Enter '
|
||||
'(q+Enter to quit)'
|
||||
f'[{index + 1}/{n_pts}] 用 Free Drive 调整,然后按 Enter '
|
||||
'(q+Enter 退出)'
|
||||
)
|
||||
reader = threading.Thread(target=_stdin_reader, daemon=True)
|
||||
reader.start()
|
||||
@@ -464,7 +463,7 @@ class HandeyeWaypoints(Node):
|
||||
if bgr is None:
|
||||
if not no_frame_warn:
|
||||
self.get_logger().warn(
|
||||
f'Waiting for {self.image_topic} ...',
|
||||
f'正在等待 {self.image_topic} ...',
|
||||
throttle_duration_sec=3.0,
|
||||
)
|
||||
no_frame_warn = True
|
||||
@@ -485,7 +484,7 @@ class HandeyeWaypoints(Node):
|
||||
if bgr is None:
|
||||
if not no_frame_warn:
|
||||
self.get_logger().warn(
|
||||
f'Waiting for {self.image_topic} ...',
|
||||
f'正在等待 {self.image_topic} ...',
|
||||
throttle_duration_sec=3.0,
|
||||
)
|
||||
no_frame_warn = True
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hole detection on the current-arm D405 color topic. Detect-only (no insert motion)."""
|
||||
"""在当前机械臂的 D405 彩色话题上做入孔检测。只检测,不做插入动作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -60,7 +60,10 @@ _HOLE_SITE_SKIP = {"weights", "workpiece_weights", "config"}
|
||||
|
||||
|
||||
def config_from_site(data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Package yaml defaults, then overlay site.yaml hole_detect: (operator source of truth)."""
|
||||
"""先取包内 yaml 默认值,再用 ``site.yaml`` 的 ``hole_detect:`` 覆盖。
|
||||
|
||||
以 ``site.yaml`` 为准(operator 的唯一数据源);``data`` 参数用于测试时直接喂字典。
|
||||
"""
|
||||
from arm_application.site_io import deep_merge, load_site_dict, ros2_root
|
||||
|
||||
site = data if data is not None else load_site_dict()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Constant-velocity Kalman filter for a 3D point (camera frame)."""
|
||||
"""三维点的匀速模型卡尔曼滤波(坐标系为相机系)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,7 +9,7 @@ import numpy as np
|
||||
|
||||
|
||||
class KalmanFilter3D:
|
||||
"""State ``[x, y, z, vx, vy, vz]``. Measurement is position only."""
|
||||
"""状态量为 ``[x, y, z, vx, vy, vz]``;观测只有位置,没有速度。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -43,7 +43,11 @@ class KalmanFilter3D:
|
||||
return self.x[3:].copy()
|
||||
|
||||
def advance(self, t: float) -> None:
|
||||
"""Predict the state forward to time ``t`` (seconds, monotonic)."""
|
||||
"""把状态预测到时刻 ``t``(秒,必须单调递增)。
|
||||
|
||||
超过 0.5 秒的空档按"目标可能已经跑掉"处理:位置不动、速度清零,
|
||||
否则旧速度会把位置外推得很离谱。
|
||||
"""
|
||||
if not self.initialized or self._t is None:
|
||||
self._t = t
|
||||
return
|
||||
@@ -51,7 +55,7 @@ class KalmanFilter3D:
|
||||
if dt <= 1e-6:
|
||||
return
|
||||
if dt > 0.5:
|
||||
# Long gap — keep position, zero velocity.
|
||||
# 空档太长——位置保持不动,速度清零。
|
||||
self.x[3:] = 0.0
|
||||
self._t = t
|
||||
return
|
||||
@@ -59,7 +63,7 @@ class KalmanFilter3D:
|
||||
self._t = t
|
||||
|
||||
def update(self, z: np.ndarray) -> None:
|
||||
"""Measurement update with a 3D position."""
|
||||
"""用一次三维位置观测做量测更新。"""
|
||||
z = np.asarray(z, dtype=float).reshape(3)
|
||||
if not self.initialized:
|
||||
self.reset(z)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Example client for arm_msgs/action/MoveToPose (Cartesian goal).
|
||||
"""``arm_msgs/action/MoveToPose``(笛卡尔目标)的示例客户端。
|
||||
|
||||
Wire this to MoveIt or your IK solver action server when available.
|
||||
等你有了 MoveIt 或自己的 IK 求解 action server 后,把它接上去即可。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,8 +24,8 @@ class MoveToPoseClient(Node):
|
||||
def send(self, x: float, y: float, z: float) -> None:
|
||||
if not self._client.wait_for_server(timeout_sec=5.0):
|
||||
self.get_logger().error(
|
||||
'move_to_pose action not available. '
|
||||
'Start a Cartesian planner (e.g. MoveIt) that implements it.'
|
||||
'move_to_pose action 不可用。'
|
||||
'请启动一个实现了它的笛卡尔规划器(例如 MoveIt)。'
|
||||
)
|
||||
return
|
||||
|
||||
@@ -36,18 +36,18 @@ class MoveToPoseClient(Node):
|
||||
goal.target_pose.pose.position.x = x
|
||||
goal.target_pose.pose.position.y = y
|
||||
goal.target_pose.pose.position.z = z
|
||||
# Identity orientation
|
||||
# 单位姿态(不旋转)
|
||||
goal.target_pose.pose.orientation.w = 1.0
|
||||
goal.velocity_scale = 0.3
|
||||
goal.acceleration_scale = 0.3
|
||||
goal.avoid_collisions = True
|
||||
|
||||
self.get_logger().info(f'Sending Cartesian goal: ({x:.3f}, {y:.3f}, {z:.3f})')
|
||||
self.get_logger().info(f'发送笛卡尔目标:({x:.3f}, {y:.3f}, {z:.3f})')
|
||||
future = self._client.send_goal_async(goal)
|
||||
rclpy.spin_until_future_complete(self, future)
|
||||
handle = future.result()
|
||||
if handle is None or not handle.accepted:
|
||||
self.get_logger().error('Goal rejected')
|
||||
self.get_logger().error('目标被拒绝')
|
||||
return
|
||||
|
||||
result_future = handle.get_result_async()
|
||||
@@ -59,7 +59,7 @@ class MoveToPoseClient(Node):
|
||||
def main(args=None) -> None:
|
||||
rclpy.init(args=args)
|
||||
node = MoveToPoseClient()
|
||||
# Default demo pose; override via CLI: x y z
|
||||
# 默认演示位姿;可通过命令行覆盖:x y z
|
||||
vals = [0.35, 0.0, 0.35]
|
||||
if len(sys.argv) >= 4:
|
||||
vals = [float(sys.argv[1]), float(sys.argv[2]), float(sys.argv[3])]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Orisys visuo-tactile adapter. SDK is pip-installed; site.yaml holds device ids."""
|
||||
"""Orisys 视触觉适配器。SDK 通过 pip 安装;设备 id 存放在 ``site.yaml`` 里。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -185,8 +185,8 @@ class OrisysTactile(TactileInterface):
|
||||
import orisys
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"orisys SDK missing. pip install -e /path/to/orisys-sdk "
|
||||
"(git.orisys-tech.cn/orisys/test). Config still comes from site.yaml tactile."
|
||||
"缺少 orisys SDK。请执行 pip install -e /path/to/orisys-sdk "
|
||||
"(git.orisys-tech.cn/orisys/test)。配置依旧从 ``site.yaml`` 的 ``tactile`` 段读取。"
|
||||
) from exc
|
||||
|
||||
self._sensors = []
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""perception_node — YOLO + depth + KF (own process).
|
||||
"""perception_node —— YOLO + 深度 + 卡尔曼滤波(独立进程)。
|
||||
|
||||
Hard rules:
|
||||
- Image callbacks ONLY store latest frames (never run YOLO in the callback)
|
||||
- YOLO runs on a dedicated worker thread so MutuallyExclusive I/O is never blocked
|
||||
- SensorDataQoS BEST_EFFORT keep_last(5)
|
||||
- Publish filtered 3D as geometry_msgs/PointStamped (no Services)
|
||||
- Dynamic object TF at vision rate only
|
||||
- High-confidence detections always reacquire (no freeze on fast motion)
|
||||
硬性规则:
|
||||
- 图像回调**只**存最新帧(绝不在回调里跑 YOLO)
|
||||
- YOLO 跑在专门的工作线程上,保证 ``MutuallyExclusive`` 的 I/O 回调永远不会被阻塞
|
||||
- 用 ``SensorDataQoS`` ``BEST_EFFORT`` ``keep_last(5)``
|
||||
- 滤波后的三维点用 ``geometry_msgs/PointStamped`` 发布(不用 Service)
|
||||
- 动态物体 TF 只在视觉帧率下发布
|
||||
- 高置信度检测始终重新捕获(快速运动时不会冻结)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -50,7 +50,7 @@ from arm_application.yolo_detector import (
|
||||
|
||||
|
||||
def _bgr_copy(msg: Image) -> np.ndarray:
|
||||
"""BGR uint8 owned by caller (safe for async YOLO; ROS may drop the msg)."""
|
||||
"""返回调用方持有的 BGR uint8 数组(对异步 YOLO 安全;ROS 可能把原消息丢掉)。"""
|
||||
h, w = int(msg.height), int(msg.width)
|
||||
enc = (msg.encoding or "").lower()
|
||||
buf = np.frombuffer(msg.data, dtype=np.uint8)
|
||||
@@ -58,7 +58,7 @@ def _bgr_copy(msg: Image) -> np.ndarray:
|
||||
return buf.reshape(h, w, 3).copy()
|
||||
if enc == "rgb8":
|
||||
return buf.reshape(h, w, 3)[:, :, ::-1].copy()
|
||||
raise RuntimeError(f"Unsupported encoding {msg.encoding!r}")
|
||||
raise RuntimeError(f"不支持的编码 {msg.encoding!r}")
|
||||
|
||||
|
||||
class PerceptionNode(Node):
|
||||
@@ -66,7 +66,7 @@ class PerceptionNode(Node):
|
||||
super().__init__("perception_node")
|
||||
tid = threading.get_ident()
|
||||
self.get_logger().warn(
|
||||
f"perception_node started pid={os.getpid()} thread_id={tid}"
|
||||
f"perception_node 已启动 pid={os.getpid()} thread_id={tid}"
|
||||
)
|
||||
|
||||
self.declare_parameter("camera_source", "d405")
|
||||
@@ -86,30 +86,30 @@ class PerceptionNode(Node):
|
||||
self.declare_parameter("jump_z_m", 0.20)
|
||||
self.declare_parameter("min_depth_m", 0.04)
|
||||
self.declare_parameter("max_depth_m", 0.55)
|
||||
self.declare_parameter("work_z_max_m", 0.50) # above this = hole/background
|
||||
self.declare_parameter("work_z_max_m", 0.50) # 高于此值 = 孔 / 背景
|
||||
self.declare_parameter("object_frame", "tracked_object")
|
||||
self.declare_parameter("camera_frame", "d405_color_optical_frame")
|
||||
self.declare_parameter("kf_q_pos", 2.0e-3)
|
||||
self.declare_parameter("kf_q_vel", 5.0e-2)
|
||||
self.declare_parameter("kf_r_pos", 8.0e-4)
|
||||
self.declare_parameter("meas_blend", 0.65) # 1=raw meas, 0=pure KF
|
||||
self.declare_parameter("meas_blend", 0.65) # 1 = 原始测量值,0 = 纯卡尔曼滤波
|
||||
self.declare_parameter("half_precision", True)
|
||||
self.declare_parameter("backend", "auto") # auto|torch|tensorrt
|
||||
self.declare_parameter("mp_infer", True) # YOLO in a spawn() process
|
||||
# Adaptive Z LPF: alpha_z = clamp(z_alpha_min + |vel_z|*z_alpha_gain, min, max)
|
||||
self.declare_parameter("mp_infer", True) # YOLO 跑在 spawn() 子进程里
|
||||
# 自适应 Z 低通滤波:alpha_z = clamp(z_alpha_min + |vel_z|*z_alpha_gain, min, max)
|
||||
self.declare_parameter("z_alpha_min", 0.10)
|
||||
self.declare_parameter("z_alpha_max", 0.60)
|
||||
self.declare_parameter("z_alpha_gain", 2.0)
|
||||
# Faster YOLO: downscale before infer; boxes scaled back to full res
|
||||
# 加速 YOLO:推理前先降采样;检测框再缩放回全分辨率
|
||||
self.declare_parameter("infer_max_width", 0)
|
||||
# Box EMA (1=raw YOLO every frame — tight follow; lower = smoother/lag)
|
||||
# 检测框 EMA(1 = 每帧都用原始 YOLO —— 紧跟目标;更低 = 更平滑但滞后)
|
||||
self.declare_parameter("box_ema_alpha", 0.85)
|
||||
# Brief YOLO misses: keep last box on preview; clear only after N frames
|
||||
# YOLO 短暂漏检:预览里保留上一个框;只有连续 N 帧都漏检才清除
|
||||
self.declare_parameter("miss_clear_frames", 12)
|
||||
self.declare_parameter("overlay_hold_on_miss", True)
|
||||
self.declare_parameter("hold_point_on_miss", True)
|
||||
self.declare_parameter("miss_hold_point_frames", 2)
|
||||
# plane = stereo surface centroid (recommended); box = YOLO box center
|
||||
# plane = 立体表面质心(推荐);box = YOLO 检测框中心
|
||||
self.declare_parameter("feature_mode", "plane")
|
||||
self.declare_parameter("plane_inlier_m", 0.008)
|
||||
self.declare_parameter("plane_band_m", 0.06)
|
||||
@@ -170,7 +170,7 @@ class PerceptionNode(Node):
|
||||
self._stop = threading.Event()
|
||||
self._wake = threading.Event()
|
||||
|
||||
# I/O group: only lightweight store-latest (never YOLO)
|
||||
# I/O 回调组:只做轻量的“存最新帧”(绝不跑 YOLO)
|
||||
self._io_cbg = MutuallyExclusiveCallbackGroup()
|
||||
self._wake_cbg = MutuallyExclusiveCallbackGroup()
|
||||
|
||||
@@ -195,7 +195,7 @@ class PerceptionNode(Node):
|
||||
self._yolo_proc: Optional[YoloInferProcess] = None
|
||||
if self.mp_infer:
|
||||
self._yolo_proc = YoloInferProcess(yolo_cfg)
|
||||
self.detector = self._yolo_proc # duck-typed infer()
|
||||
self.detector = self._yolo_proc # 鸭子类型,只要求有 infer()
|
||||
imgsz = self._yolo_proc.imgsz
|
||||
self.get_logger().info(
|
||||
f"YOLO worker pid={self._yolo_proc._p.pid} weights={self._yolo_proc.weights_path}"
|
||||
@@ -221,7 +221,7 @@ class PerceptionNode(Node):
|
||||
try:
|
||||
self.detector.warmup(half=self._yolo_half)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f"YOLO warmup failed: {exc}")
|
||||
self.get_logger().warn(f"YOLO 预热失败:{exc}")
|
||||
imgsz = self.detector.imgsz
|
||||
|
||||
image_qos = QoSProfile(
|
||||
@@ -256,8 +256,8 @@ class PerceptionNode(Node):
|
||||
depth_topic = f"/{cam_ns}/aligned_depth_to_color/image_raw"
|
||||
if not img_topic:
|
||||
raise RuntimeError(
|
||||
f"image_topic empty (camera_source={cam_src!r} ns={ns!r}); "
|
||||
"use d405 / d405_left / d405_right"
|
||||
f"image_topic 为空(camera_source={cam_src!r} ns={ns!r});"
|
||||
"请使用 d405 / d405_left / d405_right"
|
||||
)
|
||||
self.get_logger().info(
|
||||
f"perception image={img_topic} depth={depth_topic} info={info_topic or '-'}"
|
||||
@@ -283,7 +283,7 @@ class PerceptionNode(Node):
|
||||
self._point_pub = self.create_publisher(
|
||||
PointStamped, "visual_servo/filtered_point", point_qos
|
||||
)
|
||||
# Reliable + keep_last(5): camera preview tolerates brief DDS gaps
|
||||
# RELIABLE + keep_last(5):相机预览能容忍短暂的 DDS 丢包间隙
|
||||
det_qos = QoSProfile(
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=5,
|
||||
@@ -298,7 +298,7 @@ class PerceptionNode(Node):
|
||||
self._worker.start()
|
||||
|
||||
self.get_logger().info(
|
||||
f"perception ready vision≤{vision_hz:.0f}Hz imgsz={imgsz} "
|
||||
f"perception 就绪 vision≤{vision_hz:.0f}Hz imgsz={imgsz} "
|
||||
f"infer_max_w={self.infer_max_width} mp={int(self.mp_infer)} "
|
||||
f"backend={self.backend_name} half={self._yolo_half} "
|
||||
f"feature={self.feature_mode} image={img_topic}"
|
||||
@@ -328,8 +328,8 @@ class PerceptionNode(Node):
|
||||
w = float(msg.width)
|
||||
self._fx = 654.8 if w >= 1200 else 393.9 * (w / 640.0)
|
||||
self._fy = 654.0 if w >= 1200 else 393.4 * (float(msg.height) / 480.0)
|
||||
# Do NOT pickle the image into the YOLO queue here — that stalls the
|
||||
# camera callback and looks like inference freeze. Infer thread submits.
|
||||
# 千万不要在这里把图像 pickle 进 YOLO 队列 —— 那会卡住相机回调,
|
||||
# 看起来就像是推理冻结。由推理线程负责提交。
|
||||
self._wake.set()
|
||||
|
||||
def _on_info(self, msg: CameraInfo) -> None:
|
||||
@@ -349,7 +349,7 @@ class PerceptionNode(Node):
|
||||
self._wake.set()
|
||||
|
||||
def _submit_latest(self) -> None:
|
||||
"""Copy the newest camera frame and hand it to the GPU process."""
|
||||
"""复制最新的相机帧,交给 GPU 进程。"""
|
||||
msg = self._latest_image
|
||||
seq = int(self._frame_seq)
|
||||
if msg is None or seq <= self._submitted_seq:
|
||||
@@ -361,38 +361,37 @@ class PerceptionNode(Node):
|
||||
self.detector.submit(small, seq)
|
||||
self._submitted_seq = seq
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f"yolo submit: {exc}", throttle_duration_sec=2.0)
|
||||
self.get_logger().warn(f"yolo 提交失败:{exc}", throttle_duration_sec=2.0)
|
||||
|
||||
def _restart_yolo(self, why: str) -> None:
|
||||
"""Self-heal a hung/dead GPU worker (engine reload takes a few sec)."""
|
||||
"""自愈:重启卡死 / 挂掉的 GPU 工作进程(重新加载 engine 需要几秒)。"""
|
||||
now = time.monotonic()
|
||||
if now - self._last_restart_t < 5.0:
|
||||
return
|
||||
if not hasattr(self.detector, "restart"):
|
||||
return
|
||||
self._last_restart_t = now
|
||||
self.get_logger().error(f"YOLO worker {why} — restarting (box coasts)")
|
||||
self.get_logger().error(f"YOLO 工作进程 {why} —— 正在重启(检测框靠惯性滑行)")
|
||||
try:
|
||||
self.detector.restart()
|
||||
self._submitted_seq = -1
|
||||
self._last_gpu_t = time.monotonic()
|
||||
self.get_logger().info("YOLO worker restarted")
|
||||
self.get_logger().info("YOLO 工作进程已重启")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f"YOLO restart failed: {exc}")
|
||||
self.get_logger().error(f"YOLO 重启失败:{exc}")
|
||||
|
||||
def _infer_loop(self) -> None:
|
||||
"""Submit latest frame, collect GPU result, then stereo.
|
||||
"""提交最新帧,收集 GPU 结果,再做立体匹配。
|
||||
|
||||
GPU must never wait on stereo: the worker drops old results if we are
|
||||
briefly busy. We also submit *before* waiting so the next infer is
|
||||
already queued.
|
||||
GPU 绝不能等立体匹配:如果我们短暂正忙,工作进程会直接丢掉旧结果。
|
||||
我们还会**先**提交再去等,这样下一次推理已经排进队列了。
|
||||
"""
|
||||
while not self._stop.is_set():
|
||||
self._submit_latest()
|
||||
try:
|
||||
got = self.detector.wait_result(timeout=0.030)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f"infer: {exc}", throttle_duration_sec=2.0)
|
||||
self.get_logger().error(f"推理:{exc}", throttle_duration_sec=2.0)
|
||||
if not getattr(self.detector, "alive", True):
|
||||
self._restart_yolo("died")
|
||||
time.sleep(0.02)
|
||||
@@ -402,13 +401,13 @@ class PerceptionNode(Node):
|
||||
silent = (now - self._last_gpu_t) if self._last_gpu_t else 0.0
|
||||
if self._last_gpu_t is not None and silent > 0.25:
|
||||
self.get_logger().warn(
|
||||
f"YOLO silent {silent*1000:.0f}ms "
|
||||
f"(worker={'up' if getattr(self.detector, 'alive', True) else 'DEAD'})",
|
||||
f"YOLO 静默 {silent*1000:.0f}ms "
|
||||
f"(worker={'存活' if getattr(self.detector, 'alive', True) else '已死'})",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
outstanding = self._submitted_seq > self._done_seq
|
||||
if outstanding and self._last_gpu_t is not None and silent > 2.0:
|
||||
self._restart_yolo(f"hung {silent:.1f}s")
|
||||
self._restart_yolo(f"卡死 {silent:.1f}s")
|
||||
continue
|
||||
if (
|
||||
self._last_yolo_t is not None
|
||||
@@ -429,7 +428,7 @@ class PerceptionNode(Node):
|
||||
try:
|
||||
self._apply_detections(dets, scale, t_img)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f"apply: {exc}", throttle_duration_sec=2.0)
|
||||
self.get_logger().error(f"应用检测结果出错:{exc}", throttle_duration_sec=2.0)
|
||||
self._done_seq = int(seq)
|
||||
self._submit_latest()
|
||||
dt_ms = (time.monotonic() - t0) * 1000.0
|
||||
@@ -457,13 +456,13 @@ class PerceptionNode(Node):
|
||||
def _depth_in_box(self, xyxy) -> tuple:
|
||||
msg = self._latest_depth
|
||||
if msg is None:
|
||||
return None, "no depth"
|
||||
return None, "无深度"
|
||||
if not self._depth_fresh(msg):
|
||||
return None, "stale depth"
|
||||
return None, "深度过期"
|
||||
enc = (msg.encoding or "").lower()
|
||||
buf = self._decode_depth_m(msg)
|
||||
if buf is None:
|
||||
return None, "bad depth"
|
||||
return None, "深度无效"
|
||||
h, w = buf.shape
|
||||
x1, y1, x2, y2 = [float(v) for v in xyxy]
|
||||
img = self._latest_image
|
||||
@@ -471,7 +470,7 @@ class PerceptionNode(Node):
|
||||
sx, sy = w / float(img.width), h / float(img.height)
|
||||
x1, y1, x2, y2 = x1 * sx, y1 * sy, x2 * sx, y2 * sy
|
||||
bw, bh = max(8.0, x2 - x1), max(8.0, y2 - y1)
|
||||
# Inner ROI — avoid box edges; still keep enough pixels on a holey workpiece
|
||||
# 内部 ROI —— 避开检测框边缘;但对有孔的工件仍要保留足够像素
|
||||
ix0 = int(np.clip(x1 + 0.20 * bw, 0, w - 1))
|
||||
iy0 = int(np.clip(y1 + 0.20 * bh, 0, h - 1))
|
||||
ix1 = int(np.clip(x2 - 0.20 * bw, ix0 + 2, w))
|
||||
@@ -491,7 +490,7 @@ class PerceptionNode(Node):
|
||||
scale = 1.0
|
||||
vals = meters[np.isfinite(meters) & (meters > self.min_depth) & (meters < self.max_depth)]
|
||||
if vals.size < 8:
|
||||
# Inner ROI is often holes; retry the full box.
|
||||
# 内部 ROI 经常落在孔上;改用整个检测框重试。
|
||||
y0 = int(np.clip(y1, 0, h - 1))
|
||||
x0 = int(np.clip(x1, 0, w - 1))
|
||||
y1b = int(np.clip(y2, y0 + 2, h))
|
||||
@@ -509,8 +508,8 @@ class PerceptionNode(Node):
|
||||
if vals.size < 8:
|
||||
med_m = float(np.median(meters)) if meters.size else 0.0
|
||||
return None, f"ok={vals.size} scale={scale} med={med_m:.3f}"
|
||||
# Near-surface: holes / table / far background must NOT pull Z out.
|
||||
# Take closest 40%, then keep a 6 cm band around the 10th percentile.
|
||||
# 只取近表面:孔 / 桌面 / 远处背景绝不能把 Z 拉偏。
|
||||
# 先取最近的 40%,再围绕第 10 百分位保留一个 6 cm 的带宽。
|
||||
vals = np.sort(vals)
|
||||
n_near = max(8, int(0.40 * vals.size))
|
||||
near = vals[:n_near]
|
||||
@@ -524,7 +523,7 @@ class PerceptionNode(Node):
|
||||
return z, f"ok={vals.size} near={band.size}"
|
||||
|
||||
def _adaptive_filter_z(self, z_raw: float, now: float) -> float:
|
||||
"""Item 4: adaptive LPF on depth — quiet when still, responsive when moving."""
|
||||
"""第 4 项:对深度做自适应低通滤波 —— 静止时更稳,运动时更灵敏。"""
|
||||
if self._z_filt is None or self._z_filt_t is None:
|
||||
self._z_filt = float(z_raw)
|
||||
self._z_filt_t = now
|
||||
@@ -563,7 +562,7 @@ class PerceptionNode(Node):
|
||||
)
|
||||
|
||||
def _publish_detection_clear(self) -> None:
|
||||
"""Empty detection → preview clears green box (must NOT reuse last xyxy)."""
|
||||
"""空检测 → 预览清掉绿框(绝不能复用上一次的 xyxy)。"""
|
||||
self._last_det_payload = None
|
||||
msg = Float32MultiArray()
|
||||
msg.data = []
|
||||
@@ -589,7 +588,7 @@ class PerceptionNode(Node):
|
||||
self._det_pub.publish(msg)
|
||||
|
||||
def _prepare_infer(self, bgr: np.ndarray) -> tuple:
|
||||
"""Optional downscale for YOLO; return (infer_bgr, scale) full = infer * scale."""
|
||||
"""可选的 YOLO 降采样;返回 (infer_bgr, scale),全分辨率 = 推理图 * scale。"""
|
||||
max_w = self.infer_max_width
|
||||
h, w = bgr.shape[:2]
|
||||
if max_w <= 0 or w <= max_w:
|
||||
@@ -612,7 +611,7 @@ class PerceptionNode(Node):
|
||||
return (int(msg.width), int(msg.height))
|
||||
|
||||
def _coast_box(self, note: str) -> bool:
|
||||
"""Keep the green box moving with last velocity; do not drive 3D."""
|
||||
"""让绿框按上一次的速度继续移动;不要用它驱动三维。"""
|
||||
now = time.monotonic()
|
||||
pred = self._box_trk.predict(now, self._img_wh())
|
||||
if pred is None and self._last_raw_xyxy is not None:
|
||||
@@ -626,7 +625,7 @@ class PerceptionNode(Node):
|
||||
self._publish_detection_box(
|
||||
pred, float(self._last_box_conf), float(self._last_box_cls), uv, z
|
||||
)
|
||||
self.get_logger().info(f"box coast {note}", throttle_duration_sec=0.5)
|
||||
self.get_logger().info(f"检测框惯滑 {note}", throttle_duration_sec=0.5)
|
||||
return True
|
||||
|
||||
def _clear_track(self) -> None:
|
||||
@@ -640,7 +639,7 @@ class PerceptionNode(Node):
|
||||
self._publish_detection_clear()
|
||||
|
||||
def _apply_detections(self, dets, scale: float, t_img: float) -> None:
|
||||
"""Match YOLO, publish a *now*-predicted box, then stereo on that box."""
|
||||
"""匹配 YOLO 结果,发布按当前时刻预测的检测框,再在该框上做立体匹配。"""
|
||||
if scale != 1.0:
|
||||
for d in dets:
|
||||
d.xyxy = d.xyxy * scale
|
||||
@@ -665,7 +664,7 @@ class PerceptionNode(Node):
|
||||
self._clear_track()
|
||||
self._last_yolo_t = None
|
||||
self.get_logger().info(
|
||||
"detection miss — cleared overlay",
|
||||
"检测漏检 —— 已清除叠加显示",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
else:
|
||||
@@ -691,7 +690,7 @@ class PerceptionNode(Node):
|
||||
self._last_box_conf = float(best.conf)
|
||||
self._last_box_cls = float(best.cls_id)
|
||||
self._box_trk.update(xyxy_raw, t_img)
|
||||
# Compensate infer delay: draw where the part is *now*, not where YOLO saw it
|
||||
# 补偿推理延迟:画工件**当前**所在的位置,而不是 YOLO 看到它时的位置
|
||||
xyxy = self._box_trk.predict(now, self._img_wh())
|
||||
if xyxy is None:
|
||||
xyxy = xyxy_raw
|
||||
@@ -705,8 +704,8 @@ class PerceptionNode(Node):
|
||||
xyxy, float(best.conf), float(best.cls_id), box_uv, z_preview
|
||||
)
|
||||
|
||||
# Hover target = stereo 3D on the workpiece inside the box (not a ray
|
||||
# through a hole / table, and not z_ref). Red dot = that 3D point.
|
||||
# 悬停目标 = 检测框内工件表面的立体三维点(不是穿过孔 / 桌面的射线,
|
||||
# 也不是 z_ref)。红点 = 该三维点。
|
||||
feat = None
|
||||
p_cam: Optional[np.ndarray] = None
|
||||
note = "box"
|
||||
@@ -727,13 +726,13 @@ class PerceptionNode(Node):
|
||||
z_ok = z_meas is not None
|
||||
z, note = self._metric_z(z_meas, note)
|
||||
if z is None:
|
||||
# Never had usable depth (target beyond D405 range / not in the work
|
||||
# area / camera mount or handeye off). Do NOT publish a z_ref point —
|
||||
# that would fake-lock the gripper over a phantom. Hold instead.
|
||||
# 从未拿到过可用深度(目标超出 D405 量程 / 不在工作区内 / 相机安装或
|
||||
# 手眼标定有问题)。不要发布 z_ref 点 —— 那会在幻影位置上假装锁定夹爪。
|
||||
# 此时应保持不动。
|
||||
self.get_logger().warn(
|
||||
f"target has NO usable depth ({note}) — NOT tracking. "
|
||||
f"Put the workpiece in view at {self.min_depth:.2f}-{self.work_z_max:.2f} m "
|
||||
"from the camera, or check camera mount / handeye calibration.",
|
||||
f"目标没有可用深度({note})—— 不跟踪。"
|
||||
f"请把工件放到相机视野内 {self.min_depth:.2f}-{self.work_z_max:.2f} m 处,"
|
||||
"或检查相机安装 / 手眼标定。",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
return
|
||||
@@ -754,7 +753,7 @@ class PerceptionNode(Node):
|
||||
if dpx > self.jump_px or (z_ok and dz > self.jump_z_m):
|
||||
self.kf.reset(p_cam, now)
|
||||
self.get_logger().info(
|
||||
f"reacquire conf={best.conf:.2f} dpx={dpx:.0f}",
|
||||
f"重新捕获 conf={best.conf:.2f} dpx={dpx:.0f}",
|
||||
throttle_duration_sec=0.5,
|
||||
)
|
||||
else:
|
||||
@@ -765,7 +764,7 @@ class PerceptionNode(Node):
|
||||
kf_p = self.kf.position
|
||||
blend = self.meas_blend
|
||||
filt = blend * p_cam + (1.0 - blend) * kf_p
|
||||
# Keep XY scale honest even if KF still has an old far-Z
|
||||
# 即使卡尔曼里还残留着旧的远距离 Z,也要保证 XY 的尺度真实
|
||||
z_use = float(np.clip(filt[2], self.min_depth, self.work_z_max))
|
||||
if abs(z_use - float(p_cam[2])) > 0.04:
|
||||
filt = p_cam.copy()
|
||||
@@ -775,14 +774,14 @@ class PerceptionNode(Node):
|
||||
|
||||
self._publish_point(filt, stamp)
|
||||
self.get_logger().info(
|
||||
f"track {self.feature_mode} conf={best.conf:.2f} uv=({uv[0]:.0f},{uv[1]:.0f}) "
|
||||
f"跟踪 {self.feature_mode} conf={best.conf:.2f} uv=({uv[0]:.0f},{uv[1]:.0f}) "
|
||||
f"z={filt[2]:.3f} z_ok={int(z_ok)} {note}",
|
||||
throttle_duration_sec=0.4,
|
||||
)
|
||||
|
||||
def _depth_fresh(self, msg: Image) -> bool:
|
||||
"""Reject stale depth — on a fast-moving part, old depth at the current
|
||||
box reads the background (table at ~2.4 m) and poisons the 3D point."""
|
||||
"""拒绝过期的深度 —— 对快速运动的工件,当前检测框处的旧深度会读到背景
|
||||
(约 2.4 m 处的桌面),从而污染三维点。"""
|
||||
try:
|
||||
t = rclpy.time.Time.from_msg(msg.header.stamp)
|
||||
age = (self.get_clock().now() - t).nanoseconds * 1e-9
|
||||
@@ -816,7 +815,7 @@ class PerceptionNode(Node):
|
||||
band_m=self.plane_band_m,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f"inner3d feature: {exc}", throttle_duration_sec=2.0)
|
||||
self.get_logger().warn(f"inner3d 特征计算失败:{exc}", throttle_duration_sec=2.0)
|
||||
return None
|
||||
|
||||
def _plane_center(self, xyxy):
|
||||
@@ -846,13 +845,13 @@ class PerceptionNode(Node):
|
||||
band_m=self.plane_band_m,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f"plane feature: {exc}", throttle_duration_sec=2.0)
|
||||
self.get_logger().warn(f"平面特征计算失败:{exc}", throttle_duration_sec=2.0)
|
||||
return None
|
||||
|
||||
def _metric_z(self, z_meas: Optional[float], note: str) -> tuple:
|
||||
"""Z used for pixel→metre. Never use hole/floor depth (inflates XY error).
|
||||
Returns (None, note) when this target NEVER had usable depth — publishing
|
||||
a z_ref point then would fake-lock the servo onto a phantom XY."""
|
||||
"""用于像素→米的 Z。绝不能用孔 / 地面的深度(会放大 XY 误差)。
|
||||
当这个目标**从未**有过可用深度时返回 (None, note) —— 那种情况下发布
|
||||
z_ref 点会让伺服假锁到一个幻影 XY 上。"""
|
||||
z_ref = max(0.12, float(self.z_ref))
|
||||
if z_meas is not None and self.min_depth <= z_meas <= self.work_z_max:
|
||||
self._last_good_z = float(z_meas)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PID with integral clamp, derivative-on-measurement, and integral separation."""
|
||||
"""PID 控制器,带积分限幅、微分作用于测量值(derivative-on-measurement)以及积分分离。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,10 +7,10 @@ from typing import Optional
|
||||
|
||||
|
||||
class PidController:
|
||||
"""``u = Kp e + Ki ∫e + Kd (−dmeas/dt)``, then saturate.
|
||||
"""``u = Kp e + Ki ∫e + Kd (−dmeas/dt)``,然后再做饱和限幅。
|
||||
|
||||
Integral separation: when ``|error| > i_sep_thresh``, integral is forced to 0
|
||||
and does not accumulate (P+D only for fast chase / less overshoot).
|
||||
积分分离:当 ``|error| > i_sep_thresh`` 时,积分被强制置 0 且不再累积
|
||||
(只用 P+D,以便快速追赶 / 减少超调)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -50,7 +50,7 @@ class PidController:
|
||||
dterm = -(x - self._meas_prev) / dt
|
||||
self._meas_prev = x
|
||||
|
||||
# Integral separation — large error: clear I, do not accumulate
|
||||
# 积分分离 —— 误差较大:清空 I,不再累积
|
||||
if self.i_sep_thresh > 0.0 and abs(err) > self.i_sep_thresh:
|
||||
self._i = 0.0
|
||||
else:
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stereo plane Z at the YOLO box center.
|
||||
"""在 YOLO 检测框中心处求立体平面的 Z。
|
||||
|
||||
The hover target is the box-center ray intersected with the fitted near
|
||||
plane. That stays on the detection, with metric Z (30° camera: wrong Z
|
||||
would shift base XY). The cloud-mean centroid is not used — holes / table
|
||||
inliers pull it off the green box.
|
||||
悬停目标是「检测框中心射线」与「拟合出的近平面」的交点。这样既能留在检测结果上,
|
||||
又带有真实的米制 Z(30° 相机下,Z 错了会把 base 系的 XY 也带偏)。不使用点云均值
|
||||
质心 —— 孔 / 桌面的内点会把它从绿框上拉走。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,9 +16,9 @@ import numpy as np
|
||||
|
||||
@dataclass
|
||||
class PlaneFeature:
|
||||
uv: np.ndarray # (2,) color-pixel coords of the 3D centroid
|
||||
z: float # camera-frame Z of the 3D centroid
|
||||
xyz: np.ndarray # (3,) stereo-cloud centroid in camera frame
|
||||
uv: np.ndarray # (2,) 三维质心对应的彩色像素坐标
|
||||
z: float # 三维质心在相机坐标系下的 Z
|
||||
xyz: np.ndarray # (3,) 相机坐标系下的立体点云质心
|
||||
n_inliers: int
|
||||
note: str
|
||||
|
||||
@@ -128,10 +127,10 @@ def workpiece_xyz_in_box(
|
||||
inner: float = 0.40,
|
||||
band_m: float = 0.06,
|
||||
) -> Optional[PlaneFeature]:
|
||||
"""Physical workpiece point: median 3D of the near surface in the inner box.
|
||||
"""工件上的实体点:内层检测框里近表面深度的三维中位数。
|
||||
|
||||
Not a monocular ray (holes punch through to the table) and not the
|
||||
whole-ROI cloud mean (table / one-sided depth pulls it off the part).
|
||||
不是单目射线(孔会穿透到桌面),也不是整个 ROI 的点云均值
|
||||
(桌面 / 单侧深度会把它从工件上拉走)。
|
||||
"""
|
||||
meters, _ = _depth_meters(depth_buf, encoding)
|
||||
if meters.ndim != 2:
|
||||
@@ -212,7 +211,7 @@ def geometric_center_on_plane(
|
||||
inlier_m: float = 0.008,
|
||||
band_m: float = 0.06,
|
||||
) -> Optional[PlaneFeature]:
|
||||
"""Box-center point on the fitted near plane, or None."""
|
||||
"""拟合出的近平面上、位于检测框中心的点,拿不到则返回 None。"""
|
||||
meters, _ = _depth_meters(depth_buf, encoding)
|
||||
if meters.ndim != 2:
|
||||
return None
|
||||
@@ -224,7 +223,7 @@ def geometric_center_on_plane(
|
||||
if dw != img_w or dh != img_h:
|
||||
sx, sy = dw / max(float(img_w), 1.0), dh / max(float(img_h), 1.0)
|
||||
x1, y1, x2, y2 = x1 * sx, y1 * sy, x2 * sx, y2 * sy
|
||||
# Keep uv in color pixels: work in depth pixels then scale back.
|
||||
# uv 保持用彩色像素坐标:先在深度像素下计算,再缩放回来。
|
||||
scale_u, scale_v = 1.0 / sx, 1.0 / sy
|
||||
else:
|
||||
scale_u = scale_v = 1.0
|
||||
@@ -246,7 +245,7 @@ def geometric_center_on_plane(
|
||||
|
||||
yy, xx = np.nonzero(near)
|
||||
zs = roi[yy, xx]
|
||||
# Color-equivalent pixel for back-projection (aligned depth uses color K).
|
||||
# 反投影用的彩色等效像素(对齐后的深度使用的是彩色内参 K)。
|
||||
us = (xx + x0) * scale_u
|
||||
vs = (yy + y0) * scale_v
|
||||
pts = np.stack(
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish RealSense color stream to ROS topics (no realsense2_camera apt pkg).
|
||||
"""把 RealSense 彩色流发布到 ROS 话题(不依赖 realsense2_camera 的 apt 包)。
|
||||
|
||||
Use when ``ros-humble-realsense2-camera`` is not installed but ``pyrealsense2`` works.
|
||||
当你没装 ``ros-humble-realsense2-camera``、但 ``pyrealsense2`` 能正常工作时用它。
|
||||
|
||||
Default topics (camera_name=d405):
|
||||
默认话题(camera_name=d405):
|
||||
/d405/color/image_raw
|
||||
/d405/color/camera_info
|
||||
/d405/aligned_depth_to_color/image_raw (16UC1 mm, if enable_depth)
|
||||
/d405/aligned_depth_to_color/image_raw (16UC1 mm,需开启 enable_depth)
|
||||
|
||||
Performance notes
|
||||
-----------------
|
||||
* Publishes from the capture thread (no timer lag).
|
||||
* Uses SensorDataQoS (BEST_EFFORT, depth=1) so slow subscribers drop old frames.
|
||||
* Optional SDK preview: ``-p show_preview:=true`` (Intel sample path: pipeline → numpy → window).
|
||||
性能说明
|
||||
--------
|
||||
* 在采集线程里直接发布(没有定时器带来的延迟)。
|
||||
* 使用 SensorDataQoS(BEST_EFFORT,depth=1),这样慢的订阅者会主动丢旧帧。
|
||||
* 可选的 SDK 预览:``-p show_preview:=true``(沿用 Intel 示例的链路:pipeline → numpy → 窗口)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -34,7 +34,7 @@ from std_msgs.msg import Float32MultiArray
|
||||
|
||||
|
||||
def _param_as_str(node: Node, name: str, default: str = '') -> str:
|
||||
"""String param that also accepts numeric CLI overrides (e.g. serial numbers)."""
|
||||
"""字符串参数,同时兼容命令行传数字的情况(例如序列号)。"""
|
||||
node.declare_parameter(
|
||||
name, default, ParameterDescriptor(dynamic_typing=True)
|
||||
)
|
||||
@@ -82,7 +82,7 @@ def _site_yaml_path() -> Path:
|
||||
class RealsenseColorPub(Node):
|
||||
def __init__(self) -> None:
|
||||
super().__init__('realsense_color_pub')
|
||||
self.declare_parameter('camera_name', 'd405') # topic namespace
|
||||
self.declare_parameter('camera_name', 'd405') # 话题命名空间
|
||||
self._name = str(self.get_parameter('camera_name').value).strip().strip('/')
|
||||
self._serial = _param_as_str(self, 'camera_serial', '')
|
||||
self.declare_parameter('width', 1280)
|
||||
@@ -94,8 +94,8 @@ class RealsenseColorPub(Node):
|
||||
self.declare_parameter('enable_depth', True)
|
||||
self.declare_parameter('enable_infra', True)
|
||||
self.declare_parameter('preview_stereo', True)
|
||||
# Render the Qt preview only every Nth frame — publishing stays at full
|
||||
# rate; the CPU colorizer + 4-pane paint must not slow the stream.
|
||||
# Qt 预览每 N 帧才渲染一次 —— 发布始终保持满帧率;
|
||||
# CPU 上色 + 四窗格绘制绝不能拖慢数据流。
|
||||
self.declare_parameter('preview_decimation', 2)
|
||||
self.declare_parameter('auto_exposure', True)
|
||||
self.declare_parameter('ae_limit_us', 3000.0)
|
||||
@@ -122,7 +122,7 @@ class RealsenseColorPub(Node):
|
||||
self._laser_power = float(self.get_parameter('laser_power').value)
|
||||
self._exposure_us = float(self.get_parameter('exposure_us').value)
|
||||
self._gain = float(self.get_parameter('gain').value)
|
||||
# FPS meter: hardware frame numbers vs actually published (loop rate)
|
||||
# FPS 表:硬件帧号对比实际发布帧数(循环速率)
|
||||
self._fps_t0 = 0.0
|
||||
self._fps_pub_color = 0
|
||||
self._fps_pub_depth = 0
|
||||
@@ -132,7 +132,7 @@ class RealsenseColorPub(Node):
|
||||
self._t_align = 0.0
|
||||
self._t_pub = 0.0
|
||||
|
||||
qos = qos_profile_sensor_data # BEST_EFFORT, keep last 1
|
||||
qos = qos_profile_sensor_data # BEST_EFFORT,只保留最后 1 帧
|
||||
base = f'/{self._name}/color'
|
||||
self._img_pub = self.create_publisher(
|
||||
Image, f'{base}/image_raw', qos
|
||||
@@ -143,8 +143,8 @@ class RealsenseColorPub(Node):
|
||||
self._depth_pub = self.create_publisher(
|
||||
Image, f'/{self._name}/aligned_depth_to_color/image_raw', qos
|
||||
)
|
||||
# IR streams are published too so the preview can live in a separate
|
||||
# viewer process (Qt must not share the GIL with the capture loop).
|
||||
# IR 流也会一起发布,这样预览可以放在独立的查看器进程里
|
||||
# (Qt 不能和采集循环共享 GIL)。
|
||||
self._ir1_pub = None
|
||||
self._ir2_pub = None
|
||||
if self._enable_infra_param and self._preview_stereo:
|
||||
@@ -163,8 +163,8 @@ class RealsenseColorPub(Node):
|
||||
self._infra_mode = 'none'
|
||||
self._colorizer = None
|
||||
self._logged_depth = False
|
||||
# Device depth scale → multiply raw uint16 by this to get true mm.
|
||||
# D405 = 0.1 mm/unit, D435 = 1.0 mm/unit. Read from the sensor at startup.
|
||||
# 设备深度尺度 → 用原始 uint16 乘上它才是真实的 mm。
|
||||
# D405 = 0.1 mm/单位,D435 = 1.0 mm/单位。启动时从传感器读取。
|
||||
self._depth_to_mm = 1.0
|
||||
self._running = True
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
@@ -205,13 +205,13 @@ class RealsenseColorPub(Node):
|
||||
det_qos,
|
||||
)
|
||||
self.get_logger().info(
|
||||
'SDK preview ON — COLOR|DEPTH|IR-L|IR-R in this process '
|
||||
f'(hold {self._overlay_hold_sec:.1f}s). Close window ≠ stop camera.'
|
||||
'SDK 预览已开启 —— 本进程内显示 COLOR|DEPTH|IR-L|IR-R '
|
||||
f'(保留 {self._overlay_hold_sec:.1f}s)。关窗口 ≠ 停相机。'
|
||||
)
|
||||
self._start_pipeline()
|
||||
|
||||
def _pick_device(self, devices, rs):
|
||||
"""Prefer configured serial, then D405 by name (this PC also has a D435)."""
|
||||
"""优先用配置的序列号,其次按名字选 D405(这台机器上还接了 D435)。"""
|
||||
hint = (self._name or 'd405').lower()
|
||||
if self._serial:
|
||||
for d in devices:
|
||||
@@ -222,7 +222,7 @@ class RealsenseColorPub(Node):
|
||||
for d in devices
|
||||
)
|
||||
raise RuntimeError(
|
||||
f'Serial {self._serial!r} not found. Connected: {avail}'
|
||||
f'找不到序列号 {self._serial!r}。当前已连接:{avail}'
|
||||
)
|
||||
for d in devices:
|
||||
name = str(d.get_info(rs.camera_info.name) or '').lower()
|
||||
@@ -231,7 +231,7 @@ class RealsenseColorPub(Node):
|
||||
return devices[0]
|
||||
|
||||
def _reload_site_preview(self, stream_w: int = 0, stream_h: int = 0) -> None:
|
||||
"""Pick up overlay_hold_sec (and warn if width/height changed) without restart."""
|
||||
"""不重启就能读到新的 overlay_hold_sec(宽高变了则给出警告)。"""
|
||||
path = self._site_yaml
|
||||
if not path.is_file():
|
||||
return
|
||||
@@ -248,7 +248,7 @@ class RealsenseColorPub(Node):
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
cam = data.get("camera") or {}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f"site.yaml reload failed: {exc}")
|
||||
self.get_logger().warn(f"site.yaml 重新加载失败:{exc}")
|
||||
return
|
||||
if "overlay_hold_sec" in cam:
|
||||
hold = max(0.0, float(cam.get("overlay_hold_sec") or 0.0))
|
||||
@@ -264,14 +264,14 @@ class RealsenseColorPub(Node):
|
||||
if (want_w, want_h) != (live_w, live_h) and not self._site_wh_warned:
|
||||
self._site_wh_warned = True
|
||||
self.get_logger().warn(
|
||||
f"preview stream is {live_w}x{live_h}, site.yaml camera is "
|
||||
f"{want_w}x{want_h} — restart: bash ~/ros2/scripts/run_camera_d405.sh --preview"
|
||||
f"预览流是 {live_w}x{live_h},而 site.yaml 里的 camera 是 "
|
||||
f"{want_w}x{want_h} —— 请重启:bash ~/ros2/scripts/run_camera_d405.sh --preview"
|
||||
)
|
||||
elif (want_w, want_h) == (live_w, live_h):
|
||||
self._site_wh_warned = False
|
||||
|
||||
def _on_det(self, msg: Float32MultiArray) -> None:
|
||||
# Empty payload = perception miss → clear green box immediately
|
||||
# 空负载 = 感知漏检 → 立刻清掉绿框
|
||||
if not msg.data or len(msg.data) < 8:
|
||||
self._overlay = None
|
||||
self._overlay_stamp = 0.0
|
||||
@@ -295,8 +295,8 @@ class RealsenseColorPub(Node):
|
||||
img.encoding = 'bgr8'
|
||||
img.is_bigendian = 0
|
||||
img.step = w * 3
|
||||
# array.array('B', ...) hits the setter's fast path — plain bytes gets
|
||||
# an element-wise debug check that costs ~40 ms per frame here.
|
||||
# array.array('B', ...) 会走 setter 的快路径 —— 直接传 bytes 会触发
|
||||
# 逐元素的 debug 检查,在这里每帧要多花约 40 ms。
|
||||
img.data = array.array('B', np.ascontiguousarray(bgr).tobytes())
|
||||
self._img_pub.publish(img)
|
||||
|
||||
@@ -324,7 +324,7 @@ class RealsenseColorPub(Node):
|
||||
if not self._logged_depth:
|
||||
self._logged_depth = True
|
||||
self.get_logger().info(
|
||||
f'first aligned depth {dw}x{dh} → '
|
||||
f'首个对齐深度 {dw}x{dh} → '
|
||||
f'/{self._name}/aligned_depth_to_color/image_raw'
|
||||
)
|
||||
|
||||
@@ -333,10 +333,10 @@ class RealsenseColorPub(Node):
|
||||
import pyrealsense2 as rs
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(
|
||||
'pyrealsense2 not available. conda activate ros_flexiv'
|
||||
'pyrealsense2 不可用。请执行 conda activate ros_flexiv'
|
||||
) from exc
|
||||
|
||||
# After killing a previous publisher, USB may take 1–3s to re-enumerate.
|
||||
# 杀掉上一个发布进程后,USB 需要 1–3s 才能重新枚举。
|
||||
ctx = rs.context()
|
||||
devices = []
|
||||
for attempt in range(8):
|
||||
@@ -347,9 +347,9 @@ class RealsenseColorPub(Node):
|
||||
ctx = rs.context()
|
||||
if not devices:
|
||||
raise RuntimeError(
|
||||
'No RealSense connected (USB empty after retries). '
|
||||
'Plug D405 into THIS machine on a USB3 (blue) port, unplug/replug, '
|
||||
'and close realsense-viewer / the 192.168.0.108 capture site if it holds the camera.'
|
||||
'没有连接 RealSense(多次重试后 USB 仍为空)。'
|
||||
'请把 D405 插到**本机**的 USB3(蓝色)口,拔插一次,'
|
||||
'并关闭 realsense-viewer / 192.168.0.108 上占用该相机的采集端。'
|
||||
)
|
||||
|
||||
found = self._pick_device(devices, rs)
|
||||
@@ -375,8 +375,8 @@ class RealsenseColorPub(Node):
|
||||
)
|
||||
if usb2:
|
||||
self.get_logger().warn(
|
||||
f'{cam_name} is on USB {usb_desc} — keeping depth/IR at '
|
||||
'640x480@15. Plug D405 into a USB3 (blue) port for 848@30.'
|
||||
f'{cam_name} 接在 USB {usb_desc} 上 —— 深度/IR 将保持 '
|
||||
'640x480@15。把 D405 插到 USB3(蓝色)口可获得 848@30。'
|
||||
)
|
||||
self._width, self._height, self._fps = 640, 480, 15
|
||||
|
||||
@@ -399,7 +399,7 @@ class RealsenseColorPub(Node):
|
||||
]
|
||||
if usb2:
|
||||
candidates = [(640, 480, 15), (640, 480, 5), (424, 240, 15)]
|
||||
# Drop firmware junk (848@90 etc.) that ioctl-fails and wedges USB.
|
||||
# 丢弃固件里的垃圾模式(848@90 之类),它们会 ioctl 失败并卡死 USB。
|
||||
seen = set()
|
||||
trimmed = []
|
||||
for item in candidates:
|
||||
@@ -425,8 +425,8 @@ class RealsenseColorPub(Node):
|
||||
have_depth = False
|
||||
have_infra = False
|
||||
|
||||
# USB3: try real IR-L+IR-R first (same stereo AE). Color+IR-L often
|
||||
# fails on D405 — then IR-R + color-as-left (different look, not AE).
|
||||
# USB3:先尝试真正的 IR-L+IR-R(共享立体自动曝光)。彩色+IR-L 在 D405 上
|
||||
# 经常失败 —— 那就退回 IR-R + 把彩色当左目(观感不同,且不共享自动曝光)。
|
||||
def _modes():
|
||||
if want_depth and want_infra and not usb2:
|
||||
yield True, 'both'
|
||||
@@ -461,25 +461,25 @@ class RealsenseColorPub(Node):
|
||||
self._infra_mode = infra_mode
|
||||
if (w, h, fps) != want:
|
||||
self.get_logger().warn(
|
||||
f'Requested {want[0]}x{want[1]}@{want[2]}Hz '
|
||||
f'→ using {w}x{h}@{fps}Hz'
|
||||
f'请求 {want[0]}x{want[1]}@{want[2]}Hz '
|
||||
f'→ 改用 {w}x{h}@{fps}Hz'
|
||||
)
|
||||
if want_depth and not with_depth:
|
||||
self.get_logger().warn(
|
||||
f'{w}x{h}@{fps}Hz color+depth failed — color only'
|
||||
f'{w}x{h}@{fps}Hz 彩色+深度失败 —— 只用彩色'
|
||||
)
|
||||
elif want_infra and infra_mode == 'none':
|
||||
self.get_logger().warn(
|
||||
f'{w}x{h}@{fps}Hz stereo IR failed — color+depth preview'
|
||||
f'{w}x{h}@{fps}Hz 立体 IR 失败 —— 改用彩色+深度预览'
|
||||
)
|
||||
elif infra_mode == 'both':
|
||||
self.get_logger().info(
|
||||
f'{w}x{h}@{fps}Hz stereo IR-L+IR-R (shared AE) + depth'
|
||||
f'{w}x{h}@{fps}Hz 立体 IR-L+IR-R(共享自动曝光)+ 深度'
|
||||
)
|
||||
elif infra_mode == 'right':
|
||||
self.get_logger().info(
|
||||
f'{w}x{h}@{fps}Hz IR-L=color gray, IR-R=raw IR '
|
||||
'(looks brighter/darker — not two AE loops)'
|
||||
f'{w}x{h}@{fps}Hz IR-L=彩色灰度,IR-R=原始 IR '
|
||||
'(明暗不同 —— 这不是两路自动曝光)'
|
||||
)
|
||||
self._width, self._height, self._fps = w, h, fps
|
||||
self._last_ok = (w, h, fps)
|
||||
@@ -487,7 +487,7 @@ class RealsenseColorPub(Node):
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_err = exc
|
||||
kind = f'color+depth+IR-{infra_mode}' if with_depth else 'color'
|
||||
self.get_logger().warn(f'{w}x{h}@{fps}Hz {kind} failed: {exc}')
|
||||
self.get_logger().warn(f'{w}x{h}@{fps}Hz {kind} 失败:{exc}')
|
||||
profile = None
|
||||
have_depth = False
|
||||
have_infra = False
|
||||
@@ -501,7 +501,7 @@ class RealsenseColorPub(Node):
|
||||
break
|
||||
if profile is None:
|
||||
raise RuntimeError(
|
||||
f'No supported color profile for D405. Last error: {last_err}'
|
||||
f'D405 没有可用的彩色配置。最后的错误:{last_err}'
|
||||
)
|
||||
|
||||
self._pipe = pipe
|
||||
@@ -509,18 +509,18 @@ class RealsenseColorPub(Node):
|
||||
self._have_depth = have_depth
|
||||
self._have_infra = have_infra
|
||||
self._logged_depth = False
|
||||
# Publish depth in TRUE millimetres. D405 raw units are 0.1 mm (D435 1 mm);
|
||||
# downstream (perception/servo) assumes mm, so normalise by the device scale.
|
||||
# 深度按**真实毫米**发布。D405 的原始单位是 0.1 mm(D435 是 1 mm);
|
||||
# 下游(感知/伺服)都按 mm 处理,所以要按设备尺度归一化。
|
||||
self._depth_to_mm = 1.0
|
||||
if have_depth:
|
||||
try:
|
||||
ds = float(profile.get_device().first_depth_sensor().get_depth_scale())
|
||||
self._depth_to_mm = ds * 1000.0 # m/unit → mm/unit
|
||||
self._depth_to_mm = ds * 1000.0 # m/单位 → mm/单位
|
||||
self.get_logger().info(
|
||||
f'depth scale {ds * 1000.0:.4f} mm/unit → publishing true mm'
|
||||
f'深度尺度 {ds * 1000.0:.4f} mm/单位 → 按真实 mm 发布'
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f'depth scale read failed, assume mm: {exc}')
|
||||
self.get_logger().warn(f'读取深度尺度失败,按 mm 处理:{exc}')
|
||||
self._depth_to_mm = 1.0
|
||||
self._align = rs.align(rs.stream.color) if have_depth else None
|
||||
self._colorizer = None
|
||||
@@ -546,11 +546,11 @@ class RealsenseColorPub(Node):
|
||||
log=self.get_logger().info,
|
||||
)
|
||||
apply_realsense_exposure(profile.get_device(), **kwargs)
|
||||
# Firmware often resets AE on the first frames; stamp it again.
|
||||
# 固件经常会在最初几帧重置自动曝光;所以再设置一次。
|
||||
_time.sleep(0.25)
|
||||
apply_realsense_exposure(profile.get_device(), **kwargs)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f'exposure apply: {exc}')
|
||||
self.get_logger().warn(f'应用曝光参数失败:{exc}')
|
||||
if self._preview is not None:
|
||||
if self._infra_mode == 'both':
|
||||
self._preview.set_pane_titles(
|
||||
@@ -575,8 +575,8 @@ class RealsenseColorPub(Node):
|
||||
dist = list(intr.coeffs[:5]) if intr.coeffs else [0.0] * 5
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(
|
||||
f'get_intrinsics failed ({exc}); using D405 defaults for '
|
||||
f'{self._width}x{self._height}'
|
||||
f'get_intrinsics 失败({exc});改用 D405 的默认内参,'
|
||||
f'分辨率 {self._width}x{self._height}'
|
||||
)
|
||||
w, h = float(self._width), float(self._height)
|
||||
if w >= 1200:
|
||||
@@ -595,10 +595,10 @@ class RealsenseColorPub(Node):
|
||||
depth_txt = (
|
||||
f' + aligned depth /{self._name}/aligned_depth_to_color/image_raw'
|
||||
if have_depth
|
||||
else ' (color only)'
|
||||
else ' (仅彩色)'
|
||||
)
|
||||
if have_infra:
|
||||
depth_txt += ' + IR-L/IR-R preview'
|
||||
depth_txt += ' + IR-L/IR-R 预览'
|
||||
self.get_logger().info(
|
||||
f'{model} serial={sn} USB={usb_desc} → /{self._name}/color/image_raw '
|
||||
f'{self._width}x{self._height}@{self._fps}Hz '
|
||||
@@ -611,30 +611,30 @@ class RealsenseColorPub(Node):
|
||||
self._thread.start()
|
||||
|
||||
def _enable_color_ae_fast(self, profile, rs) -> None:
|
||||
"""Color auto-exposure that prefers short exposure (less motion blur)."""
|
||||
"""彩色自动曝光,优先用短曝光(减少运动模糊)。"""
|
||||
try:
|
||||
sensor = profile.get_device().first_color_sensor()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f'color AE: no color sensor ({exc})')
|
||||
self.get_logger().warn(f'彩色 AE:没有彩色传感器({exc})')
|
||||
return
|
||||
try:
|
||||
if sensor.supports(rs.option.enable_auto_exposure):
|
||||
sensor.set_option(rs.option.enable_auto_exposure, 1.0)
|
||||
if sensor.supports(rs.option.auto_exposure_priority):
|
||||
# 0 = keep FPS, shorten exposure when the scene is bright enough
|
||||
# 0 = 保住帧率,场景够亮时缩短曝光时间
|
||||
sensor.set_option(rs.option.auto_exposure_priority, 0.0)
|
||||
limit = getattr(rs.option, "auto_exposure_limit", None)
|
||||
if limit is not None and sensor.supports(limit):
|
||||
try:
|
||||
sensor.set_option(limit, 5000.0) # µs; less motion blur at 30 Hz
|
||||
sensor.set_option(limit, 5000.0) # µs;30 Hz 下减少运动模糊
|
||||
except Exception:
|
||||
pass
|
||||
self.get_logger().info('color auto-exposure ON (prefer FPS / less blur)')
|
||||
self.get_logger().info('彩色自动曝光已开启(优先帧率 / 减少模糊)')
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f'color AE: {exc}')
|
||||
self.get_logger().warn(f'彩色 AE:{exc}')
|
||||
|
||||
def _enable_stereo_ae(self, profile, rs) -> None:
|
||||
"""One auto-exposure for both IR imagers (stereo module)."""
|
||||
"""两路 IR 成像器共用一路自动曝光(立体模块)。"""
|
||||
try:
|
||||
sensor = profile.get_device().first_depth_sensor()
|
||||
if sensor.supports(rs.option.enable_auto_exposure):
|
||||
@@ -642,15 +642,15 @@ class RealsenseColorPub(Node):
|
||||
limit = getattr(rs.option, "auto_exposure_limit", None)
|
||||
if limit is not None and sensor.supports(limit):
|
||||
try:
|
||||
# Cap IR exposure well under the 33 ms frame period so a
|
||||
# dim scene cannot silently drop the stream below 30 Hz.
|
||||
# 把 IR 曝光上限压到远低于 33 ms 的帧周期,这样光线暗的场景
|
||||
# 也不会悄悄把数据流掉到 30 Hz 以下。
|
||||
sensor.set_option(limit, 20000.0)
|
||||
self.get_logger().info('stereo IR exposure limit 20ms')
|
||||
self.get_logger().info('立体 IR 曝光上限 20ms')
|
||||
except Exception:
|
||||
pass
|
||||
self.get_logger().info('stereo IR auto-exposure ON (shared L/R)')
|
||||
self.get_logger().info('立体 IR 自动曝光已开启(左右共享)')
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f'stereo AE: {exc}')
|
||||
self.get_logger().warn(f'立体 AE:{exc}')
|
||||
|
||||
def _colorize_depth(self, depth_frame, depth_u16: np.ndarray) -> np.ndarray:
|
||||
if self._colorizer is not None:
|
||||
@@ -695,7 +695,7 @@ class RealsenseColorPub(Node):
|
||||
return self._pipe is not None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(
|
||||
f'reconnect failed: {exc}. Unplug/replug USB3, slack the cable.',
|
||||
f'重连失败:{exc}。请拔插 USB3,并给线缆留点余量。',
|
||||
throttle_duration_sec=5.0,
|
||||
)
|
||||
return False
|
||||
@@ -738,15 +738,15 @@ class RealsenseColorPub(Node):
|
||||
if dropped or misses >= 4:
|
||||
usb = getattr(self, '_usb_desc', '?')
|
||||
self.get_logger().error(
|
||||
f'Camera stream lost (USB={usb}): {text[:120]}. '
|
||||
'Reconnecting — slack the USB cable so the arm does not yank it.'
|
||||
f'相机数据流中断(USB={usb}):{text[:120]}。'
|
||||
'正在重连 —— 请给 USB 线缆留点余量,别让机械臂把它拽掉。'
|
||||
)
|
||||
got = False
|
||||
misses = 0
|
||||
self._reconnect()
|
||||
continue
|
||||
if misses == 1:
|
||||
self.get_logger().warn(f'wait_for_frames: {exc}')
|
||||
self.get_logger().warn(f'wait_for_frames:{exc}')
|
||||
continue
|
||||
if not color:
|
||||
misses += 1
|
||||
@@ -756,14 +756,14 @@ class RealsenseColorPub(Node):
|
||||
depth = frames.get_depth_frame()
|
||||
depth_u16 = np.asanyarray(depth.get_data()) if depth else None
|
||||
if depth_u16 is not None and self._depth_to_mm != 1.0:
|
||||
# raw device units → true mm (D405 raw is 0.1 mm/unit)
|
||||
# 原始设备单位 → 真实 mm(D405 原始为 0.1 mm/单位)
|
||||
depth_u16 = np.clip(
|
||||
depth_u16.astype(np.float32) * self._depth_to_mm, 0, 65535
|
||||
).astype(np.uint16)
|
||||
self._frame_i += 1
|
||||
self._fps_meter(color, depth, depth_u16)
|
||||
# Preview work (CPU colorizer, IR copies, 4-pane paint) only every
|
||||
# Nth frame — it must not slow down publishing.
|
||||
# 预览相关的工作(CPU 上色、IR 拷贝、四窗格绘制)每 N 帧才做一次
|
||||
# —— 它绝不能拖慢发布。
|
||||
do_push = (
|
||||
self._preview is not None
|
||||
and (self._frame_i % self._preview_every) == 0
|
||||
@@ -776,13 +776,13 @@ class RealsenseColorPub(Node):
|
||||
depth_bgr = self._colorize_depth(depth, depth_u16)
|
||||
ir_l_img = np.asanyarray(ir_l.get_data()).copy() if ir_l else None
|
||||
ir_r_img = np.asanyarray(ir_r.get_data()).copy() if ir_r else None
|
||||
# Only substitute color-gray when the raw left IR stream is unavailable.
|
||||
# 只有在拿不到原始左目 IR 流时,才用彩色灰度来顶替。
|
||||
if ir_l_img is None and self._infra_mode != 'both':
|
||||
ir_l_img = np.mean(bgr, axis=2).astype(np.uint8)
|
||||
if not got:
|
||||
got = True
|
||||
sw, sh = int(bgr.shape[1]), int(bgr.shape[0])
|
||||
self.get_logger().info(f'first frame {sw}x{sh}')
|
||||
self.get_logger().info(f'首帧 {sw}x{sh}')
|
||||
if self._preview is not None:
|
||||
self._preview.set_pane_titles(
|
||||
f"COLOR {sw}x{sh}",
|
||||
@@ -809,7 +809,7 @@ class RealsenseColorPub(Node):
|
||||
self._overlay is None or age > hold
|
||||
):
|
||||
self._preview.set_status(
|
||||
'NO box — check perception_node / conf'
|
||||
'没有框 —— 检查 perception_node / conf'
|
||||
)
|
||||
else:
|
||||
self._preview.set_status('')
|
||||
@@ -817,15 +817,15 @@ class RealsenseColorPub(Node):
|
||||
bgr.copy(), depth_bgr, ir_l_img, ir_r_img
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Context may be shutting down
|
||||
# 上下文可能正在关闭
|
||||
if not self._running:
|
||||
break
|
||||
self.get_logger().warn(
|
||||
f'publish failed: {exc}', throttle_duration_sec=2.0
|
||||
f'发布失败:{exc}', throttle_duration_sec=2.0
|
||||
)
|
||||
|
||||
def _publish_ir(self, frame, pub) -> None:
|
||||
"""Raw IR imager as mono8 for the external viewer process."""
|
||||
"""把原始 IR 成像器以 mono8 发布,供外部查看器进程使用。"""
|
||||
arr = np.asanyarray(frame.get_data())
|
||||
h, w = int(arr.shape[0]), int(arr.shape[1])
|
||||
msg = Image()
|
||||
@@ -840,8 +840,8 @@ class RealsenseColorPub(Node):
|
||||
pub.publish(msg)
|
||||
|
||||
def _fps_meter(self, color, depth, depth_u16) -> None:
|
||||
"""Log published vs hardware frame rate — separates loop slowness
|
||||
(preview/CPU) from SDK-side drops (AE long exposure / USB)."""
|
||||
"""记录发布帧率与硬件帧率 —— 用来区分循环太慢
|
||||
(预览/CPU)和 SDK 侧掉帧(自动曝光长曝光 / USB)。"""
|
||||
now = time.monotonic()
|
||||
try:
|
||||
fn_c = int(color.get_frame_number())
|
||||
@@ -886,13 +886,13 @@ def main(args=None) -> None:
|
||||
preview = getattr(node, '_preview', None)
|
||||
if preview is not None:
|
||||
app = node._qt_app
|
||||
# Closing the window must NOT stop the camera — track needs the stream.
|
||||
# 关闭窗口绝不能停掉相机 —— 跟踪需要这条数据流。
|
||||
while rclpy.ok() and node._running:
|
||||
rclpy.spin_once(node, timeout_sec=0.01)
|
||||
app.processEvents()
|
||||
if node._preview is not None and not node._preview.isVisible():
|
||||
node.get_logger().warn(
|
||||
'Preview closed — camera keeps publishing'
|
||||
'预览已关闭 —— 相机继续发布'
|
||||
)
|
||||
node._preview = None
|
||||
else:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RealSense SDK preview: fixed 2x2 Qt panes (color / depth / IR-L / IR-R).
|
||||
"""RealSense SDK 预览:固定的 2x2 Qt 窗格(彩色 / 深度 / IR-L / IR-R)。
|
||||
|
||||
Stitching four streams into one pixmap looked like a single camera view when
|
||||
depth/IR were missing. Each stream now has its own labeled pane.
|
||||
把四路画面拼成一张 pixmap 时,一旦缺了深度 / IR 看起来就像单相机视图。
|
||||
现在每路画面都有自己带标题的独立窗格。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -221,15 +221,15 @@ class SdkPreviewWindow(QMainWindow):
|
||||
return
|
||||
color = self._draw_overlay(color, ov, status)
|
||||
labs = self._img_labels
|
||||
self._set_pane(labs[0], color, "NO COLOR")
|
||||
self._set_pane(labs[1], depth, "NO DEPTH")
|
||||
self._set_pane(labs[2], ir_l, "NO IR-L")
|
||||
self._set_pane(labs[3], ir_r, "NO IR-R")
|
||||
self._set_pane(labs[0], color, "无彩色")
|
||||
self._set_pane(labs[1], depth, "无深度")
|
||||
self._set_pane(labs[2], ir_l, "无 IR-L")
|
||||
self._set_pane(labs[3], ir_r, "无 IR-R")
|
||||
|
||||
|
||||
def ensure_app() -> "QApplication":
|
||||
if QApplication is None:
|
||||
raise RuntimeError("PySide6 not available for RealSense preview window")
|
||||
raise RuntimeError("RealSense 预览窗口缺少 PySide6,无法创建")
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
app = QApplication([])
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""realsense_view — D405 4-pane preview (COLOR|DEPTH|IR-L|IR-R), own process.
|
||||
"""realsense_view —— D405 四窗格预览(COLOR|DEPTH|IR-L|IR-R),独立进程运行。
|
||||
|
||||
The camera node (``realsense_color_pub``) only publishes topics. All Qt work
|
||||
runs here so painting can never starve the camera capture loop (Python GIL
|
||||
made ``publish`` wall-time jump from 1 ms to ~70 ms when they shared a
|
||||
process). Closing the window only hides the preview; Ctrl+C stops the viewer
|
||||
(the camera keeps publishing).
|
||||
相机节点(``realsense_color_pub``)只负责发布话题。所有 Qt 工作都放在这里,
|
||||
这样绘制永远不会拖垮相机采集循环(两者同进程时,Python 的 GIL 会让 ``publish``
|
||||
的墙钟耗时从 1 ms 跳到约 70 ms)。关闭窗口只是隐藏预览;Ctrl+C 才停止查看器
|
||||
(相机仍在持续发布)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,7 +30,7 @@ from arm_application.realsense_color_pub import _site_yaml_path
|
||||
|
||||
|
||||
def _colorize_depth_u16(d: np.ndarray) -> np.ndarray:
|
||||
"""Fast numpy JET colorize for the preview pane (no SDK colorizer)."""
|
||||
"""为预览窗格做快速 numpy JET 上色(不使用 SDK 的 colorizer)。"""
|
||||
vis = np.zeros((d.shape[0], d.shape[1], 3), dtype=np.uint8)
|
||||
valid = d > 0
|
||||
if not valid.any():
|
||||
@@ -79,7 +78,7 @@ class RealsenseView(Node):
|
||||
self._stage2_stamp = 0.0
|
||||
self._site_yaml = _site_yaml_path()
|
||||
self._site_mtime = 0.0
|
||||
# RX rate meter — visible stutter diagnosis (transport vs paint)
|
||||
# RX 速率表 —— 用来直观诊断卡顿(是传输问题还是绘制问题)
|
||||
self._rx_color = 0
|
||||
self._rx_depth = 0
|
||||
self._rx_ir = 0
|
||||
@@ -115,7 +114,7 @@ class RealsenseView(Node):
|
||||
self._qt_app = ensure_app()
|
||||
hud = self._hud or self._name
|
||||
self._preview = SdkPreviewWindow(
|
||||
title=f'{hud} /{self._name} COLOR|DEPTH|IR (viewer)'
|
||||
title=f'{hud} /{self._name} COLOR|DEPTH|IR (查看器)'
|
||||
)
|
||||
self._preview.set_pane_titles(
|
||||
'COLOR', 'DEPTH', 'IR-L (左红外)', 'IR-R (右红外)'
|
||||
@@ -124,11 +123,11 @@ class RealsenseView(Node):
|
||||
hz = max(5.0, min(30.0, float(self.get_parameter('view_hz').value)))
|
||||
self.create_timer(1.0 / hz, self._push)
|
||||
self.get_logger().info(
|
||||
f'viewer ON — subscribing /{self._name}/color|depth|infra1|infra2 '
|
||||
f'(hold {self._hold:.2f}s). Close window = hide only.'
|
||||
f'查看器已启动 —— 订阅 /{self._name}/color|depth|infra1|infra2 '
|
||||
f'(保留 {self._hold:.2f}s)。关闭窗口 = 仅隐藏。'
|
||||
)
|
||||
|
||||
# Callbacks only store the newest message; conversion happens in _push.
|
||||
# 回调只存最新消息;转换在 _push 里做。
|
||||
def _on_color(self, msg: Image) -> None:
|
||||
self._color = msg
|
||||
self._rx_color += 1
|
||||
@@ -184,7 +183,7 @@ class RealsenseView(Node):
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
cam = data.get("camera") or {}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f"site.yaml reload failed: {exc}")
|
||||
self.get_logger().warn(f"site.yaml 重新加载失败:{exc}")
|
||||
return
|
||||
if "overlay_hold_sec" in cam:
|
||||
hold = max(0.0, float(cam.get("overlay_hold_sec") or 0.0))
|
||||
@@ -216,7 +215,7 @@ class RealsenseView(Node):
|
||||
elif now - self._rx_t0 >= 5.0:
|
||||
dt = now - self._rx_t0
|
||||
self.get_logger().info(
|
||||
f'viewer rx color={self._rx_color / dt:.1f}/s '
|
||||
f'查看器接收 color={self._rx_color / dt:.1f}/s '
|
||||
f'depth={self._rx_depth / dt:.1f}/s ir={self._rx_ir / dt:.1f}/s'
|
||||
)
|
||||
self._rx_t0 = now
|
||||
@@ -228,7 +227,7 @@ class RealsenseView(Node):
|
||||
self._overlay = None
|
||||
self._preview.set_overlay(None)
|
||||
if self._overlay is None or age > self._hold:
|
||||
miss = 'NO box — check perception_node / conf'
|
||||
miss = '没有框 —— 检查 perception_node / conf'
|
||||
self._preview.set_status(f'{self._hud} {miss}' if self._hud else miss)
|
||||
else:
|
||||
self._preview.set_status(self._hud)
|
||||
@@ -244,7 +243,7 @@ class RealsenseView(Node):
|
||||
int(self._depth.height), int(self._depth.width)
|
||||
)
|
||||
depth_bgr = _colorize_depth_u16(d)
|
||||
# Mono→BGR here (executor thread) so the Qt paint thread stays cheap.
|
||||
# 在这里(执行器线程)做 Mono→BGR,让 Qt 绘制线程保持轻量。
|
||||
ir1 = None if self._ir1 is None else _mono_to_bgr(self._mono(self._ir1))
|
||||
ir2 = None if self._ir2 is None else _mono_to_bgr(self._mono(self._ir2))
|
||||
self._preview.push_views(color, depth_bgr, ir1, ir2)
|
||||
@@ -254,8 +253,8 @@ def main(args=None) -> None:
|
||||
rclpy.init(args=args)
|
||||
node = RealsenseView()
|
||||
app = node._qt_app
|
||||
# ROS callbacks (image deserialization, depth colorize, push) run on
|
||||
# executor threads; the main thread only paints Qt — no shared GIL bursts.
|
||||
# ROS 回调(图像反序列化、深度上色、推送)跑在执行器线程上;
|
||||
# 主线程只负责绘制 Qt —— 不会有共享的 GIL 突发。
|
||||
executor = MultiThreadedExecutor(num_threads=3)
|
||||
executor.add_node(node)
|
||||
spin_t = threading.Thread(target=executor.spin, daemon=True)
|
||||
@@ -265,7 +264,7 @@ def main(args=None) -> None:
|
||||
app.processEvents()
|
||||
time.sleep(0.005)
|
||||
if node._preview is not None and not node._preview.isVisible():
|
||||
node.get_logger().warn('Preview closed — viewer idles (camera keeps publishing)')
|
||||
node.get_logger().warn('预览已关闭 —— 查看器挂起(相机仍在发布)')
|
||||
node._preview = None
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Send a multi-point joint trajectory via /follow_joint_trajectory (real + mock)."""
|
||||
"""通过 ``/follow_joint_trajectory`` 发送多点关节轨迹(真机 + mock 都适用)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,7 +16,7 @@ DEFAULT_JOINTS = [
|
||||
'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7',
|
||||
]
|
||||
NUM_JOINTS = 7
|
||||
# flat positions (3 pts x 7 joints) + times — ROS params do not support dict lists
|
||||
# 扁平化存放的位置(3 个点 x 7 个关节)+ 时间 —— ROS 参数不支持列表套字典
|
||||
DEFAULT_TIMES = [3.0, 6.0, 9.0]
|
||||
DEFAULT_POSITIONS = [
|
||||
0.0, -0.698, 0.0, 1.57, 0.0, 0.698, 0.0,
|
||||
@@ -34,7 +34,7 @@ class RunJointTrajectory(Node):
|
||||
self.declare_parameter('action_name', 'follow_joint_trajectory')
|
||||
self.declare_parameter('waypoint_times', DEFAULT_TIMES)
|
||||
self.declare_parameter('waypoint_positions', DEFAULT_POSITIONS)
|
||||
# 1 = run once (default); 0 = loop until Ctrl+C; N = repeat N times
|
||||
# 1 = 只跑一次(默认);0 = 循环直到 Ctrl+C;N = 重复 N 次
|
||||
self.declare_parameter('loop_count', 1)
|
||||
self.declare_parameter('pause_between_loops', 0.5)
|
||||
|
||||
@@ -52,8 +52,8 @@ class RunJointTrajectory(Node):
|
||||
def run(self) -> int:
|
||||
if not self._client.wait_for_server(timeout_sec=15.0):
|
||||
self.get_logger().error(
|
||||
f'Action [{self._action_name}] not available. '
|
||||
'Start real stack first: bash scripts/run_real.sh'
|
||||
f'Action [{self._action_name}] 不可用。'
|
||||
'请先启动真机栈:bash scripts/run_real.sh'
|
||||
)
|
||||
return 1
|
||||
|
||||
@@ -62,12 +62,12 @@ class RunJointTrajectory(Node):
|
||||
n_j = len(self.joint_names)
|
||||
n_pts = len(times)
|
||||
if n_pts < 1:
|
||||
self.get_logger().error('waypoint_times is empty')
|
||||
self.get_logger().error('waypoint_times 为空')
|
||||
return 1
|
||||
if len(positions) != n_pts * n_j:
|
||||
self.get_logger().error(
|
||||
f'waypoint_positions length {len(positions)} != '
|
||||
f'{n_pts} points x {n_j} joints = {n_pts * n_j}'
|
||||
f'waypoint_positions 长度 {len(positions)} != '
|
||||
f'{n_pts} 个点 x {n_j} 个关节 = {n_pts * n_j}'
|
||||
)
|
||||
return 1
|
||||
|
||||
@@ -83,35 +83,35 @@ class RunJointTrajectory(Node):
|
||||
duration = goal.time_from_start[-1]
|
||||
if loop_count == 0:
|
||||
self.get_logger().info(
|
||||
f'Loop mode: {goal.num_points} points, ~{duration:.1f}s/cycle '
|
||||
f'(Ctrl+C to stop)'
|
||||
f'循环模式:{goal.num_points} 个点,约 {duration:.1f}s/圈 '
|
||||
f'(Ctrl+C 停止)'
|
||||
)
|
||||
elif loop_count > 1:
|
||||
self.get_logger().info(
|
||||
f'Loop mode: {loop_count} cycles, {goal.num_points} points, '
|
||||
f'~{duration:.1f}s/cycle'
|
||||
f'循环模式:{loop_count} 圈,{goal.num_points} 个点,'
|
||||
f'约 {duration:.1f}s/圈'
|
||||
)
|
||||
else:
|
||||
self.get_logger().info(
|
||||
f'Sending {goal.num_points} points, v_scale={goal.velocity_scale}, '
|
||||
f'duration={duration:.1f}s'
|
||||
f'发送 {goal.num_points} 个点,v_scale={goal.velocity_scale},'
|
||||
f'时长={duration:.1f}s'
|
||||
)
|
||||
|
||||
iteration = 0
|
||||
while rclpy.ok():
|
||||
iteration += 1
|
||||
if loop_count > 1:
|
||||
self.get_logger().info(f'Cycle {iteration}/{loop_count}')
|
||||
self.get_logger().info(f'第 {iteration}/{loop_count} 圈')
|
||||
|
||||
code = self._send_once(goal, duration)
|
||||
if code != 0:
|
||||
return code
|
||||
|
||||
if loop_count == 1:
|
||||
self.get_logger().info('Done: OK')
|
||||
self.get_logger().info('完成:OK')
|
||||
return 0
|
||||
if loop_count > 1 and iteration >= loop_count:
|
||||
self.get_logger().info(f'Done: completed {loop_count} cycles')
|
||||
self.get_logger().info(f'完成:已跑完 {loop_count} 圈')
|
||||
return 0
|
||||
if pause > 0:
|
||||
time.sleep(pause)
|
||||
@@ -122,11 +122,11 @@ class RunJointTrajectory(Node):
|
||||
while rclpy.ok() and not send.done():
|
||||
rclpy.spin_once(self, timeout_sec=0.05)
|
||||
if time.monotonic() > deadline:
|
||||
self.get_logger().error('Timed out sending goal')
|
||||
self.get_logger().error('发送目标超时')
|
||||
return 2
|
||||
handle = send.result()
|
||||
if handle is None or not handle.accepted:
|
||||
self.get_logger().error('Goal rejected')
|
||||
self.get_logger().error('目标被拒绝')
|
||||
return 3
|
||||
|
||||
res_fut = handle.get_result_async()
|
||||
@@ -138,18 +138,18 @@ class RunJointTrajectory(Node):
|
||||
if now >= next_log:
|
||||
left = max(0.0, deadline - now)
|
||||
self.get_logger().info(
|
||||
f'Executing trajectory (~{duration:.0f}s planned), '
|
||||
f'waiting for robot... ({left:.0f}s timeout left)'
|
||||
f'正在执行轨迹(计划约 {duration:.0f}s),'
|
||||
f'等待机器人……(剩余超时 {left:.0f}s)'
|
||||
)
|
||||
next_log = now + 10.0
|
||||
if now > deadline:
|
||||
self.get_logger().error('Timed out waiting for result')
|
||||
self.get_logger().error('等待结果超时')
|
||||
return 4
|
||||
result = res_fut.result().result
|
||||
if not result.success:
|
||||
self.get_logger().error(f'Failed: {result.message}')
|
||||
self.get_logger().error(f'失败:{result.message}')
|
||||
return 5
|
||||
self.get_logger().info(f'Cycle finished: {result.message or "OK"}')
|
||||
self.get_logger().info(f'本圈结束:{result.message or "OK"}')
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""servo_node — 100 Hz Twist PBVS (own process, no YOLO).
|
||||
"""servo_node —— 100 Hz 的 Twist 视觉伺服(PBVS,独立进程,不跑 YOLO)。
|
||||
|
||||
Hard rules:
|
||||
- MultiThreadedExecutor + Reentrant ControlCallbackGroup for 100 Hz timer
|
||||
- Subscribe PointStamped only (no Service)
|
||||
- cmd_vel: SystemDefaults + keep_last(1) so driver always sees latest
|
||||
- Watchdog: lost target → freeze extrapolate, zero vel, clear I, zero traj
|
||||
- Latency compensation only while target is fresh
|
||||
- Never sendTransform in the 100 Hz loop
|
||||
硬性规则:
|
||||
- 用 ``MultiThreadedExecutor`` + ``Reentrant`` 的 ``ControlCallbackGroup`` 驱动 100 Hz 定时器
|
||||
- 只订阅 ``PointStamped``(不用 Service)
|
||||
- ``cmd_vel`` 用 ``SystemDefaults`` + ``keep_last(1)``,保证驱动器总能看到最新指令
|
||||
- 看门狗:目标丢失 → 冻结外推、速度置零、清 I、轨迹置零
|
||||
- 只有在目标数据新鲜时才做延迟补偿
|
||||
- 绝不在 100 Hz 循环里调用 ``sendTransform``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -59,7 +59,7 @@ class ServoNode(Node):
|
||||
super().__init__("servo_node")
|
||||
tid = threading.get_ident()
|
||||
self.get_logger().warn(
|
||||
f"servo_node started pid={os.getpid()} thread_id={tid}"
|
||||
f"servo_node 已启动 pid={os.getpid()} thread_id={tid}"
|
||||
)
|
||||
|
||||
self.declare_parameter("joint_prefix", "Rizon4-063989_")
|
||||
@@ -87,9 +87,9 @@ class ServoNode(Node):
|
||||
self.declare_parameter("predict_v_max", 0.35)
|
||||
self.declare_parameter("predict_jump_m", 0.08)
|
||||
|
||||
# --- Watchdog (lost target hard stop) ---
|
||||
# --- 看门狗(丢失目标即硬停) ---
|
||||
self.declare_parameter("watchdog_timeout_sec", 1.2)
|
||||
self.declare_parameter("stale_hold_sec", 0.28) # freeze XY before watchdog trip
|
||||
self.declare_parameter("stale_hold_sec", 0.28) # 触发看门狗前先冻结 XY
|
||||
|
||||
# --- PID ---
|
||||
self.declare_parameter("kp_xy", 2.00)
|
||||
@@ -100,7 +100,7 @@ class ServoNode(Node):
|
||||
self.declare_parameter("kd_z", 0.45)
|
||||
self.declare_parameter("i_limit_xy", 0.03)
|
||||
self.declare_parameter("i_limit_z", 0.025)
|
||||
# Integral separation thresholds
|
||||
# 积分分离阈值
|
||||
self.declare_parameter("i_sep_pos_m", 0.005)
|
||||
self.declare_parameter("i_sep_ang_rad", 0.02)
|
||||
|
||||
@@ -109,27 +109,27 @@ class ServoNode(Node):
|
||||
self.declare_parameter("v_max_xy", 0.06)
|
||||
self.declare_parameter("v_max_z", 0.04)
|
||||
self.declare_parameter("a_max", 0.45)
|
||||
self.declare_parameter("lost_timeout_sec", 1.2) # alias / stamp-age backup
|
||||
self.declare_parameter("e_xy_clip_m", 0.080) # never treat error as >8 cm
|
||||
self.declare_parameter("lost_timeout_sec", 1.2) # 别名 / 用时间戳年龄兜底
|
||||
self.declare_parameter("e_xy_clip_m", 0.080) # 误差绝不按大于 8 cm 处理
|
||||
|
||||
# --- Velocity LPF (smooth 100Hz → traj) ---
|
||||
# --- 速度低通滤波(把 100Hz 平滑成轨迹) ---
|
||||
self.declare_parameter("vel_lpf_alpha", 0.40)
|
||||
self.declare_parameter("wz_lpf_alpha", 0.40)
|
||||
|
||||
# --- JTC dq sliding window ---
|
||||
# --- JTC 的 dq 滑动窗口 ---
|
||||
self.declare_parameter("dq_window_size", 5)
|
||||
|
||||
self.declare_parameter("min_ee_z_m", 0.20)
|
||||
self.declare_parameter("workspace_xyz_min", [0.15, -0.50, 0.18])
|
||||
self.declare_parameter("workspace_xyz_max", [0.80, 0.50, 0.60])
|
||||
# Base-frame EE hold (flange). Z/XY in metres, editable via site.yaml.
|
||||
# base 系下的末端保持(法兰)。Z/XY 单位为米,可通过 site.yaml 修改。
|
||||
self.declare_parameter("ee_x_m", 0.25)
|
||||
self.declare_parameter("ee_y_m", 0.00)
|
||||
self.declare_parameter("ee_z_m", 0.25)
|
||||
self.declare_parameter("ee_hold_xy", False)
|
||||
self.declare_parameter("ee_vertical_down", True)
|
||||
# hover = flange/gripper-center XY on the 3D feature (any lock height).
|
||||
# lookat = image center. hover_offset_ee_xy_m is flange-XY to jaw center.
|
||||
# hover = 让法兰/夹爪中心 XY 停在三维特征点上(任意锁定高度都可)。
|
||||
# lookat = 对准图像中心。hover_offset_ee_xy_m 是法兰 XY 到爪中心的偏移。
|
||||
self.declare_parameter("xy_track", "hover")
|
||||
self.declare_parameter("hover_offset_ee_xy_m", [0.0, 0.0])
|
||||
self.declare_parameter("arm_label", "")
|
||||
@@ -144,14 +144,14 @@ class ServoNode(Node):
|
||||
self.declare_parameter("camera_fy", 390.0)
|
||||
self.declare_parameter("camera_cx", 320.0)
|
||||
self.declare_parameter("camera_cy", 240.0)
|
||||
# Seek: at lock height with no valid target, scan the work area so the
|
||||
# eye-in-hand camera can find the workpiece on its own (自适应找工件).
|
||||
# Seek:到达锁定高度却没有有效目标时,扫描工作区域,让 eye-in-hand
|
||||
# 相机自己去找到工件(自适应找工件)。
|
||||
self.declare_parameter("seek_enabled", True)
|
||||
self.declare_parameter("seek_radius_max_m", 0.16)
|
||||
self.declare_parameter("seek_speed_mps", 0.05)
|
||||
self.declare_parameter("seek_timeout_sec", 45.0)
|
||||
# Hover integral: breaks the ~4mm joint-command deadband that otherwise
|
||||
# stalls the last few mm of XY convergence onto the workpiece.
|
||||
# 悬停积分:用来突破约 4mm 的关节指令死区,否则 XY 收敛到最后几毫米
|
||||
# 时就会停滞、贴不到工件上。
|
||||
self.declare_parameter("ki_hover", 0.8)
|
||||
# 串联用:XY 入死区 + Z 到锁高持续这么久 → /visual_servo/locked=True
|
||||
self.declare_parameter("lock_hold_sec", 2.0)
|
||||
@@ -253,7 +253,7 @@ class ServoNode(Node):
|
||||
f"HANDEYE yaml={he_path} "
|
||||
f"cam_in_flange=({t_he[0]*1000:.1f},{t_he[1]*1000:.1f},{t_he[2]*1000:.1f})mm "
|
||||
f"optical_vs_flangeZ={self._cam_tilt_deg:.1f}deg "
|
||||
f"— hover uses this T, not TF lookup"
|
||||
f"— 悬停使用这个 T,而不是用 TF 查询"
|
||||
)
|
||||
|
||||
self.pid_x = PidController(
|
||||
@@ -287,12 +287,12 @@ class ServoNode(Node):
|
||||
self._lock = threading.Lock()
|
||||
self._p_meas = np.zeros(3, dtype=float)
|
||||
self._stamp_sec = -1.0
|
||||
self._last_seen_time = -1.0 # monotonic, updated on every PointStamped
|
||||
self._last_seen_time = -1.0 # 单调时钟,每次收到 PointStamped 都会更新
|
||||
self._have_target = False
|
||||
self._target_lost_flag = True
|
||||
self._v_raw = np.zeros(3, dtype=float)
|
||||
self._v_filt = np.zeros(3, dtype=float) # LPF linear (cam)
|
||||
self._wz_filt = 0.0 # reserved angular z LPF
|
||||
self._v_filt = np.zeros(3, dtype=float) # 线速度低通(相机系)
|
||||
self._wz_filt = 0.0 # 预留的角速度 z 低通
|
||||
self._wz_raw = 0.0
|
||||
self._zeroed = True
|
||||
self._last_js: Optional[JointState] = None
|
||||
@@ -305,14 +305,14 @@ class ServoNode(Node):
|
||||
self._p_feat_base: Optional[np.ndarray] = None
|
||||
self._he_cmp_done = False
|
||||
self._feat_kf = KalmanFilter3D(q_pos=2.0e-3, q_vel=1.5e-1, r_pos=4.0e-4)
|
||||
# XY hover only after flange Z is on the lock plane (fresh depth).
|
||||
# 只有当法兰 Z 已经落到锁定平面上(深度新鲜)才开启 XY 悬停。
|
||||
self._xy_armed = bool(self.ee_z_m <= 0.0)
|
||||
# Seek (search) state: scan for the workpiece when at lock_z with no target.
|
||||
# Seek(搜索)状态:在锁定高度且没有目标时扫描工件。
|
||||
self._seek_t0 = 0.0
|
||||
self._seek_theta = 0.0
|
||||
self._seek_origin = (0.0, 0.0)
|
||||
self._seek_warned = False
|
||||
self._hover_ixy = np.zeros(2, dtype=float) # hover integral (anti-deadband)
|
||||
self._hover_ixy = np.zeros(2, dtype=float) # 悬停积分(抗死区)
|
||||
|
||||
point_topic = str(self.get_parameter("point_topic").value)
|
||||
point_qos = QoSProfile(
|
||||
@@ -363,10 +363,10 @@ class ServoNode(Node):
|
||||
callback_group=self._control_cbg,
|
||||
)
|
||||
|
||||
mode = "CONTROL ON" if self.enable_control else "cmd_vel only"
|
||||
mode = "控制已开启" if self.enable_control else "仅 cmd_vel"
|
||||
arm = self.arm_label or self.ee_frame
|
||||
self.get_logger().info(
|
||||
f"servo ready [{arm}] [{mode}] {control_hz:.0f}Hz watchdog={self.watchdog_timeout:.2f}s "
|
||||
f"伺服就绪 [{arm}] [{mode}] {control_hz:.0f}Hz watchdog={self.watchdog_timeout:.2f}s "
|
||||
f"vel_lpf_α={self.vel_lpf_alpha:.2f} dq_win={self.dq_window_size} "
|
||||
f"lock_z={self.ee_z_m:.3f}m (site servo.ee_z_m) "
|
||||
f"xy_track={self.xy_track} cam_tilt={self._cam_tilt_deg:.1f}° "
|
||||
@@ -381,7 +381,7 @@ class ServoNode(Node):
|
||||
self._last_js = msg
|
||||
|
||||
def _on_point(self, msg: PointStamped) -> None:
|
||||
"""Fresh vision measurement — arm the watchdog and clear lost flag."""
|
||||
"""新的视觉测量 —— 给看门狗“上弦”并清除丢失标志。"""
|
||||
p_cam = np.array(
|
||||
[float(msg.point.x), float(msg.point.y), float(msg.point.z)], dtype=float
|
||||
)
|
||||
@@ -417,7 +417,7 @@ class ServoNode(Node):
|
||||
self._last_seen_time = now_m
|
||||
self._have_target = True
|
||||
self._target_lost_flag = False
|
||||
self._seek_t0 = 0.0 # target acquired — reset seek for next time
|
||||
self._seek_t0 = 0.0 # 已捕获目标 —— 重置 seek,供下次使用
|
||||
self._seek_warned = False
|
||||
if msg.header.frame_id:
|
||||
self.camera_frame = msg.header.frame_id
|
||||
@@ -432,12 +432,12 @@ class ServoNode(Node):
|
||||
if was_lost:
|
||||
self._reacq_mono = time.monotonic()
|
||||
self.get_logger().info(
|
||||
"target reacquired — resume servo",
|
||||
"已重新捕获目标 —— 继续伺服",
|
||||
throttle_duration_sec=0.5,
|
||||
)
|
||||
|
||||
def _reset_motion_state(self) -> None:
|
||||
"""Hard stop internals: I=0, filt vel=0, dq window clear."""
|
||||
"""硬停内部状态:I=0、滤波速度=0、dq 窗口清空。"""
|
||||
self.pid_x.reset()
|
||||
self.pid_y.reset()
|
||||
self.pid_z.reset()
|
||||
@@ -455,9 +455,9 @@ class ServoNode(Node):
|
||||
self._twist_pub.publish(tw)
|
||||
|
||||
def _watchdog_trip(self, reason: str) -> None:
|
||||
"""Priority-1 hard stop: no extrapolate, zero cmd, zero traj delta."""
|
||||
"""优先级 1 的硬停:不外推、指令置零、轨迹增量为零。"""
|
||||
if not self._target_lost_flag:
|
||||
self.get_logger().warn(f"WATCHDOG STOP: {reason}")
|
||||
self.get_logger().warn(f"看门狗急停:{reason}")
|
||||
self._target_lost_flag = True
|
||||
self._reset_motion_state()
|
||||
self._zeroed = True
|
||||
@@ -475,7 +475,7 @@ class ServoNode(Node):
|
||||
if self._ctrl_count >= int(round(1.0 / self._ctrl_dt)):
|
||||
elapsed = (now_msg - self._ctrl_t0).nanoseconds * 1e-9
|
||||
hz = self._ctrl_count / max(elapsed, 1e-6)
|
||||
self.get_logger().info(f"cmd_vel rate ~{hz:.1f} Hz", throttle_duration_sec=1.0)
|
||||
self.get_logger().info(f"cmd_vel 速率约 {hz:.1f} Hz", throttle_duration_sec=1.0)
|
||||
self._ctrl_count = 0
|
||||
self._ctrl_t0 = now_msg
|
||||
|
||||
@@ -486,9 +486,9 @@ class ServoNode(Node):
|
||||
p_meas = self._p_meas.copy()
|
||||
lost = self._target_lost_flag
|
||||
|
||||
# ----- Startup: descend to lock height FIRST, even before any detection.
|
||||
# have=False only until the first PointStamped ever arrives, so this is
|
||||
# a pure startup phase: Z-only descent, XY pinned, vertical-down.
|
||||
# ----- 启动阶段:**先**下降到锁定高度,甚至在任何检测到来之前。
|
||||
# 在第一个 PointStamped 到达之前 have 一直是 False,所以这是一个纯启动
|
||||
# 阶段:只动 Z 下降,XY 锁死,姿态朝下。
|
||||
if (
|
||||
self.enable_control
|
||||
and self.ee_z_m > 0.0
|
||||
@@ -497,22 +497,21 @@ class ServoNode(Node):
|
||||
):
|
||||
T_ee0 = self._lookup_T(self.base_frame, self.ee_frame)
|
||||
if T_ee0 is None:
|
||||
return # TF not ready yet — wait, do not hold-trip
|
||||
return # TF 还没就绪 —— 等待,不要触发保持急停
|
||||
self._update_xy_arm(float(T_ee0[2, 3]))
|
||||
if not self._xy_armed:
|
||||
self.get_logger().info(
|
||||
f"startup descend to lock_z {self.ee_z_m:.3f}m "
|
||||
f"now={T_ee0[2, 3]:.3f}m (no target yet, XY pinned)",
|
||||
f"启动下降至 lock_z {self.ee_z_m:.3f}m "
|
||||
f"当前={T_ee0[2, 3]:.3f}m(还没有目标,XY 锁死)",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
self._target_lost_flag = False # allow execute during blind descent
|
||||
self._target_lost_flag = False # 盲降期间允许执行
|
||||
self._execute_twist(np.zeros(3, dtype=float), p_cam=None, hold_xy=True)
|
||||
self._lock_feedback(False)
|
||||
return
|
||||
|
||||
# ----- Seek: at lock height but no valid target yet. The eye-in-hand
|
||||
# camera only sees a small patch, so scan the work area (drift to the
|
||||
# nominal work XY, then spiral out) until the workpiece enters view.
|
||||
# ----- Seek:已在锁定高度但还没有有效目标。eye-in-hand 相机只能看到
|
||||
# 一小块区域,所以扫描工作区(先漂到标称工作 XY,再向外螺旋)直到工件进入视野。
|
||||
if (
|
||||
self.enable_control
|
||||
and self.seek_enabled
|
||||
@@ -522,53 +521,53 @@ class ServoNode(Node):
|
||||
):
|
||||
T_ee0 = self._lookup_T(self.base_frame, self.ee_frame)
|
||||
if T_ee0 is None:
|
||||
return # TF not ready — wait, do not hold-trip
|
||||
return # TF 还没就绪 —— 等待,不要触发保持急停
|
||||
v_seek = self._seek_velocity(T_ee0[:3, 3])
|
||||
if v_seek is None:
|
||||
# timed out — give up searching, hold in place
|
||||
# 超时了 —— 放弃搜索,原地保持
|
||||
if not self._seek_warned:
|
||||
self._seek_warned = True
|
||||
self.get_logger().warn(
|
||||
f"seek timeout {self.seek_timeout:.0f}s — workpiece not found. "
|
||||
"Hold position. Put the workpiece on the table in view, or widen "
|
||||
"seek_radius_max_m / check camera mount & handeye."
|
||||
f"seek 超时 {self.seek_timeout:.0f}s —— 没找到工件。"
|
||||
"保持当前位置。请把工件放到桌面视野内,或调大 "
|
||||
"seek_radius_max_m / 检查相机安装与手眼标定。"
|
||||
)
|
||||
self._lock_feedback(False)
|
||||
self._pub_zero_twist()
|
||||
self._send_hold()
|
||||
return
|
||||
self.get_logger().info(
|
||||
f"seek workpiece… ee_xy=({T_ee0[0,3]:.3f},{T_ee0[1,3]:.3f}) "
|
||||
f"寻找工件…… ee_xy=({T_ee0[0,3]:.3f},{T_ee0[1,3]:.3f}) "
|
||||
f"v=({v_seek[0]:+.3f},{v_seek[1]:+.3f})",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
self._target_lost_flag = False # allow execute during seek
|
||||
self._target_lost_flag = False # seek 期间允许执行
|
||||
self._execute_twist(
|
||||
np.zeros(3, dtype=float), p_cam=None, v_base_override=v_seek
|
||||
)
|
||||
self._lock_feedback(False)
|
||||
return
|
||||
|
||||
# ----- Item 1: Watchdog (receive-time) — freeze ALL motion if stale -----
|
||||
# ----- 第 1 项:看门狗(按接收时间)—— 数据过期就冻结所有运动 -----
|
||||
if (not have) or last_seen < 0.0:
|
||||
self._lock_feedback(False)
|
||||
self._watchdog_trip("no PointStamped yet")
|
||||
self._watchdog_trip("还没有收到 PointStamped")
|
||||
return
|
||||
|
||||
seen_age = now_mono - last_seen
|
||||
stamp_age = now_sec - stamp if stamp >= 0.0 else 1e9
|
||||
if seen_age > self.watchdog_timeout or stamp_age > self.lost_timeout:
|
||||
self._watchdog_trip(
|
||||
f"target lost seen_age={seen_age:.3f}s stamp_age={stamp_age:.3f}s"
|
||||
f"目标丢失 seen_age={seen_age:.3f}s stamp_age={stamp_age:.3f}s"
|
||||
)
|
||||
self._lock_feedback(False)
|
||||
return
|
||||
|
||||
# Frozen box / USB stall: pin joints, do not keep driving last error.
|
||||
# 检测框冻结 / USB 卡住:锁住关节,不要继续按上一个误差驱动。
|
||||
stale = max(0.15, float(self.stale_hold_sec))
|
||||
if seen_age > stale or stamp_age > stale:
|
||||
self.get_logger().info(
|
||||
f"stale hold seen_age={seen_age:.2f}s — not tracking a frozen box",
|
||||
f"过期保持 seen_age={seen_age:.2f}s —— 不跟踪冻结的检测框",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
self._v_raw[:] = 0.0
|
||||
@@ -580,13 +579,13 @@ class ServoNode(Node):
|
||||
return
|
||||
|
||||
if lost:
|
||||
# Should have been cleared on _on_point; belt-and-suspenders
|
||||
# 本应在 _on_point 里清掉;这里再加一道保险
|
||||
self._lock_feedback(False)
|
||||
self._pub_zero_twist()
|
||||
return
|
||||
|
||||
# Hover uses the vision 3D point as-is. Do not add cmd_vel (wrong frame)
|
||||
# onto p_cam — that walks the target off the workpiece.
|
||||
# Hover 直接使用视觉三维点。不要把 cmd_vel(坐标系不对)
|
||||
# 叠加到 p_cam 上 —— 那样会把目标从工件上带跑。
|
||||
p = p_meas.copy()
|
||||
if self.xy_track == "lookat":
|
||||
dt_lat = float(np.clip(stamp_age, 0.0, self.max_extrap))
|
||||
@@ -618,7 +617,7 @@ class ServoNode(Node):
|
||||
ex *= s
|
||||
ey *= s
|
||||
r_xy = clip_xy
|
||||
# Camera-depth Z is unused when holding flange height in base frame
|
||||
# 在 base 系里保持法兰高度时,相机深度 Z 就不再使用
|
||||
z_dead = True if self.ee_z_m > 0.0 else (abs(ez) <= self.deadzone_z_m)
|
||||
if xy_dead:
|
||||
ex = ey = 0.0
|
||||
@@ -628,7 +627,7 @@ class ServoNode(Node):
|
||||
ez = 0.0
|
||||
self.pid_z.reset()
|
||||
|
||||
# ----- Item 2: PID with integral separation (inside PidController) -----
|
||||
# ----- 第 2 项:带积分分离的 PID(分离逻辑在 PidController 内部) -----
|
||||
v_des = np.array(
|
||||
[
|
||||
0.0 if self.ee_hold_xy else self.pid_x.update(ex, float(p[0]), dt),
|
||||
@@ -644,7 +643,7 @@ class ServoNode(Node):
|
||||
v_des[:2] = _saturate_vec(v_des[:2].copy(), self.v_max_xy)
|
||||
v_des[2] = float(np.clip(v_des[2], -self.v_max_z, self.v_max_z))
|
||||
|
||||
# Base-plane mode: never command camera +Z (that dives into the part)
|
||||
# base 平面模式:绝不指令相机 +Z(那会直接扎进工件里)
|
||||
if self.ee_z_m > 0.0:
|
||||
v_des[2] = 0.0
|
||||
else:
|
||||
@@ -661,7 +660,7 @@ class ServoNode(Node):
|
||||
if z_meas < (self.z_min_m - 0.02):
|
||||
v_des[2] = min(v_des[2], -0.03)
|
||||
|
||||
# Accel limit on raw desire, then LPF
|
||||
# 先对原始期望速度做加速度限制,再做低通滤波
|
||||
dv = v_des - self._v_raw
|
||||
n_dv = float(np.linalg.norm(dv))
|
||||
dv_max = self.a_max * dt
|
||||
@@ -669,10 +668,10 @@ class ServoNode(Node):
|
||||
dv *= dv_max / n_dv
|
||||
self._v_raw = self._v_raw + dv
|
||||
|
||||
# ----- Item 3: first-order LPF on linear (+ reserved wz) -----
|
||||
# ----- 第 3 项:对线速度做一阶低通(+ 预留的 wz) -----
|
||||
a = self.vel_lpf_alpha
|
||||
self._v_filt = a * self._v_raw + (1.0 - a) * self._v_filt
|
||||
# Angular reserved (PBVS currently linear-only); still filter for API completeness
|
||||
# 角速度目前预留(PBVS 当前只做线速度);仍做滤波以保持接口完整
|
||||
self._wz_raw = 0.0
|
||||
aw = self.wz_lpf_alpha
|
||||
self._wz_filt = aw * self._wz_raw + (1.0 - aw) * self._wz_filt
|
||||
@@ -706,11 +705,11 @@ class ServoNode(Node):
|
||||
if xy_dead and z_dead:
|
||||
self._lock_feedback(True) # 串联信号:活目标 + 死区驻留 = 收敛
|
||||
self.get_logger().info(
|
||||
f"deadzone hold |e_xy|={r_xy_raw*1000:.1f}mm ez={ez*1000:.1f}mm",
|
||||
f"死区保持 |e_xy|={r_xy_raw*1000:.1f}mm ez={ez*1000:.1f}mm",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
if self.enable_control:
|
||||
# Still pin height / vertical-down if slightly off, else freeze
|
||||
# 若高度 / 垂直朝下稍有偏差仍要修正,否则彻底冻结
|
||||
if need_base:
|
||||
self._execute_twist(np.zeros(3, dtype=float), p_cam=p, hold_xy=True)
|
||||
elif not self._zeroed:
|
||||
@@ -728,7 +727,7 @@ class ServoNode(Node):
|
||||
f" feat=({look[1][0]:.3f},{look[1][1]:.3f})"
|
||||
)
|
||||
self.get_logger().info(
|
||||
f"twist cam v=({v_cam[0]:+.3f},{v_cam[1]:+.3f},{v_cam[2]:+.3f}) "
|
||||
f"twist 相机系 v=({v_cam[0]:+.3f},{v_cam[1]:+.3f},{v_cam[2]:+.3f}) "
|
||||
f"|e_xy|={r_xy_raw*1000:.0f}mm ez={ez*1000:.0f}mm age={stamp_age*1000:.0f}ms"
|
||||
f"{extra}",
|
||||
throttle_duration_sec=0.5,
|
||||
@@ -739,14 +738,14 @@ class ServoNode(Node):
|
||||
self._execute_twist(v_cam, p_cam=p)
|
||||
|
||||
def _e_stop(self, reason: str) -> None:
|
||||
self.get_logger().warn(f"E-STOP: {reason}")
|
||||
self.get_logger().warn(f"急停:{reason}")
|
||||
self._watchdog_trip(reason)
|
||||
|
||||
def _R_cam_to_base(self, T_ee: np.ndarray) -> np.ndarray:
|
||||
return (T_ee @ self._T_ee_cam())[:3, :3]
|
||||
|
||||
def _T_ee_cam(self) -> np.ndarray:
|
||||
# Always the calibrated yaml. TF is only for RViz / a sanity print.
|
||||
# 始终使用标定出的 yaml。TF 只用于 RViz / 打印做核对。
|
||||
return np.array(self.T_cam_to_gripper, dtype=float)
|
||||
|
||||
def _log_handeye_tf_vs_yaml(self) -> None:
|
||||
@@ -754,7 +753,7 @@ class ServoNode(Node):
|
||||
T_tf = self._lookup_T(self.ee_frame, self.handeye_child)
|
||||
if T_tf is None:
|
||||
self.get_logger().warn(
|
||||
"HANDEYE TF lookup failed (ok) — servo uses yaml "
|
||||
"HANDEYE TF 查询失败(无妨)—— 伺服使用 yaml "
|
||||
f"t=({t_yaml[0]*1000:.1f},{t_yaml[1]*1000:.1f},{t_yaml[2]*1000:.1f})mm"
|
||||
)
|
||||
return
|
||||
@@ -767,8 +766,8 @@ class ServoNode(Node):
|
||||
)
|
||||
if d > 0.015:
|
||||
self.get_logger().error(
|
||||
"HANDEYE TF ≠ yaml by >15mm — duplicate parent on camera frame? "
|
||||
"Servo still uses yaml."
|
||||
"HANDEYE TF 与 yaml 的偏差 >15mm —— 相机坐标系上挂了重复的父节点? "
|
||||
"伺服仍然使用 yaml。"
|
||||
)
|
||||
|
||||
def _intersect_z(
|
||||
@@ -789,11 +788,11 @@ class ServoNode(Node):
|
||||
def _lookat_vs_feature(
|
||||
self, T_ee: np.ndarray, p_cam: np.ndarray
|
||||
) -> Optional[tuple]:
|
||||
"""Base XY error that centers the feature in the image.
|
||||
"""让特征点居中到图像里所需的 base 系 XY 误差。
|
||||
|
||||
30° eye-in-hand: image center is the optical-axis hit on the work
|
||||
plane, not the flange XY. Translating the flange in XY at fixed
|
||||
height/attitude moves that look-at 1:1.
|
||||
30° 的 eye-in-hand 安装:图像中心是光轴打到工作平面上的点,
|
||||
而不是法兰的 XY。在固定高度 / 姿态下平移法兰 XY,会把该 look-at 点
|
||||
按 1:1 一起带走。
|
||||
"""
|
||||
T_bc = T_ee @ self._T_ee_cam()
|
||||
R = T_bc[:3, :3]
|
||||
@@ -812,7 +811,7 @@ class ServoNode(Node):
|
||||
return err, p_feat, p_look
|
||||
|
||||
def _feature_in_base(self, T_ee: np.ndarray, p_cam: np.ndarray) -> np.ndarray:
|
||||
"""Workpiece 3D point (camera frame) → base XYZ. Keep XYZ consistent."""
|
||||
"""工件三维点(相机系)→ base 系 XYZ。保持 XYZ 一致。"""
|
||||
T_bc = T_ee @ self._T_ee_cam()
|
||||
ph = np.array(
|
||||
[float(p_cam[0]), float(p_cam[1]), float(p_cam[2]), 1.0], dtype=float
|
||||
@@ -822,11 +821,11 @@ class ServoNode(Node):
|
||||
def _hover_vs_feature(
|
||||
self, T_ee: np.ndarray, p_cam: np.ndarray
|
||||
) -> Optional[tuple]:
|
||||
"""Park flange (and coaxial gripper center) XY on the workpiece XY in base.
|
||||
"""把法兰(以及同轴的夹爪中心)的 XY 停在 base 系里的工件 XY 上。
|
||||
|
||||
hover_offset_ee_xy_m shifts the tracked point in the flange frame
|
||||
(jaw center if it is not on the flange origin). RGI is coaxial: leave [0,0].
|
||||
Must NOT re-multiply a stale p_cam by the live T_ee.
|
||||
hover_offset_ee_xy_m 会在法兰坐标系里平移被跟踪点
|
||||
(当爪中心不在法兰原点时用它)。RGI 是同轴的:保持 [0,0] 即可。
|
||||
绝不能拿实时的 T_ee 去重复乘一个过期的 p_cam。
|
||||
"""
|
||||
p_ee = np.asarray(T_ee[:3, 3], dtype=float)
|
||||
off = self.hover_offset_ee_xy
|
||||
@@ -887,7 +886,7 @@ class ServoNode(Node):
|
||||
return None
|
||||
|
||||
def _xy_deadzone(self, r_xy: float, *, update: bool = True) -> bool:
|
||||
"""Hysteresis: enter at deadzone_m, leave at 1.6× so noise does not chatter."""
|
||||
"""滞回:在 deadzone_m 处进入,在 1.6 倍处退出,避免噪声来回抖。"""
|
||||
enter = max(0.002, float(self.deadzone_m))
|
||||
leave = enter * 1.6
|
||||
if self._xy_in_dead:
|
||||
@@ -925,7 +924,7 @@ class ServoNode(Node):
|
||||
self._lock_since = None
|
||||
|
||||
def _w_align_flange_down(self, R_ee: np.ndarray) -> np.ndarray:
|
||||
"""Angular vel in base to put flange +Z along world −Z (tool down)."""
|
||||
"""base 系下的角速度,用来把法兰 +Z 转到世界 −Z 方向(工具朝下)。"""
|
||||
z_now = np.asarray(R_ee, dtype=float).reshape(3, 3)[:, 2]
|
||||
z_des = np.array([0.0, 0.0, -1.0], dtype=float)
|
||||
c = float(np.clip(np.dot(z_now, z_des), -1.0, 1.0))
|
||||
@@ -942,7 +941,7 @@ class ServoNode(Node):
|
||||
return axis * mag
|
||||
|
||||
def _shape_xy_vel(self, v_des_xy: np.ndarray, dt: float) -> np.ndarray:
|
||||
"""Slew-rate + LPF on the hover command that actually moves the arm."""
|
||||
"""真正驱动机械臂的那条悬停指令,外加变化率限制 + 低通滤波。"""
|
||||
v_des = np.asarray(v_des_xy, dtype=float).reshape(2)
|
||||
prev = self._v_xy_cmd
|
||||
dv = v_des - prev
|
||||
@@ -961,7 +960,7 @@ class ServoNode(Node):
|
||||
return out
|
||||
|
||||
def _update_xy_arm(self, ee_z: float) -> bool:
|
||||
"""XY hover only after flange is on lock_z (real depth, adaptive hover)."""
|
||||
"""只有当法兰到达 lock_z(真实深度、自适应悬停)后才做 XY 悬停。"""
|
||||
if self.ee_z_m <= 0.0:
|
||||
self._xy_armed = True
|
||||
return True
|
||||
@@ -977,8 +976,8 @@ class ServoNode(Node):
|
||||
self.pid_x.reset()
|
||||
self.pid_y.reset()
|
||||
self.get_logger().warn(
|
||||
f"lock-z ready ee_z={z:.3f}m (want {self.ee_z_m:.3f}, no climb) "
|
||||
"— start XY hover on live handeye"
|
||||
f"lock-z 就绪 ee_z={z:.3f}m(期望 {self.ee_z_m:.3f},不再回抬) "
|
||||
"—— 在实时 handeye 上开始 XY 悬停"
|
||||
)
|
||||
return True
|
||||
err = abs(z - float(self.ee_z_m))
|
||||
@@ -991,8 +990,8 @@ class ServoNode(Node):
|
||||
self.pid_x.reset()
|
||||
self.pid_y.reset()
|
||||
self.get_logger().warn(
|
||||
f"lock-z reached ee_z={z:.3f}m (want {self.ee_z_m:.3f}) "
|
||||
"— start XY hover on live handeye"
|
||||
f"已到达 lock-z ee_z={z:.3f}m(期望 {self.ee_z_m:.3f})"
|
||||
" —— 在实时 handeye 上开始 XY 悬停"
|
||||
)
|
||||
elif err > gate + 0.05:
|
||||
self._xy_armed = False
|
||||
@@ -1000,20 +999,19 @@ class ServoNode(Node):
|
||||
self._feat_kf.initialized = False
|
||||
self._v_xy_cmd[:] = 0.0
|
||||
self.get_logger().info(
|
||||
f"left lock-z band ee_z={z:.3f}m — XY hold, re-descend",
|
||||
f"已离开 lock-z 区间 ee_z={z:.3f}m —— XY 保持,重新下降",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
return self._xy_armed
|
||||
|
||||
def _seek_velocity(self, p_ee: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""Base-frame XY velocity to scan for the workpiece: spiral outward around
|
||||
the point where the arm reached lock height, so the eye-in-hand camera
|
||||
sweeps the table. Returns None when the seek has timed out (caller holds)."""
|
||||
"""base 系下的 XY 速度,用来扫描寻找工件:以「机械臂到达锁定高度时的位置」
|
||||
为中心向外螺旋,让 eye-in-hand 相机扫过桌面。超时后返回 None(由调用方保持)。"""
|
||||
now = time.monotonic()
|
||||
if self._seek_t0 <= 0.0:
|
||||
self._seek_t0 = now
|
||||
self._seek_theta = 0.0
|
||||
self._seek_origin = (float(p_ee[0]), float(p_ee[1])) # search near descent XY
|
||||
self._seek_origin = (float(p_ee[0]), float(p_ee[1])) # 在下降点附近搜索 XY
|
||||
if (now - self._seek_t0) > self.seek_timeout:
|
||||
return None
|
||||
ox, oy = self._seek_origin
|
||||
@@ -1037,7 +1035,7 @@ class ServoNode(Node):
|
||||
def _clip_workspace_vel(
|
||||
self, p_ee: np.ndarray, v_base: np.ndarray, T: float
|
||||
) -> np.ndarray:
|
||||
"""Keep commands inside the box; if already outside, allow recovery in."""
|
||||
"""把指令限制在盒子里;如果本来就在盒子外,允许它往回恢复。"""
|
||||
v = np.asarray(v_base, dtype=float).copy()
|
||||
dt = max(float(T), 1e-3)
|
||||
for i in range(3):
|
||||
@@ -1063,7 +1061,7 @@ class ServoNode(Node):
|
||||
hold_xy: bool = False,
|
||||
v_base_override: Optional[np.ndarray] = None,
|
||||
) -> None:
|
||||
# Hard freeze if watchdog already tripped mid-cycle
|
||||
# 如果看门狗在本周期中途已经触发,就硬冻结
|
||||
if self._target_lost_flag:
|
||||
self._dq_hist.clear()
|
||||
return
|
||||
@@ -1078,7 +1076,7 @@ class ServoNode(Node):
|
||||
T_ee = self._lookup_T(self.base_frame, self.ee_frame)
|
||||
if q is None or T_ee is None:
|
||||
self.get_logger().warn(
|
||||
"execute skip: missing joint_states or TF base→ee",
|
||||
"execute 跳过:缺少 joint_states 或 base→ee 的 TF",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
return
|
||||
@@ -1097,8 +1095,8 @@ class ServoNode(Node):
|
||||
if self._reacq_mono > 0.0 and (now_m - self._reacq_mono) < 0.12:
|
||||
v_xy *= 0.85
|
||||
|
||||
# hover: park flange XY on the 3D feature at any lock height.
|
||||
# lookat: drive optical-axis hit (image center) onto the feature.
|
||||
# hover:在任意锁定高度上把法兰 XY 停在三维特征点上。
|
||||
# lookat:把光轴命中点(图像中心)驱到特征点上。
|
||||
xy_ok = self._update_xy_arm(float(p_ee[2]))
|
||||
if (
|
||||
xy_ok
|
||||
@@ -1120,11 +1118,11 @@ class ServoNode(Node):
|
||||
v_base[1] = 0.0
|
||||
self._hover_ixy[:] = 0.0
|
||||
else:
|
||||
# Taper near the deadzone so P-control does not bang-bang.
|
||||
# 在死区附近做渐变,避免 P 控制来回 bang-bang。
|
||||
enter = max(0.002, float(self.deadzone_m))
|
||||
taper = float(np.clip((nerr - enter) / max(enter, 1e-3), 0.0, 1.0))
|
||||
# Integral (anti-windup ±4cm) breaks the joint-command deadband
|
||||
# that otherwise stalls the last few mm of XY convergence.
|
||||
# 积分项(抗积分饱和 ±4cm)用来突破关节指令死区,
|
||||
# 否则 XY 收敛的最后几毫米就会停滞。
|
||||
self._hover_ixy[0] = float(np.clip(
|
||||
self._hover_ixy[0] + float(err[0]) * dt_use, -0.04, 0.04))
|
||||
self._hover_ixy[1] = float(np.clip(
|
||||
@@ -1149,8 +1147,8 @@ class ServoNode(Node):
|
||||
v_base[0] *= v_xy / nxy
|
||||
v_base[1] *= v_xy / nxy
|
||||
|
||||
# Hold flange XYZ in base (config, default ~250 mm). Visual XY is
|
||||
# overwritten when ee_hold_xy is true.
|
||||
# 在 base 系里保持法兰 XYZ(可配置,默认约 250 mm)。当 ee_hold_xy
|
||||
# 为真时,视觉算出的 XY 会被覆盖。
|
||||
if self.ee_hold_xy:
|
||||
v_base[0] = float(np.clip(kp * (self.ee_x_m - p_ee[0]), -self.v_max_xy, self.v_max_xy))
|
||||
v_base[1] = float(np.clip(kp * (self.ee_y_m - p_ee[1]), -self.v_max_xy, self.v_max_xy))
|
||||
@@ -1164,7 +1162,7 @@ class ServoNode(Node):
|
||||
self._v_xy_cmd[:] = 0.0
|
||||
if self.ee_z_m > 0.0:
|
||||
self.get_logger().info(
|
||||
f"descend to lock_z {self.ee_z_m:.3f}m now={p_ee[2]:.3f}m XY hold",
|
||||
f"下降至 lock_z {self.ee_z_m:.3f}m 当前={p_ee[2]:.3f}m XY 保持",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
shaped = self._shape_xy_vel(v_base[:2], dt_use)
|
||||
@@ -1189,7 +1187,7 @@ class ServoNode(Node):
|
||||
|
||||
p_next = p_ee + v_base * T
|
||||
if self.ee_z_m > 0.0:
|
||||
# Never command below the configured plane
|
||||
# 绝不指令到配置平面以下
|
||||
floor = max(self.min_ee_z, self.ee_z_m - 0.02)
|
||||
if p_next[2] < floor and v_base[2] < 0.0:
|
||||
v_base[2] = 0.0
|
||||
@@ -1205,7 +1203,7 @@ class ServoNode(Node):
|
||||
Tl = self._lookup_T(self.base_frame, fr)
|
||||
if Tl is None:
|
||||
self.get_logger().warn(
|
||||
f"execute skip: TF miss {fr}",
|
||||
f"execute 跳过:TF 缺 {fr}",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
return
|
||||
@@ -1217,10 +1215,10 @@ class ServoNode(Node):
|
||||
)
|
||||
dq = qdot * T
|
||||
|
||||
# ----- Item 5: sliding-window average on joint displacement -----
|
||||
# ----- 第 5 项:对关节位移做滑动窗口平均 -----
|
||||
self._dq_hist.append(dq.copy())
|
||||
dq_avg = np.mean(np.stack(list(self._dq_hist), axis=0), axis=0)
|
||||
# Keep end-velocity consistent with averaged step
|
||||
# 让末端速度与平均后的步长保持一致
|
||||
qdot_avg = dq_avg / T
|
||||
|
||||
mag = float(np.max(np.abs(dq_avg)))
|
||||
@@ -1281,7 +1279,7 @@ def main(args=None) -> None:
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
node._e_stop("node shutdown")
|
||||
node._e_stop("节点关闭")
|
||||
executor.shutdown()
|
||||
node.destroy_node()
|
||||
if rclpy.ok():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Load ~/ros2/config/site.yaml from application nodes."""
|
||||
"""供应用节点加载 ``~/ros2/config/site.yaml``。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -149,8 +149,8 @@ class Stage2Locate(Node):
|
||||
|
||||
self.declare_parameter("robot_sn", os.environ.get("SITE_ROBOT_SN") or "")
|
||||
self.declare_parameter("hover_only", False)
|
||||
self.declare_parameter("target_hole", "") # "" → site stage2.target_hole
|
||||
self.declare_parameter("hover_z_m", -1.0) # >0 → 覆盖 site
|
||||
self.declare_parameter("target_hole", "") # 空字符串 → 用 site 的 stage2.target_hole
|
||||
self.declare_parameter("hover_z_m", -1.0) # 大于 0 → 覆盖 site 里的值
|
||||
self.declare_parameter("preview", True)
|
||||
self.declare_parameter("action_name", "rizon_arm_controller/follow_joint_trajectory")
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Orisys visuo-tactile CLI. Ports / config from site.yaml tactile:."""
|
||||
"""Orisys 视触觉命令行工具。端口 / 配置来自 ``site.yaml`` 的 ``tactile`` 段。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,7 +12,7 @@ from arm_application.site_io import load_site_dict, site_path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Orisys tactile CLI (site.yaml)")
|
||||
parser = argparse.ArgumentParser(description="Orisys 触觉命令行工具(site.yaml)")
|
||||
parser.add_argument("--duration", type=float, default=10.0)
|
||||
parser.add_argument("--site", default="")
|
||||
args = parser.parse_args()
|
||||
@@ -29,7 +29,7 @@ def main() -> int:
|
||||
try:
|
||||
tactile.connect()
|
||||
except Exception as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
return 1
|
||||
t0 = time.time()
|
||||
n = 0
|
||||
@@ -49,10 +49,10 @@ def main() -> int:
|
||||
)
|
||||
time.sleep(0.01)
|
||||
except KeyboardInterrupt:
|
||||
print("interrupt")
|
||||
print("已中断")
|
||||
finally:
|
||||
tactile.disconnect()
|
||||
print(f"done frames={n}")
|
||||
print(f"完成 frames={n}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tactile sample / command types. Backend (Orisys / mock) is swappable."""
|
||||
"""触觉采样 / 指令类型。后端(Orisys / mock)可替换。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ultralytics YOLO / TensorRT wrapper.
|
||||
"""Ultralytics YOLO / TensorRT 封装。
|
||||
|
||||
Public: ``resolve_weights``, ``YoloDetector``, ``Detection``, ``pick_best``,
|
||||
``MpYoloDetector``. TensorRT loads the sibling ``.engine`` next to the
|
||||
``.onnx`` / ``.pt`` path when ``backend='tensorrt'``.
|
||||
对外接口:``resolve_weights``、``YoloDetector``、``Detection``、``pick_best``、
|
||||
``MpYoloDetector``。当 ``backend='tensorrt'`` 时,TensorRT 会加载与
|
||||
``.onnx`` / ``.pt`` 路径同级的 ``.engine`` 文件。
|
||||
|
||||
类别名(标签)由 ``model_labels`` 统一提供:优先读权重旁边的
|
||||
``<模型名>.labels.yaml``(用户可随时手改),其次读模型内嵌 metadata。
|
||||
@@ -33,13 +33,13 @@ from .model_labels import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel: worker still on the previous frame — caller must NOT count a miss.
|
||||
# 哨兵值:worker 还停在上一次的那帧 —— 调用方不能把它算作漏检。
|
||||
INFER_BUSY = object()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Detection:
|
||||
xyxy: np.ndarray # (4,) float: x1,y1,x2,y2
|
||||
xyxy: np.ndarray # (4,) float:x1,y1,x2,y2
|
||||
conf: float
|
||||
cls_id: int
|
||||
cls_name: str
|
||||
@@ -69,7 +69,7 @@ def _resolve_device(device: str) -> str:
|
||||
|
||||
|
||||
def resolve_weights(weights: Union[str, Path], backend: str = "auto") -> Path:
|
||||
"""Prefer TensorRT .engine next to the .pt when backend=auto/tensorrt."""
|
||||
"""当 backend=auto/tensorrt 时,优先使用与 .pt 同级的 TensorRT .engine。"""
|
||||
p = Path(weights).expanduser()
|
||||
b = (backend or "auto").strip().lower()
|
||||
engine = p.with_suffix(".engine")
|
||||
@@ -78,13 +78,13 @@ def resolve_weights(weights: Union[str, Path], backend: str = "auto") -> Path:
|
||||
if engine.is_file():
|
||||
return engine
|
||||
raise FileNotFoundError(
|
||||
f"TensorRT engine missing: {engine}. "
|
||||
f"Export: bash ~/ros2/scripts/weights.sh convert {p} --name {p.stem}"
|
||||
f"缺少 TensorRT engine:{engine}。"
|
||||
f"导出命令:bash ~/ros2/scripts/weights.sh convert {p} --name {p.stem}"
|
||||
)
|
||||
if b == "onnx":
|
||||
if onnx.is_file():
|
||||
return onnx
|
||||
raise FileNotFoundError(f"ONNX missing: {onnx}")
|
||||
raise FileNotFoundError(f"缺少 ONNX 文件:{onnx}")
|
||||
if b in ("", "auto"):
|
||||
if engine.is_file():
|
||||
return engine
|
||||
@@ -155,19 +155,19 @@ class YoloDetector:
|
||||
from ultralytics import YOLO
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise RuntimeError(
|
||||
"ultralytics not installed. In ros_flexiv: pip install ultralytics"
|
||||
"未安装 ultralytics。在 ros_flexiv 环境里执行:pip install ultralytics"
|
||||
) from exc
|
||||
|
||||
path = Path(weights).expanduser()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"YOLO weights not found: {path}. "
|
||||
f"Place your .pt at ~/ros2/models/yolo/workpiece.pt"
|
||||
f"找不到 YOLO 权重:{path}。"
|
||||
f"请把你的 .pt 放到 ~/ros2/models/yolo/workpiece.pt"
|
||||
)
|
||||
try:
|
||||
import torch
|
||||
|
||||
# Do not let PyTorch occupy all CPU cores (camera + ROS need them).
|
||||
# 别让 PyTorch 占满所有 CPU 核心(相机 + ROS 都要用)。
|
||||
torch.set_num_threads(1)
|
||||
torch.set_num_interop_threads(1)
|
||||
if torch.cuda.is_available():
|
||||
@@ -243,7 +243,7 @@ class YoloDetector:
|
||||
imgsz: int | None = None,
|
||||
**_kwargs,
|
||||
) -> List[Detection]:
|
||||
"""Alias used by hole detectors (xyxy + conf)."""
|
||||
"""给孔检测器用的别名接口(xyxy + conf)。"""
|
||||
if conf is not None:
|
||||
self.conf = float(conf)
|
||||
if iou is not None:
|
||||
@@ -302,7 +302,7 @@ class YoloDetector:
|
||||
|
||||
|
||||
def _yolo_worker_main(q_in, q_out, cfg: dict) -> None:
|
||||
"""Spawned process: GPU YOLO only. Never import rclpy here."""
|
||||
"""spawn 出来的子进程:只跑 GPU 上的 YOLO。这里绝不能 import rclpy。"""
|
||||
try:
|
||||
path = resolve_weights(cfg["weights"], cfg.get("backend") or "auto")
|
||||
det = YoloDetector(
|
||||
@@ -321,7 +321,7 @@ def _yolo_worker_main(q_in, q_out, cfg: dict) -> None:
|
||||
item = q_in.get()
|
||||
if item is None:
|
||||
return
|
||||
# Drain to the newest queued frame — never spend GPU on stale images.
|
||||
# 一直排空到队列里最新的帧 —— 绝不把 GPU 浪费在过期图像上。
|
||||
try:
|
||||
while True:
|
||||
newer = q_in.get_nowait()
|
||||
@@ -345,7 +345,7 @@ def _yolo_worker_main(q_in, q_out, cfg: dict) -> None:
|
||||
|
||||
|
||||
def _put_drop_old(q, item) -> None:
|
||||
"""Never block the GPU process on a full result queue."""
|
||||
"""绝不让 GPU 进程因为结果队列满了而阻塞。"""
|
||||
try:
|
||||
q.put_nowait(item)
|
||||
return
|
||||
@@ -362,7 +362,7 @@ def _put_drop_old(q, item) -> None:
|
||||
|
||||
|
||||
class YoloInferProcess:
|
||||
"""YOLO in a spawn() child so ROS callbacks never share the PyTorch GIL."""
|
||||
"""把 YOLO 放进 spawn() 子进程,让 ROS 回调永远不用和 PyTorch 抢 GIL。"""
|
||||
|
||||
def __init__(self, cfg: dict) -> None:
|
||||
self._cfg = dict(cfg)
|
||||
@@ -371,9 +371,9 @@ class YoloInferProcess:
|
||||
|
||||
def _start(self) -> None:
|
||||
ctx = mp.get_context("spawn")
|
||||
# maxsize=2 + worker drains to newest: parent never has to drop frames
|
||||
# (Queue put_nowait/get_nowait races were the source of multi-second
|
||||
# "silent" freezes with the worker alive).
|
||||
# maxsize=2 + worker 排空到最新:父进程永远不需要主动丢帧
|
||||
# (之前 Queue put_nowait/get_nowait 的竞争,正是 worker 明明活着
|
||||
# 却出现数秒“静默”冻结的根源)。
|
||||
self._q_in = ctx.Queue(maxsize=2)
|
||||
self._q_out = ctx.Queue(maxsize=4)
|
||||
self._p = ctx.Process(
|
||||
@@ -386,12 +386,12 @@ class YoloInferProcess:
|
||||
msg = self._q_out.get(timeout=90.0)
|
||||
if not msg or msg[0] != "ready":
|
||||
err = msg[1] if msg and len(msg) > 1 else "worker failed"
|
||||
raise RuntimeError(f"YOLO worker: {err}")
|
||||
raise RuntimeError(f"YOLO 工作进程:{err}")
|
||||
self.weights_path = str(msg[1])
|
||||
self.imgsz = int(self._cfg.get("imgsz") or 416)
|
||||
|
||||
def restart(self) -> None:
|
||||
"""Kill a hung/dead worker and spawn a fresh one (engine reload ~2-5 s)."""
|
||||
"""杀掉卡死 / 挂掉的 worker,再起一个新的(重新加载 engine 约 2-5 s)。"""
|
||||
self.close()
|
||||
self._start()
|
||||
|
||||
@@ -400,19 +400,19 @@ class YoloInferProcess:
|
||||
return bool(self._p is not None and self._p.is_alive())
|
||||
|
||||
def submit(self, image_bgr: np.ndarray, seq: int = 0) -> None:
|
||||
"""Queue the newest frame. If the worker is busy, skip — it drains to
|
||||
the newest queued frame after every infer, so nothing stale runs."""
|
||||
"""把最新一帧排进队列。如果 worker 正忙就跳过 —— 它在每次推理后都会
|
||||
排空到队列里最新的帧,所以不会跑任何过期数据。"""
|
||||
if not self.alive:
|
||||
raise RuntimeError("YOLO worker died")
|
||||
raise RuntimeError("YOLO 工作进程已死亡")
|
||||
try:
|
||||
self._q_in.put((int(seq), image_bgr), timeout=0.010)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def wait_result(self, timeout: float = 0.045):
|
||||
"""Return ``(seq, list[Detection], gpu_ms)`` or ``None``."""
|
||||
"""返回 ``(seq, list[Detection], gpu_ms)`` 或 ``None``。"""
|
||||
if not self.alive:
|
||||
raise RuntimeError("YOLO worker died")
|
||||
raise RuntimeError("YOLO 工作进程已死亡")
|
||||
deadline = time.monotonic() + max(0.004, float(timeout))
|
||||
latest = None
|
||||
while time.monotonic() < deadline:
|
||||
@@ -456,7 +456,7 @@ class YoloInferProcess:
|
||||
return seq, dets, gpu_ms
|
||||
|
||||
def infer(self, image_bgr: np.ndarray, *, half: bool = False, seq: int = 0):
|
||||
"""Compat: submit + wait. Prefer submit/wait_result for pipelining."""
|
||||
"""兼容接口:提交 + 等待。要做流水线的话优先用 submit/wait_result。"""
|
||||
del half
|
||||
self.submit(image_bgr, seq)
|
||||
got = self.wait_result(timeout=0.08)
|
||||
@@ -476,7 +476,7 @@ class YoloInferProcess:
|
||||
self._p.terminate()
|
||||
self._p.join(timeout=1.0)
|
||||
if self._p.is_alive():
|
||||
self._p.kill() # stuck in a CUDA call — SIGKILL
|
||||
self._p.kill() # 卡在 CUDA 调用里出不来 —— 直接 SIGKILL
|
||||
self._p.join(timeout=1.0)
|
||||
self._p = None
|
||||
|
||||
@@ -507,16 +507,16 @@ def pick_best(
|
||||
min_conf: float = 0.12,
|
||||
min_area: float = 4000.0,
|
||||
) -> Optional[Detection]:
|
||||
"""Keep the current workpiece under fast motion.
|
||||
"""让当前工件在快速运动下也能被持续跟踪。
|
||||
|
||||
Match against a *predicted* previous box with a wide gate. If IoU fails
|
||||
but YOLO still fired above min_conf, keep that detection — do not return
|
||||
None (that was the main source of dropouts while moving).
|
||||
Tiny boxes (logo / 通止规尖) are rejected so the arm does not lock on the mat.
|
||||
用一个较宽的匹配门限去和**预测出的**上一帧检测框做匹配。如果 IoU 不达标、
|
||||
但 YOLO 仍以高于 min_conf 的置信度报出了目标,就保留该检测 —— 不要返回
|
||||
None(那正是运动过程中目标丢失的主要来源)。
|
||||
过小的框(logo / 通止规尖)会被拒掉,避免机械臂锁到垫子上。
|
||||
"""
|
||||
if not dets:
|
||||
return None
|
||||
# Locked track may dip below min_conf while moving (blur). New locks cannot.
|
||||
# 已锁定的跟踪在运动(模糊)时可能掉到 min_conf 以下;但新锁定不允许这样。
|
||||
floor = max(0.06, 0.50 * float(min_conf))
|
||||
usable = [d for d in dets if float(d.conf) >= floor and float(d.area) >= float(min_area)]
|
||||
if not usable:
|
||||
@@ -553,7 +553,7 @@ def pick_best(
|
||||
return s
|
||||
|
||||
best = max(usable, key=score_new)
|
||||
# Fast move: keep a live YOLO even if it jumped far from the last box.
|
||||
# 快速移动:即使 YOLO 报出的框离上一帧很远,也保留这个实时检测。
|
||||
need = max(float(min_conf), float(reacquire_conf))
|
||||
if float(best.conf) >= need:
|
||||
return best
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Optional fallback for DualArmGripperManager(load_dual_arm_gripper_config(path)).
|
||||
# Daily use reads ~/ros2/config/site.yaml via load_from_site().
|
||||
# DualArmGripperManager(load_dual_arm_gripper_config(path)) 的可选兜底配置。
|
||||
# 日常使用通过 load_from_site() 读取 ~/ros2/config/site.yaml。
|
||||
left_arm:
|
||||
robot_sn: "Rizon4-063989"
|
||||
gripper_port: "/dev/ttyUSB0"
|
||||
|
||||
@@ -21,26 +21,26 @@ perception_node:
|
||||
min_conf: 0.12
|
||||
vision_hz: 20.0
|
||||
z_ref_m: 0.30
|
||||
# Large gate + reacquire: fast motion must NOT freeze track
|
||||
# 大跳变门限 + 重新捕获:快速运动绝不能把跟踪卡死
|
||||
jump_px: 280.0
|
||||
jump_z_m: 0.20
|
||||
min_depth_m: 0.05
|
||||
max_depth_m: 2.50
|
||||
object_frame: "tracked_object"
|
||||
camera_frame: "d405_color_optical_frame"
|
||||
# Snappy KF + blend toward raw measurement
|
||||
# 灵敏的 KF + 向原始观测融合
|
||||
kf_q_pos: 0.002
|
||||
kf_q_vel: 0.05
|
||||
kf_r_pos: 0.0008
|
||||
meas_blend: 0.65
|
||||
# Faster infer + tight box follow
|
||||
# 更快的推理 + 更紧的框跟随
|
||||
infer_max_width: 640
|
||||
box_ema_alpha: 0.85
|
||||
miss_clear_frames: 2
|
||||
# Item 4 — adaptive Z depth LPF
|
||||
# 第 4 项 — 自适应 Z 深度低通滤波
|
||||
z_alpha_min: 0.10
|
||||
z_alpha_max: 0.60
|
||||
z_alpha_gain: 2.0
|
||||
feature_mode: "plane" # stereo 3D centroid; box = monocular box-center ray
|
||||
feature_mode: "plane" # stereo 为 3D 质心;box = 单目框中心的射线
|
||||
plane_inlier_m: 0.008
|
||||
plane_band_m: 0.06
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# servo_node defaults. Launch overlays ~/ros2/config/site.yaml → servo:
|
||||
# Do not edit this file for day-to-day tuning.
|
||||
# servo_node 的默认值。launch 会用 ~/ros2/config/site.yaml 的 servo: 覆盖。
|
||||
# 日常调参不要改这个文件。
|
||||
|
||||
servo_node:
|
||||
ros__parameters:
|
||||
@@ -19,7 +19,7 @@ servo_node:
|
||||
enable_control: false
|
||||
max_extrapolate_sec: 0.08
|
||||
|
||||
# Item 1 — lost-target hard stop
|
||||
# 第 1 项 — 丢失目标硬停
|
||||
watchdog_timeout_sec: 0.3
|
||||
lost_timeout_sec: 0.3
|
||||
|
||||
@@ -31,7 +31,7 @@ servo_node:
|
||||
kd_z: 0.45
|
||||
i_limit_xy: 0.03
|
||||
i_limit_z: 0.025
|
||||
# Item 2 — integral separation
|
||||
# 第 2 项 — 积分分离
|
||||
i_sep_pos_m: 0.005
|
||||
i_sep_ang_rad: 0.02
|
||||
|
||||
@@ -41,11 +41,11 @@ servo_node:
|
||||
v_max_z: 0.07
|
||||
a_max: 0.70
|
||||
|
||||
# Item 3 — velocity LPF
|
||||
# 第 3 项 — 速度低通滤波
|
||||
vel_lpf_alpha: 0.40
|
||||
wz_lpf_alpha: 0.40
|
||||
|
||||
# Item 5 — dq sliding window
|
||||
# 第 5 项 — dq 滑动窗口
|
||||
dq_window_size: 5
|
||||
|
||||
min_ee_z_m: 0.20
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch DH PGEA Modbus node. Config from ~/ros2/config/site.yaml gripper:."""
|
||||
"""启动 DH PGEA Modbus 节点。配置来自 ``~/ros2/config/site.yaml`` 的 ``gripper`` 段。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -44,8 +44,8 @@ def _setup(context, *args, **kwargs):
|
||||
),
|
||||
LogInfo(
|
||||
msg=(
|
||||
"[dh_pgea] Flexiv load_gripper stays false — this is DH Modbus, "
|
||||
"not a Flexiv Studio gripper."
|
||||
"[dh_pgea] Flexiv 的 load_gripper 保持 false —— 这里是 DH Modbus,"
|
||||
"不是 Flexiv Studio 夹爪。"
|
||||
)
|
||||
),
|
||||
Node(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch dual DH grippers. Ports come from site.yaml robots[].gripper."""
|
||||
"""启动双路 DH 夹爪。端口来自 ``site.yaml`` 的 ``robots[].gripper``。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,7 +20,7 @@ def generate_launch_description() -> LaunchDescription:
|
||||
[
|
||||
DeclareLaunchArgument("sides", default_value="left,right"),
|
||||
DeclareLaunchArgument("allow_offline", default_value="true"),
|
||||
LogInfo(msg=f"[dual_gripper] site={site} (arm_msgs, IPC Modbus, not Flexiv gripper)"),
|
||||
LogInfo(msg=f"[dual_gripper] site={site}(arm_msgs,IPC Modbus,不是 Flexiv 夹爪)"),
|
||||
Node(
|
||||
package="arm_application",
|
||||
executable="dual_gripper",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch hole_detect on the current-arm color topic."""
|
||||
"""在当前机械臂的彩色话题上启动 hole_detect。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch: perception_node + servo_node + static hand-eye TF.
|
||||
"""启动:perception_node + servo_node + 静态手眼 TF。
|
||||
|
||||
All tunables come from ~/ros2/config/site.yaml (override via launch args if needed).
|
||||
所有可调参数都来自 ``~/ros2/config/site.yaml``(必要时可用 launch 参数覆盖)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,12 +20,12 @@ from launch_ros.actions import Node
|
||||
|
||||
|
||||
def _ws_root() -> Path:
|
||||
# Prefer env from env.sh / load_site_config
|
||||
# 优先使用 env.sh / load_site_config 提供的环境变量
|
||||
for key in ("ROS2_WS", "SITE_ROS2_ROOT"):
|
||||
v = os.environ.get(key, "").strip()
|
||||
if v:
|
||||
return Path(v)
|
||||
# launch file lives in install/.../share/arm_application/launch or src/...
|
||||
# launch 文件可能位于 install/.../share/arm_application/launch 或 src/...
|
||||
here = Path(__file__).resolve()
|
||||
for p in [here.parents[i] for i in range(2, 8)]:
|
||||
if (p / "config" / "site.yaml").is_file():
|
||||
@@ -46,7 +46,7 @@ def _load_site():
|
||||
|
||||
cfg = os.environ.get("SITE_CONFIG", "").strip()
|
||||
path = Path(cfg).expanduser() if cfg else site_path()
|
||||
# site_path() uses script location; force workspace root file
|
||||
# site_path() 依赖脚本所在位置;这里强制使用工作区根目录下的文件
|
||||
if not path.is_file():
|
||||
path = root / "config" / "site.yaml"
|
||||
data = load_site(path)
|
||||
@@ -97,7 +97,7 @@ def _launch_setup(context, *args, **kwargs):
|
||||
share = Path(get_package_share_directory("arm_application"))
|
||||
site, site_path, perc_over, servo_over, robot = _load_site()
|
||||
|
||||
# Launch args override site when explicitly passed (non-empty / non-default handled below)
|
||||
# launch 参数显式传入时覆盖 site 配置(空值 / 默认值的处理见下)
|
||||
handeye = LaunchConfiguration("handeye_file").perform(context).strip()
|
||||
site_he = str(servo_over.get("handeye_file") or (site.get("paths") or {}).get("handeye_file") or "")
|
||||
if not handeye or handeye in (
|
||||
@@ -107,8 +107,8 @@ def _launch_setup(context, *args, **kwargs):
|
||||
handeye = site_he or handeye
|
||||
if not Path(handeye).is_file():
|
||||
raise FileNotFoundError(
|
||||
f"hand-eye result missing: {handeye}. "
|
||||
"Re-run: bash ~/ros2/scripts/run_handeye.sh calibrate"
|
||||
f"手眼标定结果缺失:{handeye}。"
|
||||
"请重新运行:bash ~/ros2/scripts/run_handeye.sh calibrate"
|
||||
)
|
||||
|
||||
enable = LaunchConfiguration("enable_control").perform(context).lower() in (
|
||||
@@ -197,8 +197,8 @@ def _launch_setup(context, *args, **kwargs):
|
||||
),
|
||||
LogInfo(msg=f"[site] YOLO weights: {weights}"),
|
||||
LogInfo(
|
||||
msg="nodes: perception_node (YOLO) | servo_node (100Hz) — "
|
||||
"separate processes, topic /visual_servo/filtered_point"
|
||||
msg="节点:perception_node(YOLO)| servo_node(100Hz)—— "
|
||||
"各自独立进程,话题 /visual_servo/filtered_point"
|
||||
),
|
||||
]
|
||||
pub_tf = LaunchConfiguration("publish_handeye_tf").perform(context).lower() in (
|
||||
@@ -206,7 +206,7 @@ def _launch_setup(context, *args, **kwargs):
|
||||
)
|
||||
if pub_tf:
|
||||
nodes.append(
|
||||
LogInfo(msg=f"static hand-eye {ee_frame} → {cam_frame} t=[{tx:.3f},{ty:.3f},{tz:.3f}]")
|
||||
LogInfo(msg=f"静态手眼 {ee_frame} → {cam_frame} t=[{tx:.3f},{ty:.3f},{tz:.3f}]")
|
||||
)
|
||||
nodes.append(static_tf)
|
||||
nodes.extend([perception, servo])
|
||||
|
||||
@@ -55,28 +55,28 @@ setup(
|
||||
'move_to_pose_client = arm_application.move_to_pose_client:main',
|
||||
'run_joint_trajectory = arm_application.run_joint_trajectory:main',
|
||||
'goto_ee_plane = arm_application.goto_ee_plane:main',
|
||||
# Camera
|
||||
# 相机
|
||||
'realsense_color_pub = arm_application.realsense_color_pub:main',
|
||||
'realsense_view = arm_application.realsense_view:main',
|
||||
# Hand-eye
|
||||
# 手眼标定
|
||||
'handeye_teach = arm_application.handeye_teach:main',
|
||||
'handeye_waypoints = arm_application.handeye_waypoints:main',
|
||||
'handeye_calibrate = arm_application.handeye_calibrate:main',
|
||||
# Visual servo (current stack)
|
||||
# 视觉伺服(当前主链路)
|
||||
'perception_node = arm_application.perception_node:main',
|
||||
'servo_node = arm_application.servo_node:main',
|
||||
# Gripper
|
||||
# 夹爪
|
||||
'dh_pgea = arm_application.dh_pgea_node:main',
|
||||
'dh_pgea_cli = arm_application.dh_pgea:main',
|
||||
'dual_gripper = arm_application.dual_gripper_node:main',
|
||||
'dual_gripper_cli = arm_application.dual_gripper_cli:main',
|
||||
# Hole detect (no insert motion)
|
||||
# 入孔检测(只检测,不做插入动作)
|
||||
'hole_detect = arm_application.hole_detect_node:main',
|
||||
# Stage-2 one-shot hole localization (k1/k2) + gauge alignment
|
||||
# 第二阶段:一次性孔定位(k1/k2)+ 对规
|
||||
'stage2_locate = arm_application.stage2_locate_node:main',
|
||||
# Stage-3 compliant hole search (Orisys tactile spiral + insert)
|
||||
# 第三阶段:柔顺找孔(Orisys 触觉螺旋 + 插入)
|
||||
'stage3_search = arm_application.stage3_search_node:main',
|
||||
# Three-stage assembly pipeline (track → locate → search, one process)
|
||||
# 三段式装配流水线(跟踪 → 定位 → 找孔,单进程)
|
||||
'assembly_pipeline = arm_application.pipeline_node:main',
|
||||
'tactile = arm_application.tactile_node:main',
|
||||
'tactile_cli = arm_application.tactile_cli:main',
|
||||
|
||||
Reference in New Issue
Block a user