Add manual angle alignment mode with overlay, keyboard nudge, and save/clear
Automatic alignment's phase-correlation translation search is unreliable, so add a Fusion -> Manual Alignment dialog: every angle's CH4 threshold mask overlaid at once with distinct colors/opacity, a selector for the active angle, arrow keys to nudge translation and Q/E to nudge rotation, an Auto De-rotate button that snaps to the known scan angle, and Save/Clear controls backed by a JSON sidecar that's restored automatically on reload. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+138
-4
@@ -4,24 +4,29 @@ signals and worker threads under the offscreen platform plugin.
|
||||
|
||||
Covers the interactions a manual smoke test would: load, switch angles and
|
||||
channels, background DC precompute, lazy FFT compute, threshold and bg-sub
|
||||
changes, angle alignment, aligned view, ROI draw/move, and CSV export.
|
||||
changes, angle alignment, manual angle alignment, aligned view, ROI
|
||||
draw/move, and CSV export.
|
||||
|
||||
Usage: QT_QPA_PLATFORM=offscreen python tools/test_gui.py [file.sras]
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
from PyQt6.QtCore import QEventLoop, QTimer # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
from PyQt6.QtCore import QEventLoop, Qt, QTimer # noqa: E402
|
||||
from PyQt6.QtTest import QTest # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox # noqa: E402
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import sras_compute as compute # noqa: E402
|
||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX # noqa: E402
|
||||
from sras_viewer import RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX # noqa: E402
|
||||
import tools.make_test_sras as gen # noqa: E402
|
||||
@@ -170,7 +175,6 @@ def main():
|
||||
check("Export ROI enabled", win.btn_export_roi.isEnabled())
|
||||
|
||||
csv_path = tmpdir / "roi.csv"
|
||||
from unittest.mock import patch
|
||||
with patch("sras_viewer.QFileDialog.getSaveFileName",
|
||||
return_value=(str(csv_path), "")):
|
||||
win._on_export_roi_csv()
|
||||
@@ -233,6 +237,136 @@ def main():
|
||||
win.image_canvas._img_shape == s.image_shape(0),
|
||||
str(win.image_canvas._img_shape))
|
||||
|
||||
print("\nmanual alignment (Fusion)")
|
||||
check("manual alignment action enabled", win._manual_align_act.isEnabled())
|
||||
|
||||
# --- Open, seeding from the still-live automatic AlignmentResult --------
|
||||
win._on_manual_alignment()
|
||||
check("dialog opened", win._manual_align_dialog is not None)
|
||||
dlg = win._manual_align_dialog
|
||||
check("mask prep needed no background worker (already DC-cached)",
|
||||
not win._job_running("manual_align_masks"))
|
||||
r = win._alignment_result # still the automatic result from the block above
|
||||
if r is not None:
|
||||
check("seeded rotation matches the automatic result",
|
||||
all(dlg._angle_params[a].rotation_deg == r.per_angle[a].rotation_deg
|
||||
for a in range(s.n_angles)))
|
||||
check("seeded shift matches the automatic result",
|
||||
all(dlg._angle_params[a].shift_mm == r.per_angle[a].shift_mm
|
||||
for a in range(s.n_angles)))
|
||||
|
||||
# --- Reference angle is locked -------------------------------------------
|
||||
dlg.combo_active_angle.setCurrentIndex(dlg._ref_angle_idx)
|
||||
pump(30)
|
||||
before_ref = dlg._angle_params[dlg._ref_angle_idx]
|
||||
dlg._on_nudge_translate(1, 0, False)
|
||||
dlg._on_nudge_rotate(1, False)
|
||||
check("reference angle group disabled", not dlg.grp_manual_adjust.isEnabled())
|
||||
check("reference angle untouched by nudge attempts",
|
||||
dlg._angle_params[dlg._ref_angle_idx] == before_ref)
|
||||
|
||||
# --- Nudging a real angle (fine + coarse, translate + rotate) -----------
|
||||
active = 1 if s.n_angles > 1 else 0
|
||||
dlg.combo_active_angle.setCurrentIndex(active)
|
||||
pump(30)
|
||||
before = dlg._angle_params[active].shift_mm
|
||||
dlg._on_nudge_translate(1, 0, False) # fine +X
|
||||
fine_step = dlg.spin_step_translate_mm.value()
|
||||
check("fine translate nudge moved shift_x by exactly one fine step",
|
||||
abs(dlg._angle_params[active].shift_mm[0] - (before[0] + fine_step)) < 1e-9)
|
||||
|
||||
before = dlg._angle_params[active].shift_mm
|
||||
dlg._on_nudge_translate(0, -1, True) # coarse -Y
|
||||
coarse_step = fine_step * dlg.spin_step_multiplier.value()
|
||||
check("coarse translate nudge uses the multiplier",
|
||||
abs(dlg._angle_params[active].shift_mm[1] - (before[1] - coarse_step)) < 1e-9)
|
||||
|
||||
before_rot = dlg._angle_params[active].rotation_deg
|
||||
dlg._on_nudge_rotate(1, False)
|
||||
check("rotate nudge changed rotation_deg",
|
||||
dlg._angle_params[active].rotation_deg != before_rot)
|
||||
check("preview canvas rebuilt for every angle after a rotation nudge",
|
||||
len(dlg._preview_layers) == s.n_angles)
|
||||
|
||||
# --- Real key-event wiring (proves keyPressEvent -> signal -> slot) -----
|
||||
before = dlg._angle_params[active].shift_mm
|
||||
QTest.keyClick(dlg.canvas, Qt.Key.Key_Right)
|
||||
check("a real Right-arrow key event nudged shift_x",
|
||||
dlg._angle_params[active].shift_mm[0] > before[0])
|
||||
|
||||
# --- Auto De-rotate: rotation only, translation untouched ---------------
|
||||
shift_before_derotate = dlg._angle_params[active].shift_mm
|
||||
dlg._on_auto_derotate()
|
||||
expected_theta = compute._theta_deg(s, active, dlg._ref_angle_idx)
|
||||
check("auto de-rotate set the known analytic angle",
|
||||
abs(dlg._angle_params[active].rotation_deg - expected_theta) < 1e-6)
|
||||
check("auto de-rotate left translation untouched",
|
||||
dlg._angle_params[active].shift_mm == shift_before_derotate)
|
||||
check("reference angle stays identity after auto de-rotate",
|
||||
dlg._angle_params[dlg._ref_angle_idx].rotation_deg == 0.0)
|
||||
|
||||
# --- Save -----------------------------------------------------------------
|
||||
dlg._on_save()
|
||||
sidecar = compute.sidecar_path(s.path)
|
||||
check("sidecar file written", sidecar.exists())
|
||||
raw = json.loads(sidecar.read_text()) if sidecar.exists() else {}
|
||||
check("sidecar schema_version is 1", raw.get("schema_version") == 1)
|
||||
check("sidecar per_angle round-trips the dialog's resolved params",
|
||||
all(raw.get("per_angle", {}).get(str(a), {}).get("rotation_deg")
|
||||
== dlg._angle_params[a].rotation_deg for a in range(s.n_angles)))
|
||||
check("main window's alignment_result replaced by the manual build",
|
||||
win._alignment_result is not None
|
||||
and win._alignment_result.per_angle[active].rotation_deg
|
||||
== dlg._angle_params[active].rotation_deg)
|
||||
check("Aligned View auto-enabled after Save",
|
||||
win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked())
|
||||
|
||||
# --- Clear (with confirmation) --------------------------------------------
|
||||
with patch("sras_viewer.QMessageBox.question",
|
||||
return_value=QMessageBox.StandardButton.Yes):
|
||||
dlg._on_clear()
|
||||
check("sidecar file deleted", not sidecar.exists())
|
||||
check("dialog params reset to identity",
|
||||
all(dlg._angle_params[a] == compute.ManualAngleParams()
|
||||
for a in range(s.n_angles)))
|
||||
check("main window alignment_result cleared", win._alignment_result is None)
|
||||
check("Aligned View disabled after Clear",
|
||||
not win.chk_aligned_view.isEnabled() and not win.chk_aligned_view.isChecked())
|
||||
|
||||
dlg.close()
|
||||
pump(150)
|
||||
check("dialog reference released on close", win._manual_align_dialog is None)
|
||||
|
||||
# --- Sidecar auto-restore on next load ------------------------------------
|
||||
win._on_manual_alignment()
|
||||
dlg = win._manual_align_dialog
|
||||
dlg.combo_active_angle.setCurrentIndex(active)
|
||||
pump(30)
|
||||
dlg._on_auto_derotate()
|
||||
dlg._on_nudge_translate(1, 1, True)
|
||||
saved_rotation = dlg._angle_params[active].rotation_deg
|
||||
saved_shift = dlg._angle_params[active].shift_mm
|
||||
dlg._on_save()
|
||||
dlg.close()
|
||||
pump(150)
|
||||
|
||||
old_sras_id = id(win._sras)
|
||||
win._load_file(str(path)) # reload the same file fresh
|
||||
check("file reloaded", wait_until(
|
||||
lambda: win._sras is not None and id(win._sras) != old_sras_id))
|
||||
s = win._sras
|
||||
check("manual dialog force-closed by a reload", win._manual_align_dialog is None)
|
||||
check("reload restores the saved manual alignment automatically",
|
||||
win._alignment_result is not None)
|
||||
if win._alignment_result is not None:
|
||||
check("restored rotation matches what was saved",
|
||||
abs(win._alignment_result.per_angle[active].rotation_deg
|
||||
- saved_rotation) < 1e-9)
|
||||
check("restored shift matches what was saved",
|
||||
win._alignment_result.per_angle[active].shift_mm == saved_shift)
|
||||
check("Aligned View auto-checked after restoring a saved alignment",
|
||||
win.chk_aligned_view.isChecked())
|
||||
|
||||
print("\npixel inspector")
|
||||
win.chk_aligned_view.setChecked(False)
|
||||
pump(100)
|
||||
|
||||
Reference in New Issue
Block a user