主界面一屏要同时看臂、夹爪、相机和触觉,按钮再被拉高就会叠到画面上。相机按原分辨率走回调取最新帧,场景一动不再等红外齐帧把彩色丢掉。模型和相机都留在工位窗口里,启动默认不再弹 RViz。

验收:test_listed_arms 与 test_camera_preview 共 20 项通过。D435 1280×720 发布约 29 帧/秒。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-10-15 16:20:35 +08:00
parent 76c9b9cc49
commit 7942014763
44 changed files with 1664 additions and 599 deletions
+59 -4
View File
@@ -213,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 客户端(官方寄存器映射)。"""
@@ -257,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:
@@ -562,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(
File diff suppressed because it is too large Load Diff
@@ -2,9 +2,11 @@
只订彩色和已对齐深度,不订左右红外。画面留在本进程,不另开查看器窗口。
回调里先丢掉当前页没在看的流。彩色和深度都缩成宽不超过 480 的 RGB,
界面直接贴这块小图。不再每帧把整幅图拷进 Python,也不在界面线程里解 JPEG。
手眼页单独要一张更宽的 JPEG,只在那一页、那一路彩色上编码。
回调里先丢掉当前页没在看的流。彩色按相机原分辨率收进来,不按时钟抽帧,
也不在收图的线程里做颜色转换。另一条线只画最新的一张,所以帧率跟相机走,
848 和 1280 都不会为了帧率被缩成 480。深度是伪彩色,仍然抽稀,
而且没打开深度时不订阅,避免大图挤占彩色。
手眼页要 JPEG 时也按原分辨率编码,只编正在看的那一路。
"""
from __future__ import annotations
@@ -15,12 +17,15 @@ import time
import numpy as np
# 主界面同时看几路彩色。480 宽够看清,界面缩放的代价也小。
_PREVIEW_WIDTH = 480
# 手眼要在这张图上找棋盘,比预览宽一档,但仍然不把 1280 原图编码。
_JPEG_WIDTH = 960
# 主界面要看清运动和推理框,10Hz 太顿。0.05s 约 20 帧。
_INTERVAL = 0.05
# 彩色上限。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()
@@ -34,6 +39,16 @@ _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}
@@ -63,13 +78,60 @@ def preview_targets(site: dict) -> list[dict]:
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``。"""
"""当前站点的主界面真机相机。规则在 ``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 {
@@ -91,10 +153,32 @@ def previews() -> dict[str, tuple[bytes, int, int]]:
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。两边都空着就只保持订阅。
``preview`` 走原分辨率 RGB,``jpeg`` 走手眼用的 JPEG。两边都空着就只保持订阅。
"""
global _PREVIEW_WANT, _JPEG_WANT
jpeg = set(jpeg or ())
@@ -114,8 +198,112 @@ def wants(key: str) -> tuple[bool, bool]:
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。"""
"""这一帧要不要做进预览。没人看,或者距上一帧不到间隔,都返回 False。
彩色实况不再走这里。固定间隔会把相机自己的帧率再砍掉一截。
"""
now = time.monotonic()
with _LOCK:
if key not in _PREVIEW_WANT and key not in _JPEG_WANT:
@@ -253,7 +441,7 @@ def _stamp_role(role: str) -> None:
_STATE["stamp"] = time.time()
def rgb_preview(image: np.ndarray, *, bgr: bool, limit: int = _PREVIEW_WIDTH) -> np.ndarray:
def rgb_preview(image: np.ndarray, *, bgr: bool, limit: int = _COLOR_WIDTH) -> np.ndarray:
"""缩成宽不超过 ``limit`` 的 RGB。返回的数组自己持有内存,不跟调用方共用缓冲。"""
import cv2
@@ -271,7 +459,7 @@ def rgb_preview(image: np.ndarray, *, bgr: bool, limit: int = _PREVIEW_WIDTH) ->
return view
def depth_preview(depth: np.ndarray, limit: int = _PREVIEW_WIDTH) -> np.ndarray:
def depth_preview(depth: np.ndarray, limit: int = _DEPTH_WIDTH) -> np.ndarray:
"""uint16 深度画成 RGB。百分位只在抽稀后的样本上算,无效像素保持黑色。"""
import cv2
@@ -374,6 +562,7 @@ def _loop(cams: list[dict]) -> None:
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")
@@ -383,12 +572,26 @@ def _loop(cams: list[dict]) -> None:
self._color(name, role),
qos_profile_sensor_data,
)
self.create_subscription(
Image,
f"/{name}/aligned_depth_to_color/image_raw",
self._depth(name),
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:
@@ -399,24 +602,12 @@ def _loop(cams: list[dict]) -> None:
key = f"{name}_color"
def _cb(msg: Image) -> None:
# 先看这路有没有人要。30Hz 的原图不再整幅拷进 Python。
# 这里只留最新原图。颜色转换和画框放到画图线程,避免把后面的帧堵住。
try:
if not accept_frame(key):
return
view = _color_view(msg)
if view is None:
return
preview, jpeg = wants(key)
limit = _JPEG_WIDTH if jpeg else _PREVIEW_WIDTH
rgb = rgb_preview(view, bgr=(msg.encoding == "bgr8"), limit=limit)
shown = rgb
if preview and int(rgb.shape[1]) > _PREVIEW_WIDTH:
shown = rgb_preview(rgb, bgr=False, limit=_PREVIEW_WIDTH)
if preview:
src_h, src_w = view.shape[:2]
_remember_clean(key, shown, role, int(src_w), int(src_h))
shown = _overlay_detection(shown, role, int(src_w), int(src_h))
_store(key, rgb, preview=shown if preview else None, jpeg=jpeg)
offer_color(key, view, bgr=(msg.encoding == "bgr8"), role=role)
except Exception as exc: # noqa: BLE001 —— 坏的一帧留在状态里,订阅不停
with _LOCK:
_STATE["error"] = str(exc)
@@ -427,14 +618,10 @@ def _loop(cams: list[dict]) -> None:
def _cb(msg: Image) -> None:
try:
if not accept_frame(key):
return
view = _depth_view(msg)
if view is None:
return
preview, _jpeg_wanted = wants(key)
colored = depth_preview(view)
_store(key, colored, preview=colored if preview else None, jpeg=False)
offer_depth(key, view)
except Exception as exc: # noqa: BLE001 —— 坏的一帧留在状态里,订阅不停
with _LOCK:
_STATE["error"] = str(exc)
@@ -444,7 +631,8 @@ def _loop(cams: list[dict]) -> None:
node = _Node()
try:
while not _STOP.is_set():
rclpy.spin_once(node, timeout_sec=0.05)
node.sync_depth()
rclpy.spin_once(node, timeout_sec=0.01)
except Exception as exc:
with _LOCK:
_STATE["error"] = str(exc)
@@ -457,8 +645,8 @@ def _loop(cams: list[dict]) -> None:
def start() -> str:
global _THREAD
cams = targets()
global _THREAD, _PAINT_THREAD
cams = stream_targets()
if not cams:
with _LOCK:
_STATE["error"] = "site 里没有可预览的真机相机"
@@ -470,14 +658,18 @@ def start() -> str:
_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} 的彩色和深度,不含左右红外。"
return f"预览订了 {names} 的彩色原图,深度点开才订,不含左右红外。"
def stop() -> None:
_STOP.set()
thread = _THREAD
if thread is not None:
thread.join(timeout=1.5)
_WAKE.set()
for thread in (_THREAD, _PAINT_THREAD):
if thread is not None:
thread.join(timeout=1.5)
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""三阶段串联:视觉跟踪锁定 → 孔定位 → 搜孔并轻轻按压(默认不拧入)。
"""跟踪锁定 → 孔定位。搜孔轻压已取消,本节点不再下压搜孔。
流畅性(相对 bash 杀进程再起节点):
- 相机 / 触觉 / 手眼 TF 全程只起一次
- 一阶段锁定后 SIGTERM 跟踪节点,立刻 pin 当前关节,不回零、不抬臂
- 二阶段已垂直 / 已在悬停高则跳过对应动作;对准完立刻下压搜孔
- 找到孔位后力闭环轻压保持;拧入是后续阶段(--depth 才插入)
- 二阶段已垂直 / 已在悬停高则跳过对应动作;对准完就停,不再下压搜孔
- 旋入不在这个节点里
"""
from __future__ import annotations
@@ -125,7 +125,7 @@ class AssemblyPipeline(Stage3Search):
super().__init__(node_name=node_name)
self.declare_parameter("skip_stage1", False)
self.declare_parameter("skip_stage2", False)
self.declare_parameter("skip_stage3", False) # 默认搜孔+轻压;拧入靠 --depth
self.declare_parameter("skip_stage3", True) # 搜孔轻压已取消,写成 false 也不再下压
self.declare_parameter("d435_move_only", False)
self.declare_parameter("init_z_m", -1.0) # >0:启动后先到这个法兰高度
self.declare_parameter("stage1_timeout_sec", 150.0)
@@ -333,7 +333,9 @@ class AssemblyPipeline(Stage3Search):
def run(self) -> int:
skip1 = bool(self.get_parameter("skip_stage1").value)
skip2 = bool(self.get_parameter("skip_stage2").value)
skip3 = bool(self.get_parameter("skip_stage3").value)
if not bool(self.get_parameter("skip_stage3").value):
self.get_logger().info("搜孔轻压已取消,忽略要求重新打开它的参数")
skip3 = True
if self.d435_move_only:
skip2 = True
skip3 = True
@@ -365,11 +367,7 @@ class AssemblyPipeline(Stage3Search):
return code
self.get_logger().info(
f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm"
+ (
" — 跳过搜孔,停在观测高度(看规尖是否对中;进程退出后臂保持)"
if skip3
else " — 立刻下压搜孔并轻压(不拧入)"
)
" — 搜孔已取消,停在这里。旋入请另跑。"
)
else:
self._state("skip_stage2")
@@ -383,7 +381,7 @@ class AssemblyPipeline(Stage3Search):
)
else:
self.get_logger().info(
"跳过搜孔 —— 臂停在当前高度。请看规尖与孔是否对中;"
"搜孔已取消 —— 臂停在当前高度。"
"15s 后本进程退出,臂保持不动"
)
if final:
@@ -176,6 +176,10 @@ class RealsenseColorPub(TolerantNode):
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()
@@ -309,23 +313,26 @@ class RealsenseColorPub(TolerantNode):
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:
@@ -457,7 +464,9 @@ class RealsenseColorPub(TolerantNode):
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
@@ -508,6 +517,13 @@ class RealsenseColorPub(TolerantNode):
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
@@ -609,7 +625,7 @@ class RealsenseColorPub(TolerantNode):
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:
@@ -702,18 +718,60 @@ class RealsenseColorPub(TolerantNode):
)
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')
@@ -724,44 +782,45 @@ class RealsenseColorPub(TolerantNode):
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 帧才做一次
@@ -794,7 +853,6 @@ class RealsenseColorPub(TolerantNode):
)
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:
@@ -159,7 +159,7 @@ def generate_launch_description() -> LaunchDescription:
[
DeclareLaunchArgument("skip_stage1", default_value="false"),
DeclareLaunchArgument("skip_stage2", default_value="false"),
DeclareLaunchArgument("skip_stage3", default_value="false"),
DeclareLaunchArgument("skip_stage3", default_value="true"),
DeclareLaunchArgument("target_hole", default_value=""),
DeclareLaunchArgument("insert_depth_mm", default_value=""),
DeclareLaunchArgument("screw_pitch_mm", default_value=""),
@@ -51,8 +51,11 @@ def test_color_preview_is_rgb_and_not_wider_than_the_widget_budget() -> None:
wide = np.zeros((720, 1280, 3), np.uint8)
wide[:, :, 0] = 10
fitted = camera_view.rgb_preview(wide, bgr=True)
assert fitted.shape[1] == 480
assert fitted.shape[2] == 3
assert fitted.shape == (720, 1280, 3)
wrist = np.zeros((480, 848, 3), np.uint8)
assert camera_view.rgb_preview(wrist, bgr=False).shape == (480, 848, 3)
assert camera_view._COLOR_WIDTH >= 1280
assert camera_view._INTERVAL <= 1.0 / 30.0 + 1e-9
with pytest.raises(ValueError, match="彩色预览"):
camera_view.rgb_preview(np.zeros((8, 8), np.uint8), bgr=True)
@@ -110,7 +113,7 @@ def test_frame_label_paints_rgb_without_set_pixmap() -> None:
def test_preview_interval_is_fast_enough_for_the_main_view() -> None:
"""主界面要看运动和推理框。退回 0.10s 就是大约 10 帧,这里会红。"""
assert camera_view._INTERVAL <= 0.05
assert camera_view._INTERVAL <= 1.0 / 30.0 + 1e-9
def test_detection_box_scales_onto_the_preview_and_empty_clears() -> None:
@@ -157,6 +160,76 @@ def test_detection_is_drawn_on_the_camera_frame() -> None:
camera_view._PREVIEW.pop("cam_color", None)
def test_color_burst_keeps_native_resolution() -> None:
"""两帧紧挨着来也不能抽掉,预览必须仍是相机原分辨率。"""
camera_view._RAW.clear()
camera_view._PREVIEW.clear()
camera_view._META.clear()
camera_view._STAMPS.clear()
camera_view._PAINTED.clear()
try:
camera_view.watch({"cam_color"}, set())
image = np.zeros((720, 1280, 3), np.uint8)
camera_view.offer_color("cam_color", image, bgr=True, role="wrist")
camera_view.offer_color("cam_color", image, bgr=True, role="wrist")
camera_view.publish_latest()
blob, width, height = camera_view.previews()["cam_color"]
assert (height, width) == (720, 1280)
assert len(blob) == 720 * 1280 * 3
assert camera_view.preview_meta()["cam_color"][:2] == (1280, 720)
finally:
camera_view.watch(set(), set())
camera_view._RAW.clear()
camera_view._PREVIEW.clear()
camera_view._META.clear()
camera_view._STAMPS.clear()
camera_view._PAINTED.clear()
def test_rate_uses_the_last_second_and_keeps_the_source_size() -> None:
camera_view._META.clear()
camera_view._STAMPS.clear()
try:
camera_view._mark_rate("cam_color", 1280, 720)
time.sleep(0.05)
camera_view._mark_rate("cam_color", 1280, 720)
width, height, fps = camera_view.preview_meta()["cam_color"]
assert (width, height) == (1280, 720)
assert fps > 1.0
finally:
camera_view._META.clear()
camera_view._STAMPS.clear()
def test_sim_cameras_are_a_separate_pair_for_the_same_view() -> None:
"""仿真腕部和手外要能在主界面里看,但不能把真机那两路挤掉。"""
site = {
"robot": {
"robots": [
{"sn": "Rizon4-063989", "arm": "left", "camera": {"name": "d405_left"}},
{"sn": "sim", "arm": "sim", "camera": {"name": "sim_d405"}},
]
},
"cameras": {
"d405_left": {"name": "d405_left", "model": "d405", "role": "left_wrist"},
"sim_d405": {"name": "sim_d405", "model": "d405", "role": "sim_wrist", "arm": "sim"},
"d435": {"name": "d435", "model": "d435", "role": "overhead_eye_to_hand"},
"sim_d435": {
"name": "sim_d435",
"model": "d435",
"role": "overhead_eye_to_hand",
"arm": "sim",
},
},
}
real = camera_view.preview_targets(site)
sim = camera_view.sim_preview_targets(site)
both = camera_view.stream_targets(site)
assert [cam["id"] for cam in real] == ["d405_left", "d435"]
assert [cam["id"] for cam in sim] == ["sim_d405", "sim_d435"]
assert [cam["id"] for cam in both] == ["d405_left", "d435", "sim_d405", "sim_d435"]
def test_main_preview_is_left_wrist_and_shared_overhead_only() -> None:
"""右臂腕部和仿真手外不进主界面。左腕和共用 D435 才进。"""
site = {
@@ -14,6 +14,8 @@ from __future__ import annotations
from pathlib import Path
from arm_application.dh_pgea import (
ctrl_values,
open_failure_text,
REG_POSITION,
S_GRIP_ARRIVED,
S_GRIP_CAUGHT,
@@ -94,6 +96,62 @@ def test_force_init_does_not_return_on_the_stale_finished_bit():
assert wrote["n"] == 1
def test_node_owned_port_uses_ctrl_topic_not_a_second_open() -> None:
assert ctrl_values("open", force=30, speed=40) == (0.0, 1000.0, 30.0, 40.0)
assert ctrl_values("close", force=20, speed=10) == (0.0, 0.0, 20.0, 10.0)
assert ctrl_values("pos", "250", force=33, speed=25) == (0.0, 250.0, 33.0, 25.0)
assert ctrl_values("init") == (1.0, 0.0, 50.0, 50.0)
assert ctrl_values("status") is None
try:
ctrl_values("pos", "1200")
except ValueError as exc:
assert "0–1000" in str(exc)
else:
raise AssertionError("超范围位置应当失败")
def test_exclusive_lock_is_not_called_a_power_failure():
exc = OSError(11, "Could not exclusively lock port /dev/ttyUSB0: Resource temporarily unavailable")
text = open_failure_text("/dev/ttyUSB0", exc)
assert "占用" in text
assert "24 V" not in text
def test_a_real_open_failure_still_names_power_and_wiring():
exc = FileNotFoundError(2, "No such file or directory")
text = open_failure_text("/dev/ttyUSB9", exc)
assert "24 V" in text
assert "RS485" in text
def test_parallel_cli_stops_before_start_when_the_port_is_missing(tmp_path):
"""缺串口必须在拉起驱动之前失败,不能去开真口,也不能写回零。"""
import os
import subprocess
root = Path(__file__).resolve().parents[3]
site = (root / "config" / "site.yaml").read_text(encoding="utf-8")
cfg = tmp_path / "site.yaml"
cfg.write_text(site.replace("/dev/ttyUSB0", "/dev/ttyUSB-missing-test"), encoding="utf-8")
env = os.environ.copy()
env["SITE_CONFIG"] = str(cfg)
proc = subprocess.run(
["bash", str(root / "scripts" / "run_gripper.sh"), "--arm", "89", "--cli", "probe"],
cwd=str(root),
env=env,
capture_output=True,
text=True,
timeout=90,
)
out = (proc.stdout or "") + (proc.stderr or "")
assert proc.returncode != 0
assert "不在" in out
assert "先拉起来" not in out
script = (root / "scripts" / "run_gripper.sh").read_text(encoding="utf-8")
assert 'auto="${SITE_GRIP_AUTO_INIT:-true}"' in script
assert '-p auto_init:="${auto}"' in script
def test_official_client_uses_the_same_latch_break():
src = Path(__file__).resolve().parents[1] / "arm_application" / "dh_official.py"
text = src.read_text(encoding="utf-8")
+2 -1
View File
@@ -113,7 +113,8 @@ def test_main_page_holds_status_sim_cameras_tactile_and_actions():
texts = [button.text() for button in main.findChildren(QPushButton)]
for name in ("跟踪并对孔", "只测旋转装配", "复位到 home", "本臂腕部相机"):
assert any(name in text for text in texts)
assert any(text.startswith("⏻") for text in texts)
power = [button for button in main.findChildren(QPushButton) if "上电控制栈" in button.text()]
assert power and not power[0].icon().isNull()
assert win._cam_pic
assert all(label.minimumHeight() <= 96 for label in win._cam_pic.values())
from arm_application.operator_camera_view import targets
@@ -402,6 +402,27 @@ def test_cameras_skips_sim_arm(tmp_path: Path) -> None:
# ---------------------------------------------------------------------------
def test_joint_angles_survive_status_packing() -> None:
"""关节角曾经让 classify_state(**state) 抛 TypeError,JSON 出不来,界面不更新。"""
import rdk_status
from rdk_helpers import rdk_whitelist_lists
payload = rdk_status.pack_status({
"connected": True,
"operational": True,
"op_status": "READY",
"mode": "IDLE",
"fault": False,
"estop_released": True,
"error": "",
"q": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7],
})
assert payload["q"] == [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]
assert payload["level"] == rdk_status.LEVEL_OK
# 内核把 USB 网卡从 eth0 改名后,白名单必须仍是本机机器人网段地址。
assert rdk_whitelist_lists("eth0", "192.168.2.10")[0] == ["192.168.2.10"]
def test_network_action_appears_once_in_the_manual_list() -> None:
"""check_network 与 rdk_status 给的"去查网线"必须是**同一串字**。
@@ -15,6 +15,7 @@ site.yaml 有两层:顶层是站点配置,``robots[]`` 里每台臂可以覆
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Iterable, List, Mapping
@@ -94,3 +95,55 @@ def test_sim_robot_carries_the_truth_backend_override(arm_section_keys):
assert sim, "site.yaml 里没有 sim 机器人 —— 仿真流程会挑不到配置"
src = (sim[0].get("perception") or {}).get("source")
assert src == "truth", f"sim 的 perception.source 应为 truth,现在是 {src!r}"
def _flatten_for(sn: str) -> dict:
import export_site_env # type: ignore
previous = os.environ.get("ROBOT_SN")
os.environ["ROBOT_SN"] = sn
try:
return export_site_env.flatten(export_site_env.load_site())
finally:
if previous is None:
os.environ.pop("ROBOT_SN", None)
else:
os.environ["ROBOT_SN"] = previous
def test_live_left_gripper_does_not_auto_home(arm_section_keys):
"""左臂现场 auto_init 是 false。拉起驱动若把它弄成 true,会写 0xA5 回零。"""
flat = _flatten_for("89")
assert flat["SITE_GRIP_AUTO_INIT"] == "false"
assert flat["SITE_GRIP_PORT"] == "/dev/ttyUSB0"
def test_auto_init_override_is_not_ignored(arm_section_keys, tmp_path):
"""把现场改成 true 必须真的变成 true。否则上面那条是 exporter 写死的假通过。"""
import export_site_env # type: ignore
doc = yaml.safe_load((ROOT / "config" / "site.yaml").read_text(encoding="utf-8"))
changed = False
for robot in robot_entries(doc):
if str(robot.get("sn") or "").endswith("063989"):
robot["gripper"]["auto_init"] = True
changed = True
assert changed
cfg = tmp_path / "site.yaml"
cfg.write_text(yaml.safe_dump(doc, allow_unicode=True), encoding="utf-8")
previous_cfg = os.environ.get("SITE_CONFIG")
previous_sn = os.environ.get("ROBOT_SN")
os.environ["SITE_CONFIG"] = str(cfg)
os.environ["ROBOT_SN"] = "89"
try:
flat = export_site_env.flatten(export_site_env.load_site())
finally:
if previous_cfg is None:
os.environ.pop("SITE_CONFIG", None)
else:
os.environ["SITE_CONFIG"] = previous_cfg
if previous_sn is None:
os.environ.pop("ROBOT_SN", None)
else:
os.environ["ROBOT_SN"] = previous_sn
assert flat["SITE_GRIP_AUTO_INIT"] == "true"