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>
This commit is contained in:
OliviaCandClaude Opus 5.5 committed 2026-09-30 16:55:19 +02:00
1 parent 5c97d166be
commit 13826adc97
2 files changed
+202 -17

No files matched your search

+68 -17
View File
@@ -1,7 +1,12 @@
"""A fake pyrealsense2 pipeline for tests: renders one 640x480 frameset per call after a fixed latency.
"""A fake pyrealsense2 pipeline for tests (there is no RealSense on the development PC).
There is no RealSense on the development PC. The driver only uses a few
methods of the SDK objects, and these fakes provide exactly those.
Two ways to deliver frames:
- per call (default): wait_for_frames blocks `latency_s` and returns a new frameset;
- clocked (`fps=...`): the camera produces a frameset every 1/fps s whether anyone
reads it or not, and wait_for_frames returns the next one, like the real device.
Frame N carries its number in the pixels (a 16x16 color block = N % 256, depth[0, 0] = N)
so a test can tell which frame an image came from. FakeAlign stands in for rs.align.
The driver only uses a few methods of the SDK objects, and these fakes provide exactly those.
"""
import threading
@@ -9,13 +14,16 @@ import time
import numpy as np
FAKE_LATENCY_S = 0.1 # one frame every 100 ms, as the request's test asks
FAKE_LATENCY_S = 0.1 # one frame every 100 ms, as the R01 test asks
WIDTH, HEIGHT = 640, 480
MARK = 16 # side of the color block that carries the frame number
class FakeVideoFrame:
def __init__(self, data: np.ndarray):
def __init__(self, data: np.ndarray, index: int, fps: float):
self._data = data
self._index = index
self._fps = fps
def get_data(self):
# A buffer over the frame's memory, as pyrealsense2's BufData: np.asanyarray() of it is a view.
@@ -31,16 +39,28 @@ class FakeVideoFrame:
def get_width(self) -> int:
return self._data.shape[1]
def get_timestamp(self) -> float:
"""Camera time of the frame, milliseconds."""
return self._index * 1000.0 / self._fps
def get_frame_timestamp_domain(self) -> str:
return "timestamp_domain.hardware_clock"
def __bool__(self) -> bool:
return True
class FakeFrameset:
def __init__(self, index: int):
def __init__(self, index: int, fps: float = 1.0 / FAKE_LATENCY_S):
self.index = index
rng = np.random.default_rng(index)
# Textured images, so the JPEG/PNG encoders do realistic work.
self._color = FakeVideoFrame(rng.integers(0, 255, (HEIGHT, WIDTH, 3), dtype=np.uint8))
self._depth = FakeVideoFrame(rng.integers(300, 3000, (HEIGHT, WIDTH), dtype=np.uint16))
color = rng.integers(0, 255, (HEIGHT, WIDTH, 3), dtype=np.uint8)
color[:MARK, :MARK] = index % 256
depth = rng.integers(300, 3000, (HEIGHT, WIDTH), dtype=np.uint16)
depth[0, 0] = index
self._color = FakeVideoFrame(color, index, fps)
self._depth = FakeVideoFrame(depth, index, fps)
def get_color_frame(self):
return self._color
@@ -49,27 +69,45 @@ class FakeFrameset:
return self._depth
class FakePipeline:
"""wait_for_frames blocks FAKE_LATENCY_S and returns a new frameset; counts the calls."""
def frame_number_of_color(bgr: np.ndarray) -> int:
"""The frame number a fake color image carries (JPEG-tolerant: median of the block)."""
return int(np.median(bgr[2:MARK - 2, 2:MARK - 2]))
def __init__(self, latency_s: float = FAKE_LATENCY_S):
class FakePipeline:
"""wait_for_frames returns framesets numbered 1, 2, 3, ...; counts the calls."""
def __init__(self, latency_s: float = FAKE_LATENCY_S, fps: float | None = None):
self.latency_s = latency_s
self.fps = fps
self.calls = 0
self.max_concurrent = 0
self._active = 0
self._lock = threading.Lock()
self._start = time.monotonic()
self.stopped = False
self.last_frameset = None
def _next_clocked_index(self) -> int:
"""Sleep until the camera's next frame and return its number."""
period = 1.0 / self.fps
elapsed = time.monotonic() - self._start
index = int(elapsed / period) + 1
time.sleep(max(0.0, self._start + index * period - time.monotonic()))
return index
def wait_for_frames(self, timeout_ms: int = 5000):
with self._lock:
self._active += 1
self.max_concurrent = max(self.max_concurrent, self._active)
self.calls += 1
index = self.calls
call = self.calls
try:
if self.fps:
self.last_frameset = FakeFrameset(self._next_clocked_index(), self.fps)
else:
time.sleep(self.latency_s)
self.last_frameset = FakeFrameset(index)
self.last_frameset = FakeFrameset(call)
return self.last_frameset
finally:
with self._lock:
@@ -79,15 +117,28 @@ class FakePipeline:
self.stopped = True
def open_fake_camera(camera, pipeline: FakePipeline) -> None:
"""Put a RealSenseD435 in the 'open' state on a fake pipeline (USB 3 path, no align).
class FakeAlign:
"""rs.align stand-in: takes `delay_s` and returns the frameset (the fake depth is already on the color grid)."""
def __init__(self, delay_s: float):
self.delay_s = delay_s
self.calls = 0
def process(self, frameset):
self.calls += 1
time.sleep(self.delay_s)
return frameset
def open_fake_camera(camera, pipeline: FakePipeline, align: FakeAlign | None = None) -> None:
"""Put a RealSenseD435 in the 'open' state on a fake pipeline (USB 3 path; align only if given).
Uses the driver's own start hook when it has one, so the same helper serves
the driver before and after the acquisition thread was introduced.
every version of the driver.
"""
import pyrealsense2 as rs
camera._pipeline = pipeline
camera._align = None
camera._align = align
camera._color_format = rs.format.bgr8
camera._depth_scale = 0.001
camera._initialized = True
+134
View File
@@ -0,0 +1,134 @@
"""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)