Files
orisys-sdk/tests/test_pipeline_pause.py
ykcheungab 6e3d509239 Add pipeline pause and resume at two levels
Pause now holds the pipeline without tearing it down: CAPTURE_ONLY keeps the camera
streaming so a live sensor stays converged while the flow and HHD stages stop, and BOTH stops
the read as well. Resume rebases the tracker, because a held reference frame would otherwise
report the hold itself as deformation.

Verified with tests/test_pipeline_pause.py: 10 passed across three consecutive runs.
2026-10-04 20:55:35 +08:00

318 lines
11 KiB
Python

"""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():
"""End of input is reported, and the hold path does not swallow it.
``wait`` is latest-wins, so it may skip stragglers and its sequence is not guaranteed
contiguous; ``on_frame`` is the channel that delivers every frame. What this asserts is the
invariant that matters: every produced frame is accounted for by one channel or the other,
``wait`` reports the end instead of hanging, and the end reads stably.
"""
frames = {"produced": 0}
via_callback: 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: via_callback.append(snap.seq)
) as pipe:
assert _wait_for(lambda: not pipe.running, timeout=3.0), "producer never finished"
drained: list[int] = []
deadline = time.perf_counter() + 2.0
while time.perf_counter() < deadline:
snap = pipe.wait(timeout=0.2)
if snap is None:
break
drained.append(snap.seq)
else:
pytest.fail("wait never reported the end of input")
# Every frame reached a consumer. The two channels are independent - ``on_frame`` runs
# after ``_publish``, so a frame can be handed to both - which is why this checks
# coverage rather than disjointness.
assert sorted(set(drained) | set(via_callback)) == [0, 1, 2, 3, 4]
# wait hands its frames over in publish order.
assert drained == sorted(drained)
assert pipe.running is False
# And the end is stable, not a one-shot.
assert pipe.wait(timeout=0.2) is None