Compare commits

...

28 Commits

Author SHA1 Message Date
zhangyijian d1f168c9c7 Merge pull request 'Merge pull request 'Dev' (#19) from dev into main' (#22) from main into dev
Reviewed-on: #22
2026-10-20 11:21:59 +08:00
ykcheungab e9710e140b 精简 Finger 示例的标定启动参数
从配置包读取几何资源,并提供显式无载荷启动置零。30 项测试、静态检查及现有录像验证通过。
2026-10-18 13:10:45 +08:00
ykcheungab ff7614a6a0 接入力标定 schema 3 与多点空间增益模型
保留旧格式和 cal_path,校验 SN、特征身份及坐标附件,按帧返回逐轴状态。相关测试 263 项通过,已核对存量预测并实测 180 帧录像。
2026-10-18 12:56:40 +08:00
ykcheungab f7f4682ff9 docs: 明确标定模型契约 2026-10-18 12:37:21 +08:00
ykcheungab 9c951e9d3c fix: 将 ARM64 wheel 纳入发布校验和上传流程
补齐全部架构的运行证据、GitHub 发布说明和 PyPI 逐文件下载核验。已通过 161 项测试、代码和文档门禁、九个 wheel 的本地索引下载核验,以及 ARM 三个 ABI 的 40 帧 CPU 检查。
2026-10-15 21:39:31 +08:00
ykcheungab bb15b750fb feat: 添加 ARM64 manylinux wheel 构建选项
复用现有 Docker 和 Nuitka 流程,隔离架构镜像并支持 aarch64 标签。已通过 47 项测试、代码及文档门禁,并完成 Python 3.10 ARM64 wheel 构建、隔离导入与内容审计。
2026-10-14 16:08:54 +08:00
ykcheungab 3953747f17 updated sensor config for better visualization 2026-10-14 13:52:24 +08:00
ykcheungab ae64c65c45 调整 SDK 发布同步清单并修正文档版本约束
移除已删除的标定目录同步项,避免同步失败;将 Ruff 版本写成依赖约束,避免被误判为 SDK 版本。快速回归398项通过,六个 wheel 通过静态及视频检查。
2026-10-11 21:19:40 +08:00
ykcheungab 120b8c2cf6 为采集帧绑定主机时间与来源信息
采集时间随图像穿过处理队列,并修正未读帧的丢帧计数。通过采集与流水线针对性测试。
2026-10-11 12:48:34 +08:00
ykcheungab 0784eacf51 把 FN/FX/FY 力读出抽到 ForceReadout。
滤波预设、EMA、中值、低通与标定换算离开 Sensor;对外 configure / filters_state 不变。已有 signal_processing、filter_presets、calibration 测试通过。
2026-10-08 10:08:35 +08:00
ykcheungab 68edb0dc4c 去掉与 examples/README.md 重复的示例走查
本文件 L184-265 重复了 examples/README.md 已有的内容:示例清单、各产品入口、全部命令与参数、3dview 说明。删掉重复段落改为指向该文件,只保留首次运行所需的最短路径,并把 Windows/Ubuntu 的取 ID 步骤与运行结果合并,不再各写一遍。

验证:doc_count 1439/1775,doc_lint changed 与 --all 均干净。
2026-10-06 14:27:56 +08:00
ykcheungab 4c3d0f8864 updated get_started.md to match new SDK calibration data loading and usage. 2026-10-05 14:07:05 +08:00
ykcheungab ceb06f5702 为基础示例接入按设备 ID 选择的力标定
让使用者从本地目录选标定并看见实际单位。短片回放及相关测试通过。
2026-10-05 14:02:40 +08:00
ykcheungab 8c147f03aa 新增 SDK 贡献者英文入门与发布指南
让源码开发和发布交接有清晰入口,并保留现有中文文档。文档门禁与标定测试通过。
2026-10-05 13:29:36 +08:00
ykcheungab 34cb83d4a4 修正示例中的三轴力单位显示
避免未标定读数被示例标成 N,并让 Finger 示例读取 SDK 的每轴单位。已通过相关测试和语法检查。
2026-10-05 13:10:58 +08:00
ykcheungab 38d3e67b00 固定 HHD 下采样并标记三轴力单位
避免运行中改变 HHD 测量配置,并明确部分标定时各轴单位。已通过 SDK 与共享层测试。
2026-10-05 12:56:29 +08:00
ykcheungab 80f7da5a20 添加按设备 ID 选择和校验标定文件
避免多台传感器误用默认标定,缺文件时保持 raw。已通过 SDK 回归测试和文档检查。
2026-10-05 12:21:04 +08:00
ykcheungab 230b04a79d Add a sensor bundle contract separating family, backend and profile
Product name decided the pipeline: Mini worked live but replay refused it. A bundle descriptor now declares profile_id, product_family and processing_backend, with computational and visualization assets kept apart, and one resolver serves live and recorded input. Legacy bundles load through a documented adapter that never guesses a backend from a folder name.
2026-10-04 20:55:39 +08:00
ykcheungab 6e3d509239 Add pipeline pause and resume at two levels
Pause now holds the pipeline without tearing it down: CAPTURE_ONLY keeps the camera
streaming so a live sensor stays converged while the flow and HHD stages stop, and BOTH stops
the read as well. Resume rebases the tracker, because a held reference frame would otherwise
report the hold itself as deformation.

Verified with tests/test_pipeline_pause.py: 10 passed across three consecutive runs.
2026-10-04 20:55:35 +08:00
ykcheungab 333fa8fcff added pause/wait functions for stream 2026-09-30 21:23:08 +08:00
ykcheungab f700102067 docs: record the GUI extraction plan, postponed
examples/gui/ is a full Qt + open3d application (30 modules, 4,815 lines) rather
than an example, and its proper home is the orisys-studio application. The move
is planned but not started, so this records the plan rather than performing any
of it.

What the plan establishes, so the work does not have to be re-derived:

- src/orisys/ imports no gui/viz/examples module, verified by AST rather than
  grep. The dependency is one-way, so the GUI can leave without a library
  change. The single apparent hit is `from .pipeline import ...` in
  src/orisys/__init__.py, which is the SDK's own module.
- examples/viz/ is shared and cannot follow the GUI wholesale: it mixes open3d
  renderers with toolkit-independent helpers that examples/pipeline/service.py
  and tests/test_contact_arrow_scale.py depend on. It has to be split.
- The real coupling is inverted and lives in the tests: tests/test_qt_viewer_backend.py
  imports examples/gui, and tests/conftest.py deliberately puts examples/ ahead
  of build/ because both define a package named `gui`. Removing the GUI means
  re-deriving that guard, not deleting it blindly.
- UVMeshViewer and friends are in src/orisys/finger/__init__.__all__, so removing
  them is a breaking change to a released API and needs a deprecation release
  first. It must not be folded into a code move.
- The `examples/` directory must keep existing in the packaged layout even after
  gui/ leaves it: src/orisys/configs.py anchors its run-root detection on a
  sibling examples/ folder.

Also updates examples/README.md to say "lives here for now" instead of "is moving
out", so nothing reads as work in progress. The GUI itself is untouched, and the
only guidance offered is that new SDK work should not build on it.
2026-09-30 17:10:17 +08:00
ykcheungab b5d7533e24 examples: make StreamPipeline the recommended entry, archive the manual loop
The two canonical examples taught the advanced path: they sequenced
get_img() -> compute_deformation() -> compute_contact() -> read_info() by hand
and unpacked a 13-element tuple positionally, which is what led developers to
reach for the compute backend, the capture queue and the stitch mode. The
managed alternative already existed and was documented nowhere.

Library (add-only, nothing removed):

- FrameSnapshot.image_size_raw carries the capture resolution, so a caller can
  compare it with the processed frame instead of reaching into Sensor.
- StreamPipeline.reset() resets the tracker and force filters *safely*. It is
  queued and applied by the flow stage before its next frame, never inline:
  Sensor.reset rebases initial_img on the current Sensor.img, which the flow
  stage is reading concurrently, so a caller-thread reset would capture a
  half-written reference frame. Idempotent.
- TactileProcessor.pipeline(produce) and FingerRuntime.pipeline() build a
  pipeline without the caller naming a backend concept; the latter is
  `with runtime.pipeline(contact=True) as pipe`.

Examples:

- examples/basic_pipeline.py and examples/finger_pipeline.py rewritten onto
  StreamPipeline + FrameSnapshot: one object, one call per frame, no tuple, and
  no compute backend, queue or stitch setting in sight.
- examples/archive/basic_pipeline_manual.py and _finger_pipeline_manual.py keep
  the previous versions byte-for-byte apart from a supersede header, so code
  copied from them keeps working. The finger header also records the archived
  --show defect (store_true with default=True, so it could never be disabled).
- examples/_display.py holds the shared display helpers, so an example no longer
  hand-rolls depth normalisation and colormap application.
- examples/README.md leads with the recommended pair and documents the manual
  form as still-supported, and states that examples/gui/ is moving to
  orisys-studio.

Tests: three added to tests/test_pipeline.py, covering the reset queue (it must
not run on the caller's thread and must land before the first compute_flow),
image_size_raw, and the None case when a producer reports no size. Verified the
reset test fails when reset() is made inline, so it discriminates.

Verified end-to-end against real media: basic_pipeline on data/sample/s1_480p.mp4
(box) reaches ~113 FPS and exits 0; finger_pipeline on data/dd02-0703/1.mp4
(finger) reaches ~140 FPS with mask coverage 0.41 and exits 0. Both archived
manual versions still run. tests/test_pipeline.py: 20 passed. Full suite:
342 passed, 3 failed - all three failures reproduce on the pristine tree
(import precedence, longrun timing, and a stale ruff version literal in
docs/code-review.md) and are unrelated to this change.
2026-09-30 17:09:02 +08:00
ykcheungab 4c1ab5c27e tools: add a static public-API audit and record its first run
Adds tools/api_audit.py, a read-only static audit of the public API surface,
and docs/api-audit.md, its first run against this revision.

The audit answers three questions the reference cannot answer about itself:

1. Does every declared export exist, have a docstring, and appear in
   docs/api.md? It reads the root __all__ plus each submodule's __all__.
2. How complete is each public definition? Docstring presence, detected
   docstring convention, parameter and return annotations.
3. Which public names do examples and tests actually use but the reference
   never explains? That set is the real documentation gap.

It parses with ast and never imports the package, so it costs no numpy/cv2
import and cannot be affected by the environment.

Findings on this revision (full detail in docs/api-audit.md):

- 393 public classes/methods/functions; 65% have a docstring, 32% have a fully
  annotated signature, 38 document their return value.
- 243 docstrings are unstructured prose, 137 are absent, 13 use Google or NumPy
  sections. There is effectively one convention: none.
- 9 of the 18 root exports are absent from docs/api.md, including
  StreamPipeline, StreamRecorder, mosaic_videos and FFCFrameProcessor - the
  pipelined multi-stream and offline-recording paths.
- Six sub-surfaces have zero coverage in the reference: unroll (18 exports),
  capture (17), hhd + solvers (7), pipeline (3), export (2).
- 74 names are used by examples or tests but absent from the reference.
- 21 public classes are exported nowhere; LowpassFilter is documented in
  docs/api.md and importable, but declared in no __all__.

No behaviour change: a new tool and a new document, no source file touched.
2026-09-30 16:44:12 +08:00
ykcheungab cde832eaca tools: delegate documentation checks to the workspace doc gate
`tools/lint_gate.py` now also runs `D:\orisys_ws\scripts\doc_lint.py`, the
workspace's single documentation gate, alongside Ruff:

- changed Markdown by default, the whole corpus under --all
- `--files` names Python files explicitly, so it skips the doc gate
- `--no-docs` opts out
- the docs verdict is reported even when Ruff is missing, so a missing
  linter cannot hide it (the exit code still signals the environment problem)

The doc rules live in exactly one place on purpose: this file is duplicated
across three repositories and had already drifted, so adding a fourth copy of
lint logic would repeat the mistake. Failing checks are the documentation
budget, inventory membership, a declared kind, unresolved mojibake, paragraph
walls, and duplicate explanations across documents. Emphasis density is
advisory only - measured as a continuum from 12% to 48% across the corpus, so
no threshold separates signal from noise.

Pre-existing oversized documents are recorded as budget-exempt with a written
reason in D:\orisys_ws\docs\inventory.json, and pre-existing findings are
baselined there, so this gate is red only for new regression.

With Ruff unavailable in the current environment the doc half still reports,
which is how it was verified.
2026-09-30 16:12:30 +08:00
ykcheungab 73b1fa7fa4 docs: correct the _perf path references after the bench moved
The `_perf/` measurement bench was gitignored and had never been committed, so
23 of the 27 scripts cited by the perf documents were already lost. The 11
surviving files (4 scripts, cfg_dm1.json, 5 result JSONs, the 53 MB MJPG clip)
have been moved to D:\orisys_ws\docs\performance\perf-measurements\ and the
scripts patched to resolve their defaults next to themselves. The directory has
been removed from this repo.

These three files said the scripts were deleted, which was only half true:
they are moved, and the four surviving ones still run. Repointed:

- src/orisys/sensor.py: the FILTER_PRESETS comment now names the real location
  (comment only; FILTER_PRESETS itself is unchanged).
- docs/filters.md and examples/filters/README.md: the same correction for the
  latency/noise measurement provenance.

No behaviour change.
2026-09-27 21:49:26 +08:00
ykcheungab d5b938738e docs: point perf references at the workspace docs tree; ignore .scratch/
orisys-sdk-perf-plan.md moved to D:\orisys_ws\docs\performance\ as part of the
2026-09-27 workspace cleanup. docs/filters.md and examples/filters/README.md now
name the new location; the _perf/ measurement scripts they also cited are deleted
(the document keeps every measured number).

Also ignore .scratch/, the new per-task scratch directory (see
D:\orisys_ws\AGENTS.md).
2026-09-27 21:14:20 +08:00
ykcheungab e2ac36269c added ruff code reivew 2026-09-27 19:21:55 +08:00
zhangyijian 75fa98eff0 Merge pull request 'Dev' (#19) from dev into main
Reviewed-on: #19
2026-09-02 21:39:03 +08:00
164 changed files with 9441 additions and 1274 deletions
+4
View File
@@ -31,6 +31,7 @@ __pycache__/
/data/* /data/*
!/data/sample/ !/data/sample/
!/data/sample/** !/data/sample/**
!/data/calibrations/
# Generated docs / tooling outputs # Generated docs / tooling outputs
/docs/.quarto/ /docs/.quarto/
@@ -47,3 +48,6 @@ __pycache__/
# Scratch output of examples/ (multi_stream.py's default --out-dir) # Scratch output of examples/ (multi_stream.py's default --out-dir)
/outputs/ /outputs/
# Agent scratch: one directory per task, see D:\orisys_ws\AGENTS.md
.scratch/
+35 -6
View File
@@ -9,7 +9,8 @@ PyPI: [https://pypi.org/project/orisys/](https://pypi.org/project/orisys/)
## Repository Layout ## Repository Layout
- `src/orisys/`: installable SDK package - `src/orisys/`: installable SDK package
- `config/`: default and lab/runtime JSON configurations - `sensors/`: product bundles containing the current `sensor.json` configurations and assets
- `config/`: legacy JSON configurations retained for compatibility
- `examples/`: runnable demos (`basic_pipeline.py`, `mini_pipeline.py`, `finger_pipeline*.py`) plus shared `gui/`, `pipeline/`, `viz/` - `examples/`: runnable demos (`basic_pipeline.py`, `mini_pipeline.py`, `finger_pipeline*.py`) plus shared `gui/`, `pipeline/`, `viz/`
- `scripts/`: maintenance, calibration, packaging, and demo scripts - `scripts/`: maintenance, calibration, packaging, and demo scripts
- `tools/`: non-package tooling such as patch-generation utilities - `tools/`: non-package tooling such as patch-generation utilities
@@ -73,12 +74,13 @@ Run commands from the repository root.
## Configuration ## Configuration
The canonical configuration files now live in the repo-level `config/` folder. Product configurations live in `sensors/<name>/sensor.json`. The repo-level `config/`
folder remains available for older configurations and aliases.
The package loader `orisys.configs` resolves configs from: The package loader `orisys.configs` resolves configs from:
1. an explicit JSON file path 1. an explicit JSON file path
2. a config file stem under `config/` (for example `box`, `mini`, `finger`) 2. `sensors/<name>/sensor.json` (for example `box`, `mini`, `finger`)
3. a product alias such as `dd02` -> `dd02-ov` 3. a file stem under the legacy `config/` folder, including aliases such as `dd02` -> `dd02-ov`
4. a backward-compatible JSON `"name"` match, such as `"prototype"` 4. a backward-compatible JSON `"name"` match, such as `"prototype"`
Examples: Examples:
@@ -90,12 +92,39 @@ sensor = orisys.Sensor(0, config_name="box")
cfg = orisys.export_config("box", "./config/exported_config.json") cfg = orisys.export_config("box", "./config/exported_config.json")
``` ```
For multiple physical sensors, keep calibration files in a user-selected directory:
`<calibration_dir>/<product>/<device_id>.json` (for example,
`data/calibrations/box/BOX-001.json`). Each file uses the existing calibration JSON
format and must contain matching `identity.sensor_type` and `identity.sn`, Newton
curves for FN/FX/FY, and a compatible measurement pipeline. Enter the device ID
manually until hardware identification is available:
The source checkout provides `data/calibrations/{box,mini,finger}/` folders; device
files placed there are ignored by Git. This location is optional: pass
`--calibration-dir data/calibrations`
and `--device-id BOX-001` to `examples/basic_pipeline.py` to select a file there.
Installed wheels do not create or search this directory automatically.
```python
sensor = orisys.Sensor(0, config_name="box", calibration_dir="data/calibrations", device_id="BOX-001")
```
If that ID has no file, the sensor reports raw force; it never falls back to the
product's default. An invalid or mismatched file raises `CalibrationError`.
`FingerRuntime.open` accepts the same `calibration_dir` and `device_id` arguments.
Existing calls without a device ID still use the product bundle default, and
explicit `cal_path` remains available for one-off use. `cal_path` and
`calibration_dir` cannot be combined. Pass `device_id` with `cal_path` to validate
that explicit file against the selected device.
## Examples and Tools ## Examples and Tools
- Example entry points: `examples/README.md` - Example entry points: `examples/README.md`
- User documentation: `docs/get_started.md` - Contributor start: [docs/get_started.en.md](docs/get_started.en.md)
- Chinese installation guide: [docs/get_started.md](docs/get_started.md)
- Build launcher: `python build/run.py` (see `build/README.md`) - Build launcher: `python build/run.py` (see `build/README.md`)
- Manylinux / PyPI binary wheel: `docs/publishing.md` - Build and publish guide: [docs/publishing.en.md](docs/publishing.en.md)
- Chinese release runbook: [docs/publishing.md](docs/publishing.md)
- Specialized utilities: `tools/` - Specialized utilities: `tools/`
## Publishing (Linux manylinux) ## Publishing (Linux manylinux)
+33 -2
View File
@@ -27,6 +27,7 @@ build/
```bash ```bash
python build/run.py wheel-win python build/run.py wheel-win
python build/run.py wheel-manylinux python build/run.py wheel-manylinux
python build/run.py wheel-manylinux -Architecture aarch64 -PythonVersions 3.10
# Single Version # Single Version
python build/run.py wheel 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 .\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 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 deps, so the entrypoint no longer re-resolves them over the network. See
`docs/publishing.md` §6 for build-time details, why `-SkipImageBuild` matters, `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 ### Release gate
Local only: never publishes, and makes no network call unless `--check-pypi` is Local only: never publishes. `--check-pypi` probes the index; native Windows
passed. See `docs/publishing.md` for the full flow. venvs may download dependencies. Linux verification uses local Docker images
and their offline wheelhouses. See `docs/publishing.en.md` for the full flow.
```bash ```bash
python build/run.py release preflight --version <version> python build/run.py release preflight --version <version>
@@ -67,6 +92,8 @@ is the expected input, so build first:
```bash ```bash
python build/run.py wheel-win 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 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 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. 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. Tokens are only ever read from a hidden prompt or stdin, never from argv.
See `docs/publishing.md` §3. See `docs/publishing.md` §3.
+4 -1
View File
@@ -20,7 +20,10 @@ from common.verify import ( # noqa: E402
verify_wheel_assets, verify_wheel_assets,
verify_wheel_runtime, verify_wheel_runtime,
) )
from common.verify.wheel import DEFAULT_RUNTIME_CONFIG, default_verification_video # noqa: E402 from common.verify.wheel import ( # noqa: E402
DEFAULT_RUNTIME_CONFIG,
default_verification_video,
)
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
+9 -8
View File
@@ -31,11 +31,16 @@ _BUILD_ROOT = Path(__file__).resolve().parents[1]
if str(_BUILD_ROOT) not in sys.path: if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT)) sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402
from common.pyproject_utils import bundle_items, extras_require, install_requires, project_metadata # noqa: E402
from wheel.layout import resolve_bundle_source_files # noqa: E402 from wheel.layout import resolve_bundle_source_files # noqa: E402
from wheel.package import is_compliant_manylinux_wheel # noqa: E402 from wheel.package import is_compliant_manylinux_wheel # noqa: E402
from common.paths import REPO_ROOT # noqa: E402
from common.pyproject_utils import ( # noqa: E402
bundle_items,
extras_require,
install_requires,
)
# PyPI's default per-project file limit is 100 MB; keep headroom. # PyPI's default per-project file limit is 100 MB; keep headroom.
MAX_WHEEL_BYTES = 90 * 1024 * 1024 MAX_WHEEL_BYTES = 90 * 1024 * 1024
@@ -339,11 +344,7 @@ def _check_forbidden_entries(result: WheelCheckResult, names: list[str]) -> None
offenders = [] offenders = []
for name in names: for name in names:
lowered = name.lower() lowered = name.lower()
if lowered.startswith(_FORBIDDEN_PREFIXES): if lowered.startswith(_FORBIDDEN_PREFIXES) or lowered.endswith(_FORBIDDEN_SUFFIXES) or "__pycache__" in lowered or ".egg-info" in lowered:
offenders.append(name)
elif lowered.endswith(_FORBIDDEN_SUFFIXES):
offenders.append(name)
elif "__pycache__" in lowered or ".egg-info" in lowered:
offenders.append(name) offenders.append(name)
result.add( result.add(
"no-forbidden-entries", "no-forbidden-entries",
@@ -426,7 +427,7 @@ def _check_platform_tag(result: WheelCheckResult) -> None:
result.add( result.add(
"platform-tag", "platform-tag",
ok, 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",
) )
+1 -1
View File
@@ -6,9 +6,9 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Callable
_BUILD_ROOT = Path(__file__).resolve().parents[2] _BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path: if str(_BUILD_ROOT) not in sys.path:
+1
View File
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT)) sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "basic_pipeline.py" ENTRY_SCRIPT = REPO_ROOT / "examples" / "basic_pipeline.py"
+1
View File
@@ -15,6 +15,7 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT)) sys.path.insert(0, str(_BUILD_ROOT))
from common.verify.gui import verify_gui_dist from common.verify.gui import verify_gui_dist
from gui.common.pyinstaller_driver import build_with_pyinstaller from gui.common.pyinstaller_driver import build_with_pyinstaller
from gui.ory_finger.config import make_config from gui.ory_finger.config import make_config
+1
View File
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT)) sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "finger_pipeline_3dview.py" ENTRY_SCRIPT = REPO_ROOT / "examples" / "finger_pipeline_3dview.py"
+1
View File
@@ -10,6 +10,7 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT)) sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "mini_pipeline.py" ENTRY_SCRIPT = REPO_ROOT / "examples" / "mini_pipeline.py"
+2 -1
View File
@@ -25,6 +25,7 @@ from common.verify.wheel import ( # noqa: E402
verify_wheel_runtime, verify_wheel_runtime,
) )
from common.verify.wheel_contents import inspect_wheel # noqa: E402 from common.verify.wheel_contents import inspect_wheel # noqa: E402
from release import auth, github, pypi # noqa: E402 from release import auth, github, pypi # noqa: E402
from release.preflight import PreflightOptions, run_preflight # noqa: E402 from release.preflight import PreflightOptions, run_preflight # noqa: E402
@@ -51,7 +52,7 @@ def _cmd_preflight(argv: list[str]) -> int:
parser.add_argument( parser.add_argument(
"--all-wheel-pythons", "--all-wheel-pythons",
action="store_true", 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("--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") parser.add_argument("--no-runtime", action="store_true", help="skip the isolated runtime check")
+10 -2
View File
@@ -29,7 +29,9 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT)) sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402 from common.paths import REPO_ROOT # noqa: E402
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402 from release.publish import GateResult, PublishGateError, load_gate # noqa: E402
from release.runtime import PLATFORM_LABELS, wheel_target # noqa: E402
MANIFEST_PATH = REPO_ROOT / "tools" / "downstream_sync_manifest.json" MANIFEST_PATH = REPO_ROOT / "tools" / "downstream_sync_manifest.json"
SYNC_TOOL = REPO_ROOT / "tools" / "sync_downstreams.py" 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: 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 = [ lines = [
f"orisys {version}", 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.extend(f"- `{wheel.name}`" for wheel in wheels)
lines.append("") lines.append("")
@@ -334,7 +337,8 @@ def publish(options: Options) -> int:
asset_dir = repo / "release" / version asset_dir = repo / "release" / version
assets = [asset_dir / wheel.name for wheel in wheels] 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( steps = build_github_steps(
version=version, version=version,
branch=branch, branch=branch,
@@ -360,11 +364,15 @@ def publish(options: Options) -> int:
print() print()
if not options.apply: if not options.apply:
if generated_notes:
print(generated_notes)
print("dry run: nothing was changed. Re-run with --apply to execute.") print("dry run: nothing was changed. Re-run with --apply to execute.")
return 0 return 0
# --- 4. execute ---------------------------------------------------- # --- 4. execute ----------------------------------------------------
try: try:
if generated_notes:
notes_file.write_text(generated_notes, encoding="utf-8")
commit_before = _git(repo, "rev-parse", "HEAD") commit_before = _git(repo, "rev-parse", "HEAD")
_sync(run_checks=not options.skip_checks) _sync(run_checks=not options.skip_checks)
_stage_wheels(wheels, asset_dir) _stage_wheels(wheels, asset_dir)
+29 -19
View File
@@ -1,8 +1,8 @@
"""Local release gate: everything that must hold before publishing a wheel. """Local release gate: everything that must hold before publishing a wheel.
``release preflight`` answers one question with an exit code: is this checkout, ``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 this version and these wheels safe to publish? It never publishes anything;
by default it makes no network calls. native verification environments may download dependencies from PyPI.
Steps, in order: Steps, in order:
@@ -45,9 +45,14 @@ from common.verify.wheel import ( # noqa: E402
verify_wheel, verify_wheel,
) )
from common.verify.wheel_contents import inspect_wheel # noqa: E402 from common.verify.wheel_contents import inspect_wheel # noqa: E402
from wheel.layout import ( # noqa: E402
resolve_bundle_source_files,
verify_required_assets,
)
from release import report as report_mod # noqa: E402 from release import report as report_mod # noqa: E402
from release import version as version_mod # noqa: E402 from release import version as version_mod # noqa: E402
from wheel.layout import resolve_bundle_source_files, verify_required_assets # noqa: E402 from release.runtime import verify_manylinux_wheel, wheel_target # noqa: E402
@dataclass @dataclass
@@ -407,31 +412,29 @@ class Preflight:
return Step("wheel-inspection", not problems, detail, data={"count": len(self.wheels)}) return Step("wheel-inspection", not problems, detail, data={"count": len(self.wheels)})
def _platform_token(self) -> str: 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]]: def _runtime_targets(self) -> list[tuple[str, Path, Path | None]]:
token = self._platform_token() token = self._platform_token()
if self.options.all_wheel_pythons: if self.options.all_wheel_pythons:
targets: list[tuple[str, Path, Path | None]] = [] targets: list[tuple[str, Path, Path | None]] = []
for tag in ("cp310", "cp311", "cp312"): for wheel in self.wheels:
match = next( tag, target = wheel_target(wheel)
(w for w in self.wheels if f"-{tag}-" in w.name and token in w.name), None executable = None
) if target == "win_amd64":
if match is None: if sys.platform != "win32":
raise RuntimeError( raise RuntimeError("Verify Windows wheels from a Windows host")
f"no {tag} {token} wheel among {[w.name for w in self.wheels]}"
)
executable = version_mod.python_for_tag(tag) executable = version_mod.python_for_tag(tag)
if executable is None: if target == "win_amd64" and executable is None:
raise RuntimeError( raise RuntimeError(
f"cannot resolve an interpreter for {tag}; create the conda env " f"cannot resolve an interpreter for {tag}; create the conda env "
f"py{tag[2:]} or set CONDA_ENVS_ROOT" f"py{tag[2:]} or set CONDA_ENVS_ROOT"
) )
targets.append((tag, match, executable)) targets.append((tag, wheel, executable))
return targets return targets
tag = f"cp{sys.version_info.major}{sys.version_info.minor}" 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: if match is None:
raise RuntimeError( raise RuntimeError(
f"no {tag} {token} wheel among {[w.name for w in self.wheels]}; pass " 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: for tag, wheel, executable in targets:
label = f"{tag} {wheel.name}" label = f"{tag} {wheel.name}"
try: try:
digest = _sha256_file(wheel)
# One venv per wheel: it runs the import smoke test, the bundled # One venv per wheel: it runs the import smoke test, the bundled
# asset check and the frame pipeline together. # 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, wheel,
video=video, video=video,
config_name=self.options.config_name, config_name=self.options.config_name,
frames=self.options.frames, frames=self.options.frames,
python_executable=executable,
offline=self.options.offline,
check_assets=not self.options.no_assets, 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: except subprocess.CalledProcessError as exc:
problems.append(f"{label}: command failed ({exc})") problems.append(f"{label}: command failed ({exc})")
continue continue
@@ -493,7 +503,7 @@ class Preflight:
f"{label}: {record['assets']} | frames={record['frames']} " f"{label}: {record['assets']} | frames={record['frames']} "
f"flow={record['flow_shape']}" 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) detail = "\n".join(lines)
if problems: if problems:
+11
View File
@@ -86,6 +86,17 @@ def evaluate_gate(
if not recorded: if not recorded:
problems.append("report lists no wheels") 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)) missing = sorted(set(recorded) - set(wheels))
if missing: if missing:
problems.append(f"wheels in the report are missing from disk: {missing}") problems.append(f"wheels in the report are missing from disk: {missing}")
+34 -20
View File
@@ -18,7 +18,7 @@ probe, and ``--apply`` being required even to reach TestPyPI.
from __future__ import annotations from __future__ import annotations
import json import json
import shutil import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -33,8 +33,9 @@ if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT)) sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT # noqa: E402 from common.paths import REPO_ROOT # noqa: E402
from release import auth # noqa: E402 from release import auth # noqa: E402
from release.publish import GateResult, PublishGateError, load_gate # noqa: E402 from release.publish import GateResult, PublishGateError, load_gate, sha256_file # noqa: E402
INDEX_JSON = {name: str(meta["json_url"]) for name, meta in auth.INDEXES.items()} 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()} 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: 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: with tempfile.TemporaryDirectory(prefix="orisys-fetch-") as tmp:
target = Path(tmp) / "out" target = Path(tmp) / "out"
python_tag, abi_tag, platform_tags = wheel.stem.rsplit("-", 3)[1:]
argv = [ argv = [
sys.executable, sys.executable,
"-m", "-m",
@@ -293,18 +295,23 @@ def _fetch_back(index: str, version: str, wheel: Path) -> int:
"download", "download",
"--no-deps", "--no-deps",
"--no-cache-dir", "--no-cache-dir",
"--only-binary=:all:",
"--implementation", "cp",
"--python-version", python_tag[2:],
"--abi", abi_tag,
"--index-url", "--index-url",
INDEX_SIMPLE[index], INDEX_SIMPLE[index],
"--dest", "--dest",
str(target), str(target),
f"orisys=={version}", f"orisys=={version}",
] ]
if index == "testpypi": for platform_tag in platform_tags.split("."):
# TestPyPI mirrors only what was uploaded there; deps live on PyPI. argv += ["--platform", platform_tag]
# --no-deps keeps this a pure "is the artifact fetchable" probe. # Index verification must not inherit a fallback index or wheelhouse.
argv += ["--extra-index-url", INDEX_SIMPLE["pypi"]] 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)}") print(f"[run ] {' '.join(argv)}")
completed = subprocess.run(argv, check=False) completed = subprocess.run(argv, check=False, env=env)
if completed.returncode != 0: if completed.returncode != 0:
return completed.returncode 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}") print(f"[FAIL] pip download produced nothing for orisys=={version}")
return 1 return 1
print(f"[ok ] fetched {[p.name for p in fetched]} from {index}") print(f"[ok ] fetched {[p.name for p in fetched]} from {index}")
if wheel.name not in {path.name for path in fetched}: if {path.name for path in fetched} != {wheel.name}:
print( raise PublishGateError(f"{index} returned a different wheel than {wheel.name}")
f"[warn] {wheel.name} was not the file fetched " if sha256_file(fetched[0]) != sha256_file(wheel):
"(the index may serve a different platform first)" raise PublishGateError(f"{index} returned different bytes for {wheel.name}")
)
return 0 return 0
@@ -436,13 +442,21 @@ def publish(options: Options) -> int:
print(f"[ok ] {index} serves all {len(published)} file(s) for {version}") print(f"[ok ] {index} serves all {len(published)} file(s) for {version}")
if options.verify_install: if options.verify_install:
if _fetch_back(index, version, wheels[0]) != 0: fetch_pending = False
print( for wheel in wheels:
f"[warn] {index} artifact is not fetchable yet; the upload is " try:
"published, so this is a propagation delay rather than a failure" if _fetch_back(index, version, wheel) != 0:
) fetch_pending = True
print(f"[warn] {wheel.name} is uploaded but download verification is inconclusive")
else: else:
print(f"[ok ] fetched the published artifact back from {index}") 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"]: if phases == ["testpypi"]:
print() print()
@@ -452,7 +466,7 @@ def publish(options: Options) -> int:
print() print()
if options.pypi_only: if options.pypi_only:
print(f"=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===") print("=== release pypi PASSED (PyPI only, no TestPyPI rehearsal) ===")
else: else:
print("=== release pypi PASSED (TestPyPI + PyPI) ===") print("=== release pypi PASSED (TestPyPI + PyPI) ===")
return 0 return 0
+117
View File
@@ -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
View File
@@ -86,11 +86,11 @@ def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv) argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help"): if not argv or argv[0] in ("-h", "--help"):
print(__doc__) print(__doc__)
print("Commands: wheel | wheel-win | manylinux | gui | verify | release") print("Commands: wheel | wheel-win | wheel-manylinux | gui | verify | release")
print("Examples:") print("Examples:")
print(" python build/run.py wheel --help") print(" python build/run.py wheel --help")
print(" python build/run.py gui finger") 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 preflight --version <version>")
print(" python build/run.py release audit SDK\\<version>\\orisys-<version>-cp312-cp312-win_amd64.whl") print(" python build/run.py release audit SDK\\<version>\\orisys-<version>-cp312-cp312-win_amd64.whl")
return 0 return 0
@@ -107,7 +107,7 @@ def main(argv: list[str] | None = None) -> int:
if command not in dispatch: if command not in dispatch:
print(f"Unknown command: {command!r}", file=sys.stderr) print(f"Unknown command: {command!r}", file=sys.stderr)
print( print(
"Choose from: wheel, wheel-win, manylinux, gui, verify, release", "Choose from: wheel, wheel-win, wheel-manylinux, gui, verify, release",
file=sys.stderr, file=sys.stderr,
) )
return 2 return 2
+11 -6
View File
@@ -29,7 +29,13 @@ _BUILD_ROOT = Path(__file__).resolve().parents[1]
if str(_BUILD_ROOT) not in sys.path: if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT)) sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repaired_wheel_dir, repo_path from common.paths import (
NUITKA_COMPILE_DIR,
NUITKA_WHEEL_DIR,
REPO_ROOT,
repaired_wheel_dir,
repo_path,
)
from common.pyproject_utils import ( from common.pyproject_utils import (
bundle_items, bundle_items,
extras_require, extras_require,
@@ -38,6 +44,7 @@ from common.pyproject_utils import (
project_metadata, project_metadata,
) )
from common.verify.wheel import verify_built_wheel from common.verify.wheel import verify_built_wheel
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
from wheel.layout import ( from wheel.layout import (
copy_bundle_items, copy_bundle_items,
@@ -69,7 +76,7 @@ def _validate_pypi_linux_environment() -> None:
if sys.platform != "linux": if sys.platform != "linux":
raise SystemExit( raise SystemExit(
"[ERROR] --target pypi-linux must run inside a Linux manylinux container " "[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: if sys.version_info[:2] not in PYPI_LINUX_PYTHON_VERSIONS:
supported = ", ".join(f"{major}.{minor}" for major, minor 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): if target == "pypi-linux" and not is_compliant_manylinux_wheel(final_candidate):
raise SystemExit( raise SystemExit(
f"[ERROR] Wheel is not PyPI-compliant: {final_candidate.name}. " 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. # 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) args = parser.parse_args(argv)
verify: bool | None verify: bool | None
if args.target == "pypi-linux": if args.target == "pypi-linux" or args.verify:
verify = True
elif args.verify:
verify = True verify = True
elif args.skip_verify: elif args.skip_verify:
verify = False verify = False
+2 -1
View File
@@ -1,7 +1,8 @@
# manylinux_2_28: modern glibc baseline with binary wheels for numpy/scipy/opencv. # 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 # Prefer this over manylinux2014 — recent NumPy requires GCC >= 10.3 and will try
# (and fail) to build from source on the older image. # (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 \ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_ROOT_USER_ACTION=ignore \ PIP_ROOT_USER_ACTION=ignore \
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/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. # Builds one Nuitka wheel per CPython ABI listed in ORISYS_PYTHON_TAGS.
set -euo pipefail set -euo pipefail
+24 -5
View File
@@ -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): # Usage (from repo root, PowerShell):
# .\build\wheel\docker\run.ps1 # .\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: # From cmd.exe:
# powershell -ExecutionPolicy Bypass -File .\build\build_manylinux_docker.ps1 # powershell -ExecutionPolicy Bypass -File .\build\wheel\docker\run.ps1
param( param(
[ValidateSet("pypi-linux", "internal")] [ValidateSet("pypi-linux", "internal")]
[string]$Target = "pypi-linux", [string]$Target = "pypi-linux",
[string]$PythonVersions = "3.10,3.11,3.12", [string]$PythonVersions = "3.10,3.11,3.12",
[ValidateSet("x86_64", "aarch64")]
[string]$Architecture = "x86_64",
[switch]$SkipImageBuild, [switch]$SkipImageBuild,
[string]$ImageName = "orisys-manylinux" [string]$ImageName = ""
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$Architecture = $Architecture.ToLowerInvariant()
function Convert-ToManylinuxTag { function Convert-ToManylinuxTag {
param([string]$Version) param([string]$Version)
@@ -45,11 +48,17 @@ if ($tags.Count -eq 0) {
throw "No Python versions specified." throw "No Python versions specified."
} }
$pythonTags = [string]::Join(",", $tags) $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 "=== Orisys manylinux Docker build ==="
Write-Host "Repo: $RepoRoot" Write-Host "Repo: $RepoRoot"
Write-Host "Target: $Target" Write-Host "Target: $Target"
Write-Host "Image: $ImageName" Write-Host "Image: $ImageName"
Write-Host "Architecture: $Architecture ($dockerPlatform)"
Write-Host "Python tags: $pythonTags" Write-Host "Python tags: $pythonTags"
# Docker Desktop must be in Linux container mode for manylinux. # Docker Desktop must be in Linux container mode for manylinux.
@@ -71,15 +80,25 @@ $dockerfile = "build/wheel/docker/Dockerfile.manylinux"
if (-not $SkipImageBuild) { if (-not $SkipImageBuild) {
Write-Host "`n=== Building Docker image ===" 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) { if ($LASTEXITCODE -ne 0) {
throw "docker build failed" 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 ===" Write-Host "`n=== Running manylinux container ==="
# Mount the repo so artifacts land under dist/repaired and SDK/<version>/ on the host. # Mount the repo so artifacts land under dist/repaired and SDK/<version>/ on the host.
docker run --rm ` docker run --rm `
--platform $dockerPlatform `
-e "ORISYS_BUILD_TARGET=$Target" ` -e "ORISYS_BUILD_TARGET=$Target" `
-e "ORISYS_PYTHON_TAGS=$pythonTags" ` -e "ORISYS_PYTHON_TAGS=$pythonTags" `
-v "${RepoRoot}:/src" ` -v "${RepoRoot}:/src" `
+4 -4
View File
@@ -16,7 +16,7 @@ from common.paths import sdk_dir
# PyPI rejects bare tags like "manylinux_x86_64"; require a glibc ABI version. # PyPI rejects bare tags like "manylinux_x86_64"; require a glibc ABI version.
_MANYLINUX_TAG_RE = re.compile( _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). # 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: 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)) 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) names = ", ".join(path.name for path in repaired)
raise RuntimeError( raise RuntimeError(
"auditwheel repair did not produce a PyPI-compliant manylinux tag. " "auditwheel repair did not produce a PyPI-compliant manylinux tag. "
f"Got: {names}. Rebuild inside quay.io/pypa/manylinux_2_28_x86_64 " f"Got: {names}. Rebuild inside the manylinux_2_28 image for the "
"(or manylinux2014_x86_64) instead of a generic Linux host." "target architecture instead of a generic Linux host."
) )
final = compliant[0] final = compliant[0]
+147
View File
@@ -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.
-1
View File
@@ -205,7 +205,6 @@ sensor.configure(lowpass_cutoff_hz=5.0, lowpass_sample_rate_hz=30.0)
* **lowpass_sample_rate_hz** (`float`,可选): 力信号采样率,默认 `30.0`。 * **lowpass_sample_rate_hz** (`float`,可选): 力信号采样率,默认 `30.0`。
* **lowpass_order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。 * **lowpass_order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
* **lowpass_enabled** (`bool`,可选): 显式开启或关闭低通滤波。 * **lowpass_enabled** (`bool`,可选): 显式开启或关闭低通滤波。
* **hhd_downsample** (`int`,可选): HHD 解算下采样倍数,需为 `>= 1` 的整数,配置默认 `3`。
#### 读取结果 #### 读取结果
+248
View File
@@ -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。
另以独立录像验证物理准确度,契约或单元测试通过不代表标定合格。
+74
View File
@@ -0,0 +1,74 @@
# Code review: the automated part
Four gates existed before this document (the test suites, `apps/studio/tools/ui_check.py`, the
real-GL smoke tests, the binding-purity test) and none of them is a linter. This adds the missing
*static* layer, deliberately small.
## Why it exists, in one measured fact
Until now the three repos had **no Ruff configuration at all**. `ruff check .` therefore ran
whatever the installed version enables by default — Ruff 0.16 enables a very broad set
(`UP`/`RUF`/`PLR`/`S`/`TRY`/`PERF`/`FURB`/`YTT`/`ASYNC`…) — so the same tree produced **1038 /
438 / 117** findings in `orisys-app` / `orisys-sdk` / `orisys-sensor-design`, and a different
Ruff version produced different ones. A gate built on that is not a gate. `ruff.toml` now pins it.
## What is gated, and what is deliberately not
| rule | meaning | gated? | why |
|---|---|---|---|
| `BLE001` | broad `except Exception` | **no** | A broad catch is the *right* choice around device I/O, GL teardown and camera probing. This codebase already records that choice inline as `# noqa: BLE001 - <reason>`. Gating it would produce ~100 findings whose only correct fix is "add a reasoned suppression". |
| `S110`, `S112` | handler is only `pass` / `continue` | **yes** | The complaint is not the breadth of the catch, it is that the failure becomes **invisible**. Silence is acceptable only with a reasoned suppression or a handler that reports/re-raises. |
| `E722` | bare `except:` | **yes** | Strictly worse than `except Exception:` — it also swallows `KeyboardInterrupt`/`SystemExit`. |
| `F` | unused import, redefinition, undefined name, unused variable | **yes** | Real defects: `F821`/`F822` are latent `NameError`s, `F811` can be a shadowed definition. |
| `B023` | closure over a loop variable | **yes** | Late binding silently produces wrong geometry in the headless Blender scripts. |
| `PLW1510` | `subprocess.run` without `check` | **yes** | A swallowed tool failure. |
| `RUF100` | unused suppression | reported, **never auto-fixed** | The `- reason` in a `noqa` comment is documentation. A machine must not delete it. Triage by hand. |
| `UP`, `I`, `PIE`, `RET501`, `PLR1711`, `SIM114`, `RUF022`, `RUF023` | mechanical modernisation | in the lint config, **not** in the gate | Applied once by `--fix`; gating them would only churn AI-authored diffs. |
`E402` (module import not at top of file) is **selected but not gated**. `import` after
`os.environ[...]` is deliberate in this codebase — the environment must be set before Qt/cv2/the SDK
load — so gating it would be pure noise. But it is the only rule that would catch an *accidental*
import moved above that setup, so it stays visible. When you touch one of those files, record the
intent as `# noqa: E402 - <reason>`; `E701`/`E702` are style, which `ruff format` owns. A gate that
cries wolf gets ignored, which is worse than no gate.
## The three commands
```powershell
# 1. The agent loop, after every edit — mechanical, no review needed.
ruff check <changed files> --fix # never --unsafe-fixes
ruff check <changed files> --output-format json # what remains, machine-readable
# 2. The gate before a change lands. Changed files only, machine-readable, non-zero on findings.
python tools/lint_gate.py # vs HEAD
python tools/lint_gate.py --base origin/main
python tools/lint_gate.py --all # gate rules over the whole repo
python tools/lint_gate.py --json
# 3. Formatting, changed files only — never a whole-tree rewrite mid-diff.
ruff format <changed files>
```
`$RUFF` may point at a specific ruff binary; otherwise `tools/lint_gate.py` uses `PATH` or the one
beside the interpreter. **Pin the version**: the gate prints the version it ran, and findings differ
between versions, so a runner must install the same one the developers use (`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`.
+3 -1
View File
@@ -38,7 +38,9 @@
| `balanced` | 0.5 | 3 | 0.0 | ≈2 帧(67 ms) | 建议起步档:EMA 干掉力级滤波够不到的光流抖动 | | `balanced` | 0.5 | 3 | 0.0 | ≈2 帧(67 ms) | 建议起步档:EMA 干掉力级滤波够不到的光流抖动 |
| `low_noise` | 0.3 | 3 | 10.0 | ≈3 帧(100 ms) | 底噪优先,读数再低通一道 | | `low_noise` | 0.3 | 3 | 10.0 | ≈3 帧(100 ms) | 底噪优先,读数再低通一道 |
延迟换算 @60fps 减半;数字来自 `_perf/`(对照表见 `orisys-sdk-perf-plan.md`)。 延迟换算 @60fps 减半;数字来自 `_perf/`(该一次性目录已从 `orisys-sdk` 移除:幸存的脚本与片源
迁到 `D:\orisys_ws\docs\performance\perf-measurements\`,其余已永久丢失;对照表见
`D:\orisys_ws\docs\performance\orisys-sdk-perf-plan.md`,其中仍保留全部实测数字)。
≥1 帧的延迟与相位差是**因果滤波的代价**:旧显示端曾用零相位 `filtfilt`,那需要"未来", ≥1 帧的延迟与相位差是**因果滤波的代价**:旧显示端曾用零相位 `filtfilt`,那需要"未来",
无法在采集里做,也让"显示 == 记录"不可能——所以现在只有一条因果链。 无法在采集里做,也让"显示 == 记录"不可能——所以现在只有一条因果链。
+77
View File
@@ -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
View File
@@ -6,7 +6,7 @@ description: 安装并运行 Orisys SDK 的基础步骤
# 快速开始 # 快速开始
## 简介 ## 简介
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。 本文件介绍如何上手和运行叠动视触觉传感器配套的SDK,进行图像处理,计算形变、力以及接触信息,接受从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
支持操作系统:Windows、Ubuntu 支持操作系统:Windows、Ubuntu
建议Python 版本: 3.10, 3.11, 3.12 建议Python 版本: 3.10, 3.11, 3.12
@@ -43,7 +43,7 @@ mkdir orisys_ws
```bash ```bash
cd "C:\Users\orisys\orisys_ws" cd "C:\Users\orisys\orisys_ws"
``` ```
### 安装 CUDA 和 cuDNN(可选) ### 安装 CUDA(可选)
如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。 如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。
1. 方法一(官网下载): 1. 方法一(官网下载):
@@ -56,7 +56,7 @@ cd "C:\Users\orisys\orisys_ws"
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
``` ```
该方法会自动配置好 CUDA 和 cuDNN,无需手动下载。 该方法会自动配置好 CUDA,无需手动下载。
注意:如果您的计算机没有 NVIDIA 显卡,或者不想使用 GPU 加速,可以跳过此步骤。套件也支持纯 CPU 运行(但速度可能较慢)。 注意:如果您的计算机没有 NVIDIA 显卡,或者不想使用 GPU 加速,可以跳过此步骤。套件也支持纯 CPU 运行(但速度可能较慢)。
@@ -140,85 +140,63 @@ print(orisys.__version__)
如果打印出版本号,则说明安装成功。 如果打印出版本号,则说明安装成功。
## 使用方法 ## 使用方法
## 使用设备专属的力标定参数
摄像头编号与设备 ID 是两个独立参数。命令中的 `-v 0` 或 `--camera_id 0` 只表示选择第 0 个视频源;`device_id` 才是传感器的设备编号(SN),目前需要人工确认并填写。
将**这台设备实际测得**的标定 JSON 放在自行选择的目录中,不要放进 SDK 的 `config/`,也不要覆盖 `sensors/<产品>/force_calibration.json`。目录结构如下,路径可由用户更改:
```te
D:/orisys_data/calibrations/
├── box/
│ └── BOX-001.json
├── mini/
│ └── MINI-001.json
└── finger/
└── FINGER-001.json
```
例如,使用 `BOX-001` 时,`calibration_dir` 指向上图的 `calibrations` 目录,`device_id` 填 `BOX-001`:
```python
import orisys
sensor = orisys.Sensor(
0, # 摄像头编号,不是设备 ID
config_name="box",
calibration_dir="D:/orisys_data/calibrations",
device_id="BOX-001",
verbose=True,
)
try:
print(sensor.calibration.source_path if sensor.calibration else "未加载标定")
print(sensor.force_units) # 依次为 FN、FX、FY 的单位
finally:
sensor.disconnect()
```
或者使用命令:
```bash
python examples/basic_pipeline.py -v 0 -c box --calibration-dir data/calibrations --device-id BOX-001
```
若该设备的标定文件不存在,SDK 不应用标定,力值保持 raw,也不会回退到产品默认标定。若文件存在,但其中的设备 ID、产品类型或测量设置与当前设备不匹配,SDK 会报错。FingerRuntime.open() 支持这两个参数,但 finger_pipeline.py 目前还没有对应的命令行参数。
### 运行示例程序 ### 运行示例程序
套件里面包含了多个例子。运行示例前,请先进入 SDK 示例所在的文件夹,建议在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。如果您是通过 PyPI 安装并且本地没有示例文件,可以从上方项目链接下载示例。按产品入口如下:
1. `examples/basic_pipeline.py`:ORY-BOX 复眼/拼接传感器基础 OpenCV 示例(默认配置 `box`) 示例的完整清单、每个产品对应的入口、全部命令与参数,见 [`examples/README.md`](../examples/README.md)。这里只给第一次运行的最短路径。
2. `examples/mini_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`)
3. `examples/finger_pipeline.py`:ORY-FINGER 2D 显示最小流程(默认配置 `finger`)
4. `examples/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `finger`)
`finger` 是当前 ORY-FINGER 的产品配置名。 先在仓库根目录(即 `config/`、`examples/`、`assets/` 同级)确认传感器 ID:
下面先介绍最基础的 ORY-BOX 运行方式。 - Windows:在 PowerShell 运行 `Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`。若插上传感器后多出一行,ID 为 `1`,否则为 `0`。
- Ubuntu:运行 `v4l2 --list-devices`,从新增的设备名取编号(`/dev/video0` 即 ID `0`)。
#### Windows 然后运行(假设 ID 为 `0`):
1. 检查是否有其他正在连接的 USB 摄像头设备:打开 PowerShell,运行命令 `Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。
2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增一行输出。
3. 如果第一步没有输出,传感器的 ID 就是 `0`;如果第一步有一个设备,传感器的 ID 就是 `1`,如此类推。
4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开 Anaconda Prompt,切换到工作路径,然后在命令行中输入:
```bash
python examples/basic_pipeline.py -v 0 -c box
```
5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
#### Ubuntu
1. 检查是否有其他正在连接的 USB 摄像头设备:打开终端,运行命令 `v4l2 --list-devices`,如果没有的话不会有输出。
2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增输出。
3. 根据新增的输出,例如 `/dev/video0`,确定传感器的 ID 为 `0`;如果是 `/dev/video3`,ID 为 `3`,如此类推。
4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开终端,激活相应的 Python 环境,切换到工作路径,然后在命令行中输入:
```bash
python examples/basic_pipeline.py -v 0 -c box
```
5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
#### ORY-FINGER 示例
如果您使用的是 ORY-FINGER(配置名 `finger`),可运行:
```bash ```bash
python examples/finger_pipeline.py --camera_id 0 --config finger python examples/basic_pipeline.py -v 0 -c box
``` ```
如果输入来自视频文件,也可以改用: 窗口打开后按 `q` 停止,按 `r` 重设数据。若本地没有示例文件(例如通过 PyPI 安装),从项目链接下载。
```bash
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
```
该示例会:
- 读取相机或视频源
- 将图像展开到平面
- 计算光流、法向/切向力和接触区域
- 在 OpenCV 窗口中显示展开后的光流箭头图
常用参数:
- `--verbose`:输出更详细的初始化日志
例如:
```bash
python examples/finger_pipeline.py --camera_id 0 --config finger
```
#### ORY-FINGER 3D 可视化界面
如果要使用 GUI,先安装 GUI 依赖:
```bash
pip install "orisys[gui]"
```
然后运行:
```bash
python examples/finger_pipeline_3dview.py --video 0 --config finger
```
该界面会提供:
- 手指平面化光流箭头显示
- 力曲线
- 3D 视图
### 开发套件使用方法 ### 开发套件使用方法
参考开发套件附带的开发文档。 参考开发套件附带的开发文档。
+139
View File
@@ -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.
+120
View File
@@ -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.
+161
View File
@@ -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
View File
@@ -1,75 +1,90 @@
# Examples # Examples
Runnable demos live at the `examples/` root. Shared helpers stay in Runnable demos live at the `examples/` root. Shared helpers stay in `pipeline/`, `viz/`, and
`pipeline/`, `viz/`, and `gui/`. Run commands from the repository root. `gui/`. Run commands from the repository root.
## Product Entry Points ## Start here
Both recommended examples create one object, then read a frame at a time. Everything else -
camera reading, stitching, preprocessing, the optical-flow and force stages - happens inside
`StreamPipeline`, and each frame arrives as one `FrameSnapshot`. There is no stage sequence to
remember and no tuple to unpack, and no backend setting to tune.
| Product | Entry | Default config | | Product | Entry | Default config |
|---------|-------|----------------| |---------|-------|----------------|
| ORY-BOX | `basic_pipeline.py` | `box` | | ORY-BOX | `basic_pipeline.py` | `box` |
| ORY-MINI | `mini_pipeline.py` | `mini` | | ORY-MINI | `basic_pipeline.py -c mini` | `mini` |
| ORY-FINGER (2D) | `finger_pipeline.py` | `finger` | | ORY-FINGER | `finger_pipeline.py` | `finger` |
| ORY-FINGER (3D Qt) | `finger_pipeline_3dview.py` | `finger` |
`finger` is the public ORY-FINGER config stem. `dd02` remains a compatibility alias for `dd02-ov`. ```python
# ORY-BOX / ORY-MINI
sensor = orisys.Sensor(0, config_name="box")
with orisys.StreamPipeline(sensor, sensor.get_img, contact=True) as pipe:
while (snap := pipe.wait()) is not None:
print(snap.normal_force, snap.shear_x, snap.shear_y)
## Typical Usage # ORY-FINGER - the runtime owns camera, Blender export, unroll map and valid mask
runtime = FingerRuntime.open(config="finger", video=0)
ORY-BOX: with runtime.pipeline(contact=True) as pipe:
while (snap := pipe.wait()) is not None:
```bash print(snap.normal_force, snap.shear_x, snap.shear_y)
python examples/basic_pipeline.py -v 0 -c box
``` ```
ORY-MINI: `contact=True` runs contact analysis; `include=(...)` chooses which arrays each snapshot carries
(`image`, `flow`, `vnormal`, `vshear`, `depth`, `centroid`, `contour`). Arrays you do not include
```bash are `None`, so you pay only for what you use. `pipe.reset()` resets the tracker safely from your
python examples/mini_pipeline.py -v 0 -c mini loop; `snap.image_size_raw` reports the capture resolution before UV unroll or resize.
```
### Typical usage
ORY-FINGER 2D:
```bash ```bash
python examples/basic_pipeline.py -v 0 -c box # first camera, ORY-BOX
python examples/basic_pipeline.py -v 0 -c mini # ORY-MINI
python examples/basic_pipeline.py -v recording.mp4 -c box
python examples/basic_pipeline.py --no-show # headless: print only
python examples/finger_pipeline.py --camera_id 0 --config finger python examples/finger_pipeline.py --camera_id 0 --config finger
python examples/finger_pipeline.py --video data/dd02-0703/1.mp4
``` ```
ORY-FINGER video file: Keys in both: `q` quits, `r` resets the tracker.
```bash ## The manual form, for when you need the stages
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
`examples/archive/` holds the previous versions of these two examples. They call the stages by
hand:
```python
sensor.get_img()
sensor.compute_deformation()
sensor.compute_contact()
fps, flow, vnormal, img, ... = sensor.read_info( ... ) # positional, 13 elements
``` ```
ORY-FINGER 3D Qt dashboard: That path is still supported and still correct - it is the right choice if you are implementing
your own scheduling. It is no longer the recommended starting point, and the archived files carry
a header explaining the difference. They are kept so that code copied from them keeps working.
```bash ## Other root examples
pip install -e ".[gui]"
python examples/finger_pipeline_3dview.py --video 0 --config finger
```
## Other Root Examples - `multi_stream.py` - several sensors at once, using `StreamPipeline` with `StreamRecorder`
- `two_sensor_pipeline.py` - two-sensor multiprocessing demo, manual staging
- `plot_data.py` — single-sensor OpenCV views plus PyQtGraph force charts - `plot_data.py` - single-sensor OpenCV views plus PyQtGraph force charts
- `two_sensor_pipeline.py` — two-sensor multiprocessing demo - `finger_pipeline_3dview.py` - the Qt/open3d dashboard; see the note below
## Requirements ## Requirements
- SDK installed from the repository root, for example `pip install -e .` - SDK installed from the repository root: `pip install -e .`
- camera index or test video path - a camera index or a test video path
- matching config names under `./config/` (or packaged `orisys/configs/`) - matching config names under `./config/` or the packaged `orisys/configs/`
Qt / PyQtGraph examples additionally require: Qt / PyQtGraph examples additionally need `pip install -e ".[gui]"`.
```bash ## The GUI lives here for now
pip install -e ".[gui]"
```
## Notes `examples/gui/` - the 2D viewer, the 3D dashboard and their widgets - is a full Qt + open3d
application rather than an example, and its proper home is the **orisys-studio** application.
That move is **planned but not started**; `finger_pipeline_3dview.py` and `examples/gui/` work
exactly as before. The plan, including the coupling that makes it non-trivial, is in
`docs/plan-gui-extraction.md`.
- `finger_pipeline_3dview.py` is the main interactive finger demo. The practical guidance is only that new SDK work should not build on `examples/gui/`.
It uses `gui/app_paths.py` so the same entry works in editable and packaged runs.
- Dashboard controls: click **启动** to start acquisition; use **Record CSV / Stop CSV**
and the CSV save-directory browser to log contact/force data; `Q` quits, `R` resets,
`S` starts video recording, `E` stops video recording, and `Esc` toggles fullscreen.
- `finger_pipeline.py` is the leanest finger example if you want to
validate UV unroll + force/contact processing without the full dashboard.
+52
View File
@@ -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)
+159
View File
@@ -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()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
ORY-FINGER: Minimum UV-unroll finger tactile pipeline. **ARCHIVED — manual version.**
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
Recommended entry:
python examples/finger_pipeline.py --camera_id 0 --config finger
Current product config: ``finger`` (``dd02`` still resolves to ``dd02-ov``).
---
**ARCHIVED. Use ``examples/finger_pipeline.py`` instead.**
Kept so code copied from this file keeps working. It hand-sequences ``grab_and_process()`` →
``compute_deformation()`` → ``compute_contact()`` → ``read_info()`` and unpacks a ten-element
tuple by position.
The recommended form lets :class:`orisys.StreamPipeline` run those stages concurrently and hand
back one immutable snapshot per frame:
with runtime.pipeline(contact=True, include=("image", "depth")) as pipe:
snap = pipe.wait()
print(snap.normal_force, snap.shear_x, snap.shear_y)
``tactile.reset()`` becomes ``pipe.reset()``, which is queued onto the flow-stage thread instead of
racing it.
This archived version also has a defect: ``--show`` is declared ``store_true`` with
``default=True``, so it can never be turned off. The replacement uses ``--no-show``.
"""
import argparse
import os
import sys
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
import cv2
import orisys
from orisys.finger import FingerRuntime
def main():
parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline")
parser.add_argument("--camera_id", type=int, default=0)
parser.add_argument("--video","-v", help="Video file path (overrides camera)")
parser.add_argument("--config", "-c", default="finger", help="Orisys SDK config name or path")
parser.add_argument("--show", action="store_true", default=True, help="Show unrolled image window")
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
args = parser.parse_args()
try:
video = args.camera_id
if args.video:
video = args.video
runtime = FingerRuntime.open(
config=args.config,
video=video,
verbose=args.verbose,
)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
if args.verbose:
geometry = runtime.geometry
print(
f"UV geometry: native={geometry.native_width}x{geometry.native_height}, "
f"source={geometry.source_width}x{geometry.source_height} "
f"output={geometry.out_width}x{geometry.out_height} "
)
runtime.warmup(color=False)
tactile = runtime.tactile
print("Press 'q' to quit, 'r' to reset tracker.")
try:
while True:
ok, unrolled = runtime.grab_and_process(color=False)
if not ok:
break
tactile.compute_deformation()
tactile.compute_contact()
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = tactile.read_info(
tactile.info.FPS,
tactile.info.VRAW,
tactile.info.VNORMAL,
tactile.info.IMG,
tactile.info.CONTOUR,
tactile.info.CENTROID,
tactile.info.DEPTH,
tactile.info.FNORMAL,
tactile.info.FSHEARX,
tactile.info.FSHEARY,
)
status = (
f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) "
f"mask={tactile.mask_stats['coverage']:.2f}"
)
if args.show:
arrows = orisys.util.draw_arrows(
unrolled,
flow,
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
valid_mask=tactile.valid_mask,
)
cv2.imshow("UV Unrolled", arrows)
print(status, end="\r")
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
print("\nExiting.")
break
if key == ord("r"):
tactile.reset()
print("\nTracker reset.")
finally:
runtime.close()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
+126 -117
View File
@@ -1,133 +1,142 @@
""" """ORY-BOX / ORY-MINI: read a sensor and get tactile information, in one loop.
ORY-BOX:单传感器基础流程
本示例演示 Orisys SDK 的标准处理流程: The whole recommended flow:
1. 初始化传感器(摄像头或视频文件)
2. 获取图像并计算形变
3. 按需计算接触区域
4. 读取并可视化结果
sensor -> StreamPipeline -> FrameSnapshot per frame
One object reads the camera, pre-processes (stitch, grey, resize), computes the deformation field
and the forces, and hands back one immutable snapshot per frame. There is no stage sequence to
remember and no tuple to unpack by position.
What you do NOT need to touch: the compute backend, the capture queue, the stitch mode, the
low-pass filter, or the order in which the stages run. Those are the SDK's concern and
``StreamPipeline`` sequences them on three concurrent threads. If you later need that control, the
manual form is preserved in ``examples/archive/basic_pipeline_manual.py``.
Run:
python examples/basic_pipeline.py # first camera, box config
python examples/basic_pipeline.py -v 0 -c mini
python examples/basic_pipeline.py -v recording.mp4 -c box
python examples/basic_pipeline.py --no-show # headless: print only
python examples/basic_pipeline.py -v 0 -c box --calibration-dir data/calibrations --device-id BOX-001
Keys: 'q' quit, 'r' reset the tracker.
""" """
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import argparse import argparse
import os
import sys
def main(): # Set before OpenCV is imported: stops the capture backend applying its own transform on top of
parser = argparse.ArgumentParser() # the SDK's preprocessing. This is a capture detail, not a tuning knob you need to own - it is
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径") # here so the file can be copied as a whole.
parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径") os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
parser.add_argument("--cal_path","-cal", type=str, default=None, help="标定文件路径") import cv2
import orisys
from _display import depth_to_color, draw_contact, draw_flow_arrows
def main() -> int:
parser = argparse.ArgumentParser(description="Read a sensor and print its tactile information.")
parser.add_argument("-v", "--video", default="0",
help="camera index or video file path (default: 0)")
parser.add_argument("-c", "--config", default="box",
help="product config: box, mini, finger, or a config path")
calibration = parser.add_mutually_exclusive_group()
calibration.add_argument("--cal", default=None,
help="force calibration file; omit to use the sensor bundle's own")
calibration.add_argument("--calibration-dir", default=None,
help="root containing <product>/<device-id>.json")
parser.add_argument("--device-id", default=None,
help="physical sensor ID; required with --calibration-dir")
parser.add_argument("--no-show", dest="show", action="store_false",
help="do not open the display windows")
parser.add_argument("-verbose", action="store_true", help="SDK verbose logs")
args = parser.parse_args() args = parser.parse_args()
if args.calibration_dir is not None and args.device_id is None:
parser.error("--calibration-dir requires --device-id")
if args.device_id is not None and args.calibration_dir is None and args.cal is None:
parser.error("--device-id requires --calibration-dir or --cal")
# 步骤 1:创建传感器对象(必需) # ------------------------------------------------------------------ create the object
# config_name 可以是内置配置名称,也可以是自定义配置文件路径 # That is the whole setup. The source may be a camera index or a video path.
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件 source = int(args.video) if args.video.isdigit() else args.video
sensor = orisys.Sensor(
# 输入既可以是摄像头编号,也可以是视频文件路径 source, config_name=args.config, cal_path=args.cal,
if args.video.isdigit(): calibration_dir=args.calibration_dir, device_id=args.device_id,
sensor = orisys.Sensor(int(args.video), config_name=args.config, verbose=args.verbose, cal_path=args.cal_path) verbose=args.verbose,
else:
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose, cal_path=args.cal_path)
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
while True:
# 步骤 2:数据处理
# 步骤 2.1:获取并拼接图像(必需)
sensor.get_img()
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
is_contact_now = sensor.compute_deformation()
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
# if is_contact_now:
sensor.compute_contact()
# 步骤 2.4:读取结果(FNORMAL/FSHEAR* 在有 sensors/box/force_calibration.json 时为标定后牛顿力;
# *_RAW 始终为原始积分量)
(
fps,
flow,
vnormal,
img,
contour,
centroid,
depth_map,
fnormal,
fshearx,
fsheary,
fnormal_raw,
fshearx_raw,
fsheary_raw,
) = sensor.read_info(
sensor.info.FPS,
sensor.info.VRAW,
sensor.info.VNORMAL,
sensor.info.IMG,
sensor.info.CONTOUR,
sensor.info.CENTROID,
sensor.info.DEPTH,
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
sensor.info.FNORMAL_RAW,
sensor.info.FSHEARX_RAW,
sensor.info.FSHEARY_RAW,
) )
fn_unit, fx_unit, fy_unit = sensor.force_units
# 步骤 3:可视化显示 selected = sensor.calibration.source_path if sensor.calibration else None
# 步骤 3.1:显示形变矢量场 print(f"Force calibration: {selected or 'none'}")
arrows = orisys.util.draw_arrows( print(f"Force units: FN={fn_unit}, FX={fx_unit}, FY={fy_unit}")
img, if sensor.device_calibration_missing:
flow,
threshold=2,
grid_spacing=20,
arrow_scale=1.0
)
cv2.imshow("arrow", arrows)
# 步骤 3.2:显示接触区域和质心
image_with_foe = orisys.util.draw_contact(img, contour, centroid)
cv2.imshow("contact", image_with_foe)
# 步骤 3.3:显示深度图
# 将深度值归一化到 0-255 范围
div_abs = depth_map
if div_abs.max() > div_abs.min():
div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
else:
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
# 应用伪彩色映射
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
# 步骤 3.4:打印关键结果(标定力 + 原始力)
print( print(
f"FPS={fps:.2f}, " f"Warning: no calibration for {args.device_id!r} under "
f"法向力={fnormal:.4f} N (raw={fnormal_raw:.4f}), " f"{args.calibration_dir}; force is raw."
f"切向力X={fshearx:.4f} N (raw={fshearx_raw:.4f}), "
f"切向力Y={fsheary:.4f} N (raw={fsheary_raw:.4f}), "
f"深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
) )
# 步骤 3.5:键盘控制 # contact=True runs contact analysis. include=... selects which arrays each snapshot carries;
key = cv2.waitKey(1) & 0xFF # arrays that are not included are None, so you pay only for what you use.
include = ("image", "flow", "depth", "centroid", "contour") if args.show else ("image",)
if args.show:
print("Keys: 'q' quit, 'r' reset tracker.")
try:
with orisys.StreamPipeline(sensor, sensor.get_img, contact=True,
include=include) as pipe:
while True:
# ------------------------------------------------------ read one frame
# Blocks until a frame newer than the last one is ready. Everything in the
# snapshot belongs to the same frame.
snap = pipe.wait()
if snap is None:
print("\nInput ended.")
break
# ------------------------------------------------------ use the results
# The *_raw pair is always the unfiltered integrator output.
touching = "yes" if snap.centroid is not None else "no"
fn_unit, fx_unit, fy_unit = sensor.force_units
print(
f"FPS={snap.fps:5.1f} "
f"normal={snap.normal_force:8.4f} {fn_unit} (raw {snap.normal_force_raw:10.2f}) "
f"shear=({snap.shear_x:7.4f} {fx_unit}, {snap.shear_y:7.4f} {fy_unit}) "
f"contact={touching} age={snap.age_ms:4.1f} ms",
end="\r",
)
if args.show and snap.image is not None:
if snap.flow is not None:
cv2.imshow("deformation", draw_flow_arrows(
snap.image, snap.flow, threshold=2, grid_spacing=20))
if snap.contour is not None and snap.centroid is not None:
cv2.imshow("contact", draw_contact(
snap.image, snap.contour, snap.centroid))
colour = depth_to_color(snap.depth)
if colour is not None:
cv2.imshow("depth", colour)
key = cv2.waitKey(1) & 0xFF if args.show else -1
if key == ord("q"): if key == ord("q"):
print("\n正在退出示例程序...") print("\nExiting.")
break break
if key == ord("r"): if key == ord("r"):
# 当光流追踪出现漂移时,可手动重置追踪器 # Queued onto the flow stage, so this is safe from the consumer loop.
sensor.reset() pipe.reset()
print("追踪器已重置。") print("\nTracker reset.")
except KeyboardInterrupt:
# ========================================================================= print("\nInterrupted.")
# 4. 释放资源 finally:
# ========================================================================= sensor.disconnect()
sensor.disconnect() # 释放视频源及相关资源 if args.show:
cv2.destroyAllWindows()
return 0
if __name__ == '__main__': if __name__ == "__main__":
main() sys.exit(main())
+3 -2
View File
@@ -6,8 +6,9 @@
配置里**没写**的滤波键等于"关":整段删掉就是什么都不滤,和 `off` 那段等价。 配置里**没写**的滤波键等于"关":整段删掉就是什么都不滤,和 `off` 那段等价。
背景、代价与可追溯性约定见 `docs/filters.md`;延迟/噪声实测见 `_perf/` 与 背景、代价与可追溯性约定见 `docs/filters.md`;延迟/噪声实测见
`orisys-sdk-perf-plan.md`。 `D:\orisys_ws\docs\performance\orisys-sdk-perf-plan.md`(原 `_perf/` 已从本仓库移除:幸存脚本与
片源在 `D:\orisys_ws\docs\performance\perf-measurements\`,该文档保留全部实测数字)。
下面每一段都有一个同目录的机器可读副本 `filters_<预设名>.json`(四个文件), 下面每一段都有一个同目录的机器可读副本 `filters_<预设名>.json`(四个文件),
`tests/test_filter_presets.py` 会核对它们与 `FILTER_PRESETS` 完全一致 —— 所以改预设时 `tests/test_filter_presets.py` 会核对它们与 `FILTER_PRESETS` 完全一致 —— 所以改预设时
+164 -76
View File
@@ -1,112 +1,200 @@
#!/usr/bin/env python3 """ORY-FINGER: UV-unrolled finger tactile pipeline.
"""
ORY-FINGER:Minimum UV-unroll finger tactile pipeline.
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz Same shape as ``basic_pipeline.py`` - one object, one call per frame - with the finger-specific
Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact capture path underneath:
FingerRuntime -> runtime.pipeline() -> FrameSnapshot per frame
``FingerRuntime`` owns the camera, the Blender export (mesh, intrinsics, world-to-camera), the UV
unroll map and the valid-region mask. ``runtime.pipeline()`` runs those stages concurrently and
returns one immutable snapshot per frame, so there is nothing to sequence and no tuple to unpack.
Run:
Recommended entry:
python examples/finger_pipeline.py --camera_id 0 --config finger python examples/finger_pipeline.py --camera_id 0 --config finger
python examples/finger_pipeline.py --video path/to/clip.mp4
python examples/finger_pipeline.py --no-show # headless: print only
python examples/finger_pipeline.py --config path/to/sensor.json --video clip.mp4 --cal-path model.json --zero
Current product config: ``finger`` (``dd02`` still resolves to ``dd02-ov``). 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 argparse
import math
import os import os
import statistics
import sys import sys
from pathlib import Path
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
import cv2 import cv2
import orisys from _display import draw_flow_arrows
from orisys.configs import resolve_sensor_bundle
from orisys.finger import FingerRuntime from orisys.finger import FingerRuntime
def main(): def configured_export_dir(config):
parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline") """Reuse the selected bundle's geometry, including an explicit sensor.json path."""
parser.add_argument("--camera_id", type=int, default=0) path = Path(config)
parser.add_argument("--video","-v", help="Video file path (overrides camera)") bundle = resolve_sensor_bundle(str(path.resolve().parent) if path.is_file() else config)
parser.add_argument("--config", "-c", default="finger", help="Orisys SDK config name or path") if bundle is None:
parser.add_argument("--show", action="store_true", default=True, help="Show unrolled image window") return None
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs") if path.is_file() and bundle.sensor_json != path.resolve():
args = parser.parse_args() 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: try:
video = args.camera_id if "export_dir" not in options:
if args.video: export_dir = configured_export_dir(args.config)
video = args.video if export_dir is not None:
runtime = FingerRuntime.open( options["export_dir"] = export_dir
config=args.config, runtime = FingerRuntime.open(config=args.config, video=source, verbose=args.verbose, **options)
video=video, except (RuntimeError, ValueError, OSError) as exc:
verbose=args.verbose,
)
except RuntimeError as exc:
print(str(exc), file=sys.stderr) print(str(exc), file=sys.stderr)
sys.exit(1) return 1
if args.verbose: if args.verbose:
geometry = runtime.geometry g = runtime.geometry
print( print(f"UV geometry: native={g.native_width}x{g.native_height} "
f"UV geometry: native={geometry.native_width}x{geometry.native_height}, " f"source={g.source_width}x{g.source_height} "
f"source={geometry.source_width}x{geometry.source_height} " f"output={g.out_width}x{g.out_height}")
f"output={geometry.out_width}x{geometry.out_height} "
)
runtime.warmup(color=False)
tactile = runtime.tactile
print("Press 'q' to quit, 'r' to reset tracker.")
try:
while True:
ok, unrolled = runtime.grab_and_process(color=False)
if not ok:
break
tactile.compute_deformation()
tactile.compute_contact()
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = tactile.read_info(
tactile.info.FPS,
tactile.info.VRAW,
tactile.info.VNORMAL,
tactile.info.IMG,
tactile.info.CONTOUR,
tactile.info.CENTROID,
tactile.info.DEPTH,
tactile.info.FNORMAL,
tactile.info.FSHEARX,
tactile.info.FSHEARY,
)
status = (
f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) "
f"mask={tactile.mask_stats['coverage']:.2f}"
)
if args.show: if args.show:
arrows = orisys.util.draw_arrows( print("Keys: 'q' quit, 'r' reset tracker and clear session zero.")
unrolled,
flow, include = ("image", "flow") if args.show else ("image",)
threshold=0.4, try:
grid_spacing=10, # Warmup may reset the session; apply a supplied reference afterwards and before threads start.
arrow_scale=1.0, runtime.warmup(color=False)
valid_mask=tactile.valid_mask, 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
# ------------------------------------------------------ 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 key = cv2.waitKey(1) & 0xFF if args.show else -1
if key == ord("q"): if key == ord("q"):
print("\nExiting.") print("\nExiting.")
break break
if key == ord("r"): if key == ord("r"):
tactile.reset() pipe.reset()
print("\nTracker reset.") print("\nTracker reset. 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: finally:
runtime.close() runtime.close()
if args.show:
cv2.destroyAllWindows() cv2.destroyAllWindows()
return 0
if __name__ == "__main__": if __name__ == "__main__":
main() sys.exit(main())
+10 -9
View File
@@ -9,9 +9,10 @@ Acquisition adapters (no widgets) live in ``gui.sensors``.
Shared bootstrap: ``gui.app``, ``gui.app_paths``, ``gui.log_config``, ``gui.widgets``. Shared bootstrap: ``gui.app``, ``gui.app_paths``, ``gui.log_config``, ``gui.widgets``.
""" """
from viz.contact_arrow import ContactArrowStyle
from .log_config import LOG_CONFIG, LogConfig from .log_config import LOG_CONFIG, LogConfig
from .viewer3d import ( from .viewer3d import (
FingerDashboardWindow,
LAYOUT, LAYOUT,
LOG_PANEL, LOG_PANEL,
MESH_CONTACT_ARROW, MESH_CONTACT_ARROW,
@@ -19,33 +20,33 @@ from .viewer3d import (
PANEL_SETUP, PANEL_SETUP,
TOP_ROW, TOP_ROW,
DashboardLayout, DashboardLayout,
FingerDashboardWindow,
MeshLighting, MeshLighting,
PanelRow, PanelRow,
PanelSetup, PanelSetup,
PanelSlot, PanelSlot,
run_dashboard, run_dashboard,
top_row_splitter_sizes,
top_row_slot_index, top_row_slot_index,
top_row_splitter_sizes,
) )
from viz.contact_arrow import ContactArrowStyle
__all__ = [ __all__ = [
"DashboardLayout",
"FingerDashboardWindow",
"LAYOUT", "LAYOUT",
"LOG_CONFIG", "LOG_CONFIG",
"LOG_PANEL", "LOG_PANEL",
"LogConfig",
"MESH_CONTACT_ARROW", "MESH_CONTACT_ARROW",
"MESH_LIGHTING", "MESH_LIGHTING",
"ContactArrowStyle",
"MeshLighting",
"PANEL_SETUP", "PANEL_SETUP",
"TOP_ROW", "TOP_ROW",
"ContactArrowStyle",
"DashboardLayout",
"FingerDashboardWindow",
"LogConfig",
"MeshLighting",
"PanelRow", "PanelRow",
"PanelSetup", "PanelSetup",
"PanelSlot", "PanelSlot",
"run_dashboard", "run_dashboard",
"top_row_splitter_sizes",
"top_row_slot_index", "top_row_slot_index",
"top_row_splitter_sizes",
] ]
+1 -1
View File
@@ -34,8 +34,8 @@ def default_config_path() -> str:
def require_gui_extras() -> None: def require_gui_extras() -> None:
try: try:
import open3d # noqa: F401 import open3d # noqa: F401
from PyQt5 import QtWidgets # noqa: F401
import pyqtgraph # noqa: F401 import pyqtgraph # noqa: F401
from PyQt5 import QtWidgets # noqa: F401
except ImportError as exc: except ImportError as exc:
raise SystemExit('GUI example requires: pip install "orisys[gui]"') from exc raise SystemExit('GUI example requires: pip install "orisys[gui]"') from exc
+3 -3
View File
@@ -1,23 +1,23 @@
"""Sensor acquisition adapters for Qt / multi-sensor viewers (no Qt widgets).""" """Sensor acquisition adapters for Qt / multi-sensor viewers (no Qt widgets)."""
from . import roi_geometry
from .adapter import ( from .adapter import (
Dd06RoiAdapter, Dd06RoiAdapter,
FrameSnapshot, FrameSnapshot,
OrisysSensorAdapter, OrisysSensorAdapter,
SensorAdapter, SensorAdapter,
_snapshot_from_sensor,
calibrate_normal_force, calibrate_normal_force,
create_sensor_adapter, create_sensor_adapter,
_snapshot_from_sensor,
) )
from . import roi_geometry
__all__ = [ __all__ = [
"Dd06RoiAdapter", "Dd06RoiAdapter",
"FrameSnapshot", "FrameSnapshot",
"OrisysSensorAdapter", "OrisysSensorAdapter",
"SensorAdapter", "SensorAdapter",
"_snapshot_from_sensor",
"calibrate_normal_force", "calibrate_normal_force",
"create_sensor_adapter", "create_sensor_adapter",
"roi_geometry", "roi_geometry",
"_snapshot_from_sensor",
] ]
+14 -15
View File
@@ -5,11 +5,11 @@ from __future__ import annotations
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from typing import Any
from typing import Any, Optional
import cv2 import cv2
import numpy as np import numpy as np
import orisys import orisys
from .roi_geometry import ( from .roi_geometry import (
@@ -21,7 +21,6 @@ from .roi_geometry import (
warp_roi_to_rectangle, warp_roi_to_rectangle,
) )
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05 NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535 NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
@@ -30,10 +29,10 @@ NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
class FrameSnapshot: class FrameSnapshot:
"""Normalized per-frame payload consumed by the Qt viewer.""" """Normalized per-frame payload consumed by the Qt viewer."""
image: Optional[np.ndarray] = None image: np.ndarray | None = None
flow: Optional[np.ndarray] = None flow: np.ndarray | None = None
depth: Optional[np.ndarray] = None depth: np.ndarray | None = None
contour: Optional[np.ndarray] = None contour: np.ndarray | None = None
centroid: Any = None centroid: Any = None
# Raw SDK force channels used for curves (same units for FN/FX/FY). # Raw SDK force channels used for curves (same units for FN/FX/FY).
normal_force: float = 0.0 normal_force: float = 0.0
@@ -43,10 +42,10 @@ class FrameSnapshot:
normal_force_calibrated: float = 0.0 normal_force_calibrated: float = 0.0
fps: float = 0.0 fps: float = 0.0
timestamp: float = 0.0 timestamp: float = 0.0
raw_image: Optional[np.ndarray] = None raw_image: np.ndarray | None = None
processed_image: Optional[np.ndarray] = None processed_image: np.ndarray | None = None
flow_overlay: Optional[np.ndarray] = None flow_overlay: np.ndarray | None = None
record_frame: Optional[np.ndarray] = None record_frame: np.ndarray | None = None
def calibrate_normal_force(raw_value: float) -> float: def calibrate_normal_force(raw_value: float) -> float:
@@ -62,7 +61,7 @@ class SensorAdapter(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
def read(self) -> Optional[FrameSnapshot]: def read(self) -> FrameSnapshot | None:
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
@@ -89,7 +88,7 @@ def _snapshot_from_sensor(
*, *,
raw_image=None, raw_image=None,
processed_image=None, processed_image=None,
) -> Optional[FrameSnapshot]: ) -> FrameSnapshot | None:
"""Build a FrameSnapshot with HHD/forces refreshed every frame.""" """Build a FrameSnapshot with HHD/forces refreshed every frame."""
if sensor is None or sensor.img is None: if sensor is None or sensor.img is None:
return None return None
@@ -186,7 +185,7 @@ class OrisysSensorAdapter(SensorAdapter):
verbose=self.verbose, verbose=self.verbose,
) )
def read(self) -> Optional[FrameSnapshot]: def read(self) -> FrameSnapshot | None:
if self.sensor is None: if self.sensor is None:
return None return None
img = self.sensor.get_img() img = self.sensor.get_img()
@@ -255,7 +254,7 @@ class Dd06RoiAdapter(SensorAdapter):
f"video={self._source}" f"video={self._source}"
) )
def read(self) -> Optional[FrameSnapshot]: def read(self) -> FrameSnapshot | None:
if self.sensor is None or self.cap is None: if self.sensor is None or self.cap is None:
return None return None
+17 -16
View File
@@ -8,13 +8,14 @@ Does not import camera, ROI, or `orisys.Sensor`.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal, Optional, Sequence, Tuple from typing import Literal
import cv2 import cv2
import numpy as np import numpy as np
import orisys
from scipy.signal import butter, filtfilt from scipy.signal import butter, filtfilt
import orisys
FORCE_SAMPLE_RATE_HZ = 30.0 FORCE_SAMPLE_RATE_HZ = 30.0
PLOT_BUFFER_POINTS = 120 PLOT_BUFFER_POINTS = 120
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ
@@ -40,14 +41,14 @@ class ViewerFrame:
image_height: int image_height: int
image_width: int image_width: int
aspect_ratio: float # width / height aspect_ratio: float # width / height
depth: Optional[np.ndarray] depth: np.ndarray | None
normal_force: float normal_force: float
shear_x: float shear_x: float
shear_y: float shear_y: float
normal_force_calibrated: float normal_force_calibrated: float
fps: float fps: float
timestamp: float timestamp: float
record_frame: Optional[np.ndarray] record_frame: np.ndarray | None
@dataclass @dataclass
@@ -57,8 +58,8 @@ class ForcePlotState:
times: np.ndarray # shape (n,) times: np.ndarray # shape (n,)
values: np.ndarray # shape (n, 3) FN, FX, FY values: np.ndarray # shape (n, 3) FN, FX, FY
sample_count: int sample_count: int
x_range: Tuple[float, float] x_range: tuple[float, float]
y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]] y_ranges: tuple[tuple[float, float], tuple[float, float], tuple[float, float]]
def lowpass_filter( def lowpass_filter(
@@ -87,7 +88,7 @@ def lowpass_filter(
return filtfilt(b, a, y) return filtfilt(b, a, y)
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]: def ensure_bgr(img: np.ndarray | None) -> np.ndarray | None:
if img is None: if img is None:
return None return None
if img.ndim == 2: if img.ndim == 2:
@@ -97,14 +98,14 @@ def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
return img return img
def image_shape_meta(img: np.ndarray) -> Tuple[int, int, float]: def image_shape_meta(img: np.ndarray) -> tuple[int, int, float]:
"""Return (height, width, width/height) from a display image.""" """Return (height, width, width/height) from a display image."""
h, w = int(img.shape[0]), int(img.shape[1]) h, w = int(img.shape[0]), int(img.shape[1])
aspect = float(w) / float(h) if h > 0 else 1.0 aspect = float(w) / float(h) if h > 0 else 1.0
return h, w, aspect return h, w, aspect
def _base_bgr(snapshot) -> Optional[np.ndarray]: def _base_bgr(snapshot) -> np.ndarray | None:
for attr in ("image", "processed_image", "raw_image"): for attr in ("image", "processed_image", "raw_image"):
out = ensure_bgr(getattr(snapshot, attr, None)) out = ensure_bgr(getattr(snapshot, attr, None))
if out is not None: if out is not None:
@@ -152,7 +153,7 @@ def build_display_image(
return select_display_image(snapshot) return select_display_image(snapshot)
def bgr_to_rgb_bytes(img_bgr: np.ndarray) -> Tuple[np.ndarray, int, int]: def bgr_to_rgb_bytes(img_bgr: np.ndarray) -> tuple[np.ndarray, int, int]:
"""Convert BGR uint8 image to contiguous RGB for QImage.""" """Convert BGR uint8 image to contiguous RGB for QImage."""
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
if not rgb.flags["C_CONTIGUOUS"]: if not rgb.flags["C_CONTIGUOUS"]:
@@ -231,7 +232,7 @@ class ForceHistory:
self.data[-1, 0, :] = t self.data[-1, 0, :] = t
self.data[-1, 1, :] = values self.data[-1, 1, :] = values
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> Tuple[float, float]: def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> tuple[float, float]:
if self._y_range_smooth[channel] is None: if self._y_range_smooth[channel] is None:
smoothed = (ymin, ymax) smoothed = (ymin, ymax)
else: else:
@@ -251,7 +252,7 @@ class ForceHistory:
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01) pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
return smoothed[0] - pad, smoothed[1] + pad return smoothed[0] - pad, smoothed[1] + pad
def plot_state(self) -> Optional[ForcePlotState]: def plot_state(self) -> ForcePlotState | None:
n = self.sample_count n = self.sample_count
if n == 0: if n == 0:
return None return None
@@ -305,8 +306,8 @@ class QtViewerBackend:
lowpass_cutoff_hz=lowpass_cutoff_hz, lowpass_cutoff_hz=lowpass_cutoff_hz,
y_axis_min_span=y_axis_min_span, y_axis_min_span=y_axis_min_span,
) )
self._start_time: Optional[float] = None self._start_time: float | None = None
self.last_viewer_frame: Optional[ViewerFrame] = None self.last_viewer_frame: ViewerFrame | None = None
self.arrow_background: ArrowBackground = arrow_background self.arrow_background: ArrowBackground = arrow_background
def set_arrow_background(self, background: ArrowBackground) -> None: def set_arrow_background(self, background: ArrowBackground) -> None:
@@ -357,7 +358,7 @@ class QtViewerBackend:
self._start_time = None self._start_time = None
self.last_viewer_frame = None self.last_viewer_frame = None
def ingest(self, snapshot, *, now: Optional[float] = None) -> ViewerFrame: def ingest(self, snapshot, *, now: float | None = None) -> ViewerFrame:
"""Convert a FrameSnapshot into ViewerFrame and update force history.""" """Convert a FrameSnapshot into ViewerFrame and update force history."""
display = build_display_image(snapshot, self.arrow_background) display = build_display_image(snapshot, self.arrow_background)
h, w, aspect = image_shape_meta(display) h, w, aspect = image_shape_meta(display)
@@ -396,5 +397,5 @@ class QtViewerBackend:
self.last_viewer_frame = frame self.last_viewer_frame = frame
return frame return frame
def plot_state(self) -> Optional[ForcePlotState]: def plot_state(self) -> ForcePlotState | None:
return self.force_history.plot_state() return self.force_history.plot_state()
+6 -6
View File
@@ -2,8 +2,8 @@
from .dashboard import FingerDashboardWindow, run_dashboard from .dashboard import FingerDashboardWindow, run_dashboard
from .layout import ( from .layout import (
LOG_PANEL,
LAYOUT, LAYOUT,
LOG_PANEL,
MESH_CONTACT_ARROW, MESH_CONTACT_ARROW,
MESH_LIGHTING, MESH_LIGHTING,
PANEL_SETUP, PANEL_SETUP,
@@ -13,24 +13,24 @@ from .layout import (
PanelRow, PanelRow,
PanelSetup, PanelSetup,
PanelSlot, PanelSlot,
top_row_splitter_sizes,
top_row_slot_index, top_row_slot_index,
top_row_splitter_sizes,
) )
__all__ = [ __all__ = [
"DashboardLayout",
"FingerDashboardWindow",
"LAYOUT", "LAYOUT",
"LOG_PANEL", "LOG_PANEL",
"MESH_CONTACT_ARROW", "MESH_CONTACT_ARROW",
"MESH_LIGHTING", "MESH_LIGHTING",
"MeshLighting",
"PANEL_SETUP", "PANEL_SETUP",
"TOP_ROW", "TOP_ROW",
"DashboardLayout",
"FingerDashboardWindow",
"MeshLighting",
"PanelRow", "PanelRow",
"PanelSetup", "PanelSetup",
"PanelSlot", "PanelSlot",
"run_dashboard", "run_dashboard",
"top_row_splitter_sizes",
"top_row_slot_index", "top_row_slot_index",
"top_row_splitter_sizes",
] ]
+24 -3
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import logging
import os import os
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
@@ -11,6 +12,8 @@ import cv2
DEFAULT_MAX_CAMERA_INDEX = 10 DEFAULT_MAX_CAMERA_INDEX = 10
logger = logging.getLogger(__name__)
@dataclass(frozen=True) @dataclass(frozen=True)
class CameraInfo: class CameraInfo:
@@ -62,8 +65,14 @@ def _suppress_opencv_noise():
if set_level is not None and prev_level is not None: if set_level is not None and prev_level is not None:
try: try:
set_level(prev_level) set_level(prev_level)
except Exception: except Exception as exc:
pass # Best-effort: a failure only leaves OpenCV probe noise suppressed,
# but report it so a stuck log level is diagnosable.
logger.warning(
"camera discovery: could not restore OpenCV log level %r: %s",
prev_level,
exc,
)
def _backend_candidates() -> list[tuple[str, int]]: def _backend_candidates() -> list[tuple[str, int]]:
@@ -115,9 +124,21 @@ def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]:
backends = _backend_candidates() backends = _backend_candidates()
with _suppress_opencv_noise(): with _suppress_opencv_noise():
for index in range(0, max_index + 1): for index in range(max_index + 1):
for backend_name, backend_api in backends: for backend_name, backend_api in backends:
try:
info = _try_open(index, backend_name, backend_api) info = _try_open(index, backend_name, backend_api)
except Exception as exc:
# Same hazard as the SDK enumerator: an escaping error for one
# backend would abort the whole probe and hide every other
# camera. Skip that candidate and keep going.
logger.warning(
"camera discovery: probing index %d via %s failed: %s",
index,
backend_name,
exc,
)
continue
if info is None: if info is None:
continue continue
if index in seen_indices: if index in seen_indices:
+7 -10
View File
@@ -6,6 +6,9 @@ import math
import sys import sys
import time import time
from pipeline.config import PipelineConfig
from pipeline.csv_recorder import CsvRecorder
from pipeline.service import FingerPipeline
from PyQt5.QtCore import Qt, QTimer from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
@@ -17,23 +20,17 @@ from PyQt5.QtWidgets import (
QWidget, QWidget,
) )
from pipeline.config import PipelineConfig
from pipeline.csv_recorder import CsvRecorder
from pipeline.service import FingerPipeline
from gui.app_paths import resource_path from gui.app_paths import resource_path
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
from .camera_discovery import list_cameras, pick_default_camera from .camera_discovery import list_cameras, pick_default_camera
from .preferences import get_preferred_video_source, load_preferences, save_preferences
from .layout import ( from .layout import (
DashboardLayout,
LAYOUT, LAYOUT,
PANEL_SETUP, DashboardLayout,
PanelSetup,
top_row_splitter_sizes,
) )
from .mesh_view_state import MeshViewState from .mesh_view_state import MeshViewState
from .preferences import get_preferred_video_source, load_preferences, save_preferences
from .styles import STYLE from .styles import STYLE
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
from .widgets import ( from .widgets import (
ArrowPanelWidget, ArrowPanelWidget,
ForceChartWidget, ForceChartWidget,
+3 -2
View File
@@ -47,11 +47,12 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal
from gui.log_config import LOG_CONFIG, LogConfig from viz.contact_arrow import ContactArrowStyle
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
from viz.flow_arrow import FlowArrowStyle from viz.flow_arrow import FlowArrowStyle
from viz.render_2d import ArrowBackground, ArrowCanvasColors from viz.render_2d import ArrowBackground, ArrowCanvasColors
from gui.log_config import LOG_CONFIG, LogConfig
PanelId = Literal["mesh", "arrows", "charts", "log", "settings"] PanelId = Literal["mesh", "arrows", "charts", "log", "settings"]
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from PyQt5.QtCore import QObject, pyqtSignal from PyQt5.QtCore import QObject, pyqtSignal
from .layout import DashboardLayout, LAYOUT from .layout import LAYOUT, DashboardLayout
class MeshViewState(QObject): class MeshViewState(QObject):
+3 -3
View File
@@ -3,14 +3,14 @@
from __future__ import annotations from __future__ import annotations
import cv2 import cv2
from pipeline.frame import FingerFrameView from pipeline.frame import FingerFrameView
from viz.render_2d import ArrowBackground, ArrowCanvasColors, render_uv_arrows from viz.render_2d import ArrowBackground, ArrowCanvasColors, render_uv_arrows
from ..layout import DashboardLayout, LAYOUT
from .image_panel import ImagePanel
from gui.widgets.qt_pixmap import ndarray_to_pixmap from gui.widgets.qt_pixmap import ndarray_to_pixmap
from ..layout import LAYOUT, DashboardLayout
from .image_panel import ImagePanel
class ArrowPanelWidget(ImagePanel): class ArrowPanelWidget(ImagePanel):
"""UV unrolled flow-arrow panel.""" """UV unrolled flow-arrow panel."""
@@ -3,14 +3,14 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from pipeline.frame import FingerFrameView
from PyQt5.QtGui import QFont from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import QSizePolicy from PyQt5.QtWidgets import QSizePolicy
from ..layout import DashboardLayout, LAYOUT
from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
from pipeline.frame import FingerFrameView
from viz.signal_filters import lowpass_filter from viz.signal_filters import lowpass_filter
from ..layout import LAYOUT, DashboardLayout
from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
def _require_pyqtgraph(): def _require_pyqtgraph():
try: try:
@@ -2,16 +2,16 @@
from __future__ import annotations from __future__ import annotations
from pipeline.frame import FingerFrameView
from PyQt5.QtCore import QEvent, Qt from PyQt5.QtCore import QEvent, Qt
from PyQt5.QtGui import QMouseEvent from PyQt5.QtGui import QMouseEvent
from pipeline.frame import FingerFrameView
from viz.offscreen_mesh import OffscreenFingerMeshRenderer from viz.offscreen_mesh import OffscreenFingerMeshRenderer
from ..layout import DashboardLayout, LAYOUT
from ..mesh_view_state import MeshViewState
from ..mesh_render_worker import MeshRenderWorker
from gui.widgets.qt_pixmap import ndarray_to_pixmap from gui.widgets.qt_pixmap import ndarray_to_pixmap
from ..layout import LAYOUT, DashboardLayout
from ..mesh_render_worker import MeshRenderWorker
from ..mesh_view_state import MeshViewState
from .image_panel import ImagePanel from .image_panel import ImagePanel
_VIEW_ROTATE_SENSITIVITY = 0.35 _VIEW_ROTATE_SENSITIVITY = 0.35
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from PyQt5.QtCore import QSize, Qt, pyqtSignal from PyQt5.QtCore import QSize, pyqtSignal
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
@@ -14,11 +14,11 @@ from PyQt5.QtWidgets import (
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
from viz.render_2d import ArrowBackground from viz.render_2d import ArrowBackground
from ..layout import DashboardLayout, LAYOUT from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
from ..layout import LAYOUT, DashboardLayout
from ..mesh_view_state import MeshViewState from ..mesh_view_state import MeshViewState
from ..transport_icons import reset_icon, transport_icon from ..transport_icons import reset_icon, transport_icon
+16 -9
View File
@@ -9,12 +9,16 @@ ORY-MINI:单传感器基础流程
""" """
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import argparse import argparse
import cv2
import numpy as np
import orisys
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径") parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
@@ -29,11 +33,13 @@ def main():
# 输入既可以是摄像头编号,也可以是视频文件路径 # 输入既可以是摄像头编号,也可以是视频文件路径
try: try:
input = int(args.video) input = int(args.video)
is_camera = True except ValueError:
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose) # Not a camera index: treat the argument as a video-file path. Falling back
except: # only on the conversion (not on Sensor construction) keeps a real
is_camera = False # constructor failure visible instead of retrying it as a file path.
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose) sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
else:
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。") print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
@@ -89,9 +95,10 @@ def main():
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET)) cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
# 步骤 3.4:打印关键结果 # 步骤 3.4:打印关键结果
fn_unit, fx_unit, fy_unit = sensor.force_units
print( print(
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, " f"FPS={fps:.2f}, 法向力={fnormal:.4f} {fn_unit}, 切向力X={fshearx:.4f} {fx_unit}, "
f"切向力Y={fsheary:.4f}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}" f"切向力Y={fsheary:.4f} {fy_unit}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
) )
# 步骤 3.5:键盘控制 # 步骤 3.5:键盘控制
+4 -1
View File
@@ -245,7 +245,10 @@ def main():
try: try:
with open(path) as fh: with open(path) as fh:
rows.append(json.load(fh)) rows.append(json.load(fh))
except Exception: except Exception: # noqa: S110 - a stream that wrote no result is reported below
# A stream that crashed or produced no JSON output is expected here;
# the empty-``rows`` check below reports that case explicitly, so this
# is not a hidden failure.
pass pass
if not rows: if not rows:
print("no stream produced results", file=sys.stderr) print("no stream produced results", file=sys.stderr)
+3 -2
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
from viz.mesh_camera import DEFAULT_FINGER_VIZ_POSE from viz.mesh_camera import DEFAULT_FINGER_VIZ_POSE
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
def mesh_threshold(mesh_map: str, threshold: float | None) -> float: def mesh_threshold(mesh_map: str, threshold: float | None) -> float:
if threshold is not None: if threshold is not None:
@@ -33,7 +34,7 @@ class PipelineConfig:
profile_every: int = 30 profile_every: int = 30
@classmethod @classmethod
def from_args(cls, args) -> "PipelineConfig": def from_args(cls, args) -> PipelineConfig:
return cls( return cls(
video=args.video, video=args.video,
config=args.config, config=args.config,
-1
View File
@@ -7,7 +7,6 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
CSV_COLUMNS = ( CSV_COLUMNS = (
"frame_index", "frame_index",
"timestamp_s", "timestamp_s",
+2 -2
View File
@@ -6,10 +6,10 @@ import time
from dataclasses import dataclass from dataclasses import dataclass
import numpy as np import numpy as np
from orisys.finger import FingerRuntime, compute_uv_surface_frames
from viz import FrameRecorder, build_uv_surface_mesh, resolve_viz_pose_path from viz import FrameRecorder, build_uv_surface_mesh, resolve_viz_pose_path
from orisys.finger import FingerRuntime, compute_uv_surface_frames
from .config import PipelineConfig from .config import PipelineConfig
from .csv_recorder import CsvMeasurement from .csv_recorder import CsvMeasurement
from .frame import FingerFrameView from .frame import FingerFrameView
+7 -3
View File
@@ -16,10 +16,10 @@ os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2 import cv2
import numpy as np import numpy as np
import orisys
from viz import ForceChartWindow, FrameRecorder from viz import ForceChartWindow, FrameRecorder
import orisys
def open_sensor(video: str, config: str, cal: str, verbose: bool): def open_sensor(video: str, config: str, cal: str, verbose: bool):
try: try:
@@ -65,7 +65,11 @@ def main():
sensor.info.VRAW, sensor.info.VRAW,
sensor.info.DEPTH, sensor.info.DEPTH,
) )
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}") fn_unit, fx_unit, fy_unit = sensor.force_units
print(
f"FPS={fps:.2f}, 法向力={fn:.4f} {fn_unit}, "
f"切向力X={fx:.4f} {fx_unit}, 切向力Y={fy:.4f} {fy_unit}"
)
arrows = orisys.util.draw_arrows(sensor.img, flow, threshold=5, grid_spacing=10, arrow_scale=1.0) arrows = orisys.util.draw_arrows(sensor.img, flow, threshold=5, grid_spacing=10, arrow_scale=1.0)
cv2.imshow("形变矢量场", arrows) cv2.imshow("形变矢量场", arrows)
+22 -10
View File
@@ -20,14 +20,17 @@
""" """
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import multiprocessing as mp
from multiprocessing import Queue, Process
import time
import argparse import argparse
import multiprocessing as mp
import queue
import time
from multiprocessing import Process, Queue
import cv2
import orisys
def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, target_fps=None): def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, target_fps=None):
@@ -55,6 +58,7 @@ def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, tar
print(f"传感器 {sensor_id} 已启动" + (f"(目标帧率:{target_fps} FPS)" if target_fps else "")) print(f"传感器 {sensor_id} 已启动" + (f"(目标帧率:{target_fps} FPS)" if target_fps else ""))
frame_interval = 1.0 / target_fps if target_fps and target_fps > 0 else 0.0 frame_interval = 1.0 / target_fps if target_fps and target_fps > 0 else 0.0
dropped_frames = 0
while not stop_event.is_set(): while not stop_event.is_set():
t_start = time.perf_counter() t_start = time.perf_counter()
@@ -95,9 +99,10 @@ def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, tar
# 步骤 6:将结果发送到主进程(非阻塞) # 步骤 6:将结果发送到主进程(非阻塞)
try: try:
result_queue.put_nowait(data) result_queue.put_nowait(data)
except Exception: except queue.Full:
# 如果队列已满,则丢弃当前帧,避免阻塞工作进程 # 队列已满:丢弃当前帧,避免阻塞工作进程。这是有意的背压策略,
pass # 但丢帧必须可观测,否则主进程看到的低帧率无从解释。
dropped_frames += 1
except Exception as e: except Exception as e:
print(f"传感器 {sensor_id} 处理失败:{e}") print(f"传感器 {sensor_id} 处理失败:{e}")
@@ -112,6 +117,12 @@ def sensor_worker(sensor_id, vid_src, config_name, result_queue, stop_event, tar
# 释放资源 # 释放资源
sensor.disconnect() sensor.disconnect()
if dropped_frames:
print(
f"传感器 {sensor_id} 已停止(因结果队列已满丢弃 {dropped_frames} 帧,"
"主进程处理慢于采集)。"
)
else:
print(f"传感器 {sensor_id} 已停止。") print(f"传感器 {sensor_id} 已停止。")
except Exception as e: except Exception as e:
@@ -193,7 +204,8 @@ def main():
while True: while True:
try: try:
data = queue.get_nowait() data = queue.get_nowait()
except: except queue.Empty:
# 队列已排空:正常结束内层循环,不是错误。
break break
if data is not None: if data is not None:
+9 -9
View File
@@ -1,5 +1,7 @@
"""Shared visualization helpers for Orisys example scripts.""" """Shared visualization helpers for Orisys example scripts."""
from .finger_mesh_viewer import FingerMeshViewer
from .flow_arrow import DEFAULT_FLOW_ARROW_STYLE, FlowArrowOverlay, FlowArrowStyle
from .force_charts import ForceChartWindow from .force_charts import ForceChartWindow
from .mesh_camera import ( from .mesh_camera import (
DEFAULT_FINGER_VIZ_POSE, DEFAULT_FINGER_VIZ_POSE,
@@ -8,33 +10,32 @@ from .mesh_camera import (
camera_look_at_from_world_to_cam, camera_look_at_from_world_to_cam,
resolve_viz_pose_path, resolve_viz_pose_path,
) )
from .mesh_viewer import UVMeshViewer, build_uv_surface_mesh
from .offscreen_mesh import OffscreenFingerMeshRenderer from .offscreen_mesh import OffscreenFingerMeshRenderer
from .recording import FrameRecorder
from .render_2d import ( from .render_2d import (
DEFAULT_ARROW_CANVAS_COLORS,
ArrowBackground, ArrowBackground,
ArrowCanvasColors, ArrowCanvasColors,
DEFAULT_ARROW_CANVAS_COLORS,
depth_colormap, depth_colormap,
frame_to_bgr, frame_to_bgr,
render_contact_panel, render_contact_panel,
render_depth_panel, render_depth_panel,
render_uv_arrows, render_uv_arrows,
) )
from .flow_arrow import DEFAULT_FLOW_ARROW_STYLE, FlowArrowOverlay, FlowArrowStyle
from .finger_mesh_viewer import FingerMeshViewer
from .mesh_viewer import UVMeshViewer, build_uv_surface_mesh
from .recording import FrameRecorder
from .signal_filters import lowpass_filter from .signal_filters import lowpass_filter
__all__ = [ __all__ = [
"ArrowBackground",
"ArrowCanvasColors",
"DEFAULT_ARROW_CANVAS_COLORS", "DEFAULT_ARROW_CANVAS_COLORS",
"DEFAULT_FLOW_ARROW_STYLE", "DEFAULT_FLOW_ARROW_STYLE",
"ArrowBackground",
"ArrowCanvasColors",
"FingerMeshViewer",
"FlowArrowOverlay", "FlowArrowOverlay",
"FlowArrowStyle", "FlowArrowStyle",
"FingerMeshViewer",
"ForceChartWindow", "ForceChartWindow",
"FrameRecorder", "FrameRecorder",
"OffscreenFingerMeshRenderer",
"UVMeshViewer", "UVMeshViewer",
"apply_mesh_lighting", "apply_mesh_lighting",
"apply_view_from_world_to_cam", "apply_view_from_world_to_cam",
@@ -43,7 +44,6 @@ __all__ = [
"depth_colormap", "depth_colormap",
"frame_to_bgr", "frame_to_bgr",
"lowpass_filter", "lowpass_filter",
"OffscreenFingerMeshRenderer",
"render_contact_panel", "render_contact_panel",
"render_depth_panel", "render_depth_panel",
"render_uv_arrows", "render_uv_arrows",
+5 -1
View File
@@ -8,7 +8,11 @@ import numpy as np
from orisys.finger.viewer import UVMeshViewer from orisys.finger.viewer import UVMeshViewer
from .contact_arrow import ContactArrowOverlay, ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE from .contact_arrow import (
DEFAULT_CONTACT_ARROW_STYLE,
ContactArrowOverlay,
ContactArrowStyle,
)
from .flow_arrow import DEFAULT_FLOW_ARROW_STYLE, FlowArrowOverlay, FlowArrowStyle from .flow_arrow import DEFAULT_FLOW_ARROW_STYLE, FlowArrowOverlay, FlowArrowStyle
+2 -1
View File
@@ -2,8 +2,9 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable, Literal from typing import Literal
import cv2 import cv2
import numpy as np import numpy as np
+5 -1
View File
@@ -1,4 +1,8 @@
"""Re-exports for finger 3D mesh examples; core viewer lives in orisys.finger.""" """Re-exports for finger 3D mesh examples; core viewer lives in orisys.finger."""
from orisys.finger.mesh import * # noqa: F403 from orisys.finger.mesh import * # noqa: F403
from orisys.finger.viewer import UVMeshViewer, init_uv_mesh_viewer, update_uv_mesh_viewer # noqa: F401 from orisys.finger.viewer import ( # noqa: F401
UVMeshViewer,
init_uv_mesh_viewer,
update_uv_mesh_viewer,
)
-1
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
import cv2 import cv2
+1
View File
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Literal
import cv2 import cv2
import numpy as np import numpy as np
import orisys import orisys
if TYPE_CHECKING: if TYPE_CHECKING:
+1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import numpy as np import numpy as np
from scipy.signal import butter, filtfilt from scipy.signal import butter, filtfilt
def lowpass_filter(data, cutoff_hz=5, sample_rate_hz=30, order=2) -> np.ndarray: def lowpass_filter(data, cutoff_hz=5, sample_rate_hz=30, order=2) -> np.ndarray:
"""Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data.""" """Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data."""
y = np.asarray(data, dtype=np.float64) y = np.asarray(data, dtype=np.float64)
+55
View File
@@ -0,0 +1,55 @@
# Ruff configuration — pinned deliberately.
#
# Before this file existed the repo had no Ruff config, so `ruff check .` ran whatever the
# installed Ruff version enables by default (Ruff 0.16 turns on a very broad set: UP/RUF/PLR/
# S/TRY/PERF/FURB/YTT/ASYNC...). The same tree therefore produced different findings on
# different machines and between a developer and a runner. Never rely on tool defaults.
#
# Policy, decided from the measured findings rather than from taste:
#
# * `select` is explicit. High-signal correctness rules first, then only the mechanical
# modernisation rules we are willing to apply without human review.
# * BLE001 is selected so existing `# noqa: BLE001 - <reason>` suppressions stay *used* and
# RUF100 can never delete their rationale — a broad `except Exception` is a legitimate
# choice around device I/O, capture probes and teardown. It is NOT part of the gate.
# * S110 / S112 / E722 ARE part of the gate: a silent handler hides a failure. This matters
# most in the live capture path, where a swallowed read error kills a source silently.
# * RUF100 reports unused suppressions but is `unfixable`: a machine must not remove a
# suppression whose comment records why it exists. Triage those by hand.
# * The mechanical sweep never uses `--unsafe-fixes`.
#
# The gate selects the narrow set explicitly, so BLE001 cannot fail a build, and only ever
# touches changed files:
#
# tools/lint_gate.py # the gate: changed files, machine-readable
target-version = "py310"
line-length = 88
[lint]
select = [
"E9", "E722", # syntax/IO errors, and bare `except:`
"E402", # module import not at top of file — SELECTED for visibility, NOT gated.
# `import` after `os.environ[...]` is deliberate in examples/ and scripts/
# (the environment must be set before Qt/cv2/orisys load). E402 is the only
# rule that would catch an accidental import moved above that setup, so it
# stays reported, without failing a build. An intentional site belongs in a
# `# noqa: E402 - <reason>` when it is touched.
"F", # pyflakes: unused import, redefinition, undefined name
"S110", "S112", # try-except-pass / try-except-continue — an invisible failure
"B023", # function definition does not bind loop variable
"PLW1510", # subprocess.run without an explicit check
"RUF100", # unused noqa — reported for triage, never auto-removed
"UP", # pyupgrade
"I", # import sorting
"RUF022", "RUF023", # __all__ / __slots__ ordering
"PIE", # flake8-pie
"RET501", "PLR1711",
"SIM114",
"BLE001", # selected for suppression hygiene only — excluded from the gate
]
unfixable = ["RUF100"]
[format]
quote-style = "double"
indent-style = "space"
+11 -7
View File
@@ -1,6 +1,7 @@
import os import os
import sys import sys
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2 import cv2
@@ -9,10 +10,11 @@ _local_src = os.path.join(os.path.dirname(__file__), "src")
if _local_src not in sys.path: if _local_src not in sys.path:
sys.path.insert(0, _local_src) sys.path.insert(0, _local_src)
import orisys
import numpy as np
import time
import argparse import argparse
import time
import orisys
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@@ -30,11 +32,13 @@ def main():
# 方式1: 从视频文件读取 # 方式1: 从视频文件读取
try: try:
input = int(args.video) input = int(args.video)
is_camera = True except ValueError:
sensor = orisys.Sensor(input, config_name=args.config, cal_path= args.cal, verbose=args.verbose, debug=args.debug) # Not a camera index: treat the argument as a video-file path. Only the
except: # conversion is guarded, so a genuine Sensor construction error is not
is_camera = False # swallowed and retried as a file path.
sensor = orisys.Sensor(args.video, config_name=args.config, cal_path= args.cal, verbose=args.verbose, debug=args.debug) sensor = orisys.Sensor(args.video, config_name=args.config, cal_path= args.cal, verbose=args.verbose, debug=args.debug)
else:
sensor = orisys.Sensor(input, config_name=args.config, cal_path= args.cal, verbose=args.verbose, debug=args.debug)
# sensor = orisys.Sensor(2, config_name="./config/ddjx01_q6a.json", cal_path= "./config/ddjx01.npy", isstitch=True, cuda=False, verbose=True, backend='cpu') # sensor = orisys.Sensor(2, config_name="./config/ddjx01_q6a.json", cal_path= "./config/ddjx01.npy", isstitch=True, cuda=False, verbose=True, backend='cpu')
# 方式2: 从摄像头读取(取消注释使用) # 方式2: 从摄像头读取(取消注释使用)
+7 -4
View File
@@ -1,12 +1,15 @@
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import time import time
import cv2
import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
import orisys
def create_plot(): def create_plot():
p1 = win.addPlot(title="法相力") p1 = win.addPlot(title="法相力")
p1.setLabel('bottom', '时间', units='s') p1.setLabel('bottom', '时间', units='s')
@@ -149,7 +152,7 @@ def main():
print("\n 停止程序") print("\n 停止程序")
break break
if key == ord("s"): if key == ord("s"):
save_path = f"./data/pulse_test.mp4" save_path = "./data/pulse_test.mp4"
# Ensure directory exists # Ensure directory exists
os.makedirs(os.path.dirname(save_path), exist_ok=True) os.makedirs(os.path.dirname(save_path), exist_ok=True)
# Get frame dimensions from the actual frame # Get frame dimensions from the actual frame
+16 -9
View File
@@ -46,7 +46,7 @@ import os
import shlex import shlex
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Union
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
@@ -60,13 +60,14 @@ except ImportError as exc: # pragma: no cover
) from exc ) from exc
import cv2 import cv2
from orisys.capture import is_live_camera, list_cameras from orisys.capture import is_live_camera, list_cameras
from orisys.finger import FingerRuntime from orisys.finger import FingerRuntime
Source = Union[int, str] Source = Union[int, str]
def _parse_source(token: str) -> Optional[Source]: def _parse_source(token: str) -> Source | None:
"""Accept camera index int, numeric string, V4L path, or filesystem path.""" """Accept camera index int, numeric string, V4L path, or filesystem path."""
text = str(token).strip().strip('"').strip("'") text = str(token).strip().strip('"').strip("'")
if not text: if not text:
@@ -116,9 +117,9 @@ class UartFingerController:
self.show = show self.show = show
self.verbose = verbose self.verbose = verbose
self.runtime: Optional[FingerRuntime] = None self.runtime: FingerRuntime | None = None
self._connected_source: Optional[Source] = None self._connected_source: Source | None = None
self._last_search_source: Optional[Source] = None self._last_search_source: Source | None = None
self._streaming = False self._streaming = False
self._should_quit = False self._should_quit = False
@@ -134,14 +135,18 @@ class UartFingerController:
if self.runtime is not None: if self.runtime is not None:
try: try:
self.runtime.close() self.runtime.close()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001, S110 - teardown must not raise
# Best-effort release: a close failure here has no recovery path in
# a shutdown request handler, and raising would skip the reset below.
pass pass
self.runtime = None self.runtime = None
self._connected_source = None self._connected_source = None
if self.show: if self.show:
try: try:
cv2.destroyAllWindows() cv2.destroyAllWindows()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001, S110 - no GUI window to destroy
# Expected when no window was ever created (headless/--no-show run):
# this is a normal no-op, not a hidden failure.
pass pass
def _resolve_connect_source(self, args: list[str]) -> Source: def _resolve_connect_source(self, args: list[str]) -> Source:
@@ -220,7 +225,7 @@ class UartFingerController:
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
return False return False
def _poll_command_line(self, *, blocking: bool = True) -> Optional[str]: def _poll_command_line(self, *, blocking: bool = True) -> str | None:
"""Read one command line if available; return None on timeout/empty.""" """Read one command line if available; return None on timeout/empty."""
if not blocking and self.ser.in_waiting <= 0: if not blocking and self.ser.in_waiting <= 0:
return None return None
@@ -447,7 +452,9 @@ def main(argv: list[str] | None = None) -> int:
print("\nInterrupted.", flush=True) print("\nInterrupted.", flush=True)
try: try:
controller.reply("OK quit") controller.reply("OK quit")
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001, S110 - peer may already be gone
# We are already shutting down on Ctrl-C; failing to send the final
# acknowledgement (peer closed the port) must not mask the exit path.
pass pass
finally: finally:
controller.close_runtime() controller.close_runtime()
+2 -3
View File
@@ -36,10 +36,9 @@ from __future__ import annotations
import argparse import argparse
import csv import csv
import statistics
import sys import sys
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from typing import Sequence
import numpy as np import numpy as np
@@ -79,7 +78,7 @@ def _window(
valid = _column(rows, VALID_COLUMN) valid = _column(rows, VALID_COLUMN)
mask = np.ones(times.shape, dtype=bool) mask = np.ones(times.shape, dtype=bool)
if times.size: if times.size:
mask &= times == times # drop NaN timestamps mask &= ~np.isnan(times) # drop NaN timestamps (unparsable TIME cells)
if start is not None: if start is not None:
mask &= times >= float(start) mask &= times >= float(start)
if stop is not None: if stop is not None:
+9 -5
View File
@@ -1,14 +1,18 @@
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import time
import argparse import argparse
import time
import cv2
import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5.QtGui import QImage, QFont from PyQt5.QtGui import QFont, QImage
from pyqtgraph.Qt import QtGui from pyqtgraph.Qt import QtGui
import orisys
def make_gradient_brush(color_hex, max_alpha=150): def make_gradient_brush(color_hex, max_alpha=150):
""" """
创建一个从上到下渐变的笔刷 (兼容 PyQt5 和 PyQt6) 创建一个从上到下渐变的笔刷 (兼容 PyQt5 和 PyQt6)
+2 -2
View File
@@ -42,8 +42,8 @@
}, },
"contact": { "contact": {
"min_contour_area": 3000, "min_contour_area": 3000,
"flow_threshold":5, "flow_threshold":0.2,
"div_threshold":0.05, "div_threshold":0.007,
"expansion_threshold": 0.01, "expansion_threshold": 0.01,
"kernel_size": [5,5], "kernel_size": [5,5],
"denoise_sigma": 12 "denoise_sigma": 12
+1 -1
View File
@@ -34,7 +34,7 @@
"contact": { "contact": {
"min_contour_area": 3000, "min_contour_area": 3000,
"flow_threshold": 0.2, "flow_threshold": 0.2,
"div_threshold": 0.022, "div_threshold": 0.007,
"expansion_threshold": 0.01, "expansion_threshold": 0.01,
"kernel_size": [5, 5], "kernel_size": [5, 5],
"denoise_sigma": 12 "denoise_sigma": 12
+2 -2
View File
@@ -42,8 +42,8 @@
}, },
"contact": { "contact": {
"min_contour_area": 3000, "min_contour_area": 3000,
"flow_threshold":5, "flow_threshold":0.2,
"div_threshold":0.05, "div_threshold":0.007,
"expansion_threshold": 0.01, "expansion_threshold": 0.01,
"kernel_size": [5,5], "kernel_size": [5,5],
"denoise_sigma": 12 "denoise_sigma": 12
+1 -3
View File
@@ -5,9 +5,7 @@ Historical import path ``from orisys.Stitcher import Stitcher`` remains supporte
from .stitch import Stitcher from .stitch import Stitcher
from .stitch.backends.numpy_backend import blend_kernel_numpy as _blend_kernel_numpy from .stitch.backends.numpy_backend import blend_kernel_numpy as _blend_kernel_numpy
from .stitch.config import BLEND_MODES as _BLEND_MODES from .stitch.config import numba_blend_available
from .stitch.config import DEFAULT_BLEND_MODE as _DEFAULT_BLEND_MODE
from .stitch.config import numba_blend_available, resolve_blend_mode as _resolve_blend_mode
# Backward-compatible aliases used by older call sites / tests. # Backward-compatible aliases used by older call sites / tests.
_blend_kernel = _blend_kernel_numpy _blend_kernel = _blend_kernel_numpy
+12 -9
View File
@@ -5,19 +5,20 @@ A Python library for processing and analyzing optical tactile sensor data,
including image stitching, optical flow tracking, and deformation decomposition. including image stitching, optical flow tracking, and deformation decomposition.
""" """
__version__ = "0.5.3" __version__ = "0.5.4"
__author__ = "Orisys" __author__ = "Orisys"
from .sensor import Sensor from . import calibration, capture, configs, finger, signal_processing, util
from . import util
from . import configs
from . import calibration
from . import finger
from . import signal_processing
from . import capture
from .pipeline import FrameSnapshot, PipelineStats, StreamPipeline
from .export import StreamRecorder, mosaic_videos from .export import StreamRecorder, mosaic_videos
from .frame_processors import FFCFrameProcessor from .frame_processors import FFCFrameProcessor
from .pipeline import (
FrameSnapshot,
PauseLevel,
PipelineStats,
ProducedFrame,
StreamPipeline,
)
from .sensor import Sensor
CompoundEyeSensor = Sensor CompoundEyeSensor = Sensor
CameraSensor = Sensor CameraSensor = Sensor
@@ -49,7 +50,9 @@ __all__ = [
# pipelined multi-stream processing # pipelined multi-stream processing
'StreamPipeline', 'StreamPipeline',
'FrameSnapshot', 'FrameSnapshot',
'ProducedFrame',
'PipelineStats', 'PipelineStats',
'PauseLevel',
# offline recording of arrow overlays # offline recording of arrow overlays
'StreamRecorder', 'StreamRecorder',
+1 -1
View File
@@ -63,8 +63,8 @@ except ImportError:
try: try:
import pyopencl as cl import pyopencl as cl
import pyopencl.array as cla import pyopencl.array as cla
from pyvkfft.fft import rfftn as cl_rfftn
from pyvkfft.fft import irfftn as cl_irfftn from pyvkfft.fft import irfftn as cl_irfftn
from pyvkfft.fft import rfftn as cl_rfftn
_opencl_cl = cl _opencl_cl = cl
_opencl_cla = cla _opencl_cla = cla
_opencl_rfftn = cl_rfftn _opencl_rfftn = cl_rfftn
+115 -6
View File
@@ -7,15 +7,17 @@ curves in ``_apply_force_filter``; ``read_info(FNORMAL, …)`` returns Newtons.
from __future__ import annotations from __future__ import annotations
import json import json
import re
import warnings import warnings
from collections.abc import Iterable, Mapping
from copy import deepcopy from copy import deepcopy
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable, Mapping from typing import Any
CURRENT_SCHEMA = 2 CURRENT_SCHEMA = 2
SUPPORTED_SCHEMAS = (1, 2) SUPPORTED_SCHEMAS = (1, 2, 3)
CHANNEL_IDS: tuple[str, ...] = ("fn", "fx", "fy") CHANNEL_IDS: tuple[str, ...] = ("fn", "fx", "fy")
CHANNEL_ALIASES: dict[str, str] = { CHANNEL_ALIASES: dict[str, str] = {
@@ -498,6 +500,17 @@ def check(doc: dict | Calibration, *, pipeline: dict | None = None) -> CheckResu
warns: list[str] = [] warns: list[str] = []
notes: 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): if isinstance(doc, Calibration):
data = doc.to_dict() data = doc.to_dict()
channels_obj = doc.channels channels_obj = doc.channels
@@ -603,6 +616,14 @@ def parse(data: dict, *, source_path: Path | None = None) -> Calibration:
if schema not in SUPPORTED_SCHEMAS: if schema not in SUPPORTED_SCHEMAS:
raise CalibrationError(f"unsupported schema_version: {schema}") 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")) unit = str(data.get("unit", "N"))
channels_raw = data.get("channels") channels_raw = data.get("channels")
if not isinstance(channels_raw, dict): if not isinstance(channels_raw, dict):
@@ -764,7 +785,7 @@ def load(
explicit = source is not None and str(source).strip() != "" explicit = source is not None and str(source).strip() != ""
if explicit: if explicit:
path = Path(str(source)).expanduser() path = select_calibration_path(cal_path=source)
if not path.is_file(): if not path.is_file():
msg = f"calibration file not found: {path}" msg = f"calibration file not found: {path}"
if strict or explicit: if strict or explicit:
@@ -799,11 +820,96 @@ def load(
try: try:
return parse(data, source_path=path) return parse(data, source_path=path)
except CalibrationError: 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 raise
return None return None
def load_for_device(
calibration_dir: str | Path,
sensor_type: str,
device_id: str,
*,
pipeline: Mapping[str, Any] | None = None,
) -> Calibration | None:
"""Load ``<directory>/<sensor_type>/<device_id>.json``; missing file means raw."""
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( def pipeline_from_sensor_json(
path: str | Path | None, path: str | Path | None,
*, *,
@@ -994,10 +1100,13 @@ def write(
path = Path(path) path = Path(path)
if path.exists() and not overwrite: if path.exists() and not overwrite:
raise FileExistsError(f"calibration file exists: {path}") 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) path.parent.mkdir(parents=True, exist_ok=True)
doc = cal.to_dict() doc = cal.to_dict()
# Always emit schema 2 for new writes. # Preserve an explicitly selected model format; legacy builders still emit v2.
doc["schema_version"] = CURRENT_SCHEMA doc["schema_version"] = 3 if cal.schema_version == 3 else CURRENT_SCHEMA
path.write_text( path.write_text(
json.dumps(doc, indent=2, ensure_ascii=False) + "\n", json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8", encoding="utf-8",
+75
View File
@@ -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
+307
View File
@@ -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)
+15 -13
View File
@@ -16,6 +16,7 @@ from .discovery import (
pick_default_camera, pick_default_camera,
) )
from .source import ( from .source import (
CapturedFrame,
CaptureSource, CaptureSource,
CaptureWorker, CaptureWorker,
is_live_camera, is_live_camera,
@@ -25,21 +26,22 @@ from .source import (
) )
__all__ = [ __all__ = [
"MAX_SIMULTANEOUS_CAPTURES",
"DEFAULT_MAX_CAMERA_INDEX",
"DEFAULT_LAPTOP_WEBCAM_PATTERNS", "DEFAULT_LAPTOP_WEBCAM_PATTERNS",
"CaptureConfig", "DEFAULT_MAX_CAMERA_INDEX",
"CaptureHealth", "MAX_SIMULTANEOUS_CAPTURES",
"CaptureStatus",
"ReconnectPolicy",
"CaptureSource",
"CaptureWorker",
"CameraInfo",
"CameraFilter", "CameraFilter",
"list_cameras", "CameraInfo",
"pick_default_camera", "CaptureConfig",
"normalize_source", "CapturedFrame",
"CaptureHealth",
"CaptureSource",
"CaptureStatus",
"CaptureWorker",
"ReconnectPolicy",
"is_live_camera", "is_live_camera",
"open_opencv_capture", "list_cameras",
"normalize_source",
"open_capture", "open_capture",
"open_opencv_capture",
"pick_default_camera",
] ]
+18 -17
View File
@@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Any, Optional, Sequence, Tuple, Union from typing import Any, Union
MAX_SIMULTANEOUS_CAPTURES = 10 MAX_SIMULTANEOUS_CAPTURES = 10
@@ -44,11 +45,11 @@ class ReconnectPolicy:
""" """
enabled: bool = False enabled: bool = False
max_attempts: Optional[int] = None max_attempts: int | None = None
initial_backoff_s: float = 0.25 initial_backoff_s: float = 0.25
max_backoff_s: float = 5.0 max_backoff_s: float = 5.0
fail_threshold: int = 5 fail_threshold: int = 5
stale_timeout_s: Optional[float] = 2.0 stale_timeout_s: float | None = 2.0
def next_backoff(self, attempt: int) -> float: def next_backoff(self, attempt: int) -> float:
"""Exponential backoff capped at ``max_backoff_s`` (attempt is 0-based).""" """Exponential backoff capped at ``max_backoff_s`` (attempt is 0-based)."""
@@ -62,19 +63,19 @@ class CaptureConfig:
source: SourceType source: SourceType
backend: str = "auto" backend: str = "auto"
width: Optional[int] = None width: int | None = None
height: Optional[int] = None height: int | None = None
fps: Optional[float] = None fps: float | None = None
fourcc: Optional[str] = "MJPG" fourcc: str | None = "MJPG"
background: bool = True background: bool = True
reconnect: ReconnectPolicy = field(default_factory=ReconnectPolicy) reconnect: ReconnectPolicy = field(default_factory=ReconnectPolicy)
verbose: bool = False verbose: bool = False
# Optional discovery filters (used by list/open helpers, not open itself). # Optional discovery filters (used by list/open helpers, not open itself).
name_include: Optional[Sequence[str]] = None name_include: Sequence[str] | None = None
name_exclude: Optional[Sequence[str]] = None name_exclude: Sequence[str] | None = None
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None vid_pid_allowlist: Sequence[tuple[int, int]] | None = None
min_width: Optional[int] = None min_width: int | None = None
min_height: Optional[int] = None min_height: int | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not isinstance(self.reconnect, ReconnectPolicy): if not isinstance(self.reconnect, ReconnectPolicy):
@@ -86,10 +87,10 @@ class CaptureConfig:
source: SourceType, source: SourceType,
camera: dict[str, Any], camera: dict[str, Any],
*, *,
reconnect: Optional[ReconnectPolicy] = None, reconnect: ReconnectPolicy | None = None,
verbose: bool = False, verbose: bool = False,
**kwargs: Any, **kwargs: Any,
) -> "CaptureConfig": ) -> CaptureConfig:
"""Build config from a sensor JSON ``camera`` section.""" """Build config from a sensor JSON ``camera`` section."""
# Accept deprecated queue/worker kwargs from older callers without error. # Accept deprecated queue/worker kwargs from older callers without error.
kwargs.pop("mode", None) kwargs.pop("mode", None)
@@ -117,9 +118,9 @@ class CaptureStatus:
"""Snapshot of capture health for diagnostics / multi-camera UIs.""" """Snapshot of capture health for diagnostics / multi-camera UIs."""
health: CaptureHealth = CaptureHealth.CLOSED health: CaptureHealth = CaptureHealth.CLOSED
source: Optional[SourceType] = None source: SourceType | None = None
last_error: Optional[str] = None last_error: str | None = None
last_frame_time: Optional[float] = None last_frame_time: float | None = None
last_seq: int = 0 last_seq: int = 0
dropped_frames: int = 0 dropped_frames: int = 0
reconnect_attempts: int = 0 reconnect_attempts: int = 0
+46 -24
View File
@@ -3,21 +3,24 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import logging
import os import os
import re import re
import sys import sys
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Iterable, Optional, Sequence, Tuple
import cv2 import cv2
from .config import MAX_SIMULTANEOUS_CAPTURES from .config import MAX_SIMULTANEOUS_CAPTURES
from .source import _backend_candidates from .source import _backend_candidates
logger = logging.getLogger(__name__)
DEFAULT_MAX_CAMERA_INDEX = MAX_SIMULTANEOUS_CAPTURES DEFAULT_MAX_CAMERA_INDEX = MAX_SIMULTANEOUS_CAPTURES
# Common built-in laptop webcam name fragments (case-insensitive). # Common built-in laptop webcam name fragments (case-insensitive).
DEFAULT_LAPTOP_WEBCAM_PATTERNS: Tuple[str, ...] = ( DEFAULT_LAPTOP_WEBCAM_PATTERNS: tuple[str, ...] = (
r"integrated", r"integrated",
r"built[- ]?in", r"built[- ]?in",
r"laptop", r"laptop",
@@ -49,8 +52,8 @@ class CameraInfo:
height: int height: int
name: str = "" name: str = ""
path: str = "" path: str = ""
vendor_id: Optional[int] = None vendor_id: int | None = None
product_id: Optional[int] = None product_id: int | None = None
bus: str = "" bus: str = ""
@property @property
@@ -64,7 +67,7 @@ class CameraInfo:
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]" return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
@property @property
def vid_pid(self) -> Optional[Tuple[int, int]]: def vid_pid(self) -> tuple[int, int] | None:
if self.vendor_id is None or self.product_id is None: if self.vendor_id is None or self.product_id is None:
return None return None
return (int(self.vendor_id), int(self.product_id)) return (int(self.vendor_id), int(self.product_id))
@@ -74,13 +77,13 @@ class CameraInfo:
class CameraFilter: class CameraFilter:
"""Optional filters for ``list_cameras`` / ``pick_default_camera``.""" """Optional filters for ``list_cameras`` / ``pick_default_camera``."""
name_include: Optional[Sequence[str]] = None name_include: Sequence[str] | None = None
name_exclude: Optional[Sequence[str]] = None name_exclude: Sequence[str] | None = None
exclude_laptop_webcams: bool = False exclude_laptop_webcams: bool = False
laptop_patterns: Sequence[str] = field(default_factory=lambda: DEFAULT_LAPTOP_WEBCAM_PATTERNS) laptop_patterns: Sequence[str] = field(default_factory=lambda: DEFAULT_LAPTOP_WEBCAM_PATTERNS)
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None vid_pid_allowlist: Sequence[tuple[int, int]] | None = None
min_width: Optional[int] = None min_width: int | None = None
min_height: Optional[int] = None min_height: int | None = None
prefer_named: bool = True prefer_named: bool = True
def matches(self, cam: CameraInfo) -> bool: def matches(self, cam: CameraInfo) -> bool:
@@ -143,8 +146,15 @@ def _suppress_opencv_noise():
if set_level is not None and prev_level is not None: if set_level is not None and prev_level is not None:
try: try:
set_level(prev_level) set_level(prev_level)
except Exception: except Exception as exc:
pass # Restoring OpenCV's log level is best-effort, but a silent failure
# here leaves probing noise suppressed for the rest of the process,
# which hides exactly the diagnostics a failed enumeration needs.
logger.warning(
"capture discovery: could not restore OpenCV log level %r: %s",
prev_level,
exc,
)
def _linux_device_metadata(index: int) -> dict: def _linux_device_metadata(index: int) -> dict:
@@ -152,7 +162,7 @@ def _linux_device_metadata(index: int) -> dict:
meta = {"name": "", "path": f"/dev/video{index}", "vendor_id": None, "product_id": None, "bus": ""} meta = {"name": "", "path": f"/dev/video{index}", "vendor_id": None, "product_id": None, "bus": ""}
name_path = f"/sys/class/video4linux/video{index}/name" name_path = f"/sys/class/video4linux/video{index}/name"
try: try:
with open(name_path, "r", encoding="utf-8", errors="ignore") as fh: with open(name_path, encoding="utf-8", errors="ignore") as fh:
meta["name"] = fh.read().strip() meta["name"] = fh.read().strip()
except OSError: except OSError:
pass pass
@@ -165,9 +175,9 @@ def _linux_device_metadata(index: int) -> dict:
vend = os.path.join(cur, "idVendor") vend = os.path.join(cur, "idVendor")
prod = os.path.join(cur, "idProduct") prod = os.path.join(cur, "idProduct")
if os.path.isfile(vend) and os.path.isfile(prod): if os.path.isfile(vend) and os.path.isfile(prod):
with open(vend, "r", encoding="utf-8") as fh: with open(vend, encoding="utf-8") as fh:
meta["vendor_id"] = int(fh.read().strip(), 16) meta["vendor_id"] = int(fh.read().strip(), 16)
with open(prod, "r", encoding="utf-8") as fh: with open(prod, encoding="utf-8") as fh:
meta["product_id"] = int(fh.read().strip(), 16) meta["product_id"] = int(fh.read().strip(), 16)
meta["bus"] = "usb" meta["bus"] = "usb"
break break
@@ -229,7 +239,7 @@ def _windows_device_names() -> dict[int, dict]:
return {} return {}
def _device_metadata(index: int, windows_names: Optional[dict[int, dict]] = None) -> dict: def _device_metadata(index: int, windows_names: dict[int, dict] | None = None) -> dict:
if sys.platform.startswith("linux"): if sys.platform.startswith("linux"):
return _linux_device_metadata(index) return _linux_device_metadata(index)
if sys.platform == "win32" and windows_names is not None: if sys.platform == "win32" and windows_names is not None:
@@ -238,7 +248,7 @@ def _device_metadata(index: int, windows_names: Optional[dict[int, dict]] = None
return {"name": "", "path": "", "vendor_id": None, "product_id": None, "bus": ""} return {"name": "", "path": "", "vendor_id": None, "product_id": None, "bus": ""}
def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> Optional[CameraInfo]: def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> CameraInfo | None:
cap = cv2.VideoCapture(index, backend_api) cap = cv2.VideoCapture(index, backend_api)
if not cap.isOpened(): if not cap.isOpened():
cap.release() cap.release()
@@ -272,13 +282,13 @@ def _try_open(index: int, backend_name: str, backend_api: int, meta: dict) -> Op
def list_cameras( def list_cameras(
max_index: int = DEFAULT_MAX_CAMERA_INDEX, max_index: int = DEFAULT_MAX_CAMERA_INDEX,
*, *,
camera_filter: Optional[CameraFilter] = None, camera_filter: CameraFilter | None = None,
exclude_laptop_webcams: bool = False, exclude_laptop_webcams: bool = False,
name_include: Optional[Sequence[str]] = None, name_include: Sequence[str] | None = None,
name_exclude: Optional[Sequence[str]] = None, name_exclude: Sequence[str] | None = None,
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None, vid_pid_allowlist: Sequence[tuple[int, int]] | None = None,
min_width: Optional[int] = None, min_width: int | None = None,
min_height: Optional[int] = None, min_height: int | None = None,
backend: str = "auto", backend: str = "auto",
) -> list[CameraInfo]: ) -> list[CameraInfo]:
"""Probe indices ``0 .. max_index`` and return working cameras. """Probe indices ``0 .. max_index`` and return working cameras.
@@ -302,10 +312,22 @@ def list_cameras(
windows_names = _windows_device_names() if sys.platform == "win32" else None windows_names = _windows_device_names() if sys.platform == "win32" else None
with _suppress_opencv_noise(): with _suppress_opencv_noise():
for index in range(0, max_index + 1): for index in range(max_index + 1):
meta = _device_metadata(index, windows_names) meta = _device_metadata(index, windows_names)
for backend_name, backend_api in backends: for backend_name, backend_api in backends:
try:
info = _try_open(index, backend_name, backend_api, meta) info = _try_open(index, backend_name, backend_api, meta)
except Exception as exc:
# One bad backend/device must not abort the whole probe: an
# escaping error here made every *other* camera invisible, not
# just this index. Skip the candidate and keep enumerating.
logger.warning(
"capture discovery: probing index %d via %s failed: %s",
index,
backend_name,
exc,
)
continue
if info is None: if info is None:
continue continue
if index in seen_indices: if index in seen_indices:
+127 -50
View File
@@ -2,11 +2,14 @@
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import sys import sys
import threading import threading
import time import time
from typing import Any, Optional, Protocol, runtime_checkable from dataclasses import dataclass
from math import isfinite
from typing import Any, Protocol, runtime_checkable
import cv2 import cv2
import numpy as np import numpy as np
@@ -19,6 +22,23 @@ from .config import (
SourceType, 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 @runtime_checkable
class VideoCaptureLike(Protocol): class VideoCaptureLike(Protocol):
@@ -83,7 +103,7 @@ def _backend_candidates(preferred: str = "auto") -> list[tuple[str, int]]:
return candidates return candidates
def _fourcc_code(fourcc: Optional[str]) -> Optional[int]: def _fourcc_code(fourcc: str | None) -> int | None:
if not fourcc: if not fourcc:
return None return None
text = str(fourcc).strip() text = str(fourcc).strip()
@@ -111,10 +131,10 @@ def _read_stream_meta(cap: VideoCaptureLike) -> tuple[int, int, float, str]:
def _apply_stream_props( def _apply_stream_props(
cap: VideoCaptureLike, cap: VideoCaptureLike,
*, *,
width: Optional[int], width: int | None,
height: Optional[int], height: int | None,
fps: Optional[float], fps: float | None,
fourcc: Optional[str], fourcc: str | None,
) -> None: ) -> None:
"""Apply live-camera stream properties (width/height before fourcc on DSHOW).""" """Apply live-camera stream properties (width/height before fourcc on DSHOW)."""
code = _fourcc_code(fourcc) code = _fourcc_code(fourcc)
@@ -143,10 +163,10 @@ def open_opencv_capture(
source: SourceType, source: SourceType,
*, *,
backend: str = "auto", backend: str = "auto",
width: Optional[int] = None, width: int | None = None,
height: Optional[int] = None, height: int | None = None,
fps: Optional[float] = None, fps: float | None = None,
fourcc: Optional[str] = "MJPG", fourcc: str | None = "MJPG",
apply_stream_props: bool = True, apply_stream_props: bool = True,
) -> tuple[VideoCaptureLike, str]: ) -> tuple[VideoCaptureLike, str]:
"""Open a ``cv2.VideoCapture`` trying preferred backends in order. """Open a ``cv2.VideoCapture`` trying preferred backends in order.
@@ -212,16 +232,19 @@ class CaptureSource:
): ):
self.config = config self.config = config
self._factory = factory or self._default_factory self._factory = factory or self._default_factory
self._cap: Optional[VideoCaptureLike] = None self._cap: VideoCaptureLike | None = None
self._backend_name = "" self._backend_name = ""
self._status = CaptureStatus(source=normalize_source(config.source)) self._status = CaptureStatus(source=normalize_source(config.source))
self._reconnect_attempt = 0 self._reconnect_attempt = 0
self._lock = threading.Lock() self._lock = threading.Lock()
self._frame_lock = threading.Lock() self._frame_lock = threading.Lock()
self._frame_cond = threading.Condition(self._frame_lock) self._frame_cond = threading.Condition(self._frame_lock)
self._latest_frame: Optional[np.ndarray] = None self._latest_frame: np.ndarray | None = None
self._latest_captured: CapturedFrame | None = None
self._latest_seq = 0 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 self._background_running = False
@staticmethod @staticmethod
@@ -238,7 +261,7 @@ class CaptureSource:
# Compatibility with older CaptureWorker API. # Compatibility with older CaptureWorker API.
@property @property
def source(self) -> "CaptureSource": def source(self) -> CaptureSource:
return self return self
@property @property
@@ -265,7 +288,7 @@ class CaptureSource:
) )
@property @property
def cap(self) -> Optional[VideoCaptureLike]: def cap(self) -> VideoCaptureLike | None:
"""Raw capture handle (compat with Sensor.cap).""" """Raw capture handle (compat with Sensor.cap)."""
return self._cap return self._cap
@@ -324,11 +347,18 @@ class CaptureSource:
if self._cap is not None: if self._cap is not None:
try: try:
self._cap.release() self._cap.release()
except Exception: except Exception as exc:
pass # A failed release is not fatal, but it must not be invisible: the
# handle below is dropped either way, so a live-camera handle that
# OpenCV refused to free would otherwise leak silently until the
# process exits. Report it and keep tearing down.
logger.warning(
"capture: %r.release() failed: %s", self._backend_name or "capture", exc
)
self._cap = None self._cap = None
with self._frame_cond: with self._frame_cond:
self._latest_frame = None self._latest_frame = None
self._latest_captured = None
self._latest_seq = 0 self._latest_seq = 0
def _refresh_stream_meta_unlocked(self) -> None: def _refresh_stream_meta_unlocked(self) -> None:
@@ -341,8 +371,11 @@ class CaptureSource:
self._status.height = height self._status.height = height
self._status.fps = fps self._status.fps = fps
self._status.fourcc = fourcc self._status.fourcc = fourcc
except Exception: except Exception as exc:
pass # Metadata is advisory: a backend that cannot answer these get() calls
# still delivers frames, so do not fail the open. Record why the
# reported stream geometry is left at its previous/default values.
logger.warning("capture: could not read stream metadata: %s", exc)
def warmup_frames(self, count: int = 3) -> None: def warmup_frames(self, count: int = 3) -> None:
"""Discard frames so the driver/format stabilizes (sync reads only).""" """Discard frames so the driver/format stabilizes (sync reads only)."""
@@ -351,53 +384,92 @@ class CaptureSource:
for _ in range(max(0, int(count))): for _ in range(max(0, int(count))):
self.read_raw() self.read_raw()
def read_raw(self) -> tuple[bool, Optional[np.ndarray]]: def read_raw_stamped(self) -> CapturedFrame | None:
"""Read one frame directly from the underlying capture (no reconnect).""" """Read a frame and bind its identity and clocks before another read."""
cap = self._cap cap = self._cap
if cap is None: if cap is None:
return False, None return None
try: try:
ok, frame = cap.read() ok, frame = cap.read()
except Exception as exc: except Exception as exc:
with self._lock: with self._lock:
self._status.consecutive_failures += 1 self._status.consecutive_failures += 1
self._status.last_error = str(exc) self._status.last_error = str(exc)
return False, None return None
if not ok or frame is None: if not ok or frame is None:
with self._lock: with self._lock:
self._status.consecutive_failures += 1 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: with self._lock:
self._read_seq += 1
sequence = self._read_seq
self._status.consecutive_failures = 0 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: if self._status.health != CaptureHealth.CONNECTED:
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( def read_latest(
self, self,
*, *,
copy: bool = True, copy: bool = True,
wait_s: float = 0.0, wait_s: float = 0.0,
) -> tuple[bool, Optional[np.ndarray]]: ) -> tuple[bool, np.ndarray | None]:
"""Return the newest frame from the background thread, or read sync.""" """Return the newest frame from the background thread, or read sync."""
if not self._background_running: captured = self.read_latest_stamped(copy=copy, wait_s=wait_s)
ok, frame = self.read_raw() return (captured is not None, None if captured is None else captured.image)
if ok and copy and frame is not None:
return ok, frame.copy()
return ok, frame
deadline = time.perf_counter() + max(0.0, wait_s) def read(self, *, copy: bool = True, wait_s: float = 1.0) -> tuple[bool, np.ndarray | None]:
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]]:
"""Compat alias used by finger runtime and older worker callers.""" """Compat alias used by finger runtime and older worker callers."""
wait = wait_s if wait_s > 0 else 1.0 wait = wait_s if wait_s > 0 else 1.0
return self.read_latest(copy=copy, wait_s=wait) return self.read_latest(copy=copy, wait_s=wait)
@@ -429,6 +501,7 @@ class CaptureSource:
self._thread = None self._thread = None
with self._frame_cond: with self._frame_cond:
self._latest_frame = None self._latest_frame = None
self._latest_captured = None
def stop(self, timeout: float = 1.0) -> None: def stop(self, timeout: float = 1.0) -> None:
"""Compat alias for ``stop_background``.""" """Compat alias for ``stop_background``."""
@@ -443,19 +516,23 @@ class CaptureSource:
time.sleep(0.2) time.sleep(0.2)
continue continue
ok, frame = self.read_raw() captured = self.read_raw_stamped()
if not ok or frame is None: if captured is None:
if self.should_reconnect(): if self.should_reconnect():
continue continue
time.sleep(0.001) time.sleep(0.001)
continue continue
with self._frame_cond: 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: with self._lock:
self._status.dropped_frames += 1 self._status.dropped_frames += 1
self._latest_seq += 1 self._latest_seq = captured.sequence
self._latest_frame = frame self._latest_frame = captured.image
self._latest_captured = captured
with self._lock: with self._lock:
self._status.last_seq = self._latest_seq self._status.last_seq = self._latest_seq
self._frame_cond.notify_all() self._frame_cond.notify_all()
@@ -531,8 +608,8 @@ def open_capture(
config: CaptureConfig, config: CaptureConfig,
*, *,
factory=None, factory=None,
start_background: Optional[bool] = None, start_background: bool | None = None,
start_worker: Optional[bool] = None, start_worker: bool | None = None,
) -> CaptureSource: ) -> CaptureSource:
"""Open a capture source and optionally start its background thread. """Open a capture source and optionally start its background thread.
+2 -2
View File
@@ -191,7 +191,7 @@ def _config_files() -> list[Path]:
def _load_json(path: Path) -> dict: def _load_json(path: Path) -> dict:
with open(path, "r", encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
return json.load(f) return json.load(f)
@@ -433,7 +433,7 @@ def load_config(config_name_or_path):
for candidate in _config_files(): for candidate in _config_files():
try: try:
data = _load_json(candidate) data = _load_json(candidate)
except Exception: except Exception: # noqa: S112 - skip unreadable config during alias scan
continue continue
if data.get("name") in candidates: if data.get("name") in candidates:
return data return data
+3 -3
View File
@@ -42,8 +42,8 @@ from __future__ import annotations
import os import os
import queue import queue
import threading import threading
import time from collections.abc import Callable, Iterable, Sequence
from typing import Any, Callable, Iterable, Sequence from typing import Any
import cv2 import cv2
import numpy as np import numpy as np
@@ -243,7 +243,7 @@ class StreamRecorder:
"fourcc": self.fourcc, "fourcc": self.fourcc,
} }
def __enter__(self) -> "StreamRecorder": def __enter__(self) -> StreamRecorder:
return self return self
def __exit__(self, exc_type, exc, tb) -> bool: def __exit__(self, exc_type, exc, tb) -> bool:
+2 -2
View File
@@ -18,8 +18,8 @@ from .mesh import (
lift_uv_deformation_to_3d, lift_uv_deformation_to_3d,
lift_uv_flow_to_3d, lift_uv_flow_to_3d,
mesh_uv_to_world_scale, mesh_uv_to_world_scale,
scalar_map_vertex_colors,
sample_field_at_mesh_vertices, sample_field_at_mesh_vertices,
scalar_map_vertex_colors,
) )
from .runtime import ( from .runtime import (
FingerGeometry, FingerGeometry,
@@ -39,8 +39,8 @@ __all__ = [
"FingerGeometry", "FingerGeometry",
"FingerRuntime", "FingerRuntime",
"TactileProcessor", "TactileProcessor",
"UVSurfaceMesh",
"UVMeshViewer", "UVMeshViewer",
"UVSurfaceMesh",
"build_uv_surface_mesh", "build_uv_surface_mesh",
"compute_uv_surface_frames", "compute_uv_surface_frames",
"deform_mesh_vertices", "deform_mesh_vertices",
+8 -8
View File
@@ -12,15 +12,15 @@ class UVSurfaceMesh:
"""Triangle mesh built from a UV flatten grid and world_xyz surface map.""" """Triangle mesh built from a UV flatten grid and world_xyz surface map."""
__slots__ = ( __slots__ = (
"rest_vertices",
"triangles",
"vertex_ys",
"vertex_xs",
"index_map",
"valid_mask",
"shape",
"world_xyz",
"base_colors", "base_colors",
"index_map",
"rest_vertices",
"shape",
"triangles",
"valid_mask",
"vertex_xs",
"vertex_ys",
"world_xyz",
) )
def __init__( def __init__(
+98 -2
View File
@@ -101,6 +101,21 @@ class TactileProcessor:
def valid_mask(self): def valid_mask(self):
return self._sensor.valid_mask return self._sensor.valid_mask
@property
def force_units(self) -> tuple[str, str, str]:
return self._sensor.force_units
@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): def push_frame(self, frame, *, already_gray=False, valid_mask=None):
"""Feed one processed tactile frame into the analysis pipeline.""" """Feed one processed tactile frame into the analysis pipeline."""
mask = self._default_valid_mask if valid_mask is None else valid_mask mask = self._default_valid_mask if valid_mask is None else valid_mask
@@ -114,6 +129,21 @@ class TactileProcessor:
def warmup(self): def warmup(self):
return self._sensor.warmup_deformation() return self._sensor.warmup_deformation()
def pipeline(self, produce, **kwargs):
"""Start a :class:`~orisys.pipeline.StreamPipeline` over this processor's own Sensor.
``produce`` is the frame source: a zero-argument callable returning the next unrolled
frame, or ``None`` at end of input. :class:`FingerRuntime` supplies it through
:meth:`FingerRuntime.pipeline`, so call this directly only when you are producing frames
yourself.
Any keyword argument is forwarded to ``StreamPipeline``, so ``contact=True`` and
``include=(...)`` work exactly as they do there.
"""
from orisys.pipeline import StreamPipeline
return StreamPipeline(self._sensor, produce, **kwargs)
def compute_deformation(self, *args, **kwargs): def compute_deformation(self, *args, **kwargs):
return self._sensor.compute_deformation(*args, **kwargs) return self._sensor.compute_deformation(*args, **kwargs)
@@ -239,6 +269,9 @@ class FingerRuntime:
map_cache=DEFAULT_FINGER_MAP_CACHE, map_cache=DEFAULT_FINGER_MAP_CACHE,
source_scale=None, source_scale=None,
calib=None, calib=None,
cal_path=None,
calibration_dir=None,
device_id=None,
video=0, video=0,
camera_id=None, camera_id=None,
flip_source_vertical=True, flip_source_vertical=True,
@@ -246,7 +279,7 @@ class FingerRuntime:
mirror=False, mirror=False,
auto_reconnect: bool = False, auto_reconnect: bool = False,
capture_mode: str = "latest", capture_mode: str = "latest",
) -> "FingerRuntime": ) -> FingerRuntime:
"""Open a live finger runtime from export bundle, config, and video source.""" """Open a live finger runtime from export bundle, config, and video source."""
del capture_mode # deprecated; latest-frame capture only del capture_mode # deprecated; latest-frame capture only
if camera_id is not None: if camera_id is not None:
@@ -278,6 +311,32 @@ class FingerRuntime:
) )
domain.remap.ensure_fast_maps() domain.remap.ensure_fast_maps()
sensor = Sensor(
"external", config_name=config, cal_path=cal_path,
calibration_dir=calibration_dir, device_id=device_id,
verbose=verbose, mirror=mirror,
)
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( capture_config = CaptureConfig.from_camera_dict(
video, video,
geometry.config.get("camera", {}), geometry.config.get("camera", {}),
@@ -292,7 +351,6 @@ class FingerRuntime:
capture_source.start_background() capture_source.start_background()
capture = capture_source.cap capture = capture_source.cap
sensor = Sensor("external", config_name=config, verbose=verbose, mirror=mirror)
if not is_live_camera(video): if not is_live_camera(video):
# File playback: wall-clock cadence is not signal time, so pin the # File playback: wall-clock cadence is not signal time, so pin the
# force-filter design rate to the configured camera fps. # force-filter design rate to the configured camera fps.
@@ -324,6 +382,15 @@ class FingerRuntime:
def valid_mask(self): def valid_mask(self):
return self.frame_processor.valid_mask 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): def read_source(self):
if self.capture_source is not None and self.capture_source.running: if self.capture_source is not None and self.capture_source.running:
return self.capture_source.read(copy=True, wait_s=1.0) return self.capture_source.read(copy=True, wait_s=1.0)
@@ -331,6 +398,14 @@ class FingerRuntime:
return self.capture_source.read_raw() return self.capture_source.read_raw()
return self.capture.read() 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): def process_frame(self, frame, *, color=False):
"""Prepare, unroll, and feed one source frame into the tactile processor.""" """Prepare, unroll, and feed one source frame into the tactile processor."""
if color: if color:
@@ -361,6 +436,27 @@ class FingerRuntime:
self.tactile.warmup() self.tactile.warmup()
return ok, unrolled return ok, unrolled
def pipeline(self, **kwargs):
"""Return a :class:`~orisys.pipeline.StreamPipeline` for this runtime.
The recommended way to run a finger sensor. Each finished frame arrives as a single
:class:`~orisys.pipeline.FrameSnapshot`, so there are no stage calls to sequence and no
tuple to unpack::
with runtime.pipeline(contact=True, include=("image", "depth")) as pipe:
while True:
snap = pipe.wait()
if snap is None:
break
print(snap.normal_force, snap.shear_x, snap.shear_y)
Any keyword argument is forwarded to ``StreamPipeline``. Use ``contact=True`` to run
contact analysis, and ``include=(...)`` to select which arrays each snapshot carries.
"""
from orisys.pipeline import StreamPipeline
return StreamPipeline.for_finger_runtime(self, **kwargs)
def close(self): def close(self):
if self.capture_source is not None: if self.capture_source is not None:
self.capture_source.close() self.capture_source.close()
+394
View File
@@ -0,0 +1,394 @@
"""Force readout for FN/FX/FY: EMA, median, low-pass, and optional convert.
Any sensor that produces a raw (fn, fx, fy) triple can use this. Calibration
coefficients stay outside: pass ``convert`` into :meth:`ForceReadout.apply`.
"""
from __future__ import annotations
from collections.abc import Callable
import numpy as np
from .signal_processing import (
DEFAULT_SAMPLE_RATE_HZ,
CausalMedianFilter,
LowpassFilter,
SampleRateEstimator,
)
# Default signal-conditioning settings (config: top-level "filters" block).
#
# A key a config does not state is **off**, not "whatever we thought was
# sensible": the file has to name what runs, or a device can be filtering a
# measurement nobody asked it to filter and no reader of the file could tell.
# The shipped bundles state all three values explicitly, so they are unaffected
# by these defaults; a config with no "filters" block at all filters nothing.
#
# Which settings are worth asking for is measured, not guessed. On dd02-0703 at
# 280x300 (see the noise analysis in the perf notes) flow EMA 0.5 + median 3
# costs ~2 frames at 30 fps and cuts steady-press force jitter by roughly half
# while also stabilising vnormal/contact (which a force-level filter cannot do);
# that combination is the "balanced" preset. The force low-pass stays off there
# too: on top of the EMA it added no measurable noise benefit at the same
# latency, so it is an opt-in stage for sites with different noise
# characteristics (the "low_noise" preset).
DEFAULT_FORCE_LOWPASS_HZ = 0.0
DEFAULT_FORCE_MEDIAN_WINDOW = 1
DEFAULT_FLOW_EMA_ALPHA = 1.0
#: Named filter presets, so a recommendation is *executable* instead of a
#: comment in a JSON file (``json.load`` cannot carry comments). A config may
#: write ``"filters": {"preset": "balanced"}`` and override any single key on
#: top of it; the resolved values are what ``Sensor.filters_state()`` reports.
#: The latency/noise trade-offs behind these numbers are measured in
#: ``docs/performance/perf-measurements/`` (moved out of the once-gitignored
#: ``orisys-sdk/_perf/`` on 2026-09-27) and tabulated in ``docs/filters.md``.
FILTER_PRESETS = {
# Everything off: the file says what runs, and the raw reading is the record.
"off": {
"flow_ema_alpha": 1.0,
"force_median_window": 1,
"force_lowpass_cutoff_hz": 0.0,
},
# One frame of latency @30 fps: median only. The EMA changes the
# measurement, so it stays off when latency matters more than jitter.
"low_latency": {
"flow_ema_alpha": 1.0,
"force_median_window": 3,
"force_lowpass_cutoff_hz": 0.0,
},
# Two frames @30 fps: the EMA removes the spatially-coherent flow jitter
# that no force-level filter can reach, the median removes its spikes.
"balanced": {
"flow_ema_alpha": 0.5,
"force_median_window": 3,
"force_lowpass_cutoff_hz": 0.0,
},
# Three frames @30 fps and the quietest readout: as ``balanced`` plus the
# force low-pass on top of the median.
"low_noise": {
"flow_ema_alpha": 0.3,
"force_median_window": 3,
"force_lowpass_cutoff_hz": 10.0,
},
}
def available_filter_presets() -> dict:
"""The presets, for UIs that want to offer them (the studio's gear dialog)."""
return {name: dict(values) for name, values in FILTER_PRESETS.items()}
ConvertFn = Callable[[float, float, float], tuple[float, float, float]]
class ForceReadout:
"""Median → low-pass → convert for a three-channel force triple."""
def __init__(
self,
filters_cfg: dict | None = None,
*,
config_fps: float = DEFAULT_SAMPLE_RATE_HZ,
verbose: bool = False,
) -> None:
# These settings change the reported force values, so they are part of
# the measurement-pipeline identity and are recorded in the calibration
# metadata / mismatch check alongside dis_mode and hhd_downsample.
#
# Every stage is switched by its **own value**, so the config states what
# runs: ``flow_ema_alpha >= 1``, ``force_median_window <= 1`` and
# ``force_lowpass_cutoff_hz <= 0`` each mean "stage off". Values are never
# overridden: there is no master switch that could make the file say 0.5
# while the device runs 1.0. An invalid value falls back to **off**, never
# to a default that filters. ``enabled`` is kept only as a deprecated
# alias for "turn every stage off" and is honoured when it is explicitly
# false. ``state()`` reports what actually took effect.
if not isinstance(filters_cfg, dict):
filters_cfg = {}
legacy_off = filters_cfg.get("enabled", None) is False
preset_name = filters_cfg.get("preset", None)
preset_values = FILTER_PRESETS.get(str(preset_name), {}) if preset_name else {}
self.preset = str(preset_name) if preset_name in FILTER_PRESETS else None
def _cfg(key, default):
if legacy_off:
return default
value = filters_cfg.get(key, None)
if value is None:
value = preset_values.get(key, None)
return default if value is None else value
try:
self.config_fps = float(config_fps or 0.0)
except (TypeError, ValueError):
self.config_fps = 0.0
if self.config_fps <= 0:
self.config_fps = float(DEFAULT_SAMPLE_RATE_HZ)
self.lowpass_order = 2
self.lowpass_sample_rate_hz = self.config_fps
self.lowpass_cutoff_hz = 0.0
self.lowpass_rate_auto = True
try:
self.lowpass_order = max(1, int(_cfg("force_lowpass_order", 2)))
except (TypeError, ValueError):
self.lowpass_order = 2
rate_cfg = _cfg("force_lowpass_sample_rate_hz", "auto")
if isinstance(rate_cfg, str):
self.lowpass_rate_auto = rate_cfg.strip().lower() in ("auto", "", "measured")
else:
try:
rate = float(rate_cfg)
except (TypeError, ValueError):
rate = 0.0
self.lowpass_rate_auto = rate <= 0.0
if not self.lowpass_rate_auto:
self.lowpass_sample_rate_hz = rate
try:
self.lowpass_cutoff_hz = float(
_cfg("force_lowpass_cutoff_hz", DEFAULT_FORCE_LOWPASS_HZ)
)
except (TypeError, ValueError):
self.lowpass_cutoff_hz = 0.0
if self.lowpass_sample_rate_hz <= 0:
self.lowpass_sample_rate_hz = self.config_fps
# An unusable cutoff is "off", never a silent jump to the default.
if not (0.0 <= self.lowpass_cutoff_hz < 0.5 * self.lowpass_sample_rate_hz):
self.lowpass_cutoff_hz = 0.0
self.lowpass_enabled = self.lowpass_cutoff_hz > 0
self._force_filter = LowpassFilter(
cutoff_hz=self.lowpass_cutoff_hz,
sample_rate_hz=self.lowpass_sample_rate_hz,
order=self.lowpass_order,
n_channels=3,
)
self._force_rate = SampleRateEstimator(initial_hz=self.config_fps)
self.sample_rate_source = "config"
try:
self.force_median_window = max(
1, int(_cfg("force_median_window", DEFAULT_FORCE_MEDIAN_WINDOW))
)
except (TypeError, ValueError):
self.force_median_window = 1
self._force_median = CausalMedianFilter(
window=self.force_median_window, n_channels=3
)
self.flow_ema_alpha = 1.0
try:
alpha = float(_cfg("flow_ema_alpha", DEFAULT_FLOW_EMA_ALPHA))
except (TypeError, ValueError):
alpha = 1.0
if 0.0 < alpha < 1.0:
self.flow_ema_alpha = alpha
self._ema_flow = None
if legacy_off and verbose:
print(
"Note: filters.enabled is deprecated and means 'all stages off'; "
"remove it and switch each stage by its own value "
"(flow_ema_alpha=1, force_median_window=1, force_lowpass_cutoff_hz=0)."
)
self.any_stage_applied = bool(
self.flow_ema_alpha < 1.0
or self.force_median_window > 1
or self.lowpass_enabled
)
self.filtered_raw: tuple[float, float, float] | None = None
self.raw_unfiltered: tuple[float, float, float] | None = None
self.unfiltered_newton: tuple[float, float, float] | None = None
@property
def sample_rate_hz(self) -> float:
return float(self.lowpass_sample_rate_hz)
def pin_file_rate(self) -> None:
"""Wall-clock cadence is not signal time (video file / offline replay)."""
self.lowpass_rate_auto = False
self.sample_rate_source = "config (file)"
self.lowpass_sample_rate_hz = self.config_fps
self._force_rate.reset(self.config_fps)
def apply_flow_ema(self, flow):
"""Temporally smooth the optical-flow field before HHD.
The frame-to-frame jitter of the estimated flow is spatially coherent
(a near-global shift: it survives ~40x less averaging than per-pixel
noise would, see the noise analysis), so it cannot be removed by
spatial smoothing, a coarser HHD grid or a different DIS setting - only
by averaging along time. Applying it here also stabilises
``vnormal``/contact, which a force-level filter cannot reach.
``flow_ema_alpha >= 1`` disables the stage.
"""
alpha = self.flow_ema_alpha
if flow is None or alpha >= 1.0:
return flow
prev = self._ema_flow
if prev is None or prev.shape != flow.shape or prev.dtype != flow.dtype:
self._ema_flow = np.array(flow, copy=True)
return flow
prev += alpha * (flow - prev)
return prev
def update_sample_rate(self) -> None:
"""Track the achieved force update rate for the low-pass design."""
if not self.lowpass_rate_auto:
return
hz = self._force_rate.tick()
if not (1.0 <= hz <= 240.0):
return
self.sample_rate_source = "measured"
used = self.lowpass_sample_rate_hz
if used > 0 and abs(hz - used) <= 0.1 * used:
return
self.lowpass_sample_rate_hz = hz
if self.lowpass_enabled:
self._force_filter.configure(
cutoff_hz=self.lowpass_cutoff_hz,
sample_rate_hz=hz,
order=self.lowpass_order,
)
def apply(
self,
raw,
convert: ConvertFn | None = None,
) -> tuple[float, float, float]:
"""Median → low-pass → optional convert. Returns filtered newton triple."""
pre_filter = np.asarray(raw, dtype=np.float64)
values = pre_filter
# Median first: it deletes the heavy-tailed spikes that the flow chain
# produces, so the low-pass does not have to spread them over time.
if self._force_median.enabled:
values = self._force_median.process(values)
if self.lowpass_enabled and self.lowpass_cutoff_hz > 0:
values = self._force_filter.process(values)
self.filtered_raw = (float(values[0]), float(values[1]), float(values[2]))
fn, fx, fy = self.filtered_raw
if convert is not None:
fn, fx, fy = convert(fn, fx, fy)
fn, fx, fy = float(fn), float(fx), float(fy)
ufn, ufx, ufy = (
float(pre_filter[0]),
float(pre_filter[1]),
float(pre_filter[2]),
)
self.raw_unfiltered = (ufn, ufx, ufy)
if convert is not None:
ufn, ufx, ufy = convert(ufn, ufx, ufy)
ufn, ufx, ufy = float(ufn), float(ufx), float(ufy)
self.unfiltered_newton = (ufn, ufx, ufy)
return fn, fx, fy
def configure(
self,
*,
lowpass_cutoff_hz=None,
lowpass_sample_rate_hz=None,
lowpass_order=None,
lowpass_enabled=None,
flow_ema_alpha=None,
force_median_window=None,
) -> bool:
"""Update filter settings. Returns True when the force filter changed."""
filter_changed = False
if lowpass_cutoff_hz is not None:
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
filter_changed = True
if lowpass_enabled is None:
self.lowpass_enabled = self.lowpass_cutoff_hz > 0
if lowpass_sample_rate_hz is not None:
# Accept a positive number to pin the rate, or "auto"/<=0 to track
# the measured output rate (see update_sample_rate).
auto = False
rate = 0.0
if isinstance(lowpass_sample_rate_hz, str):
auto = lowpass_sample_rate_hz.strip().lower() in ("auto", "", "measured")
else:
try:
rate = float(lowpass_sample_rate_hz)
except (TypeError, ValueError):
auto = True
else:
auto = rate <= 0.0
self.lowpass_rate_auto = auto
if not auto:
self.lowpass_sample_rate_hz = rate
elif self.lowpass_sample_rate_hz <= 0:
self.lowpass_sample_rate_hz = self.config_fps
self._force_rate.reset(self.config_fps)
filter_changed = True
if lowpass_order is not None:
order = int(lowpass_order)
if order < 1:
raise ValueError("lowpass_order must be >= 1")
self.lowpass_order = order
filter_changed = True
if lowpass_enabled is not None:
self.lowpass_enabled = bool(lowpass_enabled)
filter_changed = True
if flow_ema_alpha is not None:
alpha = float(flow_ema_alpha)
if not (0.0 < alpha <= 1.0):
raise ValueError("flow_ema_alpha must be in (0, 1]")
self.flow_ema_alpha = alpha
self._ema_flow = None
if force_median_window is not None:
try:
window = max(1, int(force_median_window))
except (TypeError, ValueError) as exc:
raise ValueError("force_median_window must be an integer >= 1") from exc
self.force_median_window = window
self._force_median.configure(window)
filter_changed = True
if filter_changed:
cutoff = self.lowpass_cutoff_hz if self.lowpass_enabled else 0.0
self._force_filter.configure(
cutoff_hz=cutoff,
sample_rate_hz=self.lowpass_sample_rate_hz,
order=self.lowpass_order,
)
self.any_stage_applied = bool(
self.flow_ema_alpha < 1.0
or self.force_median_window > 1
or self.lowpass_enabled
)
return filter_changed
def reset(self) -> None:
self._force_filter.reset()
self._force_median.reset()
self._ema_flow = None
self.filtered_raw = None
self.raw_unfiltered = None
self.unfiltered_newton = None
def state(self, *, dis_mode=None) -> dict:
"""Effective filter chain, for records and audits."""
return {
"preset": self.preset,
"measurement": {
"flow_ema_alpha": float(self.flow_ema_alpha),
"flow_ema_applied": bool(self.flow_ema_alpha < 1.0),
"dis_mode": dis_mode,
},
"readout": {
"force_median_window": int(self.force_median_window),
"force_median_applied": bool(self._force_median.enabled),
"force_lowpass_cutoff_hz": float(self.lowpass_cutoff_hz),
"force_lowpass_order": int(self.lowpass_order),
"force_lowpass_sample_rate_hz": float(self.lowpass_sample_rate_hz),
"force_lowpass_rate_auto": bool(self.lowpass_rate_auto),
"force_lowpass_applied": bool(self.lowpass_enabled),
"sample_rate_source": str(self.sample_rate_source),
},
"any_stage_applied": bool(self.any_stage_applied),
"unfiltered_available": True,
}
+1 -1
View File
@@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
import time import time
from pathlib import Path
import cv2 import cv2
import numpy as np import numpy as np
from pathlib import Path
from .unroll import UVRemap from .unroll import UVRemap
+2 -1
View File
@@ -1,7 +1,8 @@
import numpy as np
import cv2 import cv2
import numpy as np
from .backend_availability import CUPY_AVAILABLE, get_cupy from .backend_availability import CUPY_AVAILABLE, get_cupy
cp = get_cupy() cp = get_cupy()
# 缓存形态学操作的核,避免重复创建 # 缓存形态学操作的核,避免重复创建
_morphology_kernels = {} _morphology_kernels = {}
+1 -1
View File
@@ -20,4 +20,4 @@ from .grid import StructuredGrid
from .poisson import PoissonSolver from .poisson import PoissonSolver
from .timing import Timer from .timing import Timer
__all__ = ["nHHD", "StructuredGrid", "PoissonSolver", "Timer"] __all__ = ["PoissonSolver", "StructuredGrid", "Timer", "nHHD"]
+4 -6
View File
@@ -2,16 +2,16 @@ import numpy
from ..backend_availability import ( from ..backend_availability import (
CUPY_AVAILABLE, CUPY_AVAILABLE,
OPENCL_AVAILABLE,
KERNEL_AVAILABLE, KERNEL_AVAILABLE,
cp, OPENCL_AVAILABLE,
_torque_kernel, _torque_kernel,
cp,
) )
from .grid import StructuredGrid from .grid import StructuredGrid
from .poisson import PoissonSolver from .poisson import PoissonSolver
class nHHD(object): class nHHD:
# constructor # constructor
def __init__(self, cuda=True, **kwargs): def __init__(self, cuda=True, **kwargs):
@@ -25,9 +25,7 @@ class nHHD(object):
backend = 'cupy' if CUPY_AVAILABLE else ('opencl' if OPENCL_AVAILABLE else 'cpu') backend = 'cupy' if CUPY_AVAILABLE else ('opencl' if OPENCL_AVAILABLE else 'cpu')
# Validate backend availability and fallback to 'cpu' if needed # Validate backend availability and fallback to 'cpu' if needed
if backend == 'cupy' and not CUPY_AVAILABLE: if backend == 'cupy' and not CUPY_AVAILABLE or backend == 'opencl' and not OPENCL_AVAILABLE:
backend = 'cpu'
elif backend == 'opencl' and not OPENCL_AVAILABLE:
backend = 'cpu' backend = 'cpu'
self.debug = kwargs.get('debug', False) self.debug = kwargs.get('debug', False)
+13 -11
View File
@@ -56,7 +56,9 @@ def build_masked_factor(lap):
options=dict(SymmetricMode=True), options=dict(SymmetricMode=True),
) )
return lu.solve, numpy.float32 return lu.solve, numpy.float32
except Exception: except Exception: # noqa: S112 - try the next pivoting strategy
# Probe: try the next pivoting strategy; the float64 fallback below is
# returned if every candidate fails, so this failure is not lost.
continue continue
# Last resort: the original pivoting LU on the float64 matrix. # Last resort: the original pivoting LU on the float64 matrix.
return sparse_linalg.factorized(lap_csc), numpy.float64 return sparse_linalg.factorized(lap_csc), numpy.float64
@@ -82,20 +84,20 @@ class MaskedDomain:
"""Reduced Laplacian system on active mask pixels (2D only).""" """Reduced Laplacian system on active mask pixels (2D only)."""
__slots__ = ( __slots__ = (
"shape",
"mask",
"index_map",
"factor",
"factor_dtype",
"lap",
"num_active",
"pinned",
"active_coords",
"active_linear_indices",
"_rhs_buffer", "_rhs_buffer",
"_rhs_pair_buffer", "_rhs_pair_buffer",
"_scatter_buffer", "_scatter_buffer",
"_scatter_pair_buffer", "_scatter_pair_buffer",
"active_coords",
"active_linear_indices",
"factor",
"factor_dtype",
"index_map",
"lap",
"mask",
"num_active",
"pinned",
"shape",
) )
def __init__( def __init__(
+7 -8
View File
@@ -1,17 +1,16 @@
import numpy
import timeit
import sys import sys
# import cv2
from scipy import signal, spatial
import numpy
# import cv2
from ..backend_availability import ( from ..backend_availability import (
CUPY_AVAILABLE, OPENCL_AVAILABLE, KERNEL_AVAILABLE, CUPY_AVAILABLE,
cp, cp_signal, _torque_kernel, cp,
cl, cla, cl_rfftn, cl_irfftn
) )
from .timing import Timer from .timing import Timer
class StructuredGrid(object):
class StructuredGrid:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
+1 -1
View File
@@ -50,7 +50,7 @@ from .timing import Timer
''' '''
class PoissonSolver(object): class PoissonSolver:
def __init__(self, **kwargs): def __init__(self, **kwargs):
args = kwargs.keys() args = kwargs.keys()

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