diff --git a/.gitignore b/.gitignore index 5252766..0db79d4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,13 +4,14 @@ __pycache__/ .pytest_cache/ # Packaging / build outputs -/build/ +/build/nuitka/ +/build/lib/orisys/ +/build/pyinstaller_work/ /dist/ /dist_obfuscated/ /dist_pyd/ /dist_single_pyd/ /nuitka_wheel/ -/build_nuitka/ /release/ /SDK/ *.egg-info/ diff --git a/assets/finger/30fps-0616/camera_intrinsics.npz b/assets/finger/30fps-0616/camera_intrinsics.npz new file mode 100644 index 0000000..888b1eb Binary files /dev/null and b/assets/finger/30fps-0616/camera_intrinsics.npz differ diff --git a/assets/finger/30fps-0616/mesh.npz b/assets/finger/30fps-0616/mesh.npz new file mode 100644 index 0000000..ecb8980 Binary files /dev/null and b/assets/finger/30fps-0616/mesh.npz differ diff --git a/assets/finger/30fps-0616/pixel_to_xyz.npz b/assets/finger/30fps-0616/pixel_to_xyz.npz new file mode 100644 index 0000000..c15f54f Binary files /dev/null and b/assets/finger/30fps-0616/pixel_to_xyz.npz 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 new file mode 100644 index 0000000..e9a8bf1 Binary files /dev/null and b/assets/finger/30fps-0616/unroll_map_uv_s0.4.npz differ diff --git a/assets/finger/30fps-0616/world_to_cam.npy b/assets/finger/30fps-0616/world_to_cam.npy new file mode 100644 index 0000000..1923088 Binary files /dev/null and b/assets/finger/30fps-0616/world_to_cam.npy differ diff --git a/build/OrisysDashboard.spec b/build/OrisysDashboard.spec new file mode 100644 index 0000000..47bae68 --- /dev/null +++ b/build/OrisysDashboard.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['D:\\repo\\orisyssdk_git\\examples\\finger_pipeline_3dview.py'], + pathex=[], + binaries=[], + datas=[('D:\\repo\\orisyssdk_git\\config', 'config'), ('D:\\repo\\orisyssdk_git\\assets', 'assets')], + hiddenimports=['PyQt5', 'pyqtgraph', 'open3d', 'cv2', 'orisys', 'gui', 'pipeline', 'viz', 'app_paths', 'plotly', '_plotly_utils', 'open3d.cpu.pybind'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['cupy', 'cupyx', 'pyopencl', 'tkinter', 'pip', 'wheel', 'matplotlib'], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='OrisysDashboard', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='OrisysDashboard', +) diff --git a/build/build_gui_win_pyinstaller.py b/build/build_gui_win_pyinstaller.py new file mode 100644 index 0000000..9ca0284 --- /dev/null +++ b/build/build_gui_win_pyinstaller.py @@ -0,0 +1,194 @@ +"""Build a CPU-only standalone Windows folder for Orisys 3D dashboard using PyInstaller. + +Usage: + conda activate py310 # or a clean virtualenv with only required packages + pip install pyinstaller + python build/build_gui_win_pyinstaller.py + +Output: + dist/OrisysDashboard/OrisysDashboard.exe (+ bundled Python, DLLs, config/, assets/) +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +# ---------- Configuration ---------- +ENTRY_SCRIPT = Path("examples/finger_pipeline_3dview.py").resolve() +EXE_NAME = "OrisysDashboard.exe" +DIST_NAME = "OrisysDashboard" # final folder name +repo_root = Path(__file__).parent.parent + +# Packages you want to force-include (PyInstaller often auto-detects them, but explicit avoids misses) +HIDDEN_IMPORTS = [ + "PyQt5", + "pyqtgraph", + "open3d", + "cv2", + "orisys", + "gui", + "pipeline", + "viz", + "app_paths", + "plotly", + "_plotly_utils", + # sometimes needed for open3d: + "open3d.cpu.pybind", # adjust to actual module if needed +] + +# Modules to completely exclude (they won't be imported anyway if absent in venv) +EXCLUDE_MODULES = [ + "cupy", + "cupyx", + "pyopencl", + "tkinter", + # "unittest", + # "email", + # "http", + # "xmlrpc", + # "pkg_resources", + # "setuptools", + "pip", + "wheel", + "matplotlib", # unless you use it + # "scipy", # unless you use it +] + +# Data directories: (source relative to repo root, destination in bundle) +DATA_DIRS = [ + ("config", "config"), + ("assets", "assets"), +] + +# Optional: UPX directory (download from https://upx.github.io) +UPX_DIR = r"C:\tools\upx" # set to None if not available +# ---------- End Configuration ---------- + + +def clean_dist(dist_path: Path) -> None: + if dist_path.exists(): + shutil.rmtree(dist_path) + dist_path.mkdir(parents=True) + + +def build_with_pyinstaller(): + # Ensure we are in a clean, minimal venv + print("Using Python:", sys.executable) + + # Output folder for PyInstaller work files + work_dir = Path("build/pyinstaller_work") + dist_dir = Path("dist") / DIST_NAME + clean_dist(work_dir) + clean_dist(dist_dir) + + # Build PyInstaller command + cmd = [ + sys.executable, "-m", "PyInstaller", + "--onedir", # produce a folder, not a single exe + "--name", DIST_NAME, + f"--distpath={dist_dir.parent}", + f"--workpath={work_dir}", + f"--specpath=build", + "--noconfirm", + "--clean", + "--windowed", # or "--console" if you need terminal + ] + + # Add data files + for src_rel, dest_rel in DATA_DIRS: + src = Path(src_rel).resolve() + if not src.is_dir(): + raise FileNotFoundError(f"Data directory missing: {src}") + cmd.append(f"--add-data={src};{dest_rel}") + + # Logo file if exists + logo = Path("logo.png") + if logo.exists(): + cmd.append(f"--add-data={logo.resolve()};.") + + # Hidden imports + for mod in HIDDEN_IMPORTS: + cmd.append(f"--hidden-import={mod}") + + # Exclude modules + for mod in EXCLUDE_MODULES: + cmd.append(f"--exclude-module={mod}") + + # UPX if available + if UPX_DIR and Path(UPX_DIR).is_dir(): + cmd.append(f"--upx-dir={UPX_DIR}") + + # Entry point + cmd.append(str(ENTRY_SCRIPT)) + + # Environment (same as Nuitka script) + env = os.environ.copy() + env["PYTHONPATH"] = str(Path("src").resolve()) + os.pathsep + str(Path("examples").resolve()) + os.pathsep + env.get("PYTHONPATH", "") + + print("\n=== Running PyInstaller ===") + print("Command:", " ".join(cmd)) + subprocess.check_call(cmd, env=env) + + # After build, copy data directories explicitly + # PyInstaller output folder + dist_dir = Path("dist") / DIST_NAME + final_exe = dist_dir / EXE_NAME + if not final_exe.is_file(): + raise RuntimeError(f"Executable not found at {final_exe}") + print("\n=== Copying data directories ===") + for src_rel, dest_rel in DATA_DIRS: + src = repo_root / src_rel # use an absolute reference to repo root + dest = dist_dir / dest_rel + if not src.is_dir(): + raise FileNotFoundError(f"Data directory not found: {src}") + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest, dirs_exist_ok=True) # Python 3.8+ use dirs_exist_ok=True + print(f" {src} -> {dest}") + + # Copy logo + logo_src = repo_root / "logo.png" + if logo_src.is_file(): + shutil.copy2(logo_src, dist_dir / "logo.png") + print(f" Logo copied") + + # Write README (same as Nuitka script) + readme = dist_dir / "README.txt" + readme.write_text( + "\n".join([ + "Orisys Finger Dashboard (standalone)", + "", + "Run:", + f" {EXE_NAME}", + f" {EXE_NAME} --video path\\to\\video.mp4", + f" {EXE_NAME} --config config\\dd02-ov.json", + "", + "Bundled folders:", + " config/ sensor JSON configs", + " assets/ finger mesh and visualization data", + "", + "CPU-only build (no CUDA/CuPy).", + ]), + encoding="utf-8", + ) + + print(f"\n[SUCCESS] Standalone GUI folder: {dist_dir}") + print(f"Executable: {final_exe}") + + # Optional smoke test (same as Nuitka script) + try: + from verify import verify_gui_dist + print("\n=== Smoke test ===") + verify_gui_dist(dist_dir) + except Exception as exc: + print(f"\n[WARNING] Smoke test failed: {exc}") + + return dist_dir + + +if __name__ == "__main__": + build_with_pyinstaller() \ No newline at end of file diff --git a/build/build_nuitka.py b/build/build_nuitka.py new file mode 100644 index 0000000..23249bf --- /dev/null +++ b/build/build_nuitka.py @@ -0,0 +1,89 @@ +"""Build a Nuitka-compiled orisys wheel and publish it to SDK/{version}/. + +Pipeline: + 1. Compile src/orisys → .pyd + 2. Stage wheel tree (compiled module + bundled files from pyproject.toml) + 3. Run bdist_wheel and move output to SDK/{version}/ + 4. Smoke-test imports in a temporary venv + +Run from repo root: python build/build_nuitka.py +""" + +from __future__ import annotations + +import os +import shutil +import subprocess + +from nuitka_compile import compile_orisys, copy_compiled_orisys, install_nuitka +from paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repo_path +from pyproject_utils import bundle_items, extras_require, get_version, install_requires +from verify import verify_built_wheel +from wheel_build import publish_wheel_to_sdk, run_bdist_wheel, write_wheel_setup +from wheel_layout import copy_bundle_items, package_data_from_bundle + + +def _reset_dir(path) -> None: + """Remove and recreate a build output directory.""" + if path.exists(): + shutil.rmtree(path) + path.mkdir(parents=True) + + +def build() -> None: + # --- 1. Compile --- + install_nuitka() + + version = get_version(REPO_ROOT) + _reset_dir(NUITKA_COMPILE_DIR) + _reset_dir(NUITKA_WHEEL_DIR) + + print("\n=== Compiling with Nuitka ===") + env = os.environ.copy() + src_dir = repo_path("src") + env["PYTHONPATH"] = src_dir.as_posix() + os.pathsep + env.get("PYTHONPATH", "") + ext_file = compile_orisys(NUITKA_COMPILE_DIR, env) + + # --- 2. Stage wheel contents --- + print("\n=== Preparing Wheel Structure ===") + pkg_dir = NUITKA_WHEEL_DIR / "wheel_root" / "orisys" + pkg_dir.mkdir(parents=True) + + ext_suffix = copy_compiled_orisys(pkg_dir, ext_file) + + # Bundle rules live in pyproject.toml → [[tool.orisys.bundle.items]] + bundle = bundle_items(REPO_ROOT) + copied_counts = copy_bundle_items(REPO_ROOT, pkg_dir, bundle) + print(f"Copied {copied_counts['configs']} JSON config files") + print(f"Copied {copied_counts['examples']} example files to orisys/examples/") + print(f"Copied {copied_counts['docs']} doc files to orisys/docs/") + print(f"Copied {copied_counts['assets']} finger asset files to orisys/assets/") + + write_wheel_setup( + NUITKA_WHEEL_DIR, + version=version, + package_data=package_data_from_bundle(ext_suffix, bundle), + install_requires=install_requires(REPO_ROOT), + extras_require=extras_require(REPO_ROOT), + ) + + # --- 3. Build wheel and publish to SDK/ --- + wheel_path = run_bdist_wheel(NUITKA_WHEEL_DIR) + if wheel_path is None: + print("\n[ERROR] Wheel not found.") + return + + final_wheel = publish_wheel_to_sdk(wheel_path, version) + print(f"\n[SUCCESS] Wheel created and moved to: {final_wheel}") + + # # --- 4. Verify (isolated venv; does not touch the active environment) --- + # print("\n=== Verifying finger mesh/viewer imports ===") + # try: + # verify_built_wheel(final_wheel) + # except subprocess.CalledProcessError as exc: + # print(f"\n[WARNING] Wheel verify failed: {exc}") + # print("The wheel was built successfully; verification runs in an isolated temp venv.") + + +if __name__ == "__main__": + build() diff --git a/build/build_nuitka_win.bat b/build/build_nuitka_win.bat new file mode 100644 index 0000000..fb9f9d1 --- /dev/null +++ b/build/build_nuitka_win.bat @@ -0,0 +1,70 @@ +@echo off +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\.." +if errorlevel 1 ( + echo ERROR: Could not change to repository root. + exit /b 1 +) + +if not defined CONDA_ENVS_ROOT ( + if exist "%USERPROFILE%\anaconda3\envs" ( + set "CONDA_ENVS_ROOT=%USERPROFILE%\anaconda3\envs" + ) else if exist "%USERPROFILE%\miniconda3\envs" ( + set "CONDA_ENVS_ROOT=%USERPROFILE%\miniconda3\envs" + ) else if exist "%LOCALAPPDATA%\anaconda3\envs" ( + set "CONDA_ENVS_ROOT=%LOCALAPPDATA%\anaconda3\envs" + ) +) + +where conda >nul 2>&1 +set "HAS_CONDA=0" +if not errorlevel 1 set "HAS_CONDA=1" + +set FAILED=0 +for %%E in (py310 py311 py312) do ( + echo. + echo ======================================== + echo Building orisys wheel: %%E + echo ======================================== + + set "OK=0" + if !HAS_CONDA! equ 1 ( + call conda run --no-capture-output -n %%E python build\build_nuitka.py + if not errorlevel 1 set "OK=1" + ) + + if !OK! equ 0 ( + if not defined CONDA_ENVS_ROOT ( + echo ERROR: Could not find conda env %%E. Set CONDA_ENVS_ROOT or add conda to PATH. + ) else ( + set "PYEXE=!CONDA_ENVS_ROOT!\%%E\python.exe" + if not exist "!PYEXE!" ( + echo ERROR: Python not found: !PYEXE! + ) else ( + "!PYEXE!" build\build_nuitka.py + if not errorlevel 1 set "OK=1" + ) + ) + ) + + if !OK! equ 0 set FAILED=1 +) + +echo. +if !FAILED! equ 1 ( + echo One or more wheel builds failed. + exit /b 1 +) + +echo SUCCESS: all wheels built under SDK\ +for /f "delims=" %%V in ('dir /b /ad SDK 2^>nul') do ( + for %%W in ("SDK\%%V\orisys-*-cp310-*.whl" "SDK\%%V\orisys-*-cp311-*.whl" "SDK\%%V\orisys-*-cp312-*.whl") do ( + if exist %%W echo %%~nxW + ) +) + +endlocal +exit /b 0 diff --git a/build/nuitka_compile.py b/build/nuitka_compile.py new file mode 100644 index 0000000..cd87e0d --- /dev/null +++ b/build/nuitka_compile.py @@ -0,0 +1,87 @@ +"""Nuitka compilation of the orisys package.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from importlib.machinery import EXTENSION_SUFFIXES +from pathlib import Path + +from paths import repo_path + +NUITKA_INCLUDE_PACKAGES = ( + "orisys", + "orisys.finger", + "orisys.unroll", + "orisys.hhd", + "orisys.hhd.solvers", +) + +NUITKA_NOFOLLOW_IMPORTS = ( + "cupy", + "cupyx", + "pyopencl", + "open3d", +) + + +def install_nuitka() -> None: + print("Checking Nuitka installation...") + try: + import nuitka # noqa: F401 + print("Nuitka is installed.") + except ImportError: + print("Installing Nuitka...") + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "nuitka", "ordered-set", "zstandard"] + ) + + +def find_compiled_extension(output_dir: Path, module_name: str) -> Path: + candidates = [] + for suffix in EXTENSION_SUFFIXES: + candidates.extend(output_dir.glob(f"{module_name}*{suffix}")) + if not candidates: + all_files = ", ".join(p.name for p in output_dir.iterdir()) if output_dir.exists() else "(none)" + raise RuntimeError( + f"Nuitka did not produce a compiled extension for {module_name}. Found: {all_files}" + ) + return sorted(candidates, key=lambda p: len(p.name))[0] + + +def compile_orisys(build_root: Path, env: dict[str, str]) -> Path: + module_output_dir = build_root / "orisys" + if module_output_dir.exists(): + shutil.rmtree(module_output_dir) + module_output_dir.mkdir(parents=True) + + cmd = [ + sys.executable, + "-m", + "nuitka", + "--module", + "--output-dir=" + str(module_output_dir), + "--remove-output", + "--no-pyi-file", + repo_path("src/orisys").as_posix(), + ] + for package_name in NUITKA_INCLUDE_PACKAGES: + cmd.append(f"--include-package={package_name}") + for import_name in NUITKA_NOFOLLOW_IMPORTS: + cmd.append(f"--nofollow-import-to={import_name}") + + print(f"Running: {' '.join(cmd)}") + subprocess.check_call(cmd, env=env) + + ext_file = find_compiled_extension(module_output_dir, "orisys") + print(f"Generated compiled extension for orisys: {ext_file}") + return ext_file + + +def copy_compiled_orisys(pkg_dir: Path, ext_file: Path) -> str: + ext_suffix = "".join(ext_file.suffixes) + dest_ext = pkg_dir / f"__init__{ext_suffix}" + shutil.copy(ext_file, dest_ext) + print(f"Copied compiled module orisys to {dest_ext}") + return ext_suffix diff --git a/build/paths.py b/build/paths.py new file mode 100644 index 0000000..817ac98 --- /dev/null +++ b/build/paths.py @@ -0,0 +1,20 @@ +"""Repository and Nuitka build output paths.""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +NUITKA_COMPILE_DIR = REPO_ROOT / "build" / "nuitka" / "compile" +NUITKA_WHEEL_DIR = REPO_ROOT / "build" / "nuitka" / "wheel" +NUITKA_GUI_DIR = REPO_ROOT / "build" / "nuitka" / "gui" +GUI_DIST_DIR = REPO_ROOT / "dist" / "OrisysDashboard" + + +def repo_path(*parts: str) -> Path: + return REPO_ROOT.joinpath(*parts) + + +def sdk_dir(version: str) -> Path: + return REPO_ROOT / "SDK" / version diff --git a/build/pyproject_utils.py b/build/pyproject_utils.py new file mode 100644 index 0000000..dc1f73f --- /dev/null +++ b/build/pyproject_utils.py @@ -0,0 +1,100 @@ +"""Read project metadata from pyproject.toml for packaging scripts.""" + +from __future__ import annotations + +import importlib +import subprocess +import sys +from pathlib import Path + + +def _load_toml(text: str) -> dict: + if sys.version_info >= (3, 11): + import tomllib + + return tomllib.loads(text) + try: + import tomli + except ImportError: + subprocess.check_call([sys.executable, "-m", "pip", "install", "tomli", "-q"]) + import tomli + return tomli.loads(text) + + +def load_pyproject(repo_root: Path) -> dict: + path = repo_root / "pyproject.toml" + return _load_toml(path.read_text(encoding="utf-8")) + + +def project_metadata(repo_root: Path) -> dict: + return load_pyproject(repo_root)["project"] + + +def install_requires(repo_root: Path) -> list[str]: + return list(project_metadata(repo_root)["dependencies"]) + + +def extras_require(repo_root: Path) -> dict[str, list[str]]: + optional = project_metadata(repo_root).get("optional-dependencies", {}) + return {name: list(requirements) for name, requirements in optional.items()} + + +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) + + +def get_version(repo_root: Path) -> str: + """Return project version from pyproject.toml (static or dynamic).""" + project = project_metadata(repo_root) + if "version" in project: + return str(project["version"]) + + dynamic = load_pyproject(repo_root).get("tool", {}).get("setuptools", {}).get("dynamic", {}) + version_cfg = dynamic.get("version") + if isinstance(version_cfg, dict) and "attr" in version_cfg: + return _resolve_version_attr(repo_root, version_cfg["attr"]) + + raise RuntimeError("Project version is not defined in pyproject.toml") + + +def bundle_items(repo_root: Path) -> list[dict]: + """Return bundle rules from [[tool.orisys.bundle.items]] in pyproject.toml.""" + items = load_pyproject(repo_root).get("tool", {}).get("orisys", {}).get("bundle", {}).get("items") + if not items: + raise RuntimeError("Missing [[tool.orisys.bundle.items]] in pyproject.toml") + return list(items) + + +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 + + grouped: dict[str, list[str]] = {} + for item in bundle_items(repo_root): + if not item.get("dev_install", False): + continue + + src_dir = repo_root / item["source"] + base_dest = f"orisys/{item['target']}".replace("\\", "/") + + for src_file in resolve_bundle_source_files(repo_root, item): + rel_src = src_file.relative_to(repo_root).as_posix() + if item.get("preserve_tree", False): + rel_parent = src_file.relative_to(src_dir).parent.as_posix() + dest = base_dest if rel_parent == "." else f"{base_dest}/{rel_parent}" + else: + dest = base_dest + grouped.setdefault(dest, []).append(rel_src) + + return [(dest, sorted(files)) for dest, files in sorted(grouped.items())] diff --git a/build/verify.py b/build/verify.py new file mode 100644 index 0000000..b4aa4ca --- /dev/null +++ b/build/verify.py @@ -0,0 +1,82 @@ +"""Post-build smoke tests for packaged wheels and standalone GUI builds.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tempfile +import venv +from pathlib import Path + +from paths import GUI_DIST_DIR + + +def _venv_python(venv_dir: Path) -> Path: + if sys.platform == "win32": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +def verify_built_wheel(wheel_path: Path) -> None: + """Install the wheel in a temporary venv and smoke-test finger imports.""" + wheel_path = wheel_path.resolve() + with tempfile.TemporaryDirectory(prefix="orisys-wheel-verify-") as tmp: + venv_dir = Path(tmp) / "venv" + venv.create(venv_dir, with_pip=True) + python = _venv_python(venv_dir) + + subprocess.check_call( + [python, "-m", "pip", "install", "-q", str(wheel_path)], + ) + subprocess.check_call( + [ + python, + "-c", + ( + "from orisys.finger import UVMeshViewer, build_uv_surface_mesh, FingerRuntime; " + "from orisys.finger.mesh import UVSurfaceMesh; " + "from orisys.finger.viewer import init_uv_mesh_viewer; " + "print('finger mesh/viewer imports OK')" + ), + ], + ) + + +def verify_gui_dist(dist_dir: Path | None = None) -> None: + """Smoke-test a standalone GUI folder produced by build_gui_win.py.""" + dist_dir = (dist_dir or GUI_DIST_DIR).resolve() + exe = dist_dir / "OrisysDashboard.exe" + if not exe.is_file(): + raise FileNotFoundError(f"GUI executable not found: {exe}") + + required = [ + dist_dir / "config" / "dd02-ov.json", + dist_dir / "assets" / "finger" / "visualization" / "world_to_cam.npy", + dist_dir / "assets" / "finger" / "30fps" / "mesh.npz", + ] + missing = [str(path.relative_to(dist_dir)) for path in required if not path.is_file()] + if missing: + raise FileNotFoundError(f"Missing bundled files in {dist_dir}: {', '.join(missing)}") + + subprocess.check_call([str(exe), "--help"], cwd=dist_dir) + print(f"GUI dist smoke test OK: {exe}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Verify packaged Orisys artifacts.") + parser.add_argument("--gui-dist", action="store_true", help="Verify dist/OrisysDashboard/") + parser.add_argument("--dist-dir", type=Path, default=None, help="Override GUI dist folder") + parser.add_argument("wheel", nargs="?", type=Path, help="Wheel path for import smoke test") + args = parser.parse_args() + + if args.gui_dist: + verify_gui_dist(args.dist_dir) + return + if args.wheel is None: + parser.error("Provide a wheel path or pass --gui-dist") + verify_built_wheel(args.wheel) + + +if __name__ == "__main__": + main() diff --git a/build/wheel_build.py b/build/wheel_build.py new file mode 100644 index 0000000..e23edaa --- /dev/null +++ b/build/wheel_build.py @@ -0,0 +1,62 @@ +"""Assemble and build the Nuitka wheel with setuptools.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +from paths import sdk_dir + + +def write_wheel_setup( + wheel_dir: Path, + *, + version: str, + package_data: dict[str, list[str]], + install_requires: list[str], + extras_require: dict[str, list[str]], +) -> Path: + setup_py_path = wheel_dir / "setup.py" + setup_content = f""" +from setuptools import setup, Distribution + +class BinaryDistribution(Distribution): + def has_ext_modules(self): + return True + +setup( + name="orisys", + version="{version}", + description="Orisys - Optical Tactile Sensor SDK (Nuitka Compiled)", + author="Orisys", + packages=['orisys', 'orisys.examples'], + package_dir={{'': 'wheel_root'}}, + package_data={package_data}, + include_package_data=True, + install_requires={install_requires!r}, + extras_require={extras_require!r}, + distclass=BinaryDistribution, + zip_safe=False, +) +""" + setup_py_path.write_text(setup_content, encoding="utf-8") + return setup_py_path + + +def run_bdist_wheel(wheel_dir: Path) -> Path | None: + print("\n=== Building Wheel ===") + subprocess.check_call([sys.executable, "setup.py", "bdist_wheel"], cwd=wheel_dir) + wheels = sorted((wheel_dir / "dist").glob("*.whl")) + return wheels[0] if wheels else None + + +def publish_wheel_to_sdk(wheel_path: Path, version: str) -> Path: + sdk_version_dir = sdk_dir(version) + sdk_version_dir.mkdir(parents=True, exist_ok=True) + dest_wheel = sdk_version_dir / wheel_path.name + if dest_wheel.exists(): + dest_wheel.unlink() + shutil.move(str(wheel_path), str(dest_wheel)) + return dest_wheel diff --git a/build/wheel_layout.py b/build/wheel_layout.py new file mode 100644 index 0000000..00f2ec9 --- /dev/null +++ b/build/wheel_layout.py @@ -0,0 +1,91 @@ +"""Copy bundled non-code files into the Nuitka wheel tree.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + + +def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]: + """Resolve explicit files and/or glob patterns for one bundle item.""" + src_dir = repo_root / item["source"] + patterns = item.get("patterns", []) + files = item.get("files", []) + if not patterns and not files: + raise ValueError(f"Bundle item {item['name']!r} requires patterns and/or files") + + seen: set[Path] = set() + resolved: list[Path] = [] + + def add(path: Path) -> None: + key = path.resolve() + if key not in seen: + seen.add(key) + resolved.append(path) + + for pattern in patterns: + for path in sorted(src_dir.glob(pattern)): + if path.is_file(): + add(path) + + for name in files: + path = src_dir / name + if not path.is_file(): + raise FileNotFoundError(f"Bundle file not found for {item['name']}: {path}") + add(path) + + return resolved + + +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("\\", "/") + entries = [f"{target}/{pattern}" for pattern in item.get("patterns", [])] + entries.extend(f"{target}/{name}" for name in item.get("files", [])) + return entries + + +def data_file_entries_for_item(item: dict) -> list[str]: + """Return setuptools data_files paths relative to the repo root.""" + source = str(item["source"]).replace("\\", "/") + entries = [f"{source}/{pattern}" for pattern in item.get("patterns", [])] + entries.extend(f"{source}/{name}" for name in item.get("files", [])) + return entries + + +def copy_bundle_items(repo_root: Path, pkg_dir: Path, items: list[dict]) -> dict[str, int]: + copied_counts: dict[str, int] = {} + for item in items: + name = item["name"] + src_dir = repo_root / item["source"] + dst_dir = pkg_dir / item["target"] + dst_dir.mkdir(parents=True, exist_ok=True) + + copied = 0 + for src_file in resolve_bundle_source_files(repo_root, item): + if item.get("preserve_tree", False): + dst_file = dst_dir / src_file.relative_to(src_dir) + dst_file.parent.mkdir(parents=True, exist_ok=True) + else: + dst_file = dst_dir / src_file.name + shutil.copy(src_file, dst_file) + copied += 1 + print(f"Copied {name}: {src_file}") + + if name == "examples": + (dst_dir / "__init__.py").write_text("# Examples for orisys package\n", encoding="utf-8") + + copied_counts[name] = copied + return copied_counts + + +def package_data_from_bundle(ext_suffix: str, items: list[dict]) -> dict[str, list[str]]: + package_data: dict[str, list[str]] = { + "orisys": [f"*{ext_suffix}"], + "orisys.examples": ["*.py", "README.md", "app_paths.py", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"], + } + for item in items: + if item["name"] == "examples": + continue + package_data["orisys"].extend(package_data_paths_for_item(item)) + return package_data diff --git a/examples/gui/dashboard.py b/examples/gui/dashboard.py index c5b1ec4..e8e94f4 100644 --- a/examples/gui/dashboard.py +++ b/examples/gui/dashboard.py @@ -102,12 +102,16 @@ class FingerDashboardWindow(QMainWindow): self.settings_panel.set_video_source(str(self.config.video)) self.settings_panel.set_config_path(self.config.config) self.settings_panel.set_calibration_path(self.config.calib or "") + self.settings_panel.set_finger_export_dir(self.config.export_dir) self.settings_panel.log_visibility_changed.connect(self._set_log_panel_visible) self.settings_panel.free_rotation_changed.connect(self.mesh_viewport.set_free_rotation) + if self.arrow_panel is not None: + self.settings_panel.arrow_background_changed.connect(self.arrow_panel.set_background) self.settings_panel.view_reset_requested.connect(self.mesh_viewport.reset_view) self.settings_panel.browse_video_requested.connect(self._select_camera) self.settings_panel.browse_config_requested.connect(self._select_config) + self.settings_panel.browse_finger_assets_requested.connect(self._select_finger_assets) self.settings_panel.btn_play_pause.clicked.connect(self._on_play_pause_clicked) self.settings_panel.btn_reset_tracker.clicked.connect(self._on_reset) self.settings_panel.set_play_pause_state("run") @@ -212,12 +216,14 @@ class FingerDashboardWindow(QMainWindow): if self._running: return self._set_play_pause_button("starting") + self.settings_panel.set_finger_export_dir_enabled(False) QApplication.processEvents() self.config.video = self.settings_panel.video_source() self.config.config = self.settings_panel.config_path() cal = self.settings_panel.calibration_path() self.config.calib = cal or None + self.config.export_dir = self.settings_panel.finger_export_dir() try: self.pipeline = FingerPipeline(self.config) @@ -228,6 +234,7 @@ class FingerDashboardWindow(QMainWindow): except Exception as exc: self.mesh_viewport.close_renderer() self._set_play_pause_button("run") + self.settings_panel.set_finger_export_dir_enabled(True) print(f"启动失败: {exc}") return @@ -239,7 +246,7 @@ class FingerDashboardWindow(QMainWindow): self.settings_panel.btn_reset_tracker.setEnabled(True) print( f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, " - f"标定={self.config.calib or '(none)'}" + f"标定={self.config.calib or '(none)'}, 资产={self.config.export_dir}" ) def _on_stop(self) -> None: @@ -256,6 +263,7 @@ class FingerDashboardWindow(QMainWindow): self._set_play_pause_button("run") self.settings_panel.btn_reset_tracker.setEnabled(False) + self.settings_panel.set_finger_export_dir_enabled(True) def _on_reset(self) -> None: if not self._running: @@ -353,6 +361,15 @@ class FingerDashboardWindow(QMainWindow): if path: self.settings_panel.set_config_path(path) + def _select_finger_assets(self) -> None: + path = QFileDialog.getExistingDirectory( + self, + "选择 finger 资产目录", + self.settings_panel.finger_export_dir() or "assets/finger", + ) + if path: + self.settings_panel.set_finger_export_dir(path) + def keyPressEvent(self, event): key = event.key() if key == Qt.Key_Escape: diff --git a/examples/gui/layout.py b/examples/gui/layout.py index 32e0eac..7218a57 100644 --- a/examples/gui/layout.py +++ b/examples/gui/layout.py @@ -85,7 +85,7 @@ class PanelSetup: TOP_ROW = PanelRow( slots=( PanelSlot("mesh", "3D mesh", width_px=600, height_px=600), - PanelSlot("arrows", "UV Unrolled", width_px=500, height_px=500, enabled=False), + PanelSlot("arrows", "UV Unrolled", width_px=500, height_px=500, enabled=True), PanelSlot("charts", "Force charts", width_px=400, height_px=500), PanelSlot("settings", "Settings", width_px=200, height_px=0), ), @@ -223,7 +223,7 @@ class DashboardLayout: panel_setup: PanelSetup = PANEL_SETUP window_title: str = "Orisys Finger Dashboard" - window_width: int = 1100 + window_width: int = 1600 window_height: int = 620 root_margin_px: int = 6 @@ -241,8 +241,8 @@ class DashboardLayout: # UV arrow panel: min flow magnitude to draw an arrow (display only; not dd02-ov.json contact.flow_threshold) arrow_flow_threshold: float = 1.0 - # "unrolled" = sensor texture; "white" = solid canvas (tune colors via arrow_canvas_colors) - arrow_background: ArrowBackground = "white" + # "unrolled" = sensor texture; "pure_color" = solid canvas (tune colors via arrow_canvas_colors) + arrow_background: ArrowBackground = "unrolled" arrow_canvas_colors: ArrowCanvasColors = ARROW_CANVAS_COLORS mesh_lighting: MeshLighting = MESH_LIGHTING diff --git a/examples/gui/widgets/arrow_image.py b/examples/gui/widgets/arrow_image.py index c48aec2..6885b31 100644 --- a/examples/gui/widgets/arrow_image.py +++ b/examples/gui/widgets/arrow_image.py @@ -38,6 +38,9 @@ class ArrowPanelWidget(ImagePanel): def set_flow_threshold(self, threshold: float) -> None: self.flow_threshold = threshold + def set_background(self, background: ArrowBackground) -> None: + self.background = background + def update_frame(self, frame: FingerFrameView) -> None: arrows = render_uv_arrows( frame, diff --git a/examples/gui/widgets/settings_panel.py b/examples/gui/widgets/settings_panel.py index 6e920ab..22fb18f 100644 --- a/examples/gui/widgets/settings_panel.py +++ b/examples/gui/widgets/settings_panel.py @@ -5,6 +5,7 @@ from __future__ import annotations from PyQt5.QtCore import QSize, Qt, pyqtSignal from PyQt5.QtWidgets import ( QCheckBox, + QComboBox, QHBoxLayout, QLabel, QLineEdit, @@ -14,6 +15,9 @@ from PyQt5.QtWidgets import ( QWidget, ) +from orisys.finger import DEFAULT_FINGER_EXPORT_DIR +from viz.render_2d import ArrowBackground + from ..layout import DashboardLayout, LAYOUT from ..mesh_view_state import MeshViewState from ..transport_icons import reset_icon, transport_icon @@ -32,9 +36,11 @@ class SettingsPanelWidget(QWidget): log_visibility_changed = pyqtSignal(bool) free_rotation_changed = pyqtSignal(bool) + arrow_background_changed = pyqtSignal(str) view_reset_requested = pyqtSignal() browse_video_requested = pyqtSignal() browse_config_requested = pyqtSignal() + browse_finger_assets_requested = pyqtSignal() def __init__( self, @@ -83,6 +89,17 @@ class SettingsPanelWidget(QWidget): self.show_flow_arrows_check.toggled.connect(self._mesh_state.set_flow_arrows_enabled) root.addWidget(self.show_flow_arrows_check) + root.addWidget(self._caption("UV arrow background")) + self.arrow_background_combo = QComboBox() + for label, value in ( + ("Unrolled", "unrolled"), + ("Pure color", "pure_color"), + ): + self.arrow_background_combo.addItem(label, value) + self.set_arrow_background(layout.arrow_background) + self.arrow_background_combo.currentIndexChanged.connect(self._emit_arrow_background_changed) + root.addWidget(self.arrow_background_combo) + root.addWidget(self._caption("Video source")) video_row = QHBoxLayout() video_row.setSpacing(6) @@ -113,6 +130,19 @@ class SettingsPanelWidget(QWidget): root.addWidget(self._caption("Calibration")) root.addWidget(self.cal_edit) + root.addWidget(self._caption("Finger assets")) + finger_assets_row = QHBoxLayout() + finger_assets_row.setSpacing(6) + self.finger_assets_edit = QLineEdit(DEFAULT_FINGER_EXPORT_DIR) + self.finger_assets_edit.setPlaceholderText("Blender export bundle directory") + finger_assets_row.addWidget(self.finger_assets_edit, 1) + self.btn_browse_finger_assets = QPushButton("…") + self.btn_browse_finger_assets.setFixedWidth(32) + self.btn_browse_finger_assets.setToolTip("Browse finger asset directory") + self.btn_browse_finger_assets.clicked.connect(self.browse_finger_assets_requested.emit) + finger_assets_row.addWidget(self.btn_browse_finger_assets) + root.addLayout(finger_assets_row) + root.addSpacing(8) rotate_row = QHBoxLayout() rotate_row.setSpacing(6) @@ -174,6 +204,30 @@ class SettingsPanelWidget(QWidget): def set_calibration_path(self, value: str) -> None: self.cal_edit.setText(value or "") + def set_finger_export_dir(self, value: str) -> None: + self.finger_assets_edit.setText(value) + + def set_finger_export_dir_enabled(self, enabled: bool) -> None: + self.finger_assets_edit.setEnabled(enabled) + self.btn_browse_finger_assets.setEnabled(enabled) + + def _emit_arrow_background_changed(self, _index: int) -> None: + value = self.arrow_background_combo.currentData() + if value: + self.arrow_background_changed.emit(value) + + def set_arrow_background(self, value: ArrowBackground) -> None: + idx = self.arrow_background_combo.findData(value) + if idx < 0: + return + self.arrow_background_combo.blockSignals(True) + self.arrow_background_combo.setCurrentIndex(idx) + self.arrow_background_combo.blockSignals(False) + + def arrow_background(self) -> ArrowBackground: + value = self.arrow_background_combo.currentData() + return value if value else "unrolled" + def video_source(self) -> str: return self.video_edit.text().strip() @@ -182,3 +236,6 @@ class SettingsPanelWidget(QWidget): def calibration_path(self) -> str: return self.cal_edit.text().strip() + + def finger_export_dir(self) -> str: + return self.finger_assets_edit.text().strip() diff --git a/examples/pipeline/config.py b/examples/pipeline/config.py index c6abf08..a44baf5 100644 --- a/examples/pipeline/config.py +++ b/examples/pipeline/config.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass +from orisys.finger import DEFAULT_FINGER_EXPORT_DIR from viz.mesh_camera import DEFAULT_FINGER_VIZ_POSE @@ -18,6 +19,7 @@ class PipelineConfig: video: str = "0" config: str = "./config/dd02-ov.json" calib: str | None = None + export_dir: str = DEFAULT_FINGER_EXPORT_DIR verbose: bool = False mesh_map: str = "depth" threshold: float | None = None diff --git a/examples/pipeline/service.py b/examples/pipeline/service.py index 18dcc7a..2da397d 100644 --- a/examples/pipeline/service.py +++ b/examples/pipeline/service.py @@ -55,6 +55,7 @@ class FingerPipeline: if self.runtime is not None: return self.runtime = FingerRuntime.open( + export_dir=self.config.export_dir, config=self.config.config, calib=self.config.calib, video=self.config.video, diff --git a/examples/viz/render_2d.py b/examples/viz/render_2d.py index 7bbc8b5..9d43926 100644 --- a/examples/viz/render_2d.py +++ b/examples/viz/render_2d.py @@ -12,12 +12,12 @@ import orisys if TYPE_CHECKING: from pipeline.frame import FingerFrameView -ArrowBackground = Literal["unrolled", "white"] +ArrowBackground = Literal["unrolled", "pure_color"] @dataclass(frozen=True) class ArrowCanvasColors: - """BGR fill colors for synthetic UV arrow background (``background='white'``).""" + """BGR fill colors for synthetic UV arrow background (``background='pure_color'``).""" outside_bgr: tuple[int, int, int] = (0, 0, 0) uv_bgr: tuple[int, int, int] = (255, 255, 255) @@ -67,6 +67,8 @@ def _arrow_background( """Return the BGR canvas for UV arrow drawing.""" if background == "unrolled": return frame.unrolled + if background != "pure_color": + raise ValueError(f"unknown arrow background: {background!r}") h, w = frame.unrolled.shape[:2] outside = np.array(_as_bgr(canvas_colors.outside_bgr), dtype=np.uint8) uv = np.array(_as_bgr(canvas_colors.uv_bgr), dtype=np.uint8) diff --git a/tools/3d_patch_generation_blender/dd02c_0607.blend b/tools/3d_patch_generation_blender/dd02c_0607.blend index f815456..6abfed0 100644 Binary files a/tools/3d_patch_generation_blender/dd02c_0607.blend and b/tools/3d_patch_generation_blender/dd02c_0607.blend differ diff --git a/tools/3d_patch_generation_blender/dd02c_0607.blend1 b/tools/3d_patch_generation_blender/dd02c_0607.blend1 index d1dd140..d140d5a 100644 Binary files a/tools/3d_patch_generation_blender/dd02c_0607.blend1 and b/tools/3d_patch_generation_blender/dd02c_0607.blend1 differ