feat(autocal): 完善标定记录检查流程与UV轮廓显示

补齐传感器身份、稳定段检查引导和未完成试验诊断,并保存采集轮廓快照。通过60项相关测试、已有记录读取及代码和文档检查。
This commit is contained in:
ykcheungab
2026-10-11 20:15:04 +08:00
parent 432f45325f
commit d06501d7d5
17 changed files with 719 additions and 141 deletions
@@ -61,6 +61,7 @@ _SCOPED_ENUMS = {
"TextElideMode",
"TextFlag",
"AspectRatioMode",
"WindowType",
),
"QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"),
"QSizePolicy": ("Policy",),
@@ -160,6 +161,12 @@ def test_no_module_uses_an_unscoped_enum():
)
def test_window_type_guard_accepts_scoped_values_but_rejects_legacy_values(tmp_path):
path = tmp_path / "window_flags.py"
path.write_text("flag = Qt.WindowType.Widget\nlegacy = Qt.Widget\n", encoding="utf-8")
assert _unscoped_enum_references(path) == ["Qt.Widget"]
def test_the_controller_interface_is_qt_free():
"""The controller and its guards stay importable from a headless process.
+24
View File
@@ -111,6 +111,27 @@ def test_uv_coordinates_are_centered_and_masked(flat):
site_at(flat, -3, 0)
def test_trial_persists_colour_region_outline_and_rejects_mismatched_mapping(trial_setup, flat):
from orisys_autocal.sensor.uv_outline import outline_snapshot
flat.textured = True
flat.image_rgb[:] = 255
flat.image_rgb[2:4, 2:5] = [20, 80, 200]
snapshot = outline_snapshot(flat)
worker = Worker()
trial = ManualTrial(worker)
with pytest.raises(ValueError, match="outline"):
trial.start(**trial_setup, uv_outline={**snapshot, "mapping_sha256": "wrong"})
directory = trial.start(**trial_setup, uv_outline=snapshot)
worker.emit()
trial.finish(aborted=True, reason="test ended")
trial.recorder.stop(wait=True)
trial.poll()
manifest = json.loads((directory.parent.parent / "session.json").read_text(encoding="utf-8"))
assert manifest["uv_outline"] == snapshot and snapshot["patch"]
trial.finish_session()
def test_manual_trial_records_video_stamps_and_load_unload_events(trial_setup, clock):
worker = Worker()
trial = ManualTrial(worker)
@@ -277,6 +298,9 @@ def test_gui_hold_controls_and_session_lifecycle(make_window, trial_setup, clock
panel.levels.setText("5, 10")
panel.start_button.click()
assert panel.trial.status == "recording"
manifest = json.loads((panel.trial.session.directory / "session.json").read_text(encoding="utf-8"))
assert manifest["uv_outline"]["surface"]
assert manifest["uv_outline"]["mapping_sha256"] == manifest["uv_definition"]["mapping_sha256"]
worker.emit()
assert panel.views.currentWidget() is panel.source_panel
assert not panel.sensor_sn.isEnabled()
@@ -0,0 +1,84 @@
"""Operator evidence remains inspectable without admitting incomplete fits."""
import json
import csv
from copy import deepcopy
import pytest
import numpy as np
from test_session_review import session as session
from test_manual_trial import flat as flat
from orisys_autocal.calibration_session import write_json
from orisys_autocal.record_diagnostics import trial_diagnostics
from orisys_autocal.session_review import review_session
from orisys_autocal.sensor.uv_outline import outline_snapshot, recorded_outline
def test_incomplete_trace_counts_and_reference_evidence_are_available(session):
manifest = json.loads((session / "session.json").read_text(encoding="utf-8"))
trial = manifest["trials"][0]
trial.update(status="incomplete", reason="operator stopped")
write_json(session / "session.json", manifest)
report = trial_diagnostics(session, trial)
assert report["frames"] == 25 and report["dropped"] == 5
assert report["invalid"] == 10 and len(report["trace"]) == 15
assert report["max_gap_s"] == 10
assert report["trace"][0] == [1_000_000_000, 1]
assert len(report["breaks_ns"]) == 10
assert report["reason"] == "operator stopped"
assert len(report["references"]) == 5
result = review_session(session)
assert result["sites"][0]["windows"] == [] and result["sites"][0]["fit"] is None
def test_diagnostic_missing_files_and_external_path(session):
report = trial_diagnostics(session, {"trial_id": "missing", "status": "incomplete"})
assert report["trace"] == [] and "frames.csv" in report["errors"]
with pytest.raises(ValueError, match="outside"):
trial_diagnostics(session, {"trial_id": "bad", "status": "incomplete", "directory": "../outside"})
def test_hold_drift_uses_selected_valid_timestamps(session):
path = session / "trials/trial-001/forces.csv"
with path.open(newline="", encoding="utf-8") as handle:
rows = list(csv.DictReader(handle))
next(row for row in rows if row["frame_id"] == "2")["FN_raw"] = "3"
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
window = review_session(session)["sites"][0]["windows"][0]
assert window["samples"] == 2 and window["raw_mean"] == 2
assert window["raw_drift_per_s"] == pytest.approx(2)
def test_outline_snapshot_preserves_region_coordinates_and_mapping(flat):
flat.textured = True
flat.image_rgb[:] = 255
flat.image_rgb[2:4, 2:5] = [20, 80, 200]
snapshot = outline_snapshot(flat)
assert snapshot["surface"] and snapshot["patch"]
assert snapshot["origin_raster_xy_px"] == [3, 3]
assert snapshot["patch"][0] == [[2, 2], [2, 3], [4, 3], [4, 2]]
definition = {k: snapshot[k] for k in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")}
review = {"uv_definition": definition, "uv_outline": snapshot, "sites": []}
assert recorded_outline(review) == (snapshot, "snapshot")
changed = deepcopy(review)
changed["uv_definition"]["mapping_sha256"] = "different"
assert recorded_outline(changed) == (None, "mismatch")
assert recorded_outline({"uv_definition": definition, "sites": []}) == (None, "unavailable")
def test_legacy_geometry_recovery_checks_actual_mapping(flat, monkeypatch):
from orisys_autocal.sensor import uv_outline
snapshot = outline_snapshot(flat)
review = {"uv_definition": {k: snapshot[k] for k in (
"mapping_sha256", "origin_raster_xy_px", "raster_size_hw")}, "sites": [{"bundle_path": "recorded-bundle"}]}
monkeypatch.setattr(uv_outline, "load_bundle", lambda path: path)
monkeypatch.setattr(uv_outline, "rasterise", lambda bundle, shape: flat)
result, state = recorded_outline(review)
assert state == "surface_only" and not result["patch_available"]
flat.positions_mm[1, 2] += np.array([0, 0, 1])
assert recorded_outline(review) == (None, "mismatch")
+48 -4
View File
@@ -6,6 +6,7 @@ from copy import deepcopy
import pytest
from PySide6.QtWidgets import QPushButton
from PySide6.QtGui import QAction
from test_session_review import repeated_session as repeated_session, session as session
from orisys_autocal.calibration_session import write_json
@@ -20,6 +21,7 @@ def overview_session(repeated_session):
definition = {"mapping_sha256": "test-map", "origin_raster_xy_px": [5, 8],
"raster_size_hw": [12, 12], "u_positive": "right", "v_positive": "up", "unit": "px"}
manifest["uv_definition"] = definition
manifest.update(sensor_model="finger", sensor_sn="F-TEST", started_at="2026-10-11T09:00:00+00:00")
for index, site in enumerate(manifest["sites"]):
site["position_sensor_mm"] = [index + 1., 2., 3.]
for trial in manifest["trials"]:
@@ -27,6 +29,7 @@ def overview_session(repeated_session):
path = root / trial["directory"] / "session.json"
metadata = json.loads(path.read_text(encoding="utf-8"))
metadata["uv_definition"] = definition
metadata["input_source"] = {"kind": "video", "value": "test.mp4"}
write_json(path, metadata)
trial["plan"] = {"steps": list(range(5))}
manifest["sites"].append({"site_id": "site-003", "uv_xy_px": [3, 2], "position_sensor_mm": [3., 2., 3.]})
@@ -109,7 +112,7 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi
dialog.review = review_session(root, selections=selections)
dialog._refresh()
monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(plan_path), ""))
dialog.findChild(QPushButton, "overview_import_plan").click()
dialog.findChild(QAction, "overview_import_plan").trigger()
assert dialog.table.rowCount() == 4
assert dialog.table.item(0, 1).text() == "site-002"
dialog.uv_chart.series()[1].clicked.emit(dialog.uv_chart.series()[1].at(0))
@@ -135,7 +138,9 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi
dialog.table.selectRow(3)
dialog.trials.setCurrentIndex(1)
assert not dialog.response_chart.series()
assert not dialog.details_button.isEnabled()
assert dialog.details_button.isEnabled()
dialog.details_button.click()
assert dialog.plots.currentIndex() == 0
dialog.table.selectRow(2)
dialog.map_mode.setCurrentIndex(1)
assert len(dialog.uv_chart.series()) == 4
@@ -151,10 +156,10 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi
assert dialog.grab().save(str(tmp_path / "overview-response.png"))
dialog.findChild(QPushButton, "overview_save").click()
saved = next(root.glob("derived/*/review.json"))
dialog.findChild(QPushButton, "overview_clear_plan").click()
dialog.findChild(QAction, "overview_clear_plan").trigger()
assert dialog.table.rowCount() == 3
monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(saved), ""))
dialog.findChild(QPushButton, "overview_load").click()
dialog.findChild(QAction, "overview_load").trigger()
assert dialog.table.rowCount() == 4
dialog.close()
@@ -198,4 +203,43 @@ def test_records_tab_browse_error_and_reload(make_window, overview_session, monk
QTest.keyClick(page.overview, Qt.Key.Key_Escape)
assert page.overview.isVisible()
assert window.grab().save(str(tmp_path / "records-tab.png"))
window.resize(1280, 900)
qt_app.processEvents()
page.viewer.ensureWidgetVisible(page.overview.save_button)
assert page.overview.save_button.isVisible()
assert window.grab().save(str(tmp_path / "records-tab-small.png"))
window.close()
def test_operator_outline_identity_incomplete_inspection_and_equal_scale(qt_app, overview_session, tmp_path):
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
from PySide6.QtCore import Qt
root, _, _ = overview_session
manifest = json.loads((root / "session.json").read_text(encoding="utf-8"))
manifest["uv_outline"] = {
**{k: manifest["uv_definition"][k] for k in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")},
"surface": [[[1, 1], [10, 1], [10, 10], [1, 10]]],
"patch": [[[2, 5], [9, 5], [9, 9], [2, 9]]], "patch_available": True,
}
manifest["trials"][0].update(status="incomplete", reason="operator stopped")
write_json(root / "session.json", manifest)
dialog = RecordOverviewDialog(root)
assert "F-TEST" in dialog.identity.text() and "finger" in dialog.identity.text()
assert dialog.outline_state == "snapshot"
assert dialog.trials.currentData() == "trial-001"
assert dialog.details_button.isEnabled()
assert dialog.diagnostics["trial-001"]["trace"]
assert "operator stopped" in dialog.diagnostic_summary.text()
dialog.details_button.click()
assert dialog.plots.currentIndex() == 0
assert not dialog.response_chart.series()
dialog.show()
qt_app.processEvents()
area = dialog.uv_chart.plotArea()
x = dialog.uv_chart.axes(Qt.Orientation.Horizontal)[0]
y = dialog.uv_chart.axes(Qt.Orientation.Vertical)[0]
assert (x.max() - x.min()) / area.width() == pytest.approx((y.max() - y.min()) / area.height(), rel=.02)
assert len(dialog.uv_chart.series()) == 5
assert dialog.grab().save(str(tmp_path / "operator-incomplete.png"))
dialog.close()
@@ -110,6 +110,9 @@ def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session
dialog = SessionReviewDialog(session)
assert dialog.sites.count() == 2
assert dialog.table.rowCount() == 5
assert dialog.pages.currentIndex() == 0 and dialog.pages.count() == 2
assert len(dialog.trace_chart.axes()) == 2
assert all(len(series.attachedAxes()) == 2 for series in dialog.trace_chart.series())
assert len(dialog.chart.series()) == 4
assert len(dialog.uv_chart.series()) == 2
dialog.sites.setCurrentIndex(1)