diff --git a/.gitignore b/.gitignore index bb07c7f..44092df 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ venv/ /dist/ /build/pyinstaller_work/ +/build/pyinstaller_work_operator/ /recordings/* !/recordings/.gitkeep /tools/ffmpeg/bin/ diff --git a/README.md b/README.md index 78eb6bc..12ab4a5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # Sensor Bench -Sensor Bench 是一款用于触觉传感器测试的 Windows 桌面工具。它基于 Orisys SDK,可预览原始相机画面、显示处理后的传感器图像、录制原始视频,并在各测试步骤中监控 FN / FX / FY 力曲线。 +Sensor Bench 是一款用于触觉传感器**展示**的 Windows 桌面工具。它基于 Orisys SDK,可预览处理后的传感器图像、录制原始视频,并实时显示 FN / FX / FY 力曲线。 + +默认启动为展示端界面(参考 `qt_viewer_2` 布局)。 + +> 内部检测 / 流程引导界面不在本公开包中。实验室内部工具见仓库内私有目录 [`internal/`](internal/README.md)(请勿随客户包发布)。 ## 界面示意 @@ -10,12 +14,22 @@ Sensor Bench 是一款用于触觉传感器测试的 Windows 桌面工具。它 ## 功能 -- 通过左侧流程引导操作员完成检测,支持 **下一步**、**上一步**、**新样品 / 重置**。 -- 从 `config/options.json` 加载可选项:传感器类型、弹性体、CMOS、标记、压头、平台。 -- 默认使用 DDJX01 Orisys 采集:原始画面 `1920x1080 @ 30 FPS`,处理后输出约 `365x365`。 -- 显示原始 `sensor.frame`、处理后的 `Sensor.get_img()`,以及实时力曲线。 -- 将 SDK 原始相机帧录制为 MP4,保存到 `recordings/` 或自定义文件夹。 -- 界面文案存放在 `config/locales/zh_CN.json` 与 `config/locales/en_US.json`。 +展示端: + +- 菜单栏 **帮助 → 关于** 显示 Sensor Bench / Orisys 版本 +- 顶部工具栏:传感器类型(Orisys 拼接 / DD06 ROI)、搜索摄像机、摄像机(视频文件)、配置、启动 / 暂停、重置(传感器追踪器)、录像 +- 默认画面为光流箭头绘制在现有背景色(`#121316`)上;也可切换到采集图像箭头或原始画面 +- 右侧三路力曲线:FN / FX / FY(与 qt_viewer_2 相同的滤波与 Y 轴最小量程) +- 底部日志区,支持 ESC 全屏、S/E 录像、R 重置追踪器 + +通用能力: + +- Orisys / DDJX01:原始画面 `1920x1080 @ 30 FPS`,处理后输出约 `365x365` +- DD06:外置采集 + 多边形 ROI 矫正后送入 `Sensor.get_img(..., already_processed=True)` +- 配置下拉框优先使用程序旁 `config/` 中的传感器 JSON(构建前从 Orisys wheel 同步到仓库 `config/`) +- 展示端可用“搜索”探测本机摄像机,也可用“摄像机”选择视频文件;也可用“配置…”浏览自定义 JSON +- 录制原始相机帧到 `recordings/` +- 界面文案存放在 `config/locales/zh_CN.json` 与 `config/locales/en_US.json` ## 运行环境 @@ -25,17 +39,21 @@ Sensor Bench 是一款用于触觉传感器测试的 Windows 桌面工具。它 - Python 3.10 - 从 PyPI 安装 `orisys`,或在开发时使用本地 `orisys-sdk` 源码 -源码运行时安装依赖: +源码运行: ```powershell cd D:\repo\sensor-bench python -m pip install numpy opencv-python PyQt5 pyqtgraph python -m pip install orisys -$env:PYTHONPATH = "D:\repo\orisys-sdk\src;$env:PYTHONPATH" python run.py ``` -使用 PyPI 包时可不设置 `PYTHONPATH`。若存在同级目录 `D:\repo\orisys-sdk\src`,`run.py` 也会自动检测并加入路径。 +使用本地 SDK 源码开发时: + +```powershell +$env:PYTHONPATH = "D:\repo\orisys-sdk\src;$env:PYTHONPATH" +python run.py +``` 运行冒烟测试: @@ -43,9 +61,15 @@ python run.py python tests/test_smoke.py ``` +内部操作员界面(私有,勿发布): + +```powershell +python internal/run_operator.py +``` + ## 打包 EXE -发布包使用 PyInstaller `onedir` 模式,并依赖 Python 3.10 的 conda 环境。 +发布包使用 PyInstaller `onedir` 模式,并依赖 Python 3.10 的 conda 环境。构建时从 PyPI 安装 `orisys`。 1. 创建构建环境: @@ -53,16 +77,30 @@ python tests/test_smoke.py .\build\create_env.bat ``` -2. 构建可执行文件: +2. 构建**公开**展示端: ```bat .\build\build_exe.bat ``` -输出路径: +输出: ```text dist/SensorBench/SensorBench.exe ``` -打包结果包含 `config/`、`config/locales/` 和 `steps/`,操作员无需改代码即可编辑选项、翻译文案和工作流程。 +公开构建**仅**包含展示端,不包含 `internal/`、`steps/` 或操作员流程文案。 + +3. 构建**内部**操作员端(勿发给客户): + +```bat +.\build\build_operator_exe.bat +``` + +输出: + +```text +dist/SensorBenchOperator/SensorBenchOperator.exe +``` + +内部包会附带 `steps/`、`config/options.json` 与完整操作员文案,可直接编辑。 diff --git a/build/README.md b/build/README.md index 58d25b9..f36b573 100644 --- a/build/README.md +++ b/build/README.md @@ -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). diff --git a/build/build_exe.ps1 b/build/build_exe.ps1 index ada20b6..adb3ca7 100644 --- a/build/build_exe.ps1 +++ b/build/build_exe.ps1 @@ -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 } - diff --git a/build/build_operator_exe.bat b/build/build_operator_exe.bat new file mode 100644 index 0000000..3a16d99 --- /dev/null +++ b/build/build_operator_exe.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0.." +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_operator_exe.ps1" %* diff --git a/build/build_operator_exe.ps1 b/build/build_operator_exe.ps1 new file mode 100644 index 0000000..a26986a --- /dev/null +++ b/build/build_operator_exe.ps1 @@ -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 +} diff --git a/build/sensor_bench.spec b/build/sensor_bench.spec index 9a6e13b..f81477f 100644 --- a/build/sensor_bench.spec +++ b/build/sensor_bench.spec @@ -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", ) - diff --git a/build/sensor_bench_operator.spec b/build/sensor_bench_operator.spec new file mode 100644 index 0000000..f0ce977 --- /dev/null +++ b/build/sensor_bench_operator.spec @@ -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", +) diff --git a/build/sync_wheel_configs.py b/build/sync_wheel_configs.py new file mode 100644 index 0000000..22b2a08 --- /dev/null +++ b/build/sync_wheel_configs.py @@ -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()) diff --git a/build/verify_dist.py b/build/verify_dist.py index 17e68eb..3f57417 100644 --- a/build/verify_dist.py +++ b/build/verify_dist.py @@ -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()) - diff --git a/build/verify_operator_dist.py b/build/verify_operator_dist.py new file mode 100644 index 0000000..1edef59 --- /dev/null +++ b/build/verify_operator_dist.py @@ -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()) diff --git a/config/X-ov.json b/config/X-ov.json new file mode 100644 index 0000000..4c966cd --- /dev/null +++ b/config/X-ov.json @@ -0,0 +1,101 @@ +{ + "name": "dd06-ov", + "type": "single_camera", + "version": "1.0", + "camera": { + "resolution_raw": [ + 1280, + 720 + ], + "fps": 30, + "resolution_stitch": [ + 195, + 275 + ], + "source_scale": 0.4, + "old": [ + 672, + 728, + 1, + 0.5 + ], + "work": [ + 504, + 546, + 1 + ], + "work2": [ + 420, + 450, + 0.8 + ], + "work3": [ + 350, + 375, + 4, + 0.7 + ], + "work4": [ + 280, + 300, + 4, + 0.4, + 0.2 + ] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": [ + "raft", + "dis" + ], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold": 0.2, + "div_threshold": 0.022, + "expansion_threshold": 0.01, + "kernel_size": [ + 5, + 5 + ], + "denoise_sigma": 12 + }, + "roi": { + "type": "polygon", + "image_size": [ + 1280, + 720 + ], + "created_at": "2026-07-26T11:56:15", + "points": [ + [ + 242, + 1 + ], + [ + 402, + 597 + ], + [ + 799, + 606 + ], + [ + 976, + 2 + ] + ], + "mask_path": "dd06-ov_mask.png", + "metadata": { + "source": "./data/dd06/0726.mp4" + } + } +} diff --git a/config/bench_theme.json b/config/bench_theme.json new file mode 100644 index 0000000..8a8e42a --- /dev/null +++ b/config/bench_theme.json @@ -0,0 +1,20 @@ +{ + "colors": { + "window_bg": "#1e1f22", + "panel_bg": "#121316", + "arrow_canvas_hex": "#121316" + }, + "arrow_draw": { + "threshold": 2, + "grid_spacing": 20, + "arrow_scale": 0.5, + "below_threshold_color_bgr": [160, 160, 160] + }, + "plot": { + "background": "#121316", + "curve_colors": ["#8ab4f8", "#34a853", "#fbbc04"] + }, + "mesh": { + "background_rgb": [18, 19, 22] + } +} diff --git a/config/box.json b/config/box.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/config/box.json @@ -0,0 +1,46 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1920, 1080], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [365, 365], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.05, + "max_aspect_ratio": 50, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 100, + "n": [3,3], + "row_tol": 0.6, + "box_size": [250, 250], + "blend":"numba", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":5, + "div_threshold":0.05, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 12 + } +} + + diff --git a/config/compound_eye.png b/config/compound_eye.png index 87e0c4c..4d2160a 100644 Binary files a/config/compound_eye.png and b/config/compound_eye.png differ diff --git a/config/dd02-ov.json b/config/dd02-ov.json new file mode 100644 index 0000000..545a74a --- /dev/null +++ b/config/dd02-ov.json @@ -0,0 +1,35 @@ +{ + "name": "dd02-ov", + "type": "single_camera", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 30, + "resolution_stitch": [420, 450], + "source_scale": 0.4, + "old":[672, 728, 1,0.5], + "work": [504, 546,1], + "work2": [420, 450,0.8], + "work3": [350, 375,4,0.7], + "work4": [280, 300,4,0.4,0.2] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 5 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold": 0.2, + "div_threshold": 0.022, + "expansion_threshold": 0.01, + "kernel_size": [5, 5], + "denoise_sigma": 12 + } +} diff --git a/config/dd06-ov-0810.json b/config/dd06-ov-0810.json new file mode 100644 index 0000000..209d274 --- /dev/null +++ b/config/dd06-ov-0810.json @@ -0,0 +1,101 @@ +{ + "name": "dd06-ov", + "type": "single_camera", + "version": "1.0", + "camera": { + "resolution_raw": [ + 1280, + 720 + ], + "fps": 30, + "resolution_stitch": [ + 195, + 275 + ], + "source_scale": 0.4, + "old": [ + 672, + 728, + 1, + 0.5 + ], + "work": [ + 504, + 546, + 1 + ], + "work2": [ + 420, + 450, + 0.8 + ], + "work3": [ + 350, + 375, + 4, + 0.7 + ], + "work4": [ + 280, + 300, + 4, + 0.4, + 0.2 + ] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": [ + "raft", + "dis" + ], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold": 0.2, + "div_threshold": 0.022, + "expansion_threshold": 0.01, + "kernel_size": [ + 5, + 5 + ], + "denoise_sigma": 12 + }, + "roi": { + "type": "polygon", + "image_size": [ + 1280, + 720 + ], + "points": [ + [ + 628, + 532 + ], + [ + 1145, + 694 + ], + [ + 1161, + 8 + ], + [ + 630, + 126 + ] + ], + "created_at": "2026-08-10T14:26:31", + "mask_path": "dd06-ov-0810_mask.png", + "metadata": { + "source": ".\\data\\dd06\\2026-08-10-142316.webm" + } + } +} diff --git a/config/dd06-ov.json b/config/dd06-ov.json new file mode 100644 index 0000000..4c966cd --- /dev/null +++ b/config/dd06-ov.json @@ -0,0 +1,101 @@ +{ + "name": "dd06-ov", + "type": "single_camera", + "version": "1.0", + "camera": { + "resolution_raw": [ + 1280, + 720 + ], + "fps": 30, + "resolution_stitch": [ + 195, + 275 + ], + "source_scale": 0.4, + "old": [ + 672, + 728, + 1, + 0.5 + ], + "work": [ + 504, + 546, + 1 + ], + "work2": [ + 420, + 450, + 0.8 + ], + "work3": [ + 350, + 375, + 4, + 0.7 + ], + "work4": [ + 280, + 300, + 4, + 0.4, + 0.2 + ] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": [ + "raft", + "dis" + ], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold": 0.2, + "div_threshold": 0.022, + "expansion_threshold": 0.01, + "kernel_size": [ + 5, + 5 + ], + "denoise_sigma": 12 + }, + "roi": { + "type": "polygon", + "image_size": [ + 1280, + 720 + ], + "created_at": "2026-07-26T11:56:15", + "points": [ + [ + 242, + 1 + ], + [ + 402, + 597 + ], + [ + 799, + 606 + ], + [ + 976, + 2 + ] + ], + "mask_path": "dd06-ov_mask.png", + "metadata": { + "source": "./data/dd06/0726.mp4" + } + } +} diff --git a/config/ddjx01_480p.json b/config/ddjx01_480p.json new file mode 100644 index 0000000..e58f998 --- /dev/null +++ b/config/ddjx01_480p.json @@ -0,0 +1,46 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [854, 480], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [123, 123], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.08, + "max_aspect_ratio": 100, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 4, + "nominal_ov_pos": 44, + "n": [3,3], + "row_tol": 0.6, + "box_size": [98, 98], + "blend":"numpy", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 2, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":1, + "div_threshold":0.02, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 6 + } +} + + diff --git a/config/ddjx01_q6a.json b/config/ddjx01_q6a.json new file mode 100644 index 0000000..08c7f59 --- /dev/null +++ b/config/ddjx01_q6a.json @@ -0,0 +1,48 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [263, 263], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.04, + "max_aspect_ratio": 25, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 67, + "n": [3,3], + "row_tol": 0.6, + "box_size": [180, 180], + "blend":"numpy", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 1, + "backend": "cpu" + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":1, + "div_threshold":0.02, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 10 + } + } + + + \ No newline at end of file diff --git a/config/ddjx01_wuxi.json b/config/ddjx01_wuxi.json new file mode 100644 index 0000000..cbbe434 --- /dev/null +++ b/config/ddjx01_wuxi.json @@ -0,0 +1,47 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 15, + "units": 15, + "units_used": 9, + "resolution_stitch": [263, 263], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.04, + "max_aspect_ratio": 25, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 67, + "n": [3,3], + "row_tol": 0.6, + "box_size": [180, 180], + "blend":"numpy", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3, + "backend": "cpu" + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":1, + "div_threshold":0.02, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 10 + } +} + + diff --git a/config/finger.json b/config/finger.json new file mode 100644 index 0000000..d5f6264 --- /dev/null +++ b/config/finger.json @@ -0,0 +1,35 @@ +{ + "name": "dd02-ov", + "type": "single_camera", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 30, + "resolution_stitch": [280, 300], + "source_scale": 0.4, + "old":[672, 728, 1,0.5], + "work": [504, 546,1], + "work2": [420, 450,0.8], + "work3": [350, 375,4,0.7], + "work4": [280, 300,4,0.4,0.2] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 1, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 5 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold": 0.2, + "div_threshold": 0.022, + "expansion_threshold": 0.01, + "kernel_size": [5, 5], + "denoise_sigma": 12 + } +} diff --git a/config/locales/en_US.json b/config/locales/en_US.json index 29bf5ec..d876fa1 100644 --- a/config/locales/en_US.json +++ b/config/locales/en_US.json @@ -5,65 +5,26 @@ "lang_zh": "中文", "lang_en": "English" }, - "panels": { - "instructions": "Instructions", - "options": "Sensor Component Types", - "camera": "Camera (Orisys SDK)", - "recording": "Recording", - "raw": "Raw Image (sensor.frame)", - "processed": "Processed Image (Sensor.get_img)", - "force": "Force Curves" - }, "labels": { - "sensor_type": "Sensor type", - "elastomer": "Elastomer", - "cmos": "CMOS", - "marker": "Marker", - "indentor": "Indenter", - "platform": "Platform", - "camera_index": "Camera index", - "backend": "Backend", "fixed_mode": "Fixed mode: {mode}", "fixed_mode_unset": "Fixed mode: set config path", "fixed_mode_invalid": "Fixed mode: invalid config ({error})", - "requested": "Requested", - "actual": "Actual", - "capture_fps": "Capture FPS", - "render_fps": "Render FPS", - "proc_ms": "Proc ms", - "fn": "FN", - "fx": "FX", - "fy": "FY", - "no_preview": "No preview", - "processed_placeholder": "Start Preview to show Orisys output", - "not_recording": "Not recording", - "force_plot": "Force" + "no_preview": "No preview" }, "buttons": { "config": "Config…", - "cal": "Cal…", - "start_preview": "Start Preview", - "stop_preview": "Stop Preview", - "browse": "Browse…", "start_record": "Start Record", - "stop_record": "Stop Record", - "next": "Next", - "revert": "Revert", - "reset": "New Sample / Reset" + "stop_record": "Stop Record" }, "placeholders": { - "config": "DDJX01 config JSON path or name", - "cal": "Optional calibration .npy" + "config": "Select an installed Orisys config, or browse a JSON file" }, "dialogs": { "select_config": "Select config JSON", - "select_cal": "Select calibration", - "select_recordings": "Select recordings folder", "orisys_title": "Orisys", "orisys_required": "orisys SDK is required for preview.\n\n{detail}", "config_title": "Config", "config_required": "Select a DDJX01 config JSON first.", - "config_invalid": "Config could not be loaded.", "capture_title": "Capture", "recording_title": "Recording", "no_frame": "No frame yet." @@ -76,48 +37,47 @@ "preview_stopped": "Preview stopped.", "capture_error": "Capture error: {message}", "frame_info": "{raw_w}x{raw_h} raw / {proc_w}x{proc_h} processed @ {fps:.1f} FPS", - "recording": "Recording… {name}\nOptions: sensor={sensor_type}, elastomer={elastomer}, cmos={cmos}, marker={marker}, indenter={indentor}, platform={platform}", - "recording_frames": "Recording… {name}\nFrames: {count}", - "saved": "Saved {count} frames → {path}", - "workflow_done": "All steps completed. Click New Sample / Reset for the next unit." + "saved": "Saved {count} frames → {path}" }, - "workflow": { - "title": "Sensor Bench Inspection Workflow", - "current_marker": "Current", - "done_marker": "Done", - "steps": { - "power_on": { - "title": "Power on and connect", - "body": "Connect the sensor USB cable and wait until Windows recognizes the device." - }, - "select_camera": { - "title": "Start Orisys preview", - "body": "Confirm camera index (usually 0) and DDJX01 config, then click Start Preview. Format is fixed by the Orisys config (1920x1080 @ 30)." - }, - "set_options": { - "title": "Set sensor options", - "body": "Choose elastomer type, CMOS type, and marker type that match the physical unit under test." - }, - "check_preview": { - "title": "Check raw and processed preview", - "body": "Confirm raw frame is ~1920x1080 and processed Orisys image is ~365x365. Inspect focus, lighting, and image cleanliness." - }, - "record": { - "title": "Record raw data", - "body": "Choose a storage folder, start recording, exercise the sensor, then stop recording." - }, - "review_curves": { - "title": "Review performance curves", - "body": "Watch capture/render FPS and force curves for drops or unstable exposure before signing off." - }, - "sensitivity": { - "title": "Sensitivity test", - "body": "Choose a small indenter and apply a 2g weight, then watch for a clear force-curve change." - }, - "max_load": { - "title": "Maximum load test", - "body": "Choose a flat plate indenter and apply weights from 10g to 500g to watch for curve changes. Use 10g to 500g for MINI and 2kg for BOX." - } - } + "exhibition": { + "title": "Sensor Bench Exhibition", + "btn_search": "Search", + "btn_camera": "Camera", + "btn_config": "Config", + "btn_start": "Start", + "btn_starting": "Starting…", + "btn_stop": "Pause", + "btn_reset": "Reset", + "btn_start_record": "Start Record", + "btn_stop_record": "Stop Record", + "show_log": "Show log panel", + "view": "View", + "view_pure_color": "Arrows on solid bg", + "view_captured": "Arrows on captured", + "view_processed": "Processed image", + "view_raw": "Raw image", + "sensor_type": "Sensor", + "sensor_orisys": "ORY-BOX/ORY-MINI", + "sensor_dd06": "ORY-X", + "sensor_switched": "Switched sensor type: {sensor}", + "placeholder_camera": "Camera index or video path", + "dialog_video": "Select video file", + "camera_found": "Found {count} camera(s): {names}", + "camera_none": "No usable camera found. Click Search to rescan, or use Camera to pick a video file.", + "plot_fn": "Normal Force FN", + "plot_fx": "Shear Force FX", + "plot_fy": "Shear Force FY", + "reset_done": "Tracker reset.", + "reset_need_start": "Start the sensor before resetting the tracker.", + "reset_failed": "Reset failed: {message}", + "force_fn_n": "Normal force (N)={value:.6f}", + "recording_started": "Recording started: {name}", + "hints": "Keys: Q quit | R reset tracker | S start record | E stop record | ESC fullscreen", + "menu_help": "Help", + "menu_about": "About", + "about_title": "About Sensor Bench", + "about_body": "Sensor Bench Exhibition\nVersion: {app_version}\nOrisys: {orisys_version}\n\nOrisys-Tech", + "config_dir_tip": "Installed configs dir: {path}\nSelected: {selected}", + "config_dir_missing": "Installed Orisys configs directory not found. Use Config… to browse a local JSON." } } diff --git a/config/locales/zh_CN.json b/config/locales/zh_CN.json index bd7e432..42da359 100644 --- a/config/locales/zh_CN.json +++ b/config/locales/zh_CN.json @@ -5,65 +5,26 @@ "lang_zh": "中文", "lang_en": "English" }, - "panels": { - "instructions": "操作步骤", - "options": "传感器组件类型", - "camera": "相机(Orisys SDK)", - "recording": "录制", - "raw": "原始图像(sensor.frame)", - "processed": "处理后图像(Sensor.get_img)", - "force": "力曲线" - }, "labels": { - "sensor_type": "传感器类型", - "elastomer": "弹性体", - "cmos": "CMOS", - "marker": "图案", - "indentor": "压头", - "platform": "平台", - "camera_index": "相机索引", - "backend": "后端", "fixed_mode": "固定模式:{mode}", "fixed_mode_unset": "固定模式:请设置配置路径", "fixed_mode_invalid": "固定模式:配置无效({error})", - "requested": "请求", - "actual": "实际", - "capture_fps": "采集 FPS", - "render_fps": "渲染 FPS", - "proc_ms": "处理 ms", - "fn": "FN", - "fx": "FX", - "fy": "FY", - "no_preview": "暂无预览", - "processed_placeholder": "点击开始预览以显示 Orisys 输出", - "not_recording": "未在录制", - "force_plot": "力" + "no_preview": "暂无预览" }, "buttons": { "config": "配置…", - "cal": "标定…", - "start_preview": "开始预览", - "stop_preview": "停止预览", - "browse": "浏览…", "start_record": "开始录制", - "stop_record": "停止录制", - "next": "下一步", - "revert": "上一步", - "reset": "新样品 / 重置" + "stop_record": "停止录制" }, "placeholders": { - "config": "DDJX01 配置 JSON 路径或名称", - "cal": "可选标定文件 .npy" + "config": "从已安装 Orisys 配置中选择,或浏览 JSON" }, "dialogs": { "select_config": "选择配置 JSON", - "select_cal": "选择标定文件", - "select_recordings": "选择录制文件夹", "orisys_title": "Orisys", "orisys_required": "预览需要 Orisys SDK。\n\n{detail}", "config_title": "配置", "config_required": "请先选择 DDJX01 配置 JSON。", - "config_invalid": "无法加载配置。", "capture_title": "采集", "recording_title": "录制", "no_frame": "尚无可用帧。" @@ -76,48 +37,47 @@ "preview_stopped": "预览已停止。", "capture_error": "采集错误:{message}", "frame_info": "{raw_w}x{raw_h} 原始 / {proc_w}x{proc_h} 处理 @ {fps:.1f} FPS", - "recording": "正在录制… {name}\n选项:传感器={sensor_type},弹性体={elastomer},CMOS={cmos},标记={marker},压头={indentor},平台={platform}", - "recording_frames": "正在录制… {name}\n帧数:{count}", - "saved": "已保存 {count} 帧 → {path}", - "workflow_done": "全部步骤已完成。可点击“新样品 / 重置”开始下一个样品。" + "saved": "已保存 {count} 帧 → {path}" }, - "workflow": { - "title": "传感器检测流程", - "current_marker": "当前", - "done_marker": "已完成", - "steps": { - "power_on": { - "title": "开机并连接", - "body": "连接传感器 USB 线缆,等待 Windows 识别设备。" - }, - "select_camera": { - "title": "启动 Orisys 预览", - "body": "确认相机索引(通常为 0)和 DDJX01 配置,然后点击开始预览。格式由 Orisys 配置固定(1920x1080 @ 30)。" - }, - "set_options": { - "title": "设置传感器选项", - "body": "选择与当前被测实物一致的弹性体类型、CMOS 类型和标记类型。" - }, - "check_preview": { - "title": "检查原始与处理后预览", - "body": "确认原始画面约为 1920x1080,处理后 Orisys 图像约为 365x365。检查对焦、光照和画面清洁度。" - }, - "record": { - "title": "录制原始数据", - "body": "选择存储文件夹,开始录制,操作传感器,然后停止录制。" - }, - "review_curves": { - "title": "查看性能曲线", - "body": "观察采集/渲染 FPS 与力曲线,确认无明显掉帧或曝光不稳定后再签字确认。" - }, - "sensitivity": { - "title": "灵敏度测试", - "body": "选择小压头,并加载 2g 砝码,观察力曲线是否有清晰变化。" - }, - "max_load": { - "title": "最大载荷测试", - "body": "选择平板压头,并加载 10g 到 500g 的砝码观察曲线变化;MINI 使用 10g 到 500g,BOX 使用 2kg。" - } - } + "exhibition": { + "title": "Sensor Bench 展示端", + "btn_search": "搜索", + "btn_camera": "摄像机", + "btn_config": "配置", + "btn_start": "启动", + "btn_starting": "启动中…", + "btn_stop": "暂停", + "btn_reset": "重置", + "btn_start_record": "开始录像", + "btn_stop_record": "结束录像", + "show_log": "显示日志面板", + "view": "画面", + "view_pure_color": "纯色背景箭头", + "view_captured": "采集图像箭头", + "view_processed": "处理后图像", + "view_raw": "原始图像", + "sensor_type": "传感器", + "sensor_orisys": "ORY-BOX/ORY-MINI", + "sensor_dd06": "ORY-X", + "sensor_switched": "已切换传感器类型:{sensor}", + "placeholder_camera": "摄像头编号或视频路径", + "dialog_video": "选择视频文件", + "camera_found": "已发现 {count} 路摄像机:{names}", + "camera_none": "未发现可用摄像机。可点击“搜索”重扫,或通过“摄像机”选择视频文件。", + "plot_fn": "法向力 FN", + "plot_fx": "切向力 FX", + "plot_fy": "切向力 FY", + "reset_done": "追踪器已重置。", + "reset_need_start": "请先启动传感器后再重置追踪器。", + "reset_failed": "重置失败:{message}", + "force_fn_n": "法向力(N)={value:.6f}", + "recording_started": "开始录像:{name}", + "hints": "按键:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏", + "menu_help": "帮助", + "menu_about": "关于", + "about_title": "关于 Sensor Bench", + "about_body": "Sensor Bench 展示端\n版本:{app_version}\nOrisys:{orisys_version}\n\nOrisys-Tech", + "config_dir_tip": "已安装配置目录:{path}\n当前:{selected}", + "config_dir_missing": "未找到已安装的 Orisys 配置目录。可用“配置…”浏览本地 JSON。" } } diff --git a/config/mini-fast.json b/config/mini-fast.json new file mode 100644 index 0000000..cc5868f --- /dev/null +++ b/config/mini-fast.json @@ -0,0 +1,47 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [263, 263], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.04, + "max_aspect_ratio": 25, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 67, + "n": [3,3], + "row_tol": 0.6, + "box_size": [180, 180], + "blend":"numpy", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":1, + "div_threshold":0.02, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 10 + } + } + + + \ No newline at end of file diff --git a/config/mini.json b/config/mini.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/config/mini.json @@ -0,0 +1,46 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1920, 1080], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [365, 365], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.05, + "max_aspect_ratio": 50, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 100, + "n": [3,3], + "row_tol": 0.6, + "box_size": [250, 250], + "blend":"numba", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":5, + "div_threshold":0.05, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 12 + } +} + + diff --git a/config/prototype_of3.json b/config/prototype_of3.json new file mode 100644 index 0000000..8d94471 --- /dev/null +++ b/config/prototype_of3.json @@ -0,0 +1,44 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1920, 1080], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [365, 365], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.04, + "max_aspect_ratio": 100, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 100, + "n": [3,3], + "row_tol": 0.6, + "box_size": [220, 220] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":1, + "div_threshold":0.02, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 15 + } +} + + diff --git a/config/prototype_of3_480p.json b/config/prototype_of3_480p.json new file mode 100644 index 0000000..5c321a3 --- /dev/null +++ b/config/prototype_of3_480p.json @@ -0,0 +1,44 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [854, 480], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [123, 123], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.06, + "max_aspect_ratio": 100, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 4, + "nominal_ov_pos": 44, + "n": [3,3], + "row_tol": 0.6, + "box_size": [98, 98] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 2, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":1, + "div_threshold":0.02, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 6 + } +} + + diff --git a/config/prototype_of3_720p.json b/config/prototype_of3_720p.json new file mode 100644 index 0000000..185ccbf --- /dev/null +++ b/config/prototype_of3_720p.json @@ -0,0 +1,44 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [263, 263], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.04, + "max_aspect_ratio": 25, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 67, + "n": [3,3], + "row_tol": 0.6, + "box_size": [180, 180] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 2, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":1, + "div_threshold":0.02, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 10 + } +} + + diff --git a/config/prototype_raft.json b/config/prototype_raft.json new file mode 100644 index 0000000..480995b --- /dev/null +++ b/config/prototype_raft.json @@ -0,0 +1,43 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1920, 1080], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [384, 384] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.04, + "max_aspect_ratio": 100, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 100, + "n": [3,3], + "row_tol": 0.6, + "box_size": [220, 220] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "raft", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":1.5, + "div_threshold":0.08, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 15 + } +} + + diff --git a/internal/README.md b/internal/README.md new file mode 100644 index 0000000..9f6fa68 --- /dev/null +++ b/internal/README.md @@ -0,0 +1,57 @@ +# Sensor Bench Operator (Internal) + +Private operator / inspection workflow UI. **Do not publish** this folder or its +resources in customer EXEs, public docs, or release zips. + +## What lives here + +- Workflow UI (`sensor_bench_operator.app.MainWindow`) +- Inspection steps (`steps/default.json`) +- Lab component options (`config/options.json`, including Chinese lab labels) +- Operator locale catalogs (`config/locales/`) + +The public exhibition app lives under the repository root (`run.py`, +`src/sensor_bench/exhibition_app.py`) and must not import this package. + +## Run (source) + +From the repository root: + +```powershell +python internal/run_operator.py +``` + +Or: + +```bat +.\internal\run_operator.bat +``` + +Requires the public `sensor-bench` package sources on `PYTHONPATH` (the launcher +adds `src/` and `internal/src/` automatically) and an importable `orisys`. + +## Build private EXE + +Uses the same conda env as the public build (`sensor-bench-build-py310`): + +```bat +.\build\create_env.bat +.\build\build_operator_exe.bat +``` + +Output (internal only — do not ship to customers): + +```text +dist/SensorBenchOperator/SensorBenchOperator.exe +``` + +The build copies public sensor configs plus operator `options.json`, locales, and +`steps/` beside the EXE. See [`../build/README.md`](../build/README.md). + +## Relationship to public Sensor Bench + +```text +orisys (public SDK) + ├── sensor-bench → dist/SensorBench/ (public exhibition EXE) + └── internal/ → dist/SensorBenchOperator/ (private operator EXE) +``` diff --git a/internal/config/locales/en_US.json b/internal/config/locales/en_US.json new file mode 100644 index 0000000..2097c0d --- /dev/null +++ b/internal/config/locales/en_US.json @@ -0,0 +1,166 @@ +{ + "app": { + "title": "Sensor Bench", + "language": "Language", + "lang_zh": "中文", + "lang_en": "English" + }, + "panels": { + "instructions": "Instructions", + "options": "Sensor Component Types", + "camera": "Camera (Orisys SDK)", + "recording": "Recording", + "raw": "Raw Image (sensor.frame)", + "processed": "Processed Image (Sensor.get_img)", + "force": "Force Curves" + }, + "labels": { + "sensor_type": "Sensor type", + "elastomer": "Elastomer", + "cmos": "CMOS", + "marker": "Marker", + "indentor": "Indenter", + "platform": "Platform", + "camera_index": "Camera index", + "backend": "Backend", + "fixed_mode": "Fixed mode: {mode}", + "fixed_mode_unset": "Fixed mode: set config path", + "fixed_mode_invalid": "Fixed mode: invalid config ({error})", + "requested": "Requested", + "actual": "Actual", + "capture_fps": "Capture FPS", + "render_fps": "Render FPS", + "proc_ms": "Proc ms", + "fn": "FN", + "fx": "FX", + "fy": "FY", + "no_preview": "No preview", + "processed_placeholder": "Start Preview to show Orisys output", + "not_recording": "Not recording", + "force_plot": "Force", + "config_dir_tip": "Installed configs dir: {path}\nSelected: {selected}", + "config_dir_missing": "Installed Orisys configs directory not found. Use Config… to browse a local JSON." + }, + "buttons": { + "config": "Config…", + "cal": "Cal…", + "start_preview": "Start Preview", + "stop_preview": "Stop Preview", + "browse": "Browse…", + "start_record": "Start Record", + "stop_record": "Stop Record", + "next": "Next", + "revert": "Revert", + "reset": "New Sample / Reset" + }, + "placeholders": { + "config": "Select an installed Orisys config, or browse a JSON file", + "cal": "Optional calibration .npy" + }, + "dialogs": { + "select_config": "Select config JSON", + "select_cal": "Select calibration", + "select_recordings": "Select recordings folder", + "orisys_title": "Orisys", + "orisys_required": "orisys SDK is required for preview.\n\n{detail}", + "config_title": "Config", + "config_required": "Select a DDJX01 config JSON first.", + "config_invalid": "Config could not be loaded.", + "capture_title": "Capture", + "recording_title": "Recording", + "no_frame": "No frame yet." + }, + "status": { + "ready": "Ready — Start Preview uses Orisys DDJX01 fixed format.", + "orisys_ok": "orisys available (version {version})", + "orisys_missing": "orisys not importable — add orisys-sdk/src to PYTHONPATH.", + "opening": "Opening Orisys ({mode})…", + "preview_stopped": "Preview stopped.", + "capture_error": "Capture error: {message}", + "frame_info": "{raw_w}x{raw_h} raw / {proc_w}x{proc_h} processed @ {fps:.1f} FPS", + "recording": "Recording… {name}\nOptions: sensor={sensor_type}, elastomer={elastomer}, cmos={cmos}, marker={marker}, indenter={indentor}, platform={platform}", + "recording_frames": "Recording… {name}\nFrames: {count}", + "saved": "Saved {count} frames → {path}", + "workflow_done": "All steps completed. Click New Sample / Reset for the next unit." + }, + "exhibition": { + "title": "Sensor Bench Exhibition", + "btn_search": "Search", + "btn_camera": "Camera", + "btn_config": "Config", + "btn_start": "Start", + "btn_starting": "Starting…", + "btn_stop": "Pause", + "btn_reset": "Reset", + "btn_start_record": "Start Record", + "btn_stop_record": "Stop Record", + "show_log": "Show log panel", + "view": "View", + "view_pure_color": "Arrows on solid bg", + "view_captured": "Arrows on captured", + "view_processed": "Processed image", + "view_raw": "Raw image", + "sensor_type": "Sensor", + "sensor_orisys": "ORY-BOX/ORY-MINI", + "sensor_dd06": "ORY-X", + "sensor_switched": "Switched sensor type: {sensor}", + "placeholder_camera": "Camera index or video path", + "dialog_video": "Select video file", + "camera_found": "Found {count} camera(s): {names}", + "camera_none": "No usable camera found. Click Search to rescan, or use Camera to pick a video file.", + "plot_fn": "Normal Force FN", + "plot_fx": "Shear Force FX", + "plot_fy": "Shear Force FY", + "reset_done": "Tracker reset.", + "reset_need_start": "Start the sensor before resetting the tracker.", + "reset_failed": "Reset failed: {message}", + "force_fn_n": "Normal force (N)={value:.6f}", + "recording_started": "Recording started: {name}", + "hints": "Keys: Q quit | R reset tracker | S start record | E stop record | ESC fullscreen", + "menu_help": "Help", + "menu_about": "About", + "about_title": "About Sensor Bench", + "about_body": "Sensor Bench Exhibition\nVersion: {app_version}\nOrisys: {orisys_version}\n\nOrisys-Tech", + "config_dir_tip": "Installed configs dir: {path}\nSelected: {selected}", + "config_dir_missing": "Installed Orisys configs directory not found. Use Config… to browse a local JSON." + }, + "workflow": { + "title": "Sensor Bench Inspection Workflow", + "current_marker": "Current", + "done_marker": "Done", + "steps": { + "power_on": { + "title": "Power on and connect", + "body": "Connect the sensor USB cable and wait until Windows recognizes the device." + }, + "select_camera": { + "title": "Start Orisys preview", + "body": "Confirm camera index (usually 0) and DDJX01 config, then click Start Preview. Format is fixed by the Orisys config (1920x1080 @ 30)." + }, + "set_options": { + "title": "Set sensor options", + "body": "Choose elastomer type, CMOS type, and marker type that match the physical unit under test." + }, + "check_preview": { + "title": "Check raw and processed preview", + "body": "Confirm raw frame is ~1920x1080 and processed Orisys image is ~365x365. Inspect focus, lighting, and image cleanliness." + }, + "record": { + "title": "Record raw data", + "body": "Choose a storage folder, start recording, exercise the sensor, then stop recording." + }, + "review_curves": { + "title": "Review performance curves", + "body": "Watch capture/render FPS and force curves for drops or unstable exposure before signing off." + }, + "sensitivity": { + "title": "Sensitivity test", + "body": "Choose a small indenter and apply a 2g weight, then watch for a clear force-curve change." + }, + "max_load": { + "title": "Maximum load test", + "body": "Choose a flat plate indenter and apply weights from 10g to 500g to watch for curve changes. Use 10g to 500g for MINI and 2kg for BOX." + } + } + } +} diff --git a/internal/config/locales/zh_CN.json b/internal/config/locales/zh_CN.json new file mode 100644 index 0000000..3db589c --- /dev/null +++ b/internal/config/locales/zh_CN.json @@ -0,0 +1,166 @@ +{ + "app": { + "title": "传感器检测台", + "language": "语言", + "lang_zh": "中文", + "lang_en": "English" + }, + "panels": { + "instructions": "操作步骤", + "options": "传感器组件类型", + "camera": "相机(Orisys SDK)", + "recording": "录制", + "raw": "原始图像(sensor.frame)", + "processed": "处理后图像(Sensor.get_img)", + "force": "力曲线" + }, + "labels": { + "sensor_type": "传感器类型", + "elastomer": "弹性体", + "cmos": "CMOS", + "marker": "图案", + "indentor": "压头", + "platform": "平台", + "camera_index": "相机索引", + "backend": "后端", + "fixed_mode": "固定模式:{mode}", + "fixed_mode_unset": "固定模式:请设置配置路径", + "fixed_mode_invalid": "固定模式:配置无效({error})", + "requested": "请求", + "actual": "实际", + "capture_fps": "采集 FPS", + "render_fps": "渲染 FPS", + "proc_ms": "处理 ms", + "fn": "FN", + "fx": "FX", + "fy": "FY", + "no_preview": "暂无预览", + "processed_placeholder": "点击开始预览以显示 Orisys 输出", + "not_recording": "未在录制", + "force_plot": "力", + "config_dir_tip": "已安装配置目录:{path}\n当前:{selected}", + "config_dir_missing": "未找到已安装的 Orisys 配置目录。可用“配置…”浏览本地 JSON。" + }, + "buttons": { + "config": "配置…", + "cal": "标定…", + "start_preview": "开始预览", + "stop_preview": "停止预览", + "browse": "浏览…", + "start_record": "开始录制", + "stop_record": "停止录制", + "next": "下一步", + "revert": "上一步", + "reset": "新样品 / 重置" + }, + "placeholders": { + "config": "从已安装 Orisys 配置中选择,或浏览 JSON", + "cal": "可选标定文件 .npy" + }, + "dialogs": { + "select_config": "选择配置 JSON", + "select_cal": "选择标定文件", + "select_recordings": "选择录制文件夹", + "orisys_title": "Orisys", + "orisys_required": "预览需要 Orisys SDK。\n\n{detail}", + "config_title": "配置", + "config_required": "请先选择 DDJX01 配置 JSON。", + "config_invalid": "无法加载配置。", + "capture_title": "采集", + "recording_title": "录制", + "no_frame": "尚无可用帧。" + }, + "status": { + "ready": "就绪 — 开始预览将使用 Orisys DDJX01 固定格式。", + "orisys_ok": "orisys 可用(版本 {version})", + "orisys_missing": "无法导入 orisys — 请将 orisys-sdk/src 加入 PYTHONPATH。", + "opening": "正在打开 Orisys({mode})…", + "preview_stopped": "预览已停止。", + "capture_error": "采集错误:{message}", + "frame_info": "{raw_w}x{raw_h} 原始 / {proc_w}x{proc_h} 处理 @ {fps:.1f} FPS", + "recording": "正在录制… {name}\n选项:传感器={sensor_type},弹性体={elastomer},CMOS={cmos},标记={marker},压头={indentor},平台={platform}", + "recording_frames": "正在录制… {name}\n帧数:{count}", + "saved": "已保存 {count} 帧 → {path}", + "workflow_done": "全部步骤已完成。可点击“新样品 / 重置”开始下一个样品。" + }, + "exhibition": { + "title": "Sensor Bench 展示端", + "btn_search": "搜索", + "btn_camera": "摄像机", + "btn_config": "配置", + "btn_start": "启动", + "btn_starting": "启动中…", + "btn_stop": "暂停", + "btn_reset": "重置", + "btn_start_record": "开始录像", + "btn_stop_record": "结束录像", + "show_log": "显示日志面板", + "view": "画面", + "view_pure_color": "纯色背景箭头", + "view_captured": "采集图像箭头", + "view_processed": "处理后图像", + "view_raw": "原始图像", + "sensor_type": "传感器", + "sensor_orisys": "ORY-BOX/ORY-MINI", + "sensor_dd06": "ORY-X", + "sensor_switched": "已切换传感器类型:{sensor}", + "placeholder_camera": "摄像头编号或视频路径", + "dialog_video": "选择视频文件", + "camera_found": "已发现 {count} 路摄像机:{names}", + "camera_none": "未发现可用摄像机。可点击“搜索”重扫,或通过“摄像机”选择视频文件。", + "plot_fn": "法向力 FN", + "plot_fx": "切向力 FX", + "plot_fy": "切向力 FY", + "reset_done": "追踪器已重置。", + "reset_need_start": "请先启动传感器后再重置追踪器。", + "reset_failed": "重置失败:{message}", + "force_fn_n": "法向力(N)={value:.6f}", + "recording_started": "开始录像:{name}", + "hints": "按键:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏", + "menu_help": "帮助", + "menu_about": "关于", + "about_title": "关于 Sensor Bench", + "about_body": "Sensor Bench 展示端\n版本:{app_version}\nOrisys:{orisys_version}\n\nOrisys-Tech", + "config_dir_tip": "已安装配置目录:{path}\n当前:{selected}", + "config_dir_missing": "未找到已安装的 Orisys 配置目录。可用“配置…”浏览本地 JSON。" + }, + "workflow": { + "title": "传感器检测流程", + "current_marker": "当前", + "done_marker": "已完成", + "steps": { + "power_on": { + "title": "开机并连接", + "body": "连接传感器 USB 线缆,等待 Windows 识别设备。" + }, + "select_camera": { + "title": "启动 Orisys 预览", + "body": "确认相机索引(通常为 0)和 DDJX01 配置,然后点击开始预览。格式由 Orisys 配置固定(1920x1080 @ 30)。" + }, + "set_options": { + "title": "设置传感器选项", + "body": "选择与当前被测实物一致的弹性体类型、CMOS 类型和标记类型。" + }, + "check_preview": { + "title": "检查原始与处理后预览", + "body": "确认原始画面约为 1920x1080,处理后 Orisys 图像约为 365x365。检查对焦、光照和画面清洁度。" + }, + "record": { + "title": "录制原始数据", + "body": "选择存储文件夹,开始录制,操作传感器,然后停止录制。" + }, + "review_curves": { + "title": "查看性能曲线", + "body": "观察采集/渲染 FPS 与力曲线,确认无明显掉帧或曝光不稳定后再签字确认。" + }, + "sensitivity": { + "title": "灵敏度测试", + "body": "选择小压头,并加载 2g 砝码,观察力曲线是否有清晰变化。" + }, + "max_load": { + "title": "最大载荷测试", + "body": "选择平板压头,并加载 10g 到 500g 的砝码观察曲线变化;MINI 使用 10g 到 500g,BOX 使用 2kg。" + } + } + } +} diff --git a/config/options.json b/internal/config/options.json similarity index 100% rename from config/options.json rename to internal/config/options.json diff --git a/internal/run_operator.bat b/internal/run_operator.bat new file mode 100644 index 0000000..c2cbce1 --- /dev/null +++ b/internal/run_operator.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0.." +python "%~dp0run_operator.py" %* diff --git a/internal/run_operator.py b/internal/run_operator.py new file mode 100644 index 0000000..a7a9fa7 --- /dev/null +++ b/internal/run_operator.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Launch the private Sensor Bench operator workflow UI. + +Usage (from the repository root or this folder): + python internal/run_operator.py +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") + +INTERNAL = Path(__file__).resolve().parent +REPO = INTERNAL.parent +PUBLIC_SRC = REPO / "src" +OPERATOR_SRC = INTERNAL / "src" + +for path in (OPERATOR_SRC, PUBLIC_SRC): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +# Prefer sibling orisys-sdk checkout for Sensor-owned preview. +ORISYS_SRC = REPO.parent / "orisys-sdk" / "src" +if ORISYS_SRC.is_dir() and str(ORISYS_SRC) not in sys.path: + sys.path.insert(0, str(ORISYS_SRC)) + +from sensor_bench_operator.__main__ import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/internal/src/sensor_bench_operator/__init__.py b/internal/src/sensor_bench_operator/__init__.py new file mode 100644 index 0000000..48fd1cd --- /dev/null +++ b/internal/src/sensor_bench_operator/__init__.py @@ -0,0 +1,5 @@ +"""Private Sensor Bench operator workflow UI (internal use only).""" + +from .app import MainWindow + +__all__ = ["MainWindow"] diff --git a/internal/src/sensor_bench_operator/__main__.py b/internal/src/sensor_bench_operator/__main__.py new file mode 100644 index 0000000..6c8885a --- /dev/null +++ b/internal/src/sensor_bench_operator/__main__.py @@ -0,0 +1,26 @@ +"""python -m sensor_bench_operator""" + +from __future__ import annotations + +import os +import sys + +os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") + +from PyQt5.QtWidgets import QApplication + +from .app import MainWindow + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv if argv is None else argv) + qt_app = QApplication(args) + qt_app.setApplicationName("Sensor Bench Operator") + qt_app.setOrganizationName("Orisys-Tech") + window = MainWindow() + window.show() + return qt_app.exec_() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/sensor_bench/app.py b/internal/src/sensor_bench_operator/app.py similarity index 90% rename from src/sensor_bench/app.py rename to internal/src/sensor_bench_operator/app.py index e504ea9..d4b16f3 100644 --- a/src/sensor_bench/app.py +++ b/internal/src/sensor_bench_operator/app.py @@ -1,4 +1,4 @@ -"""Main Sensor Bench window: SDK-owned raw/processed preview, recording, curves.""" +"""Internal operator workflow window (private; not part of public Sensor Bench).""" from __future__ import annotations @@ -37,26 +37,28 @@ except Exception as exc: # pragma: no cover "PyQtGraph is required. Install with: pip install pyqt5 pyqtgraph" ) from exc -from .acquisition import ( +from sensor_bench.acquisition import ( FixedCameraMode, SdkAcquisitionWorker, SdkPacket, load_fixed_mode, orisys_importable, ) -from .config_loader import SensorOptions, WorkflowSteps, load_sensor_options, load_workflow_steps -from .i18n import DEFAULT_LOCALE, Translator -from .paths import ( +from sensor_bench.config_loader import SensorOptions, WorkflowSteps, load_sensor_options, load_workflow_steps +from sensor_bench.i18n import DEFAULT_LOCALE, Translator +from sensor_bench.paths import ( + config_display_name, default_ddjx01_calibration, default_ddjx01_config, - default_locales_dir, - default_options_path, default_recordings_dir, - default_steps_path, + list_orisys_configs, + orisys_configs_dir, project_root, ) -from .plot_history import MetricHistory -from .recorder import RawRecorder +from sensor_bench.plot_history import MetricHistory +from sensor_bench.recorder import RawRecorder + +from .paths import default_locales_dir, default_options_path, default_steps_path STYLE = """ @@ -362,15 +364,15 @@ class MainWindow(QMainWindow): layout.addLayout(cam_row) cfg_row = QHBoxLayout() - self.config_edit = QLineEdit() - default_cfg = default_ddjx01_config() - self.config_edit.setText(str(default_cfg)) - self.config_edit.editingFinished.connect(self._refresh_fixed_mode) - cfg_row.addWidget(self.config_edit, 1) + self.config_combo = QComboBox() + self.config_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.config_combo.currentIndexChanged.connect(self._refresh_fixed_mode) + cfg_row.addWidget(self.config_combo, 1) self.btn_cfg = QPushButton() self.btn_cfg.clicked.connect(self._on_browse_config) cfg_row.addWidget(self.btn_cfg) layout.addLayout(cfg_row) + self._populate_config_combo(preferred=default_ddjx01_config()) cal_row = QHBoxLayout() self.cal_edit = QLineEdit() @@ -615,8 +617,9 @@ class MainWindow(QMainWindow): self.btn_revert.setText(self.t("buttons.revert")) self.btn_reset.setText(self.t("buttons.reset")) - self.config_edit.setPlaceholderText(self.t("placeholders.config")) + self.config_combo.setToolTip(self.t("placeholders.config")) self.cal_edit.setPlaceholderText(self.t("placeholders.cal")) + self._update_config_tooltip() if self._raw_pixmap is None: self.preview.setText(self.t("labels.no_preview")) if self._proc_pixmap is None: @@ -708,17 +711,75 @@ class MainWindow(QMainWindow): "platform": str(self.platform_combo.currentData() or ""), } + def _selected_config(self) -> str: + data = self.config_combo.currentData() + if data: + return str(data) + return self.config_combo.currentText().strip() + + def _set_config_selection(self, value: str) -> None: + value = str(value or "").strip() + if not value: + return + for index in range(self.config_combo.count()): + if str(self.config_combo.itemData(index) or "") == value: + self.config_combo.setCurrentIndex(index) + return + if Path(value).stem.lower() == "options" and Path(value).suffix.lower() == ".json": + return + self.config_combo.addItem(config_display_name(value), value) + self.config_combo.setCurrentIndex(self.config_combo.count() - 1) + + def _populate_config_combo(self, *, preferred: str | None = None) -> None: + current = preferred or self._selected_config() or default_ddjx01_config() + custom_paths: list[str] = [] + for index in range(self.config_combo.count()): + data = str(self.config_combo.itemData(index) or "") + if data and (Path(data).suffix.lower() == ".json" or "/" in data or "\\" in data): + if data not in custom_paths: + custom_paths.append(data) + + self.config_combo.blockSignals(True) + self.config_combo.clear() + for entry in list_orisys_configs(): + self.config_combo.addItem(config_display_name(entry), entry) + for path in custom_paths: + if any(str(self.config_combo.itemData(i) or "") == path for i in range(self.config_combo.count())): + continue + if Path(path).stem.lower() == "options": + continue + self.config_combo.addItem(config_display_name(path), path) + if self.config_combo.count() == 0 and current: + self.config_combo.addItem(config_display_name(current), current) + self._set_config_selection(current) + self.config_combo.blockSignals(False) + self._update_config_tooltip() + + def _update_config_tooltip(self) -> None: + cfg_dir = orisys_configs_dir() + selected = self._selected_config() + if cfg_dir is not None: + tip = self.t("labels.config_dir_tip", path=cfg_dir, selected=selected) + else: + tip = self.t("labels.config_dir_missing") + self.config_combo.setToolTip(tip) + self.btn_cfg.setToolTip(tip) + def _refresh_orisys_status(self) -> None: ok, detail = orisys_importable() if ok: self.sdk_status.setText(self.t("status.orisys_ok", version=detail)) self.btn_start.setEnabled(True) + self._populate_config_combo(preferred=self._selected_config() or default_ddjx01_config()) else: self.sdk_status.setText(self.t("status.orisys_missing")) self.btn_start.setEnabled(False) + self._update_config_tooltip() def _refresh_fixed_mode(self) -> None: - text = self.config_edit.text().strip() + if not hasattr(self, "mode_label"): + return + text = self._selected_config() if not text: self._fixed_mode = None self.mode_label.setText(self.t("labels.fixed_mode_unset")) @@ -729,14 +790,16 @@ class MainWindow(QMainWindow): except Exception as exc: # noqa: BLE001 self._fixed_mode = None self.mode_label.setText(self.t("labels.fixed_mode_invalid", error=exc)) + self._update_config_tooltip() # -------------------------------------------------------------- capture def _on_browse_config(self) -> None: + start_dir = orisys_configs_dir() or project_root() path, _ = QFileDialog.getOpenFileName( - self, self.t("dialogs.select_config"), str(project_root()), "JSON (*.json);;All (*)" + self, self.t("dialogs.select_config"), str(start_dir), "JSON (*.json);;All (*)" ) if path: - self.config_edit.setText(path) + self._set_config_selection(path) self._refresh_fixed_mode() def _on_browse_cal(self) -> None: @@ -755,7 +818,7 @@ class MainWindow(QMainWindow): self.t("dialogs.orisys_required", detail=detail), ) return - config = self.config_edit.text().strip() + config = self._selected_config() if not config: QMessageBox.warning( self, self.t("dialogs.config_title"), self.t("dialogs.config_required") @@ -791,7 +854,7 @@ class MainWindow(QMainWindow): self.btn_stop.setEnabled(True) self.btn_rec_start.setEnabled(False) self.camera_index.setEnabled(False) - self.config_edit.setEnabled(False) + self.config_combo.setEnabled(False) self.cal_edit.setEnabled(False) self.backend_combo.setEnabled(False) self.statusBar().showMessage(self.t("status.opening", mode=mode.label())) @@ -807,7 +870,7 @@ class MainWindow(QMainWindow): self.btn_rec_start.setEnabled(False) self.btn_rec_stop.setEnabled(False) self.camera_index.setEnabled(True) - self.config_edit.setEnabled(True) + self.config_combo.setEnabled(True) self.cal_edit.setEnabled(True) self.backend_combo.setEnabled(True) self.statusBar().showMessage(self.t("status.preview_stopped")) @@ -839,7 +902,7 @@ class MainWindow(QMainWindow): self.btn_stop.setEnabled(False) self.btn_rec_start.setEnabled(False) self.camera_index.setEnabled(True) - self.config_edit.setEnabled(True) + self.config_combo.setEnabled(True) self.cal_edit.setEnabled(True) self.backend_combo.setEnabled(True) QMessageBox.warning(self, self.t("dialogs.capture_title"), message) diff --git a/internal/src/sensor_bench_operator/paths.py b/internal/src/sensor_bench_operator/paths.py new file mode 100644 index 0000000..11d912e --- /dev/null +++ b/internal/src/sensor_bench_operator/paths.py @@ -0,0 +1,50 @@ +"""Resolve internal operator resource paths (steps, options, locales).""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def is_frozen() -> bool: + return bool(getattr(sys, "frozen", False)) + + +def app_root() -> Path: + """Editable ``internal/`` directory, or frozen executable directory.""" + if is_frozen(): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parents[2] + + +def resource_root() -> Path: + """Bundled resource root for editable and PyInstaller runs.""" + if is_frozen(): + return Path(getattr(sys, "_MEIPASS", app_root())).resolve() + return app_root() + + +def operator_root() -> Path: + """Compatibility alias for ``app_root()``.""" + return app_root() + + +def default_options_path() -> Path: + external = app_root() / "config" / "options.json" + if external.exists(): + return external + return resource_root() / "config" / "options.json" + + +def default_steps_path() -> Path: + external = app_root() / "steps" / "default.json" + if external.exists(): + return external + return resource_root() / "steps" / "default.json" + + +def default_locales_dir() -> Path: + external = app_root() / "config" / "locales" + if external.exists(): + return external + return resource_root() / "config" / "locales" diff --git a/steps/default.json b/internal/steps/default.json similarity index 100% rename from steps/default.json rename to internal/steps/default.json diff --git a/internal/tests/test_operator_smoke.py b/internal/tests/test_operator_smoke.py new file mode 100644 index 0000000..c1e974b --- /dev/null +++ b/internal/tests/test_operator_smoke.py @@ -0,0 +1,82 @@ +"""Smoke tests for the internal Sensor Bench operator UI.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "internal" / "src")) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + + +def test_mainwindow_offscreen() -> None: + from PyQt5.QtWidgets import QApplication + from sensor_bench.acquisition import FixedCameraMode + from sensor_bench.i18n import DEFAULT_LOCALE + from sensor_bench_operator.app import MainWindow + + app = QApplication.instance() or QApplication([]) + win = MainWindow() + assert win.tr.locale == DEFAULT_LOCALE == "zh_CN" + assert "传感器" in win.windowTitle() + assert win.btn_start.text() == "开始预览" + assert win.camera_index.value() == 0 + assert win.config_combo is not None + assert win.config_combo.count() >= 1 + assert win.btn_next is not None + assert win.btn_revert is not None + assert win.btn_reset is not None + assert win.sensor_type_combo.count() == 2 + assert win.indentor_combo.count() == 2 + assert win.platform_combo.count() == 3 + assert win.selected_sensor_options()["sensor_type"] == "BOX" + assert win.selected_sensor_options()["indentor"] == "1mm_circle" + assert win.selected_sensor_options()["platform"] == "manual" + assert not hasattr(win, "resolution_combo") + assert not hasattr(win, "btn_search") + assert win._current_step_index == 0 + assert not win.btn_revert.isEnabled() + assert win.btn_next.isEnabled() + + # Language switch refreshes labels without restart. + win.language_combo.setCurrentIndex(1) + assert win.tr.locale == "en_US" + assert win.windowTitle() == "Sensor Bench" + assert win.btn_start.text() == "Start Preview" + + # Workflow next / revert / reset. + total = len(win.workflow.steps) + win._on_step_next() + assert win._current_step_index == 1 + assert win.btn_revert.isEnabled() + win._on_step_revert() + assert win._current_step_index == 0 + for _ in range(total): + win._on_step_next() + assert win._current_step_index == total + assert not win.btn_next.isEnabled() + assert "completed" in win.workflow_status.text().lower() or "完成" in win.workflow_status.text() + win._on_step_reset() + assert win._current_step_index == 0 + assert win.btn_next.isEnabled() + + win._fixed_mode = FixedCameraMode( + width=1920, + height=1080, + fps=30.0, + config_ref=str(ROOT / "tests" / "fixtures" / "ddjx01_min.json"), + stitch_width=365, + stitch_height=365, + ) + win.mode_label.setText(win.t("labels.fixed_mode", mode=win._fixed_mode.label())) + assert "1920x1080" in win.mode_label.text() + win.close() + + +if __name__ == "__main__": + test_mainwindow_offscreen() + print("ok") diff --git a/pyproject.toml b/pyproject.toml index a722ba5..4608872 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "sensor-bench" version = "0.1.0" -description = "Operator tool for connecting sensors, viewing raw data, recording, and inspecting performance" +description = "Public exhibition desktop app for Orisys tactile sensor preview, recording, and force plots" readme = "README.md" requires-python = ">=3.10" authors = [{ name = "Orisys-Tech" }] diff --git a/run.py b/run.py index 3387069..0450240 100644 --- a/run.py +++ b/run.py @@ -1,15 +1,22 @@ #!/usr/bin/env python3 -"""Launch Sensor Bench without installing the package. +"""Launch the public Sensor Bench exhibition app without installing the package. Usage (from the repository root): python run.py + +Private operator UI: + python internal/run_operator.py """ from __future__ import annotations +import os import sys from pathlib import Path +# Must be set before importing OpenCV / sensor_bench modules that import cv2. +os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") + ROOT = Path(__file__).resolve().parent SRC = ROOT / "src" if str(SRC) not in sys.path: diff --git a/src/sensor_bench/__init__.py b/src/sensor_bench/__init__.py index e75c543..3a76ebb 100644 --- a/src/sensor_bench/__init__.py +++ b/src/sensor_bench/__init__.py @@ -1,3 +1,3 @@ -"""Sensor Bench: operator tooling for raw sensor inspection and recording.""" +"""Sensor Bench: public exhibition desktop app for Orisys tactile sensors.""" __version__ = "0.1.0" diff --git a/src/sensor_bench/acquisition.py b/src/sensor_bench/acquisition.py index fd599c7..d0fbecc 100644 --- a/src/sensor_bench/acquisition.py +++ b/src/sensor_bench/acquisition.py @@ -17,6 +17,8 @@ from typing import Callable, Optional import cv2 import numpy as np +from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ + @dataclass(frozen=True) class FixedCameraMode: @@ -51,6 +53,7 @@ class SdkPacket: shear_y: float = 0.0 session_id: int = 0 mode: Optional[FixedCameraMode] = None + flow: Optional[np.ndarray] = None def orisys_importable() -> tuple[bool, str]: @@ -104,6 +107,37 @@ def _ensure_bgr(img: np.ndarray) -> np.ndarray: return img +ARROW_BG_BGR = (0x16, 0x13, 0x12) # #121316 as BGR +_ARROW_DRAW_KW = dict( + threshold=2, + grid_spacing=20, + arrow_scale=0.5, + below_threshold_color=(160, 160, 160), +) + + +def draw_arrow_display( + processed_frame: np.ndarray, + flow: Optional[np.ndarray], + *, + background: str = "pure_color", +) -> np.ndarray: + """Build exhibition display: arrows on solid bg or over the processed frame.""" + base = _ensure_bgr(processed_frame) + if flow is None: + return base + try: + import orisys + + if background == "pure_color": + canvas = np.empty_like(base) + canvas[:, :] = ARROW_BG_BGR + return orisys.util.draw_arrows(canvas, flow, **_ARROW_DRAW_KW) + return orisys.util.draw_arrows(base, flow, **_ARROW_DRAW_KW) + except Exception: # noqa: BLE001 + return base + + class SdkAcquisitionWorker: """Background loop: ``Sensor.get_img()`` + deformation + contact.""" @@ -189,6 +223,14 @@ class SdkAcquisitionWorker: with self._lock: self._latest = None + def reset(self) -> None: + """Reset the live Orisys sensor tracker (baseline), if running.""" + with self._lock: + sensor = self._sensor + if sensor is None: + raise RuntimeError("Sensor is not running") + sensor.reset() + def _close_sensor(self) -> None: sensor = self._sensor self._sensor = None @@ -220,6 +262,10 @@ class SdkAcquisitionWorker: if self._cal_path: kwargs["cal_path"] = self._cal_path self._sensor = orisys.Sensor(source, **kwargs) + self._sensor.configure( + lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ, + lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ, + ) self._status(f"Streaming {mode.label()}") except Exception as exc: # noqa: BLE001 if self._on_error is not None and session == self._session_id: @@ -252,14 +298,23 @@ class SdkAcquisitionWorker: fps = float(len(frame_times)) if frame_times else 0.0 fn = fx = fy = 0.0 + flow = None try: - fn, fx, fy = self._sensor.read_info( + fn, fx, fy, flow = self._sensor.read_info( self._sensor.info.FNORMAL, self._sensor.info.FSHEARX, self._sensor.info.FSHEARY, + self._sensor.info.VRAW, ) except Exception: # noqa: BLE001 - pass + try: + fn, fx, fy = self._sensor.read_info( + self._sensor.info.FNORMAL, + self._sensor.info.FSHEARX, + self._sensor.info.FSHEARY, + ) + except Exception: # noqa: BLE001 + pass raw_bgr = _ensure_bgr(raw) proc_bgr = _ensure_bgr(processed) @@ -278,6 +333,7 @@ class SdkAcquisitionWorker: shear_y=float(fy), session_id=session, mode=mode, + flow=flow, ) with self._lock: self._latest = packet diff --git a/src/sensor_bench/camera_discovery.py b/src/sensor_bench/camera_discovery.py new file mode 100644 index 0000000..cd2813e --- /dev/null +++ b/src/sensor_bench/camera_discovery.py @@ -0,0 +1,138 @@ +"""Discover USB cameras for Sensor Bench (Windows DSHOW/MSMF, Linux V4L2).""" + +from __future__ import annotations + +import contextlib +import os +import sys +from dataclasses import dataclass + +import cv2 + +DEFAULT_MAX_CAMERA_INDEX = 10 + + +@dataclass(frozen=True) +class CameraInfo: + index: int + backend_name: str + backend_api: int + width: int + height: int + + @property + def source(self) -> str: + return str(self.index) + + def label(self) -> str: + return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]" + + +@contextlib.contextmanager +def _suppress_opencv_noise(): + """Hide OpenCV VideoCapture probe warnings (stderr + OpenCV log).""" + prev_level = None + set_level = None + try: + logging = getattr(getattr(cv2, "utils", None), "logging", None) + if logging is not None: + set_level = getattr(logging, "setLogLevel", None) + get_level = getattr(logging, "getLogLevel", None) + silent = getattr(logging, "LOG_LEVEL_SILENT", 0) + if set_level is not None: + if get_level is not None: + prev_level = get_level() + set_level(silent) + except Exception: + prev_level = None + + devnull = open(os.devnull, "w") + try: + with contextlib.redirect_stderr(devnull): + stderr_fd = sys.stderr.fileno() + saved_fd = os.dup(stderr_fd) + try: + os.dup2(devnull.fileno(), stderr_fd) + yield + finally: + os.dup2(saved_fd, stderr_fd) + os.close(saved_fd) + finally: + devnull.close() + if set_level is not None and prev_level is not None: + try: + set_level(prev_level) + except Exception: + pass + + +def _backend_candidates() -> list[tuple[str, int]]: + candidates: list[tuple[str, int]] = [] + if sys.platform == "win32": + if hasattr(cv2, "CAP_DSHOW"): + candidates.append(("DSHOW", cv2.CAP_DSHOW)) + if hasattr(cv2, "CAP_MSMF"): + candidates.append(("MSMF", cv2.CAP_MSMF)) + elif sys.platform.startswith("linux"): + if hasattr(cv2, "CAP_V4L2"): + candidates.append(("V4L2", cv2.CAP_V4L2)) + if not candidates: + candidates.append(("ANY", cv2.CAP_ANY)) + return candidates + + +def _try_open(index: int, backend_name: str, backend_api: int) -> CameraInfo | None: + cap = cv2.VideoCapture(index, backend_api) + if not cap.isOpened(): + cap.release() + return None + ok, frame = cap.read() + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) + if not ok or frame is None: + if width <= 0 or height <= 0: + cap.release() + return None + else: + h, w = frame.shape[:2] + width = width or int(w) + height = height or int(h) + cap.release() + return CameraInfo( + index=index, + backend_name=backend_name, + backend_api=backend_api, + width=width, + height=height, + ) + + +def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]: + """Probe indices ``0 .. max_index`` and return working cameras.""" + found: list[CameraInfo] = [] + seen_indices: set[int] = set() + backends = _backend_candidates() + + with _suppress_opencv_noise(): + for index in range(0, max_index + 1): + for backend_name, backend_api in backends: + info = _try_open(index, backend_name, backend_api) + if info is None: + continue + if index in seen_indices: + break + found.append(info) + seen_indices.add(index) + break + return found + + +def pick_default_camera(cameras: list[CameraInfo], preferred: str | None = None) -> str | None: + if not cameras: + return None + preferred = (preferred or "").strip() + if preferred: + for cam in cameras: + if cam.source == preferred: + return cam.source + return cameras[0].source diff --git a/src/sensor_bench/dd06_acquisition.py b/src/sensor_bench/dd06_acquisition.py new file mode 100644 index 0000000..560ada5 --- /dev/null +++ b/src/sensor_bench/dd06_acquisition.py @@ -0,0 +1,243 @@ +"""DD06 acquisition: external capture + ROI rectify + already_processed Sensor.""" + +from __future__ import annotations + +import threading +import time +from pathlib import Path +from typing import Callable, Optional + +from .acquisition import FixedCameraMode, SdkPacket, _ensure_bgr, load_fixed_mode, orisys_importable +from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ +from .roi_geometry import ( + build_roi_warp, + load_json_config, + load_roi_polygon, + open_video_source, + rectified_output_size, + warp_roi_to_rectangle, +) + + +class Dd06AcquisitionWorker: + """Background loop for DD06 polygon-ROI rectification pipeline.""" + + def __init__( + self, + *, + on_error: Optional[Callable[[str], None]] = None, + on_status: Optional[Callable[[str], None]] = None, + ): + self._on_error = on_error + self._on_status = on_status + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._lock = threading.Lock() + self._latest: Optional[SdkPacket] = None + self._session_id = 0 + self._sensor = None + self._cap = None + self._mode: Optional[FixedCameraMode] = None + self._video_source: Optional[str | int] = None + self._config_ref: Optional[str] = None + + @property + def is_running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + @property + def session_id(self) -> int: + return self._session_id + + @property + def mode(self) -> Optional[FixedCameraMode]: + return self._mode + + def latest(self) -> Optional[SdkPacket]: + with self._lock: + return self._latest + + def start( + self, + video_source: str | int = 0, + *, + config_path: str | Path, + ) -> FixedCameraMode: + self.stop() + ok, detail = orisys_importable() + if not ok: + raise RuntimeError( + "orisys SDK is not importable. Install orisys and ensure it is available. " + f"Detail: {detail}" + ) + mode = load_fixed_mode(config_path) + self._mode = mode + self._video_source = video_source + self._config_ref = str(config_path).strip() + self._latest = None + self._session_id += 1 + session = self._session_id + self._stop.clear() + self._thread = threading.Thread( + target=self._run, + name=f"Dd06Acquisition-{session}", + daemon=True, + args=(session,), + ) + self._thread.start() + return self._mode + + def stop(self) -> None: + self._stop.set() + thread = self._thread + if thread is not None and thread.is_alive(): + thread.join(timeout=5.0) + self._thread = None + self._close() + with self._lock: + self._latest = None + + def reset(self) -> None: + """Reset the live Orisys sensor tracker (baseline), if running.""" + with self._lock: + sensor = self._sensor + if sensor is None: + raise RuntimeError("Sensor is not running") + sensor.reset() + + def _close(self) -> None: + cap = self._cap + self._cap = None + if cap is not None: + try: + cap.release() + except Exception: # noqa: BLE001 + pass + sensor = self._sensor + self._sensor = None + if sensor is not None: + try: + sensor.disconnect() + except Exception: # noqa: BLE001 + pass + + def _status(self, message: str) -> None: + if self._on_status is not None: + self._on_status(message) + + def _run(self, session: int) -> None: + import orisys + + mode = self._mode + source = self._video_source + config_ref = self._config_ref + if mode is None or source is None or config_ref is None: + return + try: + self._status(f"Opening DD06 pipeline ({mode.width}x{mode.height})…") + config = load_json_config(config_ref) + roi_points, roi_image_size = load_roi_polygon(config) + out_size = rectified_output_size(config, roi_points) + self._sensor = orisys.Sensor("external", config_name=config_ref, verbose=False) + self._sensor.configure( + lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ, + lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ, + ) + self._cap, resolved = open_video_source(source) + warp_matrix = None + last_frame_size = None + self._status(f"Streaming DD06 ROI {out_size[0]}x{out_size[1]} from {resolved}") + except Exception as exc: # noqa: BLE001 + if self._on_error is not None and session == self._session_id: + self._on_error(str(exc)) + self._close() + return + + frame_times: list[float] = [] + while not self._stop.is_set() and session == self._session_id: + try: + t0 = time.perf_counter() + ok, frame = self._cap.read() + if not ok or frame is None: + time.sleep(0.005) + continue + + frame_h, frame_w = frame.shape[:2] + frame_size = (frame_w, frame_h) + if warp_matrix is None or last_frame_size != frame_size: + warp_matrix, _, _ = build_roi_warp( + roi_points, + roi_image_size, + frame_size, + out_size, + ) + last_frame_size = frame_size + + rectified = warp_roi_to_rectangle(frame, warp_matrix, out_size) + processed = self._sensor.get_img(frame=rectified, already_processed=True) + if processed is None: + processed = rectified + + try: + self._sensor.compute_deformation(decompose=True, check_motion=False) + self._sensor.compute_contact() + except Exception: # noqa: BLE001 + pass + + t1 = time.perf_counter() + frame_times.append(t1) + cutoff = t1 - 1.0 + frame_times = [t for t in frame_times if t >= cutoff] + fps = float(len(frame_times)) if frame_times else 0.0 + + fn = fx = fy = 0.0 + flow = None + try: + fn, fx, fy, flow = self._sensor.read_info( + self._sensor.info.FNORMAL, + self._sensor.info.FSHEARX, + self._sensor.info.FSHEARY, + self._sensor.info.VRAW, + ) + except Exception: # noqa: BLE001 + try: + fn, fx, fy = self._sensor.read_info( + self._sensor.info.FNORMAL, + self._sensor.info.FSHEARX, + self._sensor.info.FSHEARY, + ) + except Exception: # noqa: BLE001 + pass + + raw_bgr = _ensure_bgr(frame) + # Prefer sensor.img / get_img result for processed visualization. + proc_src = getattr(self._sensor, "img", None) + if proc_src is None: + proc_src = processed + proc_bgr = _ensure_bgr(proc_src) + packet = SdkPacket( + raw_frame=raw_bgr, + processed_frame=proc_bgr, + timestamp=time.time(), + fps=fps, + process_ms=(t1 - t0) * 1000.0, + width=int(raw_bgr.shape[1]), + height=int(raw_bgr.shape[0]), + processed_width=int(proc_bgr.shape[1]), + processed_height=int(proc_bgr.shape[0]), + normal_force=float(fn), + shear_x=float(fx), + shear_y=float(fy), + session_id=session, + mode=mode, + flow=flow, + ) + with self._lock: + self._latest = packet + except Exception as exc: # noqa: BLE001 + if self._on_error is not None and session == self._session_id: + self._on_error(str(exc)) + break + + self._close() + self._status("Acquisition stopped") diff --git a/src/sensor_bench/exhibition_app.py b/src/sensor_bench/exhibition_app.py new file mode 100644 index 0000000..72935ec --- /dev/null +++ b/src/sensor_bench/exhibition_app.py @@ -0,0 +1,910 @@ +"""Exhibition-friendly Sensor Bench window (qt_viewer_2-style layout).""" + +from __future__ import annotations + +import time +from importlib.metadata import PackageNotFoundError, version as pkg_version +from pathlib import Path +from typing import Optional, Protocol + +import cv2 +import numpy as np +from PyQt5.QtCore import Qt, QObject, QTimer, pyqtSignal +from PyQt5.QtGui import QImage, QPixmap +from PyQt5.QtWidgets import ( + QAction, + QCheckBox, + QComboBox, + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPlainTextEdit, + QPushButton, + QSizePolicy, + QSplitter, + QVBoxLayout, + QWidget, +) + +try: + import pyqtgraph as pg +except Exception as exc: # pragma: no cover + raise ImportError( + "PyQtGraph is required. Install with: pip install pyqt5 pyqtgraph" + ) from exc + +from .acquisition import ( + FixedCameraMode, + SdkAcquisitionWorker, + SdkPacket, + draw_arrow_display, + load_fixed_mode, + orisys_importable, +) +from .camera_discovery import list_cameras, pick_default_camera +from .dd06_acquisition import Dd06AcquisitionWorker +from .force_calibration import ( + FORCE_SAMPLE_RATE_HZ, + PLOT_BUFFER_POINTS, + PLOT_WINDOW_SEC, + Y_AXIS_MIN_SPAN, + Y_RANGE_ALPHA, + calibrate_normal_force, +) +from .i18n import DEFAULT_LOCALE, Translator +from .paths import ( + config_display_name, + default_dd06_config, + default_ddjx01_config, + default_locales_dir, + default_recordings_dir, + list_orisys_configs, + orisys_configs_dir, + project_root, +) +from .plot_history import MetricHistory +from .recorder import RawRecorder + +CURVE_COLORS = ("#8ab4f8", "#34a853", "#fbbc04") +APP_VERSION_FALLBACK = "0.1.0" + +STYLE = """ +QMainWindow, QWidget { + background-color: #1e1f22; + color: #e8eaed; + font-family: "Segoe UI", "Microsoft YaHei", "SimHei", Arial, sans-serif; + font-size: 13px; +} +QMenuBar { + background-color: #1e1f22; + color: #e8eaed; +} +QMenuBar::item:selected { background-color: #2d3139; } +QMenu { + background-color: #2a2d33; + color: #e8eaed; + border: 1px solid #4a505a; +} +QMenu::item:selected { background-color: #243447; } +QPushButton { + background-color: #2d3139; + border: 1px solid #4a505a; + border-radius: 4px; + padding: 6px 14px; + min-height: 28px; + color: #e8eaed; +} +QPushButton:hover { background-color: #3a404c; } +QPushButton:pressed { background-color: #252930; } +QPushButton:disabled { color: #6b7280; background-color: #25262a; } +QPushButton#btnPrimary { + background-color: #1a73e8; + border-color: #1a73e8; + color: white; + font-weight: 600; +} +QPushButton#btnPrimary:hover { background-color: #1967d2; } +QPushButton#btnDanger { + background-color: #c5221f; + border-color: #c5221f; + color: white; + font-weight: 600; +} +QPushButton#btnDanger:hover { background-color: #a50e0e; } +QLineEdit, QComboBox { + background-color: #2a2d33; + border: 1px solid #4a505a; + border-radius: 4px; + padding: 5px 8px; + color: #e8eaed; +} +QComboBox QAbstractItemView { + background-color: #2a2d33; + color: #e8eaed; + selection-background-color: #243447; +} +QLabel#toolbarCaption { color: #9aa0a6; } +QLabel#imagePanel { + color: #6b7280; + background-color: #121316; + border: 1px solid #3c4048; + border-radius: 6px; +} +QPlainTextEdit { + background-color: #121316; + color: #9aa0a6; + border: 1px solid #3c4048; + border-radius: 6px; + font-family: "Cascadia Code", "Consolas", monospace; + font-size: 12px; + padding: 6px; +} +QSplitter::handle { background-color: #2a2d33; } +""" + + +class AcquisitionPipeline(Protocol): + is_running: bool + + def latest(self) -> Optional[SdkPacket]: ... + + def stop(self) -> None: ... + + def reset(self) -> None: ... + + +def ndarray_to_pixmap(frame: np.ndarray) -> QPixmap: + if frame.ndim == 2 or (frame.ndim == 3 and frame.shape[2] == 1): + gray = frame if frame.ndim == 2 else frame[:, :, 0] + h, w = gray.shape[:2] + image = QImage(gray.data, w, h, w, QImage.Format_Grayscale8) + return QPixmap.fromImage(image.copy()) + if frame.ndim == 3 and frame.shape[2] == 4: + rgba = cv2.cvtColor(frame, cv2.COLOR_BGRA2RGBA) + h, w = rgba.shape[:2] + image = QImage(rgba.data, w, h, w * 4, QImage.Format_RGBA8888) + return QPixmap.fromImage(image.copy()) + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + h, w = rgb.shape[:2] + image = QImage(rgb.data, w, h, w * 3, QImage.Format_RGB888) + return QPixmap.fromImage(image.copy()) + + +def app_version() -> str: + try: + return pkg_version("sensor-bench") + except PackageNotFoundError: + return APP_VERSION_FALLBACK + + +class _AcquisitionBridge(QObject): + error = pyqtSignal(str) + status = pyqtSignal(str) + + +class ExhibitionWindow(QMainWindow): + """External exhibition UI: toolbar + image + FN/FX/FY plots + log.""" + + def __init__(self, *, locales_dir: Optional[Path] = None, locale: str = DEFAULT_LOCALE): + super().__init__() + self.resize(1480, 900) + self.setStyleSheet(STYLE) + + self.tr = Translator(locales_dir or default_locales_dir(), locale=locale) + self.recordings_dir = default_recordings_dir() + self.force_history = MetricHistory( + npoints=PLOT_BUFFER_POINTS, + channels=3, + window_sec=PLOT_WINDOW_SEC, + sample_rate_hz=FORCE_SAMPLE_RATE_HZ, + y_axis_min_span=Y_AXIS_MIN_SPAN, + y_range_alpha=Y_RANGE_ALPHA, + ) + + self._acq_bridge = _AcquisitionBridge() + self._acq_bridge.error.connect(self._on_acquisition_error) + self._acq_bridge.status.connect(self._on_acquisition_status) + self.stitch_worker = SdkAcquisitionWorker( + on_error=lambda m: self._acq_bridge.error.emit(m), + on_status=lambda m: self._acq_bridge.status.emit(m), + ) + self.dd06_worker = Dd06AcquisitionWorker( + on_error=lambda m: self._acq_bridge.error.emit(m), + on_status=lambda m: self._acq_bridge.status.emit(m), + ) + self.acquisition: AcquisitionPipeline = self.stitch_worker + self.recorder = RawRecorder() + + self._source_pixmap: Optional[QPixmap] = None + self._last_packet: Optional[SdkPacket] = None + self._last_packet_id = 0 + self._awaiting_first_frame = False + self._display_aspect = 1.0 + self._view_mode = "pure_color" + self._sensor_type = "orisys" + self._log_panel_visible = False + self._fixed_mode: Optional[FixedCameraMode] = None + self._orisys_config = str(default_ddjx01_config()) + self._dd06_config = str(default_dd06_config()) + + self._build_ui() + self._create_plots() + self._apply_language() + self._refresh_orisys_status() + self._log(self.t("exhibition.hints")) + + self.timer = QTimer(self) + self.timer.timeout.connect(self._on_tick) + self.timer.start(16) + + def t(self, key: str, **kwargs) -> str: + return self.tr.t(key, **kwargs) + + def _log(self, message: str) -> None: + stamp = time.strftime("%H:%M:%S") + self.log_widget.appendPlainText(f"[{stamp}] {message}") + + def _pipeline_running(self) -> bool: + return bool(self.stitch_worker.is_running or self.dd06_worker.is_running) + + def _build_ui(self) -> None: + self._build_menu() + + central = QWidget() + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(8) + + bar = QHBoxLayout() + bar.setSpacing(8) + + self.sensor_caption = QLabel() + self.sensor_caption.setObjectName("toolbarCaption") + bar.addWidget(self.sensor_caption) + self.sensor_combo = QComboBox() + self.sensor_combo.addItem("", "orisys") + self.sensor_combo.addItem("", "dd06") + self.sensor_combo.currentIndexChanged.connect(self._on_sensor_type_changed) + bar.addWidget(self.sensor_combo) + + self.btn_search = QPushButton() + self.btn_search.clicked.connect(self._on_search_cameras) + bar.addWidget(self.btn_search) + self.btn_camera = QPushButton() + self.btn_camera.clicked.connect(self._on_browse_camera) + bar.addWidget(self.btn_camera) + self.camera_edit = QLineEdit("0") + bar.addWidget(self.camera_edit, 1) + + self.btn_config = QPushButton() + self.btn_config.clicked.connect(self._on_browse_config) + bar.addWidget(self.btn_config) + self.config_combo = QComboBox() + self.config_combo.setMinimumWidth(160) + self.config_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + bar.addWidget(self.config_combo, 1) + self._populate_config_combo(preferred=self._orisys_config) + + self.btn_start = QPushButton() + self.btn_start.setObjectName("btnPrimary") + self.btn_start.clicked.connect(self._on_start) + bar.addWidget(self.btn_start) + + self.btn_stop = QPushButton() + self.btn_stop.setEnabled(False) + self.btn_stop.clicked.connect(self._on_stop) + bar.addWidget(self.btn_stop) + + self.btn_reset = QPushButton() + self.btn_reset.setEnabled(False) + self.btn_reset.clicked.connect(self._on_reset) + bar.addWidget(self.btn_reset) + + self.btn_record = QPushButton() + self.btn_record.setObjectName("btnDanger") + self.btn_record.setEnabled(False) + self.btn_record.clicked.connect(self._on_toggle_record) + bar.addWidget(self.btn_record) + + self.show_log_check = QCheckBox() + self.show_log_check.setChecked(False) + self.show_log_check.toggled.connect(self._on_show_log_changed) + bar.addWidget(self.show_log_check) + + self.view_caption = QLabel() + self.view_caption.setObjectName("toolbarCaption") + bar.addWidget(self.view_caption) + self.view_combo = QComboBox() + self.view_combo.addItem("", "pure_color") + self.view_combo.addItem("", "captured") + self.view_combo.addItem("", "raw") + self.view_combo.setCurrentIndex(0) + self.view_combo.currentIndexChanged.connect(self._on_view_changed) + bar.addWidget(self.view_combo) + + self.language_caption = QLabel() + self.language_caption.setObjectName("toolbarCaption") + bar.addWidget(self.language_caption) + self.language_combo = QComboBox() + self.language_combo.addItem("", "zh_CN") + self.language_combo.addItem("", "en_US") + self.language_combo.setCurrentIndex(0 if self.tr.locale == "zh_CN" else 1) + self.language_combo.currentIndexChanged.connect(self._on_language_changed) + bar.addWidget(self.language_combo) + + root.addLayout(bar) + + v_splitter = QSplitter(Qt.Vertical) + h_splitter = QSplitter(Qt.Horizontal) + + left = QWidget() + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(0, 0, 0, 0) + self.image_label = QLabel() + self.image_label.setObjectName("imagePanel") + self.image_label.setMinimumSize(240, 240) + self.image_label.setAlignment(Qt.AlignCenter) + self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + left_layout.addWidget(self.image_label) + h_splitter.addWidget(left) + + pg.setConfigOptions(antialias=True, foreground="#e8eaed", background="#121316") + self.graph_widget = pg.GraphicsLayoutWidget() + self.graph_widget.setMinimumWidth(320) + h_splitter.addWidget(self.graph_widget) + self.h_splitter = h_splitter + v_splitter.addWidget(h_splitter) + + self.log_widget = QPlainTextEdit() + self.log_widget.setReadOnly(True) + self.log_widget.setMaximumBlockCount(500) + self.log_widget.setMinimumHeight(110) + self.log_widget.setVisible(self._log_panel_visible) + v_splitter.addWidget(self.log_widget) + v_splitter.setStretchFactor(0, 5) + v_splitter.setStretchFactor(1, 1) + root.addWidget(v_splitter, 1) + + self.status_label = QLabel() + self.status_label.setStyleSheet("color: #9aa0a6;") + root.addWidget(self.status_label) + + def _build_menu(self) -> None: + menu = self.menuBar() + self.help_menu = menu.addMenu("") + self.about_action = QAction("", self) + self.about_action.triggered.connect(self._on_about) + self.help_menu.addAction(self.about_action) + + def _create_plots(self) -> None: + self.plots = [] + self.curves = [] + self.dot_cores = [] + self.plot_titles = [ + "exhibition.plot_fn", + "exhibition.plot_fx", + "exhibition.plot_fy", + ] + axis_pen = pg.mkPen(color="#3c4048", width=1) + for i, title_key in enumerate(self.plot_titles): + plot = self.graph_widget.addPlot(title=self.t(title_key)) + plot.showGrid(x=False, y=True, alpha=0.2) + plot.getAxis("left").setPen(axis_pen) + plot.getAxis("bottom").setPen(axis_pen) + plot.setLabel("bottom", "s") + curve = plot.plot(pen=pg.mkPen(CURVE_COLORS[i], width=2)) + dot = plot.plot( + [], + [], + pen=None, + symbol="o", + symbolSize=6, + symbolBrush=CURVE_COLORS[i], + ) + plot.setXRange(0, PLOT_WINDOW_SEC, padding=0) + plot.enableAutoRange(x=False, y=False) + self.plots.append(plot) + self.curves.append(curve) + self.dot_cores.append(dot) + if i < 2: + self.graph_widget.nextRow() + + def _apply_language(self) -> None: + self.setWindowTitle(self.t("exhibition.title")) + self.help_menu.setTitle(self.t("exhibition.menu_help")) + self.about_action.setText(self.t("exhibition.menu_about")) + self.sensor_caption.setText(self.t("exhibition.sensor_type")) + self.btn_search.setText(self.t("exhibition.btn_search")) + self.btn_camera.setText(self.t("exhibition.btn_camera")) + self.btn_config.setText(self.t("exhibition.btn_config")) + self.btn_start.setText(self.t("exhibition.btn_start")) + self.btn_stop.setText(self.t("exhibition.btn_stop")) + self.btn_reset.setText(self.t("exhibition.btn_reset")) + self._refresh_record_button() + self.view_caption.setText(self.t("exhibition.view")) + self.show_log_check.setText(self.t("exhibition.show_log")) + self.language_caption.setText(self.t("app.language")) + self.camera_edit.setPlaceholderText(self.t("exhibition.placeholder_camera")) + self._update_config_tooltip() + + self.language_combo.blockSignals(True) + self.language_combo.setItemText(0, self.t("app.lang_zh")) + self.language_combo.setItemText(1, self.t("app.lang_en")) + self.language_combo.blockSignals(False) + + self.sensor_combo.blockSignals(True) + self.sensor_combo.setItemText(0, self.t("exhibition.sensor_orisys")) + self.sensor_combo.setItemText(1, self.t("exhibition.sensor_dd06")) + self.sensor_combo.blockSignals(False) + + self.view_combo.blockSignals(True) + self.view_combo.setItemText(0, self.t("exhibition.view_pure_color")) + self.view_combo.setItemText(1, self.t("exhibition.view_captured")) + self.view_combo.setItemText(2, self.t("exhibition.view_raw")) + self.view_combo.blockSignals(False) + + for plot, title_key in zip(self.plots, self.plot_titles): + plot.setTitle(self.t(title_key), color="#9aa0a6", size="12pt") + + if self._source_pixmap is None: + self.image_label.setText(self.t("labels.no_preview")) + self.status_label.setText(self.t("status.ready")) + + def _selected_config(self) -> str: + data = self.config_combo.currentData() + if data: + return str(data) + return self.config_combo.currentText().strip() + + def _set_config_selection(self, value: str) -> None: + value = str(value or "").strip() + if not value: + return + for index in range(self.config_combo.count()): + if str(self.config_combo.itemData(index) or "") == value: + self.config_combo.setCurrentIndex(index) + return + # Custom path not yet in the list. + if Path(value).stem.lower() == "options" and Path(value).suffix.lower() == ".json": + return + self.config_combo.addItem(config_display_name(value), value) + self.config_combo.setCurrentIndex(self.config_combo.count() - 1) + + def _populate_config_combo(self, *, preferred: str | None = None) -> None: + current = preferred or self._selected_config() or default_ddjx01_config() + custom_paths = [] + for index in range(self.config_combo.count()): + data = str(self.config_combo.itemData(index) or "") + if data and (Path(data).suffix.lower() == ".json" or "/" in data or "\\" in data): + if data not in custom_paths: + custom_paths.append(data) + + self.config_combo.blockSignals(True) + self.config_combo.clear() + for entry in list_orisys_configs(): + self.config_combo.addItem(config_display_name(entry), entry) + for path in custom_paths: + # Avoid duplicating local entries already listed above. + if any(str(self.config_combo.itemData(i) or "") == path for i in range(self.config_combo.count())): + continue + if Path(path).stem.lower() == "options": + continue + self.config_combo.addItem(config_display_name(path), path) + if self.config_combo.count() == 0 and current: + self.config_combo.addItem(config_display_name(current), current) + self.config_combo.blockSignals(False) + self._set_config_selection(current) + self._update_config_tooltip() + + def _update_config_tooltip(self) -> None: + cfg_dir = orisys_configs_dir() + selected = self._selected_config() + if cfg_dir is not None: + tip = self.t("exhibition.config_dir_tip", path=cfg_dir, selected=selected) + else: + tip = self.t("exhibition.config_dir_missing") + self.config_combo.setToolTip(tip) + self.btn_config.setToolTip(tip) + + def _refresh_record_button(self) -> None: + if self.recorder.is_recording: + self.btn_record.setText(self.t("exhibition.btn_stop_record")) + else: + self.btn_record.setText(self.t("exhibition.btn_start_record")) + + def _on_about(self) -> None: + ok, orisys_ver = orisys_importable() + orisys_text = orisys_ver if ok else self.t("status.orisys_missing") + QMessageBox.about( + self, + self.t("exhibition.about_title"), + self.t( + "exhibition.about_body", + app_version=app_version(), + orisys_version=orisys_text, + ), + ) + + def _on_language_changed(self, *_args) -> None: + locale = self.language_combo.currentData() + if not locale: + return + self.tr.set_locale(str(locale)) + self._apply_language() + self._refresh_orisys_status() + + def _on_sensor_type_changed(self, *_args) -> None: + if self._pipeline_running(): + return + kind = str(self.sensor_combo.currentData() or "orisys") + if kind == self._sensor_type: + return + # Remember current config for the leaving type. + if self._sensor_type == "orisys": + self._orisys_config = self._selected_config() or self._orisys_config + else: + self._dd06_config = self._selected_config() or self._dd06_config + self._sensor_type = kind + self.acquisition = self.dd06_worker if kind == "dd06" else self.stitch_worker + preferred = self._dd06_config if kind == "dd06" else self._orisys_config + self._populate_config_combo(preferred=preferred) + self._log(self.t("exhibition.sensor_switched", sensor=kind)) + + def _on_view_changed(self, *_args) -> None: + mode = self.view_combo.currentData() + if mode in ("pure_color", "captured", "raw"): + self._view_mode = str(mode) + if self._last_packet is not None: + self._update_image(self._last_packet) + + def _on_show_log_changed(self, visible: bool) -> None: + self._log_panel_visible = bool(visible) + self.log_widget.setVisible(self._log_panel_visible) + + def _refresh_orisys_status(self) -> None: + ok, detail = orisys_importable() + if ok: + self.status_label.setText(self.t("status.orisys_ok", version=detail)) + self.btn_start.setEnabled(not self._pipeline_running()) + self._populate_config_combo(preferred=self._selected_config() or default_ddjx01_config()) + else: + self.status_label.setText(self.t("status.orisys_missing")) + self.btn_start.setEnabled(False) + self._update_config_tooltip() + + def _parse_camera_source(self) -> str | int: + text = self.camera_edit.text().strip() or "0" + if text.isdigit(): + return int(text) + return text + + def _on_search_cameras(self) -> None: + if self._pipeline_running(): + return + preferred = self.camera_edit.text().strip() + cameras = list_cameras() + if not cameras: + self._log(self.t("exhibition.camera_none")) + return + names = ", ".join(cam.label() for cam in cameras) + self._log(self.t("exhibition.camera_found", count=len(cameras), names=names)) + selected = pick_default_camera( + cameras, preferred if preferred.isdigit() else None + ) + if selected is not None: + self.camera_edit.setText(selected) + + def _on_browse_camera(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, + self.t("exhibition.dialog_video"), + str(project_root()), + "Video (*.mp4 *.avi *.mov);;All (*)", + ) + if path: + self.camera_edit.setText(path) + + def _on_browse_config(self) -> None: + start_dir = orisys_configs_dir() or project_root() + path, _ = QFileDialog.getOpenFileName( + self, + self.t("dialogs.select_config"), + str(start_dir), + "JSON (*.json);;All (*)", + ) + if path: + self._set_config_selection(path) + self._update_config_tooltip() + + def _on_start(self) -> None: + ok, detail = orisys_importable() + if not ok: + QMessageBox.warning( + self, + self.t("dialogs.orisys_title"), + self.t("dialogs.orisys_required", detail=detail), + ) + return + config = self._selected_config() + if not config: + QMessageBox.warning( + self, self.t("dialogs.config_title"), self.t("dialogs.config_required") + ) + return + try: + mode = load_fixed_mode(config) + except Exception as exc: # noqa: BLE001 + QMessageBox.warning( + self, + self.t("dialogs.config_title"), + self.t("labels.fixed_mode_invalid", error=exc), + ) + return + + self.force_history.reset() + self._awaiting_first_frame = True + self._source_pixmap = None + self.image_label.clear() + self.image_label.setText(self.t("labels.no_preview")) + self.btn_start.setEnabled(False) + self.btn_start.setText(self.t("exhibition.btn_starting")) + self.sensor_combo.setEnabled(False) + + try: + if self._sensor_type == "dd06": + self.acquisition = self.dd06_worker + mode = self.dd06_worker.start( + self._parse_camera_source(), + config_path=config, + ) + else: + self.acquisition = self.stitch_worker + mode = self.stitch_worker.start( + self._parse_camera_source(), + config_path=config, + cal_path=None, + backend="auto", + ) + except Exception as exc: # noqa: BLE001 + self._awaiting_first_frame = False + self.btn_start.setEnabled(True) + self.btn_start.setText(self.t("exhibition.btn_start")) + self.sensor_combo.setEnabled(True) + QMessageBox.critical(self, self.t("dialogs.capture_title"), str(exc)) + self._log(self.t("status.capture_error", message=exc)) + return + + self._fixed_mode = mode + self.btn_start.setText(self.t("exhibition.btn_start")) + self.btn_stop.setEnabled(True) + self.btn_reset.setEnabled(True) + self.btn_record.setEnabled(False) + self.btn_search.setEnabled(False) + self.btn_camera.setEnabled(False) + self.camera_edit.setEnabled(False) + self.config_combo.setEnabled(False) + self.status_label.setText(self.t("status.opening", mode=mode.label())) + self._log(self.t("status.opening", mode=mode.label())) + + def _on_stop(self) -> None: + if self.recorder.is_recording: + self._stop_record() + self.stitch_worker.stop() + self.dd06_worker.stop() + self._awaiting_first_frame = False + self._last_packet = None + self._source_pixmap = None + self.image_label.clear() + self.image_label.setText(self.t("labels.no_preview")) + self.btn_start.setEnabled(orisys_importable()[0]) + self.btn_stop.setEnabled(False) + self.btn_reset.setEnabled(False) + self.btn_record.setEnabled(False) + self.btn_search.setEnabled(True) + self.btn_camera.setEnabled(True) + self.camera_edit.setEnabled(True) + self.config_combo.setEnabled(True) + self.sensor_combo.setEnabled(True) + self.status_label.setText(self.t("status.preview_stopped")) + self._log(self.t("status.preview_stopped")) + + def _on_reset(self) -> None: + if not self._pipeline_running(): + self._log(self.t("exhibition.reset_need_start")) + return + try: + self.acquisition.reset() + except Exception as exc: # noqa: BLE001 + self._log(self.t("exhibition.reset_failed", message=exc)) + return + self._log(self.t("exhibition.reset_done")) + + def _on_toggle_record(self) -> None: + if self.recorder.is_recording: + self._stop_record() + else: + self._start_record() + + def _start_record(self) -> None: + packet = self._last_packet or self.acquisition.latest() + if packet is None: + QMessageBox.warning( + self, self.t("dialogs.recording_title"), self.t("dialogs.no_frame") + ) + return + record_fps = packet.fps or (packet.mode.fps if packet.mode else 30.0) + try: + path = self.recorder.start( + self.recordings_dir, fps=float(record_fps), frame=packet.raw_frame + ) + except Exception as exc: # noqa: BLE001 + QMessageBox.critical(self, self.t("dialogs.recording_title"), str(exc)) + return + self._refresh_record_button() + self._log(self.t("exhibition.recording_started", name=path.name)) + + def _stop_record(self) -> None: + path, count = self.recorder.stop() + self._refresh_record_button() + if path is None: + return + self._log(self.t("status.saved", count=count, path=path)) + + def _on_acquisition_error(self, message: str) -> None: + self._awaiting_first_frame = False + self.btn_start.setEnabled(orisys_importable()[0]) + self.btn_stop.setEnabled(False) + self.btn_reset.setEnabled(False) + self.btn_record.setEnabled(False) + self.btn_search.setEnabled(True) + self.btn_camera.setEnabled(True) + self.camera_edit.setEnabled(True) + self.config_combo.setEnabled(True) + self.sensor_combo.setEnabled(True) + self.status_label.setText(self.t("status.capture_error", message=message)) + self._log(self.t("status.capture_error", message=message)) + QMessageBox.warning(self, self.t("dialogs.capture_title"), message) + + def _on_acquisition_status(self, message: str) -> None: + self.status_label.setText(message) + self._log(message) + + def _on_tick(self) -> None: + packet = self.acquisition.latest() + if packet is None: + return + packet_id = id(packet) + if packet_id == self._last_packet_id and not self._awaiting_first_frame: + return + + if self._awaiting_first_frame: + self._awaiting_first_frame = False + self.btn_record.setEnabled(True) + info = self.t( + "status.frame_info", + raw_w=packet.width, + raw_h=packet.height, + proc_w=packet.processed_width, + proc_h=packet.processed_height, + fps=packet.fps, + ) + self.status_label.setText(info) + self._log(info) + self._log( + self.t( + "exhibition.force_fn_n", + value=calibrate_normal_force(packet.normal_force), + ) + ) + + self._last_packet = packet + self._last_packet_id = packet_id + self._update_image(packet) + self.force_history.push( + packet.timestamp, + packet.normal_force, + packet.shear_x, + packet.shear_y, + ) + self._update_plots() + + if self.recorder.is_recording: + self.recorder.write(packet.raw_frame) + self.status_label.setText( + self.t( + "status.recording_frames", + name=self.recorder.path.name if self.recorder.path else "", + count=self.recorder.frame_count, + ) + ) + + def _update_image(self, packet: SdkPacket) -> None: + if self._view_mode == "raw": + frame = packet.raw_frame + elif self._view_mode == "captured": + frame = draw_arrow_display( + packet.processed_frame, packet.flow, background="captured" + ) + else: + frame = draw_arrow_display( + packet.processed_frame, packet.flow, background="pure_color" + ) + h, w = frame.shape[:2] + aspect = float(w) / float(h) if h else 1.0 + first = self._source_pixmap is None + aspect_changed = abs(aspect - self._display_aspect) > 1e-3 + self._display_aspect = aspect + self._source_pixmap = ndarray_to_pixmap(frame) + if first or aspect_changed: + self._adjust_image_panel() + else: + self._render_image() + + def _adjust_image_panel(self) -> None: + available = max(self.height() - 220, 200) + image_w = int(available * self._display_aspect) + image_w = max(min(image_w, max(self.width() // 2, 240)), 240) + self.h_splitter.setSizes([image_w, max(self.width() - image_w, 320)]) + self._render_image() + + def _render_image(self) -> None: + if self._source_pixmap is None or self._source_pixmap.isNull(): + return + target = self.image_label.size() + if target.width() < 2 or target.height() < 2: + return + scaled = self._source_pixmap.scaled( + target, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + self.image_label.setPixmap(scaled) + + def _update_plots(self) -> None: + state = self.force_history.state() + if state is None: + return + for i, curve in enumerate(self.curves): + x = state.times + y = state.values[:, i] + curve.setData(x, y) + ymin, ymax = state.y_ranges[i] + self.plots[i].setYRange(ymin, ymax) + self.dot_cores[i].setData(x[-1:], y[-1:]) + x0, x1 = state.x_range + for plot in self.plots: + plot.setXRange(x0, x1, padding=0) + + def resizeEvent(self, event) -> None: # noqa: N802 + super().resizeEvent(event) + self._render_image() + + def keyPressEvent(self, event) -> None: # noqa: N802 + key = event.key() + if key == Qt.Key_Escape: + if self.isFullScreen(): + self.showNormal() + else: + self.showFullScreen() + elif key == Qt.Key_Q: + self.close() + elif key == Qt.Key_S: + if not self.recorder.is_recording and self._pipeline_running(): + self._start_record() + elif key == Qt.Key_E: + if self.recorder.is_recording: + self._stop_record() + elif key == Qt.Key_R: + self._on_reset() + else: + super().keyPressEvent(event) + + def closeEvent(self, event) -> None: # noqa: N802 + if self.recorder.is_recording: + self.recorder.stop() + self.stitch_worker.stop() + self.dd06_worker.stop() + self.timer.stop() + super().closeEvent(event) diff --git a/src/sensor_bench/force_calibration.py b/src/sensor_bench/force_calibration.py new file mode 100644 index 0000000..a4c1068 --- /dev/null +++ b/src/sensor_bench/force_calibration.py @@ -0,0 +1,20 @@ +"""Force calibration coefficients and plot defaults from Orisys viewer2d.""" + +from __future__ import annotations + +# Linear FN → Newtons (y = mx + c), from orisys examples/gui/sensors/adapter.py. +NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05 +NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535 + +# PyQtGraph force-curve tuning from examples/gui/viewer2d/backend.py. +FORCE_SAMPLE_RATE_HZ = 30.0 +PLOT_BUFFER_POINTS = 120 +PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ +Y_AXIS_MIN_SPAN = 20000.0 +Y_RANGE_ALPHA = 0.5 +LOWPASS_CUTOFF_HZ = 10.0 + + +def calibrate_normal_force(raw_value: float) -> float: + """Convert raw normal-force reading to Newtons using y = mx + c.""" + return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET diff --git a/src/sensor_bench/main.py b/src/sensor_bench/main.py index afe7b66..42dfa36 100644 --- a/src/sensor_bench/main.py +++ b/src/sensor_bench/main.py @@ -1,22 +1,30 @@ -"""Entry point for the Sensor Bench desktop application.""" +"""Entry point for the public Sensor Bench exhibition application.""" from __future__ import annotations +import os import sys +# Must run before any module that imports cv2 (exhibition_app, acquisition, etc.). +os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") + from PyQt5.QtWidgets import QApplication -from .app import MainWindow - def main(argv: list[str] | None = None) -> int: - args = list(sys.argv if argv is None else argv) - qt_app = QApplication(args) + parser_args = list(sys.argv[1:] if argv is None else argv[1:]) + # Accept unknown Qt args (e.g. -platform offscreen) without advertising + # internal-only flags on the public CLI. + qt_args = parser_args + + qt_app = QApplication([sys.argv[0], *qt_args] if argv is None else [argv[0], *qt_args]) qt_app.setApplicationName("Sensor Bench") qt_app.setOrganizationName("Orisys-Tech") - window = MainWindow() - window.show() + from .exhibition_app import ExhibitionWindow + + window = ExhibitionWindow() + window.showMaximized() return qt_app.exec_() diff --git a/src/sensor_bench/paths.py b/src/sensor_bench/paths.py index cab40f3..680e0d7 100644 --- a/src/sensor_bench/paths.py +++ b/src/sensor_bench/paths.py @@ -6,6 +6,9 @@ import sys from pathlib import Path +NON_SENSOR_CONFIG_STEMS = {"options", "bench_theme"} + + def is_frozen() -> bool: return bool(getattr(sys, "frozen", False)) @@ -29,20 +32,6 @@ def project_root() -> Path: return app_root() -def default_options_path() -> Path: - external = app_root() / "config" / "options.json" - if external.exists(): - return external - return resource_root() / "config" / "options.json" - - -def default_steps_path() -> Path: - external = app_root() / "steps" / "default.json" - if external.exists(): - return external - return resource_root() / "steps" / "default.json" - - def default_locales_dir() -> Path: external = app_root() / "config" / "locales" if external.exists(): @@ -64,17 +53,128 @@ def default_orisys_sdk_root() -> Path: return Path(r"D:\repo\orisys-sdk") -def default_ddjx01_config() -> str: - if is_frozen(): - return "ddjx01" - path = default_orisys_sdk_root() / "config" / "ddjx01.json" - if path.exists(): - return str(path) +def local_sensor_configs_dir() -> Path: + """Editable sensor configs next to the app (or repo) root.""" + return app_root() / "config" + + +def _is_sensor_config_json(path: Path) -> bool: + if path.suffix.lower() != ".json": + return False + if path.parent.name.lower() == "locales": + return False + if path.stem.lower() in NON_SENSOR_CONFIG_STEMS: + return False + return True + + +def list_local_sensor_config_files() -> list[Path]: + """Return local editable sensor config JSON paths (excludes options/locales).""" + root = local_sensor_configs_dir() + if not root.exists(): + return [] + return sorted(p for p in root.glob("*.json") if _is_sensor_config_json(p)) + + +def orisys_configs_dir() -> Path | None: + """Prefer local sensor configs dir when present; else installed package dir.""" + local = local_sensor_configs_dir() + if list_local_sensor_config_files(): + return local + try: + import orisys + + path = Path(orisys.configs.CONFIG_DIR) + return path if path.exists() else None + except Exception: # noqa: BLE001 + return None + + +def list_orisys_configs() -> list[str]: + """Return selectable sensor configs. + + Local ``config/*.json`` files (excluding non-sensor stems such as + ``bench_theme``) are listed first as + absolute paths so Start uses the editable copy beside the EXE. Package stems + from the installed Orisys wheel are appended when not already covered. + """ + entries: list[str] = [] + seen_stems: set[str] = set() + + for path in list_local_sensor_config_files(): + stem = path.stem.lower() + if stem in seen_stems: + continue + entries.append(str(path.resolve())) + seen_stems.add(stem) + + try: + import orisys + + for stem in orisys.configs.list_config(): + key = str(stem).strip() + if not key: + continue + if key.lower() in NON_SENSOR_CONFIG_STEMS: + continue + if key.lower() in seen_stems: + continue + entries.append(key) + seen_stems.add(key.lower()) + except Exception: # noqa: BLE001 + pass + + return entries + + +def config_display_name(value: str) -> str: + """Short label for a config path or package stem.""" + text = str(value or "").strip() + if not text: + return "" + path = Path(text) + if path.suffix.lower() == ".json" or "/" in text or "\\" in text: + return path.stem + return text + + +def default_config_name(sensor_type: str = "orisys") -> str: + """Preferred package config name for a sensor pipeline.""" + kind = str(sensor_type or "orisys").strip().lower() + if kind in ("dd06", "dd06-ov", "roi", "polygon_roi"): + return "dd06-ov" return "ddjx01" +def _pick_default_config(preferred_stem: str) -> str: + entries = list_orisys_configs() + for entry in entries: + if config_display_name(entry).lower() == preferred_stem.lower(): + return entry + if entries: + return entries[0] + return preferred_stem + + +def default_ddjx01_config() -> str: + """Prefer local editable config path, else package stem.""" + return _pick_default_config(default_config_name("orisys")) + + def default_ddjx01_calibration() -> Path: external = app_root() / "config" / "ddjx01.npy" if external.exists(): return external return default_orisys_sdk_root() / "config" / "ddjx01.npy" + + +def default_dd06_config() -> str: + preferred = default_config_name("dd06") + entries = list_orisys_configs() + for stem in (preferred, "dd06"): + for entry in entries: + if config_display_name(entry).lower() == stem.lower(): + return entry + if entries: + return preferred + return preferred diff --git a/src/sensor_bench/plot_history.py b/src/sensor_bench/plot_history.py index c8e19d9..f399238 100644 --- a/src/sensor_bench/plot_history.py +++ b/src/sensor_bench/plot_history.py @@ -16,19 +16,70 @@ class PlotState: y_ranges: tuple[tuple[float, float], ...] -class MetricHistory: - """Ring-style buffer for FPS / brightness (or other scalar metrics).""" +def lowpass_filter( + data, + cutoff_hz: float, + sample_rate_hz: float, + order: int = 2, +) -> np.ndarray: + """Zero-phase Butterworth low-pass for uniformly sampled 1-D data.""" + y = np.asarray(data, dtype=np.float64) + if cutoff_hz == 0: + return y.copy() + if y.size == 0: + return y - def __init__(self, npoints: int = 120, *, channels: int = 2, window_sec: float = 4.0): + nyquist = 0.5 * sample_rate_hz + if cutoff_hz < 0 or cutoff_hz >= nyquist: + raise ValueError( + f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}" + ) + + from scipy.signal import butter, filtfilt + + b, a = butter(order, cutoff_hz / nyquist, btype="low") + padlen = 3 * (max(len(b), len(a)) - 1) + if y.size <= padlen: + return y.copy() + return filtfilt(b, a, y) + + +class MetricHistory: + """Ring-style buffer for force / metric curves (viewer2d-compatible options).""" + + def __init__( + self, + npoints: int = 120, + *, + channels: int = 2, + window_sec: float = 4.0, + sample_rate_hz: float | None = None, + lowpass_cutoff_hz: float = 0.0, + y_axis_min_span: float | None = None, + y_range_alpha: float = 0.5, + ): self.npoints = int(npoints) self.channels = int(channels) self.window_sec = float(window_sec) + self.sample_rate_hz = ( + float(sample_rate_hz) + if sample_rate_hz is not None + else (self.npoints / self.window_sec if self.window_sec > 0 else 30.0) + ) + self.lowpass_cutoff_hz = float(lowpass_cutoff_hz) + self.y_axis_min_span = ( + None if y_axis_min_span is None else float(y_axis_min_span) + ) + self.y_range_alpha = float(y_range_alpha) self.reset() def reset(self) -> None: self._data = np.full((self.npoints, 1 + self.channels), np.nan, dtype=np.float64) self.sample_count = 0 self._t0: Optional[float] = None + self._y_range_smooth: list[Optional[tuple[float, float]]] = [ + None for _ in range(self.channels) + ] def push(self, timestamp: float, *values: float) -> None: if len(values) != self.channels: @@ -44,6 +95,37 @@ class MetricHistory: self._data[:-1, :] = self._data[1:, :] self._data[-1, :] = row + def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> tuple[float, float]: + if self.y_axis_min_span is None: + if abs(ymax - ymin) < 1e-6: + pad = 1.0 if abs(ymin) < 1e-6 else abs(ymin) * 0.1 + ymin -= pad + ymax += pad + else: + pad = (ymax - ymin) * 0.12 + ymin -= pad + ymax += pad + return ymin, ymax + + if self._y_range_smooth[channel] is None: + smoothed = (ymin, ymax) + else: + prev_min, prev_max = self._y_range_smooth[channel] + alpha = self.y_range_alpha + ymin = alpha * ymin + (1.0 - alpha) * prev_min + ymax = alpha * ymax + (1.0 - alpha) * prev_max + smoothed = (ymin, ymax) + + span = smoothed[1] - smoothed[0] + if span < self.y_axis_min_span: + mid = 0.5 * (smoothed[0] + smoothed[1]) + half = self.y_axis_min_span / 2.0 + smoothed = (mid - half, mid + half) + + self._y_range_smooth[channel] = smoothed + pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01) + return smoothed[0] - pad, smoothed[1] + pad + def state(self) -> Optional[PlotState]: n = self.sample_count if n == 0: @@ -51,6 +133,14 @@ class MetricHistory: block = self._data[:n] times = block[:, 0].copy() values = block[:, 1:].copy() + if self.lowpass_cutoff_hz > 0: + for i in range(self.channels): + values[:, i] = lowpass_filter( + values[:, i], + self.lowpass_cutoff_hz, + sample_rate_hz=self.sample_rate_hz, + ) + if n < self.npoints: x_range = (0.0, self.window_sec) else: @@ -67,15 +157,7 @@ class MetricHistory: ymax = float(np.nanmax(col)) if not np.isfinite(ymin) or not np.isfinite(ymax): ymin, ymax = 0.0, 1.0 - if abs(ymax - ymin) < 1e-6: - pad = 1.0 if abs(ymin) < 1e-6 else abs(ymin) * 0.1 - ymin -= pad - ymax += pad - else: - pad = (ymax - ymin) * 0.12 - ymin -= pad - ymax += pad - y_ranges.append((ymin, ymax)) + y_ranges.append(self._smooth_y_range(i, ymin, ymax)) return PlotState( times=times, diff --git a/src/sensor_bench/roi_geometry.py b/src/sensor_bench/roi_geometry.py new file mode 100644 index 0000000..5ba2352 --- /dev/null +++ b/src/sensor_bench/roi_geometry.py @@ -0,0 +1,144 @@ +"""Polygon ROI geometry helpers for DD06-style rectification.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np + + +def load_json_config(path: str | Path) -> dict[str, Any]: + path = Path(path) + if path.exists(): + with path.open("r", encoding="utf-8") as f: + return json.load(f) + try: + import orisys + + return orisys.configs.load_config(str(path)) + except Exception as exc: # noqa: BLE001 + raise FileNotFoundError(f"Config not found as file or bundled name: {path}") from exc + + +def load_roi_polygon(config: dict[str, Any]): + """Return (points Nx2 float32, image_size [w,h]) from nested or legacy ROI fields.""" + roi = config.get("roi") if isinstance(config.get("roi"), dict) else config + if not isinstance(roi, dict): + raise ValueError("Config has no usable ROI section") + + image_size = roi.get("image_size") or config.get("camera", {}).get("resolution_raw") + if image_size is None: + raise ValueError("ROI image_size is missing") + + points = roi.get("points") or roi.get("polygon") or roi.get("roi_polygon") + if points is None and roi.get("xyxy") is not None: + x1, y1, x2, y2 = [int(v) for v in roi["xyxy"]] + points = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]] + if not points: + raise ValueError("ROI points are missing") + + pts = np.asarray(points, dtype=np.float32).reshape(-1, 2) + if pts.shape[0] < 4: + raise ValueError(f"ROI needs at least 4 points, got {pts.shape[0]}") + return pts, [int(image_size[0]), int(image_size[1])] + + +def scale_points(points, source_size, target_size): + source_w, source_h = float(source_size[0]), float(source_size[1]) + target_w, target_h = float(target_size[0]), float(target_size[1]) + pts = np.asarray(points, dtype=np.float32).reshape(-1, 2).copy() + if source_w > 0 and source_h > 0 and (source_w, source_h) != (target_w, target_h): + pts[:, 0] *= target_w / source_w + pts[:, 1] *= target_h / source_h + return pts + + +def order_quad_points(points): + """Order 4 points as TL, TR, BR, BL for perspective transforms.""" + pts = np.asarray(points, dtype=np.float32).reshape(-1, 2) + if pts.shape[0] != 4: + hull = cv2.convexHull(pts.astype(np.float32)) + rect = cv2.minAreaRect(hull) + pts = cv2.boxPoints(rect).astype(np.float32) + + s = pts.sum(axis=1) + diff = np.diff(pts, axis=1).reshape(-1) + ordered = np.zeros((4, 2), dtype=np.float32) + ordered[0] = pts[np.argmin(s)] + ordered[2] = pts[np.argmax(s)] + ordered[1] = pts[np.argmin(diff)] + ordered[3] = pts[np.argmax(diff)] + return ordered + + +def rectified_output_size(config, points=None): + """Prefer camera.resolution_stitch; fall back to ROI bounding box size.""" + stitch = config.get("camera", {}).get("resolution_stitch") + if stitch is not None and len(stitch) >= 2: + return int(stitch[0]), int(stitch[1]) + if points is not None: + pts = np.asarray(points, dtype=np.float32).reshape(-1, 2) + width = max( + int(np.linalg.norm(pts[1] - pts[0])), + int(np.linalg.norm(pts[2] - pts[3])), + 1, + ) + height = max( + int(np.linalg.norm(pts[3] - pts[0])), + int(np.linalg.norm(pts[2] - pts[1])), + 1, + ) + return width, height + raise ValueError("Cannot determine rectified output size") + + +def build_roi_warp(points, source_image_size, frame_size, output_size): + frame_w, frame_h = int(frame_size[0]), int(frame_size[1]) + scaled = scale_points(points, source_image_size, (frame_w, frame_h)) + src = order_quad_points(scaled) + out_w, out_h = int(output_size[0]), int(output_size[1]) + dst = np.array( + [ + [0.0, 0.0], + [out_w - 1.0, 0.0], + [out_w - 1.0, out_h - 1.0], + [0.0, out_h - 1.0], + ], + dtype=np.float32, + ) + matrix = cv2.getPerspectiveTransform(src, dst) + return matrix, src, (out_w, out_h) + + +def warp_roi_to_rectangle(frame, matrix, output_size): + out_w, out_h = int(output_size[0]), int(output_size[1]) + return cv2.warpPerspective( + frame, + matrix, + (out_w, out_h), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_REPLICATE, + ) + + +def open_video_source(video): + """Open a camera index or video file. Returns (cap, resolved_source).""" + text = str(video).strip() + if text.isdigit(): + source = int(text) + cap = cv2.VideoCapture(source, cv2.CAP_DSHOW) + if not cap.isOpened(): + cap.release() + cap = cv2.VideoCapture(source) + else: + path = Path(text) + if not path.exists(): + raise FileNotFoundError(f"Video not found: {path}") + source = str(path) + cap = cv2.VideoCapture(source) + if not cap.isOpened(): + raise RuntimeError(f"Failed to open video source: {video}") + return cap, source diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 9340eed..7bbadc5 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -35,10 +35,13 @@ def test_translator_chinese_default() -> None: assert DEFAULT_LOCALE == "zh_CN" tr = Translator(default_locales_dir()) assert tr.locale == "zh_CN" - assert "传感器" in tr.t("app.title") + assert "传感器" in tr.t("app.title") or "展示" in tr.t("exhibition.title") tr.set_locale("en_US") assert tr.t("app.title") == "Sensor Bench" - assert "Power on" in tr.step_title("power_on") + assert "Exhibition" in tr.t("exhibition.title") + # Public locales must not ship internal inspection workflow copy. + assert "workflow" not in (tr._catalogs.get("en_US") or {}) + assert tr._lookup("en_US", "workflow.title") is None def test_sdk_worker_packet_mapping() -> None: @@ -65,6 +68,9 @@ def test_sdk_worker_packet_mapping() -> None: def read_info(self, *args): return 1.0, 2.0, 3.0 + def configure(self, **kwargs): + return self + def disconnect(self): pass @@ -102,65 +108,127 @@ def test_sdk_worker_packet_mapping() -> None: assert packet.processed_frame.shape[:2] == (365, 365) -def test_mainwindow_offscreen() -> None: +def test_force_calibration_and_plot_min_span() -> None: + from sensor_bench.force_calibration import ( + NORMAL_FORCE_CAL_OFFSET, + Y_AXIS_MIN_SPAN, + calibrate_normal_force, + ) + from sensor_bench.plot_history import MetricHistory + + assert abs(calibrate_normal_force(0.0) - NORMAL_FORCE_CAL_OFFSET) < 1e-12 + hist = MetricHistory( + npoints=8, + channels=3, + window_sec=1.0, + sample_rate_hz=30.0, + lowpass_cutoff_hz=0.0, + y_axis_min_span=Y_AXIS_MIN_SPAN, + y_range_alpha=0.5, + ) + for i in range(5): + hist.push(float(i) / 30.0, 100.0, 100.0, 100.0) + state = hist.state() + assert state is not None + for ymin, ymax in state.y_ranges: + assert (ymax - ymin) >= Y_AXIS_MIN_SPAN * 0.99 + + +def test_list_orisys_configs_excludes_options() -> None: + from sensor_bench.paths import ( + config_display_name, + list_local_sensor_config_files, + list_orisys_configs, + local_sensor_configs_dir, + ) + + entries = list_orisys_configs() + labels = [config_display_name(e).lower() for e in entries] + assert "options" not in labels + assert all(Path(e).stem.lower() != "options" for e in entries if Path(e).suffix.lower() == ".json") + + local_ddjx = local_sensor_configs_dir() / "ddjx01.json" + if local_ddjx.exists(): + local_files = list_local_sensor_config_files() + assert any(p.name == "ddjx01.json" for p in local_files) + assert any(config_display_name(e).lower() == "ddjx01" for e in entries) + + +def test_roi_geometry_helpers() -> None: + from sensor_bench.roi_geometry import build_roi_warp, order_quad_points, warp_roi_to_rectangle + import numpy as np + + points = np.array([[10, 10], [100, 12], [98, 90], [12, 88]], dtype=np.float32) + ordered = order_quad_points(points) + assert ordered.shape == (4, 2) + matrix, src, size = build_roi_warp(points, (120, 100), (120, 100), (64, 64)) + assert size == (64, 64) + assert src.shape == (4, 2) + frame = np.zeros((100, 120, 3), dtype=np.uint8) + out = warp_roi_to_rectangle(frame, matrix, size) + assert out.shape == (64, 64, 3) + + +def test_exhibition_window_offscreen() -> None: from PyQt5.QtWidgets import QApplication - from sensor_bench.app import MainWindow - from sensor_bench.acquisition import FixedCameraMode + from sensor_bench.exhibition_app import ExhibitionWindow from sensor_bench.i18n import DEFAULT_LOCALE app = QApplication.instance() or QApplication([]) - win = MainWindow() + win = ExhibitionWindow() assert win.tr.locale == DEFAULT_LOCALE == "zh_CN" - assert "传感器" in win.windowTitle() - assert win.btn_start.text() == "开始预览" - assert win.camera_index.value() == 0 - assert win.btn_next is not None - assert win.btn_revert is not None + assert "展示" in win.windowTitle() or "Sensor Bench" in win.windowTitle() + assert win.btn_start is not None + assert win.btn_stop is not None assert win.btn_reset is not None - assert win.sensor_type_combo.count() == 2 - assert win.indentor_combo.count() == 2 - assert win.platform_combo.count() == 3 - assert win.selected_sensor_options()["sensor_type"] == "BOX" - assert win.selected_sensor_options()["indentor"] == "1mm_circle" - assert win.selected_sensor_options()["platform"] == "manual" - assert not hasattr(win, "resolution_combo") - assert not hasattr(win, "btn_search") - assert win._current_step_index == 0 - assert not win.btn_revert.isEnabled() - assert win.btn_next.isEnabled() + assert win.btn_record is not None + assert win.btn_search is not None + assert win.btn_camera is not None + assert not hasattr(win, "btn_cal") + assert not hasattr(win, "cal_edit") + assert win.btn_reset.text() == "重置" + assert win.image_label is not None + assert win.log_widget is not None + assert not win.show_log_check.isChecked() + assert win.log_widget.isHidden() + assert win.show_log_check.text() == "显示日志面板" + win.show_log_check.setChecked(True) + assert win._log_panel_visible + assert not win.log_widget.isHidden() + win.show_log_check.setChecked(False) + assert win.log_widget.isHidden() + assert not win._log_panel_visible + assert len(win.plots) == 3 + assert len(win.curves) == 3 + assert win.btn_start.text() == "启动" + assert not win.btn_stop.isEnabled() + assert win._view_mode == "pure_color" + assert win.view_combo.currentData() == "pure_color" + assert win.about_action is not None + assert win.help_menu is not None + assert win.sensor_combo.count() == 2 + assert win.config_combo is not None + assert win.config_combo.count() >= 1 + from sensor_bench.paths import list_orisys_configs + + installed = list_orisys_configs() + if "ddjx01" in installed: + assert win.config_combo.currentData() == "ddjx01" or win._selected_config() == "ddjx01" - # Language switch refreshes labels without restart. win.language_combo.setCurrentIndex(1) assert win.tr.locale == "en_US" - assert win.windowTitle() == "Sensor Bench" - assert win.btn_start.text() == "Start Preview" + assert "Exhibition" in win.windowTitle() + assert win.btn_start.text() == "Start" + assert win.btn_reset.text() == "Reset" + assert win.show_log_check.text() == "Show log panel" + assert "About" in win.about_action.text() - # Workflow next / revert / reset. - total = len(win.workflow.steps) - win._on_step_next() - assert win._current_step_index == 1 - assert win.btn_revert.isEnabled() - win._on_step_revert() - assert win._current_step_index == 0 - for _ in range(total): - win._on_step_next() - assert win._current_step_index == total - assert not win.btn_next.isEnabled() - assert "completed" in win.workflow_status.text().lower() or "完成" in win.workflow_status.text() - win._on_step_reset() - assert win._current_step_index == 0 - assert win.btn_next.isEnabled() - - win._fixed_mode = FixedCameraMode( - width=1920, - height=1080, - fps=30.0, - config_ref=str(ROOT / "tests" / "fixtures" / "ddjx01_min.json"), - stitch_width=365, - stitch_height=365, - ) - win.mode_label.setText(win.t("labels.fixed_mode", mode=win._fixed_mode.label())) - assert "1920x1080" in win.mode_label.text() + win.view_combo.setCurrentIndex(1) + assert win._view_mode == "captured" + win.sensor_combo.setCurrentIndex(1) + assert win._sensor_type == "dd06" + selected = str(win._selected_config()).lower() + assert "dd06" in selected or selected.endswith(".json") win.close() @@ -168,5 +236,8 @@ if __name__ == "__main__": test_load_fixed_mode() test_translator_chinese_default() test_sdk_worker_packet_mapping() - test_mainwindow_offscreen() + test_force_calibration_and_plot_min_span() + test_list_orisys_configs_excludes_options() + test_roi_geometry_helpers() + test_exhibition_window_offscreen() print("ok")