From e2ac36269c4d120e7493b2ebb676cc1292ece422 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sun, 27 Sep 2026 19:21:55 +0800 Subject: [PATCH] added ruff code reivew --- build/common/verify/cli.py | 5 +- build/common/verify/wheel_contents.py | 15 +- build/gui/common/pyinstaller_driver.py | 2 +- build/gui/ory_box/config.py | 1 + build/gui/ory_finger/build.py | 1 + build/gui/ory_finger/config.py | 1 + build/gui/ory_mini/config.py | 1 + build/release/cli.py | 1 + build/release/github.py | 1 + build/release/preflight.py | 6 +- build/release/pypi.py | 4 +- build/wheel/build.py | 13 +- docs/code-review.md | 74 +++++++ examples/basic_pipeline.py | 10 +- examples/finger_pipeline.py | 1 + examples/gui/__init__.py | 19 +- examples/gui/app_paths.py | 2 +- examples/gui/sensors/__init__.py | 6 +- examples/gui/sensors/adapter.py | 29 ++- examples/gui/viewer2d/backend.py | 33 +-- examples/gui/viewer3d/__init__.py | 12 +- examples/gui/viewer3d/camera_discovery.py | 29 ++- examples/gui/viewer3d/dashboard.py | 17 +- examples/gui/viewer3d/layout.py | 5 +- examples/gui/viewer3d/mesh_view_state.py | 2 +- examples/gui/viewer3d/widgets/arrow_image.py | 6 +- examples/gui/viewer3d/widgets/force_charts.py | 8 +- .../gui/viewer3d/widgets/mesh_viewport.py | 10 +- .../gui/viewer3d/widgets/settings_panel.py | 8 +- examples/mini_pipeline.py | 20 +- examples/multi_stream.py | 5 +- examples/pipeline/config.py | 5 +- examples/pipeline/csv_recorder.py | 1 - examples/pipeline/service.py | 4 +- examples/plot_data.py | 4 +- examples/two_sensor_pipeline.py | 34 ++- examples/viz/__init__.py | 18 +- examples/viz/finger_mesh_viewer.py | 6 +- examples/viz/flow_arrow.py | 3 +- examples/viz/mesh_viewer.py | 6 +- examples/viz/recording.py | 1 - examples/viz/render_2d.py | 1 + examples/viz/signal_filters.py | 1 + ruff.toml | 55 +++++ scripts/benchmark.py | 18 +- scripts/calibration_script.py | 11 +- scripts/example_uart.py | 25 ++- scripts/measure_zero_drift.py | 5 +- scripts/promote_video.py | 14 +- src/orisys/Stitcher.py | 4 +- src/orisys/__init__.py | 11 +- src/orisys/backend_availability.py | 2 +- src/orisys/calibration.py | 3 +- src/orisys/capture/__init__.py | 20 +- src/orisys/capture/config.py | 35 ++-- src/orisys/capture/discovery.py | 72 ++++--- src/orisys/capture/source.py | 60 +++--- src/orisys/configs.py | 4 +- src/orisys/export.py | 6 +- src/orisys/finger/__init__.py | 4 +- src/orisys/finger/mesh.py | 16 +- src/orisys/finger/runtime.py | 2 +- src/orisys/frame_processors.py | 2 +- src/orisys/get_depth.py | 3 +- src/orisys/hhd/__init__.py | 2 +- src/orisys/hhd/api.py | 10 +- src/orisys/hhd/domain.py | 24 ++- src/orisys/hhd/grid.py | 15 +- src/orisys/hhd/poisson.py | 2 +- src/orisys/hhd/solvers/__init__.py | 2 +- src/orisys/hhd/solvers/rectangular.py | 11 +- src/orisys/hhd/timing.py | 6 +- src/orisys/pipeline.py | 11 +- src/orisys/sensor.py | 40 ++-- src/orisys/stitch/__init__.py | 18 +- src/orisys/stitch/api.py | 7 +- src/orisys/stitch/backends/__init__.py | 7 +- src/orisys/stitch/backends/numba_backend.py | 3 +- src/orisys/stitch/boxes.py | 16 +- src/orisys/stitch/config.py | 5 +- src/orisys/stitch/feature.py | 7 +- src/orisys/stitch/ops.py | 8 +- src/orisys/stitch/tiles.py | 14 +- src/orisys/tracker.py | 4 +- src/orisys/unroll/__init__.py | 13 +- src/orisys/unroll/domain.py | 3 +- src/orisys/unroll/precompute.py | 2 +- src/orisys/unroll/remap.py | 10 +- tests/DIS_no_integrate.py | 10 +- tests/conftest.py | 4 +- tests/dd06.py | 1 + tests/test_calibration.py | 2 +- tests/test_capture.py | 3 +- tests/test_contact_arrow_scale.py | 2 +- tests/test_draw_arrows.py | 1 - tests/test_export.py | 2 - tests/test_filter_presets.py | 2 +- tests/test_finger_runtime.py | 6 +- tests/test_masked_poisson_capfft.py | 1 - tests/test_pyproject.py | 6 +- tests/test_qt_viewer_backend.py | 13 +- tests/test_release_version.py | 2 - tests/test_stitch.py | 4 +- tests/test_uv_mesh_viewer.py | 1 - tests/test_uv_remap.py | 9 +- tests/test_uv_unroll_processor.py | 1 - tests/test_wheel_setup_metadata.py | 8 +- tools/ffc/roi_polygon_tool.py | 1 - tools/lint_gate.py | 193 ++++++++++++++++++ tools/sync_downstreams.py | 1 - 110 files changed, 890 insertions(+), 426 deletions(-) create mode 100644 docs/code-review.md create mode 100644 ruff.toml create mode 100644 tools/lint_gate.py diff --git a/build/common/verify/cli.py b/build/common/verify/cli.py index ec9c56b..27cf712 100644 --- a/build/common/verify/cli.py +++ b/build/common/verify/cli.py @@ -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: diff --git a/build/common/verify/wheel_contents.py b/build/common/verify/wheel_contents.py index 52baa2a..1857ea4 100644 --- a/build/common/verify/wheel_contents.py +++ b/build/common/verify/wheel_contents.py @@ -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", diff --git a/build/gui/common/pyinstaller_driver.py b/build/gui/common/pyinstaller_driver.py index ac8dddd..9674115 100644 --- a/build/gui/common/pyinstaller_driver.py +++ b/build/gui/common/pyinstaller_driver.py @@ -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: diff --git a/build/gui/ory_box/config.py b/build/gui/ory_box/config.py index 10eeaff..6a8569a 100644 --- a/build/gui/ory_box/config.py +++ b/build/gui/ory_box/config.py @@ -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" diff --git a/build/gui/ory_finger/build.py b/build/gui/ory_finger/build.py index 46c32fc..a98e4e9 100644 --- a/build/gui/ory_finger/build.py +++ b/build/gui/ory_finger/build.py @@ -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 diff --git a/build/gui/ory_finger/config.py b/build/gui/ory_finger/config.py index 39f6ecd..fe8926f 100644 --- a/build/gui/ory_finger/config.py +++ b/build/gui/ory_finger/config.py @@ -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" diff --git a/build/gui/ory_mini/config.py b/build/gui/ory_mini/config.py index 151aef1..ff599a9 100644 --- a/build/gui/ory_mini/config.py +++ b/build/gui/ory_mini/config.py @@ -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" diff --git a/build/release/cli.py b/build/release/cli.py index 74bf622..867f9be 100644 --- a/build/release/cli.py +++ b/build/release/cli.py @@ -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 diff --git a/build/release/github.py b/build/release/github.py index 0527755..518ee45 100644 --- a/build/release/github.py +++ b/build/release/github.py @@ -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" diff --git a/build/release/preflight.py b/build/release/preflight.py index 11bc0ae..f0bc9b1 100644 --- a/build/release/preflight.py +++ b/build/release/preflight.py @@ -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 diff --git a/build/release/pypi.py b/build/release/pypi.py index 98e7355..5292bf4 100644 --- a/build/release/pypi.py +++ b/build/release/pypi.py @@ -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 diff --git a/build/wheel/build.py b/build/wheel/build.py index 42ed659..b5dfabc 100644 --- a/build/wheel/build.py +++ b/build/wheel/build.py @@ -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 diff --git a/docs/code-review.md b/docs/code-review.md new file mode 100644 index 0000000..66c7191 --- /dev/null +++ b/docs/code-review.md @@ -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 - `. 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 - `; `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 --fix # never --unsafe-fixes +ruff check --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 +``` + +`$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`. diff --git a/examples/basic_pipeline.py b/examples/basic_pipeline.py index 0c16a7d..7f32846 100644 --- a/examples/basic_pipeline.py +++ b/examples/basic_pipeline.py @@ -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="视频源:摄像头编号或视频文件路径") diff --git a/examples/finger_pipeline.py b/examples/finger_pipeline.py index fc92862..63af80b 100644 --- a/examples/finger_pipeline.py +++ b/examples/finger_pipeline.py @@ -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 diff --git a/examples/gui/__init__.py b/examples/gui/__init__.py index 562677c..fd93ee9 100644 --- a/examples/gui/__init__.py +++ b/examples/gui/__init__.py @@ -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", ] diff --git a/examples/gui/app_paths.py b/examples/gui/app_paths.py index 667bba7..8f4ddf3 100644 --- a/examples/gui/app_paths.py +++ b/examples/gui/app_paths.py @@ -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 diff --git a/examples/gui/sensors/__init__.py b/examples/gui/sensors/__init__.py index e275bd6..73f7e93 100644 --- a/examples/gui/sensors/__init__.py +++ b/examples/gui/sensors/__init__.py @@ -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", ] diff --git a/examples/gui/sensors/adapter.py b/examples/gui/sensors/adapter.py index 8386494..23d474f 100644 --- a/examples/gui/sensors/adapter.py +++ b/examples/gui/sensors/adapter.py @@ -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 diff --git a/examples/gui/viewer2d/backend.py b/examples/gui/viewer2d/backend.py index 06d92c1..13f8732 100644 --- a/examples/gui/viewer2d/backend.py +++ b/examples/gui/viewer2d/backend.py @@ -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() diff --git a/examples/gui/viewer3d/__init__.py b/examples/gui/viewer3d/__init__.py index 90ae850..976105e 100644 --- a/examples/gui/viewer3d/__init__.py +++ b/examples/gui/viewer3d/__init__.py @@ -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", ] diff --git a/examples/gui/viewer3d/camera_discovery.py b/examples/gui/viewer3d/camera_discovery.py index b161abe..beabc5e 100644 --- a/examples/gui/viewer3d/camera_discovery.py +++ b/examples/gui/viewer3d/camera_discovery.py @@ -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: diff --git a/examples/gui/viewer3d/dashboard.py b/examples/gui/viewer3d/dashboard.py index b95e623..26c340c 100644 --- a/examples/gui/viewer3d/dashboard.py +++ b/examples/gui/viewer3d/dashboard.py @@ -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, diff --git a/examples/gui/viewer3d/layout.py b/examples/gui/viewer3d/layout.py index 1e74181..55c63e9 100644 --- a/examples/gui/viewer3d/layout.py +++ b/examples/gui/viewer3d/layout.py @@ -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"] diff --git a/examples/gui/viewer3d/mesh_view_state.py b/examples/gui/viewer3d/mesh_view_state.py index bfe4f04..fe409c6 100644 --- a/examples/gui/viewer3d/mesh_view_state.py +++ b/examples/gui/viewer3d/mesh_view_state.py @@ -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): diff --git a/examples/gui/viewer3d/widgets/arrow_image.py b/examples/gui/viewer3d/widgets/arrow_image.py index 968afd5..cd44e52 100644 --- a/examples/gui/viewer3d/widgets/arrow_image.py +++ b/examples/gui/viewer3d/widgets/arrow_image.py @@ -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.""" diff --git a/examples/gui/viewer3d/widgets/force_charts.py b/examples/gui/viewer3d/widgets/force_charts.py index 3557355..646a0a3 100644 --- a/examples/gui/viewer3d/widgets/force_charts.py +++ b/examples/gui/viewer3d/widgets/force_charts.py @@ -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: diff --git a/examples/gui/viewer3d/widgets/mesh_viewport.py b/examples/gui/viewer3d/widgets/mesh_viewport.py index 14672ee..a410966 100644 --- a/examples/gui/viewer3d/widgets/mesh_viewport.py +++ b/examples/gui/viewer3d/widgets/mesh_viewport.py @@ -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 diff --git a/examples/gui/viewer3d/widgets/settings_panel.py b/examples/gui/viewer3d/widgets/settings_panel.py index 094651f..62e853e 100644 --- a/examples/gui/viewer3d/widgets/settings_panel.py +++ b/examples/gui/viewer3d/widgets/settings_panel.py @@ -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 diff --git a/examples/mini_pipeline.py b/examples/mini_pipeline.py index ea9bc22..085f3b4 100644 --- a/examples/mini_pipeline.py +++ b/examples/mini_pipeline.py @@ -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' 重置追踪器。") diff --git a/examples/multi_stream.py b/examples/multi_stream.py index 7944929..5d3f6e0 100644 --- a/examples/multi_stream.py +++ b/examples/multi_stream.py @@ -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) diff --git a/examples/pipeline/config.py b/examples/pipeline/config.py index 9806cb6..9574a3d 100644 --- a/examples/pipeline/config.py +++ b/examples/pipeline/config.py @@ -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, diff --git a/examples/pipeline/csv_recorder.py b/examples/pipeline/csv_recorder.py index a8d961c..db24a02 100644 --- a/examples/pipeline/csv_recorder.py +++ b/examples/pipeline/csv_recorder.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path - CSV_COLUMNS = ( "frame_index", "timestamp_s", diff --git a/examples/pipeline/service.py b/examples/pipeline/service.py index 677e732..49d0f69 100644 --- a/examples/pipeline/service.py +++ b/examples/pipeline/service.py @@ -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 diff --git a/examples/plot_data.py b/examples/plot_data.py index cdbb002..bdde4e4 100644 --- a/examples/plot_data.py +++ b/examples/plot_data.py @@ -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: diff --git a/examples/two_sensor_pipeline.py b/examples/two_sensor_pipeline.py index 613eac3..c20fbfe 100644 --- a/examples/two_sensor_pipeline.py +++ b/examples/two_sensor_pipeline.py @@ -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: diff --git a/examples/viz/__init__.py b/examples/viz/__init__.py index b2656b8..02ebed4 100644 --- a/examples/viz/__init__.py +++ b/examples/viz/__init__.py @@ -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", diff --git a/examples/viz/finger_mesh_viewer.py b/examples/viz/finger_mesh_viewer.py index 11c3ec2..cd2e62e 100644 --- a/examples/viz/finger_mesh_viewer.py +++ b/examples/viz/finger_mesh_viewer.py @@ -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 diff --git a/examples/viz/flow_arrow.py b/examples/viz/flow_arrow.py index e31ee29..e38ff01 100644 --- a/examples/viz/flow_arrow.py +++ b/examples/viz/flow_arrow.py @@ -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 diff --git a/examples/viz/mesh_viewer.py b/examples/viz/mesh_viewer.py index 626600f..850c50d 100644 --- a/examples/viz/mesh_viewer.py +++ b/examples/viz/mesh_viewer.py @@ -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, +) diff --git a/examples/viz/recording.py b/examples/viz/recording.py index 054c2d2..e3cccb4 100644 --- a/examples/viz/recording.py +++ b/examples/viz/recording.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from pathlib import Path import cv2 diff --git a/examples/viz/render_2d.py b/examples/viz/render_2d.py index 9d43926..e1cc5de 100644 --- a/examples/viz/render_2d.py +++ b/examples/viz/render_2d.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Literal import cv2 import numpy as np + import orisys if TYPE_CHECKING: diff --git a/examples/viz/signal_filters.py b/examples/viz/signal_filters.py index ff58d53..5743f40 100644 --- a/examples/viz/signal_filters.py +++ b/examples/viz/signal_filters.py @@ -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) diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..fe27644 --- /dev/null +++ b/ruff.toml @@ -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 - ` 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 - ` 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" diff --git a/scripts/benchmark.py b/scripts/benchmark.py index f37ec5a..30fd942 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -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: 从摄像头读取(取消注释使用) diff --git a/scripts/calibration_script.py b/scripts/calibration_script.py index 87955b3..e65e38e 100644 --- a/scripts/calibration_script.py +++ b/scripts/calibration_script.py @@ -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 diff --git a/scripts/example_uart.py b/scripts/example_uart.py index 77f5c82..c7eebaf 100644 --- a/scripts/example_uart.py +++ b/scripts/example_uart.py @@ -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() diff --git a/scripts/measure_zero_drift.py b/scripts/measure_zero_drift.py index d5b2978..26a1fed 100644 --- a/scripts/measure_zero_drift.py +++ b/scripts/measure_zero_drift.py @@ -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: diff --git a/scripts/promote_video.py b/scripts/promote_video.py index 56a80b9..733cb47 100644 --- a/scripts/promote_video.py +++ b/scripts/promote_video.py @@ -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) diff --git a/src/orisys/Stitcher.py b/src/orisys/Stitcher.py index f3444d7..82b8aa2 100644 --- a/src/orisys/Stitcher.py +++ b/src/orisys/Stitcher.py @@ -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 diff --git a/src/orisys/__init__.py b/src/orisys/__init__.py index d701f5b..17bdb29 100644 --- a/src/orisys/__init__.py +++ b/src/orisys/__init__.py @@ -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 diff --git a/src/orisys/backend_availability.py b/src/orisys/backend_availability.py index 24c9475..e74e6ba 100644 --- a/src/orisys/backend_availability.py +++ b/src/orisys/backend_availability.py @@ -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 diff --git a/src/orisys/calibration.py b/src/orisys/calibration.py index 7075dd3..3442047 100644 --- a/src/orisys/calibration.py +++ b/src/orisys/calibration.py @@ -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) diff --git a/src/orisys/capture/__init__.py b/src/orisys/capture/__init__.py index a2f8148..98d6505 100644 --- a/src/orisys/capture/__init__.py +++ b/src/orisys/capture/__init__.py @@ -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", ] diff --git a/src/orisys/capture/config.py b/src/orisys/capture/config.py index 536c00d..de4f097 100644 --- a/src/orisys/capture/config.py +++ b/src/orisys/capture/config.py @@ -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 diff --git a/src/orisys/capture/discovery.py b/src/orisys/capture/discovery.py index 0ae7285..18ca617 100644 --- a/src/orisys/capture/discovery.py +++ b/src/orisys/capture/discovery.py @@ -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: diff --git a/src/orisys/capture/source.py b/src/orisys/capture/source.py index 6e16983..d758c4c 100644 --- a/src/orisys/capture/source.py +++ b/src/orisys/capture/source.py @@ -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. diff --git a/src/orisys/configs.py b/src/orisys/configs.py index 63a6bd7..8adba21 100644 --- a/src/orisys/configs.py +++ b/src/orisys/configs.py @@ -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 diff --git a/src/orisys/export.py b/src/orisys/export.py index 98f577f..67c4fa3 100644 --- a/src/orisys/export.py +++ b/src/orisys/export.py @@ -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: diff --git a/src/orisys/finger/__init__.py b/src/orisys/finger/__init__.py index b352e08..2ab10af 100644 --- a/src/orisys/finger/__init__.py +++ b/src/orisys/finger/__init__.py @@ -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", diff --git a/src/orisys/finger/mesh.py b/src/orisys/finger/mesh.py index 36e2842..13dc9d1 100644 --- a/src/orisys/finger/mesh.py +++ b/src/orisys/finger/mesh.py @@ -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__( diff --git a/src/orisys/finger/runtime.py b/src/orisys/finger/runtime.py index e17e4cb..acae2f1 100644 --- a/src/orisys/finger/runtime.py +++ b/src/orisys/finger/runtime.py @@ -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: diff --git a/src/orisys/frame_processors.py b/src/orisys/frame_processors.py index f432c60..0987541 100644 --- a/src/orisys/frame_processors.py +++ b/src/orisys/frame_processors.py @@ -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 diff --git a/src/orisys/get_depth.py b/src/orisys/get_depth.py index b596c26..5211ae3 100644 --- a/src/orisys/get_depth.py +++ b/src/orisys/get_depth.py @@ -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 = {} diff --git a/src/orisys/hhd/__init__.py b/src/orisys/hhd/__init__.py index 81abd69..4a0abe9 100644 --- a/src/orisys/hhd/__init__.py +++ b/src/orisys/hhd/__init__.py @@ -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"] diff --git a/src/orisys/hhd/api.py b/src/orisys/hhd/api.py index 0c4ef9b..a7cf2a6 100644 --- a/src/orisys/hhd/api.py +++ b/src/orisys/hhd/api.py @@ -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) diff --git a/src/orisys/hhd/domain.py b/src/orisys/hhd/domain.py index 7da6d6e..97c4861 100644 --- a/src/orisys/hhd/domain.py +++ b/src/orisys/hhd/domain.py @@ -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__( diff --git a/src/orisys/hhd/grid.py b/src/orisys/hhd/grid.py index dc768ba..c15417d 100644 --- a/src/orisys/hhd/grid.py +++ b/src/orisys/hhd/grid.py @@ -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: # -------------------------------------------------------------------------- # -------------------------------------------------------------------------- diff --git a/src/orisys/hhd/poisson.py b/src/orisys/hhd/poisson.py index 793475e..905ba55 100644 --- a/src/orisys/hhd/poisson.py +++ b/src/orisys/hhd/poisson.py @@ -50,7 +50,7 @@ from .timing import Timer ''' -class PoissonSolver(object): +class PoissonSolver: def __init__(self, **kwargs): args = kwargs.keys() diff --git a/src/orisys/hhd/solvers/__init__.py b/src/orisys/hhd/solvers/__init__.py index 565d16f..fb24ff7 100644 --- a/src/orisys/hhd/solvers/__init__.py +++ b/src/orisys/hhd/solvers/__init__.py @@ -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"] diff --git a/src/orisys/hhd/solvers/rectangular.py b/src/orisys/hhd/solvers/rectangular.py index 6782b70..063ae39 100644 --- a/src/orisys/hhd/solvers/rectangular.py +++ b/src/orisys/hhd/solvers/rectangular.py @@ -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 diff --git a/src/orisys/hhd/timing.py b/src/orisys/hhd/timing.py index 9253c5a..9a9075e 100644 --- a/src/orisys/hhd/timing.py +++ b/src/orisys/hhd/timing.py @@ -1,7 +1,9 @@ -import numpy import timeit -class Timer(object): +import numpy + + +class Timer: def display(self): diff --git a/src/orisys/pipeline.py b/src/orisys/pipeline.py index 11e1292..236d0ee 100644 --- a/src/orisys/pipeline.py +++ b/src/orisys/pipeline.py @@ -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: diff --git a/src/orisys/sensor.py b/src/orisys/sensor.py index c31c82f..d2f3291 100644 --- a/src/orisys/sensor.py +++ b/src/orisys/sensor.py @@ -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 . import configs +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)") diff --git a/src/orisys/stitch/__init__.py b/src/orisys/stitch/__init__.py index bcf579b..63fce37 100644 --- a/src/orisys/stitch/__init__.py +++ b/src/orisys/stitch/__init__.py @@ -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", ] diff --git a/src/orisys/stitch/api.py b/src/orisys/stitch/api.py index 0038dff..1fb7214 100644 --- a/src/orisys/stitch/api.py +++ b/src/orisys/stitch/api.py @@ -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: diff --git a/src/orisys/stitch/backends/__init__.py b/src/orisys/stitch/backends/__init__.py index c251c75..f36004a 100644 --- a/src/orisys/stitch/backends/__init__.py +++ b/src/orisys/stitch/backends/__init__.py @@ -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. diff --git a/src/orisys/stitch/backends/numba_backend.py b/src/orisys/stitch/backends/numba_backend.py index 17a73af..f46ec98 100644 --- a/src/orisys/stitch/backends/numba_backend.py +++ b/src/orisys/stitch/backends/numba_backend.py @@ -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 diff --git a/src/orisys/stitch/boxes.py b/src/orisys/stitch/boxes.py index c69a4a6..72ab9c8 100644 --- a/src/orisys/stitch/boxes.py +++ b/src/orisys/stitch/boxes.py @@ -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)``. diff --git a/src/orisys/stitch/config.py b/src/orisys/stitch/config.py index 77cbe0d..03cff57 100644 --- a/src/orisys/stitch/config.py +++ b/src/orisys/stitch/config.py @@ -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 diff --git a/src/orisys/stitch/feature.py b/src/orisys/stitch/feature.py index a754fe8..1dee723 100644 --- a/src/orisys/stitch/feature.py +++ b/src/orisys/stitch/feature.py @@ -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) diff --git a/src/orisys/stitch/ops.py b/src/orisys/stitch/ops.py index bc23a1f..9325854 100644 --- a/src/orisys/stitch/ops.py +++ b/src/orisys/stitch/ops.py @@ -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) diff --git a/src/orisys/stitch/tiles.py b/src/orisys/stitch/tiles.py index 60c32be..4cf81fa 100644 --- a/src/orisys/stitch/tiles.py +++ b/src/orisys/stitch/tiles.py @@ -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): diff --git a/src/orisys/tracker.py b/src/orisys/tracker.py index 826e240..a461cc4 100644 --- a/src/orisys/tracker.py +++ b/src/orisys/tracker.py @@ -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) diff --git a/src/orisys/unroll/__init__.py b/src/orisys/unroll/__init__.py index 4dd2940..63e6fcf 100644 --- a/src/orisys/unroll/__init__.py +++ b/src/orisys/unroll/__init__.py @@ -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 diff --git a/src/orisys/unroll/domain.py b/src/orisys/unroll/domain.py index 97c6a28..906c43c 100644 --- a/src/orisys/unroll/domain.py +++ b/src/orisys/unroll/domain.py @@ -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": diff --git a/src/orisys/unroll/precompute.py b/src/orisys/unroll/precompute.py index 2bd7059..b644b3c 100644 --- a/src/orisys/unroll/precompute.py +++ b/src/orisys/unroll/precompute.py @@ -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() diff --git a/src/orisys/unroll/remap.py b/src/orisys/unroll/remap.py index 01767d1..56f430c 100644 --- a/src/orisys/unroll/remap.py +++ b/src/orisys/unroll/remap.py @@ -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 diff --git a/tests/DIS_no_integrate.py b/tests/DIS_no_integrate.py index 5e21dcf..f42f7fb 100644 --- a/tests/DIS_no_integrate.py +++ b/tests/DIS_no_integrate.py @@ -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: diff --git a/tests/conftest.py b/tests/conftest.py index 5525c8c..c89fb57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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( diff --git a/tests/dd06.py b/tests/dd06.py index 5a37bef..396788b 100644 --- a/tests/dd06.py +++ b/tests/dd06.py @@ -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] diff --git a/tests/test_calibration.py b/tests/test_calibration.py index 8d1951c..62d1991 100644 --- a/tests/test_calibration.py +++ b/tests/test_calibration.py @@ -10,8 +10,8 @@ import pytest from orisys import Sensor from orisys.calibration import ( - CalSample, CalibrationError, + CalSample, build, check, filter_mismatches, diff --git a/tests/test_capture.py b/tests/test_capture.py index 179c8ec..1084529 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -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") diff --git a/tests/test_contact_arrow_scale.py b/tests/test_contact_arrow_scale.py index 45d6f08..e900c9d 100644 --- a/tests/test_contact_arrow_scale.py +++ b/tests/test_contact_arrow_scale.py @@ -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, diff --git a/tests/test_draw_arrows.py b/tests/test_draw_arrows.py index 0cc74d8..8c44968 100644 --- a/tests/test_draw_arrows.py +++ b/tests/test_draw_arrows.py @@ -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 diff --git a/tests/test_export.py b/tests/test_export.py index 900ae1c..cbb40f9 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -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) diff --git a/tests/test_filter_presets.py b/tests/test_filter_presets.py index dd27fef..70af003 100644 --- a/tests/test_filter_presets.py +++ b/tests/test_filter_presets.py @@ -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" diff --git a/tests/test_finger_runtime.py b/tests/test_finger_runtime.py index ea06304..733056b 100644 --- a/tests/test_finger_runtime.py +++ b/tests/test_finger_runtime.py @@ -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 diff --git a/tests/test_masked_poisson_capfft.py b/tests/test_masked_poisson_capfft.py index 4c96b7f..7e78347 100644 --- a/tests/test_masked_poisson_capfft.py +++ b/tests/test_masked_poisson_capfft.py @@ -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] diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index 7fd64a8..cb97ba0 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -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 diff --git a/tests/test_qt_viewer_backend.py b/tests/test_qt_viewer_backend.py index 3399bb9..a0614d6 100644 --- a/tests/test_qt_viewer_backend.py +++ b/tests/test_qt_viewer_backend.py @@ -15,6 +15,11 @@ _EXAMPLES_DIR = _REPO_ROOT / "examples" if str(_EXAMPLES_DIR) not in sys.path: sys.path.insert(0, str(_EXAMPLES_DIR)) +from gui.sensors import ( # noqa: E402 + FrameSnapshot, + _snapshot_from_sensor, + calibrate_normal_force, +) from gui.viewer2d.backend import ( # noqa: E402 QtViewerBackend, image_shape_meta, @@ -22,12 +27,10 @@ from gui.viewer2d.backend import ( # noqa: E402 scale_bgr_to_fit, select_display_image, ) -from gui.sensors import ( # noqa: E402 - FrameSnapshot, - _snapshot_from_sensor, - calibrate_normal_force, + +from orisys.signal_processing import ( + calibrate_normal_force as sdk_calibrate_normal_force, ) -from orisys.signal_processing import calibrate_normal_force as sdk_calibrate_normal_force class FakeForceAdapter: diff --git a/tests/test_release_version.py b/tests/test_release_version.py index cde73ee..3ee75b1 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -9,8 +9,6 @@ import sys import urllib.error from pathlib import Path -import pytest - REPO_ROOT = Path(__file__).resolve().parents[1] BUILD_ROOT = REPO_ROOT / "build" if str(BUILD_ROOT) not in sys.path: diff --git a/tests/test_stitch.py b/tests/test_stitch.py index 9c24762..574ac18 100644 --- a/tests/test_stitch.py +++ b/tests/test_stitch.py @@ -5,7 +5,7 @@ from __future__ import annotations import numpy as np import pytest -from orisys.Stitcher import Stitcher +from orisys.frame_processors import CompoundEyeFrameProcessor from orisys.stitch import ( StitchStatus, get_blend_backend, @@ -16,7 +16,7 @@ from orisys.stitch import ( from orisys.stitch.backends.numpy_backend import blend_kernel_numpy from orisys.stitch.config import numba_blend_available, validate_box_grid from orisys.stitch.ops import compute_final_crop_rect, coverage_ratio -from orisys.frame_processors import CompoundEyeFrameProcessor +from orisys.Stitcher import Stitcher def _solid_tile(val: int, h=32, w=32): diff --git a/tests/test_uv_mesh_viewer.py b/tests/test_uv_mesh_viewer.py index 8aa26bf..af22da7 100644 --- a/tests/test_uv_mesh_viewer.py +++ b/tests/test_uv_mesh_viewer.py @@ -1,5 +1,4 @@ import numpy as np -import pytest from orisys.finger.mesh import ( build_uv_surface_mesh, diff --git a/tests/test_uv_remap.py b/tests/test_uv_remap.py index 19f2f57..c205776 100644 --- a/tests/test_uv_remap.py +++ b/tests/test_uv_remap.py @@ -3,8 +3,13 @@ from pathlib import Path import numpy as np import pytest -from orisys.unroll import UVDomain, UVRemap, UVSurfaceMap, flatten_frame, remap_valid_ratio - +from orisys.unroll import ( + UVDomain, + UVRemap, + UVSurfaceMap, + flatten_frame, + remap_valid_ratio, +) REPO_ROOT = Path(__file__).resolve().parents[1] UV_MAP_PATH = REPO_ROOT / "unroll_map_uv.npz" diff --git a/tests/test_uv_unroll_processor.py b/tests/test_uv_unroll_processor.py index 6d34123..dcd0870 100644 --- a/tests/test_uv_unroll_processor.py +++ b/tests/test_uv_unroll_processor.py @@ -7,7 +7,6 @@ import pytest from orisys.frame_processors import FrameUnwarper from orisys.unroll import UVDomain - REPO_ROOT = Path(__file__).resolve().parents[1] UV_MAP_PATH = REPO_ROOT / "unroll_map_uv.npz" diff --git a/tests/test_wheel_setup_metadata.py b/tests/test_wheel_setup_metadata.py index a2b9e94..4f60307 100644 --- a/tests/test_wheel_setup_metadata.py +++ b/tests/test_wheel_setup_metadata.py @@ -12,14 +12,16 @@ import subprocess import sys from pathlib import Path -import pytest - REPO_ROOT = Path(__file__).resolve().parents[1] BUILD_ROOT = REPO_ROOT / "build" if str(BUILD_ROOT) not in sys.path: sys.path.insert(0, str(BUILD_ROOT)) -from wheel.package import metadata_kwargs, read_long_description, write_wheel_setup # noqa: E402 +from wheel.package import ( # noqa: E402 + metadata_kwargs, + read_long_description, + write_wheel_setup, +) PROJECT = { "name": "orisys", diff --git a/tools/ffc/roi_polygon_tool.py b/tools/ffc/roi_polygon_tool.py index d6bccfd..98badab 100644 --- a/tools/ffc/roi_polygon_tool.py +++ b/tools/ffc/roi_polygon_tool.py @@ -9,7 +9,6 @@ from pathlib import Path import cv2 import numpy as np - WINDOW_NAME = "ROI Editor" POLYGON_WINDOW_NAME = "ROI Polygon Editor" diff --git a/tools/lint_gate.py b/tools/lint_gate.py new file mode 100644 index 0000000..e1109b4 --- /dev/null +++ b/tools/lint_gate.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Merge gate for lint on a change — the narrow rule set a change must pass. + +This is deliberately *not* the repo's full `ruff.toml` selection. The policy, decided from the +measured findings rather than from taste: + + * ``BLE001`` (blind except) is **allowed**. A broad ``except Exception`` is the right choice + around device I/O, GL teardown and camera probing, and this codebase already records that + choice inline as ``# noqa: BLE001 - ``. Gating it would produce ~100 findings whose + only correct fix is "add a reasoned suppression". + * ``S110``/``S112`` (a handler that is only ``pass``/``continue``) and ``E722`` (bare + ``except:``) **are** gated: 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. + * ``RUF100`` (unused suppression) is reported for triage but is configured ``unfixable``: a + machine must never delete a suppression whose comment records why it exists. + +Run over the files a change touches, never the whole tree, so an AI-authored diff is not +drowned in pre-existing style churn. + +Usage:: + + tools/lint_gate.py # files changed vs HEAD (staged + unstaged) + tools/lint_gate.py --base origin/main # files changed vs a ref + tools/lint_gate.py --files a.py b.py # explicit files + tools/lint_gate.py --all # gate rules over the whole repo + tools/lint_gate.py --json # machine-readable result on stdout + +Exit codes: 0 clean, 1 findings, 2 a usage or environment problem (e.g. ruff missing). +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +#: The gate. Explicit, so a rule added to `ruff.toml` cannot silently start failing builds. +#: `E4`/`E7` are deliberately absent: `E402` fires on the intentional "set the environment before +#: importing Qt" pattern in scripts, and `E701`/`E702` are style, which `ruff format` handles. +GATE_SELECT = "E9,E722,F,S110,S112,B023,PLW1510" + +#: Rules we report but never let a tool auto-fix (RUF100 is `unfixable` in ruff.toml too). +REPORT_ONLY = "RUF100" + +REPO = Path(__file__).resolve().parents[1] + + +def find_ruff() -> str: + """Locate ruff: $RUFF, then PATH, then the interpreter environment.""" + for candidate in (os.environ.get("RUFF"), shutil.which("ruff")): + if candidate and Path(candidate).is_file(): + return candidate + beside = Path(sys.executable).parent / ("ruff.exe" if os.name == "nt" else "ruff") + if beside.is_file(): + return str(beside) + print( + "lint_gate: ruff not found. Set $RUFF, install it beside the interpreter, or put it on " + "PATH. The pin matters: findings differ between ruff versions, so the gate names its " + "version below when it runs.", + file=sys.stderr, + ) + raise SystemExit(2) + + +def git(*args: str) -> list[str]: + result = subprocess.run( + ["git", "-C", str(REPO), *args], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print(f"lint_gate: git {' '.join(args)} failed: {result.stderr.strip()}", file=sys.stderr) + raise SystemExit(2) + return [line for line in result.stdout.splitlines() if line.strip()] + + +def changed_python_files(base: str | None) -> list[str]: + """The .py files this change touches: working tree, index, and new files.""" + paths: set[str] = set() + if base: + paths.update(git("diff", "--name-only", "--diff-filter=ACMR", f"{base}...HEAD")) + paths.update(git("diff", "--name-only", "--diff-filter=ACMR")) + paths.update(git("diff", "--name-only", "--cached", "--diff-filter=ACMR")) + paths.update(git("ls-files", "--others", "--exclude-standard")) + return sorted(p for p in paths if p.endswith(".py") and (REPO / p).is_file()) + + +def run_ruff(ruff: str, args: list[str]) -> tuple[list[dict], str]: + version = subprocess.run( + [ruff, "--version"], capture_output=True, text=True, check=False + ).stdout.strip() + result = subprocess.run( + [ruff, "check", "--no-cache", "--output-format", "json", *args], + capture_output=True, + text=True, + check=False, + ) + if result.returncode not in (0, 1): + print(f"lint_gate: ruff failed ({result.returncode}): {result.stderr.strip()}", file=sys.stderr) + raise SystemExit(2) + try: + findings = json.loads(result.stdout or "[]") + except json.JSONDecodeError: + print(f"lint_gate: could not parse ruff output: {result.stdout[:400]}", file=sys.stderr) + raise SystemExit(2) + return findings, version + + +def describe(finding: dict) -> str: + code = finding.get("code") or "?" + location = finding.get("location") or {} + path = str(finding.get("filename", "")).replace(str(REPO) + os.sep, "") + return ( + f"{path}:{location.get('row', '?')}:{location.get('column', '?')}: {code}: " + f"{finding.get('message', '')}" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--base", default=None, help="compare against this ref (e.g. origin/main)") + parser.add_argument("--files", nargs="*", default=None, help="explicit files to check") + parser.add_argument("--all", action="store_true", help="run the gate rules over the whole repo") + parser.add_argument("--json", action="store_true", help="machine-readable result on stdout") + args = parser.parse_args(argv) + + ruff = find_ruff() + + if args.all: + targets = [str(REPO)] + else: + targets = args.files or changed_python_files(args.base) + if not targets: + print("lint_gate: no changed Python files; nothing to check.") + if args.json: + print(json.dumps({"findings": [], "checked": [], "status": "clean"})) + return 0 + + findings, version = run_ruff(ruff, ["--select", GATE_SELECT, *targets]) + report_only, _ = run_ruff(ruff, ["--select", REPORT_ONLY, *targets]) + + if args.json: + print( + json.dumps( + { + "ruff": version, + "select": GATE_SELECT, + "checked": targets, + "findings": [ + { + "file": str(f.get("filename", "")).replace(str(REPO) + os.sep, ""), + "row": (f.get("location") or {}).get("row"), + "column": (f.get("location") or {}).get("column"), + "code": f.get("code"), + "message": f.get("message"), + "fixable": bool((f.get("fix") or {}).get("applicability") == "safe"), + } + for f in findings + ], + "unused_suppressions": [describe(f) for f in report_only], + "status": "findings" if findings else "clean", + }, + indent=2, + ) + ) + return 1 if findings else 0 + + print(f"lint_gate: {version}") + print(f" select {GATE_SELECT} (BLE001 is allowed by policy; see ruff.toml)") + print(f" checked {len(targets)} target(s)") + if findings: + print(f"\n{len(findings)} finding(s):") + for finding in sorted(findings, key=describe): + hint = " [safe fix: ruff check --fix]" if (finding.get("fix") or {}).get("applicability") == "safe" else "" + print(f" {describe(finding)}{hint}") + else: + print(" no findings") + if report_only: + print(f"\n{len(report_only)} unused suppression(s) to triage by hand (never auto-removed):") + for finding in report_only: + print(f" {describe(finding)}") + print(f"\nstatus: {'FINDINGS' if findings else 'clean'}") + return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/sync_downstreams.py b/tools/sync_downstreams.py index c412480..9653c71 100644 --- a/tools/sync_downstreams.py +++ b/tools/sync_downstreams.py @@ -17,7 +17,6 @@ import sys from dataclasses import dataclass from pathlib import Path - MANIFEST_NAME = "downstream_sync_manifest.json"