Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0784eacf51 | |||
| 68edb0dc4c | |||
| 4c3d0f8864 | |||
| ceb06f5702 | |||
| 8c147f03aa | |||
| 34cb83d4a4 | |||
| 38d3e67b00 | |||
| 80f7da5a20 | |||
| 230b04a79d | |||
| 6e3d509239 | |||
| 333fa8fcff | |||
| f700102067 | |||
| b5d7533e24 | |||
| 4c1ab5c27e | |||
| cde832eaca | |||
| 73b1fa7fa4 | |||
| d5b938738e | |||
| e2ac36269c |
@@ -31,6 +31,7 @@ __pycache__/
|
|||||||
/data/*
|
/data/*
|
||||||
!/data/sample/
|
!/data/sample/
|
||||||
!/data/sample/**
|
!/data/sample/**
|
||||||
|
!/data/calibrations/
|
||||||
|
|
||||||
# Generated docs / tooling outputs
|
# Generated docs / tooling outputs
|
||||||
/docs/.quarto/
|
/docs/.quarto/
|
||||||
@@ -47,3 +48,6 @@ __pycache__/
|
|||||||
|
|
||||||
# Scratch output of examples/ (multi_stream.py's default --out-dir)
|
# Scratch output of examples/ (multi_stream.py's default --out-dir)
|
||||||
/outputs/
|
/outputs/
|
||||||
|
|
||||||
|
# Agent scratch: one directory per task, see D:\orisys_ws\AGENTS.md
|
||||||
|
.scratch/
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ PyPI: [https://pypi.org/project/orisys/](https://pypi.org/project/orisys/)
|
|||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
- `src/orisys/`: installable SDK package
|
- `src/orisys/`: installable SDK package
|
||||||
- `config/`: default and lab/runtime JSON configurations
|
- `sensors/`: product bundles containing the current `sensor.json` configurations and assets
|
||||||
|
- `config/`: legacy JSON configurations retained for compatibility
|
||||||
- `examples/`: runnable demos (`basic_pipeline.py`, `mini_pipeline.py`, `finger_pipeline*.py`) plus shared `gui/`, `pipeline/`, `viz/`
|
- `examples/`: runnable demos (`basic_pipeline.py`, `mini_pipeline.py`, `finger_pipeline*.py`) plus shared `gui/`, `pipeline/`, `viz/`
|
||||||
- `scripts/`: maintenance, calibration, packaging, and demo scripts
|
- `scripts/`: maintenance, calibration, packaging, and demo scripts
|
||||||
- `tools/`: non-package tooling such as patch-generation utilities
|
- `tools/`: non-package tooling such as patch-generation utilities
|
||||||
@@ -73,12 +74,13 @@ Run commands from the repository root.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The canonical configuration files now live in the repo-level `config/` folder.
|
Product configurations live in `sensors/<name>/sensor.json`. The repo-level `config/`
|
||||||
|
folder remains available for older configurations and aliases.
|
||||||
The package loader `orisys.configs` resolves configs from:
|
The package loader `orisys.configs` resolves configs from:
|
||||||
|
|
||||||
1. an explicit JSON file path
|
1. an explicit JSON file path
|
||||||
2. a config file stem under `config/` (for example `box`, `mini`, `finger`)
|
2. `sensors/<name>/sensor.json` (for example `box`, `mini`, `finger`)
|
||||||
3. a product alias such as `dd02` -> `dd02-ov`
|
3. a file stem under the legacy `config/` folder, including aliases such as `dd02` -> `dd02-ov`
|
||||||
4. a backward-compatible JSON `"name"` match, such as `"prototype"`
|
4. a backward-compatible JSON `"name"` match, such as `"prototype"`
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -90,12 +92,39 @@ sensor = orisys.Sensor(0, config_name="box")
|
|||||||
cfg = orisys.export_config("box", "./config/exported_config.json")
|
cfg = orisys.export_config("box", "./config/exported_config.json")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For multiple physical sensors, keep calibration files in a user-selected directory:
|
||||||
|
`<calibration_dir>/<product>/<device_id>.json` (for example,
|
||||||
|
`data/calibrations/box/BOX-001.json`). Each file uses the existing calibration JSON
|
||||||
|
format and must contain matching `identity.sensor_type` and `identity.sn`, Newton
|
||||||
|
curves for FN/FX/FY, and a compatible measurement pipeline. Enter the device ID
|
||||||
|
manually until hardware identification is available:
|
||||||
|
|
||||||
|
The source checkout provides `data/calibrations/{box,mini,finger}/` folders; device
|
||||||
|
files placed there are ignored by Git. This location is optional: pass
|
||||||
|
`--calibration-dir data/calibrations`
|
||||||
|
and `--device-id BOX-001` to `examples/basic_pipeline.py` to select a file there.
|
||||||
|
Installed wheels do not create or search this directory automatically.
|
||||||
|
|
||||||
|
```python
|
||||||
|
sensor = orisys.Sensor(0, config_name="box", calibration_dir="data/calibrations", device_id="BOX-001")
|
||||||
|
```
|
||||||
|
|
||||||
|
If that ID has no file, the sensor reports raw force; it never falls back to the
|
||||||
|
product's default. An invalid or mismatched file raises `CalibrationError`.
|
||||||
|
`FingerRuntime.open` accepts the same `calibration_dir` and `device_id` arguments.
|
||||||
|
Existing calls without a device ID still use the product bundle default, and
|
||||||
|
explicit `cal_path` remains available for one-off use. `cal_path` and
|
||||||
|
`calibration_dir` cannot be combined. Pass `device_id` with `cal_path` to validate
|
||||||
|
that explicit file against the selected device.
|
||||||
|
|
||||||
## Examples and Tools
|
## Examples and Tools
|
||||||
|
|
||||||
- Example entry points: `examples/README.md`
|
- Example entry points: `examples/README.md`
|
||||||
- User documentation: `docs/get_started.md`
|
- Contributor start: [docs/get_started.en.md](docs/get_started.en.md)
|
||||||
|
- Chinese installation guide: [docs/get_started.md](docs/get_started.md)
|
||||||
- Build launcher: `python build/run.py` (see `build/README.md`)
|
- Build launcher: `python build/run.py` (see `build/README.md`)
|
||||||
- Manylinux / PyPI binary wheel: `docs/publishing.md`
|
- Build and publish guide: [docs/publishing.en.md](docs/publishing.en.md)
|
||||||
|
- Chinese release runbook: [docs/publishing.md](docs/publishing.md)
|
||||||
- Specialized utilities: `tools/`
|
- Specialized utilities: `tools/`
|
||||||
|
|
||||||
## Publishing (Linux manylinux)
|
## Publishing (Linux manylinux)
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ from common.verify import ( # noqa: E402
|
|||||||
verify_wheel_assets,
|
verify_wheel_assets,
|
||||||
verify_wheel_runtime,
|
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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
|||||||
@@ -31,11 +31,16 @@ _BUILD_ROOT = Path(__file__).resolve().parents[1]
|
|||||||
if str(_BUILD_ROOT) not in sys.path:
|
if str(_BUILD_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(_BUILD_ROOT))
|
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.layout import resolve_bundle_source_files # noqa: E402
|
||||||
from wheel.package import is_compliant_manylinux_wheel # 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.
|
# PyPI's default per-project file limit is 100 MB; keep headroom.
|
||||||
MAX_WHEEL_BYTES = 90 * 1024 * 1024
|
MAX_WHEEL_BYTES = 90 * 1024 * 1024
|
||||||
|
|
||||||
@@ -339,11 +344,7 @@ def _check_forbidden_entries(result: WheelCheckResult, names: list[str]) -> None
|
|||||||
offenders = []
|
offenders = []
|
||||||
for name in names:
|
for name in names:
|
||||||
lowered = name.lower()
|
lowered = name.lower()
|
||||||
if lowered.startswith(_FORBIDDEN_PREFIXES):
|
if lowered.startswith(_FORBIDDEN_PREFIXES) or lowered.endswith(_FORBIDDEN_SUFFIXES) or "__pycache__" in lowered or ".egg-info" in lowered:
|
||||||
offenders.append(name)
|
|
||||||
elif lowered.endswith(_FORBIDDEN_SUFFIXES):
|
|
||||||
offenders.append(name)
|
|
||||||
elif "__pycache__" in lowered or ".egg-info" in lowered:
|
|
||||||
offenders.append(name)
|
offenders.append(name)
|
||||||
result.add(
|
result.add(
|
||||||
"no-forbidden-entries",
|
"no-forbidden-entries",
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
_BUILD_ROOT = Path(__file__).resolve().parents[2]
|
_BUILD_ROOT = Path(__file__).resolve().parents[2]
|
||||||
if str(_BUILD_ROOT) not in sys.path:
|
if str(_BUILD_ROOT) not in sys.path:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(_BUILD_ROOT))
|
sys.path.insert(0, str(_BUILD_ROOT))
|
||||||
|
|
||||||
from common.paths import REPO_ROOT
|
from common.paths import REPO_ROOT
|
||||||
|
|
||||||
from gui.common.pyinstaller_driver import GuiBuildConfig
|
from gui.common.pyinstaller_driver import GuiBuildConfig
|
||||||
|
|
||||||
ENTRY_SCRIPT = REPO_ROOT / "examples" / "basic_pipeline.py"
|
ENTRY_SCRIPT = REPO_ROOT / "examples" / "basic_pipeline.py"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(_BUILD_ROOT))
|
sys.path.insert(0, str(_BUILD_ROOT))
|
||||||
|
|
||||||
from common.verify.gui import verify_gui_dist
|
from common.verify.gui import verify_gui_dist
|
||||||
|
|
||||||
from gui.common.pyinstaller_driver import build_with_pyinstaller
|
from gui.common.pyinstaller_driver import build_with_pyinstaller
|
||||||
from gui.ory_finger.config import make_config
|
from gui.ory_finger.config import make_config
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(_BUILD_ROOT))
|
sys.path.insert(0, str(_BUILD_ROOT))
|
||||||
|
|
||||||
from common.paths import REPO_ROOT
|
from common.paths import REPO_ROOT
|
||||||
|
|
||||||
from gui.common.pyinstaller_driver import GuiBuildConfig
|
from gui.common.pyinstaller_driver import GuiBuildConfig
|
||||||
|
|
||||||
ENTRY_SCRIPT = REPO_ROOT / "examples" / "finger_pipeline_3dview.py"
|
ENTRY_SCRIPT = REPO_ROOT / "examples" / "finger_pipeline_3dview.py"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(_BUILD_ROOT))
|
sys.path.insert(0, str(_BUILD_ROOT))
|
||||||
|
|
||||||
from common.paths import REPO_ROOT
|
from common.paths import REPO_ROOT
|
||||||
|
|
||||||
from gui.common.pyinstaller_driver import GuiBuildConfig
|
from gui.common.pyinstaller_driver import GuiBuildConfig
|
||||||
|
|
||||||
ENTRY_SCRIPT = REPO_ROOT / "examples" / "mini_pipeline.py"
|
ENTRY_SCRIPT = REPO_ROOT / "examples" / "mini_pipeline.py"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from common.verify.wheel import ( # noqa: E402
|
|||||||
verify_wheel_runtime,
|
verify_wheel_runtime,
|
||||||
)
|
)
|
||||||
from common.verify.wheel_contents import inspect_wheel # noqa: E402
|
from common.verify.wheel_contents import inspect_wheel # noqa: E402
|
||||||
|
|
||||||
from release import auth, github, pypi # noqa: E402
|
from release import auth, github, pypi # noqa: E402
|
||||||
from release.preflight import PreflightOptions, run_preflight # noqa: E402
|
from release.preflight import PreflightOptions, run_preflight # noqa: E402
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(_BUILD_ROOT))
|
sys.path.insert(0, str(_BUILD_ROOT))
|
||||||
|
|
||||||
from common.paths import REPO_ROOT # noqa: E402
|
from common.paths import REPO_ROOT # noqa: E402
|
||||||
|
|
||||||
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
|
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
|
||||||
|
|
||||||
MANIFEST_PATH = REPO_ROOT / "tools" / "downstream_sync_manifest.json"
|
MANIFEST_PATH = REPO_ROOT / "tools" / "downstream_sync_manifest.json"
|
||||||
|
|||||||
@@ -45,9 +45,13 @@ from common.verify.wheel import ( # noqa: E402
|
|||||||
verify_wheel,
|
verify_wheel,
|
||||||
)
|
)
|
||||||
from common.verify.wheel_contents import inspect_wheel # noqa: E402
|
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 report as report_mod # noqa: E402
|
||||||
from release import version as version_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
|
@dataclass
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ probe, and ``--apply`` being required even to reach TestPyPI.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -33,6 +32,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(_BUILD_ROOT))
|
sys.path.insert(0, str(_BUILD_ROOT))
|
||||||
|
|
||||||
from common.paths import REPO_ROOT # noqa: E402
|
from common.paths import REPO_ROOT # noqa: E402
|
||||||
|
|
||||||
from release import auth # noqa: E402
|
from release import auth # noqa: E402
|
||||||
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
|
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
|
||||||
|
|
||||||
@@ -452,7 +452,7 @@ def publish(options: Options) -> int:
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
if options.pypi_only:
|
if options.pypi_only:
|
||||||
print(f"=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
|
print("=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
|
||||||
else:
|
else:
|
||||||
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
|
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ _BUILD_ROOT = Path(__file__).resolve().parents[1]
|
|||||||
if str(_BUILD_ROOT) not in sys.path:
|
if str(_BUILD_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(_BUILD_ROOT))
|
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 (
|
from common.pyproject_utils import (
|
||||||
bundle_items,
|
bundle_items,
|
||||||
extras_require,
|
extras_require,
|
||||||
@@ -38,6 +44,7 @@ from common.pyproject_utils import (
|
|||||||
project_metadata,
|
project_metadata,
|
||||||
)
|
)
|
||||||
from common.verify.wheel import verify_built_wheel
|
from common.verify.wheel import verify_built_wheel
|
||||||
|
|
||||||
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
|
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
|
||||||
from wheel.layout import (
|
from wheel.layout import (
|
||||||
copy_bundle_items,
|
copy_bundle_items,
|
||||||
@@ -199,9 +206,7 @@ def main(argv: list[str] | None = None) -> None:
|
|||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
verify: bool | None
|
verify: bool | None
|
||||||
if args.target == "pypi-linux":
|
if args.target == "pypi-linux" or args.verify:
|
||||||
verify = True
|
|
||||||
elif args.verify:
|
|
||||||
verify = True
|
verify = True
|
||||||
elif args.skip_verify:
|
elif args.skip_verify:
|
||||||
verify = False
|
verify = False
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
*
|
||||||
|
!*/
|
||||||
|
!.gitignore
|
||||||
|
!.gitkeep
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Public API surface audit (2026-09-30)
|
||||||
|
|
||||||
|
A snapshot of what the SDK declares as public, what is documented, and where the gaps are.
|
||||||
|
Produced by [`tools/api_audit.py`](../tools/api_audit.py), which is static and read-only — it
|
||||||
|
never imports the package, so it costs no numpy/cv2 import and cannot be affected by the
|
||||||
|
environment. Re-run it after any API change:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\orisys_ws\orisys-sdk
|
||||||
|
python tools/api_audit.py # text report
|
||||||
|
python tools/api_audit.py --json # machine-readable
|
||||||
|
python tools/api_audit.py --quiet # headline numbers
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Headline
|
||||||
|
|
||||||
|
| Measure | Value |
|
||||||
|
|---|---|
|
||||||
|
| Public classes / methods / functions | **393** (53 / 214 / 126) |
|
||||||
|
| With a docstring | **256 (65 %)** |
|
||||||
|
| Fully annotated signature (params + return) | **125 (32 %)** |
|
||||||
|
| Docstring documents its return value | **38** |
|
||||||
|
|
||||||
|
Docstring conventions actually in use:
|
||||||
|
|
||||||
|
| Convention | Count |
|
||||||
|
|---|---|
|
||||||
|
| plain prose (no structured sections) | 243 |
|
||||||
|
| none | 137 |
|
||||||
|
| Google (`Args:` / `Returns:`) | 9 |
|
||||||
|
| NumPy (`Parameters` + `---`) | 4 |
|
||||||
|
|
||||||
|
**Reading:** the dominant convention is not a convention. Structured sections are absent from
|
||||||
|
almost every docstring, and two thirds of signatures are missing type information. The hand-written
|
||||||
|
reference compensates in the places it covers; a generated reference would not.
|
||||||
|
|
||||||
|
## 2. The declared public surface
|
||||||
|
|
||||||
|
The root `__all__` (`src/orisys/__init__.py`) declares 18 names. Sub-surfaces declare a further 88.
|
||||||
|
|
||||||
|
### 2.1 Root exports — 9 of 18 are absent from `docs/api.md`
|
||||||
|
|
||||||
|
| Documented | Not mentioned anywhere in `docs/api.md` |
|
||||||
|
|---|---|
|
||||||
|
| `Sensor`, `util`, `finger`, `signal_processing`, `capture`, `list_config`, `export_config`, `export_sensor_bundle`, `export_sensor_bundles` | **`StreamPipeline`**, **`FrameSnapshot`**, **`PipelineStats`**, **`StreamRecorder`**, **`mosaic_videos`**, **`FFCFrameProcessor`**, `CompoundEyeSensor`, `CameraSensor`, `calibration` |
|
||||||
|
|
||||||
|
`StreamPipeline` is the engine behind the published "5 × 30 FPS on one desktop, 4.2× headroom"
|
||||||
|
claim and is used by `examples/multi_stream.py`. `StreamRecorder` and `mosaic_videos` are the
|
||||||
|
offline-recording path. None of them has a Markdown reference anywhere in the repository.
|
||||||
|
|
||||||
|
### 2.2 Sub-surfaces with no coverage in `api.md` at all
|
||||||
|
|
||||||
|
| Module | Exports | Documented | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `orisys.unroll` | 18 | 0 | UV domain, remap, geometry — the finger pipeline's core maths |
|
||||||
|
| `orisys.capture` | 17 | 0 | capture sources, workers, reconnect policy, camera discovery |
|
||||||
|
| `orisys.hhd` + `hhd.solvers` | 7 | 0 | **no docstrings either** — see §3 |
|
||||||
|
| `orisys.pipeline` | 3 | 0 | `StreamPipeline` etc. |
|
||||||
|
| `orisys.export` | 2 | 0 | `StreamRecorder`, `mosaic_videos` |
|
||||||
|
|
||||||
|
### 2.3 Exports with no docstring — 20 of 88
|
||||||
|
|
||||||
|
Includes `CaptureWorker`, `pick_default_camera`, `available_blend_backends`,
|
||||||
|
`compute_final_crop_rect`, `coverage_ratio`, `largest_fully_covered_rect`,
|
||||||
|
`match_bgr_brightness`, `barycentric_grid`, `face_uvs_from_loop_data`, all four `orisys.hhd`
|
||||||
|
exports, and named constants (`DEFAULT_LAPTOP_WEBCAM_PATTERNS`, `MAX_SIMULTANEOUS_CAPTURES`,
|
||||||
|
`DEFAULT_FINGER_EXPORT_DIR`, `DEFAULT_FINGER_MAP_CACHE`, `BLEND_MODES`).
|
||||||
|
|
||||||
|
## 3. Coverage by module
|
||||||
|
|
||||||
|
The extreme cases, which are where effort pays off:
|
||||||
|
|
||||||
|
| Module | Symbols | Docstrings | Annotated | Comment |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `orisys.finger.runtime` | 31 | **35 %** | **3 %** | `FingerRuntime` is the documented entry point for finger users, yet its implementation module is the thinnest in the package |
|
||||||
|
| `orisys.hhd.timing` | 4 | **0 %** | 0 % | |
|
||||||
|
| `orisys.stitch.feature` | 10 | 20 % | 0 % | |
|
||||||
|
| `orisys.stitch.tiles` | 5 | 20 % | 0 % | |
|
||||||
|
| `orisys.stitch.api` | 10 | 30 % | 10 % | |
|
||||||
|
| `orisys.frame_processors` | 21 | 48 % | **0 %** | |
|
||||||
|
| `orisys.capture.source` | 32 | 50 % | 91 % | annotations good, prose thin |
|
||||||
|
| `orisys.sensor` | 21 | 90 % | **38 %** | the most-documented module, still missing annotations |
|
||||||
|
| `orisys.configs` | 13 | 100 % | 69 % | |
|
||||||
|
| `orisys.unroll.precompute`, `domain` | 6 / 6 | 100 % | 0 % | documented but untyped |
|
||||||
|
|
||||||
|
Annotated and documented do not move together: `capture.source` is 91 % annotated but 50 %
|
||||||
|
documented; `hhd.domain` is 100 % documented but 0 % annotated.
|
||||||
|
|
||||||
|
## 4. Used by examples and tests, absent from the reference — 74 names
|
||||||
|
|
||||||
|
These are APIs users demonstrably reach for and the reference never explains. The highest-traffic
|
||||||
|
group is the runtime/typing surface:
|
||||||
|
|
||||||
|
`StreamPipeline`, `FrameSnapshot`, `PipelineStats`, `StreamRecorder`, `mosaic_videos`,
|
||||||
|
`FFCFrameProcessor`, `UVDomain`, `UVRemap`, `UVSurfaceMap`, `UVSurfaceMesh`, `UVMeshViewer`,
|
||||||
|
`Tracker`, `Contact3D`, `CalSample`, `CalibrationError`, `StitchStatus`, `CameraInfo`,
|
||||||
|
`CameraFilter`, `CaptureSource`, `CaptureConfig`, `CaptureHealth`, `ReconnectPolicy`,
|
||||||
|
`PoissonSolver`, `nHHD`
|
||||||
|
|
||||||
|
Plus the finger/geometry helpers (`build_uv_surface_mesh`, `compute_uv_surface_frames`,
|
||||||
|
`lift_uv_flow_to_3d`, `flow_magnitude_vertex_colors`, `scalar_map_vertex_colors`,
|
||||||
|
`sample_field_at_mesh_vertices`, `mesh_uv_to_world_scale`, `resolve_export_paths`,
|
||||||
|
`resolve_finger_geometry`, `load_finger_export_bundle`, `init_uv_mesh_viewer`,
|
||||||
|
`update_uv_mesh_viewer`), the capture helpers (`open_capture`, `normalize_source`,
|
||||||
|
`list_cameras`, `pick_default_camera`), the calibration helpers (`check`, `build`, `parse`,
|
||||||
|
`load`, `calibrate_normal_force`, `calibrate_shear_force`, `calibrate_shear_components`),
|
||||||
|
and the config resolvers (`load_config`, `list_sensor_bundles`, `resolve_sensor_config_path`,
|
||||||
|
`resolve_sensor_resource`, `resolve_sensor_asset_dir`, `pipeline_from_sensor_json`).
|
||||||
|
|
||||||
|
## 5. Public classes exported nowhere — 21
|
||||||
|
|
||||||
|
Defined, carrying no name-mangling underscore, but in no `__all__` and not reachable as a declared
|
||||||
|
API: `BlendBackend`, `CalSample`, `Calibration`, `CalibrationError`, `CausalMedianFilter`,
|
||||||
|
`ChannelCurve`, `CheckResult`, `CompoundEyeFrameProcessor`, `Contact3D`,
|
||||||
|
`ForceChannelCalibration`, `FrameUnwarper`, `LowpassFilter`, `MaskedDomain`,
|
||||||
|
`MaskedPoissonConfig`, `NumbaBlendBackend`, `NumpyBlendBackend`, `SampleRateEstimator`,
|
||||||
|
`SensorBundle`, `SingleCameraFrameProcessor`, `Tracker`, `VideoCaptureLike`.
|
||||||
|
|
||||||
|
Two of these are the clearest signals of a boundary that was never decided:
|
||||||
|
|
||||||
|
- **`LowpassFilter`** is documented in `docs/api.md` as `orisys.signal_processing.LowpassFilter`
|
||||||
|
and is imported by examples — but it appears in neither the submodule's `__all__` nor the root's.
|
||||||
|
It is public by documentation and private by declaration.
|
||||||
|
- **`Calibration`** / `CalibrationError` / `CalSample` / `CheckResult` are the entire force
|
||||||
|
calibration object model, and `calibration.check()` / `calibration.build()` are used directly by
|
||||||
|
the test suite. The `orisys.calibration` package is exported from the root, but none of its
|
||||||
|
public names are declared or documented.
|
||||||
|
|
||||||
|
Each of the 21 needs one decision: promote into the relevant `__all__`, or rename with a leading
|
||||||
|
underscore to mark it internal.
|
||||||
|
|
||||||
|
## 6. What this implies
|
||||||
|
|
||||||
|
1. **Fix the declaration boundary first.** Nine root exports have no reference and 21 classes sit
|
||||||
|
in the gap between documented and declared. Publishing a reference on top of an undecided
|
||||||
|
public surface multiplies the confusion rather than resolving it.
|
||||||
|
2. **Pick one docstring convention before generating anything.** With 243 prose docstrings, 137
|
||||||
|
absent, and 13 structured, a generator's output would be thin and inconsistent — the tool
|
||||||
|
renders what is there.
|
||||||
|
3. **Annotations are the larger gap.** 32 % complete signatures means generated parameter tables
|
||||||
|
would be mostly bare. This is mechanical work and it improves IDE help, not only docs.
|
||||||
|
4. **The omissions are not random.** `StreamPipeline`, `capture` and `unroll` are the *newest*
|
||||||
|
subsystems, and they are the ones with no coverage. The reference drifted toward the parts that
|
||||||
|
existed when it was written.
|
||||||
|
5. **`orisys.hhd` is documented nowhere and has no docstrings**, yet is exported from the root
|
||||||
|
package. Either document it or stop exporting it — leaving it in `__all__` promises support that
|
||||||
|
does not exist.
|
||||||
@@ -205,7 +205,6 @@ sensor.configure(lowpass_cutoff_hz=5.0, lowpass_sample_rate_hz=30.0)
|
|||||||
* **lowpass_sample_rate_hz** (`float`,可选): 力信号采样率,默认 `30.0`。
|
* **lowpass_sample_rate_hz** (`float`,可选): 力信号采样率,默认 `30.0`。
|
||||||
* **lowpass_order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
|
* **lowpass_order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
|
||||||
* **lowpass_enabled** (`bool`,可选): 显式开启或关闭低通滤波。
|
* **lowpass_enabled** (`bool`,可选): 显式开启或关闭低通滤波。
|
||||||
* **hhd_downsample** (`int`,可选): HHD 解算下采样倍数,需为 `>= 1` 的整数,配置默认 `3`。
|
|
||||||
|
|
||||||
#### 读取结果
|
#### 读取结果
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Code review: the automated part
|
||||||
|
|
||||||
|
Four gates existed before this document (the test suites, `apps/studio/tools/ui_check.py`, the
|
||||||
|
real-GL smoke tests, the binding-purity test) and none of them is a linter. This adds the missing
|
||||||
|
*static* layer, deliberately small.
|
||||||
|
|
||||||
|
## Why it exists, in one measured fact
|
||||||
|
|
||||||
|
Until now the three repos had **no Ruff configuration at all**. `ruff check .` therefore ran
|
||||||
|
whatever the installed version enables by default — Ruff 0.16 enables a very broad set
|
||||||
|
(`UP`/`RUF`/`PLR`/`S`/`TRY`/`PERF`/`FURB`/`YTT`/`ASYNC`…) — so the same tree produced **1038 /
|
||||||
|
438 / 117** findings in `orisys-app` / `orisys-sdk` / `orisys-sensor-design`, and a different
|
||||||
|
Ruff version produced different ones. A gate built on that is not a gate. `ruff.toml` now pins it.
|
||||||
|
|
||||||
|
## What is gated, and what is deliberately not
|
||||||
|
|
||||||
|
| rule | meaning | gated? | why |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `BLE001` | broad `except Exception` | **no** | A broad catch is the *right* choice around device I/O, GL teardown and camera probing. This codebase already records that choice inline as `# noqa: BLE001 - <reason>`. Gating it would produce ~100 findings whose only correct fix is "add a reasoned suppression". |
|
||||||
|
| `S110`, `S112` | handler is only `pass` / `continue` | **yes** | The complaint is not the breadth of the catch, it is that the failure becomes **invisible**. Silence is acceptable only with a reasoned suppression or a handler that reports/re-raises. |
|
||||||
|
| `E722` | bare `except:` | **yes** | Strictly worse than `except Exception:` — it also swallows `KeyboardInterrupt`/`SystemExit`. |
|
||||||
|
| `F` | unused import, redefinition, undefined name, unused variable | **yes** | Real defects: `F821`/`F822` are latent `NameError`s, `F811` can be a shadowed definition. |
|
||||||
|
| `B023` | closure over a loop variable | **yes** | Late binding silently produces wrong geometry in the headless Blender scripts. |
|
||||||
|
| `PLW1510` | `subprocess.run` without `check` | **yes** | A swallowed tool failure. |
|
||||||
|
| `RUF100` | unused suppression | reported, **never auto-fixed** | The `- reason` in a `noqa` comment is documentation. A machine must not delete it. Triage by hand. |
|
||||||
|
| `UP`, `I`, `PIE`, `RET501`, `PLR1711`, `SIM114`, `RUF022`, `RUF023` | mechanical modernisation | in the lint config, **not** in the gate | Applied once by `--fix`; gating them would only churn AI-authored diffs. |
|
||||||
|
|
||||||
|
`E402` (module import not at top of file) is **selected but not gated**. `import` after
|
||||||
|
`os.environ[...]` is deliberate in this codebase — the environment must be set before Qt/cv2/the SDK
|
||||||
|
load — so gating it would be pure noise. But it is the only rule that would catch an *accidental*
|
||||||
|
import moved above that setup, so it stays visible. When you touch one of those files, record the
|
||||||
|
intent as `# noqa: E402 - <reason>`; `E701`/`E702` are style, which `ruff format` owns. A gate that
|
||||||
|
cries wolf gets ignored, which is worse than no gate.
|
||||||
|
|
||||||
|
## The three commands
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. The agent loop, after every edit — mechanical, no review needed.
|
||||||
|
ruff check <changed files> --fix # never --unsafe-fixes
|
||||||
|
ruff check <changed files> --output-format json # what remains, machine-readable
|
||||||
|
|
||||||
|
# 2. The gate before a change lands. Changed files only, machine-readable, non-zero on findings.
|
||||||
|
python tools/lint_gate.py # vs HEAD
|
||||||
|
python tools/lint_gate.py --base origin/main
|
||||||
|
python tools/lint_gate.py --all # gate rules over the whole repo
|
||||||
|
python tools/lint_gate.py --json
|
||||||
|
|
||||||
|
# 3. Formatting, changed files only — never a whole-tree rewrite mid-diff.
|
||||||
|
ruff format <changed files>
|
||||||
|
```
|
||||||
|
|
||||||
|
`$RUFF` may point at a specific ruff binary; otherwise `tools/lint_gate.py` uses `PATH` or the one
|
||||||
|
beside the interpreter. **Pin the version**: the gate prints the version it ran, and findings differ
|
||||||
|
between versions, so a runner must install the same one the developers use (currently 0.16.9).
|
||||||
|
|
||||||
|
## What this found when it was introduced
|
||||||
|
|
||||||
|
Filtering to rules that can indicate a defect produced ~18 candidates rather than style noise —
|
||||||
|
which is the argument for the narrow set:
|
||||||
|
|
||||||
|
- six latent `NameError`s (`np`, `sys`, `Callable`, `FrameIdentity`, `tessellate_polygon`, and an
|
||||||
|
`__all__` entry naming a function that does not exist) — none caught by any test;
|
||||||
|
- a **duplicated** function definition that silently shadowed the first;
|
||||||
|
- `lower == lower` / `upper == upper` in a range check, and the same pattern inside a **test
|
||||||
|
assertion that could never fail**;
|
||||||
|
- 13 closures over loop variables in the headless Blender/patch scripts;
|
||||||
|
- two `subprocess.run` calls whose failure was silently ignored.
|
||||||
|
|
||||||
|
## Follow-up deliberately not done yet
|
||||||
|
|
||||||
|
`PLR0124` ("name compared with itself") is not in `select`. Eleven of its sites are the deliberate
|
||||||
|
`value != value` NaN idiom and must become `math.isnan(...)` (or carry a reasoned suppression)
|
||||||
|
before the rule can be gated — otherwise it is noise. Once those are converted, add `PLR0124` to
|
||||||
|
`select` and to `GATE_SELECT` in `tools/lint_gate.py`.
|
||||||
+3
-1
@@ -38,7 +38,9 @@
|
|||||||
| `balanced` | 0.5 | 3 | 0.0 | ≈2 帧(67 ms) | 建议起步档:EMA 干掉力级滤波够不到的光流抖动 |
|
| `balanced` | 0.5 | 3 | 0.0 | ≈2 帧(67 ms) | 建议起步档:EMA 干掉力级滤波够不到的光流抖动 |
|
||||||
| `low_noise` | 0.3 | 3 | 10.0 | ≈3 帧(100 ms) | 底噪优先,读数再低通一道 |
|
| `low_noise` | 0.3 | 3 | 10.0 | ≈3 帧(100 ms) | 底噪优先,读数再低通一道 |
|
||||||
|
|
||||||
延迟换算 @60fps 减半;数字来自 `_perf/`(对照表见 `orisys-sdk-perf-plan.md`)。
|
延迟换算 @60fps 减半;数字来自 `_perf/`(该一次性目录已从 `orisys-sdk` 移除:幸存的脚本与片源
|
||||||
|
迁到 `D:\orisys_ws\docs\performance\perf-measurements\`,其余已永久丢失;对照表见
|
||||||
|
`D:\orisys_ws\docs\performance\orisys-sdk-perf-plan.md`,其中仍保留全部实测数字)。
|
||||||
≥1 帧的延迟与相位差是**因果滤波的代价**:旧显示端曾用零相位 `filtfilt`,那需要"未来",
|
≥1 帧的延迟与相位差是**因果滤波的代价**:旧显示端曾用零相位 `filtfilt`,那需要"未来",
|
||||||
无法在采集里做,也让"显示 == 记录"不可能——所以现在只有一条因果链。
|
无法在采集里做,也让"显示 == 记录"不可能——所以现在只有一条因果链。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
title: Get started with Orisys SDK development
|
||||||
|
description: Set up a source checkout, run a first test, and find the right example.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Get started with Orisys SDK development
|
||||||
|
|
||||||
|
This guide is for contributors working from the SDK source checkout. Obtain the
|
||||||
|
repository URL from your team, clone it, and change into the `orisys-sdk` directory.
|
||||||
|
If you only need to use a published wheel, follow the
|
||||||
|
[Chinese installation guide](get_started.md). Run the commands below from the
|
||||||
|
repository root.
|
||||||
|
|
||||||
|
## First working loop
|
||||||
|
|
||||||
|
Use Python 3.10, 3.11, or 3.12. The existing README uses conda; an isolated
|
||||||
|
environment keeps another installed `orisys` package from shadowing this checkout.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda create -n orisys-dev python=3.10
|
||||||
|
conda activate orisys-dev
|
||||||
|
python -m pip install -e . pytest
|
||||||
|
python -c "import orisys; print(orisys.__version__)"
|
||||||
|
python -m pytest -m "not slow and not hardware" tests/test_calibration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The editable install makes changes under `src/orisys/` visible without rebuilding a
|
||||||
|
wheel. `pytest.ini` adds `src/` to the test import path. Before debugging a strange
|
||||||
|
import failure, check that `python` and `python -m pip` point to the same environment.
|
||||||
|
The full non-hardware suite is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest -m "not slow and not hardware"
|
||||||
|
```
|
||||||
|
|
||||||
|
Some tests need a recorded clip. `tests/conftest.py` looks for a repository sample or
|
||||||
|
the path in `ORISYS_TEST_VIDEO`; a missing clip can skip those tests. Inspect the test
|
||||||
|
summary rather than treating a run with unexpected skips as coverage.
|
||||||
|
|
||||||
|
## Run a pipeline
|
||||||
|
|
||||||
|
The examples accept a camera index or, where documented, a video path. Start with
|
||||||
|
the example matching your sensor. A camera example needs the device and its bundle;
|
||||||
|
the test command above does not need physical hardware.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python examples/basic_pipeline.py -v 0 -c box
|
||||||
|
python examples/basic_pipeline.py -v 0 -c mini
|
||||||
|
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||||
|
```
|
||||||
|
|
||||||
|
For a video-only first run, see the exact arguments in
|
||||||
|
[examples/README.md](../examples/README.md). GUI examples need the optional GUI
|
||||||
|
dependencies (`python -m pip install -e ".[gui]"`). GPU support is optional; the
|
||||||
|
declared extras are `cuda12` and `cuda13` in [pyproject.toml](../pyproject.toml).
|
||||||
|
Start on CPU unless your task requires a GPU backend.
|
||||||
|
|
||||||
|
## Find the owner of a change
|
||||||
|
|
||||||
|
| What you are changing | Start here |
|
||||||
|
| --- | --- |
|
||||||
|
| SDK API or algorithms | `src/orisys/`, then a focused test in `tests/` |
|
||||||
|
| Product configuration and shipped assets | `sensors/<product>/`; see [sensor bundles](sensor-bundles.md) |
|
||||||
|
| Example behavior | `examples/` and [examples/README.md](../examples/README.md) |
|
||||||
|
| Build or release tooling | `build/` and [build/README.md](../build/README.md) |
|
||||||
|
| Studio UI | The separate `orisys-app` repository |
|
||||||
|
|
||||||
|
`config/` holds legacy configurations. New product-bundle work belongs under
|
||||||
|
`sensors/`. For a physical device's calibration, use a user-selected calibration
|
||||||
|
directory and `device_id`; the [SDK README](../README.md#configuration) describes
|
||||||
|
the path and validation contract. Do not commit individual sensor calibration data
|
||||||
|
to the generic product bundle by accident.
|
||||||
|
|
||||||
|
After a change, run the focused tests first, then the relevant non-hardware suite.
|
||||||
|
If a change requires a camera, record the product, configuration, input, and observed
|
||||||
|
result in your handoff. See [publishing](publishing.en.md) when preparing a release;
|
||||||
|
ordinary development does not require a wheel build or a PyPI account.
|
||||||
+51
-73
@@ -6,7 +6,7 @@ description: 安装并运行 Orisys SDK 的基础步骤
|
|||||||
# 快速开始
|
# 快速开始
|
||||||
|
|
||||||
## 简介
|
## 简介
|
||||||
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
|
本文件介绍如何上手和运行叠动视触觉传感器配套的SDK,进行图像处理,计算形变、力以及接触信息,接受从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
|
||||||
支持操作系统:Windows、Ubuntu
|
支持操作系统:Windows、Ubuntu
|
||||||
建议Python 版本: 3.10, 3.11, 3.12
|
建议Python 版本: 3.10, 3.11, 3.12
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ mkdir orisys_ws
|
|||||||
```bash
|
```bash
|
||||||
cd "C:\Users\orisys\orisys_ws"
|
cd "C:\Users\orisys\orisys_ws"
|
||||||
```
|
```
|
||||||
### 安装 CUDA 和 cuDNN(可选)
|
### 安装 CUDA(可选)
|
||||||
如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。
|
如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。
|
||||||
|
|
||||||
1. 方法一(官网下载):
|
1. 方法一(官网下载):
|
||||||
@@ -56,7 +56,7 @@ cd "C:\Users\orisys\orisys_ws"
|
|||||||
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
|
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
|
||||||
```
|
```
|
||||||
|
|
||||||
该方法会自动配置好 CUDA 和 cuDNN,无需手动下载。
|
该方法会自动配置好 CUDA,无需手动下载。
|
||||||
|
|
||||||
注意:如果您的计算机没有 NVIDIA 显卡,或者不想使用 GPU 加速,可以跳过此步骤。套件也支持纯 CPU 运行(但速度可能较慢)。
|
注意:如果您的计算机没有 NVIDIA 显卡,或者不想使用 GPU 加速,可以跳过此步骤。套件也支持纯 CPU 运行(但速度可能较慢)。
|
||||||
|
|
||||||
@@ -140,85 +140,63 @@ print(orisys.__version__)
|
|||||||
如果打印出版本号,则说明安装成功。
|
如果打印出版本号,则说明安装成功。
|
||||||
|
|
||||||
## 使用方法
|
## 使用方法
|
||||||
|
## 使用设备专属的力标定参数
|
||||||
|
|
||||||
|
摄像头编号与设备 ID 是两个独立参数。命令中的 `-v 0` 或 `--camera_id 0` 只表示选择第 0 个视频源;`device_id` 才是传感器的设备编号(SN),目前需要人工确认并填写。
|
||||||
|
|
||||||
|
将**这台设备实际测得**的标定 JSON 放在自行选择的目录中,不要放进 SDK 的 `config/`,也不要覆盖 `sensors/<产品>/force_calibration.json`。目录结构如下,路径可由用户更改:
|
||||||
|
|
||||||
|
```te
|
||||||
|
D:/orisys_data/calibrations/
|
||||||
|
├── box/
|
||||||
|
│ └── BOX-001.json
|
||||||
|
├── mini/
|
||||||
|
│ └── MINI-001.json
|
||||||
|
└── finger/
|
||||||
|
└── FINGER-001.json
|
||||||
|
```
|
||||||
|
|
||||||
|
例如,使用 `BOX-001` 时,`calibration_dir` 指向上图的 `calibrations` 目录,`device_id` 填 `BOX-001`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
sensor = orisys.Sensor(
|
||||||
|
0, # 摄像头编号,不是设备 ID
|
||||||
|
config_name="box",
|
||||||
|
calibration_dir="D:/orisys_data/calibrations",
|
||||||
|
device_id="BOX-001",
|
||||||
|
verbose=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
print(sensor.calibration.source_path if sensor.calibration else "未加载标定")
|
||||||
|
print(sensor.force_units) # 依次为 FN、FX、FY 的单位
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
```
|
||||||
|
或者使用命令:
|
||||||
|
```bash
|
||||||
|
python examples/basic_pipeline.py -v 0 -c box --calibration-dir data/calibrations --device-id BOX-001
|
||||||
|
```
|
||||||
|
|
||||||
|
若该设备的标定文件不存在,SDK 不应用标定,力值保持 raw,也不会回退到产品默认标定。若文件存在,但其中的设备 ID、产品类型或测量设置与当前设备不匹配,SDK 会报错。FingerRuntime.open() 支持这两个参数,但 finger_pipeline.py 目前还没有对应的命令行参数。
|
||||||
|
|
||||||
### 运行示例程序
|
### 运行示例程序
|
||||||
套件里面包含了多个例子。运行示例前,请先进入 SDK 示例所在的文件夹,建议在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。如果您是通过 PyPI 安装并且本地没有示例文件,可以从上方项目链接下载示例。按产品入口如下:
|
|
||||||
|
|
||||||
1. `examples/basic_pipeline.py`:ORY-BOX 复眼/拼接传感器基础 OpenCV 示例(默认配置 `box`)
|
示例的完整清单、每个产品对应的入口、全部命令与参数,见 [`examples/README.md`](../examples/README.md)。这里只给第一次运行的最短路径。
|
||||||
2. `examples/mini_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`)
|
|
||||||
3. `examples/finger_pipeline.py`:ORY-FINGER 2D 显示最小流程(默认配置 `finger`)
|
|
||||||
4. `examples/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `finger`)
|
|
||||||
|
|
||||||
`finger` 是当前 ORY-FINGER 的产品配置名。
|
先在仓库根目录(即 `config/`、`examples/`、`assets/` 同级)确认传感器 ID:
|
||||||
|
|
||||||
下面先介绍最基础的 ORY-BOX 运行方式。
|
- Windows:在 PowerShell 运行 `Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`。若插上传感器后多出一行,ID 为 `1`,否则为 `0`。
|
||||||
|
- Ubuntu:运行 `v4l2 --list-devices`,从新增的设备名取编号(`/dev/video0` 即 ID `0`)。
|
||||||
|
|
||||||
#### Windows
|
然后运行(假设 ID 为 `0`):
|
||||||
1. 检查是否有其他正在连接的 USB 摄像头设备:打开 PowerShell,运行命令 `Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。
|
|
||||||
2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增一行输出。
|
|
||||||
3. 如果第一步没有输出,传感器的 ID 就是 `0`;如果第一步有一个设备,传感器的 ID 就是 `1`,如此类推。
|
|
||||||
4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开 Anaconda Prompt,切换到工作路径,然后在命令行中输入:
|
|
||||||
```bash
|
|
||||||
python examples/basic_pipeline.py -v 0 -c box
|
|
||||||
```
|
|
||||||
5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
|
|
||||||
|
|
||||||
#### Ubuntu
|
|
||||||
1. 检查是否有其他正在连接的 USB 摄像头设备:打开终端,运行命令 `v4l2 --list-devices`,如果没有的话不会有输出。
|
|
||||||
2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增输出。
|
|
||||||
3. 根据新增的输出,例如 `/dev/video0`,确定传感器的 ID 为 `0`;如果是 `/dev/video3`,ID 为 `3`,如此类推。
|
|
||||||
4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开终端,激活相应的 Python 环境,切换到工作路径,然后在命令行中输入:
|
|
||||||
```bash
|
|
||||||
python examples/basic_pipeline.py -v 0 -c box
|
|
||||||
```
|
|
||||||
5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
|
|
||||||
|
|
||||||
#### ORY-FINGER 示例
|
|
||||||
|
|
||||||
如果您使用的是 ORY-FINGER(配置名 `finger`),可运行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/finger_pipeline.py --camera_id 0 --config finger
|
python examples/basic_pipeline.py -v 0 -c box
|
||||||
```
|
```
|
||||||
|
|
||||||
如果输入来自视频文件,也可以改用:
|
窗口打开后按 `q` 停止,按 `r` 重设数据。若本地没有示例文件(例如通过 PyPI 安装),从项目链接下载。
|
||||||
|
|
||||||
```bash
|
|
||||||
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
|
|
||||||
```
|
|
||||||
|
|
||||||
该示例会:
|
|
||||||
|
|
||||||
- 读取相机或视频源
|
|
||||||
- 将图像展开到平面
|
|
||||||
- 计算光流、法向/切向力和接触区域
|
|
||||||
- 在 OpenCV 窗口中显示展开后的光流箭头图
|
|
||||||
|
|
||||||
常用参数:
|
|
||||||
|
|
||||||
- `--verbose`:输出更详细的初始化日志
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python examples/finger_pipeline.py --camera_id 0 --config finger
|
|
||||||
```
|
|
||||||
|
|
||||||
#### ORY-FINGER 3D 可视化界面
|
|
||||||
|
|
||||||
如果要使用 GUI,先安装 GUI 依赖:
|
|
||||||
```bash
|
|
||||||
pip install "orisys[gui]"
|
|
||||||
```
|
|
||||||
然后运行:
|
|
||||||
```bash
|
|
||||||
python examples/finger_pipeline_3dview.py --video 0 --config finger
|
|
||||||
```
|
|
||||||
|
|
||||||
该界面会提供:
|
|
||||||
|
|
||||||
- 手指平面化光流箭头显示
|
|
||||||
- 力曲线
|
|
||||||
- 3D 视图
|
|
||||||
|
|
||||||
### 开发套件使用方法
|
### 开发套件使用方法
|
||||||
参考开发套件附带的开发文档。
|
参考开发套件附带的开发文档。
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
kind: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plan: move the GUI out of the SDK into `orisys-studio`
|
||||||
|
|
||||||
|
Status: **planned, then postponed (2026-09-30).** Nothing here is in progress. This document
|
||||||
|
records what would move, what would stay, and the one coupling that makes the move non-trivial, so
|
||||||
|
that the work can be picked up without re-deriving it. It is a plan, not a description of current
|
||||||
|
behaviour — everything described below is still in the SDK exactly as listed.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`examples/gui/` is not an example. It is a full Qt + open3d + pyqtgraph application — 30 modules,
|
||||||
|
4,815 lines — that ships inside the SDK wheel. The GUI belongs to the `orisys-studio` application,
|
||||||
|
which already exists for that purpose. Leaving it in the SDK means the SDK ships, versions and
|
||||||
|
installs an application it is not responsible for.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
| Path | Modules | Lines | What it is |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `examples/gui/` | 30 | 4,815 | The application: `viewer2d/`, `viewer3d/`, `widgets/`, `app.py`, `log_config.py` |
|
||||||
|
| `examples/viz/` | 11 | 2,298 | **Shared** drawing/rendering helpers — see the coupling below |
|
||||||
|
| `examples/pipeline/` | 6 | 577 | Sensor service, CSV recorder, profiling. **SDK support code, not GUI** |
|
||||||
|
|
||||||
|
### Good news: the SDK core does not depend on the GUI
|
||||||
|
|
||||||
|
Verified by AST, not by grep: **no module under `src/orisys/` imports `gui`, `viz`, `pipeline` or
|
||||||
|
`examples`.** The only match is `src/orisys/__init__.py` importing `orisys.pipeline`, which is the
|
||||||
|
SDK's own module. The dependency runs one way, so the GUI can leave without touching the library.
|
||||||
|
|
||||||
|
Two apparent hits are prose, not code: `src/orisys/configs.py` mentions `examples/` in a docstring
|
||||||
|
about run-root layout, and `export_sensors.py` has an `Examples::` docstring section. The
|
||||||
|
`configs.py` anchor is a real behavioural dependency to preserve — the run-root heuristic keys off
|
||||||
|
a sibling `examples/` directory, so that directory must keep existing in the packaged layout even
|
||||||
|
after `gui/` leaves it.
|
||||||
|
|
||||||
|
### The coupling: `viz/` is shared, so it cannot simply follow the GUI
|
||||||
|
|
||||||
|
```
|
||||||
|
examples/gui/ ──uses──▶ examples/viz/ ──uses──▶ orisys (SDK)
|
||||||
|
examples/pipeline/service.py ──uses──▶ examples/viz/
|
||||||
|
tests/test_contact_arrow_scale.py ──uses──▶ examples/viz/
|
||||||
|
```
|
||||||
|
|
||||||
|
`examples/pipeline/service.py` and a test depend on `viz`, and `viz` is a mix: `mesh_camera.py`,
|
||||||
|
`contact_arrow.py`, `flow_arrow.py` and `offscreen_mesh.py` are open3d rendering helpers, while
|
||||||
|
`render_2d.py`, `recording.py`, `signal_filters.py` and `force_charts.py` are toolkit-independent.
|
||||||
|
**Moving `viz/` wholesale would break `examples/pipeline/`.** It has to be split, not moved.
|
||||||
|
|
||||||
|
### The real coupling is in the tests, and it is inverted
|
||||||
|
|
||||||
|
`tests/test_qt_viewer_backend.py` imports `examples/gui` directly, and `tests/conftest.py` inserts
|
||||||
|
`EXAMPLES_ROOT` into `sys.path` with a guard whose stated reason is that **`examples/gui` must win
|
||||||
|
over `build/gui`, because both are packages named `gui`**. So the SDK's test suite currently
|
||||||
|
depends on the GUI application being present and on a name-resolution guard existing for it.
|
||||||
|
|
||||||
|
This is the inversion to fix: the SDK must not depend on the GUI.
|
||||||
|
|
||||||
|
Two related facts:
|
||||||
|
|
||||||
|
- `examples/gui/` contains committed `__pycache__` directories (`adapter.cpython-310.pyc`,
|
||||||
|
`cpython-313.pyc`, …) — build artefacts tracked alongside source.
|
||||||
|
- `tests/test_wheel_contents.py` and `tests/test_pyproject.py` assert on what the wheel bundles, so
|
||||||
|
they will need updating when the `gui/**/*.py` bundle pattern goes.
|
||||||
|
|
||||||
|
## What moves, what stays
|
||||||
|
|
||||||
|
**Moves to `orisys-studio`:**
|
||||||
|
|
||||||
|
- `examples/gui/**` — the whole application, as the single home for GUI code.
|
||||||
|
- `examples/finger_pipeline_3dview.py` — a 91-line launcher for the dashboard; its only purpose is
|
||||||
|
to start the GUI, so it belongs with the GUI.
|
||||||
|
- The open3d rendering half of `examples/viz/`: `contact_arrow.py`, `flow_arrow.py`,
|
||||||
|
`mesh_camera.py`, `offscreen_mesh.py`, plus the open3d-dependent `__init__` re-exports.
|
||||||
|
- `tests/test_qt_viewer_backend.py` — it tests the GUI, so it moves with the GUI.
|
||||||
|
|
||||||
|
**Stays in the SDK:**
|
||||||
|
|
||||||
|
- `examples/pipeline/` — SDK support code (sensor service, CSV recording, profiling).
|
||||||
|
- `examples/viz/` toolkit-independent half: `render_2d.py`, `recording.py`, `signal_filters.py`,
|
||||||
|
`force_charts.py` — needed by `examples/pipeline/service.py`.
|
||||||
|
- `examples/plot_data.py` — uses pyqtgraph for force charts only; 96 lines. Decide separately
|
||||||
|
whether it follows the GUI; it is the one remaining example with a Qt dependency after the move.
|
||||||
|
- `src/orisys/finger/viewer.py` (`UVMeshViewer`) — **see the deprecation note below.**
|
||||||
|
|
||||||
|
## The public-API problem, handled separately
|
||||||
|
|
||||||
|
`UVMeshViewer`, `init_uv_mesh_viewer` and `update_uv_mesh_viewer` are exported in
|
||||||
|
`src/orisys/finger/__init__.py`'s `__all__`. **Removing them is a breaking change to a released
|
||||||
|
API**, so it must not be bundled into a code move:
|
||||||
|
|
||||||
|
1. Release with them present and documented `DeprecationWarning`s emitted on construction.
|
||||||
|
2. Remove them at the next major version, with the release notes naming the replacement.
|
||||||
|
|
||||||
|
The audit (`docs/api-audit.md`) already lists `UVMeshViewer` among the surface that is used by
|
||||||
|
examples and absent from the reference.
|
||||||
|
|
||||||
|
## Sequence, lowest risk first
|
||||||
|
|
||||||
|
| # | Step | Risk | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Add `docs/plan-gui-extraction.md` (this file) | none | Done |
|
||||||
|
| 2 | Delete the committed `__pycache__` under `examples/gui/` and add it to `.gitignore` | none | Pure hygiene; ship independently |
|
||||||
|
| 3 | Tag the SDK at the commit whose `examples/gui` is known-good, so the studio move has a pinned source | none | A tag, not a submodule or subtree |
|
||||||
|
| 4 | In `orisys-studio`: import `examples/gui` + the open3d half of `viz` as its own provenance-stated change, reconciling against its existing viewer code | **medium** | The studio repo already has viewer code; this is a merge decision, not a copy |
|
||||||
|
| 5 | In the SDK: move `tests/test_qt_viewer_backend.py` out, and simplify the `conftest.py` guard | **medium** | The guard exists for `gui`; with `gui` gone, `EXAMPLES_ROOT` may still be needed for `viz`/`pipeline`. Re-derive, do not guess |
|
||||||
|
| 6 | In the SDK: retire `examples/gui/`, `finger_pipeline_3dview.py`, and the moved `viz` modules | low once 4 and 5 land | |
|
||||||
|
| 7 | Update the bundle patterns in `pyproject.toml` (drop `gui/**/*.py`, adjust `viz/**`) and the wheel tests | low | `test_wheel_contents.py`, `test_pyproject.py` |
|
||||||
|
| 8 | Update `examples/README.md` and the SDK reference to stop pointing at the dashboard | low | The README already says the GUI is moving |
|
||||||
|
| 9 | Deprecate `UVMeshViewer` in a release; remove at the next major | low | Separate release cycle |
|
||||||
|
|
||||||
|
Steps 2 and 3 are safe to do immediately and independently. Steps 4–5 are the actual move and need
|
||||||
|
both repositories. Nothing here requires a library behaviour change, which is what makes the move
|
||||||
|
tractable.
|
||||||
|
|
||||||
|
## Open questions for the studio side
|
||||||
|
|
||||||
|
- **Merge or adopt?** `orisys-studio` already has its own viewer. If its code is the descendant of
|
||||||
|
this one, take the SDK copy only where it is ahead; otherwise choose one and delete the other.
|
||||||
|
This must be decided before step 4, because the answer changes what gets imported.
|
||||||
|
- **Do the SDK's 2D and 3D viewers need to keep working headless for CI?** `examples/viz` contains
|
||||||
|
offscreen rendering used to produce evidence images. If any SDK or sensor-design workflow depends
|
||||||
|
on that, the offscreen half may need to stay reachable from the SDK even after the app moves.
|
||||||
|
- **Does `plot_data.py` follow the GUI?** It is an example, not an application, and it is the last
|
||||||
|
Qt-dependent file in `examples/`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `src/orisys/` still imports no `gui`/`viz`/`examples` module (re-run the AST check).
|
||||||
|
- [ ] The SDK test suite passes with `examples/gui/` absent.
|
||||||
|
- [ ] No module named `gui` ships in the wheel; `test_wheel_contents.py` asserts it.
|
||||||
|
- [ ] `examples/pipeline/service.py` and `tests/test_contact_arrow_scale.py` still import their
|
||||||
|
`viz` dependencies.
|
||||||
|
- [ ] `UVMeshViewer` is either deprecated-but-present or removed by an explicit major-version
|
||||||
|
decision — never dropped silently in a move.
|
||||||
|
- [ ] The `examples/` sibling directory still exists in the packaged layout, because
|
||||||
|
`configs.py`'s run-root anchor depends on it.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
title: Build and publish the Orisys SDK
|
||||||
|
description: Contributor release checklist and the verified build and publish entry points.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Build and publish the Orisys SDK
|
||||||
|
|
||||||
|
This guide gives contributors the path from a source change to release evidence.
|
||||||
|
The build launcher is [build/run.py](../build/run.py), documented in
|
||||||
|
[build/README.md](../build/README.md). Publishing to GitHub or PyPI changes external
|
||||||
|
systems and is a maintainer action. The existing
|
||||||
|
[Chinese release runbook](publishing.md) retains historical details; verify its
|
||||||
|
version-specific examples against current code before using them.
|
||||||
|
|
||||||
|
## Before building
|
||||||
|
|
||||||
|
Run the non-hardware tests from the repository root with the intended Python
|
||||||
|
environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest -m "not slow and not hardware"
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the current SDK version at `src/orisys/__init__.py`. `pyproject.toml` reads it
|
||||||
|
as a dynamic version. Do not copy a previous release number into new commands or
|
||||||
|
documentation. Check the source diff and confirm the intended product bundles and
|
||||||
|
assets are present; `pyproject.toml` declares which non-code files are packaged.
|
||||||
|
|
||||||
|
## Build the candidate
|
||||||
|
|
||||||
|
Use the launcher appropriate to the target. `wheel-win` invokes the Windows build;
|
||||||
|
`manylinux` invokes the Docker-backed Linux build. A single-interpreter `wheel`
|
||||||
|
build is useful for a local candidate, but does not cover every supported ABI.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python build/run.py wheel-win
|
||||||
|
python build/run.py manylinux
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows builds use the configured `py310`, `py311`, and `py312` environments.
|
||||||
|
The Linux build needs its documented Docker setup. Built wheels are placed under
|
||||||
|
`SDK/<version>/`; Linux upload candidates may also appear in `dist/repaired/`.
|
||||||
|
See [build/README.md](../build/README.md) for build prerequisites and narrower
|
||||||
|
commands. A contributor without these environments can still run tests and hand
|
||||||
|
off a source change; do not describe it as a verified release candidate.
|
||||||
|
|
||||||
|
## Run the release gate
|
||||||
|
|
||||||
|
After building, run `release preflight` on a clean, reviewed commit. It checks
|
||||||
|
repository state, version consistency, tests, bundled assets, wheel contents, and
|
||||||
|
an isolated runtime on recorded frames. `--check-pypi` adds a read-only duplicate
|
||||||
|
version check against PyPI. The report is written under
|
||||||
|
`build/release/reports/<version>/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python build/run.py release preflight --all-wheel-pythons --check-pypi
|
||||||
|
```
|
||||||
|
|
||||||
|
For work in progress, `--allow-dirty --keep-going` can collect diagnostics, but
|
||||||
|
that report does **not** certify a release. Do not use `--skip-tests`, `--no-runtime`,
|
||||||
|
or `--no-assets` to turn missing verification into approval. If the runtime clip is
|
||||||
|
missing, provide `--video <path>` or `ORISYS_VERIFY_VIDEO`; the gate should report
|
||||||
|
failure rather than silently claiming a pass.
|
||||||
|
|
||||||
|
For one existing wheel, `release audit <wheel>` checks it without preparing a
|
||||||
|
complete release. Add `--assets --runtime` when a suitable verification clip and
|
||||||
|
environment are available. This is useful for investigating a candidate; it does
|
||||||
|
not replace the full preflight gate.
|
||||||
|
|
||||||
|
## Release handoff and publication
|
||||||
|
|
||||||
|
Hand the maintainer the version, source commit, wheel filenames, preflight report,
|
||||||
|
and any hardware or platform checks that were not run. The publish commands read
|
||||||
|
the passing preflight report and verify the wheel hashes again.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python build/run.py release github --version <version>
|
||||||
|
python build/run.py release pypi --version <version>
|
||||||
|
```
|
||||||
|
|
||||||
|
These commands omit `--apply`, so they preview the publish plan. The GitHub preview
|
||||||
|
may still fetch remote tags; the PyPI preview may check credentials and the index.
|
||||||
|
Maintainers run the commands with `--apply` only after checking the target repository,
|
||||||
|
release notes, credentials, and candidate files. `release pypi --apply` targets
|
||||||
|
TestPyPI; publishing to the production index also requires `--confirm-pypi`.
|
||||||
|
GitHub publication can sync the release mirror, commit, tag, push, and create a
|
||||||
|
Release. See the launcher help and [build/README.md](../build/README.md) for the
|
||||||
|
exact flags. A published version cannot be overwritten, so do not treat a rerun as
|
||||||
|
a way to replace bad wheels.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Sensor bundles and the processing contract
|
||||||
|
|
||||||
|
A sensor bundle is a folder that describes one processing profile. This document owns the bundle
|
||||||
|
descriptor, the resolver, the backend registry and the fingerprints. `sensor.json` keeps its
|
||||||
|
existing meaning: it is the processing configuration, and its `type` field is the image-processing
|
||||||
|
arrangement (`compound_eye`, `single_camera`, `uv_unroll`) - never a product family.
|
||||||
|
|
||||||
|
## Why the descriptor exists
|
||||||
|
|
||||||
|
"Which pipeline processes these frames" used to be inferred from a product name. Those are four
|
||||||
|
different things, and collapsing them is what made Mini work live but be refused in replay:
|
||||||
|
|
||||||
|
| Concept | Meaning | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Product family | What the operator calls it | Box, Mini, Finger |
|
||||||
|
| Processing backend | Which SDK pipeline processes the images | `compound_eye`, `uv_unroll` |
|
||||||
|
| Configuration profile | Camera, geometry and algorithm settings for one design revision | `orisys.mini.prototype-r1` |
|
||||||
|
| Device instance | The physical unit | Serial number, its calibration |
|
||||||
|
|
||||||
|
Several families can share one backend, one family can have several profiles, and a recorded clip
|
||||||
|
has no device instance at all - only a profile and, optionally, a compatible calibration.
|
||||||
|
|
||||||
|
## Descriptor
|
||||||
|
|
||||||
|
`manifest.json` in the bundle root. Every field except the five identifying keys is optional, so a
|
||||||
|
bundle with no manifest still resolves through the compatibility adapter.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"profile_id": "orisys.mini.prototype-r1",
|
||||||
|
"display_name": "Mini prototype R1",
|
||||||
|
"product_family": "mini",
|
||||||
|
"processing_backend": "compound_eye",
|
||||||
|
"sensor_json": "sensor.json",
|
||||||
|
"default_calibration": "force_calibration.json",
|
||||||
|
"computational_assets": { "unroll_map": "assets/30fps-0703/unroll_map_uv.npz" },
|
||||||
|
"assets": { "scene": "scene/finger-display-1.0.0.oryscene" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`computational_assets`** are required to process at all. A missing one fails the profile.
|
||||||
|
- **`assets`** are visualization only. A missing one is reported and never blocks processing - a
|
||||||
|
missing 3D scene is not a missing deformation field.
|
||||||
|
- Paths resolve relative to the bundle root, so moving or renaming the folder changes nothing.
|
||||||
|
Absolute paths and `..` escapes are rejected at parse time.
|
||||||
|
- An unsupported `processing_backend` or `schema_version` raises `DescriptorError`. The supported
|
||||||
|
descriptor schema version is 1.
|
||||||
|
|
||||||
|
## Compatibility for existing bundles
|
||||||
|
|
||||||
|
`resolve_bundle` reads a new-shape descriptor when `schema_version` is present. Otherwise - no
|
||||||
|
manifest, or the older `name`/`sensor_json`/`assets_dir`/`viz_pose` shape - the adapter describes
|
||||||
|
it. That is the only place a folder name is consulted, and only for the built-in set:
|
||||||
|
|
||||||
|
| Folder | Product family | Backend (when the config does not decide) |
|
||||||
|
|---|---|---|
|
||||||
|
| `box` | box | `compound_eye` |
|
||||||
|
| `mini` | mini | `compound_eye` |
|
||||||
|
| `finger` | finger | `uv_unroll` |
|
||||||
|
| `finger-mems` | finger-mems | `compound_eye` |
|
||||||
|
|
||||||
|
For a bundle without a descriptor the backend follows the configuration, using the rule
|
||||||
|
`Sensor._load_config` already applies: `type` when present, else `compound_eye` when the config has
|
||||||
|
a `stitcher` block, else `single_camera`. Only `finger` declares `type` today; `box`, `mini` and
|
||||||
|
`finger-mems` are decided by the stitcher. A folder that is neither built-in nor carries a
|
||||||
|
descriptor is rejected - never routed to `compound_eye` because box works that way.
|
||||||
|
|
||||||
|
## Resolver
|
||||||
|
|
||||||
|
```python
|
||||||
|
from orisys import resolve_bundle, validate_profile, create_processor, CalibrationSelection
|
||||||
|
|
||||||
|
profile = resolve_bundle("sensors/mini") # or any bundle folder
|
||||||
|
profile = resolve_bundle("sensors/mini", calibration_selection=CalibrationSelection.NONE)
|
||||||
|
session = validate_profile(profile) # re-check against the filesystem now
|
||||||
|
rebuilt = create_processor(profile, source=0) # live camera
|
||||||
|
rebuilt = create_processor(profile, source="clip.mp4") # recorded frames, same semantics
|
||||||
|
```
|
||||||
|
|
||||||
|
`resolve_bundle(bundle_path, *, calibration_selection=None) -> ResolvedSensorProfile`. The profile
|
||||||
|
is plain data - no Qt, no application preferences, no device handle - so the same object drives a
|
||||||
|
camera and a recorded clip. `_set_calibration` on a device affects only its own calibration
|
||||||
|
selection.
|
||||||
|
|
||||||
|
`validate_profile(profile) -> ValidationReport` re-reads the filesystem. It reports
|
||||||
|
`fingerprint.stale` when a computational asset changed since the profile was resolved.
|
||||||
|
|
||||||
|
`create_processor(profile, *, sensor=None, source=None, include=..., contact=...)` returns a
|
||||||
|
`ProcessingSession` with `start`/`wait`/`latest`/`reset`/`pause`/`resume`/`stop`. It raises
|
||||||
|
`ProcessorCreationError` naming what is missing rather than returning a session that does nothing.
|
||||||
|
|
||||||
|
### Backends
|
||||||
|
|
||||||
|
| Identifier | Requires | Produces |
|
||||||
|
|---|---|---|
|
||||||
|
| `compound_eye` | `camera`, `stitcher`, `tracker` config | stitched frame, flow, force |
|
||||||
|
| `single_camera` | `camera` config | frames only - no deformation field, so no force |
|
||||||
|
| `uv_unroll` | `camera` config, the mapped computational asset | unrolled frame, flow, force |
|
||||||
|
|
||||||
|
`known_backends()` and `spec_for(...)` are the registry. `uv_unroll` is not yet materialised from a
|
||||||
|
profile alone, because it needs geometry, a UV domain, a tactile processor and a capture object
|
||||||
|
assembled together - that is `orisys.finger.FingerRuntime.open`'s shape. `create_processor` says so
|
||||||
|
instead of pretending.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
Each capability is decided by the facts it depends on, not by the presence of a file:
|
||||||
|
|
||||||
|
| Capability | Unavailable when |
|
||||||
|
|---|---|
|
||||||
|
| Video processing | the configuration cannot be loaded |
|
||||||
|
| Optical flow | no `tracker` block |
|
||||||
|
| Raw force | the backend produces no deformation field |
|
||||||
|
| Calibrated force | per axis: the calibration has no curve for it |
|
||||||
|
| Deformation | the backend computes no deformation field |
|
||||||
|
| 3D visualization | no scene asset is declared, or the declared one is missing |
|
||||||
|
|
||||||
|
Deformation is relative unless a calibration says otherwise; it is reported with its meaning rather
|
||||||
|
than as a bare "available". `ValidationReport.state` is `ok`, `degraded` (usable, something
|
||||||
|
optional is missing) or `failed`.
|
||||||
|
|
||||||
|
## Calibration
|
||||||
|
|
||||||
|
`CalibrationSelection` is `BUNDLE_DEFAULT` (the default), `NONE`, or an explicit path.
|
||||||
|
|
||||||
|
- `NONE` is a choice, not a failure: raw force stays available and the calibration fingerprint
|
||||||
|
records `none`.
|
||||||
|
- An explicitly selected calibration that is missing or unparseable is an **error**. It is never
|
||||||
|
replaced by another file.
|
||||||
|
- Applicability is `declared` when the document carries `meta.sensor_type`, `unknown` when legacy
|
||||||
|
metadata is absent, or `none`. `unknown` means it applies but its fit is unverified - which is
|
||||||
|
what the shipped `box` calibration reports.
|
||||||
|
|
||||||
|
| Read | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `FrameSnapshot.normal_force_raw` / `shear_x_raw` / `shear_y_raw` | device counts, pre-calibration |
|
||||||
|
| `FrameSnapshot.normal_force` / `shear_x` / `shear_y` | newtons when a curve applies, counts otherwise |
|
||||||
|
| `FrameSnapshot.depth`, `centroid`, `contour`, `vnormal` | relative quantities, not newtons |
|
||||||
|
|
||||||
|
`Sensor.force_triplets()` and `_force_raw` remain the raw and unfiltered view.
|
||||||
|
|
||||||
|
## Fingerprints
|
||||||
|
|
||||||
|
`processing_fingerprint` hashes the effective backend, the paired data, and each computational asset's role name with
|
||||||
|
its content hash. It excludes the bundle location, `display_name`, and every visualization asset: a
|
||||||
|
relocated or cosmetically renamed bundle keeps its fingerprint, and a missing scene never changes it.
|
||||||
|
It includes the config minus `name` and `version`.
|
||||||
|
|
||||||
|
`calibration_fingerprint` hashes the selected document's content, or the constant `none`.
|
||||||
|
|
||||||
|
`profile.inventory()` returns the reproducible snapshot: bundle-relative paths, asset hashes and
|
||||||
|
both fingerprints. Compare an inventory, not a path, to decide whether two installations would
|
||||||
|
compute the same numbers.
|
||||||
|
|
||||||
|
## Resetting the reference
|
||||||
|
|
||||||
|
Both backends process externally supplied frames through the same profile. After a gap in the
|
||||||
|
input - a pause, a seek, a scene change - call `session.reset()`. It is queued to the stage that
|
||||||
|
owns the tracker, so the reference frame is rebased on the frame about to be computed rather than
|
||||||
|
on a buffer the pipeline is still reading.
|
||||||
+62
-47
@@ -1,75 +1,90 @@
|
|||||||
# Examples
|
# Examples
|
||||||
|
|
||||||
Runnable demos live at the `examples/` root. Shared helpers stay in
|
Runnable demos live at the `examples/` root. Shared helpers stay in `pipeline/`, `viz/`, and
|
||||||
`pipeline/`, `viz/`, and `gui/`. Run commands from the repository root.
|
`gui/`. Run commands from the repository root.
|
||||||
|
|
||||||
## Product Entry Points
|
## Start here
|
||||||
|
|
||||||
|
Both recommended examples create one object, then read a frame at a time. Everything else -
|
||||||
|
camera reading, stitching, preprocessing, the optical-flow and force stages - happens inside
|
||||||
|
`StreamPipeline`, and each frame arrives as one `FrameSnapshot`. There is no stage sequence to
|
||||||
|
remember and no tuple to unpack, and no backend setting to tune.
|
||||||
|
|
||||||
| Product | Entry | Default config |
|
| Product | Entry | Default config |
|
||||||
|---------|-------|----------------|
|
|---------|-------|----------------|
|
||||||
| ORY-BOX | `basic_pipeline.py` | `box` |
|
| ORY-BOX | `basic_pipeline.py` | `box` |
|
||||||
| ORY-MINI | `mini_pipeline.py` | `mini` |
|
| ORY-MINI | `basic_pipeline.py -c mini` | `mini` |
|
||||||
| ORY-FINGER (2D) | `finger_pipeline.py` | `finger` |
|
| ORY-FINGER | `finger_pipeline.py` | `finger` |
|
||||||
| ORY-FINGER (3D Qt) | `finger_pipeline_3dview.py` | `finger` |
|
|
||||||
|
|
||||||
`finger` is the public ORY-FINGER config stem. `dd02` remains a compatibility alias for `dd02-ov`.
|
```python
|
||||||
|
# ORY-BOX / ORY-MINI
|
||||||
|
sensor = orisys.Sensor(0, config_name="box")
|
||||||
|
with orisys.StreamPipeline(sensor, sensor.get_img, contact=True) as pipe:
|
||||||
|
while (snap := pipe.wait()) is not None:
|
||||||
|
print(snap.normal_force, snap.shear_x, snap.shear_y)
|
||||||
|
|
||||||
## Typical Usage
|
# ORY-FINGER - the runtime owns camera, Blender export, unroll map and valid mask
|
||||||
|
runtime = FingerRuntime.open(config="finger", video=0)
|
||||||
ORY-BOX:
|
with runtime.pipeline(contact=True) as pipe:
|
||||||
|
while (snap := pipe.wait()) is not None:
|
||||||
```bash
|
print(snap.normal_force, snap.shear_x, snap.shear_y)
|
||||||
python examples/basic_pipeline.py -v 0 -c box
|
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-MINI:
|
`contact=True` runs contact analysis; `include=(...)` chooses which arrays each snapshot carries
|
||||||
|
(`image`, `flow`, `vnormal`, `vshear`, `depth`, `centroid`, `contour`). Arrays you do not include
|
||||||
```bash
|
are `None`, so you pay only for what you use. `pipe.reset()` resets the tracker safely from your
|
||||||
python examples/mini_pipeline.py -v 0 -c mini
|
loop; `snap.image_size_raw` reports the capture resolution before UV unroll or resize.
|
||||||
```
|
|
||||||
|
### Typical usage
|
||||||
ORY-FINGER 2D:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
python examples/basic_pipeline.py -v 0 -c box # first camera, ORY-BOX
|
||||||
|
python examples/basic_pipeline.py -v 0 -c mini # ORY-MINI
|
||||||
|
python examples/basic_pipeline.py -v recording.mp4 -c box
|
||||||
|
python examples/basic_pipeline.py --no-show # headless: print only
|
||||||
python examples/finger_pipeline.py --camera_id 0 --config finger
|
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||||
|
python examples/finger_pipeline.py --video data/dd02-0703/1.mp4
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-FINGER video file:
|
Keys in both: `q` quits, `r` resets the tracker.
|
||||||
|
|
||||||
```bash
|
## The manual form, for when you need the stages
|
||||||
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
|
|
||||||
|
`examples/archive/` holds the previous versions of these two examples. They call the stages by
|
||||||
|
hand:
|
||||||
|
|
||||||
|
```python
|
||||||
|
sensor.get_img()
|
||||||
|
sensor.compute_deformation()
|
||||||
|
sensor.compute_contact()
|
||||||
|
fps, flow, vnormal, img, ... = sensor.read_info( ... ) # positional, 13 elements
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-FINGER 3D Qt dashboard:
|
That path is still supported and still correct - it is the right choice if you are implementing
|
||||||
|
your own scheduling. It is no longer the recommended starting point, and the archived files carry
|
||||||
|
a header explaining the difference. They are kept so that code copied from them keeps working.
|
||||||
|
|
||||||
```bash
|
## Other root examples
|
||||||
pip install -e ".[gui]"
|
|
||||||
python examples/finger_pipeline_3dview.py --video 0 --config finger
|
|
||||||
```
|
|
||||||
|
|
||||||
## Other Root Examples
|
- `multi_stream.py` - several sensors at once, using `StreamPipeline` with `StreamRecorder`
|
||||||
|
- `two_sensor_pipeline.py` - two-sensor multiprocessing demo, manual staging
|
||||||
- `plot_data.py` — single-sensor OpenCV views plus PyQtGraph force charts
|
- `plot_data.py` - single-sensor OpenCV views plus PyQtGraph force charts
|
||||||
- `two_sensor_pipeline.py` — two-sensor multiprocessing demo
|
- `finger_pipeline_3dview.py` - the Qt/open3d dashboard; see the note below
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- SDK installed from the repository root, for example `pip install -e .`
|
- SDK installed from the repository root: `pip install -e .`
|
||||||
- camera index or test video path
|
- a camera index or a test video path
|
||||||
- matching config names under `./config/` (or packaged `orisys/configs/`)
|
- matching config names under `./config/` or the packaged `orisys/configs/`
|
||||||
|
|
||||||
Qt / PyQtGraph examples additionally require:
|
Qt / PyQtGraph examples additionally need `pip install -e ".[gui]"`.
|
||||||
|
|
||||||
```bash
|
## The GUI lives here for now
|
||||||
pip install -e ".[gui]"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
`examples/gui/` - the 2D viewer, the 3D dashboard and their widgets - is a full Qt + open3d
|
||||||
|
application rather than an example, and its proper home is the **orisys-studio** application.
|
||||||
|
That move is **planned but not started**; `finger_pipeline_3dview.py` and `examples/gui/` work
|
||||||
|
exactly as before. The plan, including the coupling that makes it non-trivial, is in
|
||||||
|
`docs/plan-gui-extraction.md`.
|
||||||
|
|
||||||
- `finger_pipeline_3dview.py` is the main interactive finger demo.
|
The practical guidance is only that new SDK work should not build on `examples/gui/`.
|
||||||
It uses `gui/app_paths.py` so the same entry works in editable and packaged runs.
|
|
||||||
- Dashboard controls: click **启动** to start acquisition; use **Record CSV / Stop CSV**
|
|
||||||
and the CSV save-directory browser to log contact/force data; `Q` quits, `R` resets,
|
|
||||||
`S` starts video recording, `E` stops video recording, and `Esc` toggles fullscreen.
|
|
||||||
- `finger_pipeline.py` is the leanest finger example if you want to
|
|
||||||
validate UV unroll + force/contact processing without the full dashboard.
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Display helpers shared by the pipeline examples.
|
||||||
|
|
||||||
|
These exist so an example can show a result without hand-rolling OpenCV arithmetic. Everything
|
||||||
|
here is presentation only: the SDK never draws, and nothing in ``orisys`` imports this module.
|
||||||
|
|
||||||
|
The examples import it through a sibling import, which works when the examples are run from the
|
||||||
|
examples directory or with ``examples/`` on ``sys.path``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def draw_flow_arrows(image, flow, *, threshold=0.4, grid_spacing=10, arrow_scale=1.0,
|
||||||
|
valid_mask=None):
|
||||||
|
"""Return the deformation field drawn over ``image``."""
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
return orisys.util.draw_arrows(
|
||||||
|
image,
|
||||||
|
flow,
|
||||||
|
threshold=threshold,
|
||||||
|
grid_spacing=grid_spacing,
|
||||||
|
arrow_scale=arrow_scale,
|
||||||
|
valid_mask=valid_mask,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def depth_to_color(depth_map):
|
||||||
|
"""Normalise a depth/divergence map to 8-bit and apply a colormap for the ``depth`` window.
|
||||||
|
|
||||||
|
Returns ``None`` for an empty map, which is what ``read_info`` gives when the field was never
|
||||||
|
generated, so a caller can skip the window instead of showing saturated noise.
|
||||||
|
"""
|
||||||
|
if depth_map is None or not isinstance(depth_map, np.ndarray) or depth_map.size == 0:
|
||||||
|
return None
|
||||||
|
lo = float(depth_map.min())
|
||||||
|
hi = float(depth_map.max())
|
||||||
|
if hi <= lo:
|
||||||
|
scaled = np.zeros_like(depth_map, dtype=np.uint8)
|
||||||
|
else:
|
||||||
|
scaled = ((depth_map - lo) / (hi - lo) * 255.0).astype(np.uint8)
|
||||||
|
return cv2.applyColorMap(scaled, cv2.COLORMAP_JET)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_contact(image, contour, centroid):
|
||||||
|
"""Return ``image`` with the contact region and centroid drawn on it."""
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
return orisys.util.draw_contact(image, contour, centroid)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
ORY-BOX:单传感器基础流程(**已归档的手写版本**)
|
||||||
|
|
||||||
|
本示例演示 Orisys SDK 的标准处理流程:
|
||||||
|
1. 初始化传感器(摄像头或视频文件)
|
||||||
|
2. 获取图像并计算形变
|
||||||
|
3. 按需计算接触区域
|
||||||
|
4. 读取并可视化结果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**已归档。请改用 `examples/basic_pipeline.py`。**
|
||||||
|
|
||||||
|
保留本文件是为了兼容已经复制过这段代码的用户。它逐级调用
|
||||||
|
`get_img()` → `compute_deformation()` → `compute_contact()` → `read_info()`,
|
||||||
|
需要自己记住调用顺序,并按下标解包一个 13 元组。
|
||||||
|
|
||||||
|
推荐写法是交给 `orisys.StreamPipeline`:读取、预处理、力/接触解算在内部并发完成,每一帧以
|
||||||
|
单个 `FrameSnapshot` 交回:
|
||||||
|
|
||||||
|
with orisys.StreamPipeline(sensor, sensor.get_img, contact=True) as pipe:
|
||||||
|
snap = pipe.wait() # 一个对象,一次调用
|
||||||
|
print(snap.normal_force, snap.shear_x, snap.shear_y)
|
||||||
|
|
||||||
|
本文件里的 `sensor.reset()` 在 pipeline 中对应 `pipe.reset()`:它会排队到流程线程执行,
|
||||||
|
不会与光流阶段争用同一帧缓冲。
|
||||||
|
|
||||||
|
**注意**:本文件用中文 `print`,在 cp1252 控制台(未设 UTF-8 的 Windows 终端)会抛
|
||||||
|
`UnicodeEncodeError`。这是归档前就存在的行为,没有在这里修正——新示例的输出去掉了非 ASCII
|
||||||
|
字符,因此在任何终端都能跑。
|
||||||
|
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
|
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="视频源:摄像头编号或视频文件路径")
|
||||||
|
parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径")
|
||||||
|
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
|
||||||
|
parser.add_argument("--cal_path","-cal", type=str, default=None, help="标定文件路径")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 步骤 1:创建传感器对象(必需)
|
||||||
|
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
|
||||||
|
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
|
||||||
|
|
||||||
|
# 输入既可以是摄像头编号,也可以是视频文件路径
|
||||||
|
if args.video.isdigit():
|
||||||
|
sensor = orisys.Sensor(int(args.video), config_name=args.config, verbose=args.verbose, cal_path=args.cal_path)
|
||||||
|
else:
|
||||||
|
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose, cal_path=args.cal_path)
|
||||||
|
|
||||||
|
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# 步骤 2:数据处理
|
||||||
|
# 步骤 2.1:获取并拼接图像(必需)
|
||||||
|
sensor.get_img()
|
||||||
|
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
|
||||||
|
is_contact_now = sensor.compute_deformation()
|
||||||
|
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
|
||||||
|
# if is_contact_now:
|
||||||
|
sensor.compute_contact()
|
||||||
|
|
||||||
|
|
||||||
|
# 步骤 2.4:读取结果(FNORMAL/FSHEAR* 在有 sensors/box/force_calibration.json 时为标定后牛顿力;
|
||||||
|
# *_RAW 始终为原始积分量)
|
||||||
|
(
|
||||||
|
fps,
|
||||||
|
flow,
|
||||||
|
vnormal,
|
||||||
|
img,
|
||||||
|
contour,
|
||||||
|
centroid,
|
||||||
|
depth_map,
|
||||||
|
fnormal,
|
||||||
|
fshearx,
|
||||||
|
fsheary,
|
||||||
|
fnormal_raw,
|
||||||
|
fshearx_raw,
|
||||||
|
fsheary_raw,
|
||||||
|
) = sensor.read_info(
|
||||||
|
sensor.info.FPS,
|
||||||
|
sensor.info.VRAW,
|
||||||
|
sensor.info.VNORMAL,
|
||||||
|
sensor.info.IMG,
|
||||||
|
sensor.info.CONTOUR,
|
||||||
|
sensor.info.CENTROID,
|
||||||
|
sensor.info.DEPTH,
|
||||||
|
sensor.info.FNORMAL,
|
||||||
|
sensor.info.FSHEARX,
|
||||||
|
sensor.info.FSHEARY,
|
||||||
|
sensor.info.FNORMAL_RAW,
|
||||||
|
sensor.info.FSHEARX_RAW,
|
||||||
|
sensor.info.FSHEARY_RAW,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 步骤 3:可视化显示
|
||||||
|
# 步骤 3.1:显示形变矢量场
|
||||||
|
arrows = orisys.util.draw_arrows(
|
||||||
|
img,
|
||||||
|
flow,
|
||||||
|
threshold=2,
|
||||||
|
grid_spacing=20,
|
||||||
|
arrow_scale=1.0
|
||||||
|
)
|
||||||
|
cv2.imshow("arrow", arrows)
|
||||||
|
|
||||||
|
# 步骤 3.2:显示接触区域和质心
|
||||||
|
image_with_foe = orisys.util.draw_contact(img, contour, centroid)
|
||||||
|
cv2.imshow("contact", image_with_foe)
|
||||||
|
|
||||||
|
# 步骤 3.3:显示深度图
|
||||||
|
# 将深度值归一化到 0-255 范围
|
||||||
|
div_abs = depth_map
|
||||||
|
if div_abs.max() > div_abs.min():
|
||||||
|
div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
|
||||||
|
else:
|
||||||
|
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
|
||||||
|
|
||||||
|
# 应用伪彩色映射
|
||||||
|
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
||||||
|
|
||||||
|
# 步骤 3.4:打印关键结果(标定力 + 原始力)
|
||||||
|
print(
|
||||||
|
f"FPS={fps:.2f}, "
|
||||||
|
f"法向力={fnormal:.4f} N (raw={fnormal_raw:.4f}), "
|
||||||
|
f"切向力X={fshearx:.4f} N (raw={fshearx_raw:.4f}), "
|
||||||
|
f"切向力Y={fsheary:.4f} N (raw={fsheary_raw:.4f}), "
|
||||||
|
f"深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 步骤 3.5:键盘控制
|
||||||
|
key = cv2.waitKey(1) & 0xFF
|
||||||
|
if key == ord("q"):
|
||||||
|
print("\n正在退出示例程序...")
|
||||||
|
break
|
||||||
|
if key == ord("r"):
|
||||||
|
# 当光流追踪出现漂移时,可手动重置追踪器
|
||||||
|
sensor.reset()
|
||||||
|
print("追踪器已重置。")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 4. 释放资源
|
||||||
|
# =========================================================================
|
||||||
|
sensor.disconnect() # 释放视频源及相关资源
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ORY-FINGER: Minimum UV-unroll finger tactile pipeline. **ARCHIVED — manual version.**
|
||||||
|
|
||||||
|
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
|
||||||
|
Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
|
||||||
|
|
||||||
|
Recommended entry:
|
||||||
|
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||||
|
|
||||||
|
Current product config: ``finger`` (``dd02`` still resolves to ``dd02-ov``).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**ARCHIVED. Use ``examples/finger_pipeline.py`` instead.**
|
||||||
|
|
||||||
|
Kept so code copied from this file keeps working. It hand-sequences ``grab_and_process()`` →
|
||||||
|
``compute_deformation()`` → ``compute_contact()`` → ``read_info()`` and unpacks a ten-element
|
||||||
|
tuple by position.
|
||||||
|
|
||||||
|
The recommended form lets :class:`orisys.StreamPipeline` run those stages concurrently and hand
|
||||||
|
back one immutable snapshot per frame:
|
||||||
|
|
||||||
|
with runtime.pipeline(contact=True, include=("image", "depth")) as pipe:
|
||||||
|
snap = pipe.wait()
|
||||||
|
print(snap.normal_force, snap.shear_x, snap.shear_y)
|
||||||
|
|
||||||
|
``tactile.reset()`` becomes ``pipe.reset()``, which is queued onto the flow-stage thread instead of
|
||||||
|
racing it.
|
||||||
|
|
||||||
|
This archived version also has a defect: ``--show`` is declared ``store_true`` with
|
||||||
|
``default=True``, so it can never be turned off. The replacement uses ``--no-show``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
import orisys
|
||||||
|
from orisys.finger import FingerRuntime
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline")
|
||||||
|
parser.add_argument("--camera_id", type=int, default=0)
|
||||||
|
parser.add_argument("--video","-v", help="Video file path (overrides camera)")
|
||||||
|
parser.add_argument("--config", "-c", default="finger", help="Orisys SDK config name or path")
|
||||||
|
parser.add_argument("--show", action="store_true", default=True, help="Show unrolled image window")
|
||||||
|
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
video = args.camera_id
|
||||||
|
if args.video:
|
||||||
|
video = args.video
|
||||||
|
runtime = FingerRuntime.open(
|
||||||
|
config=args.config,
|
||||||
|
video=video,
|
||||||
|
verbose=args.verbose,
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
geometry = runtime.geometry
|
||||||
|
print(
|
||||||
|
f"UV geometry: native={geometry.native_width}x{geometry.native_height}, "
|
||||||
|
f"source={geometry.source_width}x{geometry.source_height} "
|
||||||
|
f"output={geometry.out_width}x{geometry.out_height} "
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime.warmup(color=False)
|
||||||
|
tactile = runtime.tactile
|
||||||
|
|
||||||
|
print("Press 'q' to quit, 'r' to reset tracker.")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
ok, unrolled = runtime.grab_and_process(color=False)
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
|
||||||
|
tactile.compute_deformation()
|
||||||
|
tactile.compute_contact()
|
||||||
|
|
||||||
|
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = tactile.read_info(
|
||||||
|
tactile.info.FPS,
|
||||||
|
tactile.info.VRAW,
|
||||||
|
tactile.info.VNORMAL,
|
||||||
|
tactile.info.IMG,
|
||||||
|
tactile.info.CONTOUR,
|
||||||
|
tactile.info.CENTROID,
|
||||||
|
tactile.info.DEPTH,
|
||||||
|
tactile.info.FNORMAL,
|
||||||
|
tactile.info.FSHEARX,
|
||||||
|
tactile.info.FSHEARY,
|
||||||
|
)
|
||||||
|
|
||||||
|
status = (
|
||||||
|
f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) "
|
||||||
|
f"mask={tactile.mask_stats['coverage']:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.show:
|
||||||
|
arrows = orisys.util.draw_arrows(
|
||||||
|
unrolled,
|
||||||
|
flow,
|
||||||
|
threshold=0.4,
|
||||||
|
grid_spacing=10,
|
||||||
|
arrow_scale=1.0,
|
||||||
|
valid_mask=tactile.valid_mask,
|
||||||
|
)
|
||||||
|
cv2.imshow("UV Unrolled", arrows)
|
||||||
|
|
||||||
|
print(status, end="\r")
|
||||||
|
|
||||||
|
key = cv2.waitKey(1) & 0xFF
|
||||||
|
if key == ord("q"):
|
||||||
|
print("\nExiting.")
|
||||||
|
break
|
||||||
|
if key == ord("r"):
|
||||||
|
tactile.reset()
|
||||||
|
print("\nTracker reset.")
|
||||||
|
finally:
|
||||||
|
runtime.close()
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+126
-117
@@ -1,133 +1,142 @@
|
|||||||
"""
|
"""ORY-BOX / ORY-MINI: read a sensor and get tactile information, in one loop.
|
||||||
ORY-BOX:单传感器基础流程
|
|
||||||
|
|
||||||
本示例演示 Orisys SDK 的标准处理流程:
|
The whole recommended flow:
|
||||||
1. 初始化传感器(摄像头或视频文件)
|
|
||||||
2. 获取图像并计算形变
|
|
||||||
3. 按需计算接触区域
|
|
||||||
4. 读取并可视化结果
|
|
||||||
|
|
||||||
|
sensor -> StreamPipeline -> FrameSnapshot per frame
|
||||||
|
|
||||||
|
One object reads the camera, pre-processes (stitch, grey, resize), computes the deformation field
|
||||||
|
and the forces, and hands back one immutable snapshot per frame. There is no stage sequence to
|
||||||
|
remember and no tuple to unpack by position.
|
||||||
|
|
||||||
|
What you do NOT need to touch: the compute backend, the capture queue, the stitch mode, the
|
||||||
|
low-pass filter, or the order in which the stages run. Those are the SDK's concern and
|
||||||
|
``StreamPipeline`` sequences them on three concurrent threads. If you later need that control, the
|
||||||
|
manual form is preserved in ``examples/archive/basic_pipeline_manual.py``.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
python examples/basic_pipeline.py # first camera, box config
|
||||||
|
python examples/basic_pipeline.py -v 0 -c mini
|
||||||
|
python examples/basic_pipeline.py -v recording.mp4 -c box
|
||||||
|
python examples/basic_pipeline.py --no-show # headless: print only
|
||||||
|
python examples/basic_pipeline.py -v 0 -c box --calibration-dir data/calibrations --device-id BOX-001
|
||||||
|
|
||||||
|
Keys: 'q' quit, 'r' reset the tracker.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
|
||||||
import cv2
|
|
||||||
import orisys
|
|
||||||
import numpy as np
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
def main():
|
# Set before OpenCV is imported: stops the capture backend applying its own transform on top of
|
||||||
parser = argparse.ArgumentParser()
|
# the SDK's preprocessing. This is a capture detail, not a tuning knob you need to own - it is
|
||||||
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
# here so the file can be copied as a whole.
|
||||||
parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径")
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||||
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
|
|
||||||
parser.add_argument("--cal_path","-cal", type=str, default=None, help="标定文件路径")
|
import cv2
|
||||||
|
|
||||||
|
import orisys
|
||||||
|
from _display import depth_to_color, draw_contact, draw_flow_arrows
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Read a sensor and print its tactile information.")
|
||||||
|
parser.add_argument("-v", "--video", default="0",
|
||||||
|
help="camera index or video file path (default: 0)")
|
||||||
|
parser.add_argument("-c", "--config", default="box",
|
||||||
|
help="product config: box, mini, finger, or a config path")
|
||||||
|
calibration = parser.add_mutually_exclusive_group()
|
||||||
|
calibration.add_argument("--cal", default=None,
|
||||||
|
help="force calibration file; omit to use the sensor bundle's own")
|
||||||
|
calibration.add_argument("--calibration-dir", default=None,
|
||||||
|
help="root containing <product>/<device-id>.json")
|
||||||
|
parser.add_argument("--device-id", default=None,
|
||||||
|
help="physical sensor ID; required with --calibration-dir")
|
||||||
|
parser.add_argument("--no-show", dest="show", action="store_false",
|
||||||
|
help="do not open the display windows")
|
||||||
|
parser.add_argument("-verbose", action="store_true", help="SDK verbose logs")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if args.calibration_dir is not None and args.device_id is None:
|
||||||
|
parser.error("--calibration-dir requires --device-id")
|
||||||
|
if args.device_id is not None and args.calibration_dir is None and args.cal is None:
|
||||||
|
parser.error("--device-id requires --calibration-dir or --cal")
|
||||||
|
|
||||||
# 步骤 1:创建传感器对象(必需)
|
# ------------------------------------------------------------------ create the object
|
||||||
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
|
# That is the whole setup. The source may be a camera index or a video path.
|
||||||
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
|
source = int(args.video) if args.video.isdigit() else args.video
|
||||||
|
sensor = orisys.Sensor(
|
||||||
# 输入既可以是摄像头编号,也可以是视频文件路径
|
source, config_name=args.config, cal_path=args.cal,
|
||||||
if args.video.isdigit():
|
calibration_dir=args.calibration_dir, device_id=args.device_id,
|
||||||
sensor = orisys.Sensor(int(args.video), config_name=args.config, verbose=args.verbose, cal_path=args.cal_path)
|
verbose=args.verbose,
|
||||||
else:
|
|
||||||
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose, cal_path=args.cal_path)
|
|
||||||
|
|
||||||
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
# 步骤 2:数据处理
|
|
||||||
# 步骤 2.1:获取并拼接图像(必需)
|
|
||||||
sensor.get_img()
|
|
||||||
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
|
|
||||||
is_contact_now = sensor.compute_deformation()
|
|
||||||
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
|
|
||||||
# if is_contact_now:
|
|
||||||
sensor.compute_contact()
|
|
||||||
|
|
||||||
|
|
||||||
# 步骤 2.4:读取结果(FNORMAL/FSHEAR* 在有 sensors/box/force_calibration.json 时为标定后牛顿力;
|
|
||||||
# *_RAW 始终为原始积分量)
|
|
||||||
(
|
|
||||||
fps,
|
|
||||||
flow,
|
|
||||||
vnormal,
|
|
||||||
img,
|
|
||||||
contour,
|
|
||||||
centroid,
|
|
||||||
depth_map,
|
|
||||||
fnormal,
|
|
||||||
fshearx,
|
|
||||||
fsheary,
|
|
||||||
fnormal_raw,
|
|
||||||
fshearx_raw,
|
|
||||||
fsheary_raw,
|
|
||||||
) = sensor.read_info(
|
|
||||||
sensor.info.FPS,
|
|
||||||
sensor.info.VRAW,
|
|
||||||
sensor.info.VNORMAL,
|
|
||||||
sensor.info.IMG,
|
|
||||||
sensor.info.CONTOUR,
|
|
||||||
sensor.info.CENTROID,
|
|
||||||
sensor.info.DEPTH,
|
|
||||||
sensor.info.FNORMAL,
|
|
||||||
sensor.info.FSHEARX,
|
|
||||||
sensor.info.FSHEARY,
|
|
||||||
sensor.info.FNORMAL_RAW,
|
|
||||||
sensor.info.FSHEARX_RAW,
|
|
||||||
sensor.info.FSHEARY_RAW,
|
|
||||||
)
|
)
|
||||||
|
fn_unit, fx_unit, fy_unit = sensor.force_units
|
||||||
# 步骤 3:可视化显示
|
selected = sensor.calibration.source_path if sensor.calibration else None
|
||||||
# 步骤 3.1:显示形变矢量场
|
print(f"Force calibration: {selected or 'none'}")
|
||||||
arrows = orisys.util.draw_arrows(
|
print(f"Force units: FN={fn_unit}, FX={fx_unit}, FY={fy_unit}")
|
||||||
img,
|
if sensor.device_calibration_missing:
|
||||||
flow,
|
|
||||||
threshold=2,
|
|
||||||
grid_spacing=20,
|
|
||||||
arrow_scale=1.0
|
|
||||||
)
|
|
||||||
cv2.imshow("arrow", arrows)
|
|
||||||
|
|
||||||
# 步骤 3.2:显示接触区域和质心
|
|
||||||
image_with_foe = orisys.util.draw_contact(img, contour, centroid)
|
|
||||||
cv2.imshow("contact", image_with_foe)
|
|
||||||
|
|
||||||
# 步骤 3.3:显示深度图
|
|
||||||
# 将深度值归一化到 0-255 范围
|
|
||||||
div_abs = depth_map
|
|
||||||
if div_abs.max() > div_abs.min():
|
|
||||||
div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
|
|
||||||
else:
|
|
||||||
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
|
|
||||||
|
|
||||||
# 应用伪彩色映射
|
|
||||||
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
|
||||||
|
|
||||||
# 步骤 3.4:打印关键结果(标定力 + 原始力)
|
|
||||||
print(
|
print(
|
||||||
f"FPS={fps:.2f}, "
|
f"Warning: no calibration for {args.device_id!r} under "
|
||||||
f"法向力={fnormal:.4f} N (raw={fnormal_raw:.4f}), "
|
f"{args.calibration_dir}; force is raw."
|
||||||
f"切向力X={fshearx:.4f} N (raw={fshearx_raw:.4f}), "
|
|
||||||
f"切向力Y={fsheary:.4f} N (raw={fsheary_raw:.4f}), "
|
|
||||||
f"深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 步骤 3.5:键盘控制
|
# contact=True runs contact analysis. include=... selects which arrays each snapshot carries;
|
||||||
key = cv2.waitKey(1) & 0xFF
|
# arrays that are not included are None, so you pay only for what you use.
|
||||||
|
include = ("image", "flow", "depth", "centroid", "contour") if args.show else ("image",)
|
||||||
|
|
||||||
|
if args.show:
|
||||||
|
print("Keys: 'q' quit, 'r' reset tracker.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with orisys.StreamPipeline(sensor, sensor.get_img, contact=True,
|
||||||
|
include=include) as pipe:
|
||||||
|
while True:
|
||||||
|
# ------------------------------------------------------ read one frame
|
||||||
|
# Blocks until a frame newer than the last one is ready. Everything in the
|
||||||
|
# snapshot belongs to the same frame.
|
||||||
|
snap = pipe.wait()
|
||||||
|
if snap is None:
|
||||||
|
print("\nInput ended.")
|
||||||
|
break
|
||||||
|
|
||||||
|
# ------------------------------------------------------ use the results
|
||||||
|
# The *_raw pair is always the unfiltered integrator output.
|
||||||
|
touching = "yes" if snap.centroid is not None else "no"
|
||||||
|
fn_unit, fx_unit, fy_unit = sensor.force_units
|
||||||
|
print(
|
||||||
|
f"FPS={snap.fps:5.1f} "
|
||||||
|
f"normal={snap.normal_force:8.4f} {fn_unit} (raw {snap.normal_force_raw:10.2f}) "
|
||||||
|
f"shear=({snap.shear_x:7.4f} {fx_unit}, {snap.shear_y:7.4f} {fy_unit}) "
|
||||||
|
f"contact={touching} age={snap.age_ms:4.1f} ms",
|
||||||
|
end="\r",
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.show and snap.image is not None:
|
||||||
|
if snap.flow is not None:
|
||||||
|
cv2.imshow("deformation", draw_flow_arrows(
|
||||||
|
snap.image, snap.flow, threshold=2, grid_spacing=20))
|
||||||
|
if snap.contour is not None and snap.centroid is not None:
|
||||||
|
cv2.imshow("contact", draw_contact(
|
||||||
|
snap.image, snap.contour, snap.centroid))
|
||||||
|
colour = depth_to_color(snap.depth)
|
||||||
|
if colour is not None:
|
||||||
|
cv2.imshow("depth", colour)
|
||||||
|
|
||||||
|
key = cv2.waitKey(1) & 0xFF if args.show else -1
|
||||||
if key == ord("q"):
|
if key == ord("q"):
|
||||||
print("\n正在退出示例程序...")
|
print("\nExiting.")
|
||||||
break
|
break
|
||||||
if key == ord("r"):
|
if key == ord("r"):
|
||||||
# 当光流追踪出现漂移时,可手动重置追踪器
|
# Queued onto the flow stage, so this is safe from the consumer loop.
|
||||||
sensor.reset()
|
pipe.reset()
|
||||||
print("追踪器已重置。")
|
print("\nTracker reset.")
|
||||||
|
except KeyboardInterrupt:
|
||||||
# =========================================================================
|
print("\nInterrupted.")
|
||||||
# 4. 释放资源
|
finally:
|
||||||
# =========================================================================
|
sensor.disconnect()
|
||||||
sensor.disconnect() # 释放视频源及相关资源
|
if args.show:
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
sys.exit(main())
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
配置里**没写**的滤波键等于"关":整段删掉就是什么都不滤,和 `off` 那段等价。
|
配置里**没写**的滤波键等于"关":整段删掉就是什么都不滤,和 `off` 那段等价。
|
||||||
|
|
||||||
背景、代价与可追溯性约定见 `docs/filters.md`;延迟/噪声实测见 `_perf/` 与
|
背景、代价与可追溯性约定见 `docs/filters.md`;延迟/噪声实测见
|
||||||
`orisys-sdk-perf-plan.md`。
|
`D:\orisys_ws\docs\performance\orisys-sdk-perf-plan.md`(原 `_perf/` 已从本仓库移除:幸存脚本与
|
||||||
|
片源在 `D:\orisys_ws\docs\performance\perf-measurements\`,该文档保留全部实测数字)。
|
||||||
|
|
||||||
下面每一段都有一个同目录的机器可读副本 `filters_<预设名>.json`(四个文件),
|
下面每一段都有一个同目录的机器可读副本 `filters_<预设名>.json`(四个文件),
|
||||||
`tests/test_filter_presets.py` 会核对它们与 `FILTER_PRESETS` 完全一致 —— 所以改预设时
|
`tests/test_filter_presets.py` 会核对它们与 `FILTER_PRESETS` 完全一致 —— 所以改预设时
|
||||||
|
|||||||
+68
-71
@@ -1,14 +1,21 @@
|
|||||||
#!/usr/bin/env python3
|
"""ORY-FINGER: UV-unrolled finger tactile pipeline.
|
||||||
"""
|
|
||||||
ORY-FINGER:Minimum UV-unroll finger tactile pipeline.
|
|
||||||
|
|
||||||
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
|
Same shape as ``basic_pipeline.py`` - one object, one call per frame - with the finger-specific
|
||||||
Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
|
capture path underneath:
|
||||||
|
|
||||||
|
FingerRuntime -> runtime.pipeline() -> FrameSnapshot per frame
|
||||||
|
|
||||||
|
``FingerRuntime`` owns the camera, the Blender export (mesh, intrinsics, world-to-camera), the UV
|
||||||
|
unroll map and the valid-region mask. ``runtime.pipeline()`` runs those stages concurrently and
|
||||||
|
returns one immutable snapshot per frame, so there is nothing to sequence and no tuple to unpack.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
Recommended entry:
|
|
||||||
python examples/finger_pipeline.py --camera_id 0 --config finger
|
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||||
|
python examples/finger_pipeline.py --video path/to/clip.mp4
|
||||||
|
python examples/finger_pipeline.py --no-show # headless: print only
|
||||||
|
|
||||||
Current product config: ``finger`` (``dd02`` still resolves to ``dd02-ov``).
|
Keys: 'q' quit, 'r' reset the tracker.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -18,95 +25,85 @@ import sys
|
|||||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import orisys
|
|
||||||
|
from _display import draw_flow_arrows
|
||||||
from orisys.finger import FingerRuntime
|
from orisys.finger import FingerRuntime
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline")
|
parser = argparse.ArgumentParser(description="UV-unrolled finger tactile pipeline")
|
||||||
parser.add_argument("--camera_id", type=int, default=0)
|
parser.add_argument("--camera_id", type=int, default=0, help="camera index (default 0)")
|
||||||
parser.add_argument("--video","-v", help="Video file path (overrides camera)")
|
parser.add_argument("-v", "--video", help="video file path; overrides --camera_id")
|
||||||
parser.add_argument("--config", "-c", default="finger", help="Orisys SDK config name or path")
|
parser.add_argument("-c", "--config", default="finger",
|
||||||
parser.add_argument("--show", action="store_true", default=True, help="Show unrolled image window")
|
help="product config name or path (default: finger)")
|
||||||
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
|
parser.add_argument("--no-show", dest="show", action="store_false",
|
||||||
|
help="do not open the display window")
|
||||||
|
parser.add_argument("-verbose", action="store_true", help="SDK verbose logs")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
source = args.video if args.video else args.camera_id
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ create the object
|
||||||
try:
|
try:
|
||||||
video = args.camera_id
|
runtime = FingerRuntime.open(config=args.config, video=source, verbose=args.verbose)
|
||||||
if args.video:
|
|
||||||
video = args.video
|
|
||||||
runtime = FingerRuntime.open(
|
|
||||||
config=args.config,
|
|
||||||
video=video,
|
|
||||||
verbose=args.verbose,
|
|
||||||
)
|
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
print(str(exc), file=sys.stderr)
|
print(str(exc), file=sys.stderr)
|
||||||
sys.exit(1)
|
return 1
|
||||||
|
|
||||||
if args.verbose:
|
if args.verbose:
|
||||||
geometry = runtime.geometry
|
g = runtime.geometry
|
||||||
print(
|
print(f"UV geometry: native={g.native_width}x{g.native_height} "
|
||||||
f"UV geometry: native={geometry.native_width}x{geometry.native_height}, "
|
f"source={g.source_width}x{g.source_height} "
|
||||||
f"source={geometry.source_width}x{geometry.source_height} "
|
f"output={g.out_width}x{g.out_height}")
|
||||||
f"output={geometry.out_width}x{geometry.out_height} "
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# Fill the flow/HHD caches once so the first published frame is not a warm-up outlier.
|
||||||
runtime.warmup(color=False)
|
runtime.warmup(color=False)
|
||||||
tactile = runtime.tactile
|
|
||||||
|
|
||||||
print("Press 'q' to quit, 'r' to reset tracker.")
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
ok, unrolled = runtime.grab_and_process(color=False)
|
|
||||||
if not ok:
|
|
||||||
break
|
|
||||||
|
|
||||||
tactile.compute_deformation()
|
|
||||||
tactile.compute_contact()
|
|
||||||
|
|
||||||
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = tactile.read_info(
|
|
||||||
tactile.info.FPS,
|
|
||||||
tactile.info.VRAW,
|
|
||||||
tactile.info.VNORMAL,
|
|
||||||
tactile.info.IMG,
|
|
||||||
tactile.info.CONTOUR,
|
|
||||||
tactile.info.CENTROID,
|
|
||||||
tactile.info.DEPTH,
|
|
||||||
tactile.info.FNORMAL,
|
|
||||||
tactile.info.FSHEARX,
|
|
||||||
tactile.info.FSHEARY,
|
|
||||||
)
|
|
||||||
|
|
||||||
status = (
|
|
||||||
f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) "
|
|
||||||
f"mask={tactile.mask_stats['coverage']:.2f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.show:
|
if args.show:
|
||||||
arrows = orisys.util.draw_arrows(
|
print("Keys: 'q' quit, 'r' reset tracker.")
|
||||||
unrolled,
|
|
||||||
flow,
|
|
||||||
threshold=0.4,
|
|
||||||
grid_spacing=10,
|
|
||||||
arrow_scale=1.0,
|
|
||||||
valid_mask=tactile.valid_mask,
|
|
||||||
)
|
|
||||||
cv2.imshow("UV Unrolled", arrows)
|
|
||||||
|
|
||||||
print(status, end="\r")
|
include = ("image", "flow") if args.show else ("image",)
|
||||||
|
try:
|
||||||
|
with runtime.pipeline(contact=True, include=include) as pipe:
|
||||||
|
while True:
|
||||||
|
# ------------------------------------------------------ read one frame
|
||||||
|
snap = pipe.wait()
|
||||||
|
if snap is None:
|
||||||
|
print("\nInput ended.")
|
||||||
|
break
|
||||||
|
|
||||||
|
# ------------------------------------------------------ use the results
|
||||||
|
coverage = runtime.tactile.mask_stats.get("coverage")
|
||||||
|
coverage_text = f"{coverage:.2f}" if isinstance(coverage, float) else "n/a"
|
||||||
|
fn_unit, fx_unit, fy_unit = runtime.tactile.force_units
|
||||||
|
print(
|
||||||
|
f"FPS={snap.fps:5.1f} "
|
||||||
|
f"normal={snap.normal_force:8.4f} {fn_unit} "
|
||||||
|
f"shear=({snap.shear_x:7.4f} {fx_unit}, {snap.shear_y:7.4f} {fy_unit}) "
|
||||||
|
f"mask={coverage_text} age={snap.age_ms:4.1f} ms",
|
||||||
|
end="\r",
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.show and snap.image is not None and snap.flow is not None:
|
||||||
|
cv2.imshow("tactile (UV unrolled)", draw_flow_arrows(
|
||||||
|
snap.image, snap.flow, threshold=0.4, grid_spacing=10,
|
||||||
|
valid_mask=runtime.tactile.valid_mask))
|
||||||
|
|
||||||
key = cv2.waitKey(1) & 0xFF
|
key = cv2.waitKey(1) & 0xFF
|
||||||
if key == ord("q"):
|
if key == ord("q"):
|
||||||
print("\nExiting.")
|
print("\nExiting.")
|
||||||
break
|
break
|
||||||
if key == ord("r"):
|
if key == ord("r"):
|
||||||
tactile.reset()
|
pipe.reset()
|
||||||
print("\nTracker reset.")
|
print("\nTracker reset.")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nInterrupted.")
|
||||||
finally:
|
finally:
|
||||||
runtime.close()
|
runtime.close()
|
||||||
|
if args.show:
|
||||||
cv2.destroyAllWindows()
|
cv2.destroyAllWindows()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
sys.exit(main())
|
||||||
@@ -9,9 +9,10 @@ Acquisition adapters (no widgets) live in ``gui.sensors``.
|
|||||||
Shared bootstrap: ``gui.app``, ``gui.app_paths``, ``gui.log_config``, ``gui.widgets``.
|
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 .log_config import LOG_CONFIG, LogConfig
|
||||||
from .viewer3d import (
|
from .viewer3d import (
|
||||||
FingerDashboardWindow,
|
|
||||||
LAYOUT,
|
LAYOUT,
|
||||||
LOG_PANEL,
|
LOG_PANEL,
|
||||||
MESH_CONTACT_ARROW,
|
MESH_CONTACT_ARROW,
|
||||||
@@ -19,33 +20,33 @@ from .viewer3d import (
|
|||||||
PANEL_SETUP,
|
PANEL_SETUP,
|
||||||
TOP_ROW,
|
TOP_ROW,
|
||||||
DashboardLayout,
|
DashboardLayout,
|
||||||
|
FingerDashboardWindow,
|
||||||
MeshLighting,
|
MeshLighting,
|
||||||
PanelRow,
|
PanelRow,
|
||||||
PanelSetup,
|
PanelSetup,
|
||||||
PanelSlot,
|
PanelSlot,
|
||||||
run_dashboard,
|
run_dashboard,
|
||||||
top_row_splitter_sizes,
|
|
||||||
top_row_slot_index,
|
top_row_slot_index,
|
||||||
|
top_row_splitter_sizes,
|
||||||
)
|
)
|
||||||
from viz.contact_arrow import ContactArrowStyle
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DashboardLayout",
|
|
||||||
"FingerDashboardWindow",
|
|
||||||
"LAYOUT",
|
"LAYOUT",
|
||||||
"LOG_CONFIG",
|
"LOG_CONFIG",
|
||||||
"LOG_PANEL",
|
"LOG_PANEL",
|
||||||
"LogConfig",
|
|
||||||
"MESH_CONTACT_ARROW",
|
"MESH_CONTACT_ARROW",
|
||||||
"MESH_LIGHTING",
|
"MESH_LIGHTING",
|
||||||
"ContactArrowStyle",
|
|
||||||
"MeshLighting",
|
|
||||||
"PANEL_SETUP",
|
"PANEL_SETUP",
|
||||||
"TOP_ROW",
|
"TOP_ROW",
|
||||||
|
"ContactArrowStyle",
|
||||||
|
"DashboardLayout",
|
||||||
|
"FingerDashboardWindow",
|
||||||
|
"LogConfig",
|
||||||
|
"MeshLighting",
|
||||||
"PanelRow",
|
"PanelRow",
|
||||||
"PanelSetup",
|
"PanelSetup",
|
||||||
"PanelSlot",
|
"PanelSlot",
|
||||||
"run_dashboard",
|
"run_dashboard",
|
||||||
"top_row_splitter_sizes",
|
|
||||||
"top_row_slot_index",
|
"top_row_slot_index",
|
||||||
|
"top_row_splitter_sizes",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ def default_config_path() -> str:
|
|||||||
def require_gui_extras() -> None:
|
def require_gui_extras() -> None:
|
||||||
try:
|
try:
|
||||||
import open3d # noqa: F401
|
import open3d # noqa: F401
|
||||||
from PyQt5 import QtWidgets # noqa: F401
|
|
||||||
import pyqtgraph # noqa: F401
|
import pyqtgraph # noqa: F401
|
||||||
|
from PyQt5 import QtWidgets # noqa: F401
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise SystemExit('GUI example requires: pip install "orisys[gui]"') from exc
|
raise SystemExit('GUI example requires: pip install "orisys[gui]"') from exc
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
"""Sensor acquisition adapters for Qt / multi-sensor viewers (no Qt widgets)."""
|
"""Sensor acquisition adapters for Qt / multi-sensor viewers (no Qt widgets)."""
|
||||||
|
|
||||||
|
from . import roi_geometry
|
||||||
from .adapter import (
|
from .adapter import (
|
||||||
Dd06RoiAdapter,
|
Dd06RoiAdapter,
|
||||||
FrameSnapshot,
|
FrameSnapshot,
|
||||||
OrisysSensorAdapter,
|
OrisysSensorAdapter,
|
||||||
SensorAdapter,
|
SensorAdapter,
|
||||||
|
_snapshot_from_sensor,
|
||||||
calibrate_normal_force,
|
calibrate_normal_force,
|
||||||
create_sensor_adapter,
|
create_sensor_adapter,
|
||||||
_snapshot_from_sensor,
|
|
||||||
)
|
)
|
||||||
from . import roi_geometry
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Dd06RoiAdapter",
|
"Dd06RoiAdapter",
|
||||||
"FrameSnapshot",
|
"FrameSnapshot",
|
||||||
"OrisysSensorAdapter",
|
"OrisysSensorAdapter",
|
||||||
"SensorAdapter",
|
"SensorAdapter",
|
||||||
|
"_snapshot_from_sensor",
|
||||||
"calibrate_normal_force",
|
"calibrate_normal_force",
|
||||||
"create_sensor_adapter",
|
"create_sensor_adapter",
|
||||||
"roi_geometry",
|
"roi_geometry",
|
||||||
"_snapshot_from_sensor",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from typing import Any
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
import orisys
|
import orisys
|
||||||
|
|
||||||
from .roi_geometry import (
|
from .roi_geometry import (
|
||||||
@@ -21,7 +21,6 @@ from .roi_geometry import (
|
|||||||
warp_roi_to_rectangle,
|
warp_roi_to_rectangle,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
|
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
|
||||||
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
|
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
|
||||||
|
|
||||||
@@ -30,10 +29,10 @@ NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
|
|||||||
class FrameSnapshot:
|
class FrameSnapshot:
|
||||||
"""Normalized per-frame payload consumed by the Qt viewer."""
|
"""Normalized per-frame payload consumed by the Qt viewer."""
|
||||||
|
|
||||||
image: Optional[np.ndarray] = None
|
image: np.ndarray | None = None
|
||||||
flow: Optional[np.ndarray] = None
|
flow: np.ndarray | None = None
|
||||||
depth: Optional[np.ndarray] = None
|
depth: np.ndarray | None = None
|
||||||
contour: Optional[np.ndarray] = None
|
contour: np.ndarray | None = None
|
||||||
centroid: Any = None
|
centroid: Any = None
|
||||||
# Raw SDK force channels used for curves (same units for FN/FX/FY).
|
# Raw SDK force channels used for curves (same units for FN/FX/FY).
|
||||||
normal_force: float = 0.0
|
normal_force: float = 0.0
|
||||||
@@ -43,10 +42,10 @@ class FrameSnapshot:
|
|||||||
normal_force_calibrated: float = 0.0
|
normal_force_calibrated: float = 0.0
|
||||||
fps: float = 0.0
|
fps: float = 0.0
|
||||||
timestamp: float = 0.0
|
timestamp: float = 0.0
|
||||||
raw_image: Optional[np.ndarray] = None
|
raw_image: np.ndarray | None = None
|
||||||
processed_image: Optional[np.ndarray] = None
|
processed_image: np.ndarray | None = None
|
||||||
flow_overlay: Optional[np.ndarray] = None
|
flow_overlay: np.ndarray | None = None
|
||||||
record_frame: Optional[np.ndarray] = None
|
record_frame: np.ndarray | None = None
|
||||||
|
|
||||||
|
|
||||||
def calibrate_normal_force(raw_value: float) -> float:
|
def calibrate_normal_force(raw_value: float) -> float:
|
||||||
@@ -62,7 +61,7 @@ class SensorAdapter(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def read(self) -> Optional[FrameSnapshot]:
|
def read(self) -> FrameSnapshot | None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -89,7 +88,7 @@ def _snapshot_from_sensor(
|
|||||||
*,
|
*,
|
||||||
raw_image=None,
|
raw_image=None,
|
||||||
processed_image=None,
|
processed_image=None,
|
||||||
) -> Optional[FrameSnapshot]:
|
) -> FrameSnapshot | None:
|
||||||
"""Build a FrameSnapshot with HHD/forces refreshed every frame."""
|
"""Build a FrameSnapshot with HHD/forces refreshed every frame."""
|
||||||
if sensor is None or sensor.img is None:
|
if sensor is None or sensor.img is None:
|
||||||
return None
|
return None
|
||||||
@@ -186,7 +185,7 @@ class OrisysSensorAdapter(SensorAdapter):
|
|||||||
verbose=self.verbose,
|
verbose=self.verbose,
|
||||||
)
|
)
|
||||||
|
|
||||||
def read(self) -> Optional[FrameSnapshot]:
|
def read(self) -> FrameSnapshot | None:
|
||||||
if self.sensor is None:
|
if self.sensor is None:
|
||||||
return None
|
return None
|
||||||
img = self.sensor.get_img()
|
img = self.sensor.get_img()
|
||||||
@@ -255,7 +254,7 @@ class Dd06RoiAdapter(SensorAdapter):
|
|||||||
f"video={self._source}"
|
f"video={self._source}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def read(self) -> Optional[FrameSnapshot]:
|
def read(self) -> FrameSnapshot | None:
|
||||||
if self.sensor is None or self.cap is None:
|
if self.sensor is None or self.cap is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ Does not import camera, ROI, or `orisys.Sensor`.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal, Optional, Sequence, Tuple
|
from typing import Literal
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import orisys
|
|
||||||
from scipy.signal import butter, filtfilt
|
from scipy.signal import butter, filtfilt
|
||||||
|
|
||||||
|
import orisys
|
||||||
|
|
||||||
FORCE_SAMPLE_RATE_HZ = 30.0
|
FORCE_SAMPLE_RATE_HZ = 30.0
|
||||||
PLOT_BUFFER_POINTS = 120
|
PLOT_BUFFER_POINTS = 120
|
||||||
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ
|
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ
|
||||||
@@ -40,14 +41,14 @@ class ViewerFrame:
|
|||||||
image_height: int
|
image_height: int
|
||||||
image_width: int
|
image_width: int
|
||||||
aspect_ratio: float # width / height
|
aspect_ratio: float # width / height
|
||||||
depth: Optional[np.ndarray]
|
depth: np.ndarray | None
|
||||||
normal_force: float
|
normal_force: float
|
||||||
shear_x: float
|
shear_x: float
|
||||||
shear_y: float
|
shear_y: float
|
||||||
normal_force_calibrated: float
|
normal_force_calibrated: float
|
||||||
fps: float
|
fps: float
|
||||||
timestamp: float
|
timestamp: float
|
||||||
record_frame: Optional[np.ndarray]
|
record_frame: np.ndarray | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -57,8 +58,8 @@ class ForcePlotState:
|
|||||||
times: np.ndarray # shape (n,)
|
times: np.ndarray # shape (n,)
|
||||||
values: np.ndarray # shape (n, 3) FN, FX, FY
|
values: np.ndarray # shape (n, 3) FN, FX, FY
|
||||||
sample_count: int
|
sample_count: int
|
||||||
x_range: Tuple[float, float]
|
x_range: tuple[float, float]
|
||||||
y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]
|
y_ranges: tuple[tuple[float, float], tuple[float, float], tuple[float, float]]
|
||||||
|
|
||||||
|
|
||||||
def lowpass_filter(
|
def lowpass_filter(
|
||||||
@@ -87,7 +88,7 @@ def lowpass_filter(
|
|||||||
return filtfilt(b, a, y)
|
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:
|
if img is None:
|
||||||
return None
|
return None
|
||||||
if img.ndim == 2:
|
if img.ndim == 2:
|
||||||
@@ -97,14 +98,14 @@ def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
|||||||
return img
|
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."""
|
"""Return (height, width, width/height) from a display image."""
|
||||||
h, w = int(img.shape[0]), int(img.shape[1])
|
h, w = int(img.shape[0]), int(img.shape[1])
|
||||||
aspect = float(w) / float(h) if h > 0 else 1.0
|
aspect = float(w) / float(h) if h > 0 else 1.0
|
||||||
return h, w, aspect
|
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"):
|
for attr in ("image", "processed_image", "raw_image"):
|
||||||
out = ensure_bgr(getattr(snapshot, attr, None))
|
out = ensure_bgr(getattr(snapshot, attr, None))
|
||||||
if out is not None:
|
if out is not None:
|
||||||
@@ -152,7 +153,7 @@ def build_display_image(
|
|||||||
return select_display_image(snapshot)
|
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."""
|
"""Convert BGR uint8 image to contiguous RGB for QImage."""
|
||||||
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||||
if not rgb.flags["C_CONTIGUOUS"]:
|
if not rgb.flags["C_CONTIGUOUS"]:
|
||||||
@@ -231,7 +232,7 @@ class ForceHistory:
|
|||||||
self.data[-1, 0, :] = t
|
self.data[-1, 0, :] = t
|
||||||
self.data[-1, 1, :] = values
|
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:
|
if self._y_range_smooth[channel] is None:
|
||||||
smoothed = (ymin, ymax)
|
smoothed = (ymin, ymax)
|
||||||
else:
|
else:
|
||||||
@@ -251,7 +252,7 @@ class ForceHistory:
|
|||||||
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
|
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
|
||||||
return smoothed[0] - pad, smoothed[1] + pad
|
return smoothed[0] - pad, smoothed[1] + pad
|
||||||
|
|
||||||
def plot_state(self) -> Optional[ForcePlotState]:
|
def plot_state(self) -> ForcePlotState | None:
|
||||||
n = self.sample_count
|
n = self.sample_count
|
||||||
if n == 0:
|
if n == 0:
|
||||||
return None
|
return None
|
||||||
@@ -305,8 +306,8 @@ class QtViewerBackend:
|
|||||||
lowpass_cutoff_hz=lowpass_cutoff_hz,
|
lowpass_cutoff_hz=lowpass_cutoff_hz,
|
||||||
y_axis_min_span=y_axis_min_span,
|
y_axis_min_span=y_axis_min_span,
|
||||||
)
|
)
|
||||||
self._start_time: Optional[float] = None
|
self._start_time: float | None = None
|
||||||
self.last_viewer_frame: Optional[ViewerFrame] = None
|
self.last_viewer_frame: ViewerFrame | None = None
|
||||||
self.arrow_background: ArrowBackground = arrow_background
|
self.arrow_background: ArrowBackground = arrow_background
|
||||||
|
|
||||||
def set_arrow_background(self, background: ArrowBackground) -> None:
|
def set_arrow_background(self, background: ArrowBackground) -> None:
|
||||||
@@ -357,7 +358,7 @@ class QtViewerBackend:
|
|||||||
self._start_time = None
|
self._start_time = None
|
||||||
self.last_viewer_frame = 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."""
|
"""Convert a FrameSnapshot into ViewerFrame and update force history."""
|
||||||
display = build_display_image(snapshot, self.arrow_background)
|
display = build_display_image(snapshot, self.arrow_background)
|
||||||
h, w, aspect = image_shape_meta(display)
|
h, w, aspect = image_shape_meta(display)
|
||||||
@@ -396,5 +397,5 @@ class QtViewerBackend:
|
|||||||
self.last_viewer_frame = frame
|
self.last_viewer_frame = frame
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
def plot_state(self) -> Optional[ForcePlotState]:
|
def plot_state(self) -> ForcePlotState | None:
|
||||||
return self.force_history.plot_state()
|
return self.force_history.plot_state()
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from .dashboard import FingerDashboardWindow, run_dashboard
|
from .dashboard import FingerDashboardWindow, run_dashboard
|
||||||
from .layout import (
|
from .layout import (
|
||||||
LOG_PANEL,
|
|
||||||
LAYOUT,
|
LAYOUT,
|
||||||
|
LOG_PANEL,
|
||||||
MESH_CONTACT_ARROW,
|
MESH_CONTACT_ARROW,
|
||||||
MESH_LIGHTING,
|
MESH_LIGHTING,
|
||||||
PANEL_SETUP,
|
PANEL_SETUP,
|
||||||
@@ -13,24 +13,24 @@ from .layout import (
|
|||||||
PanelRow,
|
PanelRow,
|
||||||
PanelSetup,
|
PanelSetup,
|
||||||
PanelSlot,
|
PanelSlot,
|
||||||
top_row_splitter_sizes,
|
|
||||||
top_row_slot_index,
|
top_row_slot_index,
|
||||||
|
top_row_splitter_sizes,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DashboardLayout",
|
|
||||||
"FingerDashboardWindow",
|
|
||||||
"LAYOUT",
|
"LAYOUT",
|
||||||
"LOG_PANEL",
|
"LOG_PANEL",
|
||||||
"MESH_CONTACT_ARROW",
|
"MESH_CONTACT_ARROW",
|
||||||
"MESH_LIGHTING",
|
"MESH_LIGHTING",
|
||||||
"MeshLighting",
|
|
||||||
"PANEL_SETUP",
|
"PANEL_SETUP",
|
||||||
"TOP_ROW",
|
"TOP_ROW",
|
||||||
|
"DashboardLayout",
|
||||||
|
"FingerDashboardWindow",
|
||||||
|
"MeshLighting",
|
||||||
"PanelRow",
|
"PanelRow",
|
||||||
"PanelSetup",
|
"PanelSetup",
|
||||||
"PanelSlot",
|
"PanelSlot",
|
||||||
"run_dashboard",
|
"run_dashboard",
|
||||||
"top_row_splitter_sizes",
|
|
||||||
"top_row_slot_index",
|
"top_row_slot_index",
|
||||||
|
"top_row_splitter_sizes",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -11,6 +12,8 @@ import cv2
|
|||||||
|
|
||||||
DEFAULT_MAX_CAMERA_INDEX = 10
|
DEFAULT_MAX_CAMERA_INDEX = 10
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CameraInfo:
|
class CameraInfo:
|
||||||
@@ -62,8 +65,14 @@ def _suppress_opencv_noise():
|
|||||||
if set_level is not None and prev_level is not None:
|
if set_level is not None and prev_level is not None:
|
||||||
try:
|
try:
|
||||||
set_level(prev_level)
|
set_level(prev_level)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
# 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]]:
|
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()
|
backends = _backend_candidates()
|
||||||
|
|
||||||
with _suppress_opencv_noise():
|
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:
|
for backend_name, backend_api in backends:
|
||||||
|
try:
|
||||||
info = _try_open(index, backend_name, backend_api)
|
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:
|
if info is None:
|
||||||
continue
|
continue
|
||||||
if index in seen_indices:
|
if index in seen_indices:
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import math
|
|||||||
import sys
|
import sys
|
||||||
import time
|
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.QtCore import Qt, QTimer
|
||||||
from PyQt5.QtGui import QIcon
|
from PyQt5.QtGui import QIcon
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
@@ -17,23 +20,17 @@ from PyQt5.QtWidgets import (
|
|||||||
QWidget,
|
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.app_paths import resource_path
|
||||||
|
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
|
||||||
|
|
||||||
from .camera_discovery import list_cameras, pick_default_camera
|
from .camera_discovery import list_cameras, pick_default_camera
|
||||||
from .preferences import get_preferred_video_source, load_preferences, save_preferences
|
|
||||||
from .layout import (
|
from .layout import (
|
||||||
DashboardLayout,
|
|
||||||
LAYOUT,
|
LAYOUT,
|
||||||
PANEL_SETUP,
|
DashboardLayout,
|
||||||
PanelSetup,
|
|
||||||
top_row_splitter_sizes,
|
|
||||||
)
|
)
|
||||||
from .mesh_view_state import MeshViewState
|
from .mesh_view_state import MeshViewState
|
||||||
|
from .preferences import get_preferred_video_source, load_preferences, save_preferences
|
||||||
from .styles import STYLE
|
from .styles import STYLE
|
||||||
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
|
|
||||||
from .widgets import (
|
from .widgets import (
|
||||||
ArrowPanelWidget,
|
ArrowPanelWidget,
|
||||||
ForceChartWidget,
|
ForceChartWidget,
|
||||||
|
|||||||
@@ -47,11 +47,12 @@ import math
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from gui.log_config import LOG_CONFIG, LogConfig
|
from viz.contact_arrow import ContactArrowStyle
|
||||||
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
|
|
||||||
from viz.flow_arrow import FlowArrowStyle
|
from viz.flow_arrow import FlowArrowStyle
|
||||||
from viz.render_2d import ArrowBackground, ArrowCanvasColors
|
from viz.render_2d import ArrowBackground, ArrowCanvasColors
|
||||||
|
|
||||||
|
from gui.log_config import LOG_CONFIG, LogConfig
|
||||||
|
|
||||||
PanelId = Literal["mesh", "arrows", "charts", "log", "settings"]
|
PanelId = Literal["mesh", "arrows", "charts", "log", "settings"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from PyQt5.QtCore import QObject, pyqtSignal
|
from PyQt5.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
from .layout import DashboardLayout, LAYOUT
|
from .layout import LAYOUT, DashboardLayout
|
||||||
|
|
||||||
|
|
||||||
class MeshViewState(QObject):
|
class MeshViewState(QObject):
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
|
|
||||||
from pipeline.frame import FingerFrameView
|
from pipeline.frame import FingerFrameView
|
||||||
from viz.render_2d import ArrowBackground, ArrowCanvasColors, render_uv_arrows
|
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 gui.widgets.qt_pixmap import ndarray_to_pixmap
|
||||||
|
|
||||||
|
from ..layout import LAYOUT, DashboardLayout
|
||||||
|
from .image_panel import ImagePanel
|
||||||
|
|
||||||
|
|
||||||
class ArrowPanelWidget(ImagePanel):
|
class ArrowPanelWidget(ImagePanel):
|
||||||
"""UV unrolled flow-arrow panel."""
|
"""UV unrolled flow-arrow panel."""
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from pipeline.frame import FingerFrameView
|
||||||
from PyQt5.QtGui import QFont
|
from PyQt5.QtGui import QFont
|
||||||
from PyQt5.QtWidgets import QSizePolicy
|
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 viz.signal_filters import lowpass_filter
|
||||||
|
|
||||||
|
from ..layout import LAYOUT, DashboardLayout
|
||||||
|
from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
|
||||||
|
|
||||||
|
|
||||||
def _require_pyqtgraph():
|
def _require_pyqtgraph():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pipeline.frame import FingerFrameView
|
||||||
from PyQt5.QtCore import QEvent, Qt
|
from PyQt5.QtCore import QEvent, Qt
|
||||||
from PyQt5.QtGui import QMouseEvent
|
from PyQt5.QtGui import QMouseEvent
|
||||||
|
|
||||||
from pipeline.frame import FingerFrameView
|
|
||||||
from viz.offscreen_mesh import OffscreenFingerMeshRenderer
|
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 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
|
from .image_panel import ImagePanel
|
||||||
|
|
||||||
_VIEW_ROTATE_SENSITIVITY = 0.35
|
_VIEW_ROTATE_SENSITIVITY = 0.35
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
from PyQt5.QtCore import QSize, pyqtSignal
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
@@ -14,11 +14,11 @@ from PyQt5.QtWidgets import (
|
|||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
|
|
||||||
from viz.render_2d import ArrowBackground
|
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 ..mesh_view_state import MeshViewState
|
||||||
from ..transport_icons import reset_icon, transport_icon
|
from ..transport_icons import reset_icon, transport_icon
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ ORY-MINI:单传感器基础流程
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
import cv2
|
|
||||||
import orisys
|
|
||||||
import numpy as np
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
||||||
@@ -29,11 +33,13 @@ def main():
|
|||||||
# 输入既可以是摄像头编号,也可以是视频文件路径
|
# 输入既可以是摄像头编号,也可以是视频文件路径
|
||||||
try:
|
try:
|
||||||
input = int(args.video)
|
input = int(args.video)
|
||||||
is_camera = True
|
except ValueError:
|
||||||
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
|
# Not a camera index: treat the argument as a video-file path. Falling back
|
||||||
except:
|
# only on the conversion (not on Sensor construction) keeps a real
|
||||||
is_camera = False
|
# constructor failure visible instead of retrying it as a file path.
|
||||||
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
|
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' 重置追踪器。")
|
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
|
||||||
|
|
||||||
@@ -89,9 +95,10 @@ def main():
|
|||||||
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
||||||
|
|
||||||
# 步骤 3.4:打印关键结果
|
# 步骤 3.4:打印关键结果
|
||||||
|
fn_unit, fx_unit, fy_unit = sensor.force_units
|
||||||
print(
|
print(
|
||||||
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
|
f"FPS={fps:.2f}, 法向力={fnormal:.4f} {fn_unit}, 切向力X={fshearx:.4f} {fx_unit}, "
|
||||||
f"切向力Y={fsheary:.4f}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
|
f"切向力Y={fsheary:.4f} {fy_unit}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 步骤 3.5:键盘控制
|
# 步骤 3.5:键盘控制
|
||||||
|
|||||||
@@ -245,7 +245,10 @@ def main():
|
|||||||
try:
|
try:
|
||||||
with open(path) as fh:
|
with open(path) as fh:
|
||||||
rows.append(json.load(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
|
pass
|
||||||
if not rows:
|
if not rows:
|
||||||
print("no stream produced results", file=sys.stderr)
|
print("no stream produced results", file=sys.stderr)
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
|
|
||||||
from viz.mesh_camera import DEFAULT_FINGER_VIZ_POSE
|
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:
|
def mesh_threshold(mesh_map: str, threshold: float | None) -> float:
|
||||||
if threshold is not None:
|
if threshold is not None:
|
||||||
@@ -33,7 +34,7 @@ class PipelineConfig:
|
|||||||
profile_every: int = 30
|
profile_every: int = 30
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_args(cls, args) -> "PipelineConfig":
|
def from_args(cls, args) -> PipelineConfig:
|
||||||
return cls(
|
return cls(
|
||||||
video=args.video,
|
video=args.video,
|
||||||
config=args.config,
|
config=args.config,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
CSV_COLUMNS = (
|
CSV_COLUMNS = (
|
||||||
"frame_index",
|
"frame_index",
|
||||||
"timestamp_s",
|
"timestamp_s",
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import time
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import numpy as np
|
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 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 .config import PipelineConfig
|
||||||
from .csv_recorder import CsvMeasurement
|
from .csv_recorder import CsvMeasurement
|
||||||
from .frame import FingerFrameView
|
from .frame import FingerFrameView
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import orisys
|
|
||||||
|
|
||||||
from viz import ForceChartWindow, FrameRecorder
|
from viz import ForceChartWindow, FrameRecorder
|
||||||
|
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
|
||||||
def open_sensor(video: str, config: str, cal: str, verbose: bool):
|
def open_sensor(video: str, config: str, cal: str, verbose: bool):
|
||||||
try:
|
try:
|
||||||
@@ -65,7 +65,11 @@ def main():
|
|||||||
sensor.info.VRAW,
|
sensor.info.VRAW,
|
||||||
sensor.info.DEPTH,
|
sensor.info.DEPTH,
|
||||||
)
|
)
|
||||||
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}")
|
fn_unit, fx_unit, fy_unit = sensor.force_units
|
||||||
|
print(
|
||||||
|
f"FPS={fps:.2f}, 法向力={fn:.4f} {fn_unit}, "
|
||||||
|
f"切向力X={fx:.4f} {fx_unit}, 切向力Y={fy:.4f} {fy_unit}"
|
||||||
|
)
|
||||||
|
|
||||||
arrows = orisys.util.draw_arrows(sensor.img, flow, threshold=5, grid_spacing=10, arrow_scale=1.0)
|
arrows = orisys.util.draw_arrows(sensor.img, flow, threshold=5, grid_spacing=10, arrow_scale=1.0)
|
||||||
cv2.imshow("形变矢量场", arrows)
|
cv2.imshow("形变矢量场", arrows)
|
||||||
|
|||||||
@@ -20,14 +20,17 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
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 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):
|
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 ""))
|
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
|
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():
|
while not stop_event.is_set():
|
||||||
t_start = time.perf_counter()
|
t_start = time.perf_counter()
|
||||||
@@ -95,9 +99,10 @@ def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, tar
|
|||||||
# 步骤 6:将结果发送到主进程(非阻塞)
|
# 步骤 6:将结果发送到主进程(非阻塞)
|
||||||
try:
|
try:
|
||||||
result_queue.put_nowait(data)
|
result_queue.put_nowait(data)
|
||||||
except Exception:
|
except queue.Full:
|
||||||
# 如果队列已满,则丢弃当前帧,避免阻塞工作进程
|
# 队列已满:丢弃当前帧,避免阻塞工作进程。这是有意的背压策略,
|
||||||
pass
|
# 但丢帧必须可观测,否则主进程看到的低帧率无从解释。
|
||||||
|
dropped_frames += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"传感器 {sensor_id} 处理失败:{e}")
|
print(f"传感器 {sensor_id} 处理失败:{e}")
|
||||||
@@ -112,6 +117,12 @@ def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, tar
|
|||||||
|
|
||||||
# 释放资源
|
# 释放资源
|
||||||
sensor.disconnect()
|
sensor.disconnect()
|
||||||
|
if dropped_frames:
|
||||||
|
print(
|
||||||
|
f"传感器 {sensor_id} 已停止(因结果队列已满丢弃 {dropped_frames} 帧,"
|
||||||
|
"主进程处理慢于采集)。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
print(f"传感器 {sensor_id} 已停止。")
|
print(f"传感器 {sensor_id} 已停止。")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -193,7 +204,8 @@ def main():
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
data = queue.get_nowait()
|
data = queue.get_nowait()
|
||||||
except:
|
except queue.Empty:
|
||||||
|
# 队列已排空:正常结束内层循环,不是错误。
|
||||||
break
|
break
|
||||||
|
|
||||||
if data is not None:
|
if data is not None:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Shared visualization helpers for Orisys example scripts."""
|
"""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 .force_charts import ForceChartWindow
|
||||||
from .mesh_camera import (
|
from .mesh_camera import (
|
||||||
DEFAULT_FINGER_VIZ_POSE,
|
DEFAULT_FINGER_VIZ_POSE,
|
||||||
@@ -8,33 +10,32 @@ from .mesh_camera import (
|
|||||||
camera_look_at_from_world_to_cam,
|
camera_look_at_from_world_to_cam,
|
||||||
resolve_viz_pose_path,
|
resolve_viz_pose_path,
|
||||||
)
|
)
|
||||||
|
from .mesh_viewer import UVMeshViewer, build_uv_surface_mesh
|
||||||
from .offscreen_mesh import OffscreenFingerMeshRenderer
|
from .offscreen_mesh import OffscreenFingerMeshRenderer
|
||||||
|
from .recording import FrameRecorder
|
||||||
from .render_2d import (
|
from .render_2d import (
|
||||||
|
DEFAULT_ARROW_CANVAS_COLORS,
|
||||||
ArrowBackground,
|
ArrowBackground,
|
||||||
ArrowCanvasColors,
|
ArrowCanvasColors,
|
||||||
DEFAULT_ARROW_CANVAS_COLORS,
|
|
||||||
depth_colormap,
|
depth_colormap,
|
||||||
frame_to_bgr,
|
frame_to_bgr,
|
||||||
render_contact_panel,
|
render_contact_panel,
|
||||||
render_depth_panel,
|
render_depth_panel,
|
||||||
render_uv_arrows,
|
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
|
from .signal_filters import lowpass_filter
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ArrowBackground",
|
|
||||||
"ArrowCanvasColors",
|
|
||||||
"DEFAULT_ARROW_CANVAS_COLORS",
|
"DEFAULT_ARROW_CANVAS_COLORS",
|
||||||
"DEFAULT_FLOW_ARROW_STYLE",
|
"DEFAULT_FLOW_ARROW_STYLE",
|
||||||
|
"ArrowBackground",
|
||||||
|
"ArrowCanvasColors",
|
||||||
|
"FingerMeshViewer",
|
||||||
"FlowArrowOverlay",
|
"FlowArrowOverlay",
|
||||||
"FlowArrowStyle",
|
"FlowArrowStyle",
|
||||||
"FingerMeshViewer",
|
|
||||||
"ForceChartWindow",
|
"ForceChartWindow",
|
||||||
"FrameRecorder",
|
"FrameRecorder",
|
||||||
|
"OffscreenFingerMeshRenderer",
|
||||||
"UVMeshViewer",
|
"UVMeshViewer",
|
||||||
"apply_mesh_lighting",
|
"apply_mesh_lighting",
|
||||||
"apply_view_from_world_to_cam",
|
"apply_view_from_world_to_cam",
|
||||||
@@ -43,7 +44,6 @@ __all__ = [
|
|||||||
"depth_colormap",
|
"depth_colormap",
|
||||||
"frame_to_bgr",
|
"frame_to_bgr",
|
||||||
"lowpass_filter",
|
"lowpass_filter",
|
||||||
"OffscreenFingerMeshRenderer",
|
|
||||||
"render_contact_panel",
|
"render_contact_panel",
|
||||||
"render_depth_panel",
|
"render_depth_panel",
|
||||||
"render_uv_arrows",
|
"render_uv_arrows",
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import numpy as np
|
|||||||
|
|
||||||
from orisys.finger.viewer import UVMeshViewer
|
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
|
from .flow_arrow import DEFAULT_FLOW_ARROW_STYLE, FlowArrowOverlay, FlowArrowStyle
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Literal
|
from typing import Literal
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
"""Re-exports for finger 3D mesh examples; core viewer lives in orisys.finger."""
|
"""Re-exports for finger 3D mesh examples; core viewer lives in orisys.finger."""
|
||||||
|
|
||||||
from orisys.finger.mesh import * # noqa: F403
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Literal
|
|||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
import orisys
|
import orisys
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.signal import butter, filtfilt
|
from scipy.signal import butter, filtfilt
|
||||||
|
|
||||||
|
|
||||||
def lowpass_filter(data, cutoff_hz=5, sample_rate_hz=30, order=2) -> np.ndarray:
|
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."""
|
"""Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data."""
|
||||||
y = np.asarray(data, dtype=np.float64)
|
y = np.asarray(data, dtype=np.float64)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Ruff configuration — pinned deliberately.
|
||||||
|
#
|
||||||
|
# Before this file existed the repo had no Ruff config, so `ruff check .` ran whatever the
|
||||||
|
# installed Ruff version enables by default (Ruff 0.16 turns on a very broad set: UP/RUF/PLR/
|
||||||
|
# S/TRY/PERF/FURB/YTT/ASYNC...). The same tree therefore produced different findings on
|
||||||
|
# different machines and between a developer and a runner. Never rely on tool defaults.
|
||||||
|
#
|
||||||
|
# Policy, decided from the measured findings rather than from taste:
|
||||||
|
#
|
||||||
|
# * `select` is explicit. High-signal correctness rules first, then only the mechanical
|
||||||
|
# modernisation rules we are willing to apply without human review.
|
||||||
|
# * BLE001 is selected so existing `# noqa: BLE001 - <reason>` suppressions stay *used* and
|
||||||
|
# RUF100 can never delete their rationale — a broad `except Exception` is a legitimate
|
||||||
|
# choice around device I/O, capture probes and teardown. It is NOT part of the gate.
|
||||||
|
# * S110 / S112 / E722 ARE part of the gate: a silent handler hides a failure. This matters
|
||||||
|
# most in the live capture path, where a swallowed read error kills a source silently.
|
||||||
|
# * RUF100 reports unused suppressions but is `unfixable`: a machine must not remove a
|
||||||
|
# suppression whose comment records why it exists. Triage those by hand.
|
||||||
|
# * The mechanical sweep never uses `--unsafe-fixes`.
|
||||||
|
#
|
||||||
|
# The gate selects the narrow set explicitly, so BLE001 cannot fail a build, and only ever
|
||||||
|
# touches changed files:
|
||||||
|
#
|
||||||
|
# tools/lint_gate.py # the gate: changed files, machine-readable
|
||||||
|
|
||||||
|
target-version = "py310"
|
||||||
|
line-length = 88
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
select = [
|
||||||
|
"E9", "E722", # syntax/IO errors, and bare `except:`
|
||||||
|
"E402", # module import not at top of file — SELECTED for visibility, NOT gated.
|
||||||
|
# `import` after `os.environ[...]` is deliberate in examples/ and scripts/
|
||||||
|
# (the environment must be set before Qt/cv2/orisys load). E402 is the only
|
||||||
|
# rule that would catch an accidental import moved above that setup, so it
|
||||||
|
# stays reported, without failing a build. An intentional site belongs in a
|
||||||
|
# `# noqa: E402 - <reason>` when it is touched.
|
||||||
|
"F", # pyflakes: unused import, redefinition, undefined name
|
||||||
|
"S110", "S112", # try-except-pass / try-except-continue — an invisible failure
|
||||||
|
"B023", # function definition does not bind loop variable
|
||||||
|
"PLW1510", # subprocess.run without an explicit check
|
||||||
|
"RUF100", # unused noqa — reported for triage, never auto-removed
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"I", # import sorting
|
||||||
|
"RUF022", "RUF023", # __all__ / __slots__ ordering
|
||||||
|
"PIE", # flake8-pie
|
||||||
|
"RET501", "PLR1711",
|
||||||
|
"SIM114",
|
||||||
|
"BLE001", # selected for suppression hygiene only — excluded from the gate
|
||||||
|
]
|
||||||
|
unfixable = ["RUF100"]
|
||||||
|
|
||||||
|
[format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
+11
-7
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
import cv2
|
import cv2
|
||||||
|
|
||||||
@@ -9,10 +10,11 @@ _local_src = os.path.join(os.path.dirname(__file__), "src")
|
|||||||
if _local_src not in sys.path:
|
if _local_src not in sys.path:
|
||||||
sys.path.insert(0, _local_src)
|
sys.path.insert(0, _local_src)
|
||||||
|
|
||||||
import orisys
|
|
||||||
import numpy as np
|
|
||||||
import time
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import time
|
||||||
|
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
@@ -30,11 +32,13 @@ def main():
|
|||||||
# 方式1: 从视频文件读取
|
# 方式1: 从视频文件读取
|
||||||
try:
|
try:
|
||||||
input = int(args.video)
|
input = int(args.video)
|
||||||
is_camera = True
|
except ValueError:
|
||||||
sensor = orisys.Sensor(input, config_name=args.config, cal_path= args.cal, verbose=args.verbose, debug=args.debug)
|
# Not a camera index: treat the argument as a video-file path. Only the
|
||||||
except:
|
# conversion is guarded, so a genuine Sensor construction error is not
|
||||||
is_camera = False
|
# 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)
|
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')
|
# sensor = orisys.Sensor(2, config_name="./config/ddjx01_q6a.json", cal_path= "./config/ddjx01.npy", isstitch=True, cuda=False, verbose=True, backend='cpu')
|
||||||
|
|
||||||
# 方式2: 从摄像头读取(取消注释使用)
|
# 方式2: 从摄像头读取(取消注释使用)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
import cv2
|
|
||||||
import orisys
|
|
||||||
import numpy as np
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
|
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
|
||||||
def create_plot():
|
def create_plot():
|
||||||
p1 = win.addPlot(title="法相力")
|
p1 = win.addPlot(title="法相力")
|
||||||
p1.setLabel('bottom', '时间', units='s')
|
p1.setLabel('bottom', '时间', units='s')
|
||||||
@@ -149,7 +152,7 @@ def main():
|
|||||||
print("\n 停止程序")
|
print("\n 停止程序")
|
||||||
break
|
break
|
||||||
if key == ord("s"):
|
if key == ord("s"):
|
||||||
save_path = f"./data/pulse_test.mp4"
|
save_path = "./data/pulse_test.mp4"
|
||||||
# Ensure directory exists
|
# Ensure directory exists
|
||||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||||
# Get frame dimensions from the actual frame
|
# Get frame dimensions from the actual frame
|
||||||
|
|||||||
+16
-9
@@ -46,7 +46,7 @@ import os
|
|||||||
import shlex
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Union
|
||||||
|
|
||||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||||
|
|
||||||
@@ -60,13 +60,14 @@ except ImportError as exc: # pragma: no cover
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
|
|
||||||
from orisys.capture import is_live_camera, list_cameras
|
from orisys.capture import is_live_camera, list_cameras
|
||||||
from orisys.finger import FingerRuntime
|
from orisys.finger import FingerRuntime
|
||||||
|
|
||||||
Source = Union[int, str]
|
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."""
|
"""Accept camera index int, numeric string, V4L path, or filesystem path."""
|
||||||
text = str(token).strip().strip('"').strip("'")
|
text = str(token).strip().strip('"').strip("'")
|
||||||
if not text:
|
if not text:
|
||||||
@@ -116,9 +117,9 @@ class UartFingerController:
|
|||||||
self.show = show
|
self.show = show
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
|
|
||||||
self.runtime: Optional[FingerRuntime] = None
|
self.runtime: FingerRuntime | None = None
|
||||||
self._connected_source: Optional[Source] = None
|
self._connected_source: Source | None = None
|
||||||
self._last_search_source: Optional[Source] = None
|
self._last_search_source: Source | None = None
|
||||||
self._streaming = False
|
self._streaming = False
|
||||||
self._should_quit = False
|
self._should_quit = False
|
||||||
|
|
||||||
@@ -134,14 +135,18 @@ class UartFingerController:
|
|||||||
if self.runtime is not None:
|
if self.runtime is not None:
|
||||||
try:
|
try:
|
||||||
self.runtime.close()
|
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
|
pass
|
||||||
self.runtime = None
|
self.runtime = None
|
||||||
self._connected_source = None
|
self._connected_source = None
|
||||||
if self.show:
|
if self.show:
|
||||||
try:
|
try:
|
||||||
cv2.destroyAllWindows()
|
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
|
pass
|
||||||
|
|
||||||
def _resolve_connect_source(self, args: list[str]) -> Source:
|
def _resolve_connect_source(self, args: list[str]) -> Source:
|
||||||
@@ -220,7 +225,7 @@ class UartFingerController:
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
return False
|
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."""
|
"""Read one command line if available; return None on timeout/empty."""
|
||||||
if not blocking and self.ser.in_waiting <= 0:
|
if not blocking and self.ser.in_waiting <= 0:
|
||||||
return None
|
return None
|
||||||
@@ -447,7 +452,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print("\nInterrupted.", flush=True)
|
print("\nInterrupted.", flush=True)
|
||||||
try:
|
try:
|
||||||
controller.reply("OK quit")
|
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
|
pass
|
||||||
finally:
|
finally:
|
||||||
controller.close_runtime()
|
controller.close_runtime()
|
||||||
|
|||||||
@@ -36,10 +36,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
import statistics
|
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ def _window(
|
|||||||
valid = _column(rows, VALID_COLUMN)
|
valid = _column(rows, VALID_COLUMN)
|
||||||
mask = np.ones(times.shape, dtype=bool)
|
mask = np.ones(times.shape, dtype=bool)
|
||||||
if times.size:
|
if times.size:
|
||||||
mask &= times == times # drop NaN timestamps
|
mask &= ~np.isnan(times) # drop NaN timestamps (unparsable TIME cells)
|
||||||
if start is not None:
|
if start is not None:
|
||||||
mask &= times >= float(start)
|
mask &= times >= float(start)
|
||||||
if stop is not None:
|
if stop is not None:
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
import cv2
|
|
||||||
import orisys
|
|
||||||
import numpy as np
|
|
||||||
import time
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import time
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from PyQt5.QtGui import QImage, QFont
|
from PyQt5.QtGui import QFont, QImage
|
||||||
from pyqtgraph.Qt import QtGui
|
from pyqtgraph.Qt import QtGui
|
||||||
|
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
|
||||||
def make_gradient_brush(color_hex, max_alpha=150):
|
def make_gradient_brush(color_hex, max_alpha=150):
|
||||||
"""
|
"""
|
||||||
创建一个从上到下渐变的笔刷 (兼容 PyQt5 和 PyQt6)
|
创建一个从上到下渐变的笔刷 (兼容 PyQt5 和 PyQt6)
|
||||||
|
|||||||
Binary file not shown.
@@ -5,9 +5,7 @@ Historical import path ``from orisys.Stitcher import Stitcher`` remains supporte
|
|||||||
|
|
||||||
from .stitch import Stitcher
|
from .stitch import Stitcher
|
||||||
from .stitch.backends.numpy_backend import blend_kernel_numpy as _blend_kernel_numpy
|
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 numba_blend_available
|
||||||
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
|
|
||||||
|
|
||||||
# Backward-compatible aliases used by older call sites / tests.
|
# Backward-compatible aliases used by older call sites / tests.
|
||||||
_blend_kernel = _blend_kernel_numpy
|
_blend_kernel = _blend_kernel_numpy
|
||||||
|
|||||||
@@ -8,16 +8,11 @@ including image stitching, optical flow tracking, and deformation decomposition.
|
|||||||
__version__ = "0.5.3"
|
__version__ = "0.5.3"
|
||||||
__author__ = "Orisys"
|
__author__ = "Orisys"
|
||||||
|
|
||||||
from .sensor import Sensor
|
from . import calibration, capture, configs, finger, signal_processing, util
|
||||||
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 .export import StreamRecorder, mosaic_videos
|
from .export import StreamRecorder, mosaic_videos
|
||||||
from .frame_processors import FFCFrameProcessor
|
from .frame_processors import FFCFrameProcessor
|
||||||
|
from .pipeline import FrameSnapshot, PauseLevel, PipelineStats, StreamPipeline
|
||||||
|
from .sensor import Sensor
|
||||||
|
|
||||||
CompoundEyeSensor = Sensor
|
CompoundEyeSensor = Sensor
|
||||||
CameraSensor = Sensor
|
CameraSensor = Sensor
|
||||||
@@ -50,6 +45,7 @@ __all__ = [
|
|||||||
'StreamPipeline',
|
'StreamPipeline',
|
||||||
'FrameSnapshot',
|
'FrameSnapshot',
|
||||||
'PipelineStats',
|
'PipelineStats',
|
||||||
|
'PauseLevel',
|
||||||
|
|
||||||
# offline recording of arrow overlays
|
# offline recording of arrow overlays
|
||||||
'StreamRecorder',
|
'StreamRecorder',
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ except ImportError:
|
|||||||
try:
|
try:
|
||||||
import pyopencl as cl
|
import pyopencl as cl
|
||||||
import pyopencl.array as cla
|
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 irfftn as cl_irfftn
|
||||||
|
from pyvkfft.fft import rfftn as cl_rfftn
|
||||||
_opencl_cl = cl
|
_opencl_cl = cl
|
||||||
_opencl_cla = cla
|
_opencl_cla = cla
|
||||||
_opencl_rfftn = cl_rfftn
|
_opencl_rfftn = cl_rfftn
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ curves in ``_apply_force_filter``; ``read_info(FNORMAL, …)`` returns Newtons.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable, Mapping
|
from typing import Any
|
||||||
|
|
||||||
CURRENT_SCHEMA = 2
|
CURRENT_SCHEMA = 2
|
||||||
SUPPORTED_SCHEMAS = (1, 2)
|
SUPPORTED_SCHEMAS = (1, 2)
|
||||||
@@ -804,6 +806,60 @@ def load(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_for_device(
|
||||||
|
calibration_dir: str | Path,
|
||||||
|
sensor_type: str,
|
||||||
|
device_id: str,
|
||||||
|
*,
|
||||||
|
pipeline: Mapping[str, Any] | None = None,
|
||||||
|
) -> Calibration | None:
|
||||||
|
"""Load ``<directory>/<sensor_type>/<device_id>.json``; missing file means raw."""
|
||||||
|
root = Path(calibration_dir).expanduser()
|
||||||
|
if not root.is_dir():
|
||||||
|
raise CalibrationError(f"calibration directory not found: {root}")
|
||||||
|
for label, value in (("sensor_type", sensor_type), ("device_id", device_id)):
|
||||||
|
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", value):
|
||||||
|
raise CalibrationError(f"invalid {label}: {value!r}")
|
||||||
|
path = root / sensor_type / f"{device_id}.json"
|
||||||
|
if not path.exists():
|
||||||
|
if path.is_symlink():
|
||||||
|
raise CalibrationError(f"calibration link is broken: {path}")
|
||||||
|
return None
|
||||||
|
if not path.is_file():
|
||||||
|
raise CalibrationError(f"calibration path is not a file: {path}")
|
||||||
|
cal = load(path, strict=True)
|
||||||
|
assert cal is not None
|
||||||
|
return validate_for_device(cal, sensor_type, device_id, pipeline=pipeline)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_for_device(
|
||||||
|
cal: Calibration,
|
||||||
|
sensor_type: str,
|
||||||
|
device_id: str,
|
||||||
|
*,
|
||||||
|
pipeline: Mapping[str, Any] | None = None,
|
||||||
|
) -> Calibration:
|
||||||
|
"""Reject a calibration for another device or measurement pipeline."""
|
||||||
|
path = cal.source_path or "<in-memory calibration>"
|
||||||
|
if cal.identity.get("sensor_type") != sensor_type or cal.identity.get("sn") != device_id:
|
||||||
|
raise CalibrationError(f"calibration identity mismatch: {path}")
|
||||||
|
if cal.unit != "N" or any(cal.channels[axis].unit != "N" for axis in cal.channels):
|
||||||
|
raise CalibrationError(f"calibration unit must be N: {path}")
|
||||||
|
if set(CHANNEL_IDS) - cal.channels.keys():
|
||||||
|
raise CalibrationError(f"calibration requires FN, FX and FY: {path}")
|
||||||
|
if pipeline is not None:
|
||||||
|
for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
|
||||||
|
recorded, current = cal.pipeline.get(key), pipeline.get(key)
|
||||||
|
if recorded is not None and current is not None and recorded != current:
|
||||||
|
raise CalibrationError(f"calibration pipeline mismatch ({key}): {path}")
|
||||||
|
measurement, _ = filter_mismatches(
|
||||||
|
cal.pipeline.get("filters"), pipeline.get("filters")
|
||||||
|
)
|
||||||
|
if measurement:
|
||||||
|
raise CalibrationError(f"calibration pipeline mismatch ({'; '.join(measurement)}): {path}")
|
||||||
|
return cal
|
||||||
|
|
||||||
|
|
||||||
def pipeline_from_sensor_json(
|
def pipeline_from_sensor_json(
|
||||||
path: str | Path | None,
|
path: str | Path | None,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -25,21 +25,21 @@ from .source import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"MAX_SIMULTANEOUS_CAPTURES",
|
|
||||||
"DEFAULT_MAX_CAMERA_INDEX",
|
|
||||||
"DEFAULT_LAPTOP_WEBCAM_PATTERNS",
|
"DEFAULT_LAPTOP_WEBCAM_PATTERNS",
|
||||||
|
"DEFAULT_MAX_CAMERA_INDEX",
|
||||||
|
"MAX_SIMULTANEOUS_CAPTURES",
|
||||||
|
"CameraFilter",
|
||||||
|
"CameraInfo",
|
||||||
"CaptureConfig",
|
"CaptureConfig",
|
||||||
"CaptureHealth",
|
"CaptureHealth",
|
||||||
"CaptureStatus",
|
|
||||||
"ReconnectPolicy",
|
|
||||||
"CaptureSource",
|
"CaptureSource",
|
||||||
|
"CaptureStatus",
|
||||||
"CaptureWorker",
|
"CaptureWorker",
|
||||||
"CameraInfo",
|
"ReconnectPolicy",
|
||||||
"CameraFilter",
|
|
||||||
"list_cameras",
|
|
||||||
"pick_default_camera",
|
|
||||||
"normalize_source",
|
|
||||||
"is_live_camera",
|
"is_live_camera",
|
||||||
"open_opencv_capture",
|
"list_cameras",
|
||||||
|
"normalize_source",
|
||||||
"open_capture",
|
"open_capture",
|
||||||
|
"open_opencv_capture",
|
||||||
|
"pick_default_camera",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Optional, Sequence, Tuple, Union
|
from typing import Any, Union
|
||||||
|
|
||||||
MAX_SIMULTANEOUS_CAPTURES = 10
|
MAX_SIMULTANEOUS_CAPTURES = 10
|
||||||
|
|
||||||
@@ -44,11 +45,11 @@ class ReconnectPolicy:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
max_attempts: Optional[int] = None
|
max_attempts: int | None = None
|
||||||
initial_backoff_s: float = 0.25
|
initial_backoff_s: float = 0.25
|
||||||
max_backoff_s: float = 5.0
|
max_backoff_s: float = 5.0
|
||||||
fail_threshold: int = 5
|
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:
|
def next_backoff(self, attempt: int) -> float:
|
||||||
"""Exponential backoff capped at ``max_backoff_s`` (attempt is 0-based)."""
|
"""Exponential backoff capped at ``max_backoff_s`` (attempt is 0-based)."""
|
||||||
@@ -62,19 +63,19 @@ class CaptureConfig:
|
|||||||
|
|
||||||
source: SourceType
|
source: SourceType
|
||||||
backend: str = "auto"
|
backend: str = "auto"
|
||||||
width: Optional[int] = None
|
width: int | None = None
|
||||||
height: Optional[int] = None
|
height: int | None = None
|
||||||
fps: Optional[float] = None
|
fps: float | None = None
|
||||||
fourcc: Optional[str] = "MJPG"
|
fourcc: str | None = "MJPG"
|
||||||
background: bool = True
|
background: bool = True
|
||||||
reconnect: ReconnectPolicy = field(default_factory=ReconnectPolicy)
|
reconnect: ReconnectPolicy = field(default_factory=ReconnectPolicy)
|
||||||
verbose: bool = False
|
verbose: bool = False
|
||||||
# Optional discovery filters (used by list/open helpers, not open itself).
|
# Optional discovery filters (used by list/open helpers, not open itself).
|
||||||
name_include: Optional[Sequence[str]] = None
|
name_include: Sequence[str] | None = None
|
||||||
name_exclude: Optional[Sequence[str]] = None
|
name_exclude: Sequence[str] | None = None
|
||||||
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None
|
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None
|
||||||
min_width: Optional[int] = None
|
min_width: int | None = None
|
||||||
min_height: Optional[int] = None
|
min_height: int | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not isinstance(self.reconnect, ReconnectPolicy):
|
if not isinstance(self.reconnect, ReconnectPolicy):
|
||||||
@@ -86,10 +87,10 @@ class CaptureConfig:
|
|||||||
source: SourceType,
|
source: SourceType,
|
||||||
camera: dict[str, Any],
|
camera: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
reconnect: Optional[ReconnectPolicy] = None,
|
reconnect: ReconnectPolicy | None = None,
|
||||||
verbose: bool = False,
|
verbose: bool = False,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> "CaptureConfig":
|
) -> CaptureConfig:
|
||||||
"""Build config from a sensor JSON ``camera`` section."""
|
"""Build config from a sensor JSON ``camera`` section."""
|
||||||
# Accept deprecated queue/worker kwargs from older callers without error.
|
# Accept deprecated queue/worker kwargs from older callers without error.
|
||||||
kwargs.pop("mode", None)
|
kwargs.pop("mode", None)
|
||||||
@@ -117,9 +118,9 @@ class CaptureStatus:
|
|||||||
"""Snapshot of capture health for diagnostics / multi-camera UIs."""
|
"""Snapshot of capture health for diagnostics / multi-camera UIs."""
|
||||||
|
|
||||||
health: CaptureHealth = CaptureHealth.CLOSED
|
health: CaptureHealth = CaptureHealth.CLOSED
|
||||||
source: Optional[SourceType] = None
|
source: SourceType | None = None
|
||||||
last_error: Optional[str] = None
|
last_error: str | None = None
|
||||||
last_frame_time: Optional[float] = None
|
last_frame_time: float | None = None
|
||||||
last_seq: int = 0
|
last_seq: int = 0
|
||||||
dropped_frames: int = 0
|
dropped_frames: int = 0
|
||||||
reconnect_attempts: int = 0
|
reconnect_attempts: int = 0
|
||||||
|
|||||||
@@ -3,21 +3,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Iterable, Optional, Sequence, Tuple
|
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
|
|
||||||
from .config import MAX_SIMULTANEOUS_CAPTURES
|
from .config import MAX_SIMULTANEOUS_CAPTURES
|
||||||
from .source import _backend_candidates
|
from .source import _backend_candidates
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_MAX_CAMERA_INDEX = MAX_SIMULTANEOUS_CAPTURES
|
DEFAULT_MAX_CAMERA_INDEX = MAX_SIMULTANEOUS_CAPTURES
|
||||||
|
|
||||||
# Common built-in laptop webcam name fragments (case-insensitive).
|
# Common built-in laptop webcam name fragments (case-insensitive).
|
||||||
DEFAULT_LAPTOP_WEBCAM_PATTERNS: Tuple[str, ...] = (
|
DEFAULT_LAPTOP_WEBCAM_PATTERNS: tuple[str, ...] = (
|
||||||
r"integrated",
|
r"integrated",
|
||||||
r"built[- ]?in",
|
r"built[- ]?in",
|
||||||
r"laptop",
|
r"laptop",
|
||||||
@@ -49,8 +52,8 @@ class CameraInfo:
|
|||||||
height: int
|
height: int
|
||||||
name: str = ""
|
name: str = ""
|
||||||
path: str = ""
|
path: str = ""
|
||||||
vendor_id: Optional[int] = None
|
vendor_id: int | None = None
|
||||||
product_id: Optional[int] = None
|
product_id: int | None = None
|
||||||
bus: str = ""
|
bus: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -64,7 +67,7 @@ class CameraInfo:
|
|||||||
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
|
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
|
||||||
|
|
||||||
@property
|
@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:
|
if self.vendor_id is None or self.product_id is None:
|
||||||
return None
|
return None
|
||||||
return (int(self.vendor_id), int(self.product_id))
|
return (int(self.vendor_id), int(self.product_id))
|
||||||
@@ -74,13 +77,13 @@ class CameraInfo:
|
|||||||
class CameraFilter:
|
class CameraFilter:
|
||||||
"""Optional filters for ``list_cameras`` / ``pick_default_camera``."""
|
"""Optional filters for ``list_cameras`` / ``pick_default_camera``."""
|
||||||
|
|
||||||
name_include: Optional[Sequence[str]] = None
|
name_include: Sequence[str] | None = None
|
||||||
name_exclude: Optional[Sequence[str]] = None
|
name_exclude: Sequence[str] | None = None
|
||||||
exclude_laptop_webcams: bool = False
|
exclude_laptop_webcams: bool = False
|
||||||
laptop_patterns: Sequence[str] = field(default_factory=lambda: DEFAULT_LAPTOP_WEBCAM_PATTERNS)
|
laptop_patterns: Sequence[str] = field(default_factory=lambda: DEFAULT_LAPTOP_WEBCAM_PATTERNS)
|
||||||
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None
|
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None
|
||||||
min_width: Optional[int] = None
|
min_width: int | None = None
|
||||||
min_height: Optional[int] = None
|
min_height: int | None = None
|
||||||
prefer_named: bool = True
|
prefer_named: bool = True
|
||||||
|
|
||||||
def matches(self, cam: CameraInfo) -> bool:
|
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:
|
if set_level is not None and prev_level is not None:
|
||||||
try:
|
try:
|
||||||
set_level(prev_level)
|
set_level(prev_level)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
# 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:
|
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": ""}
|
meta = {"name": "", "path": f"/dev/video{index}", "vendor_id": None, "product_id": None, "bus": ""}
|
||||||
name_path = f"/sys/class/video4linux/video{index}/name"
|
name_path = f"/sys/class/video4linux/video{index}/name"
|
||||||
try:
|
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()
|
meta["name"] = fh.read().strip()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
@@ -165,9 +175,9 @@ def _linux_device_metadata(index: int) -> dict:
|
|||||||
vend = os.path.join(cur, "idVendor")
|
vend = os.path.join(cur, "idVendor")
|
||||||
prod = os.path.join(cur, "idProduct")
|
prod = os.path.join(cur, "idProduct")
|
||||||
if os.path.isfile(vend) and os.path.isfile(prod):
|
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)
|
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["product_id"] = int(fh.read().strip(), 16)
|
||||||
meta["bus"] = "usb"
|
meta["bus"] = "usb"
|
||||||
break
|
break
|
||||||
@@ -229,7 +239,7 @@ def _windows_device_names() -> dict[int, dict]:
|
|||||||
return {}
|
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"):
|
if sys.platform.startswith("linux"):
|
||||||
return _linux_device_metadata(index)
|
return _linux_device_metadata(index)
|
||||||
if sys.platform == "win32" and windows_names is not None:
|
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": ""}
|
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)
|
cap = cv2.VideoCapture(index, backend_api)
|
||||||
if not cap.isOpened():
|
if not cap.isOpened():
|
||||||
cap.release()
|
cap.release()
|
||||||
@@ -272,13 +282,13 @@ def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> Op
|
|||||||
def list_cameras(
|
def list_cameras(
|
||||||
max_index: int = DEFAULT_MAX_CAMERA_INDEX,
|
max_index: int = DEFAULT_MAX_CAMERA_INDEX,
|
||||||
*,
|
*,
|
||||||
camera_filter: Optional[CameraFilter] = None,
|
camera_filter: CameraFilter | None = None,
|
||||||
exclude_laptop_webcams: bool = False,
|
exclude_laptop_webcams: bool = False,
|
||||||
name_include: Optional[Sequence[str]] = None,
|
name_include: Sequence[str] | None = None,
|
||||||
name_exclude: Optional[Sequence[str]] = None,
|
name_exclude: Sequence[str] | None = None,
|
||||||
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None,
|
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None,
|
||||||
min_width: Optional[int] = None,
|
min_width: int | None = None,
|
||||||
min_height: Optional[int] = None,
|
min_height: int | None = None,
|
||||||
backend: str = "auto",
|
backend: str = "auto",
|
||||||
) -> list[CameraInfo]:
|
) -> list[CameraInfo]:
|
||||||
"""Probe indices ``0 .. max_index`` and return working cameras.
|
"""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
|
windows_names = _windows_device_names() if sys.platform == "win32" else None
|
||||||
|
|
||||||
with _suppress_opencv_noise():
|
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)
|
meta = _device_metadata(index, windows_names)
|
||||||
for backend_name, backend_api in backends:
|
for backend_name, backend_api in backends:
|
||||||
|
try:
|
||||||
info = _try_open(index, backend_name, backend_api, meta)
|
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:
|
if info is None:
|
||||||
continue
|
continue
|
||||||
if index in seen_indices:
|
if index in seen_indices:
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Optional, Protocol, runtime_checkable
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -19,6 +20,8 @@ from .config import (
|
|||||||
SourceType,
|
SourceType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class VideoCaptureLike(Protocol):
|
class VideoCaptureLike(Protocol):
|
||||||
@@ -83,7 +86,7 @@ def _backend_candidates(preferred: str = "auto") -> list[tuple[str, int]]:
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
def _fourcc_code(fourcc: Optional[str]) -> Optional[int]:
|
def _fourcc_code(fourcc: str | None) -> int | None:
|
||||||
if not fourcc:
|
if not fourcc:
|
||||||
return None
|
return None
|
||||||
text = str(fourcc).strip()
|
text = str(fourcc).strip()
|
||||||
@@ -111,10 +114,10 @@ def _read_stream_meta(cap: VideoCaptureLike) -> tuple[int, int, float, str]:
|
|||||||
def _apply_stream_props(
|
def _apply_stream_props(
|
||||||
cap: VideoCaptureLike,
|
cap: VideoCaptureLike,
|
||||||
*,
|
*,
|
||||||
width: Optional[int],
|
width: int | None,
|
||||||
height: Optional[int],
|
height: int | None,
|
||||||
fps: Optional[float],
|
fps: float | None,
|
||||||
fourcc: Optional[str],
|
fourcc: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Apply live-camera stream properties (width/height before fourcc on DSHOW)."""
|
"""Apply live-camera stream properties (width/height before fourcc on DSHOW)."""
|
||||||
code = _fourcc_code(fourcc)
|
code = _fourcc_code(fourcc)
|
||||||
@@ -143,10 +146,10 @@ def open_opencv_capture(
|
|||||||
source: SourceType,
|
source: SourceType,
|
||||||
*,
|
*,
|
||||||
backend: str = "auto",
|
backend: str = "auto",
|
||||||
width: Optional[int] = None,
|
width: int | None = None,
|
||||||
height: Optional[int] = None,
|
height: int | None = None,
|
||||||
fps: Optional[float] = None,
|
fps: float | None = None,
|
||||||
fourcc: Optional[str] = "MJPG",
|
fourcc: str | None = "MJPG",
|
||||||
apply_stream_props: bool = True,
|
apply_stream_props: bool = True,
|
||||||
) -> tuple[VideoCaptureLike, str]:
|
) -> tuple[VideoCaptureLike, str]:
|
||||||
"""Open a ``cv2.VideoCapture`` trying preferred backends in order.
|
"""Open a ``cv2.VideoCapture`` trying preferred backends in order.
|
||||||
@@ -212,16 +215,16 @@ class CaptureSource:
|
|||||||
):
|
):
|
||||||
self.config = config
|
self.config = config
|
||||||
self._factory = factory or self._default_factory
|
self._factory = factory or self._default_factory
|
||||||
self._cap: Optional[VideoCaptureLike] = None
|
self._cap: VideoCaptureLike | None = None
|
||||||
self._backend_name = ""
|
self._backend_name = ""
|
||||||
self._status = CaptureStatus(source=normalize_source(config.source))
|
self._status = CaptureStatus(source=normalize_source(config.source))
|
||||||
self._reconnect_attempt = 0
|
self._reconnect_attempt = 0
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._frame_lock = threading.Lock()
|
self._frame_lock = threading.Lock()
|
||||||
self._frame_cond = threading.Condition(self._frame_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._latest_seq = 0
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: threading.Thread | None = None
|
||||||
self._background_running = False
|
self._background_running = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -238,7 +241,7 @@ class CaptureSource:
|
|||||||
|
|
||||||
# Compatibility with older CaptureWorker API.
|
# Compatibility with older CaptureWorker API.
|
||||||
@property
|
@property
|
||||||
def source(self) -> "CaptureSource":
|
def source(self) -> CaptureSource:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -265,7 +268,7 @@ class CaptureSource:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cap(self) -> Optional[VideoCaptureLike]:
|
def cap(self) -> VideoCaptureLike | None:
|
||||||
"""Raw capture handle (compat with Sensor.cap)."""
|
"""Raw capture handle (compat with Sensor.cap)."""
|
||||||
return self._cap
|
return self._cap
|
||||||
|
|
||||||
@@ -324,8 +327,14 @@ class CaptureSource:
|
|||||||
if self._cap is not None:
|
if self._cap is not None:
|
||||||
try:
|
try:
|
||||||
self._cap.release()
|
self._cap.release()
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
# 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
|
self._cap = None
|
||||||
with self._frame_cond:
|
with self._frame_cond:
|
||||||
self._latest_frame = None
|
self._latest_frame = None
|
||||||
@@ -341,8 +350,11 @@ class CaptureSource:
|
|||||||
self._status.height = height
|
self._status.height = height
|
||||||
self._status.fps = fps
|
self._status.fps = fps
|
||||||
self._status.fourcc = fourcc
|
self._status.fourcc = fourcc
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
# 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:
|
def warmup_frames(self, count: int = 3) -> None:
|
||||||
"""Discard frames so the driver/format stabilizes (sync reads only)."""
|
"""Discard frames so the driver/format stabilizes (sync reads only)."""
|
||||||
@@ -351,7 +363,7 @@ class CaptureSource:
|
|||||||
for _ in range(max(0, int(count))):
|
for _ in range(max(0, int(count))):
|
||||||
self.read_raw()
|
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)."""
|
"""Read one frame directly from the underlying capture (no reconnect)."""
|
||||||
cap = self._cap
|
cap = self._cap
|
||||||
if cap is None:
|
if cap is None:
|
||||||
@@ -379,7 +391,7 @@ class CaptureSource:
|
|||||||
*,
|
*,
|
||||||
copy: bool = True,
|
copy: bool = True,
|
||||||
wait_s: float = 0.0,
|
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."""
|
"""Return the newest frame from the background thread, or read sync."""
|
||||||
if not self._background_running:
|
if not self._background_running:
|
||||||
ok, frame = self.read_raw()
|
ok, frame = self.read_raw()
|
||||||
@@ -397,7 +409,7 @@ class CaptureSource:
|
|||||||
frame = self._latest_frame.copy() if copy else self._latest_frame
|
frame = self._latest_frame.copy() if copy else self._latest_frame
|
||||||
return True, 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."""
|
"""Compat alias used by finger runtime and older worker callers."""
|
||||||
wait = wait_s if wait_s > 0 else 1.0
|
wait = wait_s if wait_s > 0 else 1.0
|
||||||
return self.read_latest(copy=copy, wait_s=wait)
|
return self.read_latest(copy=copy, wait_s=wait)
|
||||||
@@ -531,8 +543,8 @@ def open_capture(
|
|||||||
config: CaptureConfig,
|
config: CaptureConfig,
|
||||||
*,
|
*,
|
||||||
factory=None,
|
factory=None,
|
||||||
start_background: Optional[bool] = None,
|
start_background: bool | None = None,
|
||||||
start_worker: Optional[bool] = None,
|
start_worker: bool | None = None,
|
||||||
) -> CaptureSource:
|
) -> CaptureSource:
|
||||||
"""Open a capture source and optionally start its background thread.
|
"""Open a capture source and optionally start its background thread.
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ def _config_files() -> list[Path]:
|
|||||||
|
|
||||||
|
|
||||||
def _load_json(path: Path) -> dict:
|
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)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
@@ -433,7 +433,7 @@ def load_config(config_name_or_path):
|
|||||||
for candidate in _config_files():
|
for candidate in _config_files():
|
||||||
try:
|
try:
|
||||||
data = _load_json(candidate)
|
data = _load_json(candidate)
|
||||||
except Exception:
|
except Exception: # noqa: S112 - skip unreadable config during alias scan
|
||||||
continue
|
continue
|
||||||
if data.get("name") in candidates:
|
if data.get("name") in candidates:
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
from typing import Any, Callable, Iterable, Sequence
|
from typing import Any
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -243,7 +243,7 @@ class StreamRecorder:
|
|||||||
"fourcc": self.fourcc,
|
"fourcc": self.fourcc,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __enter__(self) -> "StreamRecorder":
|
def __enter__(self) -> StreamRecorder:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ from .mesh import (
|
|||||||
lift_uv_deformation_to_3d,
|
lift_uv_deformation_to_3d,
|
||||||
lift_uv_flow_to_3d,
|
lift_uv_flow_to_3d,
|
||||||
mesh_uv_to_world_scale,
|
mesh_uv_to_world_scale,
|
||||||
scalar_map_vertex_colors,
|
|
||||||
sample_field_at_mesh_vertices,
|
sample_field_at_mesh_vertices,
|
||||||
|
scalar_map_vertex_colors,
|
||||||
)
|
)
|
||||||
from .runtime import (
|
from .runtime import (
|
||||||
FingerGeometry,
|
FingerGeometry,
|
||||||
@@ -39,8 +39,8 @@ __all__ = [
|
|||||||
"FingerGeometry",
|
"FingerGeometry",
|
||||||
"FingerRuntime",
|
"FingerRuntime",
|
||||||
"TactileProcessor",
|
"TactileProcessor",
|
||||||
"UVSurfaceMesh",
|
|
||||||
"UVMeshViewer",
|
"UVMeshViewer",
|
||||||
|
"UVSurfaceMesh",
|
||||||
"build_uv_surface_mesh",
|
"build_uv_surface_mesh",
|
||||||
"compute_uv_surface_frames",
|
"compute_uv_surface_frames",
|
||||||
"deform_mesh_vertices",
|
"deform_mesh_vertices",
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ class UVSurfaceMesh:
|
|||||||
"""Triangle mesh built from a UV flatten grid and world_xyz surface map."""
|
"""Triangle mesh built from a UV flatten grid and world_xyz surface map."""
|
||||||
|
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
"rest_vertices",
|
|
||||||
"triangles",
|
|
||||||
"vertex_ys",
|
|
||||||
"vertex_xs",
|
|
||||||
"index_map",
|
|
||||||
"valid_mask",
|
|
||||||
"shape",
|
|
||||||
"world_xyz",
|
|
||||||
"base_colors",
|
"base_colors",
|
||||||
|
"index_map",
|
||||||
|
"rest_vertices",
|
||||||
|
"shape",
|
||||||
|
"triangles",
|
||||||
|
"valid_mask",
|
||||||
|
"vertex_xs",
|
||||||
|
"vertex_ys",
|
||||||
|
"world_xyz",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ class TactileProcessor:
|
|||||||
def valid_mask(self):
|
def valid_mask(self):
|
||||||
return self._sensor.valid_mask
|
return self._sensor.valid_mask
|
||||||
|
|
||||||
|
@property
|
||||||
|
def force_units(self) -> tuple[str, str, str]:
|
||||||
|
return self._sensor.force_units
|
||||||
|
|
||||||
def push_frame(self, frame, *, already_gray=False, valid_mask=None):
|
def push_frame(self, frame, *, already_gray=False, valid_mask=None):
|
||||||
"""Feed one processed tactile frame into the analysis pipeline."""
|
"""Feed one processed tactile frame into the analysis pipeline."""
|
||||||
mask = self._default_valid_mask if valid_mask is None else valid_mask
|
mask = self._default_valid_mask if valid_mask is None else valid_mask
|
||||||
@@ -114,6 +118,21 @@ class TactileProcessor:
|
|||||||
def warmup(self):
|
def warmup(self):
|
||||||
return self._sensor.warmup_deformation()
|
return self._sensor.warmup_deformation()
|
||||||
|
|
||||||
|
def pipeline(self, produce, **kwargs):
|
||||||
|
"""Start a :class:`~orisys.pipeline.StreamPipeline` over this processor's own Sensor.
|
||||||
|
|
||||||
|
``produce`` is the frame source: a zero-argument callable returning the next unrolled
|
||||||
|
frame, or ``None`` at end of input. :class:`FingerRuntime` supplies it through
|
||||||
|
:meth:`FingerRuntime.pipeline`, so call this directly only when you are producing frames
|
||||||
|
yourself.
|
||||||
|
|
||||||
|
Any keyword argument is forwarded to ``StreamPipeline``, so ``contact=True`` and
|
||||||
|
``include=(...)`` work exactly as they do there.
|
||||||
|
"""
|
||||||
|
from orisys.pipeline import StreamPipeline
|
||||||
|
|
||||||
|
return StreamPipeline(self._sensor, produce, **kwargs)
|
||||||
|
|
||||||
def compute_deformation(self, *args, **kwargs):
|
def compute_deformation(self, *args, **kwargs):
|
||||||
return self._sensor.compute_deformation(*args, **kwargs)
|
return self._sensor.compute_deformation(*args, **kwargs)
|
||||||
|
|
||||||
@@ -239,6 +258,9 @@ class FingerRuntime:
|
|||||||
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
||||||
source_scale=None,
|
source_scale=None,
|
||||||
calib=None,
|
calib=None,
|
||||||
|
cal_path=None,
|
||||||
|
calibration_dir=None,
|
||||||
|
device_id=None,
|
||||||
video=0,
|
video=0,
|
||||||
camera_id=None,
|
camera_id=None,
|
||||||
flip_source_vertical=True,
|
flip_source_vertical=True,
|
||||||
@@ -246,7 +268,7 @@ class FingerRuntime:
|
|||||||
mirror=False,
|
mirror=False,
|
||||||
auto_reconnect: bool = False,
|
auto_reconnect: bool = False,
|
||||||
capture_mode: str = "latest",
|
capture_mode: str = "latest",
|
||||||
) -> "FingerRuntime":
|
) -> FingerRuntime:
|
||||||
"""Open a live finger runtime from export bundle, config, and video source."""
|
"""Open a live finger runtime from export bundle, config, and video source."""
|
||||||
del capture_mode # deprecated; latest-frame capture only
|
del capture_mode # deprecated; latest-frame capture only
|
||||||
if camera_id is not None:
|
if camera_id is not None:
|
||||||
@@ -278,6 +300,12 @@ class FingerRuntime:
|
|||||||
)
|
)
|
||||||
domain.remap.ensure_fast_maps()
|
domain.remap.ensure_fast_maps()
|
||||||
|
|
||||||
|
sensor = Sensor(
|
||||||
|
"external", config_name=config, cal_path=cal_path,
|
||||||
|
calibration_dir=calibration_dir, device_id=device_id,
|
||||||
|
verbose=verbose, mirror=mirror,
|
||||||
|
)
|
||||||
|
|
||||||
capture_config = CaptureConfig.from_camera_dict(
|
capture_config = CaptureConfig.from_camera_dict(
|
||||||
video,
|
video,
|
||||||
geometry.config.get("camera", {}),
|
geometry.config.get("camera", {}),
|
||||||
@@ -292,7 +320,6 @@ class FingerRuntime:
|
|||||||
capture_source.start_background()
|
capture_source.start_background()
|
||||||
capture = capture_source.cap
|
capture = capture_source.cap
|
||||||
|
|
||||||
sensor = Sensor("external", config_name=config, verbose=verbose, mirror=mirror)
|
|
||||||
if not is_live_camera(video):
|
if not is_live_camera(video):
|
||||||
# File playback: wall-clock cadence is not signal time, so pin the
|
# File playback: wall-clock cadence is not signal time, so pin the
|
||||||
# force-filter design rate to the configured camera fps.
|
# force-filter design rate to the configured camera fps.
|
||||||
@@ -361,6 +388,27 @@ class FingerRuntime:
|
|||||||
self.tactile.warmup()
|
self.tactile.warmup()
|
||||||
return ok, unrolled
|
return ok, unrolled
|
||||||
|
|
||||||
|
def pipeline(self, **kwargs):
|
||||||
|
"""Return a :class:`~orisys.pipeline.StreamPipeline` for this runtime.
|
||||||
|
|
||||||
|
The recommended way to run a finger sensor. Each finished frame arrives as a single
|
||||||
|
:class:`~orisys.pipeline.FrameSnapshot`, so there are no stage calls to sequence and no
|
||||||
|
tuple to unpack::
|
||||||
|
|
||||||
|
with runtime.pipeline(contact=True, include=("image", "depth")) as pipe:
|
||||||
|
while True:
|
||||||
|
snap = pipe.wait()
|
||||||
|
if snap is None:
|
||||||
|
break
|
||||||
|
print(snap.normal_force, snap.shear_x, snap.shear_y)
|
||||||
|
|
||||||
|
Any keyword argument is forwarded to ``StreamPipeline``. Use ``contact=True`` to run
|
||||||
|
contact analysis, and ``include=(...)`` to select which arrays each snapshot carries.
|
||||||
|
"""
|
||||||
|
from orisys.pipeline import StreamPipeline
|
||||||
|
|
||||||
|
return StreamPipeline.for_finger_runtime(self, **kwargs)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
if self.capture_source is not None:
|
if self.capture_source is not None:
|
||||||
self.capture_source.close()
|
self.capture_source.close()
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"""Force readout for FN/FX/FY: EMA, median, low-pass, and optional convert.
|
||||||
|
|
||||||
|
Any sensor that produces a raw (fn, fx, fy) triple can use this. Calibration
|
||||||
|
coefficients stay outside: pass ``convert`` into :meth:`ForceReadout.apply`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .signal_processing import (
|
||||||
|
DEFAULT_SAMPLE_RATE_HZ,
|
||||||
|
CausalMedianFilter,
|
||||||
|
LowpassFilter,
|
||||||
|
SampleRateEstimator,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default signal-conditioning settings (config: top-level "filters" block).
|
||||||
|
#
|
||||||
|
# A key a config does not state is **off**, not "whatever we thought was
|
||||||
|
# sensible": the file has to name what runs, or a device can be filtering a
|
||||||
|
# measurement nobody asked it to filter and no reader of the file could tell.
|
||||||
|
# The shipped bundles state all three values explicitly, so they are unaffected
|
||||||
|
# by these defaults; a config with no "filters" block at all filters nothing.
|
||||||
|
#
|
||||||
|
# Which settings are worth asking for is measured, not guessed. On dd02-0703 at
|
||||||
|
# 280x300 (see the noise analysis in the perf notes) flow EMA 0.5 + median 3
|
||||||
|
# costs ~2 frames at 30 fps and cuts steady-press force jitter by roughly half
|
||||||
|
# while also stabilising vnormal/contact (which a force-level filter cannot do);
|
||||||
|
# that combination is the "balanced" preset. The force low-pass stays off there
|
||||||
|
# too: on top of the EMA it added no measurable noise benefit at the same
|
||||||
|
# latency, so it is an opt-in stage for sites with different noise
|
||||||
|
# characteristics (the "low_noise" preset).
|
||||||
|
DEFAULT_FORCE_LOWPASS_HZ = 0.0
|
||||||
|
DEFAULT_FORCE_MEDIAN_WINDOW = 1
|
||||||
|
DEFAULT_FLOW_EMA_ALPHA = 1.0
|
||||||
|
|
||||||
|
#: Named filter presets, so a recommendation is *executable* instead of a
|
||||||
|
#: comment in a JSON file (``json.load`` cannot carry comments). A config may
|
||||||
|
#: write ``"filters": {"preset": "balanced"}`` and override any single key on
|
||||||
|
#: top of it; the resolved values are what ``Sensor.filters_state()`` reports.
|
||||||
|
#: The latency/noise trade-offs behind these numbers are measured in
|
||||||
|
#: ``docs/performance/perf-measurements/`` (moved out of the once-gitignored
|
||||||
|
#: ``orisys-sdk/_perf/`` on 2026-09-27) and tabulated in ``docs/filters.md``.
|
||||||
|
FILTER_PRESETS = {
|
||||||
|
# Everything off: the file says what runs, and the raw reading is the record.
|
||||||
|
"off": {
|
||||||
|
"flow_ema_alpha": 1.0,
|
||||||
|
"force_median_window": 1,
|
||||||
|
"force_lowpass_cutoff_hz": 0.0,
|
||||||
|
},
|
||||||
|
# One frame of latency @30 fps: median only. The EMA changes the
|
||||||
|
# measurement, so it stays off when latency matters more than jitter.
|
||||||
|
"low_latency": {
|
||||||
|
"flow_ema_alpha": 1.0,
|
||||||
|
"force_median_window": 3,
|
||||||
|
"force_lowpass_cutoff_hz": 0.0,
|
||||||
|
},
|
||||||
|
# Two frames @30 fps: the EMA removes the spatially-coherent flow jitter
|
||||||
|
# that no force-level filter can reach, the median removes its spikes.
|
||||||
|
"balanced": {
|
||||||
|
"flow_ema_alpha": 0.5,
|
||||||
|
"force_median_window": 3,
|
||||||
|
"force_lowpass_cutoff_hz": 0.0,
|
||||||
|
},
|
||||||
|
# Three frames @30 fps and the quietest readout: as ``balanced`` plus the
|
||||||
|
# force low-pass on top of the median.
|
||||||
|
"low_noise": {
|
||||||
|
"flow_ema_alpha": 0.3,
|
||||||
|
"force_median_window": 3,
|
||||||
|
"force_lowpass_cutoff_hz": 10.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def available_filter_presets() -> dict:
|
||||||
|
"""The presets, for UIs that want to offer them (the studio's gear dialog)."""
|
||||||
|
return {name: dict(values) for name, values in FILTER_PRESETS.items()}
|
||||||
|
|
||||||
|
|
||||||
|
ConvertFn = Callable[[float, float, float], tuple[float, float, float]]
|
||||||
|
|
||||||
|
|
||||||
|
class ForceReadout:
|
||||||
|
"""Median → low-pass → convert for a three-channel force triple."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
filters_cfg: dict | None = None,
|
||||||
|
*,
|
||||||
|
config_fps: float = DEFAULT_SAMPLE_RATE_HZ,
|
||||||
|
verbose: bool = False,
|
||||||
|
) -> None:
|
||||||
|
# These settings change the reported force values, so they are part of
|
||||||
|
# the measurement-pipeline identity and are recorded in the calibration
|
||||||
|
# metadata / mismatch check alongside dis_mode and hhd_downsample.
|
||||||
|
#
|
||||||
|
# Every stage is switched by its **own value**, so the config states what
|
||||||
|
# runs: ``flow_ema_alpha >= 1``, ``force_median_window <= 1`` and
|
||||||
|
# ``force_lowpass_cutoff_hz <= 0`` each mean "stage off". Values are never
|
||||||
|
# overridden: there is no master switch that could make the file say 0.5
|
||||||
|
# while the device runs 1.0. An invalid value falls back to **off**, never
|
||||||
|
# to a default that filters. ``enabled`` is kept only as a deprecated
|
||||||
|
# alias for "turn every stage off" and is honoured when it is explicitly
|
||||||
|
# false. ``state()`` reports what actually took effect.
|
||||||
|
if not isinstance(filters_cfg, dict):
|
||||||
|
filters_cfg = {}
|
||||||
|
legacy_off = filters_cfg.get("enabled", None) is False
|
||||||
|
preset_name = filters_cfg.get("preset", None)
|
||||||
|
preset_values = FILTER_PRESETS.get(str(preset_name), {}) if preset_name else {}
|
||||||
|
self.preset = str(preset_name) if preset_name in FILTER_PRESETS else None
|
||||||
|
|
||||||
|
def _cfg(key, default):
|
||||||
|
if legacy_off:
|
||||||
|
return default
|
||||||
|
value = filters_cfg.get(key, None)
|
||||||
|
if value is None:
|
||||||
|
value = preset_values.get(key, None)
|
||||||
|
return default if value is None else value
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.config_fps = float(config_fps or 0.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.config_fps = 0.0
|
||||||
|
if self.config_fps <= 0:
|
||||||
|
self.config_fps = float(DEFAULT_SAMPLE_RATE_HZ)
|
||||||
|
|
||||||
|
self.lowpass_order = 2
|
||||||
|
self.lowpass_sample_rate_hz = self.config_fps
|
||||||
|
self.lowpass_cutoff_hz = 0.0
|
||||||
|
self.lowpass_rate_auto = True
|
||||||
|
try:
|
||||||
|
self.lowpass_order = max(1, int(_cfg("force_lowpass_order", 2)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.lowpass_order = 2
|
||||||
|
rate_cfg = _cfg("force_lowpass_sample_rate_hz", "auto")
|
||||||
|
if isinstance(rate_cfg, str):
|
||||||
|
self.lowpass_rate_auto = rate_cfg.strip().lower() in ("auto", "", "measured")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
rate = float(rate_cfg)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rate = 0.0
|
||||||
|
self.lowpass_rate_auto = rate <= 0.0
|
||||||
|
if not self.lowpass_rate_auto:
|
||||||
|
self.lowpass_sample_rate_hz = rate
|
||||||
|
try:
|
||||||
|
self.lowpass_cutoff_hz = float(
|
||||||
|
_cfg("force_lowpass_cutoff_hz", DEFAULT_FORCE_LOWPASS_HZ)
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.lowpass_cutoff_hz = 0.0
|
||||||
|
if self.lowpass_sample_rate_hz <= 0:
|
||||||
|
self.lowpass_sample_rate_hz = self.config_fps
|
||||||
|
# An unusable cutoff is "off", never a silent jump to the default.
|
||||||
|
if not (0.0 <= self.lowpass_cutoff_hz < 0.5 * self.lowpass_sample_rate_hz):
|
||||||
|
self.lowpass_cutoff_hz = 0.0
|
||||||
|
self.lowpass_enabled = self.lowpass_cutoff_hz > 0
|
||||||
|
self._force_filter = LowpassFilter(
|
||||||
|
cutoff_hz=self.lowpass_cutoff_hz,
|
||||||
|
sample_rate_hz=self.lowpass_sample_rate_hz,
|
||||||
|
order=self.lowpass_order,
|
||||||
|
n_channels=3,
|
||||||
|
)
|
||||||
|
self._force_rate = SampleRateEstimator(initial_hz=self.config_fps)
|
||||||
|
self.sample_rate_source = "config"
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.force_median_window = max(
|
||||||
|
1, int(_cfg("force_median_window", DEFAULT_FORCE_MEDIAN_WINDOW))
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.force_median_window = 1
|
||||||
|
self._force_median = CausalMedianFilter(
|
||||||
|
window=self.force_median_window, n_channels=3
|
||||||
|
)
|
||||||
|
|
||||||
|
self.flow_ema_alpha = 1.0
|
||||||
|
try:
|
||||||
|
alpha = float(_cfg("flow_ema_alpha", DEFAULT_FLOW_EMA_ALPHA))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
alpha = 1.0
|
||||||
|
if 0.0 < alpha < 1.0:
|
||||||
|
self.flow_ema_alpha = alpha
|
||||||
|
self._ema_flow = None
|
||||||
|
if legacy_off and verbose:
|
||||||
|
print(
|
||||||
|
"Note: filters.enabled is deprecated and means 'all stages off'; "
|
||||||
|
"remove it and switch each stage by its own value "
|
||||||
|
"(flow_ema_alpha=1, force_median_window=1, force_lowpass_cutoff_hz=0)."
|
||||||
|
)
|
||||||
|
self.any_stage_applied = bool(
|
||||||
|
self.flow_ema_alpha < 1.0
|
||||||
|
or self.force_median_window > 1
|
||||||
|
or self.lowpass_enabled
|
||||||
|
)
|
||||||
|
self.filtered_raw: tuple[float, float, float] | None = None
|
||||||
|
self.raw_unfiltered: tuple[float, float, float] | None = None
|
||||||
|
self.unfiltered_newton: tuple[float, float, float] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sample_rate_hz(self) -> float:
|
||||||
|
return float(self.lowpass_sample_rate_hz)
|
||||||
|
|
||||||
|
def pin_file_rate(self) -> None:
|
||||||
|
"""Wall-clock cadence is not signal time (video file / offline replay)."""
|
||||||
|
self.lowpass_rate_auto = False
|
||||||
|
self.sample_rate_source = "config (file)"
|
||||||
|
self.lowpass_sample_rate_hz = self.config_fps
|
||||||
|
self._force_rate.reset(self.config_fps)
|
||||||
|
|
||||||
|
def apply_flow_ema(self, flow):
|
||||||
|
"""Temporally smooth the optical-flow field before HHD.
|
||||||
|
|
||||||
|
The frame-to-frame jitter of the estimated flow is spatially coherent
|
||||||
|
(a near-global shift: it survives ~40x less averaging than per-pixel
|
||||||
|
noise would, see the noise analysis), so it cannot be removed by
|
||||||
|
spatial smoothing, a coarser HHD grid or a different DIS setting - only
|
||||||
|
by averaging along time. Applying it here also stabilises
|
||||||
|
``vnormal``/contact, which a force-level filter cannot reach.
|
||||||
|
|
||||||
|
``flow_ema_alpha >= 1`` disables the stage.
|
||||||
|
"""
|
||||||
|
alpha = self.flow_ema_alpha
|
||||||
|
if flow is None or alpha >= 1.0:
|
||||||
|
return flow
|
||||||
|
prev = self._ema_flow
|
||||||
|
if prev is None or prev.shape != flow.shape or prev.dtype != flow.dtype:
|
||||||
|
self._ema_flow = np.array(flow, copy=True)
|
||||||
|
return flow
|
||||||
|
prev += alpha * (flow - prev)
|
||||||
|
return prev
|
||||||
|
|
||||||
|
def update_sample_rate(self) -> None:
|
||||||
|
"""Track the achieved force update rate for the low-pass design."""
|
||||||
|
if not self.lowpass_rate_auto:
|
||||||
|
return
|
||||||
|
hz = self._force_rate.tick()
|
||||||
|
if not (1.0 <= hz <= 240.0):
|
||||||
|
return
|
||||||
|
self.sample_rate_source = "measured"
|
||||||
|
used = self.lowpass_sample_rate_hz
|
||||||
|
if used > 0 and abs(hz - used) <= 0.1 * used:
|
||||||
|
return
|
||||||
|
self.lowpass_sample_rate_hz = hz
|
||||||
|
if self.lowpass_enabled:
|
||||||
|
self._force_filter.configure(
|
||||||
|
cutoff_hz=self.lowpass_cutoff_hz,
|
||||||
|
sample_rate_hz=hz,
|
||||||
|
order=self.lowpass_order,
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
raw,
|
||||||
|
convert: ConvertFn | None = None,
|
||||||
|
) -> tuple[float, float, float]:
|
||||||
|
"""Median → low-pass → optional convert. Returns filtered newton triple."""
|
||||||
|
pre_filter = np.asarray(raw, dtype=np.float64)
|
||||||
|
values = pre_filter
|
||||||
|
# Median first: it deletes the heavy-tailed spikes that the flow chain
|
||||||
|
# produces, so the low-pass does not have to spread them over time.
|
||||||
|
if self._force_median.enabled:
|
||||||
|
values = self._force_median.process(values)
|
||||||
|
if self.lowpass_enabled and self.lowpass_cutoff_hz > 0:
|
||||||
|
values = self._force_filter.process(values)
|
||||||
|
self.filtered_raw = (float(values[0]), float(values[1]), float(values[2]))
|
||||||
|
fn, fx, fy = self.filtered_raw
|
||||||
|
if convert is not None:
|
||||||
|
fn, fx, fy = convert(fn, fx, fy)
|
||||||
|
fn, fx, fy = float(fn), float(fx), float(fy)
|
||||||
|
|
||||||
|
ufn, ufx, ufy = (
|
||||||
|
float(pre_filter[0]),
|
||||||
|
float(pre_filter[1]),
|
||||||
|
float(pre_filter[2]),
|
||||||
|
)
|
||||||
|
self.raw_unfiltered = (ufn, ufx, ufy)
|
||||||
|
if convert is not None:
|
||||||
|
ufn, ufx, ufy = convert(ufn, ufx, ufy)
|
||||||
|
ufn, ufx, ufy = float(ufn), float(ufx), float(ufy)
|
||||||
|
self.unfiltered_newton = (ufn, ufx, ufy)
|
||||||
|
return fn, fx, fy
|
||||||
|
|
||||||
|
def configure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
lowpass_cutoff_hz=None,
|
||||||
|
lowpass_sample_rate_hz=None,
|
||||||
|
lowpass_order=None,
|
||||||
|
lowpass_enabled=None,
|
||||||
|
flow_ema_alpha=None,
|
||||||
|
force_median_window=None,
|
||||||
|
) -> bool:
|
||||||
|
"""Update filter settings. Returns True when the force filter changed."""
|
||||||
|
filter_changed = False
|
||||||
|
if lowpass_cutoff_hz is not None:
|
||||||
|
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
|
||||||
|
filter_changed = True
|
||||||
|
if lowpass_enabled is None:
|
||||||
|
self.lowpass_enabled = self.lowpass_cutoff_hz > 0
|
||||||
|
if lowpass_sample_rate_hz is not None:
|
||||||
|
# Accept a positive number to pin the rate, or "auto"/<=0 to track
|
||||||
|
# the measured output rate (see update_sample_rate).
|
||||||
|
auto = False
|
||||||
|
rate = 0.0
|
||||||
|
if isinstance(lowpass_sample_rate_hz, str):
|
||||||
|
auto = lowpass_sample_rate_hz.strip().lower() in ("auto", "", "measured")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
rate = float(lowpass_sample_rate_hz)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
auto = True
|
||||||
|
else:
|
||||||
|
auto = rate <= 0.0
|
||||||
|
self.lowpass_rate_auto = auto
|
||||||
|
if not auto:
|
||||||
|
self.lowpass_sample_rate_hz = rate
|
||||||
|
elif self.lowpass_sample_rate_hz <= 0:
|
||||||
|
self.lowpass_sample_rate_hz = self.config_fps
|
||||||
|
self._force_rate.reset(self.config_fps)
|
||||||
|
filter_changed = True
|
||||||
|
if lowpass_order is not None:
|
||||||
|
order = int(lowpass_order)
|
||||||
|
if order < 1:
|
||||||
|
raise ValueError("lowpass_order must be >= 1")
|
||||||
|
self.lowpass_order = order
|
||||||
|
filter_changed = True
|
||||||
|
if lowpass_enabled is not None:
|
||||||
|
self.lowpass_enabled = bool(lowpass_enabled)
|
||||||
|
filter_changed = True
|
||||||
|
|
||||||
|
if flow_ema_alpha is not None:
|
||||||
|
alpha = float(flow_ema_alpha)
|
||||||
|
if not (0.0 < alpha <= 1.0):
|
||||||
|
raise ValueError("flow_ema_alpha must be in (0, 1]")
|
||||||
|
self.flow_ema_alpha = alpha
|
||||||
|
self._ema_flow = None
|
||||||
|
|
||||||
|
if force_median_window is not None:
|
||||||
|
try:
|
||||||
|
window = max(1, int(force_median_window))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("force_median_window must be an integer >= 1") from exc
|
||||||
|
self.force_median_window = window
|
||||||
|
self._force_median.configure(window)
|
||||||
|
filter_changed = True
|
||||||
|
|
||||||
|
if filter_changed:
|
||||||
|
cutoff = self.lowpass_cutoff_hz if self.lowpass_enabled else 0.0
|
||||||
|
self._force_filter.configure(
|
||||||
|
cutoff_hz=cutoff,
|
||||||
|
sample_rate_hz=self.lowpass_sample_rate_hz,
|
||||||
|
order=self.lowpass_order,
|
||||||
|
)
|
||||||
|
self.any_stage_applied = bool(
|
||||||
|
self.flow_ema_alpha < 1.0
|
||||||
|
or self.force_median_window > 1
|
||||||
|
or self.lowpass_enabled
|
||||||
|
)
|
||||||
|
return filter_changed
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._force_filter.reset()
|
||||||
|
self._force_median.reset()
|
||||||
|
self._ema_flow = None
|
||||||
|
self.filtered_raw = None
|
||||||
|
self.raw_unfiltered = None
|
||||||
|
self.unfiltered_newton = None
|
||||||
|
|
||||||
|
def state(self, *, dis_mode=None) -> dict:
|
||||||
|
"""Effective filter chain, for records and audits."""
|
||||||
|
return {
|
||||||
|
"preset": self.preset,
|
||||||
|
"measurement": {
|
||||||
|
"flow_ema_alpha": float(self.flow_ema_alpha),
|
||||||
|
"flow_ema_applied": bool(self.flow_ema_alpha < 1.0),
|
||||||
|
"dis_mode": dis_mode,
|
||||||
|
},
|
||||||
|
"readout": {
|
||||||
|
"force_median_window": int(self.force_median_window),
|
||||||
|
"force_median_applied": bool(self._force_median.enabled),
|
||||||
|
"force_lowpass_cutoff_hz": float(self.lowpass_cutoff_hz),
|
||||||
|
"force_lowpass_order": int(self.lowpass_order),
|
||||||
|
"force_lowpass_sample_rate_hz": float(self.lowpass_sample_rate_hz),
|
||||||
|
"force_lowpass_rate_auto": bool(self.lowpass_rate_auto),
|
||||||
|
"force_lowpass_applied": bool(self.lowpass_enabled),
|
||||||
|
"sample_rate_source": str(self.sample_rate_source),
|
||||||
|
},
|
||||||
|
"any_stage_applied": bool(self.any_stage_applied),
|
||||||
|
"unfiltered_available": True,
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .unroll import UVRemap
|
from .unroll import UVRemap
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import numpy as np
|
|
||||||
import cv2
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from .backend_availability import CUPY_AVAILABLE, get_cupy
|
from .backend_availability import CUPY_AVAILABLE, get_cupy
|
||||||
|
|
||||||
cp = get_cupy()
|
cp = get_cupy()
|
||||||
# 缓存形态学操作的核,避免重复创建
|
# 缓存形态学操作的核,避免重复创建
|
||||||
_morphology_kernels = {}
|
_morphology_kernels = {}
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ from .grid import StructuredGrid
|
|||||||
from .poisson import PoissonSolver
|
from .poisson import PoissonSolver
|
||||||
from .timing import Timer
|
from .timing import Timer
|
||||||
|
|
||||||
__all__ = ["nHHD", "StructuredGrid", "PoissonSolver", "Timer"]
|
__all__ = ["PoissonSolver", "StructuredGrid", "Timer", "nHHD"]
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import numpy
|
|||||||
|
|
||||||
from ..backend_availability import (
|
from ..backend_availability import (
|
||||||
CUPY_AVAILABLE,
|
CUPY_AVAILABLE,
|
||||||
OPENCL_AVAILABLE,
|
|
||||||
KERNEL_AVAILABLE,
|
KERNEL_AVAILABLE,
|
||||||
cp,
|
OPENCL_AVAILABLE,
|
||||||
_torque_kernel,
|
_torque_kernel,
|
||||||
|
cp,
|
||||||
)
|
)
|
||||||
from .grid import StructuredGrid
|
from .grid import StructuredGrid
|
||||||
from .poisson import PoissonSolver
|
from .poisson import PoissonSolver
|
||||||
|
|
||||||
|
|
||||||
class nHHD(object):
|
class nHHD:
|
||||||
|
|
||||||
# constructor
|
# constructor
|
||||||
def __init__(self, cuda=True, **kwargs):
|
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')
|
backend = 'cupy' if CUPY_AVAILABLE else ('opencl' if OPENCL_AVAILABLE else 'cpu')
|
||||||
|
|
||||||
# Validate backend availability and fallback to 'cpu' if needed
|
# Validate backend availability and fallback to 'cpu' if needed
|
||||||
if backend == 'cupy' and not CUPY_AVAILABLE:
|
if backend == 'cupy' and not CUPY_AVAILABLE or backend == 'opencl' and not OPENCL_AVAILABLE:
|
||||||
backend = 'cpu'
|
|
||||||
elif backend == 'opencl' and not OPENCL_AVAILABLE:
|
|
||||||
backend = 'cpu'
|
backend = 'cpu'
|
||||||
|
|
||||||
self.debug = kwargs.get('debug', False)
|
self.debug = kwargs.get('debug', False)
|
||||||
|
|||||||
+13
-11
@@ -56,7 +56,9 @@ def build_masked_factor(lap):
|
|||||||
options=dict(SymmetricMode=True),
|
options=dict(SymmetricMode=True),
|
||||||
)
|
)
|
||||||
return lu.solve, numpy.float32
|
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
|
continue
|
||||||
# Last resort: the original pivoting LU on the float64 matrix.
|
# Last resort: the original pivoting LU on the float64 matrix.
|
||||||
return sparse_linalg.factorized(lap_csc), numpy.float64
|
return sparse_linalg.factorized(lap_csc), numpy.float64
|
||||||
@@ -82,20 +84,20 @@ class MaskedDomain:
|
|||||||
"""Reduced Laplacian system on active mask pixels (2D only)."""
|
"""Reduced Laplacian system on active mask pixels (2D only)."""
|
||||||
|
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
"shape",
|
|
||||||
"mask",
|
|
||||||
"index_map",
|
|
||||||
"factor",
|
|
||||||
"factor_dtype",
|
|
||||||
"lap",
|
|
||||||
"num_active",
|
|
||||||
"pinned",
|
|
||||||
"active_coords",
|
|
||||||
"active_linear_indices",
|
|
||||||
"_rhs_buffer",
|
"_rhs_buffer",
|
||||||
"_rhs_pair_buffer",
|
"_rhs_pair_buffer",
|
||||||
"_scatter_buffer",
|
"_scatter_buffer",
|
||||||
"_scatter_pair_buffer",
|
"_scatter_pair_buffer",
|
||||||
|
"active_coords",
|
||||||
|
"active_linear_indices",
|
||||||
|
"factor",
|
||||||
|
"factor_dtype",
|
||||||
|
"index_map",
|
||||||
|
"lap",
|
||||||
|
"mask",
|
||||||
|
"num_active",
|
||||||
|
"pinned",
|
||||||
|
"shape",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import numpy
|
|
||||||
import timeit
|
|
||||||
import sys
|
import sys
|
||||||
# import cv2
|
|
||||||
from scipy import signal, spatial
|
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
|
||||||
|
# import cv2
|
||||||
from ..backend_availability import (
|
from ..backend_availability import (
|
||||||
CUPY_AVAILABLE, OPENCL_AVAILABLE, KERNEL_AVAILABLE,
|
CUPY_AVAILABLE,
|
||||||
cp, cp_signal, _torque_kernel,
|
cp,
|
||||||
cl, cla, cl_rfftn, cl_irfftn
|
|
||||||
)
|
)
|
||||||
from .timing import Timer
|
from .timing import Timer
|
||||||
|
|
||||||
class StructuredGrid(object):
|
|
||||||
|
class StructuredGrid:
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ from .timing import Timer
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
class PoissonSolver(object):
|
class PoissonSolver:
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
args = kwargs.keys()
|
args = kwargs.keys()
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ from .capacitance import CapacitanceFFTSolver
|
|||||||
from .masked_sparse import MaskedSparseSolver
|
from .masked_sparse import MaskedSparseSolver
|
||||||
from .rectangular import RectangularFFTSolver
|
from .rectangular import RectangularFFTSolver
|
||||||
|
|
||||||
__all__ = ["RectangularFFTSolver", "MaskedSparseSolver", "CapacitanceFFTSolver"]
|
__all__ = ["CapacitanceFFTSolver", "MaskedSparseSolver", "RectangularFFTSolver"]
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import numpy
|
|
||||||
import timeit
|
import timeit
|
||||||
|
|
||||||
|
import numpy
|
||||||
from scipy import signal
|
from scipy import signal
|
||||||
|
|
||||||
from ...backend_availability import (
|
from ...backend_availability import (
|
||||||
CUPY_AVAILABLE,
|
CUPY_AVAILABLE,
|
||||||
OPENCL_AVAILABLE,
|
OPENCL_AVAILABLE,
|
||||||
|
cl,
|
||||||
|
cl_irfftn,
|
||||||
|
cl_rfftn,
|
||||||
|
cla,
|
||||||
cp,
|
cp,
|
||||||
cp_signal,
|
cp_signal,
|
||||||
cl,
|
|
||||||
cla,
|
|
||||||
cl_rfftn,
|
|
||||||
cl_irfftn,
|
|
||||||
)
|
)
|
||||||
from ..timing import Timer
|
from ..timing import Timer
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import numpy
|
|
||||||
import timeit
|
import timeit
|
||||||
|
|
||||||
class Timer(object):
|
import numpy
|
||||||
|
|
||||||
|
|
||||||
|
class Timer:
|
||||||
|
|
||||||
def display(self):
|
def display(self):
|
||||||
|
|
||||||
|
|||||||
+221
-10
@@ -66,18 +66,44 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
from queue import Empty, Full, Queue
|
from queue import Empty, Full, Queue
|
||||||
from typing import Any, Callable, Iterable, Sequence
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
__all__ = ["FrameSnapshot", "PipelineStats", "StreamPipeline"]
|
__all__ = ["FrameSnapshot", "PipelineStats", "PauseLevel", "StreamPipeline"]
|
||||||
|
|
||||||
#: Array fields a snapshot can carry. ``image`` is the working grey frame; the
|
#: Array fields a snapshot can carry. ``image`` is the working grey frame; the
|
||||||
#: rest come from ``Sensor.read_info`` (already copied by the SDK).
|
#: rest come from ``Sensor.read_info`` (already copied by the SDK).
|
||||||
_ARRAY_FIELDS = ("image", "flow", "vnormal", "vshear", "depth", "centroid", "contour")
|
_ARRAY_FIELDS = ("image", "flow", "vnormal", "vshear", "depth", "centroid", "contour")
|
||||||
|
|
||||||
|
#: How many periods the stages wait on the pause event before re-checking for a
|
||||||
|
#: stop. Small enough to resume promptly, large enough not to spin a core.
|
||||||
|
_PAUSE_POLL_S = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
class PauseLevel(str, Enum):
|
||||||
|
"""What a pause holds still.
|
||||||
|
|
||||||
|
``BOTH`` stops the pipeline read and the compute stages: nothing is captured
|
||||||
|
and nothing is computed, so the sensor reports nothing until it resumes.
|
||||||
|
|
||||||
|
``CAPTURE_ONLY`` keeps reading frames and skips the flow and HHD stages. The
|
||||||
|
read is still issued, so a live camera keeps streaming: its exposure,
|
||||||
|
white balance and frame rate stay where the operator set them, and resuming
|
||||||
|
does not have to re-converge. What stops is the work - the three-stage
|
||||||
|
pipeline drops to one stage of cost, which is what makes a long hold cheap.
|
||||||
|
|
||||||
|
Both levels rebase the tracker on resume, so the hold does not turn into a
|
||||||
|
phantom deformation reading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BOTH = "both"
|
||||||
|
CAPTURE_ONLY = "capture_only"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FrameSnapshot:
|
class FrameSnapshot:
|
||||||
@@ -109,6 +135,10 @@ class FrameSnapshot:
|
|||||||
produce_ms: float = 0.0
|
produce_ms: float = 0.0
|
||||||
flow_ms: float = 0.0
|
flow_ms: float = 0.0
|
||||||
hhd_ms: float = 0.0
|
hhd_ms: float = 0.0
|
||||||
|
#: Capture resolution before any stitch/unroll/resize, as ``(width, height)``, or ``None``
|
||||||
|
#: when the producer did not report one. Read from ``Sensor.img_size_raw`` because callers
|
||||||
|
#: compare it with ``image.shape`` to see how much the frame was reduced.
|
||||||
|
image_size_raw: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -155,7 +185,7 @@ class StreamPipeline:
|
|||||||
contact: bool = False,
|
contact: bool = False,
|
||||||
depth: int = 2,
|
depth: int = 2,
|
||||||
include: Sequence[str] | Iterable[str] = ("image",),
|
include: Sequence[str] | Iterable[str] = ("image",),
|
||||||
on_frame: Callable[["FrameSnapshot"], None] | None = None,
|
on_frame: Callable[[FrameSnapshot], None] | None = None,
|
||||||
verbose: bool = False,
|
verbose: bool = False,
|
||||||
fps_window: int = 30,
|
fps_window: int = 30,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -179,6 +209,16 @@ class StreamPipeline:
|
|||||||
self._threads: list[threading.Thread] = []
|
self._threads: list[threading.Thread] = []
|
||||||
self._error: BaseException | None = None
|
self._error: BaseException | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
#: Set by :meth:`reset` and consumed by the flow stage, so that resetting sensor state
|
||||||
|
#: never races the stage that owns that state.
|
||||||
|
self._reset_pending = threading.Event()
|
||||||
|
#: Pause state. ``_pause_level`` is what a pause would hold when armed;
|
||||||
|
#: ``_currently_capturing`` records whether the producer is still
|
||||||
|
#: reading, which decides whether a resume has to rebase the tracker.
|
||||||
|
self._pause = threading.Event()
|
||||||
|
self._pause_level: PauseLevel = PauseLevel.BOTH
|
||||||
|
self._currently_capturing = True
|
||||||
|
self._sensor_source: Any = None
|
||||||
|
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._latest: FrameSnapshot | None = None
|
self._latest: FrameSnapshot | None = None
|
||||||
@@ -189,7 +229,7 @@ class StreamPipeline:
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ ctors
|
# ------------------------------------------------------------------ ctors
|
||||||
@classmethod
|
@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)."""
|
"""Build a pipeline for a ``FingerRuntime`` (capture + UV unroll stage)."""
|
||||||
|
|
||||||
def produce() -> np.ndarray | None:
|
def produce() -> np.ndarray | None:
|
||||||
@@ -199,7 +239,7 @@ class StreamPipeline:
|
|||||||
return cls(runtime.tactile.raw_sensor, produce, **kwargs)
|
return cls(runtime.tactile.raw_sensor, produce, **kwargs)
|
||||||
|
|
||||||
# --------------------------------------------------------------- lifecycle
|
# --------------------------------------------------------------- lifecycle
|
||||||
def start(self) -> "StreamPipeline":
|
def start(self) -> StreamPipeline:
|
||||||
if self._running:
|
if self._running:
|
||||||
return self
|
return self
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -232,7 +272,7 @@ class StreamPipeline:
|
|||||||
err, self._error = self._error, None
|
err, self._error = self._error, None
|
||||||
raise err
|
raise err
|
||||||
|
|
||||||
def __enter__(self) -> "StreamPipeline":
|
def __enter__(self) -> StreamPipeline:
|
||||||
return self.start()
|
return self.start()
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||||
@@ -257,6 +297,120 @@ class StreamPipeline:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._latest
|
return self._latest
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset the sensor's tracker and force-filter state, safely.
|
||||||
|
|
||||||
|
Call this from the consumer thread (after a key press, on a drift alarm). The reset is
|
||||||
|
queued and applied by the flow stage before its next frame, never inline: ``Sensor.reset``
|
||||||
|
rebases ``initial_img`` on the current ``Sensor.img``, and the flow stage is reading that
|
||||||
|
buffer concurrently, so resetting from the caller's thread would capture a half-written
|
||||||
|
reference frame.
|
||||||
|
|
||||||
|
Idempotent: a second call before the next frame does not queue twice.
|
||||||
|
"""
|
||||||
|
self._reset_pending.set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paused(self) -> bool:
|
||||||
|
return self._pause.is_set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pause_level(self) -> PauseLevel:
|
||||||
|
"""The level in effect while paused (the last level requested)."""
|
||||||
|
return self._pause_level
|
||||||
|
|
||||||
|
def pause(self, level: PauseLevel | str = PauseLevel.CAPTURE_ONLY) -> None:
|
||||||
|
"""Hold the pipeline still until :meth:`resume`.
|
||||||
|
|
||||||
|
``level`` selects what stays running:
|
||||||
|
|
||||||
|
* :attr:`PauseLevel.BOTH` - stop reading *and* computing. The sensor
|
||||||
|
reports nothing at all while held.
|
||||||
|
* :attr:`PauseLevel.CAPTURE_ONLY` (default) - keep reading so a live
|
||||||
|
camera stays in its converged state (exposure, white balance, frame
|
||||||
|
rate), but skip the flow and HHD stages. Cost drops to the read alone.
|
||||||
|
|
||||||
|
No snapshots are published while paused, so :meth:`wait` returns
|
||||||
|
``None``. The tracker is rebased on resume, because a held reference
|
||||||
|
frame would otherwise report the hold itself as a large deformation -
|
||||||
|
use ``reset=False`` to resume without that rebase.
|
||||||
|
|
||||||
|
Safe to call from the consumer thread, and idempotent; re-pausing with a
|
||||||
|
different level takes effect immediately.
|
||||||
|
"""
|
||||||
|
if not isinstance(level, PauseLevel):
|
||||||
|
level = PauseLevel(level)
|
||||||
|
if self._done.is_set():
|
||||||
|
return
|
||||||
|
self._set_reading(level is PauseLevel.CAPTURE_ONLY)
|
||||||
|
self._pause_level = level
|
||||||
|
self._pause.set()
|
||||||
|
|
||||||
|
def resume(self, *, reset: bool = True) -> None:
|
||||||
|
"""Release a pause and continue processing.
|
||||||
|
|
||||||
|
With ``reset`` (the default) the tracker is rebased on the next frame,
|
||||||
|
which is what keeps the hold out of the force reading. Pass
|
||||||
|
``reset=False`` when the caller wants the pre-pause reference frame kept
|
||||||
|
- for example when the held period is known to be motion-free.
|
||||||
|
"""
|
||||||
|
if not self._pause.is_set():
|
||||||
|
return
|
||||||
|
if reset and self._currently_capturing:
|
||||||
|
# Frames read during the pause were never computed, so the sensor's
|
||||||
|
# reference frame is stale by however long the hold lasted. Ask for a
|
||||||
|
# rebase; the flow stage applies it before its next frame.
|
||||||
|
self._reset_pending.set()
|
||||||
|
# Arm capture *before* lifting the hold. The producer stops as soon as
|
||||||
|
# the pause flag is set and only returns once it is clear, so a level-1
|
||||||
|
# resume must have `_currently_capturing` true by then or the producer
|
||||||
|
# settles with capture mode off.
|
||||||
|
self._currently_capturing = True
|
||||||
|
self._pause.clear()
|
||||||
|
self._set_reading(True)
|
||||||
|
|
||||||
|
def _set_reading(self, reading: bool) -> None:
|
||||||
|
"""Turn the producer's capture on or off (level 1 versus level 2)."""
|
||||||
|
self._currently_capturing = bool(reading)
|
||||||
|
source = self._resolve_sensor_source()
|
||||||
|
if source is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if reading:
|
||||||
|
source.start_background()
|
||||||
|
else:
|
||||||
|
source.stop_background()
|
||||||
|
except Exception: # noqa: BLE001 - a video file may have no such control
|
||||||
|
if self._verbose:
|
||||||
|
print("[pipeline] pause: could not toggle background capture", flush=True)
|
||||||
|
|
||||||
|
def _resolve_sensor_source(self) -> Any:
|
||||||
|
"""The sensor's capture source, or ``None`` for file/derived sources."""
|
||||||
|
if self._sensor_source is not None:
|
||||||
|
return self._sensor_source
|
||||||
|
source = getattr(self.sensor, "_capture_source", None)
|
||||||
|
if source is None:
|
||||||
|
return None
|
||||||
|
# File playback cannot be held this way, and stopping a fake source only
|
||||||
|
# breaks the read, so leave those alone.
|
||||||
|
try:
|
||||||
|
if not hasattr(source, "start_background") or not source.is_open():
|
||||||
|
return None
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
self._sensor_source = source
|
||||||
|
return source
|
||||||
|
|
||||||
|
def _skip_while_paused(self) -> bool:
|
||||||
|
"""True while a pause discards this stage's work."""
|
||||||
|
return self._pause.is_set() and not self._wait_out_pause()
|
||||||
|
|
||||||
|
def _wait_out_pause(self) -> bool:
|
||||||
|
"""Block while paused; ``False`` means the pipeline is stopping."""
|
||||||
|
while self._pause.is_set() and not self._done.is_set():
|
||||||
|
self._pause.wait(_PAUSE_POLL_S)
|
||||||
|
return not self._done.is_set()
|
||||||
|
|
||||||
def wait(self, timeout: float | None = None) -> FrameSnapshot | None:
|
def wait(self, timeout: float | None = None) -> FrameSnapshot | None:
|
||||||
"""Block until a snapshot newer than the last returned one exists.
|
"""Block until a snapshot newer than the last returned one exists.
|
||||||
|
|
||||||
@@ -264,11 +418,16 @@ class StreamPipeline:
|
|||||||
frames are gone, which is what a live GUI/control loop wants. Use
|
frames are gone, which is what a live GUI/control loop wants. Use
|
||||||
``on_frame`` when every frame must be handled exactly once.
|
``on_frame`` when every frame must be handled exactly once.
|
||||||
|
|
||||||
Returns ``None`` on timeout, or once the pipeline has ended without a
|
Returns ``None`` on timeout, while the pipeline is paused, or once the
|
||||||
newer frame.
|
pipeline has ended with nothing left to hand over. ``running``
|
||||||
|
distinguishes a hold from the end of input.
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
if self._pause.is_set():
|
||||||
seen = self._published
|
# A hold publishes nothing. Returning the pre-pause snapshot here would look like
|
||||||
|
# live data to a control loop. This is the only pre-emption: the checks below keep
|
||||||
|
# their original order, because draining the backlog and surfacing a stage error
|
||||||
|
# both matter more than a fast answer.
|
||||||
|
return None
|
||||||
deadline = None if timeout is None else time.perf_counter() + timeout
|
deadline = None if timeout is None else time.perf_counter() + timeout
|
||||||
while True:
|
while True:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -330,8 +489,32 @@ class StreamPipeline:
|
|||||||
except Empty:
|
except Empty:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _keep_camera_fed(self) -> None:
|
||||||
|
"""Read and discard one frame, without touching the shared sensor state.
|
||||||
|
|
||||||
|
``get_img`` would write ``Sensor.img`` while the flow stage may still be
|
||||||
|
draining, so the capture source is read directly. The read is what keeps
|
||||||
|
a live camera's exposure and auto-white-balance converged for however
|
||||||
|
long the pause lasts; nothing downstream sees the frame.
|
||||||
|
"""
|
||||||
|
source = self._sensor_source
|
||||||
|
if source is None:
|
||||||
|
time.sleep(_PAUSE_POLL_S)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
source.read_latest(copy=False, wait_s=0.0)
|
||||||
|
except Exception: # noqa: BLE001 - a dropped frame is not a failure
|
||||||
|
time.sleep(_PAUSE_POLL_S)
|
||||||
|
|
||||||
def _producer_loop(self) -> None:
|
def _producer_loop(self) -> None:
|
||||||
while not self._done.is_set():
|
while not self._done.is_set():
|
||||||
|
if self._pause.is_set() and not self._currently_capturing:
|
||||||
|
# Level 1 hold: capture itself is stopped, so there is nothing to
|
||||||
|
# read. Wait it out, then drop the stale backlog.
|
||||||
|
if not self._wait_out_pause():
|
||||||
|
break
|
||||||
|
self._drain(self._q1)
|
||||||
|
continue
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
frame = self._produce()
|
frame = self._produce()
|
||||||
if frame is None:
|
if frame is None:
|
||||||
@@ -350,6 +533,20 @@ class StreamPipeline:
|
|||||||
item = self._get(self._q1)
|
item = self._get(self._q1)
|
||||||
if item is None:
|
if item is None:
|
||||||
break
|
break
|
||||||
|
if self._pause.is_set():
|
||||||
|
# Level 2 holds the frame in camera only: skip the flow work and
|
||||||
|
# let the queue drop this frame. A motion-free hold adds nothing
|
||||||
|
# to the track, and the reference frame is rebased on resume.
|
||||||
|
if not self._wait_out_pause():
|
||||||
|
break
|
||||||
|
self._drain(self._q1)
|
||||||
|
self._drain(self._q2)
|
||||||
|
continue
|
||||||
|
# Applied here, on the thread that owns the sensor's flow state, before this frame is
|
||||||
|
# computed: see reset() for why this must not run on the caller's thread.
|
||||||
|
if self._reset_pending.is_set():
|
||||||
|
self._reset_pending.clear()
|
||||||
|
self.sensor.reset()
|
||||||
frame, t_cap, produce_s = item
|
frame, t_cap, produce_s = item
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
# The frame is passed explicitly: the SDK's self.img slot is shared
|
# The frame is passed explicitly: the SDK's self.img slot is shared
|
||||||
@@ -368,6 +565,13 @@ class StreamPipeline:
|
|||||||
item = self._get(self._q2)
|
item = self._get(self._q2)
|
||||||
if item is None:
|
if item is None:
|
||||||
break
|
break
|
||||||
|
if self._pause.is_set():
|
||||||
|
# Discard rather than compute: this is the stage the level-2 hold
|
||||||
|
# is meant to avoid paying for.
|
||||||
|
self._drain(self._q2)
|
||||||
|
if not self._wait_out_pause():
|
||||||
|
break
|
||||||
|
continue
|
||||||
frame, flow, t_cap, produce_s, flow_s = item
|
frame, flow, t_cap, produce_s, flow_s = item
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
# Explicit flow: compute_hhd reads self.flow, which the flow stage
|
# Explicit flow: compute_hhd reads self.flow, which the flow stage
|
||||||
@@ -405,6 +609,12 @@ class StreamPipeline:
|
|||||||
def maybe(name: str, selector: Callable[[], Any]) -> np.ndarray | None:
|
def maybe(name: str, selector: Callable[[], Any]) -> np.ndarray | None:
|
||||||
return self._copy_array(selector) if name in include else None
|
return self._copy_array(selector) if name in include else None
|
||||||
|
|
||||||
|
raw_size = getattr(sensor, "img_size_raw", None)
|
||||||
|
if isinstance(raw_size, (tuple, list)) and len(raw_size) == 2:
|
||||||
|
image_size_raw: tuple[int, int] | None = (int(raw_size[0]), int(raw_size[1]))
|
||||||
|
else:
|
||||||
|
image_size_raw = None
|
||||||
|
|
||||||
return FrameSnapshot(
|
return FrameSnapshot(
|
||||||
seq=self.stats.published,
|
seq=self.stats.published,
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
@@ -430,6 +640,7 @@ class StreamPipeline:
|
|||||||
produce_ms=produce_s * 1000.0,
|
produce_ms=produce_s * 1000.0,
|
||||||
flow_ms=flow_s * 1000.0,
|
flow_ms=flow_s * 1000.0,
|
||||||
hhd_ms=hhd_s * 1000.0,
|
hhd_ms=hhd_s * 1000.0,
|
||||||
|
image_size_raw=image_size_raw,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _fps(self) -> float:
|
def _fps(self) -> float:
|
||||||
|
|||||||
+150
-398
@@ -7,91 +7,29 @@ and Helmholtz-Hodge decomposition.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# import os # only used by commented-out stitch_cal
|
# import os # only used by commented-out stitch_cal
|
||||||
import numpy as np
|
|
||||||
import cv2
|
|
||||||
import time
|
import time
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
|
||||||
# from threading import Thread, Event
|
|
||||||
# from queue import Queue, Empty
|
|
||||||
|
|
||||||
from .Stitcher import Stitcher
|
import cv2
|
||||||
from .tracker import Tracker
|
import numpy as np
|
||||||
from .hhd import nHHD
|
|
||||||
from . import calibration as cal_mod
|
from . import calibration as cal_mod
|
||||||
from . import configs
|
from . import configs
|
||||||
from .get_depth import Contact3D
|
from .backend_availability import check_cupy_available, check_opencl_available
|
||||||
from .backend_availability import (check_cupy_available, check_opencl_available)
|
from .force_readout import (
|
||||||
from .frame_processors import create_frame_processor
|
FILTER_PRESETS,
|
||||||
from .signal_processing import (
|
ForceReadout,
|
||||||
DEFAULT_SAMPLE_RATE_HZ,
|
available_filter_presets,
|
||||||
CausalMedianFilter,
|
|
||||||
LowpassFilter,
|
|
||||||
SampleRateEstimator,
|
|
||||||
)
|
)
|
||||||
|
from .frame_processors import create_frame_processor
|
||||||
|
from .get_depth import Contact3D
|
||||||
|
from .hhd import nHHD
|
||||||
|
|
||||||
# Default signal-conditioning settings (config: top-level "filters" block).
|
# from threading import Thread, Event
|
||||||
#
|
# from queue import Queue, Empty
|
||||||
# A key a config does not state is **off**, not "whatever we thought was
|
from .Stitcher import Stitcher
|
||||||
# sensible": the file has to name what runs, or a device can be filtering a
|
from .tracker import Tracker
|
||||||
# measurement nobody asked it to filter and no reader of the file could tell.
|
|
||||||
# The shipped bundles state all three values explicitly, so they are unaffected
|
|
||||||
# by these defaults; a config with no "filters" block at all filters nothing.
|
|
||||||
#
|
|
||||||
# Which settings are worth asking for is measured, not guessed. On dd02-0703 at
|
|
||||||
# 280x300 (see the noise analysis in the perf notes) flow EMA 0.5 + median 3
|
|
||||||
# costs ~2 frames at 30 fps and cuts steady-press force jitter by roughly half
|
|
||||||
# while also stabilising vnormal/contact (which a force-level filter cannot do);
|
|
||||||
# that combination is the "balanced" preset. The force low-pass stays off there
|
|
||||||
# too: on top of the EMA it added no measurable noise benefit at the same
|
|
||||||
# latency, so it is an opt-in stage for sites with different noise
|
|
||||||
# characteristics (the "low_noise" preset).
|
|
||||||
DEFAULT_FORCE_LOWPASS_HZ = 0.0
|
|
||||||
DEFAULT_FORCE_MEDIAN_WINDOW = 1
|
|
||||||
DEFAULT_FLOW_EMA_ALPHA = 1.0
|
|
||||||
|
|
||||||
#: Named filter presets, so a recommendation is *executable* instead of a
|
|
||||||
#: comment in a JSON file (``json.load`` cannot carry comments). A config may
|
|
||||||
#: write ``"filters": {"preset": "balanced"}`` and override any single key on
|
|
||||||
#: top of it; the resolved values are what ``Sensor.filters_state()`` reports.
|
|
||||||
#: The latency/noise trade-offs behind these numbers are measured in
|
|
||||||
#: ``_perf/`` and tabulated in ``docs/filters.md``.
|
|
||||||
FILTER_PRESETS = {
|
|
||||||
# Everything off: the file says what runs, and the raw reading is the record.
|
|
||||||
"off": {
|
|
||||||
"flow_ema_alpha": 1.0,
|
|
||||||
"force_median_window": 1,
|
|
||||||
"force_lowpass_cutoff_hz": 0.0,
|
|
||||||
},
|
|
||||||
# One frame of latency @30 fps: median only. The EMA changes the
|
|
||||||
# measurement, so it stays off when latency matters more than jitter.
|
|
||||||
"low_latency": {
|
|
||||||
"flow_ema_alpha": 1.0,
|
|
||||||
"force_median_window": 3,
|
|
||||||
"force_lowpass_cutoff_hz": 0.0,
|
|
||||||
},
|
|
||||||
# Two frames @30 fps: the EMA removes the spatially-coherent flow jitter
|
|
||||||
# that no force-level filter can reach, the median removes its spikes.
|
|
||||||
"balanced": {
|
|
||||||
"flow_ema_alpha": 0.5,
|
|
||||||
"force_median_window": 3,
|
|
||||||
"force_lowpass_cutoff_hz": 0.0,
|
|
||||||
},
|
|
||||||
# Three frames @30 fps and the quietest readout: as ``balanced`` plus the
|
|
||||||
# force low-pass on top of the median.
|
|
||||||
"low_noise": {
|
|
||||||
"flow_ema_alpha": 0.3,
|
|
||||||
"force_median_window": 3,
|
|
||||||
"force_lowpass_cutoff_hz": 10.0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def available_filter_presets() -> dict:
|
|
||||||
"""The presets, for UIs that want to offer them (the studio's gear dialog)."""
|
|
||||||
return {name: dict(values) for name, values in FILTER_PRESETS.items()}
|
|
||||||
|
|
||||||
from .capture import (
|
from .capture import (
|
||||||
CaptureConfig,
|
CaptureConfig,
|
||||||
CaptureSource,
|
CaptureSource,
|
||||||
@@ -101,6 +39,7 @@ from .capture import (
|
|||||||
open_capture,
|
open_capture,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Sensor:
|
class Sensor:
|
||||||
"""
|
"""
|
||||||
Optical tactile sensor interface with deformation analysis.
|
Optical tactile sensor interface with deformation analysis.
|
||||||
@@ -137,9 +76,10 @@ class Sensor:
|
|||||||
|
|
||||||
def __init__(self, vid_src, cuda=True, isstitch=True, verbose=True, debug=False,
|
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,
|
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",
|
auto_reconnect: bool = False, capture_mode: str = "latest",
|
||||||
capture_queue_size: int = 1, capture_overflow: str = "drop_oldest",
|
capture_queue_size: int = 1, capture_overflow: str = "drop_oldest",
|
||||||
|
calibration_dir: str | Path | None = None, device_id: str | None = None,
|
||||||
**_deprecated_capture_kwargs):
|
**_deprecated_capture_kwargs):
|
||||||
"""
|
"""
|
||||||
Initialize Sensor with video source and configuration.
|
Initialize Sensor with video source and configuration.
|
||||||
@@ -151,6 +91,8 @@ class Sensor:
|
|||||||
verbose: Print initialization logs (default: True)
|
verbose: Print initialization logs (default: True)
|
||||||
config_name: Configuration name (default: "prototype")
|
config_name: Configuration name (default: "prototype")
|
||||||
cal_path: Explicit force_calibration.json path (overrides bundle default)
|
cal_path: Explicit force_calibration.json path (overrides bundle default)
|
||||||
|
calibration_dir: User-managed root containing <product>/<device_id>.json.
|
||||||
|
device_id: Manually entered or hardware-reported device identity.
|
||||||
override: Skip auto-detection, use provided box (default: False)
|
override: Skip auto-detection, use provided box (default: False)
|
||||||
box: Manual bounding boxes when override=True (default: None)
|
box: Manual bounding boxes when override=True (default: None)
|
||||||
backend: Poisson backend (default: "auto")
|
backend: Poisson backend (default: "auto")
|
||||||
@@ -181,6 +123,15 @@ class Sensor:
|
|||||||
self.debug = debug
|
self.debug = debug
|
||||||
self.config_name = config_name
|
self.config_name = config_name
|
||||||
self.cal_path = cal_path
|
self.cal_path = cal_path
|
||||||
|
if calibration_dir is not None and device_id is None:
|
||||||
|
raise cal_mod.CalibrationError("device_id is required with calibration_dir")
|
||||||
|
if device_id is not None and calibration_dir is None and cal_path is None:
|
||||||
|
raise cal_mod.CalibrationError("device_id requires calibration_dir or cal_path")
|
||||||
|
if calibration_dir is not None and cal_path is not None:
|
||||||
|
raise cal_mod.CalibrationError("cal_path and calibration_dir are mutually exclusive")
|
||||||
|
self.calibration_dir = calibration_dir
|
||||||
|
self.device_id = device_id
|
||||||
|
self.device_calibration_missing = False
|
||||||
self.mirror = mirror
|
self.mirror = mirror
|
||||||
self._capture_config_override = capture_config
|
self._capture_config_override = capture_config
|
||||||
self._auto_reconnect = bool(auto_reconnect)
|
self._auto_reconnect = bool(auto_reconnect)
|
||||||
@@ -215,119 +166,21 @@ class Sensor:
|
|||||||
self.n_boxes = None
|
self.n_boxes = None
|
||||||
self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0
|
self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0
|
||||||
self._force_raw = (0.0, 0.0, 0.0)
|
self._force_raw = (0.0, 0.0, 0.0)
|
||||||
self.calibration: Optional[cal_mod.Calibration] = None
|
self.calibration: cal_mod.Calibration | None = None
|
||||||
self._force_cal_sensor_name: Optional[str] = None
|
self._force_cal_sensor_name: str | None = None
|
||||||
#: Why no calibration was applied, when a bundle existed but carried no
|
#: Why no calibration was applied, when a bundle existed but carried no
|
||||||
#: usable coefficients. ``None`` means the question never came up.
|
#: 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()
|
self._init_force_calibration()
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
# Signal conditioning (config: top-level "filters" block)
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
# These settings change the reported force values, so they are part of
|
|
||||||
# the measurement-pipeline identity and are recorded in the calibration
|
|
||||||
# metadata / mismatch check alongside dis_mode and hhd_downsample.
|
|
||||||
#
|
|
||||||
# Every stage is switched by its **own value**, so the config states what
|
|
||||||
# runs: ``flow_ema_alpha >= 1``, ``force_median_window <= 1`` and
|
|
||||||
# ``force_lowpass_cutoff_hz <= 0`` each mean "stage off". Values are never
|
|
||||||
# overridden: there is no master switch that could make the file say 0.5
|
|
||||||
# while the device runs 1.0. An invalid value falls back to **off**, never
|
|
||||||
# to a default that filters. ``enabled`` is kept only as a deprecated
|
|
||||||
# alias for "turn every stage off" and is honoured when it is explicitly
|
|
||||||
# false. ``Sensor.filters_state()`` reports what actually took effect.
|
|
||||||
filters_cfg = self._config.get("filters")
|
|
||||||
if not isinstance(filters_cfg, dict):
|
|
||||||
filters_cfg = {}
|
|
||||||
legacy_off = filters_cfg.get("enabled", None) is False
|
|
||||||
preset_name = filters_cfg.get("preset", None)
|
|
||||||
preset_values = FILTER_PRESETS.get(str(preset_name), {}) if preset_name else {}
|
|
||||||
self._filter_preset = str(preset_name) if preset_name in FILTER_PRESETS else None
|
|
||||||
|
|
||||||
def _cfg(key, default):
|
|
||||||
"""The configured value: an explicit key, else the preset, else default."""
|
|
||||||
if legacy_off:
|
|
||||||
return default
|
|
||||||
value = filters_cfg.get(key, None)
|
|
||||||
if value is None:
|
|
||||||
value = preset_values.get(key, None)
|
|
||||||
return default if value is None else value
|
|
||||||
|
|
||||||
self._lowpass_order = 2
|
|
||||||
self._lowpass_sample_rate_hz = float(DEFAULT_SAMPLE_RATE_HZ)
|
|
||||||
self._lowpass_cutoff_hz = 0.0
|
|
||||||
self._lowpass_rate_auto = True
|
|
||||||
self._config_fps = 0.0
|
|
||||||
try:
|
try:
|
||||||
self._config_fps = float(self.__config_cam.get("fps", 0.0) or 0.0)
|
config_fps = float(self.__config_cam.get("fps", 0.0) or 0.0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
self._config_fps = 0.0
|
config_fps = 0.0
|
||||||
if self._config_fps <= 0:
|
self._force_readout = ForceReadout(
|
||||||
self._config_fps = float(DEFAULT_SAMPLE_RATE_HZ)
|
self._config.get("filters"),
|
||||||
self._lowpass_sample_rate_hz = self._config_fps
|
config_fps=config_fps,
|
||||||
try:
|
verbose=self.verbose,
|
||||||
self._lowpass_order = max(1, int(_cfg("force_lowpass_order", 2)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
self._lowpass_order = 2
|
|
||||||
rate_cfg = _cfg("force_lowpass_sample_rate_hz", "auto")
|
|
||||||
if isinstance(rate_cfg, str):
|
|
||||||
self._lowpass_rate_auto = rate_cfg.strip().lower() in ("auto", "", "measured")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
rate = float(rate_cfg)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
rate = 0.0
|
|
||||||
self._lowpass_rate_auto = rate <= 0.0
|
|
||||||
if not self._lowpass_rate_auto:
|
|
||||||
self._lowpass_sample_rate_hz = rate
|
|
||||||
try:
|
|
||||||
self._lowpass_cutoff_hz = float(
|
|
||||||
_cfg("force_lowpass_cutoff_hz", DEFAULT_FORCE_LOWPASS_HZ)
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
self._lowpass_cutoff_hz = 0.0
|
|
||||||
if self._lowpass_sample_rate_hz <= 0:
|
|
||||||
self._lowpass_sample_rate_hz = self._config_fps
|
|
||||||
# An unusable cutoff is "off", never a silent jump to the default.
|
|
||||||
if not (0.0 <= self._lowpass_cutoff_hz < 0.5 * self._lowpass_sample_rate_hz):
|
|
||||||
self._lowpass_cutoff_hz = 0.0
|
|
||||||
self._lowpass_enabled = self._lowpass_cutoff_hz > 0
|
|
||||||
self._force_filter = LowpassFilter(
|
|
||||||
cutoff_hz=self._lowpass_cutoff_hz,
|
|
||||||
sample_rate_hz=self._lowpass_sample_rate_hz,
|
|
||||||
order=self._lowpass_order,
|
|
||||||
n_channels=3,
|
|
||||||
)
|
|
||||||
# Rate tracking: only meaningful while the force low-pass is in use.
|
|
||||||
self._force_rate = SampleRateEstimator(initial_hz=self._config_fps)
|
|
||||||
self._force_rate_source = "config"
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._force_median_window = max(1, int(_cfg("force_median_window", DEFAULT_FORCE_MEDIAN_WINDOW)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
self._force_median_window = 1
|
|
||||||
self._force_median = CausalMedianFilter(window=self._force_median_window, n_channels=3)
|
|
||||||
|
|
||||||
self._flow_ema_alpha = 1.0
|
|
||||||
try:
|
|
||||||
alpha = float(_cfg("flow_ema_alpha", DEFAULT_FLOW_EMA_ALPHA))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
alpha = 1.0
|
|
||||||
if 0.0 < alpha < 1.0:
|
|
||||||
self._flow_ema_alpha = alpha
|
|
||||||
self._ema_flow = None
|
|
||||||
if legacy_off and self.verbose:
|
|
||||||
print(
|
|
||||||
"Note: filters.enabled is deprecated and means 'all stages off'; "
|
|
||||||
"remove it and switch each stage by its own value "
|
|
||||||
"(flow_ema_alpha=1, force_median_window=1, force_lowpass_cutoff_hz=0)."
|
|
||||||
)
|
|
||||||
# Derived, for callers that ask "is any stage filtering?".
|
|
||||||
self._filters_enabled = bool(
|
|
||||||
self._flow_ema_alpha < 1.0
|
|
||||||
or self._force_median_window > 1
|
|
||||||
or self._lowpass_enabled
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
@@ -413,7 +266,7 @@ class Sensor:
|
|||||||
# self._av_stream = None
|
# self._av_stream = None
|
||||||
# self._av_decoder = None
|
# self._av_decoder = None
|
||||||
self.cap = None # compat alias for _capture_worker.source.cap
|
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 = (
|
self._is_live_camera = (
|
||||||
is_live_camera(self.vid_src) and not self._external_only
|
is_live_camera(self.vid_src) and not self._external_only
|
||||||
)
|
)
|
||||||
@@ -429,13 +282,10 @@ class Sensor:
|
|||||||
# A file source is replayed at whatever speed the CPU manages,
|
# A file source is replayed at whatever speed the CPU manages,
|
||||||
# so wall-clock intervals do not reflect signal time: keep the
|
# so wall-clock intervals do not reflect signal time: keep the
|
||||||
# configured camera fps for the force low-pass design.
|
# configured camera fps for the force low-pass design.
|
||||||
self._lowpass_rate_auto = False
|
self._force_readout.pin_file_rate()
|
||||||
self._force_rate_source = "config (file)"
|
|
||||||
self._lowpass_sample_rate_hz = self._config_fps
|
|
||||||
if getattr(self, "_force_rate", None) is not None:
|
|
||||||
self._force_rate.reset(self._config_fps)
|
|
||||||
|
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
|
readout = self._force_readout
|
||||||
print(f" Video source: {vid_src}")
|
print(f" Video source: {vid_src}")
|
||||||
print(f" Stitch mode: {self.stitch_valid}")
|
print(f" Stitch mode: {self.stitch_valid}")
|
||||||
print(f" Optical flow algorithm: {self.__config_tracker['algorithm']}")
|
print(f" Optical flow algorithm: {self.__config_tracker['algorithm']}")
|
||||||
@@ -445,11 +295,11 @@ class Sensor:
|
|||||||
print(f" OpenCL available: {self.OPENCL_AVAILABLE}")
|
print(f" OpenCL available: {self.OPENCL_AVAILABLE}")
|
||||||
print(
|
print(
|
||||||
" Signal filters: "
|
" Signal filters: "
|
||||||
f"ema_alpha={self._flow_ema_alpha} "
|
f"ema_alpha={readout.flow_ema_alpha} "
|
||||||
f"median={self._force_median_window} "
|
f"median={readout.force_median_window} "
|
||||||
f"lowpass={self._lowpass_cutoff_hz}Hz@"
|
f"lowpass={readout.lowpass_cutoff_hz}Hz@"
|
||||||
f"{self._lowpass_sample_rate_hz:.1f}Hz "
|
f"{readout.lowpass_sample_rate_hz:.1f}Hz "
|
||||||
f"({self.force_sample_rate_source})"
|
f"({readout.sample_rate_source})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -782,72 +632,19 @@ class Sensor:
|
|||||||
# flow = self.__tracker.track_raft(self.img)
|
# flow = self.__tracker.track_raft(self.img)
|
||||||
if hasattr(flow, 'download'):
|
if hasattr(flow, 'download'):
|
||||||
flow = flow.download()
|
flow = flow.download()
|
||||||
flow = self._apply_flow_ema(flow)
|
flow = self._force_readout.apply_flow_ema(flow)
|
||||||
self.flow = flow
|
self.flow = flow
|
||||||
self._compute_flow_means(self.flow)
|
self._compute_flow_means(self.flow)
|
||||||
|
|
||||||
def _apply_flow_ema(self, flow):
|
|
||||||
"""Temporally smooth the optical-flow field before HHD.
|
|
||||||
|
|
||||||
The frame-to-frame jitter of the estimated flow is spatially coherent
|
|
||||||
(a near-global shift: it survives ~40x less averaging than per-pixel
|
|
||||||
noise would, see the noise analysis), so it cannot be removed by
|
|
||||||
spatial smoothing, a coarser HHD grid or a different DIS setting - only
|
|
||||||
by averaging along time. Applying it here also stabilises
|
|
||||||
``vnormal``/contact, which a force-level filter cannot reach.
|
|
||||||
|
|
||||||
``flow_ema_alpha >= 1`` disables the stage.
|
|
||||||
"""
|
|
||||||
alpha = getattr(self, "_flow_ema_alpha", 1.0)
|
|
||||||
if flow is None or alpha >= 1.0:
|
|
||||||
return flow
|
|
||||||
prev = self._ema_flow
|
|
||||||
if prev is None or prev.shape != flow.shape or prev.dtype != flow.dtype:
|
|
||||||
self._ema_flow = np.array(flow, copy=True)
|
|
||||||
return flow
|
|
||||||
prev += alpha * (flow - prev)
|
|
||||||
return prev
|
|
||||||
|
|
||||||
def _update_force_sample_rate(self):
|
|
||||||
"""Track the achieved force update rate for the low-pass design.
|
|
||||||
|
|
||||||
A Butterworth designed for 30 Hz stops meaning the same thing at 60 Hz,
|
|
||||||
so when ``force_lowpass_sample_rate_hz`` is ``"auto"`` the rate actually
|
|
||||||
achieved is used. For sources whose wall-clock cadence does not equal
|
|
||||||
signal time (video files, offline replay) ``_lowpass_rate_auto`` is
|
|
||||||
turned off by whoever knows the source - ``__init__`` for a capture the
|
|
||||||
Sensor opened itself, and ``FingerRuntime.open`` for external frames -
|
|
||||||
and the configured camera fps is used instead.
|
|
||||||
"""
|
|
||||||
if not getattr(self, "_lowpass_rate_auto", False):
|
|
||||||
return
|
|
||||||
estimator = getattr(self, "_force_rate", None)
|
|
||||||
if estimator is None:
|
|
||||||
return
|
|
||||||
hz = estimator.tick()
|
|
||||||
if not (1.0 <= hz <= 240.0):
|
|
||||||
return
|
|
||||||
self._force_rate_source = "measured"
|
|
||||||
used = self._lowpass_sample_rate_hz
|
|
||||||
if used > 0 and abs(hz - used) <= 0.1 * used:
|
|
||||||
return
|
|
||||||
self._lowpass_sample_rate_hz = hz
|
|
||||||
if self._lowpass_enabled:
|
|
||||||
self._force_filter.configure(
|
|
||||||
cutoff_hz=self._lowpass_cutoff_hz,
|
|
||||||
sample_rate_hz=hz,
|
|
||||||
order=self._lowpass_order,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def force_sample_rate_hz(self) -> float:
|
def force_sample_rate_hz(self) -> float:
|
||||||
"""Sample rate currently used to design the force low-pass (Hz)."""
|
"""Sample rate currently used to design the force low-pass (Hz)."""
|
||||||
return float(self._lowpass_sample_rate_hz)
|
return self._force_readout.sample_rate_hz
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def force_sample_rate_source(self) -> str:
|
def force_sample_rate_source(self) -> str:
|
||||||
"""``config``, ``measured`` or ``config (replay)``."""
|
"""``config``, ``measured`` or ``config (file)``."""
|
||||||
return str(getattr(self, "_force_rate_source", "config"))
|
return self._force_readout.sample_rate_source
|
||||||
|
|
||||||
def compute_hhd(self, flow=None):
|
def compute_hhd(self, flow=None):
|
||||||
"""Decompose the optical flow and update the force readout.
|
"""Decompose the optical flow and update the force readout.
|
||||||
@@ -928,7 +725,7 @@ class Sensor:
|
|||||||
fn, fx, fy, ftor = forces
|
fn, fx, fy, ftor = forces
|
||||||
self._force_raw = (float(fn), float(fx), float(fy))
|
self._force_raw = (float(fn), float(fx), float(fy))
|
||||||
self.ftor = float(ftor)
|
self.ftor = float(ftor)
|
||||||
self._update_force_sample_rate()
|
self._force_readout.update_sample_rate()
|
||||||
self._apply_force_filter()
|
self._apply_force_filter()
|
||||||
|
|
||||||
vnormal_ds = self.__hhd.get_normal()
|
vnormal_ds = self.__hhd.get_normal()
|
||||||
@@ -1013,7 +810,7 @@ class Sensor:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def read_info(self, *args) -> Tuple:
|
def read_info(self, *args) -> tuple:
|
||||||
"""
|
"""
|
||||||
Get sensor data by info type.
|
Get sensor data by info type.
|
||||||
|
|
||||||
@@ -1069,10 +866,7 @@ class Sensor:
|
|||||||
def reset(self):
|
def reset(self):
|
||||||
"""Reset optical flow tracker and force-filter state."""
|
"""Reset optical flow tracker and force-filter state."""
|
||||||
self.__tracker.reset()
|
self.__tracker.reset()
|
||||||
self._force_filter.reset()
|
self._force_readout.reset()
|
||||||
if getattr(self, "_force_median", None) is not None:
|
|
||||||
self._force_median.reset()
|
|
||||||
self._ema_flow = None
|
|
||||||
# The unfiltered pair belongs to the frame it was computed for: a new
|
# The unfiltered pair belongs to the frame it was computed for: a new
|
||||||
# session must not report the previous one's reading.
|
# session must not report the previous one's reading.
|
||||||
self._force_unfiltered = None
|
self._force_unfiltered = None
|
||||||
@@ -1129,7 +923,6 @@ class Sensor:
|
|||||||
lowpass_enabled=None,
|
lowpass_enabled=None,
|
||||||
flow_ema_alpha=None,
|
flow_ema_alpha=None,
|
||||||
force_median_window=None,
|
force_median_window=None,
|
||||||
hhd_downsample=None,
|
|
||||||
):
|
):
|
||||||
"""Update runtime-safe settings without reconnecting the camera.
|
"""Update runtime-safe settings without reconnecting the camera.
|
||||||
|
|
||||||
@@ -1152,84 +945,29 @@ class Sensor:
|
|||||||
force_median_window : int, optional
|
force_median_window : int, optional
|
||||||
Causal sliding-median width applied to the raw force triple before
|
Causal sliding-median width applied to the raw force triple before
|
||||||
the low-pass. ``<= 1`` disables it; 3 is the default.
|
the low-pass. ``<= 1`` disables it; 3 is the default.
|
||||||
hhd_downsample : int, optional
|
|
||||||
Integer ``>= 1``. Rebuilds the HHD solver on the next frame.
|
|
||||||
"""
|
"""
|
||||||
filter_changed = False
|
|
||||||
if lowpass_cutoff_hz is not None:
|
|
||||||
self._lowpass_cutoff_hz = float(lowpass_cutoff_hz)
|
|
||||||
filter_changed = True
|
|
||||||
if lowpass_enabled is None:
|
|
||||||
self._lowpass_enabled = self._lowpass_cutoff_hz > 0
|
|
||||||
if lowpass_sample_rate_hz is not None:
|
|
||||||
# Accept a positive number to pin the rate, or "auto"/<=0 to track
|
|
||||||
# the measured output rate (see _update_force_sample_rate).
|
|
||||||
auto = False
|
|
||||||
rate = 0.0
|
|
||||||
if isinstance(lowpass_sample_rate_hz, str):
|
|
||||||
auto = lowpass_sample_rate_hz.strip().lower() in ("auto", "", "measured")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
rate = float(lowpass_sample_rate_hz)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
auto = True
|
|
||||||
else:
|
|
||||||
auto = rate <= 0.0
|
|
||||||
self._lowpass_rate_auto = auto
|
|
||||||
if not auto:
|
|
||||||
self._lowpass_sample_rate_hz = rate
|
|
||||||
elif self._lowpass_sample_rate_hz <= 0:
|
|
||||||
self._lowpass_sample_rate_hz = self._config_fps
|
|
||||||
if getattr(self, "_force_rate", None) is not None:
|
|
||||||
self._force_rate.reset(self._config_fps)
|
|
||||||
filter_changed = True
|
|
||||||
if lowpass_order is not None:
|
|
||||||
order = int(lowpass_order)
|
|
||||||
if order < 1:
|
|
||||||
raise ValueError("lowpass_order must be >= 1")
|
|
||||||
self._lowpass_order = order
|
|
||||||
filter_changed = True
|
|
||||||
if lowpass_enabled is not None:
|
|
||||||
self._lowpass_enabled = bool(lowpass_enabled)
|
|
||||||
filter_changed = True
|
|
||||||
|
|
||||||
if flow_ema_alpha is not None:
|
if flow_ema_alpha is not None:
|
||||||
alpha = float(flow_ema_alpha)
|
alpha = float(flow_ema_alpha)
|
||||||
if not (0.0 < alpha <= 1.0):
|
if not (0.0 < alpha <= 1.0):
|
||||||
raise ValueError("flow_ema_alpha must be in (0, 1]")
|
raise ValueError("flow_ema_alpha must be in (0, 1]")
|
||||||
self._flow_ema_alpha = alpha
|
if self.calibration is not None:
|
||||||
self._ema_flow = None
|
mismatches, _ = cal_mod.filter_mismatches(
|
||||||
|
self.calibration.pipeline.get("filters"),
|
||||||
if force_median_window is not None:
|
{"measurement": {"flow_ema_alpha": alpha}},
|
||||||
try:
|
|
||||||
window = max(1, int(force_median_window))
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError("force_median_window must be an integer >= 1") from exc
|
|
||||||
self._force_median_window = window
|
|
||||||
self._force_median.configure(window)
|
|
||||||
filter_changed = True
|
|
||||||
|
|
||||||
if filter_changed:
|
|
||||||
cutoff = self._lowpass_cutoff_hz if self._lowpass_enabled else 0.0
|
|
||||||
self._force_filter.configure(
|
|
||||||
cutoff_hz=cutoff,
|
|
||||||
sample_rate_hz=self._lowpass_sample_rate_hz,
|
|
||||||
order=self._lowpass_order,
|
|
||||||
)
|
)
|
||||||
if self.compute_deformation_valid:
|
if mismatches:
|
||||||
|
raise cal_mod.CalibrationError("; ".join(mismatches))
|
||||||
|
filter_changed = self._force_readout.configure(
|
||||||
|
lowpass_cutoff_hz=lowpass_cutoff_hz,
|
||||||
|
lowpass_sample_rate_hz=lowpass_sample_rate_hz,
|
||||||
|
lowpass_order=lowpass_order,
|
||||||
|
lowpass_enabled=lowpass_enabled,
|
||||||
|
flow_ema_alpha=flow_ema_alpha,
|
||||||
|
force_median_window=force_median_window,
|
||||||
|
)
|
||||||
|
if filter_changed and self.compute_deformation_valid:
|
||||||
self._apply_force_filter()
|
self._apply_force_filter()
|
||||||
|
|
||||||
if hhd_downsample is not None:
|
|
||||||
try:
|
|
||||||
k = max(1, int(hhd_downsample))
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError("hhd_downsample must be an integer >= 1") from exc
|
|
||||||
if k != self._hhd_downsample:
|
|
||||||
self._hhd_downsample = k
|
|
||||||
self.__hhd = None
|
|
||||||
self._hhd_grid_shape = None
|
|
||||||
self._hhd_ref_shape = None
|
|
||||||
self._update_hhd_mask_cache()
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def _bundle_name_candidates(self) -> list[str]:
|
def _bundle_name_candidates(self) -> list[str]:
|
||||||
@@ -1270,6 +1008,11 @@ class Sensor:
|
|||||||
bundle from another root and stopped there without ever asking whether
|
bundle from another root and stopped there without ever asking whether
|
||||||
that root had coefficients.
|
that root had coefficients.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self, "device_calibration_missing", False):
|
||||||
|
return (
|
||||||
|
f"Force calibration: raw (no file for {self.device_id!r} "
|
||||||
|
f"under {self.calibration_dir})"
|
||||||
|
)
|
||||||
attempts = self._force_cal_attempts or []
|
attempts = self._force_cal_attempts or []
|
||||||
# ``found_root`` is keyed even when nothing was found (valued None), so
|
# ``found_root`` is keyed even when nothing was found (valued None), so
|
||||||
# test its value, not the key.
|
# test its value, not the key.
|
||||||
@@ -1303,14 +1046,30 @@ class Sensor:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _init_force_calibration(self) -> None:
|
def _init_force_calibration(self) -> None:
|
||||||
"""Load force calibration from ``cal_path`` or the product bundle."""
|
"""Load the selected force calibration before processing frames."""
|
||||||
self.calibration = None
|
self.calibration = None
|
||||||
self._force_cal_sensor_name = None
|
self._force_cal_sensor_name = None
|
||||||
self._force_cal_attempts = None
|
self._force_cal_attempts = None
|
||||||
|
|
||||||
|
family = None
|
||||||
|
pipeline = None
|
||||||
|
if self.device_id is not None:
|
||||||
|
family, pipeline = self._device_calibration_expectations()
|
||||||
|
|
||||||
if self.cal_path is not None and str(self.cal_path).strip():
|
if self.cal_path is not None and str(self.cal_path).strip():
|
||||||
# Explicit path: missing/invalid raises (no silent fallback).
|
# Explicit path: missing/invalid raises (no silent fallback).
|
||||||
self.calibration = cal_mod.load(self.cal_path, strict=True)
|
self.calibration = cal_mod.load(self.cal_path, strict=True)
|
||||||
|
if self.device_id is not None:
|
||||||
|
self.calibration = cal_mod.validate_for_device(
|
||||||
|
self.calibration, family, self.device_id, pipeline=pipeline
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.calibration_dir is not None:
|
||||||
|
self.calibration = cal_mod.load_for_device(
|
||||||
|
self.calibration_dir, family, self.device_id, pipeline=pipeline
|
||||||
|
)
|
||||||
|
self.device_calibration_missing = self.calibration is None
|
||||||
return
|
return
|
||||||
|
|
||||||
attempts: list[dict] = []
|
attempts: list[dict] = []
|
||||||
@@ -1323,13 +1082,35 @@ class Sensor:
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
attempts.append(configs.describe_bundle_resolution(name))
|
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
|
continue
|
||||||
# Deliberately NOT setting ``_force_cal_sensor_name``: callers read a
|
# Deliberately NOT setting ``_force_cal_sensor_name``: callers read a
|
||||||
# non-None value there as "this sensor is calibrated", and a bundle
|
# non-None value there as "this sensor is calibrated", and a bundle
|
||||||
# without coefficients is not that.
|
# without coefficients is not that.
|
||||||
self._force_cal_attempts = attempts or None
|
self._force_cal_attempts = attempts or None
|
||||||
|
|
||||||
|
def _device_calibration_expectations(self):
|
||||||
|
family = next(
|
||||||
|
(name for name in self._bundle_name_candidates() if name in configs.PRODUCT_BUNDLES),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if family is None:
|
||||||
|
raise cal_mod.CalibrationError(
|
||||||
|
f"cannot determine product family from config_name={self.config_name!r}"
|
||||||
|
)
|
||||||
|
camera = self._config.get("camera", {})
|
||||||
|
tracker = self._config.get("tracker", {})
|
||||||
|
return family, {
|
||||||
|
"stitch_resolution": camera.get("resolution_stitch"),
|
||||||
|
"dis_mode": tracker.get("dis_mode"),
|
||||||
|
"hhd_downsample": tracker.get("hhd_downsample"),
|
||||||
|
"filters": cal_mod.filters_fingerprint(
|
||||||
|
self.filters_state()
|
||||||
|
if hasattr(self, "_force_readout")
|
||||||
|
else self._config.get("filters")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def force_calibrated(self) -> bool:
|
def force_calibrated(self) -> bool:
|
||||||
"""True when at least one channel curve is loaded."""
|
"""True when at least one channel curve is loaded."""
|
||||||
@@ -1339,6 +1120,22 @@ class Sensor:
|
|||||||
def force_unit(self) -> str:
|
def force_unit(self) -> str:
|
||||||
return "N" if self.force_calibrated else "raw"
|
return "N" if self.force_calibrated else "raw"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def calibrated_axes(self) -> tuple[str, ...]:
|
||||||
|
"""Axes with a loaded force-calibration curve."""
|
||||||
|
channels = self.calibration.channels if self.calibration is not None else {}
|
||||||
|
return tuple(axis for axis in cal_mod.CHANNEL_IDS if axis in channels)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def force_units(self) -> tuple[str, str, str]:
|
||||||
|
"""Units of FN, FX and FY in that order."""
|
||||||
|
axes = self.calibrated_axes
|
||||||
|
return (
|
||||||
|
"N" if "fn" in axes else "raw",
|
||||||
|
"N" if "fx" in axes else "raw",
|
||||||
|
"N" if "fy" in axes else "raw",
|
||||||
|
)
|
||||||
|
|
||||||
def set_calibration(
|
def set_calibration(
|
||||||
self,
|
self,
|
||||||
cal: "cal_mod.Calibration | str | Path | None",
|
cal: "cal_mod.Calibration | str | Path | None",
|
||||||
@@ -1346,56 +1143,31 @@ class Sensor:
|
|||||||
"""Attach or replace the active force calibration (live swap after save)."""
|
"""Attach or replace the active force calibration (live swap after save)."""
|
||||||
if cal is None:
|
if cal is None:
|
||||||
self.calibration = None
|
self.calibration = None
|
||||||
|
self.device_calibration_missing = False
|
||||||
self._force_cal_sensor_name = None
|
self._force_cal_sensor_name = None
|
||||||
self._force_cal_attempts = None
|
self._force_cal_attempts = None
|
||||||
return
|
return
|
||||||
if isinstance(cal, cal_mod.Calibration):
|
if isinstance(cal, cal_mod.Calibration):
|
||||||
self.calibration = cal
|
selected = cal
|
||||||
self._force_cal_attempts = None
|
else:
|
||||||
return
|
selected = cal_mod.load(cal, strict=True)
|
||||||
self.calibration = cal_mod.load(cal, strict=True)
|
if self.device_id is not None:
|
||||||
|
family, pipeline = self._device_calibration_expectations()
|
||||||
|
selected = cal_mod.validate_for_device(
|
||||||
|
selected, family, self.device_id, pipeline=pipeline
|
||||||
|
)
|
||||||
|
self.calibration = selected
|
||||||
|
self.device_calibration_missing = False
|
||||||
self._force_cal_attempts = None
|
self._force_cal_attempts = None
|
||||||
|
|
||||||
def _apply_force_filter(self):
|
def _apply_force_filter(self):
|
||||||
pre_filter = np.asarray(self._force_raw, dtype=np.float64)
|
convert = self.calibration.convert if self.calibration is not None else None
|
||||||
raw = pre_filter
|
fn, fx, fy = self._force_readout.apply(self._force_raw, convert)
|
||||||
# Median first: it deletes the heavy-tailed spikes that the flow chain
|
readout = self._force_readout
|
||||||
# produces, so the low-pass does not have to spread them over time.
|
self._force_filtered_raw = readout.filtered_raw
|
||||||
median = getattr(self, "_force_median", None)
|
|
||||||
if median is not None and median.enabled:
|
|
||||||
raw = median.process(raw)
|
|
||||||
if self._lowpass_enabled and self._lowpass_cutoff_hz > 0:
|
|
||||||
raw = self._force_filter.process(raw)
|
|
||||||
# The chain's own output, before calibration: the "filtered raw counts".
|
|
||||||
# Without this the raw axis would have no filtered/unfiltered pair and
|
|
||||||
# ``force_triplets`` could only offer the device counts twice.
|
|
||||||
self._force_filtered_raw = (
|
|
||||||
float(raw[0]),
|
|
||||||
float(raw[1]),
|
|
||||||
float(raw[2]),
|
|
||||||
)
|
|
||||||
fn, fx, fy = self._force_filtered_raw
|
|
||||||
|
|
||||||
# Apply loaded force_calibration.json when present; otherwise keep raw.
|
|
||||||
if self.calibration is not None:
|
|
||||||
fn, fx, fy = self.calibration.convert(fn, fx, fy)
|
|
||||||
fx, fy = float(fx), float(fy)
|
|
||||||
fn = float(fn)
|
|
||||||
|
|
||||||
self.fnormal, self.fshearx, self.fsheary = fn, fx, fy
|
self.fnormal, self.fshearx, self.fsheary = fn, fx, fy
|
||||||
|
self._force_raw_unfiltered = readout.raw_unfiltered
|
||||||
# The same reading with no readout filtering, so a caller can keep both
|
self._force_unfiltered = readout.unfiltered_newton
|
||||||
# (the app stores the filtered values as the record and the unfiltered
|
|
||||||
# ones beside them). Calibrated with the same coefficients, so the two
|
|
||||||
# only differ by the filter chain.
|
|
||||||
ufn, ufx, ufy = (float(pre_filter[0]), float(pre_filter[1]), float(pre_filter[2]))
|
|
||||||
if self.calibration is not None:
|
|
||||||
ufn, ufx, ufy = self.calibration.convert(ufn, ufx, ufy)
|
|
||||||
ufx, ufy, ufn = float(ufx), float(ufy), float(ufn)
|
|
||||||
self._force_raw_unfiltered = (
|
|
||||||
float(pre_filter[0]), float(pre_filter[1]), float(pre_filter[2])
|
|
||||||
)
|
|
||||||
self._force_unfiltered = (ufn, ufx, ufy)
|
|
||||||
|
|
||||||
# ------------------------------------------------------- filter contract
|
# ------------------------------------------------------- filter contract
|
||||||
def filters_state(self) -> dict:
|
def filters_state(self) -> dict:
|
||||||
@@ -1413,30 +1185,10 @@ class Sensor:
|
|||||||
(median, low-pass): recorded, but they do not change the physical
|
(median, low-pass): recorded, but they do not change the physical
|
||||||
claim.
|
claim.
|
||||||
"""
|
"""
|
||||||
median_applied = bool(getattr(self._force_median, "enabled", False))
|
|
||||||
tracker_cfg = self._config.get("tracker") or {}
|
tracker_cfg = self._config.get("tracker") or {}
|
||||||
return {
|
state = self._force_readout.state(dis_mode=tracker_cfg.get("dis_mode"))
|
||||||
"preset": getattr(self, "_filter_preset", None),
|
state["source"] = "sensor.filters_state"
|
||||||
"measurement": {
|
return state
|
||||||
"flow_ema_alpha": float(self._flow_ema_alpha),
|
|
||||||
"flow_ema_applied": bool(self._flow_ema_alpha < 1.0),
|
|
||||||
"hhd_downsample": int(getattr(self, "hhd_downsample", 0) or 0),
|
|
||||||
"dis_mode": tracker_cfg.get("dis_mode"),
|
|
||||||
},
|
|
||||||
"readout": {
|
|
||||||
"force_median_window": int(self._force_median_window),
|
|
||||||
"force_median_applied": median_applied,
|
|
||||||
"force_lowpass_cutoff_hz": float(self._lowpass_cutoff_hz),
|
|
||||||
"force_lowpass_order": int(self._lowpass_order),
|
|
||||||
"force_lowpass_sample_rate_hz": float(self._lowpass_sample_rate_hz),
|
|
||||||
"force_lowpass_rate_auto": bool(self._lowpass_rate_auto),
|
|
||||||
"force_lowpass_applied": bool(self._lowpass_enabled),
|
|
||||||
"sample_rate_source": str(getattr(self, "_force_rate_source", "config")),
|
|
||||||
},
|
|
||||||
"any_stage_applied": bool(self._filters_enabled),
|
|
||||||
"unfiltered_available": True,
|
|
||||||
"source": "sensor.filters_state",
|
|
||||||
}
|
|
||||||
|
|
||||||
def force_triplets(self) -> dict:
|
def force_triplets(self) -> dict:
|
||||||
"""The filtered and unfiltered force triples of the current frame.
|
"""The filtered and unfiltered force triples of the current frame.
|
||||||
@@ -1700,6 +1452,6 @@ class Sensor:
|
|||||||
for k, v in timings.items():
|
for k, v in timings.items():
|
||||||
if k != 'total':
|
if k != 'total':
|
||||||
print(f" {k}: {v:7.2f} ms")
|
print(f" {k}: {v:7.2f} ms")
|
||||||
print(f" ─────────────────────────")
|
print(" ─────────────────────────")
|
||||||
print(f" TOTAL: {timings['total']:7.2f} ms ({1000/timings['total']:.1f} FPS)")
|
print(f" TOTAL: {timings['total']:7.2f} ms ({1000/timings['total']:.1f} FPS)")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Sensor bundle resolution: profile identity, capabilities, calibration and fingerprints.
|
||||||
|
|
||||||
|
This package owns the answer to "which pipeline does this folder drive, what can it do, and would
|
||||||
|
two of them compute the same numbers". It replaces reading a product name as if it were a
|
||||||
|
processing backend.
|
||||||
|
|
||||||
|
The public surface is small on purpose::
|
||||||
|
|
||||||
|
from orisys import resolve_bundle, validate_profile, create_processor, CalibrationSelection
|
||||||
|
|
||||||
|
profile = resolve_bundle(r"sensors/mini")
|
||||||
|
print(profile.processing_backend, profile.processing_fingerprint)
|
||||||
|
for line in profile.summary_lines():
|
||||||
|
print(line)
|
||||||
|
session = create_processor(profile, source=0) # live camera
|
||||||
|
# or: create_processor(profile, source="clip.mp4") # recorded frames, same semantics
|
||||||
|
|
||||||
|
``ResolvedSensorProfile`` is plain data: no Qt, no application preferences, no device handle. A
|
||||||
|
device contributes only its calibration selection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .backends import (
|
||||||
|
BACKENDS,
|
||||||
|
BackendSpec,
|
||||||
|
ProcessingSession,
|
||||||
|
ProcessorCreationError,
|
||||||
|
create_processor,
|
||||||
|
known_backends,
|
||||||
|
spec_for,
|
||||||
|
)
|
||||||
|
from .calibration import (
|
||||||
|
CalibrationSelection,
|
||||||
|
ResolvedCalibration,
|
||||||
|
axis_availability,
|
||||||
|
calibration_fingerprint,
|
||||||
|
resolve_calibration,
|
||||||
|
)
|
||||||
|
from .capabilities import (
|
||||||
|
CALIBRATED_FORCE,
|
||||||
|
DEFORMATION,
|
||||||
|
OPTICAL_FLOW,
|
||||||
|
RAW_FORCE,
|
||||||
|
VIDEO_PROCESSING,
|
||||||
|
VISUALIZATION_3D,
|
||||||
|
Availability,
|
||||||
|
AxisAvailability,
|
||||||
|
Capabilities,
|
||||||
|
Issue,
|
||||||
|
ValidationReport,
|
||||||
|
ok,
|
||||||
|
report,
|
||||||
|
unavailable,
|
||||||
|
)
|
||||||
|
from .descriptor import (
|
||||||
|
BACKEND_COMPOUND_EYE,
|
||||||
|
BACKEND_SINGLE_CAMERA,
|
||||||
|
BACKEND_UV_UNROLL,
|
||||||
|
CURRENT_DESCRIPTOR_SCHEMA,
|
||||||
|
DESCRIPTOR_FILENAME,
|
||||||
|
SUPPORTED_BACKENDS,
|
||||||
|
SUPPORTED_DESCRIPTOR_SCHEMAS,
|
||||||
|
BundleDescriptor,
|
||||||
|
DescriptorError,
|
||||||
|
parse_descriptor,
|
||||||
|
)
|
||||||
|
from .fingerprint import (
|
||||||
|
CALIBRATION_FINGERPRINT_ALGORITHM,
|
||||||
|
FINGERPRINT_ALGORITHM,
|
||||||
|
dependency_inventory,
|
||||||
|
file_digest,
|
||||||
|
processing_fingerprint,
|
||||||
|
)
|
||||||
|
from .legacy import (
|
||||||
|
BUILT_IN_PROFILES,
|
||||||
|
LEGACY_MANIFEST_KEYS,
|
||||||
|
backend_from_config,
|
||||||
|
describe_legacy_bundle,
|
||||||
|
)
|
||||||
|
from .resolve import (
|
||||||
|
ResolvedSensorProfile,
|
||||||
|
resolve_bundle,
|
||||||
|
resolve_named_bundle,
|
||||||
|
validate_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# entry points
|
||||||
|
"resolve_bundle",
|
||||||
|
"resolve_named_bundle",
|
||||||
|
"validate_profile",
|
||||||
|
"create_processor",
|
||||||
|
# resolved data
|
||||||
|
"ResolvedSensorProfile",
|
||||||
|
"ResolvedCalibration",
|
||||||
|
"CalibrationSelection",
|
||||||
|
"ValidationReport",
|
||||||
|
"Capabilities",
|
||||||
|
"Availability",
|
||||||
|
"AxisAvailability",
|
||||||
|
"Issue",
|
||||||
|
# descriptor
|
||||||
|
"BundleDescriptor",
|
||||||
|
"DescriptorError",
|
||||||
|
"parse_descriptor",
|
||||||
|
"describe_legacy_bundle",
|
||||||
|
"backend_from_config",
|
||||||
|
"BUILT_IN_PROFILES",
|
||||||
|
"LEGACY_MANIFEST_KEYS",
|
||||||
|
"DESCRIPTOR_FILENAME",
|
||||||
|
"CURRENT_DESCRIPTOR_SCHEMA",
|
||||||
|
"SUPPORTED_DESCRIPTOR_SCHEMAS",
|
||||||
|
# backends
|
||||||
|
"BACKENDS",
|
||||||
|
"BackendSpec",
|
||||||
|
"ProcessingSession",
|
||||||
|
"ProcessorCreationError",
|
||||||
|
"known_backends",
|
||||||
|
"spec_for",
|
||||||
|
"BACKEND_COMPOUND_EYE",
|
||||||
|
"BACKEND_SINGLE_CAMERA",
|
||||||
|
"BACKEND_UV_UNROLL",
|
||||||
|
"SUPPORTED_BACKENDS",
|
||||||
|
# calibration helpers
|
||||||
|
"resolve_calibration",
|
||||||
|
"axis_availability",
|
||||||
|
"calibration_fingerprint",
|
||||||
|
# capabilities
|
||||||
|
"VIDEO_PROCESSING",
|
||||||
|
"OPTICAL_FLOW",
|
||||||
|
"RAW_FORCE",
|
||||||
|
"CALIBRATED_FORCE",
|
||||||
|
"DEFORMATION",
|
||||||
|
"VISUALIZATION_3D",
|
||||||
|
"ok",
|
||||||
|
"report",
|
||||||
|
"unavailable",
|
||||||
|
# reproducibility
|
||||||
|
"processing_fingerprint",
|
||||||
|
"calibration_fingerprint",
|
||||||
|
"dependency_inventory",
|
||||||
|
"file_digest",
|
||||||
|
"FINGERPRINT_ALGORITHM",
|
||||||
|
"CALIBRATION_FINGERPRINT_ALGORITHM",
|
||||||
|
]
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""Backend registry: turn a resolved profile into a processing session.
|
||||||
|
|
||||||
|
Selection is by backend identifier, never by product name. The identifiers are the SDK's existing
|
||||||
|
pipeline vocabulary - the same spellings :func:`orisys.frame_processors.create_frame_processor`
|
||||||
|
already accepts - so a profile backed by ``compound_eye`` builds a stitcher and a tile processor,
|
||||||
|
while a ``single_camera`` profile is a plain frame path with no geometry.
|
||||||
|
|
||||||
|
A backend that needs material the SDK does not yet build from a profile says so plainly instead of
|
||||||
|
producing something that looks like it worked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ..frame_processors import create_frame_processor
|
||||||
|
from ..pipeline import PauseLevel, StreamPipeline
|
||||||
|
from ..stitch.api import Stitcher
|
||||||
|
from .capabilities import Issue
|
||||||
|
from .descriptor import (
|
||||||
|
BACKEND_COMPOUND_EYE,
|
||||||
|
BACKEND_SINGLE_CAMERA,
|
||||||
|
BACKEND_UV_UNROLL,
|
||||||
|
SUPPORTED_BACKENDS,
|
||||||
|
)
|
||||||
|
from .resolve import ResolvedSensorProfile, validate_profile
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessorCreationError(RuntimeError):
|
||||||
|
"""The profile cannot be turned into a processing session, with the reason why."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BackendSpec:
|
||||||
|
"""What one backend identifier requires and what it can produce."""
|
||||||
|
|
||||||
|
backend_id: str
|
||||||
|
#: Configuration blocks the backend cannot run without.
|
||||||
|
requires_config_keys: tuple[str, ...] = ()
|
||||||
|
#: Computational asset roles it needs, if any.
|
||||||
|
requires_asset_roles: tuple[str, ...] = ()
|
||||||
|
#: Whether the produced session yields a force reading.
|
||||||
|
produces_force: bool = True
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
BACKENDS: Mapping[str, BackendSpec] = {
|
||||||
|
BACKEND_COMPOUND_EYE: BackendSpec(
|
||||||
|
backend_id=BACKEND_COMPOUND_EYE,
|
||||||
|
requires_config_keys=("stitcher", "camera", "tracker"),
|
||||||
|
produces_force=True,
|
||||||
|
description="compound-eye tiles stitched, then optical flow and HHD",
|
||||||
|
),
|
||||||
|
BACKEND_SINGLE_CAMERA: BackendSpec(
|
||||||
|
backend_id=BACKEND_SINGLE_CAMERA,
|
||||||
|
requires_config_keys=("camera",),
|
||||||
|
produces_force=False,
|
||||||
|
description="single camera frame with no tiling and no deformation geometry",
|
||||||
|
),
|
||||||
|
BACKEND_UV_UNROLL: BackendSpec(
|
||||||
|
backend_id=BACKEND_UV_UNROLL,
|
||||||
|
requires_config_keys=("camera",),
|
||||||
|
requires_asset_roles=("unroll_map",),
|
||||||
|
produces_force=True,
|
||||||
|
description="finger UV unroll onto a mesh domain, then optical flow and HHD",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def known_backends() -> tuple[str, ...]:
|
||||||
|
"""The backend identifiers this SDK can build."""
|
||||||
|
return tuple(BACKENDS)
|
||||||
|
|
||||||
|
|
||||||
|
def spec_for(backend_id: str) -> BackendSpec:
|
||||||
|
"""The spec for ``backend_id``, or an explicit error naming the supported set."""
|
||||||
|
try:
|
||||||
|
return BACKENDS[backend_id]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ProcessorCreationError(
|
||||||
|
f"unsupported processing backend {backend_id!r}; supported: {sorted(BACKENDS)}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_requirements(
|
||||||
|
spec: BackendSpec, profile: ResolvedSensorProfile
|
||||||
|
) -> list[str]:
|
||||||
|
missing: list[str] = []
|
||||||
|
for key in spec.requires_config_keys:
|
||||||
|
if key not in profile.config:
|
||||||
|
missing.append(f"config block {key!r}")
|
||||||
|
for role in spec.requires_asset_roles:
|
||||||
|
path = profile.computational_assets.get(role)
|
||||||
|
if path is None:
|
||||||
|
missing.append(f"computational asset {role!r}")
|
||||||
|
elif not path.is_file():
|
||||||
|
missing.append(f"computational asset {role!r} (missing at {path})")
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingSession:
|
||||||
|
"""One profile, one process: the unit both live acquisition and video analysis drive.
|
||||||
|
|
||||||
|
``process`` is a :class:`~orisys.pipeline.StreamPipeline` when the backend runs the three-stage
|
||||||
|
pipeline. ``streams`` is False for a backend that only produces frames, in which case
|
||||||
|
``wait()`` hands back the frames themselves rather than a force snapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
profile: ResolvedSensorProfile,
|
||||||
|
backend_id: str,
|
||||||
|
process: Any,
|
||||||
|
streams: bool,
|
||||||
|
sensor: Any = None,
|
||||||
|
) -> None:
|
||||||
|
self.profile = profile
|
||||||
|
self.backend_id = backend_id
|
||||||
|
self.process = process
|
||||||
|
self.streams = streams
|
||||||
|
self.sensor = sensor
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ lifecycle
|
||||||
|
def start(self) -> "ProcessingSession":
|
||||||
|
if self.streams and hasattr(self.process, "start"):
|
||||||
|
self.process.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __enter__(self) -> "ProcessingSession":
|
||||||
|
return self.start()
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||||
|
self.stop()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self, timeout: float = 5.0) -> None:
|
||||||
|
if self.streams and hasattr(self.process, "stop"):
|
||||||
|
self.process.stop(timeout=timeout)
|
||||||
|
|
||||||
|
def wait(self, timeout: float | None = None):
|
||||||
|
"""The next result: a force snapshot when streaming, else the next frame."""
|
||||||
|
if self.streams:
|
||||||
|
return self.process.wait(timeout=timeout)
|
||||||
|
return self.process(timeout=timeout)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset the pipeline's reference: the flow tracker and force-filter state."""
|
||||||
|
if self.streams:
|
||||||
|
self.process.reset()
|
||||||
|
return
|
||||||
|
# Frame-only backend: the reference lives on the sensor's tracker.
|
||||||
|
sensor = self.sensor
|
||||||
|
if sensor is not None and hasattr(sensor, "reset"):
|
||||||
|
sensor.reset()
|
||||||
|
|
||||||
|
def pause(self, level: PauseLevel | str = PauseLevel.CAPTURE_ONLY) -> None:
|
||||||
|
"""Hold the pipeline. Only a streaming backend can be held."""
|
||||||
|
if not self.streams:
|
||||||
|
raise ProcessorCreationError(
|
||||||
|
f"backend {self.backend_id!r} does not run a pipeline, so it cannot be paused"
|
||||||
|
)
|
||||||
|
self.process.pause(level)
|
||||||
|
|
||||||
|
def resume(self, *, reset: bool = True) -> None:
|
||||||
|
if not self.streams:
|
||||||
|
raise ProcessorCreationError(
|
||||||
|
f"backend {self.backend_id!r} does not run a pipeline, so it cannot be resumed"
|
||||||
|
)
|
||||||
|
self.process.resume(reset=reset)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paused(self) -> bool:
|
||||||
|
return bool(self.streams and getattr(self.process, "paused", False))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return bool(self.streams and getattr(self.process, "running", False))
|
||||||
|
|
||||||
|
def latest(self):
|
||||||
|
"""The most recent snapshot or frame, or ``None``."""
|
||||||
|
if self.streams and hasattr(self.process, "latest"):
|
||||||
|
return self.process.latest()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def capabilities(self):
|
||||||
|
return self.profile.capabilities
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_factory(sensor: Any, config: Mapping[str, Any], *, include=("image",), contact=False):
|
||||||
|
"""Build the three-stage pipeline for a sensor-like object."""
|
||||||
|
|
||||||
|
def producer():
|
||||||
|
return sensor.get_img()
|
||||||
|
|
||||||
|
return StreamPipeline(sensor, producer, contact=contact, include=include)
|
||||||
|
|
||||||
|
|
||||||
|
def create_processor(
|
||||||
|
profile: ResolvedSensorProfile,
|
||||||
|
*,
|
||||||
|
sensor: Any = None,
|
||||||
|
source: Any = None,
|
||||||
|
include=("image",),
|
||||||
|
contact: bool = False,
|
||||||
|
verbose: bool = False,
|
||||||
|
):
|
||||||
|
"""Build the processing session for ``profile``.
|
||||||
|
|
||||||
|
``sensor`` may be supplied when the caller already owns a device (a live ``Sensor`` trained on
|
||||||
|
this profile's ``sensor.json``); otherwise a new one is built from the profile. ``source`` is
|
||||||
|
the capture source - a camera index or a video path - and is only used when building a sensor.
|
||||||
|
Both live and recorded input therefore go through this one function.
|
||||||
|
|
||||||
|
Raises :class:`ProcessorCreationError` when the profile cannot be processed, naming what is
|
||||||
|
missing, rather than returning something that silently does nothing.
|
||||||
|
"""
|
||||||
|
validation = validate_profile(profile)
|
||||||
|
if not validation.usable:
|
||||||
|
reasons = "; ".join(str(issue) for issue in validation.errors)
|
||||||
|
raise ProcessorCreationError(
|
||||||
|
f"profile {profile.profile_id!r} cannot be processed: {reasons}"
|
||||||
|
)
|
||||||
|
|
||||||
|
backend_id = profile.processing_backend
|
||||||
|
spec = spec_for(backend_id)
|
||||||
|
missing = _missing_requirements(spec, profile)
|
||||||
|
if missing:
|
||||||
|
raise ProcessorCreationError(
|
||||||
|
f"profile {profile.profile_id!r} with backend {backend_id!r} is missing "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
if backend_id == BACKEND_UV_UNROLL:
|
||||||
|
# The UV backend needs geometry, a UV domain, a tactile processor and a capture object
|
||||||
|
# assembled together, which is FingerRuntime's shape rather than a config-driven Sensor.
|
||||||
|
# Building it here would duplicate that assembly, so it is not silently faked.
|
||||||
|
raise ProcessorCreationError(
|
||||||
|
"backend 'uv_unroll' cannot yet be materialised from a profile alone; open it "
|
||||||
|
"through orisys.finger.FingerRuntime.open(export_dir=..., config=...) until this "
|
||||||
|
"backend builds its UV domain from the profile's computational assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
if sensor is None:
|
||||||
|
from ..sensor import Sensor
|
||||||
|
|
||||||
|
sensor = Sensor(
|
||||||
|
source if source is not None else 0,
|
||||||
|
config_name=str(profile.sensor_json_path),
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
elif verbose:
|
||||||
|
print(f"[processor] using the supplied sensor for {profile.profile_id}")
|
||||||
|
|
||||||
|
if backend_id == BACKEND_COMPOUND_EYE:
|
||||||
|
config_stitcher = profile.config.get("stitcher")
|
||||||
|
stitcher = Stitcher(
|
||||||
|
isstitch=True, config_stitcher=config_stitcher, verbose=verbose
|
||||||
|
)
|
||||||
|
sensor.frame_processor = create_frame_processor(
|
||||||
|
BACKEND_COMPOUND_EYE, stitcher=stitcher
|
||||||
|
)
|
||||||
|
return ProcessingSession(
|
||||||
|
profile=profile,
|
||||||
|
backend_id=backend_id,
|
||||||
|
process=_stream_factory(sensor, profile.config, include=include, contact=contact),
|
||||||
|
streams=True,
|
||||||
|
sensor=sensor,
|
||||||
|
)
|
||||||
|
|
||||||
|
# single_camera: the sensor already builds its own pass-through processor from the config.
|
||||||
|
sensor.frame_processor = create_frame_processor(BACKEND_SINGLE_CAMERA)
|
||||||
|
|
||||||
|
def next_frame(timeout: float | None = None):
|
||||||
|
frame = sensor.get_img()
|
||||||
|
return frame
|
||||||
|
|
||||||
|
return ProcessingSession(
|
||||||
|
profile=profile,
|
||||||
|
backend_id=backend_id,
|
||||||
|
process=next_frame,
|
||||||
|
streams=False,
|
||||||
|
sensor=sensor,
|
||||||
|
)
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Calibration selection and compatibility for a resolved profile.
|
||||||
|
|
||||||
|
Three outcomes are distinct and must not be collapsed:
|
||||||
|
|
||||||
|
* an explicitly selected calibration that loads and applies - the caller asked for it;
|
||||||
|
* an explicit "no calibration" - the caller asked for raw output and gets it, reported as such;
|
||||||
|
* a selected calibration that does not load or does not fit - an error, never a quiet
|
||||||
|
substitution with some other file.
|
||||||
|
|
||||||
|
A calibration file merely existing proves nothing, so presence is never the check: the document
|
||||||
|
is parsed through :mod:`orisys.calibration`, its units and channels are read, and its declared
|
||||||
|
applicability is compared with the profile it was selected for. Legacy documents that carry no
|
||||||
|
identifying metadata are reported as *unknown applicability* rather than as compatible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from .. import calibration as cal_mod
|
||||||
|
from .capabilities import Availability, Issue, ok as _ok, unavailable
|
||||||
|
|
||||||
|
#: Axis availability when no calibration curve is present for that axis.
|
||||||
|
_NO_CURVE = "no calibration curve for this axis"
|
||||||
|
|
||||||
|
#: Sentinel digest for "explicitly no calibration". A distinct constant rather than an empty
|
||||||
|
#: string, so a stored fingerprint always says which state it recorded.
|
||||||
|
_NONE_FINGERPRINT = "none"
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSelection(enum.Enum):
|
||||||
|
"""How the caller wants the calibration chosen."""
|
||||||
|
|
||||||
|
#: Use the bundle's declared default, else ``force_calibration.json`` beside ``sensor.json``.
|
||||||
|
BUNDLE_DEFAULT = "bundle_default"
|
||||||
|
#: Explicitly run uncalibrated. Raw force is still available; it is a choice, not a failure.
|
||||||
|
NONE = "none"
|
||||||
|
#: Use this file - see :meth:`from_value`.
|
||||||
|
EXPLICIT = "explicit"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_value(cls, value: "CalibrationSelection | str | Path | None") -> tuple["CalibrationSelection", Path | None]:
|
||||||
|
"""Normalise a caller-supplied selection into ``(mode, path)``."""
|
||||||
|
if value is None:
|
||||||
|
return cls.BUNDLE_DEFAULT, None
|
||||||
|
if isinstance(value, cls):
|
||||||
|
return value, None
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text or text.lower() in ("none", "raw", "off"):
|
||||||
|
return cls.NONE, None
|
||||||
|
if text.lower() in ("default", "bundle", "bundle_default"):
|
||||||
|
return cls.BUNDLE_DEFAULT, None
|
||||||
|
return cls.EXPLICIT, Path(text).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResolvedCalibration:
|
||||||
|
"""The calibration side of a resolved profile."""
|
||||||
|
|
||||||
|
selection: CalibrationSelection
|
||||||
|
path: Path | None
|
||||||
|
document: "cal_mod.Calibration | None" = None
|
||||||
|
#: ``"declared"`` when the document carries identity metadata, ``"unknown"`` when it does
|
||||||
|
#: not (legacy), ``"none"`` for an explicit uncalibrated run.
|
||||||
|
applicability: str = "none"
|
||||||
|
#: Axes this calibration provides a curve for.
|
||||||
|
axes: tuple[str, ...] = ()
|
||||||
|
unit: str = ""
|
||||||
|
fingerprint: str = ""
|
||||||
|
issues: tuple[Issue, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def applied(self) -> bool:
|
||||||
|
return self.document is not None
|
||||||
|
|
||||||
|
def axis_detail(self, axis: str) -> str:
|
||||||
|
if self.document is None:
|
||||||
|
return _NO_CURVE if self.selection is CalibrationSelection.NONE else "no calibration"
|
||||||
|
return "" if axis in self.axes else _NO_CURVE
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"selection": self.selection.value,
|
||||||
|
"path": None if self.path is None else str(self.path),
|
||||||
|
"applicability": self.applicability,
|
||||||
|
"axes": list(self.axes),
|
||||||
|
"unit": self.unit,
|
||||||
|
"fingerprint": self.fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_of(document: "cal_mod.Calibration") -> Mapping[str, str]:
|
||||||
|
identity = getattr(document, "identity", None)
|
||||||
|
return identity if isinstance(identity, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_calibration(
|
||||||
|
*,
|
||||||
|
bundle_root: Path,
|
||||||
|
declared_default: Path | None,
|
||||||
|
profile_id: str,
|
||||||
|
product_family: str,
|
||||||
|
selection: CalibrationSelection | str | Path | None = None,
|
||||||
|
) -> ResolvedCalibration:
|
||||||
|
"""Resolve and validate the calibration for one profile.
|
||||||
|
|
||||||
|
Returns a :class:`ResolvedCalibration` whose ``issues`` carry every reason a selected
|
||||||
|
calibration could not be used. It never falls back to a different file.
|
||||||
|
"""
|
||||||
|
mode, explicit = CalibrationSelection.from_value(selection)
|
||||||
|
if mode is CalibrationSelection.NONE:
|
||||||
|
return ResolvedCalibration(
|
||||||
|
selection=mode, path=None, applicability="none", fingerprint=_NONE_FINGERPRINT
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode is CalibrationSelection.EXPLICIT:
|
||||||
|
path = explicit
|
||||||
|
else:
|
||||||
|
path = declared_default
|
||||||
|
if path is None:
|
||||||
|
fallback = bundle_root / "force_calibration.json"
|
||||||
|
path = fallback if fallback.is_file() else None
|
||||||
|
|
||||||
|
if path is None:
|
||||||
|
# Not an error: a profile may legitimately ship no calibration. Calibrated force is
|
||||||
|
# simply unavailable, per axis, and the caller can still select a file explicitly.
|
||||||
|
issue = Issue(
|
||||||
|
code="calibration.absent",
|
||||||
|
message="no calibration for this profile; raw force is available",
|
||||||
|
path=bundle_root,
|
||||||
|
)
|
||||||
|
return ResolvedCalibration(
|
||||||
|
selection=mode,
|
||||||
|
path=None,
|
||||||
|
applicability="none",
|
||||||
|
fingerprint=_NONE_FINGERPRINT,
|
||||||
|
issues=(issue,),
|
||||||
|
)
|
||||||
|
|
||||||
|
path = Path(path)
|
||||||
|
if not path.is_file():
|
||||||
|
return ResolvedCalibration(
|
||||||
|
selection=mode,
|
||||||
|
path=path,
|
||||||
|
issues=(
|
||||||
|
Issue(
|
||||||
|
code="calibration.missing",
|
||||||
|
message="the selected calibration file does not exist",
|
||||||
|
path=path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = cal_mod.load(str(path), strict=True)
|
||||||
|
except Exception as exc: # noqa: BLE001 - reported as an actionable issue
|
||||||
|
return ResolvedCalibration(
|
||||||
|
selection=mode,
|
||||||
|
path=path,
|
||||||
|
issues=(
|
||||||
|
Issue(
|
||||||
|
code="calibration.unreadable",
|
||||||
|
message=f"selected calibration could not be loaded: {exc}",
|
||||||
|
path=path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if document is None: # defensive: strict load raises rather than returning None
|
||||||
|
return ResolvedCalibration(
|
||||||
|
selection=mode,
|
||||||
|
path=path,
|
||||||
|
issues=(
|
||||||
|
Issue(
|
||||||
|
code="calibration.unreadable",
|
||||||
|
message="selected calibration produced no document",
|
||||||
|
path=path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
axes = tuple(sorted(document.channels))
|
||||||
|
unit = str(getattr(document, "unit", "") or "")
|
||||||
|
issues: list[Issue] = []
|
||||||
|
if unit and unit.upper() not in ("N", "NEWTON", "NEWTONS"):
|
||||||
|
issues.append(
|
||||||
|
Issue(
|
||||||
|
code="calibration.unit",
|
||||||
|
message=f"calibration unit {unit!r} is not a force unit in newtons",
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
identity = _identity_of(document)
|
||||||
|
declared_type = str(identity.get("sensor_type") or "")
|
||||||
|
if declared_type:
|
||||||
|
applicability = "declared"
|
||||||
|
if declared_type != product_family:
|
||||||
|
issues.append(
|
||||||
|
Issue(
|
||||||
|
code="calibration.family-mismatch",
|
||||||
|
message=(
|
||||||
|
f"calibration declares sensor_type {declared_type!r} but the profile is "
|
||||||
|
f"{product_family!r}; it was selected explicitly, so it applies as given"
|
||||||
|
),
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No metadata to compare against: honest answer is that applicability is unknown.
|
||||||
|
applicability = "unknown"
|
||||||
|
issues.append(
|
||||||
|
Issue(
|
||||||
|
code="calibration.applicability-unknown",
|
||||||
|
message=(
|
||||||
|
"calibration carries no identifying metadata (no meta.sensor_type); "
|
||||||
|
f"its applicability to {profile_id} cannot be verified"
|
||||||
|
),
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not axes:
|
||||||
|
issues.append(
|
||||||
|
Issue(
|
||||||
|
code="calibration.no-channels",
|
||||||
|
message="calibration defines no usable channel curve",
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResolvedCalibration(
|
||||||
|
selection=mode,
|
||||||
|
path=path,
|
||||||
|
document=document,
|
||||||
|
applicability=applicability,
|
||||||
|
axes=axes,
|
||||||
|
unit=unit,
|
||||||
|
fingerprint=calibration_fingerprint(document, path),
|
||||||
|
issues=tuple(issues),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def axis_availability(resolved: ResolvedCalibration) -> tuple[object, object, object]:
|
||||||
|
"""``(fn, fx, fy)`` availability, each with its own reason when unavailable."""
|
||||||
|
if resolved.document is None:
|
||||||
|
reason = (
|
||||||
|
"explicitly uncalibrated"
|
||||||
|
if resolved.selection is CalibrationSelection.NONE
|
||||||
|
else (resolved.issues[0].message if resolved.issues else "no calibration")
|
||||||
|
)
|
||||||
|
blank = unavailable(reason)
|
||||||
|
return blank, blank, blank
|
||||||
|
out: list[Availability] = []
|
||||||
|
for axis in ("fn", "fx", "fy"):
|
||||||
|
if axis in resolved.axes:
|
||||||
|
out.append(_ok(f"applicability {resolved.applicability}"))
|
||||||
|
else:
|
||||||
|
out.append(
|
||||||
|
unavailable(_NO_CURVE, detail=f"{resolved.path.name} has no {axis} curve")
|
||||||
|
)
|
||||||
|
return out[0], out[1], out[2]
|
||||||
|
|
||||||
|
|
||||||
|
def calibration_fingerprint(document: "cal_mod.Calibration", path: Path) -> str:
|
||||||
|
"""Content hash of the calibration document.
|
||||||
|
|
||||||
|
Hashes what the document *says*, not where it lives: selecting an identical calibration from
|
||||||
|
a different directory must produce the same fingerprint.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = document.to_dict()
|
||||||
|
except Exception: # noqa: BLE001 - fall back to the raw bytes rather than fail
|
||||||
|
payload = path.read_bytes().decode("utf-8", errors="replace")
|
||||||
|
text = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Capability reporting: what a resolved profile can actually do, and why not.
|
||||||
|
|
||||||
|
The picker needs answers, not guesses. "``sensor.json`` exists so processing works" and
|
||||||
|
"``scene/`` exists so 3D works" are exactly the two inferences this module exists to replace:
|
||||||
|
each capability is decided by the asset and configuration facts that capability actually needs,
|
||||||
|
and an unavailable one carries the reason it is unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Mapping
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Availability",
|
||||||
|
"AxisAvailability",
|
||||||
|
"Capabilities",
|
||||||
|
"Issue",
|
||||||
|
"ValidationReport",
|
||||||
|
"ok",
|
||||||
|
"report",
|
||||||
|
"unavailable",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Capability identifiers, stable for the UI and for stored reports.
|
||||||
|
VIDEO_PROCESSING = "video_processing"
|
||||||
|
OPTICAL_FLOW = "optical_flow"
|
||||||
|
RAW_FORCE = "raw_force"
|
||||||
|
CALIBRATED_FORCE = "calibrated_force"
|
||||||
|
DEFORMATION = "deformation"
|
||||||
|
VISUALIZATION_3D = "visualization_3d"
|
||||||
|
|
||||||
|
#: Units a "deformation" output can carry. The capability is reported with the meaning it has,
|
||||||
|
#: never as a bare "available".
|
||||||
|
DEFORMATION_MEANINGS: Mapping[str, str] = {
|
||||||
|
"optical_flow_px": "per-frame optical flow in pixels",
|
||||||
|
"displacement_relative": "relative displacement, uncalibrated",
|
||||||
|
"depth_relative": "relative depth from the deformation field",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Availability:
|
||||||
|
"""Whether one capability is usable, and the reason when it is not."""
|
||||||
|
|
||||||
|
available: bool
|
||||||
|
reason: str = ""
|
||||||
|
#: Free-form detail the caller may show, such as which axis is missing.
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {"available": self.available}
|
||||||
|
if self.reason:
|
||||||
|
payload["reason"] = self.reason
|
||||||
|
if self.detail:
|
||||||
|
payload["detail"] = self.detail
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def ok(detail: str = "") -> Availability:
|
||||||
|
return Availability(True, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
def unavailable(reason: str, detail: str = "") -> Availability:
|
||||||
|
return Availability(False, reason=reason, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AxisAvailability:
|
||||||
|
"""Per-axis availability, so "calibrated" is never a single yes/no."""
|
||||||
|
|
||||||
|
fn: Availability
|
||||||
|
fx: Availability
|
||||||
|
fy: Availability
|
||||||
|
|
||||||
|
def any_available(self) -> bool:
|
||||||
|
return self.fn.available or self.fx.available or self.fy.available
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, dict[str, object]]:
|
||||||
|
return {
|
||||||
|
"fn": self.fn.as_dict(),
|
||||||
|
"fx": self.fx.as_dict(),
|
||||||
|
"fy": self.fy.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Capabilities:
|
||||||
|
"""Every capability the picker and the analysis layer ask about."""
|
||||||
|
|
||||||
|
video_processing: Availability
|
||||||
|
optical_flow: Availability
|
||||||
|
raw_force: Availability
|
||||||
|
calibrated_force: AxisAvailability
|
||||||
|
deformation: Availability
|
||||||
|
visualization_3d: Availability
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
VIDEO_PROCESSING: self.video_processing.as_dict(),
|
||||||
|
OPTICAL_FLOW: self.optical_flow.as_dict(),
|
||||||
|
RAW_FORCE: self.raw_force.as_dict(),
|
||||||
|
CALIBRATED_FORCE: self.calibrated_force.as_dict(),
|
||||||
|
DEFORMATION: self.deformation.as_dict(),
|
||||||
|
VISUALIZATION_3D: self.visualization_3d.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def summary_lines(self) -> list[str]:
|
||||||
|
"""One human-readable line per capability, for a status panel."""
|
||||||
|
lines = [
|
||||||
|
f"video processing: {_fmt(self.video_processing)}",
|
||||||
|
f"optical flow: {_fmt(self.optical_flow)}",
|
||||||
|
f"raw force: {_fmt(self.raw_force)}",
|
||||||
|
]
|
||||||
|
for axis, availability in (
|
||||||
|
("fn", self.calibrated_force.fn),
|
||||||
|
("fx", self.calibrated_force.fx),
|
||||||
|
("fy", self.calibrated_force.fy),
|
||||||
|
):
|
||||||
|
lines.append(f"calibrated {axis}: {_fmt(availability)}")
|
||||||
|
lines.append(f"deformation: {_fmt(self.deformation)}")
|
||||||
|
lines.append(f"3D visualization: {_fmt(self.visualization_3d)}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(availability: Availability) -> str:
|
||||||
|
if availability.available:
|
||||||
|
return "available" + (f" ({availability.detail})" if availability.detail else "")
|
||||||
|
return "unavailable" + (f" - {availability.reason}" if availability.reason else "")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Issue:
|
||||||
|
"""One validation finding, addressed at a file when there is one."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
path: Path | None = None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
where = f" [{self.path}]" if self.path is not None else ""
|
||||||
|
return f"{self.code}: {self.message}{where}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValidationReport:
|
||||||
|
"""The outcome of validating a resolved profile.
|
||||||
|
|
||||||
|
``ok`` means processing can start. ``degraded`` means it can start but something optional is
|
||||||
|
missing, so the UI should say what. ``failed`` means it cannot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
state: str
|
||||||
|
errors: tuple[Issue, ...] = ()
|
||||||
|
warnings: tuple[Issue, ...] = ()
|
||||||
|
capabilities: Capabilities | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.state == "ok"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usable(self) -> bool:
|
||||||
|
return self.state != "failed"
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"state": self.state,
|
||||||
|
"errors": [
|
||||||
|
{"code": i.code, "message": i.message, "path": None if i.path is None else str(i.path)}
|
||||||
|
for i in self.errors
|
||||||
|
],
|
||||||
|
"warnings": [
|
||||||
|
{"code": i.code, "message": i.message, "path": None if i.path is None else str(i.path)}
|
||||||
|
for i in self.warnings
|
||||||
|
],
|
||||||
|
"capabilities": None if self.capabilities is None else self.capabilities.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def summary_lines(self) -> list[str]:
|
||||||
|
lines = [f"validation: {self.state}"]
|
||||||
|
lines.extend(f" error: {issue}" for issue in self.errors)
|
||||||
|
lines.extend(f" warning: {issue}" for issue in self.warnings)
|
||||||
|
if self.capabilities is not None:
|
||||||
|
lines.extend(f" {line}" for line in self.capabilities.summary_lines())
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def report(
|
||||||
|
errors: Iterable[Issue],
|
||||||
|
warnings: Iterable[Issue],
|
||||||
|
capabilities: Capabilities | None = None,
|
||||||
|
) -> ValidationReport:
|
||||||
|
"""Build a report, deriving the state from what was found."""
|
||||||
|
error_list = tuple(errors)
|
||||||
|
warning_list = tuple(warnings)
|
||||||
|
if error_list:
|
||||||
|
state = "failed"
|
||||||
|
elif warning_list:
|
||||||
|
state = "degraded"
|
||||||
|
else:
|
||||||
|
state = "ok"
|
||||||
|
return ValidationReport(
|
||||||
|
state=state,
|
||||||
|
errors=error_list,
|
||||||
|
warnings=warning_list,
|
||||||
|
capabilities=capabilities,
|
||||||
|
)
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Sensor bundle descriptor: the versioned identity of a configuration profile.
|
||||||
|
|
||||||
|
A bundle folder holds three different things that ``sensor_type`` used to conflate:
|
||||||
|
|
||||||
|
* ``product_family`` - what the operator calls the product (box, mini, finger);
|
||||||
|
* ``processing_backend`` - which SDK pipeline processes the images;
|
||||||
|
* ``profile_id`` - the identity of this particular configuration revision.
|
||||||
|
|
||||||
|
The descriptor states those explicitly in ``manifest.json``. ``sensor.json`` keeps its
|
||||||
|
existing meaning: it is the processing configuration, and its ``type`` field stays the
|
||||||
|
image-processing arrangement (``compound_eye`` / ``single_camera`` / ``uv_unroll``), never a
|
||||||
|
product family.
|
||||||
|
|
||||||
|
Every path in a descriptor resolves relative to the bundle root, so moving or renaming the
|
||||||
|
folder changes nothing about what it means. Bundles that predate this descriptor are handled
|
||||||
|
by :mod:`orisys.sensors.legacy`; nothing is ever inferred from a folder name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
CURRENT_DESCRIPTOR_SCHEMA = 1
|
||||||
|
SUPPORTED_DESCRIPTOR_SCHEMAS: tuple[int, ...] = (1,)
|
||||||
|
|
||||||
|
#: Backend identifiers the SDK can build. Kept in step with
|
||||||
|
#: :func:`orisys.frame_processors.create_frame_processor`, which is where the vocabulary already
|
||||||
|
#: lives; the registry asserts the two agree.
|
||||||
|
BACKEND_COMPOUND_EYE = "compound_eye"
|
||||||
|
BACKEND_SINGLE_CAMERA = "single_camera"
|
||||||
|
BACKEND_UV_UNROLL = "uv_unroll"
|
||||||
|
SUPPORTED_BACKENDS: tuple[str, ...] = (
|
||||||
|
BACKEND_COMPOUND_EYE,
|
||||||
|
BACKEND_SINGLE_CAMERA,
|
||||||
|
BACKEND_UV_UNROLL,
|
||||||
|
)
|
||||||
|
|
||||||
|
DESCRIPTOR_FILENAME = "manifest.json"
|
||||||
|
|
||||||
|
|
||||||
|
class DescriptorError(ValueError):
|
||||||
|
"""A bundle descriptor is missing, malformed, or names something unsupported."""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_relative(bundle_root: Path, raw: str, *, field_name: str) -> Path:
|
||||||
|
"""Resolve a descriptor path inside the bundle, refusing to escape it.
|
||||||
|
|
||||||
|
Relative paths are the contract: an absolute path would make the descriptor depend on where
|
||||||
|
it was installed, which is exactly what the fingerprint must not include.
|
||||||
|
"""
|
||||||
|
text = str(raw).strip()
|
||||||
|
if not text:
|
||||||
|
raise DescriptorError(f"{field_name}: empty path")
|
||||||
|
candidate = Path(text)
|
||||||
|
if candidate.is_absolute():
|
||||||
|
raise DescriptorError(
|
||||||
|
f"{field_name}: {text!r} is absolute; descriptor paths resolve relative to the bundle"
|
||||||
|
)
|
||||||
|
resolved = (bundle_root / candidate).resolve()
|
||||||
|
try:
|
||||||
|
resolved.relative_to(bundle_root.resolve())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise DescriptorError(
|
||||||
|
f"{field_name}: {text!r} escapes the bundle folder"
|
||||||
|
) from exc
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BundleDescriptor:
|
||||||
|
"""A parsed ``manifest.json``, with every optional field filled in."""
|
||||||
|
|
||||||
|
schema_version: int
|
||||||
|
profile_id: str
|
||||||
|
display_name: str
|
||||||
|
product_family: str
|
||||||
|
processing_backend: str
|
||||||
|
bundle_root: Path
|
||||||
|
sensor_json: Path
|
||||||
|
default_calibration: Path | None = None
|
||||||
|
#: Assets required to process at all. A missing one makes the profile fail, not degrade.
|
||||||
|
computational_assets: Mapping[str, Path] = field(default_factory=dict)
|
||||||
|
#: Optional visualization assets. A missing one never blocks image processing.
|
||||||
|
assets: Mapping[str, Path] = field(default_factory=dict)
|
||||||
|
#: True when this came from a manifest rather than a compatibility adapter.
|
||||||
|
from_manifest: bool = False
|
||||||
|
#: Where ``processing_backend`` was decided, for diagnostics.
|
||||||
|
backend_source: str = "manifest"
|
||||||
|
diagnostics: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
"""The descriptor as JSON-serialisable data, with paths made bundle-relative."""
|
||||||
|
root = self.bundle_root
|
||||||
|
|
||||||
|
def rel(path: Path | None) -> str | None:
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return path.relative_to(root).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return path.as_posix()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": self.schema_version,
|
||||||
|
"profile_id": self.profile_id,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"product_family": self.product_family,
|
||||||
|
"processing_backend": self.processing_backend,
|
||||||
|
"sensor_json": rel(self.sensor_json),
|
||||||
|
"default_calibration": rel(self.default_calibration),
|
||||||
|
"computational_assets": {
|
||||||
|
role: rel(path) for role, path in sorted(self.computational_assets.items())
|
||||||
|
},
|
||||||
|
"assets": {role: rel(path) for role, path in sorted(self.assets.items())},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_str(data: Mapping[str, Any], key: str, *, where: str) -> str:
|
||||||
|
value = data.get(key)
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise DescriptorError(f"{where}: {key!r} must be a non-empty string")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_map(
|
||||||
|
data: Mapping[str, Any], key: str, bundle_root: Path
|
||||||
|
) -> dict[str, Path]:
|
||||||
|
raw = data.get(key)
|
||||||
|
if raw is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(raw, Mapping):
|
||||||
|
raise DescriptorError(f"{key!r} must be an object of {{role: relative path}}")
|
||||||
|
out: dict[str, Path] = {}
|
||||||
|
for role, value in raw.items():
|
||||||
|
if not isinstance(role, str) or not role.strip():
|
||||||
|
raise DescriptorError(f"{key!r}: asset roles must be non-empty strings")
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise DescriptorError(f"{key!r}.{role}: path must be a string")
|
||||||
|
out[str(role).strip()] = _resolve_relative(
|
||||||
|
bundle_root, value, field_name=f"{key}.{role}"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_descriptor(bundle_root: str | Path) -> BundleDescriptor:
|
||||||
|
"""Read ``manifest.json`` from ``bundle_root`` into a :class:`BundleDescriptor`.
|
||||||
|
|
||||||
|
Raises :class:`DescriptorError` when the file is absent, malformed, names an unsupported
|
||||||
|
backend, or declares an unsupported schema version. It never guesses: a bundle without a
|
||||||
|
usable manifest must go through :func:`orisys.sensors.legacy.describe_legacy_bundle`.
|
||||||
|
"""
|
||||||
|
root = Path(bundle_root).expanduser()
|
||||||
|
if not root.is_dir():
|
||||||
|
raise DescriptorError(f"not a bundle folder: {root}")
|
||||||
|
manifest = root / DESCRIPTOR_FILENAME
|
||||||
|
if not manifest.is_file():
|
||||||
|
raise DescriptorError(
|
||||||
|
f"{root.name}: no {DESCRIPTOR_FILENAME}. A bundle needs one to be resolved; "
|
||||||
|
"legacy built-ins are handled by the compatibility adapter"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise DescriptorError(f"{manifest}: unreadable descriptor: {exc}") from exc
|
||||||
|
if not isinstance(data, Mapping):
|
||||||
|
raise DescriptorError(f"{manifest}: descriptor must be a JSON object")
|
||||||
|
|
||||||
|
raw_schema = data.get("schema_version")
|
||||||
|
try:
|
||||||
|
schema = int(raw_schema) # type: ignore[arg-type]
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise DescriptorError(
|
||||||
|
f"{manifest}: schema_version must be an integer, got {raw_schema!r}"
|
||||||
|
) from exc
|
||||||
|
if schema not in SUPPORTED_DESCRIPTOR_SCHEMAS:
|
||||||
|
raise DescriptorError(
|
||||||
|
f"{manifest}: unsupported descriptor schema_version {schema}; "
|
||||||
|
f"supported: {list(SUPPORTED_DESCRIPTOR_SCHEMAS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = _require_str(data, "processing_backend", where=str(manifest))
|
||||||
|
if backend not in SUPPORTED_BACKENDS:
|
||||||
|
raise DescriptorError(
|
||||||
|
f"{manifest}: unsupported processing_backend {backend!r}; "
|
||||||
|
f"supported: {list(SUPPORTED_BACKENDS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
sensor_json = _resolve_relative(
|
||||||
|
root,
|
||||||
|
str(data.get("sensor_json") or "sensor.json"),
|
||||||
|
field_name="sensor_json",
|
||||||
|
)
|
||||||
|
default_calibration = None
|
||||||
|
raw_cal = data.get("default_calibration")
|
||||||
|
if raw_cal:
|
||||||
|
default_calibration = _resolve_relative(
|
||||||
|
root, str(raw_cal), field_name="default_calibration"
|
||||||
|
)
|
||||||
|
|
||||||
|
return BundleDescriptor(
|
||||||
|
schema_version=schema,
|
||||||
|
profile_id=_require_str(data, "profile_id", where=str(manifest)),
|
||||||
|
display_name=_require_str(data, "display_name", where=str(manifest)),
|
||||||
|
product_family=_require_str(data, "product_family", where=str(manifest)),
|
||||||
|
processing_backend=backend,
|
||||||
|
bundle_root=root.resolve(),
|
||||||
|
sensor_json=sensor_json,
|
||||||
|
default_calibration=default_calibration,
|
||||||
|
computational_assets=_asset_map(data, "computational_assets", root.resolve()),
|
||||||
|
assets=_asset_map(data, "assets", root.resolve()),
|
||||||
|
from_manifest=True,
|
||||||
|
backend_source="manifest",
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user