first commit

This commit is contained in:
ykcheungab
2026-08-06 15:18:27 +08:00
commit a5a73d76d1
37 changed files with 2697 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
__pycache__/
*.py[cod]
.pytest_cache/
*.egg-info/
.venv/
venv/
/dist/
/build/pyinstaller_work/
/recordings/*
!/recordings/.gitkeep
/tools/ffmpeg/bin/
*.exe
*.log
.DS_Store
+68
View File
@@ -0,0 +1,68 @@
# Sensor Bench
Sensor Bench 是一款用于触觉传感器测试的 Windows 桌面工具。它基于 Orisys SDK,可预览原始相机画面、显示处理后的传感器图像、录制原始视频,并在各测试步骤中监控 FN / FX / FY 力曲线。
## 界面示意
![Sensor Bench GUI](bench_snapshot.png)
应用默认使用简体中文界面,可在语言选择器中切换到英文。
## 功能
- 通过左侧流程引导操作员完成检测,支持 **下一步**、**上一步**、**新样品 / 重置**。
- 从 `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`。
## 运行环境
推荐环境:
- Windows x64
- 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` 也会自动检测并加入路径。
运行冒烟测试:
```powershell
python tests/test_smoke.py
```
## 打包 EXE
发布包使用 PyInstaller `onedir` 模式,并依赖 Python 3.10 的 conda 环境。
1. 创建构建环境:
```bat
.\build\create_env.bat
```
2. 构建可执行文件:
```bat
.\build\build_exe.bat
```
输出路径:
```text
dist/SensorBench/SensorBench.exe
```
打包结果包含 `config/`、`config/locales/` 和 `steps/`,操作员无需改代码即可编辑选项、翻译文案和工作流程。
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+58
View File
@@ -0,0 +1,58 @@
# Sensor Bench EXE Build
This folder contains the Windows PyInstaller build setup for Sensor Bench.
## Requirements
- Windows x64
- Anaconda or Miniconda available as `conda`
- 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.
## Build
From the repository root (cmd or PowerShell):
```bat
.\build\create_env.bat
.\build\build_exe.bat
```
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
powershell -NoProfile -ExecutionPolicy Bypass -File .\build\build_exe.ps1
```
Output:
```text
dist/SensorBench/SensorBench.exe
```
The build is `onedir` rather than `onefile` so startup is faster and bundled DLL/resource issues are easier to diagnose.
## Validation
The build script runs:
```powershell
python tests/test_smoke.py
python build/verify_dist.py
```
Manual hardware check:
1. Run `dist\SensorBench\SensorBench.exe`.
2. Confirm the 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`.
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.
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0.."
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_exe.ps1" %*
+42
View File
@@ -0,0 +1,42 @@
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"
$SpecPath = Join-Path $PSScriptRoot "sensor_bench.spec"
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
conda run -n $EnvName python -m pip install -e ".[pack]"
conda run -n $EnvName python -m pip install --upgrade orisys
conda run -n $EnvName python tests/test_smoke.py
if (Test-Path $WorkDir) {
Remove-Item -Recurse -Force $WorkDir
}
if (Test-Path (Join-Path $DistDir "SensorBench")) {
Remove-Item -Recurse -Force (Join-Path $DistDir "SensorBench")
}
conda run -n $EnvName python -m PyInstaller --noconfirm --clean --distpath "$DistDir" --workpath "$WorkDir" "$SpecPath"
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
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 "%~dp0create_env.ps1" %*
+30
View File
@@ -0,0 +1,30 @@
param(
[string]$EnvName = "sensor-bench-build-py310",
[string]$PythonVersion = "3.10"
)
$ErrorActionPreference = "Stop"
$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
if (-not (Get-Command conda -ErrorAction SilentlyContinue)) {
throw "conda was not found on PATH. Open an Anaconda Prompt or initialize conda for PowerShell."
}
$envList = conda env list | Out-String
if ($envList -notmatch "(?m)^\s*$([regex]::Escape($EnvName))\s") {
conda create -y -n $EnvName python=$PythonVersion
}
conda run -n $EnvName python -m pip install --upgrade pip
Push-Location $RepoRoot
try {
conda run -n $EnvName python -m pip install -e ".[pack]"
}
finally {
Pop-Location
}
Write-Host "Build environment ready: $EnvName"
Write-Host "Orisys will be installed from PyPI by build_exe.ps1."
+98
View File
@@ -0,0 +1,98 @@
# -*- 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"
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.acquisition",
"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")
excludes = [
"cupy",
"cupyx",
"pyopencl",
"open3d",
"matplotlib",
"tkinter",
"pytest",
"pip",
"wheel",
]
a = Analysis(
[str(repo_root / "run.py")],
pathex=[str(repo_root), str(src_dir)],
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="SensorBench",
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="SensorBench",
)
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
"""Verify the PyInstaller onedir output has the expected portable layout."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist" / "SensorBench"
def require(path: Path) -> None:
if not path.exists():
raise FileNotFoundError(f"Missing build artifact: {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 / "_internal")
print(f"Verified SensorBench dist layout: {DIST}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+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": 1
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":5,
"div_threshold":0.05,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 12
}
}
+123
View File
@@ -0,0 +1,123 @@
{
"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"
},
"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": "DDJX01 config JSON path or name",
"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."
},
"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."
}
}
}
}
+123
View File
@@ -0,0 +1,123 @@
{
"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": "力"
},
"buttons": {
"config": "配置…",
"cal": "标定…",
"start_preview": "开始预览",
"stop_preview": "停止预览",
"browse": "浏览…",
"start_record": "开始录制",
"stop_record": "停止录制",
"next": "下一步",
"revert": "上一步",
"reset": "新样品 / 重置"
},
"placeholders": {
"config": "DDJX01 配置 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": "全部步骤已完成。可点击“新样品 / 重置”开始下一个样品。"
},
"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。"
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"sensor_types": [
{"id": "BOX", "label": "BOX"},
{"id": "MINI", "label": "MINI"}
],
"elastomer_types": [
{"id": "shoreA20", "label": "冯总20度"},
{"id": "shore0030", "label": "0度"},
{"id": "solaris", "label": "铂金"}
],
"cmos_types": [
{"id": "ov2710", "label": "OV2710"},
{"id": "ov9745", "label": "OV9745"}
],
"marker_types": [
{"id": "4color", "label": "4Color"}
],
"indentor_types": [
{"id": "1mm_circle", "label": "1mm圆柱"},
{"id": "1mm_ball", "label": "1mm球"}
],
"platoform_types": [
{"id": "manual", "label": "手动"},
{"id": "single_axis", "label": "单轴"},
{"id": "six_axis", "label": "六轴"}
]
}
+31
View File
@@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
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"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }]
dependencies = [
"numpy>=1.24.4",
"opencv-python>=4.10.0.84",
"PyQt5>=5.15",
"pyqtgraph>=0.13",
]
[project.optional-dependencies]
pack = [
"pyinstaller>=6.0",
]
[project.scripts]
sensor-bench = "sensor_bench.main:main"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
View File
+3
View File
@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
python "%~dp0run.py" %*
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Launch Sensor Bench without installing the package.
Usage (from the repository root):
python run.py
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
# Prefer sibling orisys-sdk checkout for Sensor-owned preview.
ORISYS_SRC = ROOT.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.main import main # noqa: E402
if __name__ == "__main__":
raise SystemExit(main())
+3
View File
@@ -0,0 +1,3 @@
"""Sensor Bench: operator tooling for raw sensor inspection and recording."""
__version__ = "0.1.0"
+290
View File
@@ -0,0 +1,290 @@
"""Orisys SDK-owned fixed-format acquisition for Sensor Bench.
Camera open/read/process is owned by ``orisys.Sensor`` using the DDJX01
config (1920x1080 @ 30 FPS MJPG by default). Sensor Bench only displays
``sensor.frame`` (raw) and ``get_img()`` (processed) plus force metrics.
"""
from __future__ import annotations
import json
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
import cv2
import numpy as np
@dataclass(frozen=True)
class FixedCameraMode:
"""Stream mode taken from the Orisys config JSON."""
width: int
height: int
fps: float
config_ref: str
stitch_width: int = 0
stitch_height: int = 0
def label(self) -> str:
return f"{self.width}x{self.height} @{self.fps:g} (DDJX01 / MJPG)"
@dataclass
class SdkPacket:
"""One acquisition tick from the Orisys Sensor."""
raw_frame: np.ndarray
processed_frame: np.ndarray
timestamp: float
fps: float
process_ms: float
width: int
height: int
processed_width: int
processed_height: int
normal_force: float = 0.0
shear_x: float = 0.0
shear_y: float = 0.0
session_id: int = 0
mode: Optional[FixedCameraMode] = None
def orisys_importable() -> tuple[bool, str]:
try:
import orisys # noqa: F401
return True, getattr(__import__("orisys"), "__version__", "unknown")
except Exception as exc: # noqa: BLE001
return False, str(exc)
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
ref = str(config_ref).strip()
path = Path(ref)
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
resolved_ref = str(path.resolve())
else:
try:
import orisys
data = orisys.configs.load_config(ref)
resolved_ref = ref
except Exception as exc: # noqa: BLE001
raise FileNotFoundError(
f"Orisys config not found as a file or bundled config name: {ref}"
) from exc
camera = data.get("camera") or {}
raw = camera.get("resolution_raw") or [1920, 1080]
stitch = camera.get("resolution_stitch") or [0, 0]
fps = float(camera.get("fps") or 30.0)
if not isinstance(raw, (list, tuple)) or len(raw) < 2:
raise ValueError(f"Invalid camera.resolution_raw in {ref}")
return FixedCameraMode(
width=int(raw[0]),
height=int(raw[1]),
fps=fps,
config_ref=resolved_ref,
stitch_width=int(stitch[0]) if len(stitch) >= 2 else 0,
stitch_height=int(stitch[1]) if len(stitch) >= 2 else 0,
)
def _ensure_bgr(img: np.ndarray) -> np.ndarray:
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 4:
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
return img
class SdkAcquisitionWorker:
"""Background loop: ``Sensor.get_img()`` + deformation + contact."""
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._mode: Optional[FixedCameraMode] = None
self._video_source: Optional[str | int] = None
self._cal_path: Optional[str] = None
self._backend = "auto"
@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,
cal_path: Optional[str | Path] = None,
backend: str = "auto",
) -> FixedCameraMode:
self.stop()
ok, detail = orisys_importable()
if not ok:
raise RuntimeError(
"orisys SDK is not importable. Install/build orisys-sdk and ensure "
f"it is on PYTHONPATH. Detail: {detail}"
)
mode = load_fixed_mode(config_path)
cal = Path(cal_path) if cal_path else None
if cal is not None and not cal.exists():
raise FileNotFoundError(f"Calibration not found: {cal}")
self._mode = mode
self._video_source = video_source
self._cal_path = str(cal) if cal is not None else None
self._backend = backend or "auto"
self._latest = None
self._session_id += 1
session = self._session_id
self._stop.clear()
self._thread = threading.Thread(
target=self._run,
name=f"SdkAcquisition-{session}",
daemon=True,
args=(session,),
)
self._thread.start()
return 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_sensor()
with self._lock:
self._latest = None
def _close_sensor(self) -> None:
sensor = self._sensor
self._sensor = None
if sensor is None:
return
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
if mode is None or source is None:
return
try:
self._status(f"Opening Orisys Sensor ({mode.label()})…")
kwargs = {
"config_name": mode.config_ref,
"verbose": False,
"backend": self._backend,
}
if self._cal_path:
kwargs["cal_path"] = self._cal_path
self._sensor = orisys.Sensor(source, **kwargs)
self._status(f"Streaming {mode.label()}")
except Exception as exc: # noqa: BLE001
if self._on_error is not None and session == self._session_id:
self._on_error(str(exc))
return
frame_times: list[float] = []
while not self._stop.is_set() and session == self._session_id:
try:
t0 = time.perf_counter()
processed = self._sensor.get_img()
if processed is None:
time.sleep(0.005)
continue
raw = getattr(self._sensor, "frame", None)
if raw is None:
time.sleep(0.005)
continue
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
try:
fn, fx, fy = self._sensor.read_info(
self._sensor.info.FNORMAL,
self._sensor.info.FSHEARX,
self._sensor.info.FSHEARY,
)
except Exception: # noqa: BLE001
pass
raw_bgr = _ensure_bgr(raw)
proc_bgr = _ensure_bgr(processed)
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,
)
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_sensor()
self._status("Acquisition stopped")
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
"""Load JSON configuration for sensor options and workflow steps."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class OptionItem:
id: str
label: str
@dataclass(frozen=True)
class SensorOptions:
sensor_types: list[OptionItem]
elastomer_types: list[OptionItem]
cmos_types: list[OptionItem]
marker_types: list[OptionItem]
indentor_types: list[OptionItem]
platform_types: list[OptionItem]
@dataclass(frozen=True)
class StepItem:
id: str
@dataclass(frozen=True)
class WorkflowSteps:
steps: list[StepItem]
def _parse_options(raw: list[Any]) -> list[OptionItem]:
items: list[OptionItem] = []
for entry in raw:
if isinstance(entry, str):
items.append(OptionItem(id=entry, label=entry))
continue
item_id = str(entry.get("id", "")).strip()
label = str(entry.get("label", item_id)).strip() or item_id
if item_id:
items.append(OptionItem(id=item_id, label=label))
return items
def load_sensor_options(path: Path) -> SensorOptions:
data = json.loads(path.read_text(encoding="utf-8"))
return SensorOptions(
sensor_types=_parse_options(data.get("sensor_types", [])),
elastomer_types=_parse_options(data.get("elastomer_types", [])),
cmos_types=_parse_options(data.get("cmos_types", [])),
marker_types=_parse_options(data.get("marker_types", [])),
indentor_types=_parse_options(data.get("indentor_types", [])),
platform_types=_parse_options(
data.get("platform_types", data.get("platoform_types", []))
),
)
def load_workflow_steps(path: Path) -> WorkflowSteps:
data = json.loads(path.read_text(encoding="utf-8"))
steps: list[StepItem] = []
for entry in data.get("steps", []):
if isinstance(entry, str):
step_id = entry.strip()
else:
step_id = str(entry.get("id", f"step_{len(steps)+1}")).strip()
if step_id:
steps.append(StepItem(id=step_id))
return WorkflowSteps(steps=steps)
+77
View File
@@ -0,0 +1,77 @@
"""Simple JSON-backed localization for Sensor Bench."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Optional
DEFAULT_LOCALE = "zh_CN"
FALLBACK_LOCALE = "en_US"
class Translator:
"""Lookup translated strings with English fallback."""
def __init__(self, locales_dir: Path, *, locale: str = DEFAULT_LOCALE):
self.locales_dir = Path(locales_dir)
self._catalogs: dict[str, dict[str, Any]] = {}
self.locale = locale
self.reload()
def available_locales(self) -> list[str]:
found = sorted(p.stem for p in self.locales_dir.glob("*.json"))
return found or [DEFAULT_LOCALE, FALLBACK_LOCALE]
def reload(self) -> None:
self._catalogs.clear()
for path in self.locales_dir.glob("*.json"):
try:
self._catalogs[path.stem] = json.loads(path.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
continue
if self.locale not in self._catalogs and DEFAULT_LOCALE in self._catalogs:
self.locale = DEFAULT_LOCALE
elif self.locale not in self._catalogs and FALLBACK_LOCALE in self._catalogs:
self.locale = FALLBACK_LOCALE
def set_locale(self, locale: str) -> None:
self.locale = locale
if locale not in self._catalogs:
self.reload()
def t(self, key: str, **kwargs: Any) -> str:
text = self._lookup(self.locale, key)
if text is None:
text = self._lookup(FALLBACK_LOCALE, key)
if text is None:
text = key
if kwargs:
try:
return str(text).format(**kwargs)
except Exception: # noqa: BLE001
return str(text)
return str(text)
def step_title(self, step_id: str) -> str:
return self.t(f"workflow.steps.{step_id}.title")
def step_body(self, step_id: str) -> str:
return self.t(f"workflow.steps.{step_id}.body")
def workflow_title(self) -> str:
return self.t("workflow.title")
def _lookup(self, locale: str, key: str) -> Optional[str]:
data: Any = self._catalogs.get(locale)
if not isinstance(data, dict):
return None
node: Any = data
for part in key.split("."):
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
if isinstance(node, str):
return node
return None
+24
View File
@@ -0,0 +1,24 @@
"""Entry point for the Sensor Bench desktop application."""
from __future__ import annotations
import sys
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")
qt_app.setOrganizationName("Orisys-Tech")
window = MainWindow()
window.show()
return qt_app.exec_()
if __name__ == "__main__":
raise SystemExit(main())
+80
View File
@@ -0,0 +1,80 @@
"""Resolve project resource paths for editable and packaged runs."""
from __future__ import annotations
import sys
from pathlib import Path
def is_frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def app_root() -> Path:
"""Return the editable repo root or frozen executable directory."""
if is_frozen():
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parents[2]
def resource_root() -> Path:
"""Return bundled resource root for editable and PyInstaller runs."""
if is_frozen():
return Path(getattr(sys, "_MEIPASS", app_root())).resolve()
return app_root()
def project_root() -> Path:
"""Return the editable repo root or frozen executable directory."""
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"
def default_recordings_dir() -> Path:
path = app_root() / "recordings"
path.mkdir(parents=True, exist_ok=True)
return path
def default_orisys_sdk_root() -> Path:
"""Sibling ``orisys-sdk`` checkout when sensor-bench lives next to it."""
sibling = app_root().parent / "orisys-sdk"
if sibling.exists():
return sibling
return Path(r"D:\repo\orisys-sdk")
def default_ddjx01_config() -> str:
if is_frozen():
return "ddjx01"
path = default_orisys_sdk_root() / "config" / "ddjx01.json"
if path.exists():
return str(path)
return "ddjx01"
def default_ddjx01_calibration() -> Path:
external = app_root() / "config" / "ddjx01.npy"
if external.exists():
return external
return default_orisys_sdk_root() / "config" / "ddjx01.npy"
+85
View File
@@ -0,0 +1,85 @@
"""Simple rolling history for live performance curves."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np
@dataclass
class PlotState:
times: np.ndarray
values: np.ndarray # shape (n, channels)
x_range: tuple[float, float]
y_ranges: tuple[tuple[float, float], ...]
class MetricHistory:
"""Ring-style buffer for FPS / brightness (or other scalar metrics)."""
def __init__(self, npoints: int = 120, *, channels: int = 2, window_sec: float = 4.0):
self.npoints = int(npoints)
self.channels = int(channels)
self.window_sec = float(window_sec)
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
def push(self, timestamp: float, *values: float) -> None:
if len(values) != self.channels:
raise ValueError(f"Expected {self.channels} values, got {len(values)}")
if self._t0 is None:
self._t0 = float(timestamp)
t = float(timestamp) - self._t0
row = (t, *map(float, values))
if self.sample_count < self.npoints:
self._data[self.sample_count, :] = row
self.sample_count += 1
return
self._data[:-1, :] = self._data[1:, :]
self._data[-1, :] = row
def state(self) -> Optional[PlotState]:
n = self.sample_count
if n == 0:
return None
block = self._data[:n]
times = block[:, 0].copy()
values = block[:, 1:].copy()
if n < self.npoints:
x_range = (0.0, self.window_sec)
else:
x0 = float(times[0])
x1 = float(times[-1])
if x1 <= x0:
x1 = x0 + self.window_sec
x_range = (x0, x1)
y_ranges = []
for i in range(self.channels):
col = values[:, i]
ymin = float(np.nanmin(col))
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))
return PlotState(
times=times,
values=values,
x_range=x_range,
y_ranges=tuple(y_ranges),
)
+82
View File
@@ -0,0 +1,82 @@
"""Raw video recording helpers."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Optional
import cv2
import numpy as np
class RawRecorder:
"""Write raw camera frames to a timestamped MP4 file."""
def __init__(self) -> None:
self._writer: Optional[cv2.VideoWriter] = None
self.path: Optional[Path] = None
self.frame_count = 0
self._size: Optional[tuple[int, int]] = None
@property
def is_recording(self) -> bool:
return self._writer is not None
def start(self, output_dir: Path, *, fps: float, frame: np.ndarray) -> Path:
self.stop()
output_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
path = output_dir / f"raw_{stamp}.mp4"
write_frame = self._to_bgr(frame)
height, width = write_frame.shape[:2]
fps_out = float(fps) if fps and fps > 1.0 else 30.0
writer = cv2.VideoWriter(
str(path),
cv2.VideoWriter_fourcc(*"mp4v"),
fps_out,
(width, height),
)
if not writer.isOpened():
raise RuntimeError(f"Unable to create recording file: {path}")
self._writer = writer
self.path = path
self.frame_count = 0
self._size = (width, height)
self.write(frame)
return path
def write(self, frame: np.ndarray) -> None:
if self._writer is None:
return
write_frame = self._to_bgr(frame)
height, width = write_frame.shape[:2]
if self._size is not None and (width, height) != self._size:
write_frame = cv2.resize(write_frame, self._size, interpolation=cv2.INTER_AREA)
self._writer.write(write_frame)
self.frame_count += 1
def stop(self) -> tuple[Optional[Path], int]:
path = self.path
count = self.frame_count
if self._writer is not None:
self._writer.release()
self._writer = None
self.path = None
self.frame_count = 0
self._size = None
return path, count
@staticmethod
def _to_bgr(frame: np.ndarray) -> np.ndarray:
if frame.ndim == 2:
return cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
if frame.ndim == 3 and frame.shape[2] == 1:
return cv2.cvtColor(frame[:, :, 0], cv2.COLOR_GRAY2BGR)
if frame.ndim == 3 and frame.shape[2] == 4:
return cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR)
if frame.ndim == 3 and frame.shape[2] == 3:
return frame
raise ValueError(f"Unsupported frame shape for recording: {frame.shape}")
+13
View File
@@ -0,0 +1,13 @@
{
"title_key": "workflow.title",
"steps": [
{"id": "power_on"},
{"id": "select_camera"},
{"id": "set_options"},
{"id": "check_preview"},
{"id": "record"},
{"id": "review_curves"},
{"id": "sensitivity"},
{"id": "max_load"}
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365]
},
"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,
"n": [3, 3],
"box_size": [250, 250],
"blend": "numba"
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"algorithm": "dis",
"dis_preset": "medium"
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 5,
"div_threshold": 0.05
}
}
+5
View File
@@ -0,0 +1,5 @@
[dshow @ 000001] DirectShow video devices (some may be both video and audio devices)
[dshow @ 000001] "USB Camera"
[dshow @ 000001] Alternative name "@device_pnp_\\\\?\\usb#vid_0000"
[dshow @ 000001] DirectShow audio devices
[dshow @ 000001] "Microphone"
+7
View File
@@ -0,0 +1,7 @@
[in#0 @ 000001] "USB Camera" (video)
[in#0 @ 000001] Alternative name "@device_pnp_\\?\usb#vid_1bcf&pid_2281&mi_00#b&1d67a3f1&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global"
[in#0 @ 000001] "OBS Virtual Camera" (none)
[in#0 @ 000001] Alternative name "@device_sw_{860BB310-5D01-11D0-BD3B-00A0C911CE86}\{A3FCE0F5-3493-419F-958A-ABA1250EC20B}"
[in#0 @ 000001] "Microphone (SunplusIT Inc)" (audio)
[in#0 @ 000001] Alternative name "@device_cm_{33D9A762-90C8-11D0-BD43-00A0C911CE86}\wave_{47A9A322-5BE2-4304-9CA5-BFEC5748F002}"
Error opening input file dummy.
+5
View File
@@ -0,0 +1,5 @@
[dshow @ 000001] pixel_format=yuyv422 min s=640x480 fps=30 max s=640x480 fps=30
[dshow @ 000001] pixel_format=yuyv422 min s=1280x720 fps=10 max s=1280x720 fps=10
[dshow @ 000001] vcodec=mjpeg min s=640x480 fps=30 max s=640x480 fps=30
[dshow @ 000001] vcodec=mjpeg min s=1280x720 fps=30 max s=1280x720 fps=30
[dshow @ 000001] vcodec=mjpeg min s=1920x1080 fps=30 max s=1920x1080 fps=30
+172
View File
@@ -0,0 +1,172 @@
"""Smoke tests for Sensor Bench SDK acquisition and UI construction."""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
def test_load_fixed_mode() -> None:
from sensor_bench.acquisition import load_fixed_mode
mode = load_fixed_mode(ROOT / "tests" / "fixtures" / "ddjx01_min.json")
assert mode.width == 1920
assert mode.height == 1080
assert mode.fps == 30.0
assert mode.stitch_width == 365
assert "1920x1080" in mode.label()
def test_translator_chinese_default() -> None:
from sensor_bench.i18n import DEFAULT_LOCALE, Translator
from sensor_bench.paths import default_locales_dir
assert DEFAULT_LOCALE == "zh_CN"
tr = Translator(default_locales_dir())
assert tr.locale == "zh_CN"
assert "传感器" in tr.t("app.title")
tr.set_locale("en_US")
assert tr.t("app.title") == "Sensor Bench"
assert "Power on" in tr.step_title("power_on")
def test_sdk_worker_packet_mapping() -> None:
from sensor_bench.acquisition import SdkAcquisitionWorker
raw = np.zeros((1080, 1920, 3), dtype=np.uint8)
processed = np.full((365, 365, 3), 40, dtype=np.uint8)
class FakeSensor:
def __init__(self, *args, **kwargs):
self.frame = raw.copy()
self.info = SimpleNamespace(FNORMAL=1, FSHEARX=2, FSHEARY=3)
def get_img(self):
self.frame = raw.copy()
return processed.copy()
def compute_deformation(self, **kwargs):
return True
def compute_contact(self):
return True
def read_info(self, *args):
return 1.0, 2.0, 3.0
def disconnect(self):
pass
fake_orisys = MagicMock()
fake_orisys.Sensor = FakeSensor
fake_orisys.__version__ = "test"
worker = SdkAcquisitionWorker()
with patch.dict(sys.modules, {"orisys": fake_orisys}):
with patch("sensor_bench.acquisition.orisys_importable", return_value=(True, "test")):
mode = worker.start(
0,
config_path=ROOT / "tests" / "fixtures" / "ddjx01_min.json",
backend="cpu",
)
assert mode.width == 1920
deadline = time.time() + 2.0
packet = None
while time.time() < deadline:
packet = worker.latest()
if packet is not None:
break
time.sleep(0.02)
worker.stop()
assert packet is not None
assert packet.width == 1920
assert packet.height == 1080
assert packet.processed_width == 365
assert packet.processed_height == 365
assert packet.normal_force == 1.0
assert packet.shear_x == 2.0
assert packet.shear_y == 3.0
assert packet.raw_frame.shape[:2] == (1080, 1920)
assert packet.processed_frame.shape[:2] == (365, 365)
def test_mainwindow_offscreen() -> None:
from PyQt5.QtWidgets import QApplication
from sensor_bench.app import MainWindow
from sensor_bench.acquisition import FixedCameraMode
from sensor_bench.i18n import DEFAULT_LOCALE
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.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_load_fixed_mode()
test_translator_chinese_default()
test_sdk_worker_packet_mapping()
test_mainwindow_offscreen()
print("ok")
+5
View File
@@ -0,0 +1,5 @@
Place a portable FFmpeg build here:
tools/ffmpeg/bin/ffmpeg.exe
Or install ffmpeg system-wide and ensure it is on PATH.