注释与提示信息全面中文化:源码 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:
arm_dev
2026-09-23 17:40:55 +08:00
parent d78e66ce98
commit 72353f3773
115 changed files with 1758 additions and 1573 deletions
+28 -28
View File
@@ -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:
+46 -46
View File
@@ -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
+114 -116
View File
@@ -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"
+5 -5
View File
@@ -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
+6 -6
View File
@@ -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
+3 -3
View File
@@ -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])
+8 -8
View File
@@ -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',
+3 -3
View File
@@ -1,12 +1,12 @@
# Site defaults for Flexiv Rizon 4 (SN: Rizon4-063989)
# Flexiv Rizon 4 的站点默认值(SN: Rizon4-063989)
robot_sn: Rizon4-063989
rizon_type: Rizon4
# Flexiv Studio gripper (GN01) — keep false. DH PGEA uses run_gripper.sh.
# Flexiv Studio 夹爪(GN01)—— 保持 false。DH PGEA 使用 run_gripper.sh。
load_gripper: false
use_fake_hardware: false
start_rviz: true
# Official Flexiv joint naming: Rizon4-063989_joint1 ... joint7
# Flexiv 官方关节命名:Rizon4-063989_joint1 ... joint7
joint_prefix: Rizon4-063989_
base_frame: base_link
+9 -9
View File
@@ -7,8 +7,8 @@ from ament_index_python.packages import get_package_share_directory, PackageNotF
import os
# Site robot — DH PGEA is a separate Modbus node (run_gripper.sh).
# Keep Flexiv load_gripper false (that flag is Flexiv-GN01 in Studio).
# 现场机械臂 —— DH PGEA 是独立的 Modbus 节点(run_gripper.sh)。
# 保持 Flexiv 的 load_gripper 为 false(该开关在 Studio 里对应 Flexiv-GN01)。
SITE_ROBOT_SN = os.environ.get('SITE_ROBOT_SN', 'Rizon4-063989')
@@ -21,7 +21,7 @@ def _launch_setup(context, *args, **kwargs):
if not robot_sn:
return [
LogInfo(msg=f'[arm_bringup] ERROR: robot_sn is required, e.g. robot_sn:={SITE_ROBOT_SN}')
LogInfo(msg=f'[arm_bringup] 错误:必须提供 robot_sn,例如 robot_sn:={SITE_ROBOT_SN}')
]
try:
@@ -30,8 +30,8 @@ def _launch_setup(context, *args, **kwargs):
return [
LogInfo(
msg=(
'[arm_bringup] flexiv_bringup not found. '
'Run scripts/fetch_flexiv.sh and build, or use sim.launch.py for mock.'
'[arm_bringup] 找不到 flexiv_bringup。'
'请运行 scripts/fetch_flexiv.sh 并编译,或改用 sim.launch.py 跑 mock。'
)
)
]
@@ -39,7 +39,7 @@ def _launch_setup(context, *args, **kwargs):
return [
LogInfo(
msg=(
f'[arm_bringup] Starting Flexiv {rizon_type} ({robot_sn}), '
f'[arm_bringup] 启动 Flexiv {rizon_type}({robot_sn}),'
f'load_gripper={load_gripper}'
)
),
@@ -61,16 +61,16 @@ def _launch_setup(context, *args, **kwargs):
def generate_launch_description():
"""Real Rizon4 bringup via official flexiv_bringup (RDK v1 / humble-v1)."""
"""通过官方 flexiv_bringup 启动真机 Rizon4(RDK v1 / humble-v1)。"""
return LaunchDescription([
DeclareLaunchArgument(
'robot_sn',
default_value=SITE_ROBOT_SN,
description='Site robot serial number (no spaces)',
description='现场机械臂序列号(不能有空格)',
),
DeclareLaunchArgument('rizon_type', default_value='Rizon4'),
DeclareLaunchArgument('use_fake_hardware', default_value='false'),
# Flexiv-GN01 only. DH PGEA: bash ~/ros2/scripts/run_gripper.sh
# 仅适用于 Flexiv-GN01。DH PGEA 用: bash ~/ros2/scripts/run_gripper.sh
DeclareLaunchArgument('load_gripper', default_value='false'),
DeclareLaunchArgument('start_rviz', default_value='true'),
OpaqueFunction(function=_launch_setup),
+2 -2
View File
@@ -7,7 +7,7 @@ from launch_ros.substitutions import FindPackageShare
def generate_launch_description():
"""Offline mock bringup for Flexiv Rizon 4 (7-DOF), no real robot required."""
"""Flexiv Rizon 4(7 自由度)的离线 mock 启动,无需真机。"""
use_rviz = LaunchConfiguration('use_rviz')
use_moveit = LaunchConfiguration('use_moveit')
robot_sn = LaunchConfiguration('robot_sn')
@@ -39,7 +39,7 @@ def generate_launch_description():
DeclareLaunchArgument(
'robot_sn',
default_value='Rizon4-063989',
description='Site SN; joints become Rizon4-063989_joint1..joint7',
description='现场 SN;关节名会变成 Rizon4-063989_joint1..joint7',
),
mock,
moveit,
+17 -17
View File
@@ -1,6 +1,6 @@
"""Unified stack launch: backend:=mock|moveit|real|isaac
"""统一的功能栈启动:backend:=mock|moveit|real|isaac
Application interfaces stay the same across backends:
各后端下应用接口保持不变:
/set_named_pose, /get_arm_state, /arm_status, /follow_joint_trajectory
"""
@@ -82,9 +82,9 @@ def _setup(context, *args, **kwargs):
actions.append(
LogInfo(
msg=(
'[arm_bringup] MoveIt on top of mock. '
'Named poses still via /set_named_pose; '
'MoveIt plans execute through trajectory_bridge.'
'[arm_bringup] 在 mock 之上叠加 MoveIt。'
'命名位姿仍走 /set_named_pose;'
'MoveIt 的规划通过 trajectory_bridge 执行。'
)
)
)
@@ -96,9 +96,9 @@ def _setup(context, *args, **kwargs):
actions.append(
LogInfo(
msg=(
'[arm_bringup] flexiv_bringup missing. '
'Run: ./scripts/fetch_flexiv.sh && rebuild. '
'Falling back tip: use backend:=mock for now.'
'[arm_bringup] 缺少 flexiv_bringup。'
'请运行: ./scripts/fetch_flexiv.sh && 重新编译。'
'临时退回建议:先用 backend:=mock。'
)
)
)
@@ -119,7 +119,7 @@ def _setup(context, *args, **kwargs):
}.items(),
)
)
# Keep application services on top of real HW when possible
# 尽量把应用服务叠在真机之上
actions.append(
Node(
package='arm_control',
@@ -167,9 +167,9 @@ def _setup(context, *args, **kwargs):
actions.append(
LogInfo(
msg=(
'[arm_bringup] REAL mode: flexiv_bringup + named_pose_server '
'+ arm_status_node. '
'Ensure RDK is enabled and network reaches the robot.'
'[arm_bringup] REAL 模式:flexiv_bringup + named_pose_server '
'+ arm_status_node。'
'请确保 RDK 已使能,且网络能连到机械臂。'
)
)
)
@@ -178,17 +178,17 @@ def _setup(context, *args, **kwargs):
actions.append(
LogInfo(
msg=(
'[arm_bringup] ISAAC mode: mock ROS stack is up for bridge testing. '
'Start Isaac Sim + flexiv_sim_plugin separately '
'(see docs/INTERFACES.md). '
'Sync joints via /joint_states or RDK bridge topics.'
'[arm_bringup] ISAAC 模式:已起 mock ROS 栈用于桥接测试。'
'请另行启动 Isaac Sim + flexiv_sim_plugin '
'(见 docs/INTERFACES.md)。'
'通过 /joint_states 或 RDK 桥接话题同步关节。'
)
)
)
if backend not in ('mock', 'moveit', 'real', 'isaac'):
actions.append(
LogInfo(msg=f'[arm_bringup] Unknown backend "{backend}". Use mock|moveit|real|isaac')
LogInfo(msg=f'[arm_bringup] 未知 backend "{backend}"。可用 mock|moveit|real|isaac')
)
return actions
+1 -1
View File
@@ -1 +1 @@
"""Package init for arm_control."""
"""arm_control 的包初始化。"""
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Publish ArmStatus + GetArmState from joint_states, TF, and Flexiv tcp_pose."""
"""根据 joint_states、TF 与 Flexiv 的 tcp_pose 发布 ArmStatus,并提供 GetArmState 服务。"""
from __future__ import annotations
@@ -43,7 +43,7 @@ class ArmStatusNode(Node):
self.create_timer(1.0 / rate, self._tick)
self.get_logger().info(
f'ArmStatus: base={self.base_frame} ee={self.ee_frame} '
f'tcp_topic={self.tcp_pose_topic or "(none)"}'
f'tcp_topic={self.tcp_pose_topic or "(无)"}'
)
def _on_js(self, msg: JointState) -> None:
@@ -55,7 +55,7 @@ class ArmStatusNode(Node):
self._last_tcp = msg
def _ee(self) -> Optional[PoseStamped]:
# Prefer Flexiv-published TCP pose (reliable on real stack)
# 优先用 Flexiv 发布的 TCP 位姿(真机栈上更可靠)
if self._last_tcp is not None:
return self._last_tcp
try:
@@ -101,7 +101,7 @@ class ArmStatusNode(Node):
and abs(s.ee_pose.pose.position.y) < 1e-9
and abs(s.ee_pose.pose.position.z) < 1e-9
):
response.message = 'ok (ee pose unavailable — check TF/tcp_pose)'
response.message = 'ok(末端位姿不可用 —— 请检查 TF/tcp_pose)'
else:
response.message = 'ok'
return response
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Service server: SetNamedPose -> FollowJointTrajectory (backend-agnostic)."""
"""服务端:SetNamedPose -> FollowJointTrajectory(与底层后端无关)。"""
from __future__ import annotations
@@ -65,18 +65,18 @@ class NamedPoseServer(Node):
SetNamedPose, 'set_named_pose', self._on_set, callback_group=self._cb
)
self.get_logger().info(
f'Named poses {list(self.poses)} joints={self.joint_names}'
f'命名位姿 {list(self.poses)} joints={self.joint_names}'
)
def _on_set(self, request: SetNamedPose.Request, response: SetNamedPose.Response):
name = request.pose_name
if name not in self.poses:
response.success = False
response.message = f'Unknown pose "{name}". Available: {list(self.poses)}'
response.message = f'未知位姿"{name}"。可用: {list(self.poses)}'
return response
if not self._client.wait_for_server(timeout_sec=3.0):
response.success = False
response.message = 'follow_joint_trajectory action not available'
response.message = 'follow_joint_trajectory action 不可用'
return response
goal = FollowJointTrajectory.Goal()
@@ -87,30 +87,30 @@ class NamedPoseServer(Node):
goal.time_from_start = [duration]
goal.velocity_scale = float(request.velocity_scale)
self.get_logger().info(f'-> pose [{name}] T={duration:.2f}s')
self.get_logger().info(f'-> 位姿 [{name}] T={duration:.2f}s')
send_fut = self._client.send_goal_async(goal)
if not _wait_future(send_fut, 5.0):
response.success = False
response.message = 'Timed out waiting for goal accept'
response.message = '等待目标被接受超时'
return response
handle = send_fut.result()
if handle is None or not handle.accepted:
response.success = False
response.message = 'Goal rejected'
response.message = '目标被拒绝'
return response
res_fut = handle.get_result_async()
if not _wait_future(res_fut, 60.0):
response.success = False
response.message = 'Timed out waiting for trajectory result'
response.message = '等待轨迹结果超时'
return response
wrapped = res_fut.result()
if wrapped is None:
response.success = False
response.message = 'Empty result'
response.message = '结果为空'
return response
result = wrapped.result
response.success = bool(result.success)
response.message = result.message or f'Moved to {name}'
response.message = result.message or f'已移动到位姿 {name}'
return response
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Expose arm_msgs/FollowJointTrajectory for apps; drive flexiv JTC (control_msgs)."""
"""对应用侧暴露 arm_msgs/FollowJointTrajectory;实际驱动 Flexiv JTC(control_msgs)。"""
from __future__ import annotations
@@ -45,19 +45,19 @@ class RealTrajectoryAdapter(Node):
cancel_callback=lambda _g: CancelResponse.ACCEPT,
callback_group=self._cb,
)
self.get_logger().info(f'Real adapter {arm} -> {ctrl}')
self.get_logger().info(f'真机适配器 {arm} -> {ctrl}')
def _execute(self, goal_handle):
result = ArmFJT.Result()
req = goal_handle.request
if not req.joint_names or req.num_points < 1:
result.success = False
result.message = 'Invalid goal'
result.message = '目标非法'
goal_handle.abort()
return result
if not self._ctrl.wait_for_server(timeout_sec=5.0):
result.success = False
result.message = f'Controller action not available'
result.message = f'控制器 action 不可用'
goal_handle.abort()
return result
@@ -72,8 +72,8 @@ class RealTrajectoryAdapter(Node):
pt = JointTrajectoryPoint()
pt.positions = pos[idx : idx + n_j]
idx += n_j
# Point-to-point named poses must end at rest — nonzero end vel
# + open_loop JTC makes the arm coast / shake after arrival.
# 点到点的命名位姿必须在静止状态下结束 —— 末速度非零
# 再叠加开环 JTC,会让机械臂到位后滑行/抖动。
pt.velocities = [0.0] * n_j
pt.accelerations = [0.0] * n_j
sec = int(t)
@@ -86,13 +86,13 @@ class RealTrajectoryAdapter(Node):
send = self._ctrl.send_goal_async(ctrl_goal)
if not _wait(send, 5.0):
result.success = False
result.message = 'Timed out sending to controller'
result.message = '发送给控制器超时'
goal_handle.abort()
return result
handle = send.result()
if handle is None or not handle.accepted:
result.success = False
result.message = 'Controller rejected goal'
result.message = '控制器拒绝了目标'
goal_handle.abort()
return result
traj_duration = 0.0
@@ -102,13 +102,13 @@ class RealTrajectoryAdapter(Node):
res_fut = handle.get_result_async()
if not _wait(res_fut, max(120.0, traj_duration * 3.0)):
result.success = False
result.message = 'Timed out waiting for controller'
result.message = '等待控制器超时'
goal_handle.abort()
return result
ctrl_res = res_fut.result().result
ok = ctrl_res.error_code == CtrlFJT.Result.SUCCESSFUL
result.success = ok
result.message = 'OK' if ok else f'controller error_code={ctrl_res.error_code}'
result.message = 'OK' if ok else f'控制器 error_code={ctrl_res.error_code}'
if ok:
goal_handle.succeed()
else:
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Bridge control_msgs/FollowJointTrajectory -> arm_msgs/FollowJointTrajectory."""
"""把 control_msgs/FollowJointTrajectory 桥接到 arm_msgs/FollowJointTrajectory。"""
from __future__ import annotations
@@ -44,7 +44,7 @@ class TrajectoryBridge(Node):
cancel_callback=lambda _g: CancelResponse.ACCEPT,
callback_group=self._cb,
)
self.get_logger().info(f'Bridge {ctrl} -> {arm}')
self.get_logger().info(f'桥接 {ctrl} -> {arm}')
def _execute(self, goal_handle):
result = CtrlFJT.Result()
+4 -4
View File
@@ -5,13 +5,13 @@ controller_manager:
joint_state_broadcaster:
type: joint_state_broadcaster/JointStateBroadcaster
# Name aligned with Flexiv official bringup
# 名字与 Flexiv 官方 bringup 对齐
rizon_arm_controller:
type: joint_trajectory_controller/JointTrajectoryController
rizon_arm_controller:
ros__parameters:
# Site robot Rizon4-063989 (official flexiv_description keeps hyphen)
# 站点机器人 Rizon4-063989(官方 flexiv_description 保留连字符)
joints:
- Rizon4-063989_joint1
- Rizon4-063989_joint2
@@ -26,8 +26,8 @@ rizon_arm_controller:
- position
- velocity
allow_partial_joints_goal: false
# Must be false: true interpolates from the last *command* (already at target
# after a "success" with no hardware motion) so the arm never moves.
# 必须为 false:若为 true,会从最后一次 *命令* 开始插值(在硬件没动
# 却报"成功"之后,命令已经落在目标点上),于是机械臂永远不动。
open_loop_control: false
constraints:
stopped_velocity_tolerance: 0.05
+2 -2
View File
@@ -1,5 +1,5 @@
# Named poses — site robot Rizon4-063989 (7-DOF), radians
# Joint base names match flexiv_description: joint1..joint7
# 命名位姿 — 站点机器人 Rizon4-063989(7 自由度),弧度
# 关节基础名与 flexiv_description 一致:joint1..joint7
named_pose_server:
ros__parameters:
joint_prefix: "Rizon4-063989_"
+2 -2
View File
@@ -1,5 +1,5 @@
# Right arm 91 (Rizon4-063991) named poses (radians).
# Starting values match the generic home; teach cell-specific poses on 91.
# 右臂 91(Rizon4-063991)的命名位姿(弧度)。
# 起始值与通用 home 一致;在 91 上示教该工位专用的位姿。
named_pose_server:
ros__parameters:
joint_prefix: "Rizon4-063991_"
@@ -8,7 +8,7 @@ from launch_ros.actions import Node
from launch_ros.parameter_descriptions import ParameterValue
from launch_ros.substitutions import FindPackageShare
# Site robot. Official flexiv_description keeps the hyphen in the SN prefix.
# 现场机械臂。官方 flexiv_description 要求 SN 前缀里**保留连字符**。
SITE_ROBOT_SN = os.environ.get('SITE_ROBOT_SN', 'Rizon4-063989')
SITE_JOINT_PREFIX = os.environ.get('SITE_JOINT_PREFIX', f'{SITE_ROBOT_SN}_')
SITE_NAMED_POSES = os.environ.get('SITE_NAMED_POSES_FILE', '').strip()
+10 -10
View File
@@ -2,11 +2,11 @@
<robot xmlns:xacro="http://www.ros.org/wiki/xacro">
<!--
Lightweight 7-DOF kinematic stand-in for Flexiv Rizon 4.
For accurate meshes/kinematics, install official flexiv_description
and launch via arm_bringup (uses flexiv_description / flexiv_bringup).
Joint names follow Flexiv convention: {prefix}joint1 .. {prefix}joint7
where prefix is typically robot_sn with '-' replaced by '_', e.g. Rizon4_123456_
Flexiv Rizon 4 的轻量级 7 自由度运动学替身。
若需要精确的网格 / 运动学,请安装官方 flexiv_description,
并通过 arm_bringup 启动(使用 flexiv_description / flexiv_bringup)。
关节命名遵循 Flexiv 约定:{prefix}joint1 .. {prefix}joint7,
其中 prefix 通常是 robot_sn 把 '-' 换成 '_',例如 Rizon4_123456_
-->
<xacro:macro name="arm_inertial_box" params="mass x y z">
@@ -41,7 +41,7 @@
</collision>
</xacro:macro>
<!-- Approximate link lengths (m); replace with official URDF for production -->
<!-- 近似的连杆长度(m);生产使用请替换为官方 URDF -->
<xacro:property name="L1" value="0.15"/>
<xacro:property name="L2" value="0.36"/>
<xacro:property name="L3" value="0.12"/>
@@ -65,7 +65,7 @@
<xacro:arm_inertial_box mass="3.0" x="0.18" y="0.18" z="0.12"/>
</link>
<!-- joint1: base yaw (±165°) -->
<!-- joint1:底座偏航(±165°) -->
<joint name="${prefix}joint1" type="revolute">
<parent link="${prefix}base_link"/>
<child link="${prefix}link1"/>
@@ -155,7 +155,7 @@
<xacro:arm_inertial_box mass="0.5" x="0.06" y="0.06" z="${L6}"/>
</link>
<!-- joint7: flange yaw -->
<!-- joint7:法兰偏航 -->
<joint name="${prefix}joint7" type="revolute">
<parent link="${prefix}link6"/>
<child link="${prefix}link7"/>
@@ -177,7 +177,7 @@
<xacro:arm_inertial_box mass="0.3" x="0.07" y="0.07" z="${L7}"/>
</link>
<!-- TCP / flange (Flexiv uses flange + tcp frames) -->
<!-- TCP / 法兰(Flexiv 使用 flange + tcp 两个坐标系) -->
<joint name="${prefix}flange_joint" type="fixed">
<parent link="${prefix}link7"/>
<child link="${prefix}flange"/>
@@ -192,7 +192,7 @@
</joint>
<link name="${prefix}tcp"/>
<!-- Alias used by our application layer -->
<!-- 应用层使用的别名 -->
<joint name="${prefix}tool0_joint" type="fixed">
<parent link="${prefix}tcp"/>
<child link="${prefix}tool0"/>
@@ -8,7 +8,7 @@
<plugin>mock_components/GenericSystem</plugin>
</xacro:if>
<xacro:unless value="${use_fake_hardware}">
<!-- Official Flexiv hardware plugin (requires flexiv_hardware) -->
<!-- 官方 Flexiv 硬件插件(需要 flexiv_hardware) -->
<plugin>flexiv_hardware/FlexivHardwareInterface</plugin>
</xacro:unless>
</hardware>
+10 -10
View File
@@ -1,14 +1,14 @@
"""Device contract layer for the robotic arm stack.
"""机械臂栈的设备契约层。
Public entry points::
公开入口::
from arm_interfaces import load_device_stack, check_requirement
stack = load_device_stack() # raises ContractError with a fix hint
check_requirement(stack, "handeye") # raises CapabilityError if unmet
stack = load_device_stack() # 失败时抛出带修复提示的 ContractError
check_requirement(stack, "handeye") # 能力不满足时抛出 CapabilityError
Everything device-selection related funnels through here so that the rest of the
framework never reads ``devices/`` or guesses at ``site.yaml`` structure itself.
所有与设备选型相关的事都汇集到这里,框架其余部分因此永远不必自己读
``devices/``,也不必猜测 ``site.yaml`` 的结构。
"""
from .capabilities import (
@@ -42,7 +42,7 @@ from .registry import Registry, list_available_brands, load_registry
__version__ = "0.1.0"
__all__ = [
# loading
# 加载
"load_device_stack",
"load_registry",
"load_manifest",
@@ -54,18 +54,18 @@ __all__ = [
"effective_capabilities",
"list_available_brands",
"manifest_path",
# types
# 类型
"DeviceStack",
"Manifest",
"Registry",
# capabilities
# 能力
"ALL_CAPABILITIES",
"CAPABILITIES_BY_KIND",
"REQUIREMENTS",
"ROS_INTERFACE",
"capabilities_for_kind",
"describe",
# errors
# 错误
"ContractError",
"RegistryError",
"ManifestError",
@@ -1,23 +1,19 @@
"""Capability vocabulary and the requirement sets upper layers depend on.
"""能力词表,以及上层依赖的能力需求集。
Two distinct ideas live here, and keeping them separate matters:
这里有彼此不同的两个概念,把它们分开很重要:
``Capability``
What a *driver* can do. Declared by each brand's ``manifest.yaml``. Adding a
new brand never changes this file — only edit it when there is genuinely a
new kind of motion or sensing to express.
*驱动*能做什么。由每个品牌的 ``manifest.yaml`` 声明。新增品牌从不改动
本文件——只有当确实要表达一种新的运动或感知方式时才编辑它。
``Requirement``
What an *upper layer* needs. A flow (tracking, hand-eye calibration, the
through-gauge assembly state machine) declares the set of capabilities it
will call. The contract layer checks the selected drivers satisfy it at
startup, so a missing capability fails immediately with a clear message
instead of failing mid-motion.
*上层*需要什么。一个流程(视觉跟踪、手眼标定、通止规装配状态机)声明
它将要调用的能力集合。契约层在启动时检查所选驱动是否满足它,因此缺失
能力会立刻报出清晰的消息,而不是在运动中途才失败。
The ``ROS_INTERFACE`` table maps each capability to the concrete ROS 2 endpoint
that implements it. It exists so error messages can name the interface an
operator should check, and so future drivers know exactly what to publish
or serve.
``ROS_INTERFACE`` 表把每项能力映射到实现它的具体 ROS 2 端点。它的存在,
一是让错误消息能点名操作员该检查哪个接口,二是让未来的驱动清楚自己该
发布或提供什么。
"""
from __future__ import annotations
@@ -26,17 +22,17 @@ from typing import Dict, FrozenSet, NamedTuple, Tuple
class CapabilityInfo(NamedTuple):
"""Documentation for one capability."""
"""对某一项能力的说明。"""
owner: str
"""Which device kind provides it: robot | gripper | sensor | camera."""
"""由哪类设备提供:robot | gripper | sensor | camera。"""
summary: str
"""One-line description of what the capability does."""
"""一句话说明该能力做什么。"""
# ---------------------------------------------------------------------------
# Robot capabilities
# 机器人能力
# ---------------------------------------------------------------------------
ROBOT_CAPABILITIES: Dict[str, CapabilityInfo] = {
"get_arm_state": CapabilityInfo(
@@ -72,7 +68,7 @@ ROBOT_CAPABILITIES: Dict[str, CapabilityInfo] = {
}
# ---------------------------------------------------------------------------
# Gripper capabilities
# 夹爪能力
# ---------------------------------------------------------------------------
GRIPPER_CAPABILITIES: Dict[str, CapabilityInfo] = {
"open": CapabilityInfo("gripper", "张开。"),
@@ -85,7 +81,7 @@ GRIPPER_CAPABILITIES: Dict[str, CapabilityInfo] = {
}
# ---------------------------------------------------------------------------
# Sensor capabilities
# 传感器能力
# ---------------------------------------------------------------------------
SENSOR_CAPABILITIES: Dict[str, CapabilityInfo] = {
"read_tangential": CapabilityInfo(
@@ -100,7 +96,7 @@ SENSOR_CAPABILITIES: Dict[str, CapabilityInfo] = {
}
# ---------------------------------------------------------------------------
# Camera capabilities
# 相机能力
# ---------------------------------------------------------------------------
CAMERA_CAPABILITIES: Dict[str, CapabilityInfo] = {
"color": CapabilityInfo("camera", "彩色图。推理与跟踪依赖它。"),
@@ -116,7 +112,7 @@ CAMERA_CAPABILITIES: Dict[str, CapabilityInfo] = {
"set_exposure": CapabilityInfo("camera", "设置曝光。跟踪需要限制曝光避免运动拖影。"),
}
#: Every capability name known to the framework, tagged with its device kind.
#: 框架已知的全部能力名,并标注其设备类别。
ALL_CAPABILITIES: Dict[str, CapabilityInfo] = {
**ROBOT_CAPABILITIES,
**GRIPPER_CAPABILITIES,
@@ -124,7 +120,7 @@ ALL_CAPABILITIES: Dict[str, CapabilityInfo] = {
**CAMERA_CAPABILITIES,
}
#: Device kind -> the capability names that kind is allowed to declare.
#: 设备类别 -> 该类设备允许声明的能力名。
CAPABILITIES_BY_KIND: Dict[str, FrozenSet[str]] = {
"robot": frozenset(ROBOT_CAPABILITIES),
"gripper": frozenset(GRIPPER_CAPABILITIES),
@@ -134,10 +130,10 @@ CAPABILITIES_BY_KIND: Dict[str, FrozenSet[str]] = {
# ---------------------------------------------------------------------------
# ROS 2 interface mapping
# ROS 2 接口映射
# ---------------------------------------------------------------------------
class RosInterface(NamedTuple):
"""The concrete ROS 2 endpoint implementing a capability."""
"""实现某项能力的具体 ROS 2 端点。"""
namespace: str
name: str
@@ -145,10 +141,10 @@ class RosInterface(NamedTuple):
type: str
#: capability -> ROS 2 endpoint. Names are relative to the device namespace and
#: are resolved per-instance (two arms never share a namespace).
#: capability -> ROS 2 端点。名称相对于设备命名空间,且按实例解析
#:(两条机械臂绝不共用命名空间)。
ROS_INTERFACE: Dict[str, RosInterface] = {
# --- robot ---
# --- 机器人 ---
"get_arm_state": RosInterface("", "get_arm_state", "service", "arm_msgs/srv/GetArmState"),
"follow_joint_trajectory": RosInterface(
"", "follow_joint_trajectory", "action", "arm_msgs/action/FollowJointTrajectory"
@@ -164,19 +160,19 @@ ROS_INTERFACE: Dict[str, RosInterface] = {
"enable": RosInterface("", "enable", "service", "std_srvs/srv/SetBool"),
"clear_fault": RosInterface("", "clear_fault", "service", "std_srvs/srv/Trigger"),
"soft_drag": RosInterface("", "soft_drag", "service", "std_srvs/srv/SetBool"),
# --- gripper ---
# --- 夹爪 ---
"open": RosInterface("", "open", "service", "arm_msgs/srv/GripperExecute"),
"close": RosInterface("", "close", "service", "arm_msgs/srv/GripperExecute"),
"move": RosInterface("", "move", "service", "arm_msgs/srv/GripperExecute"),
"rotate": RosInterface("", "rotate", "service", "arm_msgs/srv/GripperExecute"),
"get_status": RosInterface("", "status", "topic", "arm_msgs/msg/GripperStatus"),
# --- sensor ---
# --- 传感器 ---
"read_tangential": RosInterface("", "tangential", "topic", "sensor_msgs/msg/Float32MultiArray"),
"read_normal": RosInterface("", "normal", "topic", "sensor_msgs/msg/Float32"),
"read_depth": RosInterface("", "depth", "topic", "sensor_msgs/msg/Image"),
"read_force_vector": RosInterface("", "force_vector", "topic", "sensor_msgs/msg/Image"),
"measure_baseline": RosInterface("", "measure_baseline", "service", "std_srvs/srv/Trigger"),
# --- camera ---
# --- 相机 ---
"color": RosInterface("", "color/image_raw", "topic", "sensor_msgs/msg/Image"),
"depth": RosInterface("", "depth/image_raw", "topic", "sensor_msgs/msg/Image"),
"aligned_depth_to_color": RosInterface(
@@ -192,10 +188,10 @@ ROS_INTERFACE: Dict[str, RosInterface] = {
# ---------------------------------------------------------------------------
# Requirement sets
# 需求集
# ---------------------------------------------------------------------------
class Requirement(NamedTuple):
"""A named flow and the capabilities it needs, grouped by device kind."""
"""一个具名流程及其所需能力,按设备类别分组。"""
summary: str
robot: Tuple[str, ...]
@@ -261,12 +257,12 @@ REQUIREMENTS: Dict[str, Requirement] = {
def capabilities_for_kind(kind: str) -> FrozenSet[str]:
"""Return the set of capability names valid for a device kind."""
"""返回某类设备合法的能力名集合。"""
return CAPABILITIES_BY_KIND.get(kind, frozenset())
def describe(capability: str) -> str:
"""Human-readable description used in error messages."""
"""用于错误消息的、给人看的说明。"""
info = ALL_CAPABILITIES.get(capability)
if info is None:
return f"{capability}(框架未登记的能力名)"
+9 -9
View File
@@ -1,19 +1,19 @@
"""``device_check`` — validate the device stack from the command line.
"""``device_check`` —— 从命令行校验设备栈。
Usage::
用法::
ros2 run arm_interfaces device_check # load everything, print summary
ros2 run arm_interfaces device_check # 全量加载并打印摘要
ros2 run arm_interfaces device_check --requirement handeye
ros2 run arm_interfaces device_check --json
ros2 run arm_interfaces device_check --list-brands
ros2 run arm_interfaces device_check --no-assets # skip vendor asset checks
ros2 run arm_interfaces device_check --no-assets # 跳过厂商物料检查
Exit codes are meaningful so scripts can branch on them:
退出码有明确含义,脚本可据此分支:
=== ==========================================================
0 contract satisfied
2 contract violation (missing brand, config, asset, capability)
3 contract layer itself broke (unexpected exception)
0 契约满足
2 契约违规(缺少品牌、配置、物料或能力)
3 契约层自身出错(意料之外的异常)
=== ==========================================================
"""
@@ -119,7 +119,7 @@ def main(argv: Optional[List[str]] = None) -> int:
print(format_error(err), file=sys.stderr)
return EXIT_CONTRACT
except Exception as exc: # noqa: BLE001 - the CLI is the last line of defence
except Exception as exc: # noqa: BLE001 - CLI 是最后一道防线
print(
f"契约层内部错误(这本身是框架 bug,请上报): {type(exc).__name__}: {exc}",
file=sys.stderr,
@@ -1,20 +1,19 @@
"""Validate ``config/site.yaml`` against a manifest's declared ``config_schema``.
"""按清单声明的 ``config_schema`` 校验 ``config/site.yaml``。
Two classes of bug motivated this module:
有两类 bug 促成了本模块:
1. **Silent no-ops.** ``run_handeye.sh`` passed five parameters
(``settle_qd_max``, ``pin_holds``, ...) to a node that never declared them.
YAML accepted the values, nothing read them, tuning had no effect.
2. **Unit confusion.** A value typed in millimetres but interpreted as metres
produces a plausible-looking but wrong motion.
1. **静默失效。** ``run_handeye.sh`` 把五个参数
(``settle_qd_max``、``pin_holds``……)传给了一个从未声明它们的节点。
YAML 接受了这些值,却没人读它们,调参毫无效果。
2. **单位混淆。** 按毫米输入却被当成米解释的值,会产生看似合理但错误的
运动。
The guard addresses both: every declared field is checked for presence and type,
and ``unit`` is echoed in error messages so the operator sees the expected unit
next to the value they wrote.
配置守卫同时解决这两点:每个已声明的字段都会检查是否存在、类型是否正确,
并且 ``unit`` 会回显在错误消息里,让操作员在写下的值旁边就能看到期望的单位。
``site_path`` supports a list-expansion token so per-instance config can be
declared once: ``robot.robots[].ip`` resolves to ``robot.robots[0].ip`` and
``robot.robots[1].ip`` and requires *every* instance to supply the value.
``site_path`` 支持列表展开记号,使按实例的配置只需声明一次:
``robot.robots[].ip`` 会解析为 ``robot.robots[0].ip`` 和
``robot.robots[1].ip``,并要求*每一个*实例都提供该值。
"""
from __future__ import annotations
@@ -27,9 +26,8 @@ import yaml
from .errors import BadConfigValueError, MissingConfigError
from .manifest import ConfigField, Manifest
#: Python types accepted for each declared type name. ``bool`` is deliberately
#: excluded from the numeric types: in YAML ``true`` is a bool, and silently
#: using it as 1.0 has caused real bugs.
#: 每个已声明类型名所接受的 Python 类型。``bool`` 被刻意排除在数值类型之外:
#: 在 YAML 里 ``true`` 是布尔值,把它悄悄当作 1.0 用曾引发过真实 bug。
_TYPE_CHECKS = {
"str": (str,),
"int": (int,),
@@ -41,20 +39,19 @@ _TYPE_CHECKS = {
class ResolvedValue(NamedTuple):
"""One concrete value found in ``site.yaml`` for a declared field."""
"""在 ``site.yaml`` 中为某个已声明字段找到的一个具体值。"""
path: str
"""Concrete path, e.g. ``robot.robots[1].ip``."""
"""具体路径,例如 ``robot.robots[1].ip``。"""
value: Any
def load_site(path: Path | None = None) -> Dict[str, Any]:
"""Read ``config/site.yaml``.
"""读取 ``config/site.yaml``。
Returns an empty dict when the file is absent, matching the lenient
behaviour the old ``site_io.load_site_dict`` had — absence is reported by
the guard as missing required config rather than as a parse crash.
文件不存在时返回空字典,与旧 ``site_io.load_site_dict`` 的宽松行为保持
一致——缺失会由配置守卫报告为“缺少必需配置”,而不是解析崩溃。
"""
from . import paths
@@ -66,30 +63,27 @@ def load_site(path: Path | None = None) -> Dict[str, Any]:
def resolve_path(site: Dict[str, Any], site_path: str) -> List[ResolvedValue]:
"""Resolve a (possibly list-expanding) dotted path against parsed YAML.
"""在解析后的 YAML 上解析一个(可能带列表展开的)点分路径。
The ``[]`` token expands a collection into one entry per member, so
per-instance config can be declared once and still require *every* instance
to supply it:
``[]`` 记号会把一个集合展开成每个成员一条记录,因此按实例的配置只需
声明一次,却仍要求*每一个*实例都提供它:
* sequence — ``robot.robots[].sn`` → ``robot.robots[0].sn``, ``...[1].sn``
* mapping — ``cameras[].serial`` → ``cameras.d405_left.serial``, ...
* 序列 — ``robot.robots[].sn`` → ``robot.robots[0].sn``、``...[1].sn``
* 映射 — ``cameras[].serial`` → ``cameras.d405_left.serial``……
Mappings are supported because ``site.yaml`` registers cameras (and other
named devices) as mappings keyed by device name rather than as sequences.
之所以支持映射,是因为 ``site.yaml`` 把相机(以及其他具名设备)登记为
以设备名为键的映射,而不是序列。
Missing branches produce no results; the caller decides whether that is an
error. A *scalar* where a collection is expected (``cameras: 5``) is
reported as a single value resolved at the collection level, so the
type check can flag it instead of the operator being told, misleadingly,
that the value is simply absent.
缺失的分支不产生结果;是否算错误由调用方决定。在本该是集合的位置出现
*标量*(``cameras: 5``)时,会作为在集合层级解析出的单个值上报,让类型
检查能指出问题,而不是误导性地告诉操作员“该值不存在”。
"""
tokens = [t.strip() for t in site_path.split(".") if t.strip()]
return _resolve(site, tokens, "")
def _resolve(node: Any, tokens: List[str], prefix: str) -> List[ResolvedValue]:
"""Recursive worker for :func:`resolve_path`."""
""":func:`resolve_path` 的递归工作函数。"""
if not tokens:
return [ResolvedValue(path=prefix, value=node)]
@@ -122,8 +116,8 @@ def _resolve(node: Any, tokens: List[str], prefix: str) -> List[ResolvedValue]:
if child is None:
return []
# Scalar where a collection was declared. Stop here and let the type check
# produce a precise complaint rather than a misleading "missing".
# 声明为集合的位置却是标量。在这里停下,让类型检查给出精确的报错,
# 而不是误导性的“缺失”。
return [ResolvedValue(path=child_path, value=child)]
@@ -132,17 +126,17 @@ def check_config(
site: Dict[str, Any],
site_file: Optional[Path] = None,
) -> Dict[str, List[ResolvedValue]]:
"""Validate all declared fields of a manifest against parsed site config.
"""按解析后的现场配置校验清单中所有已声明的字段。
Returns a mapping of ``field.key`` to the resolved values, so callers that
need the values anyway do not have to resolve them a second time.
返回 ``field.key`` 到已解析值的映射,这样本来就需要这些值的调用方不必
再解析第二遍。
Raises
------
MissingConfigError
A required field has no value in ``site.yaml``.
某个必需字段在 ``site.yaml`` 中没有值。
BadConfigValueError
A field is present but has the wrong YAML type.
某个字段存在,但字段值的 YAML 类型不对。
"""
resolved: Dict[str, List[ResolvedValue]] = {}
problems: List[str] = []
@@ -156,9 +150,8 @@ def check_config(
problems.append(_missing_message(field))
continue
# A required field with per-instance expansion needs *every* instance
# populated. Reporting only when all were blank would let one
# unconfigured arm slip through.
# 带按实例展开的必需字段需要*每一个*实例都填好。若只在全部为空时
# 才报告,就会放过某一台未配置的机械臂。
if field.required:
blank_paths = [v.path for v in values if _is_blank(v.value)]
if blank_paths:
@@ -204,7 +197,7 @@ def _default_site_file() -> Path:
def _is_blank(value: Any) -> bool:
"""Whether a value should be treated as 'not supplied'."""
"""判断某个值是否应被视为“未提供”。"""
if value is None:
return True
if isinstance(value, str) and not value.strip():
@@ -217,7 +210,7 @@ def _is_blank(value: Any) -> bool:
def _matches_type(value: Any, declared: str) -> bool:
expected = _TYPE_CHECKS.get(declared)
if expected is None:
# Unknown declared type is rejected at manifest load time.
# 未知的已声明类型会在清单加载阶段被拒。
return True
if declared in ("int", "float") and isinstance(value, bool):
return False
@@ -225,11 +218,10 @@ def _matches_type(value: Any, declared: str) -> bool:
def _missing_message(field: ConfigField, blank_paths: Optional[List[str]] = None) -> str:
"""Render one missing-config problem.
"""渲染一条缺失配置问题。
``blank_paths`` lists concrete per-instance paths that were present but
empty (e.g. ``robot.robots[1].sn``), which is far more actionable than
repeating the abstract declaration path.
``blank_paths`` 列出存在但为空的具体按实例路径(例如
``robot.robots[1].sn``),这远比重复抽象的声明路径更具可操作性。
"""
lines = [f" 缺: {field.key}"]
if blank_paths:
+20 -22
View File
@@ -1,13 +1,12 @@
"""Structured contract errors.
"""结构化的契约错误。
Every error carries a ``fix_hint`` that names the exact file and key a human
should edit. This is deliberate: the whole point of the contract layer is to
replace "something silently did nothing" (the old behaviour, where
``run_handeye.sh`` passed five parameters the node never declared) with an
actionable message.
每个错误都带有 ``fix_hint``,明确指出人应该去改哪个文件、哪个键。这是刻意
设计的:契约层的全部意义,就是把“某个东西静默地什么都没做”(旧行为,例如
``run_handeye.sh`` 传给节点的五个参数节点根本不曾声明)替换成一条可执行的
提示。
Callers should format errors with :func:`format_error`, which renders the
hint on its own indented line so it is impossible to miss in a terminal.
调用方应当用 :func:`format_error` 格式化错误,它会把修复提示单独渲染成
一行缩进文本,在终端里绝不会被漏看。
"""
from __future__ import annotations
@@ -16,18 +15,17 @@ from typing import Optional
class ContractError(Exception):
"""Base class for every device-contract failure.
"""所有设备契约失败的基类。
Attributes
----------
what:
One-line description of what is wrong.
一句话说明哪里不对。
fix_hint:
Concrete instruction naming the file and key to edit. ``None`` means
the caller could not determine a remedy, which callers should treat as
a bug in the contract layer rather than user error.
指明要改哪个文件、哪个键的具体指示。``None`` 表示调用方无法确定
补救办法,此时调用方应把它当作契约层的 bug,而不是用户错误。
detail:
Optional extra context (paths tried, values found, ...).
可选的补充上下文(尝试过的路径、读到的值……)。
"""
def __init__(
@@ -41,36 +39,36 @@ class ContractError(Exception):
self.fix_hint = fix_hint
self.detail = detail
def __str__(self) -> str: # pragma: no cover - exercised via format_error
def __str__(self) -> str: # pragma: no cover - 通过 format_error 覆盖
return format_error(self)
class RegistryError(ContractError):
"""``devices/registry.yaml`` is missing, unreadable, or incomplete."""
"""``devices/registry.yaml`` 缺失、不可读或不完整。"""
class ManifestError(ContractError):
"""A per-brand ``manifest.yaml`` is missing, unreadable, or malformed."""
"""某个品牌的 ``manifest.yaml`` 缺失、不可读或格式错误。"""
class MissingAssetError(ContractError):
"""A vendor asset declared as required in a manifest is not on disk."""
"""清单中声明为必需的厂商物料不在磁盘上。"""
class MissingConfigError(ContractError):
"""``config/site.yaml`` does not supply a required declared config key."""
"""``config/site.yaml`` 未提供某个必需的已声明配置键。"""
class CapabilityError(ContractError):
"""An upper layer asked for a capability the selected driver does not declare."""
"""上层请求了所选驱动未声明的能力。"""
class BadConfigValueError(ContractError):
"""A config key exists but its value has the wrong type or shape."""
"""某个配置键存在,但值的类型或结构不对。"""
def format_error(err: ContractError) -> str:
"""Render a contract error as a multi-line, human-actionable message."""
"""把契约错误渲染成多行、可执行的中文提示。"""
lines = [f"设备契约校验失败: {err.what}"]
if err.detail:
for line in str(err.detail).splitlines():
+35 -42
View File
@@ -1,18 +1,16 @@
"""Load the whole device stack and check flows against it.
"""加载整个设备栈,并据此校验各流程。
This is the single entry point the rest of the framework uses. It chains:
这是框架其余部分使用的唯一入口。它依次串联:
1. :mod:`arm_interfaces.registry` — which brand implements each kind
2. :mod:`arm_interfaces.manifest` — that brand's capabilities, assets, config
3. :mod:`arm_interfaces.config_guard` — does ``site.yaml`` supply that config
1. :mod:`arm_interfaces.registry` —— 每一类设备由哪个品牌实现
2. :mod:`arm_interfaces.manifest` —— 该品牌的能力、物料、配置
3. :mod:`arm_interfaces.config_guard` —— ``site.yaml`` 是否提供了那份配置
then exposes :func:`check_requirement`, which verifies that a named flow
(tracking, hand-eye, gauge assembly, ...) only asks for capabilities the
selected drivers actually declare.
然后暴露 :func:`check_requirement`,用来验证某个具名流程
(跟踪、手眼、通止规装配……)只索要所选驱动确实声明了的能力。
The payoff is at startup: instead of discovering on the first motion that the
selected gripper cannot rotate, the run refuses to start and names the missing
capability, the interface it implies, and which manifest to change.
收益体现在启动时:不会等到第一次运动才发现所选夹爪不能旋转,而是直接
拒绝启动,并点出缺失的能力、它对应的接口,以及该改哪个清单。
"""
from __future__ import annotations
@@ -29,31 +27,31 @@ from .registry import REQUIRED_KINDS, Registry, load_registry
class DeviceStack(NamedTuple):
"""Everything about the currently selected hardware."""
"""当前所选硬件的全部信息。"""
registry: Registry
manifests: Dict[str, Manifest]
"""Registry kind -> loaded manifest, e.g. ``{"robots": <Manifest>}``."""
"""选型类别 -> 已加载的清单,例如 ``{"robots": <Manifest>}``。"""
config: Dict[str, List[ResolvedValue]]
"""``f"{kind}.{field.key}"`` -> resolved site.yaml values."""
"""``f"{kind}.{field.key}"`` -> 解析出的 site.yaml 值。"""
site_path: Path
"""The ``config/site.yaml`` that was validated."""
"""被校验过的 ``config/site.yaml``。"""
def manifest(self, kind: str) -> Manifest:
"""Return the manifest for a registry kind."""
"""返回某个选型类别的清单。"""
return self.manifests[kind]
def capabilities(self, kind: str) -> Set[str]:
"""Effective capabilities of the selected driver for a kind."""
"""某类设备所选驱动的有效能力。"""
manifest = self.manifests.get(kind)
if manifest is None:
return set()
return set(effective_capabilities(manifest, self._selected_models(kind)))
def _selected_models(self, kind: str) -> List[str]:
"""Model ids selected in site.yaml for a kind (may be empty)."""
"""某类设备在 site.yaml 中选中的型号 id(可能为空)。"""
manifest = self.manifests.get(kind)
if manifest is None:
return []
@@ -70,12 +68,12 @@ class DeviceStack(NamedTuple):
def effective_capabilities(
manifest: Manifest, selected_models: Sequence[str] = ()
) -> Set[str]:
"""Capabilities a driver provides, given which of its models are selected.
"""在给定选中哪些型号的前提下,驱动提供的能力。
Top-level ``capabilities`` are what *every* model of the brand supports.
A model may add more (RGI adds ``rotate`` on top of the shared open/close).
Declared capabilities are never removed, because narrowing silently would
turn a clear startup error into a confusing runtime failure.
顶层 ``capabilities`` 是该品牌*每一个*型号都支持的能力。某个型号可以
追加更多(RGI 在共有的 open/close 之上增加了 ``rotate``)。已声明的
能力绝不会被移除,因为悄悄收窄会把清晰的启动报错变成令人困惑的运行时
失败。
"""
caps: Set[str] = set(manifest.capabilities)
for model_id in selected_models:
@@ -91,19 +89,16 @@ def load_device_stack(
*,
verify_assets: bool = True,
) -> DeviceStack:
"""Load and validate the entire device stack.
"""加载并校验整个设备栈。
Validation failures across *all* device kinds are collected and reported
together. Reporting only the first one would force the operator to re-run
the check once per missing key, which is exactly the kind of round-trip this
layer exists to eliminate.
*所有*设备类别的校验失败都会被收集起来一并报告。只报告第一个,会迫使
操作员每修一个缺失的键就要重跑一次检查,而这种来回折腾正是本层要消除的。
Raises
------
ContractError subclass
On contract violations, with actionable fix hints. When more than one
kind fails, a single aggregated :class:`ContractError` is raised whose
detail lists every problem.
ContractError 子类
出现契约违规时抛出,并附带可执行的修复提示。当不止一类设备失败时,
会抛出单个聚合的 :class:`ContractError`,其 detail 列出全部问题。
"""
registry = load_registry(registry_path)
site_file = site_file or paths.site_path()
@@ -114,7 +109,7 @@ def load_device_stack(
problems: List[ContractError] = []
for kind in (*REQUIRED_KINDS, "sim"):
# A kind that was not selected at all is optional unless required.
# 完全没有选型的类别,除非是必需类别,否则可选。
if kind not in registry.selections:
continue
@@ -156,11 +151,10 @@ def load_device_stack(
def _aggregate(problems: List[ContractError]) -> ContractError:
"""Combine several contract errors into one reportable error.
"""把多个契约错误合并成一个可上报的错误。
A single problem is returned unchanged so the common case keeps its focused
message. Multiple problems are merged, preserving each one's own detail and
fix hint.
只有单个问题时原样返回,让常见情形保持聚焦的消息。多个问题则合并,
并保留每个问题各自的 detail 与修复提示。
"""
if len(problems) == 1:
return problems[0]
@@ -188,16 +182,15 @@ def _aggregate(problems: List[ContractError]) -> ContractError:
def check_requirement(
stack: DeviceStack, requirement_name: str
) -> Dict[str, Set[str]]:
"""Verify a flow's capability needs are met by the selected drivers.
"""验证某个流程的能力需求是否被所选驱动满足。
Returns the capabilities that were satisfied, grouped by kind, so callers
can log what the flow is running against.
返回已满足的能力,按类别分组,便于调用方记录该流程运行在什么之上。
Raises
------
CapabilityError
When a needed capability is missing. The message names the capability,
the interface it maps to, the selected brand, and the manifest to edit.
缺少某项所需能力时抛出。消息会点出该能力、它映射到的接口、所选的
品牌,以及该编辑的清单。
"""
requirement = REQUIREMENTS.get(requirement_name)
if requirement is None:
+34 -36
View File
@@ -1,18 +1,17 @@
"""Load and validate a brand's ``manifest.yaml``.
"""加载并校验某个品牌的 ``manifest.yaml``。
A manifest is a brand's self-description. It answers four questions:
清单是品牌的自我介绍,回答四个问题:
1. **Who am I?** — ``id``, ``label``, ``vendor``, ``version``
2. **What vendor material must be on disk?** — ``assets`` (checked, with the
required format and naming convention recorded so operators can comply)
3. **What can I do?** — ``capabilities`` (subset of the vocabulary in
:mod:`arm_interfaces.capabilities`)
4. **What config do I need?** — ``config_schema``, whose ``site_path`` entries
point into ``config/site.yaml``
1. **我是谁?** —— ``id``、``label``、``vendor``、``version``
2. **磁盘上必须有哪些厂商物料?** —— ``assets``(会被检查,并记录必需的
格式与命名约定,方便操作员照做)
3. **我能做什么?** —— ``capabilities``(:mod:`arm_interfaces.capabilities`
词表的子集)
4. **我需要什么配置?** —— ``config_schema``,其中每项 ``site_path`` 都指向
``config/site.yaml`` 里的位置
Validation here is intentionally strict: a manifest that declares an unknown
capability or a malformed config entry is a bug that should surface at startup,
not a runtime surprise mid-motion.
这里的校验刻意从严:清单声明了未知能力或格式错误的配置项,属于应当在启动
时暴露的 bug,而不是运动中途才冒出来的运行时意外。
"""
from __future__ import annotations
@@ -27,7 +26,7 @@ from .capabilities import ALL_CAPABILITIES, capabilities_for_kind
from .errors import ManifestError, MissingAssetError
from .registry import KIND_FOLDERS
#: registry key (plural) -> manifest ``kind`` value (singular).
#: 选型中的键(复数)-> 清单里 ``kind`` 的取值(单数)。
KIND_SINGULAR: Dict[str, str] = {
"robots": "robot",
"grippers": "gripper",
@@ -36,15 +35,15 @@ KIND_SINGULAR: Dict[str, str] = {
"sim": "sim",
}
#: Config value types the contract layer knows how to check.
#: 契约层知道如何检查的配置值类型。
KNOWN_TYPES = {"str", "int", "float", "bool", "list", "dict"}
#: Manifest top-level keys that must be present.
#: 清单中必须存在的顶层键。
REQUIRED_KEYS = ("id", "label", "kind", "driver")
class Asset(NamedTuple):
"""One piece of vendor material a manifest expects on disk."""
"""清单期望磁盘上存在的一项厂商物料。"""
path: str
required: bool
@@ -56,7 +55,7 @@ class Asset(NamedTuple):
class DriverSpec(NamedTuple):
"""How to start this brand's adapter."""
"""如何启动该品牌的适配器。"""
package: str
executable: str
@@ -65,7 +64,7 @@ class DriverSpec(NamedTuple):
class ConfigField(NamedTuple):
"""One declared config key, pointing into ``config/site.yaml``."""
"""一个已声明的配置键,指向 ``config/site.yaml`` 中的位置。"""
key: str
site_path: str
@@ -77,7 +76,7 @@ class ConfigField(NamedTuple):
class Manifest(NamedTuple):
"""Parsed and validated ``manifest.yaml``."""
"""已解析并校验的 ``manifest.yaml``。"""
id: str
label: str
@@ -94,14 +93,14 @@ class Manifest(NamedTuple):
raw: Dict[str, Any]
def supports(self, capability: str) -> bool:
"""Whether this driver declares the capability."""
"""该驱动是否声明了这项能力。"""
return capability in self.capabilities
def model_capabilities(self, model_id: str) -> Optional[Tuple[str, ...]]:
"""Capabilities for a specific model id, if the manifest lists models.
"""某个具体型号 id 的能力(前提是清单列出了型号)。
Returns ``None`` when the manifest does not enumerate models, which
means the top-level ``capabilities`` list applies to all of them.
清单未逐一列举型号时返回 ``None``,表示顶层 ``capabilities`` 列表
对全部型号都适用。
"""
for model in self.models:
if model.get("id") == model_id:
@@ -113,19 +112,19 @@ class Manifest(NamedTuple):
def manifest_path(kind: str, brand: str) -> Path:
"""Return the expected manifest path for a kind/brand pair."""
"""返回某个“类别/品牌”组合预期的清单路径。"""
return paths.brand_dir(KIND_FOLDERS.get(kind, kind), brand) / "manifest.yaml"
def load_manifest(kind: str, brand: str, path: Path | None = None) -> Manifest:
"""Read, validate and return the manifest for a kind/brand pair.
"""读取、校验并返回某个“类别/品牌”组合的清单。
Parameters
----------
kind:
Registry key — ``robots``, ``grippers``, ``sensors``, ``cameras``, ``sim``.
选型中的键 —— ``robots``、``grippers``、``sensors``、``cameras``、``sim``。
brand:
Brand folder name under ``devices/<kind>/``.
``devices/<kind>/`` 下的品牌目录名。
"""
target = path or manifest_path(kind, brand)
@@ -187,11 +186,10 @@ def load_manifest(kind: str, brand: str, path: Path | None = None) -> Manifest:
def check_assets(manifest: Manifest, brand: str, kind: str) -> None:
"""Verify every ``required`` asset exists, with format hints in the error.
"""校验每个 ``required`` 物料都存在,并在错误里给出格式提示。
Only presence is checked. Format and naming cannot be verified
automatically, so they are reported back to the operator in the failure
message to make compliance possible.
这里只检查是否存在。格式与命名无法自动验证,因此在失败消息里回告给
操作员,让照做成为可能。
"""
base = paths.brand_dir(KIND_FOLDERS.get(kind, kind), brand)
missing: List[str] = []
@@ -225,7 +223,7 @@ def check_assets(manifest: Manifest, brand: str, kind: str) -> None:
# ---------------------------------------------------------------------------
# internal helpers
# 内部辅助函数
# ---------------------------------------------------------------------------
def _available_brands(kind: str) -> List[str]:
@@ -236,10 +234,10 @@ def _available_brands(kind: str) -> List[str]:
def _asset_present(target: Path) -> bool:
"""An asset counts as present if it is a file, a dir, or a symlink to either.
"""物料是文件、目录,或指向二者的符号链接,都算作存在。
A directory is accepted because vendors often ship a tree (``rdk/``) and the
operator should not be forced to add a sentinel file inside it.
接受目录,是因为厂商常以整棵目录树(``rdk/``)交付,不应该逼着操作员
在目录里额外放一个占位文件。
"""
if target.is_symlink():
return target.exists()
@@ -298,7 +296,7 @@ def _parse_capabilities(
detail=f"该清单里合法的能力: {', '.join(known_code) or '(无)'}",
)
# A capability declared twice is almost always a copy-paste slip.
# 同一项能力声明两次,几乎总是复制粘贴出的疏漏。
duplicates = sorted({c for c in raw if raw.count(c) > 1})
if duplicates:
raise ManifestError(
+17 -17
View File
@@ -1,15 +1,15 @@
"""Single source of truth for workspace paths.
"""工作区路径的唯一权威来源。
The old codebase resolved the workspace root in three different ways:
``arm_application/site_io.py``, ``scripts/lib/export_site_env.py``, and several
nodes that read ``site.yaml`` directly with their own ``_site_path()`` helpers.
They did not always agree. This module is the one place that resolves paths, and
everything else — including ``site_io.py`` — should delegate here.
旧代码库用三种不同方式解析工作区根目录:
``arm_application/site_io.py``、``scripts/lib/export_site_env.py``,以及若干
用各自 ``_site_path()`` 辅助函数直接读 ``site.yaml`` 的节点。
它们并不总是一致。本模块是解析路径的唯一场所,其他所有地方
——包括 ``site_io.py``——都应委托到这里。
Resolution order (first non-empty wins):
解析顺序(第一个非空者胜出):
1. Explicit environment override (``SITE_ROS2_ROOT`` / ``SITE_CONFIG``)
2. ``ROS2_WS`` (exported by ``scripts/env.sh``)
1. 显式环境变量覆盖(``SITE_ROS2_ROOT`` / ``SITE_CONFIG``)
2. ``ROS2_WS``(由 ``scripts/env.sh`` 导出)
3. ``~/ros2``
"""
@@ -20,7 +20,7 @@ from pathlib import Path
def ros2_root() -> Path:
"""Return the workspace root, honouring environment overrides."""
"""返回工作区根目录,并遵循环境变量覆盖。"""
for key in ("SITE_ROS2_ROOT", "ROS2_WS"):
value = os.environ.get(key, "").strip()
if value:
@@ -29,7 +29,7 @@ def ros2_root() -> Path:
def site_path() -> Path:
"""Return the path to the master config ``config/site.yaml``."""
"""返回主配置 ``config/site.yaml`` 的路径。"""
override = os.environ.get("SITE_CONFIG", "").strip()
if override:
return Path(override).expanduser()
@@ -37,7 +37,7 @@ def site_path() -> Path:
def devices_root() -> Path:
"""Return the root of the device folder tree."""
"""返回设备目录树的根目录。"""
override = os.environ.get("SITE_DEVICES_ROOT", "").strip()
if override:
return Path(override).expanduser()
@@ -45,19 +45,19 @@ def devices_root() -> Path:
def registry_path() -> Path:
"""Return the path to the device selection file ``devices/registry.yaml``."""
"""返回设备选型文件 ``devices/registry.yaml`` 的路径。"""
return devices_root() / "registry.yaml"
def handeye_root() -> Path:
"""Return the root of the hand-eye module directory (``handeye/``)."""
"""返回手眼模块目录(``handeye/``)的根目录。"""
return ros2_root() / "handeye"
def brand_dir(kind: str, brand: str) -> Path:
"""Return the directory holding one brand under a device kind.
"""返回某类设备下某个品牌所在的目录。
``kind`` is the plural folder name used in ``devices/`` (``robots``,
``grippers``, ``sensors``, ``cameras``, ``sim``).
``kind`` 是 ``devices/`` 中使用的复数目录名(``robots``、
``grippers``、``sensors``、``cameras``、``sim``)。
"""
return devices_root() / kind / brand
+15 -17
View File
@@ -1,9 +1,8 @@
"""Load and validate ``devices/registry.yaml``.
"""加载并校验 ``devices/registry.yaml``。
The registry answers exactly one question: *which brand implements each device
kind right now?* It holds no field values by design — every speed, threshold and
serial number lives in ``config/site.yaml``. Keeping selection and values apart
is what stops the "I changed the value but nothing happened" class of bug.
选型文件只回答一个问题:*当前每一类设备由哪个品牌实现?* 按设计它不保存
任何字段值——所有速度、阈值和序列号都在 ``config/site.yaml`` 里。把“选型”
与“取值”分开,正是为了杜绝“改了值却毫无反应”这一类 bug。
"""
from __future__ import annotations
@@ -16,8 +15,8 @@ import yaml
from . import paths
from .errors import RegistryError
#: Device kind -> the folder under ``devices/`` holding its brands.
#: Also the key used in ``registry.yaml``.
#: 设备类别 -> ``devices/`` 下存放其品牌的目录。
#: 同时也是 ``registry.yaml`` 中使用的键。
KIND_FOLDERS: Dict[str, str] = {
"robots": "robots",
"grippers": "grippers",
@@ -26,25 +25,25 @@ KIND_FOLDERS: Dict[str, str] = {
"sim": "sim",
}
#: Every kind the registry must select. ``sim`` is optional (headless setups).
#: 选型文件必须选择的每一类设备。``sim`` 是可选的(无头环境)。
REQUIRED_KINDS = ("robots", "grippers", "sensors", "cameras")
OPTIONAL_KINDS = ("sim",)
class Registry(NamedTuple):
"""Parsed ``devices/registry.yaml``."""
"""解析后的 ``devices/registry.yaml``。"""
selections: Dict[str, str]
"""kind -> brand id, e.g. ``{"robots": "flexiv"}``."""
"""kind -> 品牌 id,例如 ``{"robots": "flexiv"}``。"""
path: Path
"""Where the registry was loaded from (for error messages)."""
"""选型文件的加载来源(用于错误消息)。"""
raw: Dict[str, Any]
"""Full parsed document, for forward compatibility."""
"""完整解析结果,便于向前兼容。"""
def brand(self, kind: str) -> str:
"""Return the selected brand for a kind, raising if unset."""
"""返回某类设备选中的品牌,未设置则抛错。"""
try:
return self.selections[kind]
except KeyError:
@@ -58,13 +57,12 @@ class Registry(NamedTuple):
def load_registry(path: Path | None = None) -> Registry:
"""Read and validate the registry file.
"""读取并校验选型文件。
Raises
------
RegistryError
If the file is missing, unparseable, is not a mapping, or omits a
required kind.
文件缺失、无法解析、顶层不是映射,或漏掉必需的设备类别时抛出。
"""
target = path or paths.registry_path()
@@ -119,7 +117,7 @@ def load_registry(path: Path | None = None) -> Registry:
def list_available_brands(kind: str) -> list[str]:
"""Return brand folder names present on disk for a device kind."""
"""返回到磁盘上某类设备现存的品牌目录名。"""
folder = paths.devices_root() / KIND_FOLDERS.get(kind, kind)
if not folder.is_dir():
return []
+4 -4
View File
@@ -4,9 +4,9 @@ script_dir=$base/lib/arm_interfaces
install_scripts=$base/lib/arm_interfaces
[tool:pytest]
# ROS 2 Humble's two launch-testing pytest plugins use old pytest hook
# signatures that current pytest rejects, which aborts collection before our
# tests even run. Disabling them is safe: these are pure-Python unit tests and
# use none of launch_testing's fixtures.
# ROS 2 Humble 的两个 launch-testing pytest 插件使用的旧版 pytest hook
# 签名会被当前 pytest 拒绝,导致测试还没开始就被中止收集。
# 禁用它们是安全的:这些是纯 Python 单元测试,
# 完全没有用到 launch_testing 的任何 fixture。
addopts = -p no:launch_testing -p no:launch_ros
testpaths = test
+1 -1
View File
@@ -18,7 +18,7 @@ setup(
license='Apache-2.0',
entry_points={
'console_scripts': [
# Validate the whole device stack: registry -> manifests -> site.yaml.
# 校验整条设备链:registry → 各 manifest → site.yaml。
'device_check = arm_interfaces.cli:main',
],
},
+28 -29
View File
@@ -1,10 +1,9 @@
"""Unit tests for the device contract layer.
"""设备契约层的单元测试。
These build a throwaway workspace in ``tmp_path`` so they never touch the real
``devices/`` tree or ``config/site.yaml``. Every failure mode that the layer is
supposed to catch has a test asserting the *shape* of the error — in particular
that a ``fix_hint`` is present, because a contract error without a remediation
instruction is only half the job.
这些测试在 ``tmp_path`` 里搭一个用完即弃的工作区,因此绝不会碰真实的
``devices/`` 目录树或 ``config/site.yaml``。本层应当捕获的每一种失败模式
都有一个测试断言错误的*形态*——尤其是必须带 ``fix_hint``,因为一条没有
修复指示的契约错误只算完成了一半工作。
"""
from __future__ import annotations
@@ -36,7 +35,7 @@ from arm_interfaces.registry import list_available_brands
# ---------------------------------------------------------------------------
# fake workspace construction
# 构造假工作区
# ---------------------------------------------------------------------------
MINIMAL_REGISTRY = """\
@@ -143,7 +142,7 @@ cameras:
@pytest.fixture
def workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Dict[str, Path]:
"""Create a minimal, valid device workspace and point the layer at it."""
"""创建一个最小可用的设备工作区,并让契约层指向它。"""
devices = tmp_path / "devices"
for kind, brand, manifest in (
("robots", "acme", ACME_MANIFEST),
@@ -173,7 +172,7 @@ def write(path: Path, content: str) -> None:
# ---------------------------------------------------------------------------
# registry
# 选型
# ---------------------------------------------------------------------------
class TestRegistry:
@@ -217,7 +216,7 @@ class TestRegistry:
# ---------------------------------------------------------------------------
# manifest
# 清单
# ---------------------------------------------------------------------------
class TestManifest:
@@ -254,7 +253,7 @@ class TestManifest:
assert "get_arm_state" in (excinfo.value.fix_hint or "")
def test_capability_from_another_kind_is_rejected(self, workspace):
"""A robot must not be able to claim a gripper capability."""
"""机器人绝不能声称自己拥有夹爪能力。"""
write(
workspace["devices"] / "robots" / "acme" / "manifest.yaml",
ACME_MANIFEST.replace(" - get_arm_state", " - rotate\n - get_arm_state"),
@@ -276,7 +275,7 @@ class TestManifest:
assert "重复" in excinfo.value.what
def test_required_and_default_together_is_rejected(self, workspace):
"""Ambiguous intent: either the user must supply it, or we have a fallback."""
"""意图含糊:要么必须由用户提供,要么我们已有兜底值。"""
write(
workspace["devices"] / "robots" / "acme" / "manifest.yaml",
ACME_MANIFEST.replace(
@@ -334,7 +333,7 @@ class TestManifest:
# ---------------------------------------------------------------------------
# asset presence
# 物料是否存在
# ---------------------------------------------------------------------------
class TestAssets:
@@ -354,7 +353,7 @@ class TestAssets:
with pytest.raises(MissingAssetError) as excinfo:
check_assets(loaded, "acme", "robots")
message = str(excinfo.value)
# The operator must be told how to comply, not merely that it is missing.
# 必须告诉操作员该怎么照做,而不只是说“缺了”。
assert "厂商原始格式" in message
assert "保持原目录名" in message
@@ -368,7 +367,7 @@ class TestAssets:
write(workspace["devices"] / "robots" / "acme" / "manifest.yaml", manifest)
loaded = load_manifest("robots", "acme")
(workspace["devices"] / "robots" / "acme" / "rdk").mkdir()
check_assets(loaded, "acme", "robots") # must not raise
check_assets(loaded, "acme", "robots") # 不应抛错
def test_optional_asset_may_be_absent(self, workspace):
manifest = ACME_MANIFEST + textwrap.dedent("""\
@@ -379,11 +378,11 @@ class TestAssets:
""")
write(workspace["devices"] / "robots" / "acme" / "manifest.yaml", manifest)
loaded = load_manifest("robots", "acme")
check_assets(loaded, "acme", "robots") # must not raise
check_assets(loaded, "acme", "robots") # 不应抛错
# ---------------------------------------------------------------------------
# config_guard: path resolution and type checking
# config_guard:路径解析与类型检查
# ---------------------------------------------------------------------------
class TestResolvePath:
@@ -400,7 +399,7 @@ class TestResolvePath:
assert found[1].path == "robot.robots[1].sn"
def test_mapping_expansion(self):
"""site.yaml registers cameras as a mapping keyed by name, not a sequence."""
"""site.yaml 把相机登记为以名称作键的映射,而不是序列。"""
site = {"cameras": {"left": {"serial": "1"}, "right": {"serial": "2"}}}
found = resolve_path(site, "cameras[].serial")
assert [f.value for f in found] == ["1", "2"]
@@ -410,7 +409,7 @@ class TestResolvePath:
assert resolve_path({"a": {}}, "a.b.c") == []
def test_scalar_where_collection_expected_is_kept_for_type_check(self):
"""Better to report a type error than to silently claim 'missing'."""
"""报告类型错误,总好过静默声称“缺失”。"""
found = resolve_path({"cameras": 5}, "cameras[].serial")
assert len(found) == 1
assert found[0].value == 5
@@ -418,10 +417,10 @@ class TestResolvePath:
class TestCheckConfig:
def _manifest_with(self, workspace, extra: str):
"""Append extra ``config_schema`` entries to the Acme manifest.
"""把额外的 ``config_schema`` 条目追加到 Acme 清单上。
``extra`` is written flush-left and indented here, so call sites can
indent a YAML snippet naturally without counting spaces.
``extra`` 以顶格书写后在这里统一缩进,因此调用处可以自然地为 YAML
片段缩进,而无需数空格。
"""
body = textwrap.indent(textwrap.dedent(extra), " ")
write(workspace["devices"] / "robots" / "acme" / "manifest.yaml", ACME_MANIFEST + body)
@@ -483,7 +482,7 @@ class TestCheckConfig:
assert "fast" in (excinfo.value.detail or "")
def test_bool_is_not_accepted_as_float(self, workspace):
"""YAML ``true`` must not be silently used as 1.0."""
"""YAML 里的 ``true`` 绝不能被悄悄当作 1.0 使用。"""
manifest = self._manifest_with(
workspace,
"""\
@@ -499,7 +498,7 @@ class TestCheckConfig:
check_config(manifest, site)
def test_every_instance_must_supply_per_instance_value(self, workspace):
"""robot.robots[].sn requires all arms to be configured."""
"""robot.robots[].sn 要求所有机械臂都完成配置。"""
write(
workspace["devices"] / "robots" / "acme" / "manifest.yaml",
ACME_MANIFEST.replace("site_path: robot.sn", "site_path: robot.robots[].sn"),
@@ -509,12 +508,12 @@ class TestCheckConfig:
site["robot"]["robots"] = [{"sn": "A"}, {"sn": ""}]
with pytest.raises(MissingConfigError) as excinfo:
check_config(manifest, site)
# The offending instance must be identified by index.
# 出问题的那个实例必须按索引定位到。
assert "robots[1].sn" in str(excinfo.value)
# ---------------------------------------------------------------------------
# loader: aggregation and capabilities
# loader:聚合与能力
# ---------------------------------------------------------------------------
class TestLoader:
@@ -525,9 +524,9 @@ class TestLoader:
assert stack.capabilities("robots") == {"get_arm_state", "follow_joint_trajectory"}
def test_all_problems_reported_at_once(self, workspace):
"""One run must surface every broken kind, not just the first."""
"""一次运行必须暴露出每一类坏掉的设备,而不只是第一个。"""
site = workspace["site"]
write(site, "cameras:\n cam_a:\n serial: '1'\n") # robot.sn etc. missing
write(site, "cameras:\n cam_a:\n serial: '1'\n") # robot.sn 等缺失
with pytest.raises(Exception) as excinfo:
load_device_stack()
message = str(excinfo.value)
@@ -545,7 +544,7 @@ class TestLoader:
write(workspace["devices"] / "robots" / "acme" / "manifest.yaml", manifest)
with pytest.raises(MissingAssetError):
load_device_stack()
load_device_stack(verify_assets=False) # must not raise
load_device_stack(verify_assets=False) # 不应抛错
class TestEffectiveCapabilities:
+18 -21
View File
@@ -1,11 +1,10 @@
"""Integration tests against the real workspace.
"""针对真实工作区的集成测试。
These are the tests that keep the shipped configuration honest. They load the
actual ``devices/`` tree and the actual ``config/site.yaml``, so a bad edit to
either fails here rather than on the robot.
这些测试用来让随仓库交付的配置保持诚实。它们加载真实的 ``devices/`` 目录树
和真实的 ``config/site.yaml``,所以对其中任何一处的糟糕改动都会在这里失败,
而不是在机器人上失败。
They are skipped when the real workspace is not present (for example when the
package is built in isolation), so they never turn into false failures.
真实工作区不存在时会跳过(例如包被单独构建时),因此它们绝不会变成假失败。
"""
from __future__ import annotations
@@ -31,26 +30,26 @@ pytestmark = pytest.mark.skipif(
class TestRealWorkspace:
def test_contract_satisfied(self, monkeypatch):
"""The shipped device tree and site.yaml must pass validation."""
# Guard against a leftover SITE_* override from another test leaking in.
"""随仓库交付的设备目录树和 site.yaml 必须通过校验。"""
# 防止另一个测试遗留的 SITE_* 覆盖泄漏进来。
for var in ("SITE_DEVICES_ROOT", "SITE_CONFIG", "SITE_ROS2_ROOT"):
monkeypatch.delenv(var, raising=False)
stack = load_device_stack()
# Every required kind is present and selected.
# 每个必需的设备类别都已存在并完成选型。
for kind in ("robots", "grippers", "sensors", "cameras"):
assert stack.manifest(kind) is not None, f"{kind} 未选型"
def test_selected_models_resolve(self, monkeypatch):
"""Model ids named in site.yaml must exist in the corresponding manifest."""
"""site.yaml 中写明的型号 id 必须存在于对应的清单里。"""
for var in ("SITE_DEVICES_ROOT", "SITE_CONFIG"):
monkeypatch.delenv(var, raising=False)
stack = load_device_stack()
site = load_site()
# Cameras: every entry's model must be a declared model id.
# 相机:每一项的 model 都必须是清单声明的型号 id。
camera_manifest = stack.manifest("cameras")
declared = {model["id"] for model in camera_manifest.models}
for name, camera in (site.get("cameras") or {}).items():
@@ -59,7 +58,7 @@ class TestRealWorkspace:
f"{camera_manifest.path} 声明的型号 {sorted(declared)} 里"
)
# Grippers: same check against the gripper manifest's models.
# 夹爪:对夹爪清单的型号做同样的检查。
gripper_manifest = stack.manifest("grippers")
gripper_models = {model["id"] for model in gripper_manifest.models}
for index, robot in enumerate((site.get("robot") or {}).get("robots") or []):
@@ -72,7 +71,7 @@ class TestRealWorkspace:
)
def test_sensor_models_folder_matches_config(self, monkeypatch):
"""tactile.model must match a subdirectory under sensors/orisys/models/."""
"""tactile.model 必须与 sensors/orisys/models/ 下的某个子目录对应。"""
for var in ("SITE_DEVICES_ROOT", "SITE_CONFIG"):
monkeypatch.delenv(var, raising=False)
@@ -95,7 +94,7 @@ class TestRealWorkspace:
)
def test_assembly_requirement_met_by_real_selection(self, monkeypatch):
"""The gauge assembly flow must be satisfiable with shipped hardware."""
"""随仓库交付的硬件必须能满足通止规装配流程。"""
for var in ("SITE_DEVICES_ROOT", "SITE_CONFIG"):
monkeypatch.delenv(var, raising=False)
@@ -115,13 +114,11 @@ class TestRealWorkspace:
class TestExampleParity:
"""``site.example.yaml`` is generated from ``site.yaml``; it must stay in sync.
"""``site.example.yaml`` 由 ``site.yaml`` 生成,必须保持同步。
The template used to be maintained by hand and had drifted 114 keys behind
the live config, so a fresh deployment following it would silently miss
``servo.*``, ``hole_detect.stage2.*`` and ``sim.*``. It is now generated by
``scripts/gen_site_example.py``, and this test fails the moment the two
diverge.
这份模板以前靠手工维护,曾比现场配置落后 114 个键,导致照着它做的新部署
会静默漏掉 ``servo.*``、``hole_detect.stage2.*`` 和 ``sim.*``。现在它由
``scripts/gen_site_example.py`` 生成,两者一旦分叉,本测试立即失败。
"""
def _example_path(self) -> Path:
@@ -144,7 +141,7 @@ class TestExampleParity:
)
def test_example_contains_no_live_values(self):
"""The template must not leak serial numbers, IPs or user paths."""
"""模板绝不能泄漏序列号、IP 或用户路径。"""
example = self._example_path()
if not example.is_file():
pytest.skip("config/site.example.yaml 不存在")