feat: GET /api/raw/latest, color + depth of one frameset without align

For aligning on the host: on the Jetson rs.align.process costs ~104 ms and
holds the GIL (optimize-streams R04/R05). JSON in the usual envelope, one
response: frameset_seq, camera_timestamp_ms, timestamp_domain,
host_timestamp, color_jpeg_b64 (color grid), depth_png_b64 (16-bit PNG, mm,
depth grid), color_shape, depth_shape, depth_units. after=N / timeout_ms as
/api/color/latest. JSON + base64 like /api/capture: every client already
parses that envelope, one request gets both images, base64 costs ~0.1 ms.
Built once per frameset (EncodedRaw, last 4 kept); the color JPEG is the
color channel's own (same bytes). The depth is converted only on request
(camera.raw_frame, phase raw_depth; PNG in phase png_raw_depth).
camera: _published() shared by color_frame() and raw_frame(); _depth_mm()
extracted unchanged from _aligned_frame().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
OliviaCandClaude Opus 5.5 committed 2026-10-01 10:11:09 +02:00
1 parent 3b6c17c86e
commit 22815e9b0f
3 files changed
+117 -7

No files matched your search

+78 -1
View File
@@ -12,7 +12,7 @@ from fastapi.responses import Response, JSONResponse
from src.backend.api.dependencies import verify_api_key
from src.backend.models.api import ok, fail
from src.backend.services.camera import RealSenseD435, CameraFrame, ColorFrame
from src.backend.services.camera import RealSenseD435, CameraFrame, ColorFrame, RawFrame
from src.backend.services.image_saver import ImageSaver
from src.backend.services.phase_timing import timer
@@ -187,6 +187,56 @@ def color_headers(seq: int, color: ColorFrame) -> dict:
}
ENCODED_RAWS_KEPT = 4 # recent framesets whose raw depth PNG is kept
class EncodedRaw:
"""/api/raw/latest payload of one frameset, built once for all its clients.
The color JPEG is the one of the color channel (same frameset, same bytes).
"""
def __init__(self, seq: int, raw: RawFrame):
self.seq = seq
self.raw = raw
self._lock = threading.Lock()
self._payload: dict | None = None
def payload(self) -> dict:
with self._lock:
if self._payload is None:
with timer.phase("png_raw_depth"):
_, buf = cv2.imencode(".png", self.raw.depth)
color = self.raw.color
self._payload = {
"frameset_seq": self.seq,
"camera_timestamp_ms": color.camera_timestamp_ms,
"timestamp_domain": color.timestamp_domain,
"host_timestamp": color.host_timestamp,
"color_shape": list(color.rgb.shape),
"depth_shape": list(self.raw.depth.shape),
"depth_units": "mm",
"color_jpeg_b64": _b64(encoded_color(self.seq, color).jpeg()),
"depth_png_b64": _b64(buf.tobytes()),
}
return self._payload
_encoded_raws: deque = deque(maxlen=ENCODED_RAWS_KEPT)
_encoded_raws_lock = threading.Lock()
def encoded_raw(seq: int, raw: RawFrame) -> EncodedRaw:
"""The shared EncodedRaw of frameset number `seq` (built from the first RawFrame seen for it)."""
with _encoded_raws_lock:
for enc in _encoded_raws:
if enc.seq == seq and enc.raw.color is raw.color:
return enc
enc = EncodedRaw(seq, raw)
_encoded_raws.append(enc)
return enc
def _next_frame_id() -> int:
global _frame_count
with _frame_count_lock:
@@ -417,6 +467,33 @@ def get_color_latest(
headers=color_headers(seq, color))
@router.get("/raw/latest", summary="Raw color + depth of one frameset (no align)", tags=["capture"])
def get_raw_latest(
after: int | None = Query(default=None, ge=0, description="Wait for a frameset numbered above this"),
timeout_ms: int = Query(default=1000, ge=0, le=COLOR_WAIT_MAX_MS),
):
"""Color and depth of the SAME frameset, NOT aligned, for aligning on the host.
JSON in the usual envelope: frameset_seq (the numbering of /api/color/latest
and of frameset_seq in /api/capture), camera_timestamp_ms, timestamp_domain,
host_timestamp, color_jpeg_b64 (color sensor grid), depth_png_b64 (16-bit
PNG, millimeters, depth sensor grid), color_shape, depth_shape,
depth_units. Calibration to align them: /api/intrinsics. With `after=N`,
waits up to `timeout_ms` for a frameset numbered above N. 504 when none
arrives in time.
"""
if not _camera or not _camera.is_open():
return fail("CAMERA_NOT_OPEN", "Camera not open")
seq, raw = _camera.raw_frame(after, timeout_ms)
if raw is None:
raise HTTPException(504, "No new frameset")
payload = encoded_raw(seq, raw).payload()
with timer.phase("json_raw"):
return JSONResponse(ok(payload))
@router.get("/pointcloud", summary="3D Point cloud", tags=["capture"])
def get_pointcloud(
step: int = Query(default=4, ge=1, le=16),