diff --git a/README.md b/README.md index f66e5d9..5eb9558 100644 --- a/README.md +++ b/README.md @@ -151,14 +151,14 @@ Controls: `Space` start the episode, `→` save it, `←` discard and re-record ./start_manual_record.sh -r # force resume; fails if the dataset directory does not exist ``` -The record script (`uf-lerobot-record`) reads `dataset.root` from the config and creates the path with `mkdir -p` first, then: +The record script (`uf-lerobot-record`) reads `dataset.root` from the config and checks the path before recording, then: - Directory does not exist → records a new dataset. - Directory already exists (valid LeRobot dataset) and no `-r` was given → asks interactively: - `o` overwrite: delete the existing dataset and record a new one - `r` resume: keep existing episodes and continue recording - `c` cancel -- Directory exists but is not a valid LeRobot dataset (missing `meta/info.json`) → asks to overwrite it or cancel; non-interactive runs error out instead. +- Directory exists but is incomplete (missing required metadata or data parquet files) → asks to overwrite it or cancel; non-interactive runs error out instead. Pass `-r` to resume directly without asking. Note that `./start_manual_record.sh` keeps its original launcher behavior — it automatically resumes an existing valid dataset (equivalent to `-r`), so run `uv run uf-lerobot-record --config_path config/manual_mode/xarm7_manual_record_config.yaml` directly if you want to see the overwrite/resume prompt. diff --git a/README_ZH.md b/README_ZH.md index 471c8de..a550752 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -150,14 +150,14 @@ uv run uf-lerobot-record --config_path config/gello/xarm7_gello_record_config.ya ./start_manual_record.sh -r # 强制续录;数据集目录不存在时会报错 ``` -录制脚本(`uf-lerobot-record`)会从配置读取 `dataset.root` 并先执行 `mkdir -p` 创建路径,然后: +录制脚本(`uf-lerobot-record`)会从配置读取 `dataset.root` 并在录制前检查路径,然后: - 目录不存在:直接录制新数据集。 - 目录已存在(有效 LeRobot 数据集)且未加 `-r`:交互询问: - `o` 覆盖:删除已有数据集,重新录制 - `r` 续录:保留已有 episode,继续录制 - `c` 取消 -- 目录存在但不是有效 LeRobot 数据集(缺少 `meta/info.json`):询问覆盖或取消;非交互运行时直接报错。 +- 目录存在但不完整(缺少必要元数据或数据 parquet 文件):询问覆盖或取消;非交互运行时直接报错。 加 `-r` 可跳过询问直接续录。注意 `./start_manual_record.sh` 保持原有启动脚本行为——检测到有效数据集会自动续录(相当于 `-r`),如果想看到覆盖/续录的询问,请直接用 `uv run uf-lerobot-record --config_path config/manual_mode/xarm7_manual_record_config.yaml` 运行。 @@ -222,6 +222,31 @@ uv run uf-camera-view -l # 列出所有摄像头 uv run uf-camera-view -T realsense # 查看 RealSense 摄像头 ``` +网页预览会扫描所有 RealSense 摄像头,默认使用 `640x480`、`30fps`,页面中可勾选设备切换或同时显示多路画面: + +```bash +uv run uf-realsense-view +``` + +脚本使用 LeRobot 的 `RealSenseCamera`。如果当前环境中的 OpenCV 是 LeRobot 默认的 +headless 版本,会自动启动网页预览。同一台机器上打开 +`http://127.0.0.1:8765/`;从其他机器访问时,请将 `127.0.0.1` 替换为运行脚本机器的实际 IP。 +网页服务默认监听 `0.0.0.0`,也可以用 `--host 127.0.0.1` 限制为本机访问;还可以通过 +`--backend opencv` 强制使用 OpenCV 窗口。 + +也可以只打开指定设备;需要多个设备时重复 `--serial`: + +```bash +uv run uf-realsense-view \ + --serial 148522072685 --width 640 --height 480 --fps 30 + +uv run uf-realsense-view \ + --serial 148522072685 --serial SECOND_CAMERA_SERIAL +``` + +OpenCV 窗口按 `q` 或 `Esc` 退出;网页模式按 `Ctrl+C` 退出。无桌面环境时可以用 +`--no-display` 检查是否能持续取帧。 + ### LeRobot 数据集工具 ```bash diff --git a/config/manual_mode/xarm7_manual_record_config.yaml b/config/manual_mode/xarm7_manual_record_config.yaml index acab5fd..b3e3f05 100644 --- a/config/manual_mode/xarm7_manual_record_config.yaml +++ b/config/manual_mode/xarm7_manual_record_config.yaml @@ -24,10 +24,10 @@ robot: fps: 30 dataset: - root: "/home/wsx/code/lerobot_robot_ufactory/datasets/xarm7_manual_replay_pick_pen" - repo_id: "ufactory/xarm7_manual_datas" + root: "/home/wsx/code/lerobot_robot_ufactory/datasets/xarm7_manual_replay_pick_bottle" + repo_id: "zjuscl/xarm7_manual_replay_pick_bottle" # Task description stored with each recorded frame. - single_task: "Describe the task being demonstrated." + single_task: "Pick up the black bottle and place it on the blue bag" fps: 30 episode_time_s: 60 # Reserved reset duration between episodes in seconds. diff --git a/pyproject.toml b/pyproject.toml index 80c895f..9d356a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ uf-lerobot-eval = "lerobot_robot_ufactory.scripts.uf_lerobot_eval:main" uf-lerobot-replay = "lerobot_robot_ufactory.scripts.uf_lerobot_replay:main" uf-vive-calibrate = "lerobot_robot_ufactory.scripts.vive_calibrate:main" uf-camera-view = "lerobot_robot_ufactory.scripts.uf_camera_view:main" +uf-realsense-view = "lerobot_robot_ufactory.scripts.uf_realsense_view:main" uf-camera-test = "lerobot_robot_ufactory.scripts.uf_camera_test:main" [project.optional-dependencies] diff --git a/src/lerobot_robot_ufactory/scripts/uf_realsense_view.py b/src/lerobot_robot_ufactory/scripts/uf_realsense_view.py new file mode 100644 index 0000000..050c60e --- /dev/null +++ b/src/lerobot_robot_ufactory/scripts/uf_realsense_view.py @@ -0,0 +1,570 @@ +#!/usr/bin/env python3 +"""Web viewer for one or more Intel RealSense color streams.""" + +from __future__ import annotations + +import argparse +import http.server +import json +import threading +import time +from dataclasses import dataclass +from typing import Any +from urllib.parse import parse_qs, urlsplit + +import cv2 +import pyrealsense2 as rs + +from lerobot.cameras import ColorMode +from lerobot.cameras.realsense import RealSenseCamera, RealSenseCameraConfig + + +DEFAULT_SERIAL = None +DEFAULT_WIDTH = 640 +DEFAULT_HEIGHT = 480 +DEFAULT_FPS = 30 +DEFAULT_HOST = "0.0.0.0" +DEFAULT_PORT = 8765 + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Preview one or more Intel RealSense color streams in a browser" + ) + parser.add_argument( + "-s", + "--serial", + action="append", + default=DEFAULT_SERIAL, + help="camera serial number; repeat to limit the device list to selected cameras", + ) + parser.add_argument("-W", "--width", type=int, default=DEFAULT_WIDTH, help="color width") + parser.add_argument("-H", "--height", type=int, default=DEFAULT_HEIGHT, help="color height") + parser.add_argument( + "-F", "--fps", type=int, default=DEFAULT_FPS, help="capture and preview FPS (default: 30)" + ) + parser.add_argument( + "--backend", + choices=("auto", "opencv", "web"), + default="web", + help="preview backend (default: web; web provides device selection)", + ) + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help="web server bind address (default: 0.0.0.0; use 127.0.0.1 for local-only access)", + ) + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="web server port") + parser.add_argument( + "--no-display", + action="store_true", + help="capture frames without opening a preview", + ) + return parser.parse_args(argv) + + +def opencv_has_gui() -> bool: + return next( + ("NONE" not in line for line in cv2.getBuildInformation().splitlines() if "GUI:" in line), + False, + ) + + +@dataclass(frozen=True) +class RealSenseDevice: + serial: str + name: str + product_line: str + + +def discover_devices() -> list[RealSenseDevice]: + """Discover connected RealSense devices without opening their streams.""" + devices: list[RealSenseDevice] = [] + context = rs.context() + for device in context.query_devices(): + devices.append( + RealSenseDevice( + serial=device.get_info(rs.camera_info.serial_number), + name=device.get_info(rs.camera_info.name), + product_line=device.get_info(rs.camera_info.product_line), + ) + ) + return devices + + +class _CameraFeed: + """Own one camera connection and publish its latest JPEG frame.""" + + def __init__(self, device: RealSenseDevice, width: int, height: int, fps: int) -> None: + self.device = device + self.width = width + self.height = height + self.fps = fps + self.camera = RealSenseCamera( + RealSenseCameraConfig( + serial_number_or_name=device.serial, + width=width, + height=height, + fps=fps, + color_mode=ColorMode.BGR, + ) + ) + self.condition = threading.Condition() + self.jpeg: bytes | None = None + self.frame: Any | None = None + self.frame_id = 0 + self.frame_count = 0 + self.error: str | None = None + self.connected = False + self.stopped = False + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + + def connect(self) -> None: + self.camera.connect() + self.connected = True + self._thread = threading.Thread( + target=self._capture_loop, + name=f"realsense-{self.device.serial}", + daemon=True, + ) + self._thread.start() + + def _set_error(self, error: Exception) -> None: + with self.condition: + self.error = f"{type(error).__name__}: {error}" + self.condition.notify_all() + + def _capture_loop(self) -> None: + period = 1.0 / self.fps + next_deadline = time.monotonic() + while not self._stop_event.is_set(): + try: + frame = self.camera.read() + encoded, buffer = cv2.imencode(".jpg", frame) + if not encoded: + raise RuntimeError("JPEG encoding failed") + with self.condition: + self.frame = frame + self.jpeg = buffer.tobytes() + self.frame_id += 1 + self.frame_count += 1 + self.error = None + self.condition.notify_all() + except Exception as exc: + self._set_error(exc) + if self._stop_event.wait(0.1): + break + + next_deadline += period + delay = next_deadline - time.monotonic() + if delay > 0: + self._stop_event.wait(delay) + elif -delay > period * 2: + next_deadline = time.monotonic() + + def wait_for_frame(self, last_frame_id: int) -> tuple[bytes | None, int, bool]: + with self.condition: + self.condition.wait_for( + lambda: self.frame_id > last_frame_id or self.stopped, + timeout=1.0, + ) + return self.jpeg, self.frame_id, self.stopped + + def latest_frame(self) -> Any | None: + with self.condition: + return None if self.frame is None else self.frame.copy() + + def status(self, selected: bool) -> dict[str, Any]: + with self.condition: + return { + "serial": self.device.serial, + "name": self.device.name, + "product_line": self.device.product_line, + "width": self.width, + "height": self.height, + "fps": self.fps, + "connected": self.connected, + "selected": selected, + "frame_count": self.frame_count, + "error": self.error, + } + + def stop(self) -> None: + self._stop_event.set() + with self.condition: + self.stopped = True + self.condition.notify_all() + if self._thread is not None: + self._thread.join(timeout=2.0) + if self.connected: + try: + self.camera.disconnect() + except Exception as exc: + self._set_error(exc) + self.connected = False + + +class _PreviewState: + def __init__(self, feeds: list[_CameraFeed], selected: list[str], fps: int) -> None: + self.feeds = {feed.device.serial: feed for feed in feeds} + self.selected = set(selected) + self.fps = fps + + def get_feed(self, serial: str | None) -> _CameraFeed | None: + return self.feeds.get(serial) if serial else None + + def device_status(self) -> list[dict[str, Any]]: + return [ + feed.status(feed.device.serial in self.selected) + for feed in self.feeds.values() + ] + + def stop(self) -> None: + for feed in self.feeds.values(): + feed.stop() + + +WEB_PAGE = """ + +
+ + +