update sensor bench

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

+35
View File
@@ -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
}
}
+101
View File
@@ -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"
}
}
}
+101
View File
@@ -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"
}
}
}
+46
View File
@@ -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
}
}
+48
View File
@@ -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
}
}
+47
View File
@@ -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
}
}
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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。"
}
}
+47
View File
@@ -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
}
}
+46
View File
@@ -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
}
}
+44
View File
@@ -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
}
}
+44
View File
@@ -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
}
}
+44
View File
@@ -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
}
}
+43
View File
@@ -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
}
}
+57
View File
@@ -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)
```
+166
View File
@@ -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."
}
}
}
}
+166
View File
@@ -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。"
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0.."
python "%~dp0run_operator.py" %*
+34
View File
@@ -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"
+82
View File
@@ -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
View File
@@ -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" }]
+8 -1
View File
@@ -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 -1
View File
@@ -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"
+56
View File
@@ -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
+138
View File
@@ -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
+243
View File
@@ -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")
+910
View File
@@ -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)
+20
View File
@@ -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
+15 -7
View File
@@ -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
View File
@@ -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
+94 -12
View File
@@ -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,
+144
View File
@@ -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
View File
@@ -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")