examples: make StreamPipeline the recommended entry, archive the manual loop

The two canonical examples taught the advanced path: they sequenced
get_img() -> compute_deformation() -> compute_contact() -> read_info() by hand
and unpacked a 13-element tuple positionally, which is what led developers to
reach for the compute backend, the capture queue and the stitch mode. The
managed alternative already existed and was documented nowhere.

Library (add-only, nothing removed):

- FrameSnapshot.image_size_raw carries the capture resolution, so a caller can
  compare it with the processed frame instead of reaching into Sensor.
- StreamPipeline.reset() resets the tracker and force filters *safely*. It is
  queued and applied by the flow stage before its next frame, never inline:
  Sensor.reset rebases initial_img on the current Sensor.img, which the flow
  stage is reading concurrently, so a caller-thread reset would capture a
  half-written reference frame. Idempotent.
- TactileProcessor.pipeline(produce) and FingerRuntime.pipeline() build a
  pipeline without the caller naming a backend concept; the latter is
  `with runtime.pipeline(contact=True) as pipe`.

Examples:

- examples/basic_pipeline.py and examples/finger_pipeline.py rewritten onto
  StreamPipeline + FrameSnapshot: one object, one call per frame, no tuple, and
  no compute backend, queue or stitch setting in sight.
- examples/archive/basic_pipeline_manual.py and _finger_pipeline_manual.py keep
  the previous versions byte-for-byte apart from a supersede header, so code
  copied from them keeps working. The finger header also records the archived
  --show defect (store_true with default=True, so it could never be disabled).
- examples/_display.py holds the shared display helpers, so an example no longer
  hand-rolls depth normalisation and colormap application.
- examples/README.md leads with the recommended pair and documents the manual
  form as still-supported, and states that examples/gui/ is moving to
  orisys-studio.

Tests: three added to tests/test_pipeline.py, covering the reset queue (it must
not run on the caller's thread and must land before the first compute_flow),
image_size_raw, and the None case when a producer reports no size. Verified the
reset test fails when reset() is made inline, so it discriminates.

Verified end-to-end against real media: basic_pipeline on data/sample/s1_480p.mp4
(box) reaches ~113 FPS and exits 0; finger_pipeline on data/dd02-0703/1.mp4
(finger) reaches ~140 FPS with mask coverage 0.41 and exits 0. Both archived
manual versions still run. tests/test_pipeline.py: 20 passed. Full suite:
342 passed, 3 failed - all three failures reproduce on the pristine tree
(import precedence, longrun timing, and a stale ruff version literal in
docs/code-review.md) and are unrelated to this change.
This commit is contained in:
ykcheungab
2026-09-30 17:09:02 +08:00
parent 4c1ab5c27e
commit b5d7533e24
9 changed files with 724 additions and 243 deletions
+78
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import threading
import time
import numpy as np
@@ -47,6 +48,12 @@ class FakeSensor:
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=...)
@@ -88,6 +95,10 @@ class FakeSensor:
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(),
@@ -327,3 +338,70 @@ def test_for_finger_runtime_maps_eof_to_none():
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