added ruff code reivew

This commit is contained in:
ykcheungab
2026-09-27 19:21:55 +08:00
parent 07da372204
commit e2ac36269c
110 changed files with 890 additions and 426 deletions
+4 -1
View File
@@ -20,7 +20,10 @@ from common.verify import ( # noqa: E402
verify_wheel_assets,
verify_wheel_runtime,
)
from common.verify.wheel import DEFAULT_RUNTIME_CONFIG, default_verification_video # noqa: E402
from common.verify.wheel import ( # noqa: E402
DEFAULT_RUNTIME_CONFIG,
default_verification_video,
)
def main(argv: list[str] | None = None) -> int:
+8 -7
View File
@@ -31,11 +31,16 @@ _BUILD_ROOT = Path(__file__).resolve().parents[1]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402
from common.pyproject_utils import bundle_items, extras_require, install_requires, project_metadata # noqa: E402
from wheel.layout import resolve_bundle_source_files # noqa: E402
from wheel.package import is_compliant_manylinux_wheel # noqa: E402
from common.paths import REPO_ROOT # noqa: E402
from common.pyproject_utils import ( # noqa: E402
bundle_items,
extras_require,
install_requires,
)
# PyPI's default per-project file limit is 100 MB; keep headroom.
MAX_WHEEL_BYTES = 90 * 1024 * 1024
@@ -339,11 +344,7 @@ def _check_forbidden_entries(result: WheelCheckResult, names: list[str]) -> None
offenders = []
for name in names:
lowered = name.lower()
if lowered.startswith(_FORBIDDEN_PREFIXES):
offenders.append(name)
elif lowered.endswith(_FORBIDDEN_SUFFIXES):
offenders.append(name)
elif "__pycache__" in lowered or ".egg-info" in lowered:
if lowered.startswith(_FORBIDDEN_PREFIXES) or lowered.endswith(_FORBIDDEN_SUFFIXES) or "__pycache__" in lowered or ".egg-info" in lowered:
offenders.append(name)
result.add(
"no-forbidden-entries",
+1 -1
View File
@@ -6,9 +6,9 @@ import os
import shutil
import subprocess
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
+1
View File
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "basic_pipeline.py"
+1
View File
@@ -15,6 +15,7 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.verify.gui import verify_gui_dist
from gui.common.pyinstaller_driver import build_with_pyinstaller
from gui.ory_finger.config import make_config
+1
View File
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "finger_pipeline_3dview.py"
+1
View File
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "mini_pipeline.py"
+1
View File
@@ -25,6 +25,7 @@ from common.verify.wheel import ( # noqa: E402
verify_wheel_runtime,
)
from common.verify.wheel_contents import inspect_wheel # noqa: E402
from release import auth, github, pypi # noqa: E402
from release.preflight import PreflightOptions, run_preflight # noqa: E402
+1
View File
@@ -29,6 +29,7 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
MANIFEST_PATH = REPO_ROOT / "tools" / "downstream_sync_manifest.json"
+5 -1
View File
@@ -45,9 +45,13 @@ from common.verify.wheel import ( # noqa: E402
verify_wheel,
)
from common.verify.wheel_contents import inspect_wheel # noqa: E402
from wheel.layout import ( # noqa: E402
resolve_bundle_source_files,
verify_required_assets,
)
from release import report as report_mod # noqa: E402
from release import version as version_mod # noqa: E402
from wheel.layout import resolve_bundle_source_files, verify_required_assets # noqa: E402
@dataclass
+2 -2
View File
@@ -18,7 +18,6 @@ probe, and ``--apply`` being required even to reach TestPyPI.
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import tempfile
@@ -33,6 +32,7 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402
from release import auth # noqa: E402
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
@@ -452,7 +452,7 @@ def publish(options: Options) -> int:
print()
if options.pypi_only:
print(f"=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
print("=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
else:
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
return 0
+9 -4
View File
@@ -29,7 +29,13 @@ _BUILD_ROOT = Path(__file__).resolve().parents[1]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repaired_wheel_dir, repo_path
from common.paths import (
NUITKA_COMPILE_DIR,
NUITKA_WHEEL_DIR,
REPO_ROOT,
repaired_wheel_dir,
repo_path,
)
from common.pyproject_utils import (
bundle_items,
extras_require,
@@ -38,6 +44,7 @@ from common.pyproject_utils import (
project_metadata,
)
from common.verify.wheel import verify_built_wheel
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
from wheel.layout import (
copy_bundle_items,
@@ -199,9 +206,7 @@ def main(argv: list[str] | None = None) -> None:
)
args = parser.parse_args(argv)
verify: bool | None
if args.target == "pypi-linux":
verify = True
elif args.verify:
if args.target == "pypi-linux" or args.verify:
verify = True
elif args.skip_verify:
verify = False
+74
View File
@@ -0,0 +1,74 @@
# Code review: the automated part
Four gates existed before this document (the test suites, `apps/studio/tools/ui_check.py`, the
real-GL smoke tests, the binding-purity test) and none of them is a linter. This adds the missing
*static* layer, deliberately small.
## Why it exists, in one measured fact
Until now the three repos had **no Ruff configuration at all**. `ruff check .` therefore ran
whatever the installed version enables by default — Ruff 0.16 enables a very broad set
(`UP`/`RUF`/`PLR`/`S`/`TRY`/`PERF`/`FURB`/`YTT`/`ASYNC`…) — so the same tree produced **1038 /
438 / 117** findings in `orisys-app` / `orisys-sdk` / `orisys-sensor-design`, and a different
Ruff version produced different ones. A gate built on that is not a gate. `ruff.toml` now pins it.
## What is gated, and what is deliberately not
| rule | meaning | gated? | why |
|---|---|---|---|
| `BLE001` | broad `except Exception` | **no** | A broad catch is the *right* choice around device I/O, GL teardown and camera probing. This codebase already records that choice inline as `# noqa: BLE001 - <reason>`. Gating it would produce ~100 findings whose only correct fix is "add a reasoned suppression". |
| `S110`, `S112` | handler is only `pass` / `continue` | **yes** | The complaint is not the breadth of the catch, it is that the failure becomes **invisible**. Silence is acceptable only with a reasoned suppression or a handler that reports/re-raises. |
| `E722` | bare `except:` | **yes** | Strictly worse than `except Exception:` — it also swallows `KeyboardInterrupt`/`SystemExit`. |
| `F` | unused import, redefinition, undefined name, unused variable | **yes** | Real defects: `F821`/`F822` are latent `NameError`s, `F811` can be a shadowed definition. |
| `B023` | closure over a loop variable | **yes** | Late binding silently produces wrong geometry in the headless Blender scripts. |
| `PLW1510` | `subprocess.run` without `check` | **yes** | A swallowed tool failure. |
| `RUF100` | unused suppression | reported, **never auto-fixed** | The `- reason` in a `noqa` comment is documentation. A machine must not delete it. Triage by hand. |
| `UP`, `I`, `PIE`, `RET501`, `PLR1711`, `SIM114`, `RUF022`, `RUF023` | mechanical modernisation | in the lint config, **not** in the gate | Applied once by `--fix`; gating them would only churn AI-authored diffs. |
`E402` (module import not at top of file) is **selected but not gated**. `import` after
`os.environ[...]` is deliberate in this codebase — the environment must be set before Qt/cv2/the SDK
load — so gating it would be pure noise. But it is the only rule that would catch an *accidental*
import moved above that setup, so it stays visible. When you touch one of those files, record the
intent as `# noqa: E402 - <reason>`; `E701`/`E702` are style, which `ruff format` owns. A gate that
cries wolf gets ignored, which is worse than no gate.
## The three commands
```powershell
# 1. The agent loop, after every edit — mechanical, no review needed.
ruff check <changed files> --fix # never --unsafe-fixes
ruff check <changed files> --output-format json # what remains, machine-readable
# 2. The gate before a change lands. Changed files only, machine-readable, non-zero on findings.
python tools/lint_gate.py # vs HEAD
python tools/lint_gate.py --base origin/main
python tools/lint_gate.py --all # gate rules over the whole repo
python tools/lint_gate.py --json
# 3. Formatting, changed files only — never a whole-tree rewrite mid-diff.
ruff format <changed files>
```
`$RUFF` may point at a specific ruff binary; otherwise `tools/lint_gate.py` uses `PATH` or the one
beside the interpreter. **Pin the version**: the gate prints the version it ran, and findings differ
between versions, so a runner must install the same one the developers use (currently 0.16.9).
## What this found when it was introduced
Filtering to rules that can indicate a defect produced ~18 candidates rather than style noise —
which is the argument for the narrow set:
- six latent `NameError`s (`np`, `sys`, `Callable`, `FrameIdentity`, `tessellate_polygon`, and an
`__all__` entry naming a function that does not exist) — none caught by any test;
- a **duplicated** function definition that silently shadowed the first;
- `lower == lower` / `upper == upper` in a range check, and the same pattern inside a **test
assertion that could never fail**;
- 13 closures over loop variables in the headless Blender/patch scripts;
- two `subprocess.run` calls whose failure was silently ignored.
## Follow-up deliberately not done yet
`PLR0124` ("name compared with itself") is not in `select`. Eleven of its sites are the deliberate
`value != value` NaN idiom and must become `math.isnan(...)` (or carry a reasoned suppression)
before the rule can be gated — otherwise it is noise. Once those are converted, add `PLR0124` to
`select` and to `GATE_SELECT` in `tools/lint_gate.py`.
+7 -3
View File
@@ -9,12 +9,16 @@ ORY-BOX:单传感器基础流程
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import argparse
import cv2
import numpy as np
import orisys
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
+1
View File
@@ -18,6 +18,7 @@ import sys
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
import cv2
import orisys
from orisys.finger import FingerRuntime
+10 -9
View File
@@ -9,9 +9,10 @@ Acquisition adapters (no widgets) live in ``gui.sensors``.
Shared bootstrap: ``gui.app``, ``gui.app_paths``, ``gui.log_config``, ``gui.widgets``.
"""
from viz.contact_arrow import ContactArrowStyle
from .log_config import LOG_CONFIG, LogConfig
from .viewer3d import (
FingerDashboardWindow,
LAYOUT,
LOG_PANEL,
MESH_CONTACT_ARROW,
@@ -19,33 +20,33 @@ from .viewer3d import (
PANEL_SETUP,
TOP_ROW,
DashboardLayout,
FingerDashboardWindow,
MeshLighting,
PanelRow,
PanelSetup,
PanelSlot,
run_dashboard,
top_row_splitter_sizes,
top_row_slot_index,
top_row_splitter_sizes,
)
from viz.contact_arrow import ContactArrowStyle
__all__ = [
"DashboardLayout",
"FingerDashboardWindow",
"LAYOUT",
"LOG_CONFIG",
"LOG_PANEL",
"LogConfig",
"MESH_CONTACT_ARROW",
"MESH_LIGHTING",
"ContactArrowStyle",
"MeshLighting",
"PANEL_SETUP",
"TOP_ROW",
"ContactArrowStyle",
"DashboardLayout",
"FingerDashboardWindow",
"LogConfig",
"MeshLighting",
"PanelRow",
"PanelSetup",
"PanelSlot",
"run_dashboard",
"top_row_splitter_sizes",
"top_row_slot_index",
"top_row_splitter_sizes",
]
+1 -1
View File
@@ -34,8 +34,8 @@ def default_config_path() -> str:
def require_gui_extras() -> None:
try:
import open3d # noqa: F401
from PyQt5 import QtWidgets # noqa: F401
import pyqtgraph # noqa: F401
from PyQt5 import QtWidgets # noqa: F401
except ImportError as exc:
raise SystemExit('GUI example requires: pip install "orisys[gui]"') from exc
+3 -3
View File
@@ -1,23 +1,23 @@
"""Sensor acquisition adapters for Qt / multi-sensor viewers (no Qt widgets)."""
from . import roi_geometry
from .adapter import (
Dd06RoiAdapter,
FrameSnapshot,
OrisysSensorAdapter,
SensorAdapter,
_snapshot_from_sensor,
calibrate_normal_force,
create_sensor_adapter,
_snapshot_from_sensor,
)
from . import roi_geometry
__all__ = [
"Dd06RoiAdapter",
"FrameSnapshot",
"OrisysSensorAdapter",
"SensorAdapter",
"_snapshot_from_sensor",
"calibrate_normal_force",
"create_sensor_adapter",
"roi_geometry",
"_snapshot_from_sensor",
]
+14 -15
View File
@@ -5,11 +5,11 @@ from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from typing import Any
import cv2
import numpy as np
import orisys
from .roi_geometry import (
@@ -21,7 +21,6 @@ from .roi_geometry import (
warp_roi_to_rectangle,
)
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
@@ -30,10 +29,10 @@ NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
class FrameSnapshot:
"""Normalized per-frame payload consumed by the Qt viewer."""
image: Optional[np.ndarray] = None
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
contour: Optional[np.ndarray] = None
image: np.ndarray | None = None
flow: np.ndarray | None = None
depth: np.ndarray | None = None
contour: np.ndarray | None = None
centroid: Any = None
# Raw SDK force channels used for curves (same units for FN/FX/FY).
normal_force: float = 0.0
@@ -43,10 +42,10 @@ class FrameSnapshot:
normal_force_calibrated: float = 0.0
fps: float = 0.0
timestamp: float = 0.0
raw_image: Optional[np.ndarray] = None
processed_image: Optional[np.ndarray] = None
flow_overlay: Optional[np.ndarray] = None
record_frame: Optional[np.ndarray] = None
raw_image: np.ndarray | None = None
processed_image: np.ndarray | None = None
flow_overlay: np.ndarray | None = None
record_frame: np.ndarray | None = None
def calibrate_normal_force(raw_value: float) -> float:
@@ -62,7 +61,7 @@ class SensorAdapter(ABC):
raise NotImplementedError
@abstractmethod
def read(self) -> Optional[FrameSnapshot]:
def read(self) -> FrameSnapshot | None:
raise NotImplementedError
@abstractmethod
@@ -89,7 +88,7 @@ def _snapshot_from_sensor(
*,
raw_image=None,
processed_image=None,
) -> Optional[FrameSnapshot]:
) -> FrameSnapshot | None:
"""Build a FrameSnapshot with HHD/forces refreshed every frame."""
if sensor is None or sensor.img is None:
return None
@@ -186,7 +185,7 @@ class OrisysSensorAdapter(SensorAdapter):
verbose=self.verbose,
)
def read(self) -> Optional[FrameSnapshot]:
def read(self) -> FrameSnapshot | None:
if self.sensor is None:
return None
img = self.sensor.get_img()
@@ -255,7 +254,7 @@ class Dd06RoiAdapter(SensorAdapter):
f"video={self._source}"
)
def read(self) -> Optional[FrameSnapshot]:
def read(self) -> FrameSnapshot | None:
if self.sensor is None or self.cap is None:
return None
+17 -16
View File
@@ -8,13 +8,14 @@ Does not import camera, ROI, or `orisys.Sensor`.
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Optional, Sequence, Tuple
from typing import Literal
import cv2
import numpy as np
import orisys
from scipy.signal import butter, filtfilt
import orisys
FORCE_SAMPLE_RATE_HZ = 30.0
PLOT_BUFFER_POINTS = 120
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ
@@ -40,14 +41,14 @@ class ViewerFrame:
image_height: int
image_width: int
aspect_ratio: float # width / height
depth: Optional[np.ndarray]
depth: np.ndarray | None
normal_force: float
shear_x: float
shear_y: float
normal_force_calibrated: float
fps: float
timestamp: float
record_frame: Optional[np.ndarray]
record_frame: np.ndarray | None
@dataclass
@@ -57,8 +58,8 @@ class ForcePlotState:
times: np.ndarray # shape (n,)
values: np.ndarray # shape (n, 3) FN, FX, FY
sample_count: int
x_range: Tuple[float, float]
y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]
x_range: tuple[float, float]
y_ranges: tuple[tuple[float, float], tuple[float, float], tuple[float, float]]
def lowpass_filter(
@@ -87,7 +88,7 @@ def lowpass_filter(
return filtfilt(b, a, y)
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
def ensure_bgr(img: np.ndarray | None) -> np.ndarray | None:
if img is None:
return None
if img.ndim == 2:
@@ -97,14 +98,14 @@ def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
return img
def image_shape_meta(img: np.ndarray) -> Tuple[int, int, float]:
def image_shape_meta(img: np.ndarray) -> tuple[int, int, float]:
"""Return (height, width, width/height) from a display image."""
h, w = int(img.shape[0]), int(img.shape[1])
aspect = float(w) / float(h) if h > 0 else 1.0
return h, w, aspect
def _base_bgr(snapshot) -> Optional[np.ndarray]:
def _base_bgr(snapshot) -> np.ndarray | None:
for attr in ("image", "processed_image", "raw_image"):
out = ensure_bgr(getattr(snapshot, attr, None))
if out is not None:
@@ -152,7 +153,7 @@ def build_display_image(
return select_display_image(snapshot)
def bgr_to_rgb_bytes(img_bgr: np.ndarray) -> Tuple[np.ndarray, int, int]:
def bgr_to_rgb_bytes(img_bgr: np.ndarray) -> tuple[np.ndarray, int, int]:
"""Convert BGR uint8 image to contiguous RGB for QImage."""
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
if not rgb.flags["C_CONTIGUOUS"]:
@@ -231,7 +232,7 @@ class ForceHistory:
self.data[-1, 0, :] = t
self.data[-1, 1, :] = values
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> Tuple[float, float]:
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> tuple[float, float]:
if self._y_range_smooth[channel] is None:
smoothed = (ymin, ymax)
else:
@@ -251,7 +252,7 @@ class ForceHistory:
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
return smoothed[0] - pad, smoothed[1] + pad
def plot_state(self) -> Optional[ForcePlotState]:
def plot_state(self) -> ForcePlotState | None:
n = self.sample_count
if n == 0:
return None
@@ -305,8 +306,8 @@ class QtViewerBackend:
lowpass_cutoff_hz=lowpass_cutoff_hz,
y_axis_min_span=y_axis_min_span,
)
self._start_time: Optional[float] = None
self.last_viewer_frame: Optional[ViewerFrame] = None
self._start_time: float | None = None
self.last_viewer_frame: ViewerFrame | None = None
self.arrow_background: ArrowBackground = arrow_background
def set_arrow_background(self, background: ArrowBackground) -> None:
@@ -357,7 +358,7 @@ class QtViewerBackend:
self._start_time = None
self.last_viewer_frame = None
def ingest(self, snapshot, *, now: Optional[float] = None) -> ViewerFrame:
def ingest(self, snapshot, *, now: float | None = None) -> ViewerFrame:
"""Convert a FrameSnapshot into ViewerFrame and update force history."""
display = build_display_image(snapshot, self.arrow_background)
h, w, aspect = image_shape_meta(display)
@@ -396,5 +397,5 @@ class QtViewerBackend:
self.last_viewer_frame = frame
return frame
def plot_state(self) -> Optional[ForcePlotState]:
def plot_state(self) -> ForcePlotState | None:
return self.force_history.plot_state()
+6 -6
View File
@@ -2,8 +2,8 @@
from .dashboard import FingerDashboardWindow, run_dashboard
from .layout import (
LOG_PANEL,
LAYOUT,
LOG_PANEL,
MESH_CONTACT_ARROW,
MESH_LIGHTING,
PANEL_SETUP,
@@ -13,24 +13,24 @@ from .layout import (
PanelRow,
PanelSetup,
PanelSlot,
top_row_splitter_sizes,
top_row_slot_index,
top_row_splitter_sizes,
)
__all__ = [
"DashboardLayout",
"FingerDashboardWindow",
"LAYOUT",
"LOG_PANEL",
"MESH_CONTACT_ARROW",
"MESH_LIGHTING",
"MeshLighting",
"PANEL_SETUP",
"TOP_ROW",
"DashboardLayout",
"FingerDashboardWindow",
"MeshLighting",
"PanelRow",
"PanelSetup",
"PanelSlot",
"run_dashboard",
"top_row_splitter_sizes",
"top_row_slot_index",
"top_row_splitter_sizes",
]
+25 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import contextlib
import logging
import os
import sys
from dataclasses import dataclass
@@ -11,6 +12,8 @@ import cv2
DEFAULT_MAX_CAMERA_INDEX = 10
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class CameraInfo:
@@ -62,8 +65,14 @@ def _suppress_opencv_noise():
if set_level is not None and prev_level is not None:
try:
set_level(prev_level)
except Exception:
pass
except Exception as exc:
# Best-effort: a failure only leaves OpenCV probe noise suppressed,
# but report it so a stuck log level is diagnosable.
logger.warning(
"camera discovery: could not restore OpenCV log level %r: %s",
prev_level,
exc,
)
def _backend_candidates() -> list[tuple[str, int]]:
@@ -115,9 +124,21 @@ 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)
try:
info = _try_open(index, backend_name, backend_api)
except Exception as exc:
# Same hazard as the SDK enumerator: an escaping error for one
# backend would abort the whole probe and hide every other
# camera. Skip that candidate and keep going.
logger.warning(
"camera discovery: probing index %d via %s failed: %s",
index,
backend_name,
exc,
)
continue
if info is None:
continue
if index in seen_indices:
+7 -10
View File
@@ -6,6 +6,9 @@ import math
import sys
import time
from pipeline.config import PipelineConfig
from pipeline.csv_recorder import CsvRecorder
from pipeline.service import FingerPipeline
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import (
@@ -17,23 +20,17 @@ from PyQt5.QtWidgets import (
QWidget,
)
from pipeline.config import PipelineConfig
from pipeline.csv_recorder import CsvRecorder
from pipeline.service import FingerPipeline
from gui.app_paths import resource_path
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
from .camera_discovery import list_cameras, pick_default_camera
from .preferences import get_preferred_video_source, load_preferences, save_preferences
from .layout import (
DashboardLayout,
LAYOUT,
PANEL_SETUP,
PanelSetup,
top_row_splitter_sizes,
DashboardLayout,
)
from .mesh_view_state import MeshViewState
from .preferences import get_preferred_video_source, load_preferences, save_preferences
from .styles import STYLE
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
from .widgets import (
ArrowPanelWidget,
ForceChartWidget,
+3 -2
View File
@@ -47,11 +47,12 @@ import math
from dataclasses import dataclass
from typing import Literal
from gui.log_config import LOG_CONFIG, LogConfig
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
from viz.contact_arrow import ContactArrowStyle
from viz.flow_arrow import FlowArrowStyle
from viz.render_2d import ArrowBackground, ArrowCanvasColors
from gui.log_config import LOG_CONFIG, LogConfig
PanelId = Literal["mesh", "arrows", "charts", "log", "settings"]
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from PyQt5.QtCore import QObject, pyqtSignal
from .layout import DashboardLayout, LAYOUT
from .layout import LAYOUT, DashboardLayout
class MeshViewState(QObject):
+3 -3
View File
@@ -3,14 +3,14 @@
from __future__ import annotations
import cv2
from pipeline.frame import FingerFrameView
from viz.render_2d import ArrowBackground, ArrowCanvasColors, render_uv_arrows
from ..layout import DashboardLayout, LAYOUT
from .image_panel import ImagePanel
from gui.widgets.qt_pixmap import ndarray_to_pixmap
from ..layout import LAYOUT, DashboardLayout
from .image_panel import ImagePanel
class ArrowPanelWidget(ImagePanel):
"""UV unrolled flow-arrow panel."""
@@ -3,14 +3,14 @@
from __future__ import annotations
import numpy as np
from pipeline.frame import FingerFrameView
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import QSizePolicy
from ..layout import DashboardLayout, LAYOUT
from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
from pipeline.frame import FingerFrameView
from viz.signal_filters import lowpass_filter
from ..layout import LAYOUT, DashboardLayout
from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
def _require_pyqtgraph():
try:
@@ -2,16 +2,16 @@
from __future__ import annotations
from pipeline.frame import FingerFrameView
from PyQt5.QtCore import QEvent, Qt
from PyQt5.QtGui import QMouseEvent
from pipeline.frame import FingerFrameView
from viz.offscreen_mesh import OffscreenFingerMeshRenderer
from ..layout import DashboardLayout, LAYOUT
from ..mesh_view_state import MeshViewState
from ..mesh_render_worker import MeshRenderWorker
from gui.widgets.qt_pixmap import ndarray_to_pixmap
from ..layout import LAYOUT, DashboardLayout
from ..mesh_render_worker import MeshRenderWorker
from ..mesh_view_state import MeshViewState
from .image_panel import ImagePanel
_VIEW_ROTATE_SENSITIVITY = 0.35
@@ -2,7 +2,7 @@
from __future__ import annotations
from PyQt5.QtCore import QSize, Qt, pyqtSignal
from PyQt5.QtCore import QSize, pyqtSignal
from PyQt5.QtWidgets import (
QCheckBox,
QComboBox,
@@ -14,11 +14,11 @@ from PyQt5.QtWidgets import (
QVBoxLayout,
QWidget,
)
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
from viz.render_2d import ArrowBackground
from ..layout import DashboardLayout, LAYOUT
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
from ..layout import LAYOUT, DashboardLayout
from ..mesh_view_state import MeshViewState
from ..transport_icons import reset_icon, transport_icon
+13 -7
View File
@@ -9,12 +9,16 @@ ORY-MINI:单传感器基础流程
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import argparse
import cv2
import numpy as np
import orisys
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
@@ -29,11 +33,13 @@ def main():
# 输入既可以是摄像头编号,也可以是视频文件路径
try:
input = int(args.video)
is_camera = True
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
except:
is_camera = False
except ValueError:
# Not a camera index: treat the argument as a video-file path. Falling back
# only on the conversion (not on Sensor construction) keeps a real
# constructor failure visible instead of retrying it as a file path.
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
else:
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
+4 -1
View File
@@ -245,7 +245,10 @@ def main():
try:
with open(path) as fh:
rows.append(json.load(fh))
except Exception:
except Exception: # noqa: S110 - a stream that wrote no result is reported below
# A stream that crashed or produced no JSON output is expected here;
# the empty-``rows`` check below reports that case explicitly, so this
# is not a hidden failure.
pass
if not rows:
print("no stream produced results", file=sys.stderr)
+3 -2
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
from dataclasses import dataclass
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
from viz.mesh_camera import DEFAULT_FINGER_VIZ_POSE
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
def mesh_threshold(mesh_map: str, threshold: float | None) -> float:
if threshold is not None:
@@ -33,7 +34,7 @@ class PipelineConfig:
profile_every: int = 30
@classmethod
def from_args(cls, args) -> "PipelineConfig":
def from_args(cls, args) -> PipelineConfig:
return cls(
video=args.video,
config=args.config,
-1
View File
@@ -7,7 +7,6 @@ from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
CSV_COLUMNS = (
"frame_index",
"timestamp_s",
+2 -2
View File
@@ -6,10 +6,10 @@ import time
from dataclasses import dataclass
import numpy as np
from orisys.finger import FingerRuntime, compute_uv_surface_frames
from viz import FrameRecorder, build_uv_surface_mesh, resolve_viz_pose_path
from orisys.finger import FingerRuntime, compute_uv_surface_frames
from .config import PipelineConfig
from .csv_recorder import CsvMeasurement
from .frame import FingerFrameView
+2 -2
View File
@@ -16,10 +16,10 @@ os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import numpy as np
import orisys
from viz import ForceChartWindow, FrameRecorder
import orisys
def open_sensor(video: str, config: str, cal: str, verbose: bool):
try:
+23 -11
View File
@@ -20,14 +20,17 @@
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import multiprocessing as mp
from multiprocessing import Queue, Process
import time
import argparse
import multiprocessing as mp
import queue
import time
from multiprocessing import Process, Queue
import cv2
import orisys
def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, target_fps=None):
@@ -55,6 +58,7 @@ def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, tar
print(f"传感器 {sensor_id} 已启动" + (f"(目标帧率:{target_fps} FPS)" if target_fps else ""))
frame_interval = 1.0 / target_fps if target_fps and target_fps > 0 else 0.0
dropped_frames = 0
while not stop_event.is_set():
t_start = time.perf_counter()
@@ -95,9 +99,10 @@ def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, tar
# 步骤 6:将结果发送到主进程(非阻塞)
try:
result_queue.put_nowait(data)
except Exception:
# 如果队列已满,则丢弃当前帧,避免阻塞工作进程
pass
except queue.Full:
# 队列已满:丢弃当前帧,避免阻塞工作进程。这是有意的背压策略,
# 但丢帧必须可观测,否则主进程看到的低帧率无从解释。
dropped_frames += 1
except Exception as e:
print(f"传感器 {sensor_id} 处理失败:{e}")
@@ -112,7 +117,13 @@ def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, tar
# 释放资源
sensor.disconnect()
print(f"传感器 {sensor_id} 已停止。")
if dropped_frames:
print(
f"传感器 {sensor_id} 已停止(因结果队列已满丢弃 {dropped_frames} 帧,"
"主进程处理慢于采集)。"
)
else:
print(f"传感器 {sensor_id} 已停止。")
except Exception as e:
print(f"传感器 {sensor_id} 初始化失败:{e}")
@@ -193,7 +204,8 @@ def main():
while True:
try:
data = queue.get_nowait()
except:
except queue.Empty:
# 队列已排空:正常结束内层循环,不是错误。
break
if data is not None:
+9 -9
View File
@@ -1,5 +1,7 @@
"""Shared visualization helpers for Orisys example scripts."""
from .finger_mesh_viewer import FingerMeshViewer
from .flow_arrow import DEFAULT_FLOW_ARROW_STYLE, FlowArrowOverlay, FlowArrowStyle
from .force_charts import ForceChartWindow
from .mesh_camera import (
DEFAULT_FINGER_VIZ_POSE,
@@ -8,33 +10,32 @@ from .mesh_camera import (
camera_look_at_from_world_to_cam,
resolve_viz_pose_path,
)
from .mesh_viewer import UVMeshViewer, build_uv_surface_mesh
from .offscreen_mesh import OffscreenFingerMeshRenderer
from .recording import FrameRecorder
from .render_2d import (
DEFAULT_ARROW_CANVAS_COLORS,
ArrowBackground,
ArrowCanvasColors,
DEFAULT_ARROW_CANVAS_COLORS,
depth_colormap,
frame_to_bgr,
render_contact_panel,
render_depth_panel,
render_uv_arrows,
)
from .flow_arrow import DEFAULT_FLOW_ARROW_STYLE, FlowArrowOverlay, FlowArrowStyle
from .finger_mesh_viewer import FingerMeshViewer
from .mesh_viewer import UVMeshViewer, build_uv_surface_mesh
from .recording import FrameRecorder
from .signal_filters import lowpass_filter
__all__ = [
"ArrowBackground",
"ArrowCanvasColors",
"DEFAULT_ARROW_CANVAS_COLORS",
"DEFAULT_FLOW_ARROW_STYLE",
"ArrowBackground",
"ArrowCanvasColors",
"FingerMeshViewer",
"FlowArrowOverlay",
"FlowArrowStyle",
"FingerMeshViewer",
"ForceChartWindow",
"FrameRecorder",
"OffscreenFingerMeshRenderer",
"UVMeshViewer",
"apply_mesh_lighting",
"apply_view_from_world_to_cam",
@@ -43,7 +44,6 @@ __all__ = [
"depth_colormap",
"frame_to_bgr",
"lowpass_filter",
"OffscreenFingerMeshRenderer",
"render_contact_panel",
"render_depth_panel",
"render_uv_arrows",
+5 -1
View File
@@ -8,7 +8,11 @@ import numpy as np
from orisys.finger.viewer import UVMeshViewer
from .contact_arrow import ContactArrowOverlay, ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
from .contact_arrow import (
DEFAULT_CONTACT_ARROW_STYLE,
ContactArrowOverlay,
ContactArrowStyle,
)
from .flow_arrow import DEFAULT_FLOW_ARROW_STYLE, FlowArrowOverlay, FlowArrowStyle
+2 -1
View File
@@ -2,8 +2,9 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Callable, Literal
from typing import Literal
import cv2
import numpy as np
+5 -1
View File
@@ -1,4 +1,8 @@
"""Re-exports for finger 3D mesh examples; core viewer lives in orisys.finger."""
from orisys.finger.mesh import * # noqa: F403
from orisys.finger.viewer import UVMeshViewer, init_uv_mesh_viewer, update_uv_mesh_viewer # noqa: F401
from orisys.finger.viewer import ( # noqa: F401
UVMeshViewer,
init_uv_mesh_viewer,
update_uv_mesh_viewer,
)
-1
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import os
from pathlib import Path
import cv2
+1
View File
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Literal
import cv2
import numpy as np
import orisys
if TYPE_CHECKING:
+1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import numpy as np
from scipy.signal import butter, filtfilt
def lowpass_filter(data, cutoff_hz=5, sample_rate_hz=30, order=2) -> np.ndarray:
"""Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data."""
y = np.asarray(data, dtype=np.float64)
+55
View File
@@ -0,0 +1,55 @@
# Ruff configuration — pinned deliberately.
#
# Before this file existed the repo had no Ruff config, so `ruff check .` ran whatever the
# installed Ruff version enables by default (Ruff 0.16 turns on a very broad set: UP/RUF/PLR/
# S/TRY/PERF/FURB/YTT/ASYNC...). The same tree therefore produced different findings on
# different machines and between a developer and a runner. Never rely on tool defaults.
#
# Policy, decided from the measured findings rather than from taste:
#
# * `select` is explicit. High-signal correctness rules first, then only the mechanical
# modernisation rules we are willing to apply without human review.
# * BLE001 is selected so existing `# noqa: BLE001 - <reason>` suppressions stay *used* and
# RUF100 can never delete their rationale — a broad `except Exception` is a legitimate
# choice around device I/O, capture probes and teardown. It is NOT part of the gate.
# * S110 / S112 / E722 ARE part of the gate: a silent handler hides a failure. This matters
# most in the live capture path, where a swallowed read error kills a source silently.
# * RUF100 reports unused suppressions but is `unfixable`: a machine must not remove a
# suppression whose comment records why it exists. Triage those by hand.
# * The mechanical sweep never uses `--unsafe-fixes`.
#
# The gate selects the narrow set explicitly, so BLE001 cannot fail a build, and only ever
# touches changed files:
#
# tools/lint_gate.py # the gate: changed files, machine-readable
target-version = "py310"
line-length = 88
[lint]
select = [
"E9", "E722", # syntax/IO errors, and bare `except:`
"E402", # module import not at top of file — SELECTED for visibility, NOT gated.
# `import` after `os.environ[...]` is deliberate in examples/ and scripts/
# (the environment must be set before Qt/cv2/orisys load). E402 is the only
# rule that would catch an accidental import moved above that setup, so it
# stays reported, without failing a build. An intentional site belongs in a
# `# noqa: E402 - <reason>` when it is touched.
"F", # pyflakes: unused import, redefinition, undefined name
"S110", "S112", # try-except-pass / try-except-continue — an invisible failure
"B023", # function definition does not bind loop variable
"PLW1510", # subprocess.run without an explicit check
"RUF100", # unused noqa — reported for triage, never auto-removed
"UP", # pyupgrade
"I", # import sorting
"RUF022", "RUF023", # __all__ / __slots__ ordering
"PIE", # flake8-pie
"RET501", "PLR1711",
"SIM114",
"BLE001", # selected for suppression hygiene only — excluded from the gate
]
unfixable = ["RUF100"]
[format]
quote-style = "double"
indent-style = "space"
+11 -7
View File
@@ -1,6 +1,7 @@
import os
import sys
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
@@ -9,10 +10,11 @@ _local_src = os.path.join(os.path.dirname(__file__), "src")
if _local_src not in sys.path:
sys.path.insert(0, _local_src)
import orisys
import numpy as np
import time
import argparse
import time
import orisys
def main():
parser = argparse.ArgumentParser()
@@ -30,11 +32,13 @@ def main():
# 方式1: 从视频文件读取
try:
input = int(args.video)
is_camera = True
sensor = orisys.Sensor(input, config_name=args.config, cal_path= args.cal, verbose=args.verbose, debug=args.debug)
except:
is_camera = False
except ValueError:
# Not a camera index: treat the argument as a video-file path. Only the
# conversion is guarded, so a genuine Sensor construction error is not
# swallowed and retried as a file path.
sensor = orisys.Sensor(args.video, config_name=args.config, cal_path= args.cal, verbose=args.verbose, debug=args.debug)
else:
sensor = orisys.Sensor(input, config_name=args.config, cal_path= args.cal, verbose=args.verbose, debug=args.debug)
# sensor = orisys.Sensor(2, config_name="./config/ddjx01_q6a.json", cal_path= "./config/ddjx01.npy", isstitch=True, cuda=False, verbose=True, backend='cpu')
# 方式2: 从摄像头读取(取消注释使用)
+7 -4
View File
@@ -1,12 +1,15 @@
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import time
import cv2
import numpy as np
import pyqtgraph as pg
import orisys
def create_plot():
p1 = win.addPlot(title="法相力")
p1.setLabel('bottom', '时间', units='s')
@@ -149,7 +152,7 @@ def main():
print("\n 停止程序")
break
if key == ord("s"):
save_path = f"./data/pulse_test.mp4"
save_path = "./data/pulse_test.mp4"
# Ensure directory exists
os.makedirs(os.path.dirname(save_path), exist_ok=True)
# Get frame dimensions from the actual frame
+16 -9
View File
@@ -46,7 +46,7 @@ import os
import shlex
import sys
from pathlib import Path
from typing import Optional, Union
from typing import Union
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
@@ -60,13 +60,14 @@ except ImportError as exc: # pragma: no cover
) from exc
import cv2
from orisys.capture import is_live_camera, list_cameras
from orisys.finger import FingerRuntime
Source = Union[int, str]
def _parse_source(token: str) -> Optional[Source]:
def _parse_source(token: str) -> Source | None:
"""Accept camera index int, numeric string, V4L path, or filesystem path."""
text = str(token).strip().strip('"').strip("'")
if not text:
@@ -116,9 +117,9 @@ class UartFingerController:
self.show = show
self.verbose = verbose
self.runtime: Optional[FingerRuntime] = None
self._connected_source: Optional[Source] = None
self._last_search_source: Optional[Source] = None
self.runtime: FingerRuntime | None = None
self._connected_source: Source | None = None
self._last_search_source: Source | None = None
self._streaming = False
self._should_quit = False
@@ -134,14 +135,18 @@ class UartFingerController:
if self.runtime is not None:
try:
self.runtime.close()
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001, S110 - teardown must not raise
# Best-effort release: a close failure here has no recovery path in
# a shutdown request handler, and raising would skip the reset below.
pass
self.runtime = None
self._connected_source = None
if self.show:
try:
cv2.destroyAllWindows()
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001, S110 - no GUI window to destroy
# Expected when no window was ever created (headless/--no-show run):
# this is a normal no-op, not a hidden failure.
pass
def _resolve_connect_source(self, args: list[str]) -> Source:
@@ -220,7 +225,7 @@ class UartFingerController:
except Exception: # noqa: BLE001
return False
def _poll_command_line(self, *, blocking: bool = True) -> Optional[str]:
def _poll_command_line(self, *, blocking: bool = True) -> str | None:
"""Read one command line if available; return None on timeout/empty."""
if not blocking and self.ser.in_waiting <= 0:
return None
@@ -447,7 +452,9 @@ def main(argv: list[str] | None = None) -> int:
print("\nInterrupted.", flush=True)
try:
controller.reply("OK quit")
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001, S110 - peer may already be gone
# We are already shutting down on Ctrl-C; failing to send the final
# acknowledgement (peer closed the port) must not mask the exit path.
pass
finally:
controller.close_runtime()
+2 -3
View File
@@ -36,10 +36,9 @@ from __future__ import annotations
import argparse
import csv
import statistics
import sys
from collections.abc import Sequence
from pathlib import Path
from typing import Sequence
import numpy as np
@@ -79,7 +78,7 @@ def _window(
valid = _column(rows, VALID_COLUMN)
mask = np.ones(times.shape, dtype=bool)
if times.size:
mask &= times == times # drop NaN timestamps
mask &= ~np.isnan(times) # drop NaN timestamps (unparsable TIME cells)
if start is not None:
mask &= times >= float(start)
if stop is not None:
+9 -5
View File
@@ -1,14 +1,18 @@
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import time
import argparse
import time
import cv2
import numpy as np
import pyqtgraph as pg
from PyQt5.QtGui import QImage, QFont
from PyQt5.QtGui import QFont, QImage
from pyqtgraph.Qt import QtGui
import orisys
def make_gradient_brush(color_hex, max_alpha=150):
"""
创建一个从上到下渐变的笔刷 (兼容 PyQt5 和 PyQt6)
+1 -3
View File
@@ -5,9 +5,7 @@ Historical import path ``from orisys.Stitcher import Stitcher`` remains supporte
from .stitch import Stitcher
from .stitch.backends.numpy_backend import blend_kernel_numpy as _blend_kernel_numpy
from .stitch.config import BLEND_MODES as _BLEND_MODES
from .stitch.config import DEFAULT_BLEND_MODE as _DEFAULT_BLEND_MODE
from .stitch.config import numba_blend_available, resolve_blend_mode as _resolve_blend_mode
from .stitch.config import numba_blend_available
# Backward-compatible aliases used by older call sites / tests.
_blend_kernel = _blend_kernel_numpy
+3 -8
View File
@@ -8,16 +8,11 @@ including image stitching, optical flow tracking, and deformation decomposition.
__version__ = "0.5.3"
__author__ = "Orisys"
from .sensor import Sensor
from . import util
from . import configs
from . import calibration
from . import finger
from . import signal_processing
from . import capture
from .pipeline import FrameSnapshot, PipelineStats, StreamPipeline
from . import calibration, capture, configs, finger, signal_processing, util
from .export import StreamRecorder, mosaic_videos
from .frame_processors import FFCFrameProcessor
from .pipeline import FrameSnapshot, PipelineStats, StreamPipeline
from .sensor import Sensor
CompoundEyeSensor = Sensor
CameraSensor = Sensor
+1 -1
View File
@@ -63,8 +63,8 @@ except ImportError:
try:
import pyopencl as cl
import pyopencl.array as cla
from pyvkfft.fft import rfftn as cl_rfftn
from pyvkfft.fft import irfftn as cl_irfftn
from pyvkfft.fft import rfftn as cl_rfftn
_opencl_cl = cl
_opencl_cla = cla
_opencl_rfftn = cl_rfftn
+2 -1
View File
@@ -8,11 +8,12 @@ from __future__ import annotations
import json
import warnings
from collections.abc import Iterable, Mapping
from copy import deepcopy
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping
from typing import Any
CURRENT_SCHEMA = 2
SUPPORTED_SCHEMAS = (1, 2)
+10 -10
View File
@@ -25,21 +25,21 @@ from .source import (
)
__all__ = [
"MAX_SIMULTANEOUS_CAPTURES",
"DEFAULT_MAX_CAMERA_INDEX",
"DEFAULT_LAPTOP_WEBCAM_PATTERNS",
"DEFAULT_MAX_CAMERA_INDEX",
"MAX_SIMULTANEOUS_CAPTURES",
"CameraFilter",
"CameraInfo",
"CaptureConfig",
"CaptureHealth",
"CaptureStatus",
"ReconnectPolicy",
"CaptureSource",
"CaptureStatus",
"CaptureWorker",
"CameraInfo",
"CameraFilter",
"list_cameras",
"pick_default_camera",
"normalize_source",
"ReconnectPolicy",
"is_live_camera",
"open_opencv_capture",
"list_cameras",
"normalize_source",
"open_capture",
"open_opencv_capture",
"pick_default_camera",
]
+18 -17
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional, Sequence, Tuple, Union
from typing import Any, Union
MAX_SIMULTANEOUS_CAPTURES = 10
@@ -44,11 +45,11 @@ class ReconnectPolicy:
"""
enabled: bool = False
max_attempts: Optional[int] = None
max_attempts: int | None = None
initial_backoff_s: float = 0.25
max_backoff_s: float = 5.0
fail_threshold: int = 5
stale_timeout_s: Optional[float] = 2.0
stale_timeout_s: float | None = 2.0
def next_backoff(self, attempt: int) -> float:
"""Exponential backoff capped at ``max_backoff_s`` (attempt is 0-based)."""
@@ -62,19 +63,19 @@ class CaptureConfig:
source: SourceType
backend: str = "auto"
width: Optional[int] = None
height: Optional[int] = None
fps: Optional[float] = None
fourcc: Optional[str] = "MJPG"
width: int | None = None
height: int | None = None
fps: float | None = None
fourcc: str | None = "MJPG"
background: bool = True
reconnect: ReconnectPolicy = field(default_factory=ReconnectPolicy)
verbose: bool = False
# Optional discovery filters (used by list/open helpers, not open itself).
name_include: Optional[Sequence[str]] = None
name_exclude: Optional[Sequence[str]] = None
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None
min_width: Optional[int] = None
min_height: Optional[int] = None
name_include: Sequence[str] | None = None
name_exclude: Sequence[str] | None = None
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None
min_width: int | None = None
min_height: int | None = None
def __post_init__(self) -> None:
if not isinstance(self.reconnect, ReconnectPolicy):
@@ -86,10 +87,10 @@ class CaptureConfig:
source: SourceType,
camera: dict[str, Any],
*,
reconnect: Optional[ReconnectPolicy] = None,
reconnect: ReconnectPolicy | None = None,
verbose: bool = False,
**kwargs: Any,
) -> "CaptureConfig":
) -> CaptureConfig:
"""Build config from a sensor JSON ``camera`` section."""
# Accept deprecated queue/worker kwargs from older callers without error.
kwargs.pop("mode", None)
@@ -117,9 +118,9 @@ class CaptureStatus:
"""Snapshot of capture health for diagnostics / multi-camera UIs."""
health: CaptureHealth = CaptureHealth.CLOSED
source: Optional[SourceType] = None
last_error: Optional[str] = None
last_frame_time: Optional[float] = None
source: SourceType | None = None
last_error: str | None = None
last_frame_time: float | None = None
last_seq: int = 0
dropped_frames: int = 0
reconnect_attempts: int = 0
+47 -25
View File
@@ -3,21 +3,24 @@
from __future__ import annotations
import contextlib
import logging
import os
import re
import sys
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from typing import Iterable, Optional, Sequence, Tuple
import cv2
from .config import MAX_SIMULTANEOUS_CAPTURES
from .source import _backend_candidates
logger = logging.getLogger(__name__)
DEFAULT_MAX_CAMERA_INDEX = MAX_SIMULTANEOUS_CAPTURES
# Common built-in laptop webcam name fragments (case-insensitive).
DEFAULT_LAPTOP_WEBCAM_PATTERNS: Tuple[str, ...] = (
DEFAULT_LAPTOP_WEBCAM_PATTERNS: tuple[str, ...] = (
r"integrated",
r"built[- ]?in",
r"laptop",
@@ -49,8 +52,8 @@ class CameraInfo:
height: int
name: str = ""
path: str = ""
vendor_id: Optional[int] = None
product_id: Optional[int] = None
vendor_id: int | None = None
product_id: int | None = None
bus: str = ""
@property
@@ -64,7 +67,7 @@ class CameraInfo:
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
@property
def vid_pid(self) -> Optional[Tuple[int, int]]:
def vid_pid(self) -> tuple[int, int] | None:
if self.vendor_id is None or self.product_id is None:
return None
return (int(self.vendor_id), int(self.product_id))
@@ -74,13 +77,13 @@ class CameraInfo:
class CameraFilter:
"""Optional filters for ``list_cameras`` / ``pick_default_camera``."""
name_include: Optional[Sequence[str]] = None
name_exclude: Optional[Sequence[str]] = None
name_include: Sequence[str] | None = None
name_exclude: Sequence[str] | None = None
exclude_laptop_webcams: bool = False
laptop_patterns: Sequence[str] = field(default_factory=lambda: DEFAULT_LAPTOP_WEBCAM_PATTERNS)
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None
min_width: Optional[int] = None
min_height: Optional[int] = None
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None
min_width: int | None = None
min_height: int | None = None
prefer_named: bool = True
def matches(self, cam: CameraInfo) -> bool:
@@ -143,8 +146,15 @@ def _suppress_opencv_noise():
if set_level is not None and prev_level is not None:
try:
set_level(prev_level)
except Exception:
pass
except Exception as exc:
# Restoring OpenCV's log level is best-effort, but a silent failure
# here leaves probing noise suppressed for the rest of the process,
# which hides exactly the diagnostics a failed enumeration needs.
logger.warning(
"capture discovery: could not restore OpenCV log level %r: %s",
prev_level,
exc,
)
def _linux_device_metadata(index: int) -> dict:
@@ -152,7 +162,7 @@ def _linux_device_metadata(index: int) -> dict:
meta = {"name": "", "path": f"/dev/video{index}", "vendor_id": None, "product_id": None, "bus": ""}
name_path = f"/sys/class/video4linux/video{index}/name"
try:
with open(name_path, "r", encoding="utf-8", errors="ignore") as fh:
with open(name_path, encoding="utf-8", errors="ignore") as fh:
meta["name"] = fh.read().strip()
except OSError:
pass
@@ -165,9 +175,9 @@ def _linux_device_metadata(index: int) -> dict:
vend = os.path.join(cur, "idVendor")
prod = os.path.join(cur, "idProduct")
if os.path.isfile(vend) and os.path.isfile(prod):
with open(vend, "r", encoding="utf-8") as fh:
with open(vend, encoding="utf-8") as fh:
meta["vendor_id"] = int(fh.read().strip(), 16)
with open(prod, "r", encoding="utf-8") as fh:
with open(prod, encoding="utf-8") as fh:
meta["product_id"] = int(fh.read().strip(), 16)
meta["bus"] = "usb"
break
@@ -229,7 +239,7 @@ def _windows_device_names() -> dict[int, dict]:
return {}
def _device_metadata(index: int, windows_names: Optional[dict[int, dict]] = None) -> dict:
def _device_metadata(index: int, windows_names: dict[int, dict] | None = None) -> dict:
if sys.platform.startswith("linux"):
return _linux_device_metadata(index)
if sys.platform == "win32" and windows_names is not None:
@@ -238,7 +248,7 @@ def _device_metadata(index: int, windows_names: Optional[dict[int, dict]] = None
return {"name": "", "path": "", "vendor_id": None, "product_id": None, "bus": ""}
def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> Optional[CameraInfo]:
def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> CameraInfo | None:
cap = cv2.VideoCapture(index, backend_api)
if not cap.isOpened():
cap.release()
@@ -272,13 +282,13 @@ def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> Op
def list_cameras(
max_index: int = DEFAULT_MAX_CAMERA_INDEX,
*,
camera_filter: Optional[CameraFilter] = None,
camera_filter: CameraFilter | None = None,
exclude_laptop_webcams: bool = False,
name_include: Optional[Sequence[str]] = None,
name_exclude: Optional[Sequence[str]] = None,
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None,
min_width: Optional[int] = None,
min_height: Optional[int] = None,
name_include: Sequence[str] | None = None,
name_exclude: Sequence[str] | None = None,
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None,
min_width: int | None = None,
min_height: int | None = None,
backend: str = "auto",
) -> list[CameraInfo]:
"""Probe indices ``0 .. max_index`` and return working cameras.
@@ -302,10 +312,22 @@ def list_cameras(
windows_names = _windows_device_names() if sys.platform == "win32" else None
with _suppress_opencv_noise():
for index in range(0, max_index + 1):
for index in range(max_index + 1):
meta = _device_metadata(index, windows_names)
for backend_name, backend_api in backends:
info = _try_open(index, backend_name, backend_api, meta)
try:
info = _try_open(index, backend_name, backend_api, meta)
except Exception as exc:
# One bad backend/device must not abort the whole probe: an
# escaping error here made every *other* camera invisible, not
# just this index. Skip the candidate and keep enumerating.
logger.warning(
"capture discovery: probing index %d via %s failed: %s",
index,
backend_name,
exc,
)
continue
if info is None:
continue
if index in seen_indices:
+36 -24
View File
@@ -2,11 +2,12 @@
from __future__ import annotations
import logging
import os
import sys
import threading
import time
from typing import Any, Optional, Protocol, runtime_checkable
from typing import Any, Protocol, runtime_checkable
import cv2
import numpy as np
@@ -19,6 +20,8 @@ from .config import (
SourceType,
)
logger = logging.getLogger(__name__)
@runtime_checkable
class VideoCaptureLike(Protocol):
@@ -83,7 +86,7 @@ def _backend_candidates(preferred: str = "auto") -> list[tuple[str, int]]:
return candidates
def _fourcc_code(fourcc: Optional[str]) -> Optional[int]:
def _fourcc_code(fourcc: str | None) -> int | None:
if not fourcc:
return None
text = str(fourcc).strip()
@@ -111,10 +114,10 @@ def _read_stream_meta(cap: VideoCaptureLike) -> tuple[int, int, float, str]:
def _apply_stream_props(
cap: VideoCaptureLike,
*,
width: Optional[int],
height: Optional[int],
fps: Optional[float],
fourcc: Optional[str],
width: int | None,
height: int | None,
fps: float | None,
fourcc: str | None,
) -> None:
"""Apply live-camera stream properties (width/height before fourcc on DSHOW)."""
code = _fourcc_code(fourcc)
@@ -143,10 +146,10 @@ def open_opencv_capture(
source: SourceType,
*,
backend: str = "auto",
width: Optional[int] = None,
height: Optional[int] = None,
fps: Optional[float] = None,
fourcc: Optional[str] = "MJPG",
width: int | None = None,
height: int | None = None,
fps: float | None = None,
fourcc: str | None = "MJPG",
apply_stream_props: bool = True,
) -> tuple[VideoCaptureLike, str]:
"""Open a ``cv2.VideoCapture`` trying preferred backends in order.
@@ -212,16 +215,16 @@ class CaptureSource:
):
self.config = config
self._factory = factory or self._default_factory
self._cap: Optional[VideoCaptureLike] = None
self._cap: VideoCaptureLike | None = None
self._backend_name = ""
self._status = CaptureStatus(source=normalize_source(config.source))
self._reconnect_attempt = 0
self._lock = threading.Lock()
self._frame_lock = threading.Lock()
self._frame_cond = threading.Condition(self._frame_lock)
self._latest_frame: Optional[np.ndarray] = None
self._latest_frame: np.ndarray | None = None
self._latest_seq = 0
self._thread: Optional[threading.Thread] = None
self._thread: threading.Thread | None = None
self._background_running = False
@staticmethod
@@ -238,7 +241,7 @@ class CaptureSource:
# Compatibility with older CaptureWorker API.
@property
def source(self) -> "CaptureSource":
def source(self) -> CaptureSource:
return self
@property
@@ -265,7 +268,7 @@ class CaptureSource:
)
@property
def cap(self) -> Optional[VideoCaptureLike]:
def cap(self) -> VideoCaptureLike | None:
"""Raw capture handle (compat with Sensor.cap)."""
return self._cap
@@ -324,8 +327,14 @@ class CaptureSource:
if self._cap is not None:
try:
self._cap.release()
except Exception:
pass
except Exception as exc:
# A failed release is not fatal, but it must not be invisible: the
# handle below is dropped either way, so a live-camera handle that
# OpenCV refused to free would otherwise leak silently until the
# process exits. Report it and keep tearing down.
logger.warning(
"capture: %r.release() failed: %s", self._backend_name or "capture", exc
)
self._cap = None
with self._frame_cond:
self._latest_frame = None
@@ -341,8 +350,11 @@ class CaptureSource:
self._status.height = height
self._status.fps = fps
self._status.fourcc = fourcc
except Exception:
pass
except Exception as exc:
# Metadata is advisory: a backend that cannot answer these get() calls
# still delivers frames, so do not fail the open. Record why the
# reported stream geometry is left at its previous/default values.
logger.warning("capture: could not read stream metadata: %s", exc)
def warmup_frames(self, count: int = 3) -> None:
"""Discard frames so the driver/format stabilizes (sync reads only)."""
@@ -351,7 +363,7 @@ class CaptureSource:
for _ in range(max(0, int(count))):
self.read_raw()
def read_raw(self) -> tuple[bool, Optional[np.ndarray]]:
def read_raw(self) -> tuple[bool, np.ndarray | None]:
"""Read one frame directly from the underlying capture (no reconnect)."""
cap = self._cap
if cap is None:
@@ -379,7 +391,7 @@ class CaptureSource:
*,
copy: bool = True,
wait_s: float = 0.0,
) -> tuple[bool, Optional[np.ndarray]]:
) -> tuple[bool, np.ndarray | None]:
"""Return the newest frame from the background thread, or read sync."""
if not self._background_running:
ok, frame = self.read_raw()
@@ -397,7 +409,7 @@ class CaptureSource:
frame = self._latest_frame.copy() if copy else self._latest_frame
return True, frame
def read(self, *, copy: bool = True, wait_s: float = 1.0) -> tuple[bool, Optional[np.ndarray]]:
def read(self, *, copy: bool = True, wait_s: float = 1.0) -> tuple[bool, np.ndarray | None]:
"""Compat alias used by finger runtime and older worker callers."""
wait = wait_s if wait_s > 0 else 1.0
return self.read_latest(copy=copy, wait_s=wait)
@@ -531,8 +543,8 @@ def open_capture(
config: CaptureConfig,
*,
factory=None,
start_background: Optional[bool] = None,
start_worker: Optional[bool] = None,
start_background: bool | None = None,
start_worker: bool | None = None,
) -> CaptureSource:
"""Open a capture source and optionally start its background thread.
+2 -2
View File
@@ -191,7 +191,7 @@ def _config_files() -> list[Path]:
def _load_json(path: Path) -> dict:
with open(path, "r", encoding="utf-8") as f:
with open(path, encoding="utf-8") as f:
return json.load(f)
@@ -433,7 +433,7 @@ def load_config(config_name_or_path):
for candidate in _config_files():
try:
data = _load_json(candidate)
except Exception:
except Exception: # noqa: S112 - skip unreadable config during alias scan
continue
if data.get("name") in candidates:
return data
+3 -3
View File
@@ -42,8 +42,8 @@ from __future__ import annotations
import os
import queue
import threading
import time
from typing import Any, Callable, Iterable, Sequence
from collections.abc import Callable, Iterable, Sequence
from typing import Any
import cv2
import numpy as np
@@ -243,7 +243,7 @@ class StreamRecorder:
"fourcc": self.fourcc,
}
def __enter__(self) -> "StreamRecorder":
def __enter__(self) -> StreamRecorder:
return self
def __exit__(self, exc_type, exc, tb) -> bool:
+2 -2
View File
@@ -18,8 +18,8 @@ from .mesh import (
lift_uv_deformation_to_3d,
lift_uv_flow_to_3d,
mesh_uv_to_world_scale,
scalar_map_vertex_colors,
sample_field_at_mesh_vertices,
scalar_map_vertex_colors,
)
from .runtime import (
FingerGeometry,
@@ -39,8 +39,8 @@ __all__ = [
"FingerGeometry",
"FingerRuntime",
"TactileProcessor",
"UVSurfaceMesh",
"UVMeshViewer",
"UVSurfaceMesh",
"build_uv_surface_mesh",
"compute_uv_surface_frames",
"deform_mesh_vertices",
+8 -8
View File
@@ -12,15 +12,15 @@ class UVSurfaceMesh:
"""Triangle mesh built from a UV flatten grid and world_xyz surface map."""
__slots__ = (
"rest_vertices",
"triangles",
"vertex_ys",
"vertex_xs",
"index_map",
"valid_mask",
"shape",
"world_xyz",
"base_colors",
"index_map",
"rest_vertices",
"shape",
"triangles",
"valid_mask",
"vertex_xs",
"vertex_ys",
"world_xyz",
)
def __init__(
+1 -1
View File
@@ -246,7 +246,7 @@ class FingerRuntime:
mirror=False,
auto_reconnect: bool = False,
capture_mode: str = "latest",
) -> "FingerRuntime":
) -> FingerRuntime:
"""Open a live finger runtime from export bundle, config, and video source."""
del capture_mode # deprecated; latest-frame capture only
if camera_id is not None:
+1 -1
View File
@@ -1,10 +1,10 @@
from __future__ import annotations
import time
from pathlib import Path
import cv2
import numpy as np
from pathlib import Path
from .unroll import UVRemap
+2 -1
View File
@@ -1,7 +1,8 @@
import numpy as np
import cv2
import numpy as np
from .backend_availability import CUPY_AVAILABLE, get_cupy
cp = get_cupy()
# 缓存形态学操作的核,避免重复创建
_morphology_kernels = {}
+1 -1
View File
@@ -20,4 +20,4 @@ from .grid import StructuredGrid
from .poisson import PoissonSolver
from .timing import Timer
__all__ = ["nHHD", "StructuredGrid", "PoissonSolver", "Timer"]
__all__ = ["PoissonSolver", "StructuredGrid", "Timer", "nHHD"]
+4 -6
View File
@@ -2,16 +2,16 @@ import numpy
from ..backend_availability import (
CUPY_AVAILABLE,
OPENCL_AVAILABLE,
KERNEL_AVAILABLE,
cp,
OPENCL_AVAILABLE,
_torque_kernel,
cp,
)
from .grid import StructuredGrid
from .poisson import PoissonSolver
class nHHD(object):
class nHHD:
# constructor
def __init__(self, cuda=True, **kwargs):
@@ -25,9 +25,7 @@ class nHHD(object):
backend = 'cupy' if CUPY_AVAILABLE else ('opencl' if OPENCL_AVAILABLE else 'cpu')
# Validate backend availability and fallback to 'cpu' if needed
if backend == 'cupy' and not CUPY_AVAILABLE:
backend = 'cpu'
elif backend == 'opencl' and not OPENCL_AVAILABLE:
if backend == 'cupy' and not CUPY_AVAILABLE or backend == 'opencl' and not OPENCL_AVAILABLE:
backend = 'cpu'
self.debug = kwargs.get('debug', False)
+13 -11
View File
@@ -56,7 +56,9 @@ def build_masked_factor(lap):
options=dict(SymmetricMode=True),
)
return lu.solve, numpy.float32
except Exception:
except Exception: # noqa: S112 - try the next pivoting strategy
# Probe: try the next pivoting strategy; the float64 fallback below is
# returned if every candidate fails, so this failure is not lost.
continue
# Last resort: the original pivoting LU on the float64 matrix.
return sparse_linalg.factorized(lap_csc), numpy.float64
@@ -82,20 +84,20 @@ class MaskedDomain:
"""Reduced Laplacian system on active mask pixels (2D only)."""
__slots__ = (
"shape",
"mask",
"index_map",
"factor",
"factor_dtype",
"lap",
"num_active",
"pinned",
"active_coords",
"active_linear_indices",
"_rhs_buffer",
"_rhs_pair_buffer",
"_scatter_buffer",
"_scatter_pair_buffer",
"active_coords",
"active_linear_indices",
"factor",
"factor_dtype",
"index_map",
"lap",
"mask",
"num_active",
"pinned",
"shape",
)
def __init__(
+7 -8
View File
@@ -1,17 +1,16 @@
import numpy
import timeit
import sys
# import cv2
from scipy import signal, spatial
import numpy
# import cv2
from ..backend_availability import (
CUPY_AVAILABLE, OPENCL_AVAILABLE, KERNEL_AVAILABLE,
cp, cp_signal, _torque_kernel,
cl, cla, cl_rfftn, cl_irfftn
CUPY_AVAILABLE,
cp,
)
from .timing import Timer
class StructuredGrid(object):
class StructuredGrid:
# --------------------------------------------------------------------------
# --------------------------------------------------------------------------
+1 -1
View File
@@ -50,7 +50,7 @@ from .timing import Timer
'''
class PoissonSolver(object):
class PoissonSolver:
def __init__(self, **kwargs):
args = kwargs.keys()
+1 -1
View File
@@ -2,4 +2,4 @@ from .capacitance import CapacitanceFFTSolver
from .masked_sparse import MaskedSparseSolver
from .rectangular import RectangularFFTSolver
__all__ = ["RectangularFFTSolver", "MaskedSparseSolver", "CapacitanceFFTSolver"]
__all__ = ["CapacitanceFFTSolver", "MaskedSparseSolver", "RectangularFFTSolver"]
+6 -5
View File
@@ -1,16 +1,17 @@
import numpy
import timeit
import numpy
from scipy import signal
from ...backend_availability import (
CUPY_AVAILABLE,
OPENCL_AVAILABLE,
cl,
cl_irfftn,
cl_rfftn,
cla,
cp,
cp_signal,
cl,
cla,
cl_rfftn,
cl_irfftn,
)
from ..timing import Timer
+4 -2
View File
@@ -1,7 +1,9 @@
import numpy
import timeit
class Timer(object):
import numpy
class Timer:
def display(self):
+6 -5
View File
@@ -66,9 +66,10 @@ from __future__ import annotations
import threading
import time
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, field
from queue import Empty, Full, Queue
from typing import Any, Callable, Iterable, Sequence
from typing import Any
import numpy as np
@@ -155,7 +156,7 @@ class StreamPipeline:
contact: bool = False,
depth: int = 2,
include: Sequence[str] | Iterable[str] = ("image",),
on_frame: Callable[["FrameSnapshot"], None] | None = None,
on_frame: Callable[[FrameSnapshot], None] | None = None,
verbose: bool = False,
fps_window: int = 30,
) -> None:
@@ -189,7 +190,7 @@ class StreamPipeline:
# ------------------------------------------------------------------ ctors
@classmethod
def for_finger_runtime(cls, runtime: Any, **kwargs: Any) -> "StreamPipeline":
def for_finger_runtime(cls, runtime: Any, **kwargs: Any) -> StreamPipeline:
"""Build a pipeline for a ``FingerRuntime`` (capture + UV unroll stage)."""
def produce() -> np.ndarray | None:
@@ -199,7 +200,7 @@ class StreamPipeline:
return cls(runtime.tactile.raw_sensor, produce, **kwargs)
# --------------------------------------------------------------- lifecycle
def start(self) -> "StreamPipeline":
def start(self) -> StreamPipeline:
if self._running:
return self
self._running = True
@@ -232,7 +233,7 @@ class StreamPipeline:
err, self._error = self._error, None
raise err
def __enter__(self) -> "StreamPipeline":
def __enter__(self) -> StreamPipeline:
return self.start()
def __exit__(self, exc_type, exc, tb) -> bool:
+20 -18
View File
@@ -7,23 +7,19 @@ and Helmholtz-Hodge decomposition.
"""
# import os # only used by commented-out stitch_cal
import numpy as np
import cv2
import time
from enum import Enum, auto
from pathlib import Path
from typing import Optional, Tuple
# from threading import Thread, Event
# from queue import Queue, Empty
from .Stitcher import Stitcher
from .tracker import Tracker
from .hhd import nHHD
import cv2
import numpy as np
from . import calibration as cal_mod
from . import configs
from .get_depth import Contact3D
from .backend_availability import (check_cupy_available, check_opencl_available)
from .backend_availability import check_cupy_available, check_opencl_available
from .frame_processors import create_frame_processor
from .get_depth import Contact3D
from .hhd import nHHD
from .signal_processing import (
DEFAULT_SAMPLE_RATE_HZ,
CausalMedianFilter,
@@ -31,6 +27,11 @@ from .signal_processing import (
SampleRateEstimator,
)
# from threading import Thread, Event
# from queue import Queue, Empty
from .Stitcher import Stitcher
from .tracker import Tracker
# Default signal-conditioning settings (config: top-level "filters" block).
#
# A key a config does not state is **off**, not "whatever we thought was
@@ -101,6 +102,7 @@ from .capture import (
open_capture,
)
class Sensor:
"""
Optical tactile sensor interface with deformation analysis.
@@ -137,7 +139,7 @@ class Sensor:
def __init__(self, vid_src, cuda=True, isstitch=True, verbose=True, debug=False,
config_name="prototype", cal_path=None, override=False, box=None, mirror=True,
backend='auto', capture_config: Optional[CaptureConfig] = None,
backend='auto', capture_config: CaptureConfig | None = None,
auto_reconnect: bool = False, capture_mode: str = "latest",
capture_queue_size: int = 1, capture_overflow: str = "drop_oldest",
**_deprecated_capture_kwargs):
@@ -215,11 +217,11 @@ class Sensor:
self.n_boxes = None
self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0
self._force_raw = (0.0, 0.0, 0.0)
self.calibration: Optional[cal_mod.Calibration] = None
self._force_cal_sensor_name: Optional[str] = None
self.calibration: cal_mod.Calibration | None = None
self._force_cal_sensor_name: str | None = None
#: Why no calibration was applied, when a bundle existed but carried no
#: usable coefficients. ``None`` means the question never came up.
self._force_cal_attempts: Optional[list[dict]] = None
self._force_cal_attempts: list[dict] | None = None
self._init_force_calibration()
# ---------------------------------------------------------------
@@ -413,7 +415,7 @@ class Sensor:
# self._av_stream = None
# self._av_decoder = None
self.cap = None # compat alias for _capture_worker.source.cap
self._capture_source: Optional[CaptureSource] = None
self._capture_source: CaptureSource | None = None
self._is_live_camera = (
is_live_camera(self.vid_src) and not self._external_only
)
@@ -1013,7 +1015,7 @@ class Sensor:
return result
def read_info(self, *args) -> Tuple:
def read_info(self, *args) -> tuple:
"""
Get sensor data by info type.
@@ -1323,7 +1325,7 @@ class Sensor:
return
try:
attempts.append(configs.describe_bundle_resolution(name))
except Exception: # noqa: BLE001 - diagnostics must never break loading
except Exception: # noqa: BLE001, S112 - diagnostics must never break loading
continue
# Deliberately NOT setting ``_force_cal_sensor_name``: callers read a
# non-None value there as "this sensor is calibrated", and a bundle
@@ -1700,6 +1702,6 @@ class Sensor:
for k, v in timings.items():
if k != 'total':
print(f" {k}: {v:7.2f} ms")
print(f" ─────────────────────────")
print(" ─────────────────────────")
print(f" TOTAL: {timings['total']:7.2f} ms ({1000/timings['total']:.1f} FPS)")
+9 -9
View File
@@ -15,19 +15,19 @@ from .ops import (
)
__all__ = [
"Stitcher",
"StitchStatus",
"BLEND_MODES",
"resolve_blend_mode",
"validate_box_grid",
"get_blend_backend",
"StitchStatus",
"Stitcher",
"available_blend_backends",
"tile_weight_map",
"paste_tile_clipped",
"copy_into_tile_buffer",
"build_blend_coef",
"compute_final_crop_rect",
"copy_into_tile_buffer",
"coverage_ratio",
"get_blend_backend",
"largest_fully_covered_rect",
"match_bgr_brightness",
"coverage_ratio",
"paste_tile_clipped",
"resolve_blend_mode",
"tile_weight_map",
"validate_box_grid",
]
+3 -4
View File
@@ -3,10 +3,12 @@
from __future__ import annotations
import time
from typing import Optional
import numpy as np
from . import boxes as boxes_mod
from . import feature as feature_mod
from . import tiles as tiles_mod
from .backends import get_blend_backend
from .config import (
BLEND_MODES,
@@ -14,10 +16,7 @@ from .config import (
numba_blend_available,
resolve_blend_mode,
)
from . import boxes as boxes_mod
from . import feature as feature_mod
from .ops import build_blend_coef
from . import tiles as tiles_mod
class Stitcher:
+4 -3
View File
@@ -2,12 +2,13 @@
from __future__ import annotations
from typing import Callable, Optional
from collections.abc import Callable
from typing import Optional
from ..config import numba_blend_available
from .base import BlendBackend
from .numba_backend import NumbaBlendBackend
from .numpy_backend import NumpyBlendBackend
from ..config import numba_blend_available
_BACKENDS = {
"numpy": NumpyBlendBackend,
@@ -18,7 +19,7 @@ _BACKENDS = {
def get_blend_backend(
name: str,
*,
on_numba_failure: Optional[Callable[[BaseException], None]] = None,
on_numba_failure: Callable[[BaseException], None] | None = None,
) -> BlendBackend:
"""Return a blend backend instance by name.
+2 -1
View File
@@ -7,7 +7,8 @@ import numpy as np
from .numpy_backend import NumpyBlendBackend, blend_kernel_numpy
try:
from orisys_blend.kernel import _NUMBA_AVAILABLE, blend_kernel as _blend_kernel_numba
from orisys_blend.kernel import _NUMBA_AVAILABLE
from orisys_blend.kernel import blend_kernel as _blend_kernel_numba
except Exception:
_NUMBA_AVAILABLE = False
_blend_kernel_numba = None
+7 -9
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import os
from typing import List, Optional, Sequence, Tuple
from collections.abc import Sequence
import cv2
import numpy as np
@@ -38,13 +38,13 @@ def correct_edge_bloom(img, method: str = "divide", kernel_size=None, clip: bool
def filter_boxes_by_size(
boxes: Sequence[Tuple[int, int, int, int]],
boxes: Sequence[tuple[int, int, int, int]],
img_shape,
*,
min_area_frac: float,
max_area_frac: float,
max_aspect_ratio: float,
) -> List[Tuple[int, int, int, int]]:
) -> list[tuple[int, int, int, int]]:
h, w = img_shape
img_area = h * w
min_area = img_area * float(min_area_frac)
@@ -72,7 +72,7 @@ def detect_nonblack_regions(
show: bool = False,
row_tol: float = 0.4,
save_preview_path: str = "./config/compound_eye.png",
) -> Tuple[int, List[Tuple[int, int, int, int]]]:
) -> tuple[int, list[tuple[int, int, int, int]]]:
"""Detect tile boxes; returns ``(n_boxes, boxes as x0,y0,x1,y1)``."""
if img is None:
raise ValueError("Input image is None.")
@@ -80,7 +80,7 @@ def detect_nonblack_regions(
h, w = img.shape[:2]
n_boxes = 0
bk_thresh = 0
boxes: List[Tuple[int, int, int, int]] = []
boxes: list[tuple[int, int, int, int]] = []
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
v = hsv[:, :, 2]
@@ -114,9 +114,7 @@ def detect_nonblack_regions(
bk_thresh += bk_thresh_step
print(
"Detected {} candidate boxes with bk_thresh={}".format(
n_boxes, bk_thresh - bk_thresh_step
)
f"Detected {n_boxes} candidate boxes with bk_thresh={bk_thresh - bk_thresh_step}"
)
if n_boxes == 0:
return 0, []
@@ -194,7 +192,7 @@ def find_stitch_boxes(
nrow: int,
ncol: int,
show: bool = False,
n_boxes_target: Optional[int] = None,
n_boxes_target: int | None = None,
):
"""Run bloom correction + detection; return ``(n_boxes, boxes, grid_ok)``.
+3 -2
View File
@@ -3,8 +3,9 @@
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Mapping, Optional
from typing import Any
# JSON ``stitcher.blend`` / ``blend_list``: none = fast paste; numpy/numba = feathered.
BLEND_MODES = frozenset({"none", "numpy", "numba"})
@@ -19,7 +20,7 @@ def numba_blend_available() -> bool:
return bool(_NUMBA_AVAILABLE and blend_kernel is not None)
def resolve_blend_mode(config_stitcher: Optional[Mapping[str, Any]] = None):
def resolve_blend_mode(config_stitcher: Mapping[str, Any] | None = None):
"""Resolve stitcher blend mode from env, then JSON ``blend``.
Returns ``(mode, fell_back_from_numba)`` where mode is one of
+4 -3
View File
@@ -2,7 +2,8 @@
from __future__ import annotations
from typing import Any, Dict, Optional, Sequence
from collections.abc import Sequence
from typing import Any
import cv2
import numpy as np
@@ -530,14 +531,14 @@ def stitch_feature_optimized(
nrow: int,
ncol: int,
tile_list: Sequence,
cache: Dict[str, Any],
cache: dict[str, Any],
blend_backend: BlendBackend,
blend: bool = True,
match_brightness: bool = False,
pad: int = 5,
print_every: int = 30,
verbose: bool = False,
frame_idx_holder: Optional[Dict[str, int]] = None,
frame_idx_holder: dict[str, int] | None = None,
):
"""Estimate transforms once and composite the current tile grid."""
plan = get_feature_stitch_plan(isstitch, index, nrow, ncol, tile_list)
+3 -5
View File
@@ -6,8 +6,6 @@ instance state so future Torch/CuPy backends can reuse the same call shapes.
from __future__ import annotations
from typing import Optional, Tuple
import cv2
import numpy as np
@@ -74,7 +72,7 @@ def match_bgr_brightness(tile_bgr, ref_mean_gray: float):
return cv2.LUT(tile_bgr, lut)
def largest_fully_covered_rect(cov) -> Optional[Tuple[int, int, int, int]]:
def largest_fully_covered_rect(cov) -> tuple[int, int, int, int] | None:
if cov.size == 0:
return None
@@ -104,7 +102,7 @@ def largest_fully_covered_rect(cov) -> Optional[Tuple[int, int, int, int]]:
return best_rect
def compute_final_crop_rect(cov) -> Optional[Tuple[int, int, int, int]]:
def compute_final_crop_rect(cov) -> tuple[int, int, int, int] | None:
valid = cov > 0
if not np.any(valid):
return None
@@ -126,7 +124,7 @@ def coverage_ratio(cov) -> float:
return float(np.mean(cov > 0))
def translation_from_H_to_ref(H) -> Tuple[float, float]:
def translation_from_H_to_ref(H) -> tuple[float, float]:
if H is None:
return 0.0, 0.0
H = np.asarray(H, dtype=np.float64)
+7 -7
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Iterable, List, Optional, Sequence, Tuple
from collections.abc import Iterable, Sequence
import numpy as np
@@ -11,10 +11,10 @@ from .ops import copy_into_tile_buffer
def extract_units_from_boxes(
img,
boxes: Sequence[Tuple[int, int, int, int]],
boxes: Sequence[tuple[int, int, int, int]],
*,
selected_index: Iterable[int],
units: Optional[list] = None,
units: list | None = None,
flip_x: bool = True,
flip_y: bool = True,
) -> list:
@@ -49,7 +49,7 @@ def extract_units_from_boxes(
return flipped_units
def get_tile_by_index(units: Sequence, idx) -> List:
def get_tile_by_index(units: Sequence, idx) -> list:
if isinstance(idx, int):
idx = [idx]
if len(idx) > len(units):
@@ -61,7 +61,7 @@ def get_tile_by_index(units: Sequence, idx) -> List:
return tile_list
def get_tile_index_by_position(units: Sequence, x, y, tol: int = 20) -> Optional[int]:
def get_tile_index_by_position(units: Sequence, x, y, tol: int = 20) -> int | None:
i = 0
for xi, yi, _tile in units:
if abs(int(x) - int(xi)) <= tol and abs(int(y) - int(yi)) <= tol:
@@ -70,7 +70,7 @@ def get_tile_index_by_position(units: Sequence, x, y, tol: int = 20) -> Optional
return None
def get_unit_pos(units: Sequence, idx) -> List[Tuple[int, int]]:
def get_unit_pos(units: Sequence, idx) -> list[tuple[int, int]]:
if isinstance(idx, int):
idx = [idx]
if len(idx) > len(units):
@@ -78,7 +78,7 @@ def get_unit_pos(units: Sequence, idx) -> List[Tuple[int, int]]:
return [(units[i][0], units[i][1]) for i in idx]
def normalize_index(index) -> Tuple[int, ...]:
def normalize_index(index) -> tuple[int, ...]:
if isinstance(index, int):
return (index,)
if isinstance(index, np.ndarray):
+2 -2
View File
@@ -1,6 +1,7 @@
import time
import cv2
import numpy as np
import time
class Tracker:
@@ -255,7 +256,6 @@ class Tracker:
"""
Setup a simpler version using available models with performance optimizations
"""
import torchvision.transforms as T
from torchvision.models.optical_flow import raft_large
# Load model and move to GPU
model = raft_large(pretrained=True, progress=False).to(self.__raft_device)
+11 -2
View File
@@ -6,7 +6,17 @@ Runtime: live camera frame -> FrameUnwarper -> Sensor (with valid_mask)
"""
from .domain import UVDomain
from .mesh_uv import align_frame_to_uv_map, barycentric_grid, face_uvs_from_loop_data, uv_triangle_to_image
from .geometry import (
resolve_uv_runtime_sizes,
scale_camera_geometry,
scaled_map_cache_path,
)
from .mesh_uv import (
align_frame_to_uv_map,
barycentric_grid,
face_uvs_from_loop_data,
uv_triangle_to_image,
)
from .precompute import (
build_or_load_domain,
build_uv_domain,
@@ -15,7 +25,6 @@ from .precompute import (
load_exported_data,
recommend_edge_expand,
)
from .geometry import resolve_uv_runtime_sizes, scale_camera_geometry, scaled_map_cache_path
from .remap import UVRemap, flatten_frame, remap_valid_ratio
from .surface import UVSurfaceMap
+1 -2
View File
@@ -5,7 +5,6 @@ build_or_load_domain, tests, and 3D viewer tools.
"""
from dataclasses import dataclass
from typing import Optional
import numpy as np
@@ -22,7 +21,7 @@ class UVDomain:
"""Image-to-UV remap plus optional UV-to-world surface map."""
remap: UVRemap
surface: Optional[UVSurfaceMap] = None
surface: UVSurfaceMap | None = None
@classmethod
def from_dict(cls, data: dict) -> "UVDomain":
+1 -1
View File
@@ -31,7 +31,7 @@ def load_calib_json(json_path):
returns: (K, dist_coeffs, (width, height))
"""
with open(json_path, "r", encoding="utf-8") as f:
with open(json_path, encoding="utf-8") as f:
calib = json.load(f)
K = np.array(calib["camera_matrix"], dtype=np.float64)
dist = np.array(calib["distortion"], dtype=np.float64).flatten()
+5 -5
View File
@@ -5,7 +5,7 @@ convert source camera frames into the UV flatten domain.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Tuple
from typing import Any
import cv2
import numpy as np
@@ -18,9 +18,9 @@ class UVRemap:
map_x: np.ndarray
map_y: np.ndarray
valid_mask: np.ndarray
metadata: Dict[str, Any] = field(default_factory=dict)
_map1_fixed: Optional[np.ndarray] = field(default=None, repr=False, compare=False)
_map2_fixed: Optional[np.ndarray] = field(default=None, repr=False, compare=False)
metadata: dict[str, Any] = field(default_factory=dict)
_map1_fixed: np.ndarray | None = field(default=None, repr=False, compare=False)
_map2_fixed: np.ndarray | None = field(default=None, repr=False, compare=False)
_border_only_invalid: bool = field(default=False, repr=False, compare=False)
@property
@@ -153,7 +153,7 @@ def _cache_matches(
return True
def _remap_maps(remap) -> Tuple[np.ndarray, np.ndarray, np.ndarray, bool]:
def _remap_maps(remap) -> tuple[np.ndarray, np.ndarray, np.ndarray, bool]:
if isinstance(remap, UVRemap):
remap.ensure_fast_maps()
valid_mask = remap.valid_mask
+4 -6
View File
@@ -22,7 +22,6 @@ import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import cv2
import numpy as np
@@ -35,7 +34,6 @@ for path in (REPO_ROOT, SRC_ROOT):
from orisys.tracker import Tracker # noqa: E402
DEFAULT_CONFIG = "box"
@@ -125,7 +123,7 @@ class FixedRefDIS:
preset = dis_preset_from_name(config_tracker.get("dis_preset", "medium"))
self._dis = cv2.DISOpticalFlow_create(preset)
self._dis.setFinestScale(int(config_tracker.get("dis_mode", 3)))
self._ref: Optional[np.ndarray] = None
self._ref: np.ndarray | None = None
def reset(self) -> None:
self._ref = None
@@ -148,7 +146,7 @@ def make_tracker(config_tracker: dict, *, adaptive: bool) -> Tracker:
)
def read_gray_frame(cap: cv2.VideoCapture, static_image: Optional[np.ndarray]) -> tuple[bool, Optional[np.ndarray]]:
def read_gray_frame(cap: cv2.VideoCapture, static_image: np.ndarray | None) -> tuple[bool, np.ndarray | None]:
if static_image is not None:
return True, static_image.copy()
ok, frame = cap.read()
@@ -164,11 +162,11 @@ def run_comparison(
*,
config_tracker: dict,
max_frames: int,
reset_every: Optional[int],
reset_every: int | None,
show: bool,
) -> dict[str, ModeSeries]:
path = Path(str(source)) if isinstance(source, str) else None
static_image: Optional[np.ndarray] = None
static_image: np.ndarray | None = None
if isinstance(source, str) and path is not None and path.is_file():
img = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)
if img is not None:
+3 -1
View File
@@ -114,7 +114,9 @@ def test_config_name() -> str:
try:
if _is_usable_config(name):
return name
except Exception:
except Exception: # noqa: S112 - a failing config is simply not a candidate
# Probe: a config that cannot be loaded or lacks the key is simply not
# a candidate. Any such failure is reported if no config survives.
continue
raise RuntimeError(
+1
View File
@@ -20,6 +20,7 @@ os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import numpy as np
import orisys
_REPO_ROOT = Path(__file__).resolve().parents[1]
+1 -1
View File
@@ -10,8 +10,8 @@ import pytest
from orisys import Sensor
from orisys.calibration import (
CalSample,
CalibrationError,
CalSample,
build,
check,
filter_mismatches,
+1 -2
View File
@@ -22,9 +22,8 @@ from orisys.capture import (
open_capture,
pick_default_camera,
)
from orisys.capture.source import _apply_stream_props, _warmup_capture
from orisys.capture.discovery import DEFAULT_LAPTOP_WEBCAM_PATTERNS
from orisys.capture.source import _apply_stream_props, _warmup_capture
MJPG = cv2.VideoWriter_fourcc(*"MJPG")
YUY2 = cv2.VideoWriter_fourcc(*"YUY2")
+1 -1
View File
@@ -6,8 +6,8 @@ if str(_EXAMPLES) not in sys.path:
sys.path.insert(0, str(_EXAMPLES))
from viz.contact_arrow import (
ContactArrowStyle,
DEFAULT_CONTACT_ARROW_STYLE,
ContactArrowStyle,
force_visual_strength,
length_from_force,
thickness_scale_from_force,
-1
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import cv2
import numpy as np
import pytest
from orisys.util import _prepare_draw_mask, draw_arrows
-2
View File
@@ -11,7 +11,6 @@ import pytest
from orisys import FrameSnapshot, StreamRecorder, mosaic_videos
_DEFAULT = object()
@@ -156,7 +155,6 @@ def test_valid_mask_callable_resolved_per_frame(tmp_path):
def mask():
calls.append(1)
return None
path = str(tmp_path / "s0.mp4")
rec = StreamRecorder(path, grid_spacing=8, overlay_values=False, valid_mask=mask)
+1 -1
View File
@@ -16,9 +16,9 @@ from pathlib import Path
import pytest
from orisys import Sensor
from orisys import configs as orisys_configs
from orisys.calibration import filters_fingerprint
from orisys.sensor import FILTER_PRESETS, available_filter_presets
from orisys import configs as orisys_configs
REPO_ROOT = Path(__file__).resolve().parents[1]
EXAMPLES = REPO_ROOT / "examples" / "filters"
+5 -1
View File
@@ -4,7 +4,11 @@ import numpy as np
import pytest
import orisys
from orisys.finger import TactileProcessor, load_finger_export_bundle, resolve_export_paths
from orisys.finger import (
TactileProcessor,
load_finger_export_bundle,
resolve_export_paths,
)
from orisys.finger.exports import DEFAULT_FINGER_MAP_CACHE
from orisys.finger.runtime import resolve_finger_geometry
-1
View File
@@ -6,7 +6,6 @@ import pytest
from orisys.backend_availability import CUPY_AVAILABLE
from orisys.hhd import PoissonSolver, nHHD
REPO_ROOT = Path(__file__).resolve().parents[1]
+5 -1
View File
@@ -12,7 +12,11 @@ def packaging_utils():
build_dir = REPO_ROOT / "build"
if str(build_dir) not in sys.path:
sys.path.insert(0, str(build_dir))
from common.pyproject_utils import extras_require, install_requires, project_metadata
from common.pyproject_utils import (
extras_require,
install_requires,
project_metadata,
)
return install_requires, extras_require, project_metadata

Some files were not shown because too many files have changed in this diff Show More