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
+2 -1
View File
@@ -11,7 +11,7 @@ __author__ = "Orisys"
from . import calibration, capture, configs, finger, signal_processing, util from . import calibration, capture, configs, finger, signal_processing, util
from .export import StreamRecorder, mosaic_videos from .export import StreamRecorder, mosaic_videos
from .frame_processors import FFCFrameProcessor from .frame_processors import FFCFrameProcessor
from .pipeline import FrameSnapshot, PipelineStats, StreamPipeline from .pipeline import FrameSnapshot, PauseLevel, PipelineStats, StreamPipeline
from .sensor import Sensor from .sensor import Sensor
CompoundEyeSensor = Sensor CompoundEyeSensor = Sensor
@@ -45,6 +45,7 @@ __all__ = [
'StreamPipeline', 'StreamPipeline',
'FrameSnapshot', 'FrameSnapshot',
'PipelineStats', 'PipelineStats',
'PauseLevel',
# offline recording of arrow overlays # offline recording of arrow overlays
'StreamRecorder', 'StreamRecorder',
+181 -5
View File
@@ -68,17 +68,42 @@ import threading
import time import time
from collections.abc import Callable, Iterable, Sequence from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum
from queue import Empty, Full, Queue from queue import Empty, Full, Queue
from typing import Any from typing import Any
import numpy as np 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 #: Array fields a snapshot can carry. ``image`` is the working grey frame; the
#: rest come from ``Sensor.read_info`` (already copied by the SDK). #: rest come from ``Sensor.read_info`` (already copied by the SDK).
_ARRAY_FIELDS = ("image", "flow", "vnormal", "vshear", "depth", "centroid", "contour") _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) @dataclass(frozen=True)
class FrameSnapshot: class FrameSnapshot:
@@ -187,6 +212,13 @@ class StreamPipeline:
#: Set by :meth:`reset` and consumed by the flow stage, so that resetting sensor state #: Set by :meth:`reset` and consumed by the flow stage, so that resetting sensor state
#: never races the stage that owns that state. #: never races the stage that owns that state.
self._reset_pending = threading.Event() 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._lock = threading.Lock()
self._latest: FrameSnapshot | None = None self._latest: FrameSnapshot | None = None
@@ -278,6 +310,107 @@ class StreamPipeline:
""" """
self._reset_pending.set() 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: def wait(self, timeout: float | None = None) -> FrameSnapshot | None:
"""Block until a snapshot newer than the last returned one exists. """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 frames are gone, which is what a live GUI/control loop wants. Use
``on_frame`` when every frame must be handled exactly once. ``on_frame`` when every frame must be handled exactly once.
Returns ``None`` on timeout, or once the pipeline has ended without a Returns ``None`` on timeout, while the pipeline is paused, or once the
newer frame. pipeline has ended without a newer frame. ``running`` distinguishes a
hold from the end of input.
""" """
with self._lock: if self._pause.is_set():
seen = self._published # 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 deadline = None if timeout is None else time.perf_counter() + timeout
while True: while True:
with self._lock: with self._lock:
@@ -351,8 +487,32 @@ class StreamPipeline:
except Empty: except Empty:
return None 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: def _producer_loop(self) -> None:
while not self._done.is_set(): 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() t0 = time.perf_counter()
frame = self._produce() frame = self._produce()
if frame is None: if frame is None:
@@ -371,6 +531,15 @@ class StreamPipeline:
item = self._get(self._q1) item = self._get(self._q1)
if item is None: if item is None:
break 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 # 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. # computed: see reset() for why this must not run on the caller's thread.
if self._reset_pending.is_set(): if self._reset_pending.is_set():
@@ -394,6 +563,13 @@ class StreamPipeline:
item = self._get(self._q2) item = self._get(self._q2)
if item is None: if item is None:
break 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 frame, flow, t_cap, produce_s, flow_s = item
t0 = time.perf_counter() t0 = time.perf_counter()
# Explicit flow: compute_hhd reads self.flow, which the flow stage # Explicit flow: compute_hhd reads self.flow, which the flow stage
+300
View File
@@ -0,0 +1,300 @@
"""Tests for StreamPipeline pause/resume.
A fake sensor stands in for the real one: the pause contract is about which
stages run, not about camera hardware, and a fake makes that observable without
a device. The sensor exposes the same surface the stages use - ``get_img``,
``compute_flow``, ``compute_hhd``, ``compute_contact``, ``reset``, ``read_info``
and ``_force_raw`` - so the pipeline runs its real loops unchanged.
"""
from __future__ import annotations
import threading
import time
import numpy as np
import pytest
import orisys
from orisys import PauseLevel, StreamPipeline
class FakeInfo:
FNORMAL = "fnormal"
FNORMAL_RAW = "fnormal_raw"
FSHEARX = "fshearx"
FSHEARY = "fsheary"
VNORMAL = "vnormal"
VSHEAR = "vshear"
DEPTH = "depth"
CENTROID = "centroid"
CONTOUR = "contour"
class FakeSensor:
"""Minimal sensor surface, with counters for what each stage did."""
def __init__(self, *, source=None) -> None:
self.info = FakeInfo()
self.force_unit = "raw"
self.fnormal = 1.0
self.fshearx = 2.0
self.fsheary = 3.0
self.ftor = 0.0
self._force_raw = (10.0, 20.0, 30.0)
self.img_size_raw = (8, 8)
self.flow = None
self.img = None
self._force_filtered_raw = (10.0, 20.0, 30.0)
self._force_raw_unfiltered = (10.0, 20.0, 30.0)
self._force_unfiltered = (1.0, 2.0, 3.0)
self._lock = threading.Lock()
self.produced = 0
self.flow_calls = 0
self.hhd_calls = 0
self.reset_calls = 0
if source is not None:
self._capture_source = source
# ---- producer stage
def get_img(self):
with self._lock:
self.produced += 1
# A distinct frame each time, so the stages can be told apart.
return np.full((4, 4), float(self.produced), dtype=np.float32)
# ---- flow stage
def compute_flow(self, frame=None):
with self._lock:
self.flow_calls += 1
shape = (4, 4, 2)
self.flow = np.zeros(shape, dtype=np.float32)
# ---- hhd stage
def compute_hhd(self, flow=None):
with self._lock:
self.hhd_calls += 1
def compute_contact(self):
return None
def reset(self):
with self._lock:
self.reset_calls += 1
def read_info(self, *which):
values = []
for key in which:
if key in (FakeInfo.VNORMAL, FakeInfo.VSHEAR):
values.append(np.zeros((4, 4, 2), dtype=np.float32))
elif key == FakeInfo.DEPTH:
values.append(np.zeros((4, 4), dtype=np.float32))
elif key == FakeInfo.CENTROID:
values.append(np.zeros(2, dtype=np.float32))
elif key == FakeInfo.CONTOUR:
values.append(np.zeros(0, dtype=np.float32))
else:
values.append(1.0)
return tuple(values)
class FakeCaptureSource:
"""Records whether the camera was told to keep streaming."""
def __init__(self) -> None:
self.started = 0
self.stopped = 0
self.reads = 0
self._running = True
def is_open(self):
return True
def start_background(self):
self.started += 1
self._running = True
def stop_background(self, timeout: float = 1.0):
self.stopped += 1
self._running = False
def read_latest(self, *, copy: bool = True, wait_s: float = 0.0):
self.reads += 1
return True, np.zeros((4, 4), dtype=np.float32)
def _wait_for(predicate, timeout=2.0, interval=0.01):
deadline = time.perf_counter() + timeout
while time.perf_counter() < deadline:
if predicate():
return True
time.sleep(interval)
return predicate()
def test_pause_level_is_exported_from_the_package():
assert orisys.PauseLevel is PauseLevel
assert PauseLevel.CAPTURE_ONLY.value == "capture_only"
assert PauseLevel.BOTH.value == "both"
def test_capture_only_keeps_reading_but_stops_computing():
source = FakeCaptureSource()
sensor = FakeSensor(source=source)
with StreamPipeline(sensor, sensor.get_img, include=("image",)) as pipe:
assert _wait_for(lambda: pipe.stats.published >= 3)
assert pipe.paused is False
pipe.pause(PauseLevel.CAPTURE_ONLY)
assert pipe.paused is True
assert pipe.pause_level is PauseLevel.CAPTURE_ONLY
flow_at_pause = sensor.flow_calls
hhd_at_pause = sensor.hhd_calls
produced_at_pause = sensor.produced
time.sleep(0.4)
# The read keeps going, so a live camera stays converged...
assert sensor.produced > produced_at_pause, "capture-only must keep reading"
assert source.stopped == 0, "the camera must not be stopped"
# ...and the work stops: this is the part the hold is bought for.
assert sensor.flow_calls == flow_at_pause, "flow must not run while held"
assert sensor.hhd_calls == hhd_at_pause, "no HHD work may happen while held"
published_at_pause = pipe.stats.published
pipe.resume()
assert pipe.paused is False
assert _wait_for(lambda: pipe.stats.published > published_at_pause), (
"resume must publish frames again"
)
assert sensor.hhd_calls > hhd_at_pause
def test_pause_both_stops_capture_as_well_as_compute():
source = FakeCaptureSource()
sensor = FakeSensor(source=source)
with StreamPipeline(sensor, sensor.get_img, include=("image",)) as pipe:
assert _wait_for(lambda: pipe.stats.published >= 3)
pipe.pause(PauseLevel.BOTH)
assert _wait_for(lambda: source.stopped >= 1), "capture must be stopped"
produced_at_pause = sensor.produced
hhd_at_pause = sensor.hhd_calls
published_at_pause = pipe.stats.published
time.sleep(0.4)
assert sensor.produced == produced_at_pause, "no capture while fully held"
assert sensor.hhd_calls == hhd_at_pause
assert pipe.stats.published == published_at_pause
pipe.resume()
assert _wait_for(lambda: source.started >= 1), "capture must restart"
assert _wait_for(lambda: pipe.stats.published > published_at_pause)
def test_resume_rebases_the_tracker_by_default():
sensor = FakeSensor()
with StreamPipeline(sensor, sensor.get_img, include=("image",)) as pipe:
assert _wait_for(lambda: pipe.stats.published >= 2)
resets_before = sensor.reset_calls
pipe.pause(PauseLevel.CAPTURE_ONLY)
time.sleep(0.1)
pipe.resume()
assert _wait_for(lambda: sensor.reset_calls > resets_before), (
"a held reference frame would otherwise read as deformation"
)
def test_resume_can_keep_the_reference_frame():
sensor = FakeSensor()
with StreamPipeline(sensor, sensor.get_img, include=("image",)) as pipe:
assert _wait_for(lambda: pipe.stats.published >= 2)
resets_before = sensor.reset_calls
pipe.pause(PauseLevel.CAPTURE_ONLY)
time.sleep(0.1)
pipe.resume(reset=False)
assert _wait_for(lambda: pipe.stats.published > 0)
time.sleep(0.2)
assert sensor.reset_calls == resets_before
def test_wait_returns_none_while_paused_and_frames_after_resume():
sensor = FakeSensor()
with StreamPipeline(sensor, sensor.get_img, include=("image",)) as pipe:
assert pipe.wait(timeout=2.0) is not None
pipe.pause(PauseLevel.CAPTURE_ONLY)
assert pipe.wait(timeout=0.1) is None, "a hold publishes nothing"
# A paused pipeline is not a finished pipeline: None here must not be
# read as end of input.
assert pipe.running is True
pipe.resume()
assert pipe.wait(timeout=2.0) is not None
def test_pause_is_idempotent_and_level_changes_apply():
source = FakeCaptureSource()
sensor = FakeSensor(source=source)
with StreamPipeline(sensor, sensor.get_img, include=("image",)) as pipe:
assert _wait_for(lambda: pipe.stats.published >= 2)
pipe.pause(PauseLevel.CAPTURE_ONLY)
pipe.pause(PauseLevel.CAPTURE_ONLY)
assert pipe.paused is True
pipe.pause(PauseLevel.BOTH)
assert pipe.pause_level is PauseLevel.BOTH
assert _wait_for(lambda: source.stopped >= 1)
pipe.resume()
pipe.resume() # second resume is a no-op
assert pipe.paused is False
def test_pause_accepts_a_plain_string_level():
sensor = FakeSensor()
with StreamPipeline(sensor, sensor.get_img, include=("image",)) as pipe:
assert _wait_for(lambda: pipe.stats.published >= 1)
pipe.pause("capture_only")
assert pipe.pause_level is PauseLevel.CAPTURE_ONLY
pipe.resume()
def test_stop_while_paused_terminates_promptly():
sensor = FakeSensor()
pipe = StreamPipeline(sensor, sensor.get_img, include=("image",))
pipe.start()
try:
assert _wait_for(lambda: pipe.stats.published >= 2)
pipe.pause(PauseLevel.BOTH)
time.sleep(0.1)
started = time.perf_counter()
pipe.stop(timeout=3.0)
assert time.perf_counter() - started < 2.0, "stop must not wait out the pause"
assert pipe.running is False
finally:
if pipe.running:
pipe.stop()
def test_end_of_input_is_still_reported_as_none():
"""The pause path must not swallow the end-of-stream sentinel.
Counted through ``on_frame``, which delivers every frame exactly once: with
a fast producer and a 5-frame source, ``wait`` is documented latest-wins and
legitimately skips frames, so it cannot be used to count them.
"""
frames = {"produced": 0}
delivered: list[int] = []
def produce():
frames["produced"] += 1
if frames["produced"] > 5:
return None
return np.zeros((4, 4), dtype=np.float32)
sensor = FakeSensor()
with StreamPipeline(
sensor, produce, include=("image",), on_frame=lambda snap: delivered.append(snap.seq)
) as pipe:
assert _wait_for(lambda: not pipe.running, timeout=3.0), "producer never finished"
assert len(delivered) == 5, delivered
# Once the input has ended, wait reports the end rather than hanging.
assert pipe.wait(timeout=0.5) is None
assert pipe.running is False
+14 -2
View File
@@ -131,16 +131,24 @@ def run_doc_gate(force_all: bool) -> dict:
""" """
script = REPO.parent / "scripts" / "doc_lint.py" script = REPO.parent / "scripts" / "doc_lint.py"
if not script.is_file(): if not script.is_file():
return {"status": "unavailable", "findings": [], "baselined": []} print(f"lint_gate: required documentation checker missing: {script}", file=sys.stderr)
return {"status": "error", "findings": [], "baselined": []}
cmd = [sys.executable, str(script), "--json"] cmd = [sys.executable, str(script), "--json"]
if force_all: if force_all:
cmd.append("--all") cmd.append("--all")
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", check=False) result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", check=False)
if result.returncode not in (0, 1):
print(f"lint_gate: documentation checker failed ({result.returncode}): {result.stderr.strip()}", file=sys.stderr)
return {"status": "error", "findings": [], "baselined": []}
if not result.stdout.strip(): if not result.stdout.strip():
print(f"lint_gate: doc gate produced no output: {result.stderr.strip()[:200]}", file=sys.stderr) print(f"lint_gate: doc gate produced no output: {result.stderr.strip()[:200]}", file=sys.stderr)
return {"status": "error", "findings": [], "baselined": []} return {"status": "error", "findings": [], "baselined": []}
try: try:
return json.loads(result.stdout) payload = json.loads(result.stdout)
expected = "clean" if result.returncode == 0 else "findings"
if not isinstance(payload, dict) or payload.get("status") != expected:
return {"status": "error", "findings": [], "baselined": []}
return payload
except json.JSONDecodeError: except json.JSONDecodeError:
print("lint_gate: could not parse doc gate output", file=sys.stderr) print("lint_gate: could not parse doc gate output", file=sys.stderr)
return {"status": "error", "findings": [], "baselined": []} return {"status": "error", "findings": [], "baselined": []}
@@ -161,6 +169,10 @@ def main(argv: list[str] | None = None) -> int:
docs: dict = {"status": "skipped", "findings": [], "baselined": []} docs: dict = {"status": "skipped", "findings": [], "baselined": []}
if not args.no_docs and args.files is None: if not args.no_docs and args.files is None:
docs = run_doc_gate(force_all=args.all) docs = run_doc_gate(force_all=args.all)
if docs.get("status") not in ("clean", "findings"):
if args.json:
print(json.dumps({"status": "error", "docs": docs, "findings": []}))
return 2
# Report the documentation verdict even when Ruff is unavailable: a missing linter must not # Report the documentation verdict even when Ruff is unavailable: a missing linter must not
# hide the docs result, and the exit code still signals the environment problem. # hide the docs result, and the exit code still signals the environment problem.