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:
1 parent
47ebc4fbc0
commit
6a442e9f32
3 files changed
+81
-7
No files matched your search
@@ -165,7 +165,8 @@ suo `.env` l'URL della camera con `http://192.168.123.99:8878`.
|
||||
| `/api/open` | POST | Apre camera per `device_index` (di default partita in auto-open) |
|
||||
| `/api/close` | POST | Chiude camera |
|
||||
| `/api/capture` | GET | RGB+depth in JSON (base64) |
|
||||
| `/api/rgb` | GET | RGB come JPEG |
|
||||
| `/api/rgb` | GET | RGB come JPEG (colore del frame allineato: stesso frame di `/api/depth`) |
|
||||
| `/api/color/latest` | GET | **Canale colore veloce**: JPEG dell'ultimo frame colore, **senza align**, al ritmo della camera. `after=N` aspetta un frame numerato oltre N (fino a `timeout_ms`, default 1000, max 5000; 504 se non arriva). Header `X-Frame-Seq`, `X-Camera-Timestamp-Ms` (orologio della camera), `X-Camera-Timestamp-Domain`, `X-Host-Timestamp` (arrivo sull'host, s). Non va usato insieme a `/api/depth`: non è lo stesso frame |
|
||||
| `/api/depth` | GET | Depth come PNG 16-bit (mm) |
|
||||
| `/api/depth/color` | GET | Depth colormap JET come JPEG |
|
||||
| `/api/intrinsics` | GET | Calibrazione completa (`ir`+`rgb`+`extrinsics_ir_to_rgb`) |
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -454,15 +454,15 @@ class RealSenseD435:
|
||||
def color_frame(self, after_seq: Optional[int] = None, timeout_ms: int = 1000) -> tuple[int, Optional[ColorFrame]]:
|
||||
"""(number, image) of the fast color channel: the latest color frame, no align.
|
||||
|
||||
With `after_seq`, waits up to `timeout_ms` for a frame numbered above it.
|
||||
With `after_seq`, waits up to `timeout_ms` for a frame numbered above it;
|
||||
without, returns the latest one (waiting only for the very first).
|
||||
"""
|
||||
raw = self._raw
|
||||
if not self._open or raw is None:
|
||||
return 0, None
|
||||
if after_seq is None:
|
||||
seq, published = raw.latest()
|
||||
else:
|
||||
seq, published = raw.wait_newer(after_seq, timeout_ms / 1000.0)
|
||||
seq, published = raw.latest()
|
||||
if after_seq is not None or published is None:
|
||||
seq, published = raw.wait_newer(after_seq or 0, timeout_ms / 1000.0)
|
||||
return seq, (published.color if published is not None else None)
|
||||
|
||||
def _grab_raw(self, timeout_ms: int) -> Optional[RawFrameset]:
|
||||
|
||||
Reference in new issue
Block a user