update plot to force units

This commit is contained in:
ykcheungab
2026-09-01 16:24:12 +08:00
parent 8cacb12abc
commit e7789c86cb
31 changed files with 767 additions and 77 deletions
+5 -1
View File
@@ -9,6 +9,7 @@ $DistDir = Join-Path $RepoRoot "dist"
$WorkDir = Join-Path $RepoRoot "build\pyinstaller_work" $WorkDir = Join-Path $RepoRoot "build\pyinstaller_work"
$SpecPath = Join-Path $PSScriptRoot "sensor_bench.spec" $SpecPath = Join-Path $PSScriptRoot "sensor_bench.spec"
$RepoConfig = Join-Path $RepoRoot "config" $RepoConfig = Join-Path $RepoRoot "config"
$RepoSensors = Join-Path $RepoRoot "sensors"
if (-not (Get-Command conda -ErrorAction SilentlyContinue)) { if (-not (Get-Command conda -ErrorAction SilentlyContinue)) {
throw "conda was not found on PATH. Open an Anaconda Prompt or initialize conda for PowerShell." throw "conda was not found on PATH. Open an Anaconda Prompt or initialize conda for PowerShell."
@@ -42,8 +43,11 @@ try {
conda run -n $EnvName python -m PyInstaller --noconfirm --clean --distpath "$DistDir" --workpath "$WorkDir" "$SpecPath" conda run -n $EnvName python -m PyInstaller --noconfirm --clean --distpath "$DistDir" --workpath "$WorkDir" "$SpecPath"
if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed." } if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed." }
# Public package only: copy exhibition config/. Never ship internal/ operator assets. # Public package only: copy exhibition config/ and sensors/. Never ship internal/ operator assets.
Copy-Item -Recurse -Force $RepoConfig (Join-Path $DistDir "SensorBench") Copy-Item -Recurse -Force $RepoConfig (Join-Path $DistDir "SensorBench")
if (Test-Path $RepoSensors) {
Copy-Item -Recurse -Force $RepoSensors (Join-Path $DistDir "SensorBench")
}
$InternalLeak = Join-Path $DistDir "SensorBench\internal" $InternalLeak = Join-Path $DistDir "SensorBench\internal"
if (Test-Path $InternalLeak) { if (Test-Path $InternalLeak) {
throw "Public dist must not contain internal/: $InternalLeak" throw "Public dist must not contain internal/: $InternalLeak"
+1
View File
@@ -11,6 +11,7 @@ work_dir = repo_root / "build" / "pyinstaller_work"
datas = [ datas = [
(str(repo_root / "config"), "config"), (str(repo_root / "config"), "config"),
(str(repo_root / "sensors"), "sensors"),
] ]
binaries = [] binaries = []
hiddenimports = [ hiddenimports = [
+36 -2
View File
@@ -1,4 +1,4 @@
"""Copy Orisys wheel sensor JSON configs into repo config/ (exclude options.json).""" """Copy Orisys wheel sensor JSON configs and sensors/ bundles into the repo."""
from __future__ import annotations from __future__ import annotations
@@ -17,6 +17,16 @@ def wheel_config_dir() -> Path:
return inner_pkg.parent / "configs" return inner_pkg.parent / "configs"
def wheel_sensors_dir() -> Path | None:
import orisys
inner_pkg = Path(orisys.__file__).resolve().parent
for candidate in (inner_pkg / "sensors", inner_pkg.parent / "sensors"):
if candidate.is_dir() and any(candidate.iterdir()):
return candidate
return None
def sync_configs(repo_root: Path) -> int: def sync_configs(repo_root: Path) -> int:
src = wheel_config_dir() src = wheel_config_dir()
if not src.is_dir(): if not src.is_dir():
@@ -37,9 +47,33 @@ def sync_configs(repo_root: Path) -> int:
return 0 return 0
def sync_sensor_bundles(repo_root: Path) -> int:
src = wheel_sensors_dir()
if src is None or not src.is_dir():
print("Orisys wheel sensors/ directory not found; skipping bundle sync")
return 0
dst = repo_root / "sensors"
dst.mkdir(parents=True, exist_ok=True)
copied = 0
for bundle_dir in sorted(p for p in src.iterdir() if p.is_dir()):
target = dst / bundle_dir.name
if target.exists():
shutil.rmtree(target)
shutil.copytree(bundle_dir, target)
copied += 1
print(f"Synced {copied} sensor bundle(s) from {src} to {dst}")
return 0
def main() -> int: def main() -> int:
repo_root = Path(__file__).resolve().parents[1] repo_root = Path(__file__).resolve().parents[1]
return sync_configs(repo_root) code = sync_configs(repo_root)
if code != 0:
return code
return sync_sensor_bundles(repo_root)
if __name__ == "__main__": if __name__ == "__main__":
+2
View File
@@ -99,6 +99,8 @@ def main() -> int:
require(DIST / "config" / "locales" / "zh_CN.json") require(DIST / "config" / "locales" / "zh_CN.json")
require(DIST / "config" / "locales" / "en_US.json") require(DIST / "config" / "locales" / "en_US.json")
require(DIST / "config" / "ddjx01.json") require(DIST / "config" / "ddjx01.json")
require(DIST / "sensors" / "box" / "sensor.json")
require(DIST / "sensors" / "box" / "force_calibration.json")
require(DIST / "_internal") require(DIST / "_internal")
for rel in FORBIDDEN_RELATIVE_PATHS: for rel in FORBIDDEN_RELATIVE_PATHS:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

+3
View File
@@ -67,6 +67,9 @@
"plot_fn": "Normal Force FN", "plot_fn": "Normal Force FN",
"plot_fx": "Shear Force FX", "plot_fx": "Shear Force FX",
"plot_fy": "Shear Force FY", "plot_fy": "Shear Force FY",
"plot_with_unit": "{title} ({unit})",
"force_calibrated": "FN={fn:.4f} {unit} (raw={fn_raw:.4f}) | FX={fx:.4f} {unit} | FY={fy:.4f} {unit}",
"force_raw": "FN={fn:.4f} | FX={fx:.4f} | FY={fy:.4f} (raw)",
"reset_done": "Tracker reset.", "reset_done": "Tracker reset.",
"reset_need_start": "Start the sensor before resetting the tracker.", "reset_need_start": "Start the sensor before resetting the tracker.",
"reset_failed": "Reset failed: {message}", "reset_failed": "Reset failed: {message}",
+3
View File
@@ -67,6 +67,9 @@
"plot_fn": "法向力 FN", "plot_fn": "法向力 FN",
"plot_fx": "切向力 FX", "plot_fx": "切向力 FX",
"plot_fy": "切向力 FY", "plot_fy": "切向力 FY",
"plot_with_unit": "{title}({unit})",
"force_calibrated": "FN={fn:.4f} {unit}(raw={fn_raw:.4f})| FX={fx:.4f} {unit} | FY={fy:.4f} {unit}",
"force_raw": "FN={fn:.4f} | FX={fx:.4f} | FY={fy:.4f}(原始量)",
"reset_done": "追踪器已重置。", "reset_done": "追踪器已重置。",
"reset_need_start": "请先启动传感器后再重置追踪器。", "reset_need_start": "请先启动传感器后再重置追踪器。",
"reset_failed": "重置失败:{message}", "reset_failed": "重置失败:{message}",
+30
View File
@@ -0,0 +1,30 @@
{
"schema_version": 1,
"unit": "N",
"channels": {
"normal": {
"method": "piecewise_linear",
"raw_key": "fnormal",
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[1000.0, 0.0],
[10000.0, 0.377],
[30000.0, 1.377],
[62000.0, 2.377]
]
},
"shear": {
"method": "piecewise_linear",
"input": "components",
"signed": true,
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[1000.0, 0.0],
[36000.0, 1.19],
[68000.0, 2.06]
]
}
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365,263,123,63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1,2,3,6,7,8,11,12,13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3,3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":5,
"div_threshold":0.05,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 12
}
}
@@ -0,0 +1,9 @@
Place the ORY-FINGER runtime asset bundle files in this directory before
building release wheels:
- `mesh.npz`
- `camera_intrinsics.npz`
- `world_to_cam.npy`
These binary assets are validated by the wheel build and are intentionally not
generated by the package build step.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
Place visualization camera assets in this directory before building release
wheels:
- `world_to_cam.npy`
The 3D dashboard uses this pose as the default view preset when available.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "finger",
"sensor_json": "sensor.json",
"assets_dir": "assets/30fps-0703",
"viz_pose": "assets/visualization/world_to_cam.npy"
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "dd02-ov",
"type": "single_camera",
"version": "1.0",
"camera": {
"resolution_raw": [1280, 720],
"fps": 30,
"resolution_stitch": [280, 300],
"source_scale": 0.4,
"old":[672, 728, 1,0.5],
"work": [504, 546,1],
"work2": [420, 450,0.8],
"work3": [350, 375,4,0.7],
"work4": [280, 300,4,0.4,0.2]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 1,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 1
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 0.2,
"div_threshold": 0.022,
"expansion_threshold": 0.01,
"kernel_size": [5, 5],
"denoise_sigma": 12
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"schema_version": 1,
"unit": "N",
"channels": {
"normal": {
"method": "piecewise_linear",
"raw_key": "fnormal",
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[1000.0, 0.0],
[10000.0, 0.377],
[30000.0, 1.377],
[62000.0, 2.377]
]
},
"shear": {
"method": "piecewise_linear",
"input": "components",
"signed": true,
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[1000.0, 0.0],
[36000.0, 1.19],
[68000.0, 2.06]
]
}
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365,263,123,63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1,2,3,6,7,8,11,12,13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3,3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":5,
"div_threshold":0.05,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 12
}
}
+132 -22
View File
@@ -18,6 +18,11 @@ import cv2
import numpy as np import numpy as np
from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
from .paths import (
bundle_has_force_calibration,
normalize_sensor_config_ref,
resolve_force_bundle_from_config,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -35,6 +40,21 @@ class FixedCameraMode:
return f"{self.width}x{self.height} @{self.fps:g} (DDJX01 / MJPG)" return f"{self.width}x{self.height} @{self.fps:g} (DDJX01 / MJPG)"
@dataclass
class ForceReadout:
"""Calibrated and raw force values from one Orisys Sensor tick."""
normal_force: float = 0.0
shear_x: float = 0.0
shear_y: float = 0.0
normal_force_raw: float = 0.0
shear_x_raw: float = 0.0
shear_y_raw: float = 0.0
force_calibrated: bool = False
force_unit: str = "raw"
flow: Optional[np.ndarray] = None
@dataclass @dataclass
class SdkPacket: class SdkPacket:
"""One acquisition tick from the Orisys Sensor.""" """One acquisition tick from the Orisys Sensor."""
@@ -51,11 +71,109 @@ class SdkPacket:
normal_force: float = 0.0 normal_force: float = 0.0
shear_x: float = 0.0 shear_x: float = 0.0
shear_y: float = 0.0 shear_y: float = 0.0
normal_force_raw: float = 0.0
shear_x_raw: float = 0.0
shear_y_raw: float = 0.0
force_unit: str = "raw"
force_calibrated: bool = False
session_id: int = 0 session_id: int = 0
mode: Optional[FixedCameraMode] = None mode: Optional[FixedCameraMode] = None
flow: Optional[np.ndarray] = None flow: Optional[np.ndarray] = None
def sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) -> bool:
"""True when Orisys resolved sensors/<name>/force_calibration.json."""
if getattr(sensor, "_force_cal_sensor_name", None) is not None:
return True
bundle = resolve_force_bundle_from_config(config_ref)
return bundle is not None and bundle_has_force_calibration(bundle)
def read_forces_from_sensor(
sensor,
*,
include_flow: bool = True,
config_ref: str | Path | None = None,
) -> ForceReadout:
"""Read FN/FX/FY plus *_RAW from Orisys, with legacy SDK fallback."""
info = sensor.info
fn = fx = fy = 0.0
fn_raw = fx_raw = fy_raw = 0.0
flow = None
read_raw = all(
hasattr(info, name)
for name in ("FNORMAL_RAW", "FSHEARX_RAW", "FSHEARY_RAW")
)
has_flow = include_flow and hasattr(info, "VRAW")
try:
if read_raw and has_flow:
fn, fx, fy, fn_raw, fx_raw, fy_raw, flow = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.FNORMAL_RAW,
info.FSHEARX_RAW,
info.FSHEARY_RAW,
info.VRAW,
)
elif read_raw:
fn, fx, fy, fn_raw, fx_raw, fy_raw = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.FNORMAL_RAW,
info.FSHEARX_RAW,
info.FSHEARY_RAW,
)
elif has_flow:
fn, fx, fy, flow = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.VRAW,
)
fn_raw, fx_raw, fy_raw = fn, fx, fy
else:
fn, fx, fy = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
)
fn_raw, fx_raw, fy_raw = fn, fx, fy
except Exception: # noqa: BLE001
try:
if has_flow:
fn, fx, fy, flow = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.VRAW,
)
else:
fn, fx, fy = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
)
fn_raw, fx_raw, fy_raw = fn, fx, fy
except Exception: # noqa: BLE001
pass
calibrated = sensor_force_calibrated(sensor, config_ref=config_ref)
unit = "N" if calibrated else "raw"
return ForceReadout(
normal_force=float(fn),
shear_x=float(fx),
shear_y=float(fy),
normal_force_raw=float(fn_raw),
shear_x_raw=float(fx_raw),
shear_y_raw=float(fy_raw),
force_calibrated=calibrated,
force_unit=unit,
flow=flow,
)
def orisys_importable() -> tuple[bool, str]: def orisys_importable() -> tuple[bool, str]:
try: try:
import orisys # noqa: F401 import orisys # noqa: F401
@@ -255,7 +373,7 @@ class SdkAcquisitionWorker:
try: try:
self._status(f"Opening Orisys Sensor ({mode.label()})…") self._status(f"Opening Orisys Sensor ({mode.label()})…")
kwargs = { kwargs = {
"config_name": mode.config_ref, "config_name": normalize_sensor_config_ref(mode.config_ref),
"verbose": False, "verbose": False,
"backend": self._backend, "backend": self._backend,
} }
@@ -297,24 +415,11 @@ class SdkAcquisitionWorker:
frame_times = [t for t in frame_times if t >= cutoff] frame_times = [t for t in frame_times if t >= cutoff]
fps = float(len(frame_times)) if frame_times else 0.0 fps = float(len(frame_times)) if frame_times else 0.0
fn = fx = fy = 0.0 forces = read_forces_from_sensor(
flow = None self._sensor,
try: include_flow=True,
fn, fx, fy, flow = self._sensor.read_info( config_ref=mode.config_ref,
self._sensor.info.FNORMAL,
self._sensor.info.FSHEARX,
self._sensor.info.FSHEARY,
self._sensor.info.VRAW,
) )
except Exception: # noqa: BLE001
try:
fn, fx, fy = self._sensor.read_info(
self._sensor.info.FNORMAL,
self._sensor.info.FSHEARX,
self._sensor.info.FSHEARY,
)
except Exception: # noqa: BLE001
pass
raw_bgr = _ensure_bgr(raw) raw_bgr = _ensure_bgr(raw)
proc_bgr = _ensure_bgr(processed) proc_bgr = _ensure_bgr(processed)
@@ -328,12 +433,17 @@ class SdkAcquisitionWorker:
height=int(raw_bgr.shape[0]), height=int(raw_bgr.shape[0]),
processed_width=int(proc_bgr.shape[1]), processed_width=int(proc_bgr.shape[1]),
processed_height=int(proc_bgr.shape[0]), processed_height=int(proc_bgr.shape[0]),
normal_force=float(fn), normal_force=forces.normal_force,
shear_x=float(fx), shear_x=forces.shear_x,
shear_y=float(fy), shear_y=forces.shear_y,
normal_force_raw=forces.normal_force_raw,
shear_x_raw=forces.shear_x_raw,
shear_y_raw=forces.shear_y_raw,
force_unit=forces.force_unit,
force_calibrated=forces.force_calibrated,
session_id=session, session_id=session,
mode=mode, mode=mode,
flow=flow, flow=forces.flow,
) )
with self._lock: with self._lock:
self._latest = packet self._latest = packet
+23 -22
View File
@@ -7,8 +7,16 @@ import time
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from .acquisition import FixedCameraMode, SdkPacket, _ensure_bgr, load_fixed_mode, orisys_importable from .acquisition import (
FixedCameraMode,
SdkPacket,
_ensure_bgr,
load_fixed_mode,
orisys_importable,
read_forces_from_sensor,
)
from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
from .paths import normalize_sensor_config_ref
from .roi_geometry import ( from .roi_geometry import (
build_roi_warp, build_roi_warp,
load_json_config, load_json_config,
@@ -138,6 +146,7 @@ class Dd06AcquisitionWorker:
config = load_json_config(config_ref) config = load_json_config(config_ref)
roi_points, roi_image_size = load_roi_polygon(config) roi_points, roi_image_size = load_roi_polygon(config)
out_size = rectified_output_size(config, roi_points) out_size = rectified_output_size(config, roi_points)
config_ref = normalize_sensor_config_ref(config_ref)
self._sensor = orisys.Sensor("external", config_name=config_ref, verbose=False) self._sensor = orisys.Sensor("external", config_name=config_ref, verbose=False)
self._sensor.configure( self._sensor.configure(
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ, lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
@@ -190,24 +199,11 @@ class Dd06AcquisitionWorker:
frame_times = [t for t in frame_times if t >= cutoff] frame_times = [t for t in frame_times if t >= cutoff]
fps = float(len(frame_times)) if frame_times else 0.0 fps = float(len(frame_times)) if frame_times else 0.0
fn = fx = fy = 0.0 forces = read_forces_from_sensor(
flow = None self._sensor,
try: include_flow=True,
fn, fx, fy, flow = self._sensor.read_info( config_ref=self._config_ref,
self._sensor.info.FNORMAL,
self._sensor.info.FSHEARX,
self._sensor.info.FSHEARY,
self._sensor.info.VRAW,
) )
except Exception: # noqa: BLE001
try:
fn, fx, fy = self._sensor.read_info(
self._sensor.info.FNORMAL,
self._sensor.info.FSHEARX,
self._sensor.info.FSHEARY,
)
except Exception: # noqa: BLE001
pass
raw_bgr = _ensure_bgr(frame) raw_bgr = _ensure_bgr(frame)
# Prefer sensor.img / get_img result for processed visualization. # Prefer sensor.img / get_img result for processed visualization.
@@ -225,12 +221,17 @@ class Dd06AcquisitionWorker:
height=int(raw_bgr.shape[0]), height=int(raw_bgr.shape[0]),
processed_width=int(proc_bgr.shape[1]), processed_width=int(proc_bgr.shape[1]),
processed_height=int(proc_bgr.shape[0]), processed_height=int(proc_bgr.shape[0]),
normal_force=float(fn), normal_force=forces.normal_force,
shear_x=float(fx), shear_x=forces.shear_x,
shear_y=float(fy), shear_y=forces.shear_y,
normal_force_raw=forces.normal_force_raw,
shear_x_raw=forces.shear_x_raw,
shear_y_raw=forces.shear_y_raw,
force_unit=forces.force_unit,
force_calibrated=forces.force_calibrated,
session_id=session, session_id=session,
mode=mode, mode=mode,
flow=flow, flow=forces.flow,
) )
with self._lock: with self._lock:
self._latest = packet self._latest = packet
+42 -5
View File
@@ -47,12 +47,13 @@ from .acquisition import (
from .camera_discovery import list_cameras, pick_default_camera from .camera_discovery import list_cameras, pick_default_camera
from .dd06_acquisition import Dd06AcquisitionWorker from .dd06_acquisition import Dd06AcquisitionWorker
from .force_calibration import ( from .force_calibration import (
CALIBRATED_FORCE_Y_MAX,
CALIBRATED_FORCE_Y_MIN,
FORCE_SAMPLE_RATE_HZ, FORCE_SAMPLE_RATE_HZ,
PLOT_BUFFER_POINTS, PLOT_BUFFER_POINTS,
PLOT_WINDOW_SEC, PLOT_WINDOW_SEC,
Y_AXIS_MIN_SPAN, Y_AXIS_MIN_SPAN,
Y_RANGE_ALPHA, Y_RANGE_ALPHA,
calibrate_normal_force,
) )
from .i18n import DEFAULT_LOCALE, Translator from .i18n import DEFAULT_LOCALE, Translator
from .paths import ( from .paths import (
@@ -226,6 +227,8 @@ class ExhibitionWindow(QMainWindow):
self._view_mode = "pure_color" self._view_mode = "pure_color"
self._sensor_type = "orisys" self._sensor_type = "orisys"
self._log_panel_visible = False self._log_panel_visible = False
self._force_unit = "raw"
self._force_calibrated = False
self._fixed_mode: Optional[FixedCameraMode] = None self._fixed_mode: Optional[FixedCameraMode] = None
self._orisys_config = str(default_ddjx01_config()) self._orisys_config = str(default_ddjx01_config())
self._dd06_config = str(default_dd06_config()) self._dd06_config = str(default_dd06_config())
@@ -448,12 +451,23 @@ class ExhibitionWindow(QMainWindow):
self.view_combo.blockSignals(False) self.view_combo.blockSignals(False)
for plot, title_key in zip(self.plots, self.plot_titles): for plot, title_key in zip(self.plots, self.plot_titles):
plot.setTitle(self.t(title_key), color="#9aa0a6", size="12pt") plot.setTitle(self._plot_title(title_key), color="#9aa0a6", size="12pt")
if self._source_pixmap is None: if self._source_pixmap is None:
self.image_label.setText(self.t("labels.no_preview")) self.image_label.setText(self.t("labels.no_preview"))
self.status_label.setText(self.t("status.ready")) self.status_label.setText(self.t("status.ready"))
def _plot_title(self, title_key: str) -> str:
return self.t(
"exhibition.plot_with_unit",
title=self.t(title_key),
unit=self._force_unit,
)
def _update_force_plot_titles(self) -> None:
for plot, title_key in zip(self.plots, self.plot_titles):
plot.setTitle(self._plot_title(title_key), color="#9aa0a6", size="12pt")
def _selected_config(self) -> str: def _selected_config(self) -> str:
data = self.config_combo.currentData() data = self.config_combo.currentData()
if data: if data:
@@ -646,6 +660,9 @@ class ExhibitionWindow(QMainWindow):
return return
self.force_history.reset() self.force_history.reset()
self._force_unit = "raw"
self._force_calibrated = False
self._update_force_plot_titles()
self._awaiting_first_frame = True self._awaiting_first_frame = True
self._source_pixmap = None self._source_pixmap = None
self.image_label.clear() self.image_label.clear()
@@ -794,10 +811,27 @@ class ExhibitionWindow(QMainWindow):
) )
self.status_label.setText(info) self.status_label.setText(info)
self._log(info) self._log(info)
self._force_unit = packet.force_unit
self._force_calibrated = packet.force_calibrated
self._update_force_plot_titles()
if packet.force_calibrated:
self._log( self._log(
self.t( self.t(
"exhibition.force_fn_n", "exhibition.force_calibrated",
value=calibrate_normal_force(packet.normal_force), fn=packet.normal_force,
fn_raw=packet.normal_force_raw,
fx=packet.shear_x,
fy=packet.shear_y,
unit=packet.force_unit,
)
)
else:
self._log(
self.t(
"exhibition.force_raw",
fn=packet.normal_force,
fx=packet.shear_x,
fy=packet.shear_y,
) )
) )
@@ -870,8 +904,11 @@ class ExhibitionWindow(QMainWindow):
x = state.times x = state.times
y = state.values[:, i] y = state.values[:, i]
curve.setData(x, y) curve.setData(x, y)
if self._force_calibrated:
ymin, ymax = CALIBRATED_FORCE_Y_MIN, CALIBRATED_FORCE_Y_MAX
else:
ymin, ymax = state.y_ranges[i] ymin, ymax = state.y_ranges[i]
self.plots[i].setYRange(ymin, ymax) self.plots[i].setYRange(ymin, ymax, padding=0)
self.dot_cores[i].setData(x[-1:], y[-1:]) self.dot_cores[i].setData(x[-1:], y[-1:])
x0, x1 = state.x_range x0, x1 = state.x_range
for plot in self.plots: for plot in self.plots:
+2
View File
@@ -11,6 +11,8 @@ FORCE_SAMPLE_RATE_HZ = 30.0
PLOT_BUFFER_POINTS = 120 PLOT_BUFFER_POINTS = 120
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ
Y_AXIS_MIN_SPAN = 20000.0 Y_AXIS_MIN_SPAN = 20000.0
CALIBRATED_FORCE_Y_MIN = -20.0
CALIBRATED_FORCE_Y_MAX = 20.0
Y_RANGE_ALPHA = 0.5 Y_RANGE_ALPHA = 0.5
LOWPASS_CUTOFF_HZ = 10.0 LOWPASS_CUTOFF_HZ = 10.0
+174 -16
View File
@@ -8,6 +8,31 @@ from pathlib import Path
NON_SENSOR_CONFIG_STEMS = {"options", "bench_theme"} NON_SENSOR_CONFIG_STEMS = {"options", "bench_theme"}
# Product bundles with optional force_calibration.json (orisys-sdk layout).
PRODUCT_BUNDLES = ("box", "mini", "finger")
# Legacy flat config stems mapped to a product bundle for force calibration lookup.
LEGACY_STEM_TO_BUNDLE: dict[str, str] = {
"ddjx01": "box",
"ddjx01_480p": "box",
"ddjx01_q6a": "box",
"ddjx01_wuxi": "box",
"box": "box",
"prototype": "box",
"prototype_of3": "box",
"prototype_of3_480p": "box",
"prototype_of3_720p": "box",
"prototype_raft": "box",
"mini": "mini",
"mini-fast": "mini",
"finger": "finger",
"dd02-ov": "finger",
"dd02": "finger",
"dd06-ov": "finger",
"dd06-ov-0810": "finger",
"x-ov": "finger",
}
def is_frozen() -> bool: def is_frozen() -> bool:
return bool(getattr(sys, "frozen", False)) return bool(getattr(sys, "frozen", False))
@@ -58,6 +83,11 @@ def local_sensor_configs_dir() -> Path:
return app_root() / "config" return app_root() / "config"
def local_sensor_bundles_dir() -> Path:
"""Per-sensor bundle root: ``sensors/<product>/sensor.json``."""
return app_root() / "sensors"
def _is_sensor_config_json(path: Path) -> bool: def _is_sensor_config_json(path: Path) -> bool:
if path.suffix.lower() != ".json": if path.suffix.lower() != ".json":
return False return False
@@ -76,10 +106,114 @@ def list_local_sensor_config_files() -> list[Path]:
return sorted(p for p in root.glob("*.json") if _is_sensor_config_json(p)) return sorted(p for p in root.glob("*.json") if _is_sensor_config_json(p))
def local_sensor_bundle_sensor_json(name: str) -> Path | None:
"""Return ``sensors/<name>/sensor.json`` beside the app when present."""
path = local_sensor_bundles_dir() / name / "sensor.json"
return path if path.is_file() else None
def local_sensor_bundle_force_calibration(name: str) -> Path | None:
path = local_sensor_bundles_dir() / name / "force_calibration.json"
return path if path.is_file() else None
def list_local_sensor_bundle_names() -> list[str]:
"""Product bundle folder names under ``sensors/`` with a ``sensor.json``."""
root = local_sensor_bundles_dir()
if not root.is_dir():
return []
found: list[str] = []
for name in PRODUCT_BUNDLES:
if local_sensor_bundle_sensor_json(name) is not None:
found.append(name)
return found
def _orisys_bundle_has_force_calibration(name: str) -> bool:
try:
import orisys
return orisys.configs.load_force_calibration(name) is not None
except Exception: # noqa: BLE001
return False
def bundle_has_force_calibration(name: str) -> bool:
"""True when local or packaged Orisys bundle includes force_calibration.json."""
if local_sensor_bundle_force_calibration(name) is not None:
return True
return _orisys_bundle_has_force_calibration(name)
def resolve_force_bundle_from_config(config_ref: str | Path | None) -> str | None:
"""Map a legacy flat config path/stem to a product bundle with calibration."""
if config_ref is None:
return None
text = str(config_ref).strip().replace("\\", "/")
if not text:
return None
path = Path(text)
candidates: list[str] = []
if path.suffix.lower() == ".json":
candidates.append(path.stem.lower())
stem = path.stem.lower() if path.suffix else text.lower()
if stem:
candidates.append(stem)
if text.lower() in PRODUCT_BUNDLES:
candidates.insert(0, text.lower())
parts = [p for p in text.split("/") if p]
if "sensors" in parts:
idx = parts.index("sensors")
if idx + 1 < len(parts):
candidates.insert(0, parts[idx + 1].lower())
seen: set[str] = set()
for candidate in candidates:
if not candidate or candidate in seen:
continue
seen.add(candidate)
if candidate in PRODUCT_BUNDLES and local_sensor_bundle_sensor_json(candidate):
if bundle_has_force_calibration(candidate):
return candidate
mapped = LEGACY_STEM_TO_BUNDLE.get(candidate)
if mapped and bundle_has_force_calibration(mapped):
return mapped
return None
def normalize_sensor_config_ref(config_ref: str | Path) -> str:
"""Prefer product bundle names so Orisys loads ``sensors/<name>/`` calibration."""
text = str(config_ref).strip()
if not text:
return text
path = Path(text)
if path.is_file() and path.name.lower() == "sensor.json":
parent = path.parent.name.lower()
if parent in PRODUCT_BUNDLES:
return parent
bundle = resolve_force_bundle_from_config(text)
if bundle is not None:
local_json = local_sensor_bundle_sensor_json(bundle)
if local_json is not None:
return str(local_json.resolve())
return bundle
if text.lower() in PRODUCT_BUNDLES:
local_json = local_sensor_bundle_sensor_json(text)
if local_json is not None:
return str(local_json.resolve())
return text
return text
def orisys_configs_dir() -> Path | None: def orisys_configs_dir() -> Path | None:
"""Prefer local sensor configs dir when present; else installed package dir.""" """Prefer local sensor configs dir when present; else installed package dir."""
local = local_sensor_configs_dir() local = local_sensor_configs_dir()
if list_local_sensor_config_files(): if list_local_sensor_config_files() or list_local_sensor_bundle_names():
return local return local
try: try:
import orisys import orisys
@@ -93,37 +227,54 @@ def orisys_configs_dir() -> Path | None:
def list_orisys_configs() -> list[str]: def list_orisys_configs() -> list[str]:
"""Return selectable sensor configs. """Return selectable sensor configs.
Local ``config/*.json`` files (excluding non-sensor stems such as Product bundles under ``sensors/<name>/sensor.json`` and Orisys bundle
``bench_theme``) are listed first as names are listed before legacy flat ``config/*.json`` files so force
absolute paths so Start uses the editable copy beside the EXE. Package stems calibration resolves correctly.
from the installed Orisys wheel are appended when not already covered.
""" """
entries: list[str] = [] entries: list[str] = []
seen_stems: set[str] = set() seen_stems: set[str] = set()
for path in list_local_sensor_config_files(): for name in list_local_sensor_bundle_names():
stem = path.stem.lower() path = local_sensor_bundle_sensor_json(name)
if stem in seen_stems: assert path is not None
continue
entries.append(str(path.resolve())) entries.append(str(path.resolve()))
seen_stems.add(stem) seen_stems.add(name.lower())
try: try:
import orisys import orisys
for stem in orisys.configs.list_config(): for name in orisys.configs.list_sensor_bundles():
key = str(stem).strip() key = str(name).strip()
if not key: if not key or key.lower() in NON_SENSOR_CONFIG_STEMS:
continue
if key.lower() in NON_SENSOR_CONFIG_STEMS:
continue continue
if key.lower() in seen_stems: if key.lower() in seen_stems:
continue continue
entries.append(key) entries.append(key)
seen_stems.add(key.lower()) seen_stems.add(key.lower())
for stem in orisys.configs.list_config():
key = str(stem).strip()
if not key or key.lower() in NON_SENSOR_CONFIG_STEMS:
continue
if key.lower() in seen_stems:
continue
if key.lower() in PRODUCT_BUNDLES:
continue
entries.append(key)
seen_stems.add(key.lower())
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass pass
for path in list_local_sensor_config_files():
stem = path.stem.lower()
if stem in seen_stems:
continue
mapped = LEGACY_STEM_TO_BUNDLE.get(stem)
if mapped and mapped.lower() in seen_stems:
continue
entries.append(str(path.resolve()))
seen_stems.add(stem)
return entries return entries
@@ -133,6 +284,11 @@ def config_display_name(value: str) -> str:
if not text: if not text:
return "" return ""
path = Path(text) path = Path(text)
parts = [p for p in path.as_posix().split("/") if p]
if "sensors" in parts:
idx = parts.index("sensors")
if idx + 1 < len(parts):
return parts[idx + 1]
if path.suffix.lower() == ".json" or "/" in text or "\\" in text: if path.suffix.lower() == ".json" or "/" in text or "\\" in text:
return path.stem return path.stem
return text return text
@@ -143,6 +299,8 @@ def default_config_name(sensor_type: str = "orisys") -> str:
kind = str(sensor_type or "orisys").strip().lower() kind = str(sensor_type or "orisys").strip().lower()
if kind in ("dd06", "dd06-ov", "roi", "polygon_roi"): if kind in ("dd06", "dd06-ov", "roi", "polygon_roi"):
return "dd06-ov" return "dd06-ov"
if list_local_sensor_bundle_names() or _orisys_bundle_has_force_calibration("box"):
return "box"
return "ddjx01" return "ddjx01"
@@ -157,7 +315,7 @@ def _pick_default_config(preferred_stem: str) -> str:
def default_ddjx01_config() -> str: def default_ddjx01_config() -> str:
"""Prefer local editable config path, else package stem.""" """Prefer product bundle (box), else local editable config path."""
return _pick_default_config(default_config_name("orisys")) return _pick_default_config(default_config_name("orisys"))
+129 -2
View File
@@ -44,6 +44,45 @@ def test_translator_chinese_default() -> None:
assert tr._lookup("en_US", "workflow.title") is None assert tr._lookup("en_US", "workflow.title") is None
def test_read_forces_from_sensor() -> None:
from sensor_bench.acquisition import read_forces_from_sensor, sensor_force_calibrated
class RawSensor:
info = SimpleNamespace(
FNORMAL=1,
FSHEARX=2,
FSHEARY=3,
FNORMAL_RAW=4,
FSHEARX_RAW=5,
FSHEARY_RAW=6,
VRAW=7,
)
_force_cal_sensor_name = "box"
def read_info(self, *args):
return 1.1, 2.2, 3.3, 4.4, 5.5, 6.6, None
forces = read_forces_from_sensor(RawSensor())
assert sensor_force_calibrated(RawSensor())
assert forces.force_calibrated is True
assert forces.force_unit == "N"
assert forces.normal_force == 1.1
assert forces.normal_force_raw == 4.4
class LegacySensor:
info = SimpleNamespace(FNORMAL=1, FSHEARX=2, FSHEARY=3, VRAW=4)
def read_info(self, *args):
if len(args) == 4:
return 10.0, 20.0, 30.0, None
return 10.0, 20.0, 30.0
legacy = read_forces_from_sensor(LegacySensor())
assert legacy.force_calibrated is False
assert legacy.force_unit == "raw"
assert legacy.normal_force_raw == 10.0
def test_sdk_worker_packet_mapping() -> None: def test_sdk_worker_packet_mapping() -> None:
from sensor_bench.acquisition import SdkAcquisitionWorker from sensor_bench.acquisition import SdkAcquisitionWorker
@@ -104,6 +143,9 @@ def test_sdk_worker_packet_mapping() -> None:
assert packet.normal_force == 1.0 assert packet.normal_force == 1.0
assert packet.shear_x == 2.0 assert packet.shear_x == 2.0
assert packet.shear_y == 3.0 assert packet.shear_y == 3.0
assert packet.normal_force_raw == 1.0
assert packet.force_unit == "raw"
assert packet.force_calibrated is False
assert packet.raw_frame.shape[:2] == (1080, 1920) assert packet.raw_frame.shape[:2] == (1080, 1920)
assert packet.processed_frame.shape[:2] == (365, 365) assert packet.processed_frame.shape[:2] == (365, 365)
@@ -137,6 +179,7 @@ def test_force_calibration_and_plot_min_span() -> None:
def test_list_orisys_configs_excludes_options() -> None: def test_list_orisys_configs_excludes_options() -> None:
from sensor_bench.paths import ( from sensor_bench.paths import (
config_display_name, config_display_name,
list_local_sensor_bundle_names,
list_local_sensor_config_files, list_local_sensor_config_files,
list_orisys_configs, list_orisys_configs,
local_sensor_configs_dir, local_sensor_configs_dir,
@@ -147,11 +190,76 @@ def test_list_orisys_configs_excludes_options() -> None:
assert "options" not in labels assert "options" not in labels
assert all(Path(e).stem.lower() != "options" for e in entries if Path(e).suffix.lower() == ".json") assert all(Path(e).stem.lower() != "options" for e in entries if Path(e).suffix.lower() == ".json")
bundles = list_local_sensor_bundle_names()
if bundles:
assert labels[0] in bundles
local_ddjx = local_sensor_configs_dir() / "ddjx01.json" local_ddjx = local_sensor_configs_dir() / "ddjx01.json"
if local_ddjx.exists(): if local_ddjx.exists():
local_files = list_local_sensor_config_files() local_files = list_local_sensor_config_files()
assert any(p.name == "ddjx01.json" for p in local_files) assert any(p.name == "ddjx01.json" for p in local_files)
if "box" not in bundles:
assert any(config_display_name(e).lower() == "ddjx01" for e in entries) assert any(config_display_name(e).lower() == "ddjx01" for e in entries)
else:
assert not any(config_display_name(e).lower() == "ddjx01" for e in entries)
def test_default_config_prefers_box_bundle() -> None:
from sensor_bench.paths import (
config_display_name,
default_config_name,
default_ddjx01_config,
list_local_sensor_bundle_names,
)
bundles = list_local_sensor_bundle_names()
if not bundles:
return
assert default_config_name("orisys") == "box"
default = default_ddjx01_config()
assert config_display_name(default).lower() == "box"
def test_legacy_config_force_calibration_fallback() -> None:
from sensor_bench.acquisition import sensor_force_calibrated
from sensor_bench.paths import (
bundle_has_force_calibration,
local_sensor_configs_dir,
resolve_force_bundle_from_config,
)
if not bundle_has_force_calibration("box"):
return
class LegacySensor:
pass
legacy_path = local_sensor_configs_dir() / "ddjx01.json"
if legacy_path.exists():
assert resolve_force_bundle_from_config(legacy_path) == "box"
assert sensor_force_calibrated(LegacySensor(), config_ref=legacy_path) is True
assert resolve_force_bundle_from_config("ddjx01") == "box"
assert resolve_force_bundle_from_config("prototype") == "box"
assert sensor_force_calibrated(LegacySensor(), config_ref="ddjx01") is True
def test_normalize_sensor_config_ref_prefers_bundle() -> None:
from sensor_bench.paths import (
bundle_has_force_calibration,
local_sensor_bundle_sensor_json,
normalize_sensor_config_ref,
)
if not bundle_has_force_calibration("box"):
return
local_json = local_sensor_bundle_sensor_json("box")
assert local_json is not None
normalized = normalize_sensor_config_ref("ddjx01")
assert Path(normalized).resolve() == local_json.resolve()
assert normalize_sensor_config_ref("box") == str(local_json.resolve())
def test_roi_geometry_helpers() -> None: def test_roi_geometry_helpers() -> None:
@@ -209,10 +317,13 @@ def test_exhibition_window_offscreen() -> None:
assert win.sensor_combo.count() == 2 assert win.sensor_combo.count() == 2
assert win.config_combo is not None assert win.config_combo is not None
assert win.config_combo.count() >= 1 assert win.config_combo.count() >= 1
from sensor_bench.paths import list_orisys_configs from sensor_bench.paths import config_display_name, default_ddjx01_config, list_orisys_configs
installed = list_orisys_configs() installed = list_orisys_configs()
if "ddjx01" in installed: default = default_ddjx01_config()
if config_display_name(default).lower() == "box":
assert config_display_name(win._selected_config()).lower() == "box"
elif "ddjx01" in installed:
assert win.config_combo.currentData() == "ddjx01" or win._selected_config() == "ddjx01" assert win.config_combo.currentData() == "ddjx01" or win._selected_config() == "ddjx01"
win.language_combo.setCurrentIndex(1) win.language_combo.setCurrentIndex(1)
@@ -222,6 +333,18 @@ def test_exhibition_window_offscreen() -> None:
assert win.btn_reset.text() == "Reset" assert win.btn_reset.text() == "Reset"
assert win.show_log_check.text() == "Show log panel" assert win.show_log_check.text() == "Show log panel"
assert "About" in win.about_action.text() assert "About" in win.about_action.text()
title0 = win.plots[0].titleLabel.text
assert "FN" in title0
assert "raw" in title0
from sensor_bench.force_calibration import CALIBRATED_FORCE_Y_MAX, CALIBRATED_FORCE_Y_MIN
win._force_calibrated = True
win.force_history.push(time.time(), 1.0, -2.0, 3.0)
win._update_plots()
ymin, ymax = win.plots[0].viewRange()[1]
assert abs(ymin - CALIBRATED_FORCE_Y_MIN) < 1e-6
assert abs(ymax - CALIBRATED_FORCE_Y_MAX) < 1e-6
win.view_combo.setCurrentIndex(1) win.view_combo.setCurrentIndex(1)
assert win._view_mode == "captured" assert win._view_mode == "captured"
@@ -235,9 +358,13 @@ def test_exhibition_window_offscreen() -> None:
if __name__ == "__main__": if __name__ == "__main__":
test_load_fixed_mode() test_load_fixed_mode()
test_translator_chinese_default() test_translator_chinese_default()
test_read_forces_from_sensor()
test_sdk_worker_packet_mapping() test_sdk_worker_packet_mapping()
test_force_calibration_and_plot_min_span() test_force_calibration_and_plot_min_span()
test_list_orisys_configs_excludes_options() test_list_orisys_configs_excludes_options()
test_default_config_prefers_box_bundle()
test_legacy_config_force_calibration_fallback()
test_normalize_sensor_config_ref_prefers_bundle()
test_roi_geometry_helpers() test_roi_geometry_helpers()
test_exhibition_window_offscreen() test_exhibition_window_offscreen()
print("ok") print("ok")