diff --git a/README.md b/README.md index e21d31b..5e781a1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ and several runnable examples for camera and video-based workflows. - `src/orisys/`: installable SDK package - `config/`: default and lab/runtime JSON configurations -- `examples/`: user-facing example scripts +- `examples/`: product demos (`products/ory_box`, `ory_mini`, `ory_finger`) plus shared `gui/`, `pipeline/`, `viz/` - `scripts/`: maintenance, calibration, packaging, and demo scripts - `tools/`: non-package tooling such as patch-generation utilities - `tests/`: automated pytest suite @@ -49,18 +49,36 @@ pytest ### 4. Run an example +ORY-BOX: + ```bash -python examples/basic_pipeline.py -v 0 -c ./config/ddjx01.json +python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 ``` +ORY-MINI: + +```bash +python examples/products/ory_mini/basic_pipeline.py -v 0 -c mini +``` + +ORY-FINGER: + +```bash +python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 +``` + +Compatibility wrappers remain at `examples/basic_pipeline.py` and +`examples/finger_pipeline*.py`. + ## Configuration The canonical configuration files now live in the repo-level `config/` folder. The package loader `orisys.configs` resolves configs from: 1. an explicit JSON file path -2. a config file stem under `config/` -3. a backward-compatible JSON `"name"` match, such as `"prototype"` +2. a config file stem under `config/` (for example `ddjx01`, `mini`, `dd02`) +3. a product alias such as `dd02` -> `dd02-ov` +4. a backward-compatible JSON `"name"` match, such as `"prototype"` Examples: @@ -76,11 +94,24 @@ cfg = orisys.export_config("ddjx01", "./config/exported_config.json") - 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/` - Specialized utilities: `tools/` +## Publishing (Linux manylinux) + +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 +``` + +Upload candidates land in `dist/repaired/`. Full TestPyPI → PyPI steps: +`docs/publishing.md`. + ## Notes for Collaborators - Generated build outputs such as `build/`, `dist/`, and `SDK/` are intentionally ignored. - Large or generated tooling outputs should stay under ignored output folders rather than be committed. - If you add new images or arrays that should be versioned, review `.gitignore` first. +- Never commit PyPI / TestPyPI API tokens. diff --git a/assets/finger/30fps-0616/camera_intrinsics.npz b/assets/finger/30fps-0616/camera_intrinsics.npz deleted file mode 100644 index 888b1eb..0000000 Binary files a/assets/finger/30fps-0616/camera_intrinsics.npz and /dev/null differ diff --git a/assets/finger/30fps-0616/mesh.npz b/assets/finger/30fps-0616/mesh.npz deleted file mode 100644 index ecb8980..0000000 Binary files a/assets/finger/30fps-0616/mesh.npz and /dev/null differ diff --git a/assets/finger/30fps-0616/pixel_to_xyz.npz b/assets/finger/30fps-0616/pixel_to_xyz.npz deleted file mode 100644 index c15f54f..0000000 Binary files a/assets/finger/30fps-0616/pixel_to_xyz.npz and /dev/null differ diff --git a/assets/finger/30fps-0616/unroll_map_uv_s0.4.npz b/assets/finger/30fps-0616/unroll_map_uv_s0.4.npz deleted file mode 100644 index e9a8bf1..0000000 Binary files a/assets/finger/30fps-0616/unroll_map_uv_s0.4.npz and /dev/null differ diff --git a/assets/finger/30fps-0616/world_to_cam.npy b/assets/finger/30fps-0616/world_to_cam.npy deleted file mode 100644 index 1923088..0000000 Binary files a/assets/finger/30fps-0616/world_to_cam.npy and /dev/null differ diff --git a/assets/finger/30fps/camera_intrinsics.npz b/assets/finger/30fps/camera_intrinsics.npz deleted file mode 100644 index 59ac687..0000000 Binary files a/assets/finger/30fps/camera_intrinsics.npz and /dev/null differ diff --git a/assets/finger/30fps/flatten_remap.npz b/assets/finger/30fps/flatten_remap.npz deleted file mode 100644 index 02ca287..0000000 Binary files a/assets/finger/30fps/flatten_remap.npz and /dev/null differ diff --git a/assets/finger/30fps/mesh.npz b/assets/finger/30fps/mesh.npz deleted file mode 100644 index ad81542..0000000 Binary files a/assets/finger/30fps/mesh.npz and /dev/null differ diff --git a/assets/finger/30fps/unroll_map_uv.npz b/assets/finger/30fps/unroll_map_uv.npz deleted file mode 100644 index 8415977..0000000 Binary files a/assets/finger/30fps/unroll_map_uv.npz and /dev/null differ diff --git a/assets/finger/30fps/unroll_map_uv_s0.4.npz b/assets/finger/30fps/unroll_map_uv_s0.4.npz deleted file mode 100644 index aafa344..0000000 Binary files a/assets/finger/30fps/unroll_map_uv_s0.4.npz and /dev/null differ diff --git a/assets/finger/30fps/world_to_cam.npy b/assets/finger/30fps/world_to_cam.npy deleted file mode 100644 index 2d5e411..0000000 Binary files a/assets/finger/30fps/world_to_cam.npy and /dev/null differ diff --git a/build/Dockerfile.manylinux b/build/Dockerfile.manylinux new file mode 100644 index 0000000..9e3d684 --- /dev/null +++ b/build/Dockerfile.manylinux @@ -0,0 +1,26 @@ +# manylinux_2_28: modern glibc baseline with binary wheels for numpy/scipy/opencv. +# Prefer this over manylinux2014 — recent NumPy requires GCC >= 10.3 and will try +# (and fail) to build from source on the older image. +FROM quay.io/pypa/manylinux_2_28_x86_64:latest + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_ROOT_USER_ACTION=ignore + +# Only install compile/repair tooling here. Runtime deps (numpy/scipy/opencv/numba) +# are declared in the wheel metadata and installed by pip when users install orisys. +# Nuitka is configured with --nofollow-import-to for those packages. +RUN for PYBIN in \ + /opt/python/cp310-cp310/bin \ + /opt/python/cp311-cp311/bin \ + /opt/python/cp312-cp312/bin; do \ + echo "=== Installing build tooling into ${PYBIN} ===" && \ + "${PYBIN}/python" -m pip install --upgrade pip setuptools wheel && \ + "${PYBIN}/python" -m pip install \ + "nuitka>=2.0" \ + ordered-set \ + zstandard \ + auditwheel; \ + done + +WORKDIR /src +ENTRYPOINT ["/bin/bash", "/src/build/manylinux_entrypoint.sh"] diff --git a/build/build_docker.txt b/build/build_docker.txt new file mode 100644 index 0000000..c69f9ef --- /dev/null +++ b/build/build_docker.txt @@ -0,0 +1,2 @@ +powershell -ExecutionPolicy Bypass -File .\build\build_manylinux_docker.ps1 +powershell -ExecutionPolicy Bypass -File .\build\build_manylinux_docker.ps1 -PythonVersions 3.10,3.12 \ No newline at end of file diff --git a/build/build_gui_win_pyinstaller.py b/build/build_gui_win_pyinstaller.py index b336c1e..cc53708 100644 --- a/build/build_gui_win_pyinstaller.py +++ b/build/build_gui_win_pyinstaller.py @@ -18,7 +18,7 @@ import sys from pathlib import Path # ---------- Configuration ---------- -ENTRY_SCRIPT = Path("examples/finger_pipeline_3dview.py").resolve() +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 diff --git a/build/build_manylinux_docker.ps1 b/build/build_manylinux_docker.ps1 new file mode 100644 index 0000000..b6f1b6d --- /dev/null +++ b/build/build_manylinux_docker.ps1 @@ -0,0 +1,104 @@ +# 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 +# +# 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")] + [string]$Target = "pypi-linux", + [string]$PythonVersions = "3.10,3.11,3.12", + [switch]$SkipImageBuild, + [string]$ImageName = "orisys-manylinux" +) + +$ErrorActionPreference = "Stop" + +function Convert-ToManylinuxTag { + param([string]$Version) + $v = $Version.Trim() + switch ($v) { + "3.10" { return "cp310-cp310" } + "3.11" { return "cp311-cp311" } + "3.12" { return "cp312-cp312" } + "cp310-cp310" { return "cp310-cp310" } + "cp311-cp311" { return "cp311-cp311" } + "cp312-cp312" { return "cp312-cp312" } + default { + throw "Unsupported Python version '$Version'. Use 3.10, 3.11, and/or 3.12." + } + } +} + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +Set-Location $RepoRoot + +$tags = @() +foreach ($part in ($PythonVersions -split ",")) { + if ([string]::IsNullOrWhiteSpace($part)) { continue } + $tags += (Convert-ToManylinuxTag $part) +} +if ($tags.Count -eq 0) { + throw "No Python versions specified." +} +$pythonTags = [string]::Join(",", $tags) + +Write-Host "=== Orisys manylinux Docker build ===" +Write-Host "Repo: $RepoRoot" +Write-Host "Target: $Target" +Write-Host "Image: $ImageName" +Write-Host "Python tags: $pythonTags" + +# Docker Desktop must be in Linux container mode for manylinux. +$dockerInfo = docker version --format "{{.Server.Os}}" 2>$null +if ($LASTEXITCODE -ne 0) { + throw "Docker is not available. Start Docker Desktop and retry." +} +if ($dockerInfo -ne "linux") { + throw "Docker server OS is '$dockerInfo'. Switch Docker Desktop to Linux containers." +} + +$entrypoint = Join-Path $RepoRoot "build\manylinux_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) + +if (-not $SkipImageBuild) { + Write-Host "`n=== Building Docker image ===" + docker build -f build/Dockerfile.manylinux -t $ImageName . + if ($LASTEXITCODE -ne 0) { + throw "docker build failed" + } +} + +Write-Host "`n=== Running manylinux container ===" +# Mount the repo so artifacts land under dist/repaired and SDK// on the host. +docker run --rm ` + -e "ORISYS_BUILD_TARGET=$Target" ` + -e "ORISYS_PYTHON_TAGS=$pythonTags" ` + -v "${RepoRoot}:/src" ` + $ImageName + +if ($LASTEXITCODE -ne 0) { + throw "manylinux container build failed" +} + +Write-Host "`n=== Host artifacts ===" +$repaired = Join-Path $RepoRoot "dist\repaired" +if (Test-Path $repaired) { + Get-ChildItem $repaired -Filter *.whl | ForEach-Object { Write-Host $_.FullName } +} else { + Write-Warning "No dist/repaired directory found." +} + +Write-Host "`nNext steps: see docs/publishing.md (TestPyPI then PyPI)." diff --git a/build/build_nuitka.py b/build/build_nuitka.py index 6b20ed2..a6abdb3 100644 --- a/build/build_nuitka.py +++ b/build/build_nuitka.py @@ -1,36 +1,80 @@ """Build a Nuitka-compiled orisys wheel and publish it to SDK/{version}/. Pipeline: - 1. Compile src/orisys → .pyd + 1. Compile src/orisys → extension module (.pyd / .so) 2. Stage wheel tree (compiled module + bundled files from pyproject.toml) - 3. Run bdist_wheel and move output to SDK/{version}/ - 4. Smoke-test imports in a temporary venv + 3. Run bdist_wheel; on Linux, auditwheel-repair to a manylinux tag + 4. Smoke-test imports in a temporary venv (required for --target pypi-linux) -Run from repo root: python build/build_nuitka.py +Targets: + internal – local/SDK delivery (default). Linux still repairs with auditwheel. + pypi-linux – PyPI binary wheel; requires Linux + CPython 3.10/3.11/3.12 + compliant tag. + +Run from repo root: + python build/build_nuitka.py + python build/build_nuitka.py --target pypi-linux """ from __future__ import annotations +import argparse import os import shutil 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, repo_path +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 publish_wheel_to_sdk, run_bdist_wheel, write_wheel_setup +from wheel_build 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. +PYPI_LINUX_PYTHON_VERSIONS = ((3, 10), (3, 11), (3, 12)) -def _reset_dir(path) -> None: + +def _reset_dir(path: Path) -> None: """Remove and recreate a build output directory.""" if path.exists(): shutil.rmtree(path) path.mkdir(parents=True) -def build() -> None: +def _validate_pypi_linux_environment() -> None: + if sys.platform != "linux": + raise SystemExit( + "[ERROR] --target pypi-linux must run inside a Linux manylinux container " + "(for example quay.io/pypa/manylinux_2_28_x86_64 via Docker Desktop)." + ) + if sys.version_info[:2] not in PYPI_LINUX_PYTHON_VERSIONS: + supported = ", ".join(f"{major}.{minor}" for major, minor in PYPI_LINUX_PYTHON_VERSIONS) + raise SystemExit( + f"[ERROR] --target pypi-linux requires CPython {supported}, got " + f"{sys.version_info.major}.{sys.version_info.minor}. " + "Use /opt/python/cp310-cp310 (or cp311/cp312) in the manylinux_2_28 image." + ) + + +def build(*, target: str = "internal", verify: bool | None = None) -> Path: + if target not in VALID_TARGETS: + raise SystemExit(f"[ERROR] Unknown target {target!r}; choose from {VALID_TARGETS}") + + if target == "pypi-linux": + _validate_pypi_linux_environment() + + # pypi-linux always verifies; internal opts in with --verify. + if verify is None: + verify = target == "pypi-linux" + # --- 1. Compile --- install_nuitka() @@ -38,7 +82,7 @@ def build() -> None: _reset_dir(NUITKA_COMPILE_DIR) _reset_dir(NUITKA_WHEEL_DIR) - print("\n=== Compiling with Nuitka ===") + print(f"\n=== Compiling with Nuitka (target={target}, version={version}) ===") env = os.environ.copy() src_dir = repo_path("src") env["PYTHONPATH"] = src_dir.as_posix() + os.pathsep + env.get("PYTHONPATH", "") @@ -71,23 +115,80 @@ def build() -> None: extras_require=extras_require(REPO_ROOT), ) - # --- 3. Build wheel and publish to SDK/ --- + # --- 3. Build wheel, repair on Linux, publish to SDK/ --- wheel_path = run_bdist_wheel(NUITKA_WHEEL_DIR) if wheel_path is None: - print("\n[ERROR] Wheel not found.") - return + raise SystemExit("\n[ERROR] Wheel not found after bdist_wheel.") - final_wheel = publish_wheel_to_sdk(wheel_path, version) - print(f"\n[SUCCESS] Wheel created and moved to: {final_wheel}") + repair_dir = repaired_wheel_dir() + final_candidate = repair_linux_wheel(wheel_path, repair_dir) - # # --- 4. Verify (isolated venv; does not touch the active environment) --- - # print("\n=== Verifying finger mesh/viewer imports ===") - # try: - # verify_built_wheel(final_wheel) - # except subprocess.CalledProcessError as exc: - # print(f"\n[WARNING] Wheel verify failed: {exc}") - # print("The wheel was built successfully; verification runs in an isolated temp venv.") + if target == "pypi-linux" and not is_compliant_manylinux_wheel(final_candidate): + raise SystemExit( + f"[ERROR] Wheel is not PyPI-compliant: {final_candidate.name}. " + "Expected a manylinux_2_17_x86_64 (or newer) platform tag." + ) + + # Keep a durable copy under dist/repaired for upload tooling. + dist_repaired = REPO_ROOT / "dist" / "repaired" + dist_repaired.mkdir(parents=True, exist_ok=True) + dist_copy = dist_repaired / final_candidate.name + if dist_copy.resolve() != final_candidate.resolve(): + shutil.copy2(final_candidate, dist_copy) + + final_wheel = publish_wheel_to_sdk(final_candidate, version) + print(f"\n[SUCCESS] Wheel created and copied to: {final_wheel}") + print(f"[SUCCESS] Upload candidate also at: {dist_copy}") + + # --- 4. Verify (isolated venv; does not touch the active environment) --- + if verify: + print("\n=== Verifying wheel imports in isolated venv ===") + try: + verify_built_wheel(final_wheel) + print("[SUCCESS] Wheel smoke test passed.") + except subprocess.CalledProcessError as exc: + if target == "pypi-linux": + raise SystemExit( + f"[ERROR] Wheel verify failed (required for pypi-linux): {exc}" + ) from exc + print(f"\n[WARNING] Wheel verify failed: {exc}") + print("The wheel was built successfully; verification runs in an isolated temp venv.") + else: + print("\n[INFO] Skipping wheel verify (pass --verify to enable).") + + return final_wheel + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(description="Build a Nuitka-compiled orisys wheel.") + parser.add_argument( + "--target", + choices=VALID_TARGETS, + default="internal", + help="internal: SDK delivery; pypi-linux: manylinux PyPI binary wheel", + ) + parser.add_argument( + "--verify", + action="store_true", + help="Run isolated venv smoke test (always on for --target pypi-linux)", + ) + parser.add_argument( + "--skip-verify", + action="store_true", + help="Skip smoke test when using --target internal", + ) + args = parser.parse_args(argv) + verify: bool | None + if args.target == "pypi-linux": + verify = True + elif args.verify: + verify = True + elif args.skip_verify: + verify = False + else: + verify = None + build(target=args.target, verify=verify) if __name__ == "__main__": - build() + main() diff --git a/build/manylinux_entrypoint.sh b/build/manylinux_entrypoint.sh new file mode 100644 index 0000000..c0d2514 --- /dev/null +++ b/build/manylinux_entrypoint.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Runs inside quay.io/pypa/manylinux_2_28_x86_64 with the repo mounted at /src. +# Builds one Nuitka wheel per CPython ABI listed in ORISYS_PYTHON_TAGS. +set -euo pipefail + +TARGET="${ORISYS_BUILD_TARGET:-pypi-linux}" +# Comma-separated manylinux tags, e.g. "cp310-cp310,cp311-cp311,cp312-cp312" +PYTHON_TAGS="${ORISYS_PYTHON_TAGS:-cp310-cp310,cp311-cp311,cp312-cp312}" + +cd /src + +echo "=== manylinux entrypoint ===" +echo "Target: ${TARGET}" +echo "Python tags: ${PYTHON_TAGS}" +uname -a + +IFS=',' read -r -a TAG_ARRAY <<< "${PYTHON_TAGS}" +FAILED=0 +BUILT=() + +for tag in "${TAG_ARRAY[@]}"; do + tag="$(echo "${tag}" | tr -d '[:space:]')" + if [[ -z "${tag}" ]]; then + continue + fi + + PYBIN="/opt/python/${tag}/bin" + if [[ ! -x "${PYBIN}/python" ]]; then + echo "[ERROR] Python not found for tag ${tag}: ${PYBIN}/python" >&2 + FAILED=1 + continue + fi + + echo "" + echo "========================================" + echo "Building orisys wheel: ${tag}" + echo "Python: $("${PYBIN}/python" -V)" + echo "========================================" + + export PATH="${PYBIN}:${PATH}" + export PYTHONPATH="/src/src${PYTHONPATH:+:${PYTHONPATH}}" + + "${PYBIN}/python" -m pip install -q --upgrade pip setuptools wheel auditwheel + + if "${PYBIN}/python" build/build_nuitka.py --target "${TARGET}"; then + BUILT+=("${tag}") + else + echo "[ERROR] Build failed for ${tag}" >&2 + FAILED=1 + fi +done + +echo "" +echo "=== Build artifacts ===" +ls -la /src/dist/repaired/ || true +ls -la /src/SDK/*/ || true + +if [[ ${#BUILT[@]} -gt 0 ]]; then + echo "Built tags: ${BUILT[*]}" +fi + +if [[ "${FAILED}" -ne 0 ]]; then + echo "[ERROR] One or more Python ABI builds failed." >&2 + exit 1 +fi + +echo "[SUCCESS] All requested manylinux wheels built." diff --git a/build/nuitka_compile.py b/build/nuitka_compile.py index 0217987..9caa35c 100644 --- a/build/nuitka_compile.py +++ b/build/nuitka_compile.py @@ -19,11 +19,18 @@ NUITKA_INCLUDE_PACKAGES = ( ) NUITKA_NOFOLLOW_IMPORTS = ( + # Keep as runtime deps (shipped via wheel install_requires / extras). + "numpy", + "scipy", + "cv2", + "numba", "orisys_blend", "cupy", "cupyx", "pyopencl", "open3d", + "PyQt5", + "pyqtgraph", ) diff --git a/build/paths.py b/build/paths.py index 817ac98..3fa8cfd 100644 --- a/build/paths.py +++ b/build/paths.py @@ -18,3 +18,8 @@ def repo_path(*parts: str) -> Path: def sdk_dir(version: str) -> Path: return REPO_ROOT / "SDK" / version + + +def repaired_wheel_dir() -> Path: + """Directory for auditwheel-repaired wheels under the Nuitka wheel build tree.""" + return NUITKA_WHEEL_DIR / "repaired" diff --git a/build/pyproject_utils.py b/build/pyproject_utils.py index dc1f73f..33aa629 100644 --- a/build/pyproject_utils.py +++ b/build/pyproject_utils.py @@ -2,7 +2,7 @@ from __future__ import annotations -import importlib +import ast import subprocess import sys from pathlib import Path @@ -41,17 +41,29 @@ def extras_require(repo_root: Path) -> dict[str, list[str]]: def _resolve_version_attr(repo_root: Path, attr: str) -> str: """Resolve a setuptools dynamic version attr (e.g. orisys.__version__).""" - src_dir = str(repo_root / "src") - if src_dir not in sys.path: - sys.path.insert(0, src_dir) module_name, _, attr_name = attr.partition(".") if not attr_name: raise RuntimeError(f"Invalid dynamic version attr: {attr!r}") - module = importlib.import_module(module_name) - value = module - for name in attr_name.split("."): - value = getattr(value, name) - return str(value) + + # Avoid importing package modules here: __init__ may import heavy runtime deps + # (e.g., cv2/numpy) that are not required just to read __version__. + rel_module = Path(*module_name.split(".")) + module_file = repo_root / "src" / rel_module.with_suffix(".py") + if not module_file.is_file(): + module_file = repo_root / "src" / rel_module / "__init__.py" + if not module_file.is_file(): + raise RuntimeError(f"Cannot resolve module file for dynamic version attr: {attr!r}") + + tree = ast.parse(module_file.read_text(encoding="utf-8"), filename=str(module_file)) + target_name = attr_name.split(".")[0] + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == target_name: + value = ast.literal_eval(node.value) + return str(value) + + raise RuntimeError(f"Attribute {target_name!r} not found in {module_file}") def get_version(repo_root: Path) -> str: diff --git a/build/verify.py b/build/verify.py index 6e97516..6dcd23b 100644 --- a/build/verify.py +++ b/build/verify.py @@ -11,6 +11,32 @@ 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 +from orisys import Sensor, list_config, export_config, util, finger +from orisys.finger import UVMeshViewer, build_uv_surface_mesh, FingerRuntime +from orisys.finger.mesh import UVSurfaceMesh +from orisys.finger.viewer import init_uv_mesh_viewer +from orisys.hhd import nHHD +from orisys.tracker import Tracker + +assert orisys.__version__ +assert Sensor is not None +assert callable(list_config) +assert callable(export_config) +assert util is not None +assert finger is not None +assert UVMeshViewer is not None +assert build_uv_surface_mesh is not None +assert FingerRuntime is not None +assert UVSurfaceMesh is not None +assert init_uv_mesh_viewer is not None +assert nHHD is not None +assert Tracker is not None +print(f"orisys {orisys.__version__} public API imports OK") +""" + def _venv_python(venv_dir: Path) -> Path: if sys.platform == "win32": @@ -19,7 +45,7 @@ def _venv_python(venv_dir: Path) -> Path: def verify_built_wheel(wheel_path: Path) -> None: - """Install the wheel in a temporary venv and smoke-test finger imports.""" + """Install the wheel in a temporary venv and smoke-test public imports.""" wheel_path = wheel_path.resolve() with tempfile.TemporaryDirectory(prefix="orisys-wheel-verify-") as tmp: venv_dir = Path(tmp) / "venv" @@ -27,20 +53,12 @@ def verify_built_wheel(wheel_path: Path) -> None: python = _venv_python(venv_dir) subprocess.check_call( - [python, "-m", "pip", "install", "-q", str(wheel_path)], + [python, "-m", "pip", "install", "-q", "--upgrade", "pip"], ) subprocess.check_call( - [ - python, - "-c", - ( - "from orisys.finger import UVMeshViewer, build_uv_surface_mesh, FingerRuntime; " - "from orisys.finger.mesh import UVSurfaceMesh; " - "from orisys.finger.viewer import init_uv_mesh_viewer; " - "print('finger mesh/viewer imports OK')" - ), - ], + [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: diff --git a/build/wheel_build.py b/build/wheel_build.py index 0fd1f33..19b9670 100644 --- a/build/wheel_build.py +++ b/build/wheel_build.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import shutil import subprocess import sys @@ -9,6 +10,11 @@ from pathlib import Path from paths import sdk_dir +# PyPI rejects bare tags like "manylinux_x86_64"; require a glibc ABI version. +_MANYLINUX_TAG_RE = re.compile( + r"(?:manylinux2014|manylinux_2_(?:17|24|27|28|31|34|35))_x86_64" +) + def write_wheel_setup( wheel_dir: Path, @@ -52,11 +58,78 @@ def run_bdist_wheel(wheel_dir: Path) -> Path | None: return wheels[0] if wheels else None +def _ensure_auditwheel() -> None: + try: + subprocess.check_call( + [sys.executable, "-m", "auditwheel", "--version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + print("Installing auditwheel...") + subprocess.check_call([sys.executable, "-m", "pip", "install", "auditwheel", "-q"]) + + +def is_compliant_manylinux_wheel(wheel_path: Path) -> bool: + """True if the wheel filename uses a PyPI-accepted manylinux x86_64 tag.""" + return bool(_MANYLINUX_TAG_RE.search(wheel_path.name)) + + +def repair_linux_wheel(wheel_path: Path, repair_dir: Path) -> Path: + """Run auditwheel show/repair and return a PyPI-compliant manylinux wheel. + + Raises RuntimeError if repair fails or the result still lacks a valid tag. + On non-Linux platforms the input wheel is returned unchanged. + """ + if sys.platform != "linux": + print("Skipping auditwheel repair (not running on Linux).") + return wheel_path + + _ensure_auditwheel() + repair_dir = repair_dir.resolve() + if repair_dir.exists(): + shutil.rmtree(repair_dir) + repair_dir.mkdir(parents=True) + + print("\n=== auditwheel show ===") + subprocess.check_call([sys.executable, "-m", "auditwheel", "show", str(wheel_path)]) + + print("\n=== auditwheel repair ===") + subprocess.check_call( + [ + sys.executable, + "-m", + "auditwheel", + "repair", + str(wheel_path), + "--wheel-dir", + str(repair_dir), + ] + ) + + repaired = sorted(repair_dir.glob("*.whl")) + if not repaired: + raise RuntimeError(f"auditwheel repair produced no wheels in {repair_dir}") + + compliant = [path for path in repaired if is_compliant_manylinux_wheel(path)] + if not compliant: + names = ", ".join(path.name for path in repaired) + raise RuntimeError( + "auditwheel repair did not produce a PyPI-compliant manylinux tag. " + f"Got: {names}. Rebuild inside quay.io/pypa/manylinux_2_28_x86_64 " + "(or manylinux2014_x86_64) instead of a generic Linux host." + ) + + final = compliant[0] + print(f"Repaired wheel: {final.name}") + return final + + def publish_wheel_to_sdk(wheel_path: Path, version: str) -> Path: sdk_version_dir = sdk_dir(version) sdk_version_dir.mkdir(parents=True, exist_ok=True) dest_wheel = sdk_version_dir / wheel_path.name if dest_wheel.exists(): dest_wheel.unlink() - shutil.move(str(wheel_path), str(dest_wheel)) + shutil.copy2(str(wheel_path), str(dest_wheel)) return dest_wheel diff --git a/config/ddjx01_wuxi.json b/config/box.json similarity index 66% rename from config/ddjx01_wuxi.json rename to config/box.json index cbbe434..af88413 100644 --- a/config/ddjx01_wuxi.json +++ b/config/box.json @@ -2,24 +2,24 @@ "name": "prototype", "version": "1.0", "camera": { - "resolution_raw": [1280, 720], - "fps": 15, + "resolution_raw": [1920, 1080], + "fps": 30, "units": 15, "units_used": 9, - "resolution_stitch": [263, 263], + "resolution_stitch": [365, 365], "stitch_res_list": [365,263,123,63] }, "stitcher": { "min_area_frac": 0.01, - "max_area_frac": 0.04, - "max_aspect_ratio": 25, + "max_area_frac": 0.05, + "max_aspect_ratio": 50, "index": [1,2,3,6,7,8,11,12,13], "margin": 10, - "nominal_ov_pos": 67, + "nominal_ov_pos": 100, "n": [3,3], "row_tol": 0.6, - "box_size": [180, 180], - "blend":"numpy", + "box_size": [250, 250], + "blend":"numba", "blend_list": ["numpy", "numba","none"] }, "tracker": { @@ -31,16 +31,15 @@ "dis_preset": "medium", "dis_mode": 3, "algorithm_list": ["raft", "dis"], - "hhd_downsample": 3, - "backend": "cpu" + "hhd_downsample": 3 }, "contact": { "min_contour_area": 3000, - "flow_threshold":1, - "div_threshold":0.02, + "flow_threshold":5, + "div_threshold":0.05, "expansion_threshold": 0.01, "kernel_size": [5,5], - "denoise_sigma": 10 + "denoise_sigma": 12 } } diff --git a/config/finger.json b/config/finger.json new file mode 100644 index 0000000..545a74a --- /dev/null +++ b/config/finger.json @@ -0,0 +1,35 @@ +{ + "name": "dd02-ov", + "type": "single_camera", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 30, + "resolution_stitch": [420, 450], + "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": 5 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold": 0.2, + "div_threshold": 0.022, + "expansion_threshold": 0.01, + "kernel_size": [5, 5], + "denoise_sigma": 12 + } +} diff --git a/config/mini-fast.json b/config/mini-fast.json new file mode 100644 index 0000000..cc5868f --- /dev/null +++ b/config/mini-fast.json @@ -0,0 +1,47 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [263, 263], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.04, + "max_aspect_ratio": 25, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 67, + "n": [3,3], + "row_tol": 0.6, + "box_size": [180, 180], + "blend":"numpy", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":1, + "div_threshold":0.02, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 10 + } + } + + + \ No newline at end of file diff --git a/config/mini.json b/config/mini.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/config/mini.json @@ -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 + } +} + + diff --git a/docs/api.docx b/docs/api.docx new file mode 100644 index 0000000..a898709 Binary files /dev/null and b/docs/api.docx differ diff --git a/docs/api.md b/docs/api.md index ed626f0..78f26e1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -238,7 +238,7 @@ flow = sensor.read_info(sensor.info.VRAW) ## `orisys.finger` 手指 UV pipeline -`examples/finger_pipeline.py` 展示了一个更适合手指/单相机场景的高层 API: +`examples/products/ory_finger/finger_pipeline.py` 展示了一个更适合手指/单相机场景的高层 API: 1. 使用 `FingerRuntime.open()` 打开视频源、加载 Blender 导出的资产,并构建 UV 展开映射。 2. 用 `grab_and_process()` 或 `read_source() + process_frame()` 获取一帧展开后的触觉图像。 @@ -262,7 +262,7 @@ flow = sensor.read_info(sensor.info.VRAW) #### 输入参数 * **export_dir** (`str`,可选): 手指导出资产目录,默认值来自 `orisys.finger.DEFAULT_FINGER_EXPORT_DIR`。目录下通常需要包含 `mesh.npz`、`camera_intrinsics.npz`、`world_to_cam.npy`。 -* **config** (`str`,可选): Orisys 配置文件路径,默认 `./config/dd02-ov.json`。 +* **config** (`str`,可选): Orisys 配置文件名称或路径,默认 `dd02`(兼容解析到 `dd02-ov`)。 * **map_cache** (`str`,可选): UV 展开缓存文件路径,默认值来自 `orisys.finger.DEFAULT_FINGER_MAP_CACHE`。 * **source_scale** (`float`,可选): 输入相机分辨率缩放比例;未提供时根据配置自动推断。 * **calib** (`str | None`,可选): 外部相机标定文件路径;未提供时优先使用导出资产中的内参。 @@ -282,7 +282,7 @@ flow = sensor.read_info(sensor.info.VRAW) from orisys.finger import FingerRuntime runtime = FingerRuntime.open( - config="./config/dd02-ov.json", + config="dd02", video=0, verbose=True, ) @@ -312,7 +312,7 @@ runtime = FingerRuntime.open( #### 描述 -对一帧原始输入执行源图预处理、UV 展开,并把展开后的结果送入内部 `TactileProcessor`。这是 `examples/finger_pipeline.py` 中 profile 模式使用的方法。 +对一帧原始输入执行源图预处理、UV 展开,并把展开后的结果送入内部 `TactileProcessor`。这是 `examples/products/ory_finger/finger_pipeline.py` 中 profile 模式使用的方法。 #### 输入参数 @@ -327,7 +327,7 @@ runtime = FingerRuntime.open( #### 描述 -先读取一帧视频源,再调用 `process_frame()` 完成 UV 展开并推入内部触觉处理器。非 profile 模式下,`examples/finger_pipeline.py` 使用该方法作为主循环入口。 +先读取一帧视频源,再调用 `process_frame()` 完成 UV 展开并推入内部触觉处理器。非 profile 模式下,`examples/products/ory_finger/finger_pipeline.py` 使用该方法作为主循环入口。 #### 输入参数 @@ -357,7 +357,7 @@ runtime = FingerRuntime.open( #### 描述 -先执行一次采集与 UV 展开,然后调用 `runtime.tactile.warmup()` 预热内部光流/HHD 相关缓存。`examples/finger_pipeline.py` 在进入主循环前会先执行一次 warmup。 +先执行一次采集与 UV 展开,然后调用 `runtime.tactile.warmup()` 预热内部光流/HHD 相关缓存。`examples/products/ory_finger/finger_pipeline.py` 在进入主循环前会先执行一次 warmup。 #### 输入参数 @@ -386,7 +386,7 @@ runtime = FingerRuntime.open( * **raw_sensor**: 底层 `Sensor` 对象。 * **info**: 与 `Sensor.info` 相同的枚举入口。 * **img / flow / vnormal / vshear / depth_map**: 当前帧处理结果。 -* **mask_stats**: 有效 UV 区域统计信息;`examples/finger_pipeline.py` 会读取 `mask_stats["coverage"]` 输出当前掩码覆盖率。 +* **mask_stats**: 有效 UV 区域统计信息;`examples/products/ory_finger/finger_pipeline.py` 会读取 `mask_stats["coverage"]` 输出当前掩码覆盖率。 * **valid_mask**: 当前有效区域掩码。 #### 说明 @@ -395,7 +395,7 @@ runtime = FingerRuntime.open( ### 10. 最小手指 pipeline 示例 -下面的流程与 `examples/finger_pipeline.py` 一致: +下面的流程与 `examples/products/ory_finger/finger_pipeline.py` 一致: ```python import time @@ -404,7 +404,7 @@ import orisys from orisys.finger import FingerRuntime runtime = FingerRuntime.open( - config="./config/dd02-ov.json", + config="dd02", video=0, verbose=True, ) @@ -462,7 +462,7 @@ finally: ### 11. 关于 profile 模式 -`examples/finger_pipeline.py` 中的 `--profile` / `--profile-every` 是示例脚本参数,不属于 `orisys` SDK 核心 API。本质上它只是把主循环拆成: +`examples/products/ory_finger/finger_pipeline.py` 中的 `--profile` / `--profile-every` 是示例脚本参数,不属于 `orisys` SDK 核心 API。本质上它只是把主循环拆成: 1. `runtime.read_source()` 2. `runtime.process_frame(...)` diff --git a/docs/api.pdf b/docs/api.pdf new file mode 100644 index 0000000..0b72d9c Binary files /dev/null and b/docs/api.pdf differ diff --git a/docs/get_started.docx b/docs/get_started.docx new file mode 100644 index 0000000..91657fb Binary files /dev/null and b/docs/get_started.docx differ diff --git a/docs/get_started.md b/docs/get_started.md index 1459600..daaa7cd 100644 --- a/docs/get_started.md +++ b/docs/get_started.md @@ -112,13 +112,16 @@ print(orisys.__version__) # 使用方法 ## 运行示例程序 -套件里面包含了多个例子,建议都在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。常见入口分为两类: +套件里面包含了多个例子,建议都在仓库根目录运行(即 `config/`、`examples/`、`assets/` 同级目录)。按产品入口如下: -1. `examples/basic_pipeline.py`:复眼/拼接传感器基础 OpenCV 示例 -2. `examples/finger_pipeline.py`:手指传感器2维显示最小流程示例 -3. `examples/finger_pipeline_3dview.py`:手指传感器 Qt 3D 可视化界面 +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`) -下面先介绍最基础的 `basic_pipeline.py` 运行方式。 +`dd02` 是当前 ORY-FINGER 的稳定别名,实际会解析到 `dd02-ov`。旧路径 `examples/basic_pipeline.py` / `examples/finger_pipeline*.py` 仍可作为兼容入口。 + +下面先介绍最基础的 ORY-BOX 运行方式。 ### Windows: 1. 检查是否有其他正在连接的USB摄像头设备:打开powershell, 运行命令`Get-PnpDevice -PresentOnly | Where-Object { $_.Class -match '^Camera' }`,如果没有的话不会有输出。 @@ -126,7 +129,7 @@ print(orisys.__version__) 3. 如果第一步没有输出,传感器的ID就是“0”,如果第一步有一个设备,传感器的ID就是“1”,如此类推。 4. 在确定了传感器的ID(下方例子假设为“0”)后,打开Anaconda Prompt, 切换到工作路径,然后在命令行中输入: ``` bash - python examples/basic_pipeline.py -v 0 + python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 ``` 5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据 @@ -136,22 +139,22 @@ print(orisys.__version__) 3. 根据新增的输出,例如”/dev/video0”,确定传感器的ID为“0”, 如果是”/dev/video3”的话ID为“3” ,如此类推。 4. 在确定了传感器的ID(下方例子假设为“0”)后,打开终端, 激活相应的python环境,切换到工作路径,然后在命令行中输入: ``` bash - python examples/basic_pipeline.py -v 0 + python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 ``` 5. 程序会运行,并自动打开可视化窗口和打印资料,在窗口上按键盘”q”键可以停止运行,按”r”键可以重设数据 -### 手指传感器 UV 示例 +### ORY-FINGER UV 示例 -如果您使用的是手指/单相机 UV 展开流程,可运行: +如果您使用的是 ORY-FINGER(当前配置别名 `dd02`),可运行: ``` bash -python examples/finger_pipeline.py --camera_id 0 --config ./config/dd02-ov.json +python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 ``` 如果输入来自视频文件,也可以改用: ``` bash -python examples/finger_pipeline.py --video ./data/sample.mp4 --config ./config/dd02-ov.json +python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4 --config dd02 ``` 该示例会: @@ -168,10 +171,10 @@ python examples/finger_pipeline.py --video ./data/sample.mp4 --config ./config/d 例如: ``` bash -python examples/finger_pipeline.py --camera_id 0 --config ./config/dd02-ov.json +python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 ``` -### 手指 3D 可视化界面 +### ORY-FINGER 3D 可视化界面 如果要使用 GUI ,安装GUI依赖: ``` bash @@ -180,7 +183,7 @@ pip install pyqt5 pyqtgraph ``` 然后运行: ``` bash -python examples/finger_pipeline_3dview.py --video 0 --config ./config/dd02-ov.json +python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02 ``` 该界面会提供: diff --git a/docs/get_started.pdf b/docs/get_started.pdf new file mode 100644 index 0000000..344c8cd Binary files /dev/null and b/docs/get_started.pdf differ diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 0000000..c860375 --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,167 @@ +# 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//` (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 = "" + +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==" +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 = "" + +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. diff --git a/examples/README.md b/examples/README.md index 00ac50f..913c9f1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,54 +1,66 @@ # Examples -Top-level demo scripts under `examples/`. Run them from the repository root. +Product-facing demos live under `examples/products/`. Shared helpers stay in +`pipeline/`, `viz/`, and `gui/`. Run commands from the repository root. -## Top-Level Files +## Product Entry Points -- `README.md` — this overview of the root-level example entry points -- `basic_pipeline.py` — basic single-sensor OpenCV demo for the compound-eye pipeline -- `plot_data.py` — single-sensor demo with OpenCV views plus PyQtGraph force charts -- `two_sensor_pipeline.py` — two-sensor multiprocessing demo for parallel acquisition / display -- `finger_pipeline.py` — minimal finger UV-unroll pipeline with optional profiling -- `finger_pipeline_3dview.py` — main finger dashboard entry point with the unified Qt UI +| Product | Entry | Default config | +|---------|-------|----------------| +| ORY-BOX | `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` | + +`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. ## Typical Usage -For the Qt dashboard: +ORY-BOX: + +```bash +python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 +``` + +ORY-MINI: + +```bash +python examples/products/ory_mini/basic_pipeline.py -v 0 -c mini +``` + +ORY-FINGER 2D: + +```bash +python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 +``` + +ORY-FINGER video file: + +```bash +python examples/products/ory_finger/finger_pipeline.py --video ./data/sample.mp4 --config dd02 +``` + +ORY-FINGER 3D Qt dashboard: ```bash pip install -e ".[gui]" -python examples/finger_pipeline_3dview.py --video 0 --config ./config/dd02-ov.json +python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02 ``` -For the minimal finger pipeline: +## Other Root Examples -```bash -python examples/finger_pipeline.py --camera_id 0 --config ./config/dd02-ov.json -``` - -For the minimal finger pipeline with profiling: - -```bash -python examples/finger_pipeline.py --camera_id 0 --config ./config/dd02-ov.json --profile --profile-every 30 -``` - -For a video-file finger run: - -```bash -python examples/finger_pipeline.py --video ./data/sample.mp4 --config ./config/dd02-ov.json -``` - -For the basic compound-eye pipeline: - -```bash -python examples/basic_pipeline.py -v 0 -c ./config/ddjx01.json -``` +- `plot_data.py` — single-sensor OpenCV views plus PyQtGraph force charts +- `two_sensor_pipeline.py` — two-sensor multiprocessing demo ## Requirements - SDK installed from the repository root, for example `pip install -e .` - camera index or test video path -- matching config files under `./config/` +- matching config names under `./config/` (or packaged `orisys/configs/`) Qt / PyQtGraph examples additionally require: @@ -58,8 +70,10 @@ pip install -e ".[gui]" ## Notes -- `finger_pipeline_3dview.py` is the main interactive demo. It uses `gui/app_paths.py` so the same entry point works in editable and packaged runs. -- Dashboard controls: click **启动** to start acquisition; use **Record CSV / Stop CSV** and the CSV save-directory browser to log contact/force data; `Q` quits, `R` resets, `S` starts video recording, `E` stops video recording, and `Esc` toggles fullscreen. -- `finger_pipeline.py` is the leanest finger example if you want to validate UV unroll + force/contact processing without the full dashboard. -- `finger_pipeline.py` uses `orisys.finger.FingerRuntime` as the high-level entry point, then runs tactile analysis through `runtime.tactile`. -- In `--profile` mode, the example splits the loop into capture, UV unroll, deformation, contact, and display stages so you can measure each step separately. +- `products/ory_finger/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 + validate UV unroll + force/contact processing without the full dashboard. diff --git a/examples/basic_pipeline.py b/examples/basic_pipeline.py index 7404637..13983a2 100644 --- a/examples/basic_pipeline.py +++ b/examples/basic_pipeline.py @@ -1,115 +1,21 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for ORY-BOX. + +Prefer: + python examples/products/ory_box/basic_pipeline.py -v 0 -c ddjx01 """ -示例 1:单传感器基础流程 -本示例演示 Orisys SDK 的标准处理流程: -1. 初始化传感器(摄像头或视频文件) -2. 获取图像并计算形变 -3. 按需计算接触区域 -4. 读取并可视化结果 +from __future__ import annotations -SDK version: 0.3.1 -""" -import os -os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" -import cv2 -import orisys -import numpy as np -import argparse +import sys +from pathlib import Path -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("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志") - args = parser.parse_args() +_EXAMPLES_DIR = Path(__file__).resolve().parent +if str(_EXAMPLES_DIR) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_DIR)) - # 步骤 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("形变矢量场", arrows) - - # 步骤 3.2:显示接触区域和质心 - image_with_foe = orisys.util.draw_contact(img, contour, centroid) - cv2.imshow("接触区域与质心", 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("深度图", 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() # 释放视频源及相关资源 +from products.ory_box.basic_pipeline import main -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/examples/finger_pipeline.py b/examples/finger_pipeline.py index deeaddf..836531d 100644 --- a/examples/finger_pipeline.py +++ b/examples/finger_pipeline.py @@ -1,108 +1,20 @@ #!/usr/bin/env python3 -""" -Minimum UV-unroll finger tactile pipeline. +"""Compatibility wrapper for ORY-FINGER 2D pipeline. -Offline: load Blender export -> build_or_load_domain -> unroll_map_uv.npz -Runtime: camera frame -> FrameUnwarper -> TactileProcessor -> forces/contact - -For the full demo (UV debug panels, dashboard, benchmarks) see: - tools/3d_patch_generation_blender/unroll_realtime.py +Prefer: + python examples/products/ory_finger/finger_pipeline.py --camera_id 0 --config dd02 """ -import argparse -import os +from __future__ import annotations + import sys +from pathlib import Path -os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") +_EXAMPLES_DIR = Path(__file__).resolve().parent +if str(_EXAMPLES_DIR) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_DIR)) -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="./config/dd02-ov.json", help="Orisys SDK config") - 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() +from products.ory_finger.finger_pipeline import main if __name__ == "__main__": diff --git a/examples/finger_pipeline_3dview.py b/examples/finger_pipeline_3dview.py index ca0362d..04a8d86 100644 --- a/examples/finger_pipeline_3dview.py +++ b/examples/finger_pipeline_3dview.py @@ -1,75 +1,20 @@ #!/usr/bin/env python3 -""" -UV-unroll finger tactile pipeline with unified Qt dashboard. +"""Compatibility wrapper for ORY-FINGER 3D Qt dashboard. -Where to customize the GUI: - Layout (sizes, proportions) -> examples/gui/layout.py <-- edit here - Colors / QSS -> examples/gui/styles.py - Widget rendering logic -> examples/gui/widgets/ - Window behavior -> examples/gui/dashboard.py +Prefer: + python examples/products/ory_finger/finger_pipeline_3dview.py --video 0 --config dd02 """ -import argparse -import os +from __future__ import annotations + import sys +from pathlib import Path -os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") +_EXAMPLES_DIR = Path(__file__).resolve().parent +if str(_EXAMPLES_DIR) not in sys.path: + sys.path.insert(0, str(_EXAMPLES_DIR)) -from gui.app_paths import configure_frozen_runtime, default_config_path, ensure_examples_import_path - -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)) +from products.ory_finger.finger_pipeline_3dview import main if __name__ == "__main__": diff --git a/examples/gui/app_paths.py b/examples/gui/app_paths.py index e227e3b..998640a 100644 --- a/examples/gui/app_paths.py +++ b/examples/gui/app_paths.py @@ -27,7 +27,8 @@ def resource_path(*parts: str) -> Path: def default_config_path() -> str: - return str(resource_path("config", "dd02-ov.json")) + """Default ORY-FINGER config name (resolves to dd02-ov via alias).""" + return "dd02" def configure_frozen_runtime() -> None: diff --git a/examples/gui/viewer3d/widgets/settings_panel.py b/examples/gui/viewer3d/widgets/settings_panel.py index 539a5a8..92ccb3f 100644 --- a/examples/gui/viewer3d/widgets/settings_panel.py +++ b/examples/gui/viewer3d/widgets/settings_panel.py @@ -137,7 +137,7 @@ class SettingsPanelWidget(QWidget): root.addWidget(self._caption("Config")) config_row = QHBoxLayout() config_row.setSpacing(6) - self.config_edit = QLineEdit("./config/dd02-ov.json") + self.config_edit = QLineEdit("dd02") config_row.addWidget(self.config_edit, 1) self.btn_browse_config = QPushButton("…") self.btn_browse_config.setFixedWidth(32) diff --git a/examples/pipeline/config.py b/examples/pipeline/config.py index a199319..a11a75e 100644 --- a/examples/pipeline/config.py +++ b/examples/pipeline/config.py @@ -17,7 +17,7 @@ def mesh_threshold(mesh_map: str, threshold: float | None) -> float: @dataclass class PipelineConfig: video: str = "0" - config: str = "./config/dd02-ov.json" + config: str = "dd02" calib: str | None = None export_dir: str = DEFAULT_FINGER_EXPORT_DIR csv_save_dir: str = "./outputs/csv" diff --git a/examples/products/__init__.py b/examples/products/__init__.py new file mode 100644 index 0000000..f8e6c8d --- /dev/null +++ b/examples/products/__init__.py @@ -0,0 +1 @@ +"""Product-facing example entry points for ORY-BOX / ORY-MINI / ORY-FINGER.""" diff --git a/examples/products/ory_box/basic_pipeline.py b/examples/products/ory_box/basic_pipeline.py new file mode 100644 index 0000000..d7568ed --- /dev/null +++ b/examples/products/ory_box/basic_pipeline.py @@ -0,0 +1,115 @@ +""" +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() diff --git a/examples/products/ory_finger/finger_pipeline.py b/examples/products/ory_finger/finger_pipeline.py new file mode 100644 index 0000000..6aba015 --- /dev/null +++ b/examples/products/ory_finger/finger_pipeline.py @@ -0,0 +1,111 @@ +#!/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() diff --git a/examples/products/ory_finger/finger_pipeline_3dview.py b/examples/products/ory_finger/finger_pipeline_3dview.py new file mode 100644 index 0000000..d9e1eb3 --- /dev/null +++ b/examples/products/ory_finger/finger_pipeline_3dview.py @@ -0,0 +1,85 @@ +#!/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() diff --git a/examples/products/ory_mini/basic_pipeline.py b/examples/products/ory_mini/basic_pipeline.py new file mode 100644 index 0000000..5d26216 --- /dev/null +++ b/examples/products/ory_mini/basic_pipeline.py @@ -0,0 +1,115 @@ +""" +ORY-MINI:单传感器基础流程 + +本示例演示 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="mini", 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("arrows", 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() diff --git a/examples/two_sensor_pipeline.py b/examples/two_sensor_pipeline.py index 0bf7c5a..9d78b09 100644 --- a/examples/two_sensor_pipeline.py +++ b/examples/two_sensor_pipeline.py @@ -18,7 +18,7 @@ - Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support() - 每个传感器进程都会占用独立的 GPU 显存 -SDK version: 0.3.1 +SDK version: 0.4.2 """ import os os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" diff --git a/pyproject.toml b/pyproject.toml index 226ad5f..4dcad2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,25 +4,52 @@ build-backend = "setuptools.build_meta" [project] name = "orisys" -description = "Orisys - Vision Based Tactile Sensor SDK" +description = "SDK for Orisys Technology Visuotactile Sensors" readme = "README.md" requires-python = ">=3.10" -authors = [{ name = "Orisys" }] +license = { text = "Proprietary" } +authors = [{ name = "Orisys-Tech" }] +maintainers = [ + {name = "Yijian Zhang", email = "zhangyijian@orisys-tech.cn"} +] dynamic = ["version"] dependencies = [ "numpy>=1.24.4", "opencv-python>=4.10.0.84", "scipy>=1.10.1", - "open3d>=0.17.0", + "numba>=0.59.0", ] +classifiers = [ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + "Development Status :: 4 - Beta", + + # Indicate who your project is intended for + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: Other/Proprietary License", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + + # Specify the Python versions you support here. + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +[project.urls] +Homepage = "https://www.orisys-tech.cn" +Documentation = "https://github.com/Orisys-Tech/orisys-sdk" +Issues = "https://github.com/Orisys-Tech/orisys-sdk/issues" [project.optional-dependencies] cuda12 = ["cupy-cuda12x>=12.3.0"] cuda13 = ["cupy-cuda13x>=13.0.0"] +gui = ["open3d>=0.17.0","pyqt5>=5.15", "pyqtgraph>=0.13"] # Backward compatibility: 'cuda' defaults to CUDA 12.x cuda = ["cupy-cuda12x>=12.3.0"] -gui = ["pyqt5>=5.15", "pyqtgraph>=0.13"] -numba = ["numba>=0.59.0"] [tool.setuptools] package-dir = { "" = "src" } @@ -52,7 +79,7 @@ dev_install = true name = "examples" source = "examples" target = "examples" -patterns = ["*.py", "README.md", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"] +patterns = ["*.py", "README.md", "products/**/*.py", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"] preserve_tree = true [[tool.orisys.bundle.items]] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..029fccc --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,14 @@ +# 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] + +numpy>=1.24.4 +opencv_python>=4.10.0.84 +scipy==1.13 +setuptools>=45.2.0 +av +numba +pyqt5 +pyqtgraph +nuitka \ No newline at end of file diff --git a/scripts/qt_viewer_finger.py b/scripts/qt_viewer_finger.py index 7165a35..bbe8d55 100644 --- a/scripts/qt_viewer_finger.py +++ b/scripts/qt_viewer_finger.py @@ -22,7 +22,7 @@ def main(): 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="./config/dd02-ov.json") + 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) diff --git a/src/orisys/configs.py b/src/orisys/configs.py index ebdfad8..c3249f7 100644 --- a/src/orisys/configs.py +++ b/src/orisys/configs.py @@ -1,6 +1,12 @@ import json from pathlib import Path +# Product-facing names that map to on-disk config stems. +# ORY-FINGER currently ships as dd02-ov.json; keep "dd02" as the stable alias. +CONFIG_ALIASES = { + "dd02": "dd02-ov", +} + def _candidate_config_dirs() -> list[Path]: """ @@ -62,9 +68,16 @@ def load_config(config_name_or_path): if config_path.is_absolute(): raise FileNotFoundError(f"Config file not found at absolute path: {config_path}") - direct = CONFIG_DIR / f"{config_name_or_path}.json" - if direct.exists(): - return _load_json(direct) + requested = str(config_name_or_path) + candidates = [requested] + alias = CONFIG_ALIASES.get(requested) + if alias and alias not in candidates: + candidates.append(alias) + + for name in candidates: + direct = CONFIG_DIR / f"{name}.json" + if direct.exists(): + return _load_json(direct) # Backward-compatible alias resolution: support old names via JSON "name" field. for candidate in _config_files(): @@ -72,7 +85,7 @@ def load_config(config_name_or_path): data = _load_json(candidate) except Exception: continue - if data.get("name") == config_name_or_path: + if data.get("name") in candidates: return data available = [f.stem for f in _config_files()] @@ -80,7 +93,7 @@ def load_config(config_name_or_path): raise FileNotFoundError( f"Input config file not found with name or path: {config_name_or_path}\n" f" Tried as file path: {config_path} (not found)\n" - f" Tried as config file: {direct} (not found)\n" + f" Tried as config files: {', '.join(candidates)} (not found)\n" f" Available config files: {available_str}" ) diff --git a/tests/dd06.py b/tests/dd06.py index 574706e..96bb68a 100644 --- a/tests/dd06.py +++ b/tests/dd06.py @@ -8,7 +8,7 @@ Flow: 4. Feed the rectified frame into Sensor.get_img(..., already_processed=True) 5. Compute deformation/contact and visualize -SDK version: 0.3.1 +SDK version: 0.4.2 """ from __future__ import annotations diff --git a/tests/test_wheel_build.py b/tests/test_wheel_build.py new file mode 100644 index 0000000..471a9fd --- /dev/null +++ b/tests/test_wheel_build.py @@ -0,0 +1,54 @@ +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def wheel_build(): + import sys + + build_dir = REPO_ROOT / "build" + if str(build_dir) not in sys.path: + sys.path.insert(0, str(build_dir)) + import wheel_build + + return wheel_build + + +def test_compliant_manylinux_tags(wheel_build): + assert wheel_build.is_compliant_manylinux_wheel( + Path("orisys-0.4.2-cp310-cp310-manylinux_2_17_x86_64.whl") + ) + assert wheel_build.is_compliant_manylinux_wheel( + Path("orisys-0.4.2-cp310-cp310-manylinux2014_x86_64.whl") + ) + assert wheel_build.is_compliant_manylinux_wheel( + Path("orisys-0.4.2-cp310-cp310-manylinux_2_28_x86_64.whl") + ) + + +def test_rejects_illegal_manylinux_tags(wheel_build): + assert not wheel_build.is_compliant_manylinux_wheel( + Path("orisys-0.4.2-cp310-cp310-manylinux_x86_64.whl") + ) + assert not wheel_build.is_compliant_manylinux_wheel( + Path("orisys-0.4.2-cp310-cp310-linux_x86_64.whl") + ) + assert not wheel_build.is_compliant_manylinux_wheel( + Path("orisys-0.4.2-cp310-cp310-win_amd64.whl") + ) + + +def test_pyproject_no_private_classifier(): + import sys + + build_dir = REPO_ROOT / "build" + if str(build_dir) not in sys.path: + sys.path.insert(0, str(build_dir)) + from pyproject_utils import project_metadata + + classifiers = project_metadata(REPO_ROOT).get("classifiers", []) + assert "Private :: Do Not Upload" not in classifiers + assert "License :: Other/Proprietary License" in classifiers