Files
orisys-app/apps/studio/tests/test_live_calibration_dialog.py
T
ykcheungab 81063213f9 统一实时标定身份解析并规范行尾
控制器与两个对话框共用 CalibrationService.read_identity,解析失败与缺 SN 分开上报;
本轮改动文件统一 LF,SN 必填回归已锁定。
定向 73 项、校准 UI 8 项与 lint 通过;未跑全量门禁。
2026-10-19 18:42:13 +08:00

490 lines
17 KiB
Python

"""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.analysis.bare_video import VideoFacts
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 orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
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
def _coefficients(directory: Path, *, sensor_type: str, sn: str) -> Path:
"""A coefficient document the SDK's own loader accepts (a real export shape).
The live open/apply path reads a file's identity through ``CalibrationService``
— the same parser the device would use — so a Finger serial check needs a
document that really is a calibration file, not a ``meta`` block in any JSON.
"""
from orisys_app_shared.force.calibration_io import (
CalSample,
CalSessionStore,
build_export_model,
)
store = CalSessionStore()
store.aggregate("fn", CalSample(weight_n=0.0, raw_mean=0.0, n=10))
store.aggregate("fn", CalSample(weight_n=5.0, raw_mean=500.0, n=10))
model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")})
document = model.to_dict()
document["identity"] = {"sensor_type": sensor_type, "sn": sn}
document["pipeline"] = {"config_ref": "D:/lab/sensor.json"}
path = directory / f"{sensor_type}-{sn}.json"
path.write_text(json.dumps(document), encoding="utf-8")
return path
def _assert_unreadable(problem: str) -> str:
"""The problem is ``device_unreadable`` with the parser's reason, not "no SN"."""
template = t("video_analysis.device_unreadable", error="\x00")
prefix, _, suffix = template.partition("\x00")
assert problem != t("video_analysis.device_missing_sn")
assert problem.startswith(prefix) and problem.endswith(suffix), problem
detail = problem[len(prefix) : len(problem) - len(suffix)]
assert detail.strip(), "the parser's reason was dropped"
return detail
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 = _coefficients(tmp_path, sensor_type="finger", sn="DD02-14")
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 _finger_dialog(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)
return picker, host, dialog
def test_a_finger_file_the_sdk_rejects_is_reported_as_unreadable(
qt_app, tmp_path, monkeypatch
):
"""A ``meta`` SN is not an identity: the dialog precheck uses the strict parse."""
picker, _host, dialog = _finger_dialog(qt_app, tmp_path, monkeypatch)
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)
dialog.device_edit.setText("DD02-14")
_assert_unreadable(dialog.choice_problem())
finally:
dialog.deleteLater()
picker.deleteLater()
def test_a_finger_file_that_is_not_json_is_reported_as_unreadable(
qt_app, tmp_path, monkeypatch
):
picker, _host, dialog = _finger_dialog(qt_app, tmp_path, monkeypatch)
cal = tmp_path / "not-json.json"
cal.write_text("not a calibration document\n", encoding="utf-8")
try:
dialog.select_calibration_file(cal)
dialog.device_edit.setText("DD02-14")
_assert_unreadable(dialog.choice_problem())
finally:
dialog.deleteLater()
picker.deleteLater()
def test_a_finger_file_without_an_sn_is_reported_as_missing_sn(
qt_app, tmp_path, monkeypatch
):
"""Readable but nameless stays its own answer, not a parse failure."""
picker, _host, dialog = _finger_dialog(qt_app, tmp_path, monkeypatch)
cal = _coefficients(tmp_path, sensor_type="finger", sn="")
try:
dialog.select_calibration_file(cal)
dialog.device_edit.setText("DD02-14")
assert dialog.choice_problem() == t("video_analysis.device_missing_sn")
finally:
dialog.deleteLater()
picker.deleteLater()
def test_window_resolves_finger_file_choice_for_open(make_window, tmp_path):
window = make_window()
cal = _coefficients(tmp_path, sensor_type="finger", sn="DD02-14")
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")
def _facts(path: Path) -> VideoFacts:
return VideoFacts(path=path, frame_count=4, fps=10.0, width=8, height=8)
def test_analysis_prompt_defaults_to_the_saved_sensor_file(make_window, tmp_path):
"""The file saved from the sensor dialog is the analysis prompt's starting choice."""
window = make_window()
entry = window.sensor_picker.selected_entry()
assert entry is not None
assert window.live_calibration_for(entry.ref) is None
cal = tmp_path / "picked.json"
cal.write_text("{}", encoding="utf-8")
window.set_live_calibration_choice(role=ROLE_FILE, path=cal)
dialog = VideoAnalysisDialog(
video=tmp_path / "clip.mp4",
facts=_facts(tmp_path / "clip.mp4"),
catalog=scan_sensor_catalog(),
window=window,
initial_profile=entry.ref,
remembered_calibration=window.live_calibration_for,
)
try:
assert dialog.calibration_combo.currentData() == ROLE_FILE
assert dialog._chosen_calibration() == cal
finally:
dialog.deleteLater()
def test_analysis_prompt_keeps_its_default_until_a_choice_is_saved(
make_window, tmp_path
):
window = make_window()
dialog = VideoAnalysisDialog(
video=tmp_path / "clip.mp4",
facts=_facts(tmp_path / "clip.mp4"),
catalog=scan_sensor_catalog(),
window=window,
initial_profile="shipped/box",
remembered_calibration=window.live_calibration_for,
)
try:
assert dialog.calibration_combo.currentData() == ROLE_DEFAULT
finally:
dialog.deleteLater()
def test_a_reopened_analysis_setup_still_beats_the_saved_file(make_window, tmp_path):
window = make_window()
entry = window.sensor_picker.selected_entry()
assert entry is not None
cal = tmp_path / "picked.json"
cal.write_text("{}", encoding="utf-8")
window.set_live_calibration_choice(role=ROLE_FILE, path=cal)
dialog = VideoAnalysisDialog(
video=tmp_path / "clip.mp4",
facts=_facts(tmp_path / "clip.mp4"),
catalog=scan_sensor_catalog(),
window=window,
initial_profile=entry.ref,
prefer_no_calibration=True,
remembered_calibration=window.live_calibration_for,
)
try:
assert dialog.calibration_combo.currentData() == ROLE_NONE
finally:
dialog.deleteLater()