diff --git a/config/compound_eye.png b/config/compound_eye.png new file mode 100644 index 0000000..9d85571 Binary files /dev/null and b/config/compound_eye.png differ diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 69abf0e..0bd2dad 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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 -standard is authoritative for every document here: +Required: [writing standard](../../docs/AUTHORING.md), [workspace rules](../../AGENTS.md). -**`D:\orisys_ws\docs\AUTHORING.md`** — one home per fact, current state only, link instead of -restating, the slop checklist, and the word budgets (standard 1,200 / general 1,500 / reference -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. +Studio and Autocal contracts belong beside their apps; shared architecture belongs here. +Check using the app interpreter and tools/lint_gate.py; AUTHORING defines the documentation gate. diff --git a/shared/src/orisys_app_shared/acquisition/finger_worker.py b/shared/src/orisys_app_shared/acquisition/finger_worker.py index 92d9782..72720ba 100644 --- a/shared/src/orisys_app_shared/acquisition/finger_worker.py +++ b/shared/src/orisys_app_shared/acquisition/finger_worker.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging import threading import time +from collections import deque from collections.abc import Callable from pathlib import Path @@ -22,6 +23,9 @@ from orisys_app_shared.acquisition.models import ( from orisys_app_shared.acquisition.models import ( 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 ( is_file_source as _is_file_source, ) @@ -33,6 +37,7 @@ from orisys_app_shared.acquisition.sdk_worker import ( PAUSE_POLL_S, STOP_JOIN_TIMEOUT_S, VIDEO_EOF_TIMEOUT_S, + _capture_dropped, _capture_sequence, calibration_digest, capture_status_of, @@ -49,6 +54,121 @@ logger = logging.getLogger(__name__) _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: """Return local ``assets/30fps-0703`` when present and complete. @@ -82,8 +202,15 @@ class FingerAcquisitionWorker(RecordSinkMixin): on_status: Callable[[str], 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, + use_stream_pipeline: bool = True, ): 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_status = on_status #: 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 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 - 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() + 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: - """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() + 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 def paused(self) -> bool: @@ -373,6 +526,22 @@ class FingerAcquisitionWorker(RecordSinkMixin): self._phase = "idle" 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 if previous is not None and previous.is_alive(): @@ -384,7 +553,7 @@ class FingerAcquisitionWorker(RecordSinkMixin): mode = self._mode source = self._video_source if mode is None or source is None: - return + return None is_file = _is_file_source(source) try: self._status(f"Opening FingerRuntime ({mode.label()})…") @@ -400,11 +569,13 @@ class FingerAcquisitionWorker(RecordSinkMixin): self._runtimes[session] = runtime if self._stop.is_set() or session != self._session_id: 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) if self._stop.is_set() or session != self._session_id: self._close_runtime(session) - return + return None raw_sensor = getattr(runtime.tactile, "raw_sensor", None) if raw_sensor is not None and hasattr(raw_sensor, "configure"): # 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) ) 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") if self._on_error is not None and session == self._session_id: self._on_error(str(exc)) self._set_phase("idle") 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] = [] #: (collection time, published sequence), so the camera's own rate #: can be told apart from the rate this loop consumes (see the box diff --git a/shared/src/orisys_app_shared/acquisition/models.py b/shared/src/orisys_app_shared/acquisition/models.py index b645d30..564b28b 100644 --- a/shared/src/orisys_app_shared/acquisition/models.py +++ b/shared/src/orisys_app_shared/acquisition/models.py @@ -122,6 +122,186 @@ class SdkPacket: 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: """Resolve a config reference (a file path or a product name) to its geometry.""" ref = str(config_ref).strip() diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index 21576ea..1e54556 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -8,6 +8,7 @@ import logging import sys import threading import time +from collections import deque from collections.abc import Callable, Mapping from pathlib import Path from typing import Any @@ -28,6 +29,12 @@ from orisys_app_shared.acquisition.models import ( from orisys_app_shared.acquisition.models import ( 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.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 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". + + ``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) status = getattr(source, "status", None) if source is not None else None + if status is None: + return None try: return int(getattr(status, "dropped_frames")) - except (TypeError, ValueError): + except (AttributeError, TypeError, ValueError): 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. -_CALIBRATION_AXIS_ALIASES = { - "fn": ("fn", "normal", "normal_force"), - "fx": ("fx", "shear_x", "shearx"), - "fy": ("fy", "shear_y", "sheary"), -} +#: The axis helper lives in ``models`` beside +#: :func:`~orisys_app_shared.acquisition.models.from_snapshot`, which is the one +#: place that fills a packet from an SDK snapshot. ``_calibrated_axes`` is imported +#: above and re-exported from this module, its historical home. +class _FrameProducer: + """The ``StreamPipeline`` produce callable for a box/mini ``Sensor``. -def _calibrated_axes(sensor) -> tuple[str, ...]: - """Which axes have a loaded calibration curve. + Three jobs, all of which have to happen on the producer thread because they are + about the *read* rather than about the computation: - The SDK stores one curve per channel, so a device may be calibrated on its - normal axis only. Reporting a single sensor-level flag would make the app - write raw counts into the newton columns for the uncalibrated axes. + * **One published frame per delivered frame.** ``CaptureSource.read_latest`` + hands back the newest frame and, until its background reader produces + 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) - 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) + + #: How many frames of raw-frame history to keep. Must exceed the pipeline's + #: queue depth: the consumer can be a few frames behind the producer. + CACHE = 8 + + def __init__( + self, sensor, *, is_file: bool, repeat_wait_s: float, frame_period: float = 0.0 + ) -> None: + self.sensor = sensor + self.is_file = bool(is_file) + self.repeat_wait_s = float(repeat_wait_s) + #: What one frame of this device is meant to cost. A *file* is paced by it: + #: 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: @@ -493,8 +693,13 @@ class SdkAcquisitionWorker(RecordSinkMixin): on_status: Callable[[str], 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, + use_stream_pipeline: bool = True, ): 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_status = on_status #: 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 #: publishing, but nothing is computed and no packet is emitted. 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._latest: SdkPacket | None = None self._session_id = 0 @@ -687,17 +896,43 @@ class SdkAcquisitionWorker(RecordSinkMixin): 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 - re-opened (and, on some boards, re-enumerated). While paused the loop keeps - draining the capture source — the disconnect watchdog must not fire on a - healthy camera just because an operator stopped looking at it — but it runs - no algorithm and emits no packet: no flow, no depth, no contact, and the - record channel sees a gap for the paused interval. + re-opened (and, on some boards, re-enumerated). The concurrent pipeline + holds at ``PauseLevel.CAPTURE_ONLY``, which is exactly that statement: the + read continues — so exposure, white balance and frame rate stay converged, + and the disconnect watchdog sees a live device — while the flow and HHD + 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() + 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: - """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() + 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 def paused(self) -> bool: @@ -818,6 +1053,25 @@ class SdkAcquisitionWorker(RecordSinkMixin): self._phase = "idle" 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 if previous is not None and previous.is_alive(): @@ -829,7 +1083,7 @@ class SdkAcquisitionWorker(RecordSinkMixin): mode = self._mode source = self._video_source if mode is None or source is None: - return + return None is_file = _is_file_source(source) try: self._status(f"Opening Orisys Sensor ({mode.label()})…") @@ -843,7 +1097,7 @@ class SdkAcquisitionWorker(RecordSinkMixin): self._sensors[session] = sensor if self._stop.is_set() or session != self._session_id: self._close_sensor(session) - return + return None # 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. with self._lock: @@ -866,12 +1120,372 @@ class SdkAcquisitionWorker(RecordSinkMixin): ) self._status(f"Streaming {mode.label()}") 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") if self._on_error is not None and session == self._session_id: self._on_error(str(exc)) self._set_phase("idle") 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] = [] saw_frame = False last_frame_mono = 0.0 diff --git a/shared/tests/test_stream_pipeline_acquisition.py b/shared/tests/test_stream_pipeline_acquisition.py new file mode 100644 index 0000000..c360cc0 --- /dev/null +++ b/shared/tests/test_stream_pipeline_acquisition.py @@ -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) diff --git a/shared/tests/test_worker_frame_rate.py b/shared/tests/test_worker_frame_rate.py index d162fe3..0b8b2b0 100644 --- a/shared/tests/test_worker_frame_rate.py +++ b/shared/tests/test_worker_frame_rate.py @@ -109,15 +109,42 @@ CREATED: list = [] 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: import numpy as np self.configured: dict = {} self.connected = True + self.info = _Info() self.capture_source = _CaptureSource() 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.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) def configure(self, **kwargs): @@ -135,14 +162,42 @@ class _Sensor: } 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 # 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. if PROCESS_COST_S > 0: time.sleep(PROCESS_COST_S) self.capture_source.take() - return self.frame + return self.img.copy() + + def reset(self): + return None def disconnect(self): self.connected = False diff --git a/tools/lint_gate.py b/tools/lint_gate.py index c40c8c4..3f49306 100644 --- a/tools/lint_gate.py +++ b/tools/lint_gate.py @@ -131,16 +131,24 @@ def run_doc_gate(force_all: bool) -> dict: """ script = REPO.parent / "scripts" / "doc_lint.py" 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"] if force_all: cmd.append("--all") 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(): print(f"lint_gate: doc gate produced no output: {result.stderr.strip()[:200]}", file=sys.stderr) return {"status": "error", "findings": [], "baselined": []} 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: print("lint_gate: could not parse doc gate output", file=sys.stderr) return {"status": "error", "findings": [], "baselined": []} @@ -161,6 +169,10 @@ def main(argv: list[str] | None = None) -> int: docs: dict = {"status": "skipped", "findings": [], "baselined": []} if not args.no_docs and args.files is None: 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 # hide the docs result, and the exit code still signals the environment problem.