update sensor bench
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user