changed customer to studio
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
"""Orisys Studio desktop application."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
REQUIRES_SHARED_API = "0.1"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
"""Studio app entrypoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared.compat import check_shared_compat
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
||||
from orisys_app_shared.paths import prefer_local_sensor_bundles
|
||||
from orisys_app_shared.prefs import load_preferences
|
||||
from orisys_app_shared.theme import build_qss
|
||||
|
||||
from orisys_studio import REQUIRES_SHARED_API, __version__ as app_version
|
||||
from orisys_studio.app_window import StudioMainWindow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
init_logging("orisys-studio")
|
||||
install_exception_hooks()
|
||||
preferred = prefer_local_sensor_bundles()
|
||||
if preferred is not None:
|
||||
logger.info("Preferring local sensor bundles at %s", preferred)
|
||||
check_shared_compat(REQUIRES_SHARED_API)
|
||||
logger.info(
|
||||
"Versions: app=%s shared=%s api=%s (requires %s)",
|
||||
app_version,
|
||||
shared_version,
|
||||
API_VERSION,
|
||||
REQUIRES_SHARED_API,
|
||||
)
|
||||
|
||||
prefs = load_preferences()
|
||||
language = str(prefs.get("language") or "zh_CN")
|
||||
theme = str(prefs.get("theme") or "dark")
|
||||
set_locale(language)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Orisys Studio")
|
||||
app.setApplicationVersion(app_version)
|
||||
app.setStyleSheet(build_qss(theme))
|
||||
|
||||
window = StudioMainWindow(prefs=prefs)
|
||||
window.show()
|
||||
logger.info("Studio shell started (theme=%s, language=%s)", theme, language)
|
||||
return int(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""UI package."""
|
||||
@@ -0,0 +1,44 @@
|
||||
"""About dialog for Help > About."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
|
||||
from orisys_studio import __version__ as app_version
|
||||
|
||||
|
||||
class AboutDialog(QDialog):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(t("menu.about"))
|
||||
self.setModal(True)
|
||||
self.resize(420, 220)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
row = QHBoxLayout()
|
||||
logo = QLabel()
|
||||
pix = QPixmap(str(logo_topbar_path()))
|
||||
if not pix.isNull():
|
||||
logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation))
|
||||
row.addWidget(logo)
|
||||
row.addStretch(1)
|
||||
root.addLayout(row)
|
||||
|
||||
self.body = QLabel()
|
||||
self.body.setWordWrap(True)
|
||||
self.body.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
root.addWidget(self.body)
|
||||
|
||||
close_btn = QPushButton("OK")
|
||||
close_btn.clicked.connect(self.accept)
|
||||
root.addWidget(close_btn, alignment=Qt.AlignRight)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(t("app.about_title"))
|
||||
self.body.setText(t("app.about_body", app_version=app_version))
|
||||
@@ -0,0 +1,189 @@
|
||||
"""2D image panel: arrows / depth view with mode selector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QImage, QPixmap, QTransform
|
||||
from PyQt5.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
|
||||
_ROTATION_STEPS = (0, 90, 180, 270)
|
||||
|
||||
|
||||
def _normalize_rotation_deg(value: object, default: int = 90) -> int:
|
||||
try:
|
||||
deg = int(value) % 360 # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if deg not in _ROTATION_STEPS:
|
||||
deg = int(round(deg / 90.0) * 90) % 360
|
||||
return deg
|
||||
|
||||
|
||||
class ArrowPanel(QFrame):
|
||||
view_mode_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
header = QHBoxLayout()
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("panelTitle")
|
||||
self.fps_label = QLabel()
|
||||
self.fps_label.setObjectName("panelMeta")
|
||||
self.fps_label.setVisible(False)
|
||||
self._fps_visible = False
|
||||
self._fps_value: float | None = None
|
||||
self._rotation_deg = 90
|
||||
self._rotation_enabled = True
|
||||
self._source_image: QImage | None = None
|
||||
self.view_combo = QComboBox()
|
||||
self.view_combo.setMinimumWidth(140)
|
||||
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
|
||||
header.addWidget(self.title)
|
||||
header.addStretch(1)
|
||||
header.addWidget(self.fps_label)
|
||||
header.addWidget(self.view_combo)
|
||||
layout.addLayout(header)
|
||||
|
||||
self.image_label = QLabel()
|
||||
self.image_label.setAlignment(Qt.AlignCenter)
|
||||
self.image_label.setMinimumSize(240, 180)
|
||||
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.image_label.setScaledContents(False)
|
||||
self.image_label.setWordWrap(True)
|
||||
layout.addWidget(self.image_label, 1)
|
||||
self._placeholder = ""
|
||||
self._populate_view_combo()
|
||||
self.retranslate()
|
||||
|
||||
def view_mode(self) -> str:
|
||||
data = self.view_combo.currentData()
|
||||
return str(data or "arrows_pure")
|
||||
|
||||
def set_view_mode(self, mode: str) -> None:
|
||||
wanted = str(mode or "arrows_pure")
|
||||
if wanted not in VIEW_MODES:
|
||||
wanted = "arrows_pure"
|
||||
idx = self.view_combo.findData(wanted)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.view_combo.blockSignals(True)
|
||||
self.view_combo.setCurrentIndex(idx)
|
||||
self.view_combo.blockSignals(False)
|
||||
|
||||
def rotation_deg(self) -> int:
|
||||
return self._rotation_deg
|
||||
|
||||
def set_rotation_deg(self, deg: object) -> None:
|
||||
self._rotation_deg = _normalize_rotation_deg(deg)
|
||||
self._repaint_source()
|
||||
|
||||
def set_rotation_enabled(self, enabled: bool) -> None:
|
||||
wanted = bool(enabled)
|
||||
if wanted == self._rotation_enabled:
|
||||
return
|
||||
self._rotation_enabled = wanted
|
||||
self._repaint_source()
|
||||
|
||||
def rotate_90_ccw(self) -> int:
|
||||
if not self._rotation_enabled:
|
||||
return self._rotation_deg
|
||||
self.set_rotation_deg(self._rotation_deg + 90)
|
||||
return self._rotation_deg
|
||||
|
||||
def set_fps_visible(self, visible: bool) -> None:
|
||||
self._fps_visible = bool(visible)
|
||||
self._refresh_fps_label()
|
||||
|
||||
def set_fps(self, fps: float | None) -> None:
|
||||
self._fps_value = None if fps is None else float(fps)
|
||||
self._refresh_fps_label()
|
||||
|
||||
def _refresh_fps_label(self) -> None:
|
||||
if not self._fps_visible or self._fps_value is None:
|
||||
self.fps_label.clear()
|
||||
self.fps_label.setVisible(False)
|
||||
return
|
||||
self.fps_label.setText(f"{self._fps_value:.1f} FPS")
|
||||
self.fps_label.setVisible(True)
|
||||
|
||||
def _populate_view_combo(self) -> None:
|
||||
current = self.view_combo.currentData()
|
||||
self.view_combo.blockSignals(True)
|
||||
self.view_combo.clear()
|
||||
for mode in VIEW_MODES:
|
||||
self.view_combo.addItem("", mode)
|
||||
if current:
|
||||
idx = self.view_combo.findData(current)
|
||||
self.view_combo.setCurrentIndex(max(0, idx))
|
||||
self.view_combo.blockSignals(False)
|
||||
|
||||
def _on_view_index_changed(self, _index: int) -> None:
|
||||
self.view_mode_changed.emit(self.view_mode())
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.arrow"))
|
||||
labels = (
|
||||
t("panels.view_arrows_pure"),
|
||||
t("panels.view_arrows_stitched"),
|
||||
t("panels.view_depth"),
|
||||
)
|
||||
for i, label in enumerate(labels):
|
||||
if i < self.view_combo.count():
|
||||
self.view_combo.setItemText(i, label)
|
||||
self.view_combo.setToolTip(t("panels.view_mode_tip"))
|
||||
self.image_label.setToolTip(t("panels.arrow_empty_tip"))
|
||||
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
|
||||
self._placeholder = t("panels.arrow_empty")
|
||||
self.image_label.setText(self._placeholder)
|
||||
|
||||
def _effective_rotation_deg(self) -> int:
|
||||
if not self._rotation_enabled:
|
||||
return 0
|
||||
return self._rotation_deg
|
||||
|
||||
def _repaint_source(self) -> None:
|
||||
if self._source_image is None or self._source_image.isNull():
|
||||
return
|
||||
self._paint_image(self._source_image)
|
||||
|
||||
def _paint_image(self, image: QImage) -> None:
|
||||
pix = QPixmap.fromImage(image)
|
||||
deg = self._effective_rotation_deg()
|
||||
if deg:
|
||||
# QTransform.rotate is clockwise on screen pixmaps; negate for CCW.
|
||||
pix = pix.transformed(QTransform().rotate(-deg), Qt.FastTransformation)
|
||||
scaled = pix.scaled(
|
||||
self.image_label.size(),
|
||||
Qt.KeepAspectRatio,
|
||||
Qt.SmoothTransformation,
|
||||
)
|
||||
self.image_label.setPixmap(scaled)
|
||||
self.image_label.setText("")
|
||||
|
||||
def show_frame(self, image: QImage) -> None:
|
||||
if image.isNull():
|
||||
return
|
||||
self._source_image = image
|
||||
self._paint_image(image)
|
||||
|
||||
def clear_preview(self, message: str | None = None) -> None:
|
||||
self._source_image = None
|
||||
self.image_label.clear()
|
||||
self.image_label.setPixmap(QPixmap())
|
||||
text = message if message is not None else t("panels.arrow_empty")
|
||||
self._placeholder = text
|
||||
self.image_label.setText(text)
|
||||
self.set_fps(None)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Brand bar shown at the top of the studio shell.
|
||||
|
||||
A slim deep-navy rail carrying the corporate logo lockup and the product name.
|
||||
Logo colours are re-tinted at runtime so a single asset works on the brand
|
||||
background in both themes; all other colours come from the theme tokens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QColor, QPainter, QPixmap
|
||||
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LOGO_SIZE = 22
|
||||
BAR_HEIGHT = 40
|
||||
DIVIDER_HEIGHT = 18
|
||||
|
||||
|
||||
def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap:
|
||||
"""Return ``path`` recoloured to ``tint``, preserving its alpha channel.
|
||||
|
||||
Returns a null pixmap when the source cannot be loaded, so callers can fall
|
||||
back to a text-only lockup.
|
||||
"""
|
||||
source = QPixmap(str(path))
|
||||
if source.isNull():
|
||||
return QPixmap()
|
||||
scaled = source.scaled(
|
||||
size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation
|
||||
)
|
||||
out = QPixmap(scaled.size())
|
||||
out.fill(Qt.transparent)
|
||||
painter = QPainter(out)
|
||||
painter.drawPixmap(0, 0, scaled)
|
||||
painter.setCompositionMode(QPainter.CompositionMode_SourceIn)
|
||||
painter.fillRect(out.rect(), QColor(tint))
|
||||
painter.end()
|
||||
return out
|
||||
|
||||
|
||||
class BrandBar(QFrame):
|
||||
"""Thin brand rail: logo lockup on the left, product name on the right."""
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self._theme = theme
|
||||
self.setObjectName("brandBar")
|
||||
self.setFixedHeight(BAR_HEIGHT)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(14, 0, 14, 0)
|
||||
layout.setSpacing(10)
|
||||
|
||||
self.logo = QLabel()
|
||||
self.logo.setObjectName("brandLogo")
|
||||
self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE)
|
||||
self.logo.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.logo)
|
||||
|
||||
lockup = QVBoxLayout()
|
||||
lockup.setContentsMargins(0, 0, 0, 0)
|
||||
lockup.setSpacing(0)
|
||||
self.name = QLabel()
|
||||
self.name.setObjectName("brandName")
|
||||
self.tagline = QLabel()
|
||||
self.tagline.setObjectName("brandTagline")
|
||||
lockup.addWidget(self.name)
|
||||
lockup.addWidget(self.tagline)
|
||||
layout.addLayout(lockup)
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
self.divider = QFrame()
|
||||
self.divider.setObjectName("sectionSeparator")
|
||||
self.divider.setFixedSize(1, DIVIDER_HEIGHT)
|
||||
layout.addWidget(self.divider)
|
||||
|
||||
self.product = QLabel()
|
||||
self.product.setObjectName("brandProduct")
|
||||
self.product.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
layout.addWidget(self.product)
|
||||
|
||||
self.set_theme(theme)
|
||||
self.retranslate()
|
||||
|
||||
def _refresh_logo(self) -> None:
|
||||
pixmap = tinted_pixmap(
|
||||
logo_topbar_path(), color(self._theme, "brand_bar_fg", "#FFFFFF")
|
||||
)
|
||||
if pixmap.isNull():
|
||||
logger.debug("Brand logo unavailable; showing text-only lockup")
|
||||
self.logo.clear()
|
||||
self.logo.hide()
|
||||
return
|
||||
self.logo.setPixmap(pixmap)
|
||||
self.logo.show()
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self._refresh_logo()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.name.setText(t("brand.name"))
|
||||
self.tagline.setText(t("brand.tagline"))
|
||||
self.product.setText(t("app.title"))
|
||||
self.name.setToolTip(t("app.title"))
|
||||
@@ -0,0 +1,440 @@
|
||||
"""Force calibration sampling panel (FN / FX / FY)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QButtonGroup,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CalSample,
|
||||
CalSessionStore,
|
||||
ChannelKey,
|
||||
export_force_calibration_v2,
|
||||
meta_from_sensor_json,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import calibration_export_dir, sensors_dir
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
CHANNEL_LABELS = ("fn", "fx", "fy")
|
||||
|
||||
|
||||
def _mono_font() -> str:
|
||||
families = set(QFontDatabase().families())
|
||||
for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"):
|
||||
if name in families:
|
||||
return name
|
||||
return "monospace"
|
||||
|
||||
|
||||
class CalPanel(QFrame):
|
||||
status_message = pyqtSignal(str)
|
||||
calibration_exported = pyqtSignal(str) # path applied to running sensor
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._theme = theme
|
||||
self._store = CalSessionStore()
|
||||
self._channel: ChannelKey = "fn"
|
||||
self._running = False
|
||||
self._recording = False
|
||||
self._record_deadline = 0.0
|
||||
self._record_started = 0.0
|
||||
self._record_samples: list[float] = []
|
||||
self._live_fn = 0.0
|
||||
self._live_fx = 0.0
|
||||
self._live_fy = 0.0
|
||||
self._sensor_type = "box"
|
||||
self._config_ref: str | None = None
|
||||
self._record_duration_s = 7.0
|
||||
self._mono = _mono_font()
|
||||
self._mono_font = QFont(self._mono, 10)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(8)
|
||||
|
||||
title_row = QHBoxLayout()
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("panelTitle")
|
||||
self.channel_badge = QLabel()
|
||||
self.channel_badge.setObjectName("unitBadge")
|
||||
title_row.addWidget(self.title)
|
||||
title_row.addStretch(1)
|
||||
title_row.addWidget(self.channel_badge)
|
||||
root.addLayout(title_row)
|
||||
|
||||
channel_row = QHBoxLayout()
|
||||
channel_row.setSpacing(10)
|
||||
self.channel_group = QButtonGroup(self)
|
||||
self.radio_fn = QRadioButton("FN")
|
||||
self.radio_fx = QRadioButton("FX")
|
||||
self.radio_fy = QRadioButton("FY")
|
||||
self.radio_fn.setChecked(True)
|
||||
for i, radio in enumerate((self.radio_fn, self.radio_fx, self.radio_fy)):
|
||||
self.channel_group.addButton(radio, i)
|
||||
channel_row.addWidget(radio)
|
||||
channel_row.addStretch(1)
|
||||
self.channel_group.buttonToggled.connect(self._on_channel_button)
|
||||
root.addLayout(channel_row)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setHorizontalSpacing(10)
|
||||
form.setVerticalSpacing(6)
|
||||
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self._weight_label = QLabel()
|
||||
self._weight_label.setObjectName("fieldLabel")
|
||||
self.weight_spin = QDoubleSpinBox()
|
||||
self.weight_spin.setRange(0.0, 1e6)
|
||||
self.weight_spin.setDecimals(4)
|
||||
self.weight_spin.setSingleStep(0.1)
|
||||
self.weight_spin.setValue(0.0)
|
||||
self._duration_label = QLabel()
|
||||
self._duration_label.setObjectName("fieldLabel")
|
||||
self.duration_spin = QDoubleSpinBox()
|
||||
self.duration_spin.setRange(5.0, 10.0)
|
||||
self.duration_spin.setDecimals(1)
|
||||
self.duration_spin.setSingleStep(0.5)
|
||||
self.duration_spin.setValue(7.0)
|
||||
self.duration_spin.setSuffix(" s")
|
||||
form.addRow(self._weight_label, self.weight_spin)
|
||||
form.addRow(self._duration_label, self.duration_spin)
|
||||
root.addLayout(form)
|
||||
|
||||
rec_row = QHBoxLayout()
|
||||
self.btn_record = QPushButton()
|
||||
self.btn_record.setMinimumHeight(32)
|
||||
self.btn_record.clicked.connect(self._on_record_clicked)
|
||||
rec_row.addWidget(self.btn_record)
|
||||
rec_row.addStretch(1)
|
||||
root.addLayout(rec_row)
|
||||
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 1000)
|
||||
self.progress.setValue(0)
|
||||
self.progress.setTextVisible(True)
|
||||
root.addWidget(self.progress)
|
||||
|
||||
self.live_label = QLabel()
|
||||
self.live_label.setWordWrap(True)
|
||||
self.live_label.setFont(self._mono_font)
|
||||
root.addWidget(self.live_label)
|
||||
|
||||
self.empty_hint = QLabel()
|
||||
self.empty_hint.setObjectName("hintText")
|
||||
self.empty_hint.setWordWrap(True)
|
||||
root.addWidget(self.empty_hint)
|
||||
|
||||
self.table = QTableWidget(0, 4)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
self.table.setShowGrid(False)
|
||||
header = self.table.horizontalHeader()
|
||||
header.setSectionResizeMode(QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.verticalHeader().setDefaultSectionSize(26)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
sn_row = QHBoxLayout()
|
||||
self._sn_label = QLabel()
|
||||
self._sn_label.setObjectName("fieldLabel")
|
||||
self.sn_edit = QLineEdit()
|
||||
self.sn_edit.setPlaceholderText("")
|
||||
sn_row.addWidget(self._sn_label)
|
||||
sn_row.addWidget(self.sn_edit, 1)
|
||||
root.addLayout(sn_row)
|
||||
|
||||
actions = QHBoxLayout()
|
||||
actions.setSpacing(6)
|
||||
self.btn_delete = QPushButton()
|
||||
self.btn_clear_channel = QPushButton()
|
||||
self.btn_clear_all = QPushButton()
|
||||
self.btn_export = QPushButton()
|
||||
self.btn_export.setObjectName("primaryButton")
|
||||
for button in (self.btn_delete, self.btn_clear_channel, self.btn_clear_all):
|
||||
# The action row must survive a narrow three-panel layout.
|
||||
button.setProperty("compact", "true")
|
||||
button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.btn_delete.clicked.connect(self._on_delete_row)
|
||||
self.btn_clear_channel.clicked.connect(self._on_clear_channel)
|
||||
self.btn_clear_all.clicked.connect(self._on_clear_all)
|
||||
self.btn_export.clicked.connect(self._on_export)
|
||||
actions.addWidget(self.btn_delete)
|
||||
actions.addWidget(self.btn_clear_channel)
|
||||
actions.addWidget(self.btn_clear_all)
|
||||
actions.addStretch(1)
|
||||
actions.addWidget(self.btn_export)
|
||||
root.addLayout(actions)
|
||||
|
||||
self._refresh_enabled()
|
||||
self._refresh_table()
|
||||
self.set_theme(theme)
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------ API
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self._render_live_label()
|
||||
|
||||
def set_running(self, running: bool) -> None:
|
||||
self._running = bool(running)
|
||||
if not self._running and self._recording:
|
||||
self._cancel_recording()
|
||||
self._refresh_enabled()
|
||||
|
||||
def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None:
|
||||
self._sensor_type = str(sensor_type or "box")
|
||||
self._config_ref = config_ref
|
||||
|
||||
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
|
||||
self._live_fn = float(fn)
|
||||
self._live_fx = float(fx)
|
||||
self._live_fy = float(fy)
|
||||
self._render_live_label()
|
||||
if not self._recording:
|
||||
return
|
||||
now = time.perf_counter()
|
||||
value = {"fn": self._live_fn, "fx": self._live_fx, "fy": self._live_fy}[self._channel]
|
||||
self._record_samples.append(float(value))
|
||||
span = max(1e-6, self._record_deadline - self._record_started)
|
||||
frac = min(1.0, max(0.0, (now - self._record_started) / span))
|
||||
self.progress.setValue(int(frac * 1000))
|
||||
self.progress.setFormat(f"{now - self._record_started:.1f} / {span:.1f} s")
|
||||
if now >= self._record_deadline:
|
||||
self._finish_recording()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.cal"))
|
||||
self._weight_label.setText(t("cal.weight"))
|
||||
self._duration_label.setText(t("cal.duration"))
|
||||
self.btn_record.setText(
|
||||
t("cal.recording_stop") if self._recording else t("cal.record")
|
||||
)
|
||||
self.btn_delete.setText(t("cal.delete_row"))
|
||||
self.btn_clear_channel.setText(t("cal.clear_channel"))
|
||||
self.btn_clear_all.setText(t("cal.clear_all"))
|
||||
self.btn_export.setText(t("cal.export"))
|
||||
self._sn_label.setText(t("cal.sn"))
|
||||
self.sn_edit.setPlaceholderText(t("cal.sn_placeholder"))
|
||||
self.empty_hint.setText(t("cal.empty_hint"))
|
||||
headers = (
|
||||
t("cal.col_weight"),
|
||||
t("cal.col_raw"),
|
||||
t("cal.col_n"),
|
||||
t("cal.col_std"),
|
||||
)
|
||||
for i, text in enumerate(headers):
|
||||
item = self.table.horizontalHeaderItem(i)
|
||||
if item is None:
|
||||
item = QTableWidgetItem(text)
|
||||
self.table.setHorizontalHeaderItem(i, item)
|
||||
else:
|
||||
item.setText(text)
|
||||
self._refresh_channel_badge()
|
||||
self._render_live_label()
|
||||
self._refresh_enabled()
|
||||
|
||||
# ------------------------------------------------------------- internals
|
||||
def _refresh_channel_badge(self) -> None:
|
||||
color_hex = color(self._theme, f"plot_{self._channel}")
|
||||
self.channel_badge.setText(self._channel.upper())
|
||||
self.channel_badge.setStyleSheet(
|
||||
f"QLabel#unitBadge {{ color: {color_hex};"
|
||||
f" border: 1px solid {color(self._theme, 'card_border')};"
|
||||
f" background-color: {color(self._theme, 'card_bg')}; }}"
|
||||
)
|
||||
|
||||
def _on_channel_button(self, button, checked: bool) -> None:
|
||||
if not checked:
|
||||
return
|
||||
mapping = {self.radio_fn: "fn", self.radio_fx: "fx", self.radio_fy: "fy"}
|
||||
key = mapping.get(button)
|
||||
if key is None:
|
||||
return
|
||||
if self._recording:
|
||||
self._cancel_recording()
|
||||
self._channel = key # type: ignore[assignment]
|
||||
self._refresh_channel_badge()
|
||||
self._refresh_table()
|
||||
|
||||
def _refresh_enabled(self) -> None:
|
||||
can_record = self._running and not self._recording
|
||||
self.btn_record.setEnabled(can_record)
|
||||
self.btn_record.setObjectName("dangerButton" if self._recording else "primaryButton")
|
||||
self.btn_record.style().unpolish(self.btn_record)
|
||||
self.btn_record.style().polish(self.btn_record)
|
||||
self.weight_spin.setEnabled(not self._recording)
|
||||
self.duration_spin.setEnabled(not self._recording)
|
||||
for radio in (self.radio_fn, self.radio_fx, self.radio_fy):
|
||||
radio.setEnabled(not self._recording)
|
||||
self.btn_export.setEnabled(not self._recording)
|
||||
if self._recording:
|
||||
self.progress.setVisible(True)
|
||||
record_hint = t("cal.record_disabled") if not self._running else ""
|
||||
if not self._recording:
|
||||
self.btn_record.setToolTip(record_hint)
|
||||
|
||||
def _render_live_label(self) -> None:
|
||||
colors = {
|
||||
"fn": color(self._theme, "plot_fn"),
|
||||
"fx": color(self._theme, "plot_fx"),
|
||||
"fy": color(self._theme, "plot_fy"),
|
||||
}
|
||||
parts = []
|
||||
for key, value in (
|
||||
("fn", self._live_fn),
|
||||
("fx", self._live_fx),
|
||||
("fy", self._live_fy),
|
||||
):
|
||||
parts.append(
|
||||
f"<span style='color:{colors[key]}; font-weight:600'>{key.upper()}</span>"
|
||||
f" {value:.3f}"
|
||||
)
|
||||
self.live_label.setText(
|
||||
f"<span style='color:{color(self._theme, 'text_secondary')}'>"
|
||||
f"{t('cal.live_raw_label')}</span> " + " ".join(parts)
|
||||
)
|
||||
|
||||
def _on_record_clicked(self) -> None:
|
||||
if self._recording:
|
||||
self._cancel_recording()
|
||||
self.status_message.emit(t("cal.record_cancelled"))
|
||||
return
|
||||
if not self._running:
|
||||
return
|
||||
self._recording = True
|
||||
self._record_samples = []
|
||||
self._record_duration_s = float(self.duration_spin.value())
|
||||
self._record_started = time.perf_counter()
|
||||
self._record_deadline = self._record_started + self._record_duration_s
|
||||
self.progress.setVisible(True)
|
||||
self.progress.setValue(0)
|
||||
self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s")
|
||||
self._refresh_enabled()
|
||||
self.status_message.emit(t("cal.recording"))
|
||||
|
||||
def _cancel_recording(self) -> None:
|
||||
self._recording = False
|
||||
self._record_samples = []
|
||||
self.progress.setValue(0)
|
||||
self.progress.setFormat("")
|
||||
self._refresh_enabled()
|
||||
|
||||
def _finish_recording(self) -> None:
|
||||
samples = list(self._record_samples)
|
||||
self._recording = False
|
||||
self._record_samples = []
|
||||
self.progress.setValue(1000)
|
||||
self.progress.setFormat("")
|
||||
self._refresh_enabled()
|
||||
if len(samples) < 2:
|
||||
self.status_message.emit(t("cal.record_failed_few"))
|
||||
return
|
||||
mean = sum(samples) / len(samples)
|
||||
var = sum((x - mean) ** 2 for x in samples) / len(samples)
|
||||
std = math.sqrt(var)
|
||||
sample = CalSample(
|
||||
weight_n=float(self.weight_spin.value()),
|
||||
raw_mean=mean,
|
||||
n=len(samples),
|
||||
std=std,
|
||||
)
|
||||
self._store.upsert(self._channel, sample)
|
||||
self._refresh_table()
|
||||
self.status_message.emit(
|
||||
t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}")
|
||||
)
|
||||
|
||||
def _refresh_table(self) -> None:
|
||||
rows = self._store.channel(self._channel)
|
||||
self.table.setRowCount(len(rows))
|
||||
for r, sample in enumerate(rows):
|
||||
values = (
|
||||
f"{sample.weight_n:g}",
|
||||
f"{sample.raw_mean:.4f}",
|
||||
str(sample.n),
|
||||
f"{sample.std:.4f}",
|
||||
)
|
||||
for c, text in enumerate(values):
|
||||
item = QTableWidgetItem(text)
|
||||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
||||
if c != 2:
|
||||
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter))
|
||||
else:
|
||||
item.setTextAlignment(int(Qt.AlignCenter))
|
||||
self.table.setItem(r, c, item)
|
||||
self.empty_hint.setVisible(not rows)
|
||||
|
||||
def _on_delete_row(self) -> None:
|
||||
row = self.table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
self._store.remove_at(self._channel, row)
|
||||
self._refresh_table()
|
||||
|
||||
def _on_clear_channel(self) -> None:
|
||||
self._store.clear(self._channel)
|
||||
self._refresh_table()
|
||||
|
||||
def _on_clear_all(self) -> None:
|
||||
self._store.clear()
|
||||
self._refresh_table()
|
||||
|
||||
def _on_export(self) -> None:
|
||||
meta = meta_from_sensor_json(self._config_ref)
|
||||
meta["sensor_type"] = self._sensor_type
|
||||
meta["sn"] = self.sn_edit.text().strip()
|
||||
meta["record_duration_s"] = float(self.duration_spin.value())
|
||||
local_bundle = sensors_dir() / self._sensor_type
|
||||
if not local_bundle.is_dir():
|
||||
local_bundle = None
|
||||
try:
|
||||
primary, local_path, warnings = export_force_calibration_v2(
|
||||
self._store,
|
||||
export_dir=calibration_export_dir(),
|
||||
sensor_type=self._sensor_type,
|
||||
meta=meta,
|
||||
local_bundle_dir=local_bundle,
|
||||
)
|
||||
except ValueError as exc:
|
||||
QMessageBox.warning(self, t("panels.cal"), str(exc))
|
||||
return
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, t("panels.cal"), t("cal.export_failed", error=str(exc)))
|
||||
return
|
||||
|
||||
msg = t("cal.export_ok", path=str(primary))
|
||||
if local_path is not None:
|
||||
msg += "\n" + t("cal.export_local", path=str(local_path))
|
||||
if warnings:
|
||||
msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings))
|
||||
self.status_message.emit(msg)
|
||||
# Prefer the bundle file for live apply so the next Sensor() also sees it.
|
||||
apply_path = local_path if local_path is not None else primary
|
||||
self.calibration_exported.emit(str(apply_path))
|
||||
QMessageBox.information(self, t("panels.cal"), msg)
|
||||
@@ -0,0 +1,288 @@
|
||||
"""FN / FX / FY force charts with live value labels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
|
||||
from orisys_app_shared.force import MetricHistory
|
||||
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
try:
|
||||
import pyqtgraph as pg
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise ImportError("pyqtgraph is required for force charts") from exc
|
||||
|
||||
|
||||
CHANNEL_KEYS = ("fn", "fx", "fy")
|
||||
CHANNEL_LABELS = ("FN", "FX", "FY")
|
||||
# Theme token that colors each channel curve / label, per index.
|
||||
CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
|
||||
|
||||
|
||||
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
|
||||
if hi <= lo:
|
||||
return [(lo, f"{lo:g}")]
|
||||
step = (hi - lo) / max(1, count - 1)
|
||||
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
|
||||
|
||||
|
||||
def format_force(value: float, unit: str) -> str:
|
||||
"""Render one force reading for the value cards."""
|
||||
if unit == "N":
|
||||
return f"{value:.3f}"
|
||||
return f"{value:.1f}"
|
||||
|
||||
|
||||
def unit_display(unit: str) -> str:
|
||||
return t("units.newton_short") if unit == "N" else t("units.raw_short")
|
||||
|
||||
|
||||
def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont:
|
||||
"""Pick the first available monospace face for stable digit alignment."""
|
||||
families = set(QFontDatabase().families())
|
||||
chosen = "monospace"
|
||||
for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"):
|
||||
if name in families:
|
||||
chosen = name
|
||||
break
|
||||
font = QFont(chosen, point_size)
|
||||
font.setBold(bold)
|
||||
return font
|
||||
|
||||
|
||||
class ForcePlotsPanel(QFrame):
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._theme = theme
|
||||
self._fn_max = 20.0
|
||||
self._shear_max = 20.0
|
||||
self._fixed_newton = True
|
||||
self._unit = "raw"
|
||||
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
self.history = MetricHistory(
|
||||
channels=3,
|
||||
window_sec=4.0,
|
||||
sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
|
||||
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
|
||||
y_axis_min_span=0.5,
|
||||
max_fps=DEFAULT_MAX_FPS,
|
||||
)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(10, 8, 10, 10)
|
||||
root.setSpacing(6)
|
||||
|
||||
header = QHBoxLayout()
|
||||
header.setSpacing(6)
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("panelTitle")
|
||||
header.addWidget(self.title)
|
||||
header.addStretch(1)
|
||||
self.unit_badge = QLabel()
|
||||
self.unit_badge.setObjectName("unitBadge")
|
||||
header.addWidget(self.unit_badge)
|
||||
root.addLayout(header)
|
||||
|
||||
self.plot_frame = QFrame()
|
||||
self.plot_frame.setObjectName("cardPanel")
|
||||
plot_layout = QVBoxLayout(self.plot_frame)
|
||||
plot_layout.setContentsMargins(4, 4, 4, 4)
|
||||
self.plot_widget = pg.GraphicsLayoutWidget()
|
||||
self.plot_widget.ci.setSpacing(2)
|
||||
plot_layout.addWidget(self.plot_widget)
|
||||
root.addWidget(self.plot_frame, 1)
|
||||
|
||||
self.plots = []
|
||||
self.curves = []
|
||||
pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2)
|
||||
for i, _name in enumerate(CHANNEL_LABELS):
|
||||
plot = self.plot_widget.addPlot(row=i, col=0)
|
||||
plot.showGrid(x=True, y=True, alpha=0.25)
|
||||
plot.setMouseEnabled(x=False, y=False)
|
||||
if i > 0:
|
||||
plot.setXLink(self.plots[0])
|
||||
curve = plot.plot(pen=pen)
|
||||
self.plots.append(plot)
|
||||
self.curves.append(curve)
|
||||
|
||||
values_row = QHBoxLayout()
|
||||
values_row.setSpacing(6)
|
||||
self.value_cards: list[QFrame] = []
|
||||
self.value_labels: list[QLabel] = []
|
||||
self.channel_badges: list[QLabel] = []
|
||||
self.value_units: list[QLabel] = []
|
||||
for label in CHANNEL_LABELS:
|
||||
card, badge, value_label, unit_label = self._build_value_card(label)
|
||||
values_row.addWidget(card, 1)
|
||||
self.value_cards.append(card)
|
||||
self.channel_badges.append(badge)
|
||||
self.value_labels.append(value_label)
|
||||
self.value_units.append(unit_label)
|
||||
root.addLayout(values_row)
|
||||
|
||||
self.set_theme(theme)
|
||||
self._apply_axis_mode()
|
||||
self.retranslate()
|
||||
|
||||
def _build_value_card(self, channel: str) -> tuple[QFrame, QLabel, QLabel, QLabel]:
|
||||
card = QFrame()
|
||||
card.setObjectName("cardPanel")
|
||||
layout = QVBoxLayout(card)
|
||||
layout.setContentsMargins(7, 4, 7, 4)
|
||||
layout.setSpacing(1)
|
||||
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(6)
|
||||
badge = QLabel(channel)
|
||||
badge.setObjectName("unitBadge")
|
||||
top.addWidget(badge)
|
||||
top.addStretch(1)
|
||||
unit_label = QLabel()
|
||||
unit_label.setObjectName("hintText")
|
||||
top.addWidget(unit_label)
|
||||
|
||||
value_label = QLabel()
|
||||
value_label.setObjectName("valueCard")
|
||||
value_label.setFixedHeight(20)
|
||||
value_label.setContentsMargins(6, 0, 6, 0)
|
||||
value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
value_label.setFont(_mono_font(9, bold=True))
|
||||
|
||||
layout.addLayout(top)
|
||||
layout.addWidget(value_label)
|
||||
return card, badge, value_label, unit_label
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
bg = color(theme, "plot_bg")
|
||||
text = color(theme, "text_primary")
|
||||
self.plot_widget.setBackground(bg)
|
||||
axis_pen = pg.mkPen(text)
|
||||
for i, plot in enumerate(self.plots):
|
||||
try:
|
||||
plot.getViewBox().setBackgroundColor(QColor(bg))
|
||||
except Exception:
|
||||
pass
|
||||
for axis_name in ("left", "bottom"):
|
||||
axis = plot.getAxis(axis_name)
|
||||
axis.setPen(axis_pen)
|
||||
axis.setTextPen(axis_pen)
|
||||
plot.showGrid(x=True, y=True, alpha=0.25)
|
||||
# Color each channel axis label to match its curve pen.
|
||||
plot.setLabel("left", CHANNEL_LABELS[i], color=color(theme, CHANNEL_TOKENS[i]))
|
||||
pens = tuple(color(theme, token) for token in CHANNEL_TOKENS)
|
||||
for curve, pen in zip(self.curves, pens):
|
||||
curve.setPen(pg.mkPen(pen, width=2))
|
||||
for badge, token in zip(self.channel_badges, CHANNEL_TOKENS):
|
||||
badge.setStyleSheet(
|
||||
f"QLabel#unitBadge {{ color: {color(theme, token)};"
|
||||
f" background-color: {color(theme, 'card_bg')};"
|
||||
f" border: 1px solid {color(theme, 'card_border')}; }}"
|
||||
)
|
||||
for value_label, token in zip(self.value_labels, CHANNEL_TOKENS):
|
||||
value_label.setStyleSheet(
|
||||
f"QLabel#valueCard {{ color: {color(theme, token)}; }}"
|
||||
)
|
||||
self._render_value_cards()
|
||||
|
||||
def set_window_sec(self, sec: float) -> None:
|
||||
self.history.set_window_sec(sec)
|
||||
self._clear_curves()
|
||||
self._apply_axis_mode()
|
||||
|
||||
def set_axis_limits(self, fn_max: float, shear_max: float) -> None:
|
||||
self._fn_max = max(0.1, float(fn_max))
|
||||
self._shear_max = max(0.1, float(shear_max))
|
||||
self._apply_axis_mode()
|
||||
|
||||
def set_fixed_newton(self, enabled: bool) -> None:
|
||||
self._fixed_newton = bool(enabled)
|
||||
self._apply_axis_mode()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.history.reset()
|
||||
self._clear_curves()
|
||||
self._last_values = (0.0, 0.0, 0.0)
|
||||
self._unit = "raw"
|
||||
self._render_value_cards()
|
||||
self._apply_axis_mode()
|
||||
|
||||
def push(
|
||||
self,
|
||||
timestamp: float,
|
||||
fn: float,
|
||||
fx: float,
|
||||
fy: float,
|
||||
*,
|
||||
unit: str = "raw",
|
||||
) -> None:
|
||||
self._unit = unit
|
||||
self._last_values = (float(fn), float(fx), float(fy))
|
||||
use_fixed = self._uses_fixed_y()
|
||||
plot_fn = max(0.0, float(fn)) if use_fixed else float(fn)
|
||||
self.history.push(timestamp, plot_fn, float(fx), float(fy))
|
||||
self._render_value_cards()
|
||||
state = self.history.state()
|
||||
if state is None:
|
||||
return
|
||||
values = state.values.copy()
|
||||
if use_fixed:
|
||||
values[:, 0] = np.maximum(values[:, 0], 0.0)
|
||||
for i, curve in enumerate(self.curves):
|
||||
curve.setData(state.times, values[:, i])
|
||||
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||
if use_fixed:
|
||||
ymin, ymax = self._fixed_y_range(i)
|
||||
self.plots[i].setYRange(ymin, ymax, padding=0.0)
|
||||
self.plots[i].getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
|
||||
else:
|
||||
self.plots[i].getAxis("left").setTicks(None)
|
||||
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
|
||||
|
||||
def _uses_fixed_y(self) -> bool:
|
||||
return bool(self._fixed_newton) and self._unit == "N"
|
||||
|
||||
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
|
||||
if channel_index == 0:
|
||||
return 0.0, self._fn_max
|
||||
return -self._shear_max, self._shear_max
|
||||
|
||||
def _apply_axis_mode(self) -> None:
|
||||
# Preview fixed newton axes when newton mode is selected, even before samples.
|
||||
preview_fixed = bool(self._fixed_newton)
|
||||
use_fixed = self._uses_fixed_y() or (preview_fixed and self.history.sample_count == 0)
|
||||
for i, plot in enumerate(self.plots):
|
||||
if use_fixed:
|
||||
ymin, ymax = self._fixed_y_range(i)
|
||||
plot.setYRange(ymin, ymax, padding=0.0)
|
||||
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
|
||||
else:
|
||||
plot.getAxis("left").setTicks(None)
|
||||
for plot in self.plots:
|
||||
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
|
||||
|
||||
def _clear_curves(self) -> None:
|
||||
for curve in self.curves:
|
||||
curve.setData([], [])
|
||||
|
||||
def _render_value_cards(self) -> None:
|
||||
fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}"
|
||||
for value_label, unit_label, value in zip(
|
||||
self.value_labels, self.value_units, self._last_values
|
||||
):
|
||||
value_label.setText(fmt.format(value=value))
|
||||
unit_label.setText(unit_display(self._unit))
|
||||
self.unit_badge.setText(unit_display(self._unit))
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.plots"))
|
||||
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
|
||||
badge.setText(label)
|
||||
self._render_value_cards()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Placeholder panel for finger 3D mesh view."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import QFrame, QLabel, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
|
||||
class Mesh3dPanel(QFrame):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
header = QVBoxLayout()
|
||||
header.setSpacing(2)
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("panelTitle")
|
||||
header.addWidget(self.title)
|
||||
layout.addLayout(header)
|
||||
|
||||
self.body = QLabel()
|
||||
self.body.setObjectName("hintText")
|
||||
self.body.setAlignment(Qt.AlignCenter)
|
||||
self.body.setWordWrap(True)
|
||||
layout.addWidget(self.body, 1)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.mesh3d"))
|
||||
self.body.setText(t("panels.mesh3d_empty"))
|
||||
self.body.setToolTip(t("panels.mesh3d_empty_tip"))
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Modal dialog for force-plot window and Y-axis limits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
DEFAULT_PLOT_WINDOW_SEC = 4.0
|
||||
DEFAULT_PLOT_FN_MAX = 20.0
|
||||
DEFAULT_PLOT_SHEAR_MAX = 20.0
|
||||
|
||||
|
||||
class PlotSettingsDialog(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*,
|
||||
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
|
||||
fn_max: float = DEFAULT_PLOT_FN_MAX,
|
||||
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
|
||||
units: str = "newton",
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setModal(True)
|
||||
self.resize(360, 200)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
form = QFormLayout()
|
||||
|
||||
self.window_label = QLabel()
|
||||
self.window_spin = QDoubleSpinBox()
|
||||
self.window_spin.setRange(1.0, 30.0)
|
||||
self.window_spin.setSingleStep(1.0)
|
||||
self.window_spin.setDecimals(0)
|
||||
self.window_spin.setSuffix(" s")
|
||||
self.window_spin.setValue(float(window_sec))
|
||||
|
||||
self.fn_max_label = QLabel()
|
||||
self.fn_max_spin = QDoubleSpinBox()
|
||||
self.fn_max_spin.setRange(1.0, 100.0)
|
||||
self.fn_max_spin.setSingleStep(1.0)
|
||||
self.fn_max_spin.setDecimals(0)
|
||||
self.fn_max_spin.setSuffix(" N")
|
||||
self.fn_max_spin.setValue(float(fn_max))
|
||||
|
||||
self.shear_max_label = QLabel()
|
||||
self.shear_max_spin = QDoubleSpinBox()
|
||||
self.shear_max_spin.setRange(1.0, 100.0)
|
||||
self.shear_max_spin.setSingleStep(1.0)
|
||||
self.shear_max_spin.setDecimals(0)
|
||||
self.shear_max_spin.setSuffix(" N")
|
||||
self.shear_max_spin.setValue(float(shear_max))
|
||||
|
||||
form.addRow(self.window_label, self.window_spin)
|
||||
form.addRow(self.fn_max_label, self.fn_max_spin)
|
||||
form.addRow(self.shear_max_label, self.shear_max_spin)
|
||||
root.addLayout(form)
|
||||
|
||||
newton = str(units or "newton").lower().startswith("n")
|
||||
self.fn_max_spin.setEnabled(newton)
|
||||
self.shear_max_spin.setEnabled(newton)
|
||||
self.fn_max_label.setEnabled(newton)
|
||||
self.shear_max_label.setEnabled(newton)
|
||||
|
||||
buttons_row = QHBoxLayout()
|
||||
self.btn_reset = QPushButton()
|
||||
self.btn_reset.clicked.connect(self._reset_defaults)
|
||||
buttons_row.addWidget(self.btn_reset)
|
||||
buttons_row.addStretch(1)
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
buttons_row.addWidget(self.button_box)
|
||||
root.addLayout(buttons_row)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def values(self) -> tuple[float, float, float]:
|
||||
return (
|
||||
float(self.window_spin.value()),
|
||||
float(self.fn_max_spin.value()),
|
||||
float(self.shear_max_spin.value()),
|
||||
)
|
||||
|
||||
def _reset_defaults(self) -> None:
|
||||
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
|
||||
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
|
||||
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(t("dialog.plot_settings_title"))
|
||||
self.window_label.setText(t("dialog.plot_window"))
|
||||
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
|
||||
self.fn_max_label.setText(t("dialog.plot_fn_max"))
|
||||
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
|
||||
self.shear_max_label.setText(t("dialog.plot_shear_max"))
|
||||
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
|
||||
self.btn_reset.setText(t("dialog.reset_defaults"))
|
||||
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
|
||||
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
|
||||
if ok_btn is not None:
|
||||
ok_btn.setText(t("dialog.ok"))
|
||||
if cancel_btn is not None:
|
||||
cancel_btn.setText(t("dialog.cancel"))
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Sensor type + config picker with product badge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
||||
|
||||
THUMB_SIZE = 56
|
||||
CONTROL_HEIGHT = 30
|
||||
MAX_CONTROL_WIDTH = 220
|
||||
|
||||
|
||||
class SensorPickerWidget(QWidget):
|
||||
sensor_type_changed = pyqtSignal(str)
|
||||
config_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(10)
|
||||
|
||||
self.thumb = QFrame()
|
||||
self.thumb.setObjectName("cardPanel")
|
||||
self.thumb.setFixedSize(THUMB_SIZE, THUMB_SIZE)
|
||||
thumb_layout = QVBoxLayout(self.thumb)
|
||||
thumb_layout.setContentsMargins(2, 2, 2, 2)
|
||||
thumb_layout.setSpacing(0)
|
||||
self.thumb_label = QLabel()
|
||||
self.thumb_label.setObjectName("unitBadge")
|
||||
self.thumb_label.setAlignment(Qt.AlignCenter)
|
||||
self.thumb_label.setWordWrap(False)
|
||||
thumb_layout.addWidget(self.thumb_label)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setContentsMargins(0, 0, 0, 0)
|
||||
form.setHorizontalSpacing(8)
|
||||
form.setVerticalSpacing(6)
|
||||
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
||||
|
||||
self.type_label = QLabel()
|
||||
self.type_label.setObjectName("fieldLabel")
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.setMinimumHeight(CONTROL_HEIGHT)
|
||||
self.type_combo.setMaximumWidth(MAX_CONTROL_WIDTH)
|
||||
self.type_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
for name in list_sensor_types():
|
||||
self.type_combo.addItem(name, name)
|
||||
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
|
||||
|
||||
self.config_label = QLabel()
|
||||
self.config_label.setObjectName("fieldLabel")
|
||||
self.config_combo = QComboBox()
|
||||
self.config_combo.setMinimumHeight(CONTROL_HEIGHT)
|
||||
self.config_combo.setMaximumWidth(MAX_CONTROL_WIDTH)
|
||||
self.config_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
|
||||
|
||||
form.addRow(self.type_label, self.type_combo)
|
||||
form.addRow(self.config_label, self.config_combo)
|
||||
|
||||
layout.addWidget(self.thumb)
|
||||
layout.addLayout(form, 1)
|
||||
self._reload_configs()
|
||||
self._refresh_thumb()
|
||||
self.retranslate()
|
||||
|
||||
def sensor_type(self) -> str:
|
||||
data = self.type_combo.currentData()
|
||||
return str(data or self.type_combo.currentText() or "box")
|
||||
|
||||
def config_name(self) -> str:
|
||||
data = self.config_combo.currentData()
|
||||
if data is None:
|
||||
return str(self.config_combo.currentText() or "")
|
||||
return str(data)
|
||||
|
||||
def set_sensor_type(self, sensor_type: str) -> None:
|
||||
wanted = str(sensor_type or "box")
|
||||
idx = self.type_combo.findData(wanted)
|
||||
if idx < 0:
|
||||
idx = self.type_combo.findText(wanted)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.type_combo.blockSignals(True)
|
||||
self.type_combo.setCurrentIndex(idx)
|
||||
self.type_combo.blockSignals(False)
|
||||
self._reload_configs()
|
||||
self._refresh_thumb()
|
||||
|
||||
def set_config_name(self, config_name: str) -> None:
|
||||
wanted = str(config_name or "")
|
||||
idx = self.config_combo.findData(wanted)
|
||||
if idx < 0 and wanted:
|
||||
idx = self.config_combo.findText(wanted)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.config_combo.blockSignals(True)
|
||||
self.config_combo.setCurrentIndex(max(0, idx))
|
||||
self.config_combo.blockSignals(False)
|
||||
|
||||
def set_picker_enabled(self, enabled: bool) -> None:
|
||||
"""Enable/disable both pickers (used while a session is running)."""
|
||||
self.type_combo.setEnabled(bool(enabled))
|
||||
self.config_combo.setEnabled(bool(enabled))
|
||||
|
||||
def _reload_configs(self) -> None:
|
||||
self.config_combo.blockSignals(True)
|
||||
self.config_combo.clear()
|
||||
for display, key in list_configs_for_type(self.sensor_type()):
|
||||
self.config_combo.addItem(display, key)
|
||||
if self.config_combo.count() == 0:
|
||||
self.config_combo.addItem(self.sensor_type(), self.sensor_type())
|
||||
self.config_combo.blockSignals(False)
|
||||
|
||||
def _refresh_thumb(self) -> None:
|
||||
self.thumb_label.setText(self.sensor_type().upper())
|
||||
|
||||
def _on_type_index_changed(self, _index: int) -> None:
|
||||
self._reload_configs()
|
||||
self._refresh_thumb()
|
||||
self.sensor_type_changed.emit(self.sensor_type())
|
||||
self.config_changed.emit(self.config_name())
|
||||
|
||||
def _on_config_index_changed(self, _index: int) -> None:
|
||||
self.config_changed.emit(self.config_name())
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.type_label.setText(t("sensor.type"))
|
||||
self.config_label.setText(t("sensor.config"))
|
||||
self.thumb.setToolTip(t("sensor.thumb_placeholder"))
|
||||
self._refresh_thumb()
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Top toolbar: source, camera/video, run/reset, units."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
CONTROL_HEIGHT = 30
|
||||
BUTTON_HEIGHT = 32
|
||||
|
||||
|
||||
def _separator() -> QFrame:
|
||||
line = QFrame()
|
||||
line.setObjectName("sectionSeparator")
|
||||
line.setFrameShape(QFrame.VLine)
|
||||
line.setFixedWidth(1)
|
||||
line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
return line
|
||||
|
||||
|
||||
def _group(*widgets: QWidget) -> QWidget:
|
||||
box = QWidget()
|
||||
layout = QHBoxLayout(box)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
for widget in widgets:
|
||||
layout.addWidget(widget)
|
||||
return box
|
||||
|
||||
|
||||
class ToolbarWidget(QWidget):
|
||||
search_clicked = pyqtSignal()
|
||||
preview_toggled = pyqtSignal(bool)
|
||||
camera_id_changed = pyqtSignal(int)
|
||||
source_mode_changed = pyqtSignal(str)
|
||||
browse_video_clicked = pyqtSignal()
|
||||
run_toggled = pyqtSignal(bool)
|
||||
reset_clicked = pyqtSignal()
|
||||
units_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._run_tooltip_reason = ""
|
||||
self._run_available = True
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
|
||||
self._scroll = QScrollArea()
|
||||
self._scroll.setWidgetResizable(True)
|
||||
self._scroll.setFrameShape(QFrame.NoFrame)
|
||||
self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
outer.addWidget(self._scroll)
|
||||
|
||||
content = QWidget()
|
||||
self._scroll.setWidget(content)
|
||||
layout = QHBoxLayout(content)
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(10)
|
||||
|
||||
# --- source group -------------------------------------------------
|
||||
self.source_label = QLabel()
|
||||
self.source_label.setObjectName("fieldLabel")
|
||||
self.source_combo = QComboBox()
|
||||
self.source_combo.setMinimumWidth(96)
|
||||
self.source_combo.setMinimumHeight(CONTROL_HEIGHT)
|
||||
self.source_combo.currentIndexChanged.connect(self._on_source_index_changed)
|
||||
|
||||
self.source_stack = QStackedWidget()
|
||||
cam_page = QWidget()
|
||||
cam_layout = QHBoxLayout(cam_page)
|
||||
cam_layout.setContentsMargins(0, 0, 0, 0)
|
||||
cam_layout.setSpacing(8)
|
||||
self.cam_id_label = QLabel()
|
||||
self.cam_id_label.setObjectName("fieldLabel")
|
||||
self.cam_id_spin = QSpinBox()
|
||||
self.cam_id_spin.setRange(0, 20)
|
||||
self.cam_id_spin.setValue(0)
|
||||
self.cam_id_spin.setFixedWidth(72)
|
||||
self.cam_id_spin.setMinimumHeight(CONTROL_HEIGHT)
|
||||
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
|
||||
cam_layout.addWidget(self.cam_id_label)
|
||||
cam_layout.addWidget(self.cam_id_spin)
|
||||
|
||||
video_page = QWidget()
|
||||
video_layout = QHBoxLayout(video_page)
|
||||
video_layout.setContentsMargins(0, 0, 0, 0)
|
||||
video_layout.setSpacing(8)
|
||||
self.video_edit = QLineEdit()
|
||||
self.video_edit.setMinimumWidth(180)
|
||||
self.video_edit.setMinimumHeight(CONTROL_HEIGHT)
|
||||
self.video_edit.setReadOnly(True)
|
||||
self.btn_browse_video = QPushButton()
|
||||
self.btn_browse_video.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit)
|
||||
video_layout.addWidget(self.video_edit, 1)
|
||||
video_layout.addWidget(self.btn_browse_video)
|
||||
|
||||
self.source_stack.addWidget(cam_page)
|
||||
self.source_stack.addWidget(video_page)
|
||||
|
||||
# --- action group -------------------------------------------------
|
||||
self.btn_search = QPushButton()
|
||||
self.btn_search.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_search.clicked.connect(self.search_clicked.emit)
|
||||
|
||||
self.btn_preview = QPushButton()
|
||||
self.btn_preview.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_preview.setCheckable(True)
|
||||
self.btn_preview.toggled.connect(self._on_preview_toggled)
|
||||
|
||||
self.btn_run = QPushButton()
|
||||
self.btn_run.setObjectName("primaryButton")
|
||||
self.btn_run.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_run.setMinimumWidth(96)
|
||||
self.btn_run.setCheckable(True)
|
||||
self.btn_run.toggled.connect(self._on_run_toggled)
|
||||
|
||||
self.btn_reset = QPushButton()
|
||||
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.btn_reset.clicked.connect(self.reset_clicked.emit)
|
||||
|
||||
# --- units group --------------------------------------------------
|
||||
self.units_label = QLabel()
|
||||
self.units_label.setObjectName("fieldLabel")
|
||||
self.units_combo = QComboBox()
|
||||
self.units_combo.setFixedWidth(96)
|
||||
self.units_combo.setMinimumHeight(CONTROL_HEIGHT)
|
||||
self.units_combo.currentIndexChanged.connect(self._on_units_index_changed)
|
||||
|
||||
layout.addWidget(
|
||||
_group(self.source_label, self.source_combo, self.source_stack)
|
||||
)
|
||||
layout.addWidget(_separator())
|
||||
layout.addWidget(
|
||||
_group(self.btn_search, self.btn_preview, self.btn_run, self.btn_reset)
|
||||
)
|
||||
layout.addWidget(_separator())
|
||||
layout.addWidget(_group(self.units_label, self.units_combo))
|
||||
layout.addStretch(1)
|
||||
|
||||
self._populate_source_combo()
|
||||
self._populate_units_combo()
|
||||
self.retranslate()
|
||||
self._apply_source_mode_ui()
|
||||
|
||||
# ------------------------------------------------------------------ API
|
||||
def source_mode(self) -> str:
|
||||
data = self.source_combo.currentData()
|
||||
return str(data or "camera")
|
||||
|
||||
def set_source_mode(self, mode: str) -> None:
|
||||
wanted = "video" if str(mode).lower().startswith("v") else "camera"
|
||||
idx = self.source_combo.findData(wanted)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.source_combo.blockSignals(True)
|
||||
self.source_combo.setCurrentIndex(idx)
|
||||
self.source_combo.blockSignals(False)
|
||||
self._apply_source_mode_ui()
|
||||
|
||||
def camera_id(self) -> int:
|
||||
return int(self.cam_id_spin.value())
|
||||
|
||||
def set_camera_id(self, value: int) -> None:
|
||||
self.cam_id_spin.blockSignals(True)
|
||||
self.cam_id_spin.setValue(int(value))
|
||||
self.cam_id_spin.blockSignals(False)
|
||||
|
||||
def video_path(self) -> str:
|
||||
return self.video_edit.text().strip()
|
||||
|
||||
def set_video_path(self, path: str) -> None:
|
||||
text = str(path or "").strip()
|
||||
self.video_edit.setText(text)
|
||||
self.video_edit.setToolTip(text)
|
||||
|
||||
def units(self) -> str:
|
||||
data = self.units_combo.currentData()
|
||||
return str(data or "newton")
|
||||
|
||||
def set_units(self, units: str) -> None:
|
||||
wanted = "raw" if str(units).lower().startswith("r") else "newton"
|
||||
idx = self.units_combo.findData(wanted)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.units_combo.blockSignals(True)
|
||||
self.units_combo.setCurrentIndex(idx)
|
||||
self.units_combo.blockSignals(False)
|
||||
|
||||
def set_preview_checked(self, checked: bool) -> None:
|
||||
self.btn_preview.blockSignals(True)
|
||||
self.btn_preview.setChecked(bool(checked))
|
||||
self.btn_preview.blockSignals(False)
|
||||
self.refresh_labels()
|
||||
|
||||
def set_run_checked(self, checked: bool) -> None:
|
||||
self.btn_run.blockSignals(True)
|
||||
self.btn_run.setChecked(bool(checked))
|
||||
self.btn_run.blockSignals(False)
|
||||
self.refresh_labels()
|
||||
if not checked:
|
||||
self.btn_reset.setEnabled(False)
|
||||
|
||||
def set_reset_enabled(self, enabled: bool) -> None:
|
||||
self.btn_reset.setEnabled(bool(enabled))
|
||||
|
||||
def set_camera_controls_enabled(self, enabled: bool) -> None:
|
||||
self.btn_search.setEnabled(enabled and self.source_mode() == "camera")
|
||||
self.cam_id_spin.setEnabled(enabled and self.source_mode() == "camera")
|
||||
|
||||
def set_source_controls_enabled(self, enabled: bool) -> None:
|
||||
self.source_combo.setEnabled(enabled)
|
||||
self.btn_browse_video.setEnabled(enabled and self.source_mode() == "video")
|
||||
self.set_camera_controls_enabled(enabled)
|
||||
self.btn_preview.setEnabled(enabled and self.source_mode() == "camera")
|
||||
|
||||
def set_run_enabled(self, enabled: bool) -> None:
|
||||
self._run_available = bool(enabled)
|
||||
self.btn_run.setEnabled(bool(enabled))
|
||||
self._apply_run_tooltip()
|
||||
|
||||
def set_run_tooltip_reason(self, reason: str) -> None:
|
||||
"""Explain *why* Run is unavailable (shown in the button tooltip)."""
|
||||
self._run_tooltip_reason = str(reason or "")
|
||||
self._apply_run_tooltip()
|
||||
|
||||
def active_source(self) -> str | int | None:
|
||||
if self.source_mode() == "video":
|
||||
path = self.video_path()
|
||||
return path if path else None
|
||||
return self.camera_id()
|
||||
|
||||
def refresh_labels(self) -> None:
|
||||
"""Public refresh hook (kept separate from ``retranslate`` for reuse)."""
|
||||
self._refresh_preview_label()
|
||||
self._refresh_run_label()
|
||||
|
||||
# ------------------------------------------------------------- internals
|
||||
def _populate_source_combo(self) -> None:
|
||||
self.source_combo.blockSignals(True)
|
||||
self.source_combo.clear()
|
||||
self.source_combo.addItem("", "camera")
|
||||
self.source_combo.addItem("", "video")
|
||||
self.source_combo.blockSignals(False)
|
||||
|
||||
def _populate_units_combo(self) -> None:
|
||||
current = self.units_combo.currentData()
|
||||
self.units_combo.blockSignals(True)
|
||||
self.units_combo.clear()
|
||||
self.units_combo.addItem("", "newton")
|
||||
self.units_combo.addItem("", "raw")
|
||||
if current:
|
||||
idx = self.units_combo.findData(current)
|
||||
self.units_combo.setCurrentIndex(max(0, idx))
|
||||
self.units_combo.blockSignals(False)
|
||||
|
||||
def _apply_source_mode_ui(self) -> None:
|
||||
video = self.source_mode() == "video"
|
||||
self.source_stack.setCurrentIndex(1 if video else 0)
|
||||
self.btn_search.setEnabled(not video)
|
||||
self.btn_preview.setEnabled(not video)
|
||||
if video and self.btn_preview.isChecked():
|
||||
self.set_preview_checked(False)
|
||||
|
||||
def _on_source_index_changed(self, _index: int) -> None:
|
||||
self._apply_source_mode_ui()
|
||||
self.source_mode_changed.emit(self.source_mode())
|
||||
|
||||
def _on_preview_toggled(self, checked: bool) -> None:
|
||||
self._refresh_preview_label()
|
||||
self.preview_toggled.emit(bool(checked))
|
||||
|
||||
def _on_run_toggled(self, checked: bool) -> None:
|
||||
self._refresh_run_label()
|
||||
if not checked:
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.run_toggled.emit(bool(checked))
|
||||
|
||||
def _on_units_index_changed(self, _index: int) -> None:
|
||||
self.units_changed.emit(self.units())
|
||||
|
||||
def _refresh_preview_label(self) -> None:
|
||||
if self.btn_preview.isChecked():
|
||||
self.btn_preview.setText(t("toolbar.preview_stop"))
|
||||
else:
|
||||
self.btn_preview.setText(t("toolbar.preview"))
|
||||
|
||||
def _refresh_run_label(self) -> None:
|
||||
if self.btn_run.isChecked():
|
||||
self.btn_run.setText(t("toolbar.stop_label"))
|
||||
else:
|
||||
self.btn_run.setText(t("toolbar.run_label"))
|
||||
self.btn_run.setObjectName(
|
||||
"dangerButton" if self.btn_run.isChecked() else "primaryButton"
|
||||
)
|
||||
self.btn_run.style().unpolish(self.btn_run)
|
||||
self.btn_run.style().polish(self.btn_run)
|
||||
self._apply_run_tooltip()
|
||||
|
||||
def _apply_run_tooltip(self) -> None:
|
||||
if self.btn_run.isChecked():
|
||||
self.btn_run.setToolTip(t("toolbar.stop"))
|
||||
elif not self._run_available and self._run_tooltip_reason:
|
||||
self.btn_run.setToolTip(self._run_tooltip_reason)
|
||||
elif not self._run_available:
|
||||
self.btn_run.setToolTip(t("toolbar.run_unavailable"))
|
||||
else:
|
||||
tip = t("toolbar.run")
|
||||
if self._run_tooltip_reason:
|
||||
tip = f"{tip} — {self._run_tooltip_reason}"
|
||||
self.btn_run.setToolTip(tip)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.source_label.setText(t("toolbar.source"))
|
||||
self.source_combo.setItemText(0, t("toolbar.source_camera"))
|
||||
self.source_combo.setItemText(1, t("toolbar.source_video"))
|
||||
self.cam_id_label.setText(t("toolbar.cam_id"))
|
||||
self.btn_browse_video.setText(t("toolbar.browse_video"))
|
||||
path = self.video_path()
|
||||
self.video_edit.setPlaceholderText(
|
||||
Path(path).name if path else t("toolbar.video_placeholder")
|
||||
)
|
||||
self.btn_search.setText(t("toolbar.search_cameras"))
|
||||
self.refresh_labels()
|
||||
self.btn_reset.setText(t("toolbar.reset"))
|
||||
self.units_label.setText(t("toolbar.units"))
|
||||
self.units_combo.setItemText(0, t("toolbar.units_newton"))
|
||||
self.units_combo.setItemText(1, t("toolbar.units_raw"))
|
||||
Reference in New Issue
Block a user