feat(autocal): 完善标定记录检查流程与UV轮廓显示
补齐传感器身份、稳定段检查引导和未完成试验诊断,并保存采集轮廓快照。通过60项相关测试、已有记录读取及代码和文档检查。
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user