update sensor bench

This commit is contained in:
ykcheungab
2026-09-01 11:14:56 +08:00
parent ef3ee84a68
commit 8cacb12abc
55 changed files with 4032 additions and 323 deletions
+81 -11
View File
@@ -1,6 +1,9 @@
# Sensor Bench EXE Build
This folder contains the Windows PyInstaller build setup for Sensor Bench.
This folder contains Windows PyInstaller build setups for:
- **Public** exhibition app → `dist/SensorBench/`
- **Private** operator app → `dist/SensorBenchOperator/` (internal use only)
## Requirements
@@ -9,7 +12,8 @@ This folder contains the Windows PyInstaller build setup for Sensor Bench.
- Python 3.10 build environment
- Internet access to install `orisys` from PyPI
The build installs `orisys` from PyPI. Do not use `PYTHONPATH` or an editable `orisys-sdk` checkout for release builds.
The build installs `orisys` from PyPI. Do not use `PYTHONPATH` or an editable
`orisys-sdk` checkout for release builds.
## Build
@@ -20,7 +24,8 @@ From the repository root (cmd or PowerShell):
.\build\build_exe.bat
```
The `.bat` wrappers bypass the local PowerShell execution policy. If you prefer calling PowerShell directly:
The `.bat` wrappers bypass the local PowerShell execution policy. If you prefer
calling PowerShell directly:
```bat
powershell -NoProfile -ExecutionPolicy Bypass -File .\build\create_env.ps1
@@ -33,7 +38,23 @@ Output:
dist/SensorBench/SensorBench.exe
```
The build is `onedir` rather than `onefile` so startup is faster and bundled DLL/resource issues are easier to diagnose.
After installing/upgrading `orisys`, the build syncs wheel sensor `*.json` into
repo `config/` via `build/sync_wheel_configs.py` (for example `ddjx01.json`)
before PyInstaller, never touching `locales/`. The helper reads from the
installed wheel's bundled `orisys/configs/` directory instead of
`orisys.configs.CONFIG_DIR`, which can point at the repo's local `config/`
during builds. PyInstaller bundles that `config/`, then the script copies repo
`config/` beside the EXE so operators can edit sensor JSON and locales.
The public build intentionally excludes:
- `internal/` (private operator UI)
- `steps/` workflow JSON
- `config/options.json` lab component metadata
- `sensor_bench_operator` / former `sensor_bench.app` modules
The build is `onedir` rather than `onefile` so startup is faster and bundled
DLL/resource issues are easier to diagnose.
## Validation
@@ -44,15 +65,64 @@ python tests/test_smoke.py
python build/verify_dist.py
```
`verify_dist.py` checks the expected public layout **and** fails if internal
paths, operator modules, workflow locale keys, or private workflow text appear
in the dist tree.
Manual hardware check:
1. Run `dist\SensorBench\SensorBench.exe`.
2. Confirm the UI opens without setting `PYTHONPATH`.
2. Confirm the exhibition UI opens without setting `PYTHONPATH`.
3. Confirm the UI defaults to Simplified Chinese and can switch to English.
4. Confirm Orisys status is available and fixed mode shows DDJX01.
5. Use Next / Revert / New Sample Reset on the left workflow panel.
6. Start preview with camera index `0`.
7. Confirm raw preview and processed preview update.
8. Record a short clip and verify it appears under `dist\SensorBench\recordings`.
4. Confirm Orisys status is available, the config dropdown lists local editable
sensor configs (for example `ddjx01`), and Start uses DDJX01.
5. Confirm Search finds cameras (or logs none) and Camera still opens a
video-file picker. There is no Cal (.npy) field; Reset resets the Orisys
tracker while preview is running.
6. Start preview with camera index `0` (or a searched index / video file).
7. Confirm the image panel and FN/FX/FY plots update.
8. Toggle processed/raw view and start/stop recording under
`dist\SensorBench\recordings`.
Locale files are bundled under `_internal/config/locales/` (and can also be placed beside the EXE under `config/locales/` for overrides). Edit those JSON files to change operator-facing text without rebuilding.
Locale files are bundled under `_internal/config/locales/` (and can also be
placed beside the EXE under `config/locales/` for overrides). Edit those JSON
files to change exhibition-facing text without rebuilding.
## Private operator EXE (internal only)
Do **not** ship `SensorBenchOperator` to customers. It includes workflow steps,
lab `options.json`, and operator locale copy.
```bat
.\build\create_env.bat
.\build\build_operator_exe.bat
```
Or:
```bat
powershell -NoProfile -ExecutionPolicy Bypass -File .\build\build_operator_exe.ps1
```
Output:
```text
dist/SensorBenchOperator/SensorBenchOperator.exe
```
Validation:
```powershell
python tests/test_smoke.py
python internal/tests/test_operator_smoke.py
python build/verify_operator_dist.py
```
Manual check:
1. Run `dist\SensorBenchOperator\SensorBenchOperator.exe`.
2. Confirm the workflow/steps panel and component options appear.
3. Confirm Start Preview / record / force curves still work with a camera.
4. Confirm `config/options.json` and `steps/default.json` beside the EXE are editable.
Source-level operator docs: [`../internal/README.md`](../internal/README.md).
+20 -3
View File
@@ -8,6 +8,7 @@ $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$DistDir = Join-Path $RepoRoot "dist"
$WorkDir = Join-Path $RepoRoot "build\pyinstaller_work"
$SpecPath = Join-Path $PSScriptRoot "sensor_bench.spec"
$RepoConfig = Join-Path $RepoRoot "config"
if (-not (Get-Command conda -ErrorAction SilentlyContinue)) {
throw "conda was not found on PATH. Open an Anaconda Prompt or initialize conda for PowerShell."
@@ -16,10 +17,20 @@ if (-not (Get-Command conda -ErrorAction SilentlyContinue)) {
Push-Location $RepoRoot
try {
conda run -n $EnvName python -m pip install --upgrade pip
if ($LASTEXITCODE -ne 0) { throw "pip upgrade failed in env '$EnvName'." }
conda run -n $EnvName python -m pip install -e ".[pack]"
if ($LASTEXITCODE -ne 0) { throw "Editable install failed in env '$EnvName'." }
conda run -n $EnvName python -m pip install --upgrade orisys
if ($LASTEXITCODE -ne 0) { throw "orisys install failed in env '$EnvName'." }
# Sync Orisys wheel sensor configs into repo config/ before packaging.
conda run -n $EnvName python build/sync_wheel_configs.py
if ($LASTEXITCODE -ne 0) { throw "Config sync failed." }
conda run -n $EnvName python tests/test_smoke.py
if ($LASTEXITCODE -ne 0) { throw "Smoke tests failed." }
if (Test-Path $WorkDir) {
Remove-Item -Recurse -Force $WorkDir
@@ -29,14 +40,20 @@ try {
}
conda run -n $EnvName python -m PyInstaller --noconfirm --clean --distpath "$DistDir" --workpath "$WorkDir" "$SpecPath"
if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed." }
# Public package only: copy exhibition config/. Never ship internal/ operator assets.
Copy-Item -Recurse -Force $RepoConfig (Join-Path $DistDir "SensorBench")
$InternalLeak = Join-Path $DistDir "SensorBench\internal"
if (Test-Path $InternalLeak) {
throw "Public dist must not contain internal/: $InternalLeak"
}
Copy-Item -Recurse -Force (Join-Path $RepoRoot "config") (Join-Path $DistDir "SensorBench")
Copy-Item -Recurse -Force (Join-Path $RepoRoot "steps") (Join-Path $DistDir "SensorBench")
conda run -n $EnvName python build/verify_dist.py
if ($LASTEXITCODE -ne 0) { throw "Distribution verification failed." }
Write-Host "Build complete: dist\SensorBench\SensorBench.exe"
}
finally {
Pop-Location
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0.."
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_operator_exe.ps1" %*
+78
View File
@@ -0,0 +1,78 @@
param(
[string]$EnvName = "sensor-bench-build-py310"
)
$ErrorActionPreference = "Stop"
$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$DistDir = Join-Path $RepoRoot "dist"
$WorkDir = Join-Path $RepoRoot "build\pyinstaller_work_operator"
$SpecPath = Join-Path $PSScriptRoot "sensor_bench_operator.spec"
$RepoConfig = Join-Path $RepoRoot "config"
$InternalRoot = Join-Path $RepoRoot "internal"
$DistApp = Join-Path $DistDir "SensorBenchOperator"
if (-not (Get-Command conda -ErrorAction SilentlyContinue)) {
throw "conda was not found on PATH. Open an Anaconda Prompt or initialize conda for PowerShell."
}
Push-Location $RepoRoot
try {
conda run -n $EnvName python -m pip install --upgrade pip
if ($LASTEXITCODE -ne 0) { throw "pip upgrade failed in env '$EnvName'." }
conda run -n $EnvName python -m pip install -e ".[pack]"
if ($LASTEXITCODE -ne 0) { throw "Editable install failed in env '$EnvName'." }
conda run -n $EnvName python -m pip install --upgrade orisys
if ($LASTEXITCODE -ne 0) { throw "orisys install failed in env '$EnvName'." }
# Sync Orisys wheel sensor configs into repo config/ before packaging.
conda run -n $EnvName python build/sync_wheel_configs.py
if ($LASTEXITCODE -ne 0) { throw "Config sync failed." }
conda run -n $EnvName python tests/test_smoke.py
if ($LASTEXITCODE -ne 0) { throw "Public smoke tests failed." }
conda run -n $EnvName python internal/tests/test_operator_smoke.py
if ($LASTEXITCODE -ne 0) { throw "Operator smoke tests failed." }
if (Test-Path $WorkDir) {
Remove-Item -Recurse -Force $WorkDir
}
if (Test-Path $DistApp) {
Remove-Item -Recurse -Force $DistApp
}
conda run -n $EnvName python -m PyInstaller --noconfirm --clean --distpath "$DistDir" --workpath "$WorkDir" "$SpecPath"
if ($LASTEXITCODE -ne 0) { throw "PyInstaller (operator) failed." }
# Editable sidecars beside the EXE: public sensor configs + operator overlays.
# Copy sensor JSON first, then replace locales/options/steps so public
# exhibition locales (no workflow) cannot win over operator catalogs.
$DistConfig = Join-Path $DistApp "config"
$DistLocales = Join-Path $DistConfig "locales"
$DistSteps = Join-Path $DistApp "steps"
if (Test-Path $DistConfig) {
Remove-Item -Recurse -Force $DistConfig
}
if (Test-Path $DistSteps) {
Remove-Item -Recurse -Force $DistSteps
}
Copy-Item -Recurse -Force $RepoConfig $DistConfig
Copy-Item -Force (Join-Path $InternalRoot "config\options.json") (Join-Path $DistConfig "options.json")
if (Test-Path $DistLocales) {
Remove-Item -Recurse -Force $DistLocales
}
Copy-Item -Recurse -Force (Join-Path $InternalRoot "config\locales") $DistLocales
Copy-Item -Recurse -Force (Join-Path $InternalRoot "steps") $DistSteps
conda run -n $EnvName python build/verify_operator_dist.py
if ($LASTEXITCODE -ne 0) { throw "Operator distribution verification failed." }
Write-Host "Build complete: dist\SensorBenchOperator\SensorBenchOperator.exe"
Write-Host "INTERNAL USE ONLY - do not ship this folder to customers."
}
finally {
Pop-Location
}
+7 -3
View File
@@ -11,13 +11,16 @@ work_dir = repo_root / "build" / "pyinstaller_work"
datas = [
(str(repo_root / "config"), "config"),
(str(repo_root / "steps"), "steps"),
]
binaries = []
hiddenimports = [
"sensor_bench.main",
"sensor_bench.app",
"sensor_bench.exhibition_app",
"sensor_bench.acquisition",
"sensor_bench.dd06_acquisition",
"sensor_bench.roi_geometry",
"sensor_bench.camera_discovery",
"sensor_bench.force_calibration",
"sensor_bench.config_loader",
"sensor_bench.i18n",
"sensor_bench.paths",
@@ -50,6 +53,8 @@ excludes = [
"pytest",
"pip",
"wheel",
"sensor_bench_operator",
"sensor_bench.app",
]
a = Analysis(
@@ -95,4 +100,3 @@ coll = COLLECT(
upx_exclude=[],
name="SensorBench",
)
+110
View File
@@ -0,0 +1,110 @@
# -*- mode: python ; coding: utf-8 -*-
from pathlib import Path
from PyInstaller.utils.hooks import collect_all, collect_submodules
repo_root = Path.cwd()
src_dir = repo_root / "src"
operator_src = repo_root / "internal" / "src"
work_dir = repo_root / "build" / "pyinstaller_work_operator"
datas = [
# Operator-owned options / locales / workflow steps (private).
(str(repo_root / "internal" / "config"), "config"),
(str(repo_root / "internal" / "steps"), "steps"),
# Public sensor JSON configs (ddjx01, mini, …) for the shared acquisition path.
(str(repo_root / "config"), "config"),
]
binaries = []
hiddenimports = [
"sensor_bench_operator",
"sensor_bench_operator.app",
"sensor_bench_operator.paths",
"sensor_bench.main",
"sensor_bench.exhibition_app",
"sensor_bench.acquisition",
"sensor_bench.dd06_acquisition",
"sensor_bench.roi_geometry",
"sensor_bench.camera_discovery",
"sensor_bench.force_calibration",
"sensor_bench.config_loader",
"sensor_bench.i18n",
"sensor_bench.paths",
"sensor_bench.plot_history",
"sensor_bench.recorder",
"orisys",
"orisys_blend",
"scipy.fft",
"scipy.ndimage",
"scipy.sparse.linalg",
"numba",
"llvmlite",
]
for package in ("PyQt5", "pyqtgraph", "cv2", "orisys", "orisys_blend", "numba", "llvmlite"):
pkg_datas, pkg_binaries, pkg_hidden = collect_all(package)
datas += pkg_datas
binaries += pkg_binaries
hiddenimports += pkg_hidden
hiddenimports += collect_submodules("orisys_blend")
hiddenimports += collect_submodules("sensor_bench")
hiddenimports += collect_submodules("sensor_bench_operator")
excludes = [
"cupy",
"cupyx",
"pyopencl",
"open3d",
"matplotlib",
"tkinter",
"pytest",
"pip",
"wheel",
]
a = Analysis(
[str(repo_root / "internal" / "run_operator.py")],
pathex=[str(repo_root), str(src_dir), str(operator_src)],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=excludes,
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="SensorBenchOperator",
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="SensorBenchOperator",
)
+46
View File
@@ -0,0 +1,46 @@
"""Copy Orisys wheel sensor JSON configs into repo config/ (exclude options.json)."""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
def wheel_config_dir() -> Path:
import orisys
inner_pkg = Path(orisys.__file__).resolve().parent
for candidate in (inner_pkg / "configs", inner_pkg.parent / "configs"):
if candidate.is_dir() and any(candidate.glob("*.json")):
return candidate
return inner_pkg.parent / "configs"
def sync_configs(repo_root: Path) -> int:
src = wheel_config_dir()
if not src.is_dir():
print(f"Orisys wheel configs directory not found: {src}", file=sys.stderr)
return 1
dst = repo_root / "config"
dst.mkdir(parents=True, exist_ok=True)
copied = 0
for path in sorted(src.glob("*.json")):
if path.stem.lower() == "options":
continue
shutil.copy2(path, dst / path.name)
copied += 1
print(f"Synced {copied} config(s) from {src} to {dst}")
return 0
def main() -> int:
repo_root = Path(__file__).resolve().parents[1]
return sync_configs(repo_root)
if __name__ == "__main__":
raise SystemExit(main())
+91 -5
View File
@@ -1,7 +1,8 @@
"""Verify the PyInstaller onedir output has the expected portable layout."""
"""Verify the public PyInstaller onedir output and reject internal artifacts."""
from __future__ import annotations
import json
import sys
from pathlib import Path
@@ -9,23 +10,108 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist" / "SensorBench"
# Paths / substrings that must never appear in a public customer build.
FORBIDDEN_RELATIVE_PATHS = (
"steps",
"steps/default.json",
"config/options.json",
"internal",
)
FORBIDDEN_NAME_FRAGMENTS = (
"sensor_bench_operator",
"sensor_bench.app",
"operator-ui",
)
FORBIDDEN_LOCALE_KEYS = (
"workflow",
)
FORBIDDEN_TEXT_SNIPPETS = (
"Inspection Workflow",
"检测流程",
"新样品",
"冯总",
"operator-ui",
)
def require(path: Path) -> None:
if not path.exists():
raise FileNotFoundError(f"Missing build artifact: {path}")
def forbid(path: Path, *, reason: str) -> None:
if path.exists():
raise RuntimeError(f"Public dist must not include {reason}: {path}")
def _iter_text_files(root: Path) -> list[Path]:
files: list[Path] = []
for path in root.rglob("*"):
if not path.is_file():
continue
if path.suffix.lower() in {".json", ".txt", ".md", ".py", ".toc", ".html"}:
files.append(path)
# PyInstaller may leave .pyc / .py modules under _internal
if path.suffix.lower() == ".pyc" and "operator" in path.name.lower():
files.append(path)
return files
def _assert_no_forbidden_names(root: Path) -> None:
for path in root.rglob("*"):
rel = path.relative_to(root).as_posix().lower()
name = path.name.lower()
for fragment in FORBIDDEN_NAME_FRAGMENTS:
if fragment.lower() in rel or fragment.lower() in name:
raise RuntimeError(
f"Public dist contains forbidden name '{fragment}': {path}"
)
def _assert_locale_public(path: Path) -> None:
data = json.loads(path.read_text(encoding="utf-8"))
for key in FORBIDDEN_LOCALE_KEYS:
if key in data:
raise RuntimeError(f"Public locale {path.name} still contains '{key}'")
status = data.get("status")
if isinstance(status, dict) and "workflow_done" in status:
raise RuntimeError(f"Public locale {path.name} still contains status.workflow_done")
def _assert_no_forbidden_text(root: Path) -> None:
for path in _iter_text_files(root):
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except Exception: # noqa: BLE001
continue
for snippet in FORBIDDEN_TEXT_SNIPPETS:
if snippet in text:
raise RuntimeError(
f"Public dist text contains forbidden snippet {snippet!r} in {path}"
)
def main() -> int:
require(DIST / "SensorBench.exe")
require(DIST / "config" / "options.json")
require(DIST / "config" / "locales" / "zh_CN.json")
require(DIST / "config" / "locales" / "en_US.json")
require(DIST / "steps" / "default.json")
require(DIST / "config" / "ddjx01.json")
require(DIST / "_internal")
print(f"Verified SensorBench dist layout: {DIST}")
for rel in FORBIDDEN_RELATIVE_PATHS:
forbid(DIST / rel, reason=rel)
_assert_no_forbidden_names(DIST)
_assert_locale_public(DIST / "config" / "locales" / "zh_CN.json")
_assert_locale_public(DIST / "config" / "locales" / "en_US.json")
_assert_no_forbidden_text(DIST / "config")
print(f"Verified public SensorBench dist layout: {DIST}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+57
View File
@@ -0,0 +1,57 @@
"""Verify the private SensorBenchOperator onedir layout."""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist" / "SensorBenchOperator"
def require(path: Path) -> None:
if not path.exists():
raise FileNotFoundError(f"Missing operator build artifact: {path}")
def main() -> int:
require(DIST / "SensorBenchOperator.exe")
require(DIST / "_internal")
require(DIST / "steps" / "default.json")
require(DIST / "config" / "options.json")
require(DIST / "config" / "locales" / "zh_CN.json")
require(DIST / "config" / "locales" / "en_US.json")
require(DIST / "config" / "ddjx01.json")
options = json.loads((DIST / "config" / "options.json").read_text(encoding="utf-8"))
if "sensor_types" not in options:
raise RuntimeError("Operator options.json missing sensor_types")
locale = json.loads(
(DIST / "config" / "locales" / "en_US.json").read_text(encoding="utf-8")
)
if "workflow" not in locale:
raise RuntimeError("Operator locale missing workflow section")
if "Inspection Workflow" not in json.dumps(locale, ensure_ascii=False):
# Chinese catalog may be the default UI language; English must still ship.
raise RuntimeError("Operator en_US locale missing Inspection Workflow title")
steps = json.loads((DIST / "steps" / "default.json").read_text(encoding="utf-8"))
if not steps.get("steps"):
raise RuntimeError("Operator steps/default.json has no steps")
# Guard against accidentally packaging the whole internal/ source tree.
if (DIST / "internal").exists():
raise RuntimeError(f"Operator dist should not nest internal/: {DIST / 'internal'}")
if (DIST / "src").exists():
raise RuntimeError(f"Operator dist should not ship raw src/: {DIST / 'src'}")
print(f"Verified internal SensorBenchOperator dist layout: {DIST}")
print("INTERNAL USE ONLY - do not publish to customers.")
return 0
if __name__ == "__main__":
raise SystemExit(main())