Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75fa98eff0 | |||
| bc059e3539 | |||
| 5be2d9759a | |||
| 32823a7666 | |||
| 26168852ef | |||
| 36e0c22de6 | |||
| a387b8b693 | |||
| 8a4be5f8f2 | |||
| 68910e63c1 | |||
| 577e3fe528 | |||
| 32d4164471 | |||
| d59e1cb7fb | |||
| 2cb152b76e | |||
| ab97efe039 | |||
| 135857b6de | |||
| 8a7760f12b | |||
| 4583dc3a40 | |||
| 1ceb0d9ca4 | |||
| 44bcc19426 | |||
| 2e09c9360e | |||
| 93b4517ebf | |||
| 9a69f01278 | |||
| 4acdce4456 | |||
| d7c5845646 | |||
| 3117ddd5b9 | |||
| 8b2f76c76f | |||
| 8d1772300f | |||
| 044fe02107 | |||
| b154a3acbb | |||
| 2e3d0a0609 | |||
| 28e6c7c8a9 | |||
| ee3ef3e961 | |||
| 62bc8dbff8 | |||
| 0e7fd2eeaf | |||
| 11a747a771 | |||
| 7b12d17595 | |||
| 87ac152907 | |||
| 3d1509df9d | |||
| 9ee6ab9470 | |||
| a9c26fd1fb | |||
| 30f9deb5b2 | |||
| fe6c880473 | |||
| d58fa1caa2 | |||
| 4b38dbb6b6 | |||
| 3d1039b52c | |||
| c2fb0b12b5 | |||
| c77a64ffe7 | |||
| 8c5a20b5cb | |||
| 8706e405d7 | |||
| 68b6dadff4 | |||
| edc6e42191 | |||
| 63f9622efb | |||
| 42e2264f06 | |||
| 28f4663a5f |
+5
-1
@@ -5,8 +5,12 @@ __pycache__/
|
||||
|
||||
# Packaging / build outputs
|
||||
/build/nuitka/
|
||||
/build/lib/orisys/
|
||||
/build/build/
|
||||
/build/bdist.*/
|
||||
/build/dist/
|
||||
/build/lib/
|
||||
/build/pyinstaller_work/
|
||||
/build/*.spec
|
||||
/dist/
|
||||
/dist_obfuscated/
|
||||
/dist_pyd/
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
and several runnable examples for camera and video-based workflows.
|
||||
|
||||
PyPI: [https://pypi.org/project/orisys/](https://pypi.org/project/orisys/)
|
||||
|
||||
## Repository Layout
|
||||
|
||||
- `src/orisys/`: installable SDK package
|
||||
- `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
|
||||
- `tools/`: non-package tooling such as patch-generation utilities
|
||||
- `tests/`: automated pytest suite
|
||||
@@ -49,38 +51,67 @@ pytest
|
||||
|
||||
### 4. Run an example
|
||||
|
||||
ORY-BOX:
|
||||
|
||||
```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
|
||||
|
||||
The canonical configuration files now live in the repo-level `config/` folder.
|
||||
The package loader `orisys.configs` resolves configs from:
|
||||
|
||||
1. an explicit JSON file path
|
||||
2. a config file stem under `config/`
|
||||
3. a backward-compatible JSON `"name"` match, such as `"prototype"`
|
||||
2. a config file stem under `config/` (for example `box`, `mini`, `finger`)
|
||||
3. a product alias such as `dd02` -> `dd02-ov`
|
||||
4. a backward-compatible JSON `"name"` match, such as `"prototype"`
|
||||
|
||||
Examples:
|
||||
|
||||
```python
|
||||
import orisys
|
||||
|
||||
sensor = orisys.Sensor(0, config_name="ddjx01")
|
||||
cfg = orisys.export_config("ddjx01", "./config/exported_config.json")
|
||||
sensor = orisys.Sensor(0, config_name="box")
|
||||
cfg = orisys.export_config("box", "./config/exported_config.json")
|
||||
```
|
||||
|
||||
## Examples and Tools
|
||||
|
||||
- Example entry points: `examples/README.md`
|
||||
- User documentation: `docs/get_started.md`
|
||||
- Packaging helper: `build/build_nuitka.py` (compiled SDK wheel)
|
||||
- GUI standalone folder: `build/build_gui_win.bat` → `dist/OrisysDashboard/`
|
||||
- Build launcher: `python build/run.py` (see `build/README.md`)
|
||||
- Manylinux / PyPI binary wheel: `docs/publishing.md`
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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 |
@@ -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',
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared build utilities used by wheel and GUI packaging targets."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
import importlib
|
||||
import ast
|
||||
import importlib.util
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
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:
|
||||
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:
|
||||
"""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(".")
|
||||
if not attr_name:
|
||||
raise RuntimeError(f"Invalid dynamic version attr: {attr!r}")
|
||||
module = importlib.import_module(module_name)
|
||||
value = module
|
||||
for name in attr_name.split("."):
|
||||
value = getattr(value, name)
|
||||
return str(value)
|
||||
|
||||
# Avoid importing package modules here: __init__ may import heavy runtime deps
|
||||
# (e.g., cv2/numpy) that are not required just to read __version__.
|
||||
rel_module = Path(*module_name.split("."))
|
||||
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:
|
||||
@@ -77,8 +103,12 @@ def bundle_items(repo_root: Path) -> list[dict]:
|
||||
|
||||
|
||||
def bundle_data_files(repo_root: Path) -> list[tuple[str, list[str]]]:
|
||||
"""Map bundle items marked dev_install=true to setuptools data_files entries."""
|
||||
from wheel_layout import resolve_bundle_source_files
|
||||
"""Map bundle items marked ``dev_install=true`` to install destinations.
|
||||
|
||||
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]] = {}
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -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"]
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
@@ -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])
|
||||
@@ -0,0 +1 @@
|
||||
"""Standalone GUI packaging targets (PyInstaller)."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared helpers for product GUI builds."""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""ORY-BOX internal calibration/testing GUI packaging (scaffold)."""
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""ORY-FINGER standalone GUI packaging."""
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""ORY-MINI internal calibration/testing GUI packaging (scaffold)."""
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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())
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Nuitka-compiled SDK wheel packaging."""
|
||||
@@ -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 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 = (
|
||||
"orisys",
|
||||
"orisys.capture",
|
||||
"orisys.finger",
|
||||
"orisys.stitch",
|
||||
"orisys.stitch.backends",
|
||||
"orisys.unroll",
|
||||
"orisys.hhd",
|
||||
"orisys.hhd.solvers",
|
||||
)
|
||||
|
||||
NUITKA_NOFOLLOW_IMPORTS = (
|
||||
# Keep as runtime deps (shipped via wheel install_requires / extras).
|
||||
"numpy",
|
||||
"scipy",
|
||||
"cv2",
|
||||
"numba",
|
||||
"orisys_blend",
|
||||
"cupy",
|
||||
"cupyx",
|
||||
"pyopencl",
|
||||
"open3d",
|
||||
"PyQt5",
|
||||
"pyqtgraph",
|
||||
)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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."
|
||||
@@ -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
|
||||
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]:
|
||||
"""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
|
||||
|
||||
|
||||
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]:
|
||||
"""Return setuptools package_data paths relative to the orisys package."""
|
||||
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]:
|
||||
verify_required_assets(repo_root, items)
|
||||
copied_counts: dict[str, int] = {}
|
||||
for item in items:
|
||||
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]]:
|
||||
package_data: dict[str, list[str]] = {
|
||||
"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:
|
||||
if item["name"] == "examples":
|
||||
continue
|
||||
package_data["orisys"].extend(package_data_paths_for_item(item))
|
||||
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
|
||||
@@ -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).
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
cd /d "%~dp0\.."
|
||||
cd /d "%~dp0\..\.."
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Could not change to repository root.
|
||||
exit /b 1
|
||||
@@ -32,7 +32,7 @@ for %%E in (py310 py311 py312) do (
|
||||
|
||||
set "OK=0"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -44,7 +44,7 @@ for %%E in (py310 py311 py312) do (
|
||||
if not exist "!PYEXE!" (
|
||||
echo ERROR: Python not found: !PYEXE!
|
||||
) else (
|
||||
"!PYEXE!" build\build_nuitka.py
|
||||
"!PYEXE!" build\wheel\build.py
|
||||
if not errorlevel 1 set "OK=1"
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
"min_area_frac": 0.01,
|
||||
"max_area_frac": 0.04,
|
||||
"max_aspect_ratio": 100,
|
||||
"max_area_frac": 0.05,
|
||||
"max_aspect_ratio": 50,
|
||||
"index": [1,2,3,6,7,8,11,12,13],
|
||||
"margin": 10,
|
||||
"nominal_ov_pos": 100,
|
||||
"n": [3,3],
|
||||
"row_tol": 0.6,
|
||||
"box_size": [220, 220]
|
||||
"box_size": [250, 250],
|
||||
"blend":"numba",
|
||||
"blend_list": ["numpy", "numba","none"]
|
||||
},
|
||||
"tracker": {
|
||||
"adaptive": true,
|
||||
@@ -33,11 +35,11 @@
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
"flow_threshold":1,
|
||||
"div_threshold":0.02,
|
||||
"flow_threshold":5,
|
||||
"div_threshold":0.05,
|
||||
"expansion_threshold": 0.01,
|
||||
"kernel_size": [5,5],
|
||||
"denoise_sigma": 15
|
||||
"denoise_sigma": 12
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -18,7 +18,9 @@
|
||||
"nominal_ov_pos": 100,
|
||||
"n": [3,3],
|
||||
"row_tol": 0.6,
|
||||
"box_size": [250, 250]
|
||||
"box_size": [250, 250],
|
||||
"blend":"numba",
|
||||
"blend_list": ["numpy", "numba","none"]
|
||||
},
|
||||
"tracker": {
|
||||
"adaptive": true,
|
||||
@@ -29,7 +31,7 @@
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 3,
|
||||
"algorithm_list": ["raft", "dis"],
|
||||
"hhd_downsample": 3
|
||||
"hhd_downsample": 1
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"nominal_ov_pos": 44,
|
||||
"n": [3,3],
|
||||
"row_tol": 0.6,
|
||||
"box_size": [98, 98]
|
||||
"box_size": [98, 98],
|
||||
"blend":"numpy",
|
||||
"blend_list": ["numpy", "numba","none"]
|
||||
},
|
||||
"tracker": {
|
||||
"adaptive": true,
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"nominal_ov_pos": 67,
|
||||
"n": [3,3],
|
||||
"row_tol": 0.6,
|
||||
"box_size": [180, 180]
|
||||
"box_size": [180, 180],
|
||||
"blend":"numpy",
|
||||
"blend_list": ["numpy", "numba","none"]
|
||||
},
|
||||
"tracker": {
|
||||
"adaptive": true,
|
||||
@@ -29,7 +31,7 @@
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 3,
|
||||
"algorithm_list": ["raft", "dis"],
|
||||
"hhd_downsample": 3,
|
||||
"hhd_downsample": 1,
|
||||
"backend": "cpu"
|
||||
},
|
||||
"contact": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
"units": 15,
|
||||
"units_used": 9,
|
||||
"resolution_stitch": [384, 384]
|
||||
"resolution_stitch": [365, 365],
|
||||
"stitch_res_list": [365,263,123,63]
|
||||
},
|
||||
"stitcher": {
|
||||
"min_area_frac": 0.01,
|
||||
"max_area_frac": 0.04,
|
||||
"max_aspect_ratio": 100,
|
||||
"max_area_frac": 0.05,
|
||||
"max_aspect_ratio": 50,
|
||||
"index": [1,2,3,6,7,8,11,12,13],
|
||||
"margin": 10,
|
||||
"nominal_ov_pos": 100,
|
||||
"n": [3,3],
|
||||
"row_tol": 0.6,
|
||||
"box_size": [220, 220]
|
||||
"box_size": [250, 250],
|
||||
"blend":"numba",
|
||||
"blend_list": ["numpy", "numba","none"]
|
||||
},
|
||||
"tracker": {
|
||||
"adaptive": true,
|
||||
"lower_threshold": 12.0,
|
||||
"upper_threshold": 18.0,
|
||||
"loop_closure_interval": 1,
|
||||
"algorithm": "raft",
|
||||
"algorithm": "dis",
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 3,
|
||||
"algorithm_list": ["raft", "dis"],
|
||||
@@ -32,11 +35,11 @@
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
"flow_threshold":1.5,
|
||||
"div_threshold":0.08,
|
||||
"flow_threshold":5,
|
||||
"div_threshold":0.05,
|
||||
"expansion_threshold": 0.01,
|
||||
"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
@@ -1,86 +1,30 @@
|
||||
# orisys API 文档
|
||||
---
|
||||
title: API Reference
|
||||
description: Orisys SDK Python API 中文参考
|
||||
---
|
||||
|
||||
本文件提供了 `orisys` 套件的中文文档
|
||||
# API Reference
|
||||
|
||||
## 准备 Python 开发环境
|
||||
推荐下载 Anaconda,并使用Python 3.10
|
||||
```
|
||||
# 创建并激活虚拟环境
|
||||
conda create -n orisys python=3.10
|
||||
conda activate orisys
|
||||
```
|
||||
## 安装 CUDA Toolkit 和 cuDNN
|
||||
在官方链接下载和安装:https://developer.nvidia.com/cuda/toolkit
|
||||
或
|
||||
在Anaconda环境中安装(例子使用cuda12.9):
|
||||
https://anaconda.org/channels/nvidia/packages/cuda/overview
|
||||
```
|
||||
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
|
||||
```
|
||||
## SDK安装方法
|
||||
1. 根据系统(win / linux),平台(amd64, aarch64), python(如p38、p39、p310) 版本下载对应的`.whl`文件
|
||||
2. 运行pip命令安装:
|
||||
```bash
|
||||
pip install orisys-{version}-{python-version}-{python-version}-{os}_{machine}.whl
|
||||
```
|
||||
如
|
||||
```bash
|
||||
pip install orisys-0.4.0-cp310-cp310-win_amd64.whl
|
||||
```
|
||||
本文件提供 `orisys` 套件的 Python API 中文参考。安装、CUDA、CuPy 与运行示例的基础步骤请参考 [快速开始](./get_started.md)。
|
||||
|
||||
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. 测试导入:
|
||||
```python
|
||||
import orisys
|
||||
print(orisys.__version__)
|
||||
```
|
||||
|
||||
5 安装opencl(实验):
|
||||
|
||||
在不带nvidia显卡的设备,可选择通过opencl进行gpu 计算加速,需要确保opencl的库或驱动已经安装,然后在conda环境中安装依赖:
|
||||
|
||||
``` bash
|
||||
pip install pyopencl
|
||||
conda install conda-forge::pyvkfft
|
||||
```
|
||||
|
||||
***
|
||||
|
||||
## `Sensor` 类别
|
||||
### 描述
|
||||
### `Sensor` 类别
|
||||
#### 描述
|
||||
`orisys`套件的核心类,用于从视频文件或实时摄像头中读取传感器图像,创建一个具体的传感器对象(实例)。并完成形变、光流及力信息的计算与管理。
|
||||
|
||||
### 输入参数
|
||||
#### 输入参数
|
||||
|
||||
* **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。
|
||||
* **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `ddjx01` 或 `./config/ddjx01.json`。
|
||||
* **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `box` 或 `./config/box.json`。
|
||||
* **cal_path** (`str`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。
|
||||
* **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。
|
||||
* **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。
|
||||
|
||||
|
||||
### 返回
|
||||
#### 返回
|
||||
* `Sensor` 实例,用于后续图像采集、形变计算和数据读取操作。
|
||||
### 示例
|
||||
#### 示例
|
||||
|
||||
```python
|
||||
# Example 1: 用相机编号创建
|
||||
@@ -91,63 +35,62 @@ sensor = Sensor(0)
|
||||
sensor = Sensor("/data/data.mp4")
|
||||
```
|
||||
|
||||
***
|
||||
### `Sensor.get_img` 方法
|
||||
|
||||
## 2. `Sensor.get_img` 方法
|
||||
|
||||
### 描述
|
||||
#### 描述
|
||||
|
||||
采集并预处理一帧图像并存到`Sensor` 类别中。
|
||||
|
||||
|
||||
### 返回
|
||||
#### 返回
|
||||
* `bool`:是否成功读取图像。
|
||||
|
||||
### 状态更新
|
||||
#### 状态更新
|
||||
* 成功时更新`sensor`类里面的`sensor.img`。处理后的BGR格式图像(`uint8`,尺寸为json文件中`resolution_stitch`对应分辨率)。
|
||||
### 示例
|
||||
#### 示例
|
||||
|
||||
```python
|
||||
sensor.get_img()
|
||||
```
|
||||
***
|
||||
### `Sensor.compute_deformation` 方法
|
||||
|
||||
## 3. `Sensor.compute_deformation` 方法
|
||||
|
||||
### 描述
|
||||
#### 描述
|
||||
|
||||
基于当前帧图像计算光流/形变向量场,并通过 HHD 分解将形变场分解为法向/切向分量,同时估计法向力、切向力与扭矩等标量输出。该方法会更新
|
||||
`sensor`的形变场与力相关状态,并返回当前帧的光流场。
|
||||
调用前置条件:需要先通过`get_img()`成功更新图像。
|
||||
|
||||
### 输入参数
|
||||
* **check_motion**(`bool`,可选): 为 `True` 时先根据光流判断是否有运动,有运动才做 HHD 分解;为 `False` 时每帧都做 HHD。默认 `True`。
|
||||
* **threshold**(`float`,可选): **check_motion** 功能的阈值,默认为`6`。
|
||||
* **tail_frames** (`int`,可选): 光流变化低于阈值后继续解算多少帧,确保传感器回复,,默认为 `10`.
|
||||
#### 输入参数
|
||||
* **decompose** (`bool`,可选): 是否在计算光流后继续执行 HHD 分解,默认 `True`。
|
||||
* **check_motion** (`bool`,可选): 是否启用运动检测门控。当前不推荐依赖该功能;新代码建议显式传入 `False`,让每帧都执行 HHD 分解。为兼容旧代码,函数默认值仍为 `True`。
|
||||
|
||||
### 返回
|
||||
#### 兼容参数(暂不推荐)
|
||||
|
||||
* `bool`:是否接触到物体,如果**check_motion**为`False`时返回`None`。
|
||||
以下参数只在 `check_motion=True` 时生效。由于运动检测门控目前不稳定,建议在新代码中暂时不要使用:
|
||||
|
||||
### 示例
|
||||
* **threshold** (`float`,可选): 运动检测阈值,默认 `6`。
|
||||
* **tail_frames** (`int`,可选): 运动低于阈值后继续解算多少帧,默认 `10`。
|
||||
|
||||
#### 返回
|
||||
|
||||
* `None`: 当 `check_motion=False` 时返回。推荐用法下不要依赖该返回值判断接触状态。
|
||||
* `bool`: 仅当 `check_motion=True` 时返回运动检测结果;该模式目前暂不推荐。
|
||||
|
||||
#### 示例
|
||||
|
||||
```python
|
||||
sensor.get_img()
|
||||
|
||||
is_touched = sensor.compute_deformation(check_motion=True, threshold=6)
|
||||
# 步骤 2.3: 计算触碰点
|
||||
if is_touched:
|
||||
sensor.compute_contact()
|
||||
sensor.compute_deformation(check_motion=False)
|
||||
sensor.compute_contact()
|
||||
```
|
||||
***
|
||||
## 4. `Sensor.compute_contact` 方法
|
||||
### `Sensor.compute_contact` 方法
|
||||
|
||||
### 描述
|
||||
#### 描述
|
||||
基于已计算的法向形变场进行接触区域分析,输出接触质心与轮廓等信息,并生成接触深度图,默认配置在`json`文件中,可按需调节。
|
||||
|
||||
调用前置条件:需先执行`compute_deformation()`。
|
||||
|
||||
### 返回
|
||||
#### 返回
|
||||
`dict`为接触分析结果字典,至少包含以下键:
|
||||
|
||||
* **expansion_mask**: 扩张区域掩码(`np.ndarray`)。
|
||||
@@ -157,12 +100,12 @@ if is_touched:
|
||||
* **centroid_found**: 是否检测到接触区域(`bool`)。
|
||||
* **contour**: 最大轮廓或轮廓列表(可选字段,存在时返回)。
|
||||
|
||||
### 状态更新
|
||||
#### 状态更新
|
||||
|
||||
* **sensor.centroid**: 接触质心(单点为 `(x, y)`,多点为列表)。
|
||||
* **sensor.centroid_found**: 是否找到质心(`bool`)。
|
||||
|
||||
### 示例
|
||||
#### 示例
|
||||
```python
|
||||
sensor.compute_contact()
|
||||
centroid, contour, depth = sensor.read_info(
|
||||
@@ -171,19 +114,18 @@ centroid, contour, depth = sensor.read_info(
|
||||
sensor.info.DEPTH,
|
||||
)
|
||||
```
|
||||
***
|
||||
## 4. `Sensor.read_info` 方法
|
||||
### `Sensor.read_info` 方法
|
||||
|
||||
### 描述
|
||||
#### 描述
|
||||
|
||||
按指定的信息类型(`sensor.info`枚举值)批量读取传感器状态数据,并按传入顺序返回对应值的元组。
|
||||
该方法通常用于在完成形变计算后,读取光流场、分量场、力、帧率等信息。
|
||||
|
||||
### 输入参数
|
||||
#### 输入参数
|
||||
|
||||
* **args**: 任意数量的 `Sensor.info` 枚举,用于指定需要获取的数据类型。支持的枚举值及对应数据如下:
|
||||
|
||||
### 支持的枚 info keys:
|
||||
#### 支持的 info keys
|
||||
|
||||
向量/图像类
|
||||
|
||||
@@ -197,6 +139,9 @@ centroid, contour, depth = sensor.read_info(
|
||||
* FNORMAL: 法向力,`float`。
|
||||
* FSHEARX: X切向力,`float`。
|
||||
* FSHEARY: Y切向力,`float`。
|
||||
* FNORMAL_RAW: 未经过低通滤波的原始法向力,`float`。
|
||||
* FSHEARX_RAW: 未经过低通滤波的原始 X 切向力,`float`。
|
||||
* FSHEARY_RAW: 未经过低通滤波的原始 Y 切向力,`float`。
|
||||
* FPS: 输出帧率,`float`。
|
||||
* CENTROID: 触碰点的坐标,(x, y)。
|
||||
* CONTOUR: 触碰区域轮廓,`np.ndarray`, shape=(H, W), 未生成时返回`np.empty`
|
||||
@@ -207,7 +152,7 @@ centroid, contour, depth = sensor.read_info(
|
||||
|
||||
注:H/W 由当前`json`文件中的`resolution_stitch`配置分辨率决定。
|
||||
|
||||
### 返回
|
||||
#### 返回
|
||||
|
||||
`Tuple`
|
||||
|
||||
@@ -219,7 +164,7 @@ centroid, contour, depth = sensor.read_info(
|
||||
- 标量类数据:`float`
|
||||
- 时间戳:`str`(`TIMESTAMP`,格式为 `HH:MM:SS`)
|
||||
|
||||
### 示例
|
||||
#### 示例
|
||||
```python
|
||||
# Example 1:读取标量数据
|
||||
fps, fn, fx, fy = sensor.read_info(
|
||||
@@ -232,23 +177,352 @@ fps, fn, fx, fy = sensor.read_info(
|
||||
# Example 2: 读取单个向量场(注意解包方式)
|
||||
flow = sensor.read_info(sensor.info.VRAW)
|
||||
```
|
||||
***
|
||||
### `Sensor.disconnect` 方法
|
||||
|
||||
## 5. `Sensor.disconnect` 方法
|
||||
|
||||
### 描述
|
||||
#### 描述
|
||||
|
||||
释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 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**:背景图像
|
||||
* **optical_flow**:光流场
|
||||
@@ -260,12 +534,12 @@ flow = sensor.read_info(sensor.info.VRAW)
|
||||
* **below_threshold_color**:RGB格式的`uint8`颜色,如(0, 0, 255)
|
||||
* **edge_margin**: 不显示箭头边距(pixel)
|
||||
|
||||
### 返回
|
||||
#### 返回
|
||||
`np.ndarray` :箭头光流可视化图像
|
||||
|
||||
## 2. `util.draw_contact` 方法
|
||||
### `util.draw_contact` 方法
|
||||
|
||||
### 输入参数
|
||||
#### 输入参数
|
||||
|
||||
* **image**:背景图像
|
||||
* **contour**:轮廓
|
||||
@@ -273,42 +547,36 @@ flow = sensor.read_info(sensor.info.VRAW)
|
||||
* **point_color**:触碰点的颜色 (BGR)
|
||||
* **region_color**:轮廓的颜色 (BGR)
|
||||
|
||||
### 返回
|
||||
#### 返回
|
||||
`np.ndarray` :箭头光流可视化图像
|
||||
|
||||
***
|
||||
## 配置标定相关
|
||||
|
||||
# 配置标定相关
|
||||
### `list_config` 方法
|
||||
|
||||
## 1. `list_config` 方法
|
||||
|
||||
### 描述
|
||||
#### 描述
|
||||
列出内置传感器配置参数字典。
|
||||
|
||||
### 返回
|
||||
#### 返回
|
||||
|
||||
* `list`: 内置传感器配置参数字典的名字列表。
|
||||
***
|
||||
## 2. `export_config` 方法
|
||||
### `export_config` 方法
|
||||
|
||||
### 描述
|
||||
#### 描述
|
||||
导出当前传感器实例使用的配置参数字典。
|
||||
|
||||
### 输入参数
|
||||
#### 输入参数
|
||||
* **config_name**: 需要导出的配置参数字典名字,`str`
|
||||
* **path**: 导出的路径,默认`"./config/exported_config.json"`
|
||||
|
||||
### 返回
|
||||
#### 返回
|
||||
|
||||
* `dict`: 导出的配置参数字典。
|
||||
|
||||
### 保存
|
||||
#### 保存
|
||||
|
||||
* 导出内置的`config_name`配置参数字典至`path`
|
||||
|
||||
***
|
||||
***
|
||||
|
||||
## 常见问题解答 (FAQ)
|
||||
### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`?
|
||||
A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。
|
||||
@@ -328,9 +596,5 @@ A:当需要接触相关信息(如接触质心、轮廓或深度/散度图)
|
||||
A:`read_info()`按传入的`sensor.info`参数顺序返回一个元组。
|
||||
返回值顺序与参数顺序一一对应,与内部存储顺序无关。
|
||||
|
||||
### Q5:如果没有标定文件,或拼接结果不正确,会发生什么?
|
||||
A:若未指定或未找到标定文件,系统会在需要时创建新的拼接标定数据并保存到默认路径。
|
||||
拼接结果异常通常与标定数据、拼接配置或输入图像质量有关,建议重新生成拼接标定并检查配置参数。
|
||||
|
||||
### Q6: 运行报错:“AttributeError: 'Stitcher' object has no attribute 'pos_default'”
|
||||
### Q5: 运行报错:“AttributeError: 'Stitcher' object has no attribute 'pos_default'”
|
||||
A: 请检查视频源的编号是否正确,以及是否能正常打开视频源。在linux 使用`v4l2-ctl --list-devices` 或 Windows使用`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }` 列出摄像头设备。
|
||||
+151
-63
@@ -1,80 +1,106 @@
|
||||
叠动科技软件使用手册
|
||||
版本: 0.4.0
|
||||
更新日期: 2026年4月16日
|
||||
---
|
||||
title: 快速开始
|
||||
description: 安装并运行 Orisys SDK 的基础步骤
|
||||
---
|
||||
|
||||
# 简介
|
||||
# 快速开始
|
||||
|
||||
版本:0.5.0
|
||||
|
||||
## 简介
|
||||
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
|
||||
支持操作系统: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 工作空间,可以避免不同项目所需的库版本冲突。
|
||||
打开命令行终端(或Anaconda Prompt),依次输入以下命令(以 python 3.10 为例):
|
||||
``` bash
|
||||
打开命令行终端(或 Anaconda Prompt),依次输入以下命令(以 Python 3.10 为例):
|
||||
```bash
|
||||
conda create -n orisys python=3.10
|
||||
conda activate orisys
|
||||
```
|
||||
|
||||
激活后,命令行提示符前会出现 `(orisys)` 字样,表示当前已进入虚拟环境。
|
||||
|
||||
## 创建工作区文件夹
|
||||
“Anaconda Prompt”启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理,可以手动创建或者运行`mkdir <path>`命令:, 例如
|
||||
### 创建工作区文件夹
|
||||
“Anaconda Prompt” 启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理。可以手动创建文件夹,也可以运行 `mkdir <path>` 命令。例如:
|
||||
|
||||
``` bash
|
||||
```bash
|
||||
mkdir orisys_ws
|
||||
```
|
||||
|
||||
创建后如果要更换工作区,使用 `cd <path>`命令,例如:
|
||||
创建后如果要更换工作区,使用 `cd <path>` 命令,例如:
|
||||
|
||||
``` bash
|
||||
```bash
|
||||
cd "C:\Users\orisys\orisys_ws"
|
||||
```
|
||||
<div style="page-break-after: always;"></div>
|
||||
|
||||
## 安装 CUDA 和 cuDNN(可选)
|
||||
### 安装 CUDA 和 cuDNN(可选)
|
||||
如果您拥有 NVIDIA 显卡,并希望利用 GPU 加速计算,则需要安装 CUDA 工具包和 cuDNN。
|
||||
|
||||
1. 方法一(官网下载):
|
||||
访问 [NVIDIA CUDA Toolkit官网](https://developer.nvidia.com/cuda/toolkit)下载对应系统的 CUDA Toolkit 安装包,并按照提示安装, 注意12.X 和 13.X 的版本对应不同的 CuPy 库版本。
|
||||
访问 [NVIDIA CUDA Toolkit 官网](https://developer.nvidia.com/cuda/toolkit) 下载对应系统的 CUDA Toolkit 安装包,并按照提示安装。注意 12.x 和 13.x 的版本对应不同的 CuPy 库版本。
|
||||
|
||||
2. 方法二(使用 Anaconda 安装,推荐):
|
||||
在已激活的 orisys 环境中直接运行以下命令(以 CUDA 12.9 为例):
|
||||
`conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn`
|
||||
|
||||
```bash
|
||||
conda install nvidia/label/cuda-12.9.0::cuda-toolkit nvidia::cudnn
|
||||
```
|
||||
|
||||
该方法会自动配置好 CUDA 和 cuDNN,无需手动下载。
|
||||
|
||||
注意:如果您的计算机没有 NVIDIA 显卡,或者不想使用 GPU 加速,可以跳过此步骤。套件也支持纯 CPU 运行(但速度可能较慢)。
|
||||
|
||||
# 安装 orisys SDK
|
||||
## 安装 orisys SDK
|
||||
|
||||
orisys SDK 以 .whl 文件形式提供,您需要根据操作系统、Python 版本和硬件平台选择正确的文件。
|
||||
推荐优先从 PyPI 安装。PyPI 会自动选择适合当前 Python 版本和操作系统的安装包,适合大多数用户。
|
||||
|
||||
## 下载正确的 .whl 文件
|
||||
### 使用 PyPI 安装(推荐)
|
||||
确认已经激活 `orisys` 虚拟环境后,运行:
|
||||
|
||||
```bash
|
||||
pip install orisys
|
||||
```
|
||||
|
||||
如果需要升级到最新版本,运行:
|
||||
|
||||
```bash
|
||||
pip install --upgrade orisys
|
||||
```
|
||||
|
||||
如果当前网络无法访问 PyPI,或需要安装客服提供的指定版本,可以使用下方 `.whl` 文件安装方式。
|
||||
|
||||
### 使用 .whl 文件安装(离线或指定版本)
|
||||
从官方渠道获取 .whl 文件,文件名格式通常为:
|
||||
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl
|
||||
例如:orisys-0.4.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)
|
||||
操作系统:Windows(win)
|
||||
硬件架构:64位(amd64)
|
||||
|
||||
请根据您的系统选择对应的文件,或者咨询客服索取:
|
||||
请根据您的系统选择对应的文件,或者咨询客服索取。
|
||||
|
||||
## 安装 .whl 文件
|
||||
将下载好的 .whl 文件放在一个文件夹中(如上述`orisys_ws`文件夹),打开命令行(确保已激活 orisys 环境或使用"Anaconda Prompt"),使用 `cd` 命令切换到该文件夹,然后运行:
|
||||
将下载好的 `.whl` 文件放在一个文件夹中(如上述 `orisys_ws` 文件夹),打开命令行(确保已激活 `orisys` 环境或使用 “Anaconda Prompt”),使用 `cd` 命令切换到该文件夹,然后运行:
|
||||
|
||||
``` bash
|
||||
```bash
|
||||
# 请将文件名替换为您实际下载的文件名
|
||||
pip install orisys-0.4.0-cp310-cp310-win_amd64.whl
|
||||
pip install orisys-0.5.0-cp310-cp310-win_amd64.whl
|
||||
```
|
||||
|
||||
## 安装 CuPy(可选)
|
||||
### 安装 CuPy(可选)
|
||||
注意:如果您没有安装 CUDA,或者使用 CPU 运行,可以跳过这一步。
|
||||
|
||||
CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安装的 CUDA 版本选择对应的 CuPy 版本。
|
||||
@@ -84,55 +110,117 @@ CuPy 是一个与 NumPy 类似但支持 GPU 加速的库,您需要根据您安
|
||||
|
||||
```bash
|
||||
# 如果您的 CUDA 版本是 12.x
|
||||
pip install orisys[cuda12]
|
||||
pip install "orisys[cuda12]"
|
||||
|
||||
# 如果您的 CUDA 版本是 13.x
|
||||
pip install orisys[cuda13]
|
||||
pip install "orisys[cuda13]"
|
||||
```
|
||||
如果之前已经安装了 orisys,可以单独安装 CuPy:
|
||||
|
||||
方法二(手动安装):
|
||||
|
||||
``` bash
|
||||
```bash
|
||||
# CUDA 12.x
|
||||
pip install cupy-cuda12x>=12.3.0
|
||||
pip install "cupy-cuda12x>=12.3.0"
|
||||
|
||||
# CUDA 13.x
|
||||
pip install cupy-cuda13x>=13.0.0
|
||||
pip install "cupy-cuda13x>=13.0.0"
|
||||
```
|
||||
|
||||
## 测试安装
|
||||
安装完成后,在命令行输入” python“命令进入 Python 环境, 并导入 orisys 库并查看版本号,验证是否成功:
|
||||
``` bash
|
||||
### 测试安装
|
||||
安装完成后,在命令行输入 `python` 命令进入 Python 环境,并导入 `orisys` 库查看版本号,验证是否成功:
|
||||
```bash
|
||||
python
|
||||
```
|
||||
|
||||
进入 Python 后继续输入:
|
||||
|
||||
```python
|
||||
import orisys
|
||||
print(orisys.__version__)
|
||||
```
|
||||
如果输出版本号(例如 0.4.0),则说明安装成功。
|
||||
如果输出版本号(例如 0.5.0),则说明安装成功。
|
||||
|
||||
# 使用方法
|
||||
## 运行示例程序
|
||||
套件里面包含了例子展示用法,运行步骤如下:
|
||||
## 使用方法
|
||||
### 运行示例程序
|
||||
套件里面包含了多个例子。运行示例前,请先进入 SDK 示例所在的文件夹,建议在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。如果您是通过 PyPI 安装并且本地没有示例文件,可以从上方项目链接下载示例。按产品入口如下:
|
||||
|
||||
### 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
|
||||
1. `examples/basic_pipeline.py`:ORY-BOX 复眼/拼接传感器基础 OpenCV 示例(默认配置 `box`)
|
||||
2. `examples/mini_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`)
|
||||
3. `examples/finger_pipeline.py`:ORY-FINGER 2D 显示最小流程(默认配置 `finger`)
|
||||
4. `examples/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `finger`)
|
||||
|
||||
`finger` 是当前 ORY-FINGER 的产品配置名。
|
||||
|
||||
下面先介绍最基础的 ORY-BOX 运行方式。
|
||||
|
||||
#### Windows
|
||||
1. 检查是否有其他正在连接的 USB 摄像头设备:打开 PowerShell,运行命令 `Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。
|
||||
2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增一行输出。
|
||||
3. 如果第一步没有输出,传感器的 ID 就是 `0`;如果第一步有一个设备,传感器的 ID 就是 `1`,如此类推。
|
||||
4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开 Anaconda Prompt,切换到工作路径,然后在命令行中输入:
|
||||
```bash
|
||||
python examples/basic_pipeline.py -v 0 -c box
|
||||
```
|
||||
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据
|
||||
5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
|
||||
|
||||
### Ubuntu:
|
||||
1. 检查是否有其他正在连接的USB摄像头设备:打开终端, 运行命令`v4l2 –list-devices`,如果没有的话不会有输出。
|
||||
2. 把传感器的USB头与电脑连接,再次运行步骤一的命令,会新增输出
|
||||
3. 根据新增的输出,例如”/dev/video0”,确定传感器的ID为“0”, 如果是”/dev/video3”的话ID为“3” ,如此类推。
|
||||
4. 在确定了传感器的ID(下方例子假设为“0”)后,打开终端, 激活相应的python环境,切换到工作路径,然后在命令行中输入:
|
||||
``` bash
|
||||
python examples/basic_pipeline.py -v 0
|
||||
#### Ubuntu
|
||||
1. 检查是否有其他正在连接的 USB 摄像头设备:打开终端,运行命令 `v4l2 --list-devices`,如果没有的话不会有输出。
|
||||
2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增输出。
|
||||
3. 根据新增的输出,例如 `/dev/video0`,确定传感器的 ID 为 `0`;如果是 `/dev/video3`,ID 为 `3`,如此类推。
|
||||
4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开终端,激活相应的 Python 环境,切换到工作路径,然后在命令行中输入:
|
||||
```bash
|
||||
python examples/basic_pipeline.py -v 0 -c box
|
||||
```
|
||||
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据
|
||||
5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
|
||||
|
||||
## 开发套件使用方法
|
||||
#### ORY-FINGER 示例
|
||||
|
||||
如果您使用的是 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
@@ -1,43 +1,62 @@
|
||||
# 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
|
||||
- `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
|
||||
- `plot_data.py` — single-sensor demo with OpenCV views plus PyQtGraph force charts
|
||||
- `two_sensor_pipeline.py` — two-sensor multiprocessing demo for parallel acquisition / display
|
||||
- `finger_pipeline.py` — minimal finger UV-unroll pipeline with optional profiling
|
||||
- `finger_pipeline_3dview.py` — main finger dashboard entry point with the unified Qt UI
|
||||
| Product | Entry | Default config |
|
||||
|---------|-------|----------------|
|
||||
| ORY-BOX | `basic_pipeline.py` | `box` |
|
||||
| ORY-MINI | `mini_pipeline.py` | `mini` |
|
||||
| ORY-FINGER (2D) | `finger_pipeline.py` | `finger` |
|
||||
| ORY-FINGER (3D Qt) | `finger_pipeline_3dview.py` | `finger` |
|
||||
|
||||
`finger` is the public ORY-FINGER config stem. `dd02` remains a compatibility alias for `dd02-ov`.
|
||||
|
||||
## 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
|
||||
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
|
||||
python examples/finger_pipeline.py --camera_id 0 --config ./config/dd02-ov.json
|
||||
```
|
||||
|
||||
For the basic compound-eye pipeline:
|
||||
|
||||
```bash
|
||||
python examples/basic_pipeline.py -v 0 -c ./config/ddjx01.json
|
||||
```
|
||||
- `plot_data.py` — single-sensor OpenCV views plus PyQtGraph force charts
|
||||
- `two_sensor_pipeline.py` — two-sensor multiprocessing demo
|
||||
|
||||
## Requirements
|
||||
|
||||
- SDK installed from the repository root, for example `pip install -e .`
|
||||
- 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:
|
||||
|
||||
@@ -47,6 +66,10 @@ pip install -e ".[gui]"
|
||||
|
||||
## 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.
|
||||
- Dashboard controls: click **启动** to start acquisition; `Q` quits, `R` resets, `S` starts recording, `E` stops 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.
|
||||
- `finger_pipeline_3dview.py` is the main interactive finger demo.
|
||||
It uses `gui/app_paths.py` so the same entry works in editable and packaged runs.
|
||||
- Dashboard controls: click **启动** to start acquisition; use **Record CSV / Stop CSV**
|
||||
and the CSV save-directory browser to log contact/force data; `Q` quits, `R` resets,
|
||||
`S` starts video recording, `E` stops video recording, and `Esc` toggles fullscreen.
|
||||
- `finger_pipeline.py` is the leanest finger example if you want to
|
||||
validate UV unroll + force/contact processing without the full dashboard.
|
||||
|
||||
+37
-19
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
示例 1:单传感器基础流程
|
||||
ORY-BOX:单传感器基础流程
|
||||
|
||||
本示例演示 Orisys SDK 的标准处理流程:
|
||||
1. 初始化传感器(摄像头或视频文件)
|
||||
@@ -7,7 +7,7 @@
|
||||
3. 按需计算接触区域
|
||||
4. 读取并可视化结果
|
||||
|
||||
SDK version: 0.3.1
|
||||
SDK version: 0.5.0
|
||||
"""
|
||||
import os
|
||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
||||
@@ -19,7 +19,7 @@ import argparse
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
||||
parser.add_argument("--config","-c", type=str, default="./config/ddjx01.json", help="配置文件名称或路径")
|
||||
parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径")
|
||||
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -28,12 +28,9 @@ def main():
|
||||
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
|
||||
|
||||
# 输入既可以是摄像头编号,也可以是视频文件路径
|
||||
try:
|
||||
input = int(args.video)
|
||||
is_camera = True
|
||||
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
|
||||
except:
|
||||
is_camera = False
|
||||
if args.video.isdigit():
|
||||
sensor = orisys.Sensor(int(args.video), config_name=args.config, verbose=args.verbose)
|
||||
else:
|
||||
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
|
||||
|
||||
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
|
||||
@@ -43,14 +40,29 @@ def main():
|
||||
# 步骤 2.1:获取并拼接图像(必需)
|
||||
sensor.get_img()
|
||||
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
|
||||
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0)
|
||||
is_contact_now = sensor.compute_deformation()
|
||||
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
|
||||
# if is_contact_now:
|
||||
sensor.compute_contact()
|
||||
|
||||
|
||||
# 步骤 2.4:读取结果
|
||||
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = sensor.read_info(
|
||||
# 步骤 2.4:读取结果(FNORMAL/FSHEAR* 在有 sensors/box/force_calibration.json 时为标定后牛顿力;
|
||||
# *_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.VRAW,
|
||||
sensor.info.VNORMAL,
|
||||
@@ -60,7 +72,10 @@ def main():
|
||||
sensor.info.DEPTH,
|
||||
sensor.info.FNORMAL,
|
||||
sensor.info.FSHEARX,
|
||||
sensor.info.FSHEARY
|
||||
sensor.info.FSHEARY,
|
||||
sensor.info.FNORMAL_RAW,
|
||||
sensor.info.FSHEARX_RAW,
|
||||
sensor.info.FSHEARY_RAW,
|
||||
)
|
||||
|
||||
# 步骤 3:可视化显示
|
||||
@@ -72,11 +87,11 @@ def main():
|
||||
grid_spacing=20,
|
||||
arrow_scale=1.0
|
||||
)
|
||||
cv2.imshow("形变矢量场", arrows)
|
||||
cv2.imshow("arrow", arrows)
|
||||
|
||||
# 步骤 3.2:显示接触区域和质心
|
||||
image_with_foe = orisys.util.draw_contact(img, contour, centroid)
|
||||
cv2.imshow("接触区域与质心", image_with_foe)
|
||||
cv2.imshow("contact", image_with_foe)
|
||||
|
||||
# 步骤 3.3:显示深度图
|
||||
# 将深度值归一化到 0-255 范围
|
||||
@@ -87,12 +102,15 @@ def main():
|
||||
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(
|
||||
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
|
||||
f"切向力Y={fsheary:.4f}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
|
||||
f"FPS={fps:.2f}, "
|
||||
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:键盘控制
|
||||
|
||||
+13
-81
@@ -1,18 +1,19 @@
|
||||
#!/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
|
||||
Runtime: camera frame -> UVUnrollFrameProcessor -> TactileProcessor -> forces/contact
|
||||
Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
|
||||
|
||||
For the full demo (UV debug panels, dashboard, benchmarks) see:
|
||||
tools/3d_patch_generation_blender/unroll_realtime.py
|
||||
Recommended entry:
|
||||
python examples/finger_pipeline.py --camera_id 0 --config finger
|
||||
|
||||
Current product config: ``finger`` (``dd02`` still resolves to ``dd02-ov``).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||
|
||||
@@ -20,38 +21,14 @@ import cv2
|
||||
import orisys
|
||||
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():
|
||||
parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline")
|
||||
parser.add_argument("--camera_id", type=int, default=0)
|
||||
parser.add_argument("--video", help="Video file path (overrides camera)")
|
||||
parser.add_argument("--config", "-c", default="./config/dd02-ov.json", help="Orisys SDK config")
|
||||
parser.add_argument("--video","-v", help="Video file path (overrides camera)")
|
||||
parser.add_argument("--config", "-c", default="finger", help="Orisys SDK config name or path")
|
||||
parser.add_argument("--show", action="store_true", default=True, help="Show unrolled image window")
|
||||
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
|
||||
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()
|
||||
|
||||
try:
|
||||
@@ -77,48 +54,17 @@ def main():
|
||||
|
||||
runtime.warmup(color=False)
|
||||
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.")
|
||||
if args.profile:
|
||||
print(f"Profiling enabled (report every {args.profile_every} frames).")
|
||||
try:
|
||||
while True:
|
||||
frame_t0 = time.perf_counter()
|
||||
ok, unrolled = runtime.grab_and_process(color=False)
|
||||
if not ok:
|
||||
break
|
||||
|
||||
if profiler is not None:
|
||||
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_deformation()
|
||||
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(
|
||||
tactile.info.FPS,
|
||||
tactile.info.VRAW,
|
||||
@@ -131,8 +77,6 @@ def main():
|
||||
tactile.info.FSHEARX,
|
||||
tactile.info.FSHEARY,
|
||||
)
|
||||
if profiler is not None:
|
||||
profiler.record("read_info", (time.perf_counter() - t0) * 1000.0)
|
||||
|
||||
status = (
|
||||
f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) "
|
||||
@@ -140,26 +84,16 @@ def main():
|
||||
)
|
||||
|
||||
if args.show:
|
||||
t0 = time.perf_counter()
|
||||
arrows = orisys.util.draw_arrows(
|
||||
unrolled,
|
||||
flow,
|
||||
threshold=0.4,
|
||||
grid_spacing=10,
|
||||
arrow_scale=1.0,
|
||||
valid_mask=tactile.valid_mask,
|
||||
)
|
||||
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")
|
||||
|
||||
key = cv2.waitKey(1) & 0xFF
|
||||
@@ -170,8 +104,6 @@ def main():
|
||||
tactile.reset()
|
||||
print("\nTracker reset.")
|
||||
finally:
|
||||
if profiler is not None and profiler.frame_count:
|
||||
profiler.print_report(prefix="[profile final]")
|
||||
runtime.close()
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
#!/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:
|
||||
Layout (sizes, proportions) -> examples/gui/layout.py <-- edit here
|
||||
Colors / QSS -> examples/gui/styles.py
|
||||
Widget rendering logic -> examples/gui/widgets/
|
||||
Window behavior -> examples/gui/dashboard.py
|
||||
Layout (sizes, proportions) -> examples/gui/viewer3d/layout.py
|
||||
Colors / QSS -> examples/gui/viewer3d/styles.py
|
||||
Widget rendering logic -> examples/gui/viewer3d/widgets/
|
||||
Window behavior -> examples/gui/viewer3d/dashboard.py
|
||||
|
||||
Recommended entry:
|
||||
python examples/finger_pipeline_3dview.py --video 0 --config finger
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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()
|
||||
ensure_examples_import_path()
|
||||
require_gui_extras()
|
||||
|
||||
from gui import run_dashboard
|
||||
from pipeline import PipelineConfig
|
||||
|
||||
@@ -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
|
||||
from .layout import (
|
||||
LOG_CONFIG,
|
||||
LOG_PANEL,
|
||||
Two apps live under ``examples/gui``:
|
||||
|
||||
- Finger 3D dashboard: ``from gui import run_dashboard`` (also ``gui.viewer3d``)
|
||||
- 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,
|
||||
LOG_PANEL,
|
||||
MESH_CONTACT_ARROW,
|
||||
MESH_LIGHTING,
|
||||
PANEL_SETUP,
|
||||
TOP_ROW,
|
||||
DashboardLayout,
|
||||
LogConfig,
|
||||
MeshLighting,
|
||||
PanelRow,
|
||||
PanelSetup,
|
||||
PanelSlot,
|
||||
run_dashboard,
|
||||
top_row_splitter_sizes,
|
||||
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
|
||||
|
||||
@@ -6,7 +6,8 @@ import os
|
||||
import sys
|
||||
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
|
||||
|
||||
|
||||
@@ -26,7 +27,17 @@ def resource_path(*parts: str) -> Path:
|
||||
|
||||
|
||||
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:
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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'"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
"""
|
||||
@@ -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_()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -17,9 +18,12 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
from pipeline.config import PipelineConfig
|
||||
from pipeline.csv_recorder import CsvRecorder
|
||||
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 (
|
||||
DashboardLayout,
|
||||
LAYOUT,
|
||||
@@ -29,14 +33,12 @@ from .layout import (
|
||||
)
|
||||
from .mesh_view_state import MeshViewState
|
||||
from .styles import STYLE
|
||||
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
|
||||
from .widgets import (
|
||||
ArrowPanelWidget,
|
||||
ForceChartWidget,
|
||||
MeshViewportWidget,
|
||||
SettingsPanelWidget,
|
||||
TeeStream,
|
||||
WidgetLogHandler,
|
||||
make_log_widget,
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +56,7 @@ class FingerDashboardWindow(QMainWindow):
|
||||
self.config = config
|
||||
self.layout = layout
|
||||
self.pipeline = FingerPipeline(config)
|
||||
self.csv_recorder = CsvRecorder(config.csv_save_dir)
|
||||
self._running = False
|
||||
self._frame_index = 0
|
||||
self._wall_fps = 0.0
|
||||
@@ -99,22 +102,45 @@ class FingerDashboardWindow(QMainWindow):
|
||||
mesh_state=self.mesh_state,
|
||||
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_config_path(self.config.config)
|
||||
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_csv_save_dir(self.config.csv_save_dir)
|
||||
|
||||
self.settings_panel.log_visibility_changed.connect(self._set_log_panel_visible)
|
||||
self.settings_panel.free_rotation_changed.connect(self.mesh_viewport.set_free_rotation)
|
||||
self._preferences = load_preferences()
|
||||
self.settings_panel.apply_preference_state(self._preferences)
|
||||
self._log_panel_visible = bool(self.settings_panel.show_log_check.isChecked())
|
||||
self.mesh_viewport.set_free_rotation(bool(self.settings_panel.free_rotate_check.isChecked()))
|
||||
if self.arrow_panel is not None:
|
||||
self.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.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_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_reset_tracker.clicked.connect(self._on_reset)
|
||||
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["mesh"] = self.mesh_viewport
|
||||
@@ -155,6 +181,37 @@ class FingerDashboardWindow(QMainWindow):
|
||||
self.log_widget.setVisible(self._log_panel_visible)
|
||||
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:
|
||||
setup = self.layout.panel_setup
|
||||
scale = self._layout_scale_factor()
|
||||
@@ -314,6 +371,15 @@ class FingerDashboardWindow(QMainWindow):
|
||||
cal = self.settings_panel.calibration_path()
|
||||
self.config.calib = cal or None
|
||||
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:
|
||||
self.pipeline = FingerPipeline(self.config)
|
||||
@@ -334,6 +400,8 @@ class FingerDashboardWindow(QMainWindow):
|
||||
self.timer.start(self.layout.timer_interval_ms)
|
||||
self._set_play_pause_button("pause")
|
||||
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(
|
||||
f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, "
|
||||
f"标定={self.config.calib or '(none)'}, 资产={self.config.export_dir}"
|
||||
@@ -342,6 +410,7 @@ class FingerDashboardWindow(QMainWindow):
|
||||
def _on_stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._stop_csv_recording(announce=True)
|
||||
self._running = False
|
||||
self.timer.stop()
|
||||
self.log_handler.clear_status_line()
|
||||
@@ -354,6 +423,9 @@ class FingerDashboardWindow(QMainWindow):
|
||||
self._set_play_pause_button("run")
|
||||
self.settings_panel.btn_reset_tracker.setEnabled(False)
|
||||
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:
|
||||
if not self._running:
|
||||
@@ -413,10 +485,26 @@ class FingerDashboardWindow(QMainWindow):
|
||||
profile.record("total", 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
|
||||
if frame.flow.size:
|
||||
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
|
||||
if log_cfg.status_enabled:
|
||||
self.log_handler.set_status_line(
|
||||
@@ -428,9 +516,66 @@ class FingerDashboardWindow(QMainWindow):
|
||||
flow_rms=flow_mag,
|
||||
mask_coverage=frame.mask_coverage,
|
||||
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:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
@@ -440,25 +585,99 @@ class FingerDashboardWindow(QMainWindow):
|
||||
)
|
||||
if 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:
|
||||
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(
|
||||
self,
|
||||
"选择配置文件",
|
||||
"config",
|
||||
start_dir,
|
||||
"JSON (*.json);;所有文件 (*)",
|
||||
)
|
||||
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:
|
||||
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(
|
||||
self,
|
||||
"选择 finger 资产目录",
|
||||
self.settings_panel.finger_export_dir() or "assets/finger",
|
||||
start_dir,
|
||||
)
|
||||
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):
|
||||
key = event.key()
|
||||
@@ -483,6 +702,8 @@ class FingerDashboardWindow(QMainWindow):
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.timer.stop()
|
||||
self._stop_csv_recording(announce=True)
|
||||
self._save_preferences()
|
||||
self.mesh_viewport.close_renderer()
|
||||
self.pipeline.close()
|
||||
event.accept()
|
||||
@@ -490,9 +711,10 @@ class FingerDashboardWindow(QMainWindow):
|
||||
|
||||
def run_dashboard(config: PipelineConfig, *, layout: DashboardLayout = LAYOUT) -> int:
|
||||
"""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("GUI:Record CSV / Stop CSV 写入接触点与力到所选目录。")
|
||||
if config.profile:
|
||||
print(f"Profiling enabled (report every {config.profile_every} frames).")
|
||||
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_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:
|
||||
reference_normal_force — Fn at which arrow reaches full size (log/power/linear curve)
|
||||
force_strength_mode — "log" | "power" | "linear"
|
||||
@@ -44,6 +47,7 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from gui.log_config import LOG_CONFIG, LogConfig
|
||||
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
|
||||
from viz.flow_arrow import FlowArrowStyle
|
||||
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("arrows", "UV Unrolled", width_px=400, height_px=400, enabled=True),
|
||||
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))
|
||||
|
||||
|
||||
@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).
|
||||
MESH_CONTACT_ARROW = ContactArrowStyle(
|
||||
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)
|
||||
class DashboardLayout:
|
||||
"""Window chrome, chart styling, and panel hierarchy."""
|
||||
@@ -224,7 +202,7 @@ class DashboardLayout:
|
||||
|
||||
window_title: str = "Orisys Finger Dashboard"
|
||||
window_width: int = 1300
|
||||
window_height: int = 520
|
||||
window_height: int = 560
|
||||
|
||||
root_margin_px: int = 6
|
||||
root_spacing_px: int = 6
|
||||
@@ -249,6 +227,7 @@ class DashboardLayout:
|
||||
mesh_background: MeshBackground = MESH_BACKGROUND
|
||||
mesh_contact_arrow: ContactArrowStyle = MESH_CONTACT_ARROW
|
||||
mesh_flow_arrow: FlowArrowStyle = MESH_FLOW_ARROW
|
||||
mesh_axis_overlay: MeshAxisOverlay = MESH_AXIS_OVERLAY
|
||||
|
||||
log_config: LogConfig = LOG_CONFIG
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 .image_panel import ImagePanel
|
||||
from .qt_pixmap import ndarray_to_pixmap
|
||||
from gui.widgets.qt_pixmap import ndarray_to_pixmap
|
||||
|
||||
|
||||
class ArrowPanelWidget(ImagePanel):
|
||||
+2
-2
@@ -6,8 +6,8 @@ import numpy as np
|
||||
from PyQt5.QtGui import QFont
|
||||
from PyQt5.QtWidgets import QSizePolicy
|
||||
|
||||
from gui.layout import DashboardLayout, LAYOUT
|
||||
from gui.styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
|
||||
from pipeline.frame import FingerFrameView
|
||||
from viz.signal_filters import lowpass_filter
|
||||
|
||||
@@ -6,7 +6,7 @@ from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QPixmap
|
||||
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):
|
||||
+2
-1
@@ -11,7 +11,7 @@ from viz.offscreen_mesh import OffscreenFingerMeshRenderer
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from ..mesh_view_state import MeshViewState
|
||||
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
|
||||
|
||||
_VIEW_ROTATE_SENSITIVITY = 0.35
|
||||
@@ -120,6 +120,7 @@ class MeshViewportWidget(ImagePanel):
|
||||
mesh_background=self._layout.mesh_background.color,
|
||||
contact_arrow_style=self._layout.mesh_contact_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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user