feat: GET /api/color/latest, the fast color channel (no align)

JPEG of the latest color frame at the camera rate, not aligned to depth.
after=N waits up to timeout_ms (default 1000, max 5000) for a frame numbered
above N, so a client reads every frame without polling on empty; 504 when
none arrives. Headers: X-Frame-Seq, X-Camera-Timestamp-Ms (camera clock),
X-Camera-Timestamp-Domain, X-Host-Timestamp (arrival on the host).
Each color frame is encoded once for all clients (EncodedColor, own cache of
4: the color rate would flush the aligned frames' cache). New timing phase
jpeg_color. /api/rgb is unchanged: still the color of the aligned frame, the
same frame as /api/depth (README says so next to the new row).
color_frame() without after waits for the very first frame after open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
OliviaCandClaude Opus 5.5 committed 2026-09-30 16:53:44 +02:00
1 parent 47ebc4fbc0
commit 6a442e9f32
3 files changed
+81 -7

No files matched your search

+74 -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
from src.backend.services.camera import RealSenseD435, CameraFrame, ColorFrame
from src.backend.services.image_saver import ImageSaver
from src.backend.services.phase_timing import timer
@@ -139,6 +139,54 @@ def encoded(frame: CameraFrame) -> EncodedFrame:
return enc
COLOR_JPEG_QUALITY = 85 # same as /api/rgb
COLOR_WAIT_MAX_MS = 5000 # longest wait a client may ask for on the color channel
ENCODED_COLORS_KEPT = 4 # recent color frames whose JPEG is kept (own cache: 30 fps would flush the one above)
class EncodedColor:
"""JPEG of one frame of the color channel, encoded once for all its clients."""
def __init__(self, seq: int, color: ColorFrame):
self.seq = seq
self.color = color
self._lock = threading.Lock()
self._jpeg: bytes | None = None
def jpeg(self) -> bytes:
with self._lock:
if self._jpeg is None:
with timer.phase("jpeg_color"):
_, buf = cv2.imencode(".jpg", self.color.rgb, [cv2.IMWRITE_JPEG_QUALITY, COLOR_JPEG_QUALITY])
self._jpeg = buf.tobytes()
return self._jpeg
_encoded_colors: deque = deque(maxlen=ENCODED_COLORS_KEPT)
_encoded_colors_lock = threading.Lock()
def encoded_color(seq: int, color: ColorFrame) -> EncodedColor:
"""The shared EncodedColor of color frame number `seq`."""
with _encoded_colors_lock:
for enc in _encoded_colors:
if enc.seq == seq and enc.color is color:
return enc
enc = EncodedColor(seq, color)
_encoded_colors.append(enc)
return enc
def color_headers(seq: int, color: ColorFrame) -> dict:
"""Frame number and both clocks of a color frame, as response headers."""
return {
"X-Frame-Seq": str(seq),
"X-Camera-Timestamp-Ms": f"{color.camera_timestamp_ms:.3f}",
"X-Camera-Timestamp-Domain": color.timestamp_domain,
"X-Host-Timestamp": f"{color.host_timestamp:.6f}",
}
def _next_frame_id() -> int:
global _frame_count
with _frame_count_lock:
@@ -344,6 +392,31 @@ def get_depth_colormap():
return Response(content=encoded(frame).depth_color_jpeg(), media_type="image/jpeg")
@router.get("/color/latest", summary="Fast color channel (no align)", tags=["capture"])
def get_color_latest(
after: int | None = Query(default=None, ge=0, description="Wait for a frame numbered above this"),
timeout_ms: int = Query(default=1000, ge=0, le=COLOR_WAIT_MAX_MS),
):
"""Latest color JPEG of the fast channel: every camera frame, NOT aligned to depth.
Not /api/rgb: that one is the color of the aligned color+depth frame, the
same frame /api/depth returns. With `after=N`, waits up to `timeout_ms` for
a frame numbered above N, so a client can read every frame without polling
on empty. 504 when no such frame arrives in time. Headers: X-Frame-Seq,
X-Camera-Timestamp-Ms (camera clock), X-Camera-Timestamp-Domain,
X-Host-Timestamp (arrival on the host, seconds).
"""
if not _camera or not _camera.is_open():
raise HTTPException(503, "Camera not open")
seq, color = _camera.color_frame(after, timeout_ms)
if color is None:
raise HTTPException(504, "No new color frame")
return Response(content=encoded_color(seq, color).jpeg(), media_type="image/jpeg",
headers=color_headers(seq, color))
@router.get("/pointcloud", summary="3D Point cloud", tags=["capture"])
def get_pointcloud(
step: int = Query(default=4, ge=1, le=16),