update sensor bench

This commit is contained in:
ykcheungab
2026-09-01 11:14:56 +08:00
parent ef3ee84a68
commit 8cacb12abc
55 changed files with 4032 additions and 323 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""Sensor Bench: operator tooling for raw sensor inspection and recording."""
"""Sensor Bench: public exhibition desktop app for Orisys tactile sensors."""
__version__ = "0.1.0"
+58 -2
View File
@@ -17,6 +17,8 @@ from typing import Callable, Optional
import cv2
import numpy as np
from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
@dataclass(frozen=True)
class FixedCameraMode:
@@ -51,6 +53,7 @@ class SdkPacket:
shear_y: float = 0.0
session_id: int = 0
mode: Optional[FixedCameraMode] = None
flow: Optional[np.ndarray] = None
def orisys_importable() -> tuple[bool, str]:
@@ -104,6 +107,37 @@ def _ensure_bgr(img: np.ndarray) -> np.ndarray:
return img
ARROW_BG_BGR = (0x16, 0x13, 0x12) # #121316 as BGR
_ARROW_DRAW_KW = dict(
threshold=2,
grid_spacing=20,
arrow_scale=0.5,
below_threshold_color=(160, 160, 160),
)
def draw_arrow_display(
processed_frame: np.ndarray,
flow: Optional[np.ndarray],
*,
background: str = "pure_color",
) -> np.ndarray:
"""Build exhibition display: arrows on solid bg or over the processed frame."""
base = _ensure_bgr(processed_frame)
if flow is None:
return base
try:
import orisys
if background == "pure_color":
canvas = np.empty_like(base)
canvas[:, :] = ARROW_BG_BGR
return orisys.util.draw_arrows(canvas, flow, **_ARROW_DRAW_KW)
return orisys.util.draw_arrows(base, flow, **_ARROW_DRAW_KW)
except Exception: # noqa: BLE001
return base
class SdkAcquisitionWorker:
"""Background loop: ``Sensor.get_img()`` + deformation + contact."""
@@ -189,6 +223,14 @@ class SdkAcquisitionWorker:
with self._lock:
self._latest = None
def reset(self) -> None:
"""Reset the live Orisys sensor tracker (baseline), if running."""
with self._lock:
sensor = self._sensor
if sensor is None:
raise RuntimeError("Sensor is not running")
sensor.reset()
def _close_sensor(self) -> None:
sensor = self._sensor
self._sensor = None
@@ -220,6 +262,10 @@ class SdkAcquisitionWorker:
if self._cal_path:
kwargs["cal_path"] = self._cal_path
self._sensor = orisys.Sensor(source, **kwargs)
self._sensor.configure(
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
)
self._status(f"Streaming {mode.label()}")
except Exception as exc: # noqa: BLE001
if self._on_error is not None and session == self._session_id:
@@ -252,14 +298,23 @@ class SdkAcquisitionWorker:
fps = float(len(frame_times)) if frame_times else 0.0
fn = fx = fy = 0.0
flow = None
try:
fn, fx, fy = self._sensor.read_info(
fn, fx, fy, flow = self._sensor.read_info(
self._sensor.info.FNORMAL,
self._sensor.info.FSHEARX,
self._sensor.info.FSHEARY,
self._sensor.info.VRAW,
)
except Exception: # noqa: BLE001
pass
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)
@@ -278,6 +333,7 @@ class SdkAcquisitionWorker:
shear_y=float(fy),
session_id=session,
mode=mode,
flow=flow,
)
with self._lock:
self._latest = packet
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
"""Discover USB cameras for Sensor Bench (Windows DSHOW/MSMF, Linux V4L2)."""
from __future__ import annotations
import contextlib
import os
import sys
from dataclasses import dataclass
import cv2
DEFAULT_MAX_CAMERA_INDEX = 10
@dataclass(frozen=True)
class CameraInfo:
index: int
backend_name: str
backend_api: int
width: int
height: int
@property
def source(self) -> str:
return str(self.index)
def label(self) -> str:
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
@contextlib.contextmanager
def _suppress_opencv_noise():
"""Hide OpenCV VideoCapture probe warnings (stderr + OpenCV log)."""
prev_level = None
set_level = None
try:
logging = getattr(getattr(cv2, "utils", None), "logging", None)
if logging is not None:
set_level = getattr(logging, "setLogLevel", None)
get_level = getattr(logging, "getLogLevel", None)
silent = getattr(logging, "LOG_LEVEL_SILENT", 0)
if set_level is not None:
if get_level is not None:
prev_level = get_level()
set_level(silent)
except Exception:
prev_level = None
devnull = open(os.devnull, "w")
try:
with contextlib.redirect_stderr(devnull):
stderr_fd = sys.stderr.fileno()
saved_fd = os.dup(stderr_fd)
try:
os.dup2(devnull.fileno(), stderr_fd)
yield
finally:
os.dup2(saved_fd, stderr_fd)
os.close(saved_fd)
finally:
devnull.close()
if set_level is not None and prev_level is not None:
try:
set_level(prev_level)
except Exception:
pass
def _backend_candidates() -> list[tuple[str, int]]:
candidates: list[tuple[str, int]] = []
if sys.platform == "win32":
if hasattr(cv2, "CAP_DSHOW"):
candidates.append(("DSHOW", cv2.CAP_DSHOW))
if hasattr(cv2, "CAP_MSMF"):
candidates.append(("MSMF", cv2.CAP_MSMF))
elif sys.platform.startswith("linux"):
if hasattr(cv2, "CAP_V4L2"):
candidates.append(("V4L2", cv2.CAP_V4L2))
if not candidates:
candidates.append(("ANY", cv2.CAP_ANY))
return candidates
def _try_open(index: int, backend_name: str, backend_api: int) -> CameraInfo | None:
cap = cv2.VideoCapture(index, backend_api)
if not cap.isOpened():
cap.release()
return None
ok, frame = cap.read()
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
if not ok or frame is None:
if width <= 0 or height <= 0:
cap.release()
return None
else:
h, w = frame.shape[:2]
width = width or int(w)
height = height or int(h)
cap.release()
return CameraInfo(
index=index,
backend_name=backend_name,
backend_api=backend_api,
width=width,
height=height,
)
def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]:
"""Probe indices ``0 .. max_index`` and return working cameras."""
found: list[CameraInfo] = []
seen_indices: set[int] = set()
backends = _backend_candidates()
with _suppress_opencv_noise():
for index in range(0, max_index + 1):
for backend_name, backend_api in backends:
info = _try_open(index, backend_name, backend_api)
if info is None:
continue
if index in seen_indices:
break
found.append(info)
seen_indices.add(index)
break
return found
def pick_default_camera(cameras: list[CameraInfo], preferred: str | None = None) -> str | None:
if not cameras:
return None
preferred = (preferred or "").strip()
if preferred:
for cam in cameras:
if cam.source == preferred:
return cam.source
return cameras[0].source
+243
View File
@@ -0,0 +1,243 @@
"""DD06 acquisition: external capture + ROI rectify + already_processed Sensor."""
from __future__ import annotations
import threading
import time
from pathlib import Path
from typing import Callable, Optional
from .acquisition import FixedCameraMode, SdkPacket, _ensure_bgr, load_fixed_mode, orisys_importable
from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
from .roi_geometry import (
build_roi_warp,
load_json_config,
load_roi_polygon,
open_video_source,
rectified_output_size,
warp_roi_to_rectangle,
)
class Dd06AcquisitionWorker:
"""Background loop for DD06 polygon-ROI rectification pipeline."""
def __init__(
self,
*,
on_error: Optional[Callable[[str], None]] = None,
on_status: Optional[Callable[[str], None]] = None,
):
self._on_error = on_error
self._on_status = on_status
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._lock = threading.Lock()
self._latest: Optional[SdkPacket] = None
self._session_id = 0
self._sensor = None
self._cap = None
self._mode: Optional[FixedCameraMode] = None
self._video_source: Optional[str | int] = None
self._config_ref: Optional[str] = None
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def session_id(self) -> int:
return self._session_id
@property
def mode(self) -> Optional[FixedCameraMode]:
return self._mode
def latest(self) -> Optional[SdkPacket]:
with self._lock:
return self._latest
def start(
self,
video_source: str | int = 0,
*,
config_path: str | Path,
) -> FixedCameraMode:
self.stop()
ok, detail = orisys_importable()
if not ok:
raise RuntimeError(
"orisys SDK is not importable. Install orisys and ensure it is available. "
f"Detail: {detail}"
)
mode = load_fixed_mode(config_path)
self._mode = mode
self._video_source = video_source
self._config_ref = str(config_path).strip()
self._latest = None
self._session_id += 1
session = self._session_id
self._stop.clear()
self._thread = threading.Thread(
target=self._run,
name=f"Dd06Acquisition-{session}",
daemon=True,
args=(session,),
)
self._thread.start()
return self._mode
def stop(self) -> None:
self._stop.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=5.0)
self._thread = None
self._close()
with self._lock:
self._latest = None
def reset(self) -> None:
"""Reset the live Orisys sensor tracker (baseline), if running."""
with self._lock:
sensor = self._sensor
if sensor is None:
raise RuntimeError("Sensor is not running")
sensor.reset()
def _close(self) -> None:
cap = self._cap
self._cap = None
if cap is not None:
try:
cap.release()
except Exception: # noqa: BLE001
pass
sensor = self._sensor
self._sensor = None
if sensor is not None:
try:
sensor.disconnect()
except Exception: # noqa: BLE001
pass
def _status(self, message: str) -> None:
if self._on_status is not None:
self._on_status(message)
def _run(self, session: int) -> None:
import orisys
mode = self._mode
source = self._video_source
config_ref = self._config_ref
if mode is None or source is None or config_ref is None:
return
try:
self._status(f"Opening DD06 pipeline ({mode.width}x{mode.height})…")
config = load_json_config(config_ref)
roi_points, roi_image_size = load_roi_polygon(config)
out_size = rectified_output_size(config, roi_points)
self._sensor = orisys.Sensor("external", config_name=config_ref, verbose=False)
self._sensor.configure(
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
)
self._cap, resolved = open_video_source(source)
warp_matrix = None
last_frame_size = None
self._status(f"Streaming DD06 ROI {out_size[0]}x{out_size[1]} from {resolved}")
except Exception as exc: # noqa: BLE001
if self._on_error is not None and session == self._session_id:
self._on_error(str(exc))
self._close()
return
frame_times: list[float] = []
while not self._stop.is_set() and session == self._session_id:
try:
t0 = time.perf_counter()
ok, frame = self._cap.read()
if not ok or frame is None:
time.sleep(0.005)
continue
frame_h, frame_w = frame.shape[:2]
frame_size = (frame_w, frame_h)
if warp_matrix is None or last_frame_size != frame_size:
warp_matrix, _, _ = build_roi_warp(
roi_points,
roi_image_size,
frame_size,
out_size,
)
last_frame_size = frame_size
rectified = warp_roi_to_rectangle(frame, warp_matrix, out_size)
processed = self._sensor.get_img(frame=rectified, already_processed=True)
if processed is None:
processed = rectified
try:
self._sensor.compute_deformation(decompose=True, check_motion=False)
self._sensor.compute_contact()
except Exception: # noqa: BLE001
pass
t1 = time.perf_counter()
frame_times.append(t1)
cutoff = t1 - 1.0
frame_times = [t for t in frame_times if t >= cutoff]
fps = float(len(frame_times)) if frame_times else 0.0
fn = fx = fy = 0.0
flow = None
try:
fn, fx, fy, flow = self._sensor.read_info(
self._sensor.info.FNORMAL,
self._sensor.info.FSHEARX,
self._sensor.info.FSHEARY,
self._sensor.info.VRAW,
)
except Exception: # noqa: BLE001
try:
fn, fx, fy = self._sensor.read_info(
self._sensor.info.FNORMAL,
self._sensor.info.FSHEARX,
self._sensor.info.FSHEARY,
)
except Exception: # noqa: BLE001
pass
raw_bgr = _ensure_bgr(frame)
# Prefer sensor.img / get_img result for processed visualization.
proc_src = getattr(self._sensor, "img", None)
if proc_src is None:
proc_src = processed
proc_bgr = _ensure_bgr(proc_src)
packet = SdkPacket(
raw_frame=raw_bgr,
processed_frame=proc_bgr,
timestamp=time.time(),
fps=fps,
process_ms=(t1 - t0) * 1000.0,
width=int(raw_bgr.shape[1]),
height=int(raw_bgr.shape[0]),
processed_width=int(proc_bgr.shape[1]),
processed_height=int(proc_bgr.shape[0]),
normal_force=float(fn),
shear_x=float(fx),
shear_y=float(fy),
session_id=session,
mode=mode,
flow=flow,
)
with self._lock:
self._latest = packet
except Exception as exc: # noqa: BLE001
if self._on_error is not None and session == self._session_id:
self._on_error(str(exc))
break
self._close()
self._status("Acquisition stopped")
+910
View File
@@ -0,0 +1,910 @@
"""Exhibition-friendly Sensor Bench window (qt_viewer_2-style layout)."""
from __future__ import annotations
import time
from importlib.metadata import PackageNotFoundError, version as pkg_version
from pathlib import Path
from typing import Optional, Protocol
import cv2
import numpy as np
from PyQt5.QtCore import Qt, QObject, QTimer, pyqtSignal
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtWidgets import (
QAction,
QCheckBox,
QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSizePolicy,
QSplitter,
QVBoxLayout,
QWidget,
)
try:
import pyqtgraph as pg
except Exception as exc: # pragma: no cover
raise ImportError(
"PyQtGraph is required. Install with: pip install pyqt5 pyqtgraph"
) from exc
from .acquisition import (
FixedCameraMode,
SdkAcquisitionWorker,
SdkPacket,
draw_arrow_display,
load_fixed_mode,
orisys_importable,
)
from .camera_discovery import list_cameras, pick_default_camera
from .dd06_acquisition import Dd06AcquisitionWorker
from .force_calibration import (
FORCE_SAMPLE_RATE_HZ,
PLOT_BUFFER_POINTS,
PLOT_WINDOW_SEC,
Y_AXIS_MIN_SPAN,
Y_RANGE_ALPHA,
calibrate_normal_force,
)
from .i18n import DEFAULT_LOCALE, Translator
from .paths import (
config_display_name,
default_dd06_config,
default_ddjx01_config,
default_locales_dir,
default_recordings_dir,
list_orisys_configs,
orisys_configs_dir,
project_root,
)
from .plot_history import MetricHistory
from .recorder import RawRecorder
CURVE_COLORS = ("#8ab4f8", "#34a853", "#fbbc04")
APP_VERSION_FALLBACK = "0.1.0"
STYLE = """
QMainWindow, QWidget {
background-color: #1e1f22;
color: #e8eaed;
font-family: "Segoe UI", "Microsoft YaHei", "SimHei", Arial, sans-serif;
font-size: 13px;
}
QMenuBar {
background-color: #1e1f22;
color: #e8eaed;
}
QMenuBar::item:selected { background-color: #2d3139; }
QMenu {
background-color: #2a2d33;
color: #e8eaed;
border: 1px solid #4a505a;
}
QMenu::item:selected { background-color: #243447; }
QPushButton {
background-color: #2d3139;
border: 1px solid #4a505a;
border-radius: 4px;
padding: 6px 14px;
min-height: 28px;
color: #e8eaed;
}
QPushButton:hover { background-color: #3a404c; }
QPushButton:pressed { background-color: #252930; }
QPushButton:disabled { color: #6b7280; background-color: #25262a; }
QPushButton#btnPrimary {
background-color: #1a73e8;
border-color: #1a73e8;
color: white;
font-weight: 600;
}
QPushButton#btnPrimary:hover { background-color: #1967d2; }
QPushButton#btnDanger {
background-color: #c5221f;
border-color: #c5221f;
color: white;
font-weight: 600;
}
QPushButton#btnDanger:hover { background-color: #a50e0e; }
QLineEdit, QComboBox {
background-color: #2a2d33;
border: 1px solid #4a505a;
border-radius: 4px;
padding: 5px 8px;
color: #e8eaed;
}
QComboBox QAbstractItemView {
background-color: #2a2d33;
color: #e8eaed;
selection-background-color: #243447;
}
QLabel#toolbarCaption { color: #9aa0a6; }
QLabel#imagePanel {
color: #6b7280;
background-color: #121316;
border: 1px solid #3c4048;
border-radius: 6px;
}
QPlainTextEdit {
background-color: #121316;
color: #9aa0a6;
border: 1px solid #3c4048;
border-radius: 6px;
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 12px;
padding: 6px;
}
QSplitter::handle { background-color: #2a2d33; }
"""
class AcquisitionPipeline(Protocol):
is_running: bool
def latest(self) -> Optional[SdkPacket]: ...
def stop(self) -> None: ...
def reset(self) -> None: ...
def ndarray_to_pixmap(frame: np.ndarray) -> QPixmap:
if frame.ndim == 2 or (frame.ndim == 3 and frame.shape[2] == 1):
gray = frame if frame.ndim == 2 else frame[:, :, 0]
h, w = gray.shape[:2]
image = QImage(gray.data, w, h, w, QImage.Format_Grayscale8)
return QPixmap.fromImage(image.copy())
if frame.ndim == 3 and frame.shape[2] == 4:
rgba = cv2.cvtColor(frame, cv2.COLOR_BGRA2RGBA)
h, w = rgba.shape[:2]
image = QImage(rgba.data, w, h, w * 4, QImage.Format_RGBA8888)
return QPixmap.fromImage(image.copy())
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
h, w = rgb.shape[:2]
image = QImage(rgb.data, w, h, w * 3, QImage.Format_RGB888)
return QPixmap.fromImage(image.copy())
def app_version() -> str:
try:
return pkg_version("sensor-bench")
except PackageNotFoundError:
return APP_VERSION_FALLBACK
class _AcquisitionBridge(QObject):
error = pyqtSignal(str)
status = pyqtSignal(str)
class ExhibitionWindow(QMainWindow):
"""External exhibition UI: toolbar + image + FN/FX/FY plots + log."""
def __init__(self, *, locales_dir: Optional[Path] = None, locale: str = DEFAULT_LOCALE):
super().__init__()
self.resize(1480, 900)
self.setStyleSheet(STYLE)
self.tr = Translator(locales_dir or default_locales_dir(), locale=locale)
self.recordings_dir = default_recordings_dir()
self.force_history = MetricHistory(
npoints=PLOT_BUFFER_POINTS,
channels=3,
window_sec=PLOT_WINDOW_SEC,
sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
y_axis_min_span=Y_AXIS_MIN_SPAN,
y_range_alpha=Y_RANGE_ALPHA,
)
self._acq_bridge = _AcquisitionBridge()
self._acq_bridge.error.connect(self._on_acquisition_error)
self._acq_bridge.status.connect(self._on_acquisition_status)
self.stitch_worker = SdkAcquisitionWorker(
on_error=lambda m: self._acq_bridge.error.emit(m),
on_status=lambda m: self._acq_bridge.status.emit(m),
)
self.dd06_worker = Dd06AcquisitionWorker(
on_error=lambda m: self._acq_bridge.error.emit(m),
on_status=lambda m: self._acq_bridge.status.emit(m),
)
self.acquisition: AcquisitionPipeline = self.stitch_worker
self.recorder = RawRecorder()
self._source_pixmap: Optional[QPixmap] = None
self._last_packet: Optional[SdkPacket] = None
self._last_packet_id = 0
self._awaiting_first_frame = False
self._display_aspect = 1.0
self._view_mode = "pure_color"
self._sensor_type = "orisys"
self._log_panel_visible = False
self._fixed_mode: Optional[FixedCameraMode] = None
self._orisys_config = str(default_ddjx01_config())
self._dd06_config = str(default_dd06_config())
self._build_ui()
self._create_plots()
self._apply_language()
self._refresh_orisys_status()
self._log(self.t("exhibition.hints"))
self.timer = QTimer(self)
self.timer.timeout.connect(self._on_tick)
self.timer.start(16)
def t(self, key: str, **kwargs) -> str:
return self.tr.t(key, **kwargs)
def _log(self, message: str) -> None:
stamp = time.strftime("%H:%M:%S")
self.log_widget.appendPlainText(f"[{stamp}] {message}")
def _pipeline_running(self) -> bool:
return bool(self.stitch_worker.is_running or self.dd06_worker.is_running)
def _build_ui(self) -> None:
self._build_menu()
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(8)
bar = QHBoxLayout()
bar.setSpacing(8)
self.sensor_caption = QLabel()
self.sensor_caption.setObjectName("toolbarCaption")
bar.addWidget(self.sensor_caption)
self.sensor_combo = QComboBox()
self.sensor_combo.addItem("", "orisys")
self.sensor_combo.addItem("", "dd06")
self.sensor_combo.currentIndexChanged.connect(self._on_sensor_type_changed)
bar.addWidget(self.sensor_combo)
self.btn_search = QPushButton()
self.btn_search.clicked.connect(self._on_search_cameras)
bar.addWidget(self.btn_search)
self.btn_camera = QPushButton()
self.btn_camera.clicked.connect(self._on_browse_camera)
bar.addWidget(self.btn_camera)
self.camera_edit = QLineEdit("0")
bar.addWidget(self.camera_edit, 1)
self.btn_config = QPushButton()
self.btn_config.clicked.connect(self._on_browse_config)
bar.addWidget(self.btn_config)
self.config_combo = QComboBox()
self.config_combo.setMinimumWidth(160)
self.config_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
bar.addWidget(self.config_combo, 1)
self._populate_config_combo(preferred=self._orisys_config)
self.btn_start = QPushButton()
self.btn_start.setObjectName("btnPrimary")
self.btn_start.clicked.connect(self._on_start)
bar.addWidget(self.btn_start)
self.btn_stop = QPushButton()
self.btn_stop.setEnabled(False)
self.btn_stop.clicked.connect(self._on_stop)
bar.addWidget(self.btn_stop)
self.btn_reset = QPushButton()
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self._on_reset)
bar.addWidget(self.btn_reset)
self.btn_record = QPushButton()
self.btn_record.setObjectName("btnDanger")
self.btn_record.setEnabled(False)
self.btn_record.clicked.connect(self._on_toggle_record)
bar.addWidget(self.btn_record)
self.show_log_check = QCheckBox()
self.show_log_check.setChecked(False)
self.show_log_check.toggled.connect(self._on_show_log_changed)
bar.addWidget(self.show_log_check)
self.view_caption = QLabel()
self.view_caption.setObjectName("toolbarCaption")
bar.addWidget(self.view_caption)
self.view_combo = QComboBox()
self.view_combo.addItem("", "pure_color")
self.view_combo.addItem("", "captured")
self.view_combo.addItem("", "raw")
self.view_combo.setCurrentIndex(0)
self.view_combo.currentIndexChanged.connect(self._on_view_changed)
bar.addWidget(self.view_combo)
self.language_caption = QLabel()
self.language_caption.setObjectName("toolbarCaption")
bar.addWidget(self.language_caption)
self.language_combo = QComboBox()
self.language_combo.addItem("", "zh_CN")
self.language_combo.addItem("", "en_US")
self.language_combo.setCurrentIndex(0 if self.tr.locale == "zh_CN" else 1)
self.language_combo.currentIndexChanged.connect(self._on_language_changed)
bar.addWidget(self.language_combo)
root.addLayout(bar)
v_splitter = QSplitter(Qt.Vertical)
h_splitter = QSplitter(Qt.Horizontal)
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
self.image_label = QLabel()
self.image_label.setObjectName("imagePanel")
self.image_label.setMinimumSize(240, 240)
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
left_layout.addWidget(self.image_label)
h_splitter.addWidget(left)
pg.setConfigOptions(antialias=True, foreground="#e8eaed", background="#121316")
self.graph_widget = pg.GraphicsLayoutWidget()
self.graph_widget.setMinimumWidth(320)
h_splitter.addWidget(self.graph_widget)
self.h_splitter = h_splitter
v_splitter.addWidget(h_splitter)
self.log_widget = QPlainTextEdit()
self.log_widget.setReadOnly(True)
self.log_widget.setMaximumBlockCount(500)
self.log_widget.setMinimumHeight(110)
self.log_widget.setVisible(self._log_panel_visible)
v_splitter.addWidget(self.log_widget)
v_splitter.setStretchFactor(0, 5)
v_splitter.setStretchFactor(1, 1)
root.addWidget(v_splitter, 1)
self.status_label = QLabel()
self.status_label.setStyleSheet("color: #9aa0a6;")
root.addWidget(self.status_label)
def _build_menu(self) -> None:
menu = self.menuBar()
self.help_menu = menu.addMenu("")
self.about_action = QAction("", self)
self.about_action.triggered.connect(self._on_about)
self.help_menu.addAction(self.about_action)
def _create_plots(self) -> None:
self.plots = []
self.curves = []
self.dot_cores = []
self.plot_titles = [
"exhibition.plot_fn",
"exhibition.plot_fx",
"exhibition.plot_fy",
]
axis_pen = pg.mkPen(color="#3c4048", width=1)
for i, title_key in enumerate(self.plot_titles):
plot = self.graph_widget.addPlot(title=self.t(title_key))
plot.showGrid(x=False, y=True, alpha=0.2)
plot.getAxis("left").setPen(axis_pen)
plot.getAxis("bottom").setPen(axis_pen)
plot.setLabel("bottom", "s")
curve = plot.plot(pen=pg.mkPen(CURVE_COLORS[i], width=2))
dot = plot.plot(
[],
[],
pen=None,
symbol="o",
symbolSize=6,
symbolBrush=CURVE_COLORS[i],
)
plot.setXRange(0, PLOT_WINDOW_SEC, padding=0)
plot.enableAutoRange(x=False, y=False)
self.plots.append(plot)
self.curves.append(curve)
self.dot_cores.append(dot)
if i < 2:
self.graph_widget.nextRow()
def _apply_language(self) -> None:
self.setWindowTitle(self.t("exhibition.title"))
self.help_menu.setTitle(self.t("exhibition.menu_help"))
self.about_action.setText(self.t("exhibition.menu_about"))
self.sensor_caption.setText(self.t("exhibition.sensor_type"))
self.btn_search.setText(self.t("exhibition.btn_search"))
self.btn_camera.setText(self.t("exhibition.btn_camera"))
self.btn_config.setText(self.t("exhibition.btn_config"))
self.btn_start.setText(self.t("exhibition.btn_start"))
self.btn_stop.setText(self.t("exhibition.btn_stop"))
self.btn_reset.setText(self.t("exhibition.btn_reset"))
self._refresh_record_button()
self.view_caption.setText(self.t("exhibition.view"))
self.show_log_check.setText(self.t("exhibition.show_log"))
self.language_caption.setText(self.t("app.language"))
self.camera_edit.setPlaceholderText(self.t("exhibition.placeholder_camera"))
self._update_config_tooltip()
self.language_combo.blockSignals(True)
self.language_combo.setItemText(0, self.t("app.lang_zh"))
self.language_combo.setItemText(1, self.t("app.lang_en"))
self.language_combo.blockSignals(False)
self.sensor_combo.blockSignals(True)
self.sensor_combo.setItemText(0, self.t("exhibition.sensor_orisys"))
self.sensor_combo.setItemText(1, self.t("exhibition.sensor_dd06"))
self.sensor_combo.blockSignals(False)
self.view_combo.blockSignals(True)
self.view_combo.setItemText(0, self.t("exhibition.view_pure_color"))
self.view_combo.setItemText(1, self.t("exhibition.view_captured"))
self.view_combo.setItemText(2, self.t("exhibition.view_raw"))
self.view_combo.blockSignals(False)
for plot, title_key in zip(self.plots, self.plot_titles):
plot.setTitle(self.t(title_key), color="#9aa0a6", size="12pt")
if self._source_pixmap is None:
self.image_label.setText(self.t("labels.no_preview"))
self.status_label.setText(self.t("status.ready"))
def _selected_config(self) -> str:
data = self.config_combo.currentData()
if data:
return str(data)
return self.config_combo.currentText().strip()
def _set_config_selection(self, value: str) -> None:
value = str(value or "").strip()
if not value:
return
for index in range(self.config_combo.count()):
if str(self.config_combo.itemData(index) or "") == value:
self.config_combo.setCurrentIndex(index)
return
# Custom path not yet in the list.
if Path(value).stem.lower() == "options" and Path(value).suffix.lower() == ".json":
return
self.config_combo.addItem(config_display_name(value), value)
self.config_combo.setCurrentIndex(self.config_combo.count() - 1)
def _populate_config_combo(self, *, preferred: str | None = None) -> None:
current = preferred or self._selected_config() or default_ddjx01_config()
custom_paths = []
for index in range(self.config_combo.count()):
data = str(self.config_combo.itemData(index) or "")
if data and (Path(data).suffix.lower() == ".json" or "/" in data or "\\" in data):
if data not in custom_paths:
custom_paths.append(data)
self.config_combo.blockSignals(True)
self.config_combo.clear()
for entry in list_orisys_configs():
self.config_combo.addItem(config_display_name(entry), entry)
for path in custom_paths:
# Avoid duplicating local entries already listed above.
if any(str(self.config_combo.itemData(i) or "") == path for i in range(self.config_combo.count())):
continue
if Path(path).stem.lower() == "options":
continue
self.config_combo.addItem(config_display_name(path), path)
if self.config_combo.count() == 0 and current:
self.config_combo.addItem(config_display_name(current), current)
self.config_combo.blockSignals(False)
self._set_config_selection(current)
self._update_config_tooltip()
def _update_config_tooltip(self) -> None:
cfg_dir = orisys_configs_dir()
selected = self._selected_config()
if cfg_dir is not None:
tip = self.t("exhibition.config_dir_tip", path=cfg_dir, selected=selected)
else:
tip = self.t("exhibition.config_dir_missing")
self.config_combo.setToolTip(tip)
self.btn_config.setToolTip(tip)
def _refresh_record_button(self) -> None:
if self.recorder.is_recording:
self.btn_record.setText(self.t("exhibition.btn_stop_record"))
else:
self.btn_record.setText(self.t("exhibition.btn_start_record"))
def _on_about(self) -> None:
ok, orisys_ver = orisys_importable()
orisys_text = orisys_ver if ok else self.t("status.orisys_missing")
QMessageBox.about(
self,
self.t("exhibition.about_title"),
self.t(
"exhibition.about_body",
app_version=app_version(),
orisys_version=orisys_text,
),
)
def _on_language_changed(self, *_args) -> None:
locale = self.language_combo.currentData()
if not locale:
return
self.tr.set_locale(str(locale))
self._apply_language()
self._refresh_orisys_status()
def _on_sensor_type_changed(self, *_args) -> None:
if self._pipeline_running():
return
kind = str(self.sensor_combo.currentData() or "orisys")
if kind == self._sensor_type:
return
# Remember current config for the leaving type.
if self._sensor_type == "orisys":
self._orisys_config = self._selected_config() or self._orisys_config
else:
self._dd06_config = self._selected_config() or self._dd06_config
self._sensor_type = kind
self.acquisition = self.dd06_worker if kind == "dd06" else self.stitch_worker
preferred = self._dd06_config if kind == "dd06" else self._orisys_config
self._populate_config_combo(preferred=preferred)
self._log(self.t("exhibition.sensor_switched", sensor=kind))
def _on_view_changed(self, *_args) -> None:
mode = self.view_combo.currentData()
if mode in ("pure_color", "captured", "raw"):
self._view_mode = str(mode)
if self._last_packet is not None:
self._update_image(self._last_packet)
def _on_show_log_changed(self, visible: bool) -> None:
self._log_panel_visible = bool(visible)
self.log_widget.setVisible(self._log_panel_visible)
def _refresh_orisys_status(self) -> None:
ok, detail = orisys_importable()
if ok:
self.status_label.setText(self.t("status.orisys_ok", version=detail))
self.btn_start.setEnabled(not self._pipeline_running())
self._populate_config_combo(preferred=self._selected_config() or default_ddjx01_config())
else:
self.status_label.setText(self.t("status.orisys_missing"))
self.btn_start.setEnabled(False)
self._update_config_tooltip()
def _parse_camera_source(self) -> str | int:
text = self.camera_edit.text().strip() or "0"
if text.isdigit():
return int(text)
return text
def _on_search_cameras(self) -> None:
if self._pipeline_running():
return
preferred = self.camera_edit.text().strip()
cameras = list_cameras()
if not cameras:
self._log(self.t("exhibition.camera_none"))
return
names = ", ".join(cam.label() for cam in cameras)
self._log(self.t("exhibition.camera_found", count=len(cameras), names=names))
selected = pick_default_camera(
cameras, preferred if preferred.isdigit() else None
)
if selected is not None:
self.camera_edit.setText(selected)
def _on_browse_camera(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
self.t("exhibition.dialog_video"),
str(project_root()),
"Video (*.mp4 *.avi *.mov);;All (*)",
)
if path:
self.camera_edit.setText(path)
def _on_browse_config(self) -> None:
start_dir = orisys_configs_dir() or project_root()
path, _ = QFileDialog.getOpenFileName(
self,
self.t("dialogs.select_config"),
str(start_dir),
"JSON (*.json);;All (*)",
)
if path:
self._set_config_selection(path)
self._update_config_tooltip()
def _on_start(self) -> None:
ok, detail = orisys_importable()
if not ok:
QMessageBox.warning(
self,
self.t("dialogs.orisys_title"),
self.t("dialogs.orisys_required", detail=detail),
)
return
config = self._selected_config()
if not config:
QMessageBox.warning(
self, self.t("dialogs.config_title"), self.t("dialogs.config_required")
)
return
try:
mode = load_fixed_mode(config)
except Exception as exc: # noqa: BLE001
QMessageBox.warning(
self,
self.t("dialogs.config_title"),
self.t("labels.fixed_mode_invalid", error=exc),
)
return
self.force_history.reset()
self._awaiting_first_frame = True
self._source_pixmap = None
self.image_label.clear()
self.image_label.setText(self.t("labels.no_preview"))
self.btn_start.setEnabled(False)
self.btn_start.setText(self.t("exhibition.btn_starting"))
self.sensor_combo.setEnabled(False)
try:
if self._sensor_type == "dd06":
self.acquisition = self.dd06_worker
mode = self.dd06_worker.start(
self._parse_camera_source(),
config_path=config,
)
else:
self.acquisition = self.stitch_worker
mode = self.stitch_worker.start(
self._parse_camera_source(),
config_path=config,
cal_path=None,
backend="auto",
)
except Exception as exc: # noqa: BLE001
self._awaiting_first_frame = False
self.btn_start.setEnabled(True)
self.btn_start.setText(self.t("exhibition.btn_start"))
self.sensor_combo.setEnabled(True)
QMessageBox.critical(self, self.t("dialogs.capture_title"), str(exc))
self._log(self.t("status.capture_error", message=exc))
return
self._fixed_mode = mode
self.btn_start.setText(self.t("exhibition.btn_start"))
self.btn_stop.setEnabled(True)
self.btn_reset.setEnabled(True)
self.btn_record.setEnabled(False)
self.btn_search.setEnabled(False)
self.btn_camera.setEnabled(False)
self.camera_edit.setEnabled(False)
self.config_combo.setEnabled(False)
self.status_label.setText(self.t("status.opening", mode=mode.label()))
self._log(self.t("status.opening", mode=mode.label()))
def _on_stop(self) -> None:
if self.recorder.is_recording:
self._stop_record()
self.stitch_worker.stop()
self.dd06_worker.stop()
self._awaiting_first_frame = False
self._last_packet = None
self._source_pixmap = None
self.image_label.clear()
self.image_label.setText(self.t("labels.no_preview"))
self.btn_start.setEnabled(orisys_importable()[0])
self.btn_stop.setEnabled(False)
self.btn_reset.setEnabled(False)
self.btn_record.setEnabled(False)
self.btn_search.setEnabled(True)
self.btn_camera.setEnabled(True)
self.camera_edit.setEnabled(True)
self.config_combo.setEnabled(True)
self.sensor_combo.setEnabled(True)
self.status_label.setText(self.t("status.preview_stopped"))
self._log(self.t("status.preview_stopped"))
def _on_reset(self) -> None:
if not self._pipeline_running():
self._log(self.t("exhibition.reset_need_start"))
return
try:
self.acquisition.reset()
except Exception as exc: # noqa: BLE001
self._log(self.t("exhibition.reset_failed", message=exc))
return
self._log(self.t("exhibition.reset_done"))
def _on_toggle_record(self) -> None:
if self.recorder.is_recording:
self._stop_record()
else:
self._start_record()
def _start_record(self) -> None:
packet = self._last_packet or self.acquisition.latest()
if packet is None:
QMessageBox.warning(
self, self.t("dialogs.recording_title"), self.t("dialogs.no_frame")
)
return
record_fps = packet.fps or (packet.mode.fps if packet.mode else 30.0)
try:
path = self.recorder.start(
self.recordings_dir, fps=float(record_fps), frame=packet.raw_frame
)
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, self.t("dialogs.recording_title"), str(exc))
return
self._refresh_record_button()
self._log(self.t("exhibition.recording_started", name=path.name))
def _stop_record(self) -> None:
path, count = self.recorder.stop()
self._refresh_record_button()
if path is None:
return
self._log(self.t("status.saved", count=count, path=path))
def _on_acquisition_error(self, message: str) -> None:
self._awaiting_first_frame = False
self.btn_start.setEnabled(orisys_importable()[0])
self.btn_stop.setEnabled(False)
self.btn_reset.setEnabled(False)
self.btn_record.setEnabled(False)
self.btn_search.setEnabled(True)
self.btn_camera.setEnabled(True)
self.camera_edit.setEnabled(True)
self.config_combo.setEnabled(True)
self.sensor_combo.setEnabled(True)
self.status_label.setText(self.t("status.capture_error", message=message))
self._log(self.t("status.capture_error", message=message))
QMessageBox.warning(self, self.t("dialogs.capture_title"), message)
def _on_acquisition_status(self, message: str) -> None:
self.status_label.setText(message)
self._log(message)
def _on_tick(self) -> None:
packet = self.acquisition.latest()
if packet is None:
return
packet_id = id(packet)
if packet_id == self._last_packet_id and not self._awaiting_first_frame:
return
if self._awaiting_first_frame:
self._awaiting_first_frame = False
self.btn_record.setEnabled(True)
info = self.t(
"status.frame_info",
raw_w=packet.width,
raw_h=packet.height,
proc_w=packet.processed_width,
proc_h=packet.processed_height,
fps=packet.fps,
)
self.status_label.setText(info)
self._log(info)
self._log(
self.t(
"exhibition.force_fn_n",
value=calibrate_normal_force(packet.normal_force),
)
)
self._last_packet = packet
self._last_packet_id = packet_id
self._update_image(packet)
self.force_history.push(
packet.timestamp,
packet.normal_force,
packet.shear_x,
packet.shear_y,
)
self._update_plots()
if self.recorder.is_recording:
self.recorder.write(packet.raw_frame)
self.status_label.setText(
self.t(
"status.recording_frames",
name=self.recorder.path.name if self.recorder.path else "",
count=self.recorder.frame_count,
)
)
def _update_image(self, packet: SdkPacket) -> None:
if self._view_mode == "raw":
frame = packet.raw_frame
elif self._view_mode == "captured":
frame = draw_arrow_display(
packet.processed_frame, packet.flow, background="captured"
)
else:
frame = draw_arrow_display(
packet.processed_frame, packet.flow, background="pure_color"
)
h, w = frame.shape[:2]
aspect = float(w) / float(h) if h else 1.0
first = self._source_pixmap is None
aspect_changed = abs(aspect - self._display_aspect) > 1e-3
self._display_aspect = aspect
self._source_pixmap = ndarray_to_pixmap(frame)
if first or aspect_changed:
self._adjust_image_panel()
else:
self._render_image()
def _adjust_image_panel(self) -> None:
available = max(self.height() - 220, 200)
image_w = int(available * self._display_aspect)
image_w = max(min(image_w, max(self.width() // 2, 240)), 240)
self.h_splitter.setSizes([image_w, max(self.width() - image_w, 320)])
self._render_image()
def _render_image(self) -> None:
if self._source_pixmap is None or self._source_pixmap.isNull():
return
target = self.image_label.size()
if target.width() < 2 or target.height() < 2:
return
scaled = self._source_pixmap.scaled(
target, Qt.KeepAspectRatio, Qt.SmoothTransformation
)
self.image_label.setPixmap(scaled)
def _update_plots(self) -> None:
state = self.force_history.state()
if state is None:
return
for i, curve in enumerate(self.curves):
x = state.times
y = state.values[:, i]
curve.setData(x, y)
ymin, ymax = state.y_ranges[i]
self.plots[i].setYRange(ymin, ymax)
self.dot_cores[i].setData(x[-1:], y[-1:])
x0, x1 = state.x_range
for plot in self.plots:
plot.setXRange(x0, x1, padding=0)
def resizeEvent(self, event) -> None: # noqa: N802
super().resizeEvent(event)
self._render_image()
def keyPressEvent(self, event) -> None: # noqa: N802
key = event.key()
if key == Qt.Key_Escape:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
elif key == Qt.Key_Q:
self.close()
elif key == Qt.Key_S:
if not self.recorder.is_recording and self._pipeline_running():
self._start_record()
elif key == Qt.Key_E:
if self.recorder.is_recording:
self._stop_record()
elif key == Qt.Key_R:
self._on_reset()
else:
super().keyPressEvent(event)
def closeEvent(self, event) -> None: # noqa: N802
if self.recorder.is_recording:
self.recorder.stop()
self.stitch_worker.stop()
self.dd06_worker.stop()
self.timer.stop()
super().closeEvent(event)
+20
View File
@@ -0,0 +1,20 @@
"""Force calibration coefficients and plot defaults from Orisys viewer2d."""
from __future__ import annotations
# Linear FN → Newtons (y = mx + c), from orisys examples/gui/sensors/adapter.py.
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
# PyQtGraph force-curve tuning from examples/gui/viewer2d/backend.py.
FORCE_SAMPLE_RATE_HZ = 30.0
PLOT_BUFFER_POINTS = 120
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ
Y_AXIS_MIN_SPAN = 20000.0
Y_RANGE_ALPHA = 0.5
LOWPASS_CUTOFF_HZ = 10.0
def calibrate_normal_force(raw_value: float) -> float:
"""Convert raw normal-force reading to Newtons using y = mx + c."""
return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET
+15 -7
View File
@@ -1,22 +1,30 @@
"""Entry point for the Sensor Bench desktop application."""
"""Entry point for the public Sensor Bench exhibition application."""
from __future__ import annotations
import os
import sys
# Must run before any module that imports cv2 (exhibition_app, acquisition, etc.).
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
from PyQt5.QtWidgets import QApplication
from .app import MainWindow
def main(argv: list[str] | None = None) -> int:
args = list(sys.argv if argv is None else argv)
qt_app = QApplication(args)
parser_args = list(sys.argv[1:] if argv is None else argv[1:])
# Accept unknown Qt args (e.g. -platform offscreen) without advertising
# internal-only flags on the public CLI.
qt_args = parser_args
qt_app = QApplication([sys.argv[0], *qt_args] if argv is None else [argv[0], *qt_args])
qt_app.setApplicationName("Sensor Bench")
qt_app.setOrganizationName("Orisys-Tech")
window = MainWindow()
window.show()
from .exhibition_app import ExhibitionWindow
window = ExhibitionWindow()
window.showMaximized()
return qt_app.exec_()
+120 -20
View File
@@ -6,6 +6,9 @@ import sys
from pathlib import Path
NON_SENSOR_CONFIG_STEMS = {"options", "bench_theme"}
def is_frozen() -> bool:
return bool(getattr(sys, "frozen", False))
@@ -29,20 +32,6 @@ def project_root() -> Path:
return app_root()
def default_options_path() -> Path:
external = app_root() / "config" / "options.json"
if external.exists():
return external
return resource_root() / "config" / "options.json"
def default_steps_path() -> Path:
external = app_root() / "steps" / "default.json"
if external.exists():
return external
return resource_root() / "steps" / "default.json"
def default_locales_dir() -> Path:
external = app_root() / "config" / "locales"
if external.exists():
@@ -64,17 +53,128 @@ def default_orisys_sdk_root() -> Path:
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)
def local_sensor_configs_dir() -> Path:
"""Editable sensor configs next to the app (or repo) root."""
return app_root() / "config"
def _is_sensor_config_json(path: Path) -> bool:
if path.suffix.lower() != ".json":
return False
if path.parent.name.lower() == "locales":
return False
if path.stem.lower() in NON_SENSOR_CONFIG_STEMS:
return False
return True
def list_local_sensor_config_files() -> list[Path]:
"""Return local editable sensor config JSON paths (excludes options/locales)."""
root = local_sensor_configs_dir()
if not root.exists():
return []
return sorted(p for p in root.glob("*.json") if _is_sensor_config_json(p))
def orisys_configs_dir() -> Path | None:
"""Prefer local sensor configs dir when present; else installed package dir."""
local = local_sensor_configs_dir()
if list_local_sensor_config_files():
return local
try:
import orisys
path = Path(orisys.configs.CONFIG_DIR)
return path if path.exists() else None
except Exception: # noqa: BLE001
return None
def list_orisys_configs() -> list[str]:
"""Return selectable sensor configs.
Local ``config/*.json`` files (excluding non-sensor stems such as
``bench_theme``) are listed first as
absolute paths so Start uses the editable copy beside the EXE. Package stems
from the installed Orisys wheel are appended when not already covered.
"""
entries: list[str] = []
seen_stems: set[str] = set()
for path in list_local_sensor_config_files():
stem = path.stem.lower()
if stem in seen_stems:
continue
entries.append(str(path.resolve()))
seen_stems.add(stem)
try:
import orisys
for stem in orisys.configs.list_config():
key = str(stem).strip()
if not key:
continue
if key.lower() in NON_SENSOR_CONFIG_STEMS:
continue
if key.lower() in seen_stems:
continue
entries.append(key)
seen_stems.add(key.lower())
except Exception: # noqa: BLE001
pass
return entries
def config_display_name(value: str) -> str:
"""Short label for a config path or package stem."""
text = str(value or "").strip()
if not text:
return ""
path = Path(text)
if path.suffix.lower() == ".json" or "/" in text or "\\" in text:
return path.stem
return text
def default_config_name(sensor_type: str = "orisys") -> str:
"""Preferred package config name for a sensor pipeline."""
kind = str(sensor_type or "orisys").strip().lower()
if kind in ("dd06", "dd06-ov", "roi", "polygon_roi"):
return "dd06-ov"
return "ddjx01"
def _pick_default_config(preferred_stem: str) -> str:
entries = list_orisys_configs()
for entry in entries:
if config_display_name(entry).lower() == preferred_stem.lower():
return entry
if entries:
return entries[0]
return preferred_stem
def default_ddjx01_config() -> str:
"""Prefer local editable config path, else package stem."""
return _pick_default_config(default_config_name("orisys"))
def default_ddjx01_calibration() -> Path:
external = app_root() / "config" / "ddjx01.npy"
if external.exists():
return external
return default_orisys_sdk_root() / "config" / "ddjx01.npy"
def default_dd06_config() -> str:
preferred = default_config_name("dd06")
entries = list_orisys_configs()
for stem in (preferred, "dd06"):
for entry in entries:
if config_display_name(entry).lower() == stem.lower():
return entry
if entries:
return preferred
return preferred
+94 -12
View File
@@ -16,19 +16,70 @@ class PlotState:
y_ranges: tuple[tuple[float, float], ...]
class MetricHistory:
"""Ring-style buffer for FPS / brightness (or other scalar metrics)."""
def lowpass_filter(
data,
cutoff_hz: float,
sample_rate_hz: float,
order: int = 2,
) -> np.ndarray:
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data."""
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0:
return y.copy()
if y.size == 0:
return y
def __init__(self, npoints: int = 120, *, channels: int = 2, window_sec: float = 4.0):
nyquist = 0.5 * sample_rate_hz
if cutoff_hz < 0 or cutoff_hz >= nyquist:
raise ValueError(
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
)
from scipy.signal import butter, filtfilt
b, a = butter(order, cutoff_hz / nyquist, btype="low")
padlen = 3 * (max(len(b), len(a)) - 1)
if y.size <= padlen:
return y.copy()
return filtfilt(b, a, y)
class MetricHistory:
"""Ring-style buffer for force / metric curves (viewer2d-compatible options)."""
def __init__(
self,
npoints: int = 120,
*,
channels: int = 2,
window_sec: float = 4.0,
sample_rate_hz: float | None = None,
lowpass_cutoff_hz: float = 0.0,
y_axis_min_span: float | None = None,
y_range_alpha: float = 0.5,
):
self.npoints = int(npoints)
self.channels = int(channels)
self.window_sec = float(window_sec)
self.sample_rate_hz = (
float(sample_rate_hz)
if sample_rate_hz is not None
else (self.npoints / self.window_sec if self.window_sec > 0 else 30.0)
)
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
self.y_axis_min_span = (
None if y_axis_min_span is None else float(y_axis_min_span)
)
self.y_range_alpha = float(y_range_alpha)
self.reset()
def reset(self) -> None:
self._data = np.full((self.npoints, 1 + self.channels), np.nan, dtype=np.float64)
self.sample_count = 0
self._t0: Optional[float] = None
self._y_range_smooth: list[Optional[tuple[float, float]]] = [
None for _ in range(self.channels)
]
def push(self, timestamp: float, *values: float) -> None:
if len(values) != self.channels:
@@ -44,6 +95,37 @@ class MetricHistory:
self._data[:-1, :] = self._data[1:, :]
self._data[-1, :] = row
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> tuple[float, float]:
if self.y_axis_min_span is None:
if abs(ymax - ymin) < 1e-6:
pad = 1.0 if abs(ymin) < 1e-6 else abs(ymin) * 0.1
ymin -= pad
ymax += pad
else:
pad = (ymax - ymin) * 0.12
ymin -= pad
ymax += pad
return ymin, ymax
if self._y_range_smooth[channel] is None:
smoothed = (ymin, ymax)
else:
prev_min, prev_max = self._y_range_smooth[channel]
alpha = self.y_range_alpha
ymin = alpha * ymin + (1.0 - alpha) * prev_min
ymax = alpha * ymax + (1.0 - alpha) * prev_max
smoothed = (ymin, ymax)
span = smoothed[1] - smoothed[0]
if span < self.y_axis_min_span:
mid = 0.5 * (smoothed[0] + smoothed[1])
half = self.y_axis_min_span / 2.0
smoothed = (mid - half, mid + half)
self._y_range_smooth[channel] = smoothed
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
return smoothed[0] - pad, smoothed[1] + pad
def state(self) -> Optional[PlotState]:
n = self.sample_count
if n == 0:
@@ -51,6 +133,14 @@ class MetricHistory:
block = self._data[:n]
times = block[:, 0].copy()
values = block[:, 1:].copy()
if self.lowpass_cutoff_hz > 0:
for i in range(self.channels):
values[:, i] = lowpass_filter(
values[:, i],
self.lowpass_cutoff_hz,
sample_rate_hz=self.sample_rate_hz,
)
if n < self.npoints:
x_range = (0.0, self.window_sec)
else:
@@ -67,15 +157,7 @@ class MetricHistory:
ymax = float(np.nanmax(col))
if not np.isfinite(ymin) or not np.isfinite(ymax):
ymin, ymax = 0.0, 1.0
if abs(ymax - ymin) < 1e-6:
pad = 1.0 if abs(ymin) < 1e-6 else abs(ymin) * 0.1
ymin -= pad
ymax += pad
else:
pad = (ymax - ymin) * 0.12
ymin -= pad
ymax += pad
y_ranges.append((ymin, ymax))
y_ranges.append(self._smooth_y_range(i, ymin, ymax))
return PlotState(
times=times,
+144
View File
@@ -0,0 +1,144 @@
"""Polygon ROI geometry helpers for DD06-style rectification."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import cv2
import numpy as np
def load_json_config(path: str | Path) -> dict[str, Any]:
path = Path(path)
if path.exists():
with path.open("r", encoding="utf-8") as f:
return json.load(f)
try:
import orisys
return orisys.configs.load_config(str(path))
except Exception as exc: # noqa: BLE001
raise FileNotFoundError(f"Config not found as file or bundled name: {path}") from exc
def load_roi_polygon(config: dict[str, Any]):
"""Return (points Nx2 float32, image_size [w,h]) from nested or legacy ROI fields."""
roi = config.get("roi") if isinstance(config.get("roi"), dict) else config
if not isinstance(roi, dict):
raise ValueError("Config has no usable ROI section")
image_size = roi.get("image_size") or config.get("camera", {}).get("resolution_raw")
if image_size is None:
raise ValueError("ROI image_size is missing")
points = roi.get("points") or roi.get("polygon") or roi.get("roi_polygon")
if points is None and roi.get("xyxy") is not None:
x1, y1, x2, y2 = [int(v) for v in roi["xyxy"]]
points = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]
if not points:
raise ValueError("ROI points are missing")
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
if pts.shape[0] < 4:
raise ValueError(f"ROI needs at least 4 points, got {pts.shape[0]}")
return pts, [int(image_size[0]), int(image_size[1])]
def scale_points(points, source_size, target_size):
source_w, source_h = float(source_size[0]), float(source_size[1])
target_w, target_h = float(target_size[0]), float(target_size[1])
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2).copy()
if source_w > 0 and source_h > 0 and (source_w, source_h) != (target_w, target_h):
pts[:, 0] *= target_w / source_w
pts[:, 1] *= target_h / source_h
return pts
def order_quad_points(points):
"""Order 4 points as TL, TR, BR, BL for perspective transforms."""
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
if pts.shape[0] != 4:
hull = cv2.convexHull(pts.astype(np.float32))
rect = cv2.minAreaRect(hull)
pts = cv2.boxPoints(rect).astype(np.float32)
s = pts.sum(axis=1)
diff = np.diff(pts, axis=1).reshape(-1)
ordered = np.zeros((4, 2), dtype=np.float32)
ordered[0] = pts[np.argmin(s)]
ordered[2] = pts[np.argmax(s)]
ordered[1] = pts[np.argmin(diff)]
ordered[3] = pts[np.argmax(diff)]
return ordered
def rectified_output_size(config, points=None):
"""Prefer camera.resolution_stitch; fall back to ROI bounding box size."""
stitch = config.get("camera", {}).get("resolution_stitch")
if stitch is not None and len(stitch) >= 2:
return int(stitch[0]), int(stitch[1])
if points is not None:
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
width = max(
int(np.linalg.norm(pts[1] - pts[0])),
int(np.linalg.norm(pts[2] - pts[3])),
1,
)
height = max(
int(np.linalg.norm(pts[3] - pts[0])),
int(np.linalg.norm(pts[2] - pts[1])),
1,
)
return width, height
raise ValueError("Cannot determine rectified output size")
def build_roi_warp(points, source_image_size, frame_size, output_size):
frame_w, frame_h = int(frame_size[0]), int(frame_size[1])
scaled = scale_points(points, source_image_size, (frame_w, frame_h))
src = order_quad_points(scaled)
out_w, out_h = int(output_size[0]), int(output_size[1])
dst = np.array(
[
[0.0, 0.0],
[out_w - 1.0, 0.0],
[out_w - 1.0, out_h - 1.0],
[0.0, out_h - 1.0],
],
dtype=np.float32,
)
matrix = cv2.getPerspectiveTransform(src, dst)
return matrix, src, (out_w, out_h)
def warp_roi_to_rectangle(frame, matrix, output_size):
out_w, out_h = int(output_size[0]), int(output_size[1])
return cv2.warpPerspective(
frame,
matrix,
(out_w, out_h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
def open_video_source(video):
"""Open a camera index or video file. Returns (cap, resolved_source)."""
text = str(video).strip()
if text.isdigit():
source = int(text)
cap = cv2.VideoCapture(source, cv2.CAP_DSHOW)
if not cap.isOpened():
cap.release()
cap = cv2.VideoCapture(source)
else:
path = Path(text)
if not path.exists():
raise FileNotFoundError(f"Video not found: {path}")
source = str(path)
cap = cv2.VideoCapture(source)
if not cap.isOpened():
raise RuntimeError(f"Failed to open video source: {video}")
return cap, source