diff --git a/src/reachy_mini/daemon/app/main.py b/src/reachy_mini/daemon/app/main.py index bbd57b07..5b4d6286 100644 --- a/src/reachy_mini/daemon/app/main.py +++ b/src/reachy_mini/daemon/app/main.py @@ -25,6 +25,7 @@ from reachy_mini.apps.manager import AppManager from reachy_mini.daemon.app.routers import ( apps, + camera, daemon, kinematics, motors, @@ -104,6 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: router = APIRouter(prefix="/api") router.include_router(apps.router) + router.include_router(camera.router) router.include_router(daemon.router) router.include_router(kinematics.router) router.include_router(motors.router) diff --git a/src/reachy_mini/daemon/app/routers/camera.py b/src/reachy_mini/daemon/app/routers/camera.py new file mode 100644 index 00000000..477fc8f5 --- /dev/null +++ b/src/reachy_mini/daemon/app/routers/camera.py @@ -0,0 +1,69 @@ +"""Camera streaming API routes.""" + +import asyncio +from typing import AsyncGenerator + +import cv2 +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse + +from reachy_mini.media.camera_constants import CameraResolution +from reachy_mini.media.camera_opencv import OpenCVCamera + +from ...backend.abstract import Backend +from ...backend.mujoco.backend import MujocoBackend +from ...daemon import Daemon +from ..dependencies import get_backend, get_daemon + +router = APIRouter(prefix="/camera") + +_shared_camera: OpenCVCamera | None = None +_camera_refs = 0 + + +async def _get_shared_camera(is_sim: bool) -> OpenCVCamera: + """Get or create shared camera instance.""" + global _shared_camera, _camera_refs + if _shared_camera is None: + _shared_camera = OpenCVCamera(log_level="WARNING", resolution=CameraResolution.R1280x720) + _shared_camera.open(udp_camera="udp://@127.0.0.1:5005" if is_sim else None) + _camera_refs += 1 + return _shared_camera + + +def _release_camera() -> None: + """Release camera reference and close if no more clients.""" + global _shared_camera, _camera_refs + _camera_refs -= 1 + if _camera_refs <= 0 and _shared_camera is not None: + _shared_camera.close() + _shared_camera = None + _camera_refs = 0 + + +@router.get("/stream") +async def stream_camera( + backend: Backend = Depends(get_backend), + daemon: Daemon = Depends(get_daemon), +) -> StreamingResponse: + """Stream camera feed as MJPEG.""" + + async def _stream() -> AsyncGenerator[bytes, None]: + is_sim = bool( + daemon.status().simulation_enabled and isinstance(backend, MujocoBackend) + ) + cam = await _get_shared_camera(is_sim) + try: + while True: + f = cam.read() + if f is not None: + f = cv2.resize(f, (640, 480)) + _, j = cv2.imencode(".jpg", f, [cv2.IMWRITE_JPEG_QUALITY, 80]) + if _: + yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + j.tobytes() + b"\r\n" + await asyncio.sleep(0.04) + finally: + _release_camera() + + return StreamingResponse(_stream(), media_type="multipart/x-mixed-replace; boundary=frame") + diff --git a/src/reachy_mini/daemon/backend/mujoco/backend.py b/src/reachy_mini/daemon/backend/mujoco/backend.py index aba69eaa..49a1c8ac 100644 --- a/src/reachy_mini/daemon/backend/mujoco/backend.py +++ b/src/reachy_mini/daemon/backend/mujoco/backend.py @@ -167,8 +167,8 @@ def run(self) -> None: if not self.headless: viewer.sync() - rendering_thread = Thread(target=self.rendering_loop, daemon=True) - rendering_thread.start() + rendering_thread = Thread(target=self.rendering_loop, daemon=True) + rendering_thread.start() # 3) now enter your normal loop while not self.should_stop.is_set():