120b8c2cf6
采集时间随图像穿过处理队列,并修正未读帧的丢帧计数。通过采集与流水线针对性测试。
432 lines
15 KiB
Python
432 lines
15 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
|