added pause/wait functions for stream

This commit is contained in:
ykcheungab
2026-09-30 21:23:08 +08:00
parent f700102067
commit 333fa8fcff
5 changed files with 497 additions and 8 deletions
+181 -5
View File
@@ -68,17 +68,42 @@ import threading
import time
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, field
from enum import Enum
from queue import Empty, Full, Queue
from typing import Any
import numpy as np
__all__ = ["FrameSnapshot", "PipelineStats", "StreamPipeline"]
__all__ = ["FrameSnapshot", "PipelineStats", "PauseLevel", "StreamPipeline"]
#: Array fields a snapshot can carry. ``image`` is the working grey frame; the
#: rest come from ``Sensor.read_info`` (already copied by the SDK).
_ARRAY_FIELDS = ("image", "flow", "vnormal", "vshear", "depth", "centroid", "contour")
#: How many periods the stages wait on the pause event before re-checking for a
#: stop. Small enough to resume promptly, large enough not to spin a core.
_PAUSE_POLL_S = 0.05
class PauseLevel(str, Enum):
"""What a pause holds still.
``BOTH`` stops the pipeline read and the compute stages: nothing is captured
and nothing is computed, so the sensor reports nothing until it resumes.
``CAPTURE_ONLY`` keeps reading frames and skips the flow and HHD stages. The
read is still issued, so a live camera keeps streaming: its exposure,
white balance and frame rate stay where the operator set them, and resuming
does not have to re-converge. What stops is the work - the three-stage
pipeline drops to one stage of cost, which is what makes a long hold cheap.
Both levels rebase the tracker on resume, so the hold does not turn into a
phantom deformation reading.
"""
BOTH = "both"
CAPTURE_ONLY = "capture_only"
@dataclass(frozen=True)
class FrameSnapshot:
@@ -187,6 +212,13 @@ class StreamPipeline:
#: Set by :meth:`reset` and consumed by the flow stage, so that resetting sensor state
#: never races the stage that owns that state.
self._reset_pending = threading.Event()
#: Pause state. ``_pause_level`` is what a pause would hold when armed;
#: ``_currently_capturing`` records whether the producer is still
#: reading, which decides whether a resume has to rebase the tracker.
self._pause = threading.Event()
self._pause_level: PauseLevel = PauseLevel.BOTH
self._currently_capturing = True
self._sensor_source: Any = None
self._lock = threading.Lock()
self._latest: FrameSnapshot | None = None
@@ -278,6 +310,107 @@ class StreamPipeline:
"""
self._reset_pending.set()
@property
def paused(self) -> bool:
return self._pause.is_set()
@property
def pause_level(self) -> PauseLevel:
"""The level in effect while paused (the last level requested)."""
return self._pause_level
def pause(self, level: PauseLevel | str = PauseLevel.CAPTURE_ONLY) -> None:
"""Hold the pipeline still until :meth:`resume`.
``level`` selects what stays running:
* :attr:`PauseLevel.BOTH` - stop reading *and* computing. The sensor
reports nothing at all while held.
* :attr:`PauseLevel.CAPTURE_ONLY` (default) - keep reading so a live
camera stays in its converged state (exposure, white balance, frame
rate), but skip the flow and HHD stages. Cost drops to the read alone.
No snapshots are published while paused, so :meth:`wait` returns
``None``. The tracker is rebased on resume, because a held reference
frame would otherwise report the hold itself as a large deformation -
use ``reset=False`` to resume without that rebase.
Safe to call from the consumer thread, and idempotent; re-pausing with a
different level takes effect immediately.
"""
if not isinstance(level, PauseLevel):
level = PauseLevel(level)
if self._done.is_set():
return
self._set_reading(level is PauseLevel.CAPTURE_ONLY)
self._pause_level = level
self._pause.set()
def resume(self, *, reset: bool = True) -> None:
"""Release a pause and continue processing.
With ``reset`` (the default) the tracker is rebased on the next frame,
which is what keeps the hold out of the force reading. Pass
``reset=False`` when the caller wants the pre-pause reference frame kept
- for example when the held period is known to be motion-free.
"""
if not self._pause.is_set():
return
if reset and self._currently_capturing:
# Frames read during the pause were never computed, so the sensor's
# reference frame is stale by however long the hold lasted. Ask for a
# rebase; the flow stage applies it before its next frame.
self._reset_pending.set()
# Arm capture *before* lifting the hold. The producer stops as soon as
# the pause flag is set and only returns once it is clear, so a level-1
# resume must have `_currently_capturing` true by then or the producer
# settles with capture mode off.
self._currently_capturing = True
self._pause.clear()
self._set_reading(True)
def _set_reading(self, reading: bool) -> None:
"""Turn the producer's capture on or off (level 1 versus level 2)."""
self._currently_capturing = bool(reading)
source = self._resolve_sensor_source()
if source is None:
return
try:
if reading:
source.start_background()
else:
source.stop_background()
except Exception: # noqa: BLE001 - a video file may have no such control
if self._verbose:
print("[pipeline] pause: could not toggle background capture", flush=True)
def _resolve_sensor_source(self) -> Any:
"""The sensor's capture source, or ``None`` for file/derived sources."""
if self._sensor_source is not None:
return self._sensor_source
source = getattr(self.sensor, "_capture_source", None)
if source is None:
return None
# File playback cannot be held this way, and stopping a fake source only
# breaks the read, so leave those alone.
try:
if not hasattr(source, "start_background") or not source.is_open():
return None
except Exception: # noqa: BLE001
return None
self._sensor_source = source
return source
def _skip_while_paused(self) -> bool:
"""True while a pause discards this stage's work."""
return self._pause.is_set() and not self._wait_out_pause()
def _wait_out_pause(self) -> bool:
"""Block while paused; ``False`` means the pipeline is stopping."""
while self._pause.is_set() and not self._done.is_set():
self._pause.wait(_PAUSE_POLL_S)
return not self._done.is_set()
def wait(self, timeout: float | None = None) -> FrameSnapshot | None:
"""Block until a snapshot newer than the last returned one exists.
@@ -285,11 +418,14 @@ class StreamPipeline:
frames are gone, which is what a live GUI/control loop wants. Use
``on_frame`` when every frame must be handled exactly once.
Returns ``None`` on timeout, or once the pipeline has ended without a
newer frame.
Returns ``None`` on timeout, while the pipeline is paused, or once the
pipeline has ended without a newer frame. ``running`` distinguishes a
hold from the end of input.
"""
with self._lock:
seen = self._published
if self._pause.is_set():
# A hold publishes nothing. Returning the pre-pause snapshot here
# would look like live data to a control loop.
return None
deadline = None if timeout is None else time.perf_counter() + timeout
while True:
with self._lock:
@@ -351,8 +487,32 @@ class StreamPipeline:
except Empty:
return None
def _keep_camera_fed(self) -> None:
"""Read and discard one frame, without touching the shared sensor state.
``get_img`` would write ``Sensor.img`` while the flow stage may still be
draining, so the capture source is read directly. The read is what keeps
a live camera's exposure and auto-white-balance converged for however
long the pause lasts; nothing downstream sees the frame.
"""
source = self._sensor_source
if source is None:
time.sleep(_PAUSE_POLL_S)
return
try:
source.read_latest(copy=False, wait_s=0.0)
except Exception: # noqa: BLE001 - a dropped frame is not a failure
time.sleep(_PAUSE_POLL_S)
def _producer_loop(self) -> None:
while not self._done.is_set():
if self._pause.is_set() and not self._currently_capturing:
# Level 1 hold: capture itself is stopped, so there is nothing to
# read. Wait it out, then drop the stale backlog.
if not self._wait_out_pause():
break
self._drain(self._q1)
continue
t0 = time.perf_counter()
frame = self._produce()
if frame is None:
@@ -371,6 +531,15 @@ class StreamPipeline:
item = self._get(self._q1)
if item is None:
break
if self._pause.is_set():
# Level 2 holds the frame in camera only: skip the flow work and
# let the queue drop this frame. A motion-free hold adds nothing
# to the track, and the reference frame is rebased on resume.
if not self._wait_out_pause():
break
self._drain(self._q1)
self._drain(self._q2)
continue
# Applied here, on the thread that owns the sensor's flow state, before this frame is
# computed: see reset() for why this must not run on the caller's thread.
if self._reset_pending.is_set():
@@ -394,6 +563,13 @@ class StreamPipeline:
item = self._get(self._q2)
if item is None:
break
if self._pause.is_set():
# Discard rather than compute: this is the stage the level-2 hold
# is meant to avoid paying for.
self._drain(self._q2)
if not self._wait_out_pause():
break
continue
frame, flow, t_cap, produce_s, flow_s = item
t0 = time.perf_counter()
# Explicit flow: compute_hhd reads self.flow, which the flow stage