#!/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 = """ RealSense Viewer

RealSense Viewer

Loading devices...
Loading...
""".encode("utf-8") class _PreviewHandler(http.server.BaseHTTPRequestHandler): state: _PreviewState def _send_bytes(self, status: int, content_type: str, body: bytes) -> None: try: self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) self.wfile.flush() except (BrokenPipeError, ConnectionResetError): pass def do_GET(self) -> None: parsed = urlsplit(self.path) path = parsed.path if path == "/": self._send_bytes(200, "text/html; charset=utf-8", WEB_PAGE) return if path == "/favicon.ico": self._send_bytes(204, "text/plain; charset=utf-8", b"") return if path == "/health": self._send_bytes(200, "application/json; charset=utf-8", self._json(self.state.device_status())) return if path == "/api/devices": self._send_bytes( 200, "application/json; charset=utf-8", self._json(self.state.device_status(), include_fps=True), ) return if path == "/stream": serial = parse_qs(parsed.query).get("serial", [None])[0] self._stream(serial) return try: self.send_error(404) except (BrokenPipeError, ConnectionResetError): pass def _json(self, devices: list[dict[str, Any]], include_fps: bool = False) -> bytes: payload: dict[str, Any] = {"ok": True, "devices": devices} if include_fps: payload["fps"] = self.state.fps return json.dumps(payload, ensure_ascii=False).encode("utf-8") def _stream(self, serial: str | None) -> None: feed = self.state.get_feed(serial) if feed is None: self._send_bytes(404, "text/plain; charset=utf-8", b"Unknown camera serial\n") return if not feed.connected: self._send_bytes(503, "text/plain; charset=utf-8", b"Camera is not connected\n") return try: self.send_response(200) self.send_header("Cache-Control", "no-cache, private") self.send_header("Pragma", "no-cache") self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") self.end_headers() last_frame_id = 0 while True: jpeg, frame_id, stopped = feed.wait_for_frame(last_frame_id) if stopped: return if jpeg is None or frame_id == last_frame_id: continue last_frame_id = frame_id self.wfile.write( b"--frame\r\nContent-Type: image/jpeg\r\n" + f"Content-Length: {len(jpeg)}\r\n\r\n".encode() + jpeg + b"\r\n" ) self.wfile.flush() except (BrokenPipeError, ConnectionResetError): pass def log_message(self, format: str, *args: Any) -> None: pass def start_web_server( state: _PreviewState, host: str, port: int ) -> http.server.ThreadingHTTPServer: handler = type("RealSensePreviewHandler", (_PreviewHandler,), {"state": state}) server = http.server.ThreadingHTTPServer((host, port), handler) server.daemon_threads = True thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() if host == "0.0.0.0": print(f"Open http://127.0.0.1:{port}/ on this machine to view the streams.") print(f"For another machine, use http://:{port}/") else: print(f"Open http://{host}:{port}/ in a browser to view the streams.") print("Press Ctrl+C to exit.") return server def _make_mosaic(feeds: list[_CameraFeed]) -> Any | None: tiles = [] for feed in feeds: frame = feed.latest_frame() if frame is None: continue cv2.putText( frame, f"{feed.device.name} {feed.device.serial}", (10, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 255), 2, cv2.LINE_AA, ) tiles.append(frame) if not tiles: return None height = max(tile.shape[0] for tile in tiles) aligned = [ cv2.resize(tile, (int(tile.shape[1] * height / tile.shape[0]), height)) if tile.shape[0] != height else tile for tile in tiles ] return cv2.hconcat(aligned) def main() -> None: args = parse_args() if args.fps <= 0: raise SystemExit("FPS must be positive") if args.no_display: backend = "none" elif args.backend == "auto": backend = "opencv" if opencv_has_gui() else "web" else: backend = args.backend devices = discover_devices() requested = list(dict.fromkeys(args.serial or [])) if requested: requested_set = set(requested) devices = [device for device in devices if device.serial in requested_set] missing = sorted(requested_set - {device.serial for device in devices}) if missing: print(f"Requested cameras not found: {', '.join(missing)}") if not devices: raise SystemExit("No RealSense cameras found.") feeds: list[_CameraFeed] = [] for device in devices: feed = _CameraFeed(device, args.width, args.height, args.fps) try: print(f"Opening RealSense {device.name} {device.serial}: {args.width}x{args.height} @ {args.fps} FPS") feed.connect() print(f"Connected {device.serial}") except Exception as exc: feed.error = f"{type(exc).__name__}: {exc}" print(f"Failed to connect {device.serial}: {feed.error}") feeds.append(feed) initially_selected = requested or ([feeds[0].device.serial] if feeds else []) state = _PreviewState(feeds, initially_selected, args.fps) web_server = None if backend == "web": web_server = start_web_server(state, args.host, args.port) elif backend == "opencv": print("Press q or Esc to exit.") try: if backend == "web": while True: time.sleep(1.0) elif backend == "none": while True: time.sleep(1.0) statuses = [ f"{feed.device.serial}:{'OK' if feed.connected and feed.error is None else 'ERROR'}" for feed in feeds ] print(" | ".join(statuses), flush=True) else: while True: mosaic = _make_mosaic(feeds) if mosaic is not None: cv2.imshow("RealSense Color", mosaic) key = cv2.waitKey(1) & 0xFF if key in (ord("q"), 27): break except KeyboardInterrupt: pass finally: state.stop() if web_server is not None: web_server.shutdown() web_server.server_close() if backend == "opencv": try: cv2.destroyAllWindows() except cv2.error: pass print("RealSense preview stopped.") if __name__ == "__main__": main()