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

对话框可选配置默认、原始力或浏览文件,并按配置条目写入 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
+160 -3
View File
@@ -1831,11 +1831,156 @@ class StudioMainWindow(QMainWindow):
"""Note the coefficients that were written. Nothing is applied here.
Exporting and applying are separate actions: writing a file must never
change what a running device is using.
change what a running device is using. The new file becomes the dialog's
selected live calibration so Apply / the next Run can use it.
"""
from orisys_studio.ui.sensor_dialog import ROLE_FILE
from orisys_studio.ui.video_analysis_dialog import calibration_identity
self._last_calibration_export = str(path)
self.recorder.log_event("calibration_exported", str(path))
self.statusBar().showMessage(t("cal.export_only", path=Path(path).name))
export = Path(path)
_sensor_type, sn = calibration_identity(export)
self.set_live_calibration_choice(
role=ROLE_FILE,
path=export,
device_id=sn or None,
)
if self.sensor_dialog is not None:
self.sensor_dialog.select_calibration_file(export)
self.statusBar().showMessage(t("cal.export_only", path=export.name))
# ------------------------------------------------- live calibration choice
def _live_calibration_prefs_key(self) -> str | None:
entry = self.sensor_picker.selected_entry()
return None if entry is None else entry.ref
def live_calibration_choice(self) -> tuple[str, Path | None, str]:
"""``(role, file_path_or_None, device_id)`` remembered for the selected bundle.
``file_path`` is only set for the browsed-file role; the profile default is
resolved when the chain opens. A missing file falls back to raw force.
"""
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE
key = self._live_calibration_prefs_key()
store = self.prefs.get("live_calibration")
if not key or not isinstance(store, dict):
return ROLE_NONE, None, ""
entry = store.get(key)
if not isinstance(entry, dict):
return ROLE_NONE, None, ""
role = str(entry.get("role") or ROLE_NONE)
if role not in (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE):
role = ROLE_NONE
path: Path | None = None
if role == ROLE_FILE:
raw = str(entry.get("path") or "").strip()
candidate = Path(raw) if raw else None
if candidate is None or not candidate.is_file():
return ROLE_NONE, None, ""
path = candidate
return role, path, str(entry.get("device_id") or "")
def set_live_calibration_choice(
self,
*,
role: str,
path: Path | str | None = None,
device_id: str | None = None,
) -> None:
"""Remember the live calibration choice for the currently selected bundle."""
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE
key = self._live_calibration_prefs_key()
if not key:
return
store = self.prefs.get("live_calibration")
if not isinstance(store, dict):
store = {}
self.prefs["live_calibration"] = store
wanted = str(role or ROLE_NONE)
if wanted not in (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE):
wanted = ROLE_NONE
payload: dict[str, str] = {"role": wanted}
if wanted == ROLE_FILE and path is not None and str(path).strip():
payload["path"] = str(path)
if device_id and str(device_id).strip():
payload["device_id"] = str(device_id).strip()
store[key] = payload
save_preferences(self.prefs)
def _default_live_calibration_path(self) -> Path | None:
entry = self.sensor_picker.selected_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 _resolved_live_calibration(
self,
) -> tuple[Path | None, str | None, str]:
"""``(cal_path, device_id, problem)`` for the next open or a hot apply.
``problem`` is non-empty when a Finger file choice fails the serial check.
"""
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE
from orisys_studio.ui.video_analysis_dialog import calibration_identity
role, path, device_id = self.live_calibration_choice()
if role == ROLE_NONE:
return None, None, ""
if role == ROLE_DEFAULT:
return self._default_live_calibration_path(), None, ""
if role != ROLE_FILE or path is None:
return None, None, ""
entry = self.sensor_picker.selected_entry()
is_finger = (
entry is not None
and str(entry.product_family or "").strip().lower() == "finger"
)
if not is_finger:
return path, None, ""
serial = str(device_id or "").strip()
if not serial:
return path, None, t("video_analysis.device_required")
sensor_type, sn = calibration_identity(path)
if sensor_type and sensor_type != "finger":
return path, None, t("video_analysis.device_family", family=sensor_type)
if not sn:
return path, None, t("video_analysis.device_missing_sn")
if sn != serial:
return path, None, t("video_analysis.device_mismatch", sn=sn)
return path, serial, ""
def apply_live_calibration_choice(self) -> None:
"""Remember the dialog's choice; hot-update the device when a session is open."""
if self.sensor_dialog is not None:
problem = self.sensor_dialog.choice_problem()
if problem:
self.statusBar().showMessage(
t("sensor.live_calibration_invalid", reason=problem)
)
return
cal_path, _device_id, problem = self._resolved_live_calibration()
if problem:
self.statusBar().showMessage(
t("sensor.live_calibration_invalid", reason=problem)
)
return
if cal_path is None or not self._running:
self.statusBar().showMessage(t("sensor.live_calibration_remembered"))
return
worker = self._active_worker
if worker is None or not hasattr(worker, "set_pending_calibration"):
self.statusBar().showMessage(t("cal.apply_no_target"))
return
worker.set_pending_calibration(cal_path)
self.statusBar().showMessage(
t("sensor.live_calibration_applied", name=cal_path.name)
)
# ------------------------------------------------- calibration (forwards)
# "What does this coefficient file claim, and does it match the bench?" lives in
@@ -2280,6 +2425,13 @@ class StudioMainWindow(QMainWindow):
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(t("status.video_required"))
return
cal_path, device_id, cal_problem = self._resolved_live_calibration()
if cal_problem:
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(
t("sensor.live_calibration_invalid", reason=cal_problem)
)
return
if self._preview_open:
self._stop_preview(announce=False)
@@ -2339,7 +2491,12 @@ class StudioMainWindow(QMainWindow):
# The acquisition thread applies it and records what the device reports.
self._active_worker.set_filter_settings(self._filter_settings())
try:
self._active_worker.start(source, config_ref=config_ref)
self._active_worker.start(
source,
config_ref=config_ref,
cal_path=cal_path,
device_id=device_id,
)
except Exception as exc:
report_error(exc, message="Run failed")
self.toolbar.set_run_checked(False)
+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()