别人克隆后没有 install,source 会直接失败。把展开后的 install 放进仓库,符号链接和指向本机 build 的 egg-link 收成真实文件,Python 入口改走当前环境的解释器,这样不用先 colcon build 也能启动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-10-15 11:04:43 +08:00
parent 3c86441fb0
commit 926a7f9796
1492 changed files with 1302154 additions and 2 deletions
@@ -0,0 +1,13 @@
Metadata-Version: 2.4
Name: arm_application
Version: 0.1.0
Summary: High-level arm application nodes.
Maintainer: arm_dev
Maintainer-email: dev@example.com
License: Apache-2.0
Requires-Dist: setuptools
Dynamic: license
Dynamic: maintainer
Dynamic: maintainer-email
Dynamic: requires-dist
Dynamic: summary
@@ -0,0 +1,88 @@
package.xml
setup.cfg
setup.py
../../build/arm_application/arm_application.egg-info/PKG-INFO
../../build/arm_application/arm_application.egg-info/SOURCES.txt
../../build/arm_application/arm_application.egg-info/dependency_links.txt
../../build/arm_application/arm_application.egg-info/entry_points.txt
../../build/arm_application/arm_application.egg-info/requires.txt
../../build/arm_application/arm_application.egg-info/top_level.txt
../../build/arm_application/arm_application.egg-info/zip-safe
arm_application/__init__.py
arm_application/box_tracker.py
arm_application/demo_named_poses.py
arm_application/dh_official.py
arm_application/dh_pgea.py
arm_application/dh_pgea_node.py
arm_application/dual_gripper.py
arm_application/dual_gripper_cli.py
arm_application/dual_gripper_node.py
arm_application/feature_guard.py
arm_application/feature_jump_guard.py
arm_application/gauge_assembly_node.py
arm_application/gauge_boot.py
arm_application/gauge_config.py
arm_application/gauge_fsm.py
arm_application/gauge_gripper.py
arm_application/gauge_kin.py
arm_application/geom_ik.py
arm_application/goto_ee_plane.py
arm_application/handeye_calibrate.py
arm_application/handeye_lib.py
arm_application/handeye_realsense.py
arm_application/handeye_teach.py
arm_application/handeye_waypoints.py
arm_application/hole_detect_hough.py
arm_application/hole_detect_node.py
arm_application/hole_detector.py
arm_application/image_utils.py
arm_application/kalman_filter.py
arm_application/model_labels.py
arm_application/move_to_pose_client.py
arm_application/onnx_workpiece.py
arm_application/orisys_tactile.py
arm_application/perception_node.py
arm_application/pid_controller.py
arm_application/pipeline_node.py
arm_application/plane_feature.py
arm_application/pose_detector.py
arm_application/realsense_color_pub.py
arm_application/realsense_sdk_view.py
arm_application/realsense_view.py
arm_application/ros_node.py
arm_application/run_joint_trajectory.py
arm_application/servo_node.py
arm_application/site_io.py
arm_application/stage2_locate_node.py
arm_application/stage3_search_node.py
arm_application/tactile_cli.py
arm_application/tactile_interface.py
arm_application/tactile_node.py
arm_application/weights_layout.py
arm_application/yolo_detector.py
arm_application.egg-info/PKG-INFO
arm_application.egg-info/SOURCES.txt
arm_application.egg-info/dependency_links.txt
arm_application.egg-info/entry_points.txt
arm_application.egg-info/requires.txt
arm_application.egg-info/top_level.txt
arm_application.egg-info/zip-safe
config/demo.yaml
config/dual_grippers.yaml
config/hole_detect.yaml
config/hole_insert.yaml
config/perception.yaml
config/servo.yaml
config/xunhuan_trajectory.yaml
launch/demo.launch.py
launch/dh_pgea.launch.py
launch/dual_gripper.launch.py
launch/gauge_assembly.launch.py
launch/hole_detect.launch.py
launch/pipeline.launch.py
launch/stage2_locate.launch.py
launch/stage3_search.launch.py
launch/tactile.launch.py
launch/visual_servo.launch.py
resource/arm_application
trajectories/xunhuan.traj
@@ -0,0 +1,23 @@
[console_scripts]
assembly_pipeline = arm_application.pipeline_node:main
demo_named_poses = arm_application.demo_named_poses:main
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
gauge_assembly = arm_application.gauge_assembly_node:main
goto_ee_plane = arm_application.goto_ee_plane:main
handeye_calibrate = arm_application.handeye_calibrate:main
handeye_teach = arm_application.handeye_teach:main
handeye_waypoints = arm_application.handeye_waypoints:main
hole_detect = arm_application.hole_detect_node:main
move_to_pose_client = arm_application.move_to_pose_client:main
perception_node = arm_application.perception_node:main
realsense_color_pub = arm_application.realsense_color_pub:main
realsense_view = arm_application.realsense_view:main
run_joint_trajectory = arm_application.run_joint_trajectory:main
servo_node = arm_application.servo_node:main
stage2_locate = arm_application.stage2_locate_node:main
stage3_search = arm_application.stage3_search_node:main
tactile = arm_application.tactile_node:main
tactile_cli = arm_application.tactile_cli:main
@@ -0,0 +1,32 @@
"""arm_application —— Flexiv Rizon + D405 + DH 夹爪的现场节点包。
对外可直接运行的 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
可复用的 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`` 三维匀速模型卡尔曼滤波
现场操作请走 ``scripts/*.sh`` 和 ``config/site.yaml``,不要直接 import 这些模块。
接口契约见 ``docs/INTERFACES.md``。
"""
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""针对 YOLO ``xyxy`` 框(像素坐标系)的匀速跟踪器。
用于避免工件快速运动时丢失锁定:下一帧的闸门使用*预测*框,
短暂的 YOLO 漏检则由惯性滑行(coast)维持叠加显示。
"""
from __future__ import annotations
from typing import Optional, Tuple
import numpy as np
def _center(xyxy: np.ndarray) -> np.ndarray:
return np.array(
[0.5 * (float(xyxy[0]) + float(xyxy[2])), 0.5 * (float(xyxy[1]) + float(xyxy[3]))],
dtype=float,
)
def _wh(xyxy: np.ndarray) -> Tuple[float, float]:
return (
max(8.0, float(xyxy[2]) - float(xyxy[0])),
max(8.0, float(xyxy[3]) - float(xyxy[1])),
)
class BoxTracker:
def __init__(
self,
*,
vmax_px_s: float = 1800.0,
v_lpf: float = 0.70,
max_dt: float = 0.25,
) -> None:
self.vmax_px_s = float(vmax_px_s)
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) # 中心点 px/s
self.t: Optional[float] = None
def reset(self) -> None:
self.xyxy = None
self.v[:] = 0.0
self.t = None
def predict(self, t: float, img_wh: Optional[Tuple[int, int]] = None) -> Optional[np.ndarray]:
if self.xyxy is None:
return None
out = self.xyxy.copy()
if self.t is None:
return out
dt = float(np.clip(t - self.t, 0.0, self.max_dt))
if dt <= 1e-4:
return out
c = _center(out) + self.v * dt
w, h = _wh(out)
x1, y1 = c[0] - 0.5 * w, c[1] - 0.5 * h
x2, y2 = c[0] + 0.5 * w, c[1] + 0.5 * h
if img_wh is not None:
iw, ih = float(img_wh[0]), float(img_wh[1])
x1 = float(np.clip(x1, 0.0, iw - 2.0))
y1 = float(np.clip(y1, 0.0, ih - 2.0))
x2 = float(np.clip(x2, x1 + 4.0, iw))
y2 = float(np.clip(y2, y1 + 4.0, ih))
return np.array([x1, y1, x2, y2], dtype=float)
def update(self, xyxy: np.ndarray, t: float) -> None:
xyxy = np.asarray(xyxy, dtype=float).reshape(4)
c = _center(xyxy)
if self.xyxy is not None and self.t is not None:
dt = float(t - self.t)
if dt > 0.28:
self.v[:] = 0.0
elif dt > 1e-3:
inst = (c - _center(self.xyxy)) / dt
n = float(np.hypot(inst[0], inst[1]))
if n > self.vmax_px_s > 0.0:
inst = inst * (self.vmax_px_s / n)
a = self.v_lpf
self.v = a * inst + (1.0 - a) * self.v
self.xyxy = xyxy.copy()
self.t = float(t)
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""演示:通过 ``/set_named_pose`` 循环切换命名位姿(对任何后端都适用)。"""
from __future__ import annotations
import time
import rclpy
from rclpy.node import Node
from arm_msgs.srv import SetNamedPose
from arm_application.ros_node import TolerantNode
class DemoNamedPoses(TolerantNode):
def __init__(self) -> None:
super().__init__('demo_named_poses')
self.declare_parameter(
'poses', ['home', 'ready', 'inspect', 'pos_left', 'home']
)
self.declare_parameter('velocity_scale', 0.5)
self.declare_parameter('dwell_sec', 0.3)
self.declare_parameter('loops', 1)
self.poses = list(
self.get_parameter('poses').get_parameter_value().string_array_value
or ['home', 'ready', 'inspect', 'home']
)
self.velocity_scale = float(self.get_parameter('velocity_scale').value)
self.dwell_sec = float(self.get_parameter('dwell_sec').value)
self.loops = int(self.get_parameter('loops').value)
self._cli = self.create_client(SetNamedPose, 'set_named_pose')
def run(self) -> int:
if not self._cli.wait_for_service(timeout_sec=15.0):
self.get_logger().error('``/set_named_pose`` 服务不可用')
return 1
for loop in range(max(self.loops, 1)):
self.get_logger().info(f'第 {loop + 1}/{self.loops} 轮')
for pose in self.poses:
req = SetNamedPose.Request()
req.pose_name = pose
req.velocity_scale = self.velocity_scale
req.acceleration_scale = self.velocity_scale
fut = self._cli.call_async(req)
deadline = time.monotonic() + 60.0
while rclpy.ok() and not fut.done():
rclpy.spin_once(self, timeout_sec=0.05)
if time.monotonic() > deadline:
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} 执行失败:{res.message if res else "无响应"}'
)
return 3
self.get_logger().info(f'{pose}:{res.message}')
time.sleep(self.dwell_sec)
self.get_logger().info('演示结束')
return 0
def main(args=None) -> None:
rclpy.init(args=args)
node = DemoNamedPoses()
code = 1
try:
code = node.run()
except KeyboardInterrupt:
code = 0
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
raise SystemExit(code)
if __name__ == '__main__':
main()
@@ -0,0 +1,286 @@
"""DH-Robotics 官方 Modbus RTU 控制(非 Flexiv RDK)。
覆盖两类设备:
- PGE/PGEA 平行夹爪:初始化 0x0100,力 0x0101,位置 0x0103(0~1000‰),速度 0x0104。
- RGI 旋转夹爪:在平行夹爪寄存器基础上,增加旋转角度 0x0105、旋转速度 0x0107、
旋转力 0x0108;反馈增加旋转角度 0x0208、旋转初始化状态 0x020A、旋转状态 0x020B。
说明:
- RGI 手册示例中负角度使用“反码/one's complement”:+360 -> 0x0168,-360 -> 0xFE97。
因此这里默认按 one's complement 编解码;若后续厂家确认为 two's complement,只改
`_encode_signed` / `_decode_signed` 两处即可。
- 写位置/角度寄存器后会立即运动,没有 ChangingTek SDK 的 0x0108 触发寄存器。
"""
from __future__ import annotations
import threading
import time
from typing import Iterable
# =============================================================================
# DH-Robotics Modbus 寄存器映射
# =============================================================================
# 基础控制寄存器(保持寄存器,功能码 03/06/10)
REG_INIT = 0x0100 # 0x01 初始化,0xA5 完全初始化
REG_FORCE = 0x0101 # 夹爪闭合力,20~100 %
REG_POSITION = 0x0103 # 手指参考位置,0~1000 千分比
REG_SPEED = 0x0104 # 手指开合速度,1~100 %
# RGI 旋转控制寄存器
REG_ROTATION_ANGLE = 0x0105 # 目标旋转角度,有符号角度值
REG_ROTATION_SPEED = 0x0107 # 旋转速度,1~100 %
REG_ROTATION_FORCE = 0x0108 # 旋转力/力矩,20~100 %
# 反馈寄存器
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 曾经受阻
# 配置寄存器
REG_SAVE_PARAMS = 0x0300 # 写入 1 可把所有参数保存到 flash
class DHModbusError(RuntimeError):
"""DH Modbus 通信或协议错误。"""
def _clamp_int(value: float | int, lo: int, hi: int) -> int:
return int(max(lo, min(hi, int(round(float(value))))))
def _encode_signed(value: float | int) -> int:
"""把有符号角度值编码为 DH 的反码(one's complement)寄存器值。
RGI 手册示例:+360 -> 0x0168,-360 -> 0xFE97。
"""
value = _clamp_int(value, -32767, 32767)
if value >= 0:
return value & 0xFFFF
return (~abs(value)) & 0xFFFF
def _decode_signed(raw: int) -> int:
"""把 DH 的反码(one's complement)寄存器值解码为有符号整数。"""
raw = int(raw) & 0xFFFF
if raw & 0x8000:
mag = (~raw) & 0x7FFF
return -mag
return raw & 0x7FFF
class DHModbusClient:
"""面向 DH-Robotics 夹爪的最小线程安全 Modbus RTU 客户端。"""
def __init__(
self,
port: str,
slave_id: int = 1,
baudrate: int = 115200,
timeout: float = 0.2,
*,
bytesize: int = 8,
parity: str = "N",
stopbits: int = 1,
) -> None:
try:
import minimalmodbus
import serial
except ImportError as exc: # pragma: no cover - 取决于运行时环境
raise DHModbusError(
"缺少 minimalmodbus/pyserial。请先安装:pip install minimalmodbus pyserial"
) from exc
parity_map = {
"N": serial.PARITY_NONE,
"E": serial.PARITY_EVEN,
"O": serial.PARITY_ODD,
}
self.instrument = minimalmodbus.Instrument(port, int(slave_id))
self.instrument.serial.baudrate = int(baudrate)
self.instrument.serial.bytesize = int(bytesize)
self.instrument.serial.parity = parity_map.get(str(parity).upper(), serial.PARITY_NONE)
self.instrument.serial.stopbits = int(stopbits)
self.instrument.serial.timeout = float(timeout)
self.instrument.mode = minimalmodbus.MODE_RTU
self.instrument.clear_buffers_before_each_transaction = True
try:
self.instrument.serial.exclusive = True
except Exception:
pass
self._lock = threading.Lock()
self.port = port
def close(self) -> None:
with self._lock:
try:
self.instrument.serial.close()
except Exception:
pass
# ------------------------------------------------------------------
# 底层寄存器读写
# ------------------------------------------------------------------
def write_register(self, address: int, value: int) -> None:
value = _clamp_int(value, 0, 0xFFFF)
last_exc: Exception | None = None
for attempt in range(3):
try:
with self._lock:
self.instrument.write_register(address, value, functioncode=6)
return
except Exception as exc:
last_exc = exc
time.sleep(0.05 * (attempt + 1))
raise DHModbusError(f"写寄存器 0x{address:04X}={value} 失败: {last_exc}") from last_exc
def read_register(self, address: int) -> int:
last_exc: Exception | None = None
for attempt in range(3):
try:
with self._lock:
return int(self.instrument.read_register(address, functioncode=3))
except Exception as exc:
last_exc = exc
time.sleep(0.05 * (attempt + 1))
hint = ""
text = str(last_exc).lower()
if "multiple access" in text or "no data" in text:
hint = f"。串口 {getattr(self, 'port', '')} 可能被其它进程占用,先执行: fuser -k {getattr(self, 'port', '/dev/ttyUSB*')}"
raise DHModbusError(f"读寄存器 0x{address:04X} 失败: {last_exc}{hint}") from last_exc
def write_registers(self, address: int, values: Iterable[int]) -> None:
vals = [_clamp_int(v, 0, 0xFFFF) for v in values]
with self._lock:
self.instrument.write_registers(address, vals)
def read_registers(self, address: int, count: int) -> list[int]:
with self._lock:
return [int(v) for v in self.instrument.read_registers(address, count, functioncode=3)]
def write_signed_register(self, address: int, value: float | int) -> None:
self.write_register(address, _encode_signed(value))
def read_signed_register(self, address: int) -> int:
return _decode_signed(self.read_register(address))
# ------------------------------------------------------------------
# PGE/PGEA/RGI 通用控制
# ------------------------------------------------------------------
def initialize(self, *, full: bool = False, rotation: bool = False, timeout: float = 10.0) -> None:
"""初始化/回零夹爪,并等待其就绪。"""
self.write_register(REG_INIT, 0xA5 if full else 0x01)
start = time.monotonic()
while time.monotonic() - start < timeout:
init_state = self.read_register(REG_INIT_STATE)
rot_ok = True
if rotation:
rot_ok = self.read_register(REG_ROTATION_INIT_STATE) == 1
if init_state == 1 and rot_ok:
return
time.sleep(0.1)
raise DHModbusError(f"夹爪初始化超时(>{timeout:.1f}s)")
def save_parameters(self) -> None:
self.write_register(REG_SAVE_PARAMS, 1)
def set_force_pct(self, force_pct: float | int) -> int:
value = _clamp_int(force_pct, 20, 100)
self.write_register(REG_FORCE, value)
return value
def set_speed_pct(self, speed_pct: float | int) -> int:
value = _clamp_int(speed_pct, 1, 100)
self.write_register(REG_SPEED, value)
return value
def set_position_per_mille(self, position: float | int) -> int:
value = _clamp_int(position, 0, 1000)
# 与 dh_pgea.latched_position_break 同一份:命令已是目标时先写当前位置,
# 读回之后再写最终值。60ms 后写回旧命令,固件采样不到,手指不动。
from arm_application.dh_pgea import latched_position_break
try:
cmd = _clamp_int(self.read_register(REG_POSITION), 0, 1000)
except Exception:
cmd = value
try:
cur = self.get_position_per_mille()
except Exception:
cur = value
bridge = latched_position_break(cmd, cur, value)
if bridge is not None:
self.write_register(REG_POSITION, bridge)
deadline = time.monotonic() + 0.6
echoed = False
while time.monotonic() < deadline:
try:
if _clamp_int(self.read_register(REG_POSITION), 0, 1000) == bridge:
echoed = True
break
except Exception:
pass
time.sleep(0.05)
if not echoed:
raise DHModbusError(
f"位置命令中间值 {bridge} 没有读回,不再写入最终目标 {value}"
)
time.sleep(0.15)
self.write_register(REG_POSITION, value)
return value
def get_position_per_mille(self) -> int:
return _clamp_int(self.read_register(REG_CURRENT_POSITION), 0, 1000)
def get_gripper_state(self) -> int:
return self.read_register(REG_GRIPPER_STATE)
def get_init_state(self) -> int:
return self.read_register(REG_INIT_STATE)
def hold_current_position(self) -> None:
"""在没有专用停止寄存器的 DH 夹爪上尽力停止/保持。"""
self.set_position_per_mille(self.get_position_per_mille())
# ------------------------------------------------------------------
# RGI 旋转控制
# ------------------------------------------------------------------
def set_rotation_speed_pct(self, speed_pct: float | int) -> int:
value = _clamp_int(speed_pct, 1, 100)
self.write_register(REG_ROTATION_SPEED, value)
return value
def set_rotation_force_pct(self, force_pct: float | int) -> int:
value = _clamp_int(force_pct, 20, 100)
self.write_register(REG_ROTATION_FORCE, value)
return value
def set_rotation_angle_deg(self, angle_deg: float | int) -> int:
angle = _clamp_int(angle_deg, -32767, 32767)
self.write_signed_register(REG_ROTATION_ANGLE, angle)
return angle
def get_rotation_angle_raw(self) -> int:
return self.read_register(REG_ROTATION_ANGLE_FB) & 0xFFFF
def get_rotation_angle_deg(self) -> int:
return self.read_signed_register(REG_ROTATION_ANGLE_FB)
def get_rotation_init_state(self) -> int:
return self.read_register(REG_ROTATION_INIT_STATE)
def get_rotation_state(self) -> int:
return self.read_register(REG_ROTATION_STATE)
def hold_current_rotation(self) -> None:
"""重新下发当前反馈角度来停止旋转。"""
self.write_register(REG_ROTATION_ANGLE, self.get_rotation_angle_raw())
def hold_current_all(self) -> None:
self.hold_current_position()
self.hold_current_rotation()
@@ -0,0 +1,670 @@
#!/usr/bin/env python3
"""DH Robotics PGE / PGEA Modbus RTU 客户端。
移植自官方 C++ 驱动:
DH-Robotics/dh_gripper_ros → DH_Modbus_Gripper
(工厂会按型号名里是否包含 ``PGE`` / ``PGEA`` 映射到本类)
Flexiv RDK 的 ``Gripper`` 驱动不了这个夹爪:它不是 Studio Device。
改用 RS485(USB–UART)通信,寄存器与 DH 的 ROS 驱动完全一致。
寄存器(保持寄存器,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
import argparse
import sys
import threading
import time
from dataclasses import dataclass
from typing import Optional
try:
import serial
except ImportError as exc: # pragma: no cover
raise SystemExit(
"缺少 pyserial。在 conda 环境 ros_flexiv 里执行:pip install pyserial"
) from exc
# 官方 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,
0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,
0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,
0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,
0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,
0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,
0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,
0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,
0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,
0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,
0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,
0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,
0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,
0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,
0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,
0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,
0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,
0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,
0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,
0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,
0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,
0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,
0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,
0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,
0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040,
)
REG_INIT = 0x0100
REG_FORCE = 0x0101
REG_POSITION = 0x0103
REG_SPEED = 0x0104
REG_INIT_STATE = 0x0200
REG_GRIP_STATE = 0x0201
REG_CUR_POSITION = 0x0202
INIT_CMD = 0xA5
S_INIT_NOT = 0
S_INIT_FINISHED = 1
S_INIT_DOING = 2
S_GRIP_MOVING = 0
S_GRIP_ARRIVED = 1
S_GRIP_CAUGHT = 2
S_GRIP_DROPPED = 3
GRIP_STATE_NAME = {
S_GRIP_MOVING: "moving",
S_GRIP_ARRIVED: "arrived",
S_GRIP_CAUGHT: "caught",
S_GRIP_DROPPED: "dropped",
}
INIT_STATE_NAME = {
S_INIT_NOT: "need_init",
S_INIT_FINISHED: "initialized",
S_INIT_DOING: "initializing",
}
def latched_position_break(command: int, current: int, target: int) -> Optional[int]:
"""命令寄存器已经等于目标、手指却不在目标上时,返回要先写入的中间位置。
大寰固件:再写一遍与 0x0103 当前值相同的数,不会启动新运动。
开爪卡在 caught 时就是这样——命令已是 1000,手指停在夹持位。
先把命令改成当前位置(手指本来就在这,不会再夹紧),等读回确认
固件已经采样,再写最终目标。只 sleep 几十毫秒就写回旧值,控制周期
来不及看到中间值,最终仍是旧命令,手指不动。
"""
cmd = int(max(0, min(1000, command)))
cur = int(max(0, min(1000, current)))
pos = int(max(0, min(1000, target)))
if cmd != pos or abs(cur - pos) <= 25:
return None
if cur != cmd:
return cur
return 0 if cmd != 0 else 40
def grip_motion_settled(
grip_state: int, position: int, target: int, seen_motion: bool
) -> bool:
"""这一拍能不能算「这句位置命令已经结束」。
张开前本来就是 caught、位置还停在夹持位:不能当成张开完成。
闭合夹住物体:动过之后停在比目标更开的位置,算完成。
"""
near = abs(int(position) - int(target)) <= 25
if grip_state in (S_GRIP_ARRIVED, S_GRIP_DROPPED) and near:
return True
if grip_state == S_GRIP_CAUGHT and seen_motion:
if int(target) + 25 < int(position):
return True
if near:
return True
return False
def crc16_modbus(data: bytes) -> int:
"""官方 DH CRC16(Modbus,线路上按小端发送)。"""
crc = 0xFFFF
for byte in data:
crc = (crc >> 8) ^ _CRC_TABLE[(crc ^ byte) & 0xFF]
return crc
def _frame(slave: int, func: int, index: int, value: int) -> bytes:
body = bytes(
[
slave & 0xFF,
func & 0xFF,
(index >> 8) & 0xFF,
index & 0xFF,
(value >> 8) & 0xFF,
value & 0xFF,
]
)
crc = crc16_modbus(body)
return body + bytes([crc & 0xFF, (crc >> 8) & 0xFF])
@dataclass
class GripperState:
init_state: int = S_INIT_NOT
grip_state: int = S_GRIP_MOVING
position: int = 0
target_position: int = 0
target_force: int = 0
stroke_mm: float = 20.0
@property
def initialized(self) -> bool:
return self.init_state == S_INIT_FINISHED
@property
def width_mm(self) -> float:
return float(self.position) / 1000.0 * float(self.stroke_mm)
def as_tuple(self) -> tuple[float, float, float, float, float]:
return (
1.0 if self.initialized else 0.0,
float(self.grip_state),
float(self.position),
float(self.target_position),
float(self.target_force),
)
def summary(self) -> str:
return (
f"init={INIT_STATE_NAME.get(self.init_state, self.init_state)} "
f"grip={GRIP_STATE_NAME.get(self.grip_state, self.grip_state)} "
f"pos={self.position}/1000 ({self.width_mm:.2f} mm) "
f"target={self.target_position} force={self.target_force}%"
)
class DhPgeaError(RuntimeError):
pass
WIRING_HINT = (
"PC 已发出 Modbus,夹爪 0 字节返回。不是力/速度参数问题。按顺序查:\n"
" 1) 夹爪 24 V DC 已上电(指示灯亮)\n"
" 2) USB 转接头必须是 RS485(A/B),不能是 TTL 的 TX/RX\n"
" 3) A↔A、B↔B;不通就对调 A/B\n"
" 4) 先 Ctrl+C 停节点,再:bash ~/ros2/scripts/run_gripper.sh --cli scan"
)
class DhPgeaGripper:
"""面向 PGE / PGEA 的线程安全 Modbus RTU 客户端(官方寄存器映射)。"""
def __init__(
self,
port: str = "/dev/ttyUSB0",
baudrate: int = 115200,
slave_id: int = 1,
timeout_s: float = 0.25,
stroke_mm: float = 20.0,
rs485_rts: str = "",
) -> None:
self.port = port
self.baudrate = int(baudrate)
self.slave_id = int(slave_id)
self.timeout_s = float(timeout_s)
self.stroke_mm = float(stroke_mm)
mode = str(rs485_rts or "").strip().lower()
if mode in ("", "off", "none", "false", "0"):
mode = ""
if mode not in ("", "tx", "tx_inv"):
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()
def open(self) -> None:
if self._ser is not None and self._ser.is_open:
return
try:
self._ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout_s,
write_timeout=self.timeout_s,
xonxoff=False,
rtscts=False,
dsrdtr=False,
exclusive=True,
)
except Exception as exc:
raise DhPgeaError(
f"无法打开 {self.port}:{exc}。"
"需要 24 V DC 供电 + RS485 接线,且当前用户在 dialout 组内。"
) from exc
try:
self._ser.dtr = True
except Exception:
pass
self._set_tx(False)
self._ser.reset_input_buffer()
self._ser.reset_output_buffer()
time.sleep(0.05)
def close(self) -> None:
if self._ser is not None:
try:
self._ser.close()
except Exception:
pass
self._ser = None
def __enter__(self) -> "DhPgeaGripper":
self.open()
return self
def __exit__(self, *args) -> None:
self.close()
def write_register(self, index: int, value: int, retries: int = 3) -> bool:
"""FC06 写,等待回显(对应官方 WriteRegisterFunc)。"""
frame = _frame(self.slave_id, 0x06, index, int(value) & 0xFFFF)
with self._lock:
for _ in range(max(1, retries)):
if self._transact(frame, 8) == frame:
return True
time.sleep(0.02)
return False
def read_register(self, index: int, retries: int = 8) -> int:
"""FC03 读一个保持寄存器(对应官方 ReadRegisterFunc)。"""
req = _frame(self.slave_id, 0x03, index, 1)
last: Optional[bytes] = None
with self._lock:
for _ in range(max(1, retries)):
rsp = self._transact(req, 7)
last = rsp
if rsp is None or len(rsp) != 7:
time.sleep(0.02)
continue
if crc16_modbus(rsp[:-2]) != (rsp[5] | (rsp[6] << 8)):
continue
if rsp[0] != (self.slave_id & 0xFF) or rsp[1] != 0x03:
continue
return (rsp[3] << 8) | (rsp[4] & 0xFF)
hint = f" last={last.hex() if last else '(empty)'}"
raise DhPgeaError(
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:
"""短读一次初始化状态。夹爪无应答时会抛异常。"""
return self.read_register(REG_INIT_STATE, retries=2)
def _set_tx(self, tx: bool) -> None:
if not self.rs485_rts or self._ser is None:
return
try:
if self.rs485_rts == "tx":
self._ser.rts = bool(tx)
else:
self._ser.rts = not bool(tx)
except Exception:
pass
def _transact(self, frame: bytes, expect: int) -> Optional[bytes]:
if self._ser is None or not self._ser.is_open:
raise DhPgeaError("串口未打开")
bit_s = 10.0 / max(1, self.baudrate)
self._set_tx(True)
if self.rs485_rts:
time.sleep(2.0 * bit_s)
self._ser.reset_input_buffer()
self._ser.write(frame)
self._ser.flush()
if self.rs485_rts:
time.sleep(len(frame) * bit_s + 0.0008)
self._set_tx(False)
deadline = time.monotonic() + self.timeout_s
buf = bytearray()
while time.monotonic() < deadline and len(buf) < expect:
chunk = self._ser.read(expect - len(buf))
if chunk:
buf.extend(chunk)
return bytes(buf) if buf else None
def initialization(self) -> bool:
return self.write_register(REG_INIT, INIT_CMD)
def initialize(
self, timeout_s: float = 20.0, poll_s: float = 0.2, *, force: bool = False
) -> bool:
"""写 0xA5,然后等到初始化状态 == 完成(对应官方上电流程)。
``force=False`` 时,已经完成就不再写(节点启动不要每次都让手指找零)。
命令行 ``init`` 必须 ``force=True``:状态字已经是完成时,不写 0xA5
手指根本不会回零,看起来像初始化失效。
"""
state = self.get_init_state()
if state == S_INIT_FINISHED and not force:
return True
if not self.initialization():
raise DhPgeaError("初始化写 0x0100=0xA5 失败(无 Modbus 回显)")
deadline = time.monotonic() + float(timeout_s)
# force 时必须先看到状态离开「完成」,否则读到的还是写之前的旧值。
saw_busy = not force
while time.monotonic() < deadline:
state = self.get_init_state()
if state != S_INIT_FINISHED:
saw_busy = True
elif saw_busy:
return True
time.sleep(poll_s)
raise DhPgeaError(
f"初始化在 {timeout_s:.0f}s 后超时(state={state})。"
"首次上电时手指本应先回零一次。"
)
def set_target_force(self, force_pct: int) -> bool:
force_pct = int(max(20, min(100, force_pct)))
return self.write_register(REG_FORCE, force_pct)
def set_target_speed(self, speed_pct: int) -> bool:
speed_pct = int(max(1, min(100, speed_pct)))
return self.write_register(REG_SPEED, speed_pct)
def _wait_command_echo(self, value: int, timeout_s: float = 0.6) -> bool:
"""等到 0x0103 读回就是刚写进去的值。读不回就不要再写下一笔。"""
deadline = time.monotonic() + float(timeout_s)
while time.monotonic() < deadline:
try:
if self.get_target_position(retries=1) == int(value):
return True
except Exception:
pass
time.sleep(0.05)
return False
def set_target_position(self, pos: int) -> bool:
pos = int(max(0, min(1000, pos)))
try:
cmd = self.get_target_position(retries=2)
except Exception:
cmd = pos
try:
cur = self.get_current_position(retries=2)
except Exception:
cur = pos
bridge = latched_position_break(cmd, cur, pos)
if bridge is not None:
if not self.write_register(REG_POSITION, bridge):
return False
if not self._wait_command_echo(bridge):
return False
# 读回只说明寄存器改了。再留一个控制周期,避免下一笔把中间值盖掉。
time.sleep(0.15)
return self.write_register(REG_POSITION, pos)
def wait_settle(self, target: int, timeout_s: float = 6.0) -> GripperState:
"""等到到位或夹住,或超时返回最后一帧。
张开前状态字已经是 caught 时不能立刻返回:那是上一句命令的残留,
手指还停在夹持位。
"""
t0 = time.monotonic()
last = None
seen_motion = False
st = self.get_run_states()
while time.monotonic() - t0 < timeout_s:
st = self.get_run_states()
if last is not None and st.position != last:
seen_motion = True
last = st.position
if st.grip_state == S_GRIP_MOVING:
seen_motion = True
time.sleep(0.08)
continue
if grip_motion_settled(st.grip_state, st.position, target, seen_motion):
return st
time.sleep(0.08)
return st
def get_init_state(self, retries: int = 8) -> int:
return self.read_register(REG_INIT_STATE, retries=retries)
def get_grip_state(self, retries: int = 8) -> int:
return self.read_register(REG_GRIP_STATE, retries=retries)
def get_current_position(self, retries: int = 8) -> int:
return self.read_register(REG_CUR_POSITION, retries=retries)
def get_target_position(self, retries: int = 8) -> int:
return self.read_register(REG_POSITION, retries=retries)
def get_target_force(self, retries: int = 8) -> int:
return self.read_register(REG_FORCE, retries=retries)
def get_run_states(self, retries: int = 8) -> GripperState:
return GripperState(
init_state=self.get_init_state(retries=retries),
grip_state=self.get_grip_state(retries=retries),
position=self.get_current_position(retries=retries),
target_position=self.get_target_position(retries=retries),
target_force=self.get_target_force(retries=retries),
stroke_mm=self.stroke_mm,
)
def open_jaw(self, force: int = 50, speed: int = 50) -> bool:
ok = self.set_target_force(force)
ok = self.set_target_speed(speed) and ok
return self.set_target_position(1000) and ok
def close_jaw(self, force: int = 50, speed: int = 50) -> bool:
ok = self.set_target_force(force)
ok = self.set_target_speed(speed) and ok
return self.set_target_position(0) and ok
def move(self, position: int, force: int = 50, speed: int = 50) -> bool:
ok = self.set_target_force(force)
ok = self.set_target_speed(speed) and ok
return self.set_target_position(position) and ok
def _selfcheck_crc() -> None:
"""官方简短手册示例:设置位置 500。"""
frame = _frame(1, 0x06, 0x0103, 500)
expect = bytes([0x01, 0x06, 0x01, 0x03, 0x01, 0xF4, 0x78, 0x21])
if frame != expect:
raise AssertionError(f"CRC 不匹配:{frame.hex()} != {expect.hex()}")
def _site_defaults() -> dict:
try:
from pathlib import Path
root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(root / "scripts" / "lib"))
from export_site_env import load_site # type: ignore
g = (load_site().get("gripper") or {})
return {
"port": str(g.get("port") or "/dev/ttyUSB0"),
"baudrate": int(g.get("baud") or 115200),
"slave_id": int(g.get("slave_id") or 1),
"stroke_mm": float(g.get("stroke_mm") or 20.0),
"force": int(g.get("force_pct") or 50),
"speed": int(g.get("speed_pct") or 50),
"rs485_rts": str(g.get("rs485_rts") or ""),
}
except Exception:
return {
"port": "/dev/ttyUSB0",
"baudrate": 115200,
"slave_id": 1,
"stroke_mm": 20.0,
"force": 50,
"speed": 50,
"rs485_rts": "",
}
def _scan_bus(port: str) -> int:
"""遍历波特率 × 从站号 × RTS,直到某个组合有应答。"""
bauds = (115200, 57600, 38400, 19200, 9600)
slaves = (1, 2, 3)
rts_modes = ("", "tx", "tx_inv")
print(f"扫描 {port}(请先停掉 ROS 节点 —— 该串口是独占的)")
for baud in bauds:
for slave in slaves:
for rts in rts_modes:
label = f"baud={baud} slave={slave} rts={rts or 'off'}"
g = DhPgeaGripper(
port=port,
baudrate=baud,
slave_id=slave,
timeout_s=0.08,
rs485_rts=rts,
)
try:
g.open()
state = g.probe()
print(f"OK {label} init_state={state}")
print(f" → site.yaml: baud: {baud} slave_id: {slave} rs485_rts: \"{rts}\"")
g.close()
return 0
except DhPgeaError:
print(f" miss {label}")
finally:
g.close()
print("所有组合都无应答。", file=sys.stderr)
print(WIRING_HINT, file=sys.stderr)
return 1
def _cli(argv: Optional[list[str]] = None) -> int:
site = _site_defaults()
ap = argparse.ArgumentParser(
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:空 | tx | tx_inv")
ap.add_argument(
"cmd",
choices=["probe", "init", "status", "open", "close", "pos", "selfcheck", "scan"],
)
ap.add_argument("value", nargs="?", default="", help="'pos' 命令的位置,0–1000")
args = ap.parse_args(argv)
if args.cmd == "selfcheck":
_selfcheck_crc()
print("CRC 自检通过(01 06 01 03 01 F4 78 21)")
return 0
if args.cmd == "scan":
return _scan_bus(args.port)
grip = DhPgeaGripper(
port=args.port,
baudrate=args.baud,
slave_id=args.slave,
stroke_mm=float(site["stroke_mm"]),
rs485_rts=args.rts,
)
try:
grip.open()
except DhPgeaError as exc:
print(f"错误:{exc}", file=sys.stderr)
return 1
try:
if args.cmd == "probe":
st = grip.get_run_states(retries=2)
print(st.summary())
return 0
if args.cmd == "init":
print("正在初始化(手指回零,已初始化也会再做一次)…")
grip.initialize(force=True)
print(grip.get_run_states().summary())
return 0
if args.cmd == "status":
print(grip.get_run_states().summary())
return 0
if args.cmd == "open":
if not grip.open_jaw(args.force, args.speed):
raise DhPgeaError("张开写入失败")
st = grip.wait_settle(1000)
print(st.summary())
if abs(st.position - 1000) > 25:
print(
"张开失败:手指没有离开夹持位。"
"若仍是 grip=caught,先取下工件再执行 init。",
file=sys.stderr,
)
return 1
return 0
if args.cmd == "close":
if not grip.close_jaw(args.force, args.speed):
raise DhPgeaError("闭合写入失败")
st = grip.wait_settle(0)
print(st.summary())
# 夹住物体时位置停在厚度上,grip=caught 算成功。停在张开位则失败。
if st.grip_state != S_GRIP_CAUGHT and abs(st.position) > 25:
print("闭合失败:手指没有朝合拢方向离开原位。", file=sys.stderr)
return 1
return 0
if args.cmd == "pos":
if args.value == "":
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("位置写入失败")
st = grip.wait_settle(pos)
print(st.summary())
grasped_short = st.grip_state == S_GRIP_CAUGHT and st.position > pos
if abs(st.position - pos) > 25 and not grasped_short:
print(
f"位置 {pos} 没有到位(仍在 {st.position})。",
file=sys.stderr,
)
return 1
return 0
except DhPgeaError as exc:
print(f"错误:{exc}", file=sys.stderr)
print(WIRING_HINT, file=sys.stderr)
return 1
finally:
grip.close()
return 0
def main(argv: Optional[list[str]] = None) -> int:
return _cli(argv)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""DH PGEA 的 ROS 2 节点(官方 Modbus RTU,不用 Flexiv 夹爪)。
话题(布局与 DH-Robotics/dh_gripper_ros 一致):
/gripper/ctrl std_msgs/Float32MultiArray
data: [initialize, position, force, speed]
initialize>0.5 → 写 0xA5;否则设置力/速度/位置
/gripper/states std_msgs/Float32MultiArray
data: [initialized, grip_state, position, target_pos, target_force]
/gripper/joint_states sensor_msgs/JointState
服务(std_srvs/Trigger):
/gripper/initialize /gripper/open /gripper/close
不要设置 Flexiv bringup 的 load_gripper:=true —— 那只是 Flexiv-GN01 用的。
"""
from __future__ import annotations
import threading
from typing import Optional
import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node
from sensor_msgs.msg import JointState
from std_msgs.msg import Float32, Float32MultiArray, MultiArrayDimension, MultiArrayLayout
from std_srvs.srv import Trigger
from arm_application.dh_pgea import (
DhPgeaError,
DhPgeaGripper,
GripperState,
WIRING_HINT,
)
from arm_application.ros_node import TolerantNode, param_as_str
def _as_ctrl_array(msg: Float32MultiArray) -> tuple[float, float, float, float]:
data = list(msg.data)
while len(data) < 4:
data.append(0.0)
return float(data[0]), float(data[1]), float(data[2]), float(data[3])
class DhPgeaNode(TolerantNode):
def __init__(self) -> None:
super().__init__("dh_pgea")
self.declare_parameter("port", "/dev/ttyUSB0")
self.declare_parameter("baud", 115200)
self.declare_parameter("slave_id", 1)
self.declare_parameter("stroke_mm", 20.0)
self.declare_parameter("force_pct", 50)
self.declare_parameter("speed_pct", 50)
self.declare_parameter("auto_init", True)
self.declare_parameter("state_hz", 20.0)
self.declare_parameter("open_position", 1000)
self.declare_parameter("close_position", 0)
self._model = param_as_str(self, "model", "PGEA-100-20-W")
self._sn = param_as_str(self, "serial_number", "")
self._rts = param_as_str(self, "rs485_rts", "")
self._port = str(self.get_parameter("port").value)
self._baud = int(self.get_parameter("baud").value)
self._slave = int(self.get_parameter("slave_id").value)
self._stroke = float(self.get_parameter("stroke_mm").value)
self._force = int(self.get_parameter("force_pct").value)
self._speed = int(self.get_parameter("speed_pct").value)
_auto = self.get_parameter("auto_init").value
if isinstance(_auto, str):
self._auto_init = _auto.strip().lower() in ("1", "true", "yes")
else:
self._auto_init = bool(_auto)
self._open_pos = int(self.get_parameter("open_position").value)
self._close_pos = int(self.get_parameter("close_position").value)
hz = float(self.get_parameter("state_hz").value)
self._grip = DhPgeaGripper(
port=self._port,
baudrate=self._baud,
slave_id=self._slave,
stroke_mm=self._stroke,
rs485_rts=self._rts,
)
self._state = GripperState(stroke_mm=self._stroke)
self._cmd_lock = threading.Lock()
self._pub_state = self.create_publisher(Float32MultiArray, "/gripper/states", 10)
self._pub_js = self.create_publisher(JointState, "/gripper/joint_states", 10)
self.create_subscription(Float32MultiArray, "/gripper/ctrl", self._on_ctrl, 10)
self.create_subscription(Float32, "/gripper/set_position", self._on_set_pos, 10)
self.create_service(Trigger, "/gripper/initialize", self._srv_init)
self.create_service(Trigger, "/gripper/open", self._srv_open)
self.create_service(Trigger, "/gripper/close", self._srv_close)
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"(官方 Modbus RTU;未使用 Flexiv RDK 夹爪 API)"
)
try:
self._grip.open()
except DhPgeaError as exc:
self.get_logger().error(str(exc))
raise
self._online = False
try:
self._grip.probe()
self._online = True
except DhPgeaError as exc:
self.get_logger().error(str(exc))
for line in WIRING_HINT.splitlines():
self.get_logger().error(line)
self.get_logger().error("会持续重试;在总线恢复应答前 open/close 都会失败")
if self._auto_init and self._online:
try:
self._grip.initialize()
self.get_logger().info("夹爪已初始化")
except DhPgeaError as exc:
self.get_logger().error(f"auto_init 失败:{exc}")
self._online = False
period = 1.0 / max(1.0, hz)
self._timer = self.create_timer(period, self._on_timer)
def destroy_node(self) -> bool:
try:
self._grip.close()
except Exception:
pass
return super().destroy_node()
def _apply_move(self, position: int, force: Optional[int] = None, speed: Optional[int] = None) -> str:
with self._cmd_lock:
f = self._force if force is None else int(force)
s = self._speed if speed is None else int(speed)
if force is not None:
self._force = f
if speed is not None:
self._speed = s
if not self._grip.move(int(position), f, s):
raise DhPgeaError("移动写入失败")
return f"pos={int(position)} force={f}% speed={s}%"
def _on_ctrl(self, msg: Float32MultiArray) -> None:
initialize, position, force, speed = _as_ctrl_array(msg)
try:
if initialize > 0.5:
self._grip.initialize()
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
info = self._apply_move(int(position), f, s)
self.get_logger().info(f"ctrl {info}")
except DhPgeaError as exc:
self.get_logger().error(str(exc))
def _on_set_pos(self, msg: Float32) -> None:
try:
info = self._apply_move(int(msg.data))
self.get_logger().info(f"set_position {info}")
except DhPgeaError as exc:
self.get_logger().error(str(exc))
def _srv_init(self, _req, resp):
try:
self._grip.initialize()
resp.success = True
resp.message = self._grip.get_run_states().summary()
except DhPgeaError as exc:
resp.success = False
resp.message = str(exc)
return resp
def _srv_open(self, _req, resp):
try:
info = self._apply_move(self._open_pos)
resp.success = True
resp.message = info
except DhPgeaError as exc:
resp.success = False
resp.message = str(exc)
return resp
def _srv_close(self, _req, resp):
try:
info = self._apply_move(self._close_pos)
resp.success = True
resp.message = info
except DhPgeaError as exc:
resp.success = False
resp.message = str(exc)
return resp
def _on_timer(self) -> None:
try:
st = self._grip.get_run_states(retries=2 if self._online else 1)
self._state = st
if not self._online:
self._online = True
self.get_logger().info("夹爪总线已恢复")
except DhPgeaError as exc:
self._online = False
self.get_logger().warn(str(exc), throttle_duration_sec=5.0)
return
arr = Float32MultiArray()
arr.layout = MultiArrayLayout(
dim=[MultiArrayDimension(label="dh_pgea", size=5, stride=5)],
data_offset=0,
)
arr.data = list(st.as_tuple())
self._pub_state.publish(arr)
js = JointState()
js.header.stamp = self.get_clock().now().to_msg()
js.name = ["gripper_finger1_joint"]
# 官方 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)
def main(args=None) -> None:
rclpy.init(args=args)
node = None
try:
node = DhPgeaNode()
rclpy.spin(node)
except KeyboardInterrupt:
pass
except DhPgeaError:
pass
finally:
if node is not None:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,803 @@
"""双臂夹爪统一控制架构(DH-Robotics 官方 Modbus RTU,非 Flexiv RDK)。
设计目标:
- 左臂(Rizon4-063989):大寰 PGEA 平行夹爪,控制行程/夹持力/速度。
- 右臂(Rizon4-063991):大寰 RGI-100-30-O-S-M1 旋转夹爪,控制手指夹持与旋转角度/圈数/速度。
- 统一接口,便于后续扩展到 ROS2 节点和新项目脚本。
注意:DH 官方协议是“写位置/角度寄存器后立即运动”,没有 ChangingTek SDK 的 0x0108 触发寄存器。
"""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum, auto
from pathlib import Path
from typing import Any
import numpy as np
from arm_application.dh_official import DHModbusClient, DHModbusError
class GripperType(Enum):
PARALLEL = auto() # 平行夹爪
ROTARY = auto() # 旋转夹爪
VACUUM = auto() # 真空吸盘(预留)
class GripperState(Enum):
IDLE = auto()
MOVING = auto()
GRIPPING = auto()
RELEASING = auto()
ERROR = auto()
@dataclass
class GripperStatus:
"""夹爪状态反馈。"""
state: GripperState = GripperState.IDLE
position: float = 0.0 # 平行:mm;旋转:角度 deg
force: float = 0.0 # 最近一次命令力/力矩 %
velocity: float = 0.0 # 最近一次命令速度 %
is_moving: bool = False
is_gripped: bool = False # 是否夹持到物体
connected: bool = False
initialized: bool = False
finger_position_per_mille: float = 0.0
rotation_deg: float = 0.0
gripper_state: int = -1 # DH 0x0201:0 moving,1 reached,2 caught,3 dropped
rotation_state: int = -1 # RGI 0x020B:0 moving,1 reached,2 blocking,3 blocked
error_msg: str = ""
timestamp: float = field(default_factory=time.time)
@dataclass
class GripperCommand:
"""夹爪控制指令。"""
target_position: float | None = None # 平行:mm;旋转:deg
target_force: float | None = None # 目标力/力矩 %
target_velocity: float | None = None # 目标速度 %
wait_complete: bool = True # 是否等待完成
timeout: float = 10.0 # 超时时间
class BaseGripper(ABC):
"""夹爪基类,定义统一接口。"""
name: str = "base_gripper"
gripper_type: GripperType = GripperType.PARALLEL
def __init__(self, config: dict[str, Any] | None = None) -> None:
self.cfg = config or {}
self._connected = False
self._status = GripperStatus()
@abstractmethod
def connect(self) -> None: ...
@abstractmethod
def disconnect(self) -> None: ...
@abstractmethod
def execute(self, cmd: GripperCommand) -> GripperStatus: ...
@abstractmethod
def get_status(self) -> GripperStatus: ...
def is_connected(self) -> bool:
return self._connected
def open(self, **kwargs) -> GripperStatus:
raise NotImplementedError
def close(self, **kwargs) -> GripperStatus:
raise NotImplementedError
def stop(self) -> None:
raise NotImplementedError
# =============================================================================
# 大寰平行夹爪(PGE/PGEA 系列)
# =============================================================================
@dataclass
class ParallelGripperConfig:
"""大寰平行夹爪配置。"""
port: str = "/dev/ttyUSB0"
slave_id: int = 1
baudrate: int = 115200
timeout: float = 0.2
# PGEA-100-40 行程为 40mm;若实际是 PGEA-100-26,请把 stroke_max 改成 26.0。
stroke_min: float = 0.0
stroke_max: float = 40.0
# 默认参数(DH 百分比)
default_speed: int = 50
default_force: int = 50
force_min: int = 20
force_max: int = 100
# 控制行为
auto_initialize: bool = True
init_timeout: float = 10.0
position_tolerance_per_mille: int = 15
status_poll_interval: float = 0.05
class ParallelGripper(BaseGripper):
"""大寰 PGE/PGEA 平行夹爪。"""
name = "dh_pgea_parallel"
gripper_type = GripperType.PARALLEL
def __init__(self, config: ParallelGripperConfig | None = None) -> None:
super().__init__()
self.cfg = config or ParallelGripperConfig()
self._client: DHModbusClient | None = None
self._last_position_mm = float(self.cfg.stroke_min)
self._last_force = int(self.cfg.default_force)
self._last_speed = int(self.cfg.default_speed)
def connect(self) -> None:
if self._client is not None:
self.disconnect()
self._client = DHModbusClient(
self.cfg.port,
slave_id=self.cfg.slave_id,
baudrate=self.cfg.baudrate,
timeout=self.cfg.timeout,
)
self._connected = True
self._status.connected = True
self._status.state = GripperState.IDLE
self._status.error_msg = ""
if self.cfg.auto_initialize:
self.initialize()
def disconnect(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
self._connected = False
self._status.connected = False
self._status.state = GripperState.IDLE
def initialize(self, full: bool = False) -> None:
self._require_client()
assert self._client is not None
self._client.initialize(full=full, rotation=False, timeout=self.cfg.init_timeout)
self._status.initialized = True
def _require_client(self) -> None:
if not self._connected or self._client is None:
self._status.state = GripperState.ERROR
self._status.error_msg = "平行夹爪未连接"
raise DHModbusError(self._status.error_msg)
def _mm_to_per_mille(self, position_mm: float) -> int:
lo = float(self.cfg.stroke_min)
hi = float(self.cfg.stroke_max)
if hi <= lo:
hi = lo + 1.0
ratio = (float(position_mm) - lo) / (hi - lo)
return int(np.clip(round(ratio * 1000.0), 0, 1000))
def _per_mille_to_mm(self, position: float | int) -> float:
lo = float(self.cfg.stroke_min)
hi = float(self.cfg.stroke_max)
return lo + (float(position) / 1000.0) * (hi - lo)
def execute(self, cmd: GripperCommand) -> GripperStatus:
try:
self._require_client()
except DHModbusError:
return self._status
assert self._client is not None
position_mm = self._last_position_mm if cmd.target_position is None else float(cmd.target_position)
position_mm = float(np.clip(position_mm, self.cfg.stroke_min, self.cfg.stroke_max))
speed = int(np.clip(cmd.target_velocity or self.cfg.default_speed, 1, 100))
force = int(np.clip(cmd.target_force or self.cfg.default_force, self.cfg.force_min, self.cfg.force_max))
self._client.set_speed_pct(speed)
self._client.set_force_pct(force)
target_per_mille = self._client.set_position_per_mille(self._mm_to_per_mille(position_mm))
self._last_position_mm = position_mm
self._last_speed = speed
self._last_force = force
self._status.state = GripperState.MOVING
self._status.error_msg = ""
if cmd.wait_complete:
self._wait_complete(target_per_mille, cmd.timeout)
return self.get_status()
def _wait_complete(self, target_per_mille: int, timeout: float) -> None:
assert self._client is not None
start = time.monotonic()
time.sleep(0.05)
while time.monotonic() - start < timeout:
state = self._client.get_gripper_state()
pos = self._client.get_position_per_mille()
self._status.gripper_state = state
self._status.finger_position_per_mille = float(pos)
self._status.position = self._per_mille_to_mm(pos)
self._status.is_moving = state == 0
self._status.is_gripped = state == 2
if state in (1, 2):
self._status.state = GripperState.GRIPPING if state == 2 else GripperState.IDLE
return
if state == 3:
self._status.state = GripperState.ERROR
self._status.error_msg = "夹持后物体掉落"
return
if abs(pos - target_per_mille) <= self.cfg.position_tolerance_per_mille and state != 0:
self._status.state = GripperState.IDLE
return
time.sleep(self.cfg.status_poll_interval)
self._status.state = GripperState.ERROR
self._status.error_msg = f"平行夹爪运动超时(>{timeout:.1f}s)"
def get_status(self) -> GripperStatus:
if not self._connected or self._client is None:
self._status.connected = False
return self._status
try:
pos = self._client.get_position_per_mille()
state = self._client.get_gripper_state()
init_state = self._client.get_init_state()
self._status.connected = True
self._status.initialized = init_state == 1
self._status.finger_position_per_mille = float(pos)
self._status.position = self._per_mille_to_mm(pos)
self._status.velocity = float(self._last_speed)
self._status.force = float(self._last_force)
self._status.gripper_state = state
self._status.is_moving = state == 0
self._status.is_gripped = state == 2
if self._status.state != GripperState.ERROR:
self._status.state = GripperState.MOVING if state == 0 else (
GripperState.GRIPPING if state == 2 else GripperState.IDLE
)
self._status.timestamp = time.time()
except Exception as exc:
self._status.state = GripperState.ERROR
self._status.error_msg = str(exc)
self._status.connected = False
return self._status
def open(self, force: int | None = None, speed: int | None = None, **kwargs) -> GripperStatus:
# DH 千分比:0‰=闭合,1000‰=张开,对应行程 0mm -> stroke_max。
return self.execute(GripperCommand(
target_position=float(self.cfg.stroke_max),
target_force=force or self.cfg.default_force,
target_velocity=speed or self.cfg.default_speed,
**kwargs,
))
def close(self, force: int | None = None, speed: int | None = None, **kwargs) -> GripperStatus:
return self.execute(GripperCommand(
target_position=float(self.cfg.stroke_min),
target_force=force or self.cfg.default_force,
target_velocity=speed or self.cfg.default_speed,
**kwargs,
))
def move_to(self, position_mm: float, force: int | None = None, speed: int | None = None,
**kwargs) -> GripperStatus:
return self.execute(GripperCommand(
target_position=position_mm,
target_force=force or self.cfg.default_force,
target_velocity=speed or self.cfg.default_speed,
**kwargs,
))
def set_force(self, force_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_force = self._client.set_force_pct(force_pct)
def set_speed(self, speed_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_speed = self._client.set_speed_pct(speed_pct)
def stop(self) -> None:
if self._client is not None:
self._client.hold_current_position()
self._status.state = GripperState.IDLE
# =============================================================================
# 大寰旋转夹爪(RGI-100-30-O-S-M1)
# =============================================================================
@dataclass
class RotaryGripperConfig:
"""大寰 RGI 旋转夹爪配置。"""
port: str = "/dev/ttyUSB1"
slave_id: int = 1
baudrate: int = 115200
timeout: float = 0.2
# RGI 旋转角度寄存器是 signed 16bit;为避免 one's-complement 负零歧义,默认限制在 ±32767。
angle_min: float = -32767.0
angle_max: float = 32767.0
max_step_deg: float = 30000.0 # rotate_revolutions 分段旋转的最大单步角度
# DH 手册:手指位置 0~1000‰。0=闭合,1000=张开。RGI-100 行程约 70mm。
finger_open_per_mille: int = 1000
finger_close_per_mille: int = 0
# 默认参数(DH 百分比)
default_speed: int = 50 # 旋转速度 %
default_torque: int = 50 # 旋转力/力矩 %
default_finger_speed: int = 50 # 手指速度 %
default_finger_force: int = 50 # 手指夹持力 %
torque_min: int = 20
torque_max: int = 100
# 控制行为
auto_initialize: bool = True
init_timeout: float = 15.0 # RGI 初始化包含旋转找零,时间更长
angle_tolerance_deg: float = 1.0
status_poll_interval: float = 0.05
class RotaryGripper(BaseGripper):
"""大寰 RGI-100-30-O-S-M1 旋转夹爪。"""
name = "dh_rgi_rotary"
gripper_type = GripperType.ROTARY
def __init__(self, config: RotaryGripperConfig | None = None) -> None:
super().__init__()
self.cfg = config or RotaryGripperConfig()
self._client: DHModbusClient | None = None
self._last_angle = 0.0
self._last_torque = int(self.cfg.default_torque)
self._last_speed = int(self.cfg.default_speed)
self._last_finger_force = int(self.cfg.default_finger_force)
self._last_finger_speed = int(self.cfg.default_finger_speed)
self._last_finger_per_mille = int(self.cfg.finger_open_per_mille)
def connect(self) -> None:
if self._client is not None:
self.disconnect()
self._client = DHModbusClient(
self.cfg.port,
slave_id=self.cfg.slave_id,
baudrate=self.cfg.baudrate,
timeout=self.cfg.timeout,
)
self._connected = True
self._status.connected = True
self._status.state = GripperState.IDLE
self._status.error_msg = ""
if self.cfg.auto_initialize:
self.initialize()
def disconnect(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
self._connected = False
self._status.connected = False
self._status.state = GripperState.IDLE
def initialize(self, full: bool = False) -> None:
self._require_client()
assert self._client is not None
self._client.initialize(full=full, rotation=True, timeout=self.cfg.init_timeout)
self._status.initialized = True
def _require_client(self) -> None:
if not self._connected or self._client is None:
self._status.state = GripperState.ERROR
self._status.error_msg = "旋转夹爪未连接"
raise DHModbusError(self._status.error_msg)
def execute(self, cmd: GripperCommand) -> GripperStatus:
"""执行旋转指令;cmd.target_position 单位为度。"""
return self.rotate_to(
self._last_angle if cmd.target_position is None else float(cmd.target_position),
speed=cmd.target_velocity,
torque=cmd.target_force,
wait_complete=cmd.wait_complete,
timeout=cmd.timeout,
)
def _wait_complete(self, target_angle: float, timeout: float) -> None:
assert self._client is not None
start = time.monotonic()
start_angle = float(self._client.get_rotation_angle_deg())
expected = abs(float(target_angle) - start_angle) + 30.0
settled = 0
last_angle = start_angle
time.sleep(0.05)
while time.monotonic() - start < timeout:
rot_state = self._client.get_rotation_state()
angle = float(self._client.get_rotation_angle_deg())
self._status.rotation_state = rot_state
self._status.rotation_deg = angle
self._status.position = angle
self._status.is_moving = rot_state == 0
if abs(angle - start_angle) > expected:
self._client.hold_current_rotation()
self._status.state = GripperState.ERROR
self._status.error_msg = (
f"旋转超程保护:目标 {target_angle:.1f}°,从 {start_angle:.1f}° 跑到 {angle:.1f}°,已发停止"
)
return
if rot_state in (1, 2):
self._status.state = GripperState.IDLE if rot_state == 1 else GripperState.GRIPPING
if rot_state == 2:
self._status.error_msg = "旋转受阻(blocking)"
return
if rot_state == 3:
self._status.state = GripperState.ERROR
self._status.error_msg = "旋转曾受阻且未到目标位"
return
if abs(angle - float(target_angle)) <= self.cfg.angle_tolerance_deg:
settled += 1
if settled >= 3:
self._status.state = GripperState.IDLE
self._status.is_moving = False
return
elif abs(angle - last_angle) <= self.cfg.angle_tolerance_deg:
settled += 1
if settled >= 8 and abs(angle - float(target_angle)) <= 15.0:
self._status.state = GripperState.IDLE
self._status.is_moving = False
return
else:
settled = 0
last_angle = angle
time.sleep(self.cfg.status_poll_interval)
self._client.hold_current_rotation()
self._status.state = GripperState.ERROR
self._status.error_msg = f"旋转运动超时(>{timeout:.1f}s),已发停止"
def get_status(self) -> GripperStatus:
if not self._connected or self._client is None:
self._status.connected = False
return self._status
try:
finger_pos = self._client.get_position_per_mille()
finger_state = self._client.get_gripper_state()
init_state = self._client.get_init_state()
rot_angle = self._client.get_rotation_angle_deg()
rot_state = self._client.get_rotation_state()
rot_init = self._client.get_rotation_init_state()
self._status.connected = True
self._status.initialized = init_state == 1 and rot_init == 1
self._status.finger_position_per_mille = float(finger_pos)
self._status.rotation_deg = float(rot_angle)
self._status.position = float(rot_angle)
self._status.velocity = float(self._last_speed)
self._status.force = float(self._last_torque)
self._status.gripper_state = finger_state
self._status.rotation_state = rot_state
self._status.is_moving = finger_state == 0 or rot_state == 0
self._status.is_gripped = finger_state == 2
if self._status.state != GripperState.ERROR:
if finger_state == 2 or rot_state == 2:
self._status.state = GripperState.GRIPPING
elif finger_state == 0 or rot_state == 0:
self._status.state = GripperState.MOVING
else:
self._status.state = GripperState.IDLE
self._status.timestamp = time.time()
except Exception as exc:
self._status.state = GripperState.ERROR
self._status.error_msg = str(exc)
self._status.connected = False
return self._status
# -----------------------------
# 手指夹持
# -----------------------------
def move_fingers(self, position_per_mille: int, force: int | None = None,
speed: int | None = None, wait_complete: bool = True,
timeout: float = 10.0) -> GripperStatus:
try:
self._require_client()
except DHModbusError:
return self._status
assert self._client is not None
target = int(np.clip(position_per_mille, 0, 1000))
f = int(np.clip(force or self.cfg.default_finger_force, 20, 100))
s = int(np.clip(speed or self.cfg.default_finger_speed, 1, 100))
self._client.set_speed_pct(s)
self._client.set_force_pct(f)
self._client.set_position_per_mille(target)
self._last_finger_per_mille = target
self._last_finger_force = f
self._last_finger_speed = s
if wait_complete:
start = time.monotonic()
time.sleep(0.05)
while time.monotonic() - start < timeout:
state = self._client.get_gripper_state()
pos = self._client.get_position_per_mille()
self._status.gripper_state = state
self._status.finger_position_per_mille = float(pos)
if state in (1, 2):
break
if state == 3:
self._status.state = GripperState.ERROR
self._status.error_msg = "夹持后物体掉落"
break
time.sleep(self.cfg.status_poll_interval)
return self.get_status()
def open_fingers(self, **kwargs) -> GripperStatus:
return self.move_fingers(self.cfg.finger_open_per_mille, **kwargs)
def close_fingers(self, **kwargs) -> GripperStatus:
return self.move_fingers(self.cfg.finger_close_per_mille, **kwargs)
# -----------------------------
# 旋转
# -----------------------------
def rotate_to(self, angle_deg: float, speed: int | None = None, torque: int | None = None,
wait_complete: bool = True, timeout: float = 10.0, **kwargs) -> GripperStatus:
try:
self._require_client()
except DHModbusError:
return self._status
assert self._client is not None
angle = float(np.clip(angle_deg, self.cfg.angle_min, self.cfg.angle_max))
s = int(np.clip(speed or self.cfg.default_speed, 1, 100))
t = int(np.clip(torque or self.cfg.default_torque, self.cfg.torque_min, self.cfg.torque_max))
self._client.set_rotation_speed_pct(s)
self._client.set_rotation_force_pct(t)
target = self._client.set_rotation_angle_deg(angle)
self._last_angle = float(target)
self._last_speed = s
self._last_torque = t
self._status.state = GripperState.MOVING
self._status.error_msg = ""
if wait_complete:
self._wait_complete(float(target), timeout)
return self.get_status()
def rotate_by(self, delta_deg: float, **kwargs) -> GripperStatus:
current = self.get_status().rotation_deg
return self.rotate_to(current + float(delta_deg), **kwargs)
def rotate_revolutions(self, revolutions: float, **kwargs) -> GripperStatus:
"""旋转指定圈数;超过单步角度范围时自动分段,并按实际反馈角度结算。"""
remaining = float(revolutions) * 360.0
status = self.get_status()
max_step = abs(float(self.cfg.max_step_deg)) or 30000.0
while abs(remaining) > 1e-6:
before = float(self.get_status().rotation_deg)
step = float(np.clip(remaining, -max_step, max_step))
status = self.rotate_to(before + step, **kwargs)
if status.state == GripperState.ERROR:
return status
after = float(status.rotation_deg)
achieved = after - before
if abs(achieved) < max(1.0, abs(step) * 0.2):
status.state = GripperState.ERROR
status.error_msg = (
"旋转未达到预期步进,可能超出角度寄存器范围或反馈异常:"
f"step={step:.3f}, before={before:.3f}, after={after:.3f}"
)
return status
remaining -= achieved
return status
def home_rotation(self, **kwargs) -> GripperStatus:
return self.rotate_to(0.0, **kwargs)
def set_rotation_speed(self, speed_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_speed = self._client.set_rotation_speed_pct(speed_pct)
def set_rotation_torque(self, torque_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_torque = self._client.set_rotation_force_pct(torque_pct)
def set_finger_speed(self, speed_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_finger_speed = self._client.set_speed_pct(speed_pct)
def set_finger_force(self, force_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_finger_force = self._client.set_force_pct(force_pct)
def open(self, **kwargs) -> GripperStatus:
"""对于 RGI,open 定义为张开手指;旋转请用 rotate_to/rotate_revolutions。"""
return self.open_fingers(**kwargs)
def close(self, **kwargs) -> GripperStatus:
"""对于 RGI,close 定义为闭合手指;旋转请用 rotate_to/rotate_revolutions。"""
return self.close_fingers(**kwargs)
def stop(self) -> None:
if self._client is not None:
self._client.hold_current_all()
self._status.state = GripperState.IDLE
# =============================================================================
# 双臂夹爪管理器
# =============================================================================
@dataclass
class DualArmGripperConfig:
"""双臂夹爪配置。
这里**没有** ``left_arm_sn`` / ``right_arm_sn``:它们曾经存在但从来没有被读过
(只写不读的死字段,R5.5),而且默认值是本机的 SN —— 换台臂就会误导读者。
哪个侧别是哪台臂,由 ``load_from_site()`` 从 site.yaml 的 ``robots[].arm`` 决定。
"""
left_gripper: ParallelGripperConfig = field(default_factory=ParallelGripperConfig)
right_gripper: RotaryGripperConfig = field(default_factory=RotaryGripperConfig)
class DualArmGripperManager:
"""双臂夹爪管理器:统一控制左右臂夹爪。"""
def __init__(self, config: DualArmGripperConfig | None = None) -> None:
self.cfg = config or DualArmGripperConfig()
self._left: ParallelGripper | None = None
self._right: RotaryGripper | None = None
@property
def left(self) -> ParallelGripper:
if self._left is None:
self._left = ParallelGripper(self.cfg.left_gripper)
return self._left
@property
def right(self) -> RotaryGripper:
if self._right is None:
self._right = RotaryGripper(self.cfg.right_gripper)
return self._right
def connect_all(self) -> None:
self.left.connect()
self.right.connect()
def disconnect_all(self) -> None:
if self._left:
self._left.disconnect()
if self._right:
self._right.disconnect()
def initialize_all(self, full: bool = False) -> None:
self.left.initialize(full=full)
self.right.initialize(full=full)
def get_all_status(self) -> dict[str, GripperStatus]:
return {
"left": self.left.get_status(),
"right": self.right.get_status(),
}
def emergency_stop(self) -> None:
self.left.stop()
self.right.stop()
def load_dual_arm_gripper_config(path: str | Path) -> DualArmGripperConfig:
"""从 configs/dual_arm_robot.yaml 加载双臂夹爪配置。"""
import yaml
data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
left = data.get("left_arm", {}) or {}
right = data.get("right_arm", {}) or {}
grip = data.get("gripper", {}) or {}
parallel = grip.get("parallel", {}) or {}
rotary = grip.get("rotary", {}) or {}
left_cfg = ParallelGripperConfig(
port=left.get("gripper_port", "/dev/ttyUSB0"),
slave_id=int(left.get("gripper_slave_id", 1)),
baudrate=int(left.get("gripper_baudrate", 115200)),
stroke_min=float(parallel.get("stroke_min", 0.0)),
stroke_max=float(parallel.get("stroke_max", 40.0)),
default_speed=int(parallel.get("default_speed", 50)),
default_force=int(parallel.get("default_force", 50)),
force_min=int(parallel.get("force_min", 20)),
force_max=int(parallel.get("force_max", 100)),
auto_initialize=bool(parallel.get("auto_initialize", True)),
)
right_cfg = RotaryGripperConfig(
port=right.get("gripper_port", "/dev/ttyUSB1"),
slave_id=int(right.get("gripper_slave_id", 1)),
baudrate=int(right.get("gripper_baudrate", 115200)),
angle_min=float(rotary.get("angle_min", -32767.0)),
angle_max=float(rotary.get("angle_max", 32767.0)),
max_step_deg=float(rotary.get("max_step_deg", 30000.0)),
finger_open_per_mille=int(rotary.get("finger_open_per_mille", 1000)),
finger_close_per_mille=int(rotary.get("finger_close_per_mille", 0)),
default_speed=int(rotary.get("default_speed", 50)),
default_torque=int(rotary.get("default_torque", 50)),
default_finger_speed=int(rotary.get("default_finger_speed", 50)),
default_finger_force=int(rotary.get("default_finger_force", 50)),
torque_min=int(rotary.get("torque_min", 20)),
torque_max=int(rotary.get("torque_max", 100)),
auto_initialize=bool(rotary.get("auto_initialize", True)),
)
return DualArmGripperConfig(
left_gripper=left_cfg,
right_gripper=right_cfg,
)
def load_from_site(data: dict) -> DualArmGripperConfig:
"""从 ``~/ros2/config/site.yaml`` 构建 ``DualArmGripperConfig``。
左右靠 ``robots[].arm`` 认,**不靠 SN 里的数字**:``"063989 就是左臂"``
是站点数据,写进代码后换个站点就会把左右夹爪搞反
(症状是"右臂的旋转爪去开左臂的平行爪",串口对不上才报错)。
"""
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"), {})
g = data.get("gripper") or {}
parallel = g.get("parallel") or {}
rotary = g.get("rotary") or {}
lg = left.get("gripper") or {}
rg = right.get("gripper") or {}
stroke = float(lg.get("stroke_mm") or parallel.get("stroke_max") or g.get("stroke_mm") or 20.0)
left_cfg = ParallelGripperConfig(
port=str(lg.get("port") or "/dev/ttyUSB0"),
slave_id=int(lg.get("slave_id") or g.get("slave_id") or 1),
baudrate=int(lg.get("baud") or g.get("baud") or 115200),
stroke_min=float(parallel.get("stroke_min") or 0.0),
stroke_max=stroke,
default_speed=int(parallel.get("default_speed") or g.get("speed_pct") or 50),
default_force=int(parallel.get("default_force") or g.get("force_pct") or 50),
force_min=int(parallel.get("force_min") or 20),
force_max=int(parallel.get("force_max") or 100),
auto_initialize=bool(lg.get("auto_init", parallel.get("auto_initialize", True))),
init_timeout=float(parallel.get("init_timeout") or 10.0),
)
right_cfg = RotaryGripperConfig(
port=str(rg.get("port") or "/dev/ttyUSB1"),
slave_id=int(rg.get("slave_id") or 1),
baudrate=int(rg.get("baud") or g.get("baud") or 115200),
angle_min=float(rotary.get("angle_min") or -32767.0),
angle_max=float(rotary.get("angle_max") or 32767.0),
max_step_deg=float(rotary.get("max_step_deg") or 30000.0),
finger_open_per_mille=int(rotary.get("finger_open_per_mille") or 1000),
finger_close_per_mille=int(rotary.get("finger_close_per_mille") or 0),
default_speed=int(rotary.get("default_speed") or 50),
default_torque=int(rotary.get("default_torque") or 50),
default_finger_speed=int(rotary.get("default_finger_speed") or 50),
default_finger_force=int(rotary.get("default_finger_force") or 50),
torque_min=int(rotary.get("torque_min") or 20),
torque_max=int(rotary.get("torque_max") or 100),
auto_initialize=bool(rg.get("auto_init", rotary.get("auto_initialize", False))),
init_timeout=float(rotary.get("init_timeout") or 15.0),
)
return DualArmGripperConfig(
left_gripper=left_cfg,
right_gripper=right_cfg,
)
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""DH PGEA(左臂)与 RGI(右臂)的命令行工具。从 site.yaml 读取串口。"""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
import yaml
from arm_application.dual_gripper import DualArmGripperManager, load_from_site
def _site_path() -> Path:
env = os.environ.get("SITE_CONFIG", "").strip()
if env:
return Path(env).expanduser()
return Path.home() / "ros2" / "config" / "site.yaml"
def _print_status(label: str, status) -> None:
print(
f" {label}: state={status.state.name} "
f"pos={status.position:.2f} finger={status.finger_position_per_mille:.0f}‰ "
f"rot={status.rotation_deg:.2f}° moving={status.is_moving} "
f"gripped={status.is_gripped} init={status.initialized} "
f"err={status.error_msg or '-'}"
)
def _load_site_exporter():
"""复用 ``scripts/lib/export_site_env.py`` —— 它才是 --arm 别名的权威定义。
这里**不自己再抄一份** ``89 -> SN`` 的映射:抄一份就多一处会漂移的地方(M2)。
"""
root = Path(__file__).resolve().parents[3]
lib = str(root / "scripts" / "lib")
if lib not in sys.path:
sys.path.insert(0, lib)
import export_site_env # type: ignore
return export_site_env
def _normalize_arm(raw: str, data: dict) -> str:
"""把 --arm 的写法归一成 ``left`` / ``right`` / ``both``。
别名(``89`` 这类)由 ``export_site_env`` 从 site.yaml 的 ``robots[]`` 现算
(臂号 = 该臂 SN 末两位)。代码里**不写死** SN,否则换台机器人后会指错臂,
而症状是"给右臂发命令,左臂动了"。
"""
t = (raw or "").strip()
if t.lower() in ("both", "dual", "all"):
return "both"
site_env = _load_site_exporter()
sn, matched = site_env.resolve_robot_sn(data, t)
if not matched:
raise SystemExit(
f"未知的 --arm {raw!r}:不在 site.yaml 的 robot.robots[] 里。\n"
f"可用:\n{site_env.describe_arms(data)}\n"
f"也可以直接写 left|right|both。"
)
for row in (data.get("robot") or {}).get("robots") or []:
if str(row.get("sn") or "").strip() != sn:
continue
arm = str(row.get("arm") or "").strip().lower()
if arm in ("left", "right"):
return arm
raise SystemExit(
f"--arm {raw!r} 选中了 {sn},但 site.yaml 里这台臂没写 arm: left|right,"
f"无法判断操作哪个夹爪。请补上这个键。"
)
raise SystemExit(
f"--arm {raw!r} 解析出 {sn},但它不在 site.yaml 的 robot.robots[] 里,"
f"无法判断是哪个夹爪。"
)
def test_parallel(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
gripper = manager.left
print(f"=== PGEA 平行夹爪({gripper.cfg.port})===")
gripper.connect()
_print_status("已连接", gripper.get_status())
if args.test in ("init", "probe", "all"):
if args.test != "probe":
gripper.initialize(full=args.init_full)
_print_status("初始化", gripper.get_status())
if args.test in ("open", "all"):
_print_status("张开", gripper.open(force=args.force, speed=args.speed))
if args.test in ("close", "all"):
_print_status("闭合", gripper.close(force=args.force, speed=args.speed))
if args.test in ("move", "pos") and args.pos is not None:
_print_status("移动", gripper.move_to(args.pos, force=args.force, speed=args.speed))
if args.test in ("status", "probe"):
_print_status("状态", gripper.get_status())
def test_rotary(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
gripper = manager.right
print(f"=== RGI 旋转夹爪({gripper.cfg.port})===")
gripper.connect()
_print_status("已连接", gripper.get_status())
if args.test in ("init", "all"):
gripper.initialize(full=args.init_full)
_print_status("初始化", gripper.get_status())
if args.test in ("open", "all"):
_print_status("张开", gripper.open_fingers(force=args.force, speed=args.speed))
if args.test in ("close", "all"):
_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("手指", 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(
"圈数",
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("旋转", 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("相对旋转", gripper.rotate_by(delta, speed=args.speed, torque=args.force))
if args.test in ("status", "probe"):
_print_status("状态", gripper.get_status())
def main() -> int:
parser = argparse.ArgumentParser(description="DH PGEA / RGI 命令行工具(串口来自 site.yaml)")
parser.add_argument(
"--arm",
# 默认取当前臂(parse_arm_args.sh / load_site_config.sh 已经把它放进环境)。
# **不**兜底成某个具体臂号:那等于"没选臂时也能跑,跑的是哪台看运气"。
default=(
os.environ.get("SITE_ROBOT_ARM")
or os.environ.get("ROBOT_SN")
or os.environ.get("SITE_ROBOT_SN")
or ""
),
help="left|right|both,或该臂的臂号(= SN 末两位,由 site.yaml 决定)",
)
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;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)
parser.add_argument("--force", type=int, default=33)
parser.add_argument("--init-full", action="store_true")
parser.add_argument("--site", default="")
args = parser.parse_args()
site = Path(args.site).expanduser() if args.site else _site_path()
if not site.is_file():
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))
arm = _normalize_arm(str(args.arm), data)
print(f"site={site} arm={arm} test={args.test}")
try:
if arm == "left":
test_parallel(manager, args)
elif arm == "right":
test_rotary(manager, args)
else:
test_parallel(manager, args)
test_rotary(manager, args)
except KeyboardInterrupt:
print("收到中断 —— 停止")
manager.emergency_stop()
return 130
except Exception as exc:
print(f"错误:{exc}", file=sys.stderr)
return 1
finally:
manager.disconnect_all()
time.sleep(0.05)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,320 @@
"""ROS 2 双臂 DH 夹爪节点(左臂 PGEA + 右臂 RGI)。使用 arm_msgs,而非 Flexiv RDK。"""
from __future__ import annotations
import os
import threading
from pathlib import Path
import rclpy
import yaml
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from arm_application.dual_gripper import (
DualArmGripperManager,
GripperState,
load_dual_arm_gripper_config,
load_from_site,
)
from arm_msgs.msg import GripperCommand as GripperCommandMsg
from arm_msgs.msg import GripperStatus as GripperStatusMsg
from arm_msgs.srv import GripperExecute
from arm_application.ros_node import TolerantNode
def _site_path() -> Path:
env = os.environ.get("SITE_CONFIG", "").strip()
if env:
return Path(env).expanduser()
return Path.home() / "ros2" / "config" / "site.yaml"
def _as_bool(val, default: bool = True) -> bool:
if val is None:
return default
if isinstance(val, str):
return val.strip().lower() in ("1", "true", "yes", "on")
return bool(val)
class DualGripperNode(TolerantNode):
def __init__(self) -> None:
super().__init__("dual_gripper_driver")
self.declare_parameter("config_file", "")
self.declare_parameter("auto_connect", True)
self.declare_parameter("allow_offline", True)
self.declare_parameter("publish_hz", 10.0)
self.declare_parameter("sides", "left,right")
self._cb_group = ReentrantCallbackGroup()
self._locks = {"left": threading.Lock(), "right": threading.Lock()}
sides_raw = str(self.get_parameter("sides").value or "left,right")
self._sides = [s.strip().lower() for s in sides_raw.split(",") if s.strip()]
if not self._sides:
self._sides = ["left", "right"]
cfg_path = self.get_parameter("config_file").get_parameter_value().string_value.strip()
if cfg_path:
self._manager = DualArmGripperManager(load_dual_arm_gripper_config(cfg_path))
else:
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"夹爪配置来自 site.yaml:{site}")
self._connect_errors: dict[str, str] = {}
if _as_bool(self.get_parameter("auto_connect").value, True):
self._connect_all()
self._left_pub = self.create_publisher(GripperStatusMsg, "~/left/status", 10)
self._right_pub = self.create_publisher(GripperStatusMsg, "~/right/status", 10)
if "left" in self._sides:
self.create_subscription(
GripperCommandMsg,
"~/left/command",
lambda msg: self._on_command("left", msg),
10,
callback_group=self._cb_group,
)
self.create_service(
GripperExecute,
"~/left/execute",
lambda req, resp: self._on_execute("left", req, resp),
callback_group=self._cb_group,
)
if "right" in self._sides:
self.create_subscription(
GripperCommandMsg,
"~/right/command",
lambda msg: self._on_command("right", msg),
10,
callback_group=self._cb_group,
)
self.create_service(
GripperExecute,
"~/right/execute",
lambda req, resp: self._on_execute("right", req, resp),
callback_group=self._cb_group,
)
period = 1.0 / max(1e-3, float(self.get_parameter("publish_hz").value))
self.create_timer(period, self._publish_status, callback_group=self._cb_group)
self.get_logger().info(
f"dual_gripper_driver ready sides={self._sides} "
"~/left|right/command status execute"
)
def _connect_all(self) -> None:
mapping = (("left", self._manager.left), ("right", self._manager.right))
for side, gripper in mapping:
if side not in self._sides:
continue
try:
gripper.connect()
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} 夹爪连接失败:{exc}")
if self._connect_errors and not _as_bool(self.get_parameter("allow_offline").value, True):
raise RuntimeError(f"夹爪连接失败:{self._connect_errors}")
def _gripper(self, side: str):
return self._manager.left if side == "left" else self._manager.right
def _execute(self, side: str, msg: GripperCommandMsg):
gripper = self._gripper(side)
command = (msg.command or "").strip().lower()
timeout = float(msg.timeout or 10.0)
wait = bool(msg.wait_complete)
if side in self._connect_errors and not gripper.is_connected():
status = gripper.get_status()
status.error_msg = self._connect_errors[side]
return status
if command == "connect":
gripper.connect()
self._connect_errors.pop(side, None)
return gripper.get_status()
if command == "init":
gripper.initialize(full=False)
return gripper.get_status()
if command == "init_full":
gripper.initialize(full=True)
return gripper.get_status()
if command == "stop":
gripper.stop()
return gripper.get_status()
if side == "left":
if command == "open":
return gripper.open(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "close":
return gripper.close(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "move":
return gripper.move_to(
float(msg.position),
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "set_force":
gripper.set_force(int(msg.force))
return gripper.get_status()
if command == "set_speed":
gripper.set_speed(int(msg.speed))
return gripper.get_status()
else:
if command == "open":
return gripper.open_fingers(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "close":
return gripper.close_fingers(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "move":
return gripper.move_fingers(
int(msg.position),
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command in ("rotate", "rotate_to"):
return gripper.rotate_to(
float(msg.position),
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "rotate_by":
return gripper.rotate_by(
float(msg.position),
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "rotate_revolutions":
return gripper.rotate_revolutions(
float(msg.position),
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "home_rotation":
return gripper.home_rotation(
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "set_force":
gripper.set_rotation_torque(int(msg.force))
return gripper.get_status()
if command == "set_speed":
gripper.set_rotation_speed(int(msg.speed))
return gripper.get_status()
if command == "set_finger_force":
gripper.set_finger_force(int(msg.force))
return gripper.get_status()
if command == "set_finger_speed":
gripper.set_finger_speed(int(msg.speed))
return gripper.get_status()
status = gripper.get_status()
status.state = GripperState.ERROR
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} 命令 {msg.command!r} 失败:{status.error_msg}")
def _on_execute(self, side: str, request: GripperExecute.Request, response: GripperExecute.Response):
with self._locks[side]:
status = self._execute(side, request.command)
response.success = status.state != GripperState.ERROR and status.connected
response.message = status.error_msg or "OK"
response.status = self._to_status_msg(side, status)
return response
def _to_status_msg(self, side: str, status) -> GripperStatusMsg:
gripper = self._gripper(side)
msg = GripperStatusMsg()
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = f"{side}_gripper"
msg.name = gripper.name
msg.gripper_type = gripper.gripper_type.name.lower()
msg.state = status.state.name
msg.connected = bool(status.connected)
msg.initialized = bool(status.initialized)
msg.is_moving = bool(status.is_moving)
msg.is_gripped = bool(status.is_gripped)
msg.position = float(status.position)
msg.finger_position_per_mille = float(status.finger_position_per_mille)
msg.rotation_deg = float(status.rotation_deg)
msg.gripper_state = int(status.gripper_state)
msg.rotation_state = int(status.rotation_state)
msg.velocity = float(status.velocity)
msg.force = float(status.force)
msg.error_msg = status.error_msg or ""
return msg
def _publish_status(self) -> None:
if "left" in self._sides:
left_status = self._manager.left.get_status()
if "left" in self._connect_errors and not left_status.connected:
left_status.error_msg = self._connect_errors["left"]
self._left_pub.publish(self._to_status_msg("left", left_status))
if "right" in self._sides:
right_status = self._manager.right.get_status()
if "right" in self._connect_errors and not right_status.connected:
right_status.error_msg = self._connect_errors["right"]
self._right_pub.publish(self._to_status_msg("right", right_status))
def destroy_node(self) -> bool:
try:
self._manager.disconnect_all()
finally:
return super().destroy_node()
def main() -> None:
rclpy.init()
node = DualGripperNode()
executor = MultiThreadedExecutor(num_threads=4)
try:
rclpy.spin(node, executor=executor)
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""跟踪点的三道守卫:**位置合理性** + **每帧位移上限** + **远框重捕获确认**。
为什么需要它们(2026-10-08 真机 arm 89,一阶段跑 150s 没锁上)
------------------------------------------------------------
``/tmp/pipeline_stage1.log`` 原话。真 k0 一开始是对的::
764.79 跟踪 keypoint conf=0.87 uv=(410,246) z=0.299 ← 真 k0(画面中偏右)
然后在 1 秒内被一串**假 k0** 顶掉(全都是高置信度)::
766.96 重新捕获 conf=0.58 dpx=480
766.96 跟踪 keypoint conf=0.58 uv=(76,75) z=0.392 ← 贴左上角
767.75 跟踪 keypoint conf=0.23 uv=(169,107) z_ok=0 深度过期 box_z hold_near
768.16 跟踪 keypoint conf=0.53 uv=(143,92) z=0.376
768.78 跟踪 keypoint conf=0.73 uv=(12,30) k0(ok=1551 near=620 借深度) z=0.276
769.66 重新捕获 conf=0.22 dpx=207
770.07 跟踪 keypoint conf=0.73 uv=(431,226) k0(ok=19440 near=7776 借深度) z=0.271
伺服于是以 0.45m/s 追着它们跑:``ee_xy`` home ``(0.686,-0.113)`` → ``(0.354,-0.294)``
→ ``(0.083,-0.515)``(工作盒角落),1.2s 后看门狗急停;工件已在半米外,
剩下 148s 全是"检测漏检"。三条结论:
1. **置信度挡不住**:假 k0 的 conf 是 0.53~0.73,真 k0 是 0.87 —— 没有可分区间。
但假 k0 有一个共同特征:**落在画面边上**(``uv=(12,30)`` / ``(76,75)`` /
``(143,92)``),而孔心长在工件上、不可能贴在画面边缘;
2. **借深度把"没有深度"伪装成"有深度"**:``k0(… 借深度)`` 的 z 是从工件框里
借的,看着很合理(0.27~0.39m),于是"物理量程"这道守卫也失效了。而
**借来的深度意味着该像素处读不到自己的深度** ⇒ 那个点的绝对 XY 不可靠,
不该用来**重定位**(换一个跟踪位置);
3. **位移不受限**:真 k0 到假 k0 一帧能差 480px / 400mm,伺服一次就全速扑过去。
三道守卫各挡一层,全都是"能真的失败"的判据(见 test_feature_guard_*.py 的负向用例):
``kpt_plausible``
k0 必须落在自己的检测框内(含少量余量)、且不许贴画面边缘。违反 ⇒ 本帧的
k0 **位置**不可信(不是"没有目标"),交回调用方走"按框上相对偏移重建"的
连续性路径(``perception_node._keypoint_feature`` 本来就有这条路)。
``TrackSlewGuard``
已发布跟踪点的**每帧位移上限**。工件不动、相机 30Hz,真实观测一帧不可能走
4cm(``feature_jump_guard`` 实测真实运动 ≤15mm/帧)。超过上限:观测可信就
**夹到上限**(限速跟随,不丢目标),观测来自借深度就**整帧不采信**。
``FarReacquireConfirm``
"远框重新捕获"(``dpx`` 大于门限)必须连续 ``confirm_frames`` 帧指向同一处
才采信 —— 一帧的远框更可能是模型在别的东西上误报,而不是工件真的被挪走了。
"""
from __future__ import annotations
from typing import Optional, Sequence, Tuple
import numpy as np
def _xy_dist(a: np.ndarray, b: np.ndarray) -> float:
"""两个 XY 点的距离(米/像素都行,单位跟随入参)。单一实现,别各写一份。"""
return float(np.hypot(float(a[0]) - float(b[0]), float(a[1]) - float(b[1])))
def kpt_plausible(
u: float,
v: float,
box: Optional[Sequence[float]],
w: int,
h: int,
*,
border_px: float = 6.0,
box_margin_frac: float = 0.10,
) -> Optional[str]:
"""k0 的**像素位置**是否可信。返回 None = 可信;否则返回拒绝原因。
``box`` 是同一帧的工件检测框 ``(x0,y0,x1,y1)``;``w,h`` 是彩色图宽高。
"""
if not (np.isfinite(u) and np.isfinite(v)):
return "k0 像素非有限值"
if box is None:
# 没有框就没法判断 k0 是否落在工件上 —— 宁可当不可信(走连续性路径)。
return "没有检测框,无法判断 k0 是否落在工件上"
b = np.asarray(box, dtype=float).reshape(-1)
if b.size < 4:
return "检测框字段不完整"
x0, y0, x1, y1 = (float(vv) for vv in b[:4])
bw, bh = abs(x1 - x0), abs(y1 - y0)
if not (np.isfinite(bw) and np.isfinite(bh)) or bw <= 1e-6 or bh <= 1e-6:
return "检测框尺寸无效"
mx, my = box_margin_frac * bw, box_margin_frac * bh
if u < x0 - mx or u > x1 + mx or v < y0 - my or v > y1 + my:
return (
f"k0 ({u:.0f},{v:.0f}) 落在检测框 "
f"[{x0:.0f},{y0:.0f},{x1:.0f},{y1:.0f}] 之外"
)
if w > 0 and h > 0:
if u <= border_px or v <= border_px or u >= w - 1 - border_px or v >= h - 1 - border_px:
return (
f"k0 ({u:.0f},{v:.0f}) 贴画面边缘({w}x{h},余量 {border_px:.0f}px)"
"—— 孔心长在工件上,不可能在画面边上"
)
return None
class TrackSlewGuard:
"""已发布跟踪点的每帧位移上限。
单实例服务**一个**跟踪点;调用方负责在"没有目标/重置"时 :meth:`reset`。
"""
def __init__(self, max_step_m: float) -> None:
# 只有**一个**门限,故意的:观测可信就限速跟随,借深度就整帧不采信。
# 不做成两个门限是因为"借深度"只影响"能不能重定位",不影响"小步跟不跟" ——
# 两个门限会立刻变成"哪个该调、调到多少"的扯皮(且没有实测支撑)。
self.max_step_m = float(max_step_m)
self._last: Optional[np.ndarray] = None
self.clamped_total = 0
self.rejected_borrowed_total = 0
def reset(self) -> None:
self._last = None
def update(
self, p_xy: Optional[Sequence[float]], *, borrowed: bool = False
) -> Tuple[Optional[np.ndarray], Optional[str]]:
"""喂一次观测,返回 ``(采信的点, 拒绝原因)``。
- 第一次观测(没有历史):原样采信(冷启动没有更好的信息,见服务节点的
照常起步)。
- 位移 ≤ 上限:原样采信。
- 位移 > 上限且 ``borrowed``:整帧不采信(返回 ``None`` + 原因)——
借来的深度意味着这个像素读不到自己的深度,绝对位置不足以用来重定位。
- 位移 > 上限且观测可信:**夹到上限**(限速跟随),不丢目标。
"""
if p_xy is None:
return None, "本帧没有观测"
p = np.asarray(p_xy, dtype=float).reshape(2)
if not np.all(np.isfinite(p)):
return None, "跟踪点非有限值"
if self._last is None:
self._last = p.copy()
return p, None
d = _xy_dist(p, self._last)
if d <= self.max_step_m:
self._last = p.copy()
return p, None
if borrowed:
self.rejected_borrowed_total += 1
return None, (
f"借深度的观测要求位移 {d*1000:.1f}mm > 上限 "
f"{self.max_step_m*1000:.0f}mm —— 判误检,本帧不采信"
)
step = self._last + (p - self._last) * (self.max_step_m / d)
self._last = step.copy()
self.clamped_total += 1
return step, (
f"位移 {d*1000:.1f}mm > 上限 {self.max_step_m*1000:.0f}mm —— "
"夹到上限限速跟随"
)
class FarReacquireConfirm:
"""远框"重新捕获"的多帧确认。
``dpx`` 是候选框中心与上一帧框中心的像素距离。超过 ``far_px`` 的候选必须
连续 ``confirm_frames`` 帧落在同一处(相互间距 ≤ ``far_px``)才放行。
"""
def __init__(self, far_px: float, confirm_frames: int = 3) -> None:
self.far_px = float(far_px)
self.confirm_frames = max(1, int(confirm_frames))
self._cand: Optional[np.ndarray] = None
self._count = 0
def reset(self) -> None:
self._cand = None
self._count = 0
def allow(
self, center_xy: Optional[Sequence[float]], dpx: float, *, near_px: float
) -> Tuple[bool, Optional[str]]:
"""返回 ``(是否放行, 拒绝原因)``。``near_px`` 是"不算远框"的门限。"""
if center_xy is None:
self.reset()
return True, None
c = np.asarray(center_xy, dtype=float).reshape(2)
if not np.all(np.isfinite(c)):
return False, "候选框中心非有限值"
if float(dpx) <= float(near_px):
# 近框:正常跟踪,不需要确认
self.reset()
return True, None
if self._cand is None or _xy_dist(c, self._cand) > self.far_px:
self._cand = c.copy()
self._count = 1
else:
self._count += 1
if self._count >= self.confirm_frames:
self.reset()
return True, None
return False, (
f"远框重捕获 dpx={float(dpx):.0f}px 需连续 {self.confirm_frames} 帧一致"
f"(当前 {self._count} 帧)—— 本帧不采信"
)
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""跟踪点"假跳变"的确认门限:单帧大跳不采信,连续 N 帧一致才认。
为什么需要它(2026-10-08 真机实测,一阶段跑 150s 没锁上)
--------------------------------------------------------
arm 89 的日志原话:
0659.551 死区保持 |e_xy|=3.0mm ← 其实已经锁定!误差只剩 3mm
0659.598 k0 置信度 0.01 < 0.30,本帧不用它
0659.604 HANDEYE snap feat_base=(0.675,-0.099) ← 跟踪点瞬间横移 89mm
0660.121 v=(-0.206,+0.400) |e_xy|=130mm ← 伺服以 0.45m/s 去追这个假点
0660.561 过期保持 —— 不跟踪冻结的检测框 ← 画面糊掉,目标彻底丢失
原来的写法是"跳变超过 ``predict_jump_m`` 就 ``reset()``" —— 等于**单帧就采信**。
可是相机 30Hz、工件不动,真实特征点一帧不可能跳 89mm(实测的真实运动
在 15mm/帧 以内)。所以"大跳"几乎必然是检测/兜底产生的假点,不该一路追过去。
判据
----
- 位移 ≤ ``jump_m``:正常观测(``near``),照常滤波更新。
- 位移 > ``jump_m``:进入"待确认"。此后**连续 ``confirm_frames`` 帧**都要落在
同一个新位置附近(相互间距 ≤ ``jump_m``),才判为真实重定位(``jump``)。
- 只要中间有一帧不对上(乱跳),计数清零、重新开始 —— 乱跳永远等不到确认。
这也顺手挡住"感知层 k0 掉信后乱跳"那类现象:宁可原地不动,也不要追着假点跑。
"""
from __future__ import annotations
from typing import Optional
import numpy as np
# ``servo_node._update_feat_track`` 的三种判定结果。
# 为什么要显式区分"没有目标":perception 会把误检点也发过来(2026-10-08 真机,
# 模型在运动模糊下把特征点丢到基座后方 -0.10,-0.65,工件其实在 0.5,-0.12),
# 这种点在物理上不可能 —— 必须当"没看到目标"处理,而不是"钉在原地"。
ACCEPT = "accept" # 采信这次观测
HOLD = "hold" # 疑似假跳 —— 位置钉住不动
REJECT = "reject" # 物理上不合理(工作盒外)—— 当"没有目标"处理
class FeatureJumpGate:
"""有状态的跳变确认门限。线程安全由调用方保证(servo 在锁内调用)。"""
NEAR = "near" # 正常观测:照常滤波更新
HOLD = "hold" # 疑似假跳:本次不采信,位置保持不动
JUMP = "jump" # 已确认的真实重定位:允许 reset 到新位置
def __init__(self, jump_m: float, confirm_frames: int = 3) -> None:
self.jump_m = float(jump_m)
# 至少 1 帧才谈得上"确认";<=1 就等于退回旧的"单帧采信",但那是显式选择,
# 不是静默降级 —— 这里仍然保留参数以方便现场调节。
self.confirm_frames = max(1, int(confirm_frames))
self._cand: Optional[np.ndarray] = None
self._count = 0
# 诊断计数:连续 hold 了多少帧(供日志/排查,见 servo_node 的告警)。
self.hold_frames = 0
self.hold_total = 0
def reset(self) -> None:
"""清空待确认状态(正常观测、或已确认后都要清)。"""
self._cand = None
self._count = 0
self.hold_frames = 0
@staticmethod
def _dist(a: np.ndarray, b: np.ndarray) -> float:
return float(np.hypot(float(a[0]) - float(b[0]), float(a[1]) - float(b[1])))
def update(self, p_xy: np.ndarray, ref_xy: np.ndarray) -> str:
"""喂一次观测。
参数
----
p_xy : 本帧测到的 XY(base 系,米)
ref_xy : 当前滤波后的位置 XY(米)—— 判断"跳没跳"的基准
返回 :data:`NEAR` / :data:`HOLD` / :data:`JUMP` 之一。
"""
p = np.asarray(p_xy, dtype=float).reshape(2)
ref = np.asarray(ref_xy, dtype=float).reshape(2)
if self._dist(p, ref) <= self.jump_m:
self.reset()
return self.NEAR
# 大跳:本次观测只算"候选"。候选之间互相对不上就从头再数。
if self._cand is None or self._dist(p, self._cand) > self.jump_m:
self._cand = p.copy()
self._count = 1
else:
self._count += 1
if self._count >= self.confirm_frames:
self.reset()
return self.JUMP
self.hold_frames += 1
self.hold_total += 1
return self.HOLD
@@ -0,0 +1,405 @@
"""S00 开机自检:把"能不能跑"这件事变成一张**可打印的清单**(纯逻辑部分)。
为什么要有这一阶段
------------------
看板 S00 的要求是"各服务就绪后才开指令窗,任一不就绪即中止并报告"。它挡的是
这个仓库最常见的一类事故:**流程跑起来了,只是没接上硬件**。例如:
* 触觉没起 → 三阶段的力阈值全是 0 → 搜孔"一直没找到"(实际是没数据);
* 相机没起 → 精拍拿不到图 → 报"精拍未拿到图像"(离真正原因差三层);
* 夹爪没起 → S08 松爪/夹紧静默失败 → 棘轮循环空转,看起来像"拧不动"。
放纯逻辑里的原因
----------------
"要检查哪些东西"是**事实**,会随现场(两台臂、两种夹爪后端、D435 有没有标定)
变化;"怎么检查"是 ROS 机制。前者做成纯函数,就能被测试钉住:
* 少一项检查 → 测试红(比如"忘了查触觉");
* 检查项的顺序无关紧要,但**必检项**和**选检项**的划分必须明确 ——
把核心判据依赖的触觉标成选检,就会在没传感器时一路跑到力判据才失败。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
#: 探针类型 —— 决定节点用哪种方式确认"就绪"。
#:
#: ``service`` 服务在线且类型匹配
#: ``action`` action server 在线
#: ``topic`` 话题在,且**真的收到过消息**(只看 topic list 会假阳性)
#: ``tactile`` 触觉状态话题在发,且 ``connected=true``
#: ``camera`` 图像话题在发(收到首帧)
#: ``detector`` 算法权重能加载(推理预热)
PROBES = ("service", "action", "topic", "tactile", "camera", "detector")
#: ``detector`` 自检除了"权重文件在",还要求这些 Python 模块能 import。
#: 为什么必须查:权重文件在 ≠ 能推理。实测现场 —— 仿真环境(ros_gz_sim)里
#: 没有 torch/ultralytics,S00 的"检测算法预热"照样报 OK,然后 S04 空转 150s
#: 才报"没锁定",看起来像算法或标定问题(真正的错在很远的另一处)。
#: ``ultralytics`` 会连带把 torch 拉进来,查它就够;真要细分再看 yolo_detector。
DETECTOR_MODULES = ("ultralytics",)
#: 感知来源(``site.yaml`` 顶层 ``perception.source``)—— 谁产生"视觉测量"。
#:
#: ``yolo`` 本体 perception_node(要 torch/ultralytics)
#: ``truth`` 仿真真值:按场景里已知的物体位姿直接算目标点,不跑推理
#: ``external`` 外部节点按同一话题(``visual_servo/filtered_point``)提供
#:
#: ⚠️ 别和 ``yolo.backend`` 混:那个是"**怎么**推理"(tensorrt/onnx)。
PERCEPTION_SOURCES = ("yolo", "truth", "external")
def perception_source_of(perception_cfg: Optional[Mapping] = None) -> str:
"""读 ``perception.source`` 并校验。**未知值直接报错,不默默当 yolo**。
默默退回 yolo 的后果:仿真里 perception_node 起不来,S00 又去查
"推理依赖",报出来的错跟真正的原因(source 拼错)隔着两层。
"""
src = str(((perception_cfg or {}) or {}).get("source") or "yolo").strip().lower()
if src not in PERCEPTION_SOURCES:
raise ValueError(
f"site.yaml 的 perception.source = {src!r} 不认识。"
f"只支持 {' / '.join(PERCEPTION_SOURCES)}"
"(yolo=本体推理,truth=仿真真值,external=外部节点提供)。"
)
return src
def missing_modules(modules: Sequence[str] = DETECTOR_MODULES) -> List[str]:
"""哪些模块当前解释器里 import 不到(纯逻辑,可离线测试)。"""
import importlib.util
out: List[str] = []
for m in modules:
try:
if importlib.util.find_spec(m) is None:
out.append(m)
except (ImportError, ValueError):
# find_spec 对"父包不存在/名字非法"会抛,这同样等于"用不了"
out.append(m)
return out
def dependency_advice(missing: Sequence[str]) -> str:
"""缺依赖时给**两条**出路:装它、或换掉这个后端。
只写"请安装 X"是不够的:仿真环境常常是离线的(这台机器实测 pip 直接
SSL 失败),那样的话现场需要的是"换一条不依赖它的路",而不是反复重试安装。
"""
mods = ", ".join(missing)
return (
f"缺推理依赖 {mods} —— 权重文件在也没用,导入不了就跑不了推理。两条路:\n"
f" 1) 装上:pip install {' '.join(missing)}(需要联网;本机仿真环境离线时做不到)\n"
" 2) 换后端:仿真里可以不用 YOLO —— 由外部按同一话题/同一类型提供感知结果\n"
" (见 devices/sim/gazebo/README.md 的「仿真里哪些模块用真值替代」一节)"
)
@dataclass(frozen=True)
class BootCheck:
"""一项自检。"""
key: str
"""稳定标识(日志/结果 JSON 里用它,改名要同步文档)。"""
label: str
"""人读的名字。"""
probe: str
"""见 :data:`PROBES`。"""
target: str
"""要探测的对象:服务名 / action 名 / 话题名 / 权重路径。"""
why: str
"""**缺了会怎样**。自检失败的提示必须写这个 —— 只报"未就绪"没人知道该不该急。"""
required: bool = True
"""必检 / 选检。选检失败只告警,不拦流程。"""
expect_type: Optional[str] = None
"""期望的消息/服务类型(有值时类型不符即算失败)。"""
#: 需要"绝对名字"的探针:相对名字会按节点命名空间解析,
#: 节点被放进 namespace 之后 ``home`` 会变成 ``/ns/home`` —— 自检就会去探一个
#: 不存在的名字,报"未就绪"。统一补前导斜杠,把这件事变成不可能发生。
_ABSOLUTE_PROBES = frozenset({"service", "action", "topic", "tactile", "camera"})
def abs_name(name: str) -> str:
"""把 ROS 名字规范成绝对名(``home`` → ``/home``)。
对外公开:调用方(节点)也要用同一套规范化去比较名字,否则会出现
"清单里是 /foo、参数里是 foo,比较不相等"这类只在运行时暴露的错。
"""
n = (name or "").strip()
if not n:
return n
return n if n.startswith("/") else "/" + n
def _topic(prefix: str, suffix: str) -> str:
"""拼话题名,容忍 ``prefix`` 带不带前导斜杠。"""
p = "/" + prefix.strip("/") if prefix else ""
return f"{p}/{suffix.strip('/')}"
def build_checks(
*,
joint_action: str,
camera: str,
tactile_topic: str,
gripper_open: str,
gripper_close: str,
gripper_status: str,
gripper_status_type: str,
d435_camera: Optional[str] = None,
tool_name: str = "",
weights_path: str = "",
perception_source: str = "yolo",
) -> List[BootCheck]:
"""按现场情况生成检查清单。
:param joint_action: JTC action 名(``rizon_arm_controller/follow_joint_trajectory``)。
:param camera: 腕部相机命名空间(``d405_left``)。
:param tactile_topic: 触觉状态话题(``/tactile/left/status``)。
:param gripper_status: 夹爪状态话题,随后端不同(``/gripper/status`` 或 ``/gripper/states``)。
:param d435_camera: 手外相机命名空间。给 None 表示这台臂没有手外相机 →
不生成 S03 相关的检查项(**不生成**比"生成了一个必然失败的项"更诚实:
后者会让每次启动都报一条无关的错,很快所有人都不看自检输出了)。
:param tool_name: ``site.yaml`` 的 ``robot.tool_name``。空 = 没指定工具系,
则 S01 的"工具系垂直向下锁定"无法确认 → 单独列为选检告警。
:param weights_path: 检测权重路径。空 = 不检查推理预热。
:param perception_source: ``site.yaml`` 的 ``perception.source``。**只有
``yolo`` 才查推理预热**:truth/external 根本不跑推理,查了必然是
假阴性("缺 ultralytics"),而那是预期之内的。这里刻意**不生成**
这一项,而不是生成一个永远 OK 的项 —— 后者会让人以为"感知也验过了"。
清单的顺序 = 打印顺序 = 排障顺序:先臂、再夹爪、再相机、再触觉、最后算法。
"""
checks: List[BootCheck] = [
BootCheck(
key="arm_jtc",
label="机械臂关节轨迹控制器",
probe="action",
target=joint_action,
why="没有它,S01/S06/S07/S10 的所有运动都发不出去",
# 类型必须是 **control_msgs**:本流程(和 stage2/stage3/pipeline 一样)
# 直接把轨迹发给 ros2_control 的 JTC 服务端,不经过 arm_control 的
# 轨迹适配器(那个用的是 arm_msgs/action/FollowJointTrajectory)。
# 写成 arm_msgs 的会得到一个"永远探不到服务端"的自检 —— 假阴性,
# 而且看起来像控制器没起来。
expect_type="control_msgs/action/FollowJointTrajectory",
),
BootCheck(
key="arm_home",
label="回零服务",
probe="service",
target="/home",
why="S01 回 home、S11 复位都靠它",
expect_type="std_srvs/srv/Trigger",
),
BootCheck(
key="arm_named_pose",
label="命名位姿服务",
probe="service",
target="/set_named_pose",
why="/home 内部转发给它;它不在时回零会超时",
expect_type="arm_msgs/srv/SetNamedPose",
),
BootCheck(
key="arm_compliant",
label="柔顺下压 action",
probe="action",
target="/compliant_descent",
# 选检:S07 现在走节点内的"步进 + 力突变停"(与这个 action 内部
# 同一套做法),不依赖它;有它时驱动侧原语更顺,属于加分项。
# 标成必检会让仿真(arm_driver_flexiv 不参与)永远过不了自检。
why="有它时 S07 用驱动侧柔顺原语;没有则用节点内步进(不影响正确性)",
required=False,
expect_type="arm_msgs/action/CompliantDescent",
),
BootCheck(
key="gripper_open",
label="夹爪张开服务",
probe="service",
target=gripper_open,
why="S08 棘轮的『松爪』一步靠它;失败会让回转把规拧回去",
),
BootCheck(
key="gripper_close",
label="夹爪夹紧服务",
probe="service",
target=gripper_close,
why="S08 的『夹紧』一步靠它;失败会让慢转时规打滑(磨螺纹)",
),
BootCheck(
key="camera_color",
label="腕部相机彩图",
probe="camera",
target=_topic(camera, "color/image_raw"),
why="S05 精拍定位 k1/k2 靠它;缺了只能靠 S03 粗定位(精度不够)",
expect_type="sensor_msgs/msg/Image",
),
BootCheck(
key="camera_info",
label="腕部相机内参",
probe="topic",
target=_topic(camera, "color/camera_info"),
why="没有内参就无法把像素反投影成基座坐标(会退化成 60° 默认视场)",
expect_type="sensor_msgs/msg/CameraInfo",
),
BootCheck(
key="tactile_status",
label="触觉三向力",
probe="tactile",
target=tactile_topic,
why="S07 切向力突变停、S08 拧紧判据、E1 掉件都依赖它;"
"缺了这几个判据全部失效(流程能走完但结果不可信)",
expect_type="arm_msgs/msg/TactileStatus",
),
BootCheck(
key="gripper_status",
label="夹爪状态反馈",
probe="topic",
target=gripper_status,
why="E1 掉件检测靠它;缺了掉件只能靠人看",
expect_type=gripper_status_type,
),
]
if d435_camera:
checks.append(
BootCheck(
key="coarse_camera",
label="手外粗定位相机",
probe="camera",
target=_topic(d435_camera, "color/image_raw"),
why="S03 兜底粗定位用;缺了只能在 S04 跟踪上多试几次",
required=False,
expect_type="sensor_msgs/msg/Image",
)
)
if tool_name:
checks.append(
BootCheck(
key="tool_frame",
label="工具坐标系已指定",
probe="service", # 具体内容由节点按 tool_name 复核(只查服务在不在)
target="/switch_tool",
why=f"要求 tool1(垂直向下、位姿锁定),site.yaml 里配的是 {tool_name!r}",
required=False,
expect_type="arm_msgs/srv/SwitchTool",
)
)
# 配了工具系就必须能切:S01 切不过去会直接 TIMEOUT 中止,
# 所以在自检里按**必检**拦下(早报比跑到 S01 再报少一次上电动作)。
checks.append(
BootCheck(
key="arm_switch_tool",
label="工具系切换服务",
probe="service",
target="/switch_tool",
why=f"site.yaml 指定了工具 {tool_name!r},切不过去后面全是歪的",
expect_type="arm_msgs/srv/SwitchTool",
)
)
else:
# 没配工具系:S01 只会告警不会切,所以这条只算选检 ——
# 真机没配工具系是**配置问题**(看板要求 tool1),但仿真里根本没有
# 工具系切换服务,标必检会让仿真永远过不去。
checks.append(
BootCheck(
key="arm_switch_tool",
label="工具系切换服务",
probe="service",
target="/switch_tool",
why="本机没配 robot.tool_name:真机应配 tool1 并确认这个服务在;仿真不需要",
required=False,
expect_type="arm_msgs/srv/SwitchTool",
)
)
if weights_path and perception_source == "yolo":
checks.append(
BootCheck(
key="detector",
label="检测算法预热",
probe="detector",
target=weights_path,
why="S04 跟踪、S03 粗定位都要它;预热失败会在第一次跟踪时才炸",
)
)
# 网络端点统一成绝对名(见 _ABSOLUTE_PROBES 的说明)。
# detector 的 target 是文件路径,不参与规范化。
return [
c if c.probe not in _ABSOLUTE_PROBES
else BootCheck(
key=c.key, label=c.label, probe=c.probe, target=abs_name(c.target),
why=c.why, required=c.required, expect_type=c.expect_type,
)
for c in checks
]
def format_report(results: Sequence[Tuple[BootCheck, bool, str]]) -> Tuple[str, List[str]]:
"""把逐项结果排成一张表,返回 ``(表文本, 失败项描述)``。
``results`` 每项是 ``(检查项, 是否就绪, 细节)``。
两张输出分开是为了让调用方能按"必检失败"决定是否中止:表用来给人看,
失败项描述用来进日志/结果 JSON。
"""
lines: List[str] = ["S00 自检:"]
failures: List[str] = []
for item, ok, detail in results:
mark = "OK " if ok else ("缺失" if item.required else "告警")
tag = "" if item.required else "(选检)"
lines.append(f" [{mark}] {item.label}{tag} {item.target}")
if detail:
lines.append(f" {detail}")
if not ok:
prefix = "必检项未就绪" if item.required else "选检项未就绪"
failures.append(f"{prefix}:{item.label}({item.target})—— {item.why}")
if failures:
lines.append(f" 共 {len(failures)} 项未就绪")
else:
lines.append(" 全部就绪,开指令窗")
return "\n".join(lines), failures
def blocking_failures(results: Iterable[Tuple[BootCheck, bool, str]]) -> List[str]:
"""只挑出**必检且未就绪**的项 —— 决定要不要中止的就是它们。
和 :func:`format_report` 的 ``failures`` 的区别:那里两项都收(含选检),
这里只收必检。合成一个函数的话,调用方一不留神就会拿选检失败去中止流程,
于是"D435 没接"变成"装配跑不起来"。
"""
return [
f"必检项未就绪:{item.label}({item.target})—— {item.why}"
for item, ok, _detail in results
if (not ok) and item.required
]
def optional_warnings(results: Iterable[Tuple[BootCheck, bool, str]]) -> List[str]:
"""选检未就绪的告警(不拦流程,但必须打出来)。"""
return [
f"选检项未就绪:{item.label}({item.target})—— {item.why}"
for item, ok, _detail in results
if (not ok) and not item.required
]
def summary_counts(results: Sequence[Tuple[BootCheck, bool, str]]) -> Dict[str, int]:
"""统计``必检/选检``各自通过多少,给启动日志一行摘要。"""
out = {"required_ok": 0, "required_total": 0, "optional_ok": 0, "optional_total": 0}
for item, ok, _detail in results:
slot = "required" if item.required else "optional"
out[f"{slot}_total"] += 1
if ok:
out[f"{slot}_ok"] += 1
return out
@@ -0,0 +1,591 @@
"""通止规装配的配置:从 ``site.yaml`` 的 ``gauge_assembly:`` 段解析 + 校验。
为什么单独一个模块
------------------
这一段的键**全是会动的量**(力阈值、角度、悬高、超时),也是现场最常改的地方。
散在节点里读的话会有三个已知坑(都被这个仓库踩过):
1. **名字与单位不一致** —— ``hover`` 填了毫米、代码按米用(规则 M3);
2. **键没接线** —— YAML 里写着 ``screw_step_deg``,代码读的是 ``screw_steps``,
于是改了没效果,而报错也没有(``config_guard`` 模块的头注释就是这件事);
3. **拼错键名** —— YAML 静默接受,行为退回默认值,现场"改了参数没反应"。
所以:所有键在一张表里声明(含单位),解析时**逐条校验**,多出来的键直接
报错并给出最相近的候选名,缺的键按 required/default 明确处理。
``describe()`` 把**派生**结果(比如"逆时针最多能转 147°")打出来,让现场
看到的是"实际生效的值",而不是"我填的值"。
"""
from __future__ import annotations
import difflib
from dataclasses import dataclass, fields
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
from arm_application.gauge_fsm import descend_tol_m
#: S05 稳定判据可用的特征来源。``auto`` 按 ``perception.source`` 自动选。
#:
#: 为什么需要有"motion"这一路:判据原本是**工件检测框中心**的抖动(像素),
#: 那要求检测器真的出框。仿真里合成图过不了真机训练出来的模型(实测最高分
#: 0.017),于是 S05 会永远"不稳定"地空转 —— 而真正该等的是"相机不再动"。
#: 用法兰位移(mm)当特征既直接又不依赖模型,且判据本身(到均值的最远距离)不变。
SNAP_STABLE_FEATURES = ("auto", "bbox", "motion")
def snap_feature_of(want: str, perception_source: str) -> str:
"""把 ``snap_stable_feature``(可能是 ``auto``)+ 感知来源 → 实际用哪个特征。
抽成纯函数是为了能脱离 ROS 节点测:这个选择错了,S05 会在真机上"永远不稳定"
或者在仿真里空转到超时,而两种表现都像是"机械臂在晃",很难往配置上想。
"""
want = str(want or "auto")
if want != "auto":
return want
return "bbox" if perception_source == "yolo" else "motion"
#: 合法的单位后缀。键名必须以 ``_<单位>`` 结尾(``au`` = Orisys 任意单位增量)。
_UNIT_SUFFIX: Dict[str, str] = {
"s": "_s",
"deg": "_deg",
"mm": "_mm",
"m": "_m",
"px": "_px",
"pct": "_pct",
"au": "_delta",
# 腕部 FT 的**真牛顿**。与 au 分开一个后缀是刻意的:两个口径混用不会报错,
# 只会"判据不触发"或"一碰就炸",看起来像算法问题(见 site.yaml 的注释)。
"N": "_n",
}
#: 显式无量纲的键(布尔、计数、比例、枚举)。**必须在这里列出来** ——
#: 没有单位又不登记的键,会让"单位自检"变成一句空话(规则 M1)。
_UNITLESS = frozenset(
{
"extreme_direction",
"grip_check",
"coarse_enabled",
"coarse_camera",
"coarse_conf",
"snap_min_frames",
"snap_stable_feature",
"screw_max_cycles",
"unscrew_extra_cycles",
"holes_total",
"dry_run",
}
)
#: 每个键的**类型**(用于类型校验,不做隐式转换:``"20"`` 不当 20 用)。
_TYPES: Dict[str, type] = {
"boot_timeout_s": float,
"extreme_angle_deg": float,
"extreme_speed_deg_s": float,
"extreme_direction": int,
"extreme_margin_deg": float,
"grip_check": bool,
"grip_hold_s": float,
"coarse_enabled": bool,
"coarse_camera": str,
"coarse_hover_m": float,
"coarse_conf": float,
"coarse_timeout_s": float,
"snap_stable_s": float,
"snap_tol_px": float,
"snap_tol_mm": float,
"snap_min_frames": int,
"snap_stable_feature": str,
"align_tol_mm": float,
"contact_speed_m_s": float,
"contact_step_mm": float,
"contact_max_depth_mm": float,
"tool_tip_below_flange_m": float,
"workpiece_surface_z_m": float,
"contact_tangential_delta": float,
"contact_axial_delta_n": float,
"contact_relief_mm": float,
"contact_hold_n": float,
"contact_safe_delta": float,
"screw_step_deg": float,
"screw_speed_deg_s": float,
"screw_back_speed_deg_s": float,
"screw_max_cycles": int,
"screw_normal_delta": float,
"screw_shear_delta": float,
"screw_grip_force_pct": float,
"screw_grip_speed_pct": float,
"screw_release_wait_s": float,
"unscrew_extra_cycles": int,
"holes_total": int,
"dry_run": bool,
"reload_timeout_s": float,
}
#: 每个键的单位。``""`` 表示无量纲(须同时登记在 :data:`_UNITLESS`)。
_UNITS: Dict[str, str] = {
"boot_timeout_s": "s",
"extreme_angle_deg": "deg",
"extreme_speed_deg_s": "deg/s",
"extreme_direction": "",
"extreme_margin_deg": "deg",
"grip_check": "",
"grip_hold_s": "s",
"coarse_enabled": "",
"coarse_camera": "",
"coarse_hover_m": "m",
"coarse_conf": "",
"coarse_timeout_s": "s",
"snap_stable_s": "s",
"snap_tol_px": "px",
"snap_tol_mm": "mm",
"snap_min_frames": "",
"snap_stable_feature": "",
"align_tol_mm": "mm",
"contact_speed_m_s": "m/s",
"contact_step_mm": "mm",
"contact_max_depth_mm": "mm",
# 这两个是**长度**不是"毫米量":用 m 口径,与 hover_z 同一把尺
"tool_tip_below_flange_m": "m",
"workpiece_surface_z_m": "m",
"contact_tangential_delta": "au",
# 腕部 FT 是**真牛顿**(与触觉的 au 不是一个口径,所以键名也不带 taut 前缀)
"contact_axial_delta_n": "N",
# 卸载回退是**长度**(与 step_mm 同一把尺)
"contact_relief_mm": "mm",
# 残余轴向力是**牛顿**(腕部 FT 口径,不是触觉的 au)
"contact_hold_n": "N",
"contact_safe_delta": "au",
"screw_step_deg": "deg",
"screw_speed_deg_s": "deg/s",
"screw_back_speed_deg_s": "deg/s",
"screw_max_cycles": "",
"screw_normal_delta": "au",
"screw_shear_delta": "au",
"screw_grip_force_pct": "pct",
"screw_grip_speed_pct": "pct",
"screw_release_wait_s": "s",
"unscrew_extra_cycles": "",
"holes_total": "",
"dry_run": "",
"reload_timeout_s": "s",
}
#: 键名后缀与单位必须一致(防"填毫米当米用")。``contact_speed_m_s`` 这类
#: 复合单位只检查主单位段。
_SUFFIX_OVERRIDE: Dict[str, str] = {
# 复合单位:后缀取"最后一个单位段",键名里写全
"contact_speed_m_s": "_m_s",
"extreme_speed_deg_s": "_deg_s",
"screw_speed_deg_s": "_deg_s",
"screw_back_speed_deg_s": "_deg_s",
}
class GaugeConfigError(ValueError):
"""``gauge_assembly:`` 段配置错误。消息里必须给出**改哪里**。"""
# =============================================================================
# 默认值
# =============================================================================
#: 缺省值。**只有真的有现场依据的才写默认值**:力阈值这类必须现场标定的,
#: 默认值只是"能跑起来的起点",必须在 ``describe()`` 里标出来让现场知道要改。
DEFAULTS: Dict[str, Any] = {
"boot_timeout_s": 30.0,
# S01:逆时针预绕。符号靠 extreme_direction 定,不靠改代码里的负号。
"extreme_angle_deg": 150.0,
"extreme_speed_deg_s": 20.0,
"extreme_direction": 1,
"extreme_margin_deg": 3.0,
# S02(预留)
"grip_check": False,
"grip_hold_s": 0.5,
# S03
"coarse_enabled": True,
"coarse_camera": "d435",
"coarse_hover_m": 0.25,
"coarse_conf": 0.35,
"coarse_timeout_s": 20.0,
# S05
"snap_stable_s": 1.0,
"snap_tol_px": 2.0,
"snap_tol_mm": 0.4,
"snap_min_frames": 6,
"snap_stable_feature": "auto",
# S06
"align_tol_mm": 1.0,
# S07
"contact_speed_m_s": 0.02,
"contact_step_mm": 0.5,
"contact_max_depth_mm": 35.0,
"tool_tip_below_flange_m": 0.243,
"workpiece_surface_z_m": 0.0, # 0 = 未知,运行期用 stage2 实测孔位兜底
"contact_tangential_delta": 300.0,
# 腕部 FT 的轴向力阈值 [N]。**这是 S07 的首选判据**,触觉只是兜底。
#
# 为什么默认是 8N 这么小:这是"接触了没有"的判据,不是"压到位了没有"。
# 台肩一落面,接触刚度 5e4 N/m 意味着过压 0.1mm 就有 5N、0.2mm 有 10N ——
# 所以 8N 对应约 0.16mm 的过压,**在打滑之前**就能判到(打滑上限由
# µ·N 决定:µ=0.25、N≈46N → 两指合计 ~23N ≈ 0.46mm)。留了 3 倍余量。
# 现场标定法:空载慢慢压到台肩落面,看日志里"腕部轴向"从基线涨到多少,
# 取那个值的 1/3 作为阈值(要能区分"擦到孔壁"和"台肩落面")。
"contact_axial_delta_n": 8.0,
# 接触到之后的**卸载回退** [mm]。
#
# 为什么必须有这一步(2026-09-24 一整轮排查换来的):
# 位置控制的臂在"撞上硬面"时会把力顶到自己的**出力上限**才停住 ——
# 仿真实测:一步 0.5mm 的指令压在工件上,腕部 FT 读数直接跳到 **+244N**,
# 而臂这一步根本没走完("实测只走了 +0.09mm")。这不是仿真特有的:
# 接触刚度越高、步长越大,峰值力就越大,而位置环**不限制**它。
#
# 规是**靠夹持摩擦**抱住的,轴向能传的力上限是 µ·N(两指合计 ~73N@µ=0.8)。
# 244N 远超这个上限 ⇒ 规被轴向力**顶出爪外**(本次就是:S08 一转,
# 规直接掉到地上,报 E1)。所以"判到接触"之后不能就那样顶着,
# 必须先把下压量放掉,再交给 S08 拧入。
#
# 0.5mm 的来历:判到接触时的**真实**过压量通常只有 0.0x mm(刚度 5e4 N/m
# 时,10N 对应 0.2mm),回退 0.5mm 足够把力放干净,而规尖仍在孔里
# (通端穿板 18mm),不会脱出。
"contact_relief_mm": 0.5,
# 卸载后允许的残余轴向力 [N]。
# 超了不失败(可能只是回退量不够),但要**大声报**:它说明"夹持摩擦力不够
# 承受这次接触",接着 S08 一转就会掉件。调法只有两条 —— 降峰值(调小
# contact_step_mm / contact_relief_mm)或加握力(改夹爪合拢力度/指垫)。
"contact_hold_n": 5.0,
"contact_safe_delta": 2000.0,
# S08
# 步长必须满足 2×step + 2×余量 ≤ 关节7 行程(Rizon4/10 ±175° ⇒ step ≤ 167°):
# 180° 会必然贴限位(棘轮一个来回要 360° > 350°),实测第 3 轮 ABORT。
"screw_step_deg": 120.0,
"screw_speed_deg_s": 30.0,
"screw_back_speed_deg_s": 120.0,
"screw_max_cycles": 12,
"screw_normal_delta": 400.0,
"screw_shear_delta": 600.0,
"screw_grip_force_pct": 50.0,
"screw_grip_speed_pct": 50.0,
"screw_release_wait_s": 0.3,
# S09
"unscrew_extra_cycles": 1,
# 全局
"holes_total": 2,
"dry_run": False,
# E1(掉件)重放:等操作员放好并确认 /gauge/reload_ok 的最长时间。
# 超时 = 中止(臂在 home,人不在旁边时"继续等"比"停"更危险)。
"reload_timeout_s": 180.0,
}
@dataclass(frozen=True)
class GaugeConfig:
"""通止规装配的全部可调量(解析并校验之后)。"""
boot_timeout_s: float
extreme_angle_deg: float
extreme_speed_deg_s: float
extreme_direction: int
extreme_margin_deg: float
grip_check: bool
grip_hold_s: float
coarse_enabled: bool
coarse_camera: str
coarse_hover_m: float
coarse_conf: float
coarse_timeout_s: float
snap_stable_s: float
snap_tol_px: float
snap_tol_mm: float
snap_min_frames: int
snap_stable_feature: str
align_tol_mm: float
contact_speed_m_s: float
contact_step_mm: float
contact_max_depth_mm: float
tool_tip_below_flange_m: float
workpiece_surface_z_m: float
contact_tangential_delta: float
contact_axial_delta_n: float
contact_relief_mm: float
contact_hold_n: float
contact_safe_delta: float
screw_step_deg: float
screw_speed_deg_s: float
screw_back_speed_deg_s: float
screw_max_cycles: int
screw_normal_delta: float
screw_shear_delta: float
screw_grip_force_pct: float
screw_grip_speed_pct: float
screw_release_wait_s: float
unscrew_extra_cycles: int
holes_total: int
dry_run: bool
reload_timeout_s: float
# -- 派生量(不占 YAML 键,免得两处各写一份,规则 M2)-------------------
@property
def screw_step_rad(self) -> float:
import math
return math.radians(self.screw_step_deg)
@property
def total_screw_deg(self) -> float:
"""一根规要拧进去的总角度 = 单次转角 × 循环数。"""
return self.screw_step_deg * self.screw_max_cycles
@property
def unscrew_cycles(self) -> int:
"""拧出要多转几轮:多留 ``unscrew_extra_cycles`` 轮,确保螺纹完全脱开。"""
return self.screw_max_cycles + max(0, self.unscrew_extra_cycles)
def describe(self) -> List[str]:
"""启动时打印的行:**实际生效的**值与派生结论。
特别是 S01 的"能转多少度"和 S08 的"要拧多少轮"—— 这两个数是现场
判断"参数是否真的生效"最直接的依据,比打印"我读了 site.yaml"有用。
"""
return [
f"通止规装配:每孔孔数 {self.holes_total},干跑={self.dry_run}",
f" S01 预绕 {self.extreme_angle_deg:.1f}°(方向={'逆时针' if self.extreme_direction > 0 else '顺时针'},"
f"限位余量 {self.extreme_margin_deg:.1f}°,关节速度 {self.extreme_speed_deg_s:.1f}°/s)",
f" S03 粗定位={'开' if self.coarse_enabled else '关'} 相机={self.coarse_camera} "
f"悬高 {self.coarse_hover_m * 1000:.0f}mm 置信度≥{self.coarse_conf:.2f}",
f" S05 稳定 {self.snap_stable_s:.1f}s / 至少 {self.snap_min_frames} 帧 / "
f"特征={self.snap_stable_feature}"
f"(检测框 ≤{self.snap_tol_px:.1f}px、法兰位移 ≤{self.snap_tol_mm:.2f}mm)"
"(稳定判据用「到均值的最远距离」,不是相邻帧之差)",
# 悬停高度**不在这里**:它取自 servo.hole_hover_z_m(stage2 那一层),
# 这里只印本段的容差 —— 一段配置里出现"另一个段的键"会让人改错地方。
f" S06 对位容差 {self.align_tol_mm:.1f}mm(悬停高取自 servo.hole_hover_z_m)",
f" S07 腕部轴向突变阈值 +{self.contact_axial_delta_n:.0f}N(**首选判据**,"
"腕部 FT 在夹持摩擦的上游,工具打滑也测得准),"
f"切向突变阈值 +{self.contact_tangential_delta:.0f}au(触觉兜底,两通道任一,现场标定),"
f"安全上限 +{self.contact_safe_delta:.0f},最多下探 {self.contact_max_depth_mm:.0f}mm",
f" S07 接触后卸载:回退 {self.contact_relief_mm:.2f}mm,"
f"残余轴向力要求 ≤{self.contact_hold_n:.1f}N"
"(位置控制的臂撞上硬面会把力顶到出力上限再停住,实测能到 200N+;"
"规靠夹持摩擦抱住,轴向上限只有 µ·N≈73N,顶着不放 S08 一转就掉件)",
f" S07 工具几何:尖端在法兰下方 {self.tool_tip_below_flange_m * 1000:.1f}mm,"
f"工件上表面 z={self.workpiece_surface_z_m:.3f}m"
"(启动时会按这两个值 + servo.hole_hover_z_m 算「悬停时工具到底入没入工件」,"
"入了会**明确报错**——见 gauge_fsm.tool_clearance_m)",
f" S07 步进 {self.contact_step_mm:.2f}mm @ {self.contact_speed_m_s * 1000:.0f}mm/s,"
f"到位容差 {descend_tol_m(self.contact_step_mm / 1000.0) * 1000:.3f}mm"
"(=步长的 40%,**推导出来的、不是可调项**:容差 ≥ 步长会让每一步都被判成"
"「已在容差内」,臂一步都不走)",
f" S08 棘轮 {self.screw_step_deg:.0f}°×{self.screw_max_cycles} 轮 = 累计 "
f"{self.total_screw_deg:.0f}°;停止判据「切向 >+{self.screw_shear_delta:.0f} 且 "
f"法向 >+{self.screw_normal_delta:.0f}」",
f" S09 拧出 {self.unscrew_cycles} 轮(比拧入多 {self.unscrew_extra_cycles} 轮)",
]
def _resolve_one(key: str, raw: Mapping[str, Any]) -> Tuple[Any, bool]:
"""取一个键的值,返回 ``(值, 是否来自 site.yaml)``。"""
if key in raw:
value = raw[key]
elif key in DEFAULTS:
return DEFAULTS[key], False
else:
raise GaugeConfigError(
f"gauge_assembly.{key} 既没在 site.yaml 里给,也没有默认值。"
f"请在 config/site.yaml 的 gauge_assembly: 段补上"
f"(改完跑一次 bash scripts/show_site_config.sh 确认解析)"
)
want = _TYPES[key]
if want is bool and not isinstance(value, bool):
raise GaugeConfigError(_type_msg(key, value, "布尔 true/false"))
if want is int and (isinstance(value, bool) or not isinstance(value, int)):
raise GaugeConfigError(_type_msg(key, value, "整数"))
if want is float and (isinstance(value, bool) or not isinstance(value, (int, float))):
raise GaugeConfigError(_type_msg(key, value, "数字(可带小数)"))
if want is str and not isinstance(value, str):
raise GaugeConfigError(_type_msg(key, value, "字符串"))
return (float(value) if want is float else value), True
def _type_msg(key: str, value: Any, human: str) -> str:
unit = _UNITS[key]
hint = f",单位 {unit}" if unit else ""
return (
f"gauge_assembly.{key} 的类型不对:期望 {human}{hint},"
f"实际读到 {value!r}({type(value).__name__})。"
"本模块不做隐式转换 —— \"20\" 不会被当成 20 用,"
"因为那正是「填了字符串、跑起来是默认值」这类静默失效的入口"
)
def from_site(site: Mapping[str, Any], *, overrides: Optional[Mapping[str, Any]] = None) -> GaugeConfig:
"""解析并校验 ``site.yaml`` 的 ``gauge_assembly:`` 段。
:param site: ``site.yaml`` 解析后的整份字典。
:param overrides: CLI/launch 覆盖(只允许覆盖已知键,未知键报错)。
:raises GaugeConfigError: 缺键、类型错、未知键、单位与键名不符。
整段缺失**不报错**(全部走默认值),但会在 :meth:`GaugeConfig.describe`
之外由调用方打一条告警;理由是"默认值能不能用"取决于现场,
由节点决定要不要拒绝启动,配置模块只负责如实解析。
"""
raw_any = site.get("gauge_assembly") or {}
if not isinstance(raw_any, Mapping):
raise GaugeConfigError(
"site.yaml 的 gauge_assembly: 应该是一段映射(键值对),"
f"实际是 {type(raw_any).__name__}"
)
raw: Dict[str, Any] = dict(raw_any)
if overrides:
bad = [k for k in overrides if k not in _TYPES]
if bad:
raise GaugeConfigError(
f"未知的覆盖参数: {', '.join(sorted(bad))}。"
f"可在 gauge_assembly 段覆盖的键见 gauge_config._TYPES"
)
raw.update({k: v for k, v in overrides.items() if v is not None})
_reject_unknown(raw)
values = {key: _resolve_one(key, raw)[0] for key in _TYPES}
cfg = GaugeConfig(**values)
_sanity(cfg)
return cfg
def _reject_unknown(raw: Mapping[str, Any]) -> None:
"""多余的键直接报错,并给出最相近的候选名。
静默忽略多余键的代价是:现场写 ``screw_step_degs``,改参数没反应,
而日志里一切正常。报错里带候选名,是因为拼错比写错新键常见得多。
"""
unknown = sorted(set(raw) - set(_TYPES))
if not unknown:
return
lines: List[str] = []
for key in unknown:
close = difflib.get_close_matches(key, list(_TYPES), n=1, cutoff=0.6)
hint = f",是不是想写 {close[0]}?" if close else ""
lines.append(f" - {key}{hint}")
raise GaugeConfigError(
"site.yaml 的 gauge_assembly: 段里有本框架不认识的键:\n"
+ "\n".join(lines)
+ "\n多余的键不会被忽略,因为「写了没生效」比「写错报错」难查得多。"
"确认要新增配置项时,先在 gauge_config._TYPES / _UNITS 里登记"
)
def _sanity(cfg: GaugeConfig) -> None:
"""跨键的合理性检查:单键合法、组合起来没意义的,在这里挡住。"""
if cfg.holes_total < 1:
raise GaugeConfigError(f"gauge_assembly.holes_total 至少为 1,收到 {cfg.holes_total}")
if cfg.extreme_direction not in (-1, 1):
raise GaugeConfigError(
f"gauge_assembly.extreme_direction 只能是 +1(逆时针)或 -1(顺时针),"
f"收到 {cfg.extreme_direction}"
)
if cfg.screw_step_deg <= 0.0:
raise GaugeConfigError(
f"gauge_assembly.screw_step_deg 必须为正,收到 {cfg.screw_step_deg}"
)
if cfg.screw_max_cycles < 1:
raise GaugeConfigError(
f"gauge_assembly.screw_max_cycles 至少为 1,收到 {cfg.screw_max_cycles}"
)
if cfg.extreme_angle_deg <= 0.0:
raise GaugeConfigError(
f"gauge_assembly.extreme_angle_deg 必须为正(方向由 extreme_direction 决定,"
f"不要用负角度表方向),收到 {cfg.extreme_angle_deg}"
)
if cfg.contact_safe_delta <= cfg.contact_tangential_delta:
raise GaugeConfigError(
f"contact_safe_delta({cfg.contact_safe_delta}) 必须大于 "
f"contact_tangential_delta({cfg.contact_tangential_delta}):"
"前者是「立刻抬臂」的安全上限,后者只是「停止下压」。"
"两者相等的话,接触一开始就会触发安全上限"
)
if cfg.tool_tip_below_flange_m <= 0.0:
raise GaugeConfigError(
f"tool_tip_below_flange_m 必须为正(工具尖端在法兰**下方**多少米),"
f"收到 {cfg.tool_tip_below_flange_m}。"
"填 0 会让「悬停时工具会不会已经插进工件」这个判断失效"
)
if cfg.snap_tol_px <= 0.0:
raise GaugeConfigError(f"gauge_assembly.snap_tol_px 必须为正,收到 {cfg.snap_tol_px}")
if cfg.contact_relief_mm < 0.0:
raise GaugeConfigError(
f"gauge_assembly.contact_relief_mm 不能为负(0 = 接触到之后不卸载),"
f"收到 {cfg.contact_relief_mm}。\n"
" 它为什么值得一条检查:位置控制的臂撞上硬面会把轴向下压顶到**出力上限**"
"才停住(实测 200N+),而规靠夹持摩擦抱住、轴向上限只有 µ·N(≈73N)。"
"卸载这一步省掉,S08 一转就掉件(报 E1)—— 而那时的日志只会说"
"「通止规掉了」,看不到真因。"
)
if cfg.contact_hold_n <= 0.0:
raise GaugeConfigError(
f"gauge_assembly.contact_hold_n 必须为正(卸载后的残余轴向力上限 [N]),"
f"收到 {cfg.contact_hold_n}。0 会让「卸载干不干净」永远判不过。"
)
if cfg.snap_tol_mm <= 0.0:
raise GaugeConfigError(f"gauge_assembly.snap_tol_mm 必须为正,收到 {cfg.snap_tol_mm}")
if cfg.snap_stable_feature not in SNAP_STABLE_FEATURES:
raise GaugeConfigError(
f"gauge_assembly.snap_stable_feature={cfg.snap_stable_feature!r} 不认识,"
f"只支持 {sorted(SNAP_STABLE_FEATURES)}。\n"
f" auto = 按 perception.source 自动选(真机 yolo→bbox、仿真/外部→motion)\n"
f" bbox = 用工件检测框中心(像素),需要检测器真的出框\n"
f" motion = 用法兰位移(mm),不依赖检测器"
)
# =============================================================================
# 自检:键名必须体现单位(防"填了毫米按米用")
# =============================================================================
def unit_violations(keys: Optional[Iterable[str]] = None) -> List[str]:
"""返回"键名与单位不符"的清单(空 = 全部合规)。
规则:单位非空的键,**键名必须以对应的单位后缀结尾**(见 :data:`_UNIT_SUFFIX`)。
例外只有 :data:`_SUFFIX_OVERRIDE` 里显式登记的复合单位。
为什么值得一条专门的检查:``hover`` 填毫米、代码按米用,会给出一个
"看起来完全合理"的运动(悬停 5 变成悬停 5 米或 5 毫米),
而 :mod:`arm_interfaces.config_guard` 的头注释记录的正是这类事故。
"""
bad: List[str] = []
for key in keys if keys is not None else _UNITS:
unit = _UNITS[key]
if not unit:
if key not in _UNITLESS:
bad.append(f"{key}: 无量纲但未登记在 _UNITLESS")
continue
suffix = _SUFFIX_OVERRIDE.get(key)
if suffix is None:
base = unit.split("/")[0]
suffix = _UNIT_SUFFIX.get(base)
if suffix is None:
bad.append(f"{key}: 单位 {unit} 没有登记后缀(补 _UNIT_SUFFIX 或 _SUFFIX_OVERRIDE)")
continue
if not key.endswith(suffix):
bad.append(f"{key}: 单位是 {unit},键名应以 {suffix} 结尾")
return bad
def missing_entries() -> List[str]:
"""三张表(类型/单位/默认值)是否互相覆盖完整。"""
problems: List[str] = []
for key in _TYPES:
if key not in _UNITS:
problems.append(f"{key}: 缺 _UNITS 登记")
if key not in DEFAULTS:
problems.append(f"{key}: 缺 DEFAULTS(required 的键也应写一个起点值并在文档里标注必标定)")
for key in _UNITS:
if key not in _TYPES:
problems.append(f"{key}: 在 _UNITS 里但不在 _TYPES 里")
for key in DEFAULTS:
if key not in _TYPES:
problems.append(f"{key}: 在 DEFAULTS 里但不在 _TYPES 里")
return problems
def dataclass_keys() -> List[str]:
""":class:`GaugeConfig` 的字段名 —— 与配置表比对,防"解析了但没存进对象"。"""
return [f.name for f in fields(GaugeConfig)]
@@ -0,0 +1,228 @@
"""夹爪客户端:把"真机今天的接口"和"框架契约里的接口"收敛到一个出入口。
为什么要这一层(这不是多余抽象)
--------------------------------
现场查证(2026-09-24)发现**同一个服务名后面挂着两种类型**:
=============== ========================== ================================
服务 A:真机今天跑的 B:框架契约 / 仿真
=============== ========================== ================================
``/gripper/open`` ``std_srvs/Trigger`` ``arm_msgs/srv/GripperExecute``
``/gripper/close`` ``std_srvs/Trigger`` ``arm_msgs/srv/GripperExecute``
状态 ``/gripper/states`` ``/gripper/status``
(Float32MultiArray,5 元) (arm_msgs/GripperStatus)
=============== ========================== ================================
A 来自 ``arm_application/dh_pgea_node.py``(真机在跑的那个节点,grip_state 直接
带"咬住/掉落");B 来自 ``devices/grippers/dh_pgea/manifest.yaml`` 声明的
``arm_driver_dh_gripper`` —— **那个包还不存在**(``src/`` 下没有),目前只有
仿真适配器实现了它。
于是有两个错法,都得避免:
* 只按契约(B)写:真机上服务类型对不上,装配流程一步都走不了,
而且报错会像是"服务没起"(实际起了,只是类型不同);
* 只按真机(A)写:仿真跑不了,而且把"临时接口"固化成事实(规则 M2)。
所以:**运行时看服务类型自己选**(:func:`pick_backend`),装配节点只依赖
:class:`GripperClient` 的 ``open/close/wait/is_gripped`` 四个方法。
等 ``arm_driver_dh_gripper`` 落地后,把 ``gauge_assembly.gripper_backend``
改回 ``auto`` 即可自动切过去,应用层不用改一行。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Mapping, Optional, Sequence
#: 契约后端用到的服务名(``arm_msgs/srv/GripperExecute``)。
CONTRACT_TYPE = "arm_msgs/srv/GripperExecute"
#: 真机今天用的后端(``std_srvs/srv/Trigger``)。
LEGACY_TYPE = "std_srvs/srv/Trigger"
CONTRACT_OPEN = "/gripper/open"
CONTRACT_CLOSE = "/gripper/close"
CONTRACT_STATUS_TOPIC = "/gripper/status"
LEGACY_STATUS_TOPIC = "/gripper/states"
#: DH 状态字(与 ``arm_msgs/GripperStatus.msg`` 里的注释一致)。
GRIP_STATE_MOVING = 0
GRIP_STATE_REACHED = 1
GRIP_STATE_CAUGHT = 2
GRIP_STATE_DROPPED = 3
GRIP_STATE_NAME = {
GRIP_STATE_MOVING: "运动中",
GRIP_STATE_REACHED: "到位",
GRIP_STATE_CAUGHT: "咬住物体",
GRIP_STATE_DROPPED: "物体掉落",
}
BACKENDS = ("contract", "legacy")
class GripperInterfaceError(RuntimeError):
"""夹爪接口无法确定。消息里必须写清"现在是什么状态、怎么修"。"""
@dataclass(frozen=True)
class BackendChoice:
"""选择结果。"""
backend: str
"""``contract`` 或 ``legacy``。"""
reason: str
"""为什么这么选 —— 要能直接打进启动日志。"""
status_topic: str
"""该后端下状态从哪个话题读。"""
def pick_backend(
services: Mapping[str, Sequence[str]], prefer: str = "auto"
) -> BackendChoice:
"""按**实际服务类型**决定用哪种夹爪接口。
:param services: ``{服务名: [类型, ...]}``,直接来自
``Node.get_service_names_and_types()``。
:param prefer: ``auto`` / ``contract`` / ``legacy``。
非 auto 时若实际类型与之不符,**不静默降级**,而是报错 ——
现场明确指定了后端却跑在另一个上,是最难查的一类"改了没生效"。
:raises GripperInterfaceError: 认不出来、两种都有、或指定的后端对不上。
用类型而不是名字判断,是因为两种后端的**服务名完全一样**(都是
``/gripper/open``):只看名字必然选不出来,而"随便选一个"的后果是
调用一个类型不匹配的服务,报出来的错与真实原因无关(规则 M1)。
"""
if prefer not in ("auto",) + BACKENDS:
raise GripperInterfaceError(
f"gripper_backend 只能是 auto / {' / '.join(BACKENDS)},收到 {prefer!r}"
)
open_types = list(services.get(CONTRACT_OPEN, ()))
close_types = list(services.get(CONTRACT_CLOSE, ()))
has_contract = CONTRACT_TYPE in open_types and CONTRACT_TYPE in close_types
has_legacy = LEGACY_TYPE in open_types and LEGACY_TYPE in close_types
if prefer == "contract" and not has_contract:
raise GripperInterfaceError(
f"配置要求 contract 后端,但 {CONTRACT_OPEN} 的类型是 "
f"{open_types or '(服务不存在)'},不是 {CONTRACT_TYPE}。\n"
" 怎么修:① 起仿真(它有契约服务)或等 arm_driver_dh_gripper 落地;"
"② 改用 gauge_assembly.gripper_backend: legacy(真机今天的接口);"
"③ 改回 auto 让本函数自己选"
)
if prefer == "legacy" and not has_legacy:
raise GripperInterfaceError(
f"配置要求 legacy 后端,但 {CONTRACT_OPEN} 的类型是 "
f"{open_types or '(服务不存在)'},不是 {LEGACY_TYPE}。\n"
" 怎么修:确认真机侧起的是 arm_application 的 dh_pgea 节点"
"(bash scripts/run_gripper.sh --arm 89)"
)
if has_contract and has_legacy:
# 同名服务两种类型同时在线:ROS 2 允许,但调用方只能撞运气。
raise GripperInterfaceError(
f"{CONTRACT_OPEN} 同时存在两种类型:{open_types}。"
"是仿真适配器和真机节点同时起着(或旧的没停干净)。\n"
" 怎么修:只留一个 —— 仿真用 scripts/stop_sim_gz.sh 停干净,"
"真机 pkill -f dh_pgea"
)
if prefer == "auto":
if has_contract:
return BackendChoice("contract", f"{CONTRACT_OPEN} 是 {CONTRACT_TYPE}", CONTRACT_STATUS_TOPIC)
if has_legacy:
return BackendChoice(
"legacy",
f"{CONTRACT_OPEN} 是 {LEGACY_TYPE}(真机 dh_pgea 节点;"
"契约驱动 arm_driver_dh_gripper 还没落地)",
LEGACY_STATUS_TOPIC,
)
raise GripperInterfaceError(_unrecognised_msg(services, open_types))
if prefer == "contract":
return BackendChoice("contract", "配置指定 contract", CONTRACT_STATUS_TOPIC)
return BackendChoice("legacy", "配置指定 legacy", LEGACY_STATUS_TOPIC)
def _unrecognised_msg(
services: Mapping[str, Sequence[str]], open_types: Sequence[str]
) -> str:
"""认不出来时,把"服务不在"和"服务在但类型不对"分开说。
两者都报成"服务没起"是这个仓库反复踩过的坑:现场明明起了夹爪节点,
却被提示"去起夹爪节点",然后开始怀疑人生(规则 M1、R5.4)。
"""
missing = [n for n in (CONTRACT_OPEN, CONTRACT_CLOSE) if n not in services]
if missing:
return (
f"{' / '.join(missing)} 不在,夹爪节点没起来。\n"
" 怎么修:真机 bash scripts/run_gripper.sh --arm 89;"
"仿真 bash scripts/run_sim_gz.sh(仿真自带契约服务)"
)
return (
f"{CONTRACT_OPEN} 在,但类型是 {list(open_types)},"
f"既不认识 {CONTRACT_TYPE}(契约)也不认识 {LEGACY_TYPE}(真机 dh_pgea)。\n"
" 常见原因:这个服务名被别的节点占用了。查是谁:\n"
" ros2 service list -t | grep gripper\n"
" 怎么修:停掉那个节点,或改 gauge_assembly.gripper_backend 明确指定后端"
)
# =============================================================================
# 状态解读(纯函数)
# =============================================================================
def is_gripped_from_state(grip_state: int) -> Optional[bool]:
"""DH 状态字 → "爪里有没有东西"。
``2`` 咬住 / ``3`` 掉落 是明确的;``0`` 运动中、``1`` 到位(空爪)都要
小心:**"到位"不等于"夹住了"** —— 空爪闭合也会到位。所以只有 2/3 给
明确答案,其余返回 None("不知道"),由调用方决定要不要等。
返回 None 而不是 False 很重要:把"不知道"当成"没夹住"会让 E1 掉件检测
在任何一次开合过程中误报。
"""
if grip_state == GRIP_STATE_CAUGHT:
return True
if grip_state == GRIP_STATE_DROPPED:
return False
return None
def is_dropped_from_state(grip_state: int, was_gripped: bool) -> bool:
"""E1 掉件判据:**曾经夹住过**,现在状态字变成"掉落"才是掉件。
为什么要 ``was_gripped``:装配前爪里本来就是空的,那时 ``grip_state``
可能是 3(掉落/无物),直接判成掉件会让流程一开始就报 E1。
"""
return was_gripped and grip_state == GRIP_STATE_DROPPED
def legacy_states_to_fields(data: Sequence[float]) -> Dict[str, float]:
"""``/gripper/states`` 的 5 元素数组 → 具名字段。
超出长度或为空时返回空字典(而不是抛异常):这是**状态话题**,
收到畸形数据时应该让上层走"不知道"分支,而不是让整个节点崩掉。
"""
if not data or len(data) < 5:
return {}
return {
"initialized": float(data[0]),
"grip_state": float(data[1]),
"position": float(data[2]),
"target_position": float(data[3]),
"target_force": float(data[4]),
}
def describe_backend_choice(choice: BackendChoice) -> List[str]:
"""给启动日志用的多行说明(含"契约驱动还没落地"这类现场事实)。"""
lines = [f"夹爪后端 = {choice.backend}({choice.reason})"]
if choice.backend == "legacy":
lines.append(
" 注意:legacy 后端下 /gripper/open|close 是 std_srvs/Trigger,"
"开口是 1000‰ 的百分比语义,不是毫米;装配流程只用它开合,"
"不靠它量开口"
)
return lines
@@ -0,0 +1,244 @@
"""通止规装配用的小几何/解析工具 —— **纯函数**,可离线单测。
放这里的都是"算错了不会抛异常、只会让动作做错"的东西(规则 M3、R5.1):
* ``parse_joint_limits``:从 URDF 文本里抠关节限位,给 S01 的"转到极限角"
做软夹取。为什么不用 ``urdf_parser_py``:本机没装,多一个依赖就多一个
"在真机上 import 失败"的机会;限位信息在 URDF 里是极规整的一行
``<limit lower=... upper=...>``,用标准库解析足够,而且**可控**。
* ``prewind``:算"逆时针转到不触发关节极限的极限角"到底能转多少度。
* ``rotation_about_axis_deg``:事后验证那个动作**真的**是绕工具 Z 转的。
这条是 S01 的验收判据:不验证的话,"绕错轴"会表现成"转是转了,孔位
偏了",而且要到几个阶段之后才看出来。
"""
from __future__ import annotations
import math
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Dict, Iterable, Optional, Sequence, Tuple
import numpy as np
#: 限位夹取时留的余量(度)。贴到限位上再下发,驱动器会因为"位置超出限位"
#: 直接拒绝整条轨迹 —— 那不是我们要的"极限角",是报错。
DEFAULT_LIMIT_MARGIN_DEG = 3.0
_JOINT_RE = re.compile(r"<joint\s+name\s*=\s*\"([^\"]+)\"")
_ATTR_RE = re.compile(r"(lower|upper)\s*=\s*\"([-+0-9.eE]+)\"")
def _limits_by_lines(text: str) -> Dict[str, Tuple[float, float]]:
"""逐行兜底解析:记住最近一个 ``<joint name=...>``,紧跟的 ``<limit>`` 归它。
为什么要"记住"而不是"要求同一行":正常格式化的 URDF 里关节名和 limit
**不在同一行**,只认可同一行的兜底等于永远不生效(一个不会触发的兜底,
和没有兜底是一样的)。属性顺序也按出现顺序读,不假设 lower 在 upper 前。
"""
out: Dict[str, Tuple[float, float]] = {}
current: Optional[str] = None
for line in text.splitlines():
m = _JOINT_RE.search(line)
if m:
current = m.group(1)
if current is None or "<limit" not in line:
continue
attrs = dict(_ATTR_RE.findall(line))
if "lower" in attrs and "upper" in attrs:
out[current] = (float(attrs["lower"]), float(attrs["upper"]))
current = None
return out
@dataclass(frozen=True)
class PrewindResult:
"""S01 预绕量的计算结果。"""
target_rad: float
"""目标关节角 [rad]。"""
applied_deg: float
"""实际能转的角度(带符号,符合"逆时针为正"的约定)[deg]。"""
want_deg: float
"""希望转的角度 [deg]。"""
clamped: bool
"""是否因为关节限位被夹取过。被夹取时 ``applied_deg < want_deg``。"""
has_limits: bool
"""是否拿到了可信的关节限位。False 表示没有任何夹取依据。"""
def parse_joint_limits(urdf_xml: str) -> Dict[str, Tuple[float, float]]:
"""从 URDF 文本里解析 ``关节名 -> (lower, upper)``(弧度)。
关节名**原样保留**(含 ``Rizon4-063989_`` 前缀),调用方自己决定怎么匹配;
这里不做"去前缀"之类的猜测 —— 猜测会在换臂时静默失配,然后限位夹取
悄悄失效(所有关节都"查不到限位",于是不夹取,于是贴着限位下发)。
解析失败的关节直接不进结果集:``continuous`` 关节本来就没有限位,
不该被当成"解析失败"。调用方用"查不到 = 不夹取"来处理。
"""
out: Dict[str, Tuple[float, float]] = {}
if not urdf_xml or "<robot" not in urdf_xml:
return out
try:
root = ET.fromstring(urdf_xml)
except ET.ParseError:
# URDF 里出现未展开的 xacro 或未转义的 ``&`` 是常见的(拿错话题就是
# 这种内容)。降级成逐行扫描仍能救回限位;救不回就返回空表(= 不夹取),
# 由调用方告警 —— **不会**返回半套数据。
return _limits_by_lines(urdf_xml)
for joint in root.findall(".//joint"):
name = joint.get("name")
limit = joint.find("limit")
if not name or limit is None:
continue
lo, up = limit.get("lower"), limit.get("upper")
if lo is None or up is None:
continue
try:
out[name] = (float(lo), float(up))
except ValueError:
continue
return out
def match_limits(
limits: Dict[str, Tuple[float, float]], joint_names: Sequence[str]
) -> Optional[Tuple[float, float]]:
"""按名字给一串关节名找限位,**要求全部找到**才返回。
只有一部分找到时返回 None 而不是"把找到的那部分用上":半套限位会让
"能不能转到极限角"的结论变得不可解释(有些关节夹了、有些没夹),
与其给出一个说不清的边界,不如明确地说"这次没有限位依据"。
"""
found = [limits.get(n) for n in joint_names]
if any(v is None for v in found):
return None
return found[-1] # 最后一个 = 我们要绕的那根轴(调用方传的顺序有意义)
def prewind(
q_now_rad: float,
want_deg: float,
direction: int,
limits: Optional[Tuple[float, float]] = None,
margin_deg: float = DEFAULT_LIMIT_MARGIN_DEG,
) -> PrewindResult:
"""算 S01 的预绕目标角。
:param q_now_rad: 当前关节角 [rad]。
:param want_deg: 想转的角度(**正值**)[deg]。
:param direction: ``+1`` = 绕 +Z 逆时针(右手定则);``-1`` = 顺时针。
符号到底对应现场的哪个方向,用真机确认一次后改 ``site.yaml`` 即可,
不要靠"改代码里的负号"—— 那种改动没人 review 得到(规则 M2)。
:param limits: ``(lower, upper)`` [rad]。给 None 时不做夹取。
:param margin_deg: 距离限位保留的余量 [deg]。
:returns: :class:`PrewindResult`。**无论有没有限位,都返回目标角**,
因为"贴到限位再继续转"在现场是预期行为(用户要求转到极限角),
驱动器真拒绝时会由 :func:`arm_application.gauge_fsm.is_joint_limit_error`
认成 JOINT_LIMIT 事件继续流程。
为什么要在这里夹取(而不是直接下发、等驱动器报错):
驱动器拒绝一条轨迹时,整个 action 失败,连带把已经转过的那部分**也**
变成"没转过"的语义(角度实际到位了,但流程认为失败)。夹取到
"限位 − 余量"能拿到一个明确成功、且确实接近极限的姿态度。
"""
if want_deg <= 0.0:
raise ValueError(f"预绕角度必须为正,收到 {want_deg}")
if direction not in (-1, 1):
raise ValueError(f"方向只能是 +1 / -1,收到 {direction}")
signed = direction * abs(want_deg)
target = q_now_rad + math.radians(signed)
has_limits = limits is not None
clamped = False
if limits is not None:
lo = limits[0] + math.radians(margin_deg)
up = limits[1] - math.radians(margin_deg)
if lo > up: # 限位本身比余量还窄(不该发生,但别让它变成静默走偏)
lo = up = 0.5 * (limits[0] + limits[1])
if target > up:
target, clamped = up, True
elif target < lo:
target, clamped = lo, True
applied = math.degrees(target - q_now_rad)
return PrewindResult(
target_rad=target,
applied_deg=applied,
want_deg=abs(want_deg),
clamped=clamped,
has_limits=has_limits,
)
def rotation_about_axis_deg(R_before: np.ndarray, R_after: np.ndarray, axis: np.ndarray) -> float:
"""从两个旋转矩阵算出"绕 ``axis`` 转了多少度"(带符号)。
S01 的验收判据就靠它:绕工具 Z 转 Δ 之后,如果实际绕的是别的轴,
这个函数给出的角度会与 Δ 明显不符(或者 axis 分量不占优)。
做法是把相对旋转投影到 ``axis`` 上:
``θ = atan2( axis·(R_b·R_a⁻¹ 的反对称部分) , (trace-1)/2 )``
:param axis: **单位**轴(工具 Z 在世界系下的方向 = ``R[:, 2]``)。
"""
a = np.asarray(axis, dtype=float)
n = float(np.linalg.norm(a))
if n < 1e-9:
raise ValueError("轴向量长度为 0,无法判定旋转")
a = a / n
Rb = np.asarray(R_before, dtype=float)[:3, :3]
Ra = np.asarray(R_after, dtype=float)[:3, :3]
Rrel = Ra @ Rb.T
# 反对称部分:sin(θ)·[axis]_×
v = np.array([Rrel[2, 1] - Rrel[1, 2], Rrel[0, 2] - Rrel[2, 0], Rrel[1, 0] - Rrel[0, 1]])
sin_t = 0.5 * float(np.dot(v, a))
cos_t = 0.5 * (float(np.trace(Rrel)) - 1.0)
return math.degrees(math.atan2(sin_t, cos_t))
def axis_misalignment_deg(R_before: np.ndarray, R_after: np.ndarray, axis: np.ndarray) -> float:
"""相对旋转的转轴与 ``axis`` 的夹角 [deg]。
纯绕轴旋转时这个值≈0;转错轴时它会接近 90°。用作 S01 的判错依据:
只看"转了多少度"是不够的 —— 绕正确的轴转 90° 和绕错误的轴转 90°
给出的角度读数一样。
"""
Rb = np.asarray(R_before, dtype=float)[:3, :3]
Ra = np.asarray(R_after, dtype=float)[:3, :3]
Rrel = Ra @ Rb.T
# 旋转轴 = 反对称部分对应的向量(sinθ 因子不影响方向)
v = np.array([Rrel[2, 1] - Rrel[1, 2], Rrel[0, 2] - Rrel[2, 0], Rrel[1, 0] - Rrel[0, 1]])
nv = float(np.linalg.norm(v))
if nv < 1e-9:
return 0.0 # 几乎没转,谈不上"转错轴"
a = np.asarray(axis, dtype=float)
a = a / float(np.linalg.norm(a))
cos = abs(float(np.dot(v / nv, a))) # 取绝对值:轴线的两个方向等价
return math.degrees(math.acos(max(-1.0, min(1.0, cos))))
def unit_tool_z(R: np.ndarray) -> np.ndarray:
"""工具坐标系的 Z 轴在世界系下的方向(旋转矩阵的第三列)。"""
return np.asarray(R, dtype=float)[:3, 2]
def vertical_down_error_deg(R: np.ndarray, world_down: Iterable[float] = (0.0, 0.0, -1.0)) -> float:
"""工具 Z 与"垂直向下"的夹角 [deg]。0 = 完全垂直向下。
看板里"工具系全程垂直向下、位姿全局锁定"这句话,只有能算出这个数才算
真的在执行 —— 否则每个阶段都得靠人眼看画面判断臂歪没歪。
"""
z = unit_tool_z(R)
d = np.asarray(list(world_down), dtype=float)
d = d / float(np.linalg.norm(d))
cos = float(np.dot(z / max(1e-12, float(np.linalg.norm(z))), d))
return math.degrees(math.acos(max(-1.0, min(1.0, cos))))
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""向后兼容 shim —— 实现已移到 :mod:`arm_kinematics.geom_ik`。
为什么搬走
----------
这套几何雅可比 + DLS 逆解同时被应用层(视觉伺服、搜孔装配)和驱动层
(``arm_driver_flexiv`` 的柔顺下压)需要。留在 ``arm_application`` 里会让驱动层
反过来依赖应用层,属于层次倒置。搬到不依赖 ROS 的 ``arm_kinematics`` 后,
两边都依赖一个底层包。
为什么保留这个文件
------------------
``stage2_locate_node`` / ``stage3_search_node`` / ``servo_node`` 等现有代码都在
``from arm_application.geom_ik import ...``。保留 shim 就不用改动这些已经在跑的
文件 —— 重构不应牵连能工作的代码。
新代码请直接 ``from arm_kinematics import dls_twist``。
"""
from arm_kinematics.geom_ik import ( # noqa: F401
RIZON_LINK_AXES,
dls_position_step,
dls_translate_hold_ori,
dls_twist,
geometric_jacobian,
load_handeye_T,
position_jacobian,
quat_xyzw_to_R,
)
__all__ = [
"RIZON_LINK_AXES",
"quat_xyzw_to_R",
"geometric_jacobian",
"position_jacobian",
"dls_position_step",
"dls_twist",
"dls_translate_hold_ori",
"load_handeye_T",
]
@@ -0,0 +1,457 @@
#!/usr/bin/env python3
"""感知/伺服启动前的开机回初始位流程。
流程:
[A] 若法兰低于 ``servo.safe_z_m``,先让它竖直上升(闭环 Z 小步跳),
以免回 home 时扫到墙面/工装;
[B] 调用 ``/set_named_pose``(Flexiv 命名位姿,默认 "home")—— 由 Flexiv
自己的规划器把机械臂送回 Flexiv 初始位姿;
[C] 保持(pin)住,让伺服从一个干净、静止的位姿接手。
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
from typing import List, Optional, Tuple
import numpy as np
import rclpy
from arm_msgs.srv import SetNamedPose
from control_msgs.action import FollowJointTrajectory
from rclpy.action import ActionClient
from rclpy.node import Node
from sensor_msgs.msg import JointState
from tf2_ros import Buffer, TransformListener
from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint
from arm_application.geom_ik import (
RIZON_LINK_AXES,
dls_translate_hold_ori,
dls_twist,
geometric_jacobian,
quat_xyzw_to_R,
)
from arm_application.site_io import (
base_frame_default,
ee_frame_default,
joint_prefix_default,
)
from arm_application.ros_node import TolerantNode
def _load_ee_cfg() -> dict:
root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(root / "scripts" / "lib"))
from export_site_env import load_site, resolve_robot # type: ignore
data = load_site()
servo = (data.get("servo") or {})
robot = resolve_robot(data)
ee_z = float(servo.get("ee_z_m") or 0.24)
return {
"prefix": str(robot.get("joint_prefix") or joint_prefix_default()),
"base": str(robot.get("base_frame") or base_frame_default()),
"ee": str(robot.get("ee_frame") or ee_frame_default()),
"z": ee_z,
# 回 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),
# 仅用于(目前未使用的)verticalize 规划路径
"kp": float(servo.get("ee_hold_kp") or 2.0),
"w_max": float(servo.get("ee_w_max") or 0.50),
}
def _pt(q: np.ndarray, qdot: np.ndarray, t_sec: float) -> JointTrajectoryPoint:
pt = JointTrajectoryPoint()
pt.positions = [float(v) for v in q.tolist()]
pt.velocities = [float(v) for v in qdot.tolist()]
pt.accelerations = [0.0] * int(q.size)
ns = int(round(max(0.0, float(t_sec)) * 1e9))
pt.time_from_start.sec = ns // 1_000_000_000
pt.time_from_start.nanosec = ns % 1_000_000_000
return pt
class GotoEePlane(TolerantNode):
def __init__(self, cfg: dict) -> None:
super().__init__("goto_ee_plane")
self.cfg = cfg
prefix = cfg["prefix"]
self.joint_names = [f"{prefix}joint{i}" for i in range(1, 8)]
self.link_frames = [f"{prefix}link{i}" for i in range(1, 8)]
self._js: Optional[JointState] = None
self.create_subscription(JointState, "joint_states", self._on_js, 10)
self._tf = Buffer()
TransformListener(self._tf, self, spin_thread=True)
self._cli = ActionClient(
self, FollowJointTrajectory, "rizon_arm_controller/follow_joint_trajectory"
)
def _on_js(self, msg: JointState) -> None:
self._js = msg
def _spin(self, dt: float = 0.05) -> None:
rclpy.spin_once(self, timeout_sec=dt)
def _lookup(self, parent: str, child: str) -> Optional[np.ndarray]:
try:
tf = self._tf.lookup_transform(
parent, child, rclpy.time.Time(),
timeout=rclpy.duration.Duration(nanoseconds=80_000_000),
)
except Exception:
return None
t = tf.transform.translation
o = tf.transform.rotation
T = np.eye(4)
T[:3, :3] = quat_xyzw_to_R([o.x, o.y, o.z, o.w])
T[:3, 3] = [t.x, t.y, t.z]
return T
def _q(self) -> Optional[np.ndarray]:
if self._js is None:
return None
m = {n: p for n, p in zip(self._js.name, self._js.position)}
try:
return np.array([float(m[n]) for n in self.joint_names], dtype=float)
except KeyError:
return None
def _jacobian(self, p_ee: np.ndarray) -> Optional[np.ndarray]:
link_R: List[np.ndarray] = []
link_p: List[np.ndarray] = []
for fr in self.link_frames:
Tl = self._lookup(self.cfg["base"], fr)
if Tl is None:
return None
link_R.append(Tl[:3, :3])
link_p.append(Tl[:3, 3])
return geometric_jacobian(link_R, link_p, p_ee, RIZON_LINK_AXES)
def _w_down(self, R: np.ndarray) -> Tuple[np.ndarray, float]:
z_now = R[:, 2]
z_des = np.array([0.0, 0.0, -1.0])
c = float(np.clip(np.dot(z_now, z_des), -1.0, 1.0))
ang = float(np.arccos(c))
if ang < 0.02:
return np.zeros(3), ang
axis = np.cross(z_now, z_des)
n = float(np.linalg.norm(axis))
axis = axis / n if n > 1e-9 else np.array([1.0, 0.0, 0.0])
mag = float(np.clip(self.cfg["kp"] * ang, -self.cfg["w_max"], self.cfg["w_max"]))
return axis * mag, ang
def _target_err(
self, p: np.ndarray, R: np.ndarray, target: np.ndarray, verticalize: bool
) -> Tuple[np.ndarray, float, float]:
dp = np.asarray(target, dtype=float).reshape(3) - np.asarray(p, dtype=float).reshape(3)
_, ang = (self._w_down(R) if verticalize else (np.zeros(3), 0.0))
xy = float(np.hypot(dp[0], dp[1]))
return dp, ang, xy
def _wait_state(self, timeout_s: float) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
t0 = time.monotonic()
q = None
Tee = None
while time.monotonic() - t0 < timeout_s:
self._spin(0.05)
q = self._q()
Tee = self._lookup(self.cfg["base"], self.cfg["ee"])
if q is not None and Tee is not None and self._jacobian(Tee[:3, 3]) is not None:
return q, Tee
return q, Tee
def _send_action(self, traj: JointTrajectory, wait_s: float) -> bool:
goal = FollowJointTrajectory.Goal()
goal.trajectory = traj
fut = self._cli.send_goal_async(goal)
t0 = time.monotonic()
while not fut.done() and time.monotonic() - t0 < 4.0:
self._spin(0.05)
handle = fut.result() if fut.done() else None
if handle is None or not handle.accepted:
print(" 错误:JTC 拒绝了抬升轨迹", flush=True)
return False
res = handle.get_result_async()
t0 = time.monotonic()
last = 0.0
while not res.done() and time.monotonic() - t0 < wait_s:
self._spin(0.05)
now = time.monotonic()
if now - last > 0.4:
Tee = self._lookup(self.cfg["base"], self.cfg["ee"])
if Tee is not None:
p = Tee[:3, 3]
print(
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(" 警告:JTC 结果超时 —— 仍然继续检查 TF", flush=True)
return False
return True
def _plan_and_send(
self,
q0: np.ndarray,
Tee: np.ndarray,
target: np.ndarray,
verticalize: bool,
tag: str = "",
max_seg: float = 0.08,
v: float = 0.05,
) -> bool:
"""规划并执行一段朝向 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: # 限制本次小步跳的长度;调用方会用最新的 TF 循环执行
dp = dp * (max_seg / dist)
dist = max_seg
print(
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("错误:缺少用于雅可比的 link TF", flush=True)
return False
q = q0.copy()
waypoints = [q.copy()]
remain = dp.copy()
w_step, _ = (self._w_down(R) if verticalize else (np.zeros(3), 0.0))
n_max = int(np.ceil(dist / 0.012)) + 4
for _ in range(n_max):
if float(np.linalg.norm(remain)) < 0.0015 and (
(not verticalize) or ang < 0.03
):
break
nseg = max(1, int(np.ceil(float(np.linalg.norm(remain)) / 0.012)))
step = remain / nseg
if verticalize and ang > 0.03:
qdot = dls_twist(
J, step / 0.12, w_base=w_step, damping=0.08, max_qdot=0.45
)
dq = np.clip(qdot * 0.12, -0.06, 0.06)
else:
dq = dls_translate_hold_ori(J, step, damping=0.08, max_dq=0.06)
if float(np.max(np.abs(dq))) < 1e-5:
break
q = q + dq
waypoints.append(q.copy())
remain = remain - (J[:3, :] @ dq)
ang = max(0.0, ang - 0.05)
planned = dist - float(np.linalg.norm(remain))
if len(waypoints) < 2 or planned < 0.4 * dist:
print(
f" 警告 {tag}:规划器停滞 "
f"(已规划 {planned*1000:.0f}/{dist*1000:.0f}mm)—— 跳过本次小步跳",
flush=True,
)
return False
# 关节偏移量的合理性闸门:拒绝过于夸张的规划
if float(np.max(np.abs(waypoints[-1] - q0))) > 1.0:
print(f" 错误 {tag}:关节偏移 >1 rad —— 放弃本次小步跳", flush=True)
return False
duration = max(1.6, dist / max(min(v, 0.06), 1e-3))
n = len(waypoints) - 1
traj = JointTrajectory()
traj.header.stamp.sec = 0
traj.header.stamp.nanosec = 0
traj.joint_names = list(self.joint_names)
traj.points.append(_pt(waypoints[0], np.zeros(7), 0.08))
zeros = np.zeros(7)
for i, qi in enumerate(waypoints[1:], start=1):
t = 0.08 + duration * (i / n)
qdot = zeros if i == n else (waypoints[min(i + 1, n)] - qi) / max(
duration / n, 0.05
)
qdot = np.clip(qdot, -0.40, 0.40)
traj.points.append(_pt(qi, qdot, t))
# 强制最后一个点的速度为零
traj.points[-1].velocities = [0.0] * 7
print(
f" 发送 JTC action {tag},{n} 步 T={duration:.1f}s",
flush=True,
)
return self._send_action(traj, wait_s=duration + 6.0)
def _goto(
self,
q: np.ndarray,
Tee: np.ndarray,
target: np.ndarray,
verticalize: bool,
tol_m: float,
tol_tilt: float,
tag: str,
v: float,
max_hops: int = 8,
) -> Tuple[bool, np.ndarray, np.ndarray]:
"""闭环点到点:规划 ≤8 cm 的小步跳 → 执行 → 重新测量 TF → 重复,
直到落在容差内。这样对长距离下的雅可比线性化误差更鲁棒。"""
for hop in range(max_hops):
p = Tee[:3, 3]
dp, ang, _ = self._target_err(p, Tee[:3, :3], target, verticalize)
dist = float(np.linalg.norm(dp))
if dist < tol_m and ((not verticalize) or ang < tol_tilt):
return True, q, Tee
print(
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):
return False, q, Tee
t_end = time.monotonic() + 0.5
while time.monotonic() < t_end:
self._spin(0.05)
q2, Tee2 = self._wait_state(2.0)
if q2 is None or Tee2 is None:
print("错误:移动过程中丢失状态", flush=True)
return False, q, Tee
q, Tee = q2, Tee2
p = Tee[:3, 3]
dp, ang, _ = self._target_err(p, Tee[:3, :3], target, verticalize)
ok = float(np.linalg.norm(dp)) < (tol_m * 2.0) and (
(not verticalize) or ang < (tol_tilt * 2.0)
)
return ok, q, Tee
def _pin(self, q: np.ndarray) -> None:
if not self._cli.wait_for_server(timeout_sec=3.0):
return
traj = JointTrajectory()
traj.header.stamp.sec = 0
traj.header.stamp.nanosec = 0
traj.joint_names = list(self.joint_names)
traj.points = [_pt(q, np.zeros(7), 0.08), _pt(q, np.zeros(7), 0.45)]
self._send_action(traj, wait_s=3.0)
def _named_pose(self, name: str, speed: float) -> bool:
"""调用 Flexiv 的 ``/set_named_pose``,并等待机械臂静止。"""
cli = self.create_client(SetNamedPose, "/set_named_pose")
if not cli.wait_for_server(timeout_sec=8.0):
print("错误:``/set_named_pose`` 不可用 —— 请启动 run_real.sh", flush=True)
return False
req = SetNamedPose.Request()
req.pose_name = name
req.velocity_scale = float(min(max(speed, 0.05), 1.0))
req.acceleration_scale = 0.0
print(f" [B] set_named_pose '{name}' speed={req.velocity_scale:.2f}", flush=True)
fut = cli.call_async(req)
t0 = time.monotonic()
while not fut.done() and time.monotonic() - t0 < 90.0:
self._spin(0.1)
if not fut.done():
print("错误:set_named_pose 超时", flush=True)
return False
resp = fut.result()
print(
f" [B] 响应 success={resp.success} msg={resp.message}",
flush=True,
)
if not resp.success:
return False
# 等待法兰停止运动(约 0.8 s 内 Δp < 1 mm)
last = None
still = 0.0
t0 = time.monotonic()
while time.monotonic() - t0 < 30.0:
self._spin(0.2)
Tee = self._lookup(self.cfg["base"], self.cfg["ee"])
if Tee is not None:
p = Tee[:3, 3]
if last is not None and float(np.linalg.norm(p - last)) < 0.001:
still += 0.2
if still >= 0.8:
return True
else:
still = 0.0
last = p.copy()
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("错误:没有 joint_states / TF —— 请启动 run_real.sh", flush=True)
return 2
if not self._cli.wait_for_server(timeout_sec=8.0):
print("错误:JTC action 不可用 —— 请启动 run_real.sh", flush=True)
return 3
p = Tee[:3, 3]
safe_z = float(cfg["safe_z"])
print(
f"=== 回到 Flexiv 初始位姿 '{cfg['home_pose']}' "
f"safe_z={safe_z*1000:.0f}mm ===",
flush=True,
)
print(
f" 当前 ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
flush=True,
)
# --- 阶段 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] 抬升 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("错误:抬升阶段失败 —— 就地 pin 住", flush=True)
self._pin(q)
return 4
# --- 阶段 B:Flexiv 命名位姿 → 真正的初始位姿,姿态良好 ---
if not self._named_pose(cfg["home_pose"], cfg["home_speed"]):
print("错误:回命名位姿失败 —— 就地 pin 住", flush=True)
self._pin(q)
return 4
# --- 阶段 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" 已到初始位姿 ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
flush=True,
)
return 0
def main() -> int:
cfg = _load_ee_cfg()
rclpy.init()
node = GotoEePlane(cfg)
try:
return node.run()
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,771 @@
#!/usr/bin/env python3
"""完整手眼标定流水线:移动 → 棋盘稳定判定 → 采集 → 求解 → 清理。
支持 eye_in_hand / eye_to_hand。
相机内参优先级:
1) ROS 的 CameraInfo 话题(若正在发布)
2) 通过 pyrealsense2 读取 RealSense D405/D435 的 EEPROM(camera_source)
3) YAML 中手写的 camera_fx/fy/cx/cy
棋盘尺寸(board_cols / board_rows / square_size_m)可由用户配置。
"""
from __future__ import annotations
import json
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Tuple
import numpy as np
import rclpy
from geometry_msgs.msg import PoseStamped
from rclpy.action import ActionClient
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import CameraInfo, Image, JointState
from std_msgs.msg import String
from arm_application.handeye_lib import (
BoardConfig,
CameraIntrinsics,
ChessboardStabilizer,
T_from_flexiv_tcp,
calibrate_hand_eye,
cam_to_flange_via_tool,
detect_chessboard,
format_matrix_yaml,
format_transform_yaml,
is_pose_filled,
pose_to_Rt,
solve_board_pose,
)
from arm_application.handeye_realsense import (
is_realsense_source,
query_realsense_color_intrinsics,
resolve_realsense_topics,
)
from arm_application.site_io import joint_prefix_default
from arm_msgs.action import FollowJointTrajectory
from arm_msgs.srv import GetArmState
from arm_application.ros_node import TolerantNode
DEFAULT_JOINTS = [
'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7',
]
def _image_to_bgr(msg: Image) -> np.ndarray:
h, w = int(msg.height), int(msg.width)
enc = (msg.encoding or '').lower()
data = np.frombuffer(msg.data, dtype=np.uint8)
if enc in ('bgr8', 'rgb8'):
img = data.reshape(h, w, 3)
return img[:, :, ::-1].copy() if enc == 'rgb8' else img
if enc in ('mono8', '8uc1'):
gray = data.reshape(h, w)
return np.stack([gray, gray, gray], axis=-1)
if enc in ('bgra8', 'rgba8'):
img = data.reshape(h, w, 4)
bgr = img[:, :, :3]
return bgr[:, :, ::-1].copy() if enc == 'rgba8' else bgr
raise RuntimeError(f'不支持的图像编码:{msg.encoding!r}')
def _camera_info_to_intrinsics(msg: CameraInfo) -> CameraIntrinsics:
K = np.array(msg.k, dtype=float).reshape(3, 3)
dist = np.array(msg.d, dtype=float).reshape(-1, 1) if msg.d else np.zeros((5, 1))
return CameraIntrinsics(K=K, dist=dist)
class HandeyeCalibrate(TolerantNode):
def __init__(self) -> None:
super().__init__('handeye_calibrate')
self.declare_parameter('mode', 'eye_in_hand') # eye_in_hand | eye_to_hand
self.declare_parameter('joint_prefix', joint_prefix_default())
self.declare_parameter('joint_names', DEFAULT_JOINTS)
self.declare_parameter('velocity_scale', 0.10)
self.declare_parameter('action_name', 'follow_joint_trajectory')
self.declare_parameter('move_time_sec', 4.0)
self.declare_parameter('start_index', 0)
self.declare_parameter('num_poses', 12)
self.declare_parameter('waypoint_positions', [0.0] * (12 * 7))
self.declare_parameter('min_poses', 5)
# 相机来源:manual | d405 | d435
self.declare_parameter('camera_source', 'manual')
self.declare_parameter('camera_serial', '')
self.declare_parameter('camera_namespace', '')
self.declare_parameter('realsense_width', 1280)
self.declare_parameter('realsense_height', 720)
self.declare_parameter('image_topic', '')
self.declare_parameter('camera_info_topic', '')
self.declare_parameter('camera_fx', 0.0)
self.declare_parameter('camera_fy', 0.0)
self.declare_parameter('camera_cx', 0.0)
self.declare_parameter('camera_cy', 0.0)
self.declare_parameter('camera_dist', [0.0, 0.0, 0.0, 0.0, 0.0])
# 棋盘(可自定义)
# 默认值必须与 site.yaml 的 handeye.board_* 及结果文件头里的实际板子一致。
# 真实使用的板:A5 内角点 11x8,方格 15mm(site.yaml handeye.boards.eye_in_hand)。
# 这里原先写的是 9x6 / 0.025,后来是 8x11;漏配就会静默用错尺寸。
self.declare_parameter('board_cols', 11) # 内角点
self.declare_parameter('board_rows', 8)
self.declare_parameter('square_size_m', 0.015)
self.declare_parameter('stable_window', 8)
self.declare_parameter('stable_pixel_std', 0.45)
self.declare_parameter('stable_timeout_sec', 25.0)
self.declare_parameter('settle_sec', 0.4)
# 到位后的"真的停住了"判据 —— 直接看关节速度,比固定 sleep 可靠。
# 这几个参数原先 site.yaml 和 run_handeye.sh 一直在传,但本节点从未声明,
# ROS 2 会静默忽略未声明的覆盖参数,于是"等静止再拍照"这件事其实没实现,
# 实际只靠一次固定 settle_sec。现在补上。
self.declare_parameter('settle_qd_max', 0.015) # |qd|_max [rad/s] 低于此值算静
self.declare_parameter('settle_stable_sec', 0.6) # 需要连续静多久
self.declare_parameter('settle_timeout_sec', 8.0) # 等静止的总超时
self.declare_parameter('pin_holds', 2) # 静止后再保持几拍
self.declare_parameter('pin_hold_sec', 0.45) # 每拍多长
self.declare_parameter('skip_unstable_poses', True)
from arm_application.site_io import ros2_root
self.declare_parameter('output_dir', str(ros2_root() / 'handeye' / 'tmp'))
self.declare_parameter('result_file', '')
self.declare_parameter('cleanup_images', True)
# 眼在手上:AX=XB 的 gripper 用 Studio 工具 TCP,再折成 cam→法兰写 T_4x4。
# 空 = 旧口径(ee_pose 当 gripper,T_4x4 原样写出)。配了就必须带 TCP。
self.declare_parameter('gripper_tool', '')
self.declare_parameter('gripper_tcp_xyz', [0.0, 0.0, 0.0])
self.declare_parameter('gripper_tcp_qwxyz', [1.0, 0.0, 0.0, 0.0])
prefix = str(self.get_parameter('joint_prefix').value or '')
base = list(
self.get_parameter('joint_names').get_parameter_value().string_array_value
or DEFAULT_JOINTS
)
self.joint_names = [f'{prefix}{n}' for n in base]
self.mode = str(self.get_parameter('mode').value).strip().lower()
self.velocity_scale = float(self.get_parameter('velocity_scale').value)
self.action_name = str(self.get_parameter('action_name').value)
self.move_time_sec = float(self.get_parameter('move_time_sec').value)
self.start_index = max(0, int(self.get_parameter('start_index').value))
self.min_poses = int(self.get_parameter('min_poses').value)
self.cleanup_images = bool(self.get_parameter('cleanup_images').value)
self.gripper_tool = str(self.get_parameter('gripper_tool').value or '').strip()
tcp_xyz = list(
self.get_parameter('gripper_tcp_xyz').get_parameter_value().double_array_value
or [0.0, 0.0, 0.0]
)
tcp_q = list(
self.get_parameter('gripper_tcp_qwxyz').get_parameter_value().double_array_value
or [1.0, 0.0, 0.0, 0.0]
)
self.T_flange_to_tool = T_from_flexiv_tcp(tcp_xyz[:3], tcp_q[:4])
if self.gripper_tool and float(np.linalg.norm(self.T_flange_to_tool[:3, 3])) < 1e-4:
raise RuntimeError(
f'gripper_tool={self.gripper_tool!r} 但 gripper_tcp_xyz 几乎是零:'
'等于没把 Studio TCP 传进来。不许按法兰口径假装在用工具系。'
)
self.camera_source = str(self.get_parameter('camera_source').value).strip().lower()
self.camera_serial = str(self.get_parameter('camera_serial').value or '').strip()
self.camera_namespace = str(
self.get_parameter('camera_namespace').value or ''
).strip()
self.rs_width = int(self.get_parameter('realsense_width').value)
self.rs_height = int(self.get_parameter('realsense_height').value)
self.board = BoardConfig(
cols=int(self.get_parameter('board_cols').value),
rows=int(self.get_parameter('board_rows').value),
square_size=float(self.get_parameter('square_size_m').value),
)
self.stabilizer = ChessboardStabilizer(
window=int(self.get_parameter('stable_window').value),
max_pixel_std=float(self.get_parameter('stable_pixel_std').value),
)
self.stable_timeout = float(self.get_parameter('stable_timeout_sec').value)
self.settle_sec = float(self.get_parameter('settle_sec').value)
self.settle_qd_max = float(self.get_parameter('settle_qd_max').value)
self.settle_stable_sec = float(self.get_parameter('settle_stable_sec').value)
self.settle_timeout = float(self.get_parameter('settle_timeout_sec').value)
self.pin_holds = max(0, int(self.get_parameter('pin_holds').value))
self.pin_hold_sec = max(0.0, float(self.get_parameter('pin_hold_sec').value))
self.skip_unstable = bool(self.get_parameter('skip_unstable_poses').value)
out = Path(str(self.get_parameter('output_dir').value)).expanduser()
self.output_dir = out
self.images_dir = out / 'images'
rf = str(self.get_parameter('result_file').value or '').strip()
self.result_file = Path(rf).expanduser() if rf else (out / 'handeye_result.yaml')
self._client = ActionClient(self, FollowJointTrajectory, self.action_name)
self._get_state = self.create_client(GetArmState, 'get_arm_state')
self._capture_pub = self.create_publisher(String, 'handeye/capture', 10)
self._latest_image: Optional[Image] = None
self._latest_info: Optional[CameraInfo] = None
self._latest_js: Optional[JointState] = None
self._intrinsics: Optional[CameraIntrinsics] = None
img_topic = str(self.get_parameter('image_topic').value or '').strip()
info_topic = str(self.get_parameter('camera_info_topic').value or '').strip()
if is_realsense_source(self.camera_source):
img_topic, info_topic = resolve_realsense_topics(
self.camera_source,
camera_namespace=self.camera_namespace,
image_topic=img_topic,
camera_info_topic=info_topic,
)
self._info_topic = info_topic
self.create_subscription(JointState, '/joint_states', self._on_js, 10)
if not img_topic:
self.get_logger().error(
'image_topic 为空。请设置 camera_source:=d405|d435,或填写 image_topic。'
)
else:
self.create_subscription(
Image, img_topic, self._on_image, qos_profile_sensor_data
)
self.get_logger().info(f'已订阅图像:{img_topic}(BEST_EFFORT)')
if info_topic:
self.create_subscription(
CameraInfo, info_topic, self._on_info, qos_profile_sensor_data
)
self.get_logger().info(f'已订阅 camera_info:{info_topic}(BEST_EFFORT)')
def _on_js(self, msg: JointState) -> None:
self._latest_js = msg
def _on_image(self, msg: Image) -> None:
self._latest_image = msg
def _on_info(self, msg: CameraInfo) -> None:
self._latest_info = msg
def run(self) -> int:
if self.mode not in ('eye_in_hand', 'eye_to_hand'):
self.get_logger().error(
f'未知的 mode={self.mode!r};请使用 eye_in_hand 或 eye_to_hand'
)
return 1
poses = self._load_poses()
if len(poses) < self.min_poses:
self.get_logger().error(
f'只有 {len(poses)} 个已填写位姿(需要 >= {self.min_poses})。'
'示教命令:bash ~/ros2/scripts/run_handeye.sh teach && merge。'
)
return 1
if self.start_index >= len(poses):
self.get_logger().error(
f'start_index {self.start_index} >= 已填写位姿数 {len(poses)}'
)
return 1
if not self._resolve_intrinsics():
return 1
if not self._client.wait_for_server(timeout_sec=15.0):
self.get_logger().error(
f'Action [{self.action_name}] 不可用。'
'请启动:bash ~/ros2/scripts/run_real.sh'
)
return 2
self.output_dir.mkdir(parents=True, exist_ok=True)
self.images_dir.mkdir(parents=True, exist_ok=True)
eye_to_hand = self.mode == 'eye_to_hand'
grip = self.gripper_tool or '(ee_pose 原样,无工具折算)'
self.get_logger().info(
f'手眼标定 [{self.mode}] camera_source={self.camera_source}:'
f'{len(poses)} 个位姿,棋盘={self.board.cols}x{self.board.rows} '
f'方格={self.board.square_size}m,v={self.velocity_scale} '
f'gripper_tool={grip}'
)
self.get_logger().warn('清理工作空间;确认急停就绪。Ctrl+C 可中止。')
Rg: List[np.ndarray] = []
tg: List[np.ndarray] = []
Rc: List[np.ndarray] = []
tc: List[np.ndarray] = []
meta = []
try:
for i in range(self.start_index, len(poses)):
q = poses[i]
self._log_progress(i, len(poses), '移动中...')
code = self._move_one(q, self.move_time_sec)
if code != 0:
return code
if not self._verify_joints(q, tol=0.08):
self.get_logger().error(
f'[{i + 1}] 机械臂未到达目标关节位置 '
'(控制器报告成功,但关节没有变化)。'
'请重启 run_real.sh;示教器切 AUTO Remote;清除故障。'
'不要相信这次标定结果。'
)
return 10
end = time.monotonic() + max(0.0, self.settle_sec)
while rclpy.ok() and time.monotonic() < end:
rclpy.spin_once(self, timeout_sec=0.05)
# 固定 settle_sec 之后再看关节速度:真停了才算数,然后 pin 一拍。
self._wait_settled()
self._pin_hold()
ok, std, corners, pattern, bgr = self._wait_stable()
if not ok or corners is None or bgr is None:
msg = f'[{i + 1}] 棋盘不稳定(std={std:.3f})'
if self.skip_unstable:
self.get_logger().warn(f'{msg} —— 跳过')
continue
self.get_logger().error(f'{msg}。中止。')
return 7
ee = self._read_ee_pose()
if ee is None:
self.get_logger().error('读取 ee_pose 失败')
return 8
assert self._intrinsics is not None
R_t2c, t_t2c = solve_board_pose(
corners, self.board, self._intrinsics, pattern=pattern
)
xyz = [ee.pose.position.x, ee.pose.position.y, ee.pose.position.z]
quat = [
ee.pose.orientation.x,
ee.pose.orientation.y,
ee.pose.orientation.z,
ee.pose.orientation.w,
]
R_g2b, t_g2b = pose_to_Rt(xyz, quat)
stamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
img_path = self.images_dir / f'pose_{i:02d}_{stamp}.png'
self._save_png(bgr, img_path)
msg = String()
msg.data = f'capture:{i}:{len(poses)}'
self._capture_pub.publish(msg)
Rg.append(R_g2b)
tg.append(t_g2b)
Rc.append(R_t2c)
tc.append(t_t2c)
meta.append(
{
'index': i,
'joints': list(q),
'ee_xyz': xyz,
'ee_quat_xyzw': quat,
'image': str(img_path),
'corner_std_px': std,
}
)
self.get_logger().info(
f'[{i + 1}/{len(poses)}] 已采集 '
f'(corner_std={std:.3f}px) -> {img_path.name}'
)
except KeyboardInterrupt:
self.get_logger().info('已中断')
return 0
if len(Rg) < self.min_poses:
self.get_logger().error(
f'只采集到 {len(Rg)} 次;求解至少需要 {self.min_poses} 次'
)
return 9
self.get_logger().info(f'用 {len(Rg)} 个视角求解手眼标定...')
R, t = calibrate_hand_eye(Rg, tg, Rc, tc, eye_to_hand=eye_to_hand)
extra = ''
if eye_to_hand:
name = 'T_cam_to_base (eye_to_hand)'
yaml_txt = format_transform_yaml(R, t, name)
if self.gripper_tool:
extra = (
f'gripper_tool: {self.gripper_tool}\n'
+ format_matrix_yaml(self.T_flange_to_tool, 'T_flange_to_tool')
)
self.get_logger().info(
f'gripper={self.gripper_tool}:AX=XB 的手是 TCP;'
f'T_4x4 是 cam→基座,不折 t={t.reshape(3)*1000} mm'
)
elif self.gripper_tool:
T_cam_tool = np.eye(4)
T_cam_tool[:3, :3] = R
T_cam_tool[:3, 3] = t.reshape(3)
T_cam_flange = cam_to_flange_via_tool(T_cam_tool, self.T_flange_to_tool)
delta = float(np.linalg.norm(T_cam_flange[:3, 3] - T_cam_tool[:3, 3]))
tool_len = float(np.linalg.norm(self.T_flange_to_tool[:3, 3]))
if tool_len > 0.05 and delta < 1e-4:
self.get_logger().error(
'T_cam_to_flange 与 T_cam_to_tool 平移几乎相同,'
'折算没生效。结果作废。'
)
return 11
# 腕部相机在法兰前方(法兰 +Z)。乘反了 Z 会落到 −200 mm 一带。
if (
self.T_flange_to_tool[2, 3] > 0.05
and float(T_cam_flange[2, 3]) < 0.0
):
self.get_logger().error(
f'T_cam_to_flange tz={float(T_cam_flange[2, 3])*1000:.1f} mm '
'在法兰后方,乘序反了。结果作废。'
)
return 12
R, t = T_cam_flange[:3, :3], T_cam_flange[:3, 3].reshape(3, 1)
name = 'T_cam_to_flange (eye_in_hand,由工具 TCP 折算)'
yaml_txt = format_transform_yaml(R, t, name)
extra = (
f'gripper_tool: {self.gripper_tool}\n'
+ format_matrix_yaml(self.T_flange_to_tool, 'T_flange_to_tool')
+ format_matrix_yaml(T_cam_tool, 'T_cam_to_tool')
)
self.get_logger().info(
f'gripper={self.gripper_tool}:'
f'T_cam_to_tool t={T_cam_tool[:3, 3]*1000} mm → '
f'T_cam_to_flange t={T_cam_flange[:3, 3]*1000} mm'
)
else:
name = 'T_cam_to_gripper (eye_in_hand)'
yaml_txt = format_transform_yaml(R, t, name)
header = (
f'# 手眼标定结果\n'
f'# mode: {self.mode}\n'
f'# camera_source: {self.camera_source}\n'
f'# views: {len(Rg)}\n'
f'# utc: {datetime.now(timezone.utc).isoformat()}\n'
f'# board: {self.board.cols}x{self.board.rows} '
f'square_m={self.board.square_size}\n'
)
if self.gripper_tool and eye_to_hand:
header += (
f'# gripper_tool: {self.gripper_tool}\n'
f'# T_4x4 = cam→基座(AX=XB 的手是 TCP,不折到法兰)\n'
f'# 旧结果备份在 results/backup/\n'
)
elif self.gripper_tool:
header += (
f'# gripper_tool: {self.gripper_tool}\n'
f'# T_4x4 = cam→法兰(AX=XB 在 TCP 上解,再左乘 T_flange_to_tool)\n'
f'# T_cam_to_tool 是原始求解;旧的法兰/tool1 备份在 results/backup/\n'
)
self.result_file.parent.mkdir(parents=True, exist_ok=True)
self.result_file.write_text(header + extra + yaml_txt, encoding='utf-8')
(self.output_dir / 'captures.json').write_text(
json.dumps(meta, indent=2), encoding='utf-8'
)
self.get_logger().info('======== 手眼标定结果 ========')
for line in (header + yaml_txt).strip().splitlines():
self.get_logger().info(line)
self.get_logger().info(f'已保存 -> {self.result_file}')
if self.cleanup_images:
if self.images_dir.exists():
shutil.rmtree(self.images_dir, ignore_errors=True)
self.get_logger().info('标定图像已删除')
else:
self.get_logger().info(f'图像保留在 {self.images_dir}')
return 0
def _load_poses(self) -> List[List[float]]:
flat = list(self.get_parameter('waypoint_positions').value)
n_j = len(self.joint_names)
n_cfg = int(self.get_parameter('num_poses').value)
if n_cfg < 1:
return []
need = n_cfg * n_j
if len(flat) < need:
self.get_logger().warn(
f'waypoint_positions 长度 {len(flat)} < num_poses*{n_j}={need};'
'用零补齐'
)
flat = flat + [0.0] * (need - len(flat))
poses: List[List[float]] = []
for i in range(n_cfg):
q = [float(v) for v in flat[i * n_j : (i + 1) * n_j]]
if is_pose_filled(q):
poses.append(q)
else:
self.get_logger().warn(f'位姿槽 {i} 为空(全零)—— 已跳过')
return poses
def _resolve_intrinsics(self) -> bool:
# 1) CameraInfo
deadline = time.monotonic() + 3.0
while rclpy.ok() and self._latest_info is None and time.monotonic() < deadline:
rclpy.spin_once(self, timeout_sec=0.1)
if self._latest_info is not None:
self._intrinsics = _camera_info_to_intrinsics(self._latest_info)
K = self._intrinsics.K
self.get_logger().info(
f'内参来自 CameraInfo({self._info_topic}):'
f'fx={K[0, 0]:.2f} fy={K[1, 1]:.2f} cx={K[0, 2]:.2f} cy={K[1, 2]:.2f}'
)
return True
# 2) RealSense EEPROM
if is_realsense_source(self.camera_source):
try:
intr = query_realsense_color_intrinsics(
self.camera_source,
serial=self.camera_serial,
width=self.rs_width,
height=self.rs_height,
)
except Exception as exc: # noqa: BLE001
self.get_logger().error(f'RealSense 内参查询失败:{exc}')
else:
self._intrinsics = CameraIntrinsics(K=intr.as_K(), dist=intr.as_dist())
self.get_logger().info(
f'内参来自 RealSense {intr.model.upper()} '
f'serial={intr.serial} {intr.width}x{intr.height}:'
f'fx={intr.fx:.2f} fy={intr.fy:.2f} '
f'cx={intr.cx:.2f} cy={intr.cy:.2f}'
)
if (intr.width, intr.height) != (self.rs_width, self.rs_height):
self.get_logger().warn(
f'请求 {self.rs_width}x{self.rs_height},'
f'实际得到 {intr.width}x{intr.height}(最接近的 profile)'
)
return True
# 3) Manual YAML
fx = float(self.get_parameter('camera_fx').value)
fy = float(self.get_parameter('camera_fy').value)
cx = float(self.get_parameter('camera_cx').value)
cy = float(self.get_parameter('camera_cy').value)
if fx <= 0 or fy <= 0:
self.get_logger().error(
'没有内参。请设置 camera_source:=d405|d435,'
'或提供 camera_info_topic,或填写 camera_fx/fy/cx/cy。'
)
return False
dist = np.array(
list(self.get_parameter('camera_dist').value), dtype=float
).reshape(-1, 1)
K = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]], dtype=float)
self._intrinsics = CameraIntrinsics(K=K, dist=dist)
self.get_logger().info(
f'内参来自 YAML:fx={fx:.2f} fy={fy:.2f} cx={cx:.2f} cy={cy:.2f}'
)
return True
def _log_progress(self, i: int, total: int, suffix: str = '') -> None:
"""带进度条的日志。
自动标定是无人值守的长流程(十几个点位 × 移动+稳定+拍照),
单看 `[3/12]` 很难有"还要多久"的直觉,加一条可视化进度条。
"""
frac = (i + 1) / max(1, total)
width = 24
filled = int(round(frac * width))
bar = '█' * filled + '░' * (width - filled)
self.get_logger().info(
f'[{i + 1}/{total}] [{bar}] {frac * 100:5.1f}% {suffix}'
)
def _wait_settled(self) -> bool:
"""等机械臂真的停下来,再进入拍照。
判据是关节速度 |qd|_max 连续 ``settle_stable_sec`` 低于 ``settle_qd_max``。
为什么不用固定 sleep:`settle_sec` 是拍脑袋的时长,臂负载/姿态不同,
刹车距离差别很大 —— 等太短会在臂还在动时拍照(棋盘虚 → 角点抖动),
等太长则整轮标定被无谓拉长。直接看速度更准。
超时不算致命:记一条警告后继续,让后面的棋盘稳定性检查去把关。
"""
if self.settle_qd_max <= 0.0:
return True
deadline = time.monotonic() + self.settle_timeout
stable_since: Optional[float] = None
while rclpy.ok() and time.monotonic() < deadline:
rclpy.spin_once(self, timeout_sec=0.05)
js = self._latest_js
if js is None or not list(js.velocity):
continue
try:
vmax = float(np.max(np.abs(np.asarray(js.velocity, dtype=float))))
except Exception: # noqa: BLE001
continue
now = time.monotonic()
if vmax <= self.settle_qd_max:
if stable_since is None:
stable_since = now
elif now - stable_since >= self.settle_stable_sec:
return True
else:
stable_since = None # 又动了,重新计时
self.get_logger().warn(
f'wait_settled 超时 ({self.settle_timeout:.1f}s),臂可能仍在微动;'
'交给棋盘稳定性检查兜底'
)
return False
def _pin_hold(self) -> None:
"""静止后再保持几拍,抑制到位后的微小回弹/漂移。
语义取自 site.yaml 的注释"零速 pin 后再拍照":不是重新规划轨迹,
而是纯粹等一段时间让机械臂自己稳下来(控制器在到位后仍会做少量补偿)。
"""
total = self.pin_holds * self.pin_hold_sec
end = time.monotonic() + total
while rclpy.ok() and time.monotonic() < end:
rclpy.spin_once(self, timeout_sec=0.05)
def _wait_stable(
self,
) -> Tuple[bool, float, Optional[np.ndarray], Optional[np.ndarray]]:
self.stabilizer.reset()
deadline = time.monotonic() + self.stable_timeout
last_std = float('inf')
last_corners = None
last_pattern = None
last_bgr = None
last_log = 0.0
while rclpy.ok() and time.monotonic() < deadline:
rclpy.spin_once(self, timeout_sec=0.05)
if self._latest_image is None:
continue
try:
bgr = _image_to_bgr(self._latest_image)
except Exception as exc: # noqa: BLE001
self.get_logger().error(f'图像转换失败:{exc}')
return False, last_std, None, None, None
detected = detect_chessboard(bgr, self.board)
corners = detected[0] if detected is not None else None
pattern = detected[1] if detected is not None else None
ok, std = self.stabilizer.push(corners)
last_std = std
if corners is not None:
last_corners = corners
last_pattern = pattern
last_bgr = bgr
now = time.monotonic()
if now - last_log > 1.0:
seen = '有' if corners is not None else '无'
self.get_logger().info(
f' 棋盘={seen} 角点标准差='
f'{std if std < 1e8 else float("nan"):.3f}px'
)
last_log = now
if ok:
return True, std, last_corners, last_pattern, last_bgr
return False, last_std, last_corners, last_pattern, last_bgr
def _verify_joints(self, target: List[float], tol: float = 0.08) -> bool:
"""确认 ``joint_states`` 真的走到了目标位置。
用来识破"假的成功":有些执行接口会立刻返回成功,但关节其实没动
(或只动了一半)。这里在 2 秒内轮询关节角,逐轴比对容差 ``tol``(弧度),
只有全部落在容差内才算真到位。
"""
deadline = time.monotonic() + 2.0
while rclpy.ok() and time.monotonic() < deadline:
rclpy.spin_once(self, timeout_sec=0.05)
if self._latest_js is None:
continue
name_to_q = dict(zip(list(self._latest_js.name), list(self._latest_js.position)))
actual = []
for n in self.joint_names:
if n not in name_to_q:
break
actual.append(float(name_to_q[n]))
if len(actual) != len(target):
continue
err = float(np.sqrt(np.mean([(a - b) ** 2 for a, b in zip(actual, target)])))
self.get_logger().info(
f' 关节相对目标的 RMSE:{err:.4f} rad(tol={tol})'
)
return err <= tol
self.get_logger().error('校验运动时没有 joint_states')
return False
def _move_one(self, positions: List[float], duration: float) -> int:
goal = FollowJointTrajectory.Goal()
goal.joint_names = self.joint_names
goal.num_points = 1
goal.velocity_scale = self.velocity_scale
goal.positions = list(positions)
goal.time_from_start = [float(duration)]
send = self._client.send_goal_async(goal)
if not self._wait_future(send, 10.0, '发送目标'):
return 3
handle = send.result()
if handle is None or not handle.accepted:
self.get_logger().error('目标被拒绝')
return 4
res_fut = handle.get_result_async()
if not self._wait_future(res_fut, max(60.0, duration * 3.0), '结果'):
return 5
result = res_fut.result().result
if not result.success:
self.get_logger().error(f'移动失败:{result.message}')
return 6
return 0
def _wait_future(self, fut, timeout: float, label: str) -> bool:
deadline = time.monotonic() + timeout
while rclpy.ok() and not fut.done():
rclpy.spin_once(self, timeout_sec=0.05)
if time.monotonic() > deadline:
self.get_logger().error(f'等待 {label} 超时')
return False
return fut.done()
def _read_ee_pose(self) -> Optional[PoseStamped]:
if not self._get_state.wait_for_service(timeout_sec=2.0):
return None
fut = self._get_state.call_async(GetArmState.Request())
if not self._wait_future(fut, 2.0, 'get_arm_state'):
return None
res = fut.result()
if res is None or not res.success:
return None
return res.ee_pose
def _save_png(self, bgr: np.ndarray, path: Path) -> None:
import cv2
if not cv2.imwrite(str(path), bgr):
raise RuntimeError(f'cv2.imwrite 失败:{path}')
def main(args=None) -> None:
rclpy.init(args=args)
node = HandeyeCalibrate()
code = 1
try:
code = node.run()
except KeyboardInterrupt:
code = 0
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
raise SystemExit(code)
if __name__ == '__main__':
main()
@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""手眼标定辅助工具:棋盘稳定判定 + AX=XB 求解。
有 ``cv2.calibrateHandEye`` 时用它,否则用 Tsai-Lenz(NumPy 实现)。
棋盘检测始终依赖 OpenCV。
"""
from __future__ import annotations
import math
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Deque, List, Optional, Sequence, Tuple
import numpy as np
try:
import cv2
except Exception: # noqa: BLE001
cv2 = None # type: ignore
@dataclass
class BoardConfig:
cols: int # 内角点数(宽)
rows: int # 内角点数(高)
square_size: float # 米
@dataclass
class CameraIntrinsics:
K: np.ndarray
dist: np.ndarray
def R_to_quat_xyzw(R: np.ndarray) -> Tuple[float, float, float, float]:
"""3×3 旋转 → ``(x, y, z, w)`` 四元数(Shepperd 法,按最大分量选分支)。
为什么放在库里而不是各个 launch 里:这段换算**已经在两份 launch 里各抄了
一遍**,而且抄错过(对角分支的公式被写歪,症状是手眼 TF 差了 30°,
定位看起来"总能到附近"但永远差一点)。换算只有一份才可能被测试:
``test_handeye_lib_roundtrip.py`` 拿真实标定文件验证
``quat_xyzw_to_R(R_to_quat_xyzw(R)) == R``。
"""
R = np.asarray(R, dtype=float).reshape(3, 3)
tr = float(R[0, 0] + R[1, 1] + R[2, 2])
if tr > 0.0:
s = math.sqrt(tr + 1.0) * 2.0
w = 0.25 * s
x = (R[2, 1] - R[1, 2]) / s
y = (R[0, 2] - R[2, 0]) / s
z = (R[1, 0] - R[0, 1]) / s
elif R[0, 0] > R[1, 1] and R[0, 0] > R[2, 2]:
s = math.sqrt(1.0 + R[0, 0] - R[1, 1] - R[2, 2]) * 2.0
w = (R[2, 1] - R[1, 2]) / s
x = 0.25 * s
y = (R[0, 1] + R[1, 0]) / s
z = (R[0, 2] + R[2, 0]) / s
elif R[1, 1] > R[2, 2]:
s = math.sqrt(1.0 + R[1, 1] - R[0, 0] - R[2, 2]) * 2.0
w = (R[0, 2] - R[2, 0]) / s
x = (R[0, 1] + R[1, 0]) / s
y = 0.25 * s
z = (R[1, 2] + R[2, 1]) / s
else:
s = math.sqrt(1.0 + R[2, 2] - R[0, 0] - R[1, 1]) * 2.0
w = (R[1, 0] - R[0, 1]) / s
x = (R[0, 2] + R[2, 0]) / s
y = (R[1, 2] + R[2, 1]) / s
z = 0.25 * s
n = math.sqrt(x * x + y * y + z * z + w * w)
if n <= 0.0:
raise ValueError("旋转矩阵无法转成四元数(矩阵为零?)")
return x / n, y / n, z / n, w / n
def read_transform_yaml(path: str | Path) -> Tuple[np.ndarray, Tuple[float, float, float, float]]:
"""读手眼标定产物 → ``(xyz_m, quat_xyzw)``。
文件格式见 :func:`format_transform_yaml`:``T_4x4`` 是 4 行 4 列的列表。
读取时**必须校验形状**:手眼文件是现场人手拷来拷去的,形状不对时
直接报错,比"取到一堆 nan 然后发了个全零 TF"好得多(后者会让定位
永远差一个常量,而且看起来像相机标定不准)。
"""
import yaml # 局部导入:本模块其它函数不需要 yaml(标定计算本身不依赖)
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
T = data.get("T_4x4")
if T is None:
raise ValueError(f"{path}: 缺少 T_4x4 字段")
arr = np.asarray(T, dtype=float)
if arr.shape != (4, 4):
raise ValueError(f"{path}: T_4x4 应为 4x4,实际 {arr.shape}")
xyz = np.array([arr[0, 3], arr[1, 3], arr[2, 3]], dtype=float)
return xyz, R_to_quat_xyzw(arr[:3, :3])
def quat_xyzw_to_R(q: Sequence[float]) -> np.ndarray:
x, y, z, w = [float(v) for v in q]
n = np.sqrt(x * x + y * y + z * z + w * w) + 1e-12
x, y, z, w = x / n, y / n, z / n, w / n
return np.array(
[
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
],
dtype=float,
)
def pose_to_Rt(
xyz: Sequence[float], quat_xyzw: Sequence[float]
) -> Tuple[np.ndarray, np.ndarray]:
R = quat_xyzw_to_R(quat_xyzw)
t = np.array([[float(xyz[0])], [float(xyz[1])], [float(xyz[2])]], dtype=float)
return R, t
def Rt_to_T(R: np.ndarray, t: np.ndarray) -> np.ndarray:
T = np.eye(4)
T[:3, :3] = R
T[:3, 3] = t.reshape(3)
return T
def cam_to_base_T(
T_handeye: np.ndarray, T_ee: np.ndarray, mode: str
) -> np.ndarray:
"""相机系 → 基座系的 4×4。
* ``eye_in_hand``:``T_handeye`` 是 cam→法兰,再左乘 ``T_ee``(base→法兰)。
* ``eye_to_hand``:``T_handeye`` **已经是** cam→基座。再乘 ``T_ee`` 会把
固定相机错挂到法兰上 —— 定位总能到附近、永远差一截,看起来像标定不准。
"""
T_he = np.asarray(T_handeye, dtype=float).reshape(4, 4)
tag = str(mode or "").strip().lower()
if tag == "eye_to_hand":
return T_he
if tag != "eye_in_hand":
raise ValueError(
f"手眼模式只能是 eye_in_hand 或 eye_to_hand,收到 {mode!r}"
)
return np.asarray(T_ee, dtype=float).reshape(4, 4) @ T_he
def cam_point_to_base(
p_cam: np.ndarray,
T_handeye: np.ndarray,
T_ee: np.ndarray,
mode: str,
) -> np.ndarray:
"""工件三维点(相机系)→ 基座系 XYZ。"""
T_bc = cam_to_base_T(T_handeye, T_ee, mode)
xyz = np.asarray(p_cam, dtype=float).reshape(3)
ph = np.array([float(xyz[0]), float(xyz[1]), float(xyz[2]), 1.0], dtype=float)
return (T_bc @ ph)[:3]
def invert_Rt(R: np.ndarray, t: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
Ri = R.T
ti = -Ri @ t.reshape(3, 1)
return Ri, ti
def board_object_points(cfg: BoardConfig) -> np.ndarray:
obj = np.zeros((cfg.rows * cfg.cols, 3), np.float32)
xs, ys = np.meshgrid(np.arange(cfg.cols), np.arange(cfg.rows))
obj[:, 0] = xs.reshape(-1) * cfg.square_size
obj[:, 1] = ys.reshape(-1) * cfg.square_size
return obj
def detect_chessboard(
image_bgr: np.ndarray, cfg: BoardConfig
) -> Optional[Tuple[np.ndarray, Tuple[int, int]]]:
"""返回 (角点, pattern_size) 或 None。pattern 为实际使用的 (cols, rows)。"""
if cv2 is None:
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
ok, corners = cv2.findChessboardCorners(gray, pattern, flags)
used = pattern
if not ok:
ok, corners = cv2.findChessboardCorners(
gray, (pattern[1], pattern[0]), flags
)
used = (pattern[1], pattern[0])
if not ok or corners is None:
return None
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 40, 1e-3)
corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
return np.asarray(corners, dtype=np.float32), used
def solve_board_pose(
corners: np.ndarray,
cfg: BoardConfig,
cam: CameraIntrinsics,
pattern: Optional[Tuple[int, int]] = None,
) -> Tuple[np.ndarray, np.ndarray]:
if cv2 is None:
raise RuntimeError('solvePnP 需要 OpenCV(cv2)')
if pattern is None:
pattern = (cfg.cols, cfg.rows)
board = BoardConfig(pattern[0], pattern[1], cfg.square_size)
obj = board_object_points(board)
ok, rvec, tvec = cv2.solvePnP(
obj, corners, cam.K, cam.dist, flags=cv2.SOLVEPNP_ITERATIVE
)
if not ok:
raise RuntimeError('solvePnP 失败')
R, _ = cv2.Rodrigues(rvec)
return R.astype(float), np.asarray(tvec, dtype=float).reshape(3, 1)
class ChessboardStabilizer:
def __init__(self, window: int = 8, max_pixel_std: float = 0.45) -> None:
self.window = int(window)
self.max_pixel_std = float(max_pixel_std)
self._buf: Deque[np.ndarray] = deque(maxlen=self.window)
def reset(self) -> None:
self._buf.clear()
def push(self, corners: Optional[np.ndarray]) -> Tuple[bool, float]:
if corners is None:
self._buf.clear()
return False, float('inf')
self._buf.append(corners.reshape(-1, 2).astype(float))
if len(self._buf) < self.window:
return False, float('inf')
stack = np.stack(list(self._buf), axis=0)
std = float(np.mean(np.std(stack, axis=0)))
return std <= self.max_pixel_std, std
def _skew(v: np.ndarray) -> np.ndarray:
x, y, z = v.reshape(3)
return np.array([[0.0, -z, y], [z, 0.0, -x], [-y, x, 0.0]])
def _mat_log(R: np.ndarray) -> np.ndarray:
c = float(np.clip((np.trace(R) - 1.0) * 0.5, -1.0, 1.0))
theta = float(np.arccos(c))
if theta < 1e-8:
return np.zeros(3)
w = np.array(
[R[2, 1] - R[1, 2], R[0, 2] - R[2, 0], R[1, 0] - R[0, 1]], dtype=float
)
return w * (theta / (2.0 * np.sin(theta)))
def _rodrigues(rvec: np.ndarray) -> np.ndarray:
r = rvec.reshape(3)
theta = float(np.linalg.norm(r))
if theta < 1e-12:
return np.eye(3)
k = r / theta
K = _skew(k)
return np.eye(3) + np.sin(theta) * K + (1.0 - np.cos(theta)) * (K @ K)
def calibrate_hand_eye_tsai(
R_gripper2base: List[np.ndarray],
t_gripper2base: List[np.ndarray],
R_target2cam: List[np.ndarray],
t_target2cam: List[np.ndarray],
) -> Tuple[np.ndarray, np.ndarray]:
"""Tsai-Lenz 方法。返回 R_cam2gripper、t_cam2gripper。"""
n = len(R_gripper2base)
if n < 3:
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)]
A_rows: List[np.ndarray] = []
b_rows: List[np.ndarray] = []
for i in range(n - 1):
A = np.linalg.inv(Hg[i + 1]) @ Hg[i]
B = Hc[i + 1] @ np.linalg.inv(Hc[i])
ra = _mat_log(A[:3, :3])
rb = _mat_log(B[:3, :3])
A_rows.append(_skew(ra + rb))
b_rows.append(rb - ra)
A_mat = np.vstack(A_rows)
b_vec = np.concatenate(b_rows)
p, _, _, _ = np.linalg.lstsq(A_mat, b_vec, rcond=None)
denom = np.sqrt(1.0 + float(p @ p))
P = 2.0 * p / denom
Rx = _rodrigues(P)
C_rows: List[np.ndarray] = []
d_rows: List[np.ndarray] = []
for i in range(n - 1):
A = np.linalg.inv(Hg[i + 1]) @ Hg[i]
B = Hc[i + 1] @ np.linalg.inv(Hc[i])
Ra, ta = A[:3, :3], A[:3, 3:4]
_Rb, tb = B[:3, :3], B[:3, 3:4]
C_rows.append(Ra - np.eye(3))
d_rows.append(Rx @ tb - ta)
C = np.vstack(C_rows)
d = np.vstack(d_rows)
tx, _, _, _ = np.linalg.lstsq(C, d, rcond=None)
return Rx.astype(float), tx.reshape(3, 1).astype(float)
def calibrate_hand_eye(
R_gripper2base: List[np.ndarray],
t_gripper2base: List[np.ndarray],
R_target2cam: List[np.ndarray],
t_target2cam: List[np.ndarray],
*,
eye_to_hand: bool = False,
) -> Tuple[np.ndarray, np.ndarray]:
"""
eye_in_hand -> X = cam -> gripper(相机装在法兰上)
eye_to_hand -> X = cam -> base(相机固定安装)
"""
Rg = list(R_gripper2base)
tg = list(t_gripper2base)
if eye_to_hand:
Rg = []
tg = []
for R, t in zip(R_gripper2base, t_gripper2base):
Ri, ti = invert_Rt(R, t)
Rg.append(Ri)
tg.append(ti)
if cv2 is not None and hasattr(cv2, 'calibrateHandEye'):
try:
R, t = cv2.calibrateHandEye(
Rg, tg, R_target2cam, t_target2cam, method=cv2.CALIB_HAND_EYE_TSAI
)
return np.asarray(R, float), np.asarray(t, float).reshape(3, 1)
except Exception:
pass # 退回 NumPy 版 Tsai
return calibrate_hand_eye_tsai(Rg, tg, R_target2cam, t_target2cam)
def T_from_flexiv_tcp(xyz_m, qwxyz) -> np.ndarray:
"""Studio ``tcp_location`` → 法兰→工具 4×4。
Flexiv 顺序是 ``[x, y, z, q_w, q_x, q_y, q_z]``,四元数是 wxyz,
和本仓库其余地方的 xyzw 不一样。只在这里换一次。
"""
xyz = np.asarray(xyz_m, dtype=float).reshape(3)
q = np.asarray(qwxyz, dtype=float).reshape(7) if np.asarray(qwxyz).size == 7 else np.asarray(qwxyz, dtype=float).reshape(4)
if q.size == 7:
qw, qx, qy, qz = (float(q[3]), float(q[4]), float(q[5]), float(q[6]))
else:
qw, qx, qy, qz = (float(q[0]), float(q[1]), float(q[2]), float(q[3]))
R = quat_xyzw_to_R((qx, qy, qz, qw))
return Rt_to_T(R, xyz.reshape(3, 1))
def cam_to_flange_via_tool(T_cam_to_tool: np.ndarray, T_flange_to_tool: np.ndarray) -> np.ndarray:
"""AX=XB 在工具 TCP 上解出 cam→工具后,再折成 cam→法兰。
``T_flange_to_tool`` 是 Studio TCP:``p_法兰 = T_flange_to_tool · p_工具``。
OpenCV 的 ``T_cam_to_tool`` 是 ``p_工具 = T_cam_to_tool · p_相机``。
所以 ``T_cam_to_flange = T_flange_to_tool · T_cam_to_tool``。
乘反了(``T_cam_to_tool · inv(T_ft)``)旋转看着还像,平移会把相机
扔到法兰后方约 30 cm。忘了乘则 ``T_4x4`` 停在 TCP 上,沿工具偏 ~225 mm。
两种都不报错。
"""
T_ct = np.asarray(T_cam_to_tool, dtype=float).reshape(4, 4)
T_ft = np.asarray(T_flange_to_tool, dtype=float).reshape(4, 4)
return T_ft @ T_ct
def format_transform_yaml(R: np.ndarray, t: np.ndarray, name: str) -> str:
T = Rt_to_T(R, t)
lines = [
f'# {name}',
f'translation_m: [{t[0, 0]:.8f}, {t[1, 0]:.8f}, {t[2, 0]:.8f}]',
'T_4x4:',
]
for row in T:
lines.append(' - [' + ', '.join(f'{v:.8f}' for v in row) + ']')
return '\n'.join(lines) + '\n'
def format_matrix_yaml(T: np.ndarray, key: str) -> str:
arr = np.asarray(T, dtype=float).reshape(4, 4)
lines = [f'{key}:']
for row in arr:
lines.append(' - [' + ', '.join(f'{v:.8f}' for v in row) + ']')
return '\n'.join(lines) + '\n'
def is_pose_filled(joints: Sequence[float]) -> bool:
"""若全为零 / NaN 占位则返回 False。"""
arr = np.asarray(joints, dtype=float)
if arr.size != 7:
return False
if np.any(np.isnan(arr)):
return False
return float(np.linalg.norm(arr)) > 1e-6
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""通过 pyrealsense2 查询 RealSense(D405 / D435)的出厂彩色内参。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Tuple
import numpy as np
@dataclass
class RealsenseIntrinsics:
model: str
serial: str
width: int
height: int
fx: float
fy: float
cx: float
cy: float
dist: List[float] # OpenCV 顺序,长度 5
def as_K(self) -> np.ndarray:
return np.array(
[[self.fx, 0.0, self.cx], [0.0, self.fy, self.cy], [0.0, 0.0, 1.0]],
dtype=float,
)
def as_dist(self) -> np.ndarray:
d = list(self.dist) + [0.0] * 5
return np.array(d[:5], dtype=float).reshape(-1, 1)
# camera_source 为 d405/d435 时的默认 ROS 话题命名空间。
REALSENSE_TOPIC_DEFAULTS = {
'd405': {
'image_topic': '/d405/color/image_raw',
'camera_info_topic': '/d405/color/camera_info',
},
'd435': {
'image_topic': '/d435/color/image_raw',
'camera_info_topic': '/d435/color/camera_info',
},
}
def _norm_model(model: str) -> str:
m = model.strip().lower().replace(' ', '').replace('-', '')
if m.startswith('d405') or m in ('realsensed405', 'intelrealsensed405'):
return 'd405'
if m.startswith('d435') or m in ('realsensed435', 'intelrealsensed435'):
return 'd435'
return m
def list_realsense_devices() -> List[Tuple[str, str]]:
"""返回 [(name, serial), ...]。"""
try:
import pyrealsense2 as rs
except Exception as exc: # noqa: BLE001
raise RuntimeError(
'未安装 pyrealsense2。'
'请在 ros_flexiv 环境中安装:pip/conda install pyrealsense2'
) from exc
out: List[Tuple[str, str]] = []
for d in rs.context().devices:
name = d.get_info(rs.camera_info.name)
serial = d.get_info(rs.camera_info.serial_number)
out.append((name, serial))
return out
def query_realsense_color_intrinsics(
model: str,
*,
serial: str = '',
width: int = 1280,
height: int = 720,
) -> RealsenseIntrinsics:
"""
在给定分辨率下读取 D405/D435 设备上的彩色内参。
不会启动流管道(只读取 profile 元数据)。
"""
try:
import pyrealsense2 as rs
except Exception as exc: # noqa: BLE001
raise RuntimeError(
'未安装 pyrealsense2。'
'请在 ros_flexiv 环境中安装:pip/conda install pyrealsense2'
) from exc
want = _norm_model(model)
if want not in ('d405', '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('未连接任何 RealSense 设备')
chosen = None
for d in devices:
name = d.get_info(rs.camera_info.name)
sn = d.get_info(rs.camera_info.serial_number)
if serial and sn != serial:
continue
name_u = name.upper().replace(' ', '')
if hint in name_u or hint in name.upper():
chosen = d
break
if chosen is None:
avail = ', '.join(
f'{d.get_info(rs.camera_info.name)}'
f'({d.get_info(rs.camera_info.serial_number)})'
for d in devices
)
raise RuntimeError(
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 # (评分, width, height, fx, fy, cx, cy, 畸变系数)
for s in chosen.sensors:
for p in s.profiles:
if p.stream_type() != rs.stream.color:
continue
vp = p.as_video_stream_profile()
try:
intr = vp.get_intrinsics()
except Exception: # noqa: BLE001
continue
w, h = int(intr.width), int(intr.height)
if w == int(width) and h == int(height):
score = 0
else:
score = abs(w * h - int(width) * int(height)) + 1
cand = (
score,
w,
h,
float(intr.fx),
float(intr.fy),
float(intr.ppx),
float(intr.ppy),
[float(c) for c in list(intr.coeffs)[:5]],
)
if best is None or cand[0] < best[0]:
best = cand
if best is None:
raise RuntimeError(f'{name} ({sn}) 上没有彩色流 profile')
_, w, h, fx, fy, cx, cy, coeffs = best
while len(coeffs) < 5:
coeffs.append(0.0)
return RealsenseIntrinsics(
model=want,
serial=sn,
width=w,
height=h,
fx=fx,
fy=fy,
cx=cx,
cy=cy,
dist=coeffs[:5],
)
def is_realsense_source(name: str) -> bool:
"""对 d405 / d435 及带命名空间的别名(d405_left、d405_right)返回 True。"""
return _norm_model(name or "") in ("d405", "d435")
def resolve_realsense_topics(
model: str,
*,
camera_namespace: str = '',
image_topic: str = '',
camera_info_topic: str = '',
) -> Tuple[str, str]:
"""为空时,为 d405/d435 填充默认的 image/camera_info 话题。
``model`` 可以是 ``d405_left`` / ``d405_right`` 这类话题命名空间。
"""
ns = (camera_namespace or model or 'd405').strip().strip('/')
img = (image_topic or '').strip()
info = (camera_info_topic or '').strip()
if not img and ns:
img = f'/{ns}/color/image_raw'
if not info and ns:
info = f'/{ns}/color/camera_info'
return img, info
def apply_realsense_exposure(
device,
*,
auto_exposure: bool = True,
ae_limit_us: float = 3000.0,
emitter_enabled: bool = False,
laser_power: float = 0.0,
exposure_us: float | None = None,
gain: float | None = None,
log=None,
) -> list[str]:
"""限制自动曝光 / 关闭投影器。腕部 D405 过曝通常是自动曝光过长 + 发射器导致。"""
import pyrealsense2 as rs
notes: list[str] = []
def _set(sensor, opt, val) -> bool:
try:
if sensor.supports(opt):
sensor.set_option(opt, float(val))
return True
except Exception:
return False
return False
for sensor in device.query_sensors():
if _set(sensor, rs.option.emitter_enabled, 1.0 if emitter_enabled else 0.0):
notes.append(f"emitter={int(bool(emitter_enabled))}")
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))
# 必须在写曝光*之前*关闭自动曝光;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)
# priority 0 = 保帧压暗;无上限时不要设,让自动曝光自己变亮
if pri is not None and ae_limit_us and auto_exposure:
_set(sensor, pri, 0.0)
limit = getattr(rs.option, "auto_exposure_limit", None)
if auto_exposure and limit is not None and ae_limit_us and ae_limit_us > 0:
if _set(sensor, limit, float(ae_limit_us)):
notes.append(f"ae_limit={int(ae_limit_us)}us")
if not auto_exposure:
if exposure_us is not None and _set(sensor, rs.option.exposure, float(exposure_us)):
notes.append(f"exposure={int(exposure_us)}us")
if gain is not None and _set(sensor, rs.option.gain, float(gain)):
notes.append(f"gain={gain}")
if log and notes:
log("RealSense 曝光设置:" + ", ".join(dict.fromkeys(notes)))
return notes
@@ -0,0 +1,679 @@
#!/usr/bin/env python3
"""手眼示教:实时预览 + Free Drive + 按 Enter 拍照/记录关节。
两种后端
------------
ros(默认,整套栈正在运行):
- 关节来自 ``/get_arm_state``
- 拖拽:示教器 Free Drive + 按住使能键
需保持:bash ~/ros2/scripts/run_real.sh
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
界面按键(预览窗口)
Enter / Space 拍照 + 记录关节
d 启用 Free Drive(rdk)/ 显示示教器提示(ros)
s 停止 Free Drive(rdk)/ 保持
q / Esc 结束
"""
from __future__ import annotations
import os
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
import numpy as np
import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node
from arm_application.ros_node import TolerantNode, param_as_str
HELP = """
================================================
手眼示教 TEACH:预览 -> 拖拽 -> 拍照+位姿
================================================
窗口按键:
Enter / Space = 拍照 + 保存关节
d = 开启 Free Drive
s = 关闭 Free Drive / 保持
q / Esc = 结束
提示:让棋盘清晰,并占到画面的约 30-70%
建议采 8-12 个角度有变化的位姿
================================================
"""
def _setup_matplotlib_font():
"""避免中文乱码:优先选用存在的中日韩(CJK)字体。"""
import matplotlib
matplotlib.use('QtAgg')
from matplotlib import font_manager, rcParams
candidates = [
'Noto Sans CJK SC',
'Noto Sans CJK JP',
'Noto Serif CJK SC',
'AR PL UMing CN',
'AR PL UKai CN',
'WenQuanYi Micro Hei',
'Source Han Sans CN',
'DejaVu Sans',
]
available = {f.name for f in font_manager.fontManager.ttflist}
chosen = None
for name in candidates:
if name in available:
chosen = name
break
if chosen is None:
# 按文件路径再试一次
for path in (
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc',
'/usr/share/fonts/opentype/noto/NotoSansCJK-DemiLight.ttc',
'/usr/share/fonts/truetype/arphic/uming.ttc',
):
if os.path.isfile(path):
font_manager.fontManager.addfont(path)
prop = font_manager.FontProperties(fname=path)
chosen = prop.get_name()
break
if chosen:
rcParams['font.family'] = chosen
rcParams['axes.unicode_minus'] = False
return chosen
class RdkBridge:
"""通过 rdk_py310 子进程(Python 3.10 ABI)与 Flexiv RDK 通信。"""
def __init__(self, robot_sn: str, logger=None) -> None:
import json
import subprocess
self._json = json
self._logger = logger
self.robot_sn = robot_sn
self._last_q: Optional[List[float]] = None
self._io = threading.Lock()
from arm_application.site_io import ros2_root
py = os.environ.get('RDK_PYTHON', '').strip() or str(
Path.home() / 'rdk_py310' / 'bin' / 'python'
)
bridge = ros2_root() / 'scripts' / 'rdk_drag_bridge.py'
if not bridge.is_file():
raise FileNotFoundError(f'找不到 rdk_drag_bridge.py:{bridge}')
# 只保留 Flexiv 的库 —— 不要继承 conda 的 LD_LIBRARY_PATH(会让 RDK 卡死)。
flexiv_lib = os.path.expanduser('~/flexiv_install/lib')
env = {
'HOME': os.environ.get('HOME', ''),
'PATH': f'{Path(py).parent}:/usr/bin:/bin',
'LD_LIBRARY_PATH': flexiv_lib,
'LANG': 'C.UTF-8',
'SITE_PC_IP': os.environ.get('SITE_PC_IP', ''),
'SITE_NET_IFACE': os.environ.get('SITE_NET_IFACE', ''),
}
# 不要继承 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(
[py, str(bridge)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=self._err_file,
text=True,
bufsize=1,
env=env,
)
if logger:
logger.info(f'RDK 桥接已启动;日志 → {log_path}')
pong = self._ask({'cmd': 'ping'}, timeout=8.0)
if not pong.get('ok'):
raise RuntimeError(
f'RDK 桥接 ping 失败:{pong}。参见 {log_path}'
)
if logger:
logger.info('RDK 桥接 ping 正常,正在连接机器人...')
conn = self._ask(
{
'cmd': 'connect',
'sn': robot_sn,
'iface': os.environ.get('SITE_NET_IFACE', ''),
},
timeout=45.0,
)
if not conn.get('ok'):
raise RuntimeError(
f'RDK 连接失败:{conn.get("error")}。'
f'请先停掉 run_real.sh;切 AUTO Remote 并清除故障。参见 {log_path}'
)
if logger:
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]]
def _ask(self, msg: dict, timeout: float = 10.0) -> dict:
import select
# 同一时刻只允许一条在途命令:相机/GUI 绝不能共用这个管道。
if not self._io.acquire(timeout=max(timeout, 0.2)):
return {'ok': False, 'error': '桥接忙'}
try:
assert self._proc.stdin and self._proc.stdout
self._proc.stdin.write(self._json.dumps(msg) + '\n')
self._proc.stdin.flush()
end = time.monotonic() + timeout
while time.monotonic() < end:
if self._proc.poll() is not None:
tip = '/tmp/rdk_drag_bridge.log'
return {
'ok': False,
'error': f'桥接已退出 rc={self._proc.returncode};参见 {tip}',
}
ready, _, _ = select.select([self._proc.stdout], [], [], 0.05)
if not ready:
continue
line = self._proc.stdout.readline()
if not line:
continue
line = line.strip()
if not line.startswith('{'):
continue
try:
return self._json.loads(line)
except Exception as exc: # noqa: BLE001
return {'ok': False, 'error': f'应答异常:{exc} {line!r}'}
return {'ok': False, 'error': '桥接超时'}
finally:
self._io.release()
def drag_on(self, primitive: str = 'FloatingJoint', mode: str = 'soft') -> dict:
return self._ask(
{'cmd': 'drag_on', 'primitive': primitive, 'mode': mode},
timeout=20.0,
)
def drag_off(self) -> dict:
return self._ask({'cmd': 'drag_off'}, timeout=6.0)
def joints(self) -> Optional[List[float]]:
res = self._ask({'cmd': 'joints'}, timeout=2.0)
if res.get('ok'):
q = res.get('q') or []
if len(q) >= 7:
self._last_q = [float(v) for v in q[:7]]
return self._last_q
return self._last_q
def move_joints(self, joints: List[float], speed: float = 0.15) -> dict:
"""按关节阻抗把臂缓慢送到已记录的 7 个关节角。speed 单位 rad/s。"""
return self._ask(
{'cmd': 'move_joints', 'q': [float(v) for v in joints[:7]], 'speed': float(speed)},
timeout=180.0,
)
def close(self) -> None:
try:
self._ask({'cmd': 'quit'}, timeout=3.0)
except Exception:
pass
try:
self._proc.terminate()
except Exception:
pass
try:
if getattr(self, '_err_file', None) is not None:
self._err_file.close()
except Exception:
pass
class HandeyeTeach(TolerantNode):
def __init__(self) -> None:
super().__init__('handeye_teach')
from arm_application.site_io import ros2_root
self.declare_parameter('output_dir', str(ros2_root() / 'handeye' / 'tmp'))
self.declare_parameter('width', 1280)
self.declare_parameter('height', 720)
self.declare_parameter('fps', 15)
self.declare_parameter('backend', 'ros') # ros | rdk
self.declare_parameter('robot_sn', os.environ.get('SITE_ROBOT_SN', '').strip())
self.declare_parameter('drag_primitive', 'FloatingJoint') # or FloatingCartesian
self._serial = param_as_str(self, 'camera_serial', '')
self.declare_parameter('auto_exposure', True)
self.declare_parameter('ae_limit_us', 1800.0)
self.declare_parameter('emitter_enabled', False)
self.declare_parameter('exposure_us', 0.0)
self.declare_parameter('gain', 0.0)
# 目标采集点数。0 = 不限(按 q 结束)。
# 设了就只是个"提醒":采够时提示可以收工,不强制关窗 ——
# 常常采到一半发现某个角度还没覆盖,强制退出反而要重开。
self.declare_parameter('target_points', 0)
# 必须和 run_handeye.sh 的 TAUGHT 是同一个文件。只写 taught_poses.txt、
# 脚本去读 taught_poses_<模式>.txt,merge 就会「文件在、内容空」。
self.declare_parameter('poses_file', '')
self.backend = str(self.get_parameter('backend').value).strip().lower()
self.robot_sn = str(self.get_parameter('robot_sn').value)
self.drag_primitive = str(self.get_parameter('drag_primitive').value).strip()
self.output_dir = Path(str(self.get_parameter('output_dir').value)).expanduser()
self.images_dir = self.output_dir / 'images'
_pf = str(self.get_parameter('poses_file').value).strip()
self.poses_file = (
Path(_pf).expanduser() if _pf else self.output_dir / 'taught_poses.txt'
)
self.yaml_file = self.output_dir / 'taught_waypoint_positions.yaml'
self._last_capture_t = 0.0
self.images_dir.mkdir(parents=True, exist_ok=True)
self._captures: List[dict] = []
self._latest_bgr: Optional[np.ndarray] = None
self._frame_lock = threading.Lock()
self._quit = False
self._busy = False
self._cmd_pending = False
self._drag_on = False
self._robot = None
self._rdk = None
self._get_state = None
self._cam_thread: Optional[threading.Thread] = None
self._pipe = None
self._fps = 15
if self.backend == 'rdk':
self._init_rdk()
else:
self._init_ros_state()
self._start_camera()
font = _setup_matplotlib_font()
self._start_gui()
print(HELP)
if self.backend == 'rdk':
print('[backend=rdk] 按 d = 软拖拽(在 AUTO Remote 下可用,无需 Floating 许可)')
print(' run_real.sh 必须已停止。')
print(' 拖着就能按 Enter 拍照,不必先按 s')
else:
print('[backend=ros] 用示教器 Free Drive + 按住使能键来拖拽。')
print(' 或停掉 run_real.sh 后改用:-p backend:=rdk')
if font:
self.get_logger().info(f'界面字体:{font}')
self.create_timer(1.0 / max(1, self._fps), self._tick)
def _init_ros_state(self) -> None:
from arm_msgs.srv import GetArmState
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 不可用。请启动 run_real.sh,'
'或改用:-p backend:=rdk(需先停掉整套栈)'
)
def _init_rdk(self) -> None:
self.get_logger().info(f'正在把 RDK 桥接连到 {self.robot_sn} ...')
self._rdk = RdkBridge(self.robot_sn, logger=self.get_logger())
self._robot = True # 标记:RDK 后端已就绪
self.get_logger().info('RDK 已连接。按 d 启用 Free Drive。')
def _start_camera(self) -> None:
import pyrealsense2 as rs
w = int(self.get_parameter('width').value)
h = int(self.get_parameter('height').value)
fps = int(self.get_parameter('fps').value)
pipe = rs.pipeline()
profile = None
last_err = None
for ww, hh, ff in [
(w, h, fps), (w, h, 15), (w, h, 10),
(640, 480, 30), (640, 480, 15),
]:
try:
cfg = rs.config()
if self._serial:
cfg.enable_device(self._serial)
cfg.enable_stream(rs.stream.color, ww, hh, rs.format.bgr8, ff)
profile = pipe.start(cfg)
self._fps = ff
self.get_logger().info(f'相机 {ww}x{hh}@{ff}Hz')
break
except Exception as exc: # noqa: BLE001
last_err = exc
try:
pipe.stop()
except Exception: # noqa: BLE001
pass
pipe = rs.pipeline()
if profile is None:
raise RuntimeError(
f'无法打开 D405 —— 请先停掉 realsense_color_pub。({last_err})'
)
self._pipe = pipe
try:
from arm_application.handeye_realsense import apply_realsense_exposure
apply_realsense_exposure(
profile.get_device(),
auto_exposure=bool(self.get_parameter('auto_exposure').value),
ae_limit_us=float(self.get_parameter('ae_limit_us').value),
emitter_enabled=bool(self.get_parameter('emitter_enabled').value),
exposure_us=(
float(self.get_parameter('exposure_us').value) or None
),
gain=(float(self.get_parameter('gain').value) or None),
log=self.get_logger().info,
)
except Exception as exc: # noqa: BLE001
self.get_logger().warn(f'曝光设置应用失败:{exc}')
self._cam_thread = threading.Thread(target=self._cam_loop, name='d405', daemon=True)
self._cam_thread.start()
def _cam_loop(self) -> None:
while not self._quit:
try:
frames = self._pipe.wait_for_frames(timeout_ms=400)
color = frames.get_color_frame()
except Exception: # noqa: BLE001
time.sleep(0.02)
continue
if not color:
continue
bgr = np.asanyarray(color.get_data()).copy()
with self._frame_lock:
self._latest_bgr = bgr
def _start_gui(self) -> None:
import matplotlib.pyplot as plt
self._plt = plt
self._fig, self._ax = plt.subplots(1, 1, figsize=(11, 7))
self._fig.canvas.manager.set_window_title(
'handeye_teach | Enter=拍照 d=拖拽 s=停止 q=退出'
)
self._im = None
self._status = self._ax.text(
0.02,
0.98,
self._status_text(),
transform=self._ax.transAxes,
color='lime',
fontsize=13,
va='top',
fontweight='bold',
bbox=dict(boxstyle='round', facecolor='black', alpha=0.6),
)
self._ax.axis('off')
self._fig.canvas.mpl_connect('key_press_event', self._on_key)
plt.ion()
plt.show(block=False)
def _status_text(self) -> str:
drag = '拖拽开启' if self._drag_on else '拖拽关闭'
if self.backend == 'rdk':
tip = 'd=软拖拽 s=保持'
else:
tip = '示教器 FreeDrive+使能'
return (
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('收到退出请求')
return
if self._cmd_pending:
return
if key in ('enter', ' ', 'space', 'd', 's'):
self._cmd_pending = True
threading.Thread(target=self._run_key, args=(key,), daemon=True).start()
def _run_key(self, key: str) -> None:
try:
if key in ('enter', ' ', 'space'):
self._capture_once()
elif key == 'd':
self._enable_drag()
elif key == 's':
self._disable_drag()
except Exception as exc: # noqa: BLE001
self.get_logger().error(f'按键 {key}:{exc}')
finally:
self._cmd_pending = False
def _enable_drag(self) -> None:
if self.backend != 'rdk':
self._drag_on = True
self.get_logger().warn(
'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('正在启动软拖拽(预览保持实时刷新)...')
# 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'拖拽已开启 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'拖拽失败:{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'停止拖拽:{res.get("error")}')
self._drag_on = False
self.get_logger().info('Free Drive 已关闭 / 保持')
def _tick(self) -> None:
if self._quit:
return
with self._frame_lock:
bgr = None if self._latest_bgr is None else self._latest_bgr.copy()
if bgr is None:
try:
self._fig.canvas.flush_events()
except Exception: # noqa: BLE001
pass
return
rgb = bgr[:, :, ::-1]
if self._im is None:
self._im = self._ax.imshow(rgb)
else:
self._im.set_data(rgb)
try:
self._status.set_text(self._status_text())
self._fig.canvas.draw_idle()
self._fig.canvas.flush_events()
except Exception: # noqa: BLE001
pass
def _read_joints(self) -> Optional[List[float]]:
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 关节失败')
return q
from arm_msgs.srv import GetArmState
fut = self._get_state.call_async(GetArmState.Request())
end = time.monotonic() + 2.0
while rclpy.ok() and not fut.done() and time.monotonic() < end:
rclpy.spin_once(self, timeout_sec=0.02)
res = fut.result() if fut.done() else None
if res is None or not res.success:
return None
q = list(res.joint_positions)
return [float(v) for v in q[:7]] if len(q) >= 7 else None
def _capture_once(self) -> None:
if self._busy:
return
self._busy = True
try:
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('还没有相机帧')
return
q = self._read_joints()
if q is None:
self.get_logger().error('读取关节失败')
return
now = time.monotonic()
if now - self._last_capture_t < 0.8:
self.get_logger().warn('按太快,忽略(防 Enter 连发)')
return
if self._captures:
last = self._captures[-1]['joints']
delta = max(abs(a - b) for a, b in zip(q, last))
if delta < 0.015:
self.get_logger().warn(
f'关节几乎没动(Δ={delta:.4f} rad),忽略这次拍照。'
'先拖到另一个姿态再按 Enter。'
)
return
self._last_capture_t = now
idx = len(self._captures)
stamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
img_path = self.images_dir / f'teach_{idx:02d}_{stamp}.png'
try:
import cv2
cv2.imwrite(str(img_path), bgr)
except Exception: # noqa: BLE001
from PIL import Image as PILImage
PILImage.fromarray(bgr[:, :, ::-1]).save(str(img_path))
line = ', '.join(f'{v:.6f}' for v in q) + ','
self._captures.append(
{'index': idx, 'joints': q, 'image': str(img_path), 'line': line}
)
with self.poses_file.open('a', encoding='utf-8') as f:
f.write(f'# pose {idx} {stamp}\n')
f.write(line + '\n')
f.write(f'# image {img_path}\n')
print(f'\n# pose {idx} {img_path.name}')
print(line)
n = idx + 1
try:
target = int(self.get_parameter('target_points').value)
except Exception: # noqa: BLE001
target = 0
if target > 0:
if n < target:
self.get_logger().info(
f'[{n}/{target}] 已保存照片+关节 —— 还差 {target - n} 个点'
)
elif n == target:
self.get_logger().info(
f'[{n}/{target}] 已采够 {target} 个点。'
' 建议按 q 结束,然后跑 merge + calibrate;'
' 想多采几个点也可以继续按 Enter。'
)
else:
self.get_logger().info(
f'[{n}/{target}] 已保存照片+关节 —— 已超出目标 '
f'{n - target} 个点(会用全部点求解)'
)
else:
self.get_logger().info(f'[{n}] 已保存照片+关节')
self._write_yaml_snippet()
finally:
self._busy = False
def _write_yaml_snippet(self) -> None:
lines = [
'# 粘贴到 handeye_calibrate.yaml -> waypoint_positions',
f'# num_poses: {len(self._captures)}',
'waypoint_positions: [',
]
for c in self._captures:
lines.append(f' # pose {c["index"]}')
lines.append(f' {c["line"]}')
lines.append(']')
self.yaml_file.write_text('\n'.join(lines) + '\n', encoding='utf-8')
def finish(self) -> None:
self._quit = True
if self._cam_thread is not None:
self._cam_thread.join(timeout=1.5)
if self._drag_on:
self._disable_drag()
if getattr(self, '_rdk', None) is not None:
try:
self._rdk.close()
except Exception:
pass
n = len(self._captures)
print('\n========== 示教完成 ==========')
print(f'位姿: {n}')
print(f'关节: {self.poses_file}')
print(f'yaml: {self.yaml_file}')
print(f'图像: {self.images_dir}')
if n >= 1:
print('\n--- 粘贴到 handeye_calibrate.yaml ---\n')
print(self.yaml_file.read_text(encoding='utf-8'))
print('同时设置 num_poses:', n)
if n < 5:
self.get_logger().warn(f'只有 {n} 个位姿;至少需要 5 个(建议 8-12 个)。')
def main(args=None) -> None:
rclpy.init(args=args)
node = None
try:
node = HandeyeTeach()
while rclpy.ok() and not node._quit:
rclpy.spin_once(node, timeout_sec=0.05)
except SystemExit:
pass
except KeyboardInterrupt:
pass
finally:
if node is not None:
node.finish()
try:
node._pipe.stop()
except Exception: # noqa: BLE001
pass
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()
@@ -0,0 +1,525 @@
#!/usr/bin/env python3
"""手眼标定路点执行器。
逐个移动到每个关节路点,等到位后停留一段时间,
让相机(或操作者)采集一张标定图像。
capture_mode:
auto — 停留 dwell_sec 后进入下一个位姿
keypress — 在终端按 Enter 继续
preview — OpenCV 实时相机 + Enter/Space 确认(示教时推荐)
处于 ``preview`` 模式时,可在预览窗口打开期间用示教器的 **Free Drive**
拖拽微调(ros2_control 保持 AUTO Remote;拖拽经由示教器,而非 RDK)。
"""
from __future__ import annotations
import csv
import array
import sys
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
import numpy as np
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import Image
from std_msgs.msg import String
from arm_application.handeye_realsense import (
is_realsense_source,
resolve_realsense_topics,
)
from arm_application.site_io import joint_prefix_default
from arm_msgs.action import FollowJointTrajectory
from arm_msgs.srv import GetArmState
from arm_application.ros_node import TolerantNode
try:
import cv2
except Exception: # noqa: BLE001
cv2 = None # type: ignore
DEFAULT_JOINTS = [
'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7',
]
DEFAULT_TIMES = [4.0] * 12
DEFAULT_POSITIONS = [
0.00, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00,
0.35, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00,
-0.35, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00,
0.00, -0.90, 0.00, 1.70, 0.00, 0.85, 0.00,
0.00, -0.50, 0.00, 1.40, 0.00, 0.55, 0.00,
0.20, -0.70, 0.15, 1.57, 0.40, 0.70, 0.30,
-0.20, -0.70, -0.15, 1.57, -0.40, 0.70, -0.30,
0.25, -0.80, 0.10, 1.65, 0.25, 0.90, -0.40,
-0.25, -0.80, -0.10, 1.65, -0.25, 0.90, 0.40,
0.15, -0.60, 0.20, 1.50, -0.35, 0.60, 0.50,
-0.15, -0.60, -0.20, 1.50, 0.35, 0.60, -0.50,
0.00, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00,
]
PREVIEW_HELP_GUI = """
=== 位姿预览 / 拖拽调整(OpenCV 窗口)===
1. 示教器:Free Drive + 按住使能键拖拽微调
2. 预览窗口:确认棋盘在画面内且清晰
3. 键盘:Enter / Space = 下一个位姿;q / Esc = 退出
"""
PREVIEW_HELP_HEADLESS = """
=== 位姿预览 / 拖拽调整(无 OpenCV 窗口)===
1. 另开终端查看画面:
ros2 run rqt_image_view rqt_image_view /handeye/preview_image
2. 示教器:Free Drive + 按住使能键拖拽微调
3. 本终端:Enter = 确认下一个位姿;输入 q 回车 = 退出
"""
def _cv_gui_available() -> bool:
if cv2 is None:
return False
try:
cv2.namedWindow('_handeye_gui_probe', cv2.WINDOW_NORMAL)
cv2.destroyWindow('_handeye_gui_probe')
return True
except Exception: # noqa: BLE001
return False
def _image_to_bgr(msg: Image) -> np.ndarray:
h, w = int(msg.height), int(msg.width)
enc = (msg.encoding or '').lower()
data = np.frombuffer(msg.data, dtype=np.uint8)
if enc in ('bgr8', 'rgb8'):
img = data.reshape(h, w, 3)
return img[:, :, ::-1].copy() if enc == 'rgb8' else img
if enc in ('mono8', '8uc1'):
gray = data.reshape(h, w)
return np.stack([gray, gray, gray], axis=-1)
if enc in ('bgra8', 'rgba8'):
img = data.reshape(h, w, 4)
bgr = img[:, :, :3]
return bgr[:, :, ::-1].copy() if enc == 'rgba8' else bgr
raise RuntimeError(f'不支持的图像编码:{msg.encoding!r}')
class HandeyeWaypoints(TolerantNode):
def __init__(self) -> None:
super().__init__('handeye_waypoints')
self.declare_parameter('joint_prefix', joint_prefix_default())
self.declare_parameter('joint_names', DEFAULT_JOINTS)
self.declare_parameter('velocity_scale', 0.12)
self.declare_parameter('action_name', 'follow_joint_trajectory')
self.declare_parameter('waypoint_times', DEFAULT_TIMES)
self.declare_parameter('waypoint_positions', DEFAULT_POSITIONS)
self.declare_parameter('dwell_sec', 2.5)
# auto | keypress | preview
self.declare_parameter('capture_mode', 'auto')
self.declare_parameter('start_index', 0)
self.declare_parameter('log_csv', '')
self.declare_parameter('capture_topic', 'handeye/capture')
self.declare_parameter('camera_source', 'd405')
self.declare_parameter('camera_namespace', '')
self.declare_parameter('image_topic', '')
self.declare_parameter('preview_window', True)
self.declare_parameter('append_poses_file', '/tmp/handeye_taught_poses.txt')
prefix = str(self.get_parameter('joint_prefix').value or '')
base = list(
self.get_parameter('joint_names').get_parameter_value().string_array_value
or DEFAULT_JOINTS
)
self.joint_names = [f'{prefix}{n}' for n in base]
self.velocity_scale = float(self.get_parameter('velocity_scale').value)
self.action_name = str(self.get_parameter('action_name').value)
self.dwell_sec = float(self.get_parameter('dwell_sec').value)
self.capture_mode = str(self.get_parameter('capture_mode').value).strip().lower()
self.start_index = max(0, int(self.get_parameter('start_index').value))
self.log_csv = str(self.get_parameter('log_csv').value or '').strip()
topic = str(self.get_parameter('capture_topic').value)
self.preview_window = bool(self.get_parameter('preview_window').value)
self.append_poses_file = str(
self.get_parameter('append_poses_file').value or ''
).strip()
cam_src = str(self.get_parameter('camera_source').value).strip().lower()
img_topic = str(self.get_parameter('image_topic').value or '').strip()
ns = str(self.get_parameter('camera_namespace').value or '').strip()
if is_realsense_source(cam_src) and not img_topic:
img_topic, _ = resolve_realsense_topics(
cam_src, camera_namespace=ns, image_topic='', camera_info_topic=''
)
self.image_topic = img_topic
self._client = ActionClient(self, FollowJointTrajectory, self.action_name)
self._get_state = self.create_client(GetArmState, 'get_arm_state')
self._capture_pub = self.create_publisher(String, topic, 10)
self._preview_pub = self.create_publisher(Image, 'handeye/preview_image', 10)
self._latest_bgr: Optional[np.ndarray] = None
self._cv_gui = _cv_gui_available() and self.preview_window
if self.capture_mode == 'preview' and not self._cv_gui:
self.get_logger().warn(
'OpenCV 没有 GUI(无头环境)。请在 /handeye/preview_image 上预览,'
'在本终端按 Enter 确认。'
)
if self.image_topic and self.capture_mode == 'preview':
self.create_subscription(
Image, self.image_topic, self._on_image, qos_profile_sensor_data
)
self.get_logger().info(f'预览图像:{self.image_topic}')
def _on_image(self, msg: Image) -> None:
try:
self._latest_bgr = _image_to_bgr(msg)
except Exception: # noqa: BLE001
pass
def run(self) -> int:
if self.capture_mode == 'preview':
if cv2 is None:
self.get_logger().error('preview 模式需要 OpenCV(cv2)')
return 1
if not self.image_topic:
self.get_logger().error('preview 模式需要 image_topic 或 camera_source')
return 1
times = list(self.get_parameter('waypoint_times').value)
positions = list(self.get_parameter('waypoint_positions').value)
n_j = len(self.joint_names)
n_pts = len(times)
if n_pts < 1:
self.get_logger().error('waypoint_times 为空')
return 1
if len(positions) != n_pts * n_j:
self.get_logger().error(
f'waypoint_positions 长度 {len(positions)} != {n_pts}*{n_j}'
)
return 1
if self.start_index >= n_pts:
self.get_logger().error(
f'start_index {self.start_index} >= 路点总数 {n_pts}'
)
return 1
if not self._client.wait_for_server(timeout_sec=15.0):
self.get_logger().error(
f'Action [{self.action_name}] 不可用。'
'请先启动整套栈:bash scripts/run_real.sh'
)
return 2
csv_file = None
csv_writer: Optional[csv.writer] = None
if self.log_csv:
path = Path(self.log_csv).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
csv_file = path.open('w', newline='', encoding='utf-8')
csv_writer = csv.writer(csv_file)
csv_writer.writerow(
['index', 'utc_iso', 'move_duration_s']
+ [f'target_{n}' for n in self.joint_names]
+ [f'measured_{n}' for n in self.joint_names]
)
self.get_logger().info(f'正在记录采集数据 → {path}')
if self.capture_mode == 'preview':
print(PREVIEW_HELP_GUI if self._cv_gui else PREVIEW_HELP_HEADLESS)
self.get_logger().info(
f'手眼标定:{n_pts} 个位姿,start={self.start_index},'
f'v_scale={self.velocity_scale},dwell={self.dwell_sec}s,'
f'mode={self.capture_mode}'
)
self.get_logger().warn(
'清理工作空间;让棋盘保持在视野内。Ctrl+C 可中止。'
)
win = 'handeye_preview'
try:
for i in range(self.start_index, n_pts):
q = positions[i * n_j : (i + 1) * n_j]
move_t = max(1.0, float(times[i]))
self.get_logger().info(
f'[{i + 1}/{n_pts}] 移动中(约 {move_t:.1f}s)...'
)
code = self._move_one(q, move_t)
if code != 0:
return code
measured = self._read_joints()
self._announce_capture(i, n_pts, q, measured)
if csv_writer is not None and csv_file is not None:
csv_writer.writerow(
[i, datetime.now(timezone.utc).isoformat(), move_t]
+ list(q)
+ list(measured if measured else [float('nan')] * n_j)
)
csv_file.flush()
if not self._wait_capture(i, n_pts):
self.get_logger().info('用户已中止')
return 0
# 在 preview 模式下确认后,保存示教关节
if self.capture_mode == 'preview':
taught = self._read_joints()
if taught:
self._save_taught_pose(i, taught)
finally:
if csv_file is not None:
csv_file.close()
if cv2 is not None:
try:
cv2.destroyWindow(win)
except Exception: # noqa: BLE001
pass
self.get_logger().info('手眼标定路点序列执行完毕')
if self.append_poses_file and self.capture_mode == 'preview':
self.get_logger().info(
f'示教位姿已保存 → {self.append_poses_file}'
)
return 0
def _save_taught_pose(self, index: int, joints: List[float]) -> None:
line = ', '.join(f'{v:.6f}' for v in joints[:7]) + ','
print(f'# pose {index}')
print(line)
if not self.append_poses_file:
return
path = Path(self.append_poses_file).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open('a', encoding='utf-8') as f:
f.write(f'# pose {index}\n')
f.write(line + '\n')
self.get_logger().info(f'已保存位姿 {index} → {path}')
def _move_one(self, positions: List[float], duration: float) -> int:
goal = FollowJointTrajectory.Goal()
goal.joint_names = self.joint_names
goal.num_points = 1
goal.velocity_scale = self.velocity_scale
goal.positions = list(positions)
goal.time_from_start = [float(duration)]
send = self._client.send_goal_async(goal)
if not self._wait_future(send, 10.0, '发送目标'):
return 3
handle = send.result()
if handle is None or not handle.accepted:
self.get_logger().error('目标被拒绝')
return 4
res_fut = handle.get_result_async()
if not self._wait_future(res_fut, max(60.0, duration * 3.0), '结果'):
return 5
result = res_fut.result().result
if not result.success:
self.get_logger().error(f'移动失败:{result.message}')
return 6
return 0
def _wait_future(self, fut, timeout: float, label: str) -> bool:
deadline = time.monotonic() + timeout
while rclpy.ok() and not fut.done():
rclpy.spin_once(self, timeout_sec=0.05)
if time.monotonic() > deadline:
self.get_logger().error(f'等待 {label} 超时')
return False
return fut.done()
def _read_joints(self) -> Optional[List[float]]:
if not self._get_state.wait_for_service(timeout_sec=0.5):
return None
fut = self._get_state.call_async(GetArmState.Request())
if not self._wait_future(fut, 2.0, 'get_arm_state'):
return None
res = fut.result()
if res is None or not res.success:
return None
return list(res.joint_positions)
def _announce_capture(
self,
index: int,
n_pts: int,
target: List[float],
measured: Optional[List[float]],
) -> None:
msg = String()
msg.data = f'capture:{index}:{n_pts}'
self._capture_pub.publish(msg)
q_str = ', '.join(f'{v:.4f}' for v in (measured or target))
if self.capture_mode == 'preview':
self.get_logger().info(
f'[{index + 1}/{n_pts}] 已到位 —— 用 Free Drive 调整,然后按 Enter/Space'
)
else:
self.get_logger().info(
f'[{index + 1}/{n_pts}] 已到位 —— 现在采集 q=[{q_str}]'
)
def _wait_capture(self, index: int, n_pts: int) -> bool:
if self.capture_mode == 'preview':
return self._wait_preview(index, n_pts)
if self.capture_mode in ('key', 'keypress', 'manual'):
self.get_logger().info(
'采集完成后按 Enter(q+Enter 退出)...'
)
for _ in range(5):
rclpy.spin_once(self, timeout_sec=0.05)
line = sys.stdin.readline()
if not line or line.strip().lower() in ('q', 'quit', 'exit'):
return False
return True
end = time.monotonic() + max(0.0, self.dwell_sec)
while rclpy.ok() and time.monotonic() < end:
rclpy.spin_once(self, timeout_sec=0.05)
return rclpy.ok()
def _annotate_preview(
self, bgr: np.ndarray, index: int, n_pts: int
) -> np.ndarray:
vis = bgr.copy()
if cv2 is None:
return vis
cv2.putText(
vis,
f'位姿 {index + 1}/{n_pts}',
(12, 28),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
(0, 255, 0),
2,
)
cv2.putText(
vis,
'Free Drive 拖拽 | Enter=下一个 q=退出',
(12, 56),
cv2.FONT_HERSHEY_SIMPLEX,
0.6,
(0, 255, 255),
2,
)
return vis
def _publish_preview(self, bgr: np.ndarray) -> None:
msg = Image()
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = 'camera'
msg.height, msg.width = bgr.shape[0], bgr.shape[1]
msg.encoding = 'bgr8'
msg.is_bigendian = 0
msg.step = msg.width * 3
msg.data = array.array('B', bgr.tobytes()) # 快速路径;用 bytes 会多花约 40 ms
self._preview_pub.publish(msg)
def _wait_preview(self, index: int, n_pts: int) -> bool:
if self._cv_gui:
return self._wait_preview_opencv(index, n_pts)
return self._wait_preview_terminal(index, n_pts)
def _wait_preview_terminal(self, index: int, n_pts: int) -> bool:
confirmed = threading.Event()
quit_req = threading.Event()
def _stdin_reader() -> None:
try:
line = sys.stdin.readline()
except Exception: # noqa: BLE001
quit_req.set()
return
if not line or line.strip().lower() in ('q', 'quit', 'exit'):
quit_req.set()
else:
confirmed.set()
self.get_logger().info(
f'[{index + 1}/{n_pts}] 用 Free Drive 调整,然后按 Enter '
'(q+Enter 退出)'
)
reader = threading.Thread(target=_stdin_reader, daemon=True)
reader.start()
no_frame_warn = False
while rclpy.ok():
rclpy.spin_once(self, timeout_sec=0.05)
if confirmed.is_set():
return True
if quit_req.is_set():
return False
if not reader.is_alive() and not confirmed.is_set() and not quit_req.is_set():
return False
bgr = self._latest_bgr
if bgr is None:
if not no_frame_warn:
self.get_logger().warn(
f'正在等待 {self.image_topic} ...',
throttle_duration_sec=3.0,
)
no_frame_warn = True
continue
vis = self._annotate_preview(bgr, index, n_pts)
self._publish_preview(vis)
return False
def _wait_preview_opencv(self, index: int, n_pts: int) -> bool:
assert cv2 is not None
win = 'handeye_preview'
cv2.namedWindow(win, cv2.WINDOW_NORMAL)
no_frame_warn = False
while rclpy.ok():
rclpy.spin_once(self, timeout_sec=0.02)
bgr = self._latest_bgr
if bgr is None:
if not no_frame_warn:
self.get_logger().warn(
f'正在等待 {self.image_topic} ...',
throttle_duration_sec=3.0,
)
no_frame_warn = True
key = cv2.waitKey(30) & 0xFF
else:
vis = self._annotate_preview(bgr, index, n_pts)
self._publish_preview(vis)
cv2.imshow(win, vis)
key = cv2.waitKey(30) & 0xFF
if key in (13, 10, 32):
return True
if key in (27, ord('q'), ord('Q')):
return False
return False
def main(args=None) -> None:
rclpy.init(args=args)
node = HandeyeWaypoints()
code = 1
try:
code = node.run()
except KeyboardInterrupt:
code = 0
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
raise SystemExit(code)
if __name__ == '__main__':
main()
@@ -0,0 +1,844 @@
"""工件二阶段检测:YOLO 定位工件 + HoughCircles 检测左右螺纹孔中心。"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import cv2
import numpy as np
import yaml
_PKG_CONFIG = Path(__file__).resolve().parents[1] / "config" / "hole_detect.yaml"
_WS_CONFIG = Path.home() / "ros2" / "src" / "arm_application" / "config" / "hole_detect.yaml"
DEFAULT_CONFIG = _PKG_CONFIG if _PKG_CONFIG.is_file() else _WS_CONFIG
def load_config(path: str | Path | None = None) -> dict[str, Any]:
cfg_path = Path(path) if path else DEFAULT_CONFIG
with open(cfg_path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise ValueError(f"配置格式错误: {cfg_path}")
return data
logger = logging.getLogger(__name__)
def _fail(
msg: str,
draw_img: np.ndarray,
bbox: tuple[int, int, int, int] | None = None,
gate_block: bool = False,
) -> dict[str, Any]:
return {
"status": False,
"msg": msg,
"hole_points": [],
"hole_radii": [],
"bbox": list(bbox) if bbox is not None else None,
"gate_block": gate_block,
"draw_img": draw_img,
}
def _draw_bbox(
img: np.ndarray,
bbox: tuple[int, int, int, int],
color=(0, 255, 0),
label: str = "",
) -> None:
"""画工件框。``label`` 是模型给出的类别名(由标签文件决定)。
这里刻意**不写死类名**:以前写死了 "gongjian",但实际模型的类别是
"workpiece",图上的字和模型的类名对不上,排查时会被误导。
现在用 ``Detection.cls_name``,也就是 ``weights.sh labels`` 里能读到的那份。
"""
x1, y1, x2, y2 = bbox
cv2.rectangle(img, (x1, y1), (x2, y2), color, 2)
text = (label or "").strip()
if text:
cv2.putText(img, text, (x1, max(20, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2)
def _best_label(dets) -> str:
"""取置信度最高那个检测的类名(由标签文件决定)。没有则返回空串。"""
if not dets:
return ""
try:
best = max(dets, key=lambda d: float(d.conf))
except Exception: # noqa: BLE001
return ""
return str(getattr(best, "cls_name", "") or "").strip()
def _draw_center_gate(img: np.ndarray, max_dx: float, max_dy: float, ok: bool) -> None:
h, w = img.shape[:2]
cx, cy = w * 0.5, h * 0.5
x1 = int(round(cx - max_dx))
y1 = int(round(cy - max_dy))
x2 = int(round(cx + max_dx))
y2 = int(round(cy + max_dy))
color = (0, 255, 0) if ok else (0, 165, 255)
cv2.rectangle(img, (x1, y1), (x2, y2), color, 1)
cv2.drawMarker(img, (int(cx), int(cy)), color, markerType=cv2.MARKER_CROSS, markerSize=18, thickness=1)
def _bbox_center_ok(
bbox: tuple[int, int, int, int],
img_w: int,
img_h: int,
cfg: dict[str, Any],
) -> tuple[bool, float, float, float, float]:
"""返回 (是否居中, bbox_cx, bbox_cy, max_dx, max_dy)。"""
x1, y1, x2, y2 = bbox
bx = 0.5 * (x1 + x2)
by = 0.5 * (y1 + y2)
max_dx = float(cfg.get("center_max_offset_ratio_x", 0.12)) * img_w
max_dy = float(cfg.get("center_max_offset_ratio_y", 0.12)) * img_h
ok = abs(bx - img_w * 0.5) <= max_dx and abs(by - img_h * 0.5) <= max_dy
return ok, bx, by, max_dx, max_dy
def _draw_holes(
img: np.ndarray,
points: list[list[float]],
radii: list[float] | None = None,
) -> None:
"""画孔轮廓圆 + 中心点(十字+实心点)+ 坐标。"""
for i, (cx, cy) in enumerate(points):
px, py = int(round(cx)), int(round(cy))
r = 12.0 if radii is None or i >= len(radii) else float(radii[i])
rr = max(4, int(round(r)))
# 轮廓
cv2.circle(img, (px, py), rr, (0, 0, 255), 2)
# 中心十字 + 黄点
cv2.drawMarker(img, (px, py), (0, 0, 255), markerType=cv2.MARKER_CROSS, markerSize=16, thickness=2)
cv2.circle(img, (px, py), 3, (0, 255, 255), -1)
label = f"P{i + 1}({px},{py}) r={rr}"
cv2.putText(img, label, (px + 12, py - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 0, 255), 2)
def _parse_best_bbox(dets, conf_thr: float) -> tuple[int, int, int, int] | None:
"""最多取 1 个工件:取置信度最高且 >= conf 的框。"""
if not dets:
return None
ordered = sorted(dets, key=lambda d: d.conf, reverse=True)
for det in ordered:
if float(det.conf) < conf_thr:
continue
return tuple(int(v) for v in det.xyxy) # type: ignore[return-value]
return None
def _keypoint_holes(
dets,
*,
conf_thr: float,
min_kconf: float,
radius_px: float,
min_dist_px: float,
):
"""pose 模型直接给出的 k1/k2 关键点当孔位。
现场约定(见 ``pose_detector``):k0 跟踪、k1/k2 对孔。
返回 ``((k1x,k1y),(k2x,k2y),r,r)``,拿不到可信关键点时返回 None
(不是 pose 模型 / 关键点置信度不够 / 两点几乎重合),调用方回落到找圆。
为什么值得优先用关键点:Hough / 亮环那套是在**孔本身的图像特征**上找圆,
光照、反光、通止规尖伸进视野都会把圆心拉偏;而关键点是模型在训练分布里
直接回归出来的,对这几类干扰天然更稳。
"""
if not dets:
return None
for det in sorted(dets, key=lambda d: d.conf, reverse=True):
if float(det.conf) < conf_thr:
continue
kpts = getattr(det, "kpts", None)
if kpts is None:
continue
kpts = np.asarray(kpts, dtype=float)
if kpts.ndim != 2 or kpts.shape[0] < 3 or kpts.shape[1] < 3:
continue
k1, k2 = kpts[1].copy(), kpts[2].copy()
if not (np.isfinite(k1[:2]).all() and np.isfinite(k2[:2]).all()):
continue
if float(k1[2]) < min_kconf or float(k2[2]) < min_kconf:
logger.info(
"k1/k2 关键点置信度不足(%.2f / %.2f < %.2f)—— 回落到找圆",
float(k1[2]),
float(k2[2]),
min_kconf,
)
continue
dist = float(np.hypot(k1[0] - k2[0], k1[1] - k2[1]))
if dist < float(min_dist_px):
logger.info(
"k1/k2 两关键点只差 %.1fpx(< %.1fpx)—— 视为不可信,回落到找圆",
dist,
min_dist_px,
)
continue
r = float(radius_px)
# **刻意不按图像 x 交换左右**。
# Hough 路径的 k1/k2 是"画面左孔 / 右孔"(位置定义),但 pose 模型学出来的
# k1/k2 是**固定语义**(训练时标注的那个物理孔)。工件只要转个角度,
# 位置就会翻面;按 x 强制交换会让 `target_hole=k1` 时左时右地指向两个不同
# 的物理孔 —— 那是最难查的一类现场问题。
# 下游怎么认这两个点由 `stage2.k1_label` 决定:
# hole_points[0] = 模型的 k1,hole_points[1] = 模型的 k2,
# 所以 k1_label="left" ↔ k1 就是模型的 k1(见 stage2_locate_node._snap)。
return (float(k1[0]), float(k1[1])), (float(k2[0]), float(k2[1])), r, r
return None
def _refine_circle_center(
gray: np.ndarray,
cx: float,
cy: float,
r: float,
) -> tuple[float, float]:
"""Hough 整数圆心 → 暗孔连通域一阶矩(亚像素)。失败则退回原圆心。"""
h, w = gray.shape[:2]
rad = max(5.0, float(r))
half = int(round(2.2 * rad))
x0 = max(0, int(round(cx)) - half)
y0 = max(0, int(round(cy)) - half)
x1 = min(w, int(round(cx)) + half)
y1 = min(h, int(round(cy)) + half)
if x1 - x0 < 8 or y1 - y0 < 8:
return cx, cy
roi = gray[y0:y1, x0:x1]
blur = cv2.GaussianBlur(roi, (5, 5), 1.0)
_, bw = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
n, labels, stats, cents = cv2.connectedComponentsWithStats(bw, 8)
if n <= 1:
return cx, cy
ex, ey = cx - x0, cy - y0
min_area = np.pi * (0.35 * rad) ** 2
max_area = np.pi * (1.8 * rad) ** 2
best_i = 0
best_d = 1e18
for i in range(1, n):
area = float(stats[i, cv2.CC_STAT_AREA])
if area < min_area or area > max_area:
continue
d = (float(cents[i][0]) - ex) ** 2 + (float(cents[i][1]) - ey) ** 2
if d < best_d:
best_d = d
best_i = i
if best_i <= 0 or best_d > (1.2 * rad) ** 2:
return cx, cy
mask = np.uint8(labels == best_i)
M = cv2.moments(mask)
if M["m00"] < 1.0:
return cx, cy
return x0 + M["m10"] / M["m00"], y0 + M["m01"] / M["m00"]
# ==================== 螺纹嵌件对检测(亮环签名 + 关于中心孔对称)====================
# 目标件是圆形法兰:中心一个大螺纹孔,两个目标螺纹嵌件在中心孔两侧、关于中心对称。
# 旧 Hough"间距最大的一对"在这类多孔板上会锁到普通安装孔且逐帧跳变。
# 新路径:金属嵌件有"暗孔芯 + 亮金属环 + 暗法兰背景"的明确签名;
# 两个目标嵌件关于中心孔对称(中点≈中心孔、半径相近)——据此唯一锁定,抗干扰抗跳变。
def _disc_kernel(r: float) -> np.ndarray:
R = int(np.ceil(r))
y, x = np.mgrid[-R:R + 1, -R:R + 1]
k = (x * x + y * y <= r * r).astype(np.float32)
return k / max(1.0, float(k.sum()))
def _ring_kernel(a: float, b: float) -> np.ndarray:
R = int(np.ceil(b))
y, x = np.mgrid[-R:R + 1, -R:R + 1]
d = x * x + y * y
k = ((d >= a * a) & (d <= b * b)).astype(np.float32)
return k / max(1.0, float(k.sum()))
def _insert_score_map(gray: np.ndarray, scales) -> np.ndarray:
"""亮环签名评分(多尺度取 max):环越亮、芯越暗、外背景越暗,分越高。"""
g = gray.astype(np.float32)
best = None
for ri, rr, ro in scales:
inner = cv2.filter2D(g, -1, _disc_kernel(ri))
ringm = cv2.filter2D(g, -1, _ring_kernel(ri + 1, rr))
outer = cv2.filter2D(g, -1, _ring_kernel(rr + 2, ro))
s = ringm - 0.5 * (inner + outer)
best = s if best is None else np.maximum(best, s)
return best
def _refine_dark_bore(gray: np.ndarray, cx: float, cy: float, half: int = 26):
"""暗孔连通域一阶矩 → 亚像素孔心 + 等效半径。失败退回原心。"""
h, w = gray.shape
x0 = max(0, int(round(cx)) - half)
y0 = max(0, int(round(cy)) - half)
x1 = min(w, int(round(cx)) + half)
y1 = min(h, int(round(cy)) + half)
if x1 - x0 < 12 or y1 - y0 < 12:
return float(cx), float(cy), 12.0
roi = gray[y0:y1, x0:x1].astype(np.float32)
blur = cv2.GaussianBlur(roi, (5, 5), 1.0)
thr = float(np.percentile(blur, 20))
mask = (blur < thr).astype(np.uint8) * 255
n, _lab, st, ce = cv2.connectedComponentsWithStats(mask, 8)
best_i, best_d = 0, 1e18
ex, ey = cx - x0, cy - y0
for i in range(1, n):
a = float(st[i, cv2.CC_STAT_AREA])
if a < 15:
continue
d = (float(ce[i][0]) - ex) ** 2 + (float(ce[i][1]) - ey) ** 2
if d < best_d:
best_d, best_i = d, i
if best_i <= 0:
return float(cx), float(cy), 12.0
area = float(st[best_i, cv2.CC_STAT_AREA])
r_eq = float(np.sqrt(area / np.pi))
return x0 + float(ce[best_i][0]), y0 + float(ce[best_i][1]), max(6.0, r_eq)
def _detect_insert_pair(gray: np.ndarray, bbox, cfg, depth_m=None, intr=None):
"""找"关于工件中心对称的一对螺纹嵌件"。返回 ((k1x,k1y),(k2x,k2y),r1,r2) 或 None。
尺度自适应:用深度把物理螺栓圆半径(~29mm,两孔间距~58mm)换算成像素半径,
在中心 C 周围的圆环上搜候选,再选"中点≈C、都在环上、间距≈直径"的对称对。
不依赖 YOLO 框是否盖住嵌件(框只用来定中心 C)。k1=左孔(x 小),k2=右孔(x 大)。
"""
h, w = gray.shape
x1, y1, x2, y2 = [int(v) for v in bbox]
C = np.array([0.5 * (x1 + x2), 0.5 * (y1 + y2)], dtype=float)
# ---- 螺栓圆像素半径 bolt_px ----
bolt_mm = float(cfg.get("insert_bolt_radius_mm", 29.0))
bolt_px = 0.0
if depth_m is not None and intr is not None:
# 工件表面深度:C 附近小邻域的浅分位(孔底更深,表面偏浅)
r_s = max(6, int(round(0.25 * min(x2 - x1, y2 - y1))))
cxi, cyi = int(round(C[0])), int(round(C[1]))
sx1, sx2 = max(0, cxi - r_s), min(w, cxi + r_s)
sy1, sy2 = max(0, cyi - r_s), min(h, cyi + r_s)
zreg = depth_m[sy1:sy2, sx1:sx2]
zreg = zreg[np.isfinite(zreg) & (zreg > 0.05)]
if zreg.size >= 20:
z_plate = float(np.percentile(zreg, 30))
z_surf = max(0.05, z_plate - float(cfg.get("plate_thickness_m", 0.004)))
bolt_px = bolt_mm / 1000.0 * float(intr["fx"]) / z_surf
if bolt_px <= 0:
# 无深度时退化为按框尺寸粗估(嵌件约在 0.7×框半宽处)
bolt_px = 0.70 * 0.5 * max(x2 - x1, y2 - y1)
bolt_px = float(np.clip(bolt_px, 24.0, 0.42 * min(h, w)))
# ---- 亮环分数图 ----
scales = cfg.get("insert_scales") or [
(3, 6, 10), (5, 9, 14), (7, 12, 18), (10, 16, 23), (14, 21, 30),
]
score = _insert_score_map(gray, scales)
# ---- 圆环掩码:嵌件在 [0.45, 1.55]×bolt_px 的环上 ----
yy, xx = np.mgrid[0:h, 0:w]
dist = np.hypot(xx - C[0], yy - C[1])
ring = ((dist > 0.45 * bolt_px) & (dist < 1.55 * bolt_px)).astype(np.float32)
scm = score * ring
pos = scm[scm > 0]
if pos.size < 20:
return None
thr = float(np.percentile(pos, float(cfg.get("insert_percentile", 96))))
pk = (scm > thr).astype(np.uint8) * 255
n, _lab, st, ce = cv2.connectedComponentsWithStats(pk, 8)
cands = [] # (pos, score)
for i in range(1, n):
if st[i, cv2.CC_STAT_AREA] < 8:
continue
cx_i, cy_i = float(ce[i][0]), float(ce[i][1])
cands.append((np.array([cx_i, cy_i], dtype=float),
float(scm[int(round(cy_i)), int(round(cx_i))])))
if len(cands) < 2:
return None
# ---- 对称对:两孔都在环上、间距≈直径、中点大致≈C(对称门)。
# 在几何合格的对里选"亮环总分最高"的——真嵌件亮环最强,稳定选中真孔对,
# 不被"更对称但更弱"的假对带偏(C 与真中心有几像素偏差时仍稳健,消除双稳态跳变)。
best = None
best_score = -1.0
sym_gate = float(cfg.get("insert_sym_gate_ratio", 0.40)) * bolt_px
for i in range(len(cands)):
for j in range(i + 1, len(cands)):
pi, si = cands[i]
pj, sj = cands[j]
di = float(np.linalg.norm(pi - C))
dj = float(np.linalg.norm(pj - C))
if not (0.40 * bolt_px < di < 1.65 * bolt_px):
continue
if not (0.40 * bolt_px < dj < 1.65 * bolt_px):
continue
sep = float(np.linalg.norm(pi - pj))
if not (1.2 * bolt_px < sep < 2.9 * bolt_px):
continue
mid = 0.5 * (pi + pj)
if float(np.linalg.norm(mid - C)) > sym_gate:
continue
tot = si + sj
if tot > best_score:
best_score = tot
best = (pi, pj)
if best is None:
return None
p, q = best
# 稳定分配 k1/k2:沿主方向,避免竖直对时"x 小者"被噪声翻转。
# 竖直对(|dy|>=|dx|) → k1=上(y 小)、k2=下;水平对 → k1=左(x 小)、k2=右。
if abs(p[1] - q[1]) >= abs(p[0] - q[0]):
k1raw, k2raw = (p, q) if p[1] < q[1] else (q, p)
else:
k1raw, k2raw = (p, q) if p[0] < q[0] else (q, p)
k1x, k1y, r1 = _refine_dark_bore(gray, k1raw[0], k1raw[1])
k2x, k2y, r2 = _refine_dark_bore(gray, k2raw[0], k2raw[1])
return (k1x, k1y), (k2x, k2y), r1, r2
def _pad_roi(
img_w: int,
img_h: int,
bbox: tuple[int, int, int, int],
padding: int,
) -> tuple[int, int, int, int, int, int]:
"""返回 x1,y1,x2,y2,offset_x,offset_y(裁剪左上角在原图坐标)。"""
x1, y1, x2, y2 = bbox
px1 = max(0, x1 - padding)
py1 = max(0, y1 - padding)
px2 = min(img_w, x2 + padding)
py2 = min(img_h, y2 + padding)
if px2 <= px1 or py2 <= py1:
raise ValueError("ROI 无效(宽或高为 0)")
return px1, py1, px2, py2, px1, py1
def _render_success(
image: np.ndarray,
bbox: tuple[int, int, int, int],
hole_points: list[list[float]],
hole_radii: list[float],
dist: float,
gate: tuple[float, float] | None = None,
label: str = "",
) -> np.ndarray:
draw_img = image.copy()
if gate is not None:
_draw_center_gate(draw_img, gate[0], gate[1], True)
_draw_bbox(draw_img, bbox, label=label)
_draw_holes(draw_img, hole_points, hole_radii)
cv2.putText(
draw_img,
f"OK dist={dist:.1f}px",
(16, 36),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
(0, 255, 0),
2,
)
return draw_img
def detect_holes(
image: np.ndarray,
detector,
config: dict[str, Any] | None = None,
device: str | int = 0,
skip_center_gate: bool = False,
depth_m: np.ndarray | None = None,
intr: dict | None = None,
allow_full_frame_roi: bool = False,
) -> dict[str, Any]:
"""
二阶段检测螺纹孔中心。
detector: 需提供 detect(image_bgr, conf=..., iou=..., imgsz=...) -> list[Detection]
(YOLODetector / FasterRCNNDetector / ONNX 均可)
:param allow_full_frame_roi: 检测器**没给出工件框**时,是否退回"整幅画面当 ROI"。
默认 False(保持原行为:没有框就不做,报「未检测到工件」)。
置 True 的场景是**调用方明确要试一把**:知道检测器可能漏检,但孔本身
在画面里已经看得见(例如工件检测模型与当前相机/画面不匹配、或工件贴着
画面中心)。此时 ROI 的约束由孔自身的判据接手 —— 环半径、两孔间距、
居中门控都还在,所以不会凭空多出"随便一个圆"。
返回:
status, msg, hole_points [[cx,cy],...], hole_radii [r1,r2], bbox, draw_img(BGR)
"""
try:
if image is None or not isinstance(image, np.ndarray) or image.size == 0:
return _fail("输入图像无效", np.zeros((480, 640, 3), dtype=np.uint8))
cfg = config if config is not None else load_config()
draw_img = image.copy()
h, w = image.shape[:2]
conf = float(cfg.get("conf", 0.25))
iou = float(cfg.get("iou", 0.7))
imgsz = int(cfg.get("imgsz", 640))
padding = int(cfg.get("bbox_padding", 25))
ksize = int(cfg.get("gaussian_ksize", 5))
if ksize % 2 == 0:
ksize += 1
sigma = float(cfg.get("gaussian_sigma", 1.5))
# 1) 检测器定位工件 bbox(最多 1 个)— 兼容 YOLO / Faster R-CNN
dets = detector.detect(image, conf=conf, iou=iou, imgsz=imgsz)
bbox = _parse_best_bbox(dets, conf)
if bbox is None and allow_full_frame_roi:
# 退回整幅画面:ROI 从"检测器给的框"换成"整幅",孔自身的判据
#(环半径区间、两孔间距、居中门控)继续把关。
bbox = (0, 0, int(w - 1), int(h - 1))
logger.info(
"工件检测器没给框,按调用方要求用整幅画面当 ROI 继续找孔"
"(允许的原因见 allow_full_frame_roi 的说明)"
)
if bbox is None:
return _fail("未检测到工件", draw_img)
# 类别名来自标签文件(见 model_labels / weights.sh labels),不再写死。
label = _best_label(dets)
_draw_bbox(draw_img, bbox, label=label)
# 1.5) 居中门控:bbox 中心接近画面中心才进入二阶检测。
# 精对准 k1/k2 时工件会离开画面中心,调用方应 skip_center_gate=True。
center_enable = bool(cfg.get("center_gate_enable", True)) and not skip_center_gate
centered, bx, by, max_dx, max_dy = _bbox_center_ok(bbox, w, h, cfg)
if bool(cfg.get("center_gate_draw", True)):
_draw_center_gate(draw_img, max_dx, max_dy, centered if center_enable else True)
if center_enable and not centered:
dx = abs(bx - w * 0.5)
dy = abs(by - h * 0.5)
cv2.putText(
draw_img,
"MOVE TO CENTER",
(16, 36),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
(0, 165, 255),
2,
)
return _fail(
f"工件未居中:offset=({dx:.0f},{dy:.0f})px,允许<=({max_dx:.0f},{max_dy:.0f})px",
draw_img,
bbox,
gate_block=True,
)
# ---- 首选:pose 模型直接给出 k1/k2 关键点(不必再找圆) ----
if bool(cfg.get("keypoint_enable", True)):
kp = _keypoint_holes(
dets,
conf_thr=conf,
min_kconf=float(cfg.get("keypoint_min_conf", 0.30)),
radius_px=float(cfg.get("keypoint_hole_radius_px", 12.0)),
min_dist_px=float(cfg.get("keypoint_min_dist_px", 15.0)),
)
if kp is not None:
(k1, k2, r1, r2) = kp
hole_points = [[float(k1[0]), float(k1[1])], [float(k2[0]), float(k2[1])]]
hole_radii = [float(r1), float(r2)]
dist = float(np.hypot(k1[0] - k2[0], k1[1] - k2[1]))
gate = (max_dx, max_dy) if bool(cfg.get("center_gate_draw", True)) else None
draw_img = _render_success(
image, bbox, hole_points, hole_radii, dist, gate=gate, label=label
)
return {
"status": True,
"msg": f"pose 关键点 k1/k2,两孔距离 {dist:.1f}px",
"hole_points": hole_points,
"hole_radii": hole_radii,
"bbox": list(bbox),
"label": label,
"source": "keypoint",
"draw_img": draw_img,
}
# ---- 主路径:螺纹嵌件对(亮环签名 + 关于中心孔对称),抗多孔干扰、抗跳变 ----
if bool(cfg.get("insert_pair_enable", True)):
gray_full = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
pair = _detect_insert_pair(gray_full, bbox, cfg, depth_m=depth_m, intr=intr)
if pair is not None:
(k1, k2, r1, r2) = pair
hole_points = [[float(k1[0]), float(k1[1])], [float(k2[0]), float(k2[1])]]
hole_radii = [float(r1), float(r2)]
dist = float(np.hypot(k1[0] - k2[0], k1[1] - k2[1]))
gate = (max_dx, max_dy) if bool(cfg.get("center_gate_draw", True)) else None
draw_img = _render_success(
image, bbox, hole_points, hole_radii, dist, gate=gate, label=label
)
return {
"status": True,
"msg": f"螺纹嵌件对,两孔距离 {dist:.1f}px",
"hole_points": hole_points,
"hole_radii": hole_radii,
"bbox": list(bbox),
"label": label,
"draw_img": draw_img,
}
# ---- 退路:原 Hough 老路径 ----
# YOLO 框经常只包住半边法兰;扩到能同时容纳两个螺纹孔
min_w = int(cfg.get("bbox_min_width", 280))
min_h = int(cfg.get("bbox_min_height", 220))
x1, y1, x2, y2 = bbox
bw, bh = x2 - x1, y2 - y1
if bw < min_w or bh < min_h:
cxb, cyb = 0.5 * (x1 + x2), 0.5 * (y1 + y2)
bw, bh = max(bw, min_w), max(bh, min_h)
x1 = int(round(cxb - 0.5 * bw))
y1 = int(round(cyb - 0.5 * bh))
x2 = int(round(cxb + 0.5 * bw))
y2 = int(round(cyb + 0.5 * bh))
bbox = (
max(0, x1), max(0, y1),
min(w, x2), min(h, y2),
)
# 2) padding ROI + 边界保护
rx1, ry1, rx2, ry2, offset_x, offset_y = _pad_roi(w, h, bbox, padding)
roi = image[ry1:ry2, rx1:rx2]
if roi.size == 0:
return _fail("ROI 裁剪失败", draw_img, bbox)
# 3) 灰度 + 高斯模糊
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) if roi.ndim == 3 else roi
blur = cv2.GaussianBlur(gray, (ksize, ksize), sigma)
# 4) HoughCircles
circles = cv2.HoughCircles(
blur,
cv2.HOUGH_GRADIENT,
dp=float(cfg.get("hough_dp", 1.2)),
minDist=float(cfg.get("hough_min_dist", 40)),
param1=float(cfg.get("hough_param1", 100)),
param2=float(cfg.get("hough_param2", 28)),
minRadius=int(cfg.get("hough_min_radius", 6)),
maxRadius=int(cfg.get("hough_max_radius", 45)),
)
if circles is None:
return _fail("HoughCircles 未检测到圆孔", draw_img, bbox)
# 5) ROI 坐标 -> 原图坐标,再亚像素细化孔心
circles = np.asarray(circles[0], dtype=np.float64)
gray_full = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
do_refine = bool(cfg.get("refine_hole_centers", True))
mapped: list[tuple[float, float, float]] = []
for cx, cy, r in circles:
fx = float(cx + offset_x)
fy = float(cy + offset_y)
fr = float(r)
if do_refine:
fx, fy = _refine_circle_center(gray_full, fx, fy, fr)
mapped.append((fx, fy, fr))
# 6) 工业校验(含 bbox padding 区内的孔,避免螺纹孔落在框外)
bx1, by1, bx2, by2 = bbox
vx1 = max(0, bx1 - padding)
vy1 = max(0, by1 - padding)
vx2 = min(w, bx2 + padding)
vy2 = min(h, by2 + padding)
min_r = float(cfg.get("min_radius", 6))
max_r = float(cfg.get("max_radius", 45))
dist_min = float(cfg.get("hole_distance_min", 80))
dist_max = float(cfg.get("hole_distance_max", 900))
valid: list[tuple[float, float, float]] = []
for cx, cy, r in mapped:
if not (vx1 <= cx <= vx2 and vy1 <= cy <= vy2):
continue
if not (min_r <= r <= max_r):
continue
valid.append((cx, cy, r))
# 丢掉法兰中心大孔:螺纹孔在工件两端,中心孔靠近 bbox 中心
bcx = 0.5 * (bx1 + bx2)
bcy = 0.5 * (by1 + by2)
diag = max(40.0, float(np.hypot(bx2 - bx1, by2 - by1)))
ring = [
c for c in valid
if float(np.hypot(c[0] - bcx, c[1] - bcy)) >= 0.18 * diag
]
if len(ring) >= 2:
valid = ring
# 多于 2 个:在间距窗内取半径接近、间距最大的一对(左右螺纹孔,避开中心大孔边缘)
if len(valid) >= 2:
best = None
best_d = -1.0
for i in range(len(valid)):
for j in range(i + 1, len(valid)):
d = float(np.hypot(valid[i][0] - valid[j][0], valid[i][1] - valid[j][1]))
if not (dist_min <= d <= dist_max):
continue
if abs(float(valid[i][2]) - float(valid[j][2])) > 10.0:
continue
if d > best_d:
best_d = d
best = (valid[i], valid[j])
if best is None:
return _fail(
f"候选圆孔={len(valid)},没有间距在 [{dist_min}, {dist_max}] 且半径接近的一对",
draw_img,
bbox,
)
valid = [best[0], best[1]]
if len(valid) != 2:
return _fail(f"有效圆孔数量={len(valid)},要求严格等于 2", draw_img, bbox)
ordered = sorted(valid, key=lambda c: c[0])
(x1, y1, r1), (x2, y2, r2) = ordered
dist = float(np.hypot(x1 - x2, y1 - y2))
if not (dist_min <= dist <= dist_max):
return _fail(
f"两孔距离={dist:.1f}px,不在 [{dist_min}, {dist_max}]",
draw_img,
bbox,
)
hole_points = [[x1, y1], [x2, y2]]
hole_radii = [r1, r2]
gate = (max_dx, max_dy) if bool(cfg.get("center_gate_draw", True)) else None
draw_img = _render_success(image, bbox, hole_points, hole_radii, dist, gate=gate, label=label)
return {
"status": True,
"msg": f"检测成功,两孔距离 {dist:.1f}px",
"hole_points": hole_points,
"hole_radii": hole_radii,
"bbox": list(bbox),
"label": label,
"draw_img": draw_img,
}
except Exception as exc:
fallback = image.copy() if isinstance(image, np.ndarray) and image.size else np.zeros((480, 640, 3), dtype=np.uint8)
return _fail(f"异常: {exc}", fallback)
class HoleStabilizer:
"""时序稳帧:EMA 平滑中心/半径,短暂失败时沿用上一帧结果,抑制闪烁。"""
def __init__(self, config: dict[str, Any] | None = None) -> None:
cfg = config or {}
self.cfg = cfg
self.hold_frames = int(cfg.get("stabilize_hold_frames", 15))
self.ema_alpha = float(cfg.get("stabilize_ema_alpha", 0.35))
self.max_jump = float(cfg.get("stabilize_max_jump", 60))
self.reset()
def reset(self) -> None:
self.points: list[list[float]] | None = None
self.radii: list[float] | None = None
self.bbox: tuple[int, int, int, int] | None = None
self.miss = 0
def update(self, image: np.ndarray, result: dict[str, Any]) -> dict[str, Any]:
try:
# 未居中:不做二阶、不沿用旧孔,立即清空稳帧
if result.get("gate_block"):
self.reset()
out = dict(result)
out["hole_points"] = []
out["hole_radii"] = []
return out
bbox = result.get("bbox")
if bbox is not None and len(bbox) == 4:
self.bbox = (int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3]))
if result.get("status") and len(result.get("hole_points") or []) == 2:
pts = [[float(p[0]), float(p[1])] for p in result["hole_points"]]
pts = sorted(pts, key=lambda p: p[0])
rad = result.get("hole_radii") or [12.0, 12.0]
rad = [float(rad[0]), float(rad[1])] if len(rad) >= 2 else [12.0, 12.0]
if self.points is not None:
jump = max(
float(np.hypot(pts[0][0] - self.points[0][0], pts[0][1] - self.points[0][1])),
float(np.hypot(pts[1][0] - self.points[1][0], pts[1][1] - self.points[1][1])),
)
if jump > self.max_jump:
# 跳变过大,当作本帧失败,沿用稳定值
self.miss += 1
else:
a = self.ema_alpha
self.points = [
[
(1 - a) * self.points[0][0] + a * pts[0][0],
(1 - a) * self.points[0][1] + a * pts[0][1],
],
[
(1 - a) * self.points[1][0] + a * pts[1][0],
(1 - a) * self.points[1][1] + a * pts[1][1],
],
]
self.radii = [
(1 - a) * self.radii[0] + a * rad[0],
(1 - a) * self.radii[1] + a * rad[1],
]
self.miss = 0
else:
self.points = pts
self.radii = rad
self.miss = 0
else:
self.miss += 1
if self.miss > self.hold_frames:
self.points = None
self.radii = None
if self.points is None or self.radii is None:
# 彻底丢失:只保留当前帧绘制(可能仅有 bbox)
out = dict(result)
out["hole_points"] = []
out["hole_radii"] = []
return out
dist = float(
np.hypot(
self.points[0][0] - self.points[1][0],
self.points[0][1] - self.points[1][1],
)
)
bbox_draw = self.bbox
if bbox_draw is None and result.get("bbox") is not None:
b = result["bbox"]
bbox_draw = (int(b[0]), int(b[1]), int(b[2]), int(b[3]))
if bbox_draw is None:
# 无框时仍画孔
draw_img = image.copy()
_draw_holes(draw_img, self.points, self.radii)
else:
gate = None
if bool(self.cfg.get("center_gate_draw", True)):
ih, iw = image.shape[:2]
max_dx = float(self.cfg.get("center_max_offset_ratio_x", 0.12)) * iw
max_dy = float(self.cfg.get("center_max_offset_ratio_y", 0.12)) * ih
gate = (max_dx, max_dy)
draw_img = _render_success(
image,
bbox_draw,
self.points,
self.radii,
dist,
gate=gate,
label=str(result.get("label") or ""),
)
held = self.miss > 0
return {
"status": True,
"msg": (
f"检测成功,两孔距离 {dist:.1f}px"
+ (f"(稳帧 hold={self.miss})" if held else "")
),
"hole_points": [[p[0], p[1]] for p in self.points],
"hole_radii": [float(self.radii[0]), float(self.radii[1])],
"bbox": list(bbox_draw) if bbox_draw is not None else result.get("bbox"),
"draw_img": draw_img,
}
except Exception as exc:
fallback = image.copy() if isinstance(image, np.ndarray) and image.size else np.zeros((480, 640, 3), dtype=np.uint8)
return _fail(f"稳帧异常: {exc}", fallback)
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""在当前机械臂的 D405 彩色话题上做入孔检测。只检测,不做插入动作。"""
from __future__ import annotations
import os
import numpy as np
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import Image
from std_msgs.msg import Float32MultiArray
from arm_application.handeye_realsense import resolve_realsense_topics
from arm_application.hole_detect_hough import HoleStabilizer
from arm_application.hole_detector import (
HoleDetection,
HoleDetectResult,
HoleDetector,
config_from_site,
weights_from_site,
)
from arm_application.site_io import load_site_dict
from arm_msgs.msg import HoleDetection as HoleDetectionMsg
from arm_msgs.msg import HoleDetections
from arm_application.ros_node import TolerantNode
def _as_bool(val, default: bool = False) -> bool:
if val is None:
return default
if isinstance(val, str):
return val.strip().lower() in ("1", "true", "yes", "on")
return bool(val)
def _bgr_copy(msg: Image) -> np.ndarray:
h, w = int(msg.height), int(msg.width)
enc = (msg.encoding or "").lower()
buf = np.frombuffer(msg.data, dtype=np.uint8)
if enc == "bgr8":
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}")
def _bgr_to_msg(image: np.ndarray, stamp, frame_id: str) -> Image:
msg = Image()
msg.header.stamp = stamp
msg.header.frame_id = frame_id
msg.height = int(image.shape[0])
msg.width = int(image.shape[1])
msg.encoding = "bgr8"
msg.step = int(image.shape[1] * 3)
msg.data = image.tobytes()
return msg
class HoleDetectNode(TolerantNode):
def __init__(self) -> None:
super().__init__("hole_detect")
self.declare_parameter("camera_source", os.environ.get("SITE_CAM_NAME") or "d405")
self.declare_parameter("image_topic", "")
self.declare_parameter("weights", "")
self.declare_parameter("config_file", "")
self.declare_parameter("backend", "two_stage")
self.declare_parameter("device", os.environ.get("SITE_YOLO_DEVICE") or "0")
self.declare_parameter("preview", False)
site = load_site_dict()
cam = str(self.get_parameter("camera_source").value or "d405")
image_topic, _info = resolve_realsense_topics(cam, image_topic=str(self.get_parameter("image_topic").value or ""))
cfg = config_from_site(site)
param_backend = str(self.get_parameter("backend").value or "").strip().lower()
if param_backend:
cfg["backend"] = param_backend
backend = str(cfg.get("backend") or "two_stage")
weights = str(self.get_parameter("weights").value or "").strip() or weights_from_site(site)
self._preview = _as_bool(self.get_parameter("preview").value, False)
self._detector = HoleDetector(
model_path=weights or None,
config=cfg,
device=str(self.get_parameter("device").value or "0"),
)
self._stabilizer = (
HoleStabilizer(cfg) if backend in ("two_stage", "hough_roi") else None
)
self._pub = self.create_publisher(HoleDetections, "/hole_detect/result", 10)
self._arr_pub = self.create_publisher(Float32MultiArray, "/hole_detect/holes", 10)
self._overlay_pub = self.create_publisher(Image, "/hole_detect/overlay", 10)
self.create_subscription(Image, image_topic, self._on_image, qos_profile_sensor_data)
self.get_logger().info(
f"hole_detect camera={cam} topic={image_topic} backend={backend} "
f"weights={self._detector.model_path} site overlay=hole_detect"
)
def _on_image(self, msg: Image) -> None:
try:
bgr = _bgr_copy(msg)
except Exception as exc:
self.get_logger().error(str(exc))
return
result = self._detector.detect(bgr)
if self._stabilizer is not None:
result = self._stabilize(bgr, result)
out = HoleDetections()
out.header = msg.header
out.ok = bool(result.status)
out.message = result.msg
for hole in result.holes:
hmsg = HoleDetectionMsg()
hmsg.u = float(hole.center_px[0])
hmsg.v = float(hole.center_px[1])
hmsg.radius_px = float(hole.radius_px)
hmsg.confidence = float(hole.confidence)
hmsg.label = hole.label or ""
if hole.bbox:
hmsg.bbox = [int(v) for v in hole.bbox]
out.holes.append(hmsg)
self._pub.publish(out)
arr = Float32MultiArray()
data = [1.0 if result.status else 0.0, float(len(result.holes))]
for hole in result.holes:
data.extend([hole.center_px[0], hole.center_px[1], hole.radius_px, hole.confidence])
arr.data = data
self._arr_pub.publish(arr)
overlay = result.draw_img if result.draw_img is not None else bgr
self._overlay_pub.publish(_bgr_to_msg(overlay, msg.header.stamp, msg.header.frame_id))
if self._preview:
import cv2
cv2.imshow("hole_detect", overlay)
cv2.waitKey(1)
def _stabilize(self, bgr, result: HoleDetectResult) -> HoleDetectResult:
raw = result.raw if isinstance(result.raw, dict) else {}
payload = {
"status": result.status,
"msg": result.msg,
"hole_points": raw.get("hole_points")
or [list(h.center_px) for h in result.holes],
"hole_radii": raw.get("hole_radii") or [h.radius_px for h in result.holes],
"bbox": raw.get("bbox") or (list(result.holes[0].bbox) if result.holes and result.holes[0].bbox else None),
"draw_img": result.draw_img,
"gate_block": bool(raw.get("gate_block")),
}
stab = self._stabilizer.update(bgr, payload)
holes: list[HoleDetection] = []
radii = stab.get("hole_radii") or []
bbox = stab.get("bbox")
bbox_t = tuple(int(v) for v in bbox) if bbox and len(bbox) == 4 else None
for i, pt in enumerate(stab.get("hole_points") or []):
rr = float(radii[i]) if i < len(radii) else 0.0
holes.append(
HoleDetection(
center_px=(float(pt[0]), float(pt[1])),
radius_px=rr,
confidence=1.0,
bbox=bbox_t,
label="left" if i == 0 else "right" if i == 1 else f"h{i + 1}",
)
)
return HoleDetectResult(
bool(stab.get("status")),
str(stab.get("msg") or ""),
holes,
stab.get("draw_img", result.draw_img),
raw=stab,
)
def main() -> None:
rclpy.init()
node = HoleDetectNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,368 @@
"""孔定位模块:加载已训练模型,输出孔中心像素坐标与置信度。
与工件定位(thread_gauge)完全分离,可独立替换模型与参数。
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import cv2
import numpy as np
import yaml
_PKG_SHARE_FALLBACK = Path(__file__).resolve().parents[1] / "config"
DEFAULT_CONFIG = _PKG_SHARE_FALLBACK / "hole_insert.yaml"
# 仓库里**实际存在**的孔/工件检测模型,相对仓库根。
# 以前这个位置上写的是 ``models/hole_insert/best.pt`` —— 文件根本不存在,属于悬空引用:
# 配置齐全时永远走不到这条分支,一旦 site.yaml 缺键就报"模型文件不存在",
# 而打印出来的路径看着特别合理,很容易被误判成"文件被人误删了"。
# 改动这个常量时,顺手跑 ``bash scripts/weights.sh doctor`` 确认没有悬空引用。
_CANONICAL_HOLE_MODEL = "models/yolo/gongjian0929.onnx"
@dataclass
class HoleDetection:
"""单孔检测结果。"""
center_px: tuple[float, float]
radius_px: float
confidence: float
bbox: tuple[int, int, int, int] | None = None
cls_id: int = 0
label: str = "" # left / right
@dataclass
class HoleDetectResult:
"""孔定位整体结果。"""
status: bool
msg: str
holes: list[HoleDetection]
draw_img: np.ndarray
raw: dict[str, Any] | None = None
def load_hole_config(path: str | Path | None = None) -> dict[str, Any]:
cfg_path = Path(path) if path else DEFAULT_CONFIG
with open(cfg_path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise ValueError(f"孔定位配置格式错误: {cfg_path}")
return data
_HOLE_SITE_SKIP = {"weights", "workpiece_weights", "config"}
def config_from_site(data: dict[str, Any] | None = None) -> dict[str, Any]:
"""先取包内 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()
hd = dict(site.get("hole_detect") or {})
backend = str(hd.get("backend") or "two_stage").strip().lower()
cfg_path = str(hd.get("config") or "").strip()
if not cfg_path:
root = ros2_root()
if backend in ("two_stage", "hough_roi"):
cfg_path = str(root / "src" / "arm_application" / "config" / "hole_detect.yaml")
else:
cfg_path = str(root / "src" / "arm_application" / "config" / "hole_insert.yaml")
base = load_hole_config(cfg_path) if Path(cfg_path).is_file() else {}
overlay = {k: v for k, v in hd.items() if k not in _HOLE_SITE_SKIP and v != ""}
merged = deep_merge(base, overlay)
merged["backend"] = backend
return merged
def weights_from_site(data: dict[str, Any] | None = None) -> str:
from arm_application.site_io import load_site_dict, ros2_root
site = data if data is not None else load_site_dict()
hd = site.get("hole_detect") or {}
paths = site.get("paths") or {}
backend = str(hd.get("backend") or "two_stage").strip().lower()
if backend in ("two_stage", "hough_roi"):
return str(
hd.get("workpiece_weights")
or paths.get("hole_workpiece_weights")
or paths.get("yolo_weights")
or ""
)
return str(
hd.get("weights")
or paths.get("hole_weights")
or str(ros2_root() / _CANONICAL_HOLE_MODEL)
)
def _default_model_path() -> Path:
"""未显式给 ``model_path`` 时的兜底模型路径。
以前这里枚举了 ``hole_insert/best.pt`` / ``hole_best.pt`` / ``yolo/detect_best.pt``,
三个文件在本仓库里**都不存在**,函数却会 ``return candidates[0]`` ——
于是永远返回一个不存在的路径(同 ``weights_from_site`` 里那个悬空的 best.pt)。
现在以 site.yaml 为准(它才是真正的来源),它给的路径存在就用它;
否则退到仓库里实际存在的规范模型。这样报错信息里的路径至少是对的。
"""
configured = weights_from_site()
if configured:
p = Path(configured).expanduser()
if p.is_file():
return p
from arm_application.site_io import ros2_root
return ros2_root() / _CANONICAL_HOLE_MODEL
class HoleDetector:
"""孔定位检测器:支持 YOLO / HoughCircles 双模式,可配置切换。"""
def __init__(
self,
model_path: str | Path | None = None,
config: dict[str, Any] | None = None,
device: str | int = 0,
) -> None:
self.cfg = config or load_hole_config()
self.device = device
self.model_path = Path(model_path) if model_path else _default_model_path()
self._detector = None
self._backend = self.cfg.get("backend", "yolo").lower()
def _load_yolo(self):
if self._detector is not None:
return self._detector
from arm_application.yolo_detector import build_detector, resolve_weights
weights = resolve_weights(
self.model_path,
backend=str(self.cfg.get("yolo_backend") or "auto"),
)
# build_detector 会自己识别 pose 模型(k0/k1/k2),不必在这里分支。
self._detector = build_detector(
weights,
conf=float(self.cfg.get("conf", 0.25)),
iou=float(self.cfg.get("iou", 0.7)),
device=str(self.device),
imgsz=int(self.cfg.get("imgsz", 640)),
)
return self._detector
def detect(self, image_bgr: np.ndarray) -> HoleDetectResult:
"""检测图像中的孔,返回像素坐标列表。"""
if image_bgr is None or image_bgr.size == 0:
return HoleDetectResult(False, "输入图像无效", [], np.zeros((480, 640, 3), dtype=np.uint8))
draw_img = image_bgr.copy()
backend = self._backend
if backend in {"yolo", "faster_rcnn"}:
return self._detect_by_model(image_bgr, draw_img)
if backend in {"two_stage", "hough_roi"}:
return self._detect_two_stage(image_bgr, draw_img)
if backend == "hough":
return self._detect_by_hough(image_bgr, draw_img)
return HoleDetectResult(False, f"未知 backend: {backend}", [], draw_img)
def _detect_two_stage(self, image: np.ndarray, draw_img: np.ndarray) -> HoleDetectResult:
from arm_application.hole_detect_hough import detect_holes
try:
detector = self._load_yolo()
except Exception as exc:
return HoleDetectResult(False, f"模型加载失败: {exc}", [], draw_img)
raw = detect_holes(image, detector, config=self.cfg, device=self.device)
holes: list[HoleDetection] = []
points = raw.get("hole_points") or []
radii = raw.get("hole_radii") or []
bbox = raw.get("bbox")
bbox_t = tuple(int(v) for v in bbox) if bbox and len(bbox) == 4 else None
for i, pt in enumerate(points):
r = float(radii[i]) if i < len(radii) else 0.0
label = "left" if i == 0 else "right" if i == 1 else f"h{i + 1}"
holes.append(
HoleDetection(
center_px=(float(pt[0]), float(pt[1])),
radius_px=r,
confidence=1.0,
bbox=bbox_t,
label=label,
)
)
draw = raw.get("draw_img", draw_img)
return HoleDetectResult(bool(raw.get("status")), str(raw.get("msg") or ""), holes, draw, raw=raw)
def _detect_by_model(self, image: np.ndarray, draw_img: np.ndarray) -> HoleDetectResult:
try:
detector = self._load_yolo()
except Exception as exc:
return HoleDetectResult(False, f"模型加载失败: {exc}", [], draw_img)
conf = float(self.cfg.get("conf", 0.25))
iou = float(self.cfg.get("iou", 0.7))
imgsz = int(self.cfg.get("imgsz", 640))
dets = detector.detect(image, conf=conf, iou=iou, imgsz=imgsz)
if not dets:
return HoleDetectResult(False, "未检测到孔", [], draw_img)
holes: list[HoleDetection] = []
for d in dets:
x1, y1, x2, y2 = [int(v) for v in d.xyxy]
cx = 0.5 * (x1 + x2)
cy = 0.5 * (y1 + y2)
r = 0.5 * max(x2 - x1, y2 - y1)
holes.append(
HoleDetection(
center_px=(float(cx), float(cy)),
radius_px=float(r),
confidence=float(d.conf),
bbox=(x1, y1, x2, y2),
cls_id=int(getattr(d, "cls_id", 0)),
)
)
return _finalize_holes(holes, draw_img, raw={"dets": dets}, cfg=self.cfg)
def _detect_by_hough(self, image: np.ndarray, draw_img: np.ndarray) -> HoleDetectResult:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
ksize = int(self.cfg.get("gaussian_ksize", 5))
if ksize % 2 == 0:
ksize += 1
blur = cv2.GaussianBlur(gray, (ksize, ksize), float(self.cfg.get("gaussian_sigma", 1.5)))
circles = cv2.HoughCircles(
blur,
cv2.HOUGH_GRADIENT,
dp=float(self.cfg.get("hough_dp", 1.2)),
minDist=float(self.cfg.get("hough_min_dist", 40)),
param1=float(self.cfg.get("hough_param1", 100)),
param2=float(self.cfg.get("hough_param2", 28)),
minRadius=int(self.cfg.get("hough_min_radius", 6)),
maxRadius=int(self.cfg.get("hough_max_radius", 45)),
)
if circles is None:
return HoleDetectResult(False, "HoughCircles 未检测到孔", [], draw_img)
holes: list[HoleDetection] = []
for c in np.asarray(circles[0], dtype=np.float64):
cx, cy, r = float(c[0]), float(c[1]), float(c[2])
holes.append(HoleDetection(center_px=(cx, cy), radius_px=r, confidence=1.0))
return _finalize_holes(holes, draw_img, cfg=self.cfg)
def pixel_to_camera(
self,
hole: HoleDetection,
depth_m: float | None = None,
intrinsics: dict[str, float] | None = None,
) -> np.ndarray:
depth = float(depth_m if depth_m is not None else self.cfg.get("default_depth_m", 0.10))
intr = intrinsics or self.cfg.get("camera_intrinsics") or {}
return pixel_to_camera(hole, depth, intr)
def pair_two_holes(holes: list[HoleDetection], cfg: dict[str, Any] | None = None) -> list[HoleDetection]:
"""取左右两孔:优先最高置信度,再按图像 x 排序并打 left/right 标签。"""
cfg = cfg or {}
expected = int(cfg.get("expected_holes", 2))
if expected != 2:
ordered = sorted(holes, key=lambda h: h.center_px[0])
for i, hole in enumerate(ordered):
hole.label = f"h{i + 1}"
return ordered
if len(holes) < 2:
return holes
picked = sorted(holes, key=lambda h: h.confidence, reverse=True)
if len(picked) > 2:
by_x = sorted(picked, key=lambda h: h.center_px[0])
picked = [by_x[0], by_x[-1]]
else:
picked = picked[:2]
picked.sort(key=lambda h: h.center_px[0])
dist = float(
np.hypot(
picked[1].center_px[0] - picked[0].center_px[0],
picked[1].center_px[1] - picked[0].center_px[1],
)
)
min_sep = float(cfg.get("min_hole_sep_px", 20.0))
max_sep = float(cfg.get("max_hole_sep_px", 2000.0))
if not (min_sep <= dist <= max_sep):
return []
picked[0].label = "left"
picked[1].label = "right"
return picked
def _draw_holes(draw_img: np.ndarray, holes: list[HoleDetection]) -> None:
for hole in holes:
cx, cy = int(round(hole.center_px[0])), int(round(hole.center_px[1]))
rr = max(4, int(round(hole.radius_px)))
cv2.circle(draw_img, (cx, cy), rr, (0, 0, 255), 2)
cv2.drawMarker(draw_img, (cx, cy), (0, 255, 255), cv2.MARKER_CROSS, 16, 2)
cv2.circle(draw_img, (cx, cy), 3, (0, 255, 255), -1)
name = hole.label or "hole"
cv2.putText(
draw_img,
f"{name} ({cx},{cy}) {hole.confidence:.2f}",
(cx + 12, cy - 8),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
(0, 0, 255),
2,
)
def _finalize_holes(
holes: list[HoleDetection],
draw_img: np.ndarray,
raw: dict[str, Any] | None = None,
cfg: dict[str, Any] | None = None,
) -> HoleDetectResult:
cfg = cfg or {}
paired = pair_two_holes(holes, cfg)
if len(holes) >= 2 and not paired:
_draw_holes(draw_img, holes)
return HoleDetectResult(False, "两孔间距不在允许范围", holes, draw_img, raw=raw)
if not paired:
_draw_holes(draw_img, holes)
return HoleDetectResult(False, f"有效孔数量={len(holes)},需要 2 个", holes, draw_img, raw=raw)
_draw_holes(draw_img, paired)
dist = float(
np.hypot(
paired[1].center_px[0] - paired[0].center_px[0],
paired[1].center_px[1] - paired[0].center_px[1],
)
)
msg = f"检测到双孔 left={paired[0].center_px} right={paired[1].center_px} dist={dist:.1f}px"
return HoleDetectResult(True, msg, paired, draw_img, raw=raw)
def pixel_to_camera(
hole: HoleDetection,
depth_m: float,
intrinsics: dict[str, float],
) -> np.ndarray:
"""像素坐标 + 深度 -> 相机系三维坐标(m)。"""
fx = float(intrinsics["fx"])
fy = float(intrinsics["fy"])
cx = float(intrinsics["cx"])
cy = float(intrinsics["cy"])
u, v = hole.center_px
z = float(depth_m)
x = (u - cx) * z / fx
y = (v - cy) * z / fy
return np.array([x, y, z], dtype=float)
@@ -0,0 +1,149 @@
"""把 numpy 数组打包成 ``sensor_msgs/Image``。
为什么需要
----------
Orisys 视触觉传感器的数据分两类:
* **三力**(``fnormal`` / ``fshearx`` / ``fsheary``)—— 每帧几个标量,走子进程
队列几乎没有开销。
* **图像类**(形变图 / 深度图 / 力矢量场)—— 每帧几百 KB 到几 MB。原先
``tactile_worker`` 为了性能**故意不过队列**,于是这些数据从未离开子进程、
也从未发布到 ROS 话题上。
本模块只负责最后一公里:把已经拿到的数组正确打包成 ``Image``。放在单独文件
里是为了能脱离相机单测 —— 编码、``step``、通道顺序这些细节一旦写错,下游
拿到的图会静默错位(图像撕裂、颜色通道互换),而不是报错。
支持的形状与编码
----------------
=============== ============== ==========================================
形状 dtype 编码
=============== ============== ==========================================
``(H, W)`` ``uint8`` ``mono8``
``(H, W)`` ``uint16`` ``mono16``
``(H, W)`` ``float32`` ``32FC1``
``(H, W, 1)`` 任意 ``mono8``
``(H, W, 2)`` ``float32`` ``32FC2``(力矢量场:每像素 dx, dy)
``(H, W, 3)`` ``uint8`` ``rgb8``
``(H, W, 4)`` ``uint8`` ``rgba8``
=============== ============== ==========================================
2 通道的 ``32FC2`` 是 ROS 里表达矢量场的标准做法,``step = W * 4 * 2``。
"""
from __future__ import annotations
from typing import Optional, Tuple
import numpy as np
from sensor_msgs.msg import Image
__all__ = ["ndarray_to_image", "infer_encoding", "UnsupportedImageError"]
class UnsupportedImageError(ValueError):
"""数组的形状/类型没有对应的 ROS 编码。
宁可显式报错也不要猜 —— 猜错的结果是下游拿到一张静默错位的图。
错误消息里带上实际形状与 dtype,方便对照本文档的表格。
"""
#: (通道数, dtype) -> ROS 编码
_ENCODING_BY_CHANNELS = {
(1, np.dtype(np.uint8)): "mono8",
(1, np.dtype(np.uint16)): "mono16",
(1, np.dtype(np.float32)): "32FC1",
(2, np.dtype(np.float32)): "32FC2",
(3, np.dtype(np.uint8)): "rgb8",
(4, np.dtype(np.uint8)): "rgba8",
}
def infer_encoding(array: np.ndarray) -> str:
"""推断 ROS 编码。推不出来时抛 :class:`UnsupportedImageError`。"""
arr = np.asarray(array)
if arr.ndim == 2:
channels = 1
elif arr.ndim == 3:
channels = arr.shape[2]
else:
raise UnsupportedImageError(
f"只支持 2D(灰度)或 3D(H,W,C)数组,收到 {arr.ndim}D,形状 {arr.shape}"
)
key = (channels, arr.dtype)
encoding = _ENCODING_BY_CHANNELS.get(key)
if encoding is None:
supported = ", ".join(
f"{enc}({c}通道/{d.name})"
for (c, d), enc in sorted(_ENCODING_BY_CHANNELS.items(), key=lambda kv: kv[1])
)
raise UnsupportedImageError(
f"没有 {channels} 通道 + {arr.dtype.name} 对应的 ROS 编码。"
f"支持: {supported}。"
f"(收到形状 {arr.shape})"
)
return encoding
def _layout(array: np.ndarray) -> Tuple[int, int, int]:
"""返回 ``(height, width, channels)``,并把 (H,W,1) 归一化成 2D 语义。"""
arr = np.asarray(array)
if arr.ndim == 2:
h, w = arr.shape
return int(h), int(w), 1
h, w, c = arr.shape
return int(h), int(w), int(c)
def ndarray_to_image(
array: np.ndarray,
*,
frame_id: str = "",
stamp=None,
encoding: Optional[str] = None,
) -> Image:
"""把数组打包成 ``sensor_msgs/Image``。
参数
----
array:
图像或矢量场。必须是 C 连续(``np.ascontiguousarray`` 会自动处理)。
encoding:
显式指定编码以覆盖推断。仅在确实知道自己在做什么时使用 ——
编码与 dtype 不匹配会让下游按错误的元素大小解读 ``data``。
"""
arr = np.asarray(array)
if encoding is None:
encoding = infer_encoding(arr)
height, width, channels = _layout(arr)
# (H,W,1) 在内存里与 (H,W) 连续一致,压平即可。
flat = np.ascontiguousarray(arr).reshape(-1)
msg = Image()
if stamp is not None:
msg.header.stamp = stamp
msg.header.frame_id = frame_id
msg.height = height
msg.width = width
msg.encoding = encoding
msg.is_bigendian = False
msg.step = int(width * channels * flat.dtype.itemsize)
msg.data = flat.tobytes()
return msg
def expected_step(array: np.ndarray, encoding: Optional[str] = None) -> int:
"""算出应当写入 ``Image.step`` 的值。测试与自检用。"""
arr = np.asarray(array)
_, width, channels = _layout(arr)
if encoding is not None:
channels = {"mono8": 1, "mono16": 1, "32FC1": 1, "32FC2": 2,
"rgb8": 3, "rgba8": 4}.get(encoding, channels)
itemsize = {"mono8": 1, "mono16": 2, "32FC1": 4, "32FC2": 4,
"rgb8": 1, "rgba8": 1}.get(encoding, arr.dtype.itemsize)
return int(width * channels * itemsize)
return int(width * channels * arr.dtype.itemsize)
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""三维点的匀速模型卡尔曼滤波(坐标系为相机系)。"""
from __future__ import annotations
from typing import Optional
import numpy as np
class KalmanFilter3D:
"""状态量为 ``[x, y, z, vx, vy, vz]``;观测只有位置,没有速度。"""
def __init__(
self,
*,
q_pos: float = 1e-4,
q_vel: float = 5e-3,
r_pos: float = 4e-4,
) -> None:
self.q_pos = float(q_pos)
self.q_vel = float(q_vel)
self.r_pos = float(r_pos)
self.x = np.zeros(6, dtype=float)
self.P = np.eye(6, dtype=float)
self._t: Optional[float] = None
self.initialized = False
def reset(self, p: np.ndarray, t: Optional[float] = None) -> None:
p = np.asarray(p, dtype=float).reshape(3)
self.x[:] = 0.0
self.x[:3] = p
self.P = np.diag([1e-3, 1e-3, 1e-3, 1e-2, 1e-2, 1e-2])
self._t = t
self.initialized = True
@property
def position(self) -> np.ndarray:
return self.x[:3].copy()
@property
def velocity(self) -> np.ndarray:
return self.x[3:].copy()
def advance(self, t: float) -> None:
"""把状态预测到时刻 ``t``(秒,必须单调递增)。
超过 0.5 秒的空档按"目标可能已经跑掉"处理:位置不动、速度清零,
否则旧速度会把位置外推得很离谱。
"""
if not self.initialized or self._t is None:
self._t = t
return
dt = float(t - self._t)
if dt <= 1e-6:
return
if dt > 0.5:
# 空档太长——位置保持不动,速度清零。
self.x[3:] = 0.0
self._t = t
return
self._predict(dt)
self._t = t
def zero_velocity(self) -> None:
"""把速度状态清零、位置保持不动。
用途:观测疑似假跳变(见 ``feature_jump_guard.FeatureJumpGate``)时本次不
采信观测,但**不能**让上一次的速度继续把位置外推出去 —— 否则"不动"会变成
"按旧速度匀速跑掉",刚好是我们要避免的那件事。
"""
self.x[3:] = 0.0
def update(self, z: np.ndarray) -> None:
"""用一次三维位置观测做量测更新。"""
z = np.asarray(z, dtype=float).reshape(3)
if not self.initialized:
self.reset(z)
return
H = np.zeros((3, 6), dtype=float)
H[0, 0] = H[1, 1] = H[2, 2] = 1.0
R = np.eye(3, dtype=float) * self.r_pos
y = z - H @ self.x
S = H @ self.P @ H.T + R
K = self.P @ H.T @ np.linalg.inv(S)
self.x = self.x + K @ y
I = np.eye(6)
self.P = (I - K @ H) @ self.P
def _predict(self, dt: float) -> None:
F = np.eye(6, dtype=float)
F[0, 3] = F[1, 4] = F[2, 5] = dt
q_p = self.q_pos
q_v = self.q_vel
Q = np.diag([q_p, q_p, q_p, q_v, q_v, q_v])
self.x = F @ self.x
self.P = F @ self.P @ F.T + Q
@@ -0,0 +1,558 @@
"""模型类别标签(class names)的统一读写。
## 为什么需要这个模块
推理代码需要把模型输出的**类别索引**(整数 0/1/2…)翻译成**人能看懂的名字**。
这件事原先没有统一做法,导致同一个模型在不同后端下名字都不一样:
- `.pt`:从 Ultralytics 的 `model.names` 读(来源是 checkpoint 内嵌 metadata)
- `.onnx`:ONNX 文件里有 `names` metadata,但代码没用,而是**硬编码** `{0: "workpiece"}`
- `.engine`:**标签直接丢了**
最后一条是本模块存在的主要原因。TensorRT engine 要用 `trt.OnnxParser` 重新构建,
而**当前安装的 TensorRT(10.13)Python API 根本没有 metadata 读写接口** ——
`INetworkDefinition` / `IBuilderConfig` / `ICudaEngine` 上都没有任何 metadata 方法。
所以 engine 构建完就只剩张量,标签无从附着。实测:
detect_best_0.engine 二进制里搜不到 "workpiece" → 标签已丢
detect_hc90.engine 能搜到 "workpiece" → 旧版 TRT 建的,但当前环境已无法加载
(`detect_hc90` 已作为零引用模型移出仓库,这里保留这行是因为它同时印证了两件事:
旧版 TRT 建的 engine 里其实是有标签的,以及 engine 与 TRT 版本绑死。)
后果很隐蔽:`yolo_detector` 读不到名字时会退化成 `str(cid)`,也就是类名变成字符串
`"0"`;而预览界面又不绘制类名,所以**没有任何地方看得出标签丢了**。
## 解决办法:旁挂标签文件
在每个权重旁边放一个同名文本文件 `<模型名>.labels.yaml`:
models/yolo/detect_best_0.onnx
models/yolo/detect_best_0.engine
models/yolo/detect_best_0.labels.yaml ← 就是这个
选文本文件而不是想办法塞进 engine,理由有二:
1. **可修改**。标签是配置,不是模型的一部分。塞进 engine 就只能重建 engine 才能改。
2. **跨格式统一**。engine / onnx / pt 三种格式共用同一份标签,不会出现
"换后端名字就变了"。engine 丢标签的问题也一并绕过了。
标签文件是纯文本,进 git(体积小、属于配置);权重二进制不进 git。
## 读取优先级
sidecar(旁挂文件) > onnx(ONNX metadata) > model(模型自身 metadata) > placeholder
`read_labels()` 会连**来源**一起返回。这点很重要:调用方可以据此提示
"标签来自占位符,请补一个 .labels.yaml",而不是悄悄给出一个错的类名。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple, Union
# 标签文件的扩展名。刻意用双后缀:既能被 *.yaml 工具识别,
# 又能和模型自身的配置(如果有)区分开。
LABELS_SUFFIX = ".labels.yaml"
# read_labels() 的来源标记。顺序即优先级(高 → 低)。
SOURCE_SIDECAR = "sidecar" # 旁挂 <模型名>.labels.yaml(权威,用户可直接改)
SOURCE_ONNX = "onnx" # ONNX 文件内嵌 metadata
SOURCE_MODEL = "model" # 模型自身 metadata(Ultralytics 的 .names)
SOURCE_PLACEHOLDER = "placeholder" # 都没有 → 占位符 class_0/class_1…
SOURCE_LABELS_ZH = {
SOURCE_SIDECAR: "旁挂标签文件",
SOURCE_ONNX: "ONNX 内嵌 metadata",
SOURCE_MODEL: "模型内嵌 metadata",
SOURCE_PLACEHOLDER: "占位符(缺标签)",
}
@dataclass(frozen=True)
class LabelSet:
"""一次标签读取的结果。
``names`` 是索引 → 名字;``source`` 说明它从哪来(见 SOURCE_* 常量)。
调用方**应该**检查 ``source``:如果是 ``placeholder``,说明这个模型缺标签,
此时给出的是 ``class_0`` 这样的占位名,只适合占位显示,不该当成真名使用。
"""
names: Dict[int, str]
source: str
path: Optional[Path] = None # 实际读到的文件(placeholder 时为 None)
@property
def is_placeholder(self) -> bool:
return self.source == SOURCE_PLACEHOLDER
def get(self, cid: int, default: Optional[str] = None) -> str:
if cid in self.names:
return self.names[cid]
return default if default is not None else f"class_{cid}"
def sorted_names(self) -> List[str]:
return [self.names[k] for k in sorted(self.names)]
# ---------------------------------------------------------------------------
# 路径
# ---------------------------------------------------------------------------
def labels_path_for(weights: Union[str, Path]) -> Path:
"""给出权重对应的旁挂标签文件路径。
只换后缀,目录不变:``models/yolo/detect_best_0.engine`` →
``models/yolo/detect_best_0.labels.yaml``。
这样无论 ``resolve_weights()`` 最后选中了 .pt/.onnx/.engine 中的哪一个,
算出来的标签路径都相同 —— 一份标签服务同一个模型的所有格式。
"""
p = Path(weights).expanduser()
# 用 stem 手工拼接,而不是 with_suffix('.labels.yaml'):
# 后者对 `a.b.onnx` 这类含点文件名容易踩坑,手工拼没有歧义。
return p.parent / (p.stem + LABELS_SUFFIX)
# ---------------------------------------------------------------------------
# 解析:把各种形状的标签定义统一成 {索引: 名字}
# ---------------------------------------------------------------------------
def _clean(text: object) -> str:
# None 必须先拦掉:str(None) == "None" 是 truthy 的,
# 直接 str() 会让空项变成一个字面量为 "None" 的类名。
if text is None:
return ""
return str(text).strip()
def _try_parse_structured(text: str) -> Optional[Dict[int, str]]:
"""尝试把字符串当成 dict/list 的字面量解析。
为什么需要:ONNX metadata 里的 ``names`` 存的**不是** JSON,也不是逗号列表,
而是 Python 字典的 repr(单引号):
"{0: 'workpiece'}"
Ultralytics 是直接 ``str(names)`` 写进去的,所以 ``json.loads`` 会失败。
两种都试一遍(JSON 标准、literal_eval 兜底),都不行才交给逗号解析。
``ast.literal_eval`` 只解析字面量、不执行代码,是安全的。
"""
s = text.strip()
if not s:
return None
import ast
import json
for loader in (json.loads, ast.literal_eval):
try:
obj = loader(s)
except Exception: # noqa: BLE001
continue
if isinstance(obj, (dict, list, tuple)):
return parse_names_field(obj)
return None
def _parse_freeform_text(text: str) -> Dict[int, str]:
"""解析"自由文本"形式的标签内容。
两种情况要分开,否则会互相吃掉:
- 一行一个名字(有人把 ``classes.txt`` 直接改名成 ``.labels.yaml``):
``"a\\nb"`` → ``{0:'a', 1:'b'}``
- 单行逗号串:``"a, b"`` → ``{0:'a', 1:'b'}``
判据是"有没有多行"。只有一行时才按逗号切,
否则 ``"a, b"`` 这种文件名里带逗号的写法会被切错。
"""
items = [
ln.strip()
for ln in text.splitlines()
if ln.strip() and not ln.strip().startswith("#")
]
if len(items) > 1:
return {i: n for i, n in enumerate(items)}
return parse_label_list(text)
def parse_names_field(value: object) -> Dict[int, str]:
"""把 YAML/metadata 里的 ``names`` 字段规范成 ``{int: str}``。
容忍三种写法,因为它们各自都很常见:
- 字典(Ultralytics data.yaml 的写法):``{0: workpiece, 1: hole}``
- 列表(本模块写出的规范形式,索引即顺序):``[workpiece, hole]``
- 逗号分隔的字符串:``"workpiece, hole"``
跳过空项但**保留其索引位置**,这样 ``[a, "", c]`` 不会让 c 变成 1 号。
无法解析成整数键的字典项会被忽略(不抛异常 —— 标签读不出来不该让程序崩)。
"""
if value is None:
return {}
if isinstance(value, dict):
out: Dict[int, str] = {}
for k, v in value.items():
try:
idx = int(k)
except (TypeError, ValueError):
continue
name = _clean(v)
if name:
out[idx] = name
return out
if isinstance(value, (list, tuple)):
out = {}
for i, v in enumerate(value):
name = _clean(v)
if name:
out[i] = name
return out
if isinstance(value, str):
# 字符串可能是三种东西:dict/list 的字面量(ONNX metadata)、
# 逗号列表、或者多行文本。先试结构化的,再退到逗号/多行解析。
structured = _try_parse_structured(value)
if structured is not None:
return structured
return _parse_freeform_text(value)
return {}
def parse_label_list(text: str) -> Dict[int, str]:
"""解析逗号(或中文逗号、分号)分隔的类别名。
``"workpiece, hole"`` → ``{0: 'workpiece', 1: 'hole'}``
同时兼容 ``"0:workpiece, 1:hole"`` 这种带显式索引的写法,
因为用户从别处拷配置过来时经常带着索引。空项被跳过。
"""
if not text or not text.strip():
return {}
parts = [p for p in re.split(r"[,,;;\n]", text)]
out: Dict[int, str] = {}
next_idx = 0
for raw in parts:
item = raw.strip()
if not item:
continue
m = re.match(r"^\s*(\d+)\s*[::]\s*(.+)$", item)
if m:
idx = int(m.group(1))
name = m.group(2).strip()
if name:
out[idx] = name
next_idx = max(next_idx, idx + 1)
continue
out[next_idx] = item
next_idx += 1
return out
def read_label_text_file(path: Union[str, Path]) -> Dict[int, str]:
"""读纯文本标签文件:一行一个类名(YOLO 的 classes.txt 就是这个格式)。
空行和 ``#`` 开头的注释行被忽略,且**不占索引**。
这样用户可以在文件里随意加注释而不破坏索引对应关系。
"""
p = Path(path).expanduser()
out: Dict[int, str] = {}
idx = 0
with p.open("r", encoding="utf-8") as f:
for line in f:
s = line.strip()
if not s or s.startswith("#"):
continue
out[idx] = s
idx += 1
return out
def placeholder_labels(nc: int) -> Dict[int, str]:
"""没有标签可用时的占位名。
刻意用 ``class_0`` 而不是直接回退成字符串形式的数字 ``"0"``:
后者和"真的有一个叫 0 的类"无法区分,正是原先难以发现问题所在。
``class_`` 前缀一眼就能看出是没配标签。
"""
n = max(0, int(nc))
return {i: f"class_{i}" for i in range(n)}
# ---------------------------------------------------------------------------
# 旁挂标签文件读写
# ---------------------------------------------------------------------------
def format_labels_yaml(names: Dict[int, str], model: Optional[str] = None) -> str:
"""生成旁挂标签文件的内容(带使用说明的注释头)。
注释头是刻意保留的:这个文件会被不熟悉的人直接打开编辑,
把"第几行 = 第几类"写在文件里比写在文档里有效得多。
"""
lines: List[str] = []
lines.append("# 模型类别标签(class names)")
lines.append("#")
lines.append("# 一个模型对应一份标签,放在权重旁边、同名前缀:")
lines.append("# detect_best_0.engine ← 权重")
lines.append("# detect_best_0.labels.yaml ← 本文件")
lines.append("#")
lines.append("# 写法:names 下面一行一个类名,第 1 行对应类别 0,第 2 行对应类别 1,依次类推。")
lines.append("# 改完保存即生效,不需要重新转换模型、也不用重启环境(重跑节点即可)。")
lines.append("#")
lines.append("# 命令行查看/修改(推荐,不用手改本文件):")
lines.append("# bash ~/ros2/scripts/weights.sh labels <模型名>")
lines.append("# bash ~/ros2/scripts/weights.sh labels <模型名> --set \"工件,通止规\"")
lines.append("")
if model:
# 记下所属模型,便于发现"标签文件被拷给了另一个模型"这种错配。
lines.append(f"model: {model}")
lines.append("names:")
if names:
for k in sorted(names):
lines.append(f" - {names[k]}")
else:
lines.append(" []")
return "\n".join(lines) + "\n"
def write_labels(
weights: Union[str, Path],
names: Dict[int, str],
*,
path: Optional[Union[str, Path]] = None,
) -> Path:
"""把标签写到权重旁边的旁挂文件,返回写入路径。"""
dst = Path(path).expanduser() if path else labels_path_for(weights)
dst.parent.mkdir(parents=True, exist_ok=True)
model = Path(weights).stem
dst.write_text(format_labels_yaml(names, model=model), encoding="utf-8")
return dst
def read_label_file(path: Union[str, Path]) -> Optional[Dict[int, str]]:
"""读旁挂标签文件。文件不存在或内容为空返回 None(不抛异常)。"""
p = Path(path).expanduser()
if not p.is_file():
return None
try:
raw = p.read_text(encoding="utf-8")
except Exception: # noqa: BLE001
return None
try:
import yaml
data = yaml.safe_load(raw)
except Exception: # noqa: BLE001
# 文件存在但读不动(语法错、编码错、权限)。返回 None 让调用方按
# "没有标签"处理,而不是让整个推理进程崩掉 —— 标签缺失不该是致命的。
return None
if isinstance(data, dict):
# 规范形式:names: [...]
names = parse_names_field(data.get("names"))
if names:
return names
# 容忍 {0: 'a', 1: 'b'} 这种"顶层直接就是索引字典"的写法(Ultralytics data.yaml)
names = {
int(k): str(v).strip()
for k, v in data.items()
if str(k).lstrip("-").isdigit() and str(v).strip()
}
# 是合法 YAML 字典却读不出类别名 → 就是没有标签。
# 这里**不能**再回落到"按行读文件",否则会把 `names: []` 里的
# 'names:' 这几个字当成类名。
return names or None
if isinstance(data, (list, tuple)):
return parse_names_field(data) or None
if isinstance(data, str):
# 整个文件就是一个标量 —— 可能是"改名过来的 classes.txt"。
# 注意必须用**原始文本**:YAML 会把多行纯标量折成一行
# ("a\nb\n" → "a b"),用 data 的话换行已经没了,
# "一行一个类名"会被当成一个带空格的长名字。
return _parse_freeform_text(raw) or None
# 空文件 / 只有注释 / 其它标量类型:按行读(能正确处理注释行)。
try:
return read_label_text_file(p) or None
except Exception: # noqa: BLE001
return None
def label_file_model(path: Union[str, Path]) -> Optional[str]:
"""读旁挂标签文件里记录的 ``model:`` 字段(用于发现标签错配)。"""
p = Path(path).expanduser()
if not p.is_file():
return None
try:
import yaml
data = yaml.safe_load(p.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
return None
if isinstance(data, dict):
v = data.get("model")
return _clean(v) if v else None
return None
# ---------------------------------------------------------------------------
# 从模型自身读标签
# ---------------------------------------------------------------------------
def onnx_metadata(path: Union[str, Path]) -> Dict[str, str]:
"""读 ONNX 的 ``metadata_props``(全是字符串),读不到返回空字典。
用 ``load_external_data=False``:只读图结构不读权重,对 38MB 的模型也是毫秒级。
Ultralytics 导出时会写入 ``names`` / ``imgsz`` / ``nc`` / ``task`` 等。
"""
p = Path(path).expanduser()
if not p.is_file():
return {}
try:
import onnx
m = onnx.load(str(p), load_external_data=False)
except Exception: # noqa: BLE001
return {}
return {x.key: x.value for x in m.metadata_props}
def labels_from_onnx(path: Union[str, Path]) -> Optional[Tuple[Dict[int, str], int]]:
"""从 ONNX 的 ``metadata_props`` 读 ``names`` 与类别数 ``nc``。
返回 ``(names, nc)``;读不到返回 None。
注意 ``names`` 在 ONNX 里存的是 **Python 字典的 repr 字符串**(单引号),
形如 ``"{0: 'workpiece'}"`` —— 不是 JSON。解析细节见 ``parse_names_field``。
"""
meta = onnx_metadata(path)
if not meta:
return None
names = parse_names_field(meta.get("names"))
nc = 0
raw_nc = meta.get("nc")
if raw_nc is not None:
try:
nc = int(str(raw_nc).strip())
except (TypeError, ValueError):
nc = 0
if not nc and names:
nc = max(names) + 1
return (names, nc) if names else None
def labels_from_model(path: Union[str, Path]) -> Optional[Tuple[Dict[int, str], int]]:
"""从模型自身 metadata 读标签(走 Ultralytics,主要给 .pt 用)。
对 .engine / .onnx 也能用(Ultralytics 会自己反序列化),但代价高:
要加载整个模型。所以 ``read_labels()`` 只在必要时才调它。
Ultralytics 的 model 对象除了 ``names`` 还有 ``nc``;拿不到 nc 时用 names 推断。
"""
p = Path(path).expanduser()
if not p.is_file():
return None
try:
from ultralytics import YOLO
model = YOLO(str(p), task="detect")
raw = getattr(model, "names", None) or {}
names = parse_names_field(raw)
nc = 0
for attr in ("nc",):
try:
nc = int(getattr(model, attr) or 0)
except Exception: # noqa: BLE001
nc = 0
if not nc and names:
nc = max(names) + 1
return (names, nc) if names else None
except Exception: # noqa: BLE001
return None
# ---------------------------------------------------------------------------
# 统一入口
# ---------------------------------------------------------------------------
def read_labels(
weights: Union[str, Path],
*,
allow_model_metadata: bool = True,
nc_hint: int = 0,
) -> LabelSet:
"""按优先级读出一个模型该用的标签。
优先级:旁挂文件 > ONNX metadata > 模型自身 metadata > 占位符。
旁挂优先是刻意的:它是用户能直接编辑的那一份,必须覆盖模型内嵌的旧值,
否则"我明明改了标签为什么不生效"就会变成一个很难查的问题。
绝不抛异常。读不到就返回占位符并把原因记在 ``source`` 里,
由调用方决定是记一条警告还是直接显示。
"""
p = Path(weights).expanduser()
side = labels_path_for(p)
names = read_label_file(side)
if names:
return LabelSet(names=names, source=SOURCE_SIDECAR, path=side)
suffix = p.suffix.lower()
# .pt 的标签只可能来自模型自身;.onnx 先看内嵌 metadata(快);
# .engine / .trt 没有可用 metadata(见模块头说明),直接落到占位符或模型加载。
if suffix == ".onnx":
got = labels_from_onnx(p)
if got:
return LabelSet(names=got[0], source=SOURCE_ONNX, path=p)
elif suffix in (".pt", ".pth"):
got = labels_from_model(p)
if got:
return LabelSet(names=got[0], source=SOURCE_MODEL, path=p)
if allow_model_metadata and suffix in (".engine", ".trt"):
# engine 一般读不到(当前 TRT 没 metadata API),但也别直接放弃:
# 若用户是用 Ultralytics 导出的 engine,metadata 可能在。
got = labels_from_model(p)
if got:
return LabelSet(names=got[0], source=SOURCE_MODEL, path=p)
return LabelSet(names=placeholder_labels(nc_hint), source=SOURCE_PLACEHOLDER, path=None)
def describe(labels: LabelSet) -> str:
"""一句人能看懂的说明,用于日志:``workpiece (来源: 旁挂标签文件)``。"""
names = ", ".join(labels.sorted_names()) or "(空)"
src = SOURCE_LABELS_ZH.get(labels.source, labels.source)
return f"{names} (来源: {src})"
def label_file_status(
weights: Union[str, Path],
) -> Tuple[bool, Optional[Path], Optional[str]]:
"""检查旁挂标签文件状态,供 doctor 用。
返回 ``(存在, 路径, 提示)``;``提示`` 为 None 表示没有问题。
"""
p = Path(weights).expanduser()
side = labels_path_for(p)
if not side.is_file():
return False, side, f"缺标签文件:{side.name}"
recorded = label_file_model(side)
if recorded and Path(weights).stem != recorded:
return True, side, (
f"标签文件记录的 model 是 '{recorded}',但当前权重是 "
f"'{Path(weights).stem}' —— 可能是拷错了标签文件"
)
return True, side, None
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""``arm_msgs/action/MoveToPose``(笛卡尔目标)的示例客户端。
等你有了 MoveIt 或自己的 IK 求解 action server 后,把它接上去即可。
"""
from __future__ import annotations
import sys
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from geometry_msgs.msg import PoseStamped
from arm_msgs.action import MoveToPose
from arm_application.ros_node import TolerantNode
class MoveToPoseClient(TolerantNode):
def __init__(self) -> None:
super().__init__('move_to_pose_client')
self._client = ActionClient(self, MoveToPose, 'move_to_pose')
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 不可用。'
'请启动一个实现了它的笛卡尔规划器(例如 MoveIt)。'
)
return
goal = MoveToPose.Goal()
goal.target_pose = PoseStamped()
goal.target_pose.header.frame_id = 'base_link'
goal.target_pose.header.stamp = self.get_clock().now().to_msg()
goal.target_pose.pose.position.x = x
goal.target_pose.pose.position.y = y
goal.target_pose.pose.position.z = z
# 单位姿态(不旋转)
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'发送笛卡尔目标:({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('目标被拒绝')
return
result_future = handle.get_result_async()
rclpy.spin_until_future_complete(self, result_future)
result = result_future.result().result
self.get_logger().info(f'success={result.success} msg={result.message}')
def main(args=None) -> None:
rclpy.init(args=args)
node = MoveToPoseClient()
# 默认演示位姿;可通过命令行覆盖: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])]
node.send(*vals)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Direct ONNX Runtime inference for Ultralytics end2end detect models.
为什么不走 ultralytics.YOLO:它对 .onnx 会按发行包名检查 onnxruntime,
环境装的是 onnxruntime-gpu 时会触发 pip AutoUpdate(可能卡死/破坏环境)。
本模块用 onnxruntime 直接推理,显式 CPU provider,一次性拍照场景足够快。
模型要求:Ultralytics 导出时 end2end=True(NMS 已融合),
输出形状 [1, N, 6] = x1, y1, x2, y2, conf, cls(letterbox 坐标系)。
detect_best_1.onnx 满足(metadata: end2end=True, nms=False)。
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import List, Optional, Union
import numpy as np
from arm_application.model_labels import (
SOURCE_MODEL,
LabelSet,
parse_names_field,
read_labels,
)
from arm_application.yolo_detector import Detection
logger = logging.getLogger(__name__)
class OnnxWorkpieceDetector:
"""给 detect_holes() 用的最小接口:detect(image, conf, iou, imgsz)。"""
def __init__(
self,
weights: Union[str, Path],
*,
conf: float = 0.25,
imgsz: int = 640,
class_names: Optional[dict] = None,
) -> None:
import onnxruntime as ort
path = Path(weights).expanduser()
if not path.is_file():
raise FileNotFoundError(f"ONNX weights not found: {path}")
so = ort.SessionOptions()
so.log_severity_level = 3 # 关掉 ORT 刷屏告警
self._sess = ort.InferenceSession(
str(path), sess_options=so, providers=["CPUExecutionProvider"]
)
inp = self._sess.get_inputs()[0]
self._iname = inp.name
shape = [d if isinstance(d, int) and d > 0 else imgsz for d in inp.shape]
self._in_h, self._in_w = int(shape[-2]), int(shape[-1])
# 只认 ultralytics end2end 检测输出 ``[1, N, 6]``,否则**当场报错**。
# 为什么必须在这里硬失败:2026-10-08 真机,把一个 ``task=pose`` 的模型
# (输出 ``[1, 4+nc+3K, 8400]``)从这条分支解出来,得到的是 12 个垃圾框
# ——conf 直接是坐标值(最高 70.87)、类名 ``class_73``、退化框 ``y1=y2=0``。
# 而流水线照跑不误,只在二阶段"看不出工件"上体现出来,排查代价极大。
# 宁可启动就失败(元规则:静默失效是头号敌人)。
outs = self._sess.get_outputs()
tail = outs[0].shape[-1] if (outs and outs[0].shape) else None
if isinstance(tail, int) and tail != 6:
raise RuntimeError(
f"{path.name} 的检测输出最后一维是 {tail},不是 6 —— 它不是"
" ultralytics end2end 检测模型([1,N,6])。带 k0/k1/k2 关键点的"
" pose 模型必须交给 pose_detector / build_detector,否则坐标会被"
"当成置信度解出垃圾框,而且不会报错。"
)
self.conf = float(conf)
self.imgsz = int(imgsz)
# 类别名:显式传入 > 旁挂标签文件 > ONNX 内嵌 metadata > 占位符。
# 原先这里是硬编码 {0: "workpiece"},等于把类名写死在代码里 ——
# 换一个多类模型就会展示错误的名字,而且没人改得动。
if class_names:
names = parse_names_field(class_names)
self._labels = LabelSet(names=names, source=SOURCE_MODEL, path=path)
else:
self._labels = read_labels(path, allow_model_metadata=False)
if self._labels.is_placeholder:
logger.warning(
"ONNX %s 没有类别标签,将用占位名显示。"
"补法: bash ~/ros2/scripts/weights.sh labels %s --from-model",
path.name,
path.stem,
)
# 兼容旧属性名,避免外部代码引用时报 AttributeError。
self._names = dict(self._labels.names)
def _letterbox(self, img: np.ndarray):
h, w = img.shape[:2]
scale = min(self._in_w / w, self._in_h / h)
nw, nh = int(round(w * scale)), int(round(h * scale))
import cv2
resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR)
canvas = np.full((self._in_h, self._in_w, 3), 114, dtype=np.uint8)
top = (self._in_h - nh) // 2
left = (self._in_w - nw) // 2
canvas[top : top + nh, left : left + nw] = resized
return canvas, scale, left, top
def detect(
self,
image_bgr: np.ndarray,
conf: Optional[float] = None,
iou: Optional[float] = None, # end2end 模型已内置 NMS,忽略
imgsz: Optional[int] = None, # 输入尺寸由模型决定,忽略
**_kwargs,
) -> List[Detection]:
del iou, imgsz
thr = float(conf if conf is not None else self.conf)
canvas, scale, left, top = self._letterbox(image_bgr)
blob = canvas[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0
out = self._sess.run(None, {self._iname: blob})[0] # [1, N, 6]
dets: List[Detection] = []
if out is None or out.size == 0:
return dets
arr = np.asarray(out).reshape(-1, out.shape[-1])
h, w = image_bgr.shape[:2]
for row in arr:
if row.shape[0] < 6:
continue
x1, y1, x2, y2, score, cls = [float(v) for v in row[:6]]
if score < thr:
continue
# letterbox 坐标 → 原图坐标
xa = (x1 - left) / scale
ya = (y1 - top) / scale
xb = (x2 - left) / scale
yb = (y2 - top) / scale
xyxy = np.array(
[
float(np.clip(xa, 0, w - 1)),
float(np.clip(ya, 0, h - 1)),
float(np.clip(xb, 0, w - 1)),
float(np.clip(yb, 0, h - 1)),
]
)
cid = int(cls)
dets.append(
Detection(
xyxy=xyxy,
conf=float(score),
cls_id=cid,
cls_name=self._labels.get(cid),
)
)
dets.sort(key=lambda d: d.conf, reverse=True)
return dets
@@ -0,0 +1,226 @@
"""工位程序里的相机预览。
只订彩色和已对齐深度,不订左右红外。画面留在本进程,不另开查看器窗口。
"""
from __future__ import annotations
import subprocess
import threading
import time
import numpy as np
_LOCK = threading.Lock()
_STOP = threading.Event()
_THREAD: threading.Thread | None = None
_JPEG: dict[str, bytes] = {}
_STATE = {"on": False, "error": "", "frames": {}, "stamp": 0.0}
def targets() -> list[dict]:
"""真机腕部相机和手外相机。仿真相机不进预览。"""
from arm_application.operator_console import _arms, _site
from arm_application.site_io import overhead_camera
found: list[dict] = []
seen: set[str] = set()
for arm in _arms():
name = str(arm.get("camera") or "").strip().strip("/")
if not name or name.startswith("sim") or name in seen:
continue
seen.add(name)
found.append({"id": name, "label": f"{arm.get('label') or name} 腕部"})
cam, _others = overhead_camera(_site(), None)
name = str((cam or {}).get("name") or "").strip().strip("/")
if name and not name.startswith("sim") and name not in seen:
found.append({"id": name, "label": str((cam or {}).get("label") or "手外相机")})
return found
def state() -> dict:
with _LOCK:
return {
"on": _STATE["on"],
"error": _STATE["error"],
"stamp": _STATE["stamp"],
"frames": dict(_STATE["frames"]),
}
def frame(name: str, kind: str) -> bytes | None:
with _LOCK:
return _JPEG.get(f"{name}_{kind}")
def _close_external_viewers() -> None:
"""关掉独立的四窗格查看器。它会把左右红外挂出来,本程序不再用它。"""
subprocess.run(["pkill", "-f", "lib/arm_application/realsense_view"], check=False)
subprocess.run(["pkill", "-f", "arm_application.realsense_view"], check=False)
def _jpeg(img: np.ndarray) -> bytes | None:
import cv2
if img.size == 0:
return None
view = img
if view.shape[1] > 640:
scale = 640.0 / float(view.shape[1])
view = cv2.resize(view, (640, max(1, int(view.shape[0] * scale))))
ok, buf = cv2.imencode(".jpg", view, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
if not ok:
return None
return buf.tobytes()
def _depth_bgr(depth: np.ndarray) -> np.ndarray:
import cv2
vis = np.zeros((depth.shape[0], depth.shape[1], 3), np.uint8)
valid = depth > 0
if not bool(valid.any()):
return vis
values = depth.astype(np.float32)
lo, hi = np.percentile(values[valid], [5, 95])
hi = max(float(hi), float(lo) + 1.0)
gray = (np.clip((values - lo) / (hi - lo), 0.0, 1.0) * 255.0).astype(np.uint8)
gray[~valid] = 0
colored = cv2.applyColorMap(gray, cv2.COLORMAP_JET)
colored[~valid] = 0
return colored
def _color_array(msg) -> np.ndarray | None:
if msg.encoding not in ("bgr8", "rgb8") or msg.height <= 0 or msg.width <= 0:
return None
arr = np.frombuffer(bytes(msg.data), dtype=np.uint8)
need = int(msg.height) * int(msg.width) * 3
if arr.size < need:
return None
arr = arr[:need].reshape(int(msg.height), int(msg.width), 3)
if msg.encoding == "rgb8":
arr = arr[:, :, ::-1]
return np.ascontiguousarray(arr)
def _depth_array(msg) -> np.ndarray | None:
if msg.encoding not in ("16UC1", "mono16") or msg.height <= 0 or msg.width <= 0:
return None
arr = np.frombuffer(bytes(msg.data), dtype=np.uint16)
need = int(msg.height) * int(msg.width)
if arr.size < need:
return None
return np.ascontiguousarray(arr[:need].reshape(int(msg.height), int(msg.width)))
def _loop(cams: list[dict]) -> None:
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import Image
latest: dict[str, np.ndarray] = {}
lock = threading.Lock()
class _Node(Node):
def __init__(self) -> None:
super().__init__("operator_camera_view")
for cam in cams:
name = cam["id"]
self.create_subscription(
Image,
f"/{name}/color/image_raw",
self._color(name),
qos_profile_sensor_data,
)
self.create_subscription(
Image,
f"/{name}/aligned_depth_to_color/image_raw",
self._depth(name),
qos_profile_sensor_data,
)
def _color(self, name: str):
def _cb(msg: Image) -> None:
arr = _color_array(msg)
if arr is None:
return
with lock:
latest[f"{name}_color"] = arr
return _cb
def _depth(self, name: str):
def _cb(msg: Image) -> None:
arr = _depth_array(msg)
if arr is None:
return
with lock:
latest[f"{name}_depth"] = arr
return _cb
rclpy.init()
node = _Node()
next_encode = 0.0
try:
while not _STOP.is_set():
rclpy.spin_once(node, timeout_sec=0.05)
now = time.monotonic()
if now < next_encode:
continue
next_encode = now + 0.12
with lock:
snap = dict(latest)
latest.clear()
frames = {}
blobs = {}
for key, arr in snap.items():
name, _, kind = key.rpartition("_")
img = _depth_bgr(arr) if kind == "depth" else arr
blob = _jpeg(img)
if not blob:
continue
blobs[key] = blob
frames[key] = True
if blobs:
with _LOCK:
_JPEG.update(blobs)
_STATE["frames"].update(frames)
_STATE["stamp"] = time.time()
_STATE["error"] = ""
except Exception as exc:
with _LOCK:
_STATE["error"] = str(exc)
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
with _LOCK:
_STATE["on"] = False
def start() -> str:
global _THREAD
cams = targets()
if not cams:
with _LOCK:
_STATE["error"] = "site 里没有可预览的真机相机"
return _STATE["error"]
_close_external_viewers()
with _LOCK:
if _STATE["on"]:
return "相机预览已经在程序里"
_STATE["on"] = True
_STATE["error"] = ""
_STOP.clear()
_THREAD = threading.Thread(target=_loop, args=(cams,), daemon=True, name="cam-view")
_THREAD.start()
names = "、".join(cam["label"] for cam in cams)
return f"预览订了 {names} 的彩色和深度,不含左右红外。"
def stop() -> None:
_STOP.set()
thread = _THREAD
if thread is not None:
thread.join(timeout=1.5)
@@ -0,0 +1,858 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ORISYS 通止规工位</title>
<style>
:root {
--bg: #070708;
--panel: #101216;
--panel-2: #171a20;
--line: #2a2e36;
--text: #f3f4f6;
--muted: #8b929d;
--blue: #12315d;
--blue-2: #1c4f98;
--ok: #3f9d6e;
--warn: #c8963e;
--bad: #c45c5c;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); }
body {
font-family: "Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Segoe UI", sans-serif;
display: grid;
grid-template-columns: 220px 1fr;
grid-template-rows: 72px 1fr;
height: 100vh;
overflow: hidden;
}
header {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 16px;
padding: 0 18px;
border-bottom: 1px solid var(--line);
background: #0b0c0e;
}
header img { height: 46px; width: auto; }
header .name { display: flex; flex-direction: column; gap: 2px; }
header .name b { font-size: 15px; font-weight: 560; letter-spacing: 0.08em; }
header .name span { color: var(--muted); font-size: 12px; }
header .grow { flex: 1; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.arms { display: flex; gap: 6px; }
.arms button, .btn {
border-radius: 8px;
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--text);
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
}
.arms button.on { background: var(--blue); border-color: #1a4580; }
.btn.primary { background: var(--blue-2); border-color: #2a62b0; }
.btn.on { background: var(--blue); border-color: #1a4580; }
.btn.danger { background: #6e2a2a; border-color: #8a3a3a; }
.clock { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; min-width: 72px; text-align: right; }
aside {
border-right: 1px solid var(--line);
background: #0b0c0e;
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
nav { display: flex; flex-direction: column; gap: 6px; }
nav button {
display: block;
width: 100%;
text-align: left;
background: transparent;
color: var(--text);
border: 1px solid transparent;
border-radius: 8px;
padding: 11px 12px;
font-size: 14px;
cursor: pointer;
}
nav button small { display: block; color: var(--muted); font-size: 11px; margin-top: 3px; }
nav button.active { background: var(--blue); border-color: #1a4580; }
nav button.active small { color: #c5d4ea; }
nav button:hover { border-color: var(--line); }
.side-note { margin-top: auto; color: var(--muted); font-size: 12px; line-height: 1.55; padding: 0 4px; }
main { min-width: 0; min-height: 0; overflow: auto; }
section { display: none; height: 100%; padding: 16px 18px; }
section.on { display: flex; flex-direction: column; gap: 12px; }
h2 { margin: 0; font-size: 18px; font-weight: 560; }
.lead { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
.dash {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1.35fr 0.95fr;
grid-template-rows: minmax(0, 1fr) 168px;
gap: 12px;
}
.devices, .mini-forces { display: grid; gap: 12px; min-height: 0; }
.devices { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
.mini-forces { grid-template-rows: 1fr 1fr; }
.flow { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 14px;
min-height: 0;
}
.card h3 {
margin: 0 0 8px;
font-size: 12px;
color: #9aa3b2;
font-weight: 500;
letter-spacing: 0.12em;
}
.stat { font-size: 32px; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
.sub { color: var(--muted); font-size: 12px; margin-top: 6px; line-height: 1.5; white-space: pre-line; }
.pill {
display: inline-block;
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
border: 1px solid var(--line);
margin-right: 6px;
}
.pill.ok { color: var(--ok); border-color: #245c40; }
.pill.warn { color: var(--warn); border-color: #6d5424; }
.pill.bad { color: var(--bad); border-color: #6d3030; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; flex: 1; min-height: 0; }
.split.three { grid-template-columns: 1fr 1fr 1fr; }
.instrument { display: flex; flex-direction: column; gap: 10px; height: 100%; }
.readout { display: flex; gap: 18px; }
.readout div { min-width: 88px; }
.readout em { display: block; color: var(--muted); font-style: normal; font-size: 12px; }
.readout strong { font-size: 28px; font-weight: 520; font-variant-numeric: tabular-nums; }
.meter { display: grid; grid-template-columns: 52px 1fr 72px; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
.track { height: 8px; background: #1c212b; border-radius: 99px; overflow: hidden; }
.track i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, #12315d, #4c86d4); }
.finder {
flex: 1;
min-height: 180px;
border: 1px solid #243044;
border-radius: 10px;
background:
linear-gradient(#4c86d4, #4c86d4) left top / 18px 2px no-repeat,
linear-gradient(#4c86d4, #4c86d4) left top / 2px 18px no-repeat,
linear-gradient(#4c86d4, #4c86d4) right top / 18px 2px no-repeat,
linear-gradient(#4c86d4, #4c86d4) right top / 2px 18px no-repeat,
linear-gradient(#4c86d4, #4c86d4) left bottom / 18px 2px no-repeat,
linear-gradient(#4c86d4, #4c86d4) left bottom / 2px 18px no-repeat,
linear-gradient(#4c86d4, #4c86d4) right bottom / 18px 2px no-repeat,
linear-gradient(#4c86d4, #4c86d4) right bottom / 2px 18px no-repeat,
#0c0e12;
display: flex;
align-items: flex-end;
padding: 14px;
color: var(--muted);
font-size: 13px;
}
.rail { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
.step { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px; min-height: 78px; }
.step b { display: block; color: #7f93b5; font-size: 12px; letter-spacing: 0.08em; }
.step span { display: block; margin-top: 8px; font-size: 14px; }
.ops { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.pair img:not([src]) { visibility: hidden; }
.pair img {
width: 100%;
height: 200px;
object-fit: contain;
background: #0c0e12;
border-radius: 8px;
border: 1px solid #243044;
}
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
canvas.chart { width: 100%; height: 210px; background: #0c0e12; border-radius: 8px; display: block; }
.legend { display: flex; gap: 14px; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.legend i { display: inline-block; width: 18px; height: 3px; margin-right: 6px; vertical-align: middle; }
pre.out {
margin: 0;
min-height: 140px;
max-height: 260px;
overflow: auto;
background: #0c0e12;
border-radius: 8px;
padding: 10px;
color: #c5d4ea;
font-size: 12px;
white-space: pre-wrap;
}
dialog {
border: 1px solid var(--line);
background: #12141a;
color: var(--text);
border-radius: 12px;
padding: 18px;
width: min(480px, calc(100vw - 32px));
}
dialog::backdrop { background: rgba(0, 0, 0, 0.55); }
dialog h3 { margin: 0; }
dialog code {
display: block;
margin: 12px 0;
padding: 10px;
background: #0b0c0e;
border-radius: 8px;
color: #c5d4ea;
font-size: 12px;
white-space: pre-wrap;
}
.toast {
position: fixed; right: 18px; bottom: 18px;
background: #151922; border: 1px solid var(--line);
border-radius: 10px; padding: 12px 14px; max-width: 420px;
display: none;
}
@media (max-width: 1100px) {
body { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr; height: auto; overflow: auto; }
header { flex-wrap: wrap; height: auto; padding: 12px; }
.dash, .split, .split.three, .ops, .rail, .flow { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<header>
<img src="/logo.png" alt="ORISYS 叠动科技" />
<div class="name">
<b>通止规工位</b>
<span id="arm-line">左臂 89</span>
</div>
<div class="grow"></div>
<div class="chips" id="chips"></div>
<div class="arms" id="arms"></div>
<div class="clock" id="clock">--:--:--</div>
</header>
<aside>
<nav>
<button class="active" data-page="overview">总览<small>设备与流程</small></button>
<button data-page="tactile">视触觉<small>切向、热成像、曲线</small></button>
<button data-page="handeye">手眼<small>眼在手上 / 手外</small></button>
<button data-page="camera">相机<small>D405 与 D435</small></button>
<button data-page="gripper">夹爪<small>平行爪与旋转爪</small></button>
<button data-page="assembly">装配<small>S00 到 S11</small></button>
<button data-page="operate">操作<small>启动与确认</small></button>
</nav>
<div class="side-note">读的是本机 USB、进程和话题。动臂、张合夹爪都要再确认一次。</div>
</aside>
<main>
<section id="overview" class="on">
<div>
<h2>现场</h2>
<p class="lead" id="arm-lead"></p>
</div>
<div class="dash">
<div class="devices" id="usb-cards"></div>
<div class="mini-forces" id="mini-forces"></div>
<div class="flow" id="flow"></div>
</div>
</section>
<section id="tactile">
<div>
<h2>视触觉</h2>
<p class="lead">切向力是 SDK 的剪切场箭头,热成像是接触散度的伪彩。下面两条曲线是法向、切向 X、切向 Y,同一时间轴。单位是形变读数,不是牛顿。</p>
</div>
<div class="row">
<button class="btn primary" id="view-on" type="button">开始成像</button>
<button class="btn" id="view-off" type="button">停止成像</button>
<button class="btn" data-act="tactile_up">启动三力节点</button>
<button class="btn" data-act="tactile_down">停止触觉节点</button>
</div>
<p class="sub" id="view-note"></p>
<div class="split" id="views"></div>
<div class="charts">
<div class="card">
<h3>左指</h3>
<div class="legend"><span><i style="background:#e8e8e8"></i>法向</span><span><i style="background:#4c86d4"></i>切向 X</span><span><i style="background:#d7a15a"></i>切向 Y</span></div>
<canvas class="chart" id="chart-left"></canvas>
</div>
<div class="card">
<h3>右指</h3>
<div class="legend"><span><i style="background:#e8e8e8"></i>法向</span><span><i style="background:#4c86d4"></i>切向 X</span><span><i style="background:#d7a15a"></i>切向 Y</span></div>
<canvas class="chart" id="chart-right"></canvas>
</div>
</div>
<p class="sub" id="tac-note"></p>
</section>
<section id="handeye">
<div>
<h2>手眼标定</h2>
<p class="lead">眼在手上用腕部 D405 和 A5 板,结果是相机到法兰。眼在手外用顶置 D435 和 A4 板,结果是相机到基座。两份文件不能混用。示教和求解会动臂。</p>
</div>
<div class="row" id="he-modes">
<button class="btn on" type="button" data-mode="eye_in_hand">眼在手上</button>
<button class="btn" type="button" data-mode="eye_to_hand">眼在手外</button>
</div>
<div class="card" id="he-card"></div>
<div class="row">
<button class="btn" data-act="handeye_show">查看配置</button>
<button class="btn danger" data-act="handeye_teach">示教采点</button>
<button class="btn" data-act="handeye_merge">合并示教点</button>
<button class="btn danger" data-act="handeye_calibrate">求解手眼</button>
</div>
<pre class="out" id="he-out">查看配置后,脚本输出显示在这里。</pre>
</section>
<section id="camera">
<div>
<h2>相机</h2>
<p class="lead">腕部 D405 是 848×480 @ 30,手外 D435 是 1280×720 @ 30。预览开在桌面窗口,这一页只报链路,不造画面。</p>
</div>
<div class="split three" id="cams"></div>
<div class="row">
<button class="btn primary" data-act="camera_d405">本臂 D405 预览</button>
<button class="btn" data-act="camera_d435">D435 预览</button>
</div>
</section>
<section id="gripper">
<div>
<h2>夹爪</h2>
<p class="lead" id="grip-lead"></p>
</div>
<div class="split" id="grip-body"></div>
</section>
<section id="assembly">
<div>
<h2>通止规装配</h2>
<p class="lead">顺序与状态机一致。干跑只打印计划。真跑会动臂、会拧,而且这台机器上还没跑过完整真机装配。</p>
</div>
<div class="rail" id="rail"></div>
<div class="row">
<button class="btn" data-act="gauge_dry">干跑</button>
<button class="btn danger" data-act="gauge_run">真跑</button>
</div>
</section>
<section id="operate">
<div>
<h2>操作</h2>
<p class="lead" id="op-lead"></p>
</div>
<div class="ops" id="ops"></div>
</section>
</main>
<dialog id="ask">
<h3 id="ask-title">确认</h3>
<p class="sub" id="ask-text"></p>
<code id="ask-cmd"></code>
<div class="row">
<button class="btn" id="ask-no">取消</button>
<button class="btn primary" id="ask-yes">确认执行</button>
</div>
</dialog>
<div class="toast" id="toast"></div>
<script>
const titles = {overview:"总览", tactile:"视触觉", camera:"相机", assembly:"装配", operate:"操作", gripper:"夹爪", handeye:"手眼"};
const boot = /*BOOT*/null;
let arm = "89";
let catalog = (boot && boot.catalog) || {actions:[], stages:[]};
let status = (boot && boot.status) || null;
let pending = null;
let heMode = "eye_in_hand";
function $(id){ return document.getElementById(id); }
function pill(ok, word, warn){
const s = document.createElement("span");
s.className = "pill " + (ok ? "ok" : warn ? "warn" : "bad");
s.textContent = word;
return s;
}
function toast(text){
const el = $("toast");
el.textContent = text;
el.style.display = "block";
clearTimeout(toast._t);
toast._t = setTimeout(() => { el.style.display = "none"; }, 4200);
}
async function api(path, body){
const res = await fetch(path, body ? {
method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)
} : undefined);
return res.json();
}
function cmdOf(action){
const spec = catalog.actions.find(a => a.id === action);
if (!spec) return action;
const args = (spec.args || []).map(a => a.replace("{arm}", arm).replace("{mode}", heMode)).join(" ");
return "bash scripts/" + ({
real_up:"run_real.sh", camera_d405:"run_camera_d405.sh", camera_d435:"run_camera_d435.sh",
tactile_up:"run_tactile.sh", gripper_up:"run_gripper.sh", gripper_probe:"run_gripper.sh",
gripper_open:"run_gripper.sh", gripper_close:"run_gripper.sh", gripper_rotate:"run_gripper.sh",
pipeline:"run_all.sh", gauge_dry:"run_gauge_assembly.sh", gauge_run:"run_gauge_assembly.sh",
handeye_show:"run_handeye.sh", handeye_teach:"run_handeye.sh",
handeye_merge:"run_handeye.sh", handeye_calibrate:"run_handeye.sh"
}[action] || "scripts") + (args ? " " + args : "");
}
async function fire(action, confirm){
const data = await api("/api/action", {action, arm, confirm: !!confirm, mode: heMode});
toast(data.message || (data.ok ? "已发出" : "失败"));
if (String(action).startsWith("handeye") && $("he-out")) {
$("he-out").textContent = data.detail || data.message || "";
}
}
function ask(action){
const spec = catalog.actions.find(a => a.id === action);
if (action === "gripper_rotate" && arm !== "91"){
toast("旋转只对右臂 91");
return;
}
if (spec && spec.moves){
pending = action;
$("ask-title").textContent = spec.title;
$("ask-text").textContent = spec.blurb + " 当前臂 " + arm + "。";
$("ask-cmd").textContent = cmdOf(action);
$("ask").showModal();
return;
}
fire(action, false);
}
function svc(id){ return (status?.services || []).find(s => s.id === id); }
function meters(card, data){
const keys = [["法向", data.fnormal], ["切向 X", data.fshearx], ["切向 Y", data.fsheary]];
const peak = Math.max(1, ...keys.map(([,v]) => Math.abs(typeof v === "number" ? v : 0)));
keys.forEach(([name, value]) => {
const row = document.createElement("div");
row.className = "meter";
const lab = document.createElement("span");
lab.textContent = name;
const track = document.createElement("div");
track.className = "track";
const bar = document.createElement("i");
const n = typeof value === "number" ? value : null;
bar.style.width = n == null ? "0%" : (Math.abs(n) / peak * 100).toFixed(1) + "%";
track.appendChild(bar);
const num = document.createElement("span");
num.textContent = n == null ? "—" : n.toFixed(1);
row.append(lab, track, num);
card.appendChild(row);
});
}
function forceCard(title, data, tall){
const card = document.createElement("div");
card.className = "card instrument";
const h = document.createElement("h3");
h.textContent = title;
card.appendChild(h);
const has = data && (data.connected !== undefined || data.fnormal !== undefined);
if (tall) {
const row = document.createElement("div");
row.className = "readout";
[["法向", data.fnormal], ["切向 X", data.fshearx], ["切向 Y", data.fsheary], ["fps", data.fps]].forEach(([k,v]) => {
const d = document.createElement("div");
const em = document.createElement("em");
em.textContent = k;
const st = document.createElement("strong");
st.textContent = typeof v === "number" ? (k === "fps" ? v.toFixed(1) : v.toFixed(1)) : "—";
d.append(em, st);
row.appendChild(d);
});
card.appendChild(row);
}
meters(card, data || {});
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = has
? ((data.contact ? "有接触" : "无接触") + (typeof data.fps === "number" ? " · " + data.fps.toFixed(1) + " fps" : "") + (data.connected === false ? " · 未连接" : ""))
: "话题上还没有这一侧";
card.appendChild(sub);
return card;
}
function renderHeader(){
const box = $("arms");
box.replaceChildren();
(status?.arms || [{id:"89", label:"左臂89"}, {id:"91", label:"右臂91"}]).forEach(a => {
const b = document.createElement("button");
b.textContent = a.label;
b.className = a.id === arm ? "on" : "";
b.onclick = () => { arm = a.id; render(); };
box.appendChild(b);
});
const chips = $("chips");
chips.replaceChildren();
(status?.services || []).forEach(s => {
chips.appendChild(pill(s.up, s.label + (s.up ? " 在跑" : " 未开")));
});
const cur = (status?.arms || []).find(a => a.id === arm);
$("arm-line").textContent = cur ? cur.label : ("臂 " + arm);
$("arm-lead").textContent = cur
? cur.label + " · " + (cur.sn || "") + " · 相机 " + (cur.camera || "—") + " · 夹爪 " + (cur.gripper || "—") + (cur.tactile ? " · 本臂有视触觉" : " · 本臂无视触觉")
: "";
$("op-lead").textContent = "当前臂 " + arm + "。红色按钮会动设备,先弹出命令再执行。";
$("grip-lead").textContent = arm === "91"
? "右臂 91 是旋转爪 RGI。张开、闭合、转动都要确认。探测只读状态。"
: "左臂 89 是平行爪 PGEA,串口以现场为准。张开和闭合要确认。探测只读状态。";
}
function renderUsb(){
const box = $("usb-cards");
box.replaceChildren();
const ports = status?.grippers || [];
const rows = [...(status?.usb || []), {id:"tty", label:"夹爪串口", expect:1, items: ports.map(p => ({port:p, speed:""}))}];
rows.forEach(dev => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h3");
h.textContent = dev.label;
const n = document.createElement("div");
n.className = "stat";
n.textContent = dev.id === "tty" ? String(dev.items.length) : (dev.items.length + " / " + dev.expect);
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = dev.items.length
? dev.items.map(i => i.speed ? (i.port + " · " + i.speed + " Mbps") : i.port).join("\n")
: "未检测到";
const ok = dev.id === "tty" ? dev.items.length > 0 : dev.items.length >= dev.expect;
const partial = !ok && dev.items.length > 0;
card.append(h, n, pill(ok, ok ? "齐" : partial ? "缺一" : "缺失", partial), sub);
box.appendChild(card);
});
}
function renderMini(){
const box = $("mini-forces");
const t = status?.tactile || {};
box.replaceChildren(forceCard("左指", t.left, false), forceCard("右指", t.right, false));
}
function renderFlow(){
const box = $("flow");
box.replaceChildren();
const pipe = svc("pipeline");
[["跟踪", "D405 看住工件,锁住悬停"], ["对孔", "拍照找孔,移到孔上方"], ["搜孔轻压", "力闭环入孔,再轻压"]].forEach(([name, desc]) => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h3");
h.textContent = name;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = desc + (pipe && pipe.up ? "\n进程在跑,具体阶段看终端日志。" : "\n流程未启动。");
card.append(h, pill(false, pipe && pipe.up ? "进行中" : "待命", pipe && pipe.up), sub);
box.appendChild(card);
});
}
function renderFingers(){
const t = status?.tactile || {};
const node = svc("tactile");
const live = ["left", "right"].some(side => (t[side] || {}).fnormal !== undefined);
const note = $("tac-note");
if (note) note.textContent = node && !node.up && live && !status?.view?.on
? "正式触觉节点没开。曲线若在走,是话题上另一个发布者。"
: "";
}
function camCard(title, desc, dev){
const card = document.createElement("div");
card.className = "card instrument";
const h = document.createElement("h3");
h.textContent = title;
const frame = document.createElement("div");
frame.className = "finder";
frame.textContent = dev && dev.items.length ? "链路在线 · 预览用下面的按钮挂到桌面" : "未插入";
const sub = document.createElement("div");
sub.className = "sub";
const where = (dev?.items || []).map(i => i.port + " · " + i.speed + " Mbps").join("\n");
sub.textContent = desc + (where ? "\n" + where : "");
card.append(h, frame, pill(!!(dev && dev.items.length), dev && dev.items.length ? dev.items.length + " 台" : "缺失"), sub);
return card;
}
function renderCams(){
const usb = Object.fromEntries((status?.usb || []).map(d => [d.id, d]));
const d405 = usb.d405 || {items:[]};
const d435 = usb.d435 || {items:[]};
const other = d405.items.length >= 2 ? {items: d405.items.slice(1)} : {items: []};
$("cams").replaceChildren(
camCard(arm === "91" ? "右腕 D405" : "左腕 D405", "848×480 @ 30 · 彩色 + 深度", {items: d405.items.slice(0, 1)}),
camCard("另一只腕部", "现场期望两台。缺的那台插上后这里会亮。", other),
camCard("手外 D435", "1280×720 @ 30 · 顶视", d435),
);
}
function renderGripper(){
const ports = status?.grippers || [];
const up = svc("gripper");
const box = $("grip-body");
box.replaceChildren();
[
["89", "左臂平行爪", "PGEA-100-20-W", ["gripper_probe", "gripper_up", "gripper_open", "gripper_close"]],
["91", "右臂旋转爪", "RGI-100-30-O-S-M1", ["gripper_probe", "gripper_up", "gripper_open", "gripper_close", "gripper_rotate"]],
].forEach(([id, title, model, acts]) => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h3");
h.textContent = title;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = model + "\n串口 " + (ports.join(" ") || "没有 ttyUSB") + (up && up.up ? "\n驱动在跑" : "\n驱动未开");
card.append(h, pill(id === arm, id === arm ? "当前臂" : "切换顶栏", id !== arm), sub);
const row = document.createElement("div");
row.className = "row";
acts.forEach(act => {
const spec = catalog.actions.find(a => a.id === act);
const b = document.createElement("button");
b.className = "btn " + (spec && spec.moves ? "danger" : "primary");
b.textContent = spec ? spec.title : act;
b.onclick = () => { if (arm !== id) arm = id; ask(act); render(); };
row.appendChild(b);
});
card.appendChild(row);
box.appendChild(card);
});
}
function renderRail(){
const box = $("rail");
if (box.childElementCount) return;
catalog.stages.forEach(([id, name]) => {
const d = document.createElement("div");
d.className = "step";
const b = document.createElement("b");
b.textContent = id;
const s = document.createElement("span");
s.textContent = name;
d.append(b, s);
box.appendChild(d);
});
}
function renderOps(){
const box = $("ops");
box.replaceChildren();
const skip = new Set(["gripper_open", "gripper_close", "gripper_rotate", "gripper_probe", "handeye_show", "handeye_teach", "handeye_merge", "handeye_calibrate"]);
catalog.actions.filter(spec => !skip.has(spec.id)).forEach(spec => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h3");
h.textContent = spec.title;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = spec.blurb;
const b = document.createElement("button");
b.className = "btn " + (spec.moves ? "danger" : "primary");
b.textContent = spec.moves ? "确认后执行" : "执行";
b.style.marginTop = "10px";
b.onclick = () => ask(spec.id);
card.append(h, sub, b);
box.appendChild(card);
});
const stop = document.createElement("div");
stop.className = "card";
const h = document.createElement("h3");
h.textContent = "停止视触觉";
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = "关掉触觉节点和占着相机的 worker。";
const b = document.createElement("button");
b.className = "btn";
b.textContent = "停止";
b.style.marginTop = "10px";
b.onclick = () => ask("tactile_down");
stop.append(h, sub, b);
box.appendChild(stop);
}
function shot(side, kind, title){
const wrap = document.createElement("div");
const h = document.createElement("div");
h.className = "sub";
h.textContent = title;
const img = document.createElement("img");
img.alt = "";
img.dataset.side = side;
img.dataset.kind = kind;
const ready = status?.view?.frames?.[side + "_" + kind];
if (status?.view?.on && ready) {
img.src = "/api/tactile/frame/" + side + "/" + kind + ".jpg?t=" + Math.floor((status.view.stamp || 0) * 5);
}
wrap.append(h, img);
return wrap;
}
function renderViews(){
const box = $("views");
if (!box.childElementCount) {
[["left", "左指"], ["right", "右指"]].forEach(([side, name]) => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h3");
h.textContent = name;
const pair = document.createElement("div");
pair.className = "pair";
pair.append(shot(side, "shear", "切向力"), shot(side, "thermal", "热成像"));
card.append(h, pair);
box.appendChild(card);
});
}
const view = status?.view || {};
$("view-note").textContent = view.error || view.note || (view.on ? "成像已开,等第一帧。" : "成像没开。点开始成像后这里出切向力和热成像。");
}
function drawChart(canvas, samples){
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth || 360;
const h = canvas.clientHeight || 210;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#0c0e12";
ctx.fillRect(0, 0, w, h);
const rows = (samples || []).filter(p => ["fnormal", "fshearx", "fsheary"].some(k => typeof p[k] === "number"));
if (rows.length < 2) {
ctx.fillStyle = "#8b929d";
ctx.font = "13px sans-serif";
ctx.fillText("还没有足够的采样", 16, 28);
return;
}
const keys = [["fnormal", "#e8e8e8"], ["fshearx", "#4c86d4"], ["fsheary", "#d7a15a"]];
let lo = Infinity, hi = -Infinity;
rows.forEach(p => keys.forEach(([k]) => {
if (typeof p[k] === "number") { lo = Math.min(lo, p[k]); hi = Math.max(hi, p[k]); }
}));
if (!(hi > lo)) { hi = lo + 1; }
const pad = (hi - lo) * 0.08;
lo -= pad; hi += pad;
const t0 = rows[0].t, t1 = rows[rows.length - 1].t;
const span = Math.max(t1 - t0, 1e-3);
const left = 46, right = 10, top = 12, bottom = 22;
ctx.strokeStyle = "#2a2e36";
ctx.beginPath();
const y0 = top + (hi - 0) / (hi - lo) * (h - top - bottom);
if (y0 > top && y0 < h - bottom) {
ctx.moveTo(left, y0);
ctx.lineTo(w - right, y0);
ctx.stroke();
}
keys.forEach(([k, color]) => {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.6;
let started = false;
rows.forEach(p => {
if (typeof p[k] !== "number") return;
const x = left + (p.t - t0) / span * (w - left - right);
const y = top + (hi - p[k]) / (hi - lo) * (h - top - bottom);
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
});
ctx.stroke();
});
ctx.fillStyle = "#8b929d";
ctx.font = "11px sans-serif";
ctx.fillText(hi.toFixed(0), 4, top + 10);
ctx.fillText(lo.toFixed(0), 4, h - bottom);
ctx.fillText((span).toFixed(0) + " 秒", w - 48, h - 6);
}
function mm(v){
if (!Array.isArray(v) || v.length < 3) return "—";
return v.slice(0, 3).map(x => (Number(x) * 1000).toFixed(1) + " mm").join(" ");
}
function renderHandeye(){
document.querySelectorAll("#he-modes button").forEach(b => b.classList.toggle("on", b.dataset.mode === heMode));
const card = (status?.handeye || {})[arm]?.[heMode];
const box = $("he-card");
if (!box) return;
box.replaceChildren();
if (!card) {
box.textContent = "没有这一臂的标定记录";
return;
}
const h = document.createElement("h3");
h.textContent = heMode === "eye_in_hand" ? "眼在手上" : "眼在手外";
const sub = document.createElement("div");
sub.className = "sub";
const board = card.board || {};
sub.textContent = [
card.exists ? "已有结果" : "还没有结果文件",
card.meaning || "",
"文件 " + card.file,
"视角 " + (card.views || "—") + " 时间 " + (card.utc || "—"),
"相机 " + (card.camera || "—") + " 工具 " + (card.tool || "—"),
"平移 X / Y / Z " + mm(card.translation_m),
"示教点 " + (card.poses == null ? "没有位姿文件" : card.poses + " 个"),
"板 " + (board.cols || "—") + "×" + (board.rows || "—") + " 方格 " + (board.square_m == null ? "—" : board.square_m + " m"),
].filter(Boolean).join("\n");
box.append(h, pill(!!card.exists, card.exists ? "有结果" : "无结果"), sub);
}
function render(){
renderHeader();
renderUsb();
renderMini();
renderFlow();
renderFingers();
renderViews();
drawChart($("chart-left"), status?.history?.left);
drawChart($("chart-right"), status?.history?.right);
renderCams();
renderGripper();
renderHandeye();
renderRail();
renderOps();
}
function show(page){
if (!titles[page]) page = "overview";
document.querySelectorAll("nav button").forEach(b => b.classList.toggle("active", b.dataset.page === page));
document.querySelectorAll("main section").forEach(s => s.classList.remove("on"));
$(page).classList.add("on");
if (location.hash !== "#" + page) history.replaceState(null, "", "#" + page);
}
document.querySelectorAll("nav button").forEach(btn => { btn.onclick = () => show(btn.dataset.page); });
window.addEventListener("hashchange", () => show(location.hash.slice(1)));
document.body.addEventListener("click", ev => {
const act = ev.target.closest("[data-act]");
if (act) ask(act.dataset.act);
});
document.querySelectorAll("#he-modes button").forEach(btn => {
btn.onclick = () => { heMode = btn.dataset.mode; renderHandeye(); };
});
$("view-on").onclick = async () => {
const data = await api("/api/tactile/view", {on: true});
toast(data.message || "已请求");
};
$("view-off").onclick = async () => {
const data = await api("/api/tactile/view", {on: false});
toast(data.message || "已停止");
};
setInterval(() => {
if (!$("tactile").classList.contains("on") || !status?.view?.on) return;
document.querySelectorAll("#views img").forEach(img => {
const side = img.dataset.side, kind = img.dataset.kind;
if (!status.view.frames?.[side + "_" + kind]) return;
img.src = "/api/tactile/frame/" + side + "/" + kind + ".jpg?t=" + Date.now();
});
}, 200);
$("ask-no").onclick = () => $("ask").close();
$("ask-yes").onclick = () => { const action = pending; $("ask").close(); if (action) fire(action, true); };
async function tick(){
try { status = await api("/api/status"); render(); }
catch (err) { toast("状态刷新失败"); }
paintClock();
}
function paintClock(){
$("clock").textContent = new Date().toLocaleTimeString("zh-CN", {hour12:false});
}
async function start(){
if (!boot) {
catalog = await api("/api/catalog");
await tick();
} else {
render();
paintClock();
}
show(location.hash.slice(1) || "overview");
setInterval(tick, 2000);
}
start();
</script>
</body>
</html>
@@ -0,0 +1,725 @@
#!/usr/bin/env python3
"""Orisys 工位界面。
只做两件事:把本机真实状态摊开,以及按白名单启动仓库里已有的脚本。
会动臂的动作必须带 ``confirm=true``,避免页面上误点一下就上电。
bash ~/ros2/scripts/run_console.sh
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
import yaml
from arm_application import operator_tactile_view as tactile_view
from arm_application.site_io import eye_to_hand_result_name, overhead_camera
HOST = "127.0.0.1"
PORT = 8766
_ROOT = Path(__file__).resolve()
for _p in _ROOT.parents:
if (_p / "scripts" / "run_real.sh").is_file():
ROOT = _p
break
else:
ROOT = Path.home() / "ros2"
LOG_DIR = Path("/tmp/orisys_console")
ASSETS = Path(__file__).resolve().parent
_LOCK = threading.Lock()
_TACTILE = {"left": {}, "right": {}, "stamp": 0.0}
_HIST = {"left": deque(maxlen=360), "right": deque(maxlen=360)}
_PROCS: dict[str, int] = {}
def _push_force(side: str, sample: dict) -> None:
if side not in _HIST:
return
point = {
"t": float(sample.get("t") or time.time()),
"fnormal": sample.get("fnormal"),
"fshearx": sample.get("fshearx"),
"fsheary": sample.get("fsheary"),
}
if not any(isinstance(point[k], (int, float)) for k in ("fnormal", "fshearx", "fsheary")):
return
with _LOCK:
_HIST[side].append(point)
_TACTILE[side] = {
"fnormal": point["fnormal"],
"fshearx": point["fshearx"],
"fsheary": point["fsheary"],
"connected": True,
}
tactile_view.set_sample_hook(_push_force)
def _site() -> dict:
path = ROOT / "config" / "site.yaml"
if not path.is_file():
return {}
data = yaml.safe_load(path.read_text()) or {}
return data if isinstance(data, dict) else {}
def _arms() -> list[dict]:
robots = (_site().get("robot") or {}).get("robots") or []
out = []
for robot in robots:
if not isinstance(robot, dict):
continue
sn = str(robot.get("sn") or "")
arm_no = "89" if sn.endswith("063989") else "91" if sn.endswith("063991") else ""
if not arm_no:
continue
out.append({
"id": arm_no,
"label": str(robot.get("label") or f"臂{arm_no}"),
"sn": sn,
"tactile": bool(robot.get("tactile")),
"camera": str((robot.get("camera") or {}).get("name") or ""),
"gripper": str((robot.get("gripper") or {}).get("model") or ""),
})
return out or [
{"id": "89", "label": "左臂89", "sn": "", "tactile": True, "camera": "d405_left", "gripper": ""},
{"id": "91", "label": "右臂91", "sn": "", "tactile": False, "camera": "d405_right", "gripper": ""},
]
def _usb() -> list[dict]:
found = {"d405": [], "d435": [], "orisys": []}
want = {
("8086", "0b5b"): "d405",
("8086", "0b07"): "d435",
("1bcf", "2281"): "orisys",
}
base = Path("/sys/bus/usb/devices")
if not base.is_dir():
return []
for dev in base.iterdir():
vid = dev / "idVendor"
pid = dev / "idProduct"
if not vid.is_file() or not pid.is_file():
continue
key = want.get((vid.read_text().strip(), pid.read_text().strip()))
if not key:
continue
speed = (dev / "speed").read_text().strip() if (dev / "speed").is_file() else "?"
found[key].append({"port": dev.name, "speed": speed})
return [
{"id": "d405", "label": "腕部 D405", "expect": 2, "items": found["d405"]},
{"id": "d435", "label": "手外 D435", "expect": 1, "items": found["d435"]},
{"id": "orisys", "label": "视触觉", "expect": 2, "items": found["orisys"]},
]
def _listeners(pattern: str) -> bool:
try:
out = subprocess.run(
["pgrep", "-af", pattern],
capture_output=True, text=True, timeout=2,
).stdout
except Exception:
return False
for line in out.splitlines():
if "pgrep" in line or "operator_console" in line:
continue
return True
return False
def _services() -> list[dict]:
rows = [
("real", "控制栈", "ros2_control_node"),
("tactile", "视触觉节点", "lib/arm_application/tactile"),
("camera", "相机", "realsense_color_pub"),
("gripper", "夹爪", "dh_pgea|dual_gripper_driver"),
("pipeline", "三段流程", "assembly_pipeline"),
("gauge", "通止规装配", "gauge_assembly"),
]
out = []
for sid, label, pattern in rows:
if "|" in pattern:
up = any(_listeners(part) for part in pattern.split("|"))
else:
up = _listeners(pattern)
out.append({"id": sid, "label": label, "up": up})
return out
def _grippers() -> list[str]:
return sorted(p.name for p in Path("/dev").glob("ttyUSB*"))
def _parse_tactile(text: str) -> dict:
got: dict[str, object] = {}
for line in text.splitlines():
if ":" not in line:
continue
key, _, raw = line.partition(":")
key = key.strip()
raw = raw.strip()
if key in ("fnormal", "fshearx", "fsheary", "fps"):
try:
got[key] = float(raw)
except ValueError:
continue
elif key in ("connected", "contact"):
got[key] = raw.lower() == "true"
return got
def _poll_tactile() -> None:
while True:
snap = {}
for side in ("left", "right"):
try:
proc = subprocess.run(
["timeout", "1.2", "ros2", "topic", "echo", f"/tactile/{side}/status", "--once"],
capture_output=True, text=True, timeout=2.5,
)
snap[side] = _parse_tactile(proc.stdout) if proc.returncode == 0 else {}
except Exception:
snap[side] = {}
view_on = bool(tactile_view.state().get("on"))
with _LOCK:
_TACTILE["stamp"] = time.time()
if not view_on:
_TACTILE["left"] = snap.get("left") or {}
_TACTILE["right"] = snap.get("right") or {}
now = time.time()
for side in ("left", "right"):
row = snap.get(side) or {}
if row:
_HIST[side].append({
"t": now,
"fnormal": row.get("fnormal"),
"fshearx": row.get("fshearx"),
"fsheary": row.get("fsheary"),
})
time.sleep(0.35)
def _robot(arm: str) -> dict:
robots = (_site().get("robot") or {}).get("robots") or []
for robot in robots:
if not isinstance(robot, dict):
continue
sn = str(robot.get("sn") or "")
short = "89" if sn.endswith("063989") else "91" if sn.endswith("063991") else ""
if short == arm:
return robot
return {}
def _comment(text: str, key: str) -> str:
match = re.search(rf"^#\s*{re.escape(key)}:\s*(.+)$", text, re.M)
return match.group(1).strip() if match else ""
def _pose_count(path: Path) -> int | None:
if not path.is_file():
return None
match = re.search(r"num_poses:\s*(\d+)", path.read_text(errors="replace"))
return int(match.group(1)) if match else None
def _result_card(path: Path, pose_path: Path, board: dict) -> dict:
text = path.read_text(errors="replace") if path.is_file() else ""
trans = None
tool = ""
if text:
try:
data = yaml.safe_load(text) or {}
except yaml.YAMLError:
data = {}
if isinstance(data, dict):
raw = data.get("translation_m")
if isinstance(raw, list) and len(raw) >= 3:
trans = [float(raw[0]), float(raw[1]), float(raw[2])]
tool = str(data.get("gripper_tool") or "")
meaning = ""
for line in text.splitlines():
if line.startswith("# T_4x4"):
meaning = line[2:].strip()
break
return {
"file": str(path),
"exists": path.is_file(),
"views": _comment(text, "views"),
"utc": _comment(text, "utc"),
"board_note": _comment(text, "board"),
"camera": _comment(text, "camera_source"),
"tool": tool,
"meaning": meaning,
"translation_m": trans,
"poses": _pose_count(pose_path),
"pose_file": str(pose_path),
"board": {
"cols": board.get("cols"),
"rows": board.get("rows"),
"square_m": board.get("square_size_m"),
},
}
def _handeye(arm: str) -> dict:
site = _site()
robot = _robot(arm)
paths = robot.get("paths") if isinstance(robot.get("paths"), dict) else {}
shared = site.get("paths") if isinstance(site.get("paths"), dict) else {}
boards = ((site.get("handeye") or {}).get("boards") or {})
wrist_file = Path(str(
paths.get("handeye_file")
or shared.get("handeye_file")
or (ROOT / "handeye" / "results" / f"{arm}_eye_in_hand.yaml")
))
wrist_pose = Path(str(
paths.get("handeye_waypoints")
or shared.get("handeye_waypoints")
or (ROOT / "handeye" / "poses" / f"{arm}_eye_in_hand.yaml")
))
cam, _others = overhead_camera(site, robot or None)
cam_name = str((cam or {}).get("name") or "")
e2h_name = eye_to_hand_result_name(arm, cam_name)
e2h_file = Path(str(
paths.get("handeye_file_eye_to_hand")
or shared.get("handeye_file_eye_to_hand")
or (ROOT / "handeye" / "results" / e2h_name)
))
e2h_pose = Path(str(
paths.get("handeye_waypoints_eye_to_hand")
or shared.get("handeye_waypoints_eye_to_hand")
or (ROOT / "handeye" / "poses" / f"{arm}_eye_to_hand.yaml")
))
return {
"eye_in_hand": _result_card(wrist_file, wrist_pose, boards.get("eye_in_hand") or {}),
"eye_to_hand": _result_card(e2h_file, e2h_pose, boards.get("eye_to_hand") or {}),
}
def status() -> dict:
with _LOCK:
tactile = {
"left": dict(_TACTILE["left"]),
"right": dict(_TACTILE["right"]),
"stamp": _TACTILE["stamp"],
}
history = {
"left": list(_HIST["left"]),
"right": list(_HIST["right"]),
}
return {
"arms": _arms(),
"usb": _usb(),
"services": _services(),
"grippers": _grippers(),
"tactile": tactile,
"history": history,
"view": tactile_view.state(),
"handeye": {"89": _handeye("89"), "91": _handeye("91")},
"now": time.time(),
}
# 会动真机的动作必须 confirm。argv 里的 {arm} 只接受 89 或 91。
_ACTIONS = {
"real_up": {
"title": "上电控制栈",
"script": "run_real.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "连接机械臂并拉起 ros2_control。",
},
"camera_d405": {
"title": "本臂腕部相机",
"script": "run_camera_d405.sh",
"args": ["--arm", "{arm}", "--no-preview"],
"moves": False,
"blurb": "只拉起本臂腕部相机。彩色和深度显示在程序里,不看左右红外,不另开窗口。",
},
"camera_d435": {
"title": "手外 D435",
"script": "run_camera_d435.sh",
"args": ["--no-preview"],
"moves": False,
"blurb": "只拉起顶视 D435。彩色和深度显示在程序里,不另开窗口。",
},
"tactile_up": {
"title": "视触觉三力",
"script": "run_tactile.sh",
"args": [],
"moves": False,
"blurb": "发布左右指法向力与两路切向力。",
},
"gripper_up": {
"title": "夹爪节点",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}"],
"moves": False,
"blurb": "只拉起夹爪驱动,不主动张合。",
},
"gripper_probe": {
"title": "夹爪探测",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "probe"],
"moves": False,
"blurb": "读夹爪是否在线,不动手指。",
},
"gripper_open": {
"title": "夹爪张开",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "open"],
"moves": True,
"blurb": "张开当前臂夹爪。",
},
"gripper_close": {
"title": "夹爪闭合",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "close"],
"moves": True,
"blurb": "闭合当前臂夹爪。",
},
"gripper_rotate": {
"title": "右爪转 90°",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "rotate", "90"],
"moves": True,
"blurb": "右臂旋转爪转到 90°。左臂平行爪没有这一轴。",
},
"pipeline": {
"title": "整体三段",
"script": "run_all.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "跟踪、对孔、搜孔轻压一次跑完。会动臂。",
},
"stage_track": {
"title": "只测跟踪",
"script": "run_visual_servo_rt.sh",
"args": ["--arm", "{arm}", "--control"],
"moves": True,
"blurb": "只跑视觉跟踪。从当前位姿开始,会动臂。要先有控制栈和相机。",
},
"stage_locate": {
"title": "只测对孔",
"script": "run_stage2_locate.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "只跑二阶段:朝下、悬停、拍照、对准孔。会动臂。",
},
"stage_search": {
"title": "只测搜孔",
"script": "run_stage3_search.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "只跑三阶段:触面、搜孔、轻压。会动臂。臂应已停在孔上方。",
},
"reset_home": {
"title": "复位到 home",
"script": "go_home.sh",
"args": ["--arm", "{arm}", "--pose", "home"],
"moves": True,
"sync": True,
"timeout": 120,
"blurb": "先停跟踪,再回到命名位姿 home。要控制栈已经起来。会动臂。",
},
"gauge_dry": {
"title": "装配干跑",
"script": "run_gauge_assembly.sh",
"args": ["--arm", "{arm}", "--dry-run"],
"moves": False,
"blurb": "S00 到 S11 只打印计划,不发运动指令。",
},
"gauge_run": {
"title": "装配真跑",
"script": "run_gauge_assembly.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "通止规状态机。会动臂、会拧。",
},
"handeye_show": {
"title": "查看标定配置",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "show", "--mode", "{mode}"],
"moves": False,
"sync": True,
"blurb": "打印这块板、结果文件和已有点数,不动臂。",
},
"handeye_teach": {
"title": "示教采点",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "teach", "--mode", "{mode}", "--points", "12"],
"moves": True,
"blurb": "软拖拽示教,目标 12 个姿态。要先停控制栈,会动臂。",
},
"handeye_merge": {
"title": "合并示教点",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "merge", "--mode", "{mode}"],
"moves": False,
"sync": True,
"blurb": "把刚采的点写入该模式的位姿文件,不动臂。",
},
"handeye_calibrate": {
"title": "求解手眼",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "calibrate", "--mode", "{mode}"],
"moves": True,
"blurb": "按点位走一遍并写结果。需要控制栈和对应相机,会动臂。",
},
}
def catalog() -> dict:
return {
"actions": [
{"id": key, **{k: v for k, v in spec.items() if k not in ("script", "sync")}}
for key, spec in _ACTIONS.items()
],
"stages": [
("S00", "自检"),
("S01", "回零预绕"),
("S02", "夹持确认"),
("S03", "手外粗定位"),
("S04", "视觉跟踪"),
("S05", "精拍"),
("S06", "正悬停"),
("S07", "柔顺接触"),
("S08", "棘轮拧入"),
("S09", "反向拧出"),
("S10", "换孔"),
("S11", "回零结束"),
],
}
def _stop_tactile() -> str:
subprocess.run(["pkill", "-f", "lib/arm_application/tactile"], check=False)
subprocess.run(["pkill", "-f", "arm_application/tactile_node.py"], check=False)
subprocess.run(["pkill", "orisys_tac_w"], check=False)
return "已发送停止视触觉节点"
def run_action(action: str, arm: str, confirm: bool, mode: str = "eye_in_hand") -> dict:
if action == "tactile_down":
tactile_view.stop()
return {"ok": True, "message": _stop_tactile()}
if action == "tactile_view_on":
with _LOCK:
_HIST["left"].clear()
_HIST["right"].clear()
return {"ok": True, "message": tactile_view.start(_site())}
if action == "tactile_view_off":
return {"ok": True, "message": tactile_view.stop()}
spec = _ACTIONS.get(action)
if spec is None:
return {"ok": False, "message": "没有这个动作"}
if arm not in ("89", "91"):
return {"ok": False, "message": "臂号只接受 89 或 91"}
if mode not in ("eye_in_hand", "eye_to_hand"):
return {"ok": False, "message": "手眼模式只接受 eye_in_hand 或 eye_to_hand"}
if spec["moves"] and not confirm:
return {"ok": False, "message": "这个动作会动真机,需要确认"}
script = ROOT / "scripts" / spec["script"]
if not script.is_file():
return {"ok": False, "message": f"找不到 {script}"}
args = [a.replace("{arm}", arm).replace("{mode}", mode) for a in spec["args"]]
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = LOG_DIR / f"{action}_{arm}.log"
shown = " ".join(["bash", f"scripts/{spec['script']}", *args])
if spec.get("sync"):
try:
proc = subprocess.run(
["bash", str(script), *args],
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=float(spec.get("timeout") or 40),
)
except subprocess.TimeoutExpired:
return {"ok": False, "message": "命令超时还没返回", "detail": shown}
detail = ((proc.stdout or "") + (proc.stderr or "")).strip()
log_path.write_text(detail)
ok = proc.returncode == 0
return {
"ok": ok,
"message": shown if ok else f"退出码 {proc.returncode}",
"detail": detail[-4000:],
"log": str(log_path),
}
log_f = log_path.open("ab")
proc = subprocess.Popen(
["bash", str(script), *args],
cwd=str(ROOT),
stdout=log_f,
stderr=subprocess.STDOUT,
start_new_session=True,
)
_PROCS[action] = proc.pid
return {"ok": True, "message": shown, "pid": proc.pid, "log": str(log_path)}
def _bounded(name: str, raw, low: float, high: float) -> float:
try:
value = float(raw)
except (TypeError, ValueError):
raise ValueError(f"{name} 不是数字") from None
if value != value or value < low or value > high:
raise ValueError(f"{name} 只接受 {low:g}–{high:g}")
return value
def run_gripper_tune(arm: str, kind: str, numbers: dict) -> dict:
"""按界面上的位置、力度、速度下发夹爪。右臂另有角度、圈数。"""
if arm not in ("89", "91"):
return {"ok": False, "message": "臂号只接受 89 或 91"}
if kind not in ("pos", "rotate", "rotate_by", "revolutions"):
return {"ok": False, "message": "没有这种夹爪调节"}
if kind != "pos" and arm != "91":
return {"ok": False, "message": "左臂平行爪没有旋转轴"}
try:
if kind == "pos":
pos = int(round(_bounded("位置", numbers.get("pos"), 0, 1000)))
force = int(round(_bounded("力度", numbers.get("force"), 20, 100)))
speed = int(round(_bounded("速度", numbers.get("speed"), 1, 100)))
cli = ["pos", str(pos)]
summary = f"位置 {pos}(0 合,1000 开) 力度 {force}% 速度 {speed}%"
else:
force = int(round(_bounded("力矩", numbers.get("torque"), 20, 100)))
speed = int(round(_bounded("旋转速度", numbers.get("rot_speed"), 1, 100)))
if kind == "rotate":
angle = _bounded("绝对角", numbers.get("angle"), -32767, 32767)
cli = ["rotate", f"{angle:.3f}"]
summary = f"绝对角 {angle:.3f}° 力矩 {force}% 速度 {speed}%"
elif kind == "rotate_by":
angle = _bounded("相对角", numbers.get("delta"), -32767, 32767)
cli = ["rotate_by", f"{angle:.3f}"]
summary = f"相对转 {angle:.3f}° 力矩 {force}% 速度 {speed}%"
else:
turns = _bounded("圈数", numbers.get("revolutions"), -50, 50)
cli = ["revolutions", f"{turns:.3f}"]
summary = f"转 {turns:.3f} 圈 力矩 {force}% 速度 {speed}%"
except ValueError as exc:
return {"ok": False, "message": str(exc)}
script = ROOT / "scripts" / "run_gripper.sh"
argv = [
"bash", str(script),
"--arm", arm,
"--force", str(force),
"--speed", str(speed),
"--cli", *cli,
]
shown = " ".join(argv)
try:
proc = subprocess.run(argv, cwd=str(ROOT), capture_output=True, text=True, timeout=90)
except subprocess.TimeoutExpired:
return {"ok": False, "message": "夹爪 90 秒还没到位", "detail": shown}
detail = ((proc.stdout or "") + (proc.stderr or "")).strip()
ok = proc.returncode == 0
return {
"ok": ok,
"message": summary if ok else f"夹爪退出码 {proc.returncode}",
"detail": (summary + "\n" + shown + "\n" + detail)[-4000:],
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
return
def _send(self, code: int, body: bytes, content_type: str) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json(self, code: int, payload: dict) -> None:
self._send(code, json.dumps(payload, ensure_ascii=False).encode(), "application/json; charset=utf-8")
def do_GET(self) -> None:
path = urlparse(self.path).path
if path in ("/", "/index.html"):
page = (ASSETS / "operator_console.html").read_text(encoding="utf-8")
payload = json.dumps(
{"status": status(), "catalog": catalog()},
ensure_ascii=False,
).replace("<", "\\u003c")
page = page.replace("/*BOOT*/null", payload, 1)
self._send(200, page.encode(), "text/html; charset=utf-8")
return
if path == "/logo.png":
self._send(200, (ASSETS / "operator_console_logo.png").read_bytes(), "image/png")
return
if path == "/api/status":
self._json(200, status())
return
if path == "/api/catalog":
self._json(200, catalog())
return
parts = path.strip("/").split("/")
if len(parts) == 5 and parts[:3] == ["api", "tactile", "frame"] and parts[4].endswith(".jpg"):
side, kind = parts[3], parts[4][:-4]
blob = tactile_view.frame(side, kind) if side in ("left", "right") and kind in ("shear", "thermal") else None
if not blob:
self._json(404, {"ok": False, "message": "还没有这一帧"})
return
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(blob)))
self.end_headers()
self.wfile.write(blob)
return
self._json(404, {"ok": False, "message": "没有这个路径"})
def do_POST(self) -> None:
path = urlparse(self.path).path
if path not in ("/api/action", "/api/tactile/view"):
self._json(404, {"ok": False, "message": "没有这个路径"})
return
length = int(self.headers.get("Content-Length") or 0)
try:
body = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
self._json(400, {"ok": False, "message": "请求不是 JSON"})
return
if path == "/api/tactile/view":
action = "tactile_view_on" if body.get("on") else "tactile_view_off"
result = run_action(action, "89", False)
self._json(200 if result.get("ok") else 400, result)
return
result = run_action(
str(body.get("action") or ""),
str(body.get("arm") or "89"),
bool(body.get("confirm")),
str(body.get("mode") or "eye_in_hand"),
)
self._json(200 if result.get("ok") else 400, result)
def main() -> None:
import atexit
atexit.register(tactile_view.stop)
threading.Thread(target=_poll_tactile, daemon=True).start()
server = ThreadingHTTPServer((HOST, PORT), Handler)
print(f"Orisys 工位界面 http://{HOST}:{PORT}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,353 @@
"""工位界面用的视触觉成像。
切向力是 SDK 的 ``VSHEAR`` 箭头,热成像是 ``DEPTH`` 的 JET 伪彩。
相机独占,所以只有页面点了「开始成像」才打开,失败把原因交回去,不造图。
"""
from __future__ import annotations
import os
import re
import threading
import time
from pathlib import Path
import numpy as np
_LOCK = threading.Lock()
_STOP = threading.Event()
_THREADS: list[threading.Thread] = []
_ON_SAMPLE = None
_STATE = {
"on": False,
"error": "",
"stamp": 0.0,
"note": "",
"frames": {},
}
_JPEG: dict[str, bytes] = {}
def set_sample_hook(fn) -> None:
global _ON_SAMPLE
_ON_SAMPLE = fn
def state() -> dict:
with _LOCK:
return {
"on": _STATE["on"],
"error": _STATE["error"],
"stamp": _STATE["stamp"],
"note": _STATE["note"],
"frames": dict(_STATE["frames"]),
}
def frame(side: str, kind: str) -> bytes | None:
with _LOCK:
return _JPEG.get(f"{side}_{kind}")
def _videos() -> list[str]:
found = []
by_path = Path("/dev/v4l/by-path")
if not by_path.is_dir():
return found
for link in sorted(by_path.iterdir()):
if not link.name.endswith("-video-index0"):
continue
real = os.path.realpath(link)
base = os.path.basename(real)
sysdev = os.path.realpath(f"/sys/class/video4linux/{base}/device")
dev = ""
for part in reversed(sysdev.split("/")):
if re.fullmatch(r"\d+-\d+(\.\d+)*", part):
dev = part
break
if not dev:
continue
vid = Path(f"/sys/bus/usb/devices/{dev}/idVendor")
pid = Path(f"/sys/bus/usb/devices/{dev}/idProduct")
if (
vid.is_file()
and pid.is_file()
and vid.read_text().strip() == "1bcf"
and pid.read_text().strip() == "2281"
):
found.append(str(link))
return found
def _resolve_cfg(site: dict) -> dict:
tactile = site.get("tactile") if isinstance(site.get("tactile"), dict) else {}
left = str(tactile.get("left_video") or "")
right = str(tactile.get("right_video") or "")
if not Path(left).exists() or not Path(right).exists():
found = _videos()
if len(found) >= 2:
left, right = found[0], found[1]
elif len(found) == 1:
left = right = found[0]
return {
"cfg": str(tactile.get("config_file") or ""),
"backend": str(tactile.get("infer_backend") or "cpu"),
"cuda": bool(tactile.get("cuda", False)),
"isstitch": bool(tactile.get("isstitch", True)),
"baseline": float(tactile.get("baseline_seconds") or 1.5),
"left": left,
"right": right,
}
def _as_bgr(img):
if not isinstance(img, np.ndarray) or img.size == 0:
return None
import cv2
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] >= 3:
return np.ascontiguousarray(img[:, :, :3])
return None
def _flat(arr) -> bool:
if not isinstance(arr, np.ndarray) or arr.size == 0 or arr.ndim < 2:
return True
a = arr.reshape(-1, arr.shape[-1]) if arr.ndim == 3 else arr.reshape(-1)
finite = np.isfinite(a)
if not bool(finite.any()):
return True
vals = a[finite]
return float(np.max(vals) - np.min(vals)) < 1e-6
def _thermal(depth):
import cv2
if depth is None:
return None
a = np.asarray(depth)
if a.size == 0:
return None
if a.ndim == 3 and a.shape[2] == 2:
a = np.linalg.norm(a.astype(np.float32), axis=2)
if a.ndim != 2:
return None
finite = np.isfinite(a)
if not finite.any():
return None
vals = a[finite]
lo, hi = float(vals.min()), float(vals.max())
if hi <= lo:
norm = np.zeros(a.shape, np.uint8)
else:
norm = np.clip((a - lo) / (hi - lo) * 255.0, 0, 255).astype(np.uint8)
return cv2.applyColorMap(norm, cv2.COLORMAP_JET)
def _views(sensor, gray):
import cv2
import orisys
bgr = _as_bgr(gray)
h, w = (bgr.shape[0], bgr.shape[1]) if bgr is not None else (450, 420)
try:
sensor.compute_deformation(decompose=True, check_motion=False)
except Exception:
pass
try:
sensor.compute_contact()
except Exception:
pass
try:
vshear, vnormal, depth = sensor.read_info(
sensor.info.VSHEAR, sensor.info.VNORMAL, sensor.info.DEPTH
)
except Exception:
vshear, vnormal, depth = None, None, None
canvas = np.zeros((h, w, 3), np.uint8)
shear = canvas
if isinstance(vshear, np.ndarray) and vshear.ndim == 3 and vshear.shape[2] >= 2 and vshear.size:
field = np.ascontiguousarray(vshear[:, :, :2], dtype=np.float32)
if field.shape[0] != h or field.shape[1] != w:
field = cv2.resize(field, (w, h))
mag = np.linalg.norm(field, axis=2)
peak = float(np.percentile(mag, 99)) if mag.size else 0.0
arrow_scale = (2.0 * peak / 12.0) if peak > 1e-6 else 1.0
try:
shear = orisys.util.draw_arrows(
canvas,
field,
threshold=peak * 0.15 if peak > 1e-6 else 1.0,
grid_spacing=16,
arrow_scale=max(arrow_scale, 1e-6),
below_threshold_color=(40, 40, 40),
)
except Exception:
shear = canvas
heat = None if _flat(depth) else _thermal(depth)
if heat is None:
heat = _thermal(vnormal)
if not isinstance(heat, np.ndarray) or heat.ndim != 3:
heat = np.zeros((h, w, 3), np.uint8)
elif heat.shape[0] != h or heat.shape[1] != w:
heat = cv2.resize(heat, (w, h))
return shear, heat
def _jpeg(img) -> bytes | None:
import cv2
if not isinstance(img, np.ndarray) or img.size == 0:
return None
ok, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
if not ok:
return None
return buf.tobytes()
def _store(side: str, shear, heat, sample: dict | None) -> None:
frames = {}
blobs = {}
for kind, img in (("shear", shear), ("thermal", heat)):
blob = _jpeg(img)
if blob:
blobs[f"{side}_{kind}"] = blob
frames[f"{side}_{kind}"] = True
with _LOCK:
_JPEG.update(blobs)
_STATE["frames"].update(frames)
_STATE["stamp"] = time.time()
if _STATE["error"].startswith(side):
_STATE["error"] = ""
if sample and _ON_SAMPLE is not None:
_ON_SAMPLE(side, sample)
def _open_sensor(cfg: dict, side: str):
import orisys
return orisys.Sensor(
cfg[side],
cuda=cfg["cuda"],
isstitch=cfg["isstitch"],
verbose=False,
config_name=cfg["cfg"],
backend=cfg["backend"],
)
def _worker(side: str, cfg: dict) -> None:
if not cfg.get(side):
with _LOCK:
_STATE["error"] = f"{side} 没有视频节点"
return
sensor = None
try:
sensor = _open_sensor(cfg, side)
except Exception as exc:
with _LOCK:
prev = _STATE["error"]
_STATE["error"] = (prev + ";" if prev else "") + f"{side} 打开失败:{exc}"
return
buckets = {"n": [], "x": [], "y": []}
deadline = time.perf_counter() + float(cfg["baseline"])
while time.perf_counter() < deadline and not _STOP.is_set():
try:
img = sensor.get_img()
sensor.compute_deformation(check_motion=False)
n, x, y = sensor.read_info(
sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY
)
buckets["n"].append(float(n))
buckets["x"].append(float(x))
buckets["y"].append(float(y))
except Exception:
pass
time.sleep(0.02)
base = {
"n": float(np.mean(buckets["n"])) if buckets["n"] else 0.0,
"x": float(np.mean(buckets["x"])) if buckets["x"] else 0.0,
"y": float(np.mean(buckets["y"])) if buckets["y"] else 0.0,
}
with _LOCK:
base_note = "曲线已减去打开后短暂基线,单位仍是形变读数。"
if "右指" in str(_STATE["note"]):
_STATE["note"] = _STATE["note"] + " " + base_note
else:
_STATE["note"] = base_note
last_sample = 0.0
while not _STOP.is_set():
try:
gray = sensor.get_img()
except Exception:
gray = None
if gray is None:
time.sleep(0.05)
continue
shear, heat = _views(sensor, gray)
sample = None
now = time.time()
if now - last_sample >= 0.1:
try:
n, x, y = sensor.read_info(
sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY
)
sample = {
"t": now,
"fnormal": float(n) - base["n"],
"fshearx": float(x) - base["x"],
"fsheary": float(y) - base["y"],
}
last_sample = now
except Exception:
sample = None
_store(side, shear, heat, sample)
time.sleep(0.04)
try:
sensor.disconnect()
except Exception:
pass
def start(site: dict) -> str:
global _THREADS
with _LOCK:
if _STATE["on"]:
return "成像已经开着"
_STATE["on"] = True
_STATE["error"] = ""
_STATE["frames"] = {}
_STOP.clear()
cfg = _resolve_cfg(site)
if not cfg["left"] and not cfg["right"]:
with _LOCK:
_STATE["on"] = False
_STATE["error"] = "没有找到 Orisys 相机"
return _STATE["error"]
sides = ["left"]
if cfg.get("right") and cfg.get("right") != cfg.get("left"):
sides.append("right")
elif not cfg.get("right") or cfg.get("right") == cfg.get("left"):
with _LOCK:
_STATE["note"] = "只找到一路 Orisys,右指没有画面。插上后重新开始成像。"
_THREADS = [
threading.Thread(target=_worker, args=(side, cfg), daemon=True, name=f"tac-{side}")
for side in sides
]
for thread in _THREADS:
thread.start()
return "正在打开两只视触觉。切向力和热成像出来之前会先采一小段基线。"
def stop() -> str:
_STOP.set()
for thread in list(_THREADS):
thread.join(timeout=2.0)
with _LOCK:
_STATE["on"] = False
_STATE["frames"] = {}
_JPEG.clear()
return "已停止视触觉成像"
@@ -0,0 +1,203 @@
"""软拖拽示教:开拖拽、记下当前关节角、再回到该点。
点位写在 ``handeye/tmp/<臂号>/operator_teach.yaml``。关节角单位是弧度,
直接存桥接返回的 7 个数,不另做姿态换算。
软拖拽占用 RDK,和 ``run_real.sh`` 不能同时连同一台臂。
"""
from __future__ import annotations
import threading
from datetime import datetime, timezone
from pathlib import Path
import yaml
_LOCK = threading.Lock()
_BRIDGE = None
_SN = ""
def _file(arm: str) -> Path:
from arm_application.operator_console import ROOT
if arm not in ("89", "91"):
raise ValueError("臂号只接受 89 或 91")
return ROOT / "handeye" / "tmp" / arm / "operator_teach.yaml"
def _bridge_for(sn: str):
global _BRIDGE, _SN
sn = str(sn or "").strip()
if not sn:
raise RuntimeError("site.yaml 里这台臂没有序列号,拒绝连接")
if _BRIDGE is not None and _SN == sn and _BRIDGE._proc.poll() is None:
return _BRIDGE
close()
from arm_application.handeye_teach import RdkBridge
_BRIDGE = RdkBridge(sn)
_SN = sn
return _BRIDGE
def close() -> None:
global _BRIDGE, _SN
bridge = _BRIDGE
_BRIDGE = None
_SN = ""
if bridge is None:
return
try:
bridge.close()
except Exception:
pass
def drag_on(sn: str) -> dict:
with _LOCK:
try:
bridge = _bridge_for(sn)
result = bridge.drag_on(mode="soft")
except Exception as exc:
return {"ok": False, "message": str(exc)}
if not result.get("ok"):
return {"ok": False, "message": str(result.get("error") or result)}
return {
"ok": True,
"message": "软拖拽已开启。轻推机械臂,再记点。这一步要先停掉控制栈。",
}
def drag_off(sn: str) -> dict:
with _LOCK:
try:
bridge = _bridge_for(sn)
result = bridge.drag_off()
except Exception as exc:
return {"ok": False, "message": str(exc)}
if not result.get("ok"):
return {"ok": False, "message": str(result.get("error") or result)}
return {"ok": True, "message": "软拖拽已关闭,臂保持当前位置。"}
def list_points(arm: str) -> dict:
try:
path = _file(arm)
except ValueError as exc:
return {"ok": False, "message": str(exc), "points": []}
if not path.is_file():
return {"ok": True, "points": [], "file": str(path)}
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as exc:
return {"ok": False, "message": f"示教文件读不了:{exc}", "points": [], "file": str(path)}
points = []
for row in data.get("points") or []:
if not isinstance(row, dict):
continue
joints = row.get("joints")
if not isinstance(joints, list) or len(joints) < 7:
continue
try:
values = [float(v) for v in joints[:7]]
except (TypeError, ValueError):
continue
if any(v != v for v in values):
continue
points.append({
"name": str(row.get("name") or "").strip(),
"utc": str(row.get("utc") or ""),
"joints": values,
})
points = [row for row in points if row["name"]]
return {"ok": True, "points": points, "file": str(path)}
def _write(arm: str, points: list[dict]) -> Path:
path = _file(arm)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml.safe_dump({"points": points}, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
return path
def record(sn: str, arm: str, name: str) -> dict:
title = str(name or "").strip()
if not title or "/" in title or "\\" in title or title in (".", ".."):
return {"ok": False, "message": "点名不能为空,也不能带路径"}
with _LOCK:
try:
bridge = _bridge_for(sn)
result = bridge._ask({"cmd": "joints"}, timeout=2.0)
except Exception as exc:
return {"ok": False, "message": str(exc)}
if not result.get("ok"):
return {"ok": False, "message": str(result.get("error") or "没有读到关节,点位没写下去")}
raw = result.get("q") or []
if len(raw) < 7:
return {"ok": False, "message": "关节数不够 7 个,点位没写下去"}
try:
values = [round(float(v), 6) for v in raw[:7]]
except (TypeError, ValueError):
return {"ok": False, "message": "关节角不是数字,点位没写下去"}
if any(v != v for v in values):
return {"ok": False, "message": "关节角不是有限值,点位没写下去"}
listed = list_points(arm)
if not listed.get("ok"):
return listed
points = [row for row in listed["points"] if row["name"] != title]
points.append({
"name": title,
"utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"joints": values,
})
path = _write(arm, points)
text = " ".join(f"{v:.4f}" for v in values)
return {"ok": True, "message": f"已记录 {title}", "detail": f"{path}\n关节 rad:{text}", "points": points}
def delete_point(arm: str, name: str) -> dict:
title = str(name or "").strip()
listed = list_points(arm)
if not listed.get("ok"):
return listed
kept = [row for row in listed["points"] if row["name"] != title]
if len(kept) == len(listed["points"]):
return {"ok": False, "message": f"没有名为 {title} 的点"}
path = _write(arm, kept)
return {"ok": True, "message": f"已删除 {title}", "detail": str(path), "points": kept}
def goto(sn: str, arm: str, name: str, speed: float) -> dict:
title = str(name or "").strip()
listed = list_points(arm)
if not listed.get("ok"):
return listed
hit = next((row for row in listed["points"] if row["name"] == title), None)
if hit is None:
return {"ok": False, "message": f"没有名为 {title} 的点"}
try:
speed_f = float(speed)
except (TypeError, ValueError):
return {"ok": False, "message": "关节速度不是数字"}
if not 0.05 <= speed_f <= 0.35:
return {"ok": False, "message": "关节速度只接受 0.05–0.35 rad/s"}
with _LOCK:
try:
bridge = _bridge_for(sn)
bridge.drag_off()
result = bridge.move_joints(hit["joints"], speed_f)
except Exception as exc:
return {"ok": False, "message": str(exc)}
if not result.get("ok"):
return {"ok": False, "message": str(result.get("error") or result)}
text = " ".join(f"{float(v):.4f}" for v in hit["joints"])
return {
"ok": True,
"message": f"已回到 {title}",
"detail": f"速度 {speed_f:.2f} rad/s\n关节 rad:{text}",
}
@@ -0,0 +1,587 @@
"""Orisys 视触觉适配器。SDK 通过 pip 安装;设备 id 存放在 ``site.yaml`` 里。"""
from __future__ import annotations
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
import numpy as np
from arm_application.site_io import ros2_root
from arm_application.tactile_interface import TactileCommand, TactileInterface, TactileSample
@dataclass
class OrisysSensorConfig:
vid_src: int | str = 0
config_name: str = "dd03"
rotation_config_path: str = ""
cuda: bool = True
isstitch: bool = True
backend: str = "auto"
motion_threshold: float = 0.0
target_fps: float = 30.0
@dataclass
class OrisysDualConfig:
left: OrisysSensorConfig
right: OrisysSensorConfig
baseline_seconds: float = 2.0
filter_alpha: float = 0.25
backend: str = "orisys" # orisys | mock
image_hz: float = 0.0
"""视触觉图像(深度/形变图)的发布频率。0 = 不发布图像。
为什么默认关:图像每帧几百 KB,跨进程序列化 + ROS 发布的开销远大于三力。
三力路径必须跑在 30fps(接触判据靠它),图像只是给人看和给算法备用,
默认不开以免动到已经调好的三力时序。装配现场按需在 site.yaml 打开。"""
def resolve_orisys_config(config_name: str, config_file: str = "") -> str:
"""把型号名解析成实际的 config.json 路径。
查找顺序(先命中先用):
1. 显式传入的 ``config_file``
2. 参数本身就是存在的文件路径
3. ``devices/sensors/orisys/models/<型号>/config.json`` —— 型号配置随 SDK 放,
这是现在唯一推荐的位置
4. 老位置 ``src/arm_application/config/orisys/<型号>.json``(兼容保留:
站点自己放的定制配置可能还在这里)
5. Orisys 包自带的 ``configs/<型号>.json``
6. 都没找到就把原样返回,交给 SDK 自己解析(它支持内置型号名)
"""
if config_file:
p = Path(config_file).expanduser()
if p.is_file():
return str(p)
raw = (config_name or "dd03").strip()
p = Path(raw).expanduser()
if p.is_file():
return str(p)
root = ros2_root()
for cand in (
root / "devices" / "sensors" / "orisys" / "models" / raw / "config.json",
root / "src" / "arm_application" / "config" / "orisys" / f"{raw}.json",
):
if cand.is_file():
return str(cand)
try:
import orisys
pkg_root = Path(orisys.__file__).resolve().parent.parent
for cand in (pkg_root / "configs" / f"{raw}.json", pkg_root / "config" / f"{raw}.json"):
if cand.is_file():
return str(cand)
except Exception:
pass
return raw
def load_orisys_from_site(data: dict) -> OrisysDualConfig:
t = data.get("tactile") or {}
left_raw = t.get("left") if isinstance(t.get("left"), dict) else {}
right_raw = t.get("right") if isinstance(t.get("right"), dict) else {}
cfg_file = str(t.get("config_file") or "")
# model 是设备契约层选型号用的键(对应 models/<型号>/ 目录名);
# config 是它的旧名,仅为兼容老站点文件保留,站点里应统一用 model。
cfg_name = str(t.get("model") or t.get("config") or "dd03")
resolved = resolve_orisys_config(cfg_name, cfg_file)
common = dict(
config_name=resolved,
rotation_config_path=str(t.get("rotation_config") or ""),
cuda=bool(t.get("cuda", True)),
isstitch=bool(t.get("isstitch", True)),
backend=str(t.get("infer_backend") or t.get("backend_infer") or "auto"),
motion_threshold=float(t.get("motion_threshold") or 0.0),
target_fps=float(t.get("target_fps") or 30.0),
)
left_vid = left_raw.get("video", t.get("left_video", 0))
right_vid = right_raw.get("video", t.get("right_video", 2))
left = OrisysSensorConfig(vid_src=_parse_video(left_vid), **common)
right = OrisysSensorConfig(vid_src=_parse_video(right_vid), **common)
if left_raw.get("config") or left_raw.get("config_file"):
left.config_name = resolve_orisys_config(
str(left_raw.get("config") or cfg_name),
str(left_raw.get("config_file") or ""),
)
if right_raw.get("config") or right_raw.get("config_file"):
right.config_name = resolve_orisys_config(
str(right_raw.get("config") or cfg_name),
str(right_raw.get("config_file") or ""),
)
left.rotation_config_path = str(
left_raw.get("rotation_config") or t.get("left_rotation") or t.get("rotation_config") or ""
)
right.rotation_config_path = str(
right_raw.get("rotation_config") or t.get("right_rotation") or t.get("rotation_config") or ""
)
return OrisysDualConfig(
left=left,
right=right,
baseline_seconds=float(t.get("baseline_seconds") or 2.0),
filter_alpha=float(t.get("filter_alpha") or 0.25),
backend=str(t.get("backend") or "orisys"),
image_hz=float(t.get("image_hz") or 0.0),
)
def read_vector_map(sensor: Any) -> Optional[np.ndarray]:
"""尝试从传感器读出力矢量场(每像素的切向力/位移矢量)。
为什么要"尝试"
--------------
Orisys 的 Python 绑定是编译过的,公开接口里没有一个明确叫
"force vector map" 的方法。按语义最接近的是 ``compute_flow()``
(形变光流 ≈ 切向位移场 ≈ 力矢量场),其次是 ``compute_hhd()``。
不同 SDK 版本这两个方法的返回值也不一样 —— 有的是 ``(H,W,2)`` 数组,
有的返回 ``bool``(只给"形变是否超阈值")或可视化图。
所以这里逐个试,只接受真正是二维以上数组的结果;都拿不到就返回 ``None``,
上层跳过该话题而不是发一张假图。哪一路成功会打到日志里,便于现场核对。
"""
for name in ("compute_flow", "compute_hhd", "compute_deformation"):
fn = getattr(sensor, name, None)
if fn is None:
continue
try:
result = fn()
except Exception:
continue
if isinstance(result, np.ndarray) and result.ndim >= 2 and result.size:
return result
return None
def _parse_video(value) -> int | str:
if isinstance(value, int):
return value
text = str(value).strip()
if text.isdigit():
return int(text)
return text
class OrisysTactile(TactileInterface):
name = "orisys_tactile"
def __init__(self, config: OrisysDualConfig | None = None) -> None:
self.cfg = config or OrisysDualConfig(
left=OrisysSensorConfig(vid_src=0),
right=OrisysSensorConfig(vid_src=2),
)
self._sensors: list[dict[str, Any]] = []
self._baseline: dict[str, float] = {}
self._connected = False
def connect(self, sides=("left", "right")) -> None:
"""连接指定侧的传感器。注意:SDK 单进程双开会严重掉帧(30→0.8fps),
双指请拆两个进程各连一侧(tactile.launch.py 已是双进程)。"""
try:
import orisys
except ImportError as exc:
raise RuntimeError(
"缺少 orisys SDK。请执行 pip install -e /path/to/orisys-sdk "
"(git.orisys-tech.cn/orisys/test)。配置依旧从 ``site.yaml`` 的 ``tactile`` 段读取。"
) from exc
self._sensors = []
for side, cfg in (("left", self.cfg.left), ("right", self.cfg.right)):
if side not in sides:
continue
kwargs = dict(
cuda=cfg.cuda,
isstitch=cfg.isstitch,
verbose=False,
config_name=cfg.config_name,
backend=cfg.backend,
)
if cfg.rotation_config_path:
kwargs["rotation_config_path"] = cfg.rotation_config_path
sensor = orisys.Sensor(cfg.vid_src, **kwargs)
self._sensors.append({"side": side, "sensor": sensor, "cfg": cfg})
self._calibrate_baseline()
self._connected = True
def read_side(self, side: str, want_vector: bool = False) -> dict[str, Any]:
"""读指定侧(单侧进程模式);未连接/未知侧返回全零。
``want_vector=True`` 时额外计算力矢量场(逐像素运算,较贵)——
只应在需要发布矢量图的那些帧上打开。
"""
if not self._connected:
return self._empty_sample(side)
for item in self._sensors:
if item["side"] == side:
return self._read_single(item, want_vector=want_vector)
return self._empty_sample(side)
def disconnect(self) -> None:
for item in self._sensors:
try:
item["sensor"].disconnect()
except Exception:
pass
self._sensors = []
self._connected = False
def reset(self, hole_id: int) -> None:
del hole_id
def step(self, sample: TactileSample) -> TactileCommand:
del sample
return TactileCommand(
delta_xyz=np.zeros(3),
delta_rpy=np.zeros(3),
done=False,
ok=True,
message="orisys step",
)
def read_sample(self) -> TactileSample:
if not self._connected or len(self._sensors) != 2:
return TactileSample(
timestamp=time.time(),
force_xyz=np.zeros(3),
torque_xyz=np.zeros(3),
contact=False,
)
left = self._read_single(self._sensors[0])
right = self._read_single(self._sensors[1])
force_xyz = np.array(
[
(left["fnormal"] + right["fnormal"]) * 0.5,
(left["fshearx"] + right["fshearx"]) * 0.5,
(left["fsheary"] + right["fsheary"]) * 0.5,
]
)
torque_xyz = np.array(
[
right["fnormal"] - left["fnormal"],
right["fshearx"] - left["fshearx"],
right["fsheary"] - left["fsheary"],
]
)
return TactileSample(
timestamp=time.time(),
force_xyz=force_xyz,
torque_xyz=torque_xyz,
contact=bool(left["is_contact"] or right["is_contact"]),
embedding=np.array(
[
left["fnormal"],
right["fnormal"],
left["fshearx"],
right["fshearx"],
left["fsheary"],
right["fsheary"],
]
),
raw={"left": left, "right": right, "centroid": self._estimate_centroid(left, right)},
)
def _read_single(self, item: dict, want_vector: bool = False) -> dict[str, Any]:
sensor = item["sensor"]
cfg = item["cfg"]
img = sensor.get_img()
if img is None:
return self._empty_sample(item["side"])
is_contact = sensor.compute_deformation(check_motion=True, threshold=cfg.motion_threshold)
fps, fnormal, fshearx, fsheary = sensor.read_info(
sensor.info.FPS,
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
)
side = item["side"]
fnormal = max(0.0, float(fnormal) - self._baseline.get(f"{side}_normal", 0.0))
fshearx = float(fshearx) - self._baseline.get(f"{side}_shearx", 0.0)
fsheary = float(fsheary) - self._baseline.get(f"{side}_sheary", 0.0)
centroid = (0.0, 0.0)
centroid_found = False
try:
sensor.compute_contact()
c, found = sensor.read_info(sensor.info.CENTROID)
if found:
centroid = c
centroid_found = True
except Exception:
pass
return {
"side": side,
"fnormal": fnormal,
"fshearx": fshearx,
"fsheary": fsheary,
"fps": float(fps),
"is_contact": bool(is_contact),
"centroid": centroid,
"centroid_found": centroid_found,
"img": img,
# 矢量场只在被要求时才算:compute_flow 是逐像素运算,每帧都做会
# 明显增加 worker 耗时,可能拖累三力的 30fps。
"vector": read_vector_map(sensor) if want_vector else None,
}
def _empty_sample(self, side: str = "unknown") -> dict[str, Any]:
return {
"side": side,
"fnormal": 0.0,
"fshearx": 0.0,
"fsheary": 0.0,
"fps": 0.0,
"is_contact": False,
"centroid": (0.0, 0.0),
"centroid_found": False,
"img": None,
"vector": None,
}
def _calibrate_baseline(self) -> None:
seconds = self.cfg.baseline_seconds
if seconds <= 0:
return
buckets: dict[str, list[float]] = {
"left_normal": [],
"right_normal": [],
"left_shearx": [],
"right_shearx": [],
"left_sheary": [],
"right_sheary": [],
}
deadline = time.perf_counter() + seconds
while time.perf_counter() < deadline:
for item in self._sensors:
sensor = item["sensor"]
side = item["side"]
img = sensor.get_img()
if img is None:
continue
sensor.compute_deformation(check_motion=False)
fnormal, fshearx, fsheary = sensor.read_info(
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
)
buckets[f"{side}_normal"].append(float(fnormal))
buckets[f"{side}_shearx"].append(float(fshearx))
buckets[f"{side}_sheary"].append(float(fsheary))
time.sleep(0.01)
self._baseline = {k: float(np.mean(v)) if v else 0.0 for k, v in buckets.items()}
def _estimate_centroid(self, left: dict, right: dict) -> tuple[float, float]:
if left["centroid_found"] and right["centroid_found"]:
return (
(left["centroid"][0] + right["centroid"][0]) * 0.5,
(left["centroid"][1] + right["centroid"][1]) * 0.5,
)
if left["centroid_found"]:
return left["centroid"]
if right["centroid_found"]:
return right["centroid"]
return (0.0, 0.0)
class MockOrisysTactile(OrisysTactile):
name = "mock_orisys"
def __init__(self, config: OrisysDualConfig | None = None) -> None:
super().__init__(config)
self._n = 0
def connect(self) -> None:
self._connected = True
self._baseline = {}
self._n = 0
self._sensors = [
{"side": "left", "sensor": None, "cfg": self.cfg.left},
{"side": "right", "sensor": None, "cfg": self.cfg.right},
]
def read_sample(self) -> TactileSample:
self._n += 1
contact = self._n >= 8
force = 0.2 if contact else 0.0
return TactileSample(
timestamp=time.time(),
force_xyz=np.array([force, 0.01, 0.005]),
torque_xyz=np.zeros(3),
contact=contact,
raw={
"left": {"fnormal": force, "fshearx": 0.0, "fsheary": 0.0, "is_contact": contact, "fps": 30.0, "centroid": (0, 0), "centroid_found": False},
"right": {"fnormal": force, "fshearx": 0.0, "fsheary": 0.0, "is_contact": contact, "fps": 30.0, "centroid": (0, 0), "centroid_found": False},
},
)
def make_tactile(data: dict) -> TactileInterface:
cfg = load_orisys_from_site(data)
backend = (cfg.backend or "orisys").strip().lower()
if backend in ("mock", "mock_orisys"):
return MockOrisysTactile(cfg)
return OrisysTactile(cfg)
# ==================== 多进程工作器(厂家推荐架构,每进程独立 CUDA 上下文) ====================
# 实测:单进程双开两路传感器会互相拖垮(30→0.8fps);两个独立进程各开一路则稳定 30fps。
def _usb_devpath_for_video(vid_src) -> Optional[str]:
"""/dev/v4l/by-path/... 或 /dev/videoN → sysfs USB 设备号(如 1-3.4.1)。"""
import re
try:
real = os.path.realpath(str(vid_src))
base = os.path.basename(real)
if not base.startswith("video"):
return None
sysdev = os.path.realpath(f"/sys/class/video4linux/{base}/device")
for part in reversed(sysdev.split("/")):
if re.fullmatch(r"\d+-\d+(\.\d+)*", part):
return part
except Exception:
pass
return None
def _usb_self_heal(vid_src, side: str) -> None:
"""内核级卡死(select 超时循环)只有 unbind/bind 能救。orisys-usb-reset
是 root 拥有的白名单脚本(sudoers NOPASSWD),只认 Orisys 的 vid/pid。"""
import subprocess
dev = _usb_devpath_for_video(vid_src)
if not dev:
return f"[{side}] USB 自愈:解析不到设备号({vid_src})"
try:
r = subprocess.run(
["sudo", "-n", "/usr/local/bin/orisys-usb-reset", dev],
capture_output=True, text=True, timeout=15.0,
)
note = (r.stdout or r.stderr).strip().splitlines()
return f"[{side}] USB 自愈复位 {dev}: {note[-1] if note else 'ok'}"
except Exception as exc:
return f"[{side}] USB 自愈失败 {dev}: {exc}"
def tactile_worker(side: str, cfg: OrisysDualConfig, q, stop_event, image_q=None) -> None:
"""独立进程:开一侧传感器,把样本推进队列。
q: mp.Queue(调用方给 maxsize≈2,满了丢旧留新)—— 三力等轻量数据,每帧都推。
image_q: mp.Queue 或 None —— 视触觉图像与力矢量场。
图像默认**不过队列**(``image_q=None``):每帧几百 KB,跨进程序列化 +
ROS 发布的开销会挤占三力的 30fps 时序。需要图像时调用方传一个
``maxsize=1`` 的队列,并按 ``cfg.image_hz`` 节流推送 —— 队列满时丢旧留新,
主进程来不及消费也只会丢图像,不会影响三力。
stop_event: mp.Event,置位后干净退出(先 disconnect 释放 V4L2 设备)
自动重连:摄像头被强占/USB 抖动后会出现 get_img 持续 select 超时(空读,
单次最长阻塞 ~10s)。连续 2 次空读或单次读取 >2s 即断开重连,直到恢复。
"""
try: # 给进程起个可识别的 comm 名,便于 pkill 清理孤儿(否则占死 /dev/video*)
import ctypes
ctypes.CDLL("libc.so.6").prctl(15, b"orisys_tac_w", 0, 0, 0) # PR_SET_NAME
except Exception:
pass
def _put(item: dict) -> None:
try:
if q.full():
try:
q.get_nowait()
except Exception:
pass
q.put(item)
except Exception:
pass
def _put_image(item: dict) -> None:
"""图像入队:只保留最新一帧。队列满说明主进程没在消费,丢掉旧的即可。"""
try:
if image_q is None:
return
if image_q.full():
try:
image_q.get_nowait()
except Exception:
pass
image_q.put(item)
except Exception:
pass
period = 1.0 / max(1.0, float(getattr(cfg.left if side == "left" else cfg.right, "target_fps", 30.0)))
img_period = (1.0 / cfg.image_hz) if (image_q is not None and cfg.image_hz > 0) else 0.0
last_img_t = 0.0
vid_src = getattr(cfg.left if side == "left" else cfg.right, "vid_src", None)
tac: Optional[OrisysTactile] = None
bad = 0
dead_cycles = 0 # 连续“重连了但读不到数据”的轮数 → 内核级卡死 → USB 自愈
backoff = 0.5
while not stop_event.is_set():
if tac is None:
try:
tac = OrisysTactile(cfg)
tac.connect(sides=(side,))
_put({"side": side, "error": f"[{side}] (re)connected"})
backoff = 0.5
except Exception as exc:
_put({"side": side, "error": f"connect failed: {exc}"})
tac = None
dead_cycles += 1
if dead_cycles >= 2:
_put({"side": side, "error": _usb_self_heal(vid_src, side)})
dead_cycles = 0
time.sleep(min(backoff, 3.0))
backoff *= 2.0
continue
# 只有图像帧才算矢量场:compute_flow 是逐像素运算,每帧都算会拖累三力。
want_img = image_q is not None and (time.perf_counter() - last_img_t) >= img_period
t0 = time.perf_counter()
try:
d = tac.read_side(side, want_vector=want_img)
empty = d.get("img") is None
except Exception:
d, empty = None, True
dt = time.perf_counter() - t0
if empty or dt > 2.0:
bad += 1
if bad >= 2:
_put({"side": side, "error": f"[{side}] 相机断流,自动重连中…"})
try:
tac.disconnect()
except Exception:
pass
tac = None
bad = 0
dead_cycles += 1
if dead_cycles >= 2:
_put({"side": side, "error": _usb_self_heal(vid_src, side)})
dead_cycles = 0
continue
else:
bad = 0
dead_cycles = 0
if d is not None and not empty:
if want_img:
last_img_t = time.perf_counter()
_put_image({
"side": side,
"img": d.get("img"),
"vector": d.get("vector"),
"stamp": time.time(),
})
# 图像与矢量场已单独走 image_q;三力这条轻量队列不背它们。
d = {k: v for k, v in d.items() if k not in ("img", "vector")}
d["error"] = ""
_put(d)
rest = period - (time.perf_counter() - t0)
if rest > 0:
time.sleep(rest)
try:
if tac is not None:
tac.disconnect()
except Exception:
pass
@@ -0,0 +1,820 @@
"""D405 主跟踪丢了之后,用顶置 D435 **拍一张** 把工件 / k0 定到基座系。
为什么单独成模块
----------------
一阶段平时走腕部 D405(眼在手上)。画面里没有 conf≥门槛的目标时,
不能让伺服继续追 0.12 的假框,也不能把顶视外参再乘一遍法兰
(那会让一次性定位差几十厘米还看起来合理)。
判定、取 k0、反投影、臂是否静止 —— 全是纯函数,测试不启 ROS。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, Optional
import numpy as np
from arm_application.handeye_lib import cam_point_to_base
from arm_application.path_profile import ( # 伺服和测试从本模块再导出,不另抄一份
D435_FULL_ACC_M,
D435_FULL_CRUISE_MPS,
D435_FULL_DEC_M,
D435_MID_HOP_MAX_M,
D435_MID_LEFTOVER_M,
D435_MOVE_ACC_MPS2,
D435_MOVE_SPEED_SCALE,
D435_NEAR_CRUISE_MPS,
D435_NEAR_HOP_MAX_M,
D435_NEAR_LEFTOVER_M,
D435_VENDOR_MOVEL_ACC_MPS2,
D435_VENDOR_MOVEL_VEL_MPS,
D435_VERT_TILT_RAD,
D435_SETTLE_S,
FROZEN_J_HOP_MAX_M,
build_arc_xy_move,
flange_down_w,
flange_tilt_rad,
hybrid_cruise_vxy,
oneshot_hop_limits,
smooth_joint_schedule,
trap_acc_for_hop,
translate_hold_ori_waypoints,
)
# 上一跳实际少走这么多,就认定冻结 J 在当前姿态走不动,不许按同一剩差再刷。
D435_MIN_PROGRESS_FRAC = 0.40
def best_det_conf(dets: Optional[Iterable]) -> Optional[float]:
"""画面内最高检测置信度。空列表 / None → ``None``(不是 0:0 是"看到了但很差")。"""
if not dets:
return None
vals = [float(getattr(d, "conf", float("nan"))) for d in dets]
vals = [c for c in vals if np.isfinite(c)]
if not vals:
return None
return max(vals)
def z_locked_qos():
"""``visual_servo/z_locked`` 的 QoS。发布端和订阅端必须用这一份。
感知比伺服晚启动。默认 volatile 会丢掉伺服启动时已经发出的「已到锁高」,
感知就停在初值 False,D435 永远不拍。
"""
from rclpy.qos import ( # noqa: PLC0415 — 纯函数测试不需要在导入时拉起 ROS
DurabilityPolicy,
HistoryPolicy,
QoSProfile,
ReliabilityPolicy,
)
return QoSProfile(
history=HistoryPolicy.KEEP_LAST,
depth=1,
reliability=ReliabilityPolicy.RELIABLE,
durability=DurabilityPolicy.TRANSIENT_LOCAL,
)
def at_lock_height(z_m: float, lock_z_m: float, gate_m: float) -> bool:
"""法兰是否在锁高带内。
低于锁高**不算**到位。上一轮观测下降停在 0.26 m 时,若把「不高于锁高」
当成到位,XY 会在低位开跑,D435 又因高度不对拒绝拍照,两边都不再动。
``lock_z_m <= 0`` 表示没启用锁高。
"""
lock = float(lock_z_m)
if lock <= 0.0:
return True
gate = max(0.02, float(gate_m))
return abs(float(z_m) - lock) <= gate
def lock_z_velocity(z_m: float, lock_z_m: float, *, kp: float, v_lim: float) -> float:
"""回到锁高的竖直速度 [m/s],基座 +Z 朝上。
低于锁高为正(上抬),高于为负(下降)。4 mm 内视为到位。
不许在低于锁高时把速度写成 0:那会和「没到锁高不拍 D435」卡死。
"""
lock = float(lock_z_m)
if lock <= 0.0:
return 0.0
ez = lock - float(z_m)
if abs(ez) < 0.004:
return 0.0
lim = abs(float(v_lim))
return float(np.clip(float(kp) * ez, -lim, lim))
def hold_translation_vz(z_m: float, z_hold_m: float, *, kp: float, v_lim: float) -> float:
"""平移时把法兰钉在**这一段开始时的高度**。
只写 v_z=0 不够:大范围平移加姿态校正时,阻尼求逆会把竖直速度吃掉,
2026-10-09 真机从 0.327 m 漂到 0.307 m 又抬到 0.337 m。这里用开始那一
帧的高度当设定,4 mm 内不动,避免来回抖。不追 site 里的锁高数字,
也不因此停掉 XY。下臂仍只在二阶段。
"""
ez = float(z_hold_m) - float(z_m)
if abs(ez) < 0.004:
return 0.0
lim = abs(float(v_lim))
return float(np.clip(float(kp) * ez, -lim, lim))
# D405 连续这么久没有高置信度目标,才让待命的 D435 拍一张。
# 单帧漏检就拍,会在平移半路把目标换掉。
D405_MISS_BEFORE_D435_S = 0.5
# 拍了但伺服没 ``d435_commanding``(工作盒拒收等):超过这时限就允许再拍。
# 不清的话 ``_oneshot_shot`` 会永远真,臂停着、感知还以为在赶路。
ONESHOT_ACCEPT_TIMEOUT_S = 0.8
def oneshot_shot_timed_out(
*,
shot: bool,
commanding: bool,
age_s: float,
timeout_s: float = ONESHOT_ACCEPT_TIMEOUT_S,
) -> bool:
"""拍过一张、伺服却没接住 → True。已经在赶路不许清。"""
if not bool(shot) or bool(commanding):
return False
return float(age_s) >= float(timeout_s)
def d405_commands_arm(
*,
using_d435: bool,
arrived: bool,
settled: bool,
move_only: bool = False,
) -> bool:
"""D405 这一帧的点能不能下发给左臂。
推理一直开着。正在用 D435 那一个点时,进程内 MoveL 结束并且停稳之前不下发。
停稳用 ``d435_handback_sec`` 和 ``lock_still_move_m``。赶路时 XY 已经进 15 mm
也不算到位。不许在 MoveL 期间下发 D405(两套控制互抢会晃)。
``move_only``:只测 D435 位移,D405 点一律不下发(到位之后也不交)。
"""
if move_only:
return False
if not using_d435:
return True
return bool(arrived) and bool(settled)
def oneshot_needs_verticalize(
tilt_rad: float,
leftover_m: float,
arrive_m: float,
*,
tilt_lim: float = D435_VERT_TILT_RAD,
already_done: bool = False,
) -> bool:
"""XY 已经进到位门槛、倾角还大,才单独收垂直。不许和平移捆在一起。"""
if already_done:
return False
if float(leftover_m) > float(arrive_m):
return False
return float(tilt_rad) > float(tilt_lim)
def oneshot_next_hop_m(
dist_m: float, hop_max_m: float = FROZEN_J_HOP_MAX_M
) -> float:
"""这一跳最多走 ``hop_max_m``。冻结 J 一次规划 400 mm 会少走一大截。"""
return min(max(0.0, float(dist_m)), float(hop_max_m))
def oneshot_cartesian_dp(
p_ee,
goal_xy,
lock_z: float,
*,
gate_m: float,
hop_max_m: Optional[float] = None,
leftover_xy_m: Optional[float] = None,
):
"""这一跳的笛卡尔位移。没到锁高只收 Z;远处 XY 带锁高;近处只走 XY。
工件上方最后一跳若再把 7 mm 的 Z 和 60 mm 的 XY 捆在一起,冻结 J 会把腕部带偏。
远处仍带 ``ee_z_m``,避免长跳把高度抬出锁高带。
"""
p = np.asarray(p_ee, dtype=float).reshape(3)
z_lock = float(lock_z)
leftover = (
float(leftover_xy_m)
if leftover_xy_m is not None
else float(
np.hypot(float(goal_xy[0]) - float(p[0]), float(goal_xy[1]) - float(p[1]))
)
)
hop_cap = (
float(hop_max_m)
if hop_max_m is not None
else oneshot_hop_limits(leftover)[0]
)
if not at_lock_height(float(p[2]), z_lock, float(gate_m)):
dz = z_lock - float(p[2])
mag = oneshot_next_hop_m(abs(dz), hop_cap)
if mag < 1e-6:
return np.zeros(3, dtype=float)
return np.array([0.0, 0.0, float(np.sign(dz)) * mag], dtype=float)
dz = 0.0 if leftover <= D435_NEAR_LEFTOVER_M else (z_lock - float(p[2]))
dp = np.array(
[
float(goal_xy[0]) - float(p[0]),
float(goal_xy[1]) - float(p[1]),
dz,
],
dtype=float,
)
n = float(np.linalg.norm(dp))
hop = oneshot_next_hop_m(n, hop_cap)
if n > hop > 1e-9:
dp = dp * (hop / n)
return dp
def oneshot_full_segments(
p_ee,
goal_xy,
lock_z: float,
*,
gate_m: float,
):
"""整段一次走完的笛卡尔分段。先收齐锁高,再走全部 XY,不切 80/15 mm。
近处(剩差 ≤80 mm)仍然只走 XY,不混 Z。收垂直不在这里。
返回 ``[(dp, tag), ...]``,``tag`` 是 ``lift_z`` 或 ``xy``。
"""
p = np.asarray(p_ee, dtype=float).reshape(3).copy()
z_lock = float(lock_z)
goal = np.asarray(goal_xy, dtype=float).reshape(2)
out = []
if not at_lock_height(float(p[2]), z_lock, float(gate_m)):
dz = z_lock - float(p[2])
if abs(dz) >= 1e-6:
out.append((np.array([0.0, 0.0, dz], dtype=float), "lift_z"))
p[2] = z_lock
leftover = float(np.hypot(float(goal[0]) - float(p[0]), float(goal[1]) - float(p[1])))
dz = 0.0 if leftover <= D435_NEAR_LEFTOVER_M else (z_lock - float(p[2]))
dp = np.array(
[float(goal[0]) - float(p[0]), float(goal[1]) - float(p[1]), dz],
dtype=float,
)
if float(np.linalg.norm(dp)) >= 1e-6:
out.append((dp, "xy"))
return out
def oneshot_concat_hold_ori(q0, J, segments, *, R=None):
"""把分段 ``dp`` 用同一份冻结 J 串成一条关节折线。中途不回零速。"""
q = np.asarray(q0, dtype=float).copy()
wps = [q.copy()]
planned = 0.0
asked = 0.0
tags = []
for dp, tag in segments:
hop = float(np.linalg.norm(np.asarray(dp, dtype=float).reshape(3)))
if hop < 1e-6:
continue
asked += hop
part, remain = translate_hold_ori_waypoints(
q, J, dp, R=R, verticalize=False
)
if len(part) < 2:
break
wps.extend(part[1:])
q = wps[-1]
planned += hop - float(remain)
tags.append(str(tag))
tag = "full"
if tags == ["lift_z"]:
tag = "lift_z"
elif "lift_z" in tags and "xy" in tags:
tag = "full"
elif tags == ["xy"]:
tag = "full"
return wps, planned, asked, tag
def oneshot_full_cruise_mps(leftover_m: float, far_cruise_mps: float) -> float:
"""整段巡航:工件上方 45 mm/s;远处封顶 ``D435_FULL_CRUISE_MPS``,不许再弹到远处 hop 上限。"""
far = max(1e-4, float(far_cruise_mps))
if float(leftover_m) <= D435_NEAR_LEFTOVER_M:
return min(D435_NEAR_CRUISE_MPS, far)
return min(D435_FULL_CRUISE_MPS, far)
def oneshot_may_send_another(
leftover_m: float,
last_dist_m: Optional[float],
*,
arrive_m: float,
hop_max_m: float = FROZEN_J_HOP_MAX_M,
min_progress_frac: float = D435_MIN_PROGRESS_FRAC,
) -> bool:
"""上一跳走完后还要不要再发一条。
剩差已经进到位门槛 → 钉住。
上一跳几乎没缩短剩差 → 不许按同一距离再刷(真机 28 mm 死循环)。
"""
if float(leftover_m) <= float(arrive_m):
return False
if last_dist_m is None:
return True
progressed = float(last_dist_m) - float(leftover_m)
planned = min(float(last_dist_m), float(hop_max_m))
if progressed < float(min_progress_frac) * max(planned, 1e-6):
return False
return True
def oneshot_should_submit(
*,
handoff: bool,
low_for_s: float,
already_shot: bool,
wait_s: float = D405_MISS_BEFORE_D435_S,
) -> bool:
"""腕部连续 ``wait_s`` 秒没有高置信度目标、这一轮还没拍成,才让 D435 拍一张。
高度不在这里再判一次。进一阶段之前已经抬到锁高;再等伺服报「已到锁高」
会和「没到锁高就只收 Z、不平移」卡死。拍成一张之后不许连拍,顶视不是第二套跟踪。
"""
return (
bool(handoff)
and (not already_shot)
and float(low_for_s) >= float(wait_s)
)
def select_oneshot_det(dets: Optional[Iterable], min_conf: float):
"""顶视一次性:只收 conf≥门槛的框,取最高分。没有就 None,不许降级。"""
if not dets:
return None
ok = [
d
for d in dets
if np.isfinite(float(getattr(d, "conf", float("nan"))))
and float(d.conf) >= float(min_conf)
]
if not ok:
return None
return max(ok, key=lambda d: float(d.conf))
def depth_raw_to_m(raw: Optional[np.ndarray], encoding: str) -> Optional[np.ndarray]:
"""深度图 → 米。16UC1 常见是毫米;用中位数判量纲,不许默认当米。"""
if raw is None:
return None
enc = (encoding or "").lower()
arr = np.asarray(raw)
if arr.size == 0:
return None
if enc in ("16uc1", "mono16"):
pos = arr[arr > 0]
if pos.size < 8:
return None
med = float(np.median(pos))
scale = 0.001 if 20.0 <= med <= 8000.0 else (1.0 if med < 20 else 0.0001)
out = arr.astype(float) * scale
out[arr <= 0] = np.nan
return out
if enc == "32fc1":
return arr.astype(float)
return None
def map_uv_to_depth(
u: float, v: float, color_wh: tuple[int, int], depth_wh: tuple[int, int]
) -> tuple[float, float]:
"""彩色图像素 → 对齐深度图像素。分辨率相同则原样返回。"""
cw, ch = int(color_wh[0]), int(color_wh[1])
dw, dh = int(depth_wh[0]), int(depth_wh[1])
if cw <= 0 or ch <= 0 or (cw == dw and ch == dh):
return float(u), float(v)
return float(u) * (dw / float(cw)), float(v) * (dh / float(ch))
def wrist_needs_handoff(best_conf: Optional[float], threshold: float) -> bool:
"""腕部这一帧有没有 conf≥门槛的目标。没有 → 该切 D435。
``None`` 当没有检测。门槛必须显式传入,不许在这里写死再抄一份到 site.yaml。
"""
if best_conf is None:
return True
try:
c = float(best_conf)
except (TypeError, ValueError):
return True
if not np.isfinite(c):
return True
return c < float(threshold)
def k0_uv_from_det(
det,
*,
kpt_index: int = 0,
kpt_min_conf: float = 0.30,
) -> Optional[tuple[float, float]]:
"""从 pose 检测取 k0 像素。k0 不可信就返回 None(不许拿框心冒充 k0)。"""
kpts = getattr(det, "kpts", None)
if kpts is None:
return None
arr = np.asarray(kpts, dtype=float)
if arr.ndim != 2 or arr.shape[0] <= int(kpt_index) or arr.shape[1] < 3:
return None
u, v, kc = (float(x) for x in arr[int(kpt_index)][:3])
if not (np.isfinite(u) and np.isfinite(v) and np.isfinite(kc)):
return None
if kc < float(kpt_min_conf):
return None
return u, v
def workpiece_uv_from_det(
det,
*,
kpt_index: int = 0,
kpt_min_conf: float = 0.30,
) -> tuple[float, float, bool]:
"""优先 k0;没有可信 k0 时退回框心,并标明 ``is_k0=False``。
顶视经常看不到腕部视角训出来的 k0。退回框心仍能把臂迁到工件上方,
让 D405 重新看见 k0 —— 比假装"已经是 k0"好。调用方必须看第三个返回值。
"""
uv = k0_uv_from_det(det, kpt_index=kpt_index, kpt_min_conf=kpt_min_conf)
if uv is not None:
return uv[0], uv[1], True
return float(det.cx), float(det.cy), False
def depth_at_m(
depth_m: np.ndarray,
u: float,
v: float,
*,
win: int = 3,
) -> Optional[float]:
"""窗口中位数深度 [m]。无效则 None,不许拿 0 去反投影。"""
if depth_m is None or depth_m.size == 0:
return None
h, w = int(depth_m.shape[0]), int(depth_m.shape[1])
ui, vi = int(round(u)), int(round(v))
r = max(1, int(win))
x0, x1 = max(0, ui - r), min(w, ui + r + 1)
y0, y1 = max(0, vi - r), min(h, vi + r + 1)
if x1 <= x0 or y1 <= y0:
return None
patch = np.asarray(depth_m[y0:y1, x0:x1], dtype=float).reshape(-1)
patch = patch[np.isfinite(patch) & (patch > 1e-4)]
if patch.size == 0:
return None
return float(np.median(patch))
def pixel_on_base_plane(
u: float,
v: float,
fx: float,
fy: float,
cx: float,
cy: float,
T_cam_to_base: np.ndarray,
plane_z_m: float,
) -> Optional[np.ndarray]:
"""彩色像素射线和基座水平面 ``z=plane_z_m`` 的交点 [m]。不用深度图。
相机系方向是 ``((u-cx)/fx, (v-cy)/fy, 1)``,乘外参旋转到基座。
交点在相机后面,或射线几乎贴着平面,返回 None。
"""
T = np.asarray(T_cam_to_base, dtype=float).reshape(4, 4)
direction = np.array(
[(float(u) - float(cx)) / float(fx), (float(v) - float(cy)) / float(fy), 1.0],
dtype=float,
)
d_base = T[:3, :3] @ direction
if abs(float(d_base[2])) < 1e-9:
return None
scale = (float(plane_z_m) - float(T[2, 3])) / float(d_base[2])
if not np.isfinite(scale) or scale <= 0.0:
return None
point = T[:3, 3] + scale * d_base
if not np.all(np.isfinite(point)):
return None
return np.asarray(point, dtype=float).reshape(3)
def cam_xy_bias_axes(T_cam_to_base: np.ndarray):
"""基座水平面上两个单位向量:``(视线前方, 画面右)``。
视线 = 相机 +Z 投到基座 XY(前倾 30° 时的交平面敏感轴);
画面右 = 相机 +X 投到基座 XY(立体基线 / 图像 u 方向)。
水平投影退化时返回 None,不许拿 (0,0) 当轴。
"""
T = np.asarray(T_cam_to_base, dtype=float).reshape(4, 4)
look = T[:3, :3] @ np.array([0.0, 0.0, 1.0], dtype=float)
right = T[:3, :3] @ np.array([1.0, 0.0, 0.0], dtype=float)
look_xy = np.asarray(look[:2], dtype=float)
right_xy = np.asarray(right[:2], dtype=float)
n_l = float(np.linalg.norm(look_xy))
n_r = float(np.linalg.norm(right_xy))
if n_l < 1e-6 or n_r < 1e-6:
return None
return look_xy / n_l, right_xy / n_r
# 单次精拍这一步必须能走完「框心 → k1」。两孔间距约 60mm,半距 30mm。
# 多走多拍的 12mm 封顶在这里会把一半孔距留下(2026-10-13 真机:指令 29mm 只走 12mm)。
ONESHOT_ALIGN_CAP_M = 0.040
# 下降后补 XY:一跳上限。2026-10-13 两跳合计 4.3mm 把近距检测带崩。
ONCE_TRIM_MAX_STEP_M = 0.003
ONCE_TRIM_REFUSE_M = 0.008
HOLE_PAIR_TRIM_MIN_M = 0.050
HOLE_PAIR_TRIM_MAX_M = 0.070
# 悬停 → 观察高(约 15mm)单步上限。左臂间隙约 58mm,20mm × 3 步走完。
# 观察高 → 贴面不再走这段,改斜线一步(XY 补偿 + 剩余 Z)。
ONCE_APPROACH_STEP_M = 0.020
# 悬停已对上时 ΔXY 必须为 0。site ``once_ff_look_per_m`` 同源。
# 2026-10-14 下午:框心改平面交点后两条链路下臂都仍 +1.3mm X 过补插不进。
# k=0.018 是深度框心时代的对冲,不是下降几何。笛卡尔 MoveL 只降 Z。
ONCE_FF_LOOK_PER_M = 0.0
ONCE_FF_MOVEL_VEL_MPS = 0.05
def once_look_ff_xy(look_xy, descent_m: float, k: float):
"""视线前馈 ΔXY [m]。k≤0 是笛卡尔锁 XY:返回 (0,0),不是 None。
k>0 时沿 -look 走 k×下降。下降≤0 / 视线退化 → None。
"""
if float(descent_m) <= 1e-9:
return None
if float(k) <= 1e-9:
return np.array([0.0, 0.0], dtype=float)
look = np.asarray(look_xy, dtype=float).reshape(2)
n = float(np.linalg.norm(look))
if n < 1e-6 or not np.all(np.isfinite(look)):
return None
look = look / n
dxy = -look * float(k) * float(descent_m)
if not np.all(np.isfinite(dxy)):
return None
return dxy
def once_ff_tcp_xyz(
flange_xy,
tip_off_xy,
look_xy,
descent_m: float,
k: float,
surface_z_m: float,
gap_m: float,
):
"""规尖(厂家 MoveL 的 TCP)目标 [m]。Z 是面 + 间隙,不是法兰。"""
dxy = once_look_ff_xy(look_xy, descent_m, k)
if dxy is None:
return None
off = np.asarray(tip_off_xy, dtype=float).reshape(2)
fl = np.asarray(flange_xy, dtype=float).reshape(2)
if not np.all(np.isfinite(off)) or not np.all(np.isfinite(fl)):
return None
tcp = np.array(
[
float(fl[0]) + float(off[0]) + float(dxy[0]),
float(fl[1]) + float(off[1]) + float(dxy[1]),
float(surface_z_m) + float(gap_m),
],
dtype=float,
)
if not np.all(np.isfinite(tcp)):
return None
return tcp, dxy
def d435_movel_from_tcp(tcp_xyz, flange_xy, k0_xy):
"""D435 粗定位 MoveL 目标 [m]:当前厂家 TCP + (K0−法兰) 的 ROS XY。
keep_z 由硬件用当前 TCP 的 Z 覆盖。ROS 绝对 K0 不能当 WORLD 目标
(2026-10-14 贴面 MoveL 已踩过)。缺数 / 非有限 → None。
"""
try:
t = np.asarray(tcp_xyz, dtype=float).reshape(3)
fl = np.asarray(flange_xy, dtype=float).reshape(2)
k0 = np.asarray(k0_xy, dtype=float).reshape(2)
except (TypeError, ValueError):
return None
if t.size != 3 or fl.size != 2 or k0.size != 2:
return None
if not np.all(np.isfinite(t)) or not np.all(np.isfinite(fl)) or not np.all(np.isfinite(k0)):
return None
out = np.array(
[float(t[0]) + float(k0[0]) - float(fl[0]),
float(t[1]) + float(k0[1]) - float(fl[1]),
float(t[2])],
dtype=float,
)
if not np.all(np.isfinite(out)):
return None
return out
def once_ff_movel_from_tcp(tcp_xyz, dxy, descent_m: float):
"""厂家 MoveL 目标 = 当前 Studio TCP + (ΔXY, −下降) [m]。
2026-10-14:把 ROS 规尖绝对坐标发给 MoveL,k=0.018 与 0.035 都落到
WORLD (0.595, −0.468, 0.024),法兰实走约 (+9, −8)mm。WORLD 原点与
ROS 基座不是同一点,绝对坐标不能用。相对位移才跟 k 有关。
"""
if float(descent_m) <= 1e-9:
return None
t = np.asarray(tcp_xyz, dtype=float).reshape(3)
d = np.asarray(dxy, dtype=float).reshape(2)
if t.size != 3 or d.size != 2:
return None
if not np.all(np.isfinite(t)) or not np.all(np.isfinite(d)):
return None
out = np.array(
[float(t[0]) + float(d[0]), float(t[1]) + float(d[1]), float(t[2]) - float(descent_m)],
dtype=float,
)
if not np.all(np.isfinite(out)):
return None
return out
def oneshot_align_step_m(err_m: float) -> float:
"""单次精拍允许的步长 [m]。不超过半孔距余量;超过 cap 才截断。"""
e = abs(float(err_m))
cap = float(ONESHOT_ALIGN_CAP_M)
if e <= cap:
return e
return cap
def hole_pair_span_m(p0, p1) -> float:
"""两孔基座 XY 间距 [m]。"""
a = np.asarray(p0, dtype=float).reshape(-1)
b = np.asarray(p1, dtype=float).reshape(-1)
return float(np.hypot(float(a[0]) - float(b[0]), float(a[1]) - float(b[1])))
def hole_pair_span_ok(span_m: float) -> bool:
"""下降后补孔用的间距窗。真机 k1/k2 约 60mm;45mm 是近距检测漂了。"""
s = float(span_m)
return HOLE_PAIR_TRIM_MIN_M <= s <= HOLE_PAIR_TRIM_MAX_M
def flange_z_for_gap_m(tip_len_m: float, surface_z_m: float, gap_m: float) -> float:
"""规尖离面 ``gap_m`` 时的法兰 Z [m]。与轻降几何同一条式子。"""
return float(tip_len_m) + float(surface_z_m) + float(gap_m)
def final_gap_refusal(
gap_m: float,
tool_len_m: float,
surface_z_m: float,
hover_z_m: float,
z_min_m: float,
z_max_m: float,
) -> str:
"""笛卡尔锁 XY 的贴面间隙能不能用。空串表示可以;非空就是拒绝原因。
参数是规尖离面 [m],不是法兰 Z:法兰 = 工具长 + 工件面 + 间隙,只在这里算一次。
"""
gap = float(gap_m)
if not np.isfinite(gap):
return "贴面间隙不是有限数,拒绝开跑"
if gap <= 1e-9:
return (
f"贴面间隙 {gap * 1000:.1f}mm ≤ 0,规尖会进工件/贴面接触,拒绝开跑"
)
tool = float(tool_len_m)
surf = float(surface_z_m)
if tool <= 0.0 or surf <= 0.0:
return "写了贴面间隙,但工具长度或工件面未知,无法确认尖端在面之上,拒绝开跑"
hover_clear = float(hover_z_m) - tool - surf
if gap > hover_clear + 1e-6:
return (
f"贴面间隙 {gap * 1000:.1f}mm 比悬停离面 {hover_clear * 1000:.1f}mm 还高,"
"锁 XY 只会抬臂,拒绝开跑"
)
flange = flange_z_for_gap_m(tool, surf, gap)
z_lo = float(z_min_m)
z_hi = float(z_max_m)
if flange < z_lo - 1e-6 or flange > z_hi + 1e-6:
return (
f"贴面法兰 Z {flange:.3f}m(规尖离面 {gap * 1000:.1f}mm)"
f"超出工作盒 Z [{z_lo:.3f}, {z_hi:.3f}],拒绝开跑"
)
return ""
def decompose_base_xy(delta_xy, T_cam_to_base: np.ndarray):
"""把基座 XY 残差拆成沿视线 / 沿画面右 [m]。投影退化返回 None。"""
axes = cam_xy_bias_axes(T_cam_to_base)
if axes is None:
return None
look_xy, right_xy = axes
d = np.asarray(delta_xy, dtype=float).reshape(2)
return {
"along_look_m": float(np.dot(d, look_xy)),
"along_right_m": float(np.dot(d, right_xy)),
"look_xy": [float(look_xy[0]), float(look_xy[1])],
"right_xy": [float(right_xy[0]), float(right_xy[1])],
}
def px_to_cam(
u: float, v: float, z: float, fx: float, fy: float, cx: float, cy: float
) -> np.ndarray:
return np.array(
[(u - cx) * z / fx, (v - cy) * z / fy, z], dtype=float
)
def oneshot_k0_base(
det,
depth_m: np.ndarray,
*,
fx: float,
fy: float,
cx: float,
cy: float,
T_cam_to_base: np.ndarray,
min_z_m: float,
max_z_m: float,
kpt_index: int = 0,
kpt_min_conf: float = 0.30,
min_det_conf: float = 0.5,
color_wh: Optional[tuple[int, int]] = None,
depth_wh: Optional[tuple[int, int]] = None,
) -> Optional[tuple[np.ndarray, bool]]:
"""D435 一张图 → 基座系 XYZ。失败返回 None。
外参必须是 **cam→基座**(eye_to_hand)。传入腕部 T 会得到错位置且不报错,
所以调用方必须自己保证文件对;本函数不再猜。
反投影用**彩色图**像素 + 彩色图内参;深度只在对齐图上取样。
"""
if det is None or float(getattr(det, "conf", 0.0)) < float(min_det_conf):
return None
u, v, is_k0 = workpiece_uv_from_det(
det, kpt_index=kpt_index, kpt_min_conf=kpt_min_conf
)
u_d, v_d = u, v
if color_wh is not None and depth_wh is not None:
u_d, v_d = map_uv_to_depth(u, v, color_wh, depth_wh)
z = depth_at_m(depth_m, u_d, v_d)
if z is None or z < float(min_z_m) or z > float(max_z_m):
return None
p_cam = px_to_cam(u, v, z, fx, fy, cx, cy)
T_ee = np.eye(4) # eye_to_hand 不用法兰,占位
p_base = cam_point_to_base(p_cam, T_cam_to_base, T_ee, "eye_to_hand")
if not np.all(np.isfinite(p_base)):
return None
return np.asarray(p_base, dtype=float).reshape(3), bool(is_k0)
@dataclass
class StillLock:
"""D405 跟住时,法兰 XY 连续 ``still_sec`` 秒位移不超过 ``still_move_m`` → 锁定。
顶视一次性迁过去的那段路**不算**锁定:``tracking_ok`` 必须是腕部有效目标。
大幅一动就重计时,避免"晃一下刚好满 3 秒"误进二阶段。
"""
still_sec: float
still_move_m: float
_t0: Optional[float] = None
_anchor_xy: Optional[np.ndarray] = None
def reset(self) -> None:
self._t0 = None
self._anchor_xy = None
def update(
self,
p_ee_xy: Optional[np.ndarray],
now: float,
*,
tracking_ok: bool,
) -> bool:
if (not tracking_ok) or p_ee_xy is None:
self.reset()
return False
xy = np.asarray(p_ee_xy, dtype=float).reshape(2)
if self._anchor_xy is None or self._t0 is None:
self._anchor_xy = xy.copy()
self._t0 = float(now)
return False
if float(np.linalg.norm(xy - self._anchor_xy)) > float(self.still_move_m):
self._anchor_xy = xy.copy()
self._t0 = float(now)
return False
return (float(now) - float(self._t0)) >= float(self.still_sec)
@@ -0,0 +1,556 @@
"""路径弧长上的梯形速度,以及 XY 圆弧。
二阶段下臂和一阶段 D435 迁臂共用这一份。不许再抄一套加减速。
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Optional, Tuple
import numpy as np
# 整段同一巡航:site ``v_slow_xy`` × 本系数。1.5 → 120 mm/s(远处 hop 上限)。
D435_MOVE_SPEED_SCALE = 1.5
# D435 厂家 MoveL:SDK ``vel`` [m/s]、``acc`` [m/s²]。
# 厂家示例自由空间 0.6 / 近接触 0.2;本档 0.3 和 1.5,短行程也能爬到巡航。
# 只改 vel、不给 acc 时,短 hop 仍被默认加速度卡住,肉眼看不出。
D435_VENDOR_MOVEL_VEL_MPS = 0.3
D435_VENDOR_MOVEL_ACC_MPS2 = 1.5
# 缺路程时的兜底加速度。真机 hop 用 ``trap_acc_for_hop``,和二阶段同一条刹车。
D435_MOVE_ACC_MPS2 = 0.1875
# action 走完后再停这么久,才重测 TF。二阶段 ``_goto`` 的 settle_s。
D435_SETTLE_S = 0.25
# 冻结雅可比一次只信这么远。二阶段 ``_plan_and_send`` 默认 ``max_seg`` 同源。
# 400 mm 一次规划,真机剩 117 mm,随后 28 mm 死循环刷轨迹。
FROZEN_J_HOP_MAX_M = 0.08
# 剩差进入这一带 = 工件上方。真机最后一跳 37–75 mm @ 80 mm/s 会晃并偏转。
D435_NEAR_LEFTOVER_M = 0.08
D435_NEAR_HOP_MAX_M = 0.015
D435_NEAR_CRUISE_MPS = 0.045
# 法兰倾角超过这个才单独收垂直。和 XY 捆在同一跳会把腕部漏成 Y 过冲。
D435_VERT_TILT_RAD = 0.015
D435_MID_LEFTOVER_M = 0.15
D435_MID_HOP_MAX_M = 0.040
# 弦长的 12% 当矢高,封顶 50 mm,避免大圆弧甩出工作盒。
D435_ARC_SAGITTA_FRAC = 0.12
D435_ARC_SAGITTA_MAX_M = 0.05
# 整段一次走完。起步必须够长:40 mm 就爬到 80 mm/s 是真机那下弹射
# (头尾速度本来就是 0,不是初速度;平移也不下发角速度)。
# 峰值加速度 a_max = π v² / (4 d_acc);150 mm @ 90 mm/s → 约 0.042 m/s²。
# 巡航相对原 60 mm/s 加 50%;加减速路程同比加长,起步时间差不多。
D435_FULL_ACC_M = 0.150
D435_FULL_DEC_M = 0.120
D435_FULL_CRUISE_MPS = 0.090
def hybrid_cruise_vxy(
v_slow_xy: float, scale: float = D435_MOVE_SPEED_SCALE
) -> float:
"""远处巡航。近处由 ``oneshot_hop_limits`` 另给一档,不许再抄 15 mm/s。"""
return max(1e-4, float(v_slow_xy) * float(scale))
def oneshot_hop_limits(
leftover_m: float, far_cruise_mps: float = 0.08
) -> Tuple[float, float]:
"""这一跳的 ``(hop_max_m, cruise_mps)``。近处必须比 80 mm / 80 mm/s 短且慢。"""
d = max(0.0, float(leftover_m))
far = max(1e-4, float(far_cruise_mps))
if d <= D435_NEAR_LEFTOVER_M:
return D435_NEAR_HOP_MAX_M, min(D435_NEAR_CRUISE_MPS, far)
if d <= D435_MID_LEFTOVER_M:
return D435_MID_HOP_MAX_M, far
return FROZEN_J_HOP_MAX_M, far
def flange_down_w(
R,
*,
dead_rad: float = 0.006,
gain: float = 2.0,
mag_lim: float = 0.5,
) -> Tuple[np.ndarray, float]:
"""让法兰 +Z 指向世界 −Z 的角速度 + 当前倾角。
和二阶段 ``_w_down`` 同一份。死区 0.006 rad(约 0.34°)以下不再修。
"""
z_now = np.asarray(R, 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))
ang = float(math.acos(c))
if ang < float(dead_rad):
return np.zeros(3, dtype=float), ang
axis = np.cross(z_now, z_des)
n = float(np.linalg.norm(axis))
axis = axis / n if n > 1e-9 else np.array([1.0, 0.0, 0.0], dtype=float)
mag = float(np.clip(float(gain) * ang, -float(mag_lim), float(mag_lim)))
return axis * mag, ang
def flange_tilt_rad(R) -> float:
"""法兰 +Z 相对世界 −Z 的倾角 [rad]。只读角度,不带死区。"""
z_now = np.asarray(R, 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))
return float(math.acos(c))
def trap_acc_for_hop(dist: float, v: float) -> float:
"""二阶段 ``_plan_and_send`` 同一条刹车:路程越长刹车越远,不许另写一套。"""
v_cmd = max(min(float(v), 0.08), 1e-3)
d_brake = min(0.030, max(0.006, 0.32 * max(float(dist), 1e-6)))
return max(v_cmd * v_cmd / (2.0 * d_brake), 0.030)
def trap_params(dist: float, v: float, a: float) -> Tuple[float, float, float, float, float]:
"""Rest-to-rest 梯形:返回 (T, v_peak, t_acc, t_cruise, a)。巡航速度保持 v。"""
dist = max(float(dist), 1e-6)
v = max(float(v), 1e-4)
a = max(float(a), 1e-3)
d_acc = v * v / (2.0 * a)
if 2.0 * d_acc >= dist:
v_peak = math.sqrt(max(a * dist, 1e-12))
t_acc = v_peak / a
return 2.0 * t_acc, v_peak, t_acc, 0.0, a
t_acc = v / a
t_cruise = (dist - 2.0 * d_acc) / v
return 2.0 * t_acc + t_cruise, v, t_acc, t_cruise, a
def trap_sv(
t: float, dist: float, v_peak: float, t_acc: float, t_cruise: float, a: float,
) -> Tuple[float, float]:
"""时刻 t 的路径弧长 s 与切向速度。"""
t = max(0.0, float(t))
t_dec0 = t_acc + t_cruise
t_end = t_dec0 + t_acc
if t >= t_end:
return dist, 0.0
if t < t_acc:
return 0.5 * a * t * t, a * t
if t < t_dec0:
return 0.5 * a * t_acc * t_acc + v_peak * (t - t_acc), v_peak
td = t - t_dec0
s0 = 0.5 * a * t_acc * t_acc + v_peak * t_cruise
return s0 + v_peak * td - 0.5 * a * td * td, max(0.0, v_peak - a * td)
def smooth_path_params(
dist: float,
v: float,
*,
d_acc: float = D435_FULL_ACC_M,
d_dec: float = D435_FULL_DEC_M,
) -> Tuple[float, float, float, float, float, float]:
"""整段一次走完的余弦加减速:(T, v_peak, t_acc, t_cruise, d_acc, d_dec)。
加速/减速段速度按半个余弦走,两端加速度为 0,没有梯形那种加速度台阶。
路程不够巡航时整段一条余弦,峰值不超过 ``v``。
"""
dist = max(float(dist), 1e-6)
v = max(float(v), 1e-4)
acc = min(max(float(d_acc), 1e-4), 0.45 * dist)
dec = min(max(float(d_dec), 1e-4), 0.45 * dist)
if acc + dec >= dist - 1e-6:
# 整段一条正弦速度:v = v_peak sin(π t / T),s(T)=2 v_peak T / π。
T = math.pi * dist / (2.0 * v)
return T, v, 0.0, 0.0, 0.0, dist
t_acc = 2.0 * acc / v
t_dec = 2.0 * dec / v
t_cruise = (dist - acc - dec) / v
T = t_acc + t_cruise + t_dec
return T, v, t_acc, t_cruise, acc, dec
def cosine_peak_acc(v: float, d_acc: float) -> float:
"""余弦加速段的峰值切向加速度 [m/s²]。``d_acc`` 越长,起步越不弹。"""
d = max(float(d_acc), 1e-6)
return math.pi * float(v) * float(v) / (4.0 * d)
def smooth_path_sv(
t: float,
dist: float,
v_peak: float,
t_acc: float,
t_cruise: float,
d_acc: float,
d_dec: float,
) -> Tuple[float, float]:
"""时刻 t 的弧长与切向速度。头尾速度为 0。"""
t = max(0.0, float(t))
dist = max(float(dist), 1e-6)
v_peak = max(float(v_peak), 1e-4)
if t_acc <= 1e-9 and t_cruise <= 1e-9:
T = math.pi * dist / (2.0 * v_peak)
if t >= T:
return dist, 0.0
u = math.pi * t / T
s = (v_peak * T / math.pi) * (1.0 - math.cos(u))
vs = v_peak * math.sin(u)
return min(s, dist), max(0.0, vs)
t_dec = 2.0 * float(d_dec) / v_peak if v_peak > 1e-9 else 0.0
t_end = float(t_acc) + float(t_cruise) + t_dec
if t >= t_end:
return dist, 0.0
if t < t_acc:
u = math.pi * t / t_acc
s = 0.5 * v_peak * (t - t_acc / math.pi * math.sin(u))
vs = 0.5 * v_peak * (1.0 - math.cos(u))
return s, vs
if t < t_acc + t_cruise:
return float(d_acc) + v_peak * (t - t_acc), v_peak
td = t - t_acc - t_cruise
u = math.pi * td / t_dec if t_dec > 1e-9 else math.pi
s0 = float(d_acc) + v_peak * float(t_cruise)
s = s0 + 0.5 * v_peak * (td + t_dec / math.pi * math.sin(u))
vs = 0.5 * v_peak * (1.0 + math.cos(u))
return min(s, dist), max(0.0, vs)
def _circle_3(
a: np.ndarray, b: np.ndarray, c: np.ndarray
) -> Optional[Tuple[np.ndarray, float]]:
ax, ay = float(a[0]), float(a[1])
bx, by = float(b[0]), float(b[1])
cx, cy = float(c[0]), float(c[1])
d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
if abs(d) < 1e-9:
return None
a2 = ax * ax + ay * ay
b2 = bx * bx + by * by
c2 = cx * cx + cy * cy
ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d
uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d
center = np.array([ux, uy], dtype=float)
r = float(np.hypot(ax - ux, ay - uy))
if r < 1e-4:
return None
return center, r
def _ccw(a0: float, a1: float) -> float:
return (a1 - a0) % (2.0 * math.pi)
def _xy_in_box(xy: np.ndarray, lo: np.ndarray, hi: np.ndarray) -> bool:
return bool(
float(lo[0]) <= float(xy[0]) <= float(hi[0])
and float(lo[1]) <= float(xy[1]) <= float(hi[1])
)
@dataclass
class ArcXyMove:
"""从 p0 走到 p1 的 XY 圆弧(或退化成直线),弧长上跑梯形速度。"""
p0: np.ndarray
p1: np.ndarray
length: float
v_cruise: float
T: float
v_peak: float
t_acc: float
t_cruise: float
a: float
t0: float
linear: bool
center: Optional[np.ndarray] = None
radius: float = 0.0
ang0: float = 0.0
sweep: float = 0.0
s_last: float = 0.0
_dir: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0]))
def sample(self, now: float) -> Tuple[np.ndarray, np.ndarray, bool]:
"""返回 (计划 XY, 切向速度, 是否走完)。"""
t = max(0.0, float(now) - float(self.t0))
s, sdot = trap_sv(
t, self.length, self.v_peak, self.t_acc, self.t_cruise, self.a
)
s = min(float(s), float(self.length))
self.s_last = s
done = t >= float(self.T) - 1e-4 or s >= float(self.length) - 1e-5
if self.linear or self.radius <= 1e-6 or self.center is None:
L = max(float(self.length), 1e-6)
u = s / L
xy = (1.0 - u) * self.p0 + u * self.p1
vxy = self._dir * float(sdot)
return xy, vxy, done
frac = 0.0 if abs(self.sweep) < 1e-9 else s / (self.radius * abs(self.sweep))
frac = min(1.0, max(0.0, frac))
ang = self.ang0 + self.sweep * frac
xy = self.center + self.radius * np.array(
[math.cos(ang), math.sin(ang)], dtype=float
)
tang = np.array([-math.sin(ang), math.cos(ang)], dtype=float)
if self.sweep < 0.0:
tang = -tang
return xy, tang * float(sdot), done
def build_arc_xy_move(
p0,
p1,
*,
v: float,
a: float,
now: float,
ws_min=None,
ws_max=None,
sag_frac: float = D435_ARC_SAGITTA_FRAC,
sag_max: float = D435_ARC_SAGITTA_MAX_M,
) -> ArcXyMove:
"""p0→p1 的圆弧。工作盒装不下就换侧,再不行走直线。速度整段同一档。"""
p0 = np.asarray(p0, dtype=float).reshape(2)
p1 = np.asarray(p1, dtype=float).reshape(2)
chord = p1 - p0
L = float(np.hypot(chord[0], chord[1]))
direction = (
chord / L if L > 1e-6 else np.array([1.0, 0.0], dtype=float)
)
cruise = max(float(v), 1e-4)
acc = max(float(a), 1e-3)
def _linear() -> ArcXyMove:
T, v_peak, t_acc, t_cruise, a_use = trap_params(max(L, 1e-6), cruise, acc)
return ArcXyMove(
p0=p0.copy(),
p1=p1.copy(),
length=max(L, 1e-6),
v_cruise=cruise,
T=T,
v_peak=v_peak,
t_acc=t_acc,
t_cruise=t_cruise,
a=a_use,
t0=float(now),
linear=True,
_dir=direction,
)
if L < 0.003:
return _linear()
n = np.array([-direction[1], direction[0]], dtype=float)
h = min(float(sag_max), float(sag_frac) * L)
mid = 0.5 * (p0 + p1)
lo = None if ws_min is None else np.asarray(ws_min, dtype=float).reshape(-1)[:2]
hi = None if ws_max is None else np.asarray(ws_max, dtype=float).reshape(-1)[:2]
def _try(sign: float) -> Optional[ArcXyMove]:
apex = mid + n * (h * sign)
cir = _circle_3(p0, apex, p1)
if cir is None:
return None
center, r = cir
a0 = math.atan2(p0[1] - center[1], p0[0] - center[0])
a1 = math.atan2(p1[1] - center[1], p1[0] - center[0])
a_via = math.atan2(apex[1] - center[1], apex[0] - center[0])
ccw_end = _ccw(a0, a1)
ccw_via = _ccw(a0, a_via)
if 1e-6 < ccw_via < ccw_end - 1e-6:
sweep = ccw_end
else:
sweep = ccw_end - 2.0 * math.pi
if abs(sweep) < 1e-6:
return None
length = r * abs(sweep)
if lo is not None and hi is not None:
for k in range(9):
frac = k / 8.0
ang = a0 + sweep * frac
xy = center + r * np.array([math.cos(ang), math.sin(ang)])
if not _xy_in_box(xy, lo, hi):
return None
T, v_peak, t_acc, t_cruise, a_use = trap_params(length, cruise, acc)
return ArcXyMove(
p0=p0.copy(),
p1=p1.copy(),
length=length,
v_cruise=cruise,
T=T,
v_peak=v_peak,
t_acc=t_acc,
t_cruise=t_cruise,
a=a_use,
t0=float(now),
linear=False,
center=center,
radius=r,
ang0=a0,
sweep=sweep,
)
for sign in (1.0, -1.0):
got = _try(sign)
if got is not None:
return got
return _linear()
def path_interp(waypoints, u: float):
"""沿关节路点折线插值。u∈[0,1] → (q, dq/du)。"""
nseg = len(waypoints) - 1
if nseg <= 0:
z = np.zeros_like(waypoints[0])
return waypoints[0].copy(), z
u = float(min(1.0, max(0.0, u)))
x = u * nseg
i = min(nseg - 1, int(math.floor(x)))
a = x - i
q = (1.0 - a) * waypoints[i] + a * waypoints[i + 1]
dq_du = (waypoints[i + 1] - waypoints[i]) * nseg
return q, dq_du
def translate_hold_ori_waypoints(
q0,
J,
dp,
*,
damping: float = 0.08,
max_dq: float = 0.06,
R=None,
verticalize: bool = False,
):
"""冻结当前雅可比,把 ``dp`` 拆成关节路点。
和二阶段 ``_plan_and_send`` 同一份逆解。``verticalize`` 时顺带收法兰朝下,
不许另抄一份 ``_w_down``。返回 ``(路点, 还剩多少米)``。
"""
from arm_application.geom_ik import dls_translate_hold_ori, dls_twist
q = np.asarray(q0, dtype=float).copy()
remain = np.asarray(dp, dtype=float).reshape(3).copy()
dist = float(np.linalg.norm(remain))
wps = [q.copy()]
inner_tol = min(0.00012, max(0.00005, 0.05 * max(dist, 1e-6)))
n_max = int(math.ceil(dist / 0.008)) + 16
Jj = np.asarray(J, dtype=float)
w_step = np.zeros(3, dtype=float)
ang = 0.0
if verticalize:
if R is None:
raise ValueError("verticalize 必须传入当前法兰 R,不许假装已经朝下")
w_step, ang = flange_down_w(R)
for _ in range(n_max):
if float(np.linalg.norm(remain)) < inner_tol and (
(not verticalize) or ang < 0.006
):
break
nseg = max(1, int(math.ceil(float(np.linalg.norm(remain)) / 0.012)))
step = remain / nseg
if verticalize and ang > 0.008:
qdot = dls_twist(Jj, step / 0.12, w_base=w_step, damping=damping, max_qdot=0.45)
dq = np.clip(qdot * 0.12, -float(max_dq), float(max_dq))
else:
dq = dls_translate_hold_ori(Jj, step, damping=damping, max_dq=max_dq)
if float(np.max(np.abs(dq))) < 1e-5:
break
q = q + dq
wps.append(q.copy())
remain = remain - (Jj[:3, :] @ dq)
ang = max(0.0, ang - float(np.linalg.norm(w_step)) * 0.12)
return wps, float(np.linalg.norm(remain))
def trap_joint_schedule(
waypoints,
dist: float,
v: float,
a: float,
*,
t0: float = 0.06,
dt: float = 0.04,
t_hold: float = 0.25,
):
"""路点 + 梯形速度 → ``[(q, qdot, t), ...]``。头尾速度为 0,时间严格递增。
一次下发整段。中途再发下一条会把这条掐掉,臂就重新加速 —— 那就是抖。
"""
dist = max(float(dist), 1e-6)
T_move, v_peak, t_acc, t_cruise, a_use = trap_params(dist, v, a)
n_samp = max(8, int(math.ceil(T_move / max(float(dt), 1e-3))))
zeros = np.zeros_like(waypoints[0])
out = []
last_t = -1.0
for k in range(n_samp + 1):
t_local = T_move * k / n_samp
s, vs = trap_sv(t_local, dist, v_peak, t_acc, t_cruise, a_use)
u = s / dist
qi, dq_du = path_interp(waypoints, u)
qdot = dq_du * (vs / dist) if k < n_samp else zeros
if k >= n_samp:
qi = waypoints[-1]
qdot = zeros
t = float(t0) + t_local
if t <= last_t:
t = last_t + 0.02
out.append((np.asarray(qi, dtype=float).copy(), np.asarray(qdot, dtype=float).copy(), t))
last_t = t
hold = max(0.12, float(t_hold))
for frac in (0.40, 0.70, 1.00):
t = float(t0) + T_move + hold * frac
if t < last_t + 0.05:
t = last_t + 0.05
out.append((np.asarray(waypoints[-1], dtype=float).copy(), zeros.copy(), t))
last_t = t
return out, T_move, v_peak
def smooth_joint_schedule(
waypoints,
dist: float,
v: float,
*,
t0: float = 0.06,
dt: float = 0.04,
t_hold: float = 0.25,
d_acc: float = D435_FULL_ACC_M,
d_dec: float = D435_FULL_DEC_M,
):
"""路点 + 余弦加减速 → ``[(q, qdot, t), ...]``。头尾速度为 0。
D435 整段一次下发用这一份。二阶段短跳仍走 ``trap_joint_schedule``。
"""
dist = max(float(dist), 1e-6)
T_move, v_peak, t_acc, t_cruise, acc_m, dec_m = smooth_path_params(
dist, v, d_acc=d_acc, d_dec=d_dec
)
n_samp = max(12, int(math.ceil(T_move / max(float(dt), 1e-3))))
zeros = np.zeros_like(waypoints[0])
out = []
last_t = -1.0
for k in range(n_samp + 1):
t_local = T_move * k / n_samp
s, vs = smooth_path_sv(
t_local, dist, v_peak, t_acc, t_cruise, acc_m, dec_m
)
u = min(1.0, s / dist)
qi, dq_du = path_interp(waypoints, u)
qdot = dq_du * (vs / dist) if k < n_samp else zeros
if k >= n_samp:
qi = waypoints[-1]
qdot = zeros
t = float(t0) + t_local
if t <= last_t:
t = last_t + 0.02
out.append(
(
np.asarray(qi, dtype=float).copy(),
np.asarray(qdot, dtype=float).copy(),
t,
)
)
last_t = t
hold = max(0.12, float(t_hold))
for frac in (0.40, 0.70, 1.00):
t = float(t0) + T_move + hold * frac
if t < last_t + 0.05:
t = last_t + 0.05
out.append((np.asarray(waypoints[-1], dtype=float).copy(), zeros.copy(), t))
last_t = t
return out, T_move, v_peak
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""PID 控制器,带积分限幅、微分作用于测量值(derivative-on-measurement)以及积分分离。"""
from __future__ import annotations
from typing import Optional
class PidController:
"""``u = Kp e + Ki ∫e + Kd (−dmeas/dt)``,然后再做饱和限幅。
积分分离:当 ``|error| > i_sep_thresh`` 时,积分被强制置 0 且不再累积
(只用 P+D,以便快速追赶 / 减少超调)。
"""
def __init__(
self,
kp: float,
ki: float,
kd: float,
*,
i_limit: float = 0.05,
out_limit: float = 0.20,
i_sep_thresh: float = 0.005,
) -> None:
self.kp = float(kp)
self.ki = float(ki)
self.kd = float(kd)
self.i_limit = abs(float(i_limit))
self.out_limit = abs(float(out_limit))
self.i_sep_thresh = abs(float(i_sep_thresh))
self._i = 0.0
self._meas_prev: Optional[float] = None
def reset(self) -> None:
self._i = 0.0
self._meas_prev = None
@property
def integral(self) -> float:
return float(self._i)
def update(self, error: float, meas: float, dt: float) -> float:
dt = max(float(dt), 1e-4)
err = float(error)
x = float(meas)
if self._meas_prev is None:
dterm = 0.0
else:
dterm = -(x - self._meas_prev) / dt
self._meas_prev = x
# 积分分离 —— 误差较大:清空 I,不再累积
if self.i_sep_thresh > 0.0 and abs(err) > self.i_sep_thresh:
self._i = 0.0
else:
self._i = max(-self.i_limit, min(self.i_limit, self._i + err * dt))
u = self.kp * err + self.ki * self._i + self.kd * dterm
if u > self.out_limit:
u = self.out_limit
elif u < -self.out_limit:
u = -self.out_limit
return float(u)
@@ -0,0 +1,466 @@
#!/usr/bin/env python3
"""三阶段串联:视觉跟踪锁定 → 孔定位 → 搜孔并轻轻按压(默认不拧入)。
流畅性(相对 bash 杀进程再起节点):
- 相机 / 触觉 / 手眼 TF 全程只起一次
- 一阶段锁定后 SIGTERM 跟踪节点,立刻 pin 当前关节,不回零、不抬臂
- 二阶段已垂直 / 已在悬停高则跳过对应动作;对准完立刻下压搜孔
- 找到孔位后力闭环轻压保持;拧入是后续阶段(--depth 才插入)
"""
from __future__ import annotations
import json
import os
import signal
import subprocess
import time
from typing import Optional
import numpy as np
import rclpy
from geometry_msgs.msg import PointStamped
from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy
from std_msgs.msg import Bool, String
from arm_application.stage3_search_node import Stage3Search
#: 悬停时规尖离面不够,跟踪高再抬的量 [m]。
#:
#: 只在「悬停高上规尖离工件面 < :data:`TRACK_LOCK_GAP_M`」时加上。仿真悬停按
#: 工具几何只留约 2mm,不抬就会在跟踪时刮到工件。真机 89 悬停 0.320 时规尖
#: 已经在面上方约 75mm,再抬 50mm 会让对孔前先改一次高度(0.37→0.32)。
#:
#: ⚠️ 抽成常量是因为它不只被流程用:``scripts/check_sim_scene.py`` 要用它核对
#: "跟踪高有没有超出工作盒上界"(仿真里悬停 0.632 + 0.05 = 0.682 > 真机那套
#: 0.65 上界,就是被这条查出来的)。两处各写一个字面量迟早漂移。
TRACK_CLEARANCE_M = 0.05
#: 悬停时规尖离面已经不少于这个值 [m],一阶段就锁在悬停高,对孔完成后再下降。
TRACK_LOCK_GAP_M = 0.040
def init_z_refusal(
z_m: float,
tool_len_m: float,
surface_z_m: float,
gap_m: float,
z_min: float,
z_max: float,
) -> str:
"""初始法兰高度能不能用。空串表示可以;非空就是拒绝原因,不许悄悄抬高。"""
z = float(z_m)
if z < float(z_min) - 1e-6 or z > float(z_max) + 1e-6:
return (
f"初始法兰 Z {z:.3f}m 超出工作盒 [{float(z_min):.3f}, {float(z_max):.3f}],"
"拒绝开跑"
)
tool = float(tool_len_m)
surf = float(surface_z_m)
if tool <= 0.0 or surf <= 0.0:
return "写了初始高度,但工具长度或工件面未知,无法确认尖端在面之上,拒绝开跑"
clear = z - tool - surf
if clear < float(gap_m) - 0.001:
return (
f"初始法兰 Z {z:.3f}m 会让工具尖进入工件:"
f"尖端 {z - tool:.3f},工件面 {surf:.3f},"
f"间隙 {clear * 1000:.1f}mm,要求 ≥ {float(gap_m) * 1000:.1f}mm。"
"拒绝开跑,不会悄悄抬高。"
)
return ""
def track_lock_z(hover_z: float, tool_len_m: float, surface_z_m: float) -> float:
"""一阶段锁高。
悬停时规尖离面已经 ≥ ``TRACK_LOCK_GAP_M``:锁在悬停高,跟踪和对孔都不改 Z,
对孔完成后再下降。离面不够(仿真悬停只留 2mm):再抬 ``TRACK_CLEARANCE_M``,
否则跟踪会刮到工件。工具长或工件面缺失时抬升 —— 缺数时锁在悬停高等于没检查。
"""
tool = float(tool_len_m)
surf = float(surface_z_m)
hover = float(hover_z)
if tool > 0.0 and surf > 0.0 and (hover - tool - surf) >= TRACK_LOCK_GAP_M:
return hover
return hover + TRACK_CLEARANCE_M
def stage1_timeout_reason(points_seen: int, timeout_s: float, log_path: str) -> str:
"""一阶段超时了,到底是"感知没给点"还是"给了点但没收敛"。
这两种情况的排查方向完全相反,而现象长得一模一样(都是"没锁定"):
* **一个点都没收到** → 感知侧的事:yolo 后端多半缺 ultralytics/torch、
truth 后端真值节点没起来、external 后端没人发。跟标定、跟工件在不在
视野里**都没关系**。
* **收到了点但没收敛** → 控制/几何侧的事:工件不在视野、手眼不对、
目标超出工作空间、增益太小。
以前两种情况共用一句"没锁定(视野里没工件?)",于是"perception_node
因为缺依赖早退了"被读成"算法不收敛",白查半天。
"""
if points_seen <= 0:
return (
f"一阶段 {timeout_s:.0f}s 没锁定:**一个视觉目标点都没收到**"
"(visual_servo/filtered_point 上 0 条)—— 这是感知没起来,"
"不是标定/视野问题。按 perception.source 对号入座:\n"
" yolo → 多半缺推理依赖(ultralytics/torch),"
f"看感知进程退出前几行:{log_path}\n"
" truth → 仿真真值节点没起来,或场景/物体名读不出来"
"(它退出前会打印原因)\n"
" external → 外部节点没按这个话题/这个类型发"
)
return (
f"一阶段 {timeout_s:.0f}s 没锁定(收到 {points_seen} 个目标点但没收敛):"
f"工件不在视野 / 手眼不对 / 目标超出工作空间。看 {log_path}"
)
class AssemblyPipeline(Stage3Search):
def __init__(self, node_name: str = "assembly_pipeline") -> None:
# node_name 可覆盖:通止规装配(gauge_assembly_node)继承本类以复用
# 一阶段跟踪的拉起/拆分与二阶段定位链,但它是**另一条流程**,
# 在图上必须是另一个节点名(否则 ros2 node list 里两个同名的节点,
# 出问题时连"是哪条流程在发指令"都分不清)。
super().__init__(node_name=node_name)
self.declare_parameter("skip_stage1", False)
self.declare_parameter("skip_stage2", False)
self.declare_parameter("skip_stage3", False) # 默认搜孔+轻压;拧入靠 --depth
self.declare_parameter("d435_move_only", False)
self.declare_parameter("init_z_m", -1.0) # >0:启动后先到这个法兰高度
self.declare_parameter("stage1_timeout_sec", 150.0)
self.linger3 = 1.2 # 串联结束短保持;单独跑 stage3 仍用 site linger_sec
self._s1: Optional[subprocess.Popen] = None
self._locked = False
self.create_subscription(Bool, "/visual_servo/locked", self._on_lock, 10)
# 诊断用:一阶段超时时靠它区分"感知没给点"和"给了点没收敛"。
# 只计数,不参与控制 —— 它绝不能影响流程本身的判断。
#
# ⚠️ QoS 必须与感知发布端**逐项一致**(BEST_EFFORT + KEEP_LAST(5),
# 见 perception_node / sim_truth_perception)。用默认的 RELIABLE 订阅
# BEST_EFFORT 的话题:不报错、话题列表里也在,但**一条都收不到** ——
# 于是超时归因会一口咬定"感知没起来",把人往完全错误的方向带
#(真实原因可能只是这里 QoS 不匹配)。这类"诊断自己坏了"最难查,
# 所以宁可多写这几行。
self.declare_parameter("diag_point_topic", "visual_servo/filtered_point")
self._diag_point_topic = str(self.get_parameter("diag_point_topic").value)
self._point_msgs = 0
self.create_subscription(
PointStamped,
self._diag_point_topic,
self._on_point_seen,
QoSProfile(
history=HistoryPolicy.KEEP_LAST,
depth=5,
reliability=ReliabilityPolicy.BEST_EFFORT,
),
)
self._pipe_pub = self.create_publisher(String, "/pipeline/state", 10)
self._pipe_result = self.create_publisher(String, "/pipeline/result", 10)
skip1 = bool(self.get_parameter("skip_stage1").value)
skip2 = bool(self.get_parameter("skip_stage2").value)
skip3 = bool(self.get_parameter("skip_stage3").value)
self.d435_move_only = bool(self.get_parameter("d435_move_only").value)
self.get_logger().info(
f"pipeline arm_ee={self.ee_frame} hole={self.target_hole} "
f"skip1={skip1} skip2={skip2} skip3={skip3} "
f"d435_only={self.d435_move_only} insert={self.insert_depth_mm:.1f}mm"
)
def _state(self, s: str) -> None:
self.get_logger().info(f"[pipeline] === {s} ===")
msg = String()
msg.data = s
self._state_pub.publish(msg)
self._pipe_pub.publish(msg)
def _on_lock(self, msg: Bool) -> None:
if bool(msg.data):
self._locked = True
def _on_point_seen(self, _msg: PointStamped) -> None:
"""只记"收到过目标点",用于超时后的归因(见 stage1_timeout_reason)。"""
self._point_msgs += 1
def _kill_old_stage1(self) -> None:
for pat in (
"visual_servo.launch",
"lib/arm_application/servo_node",
"lib/arm_application/perception_node",
):
subprocess.call(["pkill", "-f", pat], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(0.25)
def _stop_stage1(self) -> None:
proc = self._s1
self._s1 = None
if proc is None:
self._kill_old_stage1()
return
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGTERM)
except Exception:
proc.terminate()
try:
proc.wait(timeout=4.0)
except Exception:
try:
os.killpg(proc.pid, signal.SIGKILL)
except Exception:
proc.kill()
self._kill_old_stage1()
def _track_z(self) -> float:
"""一阶段锁高。写了 ``init_z_m`` 就用它,否则见 :func:`track_lock_z`。"""
z = float(self.get_parameter("init_z_m").value)
if z > 0.0:
return z
return track_lock_z(
float(self.hover_z),
float(getattr(self, "tool_tip_below_flange_m", 0.0) or 0.0),
float(getattr(self, "surface_z_m", 0.0) or 0.0),
)
def _ensure_clearance(self) -> bool:
"""没写初始高度:只在低于锁高时抬上去。写了:升或降到那个法兰 Z,不安全就拒绝。"""
user = float(self.get_parameter("init_z_m").value) > 0.0
q, Tee = self._wait_state(4.0)
if q is None or Tee is None:
if user:
self.get_logger().error("设了 --init-z,但拿不到当前位姿,拒绝开跑")
return False
return True
tz = self._track_z()
if user:
why = init_z_refusal(
tz,
float(getattr(self, "tool_tip_below_flange_m", 0.0) or 0.0),
float(getattr(self, "surface_z_m", 0.0) or 0.0),
float(self.final_gap_m),
float(self.ws_min[2]),
float(self.ws_max[2]),
)
if why:
self.get_logger().error(why)
return False
z = float(Tee[2, 3])
if user:
if abs(z - tz) <= 0.008:
self.get_logger().info(f"已在初始高度 Z={z:.3f}m(目标 {tz:.3f})")
return True
elif z >= tz - 0.008:
return True
self._state("pre_lift")
self.get_logger().info(f"先到初始高度:Z {z:.3f} → {tz:.3f}m")
ok, q, Tee = self._goto(
q, Tee, np.array([float(Tee[0, 3]), float(Tee[1, 3]), tz]),
verticalize=True, tol_m=0.003, tol_tilt=self.vert_tol_rad * 2.0,
tag="pre_lift", v=self.move_speed, max_hops=8, hold_xy=True, settle_s=0.25,
)
q2 = self._q()
self._pin(q2 if q2 is not None else q)
z2 = float(Tee[2, 3])
if user and (not ok or abs(z2 - tz) > 0.008):
self.get_logger().error(f"初始高度没到位:Z={z2:.3f},目标 {tz:.3f}")
return False
return True
def _run_stage1(self) -> int:
"""拉起 perception+servo,等到 /visual_servo/locked 后冻结并拆跟踪。"""
if not self._ensure_clearance():
return 2
self._state("stage1_track")
self._kill_old_stage1()
self._locked = False
log_path = "/tmp/pipeline_stage1.log"
logf = open(log_path, "w", encoding="utf-8")
lock_z = self._track_z()
# use_sim_time 必须**原样传下去**:本节点在仿真里是 true,子 launch 若是
# false,perception/servo 就跑在系统时间上,TF 全是仿真时刻的戳 →
# "看得见图、算得出点,就是查不到变换",一阶段永远不收敛。
use_sim = bool(self.get_parameter("use_sim_time").value)
cmd = [
"ros2", "launch", "arm_application", "visual_servo.launch.py",
"enable_control:=true",
"publish_handeye_tf:=false",
f"ee_z_m:={lock_z:.3f}",
f"use_sim_time:={str(use_sim).lower()}",
f"d435_move_only:={str(self.d435_move_only).lower()}",
]
if self.d435_move_only:
self.get_logger().info(
f"启动 D435 位移测试(锁高 {lock_z:.3f}m,到位后不交 D405、不进二阶段)"
)
else:
self.get_logger().info(
f"启动一阶段跟踪(锁高 {lock_z:.3f}m,锁定后自动交二阶段)"
)
self._s1 = subprocess.Popen(
cmd, stdout=logf, stderr=subprocess.STDOUT, start_new_session=True,
)
timeout = float(self.get_parameter("stage1_timeout_sec").value)
self._point_msgs = 0
t0 = time.monotonic()
while time.monotonic() - t0 < timeout:
self._spin(0.1)
if self._locked:
break
if self._s1.poll() is not None:
self.get_logger().error(f"一阶段进程提前退出,看 {log_path}")
return 1
else:
self._stop_stage1()
self.get_logger().error(
stage1_timeout_reason(self._point_msgs, timeout, log_path)
)
return 1
if self.d435_move_only:
self.get_logger().info(
"D435 位移测试结束 — 停跟踪、钉住当前关节,跳过二阶段"
)
else:
self.get_logger().info("一阶段已锁定 — 停跟踪、钉住当前关节,直接进二阶段")
self._stop_stage1()
time.sleep(0.35)
q = self._q()
if q is not None:
self._pin(q)
return 0
def run(self) -> int:
skip1 = bool(self.get_parameter("skip_stage1").value)
skip2 = bool(self.get_parameter("skip_stage2").value)
skip3 = bool(self.get_parameter("skip_stage3").value)
if self.d435_move_only:
skip2 = True
skip3 = True
if not self._cli.wait_for_server(timeout_sec=5.0):
self.get_logger().error("JTC action 不在 —— 先开 T1: run_real.sh --arm 89")
return 2
try:
if not skip1:
code = self._run_stage1()
if code != 0:
return code
else:
if not self._ensure_clearance():
return 2
self._state("skip_stage1")
q, Tee = self._wait_state(10.0)
if q is None or Tee is None:
self.get_logger().error("拿不到 joint_states/TF")
return 2
final: dict = {}
if not skip2:
self._state("stage2_locate")
code, q, Tee, final = self._run_locate(q, Tee)
if code != 0:
self.get_logger().error(f"二阶段失败 exit={code},停在当前位姿")
return code
self.get_logger().info(
f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm"
+ (
" — 跳过搜孔,停在观测高度(看规尖是否对中;进程退出后臂保持)"
if skip3
else " — 立刻下压搜孔并轻压(不拧入)"
)
)
else:
self._state("skip_stage2")
if skip3:
self._state("skip_stage3")
if self.d435_move_only:
self.get_logger().info(
"D435 位移测试结束 —— 臂停在当前高度,不交 D405、不对孔;"
"15s 后本进程退出,臂保持不动"
)
else:
self.get_logger().info(
"跳过搜孔 —— 臂停在当前高度。请看规尖与孔是否对中;"
"15s 后本进程退出,臂保持不动"
)
if final:
self._publish_result(final)
self._linger_hold(final if final else {"ok": True}, seconds=15.0)
payload = {"ok": True, "stage": "pipeline", "exit": 0, "skipped": "stage3"}
try:
payload["stage2"] = json.loads(
open("/tmp/stage2_result.json", encoding="utf-8").read()
)
except Exception:
pass
msg = String()
msg.data = json.dumps(payload, ensure_ascii=False)
self._pipe_result.publish(msg)
return 0
self._state("stage3_search")
code = self._run_search(q, Tee, linger=False, stage2_final=final)
payload = {
"ok": code == 0,
"stage": "pipeline",
"exit": code,
}
try:
payload["stage3"] = json.loads(
open("/tmp/stage3_result.json", encoding="utf-8").read()
)
except Exception:
pass
msg = String()
msg.data = json.dumps(payload, ensure_ascii=False)
self._pipe_result.publish(msg)
return code
finally:
self._stop_stage1()
def main(args=None) -> None:
rclpy.init(args=args)
node = AssemblyPipeline()
code = 2
try:
code = node.run()
except KeyboardInterrupt:
node.get_logger().info("用户中断")
code = 130
except Exception as exc: # noqa: BLE001
node.get_logger().error(f"pipeline 异常终止: {exc}")
code = 2
finally:
try:
node._stop_stage1()
except Exception:
pass
if code != 0:
try:
q = node._q()
if q is not None:
node._pin(q)
except Exception:
pass
try:
node._shutdown_tf()
except Exception:
pass
try:
node.destroy_node()
except Exception:
pass
try:
if rclpy.ok():
rclpy.shutdown()
except Exception:
pass
os._exit(code)
if __name__ == "__main__":
main()
@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""在 YOLO 检测框中心处求立体平面的 Z。
悬停目标是「检测框中心射线」与「拟合出的近平面」的交点。这样既能留在检测结果上,
又带有真实的米制 Z(30° 相机下,Z 错了会把 base 系的 XY 也带偏)。不使用点云均值
质心 —— 孔 / 桌面的内点会把它从绿框上拉走。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Tuple
import numpy as np
@dataclass
class PlaneFeature:
uv: np.ndarray # (2,) 三维质心对应的彩色像素坐标
z: float # 三维质心在相机坐标系下的 Z
xyz: np.ndarray # (3,) 相机坐标系下的立体点云质心
n_inliers: int
note: str
# 这个点的 Z 是不是**借**来的(该像素处读不到自己的深度,从检测框里借了一个)。
# 为什么要显式带出来、而不是让下游去认 ``note`` 里的字:借深度意味着该像素的
# 深度不可读 ⇒ 绝对位置不足以用来**重定位**(2026-10-08 真机,假 k0 带着借来的
# 深度、z 看着很合理 0.27~0.39m,于是"物理量程"守卫也失效)。
borrowed: bool = False
def _depth_meters(buf: np.ndarray, encoding: str) -> Tuple[np.ndarray, float]:
enc = (encoding or "").lower()
raw = np.asarray(buf)
if enc in ("16uc1", "mono16") or raw.dtype == np.uint16:
pos = raw.reshape(-1).astype(np.float32)
pos = pos[pos > 0]
if pos.size >= 8:
med = float(np.median(pos))
scale = 0.001 if 20.0 <= med <= 8000.0 else (1.0 if med < 20.0 else 0.0001)
else:
scale = 0.001
return raw.astype(np.float32) * scale, scale
return raw.astype(np.float32), 1.0
def _near_band_mask(
meters: np.ndarray,
valid: np.ndarray,
min_depth: float,
work_z_max: float,
band_m: float,
) -> np.ndarray:
vals = meters[valid]
if vals.size < 8:
return valid
vals = np.sort(vals)
n_near = max(8, int(0.40 * vals.size))
near = vals[:n_near]
z_p10 = float(np.percentile(near, 10))
hi = min(float(work_z_max), z_p10 + float(band_m))
return valid & (meters <= hi) & (meters >= float(min_depth))
def _ransac_plane(
pts: np.ndarray,
thresh: float,
n_iter: int = 24,
rng: Optional[np.random.Generator] = None,
) -> Optional[Tuple[np.ndarray, np.ndarray]]:
n = int(pts.shape[0])
if n < 12:
return None
rng = rng or np.random.default_rng()
best_inl: Optional[np.ndarray] = None
best_count = 0
for _ in range(n_iter):
i = rng.choice(n, 3, replace=False)
p0, p1, p2 = pts[i]
nvec = np.cross(p1 - p0, p2 - p0)
nn = float(np.linalg.norm(nvec))
if nn < 1e-9:
continue
nvec = nvec / nn
dist = np.abs((pts - p0) @ nvec)
inl = dist < thresh
c = int(inl.sum())
if c > best_count:
best_count = c
best_inl = inl
if best_inl is None or best_count < max(12, int(0.15 * n)):
return None
subset = pts[best_inl]
p0 = subset.mean(axis=0)
_, _, vh = np.linalg.svd(subset - p0, full_matrices=False)
nvec = vh[-1]
nvec = nvec / (float(np.linalg.norm(nvec)) + 1e-12)
if float(nvec @ p0) > 0.0:
nvec = -nvec
return nvec, p0
def _plane_z(
nvec: np.ndarray,
p0: np.ndarray,
u: float,
v: float,
fx: float,
fy: float,
cx: float,
cy: float,
) -> Optional[float]:
direc = np.array([(u - cx) / max(fx, 1.0), (v - cy) / max(fy, 1.0), 1.0], dtype=float)
denom = float(nvec @ direc)
if abs(denom) < 1e-8:
return None
z = float(nvec @ p0) / denom
return z if np.isfinite(z) else None
def workpiece_xyz_in_box(
depth_buf: np.ndarray,
encoding: str,
xyxy,
fx: float,
fy: float,
cx: float,
cy: float,
img_wh: Tuple[int, int],
min_depth: float,
max_depth: float,
work_z_max: float,
inner: float = 0.40,
band_m: float = 0.06,
) -> Optional[PlaneFeature]:
"""工件上的实体点:内层检测框里近表面深度的三维中位数。
不是单目射线(孔会穿透到桌面),也不是整个 ROI 的点云均值
(桌面 / 单侧深度会把它从工件上拉走)。
"""
meters, _ = _depth_meters(depth_buf, encoding)
if meters.ndim != 2:
return None
img_w, img_h = int(img_wh[0]), int(img_wh[1])
dh, dw = int(meters.shape[0]), int(meters.shape[1])
x1, y1, x2, y2 = [float(v) for v in xyxy]
u_c = 0.5 * (x1 + x2)
v_c = 0.5 * (y1 + y2)
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
scale_u, scale_v = 1.0 / sx, 1.0 / sy
else:
scale_u = scale_v = 1.0
bw = max(8.0, x2 - x1)
bh = max(8.0, y2 - y1)
frac = 0.5 * float(np.clip(inner, 0.2, 0.8))
ix0 = int(np.clip(min(x1, x2) + (0.5 - frac) * bw, 0, dw - 1))
iy0 = int(np.clip(min(y1, y2) + (0.5 - frac) * bh, 0, dh - 1))
ix1 = int(np.clip(max(x1, x2) - (0.5 - frac) * bw, ix0 + 2, dw))
iy1 = int(np.clip(max(y1, y2) - (0.5 - frac) * bh, iy0 + 2, dh))
roi = meters[iy0:iy1, ix0:ix1]
finite = np.isfinite(roi) & (roi > float(min_depth)) & (roi < float(max_depth))
if int(finite.sum()) < 12:
return None
near = _near_band_mask(roi, finite, min_depth, work_z_max, band_m)
if int(near.sum()) < 8:
near = finite
yy, xx = np.nonzero(near)
if yy.size < 8:
return None
zs = roi[yy, xx]
us = (xx + ix0) * scale_u
vs = (yy + iy0) * scale_v
pts = np.stack(
[
(us - cx) * zs / max(fx, 1.0),
(vs - cy) * zs / max(fy, 1.0),
zs,
],
axis=1,
)
p_xyz = np.median(pts, axis=0)
if not np.all(np.isfinite(p_xyz)):
return None
z = float(p_xyz[2])
if not (min_depth <= z <= work_z_max):
return None
uv = np.array(
[
fx * float(p_xyz[0]) / max(z, 1e-6) + cx,
fy * float(p_xyz[1]) / max(z, 1e-6) + cy,
],
dtype=float,
)
return PlaneFeature(
uv=uv,
z=z,
xyz=p_xyz.astype(float),
n_inliers=int(yy.size),
note=f"inner3d n={int(yy.size)} z={z:.3f}",
)
def geometric_center_on_plane(
depth_buf: np.ndarray,
encoding: str,
xyxy,
fx: float,
fy: float,
cx: float,
cy: float,
img_wh: Tuple[int, int],
min_depth: float,
max_depth: float,
work_z_max: float,
inlier_m: float = 0.008,
band_m: float = 0.06,
) -> Optional[PlaneFeature]:
"""拟合出的近平面上、位于检测框中心的点,拿不到则返回 None。"""
meters, _ = _depth_meters(depth_buf, encoding)
if meters.ndim != 2:
return None
img_w, img_h = int(img_wh[0]), int(img_wh[1])
dh, dw = int(meters.shape[0]), int(meters.shape[1])
x1, y1, x2, y2 = [float(v) for v in xyxy]
u_c = 0.5 * (x1 + x2)
v_c = 0.5 * (y1 + y2)
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
# uv 保持用彩色像素坐标:先在深度像素下计算,再缩放回来。
scale_u, scale_v = 1.0 / sx, 1.0 / sy
else:
scale_u = scale_v = 1.0
x0 = int(np.clip(min(x1, x2), 0, dw - 1))
y0 = int(np.clip(min(y1, y2), 0, dh - 1))
x1i = int(np.clip(max(x1, x2), x0 + 2, dw))
y1i = int(np.clip(max(y1, y2), y0 + 2, dh))
roi = meters[y0:y1i, x0:x1i]
if roi.size < 16:
return None
finite = np.isfinite(roi) & (roi > float(min_depth)) & (roi < float(max_depth))
if int(finite.sum()) < 16:
return None
near = _near_band_mask(roi, finite, min_depth, work_z_max, band_m)
if int(near.sum()) < 12:
near = finite
yy, xx = np.nonzero(near)
zs = roi[yy, xx]
# 反投影用的彩色等效像素(对齐后的深度使用的是彩色内参 K)。
us = (xx + x0) * scale_u
vs = (yy + y0) * scale_v
pts = np.stack(
[
(us - cx) * zs / max(fx, 1.0),
(vs - cy) * zs / max(fy, 1.0),
zs,
],
axis=1,
)
if pts.shape[0] > 800:
rng = np.random.default_rng()
sel = rng.choice(pts.shape[0], 800, replace=False)
fit_pts = pts[sel]
else:
fit_pts = pts
fitted = _ransac_plane(fit_pts, thresh=float(inlier_m))
if fitted is None:
return None
nvec, p0 = fitted
dist = np.abs((pts - p0) @ nvec)
n_inl = int((dist < float(inlier_m)).sum())
if n_inl < 12:
return None
z = _plane_z(nvec, p0, u_c, v_c, fx, fy, cx, cy)
if z is None or not (min_depth <= float(z) <= work_z_max):
zs_inl = pts[dist < float(inlier_m), 2]
z = float(np.median(zs_inl)) if zs_inl.size else None
if z is None or not (min_depth <= z <= work_z_max):
return None
z = float(z)
p_xyz = np.array(
[
(u_c - cx) * z / max(fx, 1.0),
(v_c - cy) * z / max(fy, 1.0),
z,
],
dtype=float,
)
return PlaneFeature(
uv=np.array([u_c, v_c], dtype=float),
z=z,
xyz=p_xyz,
n_inliers=n_inl,
note=f"box_on_plane n={n_inl} z={z:.3f}",
)
@@ -0,0 +1,479 @@
#!/usr/bin/env python3
"""YOLO **pose** 模型的推理封装(自解码,不经过 ultralytics 的 pose 后处理)。
背景:为什么会多出这一个模块
--------------------------------
`gongjian0929` 这类权重是 ``task=pose``:它输出**一个工件框 + K 个关键点**。
本项目 K=3,就是现场说的 k0 / k1 / k2(k0 跟踪、k1/k2 对孔)。
ultralytics 走不通,两条路都实测过(2026-09-29):
1. ``YOLO(engine, task="detect")`` —— 按检测去解 14 通道输出,结果吐出 300 个
垃圾框,``conf`` 直接是坐标值(600+),全被算成 ``workpiece`` 类。
跟踪会锁到空气上 —— 真机上这比不跑更危险。
2. ``YOLO(engine, task="pose")`` —— 直接崩:``negative dimension -983``。
根因是 ``scripts/weights.py`` 用**裸 TensorRT API** 构建 engine,engine 不带
metadata;ultralytics 拿不到 ``kpt_shape``,后处理就错位了。
(TensorRT 10.13 的 Python API 确实没有任何 metadata 接口 —— 见
``models/README.md`` 第 3 节。)
所以这里自己解码。输出布局是 YOLO pose 的固定约定::
output0 : (1, 4 + nc + 3*K, A) # A = anchor 数(640 输入时是 8400)
ch 0..3 : cx, cy, w, h # 输入尺度下的像素
ch 4.. : 每类置信度(nc 个)
之后 : K 组 (x, y, conf)
关键点是**同一个工件框的属性**,不是独立目标 —— 所以它们不参与 NMS 的竞争,
只跟着自己那个框一起被选出/淘汰。这点和"每个孔一个类"的检测模型不一样。
后端
----
- ``.engine``:TensorRT 10 的 ``set_tensor_address`` + ``execute_async_v3``。
用 torch 张量当设备缓冲、torch 的当前流当执行流 —— 不需要 pycuda。
实测 ~1.5 ms/帧(640 输入,FP16 engine)。
- ``.onnx``:onnxruntime。**本环境没有 CUDA provider**(只有 CPU),
所以 ONNX 只是兜底,真机请用 engine。
"""
from __future__ import annotations
import ast
import logging
from pathlib import Path
from typing import Any, List, Optional, Sequence, Tuple, Union
import cv2
import numpy as np
from .yolo_detector import Detection, resolve_class_labels
logger = logging.getLogger(__name__)
# ultralytics 的默认填充值。改了它并不会错 —— 只是输入分布和训练时不一致,
# 精度会掉一点,所以别改。
LETTERBOX_FILL = 114
# 关键点语义(现场约定):k0 是跟踪孔,k1/k2 是对孔。
# 默认值写在这里,site.yaml 可以覆盖(见 arm_application/pose_detector 的调用方)。
TRACK_KPT_INDEX = 0
HOLE_KPT_INDICES: Tuple[int, int] = (1, 2)
# --------------------------------------------------------------------------- #
# 模型信息:engine 没有 metadata,就从旁边同名的 .onnx 读
# --------------------------------------------------------------------------- #
def _parse_python_literal(raw: Any, default: Any = None) -> Any:
"""ONNX metadata 里存的是 python repr(单引号、嵌套 list/dict),要 literal_eval。"""
if raw is None:
return default
if isinstance(raw, (dict, list, tuple, int)):
return raw
try:
return ast.literal_eval(str(raw))
except (ValueError, SyntaxError):
return default
def read_pose_info(weights: Union[str, Path]) -> Optional[dict]:
"""读同名 ``.onnx`` 的 metadata,判断这是不是一个 pose 模型。
engine 本身读不到任何东西(TensorRT 10 的 Python API 没有 metadata 接口),
但 ``.onnx`` 一定在它旁边(``resolve_weights`` 保证同目录同主干),
所以统一以 ONNX 为准。
返回 ``None`` = 不是 pose 模型(调用方应回落到普通检测器)。
返回 dict 时至少含 ``kpt_count`` / ``nc`` / ``names``。
"""
p = Path(weights).expanduser()
onnx_path = p.with_suffix(".onnx")
if not onnx_path.is_file():
return None
try:
import onnx
except Exception: # noqa: BLE001
logger.warning("没装 onnx,无法判断 %s 是不是 pose 模型", p.name)
return None
try:
meta = {m.key: m.value for m in onnx.load(str(onnx_path)).metadata_props}
except Exception as exc: # noqa: BLE001
logger.warning("读 %s 的 metadata 失败:%s", onnx_path.name, exc)
return None
task = str(meta.get("task", "")).strip().lower()
kpt_shape = _parse_python_literal(meta.get("kpt_shape"))
if task != "pose" and not kpt_shape:
return None
kpt_count = 0
if isinstance(kpt_shape, (list, tuple)) and len(kpt_shape) >= 1:
try:
kpt_count = int(kpt_shape[0])
except (TypeError, ValueError):
kpt_count = 0
if kpt_count <= 0:
# metadata 缺 kpt_shape 也不能猜 —— 猜错就是"静默错位",比报错难查得多。
logger.warning("%s 标记为 pose 但读不到 kpt_shape,按非 pose 处理", onnx_path.name)
return None
names = _parse_python_literal(meta.get("names"), default={}) or {}
kpt_names = _parse_python_literal(meta.get("kpt_names"), default={}) or {}
nc = 0
if isinstance(names, dict) and names:
nc = max(int(k) for k in names) + 1 if all(
str(k).lstrip("-").isdigit() for k in names
) else len(names)
if nc <= 0 and isinstance(names, (list, tuple)):
nc = len(names)
return {
"task": "pose",
"kpt_count": kpt_count,
"kpt_names": kpt_names,
"nc": nc,
"names": names,
"model": str(meta.get("description") or p.name),
}
def is_pose_model(weights: Union[str, Path]) -> bool:
return read_pose_info(weights) is not None
# --------------------------------------------------------------------------- #
# 预处理 / 后处理(纯 numpy,可离线单测)
# --------------------------------------------------------------------------- #
def letterbox(
img: np.ndarray,
new_shape: int = 640,
color: Tuple[int, int, int] = (LETTERBOX_FILL,) * 3,
) -> Tuple[np.ndarray, float, Tuple[float, float]]:
"""等比缩放 + 居中填充到 ``new_shape``(和 ultralytics 推理时一致)。
返回 ``(padded, gain, (dw, dh))``:``dw/dh`` 是**填充量**,还原坐标时减掉它。
"""
h, w = img.shape[:2]
gain = min(new_shape / h, new_shape / w)
new_unpad = (max(1, int(round(w * gain))), max(1, int(round(h * gain))))
if (w, h) != new_unpad:
img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)
dw = (new_shape - new_unpad[0]) / 2.0
dh = (new_shape - new_unpad[1]) / 2.0
top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
if top or bottom or left or right:
img = cv2.copyMakeBorder(
img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color
)
return img, float(gain), (float(dw), float(dh))
def nms_xyxy(
boxes: np.ndarray,
scores: np.ndarray,
iou_thr: float,
max_det: int = 20,
) -> np.ndarray:
"""纯 numpy 的 NMS,返回保留下来的下标(按分数从高到低)。"""
if boxes.size == 0:
return np.zeros((0,), dtype=int)
x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
areas = np.maximum(0.0, x2 - x1) * np.maximum(0.0, y2 - y1)
order = scores.argsort()[::-1]
keep: List[int] = []
while order.size > 0 and len(keep) < int(max_det):
i = int(order[0])
keep.append(i)
if order.size == 1:
break
rest = order[1:]
ix1 = np.maximum(x1[i], x1[rest])
iy1 = np.maximum(y1[i], y1[rest])
ix2 = np.minimum(x2[i], x2[rest])
iy2 = np.minimum(y2[i], y2[rest])
inter = np.maximum(0.0, ix2 - ix1) * np.maximum(0.0, iy2 - iy1)
union = areas[i] + areas[rest] - inter
iou = np.where(union > 1e-9, inter / np.maximum(union, 1e-9), 0.0)
order = rest[iou <= float(iou_thr)]
return np.asarray(keep, dtype=int)
# --------------------------------------------------------------------------- #
# 后端:TensorRT engine / ONNX Runtime
# --------------------------------------------------------------------------- #
class _TrtRunner:
"""TensorRT 10 执行器:torch 张量当设备缓冲,torch 当前流当执行流。
不用 pycuda —— 环境里只有 torch,而 ``set_tensor_address`` +
``execute_async_v3`` 正好够用。
"""
def __init__(self, engine_path: Path, device: str = "0") -> None:
import tensorrt as trt
import torch
self._torch = torch
idx = 0
dev = str(device or "").strip().lower()
if dev and dev not in ("auto", "cuda", "gpu"):
tail = dev.split(":")[-1]
if tail.isdigit():
idx = int(tail)
if not torch.cuda.is_available():
raise RuntimeError("backend=tensorrt 但 torch 看不到 CUDA")
self._device = torch.device(f"cuda:{idx}")
trt_logger = trt.Logger(trt.Logger.ERROR)
with open(engine_path, "rb") as f:
engine = trt.Runtime(trt_logger).deserialize_cuda_engine(f.read())
if engine is None:
raise RuntimeError(
f"engine 反序列化失败:{engine_path}。"
"engine 和 TensorRT 版本绑死,重装环境后要重新导出"
"(bash scripts/weights.sh doctor --check-engines 可查)"
)
self._engine = engine
self._ctx = engine.create_execution_context()
names = [engine.get_tensor_name(i) for i in range(engine.num_io_tensors)]
ins = [n for n in names if engine.get_tensor_mode(n) == trt.TensorIOMode.INPUT]
outs = [n for n in names if engine.get_tensor_mode(n) == trt.TensorIOMode.OUTPUT]
if len(ins) != 1 or len(outs) != 1:
raise RuntimeError(f"pose engine 期望 1 进 1 出,实际 in={ins} out={outs}")
self.in_name, self.out_name = ins[0], outs[0]
with torch.cuda.device(self._device):
self._x = torch.zeros(
tuple(engine.get_tensor_shape(self.in_name)),
dtype=torch.float32,
device=self._device,
)
self._y = torch.zeros(
tuple(engine.get_tensor_shape(self.out_name)),
dtype=torch.float32,
device=self._device,
)
self._ctx.set_tensor_address(self.in_name, self._x.data_ptr())
self._ctx.set_tensor_address(self.out_name, self._y.data_ptr())
self.out_shape = tuple(engine.get_tensor_shape(self.out_name))
def run(self, chw: np.ndarray) -> np.ndarray:
torch = self._torch
with torch.cuda.device(self._device):
self._x.copy_(torch.from_numpy(np.ascontiguousarray(chw)).to(self._device))
self._ctx.execute_async_v3(torch.cuda.current_stream().cuda_stream)
torch.cuda.current_stream().synchronize()
return self._y.cpu().numpy()
class _OnnxRunner:
"""onnxruntime 兜底。本环境只有 CPU provider —— 真机请用 .engine。"""
def __init__(self, onnx_path: Path) -> None:
import onnxruntime as ort
providers = ort.get_available_providers()
if "CUDAExecutionProvider" in providers:
chosen = ["CUDAExecutionProvider", "CPUExecutionProvider"]
else:
logger.warning(
"onnxruntime 没有 CUDA provider,%s 会跑在 CPU 上(慢)。"
"真机请用同名的 .engine",
onnx_path.name,
)
chosen = ["CPUExecutionProvider"]
self._sess = ort.InferenceSession(str(onnx_path), providers=chosen)
self.in_name = self._sess.get_inputs()[0].name
self.out_shape = tuple(self._sess.get_outputs()[0].shape)
def run(self, chw: np.ndarray) -> np.ndarray:
return self._sess.run(None, {self.in_name: chw[None].astype(np.float32)})[0]
# --------------------------------------------------------------------------- #
# 检测器
# --------------------------------------------------------------------------- #
class PoseDetector:
"""把 pose 模型包装成和 ``YoloDetector`` 一样的 ``detect()/infer()`` 接口。
多出来的一件事:每个 ``Detection`` 带上 ``kpts``(``(K, 3)`` 的
``x, y, conf``,原图像素坐标)。
"""
def __init__(
self,
weights: Union[str, Path],
*,
conf: float = 0.25,
iou: float = 0.45,
imgsz: int = 640,
device: str = "0",
class_ids: Optional[Sequence[int]] = None,
class_names: Optional[Sequence[str]] = None,
max_det: int = 20,
) -> None:
path = Path(weights).expanduser()
if not path.is_file():
raise FileNotFoundError(f"找不到 pose 权重:{path}")
info = read_pose_info(path) or {}
self.kpt_count = int(info.get("kpt_count") or 0)
if self.kpt_count <= 0:
raise RuntimeError(f"{path.name} 不是可识别的 pose 模型(缺 kpt_shape)")
self.weights_path = str(path)
self.conf = float(conf)
self.iou = float(iou)
self.imgsz = int(imgsz)
self.max_det = int(max_det)
if path.suffix.lower() in {".engine", ".trt"}:
self._runner = _TrtRunner(path, device)
self.backend = "tensorrt"
else:
self._runner = _OnnxRunner(path)
self.backend = "onnx"
# 类别标签仍然走统一入口(旁挂 .labels.yaml 优先)。
# engine 没有 metadata,所以把 ONNX 里的 names 当"模型内嵌"传进去。
names = info.get("names") or {}
class _Meta: # 只为 resolve_class_labels 能读到 ONNX 内嵌标签
pass
meta = _Meta()
meta.names = names
meta.nc = int(info.get("nc") or 0)
self._labels = resolve_class_labels(path, meta)
self.class_ids = set(int(v) for v in class_ids) if class_ids else None
self.class_names = (
{str(n).strip().lower() for n in class_names if str(n).strip()}
if class_names
else None
)
self._warmed = False
# -- 对外 ---------------------------------------------------------------
def warmup(self, *, half: bool = False) -> None:
del half # engine 已是 FP16;ONNX 这边没有 half 的开关
if self._warmed:
return
# 先置位:infer() 开头会检查 _warmed,不置位就会反过来再调 warmup。
self._warmed = True
try:
self.infer(np.zeros((self.imgsz, self.imgsz, 3), dtype=np.uint8))
except Exception:
self._warmed = False
raise
def detect(
self,
image_bgr: np.ndarray,
conf: float | None = None,
iou: float | None = None,
imgsz: int | None = None,
**_kwargs,
) -> List[Detection]:
"""``detect_holes`` 用的别名接口(xyxy + conf + kpts)。"""
if conf is not None:
self.conf = float(conf)
if iou is not None:
self.iou = float(iou)
# imgsz 在这里**不能**改:engine 的输入尺寸是编译期定死的。
if imgsz is not None and int(imgsz) != self.imgsz:
logger.warning(
"pose engine 输入固定 %d,忽略调用方的 imgsz=%s", self.imgsz, imgsz
)
return self.infer(image_bgr)
def infer(self, image_bgr: np.ndarray, *, half: bool = False, seq: int = 0) -> List[Detection]:
del half, seq
if image_bgr is None or getattr(image_bgr, "size", 0) == 0:
return []
if not self._warmed:
self.warmup()
padded, gain, (dw, dh) = letterbox(image_bgr, self.imgsz)
chw = np.ascontiguousarray(
padded[:, :, ::-1].transpose(2, 0, 1), dtype=np.float32
) / 255.0
out = self._runner.run(chw)
return self._decode(out, gain, dw, dh)
# -- 解码 ---------------------------------------------------------------
def _decode(
self, raw: np.ndarray, gain: float, dw: float, dh: float
) -> List[Detection]:
o = np.asarray(raw, dtype=np.float32)[0] # (C, A)
ch, n_anchor = o.shape
k = self.kpt_count
nc = int(ch - 4 - 3 * k)
if nc < 1:
raise RuntimeError(
f"pose 输出通道数不对:C={ch},按 kpt={k} 反推 nc={nc}。"
"引擎/onnx 与 kpt_shape 对不上"
)
scores = o[4 : 4 + nc] # (nc, A)
cls_ids = scores.argmax(axis=0) # (A,)
confs = scores[cls_ids, np.arange(n_anchor)]
keep = confs >= self.conf
if not np.any(keep):
return []
xywh = o[0:4, keep].T # (N, 4)
confs = confs[keep]
cls_ids = cls_ids[keep]
kpts = o[4 + nc :, keep].reshape(k, 3, -1).transpose(2, 0, 1) # (N, k, 3)
xyxy = np.empty_like(xywh)
xyxy[:, 0] = xywh[:, 0] - 0.5 * xywh[:, 2]
xyxy[:, 1] = xywh[:, 1] - 0.5 * xywh[:, 3]
xyxy[:, 2] = xywh[:, 0] + 0.5 * xywh[:, 2]
xyxy[:, 3] = xywh[:, 1] + 0.5 * xywh[:, 3]
keep_i = nms_xyxy(xyxy, confs, self.iou, self.max_det)
if keep_i.size == 0:
return []
xyxy, confs, cls_ids, kpts = xyxy[keep_i], confs[keep_i], cls_ids[keep_i], kpts[keep_i]
# 去 letterbox 的填充,再还原到原图尺度
xyxy = xyxy.astype(float)
xyxy[:, [0, 2]] = (xyxy[:, [0, 2]] - dw) / gain
xyxy[:, [1, 3]] = (xyxy[:, [1, 3]] - dh) / gain
kpts = kpts.astype(float)
kpts[:, :, 0] = (kpts[:, :, 0] - dw) / gain
kpts[:, :, 1] = (kpts[:, :, 1] - dh) / gain
out: List[Detection] = []
for i in range(len(xyxy)):
cid = int(cls_ids[i])
name = self._labels.get(cid)
if self.class_ids is not None and cid not in self.class_ids:
continue
if self.class_names is not None and name.lower() not in self.class_names:
continue
out.append(
Detection(
xyxy=xyxy[i],
conf=float(confs[i]),
cls_id=cid,
cls_name=name,
kpts=kpts[i].copy(),
)
)
out.sort(key=lambda d: d.conf, reverse=True)
return out
def close(self) -> None:
return None
def keypoint_roles(kpt_count: int) -> Tuple[int, Tuple[int, ...]]:
"""返回 ``(跟踪点下标, 孔下标元组)``,供调用方统一口径。
现场约定:k0 跟踪,k1/k2 对孔。模型关键点少于 3 个时只返回存在的部分。
"""
track = TRACK_KPT_INDEX if kpt_count > TRACK_KPT_INDEX else -1
holes = tuple(i for i in HOLE_KPT_INDICES if i < kpt_count)
return track, holes
@@ -0,0 +1,919 @@
#!/usr/bin/env python3
"""把 RealSense 彩色流发布到 ROS 话题(不依赖 realsense2_camera 的 apt 包)。
当你没装 ``ros-humble-realsense2-camera``、但 ``pyrealsense2`` 能正常工作时用它。
默认话题(camera_name=d405):
/d405/color/image_raw
/d405/color/camera_info
/d405/aligned_depth_to_color/image_raw (16UC1 mm,需开启 enable_depth)
性能说明
--------
* 在采集线程里直接发布(没有定时器带来的延迟)。
* 使用 SensorDataQoS(BEST_EFFORT,depth=1),这样慢的订阅者会主动丢旧帧。
* 可选的 SDK 预览:``-p show_preview:=true``(沿用 Intel 示例的链路:pipeline → numpy → 窗口)。
"""
from __future__ import annotations
import array
import os
import threading
import time
from pathlib import Path
from typing import Optional
import numpy as np
import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import CameraInfo, Image
from std_msgs.msg import Float32MultiArray
from arm_application.ros_node import TolerantNode, param_as_str
# RealSense 掉到 480Mbps(或整台消失)时的排查口径。写在这里是为了"只有一处":
# 相机节点的报错、preflight 脚本、文档都引用同一套说法,不会各说各的。
# 为什么 480Mbps 值得专门解释一次(2026-09-28 现场):
# D435 原本协商到 USB 3.2、1280x720@30 跑得好好的,中途
# `xioctl(UVCIOC_CTRL_QUERY) ... No such file or directory`(= 设备从总线上消失)
# 之后重新枚举成了 **USB 2.1**。USB3 线缆/座子里 SuperSpeed 那一对针脚一旦
# 接触不良,USB2 的针脚还能用 —— 于是设备不会"掉线",只是**悄悄掉速**,
# 表现成"相机在,但帧率/分辨率不对"。这时查软件是查不出来的。
USB_RESEAT_HINT = (
'拔插一次、换一根 USB3 线,或换到机箱后面板直连的蓝色口 —— '
'只协商到 480Mbps 通常是 SuperSpeed 针脚没接上,不是相机坏了。'
'看当前协商速率:lsusb -t(设备后面写 480M 还是 5000M)。'
)
def _fill_camera_info(
msg: CameraInfo, width: int, height: int, fx, fy, cx, cy, frame_id: str, dist
) -> None:
msg.width = width
msg.height = height
msg.distortion_model = 'plumb_bob'
msg.d = [float(v) for v in list(dist)[:5]]
while len(msg.d) < 5:
msg.d.append(0.0)
msg.k = [
float(fx), 0.0, float(cx),
0.0, float(fy), float(cy),
0.0, 0.0, 1.0,
]
msg.r = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
msg.p = [
float(fx), 0.0, float(cx), 0.0,
0.0, float(fy), float(cy), 0.0,
0.0, 0.0, 1.0, 0.0,
]
msg.header.frame_id = frame_id
def _site_yaml_path() -> Path:
from arm_application.site_io import site_path
return site_path()
class RealsenseColorPub(TolerantNode):
def __init__(self) -> None:
super().__init__('realsense_color_pub')
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)
self.declare_parameter('height', 720)
self.declare_parameter('fps', 15)
self.declare_parameter('show_preview', False)
self.declare_parameter('overlay_detection', True)
self.declare_parameter('overlay_hold_sec', 1.5)
self.declare_parameter('enable_depth', True)
self.declare_parameter('enable_infra', True)
self.declare_parameter('preview_stereo', True)
# Qt 预览每 N 帧才渲染一次 —— 发布始终保持满帧率;
# CPU 上色 + 四窗格绘制绝不能拖慢数据流。
self.declare_parameter('preview_decimation', 2)
self.declare_parameter('auto_exposure', True)
self.declare_parameter('ae_limit_us', 3000.0)
self.declare_parameter('emitter_enabled', False)
self.declare_parameter('laser_power', 0.0)
self.declare_parameter('exposure_us', 0.0)
self.declare_parameter('gain', 0.0)
self._width = int(self.get_parameter('width').value)
self._height = int(self.get_parameter('height').value)
self._fps = int(self.get_parameter('fps').value)
self._show_preview = bool(self.get_parameter('show_preview').value)
self._overlay_detection = bool(self.get_parameter('overlay_detection').value)
self._overlay_hold_sec = float(self.get_parameter('overlay_hold_sec').value)
self._enable_depth_param = bool(self.get_parameter('enable_depth').value)
self._enable_infra_param = bool(self.get_parameter('enable_infra').value)
self._preview_stereo = bool(self.get_parameter('preview_stereo').value)
self._preview_every = max(
1, int(self.get_parameter('preview_decimation').value)
)
self._auto_exposure = bool(self.get_parameter('auto_exposure').value)
self._ae_limit_us = float(self.get_parameter('ae_limit_us').value)
self._emitter_enabled = bool(self.get_parameter('emitter_enabled').value)
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 表:硬件帧号对比实际发布帧数(循环速率)
self._fps_t0 = 0.0
self._fps_pub_color = 0
self._fps_pub_depth = 0
self._fps_fn_color = -1
self._fps_fn_depth = -1
self._t_wait = 0.0
self._t_align = 0.0
self._t_pub = 0.0
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
)
self._info_pub = self.create_publisher(
CameraInfo, f'{base}/camera_info', qos
)
self._depth_pub = self.create_publisher(
Image, f'/{self._name}/aligned_depth_to_color/image_raw', qos
)
# IR 流也会一起发布,这样预览可以放在独立的查看器进程里
# (Qt 不能和采集循环共享 GIL)。
self._ir1_pub = None
self._ir2_pub = None
if self._enable_infra_param and self._preview_stereo:
self._ir1_pub = self.create_publisher(
Image, f'/{self._name}/infra1/image_rect_raw', qos
)
self._ir2_pub = self.create_publisher(
Image, f'/{self._name}/infra2/image_rect_raw', qos
)
self._pipe = None
self._profile = None
self._align = None
self._have_depth = False
self._have_infra = False
self._infra_mode = 'none'
self._colorizer = None
self._logged_depth = False
# 设备深度尺度 → 用原始 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
self._fx = self._fy = self._cx = self._cy = 0.0
self._dist = [0.0] * 5
self._frame_id = f'{self._name}_color_optical_frame'
self._info_every = 1
self._frame_i = 0
self._preview = None
self._overlay: Optional[list] = None
self._overlay_stamp = 0.0
self._capture_started = False
self._usb_desc = ''
self._last_ok = None
self._site_yaml = _site_yaml_path()
self._site_mtime = 0.0
self._site_wh_warned = False
if self._show_preview:
from arm_application.realsense_sdk_view import SdkPreviewWindow, ensure_app
self._qt_app = ensure_app()
self._preview = SdkPreviewWindow(
title=f'RealSense {self._name} COLOR|DEPTH|IR-L|IR-R (pyrealsense2 SDK)'
)
self._preview.show()
if self._overlay_detection:
from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy
det_qos = QoSProfile(
history=HistoryPolicy.KEEP_LAST,
depth=5,
reliability=ReliabilityPolicy.RELIABLE,
)
self.create_subscription(
Float32MultiArray,
'workpiece/detection',
self._on_det,
det_qos,
)
self.get_logger().info(
'SDK 预览已开启 —— 本进程内显示 COLOR|DEPTH|IR-L|IR-R '
f'(保留 {self._overlay_hold_sec:.1f}s)。关窗口 ≠ 停相机。'
)
self._start_pipeline()
def _pick_device(self, devices, rs):
"""优先用配置的序列号,其次按名字选 D405(这台机器上还接了 D435)。"""
hint = (self._name or 'd405').lower()
if self._serial:
for d in devices:
if d.get_info(rs.camera_info.serial_number) == self._serial:
return d
avail = ', '.join(
f"{d.get_info(rs.camera_info.name)}:{d.get_info(rs.camera_info.serial_number)}"
for d in devices
)
raise RuntimeError(
f'找不到序列号 {self._serial!r}。当前已连接:{avail}'
)
for d in devices:
name = str(d.get_info(rs.camera_info.name) or '').lower()
if hint in name or (hint == 'd405' and '405' in name):
return d
return devices[0]
def _reload_site_preview(self, stream_w: int = 0, stream_h: int = 0) -> None:
"""不重启就能读到新的 overlay_hold_sec(宽高变了则给出警告)。"""
path = self._site_yaml
if not path.is_file():
return
try:
mtime = path.stat().st_mtime
except OSError:
return
if mtime <= self._site_mtime:
return
self._site_mtime = mtime
try:
import yaml
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 重新加载失败:{exc}")
return
if "overlay_hold_sec" in cam:
hold = max(0.0, float(cam.get("overlay_hold_sec") or 0.0))
if abs(hold - self._overlay_hold_sec) > 1e-6:
self.get_logger().info(
f"overlay_hold_sec {self._overlay_hold_sec:.2f}s → {hold:.2f}s (site.yaml)"
)
self._overlay_hold_sec = hold
want_w = int(cam.get("width") or self._width)
want_h = int(cam.get("height") or self._height)
live_w = stream_w or self._width
live_h = stream_h or self._height
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"预览流是 {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:
# 空负载 = 感知漏检 → 立刻清掉绿框
if not msg.data or len(msg.data) < 8:
self._overlay = None
self._overlay_stamp = 0.0
if self._preview is not None:
self._preview.set_overlay(None)
return
self._overlay = list(msg.data)
self._overlay_stamp = time.monotonic()
if self._preview is not None:
self._preview.set_overlay(self._overlay)
def _publish_frame(self, bgr: np.ndarray, depth_u16: Optional[np.ndarray] = None) -> None:
h, w = bgr.shape[0], bgr.shape[1]
stamp = self.get_clock().now().to_msg()
img = Image()
img.header.stamp = stamp
img.header.frame_id = self._frame_id
img.height = h
img.width = w
img.encoding = 'bgr8'
img.is_bigendian = 0
img.step = w * 3
# array.array('B', ...) 会走 setter 的快路径 —— 直接传 bytes 会触发
# 逐元素的 debug 检查,在这里每帧要多花约 40 ms。
img.data = array.array('B', np.ascontiguousarray(bgr).tobytes())
self._img_pub.publish(img)
info = CameraInfo()
info.header.stamp = stamp
info.header.frame_id = self._frame_id
_fill_camera_info(
info, w, h, self._fx, self._fy, self._cx, self._cy,
self._frame_id, self._dist,
)
self._info_pub.publish(info)
if depth_u16 is not None:
dh, dw = int(depth_u16.shape[0]), int(depth_u16.shape[1])
dmsg = Image()
dmsg.header.stamp = stamp
dmsg.header.frame_id = self._frame_id
dmsg.height = dh
dmsg.width = dw
dmsg.encoding = '16UC1'
dmsg.is_bigendian = 0
dmsg.step = dw * 2
dmsg.data = array.array('B', np.ascontiguousarray(depth_u16).tobytes())
self._depth_pub.publish(dmsg)
if not self._logged_depth:
self._logged_depth = True
self.get_logger().info(
f'首个对齐深度 {dw}x{dh} → '
f'/{self._name}/aligned_depth_to_color/image_raw'
)
def _start_pipeline(self, reconnect: bool = False) -> None:
try:
import pyrealsense2 as rs
except Exception as exc: # noqa: BLE001
raise RuntimeError(
'pyrealsense2 不可用。请执行 conda activate ros_flexiv'
) from exc
# 杀掉上一个发布进程后,USB 需要 1–3s 才能重新枚举。
ctx = rs.context()
devices = []
for attempt in range(8):
devices = list(ctx.devices)
if devices:
break
time.sleep(0.4)
ctx = rs.context()
if not devices:
raise RuntimeError(
'没有连接 RealSense(多次重试后 USB 仍为空)。'
'拔插一次;确认没有 realsense-viewer / 其它进程占着这台相机;'
+ USB_RESEAT_HINT
)
found = self._pick_device(devices, rs)
try:
self._serial = str(found.get_info(rs.camera_info.serial_number))
except Exception: # noqa: BLE001
pass
usb_desc = ''
try:
usb_desc = str(found.get_info(rs.camera_info.usb_type_descriptor))
except Exception: # noqa: BLE001
usb_desc = '?'
self._usb_desc = usb_desc
usb2 = usb_desc.startswith('2')
cam_name = ''
try:
cam_name = str(found.get_info(rs.camera_info.name))
except Exception: # noqa: BLE001
cam_name = self._name
self.get_logger().info(
f'{cam_name} serial={self._serial or "?"} USB={usb_desc} '
f'requested {self._width}x{self._height}@{self._fps}Hz'
)
if usb2:
# 只在 480Mbps 口上才降。实测(2026-09-28)640x480@15 彩色+深度
# 可以稳定跑满 15fps;但 1280x720@30 在 USB2 上物理不可能
# (SuperSpeed 没协商上)。
self.get_logger().warn(
f'{cam_name} 只协商到 USB {usb_desc}(480Mbps)—— 降到 '
f'640x480@15Hz。' + USB_RESEAT_HINT
)
self._width, self._height, self._fps = 640, 480, 15
want = (self._width, self._height, self._fps)
last = self._last_ok
if reconnect:
candidates = []
if last:
candidates.append(last)
if want not in candidates:
candidates.append(want)
if (640, 480, 15) not in candidates:
candidates.append((640, 480, 15))
else:
candidates = [
want,
(1280, 720, 15),
(640, 480, 15),
(640, 480, 5),
]
if usb2:
candidates = [(640, 480, 15), (640, 480, 5), (424, 240, 15)]
# 丢弃固件里的垃圾模式(848@90 之类),它们会 ioctl 失败并卡死 USB。
seen = set()
trimmed = []
for item in candidates:
if item in seen:
continue
w, h, fps = item
if fps not in (5, 15, 30):
continue
if w * h > 1280 * 720:
continue
seen.add(item)
trimmed.append(item)
candidates = trimmed or [(640, 480, 15)]
pipe = rs.pipeline()
profile = None
last_err = None
want_depth = bool(self._enable_depth_param)
want_infra = (
bool(self._enable_infra_param)
and bool(self._preview_stereo)
)
have_depth = False
have_infra = False
# USB3:先尝试真正的 IR-L+IR-R(共享立体自动曝光)。彩色+IR-L 在 D405 上
# 经常失败 —— 那就退回 IR-R + 把彩色当左目(观感不同,且不共享自动曝光)。
def _modes():
if want_depth and want_infra and not usb2:
yield True, 'both'
if want_depth and want_infra:
yield True, 'right'
if want_depth:
yield True, 'none'
yield False, 'none'
for w, h, fps in candidates:
for with_depth, infra_mode in _modes():
try:
trial = rs.config()
if self._serial:
trial.enable_device(self._serial)
trial.enable_stream(rs.stream.color, w, h, rs.format.bgr8, fps)
if with_depth:
trial.enable_stream(
rs.stream.depth, w, h, rs.format.z16, fps
)
if infra_mode in ('both', 'left'):
trial.enable_stream(
rs.stream.infrared, 1, w, h, rs.format.y8, fps
)
if infra_mode in ('both', 'right'):
trial.enable_stream(
rs.stream.infrared, 2, w, h, rs.format.y8, fps
)
profile = pipe.start(trial)
have_depth = with_depth
have_infra = infra_mode != 'none'
self._infra_mode = infra_mode
if (w, h, fps) != want:
self.get_logger().warn(
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 彩色+深度失败 —— 只用彩色'
)
elif want_infra and infra_mode == 'none':
self.get_logger().warn(
f'{w}x{h}@{fps}Hz 立体 IR 失败 —— 改用彩色+深度预览'
)
elif infra_mode == 'both':
self.get_logger().info(
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=彩色灰度,IR-R=原始 IR '
'(明暗不同 —— 这不是两路自动曝光)'
)
self._width, self._height, self._fps = w, h, fps
self._last_ok = (w, h, fps)
break
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} 失败:{exc}')
profile = None
have_depth = False
have_infra = False
try:
pipe.stop()
except Exception: # noqa: BLE001
pass
time.sleep(0.8)
pipe = rs.pipeline()
if profile is not None:
break
if profile is None:
raise RuntimeError(
f'{self._name} 没有可用的彩色配置。最后的错误:{last_err}'
)
self._pipe = pipe
self._profile = profile
self._have_depth = have_depth
self._have_infra = have_infra
self._logged_depth = False
# 深度按**真实毫米**发布。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/单位 → mm/单位
self.get_logger().info(
f'深度尺度 {ds * 1000.0:.4f} mm/单位 → 按真实 mm 发布'
)
except Exception as exc: # noqa: BLE001
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
if have_depth:
try:
self._colorizer = rs.colorizer()
self._colorizer.set_option(rs.option.color_scheme, 0)
except Exception: # noqa: BLE001
self._colorizer = None
try:
from arm_application.handeye_realsense import apply_realsense_exposure
import time as _time
exp_us = self._exposure_us if self._exposure_us > 0 else None
gain = self._gain if self._gain > 0 else None
kwargs = dict(
auto_exposure=self._auto_exposure,
ae_limit_us=self._ae_limit_us,
emitter_enabled=self._emitter_enabled,
laser_power=self._laser_power,
exposure_us=exp_us,
gain=gain,
log=self.get_logger().info,
)
apply_realsense_exposure(profile.get_device(), **kwargs)
# 固件经常会在最初几帧重置自动曝光;所以再设置一次。
_time.sleep(0.25)
apply_realsense_exposure(profile.get_device(), **kwargs)
except Exception as exc: # noqa: BLE001
self.get_logger().warn(f'应用曝光参数失败:{exc}')
if self._preview is not None:
if self._infra_mode == 'both':
self._preview.set_pane_titles(
'COLOR', 'DEPTH', 'IR-L (左红外,与右目同一曝光)', 'IR-R (右红外)'
)
elif self._infra_mode == 'right':
self._preview.set_pane_titles(
'COLOR', 'DEPTH', '左目≈彩图灰度(不是红外曝光)', 'IR-R (右红外)'
)
else:
self._preview.set_pane_titles(
'COLOR', 'DEPTH', 'IR-L (无)', 'IR-R (无)'
)
vs = profile.get_stream(rs.stream.color).as_video_stream_profile()
try:
intr = vs.get_intrinsics()
self._fx = float(intr.fx)
self._fy = float(intr.fy)
self._cx = float(intr.ppx)
self._cy = float(intr.ppy)
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 失败({exc});改用 D405 的默认内参,'
f'分辨率 {self._width}x{self._height}'
)
w, h = float(self._width), float(self._height)
if w >= 1200:
self._fx, self._fy = 654.8, 654.0
else:
self._fx = 393.9 * (w / 640.0)
self._fy = 393.4 * (h / 480.0)
self._cx, self._cy = w * 0.5, h * 0.5
dist = [0.0] * 5
while len(dist) < 5:
dist.append(0.0)
self._dist = dist
sn = profile.get_device().get_info(rs.camera_info.serial_number)
model = profile.get_device().get_info(rs.camera_info.name)
depth_txt = (
f' + aligned depth /{self._name}/aligned_depth_to_color/image_raw'
if have_depth
else ' (仅彩色)'
)
if have_infra:
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 '
f'fx={self._fx:.1f} fy={self._fy:.1f}{depth_txt} (SensorDataQoS)'
)
if not getattr(self, '_capture_started', False):
self._capture_started = True
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
self._thread.start()
def _enable_color_ae_fast(self, profile, rs) -> None:
"""彩色自动曝光,优先用短曝光(减少运动模糊)。"""
try:
sensor = profile.get_device().first_color_sensor()
except Exception as exc: # noqa: BLE001
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 = 保住帧率,场景够亮时缩短曝光时间
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;30 Hz 下减少运动模糊
except Exception:
pass
self.get_logger().info('彩色自动曝光已开启(优先帧率 / 减少模糊)')
except Exception as exc: # noqa: BLE001
self.get_logger().warn(f'彩色 AE:{exc}')
def _enable_stereo_ae(self, profile, rs) -> None:
"""两路 IR 成像器共用一路自动曝光(立体模块)。"""
try:
sensor = profile.get_device().first_depth_sensor()
if sensor.supports(rs.option.enable_auto_exposure):
sensor.set_option(rs.option.enable_auto_exposure, 1.0)
limit = getattr(rs.option, "auto_exposure_limit", None)
if limit is not None and sensor.supports(limit):
try:
# 把 IR 曝光上限压到远低于 33 ms 的帧周期,这样光线暗的场景
# 也不会悄悄把数据流掉到 30 Hz 以下。
sensor.set_option(limit, 20000.0)
self.get_logger().info('立体 IR 曝光上限 20ms')
except Exception:
pass
self.get_logger().info('立体 IR 自动曝光已开启(左右共享)')
except Exception as exc: # noqa: BLE001
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:
try:
rgb = np.asanyarray(self._colorizer.colorize(depth_frame).get_data())
if rgb.ndim == 3 and rgb.shape[2] >= 3:
return rgb[:, :, ::-1].copy()
except Exception: # noqa: BLE001
pass
d = np.asarray(depth_u16)
vis = np.zeros((d.shape[0], d.shape[1], 3), dtype=np.uint8)
valid = d > 0
if not valid.any():
return vis
x = d.astype(np.float32)
lo, hi = np.percentile(x[valid], [5, 95])
hi = max(float(hi), float(lo) + 1.0)
gray = np.clip((x - lo) / (hi - lo), 0.0, 1.0)
gray = (gray * 255.0).astype(np.uint8)
gray[~valid] = 0
try:
import cv2
vis = cv2.applyColorMap(gray, cv2.COLORMAP_JET)
vis[~valid] = 0
except Exception: # noqa: BLE001
vis[:, :, 0] = gray
vis[:, :, 1] = gray
vis[:, :, 2] = gray
return vis
def _reconnect(self) -> bool:
try:
if self._pipe is not None:
self._pipe.stop()
except Exception: # noqa: BLE001
pass
self._pipe = None
time.sleep(2.5)
try:
self._start_pipeline(reconnect=True)
return self._pipe is not None
except Exception as exc: # noqa: BLE001
self.get_logger().error(
f'重连失败:{exc}。请拔插 USB3,并给线缆留点余量。',
throttle_duration_sec=5.0,
)
return False
def _capture_loop(self) -> None:
got = False
misses = 0
while self._running and rclpy.ok():
if self._pipe is None:
if not self._reconnect():
time.sleep(2.0)
continue
try:
t_wait0 = time.monotonic()
frames = self._pipe.wait_for_frames(timeout_ms=2000)
self._t_wait = time.monotonic() - t_wait0
ir_l = (
frames.get_infrared_frame(1)
if self._have_infra and self._infra_mode in ('both', 'left')
else None
)
ir_r = (
frames.get_infrared_frame(2)
if self._have_infra and self._infra_mode in ('both', 'right')
else None
)
if self._align is not None:
t_al = time.monotonic()
frames = self._align.process(frames)
self._t_align = time.monotonic() - t_al
color = frames.get_color_frame()
except Exception as exc: # noqa: BLE001
misses += 1
text = str(exc)
dropped = (
'disconnect' in text.lower()
or 'ioctl' in text.lower()
or 'errno=5' in text.lower()
)
if dropped or misses >= 4:
usb = getattr(self, '_usb_desc', '?')
self.get_logger().error(
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}')
continue
if not color:
misses += 1
continue
misses = 0
bgr = np.asanyarray(color.get_data())
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:
# 原始设备单位 → 真实 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)
# 预览相关的工作(CPU 上色、IR 拷贝、四窗格绘制)每 N 帧才做一次
# —— 它绝不能拖慢发布。
do_push = (
self._preview is not None
and (self._frame_i % self._preview_every) == 0
)
depth_bgr = None
ir_l_img = None
ir_r_img = None
if do_push:
if depth and depth_u16 is not None and depth_u16.size:
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
# 只有在拿不到原始左目 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'首帧 {sw}x{sh}')
if self._preview is not None:
self._preview.set_pane_titles(
f"COLOR {sw}x{sh}",
"DEPTH",
"IR-L (左红外)",
"IR-R (右红外)",
)
try:
t_pub = time.monotonic()
self._publish_frame(bgr, depth_u16)
if self._ir1_pub is not None and ir_l is not None:
self._publish_ir(ir_l, self._ir1_pub)
if self._ir2_pub is not None and ir_r is not None:
self._publish_ir(ir_r, self._ir2_pub)
self._t_pub = time.monotonic() - t_pub
if do_push:
self._reload_site_preview(int(bgr.shape[1]), int(bgr.shape[0]))
hold = max(0.0, self._overlay_hold_sec)
age = time.monotonic() - self._overlay_stamp
if self._overlay_detection and self._overlay is not None and age > hold:
self._overlay = None
self._preview.set_overlay(None)
if self._overlay_detection and (
self._overlay is None or age > hold
):
self._preview.set_status(
'没有框 —— 检查 perception_node / conf'
)
else:
self._preview.set_status('')
self._preview.push_views(
bgr.copy(), depth_bgr, ir_l_img, ir_r_img
)
except Exception as exc: # noqa: BLE001
# 上下文可能正在关闭
if not self._running:
break
self.get_logger().warn(
f'发布失败:{exc}', throttle_duration_sec=2.0
)
def _publish_ir(self, frame, pub) -> None:
"""把原始 IR 成像器以 mono8 发布,供外部查看器进程使用。"""
arr = np.asanyarray(frame.get_data())
h, w = int(arr.shape[0]), int(arr.shape[1])
msg = Image()
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = self._frame_id
msg.height = h
msg.width = w
msg.encoding = 'mono8'
msg.is_bigendian = 0
msg.step = w
msg.data = array.array('B', np.ascontiguousarray(arr).tobytes())
pub.publish(msg)
def _fps_meter(self, color, depth, depth_u16) -> None:
"""记录发布帧率与硬件帧率 —— 用来区分循环太慢
(预览/CPU)和 SDK 侧掉帧(自动曝光长曝光 / USB)。"""
now = time.monotonic()
try:
fn_c = int(color.get_frame_number())
except Exception: # noqa: BLE001
fn_c = -1
try:
fn_d = int(depth.get_frame_number()) if depth else -1
except Exception: # noqa: BLE001
fn_d = -1
if self._fps_t0 <= 0.0:
self._fps_t0 = now
self._fps_pub_color = 0
self._fps_pub_depth = 0
self._fps_fn_color = fn_c
self._fps_fn_depth = fn_d
return
self._fps_pub_color += 1
if depth_u16 is not None:
self._fps_pub_depth += 1
dt = now - self._fps_t0
if dt < 5.0:
return
hw_c = (fn_c - self._fps_fn_color) / dt if self._fps_fn_color >= 0 and fn_c >= 0 else -1.0
hw_d = (fn_d - self._fps_fn_depth) / dt if self._fps_fn_depth >= 0 and fn_d >= 0 else -1.0
self.get_logger().info(
f'stream fps color={self._fps_pub_color / dt:.1f} (hw {hw_c:.1f}) '
f'depth={self._fps_pub_depth / dt:.1f} (hw {hw_d:.1f}) '
f'wait={self._t_wait * 1e3:.0f} align={getattr(self, "_t_align", 0.0) * 1e3:.0f} '
f'pub={getattr(self, "_t_pub", 0.0) * 1e3:.0f}ms'
)
self._fps_t0 = now
self._fps_pub_color = 0
self._fps_pub_depth = 0
self._fps_fn_color = fn_c
self._fps_fn_depth = fn_d
def main(args=None) -> None:
rclpy.init(args=args)
node = RealsenseColorPub()
try:
preview = getattr(node, '_preview', None)
if preview is not None:
app = node._qt_app
# 关闭窗口绝不能停掉相机 —— 跟踪需要这条数据流。
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(
'预览已关闭 —— 相机继续发布'
)
node._preview = None
else:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node._running = False
if node._pipe is not None:
try:
node._pipe.stop()
except Exception: # noqa: BLE001
pass
if node._thread is not None:
node._thread.join(timeout=1.0)
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""RealSense SDK 预览:固定的 2x2 Qt 窗格(彩色 / 深度 / IR-L / IR-R)。
把四路画面拼成一张 pixmap 时,一旦缺了深度 / IR 看起来就像单相机视图。
现在每路画面都有自己带标题的独立窗格。
"""
from __future__ import annotations
import threading
from typing import Optional, Sequence, Tuple
import numpy as np
try:
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import (
QApplication,
QGridLayout,
QLabel,
QMainWindow,
QSizePolicy,
QVBoxLayout,
QWidget,
)
except Exception: # noqa: BLE001
QApplication = None # type: ignore
def _to_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
if img is None:
return None
vis = img
if vis.ndim == 2:
vis = np.stack([vis, vis, vis], axis=-1)
elif vis.ndim == 3 and vis.shape[2] == 4:
vis = vis[:, :, :3]
if vis.dtype != np.uint8:
vis = np.clip(vis, 0, 255).astype(np.uint8)
return np.ascontiguousarray(vis)
def _bgr_to_pixmap(bgr: np.ndarray) -> "QPixmap":
rgb = np.ascontiguousarray(bgr[:, :, ::-1])
h, w, _ = rgb.shape
qimg = QImage(rgb.data, w, h, w * 3, QImage.Format_RGB888).copy()
return QPixmap.fromImage(qimg)
def _placeholder(text: str, w: int = 424, h: int = 240) -> np.ndarray:
vis = np.zeros((h, w, 3), dtype=np.uint8)
vis[:] = (28, 28, 28)
try:
import cv2
cv2.putText(
vis, text, (16, h // 2), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (180, 180, 180), 2
)
except Exception: # noqa: BLE001
pass
return vis
class SdkPreviewWindow(QMainWindow):
def __init__(self, title: str = "RealSense") -> None:
super().__init__()
self.setWindowTitle(title)
self._lock = threading.Lock()
self._color: Optional[np.ndarray] = None
self._depth: Optional[np.ndarray] = None
self._ir_l: Optional[np.ndarray] = None
self._ir_r: Optional[np.ndarray] = None
self._overlay: Optional[Sequence[float]] = None
self._status: str = ""
self._hud: str = ""
central = QWidget()
grid = QGridLayout(central)
grid.setContentsMargins(6, 6, 6, 6)
grid.setSpacing(6)
self._img_labels: Tuple[QLabel, QLabel, QLabel, QLabel]
self._cap_labels: Tuple[QLabel, QLabel, QLabel, QLabel]
panes = []
caps = []
for row, col, name in (
(0, 0, "COLOR"),
(0, 1, "DEPTH"),
(1, 0, "IR-L (左红外)"),
(1, 1, "IR-R (右红外)"),
):
box, lab, cap = self._make_pane(name)
grid.addWidget(box, row, col)
panes.append(lab)
caps.append(cap)
self._img_labels = tuple(panes) # type: ignore[assignment]
self._cap_labels = tuple(caps) # type: ignore[assignment]
self.setCentralWidget(central)
self.resize(1360, 820)
self.setMinimumSize(880, 520)
self._timer = QTimer(self)
self._timer.timeout.connect(self._redraw)
self._timer.start(66)
@staticmethod
def _make_pane(title: str) -> Tuple[QWidget, QLabel, QLabel]:
box = QWidget()
lay = QVBoxLayout(box)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(2)
cap = QLabel(title)
cap.setAlignment(Qt.AlignCenter)
cap.setStyleSheet("color:#e8e8e8; font-weight:600; font-size:13px; background:#222;")
img = QLabel("等待画面 …")
img.setAlignment(Qt.AlignCenter)
img.setMinimumSize(400, 220)
img.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
img.setStyleSheet("background:#111; color:#777;")
img.setScaledContents(False)
lay.addWidget(cap)
lay.addWidget(img, 1)
return box, img, cap
def set_pane_titles(self, color: str, depth: str, ir_l: str, ir_r: str) -> None:
for lab, text in zip(self._cap_labels, (color, depth, ir_l, ir_r)):
lab.setText(text)
def push_bgr(self, bgr: np.ndarray) -> None:
self.push_views(bgr)
def push_views(
self,
color: np.ndarray,
depth_bgr: Optional[np.ndarray] = None,
ir_left: Optional[np.ndarray] = None,
ir_right: Optional[np.ndarray] = None,
) -> None:
with self._lock:
self._color = color
self._depth = depth_bgr
self._ir_l = ir_left
self._ir_r = ir_right
def set_overlay(self, data: Optional[Sequence[float]]) -> None:
with self._lock:
self._overlay = data
def set_status(self, text: str) -> None:
with self._lock:
self._status = text or ""
def _draw_overlay(self, bgr: np.ndarray, ov, status: str) -> np.ndarray:
vis = bgr
need = (ov is not None and len(ov) >= 8) or bool(status)
if not need:
return vis
try:
import cv2
except Exception: # noqa: BLE001
return vis
vis = bgr.copy()
if ov is not None and len(ov) >= 8:
x1, y1, x2, y2 = [int(round(v)) for v in ov[:4]]
cx, cy = int(round(ov[6])), int(round(ov[7]))
cv2.rectangle(vis, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv2.drawMarker(
vis,
(vis.shape[1] // 2, vis.shape[0] // 2),
(255, 255, 0),
cv2.MARKER_CROSS,
20,
2,
)
cv2.circle(vis, (cx, cy), 6, (0, 0, 255), -1)
conf = float(ov[4]) if len(ov) > 4 else 0.0
z = float(ov[8]) if len(ov) > 8 else 0.0
cv2.putText(
vis,
f"{conf:.2f} z={z:.3f}m",
(x1, max(20, y1 - 8)),
cv2.FONT_HERSHEY_SIMPLEX,
0.65,
(0, 255, 0),
2,
)
if status:
is_right = ("91" in status) or ("右" in status)
col = (0, 140, 255) if is_right else (40, 200, 255)
cv2.rectangle(vis, (0, 0), (vis.shape[1], 40), (16, 16, 16), -1)
cv2.putText(
vis,
status[:80],
(12, 28),
cv2.FONT_HERSHEY_SIMPLEX,
0.75,
col,
2,
)
return vis
def _set_pane(self, label: QLabel, bgr: Optional[np.ndarray], empty: str) -> None:
vis = _to_bgr(bgr)
if vis is None:
vis = _placeholder(empty)
pix = _bgr_to_pixmap(vis)
slot = label.size()
if slot.width() > 4 and slot.height() > 4:
pix = pix.scaled(slot, Qt.KeepAspectRatio, Qt.FastTransformation)
label.setPixmap(pix)
def _redraw(self) -> None:
with self._lock:
color = None if self._color is None else self._color
depth = self._depth
ir_l = self._ir_l
ir_r = self._ir_r
ov = self._overlay
status = self._status
if color is None:
return
color = self._draw_overlay(color, ov, status)
labs = self._img_labels
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("RealSense 预览窗口缺少 PySide6,无法创建")
app = QApplication.instance()
if app is None:
app = QApplication([])
return app
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""realsense_view —— D405 四窗格预览(COLOR|DEPTH|IR-L|IR-R),独立进程运行。
相机节点(``realsense_color_pub``)只负责发布话题。所有 Qt 工作都放在这里,
这样绘制永远不会拖垮相机采集循环(两者同进程时,Python 的 GIL 会让 ``publish``
的墙钟耗时从 1 ms 跳到约 70 ms)。关闭窗口只是隐藏预览;Ctrl+C 才停止查看器
(相机仍在持续发布)。
"""
from __future__ import annotations
import threading
import time
from typing import Optional
import numpy as np
import rclpy
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from rclpy.qos import (
HistoryPolicy,
QoSProfile,
ReliabilityPolicy,
qos_profile_sensor_data,
)
from sensor_msgs.msg import Image
from std_msgs.msg import Float32MultiArray
from arm_application.realsense_color_pub import _site_yaml_path
from arm_application.ros_node import TolerantNode
def _colorize_depth_u16(d: np.ndarray) -> np.ndarray:
"""为预览窗格做快速 numpy JET 上色(不使用 SDK 的 colorizer)。"""
vis = np.zeros((d.shape[0], d.shape[1], 3), dtype=np.uint8)
valid = d > 0
if not valid.any():
return vis
x = d.astype(np.float32)
lo, hi = np.percentile(x[valid], [5, 95])
hi = max(float(hi), float(lo) + 1.0)
gray = (np.clip((x - lo) / (hi - lo), 0.0, 1.0) * 255.0).astype(np.uint8)
gray[~valid] = 0
try:
import cv2
vis = cv2.applyColorMap(gray, cv2.COLORMAP_JET)
vis[~valid] = 0
except Exception: # noqa: BLE001
vis[:, :, 0] = gray
vis[:, :, 1] = gray
vis[:, :, 2] = gray
return vis
def _mono_to_bgr(mono: np.ndarray) -> np.ndarray:
return np.ascontiguousarray(np.stack([mono, mono, mono], axis=-1))
class RealsenseView(TolerantNode):
def __init__(self) -> None:
super().__init__('realsense_view')
self.declare_parameter('camera_name', 'd405')
self.declare_parameter('overlay_hold_sec', 0.10)
self.declare_parameter('overlay_detection', True)
# D405 用 workpiece/detection;D435 用 workpiece/detection_overhead(像素坐标系不同)。
self.declare_parameter('detection_topic', 'workpiece/detection')
self.declare_parameter('view_hz', 20.0)
self.declare_parameter('hud_label', '')
self._name = str(self.get_parameter('camera_name').value).strip().strip('/')
self._hold = max(0.0, float(self.get_parameter('overlay_hold_sec').value))
self._overlay_detection = bool(self.get_parameter('overlay_detection').value)
self._det_topic = str(self.get_parameter('detection_topic').value or '').strip() or (
'workpiece/detection'
)
self._hud = str(self.get_parameter('hud_label').value or '').strip()
self._color: Optional[Image] = None
self._depth: Optional[Image] = None
self._ir1: Optional[Image] = None
self._ir2: Optional[Image] = None
self._overlay: Optional[list] = None
self._overlay_stamp = 0.0
# stage2 孔对准叠图(已是标注好的 BGR 图),新鲜时 COLOR 窗格直接显示它
self._stage2_img: Optional[np.ndarray] = None
self._stage2_stamp = 0.0
self._site_yaml = _site_yaml_path()
self._site_mtime = 0.0
# RX 速率表 —— 用来直观诊断卡顿(是传输问题还是绘制问题)
self._rx_color = 0
self._rx_depth = 0
self._rx_ir = 0
self._rx_t0 = 0.0
qos = qos_profile_sensor_data # BEST_EFFORT keep_last(1)
base = f'/{self._name}'
self.create_subscription(Image, f'{base}/color/image_raw', self._on_color, qos)
self.create_subscription(
Image, f'{base}/aligned_depth_to_color/image_raw', self._on_depth, qos
)
self.create_subscription(
Image, f'{base}/infra1/image_rect_raw', self._on_ir1, qos
)
self.create_subscription(
Image, f'{base}/infra2/image_rect_raw', self._on_ir2, qos
)
det_qos = QoSProfile(
history=HistoryPolicy.KEEP_LAST,
depth=5,
reliability=ReliabilityPolicy.RELIABLE,
)
if self._overlay_detection:
self.create_subscription(
Float32MultiArray, self._det_topic, self._on_det, det_qos
)
# stage2 孔对准叠图(k1/k2 标注 + 残差 HUD),有它时优先显示
self.create_subscription(
Image, '/stage2/overlay', self._on_stage2_overlay, qos_profile_sensor_data
)
from arm_application.realsense_sdk_view import SdkPreviewWindow, ensure_app
self._qt_app = ensure_app()
hud = self._hud or self._name
self._preview = SdkPreviewWindow(
title=f'{hud} /{self._name} COLOR|DEPTH|IR (查看器)'
)
self._preview.set_pane_titles(
'COLOR', 'DEPTH', 'IR-L (左红外)', 'IR-R (右红外)'
)
self._preview.show()
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'查看器已启动 —— 订阅 /{self._name}/color|depth|infra1|infra2 '
f'检测={self._det_topic if self._overlay_detection else "关"} '
f'(保留 {self._hold:.2f}s)。关闭窗口 = 仅隐藏。'
)
# 回调只存最新消息;转换在 _push 里做。
def _on_color(self, msg: Image) -> None:
self._color = msg
self._rx_color += 1
def _on_depth(self, msg: Image) -> None:
self._depth = msg
self._rx_depth += 1
def _on_ir1(self, msg: Image) -> None:
self._ir1 = msg
self._rx_ir += 1
def _on_ir2(self, msg: Image) -> None:
self._ir2 = msg
def _on_stage2_overlay(self, msg: Image) -> None:
try:
arr = np.frombuffer(msg.data, dtype=np.uint8).reshape(
int(msg.height), int(msg.width), 3
)
self._stage2_img = np.ascontiguousarray(arr)
self._stage2_stamp = time.monotonic()
except Exception: # noqa: BLE001
pass
def _on_det(self, msg: Float32MultiArray) -> None:
data = list(msg.data)
if not data:
self._overlay = None
self._overlay_stamp = 0.0
if self._preview is not None:
self._preview.set_overlay(None)
return
self._overlay = data
self._overlay_stamp = time.monotonic()
if self._preview is not None:
self._preview.set_overlay(data)
def _reload_site(self) -> None:
path = self._site_yaml
if not path.is_file():
return
try:
mtime = path.stat().st_mtime
except OSError:
return
if mtime <= self._site_mtime:
return
self._site_mtime = mtime
try:
import yaml
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 重新加载失败:{exc}")
return
if "overlay_hold_sec" in cam:
# 顶视检测频率低,保留 launch 里设的较长 hold,别被 site 的 0.1s 冲掉。
if "overhead" in self._det_topic:
return
hold = max(0.0, float(cam.get("overlay_hold_sec") or 0.0))
if abs(hold - self._hold) > 1e-6:
self.get_logger().info(
f"overlay_hold_sec {self._hold:.2f}s → {hold:.2f}s (site.yaml)"
)
self._hold = hold
@staticmethod
def _bgr(msg: Image) -> np.ndarray:
return np.frombuffer(msg.data, dtype=np.uint8).reshape(
int(msg.height), int(msg.width), 3
)
@staticmethod
def _mono(msg: Image) -> np.ndarray:
return np.frombuffer(msg.data, dtype=np.uint8).reshape(
int(msg.height), int(msg.width)
)
def _push(self) -> None:
if self._preview is None:
return
self._reload_site()
now = time.monotonic()
if self._rx_t0 <= 0.0:
self._rx_t0 = now
elif now - self._rx_t0 >= 5.0:
dt = now - self._rx_t0
self.get_logger().info(
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
self._rx_color = 0
self._rx_depth = 0
self._rx_ir = 0
age = now - self._overlay_stamp
if self._overlay is not None and age > self._hold:
self._overlay = None
self._preview.set_overlay(None)
if self._overlay is None or age > self._hold:
miss = '没有框 —— 检查 perception_node / conf'
self._preview.set_status(f'{self._hud} {miss}' if self._hud else miss)
else:
self._preview.set_status(self._hud)
if self._color is None:
return
color = self._bgr(self._color)
# stage2 孔对准叠图新鲜时,COLOR 窗格直接显示它(带 k1/k2 标注 + 残差)
if self._stage2_img is not None and (now - self._stage2_stamp) < 2.5:
color = self._stage2_img
depth_bgr = None
if self._depth is not None:
d = np.frombuffer(self._depth.data, dtype=np.uint16).reshape(
int(self._depth.height), int(self._depth.width)
)
depth_bgr = _colorize_depth_u16(d)
# 在这里(执行器线程)做 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)
def main(args=None) -> None:
rclpy.init(args=args)
node = RealsenseView()
app = node._qt_app
# ROS 回调(图像反序列化、深度上色、推送)跑在执行器线程上;
# 主线程只负责绘制 Qt —— 不会有共享的 GIL 突发。
executor = MultiThreadedExecutor(num_threads=3)
executor.add_node(node)
spin_t = threading.Thread(target=executor.spin, daemon=True)
spin_t.start()
try:
while rclpy.ok() and spin_t.is_alive():
app.processEvents()
time.sleep(0.005)
if node._preview is not None and not node._preview.isVisible():
node.get_logger().warn('预览已关闭 —— 查看器挂起(相机仍在发布)')
node._preview = None
except KeyboardInterrupt:
pass
finally:
executor.shutdown()
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()
@@ -0,0 +1,118 @@
"""应用层节点的统一基类:把「参数类型的宽窄」问题收在一处。
为什么需要这个基类(2026-09-28 真机落地实测,代价是顶置相机起不来)
------------------------------------------------------------------
现场 `config/site.yaml` 写的是:
cameras:
d435:
ae_limit_us: 8000 # ← YAML 整数
脚本把它当 `-p ae_limit_us:=8000` 传给节点,而节点里是:
self.declare_parameter('ae_limit_us', 3000.0) # ← double
rclpy **严格**比对类型,于是节点**一起就死**:
InvalidParameterTypeException: Trying to set parameter 'ae_limit_us'
to '8000' of type 'INTEGER', expecting type 'DOUBLE'
现场看到的是「顶置 D435 起不来」,而 site.yaml 那一行看起来毫无问题 ——
`8000` 和 `8000.0` 在运维眼里是同一个数,在 rclpy 眼里是两种类型。
同一族的雷还有:`width: 848.0`(浮点)喂给 int 参数、`emitter_enabled: 1`
喂给 bool 参数、`"26610799"` 与 `26610799`(带不带引号)等等。
**为什么不让现场人员"写对一点"**:那等于要求每个人记住「这个数必须带小数点」,
而记错的表现是节点启动即崩、报错里还完全不提 site.yaml。规则要靠人记住的就
一定会破(`FRAMEWORK_RULES.md` 的总纲)。
**为什么不在每个参数上加个 cast**:异常发生在**声明阶段**,
`float(self.get_parameter(...).value)` 根本执行不到 —— 看着宽容,其实没救。
做法
----
用 ``ParameterDescriptor(dynamic_typing=True)`` 声明:参数类型由**传进来的值**
决定,不做匹配检查;取值时由业务代码 cast(`float(...)` / `int(...)`),
语义仍然由声明处的默认值表达。于是 ``8000`` / ``8000.0`` / ``"8000"`` 都能用。
**代价与边界**:动态类型意味着 rclpy 不再替我们拦「类型写错」。
本项目接受这个代价,因为这些参数**全部**来自 `config/site.yaml` 这个运维手改的
文件(不是程序内部传参),宽容比严格更符合现场;而"名字写错"仍然会被
rclpy 拦下来(参数名是静态的,与类型无关)。
新增节点一律继承 ``TolerantNode``(有守卫测试
``test_ros_node.py::test_no_node_subclasses_rclpy_node_directly`` 钉住)。
"""
from __future__ import annotations
from typing import Any, Optional, Sequence, Union
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node
__all__ = ["TolerantNode", "tolerant_descriptor", "param_as_str"]
def tolerant_descriptor() -> ParameterDescriptor:
"""声明用的描述符:类型跟着**传进来的值**走,不做宽窄匹配。"""
return ParameterDescriptor(dynamic_typing=True)
def param_as_str(node: Node, name: str, default: str = "") -> str:
"""读一个"本质是字符串"的参数,容忍传进来的是数字。
为什么需要:现场 `site.yaml` 里 `serial_number: 26610799`(不加引号)就是
个 YAML 整数,而节点里想要的是字符串。rclpy 同样会因类型不匹配在**声明**
阶段抛异常 —— 而字符串参数在运维手上最容易"看着是数字"。
以前这段逻辑在三个节点里各抄了一份(`dh_pgea_node` / `handeye_teach` /
`realsense_color_pub`),相同的东西抄三份迟早歪(`FRAMEWORK_RULES.md` R5.4)。
现在只此一处;取不到就返回 `default`。
"""
node.declare_parameter(name, default, tolerant_descriptor())
value = node.get_parameter(name).value
if value is None:
return default
return str(value).strip()
class TolerantNode(Node):
"""`rclpy.node.Node` + 「参数类型宽容」。
只改 `declare_parameter` 一件事:默认描述符换成动态类型。
调用方若自己传了 `descriptor`(例如只想放宽某几个),以调用方为准。
"""
def declare_parameter( # type: ignore[override]
self,
name: str,
value: Any = None,
descriptor: Optional[ParameterDescriptor] = None,
ignore_override: bool = False,
) -> Any:
if descriptor is None:
descriptor = tolerant_descriptor()
return super().declare_parameter(name, value, descriptor, ignore_override)
# 列表版本是 rclpy 的另一个入口,同样要放宽(`workspace_xyz_min: [1, 2, 3]`
# 这种"整数字面量的列表"喂给 DOUBLE_ARRAY 一样会崩)。
def declare_parameters( # type: ignore[override]
self,
namespace: str,
parameters: Sequence[
Union[tuple, Any]
],
ignore_override: bool = False,
) -> Any:
fixed = []
for item in parameters:
if (
isinstance(item, tuple)
and len(item) == 2
and not isinstance(item[1], ParameterDescriptor)
):
fixed.append((item[0], item[1], tolerant_descriptor()))
else:
fixed.append(item)
return super().declare_parameters(namespace, fixed, ignore_override)
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""通过 ``/follow_joint_trajectory`` 发送多点关节轨迹(真机 + mock 都适用)。"""
from __future__ import annotations
import time
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from arm_application.site_io import joint_prefix_default
from arm_msgs.action import FollowJointTrajectory
from arm_application.ros_node import TolerantNode
DEFAULT_JOINTS = [
'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7',
]
NUM_JOINTS = 7
# 扁平化存放的位置(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,
0.35, -0.698, 0.35, 1.57, 0.0, 0.698, 0.0,
0.0, -0.698, 0.0, 1.57, 0.0, 0.698, 0.0,
]
class RunJointTrajectory(TolerantNode):
def __init__(self) -> None:
super().__init__('run_joint_trajectory')
self.declare_parameter('joint_prefix', joint_prefix_default())
self.declare_parameter('joint_names', DEFAULT_JOINTS)
self.declare_parameter('velocity_scale', 0.15)
self.declare_parameter('action_name', 'follow_joint_trajectory')
self.declare_parameter('waypoint_times', DEFAULT_TIMES)
self.declare_parameter('waypoint_positions', DEFAULT_POSITIONS)
# 1 = 只跑一次(默认);0 = 循环直到 Ctrl+C;N = 重复 N 次
self.declare_parameter('loop_count', 1)
self.declare_parameter('pause_between_loops', 0.5)
prefix = str(self.get_parameter('joint_prefix').value or '')
base = list(
self.get_parameter('joint_names').get_parameter_value().string_array_value
or DEFAULT_JOINTS
)
self.joint_names = [f'{prefix}{n}' for n in base]
self.velocity_scale = float(self.get_parameter('velocity_scale').value)
action = str(self.get_parameter('action_name').value)
self._action_name = action
self._client = ActionClient(self, FollowJointTrajectory, action)
def run(self) -> int:
if not self._client.wait_for_server(timeout_sec=15.0):
self.get_logger().error(
f'Action [{self._action_name}] 不可用。'
'请先启动真机栈:bash scripts/run_real.sh'
)
return 1
times = list(self.get_parameter('waypoint_times').value)
positions = list(self.get_parameter('waypoint_positions').value)
n_j = len(self.joint_names)
n_pts = len(times)
if n_pts < 1:
self.get_logger().error('waypoint_times 为空')
return 1
if len(positions) != n_pts * n_j:
self.get_logger().error(
f'waypoint_positions 长度 {len(positions)} != '
f'{n_pts} 个点 x {n_j} 个关节 = {n_pts * n_j}'
)
return 1
goal = FollowJointTrajectory.Goal()
goal.joint_names = self.joint_names
goal.num_points = n_pts
goal.velocity_scale = self.velocity_scale
goal.positions = positions
goal.time_from_start = [float(t) for t in times]
loop_count = int(self.get_parameter('loop_count').value)
pause = float(self.get_parameter('pause_between_loops').value)
duration = goal.time_from_start[-1]
if loop_count == 0:
self.get_logger().info(
f'循环模式:{goal.num_points} 个点,约 {duration:.1f}s/圈 '
f'(Ctrl+C 停止)'
)
elif loop_count > 1:
self.get_logger().info(
f'循环模式:{loop_count} 圈,{goal.num_points} 个点,'
f'约 {duration:.1f}s/圈'
)
else:
self.get_logger().info(
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'第 {iteration}/{loop_count} 圈')
code = self._send_once(goal, duration)
if code != 0:
return code
if loop_count == 1:
self.get_logger().info('完成:OK')
return 0
if loop_count > 1 and iteration >= loop_count:
self.get_logger().info(f'完成:已跑完 {loop_count} 圈')
return 0
if pause > 0:
time.sleep(pause)
def _send_once(self, goal: FollowJointTrajectory.Goal, duration: float) -> int:
send = self._client.send_goal_async(goal)
deadline = time.monotonic() + 10.0
while rclpy.ok() and not send.done():
rclpy.spin_once(self, timeout_sec=0.05)
if time.monotonic() > deadline:
self.get_logger().error('发送目标超时')
return 2
handle = send.result()
if handle is None or not handle.accepted:
self.get_logger().error('目标被拒绝')
return 3
res_fut = handle.get_result_async()
deadline = time.monotonic() + max(120.0, duration * 3.0)
next_log = time.monotonic() + 10.0
while rclpy.ok() and not res_fut.done():
rclpy.spin_once(self, timeout_sec=0.05)
now = time.monotonic()
if now >= next_log:
left = max(0.0, deadline - now)
self.get_logger().info(
f'正在执行轨迹(计划约 {duration:.0f}s),'
f'等待机器人……(剩余超时 {left:.0f}s)'
)
next_log = now + 10.0
if now > deadline:
self.get_logger().error('等待结果超时')
return 4
result = res_fut.result().result
if not result.success:
self.get_logger().error(f'失败:{result.message}')
return 5
self.get_logger().info(f'本圈结束:{result.message or "OK"}')
return 0
def main(args=None) -> None:
rclpy.init(args=args)
node = RunJointTrajectory()
code = 1
try:
code = node.run()
except KeyboardInterrupt:
code = 0
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
raise SystemExit(code)
if __name__ == '__main__':
main()
@@ -0,0 +1,290 @@
"""供应用节点加载 ``~/ros2/config/site.yaml``。"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
def ros2_root() -> Path:
"""仓库根目录。先看环境变量,再从本文件向上找 ``config/site.yaml``。
不能默认 ``~/ros2``:别人把仓库克隆到别的目录时,写死的家目录路径是空的,
启动会报文件不存在。
"""
for key in ("SITE_ROS2_ROOT", "ROS2_WS"):
v = os.environ.get(key, "").strip()
if v:
return Path(v).expanduser()
here = Path(__file__).resolve()
for parent in here.parents:
if (parent / "config" / "site.yaml").is_file() and (parent / "scripts" / "env.sh").is_file():
return parent
return Path.home() / "ros2"
_PATH_KEYS = frozenset({
"named_poses_file",
"handeye_file",
"handeye_waypoints",
"handeye_workdir",
"handeye_calibrate",
"yolo_weights",
"hole_weights",
"weights",
"workpiece_weights",
"config_file",
"append_poses_file",
})
def resolve_repo_path(value: str, root: Path | None = None) -> str:
"""仓库内的相对路径展开成绝对路径。已经是绝对路径的原样保留。"""
text = str(value or "").strip()
if not text:
return ""
path = Path(text).expanduser()
if path.is_absolute():
return str(path)
base = root or ros2_root()
return str((base / path).resolve())
def _is_path_key(key: str) -> bool:
return (
key in _PATH_KEYS
or key.startswith("handeye_file")
or key.startswith("handeye_waypoints")
or key.endswith("_weights")
or key.endswith("_file")
or key.endswith("_workdir")
)
def absolutize_site_paths(data: Any, root: Path | None = None) -> Any:
"""把 site 里的仓库相对路径展开。只动路径字段,不动别的字符串。"""
base = root or ros2_root()
if isinstance(data, dict):
out: dict[Any, Any] = {}
for key, val in data.items():
name = str(key)
if isinstance(val, str) and _is_path_key(name):
out[key] = resolve_repo_path(val, base)
elif (
name == "config"
and isinstance(val, str)
and (val.endswith((".yaml", ".yml", ".json")) or "/" in val)
):
out[key] = resolve_repo_path(val, base)
else:
out[key] = absolutize_site_paths(val, base)
return out
if isinstance(data, list):
return [absolutize_site_paths(item, base) for item in data]
return data
def site_path() -> Path:
env = os.environ.get("SITE_CONFIG", "").strip()
if env:
return Path(env).expanduser()
return ros2_root() / "config" / "site.yaml"
def load_site_dict(path: str | Path | None = None) -> dict[str, Any]:
p = Path(path).expanduser() if path else site_path()
if not p.is_file():
return {}
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
return {}
resolved = p.resolve()
root = resolved.parent.parent if resolved.parent.name == "config" else ros2_root()
return absolutize_site_paths(data, root)
def deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
out = dict(base)
for key, val in overlay.items():
if isinstance(val, dict) and isinstance(out.get(key), dict):
out[key] = deep_merge(out[key], val)
else:
out[key] = val
return out
def joint_prefix_default() -> str:
"""当前臂的关节名前缀(``Rizon4-XXXXXX_``),用作 ROS 参数的默认值。
来源顺序:
1. ``SITE_JOINT_PREFIX`` —— ``scripts/env.sh`` → ``load_site_config.sh`` 从
site.yaml 导出,这就是**权威值**;
2. 由 ``SITE_ROBOT_SN`` 现算(``Rizon4-063989`` → ``Rizon4-063989_``);
3. 都没有 → 空字符串。
**绝不写死某个具体 SN。** 以前这里的默认值是 ``"Rizon4-063989_"``,
于是换台机器人后 ``ros2 run`` 直接起的节点会去订**上一台臂**的关节话题,
表现为"节点起来了、什么都不动、也不报错"(R1.1)。
"""
prefix = os.environ.get("SITE_JOINT_PREFIX", "").strip()
if prefix:
return prefix
sn = os.environ.get("SITE_ROBOT_SN", "").strip()
return f"{sn}_" if sn else ""
def base_frame_default() -> str:
"""当前臂的基座坐标系(``<SN>_base_link``),用作 ROS 参数默认值。"""
frame = os.environ.get("SITE_BASE_FRAME", "").strip()
if frame:
return frame
sn = os.environ.get("SITE_ROBOT_SN", "").strip()
return f"{sn}_base_link" if sn else ""
def ee_frame_default() -> str:
"""当前臂的法兰坐标系(``<SN>_flange``),用作 ROS 参数默认值。"""
frame = os.environ.get("SITE_EE_FRAME", "").strip()
if frame:
return frame
sn = os.environ.get("SITE_ROBOT_SN", "").strip()
return f"{sn}_flange" if sn else ""
def overhead_camera(
site: dict[str, Any], robot: dict[str, Any] | None = None
) -> tuple[dict[str, Any], list[str]]:
"""手外(固定)相机 → ``(相机配置字典, 同样合格但未被选中的相机名)``。
手外相机 = ``cameras.*`` 里 ``role`` 以 ``overhead`` 开头的那台(眼在手外时
相机固定在工位外,不随臂动)。
**为什么必须有唯一实现**:这份"按 role 找,再按 arm 收敛"的规则有两个调用方——
S03 粗定位取命名空间(本模块给节点用)、手眼标定脚本取相机名。两边各写一份的
后果不是报错,而是"标定用的相机"和"推理用的相机"悄悄不是同一台,且没人发现。
所以规则只写在这里,两边都调它(`export_site_env.overhead_camera` 也走这里)。
收敛规则(确定性的三级):
1. ``arm`` 与当前臂相同的优先(仿真 ``sim_d435`` 靠这级选中);
2. 都没有 ``arm``(= 双臂共用的那台)时回落到它;
3. 还是多个 → 取第一个,并把其余名字放进第二个返回值,让调用方**出声告警**。
"""
cams = [
cfg
for cfg in (site.get("cameras") or {}).values()
if isinstance(cfg, dict)
and str(cfg.get("role") or "").strip().lower().startswith("overhead")
]
if not cams:
return {}, []
arm = str((robot or {}).get("arm") or "").strip()
mine = [c for c in cams if str(c.get("arm") or "").strip() == arm] if arm else []
hits = mine or [c for c in cams if not str(c.get("arm") or "").strip()] or cams
chosen = dict(hits[0])
others = [str(c.get("name") or "?") for c in hits[1:]]
return chosen, others
def eye_to_hand_result_name(short: str, camera_name: str) -> str:
"""眼在手外结果文件的**文件名**(不含目录)。
外参绑定「这条臂的基座 + 这台相机」:换条臂或换台相机都不通用,
所以名字必须同时带上臂号与相机。调用方只许调这里,不许自己拼
(launch / export_site_env / S03 找文件都走这一处)。
"""
short = str(short or "").strip()
cam = str(camera_name or "").strip().strip("/")
if short and cam:
return f"{short}_{cam}_eye_to_hand.yaml"
if cam:
return f"{cam}_eye_to_hand.yaml"
if short:
return f"{short}_eye_to_hand.yaml"
return "eye_to_hand.yaml"
def resolve_eye_to_hand_file(
root: Path,
*,
explicit: str = "",
camera_name: str = "",
) -> Path | None:
"""找手外相机的手眼文件。没有就返回 None。
路径一律按 ``root`` 解析,不跟当前工作目录走——相对 CWD 的路径会在
"换个目录启动"时静默失效(文件找不到 → S03 直接降级),而人只会怀疑标定。
候选顺序:
1. ``explicit`` —— launch 按 site.yaml 派生的确切路径
(``<臂号>_<相机>_eye_to_hand.yaml``);
2. ``<相机名>_eye_to_hand.yaml`` —— 旧命名 / 仿真(``sim_d435_...``);
3. ``eye_to_hand.yaml`` —— 再兜底。
"""
candidates: list[Path] = []
if explicit:
p = Path(explicit).expanduser()
candidates.append(p if p.is_absolute() else root / p)
cam = str(camera_name or "").strip().strip("/")
if cam:
candidates.append(root / "handeye" / "results" / f"{cam}_eye_to_hand.yaml")
candidates.append(root / "handeye" / "results" / "eye_to_hand.yaml")
for cand in candidates:
if cand.is_file():
return cand
return None
TRACK_CAMERA_ROLES = ("wrist", "overhead")
def track_camera_role(site: dict[str, Any] | None) -> str:
"""一阶段跟踪用哪台相机:``wrist``(腕部 D405)或 ``overhead``(顶置 D435)。
写错**当场报错**,不会退回腕部 —— 退回会让人以为在验 D435,其实还在用 D405。
缺省 ``wrist``:没写这个键时行为与改之前一致(腕部追踪)。
"""
perc = (site or {}).get("perception")
perc = perc if isinstance(perc, dict) else {}
raw = perc.get("track_camera")
if raw is None or str(raw).strip() == "":
return "wrist"
role = str(raw).strip().lower()
if role not in TRACK_CAMERA_ROLES:
raise ValueError(
f"perception.track_camera 只能是 wrist 或 overhead,收到 {raw!r}。"
"写错不会退回腕部 —— 退回会让人以为在用 D435 其实还在用 D405。"
)
return role
def track_handeye_mode(role: str) -> str:
"""跟踪相机角色 → 手眼装法。overhead 必须用 cam→基座,不能用 cam→法兰。"""
if str(role or "").strip().lower() == "overhead":
return "eye_to_hand"
return "eye_in_hand"
def handeye_static_tf_frames(
*,
mode: str,
ee_frame: str,
base_frame: str,
cam_frame: str,
) -> tuple[str, str]:
"""手眼静态 TF 的 ``(父系, 子系)``。
两种装法的父子关系**不同**,不能混:
* ``eye_in_hand``:法兰 ← 相机(相机跟着臂动)
* ``eye_to_hand``:基座 ← 相机(相机固定,不随臂动)
把眼在手外的文件按"法兰 ← 相机"发出去,会得到一条**看着合理**的错 TF ——
定位总能到附近、永远差一截,而人只会去怀疑标定精度。
"""
if str(mode or "").strip().lower() == "eye_to_hand":
return str(base_frame), str(cam_frame)
return str(ee_frame), str(cam_frame)
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Orisys 视触觉命令行工具。端口 / 配置来自 ``site.yaml`` 的 ``tactile`` 段。"""
from __future__ import annotations
import argparse
import sys
import time
from arm_application.orisys_tactile import make_tactile, resolve_orisys_config
from arm_application.site_io import load_site_dict, site_path
def main() -> int:
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()
data = load_site_dict(args.site or None)
t = data.get("tactile") or {}
cfg_name = resolve_orisys_config(str(t.get("config") or "dd03"), str(t.get("config_file") or ""))
print(
f"site={site_path() if not args.site else args.site} "
f"backend={t.get('backend') or 'orisys'} "
f"left={t.get('left_video', 0)} right={t.get('right_video', 2)} "
f"config={cfg_name}"
)
tactile = make_tactile(data)
try:
tactile.connect()
except Exception as exc:
print(f"错误:{exc}", file=sys.stderr)
return 1
t0 = time.time()
n = 0
try:
while time.time() - t0 < args.duration:
sample = tactile.read_sample()
n += 1
raw = sample.raw or {}
left = raw.get("left") or {}
right = raw.get("right") or {}
if n % 10 == 0:
print(
f"n={n:4d} contact={int(sample.contact)} "
f"L F={float(left.get('fnormal') or 0):8.2f} "
f"R F={float(right.get('fnormal') or 0):8.2f} "
f"Fz={float(sample.force_xyz[0]):8.2f}"
)
time.sleep(0.01)
except KeyboardInterrupt:
print("已中断")
finally:
tactile.disconnect()
print(f"完成 frames={n}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,84 @@
"""触觉采样 / 指令类型。后端(Orisys / mock)可替换。"""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
import numpy as np
@dataclass
class TactileSample:
timestamp: float
force_xyz: np.ndarray
torque_xyz: np.ndarray
contact: bool
embedding: np.ndarray | None = None
raw: dict[str, Any] | None = None
@dataclass
class TactileCommand:
delta_xyz: np.ndarray
delta_rpy: np.ndarray
gripper_pos: float | None = None
done: bool = False
ok: bool = True
message: str = ""
class TactileInterface(ABC):
name: str = "tactile_base"
@abstractmethod
def connect(self) -> None: ...
@abstractmethod
def disconnect(self) -> None: ...
@abstractmethod
def reset(self, hole_id: int) -> None: ...
@abstractmethod
def step(self, sample: TactileSample) -> TactileCommand: ...
class MockTactile(TactileInterface):
name = "mock_tactile"
def __init__(self) -> None:
self._i = 0
self._hole = 0
def connect(self) -> None:
self._i = 0
def disconnect(self) -> None:
pass
def reset(self, hole_id: int) -> None:
self._hole = hole_id
self._i = 0
def read_sample(self) -> TactileSample:
self._i += 1
return TactileSample(
timestamp=time.time(),
force_xyz=np.zeros(3),
torque_xyz=np.zeros(3),
contact=self._i >= 8,
)
def step(self, sample: TactileSample) -> TactileCommand:
self._i += 1
done = self._i >= 15
return TactileCommand(
delta_xyz=np.zeros(3),
delta_rpy=np.zeros(3),
done=done,
ok=True,
message=f"mock hole={self._hole} step={self._i}",
)
@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""Publish Orisys dual-finger tactile status. Config from site.yaml tactile:.
架构(厂家示例 two_sensor_pipeline 的推荐):每个传感器一个独立
multiprocessing 进程(独立 CUDA 上下文),ROS 主进程只做队列→话题转发。
原因:实测单进程内双开两路传感器会互相拖垮(30→0.8fps);独立进程稳定 30fps。
"""
from __future__ import annotations
import multiprocessing as mp
import signal
import time
import numpy as np
import rclpy
from rclpy.node import Node
from arm_application.image_utils import UnsupportedImageError, ndarray_to_image
from arm_application.orisys_tactile import load_orisys_from_site, tactile_worker
from arm_application.site_io import load_site_dict, site_path
from arm_msgs.msg import TactileStatus
from sensor_msgs.msg import Image
from arm_application.ros_node import TolerantNode
class TactileNode(TolerantNode):
def __init__(self) -> None:
super().__init__("tactile")
self.declare_parameter("publish_hz", 20.0)
# 下面两个可以用 ROS 参数覆盖 site.yaml,便于不改站点文件就做验证
# (例如 mock 后端 + 打开图像话题来确认链路通)。
self.declare_parameter("backend", "")
self.declare_parameter("image_hz", -1.0)
data = load_site_dict()
t = data.get("tactile") or {}
hz = float(t.get("publish_hz") or self.get_parameter("publish_hz").value or 20.0)
backend_override = str(self.get_parameter("backend").value).strip()
self._backend = (
backend_override or str(t.get("backend") or "orisys")
).strip().lower()
self._mock = self._backend in ("mock", "mock_orisys")
self._pub = {
side: self.create_publisher(TactileStatus, f"/tactile/{side}/status", 10)
for side in ("left", "right")
}
self._latest: dict[str, tuple[float, dict]] = {}
self._connected = {"left": False, "right": False}
self._mock_n = 0
self._workers: list = []
self._queues: dict[str, object] = {}
self._stop = None
# --- 视触觉图像 / 力矢量场 ---
# 默认关闭(image_hz=0):图像每帧几百 KB,不该拖累三力的 30fps 时序。
# 打开后走一条 maxsize=1 的独立队列,满则丢旧留新 —— 主进程来不及消费
# 只会丢图像,三力一条不受影响。
self._image_hz = (
float(self.get_parameter("image_hz").value)
if float(self.get_parameter("image_hz").value) >= 0
else float(t.get("image_hz") or 0.0)
)
self._image_q = None
self._image_warned: set[str] = set()
self._image_pub: dict[str, object] = {}
self._vector_pub: dict[str, object] = {}
self._image_count: dict[str, int] = {}
self._last_mock_img = 0.0
# 图像 publisher 与后端无关 —— mock 后端也要能发。
# 否则"图像话题到底通不通"只能等到有相机、上真机那天才发现。
if self._image_hz > 0:
for side in ("left", "right"):
self._image_pub[side] = self.create_publisher(
Image, f"/tactile/{side}/image", 5
)
self._vector_pub[side] = self.create_publisher(
Image, f"/tactile/{side}/vector", 5
)
self.get_logger().info(
f"视触觉图像已开启: {self._image_hz:.1f}Hz "
f"→ /tactile/{{left,right}}/image 与 /vector"
)
if self._mock:
self._connected = {"left": True, "right": True}
self.get_logger().info("触觉使用 mock 后端(假数据,不接真实传感器)")
else:
cfg = load_orisys_from_site(data)
ctx = mp.get_context("spawn") # spawn:子进程不带 DDS 线程,CUDA 上下文干净
self._stop = ctx.Event()
if self._image_hz > 0:
self._image_q = ctx.Queue(maxsize=1)
for side in ("left", "right"):
q = ctx.Queue(maxsize=2)
self._queues[side] = q
p = ctx.Process(
target=tactile_worker,
args=(side, cfg, q, self._stop, self._image_q),
name=f"tactile_{side}", daemon=True,
)
p.start()
self._workers.append(p)
self.get_logger().info(
f"tactile workers spawned (left pid={self._workers[0].pid} right pid={self._workers[1].pid}) "
f"site={site_path()}"
)
self.create_timer(1.0 / max(1e-3, hz), self._tick)
def _drain(self, side: str) -> None:
q = self._queues.get(side)
if q is None:
return
latest = None
while True:
try:
latest = q.get_nowait()
except Exception:
break
if latest is None:
return
if latest.get("error"):
self.get_logger().error(f"[{side}] {latest['error']}", throttle_duration_sec=3.0)
return
self._connected[side] = True
self._latest[side] = (time.monotonic(), latest)
def _drain_images(self) -> None:
"""把 worker 推来的最新图像 / 力矢量场发布出去。
只取最新一帧:worker 已按 ``image_hz`` 节流、队列 ``maxsize=1``,
所以这里读到的永远是最新画面;主进程偶尔卡一下,丢掉的是中间帧
而不是最新帧。
"""
if self._image_q is None:
return
latest = None
while True:
try:
latest = self._image_q.get_nowait()
except Exception:
break
if latest is None:
return
side = str(latest.get("side") or "")
if side not in self._image_pub:
return
stamp = self.get_clock().now().to_msg()
frame_id = f"{side}_tactile"
self._publish_array(
self._image_pub[side], latest.get("img"), frame_id, stamp, side, "image"
)
self._publish_array(
self._vector_pub[side], latest.get("vector"), frame_id, stamp, side, "vector"
)
def _publish_array(self, pub, array, frame_id: str, stamp, side: str, tag: str) -> None:
"""打包并发布一个数组;形状/类型不支持时跳过该话题而不是崩溃。
``array is None`` 是正常情况(该侧没有矢量场输出、或本帧没算),
不报错也不发空图 —— 发一张全零的假图会让下游以为接触到了东西。
"""
if array is None:
return
key = f"{side}_{tag}"
try:
msg = ndarray_to_image(array, frame_id=frame_id, stamp=stamp)
except UnsupportedImageError as exc:
# 编码不支持是固定的(SDK 给的 dtype 不会变),每侧每种图只提醒一次,
# 否则 5Hz 的警告会把现场日志冲掉。
if key not in self._image_warned:
self._image_warned.add(key)
self.get_logger().warn(
f"[{key}] 无法打包成 Image,已跳过该话题: {exc}"
)
return
try:
pub.publish(msg)
except Exception as exc:
if f"{key}_pub" not in self._image_warned:
self._image_warned.add(f"{key}_pub")
self.get_logger().warn(f"[{key}] 发布失败: {exc}")
return
if self._image_count.get(key, 0) == 0:
self.get_logger().info(
f"[{key}] 首帧已发布 ({msg.width}x{msg.height} {msg.encoding})"
)
self._image_count[key] = self._image_count.get(key, 0) + 1
def _publish_mock_images(self, force: float) -> None:
"""mock 后端下造一张形变图 + 一张力矢量场,用来打通图像发布链路。
没有传感器时也要能把 ``/tactile/<side>/image`` 与 ``/vector`` 跑起来 ——
否则这两个话题是否真的能出图,只能等到上真机那天才知道。
图像内容模仿视触觉的形变:中间一个高斯隆起(法向深度),
连同指向中心的切向流(矢量场),接触越深幅度越大。
"""
if not self._image_pub:
return
stamp = self.get_clock().now().to_msg()
h, w = 48, 64
yy, xx = np.mgrid[0:h, 0:w]
cx, cy = w / 2.0, h / 2.0
bump = np.exp(-(((xx - cx) ** 2 + (yy - cy) ** 2) / (2.0 * 9.0 ** 2)))
depth = (bump * (0.05 + 0.5 * float(force))).astype(np.float32)
vx = ((cx - xx) / w * bump * float(force)).astype(np.float32)
vy = ((cy - yy) / h * bump * float(force)).astype(np.float32)
vector = np.dstack([vx, vy])
for side in ("left", "right"):
frame = f"{side}_tactile"
self._publish_array(self._image_pub[side], depth, frame, stamp, side, "image")
self._publish_array(self._vector_pub[side], vector, frame, stamp, side, "vector")
def _tick(self) -> None:
self._drain_images()
if self._mock:
self._mock_n += 1
force = 0.2 if self._mock_n >= 8 else 0.0
for side in ("left", "right"):
self._publish(side, {
"fnormal": force, "fshearx": 0.0, "fsheary": 0.0,
"fps": 30.0, "is_contact": self._mock_n >= 8,
"centroid": (0.0, 0.0),
})
# mock 的图像也按 image_hz 节流,避免比真机行为更"勤快"。
if self._image_hz > 0:
now = time.monotonic()
if now - self._last_mock_img >= 1.0 / self._image_hz:
self._last_mock_img = now
self._publish_mock_images(force)
return
for side in ("left", "right"):
self._drain(side)
item = self._latest.get(side)
if item is None:
continue
ts, data = item
if time.monotonic() - ts > 0.6:
continue # 数据太旧(worker 卡住)不发布
self._publish(side, data)
def _publish(self, side: str, data: dict) -> None:
msg = TactileStatus()
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = f"{side}_tactile"
msg.side = side
msg.connected = bool(self._connected.get(side, False))
msg.contact = bool(data.get("is_contact", False))
msg.fnormal = float(data.get("fnormal") or 0.0)
msg.fshearx = float(data.get("fshearx") or 0.0)
msg.fsheary = float(data.get("fsheary") or 0.0)
msg.fps = float(data.get("fps") or 0.0)
c = data.get("centroid") or (0.0, 0.0)
msg.centroid_u = float(c[0])
msg.centroid_v = float(c[1])
msg.error_msg = ""
self._pub[side].publish(msg)
def destroy_node(self) -> bool:
try:
if self._stop is not None:
self._stop.set()
for p in self._workers:
p.join(timeout=2.0)
if p.is_alive():
p.terminate()
for p in self._workers:
p.join(timeout=1.5)
if p.is_alive():
try:
p.kill() # 还赖着就 SIGKILL,绝不留孤儿占摄像头
except Exception:
pass
finally:
return super().destroy_node()
def main() -> None:
rclpy.init()
node = TactileNode()
def _sigterm(_sig, _frm) -> None:
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, _sigterm) # SIGTERM 也走干净退出,释放相机
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
@@ -0,0 +1,448 @@
"""权重模块的目录布局、模型发现与标签归属(纯逻辑)。
这个模块刻意**只依赖标准库 + model_labels**,不 import ROS、不 import
ultralytics/tensorrt。原因:
1. 可以在没有 GPU、没有 ROS 的环境里跑单元测试;
2. ``scripts/weights.sh`` 这个命令行工具能直接用上,不必先 ``source`` 一整套环境。
真正干重活的转换(PyTorch → ONNX → TensorRT)在 ``scripts/weights.py`` 里,
那部分才需要 ``ros_flexiv`` 环境和 GPU。
## 目录约定
models/
README.md ← 模块规则(要改这个模块先读它)
staging/ ← 非标准格式先丢这里,转换工具有序地消费它
<分组>/ ← 例如 yolo/、hole_insert/
<模型名>.onnx
<模型名>.engine
<模型名>.labels.yaml ← 标签(旁挂,可手改,进 git)
"分组"只是一个子目录名,用来分门别类,没有语义约束。默认 ``yolo``。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple, Union
from .model_labels import (
SOURCE_MODEL,
SOURCE_ONNX,
SOURCE_PLACEHOLDER,
SOURCE_SIDECAR,
LabelSet,
label_file_status,
labels_path_for,
labels_from_model,
labels_from_onnx,
placeholder_labels,
read_label_file,
)
# 会被当成"权重"的文件后缀(不带点的小写形式)。
WEIGHT_EXTS: Tuple[str, ...] = (
"pt",
"pth",
"onnx",
"engine",
"trt",
"plan",
"tflite",
)
# 转换工具认识的输入格式。其它格式一律归到 staging 等人工处理。
# 注意 pth 也收:虽然 Ultralytics 主要用 .pt,但用户手里常是 .pth。
CONVERTIBLE_INPUT_EXTS: Tuple[str, ...] = ("pt", "pth", "onnx")
# 已经被转换过的产物,重转没有意义。
FINAL_EXTS: Tuple[str, ...] = ("engine", "trt", "plan")
# 非标准格式/待转换文件放这里。
STAGING_DIRNAME = "staging"
# 读标签时挑格式的顺序:onnx 有内嵌 metadata 且读取代价极低(毫秒级),
# 所以排在前面;engine 排最后是因为它很可能什么都没有。
LABEL_READ_ORDER: Tuple[str, ...] = ("onnx", "pt", "pth", "engine", "trt")
# ---------------------------------------------------------------------------
# 仓库根 / 权重根
# ---------------------------------------------------------------------------
def find_repo_root(start: Optional[Union[str, Path]] = None) -> Optional[Path]:
"""从 ``start`` 往上找仓库根(以存在 ``config/site.yaml`` 为标志)。
这样无论代码是从 ``src/`` 直接跑、还是从 ``install/`` 跑,
都能定位到同一份 ``models/``,不会出现"两份权重"。
"""
here = Path(start).resolve() if start else Path(__file__).resolve()
for cand in [here, *here.parents]:
if (cand / "config" / "site.yaml").is_file():
return cand
return None
def default_weights_root() -> Path:
"""``models/`` 的默认位置。找不到仓库根时退回当前工作目录下的 models/。"""
import os
env = os.environ.get("SITE_WEIGHTS_ROOT")
if env:
return Path(env).expanduser()
root = find_repo_root()
if root is not None:
return root / "models"
return Path.cwd() / "models"
# ---------------------------------------------------------------------------
# 模型条目
# ---------------------------------------------------------------------------
@dataclass
class ModelEntry:
"""一个"模型" = 权重目录里同名前缀的一组文件。"""
group: str
name: str
files: Dict[str, Path] = field(default_factory=dict)
@property
def dir(self) -> Path:
if not self.files:
return Path(".")
return next(iter(self.files.values())).parent
@property
def labels_path(self) -> Path:
return self.dir / (self.name + ".labels.yaml")
@property
def exts(self) -> List[str]:
"""已存在的格式,按固定顺序返回,保证输出稳定。
顺序就是转换流水线的顺序:``pt → onnx → engine``(源头 → 中间 → 最终)。
这样列表里一眼能看出这个模型走到哪一步了。
"""
order = list(WEIGHT_EXTS)
return sorted(self.files, key=lambda e: (order.index(e) if e in order else 99, e))
def path(self, ext: str) -> Optional[Path]:
return self.files.get(ext.lower().lstrip("."))
def sorted_files(self) -> List[Path]:
"""按读标签的推荐顺序给出文件列表。"""
out: List[Path] = []
for ext in LABEL_READ_ORDER:
p = self.files.get(ext)
if p:
out.append(p)
for ext in self.exts:
p = self.files.get(ext)
if p and p not in out:
out.append(p)
return out
def missing_of(self, wanted: Sequence[str]) -> List[str]:
return [e for e in wanted if e not in self.files]
# ---------------------------------------------------------------------------
# 发现
# ---------------------------------------------------------------------------
def is_staging(path: Path) -> bool:
return path.name == STAGING_DIRNAME
def groups(root: Union[str, Path]) -> List[str]:
"""列出所有权重分组目录(跳过 staging 与隐藏目录)。"""
r = Path(root)
if not r.is_dir():
return []
return sorted(
p.name
for p in r.iterdir()
if p.is_dir() and not p.name.startswith(".") and not is_staging(p)
)
def staging_dir(root: Union[str, Path]) -> Path:
return Path(root) / STAGING_DIRNAME
def infer_group(
root: Union[str, Path],
source: Union[str, Path],
fallback: str = "yolo",
) -> str:
"""转换产物该落到**哪个分组**:默认沿用源文件所在的分组。
为什么不能一律落到 ``yolo``(2026-09-28 真机踩到,代价是一次白转)
----------------------------------------------------------------
现场把 ``models/hole_insert/detect_best_1.onnx`` 转 engine,工具默认
``--group yolo``,于是产物落到 ``models/yolo/detect_best_1.engine`` ——
而运行时找 engine 的规则是**同级同名**(``yolo_detector.resolve_weights``:
``p.with_suffix(".engine")``,即 site.yaml 指的 ``.onnx`` 旁边)。两边
一分家,engine 就等于不存在:现象是"启动即报缺 engine",而文件明明躺着。
这正是本项目最忌讳的**静默失效** —— 工具没报错,位置也"合理"。
规则:
- 源文件已在权重根目录下的某个分组里(例如 ``models/hole_insert/x.onnx``)
→ 沿用 ``hole_insert``。这样"就地转换"永远落在原地,运行时必然找得到;
- 源文件在 ``staging/``、权重根目录之外或根目录正下方 → ``fallback``
(默认 ``yolo``):那才是"新来的模型,还没决定分组"的正常情形。
Args:
root: 权重根目录(``models/``)。
source: 输入文件。
fallback: 无法沿用时的分组名。
Returns:
分组名(不带路径)。
"""
r = Path(root)
try:
rel = Path(source).expanduser().resolve().relative_to(r.resolve())
except ValueError:
# 不在权重根目录下(/tmp、外部磁盘、staging 之类)→ 没有"原地"可言
return fallback
if len(rel.parts) < 2:
# 直接躺在 models/ 正下方:没有分组信息,用默认分组
return fallback
group = rel.parts[0]
if is_staging(r / group) or group.startswith("."):
return fallback
return group
def staging_files(root: Union[str, Path]) -> List[Path]:
"""staging 里的待处理文件。
跳过隐藏文件(``.gitkeep`` 等)和 ``.md`` 说明文档 —— 那些是给人和 git 看的,
不是待转换的模型。否则 ``doctor`` 会把 README 报成"格式不支持"。
"""
d = staging_dir(root)
if not d.is_dir():
return []
return sorted(
p
for p in d.iterdir()
if p.is_file() and not p.name.startswith(".") and p.suffix.lower() != ".md"
)
def discover(root: Optional[Union[str, Path]] = None) -> List[ModelEntry]:
"""扫描权重根,按"分组 + 模型名"归并出所有模型。
同名不同格式(m.onnx / m.engine / m.pt)会被合成一个条目 —— 因为它们
共用一个模型名、一份标签。``.labels.yaml`` 本身不算权重。
"""
r = Path(root) if root is not None else default_weights_root()
out: List[ModelEntry] = []
if not r.is_dir():
return out
for g in groups(r):
gdir = r / g
by_name: Dict[str, ModelEntry] = {}
for p in sorted(gdir.iterdir()):
if not p.is_file() or p.name.startswith("."):
continue
ext = p.suffix.lower().lstrip(".")
if ext not in WEIGHT_EXTS:
continue
# m.labels.yaml 的后缀是 .yaml,不会落进 WEIGHT_EXTS,天然被排除。
stem = p.name[: -(len(ext) + 1)]
entry = by_name.get(stem)
if entry is None:
entry = ModelEntry(group=g, name=stem, files={})
by_name[stem] = entry
entry.files[ext] = p
out.extend(by_name.values())
return out
def find_model(
root: Optional[Union[str, Path]] = None,
name: str = "",
group: Optional[str] = None,
) -> Optional[ModelEntry]:
"""按模型名找模型。
- ``name`` 是完整路径 → 直接按路径构造条目(不要求它在权重目录里)
- ``name`` 是 ``分组/模型名`` → 按分组+名字精确查找
- ``name`` 是裸名字 → 在所有分组里找同名模型;``group`` 可缩小范围
裸名字跨分组同名时会优先返回 ``group`` 指定的那个;都没指定就返回找到的第一个,
并由调用方提示存在歧义。
"""
p = Path(name).expanduser()
if p.suffix and (p.is_file() or p.parent.is_dir() and str(name).count("/")):
ext = p.suffix.lower().lstrip(".")
if ext in WEIGHT_EXTS:
entry = ModelEntry(group=group or p.parent.name, name=p.stem, files={})
entry.files[ext] = p
return entry
for e in discover(root):
if e.name != name:
continue
if group and e.group != group:
continue
return e
# 裸名字没匹配上,再试 ``分组/模型名``。
# ``list`` 的输出里模型是带分组一起显示的,用户照着敲 ``yolo/detect_best``
# 非常自然;以前这里会直接报"找不到模型",而报错又只列出裸名字,很别扭。
# 放在裸名字查找之后,是为了不把真正的相对路径(``a/b.onnx``)误判成分组名。
if group is None and "/" in name:
head, _, tail = name.rpartition("/")
if head and tail:
return find_model(root, tail, head)
return None
# ---------------------------------------------------------------------------
# 标签归属
# ---------------------------------------------------------------------------
def model_labels(entry: ModelEntry, *, deep: bool = False) -> LabelSet:
"""给出某个模型当前生效的标签,附带来源。
优先级:旁挂文件 > ONNX 内嵌 > 模型内嵌 > 占位符。
``deep=False``(默认)只读旁挂文件和 ONNX metadata —— 都快。
``deep=True`` 才会去加载模型本身(``.pt`` / ``.engine``),
代价是要起 Ultralytics、加载权重,秒级,所以默认不开。
这样 ``weights.sh list`` 能秒出结果,不因为几个 .pt 卡十几秒。
"""
files = entry.sorted_files()
if not files:
return LabelSet(names={}, source=SOURCE_PLACEHOLDER, path=None)
side = label_file_status(files[0])[1]
names = read_label_file(side) if side else None
if names:
return LabelSet(names=names, source=SOURCE_SIDECAR, path=side)
for p in files:
ext = p.suffix.lower().lstrip(".")
if ext == "onnx":
got = labels_from_onnx(p)
if got:
return LabelSet(names=got[0], source=SOURCE_ONNX, path=p)
elif deep and ext in ("pt", "pth", "engine", "trt", "plan"):
# engine 在当前的 TensorRT 下读不到(没有 metadata API),
# 但万一用户是拿 Ultralytics 导出的,还是值得试一次。
got = labels_from_model(p)
if got:
return LabelSet(names=got[0], source=SOURCE_MODEL, path=p)
return LabelSet(names=placeholder_labels(0), source=SOURCE_PLACEHOLDER, path=None)
# ---------------------------------------------------------------------------
# 转换规划
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ConversionChain:
"""从某个输入格式到目标格式,需要依次产出哪些文件。"""
source_ext: str
target: str
produce: Tuple[str, ...]
@property
def is_noop(self) -> bool:
return not self.produce
def describe(self) -> str:
if self.is_noop:
return f"{self.source_ext} → 无需转换"
return f"{self.source_ext} → " + " → ".join(self.produce)
def conversion_chain(source_ext: str, target: str = "all") -> ConversionChain:
"""算出转换链。
TensorRT 的 engine 只能由 ONNX 构建,所以任何通往 engine 的路都必须
先得到 ONNX。这点在这里集中表达,避免调用方各写一遍。
- ``target="all"``:能产出多少产出多少(.pt → ONNX + engine)
- ``target="onnx"``:只要 ONNX(engine 无法反向拆回 ONNX,返回空)
- ``target="engine"``:只要 engine(.pt 会顺带产出 ONNX 作为中间产物)
"""
src = source_ext.lower().lstrip(".")
tgt = (target or "all").lower().strip()
if tgt not in ("all", "onnx", "engine"):
raise ValueError(f"未知转换目标: {target}(可选 all/onnx/engine)")
if src not in CONVERTIBLE_INPUT_EXTS:
# 已经是最终产物,或干脆不认识 —— 都交给调用方去报错/跳过。
return ConversionChain(src, tgt, ())
if src in ("pt", "pth"):
if tgt == "onnx":
return ConversionChain(src, tgt, ("onnx",))
# engine 需要 ONNX 做中间产物,两条路都带上它。
return ConversionChain(src, tgt, ("onnx", "engine"))
# src == "onnx"
if tgt == "onnx":
return ConversionChain(src, tgt, ())
return ConversionChain(src, tgt, ("engine",))
def canonical_path(root: Union[str, Path], group: str, name: str, ext: str) -> Path:
"""转换产物该落在哪:``models/<group>/<name>.<ext>``。
这是"自动改名并归位"的落点。无论输入文件叫什么、放在哪
(哪怕在 /tmp 或 staging 里叫 ``best_v3_final(1).pt``),
产出都用调用方给的 ``name`` 和 ``group``。
"""
return Path(root) / group / f"{name}.{ext.lstrip('.')}"
def validate_name(name: str) -> Optional[str]:
"""检查模型名是否安全可用作文件名。返回错误信息或 None。"""
n = (name or "").strip()
if not n:
return "模型名不能为空"
if n != name:
return f"模型名首尾不能有空格: {name!r}"
if any(c.isspace() for c in n):
# 名字里有空格会让命令行、site.yaml、ROS 参数到处都要加引号,
# 早晚有人忘。直接禁掉,改用下划线或短横线。
return "模型名不能含有空格(建议用 _ 或 - 代替)"
bad = set('/\\:*?"<>|')
hit = sorted(bad & set(n))
if hit:
return f"模型名不能含有这些字符: {''.join(hit)}"
if n.startswith("."):
return "模型名不能以点开头"
if n.endswith(".labels"):
return "不要带 .labels 后缀,标签文件会自动生成"
return None
def plan_conversion(
root: Union[str, Path],
source: Union[str, Path],
*,
name: str,
group: str = "yolo",
target: str = "all",
) -> Tuple[ConversionChain, List[Path]]:
"""算出一条完整的转换计划:产出链 + 每个产物的最终落点。"""
src = Path(source).expanduser()
chain = conversion_chain(src.suffix, target)
outs = [canonical_path(root, group, name, ext) for ext in chain.produce]
return chain, outs
@@ -0,0 +1,618 @@
#!/usr/bin/env python3
"""Ultralytics YOLO / TensorRT 封装。
对外接口:``resolve_weights``、``YoloDetector``、``Detection``、``pick_best``、
``MpYoloDetector``。当 ``backend='tensorrt'`` 时,TensorRT 会加载与
``.onnx`` / ``.pt`` 路径同级的 ``.engine`` 文件。
类别名(标签)由 ``model_labels`` 统一提供:优先读权重旁边的
``<模型名>.labels.yaml``(用户可随时手改),其次读模型内嵌 metadata。
这样做是因为 TensorRT engine 在当前的 TensorRT Python API 下**无法携带标签**,
只能靠旁挂文件;详见 ``model_labels`` 模块头。
"""
from __future__ import annotations
import logging
import multiprocessing as mp
import time
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Sequence, Union
import numpy as np
from .model_labels import (
SOURCE_PLACEHOLDER,
LabelSet,
describe,
parse_names_field,
placeholder_labels,
read_labels,
)
logger = logging.getLogger(__name__)
# 哨兵值:worker 还停在上一次的那帧 —— 调用方不能把它算作漏检。
INFER_BUSY = object()
@dataclass
class Detection:
xyxy: np.ndarray # (4,) float:x1,y1,x2,y2
conf: float
cls_id: int
cls_name: str
# pose 模型才有:(K, 3) = 每个关键点的 x, y, conf(**原图像素坐标**)。
# 检测模型恒为 None —— 下游必须先判 None,别假设它有。
kpts: Optional[np.ndarray] = None
@property
def cx(self) -> float:
return float(0.5 * (self.xyxy[0] + self.xyxy[2]))
@property
def cy(self) -> float:
return float(0.5 * (self.xyxy[1] + self.xyxy[3]))
@property
def area(self) -> float:
return float(max(0.0, self.xyxy[2] - self.xyxy[0]) * max(0.0, self.xyxy[3] - self.xyxy[1]))
def _resolve_device(device: str) -> str:
try:
import torch
except Exception: # noqa: BLE001
return "cpu"
d = (device or "").strip().lower()
if d in ("", "auto", "cuda", "cuda:0", "0", "gpu"):
return "0" if torch.cuda.is_available() else "cpu"
return device.strip() or "cpu"
def resolve_weights(weights: Union[str, Path], backend: str = "auto") -> Path:
"""当 backend=auto/tensorrt 时,优先使用与 .pt 同级的 TensorRT .engine。"""
p = Path(weights).expanduser()
b = (backend or "auto").strip().lower()
engine = p.with_suffix(".engine")
onnx = p.with_suffix(".onnx")
if b in ("tensorrt", "engine"):
if engine.is_file():
return engine
raise FileNotFoundError(
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 文件:{onnx}")
if b in ("", "auto"):
if engine.is_file():
return engine
if p.is_file():
return p
if onnx.is_file():
return onnx
return p
def resolve_class_labels(path: Union[str, Path], model=None) -> LabelSet:
"""给出一个权重该用的类别标签(旁挂文件优先)。
``model`` 是已经加载好的 Ultralytics 模型对象(可为 None)。顺序:
1. ``<模型名>.labels.yaml`` —— 用户可编辑的权威来源
2. 传入的 ``model.names`` —— 模型内嵌 metadata
3. 占位符 ``class_0 / class_1 …``
第 3 步刻意不用 ``str(cid)``。原先读不到名字时会退化成类名 `"0"`,
和"真的有个叫 0 的类"无法区分,标签丢了都看不出来。
占位符 + 一条 warning 让这件事在日志里可见。
"""
p = Path(path).expanduser()
# 先只看旁挂文件(快,不加载模型):它必须能覆盖模型内嵌的旧值,
# 否则"我改了标签为什么不生效"会变成很难查的问题。
labels = read_labels(p, allow_model_metadata=False)
if not labels.is_placeholder:
return labels
raw = getattr(model, "names", None) if model is not None else None
names = parse_names_field(raw or {})
if names:
from .model_labels import SOURCE_MODEL
return LabelSet(names=names, source=SOURCE_MODEL, path=p)
nc = 0
try:
nc = int(getattr(model, "nc", 0) or 0)
except (TypeError, ValueError):
nc = 0
labels = LabelSet(names=placeholder_labels(nc), source=SOURCE_PLACEHOLDER, path=None)
logger.warning(
"模型 %s 没有类别标签,暂时用占位名显示。补一个 %s 即可(改完重跑节点生效)。"
"命令行: bash ~/ros2/scripts/weights.sh labels %s --from-model",
p.name,
p.stem + ".labels.yaml",
p.stem,
)
return labels
class YoloDetector:
def __init__(
self,
weights: Union[str, Path],
*,
conf: float = 0.5,
iou: float = 0.45,
device: str = "",
imgsz: int = 640,
class_ids: Optional[Sequence[int]] = None,
class_names: Optional[Sequence[str]] = None,
) -> None:
try:
from ultralytics import YOLO
except Exception as exc: # noqa: BLE001
raise RuntimeError(
"未安装 ultralytics。在 ros_flexiv 环境里执行:pip install ultralytics"
) from exc
path = Path(weights).expanduser()
if not path.is_file():
raise FileNotFoundError(
f"找不到 YOLO 权重:{path}。"
f"请把你的 .pt 放到 ~/ros2/models/yolo/workpiece.pt"
)
try:
import torch
# 别让 PyTorch 占满所有 CPU 核心(相机 + ROS 都要用)。
torch.set_num_threads(1)
torch.set_num_interop_threads(1)
if torch.cuda.is_available():
torch.backends.cudnn.benchmark = True
except Exception:
pass
self.device = _resolve_device(device)
self.conf = float(conf)
self.iou = float(iou)
self.imgsz = int(imgsz)
self._half_ok = False
self._is_engine = path.suffix.lower() in {".engine", ".trt"}
self._model = YOLO(str(path), task="detect")
if not self._is_engine:
try:
self._model.fuse()
except Exception:
pass
self.class_ids = set(int(v) for v in class_ids) if class_ids else None
self.class_names = (
{str(n).strip().lower() for n in class_names if str(n).strip()}
if class_names
else None
)
self._labels = resolve_class_labels(path, self._model)
self._id_to_name = dict(self._labels.names)
self._warmed = False
self._pending = None
def warmup(self, *, half: bool = False) -> None:
if self._warmed:
return
if half and (not self._is_engine) and self.device != "cpu":
try:
self._model.to("cuda")
self._model.model.half()
self._half_ok = True
except Exception:
self._half_ok = False
dummy = np.zeros((self.imgsz, self.imgsz, 3), dtype=np.uint8)
self._predict(dummy)
self._warmed = True
def _predict(self, image_bgr: np.ndarray):
kwargs = dict(
source=image_bgr,
conf=self.conf,
iou=self.iou,
imgsz=self.imgsz,
device=self.device,
verbose=False,
max_det=20,
)
if self._half_ok and not self._is_engine:
kwargs["quantize"] = 16
try:
import torch
ctx = torch.inference_mode()
except Exception:
from contextlib import nullcontext
ctx = nullcontext()
with ctx:
return self._model.predict(**kwargs)
def detect(
self,
image_bgr: np.ndarray,
conf: float | None = None,
iou: float | None = None,
imgsz: int | None = None,
**_kwargs,
) -> List[Detection]:
"""给孔检测器用的别名接口(xyxy + conf)。"""
if conf is not None:
self.conf = float(conf)
if iou is not None:
self.iou = float(iou)
if imgsz is not None:
self.imgsz = int(imgsz)
return self.infer(image_bgr)
def infer(self, image_bgr: np.ndarray, *, half: bool = False, seq: int = 0) -> List[Detection]:
del seq
if not self._warmed:
self.warmup(half=half)
results = self._predict(image_bgr)
if not results:
return []
r0 = results[0]
boxes = getattr(r0, "boxes", None)
if boxes is None or len(boxes) == 0:
return []
out: List[Detection] = []
xyxy = boxes.xyxy.detach().cpu().numpy()
confs = boxes.conf.detach().cpu().numpy()
clss = boxes.cls.detach().cpu().numpy().astype(int)
for i in range(len(xyxy)):
cid = int(clss[i])
# 用 LabelSet.get:读不到名字时给 class_N 占位,而不是退化成裸数字 "0"。
name = self._labels.get(cid)
if self.class_ids is not None and cid not in self.class_ids:
continue
if self.class_names is not None and name.lower() not in self.class_names:
continue
out.append(
Detection(
xyxy=xyxy[i].astype(float),
conf=float(confs[i]),
cls_id=cid,
cls_name=name,
)
)
out.sort(key=lambda d: d.conf, reverse=True)
return out
def submit(self, image_bgr: np.ndarray, seq: int = 0) -> None:
self._pending = (int(seq), image_bgr)
def wait_result(self, timeout: float = 0.045):
del timeout
if self._pending is None:
return None
seq, img = self._pending
self._pending = None
t0 = time.monotonic()
dets = self.infer(img, seq=seq)
return seq, dets, (time.monotonic() - t0) * 1000.0
def build_detector(
weights: Union[str, Path],
*,
backend: str = "auto",
conf: float = 0.25,
iou: float = 0.45,
device: str = "",
imgsz: int = 640,
):
"""按权重类型挑检测器:pose 模型走 ``PoseDetector``,否则走 ``YoloDetector``。
为什么要这个工厂:ultralytics 解不了 pose 的 TensorRT engine(见
``pose_detector`` 模块头),所以 pose 必须换成自解码实现。判断依据是模型
是不是 ``task=pose`` —— 直接读旁边 ``.onnx`` 的 metadata,不靠调用方声明。
返回对象统一有 ``detect()/infer()/warmup()/imgsz/class_names/class_ids``。
"""
path = Path(weights).expanduser()
from .pose_detector import PoseDetector, is_pose_model
if is_pose_model(path):
logger.info("检测到 pose 模型 %s → 用 PoseDetector(自解码关键点)", path.name)
return PoseDetector(
path,
conf=conf,
iou=iou,
device=device or "0",
imgsz=imgsz,
)
return YoloDetector(
path,
conf=conf,
iou=iou,
device=device,
imgsz=imgsz,
)
def _yolo_worker_main(q_in, q_out, cfg: dict) -> None:
"""spawn 出来的子进程:只跑 GPU 上的检测。这里绝不能 import rclpy。"""
try:
path = resolve_weights(cfg["weights"], cfg.get("backend") or "auto")
det = build_detector(
path,
backend=cfg.get("backend") or "auto",
conf=float(cfg.get("conf") or 0.25),
iou=float(cfg.get("iou") or 0.45),
device=str(cfg.get("device") or "0"),
imgsz=int(cfg.get("imgsz") or 416),
)
det.warmup(half=bool(cfg.get("half", True)))
q_out.put(("ready", str(path)))
except Exception as exc: # noqa: BLE001
q_out.put(("error", str(exc)))
return
while True:
item = q_in.get()
if item is None:
return
# 一直排空到队列里最新的帧 —— 绝不把 GPU 浪费在过期图像上。
try:
while True:
newer = q_in.get_nowait()
if newer is None:
return
item = newer
except Exception:
pass
seq, image = item
try:
t0 = time.monotonic()
dets = det.infer(image, half=bool(cfg.get("half", True)))
gpu_ms = (time.monotonic() - t0) * 1000.0
packed = [
(
d.xyxy.astype(np.float32),
float(d.conf),
int(d.cls_id),
str(d.cls_name),
None if d.kpts is None else np.asarray(d.kpts, dtype=np.float32),
)
for d in dets
]
_put_drop_old(q_out, ("ok", seq, packed, gpu_ms))
except Exception as exc: # noqa: BLE001
_put_drop_old(q_out, ("error", str(exc)))
def _put_drop_old(q, item) -> None:
"""绝不让 GPU 进程因为结果队列满了而阻塞。"""
try:
q.put_nowait(item)
return
except Exception:
pass
try:
q.get_nowait()
except Exception:
pass
try:
q.put_nowait(item)
except Exception:
pass
class YoloInferProcess:
"""把 YOLO 放进 spawn() 子进程,让 ROS 回调永远不用和 PyTorch 抢 GIL。"""
def __init__(self, cfg: dict) -> None:
self._cfg = dict(cfg)
self._p = None
self._start()
def _start(self) -> None:
ctx = mp.get_context("spawn")
# 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(
target=_yolo_worker_main,
args=(self._q_in, self._q_out, self._cfg),
name="yolo_infer",
daemon=True,
)
self._p.start()
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 工作进程:{err}")
self.weights_path = str(msg[1])
self.imgsz = int(self._cfg.get("imgsz") or 416)
def restart(self) -> None:
"""杀掉卡死 / 挂掉的 worker,再起一个新的(重新加载 engine 约 2-5 s)。"""
self.close()
self._start()
@property
def alive(self) -> bool:
return bool(self._p is not None and self._p.is_alive())
def submit(self, image_bgr: np.ndarray, seq: int = 0) -> bool:
"""把最新一帧排进队列。如果 worker 正忙就跳过 —— 它在每次推理后都会
排空到队列里最新的帧,所以不会跑任何过期数据。
返回是否真的进队。进不去必须告诉调用方,否则一次性会以为「已经在跑」
而空等,把 D405 也卡住。
"""
if not self.alive:
raise RuntimeError("YOLO 工作进程已死亡")
try:
self._q_in.put((int(seq), image_bgr), timeout=0.010)
return True
except Exception:
return False
def wait_result(self, timeout: float = 0.045):
"""返回 ``(seq, list[Detection], gpu_ms)`` 或 ``None``。"""
if not self.alive:
raise RuntimeError("YOLO 工作进程已死亡")
deadline = time.monotonic() + max(0.004, float(timeout))
latest = None
while time.monotonic() < deadline:
try:
msg = self._q_out.get(
timeout=max(0.001, deadline - time.monotonic())
)
except Exception:
break
latest = self._parse_out(msg)
while True:
try:
extra = self._q_out.get_nowait()
except Exception:
break
parsed = self._parse_out(extra)
if parsed is not None:
latest = parsed
if latest is not None:
break
return latest
def _parse_out(self, msg):
if not msg:
return None
if msg[0] == "error":
raise RuntimeError(str(msg[1]))
if msg[0] != "ok":
return None
seq, packed = int(msg[1]), msg[2]
gpu_ms = float(msg[3]) if len(msg) > 3 else 0.0
dets = []
for row in packed:
# 兼容旧格式(4 元组,无关键点),免得子进程和父进程版本不一致时炸掉。
xyxy, conf, cid, name = row[0], row[1], row[2], row[3]
kpts = row[4] if len(row) > 4 else None
dets.append(
Detection(
xyxy=np.asarray(xyxy, dtype=float),
conf=float(conf),
cls_id=int(cid),
cls_name=str(name),
kpts=None if kpts is None else np.asarray(kpts, dtype=float),
)
)
return seq, dets, gpu_ms
def infer(self, image_bgr: np.ndarray, *, half: bool = False, seq: int = 0):
"""兼容接口:提交 + 等待。要做流水线的话优先用 submit/wait_result。"""
del half
self.submit(image_bgr, seq)
got = self.wait_result(timeout=0.08)
if got is None:
return INFER_BUSY
return got[1]
def close(self) -> None:
if self._p is None:
return
try:
self._q_in.put_nowait(None)
except Exception:
pass
self._p.join(timeout=2.0)
if self._p.is_alive():
self._p.terminate()
self._p.join(timeout=1.0)
if self._p.is_alive():
self._p.kill() # 卡在 CUDA 调用里出不来 —— 直接 SIGKILL
self._p.join(timeout=1.0)
self._p = None
def _iou_xyxy(a: np.ndarray, b: np.ndarray) -> float:
ax1, ay1, ax2, ay2 = [float(v) for v in a]
bx1, by1, bx2, by2 = [float(v) for v in b]
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
ua = (
max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
+ max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
- inter
)
return float(inter / ua) if ua > 1e-6 else 0.0
def pick_best(
dets: Sequence[Detection],
*,
prefer_center: Optional[tuple] = None,
prev_uv: Optional[np.ndarray] = None,
prev_xyxy: Optional[np.ndarray] = None,
max_track_px: float = 320.0,
reacquire_conf: float = 0.18,
lock_iou: float = 0.08,
min_conf: float = 0.12,
min_area: float = 4000.0,
) -> Optional[Detection]:
"""让当前工件在快速运动下也能被持续跟踪。
用一个较宽的匹配门限去和**预测出的**上一帧检测框做匹配。如果 IoU 不达标、
但 YOLO 仍以高于 min_conf 的置信度报出了目标,就保留该检测 —— 不要返回
None(那正是运动过程中目标丢失的主要来源)。
过小的框(logo / 通止规尖)会被拒掉,避免机械臂锁到垫子上。
"""
if not dets:
return None
# 已锁定的跟踪在运动(模糊)时可能掉到 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:
return None
locked: List[Detection] = []
if prev_xyxy is not None:
pw = max(8.0, float(prev_xyxy[2] - prev_xyxy[0]))
ph = max(8.0, float(prev_xyxy[3] - prev_xyxy[1]))
gate = max(0.90 * float(max_track_px), 1.25 * float(np.hypot(pw, ph)))
pcx = 0.5 * (float(prev_xyxy[0]) + float(prev_xyxy[2]))
pcy = 0.5 * (float(prev_xyxy[1]) + float(prev_xyxy[3]))
for d in usable:
iou = _iou_xyxy(d.xyxy, prev_xyxy)
dpx = float(np.hypot(d.cx - pcx, d.cy - pcy))
if iou >= float(lock_iou) or dpx <= gate:
locked.append(d)
if locked:
return max(
locked,
key=lambda d: (float(d.conf) * (float(d.area) ** 0.25), float(d.conf)),
)
def score_new(d: Detection) -> float:
s = float(d.conf) * 10.0 + 0.015 * np.sqrt(max(float(d.area), 1.0))
if prefer_center is not None:
cx0, cy0 = float(prefer_center[0]), float(prefer_center[1])
s -= 1e-6 * ((d.cx - cx0) ** 2 + (d.cy - cy0) ** 2)
if prev_uv is not None:
dpx = float(np.hypot(d.cx - float(prev_uv[0]), d.cy - float(prev_uv[1])))
if dpx <= max_track_px:
s += 2.0 * (1.0 - dpx / max(max_track_px, 1.0))
return s
best = max(usable, key=score_new)
# 快速移动:即使 YOLO 报出的框离上一帧很远,也保留这个实时检测。
need = max(float(min_conf), float(reacquire_conf))
if float(best.conf) >= need:
return best
return None