included low pass filter in package and add to plot_data example
This commit is contained in:
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
@@ -3,13 +3,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
|
import importlib.util
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_BUILD_ROOT = Path(__file__).resolve().parents[1]
|
_BUILD_ROOT = Path(__file__).resolve().parents[1]
|
||||||
if str(_BUILD_ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_BUILD_ROOT))
|
|
||||||
|
def _load_wheel_layout_module():
|
||||||
|
layout_path = _BUILD_ROOT / "wheel" / "layout.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("_orisys_build_wheel_layout", layout_path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"Cannot load wheel layout helper from {layout_path}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
def _load_toml(text: str) -> dict:
|
def _load_toml(text: str) -> dict:
|
||||||
@@ -94,7 +103,7 @@ def bundle_items(repo_root: Path) -> list[dict]:
|
|||||||
|
|
||||||
def bundle_data_files(repo_root: Path) -> list[tuple[str, list[str]]]:
|
def bundle_data_files(repo_root: Path) -> list[tuple[str, list[str]]]:
|
||||||
"""Map bundle items marked dev_install=true to setuptools data_files entries."""
|
"""Map bundle items marked dev_install=true to setuptools data_files entries."""
|
||||||
from wheel.layout import resolve_bundle_source_files
|
resolve_bundle_source_files = _load_wheel_layout_module().resolve_bundle_source_files
|
||||||
|
|
||||||
grouped: dict[str, list[str]] = {}
|
grouped: dict[str, list[str]] = {}
|
||||||
for item in bundle_items(repo_root):
|
for item in bundle_items(repo_root):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
from common.paths import REPO_ROOT
|
from common.paths import REPO_ROOT
|
||||||
from gui.common.pyinstaller_driver import GuiBuildConfig
|
from gui.common.pyinstaller_driver import GuiBuildConfig
|
||||||
|
|
||||||
ENTRY_SCRIPT = REPO_ROOT / "examples" / "products" / "ory_box" / "basic_pipeline.py"
|
ENTRY_SCRIPT = REPO_ROOT / "examples" / "basic_pipeline.py"
|
||||||
EXE_NAME = "OryBox.exe"
|
EXE_NAME = "OryBox.exe"
|
||||||
DIST_NAME = "OryBox"
|
DIST_NAME = "OryBox"
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
from common.paths import REPO_ROOT
|
from common.paths import REPO_ROOT
|
||||||
from gui.common.pyinstaller_driver import GuiBuildConfig
|
from gui.common.pyinstaller_driver import GuiBuildConfig
|
||||||
|
|
||||||
ENTRY_SCRIPT = REPO_ROOT / "examples" / "products" / "ory_finger" / "finger_pipeline_3dview.py"
|
ENTRY_SCRIPT = REPO_ROOT / "examples" / "finger_pipeline_3dview.py"
|
||||||
EXE_NAME = "OrisysDashboard.exe"
|
EXE_NAME = "OrisysDashboard.exe"
|
||||||
DIST_NAME = "OrisysDashboard"
|
DIST_NAME = "OrisysDashboard"
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ if str(_BUILD_ROOT) not in sys.path:
|
|||||||
from common.paths import REPO_ROOT
|
from common.paths import REPO_ROOT
|
||||||
from gui.common.pyinstaller_driver import GuiBuildConfig
|
from gui.common.pyinstaller_driver import GuiBuildConfig
|
||||||
|
|
||||||
ENTRY_SCRIPT = REPO_ROOT / "examples" / "products" / "ory_mini" / "basic_pipeline.py"
|
ENTRY_SCRIPT = REPO_ROOT / "examples" / "mini_pipeline.py"
|
||||||
EXE_NAME = "OryMini.exe"
|
EXE_NAME = "OryMini.exe"
|
||||||
DIST_NAME = "OryMini"
|
DIST_NAME = "OryMini"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"name": "dd06-ov",
|
||||||
|
"type": "single_camera",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [
|
||||||
|
1280,
|
||||||
|
720
|
||||||
|
],
|
||||||
|
"fps": 30,
|
||||||
|
"resolution_stitch": [
|
||||||
|
195,
|
||||||
|
275
|
||||||
|
],
|
||||||
|
"source_scale": 0.4,
|
||||||
|
"old": [
|
||||||
|
672,
|
||||||
|
728,
|
||||||
|
1,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"work": [
|
||||||
|
504,
|
||||||
|
546,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"work2": [
|
||||||
|
420,
|
||||||
|
450,
|
||||||
|
0.8
|
||||||
|
],
|
||||||
|
"work3": [
|
||||||
|
350,
|
||||||
|
375,
|
||||||
|
4,
|
||||||
|
0.7
|
||||||
|
],
|
||||||
|
"work4": [
|
||||||
|
280,
|
||||||
|
300,
|
||||||
|
4,
|
||||||
|
0.4,
|
||||||
|
0.2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 3,
|
||||||
|
"algorithm_list": [
|
||||||
|
"raft",
|
||||||
|
"dis"
|
||||||
|
],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold": 0.2,
|
||||||
|
"div_threshold": 0.022,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [
|
||||||
|
5,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
},
|
||||||
|
"roi": {
|
||||||
|
"type": "polygon",
|
||||||
|
"image_size": [
|
||||||
|
1280,
|
||||||
|
720
|
||||||
|
],
|
||||||
|
"created_at": "2026-07-26T11:56:15",
|
||||||
|
"points": [
|
||||||
|
[
|
||||||
|
242,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
402,
|
||||||
|
597
|
||||||
|
],
|
||||||
|
[
|
||||||
|
799,
|
||||||
|
606
|
||||||
|
],
|
||||||
|
[
|
||||||
|
976,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"mask_path": "dd06-ov_mask.png",
|
||||||
|
"metadata": {
|
||||||
|
"source": "./data/dd06/0726.mp4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"name": "dd06-ov",
|
||||||
|
"type": "single_camera",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [
|
||||||
|
1280,
|
||||||
|
720
|
||||||
|
],
|
||||||
|
"fps": 30,
|
||||||
|
"resolution_stitch": [
|
||||||
|
195,
|
||||||
|
275
|
||||||
|
],
|
||||||
|
"source_scale": 0.4,
|
||||||
|
"old": [
|
||||||
|
672,
|
||||||
|
728,
|
||||||
|
1,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"work": [
|
||||||
|
504,
|
||||||
|
546,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"work2": [
|
||||||
|
420,
|
||||||
|
450,
|
||||||
|
0.8
|
||||||
|
],
|
||||||
|
"work3": [
|
||||||
|
350,
|
||||||
|
375,
|
||||||
|
4,
|
||||||
|
0.7
|
||||||
|
],
|
||||||
|
"work4": [
|
||||||
|
280,
|
||||||
|
300,
|
||||||
|
4,
|
||||||
|
0.4,
|
||||||
|
0.2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 3,
|
||||||
|
"algorithm_list": [
|
||||||
|
"raft",
|
||||||
|
"dis"
|
||||||
|
],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold": 0.2,
|
||||||
|
"div_threshold": 0.022,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [
|
||||||
|
5,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
},
|
||||||
|
"roi": {
|
||||||
|
"type": "polygon",
|
||||||
|
"image_size": [
|
||||||
|
1280,
|
||||||
|
720
|
||||||
|
],
|
||||||
|
"points": [
|
||||||
|
[
|
||||||
|
628,
|
||||||
|
532
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1145,
|
||||||
|
694
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1161,
|
||||||
|
8
|
||||||
|
],
|
||||||
|
[
|
||||||
|
630,
|
||||||
|
126
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"created_at": "2026-08-10T14:26:31",
|
||||||
|
"mask_path": "dd06-ov-0810_mask.png",
|
||||||
|
"metadata": {
|
||||||
|
"source": ".\\data\\dd06\\2026-08-10-142316.webm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
+12
-12
@@ -25,7 +25,7 @@ pip install orisys-{version}-{python-version}-{python-version}-{os}_{machine}.wh
|
|||||||
```
|
```
|
||||||
如
|
如
|
||||||
```bash
|
```bash
|
||||||
pip install orisys-0.4.2-cp310-cp310-win_amd64.whl
|
pip install orisys-0.4.3-cp310-cp310-win_amd64.whl
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **安装 CuPy(根据您的 CUDA 版本选择):**
|
3. **安装 CuPy(根据您的 CUDA 版本选择):**
|
||||||
@@ -63,7 +63,7 @@ print(orisys.__version__)
|
|||||||
### 输入参数
|
### 输入参数
|
||||||
|
|
||||||
* **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`。
|
||||||
@@ -238,7 +238,7 @@ flow = sensor.read_info(sensor.info.VRAW)
|
|||||||
|
|
||||||
## `orisys.finger` 手指 UV pipeline
|
## `orisys.finger` 手指 UV pipeline
|
||||||
|
|
||||||
`examples/products/ory_finger/finger_pipeline.py` 展示了一个更适合手指/单相机场景的高层 API:
|
`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()` 获取一帧展开后的触觉图像。
|
||||||
@@ -262,7 +262,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,7 +282,7 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -312,7 +312,7 @@ runtime = FingerRuntime.open(
|
|||||||
|
|
||||||
#### 描述
|
#### 描述
|
||||||
|
|
||||||
对一帧原始输入执行源图预处理、UV 展开,并把展开后的结果送入内部 `TactileProcessor`。这是 `examples/products/ory_finger/finger_pipeline.py` 中 profile 模式使用的方法。
|
对一帧原始输入执行源图预处理、UV 展开,并把展开后的结果送入内部 `TactileProcessor`。这是 `examples/finger_pipeline.py` 中 profile 模式使用的方法。
|
||||||
|
|
||||||
#### 输入参数
|
#### 输入参数
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ runtime = FingerRuntime.open(
|
|||||||
|
|
||||||
#### 描述
|
#### 描述
|
||||||
|
|
||||||
先读取一帧视频源,再调用 `process_frame()` 完成 UV 展开并推入内部触觉处理器。非 profile 模式下,`examples/products/ory_finger/finger_pipeline.py` 使用该方法作为主循环入口。
|
先读取一帧视频源,再调用 `process_frame()` 完成 UV 展开并推入内部触觉处理器。非 profile 模式下,`examples/finger_pipeline.py` 使用该方法作为主循环入口。
|
||||||
|
|
||||||
#### 输入参数
|
#### 输入参数
|
||||||
|
|
||||||
@@ -357,7 +357,7 @@ runtime = FingerRuntime.open(
|
|||||||
|
|
||||||
#### 描述
|
#### 描述
|
||||||
|
|
||||||
先执行一次采集与 UV 展开,然后调用 `runtime.tactile.warmup()` 预热内部光流/HHD 相关缓存。`examples/products/ory_finger/finger_pipeline.py` 在进入主循环前会先执行一次 warmup。
|
先执行一次采集与 UV 展开,然后调用 `runtime.tactile.warmup()` 预热内部光流/HHD 相关缓存。`examples/finger_pipeline.py` 在进入主循环前会先执行一次 warmup。
|
||||||
|
|
||||||
#### 输入参数
|
#### 输入参数
|
||||||
|
|
||||||
@@ -386,7 +386,7 @@ 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**: 当前有效区域掩码。
|
||||||
|
|
||||||
#### 说明
|
#### 说明
|
||||||
@@ -395,7 +395,7 @@ runtime = FingerRuntime.open(
|
|||||||
|
|
||||||
### 10. 最小手指 pipeline 示例
|
### 10. 最小手指 pipeline 示例
|
||||||
|
|
||||||
下面的流程与 `examples/products/ory_finger/finger_pipeline.py` 一致:
|
下面的流程与 `examples/finger_pipeline.py` 一致:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import time
|
import time
|
||||||
@@ -404,7 +404,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,
|
||||||
)
|
)
|
||||||
@@ -462,7 +462,7 @@ finally:
|
|||||||
|
|
||||||
### 11. 关于 profile 模式
|
### 11. 关于 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(...)`
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+18
-19
@@ -1,5 +1,5 @@
|
|||||||
叠动科技软件使用手册
|
叠动科技软件使用手册
|
||||||
版本: 0.4.2
|
版本: 0.4.3
|
||||||
更新日期: 2026年7月13日
|
更新日期: 2026年7月13日
|
||||||
|
|
||||||
# 简介
|
# 简介
|
||||||
@@ -57,9 +57,9 @@ orisys SDK 以 .whl 文件形式提供,您需要根据操作系统、Python
|
|||||||
## 下载正确的 .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)
|
||||||
@@ -71,7 +71,7 @@ Python 版本:3.10(cp310)
|
|||||||
|
|
||||||
``` 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(可选)
|
||||||
@@ -108,18 +108,18 @@ python
|
|||||||
import orisys
|
import orisys
|
||||||
print(orisys.__version__)
|
print(orisys.__version__)
|
||||||
```
|
```
|
||||||
如果输出版本号(例如 0.4.2),则说明安装成功。
|
如果输出版本号(例如 0.4.3),则说明安装成功。
|
||||||
|
|
||||||
# 使用方法
|
# 使用方法
|
||||||
## 运行示例程序
|
## 运行示例程序
|
||||||
套件里面包含了多个例子,建议都在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。按产品入口如下:
|
套件里面包含了多个例子,建议都在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。按产品入口如下:
|
||||||
|
|
||||||
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 UV 最小流程(默认配置 `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 的产品配置名。`dd02` 仍可作为兼容别名,实际会解析到 `dd02-ov`。
|
||||||
|
|
||||||
下面先介绍最基础的 ORY-BOX 运行方式。
|
下面先介绍最基础的 ORY-BOX 运行方式。
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ print(orisys.__version__)
|
|||||||
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”键可以重设数据
|
||||||
|
|
||||||
@@ -139,22 +139,22 @@ print(orisys.__version__)
|
|||||||
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 UV 示例
|
||||||
|
|
||||||
如果您使用的是 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
|
||||||
```
|
```
|
||||||
|
|
||||||
该示例会:
|
该示例会:
|
||||||
@@ -171,19 +171,18 @@ 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 -e ".[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
|
||||||
```
|
```
|
||||||
|
|
||||||
该界面会提供:
|
该界面会提供:
|
||||||
|
|||||||
Binary file not shown.
@@ -1,171 +0,0 @@
|
|||||||
# Publishing Orisys to PyPI
|
|
||||||
|
|
||||||
This document covers the **Linux manylinux binary wheel** path (Nuitka-compiled
|
|
||||||
`orisys` for CPython 3.10 / 3.11 / 3.12). Windows wheels are still built on
|
|
||||||
Windows hosts; they cannot be produced inside the manylinux container.
|
|
||||||
|
|
||||||
## Why Docker?
|
|
||||||
|
|
||||||
PyPI rejects wheels tagged `manylinux_x86_64` (missing glibc ABI version). A
|
|
||||||
compliant tag looks like:
|
|
||||||
|
|
||||||
```text
|
|
||||||
orisys-0.4.2-cp310-cp310-manylinux_2_28_x86_64.whl
|
|
||||||
orisys-0.4.2-cp311-cp311-manylinux_2_28_x86_64.whl
|
|
||||||
orisys-0.4.2-cp312-cp312-manylinux_2_28_x86_64.whl
|
|
||||||
```
|
|
||||||
|
|
||||||
Those tags are produced by building inside the official manylinux image and
|
|
||||||
running `auditwheel repair`. On Windows, use **Docker Desktop in Linux container
|
|
||||||
mode**.
|
|
||||||
|
|
||||||
> Note: we use `manylinux_2_28_x86_64` (not 2014). Recent NumPy/SciPy no longer
|
|
||||||
> provide manylinux2014 wheels and fail to build from source on that older GCC.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. Docker Desktop installed and running (**Linux containers**, not Windows containers).
|
|
||||||
2. Repository checked out locally (this tree is bind-mounted into the container).
|
|
||||||
3. Version bumped in `src/orisys/__init__.py` (`__version__`) before every upload.
|
|
||||||
PyPI never allows re-uploading the same filename/version.
|
|
||||||
4. PyPI / TestPyPI project-scoped API tokens (never commit tokens or `.pypirc` with secrets).
|
|
||||||
|
|
||||||
Quick Docker check:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker version --format "{{.Server.Os}}"
|
|
||||||
# expect: linux
|
|
||||||
docker run --rm hello-world
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build manylinux wheels (3.10 / 3.11 / 3.12)
|
|
||||||
|
|
||||||
From the repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python build/run.py manylinux
|
|
||||||
```
|
|
||||||
|
|
||||||
Or call the Docker helper directly:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\build\wheel\docker\run.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Default builds **all three** ABIs. To build a subset:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python build/run.py manylinux -PythonVersions 3.10,3.12
|
|
||||||
python build/run.py manylinux -PythonVersions 3.11
|
|
||||||
```
|
|
||||||
|
|
||||||
What this does:
|
|
||||||
|
|
||||||
1. Builds `orisys-manylinux` from [`build/wheel/docker/Dockerfile.manylinux`](../build/wheel/docker/Dockerfile.manylinux)
|
|
||||||
(`quay.io/pypa/manylinux_2_28_x86_64` with Nuitka/auditwheel in cp310/cp311/cp312).
|
|
||||||
2. Runs [`build/wheel/docker/entrypoint.sh`](../build/wheel/docker/entrypoint.sh), which loops:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/opt/python/cp310-cp310/bin/python build/wheel/build.py --target pypi-linux
|
|
||||||
/opt/python/cp311-cp311/bin/python build/wheel/build.py --target pypi-linux
|
|
||||||
/opt/python/cp312-cp312/bin/python build/wheel/build.py --target pypi-linux
|
|
||||||
```
|
|
||||||
|
|
||||||
3. For each ABI: Nuitka compile → wheel → `auditwheel repair` → isolated smoke test.
|
|
||||||
4. Artifacts land in:
|
|
||||||
|
|
||||||
- `dist/repaired/orisys-*-manylinux_2_28_x86_64.whl` (upload candidates)
|
|
||||||
- `SDK/<version>/` (same compliant wheels)
|
|
||||||
|
|
||||||
Expect roughly **3×** the wall time of a single-ABI build.
|
|
||||||
|
|
||||||
Rebuild without refreshing the image:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python build/run.py manylinux -SkipImageBuild
|
|
||||||
```
|
|
||||||
|
|
||||||
Equivalent manual `docker` invocation:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker build -f build/wheel/docker/Dockerfile.manylinux -t orisys-manylinux .
|
|
||||||
docker run --rm `
|
|
||||||
-e ORISYS_BUILD_TARGET=pypi-linux `
|
|
||||||
-e ORISYS_PYTHON_TAGS=cp310-cp310,cp311-cp311,cp312-cp312 `
|
|
||||||
-v "${PWD}:/src" `
|
|
||||||
orisys-manylinux
|
|
||||||
```
|
|
||||||
|
|
||||||
### Acceptance checks before upload
|
|
||||||
|
|
||||||
- Each filename contains `cp310` / `cp311` / `cp312` and `manylinux_2_28_x86_64`
|
|
||||||
(or another accepted manylinux tag such as `manylinux_2_17_x86_64`).
|
|
||||||
- Isolated smoke test printed `public API imports OK` for every ABI.
|
|
||||||
- Do **not** upload any wheel still tagged `linux_x86_64` or bare `manylinux_x86_64`.
|
|
||||||
|
|
||||||
Optional re-verify on a Linux machine:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python build/run.py verify dist/repaired/orisys-*-cp310-cp310-manylinux_*.whl
|
|
||||||
```
|
|
||||||
|
|
||||||
## Promote: TestPyPI then PyPI
|
|
||||||
|
|
||||||
Install upload tooling in a clean environment:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python -m pip install --upgrade twine
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1. Upload to TestPyPI
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:TWINE_USERNAME = "__token__"
|
|
||||||
$env:TWINE_PASSWORD = "<TestPyPI API token>"
|
|
||||||
|
|
||||||
python -m twine check dist/repaired/*.whl
|
|
||||||
python -m twine upload --repository testpypi dist/repaired/*.whl
|
|
||||||
```
|
|
||||||
|
|
||||||
Install from TestPyPI in a fresh venv (pull runtime deps from real PyPI), testing
|
|
||||||
each Python you care about:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
py -3.10 -m venv .venv-testpypi310
|
|
||||||
.\.venv-testpypi310\Scripts\Activate.ps1
|
|
||||||
python -m pip install --index-url https://test.pypi.org/simple/ `
|
|
||||||
--extra-index-url https://pypi.org/simple `
|
|
||||||
"orisys==<version>"
|
|
||||||
python -c "import orisys; print(orisys.__version__)"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Upload the same artifacts to PyPI
|
|
||||||
|
|
||||||
Only after TestPyPI install/import succeeds:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:TWINE_USERNAME = "__token__"
|
|
||||||
$env:TWINE_PASSWORD = "<PyPI API token>"
|
|
||||||
|
|
||||||
python -m twine upload dist/repaired/*.whl
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer CI **Trusted Publishing** (OIDC) for production uploads so long-lived tokens
|
|
||||||
are not stored on developer machines.
|
|
||||||
|
|
||||||
## Related local targets
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| `python build/run.py wheel` | Preferred wheel builder launcher |
|
|
||||||
| `python build/wheel/build.py` | Direct wheel builder (current host) |
|
|
||||||
| `python build/run.py wheel --target pypi-linux` | Linux-only; requires manylinux + CPython 3.10–3.12 |
|
|
||||||
| `python build/run.py wheel-win` | Windows wheels for py310/py311/py312 conda envs |
|
|
||||||
| `python build/run.py manylinux` | manylinux Docker runner |
|
|
||||||
| `python build/run.py gui finger` | ORY-FINGER standalone GUI |
|
|
||||||
| `python -m build` | Pure-Python / sdist path from `pyproject.toml` (no Nuitka) |
|
|
||||||
|
|
||||||
See also [`build/README.md`](../build/README.md) for the target-oriented layout.
|
|
||||||
|
|
||||||
Windows binary wheels and Linux manylinux wheels must be built on their respective
|
|
||||||
platforms. Never rename a platform tag to bypass PyPI validation.
|
|
||||||
+13
-17
@@ -1,54 +1,50 @@
|
|||||||
# Examples
|
# Examples
|
||||||
|
|
||||||
Product-facing demos live under `examples/products/`. Shared helpers stay in
|
Runnable demos live at the `examples/` root. Shared helpers stay in
|
||||||
`pipeline/`, `viz/`, and `gui/`. Run commands from the repository root.
|
`pipeline/`, `viz/`, and `gui/`. Run commands from the repository root.
|
||||||
|
|
||||||
## Product Entry Points
|
## Product Entry Points
|
||||||
|
|
||||||
| Product | Entry | Default config |
|
| Product | Entry | Default config |
|
||||||
|---------|-------|----------------|
|
|---------|-------|----------------|
|
||||||
| ORY-BOX | `products/ory_box/basic_pipeline.py` | `ddjx01` |
|
| ORY-BOX | `basic_pipeline.py` | `box` |
|
||||||
| ORY-MINI | `products/ory_mini/basic_pipeline.py` | `mini` |
|
| ORY-MINI | `mini_pipeline.py` | `mini` |
|
||||||
| ORY-FINGER (2D) | `products/ory_finger/finger_pipeline.py` | `dd02` |
|
| ORY-FINGER (2D) | `finger_pipeline.py` | `finger` |
|
||||||
| ORY-FINGER (3D Qt) | `products/ory_finger/finger_pipeline_3dview.py` | `dd02` |
|
| ORY-FINGER (3D Qt) | `finger_pipeline_3dview.py` | `finger` |
|
||||||
|
|
||||||
`dd02` is the stable ORY-FINGER alias and currently resolves to `dd02-ov`.
|
`finger` is the public ORY-FINGER config stem. `dd02` remains a compatibility alias for `dd02-ov`.
|
||||||
|
|
||||||
Compatibility wrappers still exist at the `examples/` root
|
|
||||||
(`basic_pipeline.py`, `finger_pipeline.py`, `finger_pipeline_3dview.py`) and
|
|
||||||
forward to the product entries above.
|
|
||||||
|
|
||||||
## Typical Usage
|
## Typical Usage
|
||||||
|
|
||||||
ORY-BOX:
|
ORY-BOX:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
|
python examples/basic_pipeline.py -v 0 -c box
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-MINI:
|
ORY-MINI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/products/ory_mini/basic_pipeline.py -v 0 -c mini
|
python examples/mini_pipeline.py -v 0 -c mini
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-FINGER 2D:
|
ORY-FINGER 2D:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
|
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-FINGER video file:
|
ORY-FINGER video file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4 --config dd02
|
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
|
||||||
```
|
```
|
||||||
|
|
||||||
ORY-FINGER 3D Qt dashboard:
|
ORY-FINGER 3D Qt dashboard:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e ".[gui]"
|
pip install -e ".[gui]"
|
||||||
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
|
python examples/finger_pipeline_3dview.py --video 0 --config finger
|
||||||
```
|
```
|
||||||
|
|
||||||
## Other Root Examples
|
## Other Root Examples
|
||||||
@@ -70,10 +66,10 @@ pip install -e ".[gui]"
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `products/ory_finger/finger_pipeline_3dview.py` is the main interactive finger demo.
|
- `finger_pipeline_3dview.py` is the main interactive finger demo.
|
||||||
It uses `gui/app_paths.py` so the same entry works in editable and packaged runs.
|
It uses `gui/app_paths.py` so the same entry works in editable and packaged runs.
|
||||||
- Dashboard controls: click **启动** to start acquisition; use **Record CSV / Stop CSV**
|
- Dashboard controls: click **启动** to start acquisition; use **Record CSV / Stop CSV**
|
||||||
and the CSV save-directory browser to log contact/force data; `Q` quits, `R` resets,
|
and the CSV save-directory browser to log contact/force data; `Q` quits, `R` resets,
|
||||||
`S` starts video recording, `E` stops video recording, and `Esc` toggles fullscreen.
|
`S` starts video recording, `E` stops video recording, and `Esc` toggles fullscreen.
|
||||||
- `products/ory_finger/finger_pipeline.py` is the leanest finger example if you want to
|
- `finger_pipeline.py` is the leanest finger example if you want to
|
||||||
validate UV unroll + force/contact processing without the full dashboard.
|
validate UV unroll + force/contact processing without the full dashboard.
|
||||||
|
|||||||
+107
-13
@@ -1,21 +1,115 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Compatibility wrapper for ORY-BOX.
|
|
||||||
|
|
||||||
Prefer:
|
|
||||||
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
|
|
||||||
"""
|
"""
|
||||||
|
ORY-BOX:单传感器基础流程
|
||||||
|
|
||||||
from __future__ import annotations
|
本示例演示 Orisys SDK 的标准处理流程:
|
||||||
|
1. 初始化传感器(摄像头或视频文件)
|
||||||
|
2. 获取图像并计算形变
|
||||||
|
3. 按需计算接触区域
|
||||||
|
4. 读取并可视化结果
|
||||||
|
|
||||||
import sys
|
SDK version: 0.4.3
|
||||||
from pathlib import Path
|
"""
|
||||||
|
import os
|
||||||
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
|
import cv2
|
||||||
|
import orisys
|
||||||
|
import numpy as np
|
||||||
|
import argparse
|
||||||
|
|
||||||
_EXAMPLES_DIR = Path(__file__).resolve().parent
|
def main():
|
||||||
if str(_EXAMPLES_DIR) not in sys.path:
|
parser = argparse.ArgumentParser()
|
||||||
sys.path.insert(0, str(_EXAMPLES_DIR))
|
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
||||||
|
parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径")
|
||||||
|
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
from products.ory_box.basic_pipeline import main
|
# 步骤 1:创建传感器对象(必需)
|
||||||
|
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
|
||||||
|
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
|
||||||
|
|
||||||
|
# 输入既可以是摄像头编号,也可以是视频文件路径
|
||||||
|
try:
|
||||||
|
input = int(args.video)
|
||||||
|
is_camera = True
|
||||||
|
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
|
||||||
|
except:
|
||||||
|
is_camera = False
|
||||||
|
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
|
||||||
|
|
||||||
|
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# 步骤 2:数据处理
|
||||||
|
# 步骤 2.1:获取并拼接图像(必需)
|
||||||
|
sensor.get_img()
|
||||||
|
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
|
||||||
|
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0)
|
||||||
|
# 步骤 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
@@ -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(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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,20 +1,90 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Compatibility wrapper for ORY-FINGER 3D Qt dashboard.
|
"""
|
||||||
|
ORY-FINGER:UV-unroll finger tactile pipeline with unified Qt dashboard.
|
||||||
|
|
||||||
Prefer:
|
Where to customize the GUI:
|
||||||
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
|
Layout (sizes, proportions) -> examples/gui/viewer3d/layout.py
|
||||||
|
Colors / QSS -> examples/gui/viewer3d/styles.py
|
||||||
|
Widget rendering logic -> examples/gui/viewer3d/widgets/
|
||||||
|
Window behavior -> examples/gui/viewer3d/dashboard.py
|
||||||
|
|
||||||
|
Recommended entry:
|
||||||
|
python examples/finger_pipeline_3dview.py --video 0 --config finger
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
import argparse
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||||
|
|
||||||
|
# Make shared packages importable when launched as a file path.
|
||||||
_EXAMPLES_DIR = Path(__file__).resolve().parent
|
_EXAMPLES_DIR = Path(__file__).resolve().parent
|
||||||
if str(_EXAMPLES_DIR) not in sys.path:
|
if str(_EXAMPLES_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(_EXAMPLES_DIR))
|
sys.path.insert(0, str(_EXAMPLES_DIR))
|
||||||
|
|
||||||
from products.ory_finger.finger_pipeline_3dview import main
|
from gui.app_paths import (
|
||||||
|
configure_frozen_runtime,
|
||||||
|
default_config_path,
|
||||||
|
ensure_examples_import_path,
|
||||||
|
require_gui_extras,
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_frozen_runtime()
|
||||||
|
ensure_examples_import_path()
|
||||||
|
require_gui_extras()
|
||||||
|
|
||||||
|
from gui import run_dashboard
|
||||||
|
from pipeline import PipelineConfig
|
||||||
|
from viz import DEFAULT_FINGER_VIZ_POSE
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="UV-unroll finger tactile pipeline with unified Qt dashboard"
|
||||||
|
)
|
||||||
|
parser.add_argument("--video", "-v", default="0", help="Camera index or video file path")
|
||||||
|
parser.add_argument("--config", "-c", default=default_config_path(), help="Orisys SDK config")
|
||||||
|
parser.add_argument("--cal", default="", help="Optional calibration file path")
|
||||||
|
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
|
||||||
|
parser.add_argument(
|
||||||
|
"--mesh-map",
|
||||||
|
choices=["flow", "depth"],
|
||||||
|
default="depth",
|
||||||
|
help="Color the 3D mesh from flow magnitude or contact depth.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--threshold", type=float, default=None, help="Mesh/arrow threshold")
|
||||||
|
parser.add_argument("--no-3d", action="store_true", help="Disable 3D mesh view")
|
||||||
|
parser.add_argument(
|
||||||
|
"--viz-pose",
|
||||||
|
default=DEFAULT_FINGER_VIZ_POSE,
|
||||||
|
help="world_to_cam.npy for the default view; pass 'none' to skip",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cutoff",
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
help="Low-pass cutoff for force curves (Hz)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--profile",
|
||||||
|
action="store_true",
|
||||||
|
help="Print per-stage timing to the log panel / console",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--profile-every",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="When --profile is set, print rolling averages every N frames",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = _parse_args()
|
||||||
|
config = PipelineConfig.from_args(args)
|
||||||
|
sys.exit(run_dashboard(config))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -27,8 +27,17 @@ def resource_path(*parts: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def default_config_path() -> str:
|
def default_config_path() -> str:
|
||||||
"""Default ORY-FINGER config name (resolves to dd02-ov via alias)."""
|
"""Default ORY-FINGER config name (packaged stem ``finger``)."""
|
||||||
return "dd02"
|
return "finger"
|
||||||
|
|
||||||
|
|
||||||
|
def require_gui_extras() -> None:
|
||||||
|
try:
|
||||||
|
import open3d # noqa: F401
|
||||||
|
from PyQt5 import QtWidgets # noqa: F401
|
||||||
|
import pyqtgraph # noqa: F401
|
||||||
|
except ImportError as exc:
|
||||||
|
raise SystemExit('GUI example requires: pip install "orisys[gui]"') from exc
|
||||||
|
|
||||||
|
|
||||||
def configure_frozen_runtime() -> None:
|
def configure_frozen_runtime() -> None:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Any, Optional
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import orisys
|
import orisys
|
||||||
|
from orisys.signal_processing import calibrate_normal_force
|
||||||
|
|
||||||
from .roi_geometry import (
|
from .roi_geometry import (
|
||||||
build_roi_warp,
|
build_roi_warp,
|
||||||
@@ -22,10 +23,6 @@ from .roi_geometry import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
|
|
||||||
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FrameSnapshot:
|
class FrameSnapshot:
|
||||||
"""Normalized per-frame payload consumed by the Qt viewer."""
|
"""Normalized per-frame payload consumed by the Qt viewer."""
|
||||||
@@ -49,11 +46,6 @@ class FrameSnapshot:
|
|||||||
record_frame: Optional[np.ndarray] = None
|
record_frame: Optional[np.ndarray] = None
|
||||||
|
|
||||||
|
|
||||||
def calibrate_normal_force(raw_value: float) -> float:
|
|
||||||
"""Convert raw normal-force reading to Newtons using y = mx + c."""
|
|
||||||
return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET
|
|
||||||
|
|
||||||
|
|
||||||
class SensorAdapter(ABC):
|
class SensorAdapter(ABC):
|
||||||
"""Common interface between Qt and concrete sensor backends."""
|
"""Common interface between Qt and concrete sensor backends."""
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from typing import Literal, Optional, Sequence, Tuple
|
|||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import orisys
|
import orisys
|
||||||
from scipy.signal import butter, filtfilt
|
from orisys.signal_processing import lowpass_filter
|
||||||
|
|
||||||
FORCE_SAMPLE_RATE_HZ = 30.0
|
FORCE_SAMPLE_RATE_HZ = 30.0
|
||||||
PLOT_BUFFER_POINTS = 120
|
PLOT_BUFFER_POINTS = 120
|
||||||
@@ -61,32 +61,6 @@ class ForcePlotState:
|
|||||||
y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]
|
y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]
|
||||||
|
|
||||||
|
|
||||||
def lowpass_filter(
|
|
||||||
data,
|
|
||||||
cutoff_hz: float,
|
|
||||||
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
|
|
||||||
order: int = 2,
|
|
||||||
) -> np.ndarray:
|
|
||||||
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data."""
|
|
||||||
y = np.asarray(data, dtype=np.float64)
|
|
||||||
if cutoff_hz == 0:
|
|
||||||
return y.copy()
|
|
||||||
if y.size == 0:
|
|
||||||
return y
|
|
||||||
|
|
||||||
nyquist = 0.5 * sample_rate_hz
|
|
||||||
if cutoff_hz < 0 or cutoff_hz >= nyquist:
|
|
||||||
raise ValueError(
|
|
||||||
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
|
|
||||||
)
|
|
||||||
|
|
||||||
b, a = butter(order, cutoff_hz / nyquist, btype="low")
|
|
||||||
padlen = 3 * (max(len(b), len(a)) - 1)
|
|
||||||
if y.size <= padlen:
|
|
||||||
return y.copy()
|
|
||||||
return filtfilt(b, a, y)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
||||||
if img is None:
|
if img is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class QtViewerWindow(QMainWindow):
|
|||||||
self.btn_config.setObjectName("btnConfig")
|
self.btn_config.setObjectName("btnConfig")
|
||||||
self.btn_config.clicked.connect(self._select_config)
|
self.btn_config.clicked.connect(self._select_config)
|
||||||
bar.addWidget(self.btn_config)
|
bar.addWidget(self.btn_config)
|
||||||
self.config_edit = QLineEdit("./config/ddjx01.json")
|
self.config_edit = QLineEdit("box")
|
||||||
bar.addWidget(self.config_edit, 1)
|
bar.addWidget(self.config_edit, 1)
|
||||||
|
|
||||||
self.btn_reset = QPushButton("重置")
|
self.btn_reset = QPushButton("重置")
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class SettingsPanelWidget(QWidget):
|
|||||||
root.addWidget(self._caption("Config"))
|
root.addWidget(self._caption("Config"))
|
||||||
config_row = QHBoxLayout()
|
config_row = QHBoxLayout()
|
||||||
config_row.setSpacing(6)
|
config_row.setSpacing(6)
|
||||||
self.config_edit = QLineEdit("dd02")
|
self.config_edit = QLineEdit("finger")
|
||||||
config_row.addWidget(self.config_edit, 1)
|
config_row.addWidget(self.config_edit, 1)
|
||||||
self.btn_browse_config = QPushButton("…")
|
self.btn_browse_config = QPushButton("…")
|
||||||
self.btn_browse_config.setFixedWidth(32)
|
self.btn_browse_config.setFixedWidth(32)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
|||||||
+24
-23
@@ -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' 结束录像。")
|
||||||
@@ -54,30 +56,29 @@ def main():
|
|||||||
recorder.write_if_recording(sensor.frame)
|
recorder.write_if_recording(sensor.frame)
|
||||||
|
|
||||||
sensor.compute_deformation(check_motion=True, threshold=0)
|
sensor.compute_deformation(check_motion=True, threshold=0)
|
||||||
fps, fn, fx, fy, flow, vnormal, vshear = sensor.read_info(
|
sensor.compute_contact()
|
||||||
|
fps, fn, fx, fy, flow, depth_map = sensor.read_info(
|
||||||
sensor.info.FPS,
|
sensor.info.FPS,
|
||||||
sensor.info.FNORMAL,
|
sensor.info.FNORMAL,
|
||||||
sensor.info.FSHEARX,
|
sensor.info.FSHEARX,
|
||||||
sensor.info.FSHEARY,
|
sensor.info.FSHEARY,
|
||||||
sensor.info.VRAW,
|
sensor.info.VRAW,
|
||||||
sensor.info.VNORMAL,
|
sensor.info.DEPTH,
|
||||||
sensor.info.VSHEAR,
|
|
||||||
)
|
)
|
||||||
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}")
|
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}")
|
||||||
|
|
||||||
arrows = orisys.util.draw_arrows(sensor.img, flow, threshold=5, grid_spacing=10, arrow_scale=1.0)
|
arrows = orisys.util.draw_arrows(sensor.img, flow, threshold=5, grid_spacing=10, arrow_scale=1.0)
|
||||||
cv2.imshow("形变矢量场", arrows)
|
cv2.imshow("形变矢量场", arrows)
|
||||||
cv2.imshow("流场幅值图", draw_magnitude_map(flow))
|
if depth_map.max() > depth_map.min():
|
||||||
|
depth_normalized = (
|
||||||
|
(depth_map - depth_map.min()) / (depth_map.max() - depth_map.min()) * 255
|
||||||
|
).astype(np.uint8)
|
||||||
|
else:
|
||||||
|
depth_normalized = np.zeros_like(depth_map, dtype=np.uint8)
|
||||||
|
cv2.imshow("depth", cv2.applyColorMap(depth_normalized, cv2.COLORMAP_JET))
|
||||||
|
|
||||||
charts.push(time.time() - start_time, fn, fx, fy)
|
charts.push(time.time() - start_time, fn, fx, fy)
|
||||||
display = charts.data.copy()
|
charts.refresh()
|
||||||
for channel in range(3):
|
|
||||||
display[:, 1, channel] = lowpass_filter(
|
|
||||||
charts.data[:, 1, channel],
|
|
||||||
cutoff_hz=5,
|
|
||||||
sample_rate_hz=30,
|
|
||||||
)
|
|
||||||
charts.refresh(display)
|
|
||||||
|
|
||||||
key = cv2.waitKey(1)
|
key = cv2.waitKey(1)
|
||||||
if key & 0xFF == ord("q"):
|
if key & 0xFF == ord("q"):
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Product-facing example entry points for ORY-BOX / ORY-MINI / ORY-FINGER."""
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"""
|
|
||||||
ORY-BOX:单传感器基础流程
|
|
||||||
|
|
||||||
本示例演示 Orisys SDK 的标准处理流程:
|
|
||||||
1. 初始化传感器(摄像头或视频文件)
|
|
||||||
2. 获取图像并计算形变
|
|
||||||
3. 按需计算接触区域
|
|
||||||
4. 读取并可视化结果
|
|
||||||
|
|
||||||
SDK version: 0.4.2
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
|
||||||
import cv2
|
|
||||||
import orisys
|
|
||||||
import numpy as np
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
|
||||||
parser.add_argument("--config","-c", type=str, default="ddjx01", help="配置文件名称或路径")
|
|
||||||
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# 步骤 1:创建传感器对象(必需)
|
|
||||||
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
|
|
||||||
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
|
|
||||||
|
|
||||||
# 输入既可以是摄像头编号,也可以是视频文件路径
|
|
||||||
try:
|
|
||||||
input = int(args.video)
|
|
||||||
is_camera = True
|
|
||||||
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
|
|
||||||
except:
|
|
||||||
is_camera = False
|
|
||||||
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
|
|
||||||
|
|
||||||
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
# 步骤 2:数据处理
|
|
||||||
# 步骤 2.1:获取并拼接图像(必需)
|
|
||||||
sensor.get_img()
|
|
||||||
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
|
|
||||||
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0)
|
|
||||||
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
|
|
||||||
# if is_contact_now:
|
|
||||||
sensor.compute_contact()
|
|
||||||
|
|
||||||
|
|
||||||
# 步骤 2.4:读取结果
|
|
||||||
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = sensor.read_info(
|
|
||||||
sensor.info.FPS,
|
|
||||||
sensor.info.VRAW,
|
|
||||||
sensor.info.VNORMAL,
|
|
||||||
sensor.info.IMG,
|
|
||||||
sensor.info.CONTOUR,
|
|
||||||
sensor.info.CENTROID,
|
|
||||||
sensor.info.DEPTH,
|
|
||||||
sensor.info.FNORMAL,
|
|
||||||
sensor.info.FSHEARX,
|
|
||||||
sensor.info.FSHEARY
|
|
||||||
)
|
|
||||||
|
|
||||||
# 步骤 3:可视化显示
|
|
||||||
# 步骤 3.1:显示形变矢量场
|
|
||||||
arrows = orisys.util.draw_arrows(
|
|
||||||
img,
|
|
||||||
flow,
|
|
||||||
threshold=2,
|
|
||||||
grid_spacing=20,
|
|
||||||
arrow_scale=1.0
|
|
||||||
)
|
|
||||||
cv2.imshow("arrow", arrows)
|
|
||||||
|
|
||||||
# 步骤 3.2:显示接触区域和质心
|
|
||||||
image_with_foe = orisys.util.draw_contact(img, contour, centroid)
|
|
||||||
cv2.imshow("contact", image_with_foe)
|
|
||||||
|
|
||||||
# 步骤 3.3:显示深度图
|
|
||||||
# 将深度值归一化到 0-255 范围
|
|
||||||
div_abs = depth_map
|
|
||||||
if div_abs.max() > div_abs.min():
|
|
||||||
div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
|
|
||||||
else:
|
|
||||||
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
|
|
||||||
|
|
||||||
# 应用伪彩色映射
|
|
||||||
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
|
|
||||||
|
|
||||||
# 步骤 3.4:打印关键结果
|
|
||||||
print(
|
|
||||||
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
|
|
||||||
f"切向力Y={fsheary:.4f}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 步骤 3.5:键盘控制
|
|
||||||
key = cv2.waitKey(1) & 0xFF
|
|
||||||
if key == ord("q"):
|
|
||||||
print("\n正在退出示例程序...")
|
|
||||||
break
|
|
||||||
if key == ord("r"):
|
|
||||||
# 当光流追踪出现漂移时,可手动重置追踪器
|
|
||||||
sensor.reset()
|
|
||||||
print("追踪器已重置。")
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# 4. 释放资源
|
|
||||||
# =========================================================================
|
|
||||||
sensor.disconnect() # 释放视频源及相关资源
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
ORY-FINGER:Minimum UV-unroll finger tactile pipeline.
|
|
||||||
|
|
||||||
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
|
|
||||||
Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
|
|
||||||
|
|
||||||
Recommended entry:
|
|
||||||
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
|
|
||||||
|
|
||||||
Current config alias: ``dd02`` resolves to ``dd02-ov`` for compatibility.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
import orisys
|
|
||||||
from orisys.finger import FingerRuntime
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline")
|
|
||||||
parser.add_argument("--camera_id", type=int, default=0)
|
|
||||||
parser.add_argument("--video", help="Video file path (overrides camera)")
|
|
||||||
parser.add_argument("--config", "-c", default="dd02", help="Orisys SDK config name or path")
|
|
||||||
parser.add_argument("--show", action="store_true", default=True, help="Show unrolled image window")
|
|
||||||
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
video = args.camera_id
|
|
||||||
if args.video:
|
|
||||||
video = args.video
|
|
||||||
runtime = FingerRuntime.open(
|
|
||||||
config=args.config,
|
|
||||||
video=video,
|
|
||||||
verbose=args.verbose,
|
|
||||||
)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
print(str(exc), file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.verbose:
|
|
||||||
geometry = runtime.geometry
|
|
||||||
print(
|
|
||||||
f"UV geometry: native={geometry.native_width}x{geometry.native_height}, "
|
|
||||||
f"source={geometry.source_width}x{geometry.source_height} "
|
|
||||||
f"output={geometry.out_width}x{geometry.out_height} "
|
|
||||||
)
|
|
||||||
|
|
||||||
runtime.warmup(color=False)
|
|
||||||
tactile = runtime.tactile
|
|
||||||
|
|
||||||
print("Press 'q' to quit, 'r' to reset tracker.")
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
ok, unrolled = runtime.grab_and_process(color=False)
|
|
||||||
if not ok:
|
|
||||||
break
|
|
||||||
|
|
||||||
tactile.compute_deformation(check_motion=True, threshold=0)
|
|
||||||
tactile.compute_contact()
|
|
||||||
|
|
||||||
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = tactile.read_info(
|
|
||||||
tactile.info.FPS,
|
|
||||||
tactile.info.VRAW,
|
|
||||||
tactile.info.VNORMAL,
|
|
||||||
tactile.info.IMG,
|
|
||||||
tactile.info.CONTOUR,
|
|
||||||
tactile.info.CENTROID,
|
|
||||||
tactile.info.DEPTH,
|
|
||||||
tactile.info.FNORMAL,
|
|
||||||
tactile.info.FSHEARX,
|
|
||||||
tactile.info.FSHEARY,
|
|
||||||
)
|
|
||||||
|
|
||||||
status = (
|
|
||||||
f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) "
|
|
||||||
f"mask={tactile.mask_stats['coverage']:.2f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.show:
|
|
||||||
arrows = orisys.util.draw_arrows(
|
|
||||||
unrolled,
|
|
||||||
flow,
|
|
||||||
threshold=0.4,
|
|
||||||
grid_spacing=10,
|
|
||||||
arrow_scale=1.0,
|
|
||||||
)
|
|
||||||
cv2.imshow("UV Unrolled", arrows)
|
|
||||||
|
|
||||||
print(status, end="\r")
|
|
||||||
|
|
||||||
key = cv2.waitKey(1) & 0xFF
|
|
||||||
if key == ord("q"):
|
|
||||||
print("\nExiting.")
|
|
||||||
break
|
|
||||||
if key == ord("r"):
|
|
||||||
tactile.reset()
|
|
||||||
print("\nTracker reset.")
|
|
||||||
finally:
|
|
||||||
runtime.close()
|
|
||||||
cv2.destroyAllWindows()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
ORY-FINGER:UV-unroll finger tactile pipeline with unified Qt dashboard.
|
|
||||||
|
|
||||||
Where to customize the GUI:
|
|
||||||
Layout (sizes, proportions) -> examples/gui/viewer3d/layout.py
|
|
||||||
Colors / QSS -> examples/gui/viewer3d/styles.py
|
|
||||||
Widget rendering logic -> examples/gui/viewer3d/widgets/
|
|
||||||
Window behavior -> examples/gui/viewer3d/dashboard.py
|
|
||||||
|
|
||||||
Recommended entry:
|
|
||||||
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
|
||||||
|
|
||||||
# Make shared packages importable when launched as a file path.
|
|
||||||
_EXAMPLES_DIR = Path(__file__).resolve().parents[2]
|
|
||||||
if str(_EXAMPLES_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_EXAMPLES_DIR))
|
|
||||||
|
|
||||||
from gui.app_paths import configure_frozen_runtime, default_config_path, ensure_examples_import_path
|
|
||||||
|
|
||||||
configure_frozen_runtime()
|
|
||||||
ensure_examples_import_path()
|
|
||||||
|
|
||||||
from gui import run_dashboard
|
|
||||||
from pipeline import PipelineConfig
|
|
||||||
from viz import DEFAULT_FINGER_VIZ_POSE
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_args():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="UV-unroll finger tactile pipeline with unified Qt dashboard"
|
|
||||||
)
|
|
||||||
parser.add_argument("--video", "-v", default="0", help="Camera index or video file path")
|
|
||||||
parser.add_argument("--config", "-c", default=default_config_path(), help="Orisys SDK config")
|
|
||||||
parser.add_argument("--cal", default="", help="Optional calibration file path")
|
|
||||||
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
|
|
||||||
parser.add_argument(
|
|
||||||
"--mesh-map",
|
|
||||||
choices=["flow", "depth"],
|
|
||||||
default="depth",
|
|
||||||
help="Color the 3D mesh from flow magnitude or contact depth.",
|
|
||||||
)
|
|
||||||
parser.add_argument("--threshold", type=float, default=None, help="Mesh/arrow threshold")
|
|
||||||
parser.add_argument("--no-3d", action="store_true", help="Disable 3D mesh view")
|
|
||||||
parser.add_argument(
|
|
||||||
"--viz-pose",
|
|
||||||
default=DEFAULT_FINGER_VIZ_POSE,
|
|
||||||
help="world_to_cam.npy for the default view; pass 'none' to skip",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--cutoff",
|
|
||||||
type=float,
|
|
||||||
default=5.0,
|
|
||||||
help="Low-pass cutoff for force curves (Hz)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--profile",
|
|
||||||
action="store_true",
|
|
||||||
help="Print per-stage timing to the log panel / console",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--profile-every",
|
|
||||||
type=int,
|
|
||||||
default=30,
|
|
||||||
help="When --profile is set, print rolling averages every N frames",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = _parse_args()
|
|
||||||
config = PipelineConfig.from_args(args)
|
|
||||||
sys.exit(run_dashboard(config))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
|
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
|
||||||
- 每个传感器进程都会占用独立的 GPU 显存
|
- 每个传感器进程都会占用独立的 GPU 显存
|
||||||
|
|
||||||
SDK version: 0.4.2
|
SDK version: 0.4.3
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||||
@@ -138,12 +138,12 @@ def main():
|
|||||||
{
|
{
|
||||||
'sensor_id': 0,
|
'sensor_id': 0,
|
||||||
'vid_src': args.video1,
|
'vid_src': args.video1,
|
||||||
'config_name': './config/ddjx01.json'
|
'config_name': 'box'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'sensor_id': 1,
|
'sensor_id': 1,
|
||||||
'vid_src': args.video2, # 可替换为其他视频源或摄像头索引
|
'vid_src': args.video2, # 可替换为其他视频源或摄像头索引
|
||||||
'config_name': './config/ddjx01.json'
|
'config_name': 'box'
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,5 @@
|
|||||||
"""Signal filtering helpers for example force charts."""
|
"""Signal filtering helpers for example force charts."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from orisys.signal_processing import lowpass_filter
|
||||||
|
|
||||||
import numpy as np
|
__all__ = ["lowpass_filter"]
|
||||||
from scipy.signal import butter, filtfilt
|
|
||||||
|
|
||||||
def lowpass_filter(data, cutoff_hz=5, sample_rate_hz=30, order=2) -> np.ndarray:
|
|
||||||
"""Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data."""
|
|
||||||
y = np.asarray(data, dtype=np.float64)
|
|
||||||
if cutoff_hz == 0:
|
|
||||||
return y.copy()
|
|
||||||
if y.size == 0:
|
|
||||||
return y
|
|
||||||
|
|
||||||
nyquist = 0.5 * sample_rate_hz
|
|
||||||
if cutoff_hz < 0 or cutoff_hz >= nyquist:
|
|
||||||
raise ValueError(
|
|
||||||
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
|
|
||||||
)
|
|
||||||
|
|
||||||
b, a = butter(order, cutoff_hz / nyquist, btype="low")
|
|
||||||
taplen = max(len(a), len(b))
|
|
||||||
# scipy.signal.filtfilt requires len(x) > padlen (default 3 * taplen for recent scipy).
|
|
||||||
padlen = 3 * taplen
|
|
||||||
if y.size <= padlen:
|
|
||||||
return y.copy()
|
|
||||||
|
|
||||||
try:
|
|
||||||
return filtfilt(b, a, y)
|
|
||||||
except ValueError:
|
|
||||||
return y.copy()
|
|
||||||
|
|||||||
+2
-2
@@ -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]
|
||||||
@@ -79,7 +79,7 @@ dev_install = true
|
|||||||
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]]
|
||||||
|
|||||||
+25
-11
@@ -1,13 +1,27 @@
|
|||||||
# CuPy - Install the appropriate version based on your CUDA version:
|
# Runtime dependencies for editable / source installs.
|
||||||
# For CUDA 12.x: pip install cupy-cuda12x>=12.3.0
|
# Prefer extras from pyproject.toml:
|
||||||
# For CUDA 13.x: pip install cupy-cuda13x>=13.0.0
|
# pip install -e .
|
||||||
# Or use extras: pip install orisys[cuda12] or pip install orisys[cuda13]
|
# pip install -e ".[gui]"
|
||||||
|
# pip install -e ".[cuda12]"
|
||||||
|
# pip install -e ".[cuda13]"
|
||||||
|
# pip install -e ".[gui,cuda12]"
|
||||||
|
# PyPI users: pip install orisys / "orisys[gui]" / "orisys[cuda12]"
|
||||||
|
|
||||||
|
# Core (same as [project.dependencies])
|
||||||
numpy>=1.24.4
|
numpy>=1.24.4
|
||||||
opencv_python>=4.10.0.84
|
opencv-python>=4.10.0.84
|
||||||
scipy==1.13
|
scipy>=1.10.1
|
||||||
setuptools>=45.2.0
|
numba>=0.59.0
|
||||||
av
|
|
||||||
numba
|
# GUI extras: pip install -e ".[gui]"
|
||||||
pyqt5
|
# open3d>=0.17.0
|
||||||
pyqtgraph
|
# pyqt5>=5.15
|
||||||
|
# pyqtgraph>=0.13
|
||||||
|
|
||||||
|
# CUDA extras (pick one CUDA major version)
|
||||||
|
# CUDA 12.x: pip install -e ".[cuda12]" -> cupy-cuda12x>=12.3.0
|
||||||
|
# CUDA 13.x: pip install -e ".[cuda13]" -> cupy-cuda13x>=13.0.0
|
||||||
|
# Alias: pip install -e ".[cuda]" -> same as cuda12
|
||||||
|
|
||||||
|
# Optional video tooling (not an orisys extra)
|
||||||
|
# av
|
||||||
|
|||||||
@@ -1,961 +0,0 @@
|
|||||||
import os
|
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
|
||||||
# Force pyqtgraph to bind to PyQt5 when multiple Qt bindings are installed.
|
|
||||||
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PyQt5")
|
|
||||||
import cv2
|
|
||||||
import orisys
|
|
||||||
import numpy as np
|
|
||||||
import time
|
|
||||||
import argparse
|
|
||||||
from datetime import datetime
|
|
||||||
import pyqtgraph as pg
|
|
||||||
from PyQt5.QtCore import Qt, QSize, QEvent
|
|
||||||
from PyQt5.QtGui import QImage, QFont, QPixmap, QKeySequence, QCursor
|
|
||||||
from PyQt5.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout, QWidget, QSizePolicy, QShortcut, QApplication
|
|
||||||
from pyqtgraph.Qt import QtGui
|
|
||||||
|
|
||||||
|
|
||||||
# Final desired appearance on the mirrored 1872x1560 (6:5) display.
|
|
||||||
# The desktop is first shown on a 1920x1080 (16:9) screen, then stretched by
|
|
||||||
# the OS onto the 6:5 mirror. We therefore pre-distort the source layout so
|
|
||||||
# the mirrored output looks correct.
|
|
||||||
TARGET_SINGLE_PANEL_W = 936
|
|
||||||
TARGET_ASPECT_W = TARGET_SINGLE_PANEL_W * 2
|
|
||||||
TARGET_VIDEO_FRAME_H = 1053
|
|
||||||
TARGET_BOTTOM_PANEL_H = 507
|
|
||||||
TARGET_ASPECT_H_TOTAL = TARGET_VIDEO_FRAME_H + TARGET_BOTTOM_PANEL_H
|
|
||||||
TARGET_MIRROR_W = 1872
|
|
||||||
TARGET_MIRROR_H = 1560
|
|
||||||
|
|
||||||
SINGLE_PANEL_W = TARGET_SINGLE_PANEL_W
|
|
||||||
ASPECT_W = TARGET_ASPECT_W
|
|
||||||
BOTTOM_PANEL_H = TARGET_BOTTOM_PANEL_H
|
|
||||||
VIDEO_FRAME_W = TARGET_ASPECT_W
|
|
||||||
VIDEO_FRAME_H = TARGET_VIDEO_FRAME_H
|
|
||||||
ASPECT_H_TOTAL = TARGET_ASPECT_H_TOTAL
|
|
||||||
UI_SCALE = 1.0
|
|
||||||
ARROW_STRETCH = 507
|
|
||||||
PLOT_STRETCH = 429
|
|
||||||
VIDEO_LOOP_PATH = "./叠动机械爪_成片.m4v"
|
|
||||||
npoints = 120 # 图表数据长度
|
|
||||||
dispaly_angle = -90 # 箭头图显示角度
|
|
||||||
touch_threshold = 5 # 触碰反应阈值
|
|
||||||
frame_to_recover = 10 # 触碰后等待回复帧数
|
|
||||||
|
|
||||||
# Opneing Hour for keep reading image
|
|
||||||
open_hour = 9
|
|
||||||
close_hour = 17
|
|
||||||
manual_open_enable = True # 控制是否可以手动切换营业状态 (按键G)
|
|
||||||
is_open = True
|
|
||||||
|
|
||||||
# Match arrow panel / Qt style (not pure black)
|
|
||||||
PLOT_THEME_BG = "#0B1016"
|
|
||||||
PLOT_THEME_FG = "#A0A0A0"
|
|
||||||
|
|
||||||
# OS title bar cannot be tinted to #0B1016; frameless removes it (use Alt+drag to move).
|
|
||||||
USE_FRAMELESS_WINDOW = False
|
|
||||||
|
|
||||||
|
|
||||||
def scale_ui_px(px, floor=1):
|
|
||||||
return max(floor, int(round(px * UI_SCALE)))
|
|
||||||
|
|
||||||
|
|
||||||
def compute_source_layout(screen_w, screen_h):
|
|
||||||
"""Pre-distort the app on the source screen so the mirrored monitor is correct."""
|
|
||||||
target_video_ratio = TARGET_VIDEO_FRAME_H / float(TARGET_ASPECT_H_TOTAL)
|
|
||||||
target_bottom_ratio = TARGET_BOTTOM_PANEL_H / float(TARGET_ASPECT_H_TOTAL)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"window_w": max(320, int(screen_w)),
|
|
||||||
"window_h": max(240, int(screen_h)),
|
|
||||||
"video_w": max(320, int(screen_w)),
|
|
||||||
"video_h": max(1, int(round(screen_h * target_video_ratio))),
|
|
||||||
"bottom_h": max(1, int(round(screen_h * target_bottom_ratio))),
|
|
||||||
"single_panel_w": max(1, int(round(screen_w / 2.0))),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class CombinedTactileWindow(QWidget):
|
|
||||||
"""
|
|
||||||
Resizable window that keeps width:height = ASPECT_W : ASPECT_H_TOTAL.
|
|
||||||
Dragging the width or height edge updates the other dimension accordingly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
RESIZE_MARGIN = 8
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._aspect_hw = ASPECT_H_TOTAL / float(ASPECT_W)
|
|
||||||
self._resize_guard = False
|
|
||||||
self.user_closed = False
|
|
||||||
self._drag_anchor = None
|
|
||||||
self._frameless_resize_edge = None
|
|
||||||
self._frameless_resize_start_global = None
|
|
||||||
self._frameless_resize_start_geom = None
|
|
||||||
|
|
||||||
def _min_client_size(self):
|
|
||||||
w = max(320, self.minimumSizeHint().width())
|
|
||||||
h = int(round(w * self._aspect_hw))
|
|
||||||
return w, h
|
|
||||||
|
|
||||||
def _hit_test_resize(self, global_pos):
|
|
||||||
if not USE_FRAMELESS_WINDOW:
|
|
||||||
return None
|
|
||||||
p = self.mapFromGlobal(global_pos)
|
|
||||||
w, h = self.width(), self.height()
|
|
||||||
m = self.RESIZE_MARGIN
|
|
||||||
left = p.x() <= m
|
|
||||||
right = p.x() >= w - m
|
|
||||||
top = p.y() <= m
|
|
||||||
bottom = p.y() >= h - m
|
|
||||||
if top and left:
|
|
||||||
return "tl"
|
|
||||||
if top and right:
|
|
||||||
return "tr"
|
|
||||||
if bottom and left:
|
|
||||||
return "bl"
|
|
||||||
if bottom and right:
|
|
||||||
return "br"
|
|
||||||
if top:
|
|
||||||
return "t"
|
|
||||||
if bottom:
|
|
||||||
return "b"
|
|
||||||
if left:
|
|
||||||
return "l"
|
|
||||||
if right:
|
|
||||||
return "r"
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _cursor_for_edge(self, edge):
|
|
||||||
return {
|
|
||||||
"tl": Qt.SizeFDiagCursor,
|
|
||||||
"br": Qt.SizeFDiagCursor,
|
|
||||||
"tr": Qt.SizeBDiagCursor,
|
|
||||||
"bl": Qt.SizeBDiagCursor,
|
|
||||||
"l": Qt.SizeHorCursor,
|
|
||||||
"r": Qt.SizeHorCursor,
|
|
||||||
"t": Qt.SizeVerCursor,
|
|
||||||
"b": Qt.SizeVerCursor,
|
|
||||||
}.get(edge, Qt.ArrowCursor)
|
|
||||||
|
|
||||||
def _apply_frameless_resize(self, dg):
|
|
||||||
edge = self._frameless_resize_edge
|
|
||||||
g0 = self._frameless_resize_start_geom
|
|
||||||
min_w, min_h = self._min_client_size()
|
|
||||||
dx, dy = dg.x(), dg.y()
|
|
||||||
x0, y0, w0, h0 = g0.x(), g0.y(), g0.width(), g0.height()
|
|
||||||
|
|
||||||
if edge == "br":
|
|
||||||
nw = max(min_w, w0 + dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
self.setGeometry(x0, y0, nw, nh)
|
|
||||||
elif edge == "r":
|
|
||||||
nw = max(min_w, w0 + dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
self.setGeometry(x0, y0, nw, nh)
|
|
||||||
elif edge == "b":
|
|
||||||
nh = max(min_h, h0 + dy)
|
|
||||||
nw = int(round(nh / self._aspect_hw))
|
|
||||||
self.setGeometry(x0, y0, nw, nh)
|
|
||||||
elif edge == "l":
|
|
||||||
nw = max(min_w, w0 - dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
nx = g0.right() - nw
|
|
||||||
self.setGeometry(nx, y0, nw, nh)
|
|
||||||
elif edge == "t":
|
|
||||||
nh = max(min_h, h0 - dy)
|
|
||||||
nw = int(round(nh / self._aspect_hw))
|
|
||||||
ny = g0.bottom() - nh
|
|
||||||
self.setGeometry(x0, ny, nw, nh)
|
|
||||||
elif edge == "tr":
|
|
||||||
nw = max(min_w, w0 + dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
ny = g0.bottom() - nh
|
|
||||||
self.setGeometry(x0, ny, nw, nh)
|
|
||||||
elif edge == "bl":
|
|
||||||
nw = max(min_w, w0 - dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
nx = g0.right() - nw
|
|
||||||
self.setGeometry(nx, y0, nw, nh)
|
|
||||||
elif edge == "tl":
|
|
||||||
nw = max(min_w, w0 - dx)
|
|
||||||
nh = int(round(nw * self._aspect_hw))
|
|
||||||
nx = g0.right() - nw
|
|
||||||
ny = g0.bottom() - nh
|
|
||||||
self.setGeometry(nx, ny, nw, nh)
|
|
||||||
|
|
||||||
def install_drag_filters(self):
|
|
||||||
"""Alt+left-drag to move when frameless; grabs go to this widget."""
|
|
||||||
for w in self.findChildren(QWidget):
|
|
||||||
if w is not self:
|
|
||||||
w.installEventFilter(self)
|
|
||||||
|
|
||||||
def eventFilter(self, watched, event):
|
|
||||||
if not USE_FRAMELESS_WINDOW:
|
|
||||||
return super().eventFilter(watched, event)
|
|
||||||
et = event.type()
|
|
||||||
if et == QEvent.MouseButtonPress and event.button() == Qt.LeftButton:
|
|
||||||
edge = self._hit_test_resize(event.globalPos())
|
|
||||||
if edge is not None:
|
|
||||||
self._frameless_resize_edge = edge
|
|
||||||
self._frameless_resize_start_global = event.globalPos()
|
|
||||||
self._frameless_resize_start_geom = self.geometry()
|
|
||||||
self.grabMouse()
|
|
||||||
return True
|
|
||||||
if QApplication.keyboardModifiers() & Qt.AltModifier:
|
|
||||||
self._drag_anchor = event.globalPos() - self.frameGeometry().topLeft()
|
|
||||||
self.grabMouse()
|
|
||||||
return True
|
|
||||||
if et == QEvent.MouseMove and not event.buttons():
|
|
||||||
edge = self._hit_test_resize(event.globalPos())
|
|
||||||
watched.setCursor(QCursor(self._cursor_for_edge(edge)))
|
|
||||||
return super().eventFilter(watched, event)
|
|
||||||
|
|
||||||
def mouseMoveEvent(self, event):
|
|
||||||
if USE_FRAMELESS_WINDOW and self._frameless_resize_edge and event.buttons() & Qt.LeftButton:
|
|
||||||
dg = event.globalPos() - self._frameless_resize_start_global
|
|
||||||
self._apply_frameless_resize(dg)
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
if (
|
|
||||||
USE_FRAMELESS_WINDOW
|
|
||||||
and self._drag_anchor is not None
|
|
||||||
and event.buttons() & Qt.LeftButton
|
|
||||||
):
|
|
||||||
self.move(event.globalPos() - self._drag_anchor)
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
super().mouseMoveEvent(event)
|
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event):
|
|
||||||
if USE_FRAMELESS_WINDOW and self._frameless_resize_edge and event.button() == Qt.LeftButton:
|
|
||||||
self.releaseMouse()
|
|
||||||
self._frameless_resize_edge = None
|
|
||||||
self._frameless_resize_start_global = None
|
|
||||||
self._frameless_resize_start_geom = None
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
if USE_FRAMELESS_WINDOW and self._drag_anchor is not None and event.button() == Qt.LeftButton:
|
|
||||||
self.releaseMouse()
|
|
||||||
self._drag_anchor = None
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
super().mouseReleaseEvent(event)
|
|
||||||
|
|
||||||
def closeEvent(self, event):
|
|
||||||
self.user_closed = True
|
|
||||||
event.accept()
|
|
||||||
|
|
||||||
def minimumSizeHint(self):
|
|
||||||
w = 320
|
|
||||||
return QSize(w, int(round(w * self._aspect_hw)))
|
|
||||||
|
|
||||||
def resizeEvent(self, event):
|
|
||||||
if self._resize_guard:
|
|
||||||
super().resizeEvent(event)
|
|
||||||
return
|
|
||||||
super().resizeEvent(event)
|
|
||||||
w, h = self.width(), self.height()
|
|
||||||
if w < 8 or h < 8:
|
|
||||||
return
|
|
||||||
old = event.oldSize()
|
|
||||||
self._resize_guard = True
|
|
||||||
try:
|
|
||||||
if old.isValid() and old.width() > 0 and old.height() > 0:
|
|
||||||
dw = abs(w - old.width())
|
|
||||||
dh = abs(h - old.height())
|
|
||||||
if dw >= dh:
|
|
||||||
new_w, new_h = w, int(round(w * self._aspect_hw))
|
|
||||||
else:
|
|
||||||
new_w, new_h = int(round(h / self._aspect_hw)), h
|
|
||||||
else:
|
|
||||||
new_w, new_h = w, int(round(w * self._aspect_hw))
|
|
||||||
if new_w != w or new_h != h:
|
|
||||||
self.resize(new_w, new_h)
|
|
||||||
finally:
|
|
||||||
self._resize_guard = False
|
|
||||||
|
|
||||||
|
|
||||||
def compute_arrow_canvas_side(panel_widget):
|
|
||||||
"""
|
|
||||||
Pixel size of the square arrow canvas from the window client area and the
|
|
||||||
fixed horizontal split ARROW_STRETCH : PLOT_STRETCH (matches QHBoxLayout stretch).
|
|
||||||
"""
|
|
||||||
w = panel_widget.width()
|
|
||||||
h = panel_widget.height()
|
|
||||||
if w < 16 or h < 16:
|
|
||||||
return max(32, ASPECT_H_TOTAL)
|
|
||||||
w_arrow = int(round(w * ARROW_STRETCH / float(ARROW_STRETCH + PLOT_STRETCH)))
|
|
||||||
# Keep the rendered arrow image square inside the left panel.
|
|
||||||
return max(32, min(h, w_arrow))
|
|
||||||
|
|
||||||
|
|
||||||
def draw_arrows_wuxi(
|
|
||||||
frame,
|
|
||||||
optical_flow,
|
|
||||||
fps=True,
|
|
||||||
threshold=5,
|
|
||||||
grid_spacing=40,
|
|
||||||
arrow_scale=1.0,
|
|
||||||
margin=0.8,
|
|
||||||
colormap=cv2.COLORMAP_PARULA,
|
|
||||||
below_threshold_color=(0, 0, 255),
|
|
||||||
edge_margin=15,
|
|
||||||
target_size=936,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Draw colored optical flow arrows on the image by magnitude (JET colormap).
|
|
||||||
Resizes frame and flow to target_size x target_size; scales flow vectors and
|
|
||||||
grid_spacing / margins / threshold so arrow density matches the original resolution.
|
|
||||||
"""
|
|
||||||
image = frame.copy()
|
|
||||||
if len(image.shape) == 2 or image.shape[2] == 1:
|
|
||||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
|
||||||
|
|
||||||
h0, w0 = image.shape[:2]
|
|
||||||
if h0 != target_size or w0 != target_size:
|
|
||||||
sx = target_size / float(w0)
|
|
||||||
sy = target_size / float(h0)
|
|
||||||
image = cv2.resize(image, (target_size, target_size), interpolation=cv2.INTER_LINEAR)
|
|
||||||
u = cv2.resize(optical_flow[:, :, 0], (target_size, target_size), interpolation=cv2.INTER_LINEAR) * sx
|
|
||||||
v = cv2.resize(optical_flow[:, :, 1], (target_size, target_size), interpolation=cv2.INTER_LINEAR) * sy
|
|
||||||
optical_flow = np.stack([u, v], axis=2)
|
|
||||||
scale = max(sx, sy)
|
|
||||||
grid_spacing = max(4, int(round(grid_spacing * scale)))
|
|
||||||
edge_margin = max(1, int(round(edge_margin * scale)))
|
|
||||||
threshold = float(threshold) * scale
|
|
||||||
|
|
||||||
h, w = image.shape[:2]
|
|
||||||
# Arrow grid in pixel coordinates (assume frame and flow same size)
|
|
||||||
rr = np.arange(grid_spacing // 2, h, grid_spacing)
|
|
||||||
cc = np.arange(grid_spacing // 2, w, grid_spacing)
|
|
||||||
rr, cc = np.meshgrid(rr, cc, indexing="ij")
|
|
||||||
rr, cc = rr.reshape(-1), cc.reshape(-1)
|
|
||||||
|
|
||||||
# Displacement at each grid point (flow stored as half-displacement)
|
|
||||||
disp = 2.0 * optical_flow[rr, cc, :]
|
|
||||||
mag = np.linalg.norm(disp, axis=1)
|
|
||||||
dx = disp[:, 0] / arrow_scale
|
|
||||||
dy = disp[:, 1] / arrow_scale
|
|
||||||
length = np.sqrt(dx * dx + dy * dy)
|
|
||||||
# Cap arrow length at grid_spacing
|
|
||||||
scale = np.where(length > grid_spacing*margin, grid_spacing / np.maximum(length, 1e-9), 1.0)*margin
|
|
||||||
dx, dy = dx * scale, dy * scale
|
|
||||||
end_x = (cc + dx).astype(np.int32)
|
|
||||||
end_y = (rr + dy).astype(np.int32)
|
|
||||||
|
|
||||||
valid = (
|
|
||||||
(cc >= edge_margin) & (cc < w - edge_margin) & (rr >= edge_margin) & (rr < h - edge_margin)
|
|
||||||
& (end_x >= 0) & (end_x < w) & (end_y >= 0) & (end_y < h)
|
|
||||||
)
|
|
||||||
mag_range = mag.max() - mag.min()
|
|
||||||
norm_mag = ((mag - mag.min()) / mag_range * 255).astype(np.uint8) if mag_range > 0 else np.zeros_like(mag, dtype=np.uint8)
|
|
||||||
norm_mag[mag < threshold] = 0
|
|
||||||
colors = cv2.applyColorMap(norm_mag.reshape(-1, 1), colormap)
|
|
||||||
|
|
||||||
for i in np.where(valid)[0]:
|
|
||||||
pt1, pt2 = (int(cc[i]), int(rr[i])), (int(end_x[i]), int(end_y[i]))
|
|
||||||
color = tuple(int(c) for c in colors[i, 0])
|
|
||||||
if mag[i] < threshold:
|
|
||||||
cv2.circle(image, pt1, radius=1, color=below_threshold_color, thickness=-1)
|
|
||||||
else:
|
|
||||||
# thickness = max(1, int(2 * mag[i] / mag.max())) if mag.max() > 0 else 2
|
|
||||||
thickness = 1
|
|
||||||
cv2.arrowedLine(image, pt1, pt2, color, thickness=thickness, tipLength=0.2)
|
|
||||||
return image
|
|
||||||
|
|
||||||
|
|
||||||
def set_arrow_label_bgr(label, bgr_image):
|
|
||||||
"""Show a BGR OpenCV image in a QLabel using source-screen pre-distortion."""
|
|
||||||
rgb = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB)
|
|
||||||
rgb = np.ascontiguousarray(rgb)
|
|
||||||
h, w, ch = rgb.shape
|
|
||||||
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy()
|
|
||||||
pm = QPixmap.fromImage(qimg)
|
|
||||||
lw, lh = label.width(), label.height()
|
|
||||||
if lw > 0 and lh > 0 and (pm.width() != lw or pm.height() != lh):
|
|
||||||
pm = pm.scaled(lw, lh, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
|
|
||||||
label.setPixmap(pm)
|
|
||||||
|
|
||||||
def set_video_label_bgr(label, bgr_image):
|
|
||||||
"""Show loop video in the top frame using the fitted display size."""
|
|
||||||
rgb = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB)
|
|
||||||
rgb = np.ascontiguousarray(rgb)
|
|
||||||
h, w, ch = rgb.shape
|
|
||||||
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy()
|
|
||||||
pm = QPixmap.fromImage(qimg)
|
|
||||||
lw, lh = label.width(), label.height()
|
|
||||||
if lw > 0 and lh > 0 and (pm.width() != lw or pm.height() != lh):
|
|
||||||
pm = pm.scaled(lw, lh, Qt.IgnoreAspectRatio, Qt.FastTransformation)
|
|
||||||
label.setPixmap(pm)
|
|
||||||
|
|
||||||
|
|
||||||
def read_loop_video_frame_realtime(video_cap, video_clock):
|
|
||||||
"""Read loop-video frame paced to wall clock; skip frames when main loop is slow."""
|
|
||||||
if video_cap is None or not video_cap.isOpened():
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
now = time.perf_counter()
|
|
||||||
fps = max(1.0, float(video_clock.get("fps", 30.0)))
|
|
||||||
frame_dt = 1.0 / fps
|
|
||||||
next_ts = video_clock.get("next_ts")
|
|
||||||
if next_ts is None:
|
|
||||||
next_ts = now
|
|
||||||
|
|
||||||
frames_due = 1
|
|
||||||
if now > next_ts + frame_dt:
|
|
||||||
frames_due += int((now - next_ts) / frame_dt)
|
|
||||||
# Cap skip burst to avoid long catch-up loops after pauses.
|
|
||||||
frames_due = min(frames_due, int(max(2.0, fps)))
|
|
||||||
|
|
||||||
for _ in range(frames_due - 1):
|
|
||||||
if not video_cap.grab():
|
|
||||||
video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
||||||
if not video_cap.grab():
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
vret, vframe = video_cap.read()
|
|
||||||
if not vret:
|
|
||||||
video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
||||||
vret, vframe = video_cap.read()
|
|
||||||
if not vret:
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
next_ts = next_ts + frames_due * frame_dt
|
|
||||||
if next_ts < now - 2.0 * frame_dt:
|
|
||||||
next_ts = now
|
|
||||||
video_clock["next_ts"] = next_ts
|
|
||||||
return True, vframe
|
|
||||||
|
|
||||||
|
|
||||||
def make_gradient_brush(color_hex, max_alpha=150):
|
|
||||||
"""
|
|
||||||
创建一个从上到下渐变的笔刷 (兼容 PyQt5 和 PyQt6)
|
|
||||||
"""
|
|
||||||
c_top = QtGui.QColor(color_hex)
|
|
||||||
c_top.setAlpha(max_alpha) # 顶部半透明
|
|
||||||
|
|
||||||
c_bot = QtGui.QColor(color_hex)
|
|
||||||
c_bot.setAlpha(0) # 底部全透明
|
|
||||||
|
|
||||||
# 创建线性渐变:(0,0)到(0,1) 代表从控件顶部到底部
|
|
||||||
grad = QtGui.QLinearGradient(0, 0, 0, 1)
|
|
||||||
|
|
||||||
if hasattr(QtGui.QGradient, 'CoordinateMode'):
|
|
||||||
grad.setCoordinateMode(QtGui.QGradient.CoordinateMode.ObjectBoundingMode)
|
|
||||||
else:
|
|
||||||
grad.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
|
|
||||||
# ==============================================
|
|
||||||
|
|
||||||
grad.setColorAt(0, c_top) # 0.0 (顶端) 颜色
|
|
||||||
grad.setColorAt(1, c_bot) # 1.0 (底端) 颜色
|
|
||||||
|
|
||||||
return QtGui.QBrush(grad)
|
|
||||||
|
|
||||||
def create_plot(plot_container):
|
|
||||||
pg.setConfigOption("background", PLOT_THEME_BG)
|
|
||||||
pg.setConfigOption("foreground", PLOT_THEME_FG)
|
|
||||||
pg.setConfigOptions(antialias=True) # 抗锯齿
|
|
||||||
theme_color = '#00E5FF' # '#00E5FF'
|
|
||||||
line_pen = pg.mkPen(color=theme_color, width=2)
|
|
||||||
grad_brush = make_gradient_brush(theme_color, max_alpha=120)
|
|
||||||
|
|
||||||
axis_font = QFont("Segoe UI", scale_ui_px(11, floor=8))
|
|
||||||
p1 = plot_container.addPlot()
|
|
||||||
p1.setLabel('left', 'N')
|
|
||||||
p1.getAxis('left').label.setFont(axis_font)
|
|
||||||
p1.getAxis('left').label.setRotation(dispaly_angle)
|
|
||||||
p1.showGrid(x=True, y=True, alpha=0.3)
|
|
||||||
plot_container.nextRow()
|
|
||||||
p2 = plot_container.addPlot()
|
|
||||||
p2.setLabel('left', 'X')
|
|
||||||
p2.getAxis('left').label.setFont(axis_font)
|
|
||||||
p2.getAxis('left').label.setRotation(dispaly_angle)
|
|
||||||
p2.showGrid(x=True, y=True, alpha=0.3)
|
|
||||||
plot_container.nextRow()
|
|
||||||
p3 = plot_container.addPlot()
|
|
||||||
p3.setLabel('left', 'Y')
|
|
||||||
|
|
||||||
# p3.setLabel('bottom', 'Time', units='s')
|
|
||||||
p3.getAxis('left').label.setFont(axis_font)
|
|
||||||
p3.getAxis('left').label.setRotation(dispaly_angle)
|
|
||||||
p3.getAxis('bottom').label.setFont(axis_font)
|
|
||||||
p3.showGrid(x=True, y=True, alpha=0.3)
|
|
||||||
_plot_bg = pg.mkColor(PLOT_THEME_BG)
|
|
||||||
for p in (p1, p2, p3):
|
|
||||||
p.getAxis("bottom").setStyle(showValues=False)
|
|
||||||
p.getAxis("left").setStyle(showValues=False)
|
|
||||||
# Keep title as "N" / "X" / "Y" — no PyQtGraph "(x0.001)" suffix on small ranges.
|
|
||||||
p.getAxis("left").enableAutoSIPrefix(False)
|
|
||||||
# PlotItem.setBackground exists only in newer pyqtgraph; ViewBox works on older installs.
|
|
||||||
p.vb.setBackgroundColor(_plot_bg)
|
|
||||||
plot_af = p1.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
|
|
||||||
plot_tfx = p2.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
|
|
||||||
plot_tfy = p3.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
|
|
||||||
return p1, p2, p3, plot_af, plot_tfx, plot_tfy
|
|
||||||
|
|
||||||
def _plot_y_range(y):
|
|
||||||
"""Avoid degenerate Y span; explicit x/y in setData so older PyQtGraph never plots time as y."""
|
|
||||||
mn = float(np.min(y))
|
|
||||||
mx = float(np.max(y))
|
|
||||||
if not (np.isfinite(mn) and np.isfinite(mx)):
|
|
||||||
return 0.0, 1.0
|
|
||||||
if mx - mn <= 1e-15:
|
|
||||||
mid = 0.5 * (mn + mx)
|
|
||||||
return mid - 0.5, mid + 0.5
|
|
||||||
pad = (mx - mn) * 0.05
|
|
||||||
return mn - pad, mx + pad
|
|
||||||
|
|
||||||
|
|
||||||
def update(plot_af, plot_tfx, plot_tfy, data, p1, p2, p3):
|
|
||||||
# global plot_af, plot_tfx, plot_tfy, data, p1, p2, p3
|
|
||||||
plot_af.setData(data[:, 0, 0], data[:, 1, 0])
|
|
||||||
p1.setYRange(*_plot_y_range(data[:, 1, 0]))
|
|
||||||
plot_tfx.setData(data[:, 0, 1], data[:, 1, 1])
|
|
||||||
p2.setYRange(*_plot_y_range(data[:, 1, 1]))
|
|
||||||
plot_tfy.setData(data[:, 0, 2], data[:, 1, 2])
|
|
||||||
p3.setYRange(*_plot_y_range(data[:, 1, 2]))
|
|
||||||
|
|
||||||
|
|
||||||
def build_sensor_panel(parent_layout):
|
|
||||||
panel = QWidget()
|
|
||||||
panel_layout = QHBoxLayout(panel)
|
|
||||||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
panel_layout.setSpacing(0)
|
|
||||||
|
|
||||||
# Ignored: do not let pixmap/content drive QLabel size — layout stretch sets arrow vs plot widths.
|
|
||||||
_policy_layout = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
|
|
||||||
|
|
||||||
arrow_label = QLabel()
|
|
||||||
arrow_label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
|
||||||
arrow_label.setStyleSheet(
|
|
||||||
"background-color: #0B1016; margin: 0; padding: 0; border: none;"
|
|
||||||
)
|
|
||||||
arrow_label.setSizePolicy(_policy_layout)
|
|
||||||
arrow_label.setMinimumSize(0, 0)
|
|
||||||
arrow_label.setScaledContents(False)
|
|
||||||
panel_layout.addWidget(arrow_label, stretch=ARROW_STRETCH)
|
|
||||||
|
|
||||||
plot_widget = pg.GraphicsLayoutWidget()
|
|
||||||
plot_widget.setBackground(PLOT_THEME_BG)
|
|
||||||
plot_widget.setSizePolicy(_policy_layout)
|
|
||||||
plot_widget.setMinimumSize(0, 0)
|
|
||||||
panel_layout.addWidget(plot_widget, stretch=PLOT_STRETCH)
|
|
||||||
|
|
||||||
disconnected_label = QLabel("已断开")
|
|
||||||
disconnected_label.setAlignment(Qt.AlignCenter)
|
|
||||||
disconnected_label.setStyleSheet(
|
|
||||||
f"background-color: #0B1016; color: #FF4D4D; font-size: {scale_ui_px(54, floor=24)}px; "
|
|
||||||
"font-weight: bold; letter-spacing: 2px; margin: 0; padding: 0; border: none;"
|
|
||||||
)
|
|
||||||
disconnected_label.hide()
|
|
||||||
panel_layout.addWidget(disconnected_label, stretch=SINGLE_PANEL_W)
|
|
||||||
|
|
||||||
parent_layout.addWidget(panel, stretch=1)
|
|
||||||
|
|
||||||
p1, p2, p3, plot_af, plot_tfx, plot_tfy = create_plot(plot_widget)
|
|
||||||
data = np.zeros([npoints, 2, 3])
|
|
||||||
for i in range(3):
|
|
||||||
data[:, 0, i] = np.linspace(0.0, npoints / 30.0, npoints)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"panel": panel,
|
|
||||||
"arrow_label": arrow_label,
|
|
||||||
"plot_widget": plot_widget,
|
|
||||||
"disconnected_label": disconnected_label,
|
|
||||||
"p1": p1,
|
|
||||||
"p2": p2,
|
|
||||||
"p3": p3,
|
|
||||||
"plot_af": plot_af,
|
|
||||||
"plot_tfx": plot_tfx,
|
|
||||||
"plot_tfy": plot_tfy,
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 实时图表:单窗口,两套并排(左 sensor1,右 sensor2)
|
|
||||||
pg.setConfigOption("background", PLOT_THEME_BG)
|
|
||||||
pg.setConfigOption("foreground", PLOT_THEME_FG)
|
|
||||||
app = pg.mkQApp("Wuxi Demo")
|
|
||||||
screen = app.primaryScreen()
|
|
||||||
screen_geometry = screen.availableGeometry() if screen is not None else None
|
|
||||||
screen_w = screen_geometry.width() if screen_geometry is not None else 1920
|
|
||||||
screen_h = screen_geometry.height() if screen_geometry is not None else 1080
|
|
||||||
source_layout = compute_source_layout(screen_w, screen_h)
|
|
||||||
ASPECT_W = source_layout["window_w"]
|
|
||||||
ASPECT_H_TOTAL = source_layout["window_h"]
|
|
||||||
VIDEO_FRAME_W = source_layout["video_w"]
|
|
||||||
VIDEO_FRAME_H = source_layout["video_h"]
|
|
||||||
BOTTOM_PANEL_H = source_layout["bottom_h"]
|
|
||||||
SINGLE_PANEL_W = source_layout["single_panel_w"]
|
|
||||||
UI_SCALE = max(0.1, screen_h / float(TARGET_ASPECT_H_TOTAL))
|
|
||||||
|
|
||||||
combined_win = CombinedTactileWindow()
|
|
||||||
combined_win.setWindowTitle("Wuxi tactile — dual flow + forces")
|
|
||||||
main_layout = QVBoxLayout(combined_win)
|
|
||||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
main_layout.setSpacing(0)
|
|
||||||
|
|
||||||
video_label = QLabel()
|
|
||||||
video_label.setAlignment(Qt.AlignCenter)
|
|
||||||
video_label.setStyleSheet("background-color: #000000; margin: 0; padding: 0; border: none;")
|
|
||||||
video_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
||||||
video_label.setMinimumSize(0, 0)
|
|
||||||
main_layout.addWidget(video_label, stretch=VIDEO_FRAME_H)
|
|
||||||
|
|
||||||
bottom_container = QWidget()
|
|
||||||
bottom_layout = QHBoxLayout(bottom_container)
|
|
||||||
bottom_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
bottom_layout.setSpacing(0)
|
|
||||||
main_layout.addWidget(bottom_container, stretch=BOTTOM_PANEL_H)
|
|
||||||
|
|
||||||
panel1_ui = build_sensor_panel(bottom_layout)
|
|
||||||
panel2_ui = build_sensor_panel(bottom_layout)
|
|
||||||
|
|
||||||
if USE_FRAMELESS_WINDOW:
|
|
||||||
combined_win.setWindowFlags(Qt.Window | Qt.FramelessWindowHint)
|
|
||||||
# Client area matches theme (no bright border flash on frameless)
|
|
||||||
combined_win.setStyleSheet(f"background-color: {PLOT_THEME_BG};")
|
|
||||||
combined_win.resize(ASPECT_W, ASPECT_H_TOTAL)
|
|
||||||
combined_win.install_drag_filters()
|
|
||||||
combined_win.showFullScreen()
|
|
||||||
closed_hour_overlay = QLabel(combined_win)
|
|
||||||
closed_hour_overlay.setAlignment(Qt.AlignCenter)
|
|
||||||
closed_hour_overlay.setStyleSheet(
|
|
||||||
f"background-color: {PLOT_THEME_BG}; color: #A0A0A0; "
|
|
||||||
f"font-size: {scale_ui_px(72, floor=32)}px; font-weight: bold; "
|
|
||||||
"margin: 0; padding: 0; border: none;"
|
|
||||||
)
|
|
||||||
closed_hour_overlay.setText("Closed hour\n非营业时间")
|
|
||||||
closed_hour_overlay.hide()
|
|
||||||
start_time = None # Will be set when sensor starts
|
|
||||||
fps = 30.0
|
|
||||||
|
|
||||||
|
|
||||||
def outside_opening_hours(now):
|
|
||||||
return now.hour < open_hour or now.hour >= close_hour
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
global start_time
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--video1","-v1", type=str, default="0", help="视频文件路径")
|
|
||||||
parser.add_argument("--video2","-v2", type=str, default="0", help="视频文件路径")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
session_start = datetime.now()
|
|
||||||
print(
|
|
||||||
f"[session] START {session_start:%Y-%m-%d %H:%M:%S} (long-run / crash log marker)",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
if USE_FRAMELESS_WINDOW:
|
|
||||||
print(
|
|
||||||
"[window] Frameless: resize = drag window edges/corners; move = Alt+left-drag; quit = Q.",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
sensor1 = None
|
|
||||||
sensor2 = None
|
|
||||||
video_cap = None
|
|
||||||
try:
|
|
||||||
input1 = int(args.video1)
|
|
||||||
input2 = int(args.video2)
|
|
||||||
is_camera = True
|
|
||||||
except:
|
|
||||||
is_camera = False
|
|
||||||
try:
|
|
||||||
if is_camera:
|
|
||||||
sensor1 = orisys.Sensor(
|
|
||||||
input1,
|
|
||||||
config_name="./config/ddjx01_wuxi.json",
|
|
||||||
cal_path="./config/dd01a_0306.npy",
|
|
||||||
isstitch=True,
|
|
||||||
cuda=False,
|
|
||||||
verbose=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
sensor2 = orisys.Sensor(
|
|
||||||
input2,
|
|
||||||
config_name="./config/ddjx01_wuxi.json",
|
|
||||||
cal_path="./config/dd01a_0306.npy",
|
|
||||||
isstitch=True,
|
|
||||||
cuda=False,
|
|
||||||
verbose=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
sensor1 = orisys.Sensor(
|
|
||||||
args.video1,
|
|
||||||
config_name="./config/ddjx01_wuxi.json",
|
|
||||||
cal_path="./config/dd01a_0306.npy",
|
|
||||||
isstitch=True,
|
|
||||||
cuda=False,
|
|
||||||
verbose=False,
|
|
||||||
)
|
|
||||||
sensor2 = orisys.Sensor(
|
|
||||||
args.video2,
|
|
||||||
config_name="./config/ddjx01_wuxi.json",
|
|
||||||
cal_path="./config/dd01a_0306.npy",
|
|
||||||
isstitch=True,
|
|
||||||
cuda=False,
|
|
||||||
verbose=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _shortcut_reset_tracker():
|
|
||||||
sensor1.reset()
|
|
||||||
sensor2.reset()
|
|
||||||
print("传感器已重置 (快捷键 R)", flush=True)
|
|
||||||
|
|
||||||
def _shortcut_quit():
|
|
||||||
combined_win.close()
|
|
||||||
def _shortcut_toggle_open():
|
|
||||||
global is_open
|
|
||||||
is_open = not is_open
|
|
||||||
print(f"营业状态: {'营业' if is_open else '非营业'}", flush=True)
|
|
||||||
|
|
||||||
_sc_r = QShortcut(QKeySequence(Qt.Key_R), combined_win)
|
|
||||||
_sc_r.activated.connect(_shortcut_reset_tracker)
|
|
||||||
if manual_open_enable:
|
|
||||||
_sc_g = QShortcut(QKeySequence(Qt.Key_G), combined_win)
|
|
||||||
_sc_g.activated.connect(_shortcut_toggle_open)
|
|
||||||
_sc_q = QShortcut(QKeySequence(Qt.Key_Q), combined_win)
|
|
||||||
_sc_q.activated.connect(_shortcut_quit)
|
|
||||||
|
|
||||||
video_cap = cv2.VideoCapture(VIDEO_LOOP_PATH)
|
|
||||||
video_clock = {"fps": 30.0, "next_ts": None}
|
|
||||||
if not video_cap.isOpened():
|
|
||||||
print(f"[video] Failed to open loop video: {VIDEO_LOOP_PATH}", flush=True)
|
|
||||||
else:
|
|
||||||
src_fps = float(video_cap.get(cv2.CAP_PROP_FPS) or 0.0)
|
|
||||||
if np.isfinite(src_fps) and src_fps >= 1.0:
|
|
||||||
video_clock["fps"] = src_fps
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
is_save = False
|
|
||||||
frame_count = 0
|
|
||||||
writer = None
|
|
||||||
sensor1_active = True
|
|
||||||
sensor2_active = True
|
|
||||||
# Local to main(): assignments inside the loop make this local; must exist
|
|
||||||
# before the first iteration that skips the closed branch.
|
|
||||||
start_open = False
|
|
||||||
while True:
|
|
||||||
now = datetime.now()
|
|
||||||
if outside_opening_hours(now) or not is_open:
|
|
||||||
start_open = False
|
|
||||||
closed_hour_overlay.setGeometry(0, 0, combined_win.width(), combined_win.height())
|
|
||||||
closed_hour_overlay.raise_()
|
|
||||||
closed_hour_overlay.show()
|
|
||||||
app.processEvents()
|
|
||||||
if combined_win.user_closed or not combined_win.isVisible():
|
|
||||||
print("\n窗口已关闭,退出程序")
|
|
||||||
if writer is not None:
|
|
||||||
writer.release()
|
|
||||||
writer = None
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not start_open:
|
|
||||||
start_open = True
|
|
||||||
sensor1.reset()
|
|
||||||
sensor2.reset()
|
|
||||||
print(f"传感器已重置: {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
|
||||||
closed_hour_overlay.hide()
|
|
||||||
|
|
||||||
if video_cap is not None and video_cap.isOpened():
|
|
||||||
vret, vframe = read_loop_video_frame_realtime(video_cap, video_clock)
|
|
||||||
if vret:
|
|
||||||
if vframe.shape[1] != VIDEO_FRAME_W or vframe.shape[0] != VIDEO_FRAME_H:
|
|
||||||
vframe = cv2.resize(vframe, (VIDEO_FRAME_W, VIDEO_FRAME_H), interpolation=cv2.INTER_LINEAR)
|
|
||||||
set_video_label_bgr(video_label, vframe)
|
|
||||||
|
|
||||||
if sensor1_active:
|
|
||||||
img1_valid = sensor1.get_img()
|
|
||||||
if img1_valid is None:
|
|
||||||
panel1_ui["arrow_label"].hide()
|
|
||||||
panel1_ui["plot_widget"].hide()
|
|
||||||
panel1_ui["disconnected_label"].show()
|
|
||||||
sensor1_active = False
|
|
||||||
print(f"Sensor1 disconnected. Showing DISCONNECTED screen. {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
|
||||||
else:
|
|
||||||
sensor1.compute_deformation(check_motion=True, tail_frames=frame_to_recover, threshold=touch_threshold)
|
|
||||||
_, fn, fx, fy, flow = sensor1.read_info(
|
|
||||||
sensor1.info.FPS,
|
|
||||||
sensor1.info.FNORMAL,
|
|
||||||
sensor1.info.FSHEARX,
|
|
||||||
sensor1.info.FSHEARY,
|
|
||||||
sensor1.info.VRAW,
|
|
||||||
)
|
|
||||||
_bg_bgr = (22, 16, 11)
|
|
||||||
_img = sensor1.img
|
|
||||||
_h, _w = _img.shape[:2]
|
|
||||||
black_img = np.empty((_h, _w, 3), dtype=_img.dtype)
|
|
||||||
black_img[:, :, 0] = _bg_bgr[0]
|
|
||||||
black_img[:, :, 1] = _bg_bgr[1]
|
|
||||||
black_img[:, :, 2] = _bg_bgr[2]
|
|
||||||
lw, lh = panel1_ui["arrow_label"].width(), panel1_ui["arrow_label"].height()
|
|
||||||
if lw > 16 and lh > 16:
|
|
||||||
side = max(32, min(lw, lh))
|
|
||||||
else:
|
|
||||||
side = compute_arrow_canvas_side(panel1_ui["panel"])
|
|
||||||
arrows = draw_arrows_wuxi(
|
|
||||||
black_img,
|
|
||||||
flow,
|
|
||||||
threshold=2,
|
|
||||||
grid_spacing=20,
|
|
||||||
arrow_scale=0.5,
|
|
||||||
below_threshold_color=(160, 160, 160),
|
|
||||||
target_size=int(side),
|
|
||||||
)
|
|
||||||
set_arrow_label_bgr(panel1_ui["arrow_label"], arrows)
|
|
||||||
data1 = panel1_ui["data"]
|
|
||||||
for i in range(3):
|
|
||||||
data1[:-1, 0, i] = data1[1:, 0, i]
|
|
||||||
data1[:-1, 1, i] = data1[1:, 1, i]
|
|
||||||
data1[-1, 0, :] = frame_count / 15.0
|
|
||||||
data1[-1, 1, 0] = fn
|
|
||||||
data1[-1, 1, 1] = abs(fx)
|
|
||||||
data1[-1, 1, 2] = abs(fy)
|
|
||||||
update(
|
|
||||||
panel1_ui["plot_af"],
|
|
||||||
panel1_ui["plot_tfx"],
|
|
||||||
panel1_ui["plot_tfy"],
|
|
||||||
data1,
|
|
||||||
panel1_ui["p1"],
|
|
||||||
panel1_ui["p2"],
|
|
||||||
panel1_ui["p3"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if sensor2_active:
|
|
||||||
img2_valid = sensor2.get_img()
|
|
||||||
if img2_valid is None:
|
|
||||||
panel2_ui["arrow_label"].hide()
|
|
||||||
panel2_ui["plot_widget"].hide()
|
|
||||||
panel2_ui["disconnected_label"].show()
|
|
||||||
sensor2_active = False
|
|
||||||
print(f"Sensor2 disconnected. Showing DISCONNECTED screen. {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
|
||||||
else:
|
|
||||||
sensor2.compute_deformation(check_motion=True, tail_frames=frame_to_recover, threshold=touch_threshold)
|
|
||||||
_, fn2, fx2, fy2, flow2 = sensor2.read_info(
|
|
||||||
sensor2.info.FPS,
|
|
||||||
sensor2.info.FNORMAL,
|
|
||||||
sensor2.info.FSHEARX,
|
|
||||||
sensor2.info.FSHEARY,
|
|
||||||
sensor2.info.VRAW,
|
|
||||||
)
|
|
||||||
_bg_bgr2 = (22, 16, 11)
|
|
||||||
_img2 = sensor2.img
|
|
||||||
_h2, _w2 = _img2.shape[:2]
|
|
||||||
black_img2 = np.empty((_h2, _w2, 3), dtype=_img2.dtype)
|
|
||||||
black_img2[:, :, 0] = _bg_bgr2[0]
|
|
||||||
black_img2[:, :, 1] = _bg_bgr2[1]
|
|
||||||
black_img2[:, :, 2] = _bg_bgr2[2]
|
|
||||||
lw2, lh2 = panel2_ui["arrow_label"].width(), panel2_ui["arrow_label"].height()
|
|
||||||
if lw2 > 16 and lh2 > 16:
|
|
||||||
side2 = max(32, min(lw2, lh2))
|
|
||||||
else:
|
|
||||||
side2 = compute_arrow_canvas_side(panel2_ui["panel"])
|
|
||||||
arrows2 = draw_arrows_wuxi(
|
|
||||||
black_img2,
|
|
||||||
flow2,
|
|
||||||
threshold=2,
|
|
||||||
grid_spacing=20,
|
|
||||||
arrow_scale=0.5,
|
|
||||||
below_threshold_color=(160, 160, 160),
|
|
||||||
target_size=int(side2),
|
|
||||||
)
|
|
||||||
set_arrow_label_bgr(panel2_ui["arrow_label"], arrows2)
|
|
||||||
data2 = panel2_ui["data"]
|
|
||||||
for i in range(3):
|
|
||||||
data2[:-1, 0, i] = data2[1:, 0, i]
|
|
||||||
data2[:-1, 1, i] = data2[1:, 1, i]
|
|
||||||
data2[-1, 0, :] = frame_count / 15.0
|
|
||||||
data2[-1, 1, 0] = fn2
|
|
||||||
data2[-1, 1, 1] = abs(fx2)
|
|
||||||
data2[-1, 1, 2] = abs(fy2)
|
|
||||||
update(
|
|
||||||
panel2_ui["plot_af"],
|
|
||||||
panel2_ui["plot_tfx"],
|
|
||||||
panel2_ui["plot_tfy"],
|
|
||||||
data2,
|
|
||||||
panel2_ui["p1"],
|
|
||||||
panel2_ui["p2"],
|
|
||||||
panel2_ui["p3"],
|
|
||||||
)
|
|
||||||
|
|
||||||
app.processEvents()
|
|
||||||
now = datetime.now()
|
|
||||||
if now.hour == 9 and now.minute == 0 and now.second == 0:
|
|
||||||
if sensor1_active:
|
|
||||||
sensor1.reset()
|
|
||||||
if sensor2_active:
|
|
||||||
sensor2.reset()
|
|
||||||
print(
|
|
||||||
f"[session] 传感器已重置: {now:%Y-%m-%d %H:%M:%S} ",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not sensor1_active and not sensor2_active:
|
|
||||||
while not combined_win.user_closed and combined_win.isVisible():
|
|
||||||
app.processEvents()
|
|
||||||
time.sleep(0.05)
|
|
||||||
break
|
|
||||||
|
|
||||||
frame_count += 1
|
|
||||||
app.processEvents()
|
|
||||||
|
|
||||||
if combined_win.user_closed or not combined_win.isVisible():
|
|
||||||
print("\n窗口已关闭,退出程序")
|
|
||||||
if writer is not None:
|
|
||||||
writer.release()
|
|
||||||
writer = None
|
|
||||||
break
|
|
||||||
|
|
||||||
# Convert combined window to image (arrows + curves)
|
|
||||||
pixmap = combined_win.grab()
|
|
||||||
# Convert QPixmap to QImage
|
|
||||||
qimg = pixmap.toImage()
|
|
||||||
# Convert QImage to numpy array (RGB format)
|
|
||||||
qimg = qimg.convertToFormat(QImage.Format_RGB888)
|
|
||||||
width = qimg.width()
|
|
||||||
height = qimg.height()
|
|
||||||
# Convert QImage to numpy array
|
|
||||||
ptr = qimg.bits()
|
|
||||||
ptr.setsize(height * width * 3)
|
|
||||||
arr = np.array(ptr, dtype=np.uint8).reshape((height, width, 3))
|
|
||||||
|
|
||||||
except BaseException as exc:
|
|
||||||
session_abnormal = datetime.now()
|
|
||||||
print(
|
|
||||||
f"[session] ABNORMAL at {session_abnormal:%Y-%m-%d %H:%M:%S}: "
|
|
||||||
f"{type(exc).__name__}: {exc}",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
session_end = datetime.now()
|
|
||||||
elapsed = session_end - session_start
|
|
||||||
print(
|
|
||||||
f"[session] END {session_end:%Y-%m-%d %H:%M:%S} "
|
|
||||||
f"(elapsed {elapsed})",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
if sensor1 is not None:
|
|
||||||
try:
|
|
||||||
sensor1.disconnect()
|
|
||||||
except Exception as disconnect_err:
|
|
||||||
print(f"[session] disconnect error: {disconnect_err} {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
|
||||||
if 'sensor2' in locals() and sensor2 is not None:
|
|
||||||
try:
|
|
||||||
sensor2.disconnect()
|
|
||||||
except Exception as disconnect_err:
|
|
||||||
print(f"[session] disconnect error (sensor2): {disconnect_err} {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
|
|
||||||
if video_cap is not None:
|
|
||||||
video_cap.release()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Shim for the Orisys finger Qt dashboard (see examples/gui/)."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
|
||||||
|
|
||||||
_EXAMPLES_DIR = Path(__file__).resolve().parents[1] / "examples"
|
|
||||||
if str(_EXAMPLES_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_EXAMPLES_DIR))
|
|
||||||
|
|
||||||
from argparse import Namespace
|
|
||||||
|
|
||||||
from gui import run_dashboard
|
|
||||||
from pipeline import PipelineConfig
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Orisys finger Qt dashboard")
|
|
||||||
parser.add_argument("--video", "-v", type=str, default="0")
|
|
||||||
parser.add_argument("--config", "-c", type=str, default="dd02")
|
|
||||||
parser.add_argument("--cal", type=str, default="")
|
|
||||||
parser.add_argument("--verbose", action="store_true")
|
|
||||||
parser.add_argument("--cutoff", type=float, default=10.0)
|
|
||||||
parser.add_argument("--no-3d", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
config = PipelineConfig.from_args(
|
|
||||||
Namespace(
|
|
||||||
video=args.video,
|
|
||||||
config=args.config,
|
|
||||||
cal=args.cal,
|
|
||||||
verbose=args.verbose,
|
|
||||||
mesh_map="depth",
|
|
||||||
threshold=None,
|
|
||||||
viz_pose="assets/finger/visualization/world_to_cam.npy",
|
|
||||||
no_3d=args.no_3d,
|
|
||||||
cutoff=args.cutoff,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sys.exit(run_dashboard(config))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import av
|
|
||||||
import cv2
|
|
||||||
import platform
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
TARGET_FPS = 30
|
|
||||||
WIDTH = 1920
|
|
||||||
HEIGHT = 1080
|
|
||||||
|
|
||||||
|
|
||||||
def list_windows_dshow_devices():
|
|
||||||
"""
|
|
||||||
Print DirectShow devices using ffmpeg, if available.
|
|
||||||
"""
|
|
||||||
cmd = ["ffmpeg", "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"]
|
|
||||||
print("Listing Windows DirectShow devices via ffmpeg...")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
||||||
except FileNotFoundError as exc:
|
|
||||||
raise RuntimeError("ffmpeg was not found in PATH; cannot list DirectShow devices.") from exc
|
|
||||||
|
|
||||||
output = (result.stderr or "") + (result.stdout or "")
|
|
||||||
print(output.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def open_pyav_capture(system_name, device, pixel_format):
|
|
||||||
"""
|
|
||||||
Open a camera device via PyAV on Linux or Windows.
|
|
||||||
"""
|
|
||||||
if system_name == "linux":
|
|
||||||
options = {
|
|
||||||
"video_size": f"{WIDTH}x{HEIGHT}",
|
|
||||||
"framerate": str(TARGET_FPS),
|
|
||||||
"input_format": pixel_format or "mjpeg",
|
|
||||||
}
|
|
||||||
container = av.open(device or "/dev/video4", format="v4l2", options=options)
|
|
||||||
elif system_name == "windows":
|
|
||||||
if not device:
|
|
||||||
raise RuntimeError(
|
|
||||||
"On Windows, PyAV/dshow requires a camera name, for example "
|
|
||||||
'--device "USB Camera". Use --list-devices first.'
|
|
||||||
)
|
|
||||||
options = {
|
|
||||||
"video_size": f"{WIDTH}x{HEIGHT}",
|
|
||||||
"framerate": str(TARGET_FPS),
|
|
||||||
}
|
|
||||||
if pixel_format:
|
|
||||||
options["pixel_format"] = pixel_format
|
|
||||||
container = av.open(f"video={device}", format="dshow", options=options)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Unsupported OS: {platform.system()}. Only Linux and Windows are supported."
|
|
||||||
)
|
|
||||||
|
|
||||||
stream = container.streams.video[0]
|
|
||||||
return container, stream
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Capture from camera via PyAV on Linux or Windows, optionally save to MP4."
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-d",
|
|
||||||
"--device",
|
|
||||||
type=str,
|
|
||||||
default="",
|
|
||||||
help=(
|
|
||||||
"Linux: V4L2 device path like /dev/video4. "
|
|
||||||
"Windows: DirectShow camera name like 'USB Camera'. "
|
|
||||||
"If empty, Linux uses /dev/video4; Windows requires --device."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--pixel-format",
|
|
||||||
type=str,
|
|
||||||
default="",
|
|
||||||
help=(
|
|
||||||
"Optional input pixel format / format hint. "
|
|
||||||
"Examples: Linux 'mjpeg'; Windows may accept values such as 'mjpeg' if supported."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--list-devices",
|
|
||||||
action="store_true",
|
|
||||||
help="On Windows, list DirectShow device names via ffmpeg and exit.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-o",
|
|
||||||
"--output",
|
|
||||||
type=str,
|
|
||||||
default="",
|
|
||||||
help="Output MP4 path (e.g. capture.mp4). If empty, only preview, no file.",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
system_name = platform.system().lower()
|
|
||||||
if args.list_devices:
|
|
||||||
if system_name != "windows":
|
|
||||||
raise RuntimeError("--list-devices is currently only implemented for Windows.")
|
|
||||||
list_windows_dshow_devices()
|
|
||||||
return
|
|
||||||
|
|
||||||
container, stream = open_pyav_capture(system_name, args.device, args.pixel_format)
|
|
||||||
print(
|
|
||||||
f"Using PyAV backend on {platform.system()} "
|
|
||||||
f"with device={args.device or '/dev/video4'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
frame_count = 0
|
|
||||||
t_start = time.perf_counter()
|
|
||||||
|
|
||||||
writer = None
|
|
||||||
if args.output:
|
|
||||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
|
||||||
writer = cv2.VideoWriter(
|
|
||||||
args.output, fourcc, float(TARGET_FPS), (WIDTH, HEIGHT)
|
|
||||||
)
|
|
||||||
if not writer.isOpened():
|
|
||||||
container.close()
|
|
||||||
raise RuntimeError(f"Could not open VideoWriter for {args.output}")
|
|
||||||
print(f"Recording to {args.output} @ {TARGET_FPS} fps, {WIDTH}x{HEIGHT}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
for frame in container.decode(stream):
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
img = frame.to_ndarray(format="bgr24") # OpenCV-compatible
|
|
||||||
t1 = time.perf_counter()
|
|
||||||
|
|
||||||
if writer is not None:
|
|
||||||
writer.write(img)
|
|
||||||
|
|
||||||
cv2.imshow("pyav_cam", img)
|
|
||||||
key = cv2.waitKey(1) & 0xFF
|
|
||||||
t2 = time.perf_counter()
|
|
||||||
|
|
||||||
frame_count += 1
|
|
||||||
if frame_count % 30 == 0:
|
|
||||||
elapsed = t2 - t_start
|
|
||||||
print(
|
|
||||||
f"Frame {frame_count}: convert={1000*(t1-t0):.1f}ms, "
|
|
||||||
f"imshow+waitKey={1000*(t2-t1):.1f}ms, avg_fps={frame_count/elapsed:.1f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if key == ord("q"):
|
|
||||||
break
|
|
||||||
finally:
|
|
||||||
container.close()
|
|
||||||
if writer is not None:
|
|
||||||
writer.release()
|
|
||||||
print(f"Saved {frame_count} frames to {args.output}")
|
|
||||||
cv2.destroyAllWindows()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -6,8 +6,8 @@ import sys
|
|||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent
|
REPO_ROOT = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, str(REPO_ROOT / "build"))
|
sys.path.insert(0, str(REPO_ROOT / "build" / "common"))
|
||||||
|
|
||||||
from common.pyproject_utils import bundle_data_files
|
from pyproject_utils import bundle_data_files
|
||||||
|
|
||||||
setup(data_files=bundle_data_files(REPO_ROOT))
|
setup(data_files=bundle_data_files(REPO_ROOT))
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ A Python library for processing and analyzing optical tactile sensor data,
|
|||||||
including image stitching, optical flow tracking, and deformation decomposition.
|
including image stitching, optical flow tracking, and deformation decomposition.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.4.2"
|
__version__ = "0.4.3"
|
||||||
__author__ = "Orisys"
|
__author__ = "Orisys"
|
||||||
|
|
||||||
from .sensor import Sensor
|
from .sensor import Sensor
|
||||||
from . import util
|
from . import util
|
||||||
from . import configs
|
from . import configs
|
||||||
from . import finger
|
from . import finger
|
||||||
|
from . import signal_processing
|
||||||
from .frame_processors import FFCFrameProcessor
|
from .frame_processors import FFCFrameProcessor
|
||||||
|
|
||||||
CompoundEyeSensor = Sensor
|
CompoundEyeSensor = Sensor
|
||||||
@@ -33,9 +34,12 @@ __all__ = [
|
|||||||
# finger runtime helpers
|
# finger runtime helpers
|
||||||
'finger',
|
'finger',
|
||||||
|
|
||||||
|
# signal helpers
|
||||||
|
'signal_processing',
|
||||||
|
|
||||||
# configs utilities functions
|
# configs utilities functions
|
||||||
'list_config',
|
'list_config',
|
||||||
'export_config'
|
'export_config',
|
||||||
|
|
||||||
# test
|
# test
|
||||||
'FFCFrameProcessor'
|
'FFCFrameProcessor'
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import json
|
|||||||
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",
|
||||||
}
|
}
|
||||||
@@ -55,7 +56,7 @@ def load_config(config_name_or_path):
|
|||||||
|
|
||||||
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. File stem under the config directory (e.g. ``box`` -> ``config/box.json``)
|
||||||
3. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
|
3. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
|
||||||
"""
|
"""
|
||||||
config_path = Path(config_name_or_path)
|
config_path = Path(config_name_or_path)
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ class FingerGeometry:
|
|||||||
def resolve_finger_geometry(
|
def resolve_finger_geometry(
|
||||||
*,
|
*,
|
||||||
export_dir,
|
export_dir,
|
||||||
config="./config/dd02-ov.json",
|
config="finger",
|
||||||
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
||||||
source_scale=None,
|
source_scale=None,
|
||||||
calib=None,
|
calib=None,
|
||||||
@@ -223,7 +223,7 @@ class FingerRuntime:
|
|||||||
cls,
|
cls,
|
||||||
*,
|
*,
|
||||||
export_dir=DEFAULT_FINGER_EXPORT_DIR,
|
export_dir=DEFAULT_FINGER_EXPORT_DIR,
|
||||||
config="./config/dd02-ov.json",
|
config="finger",
|
||||||
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
||||||
source_scale=None,
|
source_scale=None,
|
||||||
calib=None,
|
calib=None,
|
||||||
|
|||||||
+105
-2
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""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).
|
||||||
|
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
|
||||||
|
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
|
||||||
|
|
||||||
|
DEFAULT_SAMPLE_RATE_HZ = 30.0
|
||||||
|
DEFAULT_LOWPASS_ORDER = 2
|
||||||
|
|
||||||
|
|
||||||
|
def calibrate_normal_force(raw_value: float) -> float:
|
||||||
|
"""Convert a raw normal-force reading to Newtons using y = mx + c."""
|
||||||
|
return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET
|
||||||
|
|
||||||
|
|
||||||
|
def _nyquist_hz(sample_rate_hz: float) -> float:
|
||||||
|
return 0.5 * float(sample_rate_hz)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_cutoff(cutoff_hz: float, sample_rate_hz: float) -> None:
|
||||||
|
nyquist = _nyquist_hz(sample_rate_hz)
|
||||||
|
if cutoff_hz < 0 or cutoff_hz >= nyquist:
|
||||||
|
raise ValueError(
|
||||||
|
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lowpass_filter(
|
||||||
|
data,
|
||||||
|
cutoff_hz: float = 5.0,
|
||||||
|
sample_rate_hz: float = DEFAULT_SAMPLE_RATE_HZ,
|
||||||
|
order: int = DEFAULT_LOWPASS_ORDER,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data.
|
||||||
|
|
||||||
|
Matches the example/viewer chart filter: ``cutoff_hz == 0`` is a no-op.
|
||||||
|
"""
|
||||||
|
y = np.asarray(data, dtype=np.float64)
|
||||||
|
if cutoff_hz == 0:
|
||||||
|
return y.copy()
|
||||||
|
if y.size == 0:
|
||||||
|
return y
|
||||||
|
|
||||||
|
_validate_cutoff(cutoff_hz, sample_rate_hz)
|
||||||
|
b, a = butter(int(order), cutoff_hz / _nyquist_hz(sample_rate_hz), btype="low")
|
||||||
|
padlen = 3 * (max(len(a), len(b)) - 1)
|
||||||
|
if y.size <= padlen:
|
||||||
|
return y.copy()
|
||||||
|
try:
|
||||||
|
return filtfilt(b, a, y)
|
||||||
|
except ValueError:
|
||||||
|
return y.copy()
|
||||||
|
|
||||||
|
|
||||||
|
class LowpassFilter:
|
||||||
|
"""Causal Butterworth low-pass with persistent SOS state (one sample at a time)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cutoff_hz: float = 0.0,
|
||||||
|
sample_rate_hz: float = DEFAULT_SAMPLE_RATE_HZ,
|
||||||
|
order: int = DEFAULT_LOWPASS_ORDER,
|
||||||
|
n_channels: int = 3,
|
||||||
|
):
|
||||||
|
self.n_channels = int(n_channels)
|
||||||
|
self._sos = None
|
||||||
|
self._zi = None
|
||||||
|
self.configure(
|
||||||
|
cutoff_hz=cutoff_hz,
|
||||||
|
sample_rate_hz=sample_rate_hz,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._sos is not None
|
||||||
|
|
||||||
|
def configure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cutoff_hz: float | None = None,
|
||||||
|
sample_rate_hz: float | None = None,
|
||||||
|
order: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
if cutoff_hz is not None:
|
||||||
|
self.cutoff_hz = float(cutoff_hz)
|
||||||
|
if sample_rate_hz is not None:
|
||||||
|
self.sample_rate_hz = float(sample_rate_hz)
|
||||||
|
if order is not None:
|
||||||
|
self.order = int(order)
|
||||||
|
|
||||||
|
if self.cutoff_hz <= 0:
|
||||||
|
self._sos = None
|
||||||
|
self._zi = None
|
||||||
|
return
|
||||||
|
|
||||||
|
_validate_cutoff(self.cutoff_hz, self.sample_rate_hz)
|
||||||
|
self._sos = butter(
|
||||||
|
self.order,
|
||||||
|
self.cutoff_hz / _nyquist_hz(self.sample_rate_hz),
|
||||||
|
btype="low",
|
||||||
|
output="sos",
|
||||||
|
)
|
||||||
|
self._zi = None
|
||||||
|
|
||||||
|
def reset(self, x0=None) -> None:
|
||||||
|
"""Clear filter memory. Optional ``x0`` initializes to a steady-state DC."""
|
||||||
|
if self._sos is None:
|
||||||
|
self._zi = None
|
||||||
|
return
|
||||||
|
if x0 is None:
|
||||||
|
self._zi = None
|
||||||
|
return
|
||||||
|
x = np.asarray(x0, dtype=np.float64).reshape(-1)
|
||||||
|
if x.size != self.n_channels:
|
||||||
|
raise ValueError(f"x0 must have {self.n_channels} channels, got {x.size}")
|
||||||
|
zi0 = sosfilt_zi(self._sos)
|
||||||
|
self._zi = zi0[:, :, None] * x[None, None, :]
|
||||||
|
|
||||||
|
def process(self, values) -> np.ndarray:
|
||||||
|
"""Filter one multi-channel sample. Returns float64 array of shape (n_channels,)."""
|
||||||
|
x = np.asarray(values, dtype=np.float64).reshape(-1)
|
||||||
|
if x.size != self.n_channels:
|
||||||
|
raise ValueError(f"expected {self.n_channels} channels, got {x.size}")
|
||||||
|
if self._sos is None:
|
||||||
|
return x.copy()
|
||||||
|
if self._zi is None:
|
||||||
|
self.reset(x)
|
||||||
|
y, self._zi = sosfilt(self._sos, x[None, :], zi=self._zi, axis=0)
|
||||||
|
return np.asarray(y[0], dtype=np.float64)
|
||||||
+1
-1
@@ -8,7 +8,7 @@ Flow:
|
|||||||
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
|
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
|
||||||
5. Compute deformation/contact and visualize
|
5. Compute deformation/contact and visualize
|
||||||
|
|
||||||
SDK version: 0.4.2
|
SDK version: 0.4.3
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def test_pyproject_dynamic_version(packaging_utils):
|
|||||||
sys.path.insert(0, str(build_dir))
|
sys.path.insert(0, str(build_dir))
|
||||||
from common.pyproject_utils import get_version
|
from common.pyproject_utils import get_version
|
||||||
|
|
||||||
assert get_version(REPO_ROOT) == "0.4.2"
|
assert get_version(REPO_ROOT) == "0.4.3"
|
||||||
|
|
||||||
|
|
||||||
def test_pyproject_bundle_manifest(packaging_utils):
|
def test_pyproject_bundle_manifest(packaging_utils):
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from gui.sensors import ( # noqa: E402
|
|||||||
_snapshot_from_sensor,
|
_snapshot_from_sensor,
|
||||||
calibrate_normal_force,
|
calibrate_normal_force,
|
||||||
)
|
)
|
||||||
|
from orisys.signal_processing import calibrate_normal_force as sdk_calibrate_normal_force
|
||||||
|
|
||||||
|
|
||||||
class FakeForceAdapter:
|
class FakeForceAdapter:
|
||||||
@@ -163,3 +164,4 @@ def test_snapshot_requests_hhd_every_frame_without_motion_gate():
|
|||||||
assert snap.shear_x == pytest.approx(-100.0)
|
assert snap.shear_x == pytest.approx(-100.0)
|
||||||
assert snap.shear_y == pytest.approx(50.0)
|
assert snap.shear_y == pytest.approx(50.0)
|
||||||
assert snap.normal_force_calibrated == pytest.approx(calibrate_normal_force(12345.0))
|
assert snap.normal_force_calibrated == pytest.approx(calibrate_normal_force(12345.0))
|
||||||
|
assert snap.normal_force_calibrated == pytest.approx(sdk_calibrate_normal_force(12345.0))
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Tests for SDK signal helpers and Sensor.configure runtime settings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from scipy.signal import butter, filtfilt
|
||||||
|
|
||||||
|
from orisys import Sensor
|
||||||
|
from orisys.signal_processing import (
|
||||||
|
NORMAL_FORCE_CAL_OFFSET,
|
||||||
|
NORMAL_FORCE_CAL_SLOPE,
|
||||||
|
LowpassFilter,
|
||||||
|
calibrate_normal_force,
|
||||||
|
lowpass_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_viewer_lowpass(data, cutoff_hz, sample_rate_hz=30.0, order=2):
|
||||||
|
"""Reference copy of the previous viewer2d Butterworth + filtfilt path."""
|
||||||
|
y = np.asarray(data, dtype=np.float64)
|
||||||
|
if cutoff_hz == 0:
|
||||||
|
return y.copy()
|
||||||
|
if y.size == 0:
|
||||||
|
return y
|
||||||
|
nyquist = 0.5 * sample_rate_hz
|
||||||
|
b, a = butter(order, cutoff_hz / nyquist, btype="low")
|
||||||
|
padlen = 3 * (max(len(b), len(a)) - 1)
|
||||||
|
if y.size <= padlen:
|
||||||
|
return y.copy()
|
||||||
|
return filtfilt(b, a, y)
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_lowpass_matches_legacy_viewer_filter():
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
t = np.arange(180, dtype=np.float64) / 30.0
|
||||||
|
signal = np.sin(2 * np.pi * 1.0 * t) + 0.4 * rng.normal(size=t.size)
|
||||||
|
cutoff = 5.0
|
||||||
|
actual = lowpass_filter(signal, cutoff_hz=cutoff, sample_rate_hz=30.0, order=2)
|
||||||
|
expected = _legacy_viewer_lowpass(signal, cutoff_hz=cutoff, sample_rate_hz=30.0, order=2)
|
||||||
|
np.testing.assert_allclose(actual, expected, rtol=1e-10, atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_lowpass_cutoff_zero_is_noop():
|
||||||
|
data = np.array([1.0, 2.0, 3.0])
|
||||||
|
out = lowpass_filter(data, cutoff_hz=0)
|
||||||
|
np.testing.assert_array_equal(out, data)
|
||||||
|
assert out is not data
|
||||||
|
|
||||||
|
|
||||||
|
def test_stateful_lowpass_reduces_high_frequency_noise():
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
n = 240
|
||||||
|
t = np.arange(n, dtype=np.float64) / 30.0
|
||||||
|
clean = np.sin(2 * np.pi * 0.5 * t)
|
||||||
|
noisy = clean + 0.8 * rng.normal(size=n)
|
||||||
|
|
||||||
|
filt = LowpassFilter(cutoff_hz=3.0, sample_rate_hz=30.0, order=2, n_channels=1)
|
||||||
|
filtered = np.array([float(filt.process([v])[0]) for v in noisy])
|
||||||
|
|
||||||
|
assert np.isfinite(filtered).all()
|
||||||
|
noisy_hf = float(np.std(np.diff(noisy)))
|
||||||
|
filtered_hf = float(np.std(np.diff(filtered)))
|
||||||
|
assert filtered_hf < 0.5 * noisy_hf
|
||||||
|
# Steady-state init on the first sample should not start at zero.
|
||||||
|
assert filtered[0] == pytest.approx(noisy[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_normal_force_preserves_linear_curve():
|
||||||
|
raw = 12345.0
|
||||||
|
expected = NORMAL_FORCE_CAL_SLOPE * raw + NORMAL_FORCE_CAL_OFFSET
|
||||||
|
assert calibrate_normal_force(raw) == pytest.approx(expected)
|
||||||
|
assert calibrate_normal_force(0.0) == pytest.approx(NORMAL_FORCE_CAL_OFFSET)
|
||||||
|
|
||||||
|
|
||||||
|
def _external_sensor():
|
||||||
|
return Sensor("external", config_name="box", verbose=False, backend="cpu")
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_lowpass_filters_read_info_and_keeps_raw():
|
||||||
|
sensor = _external_sensor()
|
||||||
|
try:
|
||||||
|
sensor._force_raw = (1000.0, -200.0, 50.0)
|
||||||
|
sensor._apply_force_filter()
|
||||||
|
fn, fx, fy = sensor.read_info(
|
||||||
|
sensor.info.FNORMAL,
|
||||||
|
sensor.info.FSHEARX,
|
||||||
|
sensor.info.FSHEARY,
|
||||||
|
)
|
||||||
|
assert (fn, fx, fy) == pytest.approx((1000.0, -200.0, 50.0))
|
||||||
|
|
||||||
|
sensor.configure(lowpass_cutoff_hz=5.0, lowpass_sample_rate_hz=30.0)
|
||||||
|
noisy = [(1000.0 + (200.0 if i % 2 else -200.0), -200.0, 50.0) for i in range(40)]
|
||||||
|
filtered_fn = []
|
||||||
|
raw_fn = []
|
||||||
|
for sample in noisy:
|
||||||
|
sensor._force_raw = sample
|
||||||
|
sensor._apply_force_filter()
|
||||||
|
filtered_fn.append(sensor.read_info(sensor.info.FNORMAL)[0])
|
||||||
|
raw_fn.append(sensor.read_info(sensor.info.FNORMAL_RAW)[0])
|
||||||
|
|
||||||
|
assert raw_fn[-1] == pytest.approx(noisy[-1][0])
|
||||||
|
assert filtered_fn[-1] != pytest.approx(raw_fn[-1])
|
||||||
|
assert float(np.std(np.diff(filtered_fn))) < float(np.std(np.diff(raw_fn)))
|
||||||
|
fx_raw, fy_raw = sensor.read_info(sensor.info.FSHEARX_RAW, sensor.info.FSHEARY_RAW)
|
||||||
|
assert fx_raw == pytest.approx(-200.0)
|
||||||
|
assert fy_raw == pytest.approx(50.0)
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_lowpass_disabled_or_zero_cutoff_returns_raw():
|
||||||
|
sensor = _external_sensor()
|
||||||
|
try:
|
||||||
|
sensor.configure(lowpass_cutoff_hz=5.0)
|
||||||
|
sensor._force_raw = (10.0, 20.0, 30.0)
|
||||||
|
sensor._apply_force_filter()
|
||||||
|
|
||||||
|
sensor.configure(lowpass_cutoff_hz=0)
|
||||||
|
sensor._force_raw = (111.0, 222.0, 333.0)
|
||||||
|
sensor._apply_force_filter()
|
||||||
|
fn, fx, fy = sensor.read_info(
|
||||||
|
sensor.info.FNORMAL,
|
||||||
|
sensor.info.FSHEARX,
|
||||||
|
sensor.info.FSHEARY,
|
||||||
|
)
|
||||||
|
assert (fn, fx, fy) == pytest.approx((111.0, 222.0, 333.0))
|
||||||
|
|
||||||
|
sensor.configure(lowpass_cutoff_hz=5.0, lowpass_enabled=False)
|
||||||
|
sensor._force_raw = (7.0, 8.0, 9.0)
|
||||||
|
sensor._apply_force_filter()
|
||||||
|
fn, fx, fy = sensor.read_info(
|
||||||
|
sensor.info.FNORMAL,
|
||||||
|
sensor.info.FSHEARX,
|
||||||
|
sensor.info.FSHEARY,
|
||||||
|
)
|
||||||
|
fn_raw = sensor.read_info(sensor.info.FNORMAL_RAW)[0]
|
||||||
|
assert (fn, fx, fy) == pytest.approx((7.0, 8.0, 9.0))
|
||||||
|
assert fn_raw == pytest.approx(7.0)
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_hhd_downsample_invalidates_solver_cache():
|
||||||
|
sensor = _external_sensor()
|
||||||
|
try:
|
||||||
|
sensor._hhd_downsample = 1
|
||||||
|
sensor._Sensor__hhd = object()
|
||||||
|
sensor._hhd_grid_shape = (40, 40)
|
||||||
|
sensor.valid_mask = np.ones((90, 90), dtype=bool)
|
||||||
|
sensor.valid_mask_hhd = sensor.valid_mask
|
||||||
|
|
||||||
|
sensor.configure(hhd_downsample=3)
|
||||||
|
assert sensor._hhd_downsample == 3
|
||||||
|
assert sensor._Sensor__hhd is None
|
||||||
|
assert sensor._hhd_grid_shape is None
|
||||||
|
assert sensor.valid_mask_hhd is not None
|
||||||
|
assert sensor.valid_mask_hhd.shape == (30, 30)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sensor.configure(hhd_downsample="nope")
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
@@ -19,25 +19,25 @@ def wheel_package():
|
|||||||
|
|
||||||
def test_compliant_manylinux_tags(wheel_package):
|
def test_compliant_manylinux_tags(wheel_package):
|
||||||
assert wheel_package.is_compliant_manylinux_wheel(
|
assert wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-manylinux_2_17_x86_64.whl")
|
Path("orisys-0.4.3-cp310-cp310-manylinux_2_17_x86_64.whl")
|
||||||
)
|
)
|
||||||
assert wheel_package.is_compliant_manylinux_wheel(
|
assert wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-manylinux2014_x86_64.whl")
|
Path("orisys-0.4.3-cp310-cp310-manylinux2014_x86_64.whl")
|
||||||
)
|
)
|
||||||
assert wheel_package.is_compliant_manylinux_wheel(
|
assert wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-manylinux_2_28_x86_64.whl")
|
Path("orisys-0.4.3-cp310-cp310-manylinux_2_28_x86_64.whl")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_illegal_manylinux_tags(wheel_package):
|
def test_rejects_illegal_manylinux_tags(wheel_package):
|
||||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-manylinux_x86_64.whl")
|
Path("orisys-0.4.3-cp310-cp310-manylinux_x86_64.whl")
|
||||||
)
|
)
|
||||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-linux_x86_64.whl")
|
Path("orisys-0.4.3-cp310-cp310-linux_x86_64.whl")
|
||||||
)
|
)
|
||||||
assert not wheel_package.is_compliant_manylinux_wheel(
|
assert not wheel_package.is_compliant_manylinux_wheel(
|
||||||
Path("orisys-0.4.2-cp310-cp310-win_amd64.whl")
|
Path("orisys-0.4.3-cp310-cp310-win_amd64.whl")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user