Files
RealSense-D435i-G1/tests/test_parallel_capture.py
T
OliviaCandClaude Opus 5.5 47ebc4fbc0 feat: continuous color acquisition without align, align only on request
Two threads while the camera is open:
- realsense-raw (continuous, FrameAcquisition(on_demand=False)): the only
  caller of wait_for_frames. For every frameset it publishes the color image
  (own BGR copy) with the camera timestamp, its domain and the host arrival
  time, and keeps the SDK frameset. No align and no depth conversion at the
  camera rate: per frame it costs wait + one color copy (YUYV decode on USB 2).
- realsense-align (on demand, as in R01): when someone asks for color+depth
  it aligns the newest frameset not aligned yet, converts the depth, copies.
  Concurrent callers share one align. capture(), the 100 ms cache of
  get_rgb/get_depth/cached_frame and every existing endpoint read this
  aligned frame, so /api/rgb stays the color of the same frame as /api/depth.
The aligned color reuses the raw color pixels (align maps depth onto color).
At most 3 SDK framesets are referenced at a time (latest, receiving,
aligning); ColorFrame and CameraFrame hold no SDK memory.
New timing phase color_convert. One commit for items 1 and 2 of the request:
once the continuous thread owns the pipeline, the aligned path must take
its framesets, so they cannot be split without a broken intermediate state.
color_frame(after_seq, timeout_ms) gives the fast channel to the API.

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

108 lines
4.1 KiB
Python

"""The API on a fake 100 ms camera: parallel requests are not serialized, the contract is unchanged."""
import asyncio
import statistics
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 FakePipeline, open_fake_camera # noqa: E402
# Fields of /api/capture before optimize-streams R01 (the multi-camera service parses them).
CAPTURE_FIELDS = {
"frame_id", "timestamp", "rgb_shape", "depth_shape", "depth_min_mm", "depth_max_mm",
"depth_valid_pixels", "rgb_jpeg_b64", "depth_png_b64", "depth_color_b64",
}
@pytest.fixture
def fake_camera():
cam = RealSenseD435()
pipe = FakePipeline()
open_fake_camera(cam, pipe)
routes.set_camera(cam) # the lifespan (not run in tests) injects the camera into both
websocket.set_camera(cam)
yield cam, pipe
cam.close()
routes.set_camera(None)
websocket.set_camera(None)
async def _wall_time(client, n: int) -> tuple[float, list]:
t0 = time.perf_counter()
responses = await asyncio.gather(*(client.get("/api/capture") for _ in range(n)))
return time.perf_counter() - t0, [r.json() for r in responses]
def test_three_parallel_captures_take_less_than_twice_one(fake_camera):
"""The criterion declared for R01: t(3 parallel) < 2 x t(1 alone), medians of 5."""
async def run():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client:
await _wall_time(client, 1)
one = [(await _wall_time(client, 1))[0] for _ in range(5)]
three = [(await _wall_time(client, 3))[0] for _ in range(5)]
return statistics.median(one), statistics.median(three)
one, three = asyncio.run(run())
assert three < 2 * one, f"1 alone {one*1000:.0f} ms, 3 parallel {three*1000:.0f} ms"
def test_capture_fields_unchanged_and_one_frame_shared(fake_camera):
_, pipe = fake_camera
async def run():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client:
return (await _wall_time(client, 3))[1]
bodies = asyncio.run(run())
assert all(b["success"] for b in bodies)
data = [b["data"] for b in bodies]
assert all(set(d) == CAPTURE_FIELDS for d in data)
assert len({d["frame_id"] for d in data}) == 3 # frame_id still counts responses
assert len({d["timestamp"] for d in data}) == 1 # ... all three served by one frame
assert len({d["rgb_jpeg_b64"] for d in data}) == 1 # ... with the same bytes
assert pipe.max_concurrent == 1 # only one thread calls the pipeline
def test_rgb_and_depth_endpoints_decode(fake_camera):
client = TestClient(app)
rgb = cv2.imdecode(np.frombuffer(client.get("/api/rgb").content, np.uint8), cv2.IMREAD_COLOR)
depth = cv2.imdecode(np.frombuffer(client.get("/api/depth").content, np.uint8), cv2.IMREAD_UNCHANGED)
color = cv2.imdecode(np.frombuffer(client.get("/api/depth/color").content, np.uint8), cv2.IMREAD_COLOR)
assert rgb.shape == (480, 640, 3)
assert depth.shape == (480, 640) and depth.dtype == np.uint16
assert color.shape == (480, 640, 3)
def test_websocket_streams_frames(fake_camera):
with TestClient(app).websocket_connect("/ws") as ws:
ws.send_text("start")
first, second = ws.receive_json(), ws.receive_json()
ws.send_text("close")
assert set(first) == CAPTURE_FIELDS
assert second["timestamp"] > first["timestamp"]
def test_close_stops_the_acquisition_threads():
cam = RealSenseD435()
pipe = FakePipeline()
open_fake_camera(cam, pipe)
assert cam.capture() is not None
threads = [cam._raw, cam._aligned]
cam.close()
assert not cam.is_open()
assert pipe.stopped
assert not any(t._thread.is_alive() for t in threads)
assert cam.capture() is None
assert cam.color_frame() == (0, None)