Files
orisys-sdk/tests/test_pipeline.py
T
ykcheungab c256870ee2 fix: 按复位请求目标确认参考版本并隔离复位状态
防止在途旧帧提前触发归零,并保留旧 reset 用法。SDK 101 项针对性回归与 lint 通过。
2026-10-20 20:33:23 +08:00

654 lines
23 KiB
Python

"""StreamPipeline: staging, snapshot immutability, error propagation, shutdown."""
from __future__ import annotations
import threading
import time
import numpy as np
import pytest
from orisys import FrameSnapshot, ProducedFrame, StreamPipeline
class FakeSensor:
"""Minimal Sensor stand-in with the same mutable-buffer behaviour.
It deliberately reuses one image buffer (like ``Sensor._img_gray_buf``) so the
pipeline's private-copy contract is actually exercised.
"""
class info:
VRAW = "vraw"
VNORMAL = "vnormal"
VSHEAR = "vshear"
DEPTH = "depth"
CENTROID = "centroid"
CONTOUR = "contour"
def __init__(self, *, fail_at: int | None = None, flow_delay: float = 0.0,
hhd_delay: float = 0.0):
self.img = None
self.flow = None
self.vnormal = None
self.vshear = None
self.depth_map = None
self.centroid = (0, 0)
self.contour = np.empty(0)
self.ftor = 0.0
self._force_raw = (0.0, 0.0, 0.0)
self.fnormal = self.fshearx = self.fsheary = 0.0
self.force_unit = "raw"
self._img_buf = np.zeros((8, 8), dtype=np.uint8)
self._flow_buf = np.zeros((8, 8, 2), dtype=np.float32)
self._flow_delay = flow_delay
self._hhd_delay = hhd_delay
self._fail_at = fail_at
self._flow_calls = 0
self._seen_images: list[np.ndarray] = []
self._seen_flow_values: list[float] = []
self.contact_calls = 0
#: Mirrors ``Sensor.reset()``, which rebases ``initial_img`` on the current frame. Records
#: the frame sequence at each call so a test can prove the reset did not race the flow
#: stage's read of that buffer.
self.img_size_raw = None
self.reset_calls = 0
self.reset_at_frame: list[int] = []
def make_frame(self, seq: int) -> np.ndarray:
# writes INTO the shared buffer, exactly like cv2.resize(dst=...)
self._img_buf.fill(seq % 251)
self.img = self._img_buf
return self._img_buf
def compute_flow(self, img=None, **kwargs) -> None:
image = self.img if img is None else img
self._flow_calls += 1
if self._fail_at is not None and self._flow_calls == self._fail_at:
raise RuntimeError("flow exploded")
self._seen_images.append(np.array(image, copy=True))
time.sleep(self._flow_delay)
# rewrite the shared buffer, like the real DIS/EMA path does
self._flow_buf.fill(float(self._flow_calls))
self.flow = self._flow_buf
def compute_hhd(self, flow=None, **kwargs) -> None:
# Mirrors Sensor.compute_hhd, which derives the forces from the field it
# is given - so this only yields frame N's forces if the pipeline passes
# frame N's flow explicitly.
time.sleep(self._hhd_delay)
field = self.flow if flow is None else flow
value = (float(field[0, 0, 0])
if isinstance(field, np.ndarray) and field.size else 0.0)
self._seen_flow_values.append(value)
self.fnormal = value * 10.0
self.fshearx = value
self.fsheary = -value
self._force_raw = (self.fnormal, self.fshearx, self.fsheary)
self.ftor = value
self.vnormal = self._flow_buf
self.vshear = self._flow_buf
def compute_contact(self) -> None:
self.contact_calls += 1
self.centroid = (1, 2)
self.contour = np.zeros((1, 1, 2), dtype=np.int32)
self.depth_map = np.zeros((8, 8), dtype=np.float32)
def reset(self) -> None:
self.reset_calls += 1
self.reset_at_frame.append(self._flow_calls)
def read_info(self, *args):
source = {
self.info.VRAW: lambda: self.flow.copy(),
self.info.VNORMAL: lambda: self.vnormal.copy(),
self.info.VSHEAR: lambda: self.vshear.copy(),
self.info.DEPTH: lambda: self.depth_map.copy(),
self.info.CENTROID: lambda: np.array(self.centroid),
self.info.CONTOUR: lambda: self.contour.copy(),
}
return tuple(source[a]() for a in args)
def make_producer(sensor: FakeSensor, count: int):
state = {"i": 0}
def produce():
if state["i"] >= count:
return None
frame = sensor.make_frame(state["i"])
state["i"] += 1
return frame
return produce
def run_all(sensor: FakeSensor, count: int, **kwargs):
"""Drive the pipeline with an exhaustive consumer: every frame, in order."""
frames: list = []
kwargs.setdefault("depth", 2)
pipe = StreamPipeline(sensor, make_producer(sensor, count),
on_frame=frames.append, **kwargs)
pipe.start()
deadline = time.perf_counter() + 10.0
while len(frames) < count and time.perf_counter() < deadline:
if pipe.error is not None:
break
time.sleep(0.001)
pipe.stop()
return pipe, frames
def test_on_frame_receives_every_frame_in_order():
sensor = FakeSensor()
pipe, frames = run_all(sensor, 12)
assert len(frames) == 12
assert [s.seq for s in frames] == list(range(12))
assert pipe.stats.produced == 12
assert pipe.stats.published == 12
def test_producer_metadata_stays_with_its_image_through_the_queues():
sensor = FakeSensor(flow_delay=0.002)
frames = []
produced = 0
def produce():
nonlocal produced
if produced == 12:
return None
value = produced
produced += 1
return ProducedFrame(sensor.make_frame(value), {"source_seq": value})
pipe = StreamPipeline(sensor, produce, on_frame=frames.append)
pipe.start()
deadline = time.perf_counter() + 10.0
while len(frames) < 12 and time.perf_counter() < deadline:
time.sleep(0.001)
pipe.stop()
assert len(frames) == 12
for snap in frames:
assert snap.source_metadata["source_seq"] == int(snap.image[0, 0])
def test_forces_come_from_the_matching_frame():
"""Each snapshot must carry the forces computed from its own frame's flow."""
sensor = FakeSensor()
_, frames = run_all(sensor, 10)
# frame i's flow value is (i + 1); the fake derives the force from self.flow,
# so a mismatched pairing would show up as a repeated or skipped value.
assert sensor._seen_flow_values == [float(i + 1) for i in range(10)]
assert [s.normal_force for s in frames] == [10.0 * (i + 1) for i in range(10)]
assert [s.shear_x for s in frames] == [float(i + 1) for i in range(10)]
def test_stages_do_not_read_a_rewritten_image_buffer():
"""The producer reuses one buffer; no stage may observe a later frame."""
sensor = FakeSensor(flow_delay=0.004)
run_all(sensor, 8)
seen = sensor._seen_images
assert len(seen) == 8
for i, arr in enumerate(seen):
assert np.all(arr == i % 251), f"frame {i} was overwritten before flow ran"
def test_snapshot_arrays_are_private_copies():
sensor = FakeSensor()
_, frames = run_all(sensor, 4, include=("image", "flow", "vnormal"))
first = frames[0]
assert isinstance(first, FrameSnapshot)
assert first.image is not None and first.flow is not None
saved = first.flow.copy()
# the SDK keeps rewriting its buffers; the snapshot must not change
sensor._flow_buf.fill(999.0)
sensor._img_buf.fill(7)
assert np.array_equal(first.flow, saved)
assert not np.any(first.flow == 999.0)
def test_include_filters_which_arrays_are_copied():
sensor = FakeSensor()
_, frames = run_all(sensor, 3, include=("vnormal",))
assert frames[0].vnormal is not None
assert frames[0].image is None and frames[0].flow is None
assert frames[0].depth is None and frames[0].contour is None
def test_rejects_unknown_include_field():
sensor = FakeSensor()
with pytest.raises(ValueError):
StreamPipeline(sensor, make_producer(sensor, 1), include=("floww",))
def test_contact_stage_runs_when_requested():
sensor = FakeSensor()
_, frames = run_all(sensor, 5, contact=True,
include=("depth", "centroid", "contour"))
assert sensor.contact_calls == 5
assert frames[-1].depth is not None
assert frames[-1].centroid is not None
assert frames[-1].contour is not None
def test_stage_exception_surfaces_on_stop():
sensor = FakeSensor(fail_at=3)
pipe = StreamPipeline(sensor, make_producer(sensor, 20), depth=2)
pipe.start()
time.sleep(0.4)
with pytest.raises(RuntimeError, match="flow exploded"):
pipe.stop()
def test_stage_exception_surfaces_on_wait():
sensor = FakeSensor(fail_at=2)
pipe = StreamPipeline(sensor, make_producer(sensor, 20), depth=2)
pipe.start()
with pytest.raises(RuntimeError):
for _ in range(50):
pipe.wait(timeout=0.1)
pipe.stop()
def test_stop_is_idempotent():
sensor = FakeSensor()
pipe, _ = run_all(sensor, 4)
pipe.stop() # second stop must not raise
assert not pipe.running
assert pipe.latest() is not None
def test_stop_while_running_does_not_hang():
"""Stopping mid-stream must unblock the consumer threads.
The producer stops delivering sentinels once the done flag is set, so a
bare blocking Queue.get() in a consumer would wait forever.
"""
sensor = FakeSensor(flow_delay=0.002)
pipe = StreamPipeline(sensor, make_producer(sensor, 100000), depth=2)
pipe.start()
assert pipe.wait(timeout=2.0) is not None
t0 = time.perf_counter()
pipe.stop(timeout=5.0)
elapsed = time.perf_counter() - t0
assert elapsed < 2.0, f"stop() took {elapsed:.2f}s; a stage was still blocked"
assert not pipe.running
def test_context_manager_stops_threads():
sensor = FakeSensor()
frames: list = []
with StreamPipeline(sensor, make_producer(sensor, 6),
on_frame=frames.append) as pipe:
deadline = time.perf_counter() + 5.0
while len(frames) < 6 and time.perf_counter() < deadline:
time.sleep(0.001)
assert len(frames) == 6
assert not pipe.running
def test_wait_exposes_a_latest_wins_view():
sensor = FakeSensor()
pipe = StreamPipeline(sensor, make_producer(sensor, 5))
pipe.start()
last = None
while True:
snap = pipe.wait(timeout=2.0)
if snap is None:
break
if last is not None:
assert snap.seq > last.seq
last = snap
# the pipeline has drained; the final snapshot is the last frame
assert last is not None and last.normal_force == 50.0
assert pipe.latest() is last
# nothing new is coming, so a further poll returns immediately
assert pipe.wait(timeout=0.2) is None
pipe.stop()
def test_wait_timeout_returns_none_without_new_frames():
sensor = FakeSensor()
pipe = StreamPipeline(sensor, make_producer(sensor, 1))
pipe.start()
assert pipe.wait(timeout=1.0) is not None
t0 = time.perf_counter()
assert pipe.wait(timeout=0.1) is None
assert time.perf_counter() - t0 < 1.0
pipe.stop()
def test_stats_track_stage_times_and_age():
sensor = FakeSensor(flow_delay=0.002, hhd_delay=0.001)
pipe, _ = run_all(sensor, 15)
d = pipe.stats.as_dict()
assert d["published"] == 15
assert d["flow_ms"] >= 1.5 # the injected delay shows up
assert d["hhd_ms"] >= 0.8
assert d["age_ms"] > 0.0
assert d["fps"] > 0.0
assert d["produce_ms"] >= 0.0
def test_depth_does_not_change_results():
for depth in (1, 2, 4):
sensor = FakeSensor()
_, frames = run_all(sensor, 10, depth=depth)
assert [s.normal_force for s in frames] == [10.0 * (i + 1) for i in range(10)]
def test_for_finger_runtime_maps_eof_to_none():
"""``for_finger_runtime`` drives ``grab_and_process`` and maps EOF to None."""
sensor = FakeSensor()
state = {"i": 0}
class FakeRuntime:
tactile = type("T", (), {"raw_sensor": sensor})()
def grab_and_process(self, color=False):
if state["i"] >= 3:
return False, None
frame = sensor.make_frame(state["i"])
state["i"] += 1
return True, frame
frames: list = []
pipe = StreamPipeline.for_finger_runtime(FakeRuntime(), on_frame=frames.append)
pipe.start()
deadline = time.perf_counter() + 5.0
while len(frames) < 3 and time.perf_counter() < deadline:
time.sleep(0.001)
pipe.stop()
assert len(frames) == 3
assert pipe.stats.produced == 3
def test_reset_is_applied_on_the_flow_stage_not_the_caller():
"""``reset()`` must not run on the caller's thread.
``Sensor.reset`` rebases ``initial_img`` on the current ``Sensor.img``, and the flow stage is
reading that buffer concurrently, so a caller-thread reset would capture a half-written
reference frame. The pipeline queues it and applies it before the next frame instead.
"""
sensor = FakeSensor()
ready = threading.Event()
release = threading.Event()
def gated_produce():
# Hold the producer until the caller has asked for a reset, so the reset is provably
# queued before any frame reaches the flow stage.
ready.set()
release.wait(timeout=5.0)
return sensor.make_frame(0)
frames: list = []
pipe = StreamPipeline(sensor, gated_produce, on_frame=frames.append)
pipe.start()
assert ready.wait(timeout=5.0)
pipe.reset()
pipe.reset() # idempotent: a second request before the next frame is not doubled
assert sensor.reset_calls == 0, "reset must not run on the caller's thread"
release.set()
deadline = time.perf_counter() + 5.0
while not frames and time.perf_counter() < deadline:
time.sleep(0.001)
pipe.stop()
assert sensor.reset_calls == 1, f"expected one queued reset, got {sensor.reset_calls}"
assert sensor.reset_at_frame == [0], (
"the reset must land before the first compute_flow, not after it"
)
def test_snapshot_carries_the_raw_capture_size():
"""``image_size_raw`` lets a caller compare the processed frame with the capture resolution."""
sensor = FakeSensor()
sensor.img_size_raw = (1280, 720)
frames: list = []
pipe = StreamPipeline(sensor, make_producer(sensor, 1), on_frame=frames.append)
pipe.start()
deadline = time.perf_counter() + 5.0
while not frames and time.perf_counter() < deadline:
time.sleep(0.001)
pipe.stop()
assert frames and frames[0].image_size_raw == (1280, 720)
def test_snapshot_raw_capture_size_is_none_when_unreported():
"""A producer that never sets the size must not fabricate one."""
sensor = FakeSensor()
sensor.img_size_raw = None
frames: list = []
pipe = StreamPipeline(sensor, make_producer(sensor, 1), on_frame=frames.append)
pipe.start()
deadline = time.perf_counter() + 5.0
while not frames and time.perf_counter() < deadline:
time.sleep(0.001)
pipe.stop()
assert frames and frames[0].image_size_raw is None
class SplitResetFakeSensor(FakeSensor):
"""A fake with the split reset seam and event-gated flow/HHD stages.
The two halves of a reset belong to different pipeline threads, and the
version a frame is stamped with depends on which half has already run. The
events pin both stages to a known frame so the regression orders the race
instead of hoping for it: the flow stage parks at the end of its second
frame and the HHD stage parks inside its first decomposition.
"""
FLOW_GATE_CALL = 2
def __init__(self) -> None:
super().__init__()
self.flow_gate_reached = threading.Event()
self.release_flow_gate = threading.Event()
self.hhd_gate_reached = threading.Event()
self.release_hhd_gate = threading.Event()
self.hhd_calls = 0
self.image_reset_threads: list[str] = []
self.force_reset_threads: list[str] = []
self.force_reset_at_hhd_calls: list[int] = []
def compute_flow(self, img=None, **kwargs) -> None:
super().compute_flow(img, **kwargs)
# Park after frame 2's flow. The next frame that reaches the flow stage
# is then provably the first one that can observe a reset queued now.
if self._flow_calls == self.FLOW_GATE_CALL:
self.flow_gate_reached.set()
self.release_flow_gate.wait(timeout=5.0)
def compute_hhd(self, flow=None, **kwargs) -> None:
self.hhd_calls += 1
if self.hhd_calls == 1:
self.hhd_gate_reached.set()
self.release_hhd_gate.wait(timeout=5.0)
super().compute_hhd(flow, **kwargs)
def reset_image_state(self) -> None:
self.image_reset_threads.append(threading.current_thread().name)
def reset_force_state(self) -> None:
self.force_reset_threads.append(threading.current_thread().name)
self.force_reset_at_hhd_calls.append(self.hhd_calls)
def reset(self) -> None:
# Mirrors Sensor.reset(): both halves, immediately, on the caller's thread.
self.reset_calls += 1
self.reset_at_frame.append(self._flow_calls)
self.reset_image_state()
self.reset_force_state()
class LegacyResetFakeSensor(FakeSensor):
"""A stand-in with only the historical ``reset()``, like a third-party sensor."""
def __init__(self) -> None:
super().__init__()
self.reset_threads: list[str] = []
def reset(self) -> None:
self.reset_calls += 1
self.reset_at_frame.append(self._flow_calls)
self.reset_threads.append(threading.current_thread().name)
def test_reset_request_target_is_shared_until_flow_claims_it():
sensor = FakeSensor()
frames = []
pipe = StreamPipeline(sensor, make_producer(sensor, 2), on_frame=frames.append)
assert pipe.request_reset() == 1
assert pipe.reset() is None # The published entry keeps its return contract.
assert pipe.request_reset() == 1
assert pipe.tracker_reset_seq == 0
pipe.start()
try:
assert _wait_until(lambda: len(frames) == 2)
assert [snap.tracker_reset_seq for snap in frames] == [1, 1]
assert sensor.reset_calls == 1
assert pipe.request_reset() == 2
finally:
pipe.stop()
def test_reset_requested_while_previous_reset_is_running_gets_a_new_target():
entered = threading.Event()
release = threading.Event()
class Sensor(LegacyResetFakeSensor):
def reset(self):
super().reset()
if self.reset_calls == 1:
entered.set()
assert release.wait(timeout=5.0)
sensor = Sensor()
frames = []
pipe = StreamPipeline(sensor, make_producer(sensor, 2), on_frame=frames.append)
assert pipe.request_reset() == 1
pipe.start()
try:
assert entered.wait(timeout=5.0)
# Flow has claimed request 1, but has not finished applying it. The live
# counter is still 0; request 2 must not be merged into request 1.
assert pipe.tracker_reset_seq == 0
assert pipe.request_reset() == 2
assert pipe.request_reset() == 2
release.set()
assert _wait_until(lambda: len(frames) == 2)
assert [snap.tracker_reset_seq for snap in frames] == [1, 2]
assert sensor.reset_calls == 2
finally:
release.set()
pipe.stop()
def _wait_until(predicate, timeout: float = 5.0) -> bool:
"""Poll a predicate; the events, not this poll, order the race."""
deadline = time.perf_counter() + timeout
while time.perf_counter() < deadline:
if predicate():
return True
time.sleep(0.001)
return predicate()
def _run_gated_reset(sensor: SplitResetFakeSensor, frames: list, count: int = 3) -> None:
"""Drive two pre-reset frames and one post-reset frame through the real loops."""
pipe = StreamPipeline(sensor, make_producer(sensor, count),
on_frame=frames.append, depth=2)
pipe.start()
try:
assert sensor.flow_gate_reached.wait(timeout=5.0), "flow stage never reached its gate"
assert sensor.hhd_gate_reached.wait(timeout=5.0), "HHD stage never reached its gate"
# Both stages are provably parked: no frame has been published, and the
# frame whose flow would observe this reset has not been computed yet.
pipe.reset()
sensor.release_flow_gate.set()
assert _wait_until(lambda: sensor._flow_calls >= 3), (
"flow stage did not compute the first post-reset frame"
)
sensor.release_hhd_gate.set()
deadline = time.perf_counter() + 5.0
while len(frames) < count and time.perf_counter() < deadline:
time.sleep(0.001)
finally:
sensor.release_flow_gate.set()
sensor.release_hhd_gate.set()
pipe.stop()
assert len(frames) == count, f"expected {count} published frames, got {len(frames)}"
def test_frames_keep_the_reference_version_they_were_measured_with():
"""A reset queued after a frame's flow must not relabel that frame.
Frames 0 and 1 are measured while the HHD stage is held on the first
snapshot. The caller then queues a reset, and frame 2 is measured with the
new version. Each snapshot must carry the version its own flow was computed
with; reading the live counter at publish time would stamp the two older
frames with the newer version.
"""
sensor = SplitResetFakeSensor()
frames: list[FrameSnapshot] = []
_run_gated_reset(sensor, frames)
versions = [snap.tracker_reset_seq for snap in frames]
assert versions == [0, 0, 1], f"expected per-frame versions [0, 0, 1], got {versions}"
def test_reset_halves_run_on_the_threads_that_own_their_state():
"""Image state resets on the flow stage, force state on the HHD stage.
The force half must land on the HHD stage before it decomposes the first
frame of the new version. Resetting it from the flow thread - the old
behaviour - mutates model/zero state that the HHD stage is using.
"""
sensor = SplitResetFakeSensor()
frames: list[FrameSnapshot] = []
_run_gated_reset(sensor, frames)
assert sensor.image_reset_threads == ["orisys-pipeline-flow"]
assert sensor.force_reset_threads == ["orisys-pipeline-hhd"]
# Two decompositions (frames 0 and 1) had completed when the force reset ran,
# so it preceded the third one - the first frame of the new version.
assert sensor.force_reset_at_hhd_calls == [2]
assert sensor.hhd_calls == 3
assert [snap.tracker_reset_seq for snap in frames] == [0, 0, 1]
def test_legacy_sensor_with_only_reset_is_reset_on_the_flow_stage():
"""A sensor without the split methods keeps the historical fallback."""
sensor = LegacyResetFakeSensor()
ready = threading.Event()
release = threading.Event()
state = {"produced": 0}
def gated_produce():
ready.set()
release.wait(timeout=5.0)
if state["produced"]:
return None
state["produced"] = 1
return sensor.make_frame(0)
frames: list = []
pipe = StreamPipeline(sensor, gated_produce, on_frame=frames.append)
pipe.start()
try:
assert ready.wait(timeout=5.0)
pipe.reset()
assert sensor.reset_calls == 0, "reset must not run on the caller's thread"
release.set()
deadline = time.perf_counter() + 5.0
while not frames and time.perf_counter() < deadline:
time.sleep(0.001)
finally:
release.set()
pipe.stop()
assert len(frames) == 1
assert sensor.reset_calls == 1
assert sensor.reset_threads == ["orisys-pipeline-flow"]
assert sensor.reset_at_frame == [0], "the fallback reset must precede compute_flow"