Compare commits

...

49 Commits

Author SHA1 Message Date
zhangyijian 75fa98eff0 Merge pull request 'Dev' (#19) from dev into main
Reviewed-on: #19
2026-09-02 21:39:03 +08:00
zhangyijian bc059e3539 Merge pull request 'Hotfix/mems cam open' (#20) from hotfix/mems_cam_open into dev
Reviewed-on: #20
2026-09-02 21:38:48 +08:00
ykcheungab 5be2d9759a hotfix/camera not connected error 2026-09-02 21:37:40 +08:00
ykcheungab 32823a7666 test if optical flow integration error: not significant for Mems 2026-09-01 21:27:10 +08:00
zhangyijian 26168852ef Merge pull request 'fix input flag convention mismatch' (#18) from feature/force_cal into dev
Reviewed-on: #18
2026-08-26 20:27:35 +08:00
ykcheungab 36e0c22de6 fix input flag convention mismatch 2026-08-26 20:26:05 +08:00
zhangyijian a387b8b693 Merge pull request 'Feature/force cal' (#17) from feature/force_cal into dev
Reviewed-on: #17
2026-08-26 20:22:27 +08:00
ykcheungab 8a4be5f8f2 update to 0.5.0 2026-08-26 20:20:37 +08:00
ykcheungab 68910e63c1 fix finger gui cannot choose camera 2026-08-26 20:19:55 +08:00
ykcheungab 577e3fe528 update pip install .copy manifest for developments 2026-08-26 20:04:40 +08:00
ykcheungab 32d4164471 update examples to fix path issues 2026-08-26 19:38:47 +08:00
ykcheungab d59e1cb7fb fix path errors 2026-08-26 19:22:37 +08:00
zhangyijian 2cb152b76e Merge pull request 'refractor stitcher class into module' (#16) from refractor/stitcher into dev
Reviewed-on: #16
2026-08-23 16:25:27 +08:00
ykcheungab ab97efe039 refractor stitcher class into module 2026-08-23 16:24:53 +08:00
zhangyijian 135857b6de Merge pull request 'refractor capture to a module' (#15) from refractor/capture into dev
Reviewed-on: #15
2026-08-23 15:53:55 +08:00
ykcheungab 8a7760f12b refractor capture to a module 2026-08-23 15:53:02 +08:00
zhangyijian 4583dc3a40 Merge pull request 'Refractor/config package' (#14) from refractor/config_package into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/14
2026-08-23 12:25:28 +08:00
ykcheungab 1ceb0d9ca4 organize settings to dedicated sensors folder, json + assets 2026-08-23 12:21:24 +08:00
ykcheungab 44bcc19426 update docs and downstream sync 2026-08-18 11:00:57 +08:00
ykcheungab 2e09c9360e included low pass filter in package and add to plot_data example 2026-08-17 22:11:17 +08:00
zhangyijian 93b4517ebf Merge pull request 'reorganized build directory' (#13) from feature/single_build_entry into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/13
2026-08-03 22:17:48 +08:00
ykcheungab 9a69f01278 reorganized build directory 2026-08-03 22:14:19 +08:00
zhangyijian 4acdce4456 Merge pull request 'Feature/build docker manylinux pypi' (#12) from feature/build_docker_manylinux_pypi into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/12
2026-08-03 17:12:52 +08:00
ykcheungab d7c5845646 finished docker manylinux compliation for pypi 2026-08-03 17:07:54 +08:00
ykcheungab 3117ddd5b9 organize exampels according to products 2026-08-03 15:52:15 +08:00
ykcheungab 8b2f76c76f prepare for dd03 shipping 2026-08-03 14:20:30 +08:00
zhangyijian 8d1772300f Merge pull request 'update dd06 display' (#11) from test/dd06_viewer into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/11
2026-07-26 18:15:59 +08:00
zhangyijian 044fe02107 update dd06 display 2026-07-26 18:14:47 +08:00
zhangyijian b154a3acbb Merge pull request 'added dd06 test support, organized 2d viewer and 3d viewer code organization.' (#10) from test/dd06 into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/10
2026-07-26 17:05:12 +08:00
ykcheungab 2e3d0a0609 added dd06 test support, organized 2d viewer and 3d viewer code organization. 2026-07-26 17:03:44 +08:00
zhangyijian 28e6c7c8a9 Merge pull request 'Fix/stitching blend' (#9) from fix/stitching_blend into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/9
2026-07-18 15:41:38 +08:00
ykcheungab ee3ef3e961 fix numba stiching blend error and add numpy blend fallback 2026-07-18 15:38:21 +08:00
ykcheungab 62bc8dbff8 remove large tools file for pattern generation from git 2026-07-15 20:59:50 +08:00
zhangyijian 0e7fd2eeaf Merge pull request 'added camera search and select, auto preference saving and version tag to 0.4.2' (#8) from feature/usbcam_search into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/8
2026-07-15 15:10:58 +08:00
ykcheungab 11a747a771 added camera search and select, auto preference saving and version tag to 0.4.2 2026-07-15 15:08:59 +08:00
zhangyijian 7b12d17595 Merge pull request 'update csv export and added fixed mesh axis drawing' (#7) from feature/csv_export into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/7
2026-07-15 14:26:31 +08:00
ykcheungab 87ac152907 update csv export and added fixed mesh axis drawing 2026-07-15 14:25:03 +08:00
zhangyijian 3d1509df9d Merge pull request 'dd02 setting tune + change Class name from UVUnrollFrameProcessor to FrameUnwarper' (#5) from feature/finger_docs into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/5
2026-07-14 20:09:53 +08:00
ykcheungab 9ee6ab9470 Merge branch 'dev' into feature/finger_docs 2026-07-14 20:09:04 +08:00
ykcheungab a9c26fd1fb Merge branch 'feature/finger_docs' into dev 2026-07-14 20:07:43 +08:00
ykcheungab 30f9deb5b2 dd02 setting tune + change Class name from UVUnrollFrameProcessor to FrameUnwarper 2026-07-14 20:00:40 +08:00
zhangyijian fe6c880473 Merge pull request 'Refractor/finger' (#4) from refractor/finger into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/4
2026-07-12 19:57:27 +08:00
zhangyijian d58fa1caa2 moved examples/app_paths.py into gui 2026-07-12 19:54:32 +08:00
zhangyijian 4b38dbb6b6 cleanup example profile code 2026-07-12 19:35:04 +08:00
zhangyijian 3d1039b52c Merge pull request 'Feature/dd02 0703' (#3) from feature/dd02-0703 into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/3
2026-07-09 14:14:38 +08:00
ykcheungab c2fb0b12b5 AI 更新文档 2026-07-09 14:03:45 +08:00
ykcheungab c77a64ffe7 update version tag to 0.4.1 2026-07-09 13:59:19 +08:00
ykcheungab 8c5a20b5cb fix/update dd02 asset to 0703 version 2026-07-09 13:55:27 +08:00
zhangyijian 8706e405d7 Merge pull request 'Feature/ffc' (#2) from feature/ffc into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/2
2026-07-08 09:56:25 +08:00
223 changed files with 12848 additions and 11921 deletions
+5 -1
View File
@@ -5,8 +5,12 @@ __pycache__/
# Packaging / build outputs # Packaging / build outputs
/build/nuitka/ /build/nuitka/
/build/lib/orisys/ /build/build/
/build/bdist.*/
/build/dist/
/build/lib/
/build/pyinstaller_work/ /build/pyinstaller_work/
/build/*.spec
/dist/ /dist/
/dist_obfuscated/ /dist_obfuscated/
/dist_pyd/ /dist_pyd/
+8
View File
@@ -0,0 +1,8 @@
# Non-Python files needed for sdist -> wheel builds from an unpacked sdist.
include pyproject.toml
include setup.py
include README.md
recursive-include config *.json
recursive-include sensors *
recursive-include build/common *.py
recursive-include build/wheel *.py
+39 -8
View File
@@ -4,11 +4,13 @@ Orisys SDK is a Python toolkit for optical tactile sensing. It provides image ca
stitching, dense optical flow, Helmholtz-Hodge decomposition, contact/depth estimation, stitching, dense optical flow, Helmholtz-Hodge decomposition, contact/depth estimation,
and several runnable examples for camera and video-based workflows. and several runnable examples for camera and video-based workflows.
PyPI: [https://pypi.org/project/orisys/](https://pypi.org/project/orisys/)
## Repository Layout ## Repository Layout
- `src/orisys/`: installable SDK package - `src/orisys/`: installable SDK package
- `config/`: default and lab/runtime JSON configurations - `config/`: default and lab/runtime JSON configurations
- `examples/`: user-facing example scripts - `examples/`: runnable demos (`basic_pipeline.py`, `mini_pipeline.py`, `finger_pipeline*.py`) plus shared `gui/`, `pipeline/`, `viz/`
- `scripts/`: maintenance, calibration, packaging, and demo scripts - `scripts/`: maintenance, calibration, packaging, and demo scripts
- `tools/`: non-package tooling such as patch-generation utilities - `tools/`: non-package tooling such as patch-generation utilities
- `tests/`: automated pytest suite - `tests/`: automated pytest suite
@@ -49,38 +51,67 @@ pytest
### 4. Run an example ### 4. Run an example
ORY-BOX:
```bash ```bash
python examples/basic_pipeline.py -v 0 -c ./config/ddjx01.json python examples/basic_pipeline.py -v 0 -c box
``` ```
ORY-MINI:
```bash
python examples/mini_pipeline.py -v 0 -c mini
```
ORY-FINGER:
```bash
python examples/finger_pipeline.py --camera_id 0 --config finger
```
Run commands from the repository root.
## Configuration ## Configuration
The canonical configuration files now live in the repo-level `config/` folder. The canonical configuration files now live in the repo-level `config/` folder.
The package loader `orisys.configs` resolves configs from: The package loader `orisys.configs` resolves configs from:
1. an explicit JSON file path 1. an explicit JSON file path
2. a config file stem under `config/` 2. a config file stem under `config/` (for example `box`, `mini`, `finger`)
3. a backward-compatible JSON `"name"` match, such as `"prototype"` 3. a product alias such as `dd02` -> `dd02-ov`
4. a backward-compatible JSON `"name"` match, such as `"prototype"`
Examples: Examples:
```python ```python
import orisys import orisys
sensor = orisys.Sensor(0, config_name="ddjx01") sensor = orisys.Sensor(0, config_name="box")
cfg = orisys.export_config("ddjx01", "./config/exported_config.json") cfg = orisys.export_config("box", "./config/exported_config.json")
``` ```
## Examples and Tools ## Examples and Tools
- Example entry points: `examples/README.md` - Example entry points: `examples/README.md`
- User documentation: `docs/get_started.md` - User documentation: `docs/get_started.md`
- Packaging helper: `build/build_nuitka.py` (compiled SDK wheel) - Build launcher: `python build/run.py` (see `build/README.md`)
- GUI standalone folder: `build/build_gui_win.bat` → `dist/OrisysDashboard/` - Manylinux / PyPI binary wheel: `docs/publishing.md`
- Specialized utilities: `tools/` - Specialized utilities: `tools/`
## Publishing (Linux manylinux)
To build PyPI-compliant CPython 3.10/3.11/3.12 Linux wheels from Windows Docker Desktop:
```powershell
python build/run.py manylinux
```
Upload candidates land in `dist/repaired/`. Full TestPyPI → PyPI steps:
`docs/publishing.md`.
## Notes for Collaborators ## Notes for Collaborators
- Generated build outputs such as `build/`, `dist/`, and `SDK/` are intentionally ignored. - Generated build outputs such as `build/`, `dist/`, and `SDK/` are intentionally ignored.
- Large or generated tooling outputs should stay under ignored output folders rather than be committed. - Large or generated tooling outputs should stay under ignored output folders rather than be committed.
- If you add new images or arrays that should be versioned, review `.gitignore` first. - If you add new images or arrays that should be versioned, review `.gitignore` first.
- Never commit PyPI / TestPyPI API tokens.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

-44
View File
@@ -1,44 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['D:\\repo\\orisyssdk_git\\examples\\finger_pipeline_3dview.py'],
pathex=[],
binaries=[],
datas=[('D:\\repo\\orisyssdk_git\\config', 'config'), ('D:\\repo\\orisyssdk_git\\assets', 'assets')],
hiddenimports=['PyQt5', 'pyqtgraph', 'open3d', 'cv2', 'orisys', 'gui', 'pipeline', 'viz', 'app_paths', 'plotly', '_plotly_utils', 'open3d.cpu.pybind'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['cupy', 'cupyx', 'pyopencl', 'tkinter', 'pip', 'wheel', 'matplotlib'],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='OrisysDashboard',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='OrisysDashboard',
)
+65
View File
@@ -0,0 +1,65 @@
# Build tooling
Use one launcher from the repo root:
```bash
python build/run.py <command>
```
```text
build/
run.py << single entrypoint
README.md
common/ shared helpers (paths, pyproject, verify)
wheel/ SDK .whl (Nuitka + manylinux Docker)
gui/
common/ shared PyInstaller driver
ory_finger/ ORY-FINGER dashboard
ory_mini/ ORY-MINI internal GUI (scaffold)
ory_box/ ORY-BOX internal GUI (scaffold)
```
## Commands
### SDK wheel
```bash
python build/run.py wheel-win
python build/run.py wheel-manylinux
# Single Version
python build/run.py wheel
# Inside Docker
python build/run.py wheel --target pypi-linux --verify
```
Direct module paths (same behavior):
```bash
python build/wheel/build.py
build\wheel\win.bat
.\build\wheel\docker\run.ps1
```
### GUI
```bash
python build/run.py gui finger
python build/run.py gui mini # scaffold / NotImplemented
python build/run.py gui box # scaffold / NotImplemented
```
### Verify
```bash
python build/run.py verify path\to\orisys.whl
python build/run.py verify --gui-dist --product ory_finger
```
## Outputs
- Wheels: `SDK/<version>/`, `dist/repaired/`
- Finger GUI: `dist/OrisysDashboard/`
- Future mini/box: `dist/OryMini/`, `dist/OryBox/`
Generated work files under `build/nuitka/` and `build/pyinstaller_work/` are gitignored.
-194
View File
@@ -1,194 +0,0 @@
"""Build a CPU-only standalone Windows folder for Orisys 3D dashboard using PyInstaller.
Usage:
conda activate py310 # or a clean virtualenv with only required packages
pip install pyinstaller
python build/build_gui_win_pyinstaller.py
Output:
dist/OrisysDashboard/OrisysDashboard.exe (+ bundled Python, DLLs, config/, assets/)
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
# ---------- Configuration ----------
ENTRY_SCRIPT = Path("examples/finger_pipeline_3dview.py").resolve()
EXE_NAME = "OrisysDashboard.exe"
DIST_NAME = "OrisysDashboard" # final folder name
repo_root = Path(__file__).parent.parent
# Packages you want to force-include (PyInstaller often auto-detects them, but explicit avoids misses)
HIDDEN_IMPORTS = [
"PyQt5",
"pyqtgraph",
"open3d",
"cv2",
"orisys",
"gui",
"pipeline",
"viz",
"app_paths",
"plotly",
"_plotly_utils",
# sometimes needed for open3d:
"open3d.cpu.pybind", # adjust to actual module if needed
]
# Modules to completely exclude (they won't be imported anyway if absent in venv)
EXCLUDE_MODULES = [
"cupy",
"cupyx",
"pyopencl",
"tkinter",
# "unittest",
# "email",
# "http",
# "xmlrpc",
# "pkg_resources",
# "setuptools",
"pip",
"wheel",
"matplotlib", # unless you use it
# "scipy", # unless you use it
]
# Data directories: (source relative to repo root, destination in bundle)
DATA_DIRS = [
("config", "config"),
("assets", "assets"),
]
# Optional: UPX directory (download from https://upx.github.io)
UPX_DIR = r"C:\tools\upx" # set to None if not available
# ---------- End Configuration ----------
def clean_dist(dist_path: Path) -> None:
if dist_path.exists():
shutil.rmtree(dist_path)
dist_path.mkdir(parents=True)
def build_with_pyinstaller():
# Ensure we are in a clean, minimal venv
print("Using Python:", sys.executable)
# Output folder for PyInstaller work files
work_dir = Path("build/pyinstaller_work")
dist_dir = Path("dist") / DIST_NAME
clean_dist(work_dir)
clean_dist(dist_dir)
# Build PyInstaller command
cmd = [
sys.executable, "-m", "PyInstaller",
"--onedir", # produce a folder, not a single exe
"--name", DIST_NAME,
f"--distpath={dist_dir.parent}",
f"--workpath={work_dir}",
f"--specpath=build",
"--noconfirm",
"--clean",
"--windowed", # or "--console" if you need terminal
]
# Add data files
for src_rel, dest_rel in DATA_DIRS:
src = Path(src_rel).resolve()
if not src.is_dir():
raise FileNotFoundError(f"Data directory missing: {src}")
cmd.append(f"--add-data={src};{dest_rel}")
# Logo file if exists
logo = Path("logo.png")
if logo.exists():
cmd.append(f"--add-data={logo.resolve()};.")
# Hidden imports
for mod in HIDDEN_IMPORTS:
cmd.append(f"--hidden-import={mod}")
# Exclude modules
for mod in EXCLUDE_MODULES:
cmd.append(f"--exclude-module={mod}")
# UPX if available
if UPX_DIR and Path(UPX_DIR).is_dir():
cmd.append(f"--upx-dir={UPX_DIR}")
# Entry point
cmd.append(str(ENTRY_SCRIPT))
# Environment (same as Nuitka script)
env = os.environ.copy()
env["PYTHONPATH"] = str(Path("src").resolve()) + os.pathsep + str(Path("examples").resolve()) + os.pathsep + env.get("PYTHONPATH", "")
print("\n=== Running PyInstaller ===")
print("Command:", " ".join(cmd))
subprocess.check_call(cmd, env=env)
# After build, copy data directories explicitly
# PyInstaller output folder
dist_dir = Path("dist") / DIST_NAME
final_exe = dist_dir / EXE_NAME
if not final_exe.is_file():
raise RuntimeError(f"Executable not found at {final_exe}")
print("\n=== Copying data directories ===")
for src_rel, dest_rel in DATA_DIRS:
src = repo_root / src_rel # use an absolute reference to repo root
dest = dist_dir / dest_rel
if not src.is_dir():
raise FileNotFoundError(f"Data directory not found: {src}")
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src, dest, dirs_exist_ok=True) # Python 3.8+ use dirs_exist_ok=True
print(f" {src} -> {dest}")
# Copy logo
logo_src = repo_root / "logo.png"
if logo_src.is_file():
shutil.copy2(logo_src, dist_dir / "logo.png")
print(f" Logo copied")
# Write README (same as Nuitka script)
readme = dist_dir / "README.txt"
readme.write_text(
"\n".join([
"Orisys Finger Dashboard (standalone)",
"",
"Run:",
f" {EXE_NAME}",
f" {EXE_NAME} --video path\\to\\video.mp4",
f" {EXE_NAME} --config config\\dd02-ov.json",
"",
"Bundled folders:",
" config/ sensor JSON configs",
" assets/ finger mesh and visualization data",
"",
"CPU-only build (no CUDA/CuPy).",
]),
encoding="utf-8",
)
print(f"\n[SUCCESS] Standalone GUI folder: {dist_dir}")
print(f"Executable: {final_exe}")
# Optional smoke test (same as Nuitka script)
try:
from verify import verify_gui_dist
print("\n=== Smoke test ===")
verify_gui_dist(dist_dir)
except Exception as exc:
print(f"\n[WARNING] Smoke test failed: {exc}")
return dist_dir
if __name__ == "__main__":
build_with_pyinstaller()
-89
View File
@@ -1,89 +0,0 @@
"""Build a Nuitka-compiled orisys wheel and publish it to SDK/{version}/.
Pipeline:
1. Compile src/orisys → .pyd
2. Stage wheel tree (compiled module + bundled files from pyproject.toml)
3. Run bdist_wheel and move output to SDK/{version}/
4. Smoke-test imports in a temporary venv
Run from repo root: python build/build_nuitka.py
"""
from __future__ import annotations
import os
import shutil
import subprocess
from nuitka_compile import compile_orisys, copy_compiled_orisys, install_nuitka
from paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repo_path
from pyproject_utils import bundle_items, extras_require, get_version, install_requires
from verify import verify_built_wheel
from wheel_build import publish_wheel_to_sdk, run_bdist_wheel, write_wheel_setup
from wheel_layout import copy_bundle_items, package_data_from_bundle
def _reset_dir(path) -> None:
"""Remove and recreate a build output directory."""
if path.exists():
shutil.rmtree(path)
path.mkdir(parents=True)
def build() -> None:
# --- 1. Compile ---
install_nuitka()
version = get_version(REPO_ROOT)
_reset_dir(NUITKA_COMPILE_DIR)
_reset_dir(NUITKA_WHEEL_DIR)
print("\n=== Compiling with Nuitka ===")
env = os.environ.copy()
src_dir = repo_path("src")
env["PYTHONPATH"] = src_dir.as_posix() + os.pathsep + env.get("PYTHONPATH", "")
ext_file = compile_orisys(NUITKA_COMPILE_DIR, env)
# --- 2. Stage wheel contents ---
print("\n=== Preparing Wheel Structure ===")
pkg_dir = NUITKA_WHEEL_DIR / "wheel_root" / "orisys"
pkg_dir.mkdir(parents=True)
ext_suffix = copy_compiled_orisys(pkg_dir, ext_file)
# Bundle rules live in pyproject.toml → [[tool.orisys.bundle.items]]
bundle = bundle_items(REPO_ROOT)
copied_counts = copy_bundle_items(REPO_ROOT, pkg_dir, bundle)
print(f"Copied {copied_counts['configs']} JSON config files")
print(f"Copied {copied_counts['examples']} example files to orisys/examples/")
print(f"Copied {copied_counts['docs']} doc files to orisys/docs/")
print(f"Copied {copied_counts['assets']} finger asset files to orisys/assets/")
write_wheel_setup(
NUITKA_WHEEL_DIR,
version=version,
package_data=package_data_from_bundle(ext_suffix, bundle),
install_requires=install_requires(REPO_ROOT),
extras_require=extras_require(REPO_ROOT),
)
# --- 3. Build wheel and publish to SDK/ ---
wheel_path = run_bdist_wheel(NUITKA_WHEEL_DIR)
if wheel_path is None:
print("\n[ERROR] Wheel not found.")
return
final_wheel = publish_wheel_to_sdk(wheel_path, version)
print(f"\n[SUCCESS] Wheel created and moved to: {final_wheel}")
# # --- 4. Verify (isolated venv; does not touch the active environment) ---
# print("\n=== Verifying finger mesh/viewer imports ===")
# try:
# verify_built_wheel(final_wheel)
# except subprocess.CalledProcessError as exc:
# print(f"\n[WARNING] Wheel verify failed: {exc}")
# print("The wheel was built successfully; verification runs in an isolated temp venv.")
if __name__ == "__main__":
build()
+1
View File
@@ -0,0 +1 @@
"""Shared build utilities used by wheel and GUI packaging targets."""
+15
View File
@@ -0,0 +1,15 @@
"""Ensure ``build/`` is importable as the root for ``common``, ``wheel``, and ``gui``."""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[1]
def ensure_build_root_on_path() -> Path:
root = str(_BUILD_ROOT)
if root not in sys.path:
sys.path.insert(0, root)
return _BUILD_ROOT
+31
View File
@@ -0,0 +1,31 @@
"""Repository and packaging output paths."""
from __future__ import annotations
from pathlib import Path
# build/common/paths.py → parents: common, build, repo
REPO_ROOT = Path(__file__).resolve().parents[2]
NUITKA_COMPILE_DIR = REPO_ROOT / "build" / "nuitka" / "compile"
NUITKA_WHEEL_DIR = REPO_ROOT / "build" / "nuitka" / "wheel"
NUITKA_GUI_DIR = REPO_ROOT / "build" / "nuitka" / "gui"
GUI_DIST_DIR = REPO_ROOT / "dist" / "OrisysDashboard"
PYINSTALLER_WORK_DIR = REPO_ROOT / "build" / "pyinstaller_work"
def repo_path(*parts: str) -> Path:
return REPO_ROOT.joinpath(*parts)
def sdk_dir(version: str) -> Path:
return REPO_ROOT / "SDK" / version
def repaired_wheel_dir() -> Path:
"""Directory for auditwheel-repaired wheels under the Nuitka wheel build tree."""
return NUITKA_WHEEL_DIR / "repaired"
def gui_dist_dir(dist_name: str) -> Path:
return REPO_ROOT / "dist" / dist_name
@@ -2,11 +2,25 @@
from __future__ import annotations from __future__ import annotations
import importlib import ast
import importlib.util
import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[1]
def _load_wheel_layout_module():
layout_path = _BUILD_ROOT / "wheel" / "layout.py"
spec = importlib.util.spec_from_file_location("_orisys_build_wheel_layout", layout_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load wheel layout helper from {layout_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _load_toml(text: str) -> dict: def _load_toml(text: str) -> dict:
if sys.version_info >= (3, 11): if sys.version_info >= (3, 11):
@@ -41,17 +55,29 @@ def extras_require(repo_root: Path) -> dict[str, list[str]]:
def _resolve_version_attr(repo_root: Path, attr: str) -> str: def _resolve_version_attr(repo_root: Path, attr: str) -> str:
"""Resolve a setuptools dynamic version attr (e.g. orisys.__version__).""" """Resolve a setuptools dynamic version attr (e.g. orisys.__version__)."""
src_dir = str(repo_root / "src")
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
module_name, _, attr_name = attr.partition(".") module_name, _, attr_name = attr.partition(".")
if not attr_name: if not attr_name:
raise RuntimeError(f"Invalid dynamic version attr: {attr!r}") raise RuntimeError(f"Invalid dynamic version attr: {attr!r}")
module = importlib.import_module(module_name)
value = module # Avoid importing package modules here: __init__ may import heavy runtime deps
for name in attr_name.split("."): # (e.g., cv2/numpy) that are not required just to read __version__.
value = getattr(value, name) rel_module = Path(*module_name.split("."))
return str(value) module_file = repo_root / "src" / rel_module.with_suffix(".py")
if not module_file.is_file():
module_file = repo_root / "src" / rel_module / "__init__.py"
if not module_file.is_file():
raise RuntimeError(f"Cannot resolve module file for dynamic version attr: {attr!r}")
tree = ast.parse(module_file.read_text(encoding="utf-8"), filename=str(module_file))
target_name = attr_name.split(".")[0]
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == target_name:
value = ast.literal_eval(node.value)
return str(value)
raise RuntimeError(f"Attribute {target_name!r} not found in {module_file}")
def get_version(repo_root: Path) -> str: def get_version(repo_root: Path) -> str:
@@ -77,8 +103,12 @@ def bundle_items(repo_root: Path) -> list[dict]:
def bundle_data_files(repo_root: Path) -> list[tuple[str, list[str]]]: def bundle_data_files(repo_root: Path) -> list[tuple[str, list[str]]]:
"""Map bundle items marked dev_install=true to setuptools data_files entries.""" """Map bundle items marked ``dev_install=true`` to install destinations.
from wheel_layout import resolve_bundle_source_files
Kept for tests / tooling. Prefer :func:`stage_dev_bundle_into` for pip
installs so files land inside ``site-packages/orisys/``.
"""
resolve_bundle_source_files = _load_wheel_layout_module().resolve_bundle_source_files
grouped: dict[str, list[str]] = {} grouped: dict[str, list[str]] = {}
for item in bundle_items(repo_root): for item in bundle_items(repo_root):
@@ -98,3 +128,33 @@ def bundle_data_files(repo_root: Path) -> list[tuple[str, list[str]]]:
grouped.setdefault(dest, []).append(rel_src) grouped.setdefault(dest, []).append(rel_src)
return [(dest, sorted(files)) for dest, files in sorted(grouped.items())] return [(dest, sorted(files)) for dest, files in sorted(grouped.items())]
def stage_dev_bundle_into(repo_root: Path, pkg_dir: Path) -> list[str]:
"""Copy ``dev_install`` bundle files under ``pkg_dir`` (the orisys package).
Returns package-relative paths that were written (e.g. ``sensors/box/...``).
"""
resolve_bundle_source_files = _load_wheel_layout_module().resolve_bundle_source_files
packaged: list[str] = []
for item in bundle_items(repo_root):
if not item.get("dev_install", False):
continue
src_dir = repo_root / item["source"]
dst_dir = pkg_dir / item["target"]
dst_dir.mkdir(parents=True, exist_ok=True)
for src_file in resolve_bundle_source_files(repo_root, item):
if item.get("preserve_tree", False):
rel = src_file.relative_to(src_dir)
dst_file = dst_dir / rel
else:
rel = Path(src_file.name)
dst_file = dst_dir / src_file.name
dst_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_file, dst_file)
packaged.append(f"{item['target']}/{rel.as_posix()}")
return packaged
+6
View File
@@ -0,0 +1,6 @@
"""Post-build smoke tests for wheels and product GUI folders."""
from common.verify.gui import GUI_PRODUCTS, verify_gui_dist
from common.verify.wheel import verify_built_wheel
__all__ = ["GUI_PRODUCTS", "verify_built_wheel", "verify_gui_dist"]
+34
View File
@@ -0,0 +1,34 @@
"""CLI for packaged artifact verification."""
from __future__ import annotations
import argparse
from pathlib import Path
from common.verify import GUI_PRODUCTS, verify_built_wheel, verify_gui_dist
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Verify packaged Orisys artifacts.")
parser.add_argument("--gui-dist", action="store_true", help="Verify a GUI dist folder")
parser.add_argument(
"--product",
choices=sorted(GUI_PRODUCTS),
default="ory_finger",
help="GUI product profile (default: ory_finger)",
)
parser.add_argument("--dist-dir", type=Path, default=None, help="Override GUI dist folder")
parser.add_argument("wheel", nargs="?", type=Path, help="Wheel path for import smoke test")
args = parser.parse_args(argv)
if args.gui_dist:
verify_gui_dist(args.dist_dir, product=args.product)
return 0
if args.wheel is None:
parser.error("Provide a wheel path or pass --gui-dist")
verify_built_wheel(args.wheel)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
"""Smoke tests for standalone GUI product folders."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import GUI_DIST_DIR, gui_dist_dir
# Product profiles for GUI packaging verification.
GUI_PRODUCTS: dict[str, dict] = {
"ory_finger": {
"dist_name": "OrisysDashboard",
"exe_name": "OrisysDashboard.exe",
"required_relpaths": [
"config/dd02-ov.json",
"sensors/finger/assets/30fps-0703/mesh.npz",
"sensors/finger/assets/30fps-0703/world_to_cam.npy",
"sensors/finger/assets/visualization/world_to_cam.npy",
],
},
"ory_mini": {
"dist_name": "OryMini",
"exe_name": "OryMini.exe",
"required_relpaths": [
"config/mini.json",
],
},
"ory_box": {
"dist_name": "OryBox",
"exe_name": "OryBox.exe",
"required_relpaths": [
"config/ddjx01.json",
],
},
}
def verify_gui_dist(
dist_dir: Path | None = None,
*,
product: str = "ory_finger",
) -> None:
"""Smoke-test a standalone GUI folder for the given product."""
if product not in GUI_PRODUCTS:
raise ValueError(f"Unknown GUI product {product!r}; choose from {sorted(GUI_PRODUCTS)}")
profile = GUI_PRODUCTS[product]
if dist_dir is None:
dist_dir = gui_dist_dir(profile["dist_name"]) if product != "ory_finger" else GUI_DIST_DIR
dist_dir = dist_dir.resolve()
exe = dist_dir / profile["exe_name"]
if not exe.is_file():
raise FileNotFoundError(f"GUI executable not found: {exe}")
required = [dist_dir / rel for rel in profile["required_relpaths"]]
missing = [str(path.relative_to(dist_dir)) for path in required if not path.is_file()]
if missing:
raise FileNotFoundError(f"Missing bundled files in {dist_dir}: {', '.join(missing)}")
subprocess.check_call([str(exe), "--help"], cwd=dist_dir)
print(f"GUI dist smoke test OK ({product}): {exe}")
+62
View File
@@ -0,0 +1,62 @@
"""Isolated venv smoke tests for packaged orisys wheels."""
from __future__ import annotations
import subprocess
import sys
import tempfile
import venv
from pathlib import Path
# Public SDK surface that must import from a released wheel.
_SMOKE_IMPORT_SCRIPT = """
import orisys
from orisys import Sensor, list_config, export_config, util, finger
from orisys.finger import UVMeshViewer, build_uv_surface_mesh, FingerRuntime
from orisys.finger.mesh import UVSurfaceMesh
from orisys.finger.viewer import init_uv_mesh_viewer
from orisys.hhd import nHHD
from orisys.tracker import Tracker
from orisys import capture
from orisys.Stitcher import Stitcher
assert orisys.__version__
assert Sensor is not None
assert callable(list_config)
assert callable(export_config)
assert util is not None
assert finger is not None
assert UVMeshViewer is not None
assert build_uv_surface_mesh is not None
assert FingerRuntime is not None
assert UVSurfaceMesh is not None
assert init_uv_mesh_viewer is not None
assert nHHD is not None
assert Tracker is not None
assert capture is not None
assert Stitcher is not None
print(f"orisys {orisys.__version__} public API imports OK")
"""
def _venv_python(venv_dir: Path) -> Path:
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def verify_built_wheel(wheel_path: Path) -> None:
"""Install the wheel in a temporary venv and smoke-test public imports."""
wheel_path = wheel_path.resolve()
with tempfile.TemporaryDirectory(prefix="orisys-wheel-verify-") as tmp:
venv_dir = Path(tmp) / "venv"
venv.create(venv_dir, with_pip=True)
python = _venv_python(venv_dir)
subprocess.check_call(
[python, "-m", "pip", "install", "-q", "--upgrade", "pip"],
)
subprocess.check_call(
[python, "-m", "pip", "install", "-q", str(wheel_path)],
)
subprocess.check_call([python, "-c", _SMOKE_IMPORT_SCRIPT])
+1
View File
@@ -0,0 +1 @@
"""Standalone GUI packaging targets (PyInstaller)."""
+1
View File
@@ -0,0 +1 @@
"""Shared helpers for product GUI builds."""
+141
View File
@@ -0,0 +1,141 @@
"""Shared PyInstaller driver for product GUI standalone folders."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import PYINSTALLER_WORK_DIR, REPO_ROOT, gui_dist_dir
@dataclass
class GuiBuildConfig:
product: str
entry_script: Path
exe_name: str
dist_name: str
hidden_imports: list[str] = field(default_factory=list)
exclude_modules: list[str] = field(default_factory=list)
data_dirs: list[tuple[str, str]] = field(default_factory=list)
readme_lines: list[str] = field(default_factory=list)
windowed: bool = True
upx_dir: str | None = r"C:\tools\upx"
logo_name: str = "logo.png"
work_dir: Path = PYINSTALLER_WORK_DIR
spec_dir: Path = REPO_ROOT / "build" / "pyinstaller_work"
def clean_dir(path: Path) -> None:
if path.exists():
shutil.rmtree(path)
path.mkdir(parents=True)
def build_with_pyinstaller(
config: GuiBuildConfig,
*,
verify_fn: Callable[[Path], None] | None = None,
) -> Path:
"""Run PyInstaller for one GUI product and copy bundled data folders."""
print("Using Python:", sys.executable)
print(f"Building GUI product: {config.product}")
dist_dir = gui_dist_dir(config.dist_name)
clean_dir(config.work_dir)
clean_dir(dist_dir)
config.spec_dir.mkdir(parents=True, exist_ok=True)
entry = config.entry_script
if not entry.is_file():
raise FileNotFoundError(f"GUI entry script not found: {entry}")
cmd = [
sys.executable,
"-m",
"PyInstaller",
"--onedir",
"--name",
config.dist_name,
f"--distpath={dist_dir.parent}",
f"--workpath={config.work_dir}",
f"--specpath={config.spec_dir}",
"--noconfirm",
"--clean",
"--windowed" if config.windowed else "--console",
]
for src_rel, dest_rel in config.data_dirs:
src = (REPO_ROOT / src_rel).resolve()
if not src.is_dir():
raise FileNotFoundError(f"Data directory missing: {src}")
cmd.append(f"--add-data={src};{dest_rel}")
logo = REPO_ROOT / config.logo_name
if logo.is_file():
cmd.append(f"--add-data={logo.resolve()};.")
for mod in config.hidden_imports:
cmd.append(f"--hidden-import={mod}")
for mod in config.exclude_modules:
cmd.append(f"--exclude-module={mod}")
if config.upx_dir and Path(config.upx_dir).is_dir():
cmd.append(f"--upx-dir={config.upx_dir}")
cmd.append(str(entry.resolve()))
env = os.environ.copy()
env["PYTHONPATH"] = (
str((REPO_ROOT / "src").resolve())
+ os.pathsep
+ str((REPO_ROOT / "examples").resolve())
+ os.pathsep
+ env.get("PYTHONPATH", "")
)
print("\n=== Running PyInstaller ===")
print("Command:", " ".join(cmd))
subprocess.check_call(cmd, env=env, cwd=REPO_ROOT)
final_exe = dist_dir / config.exe_name
if not final_exe.is_file():
raise RuntimeError(f"Executable not found at {final_exe}")
print("\n=== Copying data directories ===")
for src_rel, dest_rel in config.data_dirs:
src = REPO_ROOT / src_rel
dest = dist_dir / dest_rel
if not src.is_dir():
raise FileNotFoundError(f"Data directory not found: {src}")
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src, dest, dirs_exist_ok=True)
print(f" {src} -> {dest}")
if logo.is_file():
shutil.copy2(logo, dist_dir / config.logo_name)
print(" Logo copied")
if config.readme_lines:
(dist_dir / "README.txt").write_text("\n".join(config.readme_lines) + "\n", encoding="utf-8")
print(f"\n[SUCCESS] Standalone GUI folder: {dist_dir}")
print(f"Executable: {final_exe}")
if verify_fn is not None:
try:
print("\n=== Smoke test ===")
verify_fn(dist_dir)
except Exception as exc:
print(f"\n[WARNING] Smoke test failed: {exc}")
return dist_dir
+1
View File
@@ -0,0 +1 @@
"""ORY-BOX internal calibration/testing GUI packaging (scaffold)."""
+30
View File
@@ -0,0 +1,30 @@
"""Scaffold for ORY-BOX internal GUI packaging.
Not implemented yet. When ready, wire a dedicated calibration/testing entry
script and call ``gui.common.pyinstaller_driver.build_with_pyinstaller``.
"""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from gui.ory_box.config import DIST_NAME, ENTRY_SCRIPT, make_config
def main() -> None:
config = make_config()
raise NotImplementedError(
"ORY-BOX GUI packaging is a scaffold only. "
f"Planned dist={DIST_NAME!r}, entry={ENTRY_SCRIPT}. "
"Implement the internal calibration/testing UI entry, then call "
"gui.common.pyinstaller_driver.build_with_pyinstaller(config)."
)
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
"""Product configuration scaffold for the ORY-BOX internal GUI."""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "basic_pipeline.py"
EXE_NAME = "OryBox.exe"
DIST_NAME = "OryBox"
HIDDEN_IMPORTS = [
"PyQt5",
"pyqtgraph",
"cv2",
"orisys",
"gui",
"pipeline",
"viz",
]
EXCLUDE_MODULES = [
"cupy",
"cupyx",
"pyopencl",
"open3d",
"tkinter",
"pip",
"wheel",
]
DATA_DIRS = [
("config", "config"),
]
README_LINES = [
"ORY-BOX internal calibration/testing GUI (scaffold)",
"",
"This product packaging target is reserved for a future internal tool.",
"Entry point placeholder:",
f" {ENTRY_SCRIPT.as_posix()}",
]
def make_config() -> GuiBuildConfig:
return GuiBuildConfig(
product="ory_box",
entry_script=ENTRY_SCRIPT,
exe_name=EXE_NAME,
dist_name=DIST_NAME,
hidden_imports=list(HIDDEN_IMPORTS),
exclude_modules=list(EXCLUDE_MODULES),
data_dirs=list(DATA_DIRS),
readme_lines=list(README_LINES),
windowed=False,
)
+1
View File
@@ -0,0 +1 @@
"""ORY-FINGER standalone GUI packaging."""
+31
View File
@@ -0,0 +1,31 @@
"""Build the ORY-FINGER standalone Windows GUI folder with PyInstaller.
Usage:
python build/run.py gui finger
python build/gui/ory_finger/build.py
"""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.verify.gui import verify_gui_dist
from gui.common.pyinstaller_driver import build_with_pyinstaller
from gui.ory_finger.config import make_config
def main() -> None:
config = make_config()
build_with_pyinstaller(
config,
verify_fn=lambda dist_dir: verify_gui_dist(dist_dir, product="ory_finger"),
)
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
"""Product configuration for the ORY-FINGER Windows dashboard."""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "finger_pipeline_3dview.py"
EXE_NAME = "OrisysDashboard.exe"
DIST_NAME = "OrisysDashboard"
HIDDEN_IMPORTS = [
"PyQt5",
"pyqtgraph",
"open3d",
"cv2",
"orisys",
"gui",
"pipeline",
"viz",
"gui.app_paths",
"plotly",
"_plotly_utils",
"open3d.cpu.pybind",
]
EXCLUDE_MODULES = [
"cupy",
"cupyx",
"pyopencl",
"tkinter",
"pip",
"wheel",
"matplotlib",
]
DATA_DIRS = [
("config", "config"),
("sensors", "sensors"),
]
README_LINES = [
"Orisys Finger Dashboard (standalone)",
"",
"Run:",
f" {EXE_NAME}",
f" {EXE_NAME} --video path\\to\\video.mp4",
f" {EXE_NAME} --config config\\dd02-ov.json",
"",
"Bundled folders:",
" config/ sensor JSON configs",
" sensors/ per-sensor bundles (finger mesh and visualization data)",
"",
"CPU-only build (no CUDA/CuPy).",
]
def make_config() -> GuiBuildConfig:
return GuiBuildConfig(
product="ory_finger",
entry_script=ENTRY_SCRIPT,
exe_name=EXE_NAME,
dist_name=DIST_NAME,
hidden_imports=list(HIDDEN_IMPORTS),
exclude_modules=list(EXCLUDE_MODULES),
data_dirs=list(DATA_DIRS),
readme_lines=list(README_LINES),
)
+1
View File
@@ -0,0 +1 @@
"""ORY-MINI internal calibration/testing GUI packaging (scaffold)."""
+30
View File
@@ -0,0 +1,30 @@
"""Scaffold for ORY-MINI internal GUI packaging.
Not implemented yet. When ready, wire a dedicated calibration/testing entry
script and call ``gui.common.pyinstaller_driver.build_with_pyinstaller``.
"""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from gui.ory_mini.config import DIST_NAME, ENTRY_SCRIPT, make_config
def main() -> None:
config = make_config()
raise NotImplementedError(
"ORY-MINI GUI packaging is a scaffold only. "
f"Planned dist={DIST_NAME!r}, entry={ENTRY_SCRIPT}. "
"Implement the internal calibration/testing UI entry, then call "
"gui.common.pyinstaller_driver.build_with_pyinstaller(config)."
)
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
"""Product configuration scaffold for the ORY-MINI internal GUI."""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "mini_pipeline.py"
EXE_NAME = "OryMini.exe"
DIST_NAME = "OryMini"
HIDDEN_IMPORTS = [
"PyQt5",
"pyqtgraph",
"cv2",
"orisys",
"gui",
"pipeline",
"viz",
]
EXCLUDE_MODULES = [
"cupy",
"cupyx",
"pyopencl",
"open3d",
"tkinter",
"pip",
"wheel",
]
DATA_DIRS = [
("config", "config"),
]
README_LINES = [
"ORY-MINI internal calibration/testing GUI (scaffold)",
"",
"This product packaging target is reserved for a future internal tool.",
"Entry point placeholder:",
f" {ENTRY_SCRIPT.as_posix()}",
]
def make_config() -> GuiBuildConfig:
return GuiBuildConfig(
product="ory_mini",
entry_script=ENTRY_SCRIPT,
exe_name=EXE_NAME,
dist_name=DIST_NAME,
hidden_imports=list(HIDDEN_IMPORTS),
exclude_modules=list(EXCLUDE_MODULES),
data_dirs=list(DATA_DIRS),
readme_lines=list(README_LINES),
windowed=False,
)
-20
View File
@@ -1,20 +0,0 @@
"""Repository and Nuitka build output paths."""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
NUITKA_COMPILE_DIR = REPO_ROOT / "build" / "nuitka" / "compile"
NUITKA_WHEEL_DIR = REPO_ROOT / "build" / "nuitka" / "wheel"
NUITKA_GUI_DIR = REPO_ROOT / "build" / "nuitka" / "gui"
GUI_DIST_DIR = REPO_ROOT / "dist" / "OrisysDashboard"
def repo_path(*parts: str) -> Path:
return REPO_ROOT.joinpath(*parts)
def sdk_dir(version: str) -> Path:
return REPO_ROOT / "SDK" / version
+104
View File
@@ -0,0 +1,104 @@
"""Unified Orisys packaging entrypoint.
Usage (from repo root):
python build/run.py wheel [--target internal|pypi-linux] [--verify]
python build/run.py wheel-win
python build/run.py wheel-manylinux [PowerShell args...]
python build/run.py gui finger|mini|box
python build/run.py verify [wheel.whl]
python build/run.py verify --gui-dist [--product ory_finger]
See build/README.md for details.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parent
_REPO_ROOT = _BUILD_ROOT.parent
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
def _run_python(script: Path, argv: list[str]) -> int:
cmd = [sys.executable, str(script), *argv]
return subprocess.call(cmd, cwd=_REPO_ROOT)
def _cmd_wheel(argv: list[str]) -> int:
return _run_python(_BUILD_ROOT / "wheel" / "build.py", argv)
def _cmd_wheel_win(argv: list[str]) -> int:
bat = _BUILD_ROOT / "wheel" / "win.bat"
return subprocess.call(["cmd", "/c", str(bat), *argv], cwd=_REPO_ROOT)
def _cmd_manylinux(argv: list[str]) -> int:
ps1 = _BUILD_ROOT / "wheel" / "docker" / "run.ps1"
cmd = [
"powershell",
"-ExecutionPolicy",
"Bypass",
"-File",
str(ps1),
*argv,
]
return subprocess.call(cmd, cwd=_REPO_ROOT)
def _cmd_gui(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="build/run.py gui")
parser.add_argument(
"product",
choices=("finger", "mini", "box", "ory_finger", "ory_mini", "ory_box"),
help="GUI product to package",
)
args, rest = parser.parse_known_args(argv)
product = {
"finger": "ory_finger",
"mini": "ory_mini",
"box": "ory_box",
}.get(args.product, args.product)
return _run_python(_BUILD_ROOT / "gui" / product / "build.py", rest)
def _cmd_verify(argv: list[str]) -> int:
from common.verify.cli import main as verify_main
return verify_main(argv)
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
print("Commands: wheel | wheel-win | manylinux | gui | verify")
print("Examples:")
print(" python build/run.py wheel --help")
print(" python build/run.py gui finger")
print(" python build/run.py manylinux -PythonVersions 3.10")
return 0
command, rest = argv[0], argv[1:]
dispatch = {
"wheel": _cmd_wheel,
"wheel-win": _cmd_wheel_win,
"wheel-manylinux": _cmd_manylinux,
"gui": _cmd_gui,
"verify": _cmd_verify,
}
if command not in dispatch:
print(f"Unknown command: {command!r}", file=sys.stderr)
print("Choose from: wheel, wheel-win, manylinux, gui, verify", file=sys.stderr)
return 2
return dispatch[command](rest)
if __name__ == "__main__":
raise SystemExit(main())
-82
View File
@@ -1,82 +0,0 @@
"""Post-build smoke tests for packaged wheels and standalone GUI builds."""
from __future__ import annotations
import argparse
import subprocess
import sys
import tempfile
import venv
from pathlib import Path
from paths import GUI_DIST_DIR
def _venv_python(venv_dir: Path) -> Path:
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def verify_built_wheel(wheel_path: Path) -> None:
"""Install the wheel in a temporary venv and smoke-test finger imports."""
wheel_path = wheel_path.resolve()
with tempfile.TemporaryDirectory(prefix="orisys-wheel-verify-") as tmp:
venv_dir = Path(tmp) / "venv"
venv.create(venv_dir, with_pip=True)
python = _venv_python(venv_dir)
subprocess.check_call(
[python, "-m", "pip", "install", "-q", str(wheel_path)],
)
subprocess.check_call(
[
python,
"-c",
(
"from orisys.finger import UVMeshViewer, build_uv_surface_mesh, FingerRuntime; "
"from orisys.finger.mesh import UVSurfaceMesh; "
"from orisys.finger.viewer import init_uv_mesh_viewer; "
"print('finger mesh/viewer imports OK')"
),
],
)
def verify_gui_dist(dist_dir: Path | None = None) -> None:
"""Smoke-test a standalone GUI folder produced by build_gui_win.py."""
dist_dir = (dist_dir or GUI_DIST_DIR).resolve()
exe = dist_dir / "OrisysDashboard.exe"
if not exe.is_file():
raise FileNotFoundError(f"GUI executable not found: {exe}")
required = [
dist_dir / "config" / "dd02-ov.json",
dist_dir / "assets" / "finger" / "visualization" / "world_to_cam.npy",
dist_dir / "assets" / "finger" / "30fps" / "mesh.npz",
]
missing = [str(path.relative_to(dist_dir)) for path in required if not path.is_file()]
if missing:
raise FileNotFoundError(f"Missing bundled files in {dist_dir}: {', '.join(missing)}")
subprocess.check_call([str(exe), "--help"], cwd=dist_dir)
print(f"GUI dist smoke test OK: {exe}")
def main() -> None:
parser = argparse.ArgumentParser(description="Verify packaged Orisys artifacts.")
parser.add_argument("--gui-dist", action="store_true", help="Verify dist/OrisysDashboard/")
parser.add_argument("--dist-dir", type=Path, default=None, help="Override GUI dist folder")
parser.add_argument("wheel", nargs="?", type=Path, help="Wheel path for import smoke test")
args = parser.parse_args()
if args.gui_dist:
verify_gui_dist(args.dist_dir)
return
if args.wheel is None:
parser.error("Provide a wheel path or pass --gui-dist")
verify_built_wheel(args.wheel)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
"""Nuitka-compiled SDK wheel packaging."""
+203
View File
@@ -0,0 +1,203 @@
"""Build a Nuitka-compiled orisys wheel and publish it to SDK/{version}/.
Pipeline:
1. Compile src/orisys → extension module (.pyd / .so)
2. Stage wheel tree (compiled module + bundled files from pyproject.toml)
3. Run bdist_wheel; on Linux, auditwheel-repair to a manylinux tag
4. Smoke-test imports in a temporary venv (required for --target pypi-linux)
Targets:
internal – local/SDK delivery (default). Linux still repairs with auditwheel.
pypi-linux – PyPI binary wheel; requires Linux + CPython 3.10/3.11/3.12 + compliant tag.
Run from repo root:
python build/run.py wheel
python build/run.py wheel --target pypi-linux
python build/wheel/build.py --target pypi-linux
"""
from __future__ import annotations
import argparse
import os
import shutil
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))
from common.paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repaired_wheel_dir, repo_path
from common.pyproject_utils import bundle_items, extras_require, get_version, install_requires
from common.verify.wheel import verify_built_wheel
from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka
from wheel.layout import (
copy_bundle_items,
copy_orisys_blend,
package_data_from_bundle,
)
from wheel.package import (
is_compliant_manylinux_wheel,
publish_wheel_to_sdk,
repair_linux_wheel,
run_bdist_wheel,
write_wheel_setup,
)
VALID_TARGETS = ("internal", "pypi-linux")
# ABI tags that manylinux images ship under /opt/python/cpXYY-cpXYY.
PYPI_LINUX_PYTHON_VERSIONS = ((3, 10), (3, 11), (3, 12))
def _reset_dir(path: Path) -> None:
"""Remove and recreate a build output directory."""
if path.exists():
shutil.rmtree(path)
path.mkdir(parents=True)
def _validate_pypi_linux_environment() -> None:
if sys.platform != "linux":
raise SystemExit(
"[ERROR] --target pypi-linux must run inside a Linux manylinux container "
"(for example quay.io/pypa/manylinux_2_28_x86_64 via Docker Desktop)."
)
if sys.version_info[:2] not in PYPI_LINUX_PYTHON_VERSIONS:
supported = ", ".join(f"{major}.{minor}" for major, minor in PYPI_LINUX_PYTHON_VERSIONS)
raise SystemExit(
f"[ERROR] --target pypi-linux requires CPython {supported}, got "
f"{sys.version_info.major}.{sys.version_info.minor}. "
"Use /opt/python/cp310-cp310 (or cp311/cp312) in the manylinux_2_28 image."
)
def build(*, target: str = "internal", verify: bool | None = None) -> Path:
if target not in VALID_TARGETS:
raise SystemExit(f"[ERROR] Unknown target {target!r}; choose from {VALID_TARGETS}")
if target == "pypi-linux":
_validate_pypi_linux_environment()
# pypi-linux always verifies; internal opts in with --verify.
if verify is None:
verify = target == "pypi-linux"
# --- 1. Compile ---
install_nuitka()
version = get_version(REPO_ROOT)
_reset_dir(NUITKA_COMPILE_DIR)
_reset_dir(NUITKA_WHEEL_DIR)
print(f"\n=== Compiling with Nuitka (target={target}, version={version}) ===")
env = os.environ.copy()
src_dir = repo_path("src")
env["PYTHONPATH"] = src_dir.as_posix() + os.pathsep + env.get("PYTHONPATH", "")
ext_file = compile_orisys(NUITKA_COMPILE_DIR, env)
# --- 2. Stage wheel contents ---
print("\n=== Preparing Wheel Structure ===")
pkg_dir = NUITKA_WHEEL_DIR / "wheel_root" / "orisys"
pkg_dir.mkdir(parents=True)
ext_suffix = copy_compiled_orisys(pkg_dir, ext_file)
wheel_root = NUITKA_WHEEL_DIR / "wheel_root"
blend_count = copy_orisys_blend(REPO_ROOT, wheel_root)
print(f"Staged orisys_blend Python modules: {blend_count}")
# Bundle rules live in pyproject.toml → [[tool.orisys.bundle.items]]
bundle = bundle_items(REPO_ROOT)
copied_counts = copy_bundle_items(REPO_ROOT, pkg_dir, bundle)
print(f"Copied {copied_counts['configs']} JSON config files")
print(f"Copied {copied_counts['examples']} example files to orisys/examples/")
print(f"Copied {copied_counts['docs']} doc files to orisys/docs/")
print(f"Copied {copied_counts['sensors']} sensor bundle files to orisys/sensors/")
write_wheel_setup(
NUITKA_WHEEL_DIR,
version=version,
package_data=package_data_from_bundle(ext_suffix, bundle),
install_requires=install_requires(REPO_ROOT),
extras_require=extras_require(REPO_ROOT),
)
# --- 3. Build wheel, repair on Linux, publish to SDK/ ---
wheel_path = run_bdist_wheel(NUITKA_WHEEL_DIR)
if wheel_path is None:
raise SystemExit("\n[ERROR] Wheel not found after bdist_wheel.")
repair_dir = repaired_wheel_dir()
final_candidate = repair_linux_wheel(wheel_path, repair_dir)
if target == "pypi-linux" and not is_compliant_manylinux_wheel(final_candidate):
raise SystemExit(
f"[ERROR] Wheel is not PyPI-compliant: {final_candidate.name}. "
"Expected a manylinux_2_17_x86_64 (or newer) platform tag."
)
# Keep a durable copy under dist/repaired for upload tooling.
dist_repaired = REPO_ROOT / "dist" / "repaired"
dist_repaired.mkdir(parents=True, exist_ok=True)
dist_copy = dist_repaired / final_candidate.name
if dist_copy.resolve() != final_candidate.resolve():
shutil.copy2(final_candidate, dist_copy)
final_wheel = publish_wheel_to_sdk(final_candidate, version)
print(f"\n[SUCCESS] Wheel created and copied to: {final_wheel}")
print(f"[SUCCESS] Upload candidate also at: {dist_copy}")
# --- 4. Verify (isolated venv; does not touch the active environment) ---
if verify:
print("\n=== Verifying wheel imports in isolated venv ===")
try:
verify_built_wheel(final_wheel)
print("[SUCCESS] Wheel smoke test passed.")
except subprocess.CalledProcessError as exc:
if target == "pypi-linux":
raise SystemExit(
f"[ERROR] Wheel verify failed (required for pypi-linux): {exc}"
) from exc
print(f"\n[WARNING] Wheel verify failed: {exc}")
print("The wheel was built successfully; verification runs in an isolated temp venv.")
else:
print("\n[INFO] Skipping wheel verify (pass --verify to enable).")
return final_wheel
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Build a Nuitka-compiled orisys wheel.")
parser.add_argument(
"--target",
choices=VALID_TARGETS,
default="internal",
help="internal: SDK delivery; pypi-linux: manylinux PyPI binary wheel",
)
parser.add_argument(
"--verify",
action="store_true",
help="Run isolated venv smoke test (always on for --target pypi-linux)",
)
parser.add_argument(
"--skip-verify",
action="store_true",
help="Skip smoke test when using --target internal",
)
args = parser.parse_args(argv)
verify: bool | None
if args.target == "pypi-linux":
verify = True
elif args.verify:
verify = True
elif args.skip_verify:
verify = False
else:
verify = None
build(target=args.target, verify=verify)
if __name__ == "__main__":
main()
@@ -8,21 +8,36 @@ import sys
from importlib.machinery import EXTENSION_SUFFIXES from importlib.machinery import EXTENSION_SUFFIXES
from pathlib import Path from pathlib import Path
from paths import repo_path _BUILD_ROOT = Path(__file__).resolve().parents[1]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import repo_path
NUITKA_INCLUDE_PACKAGES = ( NUITKA_INCLUDE_PACKAGES = (
"orisys", "orisys",
"orisys.capture",
"orisys.finger", "orisys.finger",
"orisys.stitch",
"orisys.stitch.backends",
"orisys.unroll", "orisys.unroll",
"orisys.hhd", "orisys.hhd",
"orisys.hhd.solvers", "orisys.hhd.solvers",
) )
NUITKA_NOFOLLOW_IMPORTS = ( NUITKA_NOFOLLOW_IMPORTS = (
# Keep as runtime deps (shipped via wheel install_requires / extras).
"numpy",
"scipy",
"cv2",
"numba",
"orisys_blend",
"cupy", "cupy",
"cupyx", "cupyx",
"pyopencl", "pyopencl",
"open3d", "open3d",
"PyQt5",
"pyqtgraph",
) )
+26
View File
@@ -0,0 +1,26 @@
# manylinux_2_28: modern glibc baseline with binary wheels for numpy/scipy/opencv.
# Prefer this over manylinux2014 — recent NumPy requires GCC >= 10.3 and will try
# (and fail) to build from source on the older image.
FROM quay.io/pypa/manylinux_2_28_x86_64:latest
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_ROOT_USER_ACTION=ignore
# Only install compile/repair tooling here. Runtime deps (numpy/scipy/opencv/numba)
# are declared in the wheel metadata and installed by pip when users install orisys.
# Nuitka is configured with --nofollow-import-to for those packages.
RUN for PYBIN in \
/opt/python/cp310-cp310/bin \
/opt/python/cp311-cp311/bin \
/opt/python/cp312-cp312/bin; do \
echo "=== Installing build tooling into ${PYBIN} ===" && \
"${PYBIN}/python" -m pip install --upgrade pip setuptools wheel && \
"${PYBIN}/python" -m pip install \
"nuitka>=2.0" \
ordered-set \
zstandard \
auditwheel; \
done
WORKDIR /src
ENTRYPOINT ["/bin/bash", "/src/build/wheel/docker/entrypoint.sh"]
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Runs inside quay.io/pypa/manylinux_2_28_x86_64 with the repo mounted at /src.
# Builds one Nuitka wheel per CPython ABI listed in ORISYS_PYTHON_TAGS.
set -euo pipefail
TARGET="${ORISYS_BUILD_TARGET:-pypi-linux}"
# Comma-separated manylinux tags, e.g. "cp310-cp310,cp311-cp311,cp312-cp312"
PYTHON_TAGS="${ORISYS_PYTHON_TAGS:-cp310-cp310,cp311-cp311,cp312-cp312}"
cd /src
echo "=== manylinux entrypoint ==="
echo "Target: ${TARGET}"
echo "Python tags: ${PYTHON_TAGS}"
uname -a
IFS=',' read -r -a TAG_ARRAY <<< "${PYTHON_TAGS}"
FAILED=0
BUILT=()
for tag in "${TAG_ARRAY[@]}"; do
tag="$(echo "${tag}" | tr -d '[:space:]')"
if [[ -z "${tag}" ]]; then
continue
fi
PYBIN="/opt/python/${tag}/bin"
if [[ ! -x "${PYBIN}/python" ]]; then
echo "[ERROR] Python not found for tag ${tag}: ${PYBIN}/python" >&2
FAILED=1
continue
fi
echo ""
echo "========================================"
echo "Building orisys wheel: ${tag}"
echo "Python: $("${PYBIN}/python" -V)"
echo "========================================"
export PATH="${PYBIN}:${PATH}"
export PYTHONPATH="/src/src${PYTHONPATH:+:${PYTHONPATH}}"
"${PYBIN}/python" -m pip install -q --upgrade pip setuptools wheel auditwheel
if "${PYBIN}/python" build/wheel/build.py --target "${TARGET}"; then
BUILT+=("${tag}")
else
echo "[ERROR] Build failed for ${tag}" >&2
FAILED=1
fi
done
echo ""
echo "=== Build artifacts ==="
ls -la /src/dist/repaired/ || true
ls -la /src/SDK/*/ || true
if [[ ${#BUILT[@]} -gt 0 ]]; then
echo "Built tags: ${BUILT[*]}"
fi
if [[ "${FAILED}" -ne 0 ]]; then
echo "[ERROR] One or more Python ABI builds failed." >&2
exit 1
fi
echo "[SUCCESS] All requested manylinux wheels built."
+100
View File
@@ -0,0 +1,100 @@
# Build PyPI-compliant manylinux wheels for CPython 3.10/3.11/3.12 using Docker Desktop.
#
# Usage (from repo root, PowerShell):
# .\build\wheel\docker\run.ps1
# .\build\build_manylinux_docker.ps1 # compatibility wrapper
#
# From cmd.exe:
# powershell -ExecutionPolicy Bypass -File .\build\build_manylinux_docker.ps1
param(
[ValidateSet("pypi-linux", "internal")]
[string]$Target = "pypi-linux",
[string]$PythonVersions = "3.10,3.11,3.12",
[switch]$SkipImageBuild,
[string]$ImageName = "orisys-manylinux"
)
$ErrorActionPreference = "Stop"
function Convert-ToManylinuxTag {
param([string]$Version)
$v = $Version.Trim()
switch ($v) {
"3.10" { return "cp310-cp310" }
"3.11" { return "cp311-cp311" }
"3.12" { return "cp312-cp312" }
"cp310-cp310" { return "cp310-cp310" }
"cp311-cp311" { return "cp311-cp311" }
"cp312-cp312" { return "cp312-cp312" }
default {
throw "Unsupported Python version '$Version'. Use 3.10, 3.11, and/or 3.12."
}
}
}
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path
Set-Location $RepoRoot
$tags = @()
foreach ($part in ($PythonVersions -split ",")) {
if ([string]::IsNullOrWhiteSpace($part)) { continue }
$tags += (Convert-ToManylinuxTag $part)
}
if ($tags.Count -eq 0) {
throw "No Python versions specified."
}
$pythonTags = [string]::Join(",", $tags)
Write-Host "=== Orisys manylinux Docker build ==="
Write-Host "Repo: $RepoRoot"
Write-Host "Target: $Target"
Write-Host "Image: $ImageName"
Write-Host "Python tags: $pythonTags"
# Docker Desktop must be in Linux container mode for manylinux.
$dockerInfo = docker version --format "{{.Server.Os}}" 2>$null
if ($LASTEXITCODE -ne 0) {
throw "Docker is not available. Start Docker Desktop and retry."
}
if ($dockerInfo -ne "linux") {
throw "Docker server OS is '$dockerInfo'. Switch Docker Desktop to Linux containers."
}
$entrypoint = Join-Path $RepoRoot "build\wheel\docker\entrypoint.sh"
# Normalize CRLF -> LF so bash in the container can execute the script.
$raw = [System.IO.File]::ReadAllText($entrypoint)
$normalized = $raw -replace "`r`n", "`n" -replace "`r", "`n"
[System.IO.File]::WriteAllText($entrypoint, $normalized)
$dockerfile = "build/wheel/docker/Dockerfile.manylinux"
if (-not $SkipImageBuild) {
Write-Host "`n=== Building Docker image ==="
docker build -f $dockerfile -t $ImageName .
if ($LASTEXITCODE -ne 0) {
throw "docker build failed"
}
}
Write-Host "`n=== Running manylinux container ==="
# Mount the repo so artifacts land under dist/repaired and SDK/<version>/ on the host.
docker run --rm `
-e "ORISYS_BUILD_TARGET=$Target" `
-e "ORISYS_PYTHON_TAGS=$pythonTags" `
-v "${RepoRoot}:/src" `
$ImageName
if ($LASTEXITCODE -ne 0) {
throw "manylinux container build failed"
}
Write-Host "`n=== Host artifacts ==="
$repaired = Join-Path $RepoRoot "dist\repaired"
if (Test-Path $repaired) {
Get-ChildItem $repaired -Filter *.whl | ForEach-Object { Write-Host $_.FullName }
} else {
Write-Warning "No dist/repaired directory found."
}
Write-Host "`nNext steps: see docs/publishing.md (TestPyPI then PyPI)."
@@ -5,6 +5,21 @@ from __future__ import annotations
import shutil import shutil
from pathlib import Path from pathlib import Path
# Required files that must ship with the wheel for ORY-FINGER runtime/GUI.
# Accept both the flat layout and a nested assets/finger/ checkout tree.
REQUIRED_ASSET_RELPATHS = (
"finger/assets/30fps-0703/mesh.npz",
"finger/assets/30fps-0703/camera_intrinsics.npz",
"finger/assets/30fps-0703/world_to_cam.npy",
"finger/assets/visualization/world_to_cam.npy",
)
REQUIRED_ASSET_RELPATH_ALTERNATES = {
"finger/assets/30fps-0703/mesh.npz": "finger/assets/finger/30fps-0703/mesh.npz",
"finger/assets/30fps-0703/camera_intrinsics.npz": "finger/assets/finger/30fps-0703/camera_intrinsics.npz",
"finger/assets/30fps-0703/world_to_cam.npy": "finger/assets/finger/30fps-0703/world_to_cam.npy",
"finger/assets/visualization/world_to_cam.npy": "finger/assets/finger/visualization/world_to_cam.npy",
}
def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]: def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]:
"""Resolve explicit files and/or glob patterns for one bundle item.""" """Resolve explicit files and/or glob patterns for one bundle item."""
@@ -37,6 +52,36 @@ def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]:
return resolved return resolved
def verify_required_assets(repo_root: Path, items: list[dict]) -> None:
"""Fail early when required finger assets are missing from the bundle."""
asset_items = [item for item in items if item.get("name") == "sensors"]
if not asset_items:
return
resolved: set[str] = set()
for item in asset_items:
src_dir = (repo_root / item["source"]).resolve()
for path in resolve_bundle_source_files(repo_root, item):
try:
rel = path.resolve().relative_to(src_dir).as_posix()
except ValueError:
continue
resolved.add(rel)
missing = []
for rel in REQUIRED_ASSET_RELPATHS:
alt = REQUIRED_ASSET_RELPATH_ALTERNATES.get(rel)
if rel not in resolved and (alt is None or alt not in resolved):
missing.append(rel)
if missing:
joined = ", ".join(missing)
raise FileNotFoundError(
"Required finger assets are missing from the wheel bundle. "
f"Missing: {joined}. Update [[tool.orisys.bundle.items]] patterns "
"or restore assets under sensors/finger/assets/ before building."
)
def package_data_paths_for_item(item: dict) -> list[str]: def package_data_paths_for_item(item: dict) -> list[str]:
"""Return setuptools package_data paths relative to the orisys package.""" """Return setuptools package_data paths relative to the orisys package."""
target = str(item["target"]).replace("\\", "/") target = str(item["target"]).replace("\\", "/")
@@ -54,6 +99,7 @@ def data_file_entries_for_item(item: dict) -> list[str]:
def copy_bundle_items(repo_root: Path, pkg_dir: Path, items: list[dict]) -> dict[str, int]: def copy_bundle_items(repo_root: Path, pkg_dir: Path, items: list[dict]) -> dict[str, int]:
verify_required_assets(repo_root, items)
copied_counts: dict[str, int] = {} copied_counts: dict[str, int] = {}
for item in items: for item in items:
name = item["name"] name = item["name"]
@@ -82,10 +128,23 @@ def copy_bundle_items(repo_root: Path, pkg_dir: Path, items: list[dict]) -> dict
def package_data_from_bundle(ext_suffix: str, items: list[dict]) -> dict[str, list[str]]: def package_data_from_bundle(ext_suffix: str, items: list[dict]) -> dict[str, list[str]]:
package_data: dict[str, list[str]] = { package_data: dict[str, list[str]] = {
"orisys": [f"*{ext_suffix}"], "orisys": [f"*{ext_suffix}"],
"orisys.examples": ["*.py", "README.md", "app_paths.py", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"], "orisys.examples": ["*.py", "README.md", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"],
"orisys_blend": ["*.py"],
} }
for item in items: for item in items:
if item["name"] == "examples": if item["name"] == "examples":
continue continue
package_data["orisys"].extend(package_data_paths_for_item(item)) package_data["orisys"].extend(package_data_paths_for_item(item))
return package_data return package_data
def copy_orisys_blend(repo_root: Path, wheel_root: Path) -> int:
"""Copy pure-Python orisys_blend (Numba kernels) next to the Nuitka orisys .pyd."""
src = repo_root / "src" / "orisys_blend"
dst = wheel_root / "orisys_blend"
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst)
count = sum(1 for p in dst.rglob("*.py") if p.is_file())
print(f"Copied orisys_blend ({count} .py files) to {dst}")
return count
+139
View File
@@ -0,0 +1,139 @@
"""Assemble and build the Nuitka wheel with setuptools."""
from __future__ import annotations
import re
import shutil
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))
from common.paths import sdk_dir
# PyPI rejects bare tags like "manylinux_x86_64"; require a glibc ABI version.
_MANYLINUX_TAG_RE = re.compile(
r"(?:manylinux2014|manylinux_2_(?:17|24|27|28|31|34|35))_x86_64"
)
def write_wheel_setup(
wheel_dir: Path,
*,
version: str,
package_data: dict[str, list[str]],
install_requires: list[str],
extras_require: dict[str, list[str]],
) -> Path:
setup_py_path = wheel_dir / "setup.py"
setup_content = f"""
from setuptools import setup, Distribution
class BinaryDistribution(Distribution):
def has_ext_modules(self):
return True
setup(
name="orisys",
version="{version}",
description="Orisys - Optical Tactile Sensor SDK (Nuitka Compiled)",
author="Orisys",
packages=['orisys', 'orisys.examples', 'orisys_blend'],
package_dir={{'': 'wheel_root'}},
package_data={package_data},
include_package_data=True,
install_requires={install_requires!r},
extras_require={extras_require!r},
distclass=BinaryDistribution,
zip_safe=False,
)
"""
setup_py_path.write_text(setup_content, encoding="utf-8")
return setup_py_path
def run_bdist_wheel(wheel_dir: Path) -> Path | None:
print("\n=== Building Wheel ===")
subprocess.check_call([sys.executable, "setup.py", "bdist_wheel"], cwd=wheel_dir)
wheels = sorted((wheel_dir / "dist").glob("*.whl"))
return wheels[0] if wheels else None
def _ensure_auditwheel() -> None:
try:
subprocess.check_call(
[sys.executable, "-m", "auditwheel", "--version"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, FileNotFoundError):
print("Installing auditwheel...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "auditwheel", "-q"])
def is_compliant_manylinux_wheel(wheel_path: Path) -> bool:
"""True if the wheel filename uses a PyPI-accepted manylinux x86_64 tag."""
return bool(_MANYLINUX_TAG_RE.search(wheel_path.name))
def repair_linux_wheel(wheel_path: Path, repair_dir: Path) -> Path:
"""Run auditwheel show/repair and return a PyPI-compliant manylinux wheel.
Raises RuntimeError if repair fails or the result still lacks a valid tag.
On non-Linux platforms the input wheel is returned unchanged.
"""
if sys.platform != "linux":
print("Skipping auditwheel repair (not running on Linux).")
return wheel_path
_ensure_auditwheel()
repair_dir = repair_dir.resolve()
if repair_dir.exists():
shutil.rmtree(repair_dir)
repair_dir.mkdir(parents=True)
print("\n=== auditwheel show ===")
subprocess.check_call([sys.executable, "-m", "auditwheel", "show", str(wheel_path)])
print("\n=== auditwheel repair ===")
subprocess.check_call(
[
sys.executable,
"-m",
"auditwheel",
"repair",
str(wheel_path),
"--wheel-dir",
str(repair_dir),
]
)
repaired = sorted(repair_dir.glob("*.whl"))
if not repaired:
raise RuntimeError(f"auditwheel repair produced no wheels in {repair_dir}")
compliant = [path for path in repaired if is_compliant_manylinux_wheel(path)]
if not compliant:
names = ", ".join(path.name for path in repaired)
raise RuntimeError(
"auditwheel repair did not produce a PyPI-compliant manylinux tag. "
f"Got: {names}. Rebuild inside quay.io/pypa/manylinux_2_28_x86_64 "
"(or manylinux2014_x86_64) instead of a generic Linux host."
)
final = compliant[0]
print(f"Repaired wheel: {final.name}")
return final
def publish_wheel_to_sdk(wheel_path: Path, version: str) -> Path:
sdk_version_dir = sdk_dir(version)
sdk_version_dir.mkdir(parents=True, exist_ok=True)
dest_wheel = sdk_version_dir / wheel_path.name
if dest_wheel.exists():
dest_wheel.unlink()
shutil.copy2(str(wheel_path), str(dest_wheel))
return dest_wheel
@@ -3,7 +3,7 @@ REM Build Nuitka wheels for Python 3.10, 3.11, and 3.12.
REM Expects conda envs named py310, py311, py312 (or set CONDA_ENVS_ROOT). REM Expects conda envs named py310, py311, py312 (or set CONDA_ENVS_ROOT).
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
cd /d "%~dp0\.." cd /d "%~dp0\..\.."
if errorlevel 1 ( if errorlevel 1 (
echo ERROR: Could not change to repository root. echo ERROR: Could not change to repository root.
exit /b 1 exit /b 1
@@ -32,7 +32,7 @@ for %%E in (py310 py311 py312) do (
set "OK=0" set "OK=0"
if !HAS_CONDA! equ 1 ( if !HAS_CONDA! equ 1 (
call conda run --no-capture-output -n %%E python build\build_nuitka.py call conda run --no-capture-output -n %%E python build\wheel\build.py
if not errorlevel 1 set "OK=1" if not errorlevel 1 set "OK=1"
) )
@@ -44,7 +44,7 @@ for %%E in (py310 py311 py312) do (
if not exist "!PYEXE!" ( if not exist "!PYEXE!" (
echo ERROR: Python not found: !PYEXE! echo ERROR: Python not found: !PYEXE!
) else ( ) else (
"!PYEXE!" build\build_nuitka.py "!PYEXE!" build\wheel\build.py
if not errorlevel 1 set "OK=1" if not errorlevel 1 set "OK=1"
) )
) )
-62
View File
@@ -1,62 +0,0 @@
"""Assemble and build the Nuitka wheel with setuptools."""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
from paths import sdk_dir
def write_wheel_setup(
wheel_dir: Path,
*,
version: str,
package_data: dict[str, list[str]],
install_requires: list[str],
extras_require: dict[str, list[str]],
) -> Path:
setup_py_path = wheel_dir / "setup.py"
setup_content = f"""
from setuptools import setup, Distribution
class BinaryDistribution(Distribution):
def has_ext_modules(self):
return True
setup(
name="orisys",
version="{version}",
description="Orisys - Optical Tactile Sensor SDK (Nuitka Compiled)",
author="Orisys",
packages=['orisys', 'orisys.examples'],
package_dir={{'': 'wheel_root'}},
package_data={package_data},
include_package_data=True,
install_requires={install_requires!r},
extras_require={extras_require!r},
distclass=BinaryDistribution,
zip_safe=False,
)
"""
setup_py_path.write_text(setup_content, encoding="utf-8")
return setup_py_path
def run_bdist_wheel(wheel_dir: Path) -> Path | None:
print("\n=== Building Wheel ===")
subprocess.check_call([sys.executable, "setup.py", "bdist_wheel"], cwd=wheel_dir)
wheels = sorted((wheel_dir / "dist").glob("*.whl"))
return wheels[0] if wheels else None
def publish_wheel_to_sdk(wheel_path: Path, version: str) -> Path:
sdk_version_dir = sdk_dir(version)
sdk_version_dir.mkdir(parents=True, exist_ok=True)
dest_wheel = sdk_version_dir / wheel_path.name
if dest_wheel.exists():
dest_wheel.unlink()
shutil.move(str(wheel_path), str(dest_wheel))
return dest_wheel
+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"
}
}
}
@@ -11,14 +11,16 @@
}, },
"stitcher": { "stitcher": {
"min_area_frac": 0.01, "min_area_frac": 0.01,
"max_area_frac": 0.04, "max_area_frac": 0.05,
"max_aspect_ratio": 100, "max_aspect_ratio": 50,
"index": [1,2,3,6,7,8,11,12,13], "index": [1,2,3,6,7,8,11,12,13],
"margin": 10, "margin": 10,
"nominal_ov_pos": 100, "nominal_ov_pos": 100,
"n": [3,3], "n": [3,3],
"row_tol": 0.6, "row_tol": 0.6,
"box_size": [220, 220] "box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
}, },
"tracker": { "tracker": {
"adaptive": true, "adaptive": true,
@@ -33,11 +35,11 @@
}, },
"contact": { "contact": {
"min_contour_area": 3000, "min_contour_area": 3000,
"flow_threshold":1, "flow_threshold":5,
"div_threshold":0.02, "div_threshold":0.05,
"expansion_threshold": 0.01, "expansion_threshold": 0.01,
"kernel_size": [5,5], "kernel_size": [5,5],
"denoise_sigma": 15 "denoise_sigma": 12
} }
} }
+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"
}
}
}
+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"
}
}
}
+4 -2
View File
@@ -18,7 +18,9 @@
"nominal_ov_pos": 100, "nominal_ov_pos": 100,
"n": [3,3], "n": [3,3],
"row_tol": 0.6, "row_tol": 0.6,
"box_size": [250, 250] "box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
}, },
"tracker": { "tracker": {
"adaptive": true, "adaptive": true,
@@ -29,7 +31,7 @@
"dis_preset": "medium", "dis_preset": "medium",
"dis_mode": 3, "dis_mode": 3,
"algorithm_list": ["raft", "dis"], "algorithm_list": ["raft", "dis"],
"hhd_downsample": 3 "hhd_downsample": 1
}, },
"contact": { "contact": {
"min_contour_area": 3000, "min_contour_area": 3000,
+3 -1
View File
@@ -18,7 +18,9 @@
"nominal_ov_pos": 44, "nominal_ov_pos": 44,
"n": [3,3], "n": [3,3],
"row_tol": 0.6, "row_tol": 0.6,
"box_size": [98, 98] "box_size": [98, 98],
"blend":"numpy",
"blend_list": ["numpy", "numba","none"]
}, },
"tracker": { "tracker": {
"adaptive": true, "adaptive": true,
+4 -2
View File
@@ -18,7 +18,9 @@
"nominal_ov_pos": 67, "nominal_ov_pos": 67,
"n": [3,3], "n": [3,3],
"row_tol": 0.6, "row_tol": 0.6,
"box_size": [180, 180] "box_size": [180, 180],
"blend":"numpy",
"blend_list": ["numpy", "numba","none"]
}, },
"tracker": { "tracker": {
"adaptive": true, "adaptive": true,
@@ -29,7 +31,7 @@
"dis_preset": "medium", "dis_preset": "medium",
"dis_mode": 3, "dis_mode": 3,
"algorithm_list": ["raft", "dis"], "algorithm_list": ["raft", "dis"],
"hhd_downsample": 3, "hhd_downsample": 1,
"backend": "cpu" "backend": "cpu"
}, },
"contact": { "contact": {
-45
View File
@@ -1,45 +0,0 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1280, 720],
"fps": 15,
"units": 15,
"units_used": 9,
"resolution_stitch": [263, 263],
"stitch_res_list": [365,263,123,63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.04,
"max_aspect_ratio": 25,
"index": [1,2,3,6,7,8,11,12,13],
"margin": 10,
"nominal_ov_pos": 67,
"n": [3,3],
"row_tol": 0.6,
"box_size": [180, 180]
},
"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,
"backend": "cpu"
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":1,
"div_threshold":0.02,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 10
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "dd02-ov",
"type": "single_camera",
"version": "1.0",
"camera": {
"resolution_raw": [1280, 720],
"fps": 30,
"resolution_stitch": [280, 300],
"source_scale": 0.4,
"old":[672, 728, 1,0.5],
"work": [504, 546,1],
"work2": [420, 450,0.8],
"work3": [350, 375,4,0.7],
"work4": [280, 300,4,0.4,0.2]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 1,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 5
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 0.2,
"div_threshold": 0.022,
"expansion_threshold": 0.01,
"kernel_size": [5, 5],
"denoise_sigma": 12
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1280, 720],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [263, 263],
"stitch_res_list": [365,263,123,63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.04,
"max_aspect_ratio": 25,
"index": [1,2,3,6,7,8,11,12,13],
"margin": 10,
"nominal_ov_pos": 67,
"n": [3,3],
"row_tol": 0.6,
"box_size": [180, 180],
"blend":"numpy",
"blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":1,
"div_threshold":0.02,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 10
}
}
@@ -6,25 +6,28 @@
"fps": 30, "fps": 30,
"units": 15, "units": 15,
"units_used": 9, "units_used": 9,
"resolution_stitch": [384, 384] "resolution_stitch": [365, 365],
"stitch_res_list": [365,263,123,63]
}, },
"stitcher": { "stitcher": {
"min_area_frac": 0.01, "min_area_frac": 0.01,
"max_area_frac": 0.04, "max_area_frac": 0.05,
"max_aspect_ratio": 100, "max_aspect_ratio": 50,
"index": [1,2,3,6,7,8,11,12,13], "index": [1,2,3,6,7,8,11,12,13],
"margin": 10, "margin": 10,
"nominal_ov_pos": 100, "nominal_ov_pos": 100,
"n": [3,3], "n": [3,3],
"row_tol": 0.6, "row_tol": 0.6,
"box_size": [220, 220] "box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
}, },
"tracker": { "tracker": {
"adaptive": true, "adaptive": true,
"lower_threshold": 12.0, "lower_threshold": 12.0,
"upper_threshold": 18.0, "upper_threshold": 18.0,
"loop_closure_interval": 1, "loop_closure_interval": 1,
"algorithm": "raft", "algorithm": "dis",
"dis_preset": "medium", "dis_preset": "medium",
"dis_mode": 3, "dis_mode": 3,
"algorithm_list": ["raft", "dis"], "algorithm_list": ["raft", "dis"],
@@ -32,11 +35,11 @@
}, },
"contact": { "contact": {
"min_contour_area": 3000, "min_contour_area": 3000,
"flow_threshold":1.5, "flow_threshold":5,
"div_threshold":0.08, "div_threshold":0.05,
"expansion_threshold": 0.01, "expansion_threshold": 0.01,
"kernel_size": [5,5], "kernel_size": [5,5],
"denoise_sigma": 15 "denoise_sigma": 12
} }
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+402 -138
View File
@@ -1,86 +1,30 @@
# orisys API 文档 ---
title: API Reference
description: Orisys SDK Python API 中文参考
---
本文件提供了 `orisys` 套件的中文文档 # API Reference
## 准备 Python 开发环境 本文件提供 `orisys` 套件的 Python API 中文参考。安装、CUDA、CuPy 与运行示例的基础步骤请参考 [快速开始](./get_started.md)。
推荐下载 Anaconda,并使用Python 3.10
```
# 创建并激活虚拟环境
conda create -n orisys python=3.10
conda activate orisys
```
## 安装 CUDA Toolkit 和 cuDNN
在官方链接下载和安装:https://developer.nvidia.com/cuda/toolkit
或
在Anaconda环境中安装(例子使用cuda12.9):
https://anaconda.org/channels/nvidia/packages/cuda/overview
```
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
```
## SDK安装方法
1. 根据系统(win / linux),平台(amd64, aarch64), python(如p38、p39、p310) 版本下载对应的`.whl`文件
2. 运行pip命令安装:
```bash
pip install orisys-{version}-{python-version}-{python-version}-{os}_{machine}.whl
```
如
```bash
pip install orisys-0.4.0-cp310-cp310-win_amd64.whl
```
3. **安装 CuPy(根据您的 CUDA 版本选择):** ## Sensor API
**方法 1:使用 extras(推荐)**
```bash
# 对于 CUDA 12.x
pip install orisys[cuda12]
# 对于 CUDA 13.x
pip install orisys[cuda13]
```
**方法 2:手动安装**
```bash
# 对于 CUDA 12.x
pip install cupy-cuda12x>=12.3.0
# 对于 CUDA 13.x
pip install cupy-cuda13x>=13.0.0
```
4. 测试导入: ### `Sensor` 类别
```python #### 描述
import orisys
print(orisys.__version__)
```
5 安装opencl(实验):
在不带nvidia显卡的设备,可选择通过opencl进行gpu 计算加速,需要确保opencl的库或驱动已经安装,然后在conda环境中安装依赖:
``` bash
pip install pyopencl
conda install conda-forge::pyvkfft
```
***
## `Sensor` 类别
### 描述
`orisys`套件的核心类,用于从视频文件或实时摄像头中读取传感器图像,创建一个具体的传感器对象(实例)。并完成形变、光流及力信息的计算与管理。 `orisys`套件的核心类,用于从视频文件或实时摄像头中读取传感器图像,创建一个具体的传感器对象(实例)。并完成形变、光流及力信息的计算与管理。
### 输入参数 #### 输入参数
* **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。 * **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。
* **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `ddjx01` 或 `./config/ddjx01.json`。 * **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `box` 或 `./config/box.json`。
* **cal_path** (`str`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。 * **cal_path** (`str`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。
* **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。 * **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。
* **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。 * **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。
### 返回 #### 返回
* `Sensor` 实例,用于后续图像采集、形变计算和数据读取操作。 * `Sensor` 实例,用于后续图像采集、形变计算和数据读取操作。
### 示例 #### 示例
```python ```python
# Example 1: 用相机编号创建 # Example 1: 用相机编号创建
@@ -91,63 +35,62 @@ sensor = Sensor(0)
sensor = Sensor("/data/data.mp4") sensor = Sensor("/data/data.mp4")
``` ```
*** ### `Sensor.get_img` 方法
## 2. `Sensor.get_img` 方法 #### 描述
### 描述
采集并预处理一帧图像并存到`Sensor` 类别中。 采集并预处理一帧图像并存到`Sensor` 类别中。
### 返回 #### 返回
* `bool`:是否成功读取图像。 * `bool`:是否成功读取图像。
### 状态更新 #### 状态更新
* 成功时更新`sensor`类里面的`sensor.img`。处理后的BGR格式图像(`uint8`,尺寸为json文件中`resolution_stitch`对应分辨率)。 * 成功时更新`sensor`类里面的`sensor.img`。处理后的BGR格式图像(`uint8`,尺寸为json文件中`resolution_stitch`对应分辨率)。
### 示例 #### 示例
```python ```python
sensor.get_img() sensor.get_img()
``` ```
*** ### `Sensor.compute_deformation` 方法
## 3. `Sensor.compute_deformation` 方法 #### 描述
### 描述
基于当前帧图像计算光流/形变向量场,并通过 HHD 分解将形变场分解为法向/切向分量,同时估计法向力、切向力与扭矩等标量输出。该方法会更新 基于当前帧图像计算光流/形变向量场,并通过 HHD 分解将形变场分解为法向/切向分量,同时估计法向力、切向力与扭矩等标量输出。该方法会更新
`sensor`的形变场与力相关状态,并返回当前帧的光流场。 `sensor`的形变场与力相关状态,并返回当前帧的光流场。
调用前置条件:需要先通过`get_img()`成功更新图像。 调用前置条件:需要先通过`get_img()`成功更新图像。
### 输入参数 #### 输入参数
* **check_motion**(`bool`,可选): 为 `True` 时先根据光流判断是否有运动,有运动才做 HHD 分解;为 `False` 时每帧都做 HHD。默认 `True`。 * **decompose** (`bool`,可选): 是否在计算光流后继续执行 HHD 分解,默认 `True`。
* **threshold**(`float`,可选): **check_motion** 功能的阈值,默认为`6`。 * **check_motion** (`bool`,可选): 是否启用运动检测门控。当前不推荐依赖该功能;新代码建议显式传入 `False`,让每帧都执行 HHD 分解。为兼容旧代码,函数默认值仍为 `True`。
* **tail_frames** (`int`,可选): 光流变化低于阈值后继续解算多少帧,确保传感器回复,,默认为 `10`.
### 返回 #### 兼容参数(暂不推荐)
* `bool`:是否接触到物体,如果**check_motion**为`False`时返回`None`。 以下参数只在 `check_motion=True` 时生效。由于运动检测门控目前不稳定,建议在新代码中暂时不要使用:
### 示例 * **threshold** (`float`,可选): 运动检测阈值,默认 `6`。
* **tail_frames** (`int`,可选): 运动低于阈值后继续解算多少帧,默认 `10`。
#### 返回
* `None`: 当 `check_motion=False` 时返回。推荐用法下不要依赖该返回值判断接触状态。
* `bool`: 仅当 `check_motion=True` 时返回运动检测结果;该模式目前暂不推荐。
#### 示例
```python ```python
sensor.get_img() sensor.get_img()
sensor.compute_deformation(check_motion=False)
is_touched = sensor.compute_deformation(check_motion=True, threshold=6) sensor.compute_contact()
# 步骤 2.3: 计算触碰点
if is_touched:
sensor.compute_contact()
``` ```
*** ### `Sensor.compute_contact` 方法
## 4. `Sensor.compute_contact` 方法
### 描述 #### 描述
基于已计算的法向形变场进行接触区域分析,输出接触质心与轮廓等信息,并生成接触深度图,默认配置在`json`文件中,可按需调节。 基于已计算的法向形变场进行接触区域分析,输出接触质心与轮廓等信息,并生成接触深度图,默认配置在`json`文件中,可按需调节。
调用前置条件:需先执行`compute_deformation()`。 调用前置条件:需先执行`compute_deformation()`。
### 返回 #### 返回
`dict`为接触分析结果字典,至少包含以下键: `dict`为接触分析结果字典,至少包含以下键:
* **expansion_mask**: 扩张区域掩码(`np.ndarray`)。 * **expansion_mask**: 扩张区域掩码(`np.ndarray`)。
@@ -157,12 +100,12 @@ if is_touched:
* **centroid_found**: 是否检测到接触区域(`bool`)。 * **centroid_found**: 是否检测到接触区域(`bool`)。
* **contour**: 最大轮廓或轮廓列表(可选字段,存在时返回)。 * **contour**: 最大轮廓或轮廓列表(可选字段,存在时返回)。
### 状态更新 #### 状态更新
* **sensor.centroid**: 接触质心(单点为 `(x, y)`,多点为列表)。 * **sensor.centroid**: 接触质心(单点为 `(x, y)`,多点为列表)。
* **sensor.centroid_found**: 是否找到质心(`bool`)。 * **sensor.centroid_found**: 是否找到质心(`bool`)。
### 示例 #### 示例
```python ```python
sensor.compute_contact() sensor.compute_contact()
centroid, contour, depth = sensor.read_info( centroid, contour, depth = sensor.read_info(
@@ -171,19 +114,18 @@ centroid, contour, depth = sensor.read_info(
sensor.info.DEPTH, sensor.info.DEPTH,
) )
``` ```
*** ### `Sensor.read_info` 方法
## 4. `Sensor.read_info` 方法
### 描述 #### 描述
按指定的信息类型(`sensor.info`枚举值)批量读取传感器状态数据,并按传入顺序返回对应值的元组。 按指定的信息类型(`sensor.info`枚举值)批量读取传感器状态数据,并按传入顺序返回对应值的元组。
该方法通常用于在完成形变计算后,读取光流场、分量场、力、帧率等信息。 该方法通常用于在完成形变计算后,读取光流场、分量场、力、帧率等信息。
### 输入参数 #### 输入参数
* **args**: 任意数量的 `Sensor.info` 枚举,用于指定需要获取的数据类型。支持的枚举值及对应数据如下: * **args**: 任意数量的 `Sensor.info` 枚举,用于指定需要获取的数据类型。支持的枚举值及对应数据如下:
### 支持的枚 info keys: #### 支持的 info keys
向量/图像类 向量/图像类
@@ -197,6 +139,9 @@ centroid, contour, depth = sensor.read_info(
* FNORMAL: 法向力,`float`。 * FNORMAL: 法向力,`float`。
* FSHEARX: X切向力,`float`。 * FSHEARX: X切向力,`float`。
* FSHEARY: Y切向力,`float`。 * FSHEARY: Y切向力,`float`。
* FNORMAL_RAW: 未经过低通滤波的原始法向力,`float`。
* FSHEARX_RAW: 未经过低通滤波的原始 X 切向力,`float`。
* FSHEARY_RAW: 未经过低通滤波的原始 Y 切向力,`float`。
* FPS: 输出帧率,`float`。 * FPS: 输出帧率,`float`。
* CENTROID: 触碰点的坐标,(x, y)。 * CENTROID: 触碰点的坐标,(x, y)。
* CONTOUR: 触碰区域轮廓,`np.ndarray`, shape=(H, W), 未生成时返回`np.empty` * CONTOUR: 触碰区域轮廓,`np.ndarray`, shape=(H, W), 未生成时返回`np.empty`
@@ -207,7 +152,7 @@ centroid, contour, depth = sensor.read_info(
注:H/W 由当前`json`文件中的`resolution_stitch`配置分辨率决定。 注:H/W 由当前`json`文件中的`resolution_stitch`配置分辨率决定。
### 返回 #### 返回
`Tuple` `Tuple`
@@ -219,7 +164,7 @@ centroid, contour, depth = sensor.read_info(
- 标量类数据:`float` - 标量类数据:`float`
- 时间戳:`str`(`TIMESTAMP`,格式为 `HH:MM:SS`) - 时间戳:`str`(`TIMESTAMP`,格式为 `HH:MM:SS`)
### 示例 #### 示例
```python ```python
# Example 1:读取标量数据 # Example 1:读取标量数据
fps, fn, fx, fy = sensor.read_info( fps, fn, fx, fy = sensor.read_info(
@@ -232,23 +177,352 @@ fps, fn, fx, fy = sensor.read_info(
# Example 2: 读取单个向量场(注意解包方式) # Example 2: 读取单个向量场(注意解包方式)
flow = sensor.read_info(sensor.info.VRAW) flow = sensor.read_info(sensor.info.VRAW)
``` ```
*** ### `Sensor.disconnect` 方法
## 5. `Sensor.disconnect` 方法 #### 描述
### 描述
释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 OpenCV 窗口。 释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 OpenCV 窗口。
建议在程序退出前调用以确保资源被正确释放。 建议在程序退出前调用以确保资源被正确释放。
*** ### `Sensor.configure` 低通滤波设置
***
# 可视化相关
## 1. `util.draw_arrows` 方法 #### 描述
### 输入参数 运行时更新传感器设置,无需重新连接相机。常用于开启力信号低通滤波,降低 `FNORMAL`、`FSHEARX`、`FSHEARY` 的高频噪声。
默认采样率按 30 fps 计算,推荐低通截止频率为 5 Hz:
```python
sensor.configure(lowpass_cutoff_hz=5.0, lowpass_sample_rate_hz=30.0)
```
注意:`lowpass_cutoff_hz` 必须小于 Nyquist 频率,即 `lowpass_sample_rate_hz / 2`。例如 30 fps 时,截止频率必须小于 15 Hz。
#### 输入参数
* **lowpass_cutoff_hz** (`float`,可选): 力信号低通截止频率,单位 Hz。`0` 表示关闭滤波;正数默认开启滤波。
* **lowpass_sample_rate_hz** (`float`,可选): 力信号采样率,默认 `30.0`。
* **lowpass_order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
* **lowpass_enabled** (`bool`,可选): 显式开启或关闭低通滤波。
* **hhd_downsample** (`int`,可选): HHD 解算下采样倍数,需为 `>= 1` 的整数,配置默认 `3`。
#### 读取结果
开启低通滤波后,`read_info()` 读取的力输出会被滤波:
```python
fn, fx, fy = sensor.read_info(
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
)
```
原始力输出仍可通过 `FNORMAL_RAW`、`FSHEARX_RAW`、`FSHEARY_RAW` 读取,便于调试或对比。
## Signal Processing API
### `orisys.signal_processing.lowpass_filter` 函数
#### 描述
对一维、等间隔采样数据执行零相位 Butterworth 低通滤波,适合示例程序或图表中对历史数据做平滑处理。
默认参数为 `cutoff_hz=5.0`、`sample_rate_hz=30.0`、`order=2`。这表示在 30 fps 数据上使用 5 Hz 低通滤波。
#### 输入参数
* **data**: 一维数据序列。
* **cutoff_hz** (`float`,可选): 截止频率,默认 `5.0`。传入 `0` 时不做滤波并返回数据副本。
* **sample_rate_hz** (`float`,可选): 采样率,默认 `30.0`。
* **order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
#### 返回
* `np.ndarray`: 滤波后的 `float64` 数组。
#### 示例
```python
from orisys.signal_processing import lowpass_filter
filtered_force = lowpass_filter(force_values, cutoff_hz=5.0, sample_rate_hz=30.0)
```
### `orisys.signal_processing.LowpassFilter` 类别
#### 描述
有状态的因果 Butterworth 低通滤波器,适合逐帧处理实时数据。它会保留上一帧的滤波状态,并通过 `process()` 一次处理一个多通道样本。
`Sensor.configure()` 内部使用该类来滤波力信号。构造时默认 `cutoff_hz=0.0`,表示关闭滤波;若要按 30 fps 使用推荐滤波,设置 `cutoff_hz=5.0`、`sample_rate_hz=30.0`。
#### 输入参数
* **cutoff_hz** (`float`,可选): 截止频率,默认 `0.0`,表示关闭滤波。
* **sample_rate_hz** (`float`,可选): 采样率,默认 `30.0`。
* **order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
* **n_channels** (`int`,可选): 每个样本的通道数,默认 `3`。
#### 常用方法
* **configure(...)**: 运行时更新 `cutoff_hz`、`sample_rate_hz` 或 `order`。
* **reset(x0=None)**: 清除滤波器状态;传入 `x0` 时会用该样本初始化稳态。
* **process(values)**: 滤波一个多通道样本,返回 shape 为 `(n_channels,)` 的 `np.ndarray`。
#### 示例
```python
from orisys.signal_processing import LowpassFilter
filt = LowpassFilter(cutoff_hz=5.0, sample_rate_hz=30.0, n_channels=3)
filtered = filt.process([fnormal, fshearx, fsheary])
```
## Finger Pipeline API
### `orisys.finger` 手指 UV pipeline
`examples/finger_pipeline.py` 展示了一个更适合手指/单相机场景的高层 API:
1. 使用 `FingerRuntime.open()` 打开视频源、加载 Blender 导出的资产,并构建 UV 展开映射。
2. 用 `grab_and_process()` 或 `read_source() + process_frame()` 获取一帧展开后的触觉图像。
3. 通过 `runtime.tactile`(`TactileProcessor`)调用 `compute_deformation()`、`compute_contact()`、`read_info()`。
4. 退出前调用 `runtime.close()` 释放视频源和内部 `Sensor` 资源。
相比直接使用 `Sensor`,`FingerRuntime` 已封装了视频读取、相机尺寸设置、UV 展开和有效区域掩码传递,更适合作为手指 2D/3D 示例与上层应用的入口。
### `FingerRuntime.open` 方法
#### 描述
创建一个可复用的手指 UV runtime。该方法会完成以下初始化:
- 解析 `export_dir` 下的 `mesh.npz`、`camera_intrinsics.npz`、`world_to_cam.npy`
- 根据配置和标定信息构建/加载 `unroll_map_uv.npz`
- 打开相机或视频文件
- 创建内部 `Sensor("external", ...)`
- 创建 `FrameUnwarper` 与 `TactileProcessor`
#### 输入参数
* **export_dir** (`str`,可选): 手指导出资产目录,默认值来自 `orisys.finger.DEFAULT_FINGER_EXPORT_DIR`。目录下通常需要包含 `mesh.npz`、`camera_intrinsics.npz`、`world_to_cam.npy`。
* **config** (`str`,可选): Orisys 配置文件名称或路径,默认 `finger`。
* **map_cache** (`str`,可选): UV 展开缓存文件路径,默认值来自 `orisys.finger.DEFAULT_FINGER_MAP_CACHE`。
* **source_scale** (`float`,可选): 输入相机分辨率缩放比例;未提供时根据配置自动推断。
* **calib** (`str | None`,可选): 外部相机标定文件路径;未提供时优先使用导出资产中的内参。
* **video** (`int | str`,可选): 相机编号、数字字符串或视频路径,默认 `0`。
* **camera_id** (`int | None`,可选): `video` 的别名;若提供则优先使用。
* **flip_source_vertical** (`bool`,可选): 是否在 UV 展开前垂直翻转输入源,默认 `True`。
* **verbose** (`bool`,可选): 是否输出初始化日志。
* **mirror** (`bool`,可选): 是否对内部触觉图再做镜像,默认 `False`。
#### 返回
* `FingerRuntime` 实例。
#### 示例
```python
from orisys.finger import FingerRuntime
runtime = FingerRuntime.open(
config="finger",
video=0,
verbose=True,
)
```
### `FingerRuntime` 常用属性
* **runtime.geometry**: `FingerGeometry`,包含输入/输出分辨率、导出资产路径、相机内参、缓存路径等一次性初始化结果。
* **runtime.domain**: UV 展开域对象,内部包含 remap、valid mask 以及可选 surface 数据。
* **runtime.tactile**: `TactileProcessor`,用于执行形变、接触和数据读取。
* **runtime.capture**: OpenCV `VideoCapture` 对象。
* **runtime.valid_mask**: 当前 UV 域有效像素掩码。
### `FingerRuntime.read_source` 方法
#### 描述
仅读取一帧原始视频源,等价于对内部 `cv2.VideoCapture` 调用 `read()`。适合需要把采集和 UV 展开分别计时的场景。
#### 返回
* `(ok, frame)`:
* **ok** (`bool`): 是否成功读取。
* **frame** (`np.ndarray | None`): 原始 BGR 帧。
### `FingerRuntime.process_frame` 方法
#### 描述
对一帧原始输入执行源图预处理、UV 展开,并把展开后的结果送入内部 `TactileProcessor`。这是 `examples/finger_pipeline.py` 中 profile 模式使用的方法。
#### 输入参数
* **frame** (`np.ndarray`,必填): 原始输入帧。
* **color** (`bool`,可选): 为 `False` 时走灰度展开流程;为 `True` 时保留 BGR 颜色展开。默认 `False`。
#### 返回
* `np.ndarray`: 展开后的触觉图像。`color=False` 时通常为单通道灰度图;`color=True` 时为 BGR 图。
### `FingerRuntime.grab_and_process` 方法
#### 描述
先读取一帧视频源,再调用 `process_frame()` 完成 UV 展开并推入内部触觉处理器。非 profile 模式下,`examples/finger_pipeline.py` 使用该方法作为主循环入口。
#### 输入参数
* **color** (`bool`,可选): 是否保留颜色,默认 `False`。
#### 返回
* `(ok, unrolled)`:
* **ok** (`bool`): 是否成功读取并处理。
* **unrolled** (`np.ndarray | None`): 展开后的触觉图像。
### `FingerRuntime.get_img` 方法
#### 描述
`Sensor.get_img()` 风格的别名:内部执行一次采集和 UV 展开,并在成功时返回当前展开图,否则返回 `None`。
#### 输入参数
* **color** (`bool`,可选): 是否保留颜色,默认 `False`。
#### 返回
* `np.ndarray | None`
### `FingerRuntime.warmup` 方法
#### 描述
先执行一次采集与 UV 展开,然后调用 `runtime.tactile.warmup()` 预热内部光流/HHD 相关缓存。`examples/finger_pipeline.py` 在进入主循环前会先执行一次 warmup。
#### 输入参数
* **color** (`bool`,可选): 是否保留颜色,默认 `False`。
#### 返回
* `(ok, unrolled)`:
* **ok** (`bool`): 是否成功完成 warmup 前的采集。
* **unrolled** (`np.ndarray | None`): warmup 时得到的展开图。
### `FingerRuntime.close` 方法
#### 描述
释放内部 `VideoCapture` 并调用 `runtime.tactile.disconnect()` 清理 `Sensor` 资源。程序退出前应调用。
### `TactileProcessor` 类别
#### 描述
`TactileProcessor` 是对内部 `Sensor("external", ...)` 的窄封装。它保留了 `compute_deformation()`、`compute_contact()`、`read_info()`、`reset()` 等常用接口,同时屏蔽了视频采集与 UV 展开细节。
#### 常用属性
* **raw_sensor**: 底层 `Sensor` 对象。
* **info**: 与 `Sensor.info` 相同的枚举入口。
* **img / flow / vnormal / vshear / depth_map**: 当前帧处理结果。
* **mask_stats**: 有效 UV 区域统计信息;`examples/finger_pipeline.py` 会读取 `mask_stats["coverage"]` 输出当前掩码覆盖率。
* **valid_mask**: 当前有效区域掩码。
#### 说明
除 `push_frame()` / `warmup()` 之外,`TactileProcessor` 的 `compute_deformation()`、`compute_contact()`、`read_info()`、`reset()` 调用方式与 `Sensor` 基本一致,因此可直接复用前文 `Sensor` 章节中的参数说明。
### 最小手指 pipeline 示例
下面的流程与 `examples/finger_pipeline.py` 一致:
```python
import time
import cv2
import orisys
from orisys.finger import FingerRuntime
runtime = FingerRuntime.open(
config="finger",
video=0,
verbose=True,
)
runtime.warmup(color=False)
tactile = runtime.tactile
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,
)
arrows = orisys.util.draw_arrows(
unrolled,
flow,
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
)
cv2.imshow("UV Unrolled", arrows)
print(
f"FPS={fps:.2f} normal={fnormal:.2f} "
f"shear=({fshearx:.2f}, {fsheary:.2f}) "
f"mask={tactile.mask_stats['coverage']:.2f}",
end="\r",
)
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
break
if key == ord("r"):
tactile.reset()
finally:
runtime.close()
cv2.destroyAllWindows()
```
### 关于 profile 模式
`examples/finger_pipeline.py` 中的 `--profile` / `--profile-every` 是示例脚本参数,不属于 `orisys` SDK 核心 API。本质上它只是把主循环拆成:
1. `runtime.read_source()`
2. `runtime.process_frame(...)`
3. `tactile.compute_deformation(...)`
4. `tactile.compute_contact()`
5. `tactile.read_info(...)`
并对各阶段做耗时统计。因此在业务代码中,也可以按同样方式自行插入计时逻辑。
## 可视化相关
### `util.draw_arrows` 方法
#### 输入参数
* **frame**:背景图像 * **frame**:背景图像
* **optical_flow**:光流场 * **optical_flow**:光流场
@@ -260,12 +534,12 @@ flow = sensor.read_info(sensor.info.VRAW)
* **below_threshold_color**:RGB格式的`uint8`颜色,如(0, 0, 255) * **below_threshold_color**:RGB格式的`uint8`颜色,如(0, 0, 255)
* **edge_margin**: 不显示箭头边距(pixel) * **edge_margin**: 不显示箭头边距(pixel)
### 返回 #### 返回
`np.ndarray` :箭头光流可视化图像 `np.ndarray` :箭头光流可视化图像
## 2. `util.draw_contact` 方法 ### `util.draw_contact` 方法
### 输入参数 #### 输入参数
* **image**:背景图像 * **image**:背景图像
* **contour**:轮廓 * **contour**:轮廓
@@ -273,42 +547,36 @@ flow = sensor.read_info(sensor.info.VRAW)
* **point_color**:触碰点的颜色 (BGR) * **point_color**:触碰点的颜色 (BGR)
* **region_color**:轮廓的颜色 (BGR) * **region_color**:轮廓的颜色 (BGR)
### 返回 #### 返回
`np.ndarray` :箭头光流可视化图像 `np.ndarray` :箭头光流可视化图像
*** ## 配置标定相关
# 配置标定相关 ### `list_config` 方法
## 1. `list_config` 方法 #### 描述
### 描述
列出内置传感器配置参数字典。 列出内置传感器配置参数字典。
### 返回 #### 返回
* `list`: 内置传感器配置参数字典的名字列表。 * `list`: 内置传感器配置参数字典的名字列表。
*** ### `export_config` 方法
## 2. `export_config` 方法
### 描述 #### 描述
导出当前传感器实例使用的配置参数字典。 导出当前传感器实例使用的配置参数字典。
### 输入参数 #### 输入参数
* **config_name**: 需要导出的配置参数字典名字,`str` * **config_name**: 需要导出的配置参数字典名字,`str`
* **path**: 导出的路径,默认`"./config/exported_config.json"` * **path**: 导出的路径,默认`"./config/exported_config.json"`
### 返回 #### 返回
* `dict`: 导出的配置参数字典。 * `dict`: 导出的配置参数字典。
### 保存 #### 保存
* 导出内置的`config_name`配置参数字典至`path` * 导出内置的`config_name`配置参数字典至`path`
***
***
## 常见问题解答 (FAQ) ## 常见问题解答 (FAQ)
### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`? ### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`?
A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。 A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。
@@ -328,9 +596,5 @@ A:当需要接触相关信息(如接触质心、轮廓或深度/散度图)
A:`read_info()`按传入的`sensor.info`参数顺序返回一个元组。 A:`read_info()`按传入的`sensor.info`参数顺序返回一个元组。
返回值顺序与参数顺序一一对应,与内部存储顺序无关。 返回值顺序与参数顺序一一对应,与内部存储顺序无关。
### Q5:如果没有标定文件,或拼接结果不正确,会发生什么? ### Q5: 运行报错:“AttributeError: 'Stitcher' object has no attribute 'pos_default'”
A:若未指定或未找到标定文件,系统会在需要时创建新的拼接标定数据并保存到默认路径。
拼接结果异常通常与标定数据、拼接配置或输入图像质量有关,建议重新生成拼接标定并检查配置参数。
### Q6: 运行报错:“AttributeError: 'Stitcher' object has no attribute 'pos_default'”
A: 请检查视频源的编号是否正确,以及是否能正常打开视频源。在linux 使用`v4l2-ctl --list-devices` 或 Windows使用`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }` 列出摄像头设备。 A: 请检查视频源的编号是否正确,以及是否能正常打开视频源。在linux 使用`v4l2-ctl --list-devices` 或 Windows使用`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }` 列出摄像头设备。
+151 -63
View File
@@ -1,80 +1,106 @@
叠动科技软件使用手册 ---
版本: 0.4.0 title: 快速开始
更新日期: 2026年4月16日 description: 安装并运行 Orisys SDK 的基础步骤
---
# 简介 # 快速开始
版本:0.5.0
## 简介
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。 本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
支持操作系统:Windows、Ubuntu 支持操作系统:Windows、Ubuntu
建议Python 版本: 3.10 建议Python 版本: 3.10, 3.11, 3.12
# 基础环境设置 项目链接:
## 安装 Python 与 Anaconda
1. 访问 [Anaconda 官网](https://www.anaconda.com/download)下载适合您操作系统的安装包(Windows、Linux)。
2. 按照提示完成安装。(Widnows:建议在安装过程中勾选“将 Anaconda 添加到系统环境变量”选项,方便后续使用)。
3. 安装完成后,打开命令行终端(Windows 用户可在”Anaconda Navigator”打开“Anaconda Prompt”),输入 `conda info` ,若能显示 conda 的信息,则说明安装成功。
## 创建conda虚拟环境 - PyPI 安装页:[https://pypi.org/project/orisys/](https://pypi.org/project/orisys/)
- 项目文档/代码:[https://github.com/Orisys-Tech/orisys-sdk](https://github.com/Orisys-Tech/orisys-sdk)
## 基础环境设置
### 安装 Python 与 Anaconda
1. 访问 [Anaconda 官网](https://www.anaconda.com/download) 下载适合您操作系统的安装包(Windows、Linux)。
2. 按照提示完成安装。
3. 安装完成后,打开命令行终端(Windows 用户可在 “Anaconda Navigator” 打开 “Anaconda Prompt”),输入 `conda info`,若能显示 conda 的信息,则说明安装成功。
### 创建 conda 虚拟环境
虚拟环境相当于一个独立的 Python 工作空间,可以避免不同项目所需的库版本冲突。 虚拟环境相当于一个独立的 Python 工作空间,可以避免不同项目所需的库版本冲突。
打开命令行终端(或Anaconda Prompt),依次输入以下命令(以 python 3.10 为例): 打开命令行终端(或 Anaconda Prompt),依次输入以下命令(以 Python 3.10 为例):
``` bash ```bash
conda create -n orisys python=3.10 conda create -n orisys python=3.10
conda activate orisys conda activate orisys
``` ```
激活后,命令行提示符前会出现 `(orisys)` 字样,表示当前已进入虚拟环境。 激活后,命令行提示符前会出现 `(orisys)` 字样,表示当前已进入虚拟环境。
## 创建工作区文件夹 ### 创建工作区文件夹
“Anaconda Prompt”启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理,可以手动创建或者运行`mkdir <path>`命令:, 例如 “Anaconda Prompt” 启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理。可以手动创建文件夹,也可以运行 `mkdir <path>` 命令。例如:
``` bash ```bash
mkdir orisys_ws mkdir orisys_ws
``` ```
创建后如果要更换工作区,使用 `cd <path>`命令,例如: 创建后如果要更换工作区,使用 `cd <path>` 命令,例如:
``` bash ```bash
cd "C:\Users\orisys\orisys_ws" cd "C:\Users\orisys\orisys_ws"
``` ```
<div style="page-break-after: always;"></div> ### 安装 CUDA 和 cuDNN(可选)
## 安装 CUDA 和 cuDNN(可选)
如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。 如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。
1. 方法一(官网下载): 1. 方法一(官网下载):
访问 [NVIDIA CUDA Toolkit官网](https://developer.nvidia.com/cuda/toolkit)下载对应系统的 CUDA Toolkit 安装包,并按照提示安装, 注意12.X 和 13.X 的版本对应不同的 CuPy 库版本。 访问 [NVIDIA CUDA Toolkit 官网](https://developer.nvidia.com/cuda/toolkit) 下载对应系统的 CUDA Toolkit 安装包,并按照提示安装。注意 12.x 和 13.x 的版本对应不同的 CuPy 库版本。
2. 方法二(使用 Anaconda 安装,推荐): 2. 方法二(使用 Anaconda 安装,推荐):
在已激活的 orisys 环境中直接运行以下命令(以 CUDA 12.9 为例): 在已激活的 orisys 环境中直接运行以下命令(以 CUDA 12.9 为例):
`conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn`
```bash
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
```
该方法会自动配置好 CUDA 和 cuDNN,无需手动下载。 该方法会自动配置好 CUDA 和 cuDNN,无需手动下载。
注意:如果您的计算机没有 NVIDIA 显卡,或者不想使用 GPU 加速,可以跳过此步骤。套件也支持纯 CPU 运行(但速度可能较慢)。 注意:如果您的计算机没有 NVIDIA 显卡,或者不想使用 GPU 加速,可以跳过此步骤。套件也支持纯 CPU 运行(但速度可能较慢)。
# 安装 orisys SDK ## 安装 orisys SDK
orisys SDK 以 .whl 文件形式提供,您需要根据操作系统、Python 版本和硬件平台选择正确的文件。 推荐优先从 PyPI 安装。PyPI 会自动选择适合当前 Python 版本和操作系统的安装包,适合大多数用户。
## 下载正确的 .whl 文件 ### 使用 PyPI 安装(推荐)
确认已经激活 `orisys` 虚拟环境后,运行:
```bash
pip install orisys
```
如果需要升级到最新版本,运行:
```bash
pip install --upgrade orisys
```
如果当前网络无法访问 PyPI,或需要安装客服提供的指定版本,可以使用下方 `.whl` 文件安装方式。
### 使用 .whl 文件安装(离线或指定版本)
从官方渠道获取 .whl 文件,文件名格式通常为: 从官方渠道获取 .whl 文件,文件名格式通常为:
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl
例如:orisys-0.4.0-cp310-cp310-win_amd64.whl 表示: 例如:orisys-0.5.0-cp310-cp310-win_amd64.whl 表示:
版本:0.4.0 版本:0.5.0
Python 版本:3.10(cp310) Python 版本:3.10(cp310)
操作系统:Windows(win) 操作系统:Windows(win)
硬件架构:64位(amd64) 硬件架构:64位(amd64)
请根据您的系统选择对应的文件,或者咨询客服索取: 请根据您的系统选择对应的文件,或者咨询客服索取。
## 安装 .whl 文件 将下载好的 `.whl` 文件放在一个文件夹中(如上述 `orisys_ws` 文件夹),打开命令行(确保已激活 `orisys` 环境或使用 “Anaconda Prompt”),使用 `cd` 命令切换到该文件夹,然后运行:
将下载好的 .whl 文件放在一个文件夹中(如上述`orisys_ws`文件夹),打开命令行(确保已激活 orisys 环境或使用"Anaconda Prompt"),使用 `cd` 命令切换到该文件夹,然后运行:
``` bash ```bash
# 请将文件名替换为您实际下载的文件名 # 请将文件名替换为您实际下载的文件名
pip install orisys-0.4.0-cp310-cp310-win_amd64.whl pip install orisys-0.5.0-cp310-cp310-win_amd64.whl
``` ```
## 安装 CuPy(可选) ### 安装 CuPy(可选)
注意:如果您没有安装 CUDA,或者使用 CPU 运行,可以跳过这一步。 注意:如果您没有安装 CUDA,或者使用 CPU 运行,可以跳过这一步。
CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安装的 CUDA 版本选择对应的 CuPy 版本。 CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安装的 CUDA 版本选择对应的 CuPy 版本。
@@ -84,55 +110,117 @@ CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安
```bash ```bash
# 如果您的 CUDA 版本是 12.x # 如果您的 CUDA 版本是 12.x
pip install orisys[cuda12] pip install "orisys[cuda12]"
# 如果您的 CUDA 版本是 13.x # 如果您的 CUDA 版本是 13.x
pip install orisys[cuda13] pip install "orisys[cuda13]"
``` ```
如果之前已经安装了 orisys,可以单独安装 CuPy: 如果之前已经安装了 orisys,可以单独安装 CuPy:
方法二(手动安装): 方法二(手动安装):
``` bash ```bash
# CUDA 12.x # CUDA 12.x
pip install cupy-cuda12x>=12.3.0 pip install "cupy-cuda12x>=12.3.0"
# CUDA 13.x # CUDA 13.x
pip install cupy-cuda13x>=13.0.0 pip install "cupy-cuda13x>=13.0.0"
``` ```
## 测试安装 ### 测试安装
安装完成后,在命令行输入” python“命令进入 Python 环境, 并导入 orisys 库并查看版本号,验证是否成功: 安装完成后,在命令行输入 `python` 命令进入 Python 环境,并导入 `orisys` 库查看版本号,验证是否成功:
``` bash ```bash
python python
```
进入 Python 后继续输入:
```python
import orisys import orisys
print(orisys.__version__) print(orisys.__version__)
``` ```
如果输出版本号(例如 0.4.0),则说明安装成功。 如果输出版本号(例如 0.5.0),则说明安装成功。
# 使用方法 ## 使用方法
## 运行示例程序 ### 运行示例程序
套件里面包含了例子展示用法,运行步骤如下: 套件里面包含了多个例子。运行示例前,请先进入 SDK 示例所在的文件夹,建议在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。如果您是通过 PyPI 安装并且本地没有示例文件,可以从上方项目链接下载示例。按产品入口如下:
### Windows: 1. `examples/basic_pipeline.py`:ORY-BOX 复眼/拼接传感器基础 OpenCV 示例(默认配置 `box`)
1. 检查是否有其他正在连接的USB摄像头设备:打开powershell, 运行命令`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。 2. `examples/mini_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`)
2. 把传感器的USB头与电脑连接,再次运行步骤一的命令,会新增一行输出 3. `examples/finger_pipeline.py`:ORY-FINGER 2D 显示最小流程(默认配置 `finger`)
3. 如果第一步没有输出,传感器的ID就是“0”,如果第一步有一个设备,传感器的ID就是“1”,如此类推。 4. `examples/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `finger`)
4. 在确定了传感器的ID(下方例子假设为“0”)后,打开Anaconda Prompt, 切换到工作路径,然后在命令行中输入:
``` bash `finger` 是当前 ORY-FINGER 的产品配置名。
python examples/basic_pipeline.py -v 0
下面先介绍最基础的 ORY-BOX 运行方式。
#### Windows
1. 检查是否有其他正在连接的 USB 摄像头设备:打开 PowerShell,运行命令 `Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。
2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增一行输出。
3. 如果第一步没有输出,传感器的 ID 就是 `0`;如果第一步有一个设备,传感器的 ID 就是 `1`,如此类推。
4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开 Anaconda Prompt,切换到工作路径,然后在命令行中输入:
```bash
python examples/basic_pipeline.py -v 0 -c box
``` ```
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据 5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
### Ubuntu: #### Ubuntu
1. 检查是否有其他正在连接的USB摄像头设备:打开终端, 运行命令`v4l2 –list-devices`,如果没有的话不会有输出。 1. 检查是否有其他正在连接的 USB 摄像头设备:打开终端,运行命令 `v4l2 --list-devices`,如果没有的话不会有输出。
2. 把传感器的USB头与电脑连接,再次运行步骤一的命令,会新增输出 2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增输出。
3. 根据新增的输出,例如”/dev/video0”,确定传感器的ID为“0”, 如果是”/dev/video3”的话ID为“3” ,如此类推。 3. 根据新增的输出,例如 `/dev/video0`,确定传感器的 ID 为 `0`;如果是 `/dev/video3`,ID 为 `3`,如此类推。
4. 在确定了传感器的ID(下方例子假设为“0”)后,打开终端, 激活相应的python环境,切换到工作路径,然后在命令行中输入: 4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开终端,激活相应的 Python 环境,切换到工作路径,然后在命令行中输入:
``` bash ```bash
python examples/basic_pipeline.py -v 0 python examples/basic_pipeline.py -v 0 -c box
``` ```
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据 5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
## 开发套件使用方法 #### ORY-FINGER 示例
如果您使用的是 ORY-FINGER(配置名 `finger`),可运行:
```bash
python examples/finger_pipeline.py --camera_id 0 --config finger
```
如果输入来自视频文件,也可以改用:
```bash
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
```
该示例会:
- 读取相机或视频源
- 将图像展开到平面
- 计算光流、法向/切向力和接触区域
- 在 OpenCV 窗口中显示展开后的光流箭头图
常用参数:
- `--verbose`:输出更详细的初始化日志
例如:
```bash
python examples/finger_pipeline.py --camera_id 0 --config finger
```
#### ORY-FINGER 3D 可视化界面
如果要使用 GUI,先安装 GUI 依赖:
```bash
pip install "orisys[gui]"
```
然后运行:
```bash
python examples/finger_pipeline_3dview.py --video 0 --config finger
```
该界面会提供:
- 手指平面化光流箭头显示
- 力曲线
- 3D 视图
### 开发套件使用方法
参考开发套件附带的开发文档。 参考开发套件附带的开发文档。
+48 -25
View File
@@ -1,43 +1,62 @@
# Examples # Examples
Top-level demo scripts under `examples/`. Run them from the repository root. Runnable demos live at the `examples/` root. Shared helpers stay in
`pipeline/`, `viz/`, and `gui/`. Run commands from the repository root.
## Top-Level Files ## Product Entry Points
- `README.md` — this overview of the root-level example entry points | Product | Entry | Default config |
- `app_paths.py` — path helpers used by the packaged / frozen dashboard entry point |---------|-------|----------------|
- `basic_pipeline.py` — basic single-sensor OpenCV demo for the compound-eye pipeline | ORY-BOX | `basic_pipeline.py` | `box` |
- `plot_data.py` — single-sensor demo with OpenCV views plus PyQtGraph force charts | ORY-MINI | `mini_pipeline.py` | `mini` |
- `two_sensor_pipeline.py` — two-sensor multiprocessing demo for parallel acquisition / display | ORY-FINGER (2D) | `finger_pipeline.py` | `finger` |
- `finger_pipeline.py` — minimal finger UV-unroll pipeline with optional profiling | ORY-FINGER (3D Qt) | `finger_pipeline_3dview.py` | `finger` |
- `finger_pipeline_3dview.py` — main finger dashboard entry point with the unified Qt UI
`finger` is the public ORY-FINGER config stem. `dd02` remains a compatibility alias for `dd02-ov`.
## Typical Usage ## Typical Usage
For the Qt dashboard: ORY-BOX:
```bash
python examples/basic_pipeline.py -v 0 -c box
```
ORY-MINI:
```bash
python examples/mini_pipeline.py -v 0 -c mini
```
ORY-FINGER 2D:
```bash
python examples/finger_pipeline.py --camera_id 0 --config finger
```
ORY-FINGER video file:
```bash
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
```
ORY-FINGER 3D Qt dashboard:
```bash ```bash
pip install -e ".[gui]" pip install -e ".[gui]"
python examples/finger_pipeline_3dview.py -v 0 -c ./config/dd02-ov.json python examples/finger_pipeline_3dview.py --video 0 --config finger
``` ```
For the minimal finger pipeline: ## Other Root Examples
```bash - `plot_data.py` — single-sensor OpenCV views plus PyQtGraph force charts
python examples/finger_pipeline.py --camera_id 0 --config ./config/dd02-ov.json - `two_sensor_pipeline.py` — two-sensor multiprocessing demo
```
For the basic compound-eye pipeline:
```bash
python examples/basic_pipeline.py -v 0 -c ./config/ddjx01.json
```
## Requirements ## Requirements
- SDK installed from the repository root, for example `pip install -e .` - SDK installed from the repository root, for example `pip install -e .`
- camera index or test video path - camera index or test video path
- matching config files under `./config/` - matching config names under `./config/` (or packaged `orisys/configs/`)
Qt / PyQtGraph examples additionally require: Qt / PyQtGraph examples additionally require:
@@ -47,6 +66,10 @@ pip install -e ".[gui]"
## Notes ## Notes
- `finger_pipeline_3dview.py` is the main interactive demo. It uses `app_paths.py` so the same entry point works in editable and packaged runs. - `finger_pipeline_3dview.py` is the main interactive finger demo.
- Dashboard controls: click **启动** to start acquisition; `Q` quits, `R` resets, `S` starts recording, `E` stops recording, and `Esc` toggles fullscreen. It uses `gui/app_paths.py` so the same entry works in editable and packaged runs.
- `finger_pipeline.py` is the leanest finger example if you want to validate UV unroll + force/contact processing without the full dashboard. - 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.
- `finger_pipeline.py` is the leanest finger example if you want to
validate UV unroll + force/contact processing without the full dashboard.
+37 -19
View File
@@ -1,5 +1,5 @@
""" """
示例 1:单传感器基础流程 ORY-BOX:单传感器基础流程
本示例演示 Orisys SDK 的标准处理流程: 本示例演示 Orisys SDK 的标准处理流程:
1. 初始化传感器(摄像头或视频文件) 1. 初始化传感器(摄像头或视频文件)
@@ -7,7 +7,7 @@
3. 按需计算接触区域 3. 按需计算接触区域
4. 读取并可视化结果 4. 读取并可视化结果
SDK version: 0.3.1 SDK version: 0.5.0
""" """
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
@@ -19,7 +19,7 @@ import argparse
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径") parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
parser.add_argument("--config","-c", type=str, default="./config/ddjx01.json", help="配置文件名称或路径") parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径")
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志") parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
args = parser.parse_args() args = parser.parse_args()
@@ -28,12 +28,9 @@ def main():
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件 # cal_path 为标定文件路径;每台设备建议使用独立的标定文件
# 输入既可以是摄像头编号,也可以是视频文件路径 # 输入既可以是摄像头编号,也可以是视频文件路径
try: if args.video.isdigit():
input = int(args.video) sensor = orisys.Sensor(int(args.video), config_name=args.config, verbose=args.verbose)
is_camera = True else:
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) sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。") print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
@@ -43,14 +40,29 @@ def main():
# 步骤 2.1:获取并拼接图像(必需) # 步骤 2.1:获取并拼接图像(必需)
sensor.get_img() sensor.get_img()
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动 # 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0) is_contact_now = sensor.compute_deformation()
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域 # 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
# if is_contact_now: # if is_contact_now:
sensor.compute_contact() sensor.compute_contact()
# 步骤 2.4:读取结果 # 步骤 2.4:读取结果(FNORMAL/FSHEAR* 在有 sensors/box/force_calibration.json 时为标定后牛顿力;
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = sensor.read_info( # *_RAW 始终为原始积分量)
(
fps,
flow,
vnormal,
img,
contour,
centroid,
depth_map,
fnormal,
fshearx,
fsheary,
fnormal_raw,
fshearx_raw,
fsheary_raw,
) = sensor.read_info(
sensor.info.FPS, sensor.info.FPS,
sensor.info.VRAW, sensor.info.VRAW,
sensor.info.VNORMAL, sensor.info.VNORMAL,
@@ -60,7 +72,10 @@ def main():
sensor.info.DEPTH, sensor.info.DEPTH,
sensor.info.FNORMAL, sensor.info.FNORMAL,
sensor.info.FSHEARX, sensor.info.FSHEARX,
sensor.info.FSHEARY sensor.info.FSHEARY,
sensor.info.FNORMAL_RAW,
sensor.info.FSHEARX_RAW,
sensor.info.FSHEARY_RAW,
) )
# 步骤 3:可视化显示 # 步骤 3:可视化显示
@@ -72,11 +87,11 @@ def main():
grid_spacing=20, grid_spacing=20,
arrow_scale=1.0 arrow_scale=1.0
) )
cv2.imshow("形变矢量场", arrows) cv2.imshow("arrow", arrows)
# 步骤 3.2:显示接触区域和质心 # 步骤 3.2:显示接触区域和质心
image_with_foe = orisys.util.draw_contact(img, contour, centroid) image_with_foe = orisys.util.draw_contact(img, contour, centroid)
cv2.imshow("接触区域与质心", image_with_foe) cv2.imshow("contact", image_with_foe)
# 步骤 3.3:显示深度图 # 步骤 3.3:显示深度图
# 将深度值归一化到 0-255 范围 # 将深度值归一化到 0-255 范围
@@ -87,12 +102,15 @@ def main():
div_normalized = np.zeros_like(div_abs, dtype=np.uint8) div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
# 应用伪彩色映射 # 应用伪彩色映射
cv2.imshow("深度图", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET)) cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
# 步骤 3.4:打印关键结果 # 步骤 3.4:打印关键结果(标定力 + 原始力)
print( print(
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, " f"FPS={fps:.2f}, "
f"切向力Y={fsheary:.4f}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}" f"法向力={fnormal:.4f} N (raw={fnormal_raw:.4f}), "
f"切向力X={fshearx:.4f} N (raw={fshearx_raw:.4f}), "
f"切向力Y={fsheary:.4f} N (raw={fsheary_raw:.4f}), "
f"深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
) )
# 步骤 3.5:键盘控制 # 步骤 3.5:键盘控制
+13 -81
View File
@@ -1,18 +1,19 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Minimum UV-unroll finger tactile pipeline. ORY-FINGER:Minimum UV-unroll finger tactile pipeline.
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
Runtime: camera frame -> UVUnrollFrameProcessor -> TactileProcessor -> forces/contact Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
For the full demo (UV debug panels, dashboard, benchmarks) see: Recommended entry:
tools/3d_patch_generation_blender/unroll_realtime.py python examples/finger_pipeline.py --camera_id 0 --config finger
Current product config: ``finger`` (``dd02`` still resolves to ``dd02-ov``).
""" """
import argparse import argparse
import os import os
import sys import sys
import time
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
@@ -20,38 +21,14 @@ import cv2
import orisys import orisys
from orisys.finger import FingerRuntime from orisys.finger import FingerRuntime
from pipeline.profiling import StageProfiler, run_deformation_timed
_FINGER_PIPELINE_STAGES = (
"capture",
"uv_unroll",
"flow",
"hhd",
"contact",
"read_info",
"display",
"total",
)
def main(): def main():
parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline") parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline")
parser.add_argument("--camera_id", type=int, default=0) parser.add_argument("--camera_id", type=int, default=0)
parser.add_argument("--video", help="Video file path (overrides camera)") parser.add_argument("--video","-v", help="Video file path (overrides camera)")
parser.add_argument("--config", "-c", default="./config/dd02-ov.json", help="Orisys SDK config") 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("--show", action="store_true", default=True, help="Show unrolled image window")
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs") parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
parser.add_argument(
"--profile",
action="store_true",
help="Print per-stage timing (capture, UV, flow, HHD, contact, read_info, display)",
)
parser.add_argument(
"--profile-every",
type=int,
default=30,
help="When --profile is set, print rolling averages every N frames (default: 30)",
)
args = parser.parse_args() args = parser.parse_args()
try: try:
@@ -77,48 +54,17 @@ def main():
runtime.warmup(color=False) runtime.warmup(color=False)
tactile = runtime.tactile tactile = runtime.tactile
profiler = (
StageProfiler(report_every=args.profile_every, stage_order=_FINGER_PIPELINE_STAGES)
if args.profile
else None
)
print("Press 'q' to quit, 'r' to reset tracker.") print("Press 'q' to quit, 'r' to reset tracker.")
if args.profile:
print(f"Profiling enabled (report every {args.profile_every} frames).")
try: try:
while True: while True:
frame_t0 = time.perf_counter() ok, unrolled = runtime.grab_and_process(color=False)
if not ok:
break
if profiler is not None: tactile.compute_deformation()
t0 = time.perf_counter()
ok, frame = runtime.read_source()
profiler.record("capture", (time.perf_counter() - t0) * 1000.0)
if not ok:
break
t0 = time.perf_counter()
unrolled = runtime.process_frame(frame, color=False)
profiler.record("uv_unroll", (time.perf_counter() - t0) * 1000.0)
else:
ok, unrolled = runtime.grab_and_process(color=False)
if not ok:
break
if profiler is not None:
flow_ms, hhd_ms = run_deformation_timed(
tactile, check_motion=True, threshold=0
)
profiler.record("flow", flow_ms)
profiler.record("hhd", hhd_ms)
else:
tactile.compute_deformation(check_motion=True, threshold=0)
t0 = time.perf_counter()
tactile.compute_contact() tactile.compute_contact()
if profiler is not None:
profiler.record("contact", (time.perf_counter() - t0) * 1000.0)
t0 = time.perf_counter()
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = tactile.read_info( fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = tactile.read_info(
tactile.info.FPS, tactile.info.FPS,
tactile.info.VRAW, tactile.info.VRAW,
@@ -131,8 +77,6 @@ def main():
tactile.info.FSHEARX, tactile.info.FSHEARX,
tactile.info.FSHEARY, tactile.info.FSHEARY,
) )
if profiler is not None:
profiler.record("read_info", (time.perf_counter() - t0) * 1000.0)
status = ( status = (
f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) " f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) "
@@ -140,26 +84,16 @@ def main():
) )
if args.show: if args.show:
t0 = time.perf_counter()
arrows = orisys.util.draw_arrows( arrows = orisys.util.draw_arrows(
unrolled, unrolled,
flow, flow,
threshold=0.4, threshold=0.4,
grid_spacing=10, grid_spacing=10,
arrow_scale=1.0, arrow_scale=1.0,
valid_mask=tactile.valid_mask,
) )
cv2.imshow("UV Unrolled", arrows) cv2.imshow("UV Unrolled", arrows)
if profiler is not None:
profiler.record("display", (time.perf_counter() - t0) * 1000.0)
if profiler is not None:
total_ms = (time.perf_counter() - frame_t0) * 1000.0
profiler.record("total", total_ms)
status += (
f" | wall={total_ms:.1f}ms flow={profiler.last.get('flow', 0):.1f}ms"
f" hhd={profiler.last.get('hhd', 0):.1f}ms"
)
profiler.tick()
print(status, end="\r") print(status, end="\r")
key = cv2.waitKey(1) & 0xFF key = cv2.waitKey(1) & 0xFF
@@ -170,8 +104,6 @@ def main():
tactile.reset() tactile.reset()
print("\nTracker reset.") print("\nTracker reset.")
finally: finally:
if profiler is not None and profiler.frame_count:
profiler.print_report(prefix="[profile final]")
runtime.close() runtime.close()
cv2.destroyAllWindows() cv2.destroyAllWindows()
+21 -6
View File
@@ -1,24 +1,39 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
UV-unroll finger tactile pipeline with unified Qt dashboard. ORY-FINGER:UV-unroll finger tactile pipeline with unified Qt dashboard.
Where to customize the GUI: Where to customize the GUI:
Layout (sizes, proportions) -> examples/gui/layout.py <-- edit here Layout (sizes, proportions) -> examples/gui/viewer3d/layout.py
Colors / QSS -> examples/gui/styles.py Colors / QSS -> examples/gui/viewer3d/styles.py
Widget rendering logic -> examples/gui/widgets/ Widget rendering logic -> examples/gui/viewer3d/widgets/
Window behavior -> examples/gui/dashboard.py Window behavior -> examples/gui/viewer3d/dashboard.py
Recommended entry:
python examples/finger_pipeline_3dview.py --video 0 --config finger
""" """
import argparse import argparse
import os import os
import sys import sys
from pathlib import Path
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
from app_paths import configure_frozen_runtime, default_config_path, ensure_examples_import_path # 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 gui.app_paths import (
configure_frozen_runtime,
default_config_path,
ensure_examples_import_path,
require_gui_extras,
)
configure_frozen_runtime() configure_frozen_runtime()
ensure_examples_import_path() ensure_examples_import_path()
require_gui_extras()
from gui import run_dashboard from gui import run_dashboard
from pipeline import PipelineConfig from pipeline import PipelineConfig
+15 -6
View File
@@ -1,20 +1,29 @@
"""Public GUI entry points for finger examples.""" """Public GUI entry points for Orisys example apps.
from .dashboard import FingerDashboardWindow, run_dashboard Two apps live under ``examples/gui``:
from .layout import (
LOG_CONFIG, - Finger 3D dashboard: ``from gui import run_dashboard`` (also ``gui.viewer3d``)
LOG_PANEL, - 2D sensor monitor: ``from gui.viewer2d import run_viewer``
Acquisition adapters (no widgets) live in ``gui.sensors``.
Shared bootstrap: ``gui.app``, ``gui.app_paths``, ``gui.log_config``, ``gui.widgets``.
"""
from .log_config import LOG_CONFIG, LogConfig
from .viewer3d import (
FingerDashboardWindow,
LAYOUT, LAYOUT,
LOG_PANEL,
MESH_CONTACT_ARROW, MESH_CONTACT_ARROW,
MESH_LIGHTING, MESH_LIGHTING,
PANEL_SETUP, PANEL_SETUP,
TOP_ROW, TOP_ROW,
DashboardLayout, DashboardLayout,
LogConfig,
MeshLighting, MeshLighting,
PanelRow, PanelRow,
PanelSetup, PanelSetup,
PanelSlot, PanelSlot,
run_dashboard,
top_row_splitter_sizes, top_row_splitter_sizes,
top_row_slot_index, top_row_slot_index,
) )
@@ -1,4 +1,4 @@
"""Resolve bundled resource paths for dev runs and Nuitka standalone builds.""" """Resolve bundled resource paths for dev runs and standalone builds."""
from __future__ import annotations from __future__ import annotations
@@ -6,7 +6,8 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
_EXAMPLES_DIR = Path(__file__).resolve().parent _GUI_DIR = Path(__file__).resolve().parent
_EXAMPLES_DIR = _GUI_DIR.parent
_REPO_ROOT = _EXAMPLES_DIR.parent _REPO_ROOT = _EXAMPLES_DIR.parent
@@ -26,7 +27,17 @@ def resource_path(*parts: str) -> Path:
def default_config_path() -> str: def default_config_path() -> str:
return str(resource_path("config", "dd02-ov.json")) """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: def configure_frozen_runtime() -> None:
+52
View File
@@ -0,0 +1,52 @@
"""Shared log-panel configuration for Qt viewers (2D and 3D)."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class LogConfig:
"""Log panel — edit LOG_CONFIG or DashboardLayout.log_config."""
status_enabled: bool = True
max_lines: int = 2000
font_family: str = "Cascadia Mono"
font_pt: int = 12
show_flow: bool = True
show_mask: bool = True
def format_status(
self,
*,
fps: float,
fnormal: float,
fshearx: float,
fsheary: float,
flow_rms: float = 0.0,
mask_coverage: float = 0.0,
wall_fps: float | None = None,
contact_xyz_mm: tuple[float, float, float] | None = None,
) -> str:
shear_mag = float((fshearx**2 + fsheary**2) ** 0.5)
if wall_fps is not None:
parts = [f"FPS={wall_fps:.1f}", f"sdk={fps:.1f}"]
else:
parts = [f"FPS={fps:.1f}"]
parts.extend(
[
f"Fn={fnormal:.1f}",
f"|(Fx,Fy)|={shear_mag:.1f}",
f"Fx, Fy=({fshearx:.1f}, {fsheary:.1f})",
]
)
force_line = " ".join(parts)
if contact_xyz_mm is None:
contact_line = "contact=---"
else:
cx, cy, cz = contact_xyz_mm
contact_line = f"contact=({cx:.1f}, {cy:.1f}, {cz:.1f}) mm"
return f"{force_line}\n{contact_line}"
LOG_CONFIG = LogConfig()
+11
View File
@@ -0,0 +1,11 @@
{
"video_source": "D:/repo/orisys-sdk/data/dd02-0703/1.mp4",
"config_path": "finger",
"finger_assets_dir": "sensors/finger/assets/30fps-0703",
"csv_save_dir": "./outputs/csv",
"show_log": true,
"show_flow_arrows": false,
"arrow_background": "unrolled",
"free_rotate": true,
"sensor_bundle_dir": "sensors/finger"
}
+23
View File
@@ -0,0 +1,23 @@
"""Sensor acquisition adapters for Qt / multi-sensor viewers (no Qt widgets)."""
from .adapter import (
Dd06RoiAdapter,
FrameSnapshot,
OrisysSensorAdapter,
SensorAdapter,
calibrate_normal_force,
create_sensor_adapter,
_snapshot_from_sensor,
)
from . import roi_geometry
__all__ = [
"Dd06RoiAdapter",
"FrameSnapshot",
"OrisysSensorAdapter",
"SensorAdapter",
"calibrate_normal_force",
"create_sensor_adapter",
"roi_geometry",
"_snapshot_from_sensor",
]
+330
View File
@@ -0,0 +1,330 @@
"""Sensor adapters that normalize frames for Qt / multi-sensor viewers."""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
import cv2
import numpy as np
import orisys
from .roi_geometry import (
build_roi_warp,
load_json_config,
load_roi_polygon,
open_video_source,
rectified_output_size,
warp_roi_to_rectangle,
)
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."""
image: Optional[np.ndarray] = None
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
contour: Optional[np.ndarray] = None
centroid: Any = None
# Raw SDK force channels used for curves (same units for FN/FX/FY).
normal_force: float = 0.0
shear_x: float = 0.0
shear_y: float = 0.0
# Optional calibrated FN in Newtons for export/display overlays.
normal_force_calibrated: float = 0.0
fps: float = 0.0
timestamp: float = 0.0
raw_image: Optional[np.ndarray] = None
processed_image: Optional[np.ndarray] = None
flow_overlay: Optional[np.ndarray] = None
record_frame: Optional[np.ndarray] = None
def calibrate_normal_force(raw_value: float) -> float:
"""Convert raw normal-force reading to Newtons using y = mx + c."""
return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET
class SensorAdapter(ABC):
"""Common interface between Qt and concrete sensor backends."""
@abstractmethod
def start(self) -> None:
raise NotImplementedError
@abstractmethod
def read(self) -> Optional[FrameSnapshot]:
raise NotImplementedError
@abstractmethod
def reset(self) -> None:
raise NotImplementedError
@abstractmethod
def close(self) -> None:
raise NotImplementedError
def _bgr_from_gray_or_bgr(img: np.ndarray) -> np.ndarray:
if img is None:
return None
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
return img
def _snapshot_from_sensor(
sensor,
*,
raw_image=None,
processed_image=None,
) -> Optional[FrameSnapshot]:
"""Build a FrameSnapshot with HHD/forces refreshed every frame."""
if sensor is None or sensor.img is None:
return None
# Always decompose so FN/FX/FY update every frame (motion gating freezes forces).
sensor.compute_deformation(decompose=True, check_motion=False)
sensor.compute_contact()
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,
)
)
image_bgr = _bgr_from_gray_or_bgr(img)
processed = processed_image if processed_image is not None else image_bgr
record = None
if getattr(sensor, "frame", None) is not None:
record = _bgr_from_gray_or_bgr(sensor.frame)
elif processed is not None:
record = processed
flow_overlay = None
if image_bgr is not None and flow is not None:
flow_overlay = orisys.util.draw_arrows(
image_bgr,
flow,
threshold=2,
grid_spacing=20,
arrow_scale=0.5,
below_threshold_color=(160, 160, 160),
)
fn_raw = float(fnormal)
return FrameSnapshot(
image=image_bgr,
flow=flow,
depth=depth_map,
contour=contour,
centroid=centroid,
normal_force=fn_raw,
shear_x=float(fshearx),
shear_y=float(fsheary),
normal_force_calibrated=calibrate_normal_force(fn_raw),
fps=float(fps),
timestamp=time.time(),
raw_image=raw_image,
processed_image=processed,
flow_overlay=flow_overlay,
record_frame=record,
)
class OrisysSensorAdapter(SensorAdapter):
"""Direct orisys.Sensor capture path (compound-eye / single-camera)."""
def __init__(
self,
video,
config_name,
cal_path=None,
verbose=True,
*,
rotate_ccw90=False,
motion_threshold=1,
):
self.video = video
self.config_name = config_name
self.cal_path = cal_path
self.verbose = verbose
self.rotate_ccw90 = bool(rotate_ccw90)
self.motion_threshold = motion_threshold
self.sensor = None
def start(self) -> None:
src = self.video
try:
src = int(str(src).strip())
except (TypeError, ValueError):
pass
self.sensor = orisys.Sensor(
src,
config_name=self.config_name,
cal_path=self.cal_path,
verbose=self.verbose,
)
def read(self) -> Optional[FrameSnapshot]:
if self.sensor is None:
return None
img = self.sensor.get_img()
if img is None:
return None
if self.rotate_ccw90:
self.sensor.img = cv2.rotate(self.sensor.img, cv2.ROTATE_90_COUNTERCLOCKWISE)
if self.sensor.frame is not None:
self.sensor.frame = cv2.rotate(
self.sensor.frame, cv2.ROTATE_90_COUNTERCLOCKWISE
)
return _snapshot_from_sensor(
self.sensor,
raw_image=_bgr_from_gray_or_bgr(self.sensor.frame)
if self.sensor.frame is not None
else None,
processed_image=_bgr_from_gray_or_bgr(self.sensor.img),
)
def reset(self) -> None:
if self.sensor is not None:
self.sensor.reset()
def close(self) -> None:
if self.sensor is not None:
self.sensor.disconnect()
self.sensor = None
class Dd06RoiAdapter(SensorAdapter):
"""DD06 path: external capture + polygon ROI rectify + already_processed Sensor."""
def __init__(self, video, config_name, verbose=True, *, motion_threshold=0):
self.video = video
self.config_name = config_name
self.verbose = verbose
self.motion_threshold = motion_threshold
self.sensor = None
self.cap = None
self.roi_points = None
self.roi_image_size = None
self.out_size = None
self.warp_matrix = None
self.warp_src = None
self.last_frame_size = None
self._source = None
def start(self) -> None:
config = load_json_config(self.config_name)
self.roi_points, self.roi_image_size = load_roi_polygon(config)
self.out_size = rectified_output_size(config, self.roi_points)
self.sensor = orisys.Sensor(
"external",
config_name=self.config_name,
verbose=self.verbose,
)
self.cap, self._source = open_video_source(self.video)
self.warp_matrix = None
self.last_frame_size = None
if self.verbose:
print(
f"DD06 adapter: ROI points={len(self.roi_points)} "
f"source_size={self.roi_image_size} output={self.out_size} "
f"video={self._source}"
)
def read(self) -> Optional[FrameSnapshot]:
if self.sensor is None or self.cap is None:
return None
ok, frame = self.cap.read()
if not ok or frame is None:
return None
frame_h, frame_w = frame.shape[:2]
frame_size = (frame_w, frame_h)
if self.warp_matrix is None or self.last_frame_size != frame_size:
self.warp_matrix, self.warp_src, _ = build_roi_warp(
self.roi_points,
self.roi_image_size,
frame_size,
self.out_size,
)
self.last_frame_size = frame_size
if self.verbose:
print(f"Built ROI warp for frame {frame_w}x{frame_h}")
rectified = warp_roi_to_rectangle(frame, self.warp_matrix, self.out_size)
self.sensor.get_img(frame=rectified, already_processed=True)
return _snapshot_from_sensor(
self.sensor,
raw_image=frame,
processed_image=rectified,
)
def reset(self) -> None:
if self.sensor is not None:
self.sensor.reset()
def close(self) -> None:
if self.cap is not None:
self.cap.release()
self.cap = None
if self.sensor is not None:
self.sensor.disconnect()
self.sensor = None
def create_sensor_adapter(
sensor_type: str,
*,
video,
config_name,
cal_path=None,
verbose=True,
) -> SensorAdapter:
"""Factory for Qt / demo viewers."""
kind = str(sensor_type or "orisys").strip().lower()
if kind in ("orisys", "direct", "default", "compound_eye", "single_camera"):
# Preserve historical Wuxi viewer rotation for compound-eye demos.
rotate = kind in ("orisys", "direct", "default", "compound_eye")
return OrisysSensorAdapter(
video,
config_name,
cal_path=cal_path,
verbose=verbose,
rotate_ccw90=rotate,
motion_threshold=1,
)
if kind in ("dd06", "dd06-ov", "roi", "polygon_roi"):
return Dd06RoiAdapter(
video,
config_name,
verbose=verbose,
motion_threshold=0,
)
raise ValueError(
f"Unknown sensor_type={sensor_type!r}; expected 'orisys' or 'dd06'"
)
+140
View File
@@ -0,0 +1,140 @@
"""Polygon ROI geometry helpers for rectifying tactile sensor frames."""
from __future__ import annotations
import json
from pathlib import Path
import cv2
import numpy as np
def load_json_config(path):
path = Path(path)
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def load_roi_polygon(config):
"""Return (points Nx2 float32, image_size [w,h]) from nested or legacy ROI fields."""
roi = config.get("roi") if isinstance(config.get("roi"), dict) else config
if not isinstance(roi, dict):
raise ValueError("Config has no usable ROI section")
image_size = roi.get("image_size") or config.get("camera", {}).get("resolution_raw")
if image_size is None:
raise ValueError("ROI image_size is missing")
points = roi.get("points") or roi.get("polygon") or roi.get("roi_polygon")
if points is None and roi.get("xyxy") is not None:
x1, y1, x2, y2 = [int(v) for v in roi["xyxy"]]
points = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]
if not points:
raise ValueError("ROI points are missing")
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
if pts.shape[0] < 4:
raise ValueError(f"ROI needs at least 4 points, got {pts.shape[0]}")
return pts, [int(image_size[0]), int(image_size[1])]
def scale_points(points, source_size, target_size):
source_w, source_h = float(source_size[0]), float(source_size[1])
target_w, target_h = float(target_size[0]), float(target_size[1])
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2).copy()
if source_w > 0 and source_h > 0 and (source_w, source_h) != (target_w, target_h):
pts[:, 0] *= target_w / source_w
pts[:, 1] *= target_h / source_h
return pts
def order_quad_points(points):
"""Order 4 points as TL, TR, BR, BL for perspective transforms."""
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
if pts.shape[0] != 4:
hull = cv2.convexHull(pts.astype(np.float32))
rect = cv2.minAreaRect(hull)
pts = cv2.boxPoints(rect).astype(np.float32)
s = pts.sum(axis=1)
diff = np.diff(pts, axis=1).reshape(-1)
ordered = np.zeros((4, 2), dtype=np.float32)
ordered[0] = pts[np.argmin(s)] # top-left
ordered[2] = pts[np.argmax(s)] # bottom-right
ordered[1] = pts[np.argmin(diff)] # top-right
ordered[3] = pts[np.argmax(diff)] # bottom-left
return ordered
def rectified_output_size(config, points=None):
"""Prefer camera.resolution_stitch; fall back to ROI bounding box size."""
stitch = config.get("camera", {}).get("resolution_stitch")
if stitch is not None and len(stitch) >= 2:
return int(stitch[0]), int(stitch[1])
if points is not None:
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
width = max(
int(np.linalg.norm(pts[1] - pts[0])),
int(np.linalg.norm(pts[2] - pts[3])),
1,
)
height = max(
int(np.linalg.norm(pts[3] - pts[0])),
int(np.linalg.norm(pts[2] - pts[1])),
1,
)
return width, height
raise ValueError("Cannot determine rectified output size")
def build_roi_warp(points, source_image_size, frame_size, output_size):
"""
Build a perspective transform that maps the ROI polygon in frame coords
onto a rectangle of ``output_size`` (width, height).
"""
frame_w, frame_h = int(frame_size[0]), int(frame_size[1])
scaled = scale_points(points, source_image_size, (frame_w, frame_h))
src = order_quad_points(scaled)
out_w, out_h = int(output_size[0]), int(output_size[1])
dst = np.array(
[
[0.0, 0.0],
[out_w - 1.0, 0.0],
[out_w - 1.0, out_h - 1.0],
[0.0, out_h - 1.0],
],
dtype=np.float32,
)
matrix = cv2.getPerspectiveTransform(src, dst)
return matrix, src, (out_w, out_h)
def warp_roi_to_rectangle(frame, matrix, output_size):
out_w, out_h = int(output_size[0]), int(output_size[1])
return cv2.warpPerspective(
frame,
matrix,
(out_w, out_h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
def open_video_source(video):
"""Open a camera index or video file. Returns (cap, resolved_source)."""
text = str(video).strip()
if text.isdigit():
source = int(text)
cap = cv2.VideoCapture(source, cv2.CAP_DSHOW)
if not cap.isOpened():
cap.release()
cap = cv2.VideoCapture(source)
else:
path = Path(text)
if not path.exists():
raise FileNotFoundError(f"Video not found: {path}")
source = str(path)
cap = cv2.VideoCapture(source)
if not cap.isOpened():
raise RuntimeError(f"Failed to open video source: {video}")
return cap, source
+30
View File
@@ -0,0 +1,30 @@
"""2D Qt sensor monitor (image + force curves; no finger 3D mesh)."""
from .backend import (
PURE_COLOR_BG_BGR,
ArrowBackground,
ForceHistory,
ForcePlotState,
QtViewerBackend,
ViewerFrame,
build_display_image,
image_shape_meta,
panel_width_for_aspect,
select_display_image,
)
from .window import QtViewerWindow, run_viewer
__all__ = [
"PURE_COLOR_BG_BGR",
"ArrowBackground",
"ForceHistory",
"ForcePlotState",
"QtViewerBackend",
"QtViewerWindow",
"ViewerFrame",
"build_display_image",
"image_shape_meta",
"panel_width_for_aspect",
"run_viewer",
"select_display_image",
]
+400
View File
@@ -0,0 +1,400 @@
"""Qt-facing display backend for sensor viewer snapshots.
Separates acquisition (`gui.sensors.FrameSnapshot`) from Qt display state:
normalized images, force history / filtering, and aspect-ratio metadata.
Does not import camera, ROI, or `orisys.Sensor`.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Optional, Sequence, Tuple
import cv2
import numpy as np
import orisys
from scipy.signal import butter, filtfilt
FORCE_SAMPLE_RATE_HZ = 30.0
PLOT_BUFFER_POINTS = 120
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ
Y_AXIS_MIN_SPAN = 20000.0
ArrowBackground = Literal["pure_color", "captured"]
# #0B1016 as BGR (matches demo_wuxi / promote_video theme)
PURE_COLOR_BG_BGR = (22, 16, 11)
_ARROW_DRAW_KW = dict(
threshold=2,
grid_spacing=20,
arrow_scale=0.5,
below_threshold_color=(160, 160, 160),
)
@dataclass
class ViewerFrame:
"""Normalized display payload for one UI tick."""
display_image: np.ndarray
image_height: int
image_width: int
aspect_ratio: float # width / height
depth: Optional[np.ndarray]
normal_force: float
shear_x: float
shear_y: float
normal_force_calibrated: float
fps: float
timestamp: float
record_frame: Optional[np.ndarray]
@dataclass
class ForcePlotState:
"""Filtered force series ready for pyqtgraph curves."""
times: np.ndarray # shape (n,)
values: np.ndarray # shape (n, 3) FN, FX, FY
sample_count: int
x_range: Tuple[float, float]
y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]
def lowpass_filter(
data,
cutoff_hz: float,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
order: int = 2,
) -> np.ndarray:
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data."""
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0:
return y.copy()
if y.size == 0:
return y
nyquist = 0.5 * sample_rate_hz
if cutoff_hz < 0 or cutoff_hz >= nyquist:
raise ValueError(
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
)
b, a = butter(order, cutoff_hz / nyquist, btype="low")
padlen = 3 * (max(len(b), len(a)) - 1)
if y.size <= padlen:
return y.copy()
return filtfilt(b, a, y)
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
if img is None:
return None
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
return img
def image_shape_meta(img: np.ndarray) -> Tuple[int, int, float]:
"""Return (height, width, width/height) from a display image."""
h, w = int(img.shape[0]), int(img.shape[1])
aspect = float(w) / float(h) if h > 0 else 1.0
return h, w, aspect
def _base_bgr(snapshot) -> Optional[np.ndarray]:
for attr in ("image", "processed_image", "raw_image"):
out = ensure_bgr(getattr(snapshot, attr, None))
if out is not None:
return out
return None
def select_display_image(snapshot) -> np.ndarray:
"""Pick flow overlay / processed / raw image for the left panel."""
arrows = getattr(snapshot, "flow_overlay", None)
if arrows is not None:
out = ensure_bgr(arrows)
if out is not None:
return out
for attr in ("processed_image", "image", "raw_image"):
base = getattr(snapshot, attr, None)
out = ensure_bgr(base)
if out is not None:
return out
return np.zeros((400, 400, 3), dtype=np.uint8)
def build_display_image(
snapshot,
background: ArrowBackground = "pure_color",
) -> np.ndarray:
"""Build left-panel image: arrows on pure color or on the captured frame."""
flow = getattr(snapshot, "flow", None)
base = _base_bgr(snapshot)
if flow is not None and base is not None:
if background == "pure_color":
h, w = base.shape[:2]
canvas = np.empty((h, w, 3), dtype=np.uint8)
canvas[:, :] = PURE_COLOR_BG_BGR
return orisys.util.draw_arrows(canvas, flow, **_ARROW_DRAW_KW)
overlay = ensure_bgr(getattr(snapshot, "flow_overlay", None))
if overlay is not None:
return overlay
return orisys.util.draw_arrows(base, flow, **_ARROW_DRAW_KW)
return select_display_image(snapshot)
def bgr_to_rgb_bytes(img_bgr: np.ndarray) -> Tuple[np.ndarray, int, int]:
"""Convert BGR uint8 image to contiguous RGB for QImage."""
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
if not rgb.flags["C_CONTIGUOUS"]:
rgb = np.ascontiguousarray(rgb)
h, w = rgb.shape[:2]
return rgb, h, w
def scale_bgr_to_fit(
img_bgr: np.ndarray,
max_w: int,
max_h: int,
) -> np.ndarray:
"""Resize preserving aspect ratio to fit inside (max_w, max_h)."""
if max_w <= 0 or max_h <= 0:
return img_bgr
h, w = img_bgr.shape[:2]
if h <= 0 or w <= 0:
return img_bgr
scale = min(max_w / float(w), max_h / float(h))
if abs(scale - 1.0) < 1e-6:
return img_bgr
nw = max(1, int(round(w * scale)))
nh = max(1, int(round(h * scale)))
return cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA)
def panel_width_for_aspect(
available_height: int,
aspect_ratio: float,
*,
min_size: int = 160,
) -> int:
"""Preferred left-panel width given available height and image aspect."""
h = max(int(available_height), min_size)
w = int(round(h * float(aspect_ratio)))
return max(w, min_size)
class ForceHistory:
"""Ring-style force history buffer with filtering and y-range smoothing."""
def __init__(
self,
npoints: int = PLOT_BUFFER_POINTS,
*,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
lowpass_cutoff_hz: float = 10.0,
y_axis_min_span: float = Y_AXIS_MIN_SPAN,
y_range_alpha: float = 0.5,
):
self.npoints = int(npoints)
self.sample_rate_hz = float(sample_rate_hz)
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
self.y_axis_min_span = float(y_axis_min_span)
self.y_range_alpha = float(y_range_alpha)
self.window_sec = self.npoints / self.sample_rate_hz
self.reset()
def reset(self) -> None:
self.data = np.full((self.npoints, 2, 3), np.nan, dtype=np.float64)
self.sample_count = 0
self._y_range_smooth = [None, None, None]
def push(self, t: float, fn: float, fx: float, fy: float) -> None:
values = (float(fn), float(fx), float(fy))
if self.sample_count < self.npoints:
idx = self.sample_count
self.data[idx, 0, :] = t
self.data[idx, 1, :] = values
self.sample_count += 1
return
self.data[:-1, 0, :] = self.data[1:, 0, :]
self.data[:-1, 1, :] = self.data[1:, 1, :]
self.data[-1, 0, :] = t
self.data[-1, 1, :] = values
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> Tuple[float, float]:
if self._y_range_smooth[channel] is None:
smoothed = (ymin, ymax)
else:
prev_min, prev_max = self._y_range_smooth[channel]
alpha = self.y_range_alpha
ymin = alpha * ymin + (1.0 - alpha) * prev_min
ymax = alpha * ymax + (1.0 - alpha) * prev_max
smoothed = (ymin, ymax)
span = smoothed[1] - smoothed[0]
if span < self.y_axis_min_span:
mid = 0.5 * (smoothed[0] + smoothed[1])
half = self.y_axis_min_span / 2.0
smoothed = (mid - half, mid + half)
self._y_range_smooth[channel] = smoothed
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
return smoothed[0] - pad, smoothed[1] + pad
def plot_state(self) -> Optional[ForcePlotState]:
n = self.sample_count
if n == 0:
return None
data_plot = self.data[:n].copy()
for i in range(3):
data_plot[:, 1, i] = lowpass_filter(
data_plot[:, 1, i],
self.lowpass_cutoff_hz,
sample_rate_hz=self.sample_rate_hz,
)
times = data_plot[:, 0, 0].copy()
values = data_plot[:, 1, :].copy()
y_ranges = tuple(
self._smooth_y_range(i, float(values[:, i].min()), float(values[:, i].max()))
for i in range(3)
)
if n < self.npoints:
x_range = (0.0, self.window_sec)
else:
x0 = float(times[0])
x1 = float(times[-1])
if x1 <= x0:
x1 = x0 + self.window_sec
x_range = (x0, x1)
return ForcePlotState(
times=times,
values=values,
sample_count=n,
x_range=x_range,
y_ranges=y_ranges, # type: ignore[arg-type]
)
class QtViewerBackend:
"""Normalize FrameSnapshot into display images + force history."""
def __init__(
self,
*,
npoints: int = PLOT_BUFFER_POINTS,
lowpass_cutoff_hz: float = 10.0,
y_axis_min_span: float = Y_AXIS_MIN_SPAN,
arrow_background: ArrowBackground = "pure_color",
):
self.force_history = ForceHistory(
npoints=npoints,
lowpass_cutoff_hz=lowpass_cutoff_hz,
y_axis_min_span=y_axis_min_span,
)
self._start_time: Optional[float] = None
self.last_viewer_frame: Optional[ViewerFrame] = None
self.arrow_background: ArrowBackground = arrow_background
def set_arrow_background(self, background: ArrowBackground) -> None:
self.arrow_background = background
def rebuild_display(self, snapshot) -> ViewerFrame:
"""Re-render display image without advancing force history."""
display = build_display_image(snapshot, self.arrow_background)
h, w, aspect = image_shape_meta(display)
prev = self.last_viewer_frame
if prev is not None:
frame = ViewerFrame(
display_image=display,
image_height=h,
image_width=w,
aspect_ratio=aspect,
depth=prev.depth,
normal_force=prev.normal_force,
shear_x=prev.shear_x,
shear_y=prev.shear_y,
normal_force_calibrated=prev.normal_force_calibrated,
fps=prev.fps,
timestamp=prev.timestamp,
record_frame=prev.record_frame,
)
else:
frame = ViewerFrame(
display_image=display,
image_height=h,
image_width=w,
aspect_ratio=aspect,
depth=getattr(snapshot, "depth", None),
normal_force=float(getattr(snapshot, "normal_force", 0.0)),
shear_x=float(getattr(snapshot, "shear_x", 0.0)),
shear_y=float(getattr(snapshot, "shear_y", 0.0)),
normal_force_calibrated=float(
getattr(snapshot, "normal_force_calibrated", 0.0)
),
fps=float(getattr(snapshot, "fps", 0.0)),
timestamp=float(getattr(snapshot, "timestamp", 0.0) or 0.0),
record_frame=ensure_bgr(getattr(snapshot, "record_frame", None)),
)
self.last_viewer_frame = frame
return frame
def reset(self) -> None:
self.force_history.reset()
self._start_time = None
self.last_viewer_frame = None
def ingest(self, snapshot, *, now: Optional[float] = None) -> ViewerFrame:
"""Convert a FrameSnapshot into ViewerFrame and update force history."""
display = build_display_image(snapshot, self.arrow_background)
h, w, aspect = image_shape_meta(display)
fn = float(getattr(snapshot, "normal_force", 0.0))
fx = float(getattr(snapshot, "shear_x", 0.0))
fy = float(getattr(snapshot, "shear_y", 0.0))
fn_cal = float(getattr(snapshot, "normal_force_calibrated", 0.0))
fps = float(getattr(snapshot, "fps", 0.0))
ts = float(getattr(snapshot, "timestamp", 0.0) or 0.0)
if now is None:
now = ts if ts > 0 else 0.0
if self._start_time is None:
self._start_time = now
t = now - self._start_time
self.force_history.push(t, fn, fx, fy)
record = ensure_bgr(getattr(snapshot, "record_frame", None))
depth = getattr(snapshot, "depth", None)
frame = ViewerFrame(
display_image=display,
image_height=h,
image_width=w,
aspect_ratio=aspect,
depth=depth,
normal_force=fn,
shear_x=fx,
shear_y=fy,
normal_force_calibrated=fn_cal,
fps=fps,
timestamp=ts,
record_frame=record,
)
self.last_viewer_frame = frame
return frame
def plot_state(self) -> Optional[ForcePlotState]:
return self.force_history.plot_state()
+154
View File
@@ -0,0 +1,154 @@
"""Styles for the 2D Qt sensor monitor."""
CURVE_THEME_COLOR = "#00E5FF"
STYLE = """
QMainWindow {
background-color: #000000;
}
/* ---- 图像面板 ---- */
QLabel#arrowsLabel {
border: 1px solid #262626;
border-radius: 4px;
background-color: #0b0b0b;
}
/* ---- 顶栏按钮 & 输入 ---- */
QPushButton {
background-color: #1a1a1a;
color: #999999;
border: 1px solid #333333;
border-radius: 4px;
padding: 6px 16px;
font-family: "SimHei";
font-size: 13px;
}
QPushButton:hover {
background-color: #262626;
color: #cccccc;
border: 1px solid #555555;
}
QPushButton#btnStart {
background-color: #0d2818;
color: #4caf84;
border: 1px solid #1a4d30;
}
QPushButton#btnStart:hover {
background-color: #143d24;
color: #6fd4a0;
}
QPushButton#btnStart:disabled {
background-color: #0f0f0f;
color: #333333;
border: 1px solid #222222;
}
QPushButton#btnStop {
background-color: #2b1010;
color: #e05555;
border: 1px solid #4d1a1a;
}
QPushButton#btnStop:hover {
background-color: #3d1818;
color: #f07070;
}
QPushButton#btnStop:disabled {
background-color: #0f0f0f;
color: #333333;
border: 1px solid #222222;
}
QLineEdit {
background-color: #0d0d0d;
color: #999999;
border: 1px solid #333333;
border-radius: 4px;
padding: 5px 10px;
font-size: 13px;
}
QLabel#toolbarCaption {
color: #777777;
font-family: "SimHei";
font-size: 13px;
padding-left: 4px;
}
QComboBox {
background-color: #0d0d0d;
color: #999999;
border: 1px solid #333333;
border-radius: 4px;
padding: 5px 10px;
font-family: "SimHei";
font-size: 13px;
min-width: 96px;
}
QComboBox:hover {
background-color: #1a1a1a;
color: #cccccc;
border: 1px solid #555555;
}
QComboBox::drop-down {
border: none;
width: 20px;
}
QComboBox QAbstractItemView {
background-color: #0d0d0d;
color: #999999;
border: 1px solid #333333;
selection-background-color: #1a3a4a;
selection-color: #cccccc;
}
/* ---- 力曲线容器 ---- */
GraphicsLayoutWidget {
border: 1px solid #262626;
border-radius: 4px;
}
/* ---- 日志面板 ---- */
QPlainTextEdit {
background-color: #0b0b0b;
color: #777777;
border: 1px solid #262626;
border-radius: 4px;
font-family: "Cascadia Code", "Consolas", "Courier New", monospace;
font-size: 13px;
padding: 8px;
selection-background-color: #1a3a4a;
selection-color: #cccccc;
}
QPlainTextEdit QScrollBar:vertical {
background-color: #0b0b0b;
width: 6px;
margin: 1px;
border-radius: 3px;
}
QPlainTextEdit QScrollBar::handle:vertical {
background-color: #262626;
border-radius: 3px;
min-height: 30px;
}
QPlainTextEdit QScrollBar::handle:vertical:hover {
background-color: #3a3a3a;
}
QPlainTextEdit QScrollBar::add-line:vertical,
QPlainTextEdit QScrollBar::sub-line:vertical {
height: 0px;
}
QPlainTextEdit QScrollBar::add-page:vertical,
QPlainTextEdit QScrollBar::sub-page:vertical {
background: none;
}
QSplitter::handle {
background-color: #1a1a1a;
}
QSplitter::handle:vertical {
height: 1px;
}
QSplitter::handle:horizontal {
width: 1px;
}
"""
+525
View File
@@ -0,0 +1,525 @@
"""2D Qt sensor monitor window (image + force curves)."""
from __future__ import annotations
import os
import sys
import time
from datetime import datetime
from pathlib import Path
import cv2
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QFont, QIcon
from PyQt5.QtWidgets import (
QApplication,
QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
)
try:
import pyqtgraph as pg
except Exception as exc: # pragma: no cover
raise ImportError(
"PyQtGraph is not installed. Please install it using 'pip install pyqt5 pyqtgraph'."
) from exc
from gui.sensors import create_sensor_adapter
from gui.widgets.log_panel import TeeStream, WidgetLogHandler, make_log_widget
from gui.widgets.qt_pixmap import ndarray_to_pixmap, pixmap_keep_aspect
from .backend import (
PLOT_BUFFER_POINTS,
PLOT_WINDOW_SEC,
QtViewerBackend,
panel_width_for_aspect,
)
from .styles import CURVE_THEME_COLOR, STYLE
class QtViewerWindow(QMainWindow):
"""2D monitor: flow/image panel + FN/FX/FY curves driven by QtViewerBackend."""
def __init__(self, args, log_file):
super().__init__()
self.setWindowTitle("Orisys SDK")
self.setStyleSheet(STYLE)
logo_path = Path(__file__).resolve().parents[3] / "logo.png"
if logo_path.exists():
self.setWindowIcon(QIcon(str(logo_path)))
self.sensor_adapter = None
self._last_snapshot = None
self._last_viewer_frame = None
self._display_aspect = 1.0
self._source_pixmap = None
self.args = args
self.is_save = False
self.frame_count = 0
self.writer = None
self.save_path = None
self.viewer_backend = QtViewerBackend(
npoints=PLOT_BUFFER_POINTS,
lowpass_cutoff_hz=getattr(args, "cutoff", 10.0),
)
self._setup_ui()
self._create_plots()
self.camera_edit.setText(args.video)
self.config_edit.setText(args.config)
self.cal_edit.setText(args.cal)
self.log_handler = WidgetLogHandler(self.log_widget)
sys.stdout = TeeStream(log_file, self.log_handler)
sys.stderr = sys.stdout
self.timer = QTimer()
self.timer.timeout.connect(self._on_timer)
self._running = False
def _setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
root_layout = QVBoxLayout(central)
root_layout.setContentsMargins(6, 6, 6, 6)
root_layout.setSpacing(6)
bar = QHBoxLayout()
bar.setSpacing(8)
self.btn_camera = QPushButton("摄像机")
self.btn_camera.setObjectName("btnCamera")
self.btn_camera.clicked.connect(self._select_camera)
bar.addWidget(self.btn_camera)
self.camera_edit = QLineEdit("1")
self.camera_edit.setPlaceholderText("摄像头编号 或 视频路径")
bar.addWidget(self.camera_edit, 1)
self.btn_config = QPushButton("配置")
self.btn_config.setObjectName("btnConfig")
self.btn_config.clicked.connect(self._select_config)
bar.addWidget(self.btn_config)
self.config_edit = QLineEdit("box")
bar.addWidget(self.config_edit, 1)
self.btn_reset = QPushButton("重置")
self.btn_reset.setObjectName("btnReset")
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self._on_reset)
bar.addWidget(self.btn_reset)
self.cal_edit = QLineEdit("./config/ddjx01.npy")
bar.addWidget(self.cal_edit, 1)
self.btn_start = QPushButton("启动")
self.btn_start.setObjectName("btnStart")
self.btn_start.clicked.connect(self._on_start)
bar.addWidget(self.btn_start)
self.btn_stop = QPushButton("暂停")
self.btn_stop.setObjectName("btnStop")
self.btn_stop.setEnabled(False)
self.btn_stop.clicked.connect(self._on_stop)
bar.addWidget(self.btn_stop)
bg_caption = QLabel("背景")
bg_caption.setObjectName("toolbarCaption")
bar.addWidget(bg_caption)
self.bg_combo = QComboBox()
self.bg_combo.setObjectName("bgCombo")
self.bg_combo.addItem("纯色背景", "pure_color")
self.bg_combo.addItem("采集图像", "captured")
self.bg_combo.setCurrentIndex(0)
self.bg_combo.currentIndexChanged.connect(self._on_arrow_background_changed)
bar.addWidget(self.bg_combo)
root_layout.addLayout(bar)
v_splitter = QSplitter(Qt.Vertical)
h_splitter = QSplitter(Qt.Horizontal)
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(6)
self.arrows_label = QLabel()
self.arrows_label.setObjectName("arrowsLabel")
self.arrows_label.setMinimumSize(160, 160)
self.arrows_label.setScaledContents(False)
self.arrows_label.setAlignment(Qt.AlignCenter)
left_layout.addWidget(self.arrows_label)
h_splitter.addWidget(left)
pg.setConfigOption("background", "#000000")
pg.setConfigOption("foreground", "#777777")
pg.setConfigOptions(antialias=True)
self.graph_widget = pg.GraphicsLayoutWidget()
self.graph_widget.setMinimumWidth(300)
h_splitter.addWidget(self.graph_widget)
self.h_splitter = h_splitter
v_splitter.addWidget(h_splitter)
from gui.log_config import LOG_CONFIG
self.log_widget = make_log_widget(LOG_CONFIG)
v_splitter.addWidget(self.log_widget)
v_splitter.setStretchFactor(0, 4)
v_splitter.setStretchFactor(1, 1)
root_layout.addWidget(v_splitter)
def _create_plots(self):
titles = ["法向力 (raw)", "切向力 X", "切向力 Y"]
self.plots = []
self.curves = []
self.dot_glows = []
self.dot_cores = []
axis_pen = pg.mkPen(color=(38, 38, 38), width=1)
label_font = QFont("SimHei", 9)
for i, title in enumerate(titles):
p = self.graph_widget.addPlot(title=None)
p.setTitle(title, color="#999999", size="14pt")
p.titleLabel.setFont(QFont("SimHei", 14))
p.setLabel("bottom", "时间", units="s")
p.getAxis("bottom").label.setFont(label_font)
p.showAxis("top", False)
p.showAxis("right", False)
p.getAxis("left").setPen(axis_pen)
p.getAxis("bottom").setPen(axis_pen)
p.showGrid(x=False, y=True, alpha=0.12)
tick_font = QFont("Consolas", 11)
tick_font.setStyleHint(QFont.Monospace)
p.getAxis("left").setTickFont(tick_font)
p.getAxis("bottom").setTickFont(tick_font)
line_pen = pg.mkPen(color=CURVE_THEME_COLOR, width=2)
curve = p.plot(pen=line_pen, fillLevel=0)
dot_glow = p.plot(
[],
[],
pen=None,
symbol="o",
symbolSize=8,
symbolPen=None,
symbolBrush=(0, 229, 255, 76),
)
dot_core = p.plot(
[],
[],
pen=None,
symbol="o",
symbolSize=4,
symbolPen=None,
symbolBrush=(255, 255, 255, 255),
)
p.setXRange(0, PLOT_WINDOW_SEC, padding=0)
p.enableAutoRange(x=False, y=False)
self.plots.append(p)
self.curves.append(curve)
self.dot_glows.append(dot_glow)
self.dot_cores.append(dot_core)
if i < 2:
self.graph_widget.nextRow()
def adjust_image_panel(self):
"""Size the left panel from the current image aspect ratio."""
top_bar_h = 48
log_h = self.log_widget.height()
spacing = 20
available = max(self.height() - top_bar_h - log_h - spacing, 160)
image_w = panel_width_for_aspect(available, self._display_aspect, min_size=160)
max_w = max(self.width() // 2, 160)
image_w = min(image_w, max_w)
self.h_splitter.setSizes([image_w, max(self.width() - image_w, 200)])
self._render_display_pixmap()
def resizeEvent(self, event):
super().resizeEvent(event)
self._render_display_pixmap()
def _render_display_pixmap(self):
if self._source_pixmap is None or self._source_pixmap.isNull():
return
target = self.arrows_label.size()
if target.width() < 2 or target.height() < 2:
return
self.arrows_label.setPixmap(pixmap_keep_aspect(self._source_pixmap, target))
def _on_timer(self):
if self.sensor_adapter is None:
return
snapshot = self.sensor_adapter.read()
if snapshot is None:
print("Failed to get image")
return
self._last_snapshot = snapshot
viewer_frame = self.viewer_backend.ingest(snapshot, now=time.time())
self._last_viewer_frame = viewer_frame
first_image = self._source_pixmap is None
aspect_changed = abs(viewer_frame.aspect_ratio - self._display_aspect) > 1e-3
self._display_aspect = viewer_frame.aspect_ratio
if self.is_save and self.writer is not None and snapshot.record_frame is not None:
self.frame_count += 1
save_img = snapshot.record_frame
if len(save_img.shape) == 2 or (
len(save_img.shape) == 3 and save_img.shape[2] == 1
):
if len(save_img.shape) == 3:
save_img = save_img.squeeze(2)
save_img = cv2.cvtColor(save_img, cv2.COLOR_GRAY2BGR)
self.writer.write(save_img)
print(
f"FPS={viewer_frame.fps:.2f}, "
f"法向力(raw)={viewer_frame.normal_force:.4f}, "
f"切向力X={viewer_frame.shear_x:.4f}, "
f"切向力Y={viewer_frame.shear_y:.4f}, "
f"法向力(N)={viewer_frame.normal_force_calibrated:.6f}"
)
self._source_pixmap = ndarray_to_pixmap(viewer_frame.display_image, bgr=True)
if first_image or aspect_changed:
self.adjust_image_panel()
else:
self._render_display_pixmap()
plot_state = self.viewer_backend.plot_state()
if plot_state is None:
return
for i, curve in enumerate(self.curves):
x = plot_state.times
y = plot_state.values[:, i]
curve.setData(x, y)
ymin, ymax = plot_state.y_ranges[i]
self.plots[i].setYRange(ymin, ymax)
self.dot_glows[i].setData(x[-1:], y[-1:])
self.dot_cores[i].setData(x[-1:], y[-1:])
x0, x1 = plot_state.x_range
for p in self.plots:
p.setXRange(x0, x1, padding=0)
def _on_start(self):
if self._running:
return
self.btn_start.setText("启动中...")
self.btn_start.setEnabled(False)
QApplication.processEvents()
try:
src = self.camera_edit.text()
cfg = self.config_edit.text()
cal = self.cal_edit.text()
verbose = getattr(self.args, "verbose", True)
sensor_type = getattr(self.args, "sensor_type", "orisys")
self.sensor_adapter = create_sensor_adapter(
sensor_type,
video=src,
config_name=cfg,
cal_path=cal,
verbose=verbose,
)
self.sensor_adapter.start()
except Exception as e:
self.btn_start.setText("启动")
self.btn_start.setEnabled(True)
self.sensor_adapter = None
print(f"启动失败: {e}")
return
self.viewer_backend.reset()
self._running = True
self.timer.start(10)
self.btn_start.setText("启动")
self.btn_stop.setEnabled(True)
self.btn_reset.setEnabled(True)
print(
f"▶ 启动 — sensor_type={getattr(self.args, 'sensor_type', 'orisys')}, "
f"摄像机={src}, 配置={cfg}, 标定={cal}"
)
def _on_stop(self):
if not self._running:
return
self._running = False
self.timer.stop()
self.btn_stop.setText("停止中...")
self.btn_stop.setEnabled(False)
QApplication.processEvents()
if self.sensor_adapter is not None:
self.sensor_adapter.close()
self.sensor_adapter = None
self._last_snapshot = None
self.btn_start.setEnabled(True)
self.btn_stop.setText("暂停")
self.btn_reset.setEnabled(False)
print("⏸ 已暂停")
def _select_camera(self):
path, _ = QFileDialog.getOpenFileName(
self,
"选择视频文件",
"data",
"视频 (*.mp4 *.avi *.mov);;所有文件 (*)",
)
if path:
self.camera_edit.setText(path)
def _select_config(self):
path, _ = QFileDialog.getOpenFileName(
self,
"选择配置文件",
"config",
"JSON (*.json);;所有文件 (*)",
)
if path:
self.config_edit.setText(path)
def _on_reset(self):
if self.sensor_adapter is None or not self._running:
print("请先启动传感器后再重置追踪器。")
return
self.sensor_adapter.reset()
print("追踪器已重置。")
def _on_arrow_background_changed(self, _index: int) -> None:
value = self.bg_combo.currentData()
if value not in ("pure_color", "captured"):
return
self.viewer_backend.set_arrow_background(value)
if self._last_snapshot is None:
return
viewer_frame = self.viewer_backend.rebuild_display(self._last_snapshot)
self._last_viewer_frame = viewer_frame
aspect_changed = abs(viewer_frame.aspect_ratio - self._display_aspect) > 1e-3
self._display_aspect = viewer_frame.aspect_ratio
self._source_pixmap = ndarray_to_pixmap(viewer_frame.display_image, bgr=True)
if aspect_changed:
self.adjust_image_panel()
else:
self._render_display_pixmap()
def keyPressEvent(self, event):
key = event.key()
if key == Qt.Key_Escape:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
elif key == Qt.Key_Q:
print("\n正在退出示例程序...")
self.close()
elif key == Qt.Key_S:
self._start_recording()
elif key == Qt.Key_E:
self._stop_recording()
elif key == Qt.Key_R:
self._on_reset()
else:
super().keyPressEvent(event)
def _start_recording(self):
if self.sensor_adapter is None:
print("请先启动采集")
return
self.save_path = "./data/pulse_test.mp4"
os.makedirs(os.path.dirname(self.save_path), exist_ok=True)
save_img = None
if self._last_snapshot is not None:
save_img = self._last_snapshot.record_frame
if save_img is None:
print("当前没有可用帧,无法确定录像尺寸。")
return
temp_img = save_img.copy()
if len(temp_img.shape) == 2 or (
len(temp_img.shape) == 3 and temp_img.shape[2] == 1
):
if len(temp_img.shape) == 3:
temp_img = temp_img.squeeze(2)
temp_img = cv2.cvtColor(temp_img, cv2.COLOR_GRAY2BGR)
fh, fw = temp_img.shape[:2]
self.writer = cv2.VideoWriter(
self.save_path,
cv2.VideoWriter_fourcc(*"mp4v"),
30,
(fw, fh),
)
if self.writer.isOpened():
self.is_save = True
print(f"开始录像:{self.save_path}")
else:
print(f"无法创建录像文件:{self.save_path}")
self.writer = None
def _stop_recording(self):
self.is_save = False
if self.writer is not None:
self.writer.release()
self.writer = None
if self.save_path is not None:
print(f"录像结束:{self.save_path},总帧数:{self.frame_count}")
else:
print(f"录像结束,总帧数:{self.frame_count}")
self.frame_count = 0
def closeEvent(self, event):
self.timer.stop()
if self.writer is not None:
self.writer.release()
if self.sensor_adapter is not None:
self.sensor_adapter.close()
self.sensor_adapter = None
event.accept()
def run_viewer(args, *, log_dir: str | Path | None = None) -> int:
"""Launch the 2D Qt viewer and block until the window closes."""
from gui.app import get_or_create_qapp
if log_dir is None:
log_dir = Path(__file__).resolve().parents[3] / "scripts" / "logs"
else:
log_dir = Path(log_dir)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"qt_viewer_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
log_file = open(log_path, "w", encoding="utf-8")
sys.stdout = log_file
sys.stderr = sys.stdout
print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏")
app = get_or_create_qapp("Orisys SDK")
window = QtViewerWindow(args, log_file)
window.showFullScreen()
QApplication.processEvents()
window.adjust_image_panel()
return app.exec_()
+36
View File
@@ -0,0 +1,36 @@
"""Finger 3D Qt dashboard (mesh + UV arrows + force charts)."""
from .dashboard import FingerDashboardWindow, run_dashboard
from .layout import (
LOG_PANEL,
LAYOUT,
MESH_CONTACT_ARROW,
MESH_LIGHTING,
PANEL_SETUP,
TOP_ROW,
DashboardLayout,
MeshLighting,
PanelRow,
PanelSetup,
PanelSlot,
top_row_splitter_sizes,
top_row_slot_index,
)
__all__ = [
"DashboardLayout",
"FingerDashboardWindow",
"LAYOUT",
"LOG_PANEL",
"MESH_CONTACT_ARROW",
"MESH_LIGHTING",
"MeshLighting",
"PANEL_SETUP",
"TOP_ROW",
"PanelRow",
"PanelSetup",
"PanelSlot",
"run_dashboard",
"top_row_splitter_sizes",
"top_row_slot_index",
]
+139
View File
@@ -0,0 +1,139 @@
"""Discover USB cameras for the dashboard (Windows DSHOW/MSMF, Linux V4L2)."""
from __future__ import annotations
import contextlib
import os
import sys
from dataclasses import dataclass
import cv2
DEFAULT_MAX_CAMERA_INDEX = 10
@dataclass(frozen=True)
class CameraInfo:
index: int
backend_name: str
backend_api: int
width: int
height: int
@property
def source(self) -> str:
return str(self.index)
def label(self) -> str:
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
@contextlib.contextmanager
def _suppress_opencv_noise():
"""Hide OpenCV VideoCapture probe warnings (stderr + OpenCV log)."""
prev_level = None
set_level = None
try:
logging = getattr(getattr(cv2, "utils", None), "logging", None)
if logging is not None:
set_level = getattr(logging, "setLogLevel", None)
get_level = getattr(logging, "getLogLevel", None)
silent = getattr(logging, "LOG_LEVEL_SILENT", 0)
if set_level is not None:
if get_level is not None:
prev_level = get_level()
set_level(silent)
except Exception:
prev_level = None
devnull = open(os.devnull, "w")
try:
with contextlib.redirect_stderr(devnull):
stderr_fd = sys.stderr.fileno()
saved_fd = os.dup(stderr_fd)
try:
os.dup2(devnull.fileno(), stderr_fd)
yield
finally:
os.dup2(saved_fd, stderr_fd)
os.close(saved_fd)
finally:
devnull.close()
if set_level is not None and prev_level is not None:
try:
set_level(prev_level)
except Exception:
pass
def _backend_candidates() -> list[tuple[str, int]]:
candidates: list[tuple[str, int]] = []
if sys.platform == "win32":
if hasattr(cv2, "CAP_DSHOW"):
candidates.append(("DSHOW", cv2.CAP_DSHOW))
if hasattr(cv2, "CAP_MSMF"):
candidates.append(("MSMF", cv2.CAP_MSMF))
elif sys.platform.startswith("linux"):
# Prefer v4l2 for USB camera indices on Linux.
if hasattr(cv2, "CAP_V4L2"):
candidates.append(("V4L2", cv2.CAP_V4L2))
if not candidates:
candidates.append(("ANY", cv2.CAP_ANY))
return candidates
def _try_open(index: int, backend_name: str, backend_api: int) -> CameraInfo | None:
cap = cv2.VideoCapture(index, backend_api)
if not cap.isOpened():
cap.release()
return None
ok, frame = cap.read()
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
if not ok or frame is None:
if width <= 0 or height <= 0:
cap.release()
return None
else:
h, w = frame.shape[:2]
width = width or int(w)
height = height or int(h)
cap.release()
return CameraInfo(
index=index,
backend_name=backend_name,
backend_api=backend_api,
width=width,
height=height,
)
def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]:
"""Probe indices ``0 .. max_index`` and return working cameras."""
found: list[CameraInfo] = []
seen_indices: set[int] = set()
backends = _backend_candidates()
with _suppress_opencv_noise():
for index in range(0, max_index + 1):
for backend_name, backend_api in backends:
info = _try_open(index, backend_name, backend_api)
if info is None:
continue
if index in seen_indices:
break
found.append(info)
seen_indices.add(index)
break
return found
def pick_default_camera(cameras: list[CameraInfo], preferred: str | None = None) -> str | None:
if not cameras:
return None
preferred = (preferred or "").strip()
if preferred:
for cam in cameras:
if cam.source == preferred:
return cam.source
return cameras[0].source
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import math
import sys import sys
import time import time
@@ -17,9 +18,12 @@ from PyQt5.QtWidgets import (
) )
from pipeline.config import PipelineConfig from pipeline.config import PipelineConfig
from pipeline.csv_recorder import CsvRecorder
from pipeline.service import FingerPipeline from pipeline.service import FingerPipeline
from app_paths import resource_path from gui.app_paths import resource_path
from .camera_discovery import list_cameras, pick_default_camera
from .preferences import get_preferred_video_source, load_preferences, save_preferences
from .layout import ( from .layout import (
DashboardLayout, DashboardLayout,
LAYOUT, LAYOUT,
@@ -29,14 +33,12 @@ from .layout import (
) )
from .mesh_view_state import MeshViewState from .mesh_view_state import MeshViewState
from .styles import STYLE from .styles import STYLE
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
from .widgets import ( from .widgets import (
ArrowPanelWidget, ArrowPanelWidget,
ForceChartWidget, ForceChartWidget,
MeshViewportWidget, MeshViewportWidget,
SettingsPanelWidget, SettingsPanelWidget,
TeeStream,
WidgetLogHandler,
make_log_widget,
) )
@@ -54,6 +56,7 @@ class FingerDashboardWindow(QMainWindow):
self.config = config self.config = config
self.layout = layout self.layout = layout
self.pipeline = FingerPipeline(config) self.pipeline = FingerPipeline(config)
self.csv_recorder = CsvRecorder(config.csv_save_dir)
self._running = False self._running = False
self._frame_index = 0 self._frame_index = 0
self._wall_fps = 0.0 self._wall_fps = 0.0
@@ -99,22 +102,45 @@ class FingerDashboardWindow(QMainWindow):
mesh_state=self.mesh_state, mesh_state=self.mesh_state,
show_log=self._log_panel_visible, show_log=self._log_panel_visible,
) )
# CLI / pipeline defaults first, then overlay saved user preferences.
self.settings_panel.set_video_source(str(self.config.video)) self.settings_panel.set_video_source(str(self.config.video))
self.settings_panel.set_config_path(self.config.config) self.settings_panel.set_config_path(self.config.config)
self.settings_panel.set_calibration_path(self.config.calib or "") self.settings_panel.set_calibration_path(self.config.calib or "")
self.settings_panel.set_finger_export_dir(self.config.export_dir) self.settings_panel.set_finger_export_dir(self.config.export_dir)
self.settings_panel.set_csv_save_dir(self.config.csv_save_dir)
self.settings_panel.log_visibility_changed.connect(self._set_log_panel_visible) self._preferences = load_preferences()
self.settings_panel.free_rotation_changed.connect(self.mesh_viewport.set_free_rotation) self.settings_panel.apply_preference_state(self._preferences)
self._log_panel_visible = bool(self.settings_panel.show_log_check.isChecked())
self.mesh_viewport.set_free_rotation(bool(self.settings_panel.free_rotate_check.isChecked()))
if self.arrow_panel is not None: if self.arrow_panel is not None:
self.settings_panel.arrow_background_changed.connect(self.arrow_panel.set_background) self.arrow_panel.set_background(self.settings_panel.arrow_background())
# Sync pipeline config fields from restored panel values.
self.config.video = self.settings_panel.video_source() or self.config.video
self.config.config = self.settings_panel.config_path() or self.config.config
self.config.export_dir = self.settings_panel.finger_export_dir() or self.config.export_dir
self.config.csv_save_dir = self.settings_panel.csv_save_dir() or self.config.csv_save_dir
self.settings_panel.log_visibility_changed.connect(self._on_log_visibility_changed)
self.settings_panel.free_rotation_changed.connect(self._on_free_rotation_changed)
if self.arrow_panel is not None:
self.settings_panel.arrow_background_changed.connect(self._on_arrow_background_changed)
else:
self.settings_panel.arrow_background_changed.connect(lambda _v: self._save_preferences())
self.settings_panel.view_reset_requested.connect(self.mesh_viewport.reset_view) self.settings_panel.view_reset_requested.connect(self.mesh_viewport.reset_view)
self.settings_panel.browse_video_requested.connect(self._select_camera) self.settings_panel.browse_video_requested.connect(self._select_camera)
self.settings_panel.camera_refresh_requested.connect(self._refresh_cameras)
self.settings_panel.camera_selection_changed.connect(self._on_camera_selection_changed)
self.settings_panel.browse_config_requested.connect(self._select_config) self.settings_panel.browse_config_requested.connect(self._select_config)
self.settings_panel.browse_finger_assets_requested.connect(self._select_finger_assets) self.settings_panel.browse_finger_assets_requested.connect(self._select_finger_assets)
self.settings_panel.browse_csv_dir_requested.connect(self._select_csv_dir)
self.settings_panel.csv_record_toggled.connect(self._on_csv_record_toggled)
self.settings_panel.show_flow_arrows_check.toggled.connect(lambda _v: self._save_preferences())
self.settings_panel.btn_play_pause.clicked.connect(self._on_play_pause_clicked) self.settings_panel.btn_play_pause.clicked.connect(self._on_play_pause_clicked)
self.settings_panel.btn_reset_tracker.clicked.connect(self._on_reset) self.settings_panel.btn_reset_tracker.clicked.connect(self._on_reset)
self.settings_panel.set_play_pause_state("run") self.settings_panel.set_play_pause_state("run")
self.settings_panel.set_csv_controls_for_pipeline(False)
self._refresh_cameras(announce=True)
self._panel_widgets: dict[str, QWidget] = {} self._panel_widgets: dict[str, QWidget] = {}
self._panel_widgets["mesh"] = self.mesh_viewport self._panel_widgets["mesh"] = self.mesh_viewport
@@ -155,6 +181,37 @@ class FingerDashboardWindow(QMainWindow):
self.log_widget.setVisible(self._log_panel_visible) self.log_widget.setVisible(self._log_panel_visible)
QTimer.singleShot(0, self._apply_panel_layout) QTimer.singleShot(0, self._apply_panel_layout)
def _on_log_visibility_changed(self, visible: bool) -> None:
self._set_log_panel_visible(visible)
self._save_preferences()
def _on_free_rotation_changed(self, enabled: bool) -> None:
self.mesh_viewport.set_free_rotation(enabled)
self._save_preferences()
def _on_arrow_background_changed(self, value: str) -> None:
if self.arrow_panel is not None:
self.arrow_panel.set_background(value)
self._save_preferences()
def _save_preferences(self) -> None:
prefs = self.settings_panel.preference_state()
self._preferences = prefs
save_preferences(prefs)
# Keep in-memory pipeline config aligned with panel values.
video = prefs.get("video_source")
if isinstance(video, str) and video.strip():
self.config.video = video.strip()
config_path = prefs.get("config_path")
if isinstance(config_path, str) and config_path.strip():
self.config.config = config_path.strip()
assets = prefs.get("finger_assets_dir")
if isinstance(assets, str) and assets.strip():
self.config.export_dir = assets.strip()
csv_dir = prefs.get("csv_save_dir")
if isinstance(csv_dir, str) and csv_dir.strip():
self.config.csv_save_dir = csv_dir.strip()
def _apply_panel_layout(self) -> None: def _apply_panel_layout(self) -> None:
setup = self.layout.panel_setup setup = self.layout.panel_setup
scale = self._layout_scale_factor() scale = self._layout_scale_factor()
@@ -314,6 +371,15 @@ class FingerDashboardWindow(QMainWindow):
cal = self.settings_panel.calibration_path() cal = self.settings_panel.calibration_path()
self.config.calib = cal or None self.config.calib = cal or None
self.config.export_dir = self.settings_panel.finger_export_dir() self.config.export_dir = self.settings_panel.finger_export_dir()
self.config.csv_save_dir = self.settings_panel.csv_save_dir() or "./outputs/csv"
if not self.config.video:
self._set_play_pause_button("run")
self.settings_panel.set_finger_export_dir_enabled(True)
print("请先选择摄像机或视频文件。")
return
self._save_preferences()
try: try:
self.pipeline = FingerPipeline(self.config) self.pipeline = FingerPipeline(self.config)
@@ -334,6 +400,8 @@ class FingerDashboardWindow(QMainWindow):
self.timer.start(self.layout.timer_interval_ms) self.timer.start(self.layout.timer_interval_ms)
self._set_play_pause_button("pause") self._set_play_pause_button("pause")
self.settings_panel.btn_reset_tracker.setEnabled(True) self.settings_panel.btn_reset_tracker.setEnabled(True)
self.settings_panel.set_camera_controls_enabled(False)
self.settings_panel.set_csv_controls_for_pipeline(True)
print( print(
f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, " f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, "
f"标定={self.config.calib or '(none)'}, 资产={self.config.export_dir}" f"标定={self.config.calib or '(none)'}, 资产={self.config.export_dir}"
@@ -342,6 +410,7 @@ class FingerDashboardWindow(QMainWindow):
def _on_stop(self) -> None: def _on_stop(self) -> None:
if not self._running: if not self._running:
return return
self._stop_csv_recording(announce=True)
self._running = False self._running = False
self.timer.stop() self.timer.stop()
self.log_handler.clear_status_line() self.log_handler.clear_status_line()
@@ -354,6 +423,9 @@ class FingerDashboardWindow(QMainWindow):
self._set_play_pause_button("run") self._set_play_pause_button("run")
self.settings_panel.btn_reset_tracker.setEnabled(False) self.settings_panel.btn_reset_tracker.setEnabled(False)
self.settings_panel.set_finger_export_dir_enabled(True) self.settings_panel.set_finger_export_dir_enabled(True)
self.settings_panel.set_camera_controls_enabled(True)
self.settings_panel.set_csv_controls_for_pipeline(False)
self._update_play_enabled_for_source()
def _on_reset(self) -> None: def _on_reset(self) -> None:
if not self._running: if not self._running:
@@ -413,10 +485,26 @@ class FingerDashboardWindow(QMainWindow):
profile.record("total", total_ms) profile.record("total", total_ms)
self.pipeline.record_total_frame(total_ms) self.pipeline.record_total_frame(total_ms)
measurement = self.pipeline.measure_for_csv(frame, self._frame_index)
if self.csv_recorder.is_recording:
self.csv_recorder.write(measurement)
flow_mag = 0.0 flow_mag = 0.0
if frame.flow.size: if frame.flow.size:
flow_mag = float((frame.flow[:, :, 0] ** 2 + frame.flow[:, :, 1] ** 2).mean() ** 0.5) flow_mag = float((frame.flow[:, :, 0] ** 2 + frame.flow[:, :, 1] ** 2).mean() ** 0.5)
contact_xyz_mm = None
if (
math.isfinite(measurement.contact_x)
and math.isfinite(measurement.contact_y)
and math.isfinite(measurement.contact_z)
):
contact_xyz_mm = (
measurement.contact_x,
measurement.contact_y,
measurement.contact_z,
)
log_cfg = layout.log_config log_cfg = layout.log_config
if log_cfg.status_enabled: if log_cfg.status_enabled:
self.log_handler.set_status_line( self.log_handler.set_status_line(
@@ -428,9 +516,66 @@ class FingerDashboardWindow(QMainWindow):
flow_rms=flow_mag, flow_rms=flow_mag,
mask_coverage=frame.mask_coverage, mask_coverage=frame.mask_coverage,
wall_fps=self._wall_fps, wall_fps=self._wall_fps,
contact_xyz_mm=contact_xyz_mm,
) )
) )
def _refresh_cameras(self, announce: bool = True) -> None:
"""Scan cameras and populate the settings dropdown."""
keep_file = self.settings_panel.is_file_source()
existing_file = self.settings_panel.video_source() if keep_file else ""
preferred = None
current = self.settings_panel.selected_camera_source()
saved = get_preferred_video_source(self._preferences)
cli_video = str(self.config.video).strip()
if current.isdigit():
preferred = current
elif saved and str(saved).isdigit():
preferred = str(saved)
elif cli_video.isdigit():
preferred = cli_video
cameras = list_cameras()
selected = pick_default_camera(cameras, preferred)
self.settings_panel.set_camera_options(cameras, selected)
if keep_file and existing_file:
self.settings_panel.set_video_source(existing_file)
elif selected:
self.settings_panel.set_video_source(selected)
self.config.video = selected
elif not keep_file:
self.config.video = ""
self._update_play_enabled_for_source()
self._save_preferences()
if announce:
if cameras:
names = ", ".join(cam.label() for cam in cameras)
print(f"已发现 {len(cameras)} 路摄像机:{names}")
if selected and not keep_file:
print(f"当前摄像机:{selected}")
else:
print("未发现可用摄像机。可点击 Refresh 重扫,或通过 … 选择视频文件。")
def _update_play_enabled_for_source(self) -> None:
if self._running:
return
usable = self.settings_panel.has_usable_source()
self.settings_panel.set_play_pause_state("run")
self.settings_panel.btn_play_pause.setEnabled(usable)
def _on_camera_selection_changed(self, camera_id: str) -> None:
camera_id = str(camera_id).strip()
if not camera_id:
return
# Explicit camera pick clears sticky file mode and persists the live source.
self.settings_panel.set_video_source(camera_id)
self.config.video = camera_id
self._save_preferences()
self._update_play_enabled_for_source()
def _select_camera(self) -> None: def _select_camera(self) -> None:
path, _ = QFileDialog.getOpenFileName( path, _ = QFileDialog.getOpenFileName(
self, self,
@@ -440,25 +585,99 @@ class FingerDashboardWindow(QMainWindow):
) )
if path: if path:
self.settings_panel.set_video_source(path) self.settings_panel.set_video_source(path)
self.config.video = path
self._save_preferences()
self._update_play_enabled_for_source()
def _select_config(self) -> None: def _select_config(self) -> None:
from .preferences import (
default_finger_config,
normalize_config_preference,
packaged_finger_bundle_root,
)
start = packaged_finger_bundle_root()
start_dir = str(start) if start is not None else "sensors/finger"
path, _ = QFileDialog.getOpenFileName( path, _ = QFileDialog.getOpenFileName(
self, self,
"选择配置文件", "选择配置文件",
"config", start_dir,
"JSON (*.json);;所有文件 (*)", "JSON (*.json);;所有文件 (*)",
) )
if path: if path:
self.settings_panel.set_config_path(path) config = normalize_config_preference(path) or default_finger_config()
self.settings_panel.set_config_path(config)
self.config.config = config
self._save_preferences()
def _select_finger_assets(self) -> None: def _select_finger_assets(self) -> None:
from .preferences import (
default_finger_assets_dir,
normalize_assets_preference,
packaged_finger_assets_root,
)
start = packaged_finger_assets_root()
start_dir = (
str(start)
if start is not None
else (self.settings_panel.finger_export_dir() or default_finger_assets_dir())
)
path = QFileDialog.getExistingDirectory( path = QFileDialog.getExistingDirectory(
self, self,
"选择 finger 资产目录", "选择 finger 资产目录",
self.settings_panel.finger_export_dir() or "assets/finger", start_dir,
) )
if path: if path:
self.settings_panel.set_finger_export_dir(path) assets = normalize_assets_preference(path)
self.settings_panel.set_finger_export_dir(assets)
self.config.export_dir = assets
self._save_preferences()
def _select_csv_dir(self) -> None:
path = QFileDialog.getExistingDirectory(
self,
"选择 CSV 保存目录",
self.settings_panel.csv_save_dir() or "./outputs/csv",
)
if path:
self.settings_panel.set_csv_save_dir(path)
self.config.csv_save_dir = path
self._save_preferences()
def _on_csv_record_toggled(self, start: bool) -> None:
if start:
self._start_csv_recording()
else:
self._stop_csv_recording(announce=True)
def _start_csv_recording(self) -> None:
if not self._running:
print("请先启动传感器后再记录 CSV。")
self.settings_panel.set_csv_recording(False)
return
save_dir = self.settings_panel.csv_save_dir() or "./outputs/csv"
self.config.csv_save_dir = save_dir
try:
path = self.csv_recorder.start(save_dir)
except OSError as exc:
print(f"无法开始 CSV 记录:{exc}")
self.settings_panel.set_csv_recording(False)
return
self.settings_panel.set_csv_recording(True)
print(f"开始 CSV 记录:{path}")
def _stop_csv_recording(self, *, announce: bool = False) -> None:
if not self.csv_recorder.is_recording:
self.settings_panel.set_csv_recording(False)
return
path, count = self.csv_recorder.stop()
self.settings_panel.set_csv_recording(False)
if announce:
if path is not None:
print(f"CSV 记录结束:{path},总行数:{count}")
else:
print(f"CSV 记录结束,总行数:{count}")
def keyPressEvent(self, event): def keyPressEvent(self, event):
key = event.key() key = event.key()
@@ -483,6 +702,8 @@ class FingerDashboardWindow(QMainWindow):
def closeEvent(self, event): def closeEvent(self, event):
self.timer.stop() self.timer.stop()
self._stop_csv_recording(announce=True)
self._save_preferences()
self.mesh_viewport.close_renderer() self.mesh_viewport.close_renderer()
self.pipeline.close() self.pipeline.close()
event.accept() event.accept()
@@ -490,9 +711,10 @@ class FingerDashboardWindow(QMainWindow):
def run_dashboard(config: PipelineConfig, *, layout: DashboardLayout = LAYOUT) -> int: def run_dashboard(config: PipelineConfig, *, layout: DashboardLayout = LAYOUT) -> int:
"""Launch the unified finger dashboard and block until the window closes.""" """Launch the unified finger dashboard and block until the window closes."""
from .app import get_or_create_qapp from gui.app import get_or_create_qapp
print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏") print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏")
print("GUI:Record CSV / Stop CSV 写入接触点与力到所选目录。")
if config.profile: if config.profile:
print(f"Profiling enabled (report every {config.profile_every} frames).") print(f"Profiling enabled (report every {config.profile_every} frames).")
print("Log FPS= is end-to-end dashboard rate; sdk= is Sensor internal rate.") print("Log FPS= is end-to-end dashboard rate; sdk= is Sensor internal rate.")
@@ -19,6 +19,9 @@ MeshLighting (below) controls the 3D mesh panel sun light:
MESH_BACKGROUND (below) sets the 3D mesh panel clear color (RGB 0–255). MESH_BACKGROUND (below) sets the 3D mesh panel clear color (RGB 0–255).
MESH_AXIS_OVERLAY (below) controls the fixed top-right pre-rendered 3D axis triad:
enabled, padding_px, size_px, opacity
MESH_CONTACT_ARROW (below) controls the yellow 3D contact arrow on the mesh panel: MESH_CONTACT_ARROW (below) controls the yellow 3D contact arrow on the mesh panel:
reference_normal_force — Fn at which arrow reaches full size (log/power/linear curve) reference_normal_force — Fn at which arrow reaches full size (log/power/linear curve)
force_strength_mode — "log" | "power" | "linear" force_strength_mode — "log" | "power" | "linear"
@@ -44,6 +47,7 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal
from gui.log_config import LOG_CONFIG, LogConfig
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
from viz.flow_arrow import FlowArrowStyle from viz.flow_arrow import FlowArrowStyle
from viz.render_2d import ArrowBackground, ArrowCanvasColors from viz.render_2d import ArrowBackground, ArrowCanvasColors
@@ -87,7 +91,7 @@ TOP_ROW = PanelRow(
PanelSlot("mesh", "3D mesh", width_px=400, height_px=400), PanelSlot("mesh", "3D mesh", width_px=400, height_px=400),
PanelSlot("arrows", "UV Unrolled", width_px=400, height_px=400, enabled=True), PanelSlot("arrows", "UV Unrolled", width_px=400, height_px=400, enabled=True),
PanelSlot("charts", "Force charts", width_px=300, height_px=400), PanelSlot("charts", "Force charts", width_px=300, height_px=400),
PanelSlot("settings", "Settings", width_px=200, height_px=0), PanelSlot("settings", "Settings", width_px=250, height_px=0),
), ),
) )
@@ -142,6 +146,20 @@ class MeshBackground:
MESH_BACKGROUND = MeshBackground(color=(0, 0, 0)) MESH_BACKGROUND = MeshBackground(color=(0, 0, 0))
@dataclass(frozen=True)
class MeshAxisOverlay:
"""Fixed pre-rendered XYZ triad HUD on the 3D mesh RGB output."""
enabled: bool = True
padding_px: int = 12
size_px: int = 96
opacity: float = 1.0
MESH_AXIS_OVERLAY = MeshAxisOverlay()
# 3D mesh contact arrow — edit here for dashboard tuning (see ContactArrowStyle in viz/contact_arrow.py). # 3D mesh contact arrow — edit here for dashboard tuning (see ContactArrowStyle in viz/contact_arrow.py).
MESH_CONTACT_ARROW = ContactArrowStyle( MESH_CONTACT_ARROW = ContactArrowStyle(
reference_normal_force=10000.0, reference_normal_force=10000.0,
@@ -176,46 +194,6 @@ ARROW_CANVAS_COLORS = ArrowCanvasColors(
) )
@dataclass(frozen=True)
class LogConfig:
"""Log panel — edit LOG_CONFIG or DashboardLayout.log_config."""
status_enabled: bool = True
max_lines: int = 2000
font_family: str = "Cascadia Mono"
font_pt: int = 12
show_flow: bool = True
show_mask: bool = True
def format_status(
self,
*,
fps: float,
fnormal: float,
fshearx: float,
fsheary: float,
flow_rms: float = 0.0,
mask_coverage: float = 0.0,
wall_fps: float | None = None,
) -> str:
shear_mag = float((fshearx**2 + fsheary**2) ** 0.5)
if wall_fps is not None:
parts = [f"FPS={wall_fps:.1f}", f"sdk={fps:.1f}"]
else:
parts = [f"FPS={fps:.1f}"]
parts.extend(
[
f"Fn={fnormal:.1f}",
f"|Fs|={shear_mag:.1f}",
f"Fs=({fshearx:.1f}, {fsheary:.1f})",
]
)
return " ".join(parts)
LOG_CONFIG = LogConfig()
@dataclass(frozen=True) @dataclass(frozen=True)
class DashboardLayout: class DashboardLayout:
"""Window chrome, chart styling, and panel hierarchy.""" """Window chrome, chart styling, and panel hierarchy."""
@@ -224,7 +202,7 @@ class DashboardLayout:
window_title: str = "Orisys Finger Dashboard" window_title: str = "Orisys Finger Dashboard"
window_width: int = 1300 window_width: int = 1300
window_height: int = 520 window_height: int = 560
root_margin_px: int = 6 root_margin_px: int = 6
root_spacing_px: int = 6 root_spacing_px: int = 6
@@ -249,6 +227,7 @@ class DashboardLayout:
mesh_background: MeshBackground = MESH_BACKGROUND mesh_background: MeshBackground = MESH_BACKGROUND
mesh_contact_arrow: ContactArrowStyle = MESH_CONTACT_ARROW mesh_contact_arrow: ContactArrowStyle = MESH_CONTACT_ARROW
mesh_flow_arrow: FlowArrowStyle = MESH_FLOW_ARROW mesh_flow_arrow: FlowArrowStyle = MESH_FLOW_ARROW
mesh_axis_overlay: MeshAxisOverlay = MESH_AXIS_OVERLAY
log_config: LogConfig = LOG_CONFIG log_config: LogConfig = LOG_CONFIG
+165
View File
@@ -0,0 +1,165 @@
"""Load/save dashboard user preferences for the finger 3D viewer."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from gui.app_paths import resource_path
PREFERENCE_FILE = "preference.json"
LEGACY_CAMERA_SELECTION_FILE = "camera_selection.json"
# Keep preference.json at examples/gui/ for backward compatibility.
_GUI_DIR = Path(__file__).resolve().parents[1]
# Old lab / repo-relative paths that predate packaged sensors/<name>/.
_LEGACY_CONFIG_PATHS = {
"dd02",
"dd02-ov",
"config/dd02-ov.json",
"./config/dd02-ov.json",
"config/finger.json",
"./config/finger.json",
"sensors/finger/sensor.json",
}
def preference_path() -> Path:
# Keep preference file at examples/gui/preference.json
return _GUI_DIR / PREFERENCE_FILE
def legacy_camera_selection_path() -> Path:
return resource_path(LEGACY_CAMERA_SELECTION_FILE)
def default_finger_config() -> str:
"""Public product config stem resolved via packaged ``sensors/finger``."""
return "finger"
def default_finger_assets_dir() -> str:
try:
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
return DEFAULT_FINGER_EXPORT_DIR
except ImportError:
return "sensors/finger/assets/30fps-0703"
def packaged_finger_bundle_root() -> Path | None:
"""Filesystem root of the installed ``sensors/finger`` bundle when present."""
try:
from orisys.configs import resolve_sensor_bundle
except ImportError:
return None
bundle = resolve_sensor_bundle("finger")
return bundle.root if bundle is not None else None
def packaged_finger_assets_root() -> Path | None:
try:
from orisys.configs import resolve_sensor_asset_dir
except ImportError:
return None
return resolve_sensor_asset_dir("finger")
def normalize_config_preference(value: str | None) -> str:
"""Map legacy config paths to the packaged ``finger`` sensor name."""
if value is None or not str(value).strip():
return default_finger_config()
text = str(value).strip().replace("\\", "/")
if text in _LEGACY_CONFIG_PATHS or text.endswith("/dd02-ov.json"):
return default_finger_config()
root = packaged_finger_bundle_root()
if root is not None:
try:
if Path(value).resolve() == (root / "sensor.json").resolve():
return default_finger_config()
except OSError:
pass
return text
def normalize_assets_preference(value: str | None) -> str:
"""Prefer packaged ``sensors/finger/assets/...`` when saved paths are stale."""
default = default_finger_assets_dir()
if value is None or not str(value).strip():
return default
text = str(value).strip()
posix = text.replace("\\", "/")
if posix == default or posix.endswith("/sensors/finger/assets/30fps-0703"):
return default
path = Path(text)
if path.is_dir():
packaged = packaged_finger_assets_root()
if packaged is not None:
try:
if path.resolve() == packaged.resolve():
return default
except OSError:
pass
# Keep a user-selected custom directory that still exists.
if "sensors/finger" in posix or path.name == "30fps-0703":
return text if path.is_absolute() else default
return text
# Missing old absolute/repo paths (assets/finger, ./config era) -> packaged default.
return default
def _normalize_preferences(prefs: dict[str, Any]) -> dict[str, Any]:
prefs = dict(prefs)
if "config_path" in prefs or prefs:
prefs["config_path"] = normalize_config_preference(prefs.get("config_path"))
if "finger_assets_dir" in prefs or prefs:
prefs["finger_assets_dir"] = normalize_assets_preference(prefs.get("finger_assets_dir"))
prefs.setdefault("sensor_bundle_dir", "sensors/finger")
return prefs
def _read_json(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def load_preferences() -> dict[str, Any]:
"""Load preferences, migrating camera_id / legacy config paths when needed."""
prefs = _read_json(preference_path())
if "video_source" not in prefs:
legacy = _read_json(legacy_camera_selection_path())
camera_id = legacy.get("camera_id")
if camera_id is not None and str(camera_id).strip():
prefs["video_source"] = str(camera_id).strip()
return _normalize_preferences(prefs)
def save_preferences(prefs: dict[str, Any]) -> None:
"""Write preferences atomically enough for dashboard use."""
if not isinstance(prefs, dict):
return
path = preference_path()
normalized = _normalize_preferences(prefs)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
except OSError:
pass
def get_preferred_video_source(prefs: dict[str, Any] | None = None) -> str | None:
data = prefs if prefs is not None else load_preferences()
value = data.get("video_source")
if value is None:
return None
text = str(value).strip()
return text or None
+143
View File
@@ -0,0 +1,143 @@
"""Finger-compatible sensor-bundle presets for the 3D dashboard."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
def _repo_root_candidates() -> list[Path]:
here = Path(__file__).resolve()
return [
Path.cwd(),
here.parents[3], # examples/gui/viewer3d -> repo root
]
@dataclass(frozen=True)
class SensorBundlePreset:
label: str
bundle_dir: str
config: str
export_dir: str
def _packaged_finger_preset() -> SensorBundlePreset | None:
"""Use logical names that resolve through the installed wheel sensors/ layout."""
try:
from orisys.configs import resolve_sensor_bundle
except ImportError:
return None
bundle = resolve_sensor_bundle("finger")
if bundle is None:
return None
return SensorBundlePreset(
label="ORY-FINGER",
bundle_dir="sensors/finger",
config="finger",
export_dir=DEFAULT_FINGER_EXPORT_DIR,
)
def _load_manifest(bundle_root: Path) -> dict:
path = bundle_root / "manifest.json"
if not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def _bundle_from_root(bundle_root: Path, *, label: str | None = None) -> SensorBundlePreset | None:
sensor_json = bundle_root / "sensor.json"
if not sensor_json.is_file():
return None
manifest = _load_manifest(bundle_root)
assets_dir = manifest.get("assets_dir", "assets/30fps-0703")
if not isinstance(assets_dir, str) or not assets_dir.strip():
assets_dir = "assets/30fps-0703"
rel_bundle = bundle_root.as_posix()
for root in _repo_root_candidates():
try:
rel_bundle = bundle_root.relative_to(root).as_posix()
break
except ValueError:
continue
# Packaged / checkout sensors/finger: keep resolvable product names, not absolute paths.
if rel_bundle.replace("\\", "/").endswith("sensors/finger") or bundle_root.name == "finger":
return SensorBundlePreset(
label=label or "ORY-FINGER",
bundle_dir="sensors/finger",
config="finger",
export_dir=DEFAULT_FINGER_EXPORT_DIR,
)
return SensorBundlePreset(
label=label or "ORY-FINGER",
bundle_dir=rel_bundle,
config=f"{rel_bundle}/sensor.json",
export_dir=f"{rel_bundle}/{assets_dir}".replace("\\", "/"),
)
def default_finger_bundle() -> SensorBundlePreset:
packaged = _packaged_finger_preset()
if packaged is not None:
return packaged
for root in _repo_root_candidates():
preset = _bundle_from_root(root / "sensors" / "finger", label="ORY-FINGER")
if preset is not None:
return preset
return SensorBundlePreset(
label="ORY-FINGER",
bundle_dir="sensors/finger",
config="finger",
export_dir=DEFAULT_FINGER_EXPORT_DIR,
)
def discover_sensor_bundle_presets() -> list[SensorBundlePreset]:
"""Return finger-compatible sensor bundle presets."""
presets = [default_finger_bundle()]
known_dirs = {presets[0].bundle_dir.replace("\\", "/")}
for root in _repo_root_candidates():
sensors_root = root / "sensors"
if not sensors_root.is_dir():
continue
for child in sorted(sensors_root.iterdir()):
if not child.is_dir():
continue
if child.name != "finger":
continue
preset = _bundle_from_root(child, label="ORY-FINGER")
if preset is None:
continue
key = preset.bundle_dir.replace("\\", "/")
if key in known_dirs:
continue
presets.append(preset)
known_dirs.add(key)
break
return presets
def sensor_bundle_preset_index(
config: str,
export_dir: str,
presets: list[SensorBundlePreset],
) -> int:
config = (config or "").strip().replace("\\", "/")
export_dir = (export_dir or "").strip().replace("\\", "/")
for idx, preset in enumerate(presets):
if config == preset.config and export_dir == preset.export_dir:
return idx
if config in ("finger", "dd02", "dd02-ov") and preset.config == "finger":
return idx
if config.endswith("/sensor.json") and export_dir.endswith("/" + Path(preset.export_dir).name):
return idx
return -1
+15
View File
@@ -0,0 +1,15 @@
"""Finger-specific Qt widgets for the 3D dashboard."""
from .arrow_image import ArrowPanelWidget
from .force_charts import ForceChartWidget
from .image_panel import ImagePanel
from .mesh_viewport import MeshViewportWidget
from .settings_panel import SettingsPanelWidget
__all__ = [
"ArrowPanelWidget",
"ForceChartWidget",
"ImagePanel",
"MeshViewportWidget",
"SettingsPanelWidget",
]
@@ -9,7 +9,7 @@ from viz.render_2d import ArrowBackground, ArrowCanvasColors, render_uv_arrows
from ..layout import DashboardLayout, LAYOUT from ..layout import DashboardLayout, LAYOUT
from .image_panel import ImagePanel from .image_panel import ImagePanel
from .qt_pixmap import ndarray_to_pixmap from gui.widgets.qt_pixmap import ndarray_to_pixmap
class ArrowPanelWidget(ImagePanel): class ArrowPanelWidget(ImagePanel):
@@ -6,8 +6,8 @@ import numpy as np
from PyQt5.QtGui import QFont from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import QSizePolicy from PyQt5.QtWidgets import QSizePolicy
from gui.layout import DashboardLayout, LAYOUT from ..layout import DashboardLayout, LAYOUT
from gui.styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
from pipeline.frame import FingerFrameView from pipeline.frame import FingerFrameView
from viz.signal_filters import lowpass_filter from viz.signal_filters import lowpass_filter
@@ -6,7 +6,7 @@ from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget from PyQt5.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
from .qt_pixmap import pixmap_keep_aspect from gui.widgets.qt_pixmap import pixmap_keep_aspect
class ImagePanel(QWidget): class ImagePanel(QWidget):
@@ -11,7 +11,7 @@ from viz.offscreen_mesh import OffscreenFingerMeshRenderer
from ..layout import DashboardLayout, LAYOUT from ..layout import DashboardLayout, LAYOUT
from ..mesh_view_state import MeshViewState from ..mesh_view_state import MeshViewState
from ..mesh_render_worker import MeshRenderWorker from ..mesh_render_worker import MeshRenderWorker
from .qt_pixmap import ndarray_to_pixmap from gui.widgets.qt_pixmap import ndarray_to_pixmap
from .image_panel import ImagePanel from .image_panel import ImagePanel
_VIEW_ROTATE_SENSITIVITY = 0.35 _VIEW_ROTATE_SENSITIVITY = 0.35
@@ -120,6 +120,7 @@ class MeshViewportWidget(ImagePanel):
mesh_background=self._layout.mesh_background.color, mesh_background=self._layout.mesh_background.color,
contact_arrow_style=self._layout.mesh_contact_arrow, contact_arrow_style=self._layout.mesh_contact_arrow,
flow_arrow_style=self._layout.mesh_flow_arrow, flow_arrow_style=self._layout.mesh_flow_arrow,
axis_overlay=self._layout.mesh_axis_overlay,
) )
self._renderer.flow_arrow.set_enabled(self._mesh_state.flow_arrows_enabled) self._renderer.flow_arrow.set_enabled(self._mesh_state.flow_arrows_enabled)
@@ -0,0 +1,476 @@
"""Settings side panel for the finger dashboard."""
from __future__ import annotations
from PyQt5.QtCore import QSize, Qt, pyqtSignal
from PyQt5.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
from viz.render_2d import ArrowBackground
from ..layout import DashboardLayout, LAYOUT
from ..mesh_view_state import MeshViewState
from ..transport_icons import reset_icon, transport_icon
_PLAY_PAUSE_BUTTON_NAME = "btnPlayPause"
_PLAY_PAUSE_ICON_PX = 18
_PLAY_PAUSE_ICON_COLORS = {
"run": "#4caf84",
"starting": "#555555",
"pause": "#ff9800",
}
class SettingsPanelWidget(QWidget):
"""Right-side settings: log, sources, and 3D view controls."""
log_visibility_changed = pyqtSignal(bool)
free_rotation_changed = pyqtSignal(bool)
arrow_background_changed = pyqtSignal(str)
view_reset_requested = pyqtSignal()
browse_video_requested = pyqtSignal()
browse_config_requested = pyqtSignal()
browse_finger_assets_requested = pyqtSignal()
browse_csv_dir_requested = pyqtSignal()
csv_record_toggled = pyqtSignal(bool)
camera_refresh_requested = pyqtSignal()
camera_selection_changed = pyqtSignal(str)
def __init__(
self,
parent=None,
*,
layout: DashboardLayout = LAYOUT,
mesh_state: MeshViewState | None = None,
show_log: bool = True,
):
super().__init__(parent)
self._layout = layout
self._mesh_state = mesh_state or MeshViewState.from_layout(layout)
self.setObjectName("settingsPanel")
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
root = QVBoxLayout(self)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(6)
title = QLabel(layout.settings_panel_title)
title.setObjectName("settingsTitle")
root.addWidget(title)
control_row = QHBoxLayout()
control_row.setSpacing(8)
self.btn_play_pause = QPushButton()
self.btn_play_pause.setObjectName(_PLAY_PAUSE_BUTTON_NAME)
self.btn_play_pause.setFixedSize(36, 32)
self.btn_play_pause.setIconSize(QSize(_PLAY_PAUSE_ICON_PX, _PLAY_PAUSE_ICON_PX))
control_row.addWidget(self.btn_play_pause)
self.btn_reset_tracker = QPushButton("Reset Sensor")
self.btn_reset_tracker.setToolTip("Reset tracker")
self.btn_reset_tracker.setEnabled(False)
control_row.addWidget(self.btn_reset_tracker, 1)
root.addLayout(control_row)
self.show_log_check = QCheckBox("Show log panel")
self.show_log_check.setChecked(show_log)
self.show_log_check.toggled.connect(self.log_visibility_changed.emit)
root.addWidget(self.show_log_check)
self.show_flow_arrows_check = QCheckBox("Show displacement arrows")
self.show_flow_arrows_check.setChecked(self._mesh_state.flow_arrows_enabled)
self.show_flow_arrows_check.toggled.connect(self._mesh_state.set_flow_arrows_enabled)
root.addWidget(self.show_flow_arrows_check)
root.addWidget(self._caption("UV arrow background"))
self.arrow_background_combo = QComboBox()
for label, value in (
("Unrolled", "unrolled"),
("Pure color", "pure_color"),
):
self.arrow_background_combo.addItem(label, value)
self.set_arrow_background(layout.arrow_background)
self.arrow_background_combo.currentIndexChanged.connect(self._emit_arrow_background_changed)
root.addWidget(self.arrow_background_combo)
root.addWidget(self._caption("Video source"))
video_row = QHBoxLayout()
video_row.setSpacing(6)
self.camera_combo = QComboBox()
self.camera_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.camera_combo.setToolTip("Select an available camera")
# activated fires on user pick even when the index is unchanged (file → live override).
self.camera_combo.activated.connect(self._on_camera_combo_changed)
self.camera_combo.currentIndexChanged.connect(self._on_camera_combo_changed)
video_row.addWidget(self.camera_combo, 1)
self.btn_refresh_cameras = QPushButton("Refresh")
self.btn_refresh_cameras.setFixedWidth(64)
self.btn_refresh_cameras.setToolTip("Re-scan available cameras")
self.btn_refresh_cameras.clicked.connect(self.camera_refresh_requested.emit)
video_row.addWidget(self.btn_refresh_cameras)
self.btn_browse_video = QPushButton("…")
self.btn_browse_video.setFixedWidth(32)
self.btn_browse_video.setToolTip("Browse video file")
self.btn_browse_video.clicked.connect(self.browse_video_requested.emit)
video_row.addWidget(self.btn_browse_video)
root.addLayout(video_row)
self.video_file_label = QLabel("")
self.video_file_label.setObjectName("settingsCaption")
self.video_file_label.setWordWrap(True)
self.video_file_label.hide()
root.addWidget(self.video_file_label)
self._source_mode = "camera" # "camera" | "file"
self._video_file_path = ""
self._updating_camera_options = False
root.addWidget(self._caption("Config"))
config_row = QHBoxLayout()
config_row.setSpacing(6)
self.config_edit = QLineEdit("finger")
config_row.addWidget(self.config_edit, 1)
self.btn_browse_config = QPushButton("…")
self.btn_browse_config.setFixedWidth(32)
self.btn_browse_config.setToolTip("Browse config JSON")
self.btn_browse_config.clicked.connect(self.browse_config_requested.emit)
config_row.addWidget(self.btn_browse_config)
root.addLayout(config_row)
# Optional calibration path is kept for future re-enablement but hidden for now.
self.cal_edit = QLineEdit("")
self.cal_edit.setPlaceholderText("Optional calibration path")
self.cal_edit.hide()
root.addWidget(self._caption("Finger assets"))
finger_assets_row = QHBoxLayout()
finger_assets_row.setSpacing(6)
self.finger_assets_edit = QLineEdit(DEFAULT_FINGER_EXPORT_DIR)
self.finger_assets_edit.setPlaceholderText("Blender export bundle directory")
finger_assets_row.addWidget(self.finger_assets_edit, 1)
self.btn_browse_finger_assets = QPushButton("…")
self.btn_browse_finger_assets.setFixedWidth(32)
self.btn_browse_finger_assets.setToolTip("Browse finger asset directory")
self.btn_browse_finger_assets.clicked.connect(self.browse_finger_assets_requested.emit)
finger_assets_row.addWidget(self.btn_browse_finger_assets)
root.addLayout(finger_assets_row)
root.addWidget(self._caption("CSV save directory"))
csv_dir_row = QHBoxLayout()
csv_dir_row.setSpacing(6)
self.csv_dir_edit = QLineEdit("./outputs/csv")
self.csv_dir_edit.setPlaceholderText("Directory for CSV recordings")
csv_dir_row.addWidget(self.csv_dir_edit, 1)
self.btn_browse_csv_dir = QPushButton("…")
self.btn_browse_csv_dir.setFixedWidth(32)
self.btn_browse_csv_dir.setToolTip("Browse CSV save directory")
self.btn_browse_csv_dir.clicked.connect(self.browse_csv_dir_requested.emit)
csv_dir_row.addWidget(self.btn_browse_csv_dir)
root.addLayout(csv_dir_row)
self.btn_csv_record = QPushButton("Record CSV")
self.btn_csv_record.setToolTip("Start writing contact/force CSV")
self.btn_csv_record.setEnabled(False)
self.btn_csv_record.clicked.connect(self._on_csv_toggle_clicked)
root.addWidget(self.btn_csv_record)
self._csv_recording = False
self._pipeline_running = False
root.addSpacing(4)
rotate_row = QHBoxLayout()
rotate_row.setSpacing(6)
self.free_rotate_check = QCheckBox("Free rotate 3D view")
self.free_rotate_check.toggled.connect(self.free_rotation_changed.emit)
rotate_row.addWidget(self.free_rotate_check, 1)
self.btn_reset_view = QPushButton()
self.btn_reset_view.setObjectName("btnResetView")
self.btn_reset_view.setToolTip("Reset 3D mesh camera")
self.btn_reset_view.setIcon(reset_icon("#999999"))
self.btn_reset_view.setIconSize(QSize(14, 14))
self.btn_reset_view.setFixedSize(28, 28)
self.btn_reset_view.clicked.connect(self.view_reset_requested.emit)
rotate_row.addWidget(self.btn_reset_view)
root.addLayout(rotate_row)
root.addStretch()
@staticmethod
def _caption(text: str) -> QLabel:
label = QLabel(text)
label.setObjectName("settingsCaption")
return label
def set_panel_size(self, width: int, height: int) -> None:
self.setFixedSize(max(int(width), 64), max(int(height), 64))
def set_play_pause_state(self, state: str) -> None:
"""``run`` = green play, ``starting`` = grey disabled play, ``pause`` = orange pause."""
btn = self.btn_play_pause
if state == "run":
btn.setToolTip("Run")
btn.setEnabled(True)
elif state == "starting":
btn.setToolTip("Starting…")
btn.setEnabled(False)
elif state == "pause":
btn.setToolTip("Pause")
btn.setEnabled(True)
else:
raise ValueError(f"unknown play/pause state: {state!r}")
kind = "pause" if state == "pause" else "play"
btn.setText("")
btn.setIcon(transport_icon(kind, _PLAY_PAUSE_ICON_COLORS[state]))
btn.setProperty("playState", state)
style = btn.style()
style.unpolish(btn)
style.polish(btn)
btn.update()
def set_camera_options(self, cameras, selected_value: str | None = None) -> None:
"""Populate camera combo from discovered cameras.
``cameras`` items may be objects with ``.source`` / ``.label()`` or
``(source, label)`` tuples.
"""
self._updating_camera_options = True
self.camera_combo.blockSignals(True)
self.camera_combo.clear()
sources: list[str] = []
for cam in cameras:
if hasattr(cam, "source") and callable(getattr(cam, "label", None)):
source = str(cam.source)
label = str(cam.label())
else:
source, label = cam
source = str(source)
label = str(label)
self.camera_combo.addItem(label, source)
sources.append(source)
preferred = (selected_value or "").strip()
idx = self.camera_combo.findData(preferred) if preferred else -1
if idx < 0 and self.camera_combo.count() > 0:
idx = 0
if idx >= 0:
self.camera_combo.setCurrentIndex(idx)
# File mode is cleared only when we intentionally select a camera.
if self._source_mode != "file":
self._source_mode = "camera"
self._video_file_path = ""
self.video_file_label.hide()
self.camera_combo.setEnabled(self.camera_combo.count() > 0)
self.camera_combo.blockSignals(False)
self._updating_camera_options = False
def set_video_source(self, value: str) -> None:
"""Set current video source (camera index string or file path)."""
value = (value or "").strip()
if not value:
return
# Numeric camera index → select in combo if present; else keep as preferred text via adding.
if value.isdigit() or (value.startswith("-") and value[1:].isdigit()):
idx = self.camera_combo.findData(value)
if idx >= 0:
self._source_mode = "camera"
self._video_file_path = ""
self.video_file_label.hide()
self.camera_combo.blockSignals(True)
self.camera_combo.setCurrentIndex(idx)
self.camera_combo.blockSignals(False)
else:
# Not yet scanned / not present: remember as pending camera selection.
self._source_mode = "camera"
self._video_file_path = ""
self.video_file_label.hide()
return
# Treat as video file path.
self._source_mode = "file"
self._video_file_path = value
self.video_file_label.setText(f"File: {value}")
self.video_file_label.show()
def selected_camera_source(self) -> str:
data = self.camera_combo.currentData()
return str(data) if data is not None else ""
def video_source(self) -> str:
if self._source_mode == "file" and self._video_file_path:
return self._video_file_path
return self.selected_camera_source()
def has_usable_source(self) -> bool:
if self._source_mode == "file" and self._video_file_path:
return True
return bool(self.selected_camera_source())
def is_file_source(self) -> bool:
return self._source_mode == "file" and bool(self._video_file_path)
def set_camera_controls_enabled(self, enabled: bool) -> None:
self.btn_refresh_cameras.setEnabled(enabled)
self.btn_browse_video.setEnabled(enabled)
self.camera_combo.setEnabled(enabled and self.camera_combo.count() > 0)
def _on_camera_combo_changed(self, _index: int) -> None:
if self._updating_camera_options:
return
source = self.selected_camera_source()
if not source:
return
self._source_mode = "camera"
self._video_file_path = ""
self.video_file_label.hide()
self.camera_selection_changed.emit(source)
def set_config_path(self, value: str) -> None:
self.config_edit.setText(value)
def set_calibration_path(self, value: str) -> None:
self.cal_edit.setText(value or "")
def set_finger_export_dir(self, value: str) -> None:
self.finger_assets_edit.setText(value)
def set_finger_export_dir_enabled(self, enabled: bool) -> None:
self.finger_assets_edit.setEnabled(enabled)
self.btn_browse_finger_assets.setEnabled(enabled)
def set_csv_save_dir(self, value: str) -> None:
self.csv_dir_edit.setText(value)
def set_csv_controls_for_pipeline(self, running: bool) -> None:
"""Update CSV controls from pipeline start/stop."""
self._pipeline_running = bool(running)
if not self._pipeline_running:
self._csv_recording = False
self._refresh_csv_controls()
def set_csv_recording(self, recording: bool) -> None:
"""Update CSV controls after a successful start/stop of recording."""
self._csv_recording = bool(recording) and self._pipeline_running
self._refresh_csv_controls()
def _refresh_csv_controls(self) -> None:
self.btn_csv_record.setEnabled(self._pipeline_running)
if self._csv_recording:
self.btn_csv_record.setText("Stop CSV")
self.btn_csv_record.setToolTip("Stop CSV recording")
self.btn_csv_record.setProperty("recording", True)
else:
self.btn_csv_record.setText("Record CSV")
self.btn_csv_record.setToolTip("Start writing contact/force CSV")
self.btn_csv_record.setProperty("recording", False)
style = self.btn_csv_record.style()
style.unpolish(self.btn_csv_record)
style.polish(self.btn_csv_record)
self.btn_csv_record.update()
dir_enabled = not self._csv_recording
self.csv_dir_edit.setEnabled(dir_enabled)
self.btn_browse_csv_dir.setEnabled(dir_enabled)
def _on_csv_toggle_clicked(self) -> None:
if not self._pipeline_running:
return
self.csv_record_toggled.emit(not self._csv_recording)
def _emit_arrow_background_changed(self, _index: int) -> None:
value = self.arrow_background_combo.currentData()
if value:
self.arrow_background_changed.emit(value)
def set_arrow_background(self, value: ArrowBackground) -> None:
idx = self.arrow_background_combo.findData(value)
if idx < 0:
return
self.arrow_background_combo.blockSignals(True)
self.arrow_background_combo.setCurrentIndex(idx)
self.arrow_background_combo.blockSignals(False)
def arrow_background(self) -> ArrowBackground:
value = self.arrow_background_combo.currentData()
return value if value else "unrolled"
def config_path(self) -> str:
return self.config_edit.text().strip()
def calibration_path(self) -> str:
return self.cal_edit.text().strip()
def finger_export_dir(self) -> str:
return self.finger_assets_edit.text().strip()
def csv_save_dir(self) -> str:
return self.csv_dir_edit.text().strip()
def preference_state(self) -> dict:
"""Serialize user-facing settings suitable for preference.json."""
return {
"video_source": self.video_source(),
"config_path": self.config_path(),
"finger_assets_dir": self.finger_export_dir(),
"csv_save_dir": self.csv_save_dir(),
"show_log": bool(self.show_log_check.isChecked()),
"show_flow_arrows": bool(self.show_flow_arrows_check.isChecked()),
"arrow_background": self.arrow_background(),
"free_rotate": bool(self.free_rotate_check.isChecked()),
}
def apply_preference_state(self, state: dict | None) -> None:
"""Restore user-facing settings from preference.json."""
if not isinstance(state, dict) or not state:
return
show_log = state.get("show_log")
if isinstance(show_log, bool):
self.show_log_check.blockSignals(True)
self.show_log_check.setChecked(show_log)
self.show_log_check.blockSignals(False)
show_flow = state.get("show_flow_arrows")
if isinstance(show_flow, bool):
self.show_flow_arrows_check.blockSignals(True)
self.show_flow_arrows_check.setChecked(show_flow)
self.show_flow_arrows_check.blockSignals(False)
self._mesh_state.set_flow_arrows_enabled(show_flow)
arrow_bg = state.get("arrow_background")
if isinstance(arrow_bg, str) and arrow_bg:
self.set_arrow_background(arrow_bg) # type: ignore[arg-type]
free_rotate = state.get("free_rotate")
if isinstance(free_rotate, bool):
self.free_rotate_check.blockSignals(True)
self.free_rotate_check.setChecked(free_rotate)
self.free_rotate_check.blockSignals(False)
config_path = state.get("config_path")
if isinstance(config_path, str) and config_path.strip():
self.set_config_path(config_path.strip())
assets = state.get("finger_assets_dir")
if isinstance(assets, str) and assets.strip():
self.set_finger_export_dir(assets.strip())
csv_dir = state.get("csv_save_dir")
if isinstance(csv_dir, str) and csv_dir.strip():
self.set_csv_save_dir(csv_dir.strip())
video = state.get("video_source")
if isinstance(video, str) and video.strip():
self.set_video_source(video.strip())

Some files were not shown because too many files have changed in this diff Show More