migrated sensor processing to stream
added agent doc and coding rules
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
+4
-25
@@ -1,27 +1,6 @@
|
|||||||
# Documentation rules for this repository
|
# App documentation
|
||||||
|
|
||||||
This repository lives inside the Orisys workspace at `D:\orisys_ws`. The workspace's writing
|
Required: [writing standard](../../docs/AUTHORING.md), [workspace rules](../../AGENTS.md).
|
||||||
standard is authoritative for every document here:
|
|
||||||
|
|
||||||
**`D:\orisys_ws\docs\AUTHORING.md`** — one home per fact, current state only, link instead of
|
Studio and Autocal contracts belong beside their apps; shared architecture belongs here.
|
||||||
restating, the slop checklist, and the word budgets (standard 1,200 / general 1,500 / reference
|
Check using the app interpreter and tools/lint_gate.py; AUTHORING defines the documentation gate.
|
||||||
3,000).
|
|
||||||
|
|
||||||
This file exists because the standard is not auto-discovered from inside a repository: the
|
|
||||||
workspace skill root is `D:\orisys_ws\.agents\skills\`, and an agent whose working directory is
|
|
||||||
this repository resolves its project root to this repository's `.git`. Read the standard at the
|
|
||||||
path above, or load the `orisys-doc-standard` skill when working from the workspace root.
|
|
||||||
|
|
||||||
## Check your docs before finishing
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python D:\orisys_ws\scripts\doc_lint.py # changed Markdown — must pass
|
|
||||||
python D:\orisys_ws\scripts\doc_lint.py --all # audit the whole corpus
|
|
||||||
```
|
|
||||||
|
|
||||||
`tools/lint_gate.py` runs this automatically alongside Ruff, so a change that adds an oversized
|
|
||||||
document fails the same gate as a change that breaks a lint rule. Pass `--no-docs` to skip it.
|
|
||||||
|
|
||||||
Pre-existing oversized documents in this repository are listed in `D:\orisys_ws\docs\inventory.json`
|
|
||||||
as `budget-exempt` with a written reason, and pre-existing findings are baselined there, so the
|
|
||||||
gate is red only for new regression. Fix one when you touch it and remove its entry.
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -22,6 +23,9 @@ from orisys_app_shared.acquisition.models import (
|
|||||||
from orisys_app_shared.acquisition.models import (
|
from orisys_app_shared.acquisition.models import (
|
||||||
ensure_bgr as _ensure_bgr,
|
ensure_bgr as _ensure_bgr,
|
||||||
)
|
)
|
||||||
|
from orisys_app_shared.acquisition.models import (
|
||||||
|
from_snapshot as _packet_from_snapshot,
|
||||||
|
)
|
||||||
from orisys_app_shared.acquisition.models import (
|
from orisys_app_shared.acquisition.models import (
|
||||||
is_file_source as _is_file_source,
|
is_file_source as _is_file_source,
|
||||||
)
|
)
|
||||||
@@ -33,6 +37,7 @@ from orisys_app_shared.acquisition.sdk_worker import (
|
|||||||
PAUSE_POLL_S,
|
PAUSE_POLL_S,
|
||||||
STOP_JOIN_TIMEOUT_S,
|
STOP_JOIN_TIMEOUT_S,
|
||||||
VIDEO_EOF_TIMEOUT_S,
|
VIDEO_EOF_TIMEOUT_S,
|
||||||
|
_capture_dropped,
|
||||||
_capture_sequence,
|
_capture_sequence,
|
||||||
calibration_digest,
|
calibration_digest,
|
||||||
capture_status_of,
|
capture_status_of,
|
||||||
@@ -49,6 +54,121 @@ logger = logging.getLogger(__name__)
|
|||||||
_EXPORT_REQUIRED = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
|
_EXPORT_REQUIRED = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
|
||||||
|
|
||||||
|
|
||||||
|
class _RuntimeCapture:
|
||||||
|
"""Expose a ``FingerRuntime``'s capture source to the capture-rate helpers.
|
||||||
|
|
||||||
|
Those helpers (``_capture_sequence``, ``_capture_dropped``) read
|
||||||
|
``sensor.capture_source``; the finger runtime owns the source itself and the
|
||||||
|
tactile processor in front of the sensor has no such attribute, so this adapter
|
||||||
|
is what they are given.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, runtime) -> None:
|
||||||
|
self._runtime = runtime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capture_source(self):
|
||||||
|
return getattr(self._runtime, "capture_source", None)
|
||||||
|
|
||||||
|
|
||||||
|
class _FingerProducer:
|
||||||
|
"""The ``StreamPipeline`` produce callable for a ``FingerRuntime``.
|
||||||
|
|
||||||
|
The finger pipeline's producer is ``runtime.grab_and_process`` — read the
|
||||||
|
source and UV-unroll it — so this wrapper adds the two things that have to
|
||||||
|
happen on the producer thread:
|
||||||
|
|
||||||
|
* **One published frame per delivered frame.** ``CaptureSource.read_latest``
|
||||||
|
returns the newest frame and then that same frame again until its background
|
||||||
|
reader produces another, so a loop that read without looking would publish
|
||||||
|
each frame as many times as it could poll. Only a source with a running
|
||||||
|
background reader has the sequence counter that says so; a file decodes a
|
||||||
|
new frame per call and is read once per call.
|
||||||
|
* **The pre-processing frame.** ``runtime.process_frame`` hands the unrolled
|
||||||
|
frame to the tactile sensor and drops the source frame; the record channel's
|
||||||
|
pre-roll history wants the source frame, so it is copied here while it is
|
||||||
|
still this frame's, and handed on by sequence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CACHE = 8
|
||||||
|
|
||||||
|
def __init__(self, runtime, *, is_file: bool, frame_period: float = 0.0) -> None:
|
||||||
|
self.runtime = runtime
|
||||||
|
self.is_file = bool(is_file)
|
||||||
|
#: A file has no background reader to pace on, so the pass is paced by the
|
||||||
|
#: declared frame period (see the box worker's producer).
|
||||||
|
self.frame_period = float(frame_period)
|
||||||
|
self.produced = 0
|
||||||
|
self.capture_source = _RuntimeCapture(runtime)
|
||||||
|
self.capture_seq = _capture_sequence(self.capture_source)
|
||||||
|
self._cache: deque = deque(maxlen=self.CACHE)
|
||||||
|
self._times: deque = deque()
|
||||||
|
self._last_read_at: float | None = None
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
self._wait_for_a_new_frame()
|
||||||
|
self._pace()
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
ok, source_frame = self.runtime.read_source()
|
||||||
|
if not ok or source_frame is None:
|
||||||
|
return None
|
||||||
|
unrolled = self.runtime.process_frame(source_frame, color=False)
|
||||||
|
if unrolled is None:
|
||||||
|
return None
|
||||||
|
self._last_read_at = t0
|
||||||
|
self.produced += 1
|
||||||
|
self._times.append((self.produced, t0))
|
||||||
|
cutoff = t0 - FPS_WINDOW_S
|
||||||
|
while self._times and self._times[0][1] < cutoff:
|
||||||
|
self._times.popleft()
|
||||||
|
self._cache.append((self.produced, np.array(source_frame, copy=True)))
|
||||||
|
return unrolled
|
||||||
|
|
||||||
|
def _pace(self) -> None:
|
||||||
|
if not self.is_file or self.frame_period <= 0.0 or self._last_read_at is None:
|
||||||
|
return
|
||||||
|
remaining = self.frame_period - (time.perf_counter() - self._last_read_at)
|
||||||
|
if remaining > PAUSE_POLL_S:
|
||||||
|
time.sleep(min(remaining, self.frame_period * 0.5))
|
||||||
|
|
||||||
|
def _wait_for_a_new_frame(self) -> None:
|
||||||
|
if self.is_file or self.capture_seq is None:
|
||||||
|
return
|
||||||
|
deadline = time.perf_counter() + CAMERA_DISCONNECT_TIMEOUT_S
|
||||||
|
while True:
|
||||||
|
current = _capture_sequence(self.capture_source)
|
||||||
|
if current is None or current != self.capture_seq:
|
||||||
|
self.capture_seq = current
|
||||||
|
return
|
||||||
|
if time.perf_counter() >= deadline:
|
||||||
|
# The camera stopped publishing but the device is still open: read
|
||||||
|
# on, so the session survives for the consumer's liveness check to
|
||||||
|
# see and report rather than stalling here.
|
||||||
|
return
|
||||||
|
time.sleep(0)
|
||||||
|
|
||||||
|
def raw_frame(self, seq: int) -> np.ndarray | None:
|
||||||
|
for number, frame in reversed(self._cache):
|
||||||
|
if number == seq:
|
||||||
|
return frame
|
||||||
|
return None
|
||||||
|
|
||||||
|
def collect_perf(self, seq: int) -> float | None:
|
||||||
|
self._trim()
|
||||||
|
for number, stamp in reversed(self._times):
|
||||||
|
if number == seq:
|
||||||
|
return stamp
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _trim(self) -> None:
|
||||||
|
"""Drop collection times older than the frame-rate window."""
|
||||||
|
if not self._times:
|
||||||
|
return
|
||||||
|
cutoff = self._times[-1][1] - FPS_WINDOW_S
|
||||||
|
while self._times and self._times[0][1] < cutoff:
|
||||||
|
self._times.popleft()
|
||||||
|
|
||||||
|
|
||||||
def resolve_finger_export_dir(config_ref: str | Path) -> Path | None:
|
def resolve_finger_export_dir(config_ref: str | Path) -> Path | None:
|
||||||
"""Return local ``assets/30fps-0703`` when present and complete.
|
"""Return local ``assets/30fps-0703`` when present and complete.
|
||||||
|
|
||||||
@@ -82,8 +202,15 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
on_status: Callable[[str], None] | None = None,
|
on_status: Callable[[str], None] | None = None,
|
||||||
on_calibration_applied: Callable[[str, str, int, float, int], None] | None = None,
|
on_calibration_applied: Callable[[str, str, int, float, int], None] | None = None,
|
||||||
on_reset_applied: Callable[[int, float, int], None] | None = None,
|
on_reset_applied: Callable[[int, float, int], None] | None = None,
|
||||||
|
use_stream_pipeline: bool = True,
|
||||||
):
|
):
|
||||||
self._init_sinks()
|
self._init_sinks()
|
||||||
|
#: ``runtime.pipeline()`` is the acquisition path; the serial chain is kept
|
||||||
|
#: so a change can be checked against it frame-for-frame on the same input
|
||||||
|
#: (``False`` selects the reference).
|
||||||
|
self._use_stream_pipeline = bool(use_stream_pipeline)
|
||||||
|
#: The running session's ``StreamPipeline``, when there is one.
|
||||||
|
self._pipe = None
|
||||||
self._on_error = on_error
|
self._on_error = on_error
|
||||||
self._on_status = on_status
|
self._on_status = on_status
|
||||||
#: Called from the acquisition thread once new coefficients are loaded,
|
#: Called from the acquisition thread once new coefficients are loaded,
|
||||||
@@ -253,13 +380,39 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
Same contract as the box worker's: the source keeps being read so the device
|
Same contract as the box worker's: the source keeps being read so the device
|
||||||
is not released and the disconnect watchdog stays quiet, but no frame is
|
is not released and the disconnect watchdog stays quiet, but no frame is
|
||||||
processed (no flow, no depth, no contact) and no packet is emitted, so the
|
processed (no flow, no depth, no contact) and no packet is emitted, so the
|
||||||
display freezes on its last frame and the record channel sees a gap.
|
display freezes on its last frame and the record channel sees a gap. The
|
||||||
|
concurrent pipeline holds at ``PauseLevel.CAPTURE_ONLY``: the read continues
|
||||||
|
— which is what keeps exposure, white balance and frame rate converged —
|
||||||
|
while the flow and HHD stages are skipped.
|
||||||
"""
|
"""
|
||||||
self._pause.set()
|
self._pause.set()
|
||||||
|
pipe = self._pipe
|
||||||
|
if pipe is not None:
|
||||||
|
try:
|
||||||
|
pipe.pause(self._pause_level())
|
||||||
|
except Exception: # noqa: BLE001 - the loop honours ``_pause`` anyway
|
||||||
|
logger.exception("Could not hold the finger pipeline for a pause")
|
||||||
|
|
||||||
def resume(self) -> None:
|
def resume(self) -> None:
|
||||||
"""Start computing and publishing again, from the *newest* frame."""
|
"""Start computing and publishing again, from the *newest* frame.
|
||||||
|
|
||||||
|
The tracker is rebased, because a ``CAPTURE_ONLY`` hold read frames that were
|
||||||
|
never computed: keeping the pre-pause reference frame would report the hold
|
||||||
|
itself as a large deformation.
|
||||||
|
"""
|
||||||
self._pause.clear()
|
self._pause.clear()
|
||||||
|
pipe = self._pipe
|
||||||
|
if pipe is not None:
|
||||||
|
try:
|
||||||
|
pipe.resume(reset=True)
|
||||||
|
except Exception: # noqa: BLE001 - the loop honours ``_pause`` anyway
|
||||||
|
logger.exception("Could not release the finger pipeline for a resume")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pause_level():
|
||||||
|
from orisys.pipeline import PauseLevel
|
||||||
|
|
||||||
|
return PauseLevel.CAPTURE_ONLY
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def paused(self) -> bool:
|
def paused(self) -> bool:
|
||||||
@@ -373,6 +526,22 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
self._phase = "idle"
|
self._phase = "idle"
|
||||||
|
|
||||||
def _run_session(self, session: int, previous: threading.Thread | None) -> None:
|
def _run_session(self, session: int, previous: threading.Thread | None) -> None:
|
||||||
|
opened = self._open_runtime(session, previous)
|
||||||
|
if opened is None:
|
||||||
|
return
|
||||||
|
mode, source, runtime, is_file = opened
|
||||||
|
if self._use_stream_pipeline:
|
||||||
|
# The recommended path: the SDK runs produce (capture + UV unroll) ->
|
||||||
|
# flow -> HHD on three threads, so throughput is bounded by the slowest
|
||||||
|
# stage rather than by their sum. The app's own layer — session and
|
||||||
|
# frame bookkeeping, the record sink, calibration reload, contact
|
||||||
|
# provenance — stays on top of the frames the pipeline finishes.
|
||||||
|
self._run_stream_pipeline(session, mode, source, runtime, is_file)
|
||||||
|
return
|
||||||
|
self._run_serial_loop(session, mode, source, runtime, is_file)
|
||||||
|
|
||||||
|
def _open_runtime(self, session: int, previous: threading.Thread | None):
|
||||||
|
"""Open the runtime and configure it; ``None`` when it cannot run."""
|
||||||
from orisys.finger import FingerRuntime
|
from orisys.finger import FingerRuntime
|
||||||
|
|
||||||
if previous is not None and previous.is_alive():
|
if previous is not None and previous.is_alive():
|
||||||
@@ -384,7 +553,7 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
mode = self._mode
|
mode = self._mode
|
||||||
source = self._video_source
|
source = self._video_source
|
||||||
if mode is None or source is None:
|
if mode is None or source is None:
|
||||||
return
|
return None
|
||||||
is_file = _is_file_source(source)
|
is_file = _is_file_source(source)
|
||||||
try:
|
try:
|
||||||
self._status(f"Opening FingerRuntime ({mode.label()})…")
|
self._status(f"Opening FingerRuntime ({mode.label()})…")
|
||||||
@@ -400,11 +569,13 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
self._runtimes[session] = runtime
|
self._runtimes[session] = runtime
|
||||||
if self._stop.is_set() or session != self._session_id:
|
if self._stop.is_set() or session != self._session_id:
|
||||||
self._close_runtime(session)
|
self._close_runtime(session)
|
||||||
return
|
return None
|
||||||
|
# One frame through the unroll and one HHD pass, so the first
|
||||||
|
# *published* frame is not a warm-up outlier.
|
||||||
runtime.warmup(color=False)
|
runtime.warmup(color=False)
|
||||||
if self._stop.is_set() or session != self._session_id:
|
if self._stop.is_set() or session != self._session_id:
|
||||||
self._close_runtime(session)
|
self._close_runtime(session)
|
||||||
return
|
return None
|
||||||
raw_sensor = getattr(runtime.tactile, "raw_sensor", None)
|
raw_sensor = getattr(runtime.tactile, "raw_sensor", None)
|
||||||
if raw_sensor is not None and hasattr(raw_sensor, "configure"):
|
if raw_sensor is not None and hasattr(raw_sensor, "configure"):
|
||||||
# The filter chain lives in the SDK; the app says what the
|
# The filter chain lives in the SDK; the app says what the
|
||||||
@@ -432,12 +603,313 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
str(loaded_path(tactile) or ""), loaded, 0, 0.0, int(session)
|
str(loaded_path(tactile) or ""), loaded, 0, 0.0, int(session)
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
self._set_phase("idle")
|
||||||
|
return None
|
||||||
|
return mode, source, runtime, is_file
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- concurrent path
|
||||||
|
def _run_stream_pipeline(
|
||||||
|
self, session: int, mode: FixedCameraMode, source, runtime, is_file: bool
|
||||||
|
) -> None:
|
||||||
|
"""Drive the SDK's concurrent ``StreamPipeline`` for the finger runtime.
|
||||||
|
|
||||||
|
The pipeline is built the way ``runtime.pipeline()`` builds it — a
|
||||||
|
``StreamPipeline`` over the runtime's tactile sensor — but with this
|
||||||
|
worker's own produce callable in place of ``grab_and_process``. The callable
|
||||||
|
is what reads the source, so it is where the two things that must happen on
|
||||||
|
the producer thread live: one published frame per *delivered* frame (a live
|
||||||
|
source hands the same frame back until its reader produces another), and the
|
||||||
|
copy of the pre-unroll source frame the record channel's history wants. See
|
||||||
|
:class:`_FingerProducer`.
|
||||||
|
"""
|
||||||
|
import queue as queue_module
|
||||||
|
|
||||||
|
from orisys.pipeline import PauseLevel, StreamPipeline
|
||||||
|
|
||||||
|
tactile = runtime.tactile
|
||||||
|
raw_sensor = getattr(tactile, "raw_sensor", None)
|
||||||
|
frame_period = 1.0 / max(1.0, float(mode.fps or 30.0))
|
||||||
|
producer = _FingerProducer(runtime, is_file=is_file, frame_period=frame_period)
|
||||||
|
snapshots: queue_module.Queue = queue_module.Queue(maxsize=64)
|
||||||
|
mono_t0 = time.perf_counter()
|
||||||
|
frame_times: list[float] = []
|
||||||
|
capture = _RuntimeCapture(runtime)
|
||||||
|
live_capture = _capture_sequence(capture) is not None
|
||||||
|
frame_id = 0
|
||||||
|
capture_fps = 0.0
|
||||||
|
capture_dropped = 0
|
||||||
|
session_started = time.perf_counter()
|
||||||
|
last_frame_at = session_started
|
||||||
|
saw_frame = False
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._phase = "running"
|
||||||
|
|
||||||
|
try:
|
||||||
|
pipe = StreamPipeline(
|
||||||
|
raw_sensor,
|
||||||
|
producer,
|
||||||
|
# The SDK's default: run contact analysis on every frame, because
|
||||||
|
# the pane's contact overlay and the 2D arrow both read it.
|
||||||
|
contact=True,
|
||||||
|
include=self._stream_include(),
|
||||||
|
on_frame=snapshots.put,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - reported, not swallowed
|
||||||
self._set_end_reason("error")
|
self._set_end_reason("error")
|
||||||
if self._on_error is not None and session == self._session_id:
|
if self._on_error is not None and session == self._session_id:
|
||||||
self._on_error(str(exc))
|
self._on_error(str(exc))
|
||||||
self._set_phase("idle")
|
self._set_phase("idle")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Published before ``start``: a pause clicked while the runtime was still
|
||||||
|
# opening must not be lost (see the box worker).
|
||||||
|
self._pipe = pipe
|
||||||
|
try:
|
||||||
|
pipe.start()
|
||||||
|
if self._pause.is_set():
|
||||||
|
pipe.pause(self._pause_level())
|
||||||
|
while not self._stop.is_set() and session == self._session_id:
|
||||||
|
if pipe.paused:
|
||||||
|
# ``wait`` returns at once while paused: sleep out the hold
|
||||||
|
# rather than spinning on it.
|
||||||
|
time.sleep(PAUSE_POLL_S)
|
||||||
|
continue
|
||||||
|
if pipe.error is not None:
|
||||||
|
# A stage raised. ``wait`` would re-raise it; this loop does not
|
||||||
|
# call ``wait``, so it is reported here instead.
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(pipe.error))
|
||||||
|
break
|
||||||
|
# How many frames the producer has finished, and how many this loop
|
||||||
|
# has taken: a ``wait`` is only worth making while the first is
|
||||||
|
# ahead. ``producer.produced`` also latches the moment the first read
|
||||||
|
# has happened, which is what keeps the "no frames" deadline below
|
||||||
|
# from racing the very first frame.
|
||||||
|
expect = max(producer.produced, frame_id)
|
||||||
|
snap = None
|
||||||
|
if expect > 0:
|
||||||
|
try:
|
||||||
|
snap = snapshots.get(timeout=0.05)
|
||||||
|
except queue_module.Empty:
|
||||||
|
snap = None
|
||||||
|
except Exception as exc: # noqa: BLE001 - a stage failure ends the session
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
break
|
||||||
|
now = time.perf_counter()
|
||||||
|
if snap is not None:
|
||||||
|
saw_frame = True
|
||||||
|
last_frame_at = now
|
||||||
|
try:
|
||||||
|
packet = self._packet_from_stream_snapshot(
|
||||||
|
session=session,
|
||||||
|
mode=mode,
|
||||||
|
runtime=runtime,
|
||||||
|
tactile=tactile,
|
||||||
|
producer=producer,
|
||||||
|
live_capture=live_capture,
|
||||||
|
snap=snap,
|
||||||
|
frame_id=frame_id,
|
||||||
|
frame_period=frame_period,
|
||||||
|
is_file=is_file,
|
||||||
|
frame_times=frame_times,
|
||||||
|
mono_t0=mono_t0,
|
||||||
|
capture_fps=capture_fps,
|
||||||
|
capture_dropped=capture_dropped,
|
||||||
|
compute_error="; ".join(errors),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
break
|
||||||
|
capture_fps = packet.capture_fps
|
||||||
|
capture_dropped = packet.capture_dropped
|
||||||
|
if self._pause.is_set():
|
||||||
|
# The hold arrived after this frame was published but before
|
||||||
|
# it was built: see the box worker. A paused worker publishes
|
||||||
|
# nothing, including frames already in flight.
|
||||||
|
continue
|
||||||
|
# A zeroing and new coefficients are applied by this thread at a
|
||||||
|
# frame boundary, then reported with the frame and time they took
|
||||||
|
# effect at — the same confirmation rule as the serial loop.
|
||||||
|
try:
|
||||||
|
self._consume_pending_calibration(
|
||||||
|
tactile, frame_id + 1, float(packet.mono_t or 0.0)
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(f"calibration rejected: {type(exc).__name__}: {exc}")
|
||||||
|
packet.valid = False
|
||||||
|
packet.stale_forces = True
|
||||||
|
packet.forces_valid = False
|
||||||
|
packet.invalid_reason = packet.invalid_reason or errors[-1]
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(errors[-1])
|
||||||
|
try:
|
||||||
|
self._consume_pending_reset(
|
||||||
|
runtime, frame_id + 1, float(packet.mono_t or 0.0)
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
message = f"reset failed: {type(exc).__name__}: {exc}"
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(message)
|
||||||
|
with self._lock:
|
||||||
|
self._latest = packet
|
||||||
|
self._phase = "running"
|
||||||
|
# Record channel: every published packet, exactly as the serial
|
||||||
|
# loop emitted them.
|
||||||
|
self._emit(packet)
|
||||||
|
frame_id += 1
|
||||||
|
# Liveness. The pipeline reports "input ended" and "no newer frame"
|
||||||
|
# with the same ``None``, so these facts are what tell a finished
|
||||||
|
# pass, a dead camera and a slow start apart.
|
||||||
|
producer_done = not pipe.running and producer.produced <= frame_id
|
||||||
|
if not saw_frame:
|
||||||
|
if is_file and (now - session_started) >= FILE_FIRST_FRAME_TIMEOUT_S:
|
||||||
|
message = (
|
||||||
|
f"this video produced no frames in "
|
||||||
|
f"{FILE_FIRST_FRAME_TIMEOUT_S:.0f} s: {source} may be "
|
||||||
|
"unreadable, truncated, or in a codec this build cannot decode"
|
||||||
|
)
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(message)
|
||||||
|
self._set_end_reason("error")
|
||||||
|
break
|
||||||
|
if producer_done:
|
||||||
|
# The read itself ended and nothing was ever measured: say so
|
||||||
|
# now instead of waiting out the deadline above.
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(
|
||||||
|
f"this source produced no frames: {source} may be "
|
||||||
|
"unreadable, truncated, or in a codec this build cannot "
|
||||||
|
"decode"
|
||||||
|
)
|
||||||
|
self._set_end_reason("error" if is_file else "disconnected")
|
||||||
|
break
|
||||||
|
elif producer_done:
|
||||||
|
# Every frame the producer made has become a packet and there is
|
||||||
|
# no newer one: the clean end-of-input the serial loop reported.
|
||||||
|
self._set_end_reason("ended")
|
||||||
|
break
|
||||||
|
elif not is_file and (now - last_frame_at) >= CAMERA_DISCONNECT_TIMEOUT_S:
|
||||||
|
# The producer is still reading, but a live camera has published
|
||||||
|
# nothing for a whole timeout: the device is gone.
|
||||||
|
self._set_end_reason("disconnected")
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
self._pipe = None
|
||||||
|
try:
|
||||||
|
pipe.pause(PauseLevel.BOTH)
|
||||||
|
except Exception: # noqa: BLE001 - a failed pause must not block teardown
|
||||||
|
logger.debug("Could not hold the finger pipeline while stopping", exc_info=True)
|
||||||
|
try:
|
||||||
|
pipe.stop(timeout=STOP_JOIN_TIMEOUT_S)
|
||||||
|
except Exception as exc: # noqa: BLE001 - a stage error is reported here
|
||||||
|
if self._stop.is_set():
|
||||||
|
logger.debug("finger pipeline stopped with %r", exc, exc_info=True)
|
||||||
|
else:
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
|
||||||
|
if self._stop.is_set():
|
||||||
|
self._set_end_reason("stopped")
|
||||||
|
elif saw_frame and self._end_reason is None:
|
||||||
|
self._set_end_reason("ended")
|
||||||
|
self._set_phase("idle")
|
||||||
|
self._status("Finger acquisition stopped")
|
||||||
|
|
||||||
|
def _packet_from_stream_snapshot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: int,
|
||||||
|
mode: FixedCameraMode,
|
||||||
|
runtime,
|
||||||
|
tactile,
|
||||||
|
producer: "_FingerProducer",
|
||||||
|
live_capture: bool,
|
||||||
|
snap,
|
||||||
|
frame_id: int,
|
||||||
|
frame_period: float,
|
||||||
|
is_file: bool,
|
||||||
|
frame_times: list[float],
|
||||||
|
mono_t0: float,
|
||||||
|
capture_fps: float,
|
||||||
|
capture_dropped: int,
|
||||||
|
compute_error: str,
|
||||||
|
):
|
||||||
|
"""One :class:`SdkPacket` from one finished finger snapshot."""
|
||||||
|
produced_seq = frame_id + 1
|
||||||
|
t_collect = producer.collect_perf(produced_seq) or time.perf_counter()
|
||||||
|
mono = frame_id * frame_period if is_file else max(0.0, t_collect - mono_t0)
|
||||||
|
frame_times.append(t_collect)
|
||||||
|
cutoff = t_collect - FPS_WINDOW_S
|
||||||
|
del frame_times[
|
||||||
|
: next(
|
||||||
|
(index for index, stamp in enumerate(frame_times) if stamp >= cutoff),
|
||||||
|
len(frame_times),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
span = frame_times[-1] - frame_times[0] if len(frame_times) > 1 else 0.0
|
||||||
|
fps = (len(frame_times) - 1) / span if span > 0.0 else 0.0
|
||||||
|
if live_capture:
|
||||||
|
measured = producer.capture_fps()
|
||||||
|
if measured > 0.0:
|
||||||
|
capture_fps = measured
|
||||||
|
taken = _capture_dropped(producer.capture_source)
|
||||||
|
if taken is not None:
|
||||||
|
capture_dropped = int(taken)
|
||||||
|
elif producer.produced > produced_seq:
|
||||||
|
capture_dropped = int(producer.produced - produced_seq)
|
||||||
|
with self._lock:
|
||||||
|
self._measured_capture_fps = capture_fps
|
||||||
|
raw_sensor = getattr(tactile, "raw_sensor", None)
|
||||||
|
return _packet_from_snapshot(
|
||||||
|
snap,
|
||||||
|
raw_sensor,
|
||||||
|
session_id=session,
|
||||||
|
frame_id=frame_id,
|
||||||
|
mono_t=mono,
|
||||||
|
fps=fps,
|
||||||
|
capture_fps=capture_fps,
|
||||||
|
capture_dropped=capture_dropped,
|
||||||
|
raw_frame=producer.raw_frame(produced_seq),
|
||||||
|
valid_mask=runtime.valid_mask,
|
||||||
|
contact_analysed=True,
|
||||||
|
contact_found=bool(getattr(raw_sensor, "centroid_found", False)),
|
||||||
|
compute_error=compute_error,
|
||||||
|
calibration_version=self._calibration_version,
|
||||||
|
valid=not compute_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stream_include(self) -> tuple[str, ...]:
|
||||||
|
"""The arrays the finger views display, so the SDK copies only those.
|
||||||
|
|
||||||
|
``image`` is the unrolled tactile frame the 2D canvas paints, ``flow`` and
|
||||||
|
``depth`` are the two quantities the 3D pane offers, and the contact arrays
|
||||||
|
are what its overlay draws. The serial loop read every one of them
|
||||||
|
unconditionally.
|
||||||
|
"""
|
||||||
|
return ("image", "flow", "depth", "centroid", "contour")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- serial path
|
||||||
|
def _run_serial_loop(
|
||||||
|
self, session: int, mode: FixedCameraMode, source, runtime, is_file: bool
|
||||||
|
) -> None:
|
||||||
|
"""The single-threaded chain, kept whole for the packet-diff check.
|
||||||
|
|
||||||
|
``use_stream_pipeline=False`` selects it: read, unroll, decompose and read
|
||||||
|
the forces back to back on this one thread, which bounds throughput by the
|
||||||
|
sum of the stages. It is the reference the concurrent path is measured
|
||||||
|
against, and it is not the path a session uses.
|
||||||
|
"""
|
||||||
frame_times: list[float] = []
|
frame_times: list[float] = []
|
||||||
#: (collection time, published sequence), so the camera's own rate
|
#: (collection time, published sequence), so the camera's own rate
|
||||||
#: can be told apart from the rate this loop consumes (see the box
|
#: can be told apart from the rate this loop consumes (see the box
|
||||||
|
|||||||
@@ -122,6 +122,186 @@ class SdkPacket:
|
|||||||
contact: ContactObservation | None = None
|
contact: ContactObservation | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def contact_from_arrays(
|
||||||
|
centroid,
|
||||||
|
*,
|
||||||
|
found: bool | None = None,
|
||||||
|
frame_id: int = 0,
|
||||||
|
valid: bool = True,
|
||||||
|
) -> ContactObservation:
|
||||||
|
"""A :class:`ContactObservation` from the SDK's contact result.
|
||||||
|
|
||||||
|
``found`` is the SDK's own ``centroid_found`` flag and is what decides the
|
||||||
|
answer: the SDK leaves ``centroid`` at its default ``(0, 0)`` when it found no
|
||||||
|
expansion contour, so a corner centre is a *default*, not a measurement.
|
||||||
|
Reporting it as one would put a contact marker in the frame's corner that
|
||||||
|
nothing measured. Pass ``found=None`` only for a producer that has no such flag,
|
||||||
|
where the centre itself is the only evidence there is.
|
||||||
|
"""
|
||||||
|
xy: tuple[float, float] | None = None
|
||||||
|
if centroid is not None:
|
||||||
|
try:
|
||||||
|
arr = np.asarray(centroid, dtype=np.float64).reshape(-1)
|
||||||
|
if arr.size >= 2 and bool(np.all(np.isfinite(arr[:2]))):
|
||||||
|
xy = (float(arr[0]), float(arr[1]))
|
||||||
|
except Exception: # noqa: BLE001 - an unusable centre is not a centre
|
||||||
|
xy = None
|
||||||
|
located = xy is not None if found is None else bool(found and xy is not None)
|
||||||
|
return ContactObservation(
|
||||||
|
centroid_uv_xy=xy if located else None,
|
||||||
|
found=located,
|
||||||
|
valid=bool(valid and located),
|
||||||
|
frame_id=int(frame_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def from_snapshot(
|
||||||
|
snapshot,
|
||||||
|
sensor=None,
|
||||||
|
*,
|
||||||
|
session_id: int = 0,
|
||||||
|
frame_id: int | None = None,
|
||||||
|
mono_t: float | None = None,
|
||||||
|
wall_t: float | None = None,
|
||||||
|
fps: float | None = None,
|
||||||
|
process_ms: float | None = None,
|
||||||
|
capture_fps: float = 0.0,
|
||||||
|
capture_dropped: int = 0,
|
||||||
|
raw_frame: np.ndarray | None = None,
|
||||||
|
valid_mask: np.ndarray | None = None,
|
||||||
|
contact: ContactObservation | None = None,
|
||||||
|
contact_analysed: bool = False,
|
||||||
|
contact_found: bool | None = None,
|
||||||
|
compute_error: str = "",
|
||||||
|
calibration_version: str = "",
|
||||||
|
valid: bool | None = None,
|
||||||
|
) -> SdkPacket:
|
||||||
|
"""One :class:`SdkPacket` from one SDK ``FrameSnapshot`` plus the app's own facts.
|
||||||
|
|
||||||
|
The SDK owns the measurement; the app owns the provenance. Everything the
|
||||||
|
snapshot carries is mapped straight across, and everything the snapshot cannot
|
||||||
|
know — session identity, the record's frame counter, the capture counters, the
|
||||||
|
calibration in effect, whether the compute chain failed — is passed in
|
||||||
|
explicitly, so a reader of the packet can still tell a measurement from a
|
||||||
|
carry-over.
|
||||||
|
|
||||||
|
The force triplet is used **as the SDK computed it**: ``normal_force`` and the
|
||||||
|
shears are the SDK's filtered/calibrated output and ``*_raw`` is its unfiltered
|
||||||
|
integrator pair. The app must not filter either of them again (see
|
||||||
|
``FilterSettings.apply_to``: the filter chain lives in the SDK and the app only
|
||||||
|
asks for it and reads back what the device reports).
|
||||||
|
"""
|
||||||
|
err = str(compute_error or "")
|
||||||
|
if valid is None:
|
||||||
|
valid = not err
|
||||||
|
stale = bool(err)
|
||||||
|
frame = int(snapshot.seq if frame_id is None else frame_id)
|
||||||
|
image = snapshot.image
|
||||||
|
processed = ensure_bgr(image) if image is not None else image
|
||||||
|
wall = float(snapshot.timestamp if wall_t is None else wall_t)
|
||||||
|
if mono_t is None:
|
||||||
|
# The snapshot's age is measured from its own capture, so this is the
|
||||||
|
# frame's acquisition time as the SDK saw it, on the app's session origin.
|
||||||
|
mono_t = max(0.0, wall - float(snapshot.age_ms) / 1000.0)
|
||||||
|
if contact is None and contact_analysed:
|
||||||
|
contact = contact_from_arrays(
|
||||||
|
snapshot.centroid,
|
||||||
|
# The SDK's own "I found a contour" flag; not inferred from the centre,
|
||||||
|
# which it leaves at (0, 0) when it found nothing.
|
||||||
|
found=contact_found,
|
||||||
|
frame_id=frame,
|
||||||
|
valid=not stale,
|
||||||
|
)
|
||||||
|
flow = snapshot.flow
|
||||||
|
depth = snapshot.depth
|
||||||
|
flow_valid = flow is not None and not stale
|
||||||
|
depth_valid = depth is not None and not stale
|
||||||
|
calibrated = _force_calibrated(sensor)
|
||||||
|
return SdkPacket(
|
||||||
|
raw_frame=ensure_bgr(raw_frame) if raw_frame is not None else processed,
|
||||||
|
processed_frame=processed,
|
||||||
|
timestamp=wall,
|
||||||
|
wall_t=wall,
|
||||||
|
mono_t=float(mono_t),
|
||||||
|
fps=float(snapshot.fps if fps is None else fps),
|
||||||
|
process_ms=float(
|
||||||
|
(snapshot.produce_ms + snapshot.flow_ms + snapshot.hhd_ms)
|
||||||
|
if process_ms is None
|
||||||
|
else process_ms
|
||||||
|
),
|
||||||
|
capture_fps=float(capture_fps),
|
||||||
|
capture_dropped=int(capture_dropped),
|
||||||
|
normal_force=float(snapshot.normal_force),
|
||||||
|
shear_x=float(snapshot.shear_x),
|
||||||
|
shear_y=float(snapshot.shear_y),
|
||||||
|
normal_force_raw=float(snapshot.normal_force_raw),
|
||||||
|
shear_x_raw=float(snapshot.shear_x_raw),
|
||||||
|
shear_y_raw=float(snapshot.shear_y_raw),
|
||||||
|
force_unit=str(snapshot.unit),
|
||||||
|
force_calibrated=calibrated,
|
||||||
|
calibrated_axes=_calibrated_axes(sensor),
|
||||||
|
calibration_version=str(calibration_version or ""),
|
||||||
|
session_id=int(session_id),
|
||||||
|
frame_id=frame,
|
||||||
|
valid=bool(valid),
|
||||||
|
invalid_reason=err,
|
||||||
|
stale_forces=stale,
|
||||||
|
raw_available=_raw_available(sensor),
|
||||||
|
flow=flow,
|
||||||
|
depth=depth,
|
||||||
|
valid_mask=valid_mask,
|
||||||
|
flow_valid=flow_valid,
|
||||||
|
flow_frame_id=frame if flow_valid else None,
|
||||||
|
depth_valid=depth_valid,
|
||||||
|
depth_frame_id=frame if depth_valid else None,
|
||||||
|
forces_valid=bool(valid),
|
||||||
|
contact=contact,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _force_calibrated(sensor) -> bool:
|
||||||
|
"""Whether the device carries coefficients, mirroring ``sdk_worker``'s rule."""
|
||||||
|
if sensor is None:
|
||||||
|
return False
|
||||||
|
if getattr(sensor, "force_calibrated", False):
|
||||||
|
return True
|
||||||
|
return getattr(sensor, "_force_cal_sensor_name", None) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_available(sensor) -> bool:
|
||||||
|
"""False when the device exposes no RAW force channels at all."""
|
||||||
|
if sensor is None:
|
||||||
|
return False
|
||||||
|
return hasattr(sensor, "_force_raw") or hasattr(sensor, "fnormal_raw")
|
||||||
|
|
||||||
|
|
||||||
|
#: Maps the SDK's canonical channel keys to the app's short axis names.
|
||||||
|
_CALIBRATION_AXIS_ALIASES = {
|
||||||
|
"fn": ("fn", "normal", "normal_force"),
|
||||||
|
"fx": ("fx", "shear_x", "shearx"),
|
||||||
|
"fy": ("fy", "shear_y", "sheary"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calibrated_axes(sensor) -> tuple[str, ...]:
|
||||||
|
"""Which axes have a loaded calibration curve (see ``sdk_worker``)."""
|
||||||
|
if sensor is None:
|
||||||
|
return ()
|
||||||
|
calibration = getattr(sensor, "calibration", None)
|
||||||
|
channels = getattr(calibration, "channels", None)
|
||||||
|
if not channels:
|
||||||
|
return ()
|
||||||
|
try:
|
||||||
|
keys = {str(key).lower() for key in channels}
|
||||||
|
except TypeError:
|
||||||
|
return ()
|
||||||
|
axes: list[str] = []
|
||||||
|
for axis, aliases in _CALIBRATION_AXIS_ALIASES.items():
|
||||||
|
if keys & set(aliases):
|
||||||
|
axes.append(axis)
|
||||||
|
return tuple(axes)
|
||||||
|
|
||||||
|
|
||||||
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
|
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
|
||||||
"""Resolve a config reference (a file path or a product name) to its geometry."""
|
"""Resolve a config reference (a file path or a product name) to its geometry."""
|
||||||
ref = str(config_ref).strip()
|
ref = str(config_ref).strip()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -28,6 +29,12 @@ from orisys_app_shared.acquisition.models import (
|
|||||||
from orisys_app_shared.acquisition.models import (
|
from orisys_app_shared.acquisition.models import (
|
||||||
is_file_source as _is_file_source,
|
is_file_source as _is_file_source,
|
||||||
)
|
)
|
||||||
|
from orisys_app_shared.acquisition.models import (
|
||||||
|
from_snapshot as _packet_from_snapshot,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.acquisition.models import (
|
||||||
|
_calibrated_axes,
|
||||||
|
)
|
||||||
from orisys_app_shared.acquisition.sinks import RecordSinkMixin
|
from orisys_app_shared.acquisition.sinks import RecordSinkMixin
|
||||||
from orisys_app_shared.force.filters import FilterSettings, effective_filters
|
from orisys_app_shared.force.filters import FilterSettings, effective_filters
|
||||||
|
|
||||||
@@ -155,12 +162,19 @@ def _capture_dropped(sensor) -> int | None:
|
|||||||
The SDK's background reader counts one of these every time it replaces a
|
The SDK's background reader counts one of these every time it replaces a
|
||||||
frame that was never consumed, so this is the consumer's shortfall — the
|
frame that was never consumed, so this is the consumer's shortfall — the
|
||||||
number that says "the camera had it and we did not take it".
|
number that says "the camera had it and we did not take it".
|
||||||
|
|
||||||
|
``None`` when the source cannot say: a file source has no background reader,
|
||||||
|
and a derived source (a finger runtime's tactile processor, say) may expose no
|
||||||
|
capture source at all. The caller then falls back to the pipeline's own
|
||||||
|
produced-versus-published count.
|
||||||
"""
|
"""
|
||||||
source = _capture_source(sensor)
|
source = _capture_source(sensor)
|
||||||
status = getattr(source, "status", None) if source is not None else None
|
status = getattr(source, "status", None) if source is not None else None
|
||||||
|
if status is None:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
return int(getattr(status, "dropped_frames"))
|
return int(getattr(status, "dropped_frames"))
|
||||||
except (TypeError, ValueError):
|
except (AttributeError, TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -397,34 +411,220 @@ def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#: Maps the SDK's canonical channel keys to the app's short axis names.
|
#: The axis helper lives in ``models`` beside
|
||||||
_CALIBRATION_AXIS_ALIASES = {
|
#: :func:`~orisys_app_shared.acquisition.models.from_snapshot`, which is the one
|
||||||
"fn": ("fn", "normal", "normal_force"),
|
#: place that fills a packet from an SDK snapshot. ``_calibrated_axes`` is imported
|
||||||
"fx": ("fx", "shear_x", "shearx"),
|
#: above and re-exported from this module, its historical home.
|
||||||
"fy": ("fy", "shear_y", "sheary"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
class _FrameProducer:
|
||||||
|
"""The ``StreamPipeline`` produce callable for a box/mini ``Sensor``.
|
||||||
|
|
||||||
def _calibrated_axes(sensor) -> tuple[str, ...]:
|
Three jobs, all of which have to happen on the producer thread because they are
|
||||||
"""Which axes have a loaded calibration curve.
|
about the *read* rather than about the computation:
|
||||||
|
|
||||||
The SDK stores one curve per channel, so a device may be calibrated on its
|
* **One published frame per delivered frame.** ``CaptureSource.read_latest``
|
||||||
normal axis only. Reporting a single sensor-level flag would make the app
|
hands back the newest frame and, until its background reader produces
|
||||||
write raw counts into the newton columns for the uncalibrated axes.
|
another, that same frame again. The loop this replaces detected the repeat
|
||||||
|
through the source's sequence counter and skipped it, and the record channel
|
||||||
|
depends on that: without it a 30 fps device is recorded at the loop's polling
|
||||||
|
rate and every measurement is duplicated. The counter is only a repeat marker
|
||||||
|
for a source with a running background reader, so a video file — which
|
||||||
|
decodes a new frame per call — is read once per call as before.
|
||||||
|
* **The pre-processing frame.** ``Sensor.frame`` is overwritten by the next
|
||||||
|
``get_img`` call, so a snapshot's raw frame must be copied here, while it is
|
||||||
|
still this frame's, and handed to the consumer by sequence. The 3D pane and
|
||||||
|
the pre-roll buffer both read it.
|
||||||
|
* **When the frame was collected.** ``t0`` is the moment *before* the read, the
|
||||||
|
same instant the old loop stamped a frame with. The frame rate and
|
||||||
|
``mono_t`` are measured from it, not from when the pipeline finished.
|
||||||
"""
|
"""
|
||||||
calibration = getattr(sensor, "calibration", None)
|
|
||||||
channels = getattr(calibration, "channels", None)
|
#: How many frames of raw-frame history to keep. Must exceed the pipeline's
|
||||||
if not channels:
|
#: queue depth: the consumer can be a few frames behind the producer.
|
||||||
return ()
|
CACHE = 8
|
||||||
try:
|
|
||||||
keys = {str(key).lower() for key in channels}
|
def __init__(
|
||||||
except TypeError:
|
self, sensor, *, is_file: bool, repeat_wait_s: float, frame_period: float = 0.0
|
||||||
return ()
|
) -> None:
|
||||||
axes: list[str] = []
|
self.sensor = sensor
|
||||||
for axis, aliases in _CALIBRATION_AXIS_ALIASES.items():
|
self.is_file = bool(is_file)
|
||||||
if keys & set(aliases):
|
self.repeat_wait_s = float(repeat_wait_s)
|
||||||
axes.append(axis)
|
#: What one frame of this device is meant to cost. A *file* is paced by it:
|
||||||
return tuple(axes)
|
#: a video has no background reader to wait on, so without this the pass runs
|
||||||
|
#: as fast as the CPU allows and stamps far more video time than real time —
|
||||||
|
#: the serial loop paced the same way (see its ``last_frame_at``).
|
||||||
|
self.frame_period = float(frame_period)
|
||||||
|
self.produced = 0
|
||||||
|
self.capture_seq = _capture_sequence(sensor)
|
||||||
|
self._cache: deque = deque(maxlen=self.CACHE)
|
||||||
|
#: ``(sequence, collection time)`` per produced frame, for the frame rate.
|
||||||
|
self._times: deque = deque()
|
||||||
|
self._last_read_at: float | None = None
|
||||||
|
#: ``(clock, seconds, delivered frames)`` samples behind ``capture_fps``.
|
||||||
|
self._rate_samples: deque = deque()
|
||||||
|
self._capture_mark: tuple[float, int] | None = None
|
||||||
|
#: The wait's own anchor into the counter, consumed by ``capture_fps``.
|
||||||
|
self._repeat_check: tuple[float, int] | None = None
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
self._wait_for_a_new_frame()
|
||||||
|
self._pace()
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
image = self.sensor.get_img()
|
||||||
|
if image is None:
|
||||||
|
return None
|
||||||
|
self._last_read_at = t0
|
||||||
|
self.produced += 1
|
||||||
|
self._times.append((self.produced, t0))
|
||||||
|
cutoff = t0 - FPS_WINDOW_S
|
||||||
|
while self._times and self._times[0][1] < cutoff:
|
||||||
|
self._times.popleft()
|
||||||
|
raw = getattr(self.sensor, "frame", None)
|
||||||
|
if raw is not None:
|
||||||
|
self._cache.append((self.produced, np.array(raw, copy=True)))
|
||||||
|
return image
|
||||||
|
|
||||||
|
def _pace(self) -> None:
|
||||||
|
"""Wait out the rest of the frame period, for a source with no reader."""
|
||||||
|
if not self.is_file or self.frame_period <= 0.0:
|
||||||
|
return
|
||||||
|
if self._last_read_at is None:
|
||||||
|
return
|
||||||
|
remaining = self.frame_period - (time.perf_counter() - self._last_read_at)
|
||||||
|
if remaining > CAPTURE_REPEAT_POLL_S:
|
||||||
|
time.sleep(min(remaining, self.frame_period * 0.5))
|
||||||
|
|
||||||
|
def _wait_for_a_new_frame(self) -> None:
|
||||||
|
"""Hold the read until the capture source has published another frame.
|
||||||
|
|
||||||
|
Retried with ``time.sleep(0)`` rather than a fixed sleep, because on Windows
|
||||||
|
even a 1 ms sleep costs a full 15.6 ms timer tick — one tick per frame is
|
||||||
|
most of a 30 fps frame period, and the camera's own thread is then starved of
|
||||||
|
the GIL for the rest of it. Yielding hands it back immediately and the next
|
||||||
|
poll happens as soon as the frame is there.
|
||||||
|
"""
|
||||||
|
if self.is_file or self.capture_seq is None:
|
||||||
|
return
|
||||||
|
#: When the wait started, and what the counter said then: the two together
|
||||||
|
#: are the only honest basis for the camera's own rate when this loop is
|
||||||
|
#: slower than the camera and skips a frame entirely.
|
||||||
|
self._repeat_check = (time.perf_counter(), int(self.capture_seq))
|
||||||
|
deadline = time.perf_counter() + self.repeat_wait_s
|
||||||
|
while True:
|
||||||
|
current = _capture_sequence(self.sensor)
|
||||||
|
if current is None or current != self.capture_seq:
|
||||||
|
self.capture_seq = current
|
||||||
|
return
|
||||||
|
if time.perf_counter() >= deadline:
|
||||||
|
# The camera has stopped publishing but the device is still open.
|
||||||
|
# Reading on keeps the session alive for the liveness watchdog in
|
||||||
|
# the consumer to see and report, instead of stalling here with the
|
||||||
|
# pipeline looking healthy.
|
||||||
|
return
|
||||||
|
time.sleep(0)
|
||||||
|
|
||||||
|
def raw_frame(self, seq: int) -> np.ndarray | None:
|
||||||
|
"""The pre-processing frame of produced frame ``seq``, or ``None``."""
|
||||||
|
for number, frame in reversed(self._cache):
|
||||||
|
if number == seq:
|
||||||
|
return frame
|
||||||
|
return None
|
||||||
|
|
||||||
|
def collect_perf(self, seq: int) -> float | None:
|
||||||
|
"""The collection time of produced frame ``seq``."""
|
||||||
|
for number, stamp in reversed(self._times):
|
||||||
|
if number == seq:
|
||||||
|
return stamp
|
||||||
|
return None
|
||||||
|
|
||||||
|
def capture_window(self, previous: tuple[float, int] | None) -> tuple[float, int]:
|
||||||
|
"""``(seconds, delivered frames)`` over the interval since ``previous``.
|
||||||
|
|
||||||
|
Both anchors have to be read together, and on this thread: the source's
|
||||||
|
counter advances on the capture thread whether or not a frame is taken, so
|
||||||
|
pairing a stale time with the current counter would measure the interval
|
||||||
|
between two *publishes* while crediting every frame delivered since an
|
||||||
|
earlier read — a rate that comes out at roughly double the camera's.
|
||||||
|
"""
|
||||||
|
if previous is None or self.capture_seq is None:
|
||||||
|
return 0.0, 0
|
||||||
|
t0, delivered0 = previous
|
||||||
|
span = time.perf_counter() - t0
|
||||||
|
delivered = int(self.capture_seq) - int(delivered0)
|
||||||
|
return (span, delivered) if span > 0.0 else (0.0, 0)
|
||||||
|
|
||||||
|
def capture_fps(self) -> float:
|
||||||
|
"""The capture source's own published rate, as a rate over a window.
|
||||||
|
|
||||||
|
Counted from the source's delivered-frame counter between two of this
|
||||||
|
producer's reads, against the wall-clock between them, and summed over the
|
||||||
|
last second so one scheduler-jittered interval does not become the reported
|
||||||
|
rate. Only a source with that counter can answer — a live camera. A video
|
||||||
|
file decodes a new frame per call, so it has no "the camera had it and we did
|
||||||
|
not" to measure.
|
||||||
|
"""
|
||||||
|
if self.capture_seq is None:
|
||||||
|
return 0.0
|
||||||
|
now = time.perf_counter()
|
||||||
|
span, delivered = self.capture_window(self._capture_mark)
|
||||||
|
self._capture_mark = (now, int(self.capture_seq))
|
||||||
|
if span <= 0.0 or delivered <= 0:
|
||||||
|
# Credit what the repeat wait already observed: when this loop cannot
|
||||||
|
# keep up, no frame is read at all until the counter moves again, and a
|
||||||
|
# read-anchored window would then credit a whole interval's frames to a
|
||||||
|
# fraction of it.
|
||||||
|
span, delivered = self._repeat_window()
|
||||||
|
if span > 0.0 and delivered > 0:
|
||||||
|
self._rate_samples.append((now, span, delivered))
|
||||||
|
cutoff = now - FPS_WINDOW_S
|
||||||
|
while len(self._rate_samples) > 1 and self._rate_samples[0][0] < cutoff:
|
||||||
|
self._rate_samples.popleft()
|
||||||
|
total_span = sum(sample[1] for sample in self._rate_samples)
|
||||||
|
total_frames = sum(sample[2] for sample in self._rate_samples)
|
||||||
|
return max(0.0, total_frames / total_span) if total_span > 0.0 else 0.0
|
||||||
|
|
||||||
|
def _repeat_window(self) -> tuple[float, int]:
|
||||||
|
"""``(seconds, frames)`` the repeat wait saw, and then forget it."""
|
||||||
|
previous = self._repeat_check
|
||||||
|
self._repeat_check = None
|
||||||
|
if previous is None or self._capture_mark is None or self.capture_seq is None:
|
||||||
|
return 0.0, 0
|
||||||
|
t0, seq0 = previous
|
||||||
|
span = self._capture_mark[0] - t0
|
||||||
|
frames = int(self.capture_seq) - int(seq0)
|
||||||
|
return (span, frames) if span > 0.0 and frames > 0 else (0.0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class _CaptureWindow:
|
||||||
|
"""The capture source's own published-frame rate, for the serial loop.
|
||||||
|
|
||||||
|
Measured from the source's delivered-frame counter, sampled once per frame: the
|
||||||
|
counter advances whether or not the loop takes a frame, so a rate below the
|
||||||
|
camera's own can be attributed. The concurrent path measures the same thing
|
||||||
|
inside its producer (``_FrameProducer.capture_fps``), where the counter can be
|
||||||
|
anchored to the wall clock it belongs to; this window is what the reference path
|
||||||
|
uses, so both report the rate on the same basis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sensor) -> None:
|
||||||
|
self.sensor = sensor
|
||||||
|
self._times: list[tuple[float, int]] = []
|
||||||
|
|
||||||
|
def update(self, t0: float) -> float:
|
||||||
|
if self.sensor is None:
|
||||||
|
return 0.0
|
||||||
|
delivered = _capture_sequence(self.sensor)
|
||||||
|
if delivered is not None and (
|
||||||
|
not self._times or delivered != self._times[-1][1]
|
||||||
|
):
|
||||||
|
self._times.append((t0, delivered))
|
||||||
|
cutoff = t0 - FPS_WINDOW_S
|
||||||
|
self._times = [item for item in self._times if item[0] >= cutoff]
|
||||||
|
if len(self._times) < 2:
|
||||||
|
return 0.0
|
||||||
|
span = self._times[-1][0] - self._times[0][0]
|
||||||
|
published = self._times[-1][1] - self._times[0][1]
|
||||||
|
return max(0.0, published / span) if span > 0.0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
def calibration_digest(path: str | Path) -> str:
|
def calibration_digest(path: str | Path) -> str:
|
||||||
@@ -493,8 +693,13 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
on_status: Callable[[str], None] | None = None,
|
on_status: Callable[[str], None] | None = None,
|
||||||
on_calibration_applied: Callable[[str, str, int, float, int], None] | None = None,
|
on_calibration_applied: Callable[[str, str, int, float, int], None] | None = None,
|
||||||
on_reset_applied: Callable[[int, float, int], None] | None = None,
|
on_reset_applied: Callable[[int, float, int], None] | None = None,
|
||||||
|
use_stream_pipeline: bool = True,
|
||||||
):
|
):
|
||||||
self._init_sinks()
|
self._init_sinks()
|
||||||
|
#: The SDK's concurrent ``StreamPipeline`` is the acquisition path; the
|
||||||
|
#: serial chain is kept only so a change can be checked against it
|
||||||
|
#: frame-for-frame on the same input (``False`` selects the reference).
|
||||||
|
self._use_stream_pipeline = bool(use_stream_pipeline)
|
||||||
self._on_error = on_error
|
self._on_error = on_error
|
||||||
self._on_status = on_status
|
self._on_status = on_status
|
||||||
#: Called from the acquisition thread once new coefficients are actually
|
#: Called from the acquisition thread once new coefficients are actually
|
||||||
@@ -510,6 +715,10 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
#: Set while the operator has paused: the device stays open and the capture keeps
|
#: Set while the operator has paused: the device stays open and the capture keeps
|
||||||
#: publishing, but nothing is computed and no packet is emitted.
|
#: publishing, but nothing is computed and no packet is emitted.
|
||||||
self._pause = threading.Event()
|
self._pause = threading.Event()
|
||||||
|
#: The running session's ``StreamPipeline``, when there is one, so
|
||||||
|
#: ``pause``/``resume`` from the GUI thread reach the pipeline that is doing
|
||||||
|
#: the computing.
|
||||||
|
self._pipe = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._latest: SdkPacket | None = None
|
self._latest: SdkPacket | None = None
|
||||||
self._session_id = 0
|
self._session_id = 0
|
||||||
@@ -687,17 +896,43 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
|
|
||||||
This is what makes pause different from :meth:`stop`: the camera is not
|
This is what makes pause different from :meth:`stop`: the camera is not
|
||||||
released, so resuming is immediate and the device does not have to be
|
released, so resuming is immediate and the device does not have to be
|
||||||
re-opened (and, on some boards, re-enumerated). While paused the loop keeps
|
re-opened (and, on some boards, re-enumerated). The concurrent pipeline
|
||||||
draining the capture source — the disconnect watchdog must not fire on a
|
holds at ``PauseLevel.CAPTURE_ONLY``, which is exactly that statement: the
|
||||||
healthy camera just because an operator stopped looking at it — but it runs
|
read continues — so exposure, white balance and frame rate stay converged,
|
||||||
no algorithm and emits no packet: no flow, no depth, no contact, and the
|
and the disconnect watchdog sees a live device — while the flow and HHD
|
||||||
record channel sees a gap for the paused interval.
|
stages are skipped and no snapshot is published: no flow, no depth, no
|
||||||
|
contact, and the record channel sees a gap for the paused interval.
|
||||||
"""
|
"""
|
||||||
self._pause.set()
|
self._pause.set()
|
||||||
|
pipe = self._pipe
|
||||||
|
if pipe is not None:
|
||||||
|
try:
|
||||||
|
pipe.pause(self._pause_level())
|
||||||
|
except Exception: # noqa: BLE001 - the loop honours ``_pause`` anyway
|
||||||
|
logger.exception("Could not hold the pipeline for a pause")
|
||||||
|
|
||||||
def resume(self) -> None:
|
def resume(self) -> None:
|
||||||
"""Start computing and publishing again, from the *newest* frame."""
|
"""Start computing and publishing again, from the *newest* frame.
|
||||||
|
|
||||||
|
The tracker is rebased, because a ``CAPTURE_ONLY`` hold read frames that
|
||||||
|
were never computed: keeping the pre-pause reference frame would report
|
||||||
|
the hold itself as a large deformation.
|
||||||
|
"""
|
||||||
self._pause.clear()
|
self._pause.clear()
|
||||||
|
pipe = self._pipe
|
||||||
|
if pipe is not None:
|
||||||
|
try:
|
||||||
|
# Only meaningful for a pipeline that was actually held; the SDK
|
||||||
|
# ignores a resume when it is not paused.
|
||||||
|
pipe.resume(reset=True)
|
||||||
|
except Exception: # noqa: BLE001 - the loop honours ``_pause`` anyway
|
||||||
|
logger.exception("Could not release the pipeline for a resume")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pause_level():
|
||||||
|
from orisys.pipeline import PauseLevel
|
||||||
|
|
||||||
|
return PauseLevel.CAPTURE_ONLY
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def paused(self) -> bool:
|
def paused(self) -> bool:
|
||||||
@@ -818,6 +1053,25 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
self._phase = "idle"
|
self._phase = "idle"
|
||||||
|
|
||||||
def _run_session(self, session: int, previous: threading.Thread | None) -> None:
|
def _run_session(self, session: int, previous: threading.Thread | None) -> None:
|
||||||
|
opened = self._open_session(session, previous)
|
||||||
|
if opened is None:
|
||||||
|
return
|
||||||
|
mode, source, sensor, is_file = opened
|
||||||
|
if self._use_stream_pipeline:
|
||||||
|
# The recommended path: the SDK runs produce -> flow -> HHD on three
|
||||||
|
# threads, so throughput is bounded by the slowest stage rather than by
|
||||||
|
# their sum. Everything the app adds (session bookkeeping, the record
|
||||||
|
# sink, calibration reload, the drop counters) stays layered on top.
|
||||||
|
self._run_stream_pipeline(session, mode, source, sensor, is_file)
|
||||||
|
return
|
||||||
|
self._run_serial_loop(session, mode, source, sensor, is_file)
|
||||||
|
|
||||||
|
def _open_session(self, session: int, previous: threading.Thread | None):
|
||||||
|
"""Open the device and configure it; ``None`` when the session cannot run.
|
||||||
|
|
||||||
|
Shared by both acquisition paths, so a session's calibration identity,
|
||||||
|
filter state and status messages cannot drift between them.
|
||||||
|
"""
|
||||||
import orisys
|
import orisys
|
||||||
|
|
||||||
if previous is not None and previous.is_alive():
|
if previous is not None and previous.is_alive():
|
||||||
@@ -829,7 +1083,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
mode = self._mode
|
mode = self._mode
|
||||||
source = self._video_source
|
source = self._video_source
|
||||||
if mode is None or source is None:
|
if mode is None or source is None:
|
||||||
return
|
return None
|
||||||
is_file = _is_file_source(source)
|
is_file = _is_file_source(source)
|
||||||
try:
|
try:
|
||||||
self._status(f"Opening Orisys Sensor ({mode.label()})…")
|
self._status(f"Opening Orisys Sensor ({mode.label()})…")
|
||||||
@@ -843,7 +1097,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
self._sensors[session] = sensor
|
self._sensors[session] = sensor
|
||||||
if self._stop.is_set() or session != self._session_id:
|
if self._stop.is_set() or session != self._session_id:
|
||||||
self._close_sensor(session)
|
self._close_sensor(session)
|
||||||
return
|
return None
|
||||||
# The filter chain lives in the SDK; the app only says what the
|
# The filter chain lives in the SDK; the app only says what the
|
||||||
# operator asked for and then reads back what the device is doing.
|
# operator asked for and then reads back what the device is doing.
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -866,12 +1120,372 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
)
|
)
|
||||||
self._status(f"Streaming {mode.label()}")
|
self._status(f"Streaming {mode.label()}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
self._set_phase("idle")
|
||||||
|
return None
|
||||||
|
return mode, source, sensor, is_file
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- concurrent path
|
||||||
|
def _run_stream_pipeline(
|
||||||
|
self, session: int, mode: FixedCameraMode, source, sensor, is_file: bool
|
||||||
|
) -> None:
|
||||||
|
"""Drive the SDK's concurrent ``StreamPipeline`` and build the packets.
|
||||||
|
|
||||||
|
The pipeline publishes one ``FrameSnapshot`` per frame it finishes; this
|
||||||
|
thread consumes them and owns everything the app layer adds. ``include=``
|
||||||
|
asks for exactly the arrays the app can display (see
|
||||||
|
:meth:`_stream_include`), because an array the snapshot does not carry is an
|
||||||
|
array the SDK did not have to copy.
|
||||||
|
"""
|
||||||
|
import queue as queue_module
|
||||||
|
|
||||||
|
from orisys.pipeline import PauseLevel, StreamPipeline
|
||||||
|
|
||||||
|
frame_period = 1.0 / max(1.0, float(mode.fps or 30.0))
|
||||||
|
producer = _FrameProducer(
|
||||||
|
sensor,
|
||||||
|
is_file=is_file,
|
||||||
|
# Only a live camera needs the wait; a file decodes the next frame per
|
||||||
|
# call and has no background reader to ask.
|
||||||
|
repeat_wait_s=CAMERA_DISCONNECT_TIMEOUT_S,
|
||||||
|
frame_period=frame_period,
|
||||||
|
)
|
||||||
|
snapshots: queue_module.Queue = queue_module.Queue(maxsize=64)
|
||||||
|
analysis = self._stream_contact(sensor)
|
||||||
|
#: The monotonic clock at start: every frame's ``mono_t`` is measured from it,
|
||||||
|
#: and the frame rate is measured between frame *collection* times.
|
||||||
|
mono_t0 = time.perf_counter()
|
||||||
|
frame_times: list[float] = []
|
||||||
|
#: Whether the source can report its own delivered-frame rate at all: a
|
||||||
|
#: file has no background reader to count published frames.
|
||||||
|
live_capture = _capture_sequence(sensor) is not None
|
||||||
|
frame_id = 0
|
||||||
|
capture_fps = 0.0
|
||||||
|
capture_dropped = 0
|
||||||
|
session_started = time.perf_counter()
|
||||||
|
last_frame_at = session_started
|
||||||
|
saw_frame = False
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
def merged_error() -> str:
|
||||||
|
return "; ".join(errors)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._phase = "running"
|
||||||
|
|
||||||
|
try:
|
||||||
|
pipe = StreamPipeline(
|
||||||
|
sensor,
|
||||||
|
producer,
|
||||||
|
contact=analysis,
|
||||||
|
include=self._stream_include(sensor),
|
||||||
|
on_frame=snapshots.put,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - reported, not swallowed
|
||||||
self._set_end_reason("error")
|
self._set_end_reason("error")
|
||||||
if self._on_error is not None and session == self._session_id:
|
if self._on_error is not None and session == self._session_id:
|
||||||
self._on_error(str(exc))
|
self._on_error(str(exc))
|
||||||
self._set_phase("idle")
|
self._set_phase("idle")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Published before ``start``: ``pause`` may be clicked while the device is
|
||||||
|
# still opening, and a hold that arrived then must not be lost.
|
||||||
|
self._pipe = pipe
|
||||||
|
try:
|
||||||
|
pipe.start()
|
||||||
|
if self._pause.is_set():
|
||||||
|
pipe.pause(self._pause_level())
|
||||||
|
while not self._stop.is_set() and session == self._session_id:
|
||||||
|
if pipe.paused:
|
||||||
|
# ``wait`` returns at once while paused, so a loop that only
|
||||||
|
# waited would spin a core for the whole hold. Sleep out the
|
||||||
|
# hold instead and check the stop flag between sleeps.
|
||||||
|
time.sleep(PAUSE_POLL_S)
|
||||||
|
continue
|
||||||
|
if pipe.error is not None:
|
||||||
|
# A stage raised. ``wait`` would re-raise it; this loop does not
|
||||||
|
# call ``wait``, so it is reported here instead.
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(pipe.error))
|
||||||
|
break
|
||||||
|
# How many frames the producer has finished, and how many this loop
|
||||||
|
# has taken: a ``wait`` is only worth making while the first is
|
||||||
|
# ahead. ``producer.produced`` also latches the moment the first
|
||||||
|
# read has happened, which is what keeps the "no frames" deadline
|
||||||
|
# below from racing the very first frame.
|
||||||
|
expect = max(producer.produced, frame_id)
|
||||||
|
snap = None
|
||||||
|
if expect > 0:
|
||||||
|
try:
|
||||||
|
snap = snapshots.get(timeout=0.05)
|
||||||
|
except queue_module.Empty:
|
||||||
|
snap = None
|
||||||
|
except Exception as exc: # noqa: BLE001 - a stage failure ends the session
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
break
|
||||||
|
now = time.perf_counter()
|
||||||
|
if snap is None:
|
||||||
|
# Nothing published, or nothing newer to hand over. The liveness
|
||||||
|
# rules below decide whether that is a dead camera or a slow
|
||||||
|
# start, and whether the pass is simply over.
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
saw_frame = True
|
||||||
|
last_frame_at = now
|
||||||
|
try:
|
||||||
|
packet = self._packet_from_stream_snapshot(
|
||||||
|
session=session,
|
||||||
|
sensor=sensor,
|
||||||
|
producer=producer,
|
||||||
|
live_capture=live_capture,
|
||||||
|
snap=snap,
|
||||||
|
frame_id=frame_id,
|
||||||
|
frame_period=frame_period,
|
||||||
|
is_file=is_file,
|
||||||
|
analysis=analysis,
|
||||||
|
frame_times=frame_times,
|
||||||
|
mono_t0=mono_t0,
|
||||||
|
capture_fps=capture_fps,
|
||||||
|
capture_dropped=capture_dropped,
|
||||||
|
compute_error=merged_error(),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
break
|
||||||
|
capture_fps = packet.capture_fps
|
||||||
|
capture_dropped = packet.capture_dropped
|
||||||
|
if self._pause.is_set():
|
||||||
|
# The hold arrived after this frame was published but before
|
||||||
|
# it was built. "Pause the computation and the display"
|
||||||
|
# includes not handing the operator a packet they paused
|
||||||
|
# before, so the in-flight frame is dropped here.
|
||||||
|
continue
|
||||||
|
# A zeroing and new coefficients are applied by this thread at a
|
||||||
|
# frame boundary, then reported with the frame and time they took
|
||||||
|
# effect at — the same confirmation rule as the serial loop.
|
||||||
|
try:
|
||||||
|
self._consume_pending_calibration(
|
||||||
|
sensor, frame_id + 1, float(packet.mono_t or 0.0)
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(f"calibration rejected: {type(exc).__name__}: {exc}")
|
||||||
|
packet.valid = False
|
||||||
|
packet.stale_forces = True
|
||||||
|
packet.forces_valid = False
|
||||||
|
packet.invalid_reason = (
|
||||||
|
packet.invalid_reason or errors[-1]
|
||||||
|
)
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(errors[-1])
|
||||||
|
try:
|
||||||
|
self._consume_pending_reset(
|
||||||
|
sensor, frame_id + 1, float(packet.mono_t or 0.0)
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
message = f"reset failed: {type(exc).__name__}: {exc}"
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(message)
|
||||||
|
with self._lock:
|
||||||
|
self._latest = packet
|
||||||
|
self._phase = "running"
|
||||||
|
# Record channel: every published packet, exactly as the serial
|
||||||
|
# loop emitted them.
|
||||||
|
self._emit(packet)
|
||||||
|
frame_id += 1
|
||||||
|
# Liveness. The pipeline reports "input ended" and "no newer frame"
|
||||||
|
# with the same ``None``, so these facts are what tell a finished
|
||||||
|
# pass, a dead camera and a slow start apart.
|
||||||
|
producer_done = not pipe.running and producer.produced <= frame_id
|
||||||
|
if not saw_frame:
|
||||||
|
if is_file and (now - session_started) >= FILE_FIRST_FRAME_TIMEOUT_S:
|
||||||
|
message = (
|
||||||
|
f"this video produced no frames in "
|
||||||
|
f"{FILE_FIRST_FRAME_TIMEOUT_S:.0f} s: {source} may be "
|
||||||
|
"unreadable, truncated, or in a codec this build cannot decode"
|
||||||
|
)
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(message)
|
||||||
|
self._set_end_reason("error")
|
||||||
|
break
|
||||||
|
if producer_done:
|
||||||
|
# The read itself ended and nothing was ever measured: an
|
||||||
|
# unreadable file, or a device that delivered nothing. Say
|
||||||
|
# so now instead of waiting out the deadline above.
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(
|
||||||
|
f"this source produced no frames: {source} may be "
|
||||||
|
"unreadable, truncated, or in a codec this build cannot "
|
||||||
|
"decode"
|
||||||
|
)
|
||||||
|
self._set_end_reason("error" if is_file else "disconnected")
|
||||||
|
break
|
||||||
|
elif producer_done:
|
||||||
|
# Every frame the producer made has become a packet and there is
|
||||||
|
# no newer one: the clean end-of-input the serial loop reported.
|
||||||
|
self._set_end_reason("ended")
|
||||||
|
break
|
||||||
|
elif not is_file and (now - last_frame_at) >= CAMERA_DISCONNECT_TIMEOUT_S:
|
||||||
|
# The producer is still reading, but a live camera has published
|
||||||
|
# nothing for a whole timeout: the device is gone.
|
||||||
|
self._set_end_reason("disconnected")
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
self._pipe = None
|
||||||
|
try:
|
||||||
|
pipe.pause(PauseLevel.BOTH)
|
||||||
|
except Exception: # noqa: BLE001 - a failed pause must not block teardown
|
||||||
|
logger.debug("Could not hold the pipeline while stopping", exc_info=True)
|
||||||
|
try:
|
||||||
|
pipe.stop(timeout=STOP_JOIN_TIMEOUT_S)
|
||||||
|
except Exception as exc: # noqa: BLE001 - a stage error is reported here
|
||||||
|
if self._stop.is_set():
|
||||||
|
logger.debug("pipeline stopped with %r", exc, exc_info=True)
|
||||||
|
else:
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
|
||||||
|
if self._stop.is_set():
|
||||||
|
self._set_end_reason("stopped")
|
||||||
|
elif saw_frame and self._end_reason is None:
|
||||||
|
# The producer returned end-of-input and every published frame was
|
||||||
|
# consumed: that is the same clean end the serial loop reported.
|
||||||
|
self._set_end_reason("ended")
|
||||||
|
self._set_phase("idle")
|
||||||
|
self._status("Acquisition stopped")
|
||||||
|
|
||||||
|
def _stream_include(self, sensor) -> tuple[str, ...]:
|
||||||
|
"""The arrays the app can actually display, so the SDK copies only those.
|
||||||
|
|
||||||
|
``image`` is the tactile frame the 2D canvas paints, ``flow`` and ``depth``
|
||||||
|
are the two quantities the 3D pane offers, and the contact arrays are what
|
||||||
|
its overlay and the 2D contact arrow draw. Every one of them was read
|
||||||
|
unconditionally before this migration; a sensor with no contact analysis
|
||||||
|
does not pay for the contact channels.
|
||||||
|
"""
|
||||||
|
fields = ["image", "flow", "depth"]
|
||||||
|
if self._stream_contact(sensor):
|
||||||
|
fields += ["centroid", "contour"]
|
||||||
|
return tuple(fields)
|
||||||
|
|
||||||
|
def _stream_contact(self, sensor) -> bool:
|
||||||
|
"""Whether this device's snapshot can carry a contact result at all."""
|
||||||
|
return callable(getattr(sensor, "compute_contact", None))
|
||||||
|
|
||||||
|
def _packet_from_stream_snapshot(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: int,
|
||||||
|
sensor,
|
||||||
|
producer: _FrameProducer,
|
||||||
|
live_capture: bool,
|
||||||
|
snap,
|
||||||
|
frame_id: int,
|
||||||
|
frame_period: float,
|
||||||
|
is_file: bool,
|
||||||
|
analysis: bool,
|
||||||
|
frame_times: list[float],
|
||||||
|
mono_t0: float,
|
||||||
|
capture_fps: float,
|
||||||
|
capture_dropped: int,
|
||||||
|
compute_error: str,
|
||||||
|
):
|
||||||
|
"""One :class:`SdkPacket` from one finished snapshot, plus app facts.
|
||||||
|
|
||||||
|
Everything measured comes from the snapshot through
|
||||||
|
:func:`~orisys_app_shared.acquisition.models.from_snapshot`; everything
|
||||||
|
app-owned is computed here, from the same evidence the serial loop used.
|
||||||
|
"""
|
||||||
|
# ``produced`` counts the reads the producer has made, and the pipeline
|
||||||
|
# numbers its snapshots from zero, so produced frame N carries snapshot
|
||||||
|
# sequence N - 1.
|
||||||
|
produced_seq = frame_id + 1
|
||||||
|
t_collect = producer.collect_perf(produced_seq) or time.perf_counter()
|
||||||
|
mono = (
|
||||||
|
# A file has the timeline of the *video*, not of how long the pass took:
|
||||||
|
# recomputing a 5 s recording at 60 fps would otherwise land on a
|
||||||
|
# 0-2 s axis and could not be compared with the forces.csv it is
|
||||||
|
# being checked against.
|
||||||
|
frame_id * frame_period
|
||||||
|
if is_file
|
||||||
|
else max(0.0, t_collect - mono_t0)
|
||||||
|
)
|
||||||
|
# The rate is measured between *collections*, so it is the rate the frames
|
||||||
|
# arrived at rather than the loop's own cost dressed up as a frame rate.
|
||||||
|
# The SDK measures its own rate on the stage that publishes the snapshot;
|
||||||
|
# this window is what lets the packet's ``fps`` be stated on the same basis
|
||||||
|
# as the serial loop's, which measured it from the capture time.
|
||||||
|
frame_times.append(t_collect)
|
||||||
|
cutoff = t_collect - FPS_WINDOW_S
|
||||||
|
del frame_times[
|
||||||
|
: next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index, stamp in enumerate(frame_times)
|
||||||
|
if stamp >= cutoff
|
||||||
|
),
|
||||||
|
len(frame_times),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
span = frame_times[-1] - frame_times[0] if len(frame_times) > 1 else 0.0
|
||||||
|
fps = (len(frame_times) - 1) / span if span > 0.0 else 0.0
|
||||||
|
if live_capture:
|
||||||
|
measured = producer.capture_fps()
|
||||||
|
if measured > 0.0:
|
||||||
|
capture_fps = measured
|
||||||
|
# What the capture source published that nobody took. The source counts
|
||||||
|
# these itself, which is the camera's own evidence; a file source has no
|
||||||
|
# such counter, so the pipeline's own backlog is the only honest answer.
|
||||||
|
taken = _capture_dropped(sensor)
|
||||||
|
if taken is not None:
|
||||||
|
capture_dropped = int(taken)
|
||||||
|
elif producer.produced > produced_seq:
|
||||||
|
capture_dropped = int(producer.produced - produced_seq)
|
||||||
|
with self._lock:
|
||||||
|
self._measured_capture_fps = capture_fps
|
||||||
|
raw_frame = producer.raw_frame(produced_seq)
|
||||||
|
if raw_frame is None:
|
||||||
|
# The pipeline had already moved on: the frame that produced these
|
||||||
|
# forces is unavailable, so the packet carries the newest frame the
|
||||||
|
# sensor has rather than an image it never measured.
|
||||||
|
raw_frame = getattr(sensor, "frame", None)
|
||||||
|
return _packet_from_snapshot(
|
||||||
|
snap,
|
||||||
|
sensor,
|
||||||
|
session_id=session,
|
||||||
|
frame_id=frame_id,
|
||||||
|
mono_t=mono,
|
||||||
|
fps=fps,
|
||||||
|
capture_fps=capture_fps,
|
||||||
|
capture_dropped=capture_dropped,
|
||||||
|
raw_frame=raw_frame,
|
||||||
|
valid_mask=getattr(sensor, "valid_mask", None),
|
||||||
|
contact_analysed=analysis,
|
||||||
|
contact_found=bool(getattr(sensor, "centroid_found", False)),
|
||||||
|
compute_error=compute_error,
|
||||||
|
calibration_version=self._calibration_version,
|
||||||
|
valid=not compute_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- serial path
|
||||||
|
def _run_serial_loop(
|
||||||
|
self, session: int, mode: FixedCameraMode, source, sensor, is_file: bool
|
||||||
|
) -> None:
|
||||||
|
"""The single-threaded chain, kept whole for the packet-diff check.
|
||||||
|
|
||||||
|
``use_stream_pipeline=False`` selects it: produce, decompose and read the
|
||||||
|
forces back to back on this one thread, which bounds throughput by the sum
|
||||||
|
of the three stages. It is the reference the concurrent path is measured
|
||||||
|
against, and it is not the path a session uses.
|
||||||
|
"""
|
||||||
frame_times: list[float] = []
|
frame_times: list[float] = []
|
||||||
saw_frame = False
|
saw_frame = False
|
||||||
last_frame_mono = 0.0
|
last_frame_mono = 0.0
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""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.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 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("kind", ["box", "finger"])
|
||||||
|
def test_a_stopped_session_leaves_no_pipeline_threads(kind):
|
||||||
|
"""``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("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, "the session published nothing to stop"
|
||||||
|
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)
|
||||||
@@ -109,15 +109,42 @@ CREATED: list = []
|
|||||||
|
|
||||||
|
|
||||||
class _Sensor:
|
class _Sensor:
|
||||||
|
"""A device the ``StreamPipeline`` can drive as well as the serial loop.
|
||||||
|
|
||||||
|
``Sensor`` is what the SDK pipeline calls: ``compute_flow`` then
|
||||||
|
``compute_hhd`` then, with ``contact=True``, ``compute_contact``. The fake
|
||||||
|
device has no optics, so the compute methods only have to leave the arrays the
|
||||||
|
snapshot is built from in a shape the SDK's contract describes.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
self.configured: dict = {}
|
self.configured: dict = {}
|
||||||
self.connected = True
|
self.connected = True
|
||||||
|
self.info = _Info()
|
||||||
self.capture_source = _CaptureSource()
|
self.capture_source = _CaptureSource()
|
||||||
self.frame = np.zeros((8, 8, 3), dtype=np.uint8)
|
self.frame = np.zeros((8, 8, 3), dtype=np.uint8)
|
||||||
self.info = _Info()
|
self.img = np.zeros((8, 8), 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.force_calibrated = True
|
||||||
|
self.calibration = None
|
||||||
|
self.fnormal = 1.5
|
||||||
|
self.fshearx = 0.25
|
||||||
|
self.fsheary = -0.25
|
||||||
|
self.ftor = 0.0
|
||||||
|
self.force_unit = "N"
|
||||||
|
#: The unfiltered integrator pair a real sensor keeps after ``compute_hhd``.
|
||||||
|
self._force_raw = (15.0, 2.5, -2.5)
|
||||||
CREATED.append(self)
|
CREATED.append(self)
|
||||||
|
|
||||||
def configure(self, **kwargs):
|
def configure(self, **kwargs):
|
||||||
@@ -135,14 +162,42 @@ class _Sensor:
|
|||||||
}
|
}
|
||||||
return tuple(values.get(str(name), 0.0) for name in names)
|
return tuple(values.get(str(name), 0.0) for name in names)
|
||||||
|
|
||||||
def get_img(self):
|
def compute_flow(self, img=None):
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
self.flow = np.zeros((8, 8, 2), dtype=np.float32)
|
||||||
|
|
||||||
|
def compute_hhd(self, flow=None):
|
||||||
|
# A real ``compute_hhd`` refreshes the unfiltered integrator pair; keeping
|
||||||
|
# the values here is what makes a snapshot carry a force reading at all.
|
||||||
|
self._force_raw = (15.0, 2.5, -2.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 read_latest(self, **kwargs):
|
||||||
|
"""``CaptureSource.read_latest``: the newest frame, or nothing yet."""
|
||||||
|
self.capture_source.take()
|
||||||
|
return True, self.frame
|
||||||
|
|
||||||
|
def get_img(self, **kwargs):
|
||||||
# ``read_latest``: hand back the newest frame immediately, the same one
|
# ``read_latest``: hand back the newest frame immediately, the same one
|
||||||
# again when nothing new has arrived. A test can make this cost time, so
|
# again when nothing new has arrived. A test can make this cost time, so
|
||||||
# the loop can be put under a pipeline slower than the camera.
|
# the loop can be put under a pipeline slower than the camera.
|
||||||
if PROCESS_COST_S > 0:
|
if PROCESS_COST_S > 0:
|
||||||
time.sleep(PROCESS_COST_S)
|
time.sleep(PROCESS_COST_S)
|
||||||
self.capture_source.take()
|
self.capture_source.take()
|
||||||
return self.frame
|
return self.img.copy()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
return None
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
self.connected = False
|
self.connected = False
|
||||||
|
|||||||
+14
-2
@@ -131,16 +131,24 @@ def run_doc_gate(force_all: bool) -> dict:
|
|||||||
"""
|
"""
|
||||||
script = REPO.parent / "scripts" / "doc_lint.py"
|
script = REPO.parent / "scripts" / "doc_lint.py"
|
||||||
if not script.is_file():
|
if not script.is_file():
|
||||||
return {"status": "unavailable", "findings": [], "baselined": []}
|
print(f"lint_gate: required documentation checker missing: {script}", file=sys.stderr)
|
||||||
|
return {"status": "error", "findings": [], "baselined": []}
|
||||||
cmd = [sys.executable, str(script), "--json"]
|
cmd = [sys.executable, str(script), "--json"]
|
||||||
if force_all:
|
if force_all:
|
||||||
cmd.append("--all")
|
cmd.append("--all")
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", check=False)
|
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", check=False)
|
||||||
|
if result.returncode not in (0, 1):
|
||||||
|
print(f"lint_gate: documentation checker failed ({result.returncode}): {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
return {"status": "error", "findings": [], "baselined": []}
|
||||||
if not result.stdout.strip():
|
if not result.stdout.strip():
|
||||||
print(f"lint_gate: doc gate produced no output: {result.stderr.strip()[:200]}", file=sys.stderr)
|
print(f"lint_gate: doc gate produced no output: {result.stderr.strip()[:200]}", file=sys.stderr)
|
||||||
return {"status": "error", "findings": [], "baselined": []}
|
return {"status": "error", "findings": [], "baselined": []}
|
||||||
try:
|
try:
|
||||||
return json.loads(result.stdout)
|
payload = json.loads(result.stdout)
|
||||||
|
expected = "clean" if result.returncode == 0 else "findings"
|
||||||
|
if not isinstance(payload, dict) or payload.get("status") != expected:
|
||||||
|
return {"status": "error", "findings": [], "baselined": []}
|
||||||
|
return payload
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
print("lint_gate: could not parse doc gate output", file=sys.stderr)
|
print("lint_gate: could not parse doc gate output", file=sys.stderr)
|
||||||
return {"status": "error", "findings": [], "baselined": []}
|
return {"status": "error", "findings": [], "baselined": []}
|
||||||
@@ -161,6 +169,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
docs: dict = {"status": "skipped", "findings": [], "baselined": []}
|
docs: dict = {"status": "skipped", "findings": [], "baselined": []}
|
||||||
if not args.no_docs and args.files is None:
|
if not args.no_docs and args.files is None:
|
||||||
docs = run_doc_gate(force_all=args.all)
|
docs = run_doc_gate(force_all=args.all)
|
||||||
|
if docs.get("status") not in ("clean", "findings"):
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({"status": "error", "docs": docs, "findings": []}))
|
||||||
|
return 2
|
||||||
|
|
||||||
# Report the documentation verdict even when Ruff is unavailable: a missing linter must not
|
# Report the documentation verdict even when Ruff is unavailable: a missing linter must not
|
||||||
# hide the docs result, and the exit code still signals the environment problem.
|
# hide the docs result, and the exit code still signals the environment problem.
|
||||||
|
|||||||
Reference in New Issue
Block a user