Refractor/config package #14
@@ -10,7 +10,7 @@ PyPI: [https://pypi.org/project/orisys/](https://pypi.org/project/orisys/)
|
|||||||
|
|
||||||
- `src/orisys/`: installable SDK package
|
- `src/orisys/`: installable SDK package
|
||||||
- `config/`: default and lab/runtime JSON configurations
|
- `config/`: default and lab/runtime JSON configurations
|
||||||
- `examples/`: product demos (`products/ory_box`, `ory_mini`, `ory_finger`) 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
|
||||||
- `tests/`: automated pytest suite
|
- `tests/`: automated pytest suite
|
||||||
@@ -54,23 +54,22 @@ pytest
|
|||||||
ORY-BOX:
|
ORY-BOX:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
|
python examples/basic_pipeline.py -v 0 -c box
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-MINI:
|
ORY-MINI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/products/ory_mini/basic_pipeline.py -v 0 -c mini
|
python examples/mini_pipeline.py -v 0 -c mini
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-FINGER:
|
ORY-FINGER:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
|
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||||
```
|
```
|
||||||
|
|
||||||
Compatibility wrappers remain at `examples/basic_pipeline.py` and
|
Run commands from the repository root.
|
||||||
`examples/finger_pipeline*.py`.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -78,7 +77,7 @@ The canonical configuration files now live in the repo-level `config/` folder.
|
|||||||
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 `ddjx01`, `mini`, `dd02`)
|
2. a config file stem under `config/` (for example `box`, `mini`, `finger`)
|
||||||
3. a product alias such as `dd02` -> `dd02-ov`
|
3. a product alias 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"`
|
||||||
|
|
||||||
@@ -87,8 +86,8 @@ Examples:
|
|||||||
```python
|
```python
|
||||||
import orisys
|
import orisys
|
||||||
|
|
||||||
sensor = orisys.Sensor(0, config_name="ddjx01")
|
sensor = orisys.Sensor(0, config_name="box")
|
||||||
cfg = orisys.export_config("ddjx01", "./config/exported_config.json")
|
cfg = orisys.export_config("box", "./config/exported_config.json")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Examples and Tools
|
## Examples and Tools
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
@@ -3,13 +3,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
|
import importlib.util
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_BUILD_ROOT = Path(__file__).resolve().parents[1]
|
_BUILD_ROOT = Path(__file__).resolve().parents[1]
|
||||||
if str(_BUILD_ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_BUILD_ROOT))
|
|
||||||
|
def _load_wheel_layout_module():
|
||||||
|
layout_path = _BUILD_ROOT / "wheel" / "layout.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("_orisys_build_wheel_layout", layout_path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"Cannot load wheel layout helper from {layout_path}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
def _load_toml(text: str) -> dict:
|
def _load_toml(text: str) -> dict:
|
||||||
@@ -94,7 +103,7 @@ def bundle_items(repo_root: Path) -> list[dict]:
|
|||||||
|
|
||||||
def bundle_data_files(repo_root: Path) -> list[tuple[str, list[str]]]:
|
def bundle_data_files(repo_root: Path) -> list[tuple[str, list[str]]]:
|
||||||
"""Map bundle items marked dev_install=true to setuptools data_files entries."""
|
"""Map bundle items marked dev_install=true to setuptools data_files entries."""
|
||||||
from wheel.layout import resolve_bundle_source_files
|
resolve_bundle_source_files = _load_wheel_layout_module().resolve_bundle_source_files
|
||||||
|
|
||||||
grouped: dict[str, list[str]] = {}
|
grouped: dict[str, list[str]] = {}
|
||||||
for item in bundle_items(repo_root):
|
for item in bundle_items(repo_root):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
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" / "products" / "ory_box" / "basic_pipeline.py"
|
ENTRY_SCRIPT = REPO_ROOT / "examples" / "basic_pipeline.py"
|
||||||
EXE_NAME = "OryBox.exe"
|
EXE_NAME = "OryBox.exe"
|
||||||
DIST_NAME = "OryBox"
|
DIST_NAME = "OryBox"
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
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" / "products" / "ory_finger" / "finger_pipeline_3dview.py"
|
ENTRY_SCRIPT = REPO_ROOT / "examples" / "finger_pipeline_3dview.py"
|
||||||
EXE_NAME = "OrisysDashboard.exe"
|
EXE_NAME = "OrisysDashboard.exe"
|
||||||
DIST_NAME = "OrisysDashboard"
|
DIST_NAME = "OrisysDashboard"
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
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" / "products" / "ory_mini" / "basic_pipeline.py"
|
ENTRY_SCRIPT = REPO_ROOT / "examples" / "mini_pipeline.py"
|
||||||
EXE_NAME = "OryMini.exe"
|
EXE_NAME = "OryMini.exe"
|
||||||
DIST_NAME = "OryMini"
|
DIST_NAME = "OryMini"
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ from common.paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repair
|
|||||||
from common.pyproject_utils import bundle_items, extras_require, get_version, install_requires
|
from common.pyproject_utils import bundle_items, extras_require, get_version, install_requires
|
||||||
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 copy_bundle_items, copy_orisys_blend, package_data_from_bundle
|
from wheel.layout import (
|
||||||
|
copy_bundle_items,
|
||||||
|
copy_orisys_blend,
|
||||||
|
package_data_from_bundle,
|
||||||
|
)
|
||||||
from wheel.package import (
|
from wheel.package import (
|
||||||
is_compliant_manylinux_wheel,
|
is_compliant_manylinux_wheel,
|
||||||
publish_wheel_to_sdk,
|
publish_wheel_to_sdk,
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ from __future__ import annotations
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Required files that must ship with the wheel for ORY-FINGER runtime/GUI.
|
||||||
|
REQUIRED_ASSET_RELPATHS = (
|
||||||
|
"finger/assets/30fps-0703/mesh.npz",
|
||||||
|
"finger/assets/30fps-0703/camera_intrinsics.npz",
|
||||||
|
"finger/assets/30fps-0703/world_to_cam.npy",
|
||||||
|
"finger/assets/visualization/world_to_cam.npy",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]:
|
def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]:
|
||||||
"""Resolve explicit files and/or glob patterns for one bundle item."""
|
"""Resolve explicit files and/or glob patterns for one bundle item."""
|
||||||
@@ -37,6 +45,32 @@ def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]:
|
|||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def verify_required_assets(repo_root: Path, items: list[dict]) -> None:
|
||||||
|
"""Fail early when required finger assets are missing from the bundle."""
|
||||||
|
asset_items = [item for item in items if item.get("name") == "sensors"]
|
||||||
|
if not asset_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
resolved: set[str] = set()
|
||||||
|
for item in asset_items:
|
||||||
|
src_dir = (repo_root / item["source"]).resolve()
|
||||||
|
for path in resolve_bundle_source_files(repo_root, item):
|
||||||
|
try:
|
||||||
|
rel = path.resolve().relative_to(src_dir).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
resolved.add(rel)
|
||||||
|
|
||||||
|
missing = [rel for rel in REQUIRED_ASSET_RELPATHS if rel not in resolved]
|
||||||
|
if missing:
|
||||||
|
joined = ", ".join(missing)
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"Required finger assets are missing from the wheel bundle. "
|
||||||
|
f"Missing: {joined}. Update [[tool.orisys.bundle.items]] patterns "
|
||||||
|
"or restore assets under sensors/finger/assets/ before building."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def package_data_paths_for_item(item: dict) -> list[str]:
|
def package_data_paths_for_item(item: dict) -> list[str]:
|
||||||
"""Return setuptools package_data paths relative to the orisys package."""
|
"""Return setuptools package_data paths relative to the orisys package."""
|
||||||
target = str(item["target"]).replace("\\", "/")
|
target = str(item["target"]).replace("\\", "/")
|
||||||
@@ -54,6 +88,7 @@ def data_file_entries_for_item(item: dict) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def copy_bundle_items(repo_root: Path, pkg_dir: Path, items: list[dict]) -> dict[str, int]:
|
def copy_bundle_items(repo_root: Path, pkg_dir: Path, items: list[dict]) -> dict[str, int]:
|
||||||
|
verify_required_assets(repo_root, items)
|
||||||
copied_counts: dict[str, int] = {}
|
copied_counts: dict[str, int] = {}
|
||||||
for item in items:
|
for item in items:
|
||||||
name = item["name"]
|
name = item["name"]
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"name": "dd06-ov",
|
||||||
|
"type": "single_camera",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [
|
||||||
|
1280,
|
||||||
|
720
|
||||||
|
],
|
||||||
|
"fps": 30,
|
||||||
|
"resolution_stitch": [
|
||||||
|
195,
|
||||||
|
275
|
||||||
|
],
|
||||||
|
"source_scale": 0.4,
|
||||||
|
"old": [
|
||||||
|
672,
|
||||||
|
728,
|
||||||
|
1,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"work": [
|
||||||
|
504,
|
||||||
|
546,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"work2": [
|
||||||
|
420,
|
||||||
|
450,
|
||||||
|
0.8
|
||||||
|
],
|
||||||
|
"work3": [
|
||||||
|
350,
|
||||||
|
375,
|
||||||
|
4,
|
||||||
|
0.7
|
||||||
|
],
|
||||||
|
"work4": [
|
||||||
|
280,
|
||||||
|
300,
|
||||||
|
4,
|
||||||
|
0.4,
|
||||||
|
0.2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 3,
|
||||||
|
"algorithm_list": [
|
||||||
|
"raft",
|
||||||
|
"dis"
|
||||||
|
],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold": 0.2,
|
||||||
|
"div_threshold": 0.022,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [
|
||||||
|
5,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
},
|
||||||
|
"roi": {
|
||||||
|
"type": "polygon",
|
||||||
|
"image_size": [
|
||||||
|
1280,
|
||||||
|
720
|
||||||
|
],
|
||||||
|
"created_at": "2026-07-26T11:56:15",
|
||||||
|
"points": [
|
||||||
|
[
|
||||||
|
242,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
402,
|
||||||
|
597
|
||||||
|
],
|
||||||
|
[
|
||||||
|
799,
|
||||||
|
606
|
||||||
|
],
|
||||||
|
[
|
||||||
|
976,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"mask_path": "dd06-ov_mask.png",
|
||||||
|
"metadata": {
|
||||||
|
"source": "./data/dd06/0726.mp4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"name": "dd06-ov",
|
||||||
|
"type": "single_camera",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [
|
||||||
|
1280,
|
||||||
|
720
|
||||||
|
],
|
||||||
|
"fps": 30,
|
||||||
|
"resolution_stitch": [
|
||||||
|
195,
|
||||||
|
275
|
||||||
|
],
|
||||||
|
"source_scale": 0.4,
|
||||||
|
"old": [
|
||||||
|
672,
|
||||||
|
728,
|
||||||
|
1,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"work": [
|
||||||
|
504,
|
||||||
|
546,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"work2": [
|
||||||
|
420,
|
||||||
|
450,
|
||||||
|
0.8
|
||||||
|
],
|
||||||
|
"work3": [
|
||||||
|
350,
|
||||||
|
375,
|
||||||
|
4,
|
||||||
|
0.7
|
||||||
|
],
|
||||||
|
"work4": [
|
||||||
|
280,
|
||||||
|
300,
|
||||||
|
4,
|
||||||
|
0.4,
|
||||||
|
0.2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 3,
|
||||||
|
"algorithm_list": [
|
||||||
|
"raft",
|
||||||
|
"dis"
|
||||||
|
],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold": 0.2,
|
||||||
|
"div_threshold": 0.022,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [
|
||||||
|
5,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
},
|
||||||
|
"roi": {
|
||||||
|
"type": "polygon",
|
||||||
|
"image_size": [
|
||||||
|
1280,
|
||||||
|
720
|
||||||
|
],
|
||||||
|
"points": [
|
||||||
|
[
|
||||||
|
628,
|
||||||
|
532
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1145,
|
||||||
|
694
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1161,
|
||||||
|
8
|
||||||
|
],
|
||||||
|
[
|
||||||
|
630,
|
||||||
|
126
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"created_at": "2026-08-10T14:26:31",
|
||||||
|
"mask_path": "dd06-ov-0810_mask.png",
|
||||||
|
"metadata": {
|
||||||
|
"source": ".\\data\\dd06\\2026-08-10-142316.webm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
"camera": {
|
"camera": {
|
||||||
"resolution_raw": [1280, 720],
|
"resolution_raw": [1280, 720],
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
"resolution_stitch": [420, 450],
|
"resolution_stitch": [280, 300],
|
||||||
"source_scale": 0.4,
|
"source_scale": 0.4,
|
||||||
"old":[672, 728, 1,0.5],
|
"old":[672, 728, 1,0.5],
|
||||||
"work": [504, 546,1],
|
"work": [504, 546,1],
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"loop_closure_interval": 1,
|
"loop_closure_interval": 1,
|
||||||
"algorithm": "dis",
|
"algorithm": "dis",
|
||||||
"dis_preset": "medium",
|
"dis_preset": "medium",
|
||||||
"dis_mode": 3,
|
"dis_mode": 1,
|
||||||
"algorithm_list": ["raft", "dis"],
|
"algorithm_list": ["raft", "dis"],
|
||||||
"hhd_downsample": 5
|
"hhd_downsample": 5
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
+187
-151
@@ -1,77 +1,30 @@
|
|||||||
# orisys API 文档
|
---
|
||||||
|
title: API Reference
|
||||||
|
description: Orisys SDK Python API 中文参考
|
||||||
|
---
|
||||||
|
|
||||||
本文件提供了 `orisys` 套件的中文文档
|
# API Reference
|
||||||
|
|
||||||
## 准备 Python 开发环境
|
本文件提供 `orisys` 套件的 Python API 中文参考。安装、CUDA、CuPy 与运行示例的基础步骤请参考 [快速开始](./get_started.md)。
|
||||||
推荐下载 Anaconda,并使用Python 3.10
|
|
||||||
```
|
|
||||||
# 创建并激活虚拟环境
|
|
||||||
conda create -n orisys python=3.10
|
|
||||||
conda activate orisys
|
|
||||||
```
|
|
||||||
## 安装 CUDA Toolkit 和 cuDNN
|
|
||||||
在官方链接下载和安装:https://developer.nvidia.com/cuda/toolkit
|
|
||||||
或
|
|
||||||
在Anaconda环境中安装(例子使用cuda12.9):
|
|
||||||
https://anaconda.org/channels/nvidia/packages/cuda/overview
|
|
||||||
```
|
|
||||||
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
|
|
||||||
```
|
|
||||||
## SDK安装方法
|
|
||||||
1. 根据系统(win / linux),平台(amd64, aarch64), python(如p38、p39、p310) 版本下载对应的`.whl`文件
|
|
||||||
2. 运行pip命令安装:
|
|
||||||
```bash
|
|
||||||
pip install orisys-{version}-{python-version}-{python-version}-{os}_{machine}.whl
|
|
||||||
```
|
|
||||||
如
|
|
||||||
```bash
|
|
||||||
pip install orisys-0.4.2-cp310-cp310-win_amd64.whl
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **安装 CuPy(根据您的 CUDA 版本选择):**
|
## Sensor API
|
||||||
|
|
||||||
**方法 1:使用 extras(推荐)**
|
|
||||||
```bash
|
|
||||||
# 对于 CUDA 12.x
|
|
||||||
pip install orisys[cuda12]
|
|
||||||
|
|
||||||
# 对于 CUDA 13.x
|
|
||||||
pip install orisys[cuda13]
|
|
||||||
```
|
|
||||||
|
|
||||||
**方法 2:手动安装**
|
|
||||||
```bash
|
|
||||||
# 对于 CUDA 12.x
|
|
||||||
pip install cupy-cuda12x>=12.3.0
|
|
||||||
|
|
||||||
# 对于 CUDA 13.x
|
|
||||||
pip install cupy-cuda13x>=13.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 测试导入:
|
### `Sensor` 类别
|
||||||
```python
|
#### 描述
|
||||||
import orisys
|
|
||||||
print(orisys.__version__)
|
|
||||||
```
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
## `Sensor` 类别
|
|
||||||
### 描述
|
|
||||||
`orisys`套件的核心类,用于从视频文件或实时摄像头中读取传感器图像,创建一个具体的传感器对象(实例)。并完成形变、光流及力信息的计算与管理。
|
`orisys`套件的核心类,用于从视频文件或实时摄像头中读取传感器图像,创建一个具体的传感器对象(实例)。并完成形变、光流及力信息的计算与管理。
|
||||||
|
|
||||||
### 输入参数
|
#### 输入参数
|
||||||
|
|
||||||
* **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。
|
* **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。
|
||||||
* **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `ddjx01` 或 `./config/ddjx01.json`。
|
* **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `box` 或 `./config/box.json`。
|
||||||
* **cal_path** (`str`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。
|
* **cal_path** (`str`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。
|
||||||
* **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。
|
* **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。
|
||||||
* **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。
|
* **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。
|
||||||
|
|
||||||
|
|
||||||
### 返回
|
#### 返回
|
||||||
* `Sensor` 实例,用于后续图像采集、形变计算和数据读取操作。
|
* `Sensor` 实例,用于后续图像采集、形变计算和数据读取操作。
|
||||||
### 示例
|
#### 示例
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Example 1: 用相机编号创建
|
# Example 1: 用相机编号创建
|
||||||
@@ -82,63 +35,62 @@ sensor = Sensor(0)
|
|||||||
sensor = Sensor("/data/data.mp4")
|
sensor = Sensor("/data/data.mp4")
|
||||||
```
|
```
|
||||||
|
|
||||||
***
|
### `Sensor.get_img` 方法
|
||||||
|
|
||||||
## 2. `Sensor.get_img` 方法
|
#### 描述
|
||||||
|
|
||||||
### 描述
|
|
||||||
|
|
||||||
采集并预处理一帧图像并存到`Sensor` 类别中。
|
采集并预处理一帧图像并存到`Sensor` 类别中。
|
||||||
|
|
||||||
|
|
||||||
### 返回
|
#### 返回
|
||||||
* `bool`:是否成功读取图像。
|
* `bool`:是否成功读取图像。
|
||||||
|
|
||||||
### 状态更新
|
#### 状态更新
|
||||||
* 成功时更新`sensor`类里面的`sensor.img`。处理后的BGR格式图像(`uint8`,尺寸为json文件中`resolution_stitch`对应分辨率)。
|
* 成功时更新`sensor`类里面的`sensor.img`。处理后的BGR格式图像(`uint8`,尺寸为json文件中`resolution_stitch`对应分辨率)。
|
||||||
### 示例
|
#### 示例
|
||||||
|
|
||||||
```python
|
```python
|
||||||
sensor.get_img()
|
sensor.get_img()
|
||||||
```
|
```
|
||||||
***
|
### `Sensor.compute_deformation` 方法
|
||||||
|
|
||||||
## 3. `Sensor.compute_deformation` 方法
|
#### 描述
|
||||||
|
|
||||||
### 描述
|
|
||||||
|
|
||||||
基于当前帧图像计算光流/形变向量场,并通过 HHD 分解将形变场分解为法向/切向分量,同时估计法向力、切向力与扭矩等标量输出。该方法会更新
|
基于当前帧图像计算光流/形变向量场,并通过 HHD 分解将形变场分解为法向/切向分量,同时估计法向力、切向力与扭矩等标量输出。该方法会更新
|
||||||
`sensor`的形变场与力相关状态,并返回当前帧的光流场。
|
`sensor`的形变场与力相关状态,并返回当前帧的光流场。
|
||||||
调用前置条件:需要先通过`get_img()`成功更新图像。
|
调用前置条件:需要先通过`get_img()`成功更新图像。
|
||||||
|
|
||||||
### 输入参数
|
#### 输入参数
|
||||||
* **check_motion**(`bool`,可选): 为 `True` 时先根据光流判断是否有运动,有运动才做 HHD 分解;为 `False` 时每帧都做 HHD。默认 `True`。
|
* **decompose** (`bool`,可选): 是否在计算光流后继续执行 HHD 分解,默认 `True`。
|
||||||
* **threshold**(`float`,可选): **check_motion** 功能的阈值,默认为`6`。
|
* **check_motion** (`bool`,可选): 是否启用运动检测门控。当前不推荐依赖该功能;新代码建议显式传入 `False`,让每帧都执行 HHD 分解。为兼容旧代码,函数默认值仍为 `True`。
|
||||||
* **tail_frames** (`int`,可选): 光流变化低于阈值后继续解算多少帧,确保传感器回复,,默认为 `10`.
|
|
||||||
|
|
||||||
### 返回
|
#### 兼容参数(暂不推荐)
|
||||||
|
|
||||||
* `bool`:是否接触到物体,如果**check_motion**为`False`时返回`None`。
|
以下参数只在 `check_motion=True` 时生效。由于运动检测门控目前不稳定,建议在新代码中暂时不要使用:
|
||||||
|
|
||||||
### 示例
|
* **threshold** (`float`,可选): 运动检测阈值,默认 `6`。
|
||||||
|
* **tail_frames** (`int`,可选): 运动低于阈值后继续解算多少帧,默认 `10`。
|
||||||
|
|
||||||
|
#### 返回
|
||||||
|
|
||||||
|
* `None`: 当 `check_motion=False` 时返回。推荐用法下不要依赖该返回值判断接触状态。
|
||||||
|
* `bool`: 仅当 `check_motion=True` 时返回运动检测结果;该模式目前暂不推荐。
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
```python
|
```python
|
||||||
sensor.get_img()
|
sensor.get_img()
|
||||||
|
sensor.compute_deformation(check_motion=False)
|
||||||
is_touched = sensor.compute_deformation(check_motion=True, threshold=6)
|
sensor.compute_contact()
|
||||||
# 步骤 2.3: 计算触碰点
|
|
||||||
if is_touched:
|
|
||||||
sensor.compute_contact()
|
|
||||||
```
|
```
|
||||||
***
|
### `Sensor.compute_contact` 方法
|
||||||
## 4. `Sensor.compute_contact` 方法
|
|
||||||
|
|
||||||
### 描述
|
#### 描述
|
||||||
基于已计算的法向形变场进行接触区域分析,输出接触质心与轮廓等信息,并生成接触深度图,默认配置在`json`文件中,可按需调节。
|
基于已计算的法向形变场进行接触区域分析,输出接触质心与轮廓等信息,并生成接触深度图,默认配置在`json`文件中,可按需调节。
|
||||||
|
|
||||||
调用前置条件:需先执行`compute_deformation()`。
|
调用前置条件:需先执行`compute_deformation()`。
|
||||||
|
|
||||||
### 返回
|
#### 返回
|
||||||
`dict`为接触分析结果字典,至少包含以下键:
|
`dict`为接触分析结果字典,至少包含以下键:
|
||||||
|
|
||||||
* **expansion_mask**: 扩张区域掩码(`np.ndarray`)。
|
* **expansion_mask**: 扩张区域掩码(`np.ndarray`)。
|
||||||
@@ -148,12 +100,12 @@ if is_touched:
|
|||||||
* **centroid_found**: 是否检测到接触区域(`bool`)。
|
* **centroid_found**: 是否检测到接触区域(`bool`)。
|
||||||
* **contour**: 最大轮廓或轮廓列表(可选字段,存在时返回)。
|
* **contour**: 最大轮廓或轮廓列表(可选字段,存在时返回)。
|
||||||
|
|
||||||
### 状态更新
|
#### 状态更新
|
||||||
|
|
||||||
* **sensor.centroid**: 接触质心(单点为 `(x, y)`,多点为列表)。
|
* **sensor.centroid**: 接触质心(单点为 `(x, y)`,多点为列表)。
|
||||||
* **sensor.centroid_found**: 是否找到质心(`bool`)。
|
* **sensor.centroid_found**: 是否找到质心(`bool`)。
|
||||||
|
|
||||||
### 示例
|
#### 示例
|
||||||
```python
|
```python
|
||||||
sensor.compute_contact()
|
sensor.compute_contact()
|
||||||
centroid, contour, depth = sensor.read_info(
|
centroid, contour, depth = sensor.read_info(
|
||||||
@@ -162,19 +114,18 @@ centroid, contour, depth = sensor.read_info(
|
|||||||
sensor.info.DEPTH,
|
sensor.info.DEPTH,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
***
|
### `Sensor.read_info` 方法
|
||||||
## 4. `Sensor.read_info` 方法
|
|
||||||
|
|
||||||
### 描述
|
#### 描述
|
||||||
|
|
||||||
按指定的信息类型(`sensor.info`枚举值)批量读取传感器状态数据,并按传入顺序返回对应值的元组。
|
按指定的信息类型(`sensor.info`枚举值)批量读取传感器状态数据,并按传入顺序返回对应值的元组。
|
||||||
该方法通常用于在完成形变计算后,读取光流场、分量场、力、帧率等信息。
|
该方法通常用于在完成形变计算后,读取光流场、分量场、力、帧率等信息。
|
||||||
|
|
||||||
### 输入参数
|
#### 输入参数
|
||||||
|
|
||||||
* **args**: 任意数量的 `Sensor.info` 枚举,用于指定需要获取的数据类型。支持的枚举值及对应数据如下:
|
* **args**: 任意数量的 `Sensor.info` 枚举,用于指定需要获取的数据类型。支持的枚举值及对应数据如下:
|
||||||
|
|
||||||
### 支持的枚 info keys:
|
#### 支持的 info keys
|
||||||
|
|
||||||
向量/图像类
|
向量/图像类
|
||||||
|
|
||||||
@@ -188,6 +139,9 @@ centroid, contour, depth = sensor.read_info(
|
|||||||
* FNORMAL: 法向力,`float`。
|
* FNORMAL: 法向力,`float`。
|
||||||
* FSHEARX: X切向力,`float`。
|
* FSHEARX: X切向力,`float`。
|
||||||
* FSHEARY: Y切向力,`float`。
|
* FSHEARY: Y切向力,`float`。
|
||||||
|
* FNORMAL_RAW: 未经过低通滤波的原始法向力,`float`。
|
||||||
|
* FSHEARX_RAW: 未经过低通滤波的原始 X 切向力,`float`。
|
||||||
|
* FSHEARY_RAW: 未经过低通滤波的原始 Y 切向力,`float`。
|
||||||
* FPS: 输出帧率,`float`。
|
* FPS: 输出帧率,`float`。
|
||||||
* CENTROID: 触碰点的坐标,(x, y)。
|
* CENTROID: 触碰点的坐标,(x, y)。
|
||||||
* CONTOUR: 触碰区域轮廓,`np.ndarray`, shape=(H, W), 未生成时返回`np.empty`
|
* CONTOUR: 触碰区域轮廓,`np.ndarray`, shape=(H, W), 未生成时返回`np.empty`
|
||||||
@@ -198,7 +152,7 @@ centroid, contour, depth = sensor.read_info(
|
|||||||
|
|
||||||
注:H/W 由当前`json`文件中的`resolution_stitch`配置分辨率决定。
|
注:H/W 由当前`json`文件中的`resolution_stitch`配置分辨率决定。
|
||||||
|
|
||||||
### 返回
|
#### 返回
|
||||||
|
|
||||||
`Tuple`
|
`Tuple`
|
||||||
|
|
||||||
@@ -210,7 +164,7 @@ centroid, contour, depth = sensor.read_info(
|
|||||||
- 标量类数据:`float`
|
- 标量类数据:`float`
|
||||||
- 时间戳:`str`(`TIMESTAMP`,格式为 `HH:MM:SS`)
|
- 时间戳:`str`(`TIMESTAMP`,格式为 `HH:MM:SS`)
|
||||||
|
|
||||||
### 示例
|
#### 示例
|
||||||
```python
|
```python
|
||||||
# Example 1:读取标量数据
|
# Example 1:读取标量数据
|
||||||
fps, fn, fx, fy = sensor.read_info(
|
fps, fn, fx, fy = sensor.read_info(
|
||||||
@@ -223,22 +177,114 @@ fps, fn, fx, fy = sensor.read_info(
|
|||||||
# Example 2: 读取单个向量场(注意解包方式)
|
# Example 2: 读取单个向量场(注意解包方式)
|
||||||
flow = sensor.read_info(sensor.info.VRAW)
|
flow = sensor.read_info(sensor.info.VRAW)
|
||||||
```
|
```
|
||||||
***
|
### `Sensor.disconnect` 方法
|
||||||
|
|
||||||
## 5. `Sensor.disconnect` 方法
|
#### 描述
|
||||||
|
|
||||||
### 描述
|
|
||||||
|
|
||||||
释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 OpenCV 窗口。
|
释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 OpenCV 窗口。
|
||||||
|
|
||||||
建议在程序退出前调用以确保资源被正确释放。
|
建议在程序退出前调用以确保资源被正确释放。
|
||||||
|
|
||||||
***
|
### `Sensor.configure` 低通滤波设置
|
||||||
***
|
|
||||||
|
|
||||||
## `orisys.finger` 手指 UV pipeline
|
#### 描述
|
||||||
|
|
||||||
`examples/products/ory_finger/finger_pipeline.py` 展示了一个更适合手指/单相机场景的高层 API:
|
运行时更新传感器设置,无需重新连接相机。常用于开启力信号低通滤波,降低 `FNORMAL`、`FSHEARX`、`FSHEARY` 的高频噪声。
|
||||||
|
|
||||||
|
默认采样率按 30 fps 计算,推荐低通截止频率为 5 Hz:
|
||||||
|
|
||||||
|
```python
|
||||||
|
sensor.configure(lowpass_cutoff_hz=5.0, lowpass_sample_rate_hz=30.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:`lowpass_cutoff_hz` 必须小于 Nyquist 频率,即 `lowpass_sample_rate_hz / 2`。例如 30 fps 时,截止频率必须小于 15 Hz。
|
||||||
|
|
||||||
|
#### 输入参数
|
||||||
|
|
||||||
|
* **lowpass_cutoff_hz** (`float`,可选): 力信号低通截止频率,单位 Hz。`0` 表示关闭滤波;正数默认开启滤波。
|
||||||
|
* **lowpass_sample_rate_hz** (`float`,可选): 力信号采样率,默认 `30.0`。
|
||||||
|
* **lowpass_order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
|
||||||
|
* **lowpass_enabled** (`bool`,可选): 显式开启或关闭低通滤波。
|
||||||
|
* **hhd_downsample** (`int`,可选): HHD 解算下采样倍数,需为 `>= 1` 的整数,配置默认 `3`。
|
||||||
|
|
||||||
|
#### 读取结果
|
||||||
|
|
||||||
|
开启低通滤波后,`read_info()` 读取的力输出会被滤波:
|
||||||
|
|
||||||
|
```python
|
||||||
|
fn, fx, fy = sensor.read_info(
|
||||||
|
sensor.info.FNORMAL,
|
||||||
|
sensor.info.FSHEARX,
|
||||||
|
sensor.info.FSHEARY,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
原始力输出仍可通过 `FNORMAL_RAW`、`FSHEARX_RAW`、`FSHEARY_RAW` 读取,便于调试或对比。
|
||||||
|
|
||||||
|
## Signal Processing API
|
||||||
|
|
||||||
|
### `orisys.signal_processing.lowpass_filter` 函数
|
||||||
|
|
||||||
|
#### 描述
|
||||||
|
|
||||||
|
对一维、等间隔采样数据执行零相位 Butterworth 低通滤波,适合示例程序或图表中对历史数据做平滑处理。
|
||||||
|
|
||||||
|
默认参数为 `cutoff_hz=5.0`、`sample_rate_hz=30.0`、`order=2`。这表示在 30 fps 数据上使用 5 Hz 低通滤波。
|
||||||
|
|
||||||
|
#### 输入参数
|
||||||
|
|
||||||
|
* **data**: 一维数据序列。
|
||||||
|
* **cutoff_hz** (`float`,可选): 截止频率,默认 `5.0`。传入 `0` 时不做滤波并返回数据副本。
|
||||||
|
* **sample_rate_hz** (`float`,可选): 采样率,默认 `30.0`。
|
||||||
|
* **order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
|
||||||
|
|
||||||
|
#### 返回
|
||||||
|
|
||||||
|
* `np.ndarray`: 滤波后的 `float64` 数组。
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
from orisys.signal_processing import lowpass_filter
|
||||||
|
|
||||||
|
filtered_force = lowpass_filter(force_values, cutoff_hz=5.0, sample_rate_hz=30.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `orisys.signal_processing.LowpassFilter` 类别
|
||||||
|
|
||||||
|
#### 描述
|
||||||
|
|
||||||
|
有状态的因果 Butterworth 低通滤波器,适合逐帧处理实时数据。它会保留上一帧的滤波状态,并通过 `process()` 一次处理一个多通道样本。
|
||||||
|
|
||||||
|
`Sensor.configure()` 内部使用该类来滤波力信号。构造时默认 `cutoff_hz=0.0`,表示关闭滤波;若要按 30 fps 使用推荐滤波,设置 `cutoff_hz=5.0`、`sample_rate_hz=30.0`。
|
||||||
|
|
||||||
|
#### 输入参数
|
||||||
|
|
||||||
|
* **cutoff_hz** (`float`,可选): 截止频率,默认 `0.0`,表示关闭滤波。
|
||||||
|
* **sample_rate_hz** (`float`,可选): 采样率,默认 `30.0`。
|
||||||
|
* **order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
|
||||||
|
* **n_channels** (`int`,可选): 每个样本的通道数,默认 `3`。
|
||||||
|
|
||||||
|
#### 常用方法
|
||||||
|
|
||||||
|
* **configure(...)**: 运行时更新 `cutoff_hz`、`sample_rate_hz` 或 `order`。
|
||||||
|
* **reset(x0=None)**: 清除滤波器状态;传入 `x0` 时会用该样本初始化稳态。
|
||||||
|
* **process(values)**: 滤波一个多通道样本,返回 shape 为 `(n_channels,)` 的 `np.ndarray`。
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
from orisys.signal_processing import LowpassFilter
|
||||||
|
|
||||||
|
filt = LowpassFilter(cutoff_hz=5.0, sample_rate_hz=30.0, n_channels=3)
|
||||||
|
filtered = filt.process([fnormal, fshearx, fsheary])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Finger Pipeline API
|
||||||
|
|
||||||
|
### `orisys.finger` 手指 UV pipeline
|
||||||
|
|
||||||
|
`examples/finger_pipeline.py` 展示了一个更适合手指/单相机场景的高层 API:
|
||||||
|
|
||||||
1. 使用 `FingerRuntime.open()` 打开视频源、加载 Blender 导出的资产,并构建 UV 展开映射。
|
1. 使用 `FingerRuntime.open()` 打开视频源、加载 Blender 导出的资产,并构建 UV 展开映射。
|
||||||
2. 用 `grab_and_process()` 或 `read_source() + process_frame()` 获取一帧展开后的触觉图像。
|
2. 用 `grab_and_process()` 或 `read_source() + process_frame()` 获取一帧展开后的触觉图像。
|
||||||
@@ -247,7 +293,7 @@ flow = sensor.read_info(sensor.info.VRAW)
|
|||||||
|
|
||||||
相比直接使用 `Sensor`,`FingerRuntime` 已封装了视频读取、相机尺寸设置、UV 展开和有效区域掩码传递,更适合作为手指 2D/3D 示例与上层应用的入口。
|
相比直接使用 `Sensor`,`FingerRuntime` 已封装了视频读取、相机尺寸设置、UV 展开和有效区域掩码传递,更适合作为手指 2D/3D 示例与上层应用的入口。
|
||||||
|
|
||||||
### 1. `FingerRuntime.open` 方法
|
### `FingerRuntime.open` 方法
|
||||||
|
|
||||||
#### 描述
|
#### 描述
|
||||||
|
|
||||||
@@ -262,7 +308,7 @@ flow = sensor.read_info(sensor.info.VRAW)
|
|||||||
#### 输入参数
|
#### 输入参数
|
||||||
|
|
||||||
* **export_dir** (`str`,可选): 手指导出资产目录,默认值来自 `orisys.finger.DEFAULT_FINGER_EXPORT_DIR`。目录下通常需要包含 `mesh.npz`、`camera_intrinsics.npz`、`world_to_cam.npy`。
|
* **export_dir** (`str`,可选): 手指导出资产目录,默认值来自 `orisys.finger.DEFAULT_FINGER_EXPORT_DIR`。目录下通常需要包含 `mesh.npz`、`camera_intrinsics.npz`、`world_to_cam.npy`。
|
||||||
* **config** (`str`,可选): Orisys 配置文件名称或路径,默认 `dd02`(兼容解析到 `dd02-ov`)。
|
* **config** (`str`,可选): Orisys 配置文件名称或路径,默认 `finger`。
|
||||||
* **map_cache** (`str`,可选): UV 展开缓存文件路径,默认值来自 `orisys.finger.DEFAULT_FINGER_MAP_CACHE`。
|
* **map_cache** (`str`,可选): UV 展开缓存文件路径,默认值来自 `orisys.finger.DEFAULT_FINGER_MAP_CACHE`。
|
||||||
* **source_scale** (`float`,可选): 输入相机分辨率缩放比例;未提供时根据配置自动推断。
|
* **source_scale** (`float`,可选): 输入相机分辨率缩放比例;未提供时根据配置自动推断。
|
||||||
* **calib** (`str | None`,可选): 外部相机标定文件路径;未提供时优先使用导出资产中的内参。
|
* **calib** (`str | None`,可选): 外部相机标定文件路径;未提供时优先使用导出资产中的内参。
|
||||||
@@ -282,13 +328,13 @@ flow = sensor.read_info(sensor.info.VRAW)
|
|||||||
from orisys.finger import FingerRuntime
|
from orisys.finger import FingerRuntime
|
||||||
|
|
||||||
runtime = FingerRuntime.open(
|
runtime = FingerRuntime.open(
|
||||||
config="dd02",
|
config="finger",
|
||||||
video=0,
|
video=0,
|
||||||
verbose=True,
|
verbose=True,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. `FingerRuntime` 常用属性
|
### `FingerRuntime` 常用属性
|
||||||
|
|
||||||
* **runtime.geometry**: `FingerGeometry`,包含输入/输出分辨率、导出资产路径、相机内参、缓存路径等一次性初始化结果。
|
* **runtime.geometry**: `FingerGeometry`,包含输入/输出分辨率、导出资产路径、相机内参、缓存路径等一次性初始化结果。
|
||||||
* **runtime.domain**: UV 展开域对象,内部包含 remap、valid mask 以及可选 surface 数据。
|
* **runtime.domain**: UV 展开域对象,内部包含 remap、valid mask 以及可选 surface 数据。
|
||||||
@@ -296,7 +342,7 @@ runtime = FingerRuntime.open(
|
|||||||
* **runtime.capture**: OpenCV `VideoCapture` 对象。
|
* **runtime.capture**: OpenCV `VideoCapture` 对象。
|
||||||
* **runtime.valid_mask**: 当前 UV 域有效像素掩码。
|
* **runtime.valid_mask**: 当前 UV 域有效像素掩码。
|
||||||
|
|
||||||
### 3. `FingerRuntime.read_source` 方法
|
### `FingerRuntime.read_source` 方法
|
||||||
|
|
||||||
#### 描述
|
#### 描述
|
||||||
|
|
||||||
@@ -308,11 +354,11 @@ runtime = FingerRuntime.open(
|
|||||||
* **ok** (`bool`): 是否成功读取。
|
* **ok** (`bool`): 是否成功读取。
|
||||||
* **frame** (`np.ndarray | None`): 原始 BGR 帧。
|
* **frame** (`np.ndarray | None`): 原始 BGR 帧。
|
||||||
|
|
||||||
### 4. `FingerRuntime.process_frame` 方法
|
### `FingerRuntime.process_frame` 方法
|
||||||
|
|
||||||
#### 描述
|
#### 描述
|
||||||
|
|
||||||
对一帧原始输入执行源图预处理、UV 展开,并把展开后的结果送入内部 `TactileProcessor`。这是 `examples/products/ory_finger/finger_pipeline.py` 中 profile 模式使用的方法。
|
对一帧原始输入执行源图预处理、UV 展开,并把展开后的结果送入内部 `TactileProcessor`。这是 `examples/finger_pipeline.py` 中 profile 模式使用的方法。
|
||||||
|
|
||||||
#### 输入参数
|
#### 输入参数
|
||||||
|
|
||||||
@@ -323,11 +369,11 @@ runtime = FingerRuntime.open(
|
|||||||
|
|
||||||
* `np.ndarray`: 展开后的触觉图像。`color=False` 时通常为单通道灰度图;`color=True` 时为 BGR 图。
|
* `np.ndarray`: 展开后的触觉图像。`color=False` 时通常为单通道灰度图;`color=True` 时为 BGR 图。
|
||||||
|
|
||||||
### 5. `FingerRuntime.grab_and_process` 方法
|
### `FingerRuntime.grab_and_process` 方法
|
||||||
|
|
||||||
#### 描述
|
#### 描述
|
||||||
|
|
||||||
先读取一帧视频源,再调用 `process_frame()` 完成 UV 展开并推入内部触觉处理器。非 profile 模式下,`examples/products/ory_finger/finger_pipeline.py` 使用该方法作为主循环入口。
|
先读取一帧视频源,再调用 `process_frame()` 完成 UV 展开并推入内部触觉处理器。非 profile 模式下,`examples/finger_pipeline.py` 使用该方法作为主循环入口。
|
||||||
|
|
||||||
#### 输入参数
|
#### 输入参数
|
||||||
|
|
||||||
@@ -339,7 +385,7 @@ runtime = FingerRuntime.open(
|
|||||||
* **ok** (`bool`): 是否成功读取并处理。
|
* **ok** (`bool`): 是否成功读取并处理。
|
||||||
* **unrolled** (`np.ndarray | None`): 展开后的触觉图像。
|
* **unrolled** (`np.ndarray | None`): 展开后的触觉图像。
|
||||||
|
|
||||||
### 6. `FingerRuntime.get_img` 方法
|
### `FingerRuntime.get_img` 方法
|
||||||
|
|
||||||
#### 描述
|
#### 描述
|
||||||
|
|
||||||
@@ -353,11 +399,11 @@ runtime = FingerRuntime.open(
|
|||||||
|
|
||||||
* `np.ndarray | None`
|
* `np.ndarray | None`
|
||||||
|
|
||||||
### 7. `FingerRuntime.warmup` 方法
|
### `FingerRuntime.warmup` 方法
|
||||||
|
|
||||||
#### 描述
|
#### 描述
|
||||||
|
|
||||||
先执行一次采集与 UV 展开,然后调用 `runtime.tactile.warmup()` 预热内部光流/HHD 相关缓存。`examples/products/ory_finger/finger_pipeline.py` 在进入主循环前会先执行一次 warmup。
|
先执行一次采集与 UV 展开,然后调用 `runtime.tactile.warmup()` 预热内部光流/HHD 相关缓存。`examples/finger_pipeline.py` 在进入主循环前会先执行一次 warmup。
|
||||||
|
|
||||||
#### 输入参数
|
#### 输入参数
|
||||||
|
|
||||||
@@ -369,13 +415,13 @@ runtime = FingerRuntime.open(
|
|||||||
* **ok** (`bool`): 是否成功完成 warmup 前的采集。
|
* **ok** (`bool`): 是否成功完成 warmup 前的采集。
|
||||||
* **unrolled** (`np.ndarray | None`): warmup 时得到的展开图。
|
* **unrolled** (`np.ndarray | None`): warmup 时得到的展开图。
|
||||||
|
|
||||||
### 8. `FingerRuntime.close` 方法
|
### `FingerRuntime.close` 方法
|
||||||
|
|
||||||
#### 描述
|
#### 描述
|
||||||
|
|
||||||
释放内部 `VideoCapture` 并调用 `runtime.tactile.disconnect()` 清理 `Sensor` 资源。程序退出前应调用。
|
释放内部 `VideoCapture` 并调用 `runtime.tactile.disconnect()` 清理 `Sensor` 资源。程序退出前应调用。
|
||||||
|
|
||||||
### 9. `TactileProcessor` 类别
|
### `TactileProcessor` 类别
|
||||||
|
|
||||||
#### 描述
|
#### 描述
|
||||||
|
|
||||||
@@ -386,16 +432,16 @@ runtime = FingerRuntime.open(
|
|||||||
* **raw_sensor**: 底层 `Sensor` 对象。
|
* **raw_sensor**: 底层 `Sensor` 对象。
|
||||||
* **info**: 与 `Sensor.info` 相同的枚举入口。
|
* **info**: 与 `Sensor.info` 相同的枚举入口。
|
||||||
* **img / flow / vnormal / vshear / depth_map**: 当前帧处理结果。
|
* **img / flow / vnormal / vshear / depth_map**: 当前帧处理结果。
|
||||||
* **mask_stats**: 有效 UV 区域统计信息;`examples/products/ory_finger/finger_pipeline.py` 会读取 `mask_stats["coverage"]` 输出当前掩码覆盖率。
|
* **mask_stats**: 有效 UV 区域统计信息;`examples/finger_pipeline.py` 会读取 `mask_stats["coverage"]` 输出当前掩码覆盖率。
|
||||||
* **valid_mask**: 当前有效区域掩码。
|
* **valid_mask**: 当前有效区域掩码。
|
||||||
|
|
||||||
#### 说明
|
#### 说明
|
||||||
|
|
||||||
除 `push_frame()` / `warmup()` 之外,`TactileProcessor` 的 `compute_deformation()`、`compute_contact()`、`read_info()`、`reset()` 调用方式与 `Sensor` 基本一致,因此可直接复用前文 `Sensor` 章节中的参数说明。
|
除 `push_frame()` / `warmup()` 之外,`TactileProcessor` 的 `compute_deformation()`、`compute_contact()`、`read_info()`、`reset()` 调用方式与 `Sensor` 基本一致,因此可直接复用前文 `Sensor` 章节中的参数说明。
|
||||||
|
|
||||||
### 10. 最小手指 pipeline 示例
|
### 最小手指 pipeline 示例
|
||||||
|
|
||||||
下面的流程与 `examples/products/ory_finger/finger_pipeline.py` 一致:
|
下面的流程与 `examples/finger_pipeline.py` 一致:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import time
|
import time
|
||||||
@@ -404,7 +450,7 @@ import orisys
|
|||||||
from orisys.finger import FingerRuntime
|
from orisys.finger import FingerRuntime
|
||||||
|
|
||||||
runtime = FingerRuntime.open(
|
runtime = FingerRuntime.open(
|
||||||
config="dd02",
|
config="finger",
|
||||||
video=0,
|
video=0,
|
||||||
verbose=True,
|
verbose=True,
|
||||||
)
|
)
|
||||||
@@ -460,9 +506,9 @@ finally:
|
|||||||
cv2.destroyAllWindows()
|
cv2.destroyAllWindows()
|
||||||
```
|
```
|
||||||
|
|
||||||
### 11. 关于 profile 模式
|
### 关于 profile 模式
|
||||||
|
|
||||||
`examples/products/ory_finger/finger_pipeline.py` 中的 `--profile` / `--profile-every` 是示例脚本参数,不属于 `orisys` SDK 核心 API。本质上它只是把主循环拆成:
|
`examples/finger_pipeline.py` 中的 `--profile` / `--profile-every` 是示例脚本参数,不属于 `orisys` SDK 核心 API。本质上它只是把主循环拆成:
|
||||||
|
|
||||||
1. `runtime.read_source()`
|
1. `runtime.read_source()`
|
||||||
2. `runtime.process_frame(...)`
|
2. `runtime.process_frame(...)`
|
||||||
@@ -472,11 +518,11 @@ finally:
|
|||||||
|
|
||||||
并对各阶段做耗时统计。因此在业务代码中,也可以按同样方式自行插入计时逻辑。
|
并对各阶段做耗时统计。因此在业务代码中,也可以按同样方式自行插入计时逻辑。
|
||||||
|
|
||||||
# 可视化相关
|
## 可视化相关
|
||||||
|
|
||||||
## 1. `util.draw_arrows` 方法
|
### `util.draw_arrows` 方法
|
||||||
|
|
||||||
### 输入参数
|
#### 输入参数
|
||||||
|
|
||||||
* **frame**:背景图像
|
* **frame**:背景图像
|
||||||
* **optical_flow**:光流场
|
* **optical_flow**:光流场
|
||||||
@@ -488,12 +534,12 @@ finally:
|
|||||||
* **below_threshold_color**:RGB格式的`uint8`颜色,如(0, 0, 255)
|
* **below_threshold_color**:RGB格式的`uint8`颜色,如(0, 0, 255)
|
||||||
* **edge_margin**: 不显示箭头边距(pixel)
|
* **edge_margin**: 不显示箭头边距(pixel)
|
||||||
|
|
||||||
### 返回
|
#### 返回
|
||||||
`np.ndarray` :箭头光流可视化图像
|
`np.ndarray` :箭头光流可视化图像
|
||||||
|
|
||||||
## 2. `util.draw_contact` 方法
|
### `util.draw_contact` 方法
|
||||||
|
|
||||||
### 输入参数
|
#### 输入参数
|
||||||
|
|
||||||
* **image**:背景图像
|
* **image**:背景图像
|
||||||
* **contour**:轮廓
|
* **contour**:轮廓
|
||||||
@@ -501,42 +547,36 @@ finally:
|
|||||||
* **point_color**:触碰点的颜色 (BGR)
|
* **point_color**:触碰点的颜色 (BGR)
|
||||||
* **region_color**:轮廓的颜色 (BGR)
|
* **region_color**:轮廓的颜色 (BGR)
|
||||||
|
|
||||||
### 返回
|
#### 返回
|
||||||
`np.ndarray` :箭头光流可视化图像
|
`np.ndarray` :箭头光流可视化图像
|
||||||
|
|
||||||
***
|
## 配置标定相关
|
||||||
|
|
||||||
# 配置标定相关
|
### `list_config` 方法
|
||||||
|
|
||||||
## 1. `list_config` 方法
|
#### 描述
|
||||||
|
|
||||||
### 描述
|
|
||||||
列出内置传感器配置参数字典。
|
列出内置传感器配置参数字典。
|
||||||
|
|
||||||
### 返回
|
#### 返回
|
||||||
|
|
||||||
* `list`: 内置传感器配置参数字典的名字列表。
|
* `list`: 内置传感器配置参数字典的名字列表。
|
||||||
***
|
### `export_config` 方法
|
||||||
## 2. `export_config` 方法
|
|
||||||
|
|
||||||
### 描述
|
#### 描述
|
||||||
导出当前传感器实例使用的配置参数字典。
|
导出当前传感器实例使用的配置参数字典。
|
||||||
|
|
||||||
### 输入参数
|
#### 输入参数
|
||||||
* **config_name**: 需要导出的配置参数字典名字,`str`
|
* **config_name**: 需要导出的配置参数字典名字,`str`
|
||||||
* **path**: 导出的路径,默认`"./config/exported_config.json"`
|
* **path**: 导出的路径,默认`"./config/exported_config.json"`
|
||||||
|
|
||||||
### 返回
|
#### 返回
|
||||||
|
|
||||||
* `dict`: 导出的配置参数字典。
|
* `dict`: 导出的配置参数字典。
|
||||||
|
|
||||||
### 保存
|
#### 保存
|
||||||
|
|
||||||
* 导出内置的`config_name`配置参数字典至`path`
|
* 导出内置的`config_name`配置参数字典至`path`
|
||||||
|
|
||||||
***
|
|
||||||
***
|
|
||||||
|
|
||||||
## 常见问题解答 (FAQ)
|
## 常见问题解答 (FAQ)
|
||||||
### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`?
|
### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`?
|
||||||
A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。
|
A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。
|
||||||
@@ -556,9 +596,5 @@ A:当需要接触相关信息(如接触质心、轮廓或深度/散度图)
|
|||||||
A:`read_info()`按传入的`sensor.info`参数顺序返回一个元组。
|
A:`read_info()`按传入的`sensor.info`参数顺序返回一个元组。
|
||||||
返回值顺序与参数顺序一一对应,与内部存储顺序无关。
|
返回值顺序与参数顺序一一对应,与内部存储顺序无关。
|
||||||
|
|
||||||
### Q5:如果没有标定文件,或拼接结果不正确,会发生什么?
|
### Q5: 运行报错:“AttributeError: 'Stitcher' object has no attribute 'pos_default'”
|
||||||
A:若未指定或未找到标定文件,系统会在需要时创建新的拼接标定数据并保存到默认路径。
|
|
||||||
拼接结果异常通常与标定数据、拼接配置或输入图像质量有关,建议重新生成拼接标定并检查配置参数。
|
|
||||||
|
|
||||||
### Q6: 运行报错:“AttributeError: 'Stitcher' object has no attribute 'pos_default'”
|
|
||||||
A: 请检查视频源的编号是否正确,以及是否能正常打开视频源。在linux 使用`v4l2-ctl --list-devices` 或 Windows使用`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }` 列出摄像头设备。
|
A: 请检查视频源的编号是否正确,以及是否能正常打开视频源。在linux 使用`v4l2-ctl --list-devices` 或 Windows使用`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }` 列出摄像头设备。
|
||||||
Binary file not shown.
Binary file not shown.
+113
-83
@@ -1,80 +1,106 @@
|
|||||||
叠动科技软件使用手册
|
---
|
||||||
版本: 0.4.2
|
title: 快速开始
|
||||||
更新日期: 2026年7月13日
|
description: 安装并运行 Orisys SDK 的基础步骤
|
||||||
|
---
|
||||||
|
|
||||||
# 简介
|
# 快速开始
|
||||||
|
|
||||||
|
版本:0.4.3
|
||||||
|
|
||||||
|
## 简介
|
||||||
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
|
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
|
||||||
支持操作系统:Windows、Ubuntu
|
支持操作系统:Windows、Ubuntu
|
||||||
建议Python 版本: 3.10, 3.11, 3.12
|
建议Python 版本: 3.10, 3.11, 3.12
|
||||||
|
|
||||||
# 基础环境设置
|
项目链接:
|
||||||
## 安装 Python 与 Anaconda
|
|
||||||
1. 访问 [Anaconda 官网](https://www.anaconda.com/download)下载适合您操作系统的安装包(Windows、Linux)。
|
|
||||||
2. 按照提示完成安装。
|
|
||||||
3. 安装完成后,打开命令行终端(Windows 用户可在”Anaconda Navigator”打开“Anaconda Prompt”),输入 `conda info` ,若能显示 conda 的信息,则说明安装成功。
|
|
||||||
|
|
||||||
## 创建conda虚拟环境
|
- PyPI 安装页:[https://pypi.org/project/orisys/](https://pypi.org/project/orisys/)
|
||||||
|
- 项目文档/代码:[https://github.com/Orisys-Tech/orisys-sdk](https://github.com/Orisys-Tech/orisys-sdk)
|
||||||
|
|
||||||
|
## 基础环境设置
|
||||||
|
### 安装 Python 与 Anaconda
|
||||||
|
1. 访问 [Anaconda 官网](https://www.anaconda.com/download) 下载适合您操作系统的安装包(Windows、Linux)。
|
||||||
|
2. 按照提示完成安装。
|
||||||
|
3. 安装完成后,打开命令行终端(Windows 用户可在 “Anaconda Navigator” 打开 “Anaconda Prompt”),输入 `conda info`,若能显示 conda 的信息,则说明安装成功。
|
||||||
|
|
||||||
|
### 创建 conda 虚拟环境
|
||||||
虚拟环境相当于一个独立的 Python 工作空间,可以避免不同项目所需的库版本冲突。
|
虚拟环境相当于一个独立的 Python 工作空间,可以避免不同项目所需的库版本冲突。
|
||||||
打开命令行终端(或Anaconda Prompt),依次输入以下命令(以 python 3.10 为例):
|
打开命令行终端(或 Anaconda Prompt),依次输入以下命令(以 Python 3.10 为例):
|
||||||
``` bash
|
```bash
|
||||||
conda create -n orisys python=3.10
|
conda create -n orisys python=3.10
|
||||||
conda activate orisys
|
conda activate orisys
|
||||||
```
|
```
|
||||||
|
|
||||||
激活后,命令行提示符前会出现 `(orisys)` 字样,表示当前已进入虚拟环境。
|
激活后,命令行提示符前会出现 `(orisys)` 字样,表示当前已进入虚拟环境。
|
||||||
|
|
||||||
## 创建工作区文件夹
|
### 创建工作区文件夹
|
||||||
“Anaconda Prompt”启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理,可以手动创建或者运行`mkdir <path>`命令:, 例如
|
“Anaconda Prompt” 启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理。可以手动创建文件夹,也可以运行 `mkdir <path>` 命令。例如:
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
mkdir orisys_ws
|
mkdir orisys_ws
|
||||||
```
|
```
|
||||||
|
|
||||||
创建后如果要更换工作区,使用 `cd <path>`命令,例如:
|
创建后如果要更换工作区,使用 `cd <path>` 命令,例如:
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
cd "C:\Users\orisys\orisys_ws"
|
cd "C:\Users\orisys\orisys_ws"
|
||||||
```
|
```
|
||||||
<div style="page-break-after: always;"></div>
|
### 安装 CUDA 和 cuDNN(可选)
|
||||||
|
|
||||||
## 安装 CUDA 和 cuDNN(可选)
|
|
||||||
如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。
|
如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。
|
||||||
|
|
||||||
1. 方法一(官网下载):
|
1. 方法一(官网下载):
|
||||||
访问 [NVIDIA CUDA Toolkit官网](https://developer.nvidia.com/cuda/toolkit)下载对应系统的 CUDA Toolkit 安装包,并按照提示安装, 注意12.X 和 13.X 的版本对应不同的 CuPy 库版本。
|
访问 [NVIDIA CUDA Toolkit 官网](https://developer.nvidia.com/cuda/toolkit) 下载对应系统的 CUDA Toolkit 安装包,并按照提示安装。注意 12.x 和 13.x 的版本对应不同的 CuPy 库版本。
|
||||||
|
|
||||||
2. 方法二(使用 Anaconda 安装,推荐):
|
2. 方法二(使用 Anaconda 安装,推荐):
|
||||||
在已激活的 orisys 环境中直接运行以下命令(以 CUDA 12.9 为例):
|
在已激活的 orisys 环境中直接运行以下命令(以 CUDA 12.9 为例):
|
||||||
`conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn`
|
|
||||||
|
```bash
|
||||||
|
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
|
||||||
|
```
|
||||||
|
|
||||||
该方法会自动配置好 CUDA 和 cuDNN,无需手动下载。
|
该方法会自动配置好 CUDA 和 cuDNN,无需手动下载。
|
||||||
|
|
||||||
注意:如果您的计算机没有 NVIDIA 显卡,或者不想使用 GPU 加速,可以跳过此步骤。套件也支持纯 CPU 运行(但速度可能较慢)。
|
注意:如果您的计算机没有 NVIDIA 显卡,或者不想使用 GPU 加速,可以跳过此步骤。套件也支持纯 CPU 运行(但速度可能较慢)。
|
||||||
|
|
||||||
# 安装 orisys SDK
|
## 安装 orisys SDK
|
||||||
|
|
||||||
orisys SDK 以 .whl 文件形式提供,您需要根据操作系统、Python 版本和硬件平台选择正确的文件。
|
推荐优先从 PyPI 安装。PyPI 会自动选择适合当前 Python 版本和操作系统的安装包,适合大多数用户。
|
||||||
|
|
||||||
## 下载正确的 .whl 文件
|
### 使用 PyPI 安装(推荐)
|
||||||
|
确认已经激活 `orisys` 虚拟环境后,运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install orisys
|
||||||
|
```
|
||||||
|
|
||||||
|
如果需要升级到最新版本,运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --upgrade orisys
|
||||||
|
```
|
||||||
|
|
||||||
|
如果当前网络无法访问 PyPI,或需要安装客服提供的指定版本,可以使用下方 `.whl` 文件安装方式。
|
||||||
|
|
||||||
|
### 使用 .whl 文件安装(离线或指定版本)
|
||||||
从官方渠道获取 .whl 文件,文件名格式通常为:
|
从官方渠道获取 .whl 文件,文件名格式通常为:
|
||||||
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl
|
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl
|
||||||
例如:orisys-0.4.2-cp310-cp310-win_amd64.whl 表示:
|
例如:orisys-0.4.3-cp310-cp310-win_amd64.whl 表示:
|
||||||
|
|
||||||
版本:0.4.2
|
版本:0.4.3
|
||||||
Python 版本:3.10(cp310)
|
Python 版本:3.10(cp310)
|
||||||
操作系统:Windows(win)
|
操作系统:Windows(win)
|
||||||
硬件架构:64位(amd64)
|
硬件架构:64位(amd64)
|
||||||
|
|
||||||
请根据您的系统选择对应的文件,或者咨询客服索取:
|
请根据您的系统选择对应的文件,或者咨询客服索取。
|
||||||
|
|
||||||
## 安装 .whl 文件
|
将下载好的 `.whl` 文件放在一个文件夹中(如上述 `orisys_ws` 文件夹),打开命令行(确保已激活 `orisys` 环境或使用 “Anaconda Prompt”),使用 `cd` 命令切换到该文件夹,然后运行:
|
||||||
将下载好的 .whl 文件放在一个文件夹中(如上述`orisys_ws`文件夹),打开命令行(确保已激活 orisys 环境或使用"Anaconda Prompt"),使用 `cd` 命令切换到该文件夹,然后运行:
|
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
# 请将文件名替换为您实际下载的文件名
|
# 请将文件名替换为您实际下载的文件名
|
||||||
pip install orisys-0.4.2-cp310-cp310-win_amd64.whl
|
pip install orisys-0.4.3-cp310-cp310-win_amd64.whl
|
||||||
```
|
```
|
||||||
|
|
||||||
## 安装 CuPy(可选)
|
### 安装 CuPy(可选)
|
||||||
注意:如果您没有安装 CUDA,或者使用 CPU 运行,可以跳过这一步。
|
注意:如果您没有安装 CUDA,或者使用 CPU 运行,可以跳过这一步。
|
||||||
|
|
||||||
CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安装的 CUDA 版本选择对应的 CuPy 版本。
|
CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安装的 CUDA 版本选择对应的 CuPy 版本。
|
||||||
@@ -84,83 +110,88 @@ CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 如果您的 CUDA 版本是 12.x
|
# 如果您的 CUDA 版本是 12.x
|
||||||
pip install orisys[cuda12]
|
pip install "orisys[cuda12]"
|
||||||
|
|
||||||
# 如果您的 CUDA 版本是 13.x
|
# 如果您的 CUDA 版本是 13.x
|
||||||
pip install orisys[cuda13]
|
pip install "orisys[cuda13]"
|
||||||
```
|
```
|
||||||
如果之前已经安装了 orisys,可以单独安装 CuPy:
|
如果之前已经安装了 orisys,可以单独安装 CuPy:
|
||||||
|
|
||||||
方法二(手动安装):
|
方法二(手动安装):
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
# CUDA 12.x
|
# CUDA 12.x
|
||||||
pip install cupy-cuda12x>=12.3.0
|
pip install "cupy-cuda12x>=12.3.0"
|
||||||
|
|
||||||
# CUDA 13.x
|
# CUDA 13.x
|
||||||
pip install cupy-cuda13x>=13.0.0
|
pip install "cupy-cuda13x>=13.0.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试安装
|
### 测试安装
|
||||||
安装完成后,在命令行输入” python“命令进入 Python 环境, 并导入 orisys 库并查看版本号,验证是否成功:
|
安装完成后,在命令行输入 `python` 命令进入 Python 环境,并导入 `orisys` 库查看版本号,验证是否成功:
|
||||||
``` bash
|
```bash
|
||||||
python
|
python
|
||||||
|
```
|
||||||
|
|
||||||
|
进入 Python 后继续输入:
|
||||||
|
|
||||||
|
```python
|
||||||
import orisys
|
import orisys
|
||||||
print(orisys.__version__)
|
print(orisys.__version__)
|
||||||
```
|
```
|
||||||
如果输出版本号(例如 0.4.2),则说明安装成功。
|
如果输出版本号(例如 0.4.3),则说明安装成功。
|
||||||
|
|
||||||
# 使用方法
|
## 使用方法
|
||||||
## 运行示例程序
|
### 运行示例程序
|
||||||
套件里面包含了多个例子,建议都在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。按产品入口如下:
|
套件里面包含了多个例子。运行示例前,请先进入 SDK 示例所在的文件夹,建议在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。如果您是通过 PyPI 安装并且本地没有示例文件,可以从上方项目链接下载示例。按产品入口如下:
|
||||||
|
|
||||||
1. `examples/products/ory_box/basic_pipeline.py`:ORY-BOX 复眼/拼接传感器基础 OpenCV 示例(默认配置 `ddjx01`)
|
1. `examples/basic_pipeline.py`:ORY-BOX 复眼/拼接传感器基础 OpenCV 示例(默认配置 `box`)
|
||||||
2. `examples/products/ory_mini/basic_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`)
|
2. `examples/mini_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`)
|
||||||
3. `examples/products/ory_finger/finger_pipeline.py`:ORY-FINGER 2D UV 最小流程(默认配置 `dd02`)
|
3. `examples/finger_pipeline.py`:ORY-FINGER 2D 显示最小流程(默认配置 `finger`)
|
||||||
4. `examples/products/ory_finger/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `dd02`)
|
4. `examples/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `finger`)
|
||||||
|
|
||||||
`dd02` 是当前 ORY-FINGER 的稳定别名,实际会解析到 `dd02-ov`。旧路径 `examples/basic_pipeline.py` / `examples/finger_pipeline*.py` 仍可作为兼容入口。
|
`finger` 是当前 ORY-FINGER 的产品配置名。
|
||||||
|
|
||||||
下面先介绍最基础的 ORY-BOX 运行方式。
|
下面先介绍最基础的 ORY-BOX 运行方式。
|
||||||
|
|
||||||
### Windows:
|
#### Windows
|
||||||
1. 检查是否有其他正在连接的USB摄像头设备:打开powershell, 运行命令`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。
|
1. 检查是否有其他正在连接的 USB 摄像头设备:打开 PowerShell,运行命令 `Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。
|
||||||
2. 把传感器的USB头与电脑连接,再次运行步骤一的命令,会新增一行输出
|
2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增一行输出。
|
||||||
3. 如果第一步没有输出,传感器的ID就是“0”,如果第一步有一个设备,传感器的ID就是“1”,如此类推。
|
3. 如果第一步没有输出,传感器的 ID 就是 `0`;如果第一步有一个设备,传感器的 ID 就是 `1`,如此类推。
|
||||||
4. 在确定了传感器的ID(下方例子假设为“0”)后,打开Anaconda Prompt, 切换到工作路径,然后在命令行中输入:
|
4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开 Anaconda Prompt,切换到工作路径,然后在命令行中输入:
|
||||||
``` bash
|
```bash
|
||||||
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
|
python examples/basic_pipeline.py -v 0 -c box
|
||||||
```
|
```
|
||||||
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据
|
5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
|
||||||
|
|
||||||
### Ubuntu:
|
#### Ubuntu
|
||||||
1. 检查是否有其他正在连接的USB摄像头设备:打开终端, 运行命令`v4l2 –list-devices`,如果没有的话不会有输出。
|
1. 检查是否有其他正在连接的 USB 摄像头设备:打开终端,运行命令 `v4l2 --list-devices`,如果没有的话不会有输出。
|
||||||
2. 把传感器的USB头与电脑连接,再次运行步骤一的命令,会新增输出
|
2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增输出。
|
||||||
3. 根据新增的输出,例如”/dev/video0”,确定传感器的ID为“0”, 如果是”/dev/video3”的话ID为“3” ,如此类推。
|
3. 根据新增的输出,例如 `/dev/video0`,确定传感器的 ID 为 `0`;如果是 `/dev/video3`,ID 为 `3`,如此类推。
|
||||||
4. 在确定了传感器的ID(下方例子假设为“0”)后,打开终端, 激活相应的python环境,切换到工作路径,然后在命令行中输入:
|
4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开终端,激活相应的 Python 环境,切换到工作路径,然后在命令行中输入:
|
||||||
``` bash
|
```bash
|
||||||
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
|
python examples/basic_pipeline.py -v 0 -c box
|
||||||
```
|
```
|
||||||
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据
|
5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
|
||||||
|
|
||||||
### ORY-FINGER UV 示例
|
#### ORY-FINGER 示例
|
||||||
|
|
||||||
如果您使用的是 ORY-FINGER(当前配置别名 `dd02`),可运行:
|
如果您使用的是 ORY-FINGER(配置名 `finger`),可运行:
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
|
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||||
```
|
```
|
||||||
|
|
||||||
如果输入来自视频文件,也可以改用:
|
如果输入来自视频文件,也可以改用:
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4 --config dd02
|
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
|
||||||
```
|
```
|
||||||
|
|
||||||
该示例会:
|
该示例会:
|
||||||
|
|
||||||
- 读取相机或视频源
|
- 读取相机或视频源
|
||||||
- 将图像展开到 UV 平面
|
- 将图像展开到平面
|
||||||
- 计算光流、法向/切向力和接触区域
|
- 计算光流、法向/切向力和接触区域
|
||||||
- 在 OpenCV 窗口中显示展开后的光流箭头图
|
- 在 OpenCV 窗口中显示展开后的光流箭头图
|
||||||
|
|
||||||
@@ -170,20 +201,19 @@ python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4
|
|||||||
|
|
||||||
例如:
|
例如:
|
||||||
|
|
||||||
``` bash
|
```bash
|
||||||
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
|
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||||
```
|
```
|
||||||
|
|
||||||
### ORY-FINGER 3D 可视化界面
|
#### ORY-FINGER 3D 可视化界面
|
||||||
|
|
||||||
如果要使用 GUI ,安装GUI依赖:
|
如果要使用 GUI,先安装 GUI 依赖:
|
||||||
``` bash
|
```bash
|
||||||
pip install open3d
|
pip install "orisys[gui]"
|
||||||
pip install pyqt5 pyqtgraph
|
|
||||||
```
|
```
|
||||||
然后运行:
|
然后运行:
|
||||||
``` bash
|
```bash
|
||||||
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
|
python examples/finger_pipeline_3dview.py --video 0 --config finger
|
||||||
```
|
```
|
||||||
|
|
||||||
该界面会提供:
|
该界面会提供:
|
||||||
@@ -192,5 +222,5 @@ python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config
|
|||||||
- 力曲线
|
- 力曲线
|
||||||
- 3D 视图
|
- 3D 视图
|
||||||
|
|
||||||
## 开发套件使用方法
|
### 开发套件使用方法
|
||||||
参考开发套件附带的开发文档。
|
参考开发套件附带的开发文档。
|
||||||
Binary file not shown.
@@ -1,171 +0,0 @@
|
|||||||
# Publishing Orisys to PyPI
|
|
||||||
|
|
||||||
This document covers the **Linux manylinux binary wheel** path (Nuitka-compiled
|
|
||||||
`orisys` for CPython 3.10 / 3.11 / 3.12). Windows wheels are still built on
|
|
||||||
Windows hosts; they cannot be produced inside the manylinux container.
|
|
||||||
|
|
||||||
## Why Docker?
|
|
||||||
|
|
||||||
PyPI rejects wheels tagged `manylinux_x86_64` (missing glibc ABI version). A
|
|
||||||
compliant tag looks like:
|
|
||||||
|
|
||||||
```text
|
|
||||||
orisys-0.4.2-cp310-cp310-manylinux_2_28_x86_64.whl
|
|
||||||
orisys-0.4.2-cp311-cp311-manylinux_2_28_x86_64.whl
|
|
||||||
orisys-0.4.2-cp312-cp312-manylinux_2_28_x86_64.whl
|
|
||||||
```
|
|
||||||
|
|
||||||
Those tags are produced by building inside the official manylinux image and
|
|
||||||
running `auditwheel repair`. On Windows, use **Docker Desktop in Linux container
|
|
||||||
mode**.
|
|
||||||
|
|
||||||
> Note: we use `manylinux_2_28_x86_64` (not 2014). Recent NumPy/SciPy no longer
|
|
||||||
> provide manylinux2014 wheels and fail to build from source on that older GCC.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. Docker Desktop installed and running (**Linux containers**, not Windows containers).
|
|
||||||
2. Repository checked out locally (this tree is bind-mounted into the container).
|
|
||||||
3. Version bumped in `src/orisys/__init__.py` (`__version__`) before every upload.
|
|
||||||
PyPI never allows re-uploading the same filename/version.
|
|
||||||
4. PyPI / TestPyPI project-scoped API tokens (never commit tokens or `.pypirc` with secrets).
|
|
||||||
|
|
||||||
Quick Docker check:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker version --format "{{.Server.Os}}"
|
|
||||||
# expect: linux
|
|
||||||
docker run --rm hello-world
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build manylinux wheels (3.10 / 3.11 / 3.12)
|
|
||||||
|
|
||||||
From the repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python build/run.py manylinux
|
|
||||||
```
|
|
||||||
|
|
||||||
Or call the Docker helper directly:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\build\wheel\docker\run.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Default builds **all three** ABIs. To build a subset:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python build/run.py manylinux -PythonVersions 3.10,3.12
|
|
||||||
python build/run.py manylinux -PythonVersions 3.11
|
|
||||||
```
|
|
||||||
|
|
||||||
What this does:
|
|
||||||
|
|
||||||
1. Builds `orisys-manylinux` from [`build/wheel/docker/Dockerfile.manylinux`](../build/wheel/docker/Dockerfile.manylinux)
|
|
||||||
(`quay.io/pypa/manylinux_2_28_x86_64` with Nuitka/auditwheel in cp310/cp311/cp312).
|
|
||||||
2. Runs [`build/wheel/docker/entrypoint.sh`](../build/wheel/docker/entrypoint.sh), which loops:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/opt/python/cp310-cp310/bin/python build/wheel/build.py --target pypi-linux
|
|
||||||
/opt/python/cp311-cp311/bin/python build/wheel/build.py --target pypi-linux
|
|
||||||
/opt/python/cp312-cp312/bin/python build/wheel/build.py --target pypi-linux
|
|
||||||
```
|
|
||||||
|
|
||||||
3. For each ABI: Nuitka compile → wheel → `auditwheel repair` → isolated smoke test.
|
|
||||||
4. Artifacts land in:
|
|
||||||
|
|
||||||
- `dist/repaired/orisys-*-manylinux_2_28_x86_64.whl` (upload candidates)
|
|
||||||
- `SDK/<version>/` (same compliant wheels)
|
|
||||||
|
|
||||||
Expect roughly **3×** the wall time of a single-ABI build.
|
|
||||||
|
|
||||||
Rebuild without refreshing the image:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python build/run.py manylinux -SkipImageBuild
|
|
||||||
```
|
|
||||||
|
|
||||||
Equivalent manual `docker` invocation:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker build -f build/wheel/docker/Dockerfile.manylinux -t orisys-manylinux .
|
|
||||||
docker run --rm `
|
|
||||||
-e ORISYS_BUILD_TARGET=pypi-linux `
|
|
||||||
-e ORISYS_PYTHON_TAGS=cp310-cp310,cp311-cp311,cp312-cp312 `
|
|
||||||
-v "${PWD}:/src" `
|
|
||||||
orisys-manylinux
|
|
||||||
```
|
|
||||||
|
|
||||||
### Acceptance checks before upload
|
|
||||||
|
|
||||||
- Each filename contains `cp310` / `cp311` / `cp312` and `manylinux_2_28_x86_64`
|
|
||||||
(or another accepted manylinux tag such as `manylinux_2_17_x86_64`).
|
|
||||||
- Isolated smoke test printed `public API imports OK` for every ABI.
|
|
||||||
- Do **not** upload any wheel still tagged `linux_x86_64` or bare `manylinux_x86_64`.
|
|
||||||
|
|
||||||
Optional re-verify on a Linux machine:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python build/run.py verify dist/repaired/orisys-*-cp310-cp310-manylinux_*.whl
|
|
||||||
```
|
|
||||||
|
|
||||||
## Promote: TestPyPI then PyPI
|
|
||||||
|
|
||||||
Install upload tooling in a clean environment:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python -m pip install --upgrade twine
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1. Upload to TestPyPI
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:TWINE_USERNAME = "__token__"
|
|
||||||
$env:TWINE_PASSWORD = "<TestPyPI API token>"
|
|
||||||
|
|
||||||
python -m twine check dist/repaired/*.whl
|
|
||||||
python -m twine upload --repository testpypi dist/repaired/*.whl
|
|
||||||
```
|
|
||||||
|
|
||||||
Install from TestPyPI in a fresh venv (pull runtime deps from real PyPI), testing
|
|
||||||
each Python you care about:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
py -3.10 -m venv .venv-testpypi310
|
|
||||||
.\.venv-testpypi310\Scripts\Activate.ps1
|
|
||||||
python -m pip install --index-url https://test.pypi.org/simple/ `
|
|
||||||
--extra-index-url https://pypi.org/simple `
|
|
||||||
"orisys==<version>"
|
|
||||||
python -c "import orisys; print(orisys.__version__)"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Upload the same artifacts to PyPI
|
|
||||||
|
|
||||||
Only after TestPyPI install/import succeeds:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:TWINE_USERNAME = "__token__"
|
|
||||||
$env:TWINE_PASSWORD = "<PyPI API token>"
|
|
||||||
|
|
||||||
python -m twine upload dist/repaired/*.whl
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer CI **Trusted Publishing** (OIDC) for production uploads so long-lived tokens
|
|
||||||
are not stored on developer machines.
|
|
||||||
|
|
||||||
## Related local targets
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| `python build/run.py wheel` | Preferred wheel builder launcher |
|
|
||||||
| `python build/wheel/build.py` | Direct wheel builder (current host) |
|
|
||||||
| `python build/run.py wheel --target pypi-linux` | Linux-only; requires manylinux + CPython 3.10–3.12 |
|
|
||||||
| `python build/run.py wheel-win` | Windows wheels for py310/py311/py312 conda envs |
|
|
||||||
| `python build/run.py manylinux` | manylinux Docker runner |
|
|
||||||
| `python build/run.py gui finger` | ORY-FINGER standalone GUI |
|
|
||||||
| `python -m build` | Pure-Python / sdist path from `pyproject.toml` (no Nuitka) |
|
|
||||||
|
|
||||||
See also [`build/README.md`](../build/README.md) for the target-oriented layout.
|
|
||||||
|
|
||||||
Windows binary wheels and Linux manylinux wheels must be built on their respective
|
|
||||||
platforms. Never rename a platform tag to bypass PyPI validation.
|
|
||||||
+13
-17
@@ -1,54 +1,50 @@
|
|||||||
# Examples
|
# Examples
|
||||||
|
|
||||||
Product-facing demos live under `examples/products/`. Shared helpers stay in
|
Runnable demos live at the `examples/` root. Shared helpers stay in
|
||||||
`pipeline/`, `viz/`, and `gui/`. Run commands from the repository root.
|
`pipeline/`, `viz/`, and `gui/`. Run commands from the repository root.
|
||||||
|
|
||||||
## Product Entry Points
|
## Product Entry Points
|
||||||
|
|
||||||
| Product | Entry | Default config |
|
| Product | Entry | Default config |
|
||||||
|---------|-------|----------------|
|
|---------|-------|----------------|
|
||||||
| ORY-BOX | `products/ory_box/basic_pipeline.py` | `ddjx01` |
|
| ORY-BOX | `basic_pipeline.py` | `box` |
|
||||||
| ORY-MINI | `products/ory_mini/basic_pipeline.py` | `mini` |
|
| ORY-MINI | `mini_pipeline.py` | `mini` |
|
||||||
| ORY-FINGER (2D) | `products/ory_finger/finger_pipeline.py` | `dd02` |
|
| ORY-FINGER (2D) | `finger_pipeline.py` | `finger` |
|
||||||
| ORY-FINGER (3D Qt) | `products/ory_finger/finger_pipeline_3dview.py` | `dd02` |
|
| ORY-FINGER (3D Qt) | `finger_pipeline_3dview.py` | `finger` |
|
||||||
|
|
||||||
`dd02` is the stable ORY-FINGER alias and currently resolves to `dd02-ov`.
|
`finger` is the public ORY-FINGER config stem. `dd02` remains a compatibility alias for `dd02-ov`.
|
||||||
|
|
||||||
Compatibility wrappers still exist at the `examples/` root
|
|
||||||
(`basic_pipeline.py`, `finger_pipeline.py`, `finger_pipeline_3dview.py`) and
|
|
||||||
forward to the product entries above.
|
|
||||||
|
|
||||||
## Typical Usage
|
## Typical Usage
|
||||||
|
|
||||||
ORY-BOX:
|
ORY-BOX:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
|
python examples/basic_pipeline.py -v 0 -c box
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-MINI:
|
ORY-MINI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/products/ory_mini/basic_pipeline.py -v 0 -c mini
|
python examples/mini_pipeline.py -v 0 -c mini
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-FINGER 2D:
|
ORY-FINGER 2D:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
|
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-FINGER video file:
|
ORY-FINGER video file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4 --config dd02
|
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-FINGER 3D Qt dashboard:
|
ORY-FINGER 3D Qt dashboard:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e ".[gui]"
|
pip install -e ".[gui]"
|
||||||
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
|
python examples/finger_pipeline_3dview.py --video 0 --config finger
|
||||||
```
|
```
|
||||||
|
|
||||||
## Other Root Examples
|
## Other Root Examples
|
||||||
@@ -70,10 +66,10 @@ pip install -e ".[gui]"
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `products/ory_finger/finger_pipeline_3dview.py` is the main interactive finger demo.
|
- `finger_pipeline_3dview.py` is the main interactive finger demo.
|
||||||
It uses `gui/app_paths.py` so the same entry works in editable and packaged runs.
|
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**
|
- 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,
|
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.
|
`S` starts video recording, `E` stops video recording, and `Esc` toggles fullscreen.
|
||||||
- `products/ory_finger/finger_pipeline.py` is the leanest finger example if you want to
|
- `finger_pipeline.py` is the leanest finger example if you want to
|
||||||
validate UV unroll + force/contact processing without the full dashboard.
|
validate UV unroll + force/contact processing without the full dashboard.
|
||||||
|
|||||||
+107
-13
@@ -1,21 +1,115 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Compatibility wrapper for ORY-BOX.
|
|
||||||
|
|
||||||
Prefer:
|
|
||||||
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
|
|
||||||
"""
|
"""
|
||||||
|
ORY-BOX:单传感器基础流程
|
||||||
|
|
||||||
from __future__ import annotations
|
本示例演示 Orisys SDK 的标准处理流程:
|
||||||
|
1. 初始化传感器(摄像头或视频文件)
|
||||||
|
2. 获取图像并计算形变
|
||||||
|
3. 按需计算接触区域
|
||||||
|
4. 读取并可视化结果
|
||||||
|
|
||||||
import sys
|
SDK version: 0.4.3
|
||||||
from pathlib import Path
|
"""
|
||||||
|
import os
|
||||||
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
|
import cv2
|
||||||
|
import orisys
|
||||||
|
import numpy as np
|
||||||
|
import argparse
|
||||||
|
|
||||||
_EXAMPLES_DIR = Path(__file__).resolve().parent
|
def main():
|
||||||
if str(_EXAMPLES_DIR) not in sys.path:
|
parser = argparse.ArgumentParser()
|
||||||
sys.path.insert(0, str(_EXAMPLES_DIR))
|
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="是否输出详细日志")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
from products.ory_box.basic_pipeline import main
|
# 步骤 1:创建传感器对象(必需)
|
||||||
|
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
|
||||||
|
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
|
||||||
|
|
||||||
|
# 输入既可以是摄像头编号,也可以是视频文件路径
|
||||||
|
try:
|
||||||
|
input = int(args.video)
|
||||||
|
is_camera = True
|
||||||
|
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
|
||||||
|
except:
|
||||||
|
is_camera = False
|
||||||
|
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
|
||||||
|
|
||||||
|
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:读取结果
|
||||||
|
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = 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
|
||||||
|
)
|
||||||
|
|
||||||
|
# 步骤 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}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
|
||||||
|
f"切向力Y={fsheary:.4f}, 深度图尺寸={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__":
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
+100
-10
@@ -1,20 +1,110 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Compatibility wrapper for ORY-FINGER 2D pipeline.
|
"""
|
||||||
|
ORY-FINGER:Minimum UV-unroll finger tactile pipeline.
|
||||||
|
|
||||||
Prefer:
|
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
|
||||||
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
|
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``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
import argparse
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_EXAMPLES_DIR = Path(__file__).resolve().parent
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||||
if str(_EXAMPLES_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_EXAMPLES_DIR))
|
|
||||||
|
|
||||||
from products.ory_finger.finger_pipeline import main
|
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", 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,
|
||||||
|
)
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,20 +1,90 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Compatibility wrapper for ORY-FINGER 3D Qt dashboard.
|
"""
|
||||||
|
ORY-FINGER:UV-unroll finger tactile pipeline with unified Qt dashboard.
|
||||||
|
|
||||||
Prefer:
|
Where to customize the GUI:
|
||||||
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
|
Layout (sizes, proportions) -> examples/gui/viewer3d/layout.py
|
||||||
|
Colors / QSS -> examples/gui/viewer3d/styles.py
|
||||||
|
Widget rendering logic -> examples/gui/viewer3d/widgets/
|
||||||
|
Window behavior -> examples/gui/viewer3d/dashboard.py
|
||||||
|
|
||||||
|
Recommended entry:
|
||||||
|
python examples/finger_pipeline_3dview.py --video 0 --config finger
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
import argparse
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||||
|
|
||||||
|
# Make shared packages importable when launched as a file path.
|
||||||
_EXAMPLES_DIR = Path(__file__).resolve().parent
|
_EXAMPLES_DIR = Path(__file__).resolve().parent
|
||||||
if str(_EXAMPLES_DIR) not in sys.path:
|
if str(_EXAMPLES_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(_EXAMPLES_DIR))
|
sys.path.insert(0, str(_EXAMPLES_DIR))
|
||||||
|
|
||||||
from products.ory_finger.finger_pipeline_3dview import main
|
from gui.app_paths import (
|
||||||
|
configure_frozen_runtime,
|
||||||
|
default_config_path,
|
||||||
|
ensure_examples_import_path,
|
||||||
|
require_gui_extras,
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_frozen_runtime()
|
||||||
|
ensure_examples_import_path()
|
||||||
|
require_gui_extras()
|
||||||
|
|
||||||
|
from gui import run_dashboard
|
||||||
|
from pipeline import PipelineConfig
|
||||||
|
from viz import DEFAULT_FINGER_VIZ_POSE
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="UV-unroll finger tactile pipeline with unified Qt dashboard"
|
||||||
|
)
|
||||||
|
parser.add_argument("--video", "-v", default="0", help="Camera index or video file path")
|
||||||
|
parser.add_argument("--config", "-c", default=default_config_path(), help="Orisys SDK config")
|
||||||
|
parser.add_argument("--cal", default="", help="Optional calibration file path")
|
||||||
|
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
|
||||||
|
parser.add_argument(
|
||||||
|
"--mesh-map",
|
||||||
|
choices=["flow", "depth"],
|
||||||
|
default="depth",
|
||||||
|
help="Color the 3D mesh from flow magnitude or contact depth.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--threshold", type=float, default=None, help="Mesh/arrow threshold")
|
||||||
|
parser.add_argument("--no-3d", action="store_true", help="Disable 3D mesh view")
|
||||||
|
parser.add_argument(
|
||||||
|
"--viz-pose",
|
||||||
|
default=DEFAULT_FINGER_VIZ_POSE,
|
||||||
|
help="world_to_cam.npy for the default view; pass 'none' to skip",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cutoff",
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
help="Low-pass cutoff for force curves (Hz)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--profile",
|
||||||
|
action="store_true",
|
||||||
|
help="Print per-stage timing to the log panel / console",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--profile-every",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="When --profile is set, print rolling averages every N frames",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = _parse_args()
|
||||||
|
config = PipelineConfig.from_args(args)
|
||||||
|
sys.exit(run_dashboard(config))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -27,8 +27,17 @@ def resource_path(*parts: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def default_config_path() -> str:
|
def default_config_path() -> str:
|
||||||
"""Default ORY-FINGER config name (resolves to dd02-ov via alias)."""
|
"""Default ORY-FINGER config name (packaged stem ``finger``)."""
|
||||||
return "dd02"
|
return "finger"
|
||||||
|
|
||||||
|
|
||||||
|
def require_gui_extras() -> None:
|
||||||
|
try:
|
||||||
|
import open3d # noqa: F401
|
||||||
|
from PyQt5 import QtWidgets # noqa: F401
|
||||||
|
import pyqtgraph # noqa: F401
|
||||||
|
except ImportError as exc:
|
||||||
|
raise SystemExit('GUI example requires: pip install "orisys[gui]"') from exc
|
||||||
|
|
||||||
|
|
||||||
def configure_frozen_runtime() -> None:
|
def configure_frozen_runtime() -> None:
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"video_source": "D:/repo/orisys-sdk/data/dd02/0607_1.mp4",
|
"video_source": "D:/repo/orisys-sdk/data/dd02-0703/1.mp4",
|
||||||
"config_path": "./config/dd02-ov.json",
|
"sensor_bundle_dir": "sensors/finger",
|
||||||
"finger_assets_dir": "D:/repo/orisys-sdk/assets/finger/30fps-0703",
|
"config_path": "sensors/finger/sensor.json",
|
||||||
|
"finger_assets_dir": "sensors/finger/assets/30fps-0703",
|
||||||
"csv_save_dir": "./outputs/csv",
|
"csv_save_dir": "./outputs/csv",
|
||||||
"show_log": true,
|
"show_log": true,
|
||||||
"show_flow_arrows": false,
|
"show_flow_arrows": false,
|
||||||
"arrow_background": "pure_color",
|
"arrow_background": "unrolled",
|
||||||
"free_rotate": true
|
"free_rotate": true,
|
||||||
|
"log_display_mode": "hidden"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Any, Optional
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import orisys
|
import orisys
|
||||||
|
from orisys.signal_processing import calibrate_normal_force
|
||||||
|
|
||||||
from .roi_geometry import (
|
from .roi_geometry import (
|
||||||
build_roi_warp,
|
build_roi_warp,
|
||||||
@@ -22,10 +23,6 @@ from .roi_geometry import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
|
|
||||||
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FrameSnapshot:
|
class FrameSnapshot:
|
||||||
"""Normalized per-frame payload consumed by the Qt viewer."""
|
"""Normalized per-frame payload consumed by the Qt viewer."""
|
||||||
@@ -49,11 +46,6 @@ class FrameSnapshot:
|
|||||||
record_frame: Optional[np.ndarray] = None
|
record_frame: Optional[np.ndarray] = None
|
||||||
|
|
||||||
|
|
||||||
def calibrate_normal_force(raw_value: float) -> float:
|
|
||||||
"""Convert raw normal-force reading to Newtons using y = mx + c."""
|
|
||||||
return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET
|
|
||||||
|
|
||||||
|
|
||||||
class SensorAdapter(ABC):
|
class SensorAdapter(ABC):
|
||||||
"""Common interface between Qt and concrete sensor backends."""
|
"""Common interface between Qt and concrete sensor backends."""
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from typing import Literal, Optional, Sequence, Tuple
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import orisys
|
import orisys
|
||||||
from scipy.signal import butter, filtfilt
|
from orisys.signal_processing import lowpass_filter
|
||||||
|
|
||||||
FORCE_SAMPLE_RATE_HZ = 30.0
|
FORCE_SAMPLE_RATE_HZ = 30.0
|
||||||
PLOT_BUFFER_POINTS = 120
|
PLOT_BUFFER_POINTS = 120
|
||||||
@@ -61,32 +61,6 @@ class ForcePlotState:
|
|||||||
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(
|
|
||||||
data,
|
|
||||||
cutoff_hz: float,
|
|
||||||
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
|
|
||||||
order: int = 2,
|
|
||||||
) -> np.ndarray:
|
|
||||||
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data."""
|
|
||||||
y = np.asarray(data, dtype=np.float64)
|
|
||||||
if cutoff_hz == 0:
|
|
||||||
return y.copy()
|
|
||||||
if y.size == 0:
|
|
||||||
return y
|
|
||||||
|
|
||||||
nyquist = 0.5 * sample_rate_hz
|
|
||||||
if cutoff_hz < 0 or cutoff_hz >= nyquist:
|
|
||||||
raise ValueError(
|
|
||||||
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
|
|
||||||
)
|
|
||||||
|
|
||||||
b, a = butter(order, cutoff_hz / nyquist, btype="low")
|
|
||||||
padlen = 3 * (max(len(b), len(a)) - 1)
|
|
||||||
if y.size <= padlen:
|
|
||||||
return y.copy()
|
|
||||||
return filtfilt(b, a, y)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
||||||
if img is None:
|
if img is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class QtViewerWindow(QMainWindow):
|
|||||||
self.btn_config.setObjectName("btnConfig")
|
self.btn_config.setObjectName("btnConfig")
|
||||||
self.btn_config.clicked.connect(self._select_config)
|
self.btn_config.clicked.connect(self._select_config)
|
||||||
bar.addWidget(self.btn_config)
|
bar.addWidget(self.btn_config)
|
||||||
self.config_edit = QLineEdit("./config/ddjx01.json")
|
self.config_edit = QLineEdit("box")
|
||||||
bar.addWidget(self.config_edit, 1)
|
bar.addWidget(self.config_edit, 1)
|
||||||
|
|
||||||
self.btn_reset = QPushButton("重置")
|
self.btn_reset = QPushButton("重置")
|
||||||
|
|||||||
@@ -2,14 +2,19 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import math
|
import math
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
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 (
|
||||||
|
QAction,
|
||||||
|
QActionGroup,
|
||||||
QApplication,
|
QApplication,
|
||||||
|
QDockWidget,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
@@ -61,6 +66,7 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
self._frame_index = 0
|
self._frame_index = 0
|
||||||
self._wall_fps = 0.0
|
self._wall_fps = 0.0
|
||||||
self._wall_fps_alpha = 0.15
|
self._wall_fps_alpha = 0.15
|
||||||
|
self._log_display_mode = "hidden"
|
||||||
|
|
||||||
self.setWindowTitle(layout.window_title)
|
self.setWindowTitle(layout.window_title)
|
||||||
self.setStyleSheet(STYLE)
|
self.setStyleSheet(STYLE)
|
||||||
@@ -84,7 +90,7 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
root_layout.setSpacing(layout.root_spacing_px)
|
root_layout.setSpacing(layout.root_spacing_px)
|
||||||
|
|
||||||
setup = layout.panel_setup
|
setup = layout.panel_setup
|
||||||
self._log_panel_visible = setup.log_panel.enabled
|
self._log_panel_visible = False
|
||||||
self.mesh_state = MeshViewState.from_layout(layout)
|
self.mesh_state = MeshViewState.from_layout(layout)
|
||||||
self.mesh_viewport = MeshViewportWidget(layout=layout, mesh_state=self.mesh_state)
|
self.mesh_viewport = MeshViewportWidget(layout=layout, mesh_state=self.mesh_state)
|
||||||
arrows_slot = layout.slot("arrows")
|
arrows_slot = layout.slot("arrows")
|
||||||
@@ -111,7 +117,6 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
|
|
||||||
self._preferences = load_preferences()
|
self._preferences = load_preferences()
|
||||||
self.settings_panel.apply_preference_state(self._preferences)
|
self.settings_panel.apply_preference_state(self._preferences)
|
||||||
self._log_panel_visible = bool(self.settings_panel.show_log_check.isChecked())
|
|
||||||
self.mesh_viewport.set_free_rotation(bool(self.settings_panel.free_rotate_check.isChecked()))
|
self.mesh_viewport.set_free_rotation(bool(self.settings_panel.free_rotate_check.isChecked()))
|
||||||
if self.arrow_panel is not None:
|
if self.arrow_panel is not None:
|
||||||
self.arrow_panel.set_background(self.settings_panel.arrow_background())
|
self.arrow_panel.set_background(self.settings_panel.arrow_background())
|
||||||
@@ -152,11 +157,6 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
|
|
||||||
self.charts_column = QSplitter(Qt.Vertical)
|
self.charts_column = QSplitter(Qt.Vertical)
|
||||||
self.charts_column.addWidget(self.force_charts.widget)
|
self.charts_column.addWidget(self.force_charts.widget)
|
||||||
if setup.log_panel.enabled:
|
|
||||||
self.charts_column.addWidget(self.log_widget)
|
|
||||||
self.log_widget.setVisible(self._log_panel_visible)
|
|
||||||
self.charts_column.setStretchFactor(0, setup.charts_log_stretch)
|
|
||||||
self.charts_column.setStretchFactor(1, setup.log_stretch)
|
|
||||||
self.charts_column.setChildrenCollapsible(False)
|
self.charts_column.setChildrenCollapsible(False)
|
||||||
|
|
||||||
h_splitter = QSplitter(Qt.Horizontal)
|
h_splitter = QSplitter(Qt.Horizontal)
|
||||||
@@ -172,18 +172,104 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
self.h_splitter = h_splitter
|
self.h_splitter = h_splitter
|
||||||
|
|
||||||
root_layout.addWidget(h_splitter)
|
root_layout.addWidget(h_splitter)
|
||||||
|
self._setup_log_dock()
|
||||||
|
self._apply_log_display_mode(self._preferences.get("log_display_mode", "hidden"))
|
||||||
|
|
||||||
self.resize(*self._target_window_size())
|
self.resize(*self._target_window_size())
|
||||||
QTimer.singleShot(0, self._apply_panel_layout)
|
QTimer.singleShot(0, self._apply_panel_layout)
|
||||||
|
|
||||||
def _set_log_panel_visible(self, visible: bool) -> None:
|
def _set_log_panel_visible(self, visible: bool) -> None:
|
||||||
self._log_panel_visible = bool(visible)
|
self._log_panel_visible = bool(visible)
|
||||||
self.log_widget.setVisible(self._log_panel_visible)
|
if hasattr(self, "log_dock"):
|
||||||
|
self.log_dock.setVisible(self._log_panel_visible)
|
||||||
QTimer.singleShot(0, self._apply_panel_layout)
|
QTimer.singleShot(0, self._apply_panel_layout)
|
||||||
|
|
||||||
|
def _setup_log_dock(self) -> None:
|
||||||
|
self.log_dock = QDockWidget("Log", self)
|
||||||
|
self.log_dock.setObjectName("logDock")
|
||||||
|
self.log_dock.setWidget(self.log_widget)
|
||||||
|
self.log_dock.setAllowedAreas(Qt.BottomDockWidgetArea | Qt.RightDockWidgetArea)
|
||||||
|
self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock)
|
||||||
|
self.log_dock.hide()
|
||||||
|
self.log_dock.visibilityChanged.connect(self._on_log_dock_visibility_changed)
|
||||||
|
|
||||||
|
view_menu = self.menuBar().addMenu("View")
|
||||||
|
self.log_toggle_action = QAction("Log Panel", self)
|
||||||
|
self.log_toggle_action.setCheckable(True)
|
||||||
|
self.log_toggle_action.triggered.connect(self._on_log_toggle_action)
|
||||||
|
view_menu.addAction(self.log_toggle_action)
|
||||||
|
|
||||||
|
log_content_menu = view_menu.addMenu("Log Content")
|
||||||
|
self.log_content_actions = QActionGroup(self)
|
||||||
|
self.log_content_actions.setExclusive(False)
|
||||||
|
for label, mode in (
|
||||||
|
("Runtime log", "runtime"),
|
||||||
|
("Bundle info", "bundle"),
|
||||||
|
("Performance/profile", "profile"),
|
||||||
|
):
|
||||||
|
action = QAction(label, self)
|
||||||
|
action.setCheckable(True)
|
||||||
|
action.setData(mode)
|
||||||
|
action.triggered.connect(lambda _checked=False, m=mode: self._apply_log_display_mode(m, save=True))
|
||||||
|
self.log_content_actions.addAction(action)
|
||||||
|
log_content_menu.addAction(action)
|
||||||
|
|
||||||
|
def _on_log_toggle_action(self, checked: bool) -> None:
|
||||||
|
if checked:
|
||||||
|
mode = self._log_display_mode if self._log_display_mode != "hidden" else "runtime"
|
||||||
|
self._apply_log_display_mode(mode, save=True)
|
||||||
|
else:
|
||||||
|
self._apply_log_display_mode("hidden", save=True)
|
||||||
|
|
||||||
|
def _on_log_dock_visibility_changed(self, visible: bool) -> None:
|
||||||
|
self._log_panel_visible = bool(visible)
|
||||||
|
if hasattr(self, "log_toggle_action"):
|
||||||
|
self.log_toggle_action.blockSignals(True)
|
||||||
|
self.log_toggle_action.setChecked(visible)
|
||||||
|
self.log_toggle_action.blockSignals(False)
|
||||||
|
if not visible and self._log_display_mode != "hidden":
|
||||||
|
self._log_display_mode = "hidden"
|
||||||
|
if hasattr(self, "log_content_actions"):
|
||||||
|
for action in self.log_content_actions.actions():
|
||||||
|
action.blockSignals(True)
|
||||||
|
action.setChecked(False)
|
||||||
|
action.blockSignals(False)
|
||||||
|
self._save_preferences()
|
||||||
|
|
||||||
|
def _apply_log_display_mode(self, mode: str | None, *, save: bool = False) -> None:
|
||||||
|
mode = mode or "hidden"
|
||||||
|
self._log_display_mode = mode
|
||||||
|
if hasattr(self, "log_toggle_action"):
|
||||||
|
self.log_toggle_action.blockSignals(True)
|
||||||
|
self.log_toggle_action.setChecked(mode != "hidden")
|
||||||
|
self.log_toggle_action.blockSignals(False)
|
||||||
|
if hasattr(self, "log_content_actions"):
|
||||||
|
for action in self.log_content_actions.actions():
|
||||||
|
action.blockSignals(True)
|
||||||
|
action.setChecked(action.data() == mode)
|
||||||
|
action.blockSignals(False)
|
||||||
|
if mode == "hidden":
|
||||||
|
self._set_log_panel_visible(False)
|
||||||
|
if save:
|
||||||
|
self._save_preferences()
|
||||||
|
return
|
||||||
|
if mode == "bundle":
|
||||||
|
self.log_widget.setPlainText(
|
||||||
|
"Sensor bundle\n"
|
||||||
|
f"Config: {self.settings_panel.config_path()}\n"
|
||||||
|
f"Assets: {self.settings_panel.finger_export_dir()}\n"
|
||||||
|
)
|
||||||
|
elif mode == "profile":
|
||||||
|
self.log_widget.setPlainText(
|
||||||
|
"Performance/profile\n"
|
||||||
|
"Run with --profile to print per-stage timings in the runtime log.\n"
|
||||||
|
)
|
||||||
|
self._set_log_panel_visible(True)
|
||||||
|
if save:
|
||||||
|
self._save_preferences()
|
||||||
|
|
||||||
def _on_log_visibility_changed(self, visible: bool) -> None:
|
def _on_log_visibility_changed(self, visible: bool) -> None:
|
||||||
self._set_log_panel_visible(visible)
|
self._apply_log_display_mode("runtime" if visible else "hidden", save=True)
|
||||||
self._save_preferences()
|
|
||||||
|
|
||||||
def _on_free_rotation_changed(self, enabled: bool) -> None:
|
def _on_free_rotation_changed(self, enabled: bool) -> None:
|
||||||
self.mesh_viewport.set_free_rotation(enabled)
|
self.mesh_viewport.set_free_rotation(enabled)
|
||||||
@@ -196,6 +282,7 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
|
|
||||||
def _save_preferences(self) -> None:
|
def _save_preferences(self) -> None:
|
||||||
prefs = self.settings_panel.preference_state()
|
prefs = self.settings_panel.preference_state()
|
||||||
|
prefs["log_display_mode"] = self._log_display_mode
|
||||||
self._preferences = prefs
|
self._preferences = prefs
|
||||||
save_preferences(prefs)
|
save_preferences(prefs)
|
||||||
# Keep in-memory pipeline config aligned with panel values.
|
# Keep in-memory pipeline config aligned with panel values.
|
||||||
@@ -217,7 +304,6 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
scale = self._layout_scale_factor()
|
scale = self._layout_scale_factor()
|
||||||
column_height = self._scaled_top_row_height(scale)
|
column_height = self._scaled_top_row_height(scale)
|
||||||
charts_slot = self.layout.top_slot("charts")
|
charts_slot = self.layout.top_slot("charts")
|
||||||
log_slot = setup.log_panel
|
|
||||||
|
|
||||||
for slot in setup.top_row.slots:
|
for slot in setup.top_row.slots:
|
||||||
if not slot.enabled:
|
if not slot.enabled:
|
||||||
@@ -236,27 +322,11 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
settings_height,
|
settings_height,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
self.force_charts.set_panel_size(
|
||||||
self._log_panel_visible
|
self._scaled_px(charts_slot.width_px, scale),
|
||||||
and log_slot.enabled
|
column_height,
|
||||||
and log_slot.height_px > 0
|
)
|
||||||
):
|
self.charts_column.setSizes([column_height])
|
||||||
self.log_widget.show()
|
|
||||||
log_height = self._scaled_px(log_slot.height_px, scale)
|
|
||||||
charts_height = self._scaled_px(charts_slot.height_px, scale)
|
|
||||||
self.log_widget.setFixedHeight(log_height)
|
|
||||||
self.force_charts.set_panel_size(
|
|
||||||
self._scaled_px(charts_slot.width_px, scale),
|
|
||||||
charts_height,
|
|
||||||
)
|
|
||||||
self.charts_column.setSizes([charts_height, log_height])
|
|
||||||
else:
|
|
||||||
self.log_widget.hide()
|
|
||||||
self.force_charts.set_panel_size(
|
|
||||||
self._scaled_px(charts_slot.width_px, scale),
|
|
||||||
column_height,
|
|
||||||
)
|
|
||||||
self.charts_column.setSizes([column_height, 0])
|
|
||||||
|
|
||||||
self.h_splitter.setSizes(
|
self.h_splitter.setSizes(
|
||||||
[self._scaled_px(slot.width_px, scale) for slot in setup.top_row.slots if slot.enabled]
|
[self._scaled_px(slot.width_px, scale) for slot in setup.top_row.slots if slot.enabled]
|
||||||
@@ -279,12 +349,6 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
width += margins.left() + margins.right()
|
width += margins.left() + margins.right()
|
||||||
|
|
||||||
height = layout.charts_column_height_px()
|
height = layout.charts_column_height_px()
|
||||||
if (
|
|
||||||
self._log_panel_visible
|
|
||||||
and layout.panel_setup.log_panel.enabled
|
|
||||||
and layout.panel_setup.log_panel.height_px > 0
|
|
||||||
):
|
|
||||||
height += self.charts_column.handleWidth()
|
|
||||||
height += margins.top() + margins.bottom()
|
height += margins.top() + margins.bottom()
|
||||||
|
|
||||||
return max(layout.window_width, width), max(layout.window_height, height)
|
return max(layout.window_width, width), max(layout.window_height, height)
|
||||||
@@ -316,12 +380,6 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
base_width += max(len(enabled_slots) - 1, 0) * self.h_splitter.handleWidth()
|
base_width += max(len(enabled_slots) - 1, 0) * self.h_splitter.handleWidth()
|
||||||
|
|
||||||
base_height = self.layout.charts_column_height_px()
|
base_height = self.layout.charts_column_height_px()
|
||||||
if (
|
|
||||||
self._log_panel_visible
|
|
||||||
and self.layout.panel_setup.log_panel.enabled
|
|
||||||
and self.layout.panel_setup.log_panel.height_px > 0
|
|
||||||
):
|
|
||||||
base_height += self.charts_column.handleWidth()
|
|
||||||
if base_width <= 0 or base_height <= 0:
|
if base_width <= 0 or base_height <= 0:
|
||||||
return 1.0
|
return 1.0
|
||||||
return min(available_width / base_width, available_height / base_height)
|
return min(available_width / base_width, available_height / base_height)
|
||||||
@@ -602,12 +660,27 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
def _select_finger_assets(self) -> None:
|
def _select_finger_assets(self) -> None:
|
||||||
path = QFileDialog.getExistingDirectory(
|
path = QFileDialog.getExistingDirectory(
|
||||||
self,
|
self,
|
||||||
"选择 finger 资产目录",
|
"选择传感器 bundle 目录",
|
||||||
self.settings_panel.finger_export_dir() or "assets/finger",
|
self.settings_panel.sensor_bundle_dir() or "sensors/finger",
|
||||||
)
|
)
|
||||||
if path:
|
if path:
|
||||||
self.settings_panel.set_finger_export_dir(path)
|
bundle_dir = Path(path)
|
||||||
self.config.export_dir = path
|
config_path = bundle_dir / "sensor.json"
|
||||||
|
manifest_path = bundle_dir / "manifest.json"
|
||||||
|
assets_dir = bundle_dir / "assets" / "30fps-0703"
|
||||||
|
if manifest_path.is_file():
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
manifest = {}
|
||||||
|
rel_assets = manifest.get("assets_dir")
|
||||||
|
if isinstance(rel_assets, str) and rel_assets.strip():
|
||||||
|
assets_dir = bundle_dir / rel_assets
|
||||||
|
if config_path.is_file():
|
||||||
|
self.settings_panel.set_config_path(str(config_path))
|
||||||
|
self.config.config = str(config_path)
|
||||||
|
self.settings_panel.set_finger_export_dir(str(assets_dir))
|
||||||
|
self.config.export_dir = str(assets_dir)
|
||||||
self._save_preferences()
|
self._save_preferences()
|
||||||
|
|
||||||
def _select_csv_dir(self) -> None:
|
def _select_csv_dir(self) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Finger-compatible sensor-bundle presets for the 3D dashboard."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_root_candidates() -> list[Path]:
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
return [
|
||||||
|
Path.cwd(),
|
||||||
|
here.parents[3], # examples/gui/viewer3d -> repo root
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SensorBundlePreset:
|
||||||
|
label: str
|
||||||
|
bundle_dir: str
|
||||||
|
config: str
|
||||||
|
export_dir: str
|
||||||
|
|
||||||
|
|
||||||
|
def _load_manifest(bundle_root: Path) -> dict:
|
||||||
|
path = bundle_root / "manifest.json"
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_from_root(bundle_root: Path, *, label: str | None = None) -> SensorBundlePreset | None:
|
||||||
|
sensor_json = bundle_root / "sensor.json"
|
||||||
|
if not sensor_json.is_file():
|
||||||
|
return None
|
||||||
|
manifest = _load_manifest(bundle_root)
|
||||||
|
assets_dir = manifest.get("assets_dir", "assets/30fps-0703")
|
||||||
|
if not isinstance(assets_dir, str) or not assets_dir.strip():
|
||||||
|
assets_dir = "assets/30fps-0703"
|
||||||
|
rel_bundle = bundle_root.as_posix()
|
||||||
|
for root in _repo_root_candidates():
|
||||||
|
try:
|
||||||
|
rel_bundle = bundle_root.relative_to(root).as_posix()
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return SensorBundlePreset(
|
||||||
|
label=label or "ORY-FINGER",
|
||||||
|
bundle_dir=rel_bundle,
|
||||||
|
config=f"{rel_bundle}/sensor.json",
|
||||||
|
export_dir=f"{rel_bundle}/{assets_dir}".replace("\\", "/"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_finger_bundle() -> SensorBundlePreset:
|
||||||
|
for root in _repo_root_candidates():
|
||||||
|
preset = _bundle_from_root(root / "sensors" / "finger", label="ORY-FINGER")
|
||||||
|
if preset is not None:
|
||||||
|
return preset
|
||||||
|
return SensorBundlePreset(
|
||||||
|
label="ORY-FINGER",
|
||||||
|
bundle_dir="sensors/finger",
|
||||||
|
config="sensors/finger/sensor.json",
|
||||||
|
export_dir=DEFAULT_FINGER_EXPORT_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_sensor_bundle_presets() -> list[SensorBundlePreset]:
|
||||||
|
"""Return finger-compatible sensor bundle presets."""
|
||||||
|
presets = [default_finger_bundle()]
|
||||||
|
known_dirs = {presets[0].bundle_dir.replace("\\", "/")}
|
||||||
|
|
||||||
|
for root in _repo_root_candidates():
|
||||||
|
sensors_root = root / "sensors"
|
||||||
|
if not sensors_root.is_dir():
|
||||||
|
continue
|
||||||
|
for child in sorted(sensors_root.iterdir()):
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
if child.name != "finger":
|
||||||
|
continue
|
||||||
|
preset = _bundle_from_root(child, label="ORY-FINGER")
|
||||||
|
if preset is None:
|
||||||
|
continue
|
||||||
|
key = preset.bundle_dir.replace("\\", "/")
|
||||||
|
if key in known_dirs:
|
||||||
|
continue
|
||||||
|
presets.append(preset)
|
||||||
|
known_dirs.add(key)
|
||||||
|
break
|
||||||
|
|
||||||
|
return presets
|
||||||
|
|
||||||
|
|
||||||
|
def sensor_bundle_preset_index(
|
||||||
|
config: str,
|
||||||
|
export_dir: str,
|
||||||
|
presets: list[SensorBundlePreset],
|
||||||
|
) -> int:
|
||||||
|
config = (config or "").strip().replace("\\", "/")
|
||||||
|
export_dir = (export_dir or "").strip().replace("\\", "/")
|
||||||
|
for idx, preset in enumerate(presets):
|
||||||
|
if config == preset.config and export_dir == preset.export_dir:
|
||||||
|
return idx
|
||||||
|
if config.endswith("/sensor.json") and export_dir.endswith("/" + Path(preset.export_dir).name):
|
||||||
|
return idx
|
||||||
|
return -1
|
||||||
@@ -15,11 +15,15 @@ from PyQt5.QtWidgets import (
|
|||||||
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 ..layout import DashboardLayout, LAYOUT
|
||||||
from ..mesh_view_state import MeshViewState
|
from ..mesh_view_state import MeshViewState
|
||||||
|
from ..presets import (
|
||||||
|
SensorBundlePreset,
|
||||||
|
discover_sensor_bundle_presets,
|
||||||
|
sensor_bundle_preset_index,
|
||||||
|
)
|
||||||
from ..transport_icons import reset_icon, transport_icon
|
from ..transport_icons import reset_icon, transport_icon
|
||||||
|
|
||||||
_PLAY_PAUSE_BUTTON_NAME = "btnPlayPause"
|
_PLAY_PAUSE_BUTTON_NAME = "btnPlayPause"
|
||||||
@@ -86,7 +90,7 @@ class SettingsPanelWidget(QWidget):
|
|||||||
self.show_log_check = QCheckBox("Show log panel")
|
self.show_log_check = QCheckBox("Show log panel")
|
||||||
self.show_log_check.setChecked(show_log)
|
self.show_log_check.setChecked(show_log)
|
||||||
self.show_log_check.toggled.connect(self.log_visibility_changed.emit)
|
self.show_log_check.toggled.connect(self.log_visibility_changed.emit)
|
||||||
root.addWidget(self.show_log_check)
|
self.show_log_check.hide()
|
||||||
|
|
||||||
self.show_flow_arrows_check = QCheckBox("Show displacement arrows")
|
self.show_flow_arrows_check = QCheckBox("Show displacement arrows")
|
||||||
self.show_flow_arrows_check.setChecked(self._mesh_state.flow_arrows_enabled)
|
self.show_flow_arrows_check.setChecked(self._mesh_state.flow_arrows_enabled)
|
||||||
@@ -134,35 +138,38 @@ class SettingsPanelWidget(QWidget):
|
|||||||
self._video_file_path = ""
|
self._video_file_path = ""
|
||||||
self._updating_camera_options = False
|
self._updating_camera_options = False
|
||||||
|
|
||||||
root.addWidget(self._caption("Config"))
|
root.addWidget(self._caption("Sensor bundle"))
|
||||||
config_row = QHBoxLayout()
|
bundle_row = QHBoxLayout()
|
||||||
config_row.setSpacing(6)
|
bundle_row.setSpacing(6)
|
||||||
self.config_edit = QLineEdit("dd02")
|
self.sensor_bundle_combo = QComboBox()
|
||||||
config_row.addWidget(self.config_edit, 1)
|
self.sensor_bundle_combo.setToolTip("Select a finger sensor bundle")
|
||||||
|
self._bundle_presets = discover_sensor_bundle_presets()
|
||||||
|
for preset in self._bundle_presets:
|
||||||
|
self.sensor_bundle_combo.addItem(preset.label, preset)
|
||||||
|
self.sensor_bundle_combo.addItem("Custom…", None)
|
||||||
|
self._updating_bundle_combo = False
|
||||||
|
self.sensor_bundle_combo.currentIndexChanged.connect(self._on_bundle_combo_changed)
|
||||||
|
bundle_row.addWidget(self.sensor_bundle_combo, 1)
|
||||||
|
self.btn_browse_finger_assets = QPushButton("…")
|
||||||
|
self.btn_browse_finger_assets.setFixedWidth(32)
|
||||||
|
self.btn_browse_finger_assets.setToolTip("Browse sensor bundle folder")
|
||||||
|
self.btn_browse_finger_assets.clicked.connect(self.browse_finger_assets_requested.emit)
|
||||||
|
bundle_row.addWidget(self.btn_browse_finger_assets)
|
||||||
|
root.addLayout(bundle_row)
|
||||||
|
|
||||||
|
self.config_edit = QLineEdit("sensors/finger/sensor.json")
|
||||||
|
self.config_edit.hide()
|
||||||
self.btn_browse_config = QPushButton("…")
|
self.btn_browse_config = QPushButton("…")
|
||||||
self.btn_browse_config.setFixedWidth(32)
|
self.btn_browse_config.hide()
|
||||||
self.btn_browse_config.setToolTip("Browse config JSON")
|
|
||||||
self.btn_browse_config.clicked.connect(self.browse_config_requested.emit)
|
|
||||||
config_row.addWidget(self.btn_browse_config)
|
|
||||||
root.addLayout(config_row)
|
|
||||||
|
|
||||||
# Optional calibration path is kept for future re-enablement but hidden for now.
|
# Optional calibration path is kept for future re-enablement but hidden for now.
|
||||||
self.cal_edit = QLineEdit("")
|
self.cal_edit = QLineEdit("")
|
||||||
self.cal_edit.setPlaceholderText("Optional calibration path")
|
self.cal_edit.setPlaceholderText("Optional calibration path")
|
||||||
self.cal_edit.hide()
|
self.cal_edit.hide()
|
||||||
|
|
||||||
root.addWidget(self._caption("Finger assets"))
|
self.finger_assets_edit = QLineEdit("sensors/finger/assets/30fps-0703")
|
||||||
finger_assets_row = QHBoxLayout()
|
self.finger_assets_edit.hide()
|
||||||
finger_assets_row.setSpacing(6)
|
self._apply_bundle_preset(self._bundle_presets[0])
|
||||||
self.finger_assets_edit = QLineEdit(DEFAULT_FINGER_EXPORT_DIR)
|
|
||||||
self.finger_assets_edit.setPlaceholderText("Blender export bundle directory")
|
|
||||||
finger_assets_row.addWidget(self.finger_assets_edit, 1)
|
|
||||||
self.btn_browse_finger_assets = QPushButton("…")
|
|
||||||
self.btn_browse_finger_assets.setFixedWidth(32)
|
|
||||||
self.btn_browse_finger_assets.setToolTip("Browse finger asset directory")
|
|
||||||
self.btn_browse_finger_assets.clicked.connect(self.browse_finger_assets_requested.emit)
|
|
||||||
finger_assets_row.addWidget(self.btn_browse_finger_assets)
|
|
||||||
root.addLayout(finger_assets_row)
|
|
||||||
|
|
||||||
root.addWidget(self._caption("CSV save directory"))
|
root.addWidget(self._caption("CSV save directory"))
|
||||||
csv_dir_row = QHBoxLayout()
|
csv_dir_row = QHBoxLayout()
|
||||||
@@ -337,16 +344,18 @@ class SettingsPanelWidget(QWidget):
|
|||||||
|
|
||||||
def set_config_path(self, value: str) -> None:
|
def set_config_path(self, value: str) -> None:
|
||||||
self.config_edit.setText(value)
|
self.config_edit.setText(value)
|
||||||
|
self._sync_bundle_combo_from_paths()
|
||||||
|
|
||||||
def set_calibration_path(self, value: str) -> None:
|
def set_calibration_path(self, value: str) -> None:
|
||||||
self.cal_edit.setText(value or "")
|
self.cal_edit.setText(value or "")
|
||||||
|
|
||||||
def set_finger_export_dir(self, value: str) -> None:
|
def set_finger_export_dir(self, value: str) -> None:
|
||||||
self.finger_assets_edit.setText(value)
|
self.finger_assets_edit.setText(value)
|
||||||
|
self._sync_bundle_combo_from_paths()
|
||||||
|
|
||||||
def set_finger_export_dir_enabled(self, enabled: bool) -> None:
|
def set_finger_export_dir_enabled(self, enabled: bool) -> None:
|
||||||
self.finger_assets_edit.setEnabled(enabled)
|
|
||||||
self.btn_browse_finger_assets.setEnabled(enabled)
|
self.btn_browse_finger_assets.setEnabled(enabled)
|
||||||
|
self.sensor_bundle_combo.setEnabled(enabled)
|
||||||
|
|
||||||
def set_csv_save_dir(self, value: str) -> None:
|
def set_csv_save_dir(self, value: str) -> None:
|
||||||
self.csv_dir_edit.setText(value)
|
self.csv_dir_edit.setText(value)
|
||||||
@@ -387,6 +396,33 @@ class SettingsPanelWidget(QWidget):
|
|||||||
return
|
return
|
||||||
self.csv_record_toggled.emit(not self._csv_recording)
|
self.csv_record_toggled.emit(not self._csv_recording)
|
||||||
|
|
||||||
|
def _apply_bundle_preset(self, preset: SensorBundlePreset) -> None:
|
||||||
|
self.config_edit.setText(preset.config)
|
||||||
|
self.finger_assets_edit.setText(preset.export_dir)
|
||||||
|
|
||||||
|
def _on_bundle_combo_changed(self, _index: int) -> None:
|
||||||
|
if self._updating_bundle_combo:
|
||||||
|
return
|
||||||
|
preset = self.sensor_bundle_combo.currentData()
|
||||||
|
if isinstance(preset, SensorBundlePreset):
|
||||||
|
self._apply_bundle_preset(preset)
|
||||||
|
|
||||||
|
def _sync_bundle_combo_from_paths(self) -> None:
|
||||||
|
idx = sensor_bundle_preset_index(
|
||||||
|
self.config_edit.text(),
|
||||||
|
self.finger_assets_edit.text(),
|
||||||
|
self._bundle_presets,
|
||||||
|
)
|
||||||
|
if idx < 0:
|
||||||
|
idx = self.sensor_bundle_combo.findData(None)
|
||||||
|
if idx < 0:
|
||||||
|
return
|
||||||
|
self._updating_bundle_combo = True
|
||||||
|
self.sensor_bundle_combo.blockSignals(True)
|
||||||
|
self.sensor_bundle_combo.setCurrentIndex(idx)
|
||||||
|
self.sensor_bundle_combo.blockSignals(False)
|
||||||
|
self._updating_bundle_combo = False
|
||||||
|
|
||||||
def _emit_arrow_background_changed(self, _index: int) -> None:
|
def _emit_arrow_background_changed(self, _index: int) -> None:
|
||||||
value = self.arrow_background_combo.currentData()
|
value = self.arrow_background_combo.currentData()
|
||||||
if value:
|
if value:
|
||||||
@@ -413,6 +449,15 @@ class SettingsPanelWidget(QWidget):
|
|||||||
def finger_export_dir(self) -> str:
|
def finger_export_dir(self) -> str:
|
||||||
return self.finger_assets_edit.text().strip()
|
return self.finger_assets_edit.text().strip()
|
||||||
|
|
||||||
|
def sensor_bundle_dir(self) -> str:
|
||||||
|
preset = self.sensor_bundle_combo.currentData()
|
||||||
|
if isinstance(preset, SensorBundlePreset):
|
||||||
|
return preset.bundle_dir
|
||||||
|
config = self.config_path()
|
||||||
|
if config.endswith("/sensor.json") or config.endswith("\\sensor.json"):
|
||||||
|
return config.rsplit("/", 1)[0].rsplit("\\", 1)[0]
|
||||||
|
return ""
|
||||||
|
|
||||||
def csv_save_dir(self) -> str:
|
def csv_save_dir(self) -> str:
|
||||||
return self.csv_dir_edit.text().strip()
|
return self.csv_dir_edit.text().strip()
|
||||||
|
|
||||||
@@ -420,6 +465,7 @@ class SettingsPanelWidget(QWidget):
|
|||||||
"""Serialize user-facing settings suitable for preference.json."""
|
"""Serialize user-facing settings suitable for preference.json."""
|
||||||
return {
|
return {
|
||||||
"video_source": self.video_source(),
|
"video_source": self.video_source(),
|
||||||
|
"sensor_bundle_dir": self.sensor_bundle_dir(),
|
||||||
"config_path": self.config_path(),
|
"config_path": self.config_path(),
|
||||||
"finger_assets_dir": self.finger_export_dir(),
|
"finger_assets_dir": self.finger_export_dir(),
|
||||||
"csv_save_dir": self.csv_save_dir(),
|
"csv_save_dir": self.csv_save_dir(),
|
||||||
@@ -465,6 +511,8 @@ class SettingsPanelWidget(QWidget):
|
|||||||
if isinstance(assets, str) and assets.strip():
|
if isinstance(assets, str) and assets.strip():
|
||||||
self.set_finger_export_dir(assets.strip())
|
self.set_finger_export_dir(assets.strip())
|
||||||
|
|
||||||
|
self._sync_bundle_combo_from_paths()
|
||||||
|
|
||||||
csv_dir = state.get("csv_save_dir")
|
csv_dir = state.get("csv_save_dir")
|
||||||
if isinstance(csv_dir, str) and csv_dir.strip():
|
if isinstance(csv_dir, str) and csv_dir.strip():
|
||||||
self.set_csv_save_dir(csv_dir.strip())
|
self.set_csv_save_dir(csv_dir.strip())
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ ORY-MINI:单传感器基础流程
|
|||||||
3. 按需计算接触区域
|
3. 按需计算接触区域
|
||||||
4. 读取并可视化结果
|
4. 读取并可视化结果
|
||||||
|
|
||||||
SDK version: 0.4.2
|
SDK version: 0.4.3
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
@@ -43,7 +43,7 @@ def main():
|
|||||||
# 步骤 2.1:获取并拼接图像(必需)
|
# 步骤 2.1:获取并拼接图像(必需)
|
||||||
sensor.get_img()
|
sensor.get_img()
|
||||||
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
|
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
|
||||||
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0)
|
is_contact_now = sensor.compute_deformation()
|
||||||
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
|
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
|
||||||
# if is_contact_now:
|
# if is_contact_now:
|
||||||
sensor.compute_contact()
|
sensor.compute_contact()
|
||||||
@@ -17,7 +17,7 @@ def mesh_threshold(mesh_map: str, threshold: float | None) -> float:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class PipelineConfig:
|
class PipelineConfig:
|
||||||
video: str = "0"
|
video: str = "0"
|
||||||
config: str = "dd02"
|
config: str = "finger"
|
||||||
calib: str | None = None
|
calib: str | None = None
|
||||||
export_dir: str = DEFAULT_FINGER_EXPORT_DIR
|
export_dir: str = DEFAULT_FINGER_EXPORT_DIR
|
||||||
csv_save_dir: str = "./outputs/csv"
|
csv_save_dir: str = "./outputs/csv"
|
||||||
|
|||||||
+25
-24
@@ -1,29 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
示例 3:实时曲线与幅值图显示
|
示例 3:实时曲线与深度图显示
|
||||||
|
|
||||||
本示例演示如何在读取传感器结果的同时:
|
本示例演示如何在读取传感器结果的同时:
|
||||||
1. 显示形变矢量场
|
1. 显示形变矢量场
|
||||||
2. 显示流场幅值热力图
|
2. 显示接触深度图
|
||||||
3. 使用 PyQtGraph 实时绘制法向力与切向力曲线
|
3. 使用 PyQtGraph 实时绘制法向力与切向力曲线
|
||||||
4. 按键控制录像开始与结束
|
4. 按键控制录像开始与结束
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
|
import numpy as np
|
||||||
import orisys
|
import orisys
|
||||||
|
|
||||||
_EXAMPLES_DIR = Path(__file__).resolve().parent
|
from viz import ForceChartWindow, FrameRecorder
|
||||||
if str(_EXAMPLES_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_EXAMPLES_DIR))
|
|
||||||
|
|
||||||
from viz import ForceChartWindow, FrameRecorder, draw_magnitude_map, lowpass_filter
|
|
||||||
|
|
||||||
|
|
||||||
def open_sensor(video: str, config: str, cal: str, verbose: bool):
|
def open_sensor(video: str, config: str, cal: str, verbose: bool):
|
||||||
@@ -37,14 +32,21 @@ def open_sensor(video: str, config: str, cal: str, verbose: bool):
|
|||||||
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="视频源:摄像头编号或视频文件路径")
|
||||||
parser.add_argument("--config", "-c", type=str, default="./config/ddjx01.json", help="配置文件名称或路径")
|
parser.add_argument("--config", "-c", type=str, default="box", help="配置文件名称或路径")
|
||||||
parser.add_argument("--cal", "-cal", type=str, default="./config/ddjx01.npy", help="标定文件路径")
|
parser.add_argument("--cal", "-cal", type=str, default="", help="标定文件路径")
|
||||||
|
parser.add_argument(
|
||||||
|
"--cutoff",
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
help="力曲线低通滤波截止频率(Hz),30 Hz 采样时必须小于 15;设为 0 可关闭滤波",
|
||||||
|
)
|
||||||
parser.add_argument("--verbose", "-verbose", type=bool, default=True, help="是否输出详细日志")
|
parser.add_argument("--verbose", "-verbose", type=bool, default=True, help="是否输出详细日志")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
charts = ForceChartWindow()
|
charts = ForceChartWindow()
|
||||||
recorder = FrameRecorder()
|
recorder = FrameRecorder()
|
||||||
sensor = open_sensor(args.video, args.config, args.cal, args.verbose)
|
sensor = open_sensor(args.video, args.config, args.cal, args.verbose)
|
||||||
|
sensor.configure(lowpass_cutoff_hz=args.cutoff, lowpass_sample_rate_hz=30.0)
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
print("\n按键说明:'q' 退出程序,'s' 开始录像,'e' 结束录像。")
|
print("\n按键说明:'q' 退出程序,'s' 开始录像,'e' 结束录像。")
|
||||||
@@ -53,31 +55,30 @@ def main():
|
|||||||
sensor.get_img()
|
sensor.get_img()
|
||||||
recorder.write_if_recording(sensor.frame)
|
recorder.write_if_recording(sensor.frame)
|
||||||
|
|
||||||
sensor.compute_deformation(check_motion=True, threshold=0)
|
sensor.compute_deformation()
|
||||||
fps, fn, fx, fy, flow, vnormal, vshear = sensor.read_info(
|
sensor.compute_contact()
|
||||||
|
fps, fn, fx, fy, flow, depth_map = sensor.read_info(
|
||||||
sensor.info.FPS,
|
sensor.info.FPS,
|
||||||
sensor.info.FNORMAL,
|
sensor.info.FNORMAL,
|
||||||
sensor.info.FSHEARX,
|
sensor.info.FSHEARX,
|
||||||
sensor.info.FSHEARY,
|
sensor.info.FSHEARY,
|
||||||
sensor.info.VRAW,
|
sensor.info.VRAW,
|
||||||
sensor.info.VNORMAL,
|
sensor.info.DEPTH,
|
||||||
sensor.info.VSHEAR,
|
|
||||||
)
|
)
|
||||||
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}")
|
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}")
|
||||||
|
|
||||||
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)
|
||||||
cv2.imshow("流场幅值图", draw_magnitude_map(flow))
|
if depth_map.max() > depth_map.min():
|
||||||
|
depth_normalized = (
|
||||||
|
(depth_map - depth_map.min()) / (depth_map.max() - depth_map.min()) * 255
|
||||||
|
).astype(np.uint8)
|
||||||
|
else:
|
||||||
|
depth_normalized = np.zeros_like(depth_map, dtype=np.uint8)
|
||||||
|
cv2.imshow("depth", cv2.applyColorMap(depth_normalized, cv2.COLORMAP_JET))
|
||||||
|
|
||||||
charts.push(time.time() - start_time, fn, fx, fy)
|
charts.push(time.time() - start_time, fn, fx, fy)
|
||||||
display = charts.data.copy()
|
charts.refresh()
|
||||||
for channel in range(3):
|
|
||||||
display[:, 1, channel] = lowpass_filter(
|
|
||||||
charts.data[:, 1, channel],
|
|
||||||
cutoff_hz=5,
|
|
||||||
sample_rate_hz=30,
|
|
||||||
)
|
|
||||||
charts.refresh(display)
|
|
||||||
|
|
||||||
key = cv2.waitKey(1)
|
key = cv2.waitKey(1)
|
||||||
if key & 0xFF == ord("q"):
|
if key & 0xFF == ord("q"):
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Product-facing example entry points for ORY-BOX / ORY-MINI / ORY-FINGER."""
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"""
|
|
||||||
ORY-BOX:单传感器基础流程
|
|
||||||
|
|
||||||
本示例演示 Orisys SDK 的标准处理流程:
|
|
||||||
1. 初始化传感器(摄像头或视频文件)
|
|
||||||
2. 获取图像并计算形变
|
|
||||||
3. 按需计算接触区域
|
|
||||||
4. 读取并可视化结果
|
|
||||||
|
|
||||||
SDK version: 0.4.2
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
|
||||||
import cv2
|
|
||||||
import orisys
|
|
||||||
import numpy as np
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
|
||||||
parser.add_argument("--config","-c", type=str, default="ddjx01", help="配置文件名称或路径")
|
|
||||||
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# 步骤 1:创建传感器对象(必需)
|
|
||||||
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
|
|
||||||
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
|
|
||||||
|
|
||||||
# 输入既可以是摄像头编号,也可以是视频文件路径
|
|
||||||
try:
|
|
||||||
input = int(args.video)
|
|
||||||
is_camera = True
|
|
||||||
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
|
|
||||||
except:
|
|
||||||
is_camera = False
|
|
||||||
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
|
|
||||||
|
|
||||||
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
# 步骤 2:数据处理
|
|
||||||
# 步骤 2.1:获取并拼接图像(必需)
|
|
||||||
sensor.get_img()
|
|
||||||
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
|
|
||||||
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0)
|
|
||||||
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
|
|
||||||
# if is_contact_now:
|
|
||||||
sensor.compute_contact()
|
|
||||||
|
|
||||||
|
|
||||||
# 步骤 2.4:读取结果
|
|
||||||
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = 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
|
|
||||||
)
|
|
||||||
|
|
||||||
# 步骤 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}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
|
|
||||||
f"切向力Y={fsheary:.4f}, 深度图尺寸={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()
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
ORY-FINGER:Minimum UV-unroll finger tactile pipeline.
|
|
||||||
|
|
||||||
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
|
|
||||||
Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
|
|
||||||
|
|
||||||
Recommended entry:
|
|
||||||
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
|
|
||||||
|
|
||||||
Current config alias: ``dd02`` resolves to ``dd02-ov`` for compatibility.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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", help="Video file path (overrides camera)")
|
|
||||||
parser.add_argument("--config", "-c", default="dd02", 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(check_motion=True, threshold=0)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
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()
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
ORY-FINGER:UV-unroll finger tactile pipeline with unified Qt dashboard.
|
|
||||||
|
|
||||||
Where to customize the GUI:
|
|
||||||
Layout (sizes, proportions) -> examples/gui/viewer3d/layout.py
|
|
||||||
Colors / QSS -> examples/gui/viewer3d/styles.py
|
|
||||||
Widget rendering logic -> examples/gui/viewer3d/widgets/
|
|
||||||
Window behavior -> examples/gui/viewer3d/dashboard.py
|
|
||||||
|
|
||||||
Recommended entry:
|
|
||||||
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
|
||||||
|
|
||||||
# Make shared packages importable when launched as a file path.
|
|
||||||
_EXAMPLES_DIR = Path(__file__).resolve().parents[2]
|
|
||||||
if str(_EXAMPLES_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_EXAMPLES_DIR))
|
|
||||||
|
|
||||||
from gui.app_paths import configure_frozen_runtime, default_config_path, ensure_examples_import_path
|
|
||||||
|
|
||||||
configure_frozen_runtime()
|
|
||||||
ensure_examples_import_path()
|
|
||||||
|
|
||||||
from gui import run_dashboard
|
|
||||||
from pipeline import PipelineConfig
|
|
||||||
from viz import DEFAULT_FINGER_VIZ_POSE
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_args():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="UV-unroll finger tactile pipeline with unified Qt dashboard"
|
|
||||||
)
|
|
||||||
parser.add_argument("--video", "-v", default="0", help="Camera index or video file path")
|
|
||||||
parser.add_argument("--config", "-c", default=default_config_path(), help="Orisys SDK config")
|
|
||||||
parser.add_argument("--cal", default="", help="Optional calibration file path")
|
|
||||||
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
|
|
||||||
parser.add_argument(
|
|
||||||
"--mesh-map",
|
|
||||||
choices=["flow", "depth"],
|
|
||||||
default="depth",
|
|
||||||
help="Color the 3D mesh from flow magnitude or contact depth.",
|
|
||||||
)
|
|
||||||
parser.add_argument("--threshold", type=float, default=None, help="Mesh/arrow threshold")
|
|
||||||
parser.add_argument("--no-3d", action="store_true", help="Disable 3D mesh view")
|
|
||||||
parser.add_argument(
|
|
||||||
"--viz-pose",
|
|
||||||
default=DEFAULT_FINGER_VIZ_POSE,
|
|
||||||
help="world_to_cam.npy for the default view; pass 'none' to skip",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--cutoff",
|
|
||||||
type=float,
|
|
||||||
default=5.0,
|
|
||||||
help="Low-pass cutoff for force curves (Hz)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--profile",
|
|
||||||
action="store_true",
|
|
||||||
help="Print per-stage timing to the log panel / console",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--profile-every",
|
|
||||||
type=int,
|
|
||||||
default=30,
|
|
||||||
help="When --profile is set, print rolling averages every N frames",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = _parse_args()
|
|
||||||
config = PipelineConfig.from_args(args)
|
|
||||||
sys.exit(run_dashboard(config))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
|
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
|
||||||
- 每个传感器进程都会占用独立的 GPU 显存
|
- 每个传感器进程都会占用独立的 GPU 显存
|
||||||
|
|
||||||
SDK version: 0.4.2
|
SDK version: 0.4.3
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
@@ -138,12 +138,12 @@ def main():
|
|||||||
{
|
{
|
||||||
'sensor_id': 0,
|
'sensor_id': 0,
|
||||||
'vid_src': args.video1,
|
'vid_src': args.video1,
|
||||||
'config_name': './config/ddjx01.json'
|
'config_name': 'box'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'sensor_id': 1,
|
'sensor_id': 1,
|
||||||
'vid_src': args.video2, # 可替换为其他视频源或摄像头索引
|
'vid_src': args.video2, # 可替换为其他视频源或摄像头索引
|
||||||
'config_name': './config/ddjx01.json'
|
'config_name': 'box'
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,5 @@
|
|||||||
"""Signal filtering helpers for example force charts."""
|
"""Signal filtering helpers for example force charts."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from orisys.signal_processing import lowpass_filter
|
||||||
|
|
||||||
import numpy as np
|
__all__ = ["lowpass_filter"]
|
||||||
from scipy.signal import butter, filtfilt
|
|
||||||
|
|
||||||
def lowpass_filter(data, cutoff_hz=5, sample_rate_hz=30, order=2) -> np.ndarray:
|
|
||||||
"""Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data."""
|
|
||||||
y = np.asarray(data, dtype=np.float64)
|
|
||||||
if cutoff_hz == 0:
|
|
||||||
return y.copy()
|
|
||||||
if y.size == 0:
|
|
||||||
return y
|
|
||||||
|
|
||||||
nyquist = 0.5 * sample_rate_hz
|
|
||||||
if cutoff_hz < 0 or cutoff_hz >= nyquist:
|
|
||||||
raise ValueError(
|
|
||||||
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
|
|
||||||
)
|
|
||||||
|
|
||||||
b, a = butter(order, cutoff_hz / nyquist, btype="low")
|
|
||||||
taplen = max(len(a), len(b))
|
|
||||||
# scipy.signal.filtfilt requires len(x) > padlen (default 3 * taplen for recent scipy).
|
|
||||||
padlen = 3 * taplen
|
|
||||||
if y.size <= padlen:
|
|
||||||
return y.copy()
|
|
||||||
|
|
||||||
try:
|
|
||||||
return filtfilt(b, a, y)
|
|
||||||
except ValueError:
|
|
||||||
return y.copy()
|
|
||||||
|
|||||||
+10
-10
@@ -1,5 +1,5 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=61.0", "wheel"]
|
requires = ["setuptools>=61.0,<80", "wheel==0.45.1"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
@@ -75,11 +75,19 @@ patterns = ["*.json"]
|
|||||||
# files = ["dd02-ov.json", "ddjx01.json"]
|
# files = ["dd02-ov.json", "ddjx01.json"]
|
||||||
dev_install = true
|
dev_install = true
|
||||||
|
|
||||||
|
[[tool.orisys.bundle.items]]
|
||||||
|
name = "sensors"
|
||||||
|
source = "sensors"
|
||||||
|
target = "sensors"
|
||||||
|
patterns = ["**/*.json", "finger/assets/30fps*/*", "finger/assets/visualization/*"]
|
||||||
|
preserve_tree = true
|
||||||
|
dev_install = true
|
||||||
|
|
||||||
[[tool.orisys.bundle.items]]
|
[[tool.orisys.bundle.items]]
|
||||||
name = "examples"
|
name = "examples"
|
||||||
source = "examples"
|
source = "examples"
|
||||||
target = "examples"
|
target = "examples"
|
||||||
patterns = ["*.py", "README.md", "products/**/*.py", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"]
|
patterns = ["*.py", "README.md", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"]
|
||||||
preserve_tree = true
|
preserve_tree = true
|
||||||
|
|
||||||
[[tool.orisys.bundle.items]]
|
[[tool.orisys.bundle.items]]
|
||||||
@@ -88,11 +96,3 @@ source = "docs"
|
|||||||
target = "docs"
|
target = "docs"
|
||||||
patterns = ["*.md", "*.pdf"]
|
patterns = ["*.md", "*.pdf"]
|
||||||
|
|
||||||
[[tool.orisys.bundle.items]]
|
|
||||||
name = "assets"
|
|
||||||
source = "assets"
|
|
||||||
target = "assets"
|
|
||||||
patterns = ["finger/30fps/*", "finger/visualization/*"]
|
|
||||||
preserve_tree = true
|
|
||||||
dev_install = true
|
|
||||||
|
|
||||||
|
|||||||
+25
-11
@@ -1,13 +1,27 @@
|
|||||||
# CuPy - Install the appropriate version based on your CUDA version:
|
# Runtime dependencies for editable / source installs.
|
||||||
# For CUDA 12.x: pip install cupy-cuda12x>=12.3.0
|
# Prefer extras from pyproject.toml:
|
||||||
# For CUDA 13.x: pip install cupy-cuda13x>=13.0.0
|
# pip install -e .
|
||||||
# Or use extras: pip install orisys[cuda12] or pip install orisys[cuda13]
|
# pip install -e ".[gui]"
|
||||||
|
# pip install -e ".[cuda12]"
|
||||||
|
# pip install -e ".[cuda13]"
|
||||||
|
# pip install -e ".[gui,cuda12]"
|
||||||
|
# PyPI users: pip install orisys / "orisys[gui]" / "orisys[cuda12]"
|
||||||
|
|
||||||
|
# Core (same as [project.dependencies])
|
||||||
numpy>=1.24.4
|
numpy>=1.24.4
|
||||||
opencv_python>=4.10.0.84
|
opencv-python>=4.10.0.84
|
||||||
scipy==1.13
|
scipy>=1.10.1
|
||||||
setuptools>=45.2.0
|
numba>=0.59.0
|
||||||
av
|
|
||||||
numba
|
# GUI extras: pip install -e ".[gui]"
|
||||||
pyqt5
|
# open3d>=0.17.0
|
||||||
pyqtgraph
|
# pyqt5>=5.15
|
||||||
|
# pyqtgraph>=0.13
|
||||||
|
|
||||||
|
# CUDA extras (pick one CUDA major version)
|
||||||
|
# CUDA 12.x: pip install -e ".[cuda12]" -> cupy-cuda12x>=12.3.0
|
||||||
|
# CUDA 13.x: pip install -e ".[cuda13]" -> cupy-cuda13x>=13.0.0
|
||||||
|
# Alias: pip install -e ".[cuda]" -> same as cuda12
|
||||||
|
|
||||||
|
# Optional video tooling (not an orisys extra)
|
||||||
|
# av
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ def main():
|
|||||||
|
|
||||||
writer.write(save_img)
|
writer.write(save_img)
|
||||||
|
|
||||||
sensor1.compute_deformation(check_motion=False) # 计算形变和分解
|
sensor1.compute_deformation() # 计算形变和分解
|
||||||
# 提取信息:
|
# 提取信息:
|
||||||
# 向量场: VRAW, VNORMAL, VSHEAR: 原始,法向,切向
|
# 向量场: VRAW, VNORMAL, VSHEAR: 原始,法向,切向
|
||||||
# 帧率,时间戳: FPS, TIMESTAMP
|
# 帧率,时间戳: FPS, TIMESTAMP
|
||||||
|
|||||||
@@ -1,961 +0,0 @@
|
|||||||
import os
|
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
|
||||||
# Force pyqtgraph to bind to PyQt5 when multiple Qt bindings are installed.
|
|
||||||
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PyQt5")
|
|
||||||
import cv2
|
|
||||||
import orisys
|
|
||||||
import numpy as np
|
|
||||||
import time
|
|
||||||
import argparse
|
|
||||||
from datetime import datetime
|
|
||||||
import pyqtgraph as pg
|
|
||||||
from PyQt5.QtCore import Qt, QSize, QEvent
|
|
||||||
from PyQt5.QtGui import QImage, QFont, QPixmap, QKeySequence, QCursor
|
|
||||||
from PyQt5.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout, QWidget, QSizePolicy, QShortcut, QApplication
|
|
||||||
from pyqtgraph.Qt import QtGui
|
|
||||||
|
|
||||||
|
|
||||||
# Final desired appearance on the mirrored 1872x1560 (6:5) display.
|
|
||||||
# The desktop is first shown on a 1920x1080 (16:9) screen, then stretched by
|
|
||||||
# the OS onto the 6:5 mirror. We therefore pre-distort the source layout so
|
|
||||||
# the mirrored output looks correct.
|
|
||||||
TARGET_SINGLE_PANEL_W = 936
|
|
||||||
TARGET_ASPECT_W = TARGET_SINGLE_PANEL_W * 2
|
|
||||||
TARGET_VIDEO_FRAME_H = 1053
|
|
||||||
TARGET_BOTTOM_PANEL_H = 507
|
|
||||||
TARGET_ASPECT_H_TOTAL = TARGET_VIDEO_FRAME_H + TARGET_BOTTOM_PANEL_H
|
|
||||||
TARGET_MIRROR_W = 1872
|
|
||||||
TARGET_MIRROR_H = 1560
|
|
||||||
|
|
||||||
SINGLE_PANEL_W = TARGET_SINGLE_PANEL_W
|
|
||||||
ASPECT_W = TARGET_ASPECT_W
|
|
||||||
BOTTOM_PANEL_H = TARGET_BOTTOM_PANEL_H
|
|
||||||
VIDEO_FRAME_W = TARGET_ASPECT_W
|
|
||||||
VIDEO_FRAME_H = TARGET_VIDEO_FRAME_H
|
|
||||||
ASPECT_H_TOTAL = TARGET_ASPECT_H_TOTAL
|
|
||||||
UI_SCALE = 1.0
|
|
||||||
ARROW_STRETCH = 507
|
|
||||||
PLOT_STRETCH = 429
|
|
||||||
VIDEO_LOOP_PATH = "./叠动机械爪_成片.m4v"
|
|
||||||
npoints = 120 # 图表数据长度
|
|
||||||
dispaly_angle = -90 # 箭头图显示角度
|
|
||||||
touch_threshold = 5 # 触碰反应阈值
|
|
||||||
frame_to_recover = 10 # 触碰后等待回复帧数
|
|
||||||
|
|
||||||
# Opneing Hour for keep reading image
|
|
||||||
open_hour = 9
|
|
||||||
close_hour = 17
|
|
||||||
manual_open_enable = True # 控制是否可以手动切换营业状态 (按键G)
|
|
||||||
is_open = True
|
|
||||||
|
|
||||||
# Match arrow panel / Qt style (not pure black)
|
|
||||||
PLOT_THEME_BG = "#0B1016"
|
|
||||||
PLOT_THEME_FG = "#A0A0A0"
|
|
||||||
|
|
||||||
# OS title bar cannot be tinted to #0B1016; frameless removes it (use Alt+drag to move).
|
|
||||||
USE_FRAMELESS_WINDOW = False
|
|
||||||
|
|
||||||
|
|
||||||
def scale_ui_px(px, floor=1):
|
|
||||||
return max(floor, int(round(px * UI_SCALE)))
|
|
||||||
|
|
||||||
|
|
||||||
def compute_source_layout(screen_w, screen_h):
|
|
||||||
"""Pre-distort the app on the source screen so the mirrored monitor is correct."""
|
|
||||||
target_video_ratio = TARGET_VIDEO_FRAME_H / float(TARGET_ASPECT_H_TOTAL)
|
|
||||||
target_bottom_ratio = TARGET_BOTTOM_PANEL_H / float(TARGET_ASPECT_H_TOTAL)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"window_w": max(320, int(screen_w)),
|
|
||||||
"window_h": max(240, int(screen_h)),
|
|
||||||
"video_w": max(320, int(screen_w)),
|
|
||||||
"video_h": max(1, int(round(screen_h * target_video_ratio))),
|
|
||||||
"bottom_h": max(1, int(round(screen_h * target_bottom_ratio))),
|
|
||||||
"single_panel_w": max(1, int(round(screen_w / 2.0))),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class CombinedTactileWindow(QWidget):
|
|
||||||
"""
|
|
||||||
Resizable window that keeps width:height = ASPECT_W : ASPECT_H_TOTAL.
|
|
||||||
Dragging the width or height edge updates the other dimension accordingly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
RESIZE_MARGIN = 8
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._aspect_hw = ASPECT_H_TOTAL / float(ASPECT_W)
|
|
||||||
self._resize_guard = False
|
|
||||||
self.user_closed = False
|
|
||||||
self._drag_anchor = None
|
|
||||||
self._frameless_resize_edge = None
|
|
||||||
self._frameless_resize_start_global = None
|
|
||||||
self._frameless_resize_start_geom = None
|
|
||||||
|
|
||||||
def _min_client_size(self):
|
|
||||||
w = max(320, self.minimumSizeHint().width())
|
|
||||||
h = int(round(w * self._aspect_hw))
|
|
||||||
return w, h
|
|
||||||
|
|
||||||
def _hit_test_resize(self, global_pos):
|
|
||||||
if not USE_FRAMELESS_WINDOW:
|
|
||||||
return None
|
|
||||||
p = self.mapFromGlobal(global_pos)
|
|
||||||
w, h = self.width(), self.height()
|
|
||||||
m = self.RESIZE_MARGIN
|
|
||||||
left = p.x() <= m
|
|
||||||
right = p.x() >= w - m
|
|
||||||
top = p.y() <= m
|
|
||||||
bottom = p.y() >= h - m
|
|
||||||
if top and left:
|
|
||||||
return "tl"
|
|
||||||
if top and right:
|
|
||||||
return "tr"
|
|
||||||
if bottom and left:
|
|
||||||
return "bl"
|
|
||||||
if bottom and right:
|
|
||||||
return "br"
|
|
||||||
if top:
|
|
||||||
return "t"
|
|
||||||
if bottom:
|
|
||||||
return "b"
|
|
||||||
if left:
|
|
||||||
return "l"
|
|
||||||
if right:
|
|
||||||
return "r"
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _cursor_for_edge(self, edge):
|
|
||||||
return {
|
|
||||||
"tl": Qt.SizeFDiagCursor,
|
|
||||||
"br": Qt.SizeFDiagCursor,
|
|
||||||
"tr": Qt.SizeBDiagCursor,
|
|
||||||
"bl": Qt.SizeBDiagCursor,
|
|
||||||
"l": Qt.SizeHorCursor,
|
|
||||||
"r": Qt.SizeHorCursor,
|
|
||||||
"t": Qt.SizeVerCursor,
|
|
||||||
"b": Qt.SizeVerCursor,
|
|
||||||
}.get(edge, Qt.ArrowCursor)
|
|
||||||
|
|
||||||
def _apply_frameless_resize(self, dg):
|
|
||||||
edge = self._frameless_resize_edge
|
|
||||||
g0 = self._frameless_resize_start_geom
|
|
||||||
min_w, min_h = self._min_client_size()
|
|
||||||
dx, dy = dg.x(), dg.y()
|
|
||||||
x0, y0, w0, h0 = g0.x(), g0.y(), g0.width(), g0.height()
|
|
||||||
|
|
||||||
if edge == "br":
|
|
||||||
nw = max(min_w, w0 + dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
self.setGeometry(x0, y0, nw, nh)
|
|
||||||
elif edge == "r":
|
|
||||||
nw = max(min_w, w0 + dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
self.setGeometry(x0, y0, nw, nh)
|
|
||||||
elif edge == "b":
|
|
||||||
nh = max(min_h, h0 + dy)
|
|
||||||
nw = int(round(nh / self._aspect_hw))
|
|
||||||
self.setGeometry(x0, y0, nw, nh)
|
|
||||||
elif edge == "l":
|
|
||||||
nw = max(min_w, w0 - dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
nx = g0.right() - nw
|
|
||||||
self.setGeometry(nx, y0, nw, nh)
|
|
||||||
elif edge == "t":
|
|
||||||
nh = max(min_h, h0 - dy)
|
|
||||||
nw = int(round(nh / self._aspect_hw))
|
|
||||||
ny = g0.bottom() - nh
|
|
||||||
self.setGeometry(x0, ny, nw, nh)
|
|
||||||
elif edge == "tr":
|
|
||||||
nw = max(min_w, w0 + dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
ny = g0.bottom() - nh
|
|
||||||
self.setGeometry(x0, ny, nw, nh)
|
|
||||||
elif edge == "bl":
|
|
||||||
nw = max(min_w, w0 - dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
nx = g0.right() - nw
|
|
||||||
self.setGeometry(nx, y0, nw, nh)
|
|
||||||
elif edge == "tl":
|
|
||||||
nw = max(min_w, w0 - dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
nx = g0.right() - nw
|
|
||||||
ny = g0.bottom() - nh
|
|
||||||
self.setGeometry(nx, ny, nw, nh)
|
|
||||||
|
|
||||||
def install_drag_filters(self):
|
|
||||||
"""Alt+left-drag to move when frameless; grabs go to this widget."""
|
|
||||||
for w in self.findChildren(QWidget):
|
|
||||||
if w is not self:
|
|
||||||
w.installEventFilter(self)
|
|
||||||
|
|
||||||
def eventFilter(self, watched, event):
|
|
||||||
if not USE_FRAMELESS_WINDOW:
|
|
||||||
return super().eventFilter(watched, event)
|
|
||||||
et = event.type()
|
|
||||||
if et == QEvent.MouseButtonPress and event.button() == Qt.LeftButton:
|
|
||||||
edge = self._hit_test_resize(event.globalPos())
|
|
||||||
if edge is not None:
|
|
||||||
self._frameless_resize_edge = edge
|
|
||||||
self._frameless_resize_start_global = event.globalPos()
|
|
||||||
self._frameless_resize_start_geom = self.geometry()
|
|
||||||
self.grabMouse()
|
|
||||||
return True
|
|
||||||
if QApplication.keyboardModifiers() & Qt.AltModifier:
|
|
||||||
self._drag_anchor = event.globalPos() - self.frameGeometry().topLeft()
|
|
||||||
self.grabMouse()
|
|
||||||
return True
|
|
||||||
if et == QEvent.MouseMove and not event.buttons():
|
|
||||||
edge = self._hit_test_resize(event.globalPos())
|
|
||||||
watched.setCursor(QCursor(self._cursor_for_edge(edge)))
|
|
||||||
return super().eventFilter(watched, event)
|
|
||||||
|
|
||||||
def mouseMoveEvent(self, event):
|
|
||||||
if USE_FRAMELESS_WINDOW and self._frameless_resize_edge and event.buttons() & Qt.LeftButton:
|
|
||||||
dg = event.globalPos() - self._frameless_resize_start_global
|
|
||||||
self._apply_frameless_resize(dg)
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
if (
|
|
||||||
USE_FRAMELESS_WINDOW
|
|
||||||
and self._drag_anchor is not None
|
|
||||||
and event.buttons() & Qt.LeftButton
|
|
||||||
):
|
|
||||||
self.move(event.globalPos() - self._drag_anchor)
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
super().mouseMoveEvent(event)
|
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event):
|
|
||||||
if USE_FRAMELESS_WINDOW and self._frameless_resize_edge and event.button() == Qt.LeftButton:
|
|
||||||
self.releaseMouse()
|
|
||||||
self._frameless_resize_edge = None
|
|
||||||
self._frameless_resize_start_global = None
|
|
||||||
self._frameless_resize_start_geom = None
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
if USE_FRAMELESS_WINDOW and self._drag_anchor is not None and event.button() == Qt.LeftButton:
|
|
||||||
self.releaseMouse()
|
|
||||||
self._drag_anchor = None
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
super().mouseReleaseEvent(event)
|
|
||||||
|
|
||||||
def closeEvent(self, event):
|
|
||||||
self.user_closed = True
|
|
||||||
event.accept()
|
|
||||||
|
|
||||||
def minimumSizeHint(self):
|
|
||||||
w = 320
|
|
||||||
return QSize(w, int(round(w * self._aspect_hw)))
|
|
||||||
|
|
||||||
def resizeEvent(self, event):
|
|
||||||
if self._resize_guard:
|
|
||||||
super().resizeEvent(event)
|
|
||||||
return
|
|
||||||
super().resizeEvent(event)
|
|
||||||
w, h = self.width(), self.height()
|
|
||||||
if w < 8 or h < 8:
|
|
||||||
return
|
|
||||||
old = event.oldSize()
|
|
||||||
self._resize_guard = True
|
|
||||||
try:
|
|
||||||
if old.isValid() and old.width() > 0 and old.height() > 0:
|
|
||||||
dw = abs(w - old.width())
|
|
||||||
dh = abs(h - old.height())
|
|
||||||
if dw >= dh:
|
|
||||||
new_w, new_h = w, int(round(w * self._aspect_hw))
|
|
||||||
else:
|
|
||||||
new_w, new_h = int(round(h / self._aspect_hw)), h
|
|
||||||
else:
|
|
||||||
new_w, new_h = w, int(round(w * self._aspect_hw))
|
|
||||||
if new_w != w or new_h != h:
|
|
||||||
self.resize(new_w, new_h)
|
|
||||||
finally:
|
|
||||||
self._resize_guard = False
|
|
||||||
|
|
||||||
|
|
||||||
def compute_arrow_canvas_side(panel_widget):
|
|
||||||
"""
|
|
||||||
Pixel size of the square arrow canvas from the window client area and the
|
|
||||||
fixed horizontal split ARROW_STRETCH : PLOT_STRETCH (matches QHBoxLayout stretch).
|
|
||||||
"""
|
|
||||||
w = panel_widget.width()
|
|
||||||
h = panel_widget.height()
|
|
||||||
if w < 16 or h < 16:
|
|
||||||
return max(32, ASPECT_H_TOTAL)
|
|
||||||
w_arrow = int(round(w * ARROW_STRETCH / float(ARROW_STRETCH + PLOT_STRETCH)))
|
|
||||||
# Keep the rendered arrow image square inside the left panel.
|
|
||||||
return max(32, min(h, w_arrow))
|
|
||||||
|
|
||||||
|
|
||||||
def draw_arrows_wuxi(
|
|
||||||
frame,
|
|
||||||
optical_flow,
|
|
||||||
fps=True,
|
|
||||||
threshold=5,
|
|
||||||
grid_spacing=40,
|
|
||||||
arrow_scale=1.0,
|
|
||||||
margin=0.8,
|
|
||||||
colormap=cv2.COLORMAP_PARULA,
|
|
||||||
below_threshold_color=(0, 0, 255),
|
|
||||||
edge_margin=15,
|
|
||||||
target_size=936,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Draw colored optical flow arrows on the image by magnitude (JET colormap).
|
|
||||||
Resizes frame and flow to target_size x target_size; scales flow vectors and
|
|
||||||
grid_spacing / margins / threshold so arrow density matches the original resolution.
|
|
||||||
"""
|
|
||||||
image = frame.copy()
|
|
||||||
if len(image.shape) == 2 or image.shape[2] == 1:
|
|
||||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
|
||||||
|
|
||||||
h0, w0 = image.shape[:2]
|
|
||||||
if h0 != target_size or w0 != target_size:
|
|
||||||
sx = target_size / float(w0)
|
|
||||||
sy = target_size / float(h0)
|
|
||||||
image = cv2.resize(image, (target_size, target_size), interpolation=cv2.INTER_LINEAR)
|
|
||||||
u = cv2.resize(optical_flow[:, :, 0], (target_size, target_size), interpolation=cv2.INTER_LINEAR) * sx
|
|
||||||
v = cv2.resize(optical_flow[:, :, 1], (target_size, target_size), interpolation=cv2.INTER_LINEAR) * sy
|
|
||||||
optical_flow = np.stack([u, v], axis=2)
|
|
||||||
scale = max(sx, sy)
|
|
||||||
grid_spacing = max(4, int(round(grid_spacing * scale)))
|
|
||||||
edge_margin = max(1, int(round(edge_margin * scale)))
|
|
||||||
threshold = float(threshold) * scale
|
|
||||||
|
|
||||||
h, w = image.shape[:2]
|
|
||||||
# Arrow grid in pixel coordinates (assume frame and flow same size)
|
|
||||||
rr = np.arange(grid_spacing // 2, h, grid_spacing)
|
|
||||||
cc = np.arange(grid_spacing // 2, w, grid_spacing)
|
|
||||||
rr, cc = np.meshgrid(rr, cc, indexing="ij")
|
|
||||||
rr, cc = rr.reshape(-1), cc.reshape(-1)
|
|
||||||
|
|
||||||
# Displacement at each grid point (flow stored as half-displacement)
|
|
||||||
disp = 2.0 * optical_flow[rr, cc, :]
|
|
||||||
mag = np.linalg.norm(disp, axis=1)
|
|
||||||
dx = disp[:, 0] / arrow_scale
|
|
||||||
dy = disp[:, 1] / arrow_scale
|
|
||||||
length = np.sqrt(dx * dx + dy * dy)
|
|
||||||
# Cap arrow length at grid_spacing
|
|
||||||
scale = np.where(length > grid_spacing*margin, grid_spacing / np.maximum(length, 1e-9), 1.0)*margin
|
|
||||||
dx, dy = dx * scale, dy * scale
|
|
||||||
end_x = (cc + dx).astype(np.int32)
|
|
||||||
end_y = (rr + dy).astype(np.int32)
|
|
||||||
|
|
||||||
valid = (
|
|
||||||
(cc >= edge_margin) & (cc < w - edge_margin) & (rr >= edge_margin) & (rr < h - edge_margin)
|
|
||||||
& (end_x >= 0) & (end_x < w) & (end_y >= 0) & (end_y < h)
|
|
||||||
)
|
|
||||||
mag_range = mag.max() - mag.min()
|
|
||||||
norm_mag = ((mag - mag.min()) / mag_range * 255).astype(np.uint8) if mag_range > 0 else np.zeros_like(mag, dtype=np.uint8)
|
|
||||||
norm_mag[mag < threshold] = 0
|
|
||||||
colors = cv2.applyColorMap(norm_mag.reshape(-1, 1), colormap)
|
|
||||||
|
|
||||||
for i in np.where(valid)[0]:
|
|
||||||
pt1, pt2 = (int(cc[i]), int(rr[i])), (int(end_x[i]), int(end_y[i]))
|
|
||||||
color = tuple(int(c) for c in colors[i, 0])
|
|
||||||
if mag[i] < threshold:
|
|
||||||
cv2.circle(image, pt1, radius=1, color=below_threshold_color, thickness=-1)
|
|
||||||
else:
|
|
||||||
# thickness = max(1, int(2 * mag[i] / mag.max())) if mag.max() > 0 else 2
|
|
||||||
thickness = 1
|
|
||||||
cv2.arrowedLine(image, pt1, pt2, color, thickness=thickness, tipLength=0.2)
|
|
||||||
return image
|
|
||||||
|
|
||||||
|
|
||||||
def set_arrow_label_bgr(label, bgr_image):
|
|
||||||
"""Show a BGR OpenCV image in a QLabel using source-screen pre-distortion."""
|
|
||||||
rgb = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB)
|
|
||||||
rgb = np.ascontiguousarray(rgb)
|
|
||||||
h, w, ch = rgb.shape
|
|
||||||
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy()
|
|
||||||
pm = QPixmap.fromImage(qimg)
|
|
||||||
lw, lh = label.width(), label.height()
|
|
||||||
if lw > 0 and lh > 0 and (pm.width() != lw or pm.height() != lh):
|
|
||||||
pm = pm.scaled(lw, lh, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
|
|
||||||
label.setPixmap(pm)
|
|
||||||
|
|
||||||
def set_video_label_bgr(label, bgr_image):
|
|
||||||
"""Show loop video in the top frame using the fitted display size."""
|
|
||||||
rgb = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB)
|
|
||||||
rgb = np.ascontiguousarray(rgb)
|
|
||||||
h, w, ch = rgb.shape
|
|
||||||
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy()
|
|
||||||
pm = QPixmap.fromImage(qimg)
|
|
||||||
lw, lh = label.width(), label.height()
|
|
||||||
if lw > 0 and lh > 0 and (pm.width() != lw or pm.height() != lh):
|
|
||||||
pm = pm.scaled(lw, lh, Qt.IgnoreAspectRatio, Qt.FastTransformation)
|
|
||||||
label.setPixmap(pm)
|
|
||||||
|
|
||||||
|
|
||||||
def read_loop_video_frame_realtime(video_cap, video_clock):
|
|
||||||
"""Read loop-video frame paced to wall clock; skip frames when main loop is slow."""
|
|
||||||
if video_cap is None or not video_cap.isOpened():
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
now = time.perf_counter()
|
|
||||||
fps = max(1.0, float(video_clock.get("fps", 30.0)))
|
|
||||||
frame_dt = 1.0 / fps
|
|
||||||
next_ts = video_clock.get("next_ts")
|
|
||||||
if next_ts is None:
|
|
||||||
next_ts = now
|
|
||||||
|
|
||||||
frames_due = 1
|
|
||||||
if now > next_ts + frame_dt:
|
|
||||||
frames_due += int((now - next_ts) / frame_dt)
|
|
||||||
# Cap skip burst to avoid long catch-up loops after pauses.
|
|
||||||
frames_due = min(frames_due, int(max(2.0, fps)))
|
|
||||||
|
|
||||||
for _ in range(frames_due - 1):
|
|
||||||
if not video_cap.grab():
|
|
||||||
video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
||||||
if not video_cap.grab():
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
vret, vframe = video_cap.read()
|
|
||||||
if not vret:
|
|
||||||
video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
||||||
vret, vframe = video_cap.read()
|
|
||||||
if not vret:
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
next_ts = next_ts + frames_due * frame_dt
|
|
||||||
if next_ts < now - 2.0 * frame_dt:
|
|
||||||
next_ts = now
|
|
||||||
video_clock["next_ts"] = next_ts
|
|
||||||
return True, vframe
|
|
||||||
|
|
||||||
|
|
||||||
def make_gradient_brush(color_hex, max_alpha=150):
|
|
||||||
"""
|
|
||||||
创建一个从上到下渐变的笔刷 (兼容 PyQt5 和 PyQt6)
|
|
||||||
"""
|
|
||||||
c_top = QtGui.QColor(color_hex)
|
|
||||||
c_top.setAlpha(max_alpha) # 顶部半透明
|
|
||||||
|
|
||||||
c_bot = QtGui.QColor(color_hex)
|
|
||||||
c_bot.setAlpha(0) # 底部全透明
|
|
||||||
|
|
||||||
# 创建线性渐变:(0,0)到(0,1) 代表从控件顶部到底部
|
|
||||||
grad = QtGui.QLinearGradient(0, 0, 0, 1)
|
|
||||||
|
|
||||||
if hasattr(QtGui.QGradient, 'CoordinateMode'):
|
|
||||||
grad.setCoordinateMode(QtGui.QGradient.CoordinateMode.ObjectBoundingMode)
|
|
||||||
else:
|
|
||||||
grad.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
|
|
||||||
# ==============================================
|
|
||||||
|
|
||||||
grad.setColorAt(0, c_top) # 0.0 (顶端) 颜色
|
|
||||||
grad.setColorAt(1, c_bot) # 1.0 (底端) 颜色
|
|
||||||
|
|
||||||
return QtGui.QBrush(grad)
|
|
||||||
|
|
||||||
def create_plot(plot_container):
|
|
||||||
pg.setConfigOption("background", PLOT_THEME_BG)
|
|
||||||
pg.setConfigOption("foreground", PLOT_THEME_FG)
|
|
||||||
pg.setConfigOptions(antialias=True) # 抗锯齿
|
|
||||||
theme_color = '#00E5FF' # '#00E5FF'
|
|
||||||
line_pen = pg.mkPen(color=theme_color, width=2)
|
|
||||||
grad_brush = make_gradient_brush(theme_color, max_alpha=120)
|
|
||||||
|
|
||||||
axis_font = QFont("Segoe UI", scale_ui_px(11, floor=8))
|
|
||||||
p1 = plot_container.addPlot()
|
|
||||||
p1.setLabel('left', 'N')
|
|
||||||
p1.getAxis('left').label.setFont(axis_font)
|
|
||||||
p1.getAxis('left').label.setRotation(dispaly_angle)
|
|
||||||
p1.showGrid(x=True, y=True, alpha=0.3)
|
|
||||||
plot_container.nextRow()
|
|
||||||
p2 = plot_container.addPlot()
|
|
||||||
p2.setLabel('left', 'X')
|
|
||||||
p2.getAxis('left').label.setFont(axis_font)
|
|
||||||
p2.getAxis('left').label.setRotation(dispaly_angle)
|
|
||||||
p2.showGrid(x=True, y=True, alpha=0.3)
|
|
||||||
plot_container.nextRow()
|
|
||||||
p3 = plot_container.addPlot()
|
|
||||||
p3.setLabel('left', 'Y')
|
|
||||||
|
|
||||||
# p3.setLabel('bottom', 'Time', units='s')
|
|
||||||
p3.getAxis('left').label.setFont(axis_font)
|
|
||||||
p3.getAxis('left').label.setRotation(dispaly_angle)
|
|
||||||
p3.getAxis('bottom').label.setFont(axis_font)
|
|
||||||
p3.showGrid(x=True, y=True, alpha=0.3)
|
|
||||||
_plot_bg = pg.mkColor(PLOT_THEME_BG)
|
|
||||||
for p in (p1, p2, p3):
|
|
||||||
p.getAxis("bottom").setStyle(showValues=False)
|
|
||||||
p.getAxis("left").setStyle(showValues=False)
|
|
||||||
# Keep title as "N" / "X" / "Y" — no PyQtGraph "(x0.001)" suffix on small ranges.
|
|
||||||
p.getAxis("left").enableAutoSIPrefix(False)
|
|
||||||
# PlotItem.setBackground exists only in newer pyqtgraph; ViewBox works on older installs.
|
|
||||||
p.vb.setBackgroundColor(_plot_bg)
|
|
||||||
plot_af = p1.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
|
|
||||||
plot_tfx = p2.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
|
|
||||||
plot_tfy = p3.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
|
|
||||||
return p1, p2, p3, plot_af, plot_tfx, plot_tfy
|
|
||||||
|
|
||||||
def _plot_y_range(y):
|
|
||||||
"""Avoid degenerate Y span; explicit x/y in setData so older PyQtGraph never plots time as y."""
|
|
||||||
mn = float(np.min(y))
|
|
||||||
mx = float(np.max(y))
|
|
||||||
if not (np.isfinite(mn) and np.isfinite(mx)):
|
|
||||||
return 0.0, 1.0
|
|
||||||
if mx - mn <= 1e-15:
|
|
||||||
mid = 0.5 * (mn + mx)
|
|
||||||
return mid - 0.5, mid + 0.5
|
|
||||||
pad = (mx - mn) * 0.05
|
|
||||||
return mn - pad, mx + pad
|
|
||||||
|
|
||||||
|
|
||||||
def update(plot_af, plot_tfx, plot_tfy, data, p1, p2, p3):
|
|
||||||
# global plot_af, plot_tfx, plot_tfy, data, p1, p2, p3
|
|
||||||
plot_af.setData(data[:, 0, 0], data[:, 1, 0])
|
|
||||||
p1.setYRange(*_plot_y_range(data[:, 1, 0]))
|
|
||||||
plot_tfx.setData(data[:, 0, 1], data[:, 1, 1])
|
|
||||||
p2.setYRange(*_plot_y_range(data[:, 1, 1]))
|
|
||||||
plot_tfy.setData(data[:, 0, 2], data[:, 1, 2])
|
|
||||||
p3.setYRange(*_plot_y_range(data[:, 1, 2]))
|
|
||||||
|
|
||||||
|
|
||||||
def build_sensor_panel(parent_layout):
|
|
||||||
panel = QWidget()
|
|
||||||
panel_layout = QHBoxLayout(panel)
|
|
||||||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
panel_layout.setSpacing(0)
|
|
||||||
|
|
||||||
# Ignored: do not let pixmap/content drive QLabel size — layout stretch sets arrow vs plot widths.
|
|
||||||
_policy_layout = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
|
|
||||||
|
|
||||||
arrow_label = QLabel()
|
|
||||||
arrow_label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
|
||||||
arrow_label.setStyleSheet(
|
|
||||||
"background-color: #0B1016; margin: 0; padding: 0; border: none;"
|
|
||||||
)
|
|
||||||
arrow_label.setSizePolicy(_policy_layout)
|
|
||||||
arrow_label.setMinimumSize(0, 0)
|
|
||||||
arrow_label.setScaledContents(False)
|
|
||||||
panel_layout.addWidget(arrow_label, stretch=ARROW_STRETCH)
|
|
||||||
|
|
||||||
plot_widget = pg.GraphicsLayoutWidget()
|
|
||||||
plot_widget.setBackground(PLOT_THEME_BG)
|
|
||||||
plot_widget.setSizePolicy(_policy_layout)
|
|
||||||
plot_widget.setMinimumSize(0, 0)
|
|
||||||
panel_layout.addWidget(plot_widget, stretch=PLOT_STRETCH)
|
|
||||||
|
|
||||||
disconnected_label = QLabel("已断开")
|
|
||||||
disconnected_label.setAlignment(Qt.AlignCenter)
|
|
||||||
disconnected_label.setStyleSheet(
|
|
||||||
f"background-color: #0B1016; color: #FF4D4D; font-size: {scale_ui_px(54, floor=24)}px; "
|
|
||||||
"font-weight: bold; letter-spacing: 2px; margin: 0; padding: 0; border: none;"
|
|
||||||
)
|
|
||||||
disconnected_label.hide()
|
|
||||||
panel_layout.addWidget(disconnected_label, stretch=SINGLE_PANEL_W)
|
|
||||||
|
|
||||||
parent_layout.addWidget(panel, stretch=1)
|
|
||||||
|
|
||||||
p1, p2, p3, plot_af, plot_tfx, plot_tfy = create_plot(plot_widget)
|
|
||||||
data = np.zeros([npoints, 2, 3])
|
|
||||||
for i in range(3):
|
|
||||||
data[:, 0, i] = np.linspace(0.0, npoints / 30.0, npoints)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"panel": panel,
|
|
||||||
"arrow_label": arrow_label,
|
|
||||||
"plot_widget": plot_widget,
|
|
||||||
"disconnected_label": disconnected_label,
|
|
||||||
"p1": p1,
|
|
||||||
"p2": p2,
|
|
||||||
"p3": p3,
|
|
||||||
"plot_af": plot_af,
|
|
||||||
"plot_tfx": plot_tfx,
|
|
||||||
"plot_tfy": plot_tfy,
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 实时图表:单窗口,两套并排(左 sensor1,右 sensor2)
|
|
||||||
pg.setConfigOption("background", PLOT_THEME_BG)
|
|
||||||
pg.setConfigOption("foreground", PLOT_THEME_FG)
|
|
||||||
app = pg.mkQApp("Wuxi Demo")
|
|
||||||
screen = app.primaryScreen()
|
|
||||||
screen_geometry = screen.availableGeometry() if screen is not None else None
|
|
||||||
screen_w = screen_geometry.width() if screen_geometry is not None else 1920
|
|
||||||
screen_h = screen_geometry.height() if screen_geometry is not None else 1080
|
|
||||||
source_layout = compute_source_layout(screen_w, screen_h)
|
|
||||||
ASPECT_W = source_layout["window_w"]
|
|
||||||
ASPECT_H_TOTAL = source_layout["window_h"]
|
|
||||||
VIDEO_FRAME_W = source_layout["video_w"]
|
|
||||||
VIDEO_FRAME_H = source_layout["video_h"]
|
|
||||||
BOTTOM_PANEL_H = source_layout["bottom_h"]
|
|
||||||
SINGLE_PANEL_W = source_layout["single_panel_w"]
|
|
||||||
UI_SCALE = max(0.1, screen_h / float(TARGET_ASPECT_H_TOTAL))
|
|
||||||
|
|
||||||
combined_win = CombinedTactileWindow()
|
|
||||||
combined_win.setWindowTitle("Wuxi tactile — dual flow + forces")
|
|
||||||
main_layout = QVBoxLayout(combined_win)
|
|
||||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
main_layout.setSpacing(0)
|
|
||||||
|
|
||||||
video_label = QLabel()
|
|
||||||
video_label.setAlignment(Qt.AlignCenter)
|
|
||||||
video_label.setStyleSheet("background-color: #000000; margin: 0; padding: 0; border: none;")
|
|
||||||
video_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
||||||
video_label.setMinimumSize(0, 0)
|
|
||||||
main_layout.addWidget(video_label, stretch=VIDEO_FRAME_H)
|
|
||||||
|
|
||||||
bottom_container = QWidget()
|
|
||||||
bottom_layout = QHBoxLayout(bottom_container)
|
|
||||||
bottom_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
bottom_layout.setSpacing(0)
|
|
||||||
main_layout.addWidget(bottom_container, stretch=BOTTOM_PANEL_H)
|
|
||||||
|
|
||||||
panel1_ui = build_sensor_panel(bottom_layout)
|
|
||||||
panel2_ui = build_sensor_panel(bottom_layout)
|
|
||||||
|
|
||||||
if USE_FRAMELESS_WINDOW:
|
|
||||||
combined_win.setWindowFlags(Qt.Window | Qt.FramelessWindowHint)
|
|
||||||
# Client area matches theme (no bright border flash on frameless)
|
|
||||||
combined_win.setStyleSheet(f"background-color: {PLOT_THEME_BG};")
|
|
||||||
combined_win.resize(ASPECT_W, ASPECT_H_TOTAL)
|
|
||||||
combined_win.install_drag_filters()
|
|
||||||
combined_win.showFullScreen()
|
|
||||||
closed_hour_overlay = QLabel(combined_win)
|
|
||||||
closed_hour_overlay.setAlignment(Qt.AlignCenter)
|
|
||||||
closed_hour_overlay.setStyleSheet(
|
|
||||||
f"background-color: {PLOT_THEME_BG}; color: #A0A0A0; "
|
|
||||||
f"font-size: {scale_ui_px(72, floor=32)}px; font-weight: bold; "
|
|
||||||
"margin: 0; padding: 0; border: none;"
|
|
||||||
)
|
|
||||||
closed_hour_overlay.setText("Closed hour\n非营业时间")
|
|
||||||
closed_hour_overlay.hide()
|
|
||||||
start_time = None # Will be set when sensor starts
|
|
||||||
fps = 30.0
|
|
||||||
|
|
||||||
|
|
||||||
def outside_opening_hours(now):
|
|
||||||
return now.hour < open_hour or now.hour >= close_hour
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
global start_time
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--video1","-v1", type=str, default="0", help="视频文件路径")
|
|
||||||
parser.add_argument("--video2","-v2", type=str, default="0", help="视频文件路径")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
session_start = datetime.now()
|
|
||||||
print(
|
|
||||||
f"[session] START {session_start:%Y-%m-%d %H:%M:%S} (long-run / crash log marker)",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
if USE_FRAMELESS_WINDOW:
|
|
||||||
print(
|
|
||||||
"[window] Frameless: resize = drag window edges/corners; move = Alt+left-drag; quit = Q.",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
sensor1 = None
|
|
||||||
sensor2 = None
|
|
||||||
video_cap = None
|
|
||||||
try:
|
|
||||||
input1 = int(args.video1)
|
|
||||||
input2 = int(args.video2)
|
|
||||||
is_camera = True
|
|
||||||
except:
|
|
||||||
is_camera = False
|
|
||||||
try:
|
|
||||||
if is_camera:
|
|
||||||
sensor1 = orisys.Sensor(
|
|
||||||
input1,
|
|
||||||
config_name="./config/ddjx01_wuxi.json",
|
|
||||||
cal_path="./config/dd01a_0306.npy",
|
|
||||||
isstitch=True,
|
|
||||||
cuda=False,
|
|
||||||
verbose=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
sensor2 = orisys.Sensor(
|
|
||||||
input2,
|
|
||||||
config_name="./config/ddjx01_wuxi.json",
|
|
||||||
cal_path="./config/dd01a_0306.npy",
|
|
||||||
isstitch=True,
|
|
||||||
cuda=False,
|
|
||||||
verbose=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
sensor1 = orisys.Sensor(
|
|
||||||
args.video1,
|
|
||||||
config_name="./config/ddjx01_wuxi.json",
|
|
||||||
cal_path="./config/dd01a_0306.npy",
|
|
||||||
isstitch=True,
|
|
||||||
cuda=False,
|
|
||||||
verbose=False,
|
|
||||||
)
|
|
||||||
sensor2 = orisys.Sensor(
|
|
||||||
args.video2,
|
|
||||||
config_name="./config/ddjx01_wuxi.json",
|
|
||||||
cal_path="./config/dd01a_0306.npy",
|
|
||||||
isstitch=True,
|
|
||||||
cuda=False,
|
|
||||||
verbose=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _shortcut_reset_tracker():
|
|
||||||
sensor1.reset()
|
|
||||||
sensor2.reset()
|
|
||||||
print("传感器已重置 (快捷键 R)", flush=True)
|
|
||||||
|
|
||||||
def _shortcut_quit():
|
|
||||||
combined_win.close()
|
|
||||||
def _shortcut_toggle_open():
|
|
||||||
global is_open
|
|
||||||
is_open = not is_open
|
|
||||||
print(f"营业状态: {'营业' if is_open else '非营业'}", flush=True)
|
|
||||||
|
|
||||||
_sc_r = QShortcut(QKeySequence(Qt.Key_R), combined_win)
|
|
||||||
_sc_r.activated.connect(_shortcut_reset_tracker)
|
|
||||||
if manual_open_enable:
|
|
||||||
_sc_g = QShortcut(QKeySequence(Qt.Key_G), combined_win)
|
|
||||||
_sc_g.activated.connect(_shortcut_toggle_open)
|
|
||||||
_sc_q = QShortcut(QKeySequence(Qt.Key_Q), combined_win)
|
|
||||||
_sc_q.activated.connect(_shortcut_quit)
|
|
||||||
|
|
||||||
video_cap = cv2.VideoCapture(VIDEO_LOOP_PATH)
|
|
||||||
video_clock = {"fps": 30.0, "next_ts": None}
|
|
||||||
if not video_cap.isOpened():
|
|
||||||
print(f"[video] Failed to open loop video: {VIDEO_LOOP_PATH}", flush=True)
|
|
||||||
else:
|
|
||||||
src_fps = float(video_cap.get(cv2.CAP_PROP_FPS) or 0.0)
|
|
||||||
if np.isfinite(src_fps) and src_fps >= 1.0:
|
|
||||||
video_clock["fps"] = src_fps
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
is_save = False
|
|
||||||
frame_count = 0
|
|
||||||
writer = None
|
|
||||||
sensor1_active = True
|
|
||||||
sensor2_active = True
|
|
||||||
# Local to main(): assignments inside the loop make this local; must exist
|
|
||||||
# before the first iteration that skips the closed branch.
|
|
||||||
start_open = False
|
|
||||||
while True:
|
|
||||||
now = datetime.now()
|
|
||||||
if outside_opening_hours(now) or not is_open:
|
|
||||||
start_open = False
|
|
||||||
closed_hour_overlay.setGeometry(0, 0, combined_win.width(), combined_win.height())
|
|
||||||
closed_hour_overlay.raise_()
|
|
||||||
closed_hour_overlay.show()
|
|
||||||
app.processEvents()
|
|
||||||
if combined_win.user_closed or not combined_win.isVisible():
|
|
||||||
print("\n窗口已关闭,退出程序")
|
|
||||||
if writer is not None:
|
|
||||||
writer.release()
|
|
||||||
writer = None
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not start_open:
|
|
||||||
start_open = True
|
|
||||||
sensor1.reset()
|
|
||||||
sensor2.reset()
|
|
||||||
print(f"传感器已重置: {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
|
||||||
closed_hour_overlay.hide()
|
|
||||||
|
|
||||||
if video_cap is not None and video_cap.isOpened():
|
|
||||||
vret, vframe = read_loop_video_frame_realtime(video_cap, video_clock)
|
|
||||||
if vret:
|
|
||||||
if vframe.shape[1] != VIDEO_FRAME_W or vframe.shape[0] != VIDEO_FRAME_H:
|
|
||||||
vframe = cv2.resize(vframe, (VIDEO_FRAME_W, VIDEO_FRAME_H), interpolation=cv2.INTER_LINEAR)
|
|
||||||
set_video_label_bgr(video_label, vframe)
|
|
||||||
|
|
||||||
if sensor1_active:
|
|
||||||
img1_valid = sensor1.get_img()
|
|
||||||
if img1_valid is None:
|
|
||||||
panel1_ui["arrow_label"].hide()
|
|
||||||
panel1_ui["plot_widget"].hide()
|
|
||||||
panel1_ui["disconnected_label"].show()
|
|
||||||
sensor1_active = False
|
|
||||||
print(f"Sensor1 disconnected. Showing DISCONNECTED screen. {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
|
||||||
else:
|
|
||||||
sensor1.compute_deformation(check_motion=True, tail_frames=frame_to_recover, threshold=touch_threshold)
|
|
||||||
_, fn, fx, fy, flow = sensor1.read_info(
|
|
||||||
sensor1.info.FPS,
|
|
||||||
sensor1.info.FNORMAL,
|
|
||||||
sensor1.info.FSHEARX,
|
|
||||||
sensor1.info.FSHEARY,
|
|
||||||
sensor1.info.VRAW,
|
|
||||||
)
|
|
||||||
_bg_bgr = (22, 16, 11)
|
|
||||||
_img = sensor1.img
|
|
||||||
_h, _w = _img.shape[:2]
|
|
||||||
black_img = np.empty((_h, _w, 3), dtype=_img.dtype)
|
|
||||||
black_img[:, :, 0] = _bg_bgr[0]
|
|
||||||
black_img[:, :, 1] = _bg_bgr[1]
|
|
||||||
black_img[:, :, 2] = _bg_bgr[2]
|
|
||||||
lw, lh = panel1_ui["arrow_label"].width(), panel1_ui["arrow_label"].height()
|
|
||||||
if lw > 16 and lh > 16:
|
|
||||||
side = max(32, min(lw, lh))
|
|
||||||
else:
|
|
||||||
side = compute_arrow_canvas_side(panel1_ui["panel"])
|
|
||||||
arrows = draw_arrows_wuxi(
|
|
||||||
black_img,
|
|
||||||
flow,
|
|
||||||
threshold=2,
|
|
||||||
grid_spacing=20,
|
|
||||||
arrow_scale=0.5,
|
|
||||||
below_threshold_color=(160, 160, 160),
|
|
||||||
target_size=int(side),
|
|
||||||
)
|
|
||||||
set_arrow_label_bgr(panel1_ui["arrow_label"], arrows)
|
|
||||||
data1 = panel1_ui["data"]
|
|
||||||
for i in range(3):
|
|
||||||
data1[:-1, 0, i] = data1[1:, 0, i]
|
|
||||||
data1[:-1, 1, i] = data1[1:, 1, i]
|
|
||||||
data1[-1, 0, :] = frame_count / 15.0
|
|
||||||
data1[-1, 1, 0] = fn
|
|
||||||
data1[-1, 1, 1] = abs(fx)
|
|
||||||
data1[-1, 1, 2] = abs(fy)
|
|
||||||
update(
|
|
||||||
panel1_ui["plot_af"],
|
|
||||||
panel1_ui["plot_tfx"],
|
|
||||||
panel1_ui["plot_tfy"],
|
|
||||||
data1,
|
|
||||||
panel1_ui["p1"],
|
|
||||||
panel1_ui["p2"],
|
|
||||||
panel1_ui["p3"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if sensor2_active:
|
|
||||||
img2_valid = sensor2.get_img()
|
|
||||||
if img2_valid is None:
|
|
||||||
panel2_ui["arrow_label"].hide()
|
|
||||||
panel2_ui["plot_widget"].hide()
|
|
||||||
panel2_ui["disconnected_label"].show()
|
|
||||||
sensor2_active = False
|
|
||||||
print(f"Sensor2 disconnected. Showing DISCONNECTED screen. {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
|
||||||
else:
|
|
||||||
sensor2.compute_deformation(check_motion=True, tail_frames=frame_to_recover, threshold=touch_threshold)
|
|
||||||
_, fn2, fx2, fy2, flow2 = sensor2.read_info(
|
|
||||||
sensor2.info.FPS,
|
|
||||||
sensor2.info.FNORMAL,
|
|
||||||
sensor2.info.FSHEARX,
|
|
||||||
sensor2.info.FSHEARY,
|
|
||||||
sensor2.info.VRAW,
|
|
||||||
)
|
|
||||||
_bg_bgr2 = (22, 16, 11)
|
|
||||||
_img2 = sensor2.img
|
|
||||||
_h2, _w2 = _img2.shape[:2]
|
|
||||||
black_img2 = np.empty((_h2, _w2, 3), dtype=_img2.dtype)
|
|
||||||
black_img2[:, :, 0] = _bg_bgr2[0]
|
|
||||||
black_img2[:, :, 1] = _bg_bgr2[1]
|
|
||||||
black_img2[:, :, 2] = _bg_bgr2[2]
|
|
||||||
lw2, lh2 = panel2_ui["arrow_label"].width(), panel2_ui["arrow_label"].height()
|
|
||||||
if lw2 > 16 and lh2 > 16:
|
|
||||||
side2 = max(32, min(lw2, lh2))
|
|
||||||
else:
|
|
||||||
side2 = compute_arrow_canvas_side(panel2_ui["panel"])
|
|
||||||
arrows2 = draw_arrows_wuxi(
|
|
||||||
black_img2,
|
|
||||||
flow2,
|
|
||||||
threshold=2,
|
|
||||||
grid_spacing=20,
|
|
||||||
arrow_scale=0.5,
|
|
||||||
below_threshold_color=(160, 160, 160),
|
|
||||||
target_size=int(side2),
|
|
||||||
)
|
|
||||||
set_arrow_label_bgr(panel2_ui["arrow_label"], arrows2)
|
|
||||||
data2 = panel2_ui["data"]
|
|
||||||
for i in range(3):
|
|
||||||
data2[:-1, 0, i] = data2[1:, 0, i]
|
|
||||||
data2[:-1, 1, i] = data2[1:, 1, i]
|
|
||||||
data2[-1, 0, :] = frame_count / 15.0
|
|
||||||
data2[-1, 1, 0] = fn2
|
|
||||||
data2[-1, 1, 1] = abs(fx2)
|
|
||||||
data2[-1, 1, 2] = abs(fy2)
|
|
||||||
update(
|
|
||||||
panel2_ui["plot_af"],
|
|
||||||
panel2_ui["plot_tfx"],
|
|
||||||
panel2_ui["plot_tfy"],
|
|
||||||
data2,
|
|
||||||
panel2_ui["p1"],
|
|
||||||
panel2_ui["p2"],
|
|
||||||
panel2_ui["p3"],
|
|
||||||
)
|
|
||||||
|
|
||||||
app.processEvents()
|
|
||||||
now = datetime.now()
|
|
||||||
if now.hour == 9 and now.minute == 0 and now.second == 0:
|
|
||||||
if sensor1_active:
|
|
||||||
sensor1.reset()
|
|
||||||
if sensor2_active:
|
|
||||||
sensor2.reset()
|
|
||||||
print(
|
|
||||||
f"[session] 传感器已重置: {now:%Y-%m-%d %H:%M:%S} ",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not sensor1_active and not sensor2_active:
|
|
||||||
while not combined_win.user_closed and combined_win.isVisible():
|
|
||||||
app.processEvents()
|
|
||||||
time.sleep(0.05)
|
|
||||||
break
|
|
||||||
|
|
||||||
frame_count += 1
|
|
||||||
app.processEvents()
|
|
||||||
|
|
||||||
if combined_win.user_closed or not combined_win.isVisible():
|
|
||||||
print("\n窗口已关闭,退出程序")
|
|
||||||
if writer is not None:
|
|
||||||
writer.release()
|
|
||||||
writer = None
|
|
||||||
break
|
|
||||||
|
|
||||||
# Convert combined window to image (arrows + curves)
|
|
||||||
pixmap = combined_win.grab()
|
|
||||||
# Convert QPixmap to QImage
|
|
||||||
qimg = pixmap.toImage()
|
|
||||||
# Convert QImage to numpy array (RGB format)
|
|
||||||
qimg = qimg.convertToFormat(QImage.Format_RGB888)
|
|
||||||
width = qimg.width()
|
|
||||||
height = qimg.height()
|
|
||||||
# Convert QImage to numpy array
|
|
||||||
ptr = qimg.bits()
|
|
||||||
ptr.setsize(height * width * 3)
|
|
||||||
arr = np.array(ptr, dtype=np.uint8).reshape((height, width, 3))
|
|
||||||
|
|
||||||
except BaseException as exc:
|
|
||||||
session_abnormal = datetime.now()
|
|
||||||
print(
|
|
||||||
f"[session] ABNORMAL at {session_abnormal:%Y-%m-%d %H:%M:%S}: "
|
|
||||||
f"{type(exc).__name__}: {exc}",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
session_end = datetime.now()
|
|
||||||
elapsed = session_end - session_start
|
|
||||||
print(
|
|
||||||
f"[session] END {session_end:%Y-%m-%d %H:%M:%S} "
|
|
||||||
f"(elapsed {elapsed})",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
if sensor1 is not None:
|
|
||||||
try:
|
|
||||||
sensor1.disconnect()
|
|
||||||
except Exception as disconnect_err:
|
|
||||||
print(f"[session] disconnect error: {disconnect_err} {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
|
||||||
if 'sensor2' in locals() and sensor2 is not None:
|
|
||||||
try:
|
|
||||||
sensor2.disconnect()
|
|
||||||
except Exception as disconnect_err:
|
|
||||||
print(f"[session] disconnect error (sensor2): {disconnect_err} {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
|
||||||
if video_cap is not None:
|
|
||||||
video_cap.release()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Shim for the Orisys finger Qt dashboard (see examples/gui/)."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
|
||||||
|
|
||||||
_EXAMPLES_DIR = Path(__file__).resolve().parents[1] / "examples"
|
|
||||||
if str(_EXAMPLES_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_EXAMPLES_DIR))
|
|
||||||
|
|
||||||
from argparse import Namespace
|
|
||||||
|
|
||||||
from gui import run_dashboard
|
|
||||||
from pipeline import PipelineConfig
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Orisys finger Qt dashboard")
|
|
||||||
parser.add_argument("--video", "-v", type=str, default="0")
|
|
||||||
parser.add_argument("--config", "-c", type=str, default="dd02")
|
|
||||||
parser.add_argument("--cal", type=str, default="")
|
|
||||||
parser.add_argument("--verbose", action="store_true")
|
|
||||||
parser.add_argument("--cutoff", type=float, default=10.0)
|
|
||||||
parser.add_argument("--no-3d", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
config = PipelineConfig.from_args(
|
|
||||||
Namespace(
|
|
||||||
video=args.video,
|
|
||||||
config=args.config,
|
|
||||||
cal=args.cal,
|
|
||||||
verbose=args.verbose,
|
|
||||||
mesh_map="depth",
|
|
||||||
threshold=None,
|
|
||||||
viz_pose="assets/finger/visualization/world_to_cam.npy",
|
|
||||||
no_3d=args.no_3d,
|
|
||||||
cutoff=args.cutoff,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sys.exit(run_dashboard(config))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import av
|
|
||||||
import cv2
|
|
||||||
import platform
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
TARGET_FPS = 30
|
|
||||||
WIDTH = 1920
|
|
||||||
HEIGHT = 1080
|
|
||||||
|
|
||||||
|
|
||||||
def list_windows_dshow_devices():
|
|
||||||
"""
|
|
||||||
Print DirectShow devices using ffmpeg, if available.
|
|
||||||
"""
|
|
||||||
cmd = ["ffmpeg", "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"]
|
|
||||||
print("Listing Windows DirectShow devices via ffmpeg...")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
||||||
except FileNotFoundError as exc:
|
|
||||||
raise RuntimeError("ffmpeg was not found in PATH; cannot list DirectShow devices.") from exc
|
|
||||||
|
|
||||||
output = (result.stderr or "") + (result.stdout or "")
|
|
||||||
print(output.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def open_pyav_capture(system_name, device, pixel_format):
|
|
||||||
"""
|
|
||||||
Open a camera device via PyAV on Linux or Windows.
|
|
||||||
"""
|
|
||||||
if system_name == "linux":
|
|
||||||
options = {
|
|
||||||
"video_size": f"{WIDTH}x{HEIGHT}",
|
|
||||||
"framerate": str(TARGET_FPS),
|
|
||||||
"input_format": pixel_format or "mjpeg",
|
|
||||||
}
|
|
||||||
container = av.open(device or "/dev/video4", format="v4l2", options=options)
|
|
||||||
elif system_name == "windows":
|
|
||||||
if not device:
|
|
||||||
raise RuntimeError(
|
|
||||||
"On Windows, PyAV/dshow requires a camera name, for example "
|
|
||||||
'--device "USB Camera". Use --list-devices first.'
|
|
||||||
)
|
|
||||||
options = {
|
|
||||||
"video_size": f"{WIDTH}x{HEIGHT}",
|
|
||||||
"framerate": str(TARGET_FPS),
|
|
||||||
}
|
|
||||||
if pixel_format:
|
|
||||||
options["pixel_format"] = pixel_format
|
|
||||||
container = av.open(f"video={device}", format="dshow", options=options)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Unsupported OS: {platform.system()}. Only Linux and Windows are supported."
|
|
||||||
)
|
|
||||||
|
|
||||||
stream = container.streams.video[0]
|
|
||||||
return container, stream
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Capture from camera via PyAV on Linux or Windows, optionally save to MP4."
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-d",
|
|
||||||
"--device",
|
|
||||||
type=str,
|
|
||||||
default="",
|
|
||||||
help=(
|
|
||||||
"Linux: V4L2 device path like /dev/video4. "
|
|
||||||
"Windows: DirectShow camera name like 'USB Camera'. "
|
|
||||||
"If empty, Linux uses /dev/video4; Windows requires --device."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--pixel-format",
|
|
||||||
type=str,
|
|
||||||
default="",
|
|
||||||
help=(
|
|
||||||
"Optional input pixel format / format hint. "
|
|
||||||
"Examples: Linux 'mjpeg'; Windows may accept values such as 'mjpeg' if supported."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--list-devices",
|
|
||||||
action="store_true",
|
|
||||||
help="On Windows, list DirectShow device names via ffmpeg and exit.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-o",
|
|
||||||
"--output",
|
|
||||||
type=str,
|
|
||||||
default="",
|
|
||||||
help="Output MP4 path (e.g. capture.mp4). If empty, only preview, no file.",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
system_name = platform.system().lower()
|
|
||||||
if args.list_devices:
|
|
||||||
if system_name != "windows":
|
|
||||||
raise RuntimeError("--list-devices is currently only implemented for Windows.")
|
|
||||||
list_windows_dshow_devices()
|
|
||||||
return
|
|
||||||
|
|
||||||
container, stream = open_pyav_capture(system_name, args.device, args.pixel_format)
|
|
||||||
print(
|
|
||||||
f"Using PyAV backend on {platform.system()} "
|
|
||||||
f"with device={args.device or '/dev/video4'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
frame_count = 0
|
|
||||||
t_start = time.perf_counter()
|
|
||||||
|
|
||||||
writer = None
|
|
||||||
if args.output:
|
|
||||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
|
||||||
writer = cv2.VideoWriter(
|
|
||||||
args.output, fourcc, float(TARGET_FPS), (WIDTH, HEIGHT)
|
|
||||||
)
|
|
||||||
if not writer.isOpened():
|
|
||||||
container.close()
|
|
||||||
raise RuntimeError(f"Could not open VideoWriter for {args.output}")
|
|
||||||
print(f"Recording to {args.output} @ {TARGET_FPS} fps, {WIDTH}x{HEIGHT}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
for frame in container.decode(stream):
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
img = frame.to_ndarray(format="bgr24") # OpenCV-compatible
|
|
||||||
t1 = time.perf_counter()
|
|
||||||
|
|
||||||
if writer is not None:
|
|
||||||
writer.write(img)
|
|
||||||
|
|
||||||
cv2.imshow("pyav_cam", img)
|
|
||||||
key = cv2.waitKey(1) & 0xFF
|
|
||||||
t2 = time.perf_counter()
|
|
||||||
|
|
||||||
frame_count += 1
|
|
||||||
if frame_count % 30 == 0:
|
|
||||||
elapsed = t2 - t_start
|
|
||||||
print(
|
|
||||||
f"Frame {frame_count}: convert={1000*(t1-t0):.1f}ms, "
|
|
||||||
f"imshow+waitKey={1000*(t2-t1):.1f}ms, avg_fps={frame_count/elapsed:.1f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if key == ord("q"):
|
|
||||||
break
|
|
||||||
finally:
|
|
||||||
container.close()
|
|
||||||
if writer is not None:
|
|
||||||
writer.release()
|
|
||||||
print(f"Saved {frame_count} frames to {args.output}")
|
|
||||||
cv2.destroyAllWindows()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"normal_force": {
|
||||||
|
"model": "linear",
|
||||||
|
"slope": 3.92685554668795e-05,
|
||||||
|
"offset": 0.0217596967278535,
|
||||||
|
"unit": "N"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "prototype",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [1920, 1080],
|
||||||
|
"fps": 30,
|
||||||
|
"units": 15,
|
||||||
|
"units_used": 9,
|
||||||
|
"resolution_stitch": [365, 365],
|
||||||
|
"stitch_res_list": [365,263,123,63]
|
||||||
|
},
|
||||||
|
"stitcher": {
|
||||||
|
"min_area_frac": 0.01,
|
||||||
|
"max_area_frac": 0.05,
|
||||||
|
"max_aspect_ratio": 50,
|
||||||
|
"index": [1,2,3,6,7,8,11,12,13],
|
||||||
|
"margin": 10,
|
||||||
|
"nominal_ov_pos": 100,
|
||||||
|
"n": [3,3],
|
||||||
|
"row_tol": 0.6,
|
||||||
|
"box_size": [250, 250],
|
||||||
|
"blend":"numba",
|
||||||
|
"blend_list": ["numpy", "numba","none"]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 3,
|
||||||
|
"algorithm_list": ["raft", "dis"],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold":5,
|
||||||
|
"div_threshold":0.05,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [5,5],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Place the ORY-FINGER runtime asset bundle files in this directory before
|
||||||
|
building release wheels:
|
||||||
|
|
||||||
|
- `mesh.npz`
|
||||||
|
- `camera_intrinsics.npz`
|
||||||
|
- `world_to_cam.npy`
|
||||||
|
|
||||||
|
These binary assets are validated by the wheel build and are intentionally not
|
||||||
|
generated by the package build step.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
Place visualization camera assets in this directory before building release
|
||||||
|
wheels:
|
||||||
|
|
||||||
|
- `world_to_cam.npy`
|
||||||
|
|
||||||
|
The 3D dashboard uses this pose as the default view preset when available.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "finger",
|
||||||
|
"sensor_json": "sensor.json",
|
||||||
|
"assets_dir": "assets/30fps-0703",
|
||||||
|
"viz_pose": "assets/visualization/world_to_cam.npy"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "dd02-ov",
|
||||||
|
"type": "single_camera",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [1280, 720],
|
||||||
|
"fps": 30,
|
||||||
|
"resolution_stitch": [280, 300],
|
||||||
|
"source_scale": 0.4,
|
||||||
|
"old":[672, 728, 1,0.5],
|
||||||
|
"work": [504, 546,1],
|
||||||
|
"work2": [420, 450,0.8],
|
||||||
|
"work3": [350, 375,4,0.7],
|
||||||
|
"work4": [280, 300,4,0.4,0.2]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 1,
|
||||||
|
"algorithm_list": ["raft", "dis"],
|
||||||
|
"hhd_downsample": 5
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold": 0.2,
|
||||||
|
"div_threshold": 0.022,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [5, 5],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"normal_force": {
|
||||||
|
"model": "linear",
|
||||||
|
"slope": 3.92685554668795e-05,
|
||||||
|
"offset": 0.0217596967278535,
|
||||||
|
"unit": "N"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "prototype",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [1920, 1080],
|
||||||
|
"fps": 30,
|
||||||
|
"units": 15,
|
||||||
|
"units_used": 9,
|
||||||
|
"resolution_stitch": [365, 365],
|
||||||
|
"stitch_res_list": [365,263,123,63]
|
||||||
|
},
|
||||||
|
"stitcher": {
|
||||||
|
"min_area_frac": 0.01,
|
||||||
|
"max_area_frac": 0.05,
|
||||||
|
"max_aspect_ratio": 50,
|
||||||
|
"index": [1,2,3,6,7,8,11,12,13],
|
||||||
|
"margin": 10,
|
||||||
|
"nominal_ov_pos": 100,
|
||||||
|
"n": [3,3],
|
||||||
|
"row_tol": 0.6,
|
||||||
|
"box_size": [250, 250],
|
||||||
|
"blend":"numba",
|
||||||
|
"blend_list": ["numpy", "numba","none"]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 3,
|
||||||
|
"algorithm_list": ["raft", "dis"],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold":5,
|
||||||
|
"div_threshold":0.05,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [5,5],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,8 +6,8 @@ import sys
|
|||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent
|
REPO_ROOT = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, str(REPO_ROOT / "build"))
|
sys.path.insert(0, str(REPO_ROOT / "build" / "common"))
|
||||||
|
|
||||||
from common.pyproject_utils import bundle_data_files
|
from pyproject_utils import bundle_data_files
|
||||||
|
|
||||||
setup(data_files=bundle_data_files(REPO_ROOT))
|
setup(data_files=bundle_data_files(REPO_ROOT))
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ 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.4.2"
|
__version__ = "0.4.3"
|
||||||
__author__ = "Orisys"
|
__author__ = "Orisys"
|
||||||
|
|
||||||
from .sensor import Sensor
|
from .sensor import Sensor
|
||||||
from . import util
|
from . import util
|
||||||
from . import configs
|
from . import configs
|
||||||
from . import finger
|
from . import finger
|
||||||
|
from . import signal_processing
|
||||||
from .frame_processors import FFCFrameProcessor
|
from .frame_processors import FFCFrameProcessor
|
||||||
|
|
||||||
CompoundEyeSensor = Sensor
|
CompoundEyeSensor = Sensor
|
||||||
@@ -33,9 +34,12 @@ __all__ = [
|
|||||||
# finger runtime helpers
|
# finger runtime helpers
|
||||||
'finger',
|
'finger',
|
||||||
|
|
||||||
|
# signal helpers
|
||||||
|
'signal_processing',
|
||||||
|
|
||||||
# configs utilities functions
|
# configs utilities functions
|
||||||
'list_config',
|
'list_config',
|
||||||
'export_config'
|
'export_config',
|
||||||
|
|
||||||
# test
|
# test
|
||||||
'FFCFrameProcessor'
|
'FFCFrameProcessor'
|
||||||
|
|||||||
+131
-4
@@ -1,12 +1,28 @@
|
|||||||
import json
|
import json
|
||||||
|
from contextlib import ExitStack
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from importlib.resources import as_file, files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Product-facing names that map to on-disk config stems.
|
# Product-facing names that map to on-disk config stems.
|
||||||
# ORY-FINGER currently ships as dd02-ov.json; keep "dd02" as the stable alias.
|
# Public product stems are ``box.json`` and ``finger.json``.
|
||||||
|
# Keep older lab names working: ``dd02`` still resolves to ``dd02-ov``.
|
||||||
CONFIG_ALIASES = {
|
CONFIG_ALIASES = {
|
||||||
"dd02": "dd02-ov",
|
"dd02": "dd02-ov",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PRODUCT_BUNDLES = ("box", "mini", "finger")
|
||||||
|
_RESOURCE_STACK = ExitStack()
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_path(*parts: str) -> Path | None:
|
||||||
|
"""Resolve a package resource to a filesystem path for wheel-safe loaders."""
|
||||||
|
try:
|
||||||
|
resource = files("orisys").joinpath(*parts)
|
||||||
|
return _RESOURCE_STACK.enter_context(as_file(resource))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _candidate_config_dirs() -> list[Path]:
|
def _candidate_config_dirs() -> list[Path]:
|
||||||
"""
|
"""
|
||||||
@@ -27,6 +43,26 @@ def _candidate_config_dirs() -> list[Path]:
|
|||||||
return unique
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_sensor_roots() -> list[Path]:
|
||||||
|
"""Candidate roots that contain per-sensor bundle folders."""
|
||||||
|
here = Path(__file__).resolve().parent
|
||||||
|
candidates = []
|
||||||
|
packaged = _resource_path("sensors")
|
||||||
|
if packaged is not None:
|
||||||
|
candidates.append(packaged) # packaged wheel layout: orisys/sensors/
|
||||||
|
candidates.extend([
|
||||||
|
here / "sensors",
|
||||||
|
here.parent.parent / "sensors", # editable install: repo_root/sensors/
|
||||||
|
here.parent / "sensors",
|
||||||
|
Path.cwd() / "sensors",
|
||||||
|
])
|
||||||
|
unique: list[Path] = []
|
||||||
|
for path in candidates:
|
||||||
|
if path not in unique:
|
||||||
|
unique.append(path)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
def _get_config_dir() -> Path:
|
def _get_config_dir() -> Path:
|
||||||
for candidate in _candidate_config_dirs():
|
for candidate in _candidate_config_dirs():
|
||||||
if candidate.exists() and any(candidate.glob("*.json")):
|
if candidate.exists() and any(candidate.glob("*.json")):
|
||||||
@@ -49,14 +85,96 @@ def _load_json(path: Path) -> dict:
|
|||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SensorBundle:
|
||||||
|
"""Resolved paths for one additive per-sensor package folder."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
root: Path
|
||||||
|
sensor_json: Path
|
||||||
|
force_calibration_json: Path | None = None
|
||||||
|
manifest_json: Path | None = None
|
||||||
|
assets_dir: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_sensor_bundle(name: str) -> SensorBundle | None:
|
||||||
|
"""Resolve ``sensors/<name>/`` when present; otherwise return ``None``."""
|
||||||
|
requested = CONFIG_ALIASES.get(str(name), str(name))
|
||||||
|
for root in _candidate_sensor_roots():
|
||||||
|
bundle_root = root / requested
|
||||||
|
sensor_json = bundle_root / "sensor.json"
|
||||||
|
if not sensor_json.is_file():
|
||||||
|
continue
|
||||||
|
force_path = bundle_root / "force_calibration.json"
|
||||||
|
manifest_path = bundle_root / "manifest.json"
|
||||||
|
assets_dir = None
|
||||||
|
if manifest_path.is_file():
|
||||||
|
try:
|
||||||
|
manifest = _load_json(manifest_path)
|
||||||
|
except Exception:
|
||||||
|
manifest = {}
|
||||||
|
rel_assets = manifest.get("assets_dir")
|
||||||
|
if isinstance(rel_assets, str) and rel_assets.strip():
|
||||||
|
candidate = (bundle_root / rel_assets).resolve()
|
||||||
|
if candidate.is_dir():
|
||||||
|
assets_dir = candidate
|
||||||
|
return SensorBundle(
|
||||||
|
name=requested,
|
||||||
|
root=bundle_root.resolve(),
|
||||||
|
sensor_json=sensor_json.resolve(),
|
||||||
|
force_calibration_json=force_path.resolve() if force_path.is_file() else None,
|
||||||
|
manifest_json=manifest_path.resolve() if manifest_path.is_file() else None,
|
||||||
|
assets_dir=assets_dir,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_sensor_config_path(name: str) -> Path | None:
|
||||||
|
"""Return ``sensor.json`` for a resolved sensor bundle."""
|
||||||
|
bundle = resolve_sensor_bundle(name)
|
||||||
|
return bundle.sensor_json if bundle is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_sensor_asset_dir(name: str) -> Path | None:
|
||||||
|
"""Return the default asset directory for a resolved sensor bundle."""
|
||||||
|
bundle = resolve_sensor_bundle(name)
|
||||||
|
return bundle.assets_dir if bundle is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_sensor_resource(name: str, *relative_parts: str) -> Path | None:
|
||||||
|
"""Return a path inside one sensor bundle when present."""
|
||||||
|
bundle = resolve_sensor_bundle(name)
|
||||||
|
if bundle is None:
|
||||||
|
return None
|
||||||
|
return bundle.root.joinpath(*relative_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def list_sensor_bundles() -> list[str]:
|
||||||
|
"""Return product names that have an additive ``sensors/<name>/`` folder."""
|
||||||
|
found: list[str] = []
|
||||||
|
for name in PRODUCT_BUNDLES:
|
||||||
|
if resolve_sensor_bundle(name) is not None:
|
||||||
|
found.append(name)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def load_force_calibration(name: str) -> dict | None:
|
||||||
|
"""Load ``force_calibration.json`` from a sensor bundle when available."""
|
||||||
|
bundle = resolve_sensor_bundle(name)
|
||||||
|
if bundle is None or bundle.force_calibration_json is None:
|
||||||
|
return None
|
||||||
|
return _load_json(bundle.force_calibration_json)
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_name_or_path):
|
def load_config(config_name_or_path):
|
||||||
"""
|
"""
|
||||||
Load sensor configuration from a JSON file path or config name.
|
Load sensor configuration from a JSON file path or config name.
|
||||||
|
|
||||||
Config lookup order:
|
Config lookup order:
|
||||||
1. Exact file path (absolute or relative)
|
1. Exact file path (absolute or relative)
|
||||||
2. File stem under the config directory (e.g. ``ddjx01`` -> ``config/ddjx01.json``)
|
2. Additive sensor bundle ``sensors/<name>/sensor.json``
|
||||||
3. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
|
3. File stem under the config directory (e.g. ``box`` -> ``config/box.json``)
|
||||||
|
4. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
|
||||||
"""
|
"""
|
||||||
config_path = Path(config_name_or_path)
|
config_path = Path(config_name_or_path)
|
||||||
|
|
||||||
@@ -74,6 +192,11 @@ def load_config(config_name_or_path):
|
|||||||
if alias and alias not in candidates:
|
if alias and alias not in candidates:
|
||||||
candidates.append(alias)
|
candidates.append(alias)
|
||||||
|
|
||||||
|
for name in candidates:
|
||||||
|
bundle = resolve_sensor_bundle(name)
|
||||||
|
if bundle is not None:
|
||||||
|
return _load_json(bundle.sensor_json)
|
||||||
|
|
||||||
for name in candidates:
|
for name in candidates:
|
||||||
direct = CONFIG_DIR / f"{name}.json"
|
direct = CONFIG_DIR / f"{name}.json"
|
||||||
if direct.exists():
|
if direct.exists():
|
||||||
@@ -102,7 +225,11 @@ def list_config():
|
|||||||
"""
|
"""
|
||||||
List all available JSON configuration files by file stem.
|
List all available JSON configuration files by file stem.
|
||||||
"""
|
"""
|
||||||
return [f.stem for f in _config_files()]
|
stems = [f.stem for f in _config_files()]
|
||||||
|
for name in list_sensor_bundles():
|
||||||
|
if name not in stems:
|
||||||
|
stems.append(name)
|
||||||
|
return sorted(stems)
|
||||||
|
|
||||||
|
|
||||||
def export_config(config_name=None, path="./config/exported_config.json"):
|
def export_config(config_name=None, path="./config/exported_config.json"):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import sys
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..configs import resolve_sensor_asset_dir
|
||||||
from ..unroll import (
|
from ..unroll import (
|
||||||
compute_uv_distortion_metrics,
|
compute_uv_distortion_metrics,
|
||||||
load_exported_data,
|
load_exported_data,
|
||||||
@@ -11,7 +12,7 @@ from ..unroll import (
|
|||||||
scaled_map_cache_path,
|
scaled_map_cache_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_FINGER_EXPORT_DIR = "assets/finger/30fps-0703"
|
DEFAULT_FINGER_EXPORT_DIR = "sensors/finger/assets/30fps-0703"
|
||||||
DEFAULT_FINGER_MAP_CACHE = f"{DEFAULT_FINGER_EXPORT_DIR}/unroll_map_uv.npz"
|
DEFAULT_FINGER_MAP_CACHE = f"{DEFAULT_FINGER_EXPORT_DIR}/unroll_map_uv.npz"
|
||||||
|
|
||||||
|
|
||||||
@@ -44,11 +45,15 @@ def _candidate_export_dirs(export_dir) -> list[Path]:
|
|||||||
return [raw]
|
return [raw]
|
||||||
|
|
||||||
here = Path(__file__).resolve()
|
here = Path(__file__).resolve()
|
||||||
candidates = [
|
candidates = []
|
||||||
|
bundled_assets = resolve_sensor_asset_dir("finger")
|
||||||
|
if bundled_assets is not None and raw.as_posix() == DEFAULT_FINGER_EXPORT_DIR:
|
||||||
|
candidates.append(bundled_assets)
|
||||||
|
candidates.extend([
|
||||||
Path.cwd() / raw,
|
Path.cwd() / raw,
|
||||||
here.parents[1] / raw, # packaged wheel: orisys/assets/...
|
here.parents[1] / raw, # packaged wheel filesystem layout
|
||||||
here.parents[3] / raw, # editable install: repo/assets/...
|
here.parents[3] / raw, # editable install: repo/assets/...
|
||||||
]
|
])
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
candidates.insert(0, Path(sys.executable).resolve().parent / raw)
|
candidates.insert(0, Path(sys.executable).resolve().parent / raw)
|
||||||
unique: list[Path] = []
|
unique: list[Path] = []
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ class FingerGeometry:
|
|||||||
def resolve_finger_geometry(
|
def resolve_finger_geometry(
|
||||||
*,
|
*,
|
||||||
export_dir,
|
export_dir,
|
||||||
config="./config/dd02-ov.json",
|
config="finger",
|
||||||
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
||||||
source_scale=None,
|
source_scale=None,
|
||||||
calib=None,
|
calib=None,
|
||||||
@@ -223,7 +223,7 @@ class FingerRuntime:
|
|||||||
cls,
|
cls,
|
||||||
*,
|
*,
|
||||||
export_dir=DEFAULT_FINGER_EXPORT_DIR,
|
export_dir=DEFAULT_FINGER_EXPORT_DIR,
|
||||||
config="./config/dd02-ov.json",
|
config="finger",
|
||||||
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
||||||
source_scale=None,
|
source_scale=None,
|
||||||
calib=None,
|
calib=None,
|
||||||
|
|||||||
+107
-4
@@ -23,6 +23,7 @@ from . import configs
|
|||||||
from .get_depth import Contact3D
|
from .get_depth import Contact3D
|
||||||
from .backend_availability import (check_cupy_available, check_opencl_available)
|
from .backend_availability import (check_cupy_available, check_opencl_available)
|
||||||
from .frame_processors import create_frame_processor
|
from .frame_processors import create_frame_processor
|
||||||
|
from .signal_processing import DEFAULT_SAMPLE_RATE_HZ, LowpassFilter
|
||||||
|
|
||||||
class Sensor:
|
class Sensor:
|
||||||
"""
|
"""
|
||||||
@@ -46,6 +47,9 @@ class Sensor:
|
|||||||
FNORMAL = auto()
|
FNORMAL = auto()
|
||||||
FSHEARX = auto()
|
FSHEARX = auto()
|
||||||
FSHEARY = auto()
|
FSHEARY = auto()
|
||||||
|
FNORMAL_RAW = auto()
|
||||||
|
FSHEARX_RAW = auto()
|
||||||
|
FSHEARY_RAW = auto()
|
||||||
CENTROID = auto()
|
CENTROID = auto()
|
||||||
CONTOUR = auto()
|
CONTOUR = auto()
|
||||||
DEPTH = auto()
|
DEPTH = auto()
|
||||||
@@ -126,6 +130,17 @@ class Sensor:
|
|||||||
self.vshear = self.vtemplate
|
self.vshear = self.vtemplate
|
||||||
self.n_boxes = None
|
self.n_boxes = None
|
||||||
self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0
|
self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0
|
||||||
|
self._force_raw = (0.0, 0.0, 0.0)
|
||||||
|
self._lowpass_enabled = False
|
||||||
|
self._lowpass_cutoff_hz = 0.0
|
||||||
|
self._lowpass_sample_rate_hz = float(DEFAULT_SAMPLE_RATE_HZ)
|
||||||
|
self._lowpass_order = 2
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
# Contact Data Variables (public)
|
# Contact Data Variables (public)
|
||||||
self.centroid = (0, 0)
|
self.centroid = (0, 0)
|
||||||
@@ -397,7 +412,7 @@ class Sensor:
|
|||||||
motion_value = mag.mean()
|
motion_value = mag.mean()
|
||||||
return motion_value > threshold
|
return motion_value > threshold
|
||||||
|
|
||||||
def compute_deformation(self, decompose=True, check_motion=True, threshold=6, tail_frames=10):
|
def compute_deformation(self, decompose=True, check_motion=False, threshold=6, tail_frames=10):
|
||||||
"""
|
"""
|
||||||
Compute optical flow and optionally decompose it into force components.
|
Compute optical flow and optionally decompose it into force components.
|
||||||
|
|
||||||
@@ -529,7 +544,10 @@ class Sensor:
|
|||||||
self._hhd_grid_shape = ds_shape
|
self._hhd_grid_shape = ds_shape
|
||||||
|
|
||||||
_, forces = self.__hhd.get_force(flow_ds, valid_mask=mask_ds)
|
_, forces = self.__hhd.get_force(flow_ds, valid_mask=mask_ds)
|
||||||
self.fnormal, self.fshearx, self.fsheary, self.ftor = forces
|
fn, fx, fy, ftor = forces
|
||||||
|
self._force_raw = (float(fn), float(fx), float(fy))
|
||||||
|
self.ftor = float(ftor)
|
||||||
|
self._apply_force_filter()
|
||||||
|
|
||||||
vnormal_ds = self.__hhd.get_normal()
|
vnormal_ds = self.__hhd.get_normal()
|
||||||
vshear_ds = self.__hhd.get_shear()
|
vshear_ds = self.__hhd.get_shear()
|
||||||
@@ -631,6 +649,9 @@ class Sensor:
|
|||||||
self.info.FNORMAL: lambda: self.fnormal,
|
self.info.FNORMAL: lambda: self.fnormal,
|
||||||
self.info.FSHEARX: lambda: self.fshearx,
|
self.info.FSHEARX: lambda: self.fshearx,
|
||||||
self.info.FSHEARY: lambda: self.fsheary,
|
self.info.FSHEARY: lambda: self.fsheary,
|
||||||
|
self.info.FNORMAL_RAW: lambda: self._force_raw[0],
|
||||||
|
self.info.FSHEARX_RAW: lambda: self._force_raw[1],
|
||||||
|
self.info.FSHEARY_RAW: lambda: self._force_raw[2],
|
||||||
self.info.FPS: lambda: self._fps,
|
self.info.FPS: lambda: self._fps,
|
||||||
self.info.TIMESTAMP: lambda: time.strftime('%H:%M:%S'),
|
self.info.TIMESTAMP: lambda: time.strftime('%H:%M:%S'),
|
||||||
# Return copies of arrays to prevent external modification
|
# Return copies of arrays to prevent external modification
|
||||||
@@ -649,8 +670,9 @@ class Sensor:
|
|||||||
return tuple(mapping.get(arg, lambda: None)() for arg in args if isinstance(arg, self.info))
|
return tuple(mapping.get(arg, lambda: None)() for arg in args if isinstance(arg, self.info))
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"""Reset optical flow tracker."""
|
"""Reset optical flow tracker and force-filter state."""
|
||||||
self.__tracker.reset()
|
self.__tracker.reset()
|
||||||
|
self._force_filter.reset()
|
||||||
if self.img is not None:
|
if self.img is not None:
|
||||||
self.initial_img = self.img.copy()
|
self.initial_img = self.img.copy()
|
||||||
|
|
||||||
@@ -679,7 +701,88 @@ class Sensor:
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Public API - Configuration & Calibration
|
# Public API - Configuration & Calibration
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
|
def configure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
lowpass_cutoff_hz=None,
|
||||||
|
lowpass_sample_rate_hz=None,
|
||||||
|
lowpass_order=None,
|
||||||
|
lowpass_enabled=None,
|
||||||
|
hhd_downsample=None,
|
||||||
|
):
|
||||||
|
"""Update runtime-safe settings without reconnecting the camera.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
lowpass_cutoff_hz : float, optional
|
||||||
|
Force low-pass cutoff in Hz. ``0`` disables filtering. A positive
|
||||||
|
value enables filtering unless ``lowpass_enabled=False``.
|
||||||
|
lowpass_sample_rate_hz : float, optional
|
||||||
|
Assumed force sample rate for the Butterworth design (default 30).
|
||||||
|
lowpass_order : int, optional
|
||||||
|
Butterworth order (default 2).
|
||||||
|
lowpass_enabled : bool, optional
|
||||||
|
Explicit on/off for the causal force filter.
|
||||||
|
hhd_downsample : int, optional
|
||||||
|
Integer ``>= 1``. Rebuilds the HHD solver on the next frame.
|
||||||
|
"""
|
||||||
|
filter_changed = False
|
||||||
|
if lowpass_cutoff_hz is not None:
|
||||||
|
self._lowpass_cutoff_hz = float(lowpass_cutoff_hz)
|
||||||
|
filter_changed = True
|
||||||
|
if lowpass_enabled is None:
|
||||||
|
self._lowpass_enabled = self._lowpass_cutoff_hz > 0
|
||||||
|
if lowpass_sample_rate_hz is not None:
|
||||||
|
rate = float(lowpass_sample_rate_hz)
|
||||||
|
if rate <= 0:
|
||||||
|
raise ValueError("lowpass_sample_rate_hz must be > 0")
|
||||||
|
self._lowpass_sample_rate_hz = rate
|
||||||
|
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 filter_changed:
|
||||||
|
cutoff = self._lowpass_cutoff_hz if self._lowpass_enabled else 0.0
|
||||||
|
self._force_filter.configure(
|
||||||
|
cutoff_hz=cutoff,
|
||||||
|
sample_rate_hz=self._lowpass_sample_rate_hz,
|
||||||
|
order=self._lowpass_order,
|
||||||
|
)
|
||||||
|
if self.compute_deformation_valid:
|
||||||
|
self._apply_force_filter()
|
||||||
|
|
||||||
|
if hhd_downsample is not None:
|
||||||
|
try:
|
||||||
|
k = max(1, int(hhd_downsample))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("hhd_downsample must be an integer >= 1") from exc
|
||||||
|
if k != self._hhd_downsample:
|
||||||
|
self._hhd_downsample = k
|
||||||
|
self.__hhd = None
|
||||||
|
self._hhd_grid_shape = None
|
||||||
|
self._update_hhd_mask_cache()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _apply_force_filter(self):
|
||||||
|
raw = np.asarray(self._force_raw, dtype=np.float64)
|
||||||
|
if self._lowpass_enabled and self._lowpass_cutoff_hz > 0:
|
||||||
|
filtered = self._force_filter.process(raw)
|
||||||
|
self.fnormal, self.fshearx, self.fsheary = (float(v) for v in filtered)
|
||||||
|
else:
|
||||||
|
self.fnormal, self.fshearx, self.fsheary = (
|
||||||
|
float(raw[0]),
|
||||||
|
float(raw[1]),
|
||||||
|
float(raw[2]),
|
||||||
|
)
|
||||||
|
|
||||||
# def export_config(self, path="./config/exported_config.json"):
|
# def export_config(self, path="./config/exported_config.json"):
|
||||||
# """Return current configuration dict."""
|
# """Return current configuration dict."""
|
||||||
# configs.export_config(self._config, path)
|
# configs.export_config(self._config, path)
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Signal helpers for force filtering and normal-force calibration.
|
||||||
|
|
||||||
|
Batch ``lowpass_filter`` is the zero-phase Butterworth used by example charts.
|
||||||
|
``LowpassFilter`` is a causal, stateful filter for per-frame ``Sensor`` outputs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.signal import butter, filtfilt, sosfilt, sosfilt_zi
|
||||||
|
|
||||||
|
# Linear calibration from raw normal-force units to Newtons (ORY-BOX / ORY-MINI).
|
||||||
|
# Prefer sensors/<name>/force_calibration.json when available.
|
||||||
|
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
|
||||||
|
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
|
||||||
|
|
||||||
|
DEFAULT_SAMPLE_RATE_HZ = 30.0
|
||||||
|
DEFAULT_LOWPASS_ORDER = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _load_normal_force_coeffs(sensor_name: str | None = None) -> tuple[float, float]:
|
||||||
|
if sensor_name:
|
||||||
|
try:
|
||||||
|
from .configs import load_force_calibration
|
||||||
|
|
||||||
|
data = load_force_calibration(sensor_name)
|
||||||
|
except Exception:
|
||||||
|
data = None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
block = data.get("normal_force", data)
|
||||||
|
if isinstance(block, dict):
|
||||||
|
slope = block.get("slope")
|
||||||
|
offset = block.get("offset")
|
||||||
|
if slope is not None and offset is not None:
|
||||||
|
return float(slope), float(offset)
|
||||||
|
return NORMAL_FORCE_CAL_SLOPE, NORMAL_FORCE_CAL_OFFSET
|
||||||
|
|
||||||
|
|
||||||
|
def calibrate_normal_force(raw_value: float, *, sensor_name: str | None = None) -> float:
|
||||||
|
"""Convert a raw normal-force reading to Newtons using y = mx + c."""
|
||||||
|
slope, offset = _load_normal_force_coeffs(sensor_name)
|
||||||
|
return slope * float(raw_value) + offset
|
||||||
|
|
||||||
|
|
||||||
|
def _nyquist_hz(sample_rate_hz: float) -> float:
|
||||||
|
return 0.5 * float(sample_rate_hz)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_cutoff(cutoff_hz: float, sample_rate_hz: float) -> None:
|
||||||
|
nyquist = _nyquist_hz(sample_rate_hz)
|
||||||
|
if cutoff_hz < 0 or cutoff_hz >= nyquist:
|
||||||
|
raise ValueError(
|
||||||
|
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lowpass_filter(
|
||||||
|
data,
|
||||||
|
cutoff_hz: float = 5.0,
|
||||||
|
sample_rate_hz: float = DEFAULT_SAMPLE_RATE_HZ,
|
||||||
|
order: int = DEFAULT_LOWPASS_ORDER,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data.
|
||||||
|
|
||||||
|
Matches the example/viewer chart filter: ``cutoff_hz == 0`` is a no-op.
|
||||||
|
"""
|
||||||
|
y = np.asarray(data, dtype=np.float64)
|
||||||
|
if cutoff_hz == 0:
|
||||||
|
return y.copy()
|
||||||
|
if y.size == 0:
|
||||||
|
return y
|
||||||
|
|
||||||
|
_validate_cutoff(cutoff_hz, sample_rate_hz)
|
||||||
|
b, a = butter(int(order), cutoff_hz / _nyquist_hz(sample_rate_hz), btype="low")
|
||||||
|
padlen = 3 * (max(len(a), len(b)) - 1)
|
||||||
|
if y.size <= padlen:
|
||||||
|
return y.copy()
|
||||||
|
try:
|
||||||
|
return filtfilt(b, a, y)
|
||||||
|
except ValueError:
|
||||||
|
return y.copy()
|
||||||
|
|
||||||
|
|
||||||
|
class LowpassFilter:
|
||||||
|
"""Causal Butterworth low-pass with persistent SOS state (one sample at a time)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cutoff_hz: float = 0.0,
|
||||||
|
sample_rate_hz: float = DEFAULT_SAMPLE_RATE_HZ,
|
||||||
|
order: int = DEFAULT_LOWPASS_ORDER,
|
||||||
|
n_channels: int = 3,
|
||||||
|
):
|
||||||
|
self.n_channels = int(n_channels)
|
||||||
|
self._sos = None
|
||||||
|
self._zi = None
|
||||||
|
self.configure(
|
||||||
|
cutoff_hz=cutoff_hz,
|
||||||
|
sample_rate_hz=sample_rate_hz,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._sos is not None
|
||||||
|
|
||||||
|
def configure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cutoff_hz: float | None = None,
|
||||||
|
sample_rate_hz: float | None = None,
|
||||||
|
order: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
if cutoff_hz is not None:
|
||||||
|
self.cutoff_hz = float(cutoff_hz)
|
||||||
|
if sample_rate_hz is not None:
|
||||||
|
self.sample_rate_hz = float(sample_rate_hz)
|
||||||
|
if order is not None:
|
||||||
|
self.order = int(order)
|
||||||
|
|
||||||
|
if self.cutoff_hz <= 0:
|
||||||
|
self._sos = None
|
||||||
|
self._zi = None
|
||||||
|
return
|
||||||
|
|
||||||
|
_validate_cutoff(self.cutoff_hz, self.sample_rate_hz)
|
||||||
|
self._sos = butter(
|
||||||
|
self.order,
|
||||||
|
self.cutoff_hz / _nyquist_hz(self.sample_rate_hz),
|
||||||
|
btype="low",
|
||||||
|
output="sos",
|
||||||
|
)
|
||||||
|
self._zi = None
|
||||||
|
|
||||||
|
def reset(self, x0=None) -> None:
|
||||||
|
"""Clear filter memory. Optional ``x0`` initializes to a steady-state DC."""
|
||||||
|
if self._sos is None:
|
||||||
|
self._zi = None
|
||||||
|
return
|
||||||
|
if x0 is None:
|
||||||
|
self._zi = None
|
||||||
|
return
|
||||||
|
x = np.asarray(x0, dtype=np.float64).reshape(-1)
|
||||||
|
if x.size != self.n_channels:
|
||||||
|
raise ValueError(f"x0 must have {self.n_channels} channels, got {x.size}")
|
||||||
|
zi0 = sosfilt_zi(self._sos)
|
||||||
|
self._zi = zi0[:, :, None] * x[None, None, :]
|
||||||
|
|
||||||
|
def process(self, values) -> np.ndarray:
|
||||||
|
"""Filter one multi-channel sample. Returns float64 array of shape (n_channels,)."""
|
||||||
|
x = np.asarray(values, dtype=np.float64).reshape(-1)
|
||||||
|
if x.size != self.n_channels:
|
||||||
|
raise ValueError(f"expected {self.n_channels} channels, got {x.size}")
|
||||||
|
if self._sos is None:
|
||||||
|
return x.copy()
|
||||||
|
if self._zi is None:
|
||||||
|
self.reset(x)
|
||||||
|
y, self._zi = sosfilt(self._sos, x[None, :], zi=self._zi, axis=0)
|
||||||
|
return np.asarray(y[0], dtype=np.float64)
|
||||||
+1
-1
@@ -8,7 +8,7 @@ Flow:
|
|||||||
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
|
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
|
||||||
5. Compute deformation/contact and visualize
|
5. Compute deformation/contact and visualize
|
||||||
|
|
||||||
SDK version: 0.4.2
|
SDK version: 0.4.3
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def test_pyproject_dynamic_version(packaging_utils):
|
|||||||
sys.path.insert(0, str(build_dir))
|
sys.path.insert(0, str(build_dir))
|
||||||
from common.pyproject_utils import get_version
|
from common.pyproject_utils import get_version
|
||||||
|
|
||||||
assert get_version(REPO_ROOT) == "0.4.2"
|
assert get_version(REPO_ROOT) == "0.4.3"
|
||||||
|
|
||||||
|
|
||||||
def test_pyproject_bundle_manifest(packaging_utils):
|
def test_pyproject_bundle_manifest(packaging_utils):
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from gui.sensors import ( # noqa: E402
|
|||||||
_snapshot_from_sensor,
|
_snapshot_from_sensor,
|
||||||
calibrate_normal_force,
|
calibrate_normal_force,
|
||||||
)
|
)
|
||||||
|
from orisys.signal_processing import calibrate_normal_force as sdk_calibrate_normal_force
|
||||||
|
|
||||||
|
|
||||||
class FakeForceAdapter:
|
class FakeForceAdapter:
|
||||||
@@ -163,3 +164,4 @@ def test_snapshot_requests_hhd_every_frame_without_motion_gate():
|
|||||||
assert snap.shear_x == pytest.approx(-100.0)
|
assert snap.shear_x == pytest.approx(-100.0)
|
||||||
assert snap.shear_y == pytest.approx(50.0)
|
assert snap.shear_y == pytest.approx(50.0)
|
||||||
assert snap.normal_force_calibrated == pytest.approx(calibrate_normal_force(12345.0))
|
assert snap.normal_force_calibrated == pytest.approx(calibrate_normal_force(12345.0))
|
||||||
|
assert snap.normal_force_calibrated == pytest.approx(sdk_calibrate_normal_force(12345.0))
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Tests for SDK signal helpers and Sensor.configure runtime settings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from scipy.signal import butter, filtfilt
|
||||||
|
|
||||||
|
from orisys import Sensor
|
||||||
|
from orisys.signal_processing import (
|
||||||
|
NORMAL_FORCE_CAL_OFFSET,
|
||||||
|
NORMAL_FORCE_CAL_SLOPE,
|
||||||
|
LowpassFilter,
|
||||||
|
calibrate_normal_force,
|
||||||
|
lowpass_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_viewer_lowpass(data, cutoff_hz, sample_rate_hz=30.0, order=2):
|
||||||
|
"""Reference copy of the previous viewer2d Butterworth + filtfilt path."""
|
||||||
|
y = np.asarray(data, dtype=np.float64)
|
||||||
|
if cutoff_hz == 0:
|
||||||
|
return y.copy()
|
||||||
|
if y.size == 0:
|
||||||
|
return y
|
||||||
|
nyquist = 0.5 * sample_rate_hz
|
||||||
|
b, a = butter(order, cutoff_hz / nyquist, btype="low")
|
||||||
|
padlen = 3 * (max(len(b), len(a)) - 1)
|
||||||
|
if y.size <= padlen:
|
||||||
|
return y.copy()
|
||||||
|
return filtfilt(b, a, y)
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_lowpass_matches_legacy_viewer_filter():
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
t = np.arange(180, dtype=np.float64) / 30.0
|
||||||
|
signal = np.sin(2 * np.pi * 1.0 * t) + 0.4 * rng.normal(size=t.size)
|
||||||
|
cutoff = 5.0
|
||||||
|
actual = lowpass_filter(signal, cutoff_hz=cutoff, sample_rate_hz=30.0, order=2)
|
||||||
|
expected = _legacy_viewer_lowpass(signal, cutoff_hz=cutoff, sample_rate_hz=30.0, order=2)
|
||||||
|
np.testing.assert_allclose(actual, expected, rtol=1e-10, atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_lowpass_cutoff_zero_is_noop():
|
||||||
|
data = np.array([1.0, 2.0, 3.0])
|
||||||
|
out = lowpass_filter(data, cutoff_hz=0)
|
||||||
|
np.testing.assert_array_equal(out, data)
|
||||||
|
assert out is not data
|
||||||
|
|
||||||
|
|
||||||
|
def test_stateful_lowpass_reduces_high_frequency_noise():
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
n = 240
|
||||||
|
t = np.arange(n, dtype=np.float64) / 30.0
|
||||||
|
clean = np.sin(2 * np.pi * 0.5 * t)
|
||||||
|
noisy = clean + 0.8 * rng.normal(size=n)
|
||||||
|
|
||||||
|
filt = LowpassFilter(cutoff_hz=3.0, sample_rate_hz=30.0, order=2, n_channels=1)
|
||||||
|
filtered = np.array([float(filt.process([v])[0]) for v in noisy])
|
||||||
|
|
||||||
|
assert np.isfinite(filtered).all()
|
||||||
|
noisy_hf = float(np.std(np.diff(noisy)))
|
||||||
|
filtered_hf = float(np.std(np.diff(filtered)))
|
||||||
|
assert filtered_hf < 0.5 * noisy_hf
|
||||||
|
# Steady-state init on the first sample should not start at zero.
|
||||||
|
assert filtered[0] == pytest.approx(noisy[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_normal_force_preserves_linear_curve():
|
||||||
|
raw = 12345.0
|
||||||
|
expected = NORMAL_FORCE_CAL_SLOPE * raw + NORMAL_FORCE_CAL_OFFSET
|
||||||
|
assert calibrate_normal_force(raw) == pytest.approx(expected)
|
||||||
|
assert calibrate_normal_force(0.0) == pytest.approx(NORMAL_FORCE_CAL_OFFSET)
|
||||||
|
|
||||||
|
|
||||||
|
def _external_sensor():
|
||||||
|
return Sensor("external", config_name="box", verbose=False, backend="cpu")
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_lowpass_filters_read_info_and_keeps_raw():
|
||||||
|
sensor = _external_sensor()
|
||||||
|
try:
|
||||||
|
sensor._force_raw = (1000.0, -200.0, 50.0)
|
||||||
|
sensor._apply_force_filter()
|
||||||
|
fn, fx, fy = sensor.read_info(
|
||||||
|
sensor.info.FNORMAL,
|
||||||
|
sensor.info.FSHEARX,
|
||||||
|
sensor.info.FSHEARY,
|
||||||
|
)
|
||||||
|
assert (fn, fx, fy) == pytest.approx((1000.0, -200.0, 50.0))
|
||||||
|
|
||||||
|
sensor.configure(lowpass_cutoff_hz=5.0, lowpass_sample_rate_hz=30.0)
|
||||||
|
noisy = [(1000.0 + (200.0 if i % 2 else -200.0), -200.0, 50.0) for i in range(40)]
|
||||||
|
filtered_fn = []
|
||||||
|
raw_fn = []
|
||||||
|
for sample in noisy:
|
||||||
|
sensor._force_raw = sample
|
||||||
|
sensor._apply_force_filter()
|
||||||
|
filtered_fn.append(sensor.read_info(sensor.info.FNORMAL)[0])
|
||||||
|
raw_fn.append(sensor.read_info(sensor.info.FNORMAL_RAW)[0])
|
||||||
|
|
||||||
|
assert raw_fn[-1] == pytest.approx(noisy[-1][0])
|
||||||
|
assert filtered_fn[-1] != pytest.approx(raw_fn[-1])
|
||||||
|
assert float(np.std(np.diff(filtered_fn))) < float(np.std(np.diff(raw_fn)))
|
||||||
|
fx_raw, fy_raw = sensor.read_info(sensor.info.FSHEARX_RAW, sensor.info.FSHEARY_RAW)
|
||||||
|
assert fx_raw == pytest.approx(-200.0)
|
||||||
|
assert fy_raw == pytest.approx(50.0)
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_lowpass_disabled_or_zero_cutoff_returns_raw():
|
||||||
|
sensor = _external_sensor()
|
||||||
|
try:
|
||||||
|
sensor.configure(lowpass_cutoff_hz=5.0)
|
||||||
|
sensor._force_raw = (10.0, 20.0, 30.0)
|
||||||
|
sensor._apply_force_filter()
|
||||||
|
|
||||||
|
sensor.configure(lowpass_cutoff_hz=0)
|
||||||
|
sensor._force_raw = (111.0, 222.0, 333.0)
|
||||||
|
sensor._apply_force_filter()
|
||||||
|
fn, fx, fy = sensor.read_info(
|
||||||
|
sensor.info.FNORMAL,
|
||||||
|
sensor.info.FSHEARX,
|
||||||
|
sensor.info.FSHEARY,
|
||||||
|
)
|
||||||
|
assert (fn, fx, fy) == pytest.approx((111.0, 222.0, 333.0))
|
||||||
|
|
||||||
|
sensor.configure(lowpass_cutoff_hz=5.0, lowpass_enabled=False)
|
||||||
|
sensor._force_raw = (7.0, 8.0, 9.0)
|
||||||
|
sensor._apply_force_filter()
|
||||||
|
fn, fx, fy = sensor.read_info(
|
||||||
|
sensor.info.FNORMAL,
|
||||||
|
sensor.info.FSHEARX,
|
||||||
|
sensor.info.FSHEARY,
|
||||||
|
)
|
||||||
|
fn_raw = sensor.read_info(sensor.info.FNORMAL_RAW)[0]
|
||||||
|
assert (fn, fx, fy) == pytest.approx((7.0, 8.0, 9.0))
|
||||||
|
assert fn_raw == pytest.approx(7.0)
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_hhd_downsample_invalidates_solver_cache():
|
||||||
|
sensor = _external_sensor()
|
||||||
|
try:
|
||||||
|
sensor._hhd_downsample = 1
|
||||||
|
sensor._Sensor__hhd = object()
|
||||||
|
sensor._hhd_grid_shape = (40, 40)
|
||||||
|
sensor.valid_mask = np.ones((90, 90), dtype=bool)
|
||||||
|
sensor.valid_mask_hhd = sensor.valid_mask
|
||||||
|
|
||||||
|
sensor.configure(hhd_downsample=3)
|
||||||
|
assert sensor._hhd_downsample == 3
|
||||||
|
assert sensor._Sensor__hhd is None
|
||||||
|
assert sensor._hhd_grid_shape is None
|
||||||
|
assert sensor.valid_mask_hhd is not None
|
||||||
|
assert sensor.valid_mask_hhd.shape == (30, 30)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sensor.configure(hhd_downsample="nope")
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
@@ -19,25 +19,25 @@ def wheel_package():
|
|||||||
|
|
||||||
def test_compliant_manylinux_tags(wheel_package):
|
def test_compliant_manylinux_tags(wheel_package):
|
||||||
assert wheel_package.is_compliant_manylinux_wheel(
|
assert wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-manylinux_2_17_x86_64.whl")
|
Path("orisys-0.4.3-cp310-cp310-manylinux_2_17_x86_64.whl")
|
||||||
)
|
)
|
||||||
assert wheel_package.is_compliant_manylinux_wheel(
|
assert wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-manylinux2014_x86_64.whl")
|
Path("orisys-0.4.3-cp310-cp310-manylinux2014_x86_64.whl")
|
||||||
)
|
)
|
||||||
assert wheel_package.is_compliant_manylinux_wheel(
|
assert wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-manylinux_2_28_x86_64.whl")
|
Path("orisys-0.4.3-cp310-cp310-manylinux_2_28_x86_64.whl")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_illegal_manylinux_tags(wheel_package):
|
def test_rejects_illegal_manylinux_tags(wheel_package):
|
||||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-manylinux_x86_64.whl")
|
Path("orisys-0.4.3-cp310-cp310-manylinux_x86_64.whl")
|
||||||
)
|
)
|
||||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-linux_x86_64.whl")
|
Path("orisys-0.4.3-cp310-cp310-linux_x86_64.whl")
|
||||||
)
|
)
|
||||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-win_amd64.whl")
|
Path("orisys-0.4.3-cp310-cp310-win_amd64.whl")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"downstreams": [
|
||||||
|
{
|
||||||
|
"name": "orisys-sdk-release",
|
||||||
|
"path": "../orisys-sdk-release",
|
||||||
|
"sync": [
|
||||||
|
{"from": "docs", "to": "docs"},
|
||||||
|
{"from": "examples", "to": "examples", "include": [
|
||||||
|
"README.md",
|
||||||
|
"basic_pipeline.py",
|
||||||
|
"mini_pipeline.py",
|
||||||
|
"finger_pipeline.py",
|
||||||
|
"finger_pipeline_3dview.py",
|
||||||
|
"plot_data.py",
|
||||||
|
"two_sensor_pipeline.py"
|
||||||
|
]},
|
||||||
|
{"from": "config", "to": "config", "include": ["box.json", "finger.json", "mini.json"]}
|
||||||
|
],
|
||||||
|
"checks": [
|
||||||
|
"python -m py_compile examples/basic_pipeline.py examples/finger_pipeline.py examples/finger_pipeline_3dview.py examples/plot_data.py",
|
||||||
|
"python -c \"import pathlib; p=pathlib.Path('.'); hits=[f for f in p.rglob('*') if f.is_file() and 'examples/products' in f.read_text(encoding='utf-8', errors='ignore').replace('\\\\','/')]; print('stale products paths:' if hits else 'no stale products paths'); [print(x) for x in hits]\""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sensor-bench",
|
||||||
|
"path": "../sensor-bench",
|
||||||
|
"sync": [
|
||||||
|
{"from": "config", "to": "config", "include": ["box.json", "mini.json", "dd06-ov-0810.json"]}
|
||||||
|
],
|
||||||
|
"checks": [
|
||||||
|
"python tests/test_smoke.py"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "finger-bench",
|
||||||
|
"path": "../finger-bench",
|
||||||
|
"sync": [
|
||||||
|
{"from": "config", "to": "config", "include": ["finger.json", "dd02-ov.json"]},
|
||||||
|
{"from": "assets/finger", "to": "assets/finger"}
|
||||||
|
],
|
||||||
|
"checks": [
|
||||||
|
"python tests/test_smoke.py",
|
||||||
|
"python -c \"import pathlib; t=pathlib.Path('README.md').read_text(encoding='utf-8'); print('README still mentions examples/products' if 'examples/products' in t else 'README products path ok')\""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Plan and apply downstream file syncs from tools/downstream_sync_manifest.json.
|
||||||
|
|
||||||
|
The ``apply`` command copies files/directories listed in the manifest. It does
|
||||||
|
not delete extra downstream files and does not run downstream tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MANIFEST_NAME = "downstream_sync_manifest.json"
|
||||||
|
|
||||||
|
|
||||||
|
def sdk_root() -> Path:
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(root: Path) -> dict:
|
||||||
|
path = Path(__file__).resolve().parent / MANIFEST_NAME
|
||||||
|
with path.open(encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
if data.get("version") != 1:
|
||||||
|
raise SystemExit(f"unsupported manifest version: {data.get('version')}")
|
||||||
|
if not isinstance(data.get("downstreams"), list):
|
||||||
|
raise SystemExit("manifest is missing a downstreams list")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def selected_downstreams(manifest: dict, target: str | None) -> list[dict]:
|
||||||
|
items = manifest["downstreams"]
|
||||||
|
if target is None:
|
||||||
|
return items
|
||||||
|
matches = [item for item in items if item.get("name") == target]
|
||||||
|
if not matches:
|
||||||
|
names = ", ".join(item.get("name", "<unnamed>") for item in items)
|
||||||
|
raise SystemExit(f"unknown target {target!r}; known: {names}")
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def expand_jobs(root: Path, item: dict) -> list[tuple[Path, Path]]:
|
||||||
|
dest_root = (root / item["path"]).resolve()
|
||||||
|
jobs: list[tuple[Path, Path]] = []
|
||||||
|
for entry in item.get("sync", []):
|
||||||
|
src_base = root / entry["from"]
|
||||||
|
dest_base = dest_root / entry["to"]
|
||||||
|
includes = entry.get("include")
|
||||||
|
if includes:
|
||||||
|
for name in includes:
|
||||||
|
jobs.append((src_base / name, dest_base / name))
|
||||||
|
else:
|
||||||
|
jobs.append((src_base, dest_base))
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def print_plan(root: Path, items: list[dict]) -> None:
|
||||||
|
print(f"SDK root: {root}")
|
||||||
|
print()
|
||||||
|
for item in items:
|
||||||
|
dest_root = (root / item["path"]).resolve()
|
||||||
|
print(f"== {item['name']} ==")
|
||||||
|
print(f"path: {dest_root}")
|
||||||
|
jobs = expand_jobs(root, item)
|
||||||
|
if jobs:
|
||||||
|
print("sync:")
|
||||||
|
for src, dest in jobs:
|
||||||
|
print(f" {src} -> {dest}")
|
||||||
|
else:
|
||||||
|
print("sync: (none)")
|
||||||
|
checks = item.get("checks") or []
|
||||||
|
if checks:
|
||||||
|
print("checks:")
|
||||||
|
for command in checks:
|
||||||
|
print(f" {command}")
|
||||||
|
else:
|
||||||
|
print("checks: (none)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def check_paths(root: Path, items: list[dict]) -> int:
|
||||||
|
missing: list[str] = []
|
||||||
|
print(f"SDK root: {root}")
|
||||||
|
print()
|
||||||
|
for item in items:
|
||||||
|
dest_root = (root / item["path"]).resolve()
|
||||||
|
print(f"== {item['name']} ==")
|
||||||
|
if dest_root.is_dir():
|
||||||
|
print(f"ok downstream root: {dest_root}")
|
||||||
|
else:
|
||||||
|
print(f"MISS downstream root: {dest_root}")
|
||||||
|
missing.append(str(dest_root))
|
||||||
|
for src, dest in expand_jobs(root, item):
|
||||||
|
src_ok = src.exists()
|
||||||
|
dest_note = "exists" if dest.exists() else "missing (ok until apply)"
|
||||||
|
status = "ok " if src_ok else "MISS"
|
||||||
|
print(f"{status} source: {src}")
|
||||||
|
print(f" dest: {dest} [{dest_note}]")
|
||||||
|
if not src_ok:
|
||||||
|
missing.append(str(src))
|
||||||
|
print()
|
||||||
|
if missing:
|
||||||
|
print(f"{len(missing)} missing path(s).")
|
||||||
|
return 1
|
||||||
|
print("all required source and downstream roots exist.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def copy_job(src: Path, dest: Path) -> None:
|
||||||
|
if not src.exists():
|
||||||
|
raise FileNotFoundError(src)
|
||||||
|
if src.is_dir():
|
||||||
|
shutil.copytree(src, dest, dirs_exist_ok=True)
|
||||||
|
return
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_sync(root: Path, items: list[dict]) -> int:
|
||||||
|
if check_paths(root, items) != 0:
|
||||||
|
print("apply aborted because required paths are missing.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
copied = 0
|
||||||
|
for item in items:
|
||||||
|
print(f"== {item['name']} ==")
|
||||||
|
for src, dest in expand_jobs(root, item):
|
||||||
|
copy_job(src, dest)
|
||||||
|
copied += 1
|
||||||
|
print(f"copied {src} -> {dest}")
|
||||||
|
print()
|
||||||
|
print(f"copied {copied} sync job(s).")
|
||||||
|
print("checks were not run; execute the listed check commands manually.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
plan = sub.add_parser("plan", help="print intended sync jobs and checks")
|
||||||
|
plan.add_argument("--target", help="limit to one downstream name")
|
||||||
|
|
||||||
|
check = sub.add_parser("check-paths", help="verify source and dest roots exist")
|
||||||
|
check.add_argument("--target", help="limit to one downstream name")
|
||||||
|
|
||||||
|
apply_cmd = sub.add_parser("apply", help="copy manifest sources to downstreams")
|
||||||
|
apply_cmd.add_argument("--target", help="limit to one downstream name")
|
||||||
|
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(argv if argv is not None else sys.argv[1:])
|
||||||
|
root = sdk_root()
|
||||||
|
items = selected_downstreams(load_manifest(root), getattr(args, "target", None))
|
||||||
|
if args.command == "plan":
|
||||||
|
print_plan(root, items)
|
||||||
|
return 0
|
||||||
|
if args.command == "check-paths":
|
||||||
|
return check_paths(root, items)
|
||||||
|
if args.command == "apply":
|
||||||
|
return apply_sync(root, items)
|
||||||
|
raise SystemExit(f"unknown command: {args.command}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user