让直播标定文件从传感器对话框进入打开链路。

对话框可选配置默认、原始力或浏览文件,并按配置条目写入 prefs;点运行时把 cal_path(Finger 含 device_id)交给 worker,采集中则热更新。对话框与打开链路单测通过。
This commit is contained in:
ykcheungab
2026-10-08 16:37:26 +08:00
parent 19ae1f4968
commit 3d2fb0c4eb
9 changed files with 1006 additions and 54 deletions
+289 -44
View File
@@ -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()