Timsty's picture
Add files using upload-large-folder tool
1f3a93e verified
Raw
History Blame Contribute Delete
26.2 kB
"""Composed camera server — orchestrates multiple camera drivers.
Runs each camera in its own thread with staggered initialization and
automatic reconnection. Publishes all frames as a single merged
``ImageMessageSchema`` payload over ZMQ.
Usage (on robot)::
python -m gear_sonic.camera.composed_camera \\
--ego-view-camera oak \\
--ego-view-device-id 18443010E1ABC12300 \\
--port 5555
Supported camera types: ``oak``, ``oak_mono``, ``realsense``,
``usb``, or a path to an ``.mp4`` file for replay testing.
Run ``python -m gear_sonic.camera.composed_camera --help`` for all options.
"""
from collections import deque
from dataclasses import dataclass
import queue
import threading
import time
from typing import Any
import cv2 # noqa: F401 — imported early to avoid TSL segfault with camera SDKs
import numpy as np
from gear_sonic.camera.sensor import Sensor
from gear_sonic.camera.sensor_server import (
CameraMountPosition,
ImageMessageSchema,
SensorClient,
SensorServer,
)
def read_qr_code(data):
"""Measure end-to-end latency by decoding QR-code timestamps."""
current_time = time.monotonic()
detector = cv2.QRCodeDetector()
for key, img in data["images"].items():
decoded_time, bbox, _ = detector.detectAndDecode(img)
if bbox is not None and decoded_time:
print(f"{key} latency: {(current_time - float(decoded_time)) * 1e3:.1f} ms")
else:
print(f"{key} QR code not detected.")
@dataclass
class ComposedCameraConfig:
"""Camera configuration for the composed camera server."""
ego_view_camera: str | None = "oak"
"""Camera type for ego view: oak, oak_mono, realsense, zed, usb, or None."""
ego_view_device_id: str | None = None
"""Device ID for ego view camera (OAK MxID, RealSense serial, USB /dev/video index)."""
head_camera: str | None = None
"""Camera type for head view."""
head_device_id: str | None = None
"""Device ID for head camera."""
left_wrist_camera: str | None = None
"""Camera type for left wrist view."""
left_wrist_device_id: str | None = None
"""Device ID for left wrist camera."""
right_wrist_camera: str | None = None
"""Camera type for right wrist view."""
right_wrist_device_id: str | None = None
"""Device ID for right wrist camera."""
fps: int = 30
"""Publish rate. OAK cameras run at 30 FPS; lower values add latency."""
run_as_server: bool = True
"""Run as ZMQ PUB server (set False for in-process usage)."""
server: bool = True
"""Alias for run_as_server kept for backward compatibility."""
port: int = 5555
"""ZMQ port for server / client communication."""
test_latency: bool = False
"""Decode QR-code timestamps in each frame to measure latency."""
queue_size: int = 3
"""Per-camera image queue depth."""
use_mjpeg: bool = False
"""Use on-device MJPEG encoding on OAK cameras to reduce USB bandwidth."""
mjpeg_quality: int = 80
"""MJPEG quality 1-100 (only when use_mjpeg=True)."""
def __post_init__(self):
self.run_as_server = self.server
class ComposedCameraSensor(Sensor, SensorServer):
"""Multi-camera orchestrator with per-camera threads and auto-reconnect."""
def __init__(self, config: ComposedCameraConfig):
self.config = config
self.camera_queues: dict[str, queue.Queue] = {}
self.camera_threads: dict[str, threading.Thread] = {}
self.shutdown_events: dict[str, threading.Event] = {}
self.error_events: dict[str, threading.Event] = {}
self.error_messages: dict[str, str] = {}
self._observation_spaces: dict[str, Any] = {}
camera_configs = self._get_camera_configs()
for _idx, (mount_position, camera_config) in enumerate(camera_configs.items()):
camera_queue = queue.Queue(maxsize=config.queue_size)
shutdown_event = threading.Event()
error_event = threading.Event()
self.camera_queues[mount_position] = camera_queue
self.shutdown_events[mount_position] = shutdown_event
self.error_events[mount_position] = error_event
thread = threading.Thread(
target=self._camera_worker_wrapper,
args=(
mount_position,
camera_config["camera_type"],
camera_config["device_id"],
camera_queue,
shutdown_event,
error_event,
),
)
thread.start()
self.camera_threads[mount_position] = thread
# Stagger init to avoid USB bandwidth contention
init_timeout = 15.0
init_start = time.time()
while time.time() - init_start < init_timeout:
if mount_position in self._observation_spaces:
print(f"[{mount_position}] Camera ready, waiting 3s before next camera...")
time.sleep(3.0)
break
time.sleep(0.5)
else:
print(f"[{mount_position}] Camera init timeout, proceeding anyway...")
if config.run_as_server:
print("Waiting for all cameras to be ready before starting server...")
self._wait_for_all_cameras_ready(timeout=60.0)
self.start_server(config.port)
def _get_camera_configs(self) -> dict[str, dict]:
camera_configs = {}
if self.config.ego_view_camera is not None:
camera_configs[CameraMountPosition.EGO_VIEW.value] = {
"camera_type": self.config.ego_view_camera,
"device_id": self.config.ego_view_device_id,
}
if self.config.head_camera is not None:
camera_configs[CameraMountPosition.HEAD.value] = {
"camera_type": self.config.head_camera,
"device_id": self.config.head_device_id,
}
if self.config.left_wrist_camera is not None:
camera_configs[CameraMountPosition.LEFT_WRIST.value] = {
"camera_type": self.config.left_wrist_camera,
"device_id": self.config.left_wrist_device_id,
}
if self.config.right_wrist_camera is not None:
camera_configs[CameraMountPosition.RIGHT_WRIST.value] = {
"camera_type": self.config.right_wrist_camera,
"device_id": self.config.right_wrist_device_id,
}
return camera_configs
def _wait_for_all_cameras_ready(self, timeout: float = 60.0):
expected_cameras = set(self.camera_queues.keys())
start_time = time.time()
while time.time() - start_time < timeout:
ready_cameras = set()
for mount_position, camera_queue in self.camera_queues.items():
if not camera_queue.empty():
ready_cameras.add(mount_position)
if ready_cameras == expected_cameras:
print(f"All {len(expected_cameras)} cameras ready: {ready_cameras}")
time.sleep(1.0)
return
waiting_for = expected_cameras - ready_cameras
print(
f"Waiting for cameras: {waiting_for} "
f"({len(ready_cameras)}/{len(expected_cameras)} ready)"
)
time.sleep(2.0)
ready_cameras = set()
for mount_position, camera_queue in self.camera_queues.items():
if not camera_queue.empty() or mount_position in self._observation_spaces:
ready_cameras.add(mount_position)
missing = expected_cameras - ready_cameras
print(
f"[WARNING] Timeout waiting for all cameras. "
f"Missing: {missing}. Starting anyway with: {ready_cameras}"
)
def _camera_worker_wrapper(
self,
mount_position: str,
camera_type: str,
device_id: str | None,
image_queue: queue.Queue,
shutdown_event: threading.Event,
error_event: threading.Event,
):
"""Worker thread with auto-reconnection."""
max_init_retries = 10
max_reconnect_attempts = 5
reconnect_count = 0
while not shutdown_event.is_set() and reconnect_count < max_reconnect_attempts:
camera = None
try:
init_retry_delay = 1.0
for attempt in range(max_init_retries):
if shutdown_event.is_set():
return
try:
if reconnect_count > 0:
print(
f"[{mount_position}] Reconnecting camera "
f"(reconnect {reconnect_count}/{max_reconnect_attempts}, "
f"attempt {attempt + 1}/{max_init_retries})..."
)
else:
print(
f"[{mount_position}] Initializing camera "
f"(attempt {attempt + 1}/{max_init_retries})..."
)
camera = self._instantiate_camera(mount_position, camera_type, device_id)
print(f"[{mount_position}] Camera initialized successfully")
break
except Exception as e:
print(f"[{mount_position}] Camera init failed: {e}")
if attempt < max_init_retries - 1:
print(f"[{mount_position}] Retrying in {init_retry_delay:.1f}s...")
time.sleep(init_retry_delay)
init_retry_delay = min(init_retry_delay * 1.5, 10.0)
else:
raise RuntimeError(
f"Camera {mount_position} ({camera_type}) failed to initialize "
f"after {max_init_retries} attempts: {e}"
)
obs_space = camera.observation_space()
if obs_space is not None:
self._observation_spaces[mount_position] = obs_space
else:
self._observation_spaces[mount_position] = True
consecutive_failures = 0
max_consecutive_failures = 10
warmup_period = True
warmup_start_time = time.time()
warmup_timeout = 5.0
while not shutdown_event.is_set():
try:
frame = camera.read()
except Exception as e:
print(f"[{mount_position}] Frame read exception: {e}")
frame = None
consecutive_failures = max_consecutive_failures
if frame:
consecutive_failures = 0
warmup_period = False
try:
image_queue.put_nowait(frame)
except queue.Full:
try:
image_queue.get_nowait()
image_queue.put_nowait(frame)
except queue.Empty:
pass
else:
if warmup_period:
if time.time() - warmup_start_time > warmup_timeout:
print(
f"[{mount_position}] Warmup timeout — will attempt reconnect"
)
break
time.sleep(0.1)
else:
consecutive_failures += 1
if consecutive_failures >= max_consecutive_failures:
print(
f"[{mount_position}] Too many consecutive failures "
f"({consecutive_failures}) — will attempt reconnect"
)
break
time.sleep(0.01)
if camera is not None:
try:
camera.close()
except Exception as e:
print(f"[{mount_position}] Error closing camera: {e}")
camera = None
if not shutdown_event.is_set():
reconnect_count += 1
print(f"[{mount_position}] Waiting 5 seconds before reconnect attempt...")
time.sleep(5.0)
except Exception as e:
print(f"[{mount_position}] Camera error: {e}")
if camera is not None:
try:
camera.close()
except Exception:
pass
camera = None
if not shutdown_event.is_set():
reconnect_count += 1
if reconnect_count < max_reconnect_attempts:
print(
f"[{mount_position}] Waiting 5 seconds before reconnect "
f"attempt {reconnect_count}/{max_reconnect_attempts}..."
)
time.sleep(5.0)
if reconnect_count >= max_reconnect_attempts and not shutdown_event.is_set():
error_msg = (
f"Camera {mount_position} ({camera_type}) failed "
f"after {max_reconnect_attempts} reconnect attempts"
)
print(f"[ERROR] {error_msg}")
self.error_messages[mount_position] = error_msg
error_event.set()
def _instantiate_camera(
self, mount_position: str, camera_type: str, device_id: str | None = None
) -> Sensor:
"""Instantiate a camera sensor based on camera_type (lazy imports)."""
if camera_type in ("oak", "oak_mono"):
from gear_sonic.camera.drivers.oak import OAKConfig, OAKSensor
oak_config = OAKConfig()
oak_config.use_mjpeg = self.config.use_mjpeg
oak_config.mjpeg_quality = self.config.mjpeg_quality
if camera_type == "oak_mono":
oak_config.enable_mono_cameras = True
print(f"Initializing OAK sensor for camera type: {camera_type}")
return OAKSensor(config=oak_config, mount_position=mount_position, device_id=device_id)
elif camera_type == "realsense":
from gear_sonic.camera.drivers.realsense import RealSenseSensor
print(f"Initializing RealSense sensor for camera type: {camera_type}")
return RealSenseSensor(mount_position=mount_position)
elif camera_type.endswith(".mp4"):
from gear_sonic.camera.drivers.dummy import ReplayDummySensor
print(f"Initializing Replay Dummy Sensor for camera type: {camera_type}")
return ReplayDummySensor(video_path=camera_type)
elif camera_type == "usb":
from gear_sonic.camera.drivers.usb_camera import USBCameraConfig, USBCameraSensor
usb_config = USBCameraConfig()
device_idx = int(device_id) if device_id else 0
print(f"Initializing USB camera for type: {camera_type}, device: {device_idx}")
return USBCameraSensor(
config=usb_config, mount_position=mount_position, device_index=device_idx
)
else:
raise ValueError(f"Unsupported camera type: {camera_type}")
def _check_for_errors(self):
for mount_position, error_event in self.error_events.items():
if error_event.is_set():
error_msg = self.error_messages.get(
mount_position, f"Camera {mount_position} encountered an unknown error"
)
raise RuntimeError(error_msg)
def read(self):
"""Read frames from all cameras. Returns None unless ALL cameras have frames."""
self._check_for_errors()
expected_cameras = set(self.camera_queues.keys())
message = {}
for mount_position, camera_queue in self.camera_queues.items():
frame = self._get_latest_from_queue(camera_queue)
if frame is not None:
message[mount_position] = frame
if set(message.keys()) == expected_cameras:
return message
return None
def _get_latest_from_queue(self, camera_queue: queue.Queue) -> dict[str, Any] | None:
latest = None
try:
while True:
latest = camera_queue.get_nowait()
except queue.Empty:
pass
return latest
def close(self):
for shutdown_event in self.shutdown_events.values():
shutdown_event.set()
for thread in self.camera_threads.values():
thread.join(timeout=5.0)
for camera_queue in self.camera_queues.values():
try:
while True:
camera_queue.get_nowait()
except queue.Empty:
pass
if self.config.run_as_server:
self.stop_server()
def serialize(self, data: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError("Use serialize_message() for ComposedCameraSensor")
def serialize_message(self, message: dict[str, Any]) -> dict[str, Any]:
"""Merge per-camera data into a single ImageMessageSchema."""
all_timestamps = {}
all_images = {}
for _mount, camera_data in message.items():
all_timestamps.update(camera_data.get("timestamps", {}))
all_images.update(camera_data.get("images", {}))
img_schema = ImageMessageSchema(timestamps=all_timestamps, images=all_images)
return img_schema.serialize()
def run_server(self):
"""Main server loop — reads, serializes and publishes frames."""
idx = 0
server_start_time = time.monotonic()
fps_print_time = time.monotonic()
frame_interval = 1.0 / self.config.fps
while True:
target_time = server_start_time + (idx + 1) * frame_interval
message = self.read()
if message:
if self.config.test_latency:
read_qr_code(message)
serialized_message = self.serialize_message(message)
self.send_message(serialized_message)
idx += 1
if idx % 10 == 0:
print(f"Image sending FPS: {10 / (time.monotonic() - fps_print_time):.2f}")
fps_print_time = time.monotonic()
current_time = time.monotonic()
sleep_time = target_time - current_time
if sleep_time > 0:
time.sleep(sleep_time)
else:
if not message:
idx += 1
def observation_space(self):
try:
import gymnasium as gym
return gym.spaces.Dict(self._observation_spaces)
except ImportError:
return None
class ComposedCameraClientSensor(Sensor, SensorClient):
"""ZMQ client that deserializes merged camera frames from the server."""
def __init__(self, server_ip: str = "localhost", port: int = 5555):
self.start_client(server_ip, port)
self._latest_message = None
self._avg_time_per_frame: deque = deque(maxlen=20)
self._msg_received_time = 0
self._start_time = 0.0
self.idx = 0
self._last_new_message_time = None
self._last_staleness_warning_time = 0.0
self._staleness_warning_interval = 2.0
print("Initialized composed camera client sensor")
def read(self, blocking: bool = False, **kwargs) -> dict[str, Any] | None:
self._start_time = time.time()
current_time = time.time()
if blocking:
message = self.receive_message()
if not message:
return None
else:
message = self.receive_message_nonblocking(timeout_ms=0)
if message is not None:
self.idx += 1
self._latest_message = ImageMessageSchema.deserialize(message).asdict()
self._last_new_message_time = current_time
if self.idx % 10 == 0:
for image_key, image_time in self._latest_message["timestamps"].items():
image_latency = (time.time() - image_time) * 1000
print(f"Image latency for {image_key}: {image_latency:.2f} ms")
self._msg_received_time = time.time()
self._avg_time_per_frame.append(self._msg_received_time - self._start_time)
elif not blocking and self._latest_message is not None:
if self._last_new_message_time is not None:
time_since_last_message = current_time - self._last_new_message_time
if time_since_last_message > 0.1:
if (
current_time - self._last_staleness_warning_time
>= self._staleness_warning_interval
):
print(
f"[WARNING] No new image message received for "
f"{time_since_last_message*1000:.1f}ms. "
f"Reusing stale image. Check camera server connection."
)
self._last_staleness_warning_time = current_time
return self._latest_message
def serialize(self, data: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError("Client does not serialize")
def close(self):
self.stop_client()
def fps(self) -> float:
if len(self._avg_time_per_frame) == 0:
return 0.0
return float(1 / np.mean(self._avg_time_per_frame))
class _MjpegGrabber:
"""Background thread that reads an MJPEG stream via raw HTTP."""
def __init__(self, url: str):
self.url = url
self.lock = threading.Lock()
self.frame: np.ndarray | None = None
self._running = True
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def _run(self):
import requests
resp = requests.get(self.url, stream=True, timeout=10)
buf = b""
for chunk in resp.iter_content(chunk_size=4096):
if not self._running:
break
buf += chunk
while True:
soi = buf.find(b"\xff\xd8")
if soi == -1:
break
eoi = buf.find(b"\xff\xd9", soi + 2)
if eoi == -1:
break
jpeg_bytes = buf[soi : eoi + 2]
buf = buf[eoi + 2 :]
frame = cv2.imdecode(
np.frombuffer(jpeg_bytes, dtype=np.uint8), cv2.IMREAD_COLOR
)
if frame is not None:
with self.lock:
self.frame = frame
def get(self) -> np.ndarray | None:
with self.lock:
return self.frame
def stop(self):
self._running = False
self._thread.join(timeout=2)
class ComposedCameraHttpClient:
"""Camera client that reads MJPEG streams over HTTP.
Drop-in replacement for :class:`ComposedCameraClientSensor` when cameras
are served via an HTTP MJPEG server.
Usage::
client = ComposedCameraHttpClient("http://<ROBOT_IP>:8000")
data = client.read() # {"images": {"left": ndarray, ...}, "timestamps": {...}}
"""
DEFAULT_NAME_MAP = {
"center": "ego_view",
"left": "left_wrist",
"right": "right_wrist",
}
def __init__(self, base_url: str, camera_name_map: dict[str, str] | None = None):
self.base_url = base_url.rstrip("/")
self.camera_name_map = (
camera_name_map if camera_name_map is not None else self.DEFAULT_NAME_MAP
)
self.camera_names: list[str] = []
self._grabbers: dict[str, _MjpegGrabber] = {}
self._connect()
def _connect(self):
import requests
resp = requests.get(f"{self.base_url}/cameras", timeout=5)
resp.raise_for_status()
self.camera_names = resp.json()
print(f"HTTP MJPEG: discovered cameras: {self.camera_names}")
for name in self.camera_names:
url = f"{self.base_url}/stream/{name}"
self._grabbers[name] = _MjpegGrabber(url)
def read(self, blocking: bool = False, **kwargs) -> dict[str, Any] | None:
images = {}
any_ok = False
for name, grabber in self._grabbers.items():
frame = grabber.get()
mapped_name = self.camera_name_map.get(name, name)
if frame is not None:
images[mapped_name] = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
any_ok = True
else:
images[mapped_name] = None
if not any_ok:
return None
return {"images": images, "timestamps": {n: time.time() for n in images}}
def close(self):
for grabber in self._grabbers.values():
grabber.stop()
if __name__ == "__main__":
import tyro
config = tyro.cli(ComposedCameraConfig)
if config.run_as_server:
composed_camera = ComposedCameraSensor(config)
print("Running composed camera server...")
composed_camera.run_server()
else:
composed_client = ComposedCameraClientSensor(server_ip="localhost", port=config.port)
try:
while True:
data = composed_client.read()
if data is not None:
print(f"FPS: {composed_client.fps():.2f}")
time.sleep(0.1)
except KeyboardInterrupt:
print("Stopping client...")
composed_client.close()