Compare commits
46 Commits
5f78115429
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d1f168c9c7 | |||
| e9710e140b | |||
| ff7614a6a0 | |||
| f7f4682ff9 | |||
| 9c951e9d3c | |||
| bb15b750fb | |||
| 3953747f17 | |||
| ae64c65c45 | |||
| 120b8c2cf6 | |||
| 0784eacf51 | |||
| 68edb0dc4c | |||
| 4c3d0f8864 | |||
| ceb06f5702 | |||
| 8c147f03aa | |||
| 34cb83d4a4 | |||
| 38d3e67b00 | |||
| 80f7da5a20 | |||
| 230b04a79d | |||
| 6e3d509239 | |||
| 333fa8fcff | |||
| f700102067 | |||
| b5d7533e24 | |||
| 4c1ab5c27e | |||
| cde832eaca | |||
| 73b1fa7fa4 | |||
| d5b938738e | |||
| e2ac36269c | |||
| 07da372204 | |||
| 0e903792bd | |||
| 153f51df11 | |||
| 2e7b65298b | |||
| e2b906a640 | |||
| 45419a59c4 | |||
| 05b2354d0d | |||
| 1cf78c9d71 | |||
| 14fbfec62e | |||
| 11962cdc34 | |||
| ae5bf0c208 | |||
| 7f8e5d0975 | |||
| 35190562e5 | |||
| b956d071ea | |||
| 0beb36a1af | |||
| 1adb1c30f0 | |||
| 514874e2f4 | |||
| 0d5d8ff70d | |||
| 75fa98eff0 |
+9
-1
@@ -24,12 +24,14 @@ __pycache__/
|
||||
# Local tools / editor state
|
||||
/.cursor/
|
||||
/.vscode/
|
||||
/_perf/
|
||||
|
||||
# Local data / models
|
||||
/models/
|
||||
/data/*
|
||||
!/data/sample/
|
||||
!/data/sample/**
|
||||
!/data/calibrations/
|
||||
|
||||
# Generated docs / tooling outputs
|
||||
/docs/.quarto/
|
||||
@@ -42,4 +44,10 @@ __pycache__/
|
||||
|
||||
# Legacy / scratch folders
|
||||
/old_orisys/
|
||||
/tools/calibration/
|
||||
/tools/calibration/
|
||||
|
||||
# Scratch output of examples/ (multi_stream.py's default --out-dir)
|
||||
/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
|
||||
|
||||
- `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/`
|
||||
- `scripts/`: maintenance, calibration, packaging, and demo scripts
|
||||
- `tools/`: non-package tooling such as patch-generation utilities
|
||||
@@ -73,12 +74,13 @@ Run commands from the repository root.
|
||||
|
||||
## 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:
|
||||
|
||||
1. an explicit JSON file path
|
||||
2. a config file stem under `config/` (for example `box`, `mini`, `finger`)
|
||||
3. a product alias such as `dd02` -> `dd02-ov`
|
||||
2. `sensors/<name>/sensor.json` (for example `box`, `mini`, `finger`)
|
||||
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"`
|
||||
|
||||
Examples:
|
||||
@@ -90,12 +92,39 @@ sensor = orisys.Sensor(0, config_name="box")
|
||||
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
|
||||
|
||||
- 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`)
|
||||
- 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/`
|
||||
|
||||
## Publishing (Linux manylinux)
|
||||
|
||||
+33
-2
@@ -27,6 +27,7 @@ build/
|
||||
```bash
|
||||
python build/run.py wheel-win
|
||||
python build/run.py wheel-manylinux
|
||||
python build/run.py wheel-manylinux -Architecture aarch64 -PythonVersions 3.10
|
||||
|
||||
# Single Version
|
||||
python build/run.py wheel
|
||||
@@ -42,6 +43,29 @@ build\wheel\win.bat
|
||||
.\build\wheel\docker\run.ps1 -SkipImageBuild # reuse the image when only sources changed
|
||||
```
|
||||
|
||||
`wheel-manylinux` defaults to `x86_64`; `-Architecture aarch64` builds ARM64 Linux
|
||||
wheels in `quay.io/pypa/manylinux_2_28_aarch64`. Docker Desktop must use Linux
|
||||
containers and support ARM64 emulation on an Intel/AMD host. This runs the ARM64
|
||||
compiler under QEMU, so compilation can be much slower than on native ARM64.
|
||||
The default Python versions remain 3.10, 3.11 and 3.12; `-PythonVersions` selects
|
||||
which ABIs to compile. The image still prepares tooling for all three ABIs.
|
||||
|
||||
The default image names are `orisys-manylinux` and `orisys-manylinux-aarch64`.
|
||||
`-ImageName` overrides the name; a reused image must match the requested
|
||||
architecture. For example, reuse the ARM64 image after source-only changes:
|
||||
|
||||
```powershell
|
||||
.\build\wheel\docker\run.ps1 -Architecture aarch64 -PythonVersions 3.10 -SkipImageBuild
|
||||
```
|
||||
|
||||
ARM64 wheels target glibc Linux, including Q6A and Jetson systems with compatible
|
||||
Python ABIs and glibc versions (2.28 or newer for this build image). They do not
|
||||
target Android. The build requires binary ARM64 wheels for the runtime
|
||||
dependencies and performs the existing isolated import check. Validate on each
|
||||
board before delivery; CUDA, JetPack and Qualcomm GPU/NPU runtimes need separate
|
||||
compatibility checks. Builds share staging directories, so run architectures
|
||||
and Python ABIs sequentially.
|
||||
|
||||
The image bakes in per-ABI build tooling and a per-ABI wheelhouse of the runtime
|
||||
deps, so the entrypoint no longer re-resolves them over the network. See
|
||||
`docs/publishing.md` §6 for build-time details, why `-SkipImageBuild` matters,
|
||||
@@ -49,8 +73,9 @@ and what to change before attempting to parallelize the per-ABI builds.
|
||||
|
||||
### Release gate
|
||||
|
||||
Local only: never publishes, and makes no network call unless `--check-pypi` is
|
||||
passed. See `docs/publishing.md` for the full flow.
|
||||
Local only: never publishes. `--check-pypi` probes the index; native Windows
|
||||
venvs may download dependencies. Linux verification uses local Docker images
|
||||
and their offline wheelhouses. See `docs/publishing.en.md` for the full flow.
|
||||
|
||||
```bash
|
||||
python build/run.py release preflight --version <version>
|
||||
@@ -67,6 +92,8 @@ is the expected input, so build first:
|
||||
|
||||
```bash
|
||||
python build/run.py wheel-win
|
||||
python build/run.py wheel-manylinux
|
||||
python build/run.py wheel-manylinux -Architecture aarch64
|
||||
python build/run.py release preflight --all-wheel-pythons --check-pypi
|
||||
```
|
||||
|
||||
@@ -88,6 +115,10 @@ python build/run.py release pypi --version <version> --pypi-only --apply
|
||||
|
||||
Both refuse to act unless `release preflight` passed on a clean commit with no
|
||||
waived options, and every wheel still hashes to what that report recorded.
|
||||
Runtime evidence must cover every wheel hash. `--all-wheel-pythons` verifies all
|
||||
staged platforms and ABIs, using conda for Windows and matching Docker images for
|
||||
Linux. GitHub attaches all certified wheels; PyPI verifies every uploaded
|
||||
Python/platform wheel by downloading it and comparing its hash.
|
||||
Tokens are only ever read from a hidden prompt or stdin, never from argv.
|
||||
See `docs/publishing.md` §3.
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ from common.verify import ( # noqa: E402
|
||||
verify_wheel_assets,
|
||||
verify_wheel_runtime,
|
||||
)
|
||||
from common.verify.wheel import DEFAULT_RUNTIME_CONFIG, default_verification_video # noqa: E402
|
||||
from common.verify.wheel import ( # noqa: E402
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
default_verification_video,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
@@ -193,11 +193,11 @@ print(json.dumps({
|
||||
|
||||
# Default runtime-verification inputs. Overridable by CLI flags or environment.
|
||||
DEFAULT_RUNTIME_CONFIG = os.getenv("ORISYS_VERIFY_CONFIG", "box")
|
||||
# Clips tracked in the repository, smallest first.
|
||||
# Clips tracked in the repository. ``data/sample/s1.mp4`` is the sanctioned
|
||||
# verification input; the larger 720p clip is deliberately not listed, so a
|
||||
# missing s1.mp4 fails loudly instead of silently verifying against it.
|
||||
DEFAULT_VIDEO_CANDIDATES = (
|
||||
Path("data") / "sample" / "s1_480p.mp4",
|
||||
Path("data") / "sample" / "s1_720p.mp4",
|
||||
Path("tests") / "idle_1min.mp4",
|
||||
Path("data") / "sample" / "s1.mp4",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,11 +31,16 @@ _BUILD_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BUILD_ROOT))
|
||||
|
||||
from common.paths import REPO_ROOT # noqa: E402
|
||||
from common.pyproject_utils import bundle_items, extras_require, install_requires, project_metadata # noqa: E402
|
||||
from wheel.layout import resolve_bundle_source_files # noqa: E402
|
||||
from wheel.package import is_compliant_manylinux_wheel # noqa: E402
|
||||
|
||||
from common.paths import REPO_ROOT # noqa: E402
|
||||
from common.pyproject_utils import ( # noqa: E402
|
||||
bundle_items,
|
||||
extras_require,
|
||||
install_requires,
|
||||
)
|
||||
|
||||
# PyPI's default per-project file limit is 100 MB; keep headroom.
|
||||
MAX_WHEEL_BYTES = 90 * 1024 * 1024
|
||||
|
||||
@@ -339,11 +344,7 @@ def _check_forbidden_entries(result: WheelCheckResult, names: list[str]) -> None
|
||||
offenders = []
|
||||
for name in names:
|
||||
lowered = name.lower()
|
||||
if lowered.startswith(_FORBIDDEN_PREFIXES):
|
||||
offenders.append(name)
|
||||
elif lowered.endswith(_FORBIDDEN_SUFFIXES):
|
||||
offenders.append(name)
|
||||
elif "__pycache__" in lowered or ".egg-info" in lowered:
|
||||
if lowered.startswith(_FORBIDDEN_PREFIXES) or lowered.endswith(_FORBIDDEN_SUFFIXES) or "__pycache__" in lowered or ".egg-info" in lowered:
|
||||
offenders.append(name)
|
||||
result.add(
|
||||
"no-forbidden-entries",
|
||||
@@ -426,7 +427,7 @@ def _check_platform_tag(result: WheelCheckResult) -> None:
|
||||
result.add(
|
||||
"platform-tag",
|
||||
ok,
|
||||
f"{tag!r}" if ok else f"{tag!r} is not a PyPI-accepted manylinux x86_64 tag",
|
||||
f"{tag!r}" if ok else f"{tag!r} is not a supported manylinux x86_64 or aarch64 tag",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
_BUILD_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_BUILD_ROOT) not in sys.path:
|
||||
|
||||
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BUILD_ROOT))
|
||||
|
||||
from common.paths import REPO_ROOT
|
||||
|
||||
from gui.common.pyinstaller_driver import GuiBuildConfig
|
||||
|
||||
ENTRY_SCRIPT = REPO_ROOT / "examples" / "basic_pipeline.py"
|
||||
|
||||
@@ -15,6 +15,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BUILD_ROOT))
|
||||
|
||||
from common.verify.gui import verify_gui_dist
|
||||
|
||||
from gui.common.pyinstaller_driver import build_with_pyinstaller
|
||||
from gui.ory_finger.config import make_config
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BUILD_ROOT))
|
||||
|
||||
from common.paths import REPO_ROOT
|
||||
|
||||
from gui.common.pyinstaller_driver import GuiBuildConfig
|
||||
|
||||
ENTRY_SCRIPT = REPO_ROOT / "examples" / "finger_pipeline_3dview.py"
|
||||
|
||||
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BUILD_ROOT))
|
||||
|
||||
from common.paths import REPO_ROOT
|
||||
|
||||
from gui.common.pyinstaller_driver import GuiBuildConfig
|
||||
|
||||
ENTRY_SCRIPT = REPO_ROOT / "examples" / "mini_pipeline.py"
|
||||
|
||||
@@ -25,6 +25,7 @@ from common.verify.wheel import ( # noqa: E402
|
||||
verify_wheel_runtime,
|
||||
)
|
||||
from common.verify.wheel_contents import inspect_wheel # noqa: E402
|
||||
|
||||
from release import auth, github, pypi # noqa: E402
|
||||
from release.preflight import PreflightOptions, run_preflight # noqa: E402
|
||||
|
||||
@@ -51,7 +52,7 @@ def _cmd_preflight(argv: list[str]) -> int:
|
||||
parser.add_argument(
|
||||
"--all-wheel-pythons",
|
||||
action="store_true",
|
||||
help="verify cp310/cp311/cp312 wheels, each in its own conda env",
|
||||
help="verify every staged wheel (Windows conda environments and Linux Docker images)",
|
||||
)
|
||||
parser.add_argument("--offline", action="store_true", help="reuse parent-env deps instead of PyPI")
|
||||
parser.add_argument("--no-runtime", action="store_true", help="skip the isolated runtime check")
|
||||
@@ -182,7 +183,9 @@ def _cmd_github(argv: list[str]) -> int:
|
||||
prog="build/run.py release github",
|
||||
description="Sync orisys-sdk-release, tag, and publish a GitHub Release.",
|
||||
)
|
||||
parser.add_argument("--version", required=True, help="release version, e.g. 0.5.2")
|
||||
parser.add_argument(
|
||||
"--version", required=True, help="release version, e.g. <major>.<minor>.<patch>"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
@@ -329,7 +332,9 @@ def _cmd_pypi(argv: list[str]) -> int:
|
||||
prog="build/run.py release pypi",
|
||||
description="Upload the certified wheels to TestPyPI and then PyPI.",
|
||||
)
|
||||
parser.add_argument("--version", required=True, help="release version, e.g. 0.5.2")
|
||||
parser.add_argument(
|
||||
"--version", required=True, help="release version, e.g. <major>.<minor>.<patch>"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
|
||||
+10
-2
@@ -29,7 +29,9 @@ if str(_BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BUILD_ROOT))
|
||||
|
||||
from common.paths import REPO_ROOT # noqa: E402
|
||||
|
||||
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
|
||||
from release.runtime import PLATFORM_LABELS, wheel_target # noqa: E402
|
||||
|
||||
MANIFEST_PATH = REPO_ROOT / "tools" / "downstream_sync_manifest.json"
|
||||
SYNC_TOOL = REPO_ROOT / "tools" / "sync_downstreams.py"
|
||||
@@ -92,10 +94,11 @@ def _git(repo: Path, *args: str, check: bool = True) -> str:
|
||||
|
||||
|
||||
def build_release_notes(version: str, wheels: list[Path], git_commit: str | None) -> str:
|
||||
platforms = sorted({PLATFORM_LABELS[wheel_target(wheel)[1]] for wheel in wheels})
|
||||
lines = [
|
||||
f"orisys {version}",
|
||||
"",
|
||||
"Wheels (Python 3.10 / 3.11 / 3.12, Windows x64 and Linux x86_64):",
|
||||
f"Wheels ({', '.join(platforms)}; Python ABI is shown in each filename):",
|
||||
]
|
||||
lines.extend(f"- `{wheel.name}`" for wheel in wheels)
|
||||
lines.append("")
|
||||
@@ -334,7 +337,8 @@ def publish(options: Options) -> int:
|
||||
|
||||
asset_dir = repo / "release" / version
|
||||
assets = [asset_dir / wheel.name for wheel in wheels]
|
||||
notes_file = options.notes_file
|
||||
notes_file = options.notes_file or gate.report_path.parent / "github-release-notes.md"
|
||||
generated_notes = build_release_notes(version, wheels, gate.git_commit) if options.notes_file is None else None
|
||||
steps = build_github_steps(
|
||||
version=version,
|
||||
branch=branch,
|
||||
@@ -360,11 +364,15 @@ def publish(options: Options) -> int:
|
||||
print()
|
||||
|
||||
if not options.apply:
|
||||
if generated_notes:
|
||||
print(generated_notes)
|
||||
print("dry run: nothing was changed. Re-run with --apply to execute.")
|
||||
return 0
|
||||
|
||||
# --- 4. execute ----------------------------------------------------
|
||||
try:
|
||||
if generated_notes:
|
||||
notes_file.write_text(generated_notes, encoding="utf-8")
|
||||
commit_before = _git(repo, "rev-parse", "HEAD")
|
||||
_sync(run_checks=not options.skip_checks)
|
||||
_stage_wheels(wheels, asset_dir)
|
||||
|
||||
+30
-20
@@ -1,8 +1,8 @@
|
||||
"""Local release gate: everything that must hold before publishing a wheel.
|
||||
|
||||
``release preflight`` answers one question with an exit code: is this checkout,
|
||||
this version and these wheels safe to publish? It never publishes anything, and
|
||||
by default it makes no network calls.
|
||||
this version and these wheels safe to publish? It never publishes anything;
|
||||
native verification environments may download dependencies from PyPI.
|
||||
|
||||
Steps, in order:
|
||||
|
||||
@@ -45,9 +45,14 @@ from common.verify.wheel import ( # noqa: E402
|
||||
verify_wheel,
|
||||
)
|
||||
from common.verify.wheel_contents import inspect_wheel # noqa: E402
|
||||
from wheel.layout import ( # noqa: E402
|
||||
resolve_bundle_source_files,
|
||||
verify_required_assets,
|
||||
)
|
||||
|
||||
from release import report as report_mod # noqa: E402
|
||||
from release import version as version_mod # noqa: E402
|
||||
from wheel.layout import resolve_bundle_source_files, verify_required_assets # noqa: E402
|
||||
from release.runtime import verify_manylinux_wheel, wheel_target # noqa: E402
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -407,31 +412,29 @@ class Preflight:
|
||||
return Step("wheel-inspection", not problems, detail, data={"count": len(self.wheels)})
|
||||
|
||||
def _platform_token(self) -> str:
|
||||
return "win_amd64" if sys.platform == "win32" else "manylinux"
|
||||
return "win_amd64" if sys.platform == "win32" else platform.machine().lower()
|
||||
|
||||
def _runtime_targets(self) -> list[tuple[str, Path, Path | None]]:
|
||||
token = self._platform_token()
|
||||
if self.options.all_wheel_pythons:
|
||||
targets: list[tuple[str, Path, Path | None]] = []
|
||||
for tag in ("cp310", "cp311", "cp312"):
|
||||
match = next(
|
||||
(w for w in self.wheels if f"-{tag}-" in w.name and token in w.name), None
|
||||
)
|
||||
if match is None:
|
||||
raise RuntimeError(
|
||||
f"no {tag} {token} wheel among {[w.name for w in self.wheels]}"
|
||||
)
|
||||
executable = version_mod.python_for_tag(tag)
|
||||
if executable is None:
|
||||
for wheel in self.wheels:
|
||||
tag, target = wheel_target(wheel)
|
||||
executable = None
|
||||
if target == "win_amd64":
|
||||
if sys.platform != "win32":
|
||||
raise RuntimeError("Verify Windows wheels from a Windows host")
|
||||
executable = version_mod.python_for_tag(tag)
|
||||
if target == "win_amd64" and executable is None:
|
||||
raise RuntimeError(
|
||||
f"cannot resolve an interpreter for {tag}; create the conda env "
|
||||
f"py{tag[2:]} or set CONDA_ENVS_ROOT"
|
||||
)
|
||||
targets.append((tag, match, executable))
|
||||
targets.append((tag, wheel, executable))
|
||||
return targets
|
||||
|
||||
tag = f"cp{sys.version_info.major}{sys.version_info.minor}"
|
||||
match = next((w for w in self.wheels if f"-{tag}-" in w.name and token in w.name), None)
|
||||
match = next((w for w in self.wheels if wheel_target(w) == (tag, token)), None)
|
||||
if match is None:
|
||||
raise RuntimeError(
|
||||
f"no {tag} {token} wheel among {[w.name for w in self.wheels]}; pass "
|
||||
@@ -471,17 +474,24 @@ class Preflight:
|
||||
for tag, wheel, executable in targets:
|
||||
label = f"{tag} {wheel.name}"
|
||||
try:
|
||||
digest = _sha256_file(wheel)
|
||||
# One venv per wheel: it runs the import smoke test, the bundled
|
||||
# asset check and the frame pipeline together.
|
||||
record = verify_wheel(
|
||||
verifier = verify_wheel
|
||||
extra = {"python_executable": executable, "offline": self.options.offline}
|
||||
if wheel_target(wheel)[1] != "win_amd64":
|
||||
verifier = verify_manylinux_wheel
|
||||
extra = {"repo_root": self.repo_root}
|
||||
record = verifier(
|
||||
wheel,
|
||||
video=video,
|
||||
config_name=self.options.config_name,
|
||||
frames=self.options.frames,
|
||||
python_executable=executable,
|
||||
offline=self.options.offline,
|
||||
check_assets=not self.options.no_assets,
|
||||
**extra,
|
||||
)
|
||||
if _sha256_file(wheel) != digest:
|
||||
raise RuntimeError(f"{wheel.name} changed during runtime verification")
|
||||
except subprocess.CalledProcessError as exc:
|
||||
problems.append(f"{label}: command failed ({exc})")
|
||||
continue
|
||||
@@ -493,7 +503,7 @@ class Preflight:
|
||||
f"{label}: {record['assets']} | frames={record['frames']} "
|
||||
f"flow={record['flow_shape']}"
|
||||
)
|
||||
self.runtime_records.append({"tag": tag, **record})
|
||||
self.runtime_records.append({"tag": tag, **record, "sha256": digest})
|
||||
|
||||
detail = "\n".join(lines)
|
||||
if problems:
|
||||
|
||||
@@ -86,6 +86,17 @@ def evaluate_gate(
|
||||
if not recorded:
|
||||
problems.append("report lists no wheels")
|
||||
|
||||
runtime_records = {}
|
||||
for step in payload.get("steps") or []:
|
||||
if step.get("name") == "wheel-runtime" and step.get("ok"):
|
||||
runtime_records.update({item.get("wheel"): item.get("sha256")
|
||||
for item in (step.get("data") or {}).get("records", [])})
|
||||
uncovered = sorted(name for name, digest in recorded.items()
|
||||
if runtime_records.get(name) != digest)
|
||||
if uncovered:
|
||||
problems.append(f"wheels lack matching runtime verification: {uncovered}; "
|
||||
"re-run preflight --all-wheel-pythons")
|
||||
|
||||
missing = sorted(set(recorded) - set(wheels))
|
||||
if missing:
|
||||
problems.append(f"wheels in the report are missing from disk: {missing}")
|
||||
|
||||
+35
-21
@@ -18,7 +18,7 @@ probe, and ``--apply`` being required even to reach TestPyPI.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -33,8 +33,9 @@ if str(_BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BUILD_ROOT))
|
||||
|
||||
from common.paths import REPO_ROOT # noqa: E402
|
||||
|
||||
from release import auth # noqa: E402
|
||||
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
|
||||
from release.publish import GateResult, PublishGateError, load_gate, sha256_file # noqa: E402
|
||||
|
||||
INDEX_JSON = {name: str(meta["json_url"]) for name, meta in auth.INDEXES.items()}
|
||||
INDEX_SIMPLE = {name: str(meta["simple_url"]) for name, meta in auth.INDEXES.items()}
|
||||
@@ -283,9 +284,10 @@ def _upload(wheels: list[Path], repository: str | None) -> int:
|
||||
|
||||
|
||||
def _fetch_back(index: str, version: str, wheel: Path) -> int:
|
||||
"""Download the freshly uploaded wheel back out of the index."""
|
||||
"""Fetch the exact Python/platform wheel and verify its certified bytes."""
|
||||
with tempfile.TemporaryDirectory(prefix="orisys-fetch-") as tmp:
|
||||
target = Path(tmp) / "out"
|
||||
python_tag, abi_tag, platform_tags = wheel.stem.rsplit("-", 3)[1:]
|
||||
argv = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
@@ -293,18 +295,23 @@ def _fetch_back(index: str, version: str, wheel: Path) -> int:
|
||||
"download",
|
||||
"--no-deps",
|
||||
"--no-cache-dir",
|
||||
"--only-binary=:all:",
|
||||
"--implementation", "cp",
|
||||
"--python-version", python_tag[2:],
|
||||
"--abi", abi_tag,
|
||||
"--index-url",
|
||||
INDEX_SIMPLE[index],
|
||||
"--dest",
|
||||
str(target),
|
||||
f"orisys=={version}",
|
||||
]
|
||||
if index == "testpypi":
|
||||
# TestPyPI mirrors only what was uploaded there; deps live on PyPI.
|
||||
# --no-deps keeps this a pure "is the artifact fetchable" probe.
|
||||
argv += ["--extra-index-url", INDEX_SIMPLE["pypi"]]
|
||||
for platform_tag in platform_tags.split("."):
|
||||
argv += ["--platform", platform_tag]
|
||||
# Index verification must not inherit a fallback index or wheelhouse.
|
||||
env = {key: value for key, value in os.environ.items() if not key.startswith("PIP_")}
|
||||
env["PIP_CONFIG_FILE"] = os.devnull
|
||||
print(f"[run ] {' '.join(argv)}")
|
||||
completed = subprocess.run(argv, check=False)
|
||||
completed = subprocess.run(argv, check=False, env=env)
|
||||
if completed.returncode != 0:
|
||||
return completed.returncode
|
||||
|
||||
@@ -313,11 +320,10 @@ def _fetch_back(index: str, version: str, wheel: Path) -> int:
|
||||
print(f"[FAIL] pip download produced nothing for orisys=={version}")
|
||||
return 1
|
||||
print(f"[ok ] fetched {[p.name for p in fetched]} from {index}")
|
||||
if wheel.name not in {path.name for path in fetched}:
|
||||
print(
|
||||
f"[warn] {wheel.name} was not the file fetched "
|
||||
"(the index may serve a different platform first)"
|
||||
)
|
||||
if {path.name for path in fetched} != {wheel.name}:
|
||||
raise PublishGateError(f"{index} returned a different wheel than {wheel.name}")
|
||||
if sha256_file(fetched[0]) != sha256_file(wheel):
|
||||
raise PublishGateError(f"{index} returned different bytes for {wheel.name}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -436,13 +442,21 @@ def publish(options: Options) -> int:
|
||||
print(f"[ok ] {index} serves all {len(published)} file(s) for {version}")
|
||||
|
||||
if options.verify_install:
|
||||
if _fetch_back(index, version, wheels[0]) != 0:
|
||||
print(
|
||||
f"[warn] {index} artifact is not fetchable yet; the upload is "
|
||||
"published, so this is a propagation delay rather than a failure"
|
||||
)
|
||||
else:
|
||||
print(f"[ok ] fetched the published artifact back from {index}")
|
||||
fetch_pending = False
|
||||
for wheel in wheels:
|
||||
try:
|
||||
if _fetch_back(index, version, wheel) != 0:
|
||||
fetch_pending = True
|
||||
print(f"[warn] {wheel.name} is uploaded but download verification is inconclusive")
|
||||
else:
|
||||
print(f"[ok ] fetched and hash-verified {wheel.name} from {index}")
|
||||
except PublishGateError as exc:
|
||||
print(f"[FAIL] {exc}")
|
||||
return 1
|
||||
if fetch_pending:
|
||||
print(f"[warn] {index} upload succeeded but download verification is inconclusive; "
|
||||
"later indexes were not uploaded. Re-run after checking propagation.")
|
||||
return 0
|
||||
|
||||
if phases == ["testpypi"]:
|
||||
print()
|
||||
@@ -452,7 +466,7 @@ def publish(options: Options) -> int:
|
||||
|
||||
print()
|
||||
if options.pypi_only:
|
||||
print(f"=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
|
||||
print("=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
|
||||
else:
|
||||
print("=== release pypi PASSED (TestPyPI + PyPI) ===")
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Run Linux wheel release checks in the matching manylinux Docker image."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.utils import parse_wheel_filename
|
||||
|
||||
PLATFORM_LABELS = {
|
||||
"win_amd64": "Windows x64",
|
||||
"x86_64": "Linux x86_64",
|
||||
"aarch64": "Linux ARM64",
|
||||
}
|
||||
|
||||
|
||||
def wheel_target(wheel: Path) -> tuple[str, str]:
|
||||
"""Return the CPython tag and supported OS/architecture; reject ambiguity."""
|
||||
_, _, _, tags = parse_wheel_filename(wheel.name)
|
||||
identities = set()
|
||||
for tag in tags:
|
||||
if (
|
||||
tag.interpreter not in {"cp310", "cp311", "cp312"}
|
||||
or tag.abi != tag.interpreter
|
||||
):
|
||||
raise RuntimeError(f"Unsupported Python ABI in {wheel.name}")
|
||||
if tag.platform == "win_amd64":
|
||||
target = "win_amd64"
|
||||
elif tag.platform.startswith("manylinux") and tag.platform.endswith("_aarch64"):
|
||||
target = "aarch64"
|
||||
elif tag.platform.startswith("manylinux") and tag.platform.endswith("_x86_64"):
|
||||
target = "x86_64"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported release platform in {wheel.name}")
|
||||
identities.add((tag.interpreter, target))
|
||||
if len(identities) != 1:
|
||||
raise RuntimeError(f"Ambiguous release platform in {wheel.name}")
|
||||
return identities.pop()
|
||||
|
||||
|
||||
_CONTAINER_CHECK = """
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, '/src/build')
|
||||
from common.verify.wheel import verify_wheel
|
||||
record = verify_wheel(Path(sys.argv[1]), video=Path(sys.argv[2]),
|
||||
config_name=sys.argv[3], frames=int(sys.argv[4]), check_assets=sys.argv[5] == '1')
|
||||
print('ORISYS_RUNTIME_RESULT=' + json.dumps(record))
|
||||
"""
|
||||
|
||||
|
||||
def verify_manylinux_wheel(
|
||||
wheel: Path,
|
||||
*,
|
||||
repo_root: Path,
|
||||
video: Path,
|
||||
config_name: str,
|
||||
frames: int,
|
||||
check_assets: bool = True,
|
||||
) -> dict:
|
||||
tag, architecture = wheel_target(wheel)
|
||||
if architecture == "win_amd64":
|
||||
raise RuntimeError("Windows wheels must be verified with a Windows interpreter")
|
||||
platform = "linux/arm64" if architecture == "aarch64" else "linux/amd64"
|
||||
image = (
|
||||
"orisys-manylinux-aarch64" if architecture == "aarch64" else "orisys-manylinux"
|
||||
)
|
||||
abi = f"{tag}-{tag}"
|
||||
argv = [
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--pull=never",
|
||||
"--platform",
|
||||
platform,
|
||||
"-v",
|
||||
f"{repo_root.resolve()}:/src:ro",
|
||||
"-v",
|
||||
f"{wheel.resolve().parent}:/wheels:ro",
|
||||
"-v",
|
||||
f"{video.resolve().parent}:/video:ro",
|
||||
"-e",
|
||||
"PIP_NO_INDEX=1",
|
||||
"-e",
|
||||
f"PIP_FIND_LINKS=/opt/orisys-wheelhouse/{abi}",
|
||||
"--entrypoint",
|
||||
f"/opt/python/{abi}/bin/python",
|
||||
image,
|
||||
"-c",
|
||||
_CONTAINER_CHECK,
|
||||
f"/wheels/{wheel.name}",
|
||||
f"/video/{video.name}",
|
||||
config_name,
|
||||
str(frames),
|
||||
"1" if check_assets else "0",
|
||||
]
|
||||
print(f" verifying {wheel.name} in {image} ({platform}) ...", flush=True)
|
||||
result = subprocess.run(argv, capture_output=True, text=True, check=False)
|
||||
if result.returncode:
|
||||
raise RuntimeError(
|
||||
f"{image} verification failed:\n{result.stdout}\n{result.stderr}"
|
||||
)
|
||||
marker = "ORISYS_RUNTIME_RESULT="
|
||||
records = [
|
||||
line[len(marker) :]
|
||||
for line in result.stdout.splitlines()
|
||||
if line.startswith(marker)
|
||||
]
|
||||
if len(records) != 1:
|
||||
raise RuntimeError(
|
||||
f"{image} returned no unique verification record:\n{result.stdout}"
|
||||
)
|
||||
record = json.loads(records[0])
|
||||
if record.get("wheel") != wheel.name or record.get("frames") != frames:
|
||||
raise RuntimeError(f"Unexpected verification record for {wheel.name}: {record}")
|
||||
return record
|
||||
+3
-3
@@ -86,11 +86,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not argv or argv[0] in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
print("Commands: wheel | wheel-win | manylinux | gui | verify | release")
|
||||
print("Commands: wheel | wheel-win | wheel-manylinux | gui | verify | release")
|
||||
print("Examples:")
|
||||
print(" python build/run.py wheel --help")
|
||||
print(" python build/run.py gui finger")
|
||||
print(" python build/run.py manylinux -PythonVersions 3.10")
|
||||
print(" python build/run.py wheel-manylinux -Architecture aarch64 -PythonVersions 3.10")
|
||||
print(" python build/run.py release preflight --version <version>")
|
||||
print(" python build/run.py release audit SDK\\<version>\\orisys-<version>-cp312-cp312-win_amd64.whl")
|
||||
return 0
|
||||
@@ -107,7 +107,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if command not in dispatch:
|
||||
print(f"Unknown command: {command!r}", file=sys.stderr)
|
||||
print(
|
||||
"Choose from: wheel, wheel-win, manylinux, gui, verify, release",
|
||||
"Choose from: wheel, wheel-win, wheel-manylinux, gui, verify, release",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
+11
-6
@@ -29,7 +29,13 @@ _BUILD_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_BUILD_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BUILD_ROOT))
|
||||
|
||||
from common.paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repaired_wheel_dir, repo_path
|
||||
from common.paths import (
|
||||
NUITKA_COMPILE_DIR,
|
||||
NUITKA_WHEEL_DIR,
|
||||
REPO_ROOT,
|
||||
repaired_wheel_dir,
|
||||
repo_path,
|
||||
)
|
||||
from common.pyproject_utils import (
|
||||
bundle_items,
|
||||
extras_require,
|
||||
@@ -38,6 +44,7 @@ from common.pyproject_utils import (
|
||||
project_metadata,
|
||||
)
|
||||
from common.verify.wheel import verify_built_wheel
|
||||
|
||||
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
|
||||
from wheel.layout import (
|
||||
copy_bundle_items,
|
||||
@@ -69,7 +76,7 @@ def _validate_pypi_linux_environment() -> None:
|
||||
if sys.platform != "linux":
|
||||
raise SystemExit(
|
||||
"[ERROR] --target pypi-linux must run inside a Linux manylinux container "
|
||||
"(for example quay.io/pypa/manylinux_2_28_x86_64 via Docker Desktop)."
|
||||
"(manylinux_2_28_x86_64 or manylinux_2_28_aarch64 via Docker Desktop)."
|
||||
)
|
||||
if sys.version_info[:2] not in PYPI_LINUX_PYTHON_VERSIONS:
|
||||
supported = ", ".join(f"{major}.{minor}" for major, minor in PYPI_LINUX_PYTHON_VERSIONS)
|
||||
@@ -146,7 +153,7 @@ def build(*, target: str = "internal", verify: bool | None = None) -> Path:
|
||||
if target == "pypi-linux" and not is_compliant_manylinux_wheel(final_candidate):
|
||||
raise SystemExit(
|
||||
f"[ERROR] Wheel is not PyPI-compliant: {final_candidate.name}. "
|
||||
"Expected a manylinux_2_17_x86_64 (or newer) platform tag."
|
||||
"Expected a supported manylinux x86_64 or aarch64 platform tag."
|
||||
)
|
||||
|
||||
# Keep a durable copy under dist/repaired for upload tooling.
|
||||
@@ -199,9 +206,7 @@ def main(argv: list[str] | None = None) -> None:
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
verify: bool | None
|
||||
if args.target == "pypi-linux":
|
||||
verify = True
|
||||
elif args.verify:
|
||||
if args.target == "pypi-linux" or args.verify:
|
||||
verify = True
|
||||
elif args.skip_verify:
|
||||
verify = False
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# manylinux_2_28: modern glibc baseline with binary wheels for numpy/scipy/opencv.
|
||||
# Prefer this over manylinux2014 — recent NumPy requires GCC >= 10.3 and will try
|
||||
# (and fail) to build from source on the older image.
|
||||
FROM quay.io/pypa/manylinux_2_28_x86_64:latest
|
||||
ARG MANYLINUX_ARCH=x86_64
|
||||
FROM quay.io/pypa/manylinux_2_28_${MANYLINUX_ARCH}:latest
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_ROOT_USER_ACTION=ignore \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs inside quay.io/pypa/manylinux_2_28_x86_64 with the repo mounted at /src.
|
||||
# Runs inside the selected manylinux_2_28 image with the repo mounted at /src.
|
||||
# Builds one Nuitka wheel per CPython ABI listed in ORISYS_PYTHON_TAGS.
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
# Build PyPI-compliant manylinux wheels for CPython 3.10/3.11/3.12 using Docker Desktop.
|
||||
# Build x86_64 or aarch64 manylinux wheels using Docker Desktop.
|
||||
#
|
||||
# Usage (from repo root, PowerShell):
|
||||
# .\build\wheel\docker\run.ps1
|
||||
# .\build\build_manylinux_docker.ps1 # compatibility wrapper
|
||||
# .\build\wheel\docker\run.ps1 -Architecture aarch64 -PythonVersions 3.10
|
||||
#
|
||||
# From cmd.exe:
|
||||
# powershell -ExecutionPolicy Bypass -File .\build\build_manylinux_docker.ps1
|
||||
# powershell -ExecutionPolicy Bypass -File .\build\wheel\docker\run.ps1
|
||||
|
||||
param(
|
||||
[ValidateSet("pypi-linux", "internal")]
|
||||
[string]$Target = "pypi-linux",
|
||||
[string]$PythonVersions = "3.10,3.11,3.12",
|
||||
[ValidateSet("x86_64", "aarch64")]
|
||||
[string]$Architecture = "x86_64",
|
||||
[switch]$SkipImageBuild,
|
||||
[string]$ImageName = "orisys-manylinux"
|
||||
[string]$ImageName = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Architecture = $Architecture.ToLowerInvariant()
|
||||
|
||||
function Convert-ToManylinuxTag {
|
||||
param([string]$Version)
|
||||
@@ -45,11 +48,17 @@ if ($tags.Count -eq 0) {
|
||||
throw "No Python versions specified."
|
||||
}
|
||||
$pythonTags = [string]::Join(",", $tags)
|
||||
$dockerArchitecture = if ($Architecture -eq "aarch64") { "arm64" } else { "amd64" }
|
||||
$dockerPlatform = "linux/$dockerArchitecture"
|
||||
if ([string]::IsNullOrWhiteSpace($ImageName)) {
|
||||
$ImageName = if ($Architecture -eq "aarch64") { "orisys-manylinux-aarch64" } else { "orisys-manylinux" }
|
||||
}
|
||||
|
||||
Write-Host "=== Orisys manylinux Docker build ==="
|
||||
Write-Host "Repo: $RepoRoot"
|
||||
Write-Host "Target: $Target"
|
||||
Write-Host "Image: $ImageName"
|
||||
Write-Host "Architecture: $Architecture ($dockerPlatform)"
|
||||
Write-Host "Python tags: $pythonTags"
|
||||
|
||||
# Docker Desktop must be in Linux container mode for manylinux.
|
||||
@@ -71,15 +80,25 @@ $dockerfile = "build/wheel/docker/Dockerfile.manylinux"
|
||||
|
||||
if (-not $SkipImageBuild) {
|
||||
Write-Host "`n=== Building Docker image ==="
|
||||
docker build -f $dockerfile -t $ImageName .
|
||||
docker build --platform $dockerPlatform --build-arg "MANYLINUX_ARCH=$Architecture" -f $dockerfile -t $ImageName .
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "docker build failed"
|
||||
}
|
||||
}
|
||||
|
||||
# A custom or reused image must contain the requested architecture.
|
||||
$imageArchitecture = docker image inspect --format "{{.Architecture}}" $ImageName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Cannot inspect image '$ImageName'. Rebuild without -SkipImageBuild."
|
||||
}
|
||||
if ($imageArchitecture -ne $dockerArchitecture) {
|
||||
throw "Image '$ImageName' architecture is '$imageArchitecture'; expected '$dockerArchitecture'. Rebuild without -SkipImageBuild."
|
||||
}
|
||||
|
||||
Write-Host "`n=== Running manylinux container ==="
|
||||
# Mount the repo so artifacts land under dist/repaired and SDK/<version>/ on the host.
|
||||
docker run --rm `
|
||||
--platform $dockerPlatform `
|
||||
-e "ORISYS_BUILD_TARGET=$Target" `
|
||||
-e "ORISYS_PYTHON_TAGS=$pythonTags" `
|
||||
-v "${RepoRoot}:/src" `
|
||||
|
||||
@@ -16,7 +16,7 @@ from common.paths import sdk_dir
|
||||
|
||||
# PyPI rejects bare tags like "manylinux_x86_64"; require a glibc ABI version.
|
||||
_MANYLINUX_TAG_RE = re.compile(
|
||||
r"(?:manylinux2014|manylinux_2_(?:17|24|27|28|31|34|35))_x86_64"
|
||||
r"(?:manylinux2014|manylinux_2_(?:17|24|27|28|31|34|35))_(?:x86_64|aarch64)(?=\.|$)"
|
||||
)
|
||||
|
||||
# Source of the PyPI project description (see read_long_description).
|
||||
@@ -155,7 +155,7 @@ def _ensure_auditwheel() -> None:
|
||||
|
||||
|
||||
def is_compliant_manylinux_wheel(wheel_path: Path) -> bool:
|
||||
"""True if the wheel filename uses a PyPI-accepted manylinux x86_64 tag."""
|
||||
"""True if the wheel filename uses a supported manylinux x86_64 or aarch64 tag."""
|
||||
return bool(_MANYLINUX_TAG_RE.search(wheel_path.name))
|
||||
|
||||
|
||||
@@ -200,8 +200,8 @@ def repair_linux_wheel(wheel_path: Path, repair_dir: Path) -> Path:
|
||||
names = ", ".join(path.name for path in repaired)
|
||||
raise RuntimeError(
|
||||
"auditwheel repair did not produce a PyPI-compliant manylinux tag. "
|
||||
f"Got: {names}. Rebuild inside quay.io/pypa/manylinux_2_28_x86_64 "
|
||||
"(or manylinux2014_x86_64) instead of a generic Linux host."
|
||||
f"Got: {names}. Rebuild inside the manylinux_2_28 image for the "
|
||||
"target architecture instead of a generic Linux host."
|
||||
)
|
||||
|
||||
final = compliant[0]
|
||||
|
||||
Binary file not shown.
@@ -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_order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
|
||||
* **lowpass_enabled** (`bool`,可选): 显式开启或关闭低通滤波。
|
||||
* **hhd_downsample** (`int`,可选): HHD 解算下采样倍数,需为 `>= 1` 的整数,配置默认 `3`。
|
||||
|
||||
#### 读取结果
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
# 统一力标定模型契约
|
||||
|
||||
Purpose: reference. Scope: SDK 标定文件选择、SN 绑定和推断接口。
|
||||
状态:2026-10-18 已实现 schema 3 的空间/全局增益;ML 后端仅保留契约,尚未实现。
|
||||
保持 `cal_path` 与现有单点文件;新增空间模型和机器学习模型使用同一入口。
|
||||
|
||||
## 现状与边界
|
||||
|
||||
现有实现见 [calibration.py](../src/orisys/calibration.py)、
|
||||
[Sensor](../src/orisys/sensor.py)、[profile resolver](../src/orisys/sensors/calibration.py)。
|
||||
当前读 schema 1/2,按 SN 加载要求三个通道,转换只接收三个力标量。
|
||||
`ForceReadout.apply` 会分别转换滤波前后数据,不能直接挂接带状态的空间或 ML 模型。
|
||||
|
||||
| 所有者 | 责任 |
|
||||
|---|---|
|
||||
| 离线实验 / AutoCal | 采集、训练、验证、导出模型;将实验坐标转换为契约坐标 |
|
||||
| SDK | 选文件、校验身份与输入、准备特征、推断、输出逐轴有效性 |
|
||||
| 应用 | 提供 SN 与文件选择,展示状态;不自己套用第二次增益 |
|
||||
| 设计资产 | 几何与坐标定义;不提供每台设备的实测标定值 |
|
||||
|
||||
SDK 不训练模型、不导入应用代码、不读取训练 CSV,不运行文件内指定的 Python。
|
||||
实验 `finger_multical.experiment_model/1` 必须显式导出,不能直接当 SDK 文件加载。
|
||||
|
||||
## 文件选择:保留路径,文件决定模型
|
||||
|
||||
不增加 `model_type` 构造参数或全局活动模型。`Sensor`、`FingerRuntime.open` 和
|
||||
profile 创建处理器统一复用内部选择器;现有入口参数只映射为选择请求。
|
||||
|
||||
| 请求 | 文件与行为 |
|
||||
|---|---|
|
||||
| 非空 `cal_path` | 精确加载该文件;缺失、错误或不兼容时报错,不换文件 |
|
||||
| `calibration_dir` + `device_id` | 保持 `<dir>/<sensor_type>/<device_id>.json`;缺文件保持 raw 并报告 missing,不回退产品模板 |
|
||||
| 两者都未提供 | 保持产品包默认解析;有 descriptor 时用 `default_calibration`,旧包保留既有候选顺序 |
|
||||
| profile 的 `CalibrationSelection.NONE` | 明确不标定;不赋予 `cal_path="none"` 新语义 |
|
||||
|
||||
`cal_path` 与 `calibration_dir` 仍互斥。构造入口 `device_id` 对应文件 `identity.sn`,
|
||||
不新造另一个 SN 字段。未指定设备的录像可显式选文件,但实际 SN 一致性标记为 unknown。
|
||||
有 `device_id` 时严格匹配 `sensor_type`、`sn`;不根据相机编号猜 SN。
|
||||
保留 `device_id` 单独传入时的参数错误;目录本身不存在也报错,不能当成 SN 文件缺失。
|
||||
|
||||
```python
|
||||
# 沿用现有调用参数;cal_path 可以选择 schema 1/2/3 文件。
|
||||
runtime = FingerRuntime.open(
|
||||
config="finger", video="test.mp4",
|
||||
device_id="DD02-GL-001",
|
||||
cal_path="calibrations/finger/DD02-GL-001.json",
|
||||
)
|
||||
# 归零值来自本次无载窗口,不是模型内固定常量。
|
||||
runtime.set_calibration_reference({"fn": measured_zero_raw}, reference_id="unloaded-1")
|
||||
# 自动按 SN 选择仍使用 calibration_dir="calibrations",不同时传 cal_path。
|
||||
```
|
||||
|
||||
同一 SN 比较不同模型时,显式指定不同 JSON;默认 SN 文件只代表一个选定版本。
|
||||
不扫描目录选“最新模型”,不在每帧重新加载。
|
||||
|
||||
## 最小文件:schema 3
|
||||
|
||||
复用 `force_calibration.json` 文件角色,增加 schema 3;文件名不参与模型类型判断。
|
||||
以下是三点结构示例,数值和 SHA 占位值不是设备标定结果。
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 3,
|
||||
"unit": "N",
|
||||
"identity": {"sensor_type": "finger", "sn": "DD02-GL-001"},
|
||||
"pipeline": {
|
||||
"stitch_resolution": [700, 750],
|
||||
"dis_mode": 4,
|
||||
"hhd_downsample": 3,
|
||||
"source_scale": 0.42,
|
||||
"filters": {"measurement": {"flow_ema_alpha": 1.0}},
|
||||
"feature_signature_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"geometry_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"coordinates": {
|
||||
"frame": "autocal_flat",
|
||||
"unit": "px",
|
||||
"origin_xy": [511, 1021],
|
||||
"map_sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"type": "spatial_gain/1",
|
||||
"input_profile": "fn_delta_autocal_uv/1",
|
||||
"output_frame": "sdk_force/1",
|
||||
"outputs": ["fn"],
|
||||
"params": {
|
||||
"points_uv": [[-145, 604], [145, 604], [5, 430]],
|
||||
"gains_N_per_raw": [0.00015, 0.00020, 0.00025],
|
||||
"triangles": [[0, 1, 2]],
|
||||
"valid_force_range_N": [0.0, 3.0]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 规则 |
|
||||
|---|---|
|
||||
| `schema_version` | 整数 3;未知版本拒绝 |
|
||||
| `unit` | 固定 `N` |
|
||||
| `identity` | `sensor_type`、非空 `sn` 必填;文件位置不能替代身份 |
|
||||
| `pipeline` | 输入 profile 要求的实际处理参数、几何和坐标身份;缺少必需项或不匹配则拒绝 |
|
||||
| `model.type` | SDK 已注册的版本化标识;决定参数解析和推断,不是模块路径 |
|
||||
| `input_profile` | SDK 已实现的特征、单位、形状、预处理和时序契约 |
|
||||
| `output_frame` | `sdk_force/1` 延续对应处理 profile 的 FN/FX/FY 符号和基方向;不是世界坐标合力模长 |
|
||||
| `outputs` | 非空、无重复的 `fn/fx/fy` 子集;支持 FN-only,不冒充三轴标定 |
|
||||
| `params` | 模型类型私有参数;通用加载器不解释点数或网络层 |
|
||||
|
||||
`provenance` 可选,保存数据摘要、训练版本和验证指标;不作为运行时输入。
|
||||
其余未知语义字段拒绝,不能靠忽略字段误读新模型。新增模型类型不必升级外层 schema;
|
||||
修改输入或算法语义必须升级相应 `/1` 标识。
|
||||
|
||||
`geometry_sha256` 是所用计算 mesh 文件字节的 SHA-256;`map_sha256` 是 SDK 坐标映射定义的
|
||||
附件字节摘要,包含坐标转换结果,不能只比较图幅。pipeline 的必需键由输入 profile 固定,
|
||||
覆盖该 backend 所有影响测量的设置;只检查示例中的部分配置不算兼容校验。
|
||||
|
||||
新增 profile 必须定义闭合的签名字段表。首个 profile 的 `feature_signature_sha256` 摘要
|
||||
对象包含 `feature_version`、`processing_backend`、有效的图像预处理/光流/HHD 配置和计算资产哈希;
|
||||
不含文件绝对路径、SN、显示资源或力读数滤波。以 UTF-8、键排序、无多余空格的 JSON 计算 SHA-256。
|
||||
特征定义或单位改变须升级 `feature_version`,配置覆盖后重新计算,不能用配置文件名代替。
|
||||
实现由 `calibration_context.build_context` 统一生成,特征版本为 `orisys_hhd_raw/1`。
|
||||
|
||||
## 空间增益模型
|
||||
|
||||
`spatial_gain/1` 固定输出 `fn`,使用 `fn_delta_autocal_uv/1`:
|
||||
|
||||
- 输入为同一帧的未经过力读数滤波的 FN raw、有效会话零点及接触 U/V。
|
||||
上游光流滤波属于 pipeline 身份;不得混用不同特征链。
|
||||
`delta_raw`、U、V 为有限浮点标量,前者单位 raw,后两者为上述有符号像素。
|
||||
- SDK 会话准备 `delta_raw = fn_raw - zero_raw`,模型不再次扣零。
|
||||
零点必须来自明确的归零操作或已标注窗口;模型文件不保存某段录像的启动零点。
|
||||
- U 向右、V 向上,`x=origin_x+U`、`y=origin_y-V`。
|
||||
SDK 提供并校验坐标转换;不能让模型偷偷按比例换算,不能运行时依赖 AutoCal。
|
||||
- `points_uv` 为 N×2,`gains_N_per_raw` 长度 N;N≥3 且非共线、坐标唯一、增益有限且为正。
|
||||
`triangles` 为任意数量的三索引,必须构成支持点凸包内完整、无重叠的非退化三角网。
|
||||
保存连接关系以避免重新剖分引入变化;点数由数组决定,不固定为 9/15。
|
||||
- 对三角形内查询,`g=Σw_i*g_i`,`fn_N=g*delta_raw`;不插值倒数,不外推。
|
||||
未覆盖、位置失败、归零无效均返回无效,不能偷偷改用 M0。
|
||||
- `valid_force_range_N` 必填。估计超范围时保留数值并标记 `out_of_range`,不截断。
|
||||
|
||||
FingerRuntime 使用 `artifacts` 中 `role="coordinate_map"` 的只读 NPZ:
|
||||
`uv_by_output_px` 为 H×W×2 有符号 UV,`valid_mask` 为 H×W bool;可附 `origin_xy` 与
|
||||
`flat_mapping_sha256`。`pipeline.output_domain_sha256` 绑定实际展开的 remap、mask 和 world_xyz;
|
||||
形状、几何或特征身份不同则在打开视频前拒绝。离线直接提供 UV 的模型可以没有映射附件。
|
||||
|
||||
单点/两点数据不自动切换算法;可导出独立的 `global_gain/1`,参数为各输出轴的增益和有效范围,
|
||||
输入 profile 为 `force_delta/1`,由 SDK 按同一归零语义准备对应 raw 增量。
|
||||
这不同于旧曲线的绝对 raw 输入;兼容适配器不得替旧文件额外扣零。
|
||||
|
||||
## 机器学习扩展
|
||||
|
||||
同一 SN、路径和外层字段不变,只替换 `model`。下面只预留 `onnx/1` 示例,
|
||||
不表示 SDK 已支持 ONNX;后端实现和输入 profile 验证通过前必须拒绝加载。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "onnx/1",
|
||||
"input_profile": "fn_delta_autocal_uv/1",
|
||||
"output_frame": "sdk_force/1",
|
||||
"outputs": ["fn"],
|
||||
"params": {
|
||||
"input_tensor": "features",
|
||||
"output_tensor": "force_N",
|
||||
"feature_order": ["delta_raw", "u", "v"],
|
||||
"dtype": "float32",
|
||||
"shape": [1, 3],
|
||||
"valid_force_range_N": [0.0, 3.0]
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"role": "weights",
|
||||
"path": "DD02-GL-001/model.onnx",
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
此标量 profile 的规范输入即上述顺序、类型、形状;输出 float32 `[1, len(outputs)]`,
|
||||
轴序严格按 `outputs`。归一化及单位转换封装在已验证的模型图内,不由应用另做。
|
||||
`fn_delta_autocal_uv/1` 只允许 `outputs=["fn"]`,不能凭 FN 特征宣称三轴已标定。
|
||||
需要图像、完整光流或历史序列时新增输入 profile,明确 H/W、通道、掩码、单位和时间窗口;
|
||||
没有对应特征的处理器拒绝加载,不能用零张量凑齐输入。
|
||||
|
||||
`artifacts` 只在有外部文件时必填;路径相对所选 JSON 的父目录,禁止绝对路径、URL、
|
||||
`..` 或链接逃逸。校验 SHA-256、角色和文件完整性后才初始化受支持的后端。
|
||||
不加载 pickle、任意 Python 或模型自带扩展库;缺推断依赖须明确报错,不自动安装。
|
||||
文件与所有附件共同构成内容指纹,不能只哈希 JSON。
|
||||
附件间依赖也须逐项声明和校验,未声明的外部权重不可加载。
|
||||
|
||||
## 每帧推断与有效性
|
||||
|
||||
内部统一生命周期如下,`calibration.load` 分流到旧曲线或新模型:
|
||||
|
||||
```text
|
||||
load(selected_file, device_identity, pipeline) -> CalibrationModel
|
||||
model.predict(frame_id, timestamp_s, profile_features, reference_id) -> CalibrationResult
|
||||
model.reset(reference_id)
|
||||
|
||||
CalibrationResult:
|
||||
values_N: {axis: finite_float | null}
|
||||
status: {axis: ok | no_contact | invalid_input | zero_unavailable |
|
||||
coordinate_unmapped | outside_coverage | out_of_range}
|
||||
```
|
||||
|
||||
FN 空间模型输入仅允许单一接触;当前定位器只返回一个中心,无法证明不存在多接触,
|
||||
不能宣称已标定总力。全局 `force_delta/1` 不要求定位,未提供接触状态不等同于无接触。
|
||||
无接触、失败帧的校准输出为空,raw 另行保留;缺失值不是 0 N。
|
||||
`out_of_range` 有数值但不在验证范围,其余失败状态无数值;未声明轴不可用、单位仍为 raw。
|
||||
不把某轴 raw 填进 `values_N`,也不只靠全局 `force_unit` 判断三轴单位。
|
||||
schema 3 的旧数值读数接口以 NaN 表示已声明轴的无效输出,并同步暴露上述状态;
|
||||
`force_calibrated` 表示已加载模型能力,不表示当前帧有效。
|
||||
|
||||
一个模型实例属于一个流;按帧顺序每帧推断一次,读数和录像复用结果。
|
||||
清零、重连、倒播或更换模型时重置状态;不同 SN 不共享可变状态。
|
||||
schema 3 先做上游测量与归零、模型推断,再做有效 N 读数的显示滤波;失败帧不进滤波历史。
|
||||
schema 1/2 仍保留旧滤波/转换顺序。状态模型不能塞入会调用两次的旧 `convert` 回调。
|
||||
|
||||
加载诊断至少包含路径、来源、SN 校验状态、模型/输入 profile、轴集合、内容指纹和失败原因。
|
||||
加载不匹配失败与逐帧暂时无效分开;替换时先完成加载校验,失败不得破坏原活动模型。
|
||||
Sensor 提供 `bind_calibration_context`、`set_calibration_reference`、`apply_calibration_frame`;
|
||||
FingerRuntime 自动绑定真实上下文,并转发归零。`FrameSnapshot.force_units` 和
|
||||
`calibration_status` 随帧保存,不能用稍后读取的 Sensor 状态解释旧帧。
|
||||
归零与换模型须在计算线程或暂停处理时调用,不承诺并发修改安全。
|
||||
|
||||
## 兼容、实现位置与验收
|
||||
|
||||
新读取器并存支持 schema 1/2/3;旧文件不重写,SN 校验的旧三通道要求保持。
|
||||
schema 3 按声明轴校验,允许 FN-only;旧 SDK 遇 schema 3 必须拒绝,不能降级猜读。
|
||||
回退通过恢复旧文件的 `cal_path`;SN 默认文件切换前保留旧副本,不覆盖唯一证据。
|
||||
|
||||
| 修改位置 | 最小工作 |
|
||||
|---|---|
|
||||
| `calibration.py` | schema 分流、身份和附件校验;旧曲线适配器 |
|
||||
| `sensors/calibration.py` | 统一选择器与指纹;支持 SN 请求 |
|
||||
| 新 `calibration_models.py` | 受控类型表、空间增益与模型生命周期;ML 后端另行实现 |
|
||||
| `sensor.py`、`finger/runtime.py`、处理器适配层 | 提供同帧位置/零点,缓存逐轴结果和状态 |
|
||||
| Sensor 与 `ForceReadout` | 复用读数滤波器;schema 3 在推断后逐轴滤波,旧路径不变 |
|
||||
| 离线导出器 | 实验模型到 schema 3;SDK 不依赖实验目录 |
|
||||
|
||||
当前导出入口为 workspace 的 `scripts/finger_multical/export_sdk.py`,输出已有文件时拒绝覆盖。
|
||||
`data/dd02-gl/force_calibration.v3.json` 与同名 `.coordinates.npz` 是本次实验交付,
|
||||
`identity.sn="dd02-gl"` 为实验标识,未从硬件读取;原三边缘点与非单调点警告保留于 provenance。
|
||||
它不是生产包默认文件,需显式 `cal_path` 选择。
|
||||
|
||||
实现验收必须覆盖:旧结果与单位不变;显式路径/SN/默认/关闭选择;错误 SN 不串用;
|
||||
3/14/更多点、退化网格与凸包外;节点和插值预测;零点不重复扣除;缺坐标/无接触;
|
||||
FN-only 的 FX/FY 不冒充 N;未知模型、附件损坏和依赖缺失;状态模型每帧只调用一次及 reset。
|
||||
另以独立录像验证物理准确度,契约或单元测试通过不代表标定合格。
|
||||
@@ -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 (`ruff==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`.
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
# 滤波:三级、预设与可追溯性
|
||||
|
||||
`json.load` 不支持注释(见 `src/orisys/configs.py`),所以"建议的滤波"**不写在 JSON 注释里**,
|
||||
而是写成**代码里的预设**(`src/orisys/sensor.py::FILTER_PRESETS`)+ 这份文档 +
|
||||
`examples/filters/` 里可直接复制的配置。预设是可执行、可回显、可审计的;注释不是。
|
||||
|
||||
---
|
||||
|
||||
## 1. 三级滤波,位置不同、性质不同
|
||||
|
||||
| 级 | 配置键 | 作用位置 | 性质 |
|
||||
|---|---|---|---|
|
||||
| 光流时间 EMA | `flow_ema_alpha` | `compute_flow()` 内,**HHD 之前** | **改变测量**:改 `vnormal`/接触统计与 `_force_raw` 的值。两个 `alpha` 不同的录制**不可比** |
|
||||
| 力中值 | `force_median_window` | `_apply_force_filter()`:raw → 中值 → 低通 → 标定 | **读数滤波**:删除单点尖峰(因果滑动中值) |
|
||||
| 力低通 | `force_lowpass_cutoff_hz` / `_order` / `_sample_rate_hz` | 同上(中值之后) | **读数滤波**:因果 Butterworth IIR(`sosfilt` + 持久状态) |
|
||||
|
||||
因此 `filters_state()` 把它们分成两组:`measurement`(测量身份)与 `readout`(读数滤波)。
|
||||
标定/可比性检查应比对 `measurement`,记录里两者都记。
|
||||
|
||||
## 2. 取值规则(自描述,无隐藏覆盖)
|
||||
|
||||
- **每个级由自己的值开关**:`flow_ema_alpha >= 1`、`force_median_window <= 1`、
|
||||
`force_lowpass_cutoff_hz <= 0` 都是"关"。
|
||||
- **配置没写的键 = 关**:缺省不是"我们觉得合理的那档",而是"不滤波"。因此**没有 `filters`
|
||||
段的配置文件什么都不滤**,出厂 bundle 也把三个值显式写成全关 —— 想让哪一级跑起来,
|
||||
必须在文件里写出来(数值或 `preset`)。这样"文件说的"和"设备跑的"才总是一致。
|
||||
- **非法值 → 关**,绝不回落到"默认开"。
|
||||
- `filters.enabled` 是**废弃别名**,显式 `false` 表示"三级全关";没有任何开关能覆盖已写的值。
|
||||
- `filters.preset` 可引用下面的预设;**显式键优先于预设值**(可从预设出发只改一个旋钮)。
|
||||
- 任何时刻的**真值**是 `Sensor.filters_state()`,不是配置文件 —— 运行时 `configure()` 可以改它。
|
||||
|
||||
## 3. 预设(`FILTER_PRESETS`)
|
||||
|
||||
| 预设 | `flow_ema_alpha` | `force_median_window` | `force_lowpass_cutoff_hz` | 延迟 @30fps | 用途 |
|
||||
|---|---|---|---|---|---|
|
||||
| `off` | 1.0 | 1 | 0.0 | 0 帧 | 原始读数;文件说什么就跑什么 |
|
||||
| `low_latency` | 1.0 | 3 | 0.0 | ≈1 帧(33 ms) | 延迟敏感,只去尖峰 |
|
||||
| `balanced` | 0.5 | 3 | 0.0 | ≈2 帧(67 ms) | 建议起步档:EMA 干掉力级滤波够不到的光流抖动 |
|
||||
| `low_noise` | 0.3 | 3 | 10.0 | ≈3 帧(100 ms) | 底噪优先,读数再低通一道 |
|
||||
|
||||
延迟换算 @60fps 减半;数字来自 `_perf/`(该一次性目录已从 `orisys-sdk` 移除:幸存的脚本与片源
|
||||
迁到 `D:\orisys_ws\docs\performance\perf-measurements\`,其余已永久丢失;对照表见
|
||||
`D:\orisys_ws\docs\performance\orisys-sdk-perf-plan.md`,其中仍保留全部实测数字)。
|
||||
≥1 帧的延迟与相位差是**因果滤波的代价**:旧显示端曾用零相位 `filtfilt`,那需要"未来",
|
||||
无法在采集里做,也让"显示 == 记录"不可能——所以现在只有一条因果链。
|
||||
|
||||
## 4. 怎么用
|
||||
|
||||
```jsonc
|
||||
// sensors/mini/sensor.json(片段;完整可复制版本见 examples/filters/)
|
||||
"filters": { "preset": "balanced" }
|
||||
```
|
||||
```python
|
||||
# 或运行时(UI 的齿轮就走这条路;不重连相机)
|
||||
sensor.configure(flow_ema_alpha=0.5, force_median_window=3, lowpass_cutoff_hz=0.0)
|
||||
print(sensor.filters_state()) # 实际生效值 + preset + applied 标志
|
||||
```
|
||||
UI 想列出可选档位:`from orisys.sensor import available_filter_presets`。
|
||||
|
||||
## 5. 两个 buffer:滤波前 / 滤波后
|
||||
|
||||
```python
|
||||
t = sensor.force_triplets()
|
||||
t["filtered"]["raw"], t["filtered"]["newton"] # 记录与显示用的值(同一套)
|
||||
t["unfiltered"]["raw"], t["unfiltered"]["newton"] # 中值/低通之前;同帧、同标定系数
|
||||
```
|
||||
- `filtered.raw` = 链的输出(标定前),`unfiltered.raw` = 设备计数;
|
||||
- 两个 `newton` 用同一份 `force_calibration.json`,差异**只**来自滤波链;
|
||||
- 实测:注入 `10,10,10,999,10,10`(中值窗口 3)→ `filtered.raw` 在尖峰帧为 `10.0`、
|
||||
`unfiltered.raw` 为 `999.0`。
|
||||
|
||||
## 6. 可追溯性清单
|
||||
|
||||
1. 录制元数据写 `sensor.filters_state()` 的**生效值**(不是配置文件内容),并含 `preset` 与
|
||||
`"source": "sensor.filters_state"` + `sdk_version`。
|
||||
2. 界面显示"实际生效"而不是"我填的值";两者不一致时以实际为准并提示。
|
||||
3. 记录里主列 = 滤波后(与界面一致),未滤波列另存;列结构与格式版本可区分。
|
||||
4. 标定/可比性指纹用 `measurement` 组;`readout` 组变化只记录、不拦截。
|
||||
5. 双份数据在同一帧、同一份标定系数下产生,差异可完全归因于滤波链。
|
||||
|
||||
## 7. 指纹:标定文件里怎么记(已实现)
|
||||
|
||||
`orisys.calibration` 负责把滤波设置写进标定文件的 `meta`,并在读回时还原:
|
||||
|
||||
```python
|
||||
from orisys.calibration import filters_fingerprint, filter_mismatches, pipeline_from_sensor_json
|
||||
|
||||
pipeline = pipeline_from_sensor_json(config_path, filters_state=sensor.filters_state())
|
||||
# {"stitch_resolution": …, "dis_mode": …, "hhd_downsample": …, "config_ref": …,
|
||||
# "filters": {"measurement": {"flow_ema_alpha": 0.5},
|
||||
# "readout": {"force_median_window": 3, "force_lowpass_cutoff_hz": 0.0},
|
||||
# "preset": "balanced", "source": "sensor.filters_state"}}
|
||||
```
|
||||
|
||||
写入的文件长这样(`meta` 里同时保留扁平键供旧读者按名取用):
|
||||
|
||||
```json
|
||||
"meta": {
|
||||
"filters": {
|
||||
"measurement": {"flow_ema_alpha": 0.5},
|
||||
"readout": {"force_median_window": 3, "force_lowpass_cutoff_hz": 0.0},
|
||||
"preset": "balanced",
|
||||
"source": "sensor.filters_state"
|
||||
},
|
||||
"flow_ema_alpha": 0.5,
|
||||
"force_median_window": 3
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **生效值优先**:传了 `filters_state=` 就用它;没传才读文件的 `filters` 段,并标成
|
||||
`"source": "sensor.json.filters"`(那是"意图",不一定等于设备正在做的事)。
|
||||
- **不猜**:来源没写的键**不出现**在指纹里。`{}` 表示"没有任何记录",绝不表示"大概是关的"。
|
||||
旧文档写的扁平键(`meta.flow_ema_alpha` …)在读回时会被折进对应的组,因此仍可比。
|
||||
- **`enabled: false`** 只作为"全部关"的废弃别名保留(`flow_ema_alpha=1`、中值 `1`、低通 `0`)。
|
||||
- **分组判读**:`calibration.check(doc, pipeline=…)` 把两组的差异分开报告 —— measurement 组的
|
||||
差异进 `warnings`(措辞含 `not comparable`,即"两份记录不可比"),readout 组的差异进
|
||||
`notes`(只记录,不拦截)。`CheckResult.notes` 是为此新增的字段。
|
||||
|
||||
@@ -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
|
||||
建议Python 版本: 3.10, 3.11, 3.12
|
||||
|
||||
@@ -43,7 +43,7 @@ mkdir orisys_ws
|
||||
```bash
|
||||
cd "C:\Users\orisys\orisys_ws"
|
||||
```
|
||||
### 安装 CUDA 和 cuDNN(可选)
|
||||
### 安装 CUDA(可选)
|
||||
如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。
|
||||
|
||||
1. 方法一(官网下载):
|
||||
@@ -56,7 +56,7 @@ cd "C:\Users\orisys\orisys_ws"
|
||||
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
|
||||
```
|
||||
|
||||
该方法会自动配置好 CUDA 和 cuDNN,无需手动下载。
|
||||
该方法会自动配置好 CUDA,无需手动下载。
|
||||
|
||||
注意:如果您的计算机没有 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`)
|
||||
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`)
|
||||
示例的完整清单、每个产品对应的入口、全部命令与参数,见 [`examples/README.md`](../examples/README.md)。这里只给第一次运行的最短路径。
|
||||
|
||||
`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
|
||||
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`),可运行:
|
||||
然后运行(假设 ID 为 `0`):
|
||||
|
||||
```bash
|
||||
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||
python examples/basic_pipeline.py -v 0 -c box
|
||||
```
|
||||
|
||||
如果输入来自视频文件,也可以改用:
|
||||
|
||||
```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 视图
|
||||
窗口打开后按 `q` 停止,按 `r` 重设数据。若本地没有示例文件(例如通过 PyPI 安装),从项目链接下载。
|
||||
|
||||
### 开发套件使用方法
|
||||
参考开发套件附带的开发文档。
|
||||
@@ -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,120 @@
|
||||
---
|
||||
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;
|
||||
`wheel-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 wheel-manylinux
|
||||
python build/run.py wheel-manylinux -Architecture aarch64
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Build Windows x64, Linux x86_64 and Linux ARM64 before preparing the release
|
||||
report. With Python 3.10, 3.11 and 3.12 this produces nine wheels. Both publishers
|
||||
use every wheel in `SDK/<version>/`; files added or rebuilt after preflight
|
||||
invalidate its certification. ARM64 CPU wheels do not require CUDA.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
`--all-wheel-pythons` checks every staged ABI and architecture. Run the complete
|
||||
mixed-platform gate from Windows: Windows wheels use the `py310`, `py311` and
|
||||
`py312` conda environments, while Linux wheels use `orisys-manylinux` or
|
||||
`orisys-manylinux-aarch64` with the matching container Python. Docker must already
|
||||
have these locally built images; verification never pulls or rebuilds them.
|
||||
Linux dependencies come from each image's ABI wheelhouse, in an isolated venv.
|
||||
ARM64 verification on Intel/AMD uses QEMU and can take longer. Every wheel must
|
||||
pass imports, bundled-asset loading and the CPU video pipeline; emulation does
|
||||
not certify Q6A or Jetson hardware, GPU or NPU behavior.
|
||||
|
||||
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.
|
||||
The report must also contain successful runtime evidence matching every wheel's
|
||||
hash. Older reports that checked only Windows wheels must be regenerated.
|
||||
|
||||
```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.
|
||||
|
||||
The release order is build all candidates, commit source changes, run the clean
|
||||
preflight, preview both publishers, then execute the authorized publications.
|
||||
GitHub attaches all certified wheels and generates notes naming their actual
|
||||
platforms, including ARM64. PyPI defaults to TestPyPI first; after confirming it,
|
||||
use `--confirm-pypi --apply` for production. Each upload checks the complete
|
||||
filename set and downloads every Python/platform wheel from that index to
|
||||
compare its hash. TestPyPI download verification does not fall back to PyPI.
|
||||
If downloads are not yet available, the command reports the successful upload
|
||||
as unconfirmed and stops before uploading to later indexes; check propagation
|
||||
before rerunning.
|
||||
If a published version has a different file set, the commands stop rather than
|
||||
silently extending or replacing that release.
|
||||
@@ -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
|
||||
|
||||
Runnable demos live at the `examples/` root. Shared helpers stay in
|
||||
`pipeline/`, `viz/`, and `gui/`. Run commands from the repository root.
|
||||
Runnable demos live at the `examples/` root. Shared helpers stay in `pipeline/`, `viz/`, and
|
||||
`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 |
|
||||
|---------|-------|----------------|
|
||||
| ORY-BOX | `basic_pipeline.py` | `box` |
|
||||
| ORY-MINI | `mini_pipeline.py` | `mini` |
|
||||
| ORY-FINGER (2D) | `finger_pipeline.py` | `finger` |
|
||||
| ORY-FINGER (3D Qt) | `finger_pipeline_3dview.py` | `finger` |
|
||||
| ORY-MINI | `basic_pipeline.py -c mini` | `mini` |
|
||||
| ORY-FINGER | `finger_pipeline.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-BOX:
|
||||
|
||||
```bash
|
||||
python examples/basic_pipeline.py -v 0 -c box
|
||||
# ORY-FINGER - the runtime owns camera, Blender export, unroll map and valid mask
|
||||
runtime = FingerRuntime.open(config="finger", video=0)
|
||||
with runtime.pipeline(contact=True) as pipe:
|
||||
while (snap := pipe.wait()) is not None:
|
||||
print(snap.normal_force, snap.shear_x, snap.shear_y)
|
||||
```
|
||||
|
||||
ORY-MINI:
|
||||
|
||||
```bash
|
||||
python examples/mini_pipeline.py -v 0 -c mini
|
||||
```
|
||||
|
||||
ORY-FINGER 2D:
|
||||
`contact=True` runs contact analysis; `include=(...)` chooses which arrays each snapshot carries
|
||||
(`image`, `flow`, `vnormal`, `vshear`, `depth`, `centroid`, `contour`). Arrays you do not include
|
||||
are `None`, so you pay only for what you use. `pipe.reset()` resets the tracker safely from your
|
||||
loop; `snap.image_size_raw` reports the capture resolution before UV unroll or resize.
|
||||
|
||||
### Typical usage
|
||||
|
||||
```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 --video data/dd02-0703/1.mp4
|
||||
```
|
||||
|
||||
ORY-FINGER video file:
|
||||
Keys in both: `q` quits, `r` resets the tracker.
|
||||
|
||||
```bash
|
||||
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
|
||||
## The manual form, for when you need the stages
|
||||
|
||||
`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
|
||||
pip install -e ".[gui]"
|
||||
python examples/finger_pipeline_3dview.py --video 0 --config finger
|
||||
```
|
||||
## Other root examples
|
||||
|
||||
## Other Root Examples
|
||||
|
||||
- `plot_data.py` — single-sensor OpenCV views plus PyQtGraph force charts
|
||||
- `two_sensor_pipeline.py` — two-sensor multiprocessing demo
|
||||
- `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
|
||||
- `finger_pipeline_3dview.py` - the Qt/open3d dashboard; see the note below
|
||||
|
||||
## Requirements
|
||||
|
||||
- SDK installed from the repository root, for example `pip install -e .`
|
||||
- camera index or test video path
|
||||
- matching config names under `./config/` (or packaged `orisys/configs/`)
|
||||
- SDK installed from the repository root: `pip install -e .`
|
||||
- a camera index or a test video path
|
||||
- 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
|
||||
pip install -e ".[gui]"
|
||||
```
|
||||
## The GUI lives here for now
|
||||
|
||||
## 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.
|
||||
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.
|
||||
The practical guidance is only that new SDK work should not build on `examples/gui/`.
|
||||
|
||||
@@ -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()
|
||||
+131
-122
@@ -1,133 +1,142 @@
|
||||
"""
|
||||
ORY-BOX:单传感器基础流程
|
||||
"""ORY-BOX / ORY-MINI: read a sensor and get tactile information, in one loop.
|
||||
|
||||
本示例演示 Orisys SDK 的标准处理流程:
|
||||
1. 初始化传感器(摄像头或视频文件)
|
||||
2. 获取图像并计算形变
|
||||
3. 按需计算接触区域
|
||||
4. 读取并可视化结果
|
||||
The whole recommended flow:
|
||||
|
||||
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 os
|
||||
import sys
|
||||
|
||||
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="标定文件路径")
|
||||
# Set before OpenCV is imported: stops the capture backend applying its own transform on top of
|
||||
# the SDK's preprocessing. This is a capture detail, not a tuning knob you need to own - it is
|
||||
# here so the file can be copied as a whole.
|
||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||
|
||||
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()
|
||||
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:创建传感器对象(必需)
|
||||
# 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:打印关键结果(标定力 + 原始力)
|
||||
# ------------------------------------------------------------------ create the object
|
||||
# That is the whole setup. The source may be a camera index or a video path.
|
||||
source = int(args.video) if args.video.isdigit() else args.video
|
||||
sensor = orisys.Sensor(
|
||||
source, config_name=args.config, cal_path=args.cal,
|
||||
calibration_dir=args.calibration_dir, device_id=args.device_id,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
fn_unit, fx_unit, fy_unit = sensor.force_units
|
||||
selected = sensor.calibration.source_path if sensor.calibration else None
|
||||
print(f"Force calibration: {selected or 'none'}")
|
||||
print(f"Force units: FN={fn_unit}, FX={fx_unit}, FY={fy_unit}")
|
||||
if sensor.device_calibration_missing:
|
||||
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}"
|
||||
f"Warning: no calibration for {args.device_id!r} under "
|
||||
f"{args.calibration_dir}; force is 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() # 释放视频源及相关资源
|
||||
|
||||
# contact=True runs contact analysis. include=... selects which arrays each snapshot carries;
|
||||
# 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"):
|
||||
print("\nExiting.")
|
||||
break
|
||||
if key == ord("r"):
|
||||
# Queued onto the flow stage, so this is safe from the consumer loop.
|
||||
pipe.reset()
|
||||
print("\nTracker reset.")
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
if args.show:
|
||||
cv2.destroyAllWindows()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# 滤波预设:可直接复制的 `filters` 段
|
||||
|
||||
`json.load` 不支持注释,所以这个目录就是"在配置里写说明"的替代品:**把下面任意一段整块替换**
|
||||
到 `sensors/<name>/sensor.json` 的 `"filters"` 段即可。原有的 `filters` 段请整段替换,
|
||||
不要只改其中一行(避免出现 `preset` 与数值混写的歧义——显式键优先于预设值,混写容易看错)。
|
||||
|
||||
配置里**没写**的滤波键等于"关":整段删掉就是什么都不滤,和 `off` 那段等价。
|
||||
|
||||
背景、代价与可追溯性约定见 `docs/filters.md`;延迟/噪声实测见
|
||||
`D:\orisys_ws\docs\performance\orisys-sdk-perf-plan.md`(原 `_perf/` 已从本仓库移除:幸存脚本与
|
||||
片源在 `D:\orisys_ws\docs\performance\perf-measurements\`,该文档保留全部实测数字)。
|
||||
|
||||
下面每一段都有一个同目录的机器可读副本 `filters_<预设名>.json`(四个文件),
|
||||
`tests/test_filter_presets.py` 会核对它们与 `FILTER_PRESETS` 完全一致 —— 所以改预设时
|
||||
这些文件会一起红,不会悄悄过时。
|
||||
|
||||
---
|
||||
|
||||
## `off` —— 原始读数(默认)
|
||||
|
||||
```json
|
||||
"filters": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
## `low_latency` —— 只去尖峰(≈1 帧 @30fps)
|
||||
|
||||
```json
|
||||
"filters": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
## `balanced` —— 建议默认(≈2 帧 @30fps)
|
||||
|
||||
```json
|
||||
"filters": {
|
||||
"flow_ema_alpha": 0.5,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
## `low_noise` —— 底噪优先(≈3 帧 @30fps)
|
||||
|
||||
```json
|
||||
"filters": {
|
||||
"flow_ema_alpha": 0.3,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 10.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
## 用预设名(等价写法,数值由代码展开)
|
||||
|
||||
```json
|
||||
"filters": { "preset": "balanced" }
|
||||
```
|
||||
|
||||
`off / low_latency / balanced / low_noise` 四个名字来自
|
||||
`src/orisys/sensor.py::FILTER_PRESETS`(单一真值);想从预设出发只改一个旋钮,就在同一段里写
|
||||
那一个键,例如:
|
||||
|
||||
```json
|
||||
"filters": { "preset": "balanced", "force_lowpass_cutoff_hz": 10.0 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 复制后请核对"实际生效值"
|
||||
|
||||
配置只是**意图**;`Sensor.configure()` 可以在运行时再改,所以审计与记录一律看生效值:
|
||||
|
||||
```python
|
||||
from orisys.sensor import Sensor
|
||||
s = Sensor("external", config_name="mini", verbose=False) # 无需硬件即可核对
|
||||
print(s.filters_state()["preset"], s.filters_state()["measurement"], s.filters_state()["readout"])
|
||||
```
|
||||
|
||||
`flow_ema_applied` / `force_median_applied` / `force_lowpass_applied` 三个布尔就是
|
||||
"这一级到底有没有在滤"的答案;`flow_ema_alpha` 属于 **measurement**(改变测量,
|
||||
不同值的两次录制不可比),中值与低通属于 **readout**(只影响读数呈现)。
|
||||
|
||||
导出标定/写记录时把同一个状态交给指纹函数,文件里就会留下可核对的设置:
|
||||
|
||||
```python
|
||||
from orisys.calibration import pipeline_from_sensor_json
|
||||
pipeline = pipeline_from_sensor_json("sensors/mini/sensor.json", filters_state=s.filters_state())
|
||||
print(pipeline["filters"]) # measurement / readout / preset / source
|
||||
```
|
||||
|
||||
详见 `docs/filters.md` 第 7 节。
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"flow_ema_alpha": 0.5,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"flow_ema_alpha": 0.3,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 10.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
}
|
||||
+168
-80
@@ -1,112 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ORY-FINGER:Minimum UV-unroll finger tactile pipeline.
|
||||
"""ORY-FINGER: UV-unrolled finger tactile pipeline.
|
||||
|
||||
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
|
||||
Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
|
||||
Same shape as ``basic_pipeline.py`` - one object, one call per frame - with the finger-specific
|
||||
capture path underneath:
|
||||
|
||||
Recommended entry:
|
||||
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||
FingerRuntime -> runtime.pipeline() -> FrameSnapshot per frame
|
||||
|
||||
Current product config: ``finger`` (``dd02`` still resolves to ``dd02-ov``).
|
||||
``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:
|
||||
|
||||
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
|
||||
python examples/finger_pipeline.py --config path/to/sensor.json --video clip.mp4 --cal-path model.json --zero
|
||||
|
||||
Geometry comes from the selected bundle's manifest.json assets_dir; its UV cache follows the
|
||||
geometry directory. Schema 3 requires matching processing config/geometry and a session zero.
|
||||
Use --zero only when the first 30 frames after warmup are known to be unloaded. Without a zero,
|
||||
schema 3 reports zero_unavailable. Use --help-advanced for SN selection and diagnostic overrides.
|
||||
Keys: 'q' quit, 'r' reset the tracker and clear the schema 3 session reference.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||
|
||||
import cv2
|
||||
import orisys
|
||||
from _display import draw_flow_arrows
|
||||
|
||||
from orisys.configs import resolve_sensor_bundle
|
||||
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()
|
||||
def configured_export_dir(config):
|
||||
"""Reuse the selected bundle's geometry, including an explicit sensor.json path."""
|
||||
path = Path(config)
|
||||
bundle = resolve_sensor_bundle(str(path.resolve().parent) if path.is_file() else config)
|
||||
if bundle is None:
|
||||
return None
|
||||
if path.is_file() and bundle.sensor_json != path.resolve():
|
||||
return None
|
||||
if bundle.manifest_json is not None and bundle.assets_dir is None:
|
||||
raise ValueError(f"No usable assets_dir in {bundle.manifest_json}; use --export-dir")
|
||||
return str(bundle.assets_dir) if bundle.assets_dir is not None else None
|
||||
|
||||
|
||||
def measure_unloaded_reference(runtime):
|
||||
"""Measure before pipeline threads start; --zero asserts these frames are unloaded."""
|
||||
tactile = runtime.tactile
|
||||
model = tactile.raw_sensor.calibration
|
||||
if getattr(model, "schema_version", None) != 3:
|
||||
raise ValueError("--zero requires a loaded schema 3 calibration")
|
||||
info = tactile.info
|
||||
axes = [(axis, key) for axis, key in
|
||||
(("fn", info.FNORMAL_RAW), ("fx", info.FSHEARX_RAW), ("fy", info.FSHEARY_RAW))
|
||||
if axis in model.channels]
|
||||
samples = {axis: [] for axis, _ in axes}
|
||||
print("Measuring session zero from 30 unloaded frames...")
|
||||
for _ in range(30):
|
||||
ok, _ = runtime.grab_and_process(color=False)
|
||||
if not ok:
|
||||
raise ValueError("Input ended before 30 unloaded frames; session zero was not set")
|
||||
tactile.compute_deformation()
|
||||
values = tactile.read_info(*(key for _, key in axes))
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
raise ValueError("Non-finite unloaded reading; session zero was not set")
|
||||
for (axis, _), value in zip(axes, values):
|
||||
samples[axis].append(value)
|
||||
runtime.set_calibration_reference(
|
||||
{axis: statistics.median(values) for axis, values in samples.items()},
|
||||
reference_id="startup-unloaded-reference")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
advanced = "--help-advanced" in sys.argv
|
||||
parser = argparse.ArgumentParser(description="UV-unrolled finger tactile pipeline",
|
||||
epilog="Use --help-advanced for SN selection and diagnostic overrides.")
|
||||
parser.add_argument("--help-advanced", action="help", help=argparse.SUPPRESS)
|
||||
def advanced_help(message):
|
||||
return message if advanced else argparse.SUPPRESS
|
||||
parser.add_argument("--camera_id", type=int, default=0, help="camera index (default 0)")
|
||||
parser.add_argument("-v", "--video", help="video file path; overrides --camera_id")
|
||||
parser.add_argument("-c", "--config", default="finger",
|
||||
help="product config name or path (default: finger)")
|
||||
calibration = parser.add_mutually_exclusive_group()
|
||||
calibration.add_argument("--cal-path", "--cal_path", help="explicit calibration JSON (schema 1/2/3)")
|
||||
calibration.add_argument("--calibration-dir", "--calibration_dir",
|
||||
help=advanced_help("select <directory>/<sensor_type>/<device_id>.json"))
|
||||
parser.add_argument("--device-id", "--device_id", help=advanced_help("actual device SN to validate"))
|
||||
zero = parser.add_mutually_exclusive_group()
|
||||
zero.add_argument("--zero", action="store_true",
|
||||
help="measure session zero; first 30 frames after warmup MUST be unloaded")
|
||||
zero.add_argument("--zero-fn-raw", type=float,
|
||||
help=advanced_help("measured unloaded FN raw for this session (schema 3 only)"))
|
||||
parser.add_argument("--export-dir", help=advanced_help("override bundle geometry export directory"))
|
||||
parser.add_argument("--map-cache", help=advanced_help("override UV map base path; SDK appends scale suffix"))
|
||||
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()
|
||||
if args.device_id and not (args.cal_path or args.calibration_dir):
|
||||
parser.error("--device-id requires --cal-path or --calibration-dir")
|
||||
if args.calibration_dir and not args.device_id:
|
||||
parser.error("--calibration-dir requires --device-id")
|
||||
if args.zero_fn_raw is not None and not math.isfinite(args.zero_fn_raw):
|
||||
parser.error("--zero-fn-raw must be finite")
|
||||
|
||||
source = args.video if args.video else args.camera_id
|
||||
|
||||
# ------------------------------------------------------------------ create the object
|
||||
options = {key: getattr(args, key) for key in
|
||||
("cal_path", "calibration_dir", "device_id", "export_dir", "map_cache")
|
||||
if getattr(args, key) is not None}
|
||||
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:
|
||||
if "export_dir" not in options:
|
||||
export_dir = configured_export_dir(args.config)
|
||||
if export_dir is not None:
|
||||
options["export_dir"] = export_dir
|
||||
runtime = FingerRuntime.open(config=args.config, video=source, verbose=args.verbose, **options)
|
||||
except (RuntimeError, ValueError, OSError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return 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} "
|
||||
)
|
||||
g = runtime.geometry
|
||||
print(f"UV geometry: native={g.native_width}x{g.native_height} "
|
||||
f"source={g.source_width}x{g.source_height} "
|
||||
f"output={g.out_width}x{g.out_height}")
|
||||
|
||||
runtime.warmup(color=False)
|
||||
tactile = runtime.tactile
|
||||
if args.show:
|
||||
print("Keys: 'q' quit, 'r' reset tracker and clear session zero.")
|
||||
|
||||
print("Press 'q' to quit, 'r' to reset tracker.")
|
||||
include = ("image", "flow") if args.show else ("image",)
|
||||
try:
|
||||
while True:
|
||||
ok, unrolled = runtime.grab_and_process(color=False)
|
||||
if not ok:
|
||||
break
|
||||
# Warmup may reset the session; apply a supplied reference afterwards and before threads start.
|
||||
runtime.warmup(color=False)
|
||||
if getattr(runtime.tactile.raw_sensor.calibration, "schema_version", None) == 3 and not args.device_id:
|
||||
print("Calibration file selected; hardware SN has not been independently verified.")
|
||||
if args.zero:
|
||||
measure_unloaded_reference(runtime)
|
||||
if args.zero_fn_raw is not None:
|
||||
model = runtime.tactile.raw_sensor.calibration
|
||||
if getattr(model, "schema_version", None) != 3:
|
||||
raise ValueError("--zero-fn-raw requires a loaded schema 3 calibration")
|
||||
runtime.set_calibration_reference({"fn": args.zero_fn_raw}, reference_id="cli-unloaded-reference")
|
||||
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
|
||||
|
||||
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,
|
||||
# ------------------------------------------------------ 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 = snap.force_units or runtime.tactile.force_units
|
||||
status = ",".join(f"{axis}:{value}" for axis, value in (snap.calibration_status or {}).items())
|
||||
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 calibration={status or 'legacy/raw'}",
|
||||
end="\r",
|
||||
)
|
||||
cv2.imshow("UV Unrolled", arrows)
|
||||
|
||||
print(status, 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
|
||||
if key == ord("q"):
|
||||
print("\nExiting.")
|
||||
break
|
||||
if key == ord("r"):
|
||||
tactile.reset()
|
||||
print("\nTracker reset.")
|
||||
key = cv2.waitKey(1) & 0xFF if args.show else -1
|
||||
if key == ord("q"):
|
||||
print("\nExiting.")
|
||||
break
|
||||
if key == ord("r"):
|
||||
pipe.reset()
|
||||
print("\nTracker reset. Schema 3 session zero cleared; a new unloaded reference is required.")
|
||||
except (RuntimeError, ValueError, OSError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
finally:
|
||||
runtime.close()
|
||||
cv2.destroyAllWindows()
|
||||
if args.show:
|
||||
cv2.destroyAllWindows()
|
||||
return 0
|
||||
|
||||
|
||||
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``.
|
||||
"""
|
||||
|
||||
from viz.contact_arrow import ContactArrowStyle
|
||||
|
||||
from .log_config import LOG_CONFIG, LogConfig
|
||||
from .viewer3d import (
|
||||
FingerDashboardWindow,
|
||||
LAYOUT,
|
||||
LOG_PANEL,
|
||||
MESH_CONTACT_ARROW,
|
||||
@@ -19,33 +20,33 @@ from .viewer3d import (
|
||||
PANEL_SETUP,
|
||||
TOP_ROW,
|
||||
DashboardLayout,
|
||||
FingerDashboardWindow,
|
||||
MeshLighting,
|
||||
PanelRow,
|
||||
PanelSetup,
|
||||
PanelSlot,
|
||||
run_dashboard,
|
||||
top_row_splitter_sizes,
|
||||
top_row_slot_index,
|
||||
top_row_splitter_sizes,
|
||||
)
|
||||
from viz.contact_arrow import ContactArrowStyle
|
||||
|
||||
__all__ = [
|
||||
"DashboardLayout",
|
||||
"FingerDashboardWindow",
|
||||
"LAYOUT",
|
||||
"LOG_CONFIG",
|
||||
"LOG_PANEL",
|
||||
"LogConfig",
|
||||
"MESH_CONTACT_ARROW",
|
||||
"MESH_LIGHTING",
|
||||
"ContactArrowStyle",
|
||||
"MeshLighting",
|
||||
"PANEL_SETUP",
|
||||
"TOP_ROW",
|
||||
"ContactArrowStyle",
|
||||
"DashboardLayout",
|
||||
"FingerDashboardWindow",
|
||||
"LogConfig",
|
||||
"MeshLighting",
|
||||
"PanelRow",
|
||||
"PanelSetup",
|
||||
"PanelSlot",
|
||||
"run_dashboard",
|
||||
"top_row_splitter_sizes",
|
||||
"top_row_slot_index",
|
||||
"top_row_splitter_sizes",
|
||||
]
|
||||
|
||||
@@ -34,8 +34,8 @@ def default_config_path() -> str:
|
||||
def require_gui_extras() -> None:
|
||||
try:
|
||||
import open3d # noqa: F401
|
||||
from PyQt5 import QtWidgets # noqa: F401
|
||||
import pyqtgraph # noqa: F401
|
||||
from PyQt5 import QtWidgets # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise SystemExit('GUI example requires: pip install "orisys[gui]"') from exc
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
"""Sensor acquisition adapters for Qt / multi-sensor viewers (no Qt widgets)."""
|
||||
|
||||
from . import roi_geometry
|
||||
from .adapter import (
|
||||
Dd06RoiAdapter,
|
||||
FrameSnapshot,
|
||||
OrisysSensorAdapter,
|
||||
SensorAdapter,
|
||||
_snapshot_from_sensor,
|
||||
calibrate_normal_force,
|
||||
create_sensor_adapter,
|
||||
_snapshot_from_sensor,
|
||||
)
|
||||
from . import roi_geometry
|
||||
|
||||
__all__ = [
|
||||
"Dd06RoiAdapter",
|
||||
"FrameSnapshot",
|
||||
"OrisysSensorAdapter",
|
||||
"SensorAdapter",
|
||||
"_snapshot_from_sensor",
|
||||
"calibrate_normal_force",
|
||||
"create_sensor_adapter",
|
||||
"roi_geometry",
|
||||
"_snapshot_from_sensor",
|
||||
]
|
||||
|
||||
@@ -5,11 +5,11 @@ from __future__ import annotations
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
import orisys
|
||||
|
||||
from .roi_geometry import (
|
||||
@@ -21,7 +21,6 @@ from .roi_geometry import (
|
||||
warp_roi_to_rectangle,
|
||||
)
|
||||
|
||||
|
||||
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
|
||||
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
|
||||
|
||||
@@ -30,10 +29,10 @@ NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
|
||||
class FrameSnapshot:
|
||||
"""Normalized per-frame payload consumed by the Qt viewer."""
|
||||
|
||||
image: Optional[np.ndarray] = None
|
||||
flow: Optional[np.ndarray] = None
|
||||
depth: Optional[np.ndarray] = None
|
||||
contour: Optional[np.ndarray] = None
|
||||
image: np.ndarray | None = None
|
||||
flow: np.ndarray | None = None
|
||||
depth: np.ndarray | None = None
|
||||
contour: np.ndarray | None = None
|
||||
centroid: Any = None
|
||||
# Raw SDK force channels used for curves (same units for FN/FX/FY).
|
||||
normal_force: float = 0.0
|
||||
@@ -43,10 +42,10 @@ class FrameSnapshot:
|
||||
normal_force_calibrated: float = 0.0
|
||||
fps: float = 0.0
|
||||
timestamp: float = 0.0
|
||||
raw_image: Optional[np.ndarray] = None
|
||||
processed_image: Optional[np.ndarray] = None
|
||||
flow_overlay: Optional[np.ndarray] = None
|
||||
record_frame: Optional[np.ndarray] = None
|
||||
raw_image: np.ndarray | None = None
|
||||
processed_image: np.ndarray | None = None
|
||||
flow_overlay: np.ndarray | None = None
|
||||
record_frame: np.ndarray | None = None
|
||||
|
||||
|
||||
def calibrate_normal_force(raw_value: float) -> float:
|
||||
@@ -62,7 +61,7 @@ class SensorAdapter(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def read(self) -> Optional[FrameSnapshot]:
|
||||
def read(self) -> FrameSnapshot | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
@@ -89,7 +88,7 @@ def _snapshot_from_sensor(
|
||||
*,
|
||||
raw_image=None,
|
||||
processed_image=None,
|
||||
) -> Optional[FrameSnapshot]:
|
||||
) -> FrameSnapshot | None:
|
||||
"""Build a FrameSnapshot with HHD/forces refreshed every frame."""
|
||||
if sensor is None or sensor.img is None:
|
||||
return None
|
||||
@@ -186,7 +185,7 @@ class OrisysSensorAdapter(SensorAdapter):
|
||||
verbose=self.verbose,
|
||||
)
|
||||
|
||||
def read(self) -> Optional[FrameSnapshot]:
|
||||
def read(self) -> FrameSnapshot | None:
|
||||
if self.sensor is None:
|
||||
return None
|
||||
img = self.sensor.get_img()
|
||||
@@ -255,7 +254,7 @@ class Dd06RoiAdapter(SensorAdapter):
|
||||
f"video={self._source}"
|
||||
)
|
||||
|
||||
def read(self) -> Optional[FrameSnapshot]:
|
||||
def read(self) -> FrameSnapshot | None:
|
||||
if self.sensor is None or self.cap is None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@ Does not import camera, ROI, or `orisys.Sensor`.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Optional, Sequence, Tuple
|
||||
from typing import Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import orisys
|
||||
from scipy.signal import butter, filtfilt
|
||||
|
||||
import orisys
|
||||
|
||||
FORCE_SAMPLE_RATE_HZ = 30.0
|
||||
PLOT_BUFFER_POINTS = 120
|
||||
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ
|
||||
@@ -40,14 +41,14 @@ class ViewerFrame:
|
||||
image_height: int
|
||||
image_width: int
|
||||
aspect_ratio: float # width / height
|
||||
depth: Optional[np.ndarray]
|
||||
depth: np.ndarray | None
|
||||
normal_force: float
|
||||
shear_x: float
|
||||
shear_y: float
|
||||
normal_force_calibrated: float
|
||||
fps: float
|
||||
timestamp: float
|
||||
record_frame: Optional[np.ndarray]
|
||||
record_frame: np.ndarray | None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -57,8 +58,8 @@ class ForcePlotState:
|
||||
times: np.ndarray # shape (n,)
|
||||
values: np.ndarray # shape (n, 3) FN, FX, FY
|
||||
sample_count: int
|
||||
x_range: Tuple[float, float]
|
||||
y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]
|
||||
x_range: tuple[float, float]
|
||||
y_ranges: tuple[tuple[float, float], tuple[float, float], tuple[float, float]]
|
||||
|
||||
|
||||
def lowpass_filter(
|
||||
@@ -87,7 +88,7 @@ def lowpass_filter(
|
||||
return filtfilt(b, a, y)
|
||||
|
||||
|
||||
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
||||
def ensure_bgr(img: np.ndarray | None) -> np.ndarray | None:
|
||||
if img is None:
|
||||
return None
|
||||
if img.ndim == 2:
|
||||
@@ -97,14 +98,14 @@ def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
||||
return img
|
||||
|
||||
|
||||
def image_shape_meta(img: np.ndarray) -> Tuple[int, int, float]:
|
||||
def image_shape_meta(img: np.ndarray) -> tuple[int, int, float]:
|
||||
"""Return (height, width, width/height) from a display image."""
|
||||
h, w = int(img.shape[0]), int(img.shape[1])
|
||||
aspect = float(w) / float(h) if h > 0 else 1.0
|
||||
return h, w, aspect
|
||||
|
||||
|
||||
def _base_bgr(snapshot) -> Optional[np.ndarray]:
|
||||
def _base_bgr(snapshot) -> np.ndarray | None:
|
||||
for attr in ("image", "processed_image", "raw_image"):
|
||||
out = ensure_bgr(getattr(snapshot, attr, None))
|
||||
if out is not None:
|
||||
@@ -152,7 +153,7 @@ def build_display_image(
|
||||
return select_display_image(snapshot)
|
||||
|
||||
|
||||
def bgr_to_rgb_bytes(img_bgr: np.ndarray) -> Tuple[np.ndarray, int, int]:
|
||||
def bgr_to_rgb_bytes(img_bgr: np.ndarray) -> tuple[np.ndarray, int, int]:
|
||||
"""Convert BGR uint8 image to contiguous RGB for QImage."""
|
||||
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||
if not rgb.flags["C_CONTIGUOUS"]:
|
||||
@@ -231,7 +232,7 @@ class ForceHistory:
|
||||
self.data[-1, 0, :] = t
|
||||
self.data[-1, 1, :] = values
|
||||
|
||||
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> Tuple[float, float]:
|
||||
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> tuple[float, float]:
|
||||
if self._y_range_smooth[channel] is None:
|
||||
smoothed = (ymin, ymax)
|
||||
else:
|
||||
@@ -251,7 +252,7 @@ class ForceHistory:
|
||||
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
|
||||
return smoothed[0] - pad, smoothed[1] + pad
|
||||
|
||||
def plot_state(self) -> Optional[ForcePlotState]:
|
||||
def plot_state(self) -> ForcePlotState | None:
|
||||
n = self.sample_count
|
||||
if n == 0:
|
||||
return None
|
||||
@@ -305,8 +306,8 @@ class QtViewerBackend:
|
||||
lowpass_cutoff_hz=lowpass_cutoff_hz,
|
||||
y_axis_min_span=y_axis_min_span,
|
||||
)
|
||||
self._start_time: Optional[float] = None
|
||||
self.last_viewer_frame: Optional[ViewerFrame] = None
|
||||
self._start_time: float | None = None
|
||||
self.last_viewer_frame: ViewerFrame | None = None
|
||||
self.arrow_background: ArrowBackground = arrow_background
|
||||
|
||||
def set_arrow_background(self, background: ArrowBackground) -> None:
|
||||
@@ -357,7 +358,7 @@ class QtViewerBackend:
|
||||
self._start_time = None
|
||||
self.last_viewer_frame = None
|
||||
|
||||
def ingest(self, snapshot, *, now: Optional[float] = None) -> ViewerFrame:
|
||||
def ingest(self, snapshot, *, now: float | None = None) -> ViewerFrame:
|
||||
"""Convert a FrameSnapshot into ViewerFrame and update force history."""
|
||||
display = build_display_image(snapshot, self.arrow_background)
|
||||
h, w, aspect = image_shape_meta(display)
|
||||
@@ -396,5 +397,5 @@ class QtViewerBackend:
|
||||
self.last_viewer_frame = frame
|
||||
return frame
|
||||
|
||||
def plot_state(self) -> Optional[ForcePlotState]:
|
||||
def plot_state(self) -> ForcePlotState | None:
|
||||
return self.force_history.plot_state()
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from .dashboard import FingerDashboardWindow, run_dashboard
|
||||
from .layout import (
|
||||
LOG_PANEL,
|
||||
LAYOUT,
|
||||
LOG_PANEL,
|
||||
MESH_CONTACT_ARROW,
|
||||
MESH_LIGHTING,
|
||||
PANEL_SETUP,
|
||||
@@ -13,24 +13,24 @@ from .layout import (
|
||||
PanelRow,
|
||||
PanelSetup,
|
||||
PanelSlot,
|
||||
top_row_splitter_sizes,
|
||||
top_row_slot_index,
|
||||
top_row_splitter_sizes,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DashboardLayout",
|
||||
"FingerDashboardWindow",
|
||||
"LAYOUT",
|
||||
"LOG_PANEL",
|
||||
"MESH_CONTACT_ARROW",
|
||||
"MESH_LIGHTING",
|
||||
"MeshLighting",
|
||||
"PANEL_SETUP",
|
||||
"TOP_ROW",
|
||||
"DashboardLayout",
|
||||
"FingerDashboardWindow",
|
||||
"MeshLighting",
|
||||
"PanelRow",
|
||||
"PanelSetup",
|
||||
"PanelSlot",
|
||||
"run_dashboard",
|
||||
"top_row_splitter_sizes",
|
||||
"top_row_slot_index",
|
||||
"top_row_splitter_sizes",
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
@@ -11,6 +12,8 @@ import cv2
|
||||
|
||||
DEFAULT_MAX_CAMERA_INDEX = 10
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraInfo:
|
||||
@@ -62,8 +65,14 @@ def _suppress_opencv_noise():
|
||||
if set_level is not None and prev_level is not None:
|
||||
try:
|
||||
set_level(prev_level)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
# Best-effort: a failure only leaves OpenCV probe noise suppressed,
|
||||
# but report it so a stuck log level is diagnosable.
|
||||
logger.warning(
|
||||
"camera discovery: could not restore OpenCV log level %r: %s",
|
||||
prev_level,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def _backend_candidates() -> list[tuple[str, int]]:
|
||||
@@ -115,9 +124,21 @@ def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]:
|
||||
backends = _backend_candidates()
|
||||
|
||||
with _suppress_opencv_noise():
|
||||
for index in range(0, max_index + 1):
|
||||
for index in range(max_index + 1):
|
||||
for backend_name, backend_api in backends:
|
||||
info = _try_open(index, backend_name, backend_api)
|
||||
try:
|
||||
info = _try_open(index, backend_name, backend_api)
|
||||
except Exception as exc:
|
||||
# Same hazard as the SDK enumerator: an escaping error for one
|
||||
# backend would abort the whole probe and hide every other
|
||||
# camera. Skip that candidate and keep going.
|
||||
logger.warning(
|
||||
"camera discovery: probing index %d via %s failed: %s",
|
||||
index,
|
||||
backend_name,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
if info is None:
|
||||
continue
|
||||
if index in seen_indices:
|
||||
|
||||
@@ -6,6 +6,9 @@ import math
|
||||
import sys
|
||||
import time
|
||||
|
||||
from pipeline.config import PipelineConfig
|
||||
from pipeline.csv_recorder import CsvRecorder
|
||||
from pipeline.service import FingerPipeline
|
||||
from PyQt5.QtCore import Qt, QTimer
|
||||
from PyQt5.QtGui import QIcon
|
||||
from PyQt5.QtWidgets import (
|
||||
@@ -17,23 +20,17 @@ from PyQt5.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from pipeline.config import PipelineConfig
|
||||
from pipeline.csv_recorder import CsvRecorder
|
||||
from pipeline.service import FingerPipeline
|
||||
|
||||
from gui.app_paths import resource_path
|
||||
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
|
||||
|
||||
from .camera_discovery import list_cameras, pick_default_camera
|
||||
from .preferences import get_preferred_video_source, load_preferences, save_preferences
|
||||
from .layout import (
|
||||
DashboardLayout,
|
||||
LAYOUT,
|
||||
PANEL_SETUP,
|
||||
PanelSetup,
|
||||
top_row_splitter_sizes,
|
||||
DashboardLayout,
|
||||
)
|
||||
from .mesh_view_state import MeshViewState
|
||||
from .preferences import get_preferred_video_source, load_preferences, save_preferences
|
||||
from .styles import STYLE
|
||||
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
|
||||
from .widgets import (
|
||||
ArrowPanelWidget,
|
||||
ForceChartWidget,
|
||||
|
||||
@@ -47,11 +47,12 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from gui.log_config import LOG_CONFIG, LogConfig
|
||||
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
|
||||
from viz.contact_arrow import ContactArrowStyle
|
||||
from viz.flow_arrow import FlowArrowStyle
|
||||
from viz.render_2d import ArrowBackground, ArrowCanvasColors
|
||||
|
||||
from gui.log_config import LOG_CONFIG, LogConfig
|
||||
|
||||
PanelId = Literal["mesh", "arrows", "charts", "log", "settings"]
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSignal
|
||||
|
||||
from .layout import DashboardLayout, LAYOUT
|
||||
from .layout import LAYOUT, DashboardLayout
|
||||
|
||||
|
||||
class MeshViewState(QObject):
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
|
||||
from pipeline.frame import FingerFrameView
|
||||
from viz.render_2d import ArrowBackground, ArrowCanvasColors, render_uv_arrows
|
||||
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from .image_panel import ImagePanel
|
||||
from gui.widgets.qt_pixmap import ndarray_to_pixmap
|
||||
|
||||
from ..layout import LAYOUT, DashboardLayout
|
||||
from .image_panel import ImagePanel
|
||||
|
||||
|
||||
class ArrowPanelWidget(ImagePanel):
|
||||
"""UV unrolled flow-arrow panel."""
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from pipeline.frame import FingerFrameView
|
||||
from PyQt5.QtGui import QFont
|
||||
from PyQt5.QtWidgets import QSizePolicy
|
||||
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
|
||||
from pipeline.frame import FingerFrameView
|
||||
from viz.signal_filters import lowpass_filter
|
||||
|
||||
from ..layout import LAYOUT, DashboardLayout
|
||||
from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
|
||||
|
||||
|
||||
def _require_pyqtgraph():
|
||||
try:
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pipeline.frame import FingerFrameView
|
||||
from PyQt5.QtCore import QEvent, Qt
|
||||
from PyQt5.QtGui import QMouseEvent
|
||||
|
||||
from pipeline.frame import FingerFrameView
|
||||
from viz.offscreen_mesh import OffscreenFingerMeshRenderer
|
||||
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from ..mesh_view_state import MeshViewState
|
||||
from ..mesh_render_worker import MeshRenderWorker
|
||||
from gui.widgets.qt_pixmap import ndarray_to_pixmap
|
||||
|
||||
from ..layout import LAYOUT, DashboardLayout
|
||||
from ..mesh_render_worker import MeshRenderWorker
|
||||
from ..mesh_view_state import MeshViewState
|
||||
from .image_panel import ImagePanel
|
||||
|
||||
_VIEW_ROTATE_SENSITIVITY = 0.35
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
||||
from PyQt5.QtCore import QSize, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
@@ -14,11 +14,11 @@ from PyQt5.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
|
||||
from viz.render_2d import ArrowBackground
|
||||
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
|
||||
|
||||
from ..layout import LAYOUT, DashboardLayout
|
||||
from ..mesh_view_state import MeshViewState
|
||||
from ..transport_icons import reset_icon, transport_icon
|
||||
|
||||
|
||||
@@ -9,12 +9,16 @@ ORY-MINI:单传感器基础流程
|
||||
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
import orisys
|
||||
import numpy as np
|
||||
import argparse
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
import orisys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
||||
@@ -29,11 +33,13 @@ def main():
|
||||
# 输入既可以是摄像头编号,也可以是视频文件路径
|
||||
try:
|
||||
input = int(args.video)
|
||||
is_camera = True
|
||||
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
|
||||
except:
|
||||
is_camera = False
|
||||
except ValueError:
|
||||
# Not a camera index: treat the argument as a video-file path. Falling back
|
||||
# only on the conversion (not on Sensor construction) keeps a real
|
||||
# constructor failure visible instead of retrying it as a file path.
|
||||
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
|
||||
else:
|
||||
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
|
||||
|
||||
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
|
||||
|
||||
@@ -89,9 +95,10 @@ def main():
|
||||
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
||||
|
||||
# 步骤 3.4:打印关键结果
|
||||
fn_unit, fx_unit, fy_unit = sensor.force_units
|
||||
print(
|
||||
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
|
||||
f"切向力Y={fsheary:.4f}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
|
||||
f"FPS={fps:.2f}, 法向力={fnormal:.4f} {fn_unit}, 切向力X={fshearx:.4f} {fx_unit}, "
|
||||
f"切向力Y={fsheary:.4f} {fy_unit}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
|
||||
)
|
||||
|
||||
# 步骤 3.5:键盘控制
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Multi-sensor real-time example: N streams, one process each.
|
||||
|
||||
This is the deployment shape the multi-sensor throughput numbers assume. Each
|
||||
stream owns a process and pipelines the per-frame work into three concurrent
|
||||
stages (capture+unroll -> DIS flow -> HHD+readout), so a stream's rate is bounded
|
||||
by its slowest stage instead of the sum of all three.
|
||||
|
||||
# five finger streams from a video file (benchmark / smoke test)
|
||||
python examples/multi_stream.py --streams 5 -c finger -v data/dd02-0703/1.mp4
|
||||
|
||||
# five live cameras, camera ids 0..4
|
||||
python examples/multi_stream.py --streams 5 -c finger --camera-base 0
|
||||
|
||||
# single box sensor
|
||||
python examples/multi_stream.py --streams 1 -c box -v data/long.mp4
|
||||
|
||||
# five streams, additionally recording an arrow-overlay video per stream
|
||||
# plus one 5-up mosaic, without stalling the pipeline
|
||||
python examples/multi_stream.py --streams 5 -c finger -v data/dd02-0703/1.mp4 \
|
||||
--seconds 10 --record outputs/arrows --mosaic
|
||||
|
||||
Notes
|
||||
-----
|
||||
* One process per stream. Threads inside a single process share the GIL and,
|
||||
measured on a 9950X, five finger streams reach ~150 FPS aggregate as threads
|
||||
versus ~626 FPS as processes.
|
||||
* ``cv2.setNumThreads(2)`` per process: at five streams that beats 1, 4 and 8.
|
||||
* Run one stream per process even when the sensor count is small; the process
|
||||
boundary also isolates a crashed camera from the rest.
|
||||
* ``--record`` draws the DIS flow with ``orisys.util.draw_arrows`` (plus the
|
||||
contact overlay with ``--contact``) and encodes it with ``cv2.VideoWriter``.
|
||||
Both happen on a dedicated thread fed by a bounded drop-oldest queue, so the
|
||||
pipeline never waits on the encoder; frames the writer cannot keep up with are
|
||||
counted in the ``rec`` column as ``written/dropped``. Keep ``--record`` for
|
||||
verification runs - the encoder still competes for CPU with the pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import statistics as st
|
||||
import sys
|
||||
import time
|
||||
|
||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||
|
||||
import orisys
|
||||
from orisys import StreamPipeline, StreamRecorder, configs
|
||||
|
||||
|
||||
def _make_stream(args, index):
|
||||
"""Return (sensor, produce, close) for one stream."""
|
||||
source = args.video
|
||||
if args.camera_base is not None:
|
||||
source = int(args.camera_base) + index
|
||||
if source is None:
|
||||
raise ValueError("pass --video PATH or --camera-base ID")
|
||||
|
||||
cfg = configs.load_config(args.config)
|
||||
if str(cfg.get("type", "")).lower() == "single_camera":
|
||||
from orisys.finger import FingerRuntime
|
||||
|
||||
runtime = FingerRuntime.open(config=args.config, video=source,
|
||||
verbose=False)
|
||||
return runtime.tactile.raw_sensor, _finger_produce(runtime), runtime.close
|
||||
|
||||
sensor = orisys.Sensor(source, config_name=args.config, isstitch=True,
|
||||
cuda=False, backend="cpu", verbose=False)
|
||||
|
||||
def produce():
|
||||
return sensor.get_img()
|
||||
|
||||
return sensor, produce, sensor.disconnect
|
||||
|
||||
|
||||
def _finger_produce(runtime):
|
||||
def produce():
|
||||
ok, frame = runtime.grab_and_process(color=False)
|
||||
return frame if ok else None
|
||||
|
||||
return produce
|
||||
|
||||
|
||||
def worker(args, index, out_path):
|
||||
"""One stream: own process, three-stage pipeline."""
|
||||
import cv2
|
||||
|
||||
cv2.setNumThreads(int(args.threads))
|
||||
sensor, produce, close = _make_stream(args, index)
|
||||
|
||||
frames = []
|
||||
stamps = []
|
||||
started = time.perf_counter()
|
||||
|
||||
def collect(snap):
|
||||
frames.append(snap.age_ms)
|
||||
stamps.append(time.perf_counter())
|
||||
|
||||
# Recording is entirely off the hot path: ``on_frame`` only pushes onto a
|
||||
# bounded drop-oldest queue, and a separate thread does the drawing and the
|
||||
# H.264/MPEG-4 encoding. The pipeline never blocks on the encoder.
|
||||
recorder = None
|
||||
on_frame = collect
|
||||
if args.record:
|
||||
recorder = StreamRecorder(
|
||||
os.path.join(args.record, f"stream_{index}.mp4"),
|
||||
fps=args.record_fps,
|
||||
every=args.record_every,
|
||||
fourcc=args.record_fourcc,
|
||||
valid_mask=lambda: getattr(sensor, "valid_mask", None),
|
||||
grid_spacing=args.record_grid,
|
||||
threshold=args.record_threshold,
|
||||
arrow_scale=args.record_arrow_scale,
|
||||
draw_contact=args.contact,
|
||||
label=f"#{index}",
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
def on_frame(snap, _collect=collect, _rec=recorder):
|
||||
_collect(snap)
|
||||
_rec.on_frame(snap)
|
||||
|
||||
if args.no_arrays:
|
||||
include = ()
|
||||
elif recorder is not None:
|
||||
include = ("image", "flow") # what the arrow overlay needs
|
||||
else:
|
||||
include = ("image",)
|
||||
|
||||
pipe = StreamPipeline(sensor, produce, on_frame=on_frame,
|
||||
include=include, contact=args.contact, depth=args.depth)
|
||||
pipe.start()
|
||||
record_stats = None
|
||||
try:
|
||||
deadline = started + args.seconds if args.seconds else None
|
||||
while True:
|
||||
if not pipe.running:
|
||||
break
|
||||
if args.frames and pipe.stats.published >= args.frames:
|
||||
break
|
||||
if deadline is not None and time.perf_counter() >= deadline:
|
||||
break
|
||||
time.sleep(0.002)
|
||||
finally:
|
||||
try:
|
||||
pipe.stop()
|
||||
except BaseException as exc: # surface a stage failure
|
||||
print(f"[stream {index}] pipeline error: {type(exc).__name__}: {exc}",
|
||||
file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
if recorder is not None:
|
||||
record_stats = recorder.close()
|
||||
close()
|
||||
|
||||
wall = time.perf_counter() - started
|
||||
# Steady-state rate: skip the first frames, which pay the JIT / HHD-cache /
|
||||
# DIS-reference-frame warm-up and would drag a short run's average down.
|
||||
warm = min(30, max(0, len(stamps) - 2))
|
||||
span = (stamps[-1] - stamps[warm]) if len(stamps) > warm + 1 else 0.0
|
||||
steady = (len(stamps) - 1 - warm) / span if span > 0 else 0.0
|
||||
payload = {
|
||||
"index": index,
|
||||
"published": pipe.stats.published,
|
||||
"wall": wall,
|
||||
"fps": pipe.stats.published / wall if wall else 0.0,
|
||||
"steady_fps": steady,
|
||||
"stats": pipe.stats.as_dict(),
|
||||
"age_p95_ms": float(st.quantiles(frames, n=20)[18]) if len(frames) >= 20 else 0.0,
|
||||
"record": record_stats,
|
||||
}
|
||||
with open(out_path, "w") as fh:
|
||||
json.dump(payload, fh)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--streams", "-n", type=int, default=5)
|
||||
ap.add_argument("--config", "-c", default="finger")
|
||||
ap.add_argument("--video", "-v", default=None, help="video file shared by all streams")
|
||||
ap.add_argument("--camera-base", type=int, default=None,
|
||||
help="first camera id; stream i uses base + i")
|
||||
ap.add_argument("--frames", type=int, default=0, help="stop after N frames (0 = until EOF)")
|
||||
ap.add_argument("--seconds", type=float, default=10.0, help="max wall time per stream")
|
||||
ap.add_argument("--threads", type=int, default=2, help="cv2.setNumThreads per stream")
|
||||
ap.add_argument("--depth", type=int, default=2, help="queue depth between stages")
|
||||
ap.add_argument("--contact", action="store_true", help="also run compute_contact")
|
||||
ap.add_argument("--no-arrays", action="store_true",
|
||||
help="scalars only (fastest; skips the snapshot image copy)")
|
||||
ap.add_argument("--out-dir", default=None)
|
||||
ap.add_argument("--record", default=None, metavar="DIR",
|
||||
help="write one arrow-overlay mp4 per stream into DIR "
|
||||
"(drawing+encoding run on a separate thread, off the "
|
||||
"pipeline's critical path)")
|
||||
ap.add_argument("--record-fps", type=float, default=30.0,
|
||||
help="playback fps of the recorded files")
|
||||
ap.add_argument("--record-every", type=int, default=1,
|
||||
help="keep one frame in N (raise it if the writer falls behind)")
|
||||
ap.add_argument("--record-fourcc", default="mp4v",
|
||||
help="mp4v (built in) or MJPG/avi for maximum compatibility")
|
||||
ap.add_argument("--record-grid", type=int, default=14, help="arrow grid spacing in px")
|
||||
ap.add_argument("--record-threshold", type=float, default=0.1,
|
||||
help="displacement (px) below which a dot is drawn instead of an arrow")
|
||||
ap.add_argument("--record-arrow-scale", type=float, default=0.15,
|
||||
help="draw_arrows divides the displacement by this; 0.15 magnifies "
|
||||
"the finger's ~1-3 px field ~6.7x so directions are legible")
|
||||
ap.add_argument("--mosaic", action="store_true",
|
||||
help="after the run, tile the recordings into one grid video")
|
||||
ap.add_argument("--mosaic-cols", type=int, default=0, help="mosaic columns (0 = square-ish)")
|
||||
ap.add_argument("--mosaic-scale", type=float, default=1.0, help="mosaic downscale factor")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.record and args.no_arrays:
|
||||
ap.error("--record needs the frame and flow arrays; drop --no-arrays")
|
||||
if args.mosaic and not args.record:
|
||||
ap.error("--mosaic needs --record DIR")
|
||||
|
||||
if args.record:
|
||||
args.record = os.path.abspath(args.record)
|
||||
os.makedirs(args.record, exist_ok=True)
|
||||
|
||||
out_dir = args.out_dir or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "outputs", "multi_stream")
|
||||
out_dir = os.path.abspath(out_dir)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
ctx = mp.get_context("spawn")
|
||||
procs, paths = [], []
|
||||
for i in range(args.streams):
|
||||
path = os.path.join(out_dir, f"stream_{i}.json")
|
||||
paths.append(path)
|
||||
procs.append(ctx.Process(target=worker, args=(args, i, path)))
|
||||
for p in procs:
|
||||
p.start()
|
||||
for p in procs:
|
||||
p.join()
|
||||
|
||||
rows = []
|
||||
for path in paths:
|
||||
try:
|
||||
with open(path) as fh:
|
||||
rows.append(json.load(fh))
|
||||
except Exception: # noqa: S110 - a stream that wrote no result is reported below
|
||||
# A stream that crashed or produced no JSON output is expected here;
|
||||
# the empty-``rows`` check below reports that case explicitly, so this
|
||||
# is not a hidden failure.
|
||||
pass
|
||||
if not rows:
|
||||
print("no stream produced results", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
wall = max(r["wall"] for r in rows)
|
||||
total = sum(r["published"] for r in rows)
|
||||
steady_rows = [r for r in rows if r.get("steady_fps")]
|
||||
steady = st.mean(r["steady_fps"] for r in steady_rows) if steady_rows else 0.0
|
||||
print(f"\nstreams={len(rows)} frames={total} wall={wall:.2f}s")
|
||||
print(f"aggregate throughput : {total / wall:.1f} FPS (incl. warm-up)")
|
||||
print(f"per-stream throughput : {total / wall / len(rows):.1f} FPS (incl. warm-up)")
|
||||
print(f"per-stream steady-state : {steady:.1f} FPS")
|
||||
recording = args.record is not None
|
||||
print(f"\n{'stream':>7}{'published':>11}{'FPS':>9}{'steady':>9}{'produce':>10}"
|
||||
f"{'flow':>9}{'hhd':>9}{'age':>9}{'age p95':>10}"
|
||||
+ (f"{'rec':>13}" if recording else ""))
|
||||
for r in sorted(rows, key=lambda x: x["index"]):
|
||||
s = r["stats"]
|
||||
rec = ""
|
||||
if recording:
|
||||
rs = r.get("record") or {}
|
||||
rec = f"{rs.get('written', 0)}/{rs.get('dropped', 0)}"
|
||||
if rs.get("error"):
|
||||
rec = "ERR"
|
||||
print(f"{r['index']:>7}{r['published']:>11}{r['fps']:>9.1f}"
|
||||
f"{r.get('steady_fps', 0.0):>9.1f}"
|
||||
f"{s['produce_ms']:>10.2f}{s['flow_ms']:>9.2f}{s['hhd_ms']:>9.2f}"
|
||||
f"{s['age_ms']:>9.2f}{r['age_p95_ms']:>10.2f}"
|
||||
+ (f"{rec:>13}" if recording else ""))
|
||||
if recording:
|
||||
print("rec column = frames written / frames dropped by the writer thread")
|
||||
for r in sorted(rows, key=lambda x: x["index"]):
|
||||
rs = r.get("record") or {}
|
||||
if rs.get("error"):
|
||||
print(f" stream {r['index']}: {rs['error']}")
|
||||
|
||||
if args.mosaic and recording:
|
||||
streams = sorted(r["index"] for r in rows)
|
||||
srcs = [os.path.join(args.record, f"stream_{i}.mp4") for i in streams]
|
||||
missing = [p for p in srcs if not os.path.isfile(p)]
|
||||
if missing:
|
||||
print(f"mosaic skipped, missing {missing}", file=sys.stderr)
|
||||
else:
|
||||
out = os.path.join(args.record, "mosaic.mp4")
|
||||
# No tile labels: each recorded frame already carries the "#N" HUD.
|
||||
info = orisys.mosaic_videos(srcs, out, cols=args.mosaic_cols or None,
|
||||
scale=args.mosaic_scale,
|
||||
fps=args.record_fps)
|
||||
print(f"\nmosaic -> {info['path']} {info['size'][0]}x{info['size'][1]} "
|
||||
f"{info['grid'][0]}x{info['grid'][1]} grid {info['frames']} frames "
|
||||
f"@ {info['fps']:g} fps")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
|
||||
from viz.mesh_camera import DEFAULT_FINGER_VIZ_POSE
|
||||
|
||||
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
|
||||
|
||||
|
||||
def mesh_threshold(mesh_map: str, threshold: float | None) -> float:
|
||||
if threshold is not None:
|
||||
@@ -33,7 +34,7 @@ class PipelineConfig:
|
||||
profile_every: int = 30
|
||||
|
||||
@classmethod
|
||||
def from_args(cls, args) -> "PipelineConfig":
|
||||
def from_args(cls, args) -> PipelineConfig:
|
||||
return cls(
|
||||
video=args.video,
|
||||
config=args.config,
|
||||
|
||||
@@ -7,7 +7,6 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CSV_COLUMNS = (
|
||||
"frame_index",
|
||||
"timestamp_s",
|
||||
|
||||
@@ -6,10 +6,10 @@ import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from orisys.finger import FingerRuntime, compute_uv_surface_frames
|
||||
|
||||
from viz import FrameRecorder, build_uv_surface_mesh, resolve_viz_pose_path
|
||||
|
||||
from orisys.finger import FingerRuntime, compute_uv_surface_frames
|
||||
|
||||
from .config import PipelineConfig
|
||||
from .csv_recorder import CsvMeasurement
|
||||
from .frame import FingerFrameView
|
||||
|
||||
@@ -16,10 +16,10 @@ os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import orisys
|
||||
|
||||
from viz import ForceChartWindow, FrameRecorder
|
||||
|
||||
import orisys
|
||||
|
||||
|
||||
def open_sensor(video: str, config: str, cal: str, verbose: bool):
|
||||
try:
|
||||
@@ -65,7 +65,11 @@ def main():
|
||||
sensor.info.VRAW,
|
||||
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)
|
||||
cv2.imshow("形变矢量场", arrows)
|
||||
|
||||
@@ -20,14 +20,17 @@
|
||||
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
import orisys
|
||||
import numpy as np
|
||||
import multiprocessing as mp
|
||||
from multiprocessing import Queue, Process
|
||||
import time
|
||||
import argparse
|
||||
import multiprocessing as mp
|
||||
import queue
|
||||
import time
|
||||
from multiprocessing import Process, Queue
|
||||
|
||||
import cv2
|
||||
|
||||
import orisys
|
||||
|
||||
|
||||
def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, target_fps=None):
|
||||
@@ -55,6 +58,7 @@ def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, tar
|
||||
print(f"传感器 {sensor_id} 已启动" + (f"(目标帧率:{target_fps} FPS)" if target_fps else ""))
|
||||
|
||||
frame_interval = 1.0 / target_fps if target_fps and target_fps > 0 else 0.0
|
||||
dropped_frames = 0
|
||||
|
||||
while not stop_event.is_set():
|
||||
t_start = time.perf_counter()
|
||||
@@ -95,9 +99,10 @@ def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, tar
|
||||
# 步骤 6:将结果发送到主进程(非阻塞)
|
||||
try:
|
||||
result_queue.put_nowait(data)
|
||||
except Exception:
|
||||
# 如果队列已满,则丢弃当前帧,避免阻塞工作进程
|
||||
pass
|
||||
except queue.Full:
|
||||
# 队列已满:丢弃当前帧,避免阻塞工作进程。这是有意的背压策略,
|
||||
# 但丢帧必须可观测,否则主进程看到的低帧率无从解释。
|
||||
dropped_frames += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"传感器 {sensor_id} 处理失败:{e}")
|
||||
@@ -112,7 +117,13 @@ def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, tar
|
||||
|
||||
# 释放资源
|
||||
sensor.disconnect()
|
||||
print(f"传感器 {sensor_id} 已停止。")
|
||||
if dropped_frames:
|
||||
print(
|
||||
f"传感器 {sensor_id} 已停止(因结果队列已满丢弃 {dropped_frames} 帧,"
|
||||
"主进程处理慢于采集)。"
|
||||
)
|
||||
else:
|
||||
print(f"传感器 {sensor_id} 已停止。")
|
||||
|
||||
except Exception as e:
|
||||
print(f"传感器 {sensor_id} 初始化失败:{e}")
|
||||
@@ -193,7 +204,8 @@ def main():
|
||||
while True:
|
||||
try:
|
||||
data = queue.get_nowait()
|
||||
except:
|
||||
except queue.Empty:
|
||||
# 队列已排空:正常结束内层循环,不是错误。
|
||||
break
|
||||
|
||||
if data is not None:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Shared visualization helpers for Orisys example scripts."""
|
||||
|
||||
from .finger_mesh_viewer import FingerMeshViewer
|
||||
from .flow_arrow import DEFAULT_FLOW_ARROW_STYLE, FlowArrowOverlay, FlowArrowStyle
|
||||
from .force_charts import ForceChartWindow
|
||||
from .mesh_camera import (
|
||||
DEFAULT_FINGER_VIZ_POSE,
|
||||
@@ -8,33 +10,32 @@ from .mesh_camera import (
|
||||
camera_look_at_from_world_to_cam,
|
||||
resolve_viz_pose_path,
|
||||
)
|
||||
from .mesh_viewer import UVMeshViewer, build_uv_surface_mesh
|
||||
from .offscreen_mesh import OffscreenFingerMeshRenderer
|
||||
from .recording import FrameRecorder
|
||||
from .render_2d import (
|
||||
DEFAULT_ARROW_CANVAS_COLORS,
|
||||
ArrowBackground,
|
||||
ArrowCanvasColors,
|
||||
DEFAULT_ARROW_CANVAS_COLORS,
|
||||
depth_colormap,
|
||||
frame_to_bgr,
|
||||
render_contact_panel,
|
||||
render_depth_panel,
|
||||
render_uv_arrows,
|
||||
)
|
||||
from .flow_arrow import DEFAULT_FLOW_ARROW_STYLE, FlowArrowOverlay, FlowArrowStyle
|
||||
from .finger_mesh_viewer import FingerMeshViewer
|
||||
from .mesh_viewer import UVMeshViewer, build_uv_surface_mesh
|
||||
from .recording import FrameRecorder
|
||||
from .signal_filters import lowpass_filter
|
||||
|
||||
__all__ = [
|
||||
"ArrowBackground",
|
||||
"ArrowCanvasColors",
|
||||
"DEFAULT_ARROW_CANVAS_COLORS",
|
||||
"DEFAULT_FLOW_ARROW_STYLE",
|
||||
"ArrowBackground",
|
||||
"ArrowCanvasColors",
|
||||
"FingerMeshViewer",
|
||||
"FlowArrowOverlay",
|
||||
"FlowArrowStyle",
|
||||
"FingerMeshViewer",
|
||||
"ForceChartWindow",
|
||||
"FrameRecorder",
|
||||
"OffscreenFingerMeshRenderer",
|
||||
"UVMeshViewer",
|
||||
"apply_mesh_lighting",
|
||||
"apply_view_from_world_to_cam",
|
||||
@@ -43,7 +44,6 @@ __all__ = [
|
||||
"depth_colormap",
|
||||
"frame_to_bgr",
|
||||
"lowpass_filter",
|
||||
"OffscreenFingerMeshRenderer",
|
||||
"render_contact_panel",
|
||||
"render_depth_panel",
|
||||
"render_uv_arrows",
|
||||
|
||||
@@ -8,7 +8,11 @@ import numpy as np
|
||||
|
||||
from orisys.finger.viewer import UVMeshViewer
|
||||
|
||||
from .contact_arrow import ContactArrowOverlay, ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
|
||||
from .contact_arrow import (
|
||||
DEFAULT_CONTACT_ARROW_STYLE,
|
||||
ContactArrowOverlay,
|
||||
ContactArrowStyle,
|
||||
)
|
||||
from .flow_arrow import DEFAULT_FLOW_ARROW_STYLE, FlowArrowOverlay, FlowArrowStyle
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Literal
|
||||
from typing import Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Re-exports for finger 3D mesh examples; core viewer lives in orisys.finger."""
|
||||
|
||||
from orisys.finger.mesh import * # noqa: F403
|
||||
from orisys.finger.viewer import UVMeshViewer, init_uv_mesh_viewer, update_uv_mesh_viewer # noqa: F401
|
||||
from orisys.finger.viewer import ( # noqa: F401
|
||||
UVMeshViewer,
|
||||
init_uv_mesh_viewer,
|
||||
update_uv_mesh_viewer,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
import orisys
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
from scipy.signal import butter, filtfilt
|
||||
|
||||
|
||||
def lowpass_filter(data, cutoff_hz=5, sample_rate_hz=30, order=2) -> np.ndarray:
|
||||
"""Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data."""
|
||||
y = np.asarray(data, dtype=np.float64)
|
||||
|
||||
@@ -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 sys
|
||||
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
|
||||
@@ -9,10 +10,11 @@ _local_src = os.path.join(os.path.dirname(__file__), "src")
|
||||
if _local_src not in sys.path:
|
||||
sys.path.insert(0, _local_src)
|
||||
|
||||
import orisys
|
||||
import numpy as np
|
||||
import time
|
||||
import argparse
|
||||
import time
|
||||
|
||||
import orisys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
@@ -30,11 +32,13 @@ def main():
|
||||
# 方式1: 从视频文件读取
|
||||
try:
|
||||
input = int(args.video)
|
||||
is_camera = True
|
||||
sensor = orisys.Sensor(input, config_name=args.config, cal_path= args.cal, verbose=args.verbose, debug=args.debug)
|
||||
except:
|
||||
is_camera = False
|
||||
except ValueError:
|
||||
# Not a camera index: treat the argument as a video-file path. Only the
|
||||
# conversion is guarded, so a genuine Sensor construction error is not
|
||||
# swallowed and retried as a file path.
|
||||
sensor = orisys.Sensor(args.video, config_name=args.config, cal_path= args.cal, verbose=args.verbose, debug=args.debug)
|
||||
else:
|
||||
sensor = orisys.Sensor(input, config_name=args.config, cal_path= args.cal, verbose=args.verbose, debug=args.debug)
|
||||
# sensor = orisys.Sensor(2, config_name="./config/ddjx01_q6a.json", cal_path= "./config/ddjx01.npy", isstitch=True, cuda=False, verbose=True, backend='cpu')
|
||||
|
||||
# 方式2: 从摄像头读取(取消注释使用)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import os
|
||||
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
import orisys
|
||||
import numpy as np
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
import orisys
|
||||
|
||||
|
||||
def create_plot():
|
||||
p1 = win.addPlot(title="法相力")
|
||||
p1.setLabel('bottom', '时间', units='s')
|
||||
@@ -149,7 +152,7 @@ def main():
|
||||
print("\n 停止程序")
|
||||
break
|
||||
if key == ord("s"):
|
||||
save_path = f"./data/pulse_test.mp4"
|
||||
save_path = "./data/pulse_test.mp4"
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
# Get frame dimensions from the actual frame
|
||||
|
||||
+16
-9
@@ -46,7 +46,7 @@ import os
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from typing import Union
|
||||
|
||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||
|
||||
@@ -60,13 +60,14 @@ except ImportError as exc: # pragma: no cover
|
||||
) from exc
|
||||
|
||||
import cv2
|
||||
|
||||
from orisys.capture import is_live_camera, list_cameras
|
||||
from orisys.finger import FingerRuntime
|
||||
|
||||
Source = Union[int, str]
|
||||
|
||||
|
||||
def _parse_source(token: str) -> Optional[Source]:
|
||||
def _parse_source(token: str) -> Source | None:
|
||||
"""Accept camera index int, numeric string, V4L path, or filesystem path."""
|
||||
text = str(token).strip().strip('"').strip("'")
|
||||
if not text:
|
||||
@@ -116,9 +117,9 @@ class UartFingerController:
|
||||
self.show = show
|
||||
self.verbose = verbose
|
||||
|
||||
self.runtime: Optional[FingerRuntime] = None
|
||||
self._connected_source: Optional[Source] = None
|
||||
self._last_search_source: Optional[Source] = None
|
||||
self.runtime: FingerRuntime | None = None
|
||||
self._connected_source: Source | None = None
|
||||
self._last_search_source: Source | None = None
|
||||
self._streaming = False
|
||||
self._should_quit = False
|
||||
|
||||
@@ -134,14 +135,18 @@ class UartFingerController:
|
||||
if self.runtime is not None:
|
||||
try:
|
||||
self.runtime.close()
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001, S110 - teardown must not raise
|
||||
# Best-effort release: a close failure here has no recovery path in
|
||||
# a shutdown request handler, and raising would skip the reset below.
|
||||
pass
|
||||
self.runtime = None
|
||||
self._connected_source = None
|
||||
if self.show:
|
||||
try:
|
||||
cv2.destroyAllWindows()
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001, S110 - no GUI window to destroy
|
||||
# Expected when no window was ever created (headless/--no-show run):
|
||||
# this is a normal no-op, not a hidden failure.
|
||||
pass
|
||||
|
||||
def _resolve_connect_source(self, args: list[str]) -> Source:
|
||||
@@ -220,7 +225,7 @@ class UartFingerController:
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
def _poll_command_line(self, *, blocking: bool = True) -> Optional[str]:
|
||||
def _poll_command_line(self, *, blocking: bool = True) -> str | None:
|
||||
"""Read one command line if available; return None on timeout/empty."""
|
||||
if not blocking and self.ser.in_waiting <= 0:
|
||||
return None
|
||||
@@ -447,7 +452,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print("\nInterrupted.", flush=True)
|
||||
try:
|
||||
controller.reply("OK quit")
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001, S110 - peer may already be gone
|
||||
# We are already shutting down on Ctrl-C; failing to send the final
|
||||
# acknowledgement (peer closed the port) must not mask the exit path.
|
||||
pass
|
||||
finally:
|
||||
controller.close_runtime()
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Measure normal-force zero-point drift, live or from a recording.
|
||||
|
||||
Phase 0 of the zero-point plan: quantify the drift before changing the pipeline.
|
||||
The number this prints is what the acceptance thresholds get set from, so it is
|
||||
deliberately explicit about which rows it used and what it could not conclude.
|
||||
|
||||
Usage
|
||||
-----
|
||||
Live capture (needs a sensor; writes a recording directory when --out is given):
|
||||
|
||||
python scripts/measure_zero_drift.py --live --seconds 1800 --out D:\\tmp\\idle
|
||||
|
||||
From an existing recording (the directory holding forces.csv):
|
||||
|
||||
python scripts/measure_zero_drift.py --recording <dir> --unit both
|
||||
python scripts/measure_zero_drift.py --recording <dir> --warmup 5 --start 10 --stop 60
|
||||
|
||||
What it reports
|
||||
---------------
|
||||
Per axis, over the analysed window:
|
||||
|
||||
* mean / median / stdev / peak-to-peak, in raw counts and (with --unit N) newtons
|
||||
* a least-squares slope in counts/s -- the ramp that "zero drift" actually means
|
||||
* a drift ratio (end-third mean minus start-third mean), which is insensitive to
|
||||
whether the drift is linear or a slow wander
|
||||
* the band left after removing the window mean, i.e. what a perfect single-point
|
||||
re-zero would achieve -- this separates "the zero moved" from "the noise is wide"
|
||||
|
||||
The raw columns are ``FN_raw, FX_raw, FY_raw`` and the calibrated ones are
|
||||
``FN_N, FX_N, FY_N`` (see ``FORCES_HEADER`` in
|
||||
``orisys_app_shared.record.experiment``); rows with ``valid != 1`` are excluded
|
||||
rather than read as zeros.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
RAW_AXES = ("FN_raw", "FX_raw", "FY_raw")
|
||||
NEWTON_AXES = ("FN_N", "FX_N", "FY_N")
|
||||
TIME_COLUMN = "t_rel_s"
|
||||
VALID_COLUMN = "valid"
|
||||
|
||||
|
||||
def _load_rows(source: Path) -> list[dict[str, str]]:
|
||||
path = source / "forces.csv" if source.is_dir() else source
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"no forces.csv at {path}")
|
||||
with path.open(encoding="utf-8", newline="") as handle:
|
||||
return list(csv.DictReader(handle))
|
||||
|
||||
|
||||
def _column(rows: Sequence[dict[str, str]], name: str) -> np.ndarray:
|
||||
values = []
|
||||
for row in rows:
|
||||
raw = row.get(name)
|
||||
try:
|
||||
values.append(float(raw)) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
values.append(np.nan)
|
||||
return np.asarray(values, dtype=np.float64)
|
||||
|
||||
|
||||
def _window(
|
||||
rows: list[dict[str, str]],
|
||||
*,
|
||||
start: float | None,
|
||||
stop: float | None,
|
||||
warmup: float,
|
||||
) -> tuple[np.ndarray, list[dict[str, str]]]:
|
||||
times = _column(rows, TIME_COLUMN)
|
||||
valid = _column(rows, VALID_COLUMN)
|
||||
mask = np.ones(times.shape, dtype=bool)
|
||||
if times.size:
|
||||
mask &= ~np.isnan(times) # drop NaN timestamps (unparsable TIME cells)
|
||||
if start is not None:
|
||||
mask &= times >= float(start)
|
||||
if stop is not None:
|
||||
mask &= times <= float(stop)
|
||||
# ``valid`` is authoritative: an invalid row is not a zero reading.
|
||||
mask &= (valid == 1) | np.isnan(valid)
|
||||
kept = [row for row, keep in zip(rows, mask) if keep]
|
||||
if not kept:
|
||||
return np.asarray([], dtype=np.float64), []
|
||||
times = np.asarray([float(row[TIME_COLUMN]) for row in kept], dtype=np.float64)
|
||||
if warmup > 0 and times.size:
|
||||
t0 = times[0]
|
||||
kept = [row for row in kept if float(row[TIME_COLUMN]) - t0 >= warmup]
|
||||
times = np.asarray([float(row[TIME_COLUMN]) for row in kept], dtype=np.float64)
|
||||
return times, kept
|
||||
|
||||
|
||||
def _describe(label: str, values: np.ndarray, times: np.ndarray) -> dict[str, float]:
|
||||
finite = values[np.isfinite(values)]
|
||||
if finite.size == 0:
|
||||
return {"axis": label, "n": 0}
|
||||
mean = float(finite.mean())
|
||||
median = float(np.median(finite))
|
||||
# A min-to-max span does not shrink when a constant is removed, so it cannot
|
||||
# answer "what would a re-zero achieve". The robust band and the inter-
|
||||
# percentile width can, and they are what the noise is measured against in
|
||||
# the existing perf analysis.
|
||||
centred = finite - median
|
||||
band_1sigma = float(
|
||||
(np.percentile(centred, 84.1) - np.percentile(centred, 15.9)) / 2.0
|
||||
)
|
||||
width_99 = float(np.percentile(finite, 99.5) - np.percentile(finite, 0.5))
|
||||
slope = float("nan")
|
||||
if times.size == finite.size and times.size > 2 and float(times[-1] - times[0]) > 0:
|
||||
slope = float(np.polyfit(times - times[0], finite, 1)[0])
|
||||
third = max(1, finite.size // 3)
|
||||
drift_ratio = float(finite[-third:].mean() - finite[:third].mean())
|
||||
return {
|
||||
"axis": label,
|
||||
"n": int(finite.size),
|
||||
"mean": mean,
|
||||
"median": median,
|
||||
"stdev": float(finite.std(ddof=0)),
|
||||
"p2p": float(finite.max() - finite.min()),
|
||||
"band_1sigma": band_1sigma,
|
||||
"width_99": width_99,
|
||||
"slope_per_s": slope,
|
||||
"drift_ratio": drift_ratio,
|
||||
}
|
||||
|
||||
|
||||
def _print_table(title: str, stats: list[dict[str, float]], unit: str) -> None:
|
||||
usable = [s for s in stats if s.get("n")]
|
||||
print(f"\n=== {title} ===")
|
||||
if not usable:
|
||||
print(" no rows in the analysed window")
|
||||
return
|
||||
print(
|
||||
f" {'axis':<8} {'n':>7} {'mean':>13} {'median':>13} {'stdev':>12} "
|
||||
f"{'band1s':>11} {'w99':>11} {'slope/s':>11} {'drift':>11} [{unit}]"
|
||||
)
|
||||
for s in usable:
|
||||
print(
|
||||
f" {s['axis']:<8} {s['n']:>7} {s['mean']:>13.3f} {s['median']:>13.3f} "
|
||||
f"{s['stdev']:>12.3f} {s['band_1sigma']:>11.3f} {s['width_99']:>11.3f} "
|
||||
f"{s['slope_per_s']:>11.4f} {s['drift_ratio']:>11.3f}"
|
||||
)
|
||||
print(
|
||||
" slope/s = least-squares ramp (the drift rate)\n"
|
||||
" band1s = robust 1-sigma half-width about the median\n"
|
||||
" w99 = 0.5-99.5 percentile width (tail-resistant spread)\n"
|
||||
" drift = mean(last third) - mean(first third): moved, not just noisy"
|
||||
)
|
||||
|
||||
|
||||
def _analyse(rows: list[dict[str, str]], args: argparse.Namespace) -> int:
|
||||
times, kept = _window(
|
||||
rows, start=args.start, stop=args.stop, warmup=args.warmup
|
||||
)
|
||||
print(f"recording rows : {len(rows)}")
|
||||
print(f"analysed rows : {len(kept)}")
|
||||
if times.size:
|
||||
dt = np.diff(times)
|
||||
print(
|
||||
f"analysed window : {times[0]:.3f} .. {times[-1]:.3f} s "
|
||||
f"({times[-1] - times[0]:.3f} s)"
|
||||
)
|
||||
if dt.size and dt[dt > 0].size:
|
||||
rate = 1.0 / float(np.median(dt[dt > 0]))
|
||||
print(f"effective sample rate : {rate:.2f} Hz")
|
||||
if len(kept) < 30:
|
||||
print(
|
||||
"\nNOTE: fewer than 30 analysed rows. This cannot characterise a slow\n"
|
||||
" zero drift -- capture an idle run of at least several minutes."
|
||||
)
|
||||
|
||||
if args.unit in ("raw", "both"):
|
||||
_print_table(
|
||||
"raw counts (pre-calibration)",
|
||||
[_describe(axis, _column(kept, axis), times) for axis in RAW_AXES],
|
||||
"counts",
|
||||
)
|
||||
if args.unit in ("N", "both"):
|
||||
_print_table(
|
||||
"newtons (what the operator sees)",
|
||||
[_describe(axis, _column(kept, axis), times) for axis in NEWTON_AXES],
|
||||
"N",
|
||||
)
|
||||
|
||||
if args.out:
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(
|
||||
["axis", "unit", "n", "mean", "median", "stdev", "band_1sigma",
|
||||
"width_99", "slope_per_s", "drift_ratio"]
|
||||
)
|
||||
groups = []
|
||||
if args.unit in ("raw", "both"):
|
||||
groups.append((RAW_AXES, "counts"))
|
||||
if args.unit in ("N", "both"):
|
||||
groups.append((NEWTON_AXES, "N"))
|
||||
for axes, unit in groups:
|
||||
for axis in axes:
|
||||
s = _describe(axis, _column(kept, axis), times)
|
||||
if not s.get("n"):
|
||||
continue
|
||||
writer.writerow([
|
||||
s["axis"], unit, s["n"], f"{s['mean']:.6f}",
|
||||
f"{s['median']:.6f}", f"{s['stdev']:.6f}",
|
||||
f"{s['band_1sigma']:.6f}", f"{s['width_99']:.6f}",
|
||||
f"{s['slope_per_s']:.6f}", f"{s['drift_ratio']:.6f}",
|
||||
])
|
||||
print(f"\nwrote {out}")
|
||||
return 0
|
||||
|
||||
|
||||
def _capture_live(args: argparse.Namespace) -> int:
|
||||
"""Sample a real sensor with no load applied.
|
||||
|
||||
Uses only the public ``read_info``/``disconnect`` surface, so this keeps
|
||||
working if the internals move.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
from orisys import Sensor
|
||||
|
||||
sensor = Sensor(
|
||||
"external",
|
||||
config_name=args.config,
|
||||
verbose=args.verbose,
|
||||
backend=args.backend,
|
||||
)
|
||||
seconds = float(args.seconds)
|
||||
period = 1.0 / float(args.sample_rate)
|
||||
rows: list[tuple[float, float, float, float, float, float, float]] = []
|
||||
try:
|
||||
print(
|
||||
f"capturing {seconds:.0f}s at ~{args.sample_rate:.0f} Hz from config "
|
||||
f"{args.config!r} ({args.backend}); keep the sensor unloaded"
|
||||
)
|
||||
t0 = _time.monotonic()
|
||||
while True:
|
||||
elapsed = _time.monotonic() - t0
|
||||
if elapsed >= seconds:
|
||||
break
|
||||
raw = sensor.read_info(
|
||||
sensor.info.FNORMAL_RAW,
|
||||
sensor.info.FSHEARX_RAW,
|
||||
sensor.info.FSHEARY_RAW,
|
||||
)
|
||||
newtons = sensor.read_info(
|
||||
sensor.info.FNORMAL,
|
||||
sensor.info.FSHEARX,
|
||||
sensor.info.FSHEARY,
|
||||
)
|
||||
rows.append((elapsed, *[float(v) for v in raw], *[float(v) for v in newtons]))
|
||||
_time.sleep(max(0.0, period - ((_time.monotonic() - t0) - elapsed)))
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
if not rows:
|
||||
print("no samples captured (external-frame mode may need a frame source)")
|
||||
return 1
|
||||
|
||||
times = np.asarray([row[0] for row in rows], dtype=np.float64)
|
||||
print(f"\ncaptured {len(rows)} samples over {times[-1]:.1f}s")
|
||||
|
||||
if args.out:
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
path = out / "forces.csv"
|
||||
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(
|
||||
["session_id", "frame_id", TIME_COLUMN, *RAW_AXES, *NEWTON_AXES,
|
||||
VALID_COLUMN, "calibration_id"]
|
||||
)
|
||||
for index, row in enumerate(rows):
|
||||
t_rel, *rest = row
|
||||
writer.writerow(
|
||||
[0, index, f"{t_rel:.6f}", *[f"{v:.6f}" for v in rest], 1, ""]
|
||||
)
|
||||
print(f"wrote {path}")
|
||||
return 0
|
||||
|
||||
_print_table(
|
||||
"live raw counts",
|
||||
[
|
||||
_describe(axis, np.asarray([row[1 + i] for row in rows]), times)
|
||||
for i, axis in enumerate(RAW_AXES)
|
||||
],
|
||||
"counts",
|
||||
)
|
||||
_print_table(
|
||||
"live newtons",
|
||||
[
|
||||
_describe(axis, np.asarray([row[4 + i] for row in rows]), times)
|
||||
for i, axis in enumerate(NEWTON_AXES)
|
||||
],
|
||||
"N",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Measure normal-force zero-point drift (Phase 0)."
|
||||
)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--recording", type=Path,
|
||||
help="recording directory or forces.csv path")
|
||||
source.add_argument("--live", action="store_true",
|
||||
help="capture from a Sensor instead (external-frame mode)")
|
||||
parser.add_argument("--seconds", type=float, default=1800.0,
|
||||
help="live capture length (default 1800)")
|
||||
parser.add_argument("--sample-rate", type=float, default=30.0,
|
||||
help="live polling rate Hz (default 30)")
|
||||
parser.add_argument("--config", default="box", help="sensor config for --live")
|
||||
parser.add_argument("--backend", default="cpu", help="poisson backend for --live")
|
||||
parser.add_argument("--unit", choices=("raw", "N", "both"), default="both")
|
||||
parser.add_argument("--start", type=float, default=None, help="window start (s)")
|
||||
parser.add_argument("--stop", type=float, default=None, help="window stop (s)")
|
||||
parser.add_argument("--warmup", type=float, default=0.0,
|
||||
help="discard this many seconds from the window start")
|
||||
parser.add_argument("--out", default=None,
|
||||
help="write the per-axis table to this CSV path, or the "
|
||||
"capture directory when used with --live")
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.live:
|
||||
return _capture_live(args)
|
||||
return _analyse(_load_rows(args.recording), args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,14 +1,18 @@
|
||||
import os
|
||||
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
import cv2
|
||||
import orisys
|
||||
import numpy as np
|
||||
import time
|
||||
import argparse
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtGui import QImage, QFont
|
||||
from PyQt5.QtGui import QFont, QImage
|
||||
from pyqtgraph.Qt import QtGui
|
||||
|
||||
import orisys
|
||||
|
||||
|
||||
def make_gradient_brush(color_hex, max_alpha=150):
|
||||
"""
|
||||
创建一个从上到下渐变的笔刷 (兼容 PyQt5 和 PyQt6)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"unit": "N",
|
||||
"channels": {
|
||||
"normal": {
|
||||
@@ -14,8 +14,22 @@
|
||||
[62000.0, 2.377]
|
||||
]
|
||||
},
|
||||
"shear": {
|
||||
"shear_x": {
|
||||
"method": "piecewise_linear",
|
||||
"raw_key": "fshearx",
|
||||
"input": "components",
|
||||
"signed": true,
|
||||
"extrapolate": true,
|
||||
"clamp_output_min": 0.0,
|
||||
"points": [
|
||||
[1000.0, 0.0],
|
||||
[36000.0, 1.19],
|
||||
[68000.0, 2.06]
|
||||
]
|
||||
},
|
||||
"shear_y": {
|
||||
"method": "piecewise_linear",
|
||||
"raw_key": "fsheary",
|
||||
"input": "components",
|
||||
"signed": true,
|
||||
"extrapolate": true,
|
||||
|
||||
@@ -33,10 +33,17 @@
|
||||
"algorithm_list": ["raft", "dis"],
|
||||
"hhd_downsample": 3
|
||||
},
|
||||
"filters": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
"flow_threshold":5,
|
||||
"div_threshold":0.05,
|
||||
"flow_threshold":0.2,
|
||||
"div_threshold":0.007,
|
||||
"expansion_threshold": 0.01,
|
||||
"kernel_size": [5,5],
|
||||
"denoise_sigma": 12
|
||||
|
||||
Binary file not shown.
@@ -22,12 +22,19 @@
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 1,
|
||||
"algorithm_list": ["raft", "dis"],
|
||||
"hhd_downsample": 3
|
||||
"hhd_downsample": 2
|
||||
},
|
||||
"filters": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
"flow_threshold": 0.2,
|
||||
"div_threshold": 0.022,
|
||||
"div_threshold": 0.007,
|
||||
"expansion_threshold": 0.01,
|
||||
"kernel_size": [5, 5],
|
||||
"denoise_sigma": 12
|
||||
|
||||
@@ -29,14 +29,21 @@
|
||||
"loop_closure_interval": 1,
|
||||
"algorithm": "dis",
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 3,
|
||||
"dis_mode": 1,
|
||||
"algorithm_list": ["raft", "dis"],
|
||||
"hhd_downsample": 3
|
||||
},
|
||||
"filters": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
"force_lowpass_order": 2,
|
||||
"force_lowpass_sample_rate_hz": "auto"
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
"flow_threshold":5,
|
||||
"div_threshold":0.05,
|
||||
"flow_threshold":0.2,
|
||||
"div_threshold":0.007,
|
||||
"expansion_threshold": 0.01,
|
||||
"kernel_size": [5,5],
|
||||
"denoise_sigma": 12
|
||||
|
||||
@@ -5,9 +5,7 @@ Historical import path ``from orisys.Stitcher import Stitcher`` remains supporte
|
||||
|
||||
from .stitch import Stitcher
|
||||
from .stitch.backends.numpy_backend import blend_kernel_numpy as _blend_kernel_numpy
|
||||
from .stitch.config import BLEND_MODES as _BLEND_MODES
|
||||
from .stitch.config import DEFAULT_BLEND_MODE as _DEFAULT_BLEND_MODE
|
||||
from .stitch.config import numba_blend_available, resolve_blend_mode as _resolve_blend_mode
|
||||
from .stitch.config import numba_blend_available
|
||||
|
||||
# Backward-compatible aliases used by older call sites / tests.
|
||||
_blend_kernel = _blend_kernel_numpy
|
||||
|
||||
+22
-8
@@ -5,17 +5,20 @@ A Python library for processing and analyzing optical tactile sensor data,
|
||||
including image stitching, optical flow tracking, and deformation decomposition.
|
||||
"""
|
||||
|
||||
__version__ = "0.5.2"
|
||||
__version__ = "0.5.4"
|
||||
__author__ = "Orisys"
|
||||
|
||||
from .sensor import Sensor
|
||||
from . import util
|
||||
from . import configs
|
||||
from . import calibration
|
||||
from . import finger
|
||||
from . import signal_processing
|
||||
from . import capture
|
||||
from . import calibration, capture, configs, finger, signal_processing, util
|
||||
from .export import StreamRecorder, mosaic_videos
|
||||
from .frame_processors import FFCFrameProcessor
|
||||
from .pipeline import (
|
||||
FrameSnapshot,
|
||||
PauseLevel,
|
||||
PipelineStats,
|
||||
ProducedFrame,
|
||||
StreamPipeline,
|
||||
)
|
||||
from .sensor import Sensor
|
||||
|
||||
CompoundEyeSensor = Sensor
|
||||
CameraSensor = Sensor
|
||||
@@ -44,6 +47,17 @@ __all__ = [
|
||||
# capture helpers
|
||||
'capture',
|
||||
|
||||
# pipelined multi-stream processing
|
||||
'StreamPipeline',
|
||||
'FrameSnapshot',
|
||||
'ProducedFrame',
|
||||
'PipelineStats',
|
||||
'PauseLevel',
|
||||
|
||||
# offline recording of arrow overlays
|
||||
'StreamRecorder',
|
||||
'mosaic_videos',
|
||||
|
||||
# force calibration
|
||||
'calibration',
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ except ImportError:
|
||||
try:
|
||||
import pyopencl as cl
|
||||
import pyopencl.array as cla
|
||||
from pyvkfft.fft import rfftn as cl_rfftn
|
||||
from pyvkfft.fft import irfftn as cl_irfftn
|
||||
from pyvkfft.fft import rfftn as cl_rfftn
|
||||
_opencl_cl = cl
|
||||
_opencl_cla = cla
|
||||
_opencl_rfftn = cl_rfftn
|
||||
|
||||
+367
-20
@@ -7,15 +7,17 @@ curves in ``_apply_force_filter``; ``read_info(FNORMAL, …)`` returns Newtons.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import warnings
|
||||
from collections.abc import Iterable, Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
CURRENT_SCHEMA = 2
|
||||
SUPPORTED_SCHEMAS = (1, 2)
|
||||
SUPPORTED_SCHEMAS = (1, 2, 3)
|
||||
CHANNEL_IDS: tuple[str, ...] = ("fn", "fx", "fy")
|
||||
|
||||
CHANNEL_ALIASES: dict[str, str] = {
|
||||
@@ -47,11 +49,186 @@ _KNOWN_TOP_KEYS = frozenset(
|
||||
{"schema_version", "unit", "channels", "meta", "identity", "pipeline", "provenance", "measurements", "quality"}
|
||||
)
|
||||
|
||||
#: Filter stages that change **what is measured**: they sit upstream of the
|
||||
#: force computation, so two recordings with different values are not comparable.
|
||||
#: They belong to the measurement-pipeline identity, for the same reason
|
||||
#: ``dis_mode`` and ``hhd_downsample`` do.
|
||||
FILTER_MEASUREMENT_KEYS: tuple[str, ...] = ("flow_ema_alpha",)
|
||||
#: Filter stages that only smooth the force triple on its way out. They are
|
||||
#: recorded so a file explains its own numbers, but a difference between two
|
||||
#: files does not invalidate a comparison.
|
||||
FILTER_READOUT_KEYS: tuple[str, ...] = (
|
||||
"force_median_window",
|
||||
"force_lowpass_cutoff_hz",
|
||||
"force_lowpass_order",
|
||||
"force_lowpass_sample_rate_hz",
|
||||
"force_lowpass_rate_auto",
|
||||
)
|
||||
#: Every recorded filter value, measurement group first.
|
||||
FILTER_KEYS: tuple[str, ...] = FILTER_MEASUREMENT_KEYS + FILTER_READOUT_KEYS
|
||||
#: What "off" looks like, per stage. ``enabled: false`` means exactly this.
|
||||
_OFF_FILTER_VALUES: dict[str, Any] = {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
}
|
||||
_INT_FILTER_KEYS = frozenset({"force_median_window", "force_lowpass_order"})
|
||||
_FLOAT_FILTER_KEYS = frozenset(
|
||||
{"flow_ema_alpha", "force_lowpass_cutoff_hz", "force_lowpass_sample_rate_hz"}
|
||||
)
|
||||
|
||||
|
||||
class CalibrationError(ValueError):
|
||||
"""Invalid or unusable force calibration document / path."""
|
||||
|
||||
|
||||
def _coerce_filter_value(key: str, value: Any) -> Any:
|
||||
"""A filter value in its recorded type, or ``None`` when unusable."""
|
||||
if value is None:
|
||||
return None
|
||||
if key == "force_lowpass_rate_auto":
|
||||
return bool(value)
|
||||
try:
|
||||
if key in _INT_FILTER_KEYS:
|
||||
return int(value)
|
||||
if key in _FLOAT_FILTER_KEYS:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _filters_from_config(block: Mapping[str, Any] | Any) -> dict[str, dict[str, Any]]:
|
||||
"""Best-effort filter groups from a flat ``filters`` block.
|
||||
|
||||
Only what the mapping states — directly or through a named preset, with an
|
||||
explicit key winning over the preset — is reported. Nothing is filled in from
|
||||
a default: a default is how a document ends up claiming a filter the device
|
||||
is not running. ``enabled: false`` keeps its deprecated meaning of "every
|
||||
stage off".
|
||||
"""
|
||||
flat = dict(block) if isinstance(block, Mapping) else {}
|
||||
preset_name = flat.get("preset") if isinstance(flat.get("preset"), str) else None
|
||||
merged: dict[str, Any] = {}
|
||||
if preset_name:
|
||||
try:
|
||||
from .sensor import FILTER_PRESETS # deferred: sensor imports this module
|
||||
except Exception: # pragma: no cover - defensive
|
||||
FILTER_PRESETS = {}
|
||||
merged.update(FILTER_PRESETS.get(preset_name, {}) or {})
|
||||
for key in FILTER_KEYS:
|
||||
if flat.get(key) is not None:
|
||||
merged[key] = flat[key]
|
||||
if isinstance(flat.get("force_lowpass_sample_rate_hz"), str):
|
||||
# "auto" / "measured" is a rate *source*, not a number; keep it as one.
|
||||
merged["force_lowpass_rate_auto"] = True
|
||||
merged.pop("force_lowpass_sample_rate_hz", None)
|
||||
if flat.get("enabled", None) is False:
|
||||
order = merged.get("force_lowpass_order")
|
||||
rate_auto = merged.get("force_lowpass_rate_auto")
|
||||
merged = dict(_OFF_FILTER_VALUES)
|
||||
# Order and rate tracking stay meaningful even with the stage off.
|
||||
if order is not None:
|
||||
merged["force_lowpass_order"] = order
|
||||
if rate_auto is not None:
|
||||
merged["force_lowpass_rate_auto"] = rate_auto
|
||||
return {
|
||||
group: {key: _coerce_filter_value(key, merged.get(key)) for key in keys}
|
||||
for group, keys in (
|
||||
("measurement", FILTER_MEASUREMENT_KEYS),
|
||||
("readout", FILTER_READOUT_KEYS),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _flat_filters_from(*sources: Mapping[str, Any] | Any) -> dict[str, Any]:
|
||||
"""The flat form older documents used: filter keys at the top of meta."""
|
||||
out: dict[str, Any] = {}
|
||||
for source in sources:
|
||||
if not isinstance(source, Mapping):
|
||||
continue
|
||||
for key in FILTER_KEYS:
|
||||
if key not in out and source.get(key) is not None:
|
||||
out[key] = source[key]
|
||||
return out
|
||||
|
||||
|
||||
def filters_fingerprint(state: Mapping[str, Any] | Any) -> dict[str, Any]:
|
||||
"""Split a filter state into its ``measurement`` and ``readout`` groups.
|
||||
|
||||
The honest input is ``Sensor.filters_state()`` — the values that actually
|
||||
took effect, which is not necessarily what the config file says. A flat
|
||||
mapping is accepted as a fallback (a sensor.json ``filters`` block, or the
|
||||
flat form older documents wrote) and read as the intent it is.
|
||||
|
||||
Values a source does not state are absent from the result rather than
|
||||
guessed, so ``{}`` means "nothing recorded" and never "probably off".
|
||||
"""
|
||||
if not isinstance(state, Mapping) or not state:
|
||||
return {}
|
||||
measurement = state.get("measurement")
|
||||
readout = state.get("readout")
|
||||
if isinstance(measurement, Mapping) or isinstance(readout, Mapping):
|
||||
groups = {
|
||||
"measurement": {
|
||||
key: _coerce_filter_value(key, measurement.get(key))
|
||||
for key in FILTER_MEASUREMENT_KEYS
|
||||
}
|
||||
if isinstance(measurement, Mapping)
|
||||
else {},
|
||||
"readout": {
|
||||
key: _coerce_filter_value(key, readout.get(key))
|
||||
for key in FILTER_READOUT_KEYS
|
||||
}
|
||||
if isinstance(readout, Mapping)
|
||||
else {},
|
||||
}
|
||||
source = str(state.get("source") or "sensor.filters_state")
|
||||
else:
|
||||
groups = _filters_from_config(state)
|
||||
source = "sensor.json.filters"
|
||||
out: dict[str, Any] = {}
|
||||
for group in ("measurement", "readout"):
|
||||
values = {
|
||||
key: value for key, value in (groups.get(group) or {}).items() if value is not None
|
||||
}
|
||||
if values:
|
||||
out[group] = values
|
||||
if not out:
|
||||
return {}
|
||||
if state.get("preset"):
|
||||
out["preset"] = str(state["preset"])
|
||||
out["source"] = source
|
||||
return out
|
||||
|
||||
|
||||
def filter_mismatches(
|
||||
recorded: Mapping[str, Any] | Any, expected: Mapping[str, Any] | Any
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Compare two filter fingerprints → ``(measurement, readout)`` differences.
|
||||
|
||||
A measurement difference means the two records are not comparable, so it is
|
||||
reported as a warning. A readout difference only changes how the same
|
||||
measurement was smoothed, so it is a note: recorded, never an obstacle.
|
||||
"""
|
||||
left = filters_fingerprint(recorded)
|
||||
right = filters_fingerprint(expected)
|
||||
warns: list[str] = []
|
||||
notes: list[str] = []
|
||||
for group, keys, bucket, label in (
|
||||
("measurement", FILTER_MEASUREMENT_KEYS, warns, "measurement (not comparable)"),
|
||||
("readout", FILTER_READOUT_KEYS, notes, "readout"),
|
||||
):
|
||||
for key in keys:
|
||||
if key in (left.get(group) or {}) and key in (right.get(group) or {}):
|
||||
if left[group][key] != right[group][key]:
|
||||
bucket.append(
|
||||
f"filter {label} mismatch: {key} "
|
||||
f"{left[group][key]!r} != {right[group][key]!r}"
|
||||
)
|
||||
return warns, notes
|
||||
|
||||
|
||||
def canonicalize_channel(name: str) -> str:
|
||||
"""Map FN / normal / shear_x / … to canonical ``fn`` / ``fx`` / ``fy``."""
|
||||
key = str(name or "").strip()
|
||||
@@ -175,6 +352,9 @@ class CheckResult:
|
||||
ok: bool
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
#: Differences worth recording that are not a reason to refuse anything,
|
||||
#: such as a readout-only filter change between two documents.
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -245,6 +425,16 @@ class Calibration:
|
||||
for key in ("stitch_resolution", "dis_mode", "hhd_downsample", "config_ref"):
|
||||
if key in self.pipeline and self.pipeline[key] is not None:
|
||||
meta[key] = self.pipeline[key]
|
||||
fingerprint = filters_fingerprint(self.pipeline.get("filters"))
|
||||
if fingerprint:
|
||||
# Structured, so a reader can tell a change that makes two records
|
||||
# incomparable (measurement) from one that only changes how the same
|
||||
# measurement was smoothed (readout).
|
||||
meta["filters"] = fingerprint
|
||||
# The flat keys are kept as well for readers that look them up by
|
||||
# name; the groups above are the authoritative form.
|
||||
for group in ("measurement", "readout"):
|
||||
meta.update(fingerprint.get(group) or {})
|
||||
if self.measurements:
|
||||
meta["samples"] = self.measurements
|
||||
if self.origin and "origin" not in meta:
|
||||
@@ -308,6 +498,18 @@ def check(doc: dict | Calibration, *, pipeline: dict | None = None) -> CheckResu
|
||||
"""Validate a document or Calibration. Errors refuse apply/write."""
|
||||
errors: list[str] = []
|
||||
warns: list[str] = []
|
||||
notes: list[str] = []
|
||||
|
||||
if getattr(doc, "schema_version", None) == 3 or (isinstance(doc, dict) and doc.get("schema_version") == 3):
|
||||
from .calibration_models import parse_model
|
||||
|
||||
try:
|
||||
selected = parse_model(doc) if isinstance(doc, dict) else doc
|
||||
if pipeline is not None:
|
||||
selected.validate_for_context(pipeline)
|
||||
except (CalibrationError, ValueError, TypeError) as exc:
|
||||
return CheckResult(ok=False, errors=[str(exc)])
|
||||
return CheckResult(ok=True, errors=[], warnings=[])
|
||||
|
||||
if isinstance(doc, Calibration):
|
||||
data = doc.to_dict()
|
||||
@@ -390,8 +592,15 @@ def check(doc: dict | Calibration, *, pipeline: dict | None = None) -> CheckResu
|
||||
for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
|
||||
if key in doc.pipeline and key in pipeline and doc.pipeline[key] != pipeline[key]:
|
||||
warns.append(f"pipeline mismatch: {key}")
|
||||
# The filter groups are compared apart on purpose: ``flow_ema_alpha``
|
||||
# changes what was measured, the readout stages only smooth it.
|
||||
filter_warns, filter_notes = filter_mismatches(
|
||||
doc.pipeline.get("filters"), pipeline.get("filters")
|
||||
)
|
||||
warns.extend(filter_warns)
|
||||
notes.extend(filter_notes)
|
||||
|
||||
return CheckResult(ok=not errors, errors=errors, warnings=warns)
|
||||
return CheckResult(ok=not errors, errors=errors, warnings=warns, notes=notes)
|
||||
|
||||
|
||||
def parse(data: dict, *, source_path: Path | None = None) -> Calibration:
|
||||
@@ -407,6 +616,14 @@ def parse(data: dict, *, source_path: Path | None = None) -> Calibration:
|
||||
if schema not in SUPPORTED_SCHEMAS:
|
||||
raise CalibrationError(f"unsupported schema_version: {schema}")
|
||||
|
||||
if schema == 3:
|
||||
from .calibration_models import parse_model
|
||||
|
||||
try:
|
||||
return parse_model(data, source_path=source_path)
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
raise CalibrationError(f"invalid schema 3 calibration: {exc}") from exc
|
||||
|
||||
unit = str(data.get("unit", "N"))
|
||||
channels_raw = data.get("channels")
|
||||
if not isinstance(channels_raw, dict):
|
||||
@@ -492,6 +709,14 @@ def parse(data: dict, *, source_path: Path | None = None) -> Calibration:
|
||||
"hhd_downsample": pipeline_in.get("hhd_downsample", meta.get("hhd_downsample")),
|
||||
"config_ref": pipeline_in.get("config_ref", meta.get("config_ref")),
|
||||
"config_hash": pipeline_in.get("config_hash", meta.get("config_hash")),
|
||||
# Structured when the document has it, otherwise rebuilt from the flat
|
||||
# keys older documents wrote — reading them back is what makes the
|
||||
# fingerprint checkable at all.
|
||||
"filters": filters_fingerprint(
|
||||
pipeline_in.get("filters")
|
||||
or meta.get("filters")
|
||||
or _flat_filters_from(pipeline_in, meta)
|
||||
),
|
||||
}
|
||||
provenance = {
|
||||
"origin": provenance_in.get("origin", meta.get("origin", "")),
|
||||
@@ -560,7 +785,7 @@ def load(
|
||||
explicit = source is not None and str(source).strip() != ""
|
||||
|
||||
if explicit:
|
||||
path = Path(str(source)).expanduser()
|
||||
path = select_calibration_path(cal_path=source)
|
||||
if not path.is_file():
|
||||
msg = f"calibration file not found: {path}"
|
||||
if strict or explicit:
|
||||
@@ -595,31 +820,135 @@ def load(
|
||||
try:
|
||||
return parse(data, source_path=path)
|
||||
except CalibrationError:
|
||||
if strict or explicit:
|
||||
if strict or explicit or (isinstance(data, dict) and (data.get("schema_version") == 3 or "model" in data)):
|
||||
raise
|
||||
return None
|
||||
|
||||
|
||||
def pipeline_from_sensor_json(path: str | Path | None) -> dict[str, Any]:
|
||||
"""Pull stitch / tracker fields from a sensor.json for export pipeline/meta."""
|
||||
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."""
|
||||
path = select_calibration_path(calibration_dir=calibration_dir, sensor_type=sensor_type, device_id=device_id)
|
||||
if path is None:
|
||||
return None
|
||||
cal = load(path, strict=True)
|
||||
assert cal is not None
|
||||
return validate_for_device(cal, sensor_type, device_id, pipeline=pipeline)
|
||||
|
||||
|
||||
def select_calibration_path(*, cal_path=None, calibration_dir=None, sensor_type=None,
|
||||
device_id=None, default_path=None) -> Path | None:
|
||||
"""Select a file without guessing a model or falling back across device identities."""
|
||||
explicit = cal_path is not None and bool(str(cal_path).strip())
|
||||
if explicit and calibration_dir is not None:
|
||||
raise CalibrationError("cal_path and calibration_dir are mutually exclusive")
|
||||
if device_id is not None and not explicit and calibration_dir is None:
|
||||
raise CalibrationError("device_id requires calibration_dir or cal_path")
|
||||
if explicit:
|
||||
return Path(cal_path).expanduser()
|
||||
if calibration_dir is None:
|
||||
return Path(default_path) if default_path is not None else None
|
||||
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}")
|
||||
return path
|
||||
|
||||
|
||||
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.schema_version == 3:
|
||||
if cal.unit != "N" or not cal.channels:
|
||||
raise CalibrationError(f"calibration requires declared force axes in N: {path}")
|
||||
# Full feature/geometry validation happens when the processor binds its
|
||||
# actual context. Device construction only knows this legacy subset.
|
||||
if pipeline is not None:
|
||||
for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
|
||||
if pipeline.get(key) is not None and cal.pipeline.get(key) != pipeline[key]:
|
||||
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
|
||||
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(
|
||||
path: str | Path | None,
|
||||
*,
|
||||
filters_state: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Pull stitch / tracker / filter fields from a sensor.json for export pipeline/meta.
|
||||
|
||||
``filters_state`` is the device's ``Sensor.filters_state()``. Pass it
|
||||
whenever a device is available: a config file states an intent that a runtime
|
||||
``configure()`` may have moved on from, so only the effective values are
|
||||
worth recording. Without it the file's ``filters`` block is reported for what
|
||||
it is — the intent — and values it does not state are left out rather than
|
||||
filled in with a default.
|
||||
|
||||
The result carries the filter settings in one structured ``"filters"`` key
|
||||
(``measurement`` / ``readout`` / ``preset`` / ``source``), not as loose
|
||||
top-level keys: the groups decide whether two records are comparable, so they
|
||||
have to survive a round trip intact.
|
||||
"""
|
||||
pipeline: dict[str, Any] = {
|
||||
"stitch_resolution": None,
|
||||
"dis_mode": None,
|
||||
"hhd_downsample": None,
|
||||
"filters": {},
|
||||
"config_ref": None,
|
||||
}
|
||||
if path is None:
|
||||
return pipeline
|
||||
p = Path(path)
|
||||
pipeline["config_ref"] = str(p)
|
||||
if not p.is_file():
|
||||
return pipeline
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return pipeline
|
||||
if not isinstance(data, dict):
|
||||
if path is None and filters_state is None:
|
||||
return pipeline
|
||||
p = Path(path) if path is not None else None
|
||||
if p is not None:
|
||||
pipeline["config_ref"] = str(p)
|
||||
data: dict[str, Any] = {}
|
||||
if p is not None and p.is_file():
|
||||
try:
|
||||
loaded = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
loaded = None
|
||||
if isinstance(loaded, dict):
|
||||
data = loaded
|
||||
camera = data.get("camera") if isinstance(data.get("camera"), dict) else {}
|
||||
tracker = data.get("tracker") if isinstance(data.get("tracker"), dict) else {}
|
||||
stitch = camera.get("resolution_stitch")
|
||||
@@ -635,6 +964,12 @@ def pipeline_from_sensor_json(path: str | Path | None) -> dict[str, Any]:
|
||||
pipeline["hhd_downsample"] = int(tracker["hhd_downsample"])
|
||||
except (TypeError, ValueError):
|
||||
pipeline["hhd_downsample"] = tracker.get("hhd_downsample")
|
||||
# Signal conditioning is part of the measurement-pipeline identity: these
|
||||
# stages change the reported force values, so they are recorded next to
|
||||
# dis_mode / hhd_downsample. The effective state wins over the file.
|
||||
pipeline["filters"] = filters_fingerprint(
|
||||
filters_state if filters_state else data.get("filters")
|
||||
)
|
||||
return pipeline
|
||||
|
||||
|
||||
@@ -720,6 +1055,15 @@ def build(
|
||||
if k in ("stitch_resolution", "dis_mode", "hhd_downsample", "config_ref"):
|
||||
if k not in pipeline or pipeline[k] is None:
|
||||
pipeline[k] = v
|
||||
elif k == "filters":
|
||||
if not pipeline.get("filters"):
|
||||
pipeline["filters"] = filters_fingerprint(v)
|
||||
elif k in FILTER_KEYS:
|
||||
# Flat form: fold the loose key into the structured groups.
|
||||
merged = deepcopy(pipeline.get("filters") or {})
|
||||
group = "measurement" if k in FILTER_MEASUREMENT_KEYS else "readout"
|
||||
merged.setdefault(group, {})[k] = v
|
||||
pipeline["filters"] = filters_fingerprint(merged)
|
||||
elif k in ("sensor_type", "sn", "hardware_rev", "gel_id"):
|
||||
if k not in identity or not identity[k]:
|
||||
identity[k] = v
|
||||
@@ -756,10 +1100,13 @@ def write(
|
||||
path = Path(path)
|
||||
if path.exists() and not overwrite:
|
||||
raise FileExistsError(f"calibration file exists: {path}")
|
||||
if cal.schema_version == 3:
|
||||
# A moved manifest must not acquire unresolved or different attachments.
|
||||
parse(cal.to_dict(), source_path=path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc = cal.to_dict()
|
||||
# Always emit schema 2 for new writes.
|
||||
doc["schema_version"] = CURRENT_SCHEMA
|
||||
# Preserve an explicitly selected model format; legacy builders still emit v2.
|
||||
doc["schema_version"] = 3 if cal.schema_version == 3 else CURRENT_SCHEMA
|
||||
path.write_text(
|
||||
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Measurement identities shared by SDK runtime and offline calibration exporters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .calibration import filters_fingerprint
|
||||
|
||||
|
||||
def sha256_file(path) -> str:
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def domain_fingerprint(domain) -> str:
|
||||
"""Bind a coordinate lookup to the actual unroll, not just its dimensions."""
|
||||
data = domain.to_dict() if hasattr(domain, "to_dict") else domain
|
||||
digest = hashlib.sha256()
|
||||
for key, dtype in (("map_x", "<f4"), ("map_y", "<f4"), ("valid_mask", "u1"), ("world_xyz", "<f4")):
|
||||
array = np.ascontiguousarray(data[key], dtype=dtype)
|
||||
digest.update(key.encode("ascii"))
|
||||
digest.update(str(array.shape).encode("ascii"))
|
||||
digest.update(array.tobytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build_context(config, filters=None, *, geometry_sha256=None, source_scale=None,
|
||||
coordinates=None, output_domain_sha256=None, processing_backend=None,
|
||||
preprocessing=None) -> dict:
|
||||
"""Build the closed v1 feature identity from effective values, excluding readout filters."""
|
||||
camera = config.get("camera", {})
|
||||
tracker = dict(config.get("tracker", {}))
|
||||
raw_downsample = tracker.get("hhd_downsample", 1)
|
||||
try:
|
||||
downsample = max(1, int(raw_downsample)) if raw_downsample is not None else 1
|
||||
except (TypeError, ValueError):
|
||||
downsample = 1
|
||||
tracker["hhd_downsample"] = downsample
|
||||
if source_scale is not None:
|
||||
source_scale = float(source_scale)
|
||||
measurement = filters_fingerprint(config.get("filters", {}) if filters is None else filters).get("measurement", {})
|
||||
# A missing EMA setting has the same effective meaning as the SDK default.
|
||||
measurement.setdefault("flow_ema_alpha", 1.0)
|
||||
result = {
|
||||
"stitch_resolution": camera.get("resolution_stitch"),
|
||||
"dis_mode": tracker.get("dis_mode"),
|
||||
"hhd_downsample": downsample,
|
||||
"filters": {"measurement": measurement},
|
||||
}
|
||||
if geometry_sha256 is not None:
|
||||
result["geometry_sha256"] = geometry_sha256
|
||||
if source_scale is not None:
|
||||
result["source_scale"] = float(source_scale)
|
||||
if coordinates is not None:
|
||||
result["coordinates"] = coordinates
|
||||
if output_domain_sha256 is not None:
|
||||
result["output_domain_sha256"] = output_domain_sha256
|
||||
effective = {
|
||||
"feature_version": "orisys_hhd_raw/1",
|
||||
"processing_backend": processing_backend or config.get("type", "compound_eye"),
|
||||
"camera": {key: camera.get(key) for key in ("resolution_raw", "resolution_stitch")},
|
||||
"tracker": tracker,
|
||||
"contact": config.get("contact", {}),
|
||||
"measurement": measurement,
|
||||
"geometry_sha256": geometry_sha256,
|
||||
"output_domain_sha256": output_domain_sha256,
|
||||
"source_scale": source_scale,
|
||||
"preprocessing": preprocessing or {},
|
||||
}
|
||||
encoded = json.dumps(effective, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
result["feature_signature_sha256"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
return result
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Validated, versioned force models; acquisition and training stay outside here."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial import ConvexHull, QhullError
|
||||
|
||||
|
||||
def _require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def _keys(value: Any, required: set[str], optional: set[str] | None = None) -> None:
|
||||
_require(isinstance(value, dict), "expected a JSON object")
|
||||
_require(required <= value.keys(), f"missing fields: {required - value.keys()}")
|
||||
_require(value.keys() <= required | (optional or set()), "unknown semantic fields")
|
||||
|
||||
|
||||
def _number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||
|
||||
|
||||
def _sha(value: Any) -> bool:
|
||||
return isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value)
|
||||
|
||||
|
||||
def _area(polygon: np.ndarray) -> float:
|
||||
if len(polygon) < 3:
|
||||
return 0.0
|
||||
return abs(float(np.dot(polygon[:, 0], np.roll(polygon[:, 1], 1)) -
|
||||
np.dot(polygon[:, 1], np.roll(polygon[:, 0], 1)))) / 2
|
||||
|
||||
|
||||
def _cross(a: np.ndarray, b: np.ndarray) -> float:
|
||||
return float(a[0] * b[1] - a[1] * b[0])
|
||||
|
||||
|
||||
def _intersection_area(first: np.ndarray, second: np.ndarray) -> float:
|
||||
"""Clip two counterclockwise convex polygons, including shared edges."""
|
||||
polygon = list(first)
|
||||
for a, b in zip(second, np.roll(second, -1, axis=0)):
|
||||
previous = polygon
|
||||
polygon = []
|
||||
if not previous:
|
||||
break
|
||||
for start, end in zip(previous, previous[1:] + previous[:1]):
|
||||
ds, de = _cross(b - a, start - a), _cross(b - a, end - a)
|
||||
if (ds >= 0) != (de >= 0):
|
||||
polygon.append(start + (end - start) * ds / (ds - de))
|
||||
if de >= 0:
|
||||
polygon.append(end)
|
||||
return _area(np.asarray(polygon))
|
||||
|
||||
|
||||
def _mesh(params: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
points, gains, triangles = (params[k] for k in ("points_uv", "gains_N_per_raw", "triangles"))
|
||||
_require(isinstance(points, list) and len(points) >= 3, "spatial mesh needs at least 3 points")
|
||||
_require(all(isinstance(p, list) and len(p) == 2 and all(_number(x) for x in p) for p in points),
|
||||
"points_uv must be finite Nx2 numbers")
|
||||
_require(len({tuple(p) for p in points}) == len(points), "duplicate spatial points")
|
||||
_require(isinstance(gains, list) and len(gains) == len(points) and
|
||||
all(_number(g) and g > 0 for g in gains), "gains must be finite positive numbers")
|
||||
_require(isinstance(triangles, list) and bool(triangles), "triangles are required")
|
||||
_require(all(isinstance(t, list) and len(t) == 3 and
|
||||
all(type(i) is int and 0 <= i < len(points) for i in t) and len(set(t)) == 3
|
||||
for t in triangles),
|
||||
"invalid triangle indices")
|
||||
vertices = np.asarray(points, dtype=float)
|
||||
try:
|
||||
hull_area = ConvexHull(vertices).volume
|
||||
except QhullError as exc:
|
||||
raise ValueError("spatial mesh points are collinear") from exc
|
||||
tolerance = max(1.0, hull_area) * 1e-9
|
||||
polygons = []
|
||||
for indices in triangles:
|
||||
polygon = vertices[indices]
|
||||
signed = _cross(polygon[1] - polygon[0], polygon[2] - polygon[0])
|
||||
_require(abs(signed) / 2 > tolerance, "degenerate triangle")
|
||||
if signed < 0:
|
||||
polygon = polygon[::-1]
|
||||
_require(all(_intersection_area(polygon, other) <= tolerance for other in polygons),
|
||||
"overlapping spatial triangles")
|
||||
polygons.append(polygon)
|
||||
_require({i for triangle in triangles for i in triangle} == set(range(len(points))),
|
||||
"spatial mesh must use all support points")
|
||||
_require(abs(sum(_area(p) for p in polygons) - hull_area) <= tolerance,
|
||||
"triangulation must cover the complete convex hull")
|
||||
return vertices, np.asarray(gains, dtype=float), np.asarray(triangles, dtype=int)
|
||||
|
||||
|
||||
def _pipeline(data: Any, spatial: bool) -> None:
|
||||
required = {"stitch_resolution", "dis_mode", "hhd_downsample",
|
||||
"filters", "feature_signature_sha256"}
|
||||
_keys(data, required | ({"geometry_sha256", "coordinates", "source_scale"} if spatial else set()),
|
||||
{"geometry_sha256", "coordinates", "output_domain_sha256", "source_scale"})
|
||||
size = data["stitch_resolution"]
|
||||
_require(isinstance(size, list) and len(size) == 2 and all(type(n) is int and n > 0 for n in size),
|
||||
"invalid stitch_resolution")
|
||||
for key in ("dis_mode", "hhd_downsample"):
|
||||
_require(type(data[key]) is int and data[key] >= (1 if key == "hhd_downsample" else 0),
|
||||
f"invalid {key}")
|
||||
if "source_scale" in data:
|
||||
_require(_number(data["source_scale"]) and data["source_scale"] > 0, "invalid source_scale")
|
||||
_require(_sha(data["feature_signature_sha256"]), "invalid feature signature SHA-256")
|
||||
_keys(data["filters"], {"measurement"}, {"readout"})
|
||||
_keys(data["filters"]["measurement"], {"flow_ema_alpha"})
|
||||
alpha = data["filters"]["measurement"]["flow_ema_alpha"]
|
||||
_require(_number(alpha) and 0 < alpha <= 1, "invalid flow_ema_alpha")
|
||||
if "geometry_sha256" in data:
|
||||
_require(_sha(data["geometry_sha256"]), "invalid geometry SHA-256")
|
||||
if "output_domain_sha256" in data:
|
||||
_require(_sha(data["output_domain_sha256"]), "invalid output domain SHA-256")
|
||||
if "coordinates" in data:
|
||||
coord = data["coordinates"]
|
||||
_keys(coord, {"frame", "unit", "origin_xy", "map_sha256"})
|
||||
_require(coord["frame"] == "autocal_flat" and coord["unit"] == "px", "unsupported coordinates")
|
||||
_require(isinstance(coord["origin_xy"], list) and len(coord["origin_xy"]) == 2 and
|
||||
all(_number(x) for x in coord["origin_xy"]), "invalid coordinate origin")
|
||||
_require(_sha(coord["map_sha256"]), "invalid map SHA-256")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalibrationResult:
|
||||
values_N: dict[str, float | None]
|
||||
status: dict[str, str]
|
||||
|
||||
|
||||
class ModelCalibration:
|
||||
"""One validated model per stream. Current gain backends are stateless."""
|
||||
|
||||
schema_version = 3
|
||||
unit = "N"
|
||||
|
||||
def __init__(self, document: dict[str, Any], source_path: str | Path | None = None):
|
||||
self._document = deepcopy(document)
|
||||
self.source_path = Path(source_path).resolve() if source_path is not None else None
|
||||
self.identity = deepcopy(document["identity"])
|
||||
self.pipeline = deepcopy(document["pipeline"])
|
||||
self.provenance = deepcopy(document.get("provenance", {}))
|
||||
self.model_type = document["model"]["type"]
|
||||
self.input_profile = document["model"]["input_profile"]
|
||||
self.outputs = tuple(document["model"]["outputs"])
|
||||
self.channels = {axis: "N" for axis in self.outputs}
|
||||
self._params = self._document["model"]["params"]
|
||||
self.coordinate_map = self.coordinate_valid_mask = None
|
||||
self._artifact_hashes: list[str] = []
|
||||
if self.model_type == "spatial_gain/1":
|
||||
self._points, self._gains, self._triangles = _mesh(self._params)
|
||||
self._load_artifacts(document["model"].get("artifacts", []))
|
||||
|
||||
def _load_artifacts(self, artifacts: Any) -> None:
|
||||
_require(isinstance(artifacts, list), "artifacts must be an array")
|
||||
_require(len(artifacts) <= 1, "only one coordinate_map artifact is supported")
|
||||
for artifact in artifacts:
|
||||
_keys(artifact, {"role", "path", "sha256"})
|
||||
_require(self.model_type == "spatial_gain/1" and artifact["role"] == "coordinate_map",
|
||||
"unsupported artifact role")
|
||||
name = artifact["path"]
|
||||
_require(isinstance(name, str) and bool(name) and ":" not in name and
|
||||
not PureWindowsPath(name).is_absolute() and not PurePosixPath(name).is_absolute() and
|
||||
".." not in PurePosixPath(name.replace("\\", "/")).parts,
|
||||
"artifact path must be relative without parent traversal")
|
||||
_require(self.source_path is not None, "artifact requires calibration source_path")
|
||||
path = (self.source_path.parent / name).resolve()
|
||||
_require(path.is_relative_to(self.source_path.parent) and path.is_file(),
|
||||
"artifact missing or escapes calibration directory")
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
_require(_sha(artifact["sha256"]) and digest == artifact["sha256"], "artifact SHA-256 mismatch")
|
||||
_require(self.pipeline["coordinates"]["map_sha256"] == digest,
|
||||
"coordinate map identity does not match artifact SHA-256")
|
||||
self._artifact_hashes.append(digest)
|
||||
try:
|
||||
with np.load(path, allow_pickle=False) as archive:
|
||||
keys = set(archive.files)
|
||||
_require({"uv_by_output_px", "valid_mask"} <= keys <=
|
||||
{"uv_by_output_px", "valid_mask", "origin_xy", "flat_mapping_sha256"},
|
||||
"invalid coordinate_map keys")
|
||||
uv, mask = archive["uv_by_output_px"], archive["valid_mask"]
|
||||
_require(uv.ndim == 3 and uv.shape[2] == 2 and mask.shape == uv.shape[:2],
|
||||
"invalid coordinate_map shape")
|
||||
_require(mask.dtype == np.bool_ and np.issubdtype(uv.dtype, np.floating),
|
||||
"invalid coordinate_map dtype")
|
||||
_require(np.isfinite(uv[mask]).all(), "non-finite mapped coordinates")
|
||||
if "origin_xy" in keys:
|
||||
_require(np.array_equal(archive["origin_xy"], self.pipeline["coordinates"]["origin_xy"]),
|
||||
"coordinate_map origin mismatch")
|
||||
self.coordinate_map, self.coordinate_valid_mask = uv.copy(), mask.copy()
|
||||
self.coordinate_map.flags.writeable = False
|
||||
self.coordinate_valid_mask.flags.writeable = False
|
||||
except (OSError, TypeError) as exc:
|
||||
raise ValueError("invalid coordinate_map artifact") from exc
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
content = json.dumps(self._document, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
return hashlib.sha256((content + "".join(self._artifact_hashes)).encode("utf-8")).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return deepcopy(self._document)
|
||||
|
||||
def validate_for_context(self, pipeline: Mapping[str, Any]) -> None:
|
||||
for key, value in self.pipeline.items():
|
||||
if key == "filters":
|
||||
actual = pipeline.get(key, {})
|
||||
_require(isinstance(actual, Mapping) and actual.get("measurement") == value["measurement"],
|
||||
"calibration pipeline mismatch: filters.measurement")
|
||||
else:
|
||||
_require(pipeline.get(key) == value, f"calibration pipeline mismatch: {key}")
|
||||
|
||||
def reset(self, reference_id: Any = None) -> None:
|
||||
"""Gain backends have no history; preserve the lifecycle for future models."""
|
||||
|
||||
def convert(self, fn: float, fx: float, fy: float) -> None:
|
||||
"""Reject legacy scalar calls which lack frame, position and zero features."""
|
||||
from .calibration import CalibrationError
|
||||
|
||||
raise CalibrationError("schema 3 requires frame features; use predict/apply_calibration_frame")
|
||||
|
||||
def predict(self, frame_id: int, timestamp_s: float | None, features: Mapping[str, Any],
|
||||
reference_id: Any = None) -> CalibrationResult:
|
||||
def invalid(reason: str) -> CalibrationResult:
|
||||
return CalibrationResult({a: None for a in self.outputs}, {a: reason for a in self.outputs})
|
||||
|
||||
if (not isinstance(features, Mapping) or type(frame_id) is not int or
|
||||
(timestamp_s is not None and not _number(timestamp_s))):
|
||||
return invalid("invalid_input")
|
||||
if features.get("zero_valid") is not True:
|
||||
return invalid("zero_unavailable")
|
||||
if features.get("contact_found") is False:
|
||||
return invalid("no_contact")
|
||||
values = {}
|
||||
if self.model_type == "spatial_gain/1":
|
||||
if features.get("contact_found") is not True:
|
||||
return invalid("invalid_input")
|
||||
if features.get("single_contact", True) is not True or features.get("contact_count", 1) != 1:
|
||||
return invalid("invalid_input")
|
||||
if features.get("mapping_valid") is not True:
|
||||
return invalid("coordinate_unmapped")
|
||||
if not all(_number(features.get(k)) for k in ("delta_raw", "u", "v")):
|
||||
return invalid("invalid_input")
|
||||
query = np.asarray([features["u"], features["v"]])
|
||||
for indices in self._triangles:
|
||||
triangle = self._points[indices]
|
||||
weights = np.linalg.solve((triangle[1:] - triangle[0]).T, query - triangle[0])
|
||||
weights = np.asarray([1 - sum(weights), *weights])
|
||||
if np.min(weights) >= -1e-10:
|
||||
values["fn"] = float(np.dot(weights, self._gains[indices]) * features["delta_raw"])
|
||||
break
|
||||
if not values:
|
||||
return invalid("outside_coverage")
|
||||
else:
|
||||
deltas = features.get("delta_raw_by_axis", features.get("force_delta_raw", {}))
|
||||
if not isinstance(deltas, Mapping):
|
||||
return invalid("invalid_input")
|
||||
for axis in self.outputs:
|
||||
delta = deltas.get(axis, features.get("delta_raw") if axis == "fn" else None)
|
||||
values[axis] = delta * self._params["gains_N_per_raw"][axis] if _number(delta) else None
|
||||
low, high = self._params["valid_force_range_N"]
|
||||
statuses = {}
|
||||
for axis, value in values.items():
|
||||
if value is None or not _number(value):
|
||||
values[axis], statuses[axis] = None, "invalid_input"
|
||||
else:
|
||||
statuses[axis] = "ok" if low <= value <= high else "out_of_range"
|
||||
return CalibrationResult(values, statuses)
|
||||
|
||||
|
||||
def parse_model(data: dict[str, Any], source_path: str | Path | None = None) -> ModelCalibration:
|
||||
"""Parse schema 3 without executing document-specified code or installing backends."""
|
||||
_keys(data, {"schema_version", "unit", "identity", "pipeline", "model"}, {"provenance"})
|
||||
_require(type(data["schema_version"]) is int and data["schema_version"] == 3, "expected schema_version 3")
|
||||
_require(data["unit"] == "N", "schema 3 unit must be N")
|
||||
_keys(data["identity"], {"sensor_type", "sn"})
|
||||
_require(all(isinstance(v, str) and bool(v.strip()) for v in data["identity"].values()), "invalid identity")
|
||||
_require(isinstance(data.get("provenance", {}), dict), "provenance must be an object")
|
||||
model = data["model"]
|
||||
_keys(model, {"type", "input_profile", "output_frame", "outputs", "params"}, {"artifacts"})
|
||||
_require(model["type"] in ("spatial_gain/1", "global_gain/1"), f"unsupported calibration model: {model['type']}")
|
||||
spatial = model["type"] == "spatial_gain/1"
|
||||
_require(model["input_profile"] == ("fn_delta_autocal_uv/1" if spatial else "force_delta/1"),
|
||||
"unsupported input_profile for model")
|
||||
_require(model["output_frame"] == "sdk_force/1", "unsupported output_frame")
|
||||
outputs = model["outputs"]
|
||||
_require(isinstance(outputs, list) and bool(outputs) and all(a in ("fn", "fx", "fy") for a in outputs) and
|
||||
len(set(outputs)) == len(outputs), "invalid model outputs")
|
||||
_require(not spatial or outputs == ["fn"], "spatial model supports fn only")
|
||||
_pipeline(data["pipeline"], spatial)
|
||||
params = model["params"]
|
||||
_keys(params, {"gains_N_per_raw", "valid_force_range_N"} | ({"points_uv", "triangles"} if spatial else set()))
|
||||
force_range = params["valid_force_range_N"]
|
||||
_require(isinstance(force_range, list) and len(force_range) == 2 and
|
||||
all(_number(x) for x in force_range) and force_range[0] < force_range[1], "invalid force range")
|
||||
if not spatial:
|
||||
gains = params["gains_N_per_raw"]
|
||||
_keys(gains, set(outputs))
|
||||
_require(all(_number(g) and g > 0 for g in gains.values()), "gains must be finite positive numbers")
|
||||
return ModelCalibration(data, source_path)
|
||||
@@ -16,6 +16,7 @@ from .discovery import (
|
||||
pick_default_camera,
|
||||
)
|
||||
from .source import (
|
||||
CapturedFrame,
|
||||
CaptureSource,
|
||||
CaptureWorker,
|
||||
is_live_camera,
|
||||
@@ -25,21 +26,22 @@ from .source import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MAX_SIMULTANEOUS_CAPTURES",
|
||||
"DEFAULT_MAX_CAMERA_INDEX",
|
||||
"DEFAULT_LAPTOP_WEBCAM_PATTERNS",
|
||||
"CaptureConfig",
|
||||
"CaptureHealth",
|
||||
"CaptureStatus",
|
||||
"ReconnectPolicy",
|
||||
"CaptureSource",
|
||||
"CaptureWorker",
|
||||
"CameraInfo",
|
||||
"DEFAULT_MAX_CAMERA_INDEX",
|
||||
"MAX_SIMULTANEOUS_CAPTURES",
|
||||
"CameraFilter",
|
||||
"list_cameras",
|
||||
"pick_default_camera",
|
||||
"normalize_source",
|
||||
"CameraInfo",
|
||||
"CaptureConfig",
|
||||
"CapturedFrame",
|
||||
"CaptureHealth",
|
||||
"CaptureSource",
|
||||
"CaptureStatus",
|
||||
"CaptureWorker",
|
||||
"ReconnectPolicy",
|
||||
"is_live_camera",
|
||||
"open_opencv_capture",
|
||||
"list_cameras",
|
||||
"normalize_source",
|
||||
"open_capture",
|
||||
"open_opencv_capture",
|
||||
"pick_default_camera",
|
||||
]
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Sequence, Tuple, Union
|
||||
from typing import Any, Union
|
||||
|
||||
MAX_SIMULTANEOUS_CAPTURES = 10
|
||||
|
||||
@@ -44,11 +45,11 @@ class ReconnectPolicy:
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
max_attempts: Optional[int] = None
|
||||
max_attempts: int | None = None
|
||||
initial_backoff_s: float = 0.25
|
||||
max_backoff_s: float = 5.0
|
||||
fail_threshold: int = 5
|
||||
stale_timeout_s: Optional[float] = 2.0
|
||||
stale_timeout_s: float | None = 2.0
|
||||
|
||||
def next_backoff(self, attempt: int) -> float:
|
||||
"""Exponential backoff capped at ``max_backoff_s`` (attempt is 0-based)."""
|
||||
@@ -62,19 +63,19 @@ class CaptureConfig:
|
||||
|
||||
source: SourceType
|
||||
backend: str = "auto"
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
fps: Optional[float] = None
|
||||
fourcc: Optional[str] = "MJPG"
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
fps: float | None = None
|
||||
fourcc: str | None = "MJPG"
|
||||
background: bool = True
|
||||
reconnect: ReconnectPolicy = field(default_factory=ReconnectPolicy)
|
||||
verbose: bool = False
|
||||
# Optional discovery filters (used by list/open helpers, not open itself).
|
||||
name_include: Optional[Sequence[str]] = None
|
||||
name_exclude: Optional[Sequence[str]] = None
|
||||
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None
|
||||
min_width: Optional[int] = None
|
||||
min_height: Optional[int] = None
|
||||
name_include: Sequence[str] | None = None
|
||||
name_exclude: Sequence[str] | None = None
|
||||
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None
|
||||
min_width: int | None = None
|
||||
min_height: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.reconnect, ReconnectPolicy):
|
||||
@@ -86,10 +87,10 @@ class CaptureConfig:
|
||||
source: SourceType,
|
||||
camera: dict[str, Any],
|
||||
*,
|
||||
reconnect: Optional[ReconnectPolicy] = None,
|
||||
reconnect: ReconnectPolicy | None = None,
|
||||
verbose: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> "CaptureConfig":
|
||||
) -> CaptureConfig:
|
||||
"""Build config from a sensor JSON ``camera`` section."""
|
||||
# Accept deprecated queue/worker kwargs from older callers without error.
|
||||
kwargs.pop("mode", None)
|
||||
@@ -117,9 +118,9 @@ class CaptureStatus:
|
||||
"""Snapshot of capture health for diagnostics / multi-camera UIs."""
|
||||
|
||||
health: CaptureHealth = CaptureHealth.CLOSED
|
||||
source: Optional[SourceType] = None
|
||||
last_error: Optional[str] = None
|
||||
last_frame_time: Optional[float] = None
|
||||
source: SourceType | None = None
|
||||
last_error: str | None = None
|
||||
last_frame_time: float | None = None
|
||||
last_seq: int = 0
|
||||
dropped_frames: int = 0
|
||||
reconnect_attempts: int = 0
|
||||
|
||||
@@ -3,21 +3,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, Optional, Sequence, Tuple
|
||||
|
||||
import cv2
|
||||
|
||||
from .config import MAX_SIMULTANEOUS_CAPTURES
|
||||
from .source import _backend_candidates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_CAMERA_INDEX = MAX_SIMULTANEOUS_CAPTURES
|
||||
|
||||
# Common built-in laptop webcam name fragments (case-insensitive).
|
||||
DEFAULT_LAPTOP_WEBCAM_PATTERNS: Tuple[str, ...] = (
|
||||
DEFAULT_LAPTOP_WEBCAM_PATTERNS: tuple[str, ...] = (
|
||||
r"integrated",
|
||||
r"built[- ]?in",
|
||||
r"laptop",
|
||||
@@ -49,8 +52,8 @@ class CameraInfo:
|
||||
height: int
|
||||
name: str = ""
|
||||
path: str = ""
|
||||
vendor_id: Optional[int] = None
|
||||
product_id: Optional[int] = None
|
||||
vendor_id: int | None = None
|
||||
product_id: int | None = None
|
||||
bus: str = ""
|
||||
|
||||
@property
|
||||
@@ -64,7 +67,7 @@ class CameraInfo:
|
||||
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
|
||||
|
||||
@property
|
||||
def vid_pid(self) -> Optional[Tuple[int, int]]:
|
||||
def vid_pid(self) -> tuple[int, int] | None:
|
||||
if self.vendor_id is None or self.product_id is None:
|
||||
return None
|
||||
return (int(self.vendor_id), int(self.product_id))
|
||||
@@ -74,13 +77,13 @@ class CameraInfo:
|
||||
class CameraFilter:
|
||||
"""Optional filters for ``list_cameras`` / ``pick_default_camera``."""
|
||||
|
||||
name_include: Optional[Sequence[str]] = None
|
||||
name_exclude: Optional[Sequence[str]] = None
|
||||
name_include: Sequence[str] | None = None
|
||||
name_exclude: Sequence[str] | None = None
|
||||
exclude_laptop_webcams: bool = False
|
||||
laptop_patterns: Sequence[str] = field(default_factory=lambda: DEFAULT_LAPTOP_WEBCAM_PATTERNS)
|
||||
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None
|
||||
min_width: Optional[int] = None
|
||||
min_height: Optional[int] = None
|
||||
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None
|
||||
min_width: int | None = None
|
||||
min_height: int | None = None
|
||||
prefer_named: bool = True
|
||||
|
||||
def matches(self, cam: CameraInfo) -> bool:
|
||||
@@ -143,8 +146,15 @@ def _suppress_opencv_noise():
|
||||
if set_level is not None and prev_level is not None:
|
||||
try:
|
||||
set_level(prev_level)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
# Restoring OpenCV's log level is best-effort, but a silent failure
|
||||
# here leaves probing noise suppressed for the rest of the process,
|
||||
# which hides exactly the diagnostics a failed enumeration needs.
|
||||
logger.warning(
|
||||
"capture discovery: could not restore OpenCV log level %r: %s",
|
||||
prev_level,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def _linux_device_metadata(index: int) -> dict:
|
||||
@@ -152,7 +162,7 @@ def _linux_device_metadata(index: int) -> dict:
|
||||
meta = {"name": "", "path": f"/dev/video{index}", "vendor_id": None, "product_id": None, "bus": ""}
|
||||
name_path = f"/sys/class/video4linux/video{index}/name"
|
||||
try:
|
||||
with open(name_path, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
with open(name_path, encoding="utf-8", errors="ignore") as fh:
|
||||
meta["name"] = fh.read().strip()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -165,9 +175,9 @@ def _linux_device_metadata(index: int) -> dict:
|
||||
vend = os.path.join(cur, "idVendor")
|
||||
prod = os.path.join(cur, "idProduct")
|
||||
if os.path.isfile(vend) and os.path.isfile(prod):
|
||||
with open(vend, "r", encoding="utf-8") as fh:
|
||||
with open(vend, encoding="utf-8") as fh:
|
||||
meta["vendor_id"] = int(fh.read().strip(), 16)
|
||||
with open(prod, "r", encoding="utf-8") as fh:
|
||||
with open(prod, encoding="utf-8") as fh:
|
||||
meta["product_id"] = int(fh.read().strip(), 16)
|
||||
meta["bus"] = "usb"
|
||||
break
|
||||
@@ -229,7 +239,7 @@ def _windows_device_names() -> dict[int, dict]:
|
||||
return {}
|
||||
|
||||
|
||||
def _device_metadata(index: int, windows_names: Optional[dict[int, dict]] = None) -> dict:
|
||||
def _device_metadata(index: int, windows_names: dict[int, dict] | None = None) -> dict:
|
||||
if sys.platform.startswith("linux"):
|
||||
return _linux_device_metadata(index)
|
||||
if sys.platform == "win32" and windows_names is not None:
|
||||
@@ -238,7 +248,7 @@ def _device_metadata(index: int, windows_names: Optional[dict[int, dict]] = None
|
||||
return {"name": "", "path": "", "vendor_id": None, "product_id": None, "bus": ""}
|
||||
|
||||
|
||||
def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> Optional[CameraInfo]:
|
||||
def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> CameraInfo | None:
|
||||
cap = cv2.VideoCapture(index, backend_api)
|
||||
if not cap.isOpened():
|
||||
cap.release()
|
||||
@@ -272,13 +282,13 @@ def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> Op
|
||||
def list_cameras(
|
||||
max_index: int = DEFAULT_MAX_CAMERA_INDEX,
|
||||
*,
|
||||
camera_filter: Optional[CameraFilter] = None,
|
||||
camera_filter: CameraFilter | None = None,
|
||||
exclude_laptop_webcams: bool = False,
|
||||
name_include: Optional[Sequence[str]] = None,
|
||||
name_exclude: Optional[Sequence[str]] = None,
|
||||
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None,
|
||||
min_width: Optional[int] = None,
|
||||
min_height: Optional[int] = None,
|
||||
name_include: Sequence[str] | None = None,
|
||||
name_exclude: Sequence[str] | None = None,
|
||||
vid_pid_allowlist: Sequence[tuple[int, int]] | None = None,
|
||||
min_width: int | None = None,
|
||||
min_height: int | None = None,
|
||||
backend: str = "auto",
|
||||
) -> list[CameraInfo]:
|
||||
"""Probe indices ``0 .. max_index`` and return working cameras.
|
||||
@@ -302,10 +312,22 @@ def list_cameras(
|
||||
windows_names = _windows_device_names() if sys.platform == "win32" else None
|
||||
|
||||
with _suppress_opencv_noise():
|
||||
for index in range(0, max_index + 1):
|
||||
for index in range(max_index + 1):
|
||||
meta = _device_metadata(index, windows_names)
|
||||
for backend_name, backend_api in backends:
|
||||
info = _try_open(index, backend_name, backend_api, meta)
|
||||
try:
|
||||
info = _try_open(index, backend_name, backend_api, meta)
|
||||
except Exception as exc:
|
||||
# One bad backend/device must not abort the whole probe: an
|
||||
# escaping error here made every *other* camera invisible, not
|
||||
# just this index. Skip the candidate and keep enumerating.
|
||||
logger.warning(
|
||||
"capture discovery: probing index %d via %s failed: %s",
|
||||
index,
|
||||
backend_name,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
if info is None:
|
||||
continue
|
||||
if index in seen_indices:
|
||||
|
||||
+127
-50
@@ -2,11 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional, Protocol, runtime_checkable
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -19,6 +22,23 @@ from .config import (
|
||||
SourceType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapturedFrame:
|
||||
"""One image and the host clocks sampled when its capture read completed.
|
||||
|
||||
``sequence`` belongs to this source instance. ``media_time_ms`` is advisory
|
||||
file metadata and is never substituted for the host acquisition clocks.
|
||||
"""
|
||||
|
||||
image: np.ndarray
|
||||
sequence: int
|
||||
host_mono_ns: int
|
||||
host_utc_ns: int
|
||||
media_time_ms: float | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class VideoCaptureLike(Protocol):
|
||||
@@ -83,7 +103,7 @@ def _backend_candidates(preferred: str = "auto") -> list[tuple[str, int]]:
|
||||
return candidates
|
||||
|
||||
|
||||
def _fourcc_code(fourcc: Optional[str]) -> Optional[int]:
|
||||
def _fourcc_code(fourcc: str | None) -> int | None:
|
||||
if not fourcc:
|
||||
return None
|
||||
text = str(fourcc).strip()
|
||||
@@ -111,10 +131,10 @@ def _read_stream_meta(cap: VideoCaptureLike) -> tuple[int, int, float, str]:
|
||||
def _apply_stream_props(
|
||||
cap: VideoCaptureLike,
|
||||
*,
|
||||
width: Optional[int],
|
||||
height: Optional[int],
|
||||
fps: Optional[float],
|
||||
fourcc: Optional[str],
|
||||
width: int | None,
|
||||
height: int | None,
|
||||
fps: float | None,
|
||||
fourcc: str | None,
|
||||
) -> None:
|
||||
"""Apply live-camera stream properties (width/height before fourcc on DSHOW)."""
|
||||
code = _fourcc_code(fourcc)
|
||||
@@ -143,10 +163,10 @@ def open_opencv_capture(
|
||||
source: SourceType,
|
||||
*,
|
||||
backend: str = "auto",
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
fps: Optional[float] = None,
|
||||
fourcc: Optional[str] = "MJPG",
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
fps: float | None = None,
|
||||
fourcc: str | None = "MJPG",
|
||||
apply_stream_props: bool = True,
|
||||
) -> tuple[VideoCaptureLike, str]:
|
||||
"""Open a ``cv2.VideoCapture`` trying preferred backends in order.
|
||||
@@ -212,16 +232,19 @@ class CaptureSource:
|
||||
):
|
||||
self.config = config
|
||||
self._factory = factory or self._default_factory
|
||||
self._cap: Optional[VideoCaptureLike] = None
|
||||
self._cap: VideoCaptureLike | None = None
|
||||
self._backend_name = ""
|
||||
self._status = CaptureStatus(source=normalize_source(config.source))
|
||||
self._reconnect_attempt = 0
|
||||
self._lock = threading.Lock()
|
||||
self._frame_lock = threading.Lock()
|
||||
self._frame_cond = threading.Condition(self._frame_lock)
|
||||
self._latest_frame: Optional[np.ndarray] = None
|
||||
self._latest_frame: np.ndarray | None = None
|
||||
self._latest_captured: CapturedFrame | None = None
|
||||
self._latest_seq = 0
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._read_seq = 0
|
||||
self._last_delivered_seq = 0
|
||||
self._thread: threading.Thread | None = None
|
||||
self._background_running = False
|
||||
|
||||
@staticmethod
|
||||
@@ -238,7 +261,7 @@ class CaptureSource:
|
||||
|
||||
# Compatibility with older CaptureWorker API.
|
||||
@property
|
||||
def source(self) -> "CaptureSource":
|
||||
def source(self) -> CaptureSource:
|
||||
return self
|
||||
|
||||
@property
|
||||
@@ -265,7 +288,7 @@ class CaptureSource:
|
||||
)
|
||||
|
||||
@property
|
||||
def cap(self) -> Optional[VideoCaptureLike]:
|
||||
def cap(self) -> VideoCaptureLike | None:
|
||||
"""Raw capture handle (compat with Sensor.cap)."""
|
||||
return self._cap
|
||||
|
||||
@@ -324,11 +347,18 @@ class CaptureSource:
|
||||
if self._cap is not None:
|
||||
try:
|
||||
self._cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
# A failed release is not fatal, but it must not be invisible: the
|
||||
# handle below is dropped either way, so a live-camera handle that
|
||||
# OpenCV refused to free would otherwise leak silently until the
|
||||
# process exits. Report it and keep tearing down.
|
||||
logger.warning(
|
||||
"capture: %r.release() failed: %s", self._backend_name or "capture", exc
|
||||
)
|
||||
self._cap = None
|
||||
with self._frame_cond:
|
||||
self._latest_frame = None
|
||||
self._latest_captured = None
|
||||
self._latest_seq = 0
|
||||
|
||||
def _refresh_stream_meta_unlocked(self) -> None:
|
||||
@@ -341,8 +371,11 @@ class CaptureSource:
|
||||
self._status.height = height
|
||||
self._status.fps = fps
|
||||
self._status.fourcc = fourcc
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
# Metadata is advisory: a backend that cannot answer these get() calls
|
||||
# still delivers frames, so do not fail the open. Record why the
|
||||
# reported stream geometry is left at its previous/default values.
|
||||
logger.warning("capture: could not read stream metadata: %s", exc)
|
||||
|
||||
def warmup_frames(self, count: int = 3) -> None:
|
||||
"""Discard frames so the driver/format stabilizes (sync reads only)."""
|
||||
@@ -351,53 +384,92 @@ class CaptureSource:
|
||||
for _ in range(max(0, int(count))):
|
||||
self.read_raw()
|
||||
|
||||
def read_raw(self) -> tuple[bool, Optional[np.ndarray]]:
|
||||
"""Read one frame directly from the underlying capture (no reconnect)."""
|
||||
def read_raw_stamped(self) -> CapturedFrame | None:
|
||||
"""Read a frame and bind its identity and clocks before another read."""
|
||||
cap = self._cap
|
||||
if cap is None:
|
||||
return False, None
|
||||
return None
|
||||
try:
|
||||
ok, frame = cap.read()
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
self._status.consecutive_failures += 1
|
||||
self._status.last_error = str(exc)
|
||||
return False, None
|
||||
return None
|
||||
if not ok or frame is None:
|
||||
with self._lock:
|
||||
self._status.consecutive_failures += 1
|
||||
return False, None
|
||||
return None
|
||||
host_mono_ns = time.perf_counter_ns()
|
||||
host_utc_ns = time.time_ns()
|
||||
media_time_ms = None
|
||||
if not is_live_camera(self._status.source):
|
||||
try:
|
||||
value = float(cap.get(cv2.CAP_PROP_POS_MSEC))
|
||||
if isfinite(value) and value >= 0:
|
||||
media_time_ms = value
|
||||
except Exception as exc: # noqa: BLE001 - metadata must not stop capture
|
||||
logger.debug("capture: media timestamp unavailable: %s", exc)
|
||||
with self._lock:
|
||||
self._read_seq += 1
|
||||
sequence = self._read_seq
|
||||
self._status.consecutive_failures = 0
|
||||
self._status.last_frame_time = time.perf_counter()
|
||||
self._status.last_frame_time = host_mono_ns / 1e9
|
||||
if self._status.health != CaptureHealth.CONNECTED:
|
||||
self._status.health = CaptureHealth.CONNECTED
|
||||
return True, frame
|
||||
return CapturedFrame(frame, sequence, host_mono_ns, host_utc_ns, media_time_ms)
|
||||
|
||||
def read_raw(self) -> tuple[bool, np.ndarray | None]:
|
||||
"""Compatibility read without capture metadata."""
|
||||
captured = self.read_raw_stamped()
|
||||
return (captured is not None, None if captured is None else captured.image)
|
||||
|
||||
def read_latest_stamped(
|
||||
self, *, copy: bool = True, wait_s: float = 0.0
|
||||
) -> CapturedFrame | None:
|
||||
"""Return a frame and its own stamp atomically from the background slot."""
|
||||
if not self._background_running:
|
||||
captured = self.read_raw_stamped()
|
||||
if captured is not None and copy:
|
||||
return CapturedFrame(
|
||||
captured.image.copy(),
|
||||
captured.sequence,
|
||||
captured.host_mono_ns,
|
||||
captured.host_utc_ns,
|
||||
captured.media_time_ms,
|
||||
)
|
||||
return captured
|
||||
|
||||
deadline = time.perf_counter() + max(0.0, wait_s)
|
||||
with self._frame_cond:
|
||||
while self._latest_captured is None:
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
self._frame_cond.wait(timeout=remaining)
|
||||
captured = self._latest_captured
|
||||
self._last_delivered_seq = captured.sequence
|
||||
if copy:
|
||||
return CapturedFrame(
|
||||
captured.image.copy(),
|
||||
captured.sequence,
|
||||
captured.host_mono_ns,
|
||||
captured.host_utc_ns,
|
||||
captured.media_time_ms,
|
||||
)
|
||||
return captured
|
||||
|
||||
def read_latest(
|
||||
self,
|
||||
*,
|
||||
copy: bool = True,
|
||||
wait_s: float = 0.0,
|
||||
) -> tuple[bool, Optional[np.ndarray]]:
|
||||
) -> tuple[bool, np.ndarray | None]:
|
||||
"""Return the newest frame from the background thread, or read sync."""
|
||||
if not self._background_running:
|
||||
ok, frame = self.read_raw()
|
||||
if ok and copy and frame is not None:
|
||||
return ok, frame.copy()
|
||||
return ok, frame
|
||||
captured = self.read_latest_stamped(copy=copy, wait_s=wait_s)
|
||||
return (captured is not None, None if captured is None else captured.image)
|
||||
|
||||
deadline = time.perf_counter() + max(0.0, wait_s)
|
||||
with self._frame_cond:
|
||||
while self._latest_frame is None:
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining <= 0:
|
||||
return False, None
|
||||
self._frame_cond.wait(timeout=remaining)
|
||||
frame = self._latest_frame.copy() if copy else self._latest_frame
|
||||
return True, frame
|
||||
|
||||
def read(self, *, copy: bool = True, wait_s: float = 1.0) -> tuple[bool, Optional[np.ndarray]]:
|
||||
def read(self, *, copy: bool = True, wait_s: float = 1.0) -> tuple[bool, np.ndarray | None]:
|
||||
"""Compat alias used by finger runtime and older worker callers."""
|
||||
wait = wait_s if wait_s > 0 else 1.0
|
||||
return self.read_latest(copy=copy, wait_s=wait)
|
||||
@@ -429,6 +501,7 @@ class CaptureSource:
|
||||
self._thread = None
|
||||
with self._frame_cond:
|
||||
self._latest_frame = None
|
||||
self._latest_captured = None
|
||||
|
||||
def stop(self, timeout: float = 1.0) -> None:
|
||||
"""Compat alias for ``stop_background``."""
|
||||
@@ -443,19 +516,23 @@ class CaptureSource:
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
|
||||
ok, frame = self.read_raw()
|
||||
if not ok or frame is None:
|
||||
captured = self.read_raw_stamped()
|
||||
if captured is None:
|
||||
if self.should_reconnect():
|
||||
continue
|
||||
time.sleep(0.001)
|
||||
continue
|
||||
|
||||
with self._frame_cond:
|
||||
if self._latest_frame is not None:
|
||||
if (
|
||||
self._latest_captured is not None
|
||||
and self._latest_captured.sequence > self._last_delivered_seq
|
||||
):
|
||||
with self._lock:
|
||||
self._status.dropped_frames += 1
|
||||
self._latest_seq += 1
|
||||
self._latest_frame = frame
|
||||
self._latest_seq = captured.sequence
|
||||
self._latest_frame = captured.image
|
||||
self._latest_captured = captured
|
||||
with self._lock:
|
||||
self._status.last_seq = self._latest_seq
|
||||
self._frame_cond.notify_all()
|
||||
@@ -531,8 +608,8 @@ def open_capture(
|
||||
config: CaptureConfig,
|
||||
*,
|
||||
factory=None,
|
||||
start_background: Optional[bool] = None,
|
||||
start_worker: Optional[bool] = None,
|
||||
start_background: bool | None = None,
|
||||
start_worker: bool | None = None,
|
||||
) -> CaptureSource:
|
||||
"""Open a capture source and optionally start its background thread.
|
||||
|
||||
|
||||
+167
-20
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass
|
||||
from importlib.resources import as_file, files
|
||||
@@ -20,34 +22,145 @@ def _resource_path(*parts: str) -> Path | None:
|
||||
"""Resolve a package resource to a filesystem path for wheel-safe loaders."""
|
||||
try:
|
||||
resource = files("orisys").joinpath(*parts)
|
||||
return _RESOURCE_STACK.enter_context(as_file(resource))
|
||||
resolved = _RESOURCE_STACK.enter_context(as_file(resource))
|
||||
except Exception:
|
||||
return None
|
||||
# ``as_file`` is not contractually a ``pathlib.Path``: a Nuitka-compiled
|
||||
# build hands back its own ``nuitka_resource_reader_files``, which is
|
||||
# path-like but has no ``.resolve()``. Normalise here so every caller can
|
||||
# treat the result as a Path.
|
||||
try:
|
||||
return Path(os.fspath(resolved))
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_config_dirs() -> list[Path]:
|
||||
def _unique_paths(paths) -> list[Path]:
|
||||
"""Ordered, de-duplicated paths (Windows comparison is case-insensitive)."""
|
||||
unique: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for path in paths:
|
||||
path = Path(path) if not isinstance(path, Path) else path
|
||||
try:
|
||||
key = str(path.resolve()).casefold()
|
||||
except (OSError, ValueError): # pragma: no cover - defensive
|
||||
key = str(path).casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(path)
|
||||
return unique
|
||||
|
||||
|
||||
def _is_sdk_runtime_root(path: Path) -> bool:
|
||||
"""Whether ``path`` carries the runnable SDK layout.
|
||||
|
||||
The anchor is the **folder layout**, never the folder's name: a distribution
|
||||
folder may be called anything (a release checkout, a renamed copy, an
|
||||
unpacked zip), but it always ships ``examples/`` beside ``sensors/``. That is
|
||||
what distinguishes a run root from an arbitrary directory, and it is why a
|
||||
renamed root keeps working while ``dist/**`` (``sensors/`` without
|
||||
``examples/``) is not mistaken for one.
|
||||
"""
|
||||
Candidate config directories for editable installs and packaged wheels.
|
||||
try:
|
||||
if not path.is_dir():
|
||||
return False
|
||||
return (path / "examples").is_dir() and (path / "sensors").is_dir()
|
||||
except OSError: # pragma: no cover - defensive: unreadable path
|
||||
return False
|
||||
|
||||
|
||||
def _runtime_roots() -> list[Path]:
|
||||
"""Runnable SDK roots for this process, nearest to the launcher first.
|
||||
|
||||
Evaluated once at import time, so a process that changes directory later
|
||||
keeps the answer it started with.
|
||||
"""
|
||||
anchors: list[Path] = []
|
||||
|
||||
def add(path: Path) -> None:
|
||||
if path not in anchors:
|
||||
anchors.append(path)
|
||||
|
||||
cwd = Path.cwd()
|
||||
add(cwd)
|
||||
|
||||
# A script run from a subdirectory (``<root>/examples/foo.py``, or a caller
|
||||
# that launched from ``<root>/scripts``) still names its root through argv[0].
|
||||
script_dir: Path | None = None
|
||||
try:
|
||||
argv0 = sys.argv[0] if sys.argv else ""
|
||||
if argv0:
|
||||
candidate = Path(argv0).expanduser()
|
||||
script_dir = candidate.resolve().parent if candidate.is_file() else None
|
||||
except (OSError, ValueError): # pragma: no cover - defensive
|
||||
script_dir = None
|
||||
|
||||
# Walk up from both, so the root is found however deep the launcher sits.
|
||||
for start in (cwd, script_dir):
|
||||
if start is None:
|
||||
continue
|
||||
for candidate in (start, *start.parents):
|
||||
add(candidate)
|
||||
|
||||
# Roots that put themselves on sys.path (``python <root>/examples/foo.py``
|
||||
# with the root as cwd, ``PYTHONPATH=<root>``, and similar).
|
||||
for entry in list(sys.path):
|
||||
if not entry:
|
||||
continue
|
||||
try:
|
||||
add(Path(entry).expanduser())
|
||||
except (OSError, ValueError): # pragma: no cover - defensive
|
||||
continue
|
||||
|
||||
anchors = _unique_paths(anchors)
|
||||
# The run root is the *nearest* runnable directory, walking up from the
|
||||
# launcher: starting at the cwd, then the script's directory, then the
|
||||
# entries sys.path. Order carries the meaning here -- containment does not.
|
||||
# Scanning every anchor for the shortest match instead picked the shallowest
|
||||
# ancestor, so a workspace that carried ``examples/`` alongside ``sensors/``
|
||||
# absorbed the real root below it and its bundle shadowed that root's own.
|
||||
roots: list[Path] = []
|
||||
for path in anchors:
|
||||
if not _is_sdk_runtime_root(path):
|
||||
continue
|
||||
if any(existing.is_relative_to(path) or path.is_relative_to(existing) for existing in roots):
|
||||
# Same tree: only the nearest root counts.
|
||||
continue
|
||||
roots.append(path)
|
||||
return roots
|
||||
|
||||
|
||||
def _candidate_config_dirs() -> list[Path]:
|
||||
"""Candidate config directories, the running SDK root first.
|
||||
|
||||
A run root's ``config/`` outranks the packaged copy so a bundle's
|
||||
``sensor.json`` and its ``force_calibration.json`` come from the same place.
|
||||
"""
|
||||
here = Path(__file__).resolve().parent
|
||||
candidates = [
|
||||
return _unique_paths([
|
||||
*(root / "config" for root in _runtime_roots()),
|
||||
here / "configs", # packaged wheel layout: orisys/configs/*.json
|
||||
here.parent.parent / "config", # editable install from repo: repo_root/config/*.json
|
||||
here.parent / "config", # fallback
|
||||
Path.cwd() / "config", # script launched from repo root
|
||||
]
|
||||
|
||||
unique: list[Path] = []
|
||||
for path in candidates:
|
||||
if path not in unique:
|
||||
unique.append(path)
|
||||
return unique
|
||||
])
|
||||
|
||||
|
||||
def _candidate_sensor_roots() -> list[Path]:
|
||||
"""Candidate roots that contain per-sensor bundle folders."""
|
||||
"""Candidate roots that contain per-sensor bundle folders.
|
||||
|
||||
Lookup order:
|
||||
|
||||
1. the ``sensors/`` of every runnable SDK root found at startup, nearest
|
||||
first -- so the bundle under the directory that is actually running wins,
|
||||
whatever that directory is called;
|
||||
2. the packaged resource ``orisys/sensors`` (wheel installs);
|
||||
3. the module-relative candidates (editable checkout, packaged tree);
|
||||
4. the current working directory, last, as a safety net.
|
||||
"""
|
||||
here = Path(__file__).resolve().parent
|
||||
candidates = []
|
||||
candidates = [root / "sensors" for root in _runtime_roots()]
|
||||
packaged = _resource_path("sensors")
|
||||
if packaged is not None:
|
||||
candidates.append(packaged) # packaged wheel layout: orisys/sensors/
|
||||
@@ -57,11 +170,7 @@ def _candidate_sensor_roots() -> list[Path]:
|
||||
here.parent / "sensors",
|
||||
Path.cwd() / "sensors",
|
||||
])
|
||||
unique: list[Path] = []
|
||||
for path in candidates:
|
||||
if path not in unique:
|
||||
unique.append(path)
|
||||
return unique
|
||||
return _unique_paths(candidates)
|
||||
|
||||
|
||||
def _get_config_dir() -> Path:
|
||||
@@ -82,7 +191,7 @@ def _config_files() -> list[Path]:
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
@@ -130,6 +239,44 @@ def resolve_sensor_bundle(name: str) -> SensorBundle | None:
|
||||
return None
|
||||
|
||||
|
||||
def describe_bundle_resolution(name: str) -> dict:
|
||||
"""Explain how ``name`` resolved, so a missing calibration stops being silent.
|
||||
|
||||
A bundle can resolve and still carry no ``force_calibration.json``: the
|
||||
lookup stops at the first root that has ``<name>/sensor.json`` and never
|
||||
asks whether that root also has the coefficients. Reporting which bundle was
|
||||
found -- and which roots were passed over -- is what makes that case
|
||||
distinguishable from "no bundle at all", which is otherwise what the caller
|
||||
sees.
|
||||
|
||||
Returns ``name``, the ``searched`` roots in order, and the first root that
|
||||
supplied ``<name>/sensor.json`` together with whether it carried a
|
||||
``force_calibration.json``.
|
||||
"""
|
||||
requested = CONFIG_ALIASES.get(str(name), str(name))
|
||||
searched = _candidate_sensor_roots()
|
||||
for root in searched:
|
||||
sensor_json = root / requested / "sensor.json"
|
||||
if not sensor_json.is_file():
|
||||
continue
|
||||
return {
|
||||
"name": requested,
|
||||
"searched": searched,
|
||||
"found_root": sensor_json.parent,
|
||||
"sensor_json": sensor_json,
|
||||
"force_calibration_json": (
|
||||
sensor_json.parent / "force_calibration.json"
|
||||
).is_file(),
|
||||
}
|
||||
return {
|
||||
"name": requested,
|
||||
"searched": searched,
|
||||
"found_root": None,
|
||||
"sensor_json": None,
|
||||
"force_calibration_json": False,
|
||||
}
|
||||
|
||||
|
||||
def resolve_sensor_config_path(name: str) -> Path | None:
|
||||
"""Return ``sensor.json`` for a resolved sensor bundle."""
|
||||
bundle = resolve_sensor_bundle(name)
|
||||
@@ -286,7 +433,7 @@ def load_config(config_name_or_path):
|
||||
for candidate in _config_files():
|
||||
try:
|
||||
data = _load_json(candidate)
|
||||
except Exception:
|
||||
except Exception: # noqa: S112 - skip unreadable config during alias scan
|
||||
continue
|
||||
if data.get("name") in candidates:
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Record tactile arrow overlays to video without stalling the pipeline.
|
||||
|
||||
Drawing the arrow grid costs a few milliseconds per frame (hundreds of
|
||||
``cv2.arrowedLine`` calls) and encoding costs a few more, so doing it inline
|
||||
would cut a stream's throughput. Instead:
|
||||
|
||||
* :meth:`StreamRecorder.on_frame` is designed to be the pipeline's ``on_frame``
|
||||
callback: it only performs a bounded, drop-oldest queue push, so the slowest
|
||||
pipeline stage is unaffected and the run never blocks on the encoder;
|
||||
* a dedicated writer thread does the drawing and the ``cv2.VideoWriter`` calls;
|
||||
* when the writer falls behind, the newest frames win and the rest are counted
|
||||
in :meth:`StreamRecorder.stats` so a recording gap is never silent.
|
||||
|
||||
Typical use::
|
||||
|
||||
recorder = StreamRecorder("run/stream_0.mp4", label="#0",
|
||||
valid_mask=lambda: runtime.valid_mask)
|
||||
pipe = StreamPipeline(sensor, produce, include=("image", "flow"),
|
||||
on_frame=recorder.on_frame)
|
||||
pipe.start()
|
||||
...
|
||||
print(recorder.close()) # written / dropped / fps
|
||||
|
||||
Per-stream files are the natural unit because each stream is its own process;
|
||||
:func:`mosaic_videos` tiles them into one grid afterwards, which costs nothing
|
||||
during the run.
|
||||
|
||||
Arrow parameters
|
||||
----------------
|
||||
``draw_arrows`` divides the sampled displacement by ``arrow_scale``, so the
|
||||
defaults assume the finger's UV domain, where the active-region displacement is
|
||||
only ~1-3 px: ``grid_spacing=14, arrow_scale=0.15, threshold=0.1`` magnifies it
|
||||
~6.7x, which is what makes the direction field legible instead of a scatter of
|
||||
dots (the ``draw_arrows`` defaults of ``arrow_scale=1.0, threshold=5`` render the
|
||||
raw pixel displacement and are tuned for large-motion scenes). Length saturates
|
||||
at ``grid_spacing * margin`` px, so colour - not length - carries the magnitude.
|
||||
A box/mini stream at 365x365 needs its own calibration of ``arrow_scale``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .util import draw_arrows, draw_contact
|
||||
|
||||
__all__ = ["StreamRecorder", "mosaic_videos"]
|
||||
|
||||
|
||||
class StreamRecorder:
|
||||
"""Draw arrow overlays from snapshots and encode them to a video file."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
fps: float = 30.0,
|
||||
every: int = 1,
|
||||
fourcc: str = "mp4v",
|
||||
valid_mask: np.ndarray | Callable[[], np.ndarray | None] | None = None,
|
||||
grid_spacing: int = 14,
|
||||
threshold: float = 0.1,
|
||||
arrow_scale: float = 0.15,
|
||||
margin: float = 0.8,
|
||||
draw_contact: bool = False,
|
||||
label: str | None = None,
|
||||
overlay_values: bool = True,
|
||||
queue_depth: int = 8,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
self.path = str(path)
|
||||
self.fps = max(1.0, float(fps))
|
||||
self.every = max(1, int(every))
|
||||
self.fourcc = fourcc
|
||||
# A callable is resolved once per frame: the SDK rebuilds
|
||||
# ``Sensor.valid_mask`` during ``get_img``, so holding an array taken
|
||||
# before the first frame would capture ``None``.
|
||||
self.valid_mask = valid_mask
|
||||
self.grid_spacing = int(grid_spacing)
|
||||
self.threshold = float(threshold)
|
||||
self.arrow_scale = float(arrow_scale)
|
||||
self.margin = float(margin)
|
||||
self.draw_contact_flag = bool(draw_contact)
|
||||
self.label = label
|
||||
self.overlay_values = bool(overlay_values)
|
||||
self.verbose = bool(verbose)
|
||||
|
||||
self._queue: queue.Queue = queue.Queue(maxsize=max(1, int(queue_depth)))
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._seen = 0
|
||||
self._written = 0
|
||||
self._dropped = 0
|
||||
self._skipped = 0
|
||||
self._error: BaseException | None = None
|
||||
self._writer: cv2.VideoWriter | None = None
|
||||
self._size: tuple[int, int] | None = None
|
||||
|
||||
# ------------------------------------------------------------------ ingest
|
||||
def on_frame(self, snap: Any) -> None:
|
||||
"""Pipeline callback: enqueue one snapshot. Never blocks."""
|
||||
self._seen += 1
|
||||
if self.every > 1 and (self._seen - 1) % self.every:
|
||||
self._skipped += 1
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
self._queue.put_nowait(snap)
|
||||
break
|
||||
except queue.Full:
|
||||
# Drop the oldest so the newest frame is always kept.
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
self._dropped += 1
|
||||
except queue.Empty:
|
||||
pass
|
||||
self._ensure_thread()
|
||||
|
||||
def _ensure_thread(self) -> None:
|
||||
if self._thread is None:
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True,
|
||||
name="orisys-recorder")
|
||||
self._thread.start()
|
||||
|
||||
# ------------------------------------------------------------------ writer
|
||||
def _open(self, frame: np.ndarray) -> None:
|
||||
h, w = frame.shape[:2]
|
||||
os.makedirs(os.path.dirname(os.path.abspath(self.path)) or ".", exist_ok=True)
|
||||
fourcc = cv2.VideoWriter_fourcc(*self.fourcc) if len(self.fourcc) == 4 else 0
|
||||
writer = cv2.VideoWriter(self.path, fourcc, self.fps, (w, h), True)
|
||||
if not writer.isOpened():
|
||||
raise RuntimeError(
|
||||
f"cv2.VideoWriter could not open {self.path!r} with fourcc={self.fourcc!r}"
|
||||
)
|
||||
self._writer = writer
|
||||
self._size = (w, h)
|
||||
if self.verbose:
|
||||
print(f"[recorder] {self.path} {w}x{h} @ {self.fps:g} fps fourcc={self.fourcc}")
|
||||
|
||||
def _render(self, snap: Any) -> np.ndarray:
|
||||
if snap.image is None or snap.flow is None:
|
||||
raise RuntimeError(
|
||||
"recording needs image and flow in the snapshot; build the pipeline "
|
||||
'with include=("image", "flow")'
|
||||
)
|
||||
mask = self.valid_mask() if callable(self.valid_mask) else self.valid_mask
|
||||
image = draw_arrows(
|
||||
snap.image,
|
||||
snap.flow,
|
||||
threshold=self.threshold,
|
||||
grid_spacing=self.grid_spacing,
|
||||
arrow_scale=self.arrow_scale,
|
||||
margin=self.margin,
|
||||
valid_mask=mask,
|
||||
)
|
||||
if self.draw_contact_flag and snap.contour is not None and snap.centroid is not None:
|
||||
image = draw_contact(image, snap.contour, snap.centroid)
|
||||
if self.overlay_values or self.label:
|
||||
self._draw_hud(image, snap)
|
||||
return image
|
||||
|
||||
def _draw_hud(self, image: np.ndarray, snap: Any) -> None:
|
||||
lines = []
|
||||
if self.label:
|
||||
lines.append(str(self.label))
|
||||
if self.overlay_values:
|
||||
lines.append(f"{snap.unit} fn={snap.normal_force:.3g} "
|
||||
f"fx={snap.shear_x:.3g} fy={snap.shear_y:.3g}")
|
||||
lines.append(f"{snap.fps:.0f} FPS {snap.age_ms:.1f} ms")
|
||||
y = 14
|
||||
for text in lines:
|
||||
cv2.putText(image, text, (4, y), cv2.FONT_HERSHEY_SIMPLEX, 0.38,
|
||||
(0, 0, 0), 3, cv2.LINE_AA)
|
||||
cv2.putText(image, text, (4, y), cv2.FONT_HERSHEY_SIMPLEX, 0.38,
|
||||
(255, 255, 255), 1, cv2.LINE_AA)
|
||||
y += 14
|
||||
|
||||
def _loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
snap = self._queue.get(timeout=0.05)
|
||||
except queue.Empty:
|
||||
if self._stop.is_set():
|
||||
break
|
||||
continue
|
||||
if snap is None:
|
||||
break
|
||||
try:
|
||||
frame = self._render(snap)
|
||||
if self._writer is None:
|
||||
self._open(frame)
|
||||
self._writer.write(frame)
|
||||
self._written += 1
|
||||
except BaseException as exc:
|
||||
self._error = exc
|
||||
break
|
||||
finally:
|
||||
if self._writer is not None:
|
||||
self._writer.release()
|
||||
self._writer = None
|
||||
|
||||
# ------------------------------------------------------------------- close
|
||||
def close(self, timeout: float = 30.0) -> dict:
|
||||
"""Drain, stop the writer thread and release the file."""
|
||||
if self._thread is not None:
|
||||
self._stop.set()
|
||||
# The stop flag is the real mechanism - the writer drains what is
|
||||
# queued and exits on its next empty poll. The sentinel only makes
|
||||
# that immediate, so it must never block: if the writer died on an
|
||||
# error the queue may be full and nobody would ever drain it.
|
||||
try:
|
||||
self._queue.put_nowait(None)
|
||||
except queue.Full:
|
||||
pass
|
||||
self._thread.join(timeout=timeout)
|
||||
alive = self._thread.is_alive()
|
||||
self._thread = None
|
||||
else:
|
||||
alive = False
|
||||
stats = self.stats()
|
||||
stats["thread_alive"] = alive
|
||||
if self._error is not None:
|
||||
stats["error"] = f"{type(self._error).__name__}: {self._error}"
|
||||
if self.verbose:
|
||||
print(f"[recorder] {self.path}: {stats['error']}")
|
||||
return stats
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"path": self.path,
|
||||
"seen": self._seen,
|
||||
"written": self._written,
|
||||
"dropped": self._dropped,
|
||||
"skipped": self._skipped,
|
||||
"fps": self.fps,
|
||||
"fourcc": self.fourcc,
|
||||
}
|
||||
|
||||
def __enter__(self) -> StreamRecorder:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
self.close()
|
||||
return False
|
||||
|
||||
|
||||
def mosaic_videos(
|
||||
paths: Sequence[str],
|
||||
out_path: str,
|
||||
*,
|
||||
cols: int | None = None,
|
||||
labels: Iterable[str] | None = None,
|
||||
scale: float = 1.0,
|
||||
gap: int = 4,
|
||||
fps: float | None = None,
|
||||
fourcc: str = "mp4v",
|
||||
) -> dict:
|
||||
"""Tile per-stream recordings into one grid video.
|
||||
|
||||
Post-processing only: run it after the streams have finished, so it costs
|
||||
nothing while they are running. Shorter inputs hold their last frame.
|
||||
"""
|
||||
paths = [str(p) for p in paths]
|
||||
if not paths:
|
||||
raise ValueError("mosaic_videos needs at least one input")
|
||||
for path in paths:
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
caps = [cv2.VideoCapture(p) for p in paths]
|
||||
try:
|
||||
for path, cap in zip(paths, caps):
|
||||
if not cap.isOpened():
|
||||
raise RuntimeError(f"cannot open {path!r}")
|
||||
tiles = []
|
||||
for cap in caps:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
raise RuntimeError("an input video has no frames")
|
||||
tiles.append(frame)
|
||||
|
||||
if scale != 1.0:
|
||||
tiles = [cv2.resize(t, None, fx=scale, fy=scale,
|
||||
interpolation=cv2.INTER_AREA) for t in tiles]
|
||||
th, tw = tiles[0].shape[:2]
|
||||
n = len(tiles)
|
||||
cols = int(cols) if cols else int(np.ceil(np.sqrt(n)))
|
||||
cols = max(1, min(cols, n))
|
||||
rows = int(np.ceil(n / cols))
|
||||
label_list = [str(x) for x in labels] if labels is not None else []
|
||||
|
||||
out_h = rows * th + (rows - 1) * gap
|
||||
out_w = cols * tw + (cols - 1) * gap
|
||||
canvas = np.zeros((out_h, out_w, 3), dtype=np.uint8)
|
||||
positions = []
|
||||
for i in range(n):
|
||||
r, c = divmod(i, cols)
|
||||
positions.append((r * (th + gap), c * (tw + gap)))
|
||||
# Frame 0 is already in hand; seed the canvas with it so the output is
|
||||
# never short by one frame.
|
||||
for i, tile in enumerate(tiles):
|
||||
y, x = positions[i]
|
||||
canvas[y:y + th, x:x + tw] = tile
|
||||
|
||||
out_fps = float(fps) if fps else float(caps[0].get(cv2.CAP_PROP_FPS) or 30.0)
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
|
||||
writer = cv2.VideoWriter(out_path, cv2.VideoWriter_fourcc(*fourcc), out_fps,
|
||||
(out_w, out_h), True)
|
||||
if not writer.isOpened():
|
||||
raise RuntimeError(f"cannot open {out_path!r} for writing")
|
||||
|
||||
def emit() -> None:
|
||||
# Labels go on the canvas, not on the tiles: the read loop below
|
||||
# overwrites each tile region every frame.
|
||||
for i, text in enumerate(label_list):
|
||||
if i >= n:
|
||||
break
|
||||
y, x = positions[i]
|
||||
org = (x + 4, y + th - 6)
|
||||
cv2.putText(canvas, text, org, cv2.FONT_HERSHEY_SIMPLEX, 0.45,
|
||||
(0, 0, 0), 3, cv2.LINE_AA)
|
||||
cv2.putText(canvas, text, org, cv2.FONT_HERSHEY_SIMPLEX, 0.45,
|
||||
(255, 255, 255), 1, cv2.LINE_AA)
|
||||
writer.write(canvas)
|
||||
|
||||
written = 0
|
||||
try:
|
||||
# One frame per input per iteration; a spent input keeps its last
|
||||
# frame so the grid stays full until every input has run out.
|
||||
while True:
|
||||
emit()
|
||||
written += 1
|
||||
progressed = False
|
||||
for i, cap in enumerate(caps):
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
continue
|
||||
progressed = True
|
||||
if scale != 1.0:
|
||||
frame = cv2.resize(frame, (tw, th), interpolation=cv2.INTER_AREA)
|
||||
y, x = positions[i]
|
||||
canvas[y:y + th, x:x + tw] = frame
|
||||
if not progressed:
|
||||
break
|
||||
finally:
|
||||
writer.release()
|
||||
finally:
|
||||
for cap in caps:
|
||||
cap.release()
|
||||
|
||||
return {
|
||||
"path": out_path,
|
||||
"frames": written,
|
||||
"fps": out_fps,
|
||||
"size": (out_w, out_h),
|
||||
"grid": (rows, cols),
|
||||
"scale": scale,
|
||||
}
|
||||
@@ -18,8 +18,8 @@ from .mesh import (
|
||||
lift_uv_deformation_to_3d,
|
||||
lift_uv_flow_to_3d,
|
||||
mesh_uv_to_world_scale,
|
||||
scalar_map_vertex_colors,
|
||||
sample_field_at_mesh_vertices,
|
||||
scalar_map_vertex_colors,
|
||||
)
|
||||
from .runtime import (
|
||||
FingerGeometry,
|
||||
@@ -39,8 +39,8 @@ __all__ = [
|
||||
"FingerGeometry",
|
||||
"FingerRuntime",
|
||||
"TactileProcessor",
|
||||
"UVSurfaceMesh",
|
||||
"UVMeshViewer",
|
||||
"UVSurfaceMesh",
|
||||
"build_uv_surface_mesh",
|
||||
"compute_uv_surface_frames",
|
||||
"deform_mesh_vertices",
|
||||
|
||||
@@ -12,15 +12,15 @@ class UVSurfaceMesh:
|
||||
"""Triangle mesh built from a UV flatten grid and world_xyz surface map."""
|
||||
|
||||
__slots__ = (
|
||||
"rest_vertices",
|
||||
"triangles",
|
||||
"vertex_ys",
|
||||
"vertex_xs",
|
||||
"index_map",
|
||||
"valid_mask",
|
||||
"shape",
|
||||
"world_xyz",
|
||||
"base_colors",
|
||||
"index_map",
|
||||
"rest_vertices",
|
||||
"shape",
|
||||
"triangles",
|
||||
"valid_mask",
|
||||
"vertex_xs",
|
||||
"vertex_ys",
|
||||
"world_xyz",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -101,6 +101,21 @@ class TactileProcessor:
|
||||
def valid_mask(self):
|
||||
return self._sensor.valid_mask
|
||||
|
||||
@property
|
||||
def force_units(self) -> tuple[str, str, str]:
|
||||
return self._sensor.force_units
|
||||
|
||||
@property
|
||||
def calibration_status(self):
|
||||
return self._sensor.calibration_status
|
||||
|
||||
@property
|
||||
def calibration_result(self):
|
||||
return self._sensor.calibration_result
|
||||
|
||||
def set_calibration_reference(self, raw, *, reference_id):
|
||||
return self._sensor.set_calibration_reference(raw, reference_id=reference_id)
|
||||
|
||||
def push_frame(self, frame, *, already_gray=False, valid_mask=None):
|
||||
"""Feed one processed tactile frame into the analysis pipeline."""
|
||||
mask = self._default_valid_mask if valid_mask is None else valid_mask
|
||||
@@ -114,6 +129,21 @@ class TactileProcessor:
|
||||
def warmup(self):
|
||||
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):
|
||||
return self._sensor.compute_deformation(*args, **kwargs)
|
||||
|
||||
@@ -239,6 +269,9 @@ class FingerRuntime:
|
||||
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
||||
source_scale=None,
|
||||
calib=None,
|
||||
cal_path=None,
|
||||
calibration_dir=None,
|
||||
device_id=None,
|
||||
video=0,
|
||||
camera_id=None,
|
||||
flip_source_vertical=True,
|
||||
@@ -246,7 +279,7 @@ class FingerRuntime:
|
||||
mirror=False,
|
||||
auto_reconnect: bool = False,
|
||||
capture_mode: str = "latest",
|
||||
) -> "FingerRuntime":
|
||||
) -> FingerRuntime:
|
||||
"""Open a live finger runtime from export bundle, config, and video source."""
|
||||
del capture_mode # deprecated; latest-frame capture only
|
||||
if camera_id is not None:
|
||||
@@ -278,6 +311,32 @@ class FingerRuntime:
|
||||
)
|
||||
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,
|
||||
)
|
||||
if getattr(getattr(sensor, "calibration", None), "schema_version", None) == 3:
|
||||
from ..calibration_context import (
|
||||
build_context,
|
||||
domain_fingerprint,
|
||||
sha256_file,
|
||||
)
|
||||
|
||||
model = sensor.calibration
|
||||
uv_map = getattr(model, "coordinate_map", None)
|
||||
if model.input_profile == "fn_delta_autocal_uv/1":
|
||||
if uv_map is None or uv_map.shape[:2] != (geometry.out_height, geometry.out_width):
|
||||
raise ValueError("calibration coordinate map does not match finger output shape")
|
||||
sensor.bind_calibration_context(build_context(
|
||||
sensor._config, sensor.filters_state(),
|
||||
geometry_sha256=sha256_file(mesh_path), source_scale=geometry.source_scale,
|
||||
coordinates=model.pipeline.get("coordinates"),
|
||||
output_domain_sha256=domain_fingerprint(domain),
|
||||
processing_backend="uv_unroll",
|
||||
preprocessing={"flip_source_vertical": flip_source_vertical, "mirror": mirror},
|
||||
))
|
||||
|
||||
capture_config = CaptureConfig.from_camera_dict(
|
||||
video,
|
||||
geometry.config.get("camera", {}),
|
||||
@@ -292,7 +351,15 @@ class FingerRuntime:
|
||||
capture_source.start_background()
|
||||
capture = capture_source.cap
|
||||
|
||||
sensor = Sensor("external", config_name=config, verbose=verbose, mirror=mirror)
|
||||
if not is_live_camera(video):
|
||||
# File playback: wall-clock cadence is not signal time, so pin the
|
||||
# force-filter design rate to the configured camera fps.
|
||||
try:
|
||||
fps = float(geometry.config.get("camera", {}).get("fps", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
fps = 0.0
|
||||
if fps > 0:
|
||||
sensor.configure(lowpass_sample_rate_hz=fps)
|
||||
frame_processor = FrameUnwarper(
|
||||
domain.remap,
|
||||
camera_matrix=geometry.source_K if geometry.dist_coeffs is not None else None,
|
||||
@@ -315,6 +382,15 @@ class FingerRuntime:
|
||||
def valid_mask(self):
|
||||
return self.frame_processor.valid_mask
|
||||
|
||||
def set_calibration_reference(self, raw, *, reference_id):
|
||||
"""Set an explicitly measured unloaded reference for this finger stream."""
|
||||
self.tactile.raw_sensor.set_calibration_reference(raw, reference_id=reference_id)
|
||||
|
||||
@property
|
||||
def calibration_status(self):
|
||||
"""Copy the current per-axis calibration validity state."""
|
||||
return dict(self.tactile.raw_sensor.calibration_status)
|
||||
|
||||
def read_source(self):
|
||||
if self.capture_source is not None and self.capture_source.running:
|
||||
return self.capture_source.read(copy=True, wait_s=1.0)
|
||||
@@ -322,6 +398,14 @@ class FingerRuntime:
|
||||
return self.capture_source.read_raw()
|
||||
return self.capture.read()
|
||||
|
||||
def read_source_stamped(self):
|
||||
"""Read the source image with its capture-thread identity and host clocks."""
|
||||
if self.capture_source is None:
|
||||
return None
|
||||
if self.capture_source.running:
|
||||
return self.capture_source.read_latest_stamped(copy=True, wait_s=1.0)
|
||||
return self.capture_source.read_raw_stamped()
|
||||
|
||||
def process_frame(self, frame, *, color=False):
|
||||
"""Prepare, unroll, and feed one source frame into the tactile processor."""
|
||||
if color:
|
||||
@@ -352,6 +436,27 @@ class FingerRuntime:
|
||||
self.tactile.warmup()
|
||||
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):
|
||||
if self.capture_source is not None:
|
||||
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,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user