added pause/wait functions for stream
This commit is contained in:
+181
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user