official 0.1.0 version

This commit is contained in:
ykcheungab
2026-09-16 09:43:57 +08:00
parent c538dbf074
commit 07d7c3bad6
7 changed files with 762 additions and 92 deletions
+1
View File
@@ -5,6 +5,7 @@ __pycache__/
.eggs/ .eggs/
dist/ dist/
build/_pyinstaller/ build/_pyinstaller/
build/_smoke/
*.egg *.egg
.venv/ .venv/
.venv-build/ .venv-build/
+129 -28
View File
@@ -1,40 +1,79 @@
# Build # Build
Windows packaging for the studio desktop app. Windows packaging for the studio desktop app: **one provisioning step per machine, then
repeatable builds.**
## Layout ## Layout
```text ```text
build/ build/
studio/ studio/
setup.bat / setup.ps1 # provision the build venv (run once per machine)
constraints-build.txt # pinned build-time versions
build.bat / build.ps1 # build the EXE and verify it
entry.py # frozen entry (no source chdir) entry.py # frozen entry (no source chdir)
orisys-studio.spec # PyInstaller onedir spec orisys-studio.spec # PyInstaller onedir spec
build.ps1 # create/reuse .venv-build and build
_pyinstaller/ # PyInstaller workpath (gitignored) _pyinstaller/ # PyInstaller workpath (gitignored)
_smoke/ # log of the last build's start-up smoke test
dist/ dist/
OrisysStudio/ OrisysStudio/
OrisysStudio.exe OrisysStudio.exe
config/ # lean locales + sensors (no finger visualization/) config/ # lean locales + sensors (no finger visualization/)
_internal/ # Python, PyQt5, opencv-headless, orisys, … _internal/ # Python, PyQt5, opencv-headless, orisys, OpenGL, …
``` ```
## Prerequisites ## Why there are two steps
- Windows + Python 3.10+ on PATH A PyInstaller bundle depends on the *machine* as much as on the sources: which interpreter,
- `orisys` installable into the build venv (`pip install orisys>=0.5`) whether it is Anaconda (whose `libexpat`/`libffi`/OpenSSL DLLs live outside the venv and are
- Do **not** install `orisys[gui]` / Open3D for this build therefore **not** bundled), and where the `orisys` SDK checkout is. Those facts are decided
once, by `setup.ps1`, and recorded in `.venv-build\orisys-build-env.json`.
`build.ps1` then installs nothing and guesses nothing: it verifies the environment, builds,
and verifies the artefact. That split is what keeps a build reproducible instead of
"works on my machine".
## First time on a machine
```bat
cd build\studio
setup.bat
setup.bat -Python C:\Python310\python.exe
setup.bat -AllowConda
```
`setup.bat` finds an interpreter, creates or repairs `.venv-build`, installs the pinned
toolchain plus the app's extras (including the studio's `three-d` extra — without it the 3D
pane silently reports "PyOpenGL is not installed" on the customer's machine), writes the
stamp, and then verifies the result by importing what the frozen app imports.
Interpreter resolution, in order: `-Python <path>`; `py -3.10`; `python`; the interpreter
already behind `.venv-build`. The Microsoft Store `python.exe` alias is refused (it is not an
interpreter), and an Anaconda interpreter is refused unless `-AllowConda` is passed — in that
case its `Library\bin` is recorded and the spec adds the runtime DLLs, because PyInstaller
does not search there and the EXE would otherwise die at start-up with
`ImportError: DLL load failed while importing pyexpat`. Building with python.org CPython is
still the preferred answer.
The SDK is installed from `..\..\..\orisys-sdk` when that checkout exists (`-SdkPath` to point
elsewhere, `-FromPypi` to install the published wheel instead). Installing the published SDK
is how a build once shipped with no `orisys.finger` at all.
Re-running `setup.bat` is safe: with a valid stamp for this checkout it reports and exits;
without one it repairs the existing venv rather than deleting it. `-Force` recreates it.
## Build ## Build
From `build\studio` (recommended):
```bat ```bat
build.bat build.bat
build.bat -Clean build.bat -Clean # after dependency/spec changes
build.bat -SkipInstall build.bat -RebuildVenv # setup.bat -Force, then build
build.bat -SkipInstall # never provision: fail instead
build.bat -SkipSmokeTest # do not launch the produced EXE
build.bat -StrictSmokeTest # treat a 3D-context warning as a failure
``` ```
Or from repo root: Or from the repo root:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1 powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1
@@ -42,35 +81,97 @@ powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1
`build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags. `build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags.
Flags: ## What the build verifies, and why
| Flag | Meaning | Every silent failure this packaging has produced looked like a *successful* build, so the
|------|---------| script checks the things that only show up when the EXE runs:
| `-Clean` | Pass `--clean` to PyInstaller (use after dep/spec changes) |
| `-SkipInstall` | Reuse packages already in `.venv-build` (faster iteration) |
Output: `dist\OrisysStudio\OrisysStudio.exe` | check | failure it prevents |
|---|---|
| the venv imports `orisys_app_shared`, `orisys_studio`, `orisys`, `OpenGL`, `cv2`, `PyInstaller` | a stale editable install still builds (the spec's `pathex` finds the modules) and produces an EXE that cannot start |
| `collect_data_files('orisys_app_shared', includes=['theme/*.json'])` is non-empty | the theme colours are package data; without them the app dies in `build_qss()` before logging anything |
| every `config\sensors\**\*.oryscene` is present, and the finger manifest/`sensor.json`/unroll map survive the config strip | the 3D pane refuses the scene when the device description cannot be derived |
| no `open3d*` under `dist` | the offline tool leaking into the shipped bundle |
| **smoke test**: start the produced EXE, wait for its own log to say `Studio shell started` (and `3D scene loaded` when a finger scene shipped), then stop it | a bundle that cannot start at all — missing runtime DLLs, an excluded module, missing data. The log is kept under `build\_smoke\` |
The smoke test seeds a `preference.json` beside the EXE to select the finger sensor with the
3D pane on, and restores (or removes) it afterwards. A *GL context* failure is reported as a
warning, because it is environment-dependent; `-StrictSmokeTest` turns it into a failure.
## Where the app's own code lives in the bundle
`_internal\` contains **native code and data files only**. Every pure-Python module —
`orisys_studio.*`, `orisys_app_shared.*`, `orisys.*` — is compiled into the `PYZ` archive that
sits *inside* `OrisysStudio.exe` (that is most of the EXE's ~13 MB). So there is deliberately
no `_internal\orisys_studio\` folder, and no `.py` files anywhere: PyInstaller ships bytecode,
never sources.
`_internal` therefore shows only these orisys-related paths:
```text
_internal\orisys_app_shared\theme\colors_dark.json <- package data, bundled (qss.py reads it)
_internal\orisys_app_shared\theme\colors_light.json
_internal\orisys\.gitignore <- stray non-.py file of the SDK package
_internal\branding\... <- logos
```
(`collect_data_files("orisys")` exists for the case where the SDK is installed from a wheel that
bundles `sensors/` assets; with a local editable checkout the only non-`.py` file is that
`.gitignore`. It is harmless — the app prefers `config\sensors` — but it does look odd.)
To list what is really in the archive:
```bat
.venv-build\Scripts\python.exe -m PyInstaller.utils.cliutils.archive_viewer ^
-l -r -b dist\OrisysStudio\OrisysStudio.exe
```
(The `PYZ-00.toc` under `build\_pyinstaller\orisys-studio\` is the same information at build
time, and `build\_smoke\*.log` is the runtime proof: "Studio shell started" and "3D scene
loaded" can only be printed if those modules imported.)
## Dependency notes ## Dependency notes
- App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI). - App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI). `setup.ps1` uninstalls
- Upstream `orisys` may still declare `opencv-python`. `build.ps1` uninstalls that wheel and installs **`opencv-python-headless>=4.10,<5`** (5.x has shipped broken wheels without a `cv2` module). The script verifies `import cv2` before packaging. any `opencv-python` and pins `opencv-python-headless>=4.10,<5` (5.x has shipped wheels
- **Open3D is excluded** (spec + build env). Finger 3D remains a placeholder in the UI. without a `cv2` module) and verifies `import cv2`.
- **PyOpenGL** is the GL binding for the 3D pane and comes from the studio's `three-d` extra.
Add the `protected-assets` extra (`cryptography`) if sealed `.oryscene` packages are shipped.
- **Open3D stays excluded** (spec + build env): it is an offline geometry tool. The 3D pane is
Qt `QOpenGLWidget` + PyOpenGL.
- `constraints-build.txt` pins the toolchain. Move a pin only together with `setup.bat -Force`
and a passing `build.bat` — the smoke test is the acceptance evidence, not the file.
## GL note
The pane asks for the profile in `gl_profile` (`desktop33` by default, `gles30` otherwise). A
`desktop33` request needs a desktop GL 3.3 core context; on some sessions Qt falls back to its
ANGLE (GLES) backend, which cannot satisfy it, and the widget then reports no context on a
machine whose GPU is fine. `QT_OPENGL=desktop` is the workaround; the durable fix is to pin
the backend in `orisys_studio.main` before `QApplication` is constructed
(`Qt.AA_UseDesktopOpenGL` / `Qt.AA_UseOpenGLES`). `build.bat -StrictSmokeTest` makes this a
build failure.
## Size / startup ## Size / startup
- **onedir** (not onefile): faster cold start; ship the whole `OrisysStudio` folder. - **onedir** (not onefile): faster cold start; ship the whole `OrisysStudio` folder.
- Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved). - Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved). Nothing
in the app reads it (`manifest.json`'s `viz_pose` is used only by the SDK examples); the 3D
pane needs `scene/*.oryscene` plus `assets/30fps-0703`, and the build checks both.
- Studio shell defers OpenCV / acquisition imports until Preview or Run. - Studio shell defers OpenCV / acquisition imports until Preview or Run.
Expect hundreds of MB total (Qt + numpy/scipy/numba + OpenCV). Without Open3D this stays smaller than a full `orisys[gui]` bundle. Expect ~450 MB total (Qt + numpy/scipy/numba + OpenCV + PyOpenGL).
## Verification ## Verification on a new machine
1. Launch `OrisysStudio.exe` on the build machine (and ideally a clean PC without Python). 1. `setup.bat` (see its report: interpreter, conda or not, SDK source).
2. Theme / locale / prefs work; `preference.json` and `outputs\` appear next to the EXE. 2. `build.bat` — its smoke test must print "the application started" and "the packaged 3D
3. Camera preview + box/mini/finger Run (video) with image modes and force plots. scene loaded".
4. Confirm no `open3d*` files under `dist\OrisysStudio`. 3. Launch `OrisysStudio.exe` and check theme/locale/prefs; `preference.json` and `outputs\`
appear next to the EXE.
4. Camera preview + box/mini/finger Run (video) with image modes and force plots.
5. Open the 3D pane on a finger source: the surface draws, the legend shows the field.
6. Confirm no `open3d*` files under `dist\OrisysStudio`.
## Editable run (unchanged) ## Editable run (unchanged)
+235 -58
View File
@@ -1,90 +1,134 @@
#Requires -Version 5.1 #Requires -Version 5.1
<# <#
.SYNOPSIS .SYNOPSIS
Build Orisys Studio Windows onedir EXE with PyInstaller. Build the Orisys Studio Windows onedir EXE with PyInstaller.
.DESCRIPTION
This script installs nothing. Provisioning belongs to `setup.ps1`, which decides the
machine-specific facts once (interpreter, Anaconda or not, where the SDK checkout is) and
records them in `.venv-build\orisys-build-env.json`. Here we only:
1. make sure that provisioning happened (and run `setup.ps1` if it plainly has not),
2. verify the environment can import what the frozen app imports,
3. build,
4. verify the artefact — config, excludes, and a real start-up of the produced EXE.
Step 2 and step 4 exist because the interesting failures are silent ones: a stale venv
still builds (the spec's pathex finds the modules) yet loses the theme data; an excluded
module still builds yet makes `import orisys.finger` fail; a missing runtime DLL still
builds yet the EXE never writes a log. Each of those looked like a successful build.
.PARAMETER Clean .PARAMETER Clean
Pass --clean to PyInstaller (slower; use when deps/spec change). Pass --clean to PyInstaller (slower; use after dependency or spec changes).
.PARAMETER SkipInstall .PARAMETER SkipInstall
Skip pip install steps (reuse existing .venv-build packages). Do not run `setup.ps1`, even if the environment looks unprovisioned. Fails instead of
provisioning silently.
.PARAMETER RebuildVenv
Run `setup.ps1 -Force` first (recreate the venv from scratch), then build.
.PARAMETER SkipSmokeTest
Do not launch the produced EXE. The smoke test is the only step that proves the bundle
starts; skip it only where a desktop session is genuinely unavailable.
.PARAMETER StrictSmokeTest
Treat a 3D-context warning from the smoke test as a failure.
.PARAMETER GlProfile
GL profile the smoke test starts the app with (default: the shipped `desktop33`).
#> #>
param( param(
[switch]$Clean, [switch]$Clean,
[switch]$SkipInstall [switch]$SkipInstall,
[switch]$RebuildVenv,
[switch]$SkipSmokeTest,
[switch]$StrictSmokeTest,
[string]$GlProfile = "desktop33"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path $StudioDir = $PSScriptRoot
$RepoRoot = (Resolve-Path (Join-Path $StudioDir "..\..")).Path
$VenvDir = Join-Path $RepoRoot ".venv-build" $VenvDir = Join-Path $RepoRoot ".venv-build"
$Python = Join-Path $VenvDir "Scripts\python.exe" $Python = Join-Path $VenvDir "Scripts\python.exe"
$Spec = Join-Path $PSScriptRoot "orisys-studio.spec" $StampPath = Join-Path $VenvDir "orisys-build-env.json"
$Spec = Join-Path $StudioDir "orisys-studio.spec"
$Setup = Join-Path $StudioDir "setup.ps1"
$WorkPath = Join-Path $RepoRoot "build\_pyinstaller" $WorkPath = Join-Path $RepoRoot "build\_pyinstaller"
$DistPath = Join-Path $RepoRoot "dist" $DistPath = Join-Path $RepoRoot "dist"
$OutDir = Join-Path $DistPath "OrisysStudio"
$Exe = Join-Path $OutDir "OrisysStudio.exe"
$SmokeKeep = Join-Path $RepoRoot "build\_smoke"
function Assert-Ok([string]$what) {
if ($LASTEXITCODE -ne 0) { throw "$what failed (exit $LASTEXITCODE)" }
}
# PowerShell 5.1's `Set-Content -Encoding UTF8` writes a BOM, and a BOM makes Python's
# `json.loads` raise ("Unexpected UTF-8 BOM") — the app then silently falls back to its
# defaults, which is exactly the kind of quiet failure this script exists to avoid.
function Write-JsonFile([string]$path, [string]$json) {
[System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false)))
}
Write-Host "Repo: $RepoRoot" Write-Host "Repo: $RepoRoot"
if (-not (Test-Path $Python)) { # --------------------------------------------------------------- 1. provisioning
Write-Host "Creating build venv at $VenvDir" if ($RebuildVenv) {
python -m venv $VenvDir Write-Host "Recreating the build environment ..."
& $Setup -Force
Assert-Ok "setup.ps1 -Force"
} }
if (-not $SkipInstall) { $stampRoot = ""
Write-Host "Installing build dependencies (editable shared + studio + orisys + pyinstaller)…" if (Test-Path $StampPath) {
& $Python -m pip install --upgrade pip $stampRoot = (Get-Content $StampPath -Raw | ConvertFrom-Json).checkout_root
& $Python -m pip install "pyinstaller>=6.0"
& $Python -m pip install -e (Join-Path $RepoRoot "shared")
& $Python -m pip install -e (Join-Path $RepoRoot "apps\studio")
# orisys may already be installed; ensure present without [gui]/open3d
& $Python -m pip install "orisys>=0.5"
Write-Host "Forcing opencv-python-headless 4.x (orisys may have pulled opencv-python; 5.x can be broken)…"
& $Python -m pip uninstall -y opencv-python opencv-python-headless 2>$null
& $Python -m pip install "opencv-python-headless>=4.10.0.84,<5"
& $Python -c "import cv2; print('cv2 OK', cv2.__version__)"
if ($LASTEXITCODE -ne 0) {
Write-Error "cv2 import failed after installing opencv-python-headless"
exit $LASTEXITCODE
}
# Guard: Open3D must not be present in the build env
$hasOpen3d = & $Python -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)"
if ($hasOpen3d -eq "True") {
Write-Host "Uninstalling open3d from build venv…"
& $Python -m pip uninstall -y open3d
}
} }
$needsSetup = (-not (Test-Path $Python)) -or ($stampRoot -ne $RepoRoot)
if ($needsSetup) {
if ($SkipInstall) {
throw "the build environment is missing or was provisioned for another checkout. Run setup.bat (or drop -SkipInstall)."
}
Write-Host "No usable build environment for this checkout: running setup.ps1 ..."
& $Setup
Assert-Ok "setup.ps1"
}
if (-not (Test-Path $Spec)) { throw "spec not found: $Spec" }
# ---------------------------------------------------- 2. environment verification
# Cheap (about a second) and it catches the two provisioning mistakes that build silently:
# an editable install pointing at another checkout, and a missing GL binding.
Write-Host "Verifying the build environment ..."
& $Python -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PyInstaller; print('imports OK')"
Assert-Ok "the build venv cannot import what the app imports"
& $Python -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')"
Assert-Ok "theme data would not be bundled"
# ------------------------------------------------------------------ 3. PyInstaller
$pyiArgs = @( $pyiArgs = @(
"--noconfirm", "--noconfirm",
"--distpath", $DistPath, "--distpath", $DistPath,
"--workpath", $WorkPath, "--workpath", $WorkPath,
$Spec $Spec
) )
if ($Clean) { if ($Clean) { $pyiArgs = @("--clean") + $pyiArgs }
$pyiArgs = @("--clean") + $pyiArgs
}
Write-Host "Running PyInstaller…" Write-Host "Running PyInstaller ..."
& $Python -m PyInstaller @pyiArgs & $Python -m PyInstaller @pyiArgs
if ($LASTEXITCODE -ne 0) { Assert-Ok "PyInstaller"
Write-Error "PyInstaller failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
$OutDir = Join-Path $DistPath "OrisysStudio"
Write-Host "" Write-Host ""
Write-Host "Build complete: $OutDir" Write-Host "Build complete: $OutDir"
# Lean config beside EXE (exclude finger visualization assets) # -------------------------------------------------------------- 4. shipped config
# Lean config beside the EXE (the finger visualization bundle is a development artefact).
$ConfigSrc = Join-Path $RepoRoot "apps\studio\config" $ConfigSrc = Join-Path $RepoRoot "apps\studio\config"
$ConfigDst = Join-Path $OutDir "config" $ConfigDst = Join-Path $OutDir "config"
if (Test-Path $ConfigSrc) { if (Test-Path $ConfigSrc) {
Write-Host "Copying lean config to $ConfigDst …" Write-Host "Copying lean config to $ConfigDst ..."
if (Test-Path $ConfigDst) { if (Test-Path $ConfigDst) { Remove-Item $ConfigDst -Recurse -Force }
Remove-Item $ConfigDst -Recurse -Force
}
New-Item -ItemType Directory -Path $ConfigDst -Force | Out-Null New-Item -ItemType Directory -Path $ConfigDst -Force | Out-Null
Get-ChildItem $ConfigSrc -Force | ForEach-Object { Get-ChildItem $ConfigSrc -Force | ForEach-Object {
Copy-Item $_.FullName -Destination $ConfigDst -Recurse -Force Copy-Item $_.FullName -Destination $ConfigDst -Recurse -Force
@@ -96,21 +140,154 @@ if (Test-Path $ConfigSrc) {
} }
} }
Write-Host "Launch: $(Join-Path $OutDir 'OrisysStudio.exe')" # The 3D pane refuses the whole scene when the device description cannot be derived, and that
# description needs the sensor manifest, the unroll map and the compiled package. A strip
# step that removed one of them would leave a pane that explains itself instead of drawing.
Write-Host "Checking the shipped 3D prerequisites ..."
$scenePackages = @(Get-ChildItem (Join-Path $ConfigDst "sensors") -Recurse -Filter "*.oryscene" -ErrorAction SilentlyContinue)
if ($scenePackages.Count -eq 0) {
Write-Warning "No *.oryscene package under config\sensors: the 3D pane will report 'no compiled scene package'."
} else {
foreach ($pkg in $scenePackages) { Write-Host " scene: $($pkg.FullName.Substring($ConfigDst.Length + 1))" }
}
$fingerCfg = Join-Path $ConfigDst "sensors\finger"
if (Test-Path $fingerCfg) {
$required = @("manifest.json", "sensor.json")
foreach ($name in $required) {
if (-not (Test-Path (Join-Path $fingerCfg $name))) { throw "the shipped finger config is missing $name" }
}
$manifest = Get-Content (Join-Path $fingerCfg "manifest.json") -Raw | ConvertFrom-Json
$assets = Join-Path $fingerCfg ([string]$manifest.assets_dir)
$sensor = Get-Content (Join-Path $fingerCfg "sensor.json") -Raw | ConvertFrom-Json
$scale = $sensor.camera.source_scale
$mapName = if ($null -eq $scale -or [double]$scale -eq 1.0) { "unroll_map_uv.npz" } else { "unroll_map_uv_s$([double]$scale).npz" }
if (-not (Test-Path (Join-Path $assets $mapName))) {
throw "the shipped finger assets have no $mapName (the device geometry description needs it; the pane would refuse the scene)"
}
Write-Host " finger assets: $($manifest.assets_dir) ($mapName present)"
}
# Size summary # ------------------------------------------------------------------- 5. no Open3D
$o3dHits = Get-ChildItem $OutDir -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match 'open3d' }
if ($o3dHits) {
Write-Warning "Open3D-related files found under dist (should be empty):"
$o3dHits | ForEach-Object { Write-Warning $_.FullName }
} else {
Write-Host "Open3D check: none found under dist."
}
# ----------------------------------------------------------------- 6. smoke test
# Start the EXE the way a user would and require the application's own log to say it came up.
# This is the step that would have caught every silent failure found in this build.
$smokeFailed = $false
$smokeWarned = $false
if (-not $SkipSmokeTest -and (Test-Path $Exe)) {
Write-Host ""
Write-Host "Smoke test: starting $Exe ..."
$prefPath = Join-Path $OutDir "preference.json"
$hadPref = Test-Path $prefPath
$prefBackup = $null
if ($hadPref) { $prefBackup = Get-Content $prefPath -Raw }
$logsDir = Join-Path $OutDir "outputs\logs"
$before = @()
$outputsExisted = Test-Path (Join-Path $OutDir "outputs")
if (Test-Path $logsDir) { $before = @(Get-ChildItem $logsDir -Filter *.log | ForEach-Object { $_.Name }) }
# The pane is shown for the finger sensor only; seeding the preference is how the build
# proves the *packaged* asset loads, not just that the window opened.
$sensorFor3D = ""
if (Test-Path $fingerCfg) { $sensorFor3D = "finger" }
$seed = [ordered]@{ language = "en_US"; theme = "dark"; view_preset = "live"; show_finger_3d = $true }
if ($sensorFor3D) { $seed["sensor_type"] = $sensorFor3D }
($seed | ConvertTo-Json) | ForEach-Object { Write-JsonFile $prefPath $_ }
$oldQpa = $env:QT_QPA_PLATFORM
$env:QT_QPA_PLATFORM = "windows"
$proc = $null
try {
$proc = Start-Process -FilePath $Exe -WorkingDirectory $OutDir -PassThru
# The scene is read on a worker thread *after* the shell is up, so the two lines
# arrive separately: waiting for both (rather than reading the log once) is what
# keeps this test from being a race.
$wanted = @("Studio shell started")
if ($sensorFor3D) { $wanted += "3D scene loaded" }
$deadline = (Get-Date).AddSeconds(75)
$logText = ""
$logPath = ""
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 1000
$fresh = Get-ChildItem $logsDir -Filter *.log -ErrorAction SilentlyContinue |
Where-Object { $before -notcontains $_.Name }
if ($fresh) {
$newest = $fresh | Sort-Object LastWriteTime | Select-Object -Last 1
$logPath = $newest.FullName
$logText = Get-Content $logPath -Raw -ErrorAction SilentlyContinue
$missing = @($wanted | Where-Object { $logText -notmatch [regex]::Escape($_) })
if ($missing.Count -eq 0) { break }
}
if ($proc.HasExited) { break }
}
if (-not $logText -or $logText -notmatch "Studio shell started") {
$smokeFailed = $true
Write-Warning "The produced EXE did not reach 'Studio shell started'."
if ($logPath) { Write-Warning "log: $logPath" } else { Write-Warning "no log was written at all" }
} else {
Write-Host " the application started"
if ($sensorFor3D -and $logText -notmatch "3D scene loaded") {
$smokeFailed = $true
Write-Warning "The 3D scene did not load from the shipped package."
# The loader logs the diagnostic it refused with; quoting it here is the
# difference between "the build failed" and "this asset was rejected".
$rejection = @($logText -split "`r?`n" | Where-Object { $_ -match "rejected|no compiled scene|asset_" })
if ($rejection) { Write-Warning (" " + $rejection[-1]) }
} elseif ($sensorFor3D) {
Write-Host " the packaged 3D scene loaded"
}
# A fallback to Qt's ANGLE backend cannot satisfy a desktop 3.3 core request, and
# the pane then reports itself unavailable on a machine whose GPU is fine. It is
# environment-dependent, so it is a warning unless -StrictSmokeTest is asked for.
if ($logText -match "QWindowsEGLContext|Failed to create context|context creation failed") {
$smokeWarned = $true
Write-Warning "The 3D widget could not create a GL context in this session."
Write-Warning " desktop33: set QT_OPENGL=desktop (Qt otherwise falls back to ANGLE, which is GLES-only)"
Write-Warning " or pin it in the app (Qt.AA_UseDesktopOpenGL / AA_UseOpenGLES) before QApplication."
}
}
if ($logPath) {
New-Item -ItemType Directory -Path $SmokeKeep -Force | Out-Null
$keepName = "smoke-{0}.log" -f (Get-Date).ToString("yyyyMMdd_HHmmss")
Copy-Item $logPath (Join-Path $SmokeKeep $keepName) -Force
Write-Host " smoke log kept: build\_smoke\$keepName"
}
} finally {
if ($proc -and -not $proc.HasExited) {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
}
# The app is not given the chance to save preferences; restore what was there.
if ($hadPref) { Write-JsonFile $prefPath $prefBackup }
elseif (Test-Path $prefPath) { Remove-Item $prefPath -Force -ErrorAction SilentlyContinue }
if (-not $outputsExisted -and (Test-Path (Join-Path $OutDir "outputs"))) {
Remove-Item (Join-Path $OutDir "outputs") -Recurse -Force -ErrorAction SilentlyContinue
}
if ($null -eq $oldQpa) { Remove-Item Env:\QT_QPA_PLATFORM -ErrorAction SilentlyContinue }
else { $env:QT_QPA_PLATFORM = $oldQpa }
}
}
# ------------------------------------------------------------------- 7. summary
if (Test-Path $OutDir) { if (Test-Path $OutDir) {
$bytes = (Get-ChildItem $OutDir -Recurse -File -ErrorAction SilentlyContinue | $bytes = (Get-ChildItem $OutDir -Recurse -File -ErrorAction SilentlyContinue |
Measure-Object -Property Length -Sum).Sum Measure-Object -Property Length -Sum).Sum
$mb = [math]::Round($bytes / 1MB, 1) $mb = [math]::Round($bytes / 1MB, 1)
Write-Host ("Output size: {0} MB" -f $mb) Write-Host ("Output size: {0} MB" -f $mb)
Write-Host "Launch: $Exe"
$o3dHits = Get-ChildItem $OutDir -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match 'open3d' }
if ($o3dHits) {
Write-Warning "Open3D-related files found under dist (should be empty):"
$o3dHits | ForEach-Object { Write-Warning $_.FullName }
} else {
Write-Host "Open3D check: none found under dist."
}
} }
if ($smokeFailed -or ($StrictSmokeTest -and $smokeWarned)) {
Write-Host ""
Write-Host "FAILED: the build produced an EXE that did not pass the smoke test." -ForegroundColor Red
exit 1
}
exit 0
+27
View File
@@ -0,0 +1,27 @@
# Pinned build-time versions, applied with `pip install -c` by setup.ps1.
#
# The point is not tidiness: PyInstaller's output depends on the *environment* as much as
# on the spec (which Qt, which numpy, which hooks-contrib), so a build that "works" on one
# machine and produces a non-starting EXE on another is usually a version difference that
# nobody wrote down. `-c` constrains whatever else asks for these packages, so the app's
# own loose requirements (PyQt5>=5.15, scipy>=1.10.1, ...) resolve to exactly this set.
#
# `orisys` is deliberately absent: it is installed from a local checkout (or deliberately
# from PyPI with -FromPypi), and pinning it by name would fight the editable install.
#
# To move a pin: change it here, run `setup.bat -Force`, and run the build's smoke test.
# That test is the acceptance evidence, not this file.
PyInstaller==6.22.2
pyinstaller-hooks-contrib==2026.7
PyOpenGL==3.1.10
PyQt5==5.15.11
PyQt5-Qt5==5.15.2
PyQt5-sip==12.19.0
numpy==2.2.6
scipy==1.15.3
numba==0.67.0
llvmlite==0.49.0
opencv-python-headless==4.14.0.94
pyqtgraph==0.14.0
cryptography==49.0.0
+53 -2
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import json
import os
from pathlib import Path from pathlib import Path
from PyInstaller.building.api import COLLECT, EXE, PYZ from PyInstaller.building.api import COLLECT, EXE, PYZ
@@ -13,6 +15,50 @@ SPEC_DIR = Path(SPECPATH).resolve()
REPO_ROOT = SPEC_DIR.parents[1] REPO_ROOT = SPEC_DIR.parents[1]
BRANDING_DIR = REPO_ROOT / "shared" / "resources" / "branding" BRANDING_DIR = REPO_ROOT / "shared" / "resources" / "branding"
ENTRY = SPEC_DIR / "entry.py" ENTRY = SPEC_DIR / "entry.py"
#: What ``setup.ps1`` recorded about this machine (interpreter, and where its runtime DLLs
#: live when that interpreter is not a python.org one).
BUILD_ENV_STAMP = REPO_ROOT / ".venv-build" / "orisys-build-env.json"
#: The runtime DLLs an Anaconda interpreter keeps in ``<prefix>\Library\bin`` or
#: ``<prefix>\DLLs``. PyInstaller does not search those directories, so a frozen app built
#: without them dies before it can log anything:
#: ``ImportError: DLL load failed while importing pyexpat`` (libexpat)
#: ``ImportError: DLL load failed while importing _ctypes`` (ffi-8)
#: Building with python.org CPython avoids the whole list; when ``setup.ps1`` accepts a conda
#: interpreter it records the directory, and this is where that decision is honoured.
_RUNTIME_DLLS = (
"libexpat.dll",
"expat.dll",
"ffi-8.dll",
"ffi-7.dll",
"ffi.dll",
"libssl-3-x64.dll",
"libcrypto-3-x64.dll",
"sqlite3.dll",
"liblzma.dll",
"libbz2.dll",
"zlib.dll",
)
def _extra_runtime_binaries() -> list[tuple[str, str]]:
"""Runtime DLLs from a non-python.org interpreter, as ``(source, dest)`` pairs."""
folders: list[str] = []
try:
stamp = json.loads(BUILD_ENV_STAMP.read_text(encoding="utf-8"))
folders.extend(str(item) for item in (stamp.get("extra_dll_dirs") or ()))
except Exception:
# No stamp (a spec run by hand): fall back to the environment, if it says it is conda.
prefix = os.environ.get("CONDA_PREFIX")
if prefix:
folders.append(os.path.join(prefix, "Library", "bin"))
out: list[tuple[str, str]] = []
for folder in folders:
for name in _RUNTIME_DLLS:
candidate = Path(folder) / name
if candidate.is_file():
out.append((str(candidate), "."))
return out
def _branding_datas() -> list[tuple[str, str]]: def _branding_datas() -> list[tuple[str, str]]:
@@ -30,7 +76,7 @@ block_cipher = None
# Config is copied beside the EXE by build.ps1 (writable / editable). # Config is copied beside the EXE by build.ps1 (writable / editable).
# Branding + package data go into _internal via Analysis datas. # Branding + package data go into _internal via Analysis datas.
datas = _branding_datas() datas = _branding_datas()
binaries: list = [] binaries: list = _extra_runtime_binaries()
hiddenimports = [ hiddenimports = [
"cv2", "cv2",
"PyQt5.sip", "PyQt5.sip",
@@ -68,7 +114,12 @@ a = Analysis(
runtime_hooks=[], runtime_hooks=[],
excludes=[ excludes=[
"open3d", "open3d",
"orisys.finger.viewer", # NOTE: `orisys.finger` imports `.viewer` at module level, and `finger/viewer.py`
# imports open3d *inside* its functions — so bundling it costs a few KB of Python and
# pulls in no Open3D. Excluding it made `import orisys.finger` raise
# ModuleNotFoundError in the frozen app, which took the whole window down with it
# (the app cannot even reach logging). The `open3d` entry above is the one that
# matters.
"tkinter", "tkinter",
"matplotlib", "matplotlib",
"IPython", "IPython",
+21
View File
@@ -0,0 +1,21 @@
@echo off
setlocal EnableExtensions
REM Provision the Orisys Studio build environment (run once per machine).
REM Works from any cwd; forwards args to setup.ps1
REM Examples:
REM setup.bat
REM setup.bat -Force
REM setup.bat -Python C:\Python310\python.exe
REM setup.bat -AllowConda
set "SCRIPT_DIR=%~dp0"
set "PS1=%SCRIPT_DIR%setup.ps1"
if not exist "%PS1%" (
echo ERROR: setup.ps1 not found at "%PS1%"
exit /b 1
)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS1%" %*
set "ERR=%ERRORLEVEL%"
exit /b %ERR%
+292
View File
@@ -0,0 +1,292 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Provision the Windows packaging environment for Orisys Studio. Run once per machine.
.DESCRIPTION
Building the EXE has two kinds of requirement, and mixing them is what makes a build
"work on my machine":
* things that are true of *this machine* — which interpreter exists, whether it is
Anaconda (whose libexpat/libffi DLLs live outside the venv and are therefore not
bundled), where the orisys SDK checkout is. Those are decided **here**, once, and
recorded in `.venv-build\orisys-build-env.json`.
* things that are true of the *sources* — the specs, the excludes, the assets. Those
belong to `build.ps1`, which then installs nothing and guesses nothing.
So this script is the only place that installs anything. It creates `.venv-build` (or
repairs it), installs the pinned toolchain plus the app's own extras, writes the stamp that
`build.ps1` and the spec read, and verifies the result by importing what the frozen app
imports before anything is packaged.
Re-running is safe: with a valid stamp and no -Force it reports and exits; without a stamp
it repairs the existing venv instead of deleting it.
.PARAMETER Python
Interpreter used to create the venv. Default: `py -3.10` when available, else `python`,
else the interpreter that already backs `.venv-build`.
.PARAMETER SdkPath
Local `orisys` SDK checkout to install editable. Default: `..\..\..\orisys-sdk` when that
exists. Without it (or with -FromPypi) the SDK comes from PyPI, which is *not* the code
under test — that is how a build once shipped with no `orisys.finger` at all.
.PARAMETER Force
Recreate the venv from scratch.
.PARAMETER AllowConda
Accept an Anaconda interpreter. Anaconda keeps libexpat/libffi/OpenSSL/SQLite in
`<prefix>\Library\bin`, where PyInstaller does not look; the stamp records that directory
and the spec adds those DLLs. Building with a python.org CPython is still preferred.
.PARAMETER FromPypi
Install `orisys` from PyPI instead of a local checkout.
.PARAMETER NoVerify
Skip the post-install verification (not recommended; that verification is what catches a
provisioning which would produce an EXE that cannot start).
.EXAMPLE
setup.bat
setup.bat -Force
setup.bat -Python C:\Python310\python.exe
setup.bat -AllowConda
#>
param(
[string]$Python = "",
[string]$SdkPath = "",
[switch]$Force,
[switch]$AllowConda,
[switch]$FromPypi,
[switch]$NoVerify
)
$ErrorActionPreference = "Stop"
$StudioDir = $PSScriptRoot
$RepoRoot = (Resolve-Path (Join-Path $StudioDir "..\..")).Path
$VenvDir = Join-Path $RepoRoot ".venv-build"
$VenvPy = Join-Path $VenvDir "Scripts\python.exe"
$VenvCfg = Join-Path $VenvDir "pyvenv.cfg"
$StampPath = Join-Path $VenvDir "orisys-build-env.json"
$Constraints = Join-Path $StudioDir "constraints-build.txt"
#: Bumped when the stamp's meaning changes: an old stamp must not be read as valid.
$StampVersion = 1
#: `pyinstaller` is pinned here as well as in the constraints file: it is the tool, not a
#: dependency of the app, and its version decides what the bundle contains.
$PyInstallerPin = "pyinstaller==6.22.2"
#: opencv-python 5.x has shipped wheels without a `cv2` module; the app is tested on 4.x.
$CvPin = "opencv-python-headless>=4.10.0.84,<5"
# A condition the operator has to fix, with the instructions in the message itself.
function Stop-With([string]$message) {
Write-Host ""
Write-Host $message -ForegroundColor Red
exit 1
}
# PowerShell 5.1's `Set-Content -Encoding UTF8` writes a BOM, and a BOM makes Python's
# `json.loads` raise ("Unexpected UTF-8 BOM"). The spec reads this stamp, so a BOM here would
# quietly drop the extra runtime DLLs and the EXE would not start.
function Write-JsonFile([string]$path, [string]$json) {
[System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false)))
}
# A path that is really a Python: the Microsoft Store alias on PATH answers `python` but is
# not an interpreter, and it would otherwise be silently chosen.
function Test-BasePython([string]$exe) {
if (-not $exe) { return $null }
if ($exe -match '\\WindowsApps\\') { return $null }
if (-not (Test-Path $exe)) { return $null }
try {
$out = & $exe -c "import sys; print(sys.executable)" 2>$null
if ($LASTEXITCODE -ne 0 -or -not $out) { return $null }
return ($out | Select-Object -Last 1).Trim()
} catch {
return $null
}
}
function Resolve-BasePython([string]$explicit) {
if ($explicit) {
$ok = Test-BasePython $explicit
if (-not $ok) { Stop-With "-Python is not a usable interpreter: $explicit" }
return $ok
}
$tried = @()
if (Get-Command py.exe -ErrorAction SilentlyContinue) {
$out = & py -3.10 -c "import sys; print(sys.executable)" 2>$null
$tried += "py -3.10"
if ($LASTEXITCODE -eq 0 -and $out) {
$ok = Test-BasePython (($out | Select-Object -Last 1).Trim())
if ($ok) { return $ok }
}
}
foreach ($name in @("python3.10.exe", "python.exe")) {
$found = Get-Command $name -ErrorAction SilentlyContinue
if (-not $found) { continue }
$tried += $found.Source
$ok = Test-BasePython $found.Source
if ($ok) { return $ok }
}
# Last resort: whatever already backs this venv. An existing build environment is
# evidence of an interpreter that works on this machine.
if (Test-Path $VenvCfg) {
$match = Select-String -Path $VenvCfg -Pattern '^home\s*=\s*(.+)$'
if ($match) {
$baseHome = $match.Matches[0].Groups[1].Value.Trim()
$tried += "$baseHome (from pyvenv.cfg)"
$ok = Test-BasePython (Join-Path $baseHome "python.exe")
if ($ok) { return $ok }
}
}
Stop-With "no usable Python found (tried: $($tried -join ', ')). Install CPython 3.10+ and pass -Python <path>."
}
function Get-PythonFacts([string]$exe) {
$json = & $exe -c "import json,os,sys;print(json.dumps({'exe':sys.executable,'version':sys.version.split()[0],'prefix':sys.prefix,'conda':os.path.isdir(os.path.join(sys.prefix,'conda-meta')) or 'conda' in sys.prefix.lower()}))"
if ($LASTEXITCODE -ne 0) { throw "could not query $exe" }
return ($json | Select-Object -Last 1 | ConvertFrom-Json)
}
Write-Host "Orisys Studio build environment"
Write-Host " repo : $RepoRoot"
Write-Host " venv : $VenvDir"
# --------------------------------------------------------------------- stamp check
if ((Test-Path $VenvPy) -and (Test-Path $StampPath) -and -not $Force) {
$stamp = Get-Content $StampPath -Raw | ConvertFrom-Json
if ($stamp.stamp_version -eq $StampVersion -and $stamp.checkout_root -eq $RepoRoot) {
Write-Host ""
Write-Host "Already provisioned ($($stamp.created)); nothing to do."
Write-Host " base python : $($stamp.base_python) ($($stamp.python_version))"
Write-Host " sdk : $($stamp.sdk)"
Write-Host "Use -Force to rebuild the venv, or go straight to ..\studio\build.bat"
exit 0
}
Write-Host "The existing venv was provisioned for another checkout or an older stamp: repairing it."
}
# ------------------------------------------------------------------- interpreter
$BasePy = Resolve-BasePython $Python
$facts = Get-PythonFacts $BasePy
Write-Host " base python : $($facts.exe) ($($facts.version))"
$extraDllDirs = @()
if ($facts.conda) {
$libraryBin = Join-Path $facts.prefix "Library\bin"
if (-not $AllowConda) {
Stop-With @"
$($facts.exe) is an Anaconda interpreter.
PyInstaller does not search Anaconda's Library\bin, where libexpat.dll, ffi-8.dll,
libssl/libcrypto and sqlite3.dll live, so the EXE it produces fails at start-up with
"ImportError: DLL load failed while importing pyexpat". Either:
* build with python.org CPython (recommended): setup.bat -Python C:\Python310\python.exe
* or accept the risk: setup.bat -AllowConda
-AllowConda records $libraryBin in the build stamp and the spec adds those DLLs.
"@
}
if (Test-Path $libraryBin) {
$extraDllDirs += $libraryBin
Write-Host " conda : accepted; runtime DLLs will be taken from $libraryBin"
} else {
Write-Warning "conda prefix $($facts.prefix) has no Library\bin; the EXE may not start"
}
}
# ----------------------------------------------------------------------- the venv
if ($Force -and (Test-Path $VenvDir)) {
Write-Host "Removing $VenvDir"
Remove-Item $VenvDir -Recurse -Force
}
if (-not (Test-Path $VenvPy)) {
Write-Host "Creating venv with $BasePy"
& $BasePy -m venv $VenvDir
if ($LASTEXITCODE -ne 0) { throw "python -m venv failed" }
} else {
Write-Host "Reusing the existing venv (use -Force to recreate it)"
}
# ------------------------------------------------------------------------ the SDK
$sdkSource = ""
if (-not $FromPypi) {
$candidate = if ($SdkPath) { $SdkPath } else { Join-Path (Split-Path $RepoRoot -Parent) "orisys-sdk" }
if ($candidate -and (Test-Path (Join-Path $candidate "pyproject.toml"))) {
$sdkSource = (Resolve-Path $candidate).Path
} elseif ($SdkPath) {
throw "-SdkPath has no pyproject.toml: $SdkPath"
}
}
if (-not $sdkSource) {
Write-Warning "No local orisys SDK checkout found; installing 'orisys>=0.5' from PyPI. The EXE will not contain the SDK under test unless that is what you intend."
}
# ----------------------------------------------------------------------- install
Write-Host "Installing the pinned toolchain and the app's own packages ..."
$install = @("-m", "pip", "install", "--disable-pip-version-check")
if (Test-Path $Constraints) { $install += @("-c", $Constraints) }
if ($sdkSource) { $install += @("-e", $sdkSource) } else { $install += "orisys>=0.5" }
$install += @("-e", (Join-Path $RepoRoot "shared"))
# The `three-d` extra is the GL binding. Without it the app still builds, and the 3D pane
# silently reports "PyOpenGL is not installed" on the customer's machine.
$install += @("-e", "$(Join-Path $RepoRoot 'apps\studio')[three-d]")
$install += @($PyInstallerPin, $CvPin)
& $VenvPy @install
if ($LASTEXITCODE -ne 0) { throw "pip install failed" }
# Upstream `orisys` may declare opencv-python; the app needs the headless wheel (PyQt owns
# the GUI), and 5.x can arrive without a `cv2` module.
& $VenvPy -m pip uninstall -y opencv-python opencv-python-headless 2>$null | Out-Null
& $VenvPy -m pip install --disable-pip-version-check -c $Constraints $CvPin
if ($LASTEXITCODE -ne 0) { throw "opencv-python-headless install failed" }
# Open3D must not be in the build env: it is an offline tool, and the spec excludes it.
$hasOpen3d = & $VenvPy -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)"
if ($hasOpen3d -eq "True") {
Write-Host "Uninstalling open3d from the build venv ..."
& $VenvPy -m pip uninstall -y open3d
}
# ------------------------------------------------------------------- verification
if (-not $NoVerify) {
Write-Host ""
Write-Host "Verifying the provisioned environment ..."
# These are the imports the frozen app makes before it can draw anything. Checking them
# here is what turns "the build succeeded" into "the build can start".
& $VenvPy -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PyQt5.QtWidgets, PyInstaller; print('imports OK')"
if ($LASTEXITCODE -ne 0) { throw "the venv cannot import what the app imports (see above)" }
# Theme colours are package data: PyInstaller only bundles them if the package is
# importable at build time, so a stale editable install loses them silently.
& $VenvPy -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')"
if ($LASTEXITCODE -ne 0) { throw "theme data would not be bundled" }
# When a local checkout was asked for, the installed SDK must be that checkout.
if ($sdkSource) {
& $VenvPy -c "import orisys, os; p=os.path.dirname(orisys.__file__); assert os.path.isfile(os.path.join(p,'finger','__init__.py')), 'orisys.finger missing from '+p; print('SDK OK:', p)"
if ($LASTEXITCODE -ne 0) { throw "the installed SDK has no finger package" }
}
}
# ------------------------------------------------------------------------ stamp
$stampData = [ordered]@{
stamp_version = $StampVersion
checkout_root = $RepoRoot
created = (Get-Date).ToString("o")
base_python = $facts.exe
python_version = $facts.version
is_conda = [bool]$facts.conda
conda_prefix = $facts.prefix
extra_dll_dirs = @($extraDllDirs)
sdk = $sdkSource
venv = $VenvDir
}
Write-JsonFile $StampPath ($stampData | ConvertTo-Json -Depth 4)
Write-Host "Wrote $StampPath"
Write-Host ""
Write-Host "Environment ready. Next:"
Write-Host " build.bat # build the EXE"
Write-Host " build.bat -Clean # after dependency/spec changes"
Write-Host " build.bat -SkipSmokeTest # skip launching the produced EXE"