diff --git a/README.md b/README.md index 1a9f5ae..4875b4a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ PyPI: [https://pypi.org/project/orisys/](https://pypi.org/project/orisys/) - `src/orisys/`: installable SDK package - `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 - `tools/`: non-package tooling such as patch-generation utilities - `tests/`: automated pytest suite @@ -54,23 +54,22 @@ pytest ORY-BOX: ```bash -python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 +python examples/basic_pipeline.py -v 0 -c box ``` ORY-MINI: ```bash -python examples/products/ory_mini/basic_pipeline.py -v 0 -c mini +python examples/mini_pipeline.py -v 0 -c mini ``` ORY-FINGER: ```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 -`examples/finger_pipeline*.py`. +Run commands from the repository root. ## 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: 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` 4. a backward-compatible JSON `"name"` match, such as `"prototype"` @@ -87,8 +86,8 @@ Examples: ```python import orisys -sensor = orisys.Sensor(0, config_name="ddjx01") -cfg = orisys.export_config("ddjx01", "./config/exported_config.json") +sensor = orisys.Sensor(0, config_name="box") +cfg = orisys.export_config("box", "./config/exported_config.json") ``` ## Examples and Tools diff --git a/assets/finger/finger/camera_intrinsics.npz b/assets/finger/finger/camera_intrinsics.npz new file mode 100644 index 0000000..888b1eb Binary files /dev/null and b/assets/finger/finger/camera_intrinsics.npz differ diff --git a/assets/finger/finger/mesh.npz b/assets/finger/finger/mesh.npz new file mode 100644 index 0000000..4da6bb1 Binary files /dev/null and b/assets/finger/finger/mesh.npz differ diff --git a/assets/finger/finger/pixel_to_xyz.npz b/assets/finger/finger/pixel_to_xyz.npz new file mode 100644 index 0000000..15eee73 Binary files /dev/null and b/assets/finger/finger/pixel_to_xyz.npz differ diff --git a/assets/finger/finger/unroll_map_uv_s0.4.npz b/assets/finger/finger/unroll_map_uv_s0.4.npz new file mode 100644 index 0000000..7cbb8cb Binary files /dev/null and b/assets/finger/finger/unroll_map_uv_s0.4.npz differ diff --git a/assets/finger/finger/world_to_cam.npy b/assets/finger/finger/world_to_cam.npy new file mode 100644 index 0000000..3d6f897 Binary files /dev/null and b/assets/finger/finger/world_to_cam.npy differ diff --git a/docs/images/orisys_logo.png b/assets/orisys_logo.png similarity index 100% rename from docs/images/orisys_logo.png rename to assets/orisys_logo.png diff --git a/docs/images/orisys_logo_notext.png b/assets/orisys_logo_notext.png similarity index 100% rename from docs/images/orisys_logo_notext.png rename to assets/orisys_logo_notext.png diff --git a/build/common/pyproject_utils.py b/build/common/pyproject_utils.py index 9256cde..651f253 100644 --- a/build/common/pyproject_utils.py +++ b/build/common/pyproject_utils.py @@ -3,13 +3,22 @@ from __future__ import annotations import ast +import importlib.util import subprocess import sys from pathlib import Path _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: @@ -94,7 +103,7 @@ def bundle_items(repo_root: Path) -> list[dict]: def bundle_data_files(repo_root: Path) -> list[tuple[str, list[str]]]: """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]] = {} for item in bundle_items(repo_root): diff --git a/build/gui/ory_box/config.py b/build/gui/ory_box/config.py index 10e07fc..10eeaff 100644 --- a/build/gui/ory_box/config.py +++ b/build/gui/ory_box/config.py @@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path: from common.paths import REPO_ROOT 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" DIST_NAME = "OryBox" diff --git a/build/gui/ory_finger/config.py b/build/gui/ory_finger/config.py index 6d3cf21..b3e804a 100644 --- a/build/gui/ory_finger/config.py +++ b/build/gui/ory_finger/config.py @@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path: from common.paths import REPO_ROOT 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" DIST_NAME = "OrisysDashboard" diff --git a/build/gui/ory_mini/config.py b/build/gui/ory_mini/config.py index 53ae8e4..151aef1 100644 --- a/build/gui/ory_mini/config.py +++ b/build/gui/ory_mini/config.py @@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path: from common.paths import REPO_ROOT 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" DIST_NAME = "OryMini" diff --git a/build/wheel/build.py b/build/wheel/build.py index 09dca0a..2b0a67c 100644 --- a/build/wheel/build.py +++ b/build/wheel/build.py @@ -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.verify.wheel import verify_built_wheel 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 ( is_compliant_manylinux_wheel, publish_wheel_to_sdk, diff --git a/build/wheel/layout.py b/build/wheel/layout.py index 66549e6..f9718e5 100644 --- a/build/wheel/layout.py +++ b/build/wheel/layout.py @@ -5,6 +5,14 @@ from __future__ import annotations import shutil 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]: """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 +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]: """Return setuptools package_data paths relative to the orisys package.""" 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]: + verify_required_assets(repo_root, items) copied_counts: dict[str, int] = {} for item in items: name = item["name"] diff --git a/config/X-ov.json b/config/X-ov.json new file mode 100644 index 0000000..4c966cd --- /dev/null +++ b/config/X-ov.json @@ -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" + } + } +} diff --git a/config/dd06-ov-0810.json b/config/dd06-ov-0810.json new file mode 100644 index 0000000..209d274 --- /dev/null +++ b/config/dd06-ov-0810.json @@ -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" + } + } +} diff --git a/config/finger.json b/config/finger.json index 545a74a..d5f6264 100644 --- a/config/finger.json +++ b/config/finger.json @@ -5,7 +5,7 @@ "camera": { "resolution_raw": [1280, 720], "fps": 30, - "resolution_stitch": [420, 450], + "resolution_stitch": [280, 300], "source_scale": 0.4, "old":[672, 728, 1,0.5], "work": [504, 546,1], @@ -20,7 +20,7 @@ "loop_closure_interval": 1, "algorithm": "dis", "dis_preset": "medium", - "dis_mode": 3, + "dis_mode": 1, "algorithm_list": ["raft", "dis"], "hhd_downsample": 5 }, diff --git a/docs/api.docx b/docs/api.docx deleted file mode 100644 index a898709..0000000 Binary files a/docs/api.docx and /dev/null differ diff --git a/docs/api.md b/docs/api.md index 78f26e1..5afc69b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,77 +1,30 @@ -# orisys API 文档 +--- +title: API Reference +description: Orisys SDK Python API 中文参考 +--- -本文件提供了 `orisys` 套件的中文文档 +# API Reference -## 准备 Python 开发环境 -推荐下载 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 -``` +本文件提供 `orisys` 套件的 Python API 中文参考。安装、CUDA、CuPy 与运行示例的基础步骤请参考 [快速开始](./get_started.md)。 -3. **安装 CuPy(根据您的 CUDA 版本选择):** - - **方法 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 - ``` +## Sensor API -4. 测试导入: -```python -import orisys -print(orisys.__version__) -``` - -*** - -## `Sensor` 类别 -### 描述 +### `Sensor` 类别 +#### 描述 `orisys`套件的核心类,用于从视频文件或实时摄像头中读取传感器图像,创建一个具体的传感器对象(实例)。并完成形变、光流及力信息的计算与管理。 -### 输入参数 +#### 输入参数 * **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`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。 * **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。 * **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。 -### 返回 +#### 返回 * `Sensor` 实例,用于后续图像采集、形变计算和数据读取操作。 -### 示例 +#### 示例 ```python # Example 1: 用相机编号创建 @@ -82,63 +35,62 @@ sensor = Sensor(0) sensor = Sensor("/data/data.mp4") ``` -*** +### `Sensor.get_img` 方法 -## 2. `Sensor.get_img` 方法 - -### 描述 +#### 描述 采集并预处理一帧图像并存到`Sensor` 类别中。 -### 返回 +#### 返回 * `bool`:是否成功读取图像。 -### 状态更新 +#### 状态更新 * 成功时更新`sensor`类里面的`sensor.img`。处理后的BGR格式图像(`uint8`,尺寸为json文件中`resolution_stitch`对应分辨率)。 -### 示例 +#### 示例 ```python sensor.get_img() ``` -*** +### `Sensor.compute_deformation` 方法 -## 3. `Sensor.compute_deformation` 方法 - -### 描述 +#### 描述 基于当前帧图像计算光流/形变向量场,并通过 HHD 分解将形变场分解为法向/切向分量,同时估计法向力、切向力与扭矩等标量输出。该方法会更新 `sensor`的形变场与力相关状态,并返回当前帧的光流场。 调用前置条件:需要先通过`get_img()`成功更新图像。 -### 输入参数 -* **check_motion**(`bool`,可选): 为 `True` 时先根据光流判断是否有运动,有运动才做 HHD 分解;为 `False` 时每帧都做 HHD。默认 `True`。 -* **threshold**(`float`,可选): **check_motion** 功能的阈值,默认为`6`。 -* **tail_frames** (`int`,可选): 光流变化低于阈值后继续解算多少帧,确保传感器回复,,默认为 `10`. +#### 输入参数 +* **decompose** (`bool`,可选): 是否在计算光流后继续执行 HHD 分解,默认 `True`。 +* **check_motion** (`bool`,可选): 是否启用运动检测门控。当前不推荐依赖该功能;新代码建议显式传入 `False`,让每帧都执行 HHD 分解。为兼容旧代码,函数默认值仍为 `True`。 -### 返回 +#### 兼容参数(暂不推荐) -* `bool`:是否接触到物体,如果**check_motion**为`False`时返回`None`。 +以下参数只在 `check_motion=True` 时生效。由于运动检测门控目前不稳定,建议在新代码中暂时不要使用: -### 示例 +* **threshold** (`float`,可选): 运动检测阈值,默认 `6`。 +* **tail_frames** (`int`,可选): 运动低于阈值后继续解算多少帧,默认 `10`。 + +#### 返回 + +* `None`: 当 `check_motion=False` 时返回。推荐用法下不要依赖该返回值判断接触状态。 +* `bool`: 仅当 `check_motion=True` 时返回运动检测结果;该模式目前暂不推荐。 + +#### 示例 ```python sensor.get_img() - -is_touched = sensor.compute_deformation(check_motion=True, threshold=6) -# 步骤 2.3: 计算触碰点 -if is_touched: - sensor.compute_contact() +sensor.compute_deformation(check_motion=False) +sensor.compute_contact() ``` -*** -## 4. `Sensor.compute_contact` 方法 +### `Sensor.compute_contact` 方法 -### 描述 +#### 描述 基于已计算的法向形变场进行接触区域分析,输出接触质心与轮廓等信息,并生成接触深度图,默认配置在`json`文件中,可按需调节。 调用前置条件:需先执行`compute_deformation()`。 -### 返回 +#### 返回 `dict`为接触分析结果字典,至少包含以下键: * **expansion_mask**: 扩张区域掩码(`np.ndarray`)。 @@ -148,12 +100,12 @@ if is_touched: * **centroid_found**: 是否检测到接触区域(`bool`)。 * **contour**: 最大轮廓或轮廓列表(可选字段,存在时返回)。 -### 状态更新 +#### 状态更新 * **sensor.centroid**: 接触质心(单点为 `(x, y)`,多点为列表)。 * **sensor.centroid_found**: 是否找到质心(`bool`)。 -### 示例 +#### 示例 ```python sensor.compute_contact() centroid, contour, depth = sensor.read_info( @@ -162,19 +114,18 @@ centroid, contour, depth = sensor.read_info( sensor.info.DEPTH, ) ``` -*** -## 4. `Sensor.read_info` 方法 +### `Sensor.read_info` 方法 -### 描述 +#### 描述 按指定的信息类型(`sensor.info`枚举值)批量读取传感器状态数据,并按传入顺序返回对应值的元组。 该方法通常用于在完成形变计算后,读取光流场、分量场、力、帧率等信息。 -### 输入参数 +#### 输入参数 * **args**: 任意数量的 `Sensor.info` 枚举,用于指定需要获取的数据类型。支持的枚举值及对应数据如下: -### 支持的枚 info keys: +#### 支持的 info keys 向量/图像类 @@ -188,6 +139,9 @@ centroid, contour, depth = sensor.read_info( * FNORMAL: 法向力,`float`。 * FSHEARX: X切向力,`float`。 * FSHEARY: Y切向力,`float`。 +* FNORMAL_RAW: 未经过低通滤波的原始法向力,`float`。 +* FSHEARX_RAW: 未经过低通滤波的原始 X 切向力,`float`。 +* FSHEARY_RAW: 未经过低通滤波的原始 Y 切向力,`float`。 * FPS: 输出帧率,`float`。 * CENTROID: 触碰点的坐标,(x, y)。 * CONTOUR: 触碰区域轮廓,`np.ndarray`, shape=(H, W), 未生成时返回`np.empty` @@ -198,7 +152,7 @@ centroid, contour, depth = sensor.read_info( 注:H/W 由当前`json`文件中的`resolution_stitch`配置分辨率决定。 -### 返回 +#### 返回 `Tuple` @@ -210,7 +164,7 @@ centroid, contour, depth = sensor.read_info( - 标量类数据:`float` - 时间戳:`str`(`TIMESTAMP`,格式为 `HH:MM:SS`) -### 示例 +#### 示例 ```python # Example 1:读取标量数据 fps, fn, fx, fy = sensor.read_info( @@ -223,22 +177,114 @@ fps, fn, fx, fy = sensor.read_info( # Example 2: 读取单个向量场(注意解包方式) flow = sensor.read_info(sensor.info.VRAW) ``` -*** +### `Sensor.disconnect` 方法 -## 5. `Sensor.disconnect` 方法 - -### 描述 +#### 描述 释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 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 展开映射。 2. 用 `grab_and_process()` 或 `read_source() + process_frame()` 获取一帧展开后的触觉图像。 @@ -247,7 +293,7 @@ flow = sensor.read_info(sensor.info.VRAW) 相比直接使用 `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`。 -* **config** (`str`,可选): Orisys 配置文件名称或路径,默认 `dd02`(兼容解析到 `dd02-ov`)。 +* **config** (`str`,可选): Orisys 配置文件名称或路径,默认 `finger`。 * **map_cache** (`str`,可选): UV 展开缓存文件路径,默认值来自 `orisys.finger.DEFAULT_FINGER_MAP_CACHE`。 * **source_scale** (`float`,可选): 输入相机分辨率缩放比例;未提供时根据配置自动推断。 * **calib** (`str | None`,可选): 外部相机标定文件路径;未提供时优先使用导出资产中的内参。 @@ -282,13 +328,13 @@ flow = sensor.read_info(sensor.info.VRAW) from orisys.finger import FingerRuntime runtime = FingerRuntime.open( - config="dd02", + config="finger", video=0, verbose=True, ) ``` -### 2. `FingerRuntime` 常用属性 +### `FingerRuntime` 常用属性 * **runtime.geometry**: `FingerGeometry`,包含输入/输出分辨率、导出资产路径、相机内参、缓存路径等一次性初始化结果。 * **runtime.domain**: UV 展开域对象,内部包含 remap、valid mask 以及可选 surface 数据。 @@ -296,7 +342,7 @@ runtime = FingerRuntime.open( * **runtime.capture**: OpenCV `VideoCapture` 对象。 * **runtime.valid_mask**: 当前 UV 域有效像素掩码。 -### 3. `FingerRuntime.read_source` 方法 +### `FingerRuntime.read_source` 方法 #### 描述 @@ -308,11 +354,11 @@ runtime = FingerRuntime.open( * **ok** (`bool`): 是否成功读取。 * **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 图。 -### 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`): 是否成功读取并处理。 * **unrolled** (`np.ndarray | None`): 展开后的触觉图像。 -### 6. `FingerRuntime.get_img` 方法 +### `FingerRuntime.get_img` 方法 #### 描述 @@ -353,11 +399,11 @@ runtime = FingerRuntime.open( * `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 前的采集。 * **unrolled** (`np.ndarray | None`): warmup 时得到的展开图。 -### 8. `FingerRuntime.close` 方法 +### `FingerRuntime.close` 方法 #### 描述 释放内部 `VideoCapture` 并调用 `runtime.tactile.disconnect()` 清理 `Sensor` 资源。程序退出前应调用。 -### 9. `TactileProcessor` 类别 +### `TactileProcessor` 类别 #### 描述 @@ -386,16 +432,16 @@ runtime = FingerRuntime.open( * **raw_sensor**: 底层 `Sensor` 对象。 * **info**: 与 `Sensor.info` 相同的枚举入口。 * **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**: 当前有效区域掩码。 #### 说明 除 `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 import time @@ -404,7 +450,7 @@ import orisys from orisys.finger import FingerRuntime runtime = FingerRuntime.open( - config="dd02", + config="finger", video=0, verbose=True, ) @@ -460,9 +506,9 @@ finally: 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()` 2. `runtime.process_frame(...)` @@ -472,11 +518,11 @@ finally: 并对各阶段做耗时统计。因此在业务代码中,也可以按同样方式自行插入计时逻辑。 -# 可视化相关 +## 可视化相关 -## 1. `util.draw_arrows` 方法 +### `util.draw_arrows` 方法 -### 输入参数 +#### 输入参数 * **frame**:背景图像 * **optical_flow**:光流场 @@ -488,12 +534,12 @@ finally: * **below_threshold_color**:RGB格式的`uint8`颜色,如(0, 0, 255) * **edge_margin**: 不显示箭头边距(pixel) -### 返回 +#### 返回 `np.ndarray` :箭头光流可视化图像 -## 2. `util.draw_contact` 方法 +### `util.draw_contact` 方法 -### 输入参数 +#### 输入参数 * **image**:背景图像 * **contour**:轮廓 @@ -501,42 +547,36 @@ finally: * **point_color**:触碰点的颜色 (BGR) * **region_color**:轮廓的颜色 (BGR) -### 返回 +#### 返回 `np.ndarray` :箭头光流可视化图像 -*** +## 配置标定相关 -# 配置标定相关 +### `list_config` 方法 -## 1. `list_config` 方法 - -### 描述 +#### 描述 列出内置传感器配置参数字典。 -### 返回 +#### 返回 * `list`: 内置传感器配置参数字典的名字列表。 -*** -## 2. `export_config` 方法 +### `export_config` 方法 -### 描述 +#### 描述 导出当前传感器实例使用的配置参数字典。 -### 输入参数 +#### 输入参数 * **config_name**: 需要导出的配置参数字典名字,`str` * **path**: 导出的路径,默认`"./config/exported_config.json"` -### 返回 +#### 返回 * `dict`: 导出的配置参数字典。 -### 保存 +#### 保存 * 导出内置的`config_name`配置参数字典至`path` -*** -*** - ## 常见问题解答 (FAQ) ### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`? A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。 @@ -556,9 +596,5 @@ A:当需要接触相关信息(如接触质心、轮廓或深度/散度图) A:`read_info()`按传入的`sensor.info`参数顺序返回一个元组。 返回值顺序与参数顺序一一对应,与内部存储顺序无关。 -### Q5:如果没有标定文件,或拼接结果不正确,会发生什么? -A:若未指定或未找到标定文件,系统会在需要时创建新的拼接标定数据并保存到默认路径。 -拼接结果异常通常与标定数据、拼接配置或输入图像质量有关,建议重新生成拼接标定并检查配置参数。 - -### Q6: 运行报错:“AttributeError: 'Stitcher' object has no attribute 'pos_default'” +### Q5: 运行报错:“AttributeError: 'Stitcher' object has no attribute 'pos_default'” A: 请检查视频源的编号是否正确,以及是否能正常打开视频源。在linux 使用`v4l2-ctl --list-devices` 或 Windows使用`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }` 列出摄像头设备。 \ No newline at end of file diff --git a/docs/api.pdf b/docs/api.pdf deleted file mode 100644 index 0b72d9c..0000000 Binary files a/docs/api.pdf and /dev/null differ diff --git a/docs/get_started.docx b/docs/get_started.docx deleted file mode 100644 index 91657fb..0000000 Binary files a/docs/get_started.docx and /dev/null differ diff --git a/docs/get_started.md b/docs/get_started.md index daaa7cd..b0838da 100644 --- a/docs/get_started.md +++ b/docs/get_started.md @@ -1,80 +1,106 @@ -叠动科技软件使用手册 -版本: 0.4.2 -更新日期: 2026年7月13日 +--- +title: 快速开始 +description: 安装并运行 Orisys SDK 的基础步骤 +--- -# 简介 +# 快速开始 + +版本:0.4.3 + +## 简介 本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。 支持操作系统:Windows、Ubuntu 建议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 工作空间,可以避免不同项目所需的库版本冲突。 -打开命令行终端(或Anaconda Prompt),依次输入以下命令(以 python 3.10 为例): -``` bash +打开命令行终端(或 Anaconda Prompt),依次输入以下命令(以 Python 3.10 为例): +```bash conda create -n orisys python=3.10 conda activate orisys ``` 激活后,命令行提示符前会出现 `(orisys)` 字样,表示当前已进入虚拟环境。 -## 创建工作区文件夹 -“Anaconda Prompt”启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理,可以手动创建或者运行`mkdir `命令:, 例如 +### 创建工作区文件夹 +“Anaconda Prompt” 启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理。可以手动创建文件夹,也可以运行 `mkdir ` 命令。例如: -``` bash +```bash mkdir orisys_ws ``` -创建后如果要更换工作区,使用 `cd `命令,例如: +创建后如果要更换工作区,使用 `cd ` 命令,例如: -``` bash +```bash cd "C:\Users\orisys\orisys_ws" ``` -
- -## 安装 CUDA 和 cuDNN(可选) +### 安装 CUDA 和 cuDNN(可选) 如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。 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 安装,推荐): 在已激活的 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,无需手动下载。 注意:如果您的计算机没有 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 文件,文件名格式通常为: 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) 操作系统:Windows(win) 硬件架构: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 运行,可以跳过这一步。 CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安装的 CUDA 版本选择对应的 CuPy 版本。 @@ -84,83 +110,88 @@ CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安 ```bash # 如果您的 CUDA 版本是 12.x -pip install orisys[cuda12] +pip install "orisys[cuda12]" # 如果您的 CUDA 版本是 13.x -pip install orisys[cuda13] +pip install "orisys[cuda13]" ``` 如果之前已经安装了 orisys,可以单独安装 CuPy: 方法二(手动安装): -``` bash +```bash # CUDA 12.x -pip install cupy-cuda12x>=12.3.0 +pip install "cupy-cuda12x>=12.3.0" # CUDA 13.x -pip install cupy-cuda13x>=13.0.0 +pip install "cupy-cuda13x>=13.0.0" ``` -## 测试安装 -安装完成后,在命令行输入” python“命令进入 Python 环境, 并导入 orisys 库并查看版本号,验证是否成功: -``` bash +### 测试安装 +安装完成后,在命令行输入 `python` 命令进入 Python 环境,并导入 `orisys` 库查看版本号,验证是否成功: +```bash python +``` + +进入 Python 后继续输入: + +```python import orisys 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`) -2. `examples/products/ory_mini/basic_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`) -3. `examples/products/ory_finger/finger_pipeline.py`:ORY-FINGER 2D UV 最小流程(默认配置 `dd02`) -4. `examples/products/ory_finger/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `dd02`) +1. `examples/basic_pipeline.py`:ORY-BOX 复眼/拼接传感器基础 OpenCV 示例(默认配置 `box`) +2. `examples/mini_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`) +3. `examples/finger_pipeline.py`:ORY-FINGER 2D 显示最小流程(默认配置 `finger`) +4. `examples/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `finger`) -`dd02` 是当前 ORY-FINGER 的稳定别名,实际会解析到 `dd02-ov`。旧路径 `examples/basic_pipeline.py` / `examples/finger_pipeline*.py` 仍可作为兼容入口。 +`finger` 是当前 ORY-FINGER 的产品配置名。 下面先介绍最基础的 ORY-BOX 运行方式。 -### Windows: -1. 检查是否有其他正在连接的USB摄像头设备:打开powershell, 运行命令`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。 -2. 把传感器的USB头与电脑连接,再次运行步骤一的命令,会新增一行输出 -3. 如果第一步没有输出,传感器的ID就是“0”,如果第一步有一个设备,传感器的ID就是“1”,如此类推。 -4. 在确定了传感器的ID(下方例子假设为“0”)后,打开Anaconda Prompt, 切换到工作路径,然后在命令行中输入: - ``` bash - python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 +#### Windows +1. 检查是否有其他正在连接的 USB 摄像头设备:打开 PowerShell,运行命令 `Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。 +2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增一行输出。 +3. 如果第一步没有输出,传感器的 ID 就是 `0`;如果第一步有一个设备,传感器的 ID 就是 `1`,如此类推。 +4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开 Anaconda Prompt,切换到工作路径,然后在命令行中输入: + ```bash + python examples/basic_pipeline.py -v 0 -c box ``` -5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据 +5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。 -### Ubuntu: -1. 检查是否有其他正在连接的USB摄像头设备:打开终端, 运行命令`v4l2 –list-devices`,如果没有的话不会有输出。 -2. 把传感器的USB头与电脑连接,再次运行步骤一的命令,会新增输出 -3. 根据新增的输出,例如”/dev/video0”,确定传感器的ID为“0”, 如果是”/dev/video3”的话ID为“3” ,如此类推。 -4. 在确定了传感器的ID(下方例子假设为“0”)后,打开终端, 激活相应的python环境,切换到工作路径,然后在命令行中输入: - ``` bash - python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 +#### Ubuntu +1. 检查是否有其他正在连接的 USB 摄像头设备:打开终端,运行命令 `v4l2 --list-devices`,如果没有的话不会有输出。 +2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增输出。 +3. 根据新增的输出,例如 `/dev/video0`,确定传感器的 ID 为 `0`;如果是 `/dev/video3`,ID 为 `3`,如此类推。 +4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开终端,激活相应的 Python 环境,切换到工作路径,然后在命令行中输入: + ```bash + python examples/basic_pipeline.py -v 0 -c box ``` -5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据 +5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。 -### ORY-FINGER UV 示例 +#### ORY-FINGER 示例 -如果您使用的是 ORY-FINGER(当前配置别名 `dd02`),可运行: +如果您使用的是 ORY-FINGER(配置名 `finger`),可运行: -``` bash -python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 +```bash +python examples/finger_pipeline.py --camera_id 0 --config finger ``` 如果输入来自视频文件,也可以改用: -``` bash -python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4 --config dd02 +```bash +python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger ``` 该示例会: - 读取相机或视频源 -- 将图像展开到 UV 平面 +- 将图像展开到平面 - 计算光流、法向/切向力和接触区域 - 在 OpenCV 窗口中显示展开后的光流箭头图 @@ -170,20 +201,19 @@ python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4 例如: -``` bash -python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 +```bash +python examples/finger_pipeline.py --camera_id 0 --config finger ``` -### ORY-FINGER 3D 可视化界面 +#### ORY-FINGER 3D 可视化界面 -如果要使用 GUI ,安装GUI依赖: -``` bash -pip install open3d -pip install pyqt5 pyqtgraph +如果要使用 GUI,先安装 GUI 依赖: +```bash +pip install "orisys[gui]" ``` 然后运行: -``` bash -python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02 +```bash +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 视图 -## 开发套件使用方法 +### 开发套件使用方法 参考开发套件附带的开发文档。 \ No newline at end of file diff --git a/docs/get_started.pdf b/docs/get_started.pdf deleted file mode 100644 index 344c8cd..0000000 Binary files a/docs/get_started.pdf and /dev/null differ diff --git a/docs/publishing.md b/docs/publishing.md deleted file mode 100644 index 17b07e8..0000000 --- a/docs/publishing.md +++ /dev/null @@ -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//` (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 = "" - -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==" -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 = "" - -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. diff --git a/examples/README.md b/examples/README.md index 913c9f1..d4d974a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,54 +1,50 @@ # 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. ## Product Entry Points | Product | Entry | Default config | |---------|-------|----------------| -| ORY-BOX | `products/ory_box/basic_pipeline.py` | `ddjx01` | -| ORY-MINI | `products/ory_mini/basic_pipeline.py` | `mini` | -| ORY-FINGER (2D) | `products/ory_finger/finger_pipeline.py` | `dd02` | -| ORY-FINGER (3D Qt) | `products/ory_finger/finger_pipeline_3dview.py` | `dd02` | +| ORY-BOX | `basic_pipeline.py` | `box` | +| ORY-MINI | `mini_pipeline.py` | `mini` | +| ORY-FINGER (2D) | `finger_pipeline.py` | `finger` | +| ORY-FINGER (3D Qt) | `finger_pipeline_3dview.py` | `finger` | -`dd02` is the stable ORY-FINGER alias and currently resolves to `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. +`finger` is the public ORY-FINGER config stem. `dd02` remains a compatibility alias for `dd02-ov`. ## Typical Usage ORY-BOX: ```bash -python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 +python examples/basic_pipeline.py -v 0 -c box ``` ORY-MINI: ```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: ```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: ```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: ```bash 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 @@ -70,10 +66,10 @@ pip install -e ".[gui]" ## 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. - Dashboard controls: click **启动** to start acquisition; use **Record CSV / Stop CSV** and the CSV save-directory browser to log contact/force data; `Q` quits, `R` resets, `S` starts video recording, `E` stops video recording, and `Esc` toggles fullscreen. -- `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. diff --git a/examples/basic_pipeline.py b/examples/basic_pipeline.py index 13983a2..db2d2f5 100644 --- a/examples/basic_pipeline.py +++ b/examples/basic_pipeline.py @@ -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 -from pathlib import Path +SDK version: 0.4.3 +""" +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 -if str(_EXAMPLES_DIR) not in sys.path: - sys.path.insert(0, str(_EXAMPLES_DIR)) +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径") + parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径") + parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志") + 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() diff --git a/examples/finger_pipeline.py b/examples/finger_pipeline.py index 836531d..10b17d3 100644 --- a/examples/finger_pipeline.py +++ b/examples/finger_pipeline.py @@ -1,20 +1,110 @@ #!/usr/bin/env python3 -"""Compatibility wrapper for ORY-FINGER 2D pipeline. +""" +ORY-FINGER:Minimum UV-unroll finger tactile pipeline. -Prefer: - python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 +Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz +Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact + +Recommended entry: + python examples/finger_pipeline.py --camera_id 0 --config finger + +Current product config: ``finger`` (``dd02`` still resolves to ``dd02-ov``). """ -from __future__ import annotations - +import argparse +import os import sys -from pathlib import Path -_EXAMPLES_DIR = Path(__file__).resolve().parent -if str(_EXAMPLES_DIR) not in sys.path: - sys.path.insert(0, str(_EXAMPLES_DIR)) +os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") -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__": diff --git a/examples/finger_pipeline_3dview.py b/examples/finger_pipeline_3dview.py index 04a8d86..8d9561d 100644 --- a/examples/finger_pipeline_3dview.py +++ b/examples/finger_pipeline_3dview.py @@ -1,20 +1,90 @@ #!/usr/bin/env python3 -"""Compatibility wrapper for ORY-FINGER 3D Qt dashboard. +""" +ORY-FINGER:UV-unroll finger tactile pipeline with unified Qt dashboard. -Prefer: - python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02 +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/finger_pipeline_3dview.py --video 0 --config finger """ -from __future__ import annotations - +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().parent if str(_EXAMPLES_DIR) not in sys.path: 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__": diff --git a/examples/gui/app_paths.py b/examples/gui/app_paths.py index 998640a..667bba7 100644 --- a/examples/gui/app_paths.py +++ b/examples/gui/app_paths.py @@ -27,8 +27,17 @@ def resource_path(*parts: str) -> Path: def default_config_path() -> str: - """Default ORY-FINGER config name (resolves to dd02-ov via alias).""" - return "dd02" + """Default ORY-FINGER config name (packaged stem ``finger``).""" + 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: diff --git a/examples/gui/preference.json b/examples/gui/preference.json index 3599411..c81d1e7 100644 --- a/examples/gui/preference.json +++ b/examples/gui/preference.json @@ -1,10 +1,12 @@ { - "video_source": "D:/repo/orisys-sdk/data/dd02/0607_1.mp4", - "config_path": "./config/dd02-ov.json", - "finger_assets_dir": "D:/repo/orisys-sdk/assets/finger/30fps-0703", + "video_source": "D:/repo/orisys-sdk/data/dd02-0703/1.mp4", + "sensor_bundle_dir": "sensors/finger", + "config_path": "sensors/finger/sensor.json", + "finger_assets_dir": "sensors/finger/assets/30fps-0703", "csv_save_dir": "./outputs/csv", "show_log": true, "show_flow_arrows": false, - "arrow_background": "pure_color", - "free_rotate": true + "arrow_background": "unrolled", + "free_rotate": true, + "log_display_mode": "hidden" } diff --git a/examples/gui/sensors/adapter.py b/examples/gui/sensors/adapter.py index 8386494..6690538 100644 --- a/examples/gui/sensors/adapter.py +++ b/examples/gui/sensors/adapter.py @@ -11,6 +11,7 @@ from typing import Any, Optional import cv2 import numpy as np import orisys +from orisys.signal_processing import calibrate_normal_force from .roi_geometry import ( 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 class FrameSnapshot: """Normalized per-frame payload consumed by the Qt viewer.""" @@ -49,11 +46,6 @@ class FrameSnapshot: 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): """Common interface between Qt and concrete sensor backends.""" diff --git a/examples/gui/viewer2d/backend.py b/examples/gui/viewer2d/backend.py index 06d92c1..44948b1 100644 --- a/examples/gui/viewer2d/backend.py +++ b/examples/gui/viewer2d/backend.py @@ -13,7 +13,7 @@ from typing import Literal, Optional, Sequence, Tuple import cv2 import numpy as np import orisys -from scipy.signal import butter, filtfilt +from orisys.signal_processing import lowpass_filter FORCE_SAMPLE_RATE_HZ = 30.0 PLOT_BUFFER_POINTS = 120 @@ -61,32 +61,6 @@ class ForcePlotState: 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]: if img is None: return None diff --git a/examples/gui/viewer2d/window.py b/examples/gui/viewer2d/window.py index e67ed05..fa95e2c 100644 --- a/examples/gui/viewer2d/window.py +++ b/examples/gui/viewer2d/window.py @@ -111,7 +111,7 @@ class QtViewerWindow(QMainWindow): self.btn_config.setObjectName("btnConfig") self.btn_config.clicked.connect(self._select_config) bar.addWidget(self.btn_config) - self.config_edit = QLineEdit("./config/ddjx01.json") + self.config_edit = QLineEdit("box") bar.addWidget(self.config_edit, 1) self.btn_reset = QPushButton("重置") diff --git a/examples/gui/viewer3d/dashboard.py b/examples/gui/viewer3d/dashboard.py index b85828b..13133ec 100644 --- a/examples/gui/viewer3d/dashboard.py +++ b/examples/gui/viewer3d/dashboard.py @@ -2,14 +2,19 @@ from __future__ import annotations +import json import math import sys import time +from pathlib import Path from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import ( + QAction, + QActionGroup, QApplication, + QDockWidget, QFileDialog, QMainWindow, QSplitter, @@ -61,6 +66,7 @@ class FingerDashboardWindow(QMainWindow): self._frame_index = 0 self._wall_fps = 0.0 self._wall_fps_alpha = 0.15 + self._log_display_mode = "hidden" self.setWindowTitle(layout.window_title) self.setStyleSheet(STYLE) @@ -84,7 +90,7 @@ class FingerDashboardWindow(QMainWindow): root_layout.setSpacing(layout.root_spacing_px) 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_viewport = MeshViewportWidget(layout=layout, mesh_state=self.mesh_state) arrows_slot = layout.slot("arrows") @@ -111,7 +117,6 @@ class FingerDashboardWindow(QMainWindow): self._preferences = load_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())) if self.arrow_panel is not None: 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.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) h_splitter = QSplitter(Qt.Horizontal) @@ -172,18 +172,104 @@ class FingerDashboardWindow(QMainWindow): self.h_splitter = 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()) QTimer.singleShot(0, self._apply_panel_layout) def _set_log_panel_visible(self, visible: bool) -> None: 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) + 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: - self._set_log_panel_visible(visible) - self._save_preferences() + self._apply_log_display_mode("runtime" if visible else "hidden", save=True) def _on_free_rotation_changed(self, enabled: bool) -> None: self.mesh_viewport.set_free_rotation(enabled) @@ -196,6 +282,7 @@ class FingerDashboardWindow(QMainWindow): def _save_preferences(self) -> None: prefs = self.settings_panel.preference_state() + prefs["log_display_mode"] = self._log_display_mode self._preferences = prefs save_preferences(prefs) # Keep in-memory pipeline config aligned with panel values. @@ -217,7 +304,6 @@ class FingerDashboardWindow(QMainWindow): scale = self._layout_scale_factor() column_height = self._scaled_top_row_height(scale) charts_slot = self.layout.top_slot("charts") - log_slot = setup.log_panel for slot in setup.top_row.slots: if not slot.enabled: @@ -236,27 +322,11 @@ class FingerDashboardWindow(QMainWindow): settings_height, ) - if ( - self._log_panel_visible - and log_slot.enabled - and log_slot.height_px > 0 - ): - 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.force_charts.set_panel_size( + self._scaled_px(charts_slot.width_px, scale), + column_height, + ) + self.charts_column.setSizes([column_height]) self.h_splitter.setSizes( [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() 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() 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_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: return 1.0 return min(available_width / base_width, available_height / base_height) @@ -602,12 +660,27 @@ class FingerDashboardWindow(QMainWindow): def _select_finger_assets(self) -> None: path = QFileDialog.getExistingDirectory( self, - "选择 finger 资产目录", - self.settings_panel.finger_export_dir() or "assets/finger", + "选择传感器 bundle 目录", + self.settings_panel.sensor_bundle_dir() or "sensors/finger", ) if path: - self.settings_panel.set_finger_export_dir(path) - self.config.export_dir = path + bundle_dir = Path(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() def _select_csv_dir(self) -> None: diff --git a/examples/gui/viewer3d/presets.py b/examples/gui/viewer3d/presets.py new file mode 100644 index 0000000..650e1d0 --- /dev/null +++ b/examples/gui/viewer3d/presets.py @@ -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 diff --git a/examples/gui/viewer3d/widgets/settings_panel.py b/examples/gui/viewer3d/widgets/settings_panel.py index 92ccb3f..c5c421d 100644 --- a/examples/gui/viewer3d/widgets/settings_panel.py +++ b/examples/gui/viewer3d/widgets/settings_panel.py @@ -15,11 +15,15 @@ from PyQt5.QtWidgets import ( QWidget, ) -from orisys.finger import DEFAULT_FINGER_EXPORT_DIR from viz.render_2d import ArrowBackground from ..layout import DashboardLayout, LAYOUT 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 _PLAY_PAUSE_BUTTON_NAME = "btnPlayPause" @@ -86,7 +90,7 @@ class SettingsPanelWidget(QWidget): self.show_log_check = QCheckBox("Show log panel") self.show_log_check.setChecked(show_log) 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.setChecked(self._mesh_state.flow_arrows_enabled) @@ -134,35 +138,38 @@ class SettingsPanelWidget(QWidget): self._video_file_path = "" self._updating_camera_options = False - root.addWidget(self._caption("Config")) - config_row = QHBoxLayout() - config_row.setSpacing(6) - self.config_edit = QLineEdit("dd02") - config_row.addWidget(self.config_edit, 1) + root.addWidget(self._caption("Sensor bundle")) + bundle_row = QHBoxLayout() + bundle_row.setSpacing(6) + self.sensor_bundle_combo = QComboBox() + 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.setFixedWidth(32) - 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) + self.btn_browse_config.hide() # Optional calibration path is kept for future re-enablement but hidden for now. self.cal_edit = QLineEdit("") self.cal_edit.setPlaceholderText("Optional calibration path") self.cal_edit.hide() - root.addWidget(self._caption("Finger assets")) - finger_assets_row = QHBoxLayout() - finger_assets_row.setSpacing(6) - 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) + self.finger_assets_edit = QLineEdit("sensors/finger/assets/30fps-0703") + self.finger_assets_edit.hide() + self._apply_bundle_preset(self._bundle_presets[0]) root.addWidget(self._caption("CSV save directory")) csv_dir_row = QHBoxLayout() @@ -337,16 +344,18 @@ class SettingsPanelWidget(QWidget): def set_config_path(self, value: str) -> None: self.config_edit.setText(value) + self._sync_bundle_combo_from_paths() def set_calibration_path(self, value: str) -> None: self.cal_edit.setText(value or "") def set_finger_export_dir(self, value: str) -> None: self.finger_assets_edit.setText(value) + self._sync_bundle_combo_from_paths() def set_finger_export_dir_enabled(self, enabled: bool) -> None: - self.finger_assets_edit.setEnabled(enabled) self.btn_browse_finger_assets.setEnabled(enabled) + self.sensor_bundle_combo.setEnabled(enabled) def set_csv_save_dir(self, value: str) -> None: self.csv_dir_edit.setText(value) @@ -387,6 +396,33 @@ class SettingsPanelWidget(QWidget): return 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: value = self.arrow_background_combo.currentData() if value: @@ -413,6 +449,15 @@ class SettingsPanelWidget(QWidget): def finger_export_dir(self) -> str: 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: return self.csv_dir_edit.text().strip() @@ -420,6 +465,7 @@ class SettingsPanelWidget(QWidget): """Serialize user-facing settings suitable for preference.json.""" return { "video_source": self.video_source(), + "sensor_bundle_dir": self.sensor_bundle_dir(), "config_path": self.config_path(), "finger_assets_dir": self.finger_export_dir(), "csv_save_dir": self.csv_save_dir(), @@ -465,6 +511,8 @@ class SettingsPanelWidget(QWidget): if isinstance(assets, str) and assets.strip(): self.set_finger_export_dir(assets.strip()) + self._sync_bundle_combo_from_paths() + csv_dir = state.get("csv_save_dir") if isinstance(csv_dir, str) and csv_dir.strip(): self.set_csv_save_dir(csv_dir.strip()) diff --git a/examples/products/ory_mini/basic_pipeline.py b/examples/mini_pipeline.py similarity index 97% rename from examples/products/ory_mini/basic_pipeline.py rename to examples/mini_pipeline.py index 5d26216..cb9e7c3 100644 --- a/examples/products/ory_mini/basic_pipeline.py +++ b/examples/mini_pipeline.py @@ -7,7 +7,7 @@ ORY-MINI:单传感器基础流程 3. 按需计算接触区域 4. 读取并可视化结果 -SDK version: 0.4.2 +SDK version: 0.4.3 """ import os os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" @@ -43,7 +43,7 @@ def main(): # 步骤 2.1:获取并拼接图像(必需) sensor.get_img() # 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动 - is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0) + is_contact_now = sensor.compute_deformation() # 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域 # if is_contact_now: sensor.compute_contact() diff --git a/examples/pipeline/config.py b/examples/pipeline/config.py index a11a75e..9806cb6 100644 --- a/examples/pipeline/config.py +++ b/examples/pipeline/config.py @@ -17,7 +17,7 @@ def mesh_threshold(mesh_map: str, threshold: float | None) -> float: @dataclass class PipelineConfig: video: str = "0" - config: str = "dd02" + config: str = "finger" calib: str | None = None export_dir: str = DEFAULT_FINGER_EXPORT_DIR csv_save_dir: str = "./outputs/csv" diff --git a/examples/plot_data.py b/examples/plot_data.py index 9e3cb7f..cdbb002 100644 --- a/examples/plot_data.py +++ b/examples/plot_data.py @@ -1,29 +1,24 @@ """ -示例 3:实时曲线与幅值图显示 +示例 3:实时曲线与深度图显示 本示例演示如何在读取传感器结果的同时: 1. 显示形变矢量场 -2. 显示流场幅值热力图 +2. 显示接触深度图 3. 使用 PyQtGraph 实时绘制法向力与切向力曲线 4. 按键控制录像开始与结束 """ import argparse import os -import sys import time -from pathlib import Path os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" import cv2 +import numpy as np import orisys -_EXAMPLES_DIR = Path(__file__).resolve().parent -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 +from viz import ForceChartWindow, FrameRecorder 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(): parser = argparse.ArgumentParser() 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("--cal", "-cal", type=str, default="./config/ddjx01.npy", help="标定文件路径") + parser.add_argument("--config", "-c", type=str, default="box", 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="是否输出详细日志") args = parser.parse_args() charts = ForceChartWindow() recorder = FrameRecorder() 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() print("\n按键说明:'q' 退出程序,'s' 开始录像,'e' 结束录像。") @@ -53,31 +55,30 @@ def main(): sensor.get_img() recorder.write_if_recording(sensor.frame) - sensor.compute_deformation(check_motion=True, threshold=0) - fps, fn, fx, fy, flow, vnormal, vshear = sensor.read_info( + sensor.compute_deformation() + sensor.compute_contact() + fps, fn, fx, fy, flow, depth_map = sensor.read_info( sensor.info.FPS, sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY, sensor.info.VRAW, - sensor.info.VNORMAL, - sensor.info.VSHEAR, + sensor.info.DEPTH, ) 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) 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) - display = charts.data.copy() - for channel in range(3): - display[:, 1, channel] = lowpass_filter( - charts.data[:, 1, channel], - cutoff_hz=5, - sample_rate_hz=30, - ) - charts.refresh(display) + charts.refresh() key = cv2.waitKey(1) if key & 0xFF == ord("q"): diff --git a/examples/products/__init__.py b/examples/products/__init__.py deleted file mode 100644 index f8e6c8d..0000000 --- a/examples/products/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Product-facing example entry points for ORY-BOX / ORY-MINI / ORY-FINGER.""" diff --git a/examples/products/ory_box/basic_pipeline.py b/examples/products/ory_box/basic_pipeline.py deleted file mode 100644 index d7568ed..0000000 --- a/examples/products/ory_box/basic_pipeline.py +++ /dev/null @@ -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() diff --git a/examples/products/ory_finger/finger_pipeline.py b/examples/products/ory_finger/finger_pipeline.py deleted file mode 100644 index 6aba015..0000000 --- a/examples/products/ory_finger/finger_pipeline.py +++ /dev/null @@ -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() diff --git a/examples/products/ory_finger/finger_pipeline_3dview.py b/examples/products/ory_finger/finger_pipeline_3dview.py deleted file mode 100644 index d9e1eb3..0000000 --- a/examples/products/ory_finger/finger_pipeline_3dview.py +++ /dev/null @@ -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() diff --git a/examples/two_sensor_pipeline.py b/examples/two_sensor_pipeline.py index 9d78b09..2d912dd 100644 --- a/examples/two_sensor_pipeline.py +++ b/examples/two_sensor_pipeline.py @@ -18,7 +18,7 @@ - Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support() - 每个传感器进程都会占用独立的 GPU 显存 -SDK version: 0.4.2 +SDK version: 0.4.3 """ import os os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" @@ -138,12 +138,12 @@ def main(): { 'sensor_id': 0, 'vid_src': args.video1, - 'config_name': './config/ddjx01.json' + 'config_name': 'box' }, { 'sensor_id': 1, 'vid_src': args.video2, # 可替换为其他视频源或摄像头索引 - 'config_name': './config/ddjx01.json' + 'config_name': 'box' }, ] diff --git a/examples/viz/signal_filters.py b/examples/viz/signal_filters.py index ff58d53..f142242 100644 --- a/examples/viz/signal_filters.py +++ b/examples/viz/signal_filters.py @@ -1,32 +1,5 @@ """Signal filtering helpers for example force charts.""" -from __future__ import annotations +from orisys.signal_processing import lowpass_filter -import numpy as np -from scipy.signal import butter, filtfilt - -def lowpass_filter(data, cutoff_hz=5, sample_rate_hz=30, order=2) -> np.ndarray: - """Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data.""" - y = np.asarray(data, dtype=np.float64) - 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() +__all__ = ["lowpass_filter"] diff --git a/pyproject.toml b/pyproject.toml index e9ea3d4..7517307 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=61.0,<80", "wheel==0.45.1"] build-backend = "setuptools.build_meta" [project] @@ -75,11 +75,19 @@ patterns = ["*.json"] # files = ["dd02-ov.json", "ddjx01.json"] 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]] name = "examples" source = "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 [[tool.orisys.bundle.items]] @@ -88,11 +96,3 @@ source = "docs" target = "docs" patterns = ["*.md", "*.pdf"] -[[tool.orisys.bundle.items]] -name = "assets" -source = "assets" -target = "assets" -patterns = ["finger/30fps/*", "finger/visualization/*"] -preserve_tree = true -dev_install = true - diff --git a/requirements.txt b/requirements.txt index 6f28dc5..bb5f7e6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,27 @@ -# CuPy - Install the appropriate version based on your CUDA version: -# For CUDA 12.x: pip install cupy-cuda12x>=12.3.0 -# For CUDA 13.x: pip install cupy-cuda13x>=13.0.0 -# Or use extras: pip install orisys[cuda12] or pip install orisys[cuda13] +# Runtime dependencies for editable / source installs. +# Prefer extras from pyproject.toml: +# pip install -e . +# 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 -opencv_python>=4.10.0.84 -scipy==1.13 -setuptools>=45.2.0 -av -numba -pyqt5 -pyqtgraph \ No newline at end of file +opencv-python>=4.10.0.84 +scipy>=1.10.1 +numba>=0.59.0 + +# GUI extras: pip install -e ".[gui]" +# open3d>=0.17.0 +# 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 diff --git a/scripts/calibration_script.py b/scripts/calibration_script.py index f8d85ac..87955b3 100644 --- a/scripts/calibration_script.py +++ b/scripts/calibration_script.py @@ -115,7 +115,7 @@ def main(): writer.write(save_img) - sensor1.compute_deformation(check_motion=False) # 计算形变和分解 + sensor1.compute_deformation() # 计算形变和分解 # 提取信息: # 向量场: VRAW, VNORMAL, VSHEAR: 原始,法向,切向 # 帧率,时间戳: FPS, TIMESTAMP diff --git a/scripts/demo_wuxi.py b/scripts/demo_wuxi.py deleted file mode 100644 index d6cfe1c..0000000 --- a/scripts/demo_wuxi.py +++ /dev/null @@ -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() diff --git a/scripts/qt_viewer_finger.py b/scripts/qt_viewer_finger.py deleted file mode 100644 index bbe8d55..0000000 --- a/scripts/qt_viewer_finger.py +++ /dev/null @@ -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() diff --git a/scripts/test_ffmpeg.py b/scripts/test_ffmpeg.py deleted file mode 100644 index 1c3396f..0000000 --- a/scripts/test_ffmpeg.py +++ /dev/null @@ -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() diff --git a/sensors/box/force_calibration.json b/sensors/box/force_calibration.json new file mode 100644 index 0000000..4617082 --- /dev/null +++ b/sensors/box/force_calibration.json @@ -0,0 +1,8 @@ +{ + "normal_force": { + "model": "linear", + "slope": 3.92685554668795e-05, + "offset": 0.0217596967278535, + "unit": "N" + } +} diff --git a/sensors/box/sensor.json b/sensors/box/sensor.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/sensors/box/sensor.json @@ -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 + } +} + + diff --git a/sensors/finger/assets/30fps-0703/README.md b/sensors/finger/assets/30fps-0703/README.md new file mode 100644 index 0000000..24a039e --- /dev/null +++ b/sensors/finger/assets/30fps-0703/README.md @@ -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. diff --git a/sensors/finger/assets/30fps-0703/camera_intrinsics.npz b/sensors/finger/assets/30fps-0703/camera_intrinsics.npz new file mode 100644 index 0000000..888b1eb Binary files /dev/null and b/sensors/finger/assets/30fps-0703/camera_intrinsics.npz differ diff --git a/sensors/finger/assets/30fps-0703/mesh.npz b/sensors/finger/assets/30fps-0703/mesh.npz new file mode 100644 index 0000000..4da6bb1 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/mesh.npz differ diff --git a/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz b/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz new file mode 100644 index 0000000..15eee73 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz differ diff --git a/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz b/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz new file mode 100644 index 0000000..995cee0 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz differ diff --git a/sensors/finger/assets/30fps-0703/world_to_cam.npy b/sensors/finger/assets/30fps-0703/world_to_cam.npy new file mode 100644 index 0000000..3d6f897 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/world_to_cam.npy differ diff --git a/sensors/finger/assets/visualization/README.md b/sensors/finger/assets/visualization/README.md new file mode 100644 index 0000000..fe08404 --- /dev/null +++ b/sensors/finger/assets/visualization/README.md @@ -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. diff --git a/sensors/finger/assets/visualization/camera_intrinsics.npz b/sensors/finger/assets/visualization/camera_intrinsics.npz new file mode 100644 index 0000000..1d211d5 Binary files /dev/null and b/sensors/finger/assets/visualization/camera_intrinsics.npz differ diff --git a/sensors/finger/assets/visualization/flatten_remap.npz b/sensors/finger/assets/visualization/flatten_remap.npz new file mode 100644 index 0000000..02ca287 Binary files /dev/null and b/sensors/finger/assets/visualization/flatten_remap.npz differ diff --git a/sensors/finger/assets/visualization/mesh.npz b/sensors/finger/assets/visualization/mesh.npz new file mode 100644 index 0000000..ad81542 Binary files /dev/null and b/sensors/finger/assets/visualization/mesh.npz differ diff --git a/sensors/finger/assets/visualization/pixel_to_xyz.npz b/sensors/finger/assets/visualization/pixel_to_xyz.npz new file mode 100644 index 0000000..4096205 Binary files /dev/null and b/sensors/finger/assets/visualization/pixel_to_xyz.npz differ diff --git a/sensors/finger/assets/visualization/world_to_cam.npy b/sensors/finger/assets/visualization/world_to_cam.npy new file mode 100644 index 0000000..2d5e411 Binary files /dev/null and b/sensors/finger/assets/visualization/world_to_cam.npy differ diff --git a/sensors/finger/manifest.json b/sensors/finger/manifest.json new file mode 100644 index 0000000..27f92c8 --- /dev/null +++ b/sensors/finger/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "finger", + "sensor_json": "sensor.json", + "assets_dir": "assets/30fps-0703", + "viz_pose": "assets/visualization/world_to_cam.npy" +} diff --git a/sensors/finger/sensor.json b/sensors/finger/sensor.json new file mode 100644 index 0000000..d5f6264 --- /dev/null +++ b/sensors/finger/sensor.json @@ -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 + } +} diff --git a/sensors/mini/force_calibration.json b/sensors/mini/force_calibration.json new file mode 100644 index 0000000..4617082 --- /dev/null +++ b/sensors/mini/force_calibration.json @@ -0,0 +1,8 @@ +{ + "normal_force": { + "model": "linear", + "slope": 3.92685554668795e-05, + "offset": 0.0217596967278535, + "unit": "N" + } +} diff --git a/sensors/mini/sensor.json b/sensors/mini/sensor.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/sensors/mini/sensor.json @@ -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 + } +} + + diff --git a/setup.py b/setup.py index deacbae..85399a0 100644 --- a/setup.py +++ b/setup.py @@ -6,8 +6,8 @@ import sys from setuptools import setup 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)) diff --git a/src/orisys/__init__.py b/src/orisys/__init__.py index b9bdd0d..d464157 100644 --- a/src/orisys/__init__.py +++ b/src/orisys/__init__.py @@ -5,13 +5,14 @@ A Python library for processing and analyzing optical tactile sensor data, including image stitching, optical flow tracking, and deformation decomposition. """ -__version__ = "0.4.2" +__version__ = "0.4.3" __author__ = "Orisys" from .sensor import Sensor from . import util from . import configs from . import finger +from . import signal_processing from .frame_processors import FFCFrameProcessor CompoundEyeSensor = Sensor @@ -33,9 +34,12 @@ __all__ = [ # finger runtime helpers 'finger', + # signal helpers + 'signal_processing', + # configs utilities functions 'list_config', - 'export_config' + 'export_config', # test 'FFCFrameProcessor' diff --git a/src/orisys/configs.py b/src/orisys/configs.py index c3249f7..27bed95 100644 --- a/src/orisys/configs.py +++ b/src/orisys/configs.py @@ -1,12 +1,28 @@ import json +from contextlib import ExitStack +from dataclasses import dataclass +from importlib.resources import as_file, files from pathlib import Path # 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 = { "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]: """ @@ -27,6 +43,26 @@ def _candidate_config_dirs() -> list[Path]: 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: for candidate in _candidate_config_dirs(): if candidate.exists() and any(candidate.glob("*.json")): @@ -49,14 +85,96 @@ def _load_json(path: Path) -> dict: 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//`` 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//`` 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): """ Load sensor configuration from a JSON file path or config name. Config lookup order: 1. Exact file path (absolute or relative) - 2. File stem under the config directory (e.g. ``ddjx01`` -> ``config/ddjx01.json``) - 3. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible) + 2. Additive sensor bundle ``sensors//sensor.json`` + 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) @@ -74,6 +192,11 @@ def load_config(config_name_or_path): if alias and alias not in candidates: 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: direct = CONFIG_DIR / f"{name}.json" if direct.exists(): @@ -102,7 +225,11 @@ def list_config(): """ 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"): diff --git a/src/orisys/finger/exports.py b/src/orisys/finger/exports.py index d451df7..82b7e8a 100644 --- a/src/orisys/finger/exports.py +++ b/src/orisys/finger/exports.py @@ -4,6 +4,7 @@ import sys from dataclasses import dataclass from pathlib import Path +from ..configs import resolve_sensor_asset_dir from ..unroll import ( compute_uv_distortion_metrics, load_exported_data, @@ -11,7 +12,7 @@ from ..unroll import ( 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" @@ -44,11 +45,15 @@ def _candidate_export_dirs(export_dir) -> list[Path]: return [raw] 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, - here.parents[1] / raw, # packaged wheel: orisys/assets/... + here.parents[1] / raw, # packaged wheel filesystem layout here.parents[3] / raw, # editable install: repo/assets/... - ] + ]) if getattr(sys, "frozen", False): candidates.insert(0, Path(sys.executable).resolve().parent / raw) unique: list[Path] = [] diff --git a/src/orisys/finger/runtime.py b/src/orisys/finger/runtime.py index adae914..0393797 100644 --- a/src/orisys/finger/runtime.py +++ b/src/orisys/finger/runtime.py @@ -146,7 +146,7 @@ class FingerGeometry: def resolve_finger_geometry( *, export_dir, - config="./config/dd02-ov.json", + config="finger", map_cache=DEFAULT_FINGER_MAP_CACHE, source_scale=None, calib=None, @@ -223,7 +223,7 @@ class FingerRuntime: cls, *, export_dir=DEFAULT_FINGER_EXPORT_DIR, - config="./config/dd02-ov.json", + config="finger", map_cache=DEFAULT_FINGER_MAP_CACHE, source_scale=None, calib=None, diff --git a/src/orisys/sensor.py b/src/orisys/sensor.py index 89cdddc..a8dc898 100644 --- a/src/orisys/sensor.py +++ b/src/orisys/sensor.py @@ -23,6 +23,7 @@ from . import configs from .get_depth import Contact3D from .backend_availability import (check_cupy_available, check_opencl_available) from .frame_processors import create_frame_processor +from .signal_processing import DEFAULT_SAMPLE_RATE_HZ, LowpassFilter class Sensor: """ @@ -46,6 +47,9 @@ class Sensor: FNORMAL = auto() FSHEARX = auto() FSHEARY = auto() + FNORMAL_RAW = auto() + FSHEARX_RAW = auto() + FSHEARY_RAW = auto() CENTROID = auto() CONTOUR = auto() DEPTH = auto() @@ -126,6 +130,17 @@ class Sensor: self.vshear = self.vtemplate self.n_boxes = None 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) self.centroid = (0, 0) @@ -397,7 +412,7 @@ class Sensor: motion_value = mag.mean() 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. @@ -529,7 +544,10 @@ class Sensor: self._hhd_grid_shape = ds_shape _, 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() vshear_ds = self.__hhd.get_shear() @@ -631,6 +649,9 @@ class Sensor: self.info.FNORMAL: lambda: self.fnormal, self.info.FSHEARX: lambda: self.fshearx, 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.TIMESTAMP: lambda: time.strftime('%H:%M:%S'), # 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)) def reset(self): - """Reset optical flow tracker.""" + """Reset optical flow tracker and force-filter state.""" self.__tracker.reset() + self._force_filter.reset() if self.img is not None: self.initial_img = self.img.copy() @@ -679,7 +701,88 @@ class Sensor: # ========================================================================= # 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"): # """Return current configuration dict.""" # configs.export_config(self._config, path) diff --git a/src/orisys/signal_processing.py b/src/orisys/signal_processing.py new file mode 100644 index 0000000..4bfaa13 --- /dev/null +++ b/src/orisys/signal_processing.py @@ -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//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) diff --git a/tests/dd06.py b/tests/dd06.py index 96bb68a..3774055 100644 --- a/tests/dd06.py +++ b/tests/dd06.py @@ -8,7 +8,7 @@ Flow: 4. Feed the rectified frame into Sensor.get_img(..., already_processed=True) 5. Compute deformation/contact and visualize -SDK version: 0.4.2 +SDK version: 0.4.3 """ from __future__ import annotations diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index 29a83a2..124844f 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -35,7 +35,7 @@ def test_pyproject_dynamic_version(packaging_utils): sys.path.insert(0, str(build_dir)) 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): diff --git a/tests/test_qt_viewer_backend.py b/tests/test_qt_viewer_backend.py index df101d5..3399bb9 100644 --- a/tests/test_qt_viewer_backend.py +++ b/tests/test_qt_viewer_backend.py @@ -27,6 +27,7 @@ from gui.sensors import ( # noqa: E402 _snapshot_from_sensor, calibrate_normal_force, ) +from orisys.signal_processing import calibrate_normal_force as sdk_calibrate_normal_force 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_y == pytest.approx(50.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)) diff --git a/tests/test_signal_processing.py b/tests/test_signal_processing.py new file mode 100644 index 0000000..0c18e3d --- /dev/null +++ b/tests/test_signal_processing.py @@ -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() diff --git a/tests/test_wheel_build.py b/tests/test_wheel_build.py index 6e405ec..7d0ec63 100644 --- a/tests/test_wheel_build.py +++ b/tests/test_wheel_build.py @@ -19,25 +19,25 @@ def wheel_package(): def test_compliant_manylinux_tags(wheel_package): 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( - 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( - 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): 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( - 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( - Path("orisys-0.4.2-cp310-cp310-win_amd64.whl") + Path("orisys-0.4.3-cp310-cp310-win_amd64.whl") ) diff --git a/tools/downstream_sync_manifest.json b/tools/downstream_sync_manifest.json new file mode 100644 index 0000000..3b50f09 --- /dev/null +++ b/tools/downstream_sync_manifest.json @@ -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')\"" + ] + } + ] +} diff --git a/tools/sync_downstreams.py b/tools/sync_downstreams.py new file mode 100644 index 0000000..c4f78aa --- /dev/null +++ b/tools/sync_downstreams.py @@ -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", "") 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())