feat: one acquisition thread owns the pipeline, requests wait for its frames

FrameAcquisition: a single thread calls wait_for_frames + align (the old
capture() body, now _grab_once) and keeps the latest frame, numbered.
capture() no longer touches the pipeline: it waits for the next frame of
that thread; requests waiting at the same moment get the same frame, so N
callers cost one grab. get_rgb()/get_depth() keep their 100 ms cache,
now on the thread's latest frame (_last_frame is gone).

The thread grabs on demand, only while someone waits for a newer frame:
grabbing continuously at 30 fps would align frames nobody reads, and align
on the Jetson CPU is the main suspect of the capture cost. A grab error is
logged and retried, the thread never dies. close() stops it before the
pipeline and joins it after (pipeline.stop() ends a wait in progress).
open/close and camera_open are unchanged, so the healthcheck + autoheal
recovery of ede70a2 sees the same state as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
OliviaCandClaude Opus 5.5 committed 2026-09-29 12:59:13 +02:00
1 parent 9aa63e4e02
commit cc28127c9c
3 files changed
+250 -15

No files matched your search

+43 -15
View File
@@ -30,10 +30,14 @@ import cv2
import numpy as np import numpy as np
import pyrealsense2 as rs import pyrealsense2 as rs
from src.backend.services.frame_acquisition import FrameAcquisition
from src.backend.services.phase_timing import timer from src.backend.services.phase_timing import timer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
GRAB_TIMEOUT_MS = 5000 # wait_for_frames timeout of the acquisition thread (the old capture() default)
STOP_JOIN_S = GRAB_TIMEOUT_MS / 1000.0 + 1.0 # close() waits this long for the thread to leave a grab
@dataclass @dataclass
class CameraFrame: class CameraFrame:
@@ -110,7 +114,8 @@ class RealSenseD435:
self._initialized = False self._initialized = False
self._open = False self._open = False
self._discovered: list[DiscoveredDevice] = [] self._discovered: list[DiscoveredDevice] = []
self._last_frame: Optional[CameraFrame] = None # The only caller of wait_for_frames once the camera is open (see capture()).
self._acquisition: Optional[FrameAcquisition[CameraFrame]] = None
self._color_available: bool = True self._color_available: bool = True
self._color_format = rs.format.bgr8 self._color_format = rs.format.bgr8
self._usb_type: str = "unknown" self._usb_type: str = "unknown"
@@ -301,6 +306,7 @@ class RealSenseD435:
self.device_index = device_index self.device_index = device_index
self._open = True self._open = True
self._start_acquisition()
logger.info( logger.info(
f"RealSense D4xx opened: serial={serial} usb={usb_type} " f"RealSense D4xx opened: serial={serial} usb={usb_type} "
f"color={color_w}x{color_h}@{color_fps}({color_fmt}) " f"color={color_w}x{color_h}@{color_fps}({color_fmt}) "
@@ -315,11 +321,19 @@ class RealSenseD435:
def close(self) -> None: def close(self) -> None:
if not self._open: if not self._open:
return return
self._teardown_pipeline() acquisition, self._acquisition = self._acquisition, None
if acquisition is not None:
acquisition.request_stop()
self._teardown_pipeline() # pipeline.stop() also ends a wait_for_frames in progress
if acquisition is not None:
acquisition.join(STOP_JOIN_S)
self.device_index = None self.device_index = None
self._last_frame = None
logger.info("RealSense camera closed") logger.info("RealSense camera closed")
def _start_acquisition(self) -> None:
self._acquisition = FrameAcquisition(lambda: self._grab_once(GRAB_TIMEOUT_MS), name="realsense-acquisition")
self._acquisition.start()
def _teardown_pipeline(self) -> None: def _teardown_pipeline(self) -> None:
if self._pipeline is not None: if self._pipeline is not None:
try: try:
@@ -375,18 +389,33 @@ class RealSenseD435:
# Frame capture # Frame capture
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def capture(self, timeout_ms: int = 5000) -> Optional[CameraFrame]: def capture(self, timeout_ms: int = 5000) -> Optional[CameraFrame]:
if not self._open: """The next frame acquired 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.
"""
acquisition = self._acquisition
if not self._open or acquisition is None:
return None
seq, _ = acquisition.latest()
_, frame = acquisition.wait_newer(seq, timeout_ms / 1000.0)
return frame
def _grab_once(self, timeout_ms: int) -> Optional[CameraFrame]:
"""wait_for_frames + align + numpy conversion. Called by the acquisition thread only."""
pipeline, align = self._pipeline, self._align
if pipeline is None:
return None return None
try: try:
with timer.phase("wait_frames"): with timer.phase("wait_frames"):
frames = self._pipeline.wait_for_frames(timeout_ms) frames = pipeline.wait_for_frames(timeout_ms)
except Exception as e: except Exception as e:
logger.warning(f"wait_for_frames failed: {e}") logger.warning(f"wait_for_frames failed: {e}")
return None return None
if self._align is not None: if align is not None:
with timer.phase("align"): with timer.phase("align"):
frames = self._align.process(frames) frames = align.process(frames)
color_frame = frames.get_color_frame() color_frame = frames.get_color_frame()
depth_frame = frames.get_depth_frame() depth_frame = frames.get_depth_frame()
@@ -394,9 +423,7 @@ class RealSenseD435:
return None return None
with timer.phase("convert"): with timer.phase("convert"):
frame = self._frame_from(color_frame, depth_frame) return self._frame_from(color_frame, depth_frame)
self._last_frame = frame
return frame
def _frame_from(self, color_frame, depth_frame) -> CameraFrame: 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).""" """numpy BGR + depth in mm from the SDK frames (YUYV decode, USB 2 resize, depth scale)."""
@@ -431,11 +458,12 @@ class RealSenseD435:
return CameraFrame(rgb=rgb, depth=depth_mm, timestamp=time.time()) return CameraFrame(rgb=rgb, depth=depth_mm, timestamp=time.time())
def _cached_or_capture(self) -> Optional[CameraFrame]: def _cached_or_capture(self) -> Optional[CameraFrame]:
if ( """The latest frame if younger than frame_cache_ms, else the next one."""
self._last_frame is not None acquisition = self._acquisition
and (time.time() - self._last_frame.timestamp) * 1000.0 < self._frame_cache_ms if acquisition is not None:
): _, last = acquisition.latest()
return self._last_frame if last is not None and (time.time() - last.timestamp) * 1000.0 < self._frame_cache_ms:
return last
return self.capture() return self.capture()
def get_rgb(self) -> Optional[np.ndarray]: def get_rgb(self) -> Optional[np.ndarray]:
+105
View File
@@ -0,0 +1,105 @@
"""One thread owns the camera pipeline: it grabs frames on demand and keeps the latest one.
Requests never call the camera. A request that needs a new frame waits for
`seq > n`; the thread grabs ONE frame (wait_for_frames + align) and every
request waiting at that moment gets the same frame. The thread grabs only
when someone waits: grabbing continuously at the camera rate would align
frames nobody reads, and align on the Jetson CPU is the main suspect of the
capture cost (optimize-streams R01).
The thread never dies: an exception from `grab` is logged and it goes on.
"""
import logging
import threading
from typing import Callable, Generic, Optional, TypeVar
logger = logging.getLogger(__name__)
T = TypeVar("T")
ERROR_BACKOFF_S = 0.5 # pause after an unexpected grab error, so a broken SDK does not spin
EMPTY_BACKOFF_S = 0.05 # pause after a grab that returned nothing (closed pipeline, missing stream)
class FrameAcquisition(Generic[T]):
"""Latest frame, numbered, produced by one background thread calling `grab`."""
def __init__(self, grab: Callable[[], Optional[T]], name: str = "frame-acquisition"):
self._grab = grab
self._name = name
self._cond = threading.Condition()
self._seq = 0 # number of the latest frame (0 = none yet)
self._latest: Optional[T] = None
self._wanted = 0 # highest frame number someone is waiting for
self._stopping = False
self._thread: Optional[threading.Thread] = None
# Lifecycle -----------------------------------------------------------
def start(self) -> None:
self._stopping = False
self._thread = threading.Thread(target=self._run, name=self._name, daemon=True)
self._thread.start()
def request_stop(self) -> None:
"""Ask the thread to end; waiters wake up with nothing."""
with self._cond:
self._stopping = True
self._cond.notify_all()
def join(self, timeout_s: float) -> bool:
"""Wait for the thread to end; False if it is still running (stuck in `grab`)."""
if self._thread is None:
return True
self._thread.join(timeout_s)
alive = self._thread.is_alive()
if alive:
logger.warning(f"{self._name}: thread still running {timeout_s:.1f} s after stop")
return not alive
# Read side -------------------------------------------------------------
def latest(self) -> tuple[int, Optional[T]]:
"""(number, frame) of the latest frame; (0, None) before the first one."""
with self._cond:
return self._seq, self._latest
def wait_newer(self, after_seq: int, timeout_s: float) -> tuple[int, Optional[T]]:
"""Wait for a frame numbered above `after_seq`; (seq, None) on timeout or stop."""
with self._cond:
if self._seq <= after_seq:
self._wanted = max(self._wanted, after_seq + 1)
self._cond.notify_all()
self._cond.wait_for(lambda: self._seq > after_seq or self._stopping, timeout_s)
if self._seq > after_seq:
return self._seq, self._latest
return self._seq, None
# Thread ----------------------------------------------------------------
def _run(self) -> None:
while True:
with self._cond:
self._cond.wait_for(lambda: self._wanted > self._seq or self._stopping)
if self._stopping:
return
frame = self._grab_safely()
if frame is None:
with self._cond:
self._cond.wait_for(lambda: self._stopping, EMPTY_BACKOFF_S)
continue
with self._cond:
self._seq += 1
self._latest = frame
self._cond.notify_all()
def _grab_safely(self) -> Optional[T]:
try:
return self._grab()
except Exception:
# Logged with its traceback, then retried: the acquisition must outlive any SDK error.
logger.exception(f"{self._name}: grab failed")
with self._cond:
self._cond.wait_for(lambda: self._stopping, ERROR_BACKOFF_S)
return None
+102
View File
@@ -0,0 +1,102 @@
"""FrameAcquisition: one thread grabs on demand; waiters share frames; errors never kill it."""
import threading
import time
import pytest
from src.backend.services.frame_acquisition import FrameAcquisition
LATENCY_S = 0.1
class SlowGrab:
"""Returns 1, 2, 3, ... after LATENCY_S each; optionally raises on chosen calls."""
def __init__(self, fail_on=()):
self.calls = 0
self.fail_on = set(fail_on)
def __call__(self):
self.calls += 1
time.sleep(LATENCY_S)
if self.calls in self.fail_on:
raise RuntimeError("SDK error")
return self.calls
@pytest.fixture
def acquisition():
made = []
def make(grab):
acq = FrameAcquisition(grab, name="test-acquisition")
acq.start()
made.append(acq)
return acq
yield make
for acq in made:
acq.request_stop()
assert acq.join(2.0)
def test_three_waiters_share_one_grab(acquisition):
grab = SlowGrab()
acq = acquisition(grab)
results = []
def wait():
results.append(acq.wait_newer(0, timeout_s=2.0))
threads = [threading.Thread(target=wait) for _ in range(3)]
t0 = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
elapsed = time.perf_counter() - t0
assert results == [(1, 1)] * 3
assert grab.calls == 1
assert elapsed < 2 * LATENCY_S
def test_no_grab_without_demand(acquisition):
grab = SlowGrab()
acquisition(grab)
time.sleep(3 * LATENCY_S)
assert grab.calls == 0
def test_an_exception_in_grab_does_not_stop_the_thread(acquisition):
grab = SlowGrab(fail_on={1})
acq = acquisition(grab)
seq, frame = acq.wait_newer(0, timeout_s=3.0)
assert (seq, frame) == (1, 2) # call 1 raised, call 2 delivered frame number 1
def test_timeout_returns_no_frame():
grab = SlowGrab()
acq = FrameAcquisition(grab) # never started: nothing will arrive
assert acq.wait_newer(0, timeout_s=0.05) == (0, None)
def test_stop_wakes_waiters_and_ends_the_thread():
acq = FrameAcquisition(lambda: None) # a grab that never delivers
acq.start()
got = []
waiter = threading.Thread(target=lambda: got.append(acq.wait_newer(0, timeout_s=5.0)))
waiter.start()
time.sleep(0.1)
acq.request_stop()
waiter.join(1.0)
assert got == [(0, None)]
assert acq.join(1.0)
def test_latest_is_the_last_delivered_frame(acquisition):
acq = acquisition(SlowGrab())
assert acq.latest() == (0, None)
acq.wait_newer(0, timeout_s=2.0)
acq.wait_newer(1, timeout_s=2.0)
assert acq.latest() == (2, 2)