Files
OliviaCandClaude Opus 5.5 13826adc97 test: two channels on a fake 30 fps camera with a 100 ms align
fake_realsense: clocked mode (a frameset every 1/fps s, like the device),
FakeAlign, camera timestamps, and the frame number written in the pixels
(color block, depth[0, 0]) so a test knows which frame an image came from.
test_two_channels:
- /api/rgb, /api/capture, /api/depth: rgb and depth from one frame (criterion
  B of optimize-streams R03; failed before the previous commit: 1 vs 2);
- /api/color/latest serves strictly newer frames, the image is the frame its
  headers name (camera clock), 504 on timeout, each frame encoded once;
- color >= 25 new frames/s while /api/capture loops (criteria A1/A2, 3 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:55:19 +02:00

135 lines
5.0 KiB
Python

"""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)