Add Export Fused ROI CSV export
Lets a user export the current ROI as one CSV with a column per selected angle's value (RF peak freq, Bias A, Bias B, or velocity), once angles share a common (x, y) grid -- either via a live Fusion alignment result or because the open file is itself a previous Alignment Wizard export whose angles already share one grid on disk. Never triggers a background compute; angles without cached/stored data for the chosen value type are simply unavailable in the picker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+186
-2
@@ -20,11 +20,13 @@ import numpy as np
|
||||
import pytest
|
||||
from PyQt6.QtCore import QEventLoop, Qt, QTimer
|
||||
from PyQt6.QtTest import QTest
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||
from PyQt6.QtWidgets import QApplication, QDialog, QMessageBox
|
||||
|
||||
import sras_compute as compute
|
||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile
|
||||
from sras_viewer import RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX
|
||||
from sras_viewer import (
|
||||
FusedRoiExportDialog, RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX,
|
||||
)
|
||||
import tools.make_test_sras as gen
|
||||
|
||||
|
||||
@@ -670,6 +672,188 @@ def test_wizard_closes_with_a_reload(ctx):
|
||||
ctx.s = win._sras
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export Fused ROI
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Two independent ways angles can end up sharing one (x, y) grid to fuse
|
||||
# onto: a live alignment result (case a, exercised on ctx.win -- the reload
|
||||
# above restored one from the sidecar), or a file that is itself a previous
|
||||
# Alignment Wizard export, whose angles already share a grid on disk with no
|
||||
# alignment result needed at all (case b, exercised on a second window
|
||||
# opened on ctx.written from test_wizard_export).
|
||||
|
||||
def test_fused_export_gating_case_a(ctx):
|
||||
"""A live alignment result bridges the raw scan's per-angle grids --
|
||||
angles_share_raw_grid() alone would be False here."""
|
||||
win, s = ctx.win, ctx.s
|
||||
assert win._alignment_result is not None, "alignment restored from sidecar"
|
||||
assert not s.angles_share_raw_grid(), \
|
||||
"sanity check: the raw (un-aligned) scan must not already share a grid"
|
||||
|
||||
x, y = win._aligned_canvas_axes()
|
||||
roi = RoiQuad.from_bbox(float(x[1]), float(y[1]), float(x[-2]), float(y[-2]))
|
||||
win.image_canvas.set_roi(roi)
|
||||
pump(120)
|
||||
assert win._fused_grid_ready()
|
||||
assert win.btn_export_fused_roi.isEnabled()
|
||||
|
||||
|
||||
def test_write_fused_roi_csv_case_a_content(ctx):
|
||||
win, s = ctx.win, ctx.s
|
||||
roi = win.image_canvas.get_roi()
|
||||
assert roi is not None, "ROI drawn by test_fused_export_gating_case_a"
|
||||
|
||||
csv_path = ctx.tmpdir / "fused_roi_case_a.csv"
|
||||
win._write_fused_roi_csv(roi, CH4_IDX, [0, 1], str(csv_path))
|
||||
assert csv_path.exists()
|
||||
|
||||
lines = csv_path.read_text().splitlines()
|
||||
body = [l for l in lines if not l.startswith("#")]
|
||||
header, *data_lines = body
|
||||
assert header == (
|
||||
f"x_mm,y_mm,v_{s.angles_deg[0]:.4g}deg,v_{s.angles_deg[1]:.4g}deg")
|
||||
|
||||
x, y = win._fused_export_axes()
|
||||
mask = roi.mask_for_grid(x, y)
|
||||
assert len(data_lines) == int(mask.sum())
|
||||
|
||||
data = np.array([[float(v) for v in line.split(",")] for line in data_lines])
|
||||
expect0 = win._fused_value_image(0, CH4_IDX)[mask]
|
||||
expect1 = win._fused_value_image(1, CH4_IDX)[mask]
|
||||
assert np.allclose(data[:, 2], expect0, rtol=1e-5, atol=1e-4)
|
||||
assert np.allclose(data[:, 3], expect1, rtol=1e-5, atol=1e-4)
|
||||
|
||||
|
||||
def test_fused_roi_dialog_availability_live_updates(ctx):
|
||||
"""Switching the value-type radio re-evaluates every angle checkbox,
|
||||
disabling/auto-unchecking whichever ones are no longer available --
|
||||
independent of what actually backs availability_fn, so a synthetic
|
||||
stand-in keeps this a fast, deterministic test of the dialog itself."""
|
||||
win, s = ctx.win, ctx.s
|
||||
angles = [(a, float(s.angles_deg[a])) for a in range(s.n_angles)]
|
||||
only_angle0_has_ch1 = lambda a, c: (a == 0) if c == CH1_IDX else True
|
||||
|
||||
dlg = FusedRoiExportDialog(
|
||||
win, angles=angles, availability_fn=only_angle0_has_ch1,
|
||||
default_ch_idx=CH1_IDX, out_dir=str(s.path.parent), stem=s.path.stem,
|
||||
grid_note="test")
|
||||
try:
|
||||
assert dlg._angle_checks[0].isEnabled()
|
||||
assert all(not dlg._angle_checks[a].isEnabled()
|
||||
for a in range(1, s.n_angles))
|
||||
|
||||
dlg._angle_checks[0].setChecked(True)
|
||||
dlg._val_buttons[CH4_IDX].click()
|
||||
assert all(dlg._angle_checks[a].isEnabled() for a in range(s.n_angles)), \
|
||||
"CH4 is available for every angle"
|
||||
assert dlg._angle_checks[0].isChecked(), \
|
||||
"stays checked -- still available under CH4"
|
||||
|
||||
if s.n_angles > 1:
|
||||
dlg._angle_checks[1].setChecked(True)
|
||||
dlg._val_buttons[CH1_IDX].click()
|
||||
assert dlg._angle_checks[0].isChecked()
|
||||
if s.n_angles > 1:
|
||||
assert not dlg._angle_checks[1].isEnabled()
|
||||
assert not dlg._angle_checks[1].isChecked(), \
|
||||
"auto-unchecked: angle 1 has no data under CH1"
|
||||
finally:
|
||||
dlg.close()
|
||||
|
||||
|
||||
def test_fused_roi_dialog_select_all_none(ctx):
|
||||
win, s = ctx.win, ctx.s
|
||||
angles = [(a, float(s.angles_deg[a])) for a in range(s.n_angles)]
|
||||
dlg = FusedRoiExportDialog(
|
||||
win, angles=angles, availability_fn=lambda a, c: c == CH4_IDX,
|
||||
default_ch_idx=CH4_IDX, out_dir=str(s.path.parent), stem=s.path.stem,
|
||||
grid_note="test")
|
||||
try:
|
||||
assert not dlg._btn_export.isEnabled(), "nothing checked yet"
|
||||
dlg._on_select_all_available()
|
||||
assert all(cb.isChecked() for cb in dlg._angle_checks.values())
|
||||
assert dlg._btn_export.isEnabled()
|
||||
dlg._on_select_none()
|
||||
assert not any(cb.isChecked() for cb in dlg._angle_checks.values())
|
||||
assert not dlg._btn_export.isEnabled()
|
||||
finally:
|
||||
dlg.close()
|
||||
|
||||
|
||||
def test_on_export_fused_roi_csv_end_to_end(ctx):
|
||||
win, s = ctx.win, ctx.s
|
||||
roi = win.image_canvas.get_roi()
|
||||
assert roi is not None, "ROI from the earlier fused-export tests is still set"
|
||||
|
||||
csv_path = ctx.tmpdir / "fused_roi_e2e.csv"
|
||||
with patch("sras_viewer.main_window.FusedRoiExportDialog") as MockDlg:
|
||||
inst = MockDlg.return_value
|
||||
inst.exec.return_value = QDialog.DialogCode.Accepted
|
||||
inst.get_ch_idx.return_value = CH4_IDX
|
||||
inst.get_selected_angles.return_value = [0, 1]
|
||||
inst.get_output_path.return_value = str(csv_path)
|
||||
win.btn_export_fused_roi.click()
|
||||
|
||||
assert csv_path.exists()
|
||||
kwargs = MockDlg.call_args.kwargs
|
||||
assert kwargs["default_ch_idx"] == win.combo_channel.currentIndex()
|
||||
assert kwargs["out_dir"] == str(s.path.parent)
|
||||
assert kwargs["stem"] == s.path.stem
|
||||
|
||||
|
||||
def test_fused_export_no_alignment_shared_grid_path(ctx):
|
||||
"""ctx.written (from test_wizard_export) is itself a previous Alignment
|
||||
Wizard export: opened fresh with no sidecar for its own path, so no
|
||||
alignment result is ever restored -- but its angles already share one
|
||||
grid on disk, so the export must work through the no-resample path."""
|
||||
win2 = SrasViewerWindow()
|
||||
try:
|
||||
win2._load_file(str(ctx.written.path))
|
||||
assert wait_until(lambda: win2._sras is not None)
|
||||
s2 = win2._sras
|
||||
assert win2._alignment_result is None, \
|
||||
"no sidecar exists for this path -- nothing auto-restored"
|
||||
assert s2.angles_share_raw_grid(), \
|
||||
"a wizard export already shares one grid across angles"
|
||||
assert wait_until(lambda: all((a, CH4_IDX) in win2._dc_cache
|
||||
for a in range(s2.n_angles))), \
|
||||
"DC precomputed for every angle"
|
||||
|
||||
x, y = s2.x_axis_mm(0), s2.y_positions_mm(0)
|
||||
roi = RoiQuad.from_bbox(float(x[1]), float(y[1]), float(x[-2]), float(y[-2]))
|
||||
win2.image_canvas.set_roi(roi)
|
||||
pump(120)
|
||||
assert win2._fused_grid_ready()
|
||||
assert win2.btn_export_fused_roi.isEnabled()
|
||||
|
||||
aligned_cache_before = len(win2._aligned_cache)
|
||||
angle_idxs = list(range(min(2, s2.n_angles)))
|
||||
csv_path = ctx.tmpdir / "fused_roi_case_b.csv"
|
||||
win2._write_fused_roi_csv(roi, CH4_IDX, angle_idxs, str(csv_path))
|
||||
assert csv_path.exists()
|
||||
assert len(win2._aligned_cache) == aligned_cache_before, \
|
||||
"no-resample path must never touch apply_alignment"
|
||||
|
||||
header = next(l for l in csv_path.read_text().splitlines()
|
||||
if not l.startswith("#"))
|
||||
expected_header = "x_mm,y_mm," + ",".join(
|
||||
f"v_{s2.angles_deg[a]:.4g}deg" for a in angle_idxs)
|
||||
assert header == expected_header
|
||||
|
||||
# Break the shared grid and confirm gating flips off.
|
||||
mutate_idx = 1 if s2.n_angles > 1 else 0
|
||||
s2.x_start_mm[mutate_idx] += 1.0
|
||||
assert not s2.angles_share_raw_grid()
|
||||
win2._update_fused_export_enabled()
|
||||
assert not win2._fused_grid_ready()
|
||||
assert not win2.btn_export_fused_roi.isEnabled()
|
||||
assert "Alignment Wizard" in win2.btn_export_fused_roi.toolTip()
|
||||
finally:
|
||||
win2.close()
|
||||
pump(200)
|
||||
|
||||
|
||||
def test_pixel_inspector(ctx):
|
||||
win = ctx.win
|
||||
win.chk_aligned_view.setChecked(False)
|
||||
|
||||
Reference in New Issue
Block a user