旋转操作加速版本

This commit is contained in:
arm_dev
2026-10-20 09:18:24 +08:00
parent 55acdb638d
commit e80edd7b5e
164 changed files with 39439 additions and 32292 deletions
@@ -40,7 +40,19 @@ arm_application/kalman_filter.py
arm_application/model_labels.py
arm_application/move_to_pose_client.py
arm_application/onnx_workpiece.py
arm_application/operator_app.py
arm_application/operator_arm_mesh.py
arm_application/operator_arm_state.py
arm_application/operator_camera_view.py
arm_application/operator_console.py
arm_application/operator_gripper_mesh.py
arm_application/operator_gripper_state.py
arm_application/operator_handeye_board.py
arm_application/operator_tactile_view.py
arm_application/operator_teach.py
arm_application/orisys_tactile.py
arm_application/overhead_oneshot.py
arm_application/path_profile.py
arm_application/perception_node.py
arm_application/pid_controller.py
arm_application/pipeline_node.py
@@ -10,8 +10,10 @@ from rclpy.node import Node
from arm_msgs.srv import SetNamedPose
from arm_application.ros_node import TolerantNode
class DemoNamedPoses(Node):
class DemoNamedPoses(TolerantNode):
def __init__(self) -> None:
super().__init__('demo_named_poses')
self.declare_parameter(
@@ -201,21 +201,36 @@ class DHModbusClient:
def set_position_per_mille(self, position: float | int) -> int:
value = _clamp_int(position, 0, 1000)
# 与 dh_pgea.latched_position_break 同一份:命令已是目标时先写当前位置,
# 读回之后再写最终值。60ms 后写回旧命令,固件采样不到,手指不动。
from arm_application.dh_pgea import latched_position_break
try:
cmd = _clamp_int(self.read_register(REG_POSITION), 0, 1000)
except Exception:
cmd = value
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)
try:
cur = self.get_position_per_mille()
except Exception:
cur = value
bridge = latched_position_break(cmd, cur, value)
if bridge is not None:
self.write_register(REG_POSITION, bridge)
deadline = time.monotonic() + 0.6
echoed = False
while time.monotonic() < deadline:
try:
if _clamp_int(self.read_register(REG_POSITION), 0, 1000) == bridge:
echoed = True
break
except Exception:
pass
time.sleep(0.05)
if not echoed:
raise DHModbusError(
f"位置命令中间值 {bridge} 没有读回,不再写入最终目标 {value}"
)
time.sleep(0.15)
self.write_register(REG_POSITION, value)
return value
@@ -104,6 +104,44 @@ INIT_STATE_NAME = {
}
def latched_position_break(command: int, current: int, target: int) -> Optional[int]:
"""命令寄存器已经等于目标、手指却不在目标上时,返回要先写入的中间位置。
大寰固件:再写一遍与 0x0103 当前值相同的数,不会启动新运动。
开爪卡在 caught 时就是这样——命令已是 1000,手指停在夹持位。
先把命令改成当前位置(手指本来就在这,不会再夹紧),等读回确认
固件已经采样,再写最终目标。只 sleep 几十毫秒就写回旧值,控制周期
来不及看到中间值,最终仍是旧命令,手指不动。
"""
cmd = int(max(0, min(1000, command)))
cur = int(max(0, min(1000, current)))
pos = int(max(0, min(1000, target)))
if cmd != pos or abs(cur - pos) <= 25:
return None
if cur != cmd:
return cur
return 0 if cmd != 0 else 40
def grip_motion_settled(
grip_state: int, position: int, target: int, seen_motion: bool
) -> bool:
"""这一拍能不能算「这句位置命令已经结束」。
张开前本来就是 caught、位置还停在夹持位:不能当成张开完成。
闭合夹住物体:动过之后停在比目标更开的位置,算完成。
"""
near = abs(int(position) - int(target)) <= 25
if grip_state in (S_GRIP_ARRIVED, S_GRIP_DROPPED) and near:
return True
if grip_state == S_GRIP_CAUGHT and seen_motion:
if int(target) + 25 < int(position):
return True
if near:
return True
return False
def crc16_modbus(data: bytes) -> int:
"""官方 DH CRC16(Modbus,线路上按小端发送)。"""
crc = 0xFFFF
@@ -175,6 +213,34 @@ WIRING_HINT = (
)
def open_failure_text(port: str, exc: BaseException) -> str:
"""串口打不开时把「被占用」和「没应答」分开。
独占锁失败(errno 11)以前和真没上电写成同一句「需要 24 V DC」,
界面上看起来像缺自动上电,实际是别的进程还握着这个口。
"""
text = str(exc).lower()
errno = getattr(exc, "errno", None)
busy = errno in (11, 16) or any(
token in text
for token in (
"temporarily unavailable",
"device or resource busy",
"could not exclusively lock",
"resource busy",
)
)
if busy:
return (
f"无法独占打开 {port}:{exc}。"
"串口已被别的进程占用(夹爪驱动或状态读取)。"
)
return (
f"无法打开 {port}:{exc}。"
"需要 24 V DC 供电 + RS485 接线,且当前用户在 dialout 组内。"
)
class DhPgeaGripper:
"""面向 PGE / PGEA 的线程安全 Modbus RTU 客户端(官方寄存器映射)。"""
@@ -219,10 +285,7 @@ class DhPgeaGripper:
exclusive=True,
)
except Exception as exc:
raise DhPgeaError(
f"无法打开 {self.port}:{exc}。"
"需要 24 V DC 供电 + RS485 接线,且当前用户在 dialout 组内。"
) from exc
raise DhPgeaError(open_failure_text(self.port, exc)) from exc
try:
self._ser.dtr = True
except Exception:
@@ -319,17 +382,28 @@ class DhPgeaGripper:
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,然后等到初始化状态 == 完成(对应官方上电流程)。"""
def initialize(
self, timeout_s: float = 20.0, poll_s: float = 0.2, *, force: bool = False
) -> bool:
"""写 0xA5,然后等到初始化状态 == 完成(对应官方上电流程)。
``force=False`` 时,已经完成就不再写(节点启动不要每次都让手指找零)。
命令行 ``init`` 必须 ``force=True``:状态字已经是完成时,不写 0xA5
手指根本不会回零,看起来像初始化失效。
"""
state = self.get_init_state()
if state == S_INIT_FINISHED:
if state == S_INIT_FINISHED and not force:
return True
if not self.initialization():
raise DhPgeaError("初始化写 0x0100=0xA5 失败(无 Modbus 回显)")
deadline = time.monotonic() + float(timeout_s)
# force 时必须先看到状态离开「完成」,否则读到的还是写之前的旧值。
saw_busy = not force
while time.monotonic() < deadline:
state = self.get_init_state()
if state == S_INIT_FINISHED:
if state != S_INIT_FINISHED:
saw_busy = True
elif saw_busy:
return True
time.sleep(poll_s)
raise DhPgeaError(
@@ -345,42 +419,58 @@ class DhPgeaGripper:
speed_pct = int(max(1, min(100, speed_pct)))
return self.write_register(REG_SPEED, speed_pct)
def _wait_command_echo(self, value: int, timeout_s: float = 0.6) -> bool:
"""等到 0x0103 读回就是刚写进去的值。读不回就不要再写下一笔。"""
deadline = time.monotonic() + float(timeout_s)
while time.monotonic() < deadline:
try:
if self.get_target_position(retries=1) == int(value):
return True
except Exception:
pass
time.sleep(0.05)
return False
def set_target_position(self, pos: int) -> bool:
pos = int(max(0, min(1000, pos)))
# 大寰固件: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)
try:
cur = self.get_current_position(retries=2)
except Exception:
cur = pos
bridge = latched_position_break(cmd, cur, pos)
if bridge is not None:
if not self.write_register(REG_POSITION, bridge):
return False
if not self._wait_command_echo(bridge):
return False
# 读回只说明寄存器改了。再留一个控制周期,避免下一笔把中间值盖掉。
time.sleep(0.15)
return self.write_register(REG_POSITION, pos)
def wait_settle(self, target: int, timeout_s: float = 6.0) -> GripperState:
"""等到到位/夹住/松开,或超时返回最后一帧。"""
"""等到到位或夹住,或超时返回最后一帧。
张开前状态字已经是 caught 时不能立刻返回:那是上一句命令的残留,
手指还停在夹持位。
"""
t0 = time.monotonic()
last = None
seen_motion = False
st = self.get_run_states()
while time.monotonic() - t0 < timeout_s:
st = self.get_run_states()
moved = last is not None and st.position != last
if last is not None and st.position != last:
seen_motion = True
last = st.position
if st.grip_state == S_GRIP_MOVING or moved:
if st.grip_state == S_GRIP_MOVING:
seen_motion = True
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:
if grip_motion_settled(st.grip_state, st.position, target, seen_motion):
return st
time.sleep(0.08)
return st
@@ -497,6 +587,36 @@ def _scan_bus(port: str) -> int:
return 1
def ctrl_values(
cmd: str,
value: str = "",
force: int = 50,
speed: int = 50,
) -> Optional[tuple[float, float, float, float]]:
"""节点已经占着串口时,改发 ``/gripper/ctrl`` 的四个数。
顺序是初始化、位置千分比、力度、速度。``probe`` / ``status`` 只读,返回 None。
"""
name = str(cmd or "").strip().lower()
if name in ("probe", "status"):
return None
if name == "init":
return (1.0, 0.0, float(force), float(speed))
if name == "open":
pos = 1000
elif name == "close":
pos = 0
elif name in ("pos", "move"):
if str(value).strip() == "":
raise ValueError("pos 需要 0–1000")
pos = int(round(float(value)))
if pos < 0 or pos > 1000:
raise ValueError("位置只接受 0–1000")
else:
raise ValueError(f"节点在线时不支持 {name}")
return (0.0, float(pos), float(force), float(speed))
def _cli(argv: Optional[list[str]] = None) -> int:
site = _site_defaults()
ap = argparse.ArgumentParser(
@@ -541,8 +661,8 @@ def _cli(argv: Optional[list[str]] = None) -> int:
print(st.summary())
return 0
if args.cmd == "init":
print("正在初始化(手指回零)…")
grip.initialize()
print("正在初始化(手指回零,已初始化也会再做一次)…")
grip.initialize(force=True)
print(grip.get_run_states().summary())
return 0
if args.cmd == "status":
@@ -551,12 +671,25 @@ def _cli(argv: Optional[list[str]] = None) -> int:
if args.cmd == "open":
if not grip.open_jaw(args.force, args.speed):
raise DhPgeaError("张开写入失败")
print(grip.wait_settle(1000).summary())
st = grip.wait_settle(1000)
print(st.summary())
if abs(st.position - 1000) > 25:
print(
"张开失败:手指没有离开夹持位。"
"若仍是 grip=caught,先取下工件再执行 init。",
file=sys.stderr,
)
return 1
return 0
if args.cmd == "close":
if not grip.close_jaw(args.force, args.speed):
raise DhPgeaError("闭合写入失败")
print(grip.wait_settle(0).summary())
st = grip.wait_settle(0)
print(st.summary())
# 夹住物体时位置停在厚度上,grip=caught 算成功。停在张开位则失败。
if st.grip_state != S_GRIP_CAUGHT and abs(st.position) > 25:
print("闭合失败:手指没有朝合拢方向离开原位。", file=sys.stderr)
return 1
return 0
if args.cmd == "pos":
if args.value == "":
@@ -565,7 +698,15 @@ def _cli(argv: Optional[list[str]] = None) -> int:
pos = int(float(args.value))
if not grip.move(pos, args.force, args.speed):
raise DhPgeaError("位置写入失败")
print(grip.wait_settle(pos).summary())
st = grip.wait_settle(pos)
print(st.summary())
grasped_short = st.grip_state == S_GRIP_CAUGHT and st.position > pos
if abs(st.position - pos) > 25 and not grasped_short:
print(
f"位置 {pos} 没有到位(仍在 {st.position})。",
file=sys.stderr,
)
return 1
return 0
except DhPgeaError as exc:
print(f"错误:{exc}", file=sys.stderr)
@@ -33,15 +33,7 @@ from arm_application.dh_pgea import (
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()
from arm_application.ros_node import TolerantNode, param_as_str
def _as_ctrl_array(msg: Float32MultiArray) -> tuple[float, float, float, float]:
@@ -51,7 +43,7 @@ def _as_ctrl_array(msg: Float32MultiArray) -> tuple[float, float, float, float]:
return float(data[0]), float(data[1]), float(data[2]), float(data[3])
class DhPgeaNode(Node):
class DhPgeaNode(TolerantNode):
def __init__(self) -> None:
super().__init__("dh_pgea")
self.declare_parameter("port", "/dev/ttyUSB0")
@@ -64,9 +56,9 @@ class DhPgeaNode(Node):
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._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)
@@ -22,6 +22,8 @@ from arm_msgs.msg import GripperCommand as GripperCommandMsg
from arm_msgs.msg import GripperStatus as GripperStatusMsg
from arm_msgs.srv import GripperExecute
from arm_application.ros_node import TolerantNode
def _site_path() -> Path:
env = os.environ.get("SITE_CONFIG", "").strip()
@@ -38,7 +40,7 @@ def _as_bool(val, default: bool = True) -> bool:
return bool(val)
class DualGripperNode(Node):
class DualGripperNode(TolerantNode):
def __init__(self) -> None:
super().__init__("dual_gripper_driver")
self.declare_parameter("config_file", "")
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""跟踪点的三道守卫:**位置合理性** + **每帧位移上限** + **远框重捕获确认**。
为什么需要它们(2026-10-08 真机 arm 89,一阶段跑 150s 没锁上)
------------------------------------------------------------
``/tmp/pipeline_stage1.log`` 原话。真 k0 一开始是对的::
764.79 跟踪 keypoint conf=0.87 uv=(410,246) z=0.299 ← 真 k0(画面中偏右)
然后在 1 秒内被一串**假 k0** 顶掉(全都是高置信度)::
766.96 重新捕获 conf=0.58 dpx=480
766.96 跟踪 keypoint conf=0.58 uv=(76,75) z=0.392 ← 贴左上角
767.75 跟踪 keypoint conf=0.23 uv=(169,107) z_ok=0 深度过期 box_z hold_near
768.16 跟踪 keypoint conf=0.53 uv=(143,92) z=0.376
768.78 跟踪 keypoint conf=0.73 uv=(12,30) k0(ok=1551 near=620 借深度) z=0.276
769.66 重新捕获 conf=0.22 dpx=207
770.07 跟踪 keypoint conf=0.73 uv=(431,226) k0(ok=19440 near=7776 借深度) z=0.271
伺服于是以 0.45m/s 追着它们跑:``ee_xy`` home ``(0.686,-0.113)`` → ``(0.354,-0.294)``
→ ``(0.083,-0.515)``(工作盒角落),1.2s 后看门狗急停;工件已在半米外,
剩下 148s 全是"检测漏检"。三条结论:
1. **置信度挡不住**:假 k0 的 conf 是 0.53~0.73,真 k0 是 0.87 —— 没有可分区间。
但假 k0 有一个共同特征:**落在画面边上**(``uv=(12,30)`` / ``(76,75)`` /
``(143,92)``),而孔心长在工件上、不可能贴在画面边缘;
2. **借深度把"没有深度"伪装成"有深度"**:``k0(… 借深度)`` 的 z 是从工件框里
借的,看着很合理(0.27~0.39m),于是"物理量程"这道守卫也失效了。而
**借来的深度意味着该像素处读不到自己的深度** ⇒ 那个点的绝对 XY 不可靠,
不该用来**重定位**(换一个跟踪位置);
3. **位移不受限**:真 k0 到假 k0 一帧能差 480px / 400mm,伺服一次就全速扑过去。
三道守卫各挡一层,全都是"能真的失败"的判据(见 test_feature_guard_*.py 的负向用例):
``kpt_plausible``
k0 必须落在自己的检测框内(含少量余量)、且不许贴画面边缘。违反 ⇒ 本帧的
k0 **位置**不可信(不是"没有目标"),交回调用方走"按框上相对偏移重建"的
连续性路径(``perception_node._keypoint_feature`` 本来就有这条路)。
``TrackSlewGuard``
已发布跟踪点的**每帧位移上限**。工件不动、相机 30Hz,真实观测一帧不可能走
4cm(``feature_jump_guard`` 实测真实运动 ≤15mm/帧)。超过上限:观测可信就
**夹到上限**(限速跟随,不丢目标),观测来自借深度就**整帧不采信**。
``FarReacquireConfirm``
"远框重新捕获"(``dpx`` 大于门限)必须连续 ``confirm_frames`` 帧指向同一处
才采信 —— 一帧的远框更可能是模型在别的东西上误报,而不是工件真的被挪走了。
"""
from __future__ import annotations
from typing import Optional, Sequence, Tuple
import numpy as np
def _xy_dist(a: np.ndarray, b: np.ndarray) -> float:
"""两个 XY 点的距离(米/像素都行,单位跟随入参)。单一实现,别各写一份。"""
return float(np.hypot(float(a[0]) - float(b[0]), float(a[1]) - float(b[1])))
def kpt_plausible(
u: float,
v: float,
box: Optional[Sequence[float]],
w: int,
h: int,
*,
border_px: float = 6.0,
box_margin_frac: float = 0.10,
) -> Optional[str]:
"""k0 的**像素位置**是否可信。返回 None = 可信;否则返回拒绝原因。
``box`` 是同一帧的工件检测框 ``(x0,y0,x1,y1)``;``w,h`` 是彩色图宽高。
"""
if not (np.isfinite(u) and np.isfinite(v)):
return "k0 像素非有限值"
if box is None:
# 没有框就没法判断 k0 是否落在工件上 —— 宁可当不可信(走连续性路径)。
return "没有检测框,无法判断 k0 是否落在工件上"
b = np.asarray(box, dtype=float).reshape(-1)
if b.size < 4:
return "检测框字段不完整"
x0, y0, x1, y1 = (float(vv) for vv in b[:4])
bw, bh = abs(x1 - x0), abs(y1 - y0)
if not (np.isfinite(bw) and np.isfinite(bh)) or bw <= 1e-6 or bh <= 1e-6:
return "检测框尺寸无效"
mx, my = box_margin_frac * bw, box_margin_frac * bh
if u < x0 - mx or u > x1 + mx or v < y0 - my or v > y1 + my:
return (
f"k0 ({u:.0f},{v:.0f}) 落在检测框 "
f"[{x0:.0f},{y0:.0f},{x1:.0f},{y1:.0f}] 之外"
)
if w > 0 and h > 0:
if u <= border_px or v <= border_px or u >= w - 1 - border_px or v >= h - 1 - border_px:
return (
f"k0 ({u:.0f},{v:.0f}) 贴画面边缘({w}x{h},余量 {border_px:.0f}px)"
"—— 孔心长在工件上,不可能在画面边上"
)
return None
class TrackSlewGuard:
"""已发布跟踪点的每帧位移上限。
单实例服务**一个**跟踪点;调用方负责在"没有目标/重置"时 :meth:`reset`。
"""
def __init__(self, max_step_m: float) -> None:
# 只有**一个**门限,故意的:观测可信就限速跟随,借深度就整帧不采信。
# 不做成两个门限是因为"借深度"只影响"能不能重定位",不影响"小步跟不跟" ——
# 两个门限会立刻变成"哪个该调、调到多少"的扯皮(且没有实测支撑)。
self.max_step_m = float(max_step_m)
self._last: Optional[np.ndarray] = None
self.clamped_total = 0
self.rejected_borrowed_total = 0
def reset(self) -> None:
self._last = None
def update(
self, p_xy: Optional[Sequence[float]], *, borrowed: bool = False
) -> Tuple[Optional[np.ndarray], Optional[str]]:
"""喂一次观测,返回 ``(采信的点, 拒绝原因)``。
- 第一次观测(没有历史):原样采信(冷启动没有更好的信息,见服务节点的
照常起步)。
- 位移 ≤ 上限:原样采信。
- 位移 > 上限且 ``borrowed``:整帧不采信(返回 ``None`` + 原因)——
借来的深度意味着这个像素读不到自己的深度,绝对位置不足以用来重定位。
- 位移 > 上限且观测可信:**夹到上限**(限速跟随),不丢目标。
"""
if p_xy is None:
return None, "本帧没有观测"
p = np.asarray(p_xy, dtype=float).reshape(2)
if not np.all(np.isfinite(p)):
return None, "跟踪点非有限值"
if self._last is None:
self._last = p.copy()
return p, None
d = _xy_dist(p, self._last)
if d <= self.max_step_m:
self._last = p.copy()
return p, None
if borrowed:
self.rejected_borrowed_total += 1
return None, (
f"借深度的观测要求位移 {d*1000:.1f}mm > 上限 "
f"{self.max_step_m*1000:.0f}mm —— 判误检,本帧不采信"
)
step = self._last + (p - self._last) * (self.max_step_m / d)
self._last = step.copy()
self.clamped_total += 1
return step, (
f"位移 {d*1000:.1f}mm > 上限 {self.max_step_m*1000:.0f}mm —— "
"夹到上限限速跟随"
)
class FarReacquireConfirm:
"""远框"重新捕获"的多帧确认。
``dpx`` 是候选框中心与上一帧框中心的像素距离。超过 ``far_px`` 的候选必须
连续 ``confirm_frames`` 帧落在同一处(相互间距 ≤ ``far_px``)才放行。
"""
def __init__(self, far_px: float, confirm_frames: int = 3) -> None:
self.far_px = float(far_px)
self.confirm_frames = max(1, int(confirm_frames))
self._cand: Optional[np.ndarray] = None
self._count = 0
def reset(self) -> None:
self._cand = None
self._count = 0
def allow(
self, center_xy: Optional[Sequence[float]], dpx: float, *, near_px: float
) -> Tuple[bool, Optional[str]]:
"""返回 ``(是否放行, 拒绝原因)``。``near_px`` 是"不算远框"的门限。"""
if center_xy is None:
self.reset()
return True, None
c = np.asarray(center_xy, dtype=float).reshape(2)
if not np.all(np.isfinite(c)):
return False, "候选框中心非有限值"
if float(dpx) <= float(near_px):
# 近框:正常跟踪,不需要确认
self.reset()
return True, None
if self._cand is None or _xy_dist(c, self._cand) > self.far_px:
self._cand = c.copy()
self._count = 1
else:
self._count += 1
if self._count >= self.confirm_frames:
self.reset()
return True, None
return False, (
f"远框重捕获 dpx={float(dpx):.0f}px 需连续 {self.confirm_frames} 帧一致"
f"(当前 {self._count} 帧)—— 本帧不采信"
)
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""跟踪点"假跳变"的确认门限:单帧大跳不采信,连续 N 帧一致才认。
为什么需要它(2026-10-08 真机实测,一阶段跑 150s 没锁上)
--------------------------------------------------------
arm 89 的日志原话:
0659.551 死区保持 |e_xy|=3.0mm ← 其实已经锁定!误差只剩 3mm
0659.598 k0 置信度 0.01 < 0.30,本帧不用它
0659.604 HANDEYE snap feat_base=(0.675,-0.099) ← 跟踪点瞬间横移 89mm
0660.121 v=(-0.206,+0.400) |e_xy|=130mm ← 伺服以 0.45m/s 去追这个假点
0660.561 过期保持 —— 不跟踪冻结的检测框 ← 画面糊掉,目标彻底丢失
原来的写法是"跳变超过 ``predict_jump_m`` 就 ``reset()``" —— 等于**单帧就采信**。
可是相机 30Hz、工件不动,真实特征点一帧不可能跳 89mm(实测的真实运动
在 15mm/帧 以内)。所以"大跳"几乎必然是检测/兜底产生的假点,不该一路追过去。
判据
----
- 位移 ≤ ``jump_m``:正常观测(``near``),照常滤波更新。
- 位移 > ``jump_m``:进入"待确认"。此后**连续 ``confirm_frames`` 帧**都要落在
同一个新位置附近(相互间距 ≤ ``jump_m``),才判为真实重定位(``jump``)。
- 只要中间有一帧不对上(乱跳),计数清零、重新开始 —— 乱跳永远等不到确认。
这也顺手挡住"感知层 k0 掉信后乱跳"那类现象:宁可原地不动,也不要追着假点跑。
"""
from __future__ import annotations
from typing import Optional
import numpy as np
# ``servo_node._update_feat_track`` 的三种判定结果。
# 为什么要显式区分"没有目标":perception 会把误检点也发过来(2026-10-08 真机,
# 模型在运动模糊下把特征点丢到基座后方 -0.10,-0.65,工件其实在 0.5,-0.12),
# 这种点在物理上不可能 —— 必须当"没看到目标"处理,而不是"钉在原地"。
ACCEPT = "accept" # 采信这次观测
HOLD = "hold" # 疑似假跳 —— 位置钉住不动
REJECT = "reject" # 物理上不合理(工作盒外)—— 当"没有目标"处理
class FeatureJumpGate:
"""有状态的跳变确认门限。线程安全由调用方保证(servo 在锁内调用)。"""
NEAR = "near" # 正常观测:照常滤波更新
HOLD = "hold" # 疑似假跳:本次不采信,位置保持不动
JUMP = "jump" # 已确认的真实重定位:允许 reset 到新位置
def __init__(self, jump_m: float, confirm_frames: int = 3) -> None:
self.jump_m = float(jump_m)
# 至少 1 帧才谈得上"确认";<=1 就等于退回旧的"单帧采信",但那是显式选择,
# 不是静默降级 —— 这里仍然保留参数以方便现场调节。
self.confirm_frames = max(1, int(confirm_frames))
self._cand: Optional[np.ndarray] = None
self._count = 0
# 诊断计数:连续 hold 了多少帧(供日志/排查,见 servo_node 的告警)。
self.hold_frames = 0
self.hold_total = 0
def reset(self) -> None:
"""清空待确认状态(正常观测、或已确认后都要清)。"""
self._cand = None
self._count = 0
self.hold_frames = 0
@staticmethod
def _dist(a: np.ndarray, b: np.ndarray) -> float:
return float(np.hypot(float(a[0]) - float(b[0]), float(a[1]) - float(b[1])))
def update(self, p_xy: np.ndarray, ref_xy: np.ndarray) -> str:
"""喂一次观测。
参数
----
p_xy : 本帧测到的 XY(base 系,米)
ref_xy : 当前滤波后的位置 XY(米)—— 判断"跳没跳"的基准
返回 :data:`NEAR` / :data:`HOLD` / :data:`JUMP` 之一。
"""
p = np.asarray(p_xy, dtype=float).reshape(2)
ref = np.asarray(ref_xy, dtype=float).reshape(2)
if self._dist(p, ref) <= self.jump_m:
self.reset()
return self.NEAR
# 大跳:本次观测只算"候选"。候选之间互相对不上就从头再数。
if self._cand is None or self._dist(p, self._cand) > self.jump_m:
self._cand = p.copy()
self._count = 1
else:
self._count += 1
if self._count >= self.confirm_frames:
self.reset()
return self.JUMP
self.hold_frames += 1
self.hold_total += 1
return self.HOLD
@@ -94,6 +94,10 @@ from arm_application.gauge_config import (
)
from arm_application.gauge_fsm import (
DESCEND_STALL_STEPS,
ENTRY_HIGH,
ROT_AXIS,
ROT_LIMIT,
ROT_SHORT,
Event,
FsmError,
GaugeContext,
@@ -104,12 +108,16 @@ from arm_application.gauge_fsm import (
descend_iter_budget,
descend_needed_m,
descend_progress,
hole_entry_verdict,
descend_tol_m,
is_joint_limit_error,
pixel_stable,
points_stable,
ratchet_cycles,
ratchet_delta_rad,
ratchet_travel_ok,
relief_tol_m,
rotation_verdict,
screw_stop,
tangential_jump,
tool_clearance_m,
@@ -135,16 +143,41 @@ from arm_application.gauge_kin import (
)
from arm_application.pipeline_node import AssemblyPipeline
from arm_application.handeye_realsense import resolve_realsense_topics
from arm_application.site_io import deep_merge, load_site_dict, ros2_root
from arm_application.site_io import (
deep_merge,
load_site_dict,
overhead_camera,
resolve_eye_to_hand_file,
ros2_root,
)
# 复用同一份图像解码与轨迹点构造,别在这里再写一份(规则 M2)
from arm_application.stage2_locate_node import _bgr_copy, _pt as _joint_point
#: 允许的"绕轴验证"误差 [deg]。超过就说明这根关节轴不是工具 Z 轴,
#: 后面所有以"转多少度"为单位的动作都不成立 —— 必须报错而不是继续。
AXIS_VERIFY_TOL_DEG = 5.0
#: 棘轮转腕时距离关节限位保留的余量 [deg]。
#: 3° 不够用:实测轨迹会**自己**少走 ~5°(比如命令 180° 只到位 175.1°,
#: 而关节7 的限位正好是 175.06° —— 两者叠在一起,判断"是被限位截住还是
#: 没走完"就分不清了)。留 8° 让这两种情况都还没有贴住。
RATCHET_LIMIT_MARGIN_DEG = 8.0
#: 限制"跟踪丢失 → 粗定位 → 跟踪"的来回次数,防死循环。
MAX_TRACK_RETRIES = 3
#: ``stage2_locate`` 返回码 → 人话。为什么要这张表:日志里只有 ``code=6`` 时,
#: 现场只能去翻源码;而每个码对应的**下一步动作完全不同**(垂直化/粗定位/
#: 检测/对准/工件被推走),写在这里就不用来回翻了。
_LOCATE_CODE_HINT: Dict[int, str] = {
2: "(垂直化或悬停失败:工具轴没竖直、或悬停高度到不了)",
3: "(粗定位失败:视野里没框到工件 —— 看相机/光照/权重)",
4: "(k1/k2 解不出来:孔被挡、曝光不对、或孔根本没在视野里)",
5: "(对准迭代用满仍未收敛:检查手眼/相机安装,或放宽 align_tol_m)",
6: "(工件被工具推走:不是手眼/检测的问题,是悬停时工具尖端已经进了工件"
" —— 查 gauge_assembly.tool_tip_below_flange_m 与 servo.hole_hover_z_m)",
8: "(规尖偏移一直没量到:残差看着达标、但用的是**法兰轴线**不是规尖 —— "
"对的可能是法兰、不是规;摆到能看清规尖再跑。看日志『规尖视觉量取失败』)",
}
class GaugeAbort(RuntimeError):
"""阶段内部判定要中止。带上 exit code,方便脚本区分原因。"""
@@ -194,12 +227,11 @@ class GripperClient:
if math.isfinite(pos):
self._last_aperture = pos
self._last_gripped = bool(msg.is_gripped)
if self._last_gripped:
if not self._ever_gripped:
# 记住"夹住时的开口":之后的掉件判据是**相对它的塌陷量**,
# 换一把规、换一个夹持开口都不用改阈值。
self._grip_aperture = self._last_aperture
self._ever_gripped = True
# ⚠️ 这里**故意不**记"夹住时的开口"。曾经记过,代价是一个假 E1:
# 本后端的 is_gripped 是**推断值**,夹爪**全开**时也会闪成 True
# (实测 99.29mm,见 dropped 的说明),于是基准被记成 99.29mm、
# 塌陷量算成 77mm ⇒ "通止规掉了(E1)",而规好好地在爪里。
# 基准只由 :meth:`adopt_current_grip` 在**明确知道夹住了**的时候记。
def _on_legacy(self, msg: Float32MultiArray) -> None:
fields = legacy_states_to_fields(list(msg.data))
@@ -210,6 +242,8 @@ class GripperClient:
if got is not None:
self._last_gripped = got
if got:
# 老后端只有状态字、没有开口,掉件判据本来就吃状态字(见 dropped),
# 所以这里记"曾经夹住过"是对的。
self._ever_gripped = True
# -- 查询 -------------------------------------------------------------
@@ -224,6 +258,75 @@ class GripperClient:
"""``True``/``False``/``None``(后端给不出明确答案)。"""
return self._last_gripped
def aperture(self) -> Optional[float]:
"""最近一帧的**开口** [mm](只有契约后端的 ``position`` 有;没有返回 None)。"""
return self._last_aperture
def grip_aperture(self) -> Optional[float]:
"""第一次判到"夹住"时的开口 [mm](掉件判据的基准,见 :meth:`dropped`)。"""
return self._grip_aperture
def collapse_mm(self) -> Optional[float]:
"""相对夹住时的**开口塌陷量** [mm](正 = 比夹住时更小)。拿不到返回 None。
S08/S09 每轮都要"松爪 → 回转 → 夹紧",日志里必须看得到这个数:
它一路变大就说明每轮夹紧都比上一轮更往里挤,或者规在往下滑。
"""
if self._grip_aperture is None or self._last_aperture is None:
return None
return float(self._grip_aperture) - float(self._last_aperture)
# -- 开口的"等稳定"与"记基准" ------------------------------------------
def settle(self, *, settle_s: float = 1.0, tol_mm: float = 0.05) -> Optional[float]:
"""等开口**稳定**下来,返回稳定后的开口 [mm](拿不到读数返回 None)。
为什么必须有这个(2026-09-24 用日志抓到的):
``close``/``open`` 服务**返回时动作还没做完**(JTC 轨迹是异步的)。
立刻读开口会读到**松手前的旧值**——实测 S08 夹紧后立刻读,读到的还是
全开 99.93mm,于是日志与基准全错("夹住时 99.29mm")。谁要在动作之后
判断开口,都必须先过这里。
"""
spin = getattr(self._node, "_spin", None)
deadline = time.monotonic() + max(0.0, settle_s)
prev = self._last_aperture
stable = 0
while True:
if spin is not None:
spin(0.05)
else: # pragma: no cover - 只有测试桩走这里
time.sleep(0.05)
now = self._last_aperture
if now is not None and prev is not None and abs(float(now) - float(prev)) <= tol_mm:
stable += 1
if stable >= 2:
return float(now)
else:
stable = 0
prev = now
if time.monotonic() >= deadline:
return None if now is None else float(now)
def adopt_current_grip(self, why: str, *, settle_s: float = 1.0) -> Optional[float]:
"""把**当前**开口记为"夹住时的开口"(掉件判据的基准),并记"曾经夹住过"。
只在**明确知道爪里夹着规**的时刻调用(S00 自检通过后、以及每一次
确认夹紧之后)——它是掉件判据唯一的参照,随手记会让判据失去意义
(见 :meth:`dropped` 与 :meth:`_on_contract` 的说明)。
"""
ap = self.settle(settle_s=settle_s)
if ap is None:
# 拿不到开口(老固件/别的夹爪)也要记"曾经夹住过":否则 dropped()
# 会永远返回 False,那比"会抖的旧判据"更糟(真掉件也看不见)。
self._ever_gripped = True
self._node.get_logger().warn(
f"拿不到开口读数({why})—— 掉件判据退回状态字,会抖但不会漏"
)
return None
self._grip_aperture = float(ap)
self._ever_gripped = True
self._node.get_logger().info(f"夹持基准:开口 {ap:.2f}mm({why})")
return float(ap)
def dropped(self) -> bool:
"""E1 掉件:**曾经夹住过**,现在报"掉落"。
@@ -238,6 +341,9 @@ class GripperClient:
的指令落到与实际开口同一档,``is_gripped`` 就翻成 False。于是
**S08 第 2/12 轮直接报 E1**(爪里其实好好的,是判据在抖),
然后整条流程去等人工"放好通止规",而仿真里根本没人能做这件事。
更坏的是这个推断值在**全开**时也会闪成 True(实测 99.29mm)——
拿它当基准就会得到"塌陷 77mm"的假 E1。所以基准只由
:meth:`adopt_current_grip` 明确记录(见 :meth:`_on_contract`)。
真正"掉了"的物理表现是**两指合到底**:开口从"工件宽度"塌到几毫米,
是几十毫米的变化,不会被指令切换带偏。所以判据用
@@ -349,6 +455,12 @@ class GaugeAssembly(AssemblyPipeline):
# `$?` 判不出成败 —— 自动化里会把"流程失败"记成"成功"。结论文件是唯一
# 能被脚本/CI 读到的东西,由 run_gauge_assembly.sh 传入并读回。
self.declare_parameter("result_file", "/tmp/gauge_assembly_result.json")
# 眼在手外标定产物的**确切路径**,由 launch 按 site.yaml 派生后传入
# (`<臂号>_<相机>_eye_to_hand.yaml`)。为什么不让节点自己拼:
# "臂号 = SN 末两位"这条口径只有 export_site_env 一处实现,节点再写一遍
# 必然漂移;而漂移的症状是"标定做完了、S03 却找不到文件",回退到 S04 跟踪,
# 看起来像跟踪的问题。留空则退回下面那串候选名(兼容旧命名/仿真)。
self.declare_parameter("coarse_eye_to_hand_file", "")
site = load_site_dict()
# robots[].gauge_assembly 覆盖全局段 —— 与 servo / paths / perception 同一套规矩。
@@ -369,6 +481,13 @@ class GaugeAssembly(AssemblyPipeline):
holes = int(self.get_parameter("holes_total").value)
if holes > 0:
overrides["holes_total"] = holes
# 工具长:仿真里由启动脚本传**实测值**(上料脚本刚量的),真机不传(走 site)。
# 为什么必须能覆盖:这个值决定悬停高度与 S07 下探预算,而仿真里规会在指间
# 下滑到落座位置、下滑量随装夹变化(实测见过 243 / 254mm,差 11mm)。
# 差 11mm 的后果见 gauge_fsm.hover_z_guard / drag_verdict 的说明。
tool_len = float(self.get_parameter("tool_tip_below_flange_m").value)
if tool_len > 0.0:
overrides["tool_tip_below_flange_m"] = tool_len
try:
self.cfg: GaugeConfig = from_site(site, overrides=overrides or None)
except GaugeConfigError as exc:
@@ -496,49 +615,30 @@ class GaugeAssembly(AssemblyPipeline):
def _d435_ns(self, site: Dict[str, Any]) -> str:
"""手外相机的命名空间:``role: overhead*`` 且**属于当前这台臂**的那一个。
为什么必须按 ``arm`` 过滤:仿真那台也有手外相机(``sim_d435``),
只按 role 扫会同时命中两台,而"命中哪一台"取决于字典顺序 ——
同一份配置在仿真/真机之间**错而不报**地互串。
规则(确定性的三级):
1. ``arm`` 与当前臂相同的优先(仿真:sim_d435 / 真机若两台各有相机也适用);
2. 都没有 ``arm``(= 双臂共用的那台老相机)时回落到它;
3. 还是多个 → 出声告警并取第一个,让人去配置里区分。
规则(三级收敛)的**唯一实现**在 ``site_io.overhead_camera``——手眼标定
脚本也用它取相机名。这里只负责把"配置没区分开"这件事**出声**:静默取第一台
会让"标定用的相机"和"推理用的相机"悄悄不是同一台。
"""
cams = [
cfg
for cfg in (site.get("cameras") or {}).values()
if isinstance(cfg, dict)
and str(cfg.get("role") or "").startswith("overhead")
]
if not cams:
return ""
arm = str(self._robot_dict(site).get("arm") or "").strip()
mine = [c for c in cams if str(c.get("arm") or "").strip() == arm] if arm else []
hits = mine or [c for c in cams if not str(c.get("arm") or "").strip()] or cams
if len(hits) > 1:
names = ", ".join(str(c.get("name") or "?") for c in hits)
cfg, others = overhead_camera(site, self._robot_dict(site))
if others:
names = ", ".join([str(cfg.get("name") or "?")] + others)
self.get_logger().warn(
f"有 {len(hits)} 台手外相机都符合当前臂({names})—— 取第一个,"
f"有 {len(others) + 1} 台手外相机都符合当前臂({names})—— 取第一个,"
"但请给 site.yaml 的 cameras.* 补上 arm 字段区分,否则选哪台看运气"
)
return str(hits[0].get("name") or "").strip("/")
return str(cfg.get("name") or "").strip("/")
def _eye_to_hand_file(self) -> Optional[Path]:
"""找手外相机的手眼文件。没有就返回 None(调用方据此给出**怎么生成**)。
路径按**仓库根**解析而不是当前工作目录:相对 CWD 的路径会在"换个目录
启动"时静默失效(文件找不到 → S03 直接降级),而人只会怀疑标定。
规则(候选顺序 + 按仓库根解析)只有一份,在 ``site_io.resolve_eye_to_hand_file``。
这里只负责把 launch 传入的确切路径和当前手外相机名交给它。
"""
root = ros2_root()
for rel in (
f"handeye/results/{self.cfg.coarse_camera}_eye_to_hand.yaml",
"handeye/results/eye_to_hand.yaml",
):
cand = root / rel
if cand.is_file():
return cand
return None
return resolve_eye_to_hand_file(
ros2_root(),
explicit=str(self.get_parameter("coarse_eye_to_hand_file").value or ""),
camera_name=self.cfg.coarse_camera,
)
def _on_robot_description(self, msg: String) -> None:
self._robot_description = str(msg.data)
@@ -959,6 +1059,13 @@ class GaugeAssembly(AssemblyPipeline):
return Event.BOOT_OK
self._publish_gauge_result({"ok": False, "state": "S00", "failures": blockers})
return Event.BOOT_FAIL
# 记"夹住时的开口"(掉件判据的基准)。**放在这里而不是等到 S08**:
# · 此刻爪里确实夹着规(仿真上料脚本刚装好,真机是人工装好才开始跑);
# · 基准必须是**明确知道夹住**时记的 —— 本后端的 is_gripped 是全开时
# 都会闪 True 的推断值(实测 99.29mm),拿它当基准会造出假 E1
# (见 GripperClient.dropped)。
if self.grip is not None:
self.grip.adopt_current_grip("S00 自检通过:爪里已装好通止规")
return Event.BOOT_OK
# =====================================================================
@@ -1140,20 +1247,25 @@ class GaugeAssembly(AssemblyPipeline):
f"{tag}: 目标 {delta_deg:+.1f}°,实测绕工具 Z {got:+.1f}°"
f"(转轴偏差 {mis:.1f}°,阈值 {AXIS_VERIFY_TOL_DEG:.1f}°)"
)
if abs(got) < 3.0 and abs(delta_deg) > 10.0:
# 完全没动:最可能是撞上关节极限(用户要求的"转到极限角"就是这种情形)
verdict = rotation_verdict(delta_deg, got, mis, AXIS_VERIFY_TOL_DEG)
if verdict == ROT_LIMIT:
# 完全没动、或只走了一部分:都是**关节限位**把这段截住了
# (2026-09-24 实测:棘轮回转每轮顶到上限 ±175°,于是下一步反向
# 只剩 ~16° 余量)。交给调用方让余量重试(见 _ratchet_step);
# 调用方不处理时 FSM 会按未定义转移报错,不会静默走下去。
self.get_logger().warn(
f"{tag}: 指令 {delta_deg:+.1f}° 但几乎没动 —— 判为已贴关节极限"
f"{tag}: 期望 {delta_deg:+.1f}°、实测 {got:+.1f}°"
f"(差 {abs(delta_deg) - abs(got):.1f}°)—— 判为**贴到关节限位**"
)
return Event.JOINT_LIMIT
if mis > AXIS_VERIFY_TOL_DEG:
if verdict == ROT_AXIS:
self.get_logger().error(
f"{tag}: 转轴偏差 {mis:.1f}° 超过阈值 —— 关节7 轴与工具 Z 不共线,"
"以『度』为单位的动作(S01/S08/S09)都不可信,中止。"
"先确认工具系是否垂直向下、有没有装转接件"
)
return Event.ABORT
if abs(abs(got) - abs(delta_deg)) > max(AXIS_VERIFY_TOL_DEG, 0.1 * abs(delta_deg)):
if verdict == ROT_SHORT:
self.get_logger().error(
f"{tag}: 期望 {delta_deg:+.1f}°、实测 {got:+.1f}°,偏差过大,中止"
)
@@ -1161,6 +1273,146 @@ class GaugeAssembly(AssemblyPipeline):
self._axis_verified = True
return fsm_event
def _ratchet_step(
self, target_rad: float, *, tag: str, fsm_event: Event, speed_deg_s: Optional[float],
direction: int = 0,
) -> Event:
"""棘轮的一步"转腕":行程不够时**张开爪让出余量**再转。
:param target_rad: 关节 7 的**目标角(绝对值)**,与
:meth:`_rotate_joint_and_verify` 同一口径(那边算的就是
``target_rad - q_now``)。**不是增量。**
:param direction: 这一步的**工艺转向**:``+1`` 正转、``-1`` 反转、
``0`` 交给"最短路径"。棘轮的四步一律显式给:步长接近 180° 时
"最短路径"会被 0.1° 的读数差翻方向(详见
:func:`arm_application.gauge_fsm.ratchet_delta_rad`)。
⚠️ 为什么要把口径写在这里(2026-09-24 栽过一次,S09 直接 ABORT):
调用方(S08/S09)给的本来就是绝对值(``q[-1] ± screw_step_rad``),
而本函数此前把它当**增量**用(``prewind(want_deg=abs(deg(target)))``、
让余量时又按 ``q - target`` 算新目标)。于是 S09 第一步被算成
"要转 338.9°"(其实是目标 338.9°、增量只有 180°)⇒ 误判"行程不够"
⇒ 让余量那一步算到限位**外面**(``158.9 − 338.9 = −180°``,比
−175° 的限位还低 5°)⇒ 报"期望 −338.9°、实测 +26.0°,偏差过大"
⇒ 整条流程在**最不该失败的一步** ABORT(规在爪里好好的、什么坏事都没发生)。
两个函数各用一种口径,编译器不会说话,日志还会把"目标"和"增量"都印成
一个数 —— 所以口径写进参数名和 docstring。
为什么需要"让余量"这一步:
棘轮要求"一正一反各 180°",而关节 7 的行程只有 ±175°(3.0543 rad)。
拧入(S08)的**回转**那一步每轮都正好顶到上限(实测 +175.1°),
于是 S09 要做的**反方向**第一步只剩 ~16° 余量,被限位截住。
而这一轮其实什么坏事都没发生。
为什么"让余量"是免费的:
指间**张开**时规在打滑(这正是棘轮成立的原理),所以手腕空转既不会
把已经拧进去的螺纹转回去,也不会漏掉进度 —— 只是把行程重新分配。
让多少:整步(不去算亏欠了多少)。反正张开状态下这一步不产生任何装配
效果,整步最省事也最不容易算错;重试时的那一步才是"用力"的那一步。
两条路:**先算**(限位已知时,提前让余量,干净)+ **兜底**(真被限位
截住时再让,覆盖"限位没读到 / 轨迹自己没走完"这两种情况)。
两条路**最后都汇到同一次让余量**:只有一次,避免"先算发现不够、
让完还不够、再让一次"这种把行程越绕越少的做法。
"""
limits = self._wait_joint_limits()
q = None
if limits is not None:
q, _Tee = self._wait_state(3.0)
if q is None:
return Event.TIMEOUT
# 目标角可能落在限位**外面**(这正是棘轮要解决的问题),先折成"最近一圈"
# 的增量(gauge_fsm.ratchet_delta_rad,含完整说明与单测):
# 关节在 +158.9°、目标 +338.9° ⇒ 增量 +180°(而不是 +338.9°)。
# **方向按工艺意图给**:步长贴着 180° 时"最短路径"会被读数差翻向(R2.10)。
delta_rad = ratchet_delta_rad(target_rad, float(q[-1]), direction)
why = ""
if limits is not None and abs(math.degrees(delta_rad)) > 1e-6:
plan = prewind(
q_now_rad=float(q[-1]), want_deg=abs(math.degrees(delta_rad)),
direction=1 if delta_rad > 0 else -1,
limits=limits, margin_deg=RATCHET_LIMIT_MARGIN_DEG,
)
if plan.clamped:
why = (
f"行程不够(关节7 在 {math.degrees(float(q[-1])):+.1f}°,"
f"这一步要转 {math.degrees(delta_rad):+.1f}°"
f"(目标 {math.degrees(float(target_rad)):+.1f}°),限位 "
f"{math.degrees(limits[0]):+.0f}~{math.degrees(limits[1]):+.0f}°)"
)
if not why:
ev = self._rotate_joint_and_verify(
target_rad, tag=tag, fsm_event=fsm_event, speed_deg_s=speed_deg_s
)
if ev is not Event.JOINT_LIMIT:
return ev
why = "轨迹被关节限位截住(算着够、实际没走完)"
return self._ratchet_rewind_then(
delta_rad, tag=tag, fsm_event=fsm_event, speed_deg_s=speed_deg_s, why=why,
)
def _ratchet_rewind_then(
self, delta_rad: float, *, tag: str, fsm_event: Event,
speed_deg_s: Optional[float], why: str,
) -> Event:
"""张开爪 → 反向让一步 → 夹紧 → 转原来那一步。
:param delta_rad: **要转的增量**(不是目标角,见 :meth:`_ratchet_step`)。
让余量时按它反着退一步,重试时再按它前进 —— 两次都以**当时的**
关节角为基准重新算目标,所以让完余量后关节会回到原处、工具净转
``delta_rad``。把"目标"固定成一开始那个绝对值是错的:让完余量之后
那个目标已经在另一圈上了(差 360°)。
"""
if self.grip is None:
self.get_logger().error(f"{tag}: {why},但没有夹爪客户端,无法让余量 → 中止")
return Event.ABORT
self.get_logger().info(
f"{tag}: {why} —— 张开爪让出一步(此刻规在打滑,不会动到螺纹)再转"
)
if not self.grip.open():
self.get_logger().error(f"{tag}: 需要张爪让余量,但开爪失败 → 中止")
return Event.ABORT
time.sleep(self.cfg.screw_release_wait_s)
q, _Tee = self._wait_state(3.0)
if q is None:
return Event.TIMEOUT
ev = self._rotate_joint_and_verify(
float(q[-1]) - delta_rad, tag=f"{tag}_rewind",
fsm_event=fsm_event, speed_deg_s=speed_deg_s,
)
if ev is not fsm_event:
self.get_logger().error(
f"{tag}: 让余量那一步也没走开({ev})—— 关节7 的行程真用尽了,中止。\n"
f" 排查:site.yaml 的 gauge_assembly.screw_step_deg 太接近关节行程了"
f"(Rizon4 的关节7 是 ±175°),棘轮一步要 {math.degrees(abs(delta_rad)):.0f}°,"
f"留给'回转'的余量本来就不够。\n"
f" 改法:把 screw_step_deg 调小(例如 120°),并保证 "
f"screw_max_cycles × screw_step_deg 仍是 **360° 的整数倍** —— "
f"累计必须转整数圈,否则规会停在螺纹中间(S09 拧出也就少转几分之一圈)。"
)
return Event.ABORT
if not self.grip.close():
self.get_logger().error(f"{tag}: 让完余量后夹紧失败 → 中止")
return Event.ABORT
# 重试目标**按让完余量后的位置重算**:让余量退了一步,原来那个绝对目标
# 现在差了 360°(正好一整圈),照原样下发会让关节反向绕一圈回去。
q2, _Tee2 = self._wait_state(3.0)
if q2 is None:
return Event.TIMEOUT
ev = self._rotate_joint_and_verify(
float(q2[-1]) + delta_rad, tag=f"{tag}_retry",
fsm_event=fsm_event, speed_deg_s=speed_deg_s,
)
if ev is not fsm_event:
self.get_logger().error(
f"{tag}: 让完余量重试仍然没走到位({ev})—— 中止"
f"(不是限位问题,先查是不是有东西卡住/螺纹咬死)"
)
return Event.ABORT
return fsm_event
# =====================================================================
# S02 夹持确认(预留)
# =====================================================================
@@ -1200,11 +1452,15 @@ class GaugeAssembly(AssemblyPipeline):
ns = self.cfg.coarse_camera
he = self._eye_to_hand_file()
if he is None:
expect = str(
self.get_parameter("coarse_eye_to_hand_file").value or ""
).strip() or f"handeye/results/{ns}_eye_to_hand.yaml"
self.get_logger().warn(
"S03 需要手外相机的 eye-to-hand 标定文件,现在没有 —— "
f"按兜底逻辑回 S04 跟踪。\n"
f" 相机 {ns} 的标定产物应放在 handeye/results/<名>_eye_to_hand.yaml;\n"
f" 做法:bash scripts/run_handeye.sh --mode eye_to_hand(标完再跑本流程)"
f" 应该生成:{expect}\n"
f" 做法:bash scripts/run_handeye.sh --mode eye_to_hand "
f"teach/merge/calibrate(标完再跑本流程)"
)
return Event.COARSE_FAIL
if not self._wait_coarse_frame(ns):
@@ -1354,7 +1610,9 @@ class GaugeAssembly(AssemblyPipeline):
return Event.TIMEOUT
code, q, Tee, final = self._run_locate(q, Tee)
if code != 0 or not final:
self.get_logger().error(f"S05 精拍定位失败 code={code}")
self.get_logger().error(
f"S05 精拍定位失败 code={code}{_LOCATE_CODE_HINT.get(code, '')}"
)
return Event.TIMEOUT
self._snap_final = dict(final)
self._snap_pose = Tee.copy()
@@ -1535,6 +1793,9 @@ class GaugeAssembly(AssemblyPipeline):
z0 = float(Tee[2, 3])
step_m = self.cfg.contact_step_mm / 1000.0
max_depth_m = self.cfg.contact_max_depth_mm / 1000.0
# 台肩落面所需的下压量。在下面几何自检里算出来;**要留到接触判据那里用**
# (看 hole_entry_verdict:下压深度远小于它 ⇒ 规没进孔)。
needed_m: Optional[float] = None
# ---- 工具几何自检:预算必须是**由几何推出来的**,不能拍 ------------------
# 判"到底了"的那一下是**台肩落到工件上表面**(不是尖端碰面)。工件上表面
@@ -1663,6 +1924,11 @@ class GaugeAssembly(AssemblyPipeline):
f"{ft_ref:+.2f}N 超过 +{self.cfg.contact_axial_delta_n:.1f}N"
f"(下压 {abs(float(Tee[2, 3]) - z0) * 1000:.2f}mm)"
)
# 下压这么浅就"接触" ⇒ 尖端顶在工件上表面,没进孔。必须在这里
# 拦下:放它进 S08 只会干转几轮、憋住,最后报一句"转轴偏差"
# 把所有人引去查工具系。见 hole_entry_verdict 的说明。
if self._hole_lost_guard(z0, Tee, needed_m, q, "腕部 FT"):
return Event.HOLE_LOST
# 必须先卸载再交给 S08 —— 判到接触的这一刻臂上顶着的是**出力上限**
# 那个量级的力(实测 200N+,与阈值无关),而规的轴向传力上限
# 只有 µ·N≈73N。见 _relieve_after_contact 的完整说明。
@@ -1709,6 +1975,9 @@ class GaugeAssembly(AssemblyPipeline):
f"(法向 {fn:.1f},下压 {abs(float(Tee[2, 3]) - z0) * 1000:.2f}mm)"
)
# 与腕部 FT 那条同规矩:判到接触时臂上顶着的力远大于阈值,必须先卸载。
# 同样先过"是不是根本没进孔"这道闸(见 hole_entry_verdict)。
if self._hole_lost_guard(z0, Tee, needed_m, q, "触觉切向"):
return Event.HOLE_LOST
self._relieve_after_contact(q, Tee, "触觉切向", ft_ref)
return Event.CONTACT_OK
if fn - self._fn_rest > self.cfg.contact_safe_delta:
@@ -1745,6 +2014,47 @@ class GaugeAssembly(AssemblyPipeline):
self._retract(q, Tee, dz_m=0.01)
return Event.TIMEOUT
def _hole_lost_guard(
self,
z0: float,
Tee: np.ndarray,
needed_m: Optional[float],
q: np.ndarray,
why: str,
) -> bool:
"""S07 判到接触时:下压深度是不是**远小于**几何预期(= 规没进孔)。
返回 True 表示"确实没进孔",调用方要抬臂并返回 ``Event.HOLE_LOST``。
完整来龙去脉在 ``gauge_fsm.hole_entry_verdict`` 的 docstring 里,这里只
留现场要看的那几句:工件在 S08/S09/S10 会被工具推走,而 k2 的孔位是
S05 一次性定的 ⇒ 工件一动孔位就失效 ⇒ 规落在孔旁边的平面上。
不拦的话会干转 5 轮,最后报一句"转轴偏差…先确认工具系有没有装转接件",
把所有人引去查工具系(实测 2026-09-28)。
"""
if needed_m is None:
# 没算出几何预期(工件上表面 z 没标定且 S05 也没给)—— 没有判据就别猜。
return False
descended_m = abs(float(Tee[2, 3]) - z0)
if hole_entry_verdict(descended_m, needed_m) != ENTRY_HIGH:
return False
self.get_logger().error(
f"S07 接触({why})发生在下压 {descended_m * 1000:.2f}mm 处,"
f"而按几何「台肩落面」需要 {needed_m * 1000:.1f}mm —— 只走了 "
f"{descended_m / needed_m * 100.0:.0f}%。\n"
" **规没有进孔,尖端顶在工件上表面**(正常入孔要压到台肩落面那一下)。\n"
" 最可能的原因:工件被工具推走了,而本孔的坐标是 **S05 一次性定下**的 ——\n"
" 工件一动,那个坐标就失效(S06 的「实测残差」发现不了:它比的是\n"
" 「命令位置 vs 实际位置」,不是「规尖 vs 真实孔位」)。\n"
" 所以**不要**去查工具系/转接件/力阈值(那几个都是好的)。\n"
" 正确处置:把臂退开、确认工件的实际位置;能复位就复位工件,然后\n"
" 从 S05 重新拍照定位(重跑流程即会重新定位)。\n"
" 若要治根:换孔前复核工件没动(当前只有 S05 阶段有 drag 看门狗)。\n"
" 抬起并中止,不带着失效的孔位进 S08。"
)
self._retract(q, Tee, dz_m=0.01)
return True
def _relieve_after_contact(
self,
q: np.ndarray,
@@ -1879,6 +2189,29 @@ class GaugeAssembly(AssemblyPipeline):
# =====================================================================
# S08 旋转装配循环(棘轮)
# =====================================================================
def _log_regrip(self, tag: str) -> None:
"""把"这一轮夹紧后的开口"打出来(含相对夹住时的塌陷量)。
为什么每轮都要打(2026-09-24 实测):
S08/S09 每轮都是"慢转 → 松爪 → 回转 → 夹紧",而 `close` 在仿真里
是"位置控制走到全合 0mm" —— **真机走到力上限就停,仿真会一路挤进规里**。
只报一句"通止规掉了(E1)"时,人分不出"真掉了"和"被挤小了"。
把开口与塌陷量打出来一眼就能分辨:
· 塌陷量逐轮变大、开口还在十几毫米 → 是**被挤**(规还在指间)
· 开口塌到接近 0(两指合到底) → 是**真掉了**
"""
if self.grip is None:
return
ap = self.grip.settle(settle_s=1.0) # 服务返回时动作还没做完,等它停
if ap is None:
return
gp = self.grip.grip_aperture()
col = self.grip.collapse_mm()
detail = ""
if gp is not None and col is not None:
detail = f"(夹住时 {gp:.2f}mm,塌陷 {col:+.2f}mm)"
self.get_logger().info(f"{tag}:开口 {ap:.2f}mm{detail}")
def _step_screw_in(self) -> Event:
hole = self._hole_name()
self._state(f"S08_screw_in_{hole}")
@@ -1895,6 +2228,20 @@ class GaugeAssembly(AssemblyPipeline):
f"停止判据「切向 >+{self.cfg.screw_shear_delta:.0f} "
f"且法向 >+{self.cfg.screw_normal_delta:.0f}」"
)
# 步长 vs 关节行程:**在这里算一次**,不要等第 3 轮贴限位再炸。
# 2026-09-24 实测:180° 的步长配 ±175° 的关节 7,第 3 轮 ABORT,
# 日志是一长串"贴限位 → 让余量 → 还是贴限位",真因(配置)藏在最后。
travel = ratchet_travel_ok(
self.cfg.screw_step_deg, self._wait_joint_limits(), RATCHET_LIMIT_MARGIN_DEG
)
self.get_logger().info(
f"S08 行程核算:一步棘轮要 {travel.need_deg:.0f}°,"
f"关节7 行程 {travel.travel_deg:.0f}°"
f"(最大可用步长 {travel.max_step_deg:.0f}°)—— {'放得下' if travel.ok else '放不下'}"
)
if not travel.ok:
self.get_logger().error(f"S08 配置自检不通过(先改配置,别硬跑):{travel.why}")
return Event.ABORT
for cycle in range(self.cfg.screw_max_cycles):
q, Tee = self._wait_state(5.0)
if q is None or Tee is None:
@@ -1909,9 +2256,10 @@ class GaugeAssembly(AssemblyPipeline):
# --- 四步原子动作,顺序固定(慢转 → 松爪 → 快回转 → 夹紧)---
# 慢转是**顺时针**:与 S01 的逆时针预绕相反,这样预绕行程就是
# 这一整段拧入的回转空间(S01 不预绕,转两圈就贴限位)。
ev = self._rotate_joint_and_verify(
ev = self._ratchet_step(
float(q[-1]) - self.cfg.screw_step_rad, tag=f"S08_slow_{cycle}",
fsm_event=Event.SCREW_DONE, speed_deg_s=self.cfg.screw_speed_deg_s,
direction=-1, # 顺时针拧入(与 S01 的预绕相反)
)
if ev is not Event.SCREW_DONE:
return ev
@@ -1924,15 +2272,17 @@ class GaugeAssembly(AssemblyPipeline):
q, Tee = self._wait_state(3.0)
if q is None:
return Event.TIMEOUT
ev = self._rotate_joint_and_verify(
ev = self._ratchet_step(
float(q[-1]) + self.cfg.screw_step_rad, tag=f"S08_back_{cycle}",
fsm_event=Event.SCREW_DONE, speed_deg_s=self.cfg.screw_back_speed_deg_s,
direction=+1, # 回转:与慢转正好相反
)
if ev is not Event.SCREW_DONE:
return ev
if not self.grip.close():
self.get_logger().error("S08 夹紧失败 —— 下一轮慢转会打滑磨螺纹")
return Event.ABORT
self._log_regrip(f"S08 第 {cycle + 1} 轮夹紧后")
else:
self.get_logger().error("S08 没有夹爪客户端,无法松爪/夹紧,中止")
return Event.ABORT
@@ -1961,9 +2311,17 @@ class GaugeAssembly(AssemblyPipeline):
self._progress(f"{hole} 拧入到位({cycle + 1} 轮)")
return Event.SCREW_STOP
if self.grip is not None and self.grip.dropped():
ap = self.grip.aperture()
gp = self.grip.grip_aperture()
self.get_logger().error(
"S08 通止规掉了(E1)—— 回 S01 流程:张开爪、提示人工放好,"
"确认后继续(按看板:该孔记为完成,换下一个孔)"
"确认后继续(按看板:该孔记为完成,换下一个孔)\n"
f" 开口:夹住时 {gp if gp is None else f'{gp:.2f}mm'}"
f" → 现在 {ap if ap is None else f'{ap:.2f}mm'}"
f"(判据:塌陷 ≥ {GripperClient.DROP_COLLAPSE_MM:.1f}mm)\n"
" 分辨真掉还是被挤:开口塌到接近 0 = 两指合到底(真掉);\n"
" 开口还有十几毫米、只是比夹住时小了几毫米 = 被**挤**"
"(仿真位置控制的「全合」不知道「挡住就停」,见 GripperClient.dropped)"
)
self._e1_pending = True
return Event.GAUGE_DROPPED
@@ -1994,9 +2352,10 @@ class GaugeAssembly(AssemblyPipeline):
if q is None:
return Event.TIMEOUT
# 反向:慢转是逆时针(与 S08 相反),空手回转是顺时针
ev = self._rotate_joint_and_verify(
ev = self._ratchet_step(
float(q[-1]) + self.cfg.screw_step_rad, tag=f"S09_slow_{cycle}",
fsm_event=Event.UNSCREW_DONE, speed_deg_s=self.cfg.screw_speed_deg_s,
direction=+1, # 逆时针拧出
)
if ev is not Event.UNSCREW_DONE:
return ev
@@ -2008,17 +2367,64 @@ class GaugeAssembly(AssemblyPipeline):
q, Tee = self._wait_state(3.0)
if q is None:
return Event.TIMEOUT
ev = self._rotate_joint_and_verify(
ev = self._ratchet_step(
float(q[-1]) - self.cfg.screw_step_rad, tag=f"S09_back_{cycle}",
fsm_event=Event.UNSCREW_DONE, speed_deg_s=self.cfg.screw_back_speed_deg_s,
direction=-1, # 回转:与慢转相反
)
if ev is not Event.UNSCREW_DONE:
return ev
if not self.grip.close():
return Event.ABORT
self._log_regrip(f"S09 第 {cycle + 1} 轮夹紧后")
self._lift_clear_of_hole(hole)
self._progress(f"{hole} 已拧出")
return Event.UNSCREW_DONE
def _lift_clear_of_hole(self, hole: str) -> None:
"""拧出后**先竖直把规提出孔**,之后才允许任何横向动作。
**为什么必须有这一步**(2026-09-24 仿真实测,两次栽在同一个地方):
1. 仿真里**没有螺纹几何**:S09 的"拧出"只是让规在孔里转,规**仍然插在
孔里**(真机会沿螺纹退出来几个毫米,也没退出多少)。
2. 于是紧跟着的横向动作(S10 换孔 / S11 回 home)会把规从孔里**拽**
出来 —— 实测抓取锁被拽断、规掉在台面上,而下游只表现为
"下一孔 S07 下探 35mm 判不到接触"(工具上已经没规了),
日志完全指不到真因。
3. 真机也怕这个动作:螺纹刚脱开时横向别住最容易啃伤螺纹。标准做法就是
**先竖直退出来,再走 XY**。
高度提到**跟踪高**(``_track_z()`` = 孔悬停高 + 50mm)而不是写死毫米数:
跟踪高是这条流程里"工具已离开工件"的既有定义(``_ensure_clearance``
也用它),拿它当判据不用再维护第二套口径。实测:不提出时规尖比工件面
**低 30mm**,而 S10 的斜线只往上抬了 29mm —— 差 1mm 就是"拽出孔"和
"干净退出"的分界,所以必须是**先后有序**的两个动作,不能靠斜线一起走。
失败只告警(不失败):这一刻孔里已经没有规要保护了,硬失败会把一次成功
的装配变成失败;后面的 S10/S11 仍会走到安全位。
"""
try:
q, Tee = self._wait_state(3.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.get_logger().info(
f"S09 {hole} 拧出后竖直提出:Z {z:.3f} → {tz:.3f}m"
"(先离开孔口,再做横向动作)"
)
_, q2, _Tee2 = 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=f"S09_exit_{hole}", v=self.move_speed, max_hops=8,
hold_xy=True, settle_s=0.25,
)
except Exception as exc: # noqa: BLE001
self.get_logger().warn(f"S09 {hole} 提出孔口失败(继续走 S10/S11):{exc}")
# =====================================================================
# S10 换孔 / S11 复位
# =====================================================================
@@ -254,7 +254,9 @@ DEFAULTS: Dict[str, Any] = {
"contact_hold_n": 5.0,
"contact_safe_delta": 2000.0,
# S08
"screw_step_deg": 180.0,
# 步长必须满足 2×step + 2×余量 ≤ 关节7 行程(Rizon4/10 ±175° ⇒ step ≤ 167°):
# 180° 会必然贴限位(棘轮一个来回要 360° > 350°),实测第 3 轮 ABORT。
"screw_step_deg": 120.0,
"screw_speed_deg_s": 30.0,
"screw_back_speed_deg_s": 120.0,
"screw_max_cycles": 12,
@@ -79,6 +79,10 @@ class Event(str, Enum):
CONTACT_OK = "contact_ok"
FORCE_OVER_LIMIT = "force_over_limit" # 力超安全上限 → 抬起
#: S07 判到接触、但**下压深度远小于几何预期** ⇒ 规没进孔,顶在工件上表面。
#: 单列一个事件是为了让结论与日志都是实话:复用 FORCE_OVER_LIMIT 会把这种
#: 情况报成"力超安全上限",而它的力根本没超限 —— 又是一句误导。
HOLE_LOST = "hole_lost"
SCREW_DONE = "screw_done"
SCREW_STOP = "screw_stop" # 力判据满足:切向力>阈值 且 法向力突增
@@ -158,6 +162,7 @@ TRANSITIONS: Dict[Tuple[State, Event], State] = {
# --- S07 柔顺接触 ---
(State.CONTACT, Event.CONTACT_OK): State.SCREW_IN,
(State.CONTACT, Event.FORCE_OVER_LIMIT): State.ABORT,
(State.CONTACT, Event.HOLE_LOST): State.ABORT,
# --- S08 旋转装配循环 ---
(State.SCREW_IN, Event.SCREW_DONE): State.UNSCREW,
(State.SCREW_IN, Event.SCREW_STOP): State.UNSCREW,
@@ -406,6 +411,40 @@ def wrench_live(age_s: Optional[float]) -> bool:
return age_s is not None and float(age_s) <= WRENCH_MAX_AGE_S
#: 判定"六个分量全为 0"用的容差 [N]/[Nm]。真机 FT 的噪声量级是 1e-2~1e-1,
#: 所以 1e-6 足够区分"真的是 0(数据没出来)"与"很小但活的读数"。
WRENCH_ZERO_EPS: float = 1.0e-6
def wrench_all_zero(
fx: float,
fy: float,
fz: float,
tx: float,
ty: float,
tz: float,
eps: float = WRENCH_ZERO_EPS,
) -> bool:
"""六个分量**同时**为 0(容差 ``eps``)⇒ 这是"数据没出来"的指纹,不是"安静"。
与 :func:`wrench_live` 的分工(两个都要有,缺一不可):
* ``wrench_live`` 只看"够不够新",**故意不看数值** —— 静止时轴向力本来就接近 0,
拿数值判活会把"安静的传感器"误判成掉线(2026-09-24 触觉上踩过)。
* 但一个**带着负载**的腕部 FT 不可能六个分量**同时**恰好为 0:重力、装配预紧、
传感器零漂至少让某个分量不为 0。所以"全零"是另一种失效 —— **数据源没出数**
(话题名对、但内容是空的;或 RDK 侧没启用该传感)。
2026-10-08 真机实测:应用在用的 ``/<sn>/ft_sensor_wrench`` 就是恒 (0,0,0),
于是"``abs(fz−基线)>2.5N`` 判接触"的兜底**永远触发不了** —— 而日志里一切正常
(话题收得到、回调在跑、``wrench_live`` 报"活"),属于最危险的静默失效。
改用 ``/<sn>/external_wrench_in_tcp`` 后有数(≈负载 54N)即可恢复兜底。
所以:``wrench_live`` 通过 **且** ``wrench_all_zero`` 为真 ⇒ 判为"数据源是空的"。
"""
return all(abs(float(v)) <= eps for v in (fx, fy, fz, tx, ty, tz))
#: S07 单步下压给 ``_goto`` 的到位容差 = 步长的这个比例。
#: **必须 < 1,而且只能小不能大**:``_goto`` 的语义是"已经落在容差里就直接返回、
#: 一点都不动"。容差 ≥ 步长时,每一次"往下走 0.5mm"的请求都被判成"已在容差内"
@@ -513,6 +552,209 @@ def tool_clearance_m(
return tip_z - float(surface_z_m)
def hover_z_for_tool_clearance(
surface_z_m: float,
tool_tip_below_flange_m: float,
clearance_m: float,
) -> float:
"""让工具尖端停在工件面上方 ``clearance_m`` 所需的**法兰**高度 [m]。
就是 ``tool_clearance_m`` 的逆运算 —— 写成一个函数是为了让"该用多高"这个
结论只有一个来源(下面 ``hover_z_guard`` 与调用方都用它,不各推一遍)。
"""
return float(surface_z_m) + float(tool_tip_below_flange_m) + float(clearance_m)
def hover_z_guard(
hover_z_m: float,
surface_z_m: float,
tool_tip_below_flange_m: float,
want_clearance_m: float,
) -> Tuple[float, str]:
"""二阶段悬停高度的自检 → ``(该用的悬停高度, 说明; 不用改时说明是空串)``。
**为什么必须有这条**(2026-09-24 实测栽在这上面):
悬停高度是**按相机**定的(腕部相机要离工件面 ~0.17m 才看得清孔),
而工具(夹爪 + 通止规)可能比相机长得多 —— 仿真里实测
``tool_tip_below_flange_m=0.245``,而悬停算的是
``0.412 + 0.17 + 0.05 = 0.632`` ⇒ 工具尖端在工件上表面**下面 25mm**。
后果不在 S07(S07 有自己的几何自检),而在**更早的 S05**:
"悬停"这一步本身就把工具捅进工件里了,于是 S05 的横向对准等于
拿规尖去拨工件 —— 实测把工件从 x=0.520 拖到 0.4284(**92mm**)。
而真值孔位是按工件的**实测**位姿算的(``sim_live_pose``,这是对的),
工件一走,孔位就跟着工具走 ⇒ 残差恒定在 13mm、迭代 9 次永不收敛
(日志:``[fine 3..8] 残差=13.25/13.19/…/11.79mm``)。
现象看起来像"手眼标定坏了 / 检测不认孔"。
真机同参数同结果:这不是仿真专有,只要"悬停按相机定、工具比相机长"就会这样。
判据取"明显低于目标间隙"而不是"一毫米都不许低":配置的悬停与实测的
工件面 z 都有几毫米的不确定度,拿 0 当界会把正常配置判成错的。
"""
clear_m = tool_clearance_m(hover_z_m, tool_tip_below_flange_m, surface_z_m)
if clear_m >= float(want_clearance_m) - 0.001:
return float(hover_z_m), ""
need_z = hover_z_for_tool_clearance(surface_z_m, tool_tip_below_flange_m, want_clearance_m)
why = (
f"悬停高度会让工具尖端钻进工件:法兰 {float(hover_z_m):.4f} − 工具长 "
f"{float(tool_tip_below_flange_m) * 1000:.1f}mm = 尖端 "
f"{float(hover_z_m) - float(tool_tip_below_flange_m):.4f},工件上表面 "
f"{float(surface_z_m):.4f} → 尖端在面{'下' if clear_m < 0 else '上仅'} "
f"{abs(clear_m) * 1000:.1f}mm(要求 ≥ {float(want_clearance_m) * 1000:.1f}mm)。\n"
f" 悬停高度通常是按**相机**视距定的,而工具比相机长 —— 两者冲突时必须"
f"先保证不碰工件。\n"
f" 已按几何抬到 {need_z:.4f}(= {float(surface_z_m):.3f} 上表面 + "
f"{float(tool_tip_below_flange_m) * 1000:.0f}mm 工具 + "
f"{float(want_clearance_m) * 1000:.1f}mm 间隙)。\n"
f" 要固定这个值:改 site.yaml 的 robots[<sn>].servo.hole_hover_z_m = "
f"{need_z:.3f}(以及同组件的 ee_z_m ≥ 它 +0.05)。"
)
return need_z, why
def resolve_hover_z(
hover_z_m: float,
surface_z_m: float,
tool_tip_below_flange_m: float,
want_clearance_m: float,
user_set: bool,
) -> Tuple[float, str]:
"""返回 ``(要用的法兰悬停高, 拒绝原因)``。
启动参数写了高度:不安全就拒绝,原因非空,高度原样返回,调用方不许改成别的数。
没写(用 site):沿用 :func:`hover_z_guard` 的几何抬高,拒绝原因留空。
"""
fixed, why = hover_z_guard(
hover_z_m, surface_z_m, tool_tip_below_flange_m, want_clearance_m
)
if not why:
return float(hover_z_m), ""
if not user_set:
return float(fixed), ""
clear_m = tool_clearance_m(hover_z_m, tool_tip_below_flange_m, surface_z_m)
return float(hover_z_m), (
f"悬停高度会让工具尖端进入工件:法兰 {float(hover_z_m):.4f} − 工具长 "
f"{float(tool_tip_below_flange_m) * 1000:.1f}mm,工件面 {float(surface_z_m):.4f},"
f"间隙 {clear_m * 1000:.1f}mm,要求 ≥ {float(want_clearance_m) * 1000:.1f}mm。"
"这个值是启动参数指定的,不会改成别的高度。"
)
#: S05 精对位时判"工件被工具推走"的两级阈值 [m]。
#
# 为什么需要这条判据(2026-09-24 又栽了一次,见下面 drag_verdict 的说明):
# S05 的对准是"看着孔走"的闭环,而孔位是**工件上**的。工具一旦碰到工件,
# 闭环追的就不再是孔,而是"自己推着走的工件" —— 残差恒定(本轮 11mm)、
# 迭代用满,日志报的是"检查手眼/相机安装",与真因(工具比相机长、
# 悬停时已经插进工件)差着十万八千里。
# 取 3mm:工件是刚体,两拍之间它整体挪动 >3mm 只可能是被推/被撞(正常加工
# 过程里它一动不动)。同一对孔的两拍测量噪声在 0.2mm 量级,离阈值很远。
SNAP_DRAG_TOL_M: float = 0.003
#: 单拍就这么大 ⇒ 不必等第二拍确认(刚体不会自己"挪回去")。
SNAP_DRAG_HARD_M: float = 0.006
#: "已经到位"的容差:避免 ±1e-9 的浮点噪声变成"再转一整圈"。
_RATCHET_FULL_TURN_EPS: float = 1e-9
def ratchet_delta_rad(target_rad: float, q_now_rad: float, direction: int = 0) -> float:
""""目标角 − 当前角",折成**一次**能转到的增量 [rad]。
**为什么要有这个函数**(2026-09-24 实测:棘轮把整条流程 ABORT 在 S09 第一步):
S08/S09 给棘轮的是**关节 7 的目标角(绝对值)**(``q[-1] ± step``),
而棘轮此前把它当**增量**用。于是"关节在 +158.9°、目标 +338.9°"被算成
"要转 338.9°"(实际增量只有 180°)⇒ 误判行程不够 ⇒ 让余量那一步算到
限位外面(``158.9 − 338.9 = −180°``,比 −175° 还低)⇒ 报
"期望 −338.9°、实测 +26.0°,偏差过大" ⇒ ABORT。现场什么坏事都没发生。
差值必须折一圈:目标常常**故意**超出限位(这正是要"让余量"的原因),
``+338.9°`` 与 ``−21.1°`` 是同一个关节位置。
**为什么还要 direction**(2026-09-24 又一次实测:S08 第 3 轮 ABORT):
不给方向时这里取"最短的那条路",而名义步长**正好是 180°** 时,
哪条路更短取决于 0.1° 级的读数差:调用方按自己读到的 ``q`` 算出目标
``−18.0°``,棘轮再用**稍晚一点**读到的 ``q=+162.1°`` 去算差值,
``−180.1°`` 折一圈就成了 ``+179.9°`` —— 方向**翻了个个儿**。
于是棘轮朝螺纹的反方向走,接着贴限位、让余量、重试,净位移 0 ⇒ ABORT。
棘轮的转向是**工艺意图**(S08 顺时针拧入、S09 逆时针拧出),不能交给
"谁更短"这种数值巧合决定,所以由调用方明确给出 ``direction``。
注:``direction=0``(默认)保留旧的"最短路径"语义,给"目标本来就明确
在某一侧"的调用方用;棘轮的四个原子步一律显式给方向。
:param target_rad: 目标角(绝对值,可以是限位外的角度)。
:param q_now_rad: 当前关节角。
:param direction: 想要的转向:``+1`` 正转、``-1`` 反转、``0`` 取最短路径。
:returns: 增量 [rad]。``direction=0`` 时落在 ``(−π, π]``;给了方向时落在
``[0, 2π)``(正转)或 ``(−2π, 0]``(反转)——**一定**沿指定方向,
且是能落到目标角的最小那一步。**同一个目标、同一个当前位置**只会有一个
增量 —— 棘轮的"让余量"与"重试"都按它算,两次不会互相矛盾。
"""
base = float(target_rad) - float(q_now_rad)
two_pi = 2.0 * math.pi
if direction == 0:
return math.remainder(base, two_pi)
# 先折到 [0, 2π),再按想要的转向取那一侧(与目标角差正好一整圈,是同一个位置)。
pos = math.fmod(base, two_pi)
if pos < 0.0:
pos += two_pi
if pos >= two_pi - _RATCHET_FULL_TURN_EPS:
pos = 0.0 # 已经(几乎)在目标上:不要为了凑方向白转一整圈
if direction > 0:
return pos
return pos - two_pi if pos > 0.0 else 0.0
def drag_verdict(
mid_now: Tuple[float, float],
mid_prev: Optional[Tuple[float, float]],
mid_prev2: Optional[Tuple[float, float]] = None,
*,
tol_m: float = SNAP_DRAG_TOL_M,
hard_m: float = SNAP_DRAG_HARD_M,
) -> Tuple[bool, float]:
"""两孔中点是否在两拍之间"整体挪动" → ``(是否判定为被推走, 本拍位移 m)``。
**为什么看"中点"而不是看目标孔**:目标孔是**追**的对象,工具一动它就该动
(对准本意如此),看它分不出"正常对准"和"推着工件走"。而两孔**中点**在
对准过程中不该动 —— 它只有工件整体平移时才会动。所以中点位移干净地对应
"工件被搬动了"。
**为什么看"两孔中点"而不是单孔**:单孔在相机里会因分辨率/遮挡抖动,且
目标孔本来就在被逼近;中点是两者的平均,噪声更小、语义更明确。
:param mid_now: 本拍两孔中点 (x, y)[m](基座系)。
:param mid_prev: 上一拍中点;``None`` = 还没有上一拍(第一拍不判)。
:param mid_prev2: 上上拍中点。用来确认"位移是持续的"(单拍可能是误检)。
:returns: ``(判定, 本拍位移 m)``。
"""
if mid_prev is None:
return False, 0.0
dx = float(mid_now[0]) - float(mid_prev[0])
dy = float(mid_now[1]) - float(mid_prev[1])
d1 = math.hypot(dx, dy)
if d1 >= float(hard_m):
return True, d1
if d1 < float(tol_m) or mid_prev2 is None:
return False, d1
px = float(mid_prev[0]) - float(mid_prev2[0])
py = float(mid_prev[1]) - float(mid_prev2[1])
d2 = math.hypot(px, py)
# 第二拍确认:上一拍也挪了(≥ 阈值的一半),且方向一致(点积 > 0)。
# 为什么允许方向只"大致一致":工件被推着走时前后两拍的推动量本来就不同
# (工件一让开,工具就少推一点),拿夹角严格相等会漏判。
if d2 >= float(tol_m) * 0.5 and (dx * px + dy * py) > 0.0:
return True, d1
return False, d1
def descend_needed_m(
flange_z_m: float,
tool_tip_below_flange_m: float,
@@ -555,6 +797,58 @@ def descend_budget_m(needed_m: float) -> float:
return max(float(needed_m) * DESCEND_BUDGET_FRAC, DESCEND_BUDGET_FLOOR_M)
#: ``hole_entry_verdict`` 的结论。
ENTRY_OK = "ok" # 接触深度与几何预期相符 ⇒ 规是从孔里下去的、台肩落面
ENTRY_HIGH = "high" # 早早就接触 ⇒ 尖端顶在工件**上表面**上,根本没进孔
#: 判"没进孔"的下压深度比例阈值。
#: 实测两个锚点(2026-09-28,仿真):
#: · 正常入孔:下压 28.6mm / 需要 31.9mm ⇒ 0.90
#: · 顶在平面上:下压 0.90mm / 需要 31.9mm ⇒ 0.03
#: 两者差着一个台肩高度(30mm),取 0.5 时两侧余量都很大(正常那侧要掉到
#: 一半才会误判)。**不要**为了"更灵敏"把它往 0.9 推 —— 台肩倒角没完全落面、
#: 板厚公差都会让正常值在 0.85~0.95 之间晃。
HOLE_ENTRY_MIN_RATIO: float = 0.5
def hole_entry_verdict(
descended_m: float,
needed_m: float,
*,
min_ratio: float = HOLE_ENTRY_MIN_RATIO,
) -> str:
"""S07 判到接触的那一刻:规是**进了孔**、还是**顶在工件上表面**。
为什么需要它(2026-09-28 实测,同一命令三跑里坏一跑:DONE / ABORT / DONE):
工件在 S08/S09/S10 阶段会被工具推走(仿真里实测被推 20~52mm)。而 k2 的
孔位是 **S05 一次性定下**的(``_step_hover_k`` 取 ``_hole_xy``),工件一动
它就失效。于是规落在孔**旁边的平面**上,只下压「悬停间隙」那么点就判到接触。
不拦的代价,实测完整发生过一次:
S08 干转 5 轮(切向增量 0.0、法向增量 ±0.0,**一点没进给**)→ 第 6 轮憋住 →
报出来的是「转轴偏差 7.6° 超过阈值 —— 关节7 轴与工具 Z 不共线,
先确认工具系是否垂直向下、有没有装转接件」。
**那句话会把所有人引到错误方向**:真因既不是工具系也不是转接件,是孔位失效。
而且为这句误导,白烧了 5 轮无效拧入(≈30 秒)和一次憋劲(有把规顶掉的风险)。
为什么比 ``needed_m`` 而不是比"尖端离面高度":正常入孔时接触发生在**台肩落面**
那一刻 ≈ ``needed_m``;顶在平面上时接触发生在**尖端刚碰到面**那一刻,而台肩还在
尖端上方 30mm。两者差着一个台肩高度,所以用比例判最稳(见上面两个实测锚点)。
纯函数(R5.1):判错两个方向都贵 —— 把正常的判成"没进孔"会白扔一整个孔;
把"没进孔"判成正常,就是上面那 30 秒 + 一次误导。
``needed_m <= 0`` 时**不判**(几何本身已经错了,上面那条自检会报),
返回 ``ENTRY_OK`` 不在这里抢着报第二个错。
"""
if needed_m <= 0.0:
return ENTRY_OK
if float(descended_m) < float(needed_m) * float(min_ratio):
return ENTRY_HIGH
return ENTRY_OK
def screw_stop(normal_delta: float, shear_max: float, normal_th: float, shear_th: float) -> bool:
"""S08:旋转装配的停止判据 —— **切向力>阈值 且 法向力突增**。
@@ -584,6 +878,115 @@ def ratchet_cycles(total_deg: float, step_deg: float) -> int:
return int(math.ceil(total_deg / step_deg))
@dataclass(frozen=True)
class RatchetTravel:
"""棘轮步长与关节行程的**可用性核算**(S08 开头自检,别等到第 3 轮才炸)。"""
ok: bool
step_deg: float # 配置的一步转角
travel_deg: float # 关节行程(上限 − 下限)
need_deg: float # 一步棘轮真正需要的行程
max_step_deg: float # 行程允许的最大步长
why: str # 不 ok 时给人看的说明(ok 时为空串)
def ratchet_travel_ok(
step_deg: float, limits_rad: Optional[Tuple[float, float]], margin_deg: float
) -> RatchetTravel:
"""一步棘轮 ``step_deg`` 放得进关节行程吗?
**为什么需要**:2026-09-24 实测,``screw_step_deg=180`` 配 Rizon4 的
关节 7(±175°,行程 350°)在第 3 轮 ABORT —— 慢转那一步要转 180°、
回转那一步还要 180°,两次加起来 360° > 350°,关节**必然**有一头贴限位;
更糟的是"让余量"也腾不出地方(要腾就得往反向再让一整步,还是 360°)。
日志里那一长串"贴限位 → 让余量 → 还是贴限位"就是这么来的。
判据取 ``2×step + 2×margin ≤ 行程``:
- 2×step:棘轮一个来回(慢转 + 回转)要的行程;
- 2×margin:两头都要留余量 —— 贴死限位再让余量时**一点空间都没有**,
而实测每轮回转还会比指令少走 4~5°(速度/加减速限制),
这点亏欠要靠余量吃,否则 7~8 轮后累计偏出限位、再也转不回来。
:param limits_rad: ``(lower, upper)`` [rad];``None`` = 拿不到限位,放行
(返回 ``ok=True``,理由写清楚:真机靠驱动器报 JOINT_LIMIT 兜底)。
"""
need = 2.0 * float(step_deg) + 2.0 * float(margin_deg)
if limits_rad is None:
return RatchetTravel(
ok=True, step_deg=float(step_deg), travel_deg=float("inf"),
need_deg=need, max_step_deg=float("inf"),
why="",
)
travel = math.degrees(float(limits_rad[1]) - float(limits_rad[0]))
max_step = 0.5 * (travel - 2.0 * float(margin_deg))
if need <= travel:
return RatchetTravel(
ok=True, step_deg=float(step_deg), travel_deg=travel,
need_deg=need, max_step_deg=max_step, why="",
)
# 给个能直接照着改的步长:往整十度取整(现场习惯填 120/150/180),
# 但不许把步长压到 30° 以下 —— 那样拧进/拧出的轮数会多到失控。
suggest = max(30.0, math.floor(max_step / 10.0) * 10.0)
why = (
f"棘轮一步 {step_deg:.0f}° 放不进关节 7 的行程:一步棘轮要 "
f"2×{step_deg:.0f}° + 2×{margin_deg:.0f}°(余量) = {need:.0f}°,"
f"而行程只有 {travel:.0f}°(上限 {math.degrees(limits_rad[1]):.0f}° − "
f"下限 {math.degrees(limits_rad[0]):.0f}°)。\n"
f" 改法:site.yaml 的 gauge_assembly.screw_step_deg 改到 "
f"{max_step:.0f}° 以内(例如 {suggest:.0f}°),并保证 "
f"screw_max_cycles × screw_step_deg 仍是 **360° 的整数倍**("
f"累计必须转整数圈,否则规停在螺纹中间、S09 也就少拧出几分之一圈)。"
)
return RatchetTravel(
ok=False, step_deg=float(step_deg), travel_deg=travel,
need_deg=need, max_step_deg=max_step, why=why,
)
#: ``rotation_verdict`` 的结论。
ROT_OK = "ok"
ROT_LIMIT = "limit" # 转了一部分、方向对、但没走完 ⇒ 最可能是贴到关节限位
ROT_AXIS = "axis" # 转轴与工具 Z 不共线 ⇒ 以「度」为单位的一切动作都不成立
ROT_SHORT = "short" # 其它偏差(方向不对/过冲)⇒ 真有问题,该停
def rotation_verdict(
delta_deg: float,
got_deg: float,
mis_deg: float,
tol_deg: float,
) -> str:
"""绕工具 Z 转 ``delta_deg``、实测转 ``got_deg`` 的验收判据。
**纯函数**:这条判据 2026-09-24 把整条 S09 判死过一次
("期望 +180.0°、实测 +16.4°,偏差过大" → ABORT),而真因只是
**关节7 的行程被前面的棘轮回转顶到了上限**(±175°,而棘轮一步就要 180°)。
判错两个方向都贵:
* 把"贴限位"判成"short" ⇒ 白扔一整轮(还可能把规撞歪);
* 把"真有问题"判成"limit" ⇒ 该停的没停,继续拧。
还有一条**曾经漏判**的:只比绝对值时,"让它 +180、它 -170"会算成
10° 误差、落在 10% 容差里 ⇒ 判成功,而实际上一个孔都没拧。所以方向必须比。
所以它必须能脱离节点被钉住。
"""
if abs(got_deg) < 3.0 and abs(delta_deg) > 10.0:
return ROT_LIMIT # 几乎没动:与下面的"只走了一部分"同因不同余量
if mis_deg > tol_deg:
return ROT_AXIS
if abs(got_deg) >= 3.0 and abs(delta_deg) >= 3.0 and got_deg * delta_deg < 0:
# 方向反了。这条以前**漏判**:老判据只比绝对值
# (``abs(abs(got) - abs(delta))``),所以"让它 +180、它 -170"会算 10° 误差
# 落在 10% 容差里 → 判成功,而实际上一个孔都没拧。
return ROT_SHORT
slack = max(tol_deg, 0.1 * abs(delta_deg))
short = abs(delta_deg) - abs(got_deg)
if short > slack:
return ROT_LIMIT # 方向对、只是没走完 ⇒ 被硬止点截住
if short < -slack:
return ROT_SHORT # 过冲:转多了,同样不该当成到位
return ROT_OK
def is_joint_limit_error(text: str) -> bool:
"""S01:把驱动/规划报的字符串认成"撞到关节极限"。
@@ -646,6 +1049,7 @@ EVENT_DOC: Dict[Event, str] = {
Event.ALIGN_OUT: "对位超差",
Event.CONTACT_OK: "已接触工件面",
Event.FORCE_OVER_LIMIT: "力超安全上限",
Event.HOLE_LOST: "孔位失效(规没进孔)",
Event.SCREW_DONE: "达到目标圈数",
Event.SCREW_STOP: "力判据满足(咬住螺纹)",
Event.GAUGE_DROPPED: "通止规掉了",
@@ -26,6 +26,9 @@ import numpy as np
#: 直接拒绝整条轨迹 —— 那不是我们要的"极限角",是报错。
DEFAULT_LIMIT_MARGIN_DEG = 3.0
#: 厂家 A7。``Robot::SendJointPosition`` 的关节向量按 A1..A7 排列,A7 是最后一轴。
A7_INDEX = 6
_JOINT_RE = re.compile(r"<joint\s+name\s*=\s*\"([^\"]+)\"")
_ATTR_RE = re.compile(r"(lower|upper)\s*=\s*\"([-+0-9.eE]+)\"")
@@ -178,6 +181,43 @@ def prewind(
)
def a7_limit_target(
q: Sequence[float],
limits: Optional[Tuple[float, float]],
direction: int,
margin_deg: float = DEFAULT_LIMIT_MARGIN_DEG,
) -> Optional[np.ndarray]:
"""只把 A7 收到软件限位内侧。前六轴原样拷回,不许在这里改。
厂家接口是 ``Robot::SendJointPosition``(模式 ``NRT_JOINT_POSITION``)。
向量按 A1..A7 排,A7 是下标 ``A7_INDEX``。限位是 ``RobotInfo::q_min/q_max``
的那一对,真机上与 URDF ``joint7`` 的 lower/upper 是同一组数。
``direction`` 与 ``prewind`` 相同:``+1`` 关节角增大 = 逆时针,目标取上限;
``-1`` 关节角减小 = 顺时针,目标取下限。现场若发现正负反了,改
``site.yaml`` 的 ``extreme_direction``,不要改这里的符号。
没有限位、方向不是 ±1、或者余量把上下限挤没了,返回 None。
调用方必须停,不许猜一个角度发出去。
"""
if direction not in (-1, 1):
raise ValueError(f"方向只能是 +1 / -1,收到 {direction}")
arr = np.asarray(q, dtype=float).reshape(-1)
if arr.size != 7:
raise ValueError(f"A7 目标要 7 个关节角,收到 {arr.size}")
if limits is None:
return None
lo = float(limits[0])
up = float(limits[1])
margin = math.radians(float(margin_deg))
if not math.isfinite(lo) or not math.isfinite(up) or up - lo <= 2.0 * margin:
return None
target = (up - margin) if direction > 0 else (lo + margin)
out = arr.copy()
out[A7_INDEX] = target
return out
def rotation_about_axis_deg(R_before: np.ndarray, R_after: np.ndarray, axis: np.ndarray) -> float:
"""从两个旋转矩阵算出"绕 ``axis`` 转了多少度"(带符号)。
@@ -39,6 +39,8 @@ from arm_application.site_io import (
joint_prefix_default,
)
from arm_application.ros_node import TolerantNode
def _load_ee_cfg() -> dict:
@@ -78,7 +80,7 @@ def _pt(q: np.ndarray, qdot: np.ndarray, t_sec: float) -> JointTrajectoryPoint:
return pt
class GotoEePlane(Node):
class GotoEePlane(TolerantNode):
def __init__(self, cfg: dict) -> None:
super().__init__("goto_ee_plane")
self.cfg = cfg
@@ -33,8 +33,11 @@ from arm_application.handeye_lib import (
BoardConfig,
CameraIntrinsics,
ChessboardStabilizer,
T_from_flexiv_tcp,
calibrate_hand_eye,
cam_to_flange_via_tool,
detect_chessboard,
format_matrix_yaml,
format_transform_yaml,
is_pose_filled,
pose_to_Rt,
@@ -50,6 +53,8 @@ from arm_application.site_io import joint_prefix_default
from arm_msgs.action import FollowJointTrajectory
from arm_msgs.srv import GetArmState
from arm_application.ros_node import TolerantNode
DEFAULT_JOINTS = [
'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7',
]
@@ -79,7 +84,7 @@ def _camera_info_to_intrinsics(msg: CameraInfo) -> CameraIntrinsics:
return CameraIntrinsics(K=K, dist=dist)
class HandeyeCalibrate(Node):
class HandeyeCalibrate(TolerantNode):
def __init__(self) -> None:
super().__init__('handeye_calibrate')
@@ -111,11 +116,10 @@ class HandeyeCalibrate(Node):
# 棋盘(可自定义)
# 默认值必须与 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)
# 真实使用的板:A5 内角点 11x8,方格 15mm(site.yaml handeye.boards.eye_in_hand)。
# 这里原先写的是 9x6 / 0.025,后来是 8x11;漏配就会静默用错尺寸。
self.declare_parameter('board_cols', 11) # 内角点
self.declare_parameter('board_rows', 8)
self.declare_parameter('square_size_m', 0.015)
self.declare_parameter('stable_window', 8)
@@ -136,6 +140,11 @@ class HandeyeCalibrate(Node):
self.declare_parameter('output_dir', '/home/cc/ros2/handeye/tmp')
self.declare_parameter('result_file', '')
self.declare_parameter('cleanup_images', True)
# 眼在手上:AX=XB 的 gripper 用 Studio 工具 TCP,再折成 cam→法兰写 T_4x4。
# 空 = 旧口径(ee_pose 当 gripper,T_4x4 原样写出)。配了就必须带 TCP。
self.declare_parameter('gripper_tool', '')
self.declare_parameter('gripper_tcp_xyz', [0.0, 0.0, 0.0])
self.declare_parameter('gripper_tcp_qwxyz', [1.0, 0.0, 0.0, 0.0])
prefix = str(self.get_parameter('joint_prefix').value or '')
base = list(
@@ -150,6 +159,21 @@ class HandeyeCalibrate(Node):
self.start_index = max(0, int(self.get_parameter('start_index').value))
self.min_poses = int(self.get_parameter('min_poses').value)
self.cleanup_images = bool(self.get_parameter('cleanup_images').value)
self.gripper_tool = str(self.get_parameter('gripper_tool').value or '').strip()
tcp_xyz = list(
self.get_parameter('gripper_tcp_xyz').get_parameter_value().double_array_value
or [0.0, 0.0, 0.0]
)
tcp_q = list(
self.get_parameter('gripper_tcp_qwxyz').get_parameter_value().double_array_value
or [1.0, 0.0, 0.0, 0.0]
)
self.T_flange_to_tool = T_from_flexiv_tcp(tcp_xyz[:3], tcp_q[:4])
if self.gripper_tool and float(np.linalg.norm(self.T_flange_to_tool[:3, 3])) < 1e-4:
raise RuntimeError(
f'gripper_tool={self.gripper_tool!r} 但 gripper_tcp_xyz 几乎是零:'
'等于没把 Studio TCP 传进来。不许按法兰口径假装在用工具系。'
)
self.camera_source = str(self.get_parameter('camera_source').value).strip().lower()
self.camera_serial = str(self.get_parameter('camera_serial').value or '').strip()
@@ -263,10 +287,12 @@ class HandeyeCalibrate(Node):
self.images_dir.mkdir(parents=True, exist_ok=True)
eye_to_hand = self.mode == 'eye_to_hand'
grip = self.gripper_tool or '(ee_pose 原样,无工具折算)'
self.get_logger().info(
f'手眼标定 [{self.mode}] camera_source={self.camera_source}:'
f'{len(poses)} 个位姿,棋盘={self.board.cols}x{self.board.rows} '
f'方格={self.board.square_size}m,v={self.velocity_scale}'
f'方格={self.board.square_size}m,v={self.velocity_scale} '
f'gripper_tool={grip}'
)
self.get_logger().warn('清理工作空间;确认急停就绪。Ctrl+C 可中止。')
@@ -366,12 +392,58 @@ class HandeyeCalibrate(Node):
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)
extra = ''
if eye_to_hand:
name = 'T_cam_to_base (eye_to_hand)'
yaml_txt = format_transform_yaml(R, t, name)
if self.gripper_tool:
extra = (
f'gripper_tool: {self.gripper_tool}\n'
+ format_matrix_yaml(self.T_flange_to_tool, 'T_flange_to_tool')
)
self.get_logger().info(
f'gripper={self.gripper_tool}:AX=XB 的手是 TCP;'
f'T_4x4 是 cam→基座,不折 t={t.reshape(3)*1000} mm'
)
elif self.gripper_tool:
T_cam_tool = np.eye(4)
T_cam_tool[:3, :3] = R
T_cam_tool[:3, 3] = t.reshape(3)
T_cam_flange = cam_to_flange_via_tool(T_cam_tool, self.T_flange_to_tool)
delta = float(np.linalg.norm(T_cam_flange[:3, 3] - T_cam_tool[:3, 3]))
tool_len = float(np.linalg.norm(self.T_flange_to_tool[:3, 3]))
if tool_len > 0.05 and delta < 1e-4:
self.get_logger().error(
'T_cam_to_flange 与 T_cam_to_tool 平移几乎相同,'
'折算没生效。结果作废。'
)
return 11
# 腕部相机在法兰前方(法兰 +Z)。乘反了 Z 会落到 −200 mm 一带。
if (
self.T_flange_to_tool[2, 3] > 0.05
and float(T_cam_flange[2, 3]) < 0.0
):
self.get_logger().error(
f'T_cam_to_flange tz={float(T_cam_flange[2, 3])*1000:.1f} mm '
'在法兰后方,乘序反了。结果作废。'
)
return 12
R, t = T_cam_flange[:3, :3], T_cam_flange[:3, 3].reshape(3, 1)
name = 'T_cam_to_flange (eye_in_hand,由工具 TCP 折算)'
yaml_txt = format_transform_yaml(R, t, name)
extra = (
f'gripper_tool: {self.gripper_tool}\n'
+ format_matrix_yaml(self.T_flange_to_tool, 'T_flange_to_tool')
+ format_matrix_yaml(T_cam_tool, 'T_cam_to_tool')
)
self.get_logger().info(
f'gripper={self.gripper_tool}:'
f'T_cam_to_tool t={T_cam_tool[:3, 3]*1000} mm → '
f'T_cam_to_flange t={T_cam_flange[:3, 3]*1000} mm'
)
else:
name = 'T_cam_to_gripper (eye_in_hand)'
yaml_txt = format_transform_yaml(R, t, name)
header = (
f'# 手眼标定结果\n'
f'# mode: {self.mode}\n'
@@ -381,8 +453,20 @@ class HandeyeCalibrate(Node):
f'# board: {self.board.cols}x{self.board.rows} '
f'square_m={self.board.square_size}\n'
)
if self.gripper_tool and eye_to_hand:
header += (
f'# gripper_tool: {self.gripper_tool}\n'
f'# T_4x4 = cam→基座(AX=XB 的手是 TCP,不折到法兰)\n'
f'# 旧结果备份在 results/backup/\n'
)
elif self.gripper_tool:
header += (
f'# gripper_tool: {self.gripper_tool}\n'
f'# T_4x4 = cam→法兰(AX=XB 在 TCP 上解,再左乘 T_flange_to_tool)\n'
f'# T_cam_to_tool 是原始求解;旧的法兰/tool1 备份在 results/backup/\n'
)
self.result_file.parent.mkdir(parents=True, exist_ok=True)
self.result_file.write_text(header + yaml_txt, encoding='utf-8')
self.result_file.write_text(header + extra + yaml_txt, encoding='utf-8')
(self.output_dir / 'captures.json').write_text(
json.dumps(meta, indent=2), encoding='utf-8'
)
@@ -126,6 +126,39 @@ def Rt_to_T(R: np.ndarray, t: np.ndarray) -> np.ndarray:
return T
def cam_to_base_T(
T_handeye: np.ndarray, T_ee: np.ndarray, mode: str
) -> np.ndarray:
"""相机系 → 基座系的 4×4。
* ``eye_in_hand``:``T_handeye`` 是 cam→法兰,再左乘 ``T_ee``(base→法兰)。
* ``eye_to_hand``:``T_handeye`` **已经是** cam→基座。再乘 ``T_ee`` 会把
固定相机错挂到法兰上 —— 定位总能到附近、永远差一截,看起来像标定不准。
"""
T_he = np.asarray(T_handeye, dtype=float).reshape(4, 4)
tag = str(mode or "").strip().lower()
if tag == "eye_to_hand":
return T_he
if tag != "eye_in_hand":
raise ValueError(
f"手眼模式只能是 eye_in_hand 或 eye_to_hand,收到 {mode!r}"
)
return np.asarray(T_ee, dtype=float).reshape(4, 4) @ T_he
def cam_point_to_base(
p_cam: np.ndarray,
T_handeye: np.ndarray,
T_ee: np.ndarray,
mode: str,
) -> np.ndarray:
"""工件三维点(相机系)→ 基座系 XYZ。"""
T_bc = cam_to_base_T(T_handeye, T_ee, mode)
xyz = np.asarray(p_cam, dtype=float).reshape(3)
ph = np.array([float(xyz[0]), float(xyz[1]), float(xyz[2]), 1.0], dtype=float)
return (T_bc @ ph)[:3]
def invert_Rt(R: np.ndarray, t: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
Ri = R.T
ti = -Ri @ t.reshape(3, 1)
@@ -309,6 +342,38 @@ def calibrate_hand_eye(
return calibrate_hand_eye_tsai(Rg, tg, R_target2cam, t_target2cam)
def T_from_flexiv_tcp(xyz_m, qwxyz) -> np.ndarray:
"""Studio ``tcp_location`` → 法兰→工具 4×4。
Flexiv 顺序是 ``[x, y, z, q_w, q_x, q_y, q_z]``,四元数是 wxyz,
和本仓库其余地方的 xyzw 不一样。只在这里换一次。
"""
xyz = np.asarray(xyz_m, dtype=float).reshape(3)
q = np.asarray(qwxyz, dtype=float).reshape(7) if np.asarray(qwxyz).size == 7 else np.asarray(qwxyz, dtype=float).reshape(4)
if q.size == 7:
qw, qx, qy, qz = (float(q[3]), float(q[4]), float(q[5]), float(q[6]))
else:
qw, qx, qy, qz = (float(q[0]), float(q[1]), float(q[2]), float(q[3]))
R = quat_xyzw_to_R((qx, qy, qz, qw))
return Rt_to_T(R, xyz.reshape(3, 1))
def cam_to_flange_via_tool(T_cam_to_tool: np.ndarray, T_flange_to_tool: np.ndarray) -> np.ndarray:
"""AX=XB 在工具 TCP 上解出 cam→工具后,再折成 cam→法兰。
``T_flange_to_tool`` 是 Studio TCP:``p_法兰 = T_flange_to_tool · p_工具``。
OpenCV 的 ``T_cam_to_tool`` 是 ``p_工具 = T_cam_to_tool · p_相机``。
所以 ``T_cam_to_flange = T_flange_to_tool · T_cam_to_tool``。
乘反了(``T_cam_to_tool · inv(T_ft)``)旋转看着还像,平移会把相机
扔到法兰后方约 30 cm。忘了乘则 ``T_4x4`` 停在 TCP 上,沿工具偏 ~225 mm。
两种都不报错。
"""
T_ct = np.asarray(T_cam_to_tool, dtype=float).reshape(4, 4)
T_ft = np.asarray(T_flange_to_tool, dtype=float).reshape(4, 4)
return T_ft @ T_ct
def format_transform_yaml(R: np.ndarray, t: np.ndarray, name: str) -> str:
T = Rt_to_T(R, t)
lines = [
@@ -321,6 +386,14 @@ def format_transform_yaml(R: np.ndarray, t: np.ndarray, name: str) -> str:
return '\n'.join(lines) + '\n'
def format_matrix_yaml(T: np.ndarray, key: str) -> str:
arr = np.asarray(T, dtype=float).reshape(4, 4)
lines = [f'{key}:']
for row in arr:
lines.append(' - [' + ', '.join(f'{v:.8f}' for v in row) + ']')
return '\n'.join(lines) + '\n'
def is_pose_filled(joints: Sequence[float]) -> bool:
"""若全为零 / NaN 占位则返回 False。"""
arr = np.asarray(joints, dtype=float)
@@ -37,6 +37,8 @@ import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node
from arm_application.ros_node import TolerantNode, param_as_str
HELP = """
================================================
手眼示教 TEACH:预览 -> 拖拽 -> 拍照+位姿
@@ -94,14 +96,6 @@ def _setup_matplotlib_font():
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 通信。"""
@@ -128,6 +122,8 @@ class RdkBridge:
'PATH': f'{Path(py).parent}:/usr/bin:/bin',
'LD_LIBRARY_PATH': flexiv_lib,
'LANG': 'C.UTF-8',
'SITE_PC_IP': os.environ.get('SITE_PC_IP', ''),
'SITE_NET_IFACE': os.environ.get('SITE_NET_IFACE', ''),
}
# 不要继承 conda 的 PYTHONPATH。
# 重要:stderr 不能用 PIPE —— RDK 日志会填满管道并让桥接进程死锁
@@ -240,7 +236,7 @@ class RdkBridge:
pass
class HandeyeTeach(Node):
class HandeyeTeach(TolerantNode):
def __init__(self) -> None:
super().__init__('handeye_teach')
self.declare_parameter('output_dir', '/home/cc/ros2/handeye/tmp')
@@ -250,7 +246,7 @@ class HandeyeTeach(Node):
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._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)
@@ -260,6 +256,9 @@ class HandeyeTeach(Node):
# 设了就只是个"提醒":采够时提示可以收工,不强制关窗 ——
# 常常采到一半发现某个角度还没覆盖,强制退出反而要重开。
self.declare_parameter('target_points', 0)
# 必须和 run_handeye.sh 的 TAUGHT 是同一个文件。只写 taught_poses.txt、
# 脚本去读 taught_poses_<模式>.txt,merge 就会「文件在、内容空」。
self.declare_parameter('poses_file', '')
self.backend = str(self.get_parameter('backend').value).strip().lower()
self.robot_sn = str(self.get_parameter('robot_sn').value)
@@ -267,8 +266,12 @@ class HandeyeTeach(Node):
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'
_pf = str(self.get_parameter('poses_file').value).strip()
self.poses_file = (
Path(_pf).expanduser() if _pf else self.output_dir / 'taught_poses.txt'
)
self.yaml_file = self.output_dir / 'taught_waypoint_positions.yaml'
self._last_capture_t = 0.0
self.images_dir.mkdir(parents=True, exist_ok=True)
self._captures: List[dict] = []
@@ -537,6 +540,20 @@ class HandeyeTeach(Node):
if q is None:
self.get_logger().error('读取关节失败')
return
now = time.monotonic()
if now - self._last_capture_t < 0.8:
self.get_logger().warn('按太快,忽略(防 Enter 连发)')
return
if self._captures:
last = self._captures[-1]['joints']
delta = max(abs(a - b) for a, b in zip(q, last))
if delta < 0.015:
self.get_logger().warn(
f'关节几乎没动(Δ={delta:.4f} rad),忽略这次拍照。'
'先拖到另一个姿态再按 Enter。'
)
return
self._last_capture_t = now
idx = len(self._captures)
stamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
@@ -41,6 +41,8 @@ from arm_application.site_io import joint_prefix_default
from arm_msgs.action import FollowJointTrajectory
from arm_msgs.srv import GetArmState
from arm_application.ros_node import TolerantNode
try:
import cv2
except Exception: # noqa: BLE001
@@ -110,7 +112,7 @@ def _image_to_bgr(msg: Image) -> np.ndarray:
raise RuntimeError(f'不支持的图像编码:{msg.encoding!r}')
class HandeyeWaypoints(Node):
class HandeyeWaypoints(TolerantNode):
def __init__(self) -> None:
super().__init__('handeye_waypoints')
self.declare_parameter('joint_prefix', joint_prefix_default())
@@ -134,6 +134,67 @@ def _parse_best_bbox(dets, conf_thr: float) -> tuple[int, int, int, int] | None:
return None
def _keypoint_holes(
dets,
*,
conf_thr: float,
min_kconf: float,
radius_px: float,
min_dist_px: float,
):
"""pose 模型直接给出的 k1/k2 关键点当孔位。
现场约定(见 ``pose_detector``):k0 跟踪、k1/k2 对孔。
返回 ``((k1x,k1y),(k2x,k2y),r,r)``,拿不到可信关键点时返回 None
(不是 pose 模型 / 关键点置信度不够 / 两点几乎重合),调用方回落到找圆。
为什么值得优先用关键点:Hough / 亮环那套是在**孔本身的图像特征**上找圆,
光照、反光、通止规尖伸进视野都会把圆心拉偏;而关键点是模型在训练分布里
直接回归出来的,对这几类干扰天然更稳。
"""
if not dets:
return None
for det in sorted(dets, key=lambda d: d.conf, reverse=True):
if float(det.conf) < conf_thr:
continue
kpts = getattr(det, "kpts", None)
if kpts is None:
continue
kpts = np.asarray(kpts, dtype=float)
if kpts.ndim != 2 or kpts.shape[0] < 3 or kpts.shape[1] < 3:
continue
k1, k2 = kpts[1].copy(), kpts[2].copy()
if not (np.isfinite(k1[:2]).all() and np.isfinite(k2[:2]).all()):
continue
if float(k1[2]) < min_kconf or float(k2[2]) < min_kconf:
logger.info(
"k1/k2 关键点置信度不足(%.2f / %.2f < %.2f)—— 回落到找圆",
float(k1[2]),
float(k2[2]),
min_kconf,
)
continue
dist = float(np.hypot(k1[0] - k2[0], k1[1] - k2[1]))
if dist < float(min_dist_px):
logger.info(
"k1/k2 两关键点只差 %.1fpx(< %.1fpx)—— 视为不可信,回落到找圆",
dist,
min_dist_px,
)
continue
r = float(radius_px)
# **刻意不按图像 x 交换左右**。
# Hough 路径的 k1/k2 是"画面左孔 / 右孔"(位置定义),但 pose 模型学出来的
# k1/k2 是**固定语义**(训练时标注的那个物理孔)。工件只要转个角度,
# 位置就会翻面;按 x 强制交换会让 `target_hole=k1` 时左时右地指向两个不同
# 的物理孔 —— 那是最难查的一类现场问题。
# 下游怎么认这两个点由 `stage2.k1_label` 决定:
# hole_points[0] = 模型的 k1,hole_points[1] = 模型的 k2,
# 所以 k1_label="left" ↔ k1 就是模型的 k1(见 stage2_locate_node._snap)。
return (float(k1[0]), float(k1[1])), (float(k2[0]), float(k2[1])), r, r
return None
def _refine_circle_center(
gray: np.ndarray,
cx: float,
@@ -466,6 +527,35 @@ def detect_holes(
gate_block=True,
)
# ---- 首选:pose 模型直接给出 k1/k2 关键点(不必再找圆) ----
if bool(cfg.get("keypoint_enable", True)):
kp = _keypoint_holes(
dets,
conf_thr=conf,
min_kconf=float(cfg.get("keypoint_min_conf", 0.30)),
radius_px=float(cfg.get("keypoint_hole_radius_px", 12.0)),
min_dist_px=float(cfg.get("keypoint_min_dist_px", 15.0)),
)
if kp is not None:
(k1, k2, r1, r2) = kp
hole_points = [[float(k1[0]), float(k1[1])], [float(k2[0]), float(k2[1])]]
hole_radii = [float(r1), float(r2)]
dist = float(np.hypot(k1[0] - k2[0], k1[1] - k2[1]))
gate = (max_dx, max_dy) if bool(cfg.get("center_gate_draw", True)) else None
draw_img = _render_success(
image, bbox, hole_points, hole_radii, dist, gate=gate, label=label
)
return {
"status": True,
"msg": f"pose 关键点 k1/k2,两孔距离 {dist:.1f}px",
"hole_points": hole_points,
"hole_radii": hole_radii,
"bbox": list(bbox),
"label": label,
"source": "keypoint",
"draw_img": draw_img,
}
# ---- 主路径:螺纹嵌件对(亮环签名 + 关于中心孔对称),抗多孔干扰、抗跳变 ----
if bool(cfg.get("insert_pair_enable", True)):
gray_full = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
@@ -25,6 +25,8 @@ from arm_application.site_io import load_site_dict
from arm_msgs.msg import HoleDetection as HoleDetectionMsg
from arm_msgs.msg import HoleDetections
from arm_application.ros_node import TolerantNode
def _as_bool(val, default: bool = False) -> bool:
if val is None:
@@ -57,7 +59,7 @@ def _bgr_to_msg(image: np.ndarray, stamp, frame_id: str) -> Image:
return msg
class HoleDetectNode(Node):
class HoleDetectNode(TolerantNode):
def __init__(self) -> None:
super().__init__("hole_detect")
self.declare_parameter("camera_source", os.environ.get("SITE_CAM_NAME") or "d405")
@@ -21,7 +21,7 @@ DEFAULT_CONFIG = _PKG_SHARE_FALLBACK / "hole_insert.yaml"
# 配置齐全时永远走不到这条分支,一旦 site.yaml 缺键就报"模型文件不存在",
# 而打印出来的路径看着特别合理,很容易被误判成"文件被人误删了"。
# 改动这个常量时,顺手跑 ``bash scripts/weights.sh doctor`` 确认没有悬空引用。
_CANONICAL_HOLE_MODEL = "models/hole_insert/detect_best_1.onnx"
_CANONICAL_HOLE_MODEL = "models/yolo/gongjian0929.onnx"
@dataclass
@@ -143,17 +143,18 @@ class HoleDetector:
def _load_yolo(self):
if self._detector is not None:
return self._detector
from arm_application.yolo_detector import YoloDetector, resolve_weights
from arm_application.yolo_detector import build_detector, resolve_weights
weights = resolve_weights(
self.model_path,
backend=str(self.cfg.get("yolo_backend") or "auto"),
)
self._detector = YoloDetector(
# build_detector 会自己识别 pose 模型(k0/k1/k2),不必在这里分支。
self._detector = build_detector(
weights,
device=str(self.device),
conf=float(self.cfg.get("conf", 0.25)),
iou=float(self.cfg.get("iou", 0.7)),
device=str(self.device),
imgsz=int(self.cfg.get("imgsz", 640)),
)
return self._detector
@@ -62,6 +62,15 @@ class KalmanFilter3D:
self._predict(dt)
self._t = t
def zero_velocity(self) -> None:
"""把速度状态清零、位置保持不动。
用途:观测疑似假跳变(见 ``feature_jump_guard.FeatureJumpGate``)时本次不
采信观测,但**不能**让上一次的速度继续把位置外推出去 —— 否则"不动"会变成
"按旧速度匀速跑掉",刚好是我们要避免的那件事。
"""
self.x[3:] = 0.0
def update(self, z: np.ndarray) -> None:
"""用一次三维位置观测做量测更新。"""
z = np.asarray(z, dtype=float).reshape(3)
@@ -15,8 +15,10 @@ from geometry_msgs.msg import PoseStamped
from arm_msgs.action import MoveToPose
from arm_application.ros_node import TolerantNode
class MoveToPoseClient(Node):
class MoveToPoseClient(TolerantNode):
def __init__(self) -> None:
super().__init__('move_to_pose_client')
self._client = ActionClient(self, MoveToPose, 'move_to_pose')
@@ -54,6 +54,21 @@ class OnnxWorkpieceDetector:
self._iname = inp.name
shape = [d if isinstance(d, int) and d > 0 else imgsz for d in inp.shape]
self._in_h, self._in_w = int(shape[-2]), int(shape[-1])
# 只认 ultralytics end2end 检测输出 ``[1, N, 6]``,否则**当场报错**。
# 为什么必须在这里硬失败:2026-10-08 真机,把一个 ``task=pose`` 的模型
# (输出 ``[1, 4+nc+3K, 8400]``)从这条分支解出来,得到的是 12 个垃圾框
# ——conf 直接是坐标值(最高 70.87)、类名 ``class_73``、退化框 ``y1=y2=0``。
# 而流水线照跑不误,只在二阶段"看不出工件"上体现出来,排查代价极大。
# 宁可启动就失败(元规则:静默失效是头号敌人)。
outs = self._sess.get_outputs()
tail = outs[0].shape[-1] if (outs and outs[0].shape) else None
if isinstance(tail, int) and tail != 6:
raise RuntimeError(
f"{path.name} 的检测输出最后一维是 {tail},不是 6 —— 它不是"
" ultralytics end2end 检测模型([1,N,6])。带 k0/k1/k2 关键点的"
" pose 模型必须交给 pose_detector / build_detector,否则坐标会被"
"当成置信度解出垃圾框,而且不会报错。"
)
self.conf = float(conf)
self.imgsz = int(imgsz)
# 类别名:显式传入 > 旁挂标签文件 > ONNX 内嵌 metadata > 占位符。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,339 @@
"""用非夕给的 Rizon4 外壳网格摆出当前姿态。
网格和安装位姿来自 ``flexiv_description`` 的 ``visual_parameters.yaml``
与 ``meshes/Rizon4/visual/*.obj``,不另画一套简图。面数按固定步长抽稀,
只为了界面里能转得动,几何仍是厂家的。
"""
from __future__ import annotations
import math
from pathlib import Path
import numpy as np
import yaml
from arm_application.operator_arm_state import link_frames
from arm_application.site_io import ros2_root
# 单个连杆原始面数大约 5000。再按 700 抽稀会在壳上留出三角洞,
# 白壳和灰蓝关节分不出来。这里保留全部面。
_FACES_PER_LINK = 8000
_MODELS: dict[str, list[tuple[list[tuple[float, float, float]], tuple]]] | None = None
def posed_triangles(q: list[float]) -> list[tuple]:
"""返回基座系下的三角面 ``(a, b, c, 明暗, rgb)``。
``rgb`` 来自同目录 ``.mtl`` 的 ``Kd``。外壳是白的,关节壳体是灰蓝,
这是厂家导出的两种材质,不是另涂的颜色。
"""
world, shade, rgb = _posed_arrays(q)
return [
(
(float(world[i, 0, 0]), float(world[i, 0, 1]), float(world[i, 0, 2])),
(float(world[i, 1, 0]), float(world[i, 1, 1]), float(world[i, 1, 2])),
(float(world[i, 2, 0]), float(world[i, 2, 1]), float(world[i, 2, 2])),
float(shade[i]),
(float(rgb[i, 0]), float(rgb[i, 1]), float(rgb[i, 2])),
)
for i in range(shade.shape[0])
]
def posed_gl(q: list[float]) -> tuple[bytes, int, tuple[float, float, float], float]:
"""给显卡的一份网格:交错 ``xyzrgb``、顶点数、中心、半径。
拖视角只改观察矩阵,不再把四万个面在界面线程里重投影。
"""
world, shade, rgb = _posed_arrays(q)
light = (0.55 + 0.45 * shade)[:, None]
colored = (rgb * light).astype(np.float32)
pos = world.reshape(-1, 3).astype(np.float32)
col = np.repeat(colored, 3, axis=0)
center = pos.mean(axis=0)
radius = float(np.linalg.norm(pos - center, axis=1).max())
packed = np.concatenate([pos, col], axis=1).astype(np.float32, copy=False)
return (
packed.tobytes(),
int(pos.shape[0]),
(float(center[0]), float(center[1]), float(center[2])),
max(radius, 1e-4),
)
def _posed_arrays(q: list[float]):
"""每个连杆做一次矩阵乘法,不再对每个顶点走 Python 循环。"""
frames = link_frames(q)
worlds: list[np.ndarray] = []
shades: list[np.ndarray] = []
colors: list[np.ndarray] = []
for link, parts in _models().items():
frame = frames.get(link)
if frame is None:
continue
rot = np.asarray(frame[0], dtype=np.float64)
origin = np.asarray(frame[1], dtype=np.float64)
for part in parts:
local = np.einsum("ij,fvj->fvi", part["erot"], part["verts"]) + part["epos"]
world = np.einsum("ij,fvj->fvi", rot, local) + origin
ab = world[:, 1] - world[:, 0]
ac = world[:, 2] - world[:, 0]
normal = np.cross(ab, ac)
norm = np.linalg.norm(normal, axis=1)
norm = np.where(norm > 0.0, norm, 1.0)
facing = np.abs(normal[:, 0] + normal[:, 1]) / (norm * math.sqrt(2.0))
worlds.append(world)
shades.append(0.28 + 0.72 * facing)
colors.append(part["rgb"])
if not worlds:
empty = np.zeros((0, 3, 3), dtype=np.float64)
return empty, np.zeros((0,), dtype=np.float64), np.zeros((0, 3), dtype=np.float64)
return np.concatenate(worlds), np.concatenate(shades), np.concatenate(colors)
def view_rotation(yaw: float, pitch: float):
"""先绕竖直轴转,再俯仰。拖拽视角用这一份,不在界面里另写一套。"""
return _mul(_rx(pitch), _rz(yaw))
def view_scale(radius: float, width: int, height: int) -> tuple[float, float]:
"""和着色器里的 ``u_sx`` / ``u_sy`` 同一套缩放。宽屏时横向再缩一档。"""
fit = 0.85 / max(float(radius), 1e-4)
width = max(int(width), 1)
height = max(int(height), 1)
if width >= height:
return fit * height / width, fit
return fit, fit * width / height
def screen_point(
point: tuple[float, float, float],
yaw: float,
pitch: float,
center: tuple[float, float, float],
radius: float,
width: int,
height: int,
) -> tuple[float, float]:
"""基座系的点投到控件像素。纵向和着色器里的 ``p.z`` 是同一套,+Z 朝画面上方。"""
shifted = (
float(point[0]) - float(center[0]),
float(point[1]) - float(center[1]),
float(point[2]) - float(center[2]),
)
viewed = _apply(view_rotation(yaw, pitch), shifted)
sx, sy = view_scale(radius, width, height)
ndc_x = viewed[0] * sx
# 着色器写的是 ``p.z * u_sy``。OpenGL 的 y 朝上,这里再换成 Qt 的像素 y。
ndc_y = viewed[2] * sy
width = max(int(width), 1)
height = max(int(height), 1)
return (ndc_x * 0.5 + 0.5) * width, (1.0 - (ndc_y * 0.5 + 0.5)) * height
def ground_grid(extent: float = 1.0, step: float = 0.25) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
"""地平面格子,z = 0,单位米。以前这张格子在 RViz 里,现在画在工位仿真上。"""
if step <= 0 or extent <= 0:
raise ValueError("地平面格子的间距和范围都要大于 0")
count = int(round(extent / step))
lines = []
for index in range(-count, count + 1):
coord = index * step
lines.append(((-extent, coord, 0.0), (extent, coord, 0.0)))
lines.append(((coord, -extent, 0.0), (coord, extent, 0.0)))
return lines
def axis_tip(
origin: tuple[float, float, float],
rot: tuple,
axis: int,
length: float,
) -> tuple[float, float, float]:
"""坐标系某一轴的端点。``rot`` 把连杆坐标变到基座坐标,列向量就是三根轴。"""
basis = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))[axis]
direction = _apply(rot, basis)
return (
origin[0] + direction[0] * length,
origin[1] + direction[1] * length,
origin[2] + direction[2] * length,
)
def _place(rot, origin, erot, epos, vertex):
local = _add(epos, _apply(erot, vertex))
return _add(origin, _apply(rot, local))
def _shade(a, b, c) -> float:
ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2]
nx = uy * vz - uz * vy
ny = uz * vx - ux * vz
nz = ux * vy - uy * vx
norm = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
facing = abs(nx + ny) / (norm * math.sqrt(2.0))
return 0.28 + 0.72 * facing
def _models() -> dict[str, list]:
global _MODELS
if _MODELS is not None:
return _MODELS
root = _description_root()
visual = yaml.safe_load((root / "config/Rizon4/visual_parameters.yaml").read_text(encoding="utf-8"))
params = (visual or {}).get("visual_parameters") or {}
loaded: dict[str, list] = {}
for link, spec in params.items():
shell = ((spec or {}).get("visual") or {})
parts = []
for kind in ("shell", "ring"):
item = shell.get(kind)
if not isinstance(item, dict):
continue
mesh = item.get("mesh") or {}
path = root / str(mesh.get("path") or "")
if not path.is_file():
continue
scale = _scale(mesh.get("scale"))
origin = item.get("origin") or {}
erot = _rpy(
float(origin.get("roll") or 0.0),
float(origin.get("pitch") or 0.0),
float(origin.get("yaw") or 0.0),
)
epos = (
float(origin.get("x") or 0.0),
float(origin.get("y") or 0.0),
float(origin.get("z") or 0.0),
)
parts.append(_part_arrays(_load_obj(path, scale, _materials(path)), erot, epos))
if parts:
loaded[str(link)] = parts
if "base_link" not in loaded or "link7" not in loaded:
raise FileNotFoundError(f"Rizon4 外壳网格不完整:{root}")
_MODELS = loaded
return loaded
def _part_arrays(faces: list[tuple], erot, epos) -> dict:
"""把一个零件收成数组,姿态更新时整块相乘。"""
if not faces:
verts = np.zeros((0, 3, 3), dtype=np.float64)
rgb = np.zeros((0, 3), dtype=np.float64)
else:
verts = np.asarray([[a, b, c] for a, b, c, _color in faces], dtype=np.float64)
rgb = np.asarray([color for _a, _b, _c, color in faces], dtype=np.float64)
return {
"verts": verts,
"rgb": rgb,
"erot": np.asarray(erot, dtype=np.float64),
"epos": np.asarray(epos, dtype=np.float64),
}
def _description_root() -> Path:
root = ros2_root()
candidates = (
root / "src/flexiv_description",
root / "install/flexiv_description/share/flexiv_description",
)
for item in candidates:
if (item / "config/Rizon4/visual_parameters.yaml").is_file():
return item
raise FileNotFoundError("找不到 flexiv_description 的 Rizon4 外观")
def _materials(path: Path) -> dict[str, tuple[float, float, float]]:
mtl = path.with_suffix(".mtl")
colors: dict[str, tuple[float, float, float]] = {}
name = ""
if not mtl.is_file():
return colors
for line in mtl.read_text(encoding="utf-8", errors="replace").splitlines():
if line.startswith("newmtl "):
name = line.split(None, 1)[1].strip()
elif name and line.startswith("Kd "):
parts = line.split()
if len(parts) >= 4:
colors[name] = (float(parts[1]), float(parts[2]), float(parts[3]))
return colors
def _load_obj(path: Path, scale: tuple[float, float, float], materials: dict) -> list[tuple]:
fallback = (0.75, 0.76, 0.78)
rgb = fallback
vertices: list[tuple[float, float, float]] = []
faces: list[tuple[int, int, int, tuple]] = []
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
if line.startswith("usemtl "):
rgb = materials.get(line.split(None, 1)[1].strip(), fallback)
elif line.startswith("v "):
_v, x, y, z, *_rest = line.split()
vertices.append((float(x) * scale[0], float(y) * scale[1], float(z) * scale[2]))
elif line.startswith("f "):
idx = []
for token in line.split()[1:]:
raw = token.split("/")[0]
if not raw:
continue
number = int(raw)
idx.append(number - 1 if number > 0 else number)
for offset in range(1, len(idx) - 1):
faces.append((idx[0], idx[offset], idx[offset + 1], rgb))
if not faces or not vertices:
return []
step = max(1, len(faces) // _FACES_PER_LINK)
chosen = faces[::step]
out = []
count = len(vertices)
for ia, ib, ic, color in chosen:
if not (0 <= ia < count and 0 <= ib < count and 0 <= ic < count):
continue
out.append((vertices[ia], vertices[ib], vertices[ic], color))
return out
def _scale(raw) -> tuple[float, float, float]:
if raw is None:
return (1.0, 1.0, 1.0)
if isinstance(raw, (int, float)):
value = float(raw)
return (value, value, value)
text = str(raw).replace(",", " ").split()
nums = [float(item) for item in text[:3]]
while len(nums) < 3:
nums.append(nums[-1] if nums else 1.0)
return (nums[0], nums[1], nums[2])
def _rpy(roll: float, pitch: float, yaw: float):
return _mul(_mul(_rz(yaw), _ry(pitch)), _rx(roll))
def _rx(angle: float):
c, s = math.cos(angle), math.sin(angle)
return ((1.0, 0.0, 0.0), (0.0, c, -s), (0.0, s, c))
def _ry(angle: float):
c, s = math.cos(angle), math.sin(angle)
return ((c, 0.0, s), (0.0, 1.0, 0.0), (-s, 0.0, c))
def _rz(angle: float):
c, s = math.cos(angle), math.sin(angle)
return ((c, -s, 0.0), (s, c, 0.0), (0.0, 0.0, 1.0))
def _mul(a, b):
return tuple(tuple(sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)) for i in range(3))
def _apply(rot, vec):
return tuple(sum(rot[i][k] * vec[k] for k in range(3)) for i in range(3))
def _add(a, b):
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
@@ -0,0 +1,595 @@
"""工位界面用的机械臂状态:只读使能 / Remote,并用关节角画出姿态。
不会调用 Enable,也不会在控制栈或拖拽桥已经占着 RDK 时再连一次。
``robot.mode()`` 看不出 Manual,必须看 ``operational_status``。
示教器上的 Auto(Remote)没有远程切换接口,这里只负责把这句话摆在界面上。
"""
from __future__ import annotations
import json
import math
import os
import re
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
import yaml
from arm_application.site_io import listed_arms, ros2_root
# 和 scripts/lib/rdk_status.py 的 MANUAL_HINT 同一件事,界面上始终可见。
REMOTE_PROMPT = (
"请在示教器上打开 Auto(Remote)。"
"RDK 没有远程拨这个开关的接口,必须人到现场拨。"
)
_AXES = (
(0.0, 0.0, 1.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, 1.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, 1.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, 1.0),
)
_JOINTS = tuple(f"joint{i}" for i in range(1, 8))
_LOCK = threading.Lock()
_CACHE: dict[str, dict] = {}
_STOP = False
_STARTED = False
_HOLD = 0
_HOLD_LOCK = threading.Lock()
def _empty(arm: str) -> dict:
return {
"arm": arm,
"level": "warn",
"enabled": False,
"summary": "还没读到这台臂的状态",
"prompt": REMOTE_PROMPT,
"prompt_hot": True,
"mode": "",
"op_status": "",
"fault": None,
"estop_released": None,
"q": None,
"points": [],
"source": "",
"stack_up": False,
"stamp": 0.0,
}
def snapshot(arm: str) -> dict:
with _LOCK:
row = _CACHE.get(arm)
return dict(row) if row else _empty(arm)
def present(
arm: str,
*,
stack_up: bool,
bridge_up: bool,
probe: dict | None,
joints: list[float] | None,
error: str = "",
) -> dict:
"""把一次读数收成界面直接能画的字典。纯函数,不连机器人。"""
probe = probe or {}
q = _seven(joints) or _seven(probe.get("q"))
op_status = str(probe.get("op_status") or "")
mode = str(probe.get("mode") or "")
manual = "MANUAL" in op_status.upper()
if stack_up and q is not None:
enabled = True
level = "ok"
summary = "控制栈在发关节角,这台臂已经接上。"
if probe.get("_shared_stack"):
summary = "控制栈在发关节角,但看不出是 89 还是 91,两台显示的是同一份。"
source = "控制栈 /joint_states"
elif stack_up:
enabled = False
level = "fail"
summary = "控制栈在跑,但没有关节数据。通常是还没使能,一直在等 operational。"
source = "控制栈"
elif bridge_up and not probe:
enabled = False
level = "warn"
summary = "软拖拽桥占着 RDK,界面不再另开一条连接。"
source = "拖拽桥"
q = None
elif probe.get("connected"):
enabled = bool(probe.get("operational"))
level = str(probe.get("level") or ("ok" if enabled else "fail"))
summary = str(probe.get("summary") or ("已使能" if enabled else "未使能"))
source = "RDK 直读(未使能,只查询)"
else:
enabled = False
level = "fail" if error else "warn"
summary = error or str(probe.get("summary") or probe.get("error") or "还没连上 RDK")
source = "RDK 直读" if probe else ""
hot = (not enabled) or manual
return {
"arm": arm,
"level": level,
"enabled": enabled,
"summary": summary,
"prompt": REMOTE_PROMPT,
"prompt_hot": hot,
"mode": mode,
"op_status": op_status,
"fault": probe.get("fault") if probe else None,
"estop_released": probe.get("estop_released") if probe else None,
"q": q,
"points": fk_points(q) if q else fk_points([0.0] * 7),
"pose_live": q is not None,
"source": source,
"stack_up": stack_up,
"stamp": time.time(),
}
def link_frames(q: list[float]) -> dict[str, tuple]:
"""各连杆坐标系,和 ``rizon_arm.xacro`` 的关节顺序一致。
返回 ``base_link`` 与 ``link1``…``link7``。每个值是 ``(旋转, 原点)``,
旋转把连杆坐标变到基座坐标。厂家网格就画在这些坐标系里。
"""
kin = _kinematics()
rot = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
pos = (0.0, 0.0, 0.0)
frames: dict[str, tuple] = {"base_link": (rot, pos)}
for index, angle in enumerate(q[:7]):
spec = kin[_JOINTS[index]]
pos = _add(pos, _apply(rot, (spec["x"], spec["y"], spec["z"])))
rot = _mul(rot, _rpy(spec["roll"], spec["pitch"], spec["yaw"]))
rot = _mul(rot, _axis_rot(_AXES[index], float(angle)))
frames[f"link{index + 1}"] = (rot, pos)
return frames
def fk_points(q: list[float]) -> list[tuple[float, float, float]]:
"""按 Rizon4 的 default_kinematics 做正运动学,返回基座到法兰的点(米)。
关节轴与 ``rizon_arm.xacro`` 一致:1/3/5/7 绕 z,2/4/6 绕 y。
URDF 顺序是先平移(父坐标系),再 rpy,再绕关节轴转 q。
"""
frames = link_frames(q)
points = [frames["base_link"][1]]
points.extend(frames[f"link{index}"][1] for index in range(1, 8))
_rot, pos = _flange_pose(frames)
points.append(pos)
return points
def display_frames(q: list[float], workpiece: tuple | None = None) -> list[dict]:
"""界面要画的坐标系,都在基座系里。
第一套是基座。第二套默认是法兰(旋转与 ``link7`` 相同,原点再加
``link7_to_flange``)。传入工件系时,第二套改画在工件上,不再画法兰。
"""
frames = link_frames(q)
base_rot, base_pos = frames["base_link"]
shown = [{"name": "基座", "rot": base_rot, "pos": base_pos}]
if workpiece is None:
rot, pos = _flange_pose(frames)
shown.append({"name": "法兰", "rot": rot, "pos": pos})
return shown
rot, pos = workpiece
if not isinstance(rot, tuple) or len(rot) != 3 or not isinstance(pos, tuple) or len(pos) != 3:
raise ValueError("工件坐标系要有旋转和原点")
shown.append({"name": "工件", "rot": rot, "pos": pos})
return shown
def workpiece_frame(robot: dict | None) -> tuple | None:
"""有完整工件系时返回 ``(旋转, 原点)``。没有就返回 None。
``workpiece_surface_z_m`` 只是上表面高度,没有原点的 XY,也没有朝向,
不能当成坐标系。配了 ``gauge_assembly.workpiece_frame`` 却缺 ``xyz`` 或
``rpy`` 时直接报错,不用单位阵把缺的朝向补上。
``xyz`` 单位米,``rpy`` 单位弧度,都写在基座系,旋转顺序与连杆 ``rpy`` 相同。
"""
if not isinstance(robot, dict):
return None
gauge = robot.get("gauge_assembly")
if not isinstance(gauge, dict) or "workpiece_frame" not in gauge:
return None
raw = gauge.get("workpiece_frame")
if not isinstance(raw, dict):
raise ValueError("工件坐标系要写成 xyz 和 rpy,各三个数")
xyz = raw.get("xyz")
rpy = raw.get("rpy")
if not _three_floats(xyz) or not _three_floats(rpy):
raise ValueError("工件坐标系要有 xyz 和 rpy,各三个数,单位是米和弧度,写在基座系里")
return _rpy(float(rpy[0]), float(rpy[1]), float(rpy[2])), (
float(xyz[0]), float(xyz[1]), float(xyz[2]),
)
def _flange_pose(frames: dict) -> tuple:
"""法兰位姿。旋转就是 ``link7`` 的旋转,平移来自 ``link7_to_flange``。"""
rot, pos = frames["link7"]
flange = _kinematics()["link7_to_flange"]
pos = _add(pos, _apply(rot, (float(flange["x"]), float(flange["y"]), float(flange["z"]))))
return rot, pos
def _three_floats(raw: Any) -> bool:
if not isinstance(raw, (list, tuple)) or len(raw) != 3:
return False
return all(isinstance(item, (int, float)) and not isinstance(item, bool) for item in raw)
def stack_running() -> bool:
return _stack_arm() is not None or _alive("ros2_control_node")
def control_holds_rdk() -> bool:
"""有控制命令在用 RDK 时,状态轮询必须让开。"""
with _HOLD_LOCK:
return _HOLD > 0
def release_for_control() -> None:
"""停掉正在读状态的 RDK 子进程,把机器人交给控制命令。"""
with _HOLD_LOCK:
global _HOLD
_HOLD += 1
_stop_status_probes()
deadline = time.time() + 3.0
while time.time() < deadline and _status_probe_running():
time.sleep(0.05)
def resume_after_control() -> None:
with _HOLD_LOCK:
global _HOLD
_HOLD = max(0, _HOLD - 1)
def hold_until_idle(pid: int, *, until_stack: bool = False) -> None:
"""后台脚本还在时继续占着 RDK。进程退出,或控制栈已经起来,就放开。"""
def _watch() -> None:
while True:
if until_stack and stack_running():
break
if not _pid_alive(pid):
break
time.sleep(0.3)
resume_after_control()
threading.Thread(target=_watch, daemon=True, name="arm-rdk-hold").start()
def motion_blocked(action: str, *, stack_up: bool, op_status: str) -> str:
"""控制栈没起来时,动臂按钮给一句能执行的原因,而不是把脚本挂在那儿。"""
needs_stack = {
"pipeline", "stage_track", "stage_locate", "stage_screw",
"reset_home", "gauge_run", "handeye_calibrate",
}
if action not in needs_stack or stack_up:
return ""
manual = "MANUAL" in str(op_status or "").upper()
tail = "示教器现在是 Manual,要先拨到 Auto(Remote)。" if manual else ""
return f"控制栈没起来,这个动作动不了臂。{tail}请先在操作页执行「上电控制栈」。"
def start() -> None:
global _STARTED
with _LOCK:
if _STARTED:
return
_STARTED = True
threading.Thread(target=_loop, daemon=True, name="arm-state").start()
def retain_pose(fresh: dict, previous: dict | None, *, now: float | None = None, hold_s: float = 15.0) -> dict:
"""这一拍没读到关节角时,短时间沿用上一拍,避免模型被打回零位。
超过 ``hold_s`` 仍没有新读数就放开,界面回到“没连上”,不把过期姿态一直当成当前。
"""
if fresh.get("q") is not None or not previous or previous.get("q") is None:
return fresh
moment = time.time() if now is None else now
if moment - float(previous.get("stamp") or 0.0) > hold_s:
return fresh
kept = dict(fresh)
kept["q"] = list(previous["q"])
kept["points"] = list(previous.get("points") or [])
kept["pose_live"] = True
note = "关节角沿用上次读数。"
summary = str(kept.get("summary") or "")
if note not in summary:
kept["summary"] = f"{summary} {note}".strip()
return kept
def _loop() -> None:
while not _STOP:
if control_holds_rdk():
time.sleep(0.2)
continue
try:
arms = _arms()
if not arms:
time.sleep(2.0)
continue
stack_arm = _stack_arm()
stack_up = stack_arm is not None or _alive("ros2_control_node")
bridge_up = _alive("rdk_drag_bridge.py")
joints = _read_joints() if stack_up else None
if stack_up:
targets = [row for row in arms if stack_arm is None or row["id"] == stack_arm]
if not targets:
targets = arms
shared = stack_arm is None
for row in targets:
fresh = present(
row["id"],
stack_up=True,
bridge_up=bridge_up,
probe={"_shared_stack": True} if shared else None,
joints=joints,
)
_store(retain_pose(fresh, snapshot(row["id"])))
else:
for row in arms:
probe: dict | None = None
error = ""
if control_holds_rdk():
break
if not bridge_up:
probe, error = _probe(row["sn"], row["iface"], row["pc_ip"])
fresh = present(
row["id"],
stack_up=False,
bridge_up=bridge_up,
probe=probe,
joints=None,
error=error,
)
_store(retain_pose(fresh, snapshot(row["id"])))
except Exception as exc: # noqa: BLE001 —— 读状态失败不能把界面线程打死
for row in _arms():
_store(present(
row["id"], stack_up=False, bridge_up=False, probe=None, joints=None, error=str(exc),
))
time.sleep(4.0)
def _stop_status_probes() -> None:
try:
subprocess.run(
["pkill", "-f", "scripts/lib/rdk_status.py"],
capture_output=True, text=True, timeout=2,
)
except (OSError, subprocess.TimeoutExpired):
return
def _status_probe_running() -> bool:
return "rdk_status.py" in _pgrep("scripts/lib/rdk_status.py")
def _pid_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True
def _store(row: dict) -> None:
with _LOCK:
_CACHE[str(row.get("arm") or "")] = row
def _arms() -> list[dict]:
path = ros2_root() / "config" / "site.yaml"
if not path.is_file():
return []
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return [
{"id": row["id"], "sn": row["sn"], "iface": row["iface"], "pc_ip": row["pc_ip"]}
for row in listed_arms(data)
]
def _stack_arm() -> str | None:
text = _pgrep("run_real.sh")
ids = {row["id"] for row in _arms()}
for match in re.finditer(r"--arm(?:=|\s+)(\S+)", text):
token = match.group(1)
if token in ids:
return token
return None
def _alive(pattern: str) -> bool:
return bool(_pgrep(pattern).strip())
def _pgrep(pattern: str) -> str:
try:
proc = subprocess.run(
["pgrep", "-af", pattern],
capture_output=True, text=True, timeout=2,
)
except (OSError, subprocess.TimeoutExpired):
return ""
lines = []
for line in proc.stdout.splitlines():
if "pgrep" in line:
continue
lines.append(line)
return "\n".join(lines)
def _read_joints() -> list[float] | None:
try:
proc = subprocess.run(
["timeout", "4", "ros2", "topic", "echo", "/joint_states", "--once"],
capture_output=True, text=True, timeout=6,
)
except (OSError, subprocess.TimeoutExpired):
return None
return _parse_joint_echo(proc.stdout)
def _parse_joint_echo(text: str) -> list[float] | None:
for chunk in text.split("---"):
if "position:" not in chunk:
continue
try:
data = yaml.safe_load(chunk)
except yaml.YAMLError:
continue
if not isinstance(data, dict):
continue
names = list(data.get("name") or [])
pos = list(data.get("position") or [])
if names and len(names) == len(pos):
by = {str(name): float(value) for name, value in zip(names, pos)}
q = []
for joint in _JOINTS:
key = next((name for name in by if name == joint or name.endswith(joint)), None)
if key is None:
q = []
break
q.append(by[key])
if len(q) == 7:
return q
if len(pos) >= 7:
try:
return [float(v) for v in pos[:7]]
except (TypeError, ValueError):
return None
return None
def _probe(sn: str, iface: str, pc_ip: str = "") -> tuple[dict | None, str]:
python = os.environ.get("RDK_PYTHON", "").strip() or str(Path.home() / "rdk_py310" / "bin" / "python")
script = ros2_root() / "scripts" / "lib" / "rdk_status.py"
if not Path(python).is_file() or not script.is_file():
return None, "找不到 RDK 解释器或 rdk_status.py,无法读使能状态"
cmd = _probe_cmd(python, script, sn, iface, pc_ip)
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=8)
except subprocess.TimeoutExpired:
return None, "读 RDK 超时。若控制栈没开,检查网线,并确认示教器已开 Remote。"
except OSError as exc:
return None, f"读 RDK 失败:{exc}"
payload = _json_object(proc.stdout) or _json_object(proc.stderr)
if not payload:
tail = (proc.stdout + proc.stderr).strip()[-240:]
return None, tail or "RDK 没有返回状态"
return payload, ""
def _probe_cmd(python: str, script: Path, sn: str, iface: str, pc_ip: str) -> list[str]:
"""RDK 白名单要本机 IPv4。只传网卡名 ``eth0`` 时,内核改名叫 enx… 就永远连不上。"""
cmd = [python, str(script), "--sn", sn, "--json"]
if iface:
cmd += ["--iface", iface]
if pc_ip:
cmd += ["--pc-ip", pc_ip]
return cmd
def _json_object(text: str) -> dict:
decoder = json.JSONDecoder()
for index, char in enumerate(text):
if char != "{":
continue
try:
data, _end = decoder.raw_decode(text[index:])
except json.JSONDecodeError:
continue
if isinstance(data, dict) and "connected" in data:
return data
return {}
_KIN: dict[str, dict[str, float]] | None = None
def _kinematics() -> dict[str, dict[str, float]]:
global _KIN
if _KIN is not None:
return _KIN
root = ros2_root()
candidates = (
root / "src/flexiv_description/config/Rizon4/default_kinematics.yaml",
root / "install/flexiv_description/share/flexiv_description/config/Rizon4/default_kinematics.yaml",
)
path = next((item for item in candidates if item.is_file()), None)
if path is None:
raise FileNotFoundError("找不到 Rizon4 default_kinematics.yaml")
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
kin = data.get("kinematics") or {}
_KIN = kin
return kin
def _rpy(roll: float, pitch: float, yaw: float) -> tuple:
return _mul(_mul(_rz(yaw), _ry(pitch)), _rx(roll))
def _rx(angle: float) -> tuple:
c, s = math.cos(angle), math.sin(angle)
return ((1.0, 0.0, 0.0), (0.0, c, -s), (0.0, s, c))
def _ry(angle: float) -> tuple:
c, s = math.cos(angle), math.sin(angle)
return ((c, 0.0, s), (0.0, 1.0, 0.0), (-s, 0.0, c))
def _rz(angle: float) -> tuple:
c, s = math.cos(angle), math.sin(angle)
return ((c, -s, 0.0), (s, c, 0.0), (0.0, 0.0, 1.0))
def _axis_rot(axis: tuple[float, float, float], angle: float) -> tuple:
x, y, z = axis
if abs(z) > 0.5:
return _rz(angle if z > 0 else -angle)
if abs(y) > 0.5:
return _ry(angle if y > 0 else -angle)
return _rx(angle if x > 0 else -angle)
def _mul(a: tuple, b: tuple) -> tuple:
return tuple(
tuple(sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3))
for i in range(3)
)
def _apply(rot: tuple, vec: tuple[float, float, float]) -> tuple[float, float, float]:
return tuple(sum(rot[i][k] * vec[k] for k in range(3)) for i in range(3)) # type: ignore[return-value]
def _add(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
def _seven(raw: Any) -> list[float] | None:
if not isinstance(raw, list) or len(raw) < 7:
return None
try:
q = [float(v) for v in raw[:7]]
except (TypeError, ValueError):
return None
if any(v != v or abs(v) > 12.6 for v in q):
return None
return q
@@ -0,0 +1,675 @@
"""工位程序里的相机预览。
只订彩色和已对齐深度,不订左右红外。画面留在本进程,不另开查看器窗口。
回调里先丢掉当前页没在看的流。彩色按相机原分辨率收进来,不按时钟抽帧,
也不在收图的线程里做颜色转换。另一条线只画最新的一张,所以帧率跟相机走,
848 和 1280 都不会为了帧率被缩成 480。深度是伪彩色,仍然抽稀,
而且没打开深度时不订阅,避免大图挤占彩色。
手眼页要 JPEG 时也按原分辨率编码,只编正在看的那一路。
"""
from __future__ import annotations
import subprocess
import threading
import time
import numpy as np
# 彩色上限。D405 是 848×480,D435 是 1280×720,都不超过它,所以不会被缩小。
_COLOR_WIDTH = 1280
# 深度上色不是要看的画质。全幅每帧上色会把刷新占满,所以这一路仍抽稀。
_DEPTH_WIDTH = 480
# 手眼只在正在看的那一页编码,宽度跟彩色预览一样,不另缩一档。
_JPEG_WIDTH = 1280
# 只留给还在用 ``accept_frame`` 的调用。彩色实况不再用它抽帧:
# 相机大约 30 帧,再按这个间隔丢一次,画面会掉到十几帧。
_INTERVAL = 1.0 / 30.0
_DET_HOLD_S = 1.5
_LOCK = threading.Lock()
_STOP = threading.Event()
_THREAD: threading.Thread | None = None
_JPEG: dict[str, bytes] = {}
_PREVIEW: dict[str, tuple[bytes, int, int]] = {}
_PREVIEW_WANT: set[str] = set()
_JPEG_WANT: set[str] = set()
_NEXT: dict[str, float] = {}
_DET: dict[str, tuple[float, list[float]]] = {}
# 没画框的预览。检测一到就在这张上重画,不必等下一帧相机。
_CLEAN: dict[str, dict] = {}
# 每路最近一秒的到达时刻,用来算帧率。值是 (宽, 高, 帧/秒),宽高是相机原图。
_META: dict[str, tuple[int, int, float]] = {}
_STAMPS: dict[str, list[float]] = {}
# 每路最新原图。收图线程只往这里放,画图线程只取最新的一张。
_RAW: dict[str, dict] = {}
_SLOTS: dict[str, list] = {}
_SLOT_AT: dict[str, int] = {}
_PAINTED: dict[str, int] = {}
_WAKE = threading.Event()
_PAINT_THREAD: threading.Thread | None = None
_STATE = {"on": False, "error": "", "frames": {}, "stamp": 0.0}
def preview_targets(site: dict) -> list[dict]:
"""主界面只要左臂腕部相机和共用的手外相机。
右臂腕部不进这一屏。仿真相机名字以 ``sim`` 开头,也不进。
推理框的角色仍是 ``wrist`` / ``overhead``,跟话题对得上。
"""
from arm_application.site_io import listed_arms, overhead_camera
found: list[dict] = []
seen: set[str] = set()
left = next((row for row in listed_arms(site) if row.get("side") == "left"), None)
name = str((left or {}).get("camera") or "").strip().strip("/")
if name and not name.startswith("sim"):
cams = site.get("cameras") if isinstance(site.get("cameras"), dict) else {}
cfg = cams.get(name) if isinstance(cams, dict) and isinstance(cams.get(name), dict) else {}
tag = str((cfg or {}).get("model") or "D405").strip().upper() or "D405"
seen.add(name)
found.append({"id": name, "label": f"左腕 {tag}", "role": "wrist"})
cam, _others = overhead_camera(site or {}, None)
oname = str((cam or {}).get("name") or "").strip().strip("/")
if oname and not oname.startswith("sim") and oname not in seen:
tag = str((cam or {}).get("model") or "D435").strip().upper() or "D435"
found.append({"id": oname, "label": f"手外 {tag}", "role": "overhead"})
return found
def sim_preview_targets(site: dict) -> list[dict]:
"""仿真腕部和仿真手外。画面进主界面,不另开 Gazebo 的相机窗。"""
from arm_application.site_io import overhead_camera
found: list[dict] = []
robots = ((site or {}).get("robot") or {}).get("robots") or []
cams = site.get("cameras") if isinstance(site.get("cameras"), dict) else {}
for robot in robots:
if not isinstance(robot, dict):
continue
if str(robot.get("sn") or "").strip().lower() != "sim":
continue
cam = robot.get("camera") if isinstance(robot.get("camera"), dict) else {}
name = str((cam or {}).get("name") or "").strip().strip("/")
if not name:
continue
cfg = cams.get(name) if isinstance(cams, dict) and isinstance(cams.get(name), dict) else {}
tag = str((cfg or {}).get("model") or "D405").strip().upper() or "D405"
found.append({"id": name, "label": f"仿真左腕 {tag}", "role": "wrist", "source": "sim"})
break
overhead, _others = overhead_camera(site or {}, {"arm": "sim"})
oname = str((overhead or {}).get("name") or "").strip().strip("/")
if oname.startswith("sim"):
tag = str((overhead or {}).get("model") or "D435").strip().upper() or "D435"
found.append({"id": oname, "label": f"仿真手外 {tag}", "role": "overhead", "source": "sim"})
return found
def stream_targets(site: dict | None = None) -> list[dict]:
"""真机和仿真都订上。主界面切换看哪一组,没在看的那组回调里直接丢掉。"""
if site is None:
from arm_application.operator_console import _site
site = _site()
real = preview_targets(site)
for item in real:
item["source"] = "real"
seen = {item["id"] for item in real}
return real + [item for item in sim_preview_targets(site) if item["id"] not in seen]
def targets() -> list[dict]:
"""当前站点的主界面真机相机。规则在 ``preview_targets``。"""
from arm_application.operator_console import _site
return preview_targets(_site())
def sim_targets() -> list[dict]:
from arm_application.operator_console import _site
return sim_preview_targets(_site())
def state() -> dict:
with _LOCK:
return {
"on": _STATE["on"],
"error": _STATE["error"],
"stamp": _STATE["stamp"],
"frames": dict(_STATE["frames"]),
}
def frame(name: str, kind: str) -> bytes | None:
with _LOCK:
return _JPEG.get(f"{name}_{kind}")
def previews() -> dict[str, tuple[bytes, int, int]]:
"""当前预览。值是 ``(rgb888, 宽, 高)``,同一帧反复取到的是同一块字节。"""
with _LOCK:
return dict(_PREVIEW)
def preview_meta() -> dict[str, tuple[int, int, float]]:
"""每路相机原图的宽、高,以及最近一秒的帧率。还没来帧就是空。"""
with _LOCK:
return dict(_META)
def _mark_rate(key: str, width: int, height: int) -> None:
"""记下这一帧的原图尺寸,并用最近一秒的到达间隔算帧率。"""
now = time.monotonic()
with _LOCK:
stamps = _STAMPS.setdefault(key, [])
stamps.append(now)
cut = now - 1.0
while len(stamps) > 1 and stamps[0] < cut:
stamps.pop(0)
if len(stamps) >= 2 and stamps[-1] > stamps[0]:
fps = (len(stamps) - 1) / (stamps[-1] - stamps[0])
else:
fps = 0.0
_META[key] = (int(width), int(height), float(fps))
def watch(preview: set[str], jpeg: set[str] | None = None) -> None:
"""当前页要哪几路。没点名的流在回调里直接丢掉,不拷图像。
``preview`` 走原分辨率 RGB,``jpeg`` 走手眼用的 JPEG。两边都空着就只保持订阅。
"""
global _PREVIEW_WANT, _JPEG_WANT
jpeg = set(jpeg or ())
preview = set(preview)
with _LOCK:
if preview == _PREVIEW_WANT and jpeg == _JPEG_WANT:
return
_PREVIEW_WANT = preview
_JPEG_WANT = jpeg
for key in preview | jpeg:
_NEXT.pop(key, None)
def wants(key: str) -> tuple[bool, bool]:
"""返回 ``(要预览, 要 JPEG)``。"""
with _LOCK:
return key in _PREVIEW_WANT, key in _JPEG_WANT
def offer_color(key: str, image: np.ndarray, *, bgr: bool, role: str) -> None:
"""收一帧彩色原图。没人看就丢掉。有人看就整幅留下,不按时钟抽帧。"""
preview, jpeg = wants(key)
if not preview and not jpeg:
return
if image.ndim != 3 or image.shape[2] != 3 or image.shape[0] < 1 or image.shape[1] < 1:
return
_offer(key, image, bgr=bgr, role=role, kind="color")
def offer_depth(key: str, image: np.ndarray) -> None:
"""收一帧深度原图。伪彩色仍在画图线程里做,不堵收图。"""
preview, _jpeg = wants(key)
if not preview:
return
if image.ndim != 2 or image.shape[0] < 1 or image.shape[1] < 1:
return
_offer(key, image, bgr=False, role="", kind="depth")
def _offer(key: str, image: np.ndarray, *, bgr: bool, role: str, kind: str) -> None:
height, width = int(image.shape[0]), int(image.shape[1])
with _LOCK:
slots = _SLOTS.setdefault(key, [None, None])
index = _SLOT_AT.get(key, 0)
slot = slots[index]
if slot is None or slot.shape != image.shape or slot.dtype != image.dtype:
slot = np.empty(image.shape, dtype=image.dtype)
slots[index] = slot
np.copyto(slot, image)
seq = int(_RAW.get(key, {}).get("seq", 0)) + 1
_RAW[key] = {
"seq": seq,
"frame": slot,
"bgr": bool(bgr),
"role": role,
"kind": kind,
"w": width,
"h": height,
}
_SLOT_AT[key] = 1 - index
_mark_rate(key, width, height)
_WAKE.set()
def publish_latest() -> None:
"""把各路还没画的最新原图做成预览。中间帧直接跳过,避免越画越落后。"""
with _LOCK:
jobs = []
for key, item in _RAW.items():
if _PAINTED.get(key) == item["seq"]:
continue
jobs.append((
key,
int(item["seq"]),
np.array(item["frame"], copy=True),
bool(item["bgr"]),
str(item["role"]),
str(item["kind"]),
int(item["w"]),
int(item["h"]),
))
for key, seq, frame, bgr, role, kind, width, height in jobs:
try:
preview, jpeg = wants(key)
if kind == "depth":
if preview:
colored = depth_preview(frame)
_store(key, colored, preview=colored, jpeg=False)
elif preview or jpeg:
limit = _JPEG_WIDTH if jpeg else _COLOR_WIDTH
rgb = rgb_preview(frame, bgr=bgr, limit=limit)
shown = rgb
if preview and int(rgb.shape[1]) > _COLOR_WIDTH:
shown = rgb_preview(rgb, bgr=False, limit=_COLOR_WIDTH)
if preview:
_remember_clean(key, shown, role, width, height)
shown = _overlay_detection(shown, role, width, height)
_store(key, rgb, preview=shown if preview else None, jpeg=jpeg)
except Exception as exc: # noqa: BLE001 —— 坏的一帧留在状态里,下一帧继续
with _LOCK:
_STATE["error"] = str(exc)
finally:
with _LOCK:
if _PAINTED.get(key, 0) < seq:
_PAINTED[key] = seq
def _paint_loop() -> None:
while not _STOP.is_set():
_WAKE.wait(0.005)
_WAKE.clear()
if _STOP.is_set():
return
try:
publish_latest()
except Exception as exc: # noqa: BLE001 —— 画图线程不能因为一帧坏掉就退出
with _LOCK:
_STATE["error"] = str(exc)
def accept_frame(key: str) -> bool:
"""这一帧要不要做进预览。没人看,或者距上一帧不到间隔,都返回 False。
彩色实况不再走这里。固定间隔会把相机自己的帧率再砍掉一截。
"""
now = time.monotonic()
with _LOCK:
if key not in _PREVIEW_WANT and key not in _JPEG_WANT:
return False
if now < _NEXT.get(key, 0.0):
return False
_NEXT[key] = now + _INTERVAL
return True
def _close_external_viewers() -> None:
"""关掉独立的四窗格查看器。它会把左右红外挂出来,本程序不再用它。"""
subprocess.run(["pkill", "-f", "lib/arm_application/realsense_view"], check=False)
subprocess.run(["pkill", "-f", "arm_application.realsense_view"], check=False)
def paint_detection(
rgb: np.ndarray,
xyxy: list[float] | tuple,
src_width: int,
src_height: int,
) -> np.ndarray:
"""把推理结果画到相机预览上:绿框、画面中心、跟踪红点、置信度和距离。
``xyxy`` 前四项是原图像素的框。够长时第 5 项是置信度,第 7、8 项是跟踪点,
第 9 项是距离(米)。空框原样返回。
"""
import cv2
if rgb.ndim != 3 or src_width <= 0 or src_height <= 0 or len(xyxy) < 4:
return rgb
scale_x = rgb.shape[1] / float(src_width)
scale_y = rgb.shape[0] / float(src_height)
x1 = int(round(float(xyxy[0]) * scale_x))
y1 = int(round(float(xyxy[1]) * scale_y))
x2 = int(round(float(xyxy[2]) * scale_x))
y2 = int(round(float(xyxy[3]) * scale_y))
height, width = rgb.shape[:2]
x1 = max(0, min(width - 1, x1))
x2 = max(0, min(width - 1, x2))
y1 = max(0, min(height - 1, y1))
y2 = max(0, min(height - 1, y2))
if x2 <= x1 or y2 <= y1:
return rgb
out = np.array(rgb, copy=True)
thick = max(2, width // 180)
cv2.rectangle(out, (x1, y1), (x2, y2), (80, 220, 120), thick)
cv2.drawMarker(
out,
(width // 2, height // 2),
(255, 220, 60),
cv2.MARKER_CROSS,
max(8, width // 24),
1,
)
if len(xyxy) >= 8:
px = int(round(float(xyxy[6]) * scale_x))
py = int(round(float(xyxy[7]) * scale_y))
px = max(0, min(width - 1, px))
py = max(0, min(height - 1, py))
cv2.circle(out, (px, py), max(3, width // 80), (255, 48, 48), -1)
if len(xyxy) >= 5:
conf = float(xyxy[4])
dist = f" z={float(xyxy[8]):.3f}m" if len(xyxy) >= 9 else ""
cv2.putText(
out,
f"{conf:.2f}{dist}",
(x1, max(14, y1 - 6)),
cv2.FONT_HERSHEY_SIMPLEX,
0.45,
(80, 220, 120),
1,
cv2.LINE_AA,
)
return out
def inference_status() -> str:
"""腕部和手外最近一次推理框。超过保持时间就当没有。"""
now = time.monotonic()
with _LOCK:
items = dict(_DET)
parts = []
for role, title in (("wrist", "腕部"), ("overhead", "手外")):
item = items.get(role)
if item is None or now - item[0] > _DET_HOLD_S or len(item[1]) < 5:
parts.append(f"{title} 没有框")
continue
parts.append(f"{title} 置信 {float(item[1][4]):.2f}")
return "推理 " + " ".join(parts)
def _remember_detection(role: str, data: list[float]) -> None:
with _LOCK:
if len(data) < 4:
_DET.pop(role, None)
else:
_DET[role] = (time.monotonic(), data)
_stamp_role(role)
def _remember_clean(key: str, rgb: np.ndarray, role: str, src_w: int, src_h: int) -> None:
with _LOCK:
_CLEAN[key] = {
"rgb": np.array(rgb, copy=True),
"role": role,
"src_w": int(src_w),
"src_h": int(src_h),
}
def _overlay_detection(rgb: np.ndarray, role: str, src_width: int, src_height: int) -> np.ndarray:
now = time.monotonic()
with _LOCK:
item = _DET.get(role)
if item is None or now - item[0] > _DET_HOLD_S or len(item[1]) < 4:
return rgb
return paint_detection(rgb, item[1], src_width, src_height)
def _stamp_role(role: str) -> None:
"""检测更新后,把框画回这一路相机已经在显示的画面上。"""
now = time.monotonic()
with _LOCK:
frames = [(key, dict(meta)) for key, meta in _CLEAN.items() if meta.get("role") == role]
item = _DET.get(role)
fresh = item is not None and now - item[0] <= _DET_HOLD_S and len(item[1]) >= 4
for key, meta in frames:
shown = meta["rgb"]
if fresh:
shown = paint_detection(shown, item[1], meta["src_w"], meta["src_h"])
with _LOCK:
_PREVIEW[key] = (np.ascontiguousarray(shown).tobytes(), int(shown.shape[1]), int(shown.shape[0]))
_STATE["frames"][key] = True
_STATE["stamp"] = time.time()
def rgb_preview(image: np.ndarray, *, bgr: bool, limit: int = _COLOR_WIDTH) -> np.ndarray:
"""缩成宽不超过 ``limit`` 的 RGB。返回的数组自己持有内存,不跟调用方共用缓冲。"""
import cv2
if image.ndim != 3 or image.shape[2] != 3 or image.shape[0] < 1 or image.shape[1] < 1:
raise ValueError("彩色预览要的是高×宽×3")
view = image
width = int(view.shape[1])
if width > limit:
height = max(1, int(round(int(view.shape[0]) * limit / float(width))))
view = cv2.resize(view, (limit, height), interpolation=cv2.INTER_AREA)
if bgr:
return cv2.cvtColor(view, cv2.COLOR_BGR2RGB)
if not view.flags["OWNDATA"] or not view.flags["C_CONTIGUOUS"]:
return np.ascontiguousarray(view)
return view
def depth_preview(depth: np.ndarray, limit: int = _DEPTH_WIDTH) -> np.ndarray:
"""uint16 深度画成 RGB。百分位只在抽稀后的样本上算,无效像素保持黑色。"""
import cv2
if depth.ndim != 2 or depth.shape[0] < 1 or depth.shape[1] < 1:
raise ValueError("深度预览要的是高×宽")
step = max(1, int(depth.shape[1]) // limit)
small = depth[::step, ::step]
valid = small > 0
gray = np.zeros(small.shape, np.uint8)
if bool(valid.any()):
sample = small[valid]
if sample.size > 8000:
sample = sample[:: max(1, sample.size // 8000)]
lo, hi = np.percentile(sample.astype(np.float32, copy=False), [5, 95])
hi = max(float(hi), float(lo) + 1.0)
norm = np.clip((small.astype(np.float32) - float(lo)) / (hi - float(lo)), 0.0, 1.0)
gray = (norm * 255.0).astype(np.uint8)
gray[~valid] = 0
colored = cv2.applyColorMap(gray, cv2.COLORMAP_JET)
colored[~valid] = 0
return rgb_preview(colored, bgr=True, limit=limit)
def _jpeg_bgr(rgb: np.ndarray) -> bytes | None:
import cv2
if rgb.size == 0:
return None
ok, buf = cv2.imencode(
".jpg",
cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR),
[int(cv2.IMWRITE_JPEG_QUALITY), 70],
)
if not ok:
return None
return buf.tobytes()
def _as_array(data, dtype, count: int) -> np.ndarray:
"""优先直接看消息缓冲。缓冲不支持时才拷一份。"""
try:
arr = np.frombuffer(data, dtype=dtype, count=count)
except (TypeError, BufferError, ValueError):
arr = np.frombuffer(bytes(data), dtype=dtype, count=count)
if arr.size < count:
raise ValueError("图像字节数不够")
return arr
def _color_view(msg) -> np.ndarray | None:
if msg.encoding not in ("bgr8", "rgb8") or msg.height <= 0 or msg.width <= 0:
return None
need = int(msg.height) * int(msg.width) * 3
arr = _as_array(msg.data, np.uint8, need)
return arr.reshape(int(msg.height), int(msg.width), 3)
def _depth_view(msg) -> np.ndarray | None:
if msg.encoding not in ("16UC1", "mono16") or msg.height <= 0 or msg.width <= 0:
return None
need = int(msg.height) * int(msg.width)
arr = _as_array(msg.data, np.uint16, need)
return arr.reshape(int(msg.height), int(msg.width))
def _store(key: str, rgb: np.ndarray, *, preview: np.ndarray | None, jpeg: bool) -> None:
encoded = _jpeg_bgr(rgb) if jpeg else None
with _LOCK:
if preview is not None:
_PREVIEW[key] = (preview.tobytes(), int(preview.shape[1]), int(preview.shape[0]))
if encoded:
_JPEG[key] = encoded
if preview is not None or encoded:
_STATE["frames"][key] = True
_STATE["stamp"] = time.time()
_STATE["error"] = ""
def _loop(cams: list[dict]) -> None:
import rclpy
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
class _Node(Node):
def __init__(self) -> None:
super().__init__("operator_camera_view")
det_qos = QoSProfile(
history=HistoryPolicy.KEEP_LAST,
depth=5,
reliability=ReliabilityPolicy.RELIABLE,
)
self.create_subscription(
Float32MultiArray, "workpiece/detection", self._det("wrist"), det_qos
)
self.create_subscription(
Float32MultiArray,
"workpiece/detection_overhead",
self._det("overhead"),
det_qos,
)
self._depth_subs: dict[str, object] = {}
for cam in cams:
name = cam["id"]
role = str(cam.get("role") or "wrist")
self.create_subscription(
Image,
f"/{name}/color/image_raw",
self._color(name, role),
qos_profile_sensor_data,
)
def sync_depth(self) -> None:
"""深度没打开就不订。订上之后每一帧都会整幅拷进进程,彩色帧率会被拖下去。"""
with _LOCK:
want = {
cam["id"]
for cam in cams
if f"{cam['id']}_depth" in _PREVIEW_WANT
}
for cam in cams:
name = cam["id"]
if name in want and name not in self._depth_subs:
self._depth_subs[name] = self.create_subscription(
Image,
f"/{name}/aligned_depth_to_color/image_raw",
self._depth(name),
qos_profile_sensor_data,
)
elif name not in want and name in self._depth_subs:
self.destroy_subscription(self._depth_subs.pop(name))
def _det(self, role: str):
def _cb(msg: Float32MultiArray) -> None:
_remember_detection(role, [float(v) for v in msg.data])
return _cb
def _color(self, name: str, role: str):
key = f"{name}_color"
def _cb(msg: Image) -> None:
# 这里只留最新原图。颜色转换和画框放到画图线程,避免把后面的帧堵住。
try:
view = _color_view(msg)
if view is None:
return
offer_color(key, view, bgr=(msg.encoding == "bgr8"), role=role)
except Exception as exc: # noqa: BLE001 —— 坏的一帧留在状态里,订阅不停
with _LOCK:
_STATE["error"] = str(exc)
return _cb
def _depth(self, name: str):
key = f"{name}_depth"
def _cb(msg: Image) -> None:
try:
view = _depth_view(msg)
if view is None:
return
offer_depth(key, view)
except Exception as exc: # noqa: BLE001 —— 坏的一帧留在状态里,订阅不停
with _LOCK:
_STATE["error"] = str(exc)
return _cb
rclpy.init()
node = _Node()
try:
while not _STOP.is_set():
node.sync_depth()
rclpy.spin_once(node, timeout_sec=0.01)
except Exception as exc:
with _LOCK:
_STATE["error"] = str(exc)
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
with _LOCK:
_STATE["on"] = False
def start() -> str:
global _THREAD, _PAINT_THREAD
cams = stream_targets()
if not cams:
with _LOCK:
_STATE["error"] = "site 里没有可预览的真机相机"
return _STATE["error"]
_close_external_viewers()
with _LOCK:
if _STATE["on"]:
return "相机预览已经在程序里"
_STATE["on"] = True
_STATE["error"] = ""
_STOP.clear()
_WAKE.clear()
_THREAD = threading.Thread(target=_loop, args=(cams,), daemon=True, name="cam-view")
_THREAD.start()
_PAINT_THREAD = threading.Thread(target=_paint_loop, daemon=True, name="cam-paint")
_PAINT_THREAD.start()
names = "、".join(cam["label"] for cam in cams)
return f"预览订了 {names} 的彩色原图,深度点开才订,不含左右红外。"
def stop() -> None:
_STOP.set()
_WAKE.set()
for thread in (_THREAD, _PAINT_THREAD):
if thread is not None:
thread.join(timeout=1.5)
@@ -0,0 +1,967 @@
#!/usr/bin/env python3
"""Orisys 工位界面。
只做两件事:把本机真实状态摊开,以及按白名单启动仓库里已有的脚本。
会动臂的动作必须带 ``confirm=true``,避免页面上误点一下就上电。
bash ~/ros2/scripts/run_console.sh
"""
from __future__ import annotations
import json
import os
import re
import signal
import subprocess
import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
import yaml
from arm_application import operator_tactile_view as tactile_view
from arm_application.site_io import eye_to_hand_result_name, listed_arms, overhead_camera, usb_expectations
HOST = "127.0.0.1"
PORT = 8766
_ROOT = Path(__file__).resolve()
for _p in _ROOT.parents:
if (_p / "scripts" / "run_real.sh").is_file():
ROOT = _p
break
else:
ROOT = Path.home() / "ros2"
LOG_DIR = Path("/tmp/orisys_console")
# 动臂和软拖拽都要这台臂,不能一边下发位置一边再开拖拽。
# 相机、夹爪、触觉不碰这条,也不该排在它后面等。
_ROBOT_LANES = frozenset({"arm", "teach"})
_ARM_ACTIONS = frozenset({
"pipeline", "stage_track", "stage_locate", "stage_screw",
"reset_home", "gauge_run", "real_up", "handeye_calibrate",
})
_LANE_WAIT = {
"grip": "夹爪上一条还没到位",
"camera": "相机命令还没返回",
"tactile": "触觉命令还没返回",
"arm": "这台臂上一条运动还没结束",
"teach": "软拖拽还没返回",
}
def command_lane(action: str) -> str:
"""每类命令一条通道。同一条还没返回才拒绝,不让相机去等夹爪。"""
if action == "stop_commands":
return "stop"
if action in ("camera_d405", "camera_d435"):
return "camera"
if action.startswith("tactile"):
return "tactile"
if action.startswith("gripper"):
return "grip"
if action == "handeye_teach":
return "teach"
if action in _ARM_ACTIONS:
return "arm"
return action
def command_blocked_message(running: set[str], lane: str) -> str:
"""这条通道,或同一台臂上的另一条运动,还没结束时返回要显示的句子。"""
if lane == "stop":
return ""
if lane in running:
return _LANE_WAIT.get(lane, "这一条命令还没返回")
if lane in _ROBOT_LANES:
other = next((name for name in _ROBOT_LANES if name != lane and name in running), "")
if other == "arm":
return "这台臂还在动,软拖拽先不开"
if other == "teach":
return "软拖拽占着这台臂,运动先不开"
return ""
ASSETS = Path(__file__).resolve().parent
_LOCK = threading.Lock()
_TACTILE = {"left": {}, "right": {}, "stamp": 0.0}
_HIST = {"left": deque(maxlen=360), "right": deque(maxlen=360)}
_PROCS: dict[str, int] = {}
_LIVE: dict[str, subprocess.Popen] = {}
_PROC_LOCK = threading.Lock()
def _push_force(side: str, sample: dict) -> None:
if side not in _HIST:
return
point = {
"t": float(sample.get("t") or time.time()),
"fnormal": sample.get("fnormal"),
"fshearx": sample.get("fshearx"),
"fsheary": sample.get("fsheary"),
}
if not any(isinstance(point[k], (int, float)) for k in ("fnormal", "fshearx", "fsheary")):
return
with _LOCK:
_HIST[side].append(point)
_TACTILE[side] = {
"fnormal": point["fnormal"],
"fshearx": point["fshearx"],
"fsheary": point["fsheary"],
"connected": True,
}
tactile_view.set_sample_hook(_push_force)
def _site() -> dict:
path = ROOT / "config" / "site.yaml"
if not path.is_file():
return {}
data = yaml.safe_load(path.read_text()) or {}
return data if isinstance(data, dict) else {}
def _arms() -> list[dict]:
"""真机臂来自 site.yaml。没有清单就空着,不补本机的 89/91。"""
return [
{
"id": row["id"],
"label": row["label"],
"sn": row["sn"],
"tactile": row["tactile"],
"camera": row["camera"],
"gripper": row["gripper"],
"rotary": row["rotary"],
}
for row in listed_arms(_site())
]
def _default_arm() -> str:
rows = _arms()
return str(rows[0]["id"]) if rows else ""
def _usb() -> list[dict]:
found = {"d405": [], "d435": [], "orisys": []}
want = {
("8086", "0b5b"): "d405",
("8086", "0b07"): "d435",
("1bcf", "2281"): "orisys",
}
base = Path("/sys/bus/usb/devices")
if not base.is_dir():
return []
for dev in base.iterdir():
vid = dev / "idVendor"
pid = dev / "idProduct"
if not vid.is_file() or not pid.is_file():
continue
key = want.get((vid.read_text().strip(), pid.read_text().strip()))
if not key:
continue
speed = (dev / "speed").read_text().strip() if (dev / "speed").is_file() else "?"
found[key].append({"port": dev.name, "speed": speed})
expect = usb_expectations(_site())
return [
{"id": "d405", "label": "腕部 D405", "expect": expect["d405"], "items": found["d405"]},
{"id": "d435", "label": "手外 D435", "expect": expect["d435"], "items": found["d435"]},
{"id": "orisys", "label": "视触觉", "expect": expect["orisys"], "items": found["orisys"]},
]
def _listeners(pattern: str) -> bool:
try:
out = subprocess.run(
["pgrep", "-af", pattern],
capture_output=True, text=True, timeout=2,
).stdout
except Exception:
return False
for line in out.splitlines():
if "pgrep" in line or "operator_console" in line:
continue
return True
return False
def _services() -> list[dict]:
rows = [
("real", "控制栈", "ros2_control_node"),
("tactile", "视触觉节点", "lib/arm_application/tactile"),
("camera", "相机", "realsense_color_pub"),
("gripper", "夹爪", "dh_pgea|dual_gripper_driver"),
("pipeline", "三段流程", "assembly_pipeline"),
("gauge", "通止规装配", "gauge_assembly"),
]
out = []
for sid, label, pattern in rows:
if "|" in pattern:
up = any(_listeners(part) for part in pattern.split("|"))
else:
up = _listeners(pattern)
out.append({"id": sid, "label": label, "up": up})
return out
def _grippers() -> list[str]:
return sorted(p.name for p in Path("/dev").glob("ttyUSB*"))
def _parse_tactile(text: str) -> dict:
got: dict[str, object] = {}
for line in text.splitlines():
if ":" not in line:
continue
key, _, raw = line.partition(":")
key = key.strip()
raw = raw.strip()
if key in ("fnormal", "fshearx", "fsheary", "fps"):
try:
got[key] = float(raw)
except ValueError:
continue
elif key in ("connected", "contact"):
got[key] = raw.lower() == "true"
return got
def _poll_tactile() -> None:
while True:
snap = {}
for side in ("left", "right"):
try:
proc = subprocess.run(
["timeout", "1.2", "ros2", "topic", "echo", f"/tactile/{side}/status", "--once"],
capture_output=True, text=True, timeout=2.5,
)
snap[side] = _parse_tactile(proc.stdout) if proc.returncode == 0 else {}
except Exception:
snap[side] = {}
view_on = bool(tactile_view.state().get("on"))
with _LOCK:
_TACTILE["stamp"] = time.time()
if not view_on:
_TACTILE["left"] = snap.get("left") or {}
_TACTILE["right"] = snap.get("right") or {}
now = time.time()
for side in ("left", "right"):
row = snap.get(side) or {}
if row:
_HIST[side].append({
"t": now,
"fnormal": row.get("fnormal"),
"fshearx": row.get("fshearx"),
"fsheary": row.get("fsheary"),
})
time.sleep(0.35)
def _robot(arm: str) -> dict:
from arm_application.site_io import arm_short_id
robots = (_site().get("robot") or {}).get("robots") or []
for robot in robots:
if not isinstance(robot, dict):
continue
sn = str(robot.get("sn") or "")
if sn.lower() == "sim":
continue
if arm_short_id(sn) == arm:
return robot
return {}
def _comment(text: str, key: str) -> str:
match = re.search(rf"^#\s*{re.escape(key)}:\s*(.+)$", text, re.M)
return match.group(1).strip() if match else ""
def _pose_count(path: Path) -> int | None:
if not path.is_file():
return None
match = re.search(r"num_poses:\s*(\d+)", path.read_text(errors="replace"))
return int(match.group(1)) if match else None
def _result_card(path: Path, pose_path: Path, board: dict) -> dict:
text = path.read_text(errors="replace") if path.is_file() else ""
trans = None
tool = ""
if text:
try:
data = yaml.safe_load(text) or {}
except yaml.YAMLError:
data = {}
if isinstance(data, dict):
raw = data.get("translation_m")
if isinstance(raw, list) and len(raw) >= 3:
trans = [float(raw[0]), float(raw[1]), float(raw[2])]
tool = str(data.get("gripper_tool") or "")
meaning = ""
for line in text.splitlines():
if line.startswith("# T_4x4"):
meaning = line[2:].strip()
break
return {
"file": str(path),
"exists": path.is_file(),
"views": _comment(text, "views"),
"utc": _comment(text, "utc"),
"board_note": _comment(text, "board"),
"camera": _comment(text, "camera_source"),
"tool": tool,
"meaning": meaning,
"translation_m": trans,
"poses": _pose_count(pose_path),
"pose_file": str(pose_path),
"board": {
"cols": board.get("cols"),
"rows": board.get("rows"),
"square_m": board.get("square_size_m"),
},
}
def _handeye(arm: str) -> dict:
site = _site()
robot = _robot(arm)
paths = robot.get("paths") if isinstance(robot.get("paths"), dict) else {}
shared = site.get("paths") if isinstance(site.get("paths"), dict) else {}
boards = ((site.get("handeye") or {}).get("boards") or {})
wrist_file = Path(str(
paths.get("handeye_file")
or shared.get("handeye_file")
or (ROOT / "handeye" / "results" / f"{arm}_eye_in_hand.yaml")
))
wrist_pose = Path(str(
paths.get("handeye_waypoints")
or shared.get("handeye_waypoints")
or (ROOT / "handeye" / "poses" / f"{arm}_eye_in_hand.yaml")
))
cam, _others = overhead_camera(site, robot or None)
cam_name = str((cam or {}).get("name") or "")
e2h_name = eye_to_hand_result_name(arm, cam_name)
e2h_file = Path(str(
paths.get("handeye_file_eye_to_hand")
or shared.get("handeye_file_eye_to_hand")
or (ROOT / "handeye" / "results" / e2h_name)
))
e2h_pose = Path(str(
paths.get("handeye_waypoints_eye_to_hand")
or shared.get("handeye_waypoints_eye_to_hand")
or (ROOT / "handeye" / "poses" / f"{arm}_eye_to_hand.yaml")
))
wrist = _result_card(wrist_file, wrist_pose, boards.get("eye_in_hand") or {})
wrist_name = ""
if isinstance(robot.get("camera"), dict):
wrist_name = str(robot["camera"].get("name") or "").strip().strip("/")
wrist["preview_camera"] = wrist_name
wrist["preview_label"] = "腕部相机"
outside = _result_card(e2h_file, e2h_pose, boards.get("eye_to_hand") or {})
outside["preview_camera"] = cam_name.strip().strip("/")
outside["preview_label"] = "手外相机"
return {
"eye_in_hand": wrist,
"eye_to_hand": outside,
}
def _by_arm(reader) -> dict:
return {row["id"]: reader(row["id"]) for row in _arms()}
def _gripper_live() -> dict:
from arm_application.operator_gripper_state import snapshot as grip_snapshot
return _by_arm(grip_snapshot)
def status() -> dict:
with _LOCK:
tactile = {
"left": dict(_TACTILE["left"]),
"right": dict(_TACTILE["right"]),
"stamp": _TACTILE["stamp"],
}
history = {
"left": list(_HIST["left"]),
"right": list(_HIST["right"]),
}
from arm_application.operator_arm_state import snapshot as arm_snapshot
return {
"arms": _arms(),
"arm_live": _by_arm(arm_snapshot),
"gripper_live": _gripper_live(),
"usb": _usb(),
"services": _services(),
"grippers": _grippers(),
"tactile": tactile,
"history": history,
"view": tactile_view.state(),
"handeye": _by_arm(_handeye),
"now": time.time(),
}
# 会动真机的动作必须 confirm。臂号必须是 site.yaml 里某台真机的末两位。
_ACTIONS = {
"real_up": {
"title": "上电控制栈",
"script": "run_real.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "连接机械臂并拉起 ros2_control。",
},
"camera_d405": {
"title": "本臂腕部相机",
"script": "run_camera_d405.sh",
"args": ["--arm", "{arm}", "--no-preview"],
"moves": False,
"blurb": "只拉起本臂腕部相机。彩色和深度显示在程序里,不看左右红外,不另开窗口。",
},
"camera_d435": {
"title": "手外 D435",
"script": "run_camera_d435.sh",
"args": ["--no-preview"],
"moves": False,
"blurb": "只拉起顶视 D435。彩色和深度显示在程序里,不另开窗口。",
},
"tactile_up": {
"title": "视触觉三力",
"script": "run_tactile.sh",
"args": [],
"moves": False,
"blurb": "发布左右指法向力与两路切向力。",
},
"gripper_up": {
"title": "夹爪节点",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}"],
"moves": False,
"blurb": "只拉起夹爪驱动,不主动张合。",
},
"gripper_probe": {
"title": "夹爪探测",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "probe"],
"moves": False,
"blurb": "读夹爪是否在线,不动手指。驱动没在跑时先拉起,不回零。",
},
"gripper_open": {
"title": "夹爪张开",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "open"],
"moves": True,
"blurb": "张开当前臂夹爪。驱动没在跑时先拉起,不回零。",
},
"gripper_close": {
"title": "夹爪闭合",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "close"],
"moves": True,
"blurb": "闭合当前臂夹爪。驱动没在跑时先拉起,不回零。",
},
"gripper_rotate": {
"title": "右爪转 90°",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "rotate", "90"],
"moves": True,
"blurb": "右臂旋转爪转到 90°。左臂平行爪没有这一轴。",
},
"pipeline": {
"title": "跟踪并对孔",
"script": "run_all.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "跟踪和对孔一次跑完。搜孔轻压已取消,这一步不含旋入。会动臂。",
},
"stage_track": {
"title": "只测跟踪",
"script": "run_visual_servo_rt.sh",
"args": ["--arm", "{arm}", "--control"],
"moves": True,
"blurb": "只跑视觉跟踪。从当前位姿开始,会动臂。要先有控制栈和相机。",
},
"stage_locate": {
"title": "只测对孔",
"script": "run_stage2_locate.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "只跑二阶段:朝下、悬停、拍照、对准孔。会动臂。",
},
"stage_screw": {
"title": "只测旋转装配",
"script": "run_stage3_search.sh",
"args": ["--arm", "{arm}", "--screw"],
"moves": True,
"blurb": "按现场配置的拧入深度和螺距把螺纹规旋进去。会动臂。臂应已在孔口。",
},
"reset_home": {
"title": "复位到 home",
"script": "go_home.sh",
"args": ["--arm", "{arm}", "--pose", "home"],
"moves": True,
"sync": True,
"timeout": 120,
"blurb": "先停跟踪,再回到命名位姿 home。要控制栈已经起来。会动臂。",
},
"gauge_dry": {
"title": "装配干跑",
"script": "run_gauge_assembly.sh",
"args": ["--arm", "{arm}", "--dry-run"],
"moves": False,
"blurb": "S00 到 S11 只打印计划,不发运动指令。",
},
"gauge_run": {
"title": "装配真跑",
"script": "run_gauge_assembly.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "通止规状态机。会动臂、会拧。",
},
"handeye_show": {
"title": "查看标定配置",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "show", "--mode", "{mode}"],
"moves": False,
"sync": True,
"blurb": "打印这块板、结果文件和已有点数,不动臂。",
},
"handeye_teach": {
"title": "示教采点",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "teach", "--mode", "{mode}", "--points", "{points}"],
"moves": True,
"blurb": "软拖拽示教。点数和是否接着采由手眼页上的控件决定。要先停控制栈,会动臂。",
},
"handeye_merge": {
"title": "合并示教点",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "merge", "--mode", "{mode}"],
"moves": False,
"sync": True,
"blurb": "把刚采的点写入该模式的位姿文件,不动臂。",
},
"handeye_calibrate": {
"title": "求解手眼",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "calibrate", "--mode", "{mode}"],
"moves": True,
"blurb": "按点位走一遍并写结果。需要控制栈和对应相机,会动臂。",
},
}
def catalog() -> dict:
return {
"actions": [
{"id": key, **{k: v for k, v in spec.items() if k not in ("script", "sync")}}
for key, spec in _ACTIONS.items()
],
"stages": [
("S00", "自检"),
("S01", "回零预绕"),
("S02", "夹持确认"),
("S03", "手外粗定位"),
("S04", "视觉跟踪"),
("S05", "精拍"),
("S06", "正悬停"),
("S07", "柔顺接触"),
("S08", "棘轮拧入"),
("S09", "反向拧出"),
("S10", "换孔"),
("S11", "回零结束"),
],
}
def _remember(action: str, proc: subprocess.Popen) -> None:
with _PROC_LOCK:
_LIVE[action] = proc
_PROCS[action] = proc.pid
def _forget(action: str) -> None:
with _PROC_LOCK:
_LIVE.pop(action, None)
def _pid_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True
def stop_started() -> dict:
"""停掉本界面拉起的命令。只向登记过的进程组发信号,不扫全机。"""
with _PROC_LOCK:
items = list(_LIVE.items())
if not items:
return {"ok": True, "message": "没有正在跑的命令"}
names: list[str] = []
for action, proc in items:
pid = int(getattr(proc, "pid", 0) or 0)
if not _pid_alive(pid):
_forget(action)
continue
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except ProcessLookupError:
_forget(action)
continue
except OSError as exc:
return {"ok": False, "message": f"停不了 {action}:{exc}"}
names.append(action)
time.sleep(0.3)
for action, proc in items:
pid = int(getattr(proc, "pid", 0) or 0)
if not _pid_alive(pid):
_forget(action)
continue
try:
os.killpg(os.getpgid(pid), signal.SIGKILL)
except OSError:
pass
_forget(action)
if not names:
return {"ok": True, "message": "没有正在跑的命令"}
return {"ok": True, "message": "已停止:" + "、".join(names)}
def _stop_tactile() -> str:
subprocess.run(["pkill", "-f", "lib/arm_application/tactile"], check=False)
subprocess.run(["pkill", "-f", "arm_application/tactile_node.py"], check=False)
subprocess.run(["pkill", "orisys_tac_w"], check=False)
return "已发送停止视触觉节点"
def render_action_args(
template: list[str], arm: str, mode: str, extras: dict | None = None,
) -> list[str]:
"""把动作模板里的臂号、模式、点数换成这次界面上的值。"""
extra = extras or {}
needs_points = any("{points}" in str(token) for token in template)
points_text = ""
if needs_points:
try:
points = int(extra.get("points", 12))
except (TypeError, ValueError):
raise ValueError("目标点数不是整数") from None
if points < 4 or points > 30:
raise ValueError("目标点数只接受 4–30")
points_text = str(points)
repl = {"{arm}": arm, "{mode}": mode, "{points}": points_text}
out: list[str] = []
for token in template:
text = str(token)
for key, value in repl.items():
text = text.replace(key, value)
out.append(text)
if extra.get("append"):
if "teach" not in out:
raise ValueError("只有示教采点能接着采")
out.append("--append")
return out
def run_action(
action: str, arm: str, confirm: bool, mode: str = "eye_in_hand",
extras: dict | None = None,
) -> dict:
if action == "tactile_down":
tactile_view.stop()
return {"ok": True, "message": _stop_tactile()}
if action == "tactile_view_on":
with _LOCK:
_HIST["left"].clear()
_HIST["right"].clear()
return {"ok": True, "message": tactile_view.start(_site())}
if action == "tactile_view_off":
return {"ok": True, "message": tactile_view.stop()}
spec = _ACTIONS.get(action)
if spec is None:
return {"ok": False, "message": "没有这个动作"}
if arm not in {row["id"] for row in _arms()}:
return {"ok": False, "message": f"臂号 {arm} 不在 site.yaml 里"}
if mode not in ("eye_in_hand", "eye_to_hand"):
return {"ok": False, "message": "手眼模式只接受 eye_in_hand 或 eye_to_hand"}
if spec["moves"] and not confirm:
return {"ok": False, "message": "这个动作会动真机,需要确认"}
script = ROOT / "scripts" / spec["script"]
if not script.is_file():
return {"ok": False, "message": f"找不到 {script}"}
from arm_application.operator_arm_state import (
hold_until_idle,
motion_blocked,
release_for_control,
resume_after_control,
snapshot as arm_snapshot,
stack_running,
)
blocked = motion_blocked(
action,
stack_up=stack_running(),
op_status=str((arm_snapshot(arm) or {}).get("op_status") or ""),
)
if blocked:
return {"ok": False, "message": blocked}
uses_rdk = action in {
"real_up", "handeye_teach", "pipeline", "stage_track", "stage_locate",
"stage_screw", "reset_home", "gauge_run", "handeye_calibrate",
}
if uses_rdk:
release_for_control()
from arm_application.operator_gripper_state import hold_serial
grip_port = action in {
"gripper_probe", "gripper_open", "gripper_close", "gripper_rotate", "gripper_up",
}
try:
args = render_action_args(list(spec["args"]), arm, mode, extras)
except ValueError as exc:
return {"ok": False, "message": str(exc)}
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = LOG_DIR / f"{action}_{arm}.log"
shown = " ".join(["bash", f"scripts/{spec['script']}", *args])
try:
if spec.get("sync"):
proc = subprocess.Popen(
["bash", str(script), *args],
cwd=str(ROOT),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
_remember(action, proc)
try:
detail, _ = proc.communicate(timeout=float(spec.get("timeout") or 40))
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except OSError:
pass
return {"ok": False, "message": "命令超时还没返回", "detail": shown}
finally:
_forget(action)
detail = (detail or "").strip()
log_path.write_text(detail)
ok = proc.returncode == 0
return {
"ok": ok,
"message": shown if ok else f"退出码 {proc.returncode}",
"detail": detail[-4000:],
"log": str(log_path),
}
log_f = log_path.open("ab")
proc = subprocess.Popen(
["bash", str(script), *args],
cwd=str(ROOT),
stdout=log_f,
stderr=subprocess.STDOUT,
start_new_session=True,
)
_remember(action, proc)
if grip_port:
hold_serial(proc.pid)
if uses_rdk:
hold_until_idle(proc.pid, until_stack=(action == "real_up"))
uses_rdk = False
return {"ok": True, "message": shown, "pid": proc.pid, "log": str(log_path)}
finally:
if uses_rdk:
resume_after_control()
def _bounded(name: str, raw, low: float, high: float) -> float:
try:
value = float(raw)
except (TypeError, ValueError):
raise ValueError(f"{name} 不是数字") from None
if value != value or value < low or value > high:
raise ValueError(f"{name} 只接受 {low:g}–{high:g}")
return value
def run_gripper_tune(arm: str, kind: str, numbers: dict) -> dict:
"""按界面上的位置、力度、速度下发夹爪。右臂另有角度、圈数。"""
if arm not in {row["id"] for row in _arms()}:
return {"ok": False, "message": f"臂号 {arm} 不在 site.yaml 里"}
if kind not in ("pos", "rotate", "rotate_by", "revolutions"):
return {"ok": False, "message": "没有这种夹爪调节"}
if kind != "pos" and not any(row["id"] == arm and row.get("rotary") for row in _arms()):
return {"ok": False, "message": "这台夹爪没有旋转轴"}
try:
if kind == "pos":
pos = int(round(_bounded("位置", numbers.get("pos"), 0, 1000)))
force = int(round(_bounded("力度", numbers.get("force"), 20, 100)))
speed = int(round(_bounded("速度", numbers.get("speed"), 1, 100)))
cli = ["pos", str(pos)]
summary = f"位置 {pos}(0 合,1000 开) 力度 {force}% 速度 {speed}%"
else:
force = int(round(_bounded("力矩", numbers.get("torque"), 20, 100)))
speed = int(round(_bounded("旋转速度", numbers.get("rot_speed"), 1, 100)))
if kind == "rotate":
angle = _bounded("绝对角", numbers.get("angle"), -32767, 32767)
cli = ["rotate", f"{angle:.3f}"]
summary = f"绝对角 {angle:.3f}° 力矩 {force}% 速度 {speed}%"
elif kind == "rotate_by":
angle = _bounded("相对角", numbers.get("delta"), -32767, 32767)
cli = ["rotate_by", f"{angle:.3f}"]
summary = f"相对转 {angle:.3f}° 力矩 {force}% 速度 {speed}%"
else:
turns = _bounded("圈数", numbers.get("revolutions"), -50, 50)
cli = ["revolutions", f"{turns:.3f}"]
summary = f"转 {turns:.3f} 圈 力矩 {force}% 速度 {speed}%"
except ValueError as exc:
return {"ok": False, "message": str(exc)}
from arm_application.operator_gripper_state import pause_serial, resume_serial
script = ROOT / "scripts" / "run_gripper.sh"
pause_serial()
try:
return _run_gripper_argv(script, arm, force, speed, cli, summary)
finally:
resume_serial()
def _run_gripper_argv(script, arm: str, force: int, speed: int, cli: list[str], summary: str) -> dict:
argv = [
"bash", str(script),
"--arm", arm,
"--force", str(force),
"--speed", str(speed),
"--cli", *cli,
]
shown = " ".join(argv)
proc = subprocess.Popen(
argv, cwd=str(ROOT), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, start_new_session=True,
)
_remember(f"gripper_{arm}", proc)
try:
try:
detail, _ = proc.communicate(timeout=90)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except OSError:
pass
return {"ok": False, "message": "夹爪 90 秒还没到位", "detail": shown}
finally:
_forget(f"gripper_{arm}")
detail = (detail or "").strip()
ok = proc.returncode == 0
return {
"ok": ok,
"message": summary if ok else _grip_failure_message(proc.returncode, detail),
"detail": (summary + "\n" + shown + "\n" + detail)[-4000:],
}
def _grip_failure_message(code: int, detail: str) -> str:
"""退出码旁边带上脚本打出来的原因,别只剩一个数字。"""
for line in reversed(detail.splitlines()):
text = line.strip()
if text.startswith("ERROR:") or text.startswith("无法"):
return text
return f"夹爪退出码 {code}"
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
return
def _send(self, code: int, body: bytes, content_type: str) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json(self, code: int, payload: dict) -> None:
self._send(code, json.dumps(payload, ensure_ascii=False).encode(), "application/json; charset=utf-8")
def do_GET(self) -> None:
path = urlparse(self.path).path
if path in ("/", "/index.html"):
page = (ASSETS / "operator_console.html").read_text(encoding="utf-8")
payload = json.dumps(
{"status": status(), "catalog": catalog()},
ensure_ascii=False,
).replace("<", "\\u003c")
page = page.replace("/*BOOT*/null", payload, 1)
self._send(200, page.encode(), "text/html; charset=utf-8")
return
if path == "/logo.png":
self._send(200, (ASSETS / "operator_console_logo.png").read_bytes(), "image/png")
return
if path == "/api/status":
self._json(200, status())
return
if path == "/api/catalog":
self._json(200, catalog())
return
parts = path.strip("/").split("/")
if len(parts) == 5 and parts[:3] == ["api", "tactile", "frame"] and parts[4].endswith(".jpg"):
side, kind = parts[3], parts[4][:-4]
blob = tactile_view.frame(side, kind) if side in ("left", "right") and kind in ("shear", "thermal") else None
if not blob:
self._json(404, {"ok": False, "message": "还没有这一帧"})
return
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(blob)))
self.end_headers()
self.wfile.write(blob)
return
self._json(404, {"ok": False, "message": "没有这个路径"})
def do_POST(self) -> None:
path = urlparse(self.path).path
if path not in ("/api/action", "/api/tactile/view"):
self._json(404, {"ok": False, "message": "没有这个路径"})
return
length = int(self.headers.get("Content-Length") or 0)
try:
body = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
self._json(400, {"ok": False, "message": "请求不是 JSON"})
return
if path == "/api/tactile/view":
action = "tactile_view_on" if body.get("on") else "tactile_view_off"
result = run_action(action, _default_arm(), False)
self._json(200 if result.get("ok") else 400, result)
return
result = run_action(
str(body.get("action") or ""),
str(body.get("arm") or _default_arm()),
bool(body.get("confirm")),
str(body.get("mode") or "eye_in_hand"),
)
self._json(200 if result.get("ok") else 400, result)
def main() -> None:
import atexit
atexit.register(tactile_view.stop)
from arm_application.operator_arm_state import start as start_arm_state
from arm_application.operator_gripper_state import start as start_gripper_state
start_arm_state()
start_gripper_state()
threading.Thread(target=_poll_tactile, daemon=True).start()
server = ThreadingHTTPServer((HOST, PORT), Handler)
print(f"Orisys 工位界面 http://{HOST}:{PORT}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,138 @@
"""大寰夹爪的一比一外形。
左臂按下载包 ``PGEA-3D-Model-and-2D-Drawing-4`` 里的
``PGEA-100-26-W-F`` 尺寸图。图签就是这个型号,总长 138、宽 55、厚 29,
安装端抱闸约 20,主体 78,手指伸出约 40,闭合缝 1.2。
现场装的是 ``PGEA-100-20-W``,这包里没有 100-20 的图,开口仍用
``site.yaml`` 的 20 mm,不用图上的 26。
同包另一张完整的带抱闸图是 ``PGEA-100-40-W-F``(手指更长,张开约 40.8)。
右臂是 ``RGI-100-30``,这包里没有旋转爪的图,仍按手册外形。
"""
from __future__ import annotations
import math
# 米。来源见模块说明。长指向指尖,宽是开合方向,厚是图上的 29。
PGEA_SIZE = (0.138, 0.055, 0.029)
PGEA_STROKE_MM = 20.0
PGEA_CLOSED_GAP_M = 0.0012
PGEA_FINGER_M = 0.040
RGI_SIZE = (0.158, 0.0755, 0.047)
RGI_STROKE_MM = 30.0
# 深色底上要能分开:银壳体、蓝抱闸、橙手指、绿导轨。
# 手指以前是 0.10 的黑,乘上未连接时的 0.55 就沉进背景里。
_BODY = (0.86, 0.88, 0.91)
_BRAKE = (0.16, 0.42, 0.90)
_FINGER = (0.95, 0.46, 0.10)
_RAIL = (0.18, 0.78, 0.28)
def triangles(
rotary: bool,
permille: float | None,
rotation_deg: float | None,
stroke_mm: float | None,
) -> tuple[list[tuple], str]:
"""返回 ``(三角面, 说明)``。三角面是 ``(a, b, c, 明暗占位, rgb)``,明暗由界面再乘。"""
if rotary:
length, width, thick = RGI_SIZE
stroke = (float(stroke_mm) if stroke_mm else RGI_STROKE_MM) / 1000.0
note = (
f"RGI-100-30 外形 {length*1000:.0f}×{width*1000:.1f}×{thick*1000:.0f} mm"
f" 行程 {stroke*1000:.0f} mm。这包图纸里没有旋转爪,外形仍按手册。"
)
body_end = 0.118
brake = None
else:
length, width, thick = PGEA_SIZE
stroke = (float(stroke_mm) if stroke_mm else PGEA_STROKE_MM) / 1000.0
note = (
f"PGEA-100-26-W-F 尺寸图 外形 {length*1000:.0f}×{width*1000:.0f}×{thick*1000:.0f} mm"
f" 现场行程 {stroke*1000:.0f} mm。包里没有 100-20 的图,开口不用图上的 26。"
)
body_end = length - PGEA_FINGER_M
brake = (0.0, 0.020)
gap = stroke * (0.5 if permille is None else max(0.0, min(1.0, float(permille) / 1000.0)))
if not rotary:
gap += PGEA_CLOSED_GAP_M
parts = _solid(length, width, thick, body_end, gap, brake, rotary)
if rotary and rotation_deg:
parts = _spin_fingers(parts, math.radians(float(rotation_deg)), body_end)
out = []
for a, b, c, rgb in parts:
out.append((a, b, c, _face_shade(a, b, c), rgb))
if permille is None:
note = "没有读数。下面是半开,不是当前开口。 " + note
return out, note
def _solid(length, width, thick, body_end, gap, brake, rotary: bool):
hx, hy = thick / 2.0, width / 2.0
faces = []
z0 = 0.0
if brake is not None:
z0 = brake[1]
faces.extend(_box(0.0, brake[1], -hy, hy, -hx, hx, _BRAKE))
faces.extend(_box(z0, body_end, -hy, hy, -hx, hx, _BODY))
if rotary:
finger_t = min(0.010, hy * 0.4)
finger_h = thick * 0.72
else:
# 尺寸图端视图:指厚 6.5,指高 16,都落在 55×29 的外形里面。
finger_t = 0.0065
finger_h = 0.016
rail = 0.003
faces.extend(_box(z0, body_end, -hy * 0.62, -hy * 0.62 + rail, hx - rail, hx, _RAIL))
faces.extend(_box(z0, body_end, hy * 0.62 - rail, hy * 0.62, hx - rail, hx, _RAIL))
half = gap / 2.0
faces.extend(_box(body_end, length, -(half + finger_t), -half, -finger_h / 2, finger_h / 2, _FINGER))
faces.extend(_box(body_end, length, half, half + finger_t, -finger_h / 2, finger_h / 2, _FINGER))
return faces
def _spin_fingers(parts, angle: float, body_end: float):
c, s = math.cos(angle), math.sin(angle)
spun = []
for a, b, c0, rgb in parts:
if a[0] >= body_end - 1e-6 and b[0] >= body_end - 1e-6 and c0[0] >= body_end - 1e-6:
a, b, c0 = (_rot_z(a, c, s), _rot_z(b, c, s), _rot_z(c0, c, s))
spun.append((a, b, c0, rgb))
return spun
def _face_shade(a, b, c) -> float:
"""顶面亮、底面暗。零件颜色已经分开,明暗再把面分出来。"""
ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2]
nx = uy * vz - uz * vy
ny = uz * vx - ux * vz
nz = ux * vy - uy * vx
length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
dot = (nx * 0.15 + ny * -0.25 + nz * 0.95) / length
return 0.42 + 0.58 * max(0.0, min(1.0, dot))
def _rot_z(point, c, s):
x, y, z = point
return (x, y * c - z * s, y * s + z * c)
def _box(x0, x1, y0, y1, z0, z1, rgb) -> list[tuple]:
"""轴向盒子。x 指向指尖,y 是开合方向,z 是厚度。"""
p = (
(x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0),
(x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1),
)
quads = (
(0, 1, 2, 3), (4, 7, 6, 5),
(0, 4, 5, 1), (3, 2, 6, 7),
(0, 3, 7, 4), (1, 5, 6, 2),
)
out = []
for a, b, c, d in quads:
out.append((p[a], p[b], p[c], rgb))
out.append((p[a], p[c], p[d], rgb))
return out
@@ -0,0 +1,436 @@
"""工位界面用的夹爪状态:只读开口和转角,并交给界面画出来。
夹爪节点已经占着串口时,只订它发布的话题,不再自己打开串口。
没有节点时才短连一次。连上之后只读寄存器,不写初始化,避免手指回零把工件松开。
"""
from __future__ import annotations
import os
import subprocess
import threading
import time
from typing import Any
import yaml
from arm_application.site_io import listed_arms, ros2_root
_GRIP = {0: "运动中", 1: "已到位", 2: "已夹住", 3: "已掉落"}
_ROT = {0: "转动中", 1: "已到位", 2: "受阻", 3: "曾经受阻"}
_LOCK = threading.Lock()
_CACHE: dict[str, dict] = {}
_STARTED = False
_PORT_HOLD = 0
_PORT_BUSY = 0
_PORT_LOCK = threading.Lock()
def snapshot(arm: str) -> dict:
with _LOCK:
row = _CACHE.get(arm)
return dict(row) if row else _empty(arm)
def _empty(arm: str) -> dict:
row = next((item for item in _arms() if item["id"] == arm), None)
return present(
arm,
rotary=bool(row and row["rotary"]),
stroke_mm=float(row["stroke_mm"]) if row else 20.0,
reading=None,
error="还没读到夹爪",
)
def present(
arm: str,
*,
rotary: bool,
stroke_mm: float,
reading: dict | None,
error: str = "",
) -> dict:
"""把一次读数收成界面能画的字典。不打开串口。"""
stroke = float(stroke_mm) if stroke_mm and stroke_mm > 0 else 20.0
row = reading or {}
permille = _unit(row.get("permille"), 0, 1000)
connected = bool(row.get("connected")) and permille is not None
opening = None if permille is None else permille / 1000.0 * stroke
rotation = _finite(row.get("rotation_deg")) if rotary else 0.0
grip_state = row.get("grip_state")
rot_state = row.get("rotation_state")
if not connected:
summary = error or str(row.get("error_msg") or "夹爪没连上")
else:
bits = [_GRIP.get(int(grip_state), "状态未知") if _finite(grip_state) is not None else "状态未知"]
bits.append(f"开口 {opening:.1f} mm" if opening is not None else "")
if rotary and rotation is not None:
bits.append(f"转角 {rotation:.1f}°")
if _finite(rot_state) is not None:
bits.append(_ROT.get(int(rot_state), ""))
summary = " ".join(bit for bit in bits if bit)
return {
"arm": arm,
"rotary": rotary,
"connected": connected,
"initialized": bool(row.get("initialized")) if connected else False,
"permille": permille,
"opening_mm": opening,
"stroke_mm": stroke,
"rotation_deg": rotation if rotary else None,
"force": _finite(row.get("force")),
"speed": _finite(row.get("speed")),
"grip_state": int(grip_state) if _finite(grip_state) is not None else None,
"grip_text": _GRIP.get(int(grip_state), "—") if _finite(grip_state) is not None else "—",
"rotation_text": _ROT.get(int(rot_state), "—") if rotary and _finite(rot_state) is not None else "—",
"moving": bool(row.get("moving")) if connected else False,
"gripped": bool(row.get("gripped")) or (_finite(grip_state) == 2) if connected else False,
"summary": summary,
"error": "" if connected else (error or str(row.get("error_msg") or "")),
"source": str(row.get("source") or ""),
"pose_live": connected,
"stamp": time.time(),
}
def retain_reading(
fresh: dict,
previous: dict | None,
*,
now: float | None = None,
hold_s: float = 15.0,
) -> dict:
"""这一拍没读到开口时,短时间沿用上一拍。
和机械臂同一件事:话题 echo 偶发超时会把刚读到的开口清成“没连上”,
模型和顶栏就停在那里。超过 ``hold_s`` 仍没有新读数才放开。
"""
if fresh.get("connected") or not previous or not previous.get("connected"):
return fresh
moment = time.time() if now is None else now
if moment - float(previous.get("stamp") or 0.0) > hold_s:
return fresh
kept = dict(fresh)
for key in (
"connected", "initialized", "permille", "opening_mm", "stroke_mm",
"rotation_deg", "force", "speed", "grip_state", "grip_text",
"rotation_text", "moving", "gripped", "pose_live", "rotary", "source",
):
if key in previous:
kept[key] = previous[key]
kept["summary"] = str(previous.get("summary") or fresh.get("summary") or "")
note = "沿用上次读数。"
if note not in kept["summary"]:
kept["summary"] = f"{kept['summary']} {note}".strip()
return kept
def pause_serial() -> None:
"""控制命令要自己开串口,或把驱动拉起来。先拦住状态线程,等它把口放开。
一次短读要过好几个寄存器,1.5 秒经常还没结束。提前返回会让新驱动
去抢还被占着的口,然后以「需要 24 V」失败。
"""
global _PORT_HOLD
with _PORT_LOCK:
_PORT_HOLD += 1
deadline = time.time() + 6.0
while time.time() < deadline:
with _PORT_LOCK:
busy = _PORT_BUSY
if not busy:
return
time.sleep(0.05)
def hold_serial(pid: int) -> None:
"""后台夹爪脚本还活着时,状态线程不要去开串口。"""
pause_serial()
def _watch() -> None:
while pid > 0:
try:
os.kill(pid, 0)
except OSError:
break
time.sleep(0.2)
resume_serial()
threading.Thread(target=_watch, daemon=True, name="gripper-port-hold").start()
def resume_serial() -> None:
global _PORT_HOLD
with _PORT_LOCK:
_PORT_HOLD = max(0, _PORT_HOLD - 1)
def serial_paused() -> bool:
with _PORT_LOCK:
return _PORT_HOLD > 0
def start() -> None:
global _STARTED
with _LOCK:
if _STARTED:
return
_STARTED = True
threading.Thread(target=_loop, daemon=True, name="gripper-state").start()
def _loop() -> None:
while True:
try:
arms = _arms()
nodes = _nodes()
for arm in arms:
reading, error = _read_one(arm, nodes)
fresh = present(
arm["id"],
rotary=arm["rotary"],
stroke_mm=arm["stroke_mm"],
reading=reading,
error=error,
)
_store(retain_reading(fresh, snapshot(arm["id"])))
except Exception as exc: # noqa: BLE001 —— 读夹爪失败不能拖死界面
for row in _arms():
_store(present(
row["id"], rotary=bool(row.get("rotary")), stroke_mm=float(row.get("stroke_mm") or 20.0),
reading=None, error=str(exc),
))
time.sleep(2.5)
def _store(row: dict) -> None:
with _LOCK:
_CACHE[str(row.get("arm") or "")] = row
def _arms() -> list[dict]:
path = ros2_root() / "config" / "site.yaml"
if not path.is_file():
return []
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
shared = data.get("gripper") if isinstance(data.get("gripper"), dict) else {}
baud = int(shared.get("baud") or 115200)
slave = int(shared.get("slave_id") or 1)
rts = str(shared.get("rs485_rts") or "")
out = []
for row in listed_arms(data):
item = dict(row)
item["baud"] = baud
item["slave"] = slave
item["rts"] = rts
out.append(item)
return out
def _nodes() -> dict[str, bool]:
text = _pgrep("dh_pgea_node|dual_gripper")
return {
"pgea": "dh_pgea_node" in text or "lib/arm_application/dh_pgea" in text,
"dual": "dual_gripper" in text,
}
def _read_one(arm: dict, nodes: dict[str, bool]) -> tuple[dict | None, str]:
if nodes.get("dual") and arm.get("side") in ("left", "right"):
topic = f"/dual_gripper_driver/{arm['side']}/status"
parsed = _parse_status_echo(_echo(topic))
if parsed is not None:
parsed["source"] = f"话题 {topic}"
return parsed, ""
return None, "夹爪节点在跑,但这侧话题没有数据"
if not arm.get("rotary") and nodes.get("pgea"):
parsed = _parse_states_echo(_echo("/gripper/states"))
if parsed is not None:
parsed["source"] = "话题 /gripper/states"
return parsed, ""
return None, "平行爪节点在跑,但 /gripper/states 没有数据"
if not arm.get("port"):
return None, "site.yaml 里这台夹爪没有串口"
if serial_paused():
return None, "控制命令正在用串口"
return _read_serial(arm)
def _read_serial(arm: dict) -> tuple[dict | None, str]:
"""短连串口只读。auto_initialize 必须关掉,否则旋转爪一连上就会找零。"""
global _PORT_BUSY
if serial_paused():
return None, "控制命令正在用串口"
with _PORT_LOCK:
_PORT_BUSY += 1
try:
return _read_serial_open(arm)
finally:
with _PORT_LOCK:
_PORT_BUSY = max(0, _PORT_BUSY - 1)
def _read_serial_open(arm: dict) -> tuple[dict | None, str]:
try:
if arm["rotary"]:
from arm_application.dual_gripper import RotaryGripper, RotaryGripperConfig
grip = RotaryGripper(RotaryGripperConfig(
port=arm["port"],
slave_id=int(arm["slave"]),
baudrate=int(arm["baud"]),
timeout=0.25,
auto_initialize=False,
))
grip.connect()
try:
status = grip.get_status()
finally:
grip.disconnect()
if not status.connected:
return None, status.error_msg or "旋转爪没有应答"
return {
"connected": True,
"initialized": bool(status.initialized),
"permille": float(status.finger_position_per_mille),
"rotation_deg": float(status.rotation_deg),
"force": float(status.force),
"speed": float(status.velocity),
"grip_state": int(status.gripper_state),
"rotation_state": int(status.rotation_state),
"moving": bool(status.is_moving),
"gripped": bool(status.is_gripped),
"error_msg": status.error_msg or "",
"source": f"串口 {arm['port']} 只读",
}, ""
from arm_application.dh_pgea import DhPgeaError, DhPgeaGripper
grip = DhPgeaGripper(
port=arm["port"],
baudrate=int(arm["baud"]),
slave_id=int(arm["slave"]),
stroke_mm=float(arm["stroke_mm"]),
rs485_rts=str(arm["rts"]),
)
grip.open()
try:
state = grip.get_run_states(retries=1)
finally:
grip.close()
return {
"connected": True,
"initialized": bool(state.initialized),
"permille": float(state.position),
"rotation_deg": 0.0,
"force": float(state.target_force),
"speed": None,
"grip_state": int(state.grip_state),
"rotation_state": None,
"moving": int(state.grip_state) == 0,
"gripped": int(state.grip_state) == 2,
"error_msg": "",
"source": f"串口 {arm['port']} 只读",
}, ""
except Exception as exc: # noqa: BLE001 —— 串口被节点占着、没上电,都要变成一句人话
text = str(exc).strip() or exc.__class__.__name__
if "DhPgeaError" in exc.__class__.__name__ or "Permission" in text or "busy" in text.lower():
return None, text
return None, text
def _echo(topic: str) -> str:
try:
proc = subprocess.run(
["timeout", "3", "ros2", "topic", "echo", topic, "--once"],
capture_output=True, text=True, timeout=5,
)
except (OSError, subprocess.TimeoutExpired):
return ""
return proc.stdout
def _parse_status_echo(text: str) -> dict | None:
for chunk in text.split("---"):
if "finger_position_per_mille:" not in chunk and "connected:" not in chunk:
continue
try:
data = yaml.safe_load(chunk)
except yaml.YAMLError:
continue
if not isinstance(data, dict) or "connected" not in data:
continue
return {
"connected": bool(data.get("connected")),
"initialized": bool(data.get("initialized")),
"permille": data.get("finger_position_per_mille"),
"rotation_deg": data.get("rotation_deg"),
"force": data.get("force"),
"speed": data.get("velocity"),
"grip_state": data.get("gripper_state"),
"rotation_state": data.get("rotation_state"),
"moving": bool(data.get("is_moving")),
"gripped": bool(data.get("is_gripped")),
"error_msg": str(data.get("error_msg") or ""),
}
return None
def _parse_states_echo(text: str) -> dict | None:
for chunk in text.split("---"):
if "data:" not in chunk:
continue
try:
data = yaml.safe_load(chunk)
except yaml.YAMLError:
continue
if not isinstance(data, dict):
continue
raw = data.get("data")
if not isinstance(raw, list) or len(raw) < 5:
continue
try:
init, grip, pos, _target, force = (float(v) for v in raw[:5])
except (TypeError, ValueError):
continue
return {
"connected": True,
"initialized": init >= 0.5,
"permille": pos,
"rotation_deg": 0.0,
"force": force,
"speed": None,
"grip_state": int(grip),
"rotation_state": None,
"moving": int(grip) == 0,
"gripped": int(grip) == 2,
"error_msg": "",
}
return None
def _pgrep(pattern: str) -> str:
try:
proc = subprocess.run(
["pgrep", "-af", pattern],
capture_output=True, text=True, timeout=2,
)
except (OSError, subprocess.TimeoutExpired):
return ""
return "\n".join(line for line in proc.stdout.splitlines() if "pgrep" not in line)
def _finite(raw: Any) -> float | None:
try:
value = float(raw)
except (TypeError, ValueError):
return None
if value != value:
return None
return value
def _unit(raw: Any, low: float, high: float) -> float | None:
value = _finite(raw)
if value is None:
return None
return max(low, min(high, value))
@@ -0,0 +1,91 @@
"""手眼页的棋盘预览。只在已有彩色画面上画角点,不打开相机,也不动臂。"""
from __future__ import annotations
import threading
import time
import numpy as np
from arm_application.handeye_lib import BoardConfig, detect_chessboard
def coverage_fraction(corners: np.ndarray, width: int, height: int) -> float:
"""角点外接框占画面的比例。宽或高为 0 时返回 0。"""
if width <= 0 or height <= 0:
return 0.0
pts = np.asarray(corners, dtype=float).reshape(-1, 2)
if pts.size == 0:
return 0.0
area = float(pts[:, 0].max() - pts[:, 0].min()) * float(pts[:, 1].max() - pts[:, 1].min())
return area / float(width * height)
def coverage_words(fraction: float) -> str:
"""30%–70% 是示教时棋盘该占的画面。再小对不准,再大姿态拉不开。"""
if fraction < 0.30:
return "偏小,再靠近一点"
if fraction > 0.70:
return "偏大,再离远一点"
return "大小合适"
def annotate_board(jpeg: bytes, cols: int, rows: int) -> tuple[bytes, str]:
"""返回(可能画了角点的 JPEG,一行中文状态)。检测失败时 JPEG 原样返回。"""
import cv2
if cols <= 0 or rows <= 0:
return jpeg, "配置里没有这块板的内角点"
buf = np.frombuffer(jpeg, dtype=np.uint8)
image = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if image is None:
return jpeg, "这一帧解不出来"
found = detect_chessboard(image, BoardConfig(int(cols), int(rows), 1.0))
if found is None:
return jpeg, "画面里没有这块板"
corners, pattern = found
drawn = image.copy()
cv2.drawChessboardCorners(drawn, pattern, corners, True)
fraction = coverage_fraction(corners, drawn.shape[1], drawn.shape[0])
ok, out = cv2.imencode(".jpg", drawn, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
blob = out.tobytes() if ok else jpeg
percent = int(round(fraction * 100))
return blob, f"棋盘已找到,约占画面 {percent}%。{coverage_words(fraction)}"
class BoardWatch:
"""后台画角点。界面线程只提交最新一帧,检测再慢也不卡住窗口。"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._pending: tuple | None = None
self._result = {"token": 0, "jpeg": b"", "text": ""}
self._stop = threading.Event()
self._thread = threading.Thread(target=self._loop, daemon=True, name="handeye-board")
self._thread.start()
def submit(self, token: int, jpeg: bytes, cols: int, rows: int) -> None:
with self._lock:
if self._result["token"] == token and not self._pending:
return
self._pending = (token, jpeg, int(cols), int(rows))
def latest(self) -> tuple[bytes, str, int]:
with self._lock:
return self._result["jpeg"], self._result["text"], int(self._result["token"])
def _loop(self) -> None:
while not self._stop.is_set():
with self._lock:
job = self._pending
self._pending = None
if job is None:
time.sleep(0.05)
continue
token, jpeg, cols, rows = job
try:
blob, text = annotate_board(jpeg, cols, rows)
except Exception as exc: # noqa: BLE001 —— 检测失败不能拖死预览
blob, text = jpeg, f"棋盘检测失败:{exc}"
with self._lock:
self._result = {"token": token, "jpeg": blob, "text": text}
@@ -0,0 +1,353 @@
"""工位界面用的视触觉成像。
切向力是 SDK 的 ``VSHEAR`` 箭头,热成像是 ``DEPTH`` 的 JET 伪彩。
相机独占,所以只有页面点了「开始成像」才打开,失败把原因交回去,不造图。
"""
from __future__ import annotations
import os
import re
import threading
import time
from pathlib import Path
import numpy as np
_LOCK = threading.Lock()
_STOP = threading.Event()
_THREADS: list[threading.Thread] = []
_ON_SAMPLE = None
_STATE = {
"on": False,
"error": "",
"stamp": 0.0,
"note": "",
"frames": {},
}
_JPEG: dict[str, bytes] = {}
def set_sample_hook(fn) -> None:
global _ON_SAMPLE
_ON_SAMPLE = fn
def state() -> dict:
with _LOCK:
return {
"on": _STATE["on"],
"error": _STATE["error"],
"stamp": _STATE["stamp"],
"note": _STATE["note"],
"frames": dict(_STATE["frames"]),
}
def frame(side: str, kind: str) -> bytes | None:
with _LOCK:
return _JPEG.get(f"{side}_{kind}")
def _videos() -> list[str]:
found = []
by_path = Path("/dev/v4l/by-path")
if not by_path.is_dir():
return found
for link in sorted(by_path.iterdir()):
if not link.name.endswith("-video-index0"):
continue
real = os.path.realpath(link)
base = os.path.basename(real)
sysdev = os.path.realpath(f"/sys/class/video4linux/{base}/device")
dev = ""
for part in reversed(sysdev.split("/")):
if re.fullmatch(r"\d+-\d+(\.\d+)*", part):
dev = part
break
if not dev:
continue
vid = Path(f"/sys/bus/usb/devices/{dev}/idVendor")
pid = Path(f"/sys/bus/usb/devices/{dev}/idProduct")
if (
vid.is_file()
and pid.is_file()
and vid.read_text().strip() == "1bcf"
and pid.read_text().strip() == "2281"
):
found.append(str(link))
return found
def _resolve_cfg(site: dict) -> dict:
tactile = site.get("tactile") if isinstance(site.get("tactile"), dict) else {}
left = str(tactile.get("left_video") or "")
right = str(tactile.get("right_video") or "")
if not Path(left).exists() or not Path(right).exists():
found = _videos()
if len(found) >= 2:
left, right = found[0], found[1]
elif len(found) == 1:
left = right = found[0]
return {
"cfg": str(tactile.get("config_file") or ""),
"backend": str(tactile.get("infer_backend") or "cpu"),
"cuda": bool(tactile.get("cuda", False)),
"isstitch": bool(tactile.get("isstitch", True)),
"baseline": float(tactile.get("baseline_seconds") or 1.5),
"left": left,
"right": right,
}
def _as_bgr(img):
if not isinstance(img, np.ndarray) or img.size == 0:
return None
import cv2
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] >= 3:
return np.ascontiguousarray(img[:, :, :3])
return None
def _flat(arr) -> bool:
if not isinstance(arr, np.ndarray) or arr.size == 0 or arr.ndim < 2:
return True
a = arr.reshape(-1, arr.shape[-1]) if arr.ndim == 3 else arr.reshape(-1)
finite = np.isfinite(a)
if not bool(finite.any()):
return True
vals = a[finite]
return float(np.max(vals) - np.min(vals)) < 1e-6
def _thermal(depth):
import cv2
if depth is None:
return None
a = np.asarray(depth)
if a.size == 0:
return None
if a.ndim == 3 and a.shape[2] == 2:
a = np.linalg.norm(a.astype(np.float32), axis=2)
if a.ndim != 2:
return None
finite = np.isfinite(a)
if not finite.any():
return None
vals = a[finite]
lo, hi = float(vals.min()), float(vals.max())
if hi <= lo:
norm = np.zeros(a.shape, np.uint8)
else:
norm = np.clip((a - lo) / (hi - lo) * 255.0, 0, 255).astype(np.uint8)
return cv2.applyColorMap(norm, cv2.COLORMAP_JET)
def _views(sensor, gray):
import cv2
import orisys
bgr = _as_bgr(gray)
h, w = (bgr.shape[0], bgr.shape[1]) if bgr is not None else (450, 420)
try:
sensor.compute_deformation(decompose=True, check_motion=False)
except Exception:
pass
try:
sensor.compute_contact()
except Exception:
pass
try:
vshear, vnormal, depth = sensor.read_info(
sensor.info.VSHEAR, sensor.info.VNORMAL, sensor.info.DEPTH
)
except Exception:
vshear, vnormal, depth = None, None, None
canvas = np.zeros((h, w, 3), np.uint8)
shear = canvas
if isinstance(vshear, np.ndarray) and vshear.ndim == 3 and vshear.shape[2] >= 2 and vshear.size:
field = np.ascontiguousarray(vshear[:, :, :2], dtype=np.float32)
if field.shape[0] != h or field.shape[1] != w:
field = cv2.resize(field, (w, h))
mag = np.linalg.norm(field, axis=2)
peak = float(np.percentile(mag, 99)) if mag.size else 0.0
arrow_scale = (2.0 * peak / 12.0) if peak > 1e-6 else 1.0
try:
shear = orisys.util.draw_arrows(
canvas,
field,
threshold=peak * 0.15 if peak > 1e-6 else 1.0,
grid_spacing=16,
arrow_scale=max(arrow_scale, 1e-6),
below_threshold_color=(40, 40, 40),
)
except Exception:
shear = canvas
heat = None if _flat(depth) else _thermal(depth)
if heat is None:
heat = _thermal(vnormal)
if not isinstance(heat, np.ndarray) or heat.ndim != 3:
heat = np.zeros((h, w, 3), np.uint8)
elif heat.shape[0] != h or heat.shape[1] != w:
heat = cv2.resize(heat, (w, h))
return shear, heat
def _jpeg(img) -> bytes | None:
import cv2
if not isinstance(img, np.ndarray) or img.size == 0:
return None
ok, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
if not ok:
return None
return buf.tobytes()
def _store(side: str, shear, heat, sample: dict | None) -> None:
frames = {}
blobs = {}
for kind, img in (("shear", shear), ("thermal", heat)):
blob = _jpeg(img)
if blob:
blobs[f"{side}_{kind}"] = blob
frames[f"{side}_{kind}"] = True
with _LOCK:
_JPEG.update(blobs)
_STATE["frames"].update(frames)
_STATE["stamp"] = time.time()
if _STATE["error"].startswith(side):
_STATE["error"] = ""
if sample and _ON_SAMPLE is not None:
_ON_SAMPLE(side, sample)
def _open_sensor(cfg: dict, side: str):
import orisys
return orisys.Sensor(
cfg[side],
cuda=cfg["cuda"],
isstitch=cfg["isstitch"],
verbose=False,
config_name=cfg["cfg"],
backend=cfg["backend"],
)
def _worker(side: str, cfg: dict) -> None:
if not cfg.get(side):
with _LOCK:
_STATE["error"] = f"{side} 没有视频节点"
return
sensor = None
try:
sensor = _open_sensor(cfg, side)
except Exception as exc:
with _LOCK:
prev = _STATE["error"]
_STATE["error"] = (prev + ";" if prev else "") + f"{side} 打开失败:{exc}"
return
buckets = {"n": [], "x": [], "y": []}
deadline = time.perf_counter() + float(cfg["baseline"])
while time.perf_counter() < deadline and not _STOP.is_set():
try:
img = sensor.get_img()
sensor.compute_deformation(check_motion=False)
n, x, y = sensor.read_info(
sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY
)
buckets["n"].append(float(n))
buckets["x"].append(float(x))
buckets["y"].append(float(y))
except Exception:
pass
time.sleep(0.02)
base = {
"n": float(np.mean(buckets["n"])) if buckets["n"] else 0.0,
"x": float(np.mean(buckets["x"])) if buckets["x"] else 0.0,
"y": float(np.mean(buckets["y"])) if buckets["y"] else 0.0,
}
with _LOCK:
base_note = "曲线已减去打开后短暂基线,单位仍是形变读数。"
if "右指" in str(_STATE["note"]):
_STATE["note"] = _STATE["note"] + " " + base_note
else:
_STATE["note"] = base_note
last_sample = 0.0
while not _STOP.is_set():
try:
gray = sensor.get_img()
except Exception:
gray = None
if gray is None:
time.sleep(0.05)
continue
shear, heat = _views(sensor, gray)
sample = None
now = time.time()
if now - last_sample >= 0.1:
try:
n, x, y = sensor.read_info(
sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY
)
sample = {
"t": now,
"fnormal": float(n) - base["n"],
"fshearx": float(x) - base["x"],
"fsheary": float(y) - base["y"],
}
last_sample = now
except Exception:
sample = None
_store(side, shear, heat, sample)
time.sleep(0.04)
try:
sensor.disconnect()
except Exception:
pass
def start(site: dict) -> str:
global _THREADS
with _LOCK:
if _STATE["on"]:
return "成像已经开着"
_STATE["on"] = True
_STATE["error"] = ""
_STATE["frames"] = {}
_STOP.clear()
cfg = _resolve_cfg(site)
if not cfg["left"] and not cfg["right"]:
with _LOCK:
_STATE["on"] = False
_STATE["error"] = "没有找到 Orisys 相机"
return _STATE["error"]
sides = ["left"]
if cfg.get("right") and cfg.get("right") != cfg.get("left"):
sides.append("right")
elif not cfg.get("right") or cfg.get("right") == cfg.get("left"):
with _LOCK:
_STATE["note"] = "只找到一路 Orisys,右指没有画面。插上后重新开始成像。"
_THREADS = [
threading.Thread(target=_worker, args=(side, cfg), daemon=True, name=f"tac-{side}")
for side in sides
]
for thread in _THREADS:
thread.start()
return "正在打开两只视触觉。切向力和热成像出来之前会先采一小段基线。"
def stop() -> str:
_STOP.set()
for thread in list(_THREADS):
thread.join(timeout=2.0)
with _LOCK:
_STATE["on"] = False
_STATE["frames"] = {}
_JPEG.clear()
return "已停止视触觉成像"
@@ -0,0 +1,297 @@
"""软拖拽示教:开拖拽、记下当前关节角、再回到该点。
点位写在 ``handeye/tmp/<臂号>/operator_teach.yaml``。关节角单位是弧度,
直接存桥接返回的 7 个数,不另做姿态换算。
软拖拽占用 RDK,和 ``run_real.sh`` 不能同时连同一台臂。
"""
from __future__ import annotations
import re
import subprocess
import threading
from datetime import datetime, timezone
from pathlib import Path
import yaml
_LOCK = threading.Lock()
_BRIDGE = None
_SN = ""
_YIELDED = False
_ARM_CONTROLLER = "rizon_arm_controller"
def _file(arm: str) -> Path:
from arm_application.operator_console import ROOT
from arm_application.site_io import listed_arms, load_site_dict
known = {row["id"] for row in listed_arms(load_site_dict())}
if arm not in known:
raise ValueError(f"臂号 {arm} 不在 site.yaml 里")
return ROOT / "handeye" / "tmp" / arm / "operator_teach.yaml"
def _bridge_for(sn: str):
global _BRIDGE, _SN
sn = str(sn or "").strip()
if not sn:
raise RuntimeError("site.yaml 里这台臂没有序列号,拒绝连接")
if _BRIDGE is not None and _SN == sn and _BRIDGE._proc.poll() is None:
return _BRIDGE
close()
from arm_application.handeye_teach import RdkBridge
from arm_application.operator_arm_state import release_for_control, resume_after_control
release_for_control()
try:
_BRIDGE = RdkBridge(sn)
_SN = sn
finally:
resume_after_control()
return _BRIDGE
def close() -> None:
global _BRIDGE, _SN
bridge = _BRIDGE
_BRIDGE = None
_SN = ""
if bridge is None:
return
try:
bridge.close()
except Exception:
pass
def _switch_arm_controller(activate: bool) -> str:
"""停或恢复关节轨迹控制器。硬件还在,只是不再下发关节位置。
控制栈在 ``NRT_JOINT_POSITION`` 里持续 ``SendJointPosition``,
模式一旦被改走,写循环两秒内又切回去。软拖拽要低阻抗,必须先停这条控制器。
"""
if activate:
payload = (
"{activate_controllers: ['%s'], deactivate_controllers: [], "
"strictness: 2, activate_asap: true, timeout: {sec: 3, nanosec: 0}}"
% _ARM_CONTROLLER
)
else:
payload = (
"{activate_controllers: [], deactivate_controllers: ['%s'], "
"strictness: 1, activate_asap: true, timeout: {sec: 3, nanosec: 0}}"
% _ARM_CONTROLLER
)
try:
proc = subprocess.run(
[
"ros2", "service", "call",
"/controller_manager/switch_controller",
"controller_manager_msgs/srv/SwitchController",
payload,
],
capture_output=True,
text=True,
timeout=15,
)
except FileNotFoundError:
return "找不到 ros2,停不了关节轨迹控制器"
except subprocess.TimeoutExpired:
return "关节轨迹控制器没回来" if activate else "停关节轨迹控制器超时"
text = ((proc.stdout or "") + (proc.stderr or "")).strip()
if proc.returncode == 0 and re.search(r"ok\s*[:=]\s*true", text, re.IGNORECASE):
return ""
tail = text.splitlines()[-1] if text else f"退出码 {proc.returncode}"
verb = "恢复" if activate else "停下"
return f"没能{verb}关节轨迹控制器:{tail}"
def _yield_position_stream() -> str:
"""控制栈没起来就什么都不做。起来了就先把位置控制器让开。"""
global _YIELDED
from arm_application.operator_arm_state import stack_running
if not stack_running():
return ""
err = _switch_arm_controller(False)
if err:
return err + "。位置环还在推,软拖拽切不过去。"
_YIELDED = True
return ""
def _restore_position_stream() -> str:
global _YIELDED
if not _YIELDED:
return ""
err = _switch_arm_controller(True)
_YIELDED = False
return err
def drag_on(sn: str) -> dict:
with _LOCK:
try:
yielded = _yield_position_stream()
if yielded:
return {"ok": False, "message": yielded}
bridge = _bridge_for(sn)
result = bridge.drag_on(mode="soft")
except Exception as exc:
restore = _restore_position_stream()
message = str(exc)
if restore:
message = f"{message};{restore}"
return {"ok": False, "message": message}
if not result.get("ok"):
restore = _restore_position_stream()
message = str(result.get("error") or result)
if restore:
message = f"{message};{restore}"
return {"ok": False, "message": message}
note = "软拖拽已开启。轻推机械臂,再记点。"
if _YIELDED:
note += "关节轨迹控制器已停下,关掉拖拽后会回来。"
return {"ok": True, "message": note}
def drag_off(sn: str) -> dict:
with _LOCK:
try:
bridge = _bridge_for(sn)
result = bridge.drag_off()
except Exception as exc:
restore = _restore_position_stream()
message = str(exc)
if restore:
message = f"{message};{restore}"
return {"ok": False, "message": message}
if not result.get("ok"):
return {"ok": False, "message": str(result.get("error") or result)}
restore = _restore_position_stream()
if restore:
return {"ok": False, "message": f"软拖拽已关闭,但{restore}"}
return {"ok": True, "message": "软拖拽已关闭,臂保持当前位置。"}
def list_points(arm: str) -> dict:
try:
path = _file(arm)
except ValueError as exc:
return {"ok": False, "message": str(exc), "points": []}
if not path.is_file():
return {"ok": True, "points": [], "file": str(path)}
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as exc:
return {"ok": False, "message": f"示教文件读不了:{exc}", "points": [], "file": str(path)}
points = []
for row in data.get("points") or []:
if not isinstance(row, dict):
continue
joints = row.get("joints")
if not isinstance(joints, list) or len(joints) < 7:
continue
try:
values = [float(v) for v in joints[:7]]
except (TypeError, ValueError):
continue
if any(v != v for v in values):
continue
points.append({
"name": str(row.get("name") or "").strip(),
"utc": str(row.get("utc") or ""),
"joints": values,
})
points = [row for row in points if row["name"]]
return {"ok": True, "points": points, "file": str(path)}
def _write(arm: str, points: list[dict]) -> Path:
path = _file(arm)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml.safe_dump({"points": points}, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
return path
def record(sn: str, arm: str, name: str) -> dict:
title = str(name or "").strip()
if not title or "/" in title or "\\" in title or title in (".", ".."):
return {"ok": False, "message": "点名不能为空,也不能带路径"}
with _LOCK:
try:
bridge = _bridge_for(sn)
result = bridge._ask({"cmd": "joints"}, timeout=2.0)
except Exception as exc:
return {"ok": False, "message": str(exc)}
if not result.get("ok"):
return {"ok": False, "message": str(result.get("error") or "没有读到关节,点位没写下去")}
raw = result.get("q") or []
if len(raw) < 7:
return {"ok": False, "message": "关节数不够 7 个,点位没写下去"}
try:
values = [round(float(v), 6) for v in raw[:7]]
except (TypeError, ValueError):
return {"ok": False, "message": "关节角不是数字,点位没写下去"}
if any(v != v for v in values):
return {"ok": False, "message": "关节角不是有限值,点位没写下去"}
listed = list_points(arm)
if not listed.get("ok"):
return listed
points = [row for row in listed["points"] if row["name"] != title]
points.append({
"name": title,
"utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"joints": values,
})
path = _write(arm, points)
text = " ".join(f"{v:.4f}" for v in values)
return {"ok": True, "message": f"已记录 {title}", "detail": f"{path}\n关节 rad:{text}", "points": points}
def delete_point(arm: str, name: str) -> dict:
title = str(name or "").strip()
listed = list_points(arm)
if not listed.get("ok"):
return listed
kept = [row for row in listed["points"] if row["name"] != title]
if len(kept) == len(listed["points"]):
return {"ok": False, "message": f"没有名为 {title} 的点"}
path = _write(arm, kept)
return {"ok": True, "message": f"已删除 {title}", "detail": str(path), "points": kept}
def goto(sn: str, arm: str, name: str, speed: float) -> dict:
title = str(name or "").strip()
listed = list_points(arm)
if not listed.get("ok"):
return listed
hit = next((row for row in listed["points"] if row["name"] == title), None)
if hit is None:
return {"ok": False, "message": f"没有名为 {title} 的点"}
try:
speed_f = float(speed)
except (TypeError, ValueError):
return {"ok": False, "message": "关节速度不是数字"}
if not 0.05 <= speed_f <= 0.35:
return {"ok": False, "message": "关节速度只接受 0.05–0.35 rad/s"}
with _LOCK:
try:
bridge = _bridge_for(sn)
bridge.drag_off()
result = bridge.move_joints(hit["joints"], speed_f)
except Exception as exc:
return {"ok": False, "message": str(exc)}
if not result.get("ok"):
return {"ok": False, "message": str(result.get("error") or result)}
text = " ".join(f"{float(v):.4f}" for v in hit["joints"])
return {
"ok": True,
"message": f"已回到 {title}",
"detail": f"速度 {speed_f:.2f} rad/s\n关节 rad:{text}",
}
@@ -0,0 +1,836 @@
"""D405 主跟踪丢了之后,用顶置 D435 **拍一张** 把工件 / k0 定到基座系。
为什么单独成模块
----------------
一阶段平时走腕部 D405(眼在手上)。画面里没有 conf≥门槛的目标时,
不能让伺服继续追 0.12 的假框,也不能把顶视外参再乘一遍法兰
(那会让一次性定位差几十厘米还看起来合理)。
判定、取 k0、反投影、臂是否静止 —— 全是纯函数,测试不启 ROS。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, Optional
import numpy as np
from arm_application.handeye_lib import cam_point_to_base
from arm_application.path_profile import ( # 伺服和测试从本模块再导出,不另抄一份
D435_FULL_ACC_M,
D435_FULL_CRUISE_MPS,
D435_FULL_DEC_M,
D435_MID_HOP_MAX_M,
D435_MID_LEFTOVER_M,
D435_MOVE_ACC_MPS2,
D435_MOVE_SPEED_SCALE,
D435_NEAR_CRUISE_MPS,
D435_NEAR_HOP_MAX_M,
D435_NEAR_LEFTOVER_M,
D435_VENDOR_MOVEL_ACC_MPS2,
D435_VENDOR_MOVEL_VEL_MPS,
D435_VERT_TILT_RAD,
D435_SETTLE_S,
FROZEN_J_HOP_MAX_M,
build_arc_xy_move,
flange_down_w,
flange_tilt_rad,
hybrid_cruise_vxy,
oneshot_hop_limits,
smooth_joint_schedule,
trap_acc_for_hop,
translate_hold_ori_waypoints,
)
# 上一跳实际少走这么多,就认定冻结 J 在当前姿态走不动,不许按同一剩差再刷。
D435_MIN_PROGRESS_FRAC = 0.40
# SwitchMode 返回不等于已经进 primitive。连试仍失败必须让一阶段立刻中止,
# 不许空等 150s 装成"还在粗定位"(2026-10-16 17:27)。
D435_MOVEL_RETRY = 3
D435_MOVEL_RETRY_S = 0.3
D435_MOVEL_GAVE_UP = "厂家 MoveL 连试 3 次没走成"
def best_det_conf(dets: Optional[Iterable]) -> Optional[float]:
"""画面内最高检测置信度。空列表 / None → ``None``(不是 0:0 是"看到了但很差")。"""
if not dets:
return None
vals = [float(getattr(d, "conf", float("nan"))) for d in dets]
vals = [c for c in vals if np.isfinite(c)]
if not vals:
return None
return max(vals)
def z_locked_qos():
"""``visual_servo/z_locked`` 的 QoS。发布端和订阅端必须用这一份。
感知比伺服晚启动。默认 volatile 会丢掉伺服启动时已经发出的「已到锁高」,
感知就停在初值 False,D435 永远不拍。
"""
from rclpy.qos import ( # noqa: PLC0415 — 纯函数测试不需要在导入时拉起 ROS
DurabilityPolicy,
HistoryPolicy,
QoSProfile,
ReliabilityPolicy,
)
return QoSProfile(
history=HistoryPolicy.KEEP_LAST,
depth=1,
reliability=ReliabilityPolicy.RELIABLE,
durability=DurabilityPolicy.TRANSIENT_LOCAL,
)
def at_lock_height(z_m: float, lock_z_m: float, gate_m: float) -> bool:
"""法兰是否在锁高带内。
低于锁高**不算**到位。上一轮观测下降停在 0.26 m 时,若把「不高于锁高」
当成到位,XY 会在低位开跑,D435 又因高度不对拒绝拍照,两边都不再动。
``lock_z_m <= 0`` 表示没启用锁高。
"""
lock = float(lock_z_m)
if lock <= 0.0:
return True
gate = max(0.02, float(gate_m))
return abs(float(z_m) - lock) <= gate
def lock_z_velocity(z_m: float, lock_z_m: float, *, kp: float, v_lim: float) -> float:
"""回到锁高的竖直速度 [m/s],基座 +Z 朝上。
低于锁高为正(上抬),高于为负(下降)。4 mm 内视为到位。
不许在低于锁高时把速度写成 0:那会和「没到锁高不拍 D435」卡死。
"""
lock = float(lock_z_m)
if lock <= 0.0:
return 0.0
ez = lock - float(z_m)
if abs(ez) < 0.004:
return 0.0
lim = abs(float(v_lim))
return float(np.clip(float(kp) * ez, -lim, lim))
def hold_translation_vz(z_m: float, z_hold_m: float, *, kp: float, v_lim: float) -> float:
"""平移时把法兰钉在**这一段开始时的高度**。
只写 v_z=0 不够:大范围平移加姿态校正时,阻尼求逆会把竖直速度吃掉,
2026-10-09 真机从 0.327 m 漂到 0.307 m 又抬到 0.337 m。这里用开始那一
帧的高度当设定,4 mm 内不动,避免来回抖。不追 site 里的锁高数字,
也不因此停掉 XY。下臂仍只在二阶段。
"""
ez = float(z_hold_m) - float(z_m)
if abs(ez) < 0.004:
return 0.0
lim = abs(float(v_lim))
return float(np.clip(float(kp) * ez, -lim, lim))
# D405 连续这么久没有高置信度目标,才让待命的 D435 拍一张。
# 单帧漏检就拍,会在平移半路把目标换掉。
D405_MISS_BEFORE_D435_S = 0.5
# 拍了但伺服没 ``d435_commanding``(工作盒拒收等):超过这时限就允许再拍。
# 不清的话 ``_oneshot_shot`` 会永远真,臂停着、感知还以为在赶路。
ONESHOT_ACCEPT_TIMEOUT_S = 0.8
def oneshot_shot_timed_out(
*,
shot: bool,
commanding: bool,
age_s: float,
timeout_s: float = ONESHOT_ACCEPT_TIMEOUT_S,
) -> bool:
"""拍过一张、伺服却没接住 → True。已经在赶路不许清。"""
if not bool(shot) or bool(commanding):
return False
return float(age_s) >= float(timeout_s)
def d405_commands_arm(
*,
using_d435: bool,
arrived: bool,
settled: bool,
move_only: bool = False,
) -> bool:
"""D405 这一帧的点能不能下发给左臂。
推理从启动就开着。没有高置信度目标、或 D435 厂家 MoveL 还没结束、
法兰还没连续停稳时,这帧**不下发**(输出截断,不许拿去控臂)。
厂家移动结束并且法兰停稳(``settled``,时长是 ``d435_handback_sec``)
之后才放行,伺服用它追工件框的矩形中心。框心对准停稳后才进精拍下臂。
``move_only``:只测 D435 位移,D405 点一律不下发。
"""
if move_only:
return False
if using_d435:
return bool(arrived) and bool(settled)
return True
def retract_goal_z(z_now: float, z_target: float) -> float:
"""收臂目标 Z:已经高于目标就钉在当前高度,不许为了追 5 mm 往下补。
2026-10-16:贴面 MoveL 之后 JTC 还握着悬停关节,收臂 hop1 计划 +5 mm
实际抬了 18–25 mm;hop2 把「抬过头」当成误差 ``Δz=−13 mm`` 往回压,
看起来就是进孔再弹出。抬过了 = 成功。
"""
return max(float(z_now), float(z_target))
def oneshot_needs_verticalize(
tilt_rad: float,
leftover_m: float,
arrive_m: float,
*,
tilt_lim: float = D435_VERT_TILT_RAD,
already_done: bool = False,
) -> bool:
"""XY 已经进到位门槛、倾角还大,才单独收垂直。不许和平移捆在一起。"""
if already_done:
return False
if float(leftover_m) > float(arrive_m):
return False
return float(tilt_rad) > float(tilt_lim)
def oneshot_next_hop_m(
dist_m: float, hop_max_m: float = FROZEN_J_HOP_MAX_M
) -> float:
"""这一跳最多走 ``hop_max_m``。冻结 J 一次规划 400 mm 会少走一大截。"""
return min(max(0.0, float(dist_m)), float(hop_max_m))
def oneshot_cartesian_dp(
p_ee,
goal_xy,
lock_z: float,
*,
gate_m: float,
hop_max_m: Optional[float] = None,
leftover_xy_m: Optional[float] = None,
):
"""这一跳的笛卡尔位移。没到锁高只收 Z;远处 XY 带锁高;近处只走 XY。
工件上方最后一跳若再把 7 mm 的 Z 和 60 mm 的 XY 捆在一起,冻结 J 会把腕部带偏。
远处仍带 ``ee_z_m``,避免长跳把高度抬出锁高带。
"""
p = np.asarray(p_ee, dtype=float).reshape(3)
z_lock = float(lock_z)
leftover = (
float(leftover_xy_m)
if leftover_xy_m is not None
else float(
np.hypot(float(goal_xy[0]) - float(p[0]), float(goal_xy[1]) - float(p[1]))
)
)
hop_cap = (
float(hop_max_m)
if hop_max_m is not None
else oneshot_hop_limits(leftover)[0]
)
if not at_lock_height(float(p[2]), z_lock, float(gate_m)):
dz = z_lock - float(p[2])
mag = oneshot_next_hop_m(abs(dz), hop_cap)
if mag < 1e-6:
return np.zeros(3, dtype=float)
return np.array([0.0, 0.0, float(np.sign(dz)) * mag], dtype=float)
dz = 0.0 if leftover <= D435_NEAR_LEFTOVER_M else (z_lock - float(p[2]))
dp = np.array(
[
float(goal_xy[0]) - float(p[0]),
float(goal_xy[1]) - float(p[1]),
dz,
],
dtype=float,
)
n = float(np.linalg.norm(dp))
hop = oneshot_next_hop_m(n, hop_cap)
if n > hop > 1e-9:
dp = dp * (hop / n)
return dp
def oneshot_full_segments(
p_ee,
goal_xy,
lock_z: float,
*,
gate_m: float,
):
"""整段一次走完的笛卡尔分段。先收齐锁高,再走全部 XY,不切 80/15 mm。
近处(剩差 ≤80 mm)仍然只走 XY,不混 Z。收垂直不在这里。
返回 ``[(dp, tag), ...]``,``tag`` 是 ``lift_z`` 或 ``xy``。
"""
p = np.asarray(p_ee, dtype=float).reshape(3).copy()
z_lock = float(lock_z)
goal = np.asarray(goal_xy, dtype=float).reshape(2)
out = []
if not at_lock_height(float(p[2]), z_lock, float(gate_m)):
dz = z_lock - float(p[2])
if abs(dz) >= 1e-6:
out.append((np.array([0.0, 0.0, dz], dtype=float), "lift_z"))
p[2] = z_lock
leftover = float(np.hypot(float(goal[0]) - float(p[0]), float(goal[1]) - float(p[1])))
dz = 0.0 if leftover <= D435_NEAR_LEFTOVER_M else (z_lock - float(p[2]))
dp = np.array(
[float(goal[0]) - float(p[0]), float(goal[1]) - float(p[1]), dz],
dtype=float,
)
if float(np.linalg.norm(dp)) >= 1e-6:
out.append((dp, "xy"))
return out
def oneshot_concat_hold_ori(q0, J, segments, *, R=None):
"""把分段 ``dp`` 用同一份冻结 J 串成一条关节折线。中途不回零速。"""
q = np.asarray(q0, dtype=float).copy()
wps = [q.copy()]
planned = 0.0
asked = 0.0
tags = []
for dp, tag in segments:
hop = float(np.linalg.norm(np.asarray(dp, dtype=float).reshape(3)))
if hop < 1e-6:
continue
asked += hop
part, remain = translate_hold_ori_waypoints(
q, J, dp, R=R, verticalize=False
)
if len(part) < 2:
break
wps.extend(part[1:])
q = wps[-1]
planned += hop - float(remain)
tags.append(str(tag))
tag = "full"
if tags == ["lift_z"]:
tag = "lift_z"
elif "lift_z" in tags and "xy" in tags:
tag = "full"
elif tags == ["xy"]:
tag = "full"
return wps, planned, asked, tag
def oneshot_full_cruise_mps(leftover_m: float, far_cruise_mps: float) -> float:
"""整段巡航:工件上方 45 mm/s;远处封顶 ``D435_FULL_CRUISE_MPS``,不许再弹到远处 hop 上限。"""
far = max(1e-4, float(far_cruise_mps))
if float(leftover_m) <= D435_NEAR_LEFTOVER_M:
return min(D435_NEAR_CRUISE_MPS, far)
return min(D435_FULL_CRUISE_MPS, far)
def oneshot_may_send_another(
leftover_m: float,
last_dist_m: Optional[float],
*,
arrive_m: float,
hop_max_m: float = FROZEN_J_HOP_MAX_M,
min_progress_frac: float = D435_MIN_PROGRESS_FRAC,
) -> bool:
"""上一跳走完后还要不要再发一条。
剩差已经进到位门槛 → 钉住。
上一跳几乎没缩短剩差 → 不许按同一距离再刷(真机 28 mm 死循环)。
"""
if float(leftover_m) <= float(arrive_m):
return False
if last_dist_m is None:
return True
progressed = float(last_dist_m) - float(leftover_m)
planned = min(float(last_dist_m), float(hop_max_m))
if progressed < float(min_progress_frac) * max(planned, 1e-6):
return False
return True
def oneshot_should_submit(
*,
handoff: bool,
low_for_s: float,
already_shot: bool,
wait_s: float = D405_MISS_BEFORE_D435_S,
) -> bool:
"""腕部连续 ``wait_s`` 秒没有高置信度目标、这一轮还没拍成,才让 D435 拍一张。
高度不在这里再判一次。进一阶段之前已经抬到锁高;再等伺服报「已到锁高」
会和「没到锁高就只收 Z、不平移」卡死。拍成一张之后不许连拍,顶视不是第二套跟踪。
"""
return (
bool(handoff)
and (not already_shot)
and float(low_for_s) >= float(wait_s)
)
def select_oneshot_det(dets: Optional[Iterable], min_conf: float):
"""顶视一次性:只收 conf≥门槛的框,取最高分。没有就 None,不许降级。"""
if not dets:
return None
ok = [
d
for d in dets
if np.isfinite(float(getattr(d, "conf", float("nan"))))
and float(d.conf) >= float(min_conf)
]
if not ok:
return None
return max(ok, key=lambda d: float(d.conf))
def depth_raw_to_m(raw: Optional[np.ndarray], encoding: str) -> Optional[np.ndarray]:
"""深度图 → 米。16UC1 常见是毫米;用中位数判量纲,不许默认当米。"""
if raw is None:
return None
enc = (encoding or "").lower()
arr = np.asarray(raw)
if arr.size == 0:
return None
if enc in ("16uc1", "mono16"):
pos = arr[arr > 0]
if pos.size < 8:
return None
med = float(np.median(pos))
scale = 0.001 if 20.0 <= med <= 8000.0 else (1.0 if med < 20 else 0.0001)
out = arr.astype(float) * scale
out[arr <= 0] = np.nan
return out
if enc == "32fc1":
return arr.astype(float)
return None
def map_uv_to_depth(
u: float, v: float, color_wh: tuple[int, int], depth_wh: tuple[int, int]
) -> tuple[float, float]:
"""彩色图像素 → 对齐深度图像素。分辨率相同则原样返回。"""
cw, ch = int(color_wh[0]), int(color_wh[1])
dw, dh = int(depth_wh[0]), int(depth_wh[1])
if cw <= 0 or ch <= 0 or (cw == dw and ch == dh):
return float(u), float(v)
return float(u) * (dw / float(cw)), float(v) * (dh / float(ch))
def wrist_needs_handoff(best_conf: Optional[float], threshold: float) -> bool:
"""腕部这一帧有没有 conf≥门槛的目标。没有 → 该切 D435。
``None`` 当没有检测。门槛必须显式传入,不许在这里写死再抄一份到 site.yaml。
"""
if best_conf is None:
return True
try:
c = float(best_conf)
except (TypeError, ValueError):
return True
if not np.isfinite(c):
return True
return c < float(threshold)
def k0_uv_from_det(
det,
*,
kpt_index: int = 0,
kpt_min_conf: float = 0.30,
) -> Optional[tuple[float, float]]:
"""从 pose 检测取 k0 像素。k0 不可信就返回 None(不许拿框心冒充 k0)。"""
kpts = getattr(det, "kpts", None)
if kpts is None:
return None
arr = np.asarray(kpts, dtype=float)
if arr.ndim != 2 or arr.shape[0] <= int(kpt_index) or arr.shape[1] < 3:
return None
u, v, kc = (float(x) for x in arr[int(kpt_index)][:3])
if not (np.isfinite(u) and np.isfinite(v) and np.isfinite(kc)):
return None
if kc < float(kpt_min_conf):
return None
return u, v
def workpiece_uv_from_det(
det,
*,
kpt_index: int = 0,
kpt_min_conf: float = 0.30,
) -> tuple[float, float, bool]:
"""优先 k0;没有可信 k0 时退回框心,并标明 ``is_k0=False``。
顶视经常看不到腕部视角训出来的 k0。退回框心仍能把臂迁到工件上方,
让 D405 重新看见 k0 —— 比假装"已经是 k0"好。调用方必须看第三个返回值。
"""
uv = k0_uv_from_det(det, kpt_index=kpt_index, kpt_min_conf=kpt_min_conf)
if uv is not None:
return uv[0], uv[1], True
return float(det.cx), float(det.cy), False
def depth_at_m(
depth_m: np.ndarray,
u: float,
v: float,
*,
win: int = 3,
) -> Optional[float]:
"""窗口中位数深度 [m]。无效则 None,不许拿 0 去反投影。"""
if depth_m is None or depth_m.size == 0:
return None
h, w = int(depth_m.shape[0]), int(depth_m.shape[1])
ui, vi = int(round(u)), int(round(v))
r = max(1, int(win))
x0, x1 = max(0, ui - r), min(w, ui + r + 1)
y0, y1 = max(0, vi - r), min(h, vi + r + 1)
if x1 <= x0 or y1 <= y0:
return None
patch = np.asarray(depth_m[y0:y1, x0:x1], dtype=float).reshape(-1)
patch = patch[np.isfinite(patch) & (patch > 1e-4)]
if patch.size == 0:
return None
return float(np.median(patch))
def pixel_on_base_plane(
u: float,
v: float,
fx: float,
fy: float,
cx: float,
cy: float,
T_cam_to_base: np.ndarray,
plane_z_m: float,
) -> Optional[np.ndarray]:
"""彩色像素射线和基座水平面 ``z=plane_z_m`` 的交点 [m]。不用深度图。
相机系方向是 ``((u-cx)/fx, (v-cy)/fy, 1)``,乘外参旋转到基座。
交点在相机后面,或射线几乎贴着平面,返回 None。
"""
T = np.asarray(T_cam_to_base, dtype=float).reshape(4, 4)
direction = np.array(
[(float(u) - float(cx)) / float(fx), (float(v) - float(cy)) / float(fy), 1.0],
dtype=float,
)
d_base = T[:3, :3] @ direction
if abs(float(d_base[2])) < 1e-9:
return None
scale = (float(plane_z_m) - float(T[2, 3])) / float(d_base[2])
if not np.isfinite(scale) or scale <= 0.0:
return None
point = T[:3, 3] + scale * d_base
if not np.all(np.isfinite(point)):
return None
return np.asarray(point, dtype=float).reshape(3)
def cam_xy_bias_axes(T_cam_to_base: np.ndarray):
"""基座水平面上两个单位向量:``(视线前方, 画面右)``。
视线 = 相机 +Z 投到基座 XY(前倾 30° 时的交平面敏感轴);
画面右 = 相机 +X 投到基座 XY(立体基线 / 图像 u 方向)。
水平投影退化时返回 None,不许拿 (0,0) 当轴。
"""
T = np.asarray(T_cam_to_base, dtype=float).reshape(4, 4)
look = T[:3, :3] @ np.array([0.0, 0.0, 1.0], dtype=float)
right = T[:3, :3] @ np.array([1.0, 0.0, 0.0], dtype=float)
look_xy = np.asarray(look[:2], dtype=float)
right_xy = np.asarray(right[:2], dtype=float)
n_l = float(np.linalg.norm(look_xy))
n_r = float(np.linalg.norm(right_xy))
if n_l < 1e-6 or n_r < 1e-6:
return None
return look_xy / n_l, right_xy / n_r
# 单次精拍这一步必须能走完「框心 → k1」。两孔间距约 60mm,半距 30mm。
# 多走多拍的 12mm 封顶在这里会把一半孔距留下(2026-10-13 真机:指令 29mm 只走 12mm)。
ONESHOT_ALIGN_CAP_M = 0.040
# 下降后补 XY:一跳上限。2026-10-13 两跳合计 4.3mm 把近距检测带崩。
ONCE_TRIM_MAX_STEP_M = 0.003
ONCE_TRIM_REFUSE_M = 0.008
HOLE_PAIR_TRIM_MIN_M = 0.050
HOLE_PAIR_TRIM_MAX_M = 0.070
# 悬停 → 观察高(约 15mm)单步上限。左臂间隙约 58mm,20mm × 3 步走完。
# 观察高 → 贴面不再走这段,改斜线一步(XY 补偿 + 剩余 Z)。
ONCE_APPROACH_STEP_M = 0.020
# 悬停已对上时 ΔXY 必须为 0。site ``once_ff_look_per_m`` 同源。
# 2026-10-14 下午:框心改平面交点后两条链路下臂都仍 +1.3mm X 过补插不进。
# k=0.018 是深度框心时代的对冲,不是下降几何。笛卡尔 MoveL 只降 Z。
ONCE_FF_LOOK_PER_M = 0.0
ONCE_FF_MOVEL_VEL_MPS = 0.05
def once_look_ff_xy(look_xy, descent_m: float, k: float):
"""视线前馈 ΔXY [m]。k≤0 是笛卡尔锁 XY:返回 (0,0),不是 None。
k>0 时沿 -look 走 k×下降。下降≤0 / 视线退化 → None。
"""
if float(descent_m) <= 1e-9:
return None
if float(k) <= 1e-9:
return np.array([0.0, 0.0], dtype=float)
look = np.asarray(look_xy, dtype=float).reshape(2)
n = float(np.linalg.norm(look))
if n < 1e-6 or not np.all(np.isfinite(look)):
return None
look = look / n
dxy = -look * float(k) * float(descent_m)
if not np.all(np.isfinite(dxy)):
return None
return dxy
def once_ff_tcp_xyz(
flange_xy,
tip_off_xy,
look_xy,
descent_m: float,
k: float,
surface_z_m: float,
gap_m: float,
):
"""规尖(厂家 MoveL 的 TCP)目标 [m]。Z 是面 + 间隙,不是法兰。"""
dxy = once_look_ff_xy(look_xy, descent_m, k)
if dxy is None:
return None
off = np.asarray(tip_off_xy, dtype=float).reshape(2)
fl = np.asarray(flange_xy, dtype=float).reshape(2)
if not np.all(np.isfinite(off)) or not np.all(np.isfinite(fl)):
return None
tcp = np.array(
[
float(fl[0]) + float(off[0]) + float(dxy[0]),
float(fl[1]) + float(off[1]) + float(dxy[1]),
float(surface_z_m) + float(gap_m),
],
dtype=float,
)
if not np.all(np.isfinite(tcp)):
return None
return tcp, dxy
def d435_movel_from_tcp(tcp_xyz, flange_xy, k0_xy):
"""D435 粗定位 MoveL 目标 [m]:当前厂家 TCP + (K0−法兰) 的 ROS XY。
keep_z 由硬件用当前 TCP 的 Z 覆盖。ROS 绝对 K0 不能当 WORLD 目标
(2026-10-14 贴面 MoveL 已踩过)。缺数 / 非有限 → None。
"""
try:
t = np.asarray(tcp_xyz, dtype=float).reshape(3)
fl = np.asarray(flange_xy, dtype=float).reshape(2)
k0 = np.asarray(k0_xy, dtype=float).reshape(2)
except (TypeError, ValueError):
return None
if t.size != 3 or fl.size != 2 or k0.size != 2:
return None
if not np.all(np.isfinite(t)) or not np.all(np.isfinite(fl)) or not np.all(np.isfinite(k0)):
return None
out = np.array(
[float(t[0]) + float(k0[0]) - float(fl[0]),
float(t[1]) + float(k0[1]) - float(fl[1]),
float(t[2])],
dtype=float,
)
if not np.all(np.isfinite(out)):
return None
return out
def once_ff_movel_from_tcp(tcp_xyz, dxy, descent_m: float):
"""厂家 MoveL 目标 = 当前 Studio TCP + (ΔXY, −下降) [m]。
2026-10-14:把 ROS 规尖绝对坐标发给 MoveL,k=0.018 与 0.035 都落到
WORLD (0.595, −0.468, 0.024),法兰实走约 (+9, −8)mm。WORLD 原点与
ROS 基座不是同一点,绝对坐标不能用。相对位移才跟 k 有关。
"""
if float(descent_m) <= 1e-9:
return None
t = np.asarray(tcp_xyz, dtype=float).reshape(3)
d = np.asarray(dxy, dtype=float).reshape(2)
if t.size != 3 or d.size != 2:
return None
if not np.all(np.isfinite(t)) or not np.all(np.isfinite(d)):
return None
out = np.array(
[float(t[0]) + float(d[0]), float(t[1]) + float(d[1]), float(t[2]) - float(descent_m)],
dtype=float,
)
if not np.all(np.isfinite(out)):
return None
return out
def oneshot_align_step_m(err_m: float) -> float:
"""单次精拍允许的步长 [m]。不超过半孔距余量;超过 cap 才截断。"""
e = abs(float(err_m))
cap = float(ONESHOT_ALIGN_CAP_M)
if e <= cap:
return e
return cap
def hole_pair_span_m(p0, p1) -> float:
"""两孔基座 XY 间距 [m]。"""
a = np.asarray(p0, dtype=float).reshape(-1)
b = np.asarray(p1, dtype=float).reshape(-1)
return float(np.hypot(float(a[0]) - float(b[0]), float(a[1]) - float(b[1])))
def hole_pair_span_ok(span_m: float) -> bool:
"""下降后补孔用的间距窗。真机 k1/k2 约 60mm;45mm 是近距检测漂了。"""
s = float(span_m)
return HOLE_PAIR_TRIM_MIN_M <= s <= HOLE_PAIR_TRIM_MAX_M
def flange_z_for_gap_m(tip_len_m: float, surface_z_m: float, gap_m: float) -> float:
"""规尖离面 ``gap_m`` 时的法兰 Z [m]。与轻降几何同一条式子。"""
return float(tip_len_m) + float(surface_z_m) + float(gap_m)
def final_gap_refusal(
gap_m: float,
tool_len_m: float,
surface_z_m: float,
hover_z_m: float,
z_min_m: float,
z_max_m: float,
) -> str:
"""笛卡尔锁 XY 的贴面间隙能不能用。空串表示可以;非空就是拒绝原因。
参数是规尖离面 [m],不是法兰 Z:法兰 = 工具长 + 工件面 + 间隙,只在这里算一次。
"""
gap = float(gap_m)
if not np.isfinite(gap):
return "贴面间隙不是有限数,拒绝开跑"
if gap <= 1e-9:
return (
f"贴面间隙 {gap * 1000:.1f}mm ≤ 0,规尖会进工件/贴面接触,拒绝开跑"
)
tool = float(tool_len_m)
surf = float(surface_z_m)
if tool <= 0.0 or surf <= 0.0:
return "写了贴面间隙,但工具长度或工件面未知,无法确认尖端在面之上,拒绝开跑"
hover_clear = float(hover_z_m) - tool - surf
if gap > hover_clear + 1e-6:
return (
f"贴面间隙 {gap * 1000:.1f}mm 比悬停离面 {hover_clear * 1000:.1f}mm 还高,"
"锁 XY 只会抬臂,拒绝开跑"
)
flange = flange_z_for_gap_m(tool, surf, gap)
z_lo = float(z_min_m)
z_hi = float(z_max_m)
if flange < z_lo - 1e-6 or flange > z_hi + 1e-6:
return (
f"贴面法兰 Z {flange:.3f}m(规尖离面 {gap * 1000:.1f}mm)"
f"超出工作盒 Z [{z_lo:.3f}, {z_hi:.3f}],拒绝开跑"
)
return ""
def decompose_base_xy(delta_xy, T_cam_to_base: np.ndarray):
"""把基座 XY 残差拆成沿视线 / 沿画面右 [m]。投影退化返回 None。"""
axes = cam_xy_bias_axes(T_cam_to_base)
if axes is None:
return None
look_xy, right_xy = axes
d = np.asarray(delta_xy, dtype=float).reshape(2)
return {
"along_look_m": float(np.dot(d, look_xy)),
"along_right_m": float(np.dot(d, right_xy)),
"look_xy": [float(look_xy[0]), float(look_xy[1])],
"right_xy": [float(right_xy[0]), float(right_xy[1])],
}
def px_to_cam(
u: float, v: float, z: float, fx: float, fy: float, cx: float, cy: float
) -> np.ndarray:
return np.array(
[(u - cx) * z / fx, (v - cy) * z / fy, z], dtype=float
)
def oneshot_k0_base(
det,
depth_m: np.ndarray,
*,
fx: float,
fy: float,
cx: float,
cy: float,
T_cam_to_base: np.ndarray,
min_z_m: float,
max_z_m: float,
kpt_index: int = 0,
kpt_min_conf: float = 0.30,
min_det_conf: float = 0.5,
color_wh: Optional[tuple[int, int]] = None,
depth_wh: Optional[tuple[int, int]] = None,
) -> Optional[tuple[np.ndarray, bool]]:
"""D435 一张图 → 基座系 XYZ。失败返回 None。
外参必须是 **cam→基座**(eye_to_hand)。传入腕部 T 会得到错位置且不报错,
所以调用方必须自己保证文件对;本函数不再猜。
反投影用**彩色图**像素 + 彩色图内参;深度只在对齐图上取样。
"""
if det is None or float(getattr(det, "conf", 0.0)) < float(min_det_conf):
return None
u, v, is_k0 = workpiece_uv_from_det(
det, kpt_index=kpt_index, kpt_min_conf=kpt_min_conf
)
u_d, v_d = u, v
if color_wh is not None and depth_wh is not None:
u_d, v_d = map_uv_to_depth(u, v, color_wh, depth_wh)
z = depth_at_m(depth_m, u_d, v_d)
if z is None or z < float(min_z_m) or z > float(max_z_m):
return None
p_cam = px_to_cam(u, v, z, fx, fy, cx, cy)
T_ee = np.eye(4) # eye_to_hand 不用法兰,占位
p_base = cam_point_to_base(p_cam, T_cam_to_base, T_ee, "eye_to_hand")
if not np.all(np.isfinite(p_base)):
return None
return np.asarray(p_base, dtype=float).reshape(3), bool(is_k0)
@dataclass
class StillLock:
"""D405 跟住时,法兰 XY 连续 ``still_sec`` 秒位移不超过 ``still_move_m`` → 锁定。
顶视一次性迁过去的那段路**不算**锁定:``tracking_ok`` 必须是腕部有效目标。
大幅一动就重计时,避免"晃一下刚好满 3 秒"误进二阶段。
"""
still_sec: float
still_move_m: float
_t0: Optional[float] = None
_anchor_xy: Optional[np.ndarray] = None
def reset(self) -> None:
self._t0 = None
self._anchor_xy = None
def update(
self,
p_ee_xy: Optional[np.ndarray],
now: float,
*,
tracking_ok: bool,
) -> bool:
if (not tracking_ok) or p_ee_xy is None:
self.reset()
return False
xy = np.asarray(p_ee_xy, dtype=float).reshape(2)
if self._anchor_xy is None or self._t0 is None:
self._anchor_xy = xy.copy()
self._t0 = float(now)
return False
if float(np.linalg.norm(xy - self._anchor_xy)) > float(self.still_move_m):
self._anchor_xy = xy.copy()
self._t0 = float(now)
return False
return (float(now) - float(self._t0)) >= float(self.still_sec)
@@ -0,0 +1,556 @@
"""路径弧长上的梯形速度,以及 XY 圆弧。
二阶段下臂和一阶段 D435 迁臂共用这一份。不许再抄一套加减速。
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Optional, Tuple
import numpy as np
# 整段同一巡航:site ``v_slow_xy`` × 本系数。1.5 → 120 mm/s(远处 hop 上限)。
D435_MOVE_SPEED_SCALE = 1.5
# D435 厂家 MoveL:SDK ``vel`` [m/s]、``acc`` [m/s²]。
# 厂家示例自由空间 0.6 / 近接触 0.2;本档 0.3 和 1.5,短行程也能爬到巡航。
# 只改 vel、不给 acc 时,短 hop 仍被默认加速度卡住,肉眼看不出。
D435_VENDOR_MOVEL_VEL_MPS = 0.3
D435_VENDOR_MOVEL_ACC_MPS2 = 1.5
# 缺路程时的兜底加速度。真机 hop 用 ``trap_acc_for_hop``,和二阶段同一条刹车。
D435_MOVE_ACC_MPS2 = 0.1875
# action 走完后再停这么久,才重测 TF。二阶段 ``_goto`` 的 settle_s。
D435_SETTLE_S = 0.25
# 冻结雅可比一次只信这么远。二阶段 ``_plan_and_send`` 默认 ``max_seg`` 同源。
# 400 mm 一次规划,真机剩 117 mm,随后 28 mm 死循环刷轨迹。
FROZEN_J_HOP_MAX_M = 0.08
# 剩差进入这一带 = 工件上方。真机最后一跳 37–75 mm @ 80 mm/s 会晃并偏转。
D435_NEAR_LEFTOVER_M = 0.08
D435_NEAR_HOP_MAX_M = 0.015
D435_NEAR_CRUISE_MPS = 0.045
# 法兰倾角超过这个才单独收垂直。和 XY 捆在同一跳会把腕部漏成 Y 过冲。
D435_VERT_TILT_RAD = 0.015
D435_MID_LEFTOVER_M = 0.15
D435_MID_HOP_MAX_M = 0.040
# 弦长的 12% 当矢高,封顶 50 mm,避免大圆弧甩出工作盒。
D435_ARC_SAGITTA_FRAC = 0.12
D435_ARC_SAGITTA_MAX_M = 0.05
# 整段一次走完。起步必须够长:40 mm 就爬到 80 mm/s 是真机那下弹射
# (头尾速度本来就是 0,不是初速度;平移也不下发角速度)。
# 峰值加速度 a_max = π v² / (4 d_acc);150 mm @ 90 mm/s → 约 0.042 m/s²。
# 巡航相对原 60 mm/s 加 50%;加减速路程同比加长,起步时间差不多。
D435_FULL_ACC_M = 0.150
D435_FULL_DEC_M = 0.120
D435_FULL_CRUISE_MPS = 0.090
def hybrid_cruise_vxy(
v_slow_xy: float, scale: float = D435_MOVE_SPEED_SCALE
) -> float:
"""远处巡航。近处由 ``oneshot_hop_limits`` 另给一档,不许再抄 15 mm/s。"""
return max(1e-4, float(v_slow_xy) * float(scale))
def oneshot_hop_limits(
leftover_m: float, far_cruise_mps: float = 0.08
) -> Tuple[float, float]:
"""这一跳的 ``(hop_max_m, cruise_mps)``。近处必须比 80 mm / 80 mm/s 短且慢。"""
d = max(0.0, float(leftover_m))
far = max(1e-4, float(far_cruise_mps))
if d <= D435_NEAR_LEFTOVER_M:
return D435_NEAR_HOP_MAX_M, min(D435_NEAR_CRUISE_MPS, far)
if d <= D435_MID_LEFTOVER_M:
return D435_MID_HOP_MAX_M, far
return FROZEN_J_HOP_MAX_M, far
def flange_down_w(
R,
*,
dead_rad: float = 0.006,
gain: float = 2.0,
mag_lim: float = 0.5,
) -> Tuple[np.ndarray, float]:
"""让法兰 +Z 指向世界 −Z 的角速度 + 当前倾角。
和二阶段 ``_w_down`` 同一份。死区 0.006 rad(约 0.34°)以下不再修。
"""
z_now = np.asarray(R, dtype=float).reshape(3, 3)[:, 2]
z_des = np.array([0.0, 0.0, -1.0], dtype=float)
c = float(np.clip(np.dot(z_now, z_des), -1.0, 1.0))
ang = float(math.acos(c))
if ang < float(dead_rad):
return np.zeros(3, dtype=float), ang
axis = np.cross(z_now, z_des)
n = float(np.linalg.norm(axis))
axis = axis / n if n > 1e-9 else np.array([1.0, 0.0, 0.0], dtype=float)
mag = float(np.clip(float(gain) * ang, -float(mag_lim), float(mag_lim)))
return axis * mag, ang
def flange_tilt_rad(R) -> float:
"""法兰 +Z 相对世界 −Z 的倾角 [rad]。只读角度,不带死区。"""
z_now = np.asarray(R, dtype=float).reshape(3, 3)[:, 2]
z_des = np.array([0.0, 0.0, -1.0], dtype=float)
c = float(np.clip(np.dot(z_now, z_des), -1.0, 1.0))
return float(math.acos(c))
def trap_acc_for_hop(dist: float, v: float) -> float:
"""二阶段 ``_plan_and_send`` 同一条刹车:路程越长刹车越远,不许另写一套。"""
v_cmd = max(min(float(v), 0.08), 1e-3)
d_brake = min(0.030, max(0.006, 0.32 * max(float(dist), 1e-6)))
return max(v_cmd * v_cmd / (2.0 * d_brake), 0.030)
def trap_params(dist: float, v: float, a: float) -> Tuple[float, float, float, float, float]:
"""Rest-to-rest 梯形:返回 (T, v_peak, t_acc, t_cruise, a)。巡航速度保持 v。"""
dist = max(float(dist), 1e-6)
v = max(float(v), 1e-4)
a = max(float(a), 1e-3)
d_acc = v * v / (2.0 * a)
if 2.0 * d_acc >= dist:
v_peak = math.sqrt(max(a * dist, 1e-12))
t_acc = v_peak / a
return 2.0 * t_acc, v_peak, t_acc, 0.0, a
t_acc = v / a
t_cruise = (dist - 2.0 * d_acc) / v
return 2.0 * t_acc + t_cruise, v, t_acc, t_cruise, a
def trap_sv(
t: float, dist: float, v_peak: float, t_acc: float, t_cruise: float, a: float,
) -> Tuple[float, float]:
"""时刻 t 的路径弧长 s 与切向速度。"""
t = max(0.0, float(t))
t_dec0 = t_acc + t_cruise
t_end = t_dec0 + t_acc
if t >= t_end:
return dist, 0.0
if t < t_acc:
return 0.5 * a * t * t, a * t
if t < t_dec0:
return 0.5 * a * t_acc * t_acc + v_peak * (t - t_acc), v_peak
td = t - t_dec0
s0 = 0.5 * a * t_acc * t_acc + v_peak * t_cruise
return s0 + v_peak * td - 0.5 * a * td * td, max(0.0, v_peak - a * td)
def smooth_path_params(
dist: float,
v: float,
*,
d_acc: float = D435_FULL_ACC_M,
d_dec: float = D435_FULL_DEC_M,
) -> Tuple[float, float, float, float, float, float]:
"""整段一次走完的余弦加减速:(T, v_peak, t_acc, t_cruise, d_acc, d_dec)。
加速/减速段速度按半个余弦走,两端加速度为 0,没有梯形那种加速度台阶。
路程不够巡航时整段一条余弦,峰值不超过 ``v``。
"""
dist = max(float(dist), 1e-6)
v = max(float(v), 1e-4)
acc = min(max(float(d_acc), 1e-4), 0.45 * dist)
dec = min(max(float(d_dec), 1e-4), 0.45 * dist)
if acc + dec >= dist - 1e-6:
# 整段一条正弦速度:v = v_peak sin(π t / T),s(T)=2 v_peak T / π。
T = math.pi * dist / (2.0 * v)
return T, v, 0.0, 0.0, 0.0, dist
t_acc = 2.0 * acc / v
t_dec = 2.0 * dec / v
t_cruise = (dist - acc - dec) / v
T = t_acc + t_cruise + t_dec
return T, v, t_acc, t_cruise, acc, dec
def cosine_peak_acc(v: float, d_acc: float) -> float:
"""余弦加速段的峰值切向加速度 [m/s²]。``d_acc`` 越长,起步越不弹。"""
d = max(float(d_acc), 1e-6)
return math.pi * float(v) * float(v) / (4.0 * d)
def smooth_path_sv(
t: float,
dist: float,
v_peak: float,
t_acc: float,
t_cruise: float,
d_acc: float,
d_dec: float,
) -> Tuple[float, float]:
"""时刻 t 的弧长与切向速度。头尾速度为 0。"""
t = max(0.0, float(t))
dist = max(float(dist), 1e-6)
v_peak = max(float(v_peak), 1e-4)
if t_acc <= 1e-9 and t_cruise <= 1e-9:
T = math.pi * dist / (2.0 * v_peak)
if t >= T:
return dist, 0.0
u = math.pi * t / T
s = (v_peak * T / math.pi) * (1.0 - math.cos(u))
vs = v_peak * math.sin(u)
return min(s, dist), max(0.0, vs)
t_dec = 2.0 * float(d_dec) / v_peak if v_peak > 1e-9 else 0.0
t_end = float(t_acc) + float(t_cruise) + t_dec
if t >= t_end:
return dist, 0.0
if t < t_acc:
u = math.pi * t / t_acc
s = 0.5 * v_peak * (t - t_acc / math.pi * math.sin(u))
vs = 0.5 * v_peak * (1.0 - math.cos(u))
return s, vs
if t < t_acc + t_cruise:
return float(d_acc) + v_peak * (t - t_acc), v_peak
td = t - t_acc - t_cruise
u = math.pi * td / t_dec if t_dec > 1e-9 else math.pi
s0 = float(d_acc) + v_peak * float(t_cruise)
s = s0 + 0.5 * v_peak * (td + t_dec / math.pi * math.sin(u))
vs = 0.5 * v_peak * (1.0 + math.cos(u))
return min(s, dist), max(0.0, vs)
def _circle_3(
a: np.ndarray, b: np.ndarray, c: np.ndarray
) -> Optional[Tuple[np.ndarray, float]]:
ax, ay = float(a[0]), float(a[1])
bx, by = float(b[0]), float(b[1])
cx, cy = float(c[0]), float(c[1])
d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
if abs(d) < 1e-9:
return None
a2 = ax * ax + ay * ay
b2 = bx * bx + by * by
c2 = cx * cx + cy * cy
ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d
uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d
center = np.array([ux, uy], dtype=float)
r = float(np.hypot(ax - ux, ay - uy))
if r < 1e-4:
return None
return center, r
def _ccw(a0: float, a1: float) -> float:
return (a1 - a0) % (2.0 * math.pi)
def _xy_in_box(xy: np.ndarray, lo: np.ndarray, hi: np.ndarray) -> bool:
return bool(
float(lo[0]) <= float(xy[0]) <= float(hi[0])
and float(lo[1]) <= float(xy[1]) <= float(hi[1])
)
@dataclass
class ArcXyMove:
"""从 p0 走到 p1 的 XY 圆弧(或退化成直线),弧长上跑梯形速度。"""
p0: np.ndarray
p1: np.ndarray
length: float
v_cruise: float
T: float
v_peak: float
t_acc: float
t_cruise: float
a: float
t0: float
linear: bool
center: Optional[np.ndarray] = None
radius: float = 0.0
ang0: float = 0.0
sweep: float = 0.0
s_last: float = 0.0
_dir: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0]))
def sample(self, now: float) -> Tuple[np.ndarray, np.ndarray, bool]:
"""返回 (计划 XY, 切向速度, 是否走完)。"""
t = max(0.0, float(now) - float(self.t0))
s, sdot = trap_sv(
t, self.length, self.v_peak, self.t_acc, self.t_cruise, self.a
)
s = min(float(s), float(self.length))
self.s_last = s
done = t >= float(self.T) - 1e-4 or s >= float(self.length) - 1e-5
if self.linear or self.radius <= 1e-6 or self.center is None:
L = max(float(self.length), 1e-6)
u = s / L
xy = (1.0 - u) * self.p0 + u * self.p1
vxy = self._dir * float(sdot)
return xy, vxy, done
frac = 0.0 if abs(self.sweep) < 1e-9 else s / (self.radius * abs(self.sweep))
frac = min(1.0, max(0.0, frac))
ang = self.ang0 + self.sweep * frac
xy = self.center + self.radius * np.array(
[math.cos(ang), math.sin(ang)], dtype=float
)
tang = np.array([-math.sin(ang), math.cos(ang)], dtype=float)
if self.sweep < 0.0:
tang = -tang
return xy, tang * float(sdot), done
def build_arc_xy_move(
p0,
p1,
*,
v: float,
a: float,
now: float,
ws_min=None,
ws_max=None,
sag_frac: float = D435_ARC_SAGITTA_FRAC,
sag_max: float = D435_ARC_SAGITTA_MAX_M,
) -> ArcXyMove:
"""p0→p1 的圆弧。工作盒装不下就换侧,再不行走直线。速度整段同一档。"""
p0 = np.asarray(p0, dtype=float).reshape(2)
p1 = np.asarray(p1, dtype=float).reshape(2)
chord = p1 - p0
L = float(np.hypot(chord[0], chord[1]))
direction = (
chord / L if L > 1e-6 else np.array([1.0, 0.0], dtype=float)
)
cruise = max(float(v), 1e-4)
acc = max(float(a), 1e-3)
def _linear() -> ArcXyMove:
T, v_peak, t_acc, t_cruise, a_use = trap_params(max(L, 1e-6), cruise, acc)
return ArcXyMove(
p0=p0.copy(),
p1=p1.copy(),
length=max(L, 1e-6),
v_cruise=cruise,
T=T,
v_peak=v_peak,
t_acc=t_acc,
t_cruise=t_cruise,
a=a_use,
t0=float(now),
linear=True,
_dir=direction,
)
if L < 0.003:
return _linear()
n = np.array([-direction[1], direction[0]], dtype=float)
h = min(float(sag_max), float(sag_frac) * L)
mid = 0.5 * (p0 + p1)
lo = None if ws_min is None else np.asarray(ws_min, dtype=float).reshape(-1)[:2]
hi = None if ws_max is None else np.asarray(ws_max, dtype=float).reshape(-1)[:2]
def _try(sign: float) -> Optional[ArcXyMove]:
apex = mid + n * (h * sign)
cir = _circle_3(p0, apex, p1)
if cir is None:
return None
center, r = cir
a0 = math.atan2(p0[1] - center[1], p0[0] - center[0])
a1 = math.atan2(p1[1] - center[1], p1[0] - center[0])
a_via = math.atan2(apex[1] - center[1], apex[0] - center[0])
ccw_end = _ccw(a0, a1)
ccw_via = _ccw(a0, a_via)
if 1e-6 < ccw_via < ccw_end - 1e-6:
sweep = ccw_end
else:
sweep = ccw_end - 2.0 * math.pi
if abs(sweep) < 1e-6:
return None
length = r * abs(sweep)
if lo is not None and hi is not None:
for k in range(9):
frac = k / 8.0
ang = a0 + sweep * frac
xy = center + r * np.array([math.cos(ang), math.sin(ang)])
if not _xy_in_box(xy, lo, hi):
return None
T, v_peak, t_acc, t_cruise, a_use = trap_params(length, cruise, acc)
return ArcXyMove(
p0=p0.copy(),
p1=p1.copy(),
length=length,
v_cruise=cruise,
T=T,
v_peak=v_peak,
t_acc=t_acc,
t_cruise=t_cruise,
a=a_use,
t0=float(now),
linear=False,
center=center,
radius=r,
ang0=a0,
sweep=sweep,
)
for sign in (1.0, -1.0):
got = _try(sign)
if got is not None:
return got
return _linear()
def path_interp(waypoints, u: float):
"""沿关节路点折线插值。u∈[0,1] → (q, dq/du)。"""
nseg = len(waypoints) - 1
if nseg <= 0:
z = np.zeros_like(waypoints[0])
return waypoints[0].copy(), z
u = float(min(1.0, max(0.0, u)))
x = u * nseg
i = min(nseg - 1, int(math.floor(x)))
a = x - i
q = (1.0 - a) * waypoints[i] + a * waypoints[i + 1]
dq_du = (waypoints[i + 1] - waypoints[i]) * nseg
return q, dq_du
def translate_hold_ori_waypoints(
q0,
J,
dp,
*,
damping: float = 0.08,
max_dq: float = 0.06,
R=None,
verticalize: bool = False,
):
"""冻结当前雅可比,把 ``dp`` 拆成关节路点。
和二阶段 ``_plan_and_send`` 同一份逆解。``verticalize`` 时顺带收法兰朝下,
不许另抄一份 ``_w_down``。返回 ``(路点, 还剩多少米)``。
"""
from arm_application.geom_ik import dls_translate_hold_ori, dls_twist
q = np.asarray(q0, dtype=float).copy()
remain = np.asarray(dp, dtype=float).reshape(3).copy()
dist = float(np.linalg.norm(remain))
wps = [q.copy()]
inner_tol = min(0.00012, max(0.00005, 0.05 * max(dist, 1e-6)))
n_max = int(math.ceil(dist / 0.008)) + 16
Jj = np.asarray(J, dtype=float)
w_step = np.zeros(3, dtype=float)
ang = 0.0
if verticalize:
if R is None:
raise ValueError("verticalize 必须传入当前法兰 R,不许假装已经朝下")
w_step, ang = flange_down_w(R)
for _ in range(n_max):
if float(np.linalg.norm(remain)) < inner_tol and (
(not verticalize) or ang < 0.006
):
break
nseg = max(1, int(math.ceil(float(np.linalg.norm(remain)) / 0.012)))
step = remain / nseg
if verticalize and ang > 0.008:
qdot = dls_twist(Jj, step / 0.12, w_base=w_step, damping=damping, max_qdot=0.45)
dq = np.clip(qdot * 0.12, -float(max_dq), float(max_dq))
else:
dq = dls_translate_hold_ori(Jj, step, damping=damping, max_dq=max_dq)
if float(np.max(np.abs(dq))) < 1e-5:
break
q = q + dq
wps.append(q.copy())
remain = remain - (Jj[:3, :] @ dq)
ang = max(0.0, ang - float(np.linalg.norm(w_step)) * 0.12)
return wps, float(np.linalg.norm(remain))
def trap_joint_schedule(
waypoints,
dist: float,
v: float,
a: float,
*,
t0: float = 0.06,
dt: float = 0.04,
t_hold: float = 0.25,
):
"""路点 + 梯形速度 → ``[(q, qdot, t), ...]``。头尾速度为 0,时间严格递增。
一次下发整段。中途再发下一条会把这条掐掉,臂就重新加速 —— 那就是抖。
"""
dist = max(float(dist), 1e-6)
T_move, v_peak, t_acc, t_cruise, a_use = trap_params(dist, v, a)
n_samp = max(8, int(math.ceil(T_move / max(float(dt), 1e-3))))
zeros = np.zeros_like(waypoints[0])
out = []
last_t = -1.0
for k in range(n_samp + 1):
t_local = T_move * k / n_samp
s, vs = trap_sv(t_local, dist, v_peak, t_acc, t_cruise, a_use)
u = s / dist
qi, dq_du = path_interp(waypoints, u)
qdot = dq_du * (vs / dist) if k < n_samp else zeros
if k >= n_samp:
qi = waypoints[-1]
qdot = zeros
t = float(t0) + t_local
if t <= last_t:
t = last_t + 0.02
out.append((np.asarray(qi, dtype=float).copy(), np.asarray(qdot, dtype=float).copy(), t))
last_t = t
hold = max(0.12, float(t_hold))
for frac in (0.40, 0.70, 1.00):
t = float(t0) + T_move + hold * frac
if t < last_t + 0.05:
t = last_t + 0.05
out.append((np.asarray(waypoints[-1], dtype=float).copy(), zeros.copy(), t))
last_t = t
return out, T_move, v_peak
def smooth_joint_schedule(
waypoints,
dist: float,
v: float,
*,
t0: float = 0.06,
dt: float = 0.04,
t_hold: float = 0.25,
d_acc: float = D435_FULL_ACC_M,
d_dec: float = D435_FULL_DEC_M,
):
"""路点 + 余弦加减速 → ``[(q, qdot, t), ...]``。头尾速度为 0。
D435 整段一次下发用这一份。二阶段短跳仍走 ``trap_joint_schedule``。
"""
dist = max(float(dist), 1e-6)
T_move, v_peak, t_acc, t_cruise, acc_m, dec_m = smooth_path_params(
dist, v, d_acc=d_acc, d_dec=d_dec
)
n_samp = max(12, int(math.ceil(T_move / max(float(dt), 1e-3))))
zeros = np.zeros_like(waypoints[0])
out = []
last_t = -1.0
for k in range(n_samp + 1):
t_local = T_move * k / n_samp
s, vs = smooth_path_sv(
t_local, dist, v_peak, t_acc, t_cruise, acc_m, dec_m
)
u = min(1.0, s / dist)
qi, dq_du = path_interp(waypoints, u)
qdot = dq_du * (vs / dist) if k < n_samp else zeros
if k >= n_samp:
qi = waypoints[-1]
qdot = zeros
t = float(t0) + t_local
if t <= last_t:
t = last_t + 0.02
out.append(
(
np.asarray(qi, dtype=float).copy(),
np.asarray(qdot, dtype=float).copy(),
t,
)
)
last_t = t
hold = max(0.12, float(t_hold))
for frac in (0.40, 0.70, 1.00):
t = float(t0) + T_move + hold * frac
if t < last_t + 0.05:
t = last_t + 0.05
out.append((np.asarray(waypoints[-1], dtype=float).copy(), zeros.copy(), t))
last_t = t
return out, T_move, v_peak
File diff suppressed because it is too large Load Diff
@@ -11,31 +11,116 @@
from __future__ import annotations
import json
import math
import os
import signal
import subprocess
import time
from typing import Optional
from pathlib import Path
from typing import Optional, Tuple
import numpy as np
import rclpy
from geometry_msgs.msg import PointStamped
from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy
from rclpy.qos import DurabilityPolicy, HistoryPolicy, QoSProfile, ReliabilityPolicy
from std_msgs.msg import Bool, String
from trajectory_msgs.msg import JointTrajectory
from arm_application.gauge_kin import (
a7_limit_target,
match_limits,
parse_joint_limits,
)
from arm_application.overhead_oneshot import D435_MOVEL_GAVE_UP
from arm_application.site_io import load_site_dict
from arm_application.stage2_locate_node import _pt
from arm_application.stage3_search_node import Stage3Search
#: 一阶段跟踪高相对二阶段悬停高的**抬升量** [m]。
#: 悬停时规尖离面不够,跟踪高再抬的量 [m]。
#:
#: 为什么要抬:跟踪时通止规已经夹在爪上,规尖比相机更靠下;悬停高是"相机拍清
#: 孔"的高度,直接在那儿跟踪会让规尖刮到工件。抬 5cm 让它离开工件,二阶段再
#: 小幅下降(只需 5cm 以内的行程)。
#: 只在「悬停高上规尖离工件面 < :data:`TRACK_LOCK_GAP_M`」时加上。仿真悬停按
#: 工具几何只留约 2mm,不抬就会在跟踪时刮到工件。真机 89 悬停 0.320 时规尖
#: 已经在面上方约 75mm,再抬 50mm 会让对孔前先改一次高度(0.37→0.32)。
#:
#: ⚠️ 抽成常量是因为它不只被流程用:``scripts/check_sim_scene.py`` 要用它核对
#: "跟踪高有没有超出工作盒上界"(仿真里悬停 0.632 + 0.05 = 0.682 > 真机那套
#: 0.65 上界,就是被这条查出来的)。两处各写一个字面量迟早漂移。
TRACK_CLEARANCE_M = 0.05
#: 悬停时规尖离面已经不少于这个值 [m],一阶段就锁在悬停高,对孔完成后再下降。
TRACK_LOCK_GAP_M = 0.040
def init_z_refusal(
z_m: float,
tool_len_m: float,
surface_z_m: float,
gap_m: float,
z_min: float,
z_max: float,
) -> str:
"""初始法兰高度能不能用。空串表示可以;非空就是拒绝原因,不许悄悄抬高。"""
z = float(z_m)
if z < float(z_min) - 1e-6 or z > float(z_max) + 1e-6:
return (
f"初始法兰 Z {z:.3f}m 超出工作盒 [{float(z_min):.3f}, {float(z_max):.3f}],"
"拒绝开跑"
)
tool = float(tool_len_m)
surf = float(surface_z_m)
if tool <= 0.0 or surf <= 0.0:
return "写了初始高度,但工具长度或工件面未知,无法确认尖端在面之上,拒绝开跑"
clear = z - tool - surf
if clear < float(gap_m) - 0.001:
return (
f"初始法兰 Z {z:.3f}m 会让工具尖进入工件:"
f"尖端 {z - tool:.3f},工件面 {surf:.3f},"
f"间隙 {clear * 1000:.1f}mm,要求 ≥ {float(gap_m) * 1000:.1f}mm。"
"拒绝开跑,不会悄悄抬高。"
)
return ""
def hold_at_insert_z(final, *, skip3: bool) -> bool:
"""贴面已经按 ``final_gap_mm`` 停下时,不再进三阶段。
三阶段开局会抬到悬高上方测触觉,再落回 ``hole_hover_z_m``。
没有接触传感器。到位高度就是这次的贴面间隙,不另设一个 Z。
"""
if skip3:
return True
if not isinstance(final, dict):
return False
return bool(final.get("insert_held"))
def track_lock_z(hover_z: float, tool_len_m: float, surface_z_m: float) -> float:
"""一阶段锁高。
悬停时规尖离面已经 ≥ ``TRACK_LOCK_GAP_M``:锁在悬停高,跟踪和对孔都不改 Z,
对孔完成后再下降。离面不够(仿真悬停只留 2mm):再抬 ``TRACK_CLEARANCE_M``,
否则跟踪会刮到工件。工具长或工件面缺失时抬升 —— 缺数时锁在悬停高等于没检查。
"""
tool = float(tool_len_m)
surf = float(surface_z_m)
hover = float(hover_z)
if tool > 0.0 and surf > 0.0 and (hover - tool - surf) >= TRACK_LOCK_GAP_M:
return hover
return hover + TRACK_CLEARANCE_M
def stage1_log_fatal(log_path: str) -> str:
"""一阶段日志里已经写明失败时,不许再空等锁定。空串 = 还没到这一步。"""
try:
text = Path(log_path).read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
if D435_MOVEL_GAVE_UP in text:
return (
f"D435 {D435_MOVEL_GAVE_UP}(RDK 控制模式没切到 primitive),"
f"一阶段中止。不是视野/标定。看 {log_path}"
)
return ""
def stage1_timeout_reason(points_seen: int, timeout_s: float, log_path: str) -> str:
@@ -52,6 +137,9 @@ def stage1_timeout_reason(points_seen: int, timeout_s: float, log_path: str) ->
以前两种情况共用一句"没锁定(视野里没工件?)",于是"perception_node
因为缺依赖早退了"被读成"算法不收敛",白查半天。
"""
fatal = stage1_log_fatal(log_path)
if fatal:
return fatal
if points_seen <= 0:
return (
f"一阶段 {timeout_s:.0f}s 没锁定:**一个视觉目标点都没收到**"
@@ -79,11 +167,26 @@ class AssemblyPipeline(Stage3Search):
self.declare_parameter("skip_stage1", False)
self.declare_parameter("skip_stage2", False)
self.declare_parameter("skip_stage3", False) # 默认搜孔+轻压;拧入靠 --depth
self.declare_parameter("d435_move_only", False)
self.declare_parameter("init_z_m", -1.0) # >0:启动后先到这个法兰高度
self.declare_parameter("stage1_timeout_sec", 150.0)
self.linger3 = 1.2 # 串联结束短保持;单独跑 stage3 仍用 site linger_sec
self._s1: Optional[subprocess.Popen] = None
self._locked = False
self._robot_description: Optional[str] = None
# robot_state_publisher 用 TRANSIENT_LOCAL 只发一次。默认 VOLATILE
# 订阅在它之后才起来,3s 内一条都收不到,A7 会因“没有限位”拒绝旋转。
self.create_subscription(
String,
"/robot_description",
self._on_robot_description,
QoSProfile(
depth=1,
durability=DurabilityPolicy.TRANSIENT_LOCAL,
reliability=ReliabilityPolicy.RELIABLE,
),
)
self.create_subscription(Bool, "/visual_servo/locked", self._on_lock, 10)
# 诊断用:一阶段超时时靠它区分"感知没给点"和"给了点没收敛"。
# 只计数,不参与控制 —— 它绝不能影响流程本身的判断。
@@ -113,9 +216,11 @@ class AssemblyPipeline(Stage3Search):
skip1 = bool(self.get_parameter("skip_stage1").value)
skip2 = bool(self.get_parameter("skip_stage2").value)
skip3 = bool(self.get_parameter("skip_stage3").value)
self.d435_move_only = bool(self.get_parameter("d435_move_only").value)
self.get_logger().info(
f"pipeline arm_ee={self.ee_frame} hole={self.target_hole} "
f"skip1={skip1} skip2={skip2} skip3={skip3} insert={self.insert_depth_mm:.1f}mm"
f"skip1={skip1} skip2={skip2} skip3={skip3} "
f"d435_only={self.d435_move_only} insert={self.insert_depth_mm:.1f}mm"
)
def _state(self, s: str) -> None:
@@ -125,6 +230,113 @@ class AssemblyPipeline(Stage3Search):
self._state_pub.publish(msg)
self._pipe_pub.publish(msg)
def _on_robot_description(self, msg: String) -> None:
self._robot_description = str(msg.data)
def _a7_limits(self, timeout_s: float = 3.0) -> Optional[Tuple[float, float]]:
"""A7 软件限位。来自 ``/robot_description`` 的 joint7,与厂家 ``q_min/q_max`` 同一对。"""
t0 = time.monotonic()
while self._robot_description is None and time.monotonic() - t0 < timeout_s:
self._spin(0.05)
if not self._robot_description:
self.get_logger().error("拿不到 /robot_description,A7 没有限位,拒绝旋转")
return None
got = match_limits(
parse_joint_limits(self._robot_description), [self.joint_names[-1]]
)
if got is None:
self.get_logger().error(
f"/robot_description 里没有 {self.joint_names[-1]} 的限位,拒绝旋转"
)
return got
def _send_a7_only(self, a7_rad: float, speed_deg_s: float, tag: str) -> bool:
"""只下发 A7。前六轴用这一拍的当前角,厂家 ``SendJointPosition`` 收到的也是这一组。"""
q, Tee = self._wait_state(5.0)
if q is None or Tee is None or int(q.size) != 7:
self.get_logger().error(f"{tag}: 拿不到 7 轴关节,不下发")
return False
z0 = float(Tee[2, 3])
q_hold = q.copy()
q_hold[6] = float(a7_rad)
delta_deg = abs(math.degrees(float(q_hold[6] - q[6])))
dur = max(0.8, delta_deg / max(float(speed_deg_s), 1.0))
zeros = np.zeros(q.size)
traj = JointTrajectory()
traj.header.stamp.sec = 0
traj.header.stamp.nanosec = 0
traj.joint_names = list(self.joint_names)
traj.points = [_pt(q, zeros, 0.2), _pt(q_hold, zeros, 0.2 + dur)]
self.get_logger().info(
f"{tag}: A7 {math.degrees(float(q[6])):+.1f}° → "
f"{math.degrees(float(q_hold[6])):+.1f}°,"
f"{speed_deg_s:.0f}°/s,前六轴不动,法兰 Z={z0:.4f}m"
)
self._send_action(traj, wait_s=dur + 8.0)
q2, Tee2 = self._wait_state(5.0)
if q2 is None or Tee2 is None:
self.get_logger().error(f"{tag}: 走完读不到关节")
return False
others = float(np.max(np.abs(q2[:6] - q[:6])))
dz = abs(float(Tee2[2, 3]) - z0)
a7_err = abs(float(q2[6] - q_hold[6]))
self.get_logger().info(
f"{tag}: 前六轴最大偏差 {others:.4f} rad,"
f"法兰 ΔZ={dz * 1000:.1f}mm,A7 残差 {math.degrees(a7_err):.1f}°"
)
if others > 0.02:
self.get_logger().error(f"{tag}: 前六轴动了 {others:.3f} rad,中止")
return False
if dz > 0.003:
self.get_logger().error(f"{tag}: 法兰 Z 变了 {dz * 1000:.1f}mm,中止")
return False
if a7_err > 0.05:
self.get_logger().error(
f"{tag}: A7 还差 {math.degrees(a7_err):.1f}°,没到限位内侧"
)
return False
self._pin(q2)
return True
def _run_a7_seat(self) -> bool:
"""贴面后咬螺纹:A7 先逆时针到限位,再顺时针到限位。不改 Z。"""
limits = self._a7_limits()
if limits is None:
return False
ga = load_site_dict().get("gauge_assembly") or {}
try:
margin = float(ga["extreme_margin_deg"])
speed = float(ga["extreme_speed_deg_s"])
ccw = int(ga["extreme_direction"])
except (KeyError, TypeError, ValueError) as exc:
self.get_logger().error(
f"site.yaml gauge_assembly 缺 A7 旋转参数({exc}),拒绝旋转"
)
return False
if ccw not in (-1, 1):
self.get_logger().error(
f"extreme_direction 只能是 +1/-1,收到 {ccw},拒绝旋转"
)
return False
# +1 = 关节角增大 = 逆时针。第一步逆时针出孔让出牙,第二步顺时针进孔咬合。
steps = (
(ccw, "A7 逆时针至极限(出孔)"),
(-ccw, "A7 顺时针至极限(进孔)"),
)
for direction, tag in steps:
q, _Tee = self._wait_state(5.0)
if q is None:
self.get_logger().error(f"{tag}: 没有当前关节")
return False
target = a7_limit_target(q, limits, direction, margin)
if target is None:
self.get_logger().error(f"{tag}: 算不出 A7 限位目标")
return False
if not self._send_a7_only(float(target[6]), speed, tag):
return False
self.get_logger().info("A7 两步完成:逆时针到限位,再顺时针到限位。法兰高度未改。")
return True
def _on_lock(self, msg: Bool) -> None:
if bool(msg.data):
self._locked = True
@@ -163,33 +375,64 @@ class AssemblyPipeline(Stage3Search):
self._kill_old_stage1()
def _track_z(self) -> float:
"""跟踪高度 = 孔悬停高 + :data:`TRACK_CLEARANCE_M`,通止规离开工件。"""
return float(self.hover_z) + TRACK_CLEARANCE_M
"""一阶段锁高。写了 ``init_z_m`` 就用它,否则见 :func:`track_lock_z`。"""
z = float(self.get_parameter("init_z_m").value)
if z > 0.0:
return z
return track_lock_z(
float(self.hover_z),
float(getattr(self, "tool_tip_below_flange_m", 0.0) or 0.0),
float(getattr(self, "surface_z_m", 0.0) or 0.0),
)
def _ensure_clearance(self) -> None:
"""上一轮轻压可能还在孔里:先纯 Z 抬到跟踪高,禁止 0.23↔0.35 来回。"""
def _ensure_clearance(self) -> bool:
"""没写初始高度:只在低于锁高时抬上去。写了:升或降到那个法兰 Z,不安全就拒绝。"""
user = float(self.get_parameter("init_z_m").value) > 0.0
q, Tee = self._wait_state(4.0)
if q is None or Tee is None:
return
if user:
self.get_logger().error("设了 --init-z,但拿不到当前位姿,拒绝开跑")
return False
return True
tz = self._track_z()
if user:
why = init_z_refusal(
tz,
float(getattr(self, "tool_tip_below_flange_m", 0.0) or 0.0),
float(getattr(self, "surface_z_m", 0.0) or 0.0),
float(self.final_gap_m),
float(self.ws_min[2]),
float(self.ws_max[2]),
)
if why:
self.get_logger().error(why)
return False
z = float(Tee[2, 3])
if z >= tz - 0.008:
return
if user:
if abs(z - tz) <= 0.008:
self.get_logger().info(f"已在初始高度 Z={z:.3f}m(目标 {tz:.3f})")
return True
elif z >= tz - 0.008:
return True
self._state("pre_lift")
self.get_logger().info(
f"先离开工件/孔口:Z {z:.3f} → {tz:.3f}m(跟踪高=hover+50mm)"
)
_, q, Tee = self._goto(
self.get_logger().info(f"先到初始高度:Z {z:.3f} → {tz:.3f}m")
ok, q, Tee = self._goto(
q, Tee, np.array([float(Tee[0, 3]), float(Tee[1, 3]), tz]),
verticalize=True, tol_m=0.003, tol_tilt=self.vert_tol_rad * 2.0,
tag="pre_lift", v=self.move_speed, max_hops=8, hold_xy=True, settle_s=0.25,
)
q2 = self._q()
self._pin(q2 if q2 is not None else q)
z2 = float(Tee[2, 3])
if user and (not ok or abs(z2 - tz) > 0.008):
self.get_logger().error(f"初始高度没到位:Z={z2:.3f},目标 {tz:.3f}")
return False
return True
def _run_stage1(self) -> int:
"""拉起 perception+servo,等到 /visual_servo/locked 后冻结并拆跟踪。"""
self._ensure_clearance()
if not self._ensure_clearance():
return 2
self._state("stage1_track")
self._kill_old_stage1()
self._locked = False
@@ -205,12 +448,17 @@ class AssemblyPipeline(Stage3Search):
"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()}",
f"d435_move_only:={str(self.d435_move_only).lower()}",
]
self.get_logger().info(
f"启动一阶段跟踪(锁高 {lock_z:.3f}m,锁定后自动交二阶段)"
)
if self.d435_move_only:
self.get_logger().info(
f"启动 D435 位移测试(锁高 {lock_z:.3f}m,到位后不交 D405、不进二阶段)"
)
else:
self.get_logger().info(
f"启动一阶段跟踪(锁高 {lock_z:.3f}m,锁定后自动交二阶段)"
)
self._s1 = subprocess.Popen(
cmd, stdout=logf, stderr=subprocess.STDOUT, start_new_session=True,
)
@@ -221,6 +469,11 @@ class AssemblyPipeline(Stage3Search):
self._spin(0.1)
if self._locked:
break
fatal = stage1_log_fatal(log_path)
if fatal:
self._stop_stage1()
self.get_logger().error(fatal)
return 1
if self._s1.poll() is not None:
self.get_logger().error(f"一阶段进程提前退出,看 {log_path}")
return 1
@@ -230,7 +483,12 @@ class AssemblyPipeline(Stage3Search):
stage1_timeout_reason(self._point_msgs, timeout, log_path)
)
return 1
self.get_logger().info("一阶段已锁定 — 停跟踪、钉住当前关节,直接进二阶段")
if self.d435_move_only:
self.get_logger().info(
"D435 位移测试结束 — 停跟踪、钉住当前关节,跳过二阶段"
)
else:
self.get_logger().info("一阶段已锁定 — 停跟踪、钉住当前关节,直接进二阶段")
self._stop_stage1()
time.sleep(0.35)
q = self._q()
@@ -242,6 +500,9 @@ class AssemblyPipeline(Stage3Search):
skip1 = bool(self.get_parameter("skip_stage1").value)
skip2 = bool(self.get_parameter("skip_stage2").value)
skip3 = bool(self.get_parameter("skip_stage3").value)
if self.d435_move_only:
skip2 = True
skip3 = True
if not self._cli.wait_for_server(timeout_sec=5.0):
self.get_logger().error("JTC action 不在 —— 先开 T1: run_real.sh --arm 89")
return 2
@@ -252,7 +513,8 @@ class AssemblyPipeline(Stage3Search):
if code != 0:
return code
else:
self._ensure_clearance()
if not self._ensure_clearance():
return 2
self._state("skip_stage1")
q, Tee = self._wait_state(10.0)
@@ -267,19 +529,54 @@ class AssemblyPipeline(Stage3Search):
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 " — 立刻下压搜孔并轻压(不拧入)")
)
stay = hold_at_insert_z(final, skip3=skip3)
if stay and final.get("insert_held"):
self.get_logger().info(
f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm — "
f"插孔停在法兰 Z={final.get('flange_z_m')}m,"
f"规尖离面 {final.get('tip_gap_mm')}mm"
f"(目标 {final.get('commanded_gap_mm')}mm,来自 final_gap_mm)。"
"没有接触传感器,不另设 Z,不抬回悬高。"
)
else:
self.get_logger().info(
f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm"
+ (
" — 跳过搜孔,停在观测高度(看规尖是否对中;进程退出后臂保持)"
if skip3
else " — 立刻下压搜孔并轻压(不拧入)"
)
)
else:
self._state("skip_stage2")
stay = hold_at_insert_z(final, skip3=skip3)
if skip3:
if stay:
self._state("skip_stage3")
self.get_logger().info("跳过搜孔 —— 臂保持在孔上方")
if self.d435_move_only:
self.get_logger().info(
"D435 位移测试结束 —— 臂停在当前高度,不交 D405、不对孔;"
"15s 后本进程退出,臂保持不动"
)
elif isinstance(final, dict) and final.get("insert_held"):
if q is not None:
self._pin(q)
self.get_logger().info(
"插孔已停在贴面高度,不进搜孔、不抬回悬高。"
"接着只转 A7:先逆时针到限位,再顺时针到限位。"
)
if not self._run_a7_seat():
self.get_logger().error(
"A7 旋转没走完,臂停在当前关节。不抬 Z。"
)
else:
self.get_logger().info(
"跳过搜孔 —— 臂停在当前高度。请看规尖与孔是否对中;"
"15s 后本进程退出,臂保持不动"
)
if final:
self._publish_result(final)
self._linger_hold(final if final else {"ok": True}, seconds=0.8)
self._linger_hold(final if final else {"ok": True}, seconds=15.0)
payload = {"ok": True, "stage": "pipeline", "exit": 0, "skipped": "stage3"}
try:
payload["stage2"] = json.loads(
@@ -21,6 +21,11 @@ class PlaneFeature:
xyz: np.ndarray # (3,) 相机坐标系下的立体点云质心
n_inliers: int
note: str
# 这个点的 Z 是不是**借**来的(该像素处读不到自己的深度,从检测框里借了一个)。
# 为什么要显式带出来、而不是让下游去认 ``note`` 里的字:借深度意味着该像素的
# 深度不可读 ⇒ 绝对位置不足以用来**重定位**(2026-10-08 真机,假 k0 带着借来的
# 深度、z 看着很合理 0.27~0.39m,于是"物理量程"守卫也失效)。
borrowed: bool = False
def _depth_meters(buf: np.ndarray, encoding: str) -> Tuple[np.ndarray, float]:
@@ -0,0 +1,479 @@
#!/usr/bin/env python3
"""YOLO **pose** 模型的推理封装(自解码,不经过 ultralytics 的 pose 后处理)。
背景:为什么会多出这一个模块
--------------------------------
`gongjian0929` 这类权重是 ``task=pose``:它输出**一个工件框 + K 个关键点**。
本项目 K=3,就是现场说的 k0 / k1 / k2(k0 跟踪、k1/k2 对孔)。
ultralytics 走不通,两条路都实测过(2026-09-29):
1. ``YOLO(engine, task="detect")`` —— 按检测去解 14 通道输出,结果吐出 300 个
垃圾框,``conf`` 直接是坐标值(600+),全被算成 ``workpiece`` 类。
跟踪会锁到空气上 —— 真机上这比不跑更危险。
2. ``YOLO(engine, task="pose")`` —— 直接崩:``negative dimension -983``。
根因是 ``scripts/weights.py`` 用**裸 TensorRT API** 构建 engine,engine 不带
metadata;ultralytics 拿不到 ``kpt_shape``,后处理就错位了。
(TensorRT 10.13 的 Python API 确实没有任何 metadata 接口 —— 见
``models/README.md`` 第 3 节。)
所以这里自己解码。输出布局是 YOLO pose 的固定约定::
output0 : (1, 4 + nc + 3*K, A) # A = anchor 数(640 输入时是 8400)
ch 0..3 : cx, cy, w, h # 输入尺度下的像素
ch 4.. : 每类置信度(nc 个)
之后 : K 组 (x, y, conf)
关键点是**同一个工件框的属性**,不是独立目标 —— 所以它们不参与 NMS 的竞争,
只跟着自己那个框一起被选出/淘汰。这点和"每个孔一个类"的检测模型不一样。
后端
----
- ``.engine``:TensorRT 10 的 ``set_tensor_address`` + ``execute_async_v3``。
用 torch 张量当设备缓冲、torch 的当前流当执行流 —— 不需要 pycuda。
实测 ~1.5 ms/帧(640 输入,FP16 engine)。
- ``.onnx``:onnxruntime。**本环境没有 CUDA provider**(只有 CPU),
所以 ONNX 只是兜底,真机请用 engine。
"""
from __future__ import annotations
import ast
import logging
from pathlib import Path
from typing import Any, List, Optional, Sequence, Tuple, Union
import cv2
import numpy as np
from .yolo_detector import Detection, resolve_class_labels
logger = logging.getLogger(__name__)
# ultralytics 的默认填充值。改了它并不会错 —— 只是输入分布和训练时不一致,
# 精度会掉一点,所以别改。
LETTERBOX_FILL = 114
# 关键点语义(现场约定):k0 是跟踪孔,k1/k2 是对孔。
# 默认值写在这里,site.yaml 可以覆盖(见 arm_application/pose_detector 的调用方)。
TRACK_KPT_INDEX = 0
HOLE_KPT_INDICES: Tuple[int, int] = (1, 2)
# --------------------------------------------------------------------------- #
# 模型信息:engine 没有 metadata,就从旁边同名的 .onnx 读
# --------------------------------------------------------------------------- #
def _parse_python_literal(raw: Any, default: Any = None) -> Any:
"""ONNX metadata 里存的是 python repr(单引号、嵌套 list/dict),要 literal_eval。"""
if raw is None:
return default
if isinstance(raw, (dict, list, tuple, int)):
return raw
try:
return ast.literal_eval(str(raw))
except (ValueError, SyntaxError):
return default
def read_pose_info(weights: Union[str, Path]) -> Optional[dict]:
"""读同名 ``.onnx`` 的 metadata,判断这是不是一个 pose 模型。
engine 本身读不到任何东西(TensorRT 10 的 Python API 没有 metadata 接口),
但 ``.onnx`` 一定在它旁边(``resolve_weights`` 保证同目录同主干),
所以统一以 ONNX 为准。
返回 ``None`` = 不是 pose 模型(调用方应回落到普通检测器)。
返回 dict 时至少含 ``kpt_count`` / ``nc`` / ``names``。
"""
p = Path(weights).expanduser()
onnx_path = p.with_suffix(".onnx")
if not onnx_path.is_file():
return None
try:
import onnx
except Exception: # noqa: BLE001
logger.warning("没装 onnx,无法判断 %s 是不是 pose 模型", p.name)
return None
try:
meta = {m.key: m.value for m in onnx.load(str(onnx_path)).metadata_props}
except Exception as exc: # noqa: BLE001
logger.warning("读 %s 的 metadata 失败:%s", onnx_path.name, exc)
return None
task = str(meta.get("task", "")).strip().lower()
kpt_shape = _parse_python_literal(meta.get("kpt_shape"))
if task != "pose" and not kpt_shape:
return None
kpt_count = 0
if isinstance(kpt_shape, (list, tuple)) and len(kpt_shape) >= 1:
try:
kpt_count = int(kpt_shape[0])
except (TypeError, ValueError):
kpt_count = 0
if kpt_count <= 0:
# metadata 缺 kpt_shape 也不能猜 —— 猜错就是"静默错位",比报错难查得多。
logger.warning("%s 标记为 pose 但读不到 kpt_shape,按非 pose 处理", onnx_path.name)
return None
names = _parse_python_literal(meta.get("names"), default={}) or {}
kpt_names = _parse_python_literal(meta.get("kpt_names"), default={}) or {}
nc = 0
if isinstance(names, dict) and names:
nc = max(int(k) for k in names) + 1 if all(
str(k).lstrip("-").isdigit() for k in names
) else len(names)
if nc <= 0 and isinstance(names, (list, tuple)):
nc = len(names)
return {
"task": "pose",
"kpt_count": kpt_count,
"kpt_names": kpt_names,
"nc": nc,
"names": names,
"model": str(meta.get("description") or p.name),
}
def is_pose_model(weights: Union[str, Path]) -> bool:
return read_pose_info(weights) is not None
# --------------------------------------------------------------------------- #
# 预处理 / 后处理(纯 numpy,可离线单测)
# --------------------------------------------------------------------------- #
def letterbox(
img: np.ndarray,
new_shape: int = 640,
color: Tuple[int, int, int] = (LETTERBOX_FILL,) * 3,
) -> Tuple[np.ndarray, float, Tuple[float, float]]:
"""等比缩放 + 居中填充到 ``new_shape``(和 ultralytics 推理时一致)。
返回 ``(padded, gain, (dw, dh))``:``dw/dh`` 是**填充量**,还原坐标时减掉它。
"""
h, w = img.shape[:2]
gain = min(new_shape / h, new_shape / w)
new_unpad = (max(1, int(round(w * gain))), max(1, int(round(h * gain))))
if (w, h) != new_unpad:
img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)
dw = (new_shape - new_unpad[0]) / 2.0
dh = (new_shape - new_unpad[1]) / 2.0
top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
if top or bottom or left or right:
img = cv2.copyMakeBorder(
img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color
)
return img, float(gain), (float(dw), float(dh))
def nms_xyxy(
boxes: np.ndarray,
scores: np.ndarray,
iou_thr: float,
max_det: int = 20,
) -> np.ndarray:
"""纯 numpy 的 NMS,返回保留下来的下标(按分数从高到低)。"""
if boxes.size == 0:
return np.zeros((0,), dtype=int)
x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
areas = np.maximum(0.0, x2 - x1) * np.maximum(0.0, y2 - y1)
order = scores.argsort()[::-1]
keep: List[int] = []
while order.size > 0 and len(keep) < int(max_det):
i = int(order[0])
keep.append(i)
if order.size == 1:
break
rest = order[1:]
ix1 = np.maximum(x1[i], x1[rest])
iy1 = np.maximum(y1[i], y1[rest])
ix2 = np.minimum(x2[i], x2[rest])
iy2 = np.minimum(y2[i], y2[rest])
inter = np.maximum(0.0, ix2 - ix1) * np.maximum(0.0, iy2 - iy1)
union = areas[i] + areas[rest] - inter
iou = np.where(union > 1e-9, inter / np.maximum(union, 1e-9), 0.0)
order = rest[iou <= float(iou_thr)]
return np.asarray(keep, dtype=int)
# --------------------------------------------------------------------------- #
# 后端:TensorRT engine / ONNX Runtime
# --------------------------------------------------------------------------- #
class _TrtRunner:
"""TensorRT 10 执行器:torch 张量当设备缓冲,torch 当前流当执行流。
不用 pycuda —— 环境里只有 torch,而 ``set_tensor_address`` +
``execute_async_v3`` 正好够用。
"""
def __init__(self, engine_path: Path, device: str = "0") -> None:
import tensorrt as trt
import torch
self._torch = torch
idx = 0
dev = str(device or "").strip().lower()
if dev and dev not in ("auto", "cuda", "gpu"):
tail = dev.split(":")[-1]
if tail.isdigit():
idx = int(tail)
if not torch.cuda.is_available():
raise RuntimeError("backend=tensorrt 但 torch 看不到 CUDA")
self._device = torch.device(f"cuda:{idx}")
trt_logger = trt.Logger(trt.Logger.ERROR)
with open(engine_path, "rb") as f:
engine = trt.Runtime(trt_logger).deserialize_cuda_engine(f.read())
if engine is None:
raise RuntimeError(
f"engine 反序列化失败:{engine_path}。"
"engine 和 TensorRT 版本绑死,重装环境后要重新导出"
"(bash scripts/weights.sh doctor --check-engines 可查)"
)
self._engine = engine
self._ctx = engine.create_execution_context()
names = [engine.get_tensor_name(i) for i in range(engine.num_io_tensors)]
ins = [n for n in names if engine.get_tensor_mode(n) == trt.TensorIOMode.INPUT]
outs = [n for n in names if engine.get_tensor_mode(n) == trt.TensorIOMode.OUTPUT]
if len(ins) != 1 or len(outs) != 1:
raise RuntimeError(f"pose engine 期望 1 进 1 出,实际 in={ins} out={outs}")
self.in_name, self.out_name = ins[0], outs[0]
with torch.cuda.device(self._device):
self._x = torch.zeros(
tuple(engine.get_tensor_shape(self.in_name)),
dtype=torch.float32,
device=self._device,
)
self._y = torch.zeros(
tuple(engine.get_tensor_shape(self.out_name)),
dtype=torch.float32,
device=self._device,
)
self._ctx.set_tensor_address(self.in_name, self._x.data_ptr())
self._ctx.set_tensor_address(self.out_name, self._y.data_ptr())
self.out_shape = tuple(engine.get_tensor_shape(self.out_name))
def run(self, chw: np.ndarray) -> np.ndarray:
torch = self._torch
with torch.cuda.device(self._device):
self._x.copy_(torch.from_numpy(np.ascontiguousarray(chw)).to(self._device))
self._ctx.execute_async_v3(torch.cuda.current_stream().cuda_stream)
torch.cuda.current_stream().synchronize()
return self._y.cpu().numpy()
class _OnnxRunner:
"""onnxruntime 兜底。本环境只有 CPU provider —— 真机请用 .engine。"""
def __init__(self, onnx_path: Path) -> None:
import onnxruntime as ort
providers = ort.get_available_providers()
if "CUDAExecutionProvider" in providers:
chosen = ["CUDAExecutionProvider", "CPUExecutionProvider"]
else:
logger.warning(
"onnxruntime 没有 CUDA provider,%s 会跑在 CPU 上(慢)。"
"真机请用同名的 .engine",
onnx_path.name,
)
chosen = ["CPUExecutionProvider"]
self._sess = ort.InferenceSession(str(onnx_path), providers=chosen)
self.in_name = self._sess.get_inputs()[0].name
self.out_shape = tuple(self._sess.get_outputs()[0].shape)
def run(self, chw: np.ndarray) -> np.ndarray:
return self._sess.run(None, {self.in_name: chw[None].astype(np.float32)})[0]
# --------------------------------------------------------------------------- #
# 检测器
# --------------------------------------------------------------------------- #
class PoseDetector:
"""把 pose 模型包装成和 ``YoloDetector`` 一样的 ``detect()/infer()`` 接口。
多出来的一件事:每个 ``Detection`` 带上 ``kpts``(``(K, 3)`` 的
``x, y, conf``,原图像素坐标)。
"""
def __init__(
self,
weights: Union[str, Path],
*,
conf: float = 0.25,
iou: float = 0.45,
imgsz: int = 640,
device: str = "0",
class_ids: Optional[Sequence[int]] = None,
class_names: Optional[Sequence[str]] = None,
max_det: int = 20,
) -> None:
path = Path(weights).expanduser()
if not path.is_file():
raise FileNotFoundError(f"找不到 pose 权重:{path}")
info = read_pose_info(path) or {}
self.kpt_count = int(info.get("kpt_count") or 0)
if self.kpt_count <= 0:
raise RuntimeError(f"{path.name} 不是可识别的 pose 模型(缺 kpt_shape)")
self.weights_path = str(path)
self.conf = float(conf)
self.iou = float(iou)
self.imgsz = int(imgsz)
self.max_det = int(max_det)
if path.suffix.lower() in {".engine", ".trt"}:
self._runner = _TrtRunner(path, device)
self.backend = "tensorrt"
else:
self._runner = _OnnxRunner(path)
self.backend = "onnx"
# 类别标签仍然走统一入口(旁挂 .labels.yaml 优先)。
# engine 没有 metadata,所以把 ONNX 里的 names 当"模型内嵌"传进去。
names = info.get("names") or {}
class _Meta: # 只为 resolve_class_labels 能读到 ONNX 内嵌标签
pass
meta = _Meta()
meta.names = names
meta.nc = int(info.get("nc") or 0)
self._labels = resolve_class_labels(path, meta)
self.class_ids = set(int(v) for v in class_ids) if class_ids else None
self.class_names = (
{str(n).strip().lower() for n in class_names if str(n).strip()}
if class_names
else None
)
self._warmed = False
# -- 对外 ---------------------------------------------------------------
def warmup(self, *, half: bool = False) -> None:
del half # engine 已是 FP16;ONNX 这边没有 half 的开关
if self._warmed:
return
# 先置位:infer() 开头会检查 _warmed,不置位就会反过来再调 warmup。
self._warmed = True
try:
self.infer(np.zeros((self.imgsz, self.imgsz, 3), dtype=np.uint8))
except Exception:
self._warmed = False
raise
def detect(
self,
image_bgr: np.ndarray,
conf: float | None = None,
iou: float | None = None,
imgsz: int | None = None,
**_kwargs,
) -> List[Detection]:
"""``detect_holes`` 用的别名接口(xyxy + conf + kpts)。"""
if conf is not None:
self.conf = float(conf)
if iou is not None:
self.iou = float(iou)
# imgsz 在这里**不能**改:engine 的输入尺寸是编译期定死的。
if imgsz is not None and int(imgsz) != self.imgsz:
logger.warning(
"pose engine 输入固定 %d,忽略调用方的 imgsz=%s", self.imgsz, imgsz
)
return self.infer(image_bgr)
def infer(self, image_bgr: np.ndarray, *, half: bool = False, seq: int = 0) -> List[Detection]:
del half, seq
if image_bgr is None or getattr(image_bgr, "size", 0) == 0:
return []
if not self._warmed:
self.warmup()
padded, gain, (dw, dh) = letterbox(image_bgr, self.imgsz)
chw = np.ascontiguousarray(
padded[:, :, ::-1].transpose(2, 0, 1), dtype=np.float32
) / 255.0
out = self._runner.run(chw)
return self._decode(out, gain, dw, dh)
# -- 解码 ---------------------------------------------------------------
def _decode(
self, raw: np.ndarray, gain: float, dw: float, dh: float
) -> List[Detection]:
o = np.asarray(raw, dtype=np.float32)[0] # (C, A)
ch, n_anchor = o.shape
k = self.kpt_count
nc = int(ch - 4 - 3 * k)
if nc < 1:
raise RuntimeError(
f"pose 输出通道数不对:C={ch},按 kpt={k} 反推 nc={nc}。"
"引擎/onnx 与 kpt_shape 对不上"
)
scores = o[4 : 4 + nc] # (nc, A)
cls_ids = scores.argmax(axis=0) # (A,)
confs = scores[cls_ids, np.arange(n_anchor)]
keep = confs >= self.conf
if not np.any(keep):
return []
xywh = o[0:4, keep].T # (N, 4)
confs = confs[keep]
cls_ids = cls_ids[keep]
kpts = o[4 + nc :, keep].reshape(k, 3, -1).transpose(2, 0, 1) # (N, k, 3)
xyxy = np.empty_like(xywh)
xyxy[:, 0] = xywh[:, 0] - 0.5 * xywh[:, 2]
xyxy[:, 1] = xywh[:, 1] - 0.5 * xywh[:, 3]
xyxy[:, 2] = xywh[:, 0] + 0.5 * xywh[:, 2]
xyxy[:, 3] = xywh[:, 1] + 0.5 * xywh[:, 3]
keep_i = nms_xyxy(xyxy, confs, self.iou, self.max_det)
if keep_i.size == 0:
return []
xyxy, confs, cls_ids, kpts = xyxy[keep_i], confs[keep_i], cls_ids[keep_i], kpts[keep_i]
# 去 letterbox 的填充,再还原到原图尺度
xyxy = xyxy.astype(float)
xyxy[:, [0, 2]] = (xyxy[:, [0, 2]] - dw) / gain
xyxy[:, [1, 3]] = (xyxy[:, [1, 3]] - dh) / gain
kpts = kpts.astype(float)
kpts[:, :, 0] = (kpts[:, :, 0] - dw) / gain
kpts[:, :, 1] = (kpts[:, :, 1] - dh) / gain
out: List[Detection] = []
for i in range(len(xyxy)):
cid = int(cls_ids[i])
name = self._labels.get(cid)
if self.class_ids is not None and cid not in self.class_ids:
continue
if self.class_names is not None and name.lower() not in self.class_names:
continue
out.append(
Detection(
xyxy=xyxy[i],
conf=float(confs[i]),
cls_id=cid,
cls_name=name,
kpts=kpts[i].copy(),
)
)
out.sort(key=lambda d: d.conf, reverse=True)
return out
def close(self) -> None:
return None
def keypoint_roles(kpt_count: int) -> Tuple[int, Tuple[int, ...]]:
"""返回 ``(跟踪点下标, 孔下标元组)``,供调用方统一口径。
现场约定:k0 跟踪,k1/k2 对孔。模型关键点少于 3 个时只返回存在的部分。
"""
track = TRACK_KPT_INDEX if kpt_count > TRACK_KPT_INDEX else -1
holes = tuple(i for i in HOLE_KPT_INDICES if i < kpt_count)
return track, holes
@@ -32,16 +32,21 @@ from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import CameraInfo, Image
from std_msgs.msg import Float32MultiArray
from arm_application.ros_node import TolerantNode, param_as_str
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()
# RealSense 掉到 480Mbps(或整台消失)时的排查口径。写在这里是为了"只有一处":
# 相机节点的报错、preflight 脚本、文档都引用同一套说法,不会各说各的。
# 为什么 480Mbps 值得专门解释一次(2026-09-28 现场):
# D435 原本协商到 USB 3.2、1280x720@30 跑得好好的,中途
# `xioctl(UVCIOC_CTRL_QUERY) ... No such file or directory`(= 设备从总线上消失)
# 之后重新枚举成了 **USB 2.1**。USB3 线缆/座子里 SuperSpeed 那一对针脚一旦
# 接触不良,USB2 的针脚还能用 —— 于是设备不会"掉线",只是**悄悄掉速**,
# 表现成"相机在,但帧率/分辨率不对"。这时查软件是查不出来的。
USB_RESEAT_HINT = (
'拔插一次、换一根 USB3 线,或换到机箱后面板直连的蓝色口 —— '
'只协商到 480Mbps 通常是 SuperSpeed 针脚没接上,不是相机坏了。'
'看当前协商速率:lsusb -t(设备后面写 480M 还是 5000M)。'
)
def _fill_camera_info(
@@ -68,23 +73,17 @@ def _fill_camera_info(
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")
from arm_application.site_io import site_path
return site_path()
class RealsenseColorPub(Node):
class RealsenseColorPub(TolerantNode):
def __init__(self) -> None:
super().__init__('realsense_color_pub')
self.declare_parameter('camera_name', 'd405') # 话题命名空间
self._name = str(self.get_parameter('camera_name').value).strip().strip('/')
self._serial = _param_as_str(self, 'camera_serial', '')
self._serial = param_as_str(self, 'camera_serial', '')
self.declare_parameter('width', 1280)
self.declare_parameter('height', 720)
self.declare_parameter('fps', 15)
@@ -177,6 +176,10 @@ class RealsenseColorPub(Node):
self._overlay: Optional[list] = None
self._overlay_stamp = 0.0
self._capture_started = False
self._cap_lock = threading.Lock()
self._cap_event = threading.Event()
self._pending = None
self._last_frame_mono = 0.0
self._usb_desc = ''
self._last_ok = None
self._site_yaml = _site_yaml_path()
@@ -310,23 +313,26 @@ class RealsenseColorPub(Node):
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'
)
self._publish_depth(depth_u16, stamp)
def _publish_depth(self, depth_u16: np.ndarray, stamp=None) -> None:
dh, dw = int(depth_u16.shape[0]), int(depth_u16.shape[1])
dmsg = Image()
dmsg.header.stamp = stamp or self.get_clock().now().to_msg()
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:
@@ -348,8 +354,8 @@ class RealsenseColorPub(Node):
if not devices:
raise RuntimeError(
'没有连接 RealSense(多次重试后 USB 仍为空)。'
'请把 D405 插到**本机**的 USB3(蓝色)口,拔插一次,'
'并关闭 realsense-viewer / 192.168.0.108 上占用该相机的采集端。'
'拔插一次;确认没有 realsense-viewer / 其它进程占着这台相机;'
+ USB_RESEAT_HINT
)
found = self._pick_device(devices, rs)
@@ -374,9 +380,12 @@ class RealsenseColorPub(Node):
f'requested {self._width}x{self._height}@{self._fps}Hz'
)
if usb2:
# 只在 480Mbps 口上才降。实测(2026-09-28)640x480@15 彩色+深度
# 可以稳定跑满 15fps;但 1280x720@30 在 USB2 上物理不可能
# (SuperSpeed 没协商上)。
self.get_logger().warn(
f'{cam_name} 接在 USB {usb_desc} 上 —— 深度/IR 将保持 '
'640x480@15。把 D405 插到 USB3(蓝色)口可获得 848@30。'
f'{cam_name} 只协商到 USB {usb_desc}(480Mbps)—— 降到 '
f'640x480@15Hz。' + USB_RESEAT_HINT
)
self._width, self._height, self._fps = 640, 480, 15
@@ -455,7 +464,9 @@ class RealsenseColorPub(Node):
trial.enable_stream(
rs.stream.infrared, 2, w, h, rs.format.y8, fps
)
profile = pipe.start(trial)
# 回调里只留最新一帧。wait_for_frames 会等彩色、深度、红外齐套,
# 场景一动红外对不齐,彩色帧就在这里被丢掉。
profile = pipe.start(trial, self._on_sdk_frame)
have_depth = with_depth
have_infra = infra_mode != 'none'
self._infra_mode = infra_mode
@@ -501,11 +512,18 @@ class RealsenseColorPub(Node):
break
if profile is None:
raise RuntimeError(
f'D405 没有可用的彩色配置。最后的错误:{last_err}'
f'{self._name} 没有可用的彩色配置。最后的错误:{last_err}'
)
self._pipe = pipe
self._profile = profile
try:
sensor = profile.get_device().first_color_sensor()
if sensor.supports(rs.option.frames_queue_size):
# 队列只留最新一张,避免越积越慢,动起来画面还停在旧帧。
sensor.set_option(rs.option.frames_queue_size, 1)
except Exception: # noqa: BLE001
pass
self._have_depth = have_depth
self._have_infra = have_infra
self._logged_depth = False
@@ -607,7 +625,7 @@ class RealsenseColorPub(Node):
if not getattr(self, '_capture_started', False):
self._capture_started = True
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
self._thread = threading.Thread(target=self._publish_loop, daemon=True, name="rs-publish")
self._thread.start()
def _enable_color_ae_fast(self, profile, rs) -> None:
@@ -700,18 +718,60 @@ class RealsenseColorPub(Node):
)
return False
def _capture_loop(self) -> None:
def _on_sdk_frame(self, frame) -> None:
"""SDK 回调。只拷走彩色并握住这一组帧,立刻返回。
对齐、深度换算和 ROS 发布若做在这个回调里,下一帧来不及取,
动起来时彩色帧号会跳。那些工作放到 ``_publish_loop``。
"""
try:
frames = frame.as_frameset()
color = frames.get_color_frame()
if not color:
return
bgr = np.array(np.asanyarray(color.get_data()), copy=True)
frames.keep()
with self._cap_lock:
self._pending = (bgr, frames)
self._last_frame_mono = time.monotonic()
self._cap_event.set()
except Exception as exc: # noqa: BLE001
try:
self.get_logger().warn(f'收帧:{exc}', throttle_duration_sec=2.0)
except Exception: # noqa: BLE001
pass
def _publish_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
if not self._cap_event.wait(0.2):
silent = self._last_frame_mono and time.monotonic() - self._last_frame_mono > 2.0
if silent:
usb = getattr(self, '_usb_desc', '?')
self.get_logger().error(
f'相机超过 2 秒没有新帧(USB={usb})。正在重连 —— '
'请给 USB 线缆留点余量,别让机械臂把它拽掉。'
)
self._last_frame_mono = 0.0
got = False
self._reconnect()
continue
self._cap_event.clear()
with self._cap_lock:
item = self._pending
self._pending = None
if item is None:
continue
bgr, frames = item
try:
t_wait0 = time.monotonic()
frames = self._pipe.wait_for_frames(timeout_ms=2000)
self._t_wait = time.monotonic() - t_wait0
t_pub = time.monotonic()
# 彩色先发出去。深度对齐再慢,也不能把这一帧彩色拖掉。
self._publish_frame(bgr, None)
self._t_pub = time.monotonic() - t_pub
ir_l = (
frames.get_infrared_frame(1)
if self._have_infra and self._infra_mode in ('both', 'left')
@@ -722,44 +782,45 @@ class RealsenseColorPub(Node):
if self._have_infra and self._infra_mode in ('both', 'right')
else None
)
aligned = frames
if self._align is not None:
t_al = time.monotonic()
frames = self._align.process(frames)
aligned = self._align.process(frames)
self._t_align = time.monotonic() - t_al
depth = aligned.get_depth_frame()
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:
if dropped:
usb = getattr(self, '_usb_desc', '?')
self.get_logger().error(
f'相机数据流中断(USB={usb}):{text[:120]}。'
'正在重连 —— 请给 USB 线缆留点余量,别让机械臂把它拽掉。'
)
got = False
misses = 0
self._last_frame_mono = 0.0
self._reconnect()
continue
if misses == 1:
self.get_logger().warn(f'wait_for_frames:{exc}')
else:
self.get_logger().warn(f'整理帧:{exc}', throttle_duration_sec=2.0)
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)
if depth_u16 is not None:
try:
self._publish_depth(depth_u16)
except Exception as exc: # noqa: BLE001
if not self._running:
break
self.get_logger().warn(f'发布深度失败:{exc}', throttle_duration_sec=2.0)
self._frame_i += 1
self._fps_meter(color, depth, depth_u16)
# 预览相关的工作(CPU 上色、IR 拷贝、四窗格绘制)每 N 帧才做一次
@@ -792,7 +853,6 @@ class RealsenseColorPub(Node):
)
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:
@@ -27,6 +27,7 @@ from sensor_msgs.msg import Image
from std_msgs.msg import Float32MultiArray
from arm_application.realsense_color_pub import _site_yaml_path
from arm_application.ros_node import TolerantNode
def _colorize_depth_u16(d: np.ndarray) -> np.ndarray:
@@ -56,15 +57,22 @@ def _mono_to_bgr(mono: np.ndarray) -> np.ndarray:
return np.ascontiguousarray(np.stack([mono, mono, mono], axis=-1))
class RealsenseView(Node):
class RealsenseView(TolerantNode):
def __init__(self) -> None:
super().__init__('realsense_view')
self.declare_parameter('camera_name', 'd405')
self.declare_parameter('overlay_hold_sec', 0.10)
self.declare_parameter('overlay_detection', True)
# D405 用 workpiece/detection;D435 用 workpiece/detection_overhead(像素坐标系不同)。
self.declare_parameter('detection_topic', 'workpiece/detection')
self.declare_parameter('view_hz', 20.0)
self.declare_parameter('hud_label', '')
self._name = str(self.get_parameter('camera_name').value).strip().strip('/')
self._hold = max(0.0, float(self.get_parameter('overlay_hold_sec').value))
self._overlay_detection = bool(self.get_parameter('overlay_detection').value)
self._det_topic = str(self.get_parameter('detection_topic').value or '').strip() or (
'workpiece/detection'
)
self._hud = str(self.get_parameter('hud_label').value or '').strip()
self._color: Optional[Image] = None
@@ -101,9 +109,10 @@ class RealsenseView(Node):
depth=5,
reliability=ReliabilityPolicy.RELIABLE,
)
self.create_subscription(
Float32MultiArray, 'workpiece/detection', self._on_det, det_qos
)
if self._overlay_detection:
self.create_subscription(
Float32MultiArray, self._det_topic, self._on_det, det_qos
)
# stage2 孔对准叠图(k1/k2 标注 + 残差 HUD),有它时优先显示
self.create_subscription(
Image, '/stage2/overlay', self._on_stage2_overlay, qos_profile_sensor_data
@@ -124,6 +133,7 @@ class RealsenseView(Node):
self.create_timer(1.0 / hz, self._push)
self.get_logger().info(
f'查看器已启动 —— 订阅 /{self._name}/color|depth|infra1|infra2 '
f'检测={self._det_topic if self._overlay_detection else "关"} '
f'(保留 {self._hold:.2f}s)。关闭窗口 = 仅隐藏。'
)
@@ -186,6 +196,9 @@ class RealsenseView(Node):
self.get_logger().warn(f"site.yaml 重新加载失败:{exc}")
return
if "overlay_hold_sec" in cam:
# 顶视检测频率低,保留 launch 里设的较长 hold,别被 site 的 0.1s 冲掉。
if "overhead" in self._det_topic:
return
hold = max(0.0, float(cam.get("overlay_hold_sec") or 0.0))
if abs(hold - self._hold) > 1e-6:
self.get_logger().info(
@@ -0,0 +1,118 @@
"""应用层节点的统一基类:把「参数类型的宽窄」问题收在一处。
为什么需要这个基类(2026-09-28 真机落地实测,代价是顶置相机起不来)
------------------------------------------------------------------
现场 `config/site.yaml` 写的是:
cameras:
d435:
ae_limit_us: 8000 # ← YAML 整数
脚本把它当 `-p ae_limit_us:=8000` 传给节点,而节点里是:
self.declare_parameter('ae_limit_us', 3000.0) # ← double
rclpy **严格**比对类型,于是节点**一起就死**:
InvalidParameterTypeException: Trying to set parameter 'ae_limit_us'
to '8000' of type 'INTEGER', expecting type 'DOUBLE'
现场看到的是「顶置 D435 起不来」,而 site.yaml 那一行看起来毫无问题 ——
`8000` 和 `8000.0` 在运维眼里是同一个数,在 rclpy 眼里是两种类型。
同一族的雷还有:`width: 848.0`(浮点)喂给 int 参数、`emitter_enabled: 1`
喂给 bool 参数、`"26610799"` 与 `26610799`(带不带引号)等等。
**为什么不让现场人员"写对一点"**:那等于要求每个人记住「这个数必须带小数点」,
而记错的表现是节点启动即崩、报错里还完全不提 site.yaml。规则要靠人记住的就
一定会破(`FRAMEWORK_RULES.md` 的总纲)。
**为什么不在每个参数上加个 cast**:异常发生在**声明阶段**,
`float(self.get_parameter(...).value)` 根本执行不到 —— 看着宽容,其实没救。
做法
----
用 ``ParameterDescriptor(dynamic_typing=True)`` 声明:参数类型由**传进来的值**
决定,不做匹配检查;取值时由业务代码 cast(`float(...)` / `int(...)`),
语义仍然由声明处的默认值表达。于是 ``8000`` / ``8000.0`` / ``"8000"`` 都能用。
**代价与边界**:动态类型意味着 rclpy 不再替我们拦「类型写错」。
本项目接受这个代价,因为这些参数**全部**来自 `config/site.yaml` 这个运维手改的
文件(不是程序内部传参),宽容比严格更符合现场;而"名字写错"仍然会被
rclpy 拦下来(参数名是静态的,与类型无关)。
新增节点一律继承 ``TolerantNode``(有守卫测试
``test_ros_node.py::test_no_node_subclasses_rclpy_node_directly`` 钉住)。
"""
from __future__ import annotations
from typing import Any, Optional, Sequence, Union
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node
__all__ = ["TolerantNode", "tolerant_descriptor", "param_as_str"]
def tolerant_descriptor() -> ParameterDescriptor:
"""声明用的描述符:类型跟着**传进来的值**走,不做宽窄匹配。"""
return ParameterDescriptor(dynamic_typing=True)
def param_as_str(node: Node, name: str, default: str = "") -> str:
"""读一个"本质是字符串"的参数,容忍传进来的是数字。
为什么需要:现场 `site.yaml` 里 `serial_number: 26610799`(不加引号)就是
个 YAML 整数,而节点里想要的是字符串。rclpy 同样会因类型不匹配在**声明**
阶段抛异常 —— 而字符串参数在运维手上最容易"看着是数字"。
以前这段逻辑在三个节点里各抄了一份(`dh_pgea_node` / `handeye_teach` /
`realsense_color_pub`),相同的东西抄三份迟早歪(`FRAMEWORK_RULES.md` R5.4)。
现在只此一处;取不到就返回 `default`。
"""
node.declare_parameter(name, default, tolerant_descriptor())
value = node.get_parameter(name).value
if value is None:
return default
return str(value).strip()
class TolerantNode(Node):
"""`rclpy.node.Node` + 「参数类型宽容」。
只改 `declare_parameter` 一件事:默认描述符换成动态类型。
调用方若自己传了 `descriptor`(例如只想放宽某几个),以调用方为准。
"""
def declare_parameter( # type: ignore[override]
self,
name: str,
value: Any = None,
descriptor: Optional[ParameterDescriptor] = None,
ignore_override: bool = False,
) -> Any:
if descriptor is None:
descriptor = tolerant_descriptor()
return super().declare_parameter(name, value, descriptor, ignore_override)
# 列表版本是 rclpy 的另一个入口,同样要放宽(`workspace_xyz_min: [1, 2, 3]`
# 这种"整数字面量的列表"喂给 DOUBLE_ARRAY 一样会崩)。
def declare_parameters( # type: ignore[override]
self,
namespace: str,
parameters: Sequence[
Union[tuple, Any]
],
ignore_override: bool = False,
) -> Any:
fixed = []
for item in parameters:
if (
isinstance(item, tuple)
and len(item) == 2
and not isinstance(item[1], ParameterDescriptor)
):
fixed.append((item[0], item[1], tolerant_descriptor()))
else:
fixed.append(item)
return super().declare_parameters(namespace, fixed, ignore_override)
@@ -12,6 +12,8 @@ from rclpy.node import Node
from arm_application.site_io import joint_prefix_default
from arm_msgs.action import FollowJointTrajectory
from arm_application.ros_node import TolerantNode
DEFAULT_JOINTS = [
'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7',
]
@@ -25,7 +27,7 @@ DEFAULT_POSITIONS = [
]
class RunJointTrajectory(Node):
class RunJointTrajectory(TolerantNode):
def __init__(self) -> None:
super().__init__('run_joint_trajectory')
self.declare_parameter('joint_prefix', joint_prefix_default())
File diff suppressed because it is too large Load Diff
@@ -80,3 +80,137 @@ def ee_frame_default() -> str:
sn = os.environ.get("SITE_ROBOT_SN", "").strip()
return f"{sn}_flange" if sn else ""
def overhead_camera(
site: dict[str, Any], robot: dict[str, Any] | None = None
) -> tuple[dict[str, Any], list[str]]:
"""手外(固定)相机 → ``(相机配置字典, 同样合格但未被选中的相机名)``。
手外相机 = ``cameras.*`` 里 ``role`` 以 ``overhead`` 开头的那台(眼在手外时
相机固定在工位外,不随臂动)。
**为什么必须有唯一实现**:这份"按 role 找,再按 arm 收敛"的规则有两个调用方——
S03 粗定位取命名空间(本模块给节点用)、手眼标定脚本取相机名。两边各写一份的
后果不是报错,而是"标定用的相机"和"推理用的相机"悄悄不是同一台,且没人发现。
所以规则只写在这里,两边都调它(`export_site_env.overhead_camera` 也走这里)。
收敛规则(确定性的三级):
1. ``arm`` 与当前臂相同的优先(仿真 ``sim_d435`` 靠这级选中);
2. 都没有 ``arm``(= 双臂共用的那台)时回落到它;
3. 还是多个 → 取第一个,并把其余名字放进第二个返回值,让调用方**出声告警**。
"""
cams = [
cfg
for cfg in (site.get("cameras") or {}).values()
if isinstance(cfg, dict)
and str(cfg.get("role") or "").strip().lower().startswith("overhead")
]
if not cams:
return {}, []
arm = str((robot or {}).get("arm") or "").strip()
mine = [c for c in cams if str(c.get("arm") or "").strip() == arm] if arm else []
hits = mine or [c for c in cams if not str(c.get("arm") or "").strip()] or cams
chosen = dict(hits[0])
others = [str(c.get("name") or "?") for c in hits[1:]]
return chosen, others
def eye_to_hand_result_name(short: str, camera_name: str) -> str:
"""眼在手外结果文件的**文件名**(不含目录)。
外参绑定「这条臂的基座 + 这台相机」:换条臂或换台相机都不通用,
所以名字必须同时带上臂号与相机。调用方只许调这里,不许自己拼
(launch / export_site_env / S03 找文件都走这一处)。
"""
short = str(short or "").strip()
cam = str(camera_name or "").strip().strip("/")
if short and cam:
return f"{short}_{cam}_eye_to_hand.yaml"
if cam:
return f"{cam}_eye_to_hand.yaml"
if short:
return f"{short}_eye_to_hand.yaml"
return "eye_to_hand.yaml"
def resolve_eye_to_hand_file(
root: Path,
*,
explicit: str = "",
camera_name: str = "",
) -> Path | None:
"""找手外相机的手眼文件。没有就返回 None。
路径一律按 ``root`` 解析,不跟当前工作目录走——相对 CWD 的路径会在
"换个目录启动"时静默失效(文件找不到 → S03 直接降级),而人只会怀疑标定。
候选顺序:
1. ``explicit`` —— launch 按 site.yaml 派生的确切路径
(``<臂号>_<相机>_eye_to_hand.yaml``);
2. ``<相机名>_eye_to_hand.yaml`` —— 旧命名 / 仿真(``sim_d435_...``);
3. ``eye_to_hand.yaml`` —— 再兜底。
"""
candidates: list[Path] = []
if explicit:
p = Path(explicit).expanduser()
candidates.append(p if p.is_absolute() else root / p)
cam = str(camera_name or "").strip().strip("/")
if cam:
candidates.append(root / "handeye" / "results" / f"{cam}_eye_to_hand.yaml")
candidates.append(root / "handeye" / "results" / "eye_to_hand.yaml")
for cand in candidates:
if cand.is_file():
return cand
return None
TRACK_CAMERA_ROLES = ("wrist", "overhead")
def track_camera_role(site: dict[str, Any] | None) -> str:
"""一阶段跟踪用哪台相机:``wrist``(腕部 D405)或 ``overhead``(顶置 D435)。
写错**当场报错**,不会退回腕部 —— 退回会让人以为在验 D435,其实还在用 D405。
缺省 ``wrist``:没写这个键时行为与改之前一致(腕部追踪)。
"""
perc = (site or {}).get("perception")
perc = perc if isinstance(perc, dict) else {}
raw = perc.get("track_camera")
if raw is None or str(raw).strip() == "":
return "wrist"
role = str(raw).strip().lower()
if role not in TRACK_CAMERA_ROLES:
raise ValueError(
f"perception.track_camera 只能是 wrist 或 overhead,收到 {raw!r}。"
"写错不会退回腕部 —— 退回会让人以为在用 D435 其实还在用 D405。"
)
return role
def track_handeye_mode(role: str) -> str:
"""跟踪相机角色 → 手眼装法。overhead 必须用 cam→基座,不能用 cam→法兰。"""
if str(role or "").strip().lower() == "overhead":
return "eye_to_hand"
return "eye_in_hand"
def handeye_static_tf_frames(
*,
mode: str,
ee_frame: str,
base_frame: str,
cam_frame: str,
) -> tuple[str, str]:
"""手眼静态 TF 的 ``(父系, 子系)``。
两种装法的父子关系**不同**,不能混:
* ``eye_in_hand``:法兰 ← 相机(相机跟着臂动)
* ``eye_to_hand``:基座 ← 相机(相机固定,不随臂动)
把眼在手外的文件按"法兰 ← 相机"发出去,会得到一条**看着合理**的错 TF ——
定位总能到附近、永远差一截,而人只会去怀疑标定精度。
"""
if str(mode or "").strip().lower() == "eye_to_hand":
return str(base_frame), str(cam_frame)
return str(ee_frame), str(cam_frame)
File diff suppressed because it is too large Load Diff
@@ -104,6 +104,18 @@ def tactile_timeout_reason(pairs: Sequence[Any], timeout_s: float) -> str:
)
return f"触觉话题断流 >{timeout_s:.1f}s(左右指都无新鲜数据)"
def should_lift_air_baseline(
z_now_m: float, z_air_m: float, *, tactile_live: bool
) -> bool:
"""空载抬升只在触觉已经在发、并且当前低于空载高度时做。
触觉没数据时先抬到 hover+50mm 再报断流,等于插孔做到一半把臂送回锁高。
"""
if not tactile_live:
return False
return float(z_now_m) < float(z_air_m) - 0.008
def spiral_points(
cx: float, cy: float, pitch_mm: float, step_mm: float, r_max_mm: float
) -> List[Tuple[float, float]]:
@@ -514,7 +526,8 @@ class Stage3Search(Stage2Locate):
tgt[2] += dz_m
self._goto(q, Tee, tgt, verticalize=True, tol_m=0.001,
tol_tilt=self.vert_tol_rad * 3.0, tag="retract",
v=0.03, max_hops=3, settle_s=0.2, min_dur=0.6)
v=0.03, max_hops=2, settle_s=0.2, min_dur=0.6,
hold_xy=True, z_up_only=True)
except Exception:
pass
@@ -581,9 +594,21 @@ class Stage3Search(Stage2Locate):
result: Dict[str, Any] = {"ok": False, "stage": "stage3", "target_hole": self.target_hole}
try:
# 基线必须悬空测。二阶段结束在 hover≈触面+1mm,带载会把阈值抬飞。
# 触觉没在发就原地中止,不许先抬到锁高再报断流。
z_now = float(Tee[2, 3])
z_air = float(self.hover_z) + 0.05
if z_now < z_air - 0.008:
self._spin(0.05)
tac_live = bool(
live_tactile(time.monotonic(), (self._tac_l, self._tac_r))
)
if not tac_live:
raise SearchAbort(
tactile_timeout_reason(
(self._tac_l, self._tac_r), float(self.tac_timeout)
),
code=7,
)
if should_lift_air_baseline(z_now, z_air, tactile_live=True):
self._state("air_baseline")
self.get_logger().info(
f"抬到 {z_air:.3f}m 测触觉空载基线(当前 Z={z_now:.3f})"
@@ -22,8 +22,10 @@ from arm_application.site_io import load_site_dict, site_path
from arm_msgs.msg import TactileStatus
from sensor_msgs.msg import Image
from arm_application.ros_node import TolerantNode
class TactileNode(Node):
class TactileNode(TolerantNode):
def __init__(self) -> None:
super().__init__("tactile")
self.declare_parameter("publish_hz", 20.0)
@@ -176,6 +176,51 @@ def staging_dir(root: Union[str, Path]) -> Path:
return Path(root) / STAGING_DIRNAME
def infer_group(
root: Union[str, Path],
source: Union[str, Path],
fallback: str = "yolo",
) -> str:
"""转换产物该落到**哪个分组**:默认沿用源文件所在的分组。
为什么不能一律落到 ``yolo``(2026-09-28 真机踩到,代价是一次白转)
----------------------------------------------------------------
现场把 ``models/hole_insert/detect_best_1.onnx`` 转 engine,工具默认
``--group yolo``,于是产物落到 ``models/yolo/detect_best_1.engine`` ——
而运行时找 engine 的规则是**同级同名**(``yolo_detector.resolve_weights``:
``p.with_suffix(".engine")``,即 site.yaml 指的 ``.onnx`` 旁边)。两边
一分家,engine 就等于不存在:现象是"启动即报缺 engine",而文件明明躺着。
这正是本项目最忌讳的**静默失效** —— 工具没报错,位置也"合理"。
规则:
- 源文件已在权重根目录下的某个分组里(例如 ``models/hole_insert/x.onnx``)
→ 沿用 ``hole_insert``。这样"就地转换"永远落在原地,运行时必然找得到;
- 源文件在 ``staging/``、权重根目录之外或根目录正下方 → ``fallback``
(默认 ``yolo``):那才是"新来的模型,还没决定分组"的正常情形。
Args:
root: 权重根目录(``models/``)。
source: 输入文件。
fallback: 无法沿用时的分组名。
Returns:
分组名(不带路径)。
"""
r = Path(root)
try:
rel = Path(source).expanduser().resolve().relative_to(r.resolve())
except ValueError:
# 不在权重根目录下(/tmp、外部磁盘、staging 之类)→ 没有"原地"可言
return fallback
if len(rel.parts) < 2:
# 直接躺在 models/ 正下方:没有分组信息,用默认分组
return fallback
group = rel.parts[0]
if is_staging(r / group) or group.startswith("."):
return fallback
return group
def staging_files(root: Union[str, Path]) -> List[Path]:
"""staging 里的待处理文件。
@@ -43,6 +43,9 @@ class Detection:
conf: float
cls_id: int
cls_name: str
# pose 模型才有:(K, 3) = 每个关键点的 x, y, conf(**原图像素坐标**)。
# 检测模型恒为 None —— 下游必须先判 None,别假设它有。
kpts: Optional[np.ndarray] = None
@property
def cx(self) -> float:
@@ -301,12 +304,51 @@ class YoloDetector:
return seq, dets, (time.monotonic() - t0) * 1000.0
def build_detector(
weights: Union[str, Path],
*,
backend: str = "auto",
conf: float = 0.25,
iou: float = 0.45,
device: str = "",
imgsz: int = 640,
):
"""按权重类型挑检测器:pose 模型走 ``PoseDetector``,否则走 ``YoloDetector``。
为什么要这个工厂:ultralytics 解不了 pose 的 TensorRT engine(见
``pose_detector`` 模块头),所以 pose 必须换成自解码实现。判断依据是模型
是不是 ``task=pose`` —— 直接读旁边 ``.onnx`` 的 metadata,不靠调用方声明。
返回对象统一有 ``detect()/infer()/warmup()/imgsz/class_names/class_ids``。
"""
path = Path(weights).expanduser()
from .pose_detector import PoseDetector, is_pose_model
if is_pose_model(path):
logger.info("检测到 pose 模型 %s → 用 PoseDetector(自解码关键点)", path.name)
return PoseDetector(
path,
conf=conf,
iou=iou,
device=device or "0",
imgsz=imgsz,
)
return YoloDetector(
path,
conf=conf,
iou=iou,
device=device,
imgsz=imgsz,
)
def _yolo_worker_main(q_in, q_out, cfg: dict) -> None:
"""spawn 出来的子进程:只跑 GPU 上的 YOLO。这里绝不能 import rclpy。"""
"""spawn 出来的子进程:只跑 GPU 上的检测。这里绝不能 import rclpy。"""
try:
path = resolve_weights(cfg["weights"], cfg.get("backend") or "auto")
det = YoloDetector(
det = build_detector(
path,
backend=cfg.get("backend") or "auto",
conf=float(cfg.get("conf") or 0.25),
iou=float(cfg.get("iou") or 0.45),
device=str(cfg.get("device") or "0"),
@@ -336,7 +378,13 @@ def _yolo_worker_main(q_in, q_out, cfg: dict) -> None:
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))
(
d.xyxy.astype(np.float32),
float(d.conf),
int(d.cls_id),
str(d.cls_name),
None if d.kpts is None else np.asarray(d.kpts, dtype=np.float32),
)
for d in dets
]
_put_drop_old(q_out, ("ok", seq, packed, gpu_ms))
@@ -399,15 +447,20 @@ class YoloInferProcess:
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:
def submit(self, image_bgr: np.ndarray, seq: int = 0) -> bool:
"""把最新一帧排进队列。如果 worker 正忙就跳过 —— 它在每次推理后都会
排空到队列里最新的帧,所以不会跑任何过期数据。"""
排空到队列里最新的帧,所以不会跑任何过期数据。
返回是否真的进队。进不去必须告诉调用方,否则一次性会以为「已经在跑」
而空等,把 D405 也卡住。
"""
if not self.alive:
raise RuntimeError("YOLO 工作进程已死亡")
try:
self._q_in.put((int(seq), image_bgr), timeout=0.010)
return True
except Exception:
pass
return False
def wait_result(self, timeout: float = 0.045):
"""返回 ``(seq, list[Detection], gpu_ms)`` 或 ``None``。"""
@@ -444,15 +497,20 @@ class YoloInferProcess:
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),
dets = []
for row in packed:
# 兼容旧格式(4 元组,无关键点),免得子进程和父进程版本不一致时炸掉。
xyxy, conf, cid, name = row[0], row[1], row[2], row[3]
kpts = row[4] if len(row) > 4 else None
dets.append(
Detection(
xyxy=np.asarray(xyxy, dtype=float),
conf=float(conf),
cls_id=int(cid),
cls_name=str(name),
kpts=None if kpts is None else np.asarray(kpts, dtype=float),
)
)
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):
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
../../src/arm_application/setup.py
/home/cc/ros2/src/arm_application/setup.py
File diff suppressed because it is too large Load Diff
@@ -22,283 +22,6 @@ CMakeFiles/flexiv_hardware.dir/src/flexiv_hardware_interface.cpp.o
/home/cc/anaconda3/envs/ros_flexiv/include/class_loader/class_loader/visibility_control.hpp
/home/cc/anaconda3/envs/ros_flexiv/include/console_bridge/console.h
/home/cc/anaconda3/envs/ros_flexiv/include/console_bridge/console_bridge_export.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/Cholesky
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/Core
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/Dense
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/Eigen
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/Eigenvalues
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/Geometry
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/Householder
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/IterativeLinearSolvers
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/Jacobi
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/LU
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/OrderingMethods
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/QR
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/SVD
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/Sparse
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/SparseCholesky
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/SparseCore
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/SparseLU
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/SparseQR
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/Version
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Cholesky/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Cholesky/LDLT.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Cholesky/LLT.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/ArithmeticSequence.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Array.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/ArrayBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/ArrayWrapper.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Assign.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/AssignEvaluator.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/BandMatrix.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Block.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/CommaInitializer.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/ConditionEstimator.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/CoreEvaluators.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/CoreIterators.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/CwiseBinaryOp.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/CwiseNullaryOp.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/CwiseTernaryOp.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/CwiseUnaryOp.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/CwiseUnaryView.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/DenseBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/DenseCoeffsBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/DenseStorage.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/DeviceWrapper.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Diagonal.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/DiagonalMatrix.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/DiagonalProduct.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Dot.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/EigenBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Fill.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/FindCoeff.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Fuzzy.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/GeneralProduct.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/GenericPacketMath.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/GlobalFunctions.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/IO.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/IndexedView.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/InnerProduct.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Inverse.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Map.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/MapBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/MathFunctions.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/MathFunctionsImpl.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Matrix.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/MatrixBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/NestByValue.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/NoAlias.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/NumTraits.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/PartialReduxEvaluator.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/PermutationMatrix.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/PlainObjectBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Product.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/ProductEvaluators.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Random.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/RandomImpl.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/RealView.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Redux.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Ref.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Replicate.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Reshaped.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/ReturnByValue.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Reverse.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Select.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/SelfAdjointView.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/SelfCwiseBinaryOp.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/SkewSymmetricMatrix3.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Solve.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/SolveTriangular.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/SolverBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/StableNorm.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/StlIterators.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Stride.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Swap.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Transpose.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Transpositions.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/TriangularMatrix.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/VectorBlock.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/VectorwiseOp.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/Visitor.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/arch/Default/BFloat16.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/arch/Default/ConjHelper.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/arch/Default/GenericPacketMathFunctions.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/arch/Default/GenericPacketMathFunctionsFwd.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/arch/Default/Half.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/arch/Default/Settings.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/arch/SSE/Complex.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/arch/SSE/MathFunctions.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/arch/SSE/PacketMath.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/arch/SSE/Reductions.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/arch/SSE/TypeCasting.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/functors/AssignmentFunctors.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/functors/BinaryFunctors.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/functors/NullaryFunctors.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/functors/StlFunctors.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/functors/TernaryFunctors.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/functors/UnaryFunctors.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/GeneralBlockPanelKernel.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/GeneralMatrixMatrix.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/GeneralMatrixMatrixTriangular.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/GeneralMatrixVector.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/Parallelizer.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/SelfadjointMatrixMatrix.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/SelfadjointMatrixVector.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/SelfadjointProduct.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/SelfadjointRank2Update.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/TriangularMatrixMatrix.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/TriangularMatrixVector.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/TriangularSolverMatrix.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/products/TriangularSolverVector.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/Assert.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/BlasUtil.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/ConfigureVectorization.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/Constants.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/DisableStupidWarnings.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/EmulateArray.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/ForwardDeclarations.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/IndexedViewHelper.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/IntegralConstant.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/MKL_support.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/Macros.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/Memory.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/Meta.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/MoreMeta.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/ReenableStupidWarnings.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/ReshapedHelper.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/Serializer.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/StaticAssert.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/SymbolicIndex.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Core/util/XprHelper.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/ComplexEigenSolver.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/ComplexSchur.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/EigenSolver.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/GeneralizedEigenSolver.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/GeneralizedSelfAdjointEigenSolver.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/HessenbergDecomposition.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/MatrixBaseEigenvalues.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/RealQZ.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/RealSchur.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/SelfAdjointEigenSolver.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Eigenvalues/Tridiagonalization.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/AlignedBox.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/AngleAxis.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/EulerAngles.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/Homogeneous.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/Hyperplane.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/OrthoMethods.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/ParametrizedLine.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/Quaternion.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/Rotation2D.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/RotationBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/Scaling.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/Transform.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/Translation.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/Umeyama.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Geometry/arch/Geometry_SIMD.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Householder/BlockHouseholder.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Householder/Householder.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Householder/HouseholderSequence.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Householder/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/IterativeLinearSolvers/BasicPreconditioners.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/IterativeLinearSolvers/BiCGSTAB.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/IterativeLinearSolvers/ConjugateGradient.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/IterativeLinearSolvers/IncompleteCholesky.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/IterativeLinearSolvers/IncompleteLUT.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/IterativeLinearSolvers/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/IterativeLinearSolvers/IterativeSolverBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/IterativeLinearSolvers/LeastSquareConjugateGradient.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/IterativeLinearSolvers/SolveWithGuess.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Jacobi/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/Jacobi/Jacobi.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/LU/Determinant.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/LU/FullPivLU.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/LU/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/LU/InverseImpl.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/LU/PartialPivLU.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/LU/arch/InverseSize4.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/OrderingMethods/Amd.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/OrderingMethods/Eigen_Colamd.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/OrderingMethods/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/OrderingMethods/Ordering.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/QR/ColPivHouseholderQR.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/QR/CompleteOrthogonalDecomposition.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/QR/FullPivHouseholderQR.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/QR/HouseholderQR.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/QR/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SVD/BDCSVD.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SVD/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SVD/JacobiSVD.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SVD/SVDBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SVD/UpperBidiagonalization.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCholesky/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCholesky/SimplicialCholesky.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCholesky/SimplicialCholesky_impl.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/AmbiVector.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/CompressedStorage.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/ConservativeSparseSparseProduct.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseAssign.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseBlock.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseColEtree.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseCompressedBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseCwiseBinaryOp.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseCwiseUnaryOp.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseDenseProduct.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseDiagonalProduct.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseDot.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseFuzzy.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseMap.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseMatrix.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseMatrixBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparsePermutation.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseProduct.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseRedux.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseRef.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseSelfAdjointView.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseSolverBase.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseSparseProductWithPruning.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseTranspose.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseTriangularView.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseUtil.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseVector.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/SparseView.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseCore/TriangularSolver.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLUImpl.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_Memory.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_Structs.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_SupernodalMatrix.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_Utils.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_column_bmod.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_column_dfs.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_copy_to_ucol.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_heap_relax_snode.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_kernel_bmod.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_panel_bmod.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_panel_dfs.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_pivotL.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_pruneL.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseLU/SparseLU_relax_snode.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseQR/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/SparseQR/SparseQR.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/misc/Image.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/misc/InternalHeaderCheck.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/misc/Kernel.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/misc/RealSvd2x2.h
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/plugins/ArrayCwiseBinaryOps.inc
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/plugins/ArrayCwiseUnaryOps.inc
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/plugins/BlockMethods.inc
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/plugins/CommonCwiseBinaryOps.inc
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/plugins/CommonCwiseUnaryOps.inc
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/plugins/IndexedViewMethods.inc
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/plugins/MatrixCwiseBinaryOps.inc
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/plugins/MatrixCwiseUnaryOps.inc
/home/cc/anaconda3/envs/ros_flexiv/include/eigen3/Eigen/src/plugins/ReshapedMethods.inc
/home/cc/anaconda3/envs/ros_flexiv/include/hardware_interface/handle.hpp
/home/cc/anaconda3/envs/ros_flexiv/include/hardware_interface/hardware_info.hpp
/home/cc/anaconda3/envs/ros_flexiv/include/hardware_interface/macros.hpp
@@ -787,12 +510,10 @@ CMakeFiles/flexiv_hardware.dir/src/flexiv_hardware_interface.cpp.o
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/cctype
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/cerrno
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/chrono
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/climits
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/clocale
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/cmath
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/codecvt
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/compare
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/complex
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/concepts
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/condition_variable
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/csignal
@@ -872,7 +593,6 @@ CMakeFiles/flexiv_hardware.dir/src/flexiv_hardware_interface.cpp.o
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/utility
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/variant
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/vector
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/version
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/x86_64-conda-linux-gnu/bits/atomic_word.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/x86_64-conda-linux-gnu/bits/basic_file.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/x86_64-conda-linux-gnu/bits/c++allocator.h
@@ -889,17 +609,11 @@ CMakeFiles/flexiv_hardware.dir/src/flexiv_hardware_interface.cpp.o
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/x86_64-conda-linux-gnu/bits/messages_members.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/x86_64-conda-linux-gnu/bits/os_defines.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/x86_64-conda-linux-gnu/bits/time_members.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/emmintrin.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/limits.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/mm_malloc.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/mmintrin.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/stdalign.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/stdarg.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/stdbool.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/stddef.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/stdint.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/syslimits.h
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/xmmintrin.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/alloca.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/arpa/inet.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/asm-generic/bitsperlong.h
@@ -933,15 +647,12 @@ CMakeFiles/flexiv_hardware.dir/src/flexiv_hardware_interface.cpp.o
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/iscanonical.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/libc-header-start.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/libm-simd-decl-stubs.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/local_lim.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/locale.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/long-double.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/math-vector.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/mathcalls-helper-functions.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/mathcalls-narrow.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/mathcalls.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/posix1_lim.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/posix2_lim.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/posix_opt.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/pthread_stack_min-dynamic.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/pthreadtypes-arch.h
@@ -1014,14 +725,12 @@ CMakeFiles/flexiv_hardware.dir/src/flexiv_hardware_interface.cpp.o
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/types/wint_t.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/typesizes.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/uintn-identity.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/uio_lim.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/unistd_ext.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/waitflags.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/waitstatus.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/wchar.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/wctype-wchar.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/wordsize.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/bits/xopen_lim.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/ctype.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/endian.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/errno.h
@@ -1031,10 +740,8 @@ CMakeFiles/flexiv_hardware.dir/src/flexiv_hardware_interface.cpp.o
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/gnu/stubs.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/ifaddrs.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/libintl.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/limits.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/linux/close_range.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/linux/errno.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/linux/limits.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/linux/posix_types.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/linux/stddef.h
/home/cc/anaconda3/envs/ros_flexiv/x86_64-conda-linux-gnu/sysroot/usr/include/locale.h
@@ -1063,7 +770,6 @@ CMakeFiles/flexiv_hardware.dir/src/flexiv_hardware_interface.cpp.o
/home/cc/flexiv_install/include/flexiv/rdk/data.hpp
/home/cc/flexiv_install/include/flexiv/rdk/mode.hpp
/home/cc/flexiv_install/include/flexiv/rdk/robot.hpp
/home/cc/flexiv_install/include/flexiv/rdk/utility.hpp
/home/cc/ros2/src/flexiv_hardware/include/flexiv_hardware/flexiv_hardware_interface.hpp
/home/cc/ros2/src/flexiv_hardware/include/flexiv_hardware/visibility_control.h
File diff suppressed because it is too large Load Diff
@@ -237,10 +237,16 @@ CMakeFiles/flexiv_hardware.dir/src/flexiv_hardware_interface.cpp.o: \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/x86_64-conda-linux-gnu/bits/basic_file.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/x86_64-conda-linux-gnu/bits/c++io.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/fstream.tcc \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/map \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/stl_tree.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/ext/aligned_buffer.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/node_handle.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/stl_map.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/stl_multimap.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/erase_if.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/variant \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/enable_special_members.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/monostate.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/ext/aligned_buffer.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/thread \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/std_thread.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/unique_ptr.h \
@@ -328,11 +334,8 @@ CMakeFiles/flexiv_hardware.dir/src/flexiv_hardware_interface.cpp.o: \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/std_function.h \
/home/cc/anaconda3/envs/ros_flexiv/include/rclcpp/rclcpp/executors/multi_threaded_executor.hpp \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/set \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/stl_tree.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/node_handle.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/stl_set.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/stl_multiset.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/erase_if.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/unordered_map \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/unordered_map.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/hashtable.h \
@@ -344,9 +347,6 @@ CMakeFiles/flexiv_hardware.dir/src/flexiv_hardware_interface.cpp.o: \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/list \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/stl_list.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/list.tcc \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/map \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/stl_map.h \
/home/cc/anaconda3/envs/ros_flexiv/lib/gcc/x86_64-conda-linux-gnu/15.2.0/include/c++/bits/stl_multimap.h \
/home/cc/anaconda3/envs/ros_flexiv/include/rcl/rcl/guard_condition.h \
/home/cc/anaconda3/envs/ros_flexiv/include/rcl/rcl/allocator.h \
/home/cc/anaconda3/envs/ros_flexiv/include/rcutils/rcutils/allocator.h \
+1 -1
View File
@@ -1 +1 @@
['-DCMAKE_BUILD_TYPE=Release']
['-DAMENT_CMAKE_SYMLINK_INSTALL=1']
File diff suppressed because one or more lines are too long
Binary file not shown.