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

对话框可选配置默认、原始力或浏览文件,并按配置条目写入 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
@@ -0,0 +1,321 @@
"""Live calibration choice in the sensor dialog: pick, remember, open, hot-apply."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import scan_sensor_catalog
from orisys_studio.ui import sensor_picker as picker_module
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE, SensorDialog
from orisys_studio.ui.sensor_picker import SensorPickerWidget
from PySide6.QtWidgets import QWidget
CONFIG = {
"name": "fixture",
"camera": {
"resolution_raw": [640, 480],
"fps": 30,
"resolution_stitch": [100, 100],
},
"tracker": {"algorithm": "dis"},
"stitcher": {"n": [1, 1]},
}
def _write(path: Path, payload: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def _descriptor(profile_id: str, **overrides: object) -> dict[str, object]:
data: dict[str, object] = {
"schema_version": 1,
"profile_id": profile_id,
"display_name": "Mini lab",
"product_family": "mini",
"processing_backend": "single_camera",
"sensor_json": "sensor.json",
}
data.update(overrides)
return data
class _Host(QWidget):
"""Window stand-in with the live-calibration prefs API the dialog expects."""
def __init__(self) -> None:
super().__init__()
self.prefs: dict = {}
self.applied = 0
self._choices: dict[str, tuple[str, Path | None, str]] = {}
self.picker: SensorPickerWidget | None = None
self._dialog: SensorDialog | None = None
def _on_reveal_config(self) -> None:
return
def _prefs_key(self) -> str | None:
if self.picker is None:
return None
entry = self.picker.selected_entry()
return None if entry is None else entry.ref
def live_calibration_choice(self) -> tuple[str, Path | None, str]:
key = self._prefs_key()
if not key:
return ROLE_NONE, None, ""
return self._choices.get(key, (ROLE_NONE, None, ""))
def set_live_calibration_choice(
self,
*,
role: str,
path: Path | str | None = None,
device_id: str | None = None,
) -> None:
key = self._prefs_key()
if not key:
return
file_path = Path(path) if role == ROLE_FILE and path else None
self._choices[key] = (role, file_path, str(device_id or ""))
store = self.prefs.setdefault("live_calibration", {})
payload: dict[str, str] = {"role": role}
if file_path is not None:
payload["path"] = str(file_path)
if device_id:
payload["device_id"] = str(device_id)
store[key] = payload
def apply_live_calibration_choice(self) -> None:
if self._dialog is not None and self._dialog.choice_problem():
return
self.applied += 1
def _make_picker(qt_app, tmp_path, monkeypatch, build):
root = tmp_path / "user"
build(root)
catalog = scan_sensor_catalog(
shipped_root=tmp_path / "no-shipped-root", user_root=root
)
monkeypatch.setattr(picker_module, "scan_sensor_catalog", lambda: catalog)
return SensorPickerWidget()
def _dialog_for(picker) -> tuple[_Host, SensorDialog]:
host = _Host()
host.picker = picker
dialog = SensorDialog(window=host, picker=picker)
host._dialog = dialog
return host, dialog
def test_without_a_profile_default_the_combo_starts_on_raw_force(
qt_app, tmp_path, monkeypatch
):
def build(root: Path) -> None:
_write(root / "mini-lab" / "sensor.json", CONFIG)
_write(root / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1"))
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
picker.set_sensor_type("mini")
_host, dialog = _dialog_for(picker)
try:
assert dialog.calibration_combo.findData(ROLE_DEFAULT) < 0
assert dialog.current_role() == ROLE_NONE
assert dialog.current_calibration_path() is None
assert t("sensor.catalog_calibration_none") in dialog.profile_label.text()
finally:
dialog.deleteLater()
picker.deleteLater()
def test_a_box_profile_default_appears_as_a_combo_row(qt_app):
"""The shipped box bundle declares ``force_calibration.json`` as its default."""
picker = SensorPickerWidget()
picker.set_sensor_type("box")
entry = picker.selected_entry()
assert entry is not None
assert entry.profile is not None
assert entry.profile.default_calibration is not None, (
"premise: shipped box must expose a profile default calibration"
)
_host, dialog = _dialog_for(picker)
try:
assert dialog.calibration_combo.findData(ROLE_DEFAULT) >= 0
assert dialog.calibration_combo.findData(ROLE_NONE) >= 0
assert dialog.current_role() == ROLE_NONE
dialog.calibration_combo.setCurrentIndex(
dialog.calibration_combo.findData(ROLE_DEFAULT)
)
path = dialog.current_calibration_path()
assert path is not None and path.name == "force_calibration.json"
assert t(
"sensor.catalog_calibration_selected", name="force_calibration.json"
) in dialog.profile_label.text()
finally:
dialog.deleteLater()
picker.deleteLater()
def test_browsing_a_file_shows_its_name_and_persists_per_bundle(
qt_app, tmp_path, monkeypatch
):
def build(root: Path) -> None:
for name, family, profile in (
("mini-a", "mini", "orisys.mini.a-r1"),
("finger-a", "finger", "orisys.finger.a-r1"),
):
folder = root / name
_write(folder / "sensor.json", CONFIG)
extras = {
"display_name": name,
"product_family": family,
"processing_backend": (
"uv_unroll" if family == "finger" else "single_camera"
),
}
if family == "finger":
extras["computational_assets"] = {}
_write(folder / "manifest.json", _descriptor(profile, **extras))
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
host, dialog = _dialog_for(picker)
cal = tmp_path / "custom.json"
cal.write_text(
json.dumps({"meta": {"sensor_type": "mini", "sn": "X"}}), encoding="utf-8"
)
try:
picker.set_sensor_type("mini")
dialog.select_calibration_file(cal)
assert dialog.current_role() == ROLE_FILE
assert dialog.calibration_combo.currentText() == t(
"video_analysis.calibration_file", name="custom.json"
)
mini_key = picker.selected_entry().ref
assert host.prefs["live_calibration"][mini_key]["path"] == str(cal)
picker.set_sensor_type("finger")
dialog.refresh()
assert dialog.current_role() == ROLE_NONE
finger_key = picker.selected_entry().ref
assert finger_key != mini_key
assert ROLE_FILE != host.prefs.get("live_calibration", {}).get(
finger_key, {}
).get("role")
finally:
dialog.deleteLater()
picker.deleteLater()
def test_a_finger_file_with_a_mismatched_sn_is_not_a_valid_choice(
qt_app, tmp_path, monkeypatch
):
def build(root: Path) -> None:
folder = root / "finger-lab"
_write(folder / "sensor.json", CONFIG)
_write(
folder / "manifest.json",
_descriptor(
"orisys.finger.lab-r1",
display_name="Finger lab",
product_family="finger",
processing_backend="uv_unroll",
computational_assets={},
),
)
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
picker.set_sensor_type("finger")
host, dialog = _dialog_for(picker)
cal = tmp_path / "DD02-14.json"
cal.write_text(
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
encoding="utf-8",
)
try:
dialog.select_calibration_file(cal)
# Offscreen dialogs are not shown; ``isHidden`` is the visibility flag.
assert not dialog.device_edit.isHidden()
assert dialog.device_edit.text() == "DD02-14"
assert dialog.choice_problem() == ""
dialog.device_edit.setText("OTHER")
assert dialog.choice_problem() == t(
"video_analysis.device_mismatch", sn="DD02-14"
)
dialog.btn_apply.click()
assert host.applied == 0
finally:
dialog.deleteLater()
picker.deleteLater()
def test_window_resolves_finger_file_choice_for_open(make_window, tmp_path):
window = make_window()
cal = tmp_path / "DD02-14.json"
cal.write_text(
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
encoding="utf-8",
)
finger = next(
(
entry
for entry in scan_sensor_catalog().entries
if str(entry.product_family) == "finger"
and entry.start_blocked_reason is None
),
None,
)
if finger is None:
pytest.skip("no startable Finger profile in the catalog")
window.sensor_picker.set_sensor_type("finger")
window.sensor_picker.set_config_name(finger.key)
window.set_live_calibration_choice(
role=ROLE_FILE, path=cal, device_id="DD02-14"
)
cal_path, device_id, problem = window._resolved_live_calibration()
assert problem == ""
assert cal_path == cal
assert device_id == "DD02-14"
window.set_live_calibration_choice(
role=ROLE_FILE, path=cal, device_id="OTHER"
)
_path, _device, problem = window._resolved_live_calibration()
assert problem == t("video_analysis.device_mismatch", sn="DD02-14")
def test_apply_while_running_queues_set_pending_calibration(make_window, tmp_path):
window = make_window()
cal = tmp_path / "force_calibration.json"
cal.write_text('{"schema_version": 2}', encoding="utf-8")
window.set_live_calibration_choice(role=ROLE_FILE, path=cal)
pending: list[str] = []
class _Worker:
def add_sink(self, _sink) -> None:
return
def set_pending_calibration(self, path) -> None:
pending.append(str(path))
worker = _Worker()
# Both slots filled so ``ensure_workers`` does not replace the shim.
window.session.set_worker("box", worker)
window.session.set_worker("finger", worker)
window.session.select_worker("box")
window.session.set_running(True)
assert window._running
assert window._active_worker is worker
window.apply_live_calibration_choice()
assert pending == [str(cal)]
pending.clear()
window.set_live_calibration_choice(role=ROLE_NONE)
window.apply_live_calibration_choice()
assert pending == []
assert window.statusBar().currentMessage() == t("sensor.live_calibration_remembered")