added 5 sensors multi-stream support, 5 finger reach > 100 fps stable using .mp4 video
This commit is contained in:
@@ -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))
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user