included low pass filter in package and add to plot_data example

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

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

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