first commit
This commit is contained in:
+14
@@ -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
|
||||
@@ -0,0 +1,68 @@
|
||||
# Sensor Bench
|
||||
|
||||
Sensor Bench 是一款用于触觉传感器测试的 Windows 桌面工具。它基于 Orisys SDK,可预览原始相机画面、显示处理后的传感器图像、录制原始视频,并在各测试步骤中监控 FN / FX / FY 力曲线。
|
||||
|
||||
## 界面示意
|
||||
|
||||

|
||||
|
||||
应用默认使用简体中文界面,可在语言选择器中切换到英文。
|
||||
|
||||
## 功能
|
||||
|
||||
- 通过左侧流程引导操作员完成检测,支持 **下一步**、**上一步**、**新样品 / 重置**。
|
||||
- 从 `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 |
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "%~dp0.."
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build_exe.ps1" %*
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "%~dp0.."
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0create_env.ps1" %*
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
@@ -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 |
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "六轴"}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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())
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Sensor Bench: operator tooling for raw sensor inspection and recording."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
Vendored
+33
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user