Compare commits

...

22 Commits

Author SHA1 Message Date
zhangyijian 75fa98eff0 Merge pull request 'Dev' (#19) from dev into main
Reviewed-on: #19
2026-09-02 21:39:03 +08:00
zhangyijian bc059e3539 Merge pull request 'Hotfix/mems cam open' (#20) from hotfix/mems_cam_open into dev
Reviewed-on: #20
2026-09-02 21:38:48 +08:00
ykcheungab 5be2d9759a hotfix/camera not connected error 2026-09-02 21:37:40 +08:00
ykcheungab 32823a7666 test if optical flow integration error: not significant for Mems 2026-09-01 21:27:10 +08:00
zhangyijian 26168852ef Merge pull request 'fix input flag convention mismatch' (#18) from feature/force_cal into dev
Reviewed-on: #18
2026-08-26 20:27:35 +08:00
ykcheungab 36e0c22de6 fix input flag convention mismatch 2026-08-26 20:26:05 +08:00
zhangyijian a387b8b693 Merge pull request 'Feature/force cal' (#17) from feature/force_cal into dev
Reviewed-on: #17
2026-08-26 20:22:27 +08:00
ykcheungab 8a4be5f8f2 update to 0.5.0 2026-08-26 20:20:37 +08:00
ykcheungab 68910e63c1 fix finger gui cannot choose camera 2026-08-26 20:19:55 +08:00
ykcheungab 577e3fe528 update pip install .copy manifest for developments 2026-08-26 20:04:40 +08:00
ykcheungab 32d4164471 update examples to fix path issues 2026-08-26 19:38:47 +08:00
ykcheungab d59e1cb7fb fix path errors 2026-08-26 19:22:37 +08:00
zhangyijian 2cb152b76e Merge pull request 'refractor stitcher class into module' (#16) from refractor/stitcher into dev
Reviewed-on: #16
2026-08-23 16:25:27 +08:00
ykcheungab ab97efe039 refractor stitcher class into module 2026-08-23 16:24:53 +08:00
zhangyijian 135857b6de Merge pull request 'refractor capture to a module' (#15) from refractor/capture into dev
Reviewed-on: #15
2026-08-23 15:53:55 +08:00
ykcheungab 8a7760f12b refractor capture to a module 2026-08-23 15:53:02 +08:00
zhangyijian 4583dc3a40 Merge pull request 'Refractor/config package' (#14) from refractor/config_package into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/14
2026-08-23 12:25:28 +08:00
ykcheungab 1ceb0d9ca4 organize settings to dedicated sensors folder, json + assets 2026-08-23 12:21:24 +08:00
ykcheungab 44bcc19426 update docs and downstream sync 2026-08-18 11:00:57 +08:00
ykcheungab 2e09c9360e included low pass filter in package and add to plot_data example 2026-08-17 22:11:17 +08:00
zhangyijian 93b4517ebf Merge pull request 'reorganized build directory' (#13) from feature/single_build_entry into dev
Reviewed-on: http://100.106.67.31:3000/orisys-tech/orisys-sdk/pulls/13
2026-08-03 22:17:48 +08:00
ykcheungab 9a69f01278 reorganized build directory 2026-08-03 22:14:19 +08:00
129 changed files with 8024 additions and 4107 deletions
+5 -1
View File
@@ -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/
+8
View File
@@ -0,0 +1,8 @@
# Non-Python files needed for sdist -> wheel builds from an unpacked sdist.
include pyproject.toml
include setup.py
include README.md
recursive-include config *.json
recursive-include sensors *
recursive-include build/common *.py
recursive-include build/wheel *.py
+13 -13
View File
@@ -4,11 +4,13 @@ Orisys SDK is a Python toolkit for optical tactile sensing. It provides image ca
stitching, dense optical flow, Helmholtz-Hodge decomposition, contact/depth estimation,
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/`: product demos (`products/ory_box`, `ory_mini`, `ory_finger`) plus shared `gui/`, `pipeline/`, `viz/`
- `examples/`: runnable demos (`basic_pipeline.py`, `mini_pipeline.py`, `finger_pipeline*.py`) plus shared `gui/`, `pipeline/`, `viz/`
- `scripts/`: maintenance, calibration, packaging, and demo scripts
- `tools/`: non-package tooling such as patch-generation utilities
- `tests/`: automated pytest suite
@@ -52,23 +54,22 @@ pytest
ORY-BOX:
```bash
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
python examples/basic_pipeline.py -v 0 -c box
```
ORY-MINI:
```bash
python examples/products/ory_mini/basic_pipeline.py -v 0 -c mini
python examples/mini_pipeline.py -v 0 -c mini
```
ORY-FINGER:
```bash
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
python examples/finger_pipeline.py --camera_id 0 --config finger
```
Compatibility wrappers remain at `examples/basic_pipeline.py` and
`examples/finger_pipeline*.py`.
Run commands from the repository root.
## Configuration
@@ -76,7 +77,7 @@ The canonical configuration files now live in the repo-level `config/` folder.
The package loader `orisys.configs` resolves configs from:
1. an explicit JSON file path
2. a config file stem under `config/` (for example `ddjx01`, `mini`, `dd02`)
2. a config file stem under `config/` (for example `box`, `mini`, `finger`)
3. a product alias such as `dd02` -> `dd02-ov`
4. a backward-compatible JSON `"name"` match, such as `"prototype"`
@@ -85,17 +86,16 @@ 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)
- Manylinux / PyPI binary wheel (Docker Desktop): `docs/publishing.md`
- 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)
@@ -103,7 +103,7 @@ cfg = orisys.export_config("ddjx01", "./config/exported_config.json")
To build PyPI-compliant CPython 3.10/3.11/3.12 Linux wheels from Windows Docker Desktop:
```powershell
powershell -ExecutionPolicy Bypass -File .\build\build_manylinux_docker.ps1
python build/run.py manylinux
```
Upload candidates land in `dist/repaired/`. Full TestPyPI → PyPI steps:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

-44
View File
@@ -1,44 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['D:\\repo\\orisys-sdk\\examples\\finger_pipeline_3dview.py'],
pathex=[],
binaries=[],
datas=[('D:\\repo\\orisys-sdk\\config', 'config'), ('D:\\repo\\orisys-sdk\\assets', 'assets')],
hiddenimports=['PyQt5', 'pyqtgraph', 'open3d', 'cv2', 'orisys', 'gui', 'pipeline', 'viz', 'gui.app_paths', 'plotly', '_plotly_utils', 'open3d.cpu.pybind'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['cupy', 'cupyx', 'pyopencl', 'tkinter', 'pip', 'wheel', 'matplotlib'],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='OrisysDashboard',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='OrisysDashboard',
)
+65
View File
@@ -0,0 +1,65 @@
# Build tooling
Use one launcher from the repo root:
```bash
python build/run.py <command>
```
```text
build/
run.py << single entrypoint
README.md
common/ shared helpers (paths, pyproject, verify)
wheel/ SDK .whl (Nuitka + manylinux Docker)
gui/
common/ shared PyInstaller driver
ory_finger/ ORY-FINGER dashboard
ory_mini/ ORY-MINI internal GUI (scaffold)
ory_box/ ORY-BOX internal GUI (scaffold)
```
## Commands
### SDK wheel
```bash
python build/run.py wheel-win
python build/run.py wheel-manylinux
# Single Version
python build/run.py wheel
# Inside Docker
python build/run.py wheel --target pypi-linux --verify
```
Direct module paths (same behavior):
```bash
python build/wheel/build.py
build\wheel\win.bat
.\build\wheel\docker\run.ps1
```
### GUI
```bash
python build/run.py gui finger
python build/run.py gui mini # scaffold / NotImplemented
python build/run.py gui box # scaffold / NotImplemented
```
### Verify
```bash
python build/run.py verify path\to\orisys.whl
python build/run.py verify --gui-dist --product ory_finger
```
## Outputs
- Wheels: `SDK/<version>/`, `dist/repaired/`
- Finger GUI: `dist/OrisysDashboard/`
- Future mini/box: `dist/OryMini/`, `dist/OryBox/`
Generated work files under `build/nuitka/` and `build/pyinstaller_work/` are gitignored.
-2
View File
@@ -1,2 +0,0 @@
powershell -ExecutionPolicy Bypass -File .\build\build_manylinux_docker.ps1
powershell -ExecutionPolicy Bypass -File .\build\build_manylinux_docker.ps1 -PythonVersions 3.10,3.12
-194
View File
@@ -1,194 +0,0 @@
"""Build a CPU-only standalone Windows folder for Orisys 3D dashboard using PyInstaller.
Usage:
conda activate py310 # or a clean virtualenv with only required packages
pip install pyinstaller
python build/build_gui_win_pyinstaller.py
Output:
dist/OrisysDashboard/OrisysDashboard.exe (+ bundled Python, DLLs, config/, assets/)
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
# ---------- Configuration ----------
ENTRY_SCRIPT = Path("examples/products/ory_finger/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",
"gui.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
View File
@@ -0,0 +1 @@
"""Shared build utilities used by wheel and GUI packaging targets."""
+15
View File
@@ -0,0 +1,15 @@
"""Ensure ``build/`` is importable as the root for ``common``, ``wheel``, and ``gui``."""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[1]
def ensure_build_root_on_path() -> Path:
root = str(_BUILD_ROOT)
if root not in sys.path:
sys.path.insert(0, root)
return _BUILD_ROOT
+8 -2
View File
@@ -1,15 +1,17 @@
"""Repository and Nuitka build output paths."""
"""Repository and packaging output paths."""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# 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:
@@ -23,3 +25,7 @@ def sdk_dir(version: str) -> Path:
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
@@ -3,10 +3,24 @@
from __future__ import annotations
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):
@@ -89,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):
@@ -110,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
+6
View File
@@ -0,0 +1,6 @@
"""Post-build smoke tests for wheels and product GUI folders."""
from common.verify.gui import GUI_PRODUCTS, verify_gui_dist
from common.verify.wheel import verify_built_wheel
__all__ = ["GUI_PRODUCTS", "verify_built_wheel", "verify_gui_dist"]
+34
View File
@@ -0,0 +1,34 @@
"""CLI for packaged artifact verification."""
from __future__ import annotations
import argparse
from pathlib import Path
from common.verify import GUI_PRODUCTS, verify_built_wheel, verify_gui_dist
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Verify packaged Orisys artifacts.")
parser.add_argument("--gui-dist", action="store_true", help="Verify a GUI dist folder")
parser.add_argument(
"--product",
choices=sorted(GUI_PRODUCTS),
default="ory_finger",
help="GUI product profile (default: ory_finger)",
)
parser.add_argument("--dist-dir", type=Path, default=None, help="Override GUI dist folder")
parser.add_argument("wheel", nargs="?", type=Path, help="Wheel path for import smoke test")
args = parser.parse_args(argv)
if args.gui_dist:
verify_gui_dist(args.dist_dir, product=args.product)
return 0
if args.wheel is None:
parser.error("Provide a wheel path or pass --gui-dist")
verify_built_wheel(args.wheel)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
"""Smoke tests for standalone GUI product folders."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import GUI_DIST_DIR, gui_dist_dir
# Product profiles for GUI packaging verification.
GUI_PRODUCTS: dict[str, dict] = {
"ory_finger": {
"dist_name": "OrisysDashboard",
"exe_name": "OrisysDashboard.exe",
"required_relpaths": [
"config/dd02-ov.json",
"sensors/finger/assets/30fps-0703/mesh.npz",
"sensors/finger/assets/30fps-0703/world_to_cam.npy",
"sensors/finger/assets/visualization/world_to_cam.npy",
],
},
"ory_mini": {
"dist_name": "OryMini",
"exe_name": "OryMini.exe",
"required_relpaths": [
"config/mini.json",
],
},
"ory_box": {
"dist_name": "OryBox",
"exe_name": "OryBox.exe",
"required_relpaths": [
"config/ddjx01.json",
],
},
}
def verify_gui_dist(
dist_dir: Path | None = None,
*,
product: str = "ory_finger",
) -> None:
"""Smoke-test a standalone GUI folder for the given product."""
if product not in GUI_PRODUCTS:
raise ValueError(f"Unknown GUI product {product!r}; choose from {sorted(GUI_PRODUCTS)}")
profile = GUI_PRODUCTS[product]
if dist_dir is None:
dist_dir = gui_dist_dir(profile["dist_name"]) if product != "ory_finger" else GUI_DIST_DIR
dist_dir = dist_dir.resolve()
exe = dist_dir / profile["exe_name"]
if not exe.is_file():
raise FileNotFoundError(f"GUI executable not found: {exe}")
required = [dist_dir / rel for rel in profile["required_relpaths"]]
missing = [str(path.relative_to(dist_dir)) for path in required if not path.is_file()]
if missing:
raise FileNotFoundError(f"Missing bundled files in {dist_dir}: {', '.join(missing)}")
subprocess.check_call([str(exe), "--help"], cwd=dist_dir)
print(f"GUI dist smoke test OK ({product}): {exe}")
@@ -1,16 +1,13 @@
"""Post-build smoke tests for packaged wheels and standalone GUI builds."""
"""Isolated venv smoke tests for packaged orisys wheels."""
from __future__ import annotations
import argparse
import subprocess
import sys
import tempfile
import venv
from pathlib import Path
from paths import GUI_DIST_DIR
# Public SDK surface that must import from a released wheel.
_SMOKE_IMPORT_SCRIPT = """
import orisys
@@ -20,6 +17,8 @@ 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
@@ -34,6 +33,8 @@ 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")
"""
@@ -59,42 +60,3 @@ def verify_built_wheel(wheel_path: Path) -> None:
[python, "-m", "pip", "install", "-q", str(wheel_path)],
)
subprocess.check_call([python, "-c", _SMOKE_IMPORT_SCRIPT])
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-0703" / "mesh.npz",
]
missing = [str(path.relative_to(dist_dir)) for path in required if not path.is_file()]
if missing:
raise FileNotFoundError(f"Missing bundled files in {dist_dir}: {', '.join(missing)}")
subprocess.check_call([str(exe), "--help"], cwd=dist_dir)
print(f"GUI dist smoke test OK: {exe}")
def main() -> None:
parser = argparse.ArgumentParser(description="Verify packaged Orisys artifacts.")
parser.add_argument("--gui-dist", action="store_true", help="Verify dist/OrisysDashboard/")
parser.add_argument("--dist-dir", type=Path, default=None, help="Override GUI dist folder")
parser.add_argument("wheel", nargs="?", type=Path, help="Wheel path for import smoke test")
args = parser.parse_args()
if args.gui_dist:
verify_gui_dist(args.dist_dir)
return
if args.wheel is None:
parser.error("Provide a wheel path or pass --gui-dist")
verify_built_wheel(args.wheel)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
"""Standalone GUI packaging targets (PyInstaller)."""
+1
View File
@@ -0,0 +1 @@
"""Shared helpers for product GUI builds."""
+141
View File
@@ -0,0 +1,141 @@
"""Shared PyInstaller driver for product GUI standalone folders."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import PYINSTALLER_WORK_DIR, REPO_ROOT, gui_dist_dir
@dataclass
class GuiBuildConfig:
product: str
entry_script: Path
exe_name: str
dist_name: str
hidden_imports: list[str] = field(default_factory=list)
exclude_modules: list[str] = field(default_factory=list)
data_dirs: list[tuple[str, str]] = field(default_factory=list)
readme_lines: list[str] = field(default_factory=list)
windowed: bool = True
upx_dir: str | None = r"C:\tools\upx"
logo_name: str = "logo.png"
work_dir: Path = PYINSTALLER_WORK_DIR
spec_dir: Path = REPO_ROOT / "build" / "pyinstaller_work"
def clean_dir(path: Path) -> None:
if path.exists():
shutil.rmtree(path)
path.mkdir(parents=True)
def build_with_pyinstaller(
config: GuiBuildConfig,
*,
verify_fn: Callable[[Path], None] | None = None,
) -> Path:
"""Run PyInstaller for one GUI product and copy bundled data folders."""
print("Using Python:", sys.executable)
print(f"Building GUI product: {config.product}")
dist_dir = gui_dist_dir(config.dist_name)
clean_dir(config.work_dir)
clean_dir(dist_dir)
config.spec_dir.mkdir(parents=True, exist_ok=True)
entry = config.entry_script
if not entry.is_file():
raise FileNotFoundError(f"GUI entry script not found: {entry}")
cmd = [
sys.executable,
"-m",
"PyInstaller",
"--onedir",
"--name",
config.dist_name,
f"--distpath={dist_dir.parent}",
f"--workpath={config.work_dir}",
f"--specpath={config.spec_dir}",
"--noconfirm",
"--clean",
"--windowed" if config.windowed else "--console",
]
for src_rel, dest_rel in config.data_dirs:
src = (REPO_ROOT / src_rel).resolve()
if not src.is_dir():
raise FileNotFoundError(f"Data directory missing: {src}")
cmd.append(f"--add-data={src};{dest_rel}")
logo = REPO_ROOT / config.logo_name
if logo.is_file():
cmd.append(f"--add-data={logo.resolve()};.")
for mod in config.hidden_imports:
cmd.append(f"--hidden-import={mod}")
for mod in config.exclude_modules:
cmd.append(f"--exclude-module={mod}")
if config.upx_dir and Path(config.upx_dir).is_dir():
cmd.append(f"--upx-dir={config.upx_dir}")
cmd.append(str(entry.resolve()))
env = os.environ.copy()
env["PYTHONPATH"] = (
str((REPO_ROOT / "src").resolve())
+ os.pathsep
+ str((REPO_ROOT / "examples").resolve())
+ os.pathsep
+ env.get("PYTHONPATH", "")
)
print("\n=== Running PyInstaller ===")
print("Command:", " ".join(cmd))
subprocess.check_call(cmd, env=env, cwd=REPO_ROOT)
final_exe = dist_dir / config.exe_name
if not final_exe.is_file():
raise RuntimeError(f"Executable not found at {final_exe}")
print("\n=== Copying data directories ===")
for src_rel, dest_rel in config.data_dirs:
src = REPO_ROOT / src_rel
dest = dist_dir / dest_rel
if not src.is_dir():
raise FileNotFoundError(f"Data directory not found: {src}")
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src, dest, dirs_exist_ok=True)
print(f" {src} -> {dest}")
if logo.is_file():
shutil.copy2(logo, dist_dir / config.logo_name)
print(" Logo copied")
if config.readme_lines:
(dist_dir / "README.txt").write_text("\n".join(config.readme_lines) + "\n", encoding="utf-8")
print(f"\n[SUCCESS] Standalone GUI folder: {dist_dir}")
print(f"Executable: {final_exe}")
if verify_fn is not None:
try:
print("\n=== Smoke test ===")
verify_fn(dist_dir)
except Exception as exc:
print(f"\n[WARNING] Smoke test failed: {exc}")
return dist_dir
+1
View File
@@ -0,0 +1 @@
"""ORY-BOX internal calibration/testing GUI packaging (scaffold)."""
+30
View File
@@ -0,0 +1,30 @@
"""Scaffold for ORY-BOX internal GUI packaging.
Not implemented yet. When ready, wire a dedicated calibration/testing entry
script and call ``gui.common.pyinstaller_driver.build_with_pyinstaller``.
"""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from gui.ory_box.config import DIST_NAME, ENTRY_SCRIPT, make_config
def main() -> None:
config = make_config()
raise NotImplementedError(
"ORY-BOX GUI packaging is a scaffold only. "
f"Planned dist={DIST_NAME!r}, entry={ENTRY_SCRIPT}. "
"Implement the internal calibration/testing UI entry, then call "
"gui.common.pyinstaller_driver.build_with_pyinstaller(config)."
)
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
"""Product configuration scaffold for the ORY-BOX internal GUI."""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "basic_pipeline.py"
EXE_NAME = "OryBox.exe"
DIST_NAME = "OryBox"
HIDDEN_IMPORTS = [
"PyQt5",
"pyqtgraph",
"cv2",
"orisys",
"gui",
"pipeline",
"viz",
]
EXCLUDE_MODULES = [
"cupy",
"cupyx",
"pyopencl",
"open3d",
"tkinter",
"pip",
"wheel",
]
DATA_DIRS = [
("config", "config"),
]
README_LINES = [
"ORY-BOX internal calibration/testing GUI (scaffold)",
"",
"This product packaging target is reserved for a future internal tool.",
"Entry point placeholder:",
f" {ENTRY_SCRIPT.as_posix()}",
]
def make_config() -> GuiBuildConfig:
return GuiBuildConfig(
product="ory_box",
entry_script=ENTRY_SCRIPT,
exe_name=EXE_NAME,
dist_name=DIST_NAME,
hidden_imports=list(HIDDEN_IMPORTS),
exclude_modules=list(EXCLUDE_MODULES),
data_dirs=list(DATA_DIRS),
readme_lines=list(README_LINES),
windowed=False,
)
+1
View File
@@ -0,0 +1 @@
"""ORY-FINGER standalone GUI packaging."""
+31
View File
@@ -0,0 +1,31 @@
"""Build the ORY-FINGER standalone Windows GUI folder with PyInstaller.
Usage:
python build/run.py gui finger
python build/gui/ory_finger/build.py
"""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.verify.gui import verify_gui_dist
from gui.common.pyinstaller_driver import build_with_pyinstaller
from gui.ory_finger.config import make_config
def main() -> None:
config = make_config()
build_with_pyinstaller(
config,
verify_fn=lambda dist_dir: verify_gui_dist(dist_dir, product="ory_finger"),
)
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
"""Product configuration for the ORY-FINGER Windows dashboard."""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "finger_pipeline_3dview.py"
EXE_NAME = "OrisysDashboard.exe"
DIST_NAME = "OrisysDashboard"
HIDDEN_IMPORTS = [
"PyQt5",
"pyqtgraph",
"open3d",
"cv2",
"orisys",
"gui",
"pipeline",
"viz",
"gui.app_paths",
"plotly",
"_plotly_utils",
"open3d.cpu.pybind",
]
EXCLUDE_MODULES = [
"cupy",
"cupyx",
"pyopencl",
"tkinter",
"pip",
"wheel",
"matplotlib",
]
DATA_DIRS = [
("config", "config"),
("sensors", "sensors"),
]
README_LINES = [
"Orisys Finger Dashboard (standalone)",
"",
"Run:",
f" {EXE_NAME}",
f" {EXE_NAME} --video path\\to\\video.mp4",
f" {EXE_NAME} --config config\\dd02-ov.json",
"",
"Bundled folders:",
" config/ sensor JSON configs",
" sensors/ per-sensor bundles (finger mesh and visualization data)",
"",
"CPU-only build (no CUDA/CuPy).",
]
def make_config() -> GuiBuildConfig:
return GuiBuildConfig(
product="ory_finger",
entry_script=ENTRY_SCRIPT,
exe_name=EXE_NAME,
dist_name=DIST_NAME,
hidden_imports=list(HIDDEN_IMPORTS),
exclude_modules=list(EXCLUDE_MODULES),
data_dirs=list(DATA_DIRS),
readme_lines=list(README_LINES),
)
+1
View File
@@ -0,0 +1 @@
"""ORY-MINI internal calibration/testing GUI packaging (scaffold)."""
+30
View File
@@ -0,0 +1,30 @@
"""Scaffold for ORY-MINI internal GUI packaging.
Not implemented yet. When ready, wire a dedicated calibration/testing entry
script and call ``gui.common.pyinstaller_driver.build_with_pyinstaller``.
"""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from gui.ory_mini.config import DIST_NAME, ENTRY_SCRIPT, make_config
def main() -> None:
config = make_config()
raise NotImplementedError(
"ORY-MINI GUI packaging is a scaffold only. "
f"Planned dist={DIST_NAME!r}, entry={ENTRY_SCRIPT}. "
"Implement the internal calibration/testing UI entry, then call "
"gui.common.pyinstaller_driver.build_with_pyinstaller(config)."
)
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
"""Product configuration scaffold for the ORY-MINI internal GUI."""
from __future__ import annotations
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parents[2]
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
from common.paths import REPO_ROOT
from gui.common.pyinstaller_driver import GuiBuildConfig
ENTRY_SCRIPT = REPO_ROOT / "examples" / "mini_pipeline.py"
EXE_NAME = "OryMini.exe"
DIST_NAME = "OryMini"
HIDDEN_IMPORTS = [
"PyQt5",
"pyqtgraph",
"cv2",
"orisys",
"gui",
"pipeline",
"viz",
]
EXCLUDE_MODULES = [
"cupy",
"cupyx",
"pyopencl",
"open3d",
"tkinter",
"pip",
"wheel",
]
DATA_DIRS = [
("config", "config"),
]
README_LINES = [
"ORY-MINI internal calibration/testing GUI (scaffold)",
"",
"This product packaging target is reserved for a future internal tool.",
"Entry point placeholder:",
f" {ENTRY_SCRIPT.as_posix()}",
]
def make_config() -> GuiBuildConfig:
return GuiBuildConfig(
product="ory_mini",
entry_script=ENTRY_SCRIPT,
exe_name=EXE_NAME,
dist_name=DIST_NAME,
hidden_imports=list(HIDDEN_IMPORTS),
exclude_modules=list(EXCLUDE_MODULES),
data_dirs=list(DATA_DIRS),
readme_lines=list(README_LINES),
windowed=False,
)
+104
View File
@@ -0,0 +1,104 @@
"""Unified Orisys packaging entrypoint.
Usage (from repo root):
python build/run.py wheel [--target internal|pypi-linux] [--verify]
python build/run.py wheel-win
python build/run.py wheel-manylinux [PowerShell args...]
python build/run.py gui finger|mini|box
python build/run.py verify [wheel.whl]
python build/run.py verify --gui-dist [--product ory_finger]
See build/README.md for details.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
_BUILD_ROOT = Path(__file__).resolve().parent
_REPO_ROOT = _BUILD_ROOT.parent
if str(_BUILD_ROOT) not in sys.path:
sys.path.insert(0, str(_BUILD_ROOT))
def _run_python(script: Path, argv: list[str]) -> int:
cmd = [sys.executable, str(script), *argv]
return subprocess.call(cmd, cwd=_REPO_ROOT)
def _cmd_wheel(argv: list[str]) -> int:
return _run_python(_BUILD_ROOT / "wheel" / "build.py", argv)
def _cmd_wheel_win(argv: list[str]) -> int:
bat = _BUILD_ROOT / "wheel" / "win.bat"
return subprocess.call(["cmd", "/c", str(bat), *argv], cwd=_REPO_ROOT)
def _cmd_manylinux(argv: list[str]) -> int:
ps1 = _BUILD_ROOT / "wheel" / "docker" / "run.ps1"
cmd = [
"powershell",
"-ExecutionPolicy",
"Bypass",
"-File",
str(ps1),
*argv,
]
return subprocess.call(cmd, cwd=_REPO_ROOT)
def _cmd_gui(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="build/run.py gui")
parser.add_argument(
"product",
choices=("finger", "mini", "box", "ory_finger", "ory_mini", "ory_box"),
help="GUI product to package",
)
args, rest = parser.parse_known_args(argv)
product = {
"finger": "ory_finger",
"mini": "ory_mini",
"box": "ory_box",
}.get(args.product, args.product)
return _run_python(_BUILD_ROOT / "gui" / product / "build.py", rest)
def _cmd_verify(argv: list[str]) -> int:
from common.verify.cli import main as verify_main
return verify_main(argv)
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
print("Commands: wheel | wheel-win | manylinux | gui | verify")
print("Examples:")
print(" python build/run.py wheel --help")
print(" python build/run.py gui finger")
print(" python build/run.py manylinux -PythonVersions 3.10")
return 0
command, rest = argv[0], argv[1:]
dispatch = {
"wheel": _cmd_wheel,
"wheel-win": _cmd_wheel_win,
"wheel-manylinux": _cmd_manylinux,
"gui": _cmd_gui,
"verify": _cmd_verify,
}
if command not in dispatch:
print(f"Unknown command: {command!r}", file=sys.stderr)
print("Choose from: wheel, wheel-win, manylinux, gui, verify", file=sys.stderr)
return 2
return dispatch[command](rest)
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
"""Nuitka-compiled SDK wheel packaging."""
+18 -9
View File
@@ -11,8 +11,9 @@ Targets:
pypi-linux – PyPI binary wheel; requires Linux + CPython 3.10/3.11/3.12 + compliant tag.
Run from repo root:
python build/build_nuitka.py
python build/build_nuitka.py --target pypi-linux
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
@@ -24,18 +25,26 @@ import subprocess
import sys
from pathlib import Path
from nuitka_compile import compile_orisys, copy_compiled_orisys, install_nuitka
from paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repaired_wheel_dir, repo_path
from pyproject_utils import bundle_items, extras_require, get_version, install_requires
from verify import verify_built_wheel
from wheel_build import (
_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,
)
from wheel_layout import copy_bundle_items, copy_orisys_blend, package_data_from_bundle
VALID_TARGETS = ("internal", "pypi-linux")
# ABI tags that manylinux images ship under /opt/python/cpXYY-cpXYY.
@@ -105,7 +114,7 @@ def build(*, target: str = "internal", verify: bool | None = None) -> Path:
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/")
print(f"Copied {copied_counts['sensors']} sensor bundle files to orisys/sensors/")
write_wheel_setup(
NUITKA_WHEEL_DIR,
@@ -8,11 +8,18 @@ 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",
@@ -23,4 +23,4 @@ RUN for PYBIN in \
done
WORKDIR /src
ENTRYPOINT ["/bin/bash", "/src/build/manylinux_entrypoint.sh"]
ENTRYPOINT ["/bin/bash", "/src/build/wheel/docker/entrypoint.sh"]
@@ -42,7 +42,7 @@ for tag in "${TAG_ARRAY[@]}"; do
"${PYBIN}/python" -m pip install -q --upgrade pip setuptools wheel auditwheel
if "${PYBIN}/python" build/build_nuitka.py --target "${TARGET}"; then
if "${PYBIN}/python" build/wheel/build.py --target "${TARGET}"; then
BUILT+=("${tag}")
else
echo "[ERROR] Build failed for ${tag}" >&2
@@ -1,17 +1,11 @@
# Build PyPI-compliant manylinux wheels for CPython 3.10/3.11/3.12 using Docker Desktop.
#
# Usage (from repo root, PowerShell):
# .\build\build_manylinux_docker.ps1
# .\build\build_manylinux_docker.ps1 -PythonVersions 3.10,3.11,3.12
# .\build\build_manylinux_docker.ps1 -PythonVersions 3.11
# .\build\build_manylinux_docker.ps1 -SkipImageBuild
# .\build\wheel\docker\run.ps1
# .\build\build_manylinux_docker.ps1 # compatibility wrapper
#
# From cmd.exe:
# powershell -ExecutionPolicy Bypass -File .\build\build_manylinux_docker.ps1
#
# Prerequisites:
# - Docker Desktop running in Linux container mode
# - Enough disk for the manylinux image (~2GB+)
param(
[ValidateSet("pypi-linux", "internal")]
@@ -39,7 +33,7 @@ function Convert-ToManylinuxTag {
}
}
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path
Set-Location $RepoRoot
$tags = @()
@@ -67,15 +61,17 @@ if ($dockerInfo -ne "linux") {
throw "Docker server OS is '$dockerInfo'. Switch Docker Desktop to Linux containers."
}
$entrypoint = Join-Path $RepoRoot "build\manylinux_entrypoint.sh"
$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 build/Dockerfile.manylinux -t $ImageName .
docker build -f $dockerfile -t $ImageName .
if ($LASTEXITCODE -ne 0) {
throw "docker build failed"
}
@@ -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"]
@@ -8,7 +8,11 @@ import subprocess
import sys
from pathlib import Path
from paths import sdk_dir
_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(
@@ -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"
)
)
+101
View File
@@ -0,0 +1,101 @@
{
"name": "dd06-ov",
"type": "single_camera",
"version": "1.0",
"camera": {
"resolution_raw": [
1280,
720
],
"fps": 30,
"resolution_stitch": [
195,
275
],
"source_scale": 0.4,
"old": [
672,
728,
1,
0.5
],
"work": [
504,
546,
1
],
"work2": [
420,
450,
0.8
],
"work3": [
350,
375,
4,
0.7
],
"work4": [
280,
300,
4,
0.4,
0.2
]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": [
"raft",
"dis"
],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 0.2,
"div_threshold": 0.022,
"expansion_threshold": 0.01,
"kernel_size": [
5,
5
],
"denoise_sigma": 12
},
"roi": {
"type": "polygon",
"image_size": [
1280,
720
],
"created_at": "2026-07-26T11:56:15",
"points": [
[
242,
1
],
[
402,
597
],
[
799,
606
],
[
976,
2
]
],
"mask_path": "dd06-ov_mask.png",
"metadata": {
"source": "./data/dd06/0726.mp4"
}
}
}
+101
View File
@@ -0,0 +1,101 @@
{
"name": "dd06-ov",
"type": "single_camera",
"version": "1.0",
"camera": {
"resolution_raw": [
1280,
720
],
"fps": 30,
"resolution_stitch": [
195,
275
],
"source_scale": 0.4,
"old": [
672,
728,
1,
0.5
],
"work": [
504,
546,
1
],
"work2": [
420,
450,
0.8
],
"work3": [
350,
375,
4,
0.7
],
"work4": [
280,
300,
4,
0.4,
0.2
]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": [
"raft",
"dis"
],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 0.2,
"div_threshold": 0.022,
"expansion_threshold": 0.01,
"kernel_size": [
5,
5
],
"denoise_sigma": 12
},
"roi": {
"type": "polygon",
"image_size": [
1280,
720
],
"points": [
[
628,
532
],
[
1145,
694
],
[
1161,
8
],
[
630,
126
]
],
"created_at": "2026-08-10T14:26:31",
"mask_path": "dd06-ov-0810_mask.png",
"metadata": {
"source": ".\\data\\dd06\\2026-08-10-142316.webm"
}
}
}
+1 -1
View File
@@ -31,7 +31,7 @@
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
"hhd_downsample": 1
},
"contact": {
"min_contour_area": 3000,
+1 -1
View File
@@ -31,7 +31,7 @@
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3,
"hhd_downsample": 1,
"backend": "cpu"
},
"contact": {
+2 -2
View File
@@ -5,7 +5,7 @@
"camera": {
"resolution_raw": [1280, 720],
"fps": 30,
"resolution_stitch": [420, 450],
"resolution_stitch": [280, 300],
"source_scale": 0.4,
"old":[672, 728, 1,0.5],
"work": [504, 546,1],
@@ -20,7 +20,7 @@
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"dis_mode": 1,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 5
},
BIN
View File
Binary file not shown.
+186 -150
View File
@@ -1,77 +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.2-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__)
```
***
## `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: 用相机编号创建
@@ -82,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_deformation(check_motion=False)
sensor.compute_contact()
```
***
## 4. `Sensor.compute_contact` 方法
### `Sensor.compute_contact` 方法
### 描述
#### 描述
基于已计算的法向形变场进行接触区域分析,输出接触质心与轮廓等信息,并生成接触深度图,默认配置在`json`文件中,可按需调节。
调用前置条件:需先执行`compute_deformation()`。
### 返回
#### 返回
`dict`为接触分析结果字典,至少包含以下键:
* **expansion_mask**: 扩张区域掩码(`np.ndarray`)。
@@ -148,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(
@@ -162,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
向量/图像类
@@ -188,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`
@@ -198,7 +152,7 @@ centroid, contour, depth = sensor.read_info(
注:H/W 由当前`json`文件中的`resolution_stitch`配置分辨率决定。
### 返回
#### 返回
`Tuple`
@@ -210,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(
@@ -223,22 +177,114 @@ fps, fn, fx, fy = sensor.read_info(
# Example 2: 读取单个向量场(注意解包方式)
flow = sensor.read_info(sensor.info.VRAW)
```
***
### `Sensor.disconnect` 方法
## 5. `Sensor.disconnect` 方法
### 描述
#### 描述
释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 OpenCV 窗口。
建议在程序退出前调用以确保资源被正确释放。
***
***
### `Sensor.configure` 低通滤波设置
## `orisys.finger` 手指 UV pipeline
#### 描述
`examples/products/ory_finger/finger_pipeline.py` 展示了一个更适合手指/单相机场景的高层 API:
运行时更新传感器设置,无需重新连接相机。常用于开启力信号低通滤波,降低 `FNORMAL`、`FSHEARX`、`FSHEARY` 的高频噪声。
默认采样率按 30 fps 计算,推荐低通截止频率为 5 Hz:
```python
sensor.configure(lowpass_cutoff_hz=5.0, lowpass_sample_rate_hz=30.0)
```
注意:`lowpass_cutoff_hz` 必须小于 Nyquist 频率,即 `lowpass_sample_rate_hz / 2`。例如 30 fps 时,截止频率必须小于 15 Hz。
#### 输入参数
* **lowpass_cutoff_hz** (`float`,可选): 力信号低通截止频率,单位 Hz。`0` 表示关闭滤波;正数默认开启滤波。
* **lowpass_sample_rate_hz** (`float`,可选): 力信号采样率,默认 `30.0`。
* **lowpass_order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
* **lowpass_enabled** (`bool`,可选): 显式开启或关闭低通滤波。
* **hhd_downsample** (`int`,可选): HHD 解算下采样倍数,需为 `>= 1` 的整数,配置默认 `3`。
#### 读取结果
开启低通滤波后,`read_info()` 读取的力输出会被滤波:
```python
fn, fx, fy = sensor.read_info(
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
)
```
原始力输出仍可通过 `FNORMAL_RAW`、`FSHEARX_RAW`、`FSHEARY_RAW` 读取,便于调试或对比。
## Signal Processing API
### `orisys.signal_processing.lowpass_filter` 函数
#### 描述
对一维、等间隔采样数据执行零相位 Butterworth 低通滤波,适合示例程序或图表中对历史数据做平滑处理。
默认参数为 `cutoff_hz=5.0`、`sample_rate_hz=30.0`、`order=2`。这表示在 30 fps 数据上使用 5 Hz 低通滤波。
#### 输入参数
* **data**: 一维数据序列。
* **cutoff_hz** (`float`,可选): 截止频率,默认 `5.0`。传入 `0` 时不做滤波并返回数据副本。
* **sample_rate_hz** (`float`,可选): 采样率,默认 `30.0`。
* **order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
#### 返回
* `np.ndarray`: 滤波后的 `float64` 数组。
#### 示例
```python
from orisys.signal_processing import lowpass_filter
filtered_force = lowpass_filter(force_values, cutoff_hz=5.0, sample_rate_hz=30.0)
```
### `orisys.signal_processing.LowpassFilter` 类别
#### 描述
有状态的因果 Butterworth 低通滤波器,适合逐帧处理实时数据。它会保留上一帧的滤波状态,并通过 `process()` 一次处理一个多通道样本。
`Sensor.configure()` 内部使用该类来滤波力信号。构造时默认 `cutoff_hz=0.0`,表示关闭滤波;若要按 30 fps 使用推荐滤波,设置 `cutoff_hz=5.0`、`sample_rate_hz=30.0`。
#### 输入参数
* **cutoff_hz** (`float`,可选): 截止频率,默认 `0.0`,表示关闭滤波。
* **sample_rate_hz** (`float`,可选): 采样率,默认 `30.0`。
* **order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。
* **n_channels** (`int`,可选): 每个样本的通道数,默认 `3`。
#### 常用方法
* **configure(...)**: 运行时更新 `cutoff_hz`、`sample_rate_hz` 或 `order`。
* **reset(x0=None)**: 清除滤波器状态;传入 `x0` 时会用该样本初始化稳态。
* **process(values)**: 滤波一个多通道样本,返回 shape 为 `(n_channels,)` 的 `np.ndarray`。
#### 示例
```python
from orisys.signal_processing import LowpassFilter
filt = LowpassFilter(cutoff_hz=5.0, sample_rate_hz=30.0, n_channels=3)
filtered = filt.process([fnormal, fshearx, fsheary])
```
## Finger Pipeline API
### `orisys.finger` 手指 UV pipeline
`examples/finger_pipeline.py` 展示了一个更适合手指/单相机场景的高层 API:
1. 使用 `FingerRuntime.open()` 打开视频源、加载 Blender 导出的资产,并构建 UV 展开映射。
2. 用 `grab_and_process()` 或 `read_source() + process_frame()` 获取一帧展开后的触觉图像。
@@ -247,7 +293,7 @@ flow = sensor.read_info(sensor.info.VRAW)
相比直接使用 `Sensor`,`FingerRuntime` 已封装了视频读取、相机尺寸设置、UV 展开和有效区域掩码传递,更适合作为手指 2D/3D 示例与上层应用的入口。
### 1. `FingerRuntime.open` 方法
### `FingerRuntime.open` 方法
#### 描述
@@ -262,7 +308,7 @@ flow = sensor.read_info(sensor.info.VRAW)
#### 输入参数
* **export_dir** (`str`,可选): 手指导出资产目录,默认值来自 `orisys.finger.DEFAULT_FINGER_EXPORT_DIR`。目录下通常需要包含 `mesh.npz`、`camera_intrinsics.npz`、`world_to_cam.npy`。
* **config** (`str`,可选): Orisys 配置文件名称或路径,默认 `dd02`(兼容解析到 `dd02-ov`)。
* **config** (`str`,可选): Orisys 配置文件名称或路径,默认 `finger`。
* **map_cache** (`str`,可选): UV 展开缓存文件路径,默认值来自 `orisys.finger.DEFAULT_FINGER_MAP_CACHE`。
* **source_scale** (`float`,可选): 输入相机分辨率缩放比例;未提供时根据配置自动推断。
* **calib** (`str | None`,可选): 外部相机标定文件路径;未提供时优先使用导出资产中的内参。
@@ -282,13 +328,13 @@ flow = sensor.read_info(sensor.info.VRAW)
from orisys.finger import FingerRuntime
runtime = FingerRuntime.open(
config="dd02",
config="finger",
video=0,
verbose=True,
)
```
### 2. `FingerRuntime` 常用属性
### `FingerRuntime` 常用属性
* **runtime.geometry**: `FingerGeometry`,包含输入/输出分辨率、导出资产路径、相机内参、缓存路径等一次性初始化结果。
* **runtime.domain**: UV 展开域对象,内部包含 remap、valid mask 以及可选 surface 数据。
@@ -296,7 +342,7 @@ runtime = FingerRuntime.open(
* **runtime.capture**: OpenCV `VideoCapture` 对象。
* **runtime.valid_mask**: 当前 UV 域有效像素掩码。
### 3. `FingerRuntime.read_source` 方法
### `FingerRuntime.read_source` 方法
#### 描述
@@ -308,11 +354,11 @@ runtime = FingerRuntime.open(
* **ok** (`bool`): 是否成功读取。
* **frame** (`np.ndarray | None`): 原始 BGR 帧。
### 4. `FingerRuntime.process_frame` 方法
### `FingerRuntime.process_frame` 方法
#### 描述
对一帧原始输入执行源图预处理、UV 展开,并把展开后的结果送入内部 `TactileProcessor`。这是 `examples/products/ory_finger/finger_pipeline.py` 中 profile 模式使用的方法。
对一帧原始输入执行源图预处理、UV 展开,并把展开后的结果送入内部 `TactileProcessor`。这是 `examples/finger_pipeline.py` 中 profile 模式使用的方法。
#### 输入参数
@@ -323,11 +369,11 @@ runtime = FingerRuntime.open(
* `np.ndarray`: 展开后的触觉图像。`color=False` 时通常为单通道灰度图;`color=True` 时为 BGR 图。
### 5. `FingerRuntime.grab_and_process` 方法
### `FingerRuntime.grab_and_process` 方法
#### 描述
先读取一帧视频源,再调用 `process_frame()` 完成 UV 展开并推入内部触觉处理器。非 profile 模式下,`examples/products/ory_finger/finger_pipeline.py` 使用该方法作为主循环入口。
先读取一帧视频源,再调用 `process_frame()` 完成 UV 展开并推入内部触觉处理器。非 profile 模式下,`examples/finger_pipeline.py` 使用该方法作为主循环入口。
#### 输入参数
@@ -339,7 +385,7 @@ runtime = FingerRuntime.open(
* **ok** (`bool`): 是否成功读取并处理。
* **unrolled** (`np.ndarray | None`): 展开后的触觉图像。
### 6. `FingerRuntime.get_img` 方法
### `FingerRuntime.get_img` 方法
#### 描述
@@ -353,11 +399,11 @@ runtime = FingerRuntime.open(
* `np.ndarray | None`
### 7. `FingerRuntime.warmup` 方法
### `FingerRuntime.warmup` 方法
#### 描述
先执行一次采集与 UV 展开,然后调用 `runtime.tactile.warmup()` 预热内部光流/HHD 相关缓存。`examples/products/ory_finger/finger_pipeline.py` 在进入主循环前会先执行一次 warmup。
先执行一次采集与 UV 展开,然后调用 `runtime.tactile.warmup()` 预热内部光流/HHD 相关缓存。`examples/finger_pipeline.py` 在进入主循环前会先执行一次 warmup。
#### 输入参数
@@ -369,13 +415,13 @@ runtime = FingerRuntime.open(
* **ok** (`bool`): 是否成功完成 warmup 前的采集。
* **unrolled** (`np.ndarray | None`): warmup 时得到的展开图。
### 8. `FingerRuntime.close` 方法
### `FingerRuntime.close` 方法
#### 描述
释放内部 `VideoCapture` 并调用 `runtime.tactile.disconnect()` 清理 `Sensor` 资源。程序退出前应调用。
### 9. `TactileProcessor` 类别
### `TactileProcessor` 类别
#### 描述
@@ -386,16 +432,16 @@ runtime = FingerRuntime.open(
* **raw_sensor**: 底层 `Sensor` 对象。
* **info**: 与 `Sensor.info` 相同的枚举入口。
* **img / flow / vnormal / vshear / depth_map**: 当前帧处理结果。
* **mask_stats**: 有效 UV 区域统计信息;`examples/products/ory_finger/finger_pipeline.py` 会读取 `mask_stats["coverage"]` 输出当前掩码覆盖率。
* **mask_stats**: 有效 UV 区域统计信息;`examples/finger_pipeline.py` 会读取 `mask_stats["coverage"]` 输出当前掩码覆盖率。
* **valid_mask**: 当前有效区域掩码。
#### 说明
除 `push_frame()` / `warmup()` 之外,`TactileProcessor` 的 `compute_deformation()`、`compute_contact()`、`read_info()`、`reset()` 调用方式与 `Sensor` 基本一致,因此可直接复用前文 `Sensor` 章节中的参数说明。
### 10. 最小手指 pipeline 示例
### 最小手指 pipeline 示例
下面的流程与 `examples/products/ory_finger/finger_pipeline.py` 一致:
下面的流程与 `examples/finger_pipeline.py` 一致:
```python
import time
@@ -404,7 +450,7 @@ import orisys
from orisys.finger import FingerRuntime
runtime = FingerRuntime.open(
config="dd02",
config="finger",
video=0,
verbose=True,
)
@@ -460,9 +506,9 @@ finally:
cv2.destroyAllWindows()
```
### 11. 关于 profile 模式
### 关于 profile 模式
`examples/products/ory_finger/finger_pipeline.py` 中的 `--profile` / `--profile-every` 是示例脚本参数,不属于 `orisys` SDK 核心 API。本质上它只是把主循环拆成:
`examples/finger_pipeline.py` 中的 `--profile` / `--profile-every` 是示例脚本参数,不属于 `orisys` SDK 核心 API。本质上它只是把主循环拆成:
1. `runtime.read_source()`
2. `runtime.process_frame(...)`
@@ -472,11 +518,11 @@ finally:
并对各阶段做耗时统计。因此在业务代码中,也可以按同样方式自行插入计时逻辑。
# 可视化相关
## 可视化相关
## 1. `util.draw_arrows` 方法
### `util.draw_arrows` 方法
### 输入参数
#### 输入参数
* **frame**:背景图像
* **optical_flow**:光流场
@@ -488,12 +534,12 @@ finally:
* **below_threshold_color**:RGB格式的`uint8`颜色,如(0, 0, 255)
* **edge_margin**: 不显示箭头边距(pixel)
### 返回
#### 返回
`np.ndarray` :箭头光流可视化图像
## 2. `util.draw_contact` 方法
### `util.draw_contact` 方法
### 输入参数
#### 输入参数
* **image**:背景图像
* **contour**:轮廓
@@ -501,42 +547,36 @@ finally:
* **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()`依赖最近一次获取并拼接的图像数据进行形变与力计算。
@@ -556,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' }` 列出摄像头设备。
BIN
View File
Binary file not shown.
Binary file not shown.
+97 -67
View File
@@ -1,21 +1,31 @@
叠动科技软件使用手册
版本: 0.4.2
更新日期: 2026年7月13日
---
title: 快速开始
description: 安装并运行 Orisys SDK 的基础步骤
---
# 简介
# 快速开始
版本:0.5.0
## 简介
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
支持操作系统:Windows、Ubuntu
建议Python 版本: 3.10, 3.11, 3.12
# 基础环境设置
## 安装 Python 与 Anaconda
项目链接:
- 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 的信息,则说明安装成功。
3. 安装完成后,打开命令行终端(Windows 用户可在 “Anaconda Navigator” 打开 “Anaconda Prompt”),输入 `conda info`,若能显示 conda 的信息,则说明安装成功。
## 创建conda虚拟环境
### 创建 conda 虚拟环境
虚拟环境相当于一个独立的 Python 工作空间,可以避免不同项目所需的库版本冲突。
打开命令行终端(或Anaconda Prompt),依次输入以下命令(以 python 3.10 为例):
打开命令行终端(或 Anaconda Prompt),依次输入以下命令(以 Python 3.10 为例):
```bash
conda create -n orisys python=3.10
conda activate orisys
@@ -23,8 +33,8 @@ conda activate orisys
激活后,命令行提示符前会出现 `(orisys)` 字样,表示当前已进入虚拟环境。
## 创建工作区文件夹
“Anaconda Prompt”启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理,可以手动创建或者运行`mkdir <path>`命令:, 例如
### 创建工作区文件夹
“Anaconda Prompt” 启动后的默认工作路径在用户根文件夹中,建议创建工作区文件夹方便管理。可以手动创建文件夹,也可以运行 `mkdir <path>` 命令。例如:
```bash
mkdir orisys_ws
@@ -35,46 +45,62 @@ mkdir orisys_ws
```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.2-cp310-cp310-win_amd64.whl 表示:
例如:orisys-0.5.0-cp310-cp310-win_amd64.whl 表示:
版本:0.4.2
版本: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
# 请将文件名替换为您实际下载的文件名
pip install orisys-0.4.2-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,10 +110,10 @@ 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:
@@ -95,72 +121,77 @@ pip install orisys[cuda13]
```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 库并查看版本号,验证是否成功:
### 测试安装
安装完成后,在命令行输入 `python` 命令进入 Python 环境,并导入 `orisys` 库查看版本号,验证是否成功:
```bash
python
```
进入 Python 后继续输入:
```python
import orisys
print(orisys.__version__)
```
如果输出版本号(例如 0.4.2),则说明安装成功。
如果输出版本号(例如 0.5.0),则说明安装成功。
# 使用方法
## 运行示例程序
套件里面包含了多个例子,建议都在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。按产品入口如下:
## 使用方法
### 运行示例程序
套件里面包含了多个例子。运行示例前,请先进入 SDK 示例所在的文件夹,建议在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。如果您是通过 PyPI 安装并且本地没有示例文件,可以从上方项目链接下载示例。按产品入口如下:
1. `examples/products/ory_box/basic_pipeline.py`:ORY-BOX 复眼/拼接传感器基础 OpenCV 示例(默认配置 `ddjx01`)
2. `examples/products/ory_mini/basic_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`)
3. `examples/products/ory_finger/finger_pipeline.py`:ORY-FINGER 2D UV 最小流程(默认配置 `dd02`)
4. `examples/products/ory_finger/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `dd02`)
1. `examples/basic_pipeline.py`:ORY-BOX 复眼/拼接传感器基础 OpenCV 示例(默认配置 `box`)
2. `examples/mini_pipeline.py`:ORY-MINI 基础 OpenCV 示例(默认配置 `mini`)
3. `examples/finger_pipeline.py`:ORY-FINGER 2D 显示最小流程(默认配置 `finger`)
4. `examples/finger_pipeline_3dview.py`:ORY-FINGER Qt 3D 可视化界面(默认配置 `finger`)
`dd02` 是当前 ORY-FINGER 的稳定别名,实际会解析到 `dd02-ov`。旧路径 `examples/basic_pipeline.py` / `examples/finger_pipeline*.py` 仍可作为兼容入口。
`finger` 是当前 ORY-FINGER 的产品配置名。
下面先介绍最基础的 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, 切换到工作路径,然后在命令行中输入:
#### 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/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
python examples/basic_pipeline.py -v 0 -c box
```
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据
5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
### Ubuntu:
1. 检查是否有其他正在连接的USB摄像头设备:打开终端, 运行命令`v4l2 –list-devices`,如果没有的话不会有输出。
2. 把传感器的USB头与电脑连接,再次运行步骤一的命令,会新增输出
3. 根据新增的输出,例如”/dev/video0”,确定传感器的ID为“0”, 如果是”/dev/video3”的话ID为“3” ,如此类推。
4. 在确定了传感器的ID(下方例子假设为“0”)后,打开终端, 激活相应的python环境,切换到工作路径,然后在命令行中输入:
#### Ubuntu
1. 检查是否有其他正在连接的 USB 摄像头设备:打开终端,运行命令 `v4l2 --list-devices`,如果没有的话不会有输出。
2. 把传感器的 USB 头与电脑连接,再次运行步骤一的命令,会新增输出。
3. 根据新增的输出,例如 `/dev/video0`,确定传感器的 ID 为 `0`;如果是 `/dev/video3`,ID 为 `3`,如此类推。
4. 在确定了传感器的 ID(下方例子假设为 `0`)后,打开终端,激活相应的 Python 环境,切换到工作路径,然后在命令行中输入:
```bash
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
python examples/basic_pipeline.py -v 0 -c box
```
5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据
5. 程序会运行,并自动打开可视化窗口和打印资料。在窗口上按键盘 `q` 键可以停止运行,按 `r` 键可以重设数据。
### ORY-FINGER UV 示例
#### ORY-FINGER 示例
如果您使用的是 ORY-FINGER(当前配置别名 `dd02`),可运行:
如果您使用的是 ORY-FINGER(配置名 `finger`),可运行:
```bash
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
python examples/finger_pipeline.py --camera_id 0 --config finger
```
如果输入来自视频文件,也可以改用:
```bash
python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4 --config dd02
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
```
该示例会:
- 读取相机或视频源
- 将图像展开到 UV 平面
- 将图像展开到平面
- 计算光流、法向/切向力和接触区域
- 在 OpenCV 窗口中显示展开后的光流箭头图
@@ -171,19 +202,18 @@ python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4
例如:
```bash
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
python examples/finger_pipeline.py --camera_id 0 --config finger
```
### ORY-FINGER 3D 可视化界面
#### ORY-FINGER 3D 可视化界面
如果要使用 GUI ,安装GUI依赖:
如果要使用 GUI,先安装 GUI 依赖:
```bash
pip install open3d
pip install pyqt5 pyqtgraph
pip install "orisys[gui]"
```
然后运行:
```bash
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
python examples/finger_pipeline_3dview.py --video 0 --config finger
```
该界面会提供:
@@ -192,5 +222,5 @@ python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config
- 力曲线
- 3D 视图
## 开发套件使用方法
### 开发套件使用方法
参考开发套件附带的开发文档。
Binary file not shown.
-167
View File
@@ -1,167 +0,0 @@
# Publishing Orisys to PyPI
This document covers the **Linux manylinux binary wheel** path (Nuitka-compiled
`orisys` for CPython 3.10 / 3.11 / 3.12). Windows wheels are still built on
Windows hosts; they cannot be produced inside the manylinux container.
## Why Docker?
PyPI rejects wheels tagged `manylinux_x86_64` (missing glibc ABI version). A
compliant tag looks like:
```text
orisys-0.4.2-cp310-cp310-manylinux_2_28_x86_64.whl
orisys-0.4.2-cp311-cp311-manylinux_2_28_x86_64.whl
orisys-0.4.2-cp312-cp312-manylinux_2_28_x86_64.whl
```
Those tags are produced by building inside the official manylinux image and
running `auditwheel repair`. On Windows, use **Docker Desktop in Linux container
mode**.
> Note: we use `manylinux_2_28_x86_64` (not 2014). Recent NumPy/SciPy no longer
> provide manylinux2014 wheels and fail to build from source on that older GCC.
## Prerequisites
1. Docker Desktop installed and running (**Linux containers**, not Windows containers).
2. Repository checked out locally (this tree is bind-mounted into the container).
3. Version bumped in `src/orisys/__init__.py` (`__version__`) before every upload.
PyPI never allows re-uploading the same filename/version.
4. PyPI / TestPyPI project-scoped API tokens (never commit tokens or `.pypirc` with secrets).
Quick Docker check:
```powershell
docker version --format "{{.Server.Os}}"
# expect: linux
docker run --rm hello-world
```
## Build manylinux wheels (3.10 / 3.11 / 3.12)
From the repository root. Prefer PowerShell, or from **cmd.exe**:
```bat
powershell -ExecutionPolicy Bypass -File .\build\build_manylinux_docker.ps1
```
PowerShell:
```powershell
.\build\build_manylinux_docker.ps1
```
Default builds **all three** ABIs. To build a subset:
```powershell
.\build\build_manylinux_docker.ps1 -PythonVersions 3.10,3.12
.\build\build_manylinux_docker.ps1 -PythonVersions 3.11
```
What this does:
1. Builds `orisys-manylinux` from [`build/Dockerfile.manylinux`](../build/Dockerfile.manylinux)
(`quay.io/pypa/manylinux_2_28_x86_64` with Nuitka/auditwheel in cp310/cp311/cp312).
2. Runs [`build/manylinux_entrypoint.sh`](../build/manylinux_entrypoint.sh), which loops:
```bash
/opt/python/cp310-cp310/bin/python build/build_nuitka.py --target pypi-linux
/opt/python/cp311-cp311/bin/python build/build_nuitka.py --target pypi-linux
/opt/python/cp312-cp312/bin/python build/build_nuitka.py --target pypi-linux
```
3. For each ABI: Nuitka compile → wheel → `auditwheel repair` → isolated smoke test.
4. Artifacts land in:
- `dist/repaired/orisys-*-manylinux_2_28_x86_64.whl` (upload candidates)
- `SDK/<version>/` (same compliant wheels)
Expect roughly **3×** the wall time of a single-ABI build.
Rebuild without refreshing the image:
```powershell
.\build\build_manylinux_docker.ps1 -SkipImageBuild
```
Equivalent manual `docker` invocation:
```powershell
docker build -f build/Dockerfile.manylinux -t orisys-manylinux .
docker run --rm `
-e ORISYS_BUILD_TARGET=pypi-linux `
-e ORISYS_PYTHON_TAGS=cp310-cp310,cp311-cp311,cp312-cp312 `
-v "${PWD}:/src" `
orisys-manylinux
```
### Acceptance checks before upload
- Each filename contains `cp310` / `cp311` / `cp312` and `manylinux_2_28_x86_64`
(or another accepted manylinux tag such as `manylinux_2_17_x86_64`).
- Isolated smoke test printed `public API imports OK` for every ABI.
- Do **not** upload any wheel still tagged `linux_x86_64` or bare `manylinux_x86_64`.
Optional re-verify on a Linux machine:
```bash
python build/verify.py dist/repaired/orisys-*-cp310-cp310-manylinux_*.whl
```
## Promote: TestPyPI then PyPI
Install upload tooling in a clean environment:
```powershell
python -m pip install --upgrade twine
```
### 1. Upload to TestPyPI
```powershell
$env:TWINE_USERNAME = "__token__"
$env:TWINE_PASSWORD = "<TestPyPI API token>"
python -m twine check dist/repaired/*.whl
python -m twine upload --repository testpypi dist/repaired/*.whl
```
Install from TestPyPI in a fresh venv (pull runtime deps from real PyPI), testing
each Python you care about:
```powershell
py -3.10 -m venv .venv-testpypi310
.\.venv-testpypi310\Scripts\Activate.ps1
python -m pip install --index-url https://test.pypi.org/simple/ `
--extra-index-url https://pypi.org/simple `
"orisys==<version>"
python -c "import orisys; print(orisys.__version__)"
```
### 2. Upload the same artifacts to PyPI
Only after TestPyPI install/import succeeds:
```powershell
$env:TWINE_USERNAME = "__token__"
$env:TWINE_PASSWORD = "<PyPI API token>"
python -m twine upload dist/repaired/*.whl
```
Prefer CI **Trusted Publishing** (OIDC) for production uploads so long-lived tokens
are not stored on developer machines.
## Related local targets
| Command | Purpose |
|---------|---------|
| `python build/build_nuitka.py` | Internal SDK wheel on the current host (Windows `.pyd` or Linux `.so`) |
| `python build/build_nuitka.py --target pypi-linux` | Linux-only; requires manylinux + CPython 3.10–3.12 |
| `.\build\build_nuitka_win.bat` | Windows wheels for py310/py311/py312 conda envs |
| `.\build\build_manylinux_docker.ps1` | Linux manylinux wheels for 3.10/3.11/3.12 via Docker |
| `python -m build` | Pure-Python / sdist path from `pyproject.toml` (no Nuitka) |
Windows binary wheels and Linux manylinux wheels must be built on their respective
platforms. Never rename a platform tag to bypass PyPI validation.
+13 -17
View File
@@ -1,54 +1,50 @@
# Examples
Product-facing demos live under `examples/products/`. Shared helpers stay in
Runnable demos live at the `examples/` root. Shared helpers stay in
`pipeline/`, `viz/`, and `gui/`. Run commands from the repository root.
## Product Entry Points
| Product | Entry | Default config |
|---------|-------|----------------|
| ORY-BOX | `products/ory_box/basic_pipeline.py` | `ddjx01` |
| ORY-MINI | `products/ory_mini/basic_pipeline.py` | `mini` |
| ORY-FINGER (2D) | `products/ory_finger/finger_pipeline.py` | `dd02` |
| ORY-FINGER (3D Qt) | `products/ory_finger/finger_pipeline_3dview.py` | `dd02` |
| ORY-BOX | `basic_pipeline.py` | `box` |
| ORY-MINI | `mini_pipeline.py` | `mini` |
| ORY-FINGER (2D) | `finger_pipeline.py` | `finger` |
| ORY-FINGER (3D Qt) | `finger_pipeline_3dview.py` | `finger` |
`dd02` is the stable ORY-FINGER alias and currently resolves to `dd02-ov`.
Compatibility wrappers still exist at the `examples/` root
(`basic_pipeline.py`, `finger_pipeline.py`, `finger_pipeline_3dview.py`) and
forward to the product entries above.
`finger` is the public ORY-FINGER config stem. `dd02` remains a compatibility alias for `dd02-ov`.
## Typical Usage
ORY-BOX:
```bash
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
python examples/basic_pipeline.py -v 0 -c box
```
ORY-MINI:
```bash
python examples/products/ory_mini/basic_pipeline.py -v 0 -c mini
python examples/mini_pipeline.py -v 0 -c mini
```
ORY-FINGER 2D:
```bash
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
python examples/finger_pipeline.py --camera_id 0 --config finger
```
ORY-FINGER video file:
```bash
python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4 --config dd02
python examples/finger_pipeline.py --video ./data/sample.mp4 --config finger
```
ORY-FINGER 3D Qt dashboard:
```bash
pip install -e ".[gui]"
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
python examples/finger_pipeline_3dview.py --video 0 --config finger
```
## Other Root Examples
@@ -70,10 +66,10 @@ pip install -e ".[gui]"
## Notes
- `products/ory_finger/finger_pipeline_3dview.py` is the main interactive finger demo.
- `finger_pipeline_3dview.py` is the main interactive finger demo.
It uses `gui/app_paths.py` so the same entry works in editable and packaged runs.
- Dashboard controls: click **启动** to start acquisition; use **Record CSV / Stop CSV**
and the CSV save-directory browser to log contact/force data; `Q` quits, `R` resets,
`S` starts video recording, `E` stops video recording, and `Esc` toggles fullscreen.
- `products/ory_finger/finger_pipeline.py` is the leanest finger example if you want to
- `finger_pipeline.py` is the leanest finger example if you want to
validate UV unroll + force/contact processing without the full dashboard.
+125 -13
View File
@@ -1,21 +1,133 @@
#!/usr/bin/env python3
"""Compatibility wrapper for ORY-BOX.
Prefer:
python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01
"""
ORY-BOX:单传感器基础流程
from __future__ import annotations
本示例演示 Orisys SDK 的标准处理流程:
1. 初始化传感器(摄像头或视频文件)
2. 获取图像并计算形变
3. 按需计算接触区域
4. 读取并可视化结果
import sys
from pathlib import Path
SDK version: 0.5.0
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import argparse
_EXAMPLES_DIR = Path(__file__).resolve().parent
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径")
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
args = parser.parse_args()
from products.ory_box.basic_pipeline import main
# 步骤 1:创建传感器对象(必需)
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
# 输入既可以是摄像头编号,也可以是视频文件路径
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' 重置追踪器。")
while True:
# 步骤 2:数据处理
# 步骤 2.1:获取并拼接图像(必需)
sensor.get_img()
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
is_contact_now = sensor.compute_deformation()
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
# if is_contact_now:
sensor.compute_contact()
if __name__ == "__main__":
# 步骤 2.4:读取结果(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,
sensor.info.IMG,
sensor.info.CONTOUR,
sensor.info.CENTROID,
sensor.info.DEPTH,
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
sensor.info.FNORMAL_RAW,
sensor.info.FSHEARX_RAW,
sensor.info.FSHEARY_RAW,
)
# 步骤 3:可视化显示
# 步骤 3.1:显示形变矢量场
arrows = orisys.util.draw_arrows(
img,
flow,
threshold=2,
grid_spacing=20,
arrow_scale=1.0
)
cv2.imshow("arrow", arrows)
# 步骤 3.2:显示接触区域和质心
image_with_foe = orisys.util.draw_contact(img, contour, centroid)
cv2.imshow("contact", image_with_foe)
# 步骤 3.3:显示深度图
# 将深度值归一化到 0-255 范围
div_abs = depth_map
if div_abs.max() > div_abs.min():
div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
else:
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
# 应用伪彩色映射
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
# 步骤 3.4:打印关键结果(标定力 + 原始力)
print(
f"FPS={fps:.2f}, "
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:键盘控制
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
print("\n正在退出示例程序...")
break
if key == ord("r"):
# 当光流追踪出现漂移时,可手动重置追踪器
sensor.reset()
print("追踪器已重置。")
# =========================================================================
# 4. 释放资源
# =========================================================================
sensor.disconnect() # 释放视频源及相关资源
if __name__ == '__main__':
main()
+101 -10
View File
@@ -1,20 +1,111 @@
#!/usr/bin/env python3
"""Compatibility wrapper for ORY-FINGER 2D pipeline.
"""
ORY-FINGER:Minimum UV-unroll finger tactile pipeline.
Prefer:
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
Recommended entry:
python examples/finger_pipeline.py --camera_id 0 --config finger
Current product config: ``finger`` (``dd02`` still resolves to ``dd02-ov``).
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
_EXAMPLES_DIR = Path(__file__).resolve().parent
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
from products.ory_finger.finger_pipeline import main
import cv2
import orisys
from orisys.finger import FingerRuntime
def main():
parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline")
parser.add_argument("--camera_id", type=int, default=0)
parser.add_argument("--video","-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")
args = parser.parse_args()
try:
video = args.camera_id
if args.video:
video = args.video
runtime = FingerRuntime.open(
config=args.config,
video=video,
verbose=args.verbose,
)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
if args.verbose:
geometry = runtime.geometry
print(
f"UV geometry: native={geometry.native_width}x{geometry.native_height}, "
f"source={geometry.source_width}x{geometry.source_height} "
f"output={geometry.out_width}x{geometry.out_height} "
)
runtime.warmup(color=False)
tactile = runtime.tactile
print("Press 'q' to quit, 'r' to reset tracker.")
try:
while True:
ok, unrolled = runtime.grab_and_process(color=False)
if not ok:
break
tactile.compute_deformation()
tactile.compute_contact()
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = tactile.read_info(
tactile.info.FPS,
tactile.info.VRAW,
tactile.info.VNORMAL,
tactile.info.IMG,
tactile.info.CONTOUR,
tactile.info.CENTROID,
tactile.info.DEPTH,
tactile.info.FNORMAL,
tactile.info.FSHEARX,
tactile.info.FSHEARY,
)
status = (
f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) "
f"mask={tactile.mask_stats['coverage']:.2f}"
)
if args.show:
arrows = orisys.util.draw_arrows(
unrolled,
flow,
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
valid_mask=tactile.valid_mask,
)
cv2.imshow("UV Unrolled", arrows)
print(status, end="\r")
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
print("\nExiting.")
break
if key == ord("r"):
tactile.reset()
print("\nTracker reset.")
finally:
runtime.close()
cv2.destroyAllWindows()
if __name__ == "__main__":
+76 -6
View File
@@ -1,20 +1,90 @@
#!/usr/bin/env python3
"""Compatibility wrapper for ORY-FINGER 3D Qt dashboard.
"""
ORY-FINGER:UV-unroll finger tactile pipeline with unified Qt dashboard.
Prefer:
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
Where to customize the GUI:
Layout (sizes, proportions) -> examples/gui/viewer3d/layout.py
Colors / QSS -> examples/gui/viewer3d/styles.py
Widget rendering logic -> examples/gui/viewer3d/widgets/
Window behavior -> examples/gui/viewer3d/dashboard.py
Recommended entry:
python examples/finger_pipeline_3dview.py --video 0 --config finger
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
# Make shared packages importable when launched as a file path.
_EXAMPLES_DIR = Path(__file__).resolve().parent
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
from products.ory_finger.finger_pipeline_3dview import main
from gui.app_paths import (
configure_frozen_runtime,
default_config_path,
ensure_examples_import_path,
require_gui_extras,
)
configure_frozen_runtime()
ensure_examples_import_path()
require_gui_extras()
from gui import run_dashboard
from pipeline import PipelineConfig
from viz import DEFAULT_FINGER_VIZ_POSE
def _parse_args():
parser = argparse.ArgumentParser(
description="UV-unroll finger tactile pipeline with unified Qt dashboard"
)
parser.add_argument("--video", "-v", default="0", help="Camera index or video file path")
parser.add_argument("--config", "-c", default=default_config_path(), help="Orisys SDK config")
parser.add_argument("--cal", default="", help="Optional calibration file path")
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
parser.add_argument(
"--mesh-map",
choices=["flow", "depth"],
default="depth",
help="Color the 3D mesh from flow magnitude or contact depth.",
)
parser.add_argument("--threshold", type=float, default=None, help="Mesh/arrow threshold")
parser.add_argument("--no-3d", action="store_true", help="Disable 3D mesh view")
parser.add_argument(
"--viz-pose",
default=DEFAULT_FINGER_VIZ_POSE,
help="world_to_cam.npy for the default view; pass 'none' to skip",
)
parser.add_argument(
"--cutoff",
type=float,
default=5.0,
help="Low-pass cutoff for force curves (Hz)",
)
parser.add_argument(
"--profile",
action="store_true",
help="Print per-stage timing to the log panel / console",
)
parser.add_argument(
"--profile-every",
type=int,
default=30,
help="When --profile is set, print rolling averages every N frames",
)
return parser.parse_args()
def main():
args = _parse_args()
config = PipelineConfig.from_args(args)
sys.exit(run_dashboard(config))
if __name__ == "__main__":
+11 -2
View File
@@ -27,8 +27,17 @@ def resource_path(*parts: str) -> Path:
def default_config_path() -> str:
"""Default ORY-FINGER config name (resolves to dd02-ov via alias)."""
return "dd02"
"""Default ORY-FINGER config name (packaged stem ``finger``)."""
return "finger"
def require_gui_extras() -> None:
try:
import open3d # noqa: F401
from PyQt5 import QtWidgets # noqa: F401
import pyqtgraph # noqa: F401
except ImportError as exc:
raise SystemExit('GUI example requires: pip install "orisys[gui]"') from exc
def configure_frozen_runtime() -> None:
+6 -5
View File
@@ -1,10 +1,11 @@
{
"video_source": "D:/repo/orisys-sdk/data/dd02/0607_1.mp4",
"config_path": "./config/dd02-ov.json",
"finger_assets_dir": "D:/repo/orisys-sdk/assets/finger/30fps-0703",
"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": "pure_color",
"free_rotate": true
"arrow_background": "unrolled",
"free_rotate": true,
"sensor_bundle_dir": "sensors/finger"
}
+1 -1
View File
@@ -111,7 +111,7 @@ class QtViewerWindow(QMainWindow):
self.btn_config.setObjectName("btnConfig")
self.btn_config.clicked.connect(self._select_config)
bar.addWidget(self.btn_config)
self.config_edit = QLineEdit("./config/ddjx01.json")
self.config_edit = QLineEdit("box")
bar.addWidget(self.config_edit, 1)
self.btn_reset = QPushButton("重置")
+30 -6
View File
@@ -570,6 +570,8 @@ class FingerDashboardWindow(QMainWindow):
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()
@@ -588,26 +590,48 @@ class FingerDashboardWindow(QMainWindow):
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)
self.config.config = 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)
self.config.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:
+103 -3
View File
@@ -13,6 +13,17 @@ 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
@@ -23,6 +34,94 @@ 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 {}
@@ -34,14 +133,14 @@ def _read_json(path: Path) -> dict[str, Any]:
def load_preferences() -> dict[str, Any]:
"""Load preferences, migrating camera_id from legacy file when needed."""
"""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 prefs
return _normalize_preferences(prefs)
def save_preferences(prefs: dict[str, Any]) -> None:
@@ -49,9 +148,10 @@ def save_preferences(prefs: dict[str, Any]) -> None:
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(prefs, indent=2) + "\n", encoding="utf-8")
path.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
except OSError:
pass
+143
View File
@@ -0,0 +1,143 @@
"""Finger-compatible sensor-bundle presets for the 3D dashboard."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
def _repo_root_candidates() -> list[Path]:
here = Path(__file__).resolve()
return [
Path.cwd(),
here.parents[3], # examples/gui/viewer3d -> repo root
]
@dataclass(frozen=True)
class SensorBundlePreset:
label: str
bundle_dir: str
config: str
export_dir: str
def _packaged_finger_preset() -> SensorBundlePreset | None:
"""Use logical names that resolve through the installed wheel sensors/ layout."""
try:
from orisys.configs import resolve_sensor_bundle
except ImportError:
return None
bundle = resolve_sensor_bundle("finger")
if bundle is None:
return None
return SensorBundlePreset(
label="ORY-FINGER",
bundle_dir="sensors/finger",
config="finger",
export_dir=DEFAULT_FINGER_EXPORT_DIR,
)
def _load_manifest(bundle_root: Path) -> dict:
path = bundle_root / "manifest.json"
if not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def _bundle_from_root(bundle_root: Path, *, label: str | None = None) -> SensorBundlePreset | None:
sensor_json = bundle_root / "sensor.json"
if not sensor_json.is_file():
return None
manifest = _load_manifest(bundle_root)
assets_dir = manifest.get("assets_dir", "assets/30fps-0703")
if not isinstance(assets_dir, str) or not assets_dir.strip():
assets_dir = "assets/30fps-0703"
rel_bundle = bundle_root.as_posix()
for root in _repo_root_candidates():
try:
rel_bundle = bundle_root.relative_to(root).as_posix()
break
except ValueError:
continue
# Packaged / checkout sensors/finger: keep resolvable product names, not absolute paths.
if rel_bundle.replace("\\", "/").endswith("sensors/finger") or bundle_root.name == "finger":
return SensorBundlePreset(
label=label or "ORY-FINGER",
bundle_dir="sensors/finger",
config="finger",
export_dir=DEFAULT_FINGER_EXPORT_DIR,
)
return SensorBundlePreset(
label=label or "ORY-FINGER",
bundle_dir=rel_bundle,
config=f"{rel_bundle}/sensor.json",
export_dir=f"{rel_bundle}/{assets_dir}".replace("\\", "/"),
)
def default_finger_bundle() -> SensorBundlePreset:
packaged = _packaged_finger_preset()
if packaged is not None:
return packaged
for root in _repo_root_candidates():
preset = _bundle_from_root(root / "sensors" / "finger", label="ORY-FINGER")
if preset is not None:
return preset
return SensorBundlePreset(
label="ORY-FINGER",
bundle_dir="sensors/finger",
config="finger",
export_dir=DEFAULT_FINGER_EXPORT_DIR,
)
def discover_sensor_bundle_presets() -> list[SensorBundlePreset]:
"""Return finger-compatible sensor bundle presets."""
presets = [default_finger_bundle()]
known_dirs = {presets[0].bundle_dir.replace("\\", "/")}
for root in _repo_root_candidates():
sensors_root = root / "sensors"
if not sensors_root.is_dir():
continue
for child in sorted(sensors_root.iterdir()):
if not child.is_dir():
continue
if child.name != "finger":
continue
preset = _bundle_from_root(child, label="ORY-FINGER")
if preset is None:
continue
key = preset.bundle_dir.replace("\\", "/")
if key in known_dirs:
continue
presets.append(preset)
known_dirs.add(key)
break
return presets
def sensor_bundle_preset_index(
config: str,
export_dir: str,
presets: list[SensorBundlePreset],
) -> int:
config = (config or "").strip().replace("\\", "/")
export_dir = (export_dir or "").strip().replace("\\", "/")
for idx, preset in enumerate(presets):
if config == preset.config and export_dir == preset.export_dir:
return idx
if config in ("finger", "dd02", "dd02-ov") and preset.config == "finger":
return idx
if config.endswith("/sensor.json") and export_dir.endswith("/" + Path(preset.export_dir).name):
return idx
return -1
@@ -110,6 +110,8 @@ class SettingsPanelWidget(QWidget):
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")
@@ -137,7 +139,7 @@ class SettingsPanelWidget(QWidget):
root.addWidget(self._caption("Config"))
config_row = QHBoxLayout()
config_row.setSpacing(6)
self.config_edit = QLineEdit("dd02")
self.config_edit = QLineEdit("finger")
config_row.addWidget(self.config_edit, 1)
self.btn_browse_config = QPushButton("…")
self.btn_browse_config.setFixedWidth(32)
@@ -7,7 +7,7 @@ ORY-MINI:单传感器基础流程
3. 按需计算接触区域
4. 读取并可视化结果
SDK version: 0.4.2
SDK version: 0.5.0
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
@@ -43,7 +43,7 @@ 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()
+1 -1
View File
@@ -17,7 +17,7 @@ def mesh_threshold(mesh_map: str, threshold: float | None) -> float:
@dataclass
class PipelineConfig:
video: str = "0"
config: str = "dd02"
config: str = "finger"
calib: str | None = None
export_dir: str = DEFAULT_FINGER_EXPORT_DIR
csv_save_dir: str = "./outputs/csv"
+25 -24
View File
@@ -1,29 +1,24 @@
"""
示例 3:实时曲线与幅值图显示
示例 3:实时曲线与深度图显示
本示例演示如何在读取传感器结果的同时:
1. 显示形变矢量场
2. 显示流场幅值热力图
2. 显示接触深度图
3. 使用 PyQtGraph 实时绘制法向力与切向力曲线
4. 按键控制录像开始与结束
"""
import argparse
import os
import sys
import time
from pathlib import Path
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import numpy as np
import orisys
_EXAMPLES_DIR = Path(__file__).resolve().parent
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
from viz import ForceChartWindow, FrameRecorder, draw_magnitude_map, lowpass_filter
from viz import ForceChartWindow, FrameRecorder
def open_sensor(video: str, config: str, cal: str, verbose: bool):
@@ -37,14 +32,21 @@ def open_sensor(video: str, config: str, cal: str, verbose: bool):
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--video", "-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
parser.add_argument("--config", "-c", type=str, default="./config/ddjx01.json", help="配置文件名称或路径")
parser.add_argument("--cal", "-cal", type=str, default="./config/ddjx01.npy", help="标定文件路径")
parser.add_argument("--config", "-c", type=str, default="box", help="配置文件名称或路径")
parser.add_argument("--cal", "-cal", type=str, default="", help="标定文件路径")
parser.add_argument(
"--cutoff",
type=float,
default=5.0,
help="力曲线低通滤波截止频率(Hz),30 Hz 采样时必须小于 15;设为 0 可关闭滤波",
)
parser.add_argument("--verbose", "-verbose", type=bool, default=True, help="是否输出详细日志")
args = parser.parse_args()
charts = ForceChartWindow()
recorder = FrameRecorder()
sensor = open_sensor(args.video, args.config, args.cal, args.verbose)
sensor.configure(lowpass_cutoff_hz=args.cutoff, lowpass_sample_rate_hz=30.0)
start_time = time.time()
print("\n按键说明:'q' 退出程序,'s' 开始录像,'e' 结束录像。")
@@ -53,31 +55,30 @@ def main():
sensor.get_img()
recorder.write_if_recording(sensor.frame)
sensor.compute_deformation(check_motion=True, threshold=0)
fps, fn, fx, fy, flow, vnormal, vshear = sensor.read_info(
sensor.compute_deformation()
sensor.compute_contact()
fps, fn, fx, fy, flow, depth_map = sensor.read_info(
sensor.info.FPS,
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
sensor.info.VRAW,
sensor.info.VNORMAL,
sensor.info.VSHEAR,
sensor.info.DEPTH,
)
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}")
arrows = orisys.util.draw_arrows(sensor.img, flow, threshold=5, grid_spacing=10, arrow_scale=1.0)
cv2.imshow("形变矢量场", arrows)
cv2.imshow("流场幅值图", draw_magnitude_map(flow))
if depth_map.max() > depth_map.min():
depth_normalized = (
(depth_map - depth_map.min()) / (depth_map.max() - depth_map.min()) * 255
).astype(np.uint8)
else:
depth_normalized = np.zeros_like(depth_map, dtype=np.uint8)
cv2.imshow("depth", cv2.applyColorMap(depth_normalized, cv2.COLORMAP_JET))
charts.push(time.time() - start_time, fn, fx, fy)
display = charts.data.copy()
for channel in range(3):
display[:, 1, channel] = lowpass_filter(
charts.data[:, 1, channel],
cutoff_hz=5,
sample_rate_hz=30,
)
charts.refresh(display)
charts.refresh()
key = cv2.waitKey(1)
if key & 0xFF == ord("q"):
-1
View File
@@ -1 +0,0 @@
"""Product-facing example entry points for ORY-BOX / ORY-MINI / ORY-FINGER."""
-115
View File
@@ -1,115 +0,0 @@
"""
ORY-BOX:单传感器基础流程
本示例演示 Orisys SDK 的标准处理流程:
1. 初始化传感器(摄像头或视频文件)
2. 获取图像并计算形变
3. 按需计算接触区域
4. 读取并可视化结果
SDK version: 0.4.2
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import orisys
import numpy as np
import argparse
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
parser.add_argument("--config","-c", type=str, default="ddjx01", help="配置文件名称或路径")
parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志")
args = parser.parse_args()
# 步骤 1:创建传感器对象(必需)
# config_name 可以是内置配置名称,也可以是自定义配置文件路径
# cal_path 为标定文件路径;每台设备建议使用独立的标定文件
# 输入既可以是摄像头编号,也可以是视频文件路径
try:
input = int(args.video)
is_camera = True
sensor = orisys.Sensor(input, config_name=args.config, verbose=args.verbose)
except:
is_camera = False
sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose)
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
while True:
# 步骤 2:数据处理
# 步骤 2.1:获取并拼接图像(必需)
sensor.get_img()
# 步骤 2.2:计算形变场(必需);返回值表示当前帧是否检测到接触/运动
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0)
# 步骤 2.3:仅在当前帧检测到接触/运动时计算接触区域
# if is_contact_now:
sensor.compute_contact()
# 步骤 2.4:读取结果
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = sensor.read_info(
sensor.info.FPS,
sensor.info.VRAW,
sensor.info.VNORMAL,
sensor.info.IMG,
sensor.info.CONTOUR,
sensor.info.CENTROID,
sensor.info.DEPTH,
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY
)
# 步骤 3:可视化显示
# 步骤 3.1:显示形变矢量场
arrows = orisys.util.draw_arrows(
img,
flow,
threshold=2,
grid_spacing=20,
arrow_scale=1.0
)
cv2.imshow("arrow", arrows)
# 步骤 3.2:显示接触区域和质心
image_with_foe = orisys.util.draw_contact(img, contour, centroid)
cv2.imshow("contact", image_with_foe)
# 步骤 3.3:显示深度图
# 将深度值归一化到 0-255 范围
div_abs = depth_map
if div_abs.max() > div_abs.min():
div_normalized = ((div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255).astype(np.uint8)
else:
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
# 应用伪彩色映射
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
# 步骤 3.4:打印关键结果
print(
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
f"切向力Y={fsheary:.4f}, 深度图尺寸={depth_map.shape}, 原始分辨率={sensor.img_size_raw}"
)
# 步骤 3.5:键盘控制
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
print("\n正在退出示例程序...")
break
if key == ord("r"):
# 当光流追踪出现漂移时,可手动重置追踪器
sensor.reset()
print("追踪器已重置。")
# =========================================================================
# 4. 释放资源
# =========================================================================
sensor.disconnect() # 释放视频源及相关资源
if __name__ == '__main__':
main()
@@ -1,111 +0,0 @@
#!/usr/bin/env python3
"""
ORY-FINGER:Minimum UV-unroll finger tactile pipeline.
Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz
Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact
Recommended entry:
python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02
Current config alias: ``dd02`` resolves to ``dd02-ov`` for compatibility.
"""
import argparse
import os
import sys
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
import cv2
import orisys
from orisys.finger import FingerRuntime
def main():
parser = argparse.ArgumentParser(description="Minimum UV-unroll finger tactile pipeline")
parser.add_argument("--camera_id", type=int, default=0)
parser.add_argument("--video", help="Video file path (overrides camera)")
parser.add_argument("--config", "-c", default="dd02", help="Orisys SDK config name or path")
parser.add_argument("--show", action="store_true", default=True, help="Show unrolled image window")
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
args = parser.parse_args()
try:
video = args.camera_id
if args.video:
video = args.video
runtime = FingerRuntime.open(
config=args.config,
video=video,
verbose=args.verbose,
)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
if args.verbose:
geometry = runtime.geometry
print(
f"UV geometry: native={geometry.native_width}x{geometry.native_height}, "
f"source={geometry.source_width}x{geometry.source_height} "
f"output={geometry.out_width}x{geometry.out_height} "
)
runtime.warmup(color=False)
tactile = runtime.tactile
print("Press 'q' to quit, 'r' to reset tracker.")
try:
while True:
ok, unrolled = runtime.grab_and_process(color=False)
if not ok:
break
tactile.compute_deformation(check_motion=True, threshold=0)
tactile.compute_contact()
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = tactile.read_info(
tactile.info.FPS,
tactile.info.VRAW,
tactile.info.VNORMAL,
tactile.info.IMG,
tactile.info.CONTOUR,
tactile.info.CENTROID,
tactile.info.DEPTH,
tactile.info.FNORMAL,
tactile.info.FSHEARX,
tactile.info.FSHEARY,
)
status = (
f"FPS={fps:.2f} normal={fnormal:.2f} shear=({fshearx:.2f}, {fsheary:.2f}) "
f"mask={tactile.mask_stats['coverage']:.2f}"
)
if args.show:
arrows = orisys.util.draw_arrows(
unrolled,
flow,
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
)
cv2.imshow("UV Unrolled", arrows)
print(status, end="\r")
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
print("\nExiting.")
break
if key == ord("r"):
tactile.reset()
print("\nTracker reset.")
finally:
runtime.close()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
@@ -1,85 +0,0 @@
#!/usr/bin/env python3
"""
ORY-FINGER:UV-unroll finger tactile pipeline with unified Qt dashboard.
Where to customize the GUI:
Layout (sizes, proportions) -> examples/gui/viewer3d/layout.py
Colors / QSS -> examples/gui/viewer3d/styles.py
Widget rendering logic -> examples/gui/viewer3d/widgets/
Window behavior -> examples/gui/viewer3d/dashboard.py
Recommended entry:
python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02
"""
import argparse
import os
import sys
from pathlib import Path
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
# Make shared packages importable when launched as a file path.
_EXAMPLES_DIR = Path(__file__).resolve().parents[2]
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
from gui.app_paths import configure_frozen_runtime, default_config_path, ensure_examples_import_path
configure_frozen_runtime()
ensure_examples_import_path()
from gui import run_dashboard
from pipeline import PipelineConfig
from viz import DEFAULT_FINGER_VIZ_POSE
def _parse_args():
parser = argparse.ArgumentParser(
description="UV-unroll finger tactile pipeline with unified Qt dashboard"
)
parser.add_argument("--video", "-v", default="0", help="Camera index or video file path")
parser.add_argument("--config", "-c", default=default_config_path(), help="Orisys SDK config")
parser.add_argument("--cal", default="", help="Optional calibration file path")
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
parser.add_argument(
"--mesh-map",
choices=["flow", "depth"],
default="depth",
help="Color the 3D mesh from flow magnitude or contact depth.",
)
parser.add_argument("--threshold", type=float, default=None, help="Mesh/arrow threshold")
parser.add_argument("--no-3d", action="store_true", help="Disable 3D mesh view")
parser.add_argument(
"--viz-pose",
default=DEFAULT_FINGER_VIZ_POSE,
help="world_to_cam.npy for the default view; pass 'none' to skip",
)
parser.add_argument(
"--cutoff",
type=float,
default=5.0,
help="Low-pass cutoff for force curves (Hz)",
)
parser.add_argument(
"--profile",
action="store_true",
help="Print per-stage timing to the log panel / console",
)
parser.add_argument(
"--profile-every",
type=int,
default=30,
help="When --profile is set, print rolling averages every N frames",
)
return parser.parse_args()
def main():
args = _parse_args()
config = PipelineConfig.from_args(args)
sys.exit(run_dashboard(config))
if __name__ == "__main__":
main()
+3 -3
View File
@@ -18,7 +18,7 @@
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
- 每个传感器进程都会占用独立的 GPU 显存
SDK version: 0.4.2
SDK version: 0.5.0
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
@@ -138,12 +138,12 @@ def main():
{
'sensor_id': 0,
'vid_src': args.video1,
'config_name': './config/ddjx01.json'
'config_name': 'box'
},
{
'sensor_id': 1,
'vid_src': args.video2, # 可替换为其他视频源或摄像头索引
'config_name': './config/ddjx01.json'
'config_name': 'box'
},
]
+58 -11
View File
@@ -6,7 +6,38 @@ from pathlib import Path
import numpy as np
DEFAULT_FINGER_VIZ_POSE = "assets/finger/visualization/world_to_cam.npy"
DEFAULT_FINGER_VIZ_POSE = "sensors/finger/assets/visualization/world_to_cam.npy"
_LEGACY_FINGER_VIZ_POSES = {
"assets/finger/visualization/world_to_cam.npy",
DEFAULT_FINGER_VIZ_POSE,
}
def _finger_bundle_viz_pose_candidates() -> list[Path]:
"""Resolve viz pose paths from the packaged sensors/finger bundle when present."""
try:
from orisys.configs import resolve_sensor_bundle, resolve_sensor_resource
except ImportError:
return []
found: list[Path] = []
bundle = resolve_sensor_bundle("finger")
if bundle is not None and bundle.manifest_json is not None:
try:
import json
manifest = json.loads(bundle.manifest_json.read_text(encoding="utf-8"))
except Exception:
manifest = {}
rel = manifest.get("viz_pose")
if isinstance(rel, str) and rel.strip():
found.append((bundle.root / rel).resolve())
packaged = resolve_sensor_resource("finger", "assets", "visualization", "world_to_cam.npy")
if packaged is not None:
found.append(packaged.resolve())
return found
_CAMERA_X_ROT_90 = np.array(
[
@@ -28,27 +59,43 @@ _CAMERA_Z_ROT_180 = np.array(
def resolve_viz_pose_path(path: str | Path | None = None) -> Path:
"""Resolve world_to_cam.npy from cwd or packaged asset layout."""
"""Resolve world_to_cam.npy from sensor bundle, cwd, or packaged layout."""
raw = Path(path or DEFAULT_FINGER_VIZ_POSE).expanduser()
if raw.is_file():
return raw.resolve()
here = Path(__file__).resolve()
candidates = [
Path.cwd() / raw,
here.parents[2] / raw,
]
candidates: list[Path] = []
# Prefer the sensors/finger bundle for default / legacy relative paths.
posix = raw.as_posix()
if path is None or posix in _LEGACY_FINGER_VIZ_POSES or not raw.is_absolute():
candidates.extend(_finger_bundle_viz_pose_candidates())
try:
from gui.app_paths import app_root
candidates.insert(0, app_root() / raw)
candidates.append(app_root() / raw)
except ImportError:
pass
for candidate in candidates:
if candidate.is_file():
return candidate.resolve()
candidates.extend(
[
Path.cwd() / raw,
here.parents[2] / raw,
]
)
checked = ", ".join(str(p) for p in candidates)
unique: list[Path] = []
for candidate in candidates:
resolved = candidate.resolve()
if resolved not in unique:
unique.append(resolved)
for candidate in unique:
if candidate.is_file():
return candidate
checked = ", ".join(str(p) for p in unique)
raise FileNotFoundError(f"Visualization pose file not found: {raw}. Checked: {checked}")
+20 -11
View File
@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
requires = ["setuptools>=61.0,<80", "wheel==0.45.1"]
build-backend = "setuptools.build_meta"
[project]
@@ -63,7 +63,7 @@ version = { attr = "orisys.__version__" }
# Single source of truth for bundled non-code files.
# - dev_install = true -> pip install / editable install (via setup.py)
# - all items -> Nuitka wheel (build/wheel_layout.py)
# - all items -> Nuitka wheel (build/wheel/layout.py)
[tool.orisys.bundle]
# Use patterns (globs) and/or files (explicit names under source/).
@@ -75,11 +75,28 @@ patterns = ["*.json"]
# files = ["dd02-ov.json", "ddjx01.json"]
dev_install = true
[[tool.orisys.bundle.items]]
name = "sensors"
source = "sensors"
target = "sensors"
# JSON bundles (sensor.json, force_calibration.json) plus finger mesh/viz assets.
# Prefer the flat layout sensors/finger/assets/{30fps-*,visualization}/;
# also accept a nested assets/finger/ tree if present in a checkout.
patterns = [
"**/*.json",
"finger/assets/30fps*/*",
"finger/assets/visualization/*",
"finger/assets/finger/30fps*/*",
"finger/assets/finger/visualization/*",
]
preserve_tree = true
dev_install = true
[[tool.orisys.bundle.items]]
name = "examples"
source = "examples"
target = "examples"
patterns = ["*.py", "README.md", "products/**/*.py", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"]
patterns = ["*.py", "README.md", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"]
preserve_tree = true
[[tool.orisys.bundle.items]]
@@ -88,11 +105,3 @@ source = "docs"
target = "docs"
patterns = ["*.md", "*.pdf"]
[[tool.orisys.bundle.items]]
name = "assets"
source = "assets"
target = "assets"
patterns = ["finger/30fps/*", "finger/visualization/*"]
preserve_tree = true
dev_install = true
+25 -11
View File
@@ -1,13 +1,27 @@
# CuPy - Install the appropriate version based on your CUDA version:
# For CUDA 12.x: pip install cupy-cuda12x>=12.3.0
# For CUDA 13.x: pip install cupy-cuda13x>=13.0.0
# Or use extras: pip install orisys[cuda12] or pip install orisys[cuda13]
# Runtime dependencies for editable / source installs.
# Prefer extras from pyproject.toml:
# pip install -e .
# pip install -e ".[gui]"
# pip install -e ".[cuda12]"
# pip install -e ".[cuda13]"
# pip install -e ".[gui,cuda12]"
# PyPI users: pip install orisys / "orisys[gui]" / "orisys[cuda12]"
# Core (same as [project.dependencies])
numpy>=1.24.4
opencv_python>=4.10.0.84
scipy==1.13
setuptools>=45.2.0
av
numba
pyqt5
pyqtgraph
opencv-python>=4.10.0.84
scipy>=1.10.1
numba>=0.59.0
# GUI extras: pip install -e ".[gui]"
# open3d>=0.17.0
# pyqt5>=5.15
# pyqtgraph>=0.13
# CUDA extras (pick one CUDA major version)
# CUDA 12.x: pip install -e ".[cuda12]" -> cupy-cuda12x>=12.3.0
# CUDA 13.x: pip install -e ".[cuda13]" -> cupy-cuda13x>=13.0.0
# Alias: pip install -e ".[cuda]" -> same as cuda12
# Optional video tooling (not an orisys extra)
# av
+1 -1
View File
@@ -115,7 +115,7 @@ def main():
writer.write(save_img)
sensor1.compute_deformation(check_motion=False) # 计算形变和分解
sensor1.compute_deformation() # 计算形变和分解
# 提取信息:
# 向量场: VRAW, VNORMAL, VSHEAR: 原始,法向,切向
# 帧率,时间戳: FPS, TIMESTAMP
-961
View File
@@ -1,961 +0,0 @@
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
# Force pyqtgraph to bind to PyQt5 when multiple Qt bindings are installed.
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PyQt5")
import cv2
import orisys
import numpy as np
import time
import argparse
from datetime import datetime
import pyqtgraph as pg
from PyQt5.QtCore import Qt, QSize, QEvent
from PyQt5.QtGui import QImage, QFont, QPixmap, QKeySequence, QCursor
from PyQt5.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout, QWidget, QSizePolicy, QShortcut, QApplication
from pyqtgraph.Qt import QtGui
# Final desired appearance on the mirrored 1872x1560 (6:5) display.
# The desktop is first shown on a 1920x1080 (16:9) screen, then stretched by
# the OS onto the 6:5 mirror. We therefore pre-distort the source layout so
# the mirrored output looks correct.
TARGET_SINGLE_PANEL_W = 936
TARGET_ASPECT_W = TARGET_SINGLE_PANEL_W * 2
TARGET_VIDEO_FRAME_H = 1053
TARGET_BOTTOM_PANEL_H = 507
TARGET_ASPECT_H_TOTAL = TARGET_VIDEO_FRAME_H + TARGET_BOTTOM_PANEL_H
TARGET_MIRROR_W = 1872
TARGET_MIRROR_H = 1560
SINGLE_PANEL_W = TARGET_SINGLE_PANEL_W
ASPECT_W = TARGET_ASPECT_W
BOTTOM_PANEL_H = TARGET_BOTTOM_PANEL_H
VIDEO_FRAME_W = TARGET_ASPECT_W
VIDEO_FRAME_H = TARGET_VIDEO_FRAME_H
ASPECT_H_TOTAL = TARGET_ASPECT_H_TOTAL
UI_SCALE = 1.0
ARROW_STRETCH = 507
PLOT_STRETCH = 429
VIDEO_LOOP_PATH = "./叠动机械爪_成片.m4v"
npoints = 120 # 图表数据长度
dispaly_angle = -90 # 箭头图显示角度
touch_threshold = 5 # 触碰反应阈值
frame_to_recover = 10 # 触碰后等待回复帧数
# Opneing Hour for keep reading image
open_hour = 9
close_hour = 17
manual_open_enable = True # 控制是否可以手动切换营业状态 (按键G)
is_open = True
# Match arrow panel / Qt style (not pure black)
PLOT_THEME_BG = "#0B1016"
PLOT_THEME_FG = "#A0A0A0"
# OS title bar cannot be tinted to #0B1016; frameless removes it (use Alt+drag to move).
USE_FRAMELESS_WINDOW = False
def scale_ui_px(px, floor=1):
return max(floor, int(round(px * UI_SCALE)))
def compute_source_layout(screen_w, screen_h):
"""Pre-distort the app on the source screen so the mirrored monitor is correct."""
target_video_ratio = TARGET_VIDEO_FRAME_H / float(TARGET_ASPECT_H_TOTAL)
target_bottom_ratio = TARGET_BOTTOM_PANEL_H / float(TARGET_ASPECT_H_TOTAL)
return {
"window_w": max(320, int(screen_w)),
"window_h": max(240, int(screen_h)),
"video_w": max(320, int(screen_w)),
"video_h": max(1, int(round(screen_h * target_video_ratio))),
"bottom_h": max(1, int(round(screen_h * target_bottom_ratio))),
"single_panel_w": max(1, int(round(screen_w / 2.0))),
}
class CombinedTactileWindow(QWidget):
"""
Resizable window that keeps width:height = ASPECT_W : ASPECT_H_TOTAL.
Dragging the width or height edge updates the other dimension accordingly.
"""
RESIZE_MARGIN = 8
def __init__(self):
super().__init__()
self._aspect_hw = ASPECT_H_TOTAL / float(ASPECT_W)
self._resize_guard = False
self.user_closed = False
self._drag_anchor = None
self._frameless_resize_edge = None
self._frameless_resize_start_global = None
self._frameless_resize_start_geom = None
def _min_client_size(self):
w = max(320, self.minimumSizeHint().width())
h = int(round(w * self._aspect_hw))
return w, h
def _hit_test_resize(self, global_pos):
if not USE_FRAMELESS_WINDOW:
return None
p = self.mapFromGlobal(global_pos)
w, h = self.width(), self.height()
m = self.RESIZE_MARGIN
left = p.x() <= m
right = p.x() >= w - m
top = p.y() <= m
bottom = p.y() >= h - m
if top and left:
return "tl"
if top and right:
return "tr"
if bottom and left:
return "bl"
if bottom and right:
return "br"
if top:
return "t"
if bottom:
return "b"
if left:
return "l"
if right:
return "r"
return None
def _cursor_for_edge(self, edge):
return {
"tl": Qt.SizeFDiagCursor,
"br": Qt.SizeFDiagCursor,
"tr": Qt.SizeBDiagCursor,
"bl": Qt.SizeBDiagCursor,
"l": Qt.SizeHorCursor,
"r": Qt.SizeHorCursor,
"t": Qt.SizeVerCursor,
"b": Qt.SizeVerCursor,
}.get(edge, Qt.ArrowCursor)
def _apply_frameless_resize(self, dg):
edge = self._frameless_resize_edge
g0 = self._frameless_resize_start_geom
min_w, min_h = self._min_client_size()
dx, dy = dg.x(), dg.y()
x0, y0, w0, h0 = g0.x(), g0.y(), g0.width(), g0.height()
if edge == "br":
nw = max(min_w, w0 + dx)
nh = int(round(nw * self._aspect_hw))
self.setGeometry(x0, y0, nw, nh)
elif edge == "r":
nw = max(min_w, w0 + dx)
nh = int(round(nw * self._aspect_hw))
self.setGeometry(x0, y0, nw, nh)
elif edge == "b":
nh = max(min_h, h0 + dy)
nw = int(round(nh / self._aspect_hw))
self.setGeometry(x0, y0, nw, nh)
elif edge == "l":
nw = max(min_w, w0 - dx)
nh = int(round(nw * self._aspect_hw))
nx = g0.right() - nw
self.setGeometry(nx, y0, nw, nh)
elif edge == "t":
nh = max(min_h, h0 - dy)
nw = int(round(nh / self._aspect_hw))
ny = g0.bottom() - nh
self.setGeometry(x0, ny, nw, nh)
elif edge == "tr":
nw = max(min_w, w0 + dx)
nh = int(round(nw * self._aspect_hw))
ny = g0.bottom() - nh
self.setGeometry(x0, ny, nw, nh)
elif edge == "bl":
nw = max(min_w, w0 - dx)
nh = int(round(nw * self._aspect_hw))
nx = g0.right() - nw
self.setGeometry(nx, y0, nw, nh)
elif edge == "tl":
nw = max(min_w, w0 - dx)
nh = int(round(nw * self._aspect_hw))
nx = g0.right() - nw
ny = g0.bottom() - nh
self.setGeometry(nx, ny, nw, nh)
def install_drag_filters(self):
"""Alt+left-drag to move when frameless; grabs go to this widget."""
for w in self.findChildren(QWidget):
if w is not self:
w.installEventFilter(self)
def eventFilter(self, watched, event):
if not USE_FRAMELESS_WINDOW:
return super().eventFilter(watched, event)
et = event.type()
if et == QEvent.MouseButtonPress and event.button() == Qt.LeftButton:
edge = self._hit_test_resize(event.globalPos())
if edge is not None:
self._frameless_resize_edge = edge
self._frameless_resize_start_global = event.globalPos()
self._frameless_resize_start_geom = self.geometry()
self.grabMouse()
return True
if QApplication.keyboardModifiers() & Qt.AltModifier:
self._drag_anchor = event.globalPos() - self.frameGeometry().topLeft()
self.grabMouse()
return True
if et == QEvent.MouseMove and not event.buttons():
edge = self._hit_test_resize(event.globalPos())
watched.setCursor(QCursor(self._cursor_for_edge(edge)))
return super().eventFilter(watched, event)
def mouseMoveEvent(self, event):
if USE_FRAMELESS_WINDOW and self._frameless_resize_edge and event.buttons() & Qt.LeftButton:
dg = event.globalPos() - self._frameless_resize_start_global
self._apply_frameless_resize(dg)
event.accept()
return
if (
USE_FRAMELESS_WINDOW
and self._drag_anchor is not None
and event.buttons() & Qt.LeftButton
):
self.move(event.globalPos() - self._drag_anchor)
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if USE_FRAMELESS_WINDOW and self._frameless_resize_edge and event.button() == Qt.LeftButton:
self.releaseMouse()
self._frameless_resize_edge = None
self._frameless_resize_start_global = None
self._frameless_resize_start_geom = None
event.accept()
return
if USE_FRAMELESS_WINDOW and self._drag_anchor is not None and event.button() == Qt.LeftButton:
self.releaseMouse()
self._drag_anchor = None
event.accept()
return
super().mouseReleaseEvent(event)
def closeEvent(self, event):
self.user_closed = True
event.accept()
def minimumSizeHint(self):
w = 320
return QSize(w, int(round(w * self._aspect_hw)))
def resizeEvent(self, event):
if self._resize_guard:
super().resizeEvent(event)
return
super().resizeEvent(event)
w, h = self.width(), self.height()
if w < 8 or h < 8:
return
old = event.oldSize()
self._resize_guard = True
try:
if old.isValid() and old.width() > 0 and old.height() > 0:
dw = abs(w - old.width())
dh = abs(h - old.height())
if dw >= dh:
new_w, new_h = w, int(round(w * self._aspect_hw))
else:
new_w, new_h = int(round(h / self._aspect_hw)), h
else:
new_w, new_h = w, int(round(w * self._aspect_hw))
if new_w != w or new_h != h:
self.resize(new_w, new_h)
finally:
self._resize_guard = False
def compute_arrow_canvas_side(panel_widget):
"""
Pixel size of the square arrow canvas from the window client area and the
fixed horizontal split ARROW_STRETCH : PLOT_STRETCH (matches QHBoxLayout stretch).
"""
w = panel_widget.width()
h = panel_widget.height()
if w < 16 or h < 16:
return max(32, ASPECT_H_TOTAL)
w_arrow = int(round(w * ARROW_STRETCH / float(ARROW_STRETCH + PLOT_STRETCH)))
# Keep the rendered arrow image square inside the left panel.
return max(32, min(h, w_arrow))
def draw_arrows_wuxi(
frame,
optical_flow,
fps=True,
threshold=5,
grid_spacing=40,
arrow_scale=1.0,
margin=0.8,
colormap=cv2.COLORMAP_PARULA,
below_threshold_color=(0, 0, 255),
edge_margin=15,
target_size=936,
):
"""
Draw colored optical flow arrows on the image by magnitude (JET colormap).
Resizes frame and flow to target_size x target_size; scales flow vectors and
grid_spacing / margins / threshold so arrow density matches the original resolution.
"""
image = frame.copy()
if len(image.shape) == 2 or image.shape[2] == 1:
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
h0, w0 = image.shape[:2]
if h0 != target_size or w0 != target_size:
sx = target_size / float(w0)
sy = target_size / float(h0)
image = cv2.resize(image, (target_size, target_size), interpolation=cv2.INTER_LINEAR)
u = cv2.resize(optical_flow[:, :, 0], (target_size, target_size), interpolation=cv2.INTER_LINEAR) * sx
v = cv2.resize(optical_flow[:, :, 1], (target_size, target_size), interpolation=cv2.INTER_LINEAR) * sy
optical_flow = np.stack([u, v], axis=2)
scale = max(sx, sy)
grid_spacing = max(4, int(round(grid_spacing * scale)))
edge_margin = max(1, int(round(edge_margin * scale)))
threshold = float(threshold) * scale
h, w = image.shape[:2]
# Arrow grid in pixel coordinates (assume frame and flow same size)
rr = np.arange(grid_spacing // 2, h, grid_spacing)
cc = np.arange(grid_spacing // 2, w, grid_spacing)
rr, cc = np.meshgrid(rr, cc, indexing="ij")
rr, cc = rr.reshape(-1), cc.reshape(-1)
# Displacement at each grid point (flow stored as half-displacement)
disp = 2.0 * optical_flow[rr, cc, :]
mag = np.linalg.norm(disp, axis=1)
dx = disp[:, 0] / arrow_scale
dy = disp[:, 1] / arrow_scale
length = np.sqrt(dx * dx + dy * dy)
# Cap arrow length at grid_spacing
scale = np.where(length > grid_spacing*margin, grid_spacing / np.maximum(length, 1e-9), 1.0)*margin
dx, dy = dx * scale, dy * scale
end_x = (cc + dx).astype(np.int32)
end_y = (rr + dy).astype(np.int32)
valid = (
(cc >= edge_margin) & (cc < w - edge_margin) & (rr >= edge_margin) & (rr < h - edge_margin)
& (end_x >= 0) & (end_x < w) & (end_y >= 0) & (end_y < h)
)
mag_range = mag.max() - mag.min()
norm_mag = ((mag - mag.min()) / mag_range * 255).astype(np.uint8) if mag_range > 0 else np.zeros_like(mag, dtype=np.uint8)
norm_mag[mag < threshold] = 0
colors = cv2.applyColorMap(norm_mag.reshape(-1, 1), colormap)
for i in np.where(valid)[0]:
pt1, pt2 = (int(cc[i]), int(rr[i])), (int(end_x[i]), int(end_y[i]))
color = tuple(int(c) for c in colors[i, 0])
if mag[i] < threshold:
cv2.circle(image, pt1, radius=1, color=below_threshold_color, thickness=-1)
else:
# thickness = max(1, int(2 * mag[i] / mag.max())) if mag.max() > 0 else 2
thickness = 1
cv2.arrowedLine(image, pt1, pt2, color, thickness=thickness, tipLength=0.2)
return image
def set_arrow_label_bgr(label, bgr_image):
"""Show a BGR OpenCV image in a QLabel using source-screen pre-distortion."""
rgb = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB)
rgb = np.ascontiguousarray(rgb)
h, w, ch = rgb.shape
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy()
pm = QPixmap.fromImage(qimg)
lw, lh = label.width(), label.height()
if lw > 0 and lh > 0 and (pm.width() != lw or pm.height() != lh):
pm = pm.scaled(lw, lh, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
label.setPixmap(pm)
def set_video_label_bgr(label, bgr_image):
"""Show loop video in the top frame using the fitted display size."""
rgb = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB)
rgb = np.ascontiguousarray(rgb)
h, w, ch = rgb.shape
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy()
pm = QPixmap.fromImage(qimg)
lw, lh = label.width(), label.height()
if lw > 0 and lh > 0 and (pm.width() != lw or pm.height() != lh):
pm = pm.scaled(lw, lh, Qt.IgnoreAspectRatio, Qt.FastTransformation)
label.setPixmap(pm)
def read_loop_video_frame_realtime(video_cap, video_clock):
"""Read loop-video frame paced to wall clock; skip frames when main loop is slow."""
if video_cap is None or not video_cap.isOpened():
return False, None
now = time.perf_counter()
fps = max(1.0, float(video_clock.get("fps", 30.0)))
frame_dt = 1.0 / fps
next_ts = video_clock.get("next_ts")
if next_ts is None:
next_ts = now
frames_due = 1
if now > next_ts + frame_dt:
frames_due += int((now - next_ts) / frame_dt)
# Cap skip burst to avoid long catch-up loops after pauses.
frames_due = min(frames_due, int(max(2.0, fps)))
for _ in range(frames_due - 1):
if not video_cap.grab():
video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
if not video_cap.grab():
return False, None
vret, vframe = video_cap.read()
if not vret:
video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
vret, vframe = video_cap.read()
if not vret:
return False, None
next_ts = next_ts + frames_due * frame_dt
if next_ts < now - 2.0 * frame_dt:
next_ts = now
video_clock["next_ts"] = next_ts
return True, vframe
def make_gradient_brush(color_hex, max_alpha=150):
"""
创建一个从上到下渐变的笔刷 (兼容 PyQt5 和 PyQt6)
"""
c_top = QtGui.QColor(color_hex)
c_top.setAlpha(max_alpha) # 顶部半透明
c_bot = QtGui.QColor(color_hex)
c_bot.setAlpha(0) # 底部全透明
# 创建线性渐变:(0,0)到(0,1) 代表从控件顶部到底部
grad = QtGui.QLinearGradient(0, 0, 0, 1)
if hasattr(QtGui.QGradient, 'CoordinateMode'):
grad.setCoordinateMode(QtGui.QGradient.CoordinateMode.ObjectBoundingMode)
else:
grad.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode)
# ==============================================
grad.setColorAt(0, c_top) # 0.0 (顶端) 颜色
grad.setColorAt(1, c_bot) # 1.0 (底端) 颜色
return QtGui.QBrush(grad)
def create_plot(plot_container):
pg.setConfigOption("background", PLOT_THEME_BG)
pg.setConfigOption("foreground", PLOT_THEME_FG)
pg.setConfigOptions(antialias=True) # 抗锯齿
theme_color = '#00E5FF' # '#00E5FF'
line_pen = pg.mkPen(color=theme_color, width=2)
grad_brush = make_gradient_brush(theme_color, max_alpha=120)
axis_font = QFont("Segoe UI", scale_ui_px(11, floor=8))
p1 = plot_container.addPlot()
p1.setLabel('left', 'N')
p1.getAxis('left').label.setFont(axis_font)
p1.getAxis('left').label.setRotation(dispaly_angle)
p1.showGrid(x=True, y=True, alpha=0.3)
plot_container.nextRow()
p2 = plot_container.addPlot()
p2.setLabel('left', 'X')
p2.getAxis('left').label.setFont(axis_font)
p2.getAxis('left').label.setRotation(dispaly_angle)
p2.showGrid(x=True, y=True, alpha=0.3)
plot_container.nextRow()
p3 = plot_container.addPlot()
p3.setLabel('left', 'Y')
# p3.setLabel('bottom', 'Time', units='s')
p3.getAxis('left').label.setFont(axis_font)
p3.getAxis('left').label.setRotation(dispaly_angle)
p3.getAxis('bottom').label.setFont(axis_font)
p3.showGrid(x=True, y=True, alpha=0.3)
_plot_bg = pg.mkColor(PLOT_THEME_BG)
for p in (p1, p2, p3):
p.getAxis("bottom").setStyle(showValues=False)
p.getAxis("left").setStyle(showValues=False)
# Keep title as "N" / "X" / "Y" — no PyQtGraph "(x0.001)" suffix on small ranges.
p.getAxis("left").enableAutoSIPrefix(False)
# PlotItem.setBackground exists only in newer pyqtgraph; ViewBox works on older installs.
p.vb.setBackgroundColor(_plot_bg)
plot_af = p1.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
plot_tfx = p2.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
plot_tfy = p3.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush)
return p1, p2, p3, plot_af, plot_tfx, plot_tfy
def _plot_y_range(y):
"""Avoid degenerate Y span; explicit x/y in setData so older PyQtGraph never plots time as y."""
mn = float(np.min(y))
mx = float(np.max(y))
if not (np.isfinite(mn) and np.isfinite(mx)):
return 0.0, 1.0
if mx - mn <= 1e-15:
mid = 0.5 * (mn + mx)
return mid - 0.5, mid + 0.5
pad = (mx - mn) * 0.05
return mn - pad, mx + pad
def update(plot_af, plot_tfx, plot_tfy, data, p1, p2, p3):
# global plot_af, plot_tfx, plot_tfy, data, p1, p2, p3
plot_af.setData(data[:, 0, 0], data[:, 1, 0])
p1.setYRange(*_plot_y_range(data[:, 1, 0]))
plot_tfx.setData(data[:, 0, 1], data[:, 1, 1])
p2.setYRange(*_plot_y_range(data[:, 1, 1]))
plot_tfy.setData(data[:, 0, 2], data[:, 1, 2])
p3.setYRange(*_plot_y_range(data[:, 1, 2]))
def build_sensor_panel(parent_layout):
panel = QWidget()
panel_layout = QHBoxLayout(panel)
panel_layout.setContentsMargins(0, 0, 0, 0)
panel_layout.setSpacing(0)
# Ignored: do not let pixmap/content drive QLabel size — layout stretch sets arrow vs plot widths.
_policy_layout = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
arrow_label = QLabel()
arrow_label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
arrow_label.setStyleSheet(
"background-color: #0B1016; margin: 0; padding: 0; border: none;"
)
arrow_label.setSizePolicy(_policy_layout)
arrow_label.setMinimumSize(0, 0)
arrow_label.setScaledContents(False)
panel_layout.addWidget(arrow_label, stretch=ARROW_STRETCH)
plot_widget = pg.GraphicsLayoutWidget()
plot_widget.setBackground(PLOT_THEME_BG)
plot_widget.setSizePolicy(_policy_layout)
plot_widget.setMinimumSize(0, 0)
panel_layout.addWidget(plot_widget, stretch=PLOT_STRETCH)
disconnected_label = QLabel("已断开")
disconnected_label.setAlignment(Qt.AlignCenter)
disconnected_label.setStyleSheet(
f"background-color: #0B1016; color: #FF4D4D; font-size: {scale_ui_px(54, floor=24)}px; "
"font-weight: bold; letter-spacing: 2px; margin: 0; padding: 0; border: none;"
)
disconnected_label.hide()
panel_layout.addWidget(disconnected_label, stretch=SINGLE_PANEL_W)
parent_layout.addWidget(panel, stretch=1)
p1, p2, p3, plot_af, plot_tfx, plot_tfy = create_plot(plot_widget)
data = np.zeros([npoints, 2, 3])
for i in range(3):
data[:, 0, i] = np.linspace(0.0, npoints / 30.0, npoints)
return {
"panel": panel,
"arrow_label": arrow_label,
"plot_widget": plot_widget,
"disconnected_label": disconnected_label,
"p1": p1,
"p2": p2,
"p3": p3,
"plot_af": plot_af,
"plot_tfx": plot_tfx,
"plot_tfy": plot_tfy,
"data": data,
}
# 实时图表:单窗口,两套并排(左 sensor1,右 sensor2)
pg.setConfigOption("background", PLOT_THEME_BG)
pg.setConfigOption("foreground", PLOT_THEME_FG)
app = pg.mkQApp("Wuxi Demo")
screen = app.primaryScreen()
screen_geometry = screen.availableGeometry() if screen is not None else None
screen_w = screen_geometry.width() if screen_geometry is not None else 1920
screen_h = screen_geometry.height() if screen_geometry is not None else 1080
source_layout = compute_source_layout(screen_w, screen_h)
ASPECT_W = source_layout["window_w"]
ASPECT_H_TOTAL = source_layout["window_h"]
VIDEO_FRAME_W = source_layout["video_w"]
VIDEO_FRAME_H = source_layout["video_h"]
BOTTOM_PANEL_H = source_layout["bottom_h"]
SINGLE_PANEL_W = source_layout["single_panel_w"]
UI_SCALE = max(0.1, screen_h / float(TARGET_ASPECT_H_TOTAL))
combined_win = CombinedTactileWindow()
combined_win.setWindowTitle("Wuxi tactile — dual flow + forces")
main_layout = QVBoxLayout(combined_win)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
video_label = QLabel()
video_label.setAlignment(Qt.AlignCenter)
video_label.setStyleSheet("background-color: #000000; margin: 0; padding: 0; border: none;")
video_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
video_label.setMinimumSize(0, 0)
main_layout.addWidget(video_label, stretch=VIDEO_FRAME_H)
bottom_container = QWidget()
bottom_layout = QHBoxLayout(bottom_container)
bottom_layout.setContentsMargins(0, 0, 0, 0)
bottom_layout.setSpacing(0)
main_layout.addWidget(bottom_container, stretch=BOTTOM_PANEL_H)
panel1_ui = build_sensor_panel(bottom_layout)
panel2_ui = build_sensor_panel(bottom_layout)
if USE_FRAMELESS_WINDOW:
combined_win.setWindowFlags(Qt.Window | Qt.FramelessWindowHint)
# Client area matches theme (no bright border flash on frameless)
combined_win.setStyleSheet(f"background-color: {PLOT_THEME_BG};")
combined_win.resize(ASPECT_W, ASPECT_H_TOTAL)
combined_win.install_drag_filters()
combined_win.showFullScreen()
closed_hour_overlay = QLabel(combined_win)
closed_hour_overlay.setAlignment(Qt.AlignCenter)
closed_hour_overlay.setStyleSheet(
f"background-color: {PLOT_THEME_BG}; color: #A0A0A0; "
f"font-size: {scale_ui_px(72, floor=32)}px; font-weight: bold; "
"margin: 0; padding: 0; border: none;"
)
closed_hour_overlay.setText("Closed hour\n非营业时间")
closed_hour_overlay.hide()
start_time = None # Will be set when sensor starts
fps = 30.0
def outside_opening_hours(now):
return now.hour < open_hour or now.hour >= close_hour
def main():
global start_time
parser = argparse.ArgumentParser()
parser.add_argument("--video1","-v1", type=str, default="0", help="视频文件路径")
parser.add_argument("--video2","-v2", type=str, default="0", help="视频文件路径")
args = parser.parse_args()
session_start = datetime.now()
print(
f"[session] START {session_start:%Y-%m-%d %H:%M:%S} (long-run / crash log marker)",
flush=True,
)
if USE_FRAMELESS_WINDOW:
print(
"[window] Frameless: resize = drag window edges/corners; move = Alt+left-drag; quit = Q.",
flush=True,
)
sensor1 = None
sensor2 = None
video_cap = None
try:
input1 = int(args.video1)
input2 = int(args.video2)
is_camera = True
except:
is_camera = False
try:
if is_camera:
sensor1 = orisys.Sensor(
input1,
config_name="./config/ddjx01_wuxi.json",
cal_path="./config/dd01a_0306.npy",
isstitch=True,
cuda=False,
verbose=False,
)
sensor2 = orisys.Sensor(
input2,
config_name="./config/ddjx01_wuxi.json",
cal_path="./config/dd01a_0306.npy",
isstitch=True,
cuda=False,
verbose=False,
)
else:
sensor1 = orisys.Sensor(
args.video1,
config_name="./config/ddjx01_wuxi.json",
cal_path="./config/dd01a_0306.npy",
isstitch=True,
cuda=False,
verbose=False,
)
sensor2 = orisys.Sensor(
args.video2,
config_name="./config/ddjx01_wuxi.json",
cal_path="./config/dd01a_0306.npy",
isstitch=True,
cuda=False,
verbose=False,
)
def _shortcut_reset_tracker():
sensor1.reset()
sensor2.reset()
print("传感器已重置 (快捷键 R)", flush=True)
def _shortcut_quit():
combined_win.close()
def _shortcut_toggle_open():
global is_open
is_open = not is_open
print(f"营业状态: {'营业' if is_open else '非营业'}", flush=True)
_sc_r = QShortcut(QKeySequence(Qt.Key_R), combined_win)
_sc_r.activated.connect(_shortcut_reset_tracker)
if manual_open_enable:
_sc_g = QShortcut(QKeySequence(Qt.Key_G), combined_win)
_sc_g.activated.connect(_shortcut_toggle_open)
_sc_q = QShortcut(QKeySequence(Qt.Key_Q), combined_win)
_sc_q.activated.connect(_shortcut_quit)
video_cap = cv2.VideoCapture(VIDEO_LOOP_PATH)
video_clock = {"fps": 30.0, "next_ts": None}
if not video_cap.isOpened():
print(f"[video] Failed to open loop video: {VIDEO_LOOP_PATH}", flush=True)
else:
src_fps = float(video_cap.get(cv2.CAP_PROP_FPS) or 0.0)
if np.isfinite(src_fps) and src_fps >= 1.0:
video_clock["fps"] = src_fps
start_time = time.time()
is_save = False
frame_count = 0
writer = None
sensor1_active = True
sensor2_active = True
# Local to main(): assignments inside the loop make this local; must exist
# before the first iteration that skips the closed branch.
start_open = False
while True:
now = datetime.now()
if outside_opening_hours(now) or not is_open:
start_open = False
closed_hour_overlay.setGeometry(0, 0, combined_win.width(), combined_win.height())
closed_hour_overlay.raise_()
closed_hour_overlay.show()
app.processEvents()
if combined_win.user_closed or not combined_win.isVisible():
print("\n窗口已关闭,退出程序")
if writer is not None:
writer.release()
writer = None
break
time.sleep(1)
continue
if not start_open:
start_open = True
sensor1.reset()
sensor2.reset()
print(f"传感器已重置: {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
closed_hour_overlay.hide()
if video_cap is not None and video_cap.isOpened():
vret, vframe = read_loop_video_frame_realtime(video_cap, video_clock)
if vret:
if vframe.shape[1] != VIDEO_FRAME_W or vframe.shape[0] != VIDEO_FRAME_H:
vframe = cv2.resize(vframe, (VIDEO_FRAME_W, VIDEO_FRAME_H), interpolation=cv2.INTER_LINEAR)
set_video_label_bgr(video_label, vframe)
if sensor1_active:
img1_valid = sensor1.get_img()
if img1_valid is None:
panel1_ui["arrow_label"].hide()
panel1_ui["plot_widget"].hide()
panel1_ui["disconnected_label"].show()
sensor1_active = False
print(f"Sensor1 disconnected. Showing DISCONNECTED screen. {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
else:
sensor1.compute_deformation(check_motion=True, tail_frames=frame_to_recover, threshold=touch_threshold)
_, fn, fx, fy, flow = sensor1.read_info(
sensor1.info.FPS,
sensor1.info.FNORMAL,
sensor1.info.FSHEARX,
sensor1.info.FSHEARY,
sensor1.info.VRAW,
)
_bg_bgr = (22, 16, 11)
_img = sensor1.img
_h, _w = _img.shape[:2]
black_img = np.empty((_h, _w, 3), dtype=_img.dtype)
black_img[:, :, 0] = _bg_bgr[0]
black_img[:, :, 1] = _bg_bgr[1]
black_img[:, :, 2] = _bg_bgr[2]
lw, lh = panel1_ui["arrow_label"].width(), panel1_ui["arrow_label"].height()
if lw > 16 and lh > 16:
side = max(32, min(lw, lh))
else:
side = compute_arrow_canvas_side(panel1_ui["panel"])
arrows = draw_arrows_wuxi(
black_img,
flow,
threshold=2,
grid_spacing=20,
arrow_scale=0.5,
below_threshold_color=(160, 160, 160),
target_size=int(side),
)
set_arrow_label_bgr(panel1_ui["arrow_label"], arrows)
data1 = panel1_ui["data"]
for i in range(3):
data1[:-1, 0, i] = data1[1:, 0, i]
data1[:-1, 1, i] = data1[1:, 1, i]
data1[-1, 0, :] = frame_count / 15.0
data1[-1, 1, 0] = fn
data1[-1, 1, 1] = abs(fx)
data1[-1, 1, 2] = abs(fy)
update(
panel1_ui["plot_af"],
panel1_ui["plot_tfx"],
panel1_ui["plot_tfy"],
data1,
panel1_ui["p1"],
panel1_ui["p2"],
panel1_ui["p3"],
)
if sensor2_active:
img2_valid = sensor2.get_img()
if img2_valid is None:
panel2_ui["arrow_label"].hide()
panel2_ui["plot_widget"].hide()
panel2_ui["disconnected_label"].show()
sensor2_active = False
print(f"Sensor2 disconnected. Showing DISCONNECTED screen. {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
else:
sensor2.compute_deformation(check_motion=True, tail_frames=frame_to_recover, threshold=touch_threshold)
_, fn2, fx2, fy2, flow2 = sensor2.read_info(
sensor2.info.FPS,
sensor2.info.FNORMAL,
sensor2.info.FSHEARX,
sensor2.info.FSHEARY,
sensor2.info.VRAW,
)
_bg_bgr2 = (22, 16, 11)
_img2 = sensor2.img
_h2, _w2 = _img2.shape[:2]
black_img2 = np.empty((_h2, _w2, 3), dtype=_img2.dtype)
black_img2[:, :, 0] = _bg_bgr2[0]
black_img2[:, :, 1] = _bg_bgr2[1]
black_img2[:, :, 2] = _bg_bgr2[2]
lw2, lh2 = panel2_ui["arrow_label"].width(), panel2_ui["arrow_label"].height()
if lw2 > 16 and lh2 > 16:
side2 = max(32, min(lw2, lh2))
else:
side2 = compute_arrow_canvas_side(panel2_ui["panel"])
arrows2 = draw_arrows_wuxi(
black_img2,
flow2,
threshold=2,
grid_spacing=20,
arrow_scale=0.5,
below_threshold_color=(160, 160, 160),
target_size=int(side2),
)
set_arrow_label_bgr(panel2_ui["arrow_label"], arrows2)
data2 = panel2_ui["data"]
for i in range(3):
data2[:-1, 0, i] = data2[1:, 0, i]
data2[:-1, 1, i] = data2[1:, 1, i]
data2[-1, 0, :] = frame_count / 15.0
data2[-1, 1, 0] = fn2
data2[-1, 1, 1] = abs(fx2)
data2[-1, 1, 2] = abs(fy2)
update(
panel2_ui["plot_af"],
panel2_ui["plot_tfx"],
panel2_ui["plot_tfy"],
data2,
panel2_ui["p1"],
panel2_ui["p2"],
panel2_ui["p3"],
)
app.processEvents()
now = datetime.now()
if now.hour == 9 and now.minute == 0 and now.second == 0:
if sensor1_active:
sensor1.reset()
if sensor2_active:
sensor2.reset()
print(
f"[session] 传感器已重置: {now:%Y-%m-%d %H:%M:%S} ",
flush=True,
)
if not sensor1_active and not sensor2_active:
while not combined_win.user_closed and combined_win.isVisible():
app.processEvents()
time.sleep(0.05)
break
frame_count += 1
app.processEvents()
if combined_win.user_closed or not combined_win.isVisible():
print("\n窗口已关闭,退出程序")
if writer is not None:
writer.release()
writer = None
break
# Convert combined window to image (arrows + curves)
pixmap = combined_win.grab()
# Convert QPixmap to QImage
qimg = pixmap.toImage()
# Convert QImage to numpy array (RGB format)
qimg = qimg.convertToFormat(QImage.Format_RGB888)
width = qimg.width()
height = qimg.height()
# Convert QImage to numpy array
ptr = qimg.bits()
ptr.setsize(height * width * 3)
arr = np.array(ptr, dtype=np.uint8).reshape((height, width, 3))
except BaseException as exc:
session_abnormal = datetime.now()
print(
f"[session] ABNORMAL at {session_abnormal:%Y-%m-%d %H:%M:%S}: "
f"{type(exc).__name__}: {exc}",
flush=True,
)
raise
finally:
session_end = datetime.now()
elapsed = session_end - session_start
print(
f"[session] END {session_end:%Y-%m-%d %H:%M:%S} "
f"(elapsed {elapsed})",
flush=True,
)
if sensor1 is not None:
try:
sensor1.disconnect()
except Exception as disconnect_err:
print(f"[session] disconnect error: {disconnect_err} {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
if 'sensor2' in locals() and sensor2 is not None:
try:
sensor2.disconnect()
except Exception as disconnect_err:
print(f"[session] disconnect error (sensor2): {disconnect_err} {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True)
if video_cap is not None:
video_cap.release()
if __name__ == '__main__':
main()
+461
View File
@@ -0,0 +1,461 @@
#!/usr/bin/env python3
"""UART finger controller for Radxa / Dragon Q6A (sensor-side receiver).
Combines ORY-FINGER processing (same path as ``examples/finger_pipeline.py``)
with a line-based UART command interface. A host (or ``uart_send.py``-style
transmitter) sends commands; this script replies and streams forces.
Dependencies (beyond orisys):
pip install pyserial
Default UART (Dragon Q6A):
port=/dev/ttyHS1 baud=115200 8N1
Commands (one line, case-insensitive):
search List cameras (/dev/video*) via orisys.capture.list_cameras
connect [source] Open FingerRuntime (camera index, /dev/videoN, or video file)
run Stream FNORMAL / FSHEARX / FSHEARY until stop (or EOF)
stop Stop force streaming; keep source connected
quit | exit Close runtime and serial, then exit
Replies:
camera,<index>,<path>,<WxH>
OK search <N>
OK connect <source>
OK run
force,<fnormal>,<fshearx>,<fsheary>
OK eof Video file ended (when --no-loop-video)
OK stop
OK quit
ERR <context> <message>
Examples:
# Live camera over UART
python scripts/example_uart.py --port /dev/ttyHS1 --config finger
# Offline debug from a recorded file (no camera needed)
python scripts/example_uart.py --port /dev/ttyHS1 --video /path/to/clip.mp4
# then UART: connect (uses --video) or connect /path/to/clip.mp4
# then UART: run
"""
from __future__ import annotations
import argparse
import os
import shlex
import sys
from pathlib import Path
from typing import Optional, Union
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
try:
import serial
except ImportError as exc: # pragma: no cover
raise SystemExit(
"pyserial is required for this script.\n"
" pip install pyserial\n"
f"Import error: {exc}"
) from exc
import cv2
from orisys.capture import is_live_camera, list_cameras
from orisys.finger import FingerRuntime
Source = Union[int, str]
def _parse_source(token: str) -> Optional[Source]:
"""Accept camera index int, numeric string, V4L path, or filesystem path."""
text = str(token).strip().strip('"').strip("'")
if not text:
return None
if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
return int(text)
return text
def _is_video_file(source: Source) -> bool:
"""True when source is a filesystem video path (not a live camera)."""
if is_live_camera(source):
return False
if isinstance(source, int):
return False
path = Path(str(source))
return path.is_file() or path.suffix.lower() in {
".mp4",
".avi",
".mkv",
".mov",
".m4v",
".wmv",
".webm",
}
class UartFingerController:
"""Serial command dispatcher around a single FingerRuntime."""
def __init__(
self,
ser: serial.Serial,
*,
config: str = "finger",
default_source: Source = 0,
max_camera_index: int = 10,
loop_video: bool = True,
show: bool = False,
verbose: bool = False,
):
self.ser = ser
self.config = config
self.default_source = default_source
self.max_camera_index = max_camera_index
self.loop_video = loop_video
self.show = show
self.verbose = verbose
self.runtime: Optional[FingerRuntime] = None
self._connected_source: Optional[Source] = None
self._last_search_source: Optional[Source] = None
self._streaming = False
self._should_quit = False
def reply(self, line: str) -> None:
text = line.rstrip("\r\n") + "\n"
self.ser.write(text.encode("utf-8"))
self.ser.flush()
if self.verbose:
print(f"<< {text.rstrip()}", flush=True)
def close_runtime(self) -> None:
self._streaming = False
if self.runtime is not None:
try:
self.runtime.close()
except Exception: # noqa: BLE001
pass
self.runtime = None
self._connected_source = None
if self.show:
try:
cv2.destroyAllWindows()
except Exception: # noqa: BLE001
pass
def _resolve_connect_source(self, args: list[str]) -> Source:
if args:
# Join remaining tokens so unquoted paths with spaces still work.
joined = " ".join(args).strip()
parsed = _parse_source(joined)
if parsed is None:
raise ValueError("empty connect source")
return parsed
# Prefer --video default over last search so offline debug stays on file.
if isinstance(self.default_source, str) and not is_live_camera(self.default_source):
return self.default_source
if self._last_search_source is not None:
return self._last_search_source
return self.default_source
def cmd_search(self, _args: list[str]) -> None:
try:
cameras = list_cameras(max_index=self.max_camera_index)
except Exception as exc: # noqa: BLE001
self.reply(f"ERR search {exc}")
return
self._last_search_source = None
for cam in cameras:
path = (cam.path or f"/dev/video{cam.index}").strip() or f"/dev/video{cam.index}"
size = f"{cam.width}x{cam.height}"
self.reply(f"camera,{cam.index},{path},{size}")
if self._last_search_source is None:
self._last_search_source = cam.index
self.reply(f"OK search {len(cameras)}")
def cmd_connect(self, args: list[str]) -> None:
try:
source = self._resolve_connect_source(args)
except ValueError as exc:
self.reply(f"ERR connect {exc}")
return
if isinstance(source, str) and not is_live_camera(source):
path = Path(source)
if not path.is_file():
self.reply(f"ERR connect file not found: {source}")
return
self._streaming = False
self.close_runtime()
try:
runtime = FingerRuntime.open(
config=self.config,
video=source,
verbose=self.verbose,
)
runtime.warmup(color=False)
except Exception as exc: # noqa: BLE001
self.reply(f"ERR connect {exc}")
return
self.runtime = runtime
self._connected_source = source
kind = "file" if _is_video_file(source) else "camera"
self.reply(f"OK connect {source} ({kind})")
def _rewind_video(self) -> bool:
"""Seek connected file source back to frame 0. Return True on success."""
if self.runtime is None or self._connected_source is None:
return False
if not _is_video_file(self._connected_source):
return False
cap = self.runtime.capture
if cap is None:
return False
try:
return bool(cap.set(cv2.CAP_PROP_POS_FRAMES, 0))
except Exception: # noqa: BLE001
return False
def _poll_command_line(self, *, blocking: bool = True) -> Optional[str]:
"""Read one command line if available; return None on timeout/empty."""
if not blocking and self.ser.in_waiting <= 0:
return None
raw = self.ser.readline()
if not raw:
return None
try:
line = raw.decode("utf-8", errors="replace").strip()
except Exception: # noqa: BLE001
return None
return line or None
def cmd_run(self, _args: list[str]) -> None:
if self.runtime is None:
self.reply("ERR run not connected")
return
self._streaming = True
self.reply("OK run")
tactile = self.runtime.tactile
is_file = self._connected_source is not None and _is_video_file(self._connected_source)
while self._streaming and not self._should_quit:
ok, unrolled = self.runtime.grab_and_process(color=False)
if not ok:
if is_file and self.loop_video and self._rewind_video():
if self.verbose:
print("video EOF: looping", flush=True)
continue
if is_file:
self.reply("OK eof")
else:
self.reply("ERR run grab failed")
self._streaming = False
break
tactile.compute_deformation()
tactile.compute_contact()
fnormal, fshearx, fsheary = tactile.read_info(
tactile.info.FNORMAL,
tactile.info.FSHEARX,
tactile.info.FSHEARY,
)
self.reply(f"force,{float(fnormal):.6f},{float(fshearx):.6f},{float(fsheary):.6f}")
if self.show:
try:
import orisys
arrows = orisys.util.draw_arrows(
unrolled,
tactile.flow,
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
valid_mask=tactile.valid_mask,
)
cv2.imshow("UV Unrolled", arrows)
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
self._should_quit = True
break
if key == ord("r"):
tactile.reset()
except Exception as exc: # noqa: BLE001
if self.verbose:
print(f"show error: {exc}", file=sys.stderr)
# Non-blocking command poll so force streaming is not delayed by UART timeout.
pending = self._poll_command_line(blocking=False)
if pending is not None:
if self.verbose:
print(f">> {pending}", flush=True)
if not self._dispatch(pending, allow_run=False):
break
def cmd_stop(self, _args: list[str]) -> None:
self._streaming = False
self.reply("OK stop")
def cmd_quit(self, _args: list[str]) -> None:
self._streaming = False
self._should_quit = True
self.reply("OK quit")
def _dispatch(self, line: str, *, allow_run: bool = True) -> bool:
"""Dispatch one command line. Return False if the main loop should exit."""
try:
parts = shlex.split(line)
except ValueError:
parts = line.split()
if not parts:
return True
cmd = parts[0].lower()
args = parts[1:]
if cmd == "search":
self.cmd_search(args)
elif cmd == "connect":
self.cmd_connect(args)
elif cmd == "run":
if not allow_run:
self.reply("ERR run already running")
else:
self.cmd_run(args)
elif cmd == "stop":
self.cmd_stop(args)
elif cmd in ("quit", "exit"):
self.cmd_quit(args)
return False
else:
self.reply(f"ERR unknown {cmd}")
return not self._should_quit
def serve(self) -> None:
self.reply("OK ready")
while not self._should_quit:
line = self._poll_command_line()
if line is None:
continue
if self.verbose:
print(f">> {line}", flush=True)
if not self._dispatch(line):
break
def open_serial(port: str, baud: int, timeout: float) -> serial.Serial:
try:
return serial.Serial(
port=port,
baudrate=baud,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=timeout,
)
except serial.SerialException as exc:
print(f"Serial error: {exc}", file=sys.stderr)
print("Check:", file=sys.stderr)
print(f" 1. Device node {port} exists", file=sys.stderr)
print(" 2. User has access (sudo or dialout group)", file=sys.stderr)
print(" 3. Wiring / USB-TTL connection", file=sys.stderr)
raise SystemExit(1) from exc
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="UART finger controller (search/connect/run/stop force stream)"
)
parser.add_argument("--port", default="/dev/ttyHS1", help="Serial device (default: /dev/ttyHS1)")
parser.add_argument("--baud", type=int, default=115200, help="Baud rate (default: 115200)")
parser.add_argument(
"--timeout",
type=float,
default=0.1,
help="Serial readline timeout seconds (default: 0.1)",
)
parser.add_argument("--config", "-c", default="finger", help="Orisys config name or path")
parser.add_argument(
"--camera-id",
type=int,
default=0,
help="Default camera index when connect has no argument (ignored if --video is set)",
)
parser.add_argument(
"--video",
default=None,
help="Default video file for connect (offline debug without a camera)",
)
parser.add_argument(
"--max-camera-index",
type=int,
default=10,
help="Highest index probed by search",
)
parser.add_argument(
"--loop-video",
dest="loop_video",
action="store_true",
default=True,
help="Loop video file on EOF during run (default: on)",
)
parser.add_argument(
"--no-loop-video",
dest="loop_video",
action="store_false",
help="Stop run with OK eof when video file ends",
)
parser.add_argument(
"--show",
action="store_true",
help="Show UV unrolled OpenCV window (local debug only)",
)
parser.add_argument("--verbose", action="store_true", help="Echo UART traffic and SDK logs")
args = parser.parse_args(argv)
if args.video:
video_path = Path(args.video)
if not video_path.is_file():
print(f"Video file not found: {args.video}", file=sys.stderr)
return 1
default_source: Source = str(video_path.resolve())
else:
default_source = args.camera_id
ser = open_serial(args.port, args.baud, args.timeout)
controller = UartFingerController(
ser,
config=args.config,
default_source=default_source,
max_camera_index=args.max_camera_index,
loop_video=args.loop_video,
show=args.show,
verbose=args.verbose,
)
print(f"UART finger controller on {args.port} @ {args.baud} bps", flush=True)
print(f"Default source: {default_source}", flush=True)
print("Commands: search | connect [source|file] | run | stop | quit", flush=True)
try:
controller.serve()
except KeyboardInterrupt:
print("\nInterrupted.", flush=True)
try:
controller.reply("OK quit")
except Exception: # noqa: BLE001
pass
finally:
controller.close_runtime()
if ser.is_open:
ser.close()
print("Serial closed.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""Shim for the Orisys finger Qt dashboard (see examples/gui/)."""
import os
import sys
from pathlib import Path
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
_EXAMPLES_DIR = Path(__file__).resolve().parents[1] / "examples"
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
from argparse import Namespace
from gui import run_dashboard
from pipeline import PipelineConfig
def main():
import argparse
parser = argparse.ArgumentParser(description="Orisys finger Qt dashboard")
parser.add_argument("--video", "-v", type=str, default="0")
parser.add_argument("--config", "-c", type=str, default="dd02")
parser.add_argument("--cal", type=str, default="")
parser.add_argument("--verbose", action="store_true")
parser.add_argument("--cutoff", type=float, default=10.0)
parser.add_argument("--no-3d", action="store_true")
args = parser.parse_args()
config = PipelineConfig.from_args(
Namespace(
video=args.video,
config=args.config,
cal=args.cal,
verbose=args.verbose,
mesh_map="depth",
threshold=None,
viz_pose="assets/finger/visualization/world_to_cam.npy",
no_3d=args.no_3d,
cutoff=args.cutoff,
)
)
sys.exit(run_dashboard(config))
if __name__ == "__main__":
main()
-159
View File
@@ -1,159 +0,0 @@
import argparse
import av
import cv2
import platform
import subprocess
import sys
import time
TARGET_FPS = 30
WIDTH = 1920
HEIGHT = 1080
def list_windows_dshow_devices():
"""
Print DirectShow devices using ffmpeg, if available.
"""
cmd = ["ffmpeg", "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"]
print("Listing Windows DirectShow devices via ffmpeg...")
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
except FileNotFoundError as exc:
raise RuntimeError("ffmpeg was not found in PATH; cannot list DirectShow devices.") from exc
output = (result.stderr or "") + (result.stdout or "")
print(output.strip())
def open_pyav_capture(system_name, device, pixel_format):
"""
Open a camera device via PyAV on Linux or Windows.
"""
if system_name == "linux":
options = {
"video_size": f"{WIDTH}x{HEIGHT}",
"framerate": str(TARGET_FPS),
"input_format": pixel_format or "mjpeg",
}
container = av.open(device or "/dev/video4", format="v4l2", options=options)
elif system_name == "windows":
if not device:
raise RuntimeError(
"On Windows, PyAV/dshow requires a camera name, for example "
'--device "USB Camera". Use --list-devices first.'
)
options = {
"video_size": f"{WIDTH}x{HEIGHT}",
"framerate": str(TARGET_FPS),
}
if pixel_format:
options["pixel_format"] = pixel_format
container = av.open(f"video={device}", format="dshow", options=options)
else:
raise RuntimeError(
f"Unsupported OS: {platform.system()}. Only Linux and Windows are supported."
)
stream = container.streams.video[0]
return container, stream
def main():
parser = argparse.ArgumentParser(
description="Capture from camera via PyAV on Linux or Windows, optionally save to MP4."
)
parser.add_argument(
"-d",
"--device",
type=str,
default="",
help=(
"Linux: V4L2 device path like /dev/video4. "
"Windows: DirectShow camera name like 'USB Camera'. "
"If empty, Linux uses /dev/video4; Windows requires --device."
),
)
parser.add_argument(
"--pixel-format",
type=str,
default="",
help=(
"Optional input pixel format / format hint. "
"Examples: Linux 'mjpeg'; Windows may accept values such as 'mjpeg' if supported."
),
)
parser.add_argument(
"--list-devices",
action="store_true",
help="On Windows, list DirectShow device names via ffmpeg and exit.",
)
parser.add_argument(
"-o",
"--output",
type=str,
default="",
help="Output MP4 path (e.g. capture.mp4). If empty, only preview, no file.",
)
args = parser.parse_args()
system_name = platform.system().lower()
if args.list_devices:
if system_name != "windows":
raise RuntimeError("--list-devices is currently only implemented for Windows.")
list_windows_dshow_devices()
return
container, stream = open_pyav_capture(system_name, args.device, args.pixel_format)
print(
f"Using PyAV backend on {platform.system()} "
f"with device={args.device or '/dev/video4'}"
)
frame_count = 0
t_start = time.perf_counter()
writer = None
if args.output:
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
writer = cv2.VideoWriter(
args.output, fourcc, float(TARGET_FPS), (WIDTH, HEIGHT)
)
if not writer.isOpened():
container.close()
raise RuntimeError(f"Could not open VideoWriter for {args.output}")
print(f"Recording to {args.output} @ {TARGET_FPS} fps, {WIDTH}x{HEIGHT}")
try:
for frame in container.decode(stream):
t0 = time.perf_counter()
img = frame.to_ndarray(format="bgr24") # OpenCV-compatible
t1 = time.perf_counter()
if writer is not None:
writer.write(img)
cv2.imshow("pyav_cam", img)
key = cv2.waitKey(1) & 0xFF
t2 = time.perf_counter()
frame_count += 1
if frame_count % 30 == 0:
elapsed = t2 - t_start
print(
f"Frame {frame_count}: convert={1000*(t1-t0):.1f}ms, "
f"imshow+waitKey={1000*(t2-t1):.1f}ms, avg_fps={frame_count/elapsed:.1f}"
)
if key == ord("q"):
break
finally:
container.close()
if writer is not None:
writer.release()
print(f"Saved {frame_count} frames to {args.output}")
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
{
"schema_version": 1,
"unit": "N",
"channels": {
"normal": {
"method": "piecewise_linear",
"raw_key": "fnormal",
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[1000.0, 0.0],
[10000.0, 0.377],
[30000.0, 1.377],
[62000.0, 2.377]
]
},
"shear": {
"method": "piecewise_linear",
"input": "components",
"signed": true,
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[1000.0, 0.0],
[36000.0, 1.19],
[68000.0, 2.06]
]
}
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365,263,123,63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1,2,3,6,7,8,11,12,13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3,3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":5,
"div_threshold":0.05,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 12
}
}
@@ -0,0 +1,9 @@
Place the ORY-FINGER runtime asset bundle files in this directory before
building release wheels:
- `mesh.npz`
- `camera_intrinsics.npz`
- `world_to_cam.npy`
These binary assets are validated by the wheel build and are intentionally not
generated by the package build step.
@@ -0,0 +1,6 @@
Place visualization camera assets in this directory before building release
wheels:
- `world_to_cam.npy`
The 3D dashboard uses this pose as the default view preset when available.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "finger",
"sensor_json": "sensor.json",
"assets_dir": "assets/30fps-0703",
"viz_pose": "assets/visualization/world_to_cam.npy"
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "dd02-ov",
"type": "single_camera",
"version": "1.0",
"camera": {
"resolution_raw": [1280, 720],
"fps": 30,
"resolution_stitch": [280, 300],
"source_scale": 0.4,
"old":[672, 728, 1,0.5],
"work": [504, 546,1],
"work2": [420, 450,0.8],
"work3": [350, 375,4,0.7],
"work4": [280, 300,4,0.4,0.2]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 1,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 1
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 0.2,
"div_threshold": 0.022,
"expansion_threshold": 0.01,
"kernel_size": [5, 5],
"denoise_sigma": 12
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365,263,123,63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1,2,3,6,7,8,11,12,13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3,3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":5,
"div_threshold":0.05,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 12
}
}
+34 -5
View File
@@ -1,13 +1,42 @@
"""Setuptools entry point; bundle rules are read from pyproject.toml."""
"""Setuptools entry point; bundle rules are read from pyproject.toml.
``dev_install`` bundle items (configs/sensors) are staged into the ``orisys``
package during build so ``pip install .`` places them at
``site-packages/orisys/...`` (importlib / configs resolvers), not under
``sys.prefix/orisys`` via deprecated ``data_files``.
"""
from __future__ import annotations
from pathlib import Path
import sys
from pathlib import Path
from setuptools import setup
from setuptools.command.build_py import build_py as _build_py
REPO_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(REPO_ROOT / "build"))
sys.path.insert(0, str(REPO_ROOT / "build" / "common"))
from pyproject_utils import bundle_data_files
from pyproject_utils import stage_dev_bundle_into # noqa: E402
setup(data_files=bundle_data_files(REPO_ROOT))
class build_py(_build_py):
def run(self) -> None:
super().run()
# After pure modules are copied, stage configs/sensors into build/lib/orisys.
pkg_dir = Path(self.build_lib) / "orisys"
pkg_dir.mkdir(parents=True, exist_ok=True)
stage_dev_bundle_into(REPO_ROOT, pkg_dir)
setup(
cmdclass={"build_py": build_py},
# Declared so wheels record the staged files; actual copy happens in build_py.
package_data={
"orisys": [
"configs/*.json",
"sensors/**/*",
],
},
include_package_data=True,
)
+16 -1534
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -5,13 +5,15 @@ A Python library for processing and analyzing optical tactile sensor data,
including image stitching, optical flow tracking, and deformation decomposition.
"""
__version__ = "0.4.2"
__version__ = "0.5.0"
__author__ = "Orisys"
from .sensor import Sensor
from . import util
from . import configs
from . import finger
from . import signal_processing
from . import capture
from .frame_processors import FFCFrameProcessor
CompoundEyeSensor = Sensor
@@ -33,9 +35,15 @@ __all__ = [
# finger runtime helpers
'finger',
# signal helpers
'signal_processing',
# capture helpers
'capture',
# configs utilities functions
'list_config',
'export_config'
'export_config',
# test
'FFCFrameProcessor'
+45
View File
@@ -0,0 +1,45 @@
"""Capture subsystem: OpenCV source, optional background thread, discovery."""
from .config import (
MAX_SIMULTANEOUS_CAPTURES,
CaptureConfig,
CaptureHealth,
CaptureStatus,
ReconnectPolicy,
)
from .discovery import (
DEFAULT_LAPTOP_WEBCAM_PATTERNS,
DEFAULT_MAX_CAMERA_INDEX,
CameraFilter,
CameraInfo,
list_cameras,
pick_default_camera,
)
from .source import (
CaptureSource,
CaptureWorker,
is_live_camera,
normalize_source,
open_capture,
open_opencv_capture,
)
__all__ = [
"MAX_SIMULTANEOUS_CAPTURES",
"DEFAULT_MAX_CAMERA_INDEX",
"DEFAULT_LAPTOP_WEBCAM_PATTERNS",
"CaptureConfig",
"CaptureHealth",
"CaptureStatus",
"ReconnectPolicy",
"CaptureSource",
"CaptureWorker",
"CameraInfo",
"CameraFilter",
"list_cameras",
"pick_default_camera",
"normalize_source",
"is_live_camera",
"open_opencv_capture",
"open_capture",
]

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