Refractor/config package #14

Merged
zhangyijian merged 3 commits from refractor/config_package into dev 2026-08-23 12:25:29 +08:00
83 changed files with 2233 additions and 2174 deletions
+8 -9
View File
@@ -10,7 +10,7 @@ PyPI: [https://pypi.org/project/orisys/](https://pypi.org/project/orisys/)
- `src/orisys/`: installable SDK package - `src/orisys/`: installable SDK package
- `config/`: default and lab/runtime JSON configurations - `config/`: default and lab/runtime JSON configurations
- `examples/`: product demos (`products/ory_box`, `ory_mini`, `ory_finger`) plus shared `gui/`, `pipeline/`, `viz/` - `examples/`: runnable demos (`basic_pipeline.py`, `mini_pipeline.py`, `finger_pipeline*.py`) plus shared `gui/`, `pipeline/`, `viz/`
- `scripts/`: maintenance, calibration, packaging, and demo scripts - `scripts/`: maintenance, calibration, packaging, and demo scripts
- `tools/`: non-package tooling such as patch-generation utilities - `tools/`: non-package tooling such as patch-generation utilities
- `tests/`: automated pytest suite - `tests/`: automated pytest suite
@@ -54,23 +54,22 @@ pytest
ORY-BOX: ORY-BOX:
```bash ```bash
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 python examples/basic_pipeline.py -v 0 -c box
``` ```
ORY-MINI: ORY-MINI:
```bash ```bash
python examples/products/ory_mini/basic_pipeline.py -v 0 -c mini python examples/mini_pipeline.py -v 0 -c mini
``` ```
ORY-FINGER: ORY-FINGER:
```bash ```bash
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 python examples/finger_pipeline.py --camera_id 0 --config finger
``` ```
Compatibility wrappers remain at `examples/basic_pipeline.py` and Run commands from the repository root.
`examples/finger_pipeline*.py`.
## Configuration ## Configuration
@@ -78,7 +77,7 @@ The canonical configuration files now live in the repo-level `config/` folder.
The package loader `orisys.configs` resolves configs from: The package loader `orisys.configs` resolves configs from:
1. an explicit JSON file path 1. an explicit JSON file path
2. a config file stem under `config/` (for example `ddjx01`, `mini`, `dd02`) 2. a config file stem under `config/` (for example `box`, `mini`, `finger`)
3. a product alias such as `dd02` -> `dd02-ov` 3. a product alias such as `dd02` -> `dd02-ov`
4. a backward-compatible JSON `"name"` match, such as `"prototype"` 4. a backward-compatible JSON `"name"` match, such as `"prototype"`
@@ -87,8 +86,8 @@ Examples:
```python ```python
import orisys import orisys
sensor = orisys.Sensor(0, config_name="ddjx01") sensor = orisys.Sensor(0, config_name="box")
cfg = orisys.export_config("ddjx01", "./config/exported_config.json") cfg = orisys.export_config("box", "./config/exported_config.json")
``` ```
## Examples and Tools ## Examples and Tools
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

+12 -3
View File
@@ -3,13 +3,22 @@
from __future__ import annotations from __future__ import annotations
import ast import ast
import importlib.util
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[1] _BUILD_ROOT = Path(__file__).resolve().parents[1]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
def _load_wheel_layout_module():
layout_path = _BUILD_ROOT / "wheel" / "layout.py"
spec = importlib.util.spec_from_file_location("_orisys_build_wheel_layout", layout_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load wheel layout helper from {layout_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _load_toml(text: str) -> dict: def _load_toml(text: str) -> dict:
@@ -94,7 +103,7 @@ def bundle_items(repo_root: Path) -> list[dict]:
def bundle_data_files(repo_root: Path) -> list[tuple[str, list[str]]]: def bundle_data_files(repo_root: Path) -> list[tuple[str, list[str]]]:
"""Map bundle items marked dev_install=true to setuptools data_files entries.""" """Map bundle items marked dev_install=true to setuptools data_files entries."""
from wheel.layout import resolve_bundle_source_files resolve_bundle_source_files = _load_wheel_layout_module().resolve_bundle_source_files
grouped: dict[str, list[str]] = {} grouped: dict[str, list[str]] = {}
for item in bundle_items(repo_root): for item in bundle_items(repo_root):
+1 -1
View File
@@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path:
from common.paths import REPO_ROOT from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "products" / "ory_box" / "basic_pipeline.py" ENTRY_SCRIPT = REPO_ROOT / "examples" / "basic_pipeline.py"
EXE_NAME = "OryBox.exe" EXE_NAME = "OryBox.exe"
DIST_NAME = "OryBox" DIST_NAME = "OryBox"
+1 -1
View File
@@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path:
from common.paths import REPO_ROOT from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "products" / "ory_finger" / "finger_pipeline_3dview.py" ENTRY_SCRIPT = REPO_ROOT / "examples" / "finger_pipeline_3dview.py"
EXE_NAME = "OrisysDashboard.exe" EXE_NAME = "OrisysDashboard.exe"
DIST_NAME = "OrisysDashboard" DIST_NAME = "OrisysDashboard"
+1 -1
View File
@@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path:
from common.paths import REPO_ROOT from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "products" / "ory_mini" / "basic_pipeline.py" ENTRY_SCRIPT = REPO_ROOT / "examples" / "mini_pipeline.py"
EXE_NAME = "OryMini.exe" EXE_NAME = "OryMini.exe"
DIST_NAME = "OryMini" DIST_NAME = "OryMini"
+5 -1
View File
@@ -33,7 +33,11 @@ from common.paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repair
from common.pyproject_utils import bundle_items, extras_require, get_version, install_requires from common.pyproject_utils import bundle_items, extras_require, get_version, install_requires
from common.verify.wheel import verify_built_wheel from common.verify.wheel import verify_built_wheel
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
from wheel.layout import copy_bundle_items, copy_orisys_blend, package_data_from_bundle from wheel.layout import (
copy_bundle_items,
copy_orisys_blend,
package_data_from_bundle,
)
from wheel.package import ( from wheel.package import (
is_compliant_manylinux_wheel, is_compliant_manylinux_wheel,
publish_wheel_to_sdk, publish_wheel_to_sdk,
+35
View File
@@ -5,6 +5,14 @@ from __future__ import annotations
import shutil import shutil
from pathlib import Path from pathlib import Path
# Required files that must ship with the wheel for ORY-FINGER runtime/GUI.
REQUIRED_ASSET_RELPATHS = (
"finger/assets/30fps-0703/mesh.npz",
"finger/assets/30fps-0703/camera_intrinsics.npz",
"finger/assets/30fps-0703/world_to_cam.npy",
"finger/assets/visualization/world_to_cam.npy",
)
def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]: def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]:
"""Resolve explicit files and/or glob patterns for one bundle item.""" """Resolve explicit files and/or glob patterns for one bundle item."""
@@ -37,6 +45,32 @@ def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]:
return resolved return resolved
def verify_required_assets(repo_root: Path, items: list[dict]) -> None:
"""Fail early when required finger assets are missing from the bundle."""
asset_items = [item for item in items if item.get("name") == "sensors"]
if not asset_items:
return
resolved: set[str] = set()
for item in asset_items:
src_dir = (repo_root / item["source"]).resolve()
for path in resolve_bundle_source_files(repo_root, item):
try:
rel = path.resolve().relative_to(src_dir).as_posix()
except ValueError:
continue
resolved.add(rel)
missing = [rel for rel in REQUIRED_ASSET_RELPATHS if rel not in resolved]
if missing:
joined = ", ".join(missing)
raise FileNotFoundError(
"Required finger assets are missing from the wheel bundle. "
f"Missing: {joined}. Update [[tool.orisys.bundle.items]] patterns "
"or restore assets under sensors/finger/assets/ before building."
)
def package_data_paths_for_item(item: dict) -> list[str]: def package_data_paths_for_item(item: dict) -> list[str]:
"""Return setuptools package_data paths relative to the orisys package.""" """Return setuptools package_data paths relative to the orisys package."""
target = str(item["target"]).replace("\\", "/") target = str(item["target"]).replace("\\", "/")
@@ -54,6 +88,7 @@ def data_file_entries_for_item(item: dict) -> list[str]:
def copy_bundle_items(repo_root: Path, pkg_dir: Path, items: list[dict]) -> dict[str, int]: def copy_bundle_items(repo_root: Path, pkg_dir: Path, items: list[dict]) -> dict[str, int]:
verify_required_assets(repo_root, items)
copied_counts: dict[str, int] = {} copied_counts: dict[str, int] = {}
for item in items: for item in items:
name = item["name"] name = item["name"]
+101
View File
@@ -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"
}
}
}
+101
View File
@@ -0,0 +1,101 @@
{
"name": "dd06-ov",
"type": "single_camera",
"version": "1.0",
"camera": {
"resolution_raw": [
1280,
720
],
"fps": 30,
"resolution_stitch": [
195,
275
],
"source_scale": 0.4,
"old": [
672,
728,
1,
0.5
],
"work": [
504,
546,
1
],
"work2": [
420,
450,
0.8
],
"work3": [
350,
375,
4,
0.7
],
"work4": [
280,
300,
4,
0.4,
0.2
]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": [
"raft",
"dis"
],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 0.2,
"div_threshold": 0.022,
"expansion_threshold": 0.01,
"kernel_size": [
5,
5
],
"denoise_sigma": 12
},
"roi": {
"type": "polygon",
"image_size": [
1280,
720
],
"points": [
[
628,
532
],
[
1145,
694
],
[
1161,
8
],
[
630,
126
]
],
"created_at": "2026-08-10T14:26:31",
"mask_path": "dd06-ov-0810_mask.png",
"metadata": {
"source": ".\\data\\dd06\\2026-08-10-142316.webm"
}
}
}
+2 -2
View File
@@ -5,7 +5,7 @@
"camera": { "camera": {
"resolution_raw": [1280, 720], "resolution_raw": [1280, 720],
"fps": 30, "fps": 30,
"resolution_stitch": [420, 450], "resolution_stitch": [280, 300],
"source_scale": 0.4, "source_scale": 0.4,
"old":[672, 728, 1,0.5], "old":[672, 728, 1,0.5],
"work": [504, 546,1], "work": [504, 546,1],
@@ -20,7 +20,7 @@
"loop_closure_interval": 1, "loop_closure_interval": 1,
"algorithm": "dis", "algorithm": "dis",
"dis_preset": "medium", "dis_preset": "medium",
"dis_mode": 3, "dis_mode": 1,
"algorithm_list": ["raft", "dis"], "algorithm_list": ["raft", "dis"],
"hhd_downsample": 5 "hhd_downsample": 5
}, },
BIN
View File
Binary file not shown.
+187 -151
View File
@@ -1,77 +1,30 @@
# orisys API 文档 ---
title: API Reference
description: Orisys SDK Python API 中文参考
---
本文件提供了 `orisys` 套件的中文文档 # API Reference
## 准备 Python 开发环境 本文件提供 `orisys` 套件的 Python API 中文参考。安装、CUDA、CuPy 与运行示例的基础步骤请参考 [快速开始](./get_started.md)。
推荐下载 Anaconda,并使用Python 3.10
```
# 创建并激活虚拟环境
conda create -n orisys python=3.10
conda activate orisys
```
## 安装 CUDA Toolkit 和 cuDNN
在官方链接下载和安装:https://developer.nvidia.com/cuda/toolkit
或
在Anaconda环境中安装(例子使用cuda12.9):
https://anaconda.org/channels/nvidia/packages/cuda/overview
```
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
```
## SDK安装方法
1. 根据系统(win / linux),平台(amd64, aarch64), python(如p38、p39、p310) 版本下载对应的`.whl`文件
2. 运行pip命令安装:
```bash
pip install orisys-{version}-{python-version}-{python-version}-{os}_{machine}.whl
```
如
```bash
pip install orisys-0.4.2-cp310-cp310-win_amd64.whl
```
3. **安装 CuPy(根据您的 CUDA 版本选择):** ## Sensor API
**方法 1:使用 extras(推荐)** ### `Sensor` 类别
```bash #### 描述
# 对于 CUDA 12.x
pip install orisys[cuda12]
# 对于 CUDA 13.x
pip install orisys[cuda13]
```
**方法 2:手动安装**
```bash
# 对于 CUDA 12.x
pip install cupy-cuda12x>=12.3.0
# 对于 CUDA 13.x
pip install cupy-cuda13x>=13.0.0
```
4. 测试导入:
```python
import orisys
print(orisys.__version__)
```
***
## `Sensor` 类别
### 描述
`orisys`套件的核心类,用于从视频文件或实时摄像头中读取传感器图像,创建一个具体的传感器对象(实例)。并完成形变、光流及力信息的计算与管理。 `orisys`套件的核心类,用于从视频文件或实时摄像头中读取传感器图像,创建一个具体的传感器对象(实例)。并完成形变、光流及力信息的计算与管理。
### 输入参数 #### 输入参数
* **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。 * **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。
* **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `ddjx01` 或 `./config/ddjx01.json`。 * **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `box` 或 `./config/box.json`。
* **cal_path** (`str`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。 * **cal_path** (`str`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。
* **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。 * **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。
* **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。 * **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。
### 返回 #### 返回
* `Sensor` 实例,用于后续图像采集、形变计算和数据读取操作。 * `Sensor` 实例,用于后续图像采集、形变计算和数据读取操作。
### 示例 #### 示例
```python ```python
# Example 1: 用相机编号创建 # Example 1: 用相机编号创建
@@ -82,63 +35,62 @@ sensor = Sensor(0)
sensor = Sensor("/data/data.mp4") sensor = Sensor("/data/data.mp4")
``` ```
*** ### `Sensor.get_img` 方法
## 2. `Sensor.get_img` 方法 #### 描述
### 描述
采集并预处理一帧图像并存到`Sensor` 类别中。 采集并预处理一帧图像并存到`Sensor` 类别中。
### 返回 #### 返回
* `bool`:是否成功读取图像。 * `bool`:是否成功读取图像。
### 状态更新 #### 状态更新
* 成功时更新`sensor`类里面的`sensor.img`。处理后的BGR格式图像(`uint8`,尺寸为json文件中`resolution_stitch`对应分辨率)。 * 成功时更新`sensor`类里面的`sensor.img`。处理后的BGR格式图像(`uint8`,尺寸为json文件中`resolution_stitch`对应分辨率)。
### 示例 #### 示例
```python ```python
sensor.get_img() sensor.get_img()
``` ```
*** ### `Sensor.compute_deformation` 方法
## 3. `Sensor.compute_deformation` 方法 #### 描述
### 描述
基于当前帧图像计算光流/形变向量场,并通过 HHD 分解将形变场分解为法向/切向分量,同时估计法向力、切向力与扭矩等标量输出。该方法会更新 基于当前帧图像计算光流/形变向量场,并通过 HHD 分解将形变场分解为法向/切向分量,同时估计法向力、切向力与扭矩等标量输出。该方法会更新
`sensor`的形变场与力相关状态,并返回当前帧的光流场。 `sensor`的形变场与力相关状态,并返回当前帧的光流场。
调用前置条件:需要先通过`get_img()`成功更新图像。 调用前置条件:需要先通过`get_img()`成功更新图像。
### 输入参数 #### 输入参数
* **check_motion**(`bool`,可选): 为 `True` 时先根据光流判断是否有运动,有运动才做 HHD 分解;为 `False` 时每帧都做 HHD。默认 `True`。 * **decompose** (`bool`,可选): 是否在计算光流后继续执行 HHD 分解,默认 `True`。
* **threshold**(`float`,可选): **check_motion** 功能的阈值,默认为`6`。 * **check_motion** (`bool`,可选): 是否启用运动检测门控。当前不推荐依赖该功能;新代码建议显式传入 `False`,让每帧都执行 HHD 分解。为兼容旧代码,函数默认值仍为 `True`。
* **tail_frames** (`int`,可选): 光流变化低于阈值后继续解算多少帧,确保传感器回复,,默认为 `10`.
### 返回 #### 兼容参数(暂不推荐)
* `bool`:是否接触到物体,如果**check_motion**为`False`时返回`None`。 以下参数只在 `check_motion=True` 时生效。由于运动检测门控目前不稳定,建议在新代码中暂时不要使用:
### 示例 * **threshold** (`float`,可选): 运动检测阈值,默认 `6`。
* **tail_frames** (`int`,可选): 运动低于阈值后继续解算多少帧,默认 `10`。
#### 返回
* `None`: 当 `check_motion=False` 时返回。推荐用法下不要依赖该返回值判断接触状态。
* `bool`: 仅当 `check_motion=True` 时返回运动检测结果;该模式目前暂不推荐。
#### 示例
```python ```python
sensor.get_img() sensor.get_img()
sensor.compute_deformation(check_motion=False)
is_touched = sensor.compute_deformation(check_motion=True, threshold=6) sensor.compute_contact()
# 步骤 2.3: 计算触碰点
if is_touched:
sensor.compute_contact()
``` ```
*** ### `Sensor.compute_contact` 方法
## 4. `Sensor.compute_contact` 方法
### 描述 #### 描述
基于已计算的法向形变场进行接触区域分析,输出接触质心与轮廓等信息,并生成接触深度图,默认配置在`json`文件中,可按需调节。 基于已计算的法向形变场进行接触区域分析,输出接触质心与轮廓等信息,并生成接触深度图,默认配置在`json`文件中,可按需调节。
调用前置条件:需先执行`compute_deformation()`。 调用前置条件:需先执行`compute_deformation()`。
### 返回 #### 返回
`dict`为接触分析结果字典,至少包含以下键: `dict`为接触分析结果字典,至少包含以下键:
* **expansion_mask**: 扩张区域掩码(`np.ndarray`)。 * **expansion_mask**: 扩张区域掩码(`np.ndarray`)。
@@ -148,12 +100,12 @@ if is_touched:
* **centroid_found**: 是否检测到接触区域(`bool`)。 * **centroid_found**: 是否检测到接触区域(`bool`)。
* **contour**: 最大轮廓或轮廓列表(可选字段,存在时返回)。 * **contour**: 最大轮廓或轮廓列表(可选字段,存在时返回)。
### 状态更新 #### 状态更新
* **sensor.centroid**: 接触质心(单点为 `(x, y)`,多点为列表)。 * **sensor.centroid**: 接触质心(单点为 `(x, y)`,多点为列表)。
* **sensor.centroid_found**: 是否找到质心(`bool`)。 * **sensor.centroid_found**: 是否找到质心(`bool`)。
### 示例 #### 示例
```python ```python
sensor.compute_contact() sensor.compute_contact()
centroid, contour, depth = sensor.read_info( centroid, contour, depth = sensor.read_info(
@@ -162,19 +114,18 @@ centroid, contour, depth = sensor.read_info(
sensor.info.DEPTH, sensor.info.DEPTH,
) )
``` ```
*** ### `Sensor.read_info` 方法
## 4. `Sensor.read_info` 方法
### 描述 #### 描述
按指定的信息类型(`sensor.info`枚举值)批量读取传感器状态数据,并按传入顺序返回对应值的元组。 按指定的信息类型(`sensor.info`枚举值)批量读取传感器状态数据,并按传入顺序返回对应值的元组。
该方法通常用于在完成形变计算后,读取光流场、分量场、力、帧率等信息。 该方法通常用于在完成形变计算后,读取光流场、分量场、力、帧率等信息。
### 输入参数 #### 输入参数
* **args**: 任意数量的 `Sensor.info` 枚举,用于指定需要获取的数据类型。支持的枚举值及对应数据如下: * **args**: 任意数量的 `Sensor.info` 枚举,用于指定需要获取的数据类型。支持的枚举值及对应数据如下:
### 支持的枚 info keys: #### 支持的 info keys
向量/图像类 向量/图像类
@@ -188,6 +139,9 @@ centroid, contour, depth = sensor.read_info(
* FNORMAL: 法向力,`float`。 * FNORMAL: 法向力,`float`。
* FSHEARX: X切向力,`float`。 * FSHEARX: X切向力,`float`。
* FSHEARY: Y切向力,`float`。 * FSHEARY: Y切向力,`float`。
* FNORMAL_RAW: 未经过低通滤波的原始法向力,`float`。
* FSHEARX_RAW: 未经过低通滤波的原始 X 切向力,`float`。
* FSHEARY_RAW: 未经过低通滤波的原始 Y 切向力,`float`。
* FPS: 输出帧率,`float`。 * FPS: 输出帧率,`float`。
* CENTROID: 触碰点的坐标,(x, y)。 * CENTROID: 触碰点的坐标,(x, y)。
* CONTOUR: 触碰区域轮廓,`np.ndarray`, shape=(H, W), 未生成时返回`np.empty` * CONTOUR: 触碰区域轮廓,`np.ndarray`, shape=(H, W), 未生成时返回`np.empty`
@@ -198,7 +152,7 @@ centroid, contour, depth = sensor.read_info(
注:H/W 由当前`json`文件中的`resolution_stitch`配置分辨率决定。 注:H/W 由当前`json`文件中的`resolution_stitch`配置分辨率决定。
### 返回 #### 返回
`Tuple` `Tuple`
@@ -210,7 +164,7 @@ centroid, contour, depth = sensor.read_info(
- 标量类数据:`float` - 标量类数据:`float`
- 时间戳:`str`(`TIMESTAMP`,格式为 `HH:MM:SS`) - 时间戳:`str`(`TIMESTAMP`,格式为 `HH:MM:SS`)
### 示例 #### 示例
```python ```python
# Example 1:读取标量数据 # Example 1:读取标量数据
fps, fn, fx, fy = sensor.read_info( fps, fn, fx, fy = sensor.read_info(
@@ -223,22 +177,114 @@ fps, fn, fx, fy = sensor.read_info(
# Example 2: 读取单个向量场(注意解包方式) # Example 2: 读取单个向量场(注意解包方式)
flow = sensor.read_info(sensor.info.VRAW) flow = sensor.read_info(sensor.info.VRAW)
``` ```
*** ### `Sensor.disconnect` 方法
## 5. `Sensor.disconnect` 方法 #### 描述
### 描述
释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 OpenCV 窗口。 释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 OpenCV 窗口。
建议在程序退出前调用以确保资源被正确释放。 建议在程序退出前调用以确保资源被正确释放。
*** ### `Sensor.configure` 低通滤波设置
***
## `orisys.finger` 手指 UV pipeline #### 描述
`examples/products/ory_finger/finger_pipeline.py` 展示了一个更适合手指/单相机场景的高层 API: 运行时更新传感器设置,无需重新连接相机。常用于开启力信号低通滤波,降低 `FNORMAL`、`FSHEARX`、`FSHEARY` 的高频噪声。
默认采样率按 30 fps 计算,推荐低通截止频率为 5 Hz:
```python
sensor.configure(lowpass_cutoff_hz=5.0, lowpass_sample_rate_hz=30.0)
```
注意:`lowpass_cutoff_hz` 必须小于 Nyquist 频率,即 `lowpass_sample_rate_hz / 2`。例如 30 fps 时,截止频率必须小于 15 Hz。
#### 输入参数
* **lowpass_cutoff_hz** (`float`,可选): 力信号低通截止频率,单位 Hz。`0` 表示关闭滤波;正数默认开启滤波。
* **lowpass_sample_rate_hz** (`float`,可选): 力信号采样率,默认 `30.0`。
* **lowpass_order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
* **lowpass_enabled** (`bool`,可选): 显式开启或关闭低通滤波。
* **hhd_downsample** (`int`,可选): HHD 解算下采样倍数,需为 `>= 1` 的整数,配置默认 `3`。
#### 读取结果
开启低通滤波后,`read_info()` 读取的力输出会被滤波:
```python
fn, fx, fy = sensor.read_info(
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
)
```
原始力输出仍可通过 `FNORMAL_RAW`、`FSHEARX_RAW`、`FSHEARY_RAW` 读取,便于调试或对比。
## Signal Processing API
### `orisys.signal_processing.lowpass_filter` 函数
#### 描述
对一维、等间隔采样数据执行零相位 Butterworth 低通滤波,适合示例程序或图表中对历史数据做平滑处理。
默认参数为 `cutoff_hz=5.0`、`sample_rate_hz=30.0`、`order=2`。这表示在 30 fps 数据上使用 5 Hz 低通滤波。
#### 输入参数
* **data**: 一维数据序列。
* **cutoff_hz** (`float`,可选): 截止频率,默认 `5.0`。传入 `0` 时不做滤波并返回数据副本。
* **sample_rate_hz** (`float`,可选): 采样率,默认 `30.0`。
* **order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
#### 返回
* `np.ndarray`: 滤波后的 `float64` 数组。
#### 示例
```python
from orisys.signal_processing import lowpass_filter
filtered_force = lowpass_filter(force_values, cutoff_hz=5.0, sample_rate_hz=30.0)
```
### `orisys.signal_processing.LowpassFilter` 类别
#### 描述
有状态的因果 Butterworth 低通滤波器,适合逐帧处理实时数据。它会保留上一帧的滤波状态,并通过 `process()` 一次处理一个多通道样本。
`Sensor.configure()` 内部使用该类来滤波力信号。构造时默认 `cutoff_hz=0.0`,表示关闭滤波;若要按 30 fps 使用推荐滤波,设置 `cutoff_hz=5.0`、`sample_rate_hz=30.0`。
#### 输入参数
* **cutoff_hz** (`float`,可选): 截止频率,默认 `0.0`,表示关闭滤波。
* **sample_rate_hz** (`float`,可选): 采样率,默认 `30.0`。
* **order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
* **n_channels** (`int`,可选): 每个样本的通道数,默认 `3`。
#### 常用方法
* **configure(...)**: 运行时更新 `cutoff_hz`、`sample_rate_hz` 或 `order`。
* **reset(x0=None)**: 清除滤波器状态;传入 `x0` 时会用该样本初始化稳态。
* **process(values)**: 滤波一个多通道样本,返回 shape 为 `(n_channels,)` 的 `np.ndarray`。
#### 示例
```python
from orisys.signal_processing import LowpassFilter
filt = LowpassFilter(cutoff_hz=5.0, sample_rate_hz=30.0, n_channels=3)
filtered = filt.process([fnormal, fshearx, fsheary])
```
## Finger Pipeline API
### `orisys.finger` 手指 UV pipeline
`examples/finger_pipeline.py` 展示了一个更适合手指/单相机场景的高层 API:
1. 使用 `FingerRuntime.open()` 打开视频源、加载 Blender 导出的资产,并构建 UV 展开映射。 1. 使用 `FingerRuntime.open()` 打开视频源、加载 Blender 导出的资产,并构建 UV 展开映射。
2. 用 `grab_and_process()` 或 `read_source() + process_frame()` 获取一帧展开后的触觉图像。 2. 用 `grab_and_process()` 或 `read_source() + process_frame()` 获取一帧展开后的触觉图像。
@@ -247,7 +293,7 @@ flow = sensor.read_info(sensor.info.VRAW)
相比直接使用 `Sensor`,`FingerRuntime` 已封装了视频读取、相机尺寸设置、UV 展开和有效区域掩码传递,更适合作为手指 2D/3D 示例与上层应用的入口。 相比直接使用 `Sensor`,`FingerRuntime` 已封装了视频读取、相机尺寸设置、UV 展开和有效区域掩码传递,更适合作为手指 2D/3D 示例与上层应用的入口。
### 1. `FingerRuntime.open` 方法 ### `FingerRuntime.open` 方法
#### 描述 #### 描述
@@ -262,7 +308,7 @@ flow = sensor.read_info(sensor.info.VRAW)
#### 输入参数 #### 输入参数
* **export_dir** (`str`,可选): 手指导出资产目录,默认值来自 `orisys.finger.DEFAULT_FINGER_EXPORT_DIR`。目录下通常需要包含 `mesh.npz`、`camera_intrinsics.npz`、`world_to_cam.npy`。 * **export_dir** (`str`,可选): 手指导出资产目录,默认值来自 `orisys.finger.DEFAULT_FINGER_EXPORT_DIR`。目录下通常需要包含 `mesh.npz`、`camera_intrinsics.npz`、`world_to_cam.npy`。
* **config** (`str`,可选): Orisys 配置文件名称或路径,默认 `dd02`(兼容解析到 `dd02-ov`)。 * **config** (`str`,可选): Orisys 配置文件名称或路径,默认 `finger`。
* **map_cache** (`str`,可选): UV 展开缓存文件路径,默认值来自 `orisys.finger.DEFAULT_FINGER_MAP_CACHE`。 * **map_cache** (`str`,可选): UV 展开缓存文件路径,默认值来自 `orisys.finger.DEFAULT_FINGER_MAP_CACHE`。
* **source_scale** (`float`,可选): 输入相机分辨率缩放比例;未提供时根据配置自动推断。 * **source_scale** (`float`,可选): 输入相机分辨率缩放比例;未提供时根据配置自动推断。
* **calib** (`str | None`,可选): 外部相机标定文件路径;未提供时优先使用导出资产中的内参。 * **calib** (`str | None`,可选): 外部相机标定文件路径;未提供时优先使用导出资产中的内参。
@@ -282,13 +328,13 @@ flow = sensor.read_info(sensor.info.VRAW)
from orisys.finger import FingerRuntime from orisys.finger import FingerRuntime
runtime = FingerRuntime.open( runtime = FingerRuntime.open(
config="dd02", config="finger",
video=0, video=0,
verbose=True, verbose=True,
) )
``` ```
### 2. `FingerRuntime` 常用属性 ### `FingerRuntime` 常用属性
* **runtime.geometry**: `FingerGeometry`,包含输入/输出分辨率、导出资产路径、相机内参、缓存路径等一次性初始化结果。 * **runtime.geometry**: `FingerGeometry`,包含输入/输出分辨率、导出资产路径、相机内参、缓存路径等一次性初始化结果。
* **runtime.domain**: UV 展开域对象,内部包含 remap、valid mask 以及可选 surface 数据。 * **runtime.domain**: UV 展开域对象,内部包含 remap、valid mask 以及可选 surface 数据。
@@ -296,7 +342,7 @@ runtime = FingerRuntime.open(
* **runtime.capture**: OpenCV `VideoCapture` 对象。 * **runtime.capture**: OpenCV `VideoCapture` 对象。
* **runtime.valid_mask**: 当前 UV 域有效像素掩码。 * **runtime.valid_mask**: 当前 UV 域有效像素掩码。
### 3. `FingerRuntime.read_source` 方法 ### `FingerRuntime.read_source` 方法
#### 描述 #### 描述
@@ -308,11 +354,11 @@ runtime = FingerRuntime.open(
* **ok** (`bool`): 是否成功读取。 * **ok** (`bool`): 是否成功读取。
* **frame** (`np.ndarray | None`): 原始 BGR 帧。 * **frame** (`np.ndarray | None`): 原始 BGR 帧。
### 4. `FingerRuntime.process_frame` 方法 ### `FingerRuntime.process_frame` 方法
#### 描述 #### 描述
对一帧原始输入执行源图预处理、UV 展开,并把展开后的结果送入内部 `TactileProcessor`。这是 `examples/products/ory_finger/finger_pipeline.py` 中 profile 模式使用的方法。 对一帧原始输入执行源图预处理、UV 展开,并把展开后的结果送入内部 `TactileProcessor`。这是 `examples/finger_pipeline.py` 中 profile 模式使用的方法。
#### 输入参数 #### 输入参数
@@ -323,11 +369,11 @@ runtime = FingerRuntime.open(
* `np.ndarray`: 展开后的触觉图像。`color=False` 时通常为单通道灰度图;`color=True` 时为 BGR 图。 * `np.ndarray`: 展开后的触觉图像。`color=False` 时通常为单通道灰度图;`color=True` 时为 BGR 图。
### 5. `FingerRuntime.grab_and_process` 方法 ### `FingerRuntime.grab_and_process` 方法
#### 描述 #### 描述
先读取一帧视频源,再调用 `process_frame()` 完成 UV 展开并推入内部触觉处理器。非 profile 模式下,`examples/products/ory_finger/finger_pipeline.py` 使用该方法作为主循环入口。 先读取一帧视频源,再调用 `process_frame()` 完成 UV 展开并推入内部触觉处理器。非 profile 模式下,`examples/finger_pipeline.py` 使用该方法作为主循环入口。
#### 输入参数 #### 输入参数
@@ -339,7 +385,7 @@ runtime = FingerRuntime.open(
* **ok** (`bool`): 是否成功读取并处理。 * **ok** (`bool`): 是否成功读取并处理。
* **unrolled** (`np.ndarray | None`): 展开后的触觉图像。 * **unrolled** (`np.ndarray | None`): 展开后的触觉图像。
### 6. `FingerRuntime.get_img` 方法 ### `FingerRuntime.get_img` 方法
#### 描述 #### 描述
@@ -353,11 +399,11 @@ runtime = FingerRuntime.open(
* `np.ndarray | None` * `np.ndarray | None`
### 7. `FingerRuntime.warmup` 方法 ### `FingerRuntime.warmup` 方法
#### 描述 #### 描述
先执行一次采集与 UV 展开,然后调用 `runtime.tactile.warmup()` 预热内部光流/HHD 相关缓存。`examples/products/ory_finger/finger_pipeline.py` 在进入主循环前会先执行一次 warmup。 先执行一次采集与 UV 展开,然后调用 `runtime.tactile.warmup()` 预热内部光流/HHD 相关缓存。`examples/finger_pipeline.py` 在进入主循环前会先执行一次 warmup。
#### 输入参数 #### 输入参数
@@ -369,13 +415,13 @@ runtime = FingerRuntime.open(
* **ok** (`bool`): 是否成功完成 warmup 前的采集。 * **ok** (`bool`): 是否成功完成 warmup 前的采集。
* **unrolled** (`np.ndarray | None`): warmup 时得到的展开图。 * **unrolled** (`np.ndarray | None`): warmup 时得到的展开图。
### 8. `FingerRuntime.close` 方法 ### `FingerRuntime.close` 方法
#### 描述 #### 描述
释放内部 `VideoCapture` 并调用 `runtime.tactile.disconnect()` 清理 `Sensor` 资源。程序退出前应调用。 释放内部 `VideoCapture` 并调用 `runtime.tactile.disconnect()` 清理 `Sensor` 资源。程序退出前应调用。
### 9. `TactileProcessor` 类别 ### `TactileProcessor` 类别
#### 描述 #### 描述
@@ -386,16 +432,16 @@ runtime = FingerRuntime.open(
* **raw_sensor**: 底层 `Sensor` 对象。 * **raw_sensor**: 底层 `Sensor` 对象。
* **info**: 与 `Sensor.info` 相同的枚举入口。 * **info**: 与 `Sensor.info` 相同的枚举入口。
* **img / flow / vnormal / vshear / depth_map**: 当前帧处理结果。 * **img / flow / vnormal / vshear / depth_map**: 当前帧处理结果。
* **mask_stats**: 有效 UV 区域统计信息;`examples/products/ory_finger/finger_pipeline.py` 会读取 `mask_stats["coverage"]` 输出当前掩码覆盖率。 * **mask_stats**: 有效 UV 区域统计信息;`examples/finger_pipeline.py` 会读取 `mask_stats["coverage"]` 输出当前掩码覆盖率。
* **valid_mask**: 当前有效区域掩码。 * **valid_mask**: 当前有效区域掩码。
#### 说明 #### 说明
除 `push_frame()` / `warmup()` 之外,`TactileProcessor` 的 `compute_deformation()`、`compute_contact()`、`read_info()`、`reset()` 调用方式与 `Sensor` 基本一致,因此可直接复用前文 `Sensor` 章节中的参数说明。 除 `push_frame()` / `warmup()` 之外,`TactileProcessor` 的 `compute_deformation()`、`compute_contact()`、`read_info()`、`reset()` 调用方式与 `Sensor` 基本一致,因此可直接复用前文 `Sensor` 章节中的参数说明。
### 10. 最小手指 pipeline 示例 ### 最小手指 pipeline 示例
下面的流程与 `examples/products/ory_finger/finger_pipeline.py` 一致: 下面的流程与 `examples/finger_pipeline.py` 一致:
```python ```python
import time import time
@@ -404,7 +450,7 @@ import orisys
from orisys.finger import FingerRuntime from orisys.finger import FingerRuntime
runtime = FingerRuntime.open( runtime = FingerRuntime.open(
config="dd02", config="finger",
video=0, video=0,
verbose=True, verbose=True,
) )
@@ -460,9 +506,9 @@ finally:
cv2.destroyAllWindows() cv2.destroyAllWindows()
``` ```
### 11. 关于 profile 模式 ### 关于 profile 模式
`examples/products/ory_finger/finger_pipeline.py` 中的 `--profile` / `--profile-every` 是示例脚本参数,不属于 `orisys` SDK 核心 API。本质上它只是把主循环拆成: `examples/finger_pipeline.py` 中的 `--profile` / `--profile-every` 是示例脚本参数,不属于 `orisys` SDK 核心 API。本质上它只是把主循环拆成:
1. `runtime.read_source()` 1. `runtime.read_source()`
2. `runtime.process_frame(...)` 2. `runtime.process_frame(...)`
@@ -472,11 +518,11 @@ finally:
并对各阶段做耗时统计。因此在业务代码中,也可以按同样方式自行插入计时逻辑。 并对各阶段做耗时统计。因此在业务代码中,也可以按同样方式自行插入计时逻辑。
# 可视化相关 ## 可视化相关
## 1. `util.draw_arrows` 方法 ### `util.draw_arrows` 方法
### 输入参数 #### 输入参数
* **frame**:背景图像 * **frame**:背景图像
* **optical_flow**:光流场 * **optical_flow**:光流场
@@ -488,12 +534,12 @@ finally:
* **below_threshold_color**:RGB格式的`uint8`颜色,如(0, 0, 255) * **below_threshold_color**:RGB格式的`uint8`颜色,如(0, 0, 255)
* **edge_margin**: 不显示箭头边距(pixel) * **edge_margin**: 不显示箭头边距(pixel)
### 返回 #### 返回
`np.ndarray` :箭头光流可视化图像 `np.ndarray` :箭头光流可视化图像
## 2. `util.draw_contact` 方法 ### `util.draw_contact` 方法
### 输入参数 #### 输入参数
* **image**:背景图像 * **image**:背景图像
* **contour**:轮廓 * **contour**:轮廓
@@ -501,42 +547,36 @@ finally:
* **point_color**:触碰点的颜色 (BGR) * **point_color**:触碰点的颜色 (BGR)
* **region_color**:轮廓的颜色 (BGR) * **region_color**:轮廓的颜色 (BGR)
### 返回 #### 返回
`np.ndarray` :箭头光流可视化图像 `np.ndarray` :箭头光流可视化图像
*** ## 配置标定相关
# 配置标定相关 ### `list_config` 方法
## 1. `list_config` 方法 #### 描述
### 描述
列出内置传感器配置参数字典。 列出内置传感器配置参数字典。
### 返回 #### 返回
* `list`: 内置传感器配置参数字典的名字列表。 * `list`: 内置传感器配置参数字典的名字列表。
*** ### `export_config` 方法
## 2. `export_config` 方法
### 描述 #### 描述
导出当前传感器实例使用的配置参数字典。 导出当前传感器实例使用的配置参数字典。
### 输入参数 #### 输入参数
* **config_name**: 需要导出的配置参数字典名字,`str` * **config_name**: 需要导出的配置参数字典名字,`str`
* **path**: 导出的路径,默认`"./config/exported_config.json"` * **path**: 导出的路径,默认`"./config/exported_config.json"`
### 返回 #### 返回
* `dict`: 导出的配置参数字典。 * `dict`: 导出的配置参数字典。
### 保存 #### 保存
* 导出内置的`config_name`配置参数字典至`path` * 导出内置的`config_name`配置参数字典至`path`
***
***
## 常见问题解答 (FAQ) ## 常见问题解答 (FAQ)
### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`? ### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`?
A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。 A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。
@@ -556,9 +596,5 @@ A:当需要接触相关信息(如接触质心、轮廓或深度/散度图)
A:`read_info()`按传入的`sensor.info`参数顺序返回一个元组。 A:`read_info()`按传入的`sensor.info`参数顺序返回一个元组。
返回值顺序与参数顺序一一对应,与内部存储顺序无关。 返回值顺序与参数顺序一一对应,与内部存储顺序无关。
### Q5:如果没有标定文件,或拼接结果不正确,会发生什么? ### Q5: 运行报错:“AttributeError: 'Stitcher' object has no attribute 'pos_default'”
A:若未指定或未找到标定文件,系统会在需要时创建新的拼接标定数据并保存到默认路径。
拼接结果异常通常与标定数据、拼接配置或输入图像质量有关,建议重新生成拼接标定并检查配置参数。
### Q6: 运行报错:“AttributeError: 'Stitcher' object has no attribute 'pos_default'”
A: 请检查视频源的编号是否正确,以及是否能正常打开视频源。在linux 使用`v4l2-ctl --list-devices` 或 Windows使用`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }` 列出摄像头设备。 A: 请检查视频源的编号是否正确,以及是否能正常打开视频源。在linux 使用`v4l2-ctl --list-devices` 或 Windows使用`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }` 列出摄像头设备。
BIN
View File
Binary file not shown.
Binary file not shown.
+113 -83
View File
@@ -1,80 +1,106 @@
叠动科技软件使用手册 ---
版本: 0.4.2 title: 快速开始
更新日期: 2026年7月13日 description: 安装并运行 Orisys SDK 的基础步骤
---
# 简介 # 快速开始
版本:0.4.3
## 简介
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。 本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
支持操作系统:Windows、Ubuntu 支持操作系统:Windows、Ubuntu
建议Python 版本: 3.10, 3.11, 3.12 建议Python 版本: 3.10, 3.11, 3.12
# 基础环境设置 项目链接:
## 安装 Python 与 Anaconda
1. 访问 [Anaconda 官网](https://www.anaconda.com/download)下载适合您操作系统的安装包(Windows、Linux)。
2. 按照提示完成安装。
3. 安装完成后,打开命令行终端(Windows 用户可在”Anaconda Navigator”打开“Anaconda Prompt”),输入 `conda info` ,若能显示 conda 的信息,则说明安装成功。
## 创建conda虚拟环境 - PyPI 安装页:[https://pypi.org/project/orisys/](https://pypi.org/project/orisys/)
- 项目文档/代码:[https://github.com/Orisys-Tech/orisys-sdk](https://github.com/Orisys-Tech/orisys-sdk)
## 基础环境设置
### 安装 Python 与 Anaconda
1. 访问 [Anaconda 官网](https://www.anaconda.com/download) 下载适合您操作系统的安装包(Windows、Linux)。
2. 按照提示完成安装。
3. 安装完成后,打开命令行终端(Windows 用户可在 “Anaconda Navigator” 打开 “Anaconda Prompt”),输入 `conda info`,若能显示 conda 的信息,则说明安装成功。
### 创建 conda 虚拟环境
虚拟环境相当于一个独立的 Python 工作空间,可以避免不同项目所需的库版本冲突。 虚拟环境相当于一个独立的 Python 工作空间,可以避免不同项目所需的库版本冲突。
打开命令行终端(或Anaconda Prompt),依次输入以下命令(以 python 3.10 为例): 打开命令行终端(或 Anaconda Prompt),依次输入以下命令(以 Python 3.10 为例):
``` bash ```bash
conda create -n orisys python=3.10 conda create -n orisys python=3.10
conda activate orisys conda activate orisys
``` ```
激活后,命令行提示符前会出现 `(orisys)` 字样,表示当前已进入虚拟环境。 激活后,命令行提示符前会出现 `(orisys)` 字样,表示当前已进入虚拟环境。
## 创建工作区文件夹 ### 创建工作区文件夹
“Anaconda Prompt”启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理,可以手动创建或者运行`mkdir <path>`命令:, 例如 “Anaconda Prompt” 启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理。可以手动创建文件夹,也可以运行 `mkdir <path>` 命令。例如:
``` bash ```bash
mkdir orisys_ws mkdir orisys_ws
``` ```
创建后如果要更换工作区,使用 `cd <path>`命令,例如: 创建后如果要更换工作区,使用 `cd <path>` 命令,例如:
``` bash ```bash
cd "C:\Users\orisys\orisys_ws" cd "C:\Users\orisys\orisys_ws"
``` ```
<div style="page-break-after: always;"></div> ### 安装 CUDA 和 cuDNN(可选)
## 安装 CUDA 和 cuDNN(可选)
如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。 如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。
1. 方法一(官网下载): 1. 方法一(官网下载):
访问 [NVIDIA CUDA Toolkit官网](https://developer.nvidia.com/cuda/toolkit)下载对应系统的 CUDA Toolkit 安装包,并按照提示安装, 注意12.X 和 13.X 的版本对应不同的 CuPy 库版本。 访问 [NVIDIA CUDA Toolkit 官网](https://developer.nvidia.com/cuda/toolkit) 下载对应系统的 CUDA Toolkit 安装包,并按照提示安装。注意 12.x 和 13.x 的版本对应不同的 CuPy 库版本。
2. 方法二(使用 Anaconda 安装,推荐): 2. 方法二(使用 Anaconda 安装,推荐):
在已激活的 orisys 环境中直接运行以下命令(以 CUDA 12.9 为例): 在已激活的 orisys 环境中直接运行以下命令(以 CUDA 12.9 为例):
`conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn`
```bash
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
```
该方法会自动配置好 CUDA 和 cuDNN,无需手动下载。 该方法会自动配置好 CUDA 和 cuDNN,无需手动下载。
注意:如果您的计算机没有 NVIDIA 显卡,或者不想使用 GPU 加速,可以跳过此步骤。套件也支持纯 CPU 运行(但速度可能较慢)。 注意:如果您的计算机没有 NVIDIA 显卡,或者不想使用 GPU 加速,可以跳过此步骤。套件也支持纯 CPU 运行(但速度可能较慢)。
# 安装 orisys SDK ## 安装 orisys SDK
orisys SDK 以 .whl 文件形式提供,您需要根据操作系统、Python 版本和硬件平台选择正确的文件。 推荐优先从 PyPI 安装。PyPI 会自动选择适合当前 Python 版本和操作系统的安装包,适合大多数用户。
## 下载正确的 .whl 文件 ### 使用 PyPI 安装(推荐)
确认已经激活 `orisys` 虚拟环境后,运行:
```bash
pip install orisys
```
如果需要升级到最新版本,运行:
```bash
pip install --upgrade orisys
```
如果当前网络无法访问 PyPI,或需要安装客服提供的指定版本,可以使用下方 `.whl` 文件安装方式。
### 使用 .whl 文件安装(离线或指定版本)
从官方渠道获取 .whl 文件,文件名格式通常为: 从官方渠道获取 .whl 文件,文件名格式通常为:
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl
例如:orisys-0.4.2-cp310-cp310-win_amd64.whl 表示: 例如:orisys-0.4.3-cp310-cp310-win_amd64.whl 表示:
版本:0.4.2 版本:0.4.3
Python 版本:3.10(cp310) Python 版本:3.10(cp310)
操作系统:Windows(win) 操作系统:Windows(win)
硬件架构:64位(amd64) 硬件架构:64位(amd64)
请根据您的系统选择对应的文件,或者咨询客服索取: 请根据您的系统选择对应的文件,或者咨询客服索取。
## 安装 .whl 文件 将下载好的 `.whl` 文件放在一个文件夹中(如上述 `orisys_ws` 文件夹),打开命令行(确保已激活 `orisys` 环境或使用 “Anaconda Prompt”),使用 `cd` 命令切换到该文件夹,然后运行:
将下载好的 .whl 文件放在一个文件夹中(如上述`orisys_ws`文件夹),打开命令行(确保已激活 orisys 环境或使用"Anaconda Prompt"),使用 `cd` 命令切换到该文件夹,然后运行:
``` bash ```bash
# 请将文件名替换为您实际下载的文件名 # 请将文件名替换为您实际下载的文件名
pip install orisys-0.4.2-cp310-cp310-win_amd64.whl pip install orisys-0.4.3-cp310-cp310-win_amd64.whl
``` ```
## 安装 CuPy(可选) ### 安装 CuPy(可选)
注意:如果您没有安装 CUDA,或者使用 CPU 运行,可以跳过这一步。 注意:如果您没有安装 CUDA,或者使用 CPU 运行,可以跳过这一步。
CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安装的 CUDA 版本选择对应的 CuPy 版本。 CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安装的 CUDA 版本选择对应的 CuPy 版本。
@@ -84,83 +110,88 @@ CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安
```bash ```bash
# 如果您的 CUDA 版本是 12.x # 如果您的 CUDA 版本是 12.x
pip install orisys[cuda12] pip install "orisys[cuda12]"
# 如果您的 CUDA 版本是 13.x # 如果您的 CUDA 版本是 13.x
pip install orisys[cuda13] pip install "orisys[cuda13]"
``` ```
如果之前已经安装了 orisys,可以单独安装 CuPy: 如果之前已经安装了 orisys,可以单独安装 CuPy:
方法二(手动安装): 方法二(手动安装):
``` bash ```bash
# CUDA 12.x # CUDA 12.x
pip install cupy-cuda12x>=12.3.0 pip install "cupy-cuda12x>=12.3.0"
# CUDA 13.x # CUDA 13.x
pip install cupy-cuda13x>=13.0.0 pip install "cupy-cuda13x>=13.0.0"
``` ```
## 测试安装 ### 测试安装
安装完成后,在命令行输入” python“命令进入 Python 环境, 并导入 orisys 库并查看版本号,验证是否成功: 安装完成后,在命令行输入 `python` 命令进入 Python 环境,并导入 `orisys` 库查看版本号,验证是否成功:
``` bash ```bash
python python
```
进入 Python 后继续输入:
```python
import orisys import orisys
print(orisys.__version__) print(orisys.__version__)
``` ```
如果输出版本号(例如 0.4.2),则说明安装成功。 如果输出版本号(例如 0.4.3),则说明安装成功。
# 使用方法 ## 使用方法
## 运行示例程序 ### 运行示例程序
套件里面包含了多个例子,建议都在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。按产品入口如下: 套件里面包含了多个例子。运行示例前,请先进入 SDK 示例所在的文件夹,建议在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。如果您是通过 PyPI 安装并且本地没有示例文件,可以从上方项目链接下载示例。按产品入口如下:
1. `examples/products/ory_box/basic_pipeline.py`:ORY-BOX 复眼/拼接传感器基础 OpenCV 示例(默认配置 `ddjx01`) 1. `examples/basic_pipeline.py`:ORY-BOX 复眼/拼接传感器基础 OpenCV 示例(默认配置 `box`)
2. `examples/products/ory_mini/basic_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`) 2. `examples/mini_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`)
3. `examples/products/ory_finger/finger_pipeline.py`:ORY-FINGER 2D UV 最小流程(默认配置 `dd02`) 3. `examples/finger_pipeline.py`:ORY-FINGER 2D 显示最小流程(默认配置 `finger`)
4. `examples/products/ory_finger/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `dd02`) 4. `examples/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `finger`)
`dd02` 是当前 ORY-FINGER 的稳定别名,实际会解析到 `dd02-ov`。旧路径 `examples/basic_pipeline.py` / `examples/finger_pipeline*.py` 仍可作为兼容入口。 `finger` 是当前 ORY-FINGER 的产品配置名。
下面先介绍最基础的 ORY-BOX 运行方式。 下面先介绍最基础的 ORY-BOX 运行方式。
### Windows: #### Windows
1. 检查是否有其他正在连接的USB摄像头设备:打开powershell, 运行命令`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。 1. 检查是否有其他正在连接的 USB 摄像头设备:打开 PowerShell,运行命令 `Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。
2. 把传感器的USB头与电脑连接,再次运行步骤一的命令,会新增一行输出 2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增一行输出。
3. 如果第一步没有输出,传感器的ID就是“0”,如果第一步有一个设备,传感器的ID就是“1”,如此类推。 3. 如果第一步没有输出,传感器的 ID 就是 `0`;如果第一步有一个设备,传感器的 ID 就是 `1`,如此类推。
4. 在确定了传感器的ID(下方例子假设为“0”)后,打开Anaconda Prompt, 切换到工作路径,然后在命令行中输入: 4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开 Anaconda Prompt,切换到工作路径,然后在命令行中输入:
``` bash ```bash
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 python examples/basic_pipeline.py -v 0 -c box
``` ```
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据 5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
### Ubuntu: #### Ubuntu
1. 检查是否有其他正在连接的USB摄像头设备:打开终端, 运行命令`v4l2 –list-devices`,如果没有的话不会有输出。 1. 检查是否有其他正在连接的 USB 摄像头设备:打开终端,运行命令 `v4l2 --list-devices`,如果没有的话不会有输出。
2. 把传感器的USB头与电脑连接,再次运行步骤一的命令,会新增输出 2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增输出。
3. 根据新增的输出,例如”/dev/video0”,确定传感器的ID为“0”, 如果是”/dev/video3”的话ID为“3” ,如此类推。 3. 根据新增的输出,例如 `/dev/video0`,确定传感器的 ID 为 `0`;如果是 `/dev/video3`,ID 为 `3`,如此类推。
4. 在确定了传感器的ID(下方例子假设为“0”)后,打开终端, 激活相应的python环境,切换到工作路径,然后在命令行中输入: 4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开终端,激活相应的 Python 环境,切换到工作路径,然后在命令行中输入:
``` bash ```bash
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 python examples/basic_pipeline.py -v 0 -c box
``` ```
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据 5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
### ORY-FINGER UV 示例 #### ORY-FINGER 示例
如果您使用的是 ORY-FINGER(当前配置别名 `dd02`),可运行: 如果您使用的是 ORY-FINGER(配置名 `finger`),可运行:
``` bash ```bash
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 python examples/finger_pipeline.py --camera_id 0 --config finger
``` ```
如果输入来自视频文件,也可以改用: 如果输入来自视频文件,也可以改用:
``` bash ```bash
python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4 --config dd02 python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
``` ```
该示例会: 该示例会:
- 读取相机或视频源 - 读取相机或视频源
- 将图像展开到 UV 平面 - 将图像展开到平面
- 计算光流、法向/切向力和接触区域 - 计算光流、法向/切向力和接触区域
- 在 OpenCV 窗口中显示展开后的光流箭头图 - 在 OpenCV 窗口中显示展开后的光流箭头图
@@ -170,20 +201,19 @@ python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4
例如: 例如:
``` bash ```bash
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 python examples/finger_pipeline.py --camera_id 0 --config finger
``` ```
### ORY-FINGER 3D 可视化界面 #### ORY-FINGER 3D 可视化界面
如果要使用 GUI ,安装GUI依赖: 如果要使用 GUI,先安装 GUI 依赖:
``` bash ```bash
pip install open3d pip install "orisys[gui]"
pip install pyqt5 pyqtgraph
``` ```
然后运行: 然后运行:
``` bash ```bash
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02 python examples/finger_pipeline_3dview.py --video 0 --config finger
``` ```
该界面会提供: 该界面会提供:
@@ -192,5 +222,5 @@ python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config
- 力曲线 - 力曲线
- 3D 视图 - 3D 视图
## 开发套件使用方法 ### 开发套件使用方法
参考开发套件附带的开发文档。 参考开发套件附带的开发文档。
Binary file not shown.
-171
View File
@@ -1,171 +0,0 @@
# Publishing Orisys to PyPI
This document covers the **Linux manylinux binary wheel** path (Nuitka-compiled
`orisys` for CPython 3.10 / 3.11 / 3.12). Windows wheels are still built on
Windows hosts; they cannot be produced inside the manylinux container.
## Why Docker?
PyPI rejects wheels tagged `manylinux_x86_64` (missing glibc ABI version). A
compliant tag looks like:
```text
orisys-0.4.2-cp310-cp310-manylinux_2_28_x86_64.whl
orisys-0.4.2-cp311-cp311-manylinux_2_28_x86_64.whl
orisys-0.4.2-cp312-cp312-manylinux_2_28_x86_64.whl
```
Those tags are produced by building inside the official manylinux image and
running `auditwheel repair`. On Windows, use **Docker Desktop in Linux container
mode**.
> Note: we use `manylinux_2_28_x86_64` (not 2014). Recent NumPy/SciPy no longer
> provide manylinux2014 wheels and fail to build from source on that older GCC.
## Prerequisites
1. Docker Desktop installed and running (**Linux containers**, not Windows containers).
2. Repository checked out locally (this tree is bind-mounted into the container).
3. Version bumped in `src/orisys/__init__.py` (`__version__`) before every upload.
PyPI never allows re-uploading the same filename/version.
4. PyPI / TestPyPI project-scoped API tokens (never commit tokens or `.pypirc` with secrets).
Quick Docker check:
```powershell
docker version --format "{{.Server.Os}}"
# expect: linux
docker run --rm hello-world
```
## Build manylinux wheels (3.10 / 3.11 / 3.12)
From the repository root:
```bash
python build/run.py manylinux
```
Or call the Docker helper directly:
```powershell
.\build\wheel\docker\run.ps1
```
Default builds **all three** ABIs. To build a subset:
```powershell
python build/run.py manylinux -PythonVersions 3.10,3.12
python build/run.py manylinux -PythonVersions 3.11
```
What this does:
1. Builds `orisys-manylinux` from [`build/wheel/docker/Dockerfile.manylinux`](../build/wheel/docker/Dockerfile.manylinux)
(`quay.io/pypa/manylinux_2_28_x86_64` with Nuitka/auditwheel in cp310/cp311/cp312).
2. Runs [`build/wheel/docker/entrypoint.sh`](../build/wheel/docker/entrypoint.sh), which loops:
```bash
/opt/python/cp310-cp310/bin/python build/wheel/build.py --target pypi-linux
/opt/python/cp311-cp311/bin/python build/wheel/build.py --target pypi-linux
/opt/python/cp312-cp312/bin/python build/wheel/build.py --target pypi-linux
```
3. For each ABI: Nuitka compile → wheel → `auditwheel repair` → isolated smoke test.
4. Artifacts land in:
- `dist/repaired/orisys-*-manylinux_2_28_x86_64.whl` (upload candidates)
- `SDK/<version>/` (same compliant wheels)
Expect roughly **3×** the wall time of a single-ABI build.
Rebuild without refreshing the image:
```powershell
python build/run.py manylinux -SkipImageBuild
```
Equivalent manual `docker` invocation:
```powershell
docker build -f build/wheel/docker/Dockerfile.manylinux -t orisys-manylinux .
docker run --rm `
-e ORISYS_BUILD_TARGET=pypi-linux `
-e ORISYS_PYTHON_TAGS=cp310-cp310,cp311-cp311,cp312-cp312 `
-v "${PWD}:/src" `
orisys-manylinux
```
### Acceptance checks before upload
- Each filename contains `cp310` / `cp311` / `cp312` and `manylinux_2_28_x86_64`
(or another accepted manylinux tag such as `manylinux_2_17_x86_64`).
- Isolated smoke test printed `public API imports OK` for every ABI.
- Do **not** upload any wheel still tagged `linux_x86_64` or bare `manylinux_x86_64`.
Optional re-verify on a Linux machine:
```bash
python build/run.py verify dist/repaired/orisys-*-cp310-cp310-manylinux_*.whl
```
## Promote: TestPyPI then PyPI
Install upload tooling in a clean environment:
```powershell
python -m pip install --upgrade twine
```
### 1. Upload to TestPyPI
```powershell
$env:TWINE_USERNAME = "__token__"
$env:TWINE_PASSWORD = "<TestPyPI API token>"
python -m twine check dist/repaired/*.whl
python -m twine upload --repository testpypi dist/repaired/*.whl
```
Install from TestPyPI in a fresh venv (pull runtime deps from real PyPI), testing
each Python you care about:
```powershell
py -3.10 -m venv .venv-testpypi310
.\.venv-testpypi310\Scripts\Activate.ps1
python -m pip install --index-url https://test.pypi.org/simple/ `
--extra-index-url https://pypi.org/simple `
"orisys==<version>"
python -c "import orisys; print(orisys.__version__)"
```
### 2. Upload the same artifacts to PyPI
Only after TestPyPI install/import succeeds:
```powershell
$env:TWINE_USERNAME = "__token__"
$env:TWINE_PASSWORD = "<PyPI API token>"
python -m twine upload dist/repaired/*.whl
```
Prefer CI **Trusted Publishing** (OIDC) for production uploads so long-lived tokens
are not stored on developer machines.
## Related local targets
| Command | Purpose |
|---------|---------|
| `python build/run.py wheel` | Preferred wheel builder launcher |
| `python build/wheel/build.py` | Direct wheel builder (current host) |
| `python build/run.py wheel --target pypi-linux` | Linux-only; requires manylinux + CPython 3.10–3.12 |
| `python build/run.py wheel-win` | Windows wheels for py310/py311/py312 conda envs |
| `python build/run.py manylinux` | manylinux Docker runner |
| `python build/run.py gui finger` | ORY-FINGER standalone GUI |
| `python -m build` | Pure-Python / sdist path from `pyproject.toml` (no Nuitka) |
See also [`build/README.md`](../build/README.md) for the target-oriented layout.
Windows binary wheels and Linux manylinux wheels must be built on their respective
platforms. Never rename a platform tag to bypass PyPI validation.
+13 -17
View File
@@ -1,54 +1,50 @@
# Examples # Examples
Product-facing demos live under `examples/products/`. Shared helpers stay in Runnable demos live at the `examples/` root. Shared helpers stay in
`pipeline/`, `viz/`, and `gui/`. Run commands from the repository root. `pipeline/`, `viz/`, and `gui/`. Run commands from the repository root.
## Product Entry Points ## Product Entry Points
| Product | Entry | Default config | | Product | Entry | Default config |
|---------|-------|----------------| |---------|-------|----------------|
| ORY-BOX | `products/ory_box/basic_pipeline.py` | `ddjx01` | | ORY-BOX | `basic_pipeline.py` | `box` |
| ORY-MINI | `products/ory_mini/basic_pipeline.py` | `mini` | | ORY-MINI | `mini_pipeline.py` | `mini` |
| ORY-FINGER (2D) | `products/ory_finger/finger_pipeline.py` | `dd02` | | ORY-FINGER (2D) | `finger_pipeline.py` | `finger` |
| ORY-FINGER (3D Qt) | `products/ory_finger/finger_pipeline_3dview.py` | `dd02` | | ORY-FINGER (3D Qt) | `finger_pipeline_3dview.py` | `finger` |
`dd02` is the stable ORY-FINGER alias and currently resolves to `dd02-ov`. `finger` is the public ORY-FINGER config stem. `dd02` remains a compatibility alias for `dd02-ov`.
Compatibility wrappers still exist at the `examples/` root
(`basic_pipeline.py`, `finger_pipeline.py`, `finger_pipeline_3dview.py`) and
forward to the product entries above.
## Typical Usage ## Typical Usage
ORY-BOX: ORY-BOX:
```bash ```bash
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 python examples/basic_pipeline.py -v 0 -c box
``` ```
ORY-MINI: ORY-MINI:
```bash ```bash
python examples/products/ory_mini/basic_pipeline.py -v 0 -c mini python examples/mini_pipeline.py -v 0 -c mini
``` ```
ORY-FINGER 2D: ORY-FINGER 2D:
```bash ```bash
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 python examples/finger_pipeline.py --camera_id 0 --config finger
``` ```
ORY-FINGER video file: ORY-FINGER video file:
```bash ```bash
python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4 --config dd02 python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
``` ```
ORY-FINGER 3D Qt dashboard: ORY-FINGER 3D Qt dashboard:
```bash ```bash
pip install -e ".[gui]" pip install -e ".[gui]"
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02 python examples/finger_pipeline_3dview.py --video 0 --config finger
``` ```
## Other Root Examples ## Other Root Examples
@@ -70,10 +66,10 @@ pip install -e ".[gui]"
## Notes ## Notes
- `products/ory_finger/finger_pipeline_3dview.py` is the main interactive finger demo. - `finger_pipeline_3dview.py` is the main interactive finger demo.
It uses `gui/app_paths.py` so the same entry works in editable and packaged runs. It uses `gui/app_paths.py` so the same entry works in editable and packaged runs.
- Dashboard controls: click **启动** to start acquisition; use **Record CSV / Stop CSV** - Dashboard controls: click **启动** to start acquisition; use **Record CSV / Stop CSV**
and the CSV save-directory browser to log contact/force data; `Q` quits, `R` resets, and the CSV save-directory browser to log contact/force data; `Q` quits, `R` resets,
`S` starts video recording, `E` stops video recording, and `Esc` toggles fullscreen. `S` starts video recording, `E` stops video recording, and `Esc` toggles fullscreen.
- `products/ory_finger/finger_pipeline.py` is the leanest finger example if you want to - `finger_pipeline.py` is the leanest finger example if you want to
validate UV unroll + force/contact processing without the full dashboard. validate UV unroll + force/contact processing without the full dashboard.
+107 -13
View File
@@ -1,21 +1,115 @@
#!/usr/bin/env python3
"""Compatibility wrapper for ORY-BOX.
Prefer:
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
""" """
ORY-BOX:单传感器基础流程
from __future__ import annotations 本示例演示 Orisys SDK 的标准处理流程:
1. 初始化传感器(摄像头或视频文件)
2. 获取图像并计算形变
3. 按需计算接触区域
4. 读取并可视化结果
import sys SDK version: 0.4.3
from pathlib import Path """
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import argparse
_EXAMPLES_DIR = Path(__file__).resolve().parent def main():
if str(_EXAMPLES_DIR) not in sys.path: parser = argparse.ArgumentParser()
sys.path.insert(0, str(_EXAMPLES_DIR)) parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径")
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
args = parser.parse_args()
from products.ory_box.basic_pipeline import main # 步骤 1:创建传感器对象(必需)
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
# 输入既可以是摄像头编号,也可以是视频文件路径
try:
input = int(args.video)
is_camera = True
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
except:
is_camera = False
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
while True:
# 步骤 2:数据处理
# 步骤 2.1:获取并拼接图像(必需)
sensor.get_img()
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
is_contact_now = sensor.compute_deformation()
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
# if is_contact_now:
sensor.compute_contact()
if __name__ == "__main__": # 步骤 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() main()
+100 -10
View File
@@ -1,20 +1,110 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Compatibility wrapper for ORY-FINGER 2D pipeline. """
ORY-FINGER:Minimum UV-unroll finger tactile pipeline.
Prefer: Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
Recommended entry:
python examples/finger_pipeline.py --camera_id 0 --config finger
Current product config: ``finger`` (``dd02`` still resolves to ``dd02-ov``).
""" """
from __future__ import annotations import argparse
import os
import sys import sys
from pathlib import Path
_EXAMPLES_DIR = Path(__file__).resolve().parent os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
from products.ory_finger.finger_pipeline import main import cv2
import orisys
from orisys.finger import FingerRuntime
def main():
parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline")
parser.add_argument("--camera_id", type=int, default=0)
parser.add_argument("--video", help="Video file path (overrides camera)")
parser.add_argument("--config", "-c", default="finger", help="Orisys SDK config name or path")
parser.add_argument("--show", action="store_true", default=True, help="Show unrolled image window")
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
args = parser.parse_args()
try:
video = args.camera_id
if args.video:
video = args.video
runtime = FingerRuntime.open(
config=args.config,
video=video,
verbose=args.verbose,
)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
if args.verbose:
geometry = runtime.geometry
print(
f"UV geometry: native={geometry.native_width}x{geometry.native_height}, "
f"source={geometry.source_width}x{geometry.source_height} "
f"output={geometry.out_width}x{geometry.out_height} "
)
runtime.warmup(color=False)
tactile = runtime.tactile
print("Press 'q' to quit, 'r' to reset tracker.")
try:
while True:
ok, unrolled = runtime.grab_and_process(color=False)
if not ok:
break
tactile.compute_deformation()
tactile.compute_contact()
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = tactile.read_info(
tactile.info.FPS,
tactile.info.VRAW,
tactile.info.VNORMAL,
tactile.info.IMG,
tactile.info.CONTOUR,
tactile.info.CENTROID,
tactile.info.DEPTH,
tactile.info.FNORMAL,
tactile.info.FSHEARX,
tactile.info.FSHEARY,
)
status = (
f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) "
f"mask={tactile.mask_stats['coverage']:.2f}"
)
if args.show:
arrows = orisys.util.draw_arrows(
unrolled,
flow,
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
)
cv2.imshow("UV Unrolled", arrows)
print(status, end="\r")
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
print("\nExiting.")
break
if key == ord("r"):
tactile.reset()
print("\nTracker reset.")
finally:
runtime.close()
cv2.destroyAllWindows()
if __name__ == "__main__": if __name__ == "__main__":
+76 -6
View File
@@ -1,20 +1,90 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Compatibility wrapper for ORY-FINGER 3D Qt dashboard. """
ORY-FINGER:UV-unroll finger tactile pipeline with unified Qt dashboard.
Prefer: Where to customize the GUI:
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02 Layout (sizes, proportions) -> examples/gui/viewer3d/layout.py
Colors / QSS -> examples/gui/viewer3d/styles.py
Widget rendering logic -> examples/gui/viewer3d/widgets/
Window behavior -> examples/gui/viewer3d/dashboard.py
Recommended entry:
python examples/finger_pipeline_3dview.py --video 0 --config finger
""" """
from __future__ import annotations import argparse
import os
import sys import sys
from pathlib import Path from pathlib import Path
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
# Make shared packages importable when launched as a file path.
_EXAMPLES_DIR = Path(__file__).resolve().parent _EXAMPLES_DIR = Path(__file__).resolve().parent
if str(_EXAMPLES_DIR) not in sys.path: if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR)) sys.path.insert(0, str(_EXAMPLES_DIR))
from products.ory_finger.finger_pipeline_3dview import main from gui.app_paths import (
configure_frozen_runtime,
default_config_path,
ensure_examples_import_path,
require_gui_extras,
)
configure_frozen_runtime()
ensure_examples_import_path()
require_gui_extras()
from gui import run_dashboard
from pipeline import PipelineConfig
from viz import DEFAULT_FINGER_VIZ_POSE
def _parse_args():
parser = argparse.ArgumentParser(
description="UV-unroll finger tactile pipeline with unified Qt dashboard"
)
parser.add_argument("--video", "-v", default="0", help="Camera index or video file path")
parser.add_argument("--config", "-c", default=default_config_path(), help="Orisys SDK config")
parser.add_argument("--cal", default="", help="Optional calibration file path")
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
parser.add_argument(
"--mesh-map",
choices=["flow", "depth"],
default="depth",
help="Color the 3D mesh from flow magnitude or contact depth.",
)
parser.add_argument("--threshold", type=float, default=None, help="Mesh/arrow threshold")
parser.add_argument("--no-3d", action="store_true", help="Disable 3D mesh view")
parser.add_argument(
"--viz-pose",
default=DEFAULT_FINGER_VIZ_POSE,
help="world_to_cam.npy for the default view; pass 'none' to skip",
)
parser.add_argument(
"--cutoff",
type=float,
default=5.0,
help="Low-pass cutoff for force curves (Hz)",
)
parser.add_argument(
"--profile",
action="store_true",
help="Print per-stage timing to the log panel / console",
)
parser.add_argument(
"--profile-every",
type=int,
default=30,
help="When --profile is set, print rolling averages every N frames",
)
return parser.parse_args()
def main():
args = _parse_args()
config = PipelineConfig.from_args(args)
sys.exit(run_dashboard(config))
if __name__ == "__main__": if __name__ == "__main__":
+11 -2
View File
@@ -27,8 +27,17 @@ def resource_path(*parts: str) -> Path:
def default_config_path() -> str: def default_config_path() -> str:
"""Default ORY-FINGER config name (resolves to dd02-ov via alias).""" """Default ORY-FINGER config name (packaged stem ``finger``)."""
return "dd02" return "finger"
def require_gui_extras() -> None:
try:
import open3d # noqa: F401
from PyQt5 import QtWidgets # noqa: F401
import pyqtgraph # noqa: F401
except ImportError as exc:
raise SystemExit('GUI example requires: pip install "orisys[gui]"') from exc
def configure_frozen_runtime() -> None: def configure_frozen_runtime() -> None:
+7 -5
View File
@@ -1,10 +1,12 @@
{ {
"video_source": "D:/repo/orisys-sdk/data/dd02/0607_1.mp4", "video_source": "D:/repo/orisys-sdk/data/dd02-0703/1.mp4",
"config_path": "./config/dd02-ov.json", "sensor_bundle_dir": "sensors/finger",
"finger_assets_dir": "D:/repo/orisys-sdk/assets/finger/30fps-0703", "config_path": "sensors/finger/sensor.json",
"finger_assets_dir": "sensors/finger/assets/30fps-0703",
"csv_save_dir": "./outputs/csv", "csv_save_dir": "./outputs/csv",
"show_log": true, "show_log": true,
"show_flow_arrows": false, "show_flow_arrows": false,
"arrow_background": "pure_color", "arrow_background": "unrolled",
"free_rotate": true "free_rotate": true,
"log_display_mode": "hidden"
} }
+1 -9
View File
@@ -11,6 +11,7 @@ from typing import Any, Optional
import cv2 import cv2
import numpy as np import numpy as np
import orisys import orisys
from orisys.signal_processing import calibrate_normal_force
from .roi_geometry import ( from .roi_geometry import (
build_roi_warp, build_roi_warp,
@@ -22,10 +23,6 @@ from .roi_geometry import (
) )
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
@dataclass @dataclass
class FrameSnapshot: class FrameSnapshot:
"""Normalized per-frame payload consumed by the Qt viewer.""" """Normalized per-frame payload consumed by the Qt viewer."""
@@ -49,11 +46,6 @@ class FrameSnapshot:
record_frame: Optional[np.ndarray] = None record_frame: Optional[np.ndarray] = None
def calibrate_normal_force(raw_value: float) -> float:
"""Convert raw normal-force reading to Newtons using y = mx + c."""
return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET
class SensorAdapter(ABC): class SensorAdapter(ABC):
"""Common interface between Qt and concrete sensor backends.""" """Common interface between Qt and concrete sensor backends."""
+1 -27
View File
@@ -13,7 +13,7 @@ from typing import Literal, Optional, Sequence, Tuple
import cv2 import cv2
import numpy as np import numpy as np
import orisys import orisys
from scipy.signal import butter, filtfilt from orisys.signal_processing import lowpass_filter
FORCE_SAMPLE_RATE_HZ = 30.0 FORCE_SAMPLE_RATE_HZ = 30.0
PLOT_BUFFER_POINTS = 120 PLOT_BUFFER_POINTS = 120
@@ -61,32 +61,6 @@ class ForcePlotState:
y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]] y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]
def lowpass_filter(
data,
cutoff_hz: float,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
order: int = 2,
) -> np.ndarray:
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data."""
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0:
return y.copy()
if y.size == 0:
return y
nyquist = 0.5 * sample_rate_hz
if cutoff_hz < 0 or cutoff_hz >= nyquist:
raise ValueError(
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
)
b, a = butter(order, cutoff_hz / nyquist, btype="low")
padlen = 3 * (max(len(b), len(a)) - 1)
if y.size <= padlen:
return y.copy()
return filtfilt(b, a, y)
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]: def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
if img is None: if img is None:
return None return None
+1 -1
View File
@@ -111,7 +111,7 @@ class QtViewerWindow(QMainWindow):
self.btn_config.setObjectName("btnConfig") self.btn_config.setObjectName("btnConfig")
self.btn_config.clicked.connect(self._select_config) self.btn_config.clicked.connect(self._select_config)
bar.addWidget(self.btn_config) bar.addWidget(self.btn_config)
self.config_edit = QLineEdit("./config/ddjx01.json") self.config_edit = QLineEdit("box")
bar.addWidget(self.config_edit, 1) bar.addWidget(self.config_edit, 1)
self.btn_reset = QPushButton("重置") self.btn_reset = QPushButton("重置")
+121 -48
View File
@@ -2,14 +2,19 @@
from __future__ import annotations from __future__ import annotations
import json
import math import math
import sys import sys
import time import time
from pathlib import Path
from PyQt5.QtCore import Qt, QTimer from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QAction,
QActionGroup,
QApplication, QApplication,
QDockWidget,
QFileDialog, QFileDialog,
QMainWindow, QMainWindow,
QSplitter, QSplitter,
@@ -61,6 +66,7 @@ class FingerDashboardWindow(QMainWindow):
self._frame_index = 0 self._frame_index = 0
self._wall_fps = 0.0 self._wall_fps = 0.0
self._wall_fps_alpha = 0.15 self._wall_fps_alpha = 0.15
self._log_display_mode = "hidden"
self.setWindowTitle(layout.window_title) self.setWindowTitle(layout.window_title)
self.setStyleSheet(STYLE) self.setStyleSheet(STYLE)
@@ -84,7 +90,7 @@ class FingerDashboardWindow(QMainWindow):
root_layout.setSpacing(layout.root_spacing_px) root_layout.setSpacing(layout.root_spacing_px)
setup = layout.panel_setup setup = layout.panel_setup
self._log_panel_visible = setup.log_panel.enabled self._log_panel_visible = False
self.mesh_state = MeshViewState.from_layout(layout) self.mesh_state = MeshViewState.from_layout(layout)
self.mesh_viewport = MeshViewportWidget(layout=layout, mesh_state=self.mesh_state) self.mesh_viewport = MeshViewportWidget(layout=layout, mesh_state=self.mesh_state)
arrows_slot = layout.slot("arrows") arrows_slot = layout.slot("arrows")
@@ -111,7 +117,6 @@ class FingerDashboardWindow(QMainWindow):
self._preferences = load_preferences() self._preferences = load_preferences()
self.settings_panel.apply_preference_state(self._preferences) self.settings_panel.apply_preference_state(self._preferences)
self._log_panel_visible = bool(self.settings_panel.show_log_check.isChecked())
self.mesh_viewport.set_free_rotation(bool(self.settings_panel.free_rotate_check.isChecked())) self.mesh_viewport.set_free_rotation(bool(self.settings_panel.free_rotate_check.isChecked()))
if self.arrow_panel is not None: if self.arrow_panel is not None:
self.arrow_panel.set_background(self.settings_panel.arrow_background()) self.arrow_panel.set_background(self.settings_panel.arrow_background())
@@ -152,11 +157,6 @@ class FingerDashboardWindow(QMainWindow):
self.charts_column = QSplitter(Qt.Vertical) self.charts_column = QSplitter(Qt.Vertical)
self.charts_column.addWidget(self.force_charts.widget) self.charts_column.addWidget(self.force_charts.widget)
if setup.log_panel.enabled:
self.charts_column.addWidget(self.log_widget)
self.log_widget.setVisible(self._log_panel_visible)
self.charts_column.setStretchFactor(0, setup.charts_log_stretch)
self.charts_column.setStretchFactor(1, setup.log_stretch)
self.charts_column.setChildrenCollapsible(False) self.charts_column.setChildrenCollapsible(False)
h_splitter = QSplitter(Qt.Horizontal) h_splitter = QSplitter(Qt.Horizontal)
@@ -172,18 +172,104 @@ class FingerDashboardWindow(QMainWindow):
self.h_splitter = h_splitter self.h_splitter = h_splitter
root_layout.addWidget(h_splitter) root_layout.addWidget(h_splitter)
self._setup_log_dock()
self._apply_log_display_mode(self._preferences.get("log_display_mode", "hidden"))
self.resize(*self._target_window_size()) self.resize(*self._target_window_size())
QTimer.singleShot(0, self._apply_panel_layout) QTimer.singleShot(0, self._apply_panel_layout)
def _set_log_panel_visible(self, visible: bool) -> None: def _set_log_panel_visible(self, visible: bool) -> None:
self._log_panel_visible = bool(visible) self._log_panel_visible = bool(visible)
self.log_widget.setVisible(self._log_panel_visible) if hasattr(self, "log_dock"):
self.log_dock.setVisible(self._log_panel_visible)
QTimer.singleShot(0, self._apply_panel_layout) QTimer.singleShot(0, self._apply_panel_layout)
def _setup_log_dock(self) -> None:
self.log_dock = QDockWidget("Log", self)
self.log_dock.setObjectName("logDock")
self.log_dock.setWidget(self.log_widget)
self.log_dock.setAllowedAreas(Qt.BottomDockWidgetArea | Qt.RightDockWidgetArea)
self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock)
self.log_dock.hide()
self.log_dock.visibilityChanged.connect(self._on_log_dock_visibility_changed)
view_menu = self.menuBar().addMenu("View")
self.log_toggle_action = QAction("Log Panel", self)
self.log_toggle_action.setCheckable(True)
self.log_toggle_action.triggered.connect(self._on_log_toggle_action)
view_menu.addAction(self.log_toggle_action)
log_content_menu = view_menu.addMenu("Log Content")
self.log_content_actions = QActionGroup(self)
self.log_content_actions.setExclusive(False)
for label, mode in (
("Runtime log", "runtime"),
("Bundle info", "bundle"),
("Performance/profile", "profile"),
):
action = QAction(label, self)
action.setCheckable(True)
action.setData(mode)
action.triggered.connect(lambda _checked=False, m=mode: self._apply_log_display_mode(m, save=True))
self.log_content_actions.addAction(action)
log_content_menu.addAction(action)
def _on_log_toggle_action(self, checked: bool) -> None:
if checked:
mode = self._log_display_mode if self._log_display_mode != "hidden" else "runtime"
self._apply_log_display_mode(mode, save=True)
else:
self._apply_log_display_mode("hidden", save=True)
def _on_log_dock_visibility_changed(self, visible: bool) -> None:
self._log_panel_visible = bool(visible)
if hasattr(self, "log_toggle_action"):
self.log_toggle_action.blockSignals(True)
self.log_toggle_action.setChecked(visible)
self.log_toggle_action.blockSignals(False)
if not visible and self._log_display_mode != "hidden":
self._log_display_mode = "hidden"
if hasattr(self, "log_content_actions"):
for action in self.log_content_actions.actions():
action.blockSignals(True)
action.setChecked(False)
action.blockSignals(False)
self._save_preferences()
def _apply_log_display_mode(self, mode: str | None, *, save: bool = False) -> None:
mode = mode or "hidden"
self._log_display_mode = mode
if hasattr(self, "log_toggle_action"):
self.log_toggle_action.blockSignals(True)
self.log_toggle_action.setChecked(mode != "hidden")
self.log_toggle_action.blockSignals(False)
if hasattr(self, "log_content_actions"):
for action in self.log_content_actions.actions():
action.blockSignals(True)
action.setChecked(action.data() == mode)
action.blockSignals(False)
if mode == "hidden":
self._set_log_panel_visible(False)
if save:
self._save_preferences()
return
if mode == "bundle":
self.log_widget.setPlainText(
"Sensor bundle\n"
f"Config: {self.settings_panel.config_path()}\n"
f"Assets: {self.settings_panel.finger_export_dir()}\n"
)
elif mode == "profile":
self.log_widget.setPlainText(
"Performance/profile\n"
"Run with --profile to print per-stage timings in the runtime log.\n"
)
self._set_log_panel_visible(True)
if save:
self._save_preferences()
def _on_log_visibility_changed(self, visible: bool) -> None: def _on_log_visibility_changed(self, visible: bool) -> None:
self._set_log_panel_visible(visible) self._apply_log_display_mode("runtime" if visible else "hidden", save=True)
self._save_preferences()
def _on_free_rotation_changed(self, enabled: bool) -> None: def _on_free_rotation_changed(self, enabled: bool) -> None:
self.mesh_viewport.set_free_rotation(enabled) self.mesh_viewport.set_free_rotation(enabled)
@@ -196,6 +282,7 @@ class FingerDashboardWindow(QMainWindow):
def _save_preferences(self) -> None: def _save_preferences(self) -> None:
prefs = self.settings_panel.preference_state() prefs = self.settings_panel.preference_state()
prefs["log_display_mode"] = self._log_display_mode
self._preferences = prefs self._preferences = prefs
save_preferences(prefs) save_preferences(prefs)
# Keep in-memory pipeline config aligned with panel values. # Keep in-memory pipeline config aligned with panel values.
@@ -217,7 +304,6 @@ class FingerDashboardWindow(QMainWindow):
scale = self._layout_scale_factor() scale = self._layout_scale_factor()
column_height = self._scaled_top_row_height(scale) column_height = self._scaled_top_row_height(scale)
charts_slot = self.layout.top_slot("charts") charts_slot = self.layout.top_slot("charts")
log_slot = setup.log_panel
for slot in setup.top_row.slots: for slot in setup.top_row.slots:
if not slot.enabled: if not slot.enabled:
@@ -236,27 +322,11 @@ class FingerDashboardWindow(QMainWindow):
settings_height, settings_height,
) )
if ( self.force_charts.set_panel_size(
self._log_panel_visible self._scaled_px(charts_slot.width_px, scale),
and log_slot.enabled column_height,
and log_slot.height_px > 0 )
): self.charts_column.setSizes([column_height])
self.log_widget.show()
log_height = self._scaled_px(log_slot.height_px, scale)
charts_height = self._scaled_px(charts_slot.height_px, scale)
self.log_widget.setFixedHeight(log_height)
self.force_charts.set_panel_size(
self._scaled_px(charts_slot.width_px, scale),
charts_height,
)
self.charts_column.setSizes([charts_height, log_height])
else:
self.log_widget.hide()
self.force_charts.set_panel_size(
self._scaled_px(charts_slot.width_px, scale),
column_height,
)
self.charts_column.setSizes([column_height, 0])
self.h_splitter.setSizes( self.h_splitter.setSizes(
[self._scaled_px(slot.width_px, scale) for slot in setup.top_row.slots if slot.enabled] [self._scaled_px(slot.width_px, scale) for slot in setup.top_row.slots if slot.enabled]
@@ -279,12 +349,6 @@ class FingerDashboardWindow(QMainWindow):
width += margins.left() + margins.right() width += margins.left() + margins.right()
height = layout.charts_column_height_px() height = layout.charts_column_height_px()
if (
self._log_panel_visible
and layout.panel_setup.log_panel.enabled
and layout.panel_setup.log_panel.height_px > 0
):
height += self.charts_column.handleWidth()
height += margins.top() + margins.bottom() height += margins.top() + margins.bottom()
return max(layout.window_width, width), max(layout.window_height, height) return max(layout.window_width, width), max(layout.window_height, height)
@@ -316,12 +380,6 @@ class FingerDashboardWindow(QMainWindow):
base_width += max(len(enabled_slots) - 1, 0) * self.h_splitter.handleWidth() base_width += max(len(enabled_slots) - 1, 0) * self.h_splitter.handleWidth()
base_height = self.layout.charts_column_height_px() base_height = self.layout.charts_column_height_px()
if (
self._log_panel_visible
and self.layout.panel_setup.log_panel.enabled
and self.layout.panel_setup.log_panel.height_px > 0
):
base_height += self.charts_column.handleWidth()
if base_width <= 0 or base_height <= 0: if base_width <= 0 or base_height <= 0:
return 1.0 return 1.0
return min(available_width / base_width, available_height / base_height) return min(available_width / base_width, available_height / base_height)
@@ -602,12 +660,27 @@ class FingerDashboardWindow(QMainWindow):
def _select_finger_assets(self) -> None: def _select_finger_assets(self) -> None:
path = QFileDialog.getExistingDirectory( path = QFileDialog.getExistingDirectory(
self, self,
"选择 finger 资产目录", "选择传感器 bundle 目录",
self.settings_panel.finger_export_dir() or "assets/finger", self.settings_panel.sensor_bundle_dir() or "sensors/finger",
) )
if path: if path:
self.settings_panel.set_finger_export_dir(path) bundle_dir = Path(path)
self.config.export_dir = path config_path = bundle_dir / "sensor.json"
manifest_path = bundle_dir / "manifest.json"
assets_dir = bundle_dir / "assets" / "30fps-0703"
if manifest_path.is_file():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
manifest = {}
rel_assets = manifest.get("assets_dir")
if isinstance(rel_assets, str) and rel_assets.strip():
assets_dir = bundle_dir / rel_assets
if config_path.is_file():
self.settings_panel.set_config_path(str(config_path))
self.config.config = str(config_path)
self.settings_panel.set_finger_export_dir(str(assets_dir))
self.config.export_dir = str(assets_dir)
self._save_preferences() self._save_preferences()
def _select_csv_dir(self) -> None: def _select_csv_dir(self) -> None:
+113
View File
@@ -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
+73 -25
View File
@@ -15,11 +15,15 @@ from PyQt5.QtWidgets import (
QWidget, QWidget,
) )
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
from viz.render_2d import ArrowBackground from viz.render_2d import ArrowBackground
from ..layout import DashboardLayout, LAYOUT from ..layout import DashboardLayout, LAYOUT
from ..mesh_view_state import MeshViewState from ..mesh_view_state import MeshViewState
from ..presets import (
SensorBundlePreset,
discover_sensor_bundle_presets,
sensor_bundle_preset_index,
)
from ..transport_icons import reset_icon, transport_icon from ..transport_icons import reset_icon, transport_icon
_PLAY_PAUSE_BUTTON_NAME = "btnPlayPause" _PLAY_PAUSE_BUTTON_NAME = "btnPlayPause"
@@ -86,7 +90,7 @@ class SettingsPanelWidget(QWidget):
self.show_log_check = QCheckBox("Show log panel") self.show_log_check = QCheckBox("Show log panel")
self.show_log_check.setChecked(show_log) self.show_log_check.setChecked(show_log)
self.show_log_check.toggled.connect(self.log_visibility_changed.emit) self.show_log_check.toggled.connect(self.log_visibility_changed.emit)
root.addWidget(self.show_log_check) self.show_log_check.hide()
self.show_flow_arrows_check = QCheckBox("Show displacement arrows") self.show_flow_arrows_check = QCheckBox("Show displacement arrows")
self.show_flow_arrows_check.setChecked(self._mesh_state.flow_arrows_enabled) self.show_flow_arrows_check.setChecked(self._mesh_state.flow_arrows_enabled)
@@ -134,35 +138,38 @@ class SettingsPanelWidget(QWidget):
self._video_file_path = "" self._video_file_path = ""
self._updating_camera_options = False self._updating_camera_options = False
root.addWidget(self._caption("Config")) root.addWidget(self._caption("Sensor bundle"))
config_row = QHBoxLayout() bundle_row = QHBoxLayout()
config_row.setSpacing(6) bundle_row.setSpacing(6)
self.config_edit = QLineEdit("dd02") self.sensor_bundle_combo = QComboBox()
config_row.addWidget(self.config_edit, 1) self.sensor_bundle_combo.setToolTip("Select a finger sensor bundle")
self._bundle_presets = discover_sensor_bundle_presets()
for preset in self._bundle_presets:
self.sensor_bundle_combo.addItem(preset.label, preset)
self.sensor_bundle_combo.addItem("Custom…", None)
self._updating_bundle_combo = False
self.sensor_bundle_combo.currentIndexChanged.connect(self._on_bundle_combo_changed)
bundle_row.addWidget(self.sensor_bundle_combo, 1)
self.btn_browse_finger_assets = QPushButton("…")
self.btn_browse_finger_assets.setFixedWidth(32)
self.btn_browse_finger_assets.setToolTip("Browse sensor bundle folder")
self.btn_browse_finger_assets.clicked.connect(self.browse_finger_assets_requested.emit)
bundle_row.addWidget(self.btn_browse_finger_assets)
root.addLayout(bundle_row)
self.config_edit = QLineEdit("sensors/finger/sensor.json")
self.config_edit.hide()
self.btn_browse_config = QPushButton("…") self.btn_browse_config = QPushButton("…")
self.btn_browse_config.setFixedWidth(32) self.btn_browse_config.hide()
self.btn_browse_config.setToolTip("Browse config JSON")
self.btn_browse_config.clicked.connect(self.browse_config_requested.emit)
config_row.addWidget(self.btn_browse_config)
root.addLayout(config_row)
# Optional calibration path is kept for future re-enablement but hidden for now. # Optional calibration path is kept for future re-enablement but hidden for now.
self.cal_edit = QLineEdit("") self.cal_edit = QLineEdit("")
self.cal_edit.setPlaceholderText("Optional calibration path") self.cal_edit.setPlaceholderText("Optional calibration path")
self.cal_edit.hide() self.cal_edit.hide()
root.addWidget(self._caption("Finger assets")) self.finger_assets_edit = QLineEdit("sensors/finger/assets/30fps-0703")
finger_assets_row = QHBoxLayout() self.finger_assets_edit.hide()
finger_assets_row.setSpacing(6) self._apply_bundle_preset(self._bundle_presets[0])
self.finger_assets_edit = QLineEdit(DEFAULT_FINGER_EXPORT_DIR)
self.finger_assets_edit.setPlaceholderText("Blender export bundle directory")
finger_assets_row.addWidget(self.finger_assets_edit, 1)
self.btn_browse_finger_assets = QPushButton("…")
self.btn_browse_finger_assets.setFixedWidth(32)
self.btn_browse_finger_assets.setToolTip("Browse finger asset directory")
self.btn_browse_finger_assets.clicked.connect(self.browse_finger_assets_requested.emit)
finger_assets_row.addWidget(self.btn_browse_finger_assets)
root.addLayout(finger_assets_row)
root.addWidget(self._caption("CSV save directory")) root.addWidget(self._caption("CSV save directory"))
csv_dir_row = QHBoxLayout() csv_dir_row = QHBoxLayout()
@@ -337,16 +344,18 @@ class SettingsPanelWidget(QWidget):
def set_config_path(self, value: str) -> None: def set_config_path(self, value: str) -> None:
self.config_edit.setText(value) self.config_edit.setText(value)
self._sync_bundle_combo_from_paths()
def set_calibration_path(self, value: str) -> None: def set_calibration_path(self, value: str) -> None:
self.cal_edit.setText(value or "") self.cal_edit.setText(value or "")
def set_finger_export_dir(self, value: str) -> None: def set_finger_export_dir(self, value: str) -> None:
self.finger_assets_edit.setText(value) self.finger_assets_edit.setText(value)
self._sync_bundle_combo_from_paths()
def set_finger_export_dir_enabled(self, enabled: bool) -> None: def set_finger_export_dir_enabled(self, enabled: bool) -> None:
self.finger_assets_edit.setEnabled(enabled)
self.btn_browse_finger_assets.setEnabled(enabled) self.btn_browse_finger_assets.setEnabled(enabled)
self.sensor_bundle_combo.setEnabled(enabled)
def set_csv_save_dir(self, value: str) -> None: def set_csv_save_dir(self, value: str) -> None:
self.csv_dir_edit.setText(value) self.csv_dir_edit.setText(value)
@@ -387,6 +396,33 @@ class SettingsPanelWidget(QWidget):
return return
self.csv_record_toggled.emit(not self._csv_recording) self.csv_record_toggled.emit(not self._csv_recording)
def _apply_bundle_preset(self, preset: SensorBundlePreset) -> None:
self.config_edit.setText(preset.config)
self.finger_assets_edit.setText(preset.export_dir)
def _on_bundle_combo_changed(self, _index: int) -> None:
if self._updating_bundle_combo:
return
preset = self.sensor_bundle_combo.currentData()
if isinstance(preset, SensorBundlePreset):
self._apply_bundle_preset(preset)
def _sync_bundle_combo_from_paths(self) -> None:
idx = sensor_bundle_preset_index(
self.config_edit.text(),
self.finger_assets_edit.text(),
self._bundle_presets,
)
if idx < 0:
idx = self.sensor_bundle_combo.findData(None)
if idx < 0:
return
self._updating_bundle_combo = True
self.sensor_bundle_combo.blockSignals(True)
self.sensor_bundle_combo.setCurrentIndex(idx)
self.sensor_bundle_combo.blockSignals(False)
self._updating_bundle_combo = False
def _emit_arrow_background_changed(self, _index: int) -> None: def _emit_arrow_background_changed(self, _index: int) -> None:
value = self.arrow_background_combo.currentData() value = self.arrow_background_combo.currentData()
if value: if value:
@@ -413,6 +449,15 @@ class SettingsPanelWidget(QWidget):
def finger_export_dir(self) -> str: def finger_export_dir(self) -> str:
return self.finger_assets_edit.text().strip() return self.finger_assets_edit.text().strip()
def sensor_bundle_dir(self) -> str:
preset = self.sensor_bundle_combo.currentData()
if isinstance(preset, SensorBundlePreset):
return preset.bundle_dir
config = self.config_path()
if config.endswith("/sensor.json") or config.endswith("\\sensor.json"):
return config.rsplit("/", 1)[0].rsplit("\\", 1)[0]
return ""
def csv_save_dir(self) -> str: def csv_save_dir(self) -> str:
return self.csv_dir_edit.text().strip() return self.csv_dir_edit.text().strip()
@@ -420,6 +465,7 @@ class SettingsPanelWidget(QWidget):
"""Serialize user-facing settings suitable for preference.json.""" """Serialize user-facing settings suitable for preference.json."""
return { return {
"video_source": self.video_source(), "video_source": self.video_source(),
"sensor_bundle_dir": self.sensor_bundle_dir(),
"config_path": self.config_path(), "config_path": self.config_path(),
"finger_assets_dir": self.finger_export_dir(), "finger_assets_dir": self.finger_export_dir(),
"csv_save_dir": self.csv_save_dir(), "csv_save_dir": self.csv_save_dir(),
@@ -465,6 +511,8 @@ class SettingsPanelWidget(QWidget):
if isinstance(assets, str) and assets.strip(): if isinstance(assets, str) and assets.strip():
self.set_finger_export_dir(assets.strip()) self.set_finger_export_dir(assets.strip())
self._sync_bundle_combo_from_paths()
csv_dir = state.get("csv_save_dir") csv_dir = state.get("csv_save_dir")
if isinstance(csv_dir, str) and csv_dir.strip(): if isinstance(csv_dir, str) and csv_dir.strip():
self.set_csv_save_dir(csv_dir.strip()) self.set_csv_save_dir(csv_dir.strip())
@@ -7,7 +7,7 @@ ORY-MINI:单传感器基础流程
3. 按需计算接触区域 3. 按需计算接触区域
4. 读取并可视化结果 4. 读取并可视化结果
SDK version: 0.4.2 SDK version: 0.4.3
""" """
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
@@ -43,7 +43,7 @@ def main():
# 步骤 2.1:获取并拼接图像(必需) # 步骤 2.1:获取并拼接图像(必需)
sensor.get_img() sensor.get_img()
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动 # 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0) is_contact_now = sensor.compute_deformation()
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域 # 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
# if is_contact_now: # if is_contact_now:
sensor.compute_contact() sensor.compute_contact()
+1 -1
View File
@@ -17,7 +17,7 @@ def mesh_threshold(mesh_map: str, threshold: float | None) -> float:
@dataclass @dataclass
class PipelineConfig: class PipelineConfig:
video: str = "0" video: str = "0"
config: str = "dd02" config: str = "finger"
calib: str | None = None calib: str | None = None
export_dir: str = DEFAULT_FINGER_EXPORT_DIR export_dir: str = DEFAULT_FINGER_EXPORT_DIR
csv_save_dir: str = "./outputs/csv" csv_save_dir: str = "./outputs/csv"
+25 -24
View File
@@ -1,29 +1,24 @@
""" """
示例 3:实时曲线与幅值图显示 示例 3:实时曲线与深度图显示
本示例演示如何在读取传感器结果的同时: 本示例演示如何在读取传感器结果的同时:
1. 显示形变矢量场 1. 显示形变矢量场
2. 显示流场幅值热力图 2. 显示接触深度图
3. 使用 PyQtGraph 实时绘制法向力与切向力曲线 3. 使用 PyQtGraph 实时绘制法向力与切向力曲线
4. 按键控制录像开始与结束 4. 按键控制录像开始与结束
""" """
import argparse import argparse
import os import os
import sys
import time import time
from pathlib import Path
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2 import cv2
import numpy as np
import orisys import orisys
_EXAMPLES_DIR = Path(__file__).resolve().parent from viz import ForceChartWindow, FrameRecorder
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
from viz import ForceChartWindow, FrameRecorder, draw_magnitude_map, lowpass_filter
def open_sensor(video: str, config: str, cal: str, verbose: bool): def open_sensor(video: str, config: str, cal: str, verbose: bool):
@@ -37,14 +32,21 @@ def open_sensor(video: str, config: str, cal: str, verbose: bool):
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--video", "-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径") parser.add_argument("--video", "-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
parser.add_argument("--config", "-c", type=str, default="./config/ddjx01.json", help="配置文件名称或路径") parser.add_argument("--config", "-c", type=str, default="box", help="配置文件名称或路径")
parser.add_argument("--cal", "-cal", type=str, default="./config/ddjx01.npy", help="标定文件路径") parser.add_argument("--cal", "-cal", type=str, default="", help="标定文件路径")
parser.add_argument(
"--cutoff",
type=float,
default=5.0,
help="力曲线低通滤波截止频率(Hz),30 Hz 采样时必须小于 15;设为 0 可关闭滤波",
)
parser.add_argument("--verbose", "-verbose", type=bool, default=True, help="是否输出详细日志") parser.add_argument("--verbose", "-verbose", type=bool, default=True, help="是否输出详细日志")
args = parser.parse_args() args = parser.parse_args()
charts = ForceChartWindow() charts = ForceChartWindow()
recorder = FrameRecorder() recorder = FrameRecorder()
sensor = open_sensor(args.video, args.config, args.cal, args.verbose) sensor = open_sensor(args.video, args.config, args.cal, args.verbose)
sensor.configure(lowpass_cutoff_hz=args.cutoff, lowpass_sample_rate_hz=30.0)
start_time = time.time() start_time = time.time()
print("\n按键说明:'q' 退出程序,'s' 开始录像,'e' 结束录像。") print("\n按键说明:'q' 退出程序,'s' 开始录像,'e' 结束录像。")
@@ -53,31 +55,30 @@ def main():
sensor.get_img() sensor.get_img()
recorder.write_if_recording(sensor.frame) recorder.write_if_recording(sensor.frame)
sensor.compute_deformation(check_motion=True, threshold=0) sensor.compute_deformation()
fps, fn, fx, fy, flow, vnormal, vshear = sensor.read_info( sensor.compute_contact()
fps, fn, fx, fy, flow, depth_map = sensor.read_info(
sensor.info.FPS, sensor.info.FPS,
sensor.info.FNORMAL, sensor.info.FNORMAL,
sensor.info.FSHEARX, sensor.info.FSHEARX,
sensor.info.FSHEARY, sensor.info.FSHEARY,
sensor.info.VRAW, sensor.info.VRAW,
sensor.info.VNORMAL, sensor.info.DEPTH,
sensor.info.VSHEAR,
) )
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}") print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}")
arrows = orisys.util.draw_arrows(sensor.img, flow, threshold=5, grid_spacing=10, arrow_scale=1.0) arrows = orisys.util.draw_arrows(sensor.img, flow, threshold=5, grid_spacing=10, arrow_scale=1.0)
cv2.imshow("形变矢量场", arrows) cv2.imshow("形变矢量场", arrows)
cv2.imshow("流场幅值图", draw_magnitude_map(flow)) if depth_map.max() > depth_map.min():
depth_normalized = (
(depth_map - depth_map.min()) / (depth_map.max() - depth_map.min()) * 255
).astype(np.uint8)
else:
depth_normalized = np.zeros_like(depth_map, dtype=np.uint8)
cv2.imshow("depth", cv2.applyColorMap(depth_normalized, cv2.COLORMAP_JET))
charts.push(time.time() - start_time, fn, fx, fy) charts.push(time.time() - start_time, fn, fx, fy)
display = charts.data.copy() charts.refresh()
for channel in range(3):
display[:, 1, channel] = lowpass_filter(
charts.data[:, 1, channel],
cutoff_hz=5,
sample_rate_hz=30,
)
charts.refresh(display)
key = cv2.waitKey(1) key = cv2.waitKey(1)
if key & 0xFF == ord("q"): if key & 0xFF == ord("q"):
-1
View File
@@ -1 +0,0 @@
"""Product-facing example entry points for ORY-BOX / ORY-MINI / ORY-FINGER."""
-115
View File
@@ -1,115 +0,0 @@
"""
ORY-BOX:单传感器基础流程
本示例演示 Orisys SDK 的标准处理流程:
1. 初始化传感器(摄像头或视频文件)
2. 获取图像并计算形变
3. 按需计算接触区域
4. 读取并可视化结果
SDK version: 0.4.2
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import argparse
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
parser.add_argument("--config","-c", type=str, default="ddjx01", help="配置文件名称或路径")
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
args = parser.parse_args()
# 步骤 1:创建传感器对象(必需)
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
# 输入既可以是摄像头编号,也可以是视频文件路径
try:
input = int(args.video)
is_camera = True
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
except:
is_camera = False
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
while True:
# 步骤 2:数据处理
# 步骤 2.1:获取并拼接图像(必需)
sensor.get_img()
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0)
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
# if is_contact_now:
sensor.compute_contact()
# 步骤 2.4:读取结果
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = sensor.read_info(
sensor.info.FPS,
sensor.info.VRAW,
sensor.info.VNORMAL,
sensor.info.IMG,
sensor.info.CONTOUR,
sensor.info.CENTROID,
sensor.info.DEPTH,
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY
)
# 步骤 3:可视化显示
# 步骤 3.1:显示形变矢量场
arrows = orisys.util.draw_arrows(
img,
flow,
threshold=2,
grid_spacing=20,
arrow_scale=1.0
)
cv2.imshow("arrow", arrows)
# 步骤 3.2:显示接触区域和质心
image_with_foe = orisys.util.draw_contact(img, contour, centroid)
cv2.imshow("contact", image_with_foe)
# 步骤 3.3:显示深度图
# 将深度值归一化到 0-255 范围
div_abs = depth_map
if div_abs.max() > div_abs.min():
div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
else:
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
# 应用伪彩色映射
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
# 步骤 3.4:打印关键结果
print(
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
f"切向力Y={fsheary:.4f}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
)
# 步骤 3.5:键盘控制
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
print("\n正在退出示例程序...")
break
if key == ord("r"):
# 当光流追踪出现漂移时,可手动重置追踪器
sensor.reset()
print("追踪器已重置。")
# =========================================================================
# 4. 释放资源
# =========================================================================
sensor.disconnect() # 释放视频源及相关资源
if __name__ == '__main__':
main()
@@ -1,111 +0,0 @@
#!/usr/bin/env python3
"""
ORY-FINGER:Minimum UV-unroll finger tactile pipeline.
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
Recommended entry:
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
Current config alias: ``dd02`` resolves to ``dd02-ov`` for compatibility.
"""
import argparse
import os
import sys
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
import cv2
import orisys
from orisys.finger import FingerRuntime
def main():
parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline")
parser.add_argument("--camera_id", type=int, default=0)
parser.add_argument("--video", help="Video file path (overrides camera)")
parser.add_argument("--config", "-c", default="dd02", help="Orisys SDK config name or path")
parser.add_argument("--show", action="store_true", default=True, help="Show unrolled image window")
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
args = parser.parse_args()
try:
video = args.camera_id
if args.video:
video = args.video
runtime = FingerRuntime.open(
config=args.config,
video=video,
verbose=args.verbose,
)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
if args.verbose:
geometry = runtime.geometry
print(
f"UV geometry: native={geometry.native_width}x{geometry.native_height}, "
f"source={geometry.source_width}x{geometry.source_height} "
f"output={geometry.out_width}x{geometry.out_height} "
)
runtime.warmup(color=False)
tactile = runtime.tactile
print("Press 'q' to quit, 'r' to reset tracker.")
try:
while True:
ok, unrolled = runtime.grab_and_process(color=False)
if not ok:
break
tactile.compute_deformation(check_motion=True, threshold=0)
tactile.compute_contact()
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = tactile.read_info(
tactile.info.FPS,
tactile.info.VRAW,
tactile.info.VNORMAL,
tactile.info.IMG,
tactile.info.CONTOUR,
tactile.info.CENTROID,
tactile.info.DEPTH,
tactile.info.FNORMAL,
tactile.info.FSHEARX,
tactile.info.FSHEARY,
)
status = (
f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) "
f"mask={tactile.mask_stats['coverage']:.2f}"
)
if args.show:
arrows = orisys.util.draw_arrows(
unrolled,
flow,
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
)
cv2.imshow("UV Unrolled", arrows)
print(status, end="\r")
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
print("\nExiting.")
break
if key == ord("r"):
tactile.reset()
print("\nTracker reset.")
finally:
runtime.close()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
@@ -1,85 +0,0 @@
#!/usr/bin/env python3
"""
ORY-FINGER:UV-unroll finger tactile pipeline with unified Qt dashboard.
Where to customize the GUI:
Layout (sizes, proportions) -> examples/gui/viewer3d/layout.py
Colors / QSS -> examples/gui/viewer3d/styles.py
Widget rendering logic -> examples/gui/viewer3d/widgets/
Window behavior -> examples/gui/viewer3d/dashboard.py
Recommended entry:
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
"""
import argparse
import os
import sys
from pathlib import Path
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
# Make shared packages importable when launched as a file path.
_EXAMPLES_DIR = Path(__file__).resolve().parents[2]
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
from gui.app_paths import configure_frozen_runtime, default_config_path, ensure_examples_import_path
configure_frozen_runtime()
ensure_examples_import_path()
from gui import run_dashboard
from pipeline import PipelineConfig
from viz import DEFAULT_FINGER_VIZ_POSE
def _parse_args():
parser = argparse.ArgumentParser(
description="UV-unroll finger tactile pipeline with unified Qt dashboard"
)
parser.add_argument("--video", "-v", default="0", help="Camera index or video file path")
parser.add_argument("--config", "-c", default=default_config_path(), help="Orisys SDK config")
parser.add_argument("--cal", default="", help="Optional calibration file path")
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
parser.add_argument(
"--mesh-map",
choices=["flow", "depth"],
default="depth",
help="Color the 3D mesh from flow magnitude or contact depth.",
)
parser.add_argument("--threshold", type=float, default=None, help="Mesh/arrow threshold")
parser.add_argument("--no-3d", action="store_true", help="Disable 3D mesh view")
parser.add_argument(
"--viz-pose",
default=DEFAULT_FINGER_VIZ_POSE,
help="world_to_cam.npy for the default view; pass 'none' to skip",
)
parser.add_argument(
"--cutoff",
type=float,
default=5.0,
help="Low-pass cutoff for force curves (Hz)",
)
parser.add_argument(
"--profile",
action="store_true",
help="Print per-stage timing to the log panel / console",
)
parser.add_argument(
"--profile-every",
type=int,
default=30,
help="When --profile is set, print rolling averages every N frames",
)
return parser.parse_args()
def main():
args = _parse_args()
config = PipelineConfig.from_args(args)
sys.exit(run_dashboard(config))
if __name__ == "__main__":
main()
+3 -3
View File
@@ -18,7 +18,7 @@
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support() - Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
- 每个传感器进程都会占用独立的 GPU 显存 - 每个传感器进程都会占用独立的 GPU 显存
SDK version: 0.4.2 SDK version: 0.4.3
""" """
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
@@ -138,12 +138,12 @@ def main():
{ {
'sensor_id': 0, 'sensor_id': 0,
'vid_src': args.video1, 'vid_src': args.video1,
'config_name': './config/ddjx01.json' 'config_name': 'box'
}, },
{ {
'sensor_id': 1, 'sensor_id': 1,
'vid_src': args.video2, # 可替换为其他视频源或摄像头索引 'vid_src': args.video2, # 可替换为其他视频源或摄像头索引
'config_name': './config/ddjx01.json' 'config_name': 'box'
}, },
] ]
+2 -29
View File
@@ -1,32 +1,5 @@
"""Signal filtering helpers for example force charts.""" """Signal filtering helpers for example force charts."""
from __future__ import annotations from orisys.signal_processing import lowpass_filter
import numpy as np __all__ = ["lowpass_filter"]
from scipy.signal import butter, filtfilt
def lowpass_filter(data, cutoff_hz=5, sample_rate_hz=30, order=2) -> np.ndarray:
"""Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data."""
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0:
return y.copy()
if y.size == 0:
return y
nyquist = 0.5 * sample_rate_hz
if cutoff_hz < 0 or cutoff_hz >= nyquist:
raise ValueError(
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
)
b, a = butter(order, cutoff_hz / nyquist, btype="low")
taplen = max(len(a), len(b))
# scipy.signal.filtfilt requires len(x) > padlen (default 3 * taplen for recent scipy).
padlen = 3 * taplen
if y.size <= padlen:
return y.copy()
try:
return filtfilt(b, a, y)
except ValueError:
return y.copy()
+10 -10
View File
@@ -1,5 +1,5 @@
[build-system] [build-system]
requires = ["setuptools>=61.0", "wheel"] requires = ["setuptools>=61.0,<80", "wheel==0.45.1"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
@@ -75,11 +75,19 @@ patterns = ["*.json"]
# files = ["dd02-ov.json", "ddjx01.json"] # files = ["dd02-ov.json", "ddjx01.json"]
dev_install = true dev_install = true
[[tool.orisys.bundle.items]]
name = "sensors"
source = "sensors"
target = "sensors"
patterns = ["**/*.json", "finger/assets/30fps*/*", "finger/assets/visualization/*"]
preserve_tree = true
dev_install = true
[[tool.orisys.bundle.items]] [[tool.orisys.bundle.items]]
name = "examples" name = "examples"
source = "examples" source = "examples"
target = "examples" target = "examples"
patterns = ["*.py", "README.md", "products/**/*.py", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"] patterns = ["*.py", "README.md", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"]
preserve_tree = true preserve_tree = true
[[tool.orisys.bundle.items]] [[tool.orisys.bundle.items]]
@@ -88,11 +96,3 @@ source = "docs"
target = "docs" target = "docs"
patterns = ["*.md", "*.pdf"] patterns = ["*.md", "*.pdf"]
[[tool.orisys.bundle.items]]
name = "assets"
source = "assets"
target = "assets"
patterns = ["finger/30fps/*", "finger/visualization/*"]
preserve_tree = true
dev_install = true
+25 -11
View File
@@ -1,13 +1,27 @@
# CuPy - Install the appropriate version based on your CUDA version: # Runtime dependencies for editable / source installs.
# For CUDA 12.x: pip install cupy-cuda12x>=12.3.0 # Prefer extras from pyproject.toml:
# For CUDA 13.x: pip install cupy-cuda13x>=13.0.0 # pip install -e .
# Or use extras: pip install orisys[cuda12] or pip install orisys[cuda13] # pip install -e ".[gui]"
# pip install -e ".[cuda12]"
# pip install -e ".[cuda13]"
# pip install -e ".[gui,cuda12]"
# PyPI users: pip install orisys / "orisys[gui]" / "orisys[cuda12]"
# Core (same as [project.dependencies])
numpy>=1.24.4 numpy>=1.24.4
opencv_python>=4.10.0.84 opencv-python>=4.10.0.84
scipy==1.13 scipy>=1.10.1
setuptools>=45.2.0 numba>=0.59.0
av
numba # GUI extras: pip install -e ".[gui]"
pyqt5 # open3d>=0.17.0
pyqtgraph # pyqt5>=5.15
# pyqtgraph>=0.13
# CUDA extras (pick one CUDA major version)
# CUDA 12.x: pip install -e ".[cuda12]" -> cupy-cuda12x>=12.3.0
# CUDA 13.x: pip install -e ".[cuda13]" -> cupy-cuda13x>=13.0.0
# Alias: pip install -e ".[cuda]" -> same as cuda12
# Optional video tooling (not an orisys extra)
# av
+1 -1
View File
@@ -115,7 +115,7 @@ def main():
writer.write(save_img) writer.write(save_img)
sensor1.compute_deformation(check_motion=False) # 计算形变和分解 sensor1.compute_deformation() # 计算形变和分解
# 提取信息: # 提取信息:
# 向量场: VRAW, VNORMAL, VSHEAR: 原始,法向,切向 # 向量场: VRAW, VNORMAL, VSHEAR: 原始,法向,切向
# 帧率,时间戳: FPS, TIMESTAMP # 帧率,时间戳: FPS, TIMESTAMP
-961
View File
@@ -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()
-49
View File
@@ -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()
-159
View File
@@ -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()
+8
View File
@@ -0,0 +1,8 @@
{
"normal_force": {
"model": "linear",
"slope": 3.92685554668795e-05,
"offset": 0.0217596967278535,
"unit": "N"
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365,263,123,63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1,2,3,6,7,8,11,12,13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3,3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":5,
"div_threshold":0.05,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 12
}
}
@@ -0,0 +1,9 @@
Place the ORY-FINGER runtime asset bundle files in this directory before
building release wheels:
- `mesh.npz`
- `camera_intrinsics.npz`
- `world_to_cam.npy`
These binary assets are validated by the wheel build and are intentionally not
generated by the package build step.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
Place visualization camera assets in this directory before building release
wheels:
- `world_to_cam.npy`
The 3D dashboard uses this pose as the default view preset when available.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "finger",
"sensor_json": "sensor.json",
"assets_dir": "assets/30fps-0703",
"viz_pose": "assets/visualization/world_to_cam.npy"
}
+35
View File
@@ -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
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"normal_force": {
"model": "linear",
"slope": 3.92685554668795e-05,
"offset": 0.0217596967278535,
"unit": "N"
}
}
+46
View File
@@ -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
}
}
+2 -2
View File
@@ -6,8 +6,8 @@ import sys
from setuptools import setup from setuptools import setup
REPO_ROOT = Path(__file__).resolve().parent REPO_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(REPO_ROOT / "build")) sys.path.insert(0, str(REPO_ROOT / "build" / "common"))
from common.pyproject_utils import bundle_data_files from pyproject_utils import bundle_data_files
setup(data_files=bundle_data_files(REPO_ROOT)) setup(data_files=bundle_data_files(REPO_ROOT))
+6 -2
View File
@@ -5,13 +5,14 @@ A Python library for processing and analyzing optical tactile sensor data,
including image stitching, optical flow tracking, and deformation decomposition. including image stitching, optical flow tracking, and deformation decomposition.
""" """
__version__ = "0.4.2" __version__ = "0.4.3"
__author__ = "Orisys" __author__ = "Orisys"
from .sensor import Sensor from .sensor import Sensor
from . import util from . import util
from . import configs from . import configs
from . import finger from . import finger
from . import signal_processing
from .frame_processors import FFCFrameProcessor from .frame_processors import FFCFrameProcessor
CompoundEyeSensor = Sensor CompoundEyeSensor = Sensor
@@ -33,9 +34,12 @@ __all__ = [
# finger runtime helpers # finger runtime helpers
'finger', 'finger',
# signal helpers
'signal_processing',
# configs utilities functions # configs utilities functions
'list_config', 'list_config',
'export_config' 'export_config',
# test # test
'FFCFrameProcessor' 'FFCFrameProcessor'
+131 -4
View File
@@ -1,12 +1,28 @@
import json import json
from contextlib import ExitStack
from dataclasses import dataclass
from importlib.resources import as_file, files
from pathlib import Path from pathlib import Path
# Product-facing names that map to on-disk config stems. # Product-facing names that map to on-disk config stems.
# ORY-FINGER currently ships as dd02-ov.json; keep "dd02" as the stable alias. # Public product stems are ``box.json`` and ``finger.json``.
# Keep older lab names working: ``dd02`` still resolves to ``dd02-ov``.
CONFIG_ALIASES = { CONFIG_ALIASES = {
"dd02": "dd02-ov", "dd02": "dd02-ov",
} }
PRODUCT_BUNDLES = ("box", "mini", "finger")
_RESOURCE_STACK = ExitStack()
def _resource_path(*parts: str) -> Path | None:
"""Resolve a package resource to a filesystem path for wheel-safe loaders."""
try:
resource = files("orisys").joinpath(*parts)
return _RESOURCE_STACK.enter_context(as_file(resource))
except Exception:
return None
def _candidate_config_dirs() -> list[Path]: def _candidate_config_dirs() -> list[Path]:
""" """
@@ -27,6 +43,26 @@ def _candidate_config_dirs() -> list[Path]:
return unique return unique
def _candidate_sensor_roots() -> list[Path]:
"""Candidate roots that contain per-sensor bundle folders."""
here = Path(__file__).resolve().parent
candidates = []
packaged = _resource_path("sensors")
if packaged is not None:
candidates.append(packaged) # packaged wheel layout: orisys/sensors/
candidates.extend([
here / "sensors",
here.parent.parent / "sensors", # editable install: repo_root/sensors/
here.parent / "sensors",
Path.cwd() / "sensors",
])
unique: list[Path] = []
for path in candidates:
if path not in unique:
unique.append(path)
return unique
def _get_config_dir() -> Path: def _get_config_dir() -> Path:
for candidate in _candidate_config_dirs(): for candidate in _candidate_config_dirs():
if candidate.exists() and any(candidate.glob("*.json")): if candidate.exists() and any(candidate.glob("*.json")):
@@ -49,14 +85,96 @@ def _load_json(path: Path) -> dict:
return json.load(f) return json.load(f)
@dataclass(frozen=True)
class SensorBundle:
"""Resolved paths for one additive per-sensor package folder."""
name: str
root: Path
sensor_json: Path
force_calibration_json: Path | None = None
manifest_json: Path | None = None
assets_dir: Path | None = None
def resolve_sensor_bundle(name: str) -> SensorBundle | None:
"""Resolve ``sensors/<name>/`` when present; otherwise return ``None``."""
requested = CONFIG_ALIASES.get(str(name), str(name))
for root in _candidate_sensor_roots():
bundle_root = root / requested
sensor_json = bundle_root / "sensor.json"
if not sensor_json.is_file():
continue
force_path = bundle_root / "force_calibration.json"
manifest_path = bundle_root / "manifest.json"
assets_dir = None
if manifest_path.is_file():
try:
manifest = _load_json(manifest_path)
except Exception:
manifest = {}
rel_assets = manifest.get("assets_dir")
if isinstance(rel_assets, str) and rel_assets.strip():
candidate = (bundle_root / rel_assets).resolve()
if candidate.is_dir():
assets_dir = candidate
return SensorBundle(
name=requested,
root=bundle_root.resolve(),
sensor_json=sensor_json.resolve(),
force_calibration_json=force_path.resolve() if force_path.is_file() else None,
manifest_json=manifest_path.resolve() if manifest_path.is_file() else None,
assets_dir=assets_dir,
)
return None
def resolve_sensor_config_path(name: str) -> Path | None:
"""Return ``sensor.json`` for a resolved sensor bundle."""
bundle = resolve_sensor_bundle(name)
return bundle.sensor_json if bundle is not None else None
def resolve_sensor_asset_dir(name: str) -> Path | None:
"""Return the default asset directory for a resolved sensor bundle."""
bundle = resolve_sensor_bundle(name)
return bundle.assets_dir if bundle is not None else None
def resolve_sensor_resource(name: str, *relative_parts: str) -> Path | None:
"""Return a path inside one sensor bundle when present."""
bundle = resolve_sensor_bundle(name)
if bundle is None:
return None
return bundle.root.joinpath(*relative_parts)
def list_sensor_bundles() -> list[str]:
"""Return product names that have an additive ``sensors/<name>/`` folder."""
found: list[str] = []
for name in PRODUCT_BUNDLES:
if resolve_sensor_bundle(name) is not None:
found.append(name)
return found
def load_force_calibration(name: str) -> dict | None:
"""Load ``force_calibration.json`` from a sensor bundle when available."""
bundle = resolve_sensor_bundle(name)
if bundle is None or bundle.force_calibration_json is None:
return None
return _load_json(bundle.force_calibration_json)
def load_config(config_name_or_path): def load_config(config_name_or_path):
""" """
Load sensor configuration from a JSON file path or config name. Load sensor configuration from a JSON file path or config name.
Config lookup order: Config lookup order:
1. Exact file path (absolute or relative) 1. Exact file path (absolute or relative)
2. File stem under the config directory (e.g. ``ddjx01`` -> ``config/ddjx01.json``) 2. Additive sensor bundle ``sensors/<name>/sensor.json``
3. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible) 3. File stem under the config directory (e.g. ``box`` -> ``config/box.json``)
4. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
""" """
config_path = Path(config_name_or_path) config_path = Path(config_name_or_path)
@@ -74,6 +192,11 @@ def load_config(config_name_or_path):
if alias and alias not in candidates: if alias and alias not in candidates:
candidates.append(alias) candidates.append(alias)
for name in candidates:
bundle = resolve_sensor_bundle(name)
if bundle is not None:
return _load_json(bundle.sensor_json)
for name in candidates: for name in candidates:
direct = CONFIG_DIR / f"{name}.json" direct = CONFIG_DIR / f"{name}.json"
if direct.exists(): if direct.exists():
@@ -102,7 +225,11 @@ def list_config():
""" """
List all available JSON configuration files by file stem. List all available JSON configuration files by file stem.
""" """
return [f.stem for f in _config_files()] stems = [f.stem for f in _config_files()]
for name in list_sensor_bundles():
if name not in stems:
stems.append(name)
return sorted(stems)
def export_config(config_name=None, path="./config/exported_config.json"): def export_config(config_name=None, path="./config/exported_config.json"):
+9 -4
View File
@@ -4,6 +4,7 @@ import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ..configs import resolve_sensor_asset_dir
from ..unroll import ( from ..unroll import (
compute_uv_distortion_metrics, compute_uv_distortion_metrics,
load_exported_data, load_exported_data,
@@ -11,7 +12,7 @@ from ..unroll import (
scaled_map_cache_path, scaled_map_cache_path,
) )
DEFAULT_FINGER_EXPORT_DIR = "assets/finger/30fps-0703" DEFAULT_FINGER_EXPORT_DIR = "sensors/finger/assets/30fps-0703"
DEFAULT_FINGER_MAP_CACHE = f"{DEFAULT_FINGER_EXPORT_DIR}/unroll_map_uv.npz" DEFAULT_FINGER_MAP_CACHE = f"{DEFAULT_FINGER_EXPORT_DIR}/unroll_map_uv.npz"
@@ -44,11 +45,15 @@ def _candidate_export_dirs(export_dir) -> list[Path]:
return [raw] return [raw]
here = Path(__file__).resolve() here = Path(__file__).resolve()
candidates = [ candidates = []
bundled_assets = resolve_sensor_asset_dir("finger")
if bundled_assets is not None and raw.as_posix() == DEFAULT_FINGER_EXPORT_DIR:
candidates.append(bundled_assets)
candidates.extend([
Path.cwd() / raw, Path.cwd() / raw,
here.parents[1] / raw, # packaged wheel: orisys/assets/... here.parents[1] / raw, # packaged wheel filesystem layout
here.parents[3] / raw, # editable install: repo/assets/... here.parents[3] / raw, # editable install: repo/assets/...
] ])
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
candidates.insert(0, Path(sys.executable).resolve().parent / raw) candidates.insert(0, Path(sys.executable).resolve().parent / raw)
unique: list[Path] = [] unique: list[Path] = []
+2 -2
View File
@@ -146,7 +146,7 @@ class FingerGeometry:
def resolve_finger_geometry( def resolve_finger_geometry(
*, *,
export_dir, export_dir,
config="./config/dd02-ov.json", config="finger",
map_cache=DEFAULT_FINGER_MAP_CACHE, map_cache=DEFAULT_FINGER_MAP_CACHE,
source_scale=None, source_scale=None,
calib=None, calib=None,
@@ -223,7 +223,7 @@ class FingerRuntime:
cls, cls,
*, *,
export_dir=DEFAULT_FINGER_EXPORT_DIR, export_dir=DEFAULT_FINGER_EXPORT_DIR,
config="./config/dd02-ov.json", config="finger",
map_cache=DEFAULT_FINGER_MAP_CACHE, map_cache=DEFAULT_FINGER_MAP_CACHE,
source_scale=None, source_scale=None,
calib=None, calib=None,
+106 -3
View File
@@ -23,6 +23,7 @@ from . import configs
from .get_depth import Contact3D from .get_depth import Contact3D
from .backend_availability import (check_cupy_available, check_opencl_available) from .backend_availability import (check_cupy_available, check_opencl_available)
from .frame_processors import create_frame_processor from .frame_processors import create_frame_processor
from .signal_processing import DEFAULT_SAMPLE_RATE_HZ, LowpassFilter
class Sensor: class Sensor:
""" """
@@ -46,6 +47,9 @@ class Sensor:
FNORMAL = auto() FNORMAL = auto()
FSHEARX = auto() FSHEARX = auto()
FSHEARY = auto() FSHEARY = auto()
FNORMAL_RAW = auto()
FSHEARX_RAW = auto()
FSHEARY_RAW = auto()
CENTROID = auto() CENTROID = auto()
CONTOUR = auto() CONTOUR = auto()
DEPTH = auto() DEPTH = auto()
@@ -126,6 +130,17 @@ class Sensor:
self.vshear = self.vtemplate self.vshear = self.vtemplate
self.n_boxes = None self.n_boxes = None
self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0 self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0
self._force_raw = (0.0, 0.0, 0.0)
self._lowpass_enabled = False
self._lowpass_cutoff_hz = 0.0
self._lowpass_sample_rate_hz = float(DEFAULT_SAMPLE_RATE_HZ)
self._lowpass_order = 2
self._force_filter = LowpassFilter(
cutoff_hz=self._lowpass_cutoff_hz,
sample_rate_hz=self._lowpass_sample_rate_hz,
order=self._lowpass_order,
n_channels=3,
)
# Contact Data Variables (public) # Contact Data Variables (public)
self.centroid = (0, 0) self.centroid = (0, 0)
@@ -397,7 +412,7 @@ class Sensor:
motion_value = mag.mean() motion_value = mag.mean()
return motion_value > threshold return motion_value > threshold
def compute_deformation(self, decompose=True, check_motion=True, threshold=6, tail_frames=10): def compute_deformation(self, decompose=True, check_motion=False, threshold=6, tail_frames=10):
""" """
Compute optical flow and optionally decompose it into force components. Compute optical flow and optionally decompose it into force components.
@@ -529,7 +544,10 @@ class Sensor:
self._hhd_grid_shape = ds_shape self._hhd_grid_shape = ds_shape
_, forces = self.__hhd.get_force(flow_ds, valid_mask=mask_ds) _, forces = self.__hhd.get_force(flow_ds, valid_mask=mask_ds)
self.fnormal, self.fshearx, self.fsheary, self.ftor = forces fn, fx, fy, ftor = forces
self._force_raw = (float(fn), float(fx), float(fy))
self.ftor = float(ftor)
self._apply_force_filter()
vnormal_ds = self.__hhd.get_normal() vnormal_ds = self.__hhd.get_normal()
vshear_ds = self.__hhd.get_shear() vshear_ds = self.__hhd.get_shear()
@@ -631,6 +649,9 @@ class Sensor:
self.info.FNORMAL: lambda: self.fnormal, self.info.FNORMAL: lambda: self.fnormal,
self.info.FSHEARX: lambda: self.fshearx, self.info.FSHEARX: lambda: self.fshearx,
self.info.FSHEARY: lambda: self.fsheary, self.info.FSHEARY: lambda: self.fsheary,
self.info.FNORMAL_RAW: lambda: self._force_raw[0],
self.info.FSHEARX_RAW: lambda: self._force_raw[1],
self.info.FSHEARY_RAW: lambda: self._force_raw[2],
self.info.FPS: lambda: self._fps, self.info.FPS: lambda: self._fps,
self.info.TIMESTAMP: lambda: time.strftime('%H:%M:%S'), self.info.TIMESTAMP: lambda: time.strftime('%H:%M:%S'),
# Return copies of arrays to prevent external modification # Return copies of arrays to prevent external modification
@@ -649,8 +670,9 @@ class Sensor:
return tuple(mapping.get(arg, lambda: None)() for arg in args if isinstance(arg, self.info)) return tuple(mapping.get(arg, lambda: None)() for arg in args if isinstance(arg, self.info))
def reset(self): def reset(self):
"""Reset optical flow tracker.""" """Reset optical flow tracker and force-filter state."""
self.__tracker.reset() self.__tracker.reset()
self._force_filter.reset()
if self.img is not None: if self.img is not None:
self.initial_img = self.img.copy() self.initial_img = self.img.copy()
@@ -680,6 +702,87 @@ class Sensor:
# Public API - Configuration & Calibration # Public API - Configuration & Calibration
# ========================================================================= # =========================================================================
def configure(
self,
*,
lowpass_cutoff_hz=None,
lowpass_sample_rate_hz=None,
lowpass_order=None,
lowpass_enabled=None,
hhd_downsample=None,
):
"""Update runtime-safe settings without reconnecting the camera.
Parameters
----------
lowpass_cutoff_hz : float, optional
Force low-pass cutoff in Hz. ``0`` disables filtering. A positive
value enables filtering unless ``lowpass_enabled=False``.
lowpass_sample_rate_hz : float, optional
Assumed force sample rate for the Butterworth design (default 30).
lowpass_order : int, optional
Butterworth order (default 2).
lowpass_enabled : bool, optional
Explicit on/off for the causal force filter.
hhd_downsample : int, optional
Integer ``>= 1``. Rebuilds the HHD solver on the next frame.
"""
filter_changed = False
if lowpass_cutoff_hz is not None:
self._lowpass_cutoff_hz = float(lowpass_cutoff_hz)
filter_changed = True
if lowpass_enabled is None:
self._lowpass_enabled = self._lowpass_cutoff_hz > 0
if lowpass_sample_rate_hz is not None:
rate = float(lowpass_sample_rate_hz)
if rate <= 0:
raise ValueError("lowpass_sample_rate_hz must be > 0")
self._lowpass_sample_rate_hz = rate
filter_changed = True
if lowpass_order is not None:
order = int(lowpass_order)
if order < 1:
raise ValueError("lowpass_order must be >= 1")
self._lowpass_order = order
filter_changed = True
if lowpass_enabled is not None:
self._lowpass_enabled = bool(lowpass_enabled)
filter_changed = True
if filter_changed:
cutoff = self._lowpass_cutoff_hz if self._lowpass_enabled else 0.0
self._force_filter.configure(
cutoff_hz=cutoff,
sample_rate_hz=self._lowpass_sample_rate_hz,
order=self._lowpass_order,
)
if self.compute_deformation_valid:
self._apply_force_filter()
if hhd_downsample is not None:
try:
k = max(1, int(hhd_downsample))
except (TypeError, ValueError) as exc:
raise ValueError("hhd_downsample must be an integer >= 1") from exc
if k != self._hhd_downsample:
self._hhd_downsample = k
self.__hhd = None
self._hhd_grid_shape = None
self._update_hhd_mask_cache()
return self
def _apply_force_filter(self):
raw = np.asarray(self._force_raw, dtype=np.float64)
if self._lowpass_enabled and self._lowpass_cutoff_hz > 0:
filtered = self._force_filter.process(raw)
self.fnormal, self.fshearx, self.fsheary = (float(v) for v in filtered)
else:
self.fnormal, self.fshearx, self.fsheary = (
float(raw[0]),
float(raw[1]),
float(raw[2]),
)
# def export_config(self, path="./config/exported_config.json"): # def export_config(self, path="./config/exported_config.json"):
# """Return current configuration dict.""" # """Return current configuration dict."""
# configs.export_config(self._config, path) # configs.export_config(self._config, path)
+159
View File
@@ -0,0 +1,159 @@
"""Signal helpers for force filtering and normal-force calibration.
Batch ``lowpass_filter`` is the zero-phase Butterworth used by example charts.
``LowpassFilter`` is a causal, stateful filter for per-frame ``Sensor`` outputs.
"""
from __future__ import annotations
import numpy as np
from scipy.signal import butter, filtfilt, sosfilt, sosfilt_zi
# Linear calibration from raw normal-force units to Newtons (ORY-BOX / ORY-MINI).
# Prefer sensors/<name>/force_calibration.json when available.
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
DEFAULT_SAMPLE_RATE_HZ = 30.0
DEFAULT_LOWPASS_ORDER = 2
def _load_normal_force_coeffs(sensor_name: str | None = None) -> tuple[float, float]:
if sensor_name:
try:
from .configs import load_force_calibration
data = load_force_calibration(sensor_name)
except Exception:
data = None
if isinstance(data, dict):
block = data.get("normal_force", data)
if isinstance(block, dict):
slope = block.get("slope")
offset = block.get("offset")
if slope is not None and offset is not None:
return float(slope), float(offset)
return NORMAL_FORCE_CAL_SLOPE, NORMAL_FORCE_CAL_OFFSET
def calibrate_normal_force(raw_value: float, *, sensor_name: str | None = None) -> float:
"""Convert a raw normal-force reading to Newtons using y = mx + c."""
slope, offset = _load_normal_force_coeffs(sensor_name)
return slope * float(raw_value) + offset
def _nyquist_hz(sample_rate_hz: float) -> float:
return 0.5 * float(sample_rate_hz)
def _validate_cutoff(cutoff_hz: float, sample_rate_hz: float) -> None:
nyquist = _nyquist_hz(sample_rate_hz)
if cutoff_hz < 0 or cutoff_hz >= nyquist:
raise ValueError(
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
)
def lowpass_filter(
data,
cutoff_hz: float = 5.0,
sample_rate_hz: float = DEFAULT_SAMPLE_RATE_HZ,
order: int = DEFAULT_LOWPASS_ORDER,
) -> np.ndarray:
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data.
Matches the example/viewer chart filter: ``cutoff_hz == 0`` is a no-op.
"""
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0:
return y.copy()
if y.size == 0:
return y
_validate_cutoff(cutoff_hz, sample_rate_hz)
b, a = butter(int(order), cutoff_hz / _nyquist_hz(sample_rate_hz), btype="low")
padlen = 3 * (max(len(a), len(b)) - 1)
if y.size <= padlen:
return y.copy()
try:
return filtfilt(b, a, y)
except ValueError:
return y.copy()
class LowpassFilter:
"""Causal Butterworth low-pass with persistent SOS state (one sample at a time)."""
def __init__(
self,
cutoff_hz: float = 0.0,
sample_rate_hz: float = DEFAULT_SAMPLE_RATE_HZ,
order: int = DEFAULT_LOWPASS_ORDER,
n_channels: int = 3,
):
self.n_channels = int(n_channels)
self._sos = None
self._zi = None
self.configure(
cutoff_hz=cutoff_hz,
sample_rate_hz=sample_rate_hz,
order=order,
)
@property
def enabled(self) -> bool:
return self._sos is not None
def configure(
self,
*,
cutoff_hz: float | None = None,
sample_rate_hz: float | None = None,
order: int | None = None,
) -> None:
if cutoff_hz is not None:
self.cutoff_hz = float(cutoff_hz)
if sample_rate_hz is not None:
self.sample_rate_hz = float(sample_rate_hz)
if order is not None:
self.order = int(order)
if self.cutoff_hz <= 0:
self._sos = None
self._zi = None
return
_validate_cutoff(self.cutoff_hz, self.sample_rate_hz)
self._sos = butter(
self.order,
self.cutoff_hz / _nyquist_hz(self.sample_rate_hz),
btype="low",
output="sos",
)
self._zi = None
def reset(self, x0=None) -> None:
"""Clear filter memory. Optional ``x0`` initializes to a steady-state DC."""
if self._sos is None:
self._zi = None
return
if x0 is None:
self._zi = None
return
x = np.asarray(x0, dtype=np.float64).reshape(-1)
if x.size != self.n_channels:
raise ValueError(f"x0 must have {self.n_channels} channels, got {x.size}")
zi0 = sosfilt_zi(self._sos)
self._zi = zi0[:, :, None] * x[None, None, :]
def process(self, values) -> np.ndarray:
"""Filter one multi-channel sample. Returns float64 array of shape (n_channels,)."""
x = np.asarray(values, dtype=np.float64).reshape(-1)
if x.size != self.n_channels:
raise ValueError(f"expected {self.n_channels} channels, got {x.size}")
if self._sos is None:
return x.copy()
if self._zi is None:
self.reset(x)
y, self._zi = sosfilt(self._sos, x[None, :], zi=self._zi, axis=0)
return np.asarray(y[0], dtype=np.float64)
+1 -1
View File
@@ -8,7 +8,7 @@ Flow:
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True) 4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
5. Compute deformation/contact and visualize 5. Compute deformation/contact and visualize
SDK version: 0.4.2 SDK version: 0.4.3
""" """
from __future__ import annotations from __future__ import annotations
+1 -1
View File
@@ -35,7 +35,7 @@ def test_pyproject_dynamic_version(packaging_utils):
sys.path.insert(0, str(build_dir)) sys.path.insert(0, str(build_dir))
from common.pyproject_utils import get_version from common.pyproject_utils import get_version
assert get_version(REPO_ROOT) == "0.4.2" assert get_version(REPO_ROOT) == "0.4.3"
def test_pyproject_bundle_manifest(packaging_utils): def test_pyproject_bundle_manifest(packaging_utils):
+2
View File
@@ -27,6 +27,7 @@ from gui.sensors import ( # noqa: E402
_snapshot_from_sensor, _snapshot_from_sensor,
calibrate_normal_force, calibrate_normal_force,
) )
from orisys.signal_processing import calibrate_normal_force as sdk_calibrate_normal_force
class FakeForceAdapter: class FakeForceAdapter:
@@ -163,3 +164,4 @@ def test_snapshot_requests_hhd_every_frame_without_motion_gate():
assert snap.shear_x == pytest.approx(-100.0) assert snap.shear_x == pytest.approx(-100.0)
assert snap.shear_y == pytest.approx(50.0) assert snap.shear_y == pytest.approx(50.0)
assert snap.normal_force_calibrated == pytest.approx(calibrate_normal_force(12345.0)) assert snap.normal_force_calibrated == pytest.approx(calibrate_normal_force(12345.0))
assert snap.normal_force_calibrated == pytest.approx(sdk_calibrate_normal_force(12345.0))
+163
View File
@@ -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()
+6 -6
View File
@@ -19,25 +19,25 @@ def wheel_package():
def test_compliant_manylinux_tags(wheel_package): def test_compliant_manylinux_tags(wheel_package):
assert wheel_package.is_compliant_manylinux_wheel( assert wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.4.2-cp310-cp310-manylinux_2_17_x86_64.whl") Path("orisys-0.4.3-cp310-cp310-manylinux_2_17_x86_64.whl")
) )
assert wheel_package.is_compliant_manylinux_wheel( assert wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.4.2-cp310-cp310-manylinux2014_x86_64.whl") Path("orisys-0.4.3-cp310-cp310-manylinux2014_x86_64.whl")
) )
assert wheel_package.is_compliant_manylinux_wheel( assert wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.4.2-cp310-cp310-manylinux_2_28_x86_64.whl") Path("orisys-0.4.3-cp310-cp310-manylinux_2_28_x86_64.whl")
) )
def test_rejects_illegal_manylinux_tags(wheel_package): def test_rejects_illegal_manylinux_tags(wheel_package):
assert not wheel_package.is_compliant_manylinux_wheel( assert not wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.4.2-cp310-cp310-manylinux_x86_64.whl") Path("orisys-0.4.3-cp310-cp310-manylinux_x86_64.whl")
) )
assert not wheel_package.is_compliant_manylinux_wheel( assert not wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.4.2-cp310-cp310-linux_x86_64.whl") Path("orisys-0.4.3-cp310-cp310-linux_x86_64.whl")
) )
assert not wheel_package.is_compliant_manylinux_wheel( assert not wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.4.2-cp310-cp310-win_amd64.whl") Path("orisys-0.4.3-cp310-cp310-win_amd64.whl")
) )
+48
View File
@@ -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')\""
]
}
]
}
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Plan and apply downstream file syncs from tools/downstream_sync_manifest.json.
The ``apply`` command copies files/directories listed in the manifest. It does
not delete extra downstream files and does not run downstream tests.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from pathlib import Path
MANIFEST_NAME = "downstream_sync_manifest.json"
def sdk_root() -> Path:
return Path(__file__).resolve().parent.parent
def load_manifest(root: Path) -> dict:
path = Path(__file__).resolve().parent / MANIFEST_NAME
with path.open(encoding="utf-8") as handle:
data = json.load(handle)
if data.get("version") != 1:
raise SystemExit(f"unsupported manifest version: {data.get('version')}")
if not isinstance(data.get("downstreams"), list):
raise SystemExit("manifest is missing a downstreams list")
return data
def selected_downstreams(manifest: dict, target: str | None) -> list[dict]:
items = manifest["downstreams"]
if target is None:
return items
matches = [item for item in items if item.get("name") == target]
if not matches:
names = ", ".join(item.get("name", "<unnamed>") for item in items)
raise SystemExit(f"unknown target {target!r}; known: {names}")
return matches
def expand_jobs(root: Path, item: dict) -> list[tuple[Path, Path]]:
dest_root = (root / item["path"]).resolve()
jobs: list[tuple[Path, Path]] = []
for entry in item.get("sync", []):
src_base = root / entry["from"]
dest_base = dest_root / entry["to"]
includes = entry.get("include")
if includes:
for name in includes:
jobs.append((src_base / name, dest_base / name))
else:
jobs.append((src_base, dest_base))
return jobs
def print_plan(root: Path, items: list[dict]) -> None:
print(f"SDK root: {root}")
print()
for item in items:
dest_root = (root / item["path"]).resolve()
print(f"== {item['name']} ==")
print(f"path: {dest_root}")
jobs = expand_jobs(root, item)
if jobs:
print("sync:")
for src, dest in jobs:
print(f" {src} -> {dest}")
else:
print("sync: (none)")
checks = item.get("checks") or []
if checks:
print("checks:")
for command in checks:
print(f" {command}")
else:
print("checks: (none)")
print()
def check_paths(root: Path, items: list[dict]) -> int:
missing: list[str] = []
print(f"SDK root: {root}")
print()
for item in items:
dest_root = (root / item["path"]).resolve()
print(f"== {item['name']} ==")
if dest_root.is_dir():
print(f"ok downstream root: {dest_root}")
else:
print(f"MISS downstream root: {dest_root}")
missing.append(str(dest_root))
for src, dest in expand_jobs(root, item):
src_ok = src.exists()
dest_note = "exists" if dest.exists() else "missing (ok until apply)"
status = "ok " if src_ok else "MISS"
print(f"{status} source: {src}")
print(f" dest: {dest} [{dest_note}]")
if not src_ok:
missing.append(str(src))
print()
if missing:
print(f"{len(missing)} missing path(s).")
return 1
print("all required source and downstream roots exist.")
return 0
def copy_job(src: Path, dest: Path) -> None:
if not src.exists():
raise FileNotFoundError(src)
if src.is_dir():
shutil.copytree(src, dest, dirs_exist_ok=True)
return
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
def apply_sync(root: Path, items: list[dict]) -> int:
if check_paths(root, items) != 0:
print("apply aborted because required paths are missing.")
return 1
copied = 0
for item in items:
print(f"== {item['name']} ==")
for src, dest in expand_jobs(root, item):
copy_job(src, dest)
copied += 1
print(f"copied {src} -> {dest}")
print()
print(f"copied {copied} sync job(s).")
print("checks were not run; execute the listed check commands manually.")
return 0
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
plan = sub.add_parser("plan", help="print intended sync jobs and checks")
plan.add_argument("--target", help="limit to one downstream name")
check = sub.add_parser("check-paths", help="verify source and dest roots exist")
check.add_argument("--target", help="limit to one downstream name")
apply_cmd = sub.add_parser("apply", help="copy manifest sources to downstreams")
apply_cmd.add_argument("--target", help="limit to one downstream name")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv if argv is not None else sys.argv[1:])
root = sdk_root()
items = selected_downstreams(load_manifest(root), getattr(args, "target", None))
if args.command == "plan":
print_plan(root, items)
return 0
if args.command == "check-paths":
return check_paths(root, items)
if args.command == "apply":
return apply_sync(root, items)
raise SystemExit(f"unknown command: {args.command}")
if __name__ == "__main__":
raise SystemExit(main())