让直播标定文件从传感器对话框进入打开链路。
对话框可选配置默认、原始力或浏览文件,并按配置条目写入 prefs;点运行时把 cal_path(Finger 含 device_id)交给 worker,采集中则热更新。对话框与打开链路单测通过。
This commit is contained in:
@@ -5,11 +5,12 @@ product and config the window is configured for is worth reading at a glance,
|
||||
but the pickers themselves — and the calibration that belongs to them — need
|
||||
more room than a 26 px rail can give.
|
||||
|
||||
The calibration half is read-only on purpose: it states which calibration the
|
||||
*device* confirmed, which axes it covers, and what the last exported
|
||||
coefficients file says it belongs to. Exporting and applying stay single-entry
|
||||
actions (they are the calibration panel's own methods), so there is exactly one
|
||||
implementation of each.
|
||||
The calibration half lets the operator choose the force curve for the **next**
|
||||
live run (and hot-apply it while a session is already open): the profile's
|
||||
default file when the bundle declares one, raw force, or a browsed file — the
|
||||
same three answers the bare-video analysis prompt asks for. Exporting still
|
||||
writes a coefficients file without changing a running device until Apply is
|
||||
used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,10 +27,14 @@ from orisys_app_shared.sensors import (
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
@@ -38,6 +43,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
from orisys_studio.ui.video_analysis_dialog import calibration_identity
|
||||
|
||||
#: How the catalog's shape identifiers read here. One literal key per kind, so the locale scan
|
||||
#: sees them and a new kind is a visible fallback rather than a printed identifier.
|
||||
@@ -57,15 +63,22 @@ _STATE_KEYS: dict[str, str] = {
|
||||
STATE_UNREADABLE: "sensor.catalog_state_invalid",
|
||||
}
|
||||
|
||||
ROLE_DEFAULT = "default"
|
||||
ROLE_NONE = "none"
|
||||
ROLE_FILE = "file"
|
||||
|
||||
|
||||
class SensorDialog(QDialog):
|
||||
"""Sensor type / config picker plus the calibration facts that follow it."""
|
||||
"""Sensor type / config picker plus the live calibration choice that follows it."""
|
||||
|
||||
def __init__(self, *, window, picker, theme: str = "dark") -> None:
|
||||
super().__init__(window)
|
||||
self._window = window
|
||||
self._theme = str(theme)
|
||||
self.picker = picker
|
||||
self._custom_calibration: Path | None = None
|
||||
self._device_source: Path | None = None
|
||||
self._syncing = False
|
||||
self.setObjectName("sensorDialog")
|
||||
self.setModal(False)
|
||||
|
||||
@@ -116,6 +129,37 @@ class SensorDialog(QDialog):
|
||||
)
|
||||
root.addWidget(self.profile_scroll)
|
||||
|
||||
root.addWidget(self._section_rule())
|
||||
self.choice_title = QLabel()
|
||||
self.choice_title.setObjectName("panelTitle")
|
||||
root.addWidget(self.choice_title)
|
||||
|
||||
choice_form = QFormLayout()
|
||||
choice_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.calibration_label = QLabel()
|
||||
self.calibration_label.setObjectName("fieldLabel")
|
||||
self.calibration_combo = QComboBox()
|
||||
self.calibration_combo.setObjectName("liveCalibrationCombo")
|
||||
calibration_row = QHBoxLayout()
|
||||
calibration_row.addWidget(self.calibration_combo, 1)
|
||||
self.btn_browse = QPushButton()
|
||||
self.btn_browse.setProperty("compact", "true")
|
||||
self.btn_browse.clicked.connect(self._on_browse)
|
||||
calibration_row.addWidget(self.btn_browse)
|
||||
choice_form.addRow(self.calibration_label, calibration_row)
|
||||
|
||||
self.device_label = QLabel()
|
||||
self.device_label.setObjectName("fieldLabel")
|
||||
self.device_edit = QLineEdit()
|
||||
self.device_edit.setObjectName("liveDeviceEdit")
|
||||
choice_form.addRow(self.device_label, self.device_edit)
|
||||
self._choice_form = choice_form
|
||||
root.addLayout(choice_form)
|
||||
self.choice_note = ShrinkableLabel()
|
||||
self.choice_note.setObjectName("hintText")
|
||||
self.choice_note.setWordWrap(True)
|
||||
root.addWidget(self.choice_note)
|
||||
|
||||
root.addWidget(self._section_rule())
|
||||
self.calib_title = QLabel()
|
||||
self.calib_title.setObjectName("panelTitle")
|
||||
@@ -169,6 +213,8 @@ class SensorDialog(QDialog):
|
||||
# follows them while it is open.
|
||||
self.picker.sensor_type_changed.connect(lambda _value: self.refresh())
|
||||
self.picker.config_changed.connect(lambda _value: self.refresh())
|
||||
self.calibration_combo.currentIndexChanged.connect(self._on_choice_changed)
|
||||
self.device_edit.textChanged.connect(self._on_device_edited)
|
||||
|
||||
self.retranslate()
|
||||
self.refresh()
|
||||
@@ -183,17 +229,41 @@ class SensorDialog(QDialog):
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
def _on_export(self) -> None:
|
||||
self._window.cal_panel.export_coefficients()
|
||||
panel = getattr(self._window, "cal_panel", None)
|
||||
if panel is not None and hasattr(panel, "export_coefficients"):
|
||||
panel.export_coefficients()
|
||||
export = str(getattr(self._window, "_last_calibration_export", "") or "")
|
||||
if export:
|
||||
self.select_calibration_file(Path(export))
|
||||
self.refresh()
|
||||
|
||||
def _on_apply(self) -> None:
|
||||
self._window.cal_panel.apply_coefficients()
|
||||
self._persist_choice()
|
||||
apply = getattr(self._window, "apply_live_calibration_choice", None)
|
||||
if callable(apply):
|
||||
apply()
|
||||
self.refresh()
|
||||
|
||||
def _on_browse(self) -> None:
|
||||
chosen, _filter = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
t("video_analysis.calibration_browse"),
|
||||
str(Path.cwd()),
|
||||
t("video_analysis.calibration_filter"),
|
||||
)
|
||||
if chosen:
|
||||
self.select_calibration_file(Path(chosen))
|
||||
|
||||
def select_calibration_file(self, path: Path) -> None:
|
||||
"""Put a browsed (or just-exported) file in the combo and persist it."""
|
||||
self._refresh_calibrations(custom=Path(path))
|
||||
self._persist_choice()
|
||||
|
||||
# ------------------------------------------------------------------ content
|
||||
def refresh(self) -> None:
|
||||
"""Re-read the shell's calibration state. Called on open and on change."""
|
||||
window = self._window
|
||||
self._load_choice_into_controls()
|
||||
version = str(getattr(window, "_active_calibration_id", "") or "")
|
||||
self.version_label.setText(
|
||||
f"{t('sensor.calib_version')}: {version or t('sensor.calib_none')}"
|
||||
@@ -213,40 +283,209 @@ class SensorDialog(QDialog):
|
||||
)
|
||||
self.btn_reveal.setEnabled(bool(config_ref))
|
||||
self._refresh_profile()
|
||||
self._sync_device_row()
|
||||
self._refresh_choice_note()
|
||||
|
||||
export = str(getattr(window, "_last_calibration_export", "") or "")
|
||||
if not export:
|
||||
self.export_label.setText(t("sensor.export_none"))
|
||||
self.identity_label.setText("")
|
||||
self.btn_apply.setEnabled(False)
|
||||
return
|
||||
path = Path(export)
|
||||
self.export_label.setText(
|
||||
t(
|
||||
"sensor.export_file",
|
||||
name=path.name,
|
||||
size=f"{path.stat().st_size / 1024:.1f}" if path.is_file() else "?",
|
||||
time=(
|
||||
datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||
if path.is_file()
|
||||
else "?"
|
||||
),
|
||||
else:
|
||||
path = Path(export)
|
||||
self.export_label.setText(
|
||||
t(
|
||||
"sensor.export_file",
|
||||
name=path.name,
|
||||
size=f"{path.stat().st_size / 1024:.1f}" if path.is_file() else "?",
|
||||
time=(
|
||||
datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||
if path.is_file()
|
||||
else "?"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
identity = {}
|
||||
read_identity = getattr(window, "calibration_identity", None)
|
||||
if callable(read_identity):
|
||||
identity = dict(read_identity(export) or {})
|
||||
self.identity_label.setText(
|
||||
t(
|
||||
"sensor.export_identity",
|
||||
sensor_type=identity.get("sensor_type") or "?",
|
||||
sn=identity.get("sn") or "?",
|
||||
config=Path(str(identity.get("config_ref") or "?")).name,
|
||||
identity = {}
|
||||
read_identity = getattr(window, "calibration_identity", None)
|
||||
if callable(read_identity):
|
||||
identity = dict(read_identity(export) or {})
|
||||
self.identity_label.setText(
|
||||
t(
|
||||
"sensor.export_identity",
|
||||
sensor_type=identity.get("sensor_type") or "?",
|
||||
sn=identity.get("sn") or "?",
|
||||
config=Path(str(identity.get("config_ref") or "?")).name,
|
||||
)
|
||||
)
|
||||
)
|
||||
self.btn_apply.setEnabled(True)
|
||||
|
||||
def _entry(self):
|
||||
read_entry = getattr(self.picker, "selected_entry", None)
|
||||
return read_entry() if callable(read_entry) else None
|
||||
|
||||
def _default_calibration_path(self) -> Path | None:
|
||||
entry = self._entry()
|
||||
if entry is None or entry.profile is None:
|
||||
return None
|
||||
declared = getattr(entry.profile, "default_calibration", None)
|
||||
if declared is not None and Path(declared).is_file():
|
||||
return Path(declared)
|
||||
return None
|
||||
|
||||
def _load_choice_into_controls(self) -> None:
|
||||
read = getattr(self._window, "live_calibration_choice", None)
|
||||
role, path, device_id = ROLE_NONE, None, ""
|
||||
if callable(read):
|
||||
role, path, device_id = read()
|
||||
custom = Path(path) if role == ROLE_FILE and path else None
|
||||
self._refresh_calibrations(custom=custom)
|
||||
self._syncing = True
|
||||
try:
|
||||
if role == ROLE_FILE and custom is not None:
|
||||
# already selected by _refresh_calibrations
|
||||
pass
|
||||
elif role == ROLE_DEFAULT and self._default_calibration_path() is not None:
|
||||
index = self.calibration_combo.findData(ROLE_DEFAULT)
|
||||
if index >= 0:
|
||||
self.calibration_combo.setCurrentIndex(index)
|
||||
else:
|
||||
index = self.calibration_combo.findData(ROLE_NONE)
|
||||
if index >= 0:
|
||||
self.calibration_combo.setCurrentIndex(index)
|
||||
if device_id:
|
||||
self.device_edit.setText(str(device_id))
|
||||
finally:
|
||||
self._syncing = False
|
||||
|
||||
def _refresh_calibrations(self, *, custom: Path | None) -> None:
|
||||
self.calibration_combo.blockSignals(True)
|
||||
self.calibration_combo.clear()
|
||||
default = self._default_calibration_path()
|
||||
if default is not None:
|
||||
self.calibration_combo.addItem(
|
||||
t("video_analysis.calibration_default", name=default.name),
|
||||
ROLE_DEFAULT,
|
||||
)
|
||||
self.calibration_combo.addItem(t("video_analysis.calibration_none"), ROLE_NONE)
|
||||
if custom is not None:
|
||||
self._custom_calibration = Path(custom)
|
||||
self.calibration_combo.addItem(
|
||||
t("video_analysis.calibration_file", name=Path(custom).name), ROLE_FILE
|
||||
)
|
||||
self.calibration_combo.setCurrentIndex(self.calibration_combo.count() - 1)
|
||||
elif self.calibration_combo.count():
|
||||
# Prefer the profile default when one exists; otherwise raw force.
|
||||
wanted = ROLE_DEFAULT if default is not None else ROLE_NONE
|
||||
index = self.calibration_combo.findData(wanted)
|
||||
self.calibration_combo.setCurrentIndex(max(index, 0))
|
||||
self.calibration_combo.blockSignals(False)
|
||||
self._sync_device_row()
|
||||
|
||||
def current_role(self) -> str:
|
||||
return str(self.calibration_combo.currentData() or ROLE_NONE)
|
||||
|
||||
def current_calibration_path(self) -> Path | None:
|
||||
role = self.current_role()
|
||||
if role == ROLE_FILE:
|
||||
return self._custom_calibration
|
||||
if role == ROLE_NONE:
|
||||
return None
|
||||
return self._default_calibration_path()
|
||||
|
||||
def current_device_id(self) -> str | None:
|
||||
if not self._is_finger() or self.current_role() != ROLE_FILE:
|
||||
return None
|
||||
serial = self.device_edit.text().strip()
|
||||
return serial or None
|
||||
|
||||
def _is_finger(self) -> bool:
|
||||
entry = self._entry()
|
||||
if entry is None:
|
||||
return False
|
||||
return str(entry.product_family or "").strip().lower() == "finger"
|
||||
|
||||
def _persist_choice(self) -> None:
|
||||
save = getattr(self._window, "set_live_calibration_choice", None)
|
||||
if not callable(save):
|
||||
return
|
||||
save(
|
||||
role=self.current_role(),
|
||||
path=self.current_calibration_path(),
|
||||
device_id=self.current_device_id(),
|
||||
)
|
||||
|
||||
def _on_choice_changed(self) -> None:
|
||||
if self._syncing:
|
||||
return
|
||||
self._sync_device_row()
|
||||
self._refresh_choice_note()
|
||||
self._refresh_profile()
|
||||
self._persist_choice()
|
||||
|
||||
def _on_device_edited(self, _text: str) -> None:
|
||||
if self._syncing:
|
||||
return
|
||||
self._refresh_choice_note()
|
||||
self._persist_choice()
|
||||
|
||||
def _sync_device_row(self) -> None:
|
||||
path = self.current_calibration_path() if self.current_role() == ROLE_FILE else None
|
||||
visible = self._is_finger() and path is not None
|
||||
self.device_label.setVisible(visible)
|
||||
self.device_edit.setVisible(visible)
|
||||
form = getattr(self, "_choice_form", None)
|
||||
if form is not None and hasattr(form, "setRowVisible"):
|
||||
form.setRowVisible(self.device_label, visible)
|
||||
if not visible:
|
||||
self._device_source = None
|
||||
return
|
||||
if path != self._device_source:
|
||||
self._device_source = path
|
||||
_sensor_type, sn = calibration_identity(path)
|
||||
if sn:
|
||||
self._syncing = True
|
||||
try:
|
||||
self.device_edit.setText(sn)
|
||||
finally:
|
||||
self._syncing = False
|
||||
|
||||
def choice_problem(self) -> str:
|
||||
"""Why the current live choice must not be opened or applied; empty when usable."""
|
||||
return self._device_problem()
|
||||
|
||||
def _device_problem(self) -> str:
|
||||
path = self.current_calibration_path() if self.current_role() == ROLE_FILE else None
|
||||
if not self._is_finger() or path is None:
|
||||
return ""
|
||||
serial = self.device_edit.text().strip()
|
||||
if not serial:
|
||||
return t("video_analysis.device_required")
|
||||
sensor_type, sn = calibration_identity(path)
|
||||
if sensor_type and sensor_type != "finger":
|
||||
return t("video_analysis.device_family", family=sensor_type)
|
||||
if not sn:
|
||||
return t("video_analysis.device_missing_sn")
|
||||
if sn != serial:
|
||||
return t("video_analysis.device_mismatch", sn=sn)
|
||||
return ""
|
||||
|
||||
def _refresh_choice_note(self) -> None:
|
||||
problem = self._device_problem()
|
||||
if problem:
|
||||
self.choice_note.setText(problem)
|
||||
return
|
||||
role = self.current_role()
|
||||
path = self.current_calibration_path()
|
||||
if role == ROLE_NONE or path is None:
|
||||
self.choice_note.setText(t("sensor.live_calibration_raw_tip"))
|
||||
elif role == ROLE_DEFAULT:
|
||||
self.choice_note.setText(
|
||||
t("sensor.live_calibration_default_tip", name=path.name)
|
||||
)
|
||||
else:
|
||||
self.choice_note.setText(
|
||||
t("sensor.live_calibration_file_tip", name=path.name)
|
||||
)
|
||||
|
||||
def _refresh_profile(self) -> None:
|
||||
"""State the selected catalog entry, and the reason when it cannot be started.
|
||||
|
||||
@@ -256,12 +495,19 @@ class SensorDialog(QDialog):
|
||||
sentence. It is read from the picker rather than re-derived here, so the window and the
|
||||
dialog can never say two different things about one bundle.
|
||||
"""
|
||||
read_entry = getattr(self.picker, "selected_entry", None)
|
||||
entry = read_entry() if callable(read_entry) else None
|
||||
entry = self._entry()
|
||||
if entry is None:
|
||||
self.profile_label.setText("")
|
||||
self.profile_label.setToolTip("")
|
||||
return
|
||||
path = self.current_calibration_path()
|
||||
role = self.current_role()
|
||||
if role == ROLE_NONE or path is None:
|
||||
calibration_line = t("sensor.catalog_calibration_none")
|
||||
else:
|
||||
calibration_line = t(
|
||||
"sensor.catalog_calibration_selected", name=path.name
|
||||
)
|
||||
lines = [
|
||||
t("sensor.catalog_name", name=entry.name, folder=entry.folder),
|
||||
t(
|
||||
@@ -276,11 +522,7 @@ class SensorDialog(QDialog):
|
||||
state=entry.state,
|
||||
note=t(_STATE_KEYS.get(entry.state, "sensor.catalog_state_unknown")),
|
||||
),
|
||||
(
|
||||
t("sensor.catalog_calibration_selected", name=entry.selected_calibration)
|
||||
if entry.selected_calibration
|
||||
else t("sensor.catalog_calibration_none")
|
||||
),
|
||||
calibration_line,
|
||||
]
|
||||
if entry.duplicates:
|
||||
lines.append(t("sensor.catalog_duplicate", refs=", ".join(entry.duplicates)))
|
||||
@@ -300,13 +542,18 @@ class SensorDialog(QDialog):
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(t("sensor.dialog_title"))
|
||||
self.choice_title.setText(t("sensor.live_calibration_section"))
|
||||
self.calibration_label.setText(t("video_analysis.calibration"))
|
||||
self.device_label.setText(t("video_analysis.device"))
|
||||
self.device_edit.setPlaceholderText(t("video_analysis.device_tip"))
|
||||
self.btn_browse.setText(t("video_analysis.calibration_browse"))
|
||||
self.calib_title.setText(t("sensor.calib_section"))
|
||||
self.export_title.setText(t("sensor.export_section"))
|
||||
self.btn_reveal.setText(t("menu.reveal_config"))
|
||||
self.btn_export.setText(t("cal.export"))
|
||||
self.btn_export.setToolTip(t("cal.export_tip"))
|
||||
self.btn_apply.setText(t("cal.apply"))
|
||||
self.btn_apply.setToolTip(t("cal.apply_tip"))
|
||||
self.btn_apply.setToolTip(t("sensor.live_calibration_apply_tip"))
|
||||
self.btn_close.setText(t("dialog.cancel"))
|
||||
self.picker.retranslate()
|
||||
self.refresh()
|
||||
@@ -315,7 +562,5 @@ class SensorDialog(QDialog):
|
||||
self._theme = str(theme)
|
||||
|
||||
def showEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
# Every open re-reads the shell: the device may have confirmed a
|
||||
# calibration since the dialog was last shown.
|
||||
self.refresh()
|
||||
super().showEvent(event)
|
||||
self.refresh()
|
||||
|
||||
Reference in New Issue
Block a user