"""Two channels on a fake 30 fps camera: fast color (no align) and aligned color+depth on request.""" import asyncio import base64 import time import cv2 import numpy as np import pytest pytest.importorskip("pyrealsense2") from httpx import ASGITransport, AsyncClient # noqa: E402 from starlette.testclient import TestClient # noqa: E402 from src.backend.api import routes, websocket # noqa: E402 from src.backend.main import app # noqa: E402 from src.backend.services.camera import RealSenseD435 # noqa: E402 from tests.fake_realsense import FakeAlign, FakePipeline, frame_number_of_color, open_fake_camera # noqa: E402 FPS = 30 def _open(cam, align_s): pipe, align = FakePipeline(fps=FPS), FakeAlign(delay_s=align_s) open_fake_camera(cam, pipe, align) routes.set_camera(cam) websocket.set_camera(cam) return pipe, align @pytest.fixture def camera(): cams = [] def make(align_s=0.0, frame_cache_ms=100.0): cam = RealSenseD435(frame_cache_ms=frame_cache_ms) cams.append(cam) return (cam, *_open(cam, align_s)) yield make for cam in cams: cam.close() routes.set_camera(None) websocket.set_camera(None) def _decode(content: bytes, flags=cv2.IMREAD_COLOR) -> np.ndarray: return cv2.imdecode(np.frombuffer(content, np.uint8), flags) def test_rgb_then_capture_then_depth_come_from_one_frame(camera): """Criterion B: /api/capture in between must not move the frame /api/rgb and /api/depth share. frame_cache_ms is raised so the test does not depend on this PC's timing: what it checks is WHICH frame the cache serves, not the 100 ms. """ camera(frame_cache_ms=1000.0) client = TestClient(app) rgb_number = frame_number_of_color(_decode(client.get("/api/rgb").content)) capture = client.get("/api/capture").json()["data"] depth = _decode(client.get("/api/depth").content, cv2.IMREAD_UNCHANGED) capture_number = frame_number_of_color(_decode(base64.b64decode(capture["rgb_jpeg_b64"]))) assert capture_number != rgb_number # /api/capture did get a newer frame ... assert int(depth[0, 0]) % 256 == rgb_number # ... and /api/depth still pairs with /api/rgb def test_color_channel_serves_consecutive_frames_with_both_clocks(camera): camera() client = TestClient(app) first = client.get("/api/color/latest") seq = int(first.headers["X-Frame-Seq"]) seqs, marks = [seq], [] for _ in range(10): r = client.get("/api/color/latest", params={"after": seq, "timeout_ms": 1000}) assert r.status_code == 200 seq = int(r.headers["X-Frame-Seq"]) seqs.append(seq) camera_index = round(float(r.headers["X-Camera-Timestamp-Ms"]) * FPS / 1000.0) marks.append((frame_number_of_color(_decode(r.content)), camera_index % 256)) assert r.headers["X-Camera-Timestamp-Domain"] == "hardware_clock" assert abs(float(r.headers["X-Host-Timestamp"]) - time.time()) < 5.0 assert seqs == sorted(set(seqs)) # strictly newer each time assert all(pixel == clock for pixel, clock in marks) # the image is the frame its header names def test_color_channel_times_out_with_504(camera): camera() client = TestClient(app) seq = int(client.get("/api/color/latest").headers["X-Frame-Seq"]) t0 = time.perf_counter() r = client.get("/api/color/latest", params={"after": seq + 10_000, "timeout_ms": 100}) assert r.status_code == 504 assert time.perf_counter() - t0 < 1.0 def test_color_frame_is_encoded_once(camera): cam, _, _ = camera() seq, color = cam.color_frame() first = routes.encoded_color(seq, color) assert routes.encoded_color(seq, color) is first assert first.jpeg() is first.jpeg() def test_color_channel_keeps_camera_rate_while_capture_runs(camera): """Criteria A1/A2 in short (3 s): color >= 25 new frames/s while /api/capture loops with a 100 ms align.""" _, _, align = camera(align_s=0.1) duration = 3.0 async def run(): seqs, stamps = set(), set() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t", timeout=10) as c: stop_at = time.monotonic() + duration async def color(): after = int((await c.get("/api/color/latest")).headers["X-Frame-Seq"]) while time.monotonic() < stop_at: r = await c.get("/api/color/latest", params={"after": after, "timeout_ms": 1000}) if r.status_code == 200: after = int(r.headers["X-Frame-Seq"]) seqs.add(after) async def capture(): while time.monotonic() < stop_at: body = (await c.get("/api/capture")).json() if body["success"]: stamps.add(body["data"]["timestamp"]) await asyncio.gather(color(), capture()) return seqs, stamps seqs, stamps = asyncio.run(run()) assert len(seqs) / duration >= 25 assert len(stamps) >= 1 and align.calls >= len(stamps)