Files
orisys-app/shared/tests/test_stream_pipeline_acquisition.py
T
ykcheungab 5a3ca51b29 fix(studio): 消除 Finger 实时采集的帧排队
实时处理相机最新帧,并修复重复采样、数组快照和暂停恢复。通过共享模块及相关 Studio 测试。
2026-10-12 21:14:33 +08:00

505 lines
17 KiB
Python

"""The concurrent acquisition path: no left-behind threads, and a stop/start cycle.
The worker drives the SDK's ``StreamPipeline``, which runs three threads of its own
(``orisys-pipeline-produce``, ``-flow``, ``-hhd``). Those threads own the sensor's
buffers and tracker state, so a session that leaves one running would corrupt the
next session's reference frame — the failure this test exists to catch.
No camera and no sensor bundle are needed: the fake ``orisys`` module below serves
``Sensor`` and ``finger.FingerRuntime`` the way the real package does, including the
compute methods ``StreamPipeline`` calls.
"""
from __future__ import annotations
import sys
import threading
import time
import types
from pathlib import Path
import numpy as np
import pytest
APP_ROOT = Path(__file__).resolve().parents[2]
BOX_CONFIG = APP_ROOT / "apps" / "studio" / "config" / "sensors" / "box" / "sensor.json"
FINGER_CONFIG = APP_ROOT / "apps" / "studio" / "config" / "sensors" / "finger" / "sensor.json"
PIPELINE_THREADS = ("orisys-pipeline-produce", "orisys-pipeline-flow", "orisys-pipeline-hhd")
#: Bound by :func:`_install` from the real SDK, before the fake device replaces the
#: ``orisys`` name.
StreamPipeline = None
FrameSnapshot = None
def _pipeline_threads() -> list[str]:
return [t.name for t in threading.enumerate() if t.name in PIPELINE_THREADS]
class _Info:
FNORMAL = "FNORMAL"
FSHEARX = "FSHEARX"
FSHEARY = "FSHEARY"
FNORMAL_RAW = "FNORMAL_RAW"
FSHEARX_RAW = "FSHEARX_RAW"
FSHEARY_RAW = "FSHEARY_RAW"
VRAW = "VRAW"
VNORMAL = "VNORMAL"
VSHEAR = "VSHEAR"
DEPTH = "DEPTH"
CENTROID = "CENTROID"
CONTOUR = "CONTOUR"
IMG = "IMG"
class _Source:
"""A capture source with a running background reader, so repeats are counted."""
class _Status:
def __init__(self) -> None:
self.last_seq = 0
self.dropped_frames = 0
self.width = 1920
self.height = 1080
self.fps = 30.0
self.fourcc = "MJPG"
class _Config:
def __init__(self) -> None:
self.width = 1920
self.height = 1080
self.fps = 30.0
self.fourcc = "MJPG"
self.source = 0
def __init__(self) -> None:
self.status = self._Status()
self.config = self._Config()
self.running = True
def read_latest(self, **kwargs):
return True, np.zeros((8, 8, 3), dtype=np.uint8)
def is_open(self) -> bool:
return True
def start_background(self) -> None:
self.running = True
def stop_background(self) -> None:
self.running = False
def open(self) -> None:
return None
def close(self) -> None:
self.running = False
class _Sensor:
"""A device ``StreamPipeline`` can drive."""
def __init__(self, *args, **kwargs) -> None:
self.info = _Info()
self.configured: dict = {}
self.connected = True
self.capture_source = _Source()
self._capture_source = self.capture_source
self.img = np.zeros((8, 8), dtype=np.uint8)
self.frame = np.zeros((8, 8, 3), dtype=np.uint8)
self.flow = None
self.vnormal = None
self.vshear = None
self.depth_map = None
self.centroid = None
self.centroid_found = False
self.contour = None
self.compute_contact_valid = False
self.valid_mask = None
self.img_size_raw = (1920, 1080)
self.force_calibrated = True
self.calibration = None
self.fnormal = 1.0
self.fshearx = 0.0
self.fsheary = 0.0
self.ftor = 0.0
self.force_unit = "N"
self._force_raw = (9.0, 0.5, -0.5)
self._frames = 0
def configure(self, **kwargs):
self.configured = kwargs
def read_info(self, *names):
values = {
_Info.FNORMAL: 1.0,
_Info.FSHEARX: 0.0,
_Info.FSHEARY: 0.0,
_Info.FNORMAL_RAW: 9.0,
_Info.FSHEARX_RAW: 0.5,
_Info.FSHEARY_RAW: -0.5,
}
return tuple(values.get(str(name), None) for name in names)
def compute_flow(self, img=None):
self.flow = np.zeros((8, 8, 2), dtype=np.float32)
def compute_hhd(self, flow=None):
self._force_raw = (9.0, 0.5, -0.5)
def compute_deformation(self, decompose=True, check_motion=False, **kwargs):
self.compute_flow()
if decompose:
self.compute_hhd()
return None
def compute_contact(self, vnormal=None):
self.compute_contact_valid = True
return {}
def get_img(self, **kwargs):
self._frames += 1
return self.img.copy()
def warmup_deformation(self):
return True
def reset(self):
return None
def disconnect(self):
self.connected = False
class _Runtime:
"""A stand-in for ``FingerRuntime`` with the API the worker uses."""
def __init__(self, *args, **kwargs) -> None:
self._source_seq = 0
self.tactile = types.SimpleNamespace(
raw_sensor=_Sensor(),
reset=lambda: None,
)
@classmethod
def open(cls, **kwargs):
return cls(**kwargs)
@property
def valid_mask(self):
return np.ones((8, 8), dtype=bool)
def read_source(self):
return True, np.zeros((8, 8, 3), dtype=np.uint8)
def read_source_stamped(self):
self._source_seq += 1
return types.SimpleNamespace(
image=np.zeros((8, 8, 3), dtype=np.uint8),
sequence=self._source_seq,
host_mono_ns=time.perf_counter_ns(),
host_utc_ns=time.time_ns(),
media_time_ms=self._source_seq * 50.0,
)
def process_frame(self, frame, *, color=False):
return np.zeros((8, 8), dtype=np.uint8)
def warmup(self, *, color=False):
return True, np.zeros((8, 8), dtype=np.uint8)
def close(self):
return None
def _install() -> dict:
"""Replace ``orisys`` with the fakes, keeping the real ``StreamPipeline``.
``StreamPipeline`` and ``FrameSnapshot`` are the SDK under test, so they are
bound before the swap: the fake stands in for the device, not for the pipeline.
The submodules the workers import lazily are bound too, because a module object
placed in ``sys.modules`` is not a package and cannot resolve them itself.
"""
import orisys.calibration # noqa: F401 - bound so the workers can import it
import orisys.finger # noqa: F401
import orisys.pipeline # noqa: F401
global StreamPipeline, FrameSnapshot
from orisys.pipeline import FrameSnapshot as _Snapshot
from orisys.pipeline import StreamPipeline as _Pipeline
StreamPipeline, FrameSnapshot = _Pipeline, _Snapshot
saved = {name: sys.modules.get(name) for name in ("orisys", "orisys.finger")}
module = types.ModuleType("orisys")
module.__version__ = "fake"
module.Sensor = _Sensor # type: ignore[attr-defined]
finger_module = types.ModuleType("orisys.finger")
finger_module.FingerRuntime = _Runtime # type: ignore[attr-defined]
module.finger = finger_module # type: ignore[attr-defined]
sys.modules["orisys"] = module
sys.modules["orisys.finger"] = finger_module
return saved
def _restore(saved: dict) -> None:
for name, original in saved.items():
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
def _worker(kind: str):
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
return FingerAcquisitionWorker() if kind == "finger" else SdkAcquisitionWorker()
def _config(kind: str) -> Path:
return FINGER_CONFIG if kind == "finger" else BOX_CONFIG
@pytest.mark.parametrize("source", ["recording.mp4", 0])
def test_finger_video_and_camera_use_the_same_packet_path(source, monkeypatch):
"""Both source forms reach FingerRuntime and publish the same packet contract."""
opened: list[str | int] = []
def open_runtime(cls, **kwargs):
opened.append(kwargs["video"])
return cls()
saved = _install()
monkeypatch.setattr(_Runtime, "open", classmethod(open_runtime))
worker = _worker("finger")
packets: list = []
try:
worker.add_sink(packets.append)
worker.start(source, config_ref=FINGER_CONFIG)
deadline = time.perf_counter() + 5.0
while time.perf_counter() < deadline and not packets:
time.sleep(0.02)
assert opened == [source]
assert packets
packet = packets[0]
assert packet.valid
assert packet.force_calibrated
assert packet.raw_frame.shape == (8, 8, 3)
assert packet.processed_frame.shape == (8, 8, 3)
assert packet.session_id == 1
assert packet.frame_id == 0
assert packet.source_frame_id == 1
assert packet.capture_mono_ns is not None
assert packet.capture_utc_ns is not None
assert packet.timestamp == pytest.approx(packet.capture_utc_ns / 1e9)
assert packet.time_basis == (
"host_replay" if isinstance(source, str) else "host_camera"
)
assert packet.media_time_ms == 50.0
finally:
worker.stop(wait=True)
_restore(saved)
@pytest.mark.parametrize(
"kind, source", [("box", "0"), ("finger", "recording.mp4"), ("finger", 0)]
)
def test_a_stopped_session_leaves_no_pipeline_threads(kind, source):
"""``stop`` must join the three stages: they own the sensor's tracker state."""
packets: list = []
saved = _install()
worker = _worker(kind)
try:
worker.add_sink(packets.append)
worker.start(source, config_ref=_config(kind))
deadline = time.perf_counter() + 5.0
while time.perf_counter() < deadline and not packets:
time.sleep(0.02)
assert packets, "the session published nothing to stop"
if kind == "finger" and source == 0:
assert _pipeline_threads() == []
else:
assert _pipeline_threads(), "the fake session did not start the pipeline"
worker.stop(wait=True)
deadline = time.perf_counter() + 5.0
while time.perf_counter() < deadline and _pipeline_threads():
time.sleep(0.02)
assert _pipeline_threads() == [], (
f"{kind}: pipeline threads outlived stop(): {_pipeline_threads()}"
)
finally:
worker.stop(wait=True)
_restore(saved)
@pytest.mark.parametrize("kind", ["box", "finger"])
def test_a_stop_start_cycle_works_and_leaves_nothing_behind(kind):
"""One worker, two sessions: the second must run and the first must be gone."""
saved = _install()
worker = _worker(kind)
try:
for session in (1, 2):
packets: list = []
worker.add_sink(packets.append)
worker.start("0", config_ref=_config(kind))
deadline = time.perf_counter() + 5.0
while time.perf_counter() < deadline and not packets:
time.sleep(0.02)
assert packets, f"session {session} published nothing"
assert worker.latest() is packets[-1]
worker.stop(wait=True)
assert not worker.is_running
assert worker.phase == "idle"
worker.remove_sink(packets.append)
deadline = time.perf_counter() + 5.0
while time.perf_counter() < deadline and _pipeline_threads():
time.sleep(0.02)
assert _pipeline_threads() == [], _pipeline_threads()
finally:
worker.stop(wait=True)
_restore(saved)
def test_the_packet_carries_every_field_the_ui_reads():
"""The mapping from a snapshot: forces, raws, arrays, ids and provenance."""
import orisys_app_shared.acquisition.models as models
saved = _install()
try:
snapshot = FrameSnapshot(
seq=7,
timestamp=1000.5,
age_ms=12.0,
fps=30.0,
unit="N",
normal_force=1.25,
shear_x=0.5,
shear_y=-0.5,
normal_force_raw=9.0,
shear_x_raw=0.4,
shear_y_raw=-0.4,
torque=0.0,
image=np.zeros((4, 4), dtype=np.uint8),
flow=np.zeros((4, 4, 2), dtype=np.float32),
depth=np.ones((4, 4), dtype=np.float32),
centroid=np.array([2.0, 3.0]),
contour=None,
produce_ms=1.0,
flow_ms=2.0,
hhd_ms=3.0,
)
packet = models.from_snapshot(
snapshot,
_Sensor(),
session_id=3,
frame_id=7,
mono_t=0.25,
capture_fps=30.0,
capture_dropped=2,
raw_frame=np.zeros((8, 8, 3), dtype=np.uint8),
valid_mask=np.ones((4, 4), dtype=bool),
contact_analysed=True,
contact_found=True,
)
assert packet.frame_id == 7
assert packet.session_id == 3
assert packet.mono_t == pytest.approx(0.25)
assert packet.fps == 30.0
# The stage costs add up to the frame's own cost; the SDK measures them.
assert packet.process_ms == pytest.approx(6.0)
assert (packet.normal_force, packet.shear_x, packet.shear_y) == (1.25, 0.5, -0.5)
assert (
packet.normal_force_raw,
packet.shear_x_raw,
packet.shear_y_raw,
) == (9.0, 0.4, -0.4)
assert packet.force_unit == "N"
assert packet.capture_fps == 30.0
assert packet.capture_dropped == 2
assert packet.processed_frame.shape == (4, 4, 3)
assert packet.raw_frame.shape == (8, 8, 3)
assert packet.flow is not None and packet.depth is not None
assert packet.valid_mask is not None
assert packet.flow_valid is True and packet.flow_frame_id == 7
assert packet.depth_valid is True and packet.depth_frame_id == 7
assert packet.forces_valid is True and packet.valid is True
assert packet.contact is not None
assert packet.contact.centroid_uv_xy == (2.0, 3.0)
assert packet.contact.found is True and packet.contact.frame_id == 7
finally:
_restore(saved)
def test_a_failed_chain_marks_the_frame_rather_than_claiming_its_forces():
"""A compute error must leave the frame's forces unclaimable, not silently trusted."""
import orisys_app_shared.acquisition.models as models
saved = _install()
try:
snapshot = FrameSnapshot(
seq=0,
timestamp=1000.0,
age_ms=1.0,
fps=0.0,
unit="raw",
normal_force=1.0,
shear_x=0.0,
shear_y=0.0,
normal_force_raw=1.0,
shear_x_raw=0.0,
shear_y_raw=0.0,
torque=0.0,
image=np.zeros((4, 4), dtype=np.uint8),
flow=None,
depth=None,
centroid=np.array(0),
contour=None,
)
packet = models.from_snapshot(
snapshot,
_Sensor(),
frame_id=0,
contact_analysed=True,
contact_found=False,
compute_error="compute: ValueError: boom",
)
assert packet.valid is False
assert packet.stale_forces is True
assert packet.forces_valid is False
assert packet.flow_valid is False and packet.depth_valid is False
assert packet.contact is not None
assert packet.contact.found is False and packet.contact.valid is False
assert packet.contact.centroid_uv_xy is None
finally:
_restore(saved)
def test_the_finger_producer_reports_the_camera_rate(monkeypatch):
"""The first live frame asks the producer for ``capture_fps``.
That call is what ended a finger session with
``'_FingerProducer' object has no attribute 'capture_fps'``. A file has no
delivered-frame counter, so it reports nothing; a live source reports the
frames its counter advanced over the wall-clock between two reads.
"""
from orisys_app_shared.acquisition import finger_worker
clock = {"t": 1000.0}
monkeypatch.setattr(finger_worker.time, "perf_counter", lambda: clock["t"])
file_source = types.SimpleNamespace(running=False, status=types.SimpleNamespace(last_seq=0))
file_runtime = types.SimpleNamespace(capture_source=file_source)
file_producer = finger_worker._FingerProducer(file_runtime, is_file=True)
assert file_producer.capture_fps() == 0.0
status = types.SimpleNamespace(last_seq=10, dropped_frames=0)
live = types.SimpleNamespace(running=True, status=status)
producer = finger_worker._FingerProducer(
types.SimpleNamespace(capture_source=live), is_file=False
)
assert producer.capture_fps() == 0.0
clock["t"] = 1001.0
producer.capture_seq = 40
assert producer.capture_fps() == pytest.approx(30.0)