Skip to content

Commit dd48b10

Browse files
committed
Backend server split into components
Signed-off-by: anuunchin <anuun.ch@gmail.com>
1 parent 54a4562 commit dd48b10

11 files changed

Lines changed: 476 additions & 294 deletions

File tree

Makefile

Lines changed: 62 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,52 +2,64 @@
22
#
33
# SPDX-License-Identifier: MIT
44

5-
.PHONY: help lint lint-frontend lint-backend lint-licensing test test-frontend test-backend format format-frontend format-backend docker-build docker-build-frontend docker-build-backend docker-run-frontend docker-run-backend docker-stop docker-clean
5+
.PHONY: help \
6+
dev install install-frontend install-backend \
7+
lint lint-frontend lint-backend lint-licensing type-check-backend \
8+
format format-frontend format-backend \
9+
test test-frontend test-backend \
10+
run-backend-local run-frontend-local \
11+
docker-build docker-build-frontend docker-build-backend \
12+
docker-run-frontend docker-run-backend \
13+
docker-stop docker-clean
614

715
help:
816
@echo "make"
9-
@echo " dev (or install)"
10-
@echo " install all dependencies for development"
11-
@echo " install-frontend"
12-
@echo " install frontend dependencies (npm)"
13-
@echo " install-backend"
14-
@echo " install backend dependencies (uv)"
15-
@echo " lint"
16-
@echo " runs all linters and type checking (frontend, backend, licensing)"
17-
@echo " lint-frontend"
18-
@echo " lints frontend code with npm run lint"
19-
@echo " lint-backend"
20-
@echo " lints backend Python code with ruff"
21-
@echo " lint-licensing"
22-
@echo " lints licensing files with reuse"
23-
@echo " type-check-backend"
24-
@echo " type checks backend Python code with mypy"
25-
@echo " format"
26-
@echo " formats all code (frontend and backend)"
27-
@echo " format-frontend"
28-
@echo " formats frontend code with prettier"
29-
@echo " format-backend"
30-
@echo " formats backend Python code with ruff"
31-
@echo " test"
32-
@echo " runs all tests (frontend and backend)"
33-
@echo " test-frontend"
34-
@echo " runs frontend tests with vitest"
35-
@echo " test-backend"
36-
@echo " runs backend tests with pytest"
37-
@echo " docker-build"
38-
@echo " builds all Docker images (frontend and backend)"
39-
@echo " docker-build-frontend"
40-
@echo " builds frontend Docker image"
41-
@echo " docker-build-backend"
42-
@echo " builds backend Docker image"
43-
@echo " docker-run-frontend"
44-
@echo " runs frontend container on port 8080"
45-
@echo " docker-run-backend"
46-
@echo " runs backend container on port 8000"
47-
@echo " docker-stop"
48-
@echo " stops running containers"
49-
@echo " docker-clean"
50-
@echo " stops containers and removes Docker images"
17+
@echo " dev (or install)"
18+
@echo " install all dependencies for development"
19+
@echo " install-frontend"
20+
@echo " install frontend dependencies (npm)"
21+
@echo " install-backend"
22+
@echo " install backend dependencies (uv)"
23+
@echo " lint"
24+
@echo " runs all linters and type checking (frontend, backend, licensing)"
25+
@echo " lint-frontend"
26+
@echo " lints frontend code with npm run lint"
27+
@echo " lint-backend"
28+
@echo " lints backend Python code with ruff"
29+
@echo " type-check-backend"
30+
@echo " type checks backend Python code with mypy"
31+
@echo " lint-licensing"
32+
@echo " lints licensing files with reuse"
33+
@echo " format"
34+
@echo " formats all code (frontend and backend)"
35+
@echo " format-frontend"
36+
@echo " formats frontend code with prettier"
37+
@echo " format-backend"
38+
@echo " formats backend code with ruff"
39+
@echo " test"
40+
@echo " runs all tests (frontend and backend)"
41+
@echo " test-frontend"
42+
@echo " runs frontend tests with vitest"
43+
@echo " test-backend"
44+
@echo " runs backend tests with pytest"
45+
@echo " run-backend-local"
46+
@echo " runs backend locally with uvicorn"
47+
@echo " run-frontend-local"
48+
@echo " runs frontend locally with Vite (uses VITE_BACKEND_URL)"
49+
@echo " docker-build"
50+
@echo " builds all Docker images (frontend and backend)"
51+
@echo " docker-build-frontend"
52+
@echo " builds frontend Docker image"
53+
@echo " docker-build-backend"
54+
@echo " builds backend Docker image"
55+
@echo " docker-run-frontend"
56+
@echo " runs frontend container on port 8080"
57+
@echo " docker-run-backend"
58+
@echo " runs backend container on port 8000"
59+
@echo " docker-stop"
60+
@echo " stops running containers"
61+
@echo " docker-clean"
62+
@echo " stops containers and removes Docker images"
5163

5264
dev: install
5365

@@ -63,7 +75,7 @@ install-backend:
6375
cd src/backend && uv pip install -r requirements.txt
6476
cd src/backend && uv pip install -r requirements-dev.txt
6577

66-
lint: lint-frontend lint-backend lint-licensing type-check-backend
78+
lint: lint-frontend lint-backend lint-licensing
6779

6880
lint-frontend:
6981
cd src/frontend && npm run lint
@@ -91,6 +103,12 @@ test-frontend:
91103
test-backend:
92104
cd src/backend && uv run pytest
93105

106+
run-backend-local:
107+
cd src/backend && uv run uvicorn webrtc.server:app --host 0.0.0.0 --port 8000
108+
109+
run-frontend-local:
110+
cd src/frontend && VITE_BACKEND_URL=http://localhost:8000 npm run dev
111+
94112
docker-build: docker-build-frontend docker-build-backend
95113

96114
docker-build-frontend:

src/backend/__init__.py

Whitespace-only changes.

src/backend/camera.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# SPDX-FileCopyrightText: 2025 robot-visual-perception
2+
#
3+
# SPDX-License-Identifier: MIT
4+
import asyncio
5+
import contextlib
6+
import os
7+
import sys
8+
from typing import List, Optional, Tuple
9+
10+
import cv2
11+
import numpy as np
12+
13+
14+
class _SharedCamera:
15+
def __init__(self) -> None:
16+
self._refcount = 0
17+
self._lock = asyncio.Lock()
18+
self._cap: Optional[cv2.VideoCapture] = None
19+
self._frame: Optional[np.ndarray] = None
20+
self._running = False
21+
self._reader_task: Optional[asyncio.Task] = None
22+
23+
async def acquire(self) -> None:
24+
async with self._lock:
25+
self._refcount += 1
26+
if self._cap is None:
27+
idx = int(os.getenv("CAMERA_INDEX", "0"))
28+
self._cap = _open_camera(idx)
29+
self._running = True
30+
self._reader_task = asyncio.create_task(self._read_loop())
31+
32+
async def release(self) -> None:
33+
async with self._lock:
34+
self._refcount -= 1
35+
if self._refcount <= 0:
36+
self._running = False
37+
if self._reader_task:
38+
self._reader_task.cancel()
39+
with contextlib.suppress(Exception):
40+
await self._reader_task
41+
if self._cap is not None:
42+
self._cap.release()
43+
self._cap = None
44+
self._frame = None
45+
self._reader_task = None
46+
self._refcount = 0
47+
48+
async def _read_loop(self) -> None:
49+
loop = asyncio.get_running_loop()
50+
try:
51+
while self._running and self._cap:
52+
ok, frame = await loop.run_in_executor(None, _read_frame, self._cap)
53+
if ok:
54+
self._frame = frame
55+
else:
56+
await asyncio.sleep(0.03)
57+
except asyncio.CancelledError:
58+
pass
59+
60+
def latest(self) -> Optional[np.ndarray]:
61+
return self._frame
62+
63+
64+
_shared_cam = _SharedCamera()
65+
66+
67+
def _open_camera(idx: int) -> cv2.VideoCapture:
68+
"""Try platform-appropriate backends before giving up."""
69+
backends: List[int] = []
70+
if sys.platform.startswith("win"):
71+
backends = [cv2.CAP_DSHOW, cv2.CAP_ANY]
72+
elif sys.platform == "darwin":
73+
backends = [cv2.CAP_AVFOUNDATION, cv2.CAP_ANY]
74+
else:
75+
backends = [cv2.CAP_V4L2, cv2.CAP_ANY]
76+
77+
last_error: Optional[str] = None
78+
for backend in backends:
79+
cap = (
80+
cv2.VideoCapture(idx, backend)
81+
if backend != cv2.CAP_ANY
82+
else cv2.VideoCapture(idx)
83+
)
84+
if cap.isOpened():
85+
return cap
86+
cap.release()
87+
last_error = f"backend={backend}"
88+
89+
msg = f"Cannot open webcam at index {idx}"
90+
if last_error:
91+
msg += f" (last tried {last_error})"
92+
msg += ". Try CAMERA_INDEX=1 or ensure camera permissions are granted."
93+
raise RuntimeError(msg)
94+
95+
96+
def _read_frame(cap: cv2.VideoCapture) -> Tuple[bool, Optional[np.ndarray]]:
97+
"""Run in a thread to grab frames without blocking asyncio loop."""
98+
return cap.read()

src/backend/detector.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# SPDX-FileCopyrightText: 2025 robot-visual-perception
2+
#
3+
# SPDX-License-Identifier: MIT
4+
import asyncio
5+
import os
6+
from typing import Optional
7+
8+
import cv2
9+
import numpy as np
10+
from ultralytics import YOLO # type: ignore[import-untyped]
11+
12+
13+
class _Detector:
14+
def __init__(self) -> None:
15+
self._model = YOLO("yolov8n.pt")
16+
self._last_det: Optional[list[tuple[int, int, int, int, int, float]]] = None
17+
self._last_time: float = 0.0
18+
self._lock = asyncio.Lock()
19+
self._fov_deg: float = float(os.getenv("CAMERA_HFOV_DEG", "60"))
20+
21+
async def infer(
22+
self, frame_bgr: np.ndarray
23+
) -> list[tuple[int, int, int, int, int, float]]:
24+
now = asyncio.get_running_loop().time()
25+
if self._last_det is not None and (now - self._last_time) < 0.10:
26+
return self._last_det
27+
28+
async with self._lock:
29+
now = asyncio.get_running_loop().time()
30+
if self._last_det is not None and (now - self._last_time) < 0.10:
31+
return self._last_det
32+
33+
rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
34+
results = self._model.predict(rgb, imgsz=640, conf=0.25, verbose=False)
35+
dets: list[tuple[int, int, int, int, int, float]] = []
36+
if results:
37+
r = results[0]
38+
if r.boxes is not None and len(r.boxes) > 0:
39+
xyxy = r.boxes.xyxy.cpu().numpy().astype(int)
40+
cls = r.boxes.cls.cpu().numpy().astype(int)
41+
conf = r.boxes.conf.cpu().numpy()
42+
for (x1, y1, x2, y2), c, p in zip(xyxy, cls, conf):
43+
dets.append(
44+
(int(x1), int(y1), int(x2), int(y2), int(c), float(p))
45+
)
46+
47+
self._last_det = dets
48+
self._last_time = now
49+
return dets
50+
51+
def estimate_distance_m(
52+
self, bbox: tuple[int, int, int, int], frame_width: int
53+
) -> float:
54+
x1, y1, x2, y2 = bbox
55+
pix_w = max(1, x2 - x1)
56+
obj_w_m = float(os.getenv("OBJ_WIDTH_M", "0.5"))
57+
fov_rad = np.deg2rad(self._fov_deg)
58+
focal_px = (frame_width / 2.0) / np.tan(fov_rad / 2.0)
59+
dist_m = (obj_w_m * focal_px) / pix_w
60+
scale = float(os.getenv("DIST_SCALE", "1.5"))
61+
return float(dist_m * scale)
62+
63+
64+
_detector = _Detector()

src/backend/routes.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# SPDX-FileCopyrightText: 2025 robot-visual-perception
2+
#
3+
# SPDX-License-Identifier: MIT
4+
import asyncio
5+
import contextlib
6+
7+
from fastapi import APIRouter, HTTPException, Response
8+
from aiortc import (
9+
RTCPeerConnection,
10+
RTCSessionDescription,
11+
RTCConfiguration,
12+
RTCIceServer,
13+
)
14+
from aiortc.rtcrtpsender import RTCRtpSender
15+
16+
from .schemas import SDPModel
17+
from .camera import _shared_cam
18+
from .tracks import CameraVideoTrack
19+
from .webrtc_utils import _cleanup_pc
20+
from .state import pcs, _datachannels
21+
22+
router = APIRouter()
23+
24+
25+
@router.get("/health")
26+
def health() -> dict[str, str]:
27+
return {"status": "ok"}
28+
29+
30+
# Explicit OPTIONS handlers to avoid 405 on preflight in some setups
31+
@router.options("/offer")
32+
@router.options("/offer/")
33+
def options_offer() -> Response:
34+
return Response(status_code=204)
35+
36+
37+
# Accept both /offer and /offer/
38+
@router.post("/offer")
39+
@router.post("/offer/")
40+
async def offer(sdp: SDPModel) -> dict[str, str]:
41+
if sdp.type != "offer":
42+
raise HTTPException(400, "type must be 'offer'")
43+
44+
cfg = RTCConfiguration(
45+
iceServers=[RTCIceServer(urls=["stun:stun.l.google.com:19302"])]
46+
)
47+
pc = RTCPeerConnection(configuration=cfg)
48+
pcs.append(pc)
49+
50+
ice_ready = asyncio.get_event_loop().create_future()
51+
52+
try:
53+
await _shared_cam.acquire()
54+
except Exception as e:
55+
await pc.close()
56+
pcs.remove(pc)
57+
raise HTTPException(500, f"Camera error: {e}")
58+
59+
local_video = CameraVideoTrack()
60+
pc.addTrack(local_video)
61+
62+
# Create a data channel for metadata
63+
ch = pc.createDataChannel("meta")
64+
_datachannels[id(pc)] = ch
65+
66+
# Prefer H.264 to support Safari and improve cross-browser compatibility
67+
try:
68+
caps = RTCRtpSender.getCapabilities("video").codecs
69+
h264 = [c for c in caps if getattr(c, "mimeType", "").lower() == "video/h264"]
70+
if h264:
71+
for t in pc.getTransceivers():
72+
if t.kind == "video":
73+
t.setCodecPreferences(h264)
74+
except Exception:
75+
pass
76+
77+
if pc.iceGatheringState == "complete":
78+
if not ice_ready.done():
79+
ice_ready.set_result(True)
80+
81+
@pc.on("icegatheringstatechange")
82+
def on_ice_gathering_state_change() -> None:
83+
if pc.iceGatheringState == "complete" and not ice_ready.done():
84+
ice_ready.set_result(True)
85+
86+
@pc.on("iceconnectionstatechange")
87+
async def on_ice_state_change() -> None:
88+
if pc.iceConnectionState in ("failed", "closed", "disconnected"):
89+
await _cleanup_pc(pc)
90+
91+
offer_desc = RTCSessionDescription(sdp=sdp.sdp, type=sdp.type)
92+
await pc.setRemoteDescription(offer_desc)
93+
answer = await pc.createAnswer()
94+
await pc.setLocalDescription(answer)
95+
96+
with contextlib.suppress(asyncio.TimeoutError):
97+
await asyncio.wait_for(ice_ready, timeout=5)
98+
99+
return {"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}
100+
101+
102+
# expose original shutdown hook name; server wires it
103+
async def on_shutdown() -> None:
104+
# keep same semantics as original
105+
await asyncio.gather(*[_cleanup_pc(pc) for pc in list(pcs)], return_exceptions=True)

0 commit comments

Comments
 (0)