Compare commits
6 Commits
ab73399426
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c4dccccb1 | |||
| 7942014763 | |||
| 76c9b9cc49 | |||
| 80983954a4 | |||
| 926a7f9796 | |||
| 3c86441fb0 |
+3
-3
@@ -6,9 +6,9 @@ models/staging/**
|
||||
!models/staging/.gitkeep
|
||||
!models/staging/README.md
|
||||
|
||||
# 编译产物
|
||||
build/
|
||||
install/
|
||||
# 编译产物。install/、install_sim/、build/、build_sim/ 都进仓库,
|
||||
# 克隆后两边都能 source,不必先 colcon build。
|
||||
# build/ 里指向源码的链接已改成相对路径。
|
||||
log/
|
||||
# 仿真的构建产物 —— 与真机的 build/ install/ 隔离(见 scripts/build_sim.sh)
|
||||
# 两套产物目录并存是刻意的:工作区只有一份源码,两个环境的依赖路径不同,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
colcon
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../src/arm_application/arm_application
|
||||
@@ -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 @@
|
||||
setuptools
|
||||
@@ -0,0 +1 @@
|
||||
arm_application
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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,78 @@
|
||||
#!/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
|
||||
|
||||
|
||||
class DemoNamedPoses(Node):
|
||||
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,271 @@
|
||||
"""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)
|
||||
try:
|
||||
cmd = _clamp_int(self.read_register(REG_POSITION), 0, 1000)
|
||||
except Exception:
|
||||
cmd = value
|
||||
if cmd == value:
|
||||
try:
|
||||
cur = self.get_position_per_mille()
|
||||
except Exception:
|
||||
cur = value
|
||||
nudge = cur + (40 if value >= cur else -40)
|
||||
nudge = _clamp_int(nudge, 0, 1000)
|
||||
if nudge == value:
|
||||
nudge = 0 if value != 0 else 40
|
||||
self.write_register(REG_POSITION, nudge)
|
||||
time.sleep(0.06)
|
||||
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,584 @@
|
||||
#!/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 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) -> bool:
|
||||
"""写 0xA5,然后等到初始化状态 == 完成(对应官方上电流程)。"""
|
||||
state = self.get_init_state()
|
||||
if state == S_INIT_FINISHED:
|
||||
return True
|
||||
if not self.initialization():
|
||||
raise DhPgeaError("初始化写 0x0100=0xA5 失败(无 Modbus 回显)")
|
||||
deadline = time.monotonic() + float(timeout_s)
|
||||
while time.monotonic() < deadline:
|
||||
state = self.get_init_state()
|
||||
if state == S_INIT_FINISHED:
|
||||
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 set_target_position(self, pos: int) -> bool:
|
||||
pos = int(max(0, min(1000, pos)))
|
||||
# 大寰固件:0x0103 写入与当前命令相同的值不会启动新运动。
|
||||
# 开/关卡在 caught 时最常见——命令已是 1000,手指却停在夹持位。
|
||||
try:
|
||||
cmd = self.get_target_position(retries=2)
|
||||
except Exception:
|
||||
cmd = pos
|
||||
if cmd == pos:
|
||||
try:
|
||||
cur = self.get_current_position(retries=2)
|
||||
except Exception:
|
||||
cur = pos
|
||||
nudge = cur + (40 if pos >= cur else -40)
|
||||
nudge = int(max(0, min(1000, nudge)))
|
||||
if nudge == pos:
|
||||
nudge = 0 if pos != 0 else 40
|
||||
self.write_register(REG_POSITION, nudge)
|
||||
time.sleep(0.06)
|
||||
return self.write_register(REG_POSITION, pos)
|
||||
|
||||
def wait_settle(self, target: int, timeout_s: float = 6.0) -> GripperState:
|
||||
"""等到到位/夹住/松开,或超时返回最后一帧。"""
|
||||
t0 = time.monotonic()
|
||||
last = None
|
||||
st = self.get_run_states()
|
||||
while time.monotonic() - t0 < timeout_s:
|
||||
st = self.get_run_states()
|
||||
moved = last is not None and st.position != last
|
||||
last = st.position
|
||||
if st.grip_state == S_GRIP_MOVING or moved:
|
||||
time.sleep(0.08)
|
||||
continue
|
||||
if st.grip_state in (S_GRIP_ARRIVED, S_GRIP_DROPPED) and abs(st.position - target) <= 25:
|
||||
return st
|
||||
if st.grip_state == S_GRIP_CAUGHT:
|
||||
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()
|
||||
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("张开写入失败")
|
||||
print(grip.wait_settle(1000).summary())
|
||||
return 0
|
||||
if args.cmd == "close":
|
||||
if not grip.close_jaw(args.force, args.speed):
|
||||
raise DhPgeaError("闭合写入失败")
|
||||
print(grip.wait_settle(0).summary())
|
||||
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("位置写入失败")
|
||||
print(grip.wait_settle(pos).summary())
|
||||
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,254 @@
|
||||
#!/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,
|
||||
)
|
||||
|
||||
|
||||
def _param_as_str(node: Node, name: str, default: str = "") -> str:
|
||||
"""字符串参数,同时允许命令行用数字覆盖(例如 SN 26610799)。"""
|
||||
node.declare_parameter(name, default, ParameterDescriptor(dynamic_typing=True))
|
||||
val = node.get_parameter(name).value
|
||||
if val is None or val == "":
|
||||
return ""
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
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(Node):
|
||||
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,318 @@
|
||||
"""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
|
||||
|
||||
|
||||
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(Node):
|
||||
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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,589 @@
|
||||
"""通止规装配的配置:从 ``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
|
||||
"screw_step_deg": 180.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,656 @@
|
||||
"""通止规装配的状态机 —— **纯逻辑**,不 import rclpy,可离线单测。
|
||||
|
||||
为什么单独一个文件
|
||||
------------------
|
||||
状态机的价值全在"顺序"和"边界"上:S07 的力判据有没有在 S08 之前生效、
|
||||
E1 掉件之后到底回到哪一步、k2 是"回到 S05 记录的位姿"还是"重新拍照"。
|
||||
这些错**不会在运行时暴露成异常**,只会表现成"流程走得下去但结果不对",
|
||||
和力轴向映射反了一样属于静默失效(规则 R5.1)。所以转移表写成**数据**,
|
||||
由测试逐条钉住,而不是散在节点的一堆 if/else 里。
|
||||
|
||||
设计口径
|
||||
--------
|
||||
- 状态编号与看板《五、通止规定位装配:状态机设计》一致(S00–S11),
|
||||
E1/E2 两条异常分支也在这里,**改名要同步改看板与文档**(规则 R6.1)。
|
||||
- 转移表只回答"给这个事件、从当前状态去哪";**做不做得了**由节点判断
|
||||
(比如 S03 需要 D435 手眼,没标定就报 COARSE_FAIL 走 S04 兜底)。
|
||||
状态机不掺硬件条件,否则没法离线测。
|
||||
- 上下文里的两个字段(`hole_index` / `holes_total`)只影响 S09 的出口:
|
||||
最后一个孔拧出后去 S11 复位,否则去 S10 换到下一个孔。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 状态与事件
|
||||
# =============================================================================
|
||||
class State(str, Enum):
|
||||
"""S00–S11 全部状态 + 两个终态。"""
|
||||
|
||||
BOOT = "S00" # 各服务自检(伺服/相机/夹爪/触觉图传/推理预热)
|
||||
HOME_EXTREME = "S01" # 回 home + 按 tool 系逆时针转到极限角
|
||||
GRIP_CHECK = "S02" # 夹持确认(预留,当前跳过)
|
||||
COARSE = "S03" # 手外 D435 粗定位
|
||||
TRACK = "S04" # 腕部 D405 跟踪 k0
|
||||
SNAP = "S05" # 像素稳定后拍照定位 k1/k2 + 记位姿
|
||||
HOVER_K = "S06" # 到当前孔正悬停
|
||||
CONTACT = "S07" # 柔顺接触(切向力突变停)
|
||||
SCREW_IN = "S08" # 旋转装配循环
|
||||
UNSCREW = "S09" # 反向拧出
|
||||
NEXT_HOLE = "S10" # 换到下一个孔(回到 S05 记录的位姿)
|
||||
RESET = "S11" # 复位退出
|
||||
DONE = "DONE" # 正常结束
|
||||
ABORT = "ABORT" # 中止(已按安全顺序退出到安全高度)
|
||||
|
||||
def __str__(self) -> str: # 日志里直接印 S07 而不是 State.CONTACT
|
||||
return self.value
|
||||
|
||||
|
||||
class Event(str, Enum):
|
||||
"""驱动状态机的事件。命名规则:``动作_结果``。"""
|
||||
|
||||
BOOT_OK = "boot_ok"
|
||||
BOOT_FAIL = "boot_fail"
|
||||
|
||||
POSE_OK = "pose_ok"
|
||||
JOINT_LIMIT = "joint_limit" # S01 转不动 = 已经贴着关节极限
|
||||
|
||||
GRIP_PASS = "grip_pass"
|
||||
GRIP_FAIL = "grip_fail"
|
||||
GRIP_SKIPPED = "grip_skipped" # S02 预留:没装/没标定,明确跳过
|
||||
|
||||
COARSE_OK = "coarse_ok"
|
||||
COARSE_FAIL = "coarse_fail"
|
||||
|
||||
TRACK_LOCKED = "track_locked"
|
||||
TRACK_LOST = "track_lost" # 丢失超时 → 回 S03 兜底
|
||||
|
||||
SNAP_OK = "snap_ok"
|
||||
SNAP_UNSTABLE = "snap_unstable" # 画面还在动,继续等
|
||||
|
||||
ALIGN_OK = "align_ok"
|
||||
ALIGN_OUT = "align_out" # 对位超差 → 回 S05 重拍
|
||||
|
||||
CONTACT_OK = "contact_ok"
|
||||
FORCE_OVER_LIMIT = "force_over_limit" # 力超安全上限 → 抬起
|
||||
|
||||
SCREW_DONE = "screw_done"
|
||||
SCREW_STOP = "screw_stop" # 力判据满足:切向力>阈值 且 法向力突增
|
||||
GAUGE_DROPPED = "gauge_dropped" # E1:夹持丢了
|
||||
|
||||
UNSCREW_DONE = "unscrew_done"
|
||||
|
||||
RELOADED = "reloaded" # E1:人工放好按 R 之后
|
||||
TIMEOUT = "timeout" # E2:任意阶段超时
|
||||
ABORT = "abort" # 用户 ^C / 外部中止
|
||||
|
||||
|
||||
#: 终态:到这里状态机不再接受事件。
|
||||
TERMINAL = frozenset({State.DONE, State.ABORT})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 上下文:只放"影响去哪"的可变数据,不放硬件句柄
|
||||
# =============================================================================
|
||||
@dataclass
|
||||
class GaugeContext:
|
||||
"""状态机运行上下文。
|
||||
|
||||
:param holes_total: 一次任务要装的孔数(k1/k2 两个就是 2)。
|
||||
:param hole_index: 当前孔序号,0 基。S10 会 +1。
|
||||
:param relocations: E1 掉件后人工重放计数,用于日志与上限保护。
|
||||
:param max_relocations: 允许的掉件重试次数,超了直接 ABORT,
|
||||
避免"掉件 → 重放 → 又掉"无限循环。
|
||||
"""
|
||||
|
||||
holes_total: int = 2
|
||||
hole_index: int = 0
|
||||
relocations: int = 0
|
||||
max_relocations: int = 3
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_last_hole(self) -> bool:
|
||||
return self.hole_index >= self.holes_total - 1
|
||||
|
||||
def hole_name(self) -> str:
|
||||
"""给日志用的人读孔名:k1 / k2。"""
|
||||
return f"k{self.hole_index + 1}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 转移表
|
||||
# =============================================================================
|
||||
#: ``(状态, 事件) -> 下一状态``。**没有列出的组合就是非法转移**,
|
||||
#: 会抛 :class:`FsmError` —— 宁可当场炸,也不要"悄悄停在原地"。
|
||||
TRANSITIONS: Dict[Tuple[State, Event], State] = {
|
||||
# --- S00 自检 ---
|
||||
(State.BOOT, Event.BOOT_OK): State.HOME_EXTREME,
|
||||
(State.BOOT, Event.BOOT_FAIL): State.ABORT,
|
||||
# --- S01 home + 极限角 ---
|
||||
(State.HOME_EXTREME, Event.POSE_OK): State.GRIP_CHECK,
|
||||
# 转不动不是失败:已经贴着极限,就按当前角继续(提示即可)
|
||||
(State.HOME_EXTREME, Event.JOINT_LIMIT): State.GRIP_CHECK,
|
||||
# E1 掉件后半段:S01 流程走完 → 提示人工放置 → 按 R → 回记录位姿继续
|
||||
(State.HOME_EXTREME, Event.RELOADED): State.NEXT_HOLE,
|
||||
# --- S02 夹持确认(预留)---
|
||||
(State.GRIP_CHECK, Event.GRIP_SKIPPED): State.TRACK,
|
||||
(State.GRIP_CHECK, Event.GRIP_PASS): State.TRACK,
|
||||
(State.GRIP_CHECK, Event.GRIP_FAIL): State.ABORT,
|
||||
# --- S03 手外粗定位(S04 的兜底,不是必经)---
|
||||
(State.COARSE, Event.COARSE_OK): State.SNAP,
|
||||
(State.COARSE, Event.COARSE_FAIL): State.TRACK,
|
||||
# --- S04 跟踪 ---
|
||||
(State.TRACK, Event.TRACK_LOCKED): State.SNAP,
|
||||
(State.TRACK, Event.TRACK_LOST): State.COARSE,
|
||||
# --- S05 稳定拍照 ---
|
||||
(State.SNAP, Event.SNAP_OK): State.HOVER_K,
|
||||
(State.SNAP, Event.SNAP_UNSTABLE): State.SNAP, # 自环:继续等稳定
|
||||
# --- S06 正悬停 ---
|
||||
(State.HOVER_K, Event.ALIGN_OK): State.CONTACT,
|
||||
(State.HOVER_K, Event.ALIGN_OUT): State.SNAP, # 对位超差 → 回 S05 重拍
|
||||
# --- S07 柔顺接触 ---
|
||||
(State.CONTACT, Event.CONTACT_OK): State.SCREW_IN,
|
||||
(State.CONTACT, Event.FORCE_OVER_LIMIT): State.ABORT,
|
||||
# --- S08 旋转装配循环 ---
|
||||
(State.SCREW_IN, Event.SCREW_DONE): State.UNSCREW,
|
||||
(State.SCREW_IN, Event.SCREW_STOP): State.UNSCREW,
|
||||
(State.SCREW_IN, Event.GAUGE_DROPPED): State.HOME_EXTREME, # E1 前半段
|
||||
# --- S09 反向拧出的出口与"是不是最后一个孔"有关,见 resolve_next() ---
|
||||
# --- S10 换到下一个孔(= 回到 S05 记录的位姿),到了就重复 S06–S09 ---
|
||||
(State.NEXT_HOLE, Event.POSE_OK): State.HOVER_K,
|
||||
# --- S11 复位退出 ---
|
||||
(State.RESET, Event.POSE_OK): State.DONE,
|
||||
}
|
||||
|
||||
#: 兜底:任意非终态收到 ABORT / TIMEOUT 都去对应终态。
|
||||
#: 单列出来是因为"每个状态各自列一遍"最容易漏,而漏的那个状态恰恰会在
|
||||
#: 真机上表现为"按了急停但流程还在跑"。
|
||||
GLOBAL: Dict[Event, State] = {
|
||||
Event.ABORT: State.ABORT,
|
||||
Event.TIMEOUT: State.ABORT,
|
||||
}
|
||||
|
||||
|
||||
def resolve_next(state: State, event: Event, ctx: GaugeContext) -> State:
|
||||
"""算出下一状态。
|
||||
|
||||
绝大多数组合直接查 :data:`TRANSITIONS`;只有一处要结合上下文:
|
||||
|
||||
* ``S09 + UNSCREW_DONE``:最后一个孔 → S11 复位;否则 → S10 换到下一个孔。
|
||||
|
||||
其余"看起来需要上下文"的分支都用了**显式事件**,而不是让 resolve_next
|
||||
去猜(比如 E1 重放用 ``RELOADED`` 而不是"看 relocations 是否大于 0")。
|
||||
猜出来的分支在测试里只能靠改内部计数复现,读代码的人也得跟着一起猜。
|
||||
"""
|
||||
# 全局兜底优先:谁都得能被中止
|
||||
if event in GLOBAL:
|
||||
return GLOBAL[event]
|
||||
|
||||
if state is State.UNSCREW and event is Event.UNSCREW_DONE:
|
||||
return State.RESET if ctx.is_last_hole else State.NEXT_HOLE
|
||||
|
||||
try:
|
||||
return TRANSITIONS[(state, event)]
|
||||
except KeyError:
|
||||
raise FsmError(state, event, ctx) from None
|
||||
|
||||
|
||||
class FsmError(RuntimeError):
|
||||
"""非法转移。附带当前状态与事件,方便直接定位是哪一步没接上。"""
|
||||
|
||||
def __init__(self, state: State, event: Event, ctx: Optional[GaugeContext] = None) -> None:
|
||||
self.state = state
|
||||
self.event = event
|
||||
ctx = ctx or GaugeContext()
|
||||
super().__init__(
|
||||
f"通止规装配状态机:状态 {state}({STATE_DOC.get(state, '?')})"
|
||||
f"不接受事件 {event.value}。当前孔 {ctx.hole_name()},"
|
||||
f"已重放 {ctx.relocations} 次。"
|
||||
"若是新增流程,先在 gauge_fsm.TRANSITIONS 里登记这条转移。"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 状态机本体
|
||||
# =============================================================================
|
||||
@dataclass
|
||||
class Step:
|
||||
"""一次转移的记录,用于日志与事后复盘。"""
|
||||
|
||||
index: int
|
||||
prev: State
|
||||
event: Event
|
||||
next: State
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"#{self.index} {self.prev} --{self.event.value}--> {self.next}"
|
||||
|
||||
|
||||
class GaugeFsm:
|
||||
"""通止规装配状态机。
|
||||
|
||||
用法::
|
||||
|
||||
fsm = GaugeFsm(GaugeContext(holes_total=2))
|
||||
fsm.handle(Event.BOOT_OK) # S00 -> S01
|
||||
print(fsm.state) # S01
|
||||
fsm.handle(Event.ABORT) # 任何状态都能中止
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: Optional[GaugeContext] = None, start: State = State.BOOT) -> None:
|
||||
self.ctx = ctx or GaugeContext()
|
||||
self.state = start
|
||||
self.history: List[Step] = []
|
||||
|
||||
# -- 查询 ---------------------------------------------------------------
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
return self.state in TERMINAL
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.state is State.DONE
|
||||
|
||||
def can(self, event: Event) -> bool:
|
||||
"""这个事件在当前状态下合法吗(不改状态,给"要不要试"用)。"""
|
||||
if event in GLOBAL:
|
||||
return not self.is_terminal
|
||||
try:
|
||||
resolve_next(self.state, event, self.ctx)
|
||||
except FsmError:
|
||||
return False
|
||||
return True
|
||||
|
||||
# -- 推进 ---------------------------------------------------------------
|
||||
def handle(self, event: Event) -> State:
|
||||
"""吃一个事件,返回新状态。非法转移抛 :class:`FsmError`。"""
|
||||
if self.is_terminal:
|
||||
raise FsmError(self.state, event, self.ctx)
|
||||
prev = self.state
|
||||
# 先算转移(非法转移在这里就抛,**不留副作用**)——
|
||||
# 顺序反过来的话,一次手误的 GAUGE_DROPPED 会白白吃掉一次重放预算,
|
||||
# 报错信息还说"状态不对",而现场已经少了一次重试机会。
|
||||
nxt = resolve_next(prev, event, self.ctx)
|
||||
|
||||
if event is Event.GAUGE_DROPPED:
|
||||
self.ctx.relocations += 1
|
||||
if self.ctx.relocations > self.ctx.max_relocations:
|
||||
# **超过上限不抛异常**:掉件 4 次不是"程序非法状态",是现场问题。
|
||||
# 报错文案要指出现场(夹持/螺纹),而不是指着转移表说这条边不存在。
|
||||
self.note(
|
||||
f"通止规已掉 {self.ctx.relocations} 次(上限 "
|
||||
f"{self.ctx.max_relocations}),不再自动重放,请检查夹持与螺纹"
|
||||
)
|
||||
nxt = State.ABORT
|
||||
|
||||
if nxt is State.NEXT_HOLE and prev is not State.NEXT_HOLE:
|
||||
# 换孔动作:序号在**进入** S10 时推进;S10 完成就是新孔的 S06。
|
||||
self.ctx.hole_index = min(self.ctx.hole_index + 1, max(0, self.ctx.holes_total - 1))
|
||||
|
||||
self.state = nxt
|
||||
self.history.append(Step(len(self.history) + 1, prev, event, nxt))
|
||||
return nxt
|
||||
|
||||
def note(self, text: str) -> None:
|
||||
"""挂一条人工说明(比如"跳过 S03:D435 未标定"),随结果一起输出。"""
|
||||
self.ctx.notes.append(text)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 判据(纯函数,被节点与测试共用)
|
||||
# =============================================================================
|
||||
def points_stable(samples: List[Sequence[float]], tol: float, min_frames: int) -> bool:
|
||||
"""稳定判据(任意维):样本够多、且所有点到均值距离都 ≤ ``tol``。
|
||||
|
||||
与 :func:`pixel_stable` 是同一套判据,只是不限定二维。为什么需要:
|
||||
S05 的"画面稳了再拍"在仿真里用不了检测框(合成图过不了真机训练出来的
|
||||
工件模型),于是改用**法兰位移**(mm)当特征 —— 判据本身一模一样,
|
||||
只是量纲从像素变成毫米,所以抽出来共用,而不是各写一份。
|
||||
"""
|
||||
if len(samples) < max(2, int(min_frames)):
|
||||
return False
|
||||
dim = len(samples[0])
|
||||
means = [sum(float(s[i]) for s in samples) / len(samples) for i in range(dim)]
|
||||
if any(len(s) != dim for s in samples):
|
||||
return False
|
||||
for s in samples:
|
||||
if math.sqrt(sum((float(s[i]) - means[i]) ** 2 for i in range(dim))) > tol:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def pixel_stable(samples: List[Tuple[float, float]], tol_px: float, min_frames: int) -> bool:
|
||||
"""S05:像素稳定判据。
|
||||
|
||||
:param samples: 最近若干帧的 ``(u, v)`` 像素坐标。
|
||||
:param tol_px: 允许的抖动半径(像素)。
|
||||
:param min_frames: 至少要看多少帧才敢说"稳定"。
|
||||
:returns: 样本够多、且所有点到它们的均值的距离都 ≤ ``tol_px``。
|
||||
|
||||
为什么不用"两帧之差":跟踪输出的抖动是**零均值**的,相邻两帧可能恰好
|
||||
很近,于是"稳定"判据会在画面明明在晃的时候早早触发,接着拍出一张糊的
|
||||
定位图 —— 表现为"孔位偶尔偏 1mm",随机且难复现。用"到均值的最远距离"
|
||||
才对得上"画面不动"这个物理事实。
|
||||
"""
|
||||
return points_stable(samples, tol_px, min_frames)
|
||||
|
||||
|
||||
def tangential_jump(
|
||||
shear_now: Tuple[float, float], shear_ref: Tuple[float, float], delta: float
|
||||
) -> bool:
|
||||
"""S07:切向力突变判据 —— **两个通道都看,任一超阈值即算**。
|
||||
|
||||
用户明确过:切向力到底走 X 还是 Y 通道未确认。只监控一个通道的话,
|
||||
赌错了就是整段流程静默失效(下发正常、日志正常、就是停不住)。
|
||||
两个都监控的代价只是可能更早停一点点,这个方向的错是安全的。
|
||||
|
||||
:param shear_now: 当前两路切向读数。
|
||||
:param shear_ref: 静止基线的两路切向读数。
|
||||
:param delta: 触发阈值(相对基线的增量)。
|
||||
"""
|
||||
return any(abs(n - r) >= delta for n, r in zip(shear_now, shear_ref))
|
||||
|
||||
|
||||
def wrench_touch(
|
||||
axial_now: float, axial_ref: float, delta: float
|
||||
) -> bool:
|
||||
"""S07(**首选判据**):腕部 FT 的轴向力相对基线突变 → 判为接触到工件。
|
||||
|
||||
为什么这是首选、而不是手指触觉(2026-09-24 用一整轮排查换来的结论):
|
||||
|
||||
通止规是**靠夹持摩擦抱住**的工具。轴向力要传到手指,必须过"指垫↔规"的
|
||||
摩擦面,而摩擦能传的上限是 µ·N:
|
||||
夹持段 µ=0.25、实测握力 N≈46N → 单指 ~11.5N、两指 ~23N
|
||||
可台肩落面那一下,接触刚度 5e4 N/m 意味着**过压 0.5mm 就是 25N** ——
|
||||
一步的步进量就足以让规在爪里打滑。**于是手指触觉物理上读不到那个力**
|
||||
(读数停在 0,而它是对的),阈值怎么调都不对:调小被噪声骗,调大永远
|
||||
达不到。当时确实是照"阈值调高了"去改的,白费一轮。
|
||||
|
||||
腕部 FT 在这条力路的**上游**:工件→规→夹爪→法兰→腕部关节。
|
||||
工具打不打滑都要经过它,所以它测得到真实的轴向力。
|
||||
|
||||
判据用**绝对增量**而不是带符号压入量,理由与 ``tangential_jump`` 相同:
|
||||
传感器坐标系的正负号方向取决于安装,赌错了就是"整段流程静默失效"
|
||||
(下发正常、日志正常、就是停不住)。取绝对值只会更早停一点点,
|
||||
这个方向的错是安全的 —— 与 ``stage2_locate_node._contact_detected``
|
||||
的既有口径也一致(那里也是 ``abs(fz - baseline)``)。
|
||||
|
||||
:param axial_now: 当前轴向力读数 [N]。
|
||||
:param axial_ref: 静止(还没碰工件)时的轴向基线 [N]。
|
||||
:param delta: 触发阈值(相对基线的增量)[N]。
|
||||
"""
|
||||
return abs(float(axial_now) - float(axial_ref)) >= float(delta)
|
||||
|
||||
|
||||
#: S07 腕部 FT 判据需要多"新"的数据才算活着 [s]。
|
||||
#: 真机 broadcaster 与仿真传感器都是 100Hz,0.5s 相当于容许掉 50 帧 ——
|
||||
#: 只用来排除"根本没在发",不是用来做实时性判据的。
|
||||
WRENCH_MAX_AGE_S: float = 0.5
|
||||
|
||||
|
||||
def wrench_live(age_s: Optional[float]) -> bool:
|
||||
"""腕部 FT 是否有活的数据(够新)。
|
||||
|
||||
``age_s`` 为 None 表示**一帧都没收到过**(话题名写错、传感器没起来)。
|
||||
注意"收到过但数值为 0"**是活的** —— 静止时轴向力本来就接近 0。
|
||||
这个区别很关键:拿数值大小判活,会让"安静的传感器"被误判成掉线,
|
||||
而那正是 2026-09-24 在触觉上踩过的同一个坑(见 stage3 的 ``live_tactile``)。
|
||||
"""
|
||||
return age_s is not None and float(age_s) <= WRENCH_MAX_AGE_S
|
||||
|
||||
|
||||
#: S07 单步下压给 ``_goto`` 的到位容差 = 步长的这个比例。
|
||||
#: **必须 < 1,而且只能小不能大**:``_goto`` 的语义是"已经落在容差里就直接返回、
|
||||
#: 一点都不动"。容差 ≥ 步长时,每一次"往下走 0.5mm"的请求都被判成"已在容差内"
|
||||
#: → 臂原地不动、循环条件永远成立 → **静默自旋**(日志停在基线那行、CPU 跑满、
|
||||
#: 既不报错也不前进)。2026-09-24 在仿真里踩到过一次,真机同参数同结果。
|
||||
DESCEND_TOL_FRAC: float = 0.4
|
||||
#: 容差下限 [m]:再小就追不动了(伺服死区/噪声会让臂永远"差一点点")。
|
||||
DESCEND_TOL_FLOOR_M: float = 2.0e-5
|
||||
#: 判定"这一步真的往下走了"的位移下限,占步长的比例。
|
||||
DESCEND_PROGRESS_FRAC: float = 0.2
|
||||
#: 卸载回退的到位容差占**回退量**的比例(见 :func:`relief_tol_m`)。
|
||||
RELIEF_TOL_FRAC: float = 0.2
|
||||
#: 允许连续多少步毫无位移后中止(防"没接触也一直转"的静默自旋)。
|
||||
DESCEND_STALL_STEPS: int = 5
|
||||
|
||||
|
||||
def relief_tol_m(dz_m: float) -> float:
|
||||
"""S07 判到接触后**卸载回退**该给 ``_goto`` 的到位容差 [m]。
|
||||
|
||||
为什么单独立一条(2026-09-24 实测踩到,代价是一轮假通过):
|
||||
|
||||
回退量 0.50mm,而 ``_goto`` 的容差被写死成 0.0005 m(**同量级**)。
|
||||
目标 z 是 ``Tee[2,3] + 0.0005``,浮点回减后 ``dist`` 会比 0.0005 略小
|
||||
⇒ ``_goto`` 的入口判据 ``dist < tol_m`` 当场成立 ⇒ **一步没走就返回 True**。
|
||||
于是"卸载"变成空操作,而残余力判据比的是"回退前 vs 回退后"两个**相同**的
|
||||
读数 ⇒ 报"残余 0.0N,已放干净"(假通过)。
|
||||
接着 S08 带着 ~70N 的轴向力去转(夹持摩擦上限 µ·N 两指合计 ≈73N)
|
||||
→ 掉件(E1),而 E1 的日志只说"规掉了"。
|
||||
|
||||
返回 ``回退量的 20%``,再夹到 ``[DESCEND_TOL_FLOOR_M, 回退量的一半]`` ——
|
||||
后者保证**容差永远严格小于回退量**,`_goto` 不可能在入口就判"已到位"。
|
||||
"""
|
||||
dz = float(dz_m)
|
||||
if dz <= 0.0:
|
||||
raise ValueError(f"卸载回退量必须为正,收到 {dz_m}")
|
||||
return min(max(DESCEND_TOL_FLOOR_M, dz * RELIEF_TOL_FRAC), dz * 0.5)
|
||||
|
||||
|
||||
def descend_tol_m(step_m: float) -> float:
|
||||
"""S07 单步下压该给 ``_goto`` 的到位容差 [m]。
|
||||
|
||||
取步长的 40%,但不超过步长的一半、不低于 ``DESCEND_TOL_FLOOR_M``。
|
||||
这样"走满这一步"才是循环推进的**唯一**出口 —— 容差比步长小,`_goto`
|
||||
就不可能因为"已经在容差内"而空转(那正是上面注释里那个静默自旋)。
|
||||
"""
|
||||
if step_m <= 0.0:
|
||||
raise ValueError(f"下压步长必须为正,收到 {step_m}")
|
||||
return max(step_m * DESCEND_TOL_FRAC, min(DESCEND_TOL_FLOOR_M, step_m * 0.5))
|
||||
|
||||
|
||||
def descend_iter_budget(max_depth_m: float, step_m: float, slack: int = 8) -> int:
|
||||
"""S07 下压循环的**硬**迭代上限:够走完整个下探深度,再多给几步余量。
|
||||
|
||||
为什么要有硬上限:循环条件写的是"实测高度还没到最深",一旦臂因为死区、
|
||||
被夹住、孔位偏了而**实际没动**,这个条件就恒真。上限不是用来"早点失败"
|
||||
的(那是 ``contact_max_depth_mm`` 的活),而是**兜住静默自旋**:
|
||||
宁可报"S07 没走完也没接触",也不能让节点无声无息地空转到天亮。
|
||||
"""
|
||||
if step_m <= 0.0:
|
||||
raise ValueError(f"下压步长必须为正,收到 {step_m}")
|
||||
if max_depth_m <= 0.0:
|
||||
return 0
|
||||
return int(math.ceil(max_depth_m / step_m)) + max(1, int(slack))
|
||||
|
||||
|
||||
def descend_progress(z_prev: float, z_now: float, step_m: float) -> bool:
|
||||
"""这一步是否真的往下走了(法兰 z 向下为负)。
|
||||
|
||||
判据是**实测**位移,不是"指令发出去了":``_goto`` 因为容差而空转、
|
||||
或步长被控制器死区吞掉时,实测位移≈0 而指令日志一切正常。
|
||||
"""
|
||||
return (float(z_prev) - float(z_now)) >= step_m * DESCEND_PROGRESS_FRAC
|
||||
|
||||
|
||||
#: 通止规**台肩**(COLLAR)在规尖上方的距离 [m]。
|
||||
#: 取自 devices/sim/gazebo/models/gauge/model.sdf:通端 φ6×30mm,台肩在 30~33mm。
|
||||
#: 判"到底了"的那一下就是台肩落到工件上表面 —— 所以它决定了 S07 至少要下压
|
||||
#: 多少(见 descend_needed_m)。改规的模型时必须一起改这里,否则预算会算错。
|
||||
GAUGE_SHOULDER_ABOVE_TIP_M: float = 0.030
|
||||
|
||||
|
||||
def tool_clearance_m(
|
||||
flange_z_m: float,
|
||||
tool_tip_below_flange_m: float,
|
||||
surface_z_m: float,
|
||||
) -> float:
|
||||
"""工具尖端离工件上表面还有多高 [m]。**负 = 已经插进去了。**
|
||||
|
||||
为什么需要这个:流程里"悬停高度"是按**相机**定的,不是按**工具**定的。
|
||||
腕部相机要看清楚孔,就得离工件面 ~0.17m;而工具(装在法兰下面)可能比
|
||||
相机长得多。两者一冲突,就会出现下面这种极难查的局面:
|
||||
|
||||
悬停高度 0.632(法兰)− 工具长 0.243 = 0.389
|
||||
工件上表面 0.412
|
||||
→ 工具尖端已经插进工件 23mm
|
||||
|
||||
此时"悬停"这一步本身已经把工具捅进孔里了,S07 再往下压就一路压到顶、
|
||||
等不到"台肩落面"那一下,最后报"下探 N mm 都没触发接触判据"然后 ABORT。
|
||||
现象看起来像力阈值标错了,实际是**几何**错了。
|
||||
|
||||
真机同参数同结果(不是仿真特有的):只要悬停高度是按相机定的、而工具
|
||||
比相机长,就会这样。所以这个值要在**启动时**算出来并报出来。
|
||||
"""
|
||||
tip_z = float(flange_z_m) - float(tool_tip_below_flange_m)
|
||||
return tip_z - float(surface_z_m)
|
||||
|
||||
|
||||
def descend_needed_m(
|
||||
flange_z_m: float,
|
||||
tool_tip_below_flange_m: float,
|
||||
surface_z_m: float,
|
||||
tool_shoulder_above_tip_m: float,
|
||||
) -> float:
|
||||
"""S07 要判"台肩落面",从当前法兰高度起还需要下压多少 [m]。
|
||||
|
||||
判"到底了"的那一下不是工具尖端碰面,而是**台肩落到工件上表面**;
|
||||
台肩在工具尖端上方 ``tool_shoulder_above_tip_m``(规是通端长 30mm)。
|
||||
所以需要的下压量 = 台肩到面的距离,而不是尖端到面的距离。
|
||||
|
||||
返回值可能是负的(台肩已经在面以下)—— 那说明几何配错了,调用方应当
|
||||
报错而不是"往下走一个负距离"。
|
||||
"""
|
||||
shoulder_z = (
|
||||
float(flange_z_m)
|
||||
- float(tool_tip_below_flange_m)
|
||||
+ float(tool_shoulder_above_tip_m)
|
||||
)
|
||||
return shoulder_z - float(surface_z_m)
|
||||
|
||||
|
||||
#: S07 下探预算相对"实际需要下压量"的余量系数。
|
||||
#: 预算是**兜底**用的(不是用来判成败的):真正让循环停下的是接触判据。
|
||||
#: 给 1.5 倍余量是为了容纳对位残差、板厚公差、台肩倒角还没完全落面等。
|
||||
DESCEND_BUDGET_FRAC: float = 1.5
|
||||
#: 下探预算的绝对下限 [m]:几何算出来很小(比如台肩已快贴面)时也要留够,
|
||||
#: 否则一旦对位有点偏,"预算用完"会先于"判到接触"触发,报出来的原因是假的。
|
||||
DESCEND_BUDGET_FLOOR_M: float = 0.008
|
||||
|
||||
|
||||
def descend_budget_m(needed_m: float) -> float:
|
||||
"""由"实际需要下压多少"推出 S07 的下探预算 [m]。
|
||||
|
||||
不要手工填这个数:填小了会在台肩落面**之前**就用完预算(报"下探 N mm
|
||||
都没触发接触判据",而真实原因只是预算不够);填大了会在判据失灵时压得
|
||||
更深(更危险)。由几何推出来,两个毛病都没有。
|
||||
"""
|
||||
return max(float(needed_m) * DESCEND_BUDGET_FRAC, DESCEND_BUDGET_FLOOR_M)
|
||||
|
||||
|
||||
def screw_stop(normal_delta: float, shear_max: float, normal_th: float, shear_th: float) -> bool:
|
||||
"""S08:旋转装配的停止判据 —— **切向力>阈值 且 法向力突增**。
|
||||
|
||||
注意是"且":只看切向力,规刚接触螺纹时会误停;只看法向力,规顶到孔底
|
||||
时会误停。两者同时出现才说明"螺纹咬住了",这是拧入到位的特征。
|
||||
"""
|
||||
return shear_max > shear_th and normal_delta > normal_th
|
||||
|
||||
|
||||
#: S08 的四个原子步,顺序即执行顺序。
|
||||
#: **故意不封装成一个"拧螺丝"复合动作**:中途掉件(E1)时,只有每一步都能
|
||||
#: 单独调用、单独判断,才可能从任意一步精确恢复,而不是从头再来。
|
||||
RATCHET_STEPS: Tuple[str, ...] = (
|
||||
"rotate_slow", # 工具系顺时针慢转 step_deg(拧入,带着规一起转)
|
||||
"release", # 松爪(规留在孔里)
|
||||
"rotate_back", # 快回转 step_deg(空手回来)
|
||||
"regrip", # 夹紧
|
||||
)
|
||||
|
||||
|
||||
def ratchet_cycles(total_deg: float, step_deg: float) -> int:
|
||||
"""要拧 ``total_deg`` 需要多少轮棘轮。向上取整,``step_deg<=0`` 报错。"""
|
||||
if step_deg <= 0.0:
|
||||
raise ValueError(f"单次转角必须为正,收到 {step_deg}")
|
||||
if total_deg <= 0.0:
|
||||
return 0
|
||||
return int(math.ceil(total_deg / step_deg))
|
||||
|
||||
|
||||
def is_joint_limit_error(text: str) -> bool:
|
||||
"""S01:把驱动/规划报的字符串认成"撞到关节极限"。
|
||||
|
||||
认得出的理由:这一条在真机上不是异常而是**预期结果**(用户要求"转到不
|
||||
触发关节极限的极限角",那就一定会在某个角度贴住)。贴住时报 JOINT_LIMIT
|
||||
继续流程;认不出来时按普通失败处理(更保守),但要把原文打印出来,
|
||||
否则下次还得猜。
|
||||
"""
|
||||
low = (text or "").lower()
|
||||
return any(
|
||||
key in low
|
||||
for key in (
|
||||
"joint limit",
|
||||
"joint_limit",
|
||||
"超出关节",
|
||||
"关节极限",
|
||||
"限位",
|
||||
"out of range",
|
||||
"position limit",
|
||||
"exceed",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 给人看的说明(文档 / 日志共用,避免两处各写一份,规则 M2)
|
||||
# =============================================================================
|
||||
STATE_DOC: Dict[State, str] = {
|
||||
State.BOOT: "各服务自检(伺服/相机/夹爪/触觉图传/推理预热)",
|
||||
State.HOME_EXTREME: "回 home + 按工具系逆时针转到极限角",
|
||||
State.GRIP_CHECK: "夹持确认(预留,当前跳过)",
|
||||
State.COARSE: "手外 D435 粗定位(S04 的兜底)",
|
||||
State.TRACK: "腕部 D405 跟踪 k0",
|
||||
State.SNAP: "像素稳定后拍照定位 k1/k2 并记位姿",
|
||||
State.HOVER_K: "到当前孔正悬停",
|
||||
State.CONTACT: "柔顺接触(切向力突变即停)",
|
||||
State.SCREW_IN: "旋转装配循环(棘轮式拧入)",
|
||||
State.UNSCREW: "反向拧出",
|
||||
State.NEXT_HOLE: "换到下一个孔(回 S05 记录的位姿)",
|
||||
State.RESET: "复位退出",
|
||||
State.DONE: "正常结束",
|
||||
State.ABORT: "中止(已退到安全高度)",
|
||||
}
|
||||
|
||||
EVENT_DOC: Dict[Event, str] = {
|
||||
Event.BOOT_OK: "全部服务就绪",
|
||||
Event.BOOT_FAIL: "有服务不就绪",
|
||||
Event.POSE_OK: "位姿就绪",
|
||||
Event.JOINT_LIMIT: "已贴住关节极限",
|
||||
Event.GRIP_PASS: "确认夹着通止规",
|
||||
Event.GRIP_FAIL: "没夹住/判定不可信",
|
||||
Event.GRIP_SKIPPED: "跳过夹持确认",
|
||||
Event.COARSE_OK: "粗定位出坐标",
|
||||
Event.COARSE_FAIL: "粗定位失败(回跟踪兜底)",
|
||||
Event.TRACK_LOCKED: "跟踪锁定 k0",
|
||||
Event.TRACK_LOST: "跟踪丢失超时",
|
||||
Event.SNAP_OK: "定位到 k1/k2",
|
||||
Event.SNAP_UNSTABLE: "画面还在动",
|
||||
Event.ALIGN_OK: "对位在容差内",
|
||||
Event.ALIGN_OUT: "对位超差",
|
||||
Event.CONTACT_OK: "已接触工件面",
|
||||
Event.FORCE_OVER_LIMIT: "力超安全上限",
|
||||
Event.SCREW_DONE: "达到目标圈数",
|
||||
Event.SCREW_STOP: "力判据满足(咬住螺纹)",
|
||||
Event.GAUGE_DROPPED: "通止规掉了",
|
||||
Event.UNSCREW_DONE: "已拧出",
|
||||
Event.RELOADED: "人工放好并按 R",
|
||||
Event.TIMEOUT: "阶段超时",
|
||||
Event.ABORT: "中止",
|
||||
}
|
||||
@@ -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,455 @@
|
||||
#!/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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
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(Node):
|
||||
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,685 @@
|
||||
#!/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,
|
||||
calibrate_hand_eye,
|
||||
detect_chessboard,
|
||||
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
|
||||
|
||||
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(Node):
|
||||
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_* 及结果文件头里的实际板子一致。
|
||||
# 真实使用的板:内角点 8x11,方格 15mm(见 handeye/results/*.yaml 的头部注释)。
|
||||
# 这里原先写的是 9x6 / 0.025(另一张早期的板),一旦 site.yaml 漏配就会被
|
||||
# 静默启用 —— 症状是"标定能顺利跑完,但结果明显不对",极难排查。
|
||||
self.declare_parameter('board_cols', 8) # 内角点
|
||||
self.declare_parameter('board_rows', 11)
|
||||
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)
|
||||
|
||||
self.declare_parameter('output_dir', '/home/cc/ros2/handeye/tmp')
|
||||
self.declare_parameter('result_file', '')
|
||||
self.declare_parameter('cleanup_images', True)
|
||||
|
||||
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.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'
|
||||
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}'
|
||||
)
|
||||
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)
|
||||
name = (
|
||||
'T_cam_to_base (eye_to_hand)'
|
||||
if eye_to_hand
|
||||
else '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'
|
||||
)
|
||||
self.result_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.result_file.write_text(header + 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,331 @@
|
||||
#!/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 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 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 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,651 @@
|
||||
#!/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
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _param_as_str(node: Node, name: str, default: str = '') -> str:
|
||||
node.declare_parameter(name, default, ParameterDescriptor(dynamic_typing=True))
|
||||
val = node.get_parameter(name).value
|
||||
if val is None or val == '':
|
||||
return ''
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
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()
|
||||
py = os.environ.get('RDK_PYTHON', '/home/cc/rdk_py310/bin/python')
|
||||
bridge = Path.home() / 'ros2' / 'scripts' / 'rdk_drag_bridge.py'
|
||||
if not bridge.is_file():
|
||||
bridge = Path('/home/cc/ros2/scripts/rdk_drag_bridge.py')
|
||||
if not bridge.is_file():
|
||||
raise FileNotFoundError(f'找不到 rdk_drag_bridge.py:{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',
|
||||
}
|
||||
# 不要继承 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 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(Node):
|
||||
def __init__(self) -> None:
|
||||
super().__init__('handeye_teach')
|
||||
self.declare_parameter('output_dir', '/home/cc/ros2/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)
|
||||
|
||||
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'
|
||||
self.poses_file = self.output_dir / 'taught_poses.txt'
|
||||
self.yaml_file = self.output_dir / 'taught_waypoint_positions.yaml'
|
||||
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
|
||||
|
||||
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,523 @@
|
||||
#!/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
|
||||
|
||||
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(Node):
|
||||
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,754 @@
|
||||
"""工件二阶段检测: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 _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,
|
||||
)
|
||||
|
||||
# ---- 主路径:螺纹嵌件对(亮环签名 + 关于中心孔对称),抗多孔干扰、抗跳变 ----
|
||||
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,188 @@
|
||||
#!/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
|
||||
|
||||
|
||||
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(Node):
|
||||
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,367 @@
|
||||
"""孔定位模块:加载已训练模型,输出孔中心像素坐标与置信度。
|
||||
|
||||
与工件定位(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/hole_insert/detect_best_1.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 YoloDetector, resolve_weights
|
||||
|
||||
weights = resolve_weights(
|
||||
self.model_path,
|
||||
backend=str(self.cfg.get("yolo_backend") or "auto"),
|
||||
)
|
||||
self._detector = YoloDetector(
|
||||
weights,
|
||||
device=str(self.device),
|
||||
conf=float(self.cfg.get("conf", 0.25)),
|
||||
iou=float(self.cfg.get("iou", 0.7)),
|
||||
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,88 @@
|
||||
#!/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 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,72 @@
|
||||
#!/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
|
||||
|
||||
|
||||
class MoveToPoseClient(Node):
|
||||
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,137 @@
|
||||
#!/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])
|
||||
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,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,915 @@
|
||||
#!/usr/bin/env python3
|
||||
"""perception_node —— YOLO + 深度 + 卡尔曼滤波(独立进程)。
|
||||
|
||||
硬性规则:
|
||||
- 图像回调**只**存最新帧(绝不在回调里跑 YOLO)
|
||||
- YOLO 跑在专门的工作线程上,保证 ``MutuallyExclusive`` 的 I/O 回调永远不会被阻塞
|
||||
- 用 ``SensorDataQoS`` ``BEST_EFFORT`` ``keep_last(5)``
|
||||
- 滤波后的三维点用 ``geometry_msgs/PointStamped`` 发布(不用 Service)
|
||||
- 动态物体 TF 只在视觉帧率下发布
|
||||
- 高置信度检测始终重新捕获(快速运动时不会冻结)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import rclpy
|
||||
from geometry_msgs.msg import PointStamped, TransformStamped
|
||||
from rclpy.callback_groups import MutuallyExclusiveCallbackGroup
|
||||
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 CameraInfo, Image
|
||||
from std_msgs.msg import Float32MultiArray
|
||||
from tf2_ros import TransformBroadcaster
|
||||
|
||||
from arm_application.box_tracker import BoxTracker
|
||||
from arm_application.handeye_realsense import (
|
||||
is_realsense_source,
|
||||
resolve_realsense_topics,
|
||||
)
|
||||
from arm_application.kalman_filter import KalmanFilter3D
|
||||
from arm_application.plane_feature import geometric_center_on_plane, workpiece_xyz_in_box
|
||||
from arm_application.yolo_detector import (
|
||||
INFER_BUSY,
|
||||
YoloDetector,
|
||||
YoloInferProcess,
|
||||
pick_best,
|
||||
resolve_weights,
|
||||
)
|
||||
|
||||
|
||||
def _bgr_copy(msg: Image) -> np.ndarray:
|
||||
"""返回调用方持有的 BGR uint8 数组(对异步 YOLO 安全;ROS 可能把原消息丢掉)。"""
|
||||
h, w = int(msg.height), int(msg.width)
|
||||
enc = (msg.encoding or "").lower()
|
||||
buf = np.frombuffer(msg.data, dtype=np.uint8)
|
||||
if enc == "bgr8":
|
||||
return buf.reshape(h, w, 3).copy()
|
||||
if enc == "rgb8":
|
||||
return buf.reshape(h, w, 3)[:, :, ::-1].copy()
|
||||
raise RuntimeError(f"不支持的编码 {msg.encoding!r}")
|
||||
|
||||
|
||||
class PerceptionNode(Node):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("perception_node")
|
||||
tid = threading.get_ident()
|
||||
self.get_logger().warn(
|
||||
f"perception_node 已启动 pid={os.getpid()} thread_id={tid}"
|
||||
)
|
||||
|
||||
self.declare_parameter("camera_source", "d405")
|
||||
self.declare_parameter("camera_namespace", "")
|
||||
self.declare_parameter("image_topic", "")
|
||||
self.declare_parameter("camera_info_topic", "")
|
||||
self.declare_parameter("depth_topic", "/d405/aligned_depth_to_color/image_raw")
|
||||
self.declare_parameter("weights", "/home/cc/ros2/models/yolo/detect_best_0.onnx")
|
||||
self.declare_parameter("conf", 0.10)
|
||||
self.declare_parameter("iou", 0.45)
|
||||
self.declare_parameter("imgsz", 640)
|
||||
self.declare_parameter("device", "0")
|
||||
self.declare_parameter("min_conf", 0.12)
|
||||
self.declare_parameter("vision_hz", 30.0)
|
||||
self.declare_parameter("z_ref_m", 0.40)
|
||||
self.declare_parameter("jump_px", 280.0)
|
||||
self.declare_parameter("jump_z_m", 0.20)
|
||||
self.declare_parameter("min_depth_m", 0.04)
|
||||
self.declare_parameter("max_depth_m", 0.55)
|
||||
self.declare_parameter("work_z_max_m", 0.50) # 高于此值 = 孔 / 背景
|
||||
self.declare_parameter("object_frame", "tracked_object")
|
||||
self.declare_parameter("camera_frame", "d405_color_optical_frame")
|
||||
self.declare_parameter("kf_q_pos", 2.0e-3)
|
||||
self.declare_parameter("kf_q_vel", 5.0e-2)
|
||||
self.declare_parameter("kf_r_pos", 8.0e-4)
|
||||
self.declare_parameter("meas_blend", 0.65) # 1 = 原始测量值,0 = 纯卡尔曼滤波
|
||||
self.declare_parameter("half_precision", True)
|
||||
self.declare_parameter("backend", "auto") # auto|torch|tensorrt
|
||||
self.declare_parameter("mp_infer", True) # YOLO 跑在 spawn() 子进程里
|
||||
# 自适应 Z 低通滤波:alpha_z = clamp(z_alpha_min + |vel_z|*z_alpha_gain, min, max)
|
||||
self.declare_parameter("z_alpha_min", 0.10)
|
||||
self.declare_parameter("z_alpha_max", 0.60)
|
||||
self.declare_parameter("z_alpha_gain", 2.0)
|
||||
# 加速 YOLO:推理前先降采样;检测框再缩放回全分辨率
|
||||
self.declare_parameter("infer_max_width", 0)
|
||||
# 检测框 EMA(1 = 每帧都用原始 YOLO —— 紧跟目标;更低 = 更平滑但滞后)
|
||||
self.declare_parameter("box_ema_alpha", 0.85)
|
||||
# YOLO 短暂漏检:预览里保留上一个框;只有连续 N 帧都漏检才清除
|
||||
self.declare_parameter("miss_clear_frames", 12)
|
||||
self.declare_parameter("overlay_hold_on_miss", True)
|
||||
self.declare_parameter("hold_point_on_miss", True)
|
||||
self.declare_parameter("miss_hold_point_frames", 2)
|
||||
# plane = 立体表面质心(推荐);box = YOLO 检测框中心
|
||||
self.declare_parameter("feature_mode", "plane")
|
||||
self.declare_parameter("plane_inlier_m", 0.008)
|
||||
self.declare_parameter("plane_band_m", 0.06)
|
||||
|
||||
self.z_ref = float(self.get_parameter("z_ref_m").value)
|
||||
self.min_conf = float(self.get_parameter("min_conf").value)
|
||||
self.jump_px = float(self.get_parameter("jump_px").value)
|
||||
self.jump_z_m = float(self.get_parameter("jump_z_m").value)
|
||||
self.min_depth = float(self.get_parameter("min_depth_m").value)
|
||||
self.max_depth = float(self.get_parameter("max_depth_m").value)
|
||||
self.work_z_max = float(self.get_parameter("work_z_max_m").value)
|
||||
self.hold_point_on_miss = bool(self.get_parameter("hold_point_on_miss").value)
|
||||
self.object_frame = str(self.get_parameter("object_frame").value)
|
||||
self.camera_frame = str(self.get_parameter("camera_frame").value)
|
||||
self.meas_blend = float(np.clip(float(self.get_parameter("meas_blend").value), 0.0, 1.0))
|
||||
self.z_alpha_min = float(self.get_parameter("z_alpha_min").value)
|
||||
self.z_alpha_max = float(self.get_parameter("z_alpha_max").value)
|
||||
self.z_alpha_gain = float(self.get_parameter("z_alpha_gain").value)
|
||||
self.infer_max_width = int(self.get_parameter("infer_max_width").value)
|
||||
self.backend_name = str(self.get_parameter("backend").value or "auto")
|
||||
self.mp_infer = bool(self.get_parameter("mp_infer").value)
|
||||
self.box_ema_alpha = float(np.clip(float(self.get_parameter("box_ema_alpha").value), 0.2, 1.0))
|
||||
self.miss_clear_frames = max(1, int(self.get_parameter("miss_clear_frames").value))
|
||||
self.overlay_hold_on_miss = bool(self.get_parameter("overlay_hold_on_miss").value)
|
||||
self.miss_hold_point_frames = max(
|
||||
1, int(self.get_parameter("miss_hold_point_frames").value)
|
||||
)
|
||||
self.feature_mode = str(self.get_parameter("feature_mode").value or "plane").strip().lower()
|
||||
self.plane_inlier_m = float(self.get_parameter("plane_inlier_m").value)
|
||||
self.plane_band_m = float(self.get_parameter("plane_band_m").value)
|
||||
|
||||
self._fx = self._fy = self._cx = self._cy = 0.0
|
||||
self._latest_image: Optional[Image] = None
|
||||
self._latest_depth: Optional[Image] = None
|
||||
self._last_uv: Optional[np.ndarray] = None
|
||||
self._last_good_z: Optional[float] = None
|
||||
self._last_plane_xyz: Optional[np.ndarray] = None
|
||||
self._last_filt: Optional[np.ndarray] = None
|
||||
self._z_filt: Optional[float] = None
|
||||
self._z_filt_t: Optional[float] = None
|
||||
self._box_ema: Optional[np.ndarray] = None # xyxy
|
||||
self._last_raw_xyxy: Optional[np.ndarray] = None
|
||||
self._box_trk = BoxTracker()
|
||||
self._last_box_cls = 0.0
|
||||
self._last_box_conf = 0.0
|
||||
self._last_yolo_t: Optional[float] = None
|
||||
self._miss_count = 0
|
||||
self._last_det_payload: Optional[list] = None
|
||||
self._frame_seq = 0
|
||||
self._done_seq = -1
|
||||
self._frame_t: dict = {}
|
||||
self._infer_meta: dict = {}
|
||||
self._submitted_seq = -1
|
||||
self._last_gpu_t: Optional[float] = None
|
||||
self._last_restart_t = 0.0
|
||||
self._lock = threading.Lock()
|
||||
self._infer_busy = False
|
||||
self._stop = threading.Event()
|
||||
self._wake = threading.Event()
|
||||
|
||||
# I/O 回调组:只做轻量的“存最新帧”(绝不跑 YOLO)
|
||||
self._io_cbg = MutuallyExclusiveCallbackGroup()
|
||||
self._wake_cbg = MutuallyExclusiveCallbackGroup()
|
||||
|
||||
self.kf = KalmanFilter3D(
|
||||
q_pos=float(self.get_parameter("kf_q_pos").value),
|
||||
q_vel=float(self.get_parameter("kf_q_vel").value),
|
||||
r_pos=float(self.get_parameter("kf_r_pos").value),
|
||||
)
|
||||
|
||||
weights_raw = str(self.get_parameter("weights").value)
|
||||
weights = str(resolve_weights(weights_raw, self.backend_name))
|
||||
yolo_cfg = {
|
||||
"weights": weights,
|
||||
"backend": self.backend_name,
|
||||
"conf": float(self.get_parameter("conf").value),
|
||||
"iou": float(self.get_parameter("iou").value),
|
||||
"device": str(self.get_parameter("device").value or ""),
|
||||
"imgsz": int(self.get_parameter("imgsz").value),
|
||||
"half": bool(self.get_parameter("half_precision").value),
|
||||
}
|
||||
self._yolo_half = bool(yolo_cfg["half"])
|
||||
self._yolo_proc: Optional[YoloInferProcess] = None
|
||||
if self.mp_infer:
|
||||
self._yolo_proc = YoloInferProcess(yolo_cfg)
|
||||
self.detector = self._yolo_proc # 鸭子类型,只要求有 infer()
|
||||
imgsz = self._yolo_proc.imgsz
|
||||
self.get_logger().info(
|
||||
f"YOLO worker pid={self._yolo_proc._p.pid} weights={self._yolo_proc.weights_path}"
|
||||
)
|
||||
else:
|
||||
self.detector = YoloDetector(
|
||||
weights,
|
||||
conf=float(yolo_cfg["conf"]),
|
||||
iou=float(yolo_cfg["iou"]),
|
||||
device=str(yolo_cfg["device"]),
|
||||
imgsz=int(yolo_cfg["imgsz"]),
|
||||
)
|
||||
if self._yolo_half:
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
self.detector._model.to("cuda")
|
||||
else:
|
||||
self._yolo_half = False
|
||||
except Exception: # noqa: BLE001
|
||||
self._yolo_half = False
|
||||
try:
|
||||
self.detector.warmup(half=self._yolo_half)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f"YOLO 预热失败:{exc}")
|
||||
imgsz = self.detector.imgsz
|
||||
|
||||
image_qos = QoSProfile(
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=5,
|
||||
reliability=ReliabilityPolicy.BEST_EFFORT,
|
||||
)
|
||||
assert image_qos.reliability == qos_profile_sensor_data.reliability
|
||||
|
||||
cam_src = str(self.get_parameter("camera_source").value).strip().lower()
|
||||
img_topic = str(self.get_parameter("image_topic").value or "").strip()
|
||||
info_topic = str(self.get_parameter("camera_info_topic").value or "").strip()
|
||||
ns = str(self.get_parameter("camera_namespace").value or "").strip()
|
||||
if is_realsense_source(cam_src) or is_realsense_source(ns):
|
||||
img_topic, info_topic = resolve_realsense_topics(
|
||||
cam_src,
|
||||
camera_namespace=ns,
|
||||
image_topic=img_topic,
|
||||
camera_info_topic=info_topic,
|
||||
)
|
||||
depth_topic = str(self.get_parameter("depth_topic").value or "").strip()
|
||||
cam_ns = (ns or cam_src).strip().strip("/")
|
||||
if not depth_topic or (
|
||||
is_realsense_source(cam_ns)
|
||||
and depth_topic
|
||||
in (
|
||||
"/d405/aligned_depth_to_color/image_raw",
|
||||
"/d435/aligned_depth_to_color/image_raw",
|
||||
)
|
||||
and cam_ns not in ("d405", "d435")
|
||||
):
|
||||
depth_topic = f"/{cam_ns}/aligned_depth_to_color/image_raw"
|
||||
if not img_topic:
|
||||
raise RuntimeError(
|
||||
f"image_topic 为空(camera_source={cam_src!r} ns={ns!r});"
|
||||
"请使用 d405 / d405_left / d405_right"
|
||||
)
|
||||
self.get_logger().info(
|
||||
f"perception image={img_topic} depth={depth_topic} info={info_topic or '-'}"
|
||||
)
|
||||
|
||||
self.create_subscription(
|
||||
Image, img_topic, self._on_image, image_qos, callback_group=self._io_cbg
|
||||
)
|
||||
if info_topic:
|
||||
self.create_subscription(
|
||||
CameraInfo, info_topic, self._on_info, image_qos,
|
||||
callback_group=self._io_cbg,
|
||||
)
|
||||
self.create_subscription(
|
||||
Image, depth_topic, self._on_depth, image_qos, callback_group=self._io_cbg
|
||||
)
|
||||
|
||||
point_qos = QoSProfile(
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=5,
|
||||
reliability=ReliabilityPolicy.BEST_EFFORT,
|
||||
)
|
||||
self._point_pub = self.create_publisher(
|
||||
PointStamped, "visual_servo/filtered_point", point_qos
|
||||
)
|
||||
# RELIABLE + keep_last(5):相机预览能容忍短暂的 DDS 丢包间隙
|
||||
det_qos = QoSProfile(
|
||||
history=HistoryPolicy.KEEP_LAST,
|
||||
depth=5,
|
||||
reliability=ReliabilityPolicy.RELIABLE,
|
||||
)
|
||||
self._det_pub = self.create_publisher(Float32MultiArray, "workpiece/detection", det_qos)
|
||||
self._tf_br = TransformBroadcaster(self)
|
||||
|
||||
vision_hz = max(5.0, min(40.0, float(self.get_parameter("vision_hz").value)))
|
||||
self.create_timer(1.0 / vision_hz, self._wake_infer, callback_group=self._wake_cbg)
|
||||
self._worker = threading.Thread(target=self._infer_loop, name="yolo_worker", daemon=True)
|
||||
self._worker.start()
|
||||
|
||||
self.get_logger().info(
|
||||
f"perception 就绪 vision≤{vision_hz:.0f}Hz imgsz={imgsz} "
|
||||
f"infer_max_w={self.infer_max_width} mp={int(self.mp_infer)} "
|
||||
f"backend={self.backend_name} half={self._yolo_half} "
|
||||
f"feature={self.feature_mode} image={img_topic}"
|
||||
)
|
||||
|
||||
def destroy_node(self) -> bool:
|
||||
self._stop.set()
|
||||
self._wake.set()
|
||||
if self._yolo_proc is not None:
|
||||
self._yolo_proc.close()
|
||||
self._yolo_proc = None
|
||||
return super().destroy_node()
|
||||
|
||||
def _on_image(self, msg: Image) -> None:
|
||||
self._latest_image = msg
|
||||
self._frame_seq += 1
|
||||
seq = self._frame_seq
|
||||
self._frame_t[seq] = time.monotonic()
|
||||
if len(self._frame_t) > 40:
|
||||
cut = seq - 30
|
||||
self._frame_t = {k: v for k, v in self._frame_t.items() if k >= cut}
|
||||
self._infer_meta = {k: v for k, v in self._infer_meta.items() if k >= cut}
|
||||
if self._cx <= 1.0:
|
||||
self._cx = float(msg.width) * 0.5
|
||||
self._cy = float(msg.height) * 0.5
|
||||
if self._fx <= 1.0:
|
||||
w = float(msg.width)
|
||||
self._fx = 654.8 if w >= 1200 else 393.9 * (w / 640.0)
|
||||
self._fy = 654.0 if w >= 1200 else 393.4 * (float(msg.height) / 480.0)
|
||||
# 千万不要在这里把图像 pickle 进 YOLO 队列 —— 那会卡住相机回调,
|
||||
# 看起来就像是推理冻结。由推理线程负责提交。
|
||||
self._wake.set()
|
||||
|
||||
def _on_info(self, msg: CameraInfo) -> None:
|
||||
self._fx = float(msg.k[0])
|
||||
self._fy = float(msg.k[4])
|
||||
self._cx = float(msg.k[2])
|
||||
self._cy = float(msg.k[5])
|
||||
if msg.header.frame_id:
|
||||
self.camera_frame = msg.header.frame_id
|
||||
|
||||
def _on_depth(self, msg: Image) -> None:
|
||||
self._latest_depth = msg
|
||||
|
||||
def _wake_infer(self) -> None:
|
||||
if self._latest_image is None:
|
||||
return
|
||||
self._wake.set()
|
||||
|
||||
def _submit_latest(self) -> None:
|
||||
"""复制最新的相机帧,交给 GPU 进程。"""
|
||||
msg = self._latest_image
|
||||
seq = int(self._frame_seq)
|
||||
if msg is None or seq <= self._submitted_seq:
|
||||
return
|
||||
try:
|
||||
bgr = _bgr_copy(msg)
|
||||
small, scale = self._prepare_infer(bgr)
|
||||
self._infer_meta[seq] = scale
|
||||
self.detector.submit(small, seq)
|
||||
self._submitted_seq = seq
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f"yolo 提交失败:{exc}", throttle_duration_sec=2.0)
|
||||
|
||||
def _restart_yolo(self, why: str) -> None:
|
||||
"""自愈:重启卡死 / 挂掉的 GPU 工作进程(重新加载 engine 需要几秒)。"""
|
||||
now = time.monotonic()
|
||||
if now - self._last_restart_t < 5.0:
|
||||
return
|
||||
if not hasattr(self.detector, "restart"):
|
||||
return
|
||||
self._last_restart_t = now
|
||||
self.get_logger().error(f"YOLO 工作进程 {why} —— 正在重启(检测框靠惯性滑行)")
|
||||
try:
|
||||
self.detector.restart()
|
||||
self._submitted_seq = -1
|
||||
self._last_gpu_t = time.monotonic()
|
||||
self.get_logger().info("YOLO 工作进程已重启")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f"YOLO 重启失败:{exc}")
|
||||
|
||||
def _infer_loop(self) -> None:
|
||||
"""提交最新帧,收集 GPU 结果,再做立体匹配。
|
||||
|
||||
GPU 绝不能等立体匹配:如果我们短暂正忙,工作进程会直接丢掉旧结果。
|
||||
我们还会**先**提交再去等,这样下一次推理已经排进队列了。
|
||||
"""
|
||||
while not self._stop.is_set():
|
||||
self._submit_latest()
|
||||
try:
|
||||
got = self.detector.wait_result(timeout=0.030)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f"推理:{exc}", throttle_duration_sec=2.0)
|
||||
if not getattr(self.detector, "alive", True):
|
||||
self._restart_yolo("died")
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
now = time.monotonic()
|
||||
if got is None:
|
||||
silent = (now - self._last_gpu_t) if self._last_gpu_t else 0.0
|
||||
if self._last_gpu_t is not None and silent > 0.25:
|
||||
self.get_logger().warn(
|
||||
f"YOLO 静默 {silent*1000:.0f}ms "
|
||||
f"(worker={'存活' if getattr(self.detector, 'alive', True) else '已死'})",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
outstanding = self._submitted_seq > self._done_seq
|
||||
if outstanding and self._last_gpu_t is not None and silent > 2.0:
|
||||
self._restart_yolo(f"卡死 {silent:.1f}s")
|
||||
continue
|
||||
if (
|
||||
self._last_yolo_t is not None
|
||||
and (now - self._last_yolo_t) > 0.070
|
||||
and self._last_raw_xyxy is not None
|
||||
):
|
||||
self._coast_box("gpu")
|
||||
self._wake.wait(timeout=0.015)
|
||||
self._wake.clear()
|
||||
continue
|
||||
self._last_gpu_t = now
|
||||
seq, dets, gpu_ms = got
|
||||
if dets is INFER_BUSY:
|
||||
continue
|
||||
scale = float(self._infer_meta.get(int(seq), 1.0))
|
||||
t_img = float(self._frame_t.get(int(seq), time.monotonic()))
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
self._apply_detections(dets, scale, t_img)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().error(f"应用检测结果出错:{exc}", throttle_duration_sec=2.0)
|
||||
self._done_seq = int(seq)
|
||||
self._submit_latest()
|
||||
dt_ms = (time.monotonic() - t0) * 1000.0
|
||||
lag_ms = (time.monotonic() - t_img) * 1000.0
|
||||
self.get_logger().info(
|
||||
f"infer gpu={gpu_ms:.0f}ms apply={dt_ms:.0f}ms lag={lag_ms:.0f}ms seq={seq}",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
|
||||
def _decode_depth_m(self, msg: Image) -> Optional[np.ndarray]:
|
||||
w, h = int(msg.width), int(msg.height)
|
||||
enc = (msg.encoding or "").lower()
|
||||
if enc in ("16uc1", "mono16"):
|
||||
raw = np.frombuffer(msg.data, dtype=np.uint16)
|
||||
if raw.size < w * h:
|
||||
return None
|
||||
return raw[: w * h].reshape(h, w)
|
||||
if enc == "32fc1":
|
||||
raw = np.frombuffer(msg.data, dtype=np.float32)
|
||||
if raw.size < w * h:
|
||||
return None
|
||||
return raw[: w * h].reshape(h, w)
|
||||
return None
|
||||
|
||||
def _depth_in_box(self, xyxy) -> tuple:
|
||||
msg = self._latest_depth
|
||||
if msg is None:
|
||||
return None, "无深度"
|
||||
if not self._depth_fresh(msg):
|
||||
return None, "深度过期"
|
||||
enc = (msg.encoding or "").lower()
|
||||
buf = self._decode_depth_m(msg)
|
||||
if buf is None:
|
||||
return None, "深度无效"
|
||||
h, w = buf.shape
|
||||
x1, y1, x2, y2 = [float(v) for v in xyxy]
|
||||
img = self._latest_image
|
||||
if img is not None and (int(img.width) != w or int(img.height) != h):
|
||||
sx, sy = w / float(img.width), h / float(img.height)
|
||||
x1, y1, x2, y2 = x1 * sx, y1 * sy, x2 * sx, y2 * sy
|
||||
bw, bh = max(8.0, x2 - x1), max(8.0, y2 - y1)
|
||||
# 内部 ROI —— 避开检测框边缘;但对有孔的工件仍要保留足够像素
|
||||
ix0 = int(np.clip(x1 + 0.20 * bw, 0, w - 1))
|
||||
iy0 = int(np.clip(y1 + 0.20 * bh, 0, h - 1))
|
||||
ix1 = int(np.clip(x2 - 0.20 * bw, ix0 + 2, w))
|
||||
iy1 = int(np.clip(y2 - 0.20 * bh, iy0 + 2, h))
|
||||
roi = buf[iy0:iy1, ix0:ix1].reshape(-1).astype(np.float32)
|
||||
scale = 0.001
|
||||
if enc in ("16uc1", "mono16"):
|
||||
pos = roi[roi > 0]
|
||||
if pos.size >= 8:
|
||||
med_raw = float(np.median(pos))
|
||||
scale = 0.001 if 20.0 <= med_raw <= 8000.0 else (1.0 if med_raw < 20 else 0.0001)
|
||||
meters = pos * scale
|
||||
else:
|
||||
meters = np.array([], dtype=np.float32)
|
||||
else:
|
||||
meters = roi
|
||||
scale = 1.0
|
||||
vals = meters[np.isfinite(meters) & (meters > self.min_depth) & (meters < self.max_depth)]
|
||||
if vals.size < 8:
|
||||
# 内部 ROI 经常落在孔上;改用整个检测框重试。
|
||||
y0 = int(np.clip(y1, 0, h - 1))
|
||||
x0 = int(np.clip(x1, 0, w - 1))
|
||||
y1b = int(np.clip(y2, y0 + 2, h))
|
||||
x1b = int(np.clip(x2, x0 + 2, w))
|
||||
roi2 = buf[y0:y1b, x0:x1b].reshape(-1).astype(np.float32)
|
||||
if enc in ("16uc1", "mono16"):
|
||||
pos2 = roi2[roi2 > 0]
|
||||
if pos2.size >= 8:
|
||||
med_raw = float(np.median(pos2))
|
||||
scale = 0.001 if 20.0 <= med_raw <= 8000.0 else (1.0 if med_raw < 20 else 0.0001)
|
||||
meters2 = pos2 * scale
|
||||
else:
|
||||
meters2 = roi2
|
||||
vals = meters2[np.isfinite(meters2) & (meters2 > self.min_depth) & (meters2 < self.max_depth)]
|
||||
if vals.size < 8:
|
||||
med_m = float(np.median(meters)) if meters.size else 0.0
|
||||
return None, f"ok={vals.size} scale={scale} med={med_m:.3f}"
|
||||
# 只取近表面:孔 / 桌面 / 远处背景绝不能把 Z 拉偏。
|
||||
# 先取最近的 40%,再围绕第 10 百分位保留一个 6 cm 的带宽。
|
||||
vals = np.sort(vals)
|
||||
n_near = max(8, int(0.40 * vals.size))
|
||||
near = vals[:n_near]
|
||||
z_p10 = float(np.percentile(near, 10))
|
||||
band = near[near <= (z_p10 + 0.06)]
|
||||
if band.size < 4:
|
||||
band = near
|
||||
z = float(np.median(band))
|
||||
if z > self.work_z_max:
|
||||
return None, f"ok={vals.size} far={z:.3f}"
|
||||
return z, f"ok={vals.size} near={band.size}"
|
||||
|
||||
def _adaptive_filter_z(self, z_raw: float, now: float) -> float:
|
||||
"""第 4 项:对深度做自适应低通滤波 —— 静止时更稳,运动时更灵敏。"""
|
||||
if self._z_filt is None or self._z_filt_t is None:
|
||||
self._z_filt = float(z_raw)
|
||||
self._z_filt_t = now
|
||||
return self._z_filt
|
||||
dt = max(1e-3, float(now - self._z_filt_t))
|
||||
vel_z = abs(float(z_raw) - self._z_filt) / dt
|
||||
alpha_z = float(
|
||||
np.clip(
|
||||
self.z_alpha_min + vel_z * self.z_alpha_gain,
|
||||
self.z_alpha_min,
|
||||
self.z_alpha_max,
|
||||
)
|
||||
)
|
||||
self._z_filt = alpha_z * float(z_raw) + (1.0 - alpha_z) * self._z_filt
|
||||
self._z_filt_t = now
|
||||
return float(self._z_filt)
|
||||
|
||||
def _pixel_to_cam(self, u: float, v: float, z: float) -> np.ndarray:
|
||||
return np.array(
|
||||
[
|
||||
(u - self._cx) * z / max(self._fx, 1.0),
|
||||
(v - self._cy) * z / max(self._fy, 1.0),
|
||||
z,
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
def _cam_to_pixel(self, p: np.ndarray) -> np.ndarray:
|
||||
z = max(float(p[2]), 1e-3)
|
||||
return np.array(
|
||||
[
|
||||
self._fx * float(p[0]) / z + self._cx,
|
||||
self._fy * float(p[1]) / z + self._cy,
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
def _publish_detection_clear(self) -> None:
|
||||
"""空检测 → 预览清掉绿框(绝不能复用上一次的 xyxy)。"""
|
||||
self._last_det_payload = None
|
||||
msg = Float32MultiArray()
|
||||
msg.data = []
|
||||
self._det_pub.publish(msg)
|
||||
|
||||
def _publish_detection_box(
|
||||
self, xyxy: np.ndarray, conf: float, cls_id: float, uv: np.ndarray, z: float
|
||||
) -> None:
|
||||
det = Float32MultiArray()
|
||||
det.data = [
|
||||
float(xyxy[0]), float(xyxy[1]), float(xyxy[2]), float(xyxy[3]),
|
||||
float(conf), float(cls_id),
|
||||
float(uv[0]), float(uv[1]), float(z),
|
||||
]
|
||||
self._last_det_payload = list(det.data)
|
||||
self._det_pub.publish(det)
|
||||
|
||||
def _republish_last_box(self) -> None:
|
||||
if not self._last_det_payload:
|
||||
return
|
||||
msg = Float32MultiArray()
|
||||
msg.data = list(self._last_det_payload)
|
||||
self._det_pub.publish(msg)
|
||||
|
||||
def _prepare_infer(self, bgr: np.ndarray) -> tuple:
|
||||
"""可选的 YOLO 降采样;返回 (infer_bgr, scale),全分辨率 = 推理图 * scale。"""
|
||||
max_w = self.infer_max_width
|
||||
h, w = bgr.shape[:2]
|
||||
if max_w <= 0 or w <= max_w:
|
||||
return bgr, 1.0
|
||||
scale = w / float(max_w)
|
||||
new_w = int(max_w)
|
||||
new_h = max(1, int(round(h / scale)))
|
||||
try:
|
||||
import cv2
|
||||
small = cv2.resize(bgr, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
||||
except Exception: # noqa: BLE001
|
||||
small = bgr
|
||||
scale = 1.0
|
||||
return small, scale
|
||||
|
||||
def _img_wh(self) -> Optional[tuple]:
|
||||
msg = self._latest_image
|
||||
if msg is None:
|
||||
return None
|
||||
return (int(msg.width), int(msg.height))
|
||||
|
||||
def _coast_box(self, note: str) -> bool:
|
||||
"""让绿框按上一次的速度继续移动;不要用它驱动三维。"""
|
||||
now = time.monotonic()
|
||||
pred = self._box_trk.predict(now, self._img_wh())
|
||||
if pred is None and self._last_raw_xyxy is not None:
|
||||
pred = np.asarray(self._last_raw_xyxy, dtype=float).reshape(4)
|
||||
if pred is None:
|
||||
return False
|
||||
uv = np.array(
|
||||
[0.5 * (pred[0] + pred[2]), 0.5 * (pred[1] + pred[3])], dtype=float
|
||||
)
|
||||
z = float(self._z_filt if self._z_filt is not None else self.z_ref)
|
||||
self._publish_detection_box(
|
||||
pred, float(self._last_box_conf), float(self._last_box_cls), uv, z
|
||||
)
|
||||
self.get_logger().info(f"检测框惯滑 {note}", throttle_duration_sec=0.5)
|
||||
return True
|
||||
|
||||
def _clear_track(self) -> None:
|
||||
self._box_ema = None
|
||||
self._last_raw_xyxy = None
|
||||
self._last_uv = None
|
||||
self._last_filt = None
|
||||
self._last_plane_xyz = None
|
||||
self._box_trk.reset()
|
||||
self._last_yolo_t = None
|
||||
self._publish_detection_clear()
|
||||
|
||||
def _apply_detections(self, dets, scale: float, t_img: float) -> None:
|
||||
"""匹配 YOLO 结果,发布按当前时刻预测的检测框,再在该框上做立体匹配。"""
|
||||
if scale != 1.0:
|
||||
for d in dets:
|
||||
d.xyxy = d.xyxy * scale
|
||||
now = time.monotonic()
|
||||
pred_at_img = self._box_trk.predict(t_img, self._img_wh())
|
||||
with self._lock:
|
||||
prev_uv = None if self._last_uv is None else self._last_uv.copy()
|
||||
prev_xyxy = pred_at_img if pred_at_img is not None else self._last_raw_xyxy
|
||||
best = pick_best(
|
||||
dets,
|
||||
prefer_center=(self._cx, self._cy),
|
||||
prev_uv=prev_uv,
|
||||
prev_xyxy=prev_xyxy,
|
||||
max_track_px=max(400.0, self.jump_px),
|
||||
reacquire_conf=self.min_conf,
|
||||
min_conf=self.min_conf,
|
||||
lock_iou=0.05,
|
||||
)
|
||||
if best is None:
|
||||
self._miss_count += 1
|
||||
if self._miss_count >= self.miss_clear_frames:
|
||||
self._clear_track()
|
||||
self._last_yolo_t = None
|
||||
self.get_logger().info(
|
||||
"检测漏检 —— 已清除叠加显示",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
else:
|
||||
if not self._coast_box(f"miss={self._miss_count}"):
|
||||
if self.overlay_hold_on_miss:
|
||||
self._republish_last_box()
|
||||
if (
|
||||
self.hold_point_on_miss
|
||||
and self._miss_count <= self.miss_hold_point_frames
|
||||
):
|
||||
self._republish_held_point()
|
||||
return
|
||||
|
||||
self._miss_count = 0
|
||||
self._last_yolo_t = now
|
||||
xyxy_raw = np.asarray(best.xyxy, dtype=float).reshape(4)
|
||||
a = self.box_ema_alpha
|
||||
if self._box_ema is None:
|
||||
self._box_ema = xyxy_raw.copy()
|
||||
else:
|
||||
self._box_ema = a * xyxy_raw + (1.0 - a) * self._box_ema
|
||||
self._last_raw_xyxy = xyxy_raw.copy()
|
||||
self._last_box_conf = float(best.conf)
|
||||
self._last_box_cls = float(best.cls_id)
|
||||
self._box_trk.update(xyxy_raw, t_img)
|
||||
# 补偿推理延迟:画工件**当前**所在的位置,而不是 YOLO 看到它时的位置
|
||||
xyxy = self._box_trk.predict(now, self._img_wh())
|
||||
if xyxy is None:
|
||||
xyxy = xyxy_raw
|
||||
uv = np.array(
|
||||
[0.5 * (xyxy[0] + xyxy[2]), 0.5 * (xyxy[1] + xyxy[3])],
|
||||
dtype=float,
|
||||
)
|
||||
box_uv = uv.copy()
|
||||
z_preview = float(self._z_filt if self._z_filt is not None else self.z_ref)
|
||||
self._publish_detection_box(
|
||||
xyxy, float(best.conf), float(best.cls_id), box_uv, z_preview
|
||||
)
|
||||
|
||||
# 悬停目标 = 检测框内工件表面的立体三维点(不是穿过孔 / 桌面的射线,
|
||||
# 也不是 z_ref)。红点 = 该三维点。
|
||||
feat = None
|
||||
p_cam: Optional[np.ndarray] = None
|
||||
note = "box"
|
||||
z_meas = None
|
||||
uv = box_uv
|
||||
if self.feature_mode != "box":
|
||||
feat = self._workpiece_xyz(xyxy)
|
||||
if feat is None:
|
||||
feat = self._plane_center(xyxy)
|
||||
if feat is not None:
|
||||
p_cam = np.asarray(feat.xyz, dtype=float).reshape(3)
|
||||
uv = np.asarray(feat.uv, dtype=float).reshape(2)
|
||||
z_meas = float(p_cam[2])
|
||||
note = feat.note
|
||||
else:
|
||||
z_meas, note = self._depth_in_box(xyxy)
|
||||
note = f"{note} box_z"
|
||||
z_ok = z_meas is not None
|
||||
z, note = self._metric_z(z_meas, note)
|
||||
if z is None:
|
||||
# 从未拿到过可用深度(目标超出 D405 量程 / 不在工作区内 / 相机安装或
|
||||
# 手眼标定有问题)。不要发布 z_ref 点 —— 那会在幻影位置上假装锁定夹爪。
|
||||
# 此时应保持不动。
|
||||
self.get_logger().warn(
|
||||
f"目标没有可用深度({note})—— 不跟踪。"
|
||||
f"请把工件放到相机视野内 {self.min_depth:.2f}-{self.work_z_max:.2f} m 处,"
|
||||
"或检查相机安装 / 手眼标定。",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
return
|
||||
now = time.monotonic()
|
||||
z = self._adaptive_filter_z(float(z), now)
|
||||
if p_cam is None:
|
||||
p_cam = self._pixel_to_cam(box_uv[0], box_uv[1], float(z))
|
||||
stamp = self.get_clock().now().to_msg()
|
||||
|
||||
self._publish_detection_box(xyxy, float(best.conf), float(best.cls_id), uv, float(z))
|
||||
|
||||
with self._lock:
|
||||
self.kf.advance(now)
|
||||
if self.kf.initialized:
|
||||
pred = self.kf.position
|
||||
dpx = float(np.linalg.norm(box_uv - self._cam_to_pixel(pred)))
|
||||
dz = abs(float(p_cam[2] - pred[2])) if z_ok else 0.0
|
||||
if dpx > self.jump_px or (z_ok and dz > self.jump_z_m):
|
||||
self.kf.reset(p_cam, now)
|
||||
self.get_logger().info(
|
||||
f"重新捕获 conf={best.conf:.2f} dpx={dpx:.0f}",
|
||||
throttle_duration_sec=0.5,
|
||||
)
|
||||
else:
|
||||
self.kf.update(p_cam)
|
||||
else:
|
||||
self.kf.reset(p_cam, now)
|
||||
self._last_uv = box_uv
|
||||
kf_p = self.kf.position
|
||||
blend = self.meas_blend
|
||||
filt = blend * p_cam + (1.0 - blend) * kf_p
|
||||
# 即使卡尔曼里还残留着旧的远距离 Z,也要保证 XY 的尺度真实
|
||||
z_use = float(np.clip(filt[2], self.min_depth, self.work_z_max))
|
||||
if abs(z_use - float(p_cam[2])) > 0.04:
|
||||
filt = p_cam.copy()
|
||||
z_use = float(p_cam[2])
|
||||
filt[2] = z_use
|
||||
self._last_filt = filt.copy()
|
||||
|
||||
self._publish_point(filt, stamp)
|
||||
self.get_logger().info(
|
||||
f"跟踪 {self.feature_mode} conf={best.conf:.2f} uv=({uv[0]:.0f},{uv[1]:.0f}) "
|
||||
f"z={filt[2]:.3f} z_ok={int(z_ok)} {note}",
|
||||
throttle_duration_sec=0.4,
|
||||
)
|
||||
|
||||
def _depth_fresh(self, msg: Image) -> bool:
|
||||
"""拒绝过期的深度 —— 对快速运动的工件,当前检测框处的旧深度会读到背景
|
||||
(约 2.4 m 处的桌面),从而污染三维点。"""
|
||||
try:
|
||||
t = rclpy.time.Time.from_msg(msg.header.stamp)
|
||||
age = (self.get_clock().now() - t).nanoseconds * 1e-9
|
||||
except Exception: # noqa: BLE001
|
||||
return True
|
||||
return age < 0.25
|
||||
|
||||
def _workpiece_xyz(self, xyxy):
|
||||
msg = self._latest_depth
|
||||
img = self._latest_image
|
||||
if msg is None or img is None:
|
||||
return None
|
||||
if not self._depth_fresh(msg):
|
||||
return None
|
||||
buf = self._decode_depth_m(msg)
|
||||
if buf is None:
|
||||
return None
|
||||
try:
|
||||
return workpiece_xyz_in_box(
|
||||
buf,
|
||||
msg.encoding or "",
|
||||
xyxy,
|
||||
self._fx,
|
||||
self._fy,
|
||||
self._cx,
|
||||
self._cy,
|
||||
(int(img.width), int(img.height)),
|
||||
min_depth=self.min_depth,
|
||||
max_depth=self.max_depth,
|
||||
work_z_max=self.work_z_max,
|
||||
band_m=self.plane_band_m,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f"inner3d 特征计算失败:{exc}", throttle_duration_sec=2.0)
|
||||
return None
|
||||
|
||||
def _plane_center(self, xyxy):
|
||||
msg = self._latest_depth
|
||||
img = self._latest_image
|
||||
if msg is None or img is None:
|
||||
return None
|
||||
if not self._depth_fresh(msg):
|
||||
return None
|
||||
buf = self._decode_depth_m(msg)
|
||||
if buf is None:
|
||||
return None
|
||||
try:
|
||||
return geometric_center_on_plane(
|
||||
buf,
|
||||
msg.encoding or "",
|
||||
xyxy,
|
||||
self._fx,
|
||||
self._fy,
|
||||
self._cx,
|
||||
self._cy,
|
||||
(int(img.width), int(img.height)),
|
||||
min_depth=self.min_depth,
|
||||
max_depth=self.max_depth,
|
||||
work_z_max=self.work_z_max,
|
||||
inlier_m=self.plane_inlier_m,
|
||||
band_m=self.plane_band_m,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.get_logger().warn(f"平面特征计算失败:{exc}", throttle_duration_sec=2.0)
|
||||
return None
|
||||
|
||||
def _metric_z(self, z_meas: Optional[float], note: str) -> tuple:
|
||||
"""用于像素→米的 Z。绝不能用孔 / 地面的深度(会放大 XY 误差)。
|
||||
当这个目标**从未**有过可用深度时返回 (None, note) —— 那种情况下发布
|
||||
z_ref 点会让伺服假锁到一个幻影 XY 上。"""
|
||||
z_ref = max(0.12, float(self.z_ref))
|
||||
if z_meas is not None and self.min_depth <= z_meas <= self.work_z_max:
|
||||
self._last_good_z = float(z_meas)
|
||||
return float(z_meas), note
|
||||
if self._last_good_z is not None and self._last_good_z <= self.work_z_max:
|
||||
return float(self._last_good_z), f"{note} hold_near"
|
||||
return None, f"{note} no_depth"
|
||||
|
||||
def _publish_point(self, filt: np.ndarray, stamp) -> None:
|
||||
pt = PointStamped()
|
||||
pt.header.stamp = stamp
|
||||
pt.header.frame_id = self.camera_frame
|
||||
pt.point.x = float(filt[0])
|
||||
pt.point.y = float(filt[1])
|
||||
pt.point.z = float(filt[2])
|
||||
self._point_pub.publish(pt)
|
||||
tf = TransformStamped()
|
||||
tf.header.stamp = stamp
|
||||
tf.header.frame_id = self.camera_frame
|
||||
tf.child_frame_id = self.object_frame
|
||||
tf.transform.translation.x = float(filt[0])
|
||||
tf.transform.translation.y = float(filt[1])
|
||||
tf.transform.translation.z = float(filt[2])
|
||||
tf.transform.rotation.w = 1.0
|
||||
self._tf_br.sendTransform(tf)
|
||||
|
||||
def _republish_held_point(self) -> None:
|
||||
if self._last_filt is None:
|
||||
return
|
||||
self._publish_point(self._last_filt, self.get_clock().now().to_msg())
|
||||
|
||||
|
||||
def main(args=None) -> None:
|
||||
import multiprocessing as mp
|
||||
|
||||
try:
|
||||
mp.set_start_method("spawn", force=True)
|
||||
except RuntimeError:
|
||||
pass
|
||||
rclpy.init(args=args)
|
||||
node = PerceptionNode()
|
||||
executor = MultiThreadedExecutor(num_threads=4)
|
||||
executor.add_node(node)
|
||||
try:
|
||||
executor.spin()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
node._stop.set()
|
||||
node._wake.set()
|
||||
if getattr(node, "_yolo_proc", None) is not None:
|
||||
node._yolo_proc.close()
|
||||
node._yolo_proc = None
|
||||
executor.shutdown()
|
||||
node.destroy_node()
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,357 @@
|
||||
#!/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]。
|
||||
#:
|
||||
#: 为什么要抬:跟踪时通止规已经夹在爪上,规尖比相机更靠下;悬停高是"相机拍清
|
||||
#: 孔"的高度,直接在那儿跟踪会让规尖刮到工件。抬 5cm 让它离开工件,二阶段再
|
||||
#: 小幅下降(只需 5cm 以内的行程)。
|
||||
#:
|
||||
#: ⚠️ 抽成常量是因为它不只被流程用:``scripts/check_sim_scene.py`` 要用它核对
|
||||
#: "跟踪高有没有超出工作盒上界"(仿真里悬停 0.632 + 0.05 = 0.682 > 真机那套
|
||||
#: 0.65 上界,就是被这条查出来的)。两处各写一个字面量迟早漂移。
|
||||
TRACK_CLEARANCE_M = 0.05
|
||||
|
||||
|
||||
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("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.get_logger().info(
|
||||
f"pipeline arm_ee={self.ee_frame} hole={self.target_hole} "
|
||||
f"skip1={skip1} skip2={skip2} skip3={skip3} 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:
|
||||
"""跟踪高度 = 孔悬停高 + :data:`TRACK_CLEARANCE_M`,通止规离开工件。"""
|
||||
return float(self.hover_z) + TRACK_CLEARANCE_M
|
||||
|
||||
def _ensure_clearance(self) -> None:
|
||||
"""上一轮轻压可能还在孔里:先纯 Z 抬到跟踪高,禁止 0.23↔0.35 来回。"""
|
||||
q, Tee = self._wait_state(4.0)
|
||||
if q is None or Tee is None:
|
||||
return
|
||||
tz = self._track_z()
|
||||
z = float(Tee[2, 3])
|
||||
if z >= tz - 0.008:
|
||||
return
|
||||
self._state("pre_lift")
|
||||
self.get_logger().info(
|
||||
f"先离开工件/孔口:Z {z:.3f} → {tz:.3f}m(跟踪高=hover+50mm)"
|
||||
)
|
||||
_, 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)
|
||||
|
||||
def _run_stage1(self) -> int:
|
||||
"""拉起 perception+servo,等到 /visual_servo/locked 后冻结并拆跟踪。"""
|
||||
self._ensure_clearance()
|
||||
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}",
|
||||
"lock_hold_sec:=0.6",
|
||||
f"use_sim_time:={str(use_sim).lower()}",
|
||||
]
|
||||
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
|
||||
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 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:
|
||||
self._ensure_clearance()
|
||||
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")
|
||||
self.get_logger().info("跳过搜孔 —— 臂保持在孔上方")
|
||||
if final:
|
||||
self._publish_result(final)
|
||||
self._linger_hold(final if final else {"ok": True}, seconds=0.8)
|
||||
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,295 @@
|
||||
#!/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
|
||||
|
||||
|
||||
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,917 @@
|
||||
#!/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
|
||||
|
||||
|
||||
def _param_as_str(node: Node, name: str, default: str = '') -> str:
|
||||
"""字符串参数,同时兼容命令行传数字的情况(例如序列号)。"""
|
||||
node.declare_parameter(
|
||||
name, default, ParameterDescriptor(dynamic_typing=True)
|
||||
)
|
||||
val = node.get_parameter(name).value
|
||||
if val is None or val == '':
|
||||
return ''
|
||||
return str(val).strip()
|
||||
|
||||
|
||||
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:
|
||||
env = str(os.environ.get("SITE_CONFIG") or "").strip()
|
||||
if env:
|
||||
p = Path(env).expanduser()
|
||||
if p.is_file():
|
||||
return p
|
||||
home = Path.home() / "ros2" / "config" / "site.yaml"
|
||||
if home.is_file():
|
||||
return home
|
||||
return Path("/home/cc/ros2/config/site.yaml")
|
||||
|
||||
|
||||
class RealsenseColorPub(Node):
|
||||
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 仍为空)。'
|
||||
'请把 D405 插到**本机**的 USB3(蓝色)口,拔插一次,'
|
||||
'并关闭 realsense-viewer / 192.168.0.108 上占用该相机的采集端。'
|
||||
)
|
||||
|
||||
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:
|
||||
self.get_logger().warn(
|
||||
f'{cam_name} 接在 USB {usb_desc} 上 —— 深度/IR 将保持 '
|
||||
'640x480@15。把 D405 插到 USB3(蓝色)口可获得 848@30。'
|
||||
)
|
||||
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'D405 没有可用的彩色配置。最后的错误:{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,279 @@
|
||||
#!/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
|
||||
|
||||
|
||||
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(Node):
|
||||
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('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._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,
|
||||
)
|
||||
self.create_subscription(
|
||||
Float32MultiArray, 'workpiece/detection', 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._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:
|
||||
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,172 @@
|
||||
#!/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
|
||||
|
||||
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(Node):
|
||||
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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
"""供应用节点加载 ``~/ros2/config/site.yaml``。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def ros2_root() -> Path:
|
||||
for key in ("SITE_ROS2_ROOT", "ROS2_WS"):
|
||||
v = os.environ.get(key, "").strip()
|
||||
if v:
|
||||
return Path(v).expanduser()
|
||||
return Path.home() / "ros2"
|
||||
|
||||
|
||||
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 {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,303 @@
|
||||
#!/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
|
||||
|
||||
|
||||
class TactileNode(Node):
|
||||
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,403 @@
|
||||
"""权重模块的目录布局、模型发现与标签归属(纯逻辑)。
|
||||
|
||||
这个模块刻意**只依赖标准库 + 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 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,560 @@
|
||||
#!/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
|
||||
|
||||
@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 _yolo_worker_main(q_in, q_out, cfg: dict) -> None:
|
||||
"""spawn 出来的子进程:只跑 GPU 上的 YOLO。这里绝不能 import rclpy。"""
|
||||
try:
|
||||
path = resolve_weights(cfg["weights"], cfg.get("backend") or "auto")
|
||||
det = YoloDetector(
|
||||
path,
|
||||
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))
|
||||
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) -> None:
|
||||
"""把最新一帧排进队列。如果 worker 正忙就跳过 —— 它在每次推理后都会
|
||||
排空到队列里最新的帧,所以不会跑任何过期数据。"""
|
||||
if not self.alive:
|
||||
raise RuntimeError("YOLO 工作进程已死亡")
|
||||
try:
|
||||
self._q_in.put((int(seq), image_bgr), timeout=0.010)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def wait_result(self, timeout: float = 0.045):
|
||||
"""返回 ``(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 = [
|
||||
Detection(
|
||||
xyxy=np.asarray(xyxy, dtype=float),
|
||||
conf=float(conf),
|
||||
cls_id=int(cid),
|
||||
cls_name=str(name),
|
||||
)
|
||||
for xyxy, conf, cid, name in packed
|
||||
]
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1,3 @@
|
||||
# generated from colcon_core/shell/template/command_prefix.sh.em
|
||||
. "/home/cc/ros2/install/arm_kinematics/share/arm_kinematics/package.sh"
|
||||
. "/home/cc/ros2/install/arm_msgs/share/arm_msgs/package.sh"
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
0
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/config/demo.yaml
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/config/dual_grippers.yaml
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/config/hole_detect.yaml
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/config/hole_insert.yaml
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/config/perception.yaml
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/config/servo.yaml
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/config/xunhuan_trajectory.yaml
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/launch/demo.launch.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/launch/dh_pgea.launch.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/launch/dual_gripper.launch.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/launch/gauge_assembly.launch.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/launch/hole_detect.launch.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/launch/pipeline.launch.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/launch/stage2_locate.launch.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/launch/stage3_search.launch.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/launch/tactile.launch.py
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/launch/visual_servo.launch.py
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../src/arm_application/package.xml
|
||||
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
if sys.prefix == '/home/cc/anaconda3/envs/ros_flexiv':
|
||||
sys.real_prefix = sys.prefix
|
||||
sys.prefix = sys.exec_prefix = '/home/cc/ros2/install/arm_application'
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/resource/arm_application
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../src/arm_application/setup.cfg
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../src/arm_application/setup.py
|
||||
@@ -0,0 +1 @@
|
||||
prepend-non-duplicate;PYTHONPATH;/home/cc/ros2/build/arm_application
|
||||
@@ -0,0 +1,3 @@
|
||||
# generated from colcon_powershell/shell/template/hook_prepend_value.ps1.em
|
||||
|
||||
colcon_prepend_unique_value PYTHONPATH "$env:COLCON_CURRENT_PREFIX//home/cc/ros2/build/arm_application"
|
||||
@@ -0,0 +1,3 @@
|
||||
# generated from colcon_core/shell/template/hook_prepend_value.sh.em
|
||||
|
||||
_colcon_prepend_unique_value PYTHONPATH "/home/cc/ros2/build/arm_application"
|
||||
@@ -0,0 +1 @@
|
||||
../../../src/arm_application/trajectories/xunhuan.traj
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"configurations" :
|
||||
[
|
||||
{
|
||||
"abstractTargets" :
|
||||
[
|
||||
{
|
||||
"directoryIndex" : 0,
|
||||
"id" : "Python3::Interpreter::@6890427a1f51a3e7e1df",
|
||||
"jsonFile" : "target-Python3__Interpreter-e0a5c23ae94c2a2ea44f.json",
|
||||
"name" : "Python3::Interpreter",
|
||||
"projectIndex" : 0
|
||||
},
|
||||
{
|
||||
"directoryIndex" : 0,
|
||||
"id" : "Python3::InterpreterMultiConfig::@6890427a1f51a3e7e1df",
|
||||
"jsonFile" : "target-Python3__InterpreterMultiConfig-2e7feeb315f7f0b964c6.json",
|
||||
"name" : "Python3::InterpreterMultiConfig",
|
||||
"projectIndex" : 0
|
||||
}
|
||||
],
|
||||
"directories" :
|
||||
[
|
||||
{
|
||||
"abstractTargetIndexes" :
|
||||
[
|
||||
0,
|
||||
1
|
||||
],
|
||||
"build" : ".",
|
||||
"hasInstallRule" : true,
|
||||
"jsonFile" : "directory-.-5933910a64344a394b41.json",
|
||||
"minimumCMakeVersion" :
|
||||
{
|
||||
"string" : "3.12"
|
||||
},
|
||||
"projectIndex" : 0,
|
||||
"source" : ".",
|
||||
"targetIndexes" :
|
||||
[
|
||||
0,
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"name" : "",
|
||||
"projects" :
|
||||
[
|
||||
{
|
||||
"abstractTargetIndexes" :
|
||||
[
|
||||
0,
|
||||
1
|
||||
],
|
||||
"directoryIndexes" :
|
||||
[
|
||||
0
|
||||
],
|
||||
"name" : "arm_bringup",
|
||||
"targetIndexes" :
|
||||
[
|
||||
0,
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"targets" :
|
||||
[
|
||||
{
|
||||
"directoryIndex" : 0,
|
||||
"id" : "arm_bringup_uninstall::@6890427a1f51a3e7e1df",
|
||||
"jsonFile" : "target-arm_bringup_uninstall-e7f5ffe7c22e54238ffe.json",
|
||||
"name" : "arm_bringup_uninstall",
|
||||
"projectIndex" : 0
|
||||
},
|
||||
{
|
||||
"directoryIndex" : 0,
|
||||
"id" : "uninstall::@6890427a1f51a3e7e1df",
|
||||
"jsonFile" : "target-uninstall-929a9374f71cd797c19c.json",
|
||||
"name" : "uninstall",
|
||||
"projectIndex" : 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"kind" : "codemodel",
|
||||
"paths" :
|
||||
{
|
||||
"build" : "/home/cc/ros2/build/arm_bringup",
|
||||
"source" : "/home/cc/ros2/src/arm_bringup"
|
||||
},
|
||||
"version" :
|
||||
{
|
||||
"major" : 2,
|
||||
"minor" : 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"backtraceGraph" :
|
||||
{
|
||||
"commands" :
|
||||
[
|
||||
"_install",
|
||||
"install",
|
||||
"include",
|
||||
"find_package"
|
||||
],
|
||||
"files" :
|
||||
[
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/symlink_install/install.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/ament_cmake_symlink_install-extras.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/ament_cmake_coreConfig.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake/cmake/ament_cmake_export_dependencies-extras.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake/cmake/ament_cmakeConfig.cmake",
|
||||
"CMakeLists.txt"
|
||||
],
|
||||
"nodes" :
|
||||
[
|
||||
{
|
||||
"file" : 5
|
||||
},
|
||||
{
|
||||
"command" : 3,
|
||||
"file" : 5,
|
||||
"line" : 4,
|
||||
"parent" : 0
|
||||
},
|
||||
{
|
||||
"file" : 4,
|
||||
"parent" : 1
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 4,
|
||||
"line" : 41,
|
||||
"parent" : 2
|
||||
},
|
||||
{
|
||||
"file" : 3,
|
||||
"parent" : 3
|
||||
},
|
||||
{
|
||||
"command" : 3,
|
||||
"file" : 3,
|
||||
"line" : 15,
|
||||
"parent" : 4
|
||||
},
|
||||
{
|
||||
"file" : 2,
|
||||
"parent" : 5
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 2,
|
||||
"line" : 41,
|
||||
"parent" : 6
|
||||
},
|
||||
{
|
||||
"file" : 1,
|
||||
"parent" : 7
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 1,
|
||||
"line" : 47,
|
||||
"parent" : 8
|
||||
},
|
||||
{
|
||||
"command" : 0,
|
||||
"file" : 0,
|
||||
"line" : 43,
|
||||
"parent" : 9
|
||||
}
|
||||
]
|
||||
},
|
||||
"codemodelVersion" :
|
||||
{
|
||||
"major" : 2,
|
||||
"minor" : 10
|
||||
},
|
||||
"installers" :
|
||||
[
|
||||
{
|
||||
"backtrace" : 10,
|
||||
"component" : "Unspecified",
|
||||
"scriptFile" : "/home/cc/ros2/build/arm_bringup/ament_cmake_symlink_install/ament_cmake_symlink_install.cmake",
|
||||
"type" : "script"
|
||||
}
|
||||
],
|
||||
"paths" :
|
||||
{
|
||||
"build" : ".",
|
||||
"source" : "."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"cmake" :
|
||||
{
|
||||
"generator" :
|
||||
{
|
||||
"multiConfig" : false,
|
||||
"name" : "Unix Makefiles"
|
||||
},
|
||||
"paths" :
|
||||
{
|
||||
"cmake" : "/home/cc/anaconda3/envs/ros_flexiv/bin/cmake",
|
||||
"cpack" : "/home/cc/anaconda3/envs/ros_flexiv/bin/cpack",
|
||||
"ctest" : "/home/cc/anaconda3/envs/ros_flexiv/bin/ctest",
|
||||
"root" : "/home/cc/anaconda3/envs/ros_flexiv/share/cmake-4.3"
|
||||
},
|
||||
"version" :
|
||||
{
|
||||
"isDirty" : false,
|
||||
"major" : 4,
|
||||
"minor" : 3,
|
||||
"patch" : 1,
|
||||
"string" : "4.3.1",
|
||||
"suffix" : ""
|
||||
}
|
||||
},
|
||||
"objects" :
|
||||
[
|
||||
{
|
||||
"jsonFile" : "codemodel-v2-ecb16a25d84b0d712f2b.json",
|
||||
"kind" : "codemodel",
|
||||
"version" :
|
||||
{
|
||||
"major" : 2,
|
||||
"minor" : 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"reply" :
|
||||
{
|
||||
"client-colcon-cmake" :
|
||||
{
|
||||
"codemodel-v2" :
|
||||
{
|
||||
"jsonFile" : "codemodel-v2-ecb16a25d84b0d712f2b.json",
|
||||
"kind" : "codemodel",
|
||||
"version" :
|
||||
{
|
||||
"major" : 2,
|
||||
"minor" : 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"abstract" : true,
|
||||
"artifacts" :
|
||||
[
|
||||
{
|
||||
"path" : "/home/cc/anaconda3/envs/ros_flexiv/bin/python3"
|
||||
}
|
||||
],
|
||||
"backtrace" : 17,
|
||||
"backtraceGraph" :
|
||||
{
|
||||
"commands" :
|
||||
[
|
||||
"add_executable",
|
||||
"include",
|
||||
"find_package"
|
||||
],
|
||||
"files" :
|
||||
[
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/cmake-4.3/Modules/FindPython/Support.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/cmake-4.3/Modules/FindPython3.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/core/python.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/core/all.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/ament_cmake_core-extras.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/ament_cmake_coreConfig.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake/cmake/ament_cmake_export_dependencies-extras.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake/cmake/ament_cmakeConfig.cmake",
|
||||
"CMakeLists.txt"
|
||||
],
|
||||
"nodes" :
|
||||
[
|
||||
{
|
||||
"file" : 8
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 8,
|
||||
"line" : 4,
|
||||
"parent" : 0
|
||||
},
|
||||
{
|
||||
"file" : 7,
|
||||
"parent" : 1
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 7,
|
||||
"line" : 41,
|
||||
"parent" : 2
|
||||
},
|
||||
{
|
||||
"file" : 6,
|
||||
"parent" : 3
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 6,
|
||||
"line" : 15,
|
||||
"parent" : 4
|
||||
},
|
||||
{
|
||||
"file" : 5,
|
||||
"parent" : 5
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 5,
|
||||
"line" : 41,
|
||||
"parent" : 6
|
||||
},
|
||||
{
|
||||
"file" : 4,
|
||||
"parent" : 7
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 4,
|
||||
"line" : 17,
|
||||
"parent" : 8
|
||||
},
|
||||
{
|
||||
"file" : 3,
|
||||
"parent" : 9
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 3,
|
||||
"line" : 54,
|
||||
"parent" : 10
|
||||
},
|
||||
{
|
||||
"file" : 2,
|
||||
"parent" : 11
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 2,
|
||||
"line" : 37,
|
||||
"parent" : 12
|
||||
},
|
||||
{
|
||||
"file" : 1,
|
||||
"parent" : 13
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 1,
|
||||
"line" : 671,
|
||||
"parent" : 14
|
||||
},
|
||||
{
|
||||
"file" : 0,
|
||||
"parent" : 15
|
||||
},
|
||||
{
|
||||
"command" : 0,
|
||||
"file" : 0,
|
||||
"line" : 4348,
|
||||
"parent" : 16
|
||||
}
|
||||
]
|
||||
},
|
||||
"codemodelVersion" :
|
||||
{
|
||||
"major" : 2,
|
||||
"minor" : 10
|
||||
},
|
||||
"id" : "Python3::Interpreter::@6890427a1f51a3e7e1df",
|
||||
"imported" : true,
|
||||
"local" : true,
|
||||
"name" : "Python3::Interpreter",
|
||||
"nameOnDisk" : "python3",
|
||||
"paths" :
|
||||
{
|
||||
"build" : ".",
|
||||
"source" : "."
|
||||
},
|
||||
"sources" : [],
|
||||
"type" : "EXECUTABLE"
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"abstract" : true,
|
||||
"artifacts" :
|
||||
[
|
||||
{
|
||||
"path" : "/home/cc/anaconda3/envs/ros_flexiv/bin/python3"
|
||||
}
|
||||
],
|
||||
"backtrace" : 17,
|
||||
"backtraceGraph" :
|
||||
{
|
||||
"commands" :
|
||||
[
|
||||
"add_executable",
|
||||
"include",
|
||||
"find_package"
|
||||
],
|
||||
"files" :
|
||||
[
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/cmake-4.3/Modules/FindPython/Support.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/cmake-4.3/Modules/FindPython3.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/core/python.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/core/all.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/ament_cmake_core-extras.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/ament_cmake_coreConfig.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake/cmake/ament_cmake_export_dependencies-extras.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake/cmake/ament_cmakeConfig.cmake",
|
||||
"CMakeLists.txt"
|
||||
],
|
||||
"nodes" :
|
||||
[
|
||||
{
|
||||
"file" : 8
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 8,
|
||||
"line" : 4,
|
||||
"parent" : 0
|
||||
},
|
||||
{
|
||||
"file" : 7,
|
||||
"parent" : 1
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 7,
|
||||
"line" : 41,
|
||||
"parent" : 2
|
||||
},
|
||||
{
|
||||
"file" : 6,
|
||||
"parent" : 3
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 6,
|
||||
"line" : 15,
|
||||
"parent" : 4
|
||||
},
|
||||
{
|
||||
"file" : 5,
|
||||
"parent" : 5
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 5,
|
||||
"line" : 41,
|
||||
"parent" : 6
|
||||
},
|
||||
{
|
||||
"file" : 4,
|
||||
"parent" : 7
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 4,
|
||||
"line" : 17,
|
||||
"parent" : 8
|
||||
},
|
||||
{
|
||||
"file" : 3,
|
||||
"parent" : 9
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 3,
|
||||
"line" : 54,
|
||||
"parent" : 10
|
||||
},
|
||||
{
|
||||
"file" : 2,
|
||||
"parent" : 11
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 2,
|
||||
"line" : 37,
|
||||
"parent" : 12
|
||||
},
|
||||
{
|
||||
"file" : 1,
|
||||
"parent" : 13
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 1,
|
||||
"line" : 671,
|
||||
"parent" : 14
|
||||
},
|
||||
{
|
||||
"file" : 0,
|
||||
"parent" : 15
|
||||
},
|
||||
{
|
||||
"command" : 0,
|
||||
"file" : 0,
|
||||
"line" : 4358,
|
||||
"parent" : 16
|
||||
}
|
||||
]
|
||||
},
|
||||
"codemodelVersion" :
|
||||
{
|
||||
"major" : 2,
|
||||
"minor" : 10
|
||||
},
|
||||
"id" : "Python3::InterpreterMultiConfig::@6890427a1f51a3e7e1df",
|
||||
"imported" : true,
|
||||
"local" : true,
|
||||
"name" : "Python3::InterpreterMultiConfig",
|
||||
"nameOnDisk" : "python3",
|
||||
"paths" :
|
||||
{
|
||||
"build" : ".",
|
||||
"source" : "."
|
||||
},
|
||||
"sources" : [],
|
||||
"type" : "EXECUTABLE"
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"backtrace" : 9,
|
||||
"backtraceGraph" :
|
||||
{
|
||||
"commands" :
|
||||
[
|
||||
"add_custom_target",
|
||||
"include",
|
||||
"find_package"
|
||||
],
|
||||
"files" :
|
||||
[
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/ament_cmake_uninstall_target-extras.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/ament_cmake_coreConfig.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake/cmake/ament_cmake_export_dependencies-extras.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake/cmake/ament_cmakeConfig.cmake",
|
||||
"CMakeLists.txt"
|
||||
],
|
||||
"nodes" :
|
||||
[
|
||||
{
|
||||
"file" : 4
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 4,
|
||||
"line" : 4,
|
||||
"parent" : 0
|
||||
},
|
||||
{
|
||||
"file" : 3,
|
||||
"parent" : 1
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 3,
|
||||
"line" : 41,
|
||||
"parent" : 2
|
||||
},
|
||||
{
|
||||
"file" : 2,
|
||||
"parent" : 3
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 2,
|
||||
"line" : 15,
|
||||
"parent" : 4
|
||||
},
|
||||
{
|
||||
"file" : 1,
|
||||
"parent" : 5
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 1,
|
||||
"line" : 41,
|
||||
"parent" : 6
|
||||
},
|
||||
{
|
||||
"file" : 0,
|
||||
"parent" : 7
|
||||
},
|
||||
{
|
||||
"command" : 0,
|
||||
"file" : 0,
|
||||
"line" : 40,
|
||||
"parent" : 8
|
||||
}
|
||||
]
|
||||
},
|
||||
"codemodelVersion" :
|
||||
{
|
||||
"major" : 2,
|
||||
"minor" : 10
|
||||
},
|
||||
"id" : "arm_bringup_uninstall::@6890427a1f51a3e7e1df",
|
||||
"name" : "arm_bringup_uninstall",
|
||||
"paths" :
|
||||
{
|
||||
"build" : ".",
|
||||
"source" : "."
|
||||
},
|
||||
"sourceGroups" :
|
||||
[
|
||||
{
|
||||
"name" : "",
|
||||
"sourceIndexes" :
|
||||
[
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"name" : "CMake Rules",
|
||||
"sourceIndexes" :
|
||||
[
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"sources" :
|
||||
[
|
||||
{
|
||||
"backtrace" : 9,
|
||||
"isGenerated" : true,
|
||||
"path" : "/home/cc/ros2/build/arm_bringup/CMakeFiles/arm_bringup_uninstall",
|
||||
"sourceGroupIndex" : 0
|
||||
},
|
||||
{
|
||||
"backtrace" : 0,
|
||||
"isGenerated" : true,
|
||||
"path" : "/home/cc/ros2/build/arm_bringup/CMakeFiles/arm_bringup_uninstall.rule",
|
||||
"sourceGroupIndex" : 1
|
||||
}
|
||||
],
|
||||
"type" : "UTILITY"
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"backtrace" : 9,
|
||||
"backtraceGraph" :
|
||||
{
|
||||
"commands" :
|
||||
[
|
||||
"add_custom_target",
|
||||
"include",
|
||||
"find_package",
|
||||
"add_dependencies"
|
||||
],
|
||||
"files" :
|
||||
[
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/ament_cmake_uninstall_target-extras.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake/ament_cmake_coreConfig.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake/cmake/ament_cmake_export_dependencies-extras.cmake",
|
||||
"/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake/cmake/ament_cmakeConfig.cmake",
|
||||
"CMakeLists.txt"
|
||||
],
|
||||
"nodes" :
|
||||
[
|
||||
{
|
||||
"file" : 4
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 4,
|
||||
"line" : 4,
|
||||
"parent" : 0
|
||||
},
|
||||
{
|
||||
"file" : 3,
|
||||
"parent" : 1
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 3,
|
||||
"line" : 41,
|
||||
"parent" : 2
|
||||
},
|
||||
{
|
||||
"file" : 2,
|
||||
"parent" : 3
|
||||
},
|
||||
{
|
||||
"command" : 2,
|
||||
"file" : 2,
|
||||
"line" : 15,
|
||||
"parent" : 4
|
||||
},
|
||||
{
|
||||
"file" : 1,
|
||||
"parent" : 5
|
||||
},
|
||||
{
|
||||
"command" : 1,
|
||||
"file" : 1,
|
||||
"line" : 41,
|
||||
"parent" : 6
|
||||
},
|
||||
{
|
||||
"file" : 0,
|
||||
"parent" : 7
|
||||
},
|
||||
{
|
||||
"command" : 0,
|
||||
"file" : 0,
|
||||
"line" : 35,
|
||||
"parent" : 8
|
||||
},
|
||||
{
|
||||
"command" : 3,
|
||||
"file" : 0,
|
||||
"line" : 42,
|
||||
"parent" : 8
|
||||
}
|
||||
]
|
||||
},
|
||||
"codemodelVersion" :
|
||||
{
|
||||
"major" : 2,
|
||||
"minor" : 10
|
||||
},
|
||||
"dependencies" :
|
||||
[
|
||||
{
|
||||
"backtrace" : 10,
|
||||
"id" : "arm_bringup_uninstall::@6890427a1f51a3e7e1df"
|
||||
}
|
||||
],
|
||||
"id" : "uninstall::@6890427a1f51a3e7e1df",
|
||||
"name" : "uninstall",
|
||||
"orderDependencies" :
|
||||
[
|
||||
{
|
||||
"backtrace" : 10,
|
||||
"id" : "arm_bringup_uninstall::@6890427a1f51a3e7e1df"
|
||||
}
|
||||
],
|
||||
"paths" :
|
||||
{
|
||||
"build" : ".",
|
||||
"source" : "."
|
||||
},
|
||||
"sources" : [],
|
||||
"type" : "UTILITY"
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
# This is the CMakeCache file.
|
||||
# For build in directory: /home/cc/ros2/build/arm_bringup
|
||||
# It was generated by CMake: /home/cc/anaconda3/envs/ros_flexiv/bin/cmake
|
||||
# You can edit this file to change values found and used by cmake.
|
||||
# If you do not want to change any of the values, simply exit the editor.
|
||||
# If you do want to change a value, simply edit, save, and exit the editor.
|
||||
# The syntax for the file is as follows:
|
||||
# KEY:TYPE=VALUE
|
||||
# KEY is the name of a variable in the cache.
|
||||
# TYPE is a hint to GUIs for the type of VALUE, DO NOT EDIT TYPE!.
|
||||
# VALUE is the current value for the KEY.
|
||||
|
||||
########################
|
||||
# EXTERNAL cache entries
|
||||
########################
|
||||
|
||||
//Generate environment files in the CMAKE_INSTALL_PREFIX
|
||||
AMENT_CMAKE_ENVIRONMENT_GENERATION:BOOL=OFF
|
||||
|
||||
//Generate environment files in the package share folder
|
||||
AMENT_CMAKE_ENVIRONMENT_PACKAGE_GENERATION:BOOL=ON
|
||||
|
||||
//Generate marker file containing the parent prefix path
|
||||
AMENT_CMAKE_ENVIRONMENT_PARENT_PREFIX_PATH_GENERATION:BOOL=ON
|
||||
|
||||
//Replace the CMake install command with a custom implementation
|
||||
// using symlinks instead of copying resources
|
||||
AMENT_CMAKE_SYMLINK_INSTALL:BOOL=1
|
||||
|
||||
//Generate an uninstall target to revert the effects of the install
|
||||
// step
|
||||
AMENT_CMAKE_UNINSTALL_TARGET:BOOL=ON
|
||||
|
||||
//The path where test results are generated
|
||||
AMENT_TEST_RESULTS_DIR:PATH=/home/cc/ros2/build/arm_bringup/test_results
|
||||
|
||||
//Build the testing tree.
|
||||
BUILD_TESTING:BOOL=ON
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_ADDR2LINE:FILEPATH=/usr/bin/addr2line
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_AR:FILEPATH=/usr/bin/ar
|
||||
|
||||
//Choose the type of build, options are: None Debug Release RelWithDebInfo
|
||||
// MinSizeRel ...
|
||||
CMAKE_BUILD_TYPE:STRING=
|
||||
|
||||
//Enable/Disable color output during build.
|
||||
CMAKE_COLOR_MAKEFILE:BOOL=ON
|
||||
|
||||
//CXX compiler
|
||||
CMAKE_CXX_COMPILER:FILEPATH=/home/cc/anaconda3/envs/ros_flexiv/bin/c++
|
||||
|
||||
//A wrapper around 'ar' adding the appropriate '--plugin' option
|
||||
// for the GCC compiler
|
||||
CMAKE_CXX_COMPILER_AR:FILEPATH=/home/cc/anaconda3/envs/ros_flexiv/bin/gcc-ar
|
||||
|
||||
//A wrapper around 'ranlib' adding the appropriate '--plugin' option
|
||||
// for the GCC compiler
|
||||
CMAKE_CXX_COMPILER_RANLIB:FILEPATH=/home/cc/anaconda3/envs/ros_flexiv/bin/gcc-ranlib
|
||||
|
||||
//Flags used by the CXX compiler during all build types.
|
||||
CMAKE_CXX_FLAGS:STRING=
|
||||
|
||||
//Flags used by the CXX compiler during DEBUG builds.
|
||||
CMAKE_CXX_FLAGS_DEBUG:STRING=-g
|
||||
|
||||
//Flags used by the CXX compiler during MINSIZEREL builds.
|
||||
CMAKE_CXX_FLAGS_MINSIZEREL:STRING=-Os -DNDEBUG
|
||||
|
||||
//Flags used by the CXX compiler during RELEASE builds.
|
||||
CMAKE_CXX_FLAGS_RELEASE:STRING=-O3 -DNDEBUG
|
||||
|
||||
//Flags used by the CXX compiler during RELWITHDEBINFO builds.
|
||||
CMAKE_CXX_FLAGS_RELWITHDEBINFO:STRING=-O2 -g -DNDEBUG
|
||||
|
||||
//C compiler
|
||||
CMAKE_C_COMPILER:FILEPATH=/home/cc/anaconda3/envs/ros_flexiv/bin/cc
|
||||
|
||||
//A wrapper around 'ar' adding the appropriate '--plugin' option
|
||||
// for the GCC compiler
|
||||
CMAKE_C_COMPILER_AR:FILEPATH=/home/cc/anaconda3/envs/ros_flexiv/bin/gcc-ar
|
||||
|
||||
//A wrapper around 'ranlib' adding the appropriate '--plugin' option
|
||||
// for the GCC compiler
|
||||
CMAKE_C_COMPILER_RANLIB:FILEPATH=/home/cc/anaconda3/envs/ros_flexiv/bin/gcc-ranlib
|
||||
|
||||
//Flags used by the C compiler during all build types.
|
||||
CMAKE_C_FLAGS:STRING=
|
||||
|
||||
//Flags used by the C compiler during DEBUG builds.
|
||||
CMAKE_C_FLAGS_DEBUG:STRING=-g
|
||||
|
||||
//Flags used by the C compiler during MINSIZEREL builds.
|
||||
CMAKE_C_FLAGS_MINSIZEREL:STRING=-Os -DNDEBUG
|
||||
|
||||
//Flags used by the C compiler during RELEASE builds.
|
||||
CMAKE_C_FLAGS_RELEASE:STRING=-O3 -DNDEBUG
|
||||
|
||||
//Flags used by the C compiler during RELWITHDEBINFO builds.
|
||||
CMAKE_C_FLAGS_RELWITHDEBINFO:STRING=-O2 -g -DNDEBUG
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_DLLTOOL:FILEPATH=CMAKE_DLLTOOL-NOTFOUND
|
||||
|
||||
//Flags used by the linker during all build types.
|
||||
CMAKE_EXE_LINKER_FLAGS:STRING=
|
||||
|
||||
//Flags used by the linker during DEBUG builds.
|
||||
CMAKE_EXE_LINKER_FLAGS_DEBUG:STRING=
|
||||
|
||||
//Flags used by the linker during MINSIZEREL builds.
|
||||
CMAKE_EXE_LINKER_FLAGS_MINSIZEREL:STRING=
|
||||
|
||||
//Flags used by the linker during RELEASE builds.
|
||||
CMAKE_EXE_LINKER_FLAGS_RELEASE:STRING=
|
||||
|
||||
//Flags used by the linker during RELWITHDEBINFO builds.
|
||||
CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO:STRING=
|
||||
|
||||
//Enable/Disable output of compile commands during generation.
|
||||
CMAKE_EXPORT_COMPILE_COMMANDS:BOOL=
|
||||
|
||||
//Value Computed by CMake.
|
||||
CMAKE_FIND_PACKAGE_REDIRECTS_DIR:STATIC=/home/cc/ros2/build/arm_bringup/CMakeFiles/pkgRedirects
|
||||
|
||||
//Install path prefix, prepended onto install directories.
|
||||
CMAKE_INSTALL_PREFIX:PATH=/home/cc/ros2/install/arm_bringup
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_LINKER:FILEPATH=/usr/bin/ld
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_MAKE_PROGRAM:FILEPATH=/usr/bin/gmake
|
||||
|
||||
//Flags used by the linker during the creation of modules during
|
||||
// all build types.
|
||||
CMAKE_MODULE_LINKER_FLAGS:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of modules during
|
||||
// DEBUG builds.
|
||||
CMAKE_MODULE_LINKER_FLAGS_DEBUG:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of modules during
|
||||
// MINSIZEREL builds.
|
||||
CMAKE_MODULE_LINKER_FLAGS_MINSIZEREL:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of modules during
|
||||
// RELEASE builds.
|
||||
CMAKE_MODULE_LINKER_FLAGS_RELEASE:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of modules during
|
||||
// RELWITHDEBINFO builds.
|
||||
CMAKE_MODULE_LINKER_FLAGS_RELWITHDEBINFO:STRING=
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_NM:FILEPATH=/usr/bin/nm
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_OBJCOPY:FILEPATH=/usr/bin/objcopy
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_OBJDUMP:FILEPATH=/usr/bin/objdump
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_COMPAT_VERSION:STATIC=
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_DESCRIPTION:STATIC=
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_HOMEPAGE_URL:STATIC=
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_NAME:STATIC=arm_bringup
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_SPDX_LICENSE:STATIC=
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_RANLIB:FILEPATH=/usr/bin/ranlib
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_READELF:FILEPATH=/usr/bin/readelf
|
||||
|
||||
//Flags used by the linker during the creation of shared libraries
|
||||
// during all build types.
|
||||
CMAKE_SHARED_LINKER_FLAGS:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of shared libraries
|
||||
// during DEBUG builds.
|
||||
CMAKE_SHARED_LINKER_FLAGS_DEBUG:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of shared libraries
|
||||
// during MINSIZEREL builds.
|
||||
CMAKE_SHARED_LINKER_FLAGS_MINSIZEREL:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of shared libraries
|
||||
// during RELEASE builds.
|
||||
CMAKE_SHARED_LINKER_FLAGS_RELEASE:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of shared libraries
|
||||
// during RELWITHDEBINFO builds.
|
||||
CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO:STRING=
|
||||
|
||||
//If set, runtime paths are not added when installing shared libraries,
|
||||
// but are added when building.
|
||||
CMAKE_SKIP_INSTALL_RPATH:BOOL=NO
|
||||
|
||||
//If set, runtime paths are not added when using shared libraries.
|
||||
CMAKE_SKIP_RPATH:BOOL=NO
|
||||
|
||||
//Flags used by the archiver during the creation of static libraries
|
||||
// during all build types.
|
||||
CMAKE_STATIC_LINKER_FLAGS:STRING=
|
||||
|
||||
//Flags used by the archiver during the creation of static libraries
|
||||
// during DEBUG builds.
|
||||
CMAKE_STATIC_LINKER_FLAGS_DEBUG:STRING=
|
||||
|
||||
//Flags used by the archiver during the creation of static libraries
|
||||
// during MINSIZEREL builds.
|
||||
CMAKE_STATIC_LINKER_FLAGS_MINSIZEREL:STRING=
|
||||
|
||||
//Flags used by the archiver during the creation of static libraries
|
||||
// during RELEASE builds.
|
||||
CMAKE_STATIC_LINKER_FLAGS_RELEASE:STRING=
|
||||
|
||||
//Flags used by the archiver during the creation of static libraries
|
||||
// during RELWITHDEBINFO builds.
|
||||
CMAKE_STATIC_LINKER_FLAGS_RELWITHDEBINFO:STRING=
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_STRIP:FILEPATH=/usr/bin/strip
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_TAPI:FILEPATH=CMAKE_TAPI-NOTFOUND
|
||||
|
||||
//If this value is on, makefiles will be generated without the
|
||||
// .SILENT directive, and all commands will be echoed to the console
|
||||
// during the make. This is useful for debugging only. With Visual
|
||||
// Studio IDE projects all commands are done without /nologo.
|
||||
CMAKE_VERBOSE_MAKEFILE:BOOL=FALSE
|
||||
|
||||
//Path to a program.
|
||||
Python3_EXECUTABLE:FILEPATH=/home/cc/anaconda3/envs/ros_flexiv/bin/python3
|
||||
|
||||
//Name of the computer/site where compile is being run
|
||||
SITE:STRING=cc
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake.
|
||||
ament_cmake_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_core.
|
||||
ament_cmake_core_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_core/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_export_definitions.
|
||||
ament_cmake_export_definitions_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_export_definitions/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_export_dependencies.
|
||||
ament_cmake_export_dependencies_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_export_dependencies/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_export_include_directories.
|
||||
ament_cmake_export_include_directories_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_export_include_directories/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_export_interfaces.
|
||||
ament_cmake_export_interfaces_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_export_interfaces/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_export_libraries.
|
||||
ament_cmake_export_libraries_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_export_libraries/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_export_link_flags.
|
||||
ament_cmake_export_link_flags_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_export_link_flags/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_export_targets.
|
||||
ament_cmake_export_targets_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_export_targets/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_gen_version_h.
|
||||
ament_cmake_gen_version_h_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_gen_version_h/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_include_directories.
|
||||
ament_cmake_include_directories_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_include_directories/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_libraries.
|
||||
ament_cmake_libraries_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_libraries/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_python.
|
||||
ament_cmake_python_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_python/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_target_dependencies.
|
||||
ament_cmake_target_dependencies_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_target_dependencies/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_test.
|
||||
ament_cmake_test_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_test/cmake
|
||||
|
||||
//The directory containing a CMake configuration file for ament_cmake_version.
|
||||
ament_cmake_version_DIR:PATH=/home/cc/anaconda3/envs/ros_flexiv/share/ament_cmake_version/cmake
|
||||
|
||||
//Value Computed by CMake
|
||||
arm_bringup_BINARY_DIR:STATIC=/home/cc/ros2/build/arm_bringup
|
||||
|
||||
//Value Computed by CMake
|
||||
arm_bringup_IS_TOP_LEVEL:STATIC=ON
|
||||
|
||||
//Value Computed by CMake
|
||||
arm_bringup_SOURCE_DIR:STATIC=/home/cc/ros2/src/arm_bringup
|
||||
|
||||
|
||||
########################
|
||||
# INTERNAL cache entries
|
||||
########################
|
||||
|
||||
//ADVANCED property for variable: CMAKE_ADDR2LINE
|
||||
CMAKE_ADDR2LINE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_AR
|
||||
CMAKE_AR-ADVANCED:INTERNAL=1
|
||||
//This is the directory where this CMakeCache.txt was created
|
||||
CMAKE_CACHEFILE_DIR:INTERNAL=/home/cc/ros2/build/arm_bringup
|
||||
//Major version of cmake used to create the current loaded cache
|
||||
CMAKE_CACHE_MAJOR_VERSION:INTERNAL=4
|
||||
//Minor version of cmake used to create the current loaded cache
|
||||
CMAKE_CACHE_MINOR_VERSION:INTERNAL=3
|
||||
//Patch version of cmake used to create the current loaded cache
|
||||
CMAKE_CACHE_PATCH_VERSION:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_COLOR_MAKEFILE
|
||||
CMAKE_COLOR_MAKEFILE-ADVANCED:INTERNAL=1
|
||||
//Path to CMake executable.
|
||||
CMAKE_COMMAND:INTERNAL=/home/cc/anaconda3/envs/ros_flexiv/bin/cmake
|
||||
//Path to cpack program executable.
|
||||
CMAKE_CPACK_COMMAND:INTERNAL=/home/cc/anaconda3/envs/ros_flexiv/bin/cpack
|
||||
//Path to ctest program executable.
|
||||
CMAKE_CTEST_COMMAND:INTERNAL=/home/cc/anaconda3/envs/ros_flexiv/bin/ctest
|
||||
//ADVANCED property for variable: CMAKE_CXX_COMPILER
|
||||
CMAKE_CXX_COMPILER-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_CXX_COMPILER_AR
|
||||
CMAKE_CXX_COMPILER_AR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_CXX_COMPILER_RANLIB
|
||||
CMAKE_CXX_COMPILER_RANLIB-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_CXX_FLAGS
|
||||
CMAKE_CXX_FLAGS-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_CXX_FLAGS_DEBUG
|
||||
CMAKE_CXX_FLAGS_DEBUG-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_CXX_FLAGS_MINSIZEREL
|
||||
CMAKE_CXX_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_CXX_FLAGS_RELEASE
|
||||
CMAKE_CXX_FLAGS_RELEASE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_CXX_FLAGS_RELWITHDEBINFO
|
||||
CMAKE_CXX_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_COMPILER
|
||||
CMAKE_C_COMPILER-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_COMPILER_AR
|
||||
CMAKE_C_COMPILER_AR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_COMPILER_RANLIB
|
||||
CMAKE_C_COMPILER_RANLIB-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_FLAGS
|
||||
CMAKE_C_FLAGS-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_FLAGS_DEBUG
|
||||
CMAKE_C_FLAGS_DEBUG-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_FLAGS_MINSIZEREL
|
||||
CMAKE_C_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_FLAGS_RELEASE
|
||||
CMAKE_C_FLAGS_RELEASE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_FLAGS_RELWITHDEBINFO
|
||||
CMAKE_C_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_DLLTOOL
|
||||
CMAKE_DLLTOOL-ADVANCED:INTERNAL=1
|
||||
//Path to cache edit program executable.
|
||||
CMAKE_EDIT_COMMAND:INTERNAL=/home/cc/anaconda3/envs/ros_flexiv/bin/ccmake
|
||||
//Executable file format
|
||||
CMAKE_EXECUTABLE_FORMAT:INTERNAL=ELF
|
||||
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS
|
||||
CMAKE_EXE_LINKER_FLAGS-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_DEBUG
|
||||
CMAKE_EXE_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_MINSIZEREL
|
||||
CMAKE_EXE_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_RELEASE
|
||||
CMAKE_EXE_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO
|
||||
CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_EXPORT_COMPILE_COMMANDS
|
||||
CMAKE_EXPORT_COMPILE_COMMANDS-ADVANCED:INTERNAL=1
|
||||
//Name of external makefile project generator.
|
||||
CMAKE_EXTRA_GENERATOR:INTERNAL=
|
||||
//Name of generator.
|
||||
CMAKE_GENERATOR:INTERNAL=Unix Makefiles
|
||||
//Generator instance identifier.
|
||||
CMAKE_GENERATOR_INSTANCE:INTERNAL=
|
||||
//Name of generator platform.
|
||||
CMAKE_GENERATOR_PLATFORM:INTERNAL=
|
||||
//Name of generator toolset.
|
||||
CMAKE_GENERATOR_TOOLSET:INTERNAL=
|
||||
//Source directory with the top level CMakeLists.txt file for this
|
||||
// project
|
||||
CMAKE_HOME_DIRECTORY:INTERNAL=/home/cc/ros2/src/arm_bringup
|
||||
//Install .so files without execute permission.
|
||||
CMAKE_INSTALL_SO_NO_EXE:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_LINKER
|
||||
CMAKE_LINKER-ADVANCED:INTERNAL=1
|
||||
//Name of CMakeLists files to read
|
||||
CMAKE_LIST_FILE_NAME:INTERNAL=CMakeLists.txt
|
||||
//ADVANCED property for variable: CMAKE_MAKE_PROGRAM
|
||||
CMAKE_MAKE_PROGRAM-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS
|
||||
CMAKE_MODULE_LINKER_FLAGS-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_DEBUG
|
||||
CMAKE_MODULE_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_MINSIZEREL
|
||||
CMAKE_MODULE_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_RELEASE
|
||||
CMAKE_MODULE_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_RELWITHDEBINFO
|
||||
CMAKE_MODULE_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_NM
|
||||
CMAKE_NM-ADVANCED:INTERNAL=1
|
||||
//number of local generators
|
||||
CMAKE_NUMBER_OF_MAKEFILES:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_OBJCOPY
|
||||
CMAKE_OBJCOPY-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_OBJDUMP
|
||||
CMAKE_OBJDUMP-ADVANCED:INTERNAL=1
|
||||
//Platform information initialized
|
||||
CMAKE_PLATFORM_INFO_INITIALIZED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_RANLIB
|
||||
CMAKE_RANLIB-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_READELF
|
||||
CMAKE_READELF-ADVANCED:INTERNAL=1
|
||||
//Path to CMake installation.
|
||||
CMAKE_ROOT:INTERNAL=/home/cc/anaconda3/envs/ros_flexiv/share/cmake-4.3
|
||||
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS
|
||||
CMAKE_SHARED_LINKER_FLAGS-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_DEBUG
|
||||
CMAKE_SHARED_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_MINSIZEREL
|
||||
CMAKE_SHARED_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_RELEASE
|
||||
CMAKE_SHARED_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO
|
||||
CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SKIP_INSTALL_RPATH
|
||||
CMAKE_SKIP_INSTALL_RPATH-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SKIP_RPATH
|
||||
CMAKE_SKIP_RPATH-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS
|
||||
CMAKE_STATIC_LINKER_FLAGS-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_DEBUG
|
||||
CMAKE_STATIC_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_MINSIZEREL
|
||||
CMAKE_STATIC_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_RELEASE
|
||||
CMAKE_STATIC_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_RELWITHDEBINFO
|
||||
CMAKE_STATIC_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STRIP
|
||||
CMAKE_STRIP-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_TAPI
|
||||
CMAKE_TAPI-ADVANCED:INTERNAL=1
|
||||
//uname command
|
||||
CMAKE_UNAME:INTERNAL=/usr/bin/uname
|
||||
//ADVANCED property for variable: CMAKE_VERBOSE_MAKEFILE
|
||||
CMAKE_VERBOSE_MAKEFILE-ADVANCED:INTERNAL=1
|
||||
//Details about finding Python3
|
||||
FIND_PACKAGE_MESSAGE_DETAILS_Python3:INTERNAL=[/home/cc/anaconda3/envs/ros_flexiv/bin/python3][found components: Interpreter ][v3.11.15()]
|
||||
//Compiler reason failure
|
||||
_Python3_Compiler_REASON_FAILURE:INTERNAL=
|
||||
//Development reason failure
|
||||
_Python3_Development_REASON_FAILURE:INTERNAL=
|
||||
_Python3_EXECUTABLE:INTERNAL=/home/cc/anaconda3/envs/ros_flexiv/bin/python3
|
||||
//Python3 Properties
|
||||
_Python3_INTERPRETER_PROPERTIES:INTERNAL=Python;3;11;15;64;32;<none>;cpython-311-x86_64-linux-gnu;abi3;/home/cc/anaconda3/envs/ros_flexiv/lib/python3.11;/home/cc/anaconda3/envs/ros_flexiv/lib/python3.11;/home/cc/anaconda3/envs/ros_flexiv/lib/python3.11/site-packages;/home/cc/anaconda3/envs/ros_flexiv/lib/python3.11/site-packages
|
||||
_Python3_INTERPRETER_SIGNATURE:INTERNAL=f769a1428c9a8a018e677a931e5fbcc4
|
||||
//NumPy reason failure
|
||||
_Python3_NumPy_REASON_FAILURE:INTERNAL=
|
||||
//Index for unique symlink install targets
|
||||
__AMENT_CMAKE_SYMLINK_INSTALL_TARGETS_INDEX:INTERNAL=0
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
set(CMAKE_C_COMPILER "/home/cc/anaconda3/envs/ros_flexiv/bin/cc")
|
||||
set(CMAKE_C_COMPILER_ARG1 "")
|
||||
set(CMAKE_C_COMPILER_ID "GNU")
|
||||
set(CMAKE_C_COMPILER_VERSION "15.2.0")
|
||||
set(CMAKE_C_COMPILER_VERSION_INTERNAL "")
|
||||
set(CMAKE_C_COMPILER_WRAPPER "")
|
||||
set(CMAKE_C_STANDARD_COMPUTED_DEFAULT "23")
|
||||
set(CMAKE_C_EXTENSIONS_COMPUTED_DEFAULT "ON")
|
||||
set(CMAKE_C_STANDARD_LATEST "23")
|
||||
set(CMAKE_C_COMPILE_FEATURES "c_std_90;c_function_prototypes;c_std_99;c_restrict;c_variadic_macros;c_std_11;c_static_assert;c_std_17;c_std_23")
|
||||
set(CMAKE_C90_COMPILE_FEATURES "c_std_90;c_function_prototypes")
|
||||
set(CMAKE_C99_COMPILE_FEATURES "c_std_99;c_restrict;c_variadic_macros")
|
||||
set(CMAKE_C11_COMPILE_FEATURES "c_std_11;c_static_assert")
|
||||
set(CMAKE_C17_COMPILE_FEATURES "c_std_17")
|
||||
set(CMAKE_C23_COMPILE_FEATURES "c_std_23")
|
||||
|
||||
set(CMAKE_C_PLATFORM_ID "Linux")
|
||||
set(CMAKE_C_SIMULATE_ID "")
|
||||
set(CMAKE_C_COMPILER_FRONTEND_VARIANT "GNU")
|
||||
set(CMAKE_C_COMPILER_APPLE_SYSROOT "")
|
||||
set(CMAKE_C_SIMULATE_VERSION "")
|
||||
set(CMAKE_C_COMPILER_ARCHITECTURE_ID "x86_64")
|
||||
set(CMAKE_C_COMPILER_SYSROOT "/home/cc/anaconda3/envs/ros_flexiv/bin/../x86_64-conda-linux-gnu/sysroot/usr")
|
||||
set(CMAKE_COMPILER_SYSROOT "/home/cc/anaconda3/envs/ros_flexiv/bin/../x86_64-conda-linux-gnu/sysroot/usr")
|
||||
|
||||
|
||||
|
||||
set(CMAKE_AR "/usr/bin/ar")
|
||||
set(CMAKE_C_COMPILER_AR "/home/cc/anaconda3/envs/ros_flexiv/bin/gcc-ar")
|
||||
set(CMAKE_RANLIB "/usr/bin/ranlib")
|
||||
set(CMAKE_C_COMPILER_RANLIB "/home/cc/anaconda3/envs/ros_flexiv/bin/gcc-ranlib")
|
||||
set(CMAKE_LINKER "/usr/bin/ld")
|
||||
set(CMAKE_LINKER_LINK "")
|
||||
set(CMAKE_LINKER_LLD "")
|
||||
set(CMAKE_C_COMPILER_LINKER "/home/cc/anaconda3/envs/ros_flexiv/bin/../lib/gcc/x86_64-conda-linux-gnu/15.2.0/../../../../x86_64-conda-linux-gnu/bin/ld")
|
||||
set(CMAKE_C_COMPILER_LINKER_ID "GNU")
|
||||
set(CMAKE_C_COMPILER_LINKER_VERSION 2.44)
|
||||
set(CMAKE_C_COMPILER_LINKER_FRONTEND_VARIANT GNU)
|
||||
set(CMAKE_MT "")
|
||||
set(CMAKE_TAPI "CMAKE_TAPI-NOTFOUND")
|
||||
set(CMAKE_COMPILER_IS_GNUCC 1)
|
||||
set(CMAKE_C_COMPILER_LOADED 1)
|
||||
set(CMAKE_C_COMPILER_WORKS TRUE)
|
||||
set(CMAKE_C_ABI_COMPILED TRUE)
|
||||
|
||||
set(CMAKE_C_COMPILER_ENV_VAR "CC")
|
||||
|
||||
set(CMAKE_C_COMPILER_ID_RUN 1)
|
||||
set(CMAKE_C_SOURCE_FILE_EXTENSIONS c;m)
|
||||
set(CMAKE_C_IGNORE_EXTENSIONS h;H;o;O;obj;OBJ;def;DEF;rc;RC)
|
||||
set(CMAKE_C_LINKER_PREFERENCE 10)
|
||||
set(CMAKE_C_LINKER_DEPFILE_SUPPORTED TRUE)
|
||||
set(CMAKE_LINKER_PUSHPOP_STATE_SUPPORTED TRUE)
|
||||
set(CMAKE_C_LINKER_PUSHPOP_STATE_SUPPORTED TRUE)
|
||||
|
||||
# Save compiler ABI information.
|
||||
set(CMAKE_C_SIZEOF_DATA_PTR "8")
|
||||
set(CMAKE_C_COMPILER_ABI "ELF")
|
||||
set(CMAKE_C_BYTE_ORDER "LITTLE_ENDIAN")
|
||||
set(CMAKE_C_LIBRARY_ARCHITECTURE "")
|
||||
|
||||
if(CMAKE_C_SIZEOF_DATA_PTR)
|
||||
set(CMAKE_SIZEOF_VOID_P "${CMAKE_C_SIZEOF_DATA_PTR}")
|
||||
endif()
|
||||
|
||||
if(CMAKE_C_COMPILER_ABI)
|
||||
set(CMAKE_INTERNAL_PLATFORM_ABI "${CMAKE_C_COMPILER_ABI}")
|
||||
endif()
|
||||
|
||||
if(CMAKE_C_LIBRARY_ARCHITECTURE)
|
||||
set(CMAKE_LIBRARY_ARCHITECTURE "")
|
||||
endif()
|
||||
|
||||
set(CMAKE_C_CL_SHOWINCLUDES_PREFIX "")
|
||||
if(CMAKE_C_CL_SHOWINCLUDES_PREFIX)
|
||||
set(CMAKE_CL_SHOWINCLUDES_PREFIX "${CMAKE_C_CL_SHOWINCLUDES_PREFIX}")
|
||||
endif()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
set(CMAKE_C_IMPLICIT_INCLUDE_DIRECTORIES "/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include;/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include-fixed;/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include;/home/cc/anaconda3/envs/ros_flexiv/include")
|
||||
set(CMAKE_C_IMPLICIT_LINK_LIBRARIES "gcc;gcc_s;c;gcc;gcc_s")
|
||||
set(CMAKE_C_IMPLICIT_LINK_DIRECTORIES "/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0;/home/cc/anaconda3/envs/ros_flexiv/lib/gcc;/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/lib;/home/cc/anaconda3/envs/ros_flexiv/lib;/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/lib;/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/lib;/home/cc/anaconda3/envs/ros_flexiv/lib/stubs")
|
||||
set(CMAKE_C_IMPLICIT_LINK_FRAMEWORK_DIRECTORIES "")
|
||||
@@ -0,0 +1,103 @@
|
||||
set(CMAKE_CXX_COMPILER "/home/cc/anaconda3/envs/ros_flexiv/bin/c++")
|
||||
set(CMAKE_CXX_COMPILER_ARG1 "")
|
||||
set(CMAKE_CXX_COMPILER_ID "GNU")
|
||||
set(CMAKE_CXX_COMPILER_VERSION "15.2.0")
|
||||
set(CMAKE_CXX_COMPILER_VERSION_INTERNAL "")
|
||||
set(CMAKE_CXX_COMPILER_WRAPPER "")
|
||||
set(CMAKE_CXX_STANDARD_COMPUTED_DEFAULT "17")
|
||||
set(CMAKE_CXX_EXTENSIONS_COMPUTED_DEFAULT "ON")
|
||||
set(CMAKE_CXX_STANDARD_LATEST "26")
|
||||
set(CMAKE_CXX_COMPILE_FEATURES "cxx_std_98;cxx_template_template_parameters;cxx_std_11;cxx_alias_templates;cxx_alignas;cxx_alignof;cxx_attributes;cxx_auto_type;cxx_constexpr;cxx_decltype;cxx_decltype_incomplete_return_types;cxx_default_function_template_args;cxx_defaulted_functions;cxx_defaulted_move_initializers;cxx_delegating_constructors;cxx_deleted_functions;cxx_enum_forward_declarations;cxx_explicit_conversions;cxx_extended_friend_declarations;cxx_extern_templates;cxx_final;cxx_func_identifier;cxx_generalized_initializers;cxx_inheriting_constructors;cxx_inline_namespaces;cxx_lambdas;cxx_local_type_template_args;cxx_long_long_type;cxx_noexcept;cxx_nonstatic_member_init;cxx_nullptr;cxx_override;cxx_range_for;cxx_raw_string_literals;cxx_reference_qualified_functions;cxx_right_angle_brackets;cxx_rvalue_references;cxx_sizeof_member;cxx_static_assert;cxx_strong_enums;cxx_thread_local;cxx_trailing_return_types;cxx_unicode_literals;cxx_uniform_initialization;cxx_unrestricted_unions;cxx_user_literals;cxx_variadic_macros;cxx_variadic_templates;cxx_std_14;cxx_aggregate_default_initializers;cxx_attribute_deprecated;cxx_binary_literals;cxx_contextual_conversions;cxx_decltype_auto;cxx_digit_separators;cxx_generic_lambdas;cxx_lambda_init_captures;cxx_relaxed_constexpr;cxx_return_type_deduction;cxx_variable_templates;cxx_std_17;cxx_std_20;cxx_std_23;cxx_std_26")
|
||||
set(CMAKE_CXX98_COMPILE_FEATURES "cxx_std_98;cxx_template_template_parameters")
|
||||
set(CMAKE_CXX11_COMPILE_FEATURES "cxx_std_11;cxx_alias_templates;cxx_alignas;cxx_alignof;cxx_attributes;cxx_auto_type;cxx_constexpr;cxx_decltype;cxx_decltype_incomplete_return_types;cxx_default_function_template_args;cxx_defaulted_functions;cxx_defaulted_move_initializers;cxx_delegating_constructors;cxx_deleted_functions;cxx_enum_forward_declarations;cxx_explicit_conversions;cxx_extended_friend_declarations;cxx_extern_templates;cxx_final;cxx_func_identifier;cxx_generalized_initializers;cxx_inheriting_constructors;cxx_inline_namespaces;cxx_lambdas;cxx_local_type_template_args;cxx_long_long_type;cxx_noexcept;cxx_nonstatic_member_init;cxx_nullptr;cxx_override;cxx_range_for;cxx_raw_string_literals;cxx_reference_qualified_functions;cxx_right_angle_brackets;cxx_rvalue_references;cxx_sizeof_member;cxx_static_assert;cxx_strong_enums;cxx_thread_local;cxx_trailing_return_types;cxx_unicode_literals;cxx_uniform_initialization;cxx_unrestricted_unions;cxx_user_literals;cxx_variadic_macros;cxx_variadic_templates")
|
||||
set(CMAKE_CXX14_COMPILE_FEATURES "cxx_std_14;cxx_aggregate_default_initializers;cxx_attribute_deprecated;cxx_binary_literals;cxx_contextual_conversions;cxx_decltype_auto;cxx_digit_separators;cxx_generic_lambdas;cxx_lambda_init_captures;cxx_relaxed_constexpr;cxx_return_type_deduction;cxx_variable_templates")
|
||||
set(CMAKE_CXX17_COMPILE_FEATURES "cxx_std_17")
|
||||
set(CMAKE_CXX20_COMPILE_FEATURES "cxx_std_20")
|
||||
set(CMAKE_CXX23_COMPILE_FEATURES "cxx_std_23")
|
||||
set(CMAKE_CXX26_COMPILE_FEATURES "cxx_std_26")
|
||||
|
||||
set(CMAKE_CXX_PLATFORM_ID "Linux")
|
||||
set(CMAKE_CXX_SIMULATE_ID "")
|
||||
set(CMAKE_CXX_COMPILER_FRONTEND_VARIANT "GNU")
|
||||
set(CMAKE_CXX_COMPILER_APPLE_SYSROOT "")
|
||||
set(CMAKE_CXX_SIMULATE_VERSION "")
|
||||
set(CMAKE_CXX_COMPILER_ARCHITECTURE_ID "x86_64")
|
||||
set(CMAKE_CXX_COMPILER_SYSROOT "/home/cc/anaconda3/envs/ros_flexiv/bin/../x86_64-conda-linux-gnu/sysroot/usr")
|
||||
set(CMAKE_COMPILER_SYSROOT "/home/cc/anaconda3/envs/ros_flexiv/bin/../x86_64-conda-linux-gnu/sysroot/usr")
|
||||
|
||||
|
||||
|
||||
set(CMAKE_AR "/usr/bin/ar")
|
||||
set(CMAKE_CXX_COMPILER_AR "/home/cc/anaconda3/envs/ros_flexiv/bin/gcc-ar")
|
||||
set(CMAKE_RANLIB "/usr/bin/ranlib")
|
||||
set(CMAKE_CXX_COMPILER_RANLIB "/home/cc/anaconda3/envs/ros_flexiv/bin/gcc-ranlib")
|
||||
set(CMAKE_LINKER "/usr/bin/ld")
|
||||
set(CMAKE_LINKER_LINK "")
|
||||
set(CMAKE_LINKER_LLD "")
|
||||
set(CMAKE_CXX_COMPILER_LINKER "/home/cc/anaconda3/envs/ros_flexiv/bin/../lib/gcc/x86_64-conda-linux-gnu/15.2.0/../../../../x86_64-conda-linux-gnu/bin/ld")
|
||||
set(CMAKE_CXX_COMPILER_LINKER_ID "GNU")
|
||||
set(CMAKE_CXX_COMPILER_LINKER_VERSION 2.44)
|
||||
set(CMAKE_CXX_COMPILER_LINKER_FRONTEND_VARIANT GNU)
|
||||
set(CMAKE_MT "")
|
||||
set(CMAKE_TAPI "CMAKE_TAPI-NOTFOUND")
|
||||
set(CMAKE_COMPILER_IS_GNUCXX 1)
|
||||
set(CMAKE_CXX_COMPILER_LOADED 1)
|
||||
set(CMAKE_CXX_COMPILER_WORKS TRUE)
|
||||
set(CMAKE_CXX_ABI_COMPILED TRUE)
|
||||
|
||||
set(CMAKE_CXX_COMPILER_ENV_VAR "CXX")
|
||||
|
||||
set(CMAKE_CXX_COMPILER_ID_RUN 1)
|
||||
set(CMAKE_CXX_SOURCE_FILE_EXTENSIONS C;M;c++;cc;cpp;cxx;m;mm;mpp;CPP;ixx;cppm;ccm;cxxm;c++m)
|
||||
set(CMAKE_CXX_IGNORE_EXTENSIONS inl;h;hpp;HPP;H;o;O;obj;OBJ;def;DEF;rc;RC)
|
||||
|
||||
foreach (lang IN ITEMS C OBJC OBJCXX)
|
||||
if (CMAKE_${lang}_COMPILER_ID_RUN)
|
||||
foreach(extension IN LISTS CMAKE_${lang}_SOURCE_FILE_EXTENSIONS)
|
||||
list(REMOVE_ITEM CMAKE_CXX_SOURCE_FILE_EXTENSIONS ${extension})
|
||||
endforeach()
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(CMAKE_CXX_LINKER_PREFERENCE 30)
|
||||
set(CMAKE_CXX_LINKER_PREFERENCE_PROPAGATES 1)
|
||||
set(CMAKE_CXX_LINKER_DEPFILE_SUPPORTED TRUE)
|
||||
set(CMAKE_LINKER_PUSHPOP_STATE_SUPPORTED TRUE)
|
||||
set(CMAKE_CXX_LINKER_PUSHPOP_STATE_SUPPORTED TRUE)
|
||||
|
||||
# Save compiler ABI information.
|
||||
set(CMAKE_CXX_SIZEOF_DATA_PTR "8")
|
||||
set(CMAKE_CXX_COMPILER_ABI "ELF")
|
||||
set(CMAKE_CXX_BYTE_ORDER "LITTLE_ENDIAN")
|
||||
set(CMAKE_CXX_LIBRARY_ARCHITECTURE "")
|
||||
|
||||
if(CMAKE_CXX_SIZEOF_DATA_PTR)
|
||||
set(CMAKE_SIZEOF_VOID_P "${CMAKE_CXX_SIZEOF_DATA_PTR}")
|
||||
endif()
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ABI)
|
||||
set(CMAKE_INTERNAL_PLATFORM_ABI "${CMAKE_CXX_COMPILER_ABI}")
|
||||
endif()
|
||||
|
||||
if(CMAKE_CXX_LIBRARY_ARCHITECTURE)
|
||||
set(CMAKE_LIBRARY_ARCHITECTURE "")
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_CL_SHOWINCLUDES_PREFIX "")
|
||||
if(CMAKE_CXX_CL_SHOWINCLUDES_PREFIX)
|
||||
set(CMAKE_CL_SHOWINCLUDES_PREFIX "${CMAKE_CXX_CL_SHOWINCLUDES_PREFIX}")
|
||||
endif()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
set(CMAKE_CXX_IMPLICIT_INCLUDE_DIRECTORIES "/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++;/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/x86_64-conda-linux-gnu;/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/backward;/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include;/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include-fixed;/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include;/home/cc/anaconda3/envs/ros_flexiv/include")
|
||||
set(CMAKE_CXX_IMPLICIT_LINK_LIBRARIES "stdc++;m;gcc_s;gcc;c;gcc_s;gcc")
|
||||
set(CMAKE_CXX_IMPLICIT_LINK_DIRECTORIES "/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0;/home/cc/anaconda3/envs/ros_flexiv/lib/gcc;/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/lib;/home/cc/anaconda3/envs/ros_flexiv/lib;/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/lib;/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/lib;/home/cc/anaconda3/envs/ros_flexiv/lib/stubs")
|
||||
set(CMAKE_CXX_IMPLICIT_LINK_FRAMEWORK_DIRECTORIES "")
|
||||
set(CMAKE_CXX_COMPILER_CLANG_RESOURCE_DIR "")
|
||||
|
||||
set(CMAKE_CXX_COMPILER_IMPORT_STD "")
|
||||
set(CMAKE_CXX_COMPILER_IMPORT_STD_ERROR_MESSAGE "Unsupported generator: Unix Makefiles")
|
||||
set(CMAKE_CXX_STDLIB_MODULES_JSON "")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user