Skip to content

Commit 7d5560b

Browse files
committed
merge with main
Signed-off-by: Emil Badura <badura@tu-berlin.de>
2 parents 4e9e29b + 929b2c9 commit 7d5560b

File tree

11 files changed

+80
-44
lines changed

11 files changed

+80
-44
lines changed

CONTRIBUTING.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,20 +95,30 @@ Before you can run the project locally, install the required tools:
9595

9696
#### Prerequisites
9797

98-
**Node.js 20+** (for frontend)
98+
**Node.js 20** (for frontend)
99+
100+
Recommended: Use [nvm](https://github.com/nvm-sh/nvm) to manage Node.js versions:
99101
```bash
100-
brew install node@20
102+
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
103+
104+
cd src/frontend
105+
nvm install # Installs Node.js 20
106+
nvm use # Switches to Node.js 20
101107
```
102108

103-
**Python 3.11+** (for backend)
109+
Alternative (without nvm):
104110
```bash
105-
brew install python@3.11
111+
brew install node@20
106112
```
107113

114+
**Python 3.11** (for backend)
115+
116+
The Makefile will automatically install Python 3.11 via `uv`. You just need `uv` installed:
117+
108118
**uv** (Python package manager)
109119
```bash
110120
curl -LsSf https://astral.sh/uv/install.sh | sh
111-
# or on mac
121+
# or on macOS
112122
brew install uv
113123
```
114124

@@ -148,8 +158,7 @@ Linting catches code issues and enforces style guidelines. Always run before pus
148158
```bash
149159
make lint # Lint all components (includes type checking)
150160
make lint-frontend # Lint frontend with ESLint
151-
make lint-backend # Lint backend with Ruff
152-
make type-check-backend # Type check backend with mypy
161+
make lint-backend # Lints and type checks backend with Ruff
153162
```
154163

155164
Before you push your changes, run `make lint` and address any errors.

Makefile

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

5-
.PHONY: help lint lint-frontend lint-backend type-check-backend 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 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
66

77
help:
88
@echo "make"
@@ -54,20 +54,27 @@ dev: install
5454
install: install-frontend install-backend
5555

5656
install-frontend:
57+
@echo "Installing frontend dependencies (Node.js 20 required, see .nvmrc)"
5758
cd src/frontend && npm install
5859

5960
install-backend:
60-
cd src/backend && uv venv
61+
cd src/backend && uv python install # Auto-uses .python-version (3.11)
62+
cd src/backend && uv venv # Auto-uses .python-version (3.11)
6163
cd src/backend && uv pip install -r requirements.txt
6264
cd src/backend && uv pip install -r requirements-dev.txt
6365

66+
<<<<<<< HEAD
6467
lint: lint-frontend lint-backend lint-licensing type-check-backend
68+
=======
69+
lint: lint-frontend lint-backend
70+
>>>>>>> origin/main
6571

6672
lint-frontend:
6773
cd src/frontend && npm run lint
6874

6975
lint-backend:
7076
cd src/backend && uv run ruff check .
77+
cd src/backend && uv run mypy .
7178

7279
lint-licensing:
7380
reuse lint
@@ -80,9 +87,6 @@ format-frontend:
8087
format-backend:
8188
cd src/backend && uv run ruff format .
8289

83-
type-check-backend:
84-
cd src/backend && uv run mypy .
85-
8690
test: test-frontend test-backend
8791

8892
test-frontend:

src/backend/.python-version

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
3.11
2+

src/backend/requirements.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
# SPDX-FileCopyrightText: 2025 robot-visual-perception
22
#
33
# SPDX-License-Identifier: MIT
4+
fastapi==0.115.10
5+
aiortc==1.14.0
6+
av==16.0.1
7+
opencv-python==4.12.0.88
8+
pydantic==2.12.3
9+
uvicorn==0.38.0
10+
numpy==2.2.6

src/backend/webrtc/server.py

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,16 @@
1212
- Wildcard origins without credentials (spec-compliant).
1313
1414
Run:
15-
uvicorn server:app --host 0.0.0.0 --port 8000
15+
uv run uvicorn server:app --host 0.0.0.0 --port 8000
1616
"""
1717

1818
import asyncio
1919
import os
2020
import sys
2121
import contextlib
22-
from typing import Optional, List
22+
from typing import Optional, List, Tuple
2323

24+
import numpy as np
2425
import cv2
2526
from fastapi import FastAPI, HTTPException, Response
2627
from fastapi.middleware.cors import CORSMiddleware
@@ -32,22 +33,24 @@
3233
RTCIceServer,
3334
VideoStreamTrack,
3435
)
36+
from av import VideoFrame
37+
3538

3639
# -----------------------------
3740
# Global camera singleton
3841
# -----------------------------
3942

4043

4144
class _SharedCamera:
42-
def __init__(self):
45+
def __init__(self) -> None:
4346
self._refcount = 0
4447
self._lock = asyncio.Lock()
45-
self._cap = None
46-
self._frame = None
48+
self._cap: Optional[cv2.VideoCapture] = None
49+
self._frame: Optional[np.ndarray] = None
4750
self._running = False
4851
self._reader_task: Optional[asyncio.Task] = None
4952

50-
async def acquire(self):
53+
async def acquire(self) -> None:
5154
async with self._lock:
5255
self._refcount += 1
5356
if self._cap is None:
@@ -56,7 +59,7 @@ async def acquire(self):
5659
self._running = True
5760
self._reader_task = asyncio.create_task(self._read_loop())
5861

59-
async def release(self):
62+
async def release(self) -> None:
6063
async with self._lock:
6164
self._refcount -= 1
6265
if self._refcount <= 0:
@@ -72,7 +75,7 @@ async def release(self):
7275
self._reader_task = None
7376
self._refcount = 0
7477

75-
async def _read_loop(self):
78+
async def _read_loop(self) -> None:
7679
loop = asyncio.get_running_loop()
7780
try:
7881
while self._running and self._cap:
@@ -84,11 +87,13 @@ async def _read_loop(self):
8487
except asyncio.CancelledError:
8588
pass
8689

87-
def latest(self):
90+
def latest(self) -> Optional[np.ndarray]:
8891
return self._frame
8992

93+
9094
_shared_cam = _SharedCamera()
9195

96+
9297
def _open_camera(idx: int) -> cv2.VideoCapture:
9398
"""Try platform-appropriate backends before giving up."""
9499
backends: List[int] = []
@@ -101,7 +106,11 @@ def _open_camera(idx: int) -> cv2.VideoCapture:
101106

102107
last_error: Optional[str] = None
103108
for backend in backends:
104-
cap = cv2.VideoCapture(idx, backend) if backend != cv2.CAP_ANY else cv2.VideoCapture(idx)
109+
cap = (
110+
cv2.VideoCapture(idx, backend)
111+
if backend != cv2.CAP_ANY
112+
else cv2.VideoCapture(idx)
113+
)
105114
if cap.isOpened():
106115
return cap
107116
cap.release()
@@ -113,16 +122,17 @@ def _open_camera(idx: int) -> cv2.VideoCapture:
113122
msg += ". Try CAMERA_INDEX=1 or ensure camera permissions are granted."
114123
raise RuntimeError(msg)
115124

125+
116126
# -----------------------------
117127
# Media track
118128
# -----------------------------
119129
class CameraVideoTrack(VideoStreamTrack):
120130
kind = "video"
121131

122-
def __init__(self):
132+
def __init__(self) -> None:
123133
super().__init__()
124134

125-
async def recv(self):
135+
async def recv(self) -> VideoFrame:
126136
pts, time_base = await self.next_timestamp()
127137

128138
frame = None
@@ -138,15 +148,13 @@ async def recv(self):
138148
else:
139149
await asyncio.sleep(0.005)
140150

141-
from av import VideoFrame
142-
import cv2 as _cv2
143-
144-
rgb = _cv2.cvtColor(frame, _cv2.COLOR_BGR2RGB)
151+
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB).astype(np.uint8)
145152
video_frame = VideoFrame.from_ndarray(rgb, format="rgb24")
146153
video_frame.pts = pts
147154
video_frame.time_base = time_base
148155
return video_frame
149156

157+
150158
# -----------------------------
151159
# FastAPI app
152160
# -----------------------------
@@ -161,30 +169,37 @@ async def recv(self):
161169
allow_headers=["*"],
162170
)
163171

172+
164173
class SDPModel(BaseModel):
165174
sdp: str
166175
type: str # "offer"
167176

177+
168178
pcs: List[RTCPeerConnection] = []
169179

180+
170181
@app.get("/health")
171-
def health():
182+
def health() -> dict[str, str]:
172183
return {"status": "ok"}
173184

185+
174186
# Explicit OPTIONS handlers to avoid 405 on preflight in some setups
175187
@app.options("/offer")
176188
@app.options("/offer/")
177-
def options_offer():
189+
def options_offer() -> Response:
178190
return Response(status_code=204)
179191

192+
180193
# Accept both /offer and /offer/
181194
@app.post("/offer")
182195
@app.post("/offer/")
183-
async def offer(sdp: SDPModel):
196+
async def offer(sdp: SDPModel) -> dict[str, str]:
184197
if sdp.type != "offer":
185198
raise HTTPException(400, "type must be 'offer'")
186199

187-
cfg = RTCConfiguration(iceServers=[RTCIceServer(urls=["stun:stun.l.google.com:19302"])])
200+
cfg = RTCConfiguration(
201+
iceServers=[RTCIceServer(urls=["stun:stun.l.google.com:19302"])]
202+
)
188203
pc = RTCPeerConnection(configuration=cfg)
189204
pcs.append(pc)
190205

@@ -205,12 +220,12 @@ async def offer(sdp: SDPModel):
205220
ice_ready.set_result(True)
206221

207222
@pc.on("icegatheringstatechange")
208-
def on_ice_gathering_state_change():
223+
def on_ice_gathering_state_change() -> None:
209224
if pc.iceGatheringState == "complete" and not ice_ready.done():
210225
ice_ready.set_result(True)
211226

212227
@pc.on("iceconnectionstatechange")
213-
async def on_ice_state_change():
228+
async def on_ice_state_change() -> None:
214229
if pc.iceConnectionState in ("failed", "closed", "disconnected"):
215230
await _cleanup_pc(pc)
216231

@@ -224,11 +239,13 @@ async def on_ice_state_change():
224239

225240
return {"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}
226241

242+
227243
@app.on_event("shutdown")
228-
async def on_shutdown():
244+
async def on_shutdown() -> None:
229245
await asyncio.gather(*[_cleanup_pc(pc) for pc in list(pcs)], return_exceptions=True)
230246

231-
async def _cleanup_pc(pc: RTCPeerConnection):
247+
248+
async def _cleanup_pc(pc: RTCPeerConnection) -> None:
232249
if pc in pcs:
233250
pcs.remove(pc)
234251
with contextlib.suppress(Exception):
@@ -238,6 +255,6 @@ async def _cleanup_pc(pc: RTCPeerConnection):
238255
await _shared_cam.release()
239256

240257

241-
def _read_frame(cap: cv2.VideoCapture):
258+
def _read_frame(cap: cv2.VideoCapture) -> Tuple[bool, Optional[np.ndarray]]:
242259
"""Run in a thread to grab frames without blocking asyncio loop."""
243-
return cap.read()
260+
return cap.read()

src/frontend/.nvmrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
20
2+

src/frontend/index.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
SPDX-License-Identifier: MIT
55
-->
66

7-
<!DOCTYPE html>
7+
<!doctype html>
88
<html lang="en">
99
<head>
1010
<meta charset="UTF-8" />
@@ -16,4 +16,3 @@
1616
<script type="module" src="/src/main.tsx"></script>
1717
</body>
1818
</html>
19-

src/frontend/src/App.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,3 @@ function App() {
3131
}
3232

3333
export default App;
34-

src/frontend/src/main.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,3 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
1414
<App />
1515
</React.StrictMode>
1616
);
17-

src/frontend/tsconfig.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,3 @@
2222
},
2323
"include": ["src"]
2424
}
25-

0 commit comments

Comments
 (0)