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
+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)