#!/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()