81063213f9
控制器与两个对话框共用 CalibrationService.read_identity,解析失败与缺 SN 分开上报; 本轮改动文件统一 LF,SN 必填回归已锁定。 定向 73 项、校准 UI 8 项与 lint 通过;未跑全量门禁。
490 lines
17 KiB
Python
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()
|