495 lines
16 KiB
Python
Executable File
495 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""同时预览并检查两台 D405 和一台 D455 的彩色画面。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from collections import deque
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
import threading
|
||
import time
|
||
from typing import Any
|
||
|
||
|
||
DEFAULT_WIDTH = 640
|
||
DEFAULT_HEIGHT = 480
|
||
DEFAULT_FPS = 30
|
||
DEFAULT_LEFT_SERIAL = "260322272273"
|
||
FPS_WINDOW_SECONDS = 2.0
|
||
WARMUP_SECONDS = 5.0
|
||
MAX_DROP_RATE = 0.01
|
||
MIN_FPS_RATIO = 0.9
|
||
PREVIEW_TILE_WIDTH = 640
|
||
WINDOW_NAME = "XR RM - Three RealSense Camera Test"
|
||
OUTPUT_DIR = Path(__file__).resolve().parents[1] / "test" / "camera_test_output"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DeviceInfo:
|
||
name: str
|
||
model: str
|
||
serial: str
|
||
usb_type: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CameraAssignment:
|
||
role: str
|
||
name: str
|
||
model: str
|
||
serial: str
|
||
usb_type: str
|
||
|
||
|
||
def assign_camera_roles(
|
||
devices: list[DeviceInfo], left_serial: str | None
|
||
) -> list[CameraAssignment]:
|
||
d405 = sorted(
|
||
(device for device in devices if device.model == "D405"),
|
||
key=lambda device: device.serial,
|
||
)
|
||
d455 = [device for device in devices if device.model == "D455"]
|
||
if len(d405) != 2 or len(d455) != 1 or len(devices) != 3:
|
||
raise ValueError(
|
||
f"需要连接 2 台 D405 和 1 台 D455,当前识别到 "
|
||
f"{len(d405)} 台 D405、{len(d455)} 台 D455、共 {len(devices)} 台 RealSense"
|
||
)
|
||
|
||
for device in devices:
|
||
if not device.usb_type.startswith("3"):
|
||
raise ValueError(
|
||
f"{device.model} ({device.serial}) 当前为 USB {device.usb_type},"
|
||
"请检查扩展坞和数据线"
|
||
)
|
||
|
||
global_camera = CameraAssignment("GLOBAL", **d455[0].__dict__)
|
||
if left_serial is None:
|
||
arms = [
|
||
CameraAssignment(f"D405-{suffix}", **device.__dict__)
|
||
for suffix, device in zip(("A", "B"), d405)
|
||
]
|
||
else:
|
||
matches = [device for device in d405 if device.serial == left_serial]
|
||
if not matches:
|
||
raise ValueError(f"左臂序列号 {left_serial} 不属于当前连接的 D405")
|
||
left = matches[0]
|
||
right = next(device for device in d405 if device.serial != left_serial)
|
||
arms = [
|
||
CameraAssignment("LEFT", **left.__dict__),
|
||
CameraAssignment("RIGHT", **right.__dict__),
|
||
]
|
||
return [global_camera, *arms]
|
||
|
||
|
||
def snapshot_filenames(assignments: list[CameraAssignment]) -> dict[str, str]:
|
||
role_names = {
|
||
"GLOBAL": "global.png",
|
||
"LEFT": "left.png",
|
||
"RIGHT": "right.png",
|
||
}
|
||
return {
|
||
camera.serial: role_names.get(camera.role, f"d405_{camera.serial}.png")
|
||
for camera in assignments
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class FrameStats:
|
||
target_fps: int
|
||
start_time: float
|
||
received: int = 0
|
||
dropped: int = 0
|
||
last_frame_number: int | None = None
|
||
first_frame_time: float | None = None
|
||
recent_times: deque[float] = field(default_factory=deque)
|
||
|
||
def update(self, frame_number: int, now: float) -> None:
|
||
if self.last_frame_number is not None and frame_number > self.last_frame_number:
|
||
self.dropped += max(0, frame_number - self.last_frame_number - 1)
|
||
self.last_frame_number = frame_number
|
||
self.received += 1
|
||
if self.first_frame_time is None:
|
||
self.first_frame_time = now
|
||
self.recent_times.append(now)
|
||
cutoff = now - FPS_WINDOW_SECONDS
|
||
while self.recent_times and self.recent_times[0] < cutoff:
|
||
self.recent_times.popleft()
|
||
|
||
@property
|
||
def drop_rate(self) -> float:
|
||
expected = self.received + self.dropped
|
||
return self.dropped / expected if expected else 0.0
|
||
|
||
@property
|
||
def rolling_fps(self) -> float:
|
||
if len(self.recent_times) < 2:
|
||
return 0.0
|
||
elapsed = self.recent_times[-1] - self.recent_times[0]
|
||
return (len(self.recent_times) - 1) / elapsed if elapsed > 0 else 0.0
|
||
|
||
def average_fps(self, now: float) -> float:
|
||
if self.received < 2 or self.first_frame_time is None:
|
||
return 0.0
|
||
last_frame_time = self.recent_times[-1] if self.recent_times else now
|
||
elapsed = last_frame_time - self.first_frame_time
|
||
return (self.received - 1) / elapsed if elapsed > 0 else 0.0
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CameraState:
|
||
frame: Any | None
|
||
actual_size: tuple[int, int]
|
||
received: int
|
||
dropped: int
|
||
drop_rate: float
|
||
rolling_fps: float
|
||
average_fps: float
|
||
elapsed: float
|
||
error: str
|
||
|
||
|
||
class CameraWorker:
|
||
def __init__(
|
||
self,
|
||
assignment: CameraAssignment,
|
||
width: int,
|
||
height: int,
|
||
fps: int,
|
||
rs: Any,
|
||
np: Any,
|
||
) -> None:
|
||
self.assignment = assignment
|
||
self.width = width
|
||
self.height = height
|
||
self.fps = fps
|
||
self._rs = rs
|
||
self._np = np
|
||
self._pipeline = rs.pipeline()
|
||
self._stop_event = threading.Event()
|
||
self._lock = threading.Lock()
|
||
self._thread: threading.Thread | None = None
|
||
self._frame: Any | None = None
|
||
self._actual_size = (width, height)
|
||
self._stats = FrameStats(fps, time.monotonic())
|
||
self._error = ""
|
||
|
||
def start(self) -> None:
|
||
config = self._rs.config()
|
||
config.enable_device(self.assignment.serial)
|
||
config.enable_stream(
|
||
self._rs.stream.color,
|
||
self.width,
|
||
self.height,
|
||
self._rs.format.bgr8,
|
||
self.fps,
|
||
)
|
||
profile = self._pipeline.start(config)
|
||
video_profile = profile.get_stream(self._rs.stream.color).as_video_stream_profile()
|
||
with self._lock:
|
||
self._actual_size = (video_profile.width(), video_profile.height())
|
||
self._stats = FrameStats(self.fps, time.monotonic())
|
||
self._thread = threading.Thread(
|
||
target=self._capture_loop,
|
||
name=f"camera-{self.assignment.serial}",
|
||
daemon=True,
|
||
)
|
||
self._thread.start()
|
||
|
||
def _capture_loop(self) -> None:
|
||
while not self._stop_event.is_set():
|
||
try:
|
||
frames = self._pipeline.wait_for_frames(timeout_ms=1000)
|
||
except RuntimeError as exc:
|
||
if self._stop_event.is_set():
|
||
return
|
||
with self._lock:
|
||
self._error = str(exc)
|
||
return
|
||
|
||
color_frame = frames.get_color_frame()
|
||
if not color_frame:
|
||
continue
|
||
frame = self._np.asanyarray(color_frame.get_data()).copy()
|
||
now = time.monotonic()
|
||
with self._lock:
|
||
self._frame = frame
|
||
self._stats.update(color_frame.get_frame_number(), now)
|
||
|
||
def state(self, now: float) -> CameraState:
|
||
with self._lock:
|
||
return CameraState(
|
||
frame=self._frame,
|
||
actual_size=self._actual_size,
|
||
received=self._stats.received,
|
||
dropped=self._stats.dropped,
|
||
drop_rate=self._stats.drop_rate,
|
||
rolling_fps=self._stats.rolling_fps,
|
||
average_fps=self._stats.average_fps(now),
|
||
elapsed=now - self._stats.start_time,
|
||
error=self._error,
|
||
)
|
||
|
||
def stop(self) -> None:
|
||
self._stop_event.set()
|
||
if self._thread is not None:
|
||
self._thread.join(timeout=1.2)
|
||
try:
|
||
self._pipeline.stop()
|
||
except RuntimeError:
|
||
pass
|
||
if self._thread is not None and self._thread.is_alive():
|
||
self._thread.join(timeout=1.0)
|
||
|
||
|
||
def load_runtime_dependencies() -> tuple[Any, Any, Any]:
|
||
try:
|
||
import cv2
|
||
import numpy as np
|
||
import pyrealsense2 as rs
|
||
except ImportError as exc:
|
||
raise RuntimeError(
|
||
"缺少相机测试依赖。请使用 /home/robot/miniconda3/envs/xr/bin/python "
|
||
"运行,并确认 xr 环境已安装 pyrealsense2、numpy 和 opencv-python。"
|
||
) from exc
|
||
return cv2, np, rs
|
||
|
||
|
||
def enumerate_devices(rs: Any) -> list[DeviceInfo]:
|
||
devices = []
|
||
for device in rs.context().query_devices():
|
||
name = device.get_info(rs.camera_info.name)
|
||
if "D405" in name:
|
||
model = "D405"
|
||
elif "D455" in name:
|
||
model = "D455"
|
||
else:
|
||
model = name
|
||
usb_type = (
|
||
device.get_info(rs.camera_info.usb_type_descriptor)
|
||
if device.supports(rs.camera_info.usb_type_descriptor)
|
||
else "unknown"
|
||
)
|
||
devices.append(
|
||
DeviceInfo(
|
||
name=name,
|
||
model=model,
|
||
serial=device.get_info(rs.camera_info.serial_number),
|
||
usb_type=usb_type,
|
||
)
|
||
)
|
||
return devices
|
||
|
||
|
||
def camera_status(state: CameraState, target_fps: int) -> tuple[str, tuple[int, int, int]]:
|
||
if state.error:
|
||
return "ERROR", (0, 0, 255)
|
||
if state.received == 0:
|
||
return "WAITING", (0, 215, 255)
|
||
if state.elapsed < WARMUP_SECONDS:
|
||
return "WARMUP", (0, 215, 255)
|
||
if state.rolling_fps >= target_fps * MIN_FPS_RATIO and state.drop_rate <= MAX_DROP_RATE:
|
||
return "PASS", (0, 200, 0)
|
||
return "FAIL", (0, 0, 255)
|
||
|
||
|
||
def render_tile(
|
||
worker: CameraWorker,
|
||
state: CameraState,
|
||
tile_width: int,
|
||
tile_height: int,
|
||
cv2: Any,
|
||
np: Any,
|
||
) -> Any:
|
||
if state.frame is None:
|
||
tile = np.zeros((tile_height, tile_width, 3), dtype=np.uint8)
|
||
else:
|
||
tile = cv2.resize(state.frame, (tile_width, tile_height))
|
||
|
||
status, color = camera_status(state, worker.fps)
|
||
cv2.rectangle(tile, (0, 0), (tile_width, 100), (0, 0, 0), -1)
|
||
width, height = state.actual_size
|
||
lines = [
|
||
f"{worker.assignment.role} {worker.assignment.model} {worker.assignment.serial}",
|
||
f"USB {worker.assignment.usb_type} {width}x{height}@{worker.fps}",
|
||
f"FPS {state.rolling_fps:.1f} Frames {state.received} "
|
||
f"Dropped {state.dropped} ({state.drop_rate:.2%})",
|
||
status if not state.error else f"ERROR: {state.error[:70]}",
|
||
]
|
||
for index, line in enumerate(lines):
|
||
cv2.putText(
|
||
tile,
|
||
line,
|
||
(10, 22 + index * 24),
|
||
cv2.FONT_HERSHEY_SIMPLEX,
|
||
0.55,
|
||
color if index == len(lines) - 1 else (255, 255, 255),
|
||
1,
|
||
cv2.LINE_AA,
|
||
)
|
||
return tile
|
||
|
||
|
||
def compose_preview(
|
||
workers: list[CameraWorker],
|
||
states: dict[str, CameraState],
|
||
capture_width: int,
|
||
capture_height: int,
|
||
cv2: Any,
|
||
np: Any,
|
||
) -> Any:
|
||
tile_width = min(capture_width, PREVIEW_TILE_WIDTH)
|
||
tile_height = round(tile_width * capture_height / capture_width)
|
||
tiles = {
|
||
worker.assignment.serial: render_tile(
|
||
worker,
|
||
states[worker.assignment.serial],
|
||
tile_width,
|
||
tile_height,
|
||
cv2,
|
||
np,
|
||
)
|
||
for worker in workers
|
||
}
|
||
global_worker = next(worker for worker in workers if worker.assignment.role == "GLOBAL")
|
||
arm_workers = [worker for worker in workers if worker.assignment.role != "GLOBAL"]
|
||
|
||
top = np.zeros((tile_height, tile_width * 2, 3), dtype=np.uint8)
|
||
offset = tile_width // 2
|
||
top[:, offset : offset + tile_width] = tiles[global_worker.assignment.serial]
|
||
bottom = np.hstack([tiles[worker.assignment.serial] for worker in arm_workers])
|
||
return np.vstack((top, bottom))
|
||
|
||
|
||
def save_snapshots(
|
||
assignments: list[CameraAssignment],
|
||
states: dict[str, CameraState],
|
||
cv2: Any,
|
||
) -> Path:
|
||
missing = [camera.role for camera in assignments if states[camera.serial].frame is None]
|
||
if missing:
|
||
raise RuntimeError(f"以下相机尚无有效画面,不能保存快照: {', '.join(missing)}")
|
||
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
|
||
snapshot_dir = OUTPUT_DIR / timestamp
|
||
snapshot_dir.mkdir(parents=True, exist_ok=False)
|
||
filenames = snapshot_filenames(assignments)
|
||
for camera in assignments:
|
||
path = snapshot_dir / filenames[camera.serial]
|
||
if not cv2.imwrite(str(path), states[camera.serial].frame):
|
||
raise RuntimeError(f"保存快照失败: {path}")
|
||
return snapshot_dir
|
||
|
||
|
||
def print_summary(workers: list[CameraWorker]) -> bool:
|
||
now = time.monotonic()
|
||
print("\n相机测试汇总:")
|
||
passed = True
|
||
for worker in workers:
|
||
state = worker.state(now)
|
||
status, _color = camera_status(state, worker.fps)
|
||
passed = passed and status == "PASS"
|
||
print(
|
||
f" {worker.assignment.role:<7} {worker.assignment.serial}: "
|
||
f"平均 {state.average_fps:.1f} FPS, 接收 {state.received}, "
|
||
f"掉帧 {state.dropped} ({state.drop_rate:.2%}), {status}"
|
||
)
|
||
print("结论: " + ("三路链路满足当前阈值" if passed else "至少一路未满足当前阈值"))
|
||
return passed
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--width", type=int, default=DEFAULT_WIDTH, help="采集宽度")
|
||
parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT, help="采集高度")
|
||
parser.add_argument("--fps", type=int, default=DEFAULT_FPS, help="目标帧率")
|
||
parser.add_argument(
|
||
"--left-serial",
|
||
default=DEFAULT_LEFT_SERIAL,
|
||
help=f"左臂 D405 的 RealSense 序列号(默认: {DEFAULT_LEFT_SERIAL})",
|
||
)
|
||
args = parser.parse_args()
|
||
if args.width <= 0 or args.height <= 0 or args.fps <= 0:
|
||
parser.error("width、height 和 fps 必须为正数")
|
||
return args
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
try:
|
||
cv2, np, rs = load_runtime_dependencies()
|
||
assignments = assign_camera_roles(enumerate_devices(rs), args.left_serial)
|
||
except RuntimeError as exc:
|
||
print(f"错误: {exc}")
|
||
return 2
|
||
except ValueError as exc:
|
||
print(f"设备检查失败: {exc}")
|
||
return 2
|
||
|
||
print("相机分配:")
|
||
for camera in assignments:
|
||
print(
|
||
f" {camera.role:<7} {camera.model} serial={camera.serial} "
|
||
f"USB={camera.usb_type}"
|
||
)
|
||
|
||
workers: list[CameraWorker] = []
|
||
passed = False
|
||
try:
|
||
for assignment in assignments:
|
||
worker = CameraWorker(
|
||
assignment,
|
||
args.width,
|
||
args.height,
|
||
args.fps,
|
||
rs,
|
||
np,
|
||
)
|
||
workers.append(worker)
|
||
worker.start()
|
||
|
||
cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_NORMAL)
|
||
print("按 S 保存三路快照,按 Q 或 Esc 退出。")
|
||
while True:
|
||
now = time.monotonic()
|
||
states = {worker.assignment.serial: worker.state(now) for worker in workers}
|
||
preview = compose_preview(
|
||
workers,
|
||
states,
|
||
args.width,
|
||
args.height,
|
||
cv2,
|
||
np,
|
||
)
|
||
cv2.imshow(WINDOW_NAME, preview)
|
||
key = cv2.waitKey(1) & 0xFF
|
||
if key in (ord("q"), ord("Q"), 27):
|
||
break
|
||
if key in (ord("s"), ord("S")):
|
||
try:
|
||
output = save_snapshots(assignments, states, cv2)
|
||
print(f"快照已保存: {output}")
|
||
except RuntimeError as exc:
|
||
print(f"快照失败: {exc}")
|
||
if cv2.getWindowProperty(WINDOW_NAME, cv2.WND_PROP_VISIBLE) < 1:
|
||
break
|
||
except KeyboardInterrupt:
|
||
print("\n收到 Ctrl+C,正在停止相机。")
|
||
except Exception as exc:
|
||
print(f"相机启动或显示失败: {exc}")
|
||
finally:
|
||
for worker in reversed(workers):
|
||
worker.stop()
|
||
try:
|
||
cv2.destroyAllWindows()
|
||
except Exception:
|
||
pass
|
||
if workers:
|
||
passed = print_summary(workers)
|
||
return 0 if passed else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|