added 5 sensors multi-stream support, 5 finger reach > 100 fps stable using .mp4 video

This commit is contained in:
ykcheungab
2026-09-13 12:18:27 +08:00
parent 0d5d8ff70d
commit 514874e2f4
10 changed files with 1900 additions and 7 deletions
+116
View File
@@ -0,0 +1,116 @@
"""orisys.util.draw_arrows: mask handling and the out-of-frame tip regression."""
from __future__ import annotations
import cv2
import numpy as np
import pytest
from orisys.util import _prepare_draw_mask, draw_arrows
def flow_of(shape, dx, dy):
flow = np.zeros((*shape, 2), dtype=np.float32)
flow[..., 0] = dx / 2.0 # draw_arrows doubles the stored value
flow[..., 1] = dy / 2.0
return flow
def background(frame):
return cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
def test_out_of_frame_arrow_tip_is_dropped_not_raised():
"""Regression: mask[end_y, end_x] was indexed before the bounds check.
A 40 px grid with the default edge_margin of 15 lets an arrow tip leave the
frame, which used to raise IndexError as soon as a valid_mask was supplied.
"""
frame = np.full((48, 48), 100, dtype=np.uint8)
flow = flow_of((48, 48), dx=40.0, dy=0.0)
mask = np.ones((48, 48), dtype=bool)
out = draw_arrows(frame, flow, grid_spacing=40, margin=0.8, valid_mask=mask)
assert out.shape == (48, 48, 3)
# The only grid point (20, 20) has a tip at x=58 in a 48 px frame, so the
# bounds check must have discarded it: nothing was drawn.
assert np.array_equal(out, background(frame))
def test_out_of_frame_tip_matches_render_without_mask():
"""Clamping the mask lookup must not change which points are valid."""
frame = np.full((48, 48), 100, dtype=np.uint8)
flow = flow_of((48, 48), dx=40.0, dy=0.0)
mask = np.ones((48, 48), dtype=bool)
with_mask = draw_arrows(frame, flow, grid_spacing=40, valid_mask=mask)
without = draw_arrows(frame, flow, grid_spacing=40)
assert np.array_equal(with_mask, without)
def test_in_frame_arrows_are_drawn_through_an_all_true_mask():
frame = np.full((96, 96), 90, dtype=np.uint8)
flow = flow_of((96, 96), dx=6.0, dy=3.0)
mask = np.ones((96, 96), dtype=bool)
out = draw_arrows(frame, flow, grid_spacing=12, arrow_scale=1.0,
threshold=0.1, valid_mask=mask)
assert not np.array_equal(out, background(frame))
def test_all_false_mask_draws_nothing():
frame = np.full((96, 96), 90, dtype=np.uint8)
flow = flow_of((96, 96), dx=6.0, dy=3.0)
mask = np.zeros((96, 96), dtype=bool)
out = draw_arrows(frame, flow, grid_spacing=12, arrow_scale=1.0,
threshold=0.1, valid_mask=mask)
assert np.array_equal(out, background(frame))
def test_mask_can_exclude_one_side():
"""Endpoints outside the mask are skipped even when inside the frame."""
frame = np.full((96, 96), 90, dtype=np.uint8)
flow = flow_of((96, 96), dx=8.0, dy=0.0)
mask = np.zeros((96, 96), dtype=bool)
mask[24:48, 24:72] = True # only the upper middle band
out = draw_arrows(frame, flow, grid_spacing=12, arrow_scale=1.0,
threshold=0.1, valid_mask=mask)
assert not np.array_equal(out, background(frame))
# Rows below the band stay untouched.
assert np.array_equal(out[80:, :, :], background(frame)[80:, :, :])
def test_below_threshold_points_get_the_dot_colour():
frame = np.full((96, 96), 90, dtype=np.uint8)
flow = flow_of((96, 96), dx=0.05, dy=0.05)
out = draw_arrows(frame, flow, grid_spacing=12, arrow_scale=1.0,
threshold=1.0, below_threshold_color=(0, 0, 255))
# OpenCV colours are BGR, so (0, 0, 255) is red.
red = (out[:, :, 2] > 150) & (out[:, :, 0] < 100) & (out[:, :, 1] < 100)
assert red.sum() > 0
def test_prepare_draw_mask_resizes_to_frame():
mask = np.zeros((10, 20), dtype=bool)
mask[:, :10] = True
out = _prepare_draw_mask(mask, (40, 40))
assert out.shape == (40, 40)
assert out[:, :20].all()
assert not out[:, 20:].any()
def test_prepare_draw_mask_passes_none_through():
assert _prepare_draw_mask(None, (10, 10)) is None
def test_non_square_grid_does_not_raise_with_mask():
"""Height and width differ, so rr/cc broadcasting must stay (N, N)."""
frame = np.full((60, 120), 80, dtype=np.uint8)
flow = flow_of((60, 120), dx=5.0, dy=5.0)
out = draw_arrows(frame, flow, grid_spacing=10, arrow_scale=1.0,
threshold=0.1, valid_mask=np.ones((60, 120), dtype=bool))
assert out.shape == (60, 120, 3)
assert not np.array_equal(out, background(frame))
+281
View File
@@ -0,0 +1,281 @@
"""StreamRecorder / mosaic_videos: off-hot-path arrow overlay export."""
from __future__ import annotations
import os
import time
import cv2
import numpy as np
import pytest
from orisys import FrameSnapshot, StreamRecorder, mosaic_videos
_DEFAULT = object()
def make_snapshot(seq: int = 0, *, size: int = 64, image=_DEFAULT, flow=_DEFAULT) -> FrameSnapshot:
if image is _DEFAULT:
image = np.full((size, size), 128, dtype=np.uint8)
image[8:-8, 8:-8] = 200
if flow is _DEFAULT:
flow = np.zeros((size, size, 2), dtype=np.float32)
flow[..., 0] = 2.0
flow[..., 1] = 1.0
return FrameSnapshot(
seq=seq,
timestamp=time.time(),
age_ms=5.0,
fps=100.0,
unit="raw",
normal_force=1.5,
shear_x=0.25,
shear_y=-0.5,
normal_force_raw=1.5,
shear_x_raw=0.25,
shear_y_raw=-0.5,
torque=0.01,
image=image,
flow=flow,
)
def read_video(path):
cap = cv2.VideoCapture(path)
try:
assert cap.isOpened(), f"cannot reopen {path}"
frames = []
while True:
ok, frame = cap.read()
if not ok:
break
frames.append(frame)
return frames, (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
finally:
cap.release()
def _diff_count(frame, expected, tol=20):
"""Pixels differing from ``expected`` by more than the codec's noise floor."""
d = np.abs(frame.astype(np.int16) - expected.astype(np.int16)).max(axis=2)
return int((d > tol).sum())
def _near(pixel, value, tol=14):
"""Codec round-trip shifts solid greys by a few levels (YUV range)."""
return all(abs(int(c) - value) <= tol for c in np.ravel(pixel))
# --------------------------------------------------------------------- recorder
def test_recorder_writes_every_frame(tmp_path):
path = str(tmp_path / "s0.mp4")
# Depth >= frame count so the drop-oldest policy provably does not engage;
# exactness of the writer itself is what this test is about.
rec = StreamRecorder(path, fps=25.0, grid_spacing=8, overlay_values=False,
queue_depth=64)
for i in range(12):
rec.on_frame(make_snapshot(i))
stats = rec.close()
assert stats["seen"] == 12
assert stats["written"] == 12
assert stats["dropped"] == 0
assert stats["skipped"] == 0
assert "error" not in stats
assert stats["thread_alive"] is False
frames, size = read_video(path)
assert len(frames) == 12
assert size == (64, 64)
def test_recorder_every_skips_frames(tmp_path):
path = str(tmp_path / "s0.mp4")
rec = StreamRecorder(path, every=3, grid_spacing=8, overlay_values=False,
queue_depth=64)
for i in range(12):
rec.on_frame(make_snapshot(i))
stats = rec.close()
assert stats["seen"] == 12
assert stats["written"] == 4
assert stats["skipped"] == 8
frames, _ = read_video(path)
assert len(frames) == 4
def test_on_frame_never_blocks_and_drops_oldest(tmp_path, monkeypatch):
"""A slow writer must cost frames, never pipeline throughput."""
path = str(tmp_path / "s0.mp4")
rec = StreamRecorder(path, queue_depth=2, grid_spacing=8, overlay_values=False)
original = rec._render
def slow_render(snap):
time.sleep(0.05)
return original(snap)
rec._render = slow_render
t0 = time.perf_counter()
for i in range(20):
rec.on_frame(make_snapshot(i))
ingest = time.perf_counter() - t0
stats = rec.close(timeout=30.0)
# Ingesting 20 frames must not wait on ~1s of encoder work.
assert ingest < 0.1, f"on_frame blocked for {ingest * 1000:.1f} ms"
assert stats["dropped"] > 0
assert stats["written"] + stats["dropped"] + stats["skipped"] == 20
assert stats["written"] < 20
def test_recorder_reports_missing_arrays(tmp_path):
path = str(tmp_path / "s0.mp4")
rec = StreamRecorder(path, grid_spacing=8, overlay_values=False)
rec.on_frame(make_snapshot(0, image=None))
stats = rec.close()
assert stats["written"] == 0
assert "error" in stats
assert 'include=("image", "flow")' in stats["error"]
def test_recorder_context_manager(tmp_path):
path = str(tmp_path / "s0.mp4")
with StreamRecorder(path, grid_spacing=8, overlay_values=False) as rec:
rec.on_frame(make_snapshot(0))
assert os.path.isfile(path)
assert rec._writer is None
def test_valid_mask_callable_resolved_per_frame(tmp_path):
calls = []
def mask():
calls.append(1)
return None
path = str(tmp_path / "s0.mp4")
rec = StreamRecorder(path, grid_spacing=8, overlay_values=False, valid_mask=mask)
for i in range(5):
rec.on_frame(make_snapshot(i))
stats = rec.close()
assert stats["written"] == 5
assert len(calls) == 5
def test_all_false_mask_suppresses_arrows(tmp_path):
"""With every grid point masked out the overlay is just the grey frame."""
path = str(tmp_path / "s0.mp4")
snap = make_snapshot(0)
rec = StreamRecorder(path, grid_spacing=8, overlay_values=False,
valid_mask=np.zeros((64, 64), dtype=bool))
rec.on_frame(snap)
rec.close()
frames, _ = read_video(path)
expected = cv2.cvtColor(snap.image, cv2.COLOR_GRAY2BGR)
assert _diff_count(frames[0], expected) == 0
def test_arrows_change_pixels_without_mask(tmp_path):
path = str(tmp_path / "s0.mp4")
snap = make_snapshot(0)
rec = StreamRecorder(path, grid_spacing=8, overlay_values=False)
rec.on_frame(snap)
rec.close()
frames, _ = read_video(path)
expected = cv2.cvtColor(snap.image, cv2.COLOR_GRAY2BGR)
assert _diff_count(frames[0], expected) > 100
def test_close_without_frames_is_safe(tmp_path):
rec = StreamRecorder(str(tmp_path / "empty.mp4"), grid_spacing=8)
stats = rec.close()
assert stats["written"] == 0
assert "error" not in stats
def test_stats_report_requested_geometry(tmp_path):
rec = StreamRecorder(str(tmp_path / "s0.mp4"), fps=12.0, fourcc="mp4v")
assert rec.stats()["fps"] == 12.0
assert rec.stats()["fourcc"] == "mp4v"
assert rec.stats()["path"].endswith("s0.mp4")
# ---------------------------------------------------------------------- mosaic
def _write_solid(path, value, frames=4, size=(16, 12), fps=10.0):
w, h = size
writer = cv2.VideoWriter(path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h), True)
assert writer.isOpened()
try:
for _ in range(frames):
writer.write(np.full((h, w, 3), value, dtype=np.uint8))
finally:
writer.release()
return path
def test_mosaic_grid_geometry(tmp_path):
srcs = [_write_solid(str(tmp_path / f"s{i}.mp4"), 40 + 40 * i) for i in range(3)]
out = str(tmp_path / "mosaic.mp4")
info = mosaic_videos(srcs, out, cols=2, gap=4, fps=10.0)
assert info["grid"] == (2, 2)
# 2 columns x 16 + 1 gap, 2 rows x 12 + 1 gap
assert info["size"] == (2 * 16 + 4, 2 * 12 + 4)
assert info["frames"] == 4
frames, size = read_video(out)
assert len(frames) == 4
assert size == info["size"]
# the empty bottom-right cell stays black
assert _near(frames[0][-1, -1], 0, tol=12)
# top-left tile carries stream 0's colour
assert _near(frames[0][0, 0], 40)
def test_mosaic_holds_last_frame_on_short_input(tmp_path):
a = _write_solid(str(tmp_path / "a.mp4"), 200, frames=5)
b = _write_solid(str(tmp_path / "b.mp4"), 50, frames=3)
out = str(tmp_path / "mosaic.mp4")
info = mosaic_videos([a, b], out, cols=2, gap=0, fps=10.0)
assert info["frames"] == 5
frames, size = read_video(out)
assert len(frames) == 5
# b's 4th frame is a repeat of its 3rd, so the right half is still solid 50
assert _near(frames[-1][0, size[0] // 2], 50)
def test_mosaic_labels_drawn(tmp_path):
srcs = [_write_solid(str(tmp_path / f"s{i}.mp4"), 30) for i in range(2)]
out = str(tmp_path / "mosaic.mp4")
plain = str(tmp_path / "plain.mp4")
mosaic_videos(srcs, plain, cols=2, gap=0, fps=10.0)
mosaic_videos(srcs, out, cols=2, gap=0, fps=10.0, labels=["#0", "#1"])
with_label, _ = read_video(out)
without, _ = read_video(plain)
assert _diff_count(with_label[0], without[0]) > 0
def test_mosaic_scale_downscales(tmp_path):
srcs = [_write_solid(str(tmp_path / f"s{i}.mp4"), 30, size=(16, 12)) for i in range(4)]
out = str(tmp_path / "mosaic.mp4")
info = mosaic_videos(srcs, out, cols=2, gap=2, scale=0.5, fps=10.0)
assert info["size"] == (8 * 2 + 2, 6 * 2 + 2)
def test_mosaic_rejects_missing_input(tmp_path):
with pytest.raises(FileNotFoundError):
mosaic_videos([str(tmp_path / "nope.mp4")], str(tmp_path / "out.mp4"))
def test_mosaic_rejects_empty_input(tmp_path):
with pytest.raises(ValueError):
mosaic_videos([], str(tmp_path / "out.mp4"))
+329
View File
@@ -0,0 +1,329 @@
"""StreamPipeline: staging, snapshot immutability, error propagation, shutdown."""
from __future__ import annotations
import time
import numpy as np
import pytest
from orisys import FrameSnapshot, 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
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 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_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