diff --git a/src/backend/services/camera.py b/src/backend/services/camera.py index e853be3..54a7fa3 100644 --- a/src/backend/services/camera.py +++ b/src/backend/services/camera.py @@ -47,6 +47,11 @@ class CameraFrame: timestamp: float +def _owned(array: np.ndarray) -> np.ndarray: + """`array` if it owns its memory, else a copy (a view on an SDK buffer has a `base`).""" + return array if array.base is None else array.copy() + + @dataclass class CameraIntrinsics: """Per-sensor intrinsics. `*_ir` = depth, `*_rgb` = color.""" @@ -389,10 +394,11 @@ class RealSenseD435: # Frame capture # ------------------------------------------------------------------ def capture(self, timeout_ms: int = 5000) -> Optional[CameraFrame]: - """The next frame acquired after this call (None on timeout or when closed). + """The next frame COMPLETED after this call (None on timeout or when closed). - Does not touch the pipeline: it waits for the acquisition thread. - Requests waiting at the same moment get the same frame. + A grab already in progress when the call arrives may have started + before it. Does not touch the pipeline: it waits for the acquisition + thread. Requests waiting at the same moment get the same frame. """ acquisition = self._acquisition if not self._open or acquisition is None: @@ -423,7 +429,11 @@ class RealSenseD435: return None with timer.phase("convert"): - return self._frame_from(color_frame, depth_frame) + frame = self._frame_from(color_frame, depth_frame) + # Own copies: frames now live on after the grab (latest frame, 100 ms cache, + # shared encodings), and views would keep the SDK's frame pool busy. + with timer.phase("copy"): + return CameraFrame(rgb=_owned(frame.rgb), depth=_owned(frame.depth), timestamp=frame.timestamp) def _frame_from(self, color_frame, depth_frame) -> CameraFrame: """numpy BGR + depth in mm from the SDK frames (YUYV decode, USB 2 resize, depth scale)."""