update sensor bench
This commit is contained in:
@@ -6,6 +6,7 @@ __pycache__/
|
||||
venv/
|
||||
/dist/
|
||||
/build/pyinstaller_work/
|
||||
/build/pyinstaller_work_operator/
|
||||
/recordings/*
|
||||
!/recordings/.gitkeep
|
||||
/tools/ffmpeg/bin/
|
||||
|
||||
@@ -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` 与完整操作员文案,可直接编辑。
|
||||
|
||||
+81
-11
@@ -1,6 +1,9 @@
|
||||
# Sensor Bench EXE Build
|
||||
|
||||
This folder contains the Windows PyInstaller build setup for Sensor Bench.
|
||||
This folder contains Windows PyInstaller build setups for:
|
||||
|
||||
- **Public** exhibition app → `dist/SensorBench/`
|
||||
- **Private** operator app → `dist/SensorBenchOperator/` (internal use only)
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -9,7 +12,8 @@ This folder contains the Windows PyInstaller build setup for Sensor Bench.
|
||||
- Python 3.10 build environment
|
||||
- Internet access to install `orisys` from PyPI
|
||||
|
||||
The build installs `orisys` from PyPI. Do not use `PYTHONPATH` or an editable `orisys-sdk` checkout for release builds.
|
||||
The build installs `orisys` from PyPI. Do not use `PYTHONPATH` or an editable
|
||||
`orisys-sdk` checkout for release builds.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -20,7 +24,8 @@ From the repository root (cmd or PowerShell):
|
||||
.\build\build_exe.bat
|
||||
```
|
||||
|
||||
The `.bat` wrappers bypass the local PowerShell execution policy. If you prefer calling PowerShell directly:
|
||||
The `.bat` wrappers bypass the local PowerShell execution policy. If you prefer
|
||||
calling PowerShell directly:
|
||||
|
||||
```bat
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\build\create_env.ps1
|
||||
@@ -33,7 +38,23 @@ Output:
|
||||
dist/SensorBench/SensorBench.exe
|
||||
```
|
||||
|
||||
The build is `onedir` rather than `onefile` so startup is faster and bundled DLL/resource issues are easier to diagnose.
|
||||
After installing/upgrading `orisys`, the build syncs wheel sensor `*.json` into
|
||||
repo `config/` via `build/sync_wheel_configs.py` (for example `ddjx01.json`)
|
||||
before PyInstaller, never touching `locales/`. The helper reads from the
|
||||
installed wheel's bundled `orisys/configs/` directory instead of
|
||||
`orisys.configs.CONFIG_DIR`, which can point at the repo's local `config/`
|
||||
during builds. PyInstaller bundles that `config/`, then the script copies repo
|
||||
`config/` beside the EXE so operators can edit sensor JSON and locales.
|
||||
|
||||
The public build intentionally excludes:
|
||||
|
||||
- `internal/` (private operator UI)
|
||||
- `steps/` workflow JSON
|
||||
- `config/options.json` lab component metadata
|
||||
- `sensor_bench_operator` / former `sensor_bench.app` modules
|
||||
|
||||
The build is `onedir` rather than `onefile` so startup is faster and bundled
|
||||
DLL/resource issues are easier to diagnose.
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -44,15 +65,64 @@ python tests/test_smoke.py
|
||||
python build/verify_dist.py
|
||||
```
|
||||
|
||||
`verify_dist.py` checks the expected public layout **and** fails if internal
|
||||
paths, operator modules, workflow locale keys, or private workflow text appear
|
||||
in the dist tree.
|
||||
|
||||
Manual hardware check:
|
||||
|
||||
1. Run `dist\SensorBench\SensorBench.exe`.
|
||||
2. Confirm the UI opens without setting `PYTHONPATH`.
|
||||
2. Confirm the exhibition UI opens without setting `PYTHONPATH`.
|
||||
3. Confirm the UI defaults to Simplified Chinese and can switch to English.
|
||||
4. Confirm Orisys status is available and fixed mode shows DDJX01.
|
||||
5. Use Next / Revert / New Sample Reset on the left workflow panel.
|
||||
6. Start preview with camera index `0`.
|
||||
7. Confirm raw preview and processed preview update.
|
||||
8. Record a short clip and verify it appears under `dist\SensorBench\recordings`.
|
||||
4. Confirm Orisys status is available, the config dropdown lists local editable
|
||||
sensor configs (for example `ddjx01`), and Start uses DDJX01.
|
||||
5. Confirm Search finds cameras (or logs none) and Camera still opens a
|
||||
video-file picker. There is no Cal (.npy) field; Reset resets the Orisys
|
||||
tracker while preview is running.
|
||||
6. Start preview with camera index `0` (or a searched index / video file).
|
||||
7. Confirm the image panel and FN/FX/FY plots update.
|
||||
8. Toggle processed/raw view and start/stop recording under
|
||||
`dist\SensorBench\recordings`.
|
||||
|
||||
Locale files are bundled under `_internal/config/locales/` (and can also be placed beside the EXE under `config/locales/` for overrides). Edit those JSON files to change operator-facing text without rebuilding.
|
||||
Locale files are bundled under `_internal/config/locales/` (and can also be
|
||||
placed beside the EXE under `config/locales/` for overrides). Edit those JSON
|
||||
files to change exhibition-facing text without rebuilding.
|
||||
|
||||
## Private operator EXE (internal only)
|
||||
|
||||
Do **not** ship `SensorBenchOperator` to customers. It includes workflow steps,
|
||||
lab `options.json`, and operator locale copy.
|
||||
|
||||
```bat
|
||||
.\build\create_env.bat
|
||||
.\build\build_operator_exe.bat
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```bat
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\build\build_operator_exe.ps1
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
dist/SensorBenchOperator/SensorBenchOperator.exe
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
```powershell
|
||||
python tests/test_smoke.py
|
||||
python internal/tests/test_operator_smoke.py
|
||||
python build/verify_operator_dist.py
|
||||
```
|
||||
|
||||
Manual check:
|
||||
|
||||
1. Run `dist\SensorBenchOperator\SensorBenchOperator.exe`.
|
||||
2. Confirm the workflow/steps panel and component options appear.
|
||||
3. Confirm Start Preview / record / force curves still work with a camera.
|
||||
4. Confirm `config/options.json` and `steps/default.json` beside the EXE are editable.
|
||||
|
||||
Source-level operator docs: [`../internal/README.md`](../internal/README.md).
|
||||
|
||||
+20
-3
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "%~dp0.."
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_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
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Copy Orisys wheel sensor JSON configs into repo config/ (exclude options.json)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def wheel_config_dir() -> Path:
|
||||
import orisys
|
||||
|
||||
inner_pkg = Path(orisys.__file__).resolve().parent
|
||||
for candidate in (inner_pkg / "configs", inner_pkg.parent / "configs"):
|
||||
if candidate.is_dir() and any(candidate.glob("*.json")):
|
||||
return candidate
|
||||
return inner_pkg.parent / "configs"
|
||||
|
||||
|
||||
def sync_configs(repo_root: Path) -> int:
|
||||
src = wheel_config_dir()
|
||||
if not src.is_dir():
|
||||
print(f"Orisys wheel configs directory not found: {src}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
dst = repo_root / "config"
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
copied = 0
|
||||
for path in sorted(src.glob("*.json")):
|
||||
if path.stem.lower() == "options":
|
||||
continue
|
||||
shutil.copy2(path, dst / path.name)
|
||||
copied += 1
|
||||
|
||||
print(f"Synced {copied} config(s) from {src} to {dst}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
return sync_configs(repo_root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+91
-5
@@ -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())
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 814 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+44
-84
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
+44
-84
@@ -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。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "%~dp0.."
|
||||
python "%~dp0run_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())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Private Sensor Bench operator workflow UI (internal use only)."""
|
||||
|
||||
from .app import MainWindow
|
||||
|
||||
__all__ = ["MainWindow"]
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
+1
-1
@@ -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" }]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,6 +298,15 @@ class SdkAcquisitionWorker:
|
||||
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,
|
||||
@@ -278,6 +333,7 @@ class SdkAcquisitionWorker:
|
||||
shear_y=float(fy),
|
||||
session_id=session,
|
||||
mode=mode,
|
||||
flow=flow,
|
||||
)
|
||||
with self._lock:
|
||||
self._latest = packet
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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_()
|
||||
|
||||
|
||||
|
||||
+120
-20
@@ -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,13 +53,112 @@ def default_orisys_sdk_root() -> Path:
|
||||
return Path(r"D:\repo\orisys-sdk")
|
||||
|
||||
|
||||
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:
|
||||
if is_frozen():
|
||||
return "ddjx01"
|
||||
path = default_orisys_sdk_root() / "config" / "ddjx01.json"
|
||||
if path.exists():
|
||||
return str(path)
|
||||
return "ddjx01"
|
||||
"""Prefer local editable config path, else package stem."""
|
||||
return _pick_default_config(default_config_name("orisys"))
|
||||
|
||||
|
||||
def default_ddjx01_calibration() -> Path:
|
||||
@@ -78,3 +166,15 @@ def default_ddjx01_calibration() -> Path:
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
+123
-52
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user