added ruff test

This commit is contained in:
ykcheungab
2026-09-27 19:20:43 +08:00
parent bcab2790bf
commit fa2703968e
178 changed files with 3277 additions and 1034 deletions
@@ -2,8 +2,8 @@
from __future__ import annotations
from collections.abc import Sequence
from functools import lru_cache
from typing import Optional, Sequence
import cv2
import numpy as np
@@ -204,9 +204,9 @@ def _paint_shared_flow_arrows(
canvas: np.ndarray,
flow: np.ndarray,
*,
valid_mask: Optional[np.ndarray],
valid_mask: np.ndarray | None,
policy,
colormap: Optional[np.ndarray],
colormap: np.ndarray | None,
below_threshold_color: tuple[int, int, int],
) -> np.ndarray:
"""Draw arrows from :func:`select_flow_samples` so 2D matches 3D sample points."""
@@ -240,14 +240,14 @@ def _paint_shared_flow_arrows(
def draw_arrow_display(
processed_frame: np.ndarray,
flow: Optional[np.ndarray],
flow: np.ndarray | None,
*,
background: str = "pure_color",
canvas_bgr: Sequence[int] | None = None,
draw_style: str = "box",
valid_mask: Optional[np.ndarray] = None,
domain_mask: Optional[np.ndarray] = None,
colormap: Optional[np.ndarray] = None,
valid_mask: np.ndarray | None = None,
domain_mask: np.ndarray | None = None,
colormap: np.ndarray | None = None,
exterior_bgr: Sequence[int] | None = None,
island_bgr: Sequence[int] | None = None,
flow_on_px: float | None = None,
@@ -333,12 +333,12 @@ def draw_arrow_display(
def depth_map_to_bgr(
depth: Optional[np.ndarray],
colormap: Optional[np.ndarray] = None,
depth: np.ndarray | None,
colormap: np.ndarray | None = None,
*,
value_range: Optional[tuple[float, float]] = None,
invalid_mask: Optional[np.ndarray] = None,
) -> Optional[np.ndarray]:
value_range: tuple[float, float] | None = None,
invalid_mask: np.ndarray | None = None,
) -> np.ndarray | None:
"""Convert a float depth/divergence map to a colormap BGR image.
``value_range`` pins the colour scale. Per-frame re-normalisation (the old
@@ -5,23 +5,26 @@ from __future__ import annotations
import logging
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Callable, Optional
import numpy as np
from orisys_app_shared.acquisition.sinks import RecordSinkMixin
# The packet and the geometry come from ``models``, so the finger worker does not
# depend on the box worker; only genuinely shared helpers are imported from it.
from orisys_app_shared.acquisition.models import (
ContactObservation,
FixedCameraMode,
SdkPacket,
ensure_bgr as _ensure_bgr,
is_file_source as _is_file_source,
load_fixed_mode,
orisys_importable,
)
from orisys_app_shared.acquisition.models import (
ensure_bgr as _ensure_bgr,
)
from orisys_app_shared.acquisition.models import (
is_file_source as _is_file_source,
)
from orisys_app_shared.acquisition.sdk_worker import (
CAMERA_DISCONNECT_TIMEOUT_S,
FILE_FIRST_FRAME_TIMEOUT_S,
@@ -38,6 +41,7 @@ from orisys_app_shared.acquisition.sdk_worker import (
process_chain,
read_forces_from_sensor,
)
from orisys_app_shared.acquisition.sinks import RecordSinkMixin
from orisys_app_shared.force.filters import ALL_KEYS, FilterSettings, effective_filters
logger = logging.getLogger(__name__)
@@ -74,10 +78,10 @@ class FingerAcquisitionWorker(RecordSinkMixin):
def __init__(
self,
*,
on_error: Optional[Callable[[str], None]] = None,
on_status: Optional[Callable[[str], None]] = None,
on_calibration_applied: Optional[Callable[[str, str, int, float, int], None]] = None,
on_reset_applied: Optional[Callable[[int, float, int], None]] = None,
on_error: 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_reset_applied: Callable[[int, float, int], None] | None = None,
):
self._init_sinks()
self._on_error = on_error
@@ -87,23 +91,23 @@ class FingerAcquisitionWorker(RecordSinkMixin):
self._on_calibration_applied = on_calibration_applied
#: Called from the acquisition thread once a queued zeroing took effect.
self._on_reset_applied = on_reset_applied
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
#: Set while the operator has paused: the camera stays open and the source keeps
#: being read, but no frame is processed and no packet is emitted.
self._pause = threading.Event()
self._lock = threading.Lock()
self._latest: Optional[SdkPacket] = None
self._latest: SdkPacket | None = None
self._session_id = 0
# Keyed by session so a session that is still winding down can only
# ever close its own runtime, never the runtime of a newer session.
self._runtimes: dict[int, object] = {}
self._mode: Optional[FixedCameraMode] = None
self._video_source: Optional[str | int] = None
self._export_dir: Optional[Path] = None
self._end_reason: Optional[str] = None
self._mode: FixedCameraMode | None = None
self._video_source: str | int | None = None
self._export_dir: Path | None = None
self._end_reason: str | None = None
self._phase: str = "idle"
self._pending_calibration: Optional[str] = None
self._pending_calibration: str | None = None
#: A zeroing requested from another thread, applied by this one.
self._pending_reset: bool = False
self._mono_t0: float = 0.0
@@ -119,7 +123,7 @@ class FingerAcquisitionWorker(RecordSinkMixin):
#: reported rate is an echo of the request and cannot be trusted).
self._measured_capture_fps: float = 0.0
def set_filter_settings(self, settings: Optional[FilterSettings]) -> None:
def set_filter_settings(self, settings: FilterSettings | None) -> None:
"""Choose the filters for the **next** session."""
with self._lock:
self._filter_settings = (settings or FilterSettings()).normalised()
@@ -159,11 +163,11 @@ class FingerAcquisitionWorker(RecordSinkMixin):
with self._lock:
return self._phase
def latest(self) -> Optional[SdkPacket]:
def latest(self) -> SdkPacket | None:
with self._lock:
return self._latest
def consume_end_reason(self) -> Optional[str]:
def consume_end_reason(self) -> str | None:
with self._lock:
reason = self._end_reason
self._end_reason = None
@@ -233,7 +237,7 @@ class FingerAcquisitionWorker(RecordSinkMixin):
self._settle(owner=thread)
# Otherwise the thread settles itself on the way out.
def _settle(self, *, owner: Optional[threading.Thread]) -> None:
def _settle(self, *, owner: threading.Thread | None) -> None:
"""Release worker state once no thread owns the runtime any more."""
if owner is not None and self._thread is not owner:
return # a newer session owns the worker now
@@ -352,7 +356,7 @@ class FingerAcquisitionWorker(RecordSinkMixin):
if self._on_status is not None:
self._on_status(message)
def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None:
def _run(self, session: int, previous: threading.Thread | None = None) -> None:
# A new session always starts unpaused: pausing is a statement about the session the
# operator is watching, not a sticky property of the device.
self._pause.clear()
@@ -368,7 +372,7 @@ class FingerAcquisitionWorker(RecordSinkMixin):
self._latest = None
self._phase = "idle"
def _run_session(self, session: int, previous: Optional[threading.Thread]) -> None:
def _run_session(self, session: int, previous: threading.Thread | None) -> None:
from orisys.finger import FingerRuntime
if previous is not None and previous.is_alive():
@@ -443,14 +447,14 @@ class FingerAcquisitionWorker(RecordSinkMixin):
capture_fps = 0.0
saw_frame = False
last_frame_mono = 0.0
none_streak_start: Optional[float] = None
none_streak_start: float | None = None
# Per-session monotonic frame index: the join key shared by the video,
# the CSV and the calibration records.
frame_id = 0
#: The last frame each derived array was *actually* produced for, so a stale
#: array can say which frame it belongs to instead of being read as new data.
last_flow_frame_id: Optional[int] = None
last_depth_frame_id: Optional[int] = None
last_flow_frame_id: int | None = None
last_depth_frame_id: int | None = None
#: When this session's loop started, for the file-source deadline below.
session_started = time.perf_counter()
@@ -28,9 +28,9 @@ from __future__ import annotations
import json
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping, Optional
from orisys_app_shared.scene3d.contracts import ExpectedGeometry, error
from orisys_app_shared.scene3d.digest import source_geometry_digest
@@ -56,7 +56,7 @@ class DeviceGeometry:
detail: Mapping[str, str]
def map_path_for(assets_dir: Path, source_scale: Optional[float]) -> Path:
def map_path_for(assets_dir: Path, source_scale: float | None) -> Path:
"""The unroll map the SDK would load for this scale, inside ``assets_dir``."""
if source_scale is None or abs(float(source_scale) - 1.0) < 1e-9:
return Path(assets_dir) / f"{_MAP_STEM}{_MAP_SUFFIX}"
@@ -75,7 +75,7 @@ def _read_json(path: Path) -> dict:
return data if isinstance(data, dict) else {}
def _bundle_root(config_ref: str | Path) -> Optional[Path]:
def _bundle_root(config_ref: str | Path) -> Path | None:
path = Path(str(config_ref))
if path.is_file():
return path.parent
@@ -22,8 +22,8 @@ NumPy only: the Qt conversion belongs to the UI layer (``ui/arrow_panel.bgr_to_q
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Optional, Sequence
import numpy as np
@@ -113,16 +113,16 @@ class DomainMaskLatch:
array a frame carries, which is what makes holding it safe.
"""
__slots__ = ("_mask", "min_iou", "adoptions", "held")
__slots__ = ("_mask", "adoptions", "held", "min_iou")
def __init__(self, *, min_iou: float = DOMAIN_MASK_MIN_IOU) -> None:
self._mask: Optional[np.ndarray] = None
self._mask: np.ndarray | None = None
self.min_iou = float(min_iou)
self.adoptions = 0
self.held = 0
@property
def mask(self) -> Optional[np.ndarray]:
def mask(self) -> np.ndarray | None:
return self._mask
def reset(self) -> None:
@@ -131,7 +131,7 @@ class DomainMaskLatch:
self.adoptions = 0
self.held = 0
def update(self, mask: Optional[np.ndarray]) -> Optional[np.ndarray]:
def update(self, mask: np.ndarray | None) -> np.ndarray | None:
"""The mask to draw the boundary with, given this frame's own mask.
A frame that carries no mask cannot move the boundary, so it inherits the
@@ -161,10 +161,10 @@ class ImageFrame:
would have to invent them.
"""
image_bgr: Optional[np.ndarray] = None
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
valid_mask: Optional[np.ndarray] = None
image_bgr: np.ndarray | None = None
flow: np.ndarray | None = None
depth: np.ndarray | None = None
valid_mask: np.ndarray | None = None
source: str = SOURCE_LIVE
frame_id: int = 0
t_rel_s: float = 0.0
@@ -172,10 +172,10 @@ class ImageFrame:
#: coordinator's "stale" state); the panel can say so under the picture.
stale: bool = False
#: Per-field provenance mirroring ``SdkPacket``; ``None`` means unstated.
flow_valid: Optional[bool] = None
flow_frame_id: Optional[int] = None
depth_valid: Optional[bool] = None
depth_frame_id: Optional[int] = None
flow_valid: bool | None = None
flow_frame_id: int | None = None
depth_valid: bool | None = None
depth_frame_id: int | None = None
def has_flow(self) -> bool:
if self.flow is None:
@@ -216,7 +216,7 @@ class ImagePlan:
wanted = str(mode or "")
return wanted if self.accepts(wanted) else self.default_mode
def needs(self, mode: str) -> Optional[str]:
def needs(self, mode: str) -> str | None:
"""The field a mode needs that this frame does not have, if any."""
wanted = str(mode or "")
if wanted in (ARROW_PURE, ARROW_STITCHED) and not self.has_flow():
@@ -277,15 +277,15 @@ def plan_image(frame: ImageFrame) -> ImagePlan:
def render_image(
frame: ImageFrame,
plan: Optional[ImagePlan] = None,
plan: ImagePlan | None = None,
*,
mode: str = "",
theme: str = "dark",
draw_style: str = "box",
depth_range: Optional[tuple[float, float]] = None,
canvas_bgr: Optional[Sequence[int]] = None,
domain_mask: Optional[np.ndarray] = None,
) -> Optional[np.ndarray]:
depth_range: tuple[float, float] | None = None,
canvas_bgr: Sequence[int] | None = None,
domain_mask: np.ndarray | None = None,
) -> np.ndarray | None:
"""Draw ``frame`` in ``mode``; ``None`` when there is nothing to draw.
The mode is resolved through the plan first, so a caller that asks for a view the
@@ -11,7 +11,6 @@ import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import cv2
import numpy as np
@@ -41,7 +40,7 @@ class ContactObservation:
without this field do not invent a yellow contact arrow.
"""
centroid_uv_xy: Optional[tuple[float, float]] = None
centroid_uv_xy: tuple[float, float] | None = None
found: bool = False
valid: bool = False
frame_id: int = 0
@@ -82,9 +81,9 @@ class SdkPacket:
#: clock that steps backwards cannot reorder history. ``None`` means the
#: producer did not measure one, and the wall clock is used instead — a
#: default of ``0.0`` would have made every frame look simultaneous.
mono_t: Optional[float] = None
mono_t: float | None = None
#: Wall clock at hand-off, for metadata only. ``None`` means unmeasured.
wall_t: Optional[float] = None
wall_t: float | None = None
#: False when the force readout failed. Invalid packets carry NaN forces and
#: must never be averaged into calibration data or written as zeros.
valid: bool = True
@@ -99,9 +98,9 @@ class SdkPacket:
#: False when the sensor exposes no RAW channels; the raw fields are then
#: NaN rather than a copy of the calibrated values.
raw_available: bool = True
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
valid_mask: Optional[np.ndarray] = None
flow: np.ndarray | None = None
depth: np.ndarray | None = None
valid_mask: np.ndarray | None = None
# --- per-field provenance -------------------------------------------------
# The 3D view colours *one* array, and "the readout failed" is not the same
# statement as "the tracker's field is stale". ``valid`` above is about the force
@@ -109,18 +108,18 @@ class SdkPacket:
# frame may claim. ``None`` means the producer made no statement, and a consumer
# must not invent one: an unstated field is unverified, not fresh.
#: Whether ``flow`` was produced for this frame's tracker pass.
flow_valid: Optional[bool] = None
flow_valid: bool | None = None
#: The frame id ``flow`` belongs to, when it is not this frame's.
flow_frame_id: Optional[int] = None
flow_frame_id: int | None = None
#: Whether ``depth`` is this frame's deformation output (a failed chain leaves the
#: previous frame's depth in place).
depth_valid: Optional[bool] = None
depth_frame_id: Optional[int] = None
depth_valid: bool | None = None
depth_frame_id: int | None = None
#: Whether the force triplet belongs to this frame (False when the chain or the
#: readout failed, whatever ``valid`` says about the record).
forces_valid: Optional[bool] = None
forces_valid: bool | None = None
#: Contact centre from ``compute_contact``; optional so old call sites stay valid.
contact: Optional[ContactObservation] = None
contact: ContactObservation | None = None
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
@@ -4,31 +4,32 @@ from __future__ import annotations
import contextlib
import ctypes
import json
import logging
import sys
import threading
import time
from dataclasses import dataclass
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any, Callable, Mapping, Optional
from typing import Any
import cv2
import numpy as np
logger = logging.getLogger(__name__)
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force.filters import FilterSettings, effective_filters
from orisys_app_shared.acquisition.models import (
FixedCameraMode,
SdkPacket,
ensure_bgr as _ensure_bgr,
is_file_source as _is_file_source,
load_fixed_mode,
orisys_importable,
)
from orisys_app_shared.acquisition.models import (
ensure_bgr as _ensure_bgr,
)
from orisys_app_shared.acquisition.models import (
is_file_source as _is_file_source,
)
from orisys_app_shared.acquisition.sinks import RecordSinkMixin
from orisys_app_shared.force.filters import FilterSettings, effective_filters
VIDEO_EOF_TIMEOUT_S = 0.5
#: How long a file source may deliver *nothing at all* before the session gives up.
@@ -94,8 +95,8 @@ def _high_resolution_timer():
# definitions live in ``models`` so neither worker depends on the other.
__all__ = [
"FixedCameraMode",
"SdkPacket",
"SdkAcquisitionWorker",
"SdkPacket",
"calibration_digest",
"capture_status_of",
"load_fixed_mode",
@@ -127,7 +128,7 @@ def _sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) ->
return False
def _capture_sequence(sensor) -> Optional[int]:
def _capture_sequence(sensor) -> int | None:
"""The capture source's delivered-frame counter, when it can mark a repeat.
``CaptureSource.read_latest`` returns the newest frame from its background
@@ -148,7 +149,7 @@ def _capture_sequence(sensor) -> Optional[int]:
return None
def _capture_dropped(sensor) -> Optional[int]:
def _capture_dropped(sensor) -> int | None:
"""Frames the capture source published that nobody took in time.
The SDK's background reader counts one of these every time it replaces a
@@ -252,7 +253,7 @@ def short_capture_fallacy(
)
def process_chain(sensor) -> tuple[Optional[np.ndarray], str]:
def process_chain(sensor) -> tuple[np.ndarray | None, str]:
"""Run the live processing chain for one already-read frame.
``get_img`` alone is not the pipeline: the SDK's depth map is produced by
@@ -276,7 +277,7 @@ def process_chain(sensor) -> tuple[Optional[np.ndarray], str]:
return read_depth(sensor), compute_error
def read_depth(sensor) -> Optional[np.ndarray]:
def read_depth(sensor) -> np.ndarray | None:
"""The current depth channel of ``sensor``, or ``None`` when unavailable."""
try:
info = sensor.info
@@ -455,7 +456,7 @@ def calibration_digest(path: str | Path) -> str:
return ""
def loaded_path(sensor) -> Optional[str]:
def loaded_path(sensor) -> str | None:
"""Where the sensor's active calibration came from, if it knows."""
calibration = getattr(sensor, "calibration", None)
if calibration is None:
@@ -488,10 +489,10 @@ class SdkAcquisitionWorker(RecordSinkMixin):
def __init__(
self,
*,
on_error: Optional[Callable[[str], None]] = None,
on_status: Optional[Callable[[str], None]] = None,
on_calibration_applied: Optional[Callable[[str, str, int, float, int], None]] = None,
on_reset_applied: Optional[Callable[[int, float, int], None]] = None,
on_error: 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_reset_applied: Callable[[int, float, int], None] | None = None,
):
self._init_sinks()
self._on_error = on_error
@@ -504,22 +505,22 @@ class SdkAcquisitionWorker(RecordSinkMixin):
#: Called from the acquisition thread once a queued zeroing took effect,
#: with ``(frame_id, mono_t, session_id)``.
self._on_reset_applied = on_reset_applied
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
#: 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()
self._lock = threading.Lock()
self._latest: Optional[SdkPacket] = None
self._latest: SdkPacket | None = None
self._session_id = 0
# Keyed by session so a session that is still winding down can only
# ever close its own device, never the device of a newer session.
self._sensors: dict[int, object] = {}
self._mode: Optional[FixedCameraMode] = None
self._video_source: Optional[str | int] = None
self._end_reason: Optional[str] = None
self._mode: FixedCameraMode | None = None
self._video_source: str | int | None = None
self._end_reason: str | None = None
self._phase: str = "idle"
self._pending_calibration: Optional[str] = None
self._pending_calibration: str | None = None
#: A zeroing requested from another thread, applied by this one.
self._pending_reset: bool = False
#: Monotonic origin for ``SdkPacket.mono_t``, reset per session.
@@ -540,7 +541,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
#: reported rate is an echo of the request, so only this can be trusted.
self._measured_capture_fps: float = 0.0
def set_filter_settings(self, settings: Optional[FilterSettings]) -> None:
def set_filter_settings(self, settings: FilterSettings | None) -> None:
"""Choose the filters for the **next** session.
Not applied to a running one on purpose: the filter controls are locked
@@ -595,11 +596,11 @@ class SdkAcquisitionWorker(RecordSinkMixin):
with self._lock:
return self._phase
def latest(self) -> Optional[SdkPacket]:
def latest(self) -> SdkPacket | None:
with self._lock:
return self._latest
def consume_end_reason(self) -> Optional[str]:
def consume_end_reason(self) -> str | None:
with self._lock:
reason = self._end_reason
self._end_reason = None
@@ -671,7 +672,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
# Otherwise the thread settles itself on the way out. Leaving
# ``self._thread`` set is what stops the next ``start`` from racing it.
def _settle(self, *, owner: Optional[threading.Thread]) -> None:
def _settle(self, *, owner: threading.Thread | None) -> None:
"""Release worker state once no thread owns the device any more."""
if owner is not None and self._thread is not owner:
return # a newer session owns the worker now
@@ -796,7 +797,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
if self._on_status is not None:
self._on_status(message)
def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None:
def _run(self, session: int, previous: threading.Thread | None = None) -> None:
# A new session always starts unpaused: pausing is a statement about the
# session the operator is watching, not a sticky property of the device.
self._pause.clear()
@@ -816,7 +817,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
self._latest = None
self._phase = "idle"
def _run_session(self, session: int, previous: Optional[threading.Thread]) -> None:
def _run_session(self, session: int, previous: threading.Thread | None) -> None:
import orisys
if previous is not None and previous.is_alive():
@@ -874,10 +875,10 @@ class SdkAcquisitionWorker(RecordSinkMixin):
frame_times: list[float] = []
saw_frame = False
last_frame_mono = 0.0
none_streak_start: Optional[float] = None
none_streak_start: float | None = None
#: The capture sequence of the last frame that was actually *processed*:
#: an unchanged counter means the same frame came back again.
last_delivered: Optional[int] = None
last_delivered: int | None = None
#: When that frame arrived, and how far apart this device's frames are
#: meant to be: the wait below never steps over the next frame's arrival.
last_frame_at = 0.0
@@ -887,7 +888,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
#: two rates says whether a low frame rate is the camera's or ours.
source_times: list[tuple[float, int]] = []
source_dropped = 0
last_source_seq: Optional[int] = None
last_source_seq: int | None = None
capture_fps = 0.0
frame_period = 1.0 / max(1.0, float(mode.fps or 30.0))
# Per-session monotonic frame index: the join key shared by the video,
@@ -895,8 +896,8 @@ class SdkAcquisitionWorker(RecordSinkMixin):
frame_id = 0
#: The last frame each derived array was *actually* produced for (see the
#: finger worker: a stale array must name the frame it belongs to).
last_flow_frame_id: Optional[int] = None
last_depth_frame_id: Optional[int] = None
last_flow_frame_id: int | None = None
last_depth_frame_id: int | None = None
#: When this session's loop started, for the file-source deadline below.
session_started = time.perf_counter()
@@ -15,7 +15,8 @@ from __future__ import annotations
import logging
import threading
from collections import deque
from typing import Any, Callable, Iterable
from collections.abc import Callable, Iterable
from typing import Any
import numpy as np
@@ -1,6 +1,10 @@
"""Camera discovery and raw preview helpers."""
from orisys_app_shared.camera.discovery import CameraInfo, list_cameras, pick_default_camera
from orisys_app_shared.camera.discovery import (
CameraInfo,
list_cameras,
pick_default_camera,
)
from orisys_app_shared.camera.preview import RawPreviewSession
__all__ = [
@@ -114,7 +114,7 @@ def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]:
backends = _backend_candidates()
with _suppress_opencv_noise():
for index in range(0, max_index + 1):
for index in range(max_index + 1):
for backend_name, backend_api in backends:
info = _try_open(index, backend_name, backend_api)
if info is None:
+2 -1
View File
@@ -4,7 +4,8 @@ from __future__ import annotations
import logging
from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_app_shared import API_VERSION
from orisys_app_shared import __version__ as shared_version
logger = logging.getLogger(__name__)
@@ -21,6 +21,11 @@ from orisys_app_shared.force.filters import (
settings_from_mapping,
)
from orisys_app_shared.force.history import MetricHistory, PlotState, lowpass_filter
from orisys_app_shared.force.presentation import (
DisplayForce,
ForcePresentation,
ZeroEstimate,
)
from orisys_app_shared.force.session import (
NA,
SelectionStats,
@@ -30,11 +35,6 @@ from orisys_app_shared.force.session import (
summarise,
)
from orisys_app_shared.force.units import DisplayForces, select_display_forces
from orisys_app_shared.force.presentation import (
DisplayForce,
ForcePresentation,
ZeroEstimate,
)
__all__ = [
"CHANNEL_KEYS",
@@ -7,17 +7,26 @@ one format. ``CalSessionStore`` remains the UI sampling table.
from __future__ import annotations
import math
from collections.abc import Iterable
from copy import deepcopy
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable, Literal, Mapping
from typing import Any, Literal
from orisys.calibration import (
CalSample as SdkCalSample,
)
from orisys.calibration import (
build as sdk_build,
)
from orisys.calibration import (
check as sdk_check,
)
from orisys.calibration import (
pipeline_from_sensor_json,
)
from orisys.calibration import (
write as sdk_write,
)
@@ -76,20 +85,6 @@ def merge_samples(first: CalSample, second: CalSample) -> CalSample:
)
def build_export_model(samples: Mapping[str, list[CalSample]]):
"""Build exactly the model that export and application would use.
Diagnostics must be computed on *this* curve. A separate least-squares fit
can look healthy while the piecewise curve the device actually applies is
the one with the error.
"""
payload = {
key: [sample.to_sdk() for sample in (samples.get(key) or [])]
for key in CHANNEL_KEYS
}
return sdk_build(payload, origin="diagnostics")
def predict_channel(cal, channel: str, raw: float) -> float:
"""Apply ``cal`` to one axis exactly as the runtime conversion does."""
triple = [0.0, 0.0, 0.0]
@@ -424,7 +419,14 @@ def prepare_calibration(
def build_export_model(samples: dict[str, list[CalSample]]):
"""The SDK model for ``samples`` — the same preparation the panel uses."""
"""Build exactly the model that export and application would use.
This is the panel's own preparation, so it is the curve the device will
actually apply: diagnostics are computed on *this* model, and a separate
least-squares fit can look healthy while the piecewise curve in force is what
carries the error. It also merges repeated loads, so a caller may hand in raw
(unmerged) samples and still get the exported model.
"""
return prepare_from_samples(samples).model
@@ -23,18 +23,16 @@ from __future__ import annotations
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Optional
import numpy as np
from orisys_app_shared.force.models import (
CHANNEL_KEYS,
MAX_TRACE_POINTS,
NA,
SAMPLE_FIELDS,
STATUS_CANCELLED,
STATUS_FEW_SAMPLES,
STATUS_OK,
AxisStats,
@@ -48,11 +46,11 @@ from orisys_app_shared.force.session import SessionSample, summarise
# Re-exported: the models live in ``force.models`` so the session file format can
# describe a point without importing the sampling state machine.
__all__ = [
"SAMPLE_FIELDS",
"AxisStats",
"CalibrationPoint",
"CalibrationRecorder",
"FitResult",
"SAMPLE_FIELDS",
"trace_dt",
"trace_from",
]
@@ -99,8 +97,8 @@ class CalibrationRecorder:
def __init__(
self,
*,
on_finished: Optional[Callable[[CalibrationPoint], None]] = None,
on_cancelled: Optional[Callable[[], None]] = None,
on_finished: Callable[[CalibrationPoint], None] | None = None,
on_cancelled: Callable[[], None] | None = None,
clock: Callable[[], float] = time.perf_counter,
wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> None:
@@ -211,7 +209,7 @@ class CalibrationRecorder:
)
)
def tick(self) -> Optional[CalibrationPoint]:
def tick(self) -> CalibrationPoint | None:
"""Finish on the UI thread once the deadline has passed."""
with self._lock:
if not self._recording:
@@ -221,7 +219,7 @@ class CalibrationRecorder:
return None
return self._finish()
def finish_now(self) -> Optional[CalibrationPoint]:
def finish_now(self) -> CalibrationPoint | None:
"""Stop early and keep whatever has been captured so far."""
with self._lock:
if not self._recording:
@@ -229,7 +227,7 @@ class CalibrationRecorder:
return self._finish()
# --------------------------------------------------------------- internals
def _finish(self) -> Optional[CalibrationPoint]:
def _finish(self) -> CalibrationPoint | None:
with self._lock:
samples = self._samples
self._samples = []
@@ -289,7 +287,7 @@ class CalibrationRecorder:
with self._lock:
return list(self.points)
def _resolve(self, point: CalibrationPoint) -> Optional[CalibrationPoint]:
def _resolve(self, point: CalibrationPoint) -> CalibrationPoint | None:
"""Find the recorded point that is ``point``, by identity.
Callers pass the object they are looking at. An integer row index would
@@ -335,7 +333,7 @@ class CalibrationRecorder:
self.points.remove(target)
return True
def clear_points(self, channel: Optional[ChannelKey] = None) -> int:
def clear_points(self, channel: ChannelKey | None = None) -> int:
"""Drop every point, or every point of one channel. Returns the count."""
with self._lock:
if channel is None:
@@ -368,7 +366,7 @@ class CalibrationRecorder:
with self._lock:
return [point for point in self.points if point.channel == channel]
def prepared(self, *, meta: Optional[dict] = None):
def prepared(self, *, meta: dict | None = None):
"""The session prepared into one model plus its diagnostics.
Everything that shows or writes a calibration goes through this, so the
@@ -18,10 +18,12 @@ from __future__ import annotations
import json
import logging
import math
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Optional
from typing import Any
from orisys_app_shared.force.models import (
SAMPLE_FIELDS,
@@ -79,10 +81,19 @@ class CalibrationSessionRecord:
return " / ".join(parts)
def _is_nan(value: Any) -> bool:
"""Whether ``value`` is a NaN float.
``None`` is not one: a blank sample in a row is an absent reading, not a
number that failed, so it has to survive as it is.
"""
return isinstance(value, float) and math.isnan(value)
def _jsonable(value: Any) -> Any:
"""NaN/Inf are not valid JSON; they are written as ``null``."""
if isinstance(value, float):
if value != value or value in (float("inf"), float("-inf")):
if math.isnan(value) or value in (float("inf"), float("-inf")):
return None
return value
if isinstance(value, dict):
@@ -95,8 +106,8 @@ def _jsonable(value: Any) -> Any:
def build_session_document(
points: Iterable,
*,
meta: Optional[dict] = None,
created_at: Optional[str] = None,
meta: dict | None = None,
created_at: str | None = None,
) -> dict:
"""Build the document for ``points`` without touching the disk."""
documents = []
@@ -105,7 +116,7 @@ def build_session_document(
# The full series is the audit data; the trace stays as a quick preview.
entry["sample_fields"] = list(SAMPLE_FIELDS)
entry["samples"] = [
[None if value != value else value for value in row]
[None if _is_nan(value) else value for value in row]
for row in getattr(point, "samples", ())
]
documents.append(entry)
@@ -120,7 +131,7 @@ def build_session_document(
}
def session_filename(sensor_type: str, *, stamp: Optional[str] = None) -> str:
def session_filename(sensor_type: str, *, stamp: str | None = None) -> str:
when = stamp or datetime.now().strftime("%Y%m%d_%H%M%S")
return f"calibration_session_{sensor_type or 'unknown'}_{when}.json"
@@ -129,7 +140,7 @@ def write_calibration_session(
path: str | Path,
points: Iterable,
*,
meta: Optional[dict] = None,
meta: dict | None = None,
) -> Path:
"""Write the session document. Returns the written path."""
target = Path(path)
@@ -271,7 +282,7 @@ def restore_diagnostics(point) -> None:
point.trace_dt_s = trace_dt(valid_block)
if not point.duration_s and block.shape[0] > 1:
span = float(block[-1, 1] - block[0, 1])
if span == span and span >= 0.0:
if not math.isnan(span) and span >= 0.0:
point.duration_s = span
+12 -11
View File
@@ -26,8 +26,9 @@ from __future__ import annotations
import inspect
import logging
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, replace
from typing import Any, Iterable, Mapping, Optional
from typing import Any
import numpy as np
@@ -159,10 +160,10 @@ class FilterSettings:
show_unfiltered: bool = False
#: The preset this came from, when it came from one (kept so a record can say
#: "balanced" instead of only the numbers it expanded to).
preset: Optional[str] = None
preset: str | None = None
# ------------------------------------------------------------- resolving
def normalised(self) -> "FilterSettings":
def normalised(self) -> FilterSettings:
"""Clamp to what the SDK can run, with unusable values meaning "off"."""
alpha = _as_float(self.flow_ema_alpha, 1.0)
if not 0.0 < alpha < 1.0:
@@ -241,7 +242,7 @@ class FilterSettings:
"""A short line for the record, upstream stage first."""
return describe_state(self.as_state())
def nyquist_issue(self, sample_rate_hz: Optional[float]) -> Optional[dict[str, float]]:
def nyquist_issue(self, sample_rate_hz: float | None) -> dict[str, float] | None:
"""The cutoff/Nyquist problem as numbers, for a UI to phrase itself.
The SDK drops a low-pass whose cutoff is at or above Nyquist instead of
@@ -262,7 +263,7 @@ class FilterSettings:
"rate_hz": float(rate),
}
def problems(self, sample_rate_hz: Optional[float] = None) -> tuple[str, ...]:
def problems(self, sample_rate_hz: float | None = None) -> tuple[str, ...]:
"""Why the device would not do what these settings ask (English, for logs)."""
issue = self.nyquist_issue(sample_rate_hz)
if issue is None:
@@ -321,7 +322,7 @@ class FilterSettings:
@classmethod
def from_filters_state(
cls, state: Mapping[str, Any], *, show_unfiltered: bool = False
) -> "FilterSettings":
) -> FilterSettings:
"""The settings a device reports as in effect.
This is what the dialog displays and what a record stores: a config file
@@ -342,7 +343,7 @@ class FilterSettings:
preset=state.get("preset"),
).normalised()
def metadata(self, sensor_state: Optional[Mapping[str, Any]] = None) -> dict[str, Any]:
def metadata(self, sensor_state: Mapping[str, Any] | None = None) -> dict[str, Any]:
"""The ``filters`` block for ``session.json`` / a recording header.
With a sensor state this records what the device was doing
@@ -396,7 +397,7 @@ def describe_state(state: Mapping[str, Any]) -> str:
return " + ".join(parts) if parts else "none"
def settings_from_mapping(data: Optional[Mapping[str, Any]]) -> FilterSettings:
def settings_from_mapping(data: Mapping[str, Any] | None) -> FilterSettings:
"""Build settings from a preferences/metadata mapping, with defaults."""
source = dict(data or {})
base = FilterSettings()
@@ -442,12 +443,12 @@ class ForceFilterChain:
``zi``). Order is median then low-pass, so a spike cannot ring the IIR.
"""
def __init__(self, settings: Optional[FilterSettings] = None) -> None:
def __init__(self, settings: FilterSettings | None = None) -> None:
self._settings = (settings or FilterSettings()).normalised()
self._median: list[list[float]] = [[] for _ in range(CHANNELS)]
self._sos = None
#: One IIR state per channel: the six readings are independent signals.
self._zi: Optional[list[np.ndarray]] = None
self._zi: list[np.ndarray] | None = None
self._build()
# ------------------------------------------------------------------ setup
@@ -485,7 +486,7 @@ class ForceFilterChain:
def active(self) -> bool:
return self._settings.active()
def reset(self, first: Optional[Iterable[float]] = None) -> None:
def reset(self, first: Iterable[float] | None = None) -> None:
"""Drop the filter memory (a new session must not inherit the old one)."""
self._median = [[] for _ in range(CHANNELS)]
self._zi = None
@@ -8,7 +8,6 @@ from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Optional
import numpy as np
@@ -97,8 +96,8 @@ class MetricHistory:
(self._capacity, 1 + self.channels), np.nan, dtype=np.float64
)
self.sample_count = 0
self._t0: Optional[float] = None
self._y_range_smooth: list[Optional[tuple[float, float]]] = [
self._t0: float | None = None
self._y_range_smooth: list[tuple[float, float] | None] = [
None for _ in range(self.channels)
]
@@ -176,7 +175,7 @@ class MetricHistory:
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
return smoothed[0] - pad, smoothed[1] + pad
def state(self) -> Optional[PlotState]:
def state(self) -> PlotState | None:
n = self.sample_count
if n == 0:
return None
@@ -33,8 +33,8 @@ contact present at session start stays visible instead of being zeroed away.
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Optional, Sequence
import numpy as np
@@ -55,7 +55,7 @@ DEFAULT_MIN_DEADBAND_N = 0.0
DEFAULT_PROVISIONAL_SAMPLES = 3
def normalise_unit(unit: Optional[str]) -> str:
def normalise_unit(unit: str | None) -> str:
"""``"N"`` or ``"raw"`` — the two units a force reading can be shown in."""
return "N" if str(unit or "").upper().startswith("N") else "raw"
@@ -88,16 +88,16 @@ class _UnitEstimate:
collecting: bool = False
samples: list[tuple[float, float, float]] = field(default_factory=list)
sample_times: list[float] = field(default_factory=list)
t0: Optional[float] = None
t0: float | None = None
#: Frozen estimate: set once the quiet window closes with quality ``ok``.
offsets: Optional[tuple[float, float, float]] = None
deadbands: Optional[tuple[float, float, float]] = None
estimate: Optional[ZeroEstimate] = None
offsets: tuple[float, float, float] | None = None
deadbands: tuple[float, float, float] | None = None
estimate: ZeroEstimate | None = None
#: Provisional estimate, published from the first few quiet samples and
#: re-fitted on each further one. It is what the display uses while the
#: window is still open, and what it keeps if the window closes badly.
provisional_offsets: Optional[tuple[float, float, float]] = None
provisional_deadbands: Optional[tuple[float, float, float]] = None
provisional_offsets: tuple[float, float, float] | None = None
provisional_deadbands: tuple[float, float, float] | None = None
provisional_samples: int = 0
#: A quiet sample was refused because a contact was present or the flow was
#: too high: this window is not a trustworthy idle window any more.
@@ -109,12 +109,12 @@ class _UnitEstimate:
return self.offsets is not None and self.deadbands is not None
@property
def display_offsets(self) -> Optional[tuple[float, float, float]]:
def display_offsets(self) -> tuple[float, float, float] | None:
"""What the display is centred on: frozen if there is one, else provisional."""
return self.offsets if self.offsets is not None else self.provisional_offsets
@property
def display_deadbands(self) -> Optional[tuple[float, float, float]]:
def display_deadbands(self) -> tuple[float, float, float] | None:
"""The deadband the display is using (frozen if there is one)."""
return (
self.deadbands if self.deadbands is not None else self.provisional_deadbands
@@ -190,12 +190,12 @@ class ForcePresentation:
"""Operator-triggered restart of the idle window."""
self.reset_session(unit=self.unit)
def set_enabled(self, enabled: bool, *, unit: Optional[str] = None) -> None:
def set_enabled(self, enabled: bool, *, unit: str | None = None) -> None:
"""The master switch; switching it on restarts the quiet window."""
self.enabled = bool(enabled)
self.reset_session(unit=self.unit if unit is None else unit)
def min_deadband_for(self, unit: Optional[str] = None) -> float:
def min_deadband_for(self, unit: str | None = None) -> float:
"""The floor under the estimated deadband, for one display unit."""
chosen = self.unit if unit is None else unit
return (
@@ -279,7 +279,7 @@ class ForcePresentation:
return "provisional"
@property
def last_estimate(self) -> Optional[ZeroEstimate]:
def last_estimate(self) -> ZeroEstimate | None:
"""The window's outcome: the frozen estimate, the failed one, or the
provisional estimate while the window is still open."""
state = self._states.get(self.unit)
@@ -318,10 +318,10 @@ class ForcePresentation:
*,
t_rel_s: float,
valid: bool = True,
flow_p95: Optional[float] = None,
flow_p95: float | None = None,
contact_found: bool = False,
unit: Optional[str] = None,
) -> Optional[ZeroEstimate]:
unit: str | None = None,
) -> ZeroEstimate | None:
"""Feed one live sample into the startup estimator.
``values`` must already be expressed in ``unit`` (the displayed one).
@@ -406,7 +406,7 @@ class ForcePresentation:
state.provisional_samples = estimate.sample_count
def fit_zero(
self, samples: Sequence[Sequence[float]], *, unit: Optional[str] = None
self, samples: Sequence[Sequence[float]], *, unit: str | None = None
) -> ZeroEstimate:
"""Estimate offsets and deadbands from quiet samples (does not apply them)."""
floor = self.min_deadband_for(unit)
@@ -456,7 +456,7 @@ class ForcePresentation:
)
def apply_estimate(
self, estimate: ZeroEstimate, *, unit: Optional[str] = None
self, estimate: ZeroEstimate, *, unit: str | None = None
) -> None:
"""Explicitly freeze an estimate (e.g. after user confirmation)."""
if estimate.quality != "ok":
@@ -472,7 +472,7 @@ class ForcePresentation:
self,
values: Sequence[float],
*,
unit: Optional[str] = None,
unit: str | None = None,
available: tuple[bool, bool, bool] = (True, True, True),
) -> DisplayForce:
"""Return a display copy of one live sample; never mutates ``values``.
@@ -530,7 +530,7 @@ class ForcePresentation:
)
def present(
self, values, *, unit: Optional[str] = None
self, values, *, unit: str | None = None
) -> tuple[np.ndarray, np.ndarray]:
"""Present a whole block of readings: ``(display copy, suppressed mask)``.
@@ -568,7 +568,7 @@ class ForcePresentation:
suppressed[finite, axis] = quiet
return block, suppressed
def format_force(self, value: float, unit: Optional[str] = None) -> str:
def format_force(self, value: float, unit: str | None = None) -> str:
"""Stable digit formatting for value cards."""
unit = normalise_unit(self.unit if unit is None else unit)
if not np.isfinite(value):
@@ -24,8 +24,8 @@ Design notes
from __future__ import annotations
import threading
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, replace
from typing import Iterable, Iterator, Optional
import numpy as np
@@ -71,7 +71,7 @@ class SessionSample:
calibrated_axes: tuple[str, ...] = ()
@classmethod
def from_record(cls, record) -> "SessionSample":
def from_record(cls, record) -> SessionSample:
"""Build from the canonical :class:`RecordSample`.
The record channel has one conversion from a packet (see
@@ -221,8 +221,8 @@ class SessionStore:
self._dropped = 0
self._session_id = 0
self._calibration_id = ""
self._t0: Optional[float] = None
self._last_t: Optional[float] = None
self._t0: float | None = None
self._last_t: float | None = None
self._invalid = 0
# ------------------------------------------------------------- lifecycle
@@ -421,7 +421,7 @@ class SessionStore:
return np.empty((0, _COLUMNS), dtype=np.float64)
return np.vstack(selected) if len(selected) > 1 else selected[0]
def statistics(self, t0: float, t1: float, unit: str = "raw") -> "SelectionStats":
def statistics(self, t0: float, t1: float, unit: str = "raw") -> SelectionStats:
"""Duration, peak, mean and std over a selection, per channel."""
t0 = float(t0)
t1 = float(t1)
+2 -1
View File
@@ -11,6 +11,7 @@ reported as such.
from __future__ import annotations
import math
from dataclasses import dataclass
from orisys_app_shared.record.sample import AXES
@@ -48,7 +49,7 @@ class DisplayForces:
def _finite(value: float) -> bool:
try:
return value == value and value not in (float("inf"), float("-inf"))
return not math.isnan(value) and value not in (float("inf"), float("-inf"))
except TypeError: # pragma: no cover - defensive
return False
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from typing import Callable
from collections.abc import Callable
class QtLogHandler(logging.Handler):
@@ -24,9 +24,15 @@ from orisys_app_shared.record.playback import (
)
from orisys_app_shared.record.visualization3d import (
BLOCK_VERSION as VISUALIZATION3D_BLOCK_VERSION,
)
from orisys_app_shared.record.visualization3d import (
VISUALIZATION3D_KEY,
Visualization3DRecord,
)
from orisys_app_shared.record.visualization3d import (
build_block as build_visualization3d_block,
)
from orisys_app_shared.record.visualization3d import (
read_block as read_visualization3d_block,
)
@@ -33,10 +33,10 @@ import threading
import time
from collections import deque
from dataclasses import dataclass, field, replace
from uuid import uuid4
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from typing import Any
from uuid import uuid4
import cv2
import numpy as np
@@ -198,7 +198,7 @@ class RecordStats:
class RecordReport:
"""Returned by :meth:`ExperimentRecorder.stop`."""
directory: Optional[Path]
directory: Path | None
stats: RecordStats
problems: list[str] = field(default_factory=list)
#: False when the writer had not finished closing the files.
@@ -217,7 +217,7 @@ class RecordReport:
class ExperimentRecorder:
"""Writes one experiment directory. Sink-compatible with the workers."""
def __init__(self, config: Optional[RecordConfig] = None) -> None:
def __init__(self, config: RecordConfig | None = None) -> None:
self.config = config or RecordConfig()
#: One ordered stream for rows and events. It is **unbounded** on count:
#: the producer checks ``ROW_BACKLOG``/``max_queue`` itself, so a slow
@@ -229,7 +229,7 @@ class ExperimentRecorder:
self._gaps: deque = deque(maxlen=GAP_BACKLOG)
self._queued_bytes = 0
self._seq = 0
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self._lock = threading.Lock()
#: Serialises session.json writes: the UI thread writes at start and the
@@ -237,26 +237,26 @@ class ExperimentRecorder:
self._json_lock = threading.Lock()
#: Explicit lifecycle. A boolean could not express "the writer died".
self._state: str = STATE_IDLE
self._directory: Optional[Path] = None
self._directory: Path | None = None
self._stats = RecordStats()
self._session_id = 0
self._calibration_id = ""
self._calibration_version = ""
self._metadata: dict[str, Any] = {}
self._events: list[dict] = []
self._t0: Optional[float] = None
self._last_t: Optional[float] = None
self._t0: float | None = None
self._last_t: float | None = None
#: When the next video frame is due, in the same acquisition clock.
self._next_video_t: Optional[float] = None
self._next_video_t: float | None = None
self._started_at = ""
self._consecutive_errors = 0
self._on_alarm = None
self._report: Optional[RecordReport] = None
self._report: RecordReport | None = None
self._accepting = False
#: Outcome of the pre-roll write, published for session.json.
self._preroll: list = []
self._preroll_frames = 0
self._preroll_segment: Optional[str] = None
self._preroll_segment: str | None = None
#: Problems the writer found while closing, carried into the report.
self._writer_problems: list[str] = []
#: Coefficient files stored inside the recording directory.
@@ -281,7 +281,7 @@ class ExperimentRecorder:
return self._state in (STATE_STARTING, STATE_RECORDING)
@property
def report(self) -> Optional[RecordReport]:
def report(self) -> RecordReport | None:
"""The terminal report, available once the writer has finished."""
return self._report
@@ -300,7 +300,7 @@ class ExperimentRecorder:
return self._accepting and self._state in (STATE_STARTING, STATE_RECORDING)
@property
def directory(self) -> Optional[Path]:
def directory(self) -> Path | None:
return self._directory
def stats(self) -> RecordStats:
@@ -329,7 +329,7 @@ class ExperimentRecorder:
root: Path,
session_id: int,
metadata: dict[str, Any],
config: Optional[RecordConfig] = None,
config: RecordConfig | None = None,
calibration_id: str = "",
calibration_version: str = "",
preroll=(),
@@ -461,7 +461,7 @@ class ExperimentRecorder:
version: str,
name: str,
frame_id: int = -1,
mono_t: Optional[float] = None,
mono_t: float | None = None,
) -> None:
"""Remember a coefficient file stored inside the recording directory."""
entry = {
@@ -480,9 +480,9 @@ class ExperimentRecorder:
kind: str,
detail: str = "",
*,
mono_t: Optional[float] = None,
mono_t: float | None = None,
frame_id: int = -1,
wall_at: Optional[str] = None,
wall_at: str | None = None,
) -> None:
"""Record an event, timestamped when it actually happened.
@@ -704,7 +704,7 @@ class ExperimentRecorder:
inside the guard for that reason: a full disk used to kill this thread
before any statistics or alarm existed.
"""
writer: Optional[_SessionWriter] = None
writer: _SessionWriter | None = None
failure = ""
try:
writer = _SessionWriter(
@@ -1022,9 +1022,9 @@ class _SessionWriter:
self.bytes_written = 0
#: Frames captured before recording started, written as their own file.
self.preroll_frames = 0
self.preroll_segment: Optional[str] = None
self.preroll_segment: str | None = None
self._last_flush = time.perf_counter()
self._t0: Optional[float] = None
self._t0: float | None = None
self._problems: list[str] = []
#: The video side, in its own component: one rolling segment at a time.
self._video = VideoSegments(
+14 -10
View File
@@ -20,9 +20,9 @@ from __future__ import annotations
import csv
import logging
from array import array
from dataclasses import dataclass, field
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Iterator, Optional
import numpy as np
@@ -82,7 +82,7 @@ class ForceWindow:
mask = plottable_axis_mask(values, self.valid)[:, index]
return self.times[mask], values[mask, index]
def statistics(self, unit: str) -> "WindowStats":
def statistics(self, unit: str) -> WindowStats:
values = self.value_for_unit(unit)
mask = np.isfinite(values) & self.valid[:, None]
peaks: list[float] = []
@@ -172,7 +172,7 @@ class RecordingIndex:
"""Size of the data file the index describes."""
return self._byte_size
def build(self) -> "RecordingIndex":
def build(self) -> RecordingIndex:
"""One streaming pass: times, flags, frame ids and byte offsets."""
path = self.path
times: list[float] = []
@@ -240,7 +240,7 @@ class RecordingIndex:
except (KeyError, IndexError, ValueError, StopIteration, csv.Error):
return None
def ensure_built(self) -> "RecordingIndex":
def ensure_built(self) -> RecordingIndex:
if not self._built:
self.build()
return self
@@ -361,7 +361,7 @@ class RecordingIndex:
decimated=bucket > 1,
)
def iter_rows(self, start: int = 0, stop: Optional[int] = None) -> Iterator[tuple]:
def iter_rows(self, start: int = 0, stop: int | None = None) -> Iterator[tuple]:
"""Yield logical rows in stable time order, seeking only the selection."""
self.ensure_built()
start = max(0, int(start))
@@ -457,13 +457,17 @@ class RecordingIndex:
values = newtons if newton else raw
for axis in range(3):
value = values[axis]
if value != value: # NaN
if np.isnan(value):
continue
current_low = lows[bucket, axis]
current_high = highs[bucket, axis]
lows[bucket, axis] = value if current_low != current_low else min(current_low, value)
highs[bucket, axis] = value if current_high != current_high else max(current_high, value)
if starts[bucket] != starts[bucket]:
lows[bucket, axis] = (
value if np.isnan(current_low) else min(current_low, value)
)
highs[bucket, axis] = (
value if np.isnan(current_high) else max(current_high, value)
)
if np.isnan(starts[bucket]):
starts[bucket] = t_rel
counts[bucket] += 1
return Overview(
+11 -11
View File
@@ -16,9 +16,9 @@ import csv
import json
import logging
import threading
from dataclasses import dataclass, field
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator, Optional
import numpy as np
@@ -140,8 +140,8 @@ class RecordingReader:
self.directory = Path(directory)
if not self.directory.is_dir():
raise FileNotFoundError(f"Not a recording directory: {self.directory}")
self._info: Optional[RecordingInfo] = None
self._events: Optional[list[dict]] = None
self._info: RecordingInfo | None = None
self._events: list[dict] | None = None
self.max_rows = int(max_rows or self.DEFAULT_MAX_ROWS)
self._index = RecordingIndex(self.directory, checkpoint_rows=checkpoint_rows)
self._overviews: dict[tuple[str, int], Overview] = {}
@@ -259,7 +259,7 @@ class RecordingReader:
window.frame_ids, index.dropped_rows, index.ordered)
# ------------------------------------------------------------------ queries
def frame_at(self, t_rel_s: float) -> Optional[RecordingFrame]:
def frame_at(self, t_rel_s: float) -> RecordingFrame | None:
"""The recorded frame at or just before ``t_rel_s``.
The index is sorted at load time, so the binary search is valid; the
@@ -277,7 +277,7 @@ class RecordingReader:
index = max(0, min(index, len(frames) - 1))
return frames[index]
def segment_path(self, segment: int) -> Optional[Path]:
def segment_path(self, segment: int) -> Path | None:
if segment <= 0:
return None
path = self.directory / f"raw_{segment:04d}.mp4"
@@ -312,7 +312,7 @@ class RecordingReader:
found.append(frame_id)
return found
def last_reset_frame(self, at_or_before: int) -> Optional[int]:
def last_reset_frame(self, at_or_before: int) -> int | None:
"""The most recent reset at or before a frame — a tracker state origin.
``Sensor.reset`` clears the optical-flow tracker and the force filters, so
@@ -328,13 +328,13 @@ class RecordingReader:
]
return max(candidates) if candidates else None
def frame_of(self, frame_id: int) -> Optional[RecordingFrame]:
def frame_of(self, frame_id: int) -> RecordingFrame | None:
for frame in self.info().frames:
if frame.frame_id == int(frame_id):
return frame
return None
def segment_start_frame(self, segment: int) -> Optional[RecordingFrame]:
def segment_start_frame(self, segment: int) -> RecordingFrame | None:
"""The first indexed frame that lives in ``segment``."""
rows = [frame for frame in self.info().frames if frame.segment == int(segment)]
if not rows:
@@ -344,7 +344,7 @@ class RecordingReader:
def preroll_frames(self) -> int:
return int(self.info().session.get("preroll_frames") or 0)
def preroll_segment(self) -> Optional[Path]:
def preroll_segment(self) -> Path | None:
"""The pre-roll video recorded just before this recording started."""
name = str(self.info().session.get("preroll_segment") or "")
if not name:
@@ -643,7 +643,7 @@ class VideoOnlySource:
self._index = -1
self._lock = threading.Lock()
def video_frame_at(self, t_rel_s: float) -> tuple[Optional[np.ndarray], None]:
def video_frame_at(self, t_rel_s: float) -> tuple[np.ndarray | None, None]:
"""The frame at ``t_rel_s``: ``(bgr, None)`` or ``(None, None)``.
The second element is always ``None``: there is no frame *record* to hand back,
@@ -25,8 +25,12 @@ The rules are therefore:
from __future__ import annotations
import math
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Iterable
from typing import TYPE_CHECKING
if TYPE_CHECKING: # numpy is imported lazily, by the one function that needs it
import numpy as np
AXES: tuple[str, ...] = ("fn", "fx", "fy")
@@ -221,7 +225,7 @@ def plottable_mask(values: Iterable[float], valid: Iterable[bool]) -> list[bool]
]
def plottable_axis_mask(values, valid) -> "np.ndarray":
def plottable_axis_mask(values, valid) -> np.ndarray:
"""``(n, axes)`` mask: one column per axis, decided for that axis alone.
The rule has to be per axis. A single-axis calibration is legitimate — the
@@ -10,7 +10,6 @@ from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Optional
import cv2
@@ -27,8 +26,8 @@ class VideoSegments:
self.segment_seconds = float(segment_seconds)
self.segments = 0
self.video_frames = 0
self.frame_shape: Optional[tuple[int, int]] = None
self._writer: Optional[cv2.VideoWriter] = None
self.frame_shape: tuple[int, int] | None = None
self._writer: cv2.VideoWriter | None = None
self._index = 0
self._frames_in_segment = 0
self._started = 0.0
@@ -27,8 +27,9 @@ timestamps and which keeps "what was configured" and "what happened" apart.
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, Mapping, Optional
from typing import Any
#: The key under which the block lives in ``session.json``.
VISUALIZATION3D_KEY = "visualization3d"
@@ -42,10 +43,10 @@ def build_block(
enabled: bool,
available: bool,
reason: str = "",
scene: Optional[Mapping[str, Any]] = None,
gl: Optional[Mapping[str, Any]] = None,
display: Optional[Mapping[str, Any]] = None,
stream: Optional[Mapping[str, Any]] = None,
scene: Mapping[str, Any] | None = None,
gl: Mapping[str, Any] | None = None,
display: Mapping[str, Any] | None = None,
stream: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""One block for ``session.json``. Plain JSON types only, no failures."""
return {
@@ -62,7 +63,7 @@ def build_block(
}
def _clean(value: Optional[Mapping[str, Any]]) -> Optional[dict[str, Any]]:
def _clean(value: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""Drop nothing silently: nulls stay nulls, and unknown types become text."""
if value is None:
return None
@@ -109,7 +110,7 @@ class Visualization3DRecord:
) not in ("", "display_only")
def read_block(session: Optional[Mapping[str, Any]]) -> Visualization3DRecord:
def read_block(session: Mapping[str, Any] | None) -> Visualization3DRecord:
"""Read the block out of a ``session.json`` mapping, tolerantly.
Never raises: a broken recording is still a recording, and a consumer asking
@@ -12,8 +12,12 @@ their stage lands.
from orisys_app_shared.scene3d.contracts import (
DIAGNOSTIC_SEVERITY,
F32,
F64,
I32,
LENGTH_UNIT_M,
SENSOR_FRAME,
U32,
UNAVAILABLE_DESCRIPTOR,
ArrowInstances,
CameraMatrices,
@@ -25,12 +29,9 @@ from orisys_app_shared.scene3d.contracts import (
DisplayField,
DisplaySettings,
ExpectedGeometry,
F32,
F64,
FieldDescriptor,
FrameIdentity,
GLCapabilities,
I32,
LatestFrameMailbox,
Mask,
Material,
@@ -45,8 +46,6 @@ from orisys_app_shared.scene3d.contracts import (
SurfaceFieldFrame,
SurfaceMapping,
ThreeDError,
U32,
diagnostic,
error,
freeze,
@@ -63,8 +62,12 @@ from orisys_app_shared.scene3d.contracts import (
__all__ = [
"DIAGNOSTIC_SEVERITY",
"F32",
"F64",
"I32",
"LENGTH_UNIT_M",
"SENSOR_FRAME",
"U32",
"UNAVAILABLE_DESCRIPTOR",
"ArrowInstances",
"CameraMatrices",
@@ -76,12 +79,9 @@ __all__ = [
"DisplayField",
"DisplaySettings",
"ExpectedGeometry",
"F32",
"F64",
"FieldDescriptor",
"FrameIdentity",
"GLCapabilities",
"I32",
"LatestFrameMailbox",
"Mask",
"Material",
@@ -96,7 +96,6 @@ __all__ = [
"SurfaceFieldFrame",
"SurfaceMapping",
"ThreeDError",
"U32",
"diagnostic",
"error",
"freeze",
@@ -20,7 +20,7 @@ from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from typing import Any, Optional
from typing import Any
from orisys_app_shared.scene3d.contracts import (
CompileOptions,
@@ -38,8 +38,8 @@ from orisys_app_shared.scene3d.package import (
ENVELOPE_VERSION,
MAGIC,
SCHEMA_VERSION,
PackagedAssetLoader,
PackageContents,
PackagedAssetLoader,
build_manifest,
describe_package,
read_package,
@@ -53,8 +53,8 @@ __all__ = [
"MAGIC",
"SCHEMA_VERSION",
"LegacyFingerImporter",
"PackagedAssetLoader",
"PackageContents",
"PackagedAssetLoader",
"SceneCompiler",
"build_manifest",
"describe_package",
@@ -78,7 +78,7 @@ class SceneCompiler:
def compile(
self,
surface: SceneAssets,
housing_glb: Optional[Path],
housing_glb: Path | None,
output: Path,
options: CompileOptions,
) -> CompileReport:
@@ -156,10 +156,10 @@ def load_or_import(
path: Path,
*,
expected: ExpectedGeometry,
keys: Optional[KeyProvider] = None,
legacy_map: Optional[Path] = None,
keys: KeyProvider | None = None,
legacy_map: Path | None = None,
transform_source_from_sensor: Any = None,
meters_per_source_unit: Optional[float] = None,
meters_per_source_unit: float | None = None,
) -> SceneAssets:
"""Load a package, or import a legacy directory, behind one entry point.
@@ -29,7 +29,6 @@ from §3.2 of the design document remain its reason to exist:
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np
@@ -38,7 +37,6 @@ from orisys_app_shared.scene3d.contracts import (
DisplayField,
DisplaySettings,
FrameIdentity,
Quality,
RenderFrame,
SceneAssets,
SurfaceFieldFrame,
@@ -92,7 +90,7 @@ class SceneComposer:
#: every frame was pure cost (measured as the bulk of a 20k-vertex compose).
self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {}
#: Last UV grid of "arrow was on" samples for flow hysteresis.
self._flow_previously_on: Optional[np.ndarray] = None
self._flow_previously_on: np.ndarray | None = None
#: The resolved ``#RRGGBB,...`` ramp both views build their lookup table from.
#: Set by the view when the theme changes; empty means this build's default.
self.ramp = str(ramp)
@@ -19,9 +19,10 @@ Two rules from the design document are enforced here rather than trusted:
from __future__ import annotations
import threading
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Generic, Iterable, Literal, Mapping, Optional, Protocol, TypeVar
from typing import Any, Generic, Literal, Protocol, TypeVar
import numpy as np
from numpy.typing import NDArray
@@ -149,7 +150,7 @@ class FrameIdentity:
generation: int
request_id: int = 0
def is_from(self, other: "FrameIdentity") -> bool:
def is_from(self, other: FrameIdentity) -> bool:
return (
self.source_id == other.source_id
and self.session_id == other.session_id
@@ -165,7 +166,7 @@ class FieldDescriptor:
unit: str
quality: Quality
signed: bool
calibration_id: Optional[str] = None
calibration_id: str | None = None
#: The descriptor a reference frame carries: no measurement is claimed at all.
@@ -190,20 +191,20 @@ class SurfaceFieldFrame:
scalar: F32 # [H,W]
scalar_descriptor: FieldDescriptor
valid: Mask # [H,W]
displacement_sensor_m: Optional[F32] = None # [H,W,3], before display gain
displacement_quality: Optional[Quality] = None
diagnostic: Optional[Diagnostic] = None
displacement_sensor_m: F32 | None = None # [H,W,3], before display gain
displacement_quality: Quality | None = None
diagnostic: Diagnostic | None = None
#: The acquisition frame the scalar array was actually computed for, when the
#: producer can say so. ``None`` means "unstated" (the frame's own identity is the
#: only claim). When it is older than ``identity.frame_id`` the array is a stale
#: measurement: a consumer must not colour it as the current instant.
computed_frame_id: Optional[int] = None
computed_frame_id: int | None = None
#: Optional flow field kept alongside a depth scalar so overlays can draw arrows
#: without forcing the colour channel to switch away from depth.
flow_uv: Optional[F32] = None # [H,W,2]
flow_valid_mask: Optional[Mask] = None # [H,W]
flow_uv: F32 | None = None # [H,W,2]
flow_valid_mask: Mask | None = None # [H,W]
#: Contact centre in the same UV domain as ``scalar``; absent on older packets.
contact_uv_xy: Optional[tuple[float, float]] = None
contact_uv_xy: tuple[float, float] | None = None
contact_found: bool = False
contact_valid: bool = False
#: Normal-force reading used only for the contact-arrow length mapping.
@@ -228,7 +229,7 @@ class ArrowInstances:
colors: F32 # [M,4], RGBA 0–1
kind: ArrowKind
#: Optional per-arrow thickness scale relative to a template (contact arrows).
thickness_scales: Optional[F32] = None # [M]
thickness_scales: F32 | None = None # [M]
@dataclass(frozen=True)
@@ -343,7 +344,7 @@ class PlatformLock:
os_family: tuple[str, ...] = ()
machine: tuple[str, ...] = ()
gl_profile: Optional[Profile] = None
gl_profile: Profile | None = None
allowed_vendors: tuple[str, ...] = ()
allowed_renderers: tuple[str, ...] = ()
forbid_software_renderer: bool = True
@@ -380,7 +381,7 @@ class PlatformLock:
return out
@classmethod
def from_dict(cls, data: Optional[Mapping[str, Any]]) -> Optional["PlatformLock"]:
def from_dict(cls, data: Mapping[str, Any] | None) -> PlatformLock | None:
"""Read one back. A malformed lock is an error, not a silently open one."""
if data is None:
return None
@@ -427,14 +428,14 @@ class PlatformInfo:
os_family: str
machine: str
gl_profile: Optional[Profile] = None
gl_profile: Profile | None = None
vendor: str = ""
renderer: str = ""
software_renderer: bool = False
max_texture_size: int = 0
@classmethod
def here(cls, capabilities: Optional[GLCapabilities] = None) -> "PlatformInfo":
def here(cls, capabilities: GLCapabilities | None = None) -> PlatformInfo:
import platform as _platform
family = _platform.system().lower()
@@ -454,7 +455,7 @@ class PlatformInfo:
def check_platform_lock(
lock: Optional[PlatformLock], info: PlatformInfo
lock: PlatformLock | None, info: PlatformInfo
) -> tuple[Diagnostic, ...]:
"""Every way this machine violates the lock. Empty means "allowed".
@@ -548,7 +549,7 @@ class SceneAssets:
#: the map back to the artist's file is many-to-one. Kept because "which
#: triangle is this?" is a question an operator will ask, and re-deriving it
#: from UVs alone is a guess.
source_vertex_of_render: Optional[I32] = None
source_vertex_of_render: I32 | None = None
#: Unit-free identity of the source geometry this asset was built from, and what the
#: package says it is compatible with. The first is what a device can verify about
#: the surface; the second catches a package released for another sensor.
@@ -557,12 +558,12 @@ class SceneAssets:
compatible_hardware_revisions: tuple[str, ...] = ()
#: Where this package may be displayed (stage 5). ``None`` means "not locked",
#: which is what every package written before the lock existed carries.
platform_lock: Optional[PlatformLock] = None
platform_lock: PlatformLock | None = None
#: UV→scene positions/normals for overlays. Optional so older packages degrade
#: to scalar-only rendering without failing to load.
surface_mapping: Optional[SurfaceMapping] = None
surface_mapping: SurfaceMapping | None = None
def part(self, part_id: str) -> Optional[MeshPart]:
def part(self, part_id: str) -> MeshPart | None:
for item in self.parts:
if item.part_id == part_id:
return item
@@ -600,7 +601,7 @@ class SceneCapabilities:
unit: str = ""
#: Whether the source can ever publish ``displacement_sensor_m``.
displacement_available: bool = False
displacement_quality: Optional[Quality] = None
displacement_quality: Quality | None = None
source_kind: str = "live"
#: Whether flow / contact overlays can be built for this source.
flow_arrows_available: bool = False
@@ -613,7 +614,7 @@ class DisplaySettings:
display_gain: float = 0.0
deform_enabled: bool = False
color_scale: Optional[ColorScale] = None
color_scale: ColorScale | None = None
#: Non-sensing parts (the housing/frame) start hidden: the measurement is what the pane is
#: for, and on the finger sensor the frame is ~33% wider than the sensing surface, so it
#: dominated the picture. Operators who want it for a mechanical check turn it on in the 3D
@@ -689,7 +690,7 @@ class RenderFrame:
diagnostics: tuple[Diagnostic, ...] = ()
#: The acquisition frame the coloured values belong to, carried through from the
#: field frame so the *display* side can re-check it before presenting.
computed_frame_id: Optional[int] = None
computed_frame_id: int | None = None
#: Whether ``positions_sensor_m``/``normals_sensor`` differ from the asset's own
#: rest geometry. ``False`` for every path the application can take today (no
#: adapter publishes a displacement), and the GPU then uploads the vertex buffer
@@ -699,8 +700,8 @@ class RenderFrame:
#: rather than read from the composer's shared counter, which the next task rewrites.
compose_ms: float = 0.0
#: Optional overlays; absent on older packages / frames without mapping.
flow_arrows: Optional[ArrowInstances] = None
contact_arrow: Optional[ArrowInstances] = None
flow_arrows: ArrowInstances | None = None
contact_arrow: ArrowInstances | None = None
@dataclass(frozen=True)
@@ -758,13 +759,13 @@ class Sealing:
#: Which content key to use, and where the key comes from. ``None`` = plain.
key_id: str = ""
provider: Optional["KeyProvider"] = None
provider: KeyProvider | None = None
#: Build-time only. The private key never reaches the runtime path.
signing_key_pem: Optional[bytes] = None
signing_key_pem: bytes | None = None
signing_key_reference: str = ""
#: Optional: the writer's own public key, so a build can prove the package it just
#: wrote opens again instead of discovering the problem at the customer.
verify_key_pem: Optional[bytes] = None
verify_key_pem: bytes | None = None
def encrypts(self) -> bool:
return bool(self.key_id) and self.provider is not None
@@ -782,12 +783,12 @@ class CompileOptions:
max_surface_vertices: int
max_housing_triangles: int
signing_key_reference: str
encryption_key_id: Optional[str] = None
encryption_key_id: str | None = None
#: Where the released package may be displayed. ``None`` leaves it unlocked, which
#: is the right default for an engineering build and the wrong one for a release.
platform_lock: Optional[PlatformLock] = None
platform_lock: PlatformLock | None = None
#: The resolved key material for signing and sealing (see :class:`Sealing`).
sealing: Optional[Sealing] = None
sealing: Sealing | None = None
@dataclass(frozen=True)
@@ -1074,7 +1075,7 @@ def stale_housing_shading(assets: SceneAssets) -> tuple[Diagnostic, ...]:
def validate_assets(
assets: SceneAssets, expected: Optional[ExpectedGeometry] = None
assets: SceneAssets, expected: ExpectedGeometry | None = None
) -> tuple[Diagnostic, ...]:
"""Everything a loader must check before an asset is published.
@@ -1319,7 +1320,7 @@ class LatestFrameMailbox(Generic[T]):
def __init__(self) -> None:
self._lock = threading.Lock()
self._frame: Optional[T] = None
self._frame: T | None = None
self._superseded = 0
def offer(self, frame: T) -> OfferResult:
@@ -1332,12 +1333,12 @@ class LatestFrameMailbox(Generic[T]):
self._superseded += 1
return "replaced" if replaced else "accepted"
def take(self) -> Optional[T]:
def take(self) -> T | None:
with self._lock:
frame, self._frame = self._frame, None
return frame
def peek(self) -> Optional[T]:
def peek(self) -> T | None:
with self._lock:
return self._frame
@@ -1360,7 +1361,7 @@ class KeyProvider(Protocol):
class AssetLoader(Protocol):
def load(
self, path: Path, expected: ExpectedGeometry, keys: Optional[KeyProvider] = None
self, path: Path, expected: ExpectedGeometry, keys: KeyProvider | None = None
) -> SceneAssets:
"""CPU/IO thread: authenticate and validate before publishing."""
...
@@ -1387,7 +1388,7 @@ class SceneCompiler(Protocol):
def compile(
self,
surface: SceneAssets,
housing_glb: Optional[Path],
housing_glb: Path | None,
output: Path,
options: CompileOptions,
) -> CompileReport: ...
@@ -1396,7 +1397,7 @@ class SceneCompiler(Protocol):
class FingerFrameAdapter(Protocol):
def snapshot(
self, packet: Any, runtime: Any, identity: FrameIdentity
) -> Optional[SurfaceFieldFrame]:
) -> SurfaceFieldFrame | None:
"""Acquisition thread only: copy what is needed; no GL, no GUI."""
...
@@ -1404,7 +1405,7 @@ class FingerFrameAdapter(Protocol):
class BoxFrameAdapter(Protocol):
def snapshot(
self, packet: Any, identity: FrameIdentity, domain: ExpectedGeometry
) -> Optional[SurfaceFieldFrame]: ...
) -> SurfaceFieldFrame | None: ...
class SceneComposer(Protocol):
@@ -20,7 +20,8 @@ from __future__ import annotations
import hashlib
import json
from typing import Any, Iterable, Mapping
from collections.abc import Iterable, Mapping
from typing import Any
import numpy as np
@@ -71,7 +72,7 @@ def _array_bytes(array: Any) -> bytes:
return data.tobytes()
def _feed(hasher: "hashlib._Hash", label: str, payload: bytes) -> None:
def _feed(hasher: hashlib._Hash, label: str, payload: bytes) -> None:
hasher.update(label.encode("utf-8"))
hasher.update(b"\0")
hasher.update(str(len(payload)).encode("ascii"))
+4 -3
View File
@@ -29,9 +29,10 @@ from __future__ import annotations
import json
import struct
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Optional
from typing import Any
import numpy as np
@@ -81,7 +82,7 @@ def read_glb(path: Path) -> tuple[dict[str, Any], bytes]:
f"{path}: the header declares {length} bytes but the file has {len(raw)}",
)
offset = 12
document: Optional[dict[str, Any]] = None
document: dict[str, Any] | None = None
binary = b""
while offset + 8 <= len(raw):
chunk_length, chunk_type = struct.unpack_from("<II", raw, offset)
@@ -101,7 +102,7 @@ def read_glb(path: Path) -> tuple[dict[str, Any], bytes]:
raise error("asset_rejected", f"{path}: no JSON chunk")
if int((document.get("asset") or {}).get("version", "0").split(".")[0]) != 2:
raise error("asset_rejected", f"{path}: only glTF 2.0 is supported")
used = set((document.get("extensionsUsed") or []))
used = set(document.get("extensionsUsed") or [])
refused = sorted(used & set(REFUSED_EXTENSIONS))
if refused:
raise error(
+16 -15
View File
@@ -30,9 +30,10 @@ scores a relative residual of 0.006, and the wrong flip scores 0.59.
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Mapping, Optional
from typing import Any
import numpy as np
@@ -86,24 +87,24 @@ class LegacyFieldDomain:
shape_hw: tuple[int, int]
flip_v: bool
source_scale: Optional[float]
source_scale: float | None
valid_mask: np.ndarray # bool [H,W], read-only
domain_id: str
origin: str = "top-left"
index_order: str = "row-major"
mapping_digest: str = ""
world_xyz: Optional[np.ndarray] = None # float32 [H,W,3], for verification only
world_xyz: np.ndarray | None = None # float32 [H,W,3], for verification only
#: The unroll map itself: for every output pixel, the source pixel it came from
#: (``-1`` where there is none). The adapters need it to bring a live field into
#: this domain, and the digest above already covers it.
map_x: Optional[np.ndarray] = None
map_y: Optional[np.ndarray] = None
map_x: np.ndarray | None = None
map_y: np.ndarray | None = None
#: The grid the map samples from, as the file states it (0 when unstated).
source_width: int = 0
source_height: int = 0
@property
def source_hw(self) -> Optional[tuple[int, int]]:
def source_hw(self) -> tuple[int, int] | None:
"""The grid the map samples from, when the file states it."""
if self.source_width <= 0 or self.source_height <= 0:
return None
@@ -172,7 +173,7 @@ def load_legacy_domain(map_path: Path) -> LegacyFieldDomain:
mapping_digest=mapping_digest,
)
def _optional_array(name: str) -> Optional[np.ndarray]:
def _optional_array(name: str) -> np.ndarray | None:
value = data.get(name)
return None if value is None else np.asarray(value)
@@ -377,7 +378,7 @@ def vertex_normals(
positions: np.ndarray,
triangles: np.ndarray,
*,
crease_angle_deg: Optional[float] = None,
crease_angle_deg: float | None = None,
) -> np.ndarray:
"""Area-weighted vertex normals: unit length, computed from the geometry.
@@ -624,7 +625,7 @@ def build_binding(
def estimate_meters_per_source_unit(
positions: np.ndarray, expected_extent_m: Optional[float] = None
positions: np.ndarray, expected_extent_m: float | None = None
) -> float:
"""What the mesh's units would have to be for its extent to be a real size.
@@ -706,8 +707,8 @@ class LegacyFingerImporter:
asset_id: str = "finger-display",
asset_version: str = "1.0.0",
lod_id: str = "lod0",
camera_presets: Optional[Iterable[CameraPreset]] = None,
housing_path: Optional[Any] = None,
camera_presets: Iterable[CameraPreset] | None = None,
housing_path: Any | None = None,
) -> SceneAssets:
export_dir = Path(export_dir)
map_path = Path(map_path)
@@ -864,7 +865,7 @@ class LegacyFingerImporter:
units: float,
transform: np.ndarray,
lod_id: str,
) -> Optional[MeshPart]:
) -> MeshPart | None:
"""The optional non-sensing part (the housing / mounting frame), or ``None``.
The file is expected in the **same source frame and units as** ``mesh.npz``: an
@@ -960,10 +961,10 @@ def sensing_surface_normal(
def default_finger_presets(
bounds_sensor_m: np.ndarray,
positions_sensor_m: Optional[np.ndarray] = None,
triangles: Optional[np.ndarray] = None,
positions_sensor_m: np.ndarray | None = None,
triangles: np.ndarray | None = None,
*,
surface_normal_sensor: Optional[np.ndarray] = None,
surface_normal_sensor: np.ndarray | None = None,
) -> tuple[CameraPreset, ...]:
"""The default views for a flat sensing surface, in sensor metres.
@@ -7,7 +7,7 @@ examples tree. Outputs :class:`ArrowInstances` for the GL backend.
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Optional
from typing import Literal
import numpy as np
@@ -118,7 +118,7 @@ def build_flow_overlay(
samples: FlowSamples,
mapping: SurfaceMapping,
*,
style: Optional[VisualPolicy] = None,
style: VisualPolicy | None = None,
normal_offset_m: float = 0.0,
) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]:
"""Map shared UV flow samples onto the sensing surface."""
@@ -194,7 +194,7 @@ def build_flow_overlay(
def build_normal_overlay(
*,
contact_uv_xy: Optional[tuple[float, float]],
contact_uv_xy: tuple[float, float] | None,
contact_found: bool,
contact_valid: bool,
fn: float,
+14 -11
View File
@@ -36,12 +36,14 @@ from __future__ import annotations
import io
import json
import zipfile
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Mapping, Optional
from typing import Any
import numpy as np
from orisys_app_shared.scene3d import protect as _protect
from orisys_app_shared.scene3d.contracts import (
CameraPreset,
CompileReport,
@@ -59,18 +61,19 @@ from orisys_app_shared.scene3d.contracts import (
)
from orisys_app_shared.scene3d.digest import (
DIGEST_SCHEMA,
asset_hash as compute_asset_hash,
binding_id,
content_digest,
)
from orisys_app_shared.scene3d.digest import (
asset_hash as compute_asset_hash,
)
from orisys_app_shared.scene3d.digest import (
geometry_id as compute_geometry_id,
)
from orisys_app_shared.scene3d.legacy import (
crease_aware_mesh,
default_finger_presets,
normals_disagree_with_faces,
sensing_surface_normal,
)
from orisys_app_shared.scene3d import protect as _protect
#: Re-exported so a caller can ask "can this build seal a package at all?" without
#: importing the crypto module directly.
@@ -303,7 +306,7 @@ def write_package(
*,
sensor_model: str = "finger",
hardware_revisions: Iterable[str] = ("*",),
sealing: Optional[Sealing] = None,
sealing: Sealing | None = None,
) -> CompileReport:
"""Write a runtime package atomically: a half-written package never appears.
@@ -477,7 +480,7 @@ def _read_envelope(archive: zipfile.ZipFile) -> dict[str, Any]:
def _verify_members(
members: Mapping[str, bytes], *, expected_hash: Optional[str] = None
members: Mapping[str, bytes], *, expected_hash: str | None = None
) -> PackageContents:
"""The stage-2 verification, over a name→bytes mapping from either envelope."""
try:
@@ -568,7 +571,7 @@ def read_package(
path: Path,
*,
keys: Any = None,
verify_key_pem: Optional[bytes] = None,
verify_key_pem: bytes | None = None,
) -> PackageContents:
"""Open, verify and hand back the arrays. Refuses rather than repairs.
@@ -646,7 +649,7 @@ def read_package(
def scene_from_contents(
contents: PackageContents, expected: Optional[ExpectedGeometry] = None
contents: PackageContents, expected: ExpectedGeometry | None = None
) -> SceneAssets:
"""Turn verified arrays into a :class:`SceneAssets` and validate it."""
manifest = contents.manifest
@@ -864,7 +867,7 @@ class PackagedAssetLoader:
expected: ExpectedGeometry,
keys: Any = None,
*,
verify_key_pem: Optional[bytes] = None,
verify_key_pem: bytes | None = None,
) -> SceneAssets:
return scene_from_contents(
read_package(path, keys=keys, verify_key_pem=verify_key_pem), expected
@@ -880,7 +883,7 @@ def describe_package(
path: Path,
*,
keys: Any = None,
verify_key_pem: Optional[bytes] = None,
verify_key_pem: bytes | None = None,
) -> dict[str, Any]:
"""A report on a package without building a scene: for the validate tool."""
contents = read_package(path, keys=keys, verify_key_pem=verify_key_pem)
@@ -53,8 +53,9 @@ from __future__ import annotations
import base64
import json
import secrets
from collections.abc import Mapping
from pathlib import Path
from typing import Any, Mapping, Optional
from typing import Any
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
@@ -141,7 +142,7 @@ def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def _unb64(text: Any, *, field: str, length: Optional[int] = None) -> bytes:
def _unb64(text: Any, *, field: str, length: int | None = None) -> bytes:
if not isinstance(text, str) or not text:
raise error("asset_auth_failed", f"the envelope's {field} is missing")
try:
@@ -245,7 +246,7 @@ class MappingKeyProvider:
``get_content_key(key_id)``.
"""
def __init__(self, keys: Optional[Mapping[str, Any]] = None) -> None:
def __init__(self, keys: Mapping[str, Any] | None = None) -> None:
self.keys: dict[str, bytes] = {
str(key_id): _key_bytes(value, key_id=str(key_id))
for key_id, value in (keys or {}).items()
@@ -328,9 +329,9 @@ def seal_payload(
*,
key_id: str,
provider: KeyProvider,
signing_key_pem: Optional[bytes] = None,
signing_key_pem: bytes | None = None,
signing_key_reference: str = "",
extra: Optional[Mapping[str, Any]] = None,
extra: Mapping[str, Any] | None = None,
) -> tuple[dict[str, Any], bytes]:
"""Encrypt (and optionally sign) one plaintext payload.
@@ -441,8 +442,8 @@ def open_payload(
envelope: Mapping[str, Any],
ciphertext: bytes,
*,
provider: Optional[KeyProvider],
public_key_pem: Optional[bytes] = None,
provider: KeyProvider | None,
public_key_pem: bytes | None = None,
) -> bytes:
"""Verify, decrypt and hand back the plaintext payload.
@@ -31,8 +31,6 @@ touches the frame's own arrays and never copies them (``tests/test_scene3d_live.
from __future__ import annotations
from typing import Optional
import numpy as np
from orisys_app_shared.scene3d.contracts import (
@@ -84,7 +82,7 @@ def check_field_domain(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> Non
def sample_vertex_displacement(
frame: SurfaceFieldFrame, binding: SurfaceBinding
) -> Optional[np.ndarray]:
) -> np.ndarray | None:
"""The frame's displacement, gathered onto the render vertices.
``None`` when the frame carries no displacement — the common case, and the only
@@ -8,11 +8,16 @@ measurement difference rather than a display bug.
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np
from orisys_app_shared.scene3d.contracts import Diagnostic, Mask, diagnostic, error, freeze
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
Mask,
diagnostic,
error,
freeze,
)
@dataclass(frozen=True)
@@ -32,7 +37,7 @@ class VisualPolicy:
island_bgr: tuple[int, int, int] = (0x16, 0x13, 0x12)
exterior_bgr: tuple[int, int, int] = (0x0A, 0x09, 0x08)
def normalised(self) -> "VisualPolicy":
def normalised(self) -> VisualPolicy:
on = max(0.0, float(self.flow_on_px))
off = max(0.0, float(self.flow_off_px))
if on < off:
@@ -65,11 +70,11 @@ class FlowSamples:
def build_domain_mask(
geometry_mask: Optional[np.ndarray],
texture_roi: Optional[np.ndarray] = None,
geometry_mask: np.ndarray | None,
texture_roi: np.ndarray | None = None,
*,
shape_hw: Optional[tuple[int, int]] = None,
) -> tuple[Optional[Mask], tuple[Diagnostic, ...]]:
shape_hw: tuple[int, int] | None = None,
) -> tuple[Mask | None, tuple[Diagnostic, ...]]:
"""Strict intersection of geometry and optional texture ROI.
Shape mismatches raise rather than silently resize: a wrong mask invents
@@ -115,10 +120,10 @@ def build_domain_mask(
def select_flow_samples(
flow: np.ndarray,
mask: Optional[np.ndarray],
mask: np.ndarray | None,
policy: VisualPolicy,
*,
previously_on: Optional[np.ndarray] = None,
previously_on: np.ndarray | None = None,
) -> FlowSamples:
"""Sample optical flow on a UV grid with hysteresis and mask clipping.
@@ -207,7 +212,7 @@ def select_flow_samples(
def paint_domain_background(
shape_hw: tuple[int, int],
mask: Optional[np.ndarray],
mask: np.ndarray | None,
*,
island_bgr: tuple[int, int, int],
exterior_bgr: tuple[int, int, int],
@@ -2,8 +2,6 @@
from __future__ import annotations
from pathlib import Path
from orisys_app_shared.paths import sensors_dir
#: Product types the UI offers.
@@ -61,7 +61,7 @@ class ZmotionResult:
def ok(self) -> bool:
return self.code == 0
def raise_for_status(self, detail: str = "") -> "ZmotionResult":
def raise_for_status(self, detail: str = "") -> ZmotionResult:
if not self.ok:
raise ZAuxError(self.code, detail)
return self
@@ -88,7 +88,7 @@ class ZmotionValue:
"""
return self.value if self.ok else None
def raise_for_status(self, detail: str = "") -> "ZmotionValue":
def raise_for_status(self, detail: str = "") -> ZmotionValue:
if not self.ok:
raise ZAuxError(self.code, detail)
return self
@@ -122,7 +122,7 @@ class ZmotionClient:
"""Directory the runtime was loaded from, for logs and About boxes."""
return self._dll_dir
def load(self) -> "ZmotionClient":
def load(self) -> ZmotionClient:
"""Resolve and load the native runtime. Idempotent."""
if self._dll is None:
resolved = resolve_dll_dir()