diff --git a/README.md b/README.md index 0aa455f..ddae278 100644 --- a/README.md +++ b/README.md @@ -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`) | diff --git a/src/backend/api/routes.py b/src/backend/api/routes.py index 63ac78a..a38cada 100644 --- a/src/backend/api/routes.py +++ b/src/backend/api/routes.py @@ -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), diff --git a/src/backend/services/camera.py b/src/backend/services/camera.py index bd1aa4f..06a9d26 100644 --- a/src/backend/services/camera.py +++ b/src/backend/services/camera.py @@ -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]: