From c30c8b181585f9d32e11db3cd8402bf629acaf56 Mon Sep 17 00:00:00 2001 From: Thomas Ales Date: Sun, 9 Aug 2026 14:30:02 -0500 Subject: [PATCH] Refactor cache validation and optimize alignment wizard incremental updates - Extract cache mismatch logic into reusable cache_mismatch_reasons() function for cleaner validation and consistent miss reporting - Expose memory_budget_bytes() for external callers (aligned exporter) - Optimize rebuild_stack() with incremental layer updates when only one angle parameters change, avoiding full reprojection overhead - Remove unused seed_per_angle parameter from alignment wizard - Simplify cached_rf_image() DC masking with cleaner early exit logic - Clean up internal state tracking with explicit origin caching Co-Authored-By: Claude Haiku 4.5 --- sras_align_export.py | 87 ++++------- sras_compute.py | 137 ++++++++++++------ sras_format.py | 51 +++++++ sras_viewer/align_wizard.py | 277 ++++++++++++++++++++---------------- sras_viewer/common.py | 27 +++- sras_viewer/main_window.py | 46 ++---- sras_workers.py | 20 +-- tests/test_gui.py | 16 ++- 8 files changed, 392 insertions(+), 269 deletions(-) diff --git a/sras_align_export.py b/sras_align_export.py index 18a83a4..4e4311b 100644 --- a/sras_align_export.py +++ b/sras_align_export.py @@ -81,19 +81,22 @@ class ExportPlan: px = self.n_rows * self.n_frames return (self.valid_px.get(angle_idx, 0) / px) if px else 0.0 + def empty_angles(self) -> list[int]: + """Angles that would be written as pure padding — no output pixel of + theirs has a source pixel.""" + return [a for a in range(self.n_angles) if not self.valid_px.get(a, 0)] + def _src_coords(t, rows, n_cols: int) -> tuple[np.ndarray, np.ndarray]: """Fractional source (row, col) coordinates for whole output rows. - *rows* may be a scalar row index or an array of them; the result is shaped - (len(rows), n_cols), or (n_cols,) for a scalar. + *rows* is an array of output row indices; both results are shaped + (len(rows), n_cols). """ cols = np.arange(n_cols, dtype=np.float64) - r = np.atleast_1d(np.asarray(rows, dtype=np.float64))[:, None] + r = np.asarray(rows, dtype=np.float64)[:, None] sr = t.matrix[0, 0] * r + t.matrix[0, 1] * cols + t.offset[0] sc = t.matrix[1, 0] * r + t.matrix[1, 1] * cols + t.offset[1] - if np.isscalar(rows) or np.asarray(rows).ndim == 0: - return sr[0], sc[0] return sr, sc @@ -104,7 +107,7 @@ def _round_idx(coord: np.ndarray) -> np.ndarray: from zero while np.rint rounds them to even, and these have to be the same source pixels the apply_alignment(order=0) preview drew. Exact halves are not a corner case here — the canvas is snapped to the reference angle's own - pixel grid (canvas_for_params snap=True), so an unrotated angle lands on + pixel grid (see canvas_for_params), so an unrotated angle lands on half-integers wherever its row pitch differs from the reference's. """ return np.floor(coord + 0.5).astype(np.int64) @@ -181,6 +184,13 @@ def plan_export(sras: SrasFile, result: AlignmentResult) -> ExportPlan: bytes_per_angle = (n_rows * sras.n_channels * n_cols * sras.samples_per_frame * sras.bytes_per_sample) + # Built before the remaining warnings so they can be phrased with the + # plan's own coverage_frac rather than a second copy of the same division. + plan = ExportPlan(n_rows=n_rows, n_frames=n_cols, n_angles=n_angles, + bytes_per_angle=bytes_per_angle, + total_bytes=bytes_per_angle * n_angles, + valid_px=valid_px, warnings=warnings) + if n_rows > _MAX_ROWS: warnings.append( f"Crop is {n_rows} rows; the .sras geometry table caps rows at " @@ -188,7 +198,7 @@ def plan_export(sras: SrasFile, result: AlignmentResult) -> ExportPlan: if n_cols > _MAX_FRAMES: warnings.append(f"Crop is {n_cols} frames; the cap is {_MAX_FRAMES}.") for a in range(n_angles): - frac = valid_px[a] / (n_rows * n_cols) if n_rows and n_cols else 0.0 + frac = plan.coverage_frac(a) if frac == 0.0: warnings.append( f"Angle {a} has no data inside this crop — it will be written " @@ -202,55 +212,12 @@ def plan_export(sras: SrasFile, result: AlignmentResult) -> ExportPlan: "is written, which makes background subtraction a no-op.") if sras.version != 6: warnings.append(f"Input is v{sras.version}; the export is written as v6.") - if getattr(sras, "scan_aborted", False): + if sras.scan_aborted: warnings.append( f"Input scan was aborted: only its {n_angles} complete angle(s) " f"are exported.") - return ExportPlan(n_rows=n_rows, n_frames=n_cols, n_angles=n_angles, - bytes_per_angle=bytes_per_angle, - total_bytes=bytes_per_angle * n_angles, - valid_px=valid_px, warnings=warnings) - - -def _encode_preambles(sras: SrasFile) -> bytes: - """The Preamble Blocks section for the output file. - - v6/v7 inputs carry the on-disk span verbatim (sras.preambles_raw), which is - both cheaper and lossless. Legacy inputs don't keep that span, and a v2 file - has no preambles at all — there, empty strings are written. That is not a - silent downgrade: _parse_preamble("") returns {} and _set_calibration falls - back to the hardcoded scope constants, which is exactly the calibration a v2 - file already gets, so mV values round-trip unchanged. - """ - raw = getattr(sras, "preambles_raw", None) - if raw is not None: - return raw - strings = sras.preambles or [""] * sras.n_channels - out = bytearray() - for s in strings: - encoded = s.encode("utf-8") - out += struct.pack(">H", len(encoded)) + encoded - return bytes(out) - - -def _encode_background(sras: SrasFile) -> bytes: - """The Background Block for the output file. - - When the input has none (v2/v3), write samples_per_frame zeros rather than a - zero-length block. Every consumer guards on `background is not None` and - then subtracts it from a (spf,)-shaped row, so a length-0 array would - broadcast-fail at the first background-subtracted FFT; zeros make the - subtraction a correct no-op instead. - """ - raw = getattr(sras, "background_raw", None) - if raw is not None: - return raw - if sras.background is None: - samples = np.zeros(sras.samples_per_frame, dtype=np.int8) - else: - samples = np.rint(sras.background).astype(np.int8) - return struct.pack(">I", samples.size) + samples.tobytes() + return plan def _fill_row(sras: SrasFile, n_cols: int, dtype) -> np.ndarray: @@ -335,7 +302,8 @@ class _SourceReader: def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path, - *, progress_cb=None, should_stop=None) -> Path: + *, progress_cb=None, should_stop=None, + budget: int | None = None) -> Path: """Write *sras*, aligned per *result* and cropped to its canvas, to a new v6 .sras file. Returns the path written. @@ -384,7 +352,7 @@ def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path, # Reference-only header fields. v6/v7 inputs have real ones to carry over; # for a legacy input describe the canvas we are actually writing. - if hasattr(sras, "x_start_nominal_mm"): + if sras.x_start_nominal_mm is not None: nominal = (sras.x_start_nominal_mm, sras.y_start_nominal_mm, sras.x_delta_nominal_mm, sras.y_delta_nominal_mm, sras.row_spacing_mm) @@ -407,7 +375,7 @@ def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path, geo = struct.pack(GEO_FMT_V6, float(x0_mm), float(sras.pixel_x_mm), int(n_cols), int(n_rows)) * n_angles - budget = compute._TOTAL_BYTES_BUDGET + budget = compute.memory_budget_bytes() if budget is None else max(1, budget) total_chunks = max(1, n_angles * ((n_rows + _ROW_CHUNK - 1) // _ROW_CHUNK)) done_chunks = 0 cancelled = False @@ -419,8 +387,8 @@ def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path, fout.write(sras.angles_deg.astype(">f4").tobytes()) fout.write(geo) fout.write(y_rows.tobytes() * n_angles) - fout.write(_encode_preambles(sras)) - fout.write(_encode_background(sras)) + fout.write(sras.encoded_preambles()) + fout.write(sras.encoded_background()) pad = _fill_row(sras, n_cols, dtype) for a in range(n_angles): @@ -461,7 +429,10 @@ def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path, out[:, keep, :] = band[ idx_r[i][keep] - base, :, idx_c[i][keep], : ].transpose(1, 0, 2) - fout.write(out.tobytes()) + # out is C-contiguous, so the buffer protocol + # writes it straight out — .tobytes() would + # copy a full row per row written. + fout.write(out) done_chunks += 1 if progress_cb is not None: progress_cb(int(done_chunks / total_chunks * 100)) diff --git a/sras_compute.py b/sras_compute.py index b9893b7..02b0ae2 100644 --- a/sras_compute.py +++ b/sras_compute.py @@ -285,6 +285,13 @@ _TOTAL_BYTES_BUDGET = int(os.environ.get("SRAS_MEM_BUDGET_MB", 1024)) * 1024 * 1 _CHUNK_ROWS_MAX = 32 # cap for small scans (original behavior) _MAX_WORKERS = int(os.environ.get("SRAS_MAX_WORKERS", 0)) or (os.cpu_count() or 4) +def memory_budget_bytes() -> int: + """The module-wide ceiling on concurrently-live working buffers, for + callers outside this module that read the same data (the aligned exporter + sizes its source-band reader against it).""" + return _TOTAL_BYTES_BUDGET + + def _chunk_rows_for(n_frames: int, samples_per_frame: int, budget: int = _TOTAL_BYTES_BUDGET) -> int: bytes_per_row = max(1, n_frames * samples_per_frame * 4) # float32 @@ -500,6 +507,36 @@ def pad_factor_for(sras: SrasFile, n_fft: int | None) -> int: return max(1, n_fft // spf) +def cache_mismatch_reasons(sras: SrasFile, *, n_fft: int | None, + apply_bg_sub: bool, row_avg_n: int) -> list[str]: + """Why this file's stored FFT images can't answer a request, as human- + readable phrases; empty means they can. + + Padding, background subtraction and row-averaging are all baked into + the stored numbers, so a request that differs in any of them has to go + back through a real FFT. This is the single accept rule: cached_rf_image + serves a stored image iff this returns nothing, and callers that want to + *explain* the miss (rather than silently recompute) format these same + strings, so the two can't drift apart. + """ + reasons = [] + pad = pad_factor_for(sras, n_fft) + if pad != sras.precomputed_pad_factor: + want = f"pad {pad}x" if pad else "a ragged n_fft" + reasons.append(f"stored at pad {sras.precomputed_pad_factor}x, " + f"requested at {want}") + if sras.precomputed_bg_sub != (apply_bg_sub and sras.background is not None): + reasons.append("stored with background subtraction " + f"{'on' if sras.precomputed_bg_sub else 'off'}") + if sras.precomputed_row_avg_n != row_avg_n: + have = (f"row-averaged (n={sras.precomputed_row_avg_n})" + if sras.precomputed_row_avg_n else "raw per-pixel") + want = (f"row-averaged (n={row_avg_n})" + if row_avg_n else "raw per-pixel") + reasons.append(f"stored {have}, requested {want}") + return reasons + + def cached_rf_image(sras: SrasFile, angle_idx: int, dc_threshold_mv: float | None, apply_bg_sub: bool = True, @@ -536,15 +573,15 @@ def cached_rf_image(sras: SrasFile, angle_idx: int, fall through to a real compute rather than block. """ cached_freq = sras.precomputed_freq_mhz[angle_idx] - if (cached_freq is None - or pad_factor_for(sras, n_fft) != sras.precomputed_pad_factor - or sras.precomputed_bg_sub != (apply_bg_sub and sras.background is not None) - or sras.precomputed_row_avg_n != row_avg_n): + if cached_freq is None or cache_mismatch_reasons( + sras, n_fft=n_fft, apply_bg_sub=apply_bg_sub, row_avg_n=row_avg_n): return None - freq_img = cached_freq.copy() + dc4_img = None if dc_threshold_mv is not None: # DC4 mask, in priority order: already-cached DC block, caller- - # supplied image, or a fresh (cheap — no FFT) recompute. + # supplied image, or a fresh (cheap — no FFT) recompute. Resolved + # before the copy below so the allow_dc_recompute bail-out doesn't + # allocate a full image it is about to throw away. dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX) if dc4_img is None: if dc4_mv is not None: @@ -554,6 +591,8 @@ def cached_rf_image(sras: SrasFile, angle_idx: int, *sras.cal(CH4_IDX)) else: return None + freq_img = cached_freq.copy() + if dc4_img is not None: freq_img[dc4_img < dc_threshold_mv] = 0.0 return freq_img @@ -778,6 +817,9 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool, try: if mode not in ("dc", "fft", "fft_rowavg"): return f"unknown cache mode {mode!r} (expected 'dc', 'fft', or 'fft_rowavg')" + # write_v7_cache enforces this bound too, but only once every angle's + # FFT has already been computed. Checking up front is the difference + # between a bad argument costing nothing and costing the whole run. if not (1 <= pad_factor <= MAX_PAD_FACTOR): return f"pad_factor must be 1-{MAX_PAD_FACTOR}, got {pad_factor}" set_fft_backend(fft_backend) @@ -1492,24 +1534,17 @@ def register_angle_to_reference( def canvas_for_params(sras: SrasFile, ref_angle_idx: int, pitch_mm: tuple[float, float], per_angle_params: dict[int, ManualAngleParams], - *, margin_frac: float = 0.0, snap: bool = True ) -> tuple[tuple[float, float], tuple[int, int]]: """Shared-canvas origin (stage mm) and (n_rows, n_cols) at *pitch_mm* that contains every angle's footprint after its own rigid transform. Angles missing from per_angle_params default to identity (e.g. a sidecar saved before a rescan added more angles). - snap=True aligns the canvas grid with the reference angle's own pixel grid, - so the reference lands on integer canvas pixels and is resampled by an - exact integer translation — the concrete meaning of "the canvas carries - angle 0's X/Y coordinates". It requires pitch_mm to be the reference's own - pitch; the manual-alignment preview passes a coarser pitch and snap=False. - - margin_frac pads the box on every side: 0 for a final canvas, nonzero for - the wizard's preview canvas, which needs headroom so an ordinary - translation nudge never has to trigger a full canvas resize (an extreme - nudge can still push content past this padding; accepted, and cheap to - recover from by re-opening the dialog). + The canvas grid is aligned with the reference angle's own pixel grid, so + the reference lands on integer canvas pixels and is resampled by an exact + integer translation — the concrete meaning of "the canvas carries angle + 0's X/Y coordinates". This requires pitch_mm to be the reference's own + pitch. """ dx, dy = pitch_mm corners = np.vstack([ @@ -1520,27 +1555,17 @@ def canvas_for_params(sras: SrasFile, ref_angle_idx: int, for a in range(sras.n_angles)]) x_min, y_min = corners.min(axis=0) x_max, y_max = corners.max(axis=0) - if margin_frac: - pad_x, pad_y = (x_max - x_min) * margin_frac, (y_max - y_min) * margin_frac - x_min, x_max = x_min - pad_x, x_max + pad_x - y_min, y_max = y_min - pad_y, y_max + pad_y - center = ref_center_mm(sras, ref_angle_idx) - if snap: - # Express the box in the reference's own pixel indices and grow it - # outward to whole pixels, so canvas index k lands exactly where the - # reference's own pixel (k + const) does. - cy, cx = _center_idx(sras, ref_angle_idx) - cols = sorted((x_min / dx + cx, x_max / dx + cx)) - rows = sorted((y_min / dy + cy, y_max / dy + cy)) - col0, col1 = int(np.floor(cols[0])), int(np.ceil(cols[1])) - row0, row1 = int(np.floor(rows[0])), int(np.ceil(rows[1])) - origin_ref = np.array([(col0 - cx) * dx, (row0 - cy) * dy]) - shape = (row1 - row0 + 1, col1 - col0 + 1) - else: - origin_ref = np.array([x_min, y_min if dy > 0 else y_max]) - shape = (int(np.ceil((y_max - y_min) / abs(dy))) + 1, - int(np.ceil((x_max - x_min) / dx)) + 1) + # Express the box in the reference's own pixel indices and grow it + # outward to whole pixels, so canvas index k lands exactly where the + # reference's own pixel (k + const) does. + cy, cx = _center_idx(sras, ref_angle_idx) + cols = sorted((x_min / dx + cx, x_max / dx + cx)) + rows = sorted((y_min / dy + cy, y_max / dy + cy)) + col0, col1 = int(np.floor(cols[0])), int(np.ceil(cols[1])) + row0, row1 = int(np.floor(rows[0])), int(np.ceil(rows[1])) + origin_ref = np.array([(col0 - cx) * dx, (row0 - cy) * dy]) + shape = (row1 - row0 + 1, col1 - col0 + 1) origin_stage = origin_ref + center return (float(origin_stage[0]), float(origin_stage[1])), shape @@ -1594,7 +1619,7 @@ def _result_from_params(sras: SrasFile, ref_angle_idx: int, thread on every manual edit.""" pitch = pixel_pitch_mm(sras, ref_angle_idx) canvas_origin_mm, canvas_shape = canvas_for_params( - sras, ref_angle_idx, pitch, params, snap=True) + sras, ref_angle_idx, pitch, params) extra = extra or {} per_angle: dict[int, AngleTransform] = {} @@ -1653,6 +1678,36 @@ def crop_alignment_result(result: AlignmentResult, row0: int, col0: int, origin, per_angle) +def coarse_rect_to_canvas(rect: tuple[int, int, int, int], + downsample: tuple[int, int], + canvas_shape: tuple[int, int], + *, inset_blocks: int = 0 + ) -> tuple[int, int, int, int]: + """A rectangle in coarse (block-mean preview) indices as canvas pixels, + both as (row0, col0, n_rows, n_cols) and clamped to the canvas. + + The single place the preview grid's relation to the real canvas is + written down: the coarse grid samples canvas pixels 0, f, 2f, …, so a + coarse pixel stands for a whole block and every caller has to agree on + which canvas pixels that block means, or a crop the user approved on the + preview lands a few pixels off in the export. + + *inset_blocks* shrinks the rectangle by that many coarse blocks on each + side. A coarse pixel reported as fully covered stands for a block whose + far edge may not be, so "fit to full overlap" insets by 1 to stay honestly + inside the overlap region; a union rectangle insets by 0 because it wants + to contain the region rather than fit inside it. + """ + row0, col0, nr, nc = rect + fy, fx = downsample + n_rows, n_cols = canvas_shape + r0 = min(n_rows - 1, (row0 + inset_blocks) * fy) + c0 = min(n_cols - 1, (col0 + inset_blocks) * fx) + r1 = min(n_rows, (row0 + nr - inset_blocks) * fy) + c1 = min(n_cols, (col0 + nc - inset_blocks) * fx) + return r0, c0, max(1, r1 - r0), max(1, c1 - c0) + + def overlap_stats(counts: np.ndarray, n_angles: int) -> dict: """Summarize a per-pixel "how many angles cover this pixel" image — the number the alignment wizard's mask-stack view is colored by. @@ -1798,8 +1853,8 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int, # to call synchronously on the GUI thread on every edit. The only genuinely # expensive per-pixel operation anywhere in this flow is reproject_mask, and # only the wizard's own downsampled mask stack calls that per keystroke -# — see that class's docstring for how it limits each nudge to reprojecting only -# the actively-edited angle. +# — see AlignmentWizard.rebuild_stack for how it limits a nudge to reprojecting +# only the actively-edited angle. # --------------------------------------------------------------------------- def reproject_mask(sras: SrasFile, angle_idx: int, ref_angle_idx: int, diff --git a/sras_format.py b/sras_format.py index 96ef358..13ef64f 100644 --- a/sras_format.py +++ b/sras_format.py @@ -254,6 +254,48 @@ class SrasFile: # be served from it — see sras_compute.cached_rf_image. self.precomputed_pad_factor: int = 1 + def encoded_preambles(self) -> bytes: + """This file's Preamble Blocks section, as bytes a writer can emit. + + v6/v7 files kept the on-disk span verbatim, which is both cheaper and + lossless; legacy files did not keep it, and a v2 file has no preambles + at all, so those are re-encoded from the parsed strings (empty ones for + v2). Empty is not a silent downgrade: _parse_preamble("") returns {} and + _set_calibration falls back to the hardcoded scope constants, which is + exactly the calibration a v2 file already gets, so mV values round-trip + unchanged. + + Lives here rather than at each writer so the version fan-out sits next + to the parser that creates it, and no writer has to probe the object + to find out which shape it got. + """ + raw = getattr(self, "preambles_raw", None) + if raw is not None: + return raw + out = bytearray() + for s in self.preambles or [""] * self.n_channels: + encoded = s.encode("utf-8") + out += struct.pack(">H", len(encoded)) + encoded + return bytes(out) + + def encoded_background(self) -> bytes: + """This file's Background Block, as bytes a writer can emit. + + When there is none (v2/v3), this is samples_per_frame zeros rather than + a zero-length block. Every consumer guards on `background is not None` + and then subtracts it from a (spf,)-shaped row, so a length-0 array + would broadcast-fail at the first background-subtracted FFT; zeros make + the subtraction a correct no-op instead. + """ + raw = getattr(self, "background_raw", None) + if raw is not None: + return raw + if self.background is None: + samples = np.zeros(self.samples_per_frame, dtype=np.int8) + else: + samples = np.rint(self.background).astype(np.int8) + return struct.pack(">I", samples.size) + samples.tobytes() + def cached_dc_mv(self, angle_idx: int, ch_idx: int) -> np.ndarray | None: """A stored DC image (already in mV) for (angle, channel), or None.""" store = self.precomputed_dc3_mv if ch_idx == CH3_IDX else self.precomputed_dc4_mv @@ -284,6 +326,15 @@ class SrasFile: self.scan_aborted = False self.n_angles_declared = n_angles + # Pre-v6 files carry no nominal ROI. Defined as None rather than + # left absent so the object's shape does not depend on its version + # and writers can ask instead of probing with hasattr. + self.x_start_nominal_mm = None + self.y_start_nominal_mm = None + self.x_delta_nominal_mm = None + self.y_delta_nominal_mm = None + self.row_spacing_mm = None + angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4").astype(np.float32) y_pos = np.frombuffer(f.read(n_rows * 4), dtype=">f4").astype(np.float32) diff --git a/sras_viewer/align_wizard.py b/sras_viewer/align_wizard.py index 1128674..2476448 100644 --- a/sras_viewer/align_wizard.py +++ b/sras_viewer/align_wizard.py @@ -35,7 +35,7 @@ import numpy as np from matplotlib.backends.backend_qtagg import NavigationToolbar2QT from PyQt6.QtCore import QSignalBlocker, Qt, pyqtSignal from PyQt6.QtWidgets import ( - QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, + QCheckBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMessageBox, QProgressBar, QPushButton, QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QWizard, QWizardPage, ) @@ -48,8 +48,8 @@ from sras_workers import AlignedExportWorker, Ch4MaskWorker, CrossCorrelateWorke from .canvases import AlignOverlayCanvas, ImageCanvas, RoiQuad, count_colormap from .common import ( - _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, Jobs, _axes_extent, _form, - _group, _make_dspin, _scroll_panel, _wrap_label, + _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, Jobs, _axes_extent, _combo, + _form, _group, _make_dspin, _scroll_panel, _wrap_label, ) if TYPE_CHECKING: @@ -66,6 +66,11 @@ _ACTIVE_ALPHA = 0.75 _PANEL_W = 372 +# Rotation this far from the file's stage angle is worth calling out: the stage +# angles are usually good to within a degree, so more than that means either the +# metadata or the fit is wrong. +_DRIFT_WARN_DEG = 1.0 + # (label, sources for register_angle_to_reference) — "both" costs roughly double # but removes the failure mode where the one chosen source happens to be # uninformative for a single angle. @@ -105,7 +110,6 @@ class WizardState: preview_shape: tuple[int, int] = (1, 1) geometry_generation: int = 0 crop: tuple[int, int, int, int] | None = None # canvas (row0,col0,nr,nc) - cropped_result: compute.AlignmentResult | None = None out_path: str = "" exported_path: str = "" @@ -116,7 +120,7 @@ class AlignmentWizard(QWizard): Non-modal by design, like the dialog it replaces: an export can take minutes on a real scan and the user should still be able to look at the data. - Background work goes through parent._run_worker, which inherits the main + Background work goes through self.run_worker, which inherits the main window's job registry, thread joining and shutdown handling. Two rules that module's docstring establishes and every launch here follows: disable the trigger *before* calling it (a re-entrant click must not be able to start a @@ -131,14 +135,17 @@ class AlignmentWizard(QWizard): def __init__(self, parent: "SrasViewerWindow", sras: SrasFile, *, ref_angle_idx: int, dc_threshold_mv: float, - seed_per_angle: dict[int, ManualAngleParams] | None, cached_dc4_mv: dict[int, np.ndarray]): super().__init__(parent) self._parent = parent self._sras = sras self._cached_dc4 = dict(cached_dc4_mv) - self._seed = dict(seed_per_angle or {}) self._closing = False + # (result, crop, cropped result, plan) — see cropped_plan. + self._plan_cache: tuple | None = None + # Canvas origin the current st.layers were reprojected against; a + # change in it invalidates every layer. See rebuild_stack. + self._stack_origin_mm: tuple[float, float] | None = None self.state = WizardState( ref_angle_idx=ref_angle_idx, threshold_mv=dc_threshold_mv, @@ -170,14 +177,6 @@ class AlignmentWizard(QWizard): def sras(self) -> SrasFile: return self._sras - def seed_params(self) -> dict[int, ManualAngleParams]: - """Per-angle starting parameters: a saved sidecar if one matches, - otherwise identity (the pre-rotation is applied on top by page 1).""" - return {a: ManualAngleParams(self._seed[a].rotation_deg, - self._seed[a].shift_mm) - if a in self._seed else ManualAngleParams() - for a in range(self._sras.n_angles)} - def rebuild_result(self): """Recompute the full AlignmentResult from the current parameters. @@ -189,7 +188,16 @@ class AlignmentWizard(QWizard): st.result = build_manual_alignment( self._sras, st.ref_angle_idx, st.threshold_mv, st.params) - def rebuild_stack(self): + def _reproject(self, angle_idx: int) -> np.ndarray: + st = self.state + p = st.params[angle_idx] + return compute.reproject_mask( + self._sras, angle_idx, st.ref_angle_idx, st.masks_small[angle_idx], + p.rotation_deg, p.shift_mm, st.preview_pitch_mm, + st.result.canvas_origin_mm, st.preview_shape, + src_downsample=st.downsample) + + def rebuild_stack(self, only_angle: int | None = None): """Reproject every angle's mask onto a coarsened view of the *final* canvas and sum them into an overlap-count image. @@ -199,29 +207,72 @@ class AlignmentWizard(QWizard): the crop page turn a rectangle drawn in millimetres into an exact integer window of the real canvas, with no second coordinate frame to reconcile. + + *only_angle* says that angle is the only one whose parameters changed. + Reprojection is the one genuinely per-pixel operation on the nudge path + and it costs the same for every angle, so a full rebuild makes an arrow + keypress N times more expensive than it needs to be. Each angle's affine + depends on its own parameters plus the shared canvas, so if the canvas + came back identical the other layers provably did not move and only the + nudged one is redrawn; if the canvas did shift, every angle's affine + changed with it and the hint is ignored. """ st = self.state if st.result is None or not st.masks_small: return fy, fx = st.downsample n_rows, n_cols = st.result.canvas_shape - st.preview_pitch_mm = (st.result.canvas_dx_mm * fx, - st.result.canvas_dy_mm * fy) - st.preview_shape = (max(1, -(-n_rows // fy)), max(1, -(-n_cols // fx))) + pitch = (st.result.canvas_dx_mm * fx, st.result.canvas_dy_mm * fy) + shape = (max(1, -(-n_rows // fy)), max(1, -(-n_cols // fx))) + + incremental = (only_angle is not None + and st.counts is not None + and len(st.layers) == self._sras.n_angles + and pitch == st.preview_pitch_mm + and shape == st.preview_shape + and st.result.canvas_origin_mm == self._stack_origin_mm) + st.preview_pitch_mm, st.preview_shape = pitch, shape + self._stack_origin_mm = st.result.canvas_origin_mm + + if incremental: + before = st.layers[only_angle] > 0.5 + layer = self._reproject(only_angle) + st.layers[only_angle] = layer + # Exact integer arithmetic on booleans, so this cannot drift from + # what a full rebuild would have produced. + st.counts += (layer > 0.5).astype(np.int16) - before.astype(np.int16) + return st.layers = {} - counts = np.zeros(st.preview_shape, dtype=np.int16) + counts = np.zeros(shape, dtype=np.int16) for a in range(self._sras.n_angles): - p = st.params[a] - layer = compute.reproject_mask( - self._sras, a, st.ref_angle_idx, st.masks_small[a], - p.rotation_deg, p.shift_mm, st.preview_pitch_mm, - st.result.canvas_origin_mm, st.preview_shape, - src_downsample=st.downsample) + layer = self._reproject(a) st.layers[a] = layer counts += (layer > 0.5).astype(np.int16) st.counts = counts + def cropped_plan(self) -> tuple[compute.AlignmentResult | None, + export.ExportPlan | None]: + """The current crop's AlignmentResult and ExportPlan, or (None, None) + before a crop exists. + + Both are pure functions of state.result and state.crop, so neither is + stored as its own piece of state. They are memoized instead, because + plan_export counts every output pixel of every angle (hundreds of + milliseconds on a full-size scan) and the ROI readout, that page's + validate step and the save page's summary all ask for the same crop. + """ + st = self.state + if st.result is None or st.crop is None: + return None, None + cached = self._plan_cache + if cached is not None and cached[0] is st.result and cached[1] == st.crop: + return cached[2], cached[3] + cropped = compute.crop_alignment_result(st.result, *st.crop) + plan = export.plan_export(self._sras, cropped) + self._plan_cache = (st.result, st.crop, cropped, plan) + return cropped, plan + def preview_extent(self) -> list[float]: st = self.state x0, y0 = st.result.canvas_origin_mm @@ -243,6 +294,15 @@ class AlignmentWizard(QWizard): return (x0 + col * st.result.canvas_dx_mm, y0 + row * st.result.canvas_dy_mm) + def run_worker(self, key: str, worker, **kwargs) -> bool: + """Start a background job on the main window's registry. Returns False + if another job already holds *key*, which callers must not ignore. + + The pages go through here rather than reaching into the parent window + themselves, so what the wizard actually needs from its parent — a job + runner — is stated in one place instead of at every launch site.""" + return self._parent._run_worker(key, worker, **kwargs) + def job_running(self) -> bool: return any(self._parent._job_running(k) for k in (Jobs.ALIGN_MASKS, Jobs.ALIGN_CORRELATE, Jobs.ALIGN_EXPORT)) @@ -277,8 +337,8 @@ class AlignmentWizard(QWizard): super().closeEvent(event) def accept(self): - st = self.state - self.alignment_ready.emit(st.cropped_result, st.exported_path) + cropped, _ = self.cropped_plan() + self.alignment_ready.emit(cropped, self.state.exported_path) super().accept() def maybe_close_after_job(self): @@ -510,7 +570,7 @@ class CorrelatePage(QWizardPage): self.lbl_status.setText( f"Preparing masks: 0/{len(missing)} angle(s) needed…") worker = Ch4MaskWorker(self._sras, missing) - started = self._wiz._parent._run_worker( + started = self._wiz.run_worker( Jobs.ALIGN_MASKS, worker, connect=(("angle_done", self._on_mask_done), ("error", lambda m: self.lbl_status.setText( @@ -549,10 +609,11 @@ class CorrelatePage(QWizardPage): st.downsample = (max(1, -(-max_rows // _MAX_PREVIEW_DIM)), max(1, -(-max_cols // _MAX_PREVIEW_DIM))) - st.params = self._wiz.seed_params() self._recompute_masks() self._masks_ready = True self._set_controls_enabled(True) + # Sets st.params for every angle, so it is the single source of the + # starting parameters — nothing needs to seed them beforehand. self._apply_prerotation() self.lbl_status.setText("Ready.") self.completeChanged.emit() @@ -613,12 +674,27 @@ class CorrelatePage(QWizardPage): st.fits = {} self._refresh() - def _refresh(self): - """Rebuild result, stack and every readout from the current params.""" + def _stage_drift_deg(self, angle_idx: int) -> float: + """How far this angle's rotation has ended up from the stage angle the + file reports. Both signs are allowed: the stage's rotational sense + relative to this module's convention is not knowable from the file, + so the closer of the two is the honest comparison.""" + st = self._wiz.state + nominal = compute.nominal_delta_deg(self._sras, angle_idx, + st.ref_angle_idx) + got = st.params[angle_idx].rotation_deg + return min(abs(got - nominal), abs(got + nominal)) + + def _refresh(self, only_angle: int | None = None): + """Rebuild result, stack and every readout from the current params. + + *only_angle* is passed through to rebuild_stack, which uses it to skip + reprojecting angles that cannot have moved; callers that changed more + than one angle's parameters must leave it None.""" st = self._wiz.state self._wiz.rebuild_result() st.geometry_generation += 1 - self._wiz.rebuild_stack() + self._wiz.rebuild_stack(only_angle) self._sync_spins() self._update_table() self._redraw() @@ -626,8 +702,14 @@ class CorrelatePage(QWizardPage): # ---- correlation -------------------------------------------------- def _reg_kwargs(self) -> dict: + """Every argument register_angle_to_reference takes from this page, in + one dict — so "lock rotation" can express all of what it means in one + place instead of half here and half at the call site.""" seed, signs, _disp = self.combo_prerotate.currentData() kwargs = { + "sources": self.combo_source.currentData(), + "dc_threshold_mv": self._wiz.state.threshold_mv, + "search_deg": self.spin_search_deg.value(), "seed_deg": seed, "seed_signs": signs, "coarse_step_deg": self.spin_coarse_step.value(), @@ -637,6 +719,7 @@ class CorrelatePage(QWizardPage): # Exactly one candidate, no hill-climb: rotation is the seed and # only the translation is searched. kwargs["refine"] = False + kwargs["search_deg"] = 0.0 if len(signs) > 1: kwargs["seed_signs"] = (1,) return kwargs @@ -650,12 +733,8 @@ class CorrelatePage(QWizardPage): self.lbl_status.setText("Only one angle — nothing to correlate.") return - search = 0.0 if self.chk_lock_rotation.isChecked() \ - else self.spin_search_deg.value() worker = CrossCorrelateWorker( self._sras, st.ref_angle_idx, angles, st.dc4_mv, - sources=self.combo_source.currentData(), - dc_threshold_mv=st.threshold_mv, search_deg=search, reg_kwargs=self._reg_kwargs()) # Claim busy and disable the trigger *before* _run_worker, never after: @@ -669,7 +748,7 @@ class CorrelatePage(QWizardPage): self.progress.setValue(0) self.lbl_status.setText(f"Cross-correlating: 0/{self._total} angle(s)…") - started = self._wiz._parent._run_worker( + started = self._wiz.run_worker( Jobs.ALIGN_CORRELATE, worker, connect=(("angle_done", self._on_angle_done), ("error", lambda m: self.lbl_status.setText( @@ -728,16 +807,12 @@ class CorrelatePage(QWizardPage): "are being treated as unrotated — lower the DC threshold, try " "Raw signal, nudge them by hand, or drop them with " "sras_edit_scans.py.") - drifted = [] - for a, _ in rows: - nominal = compute.nominal_delta_deg(self._sras, a, st.ref_angle_idx) - got = st.params[a].rotation_deg - dev = min(abs(got - nominal), abs(got + nominal)) - if dev > 1.0: - drifted.append(f"{a} ({dev:.2f}°)") + drifted = [f"{a} ({dev:.2f}°)" for a, _ in rows + if (dev := self._stage_drift_deg(a)) > _DRIFT_WARN_DEG] if drifted: - parts.append("Rotation differs from the stage angle by >1° for " - "angle(s) " + ", ".join(drifted) + ".") + parts.append(f"Rotation differs from the stage angle by " + f">{_DRIFT_WARN_DEG:g}° for angle(s) " + + ", ".join(drifted) + ".") return " ".join(parts) # ---- manual nudging ---------------------------------------------- @@ -767,7 +842,7 @@ class CorrelatePage(QWizardPage): self._wiz.state.params[self._active_angle] = ManualAngleParams( self.spin_rot.value(), (self.spin_shift_x.value(), self.spin_shift_y.value())) - self._refresh() + self._refresh(self._active_angle) def _on_nudge_translate(self, dir_x: int, dir_y: int, coarse: bool): if not self._masks_ready or self._active_angle == self._wiz.state.ref_angle_idx: @@ -779,7 +854,7 @@ class CorrelatePage(QWizardPage): self._wiz.state.params[self._active_angle] = ManualAngleParams( p.rotation_deg, (p.shift_mm[0] + dir_x * step, p.shift_mm[1] + dir_y * step)) - self._refresh() + self._refresh(self._active_angle) def _on_nudge_rotate(self, direction: int, coarse: bool): if not self._masks_ready or self._active_angle == self._wiz.state.ref_angle_idx: @@ -790,7 +865,7 @@ class CorrelatePage(QWizardPage): p = self._wiz.state.params[self._active_angle] self._wiz.state.params[self._active_angle] = ManualAngleParams( p.rotation_deg + direction * step, p.shift_mm) - self._refresh() + self._refresh(self._active_angle) # ---- drawing ------------------------------------------------------ @@ -868,9 +943,8 @@ class CorrelatePage(QWizardPage): st = self._wiz.state for a in range(self._sras.n_angles): score, source = st.fits.get(a, (float("nan"), "")) - nominal = compute.nominal_delta_deg(self._sras, a, st.ref_angle_idx) got = st.params[a].rotation_deg - dev = min(abs(got - nominal), abs(got + nominal)) + dev = self._stage_drift_deg(a) is_ref = a == st.ref_angle_idx cells = [ f"{a}" + (" [ref]" if is_ref else ""), @@ -959,6 +1033,10 @@ class RoiPage(QWizardPage): (self.spin_rows, "Rows:")): spin.setRange(0, 1) spin.setMinimumWidth(96) + # Each committed edit recounts coverage over the whole crop, so + # only commit on Enter/focus-out rather than on every digit typed + # into a four-figure column count. + spin.setKeyboardTracking(False) form.addRow(label, spin) rl.addLayout(form) self.btn_draw = QPushButton("Draw a new rectangle") @@ -1008,24 +1086,23 @@ class RoiPage(QWizardPage): with QSignalBlocker(spin): spin.setRange(lo, max(lo, hi)) self._draw_counts() + # largest_rect_at_least returns None iff no pixel qualifies, so the + # .any() answers the enable question without running its O(rows*cols) + # Python sweep on the GUI thread just to throw the rectangle away. self.btn_fit_overlap.setEnabled( - compute.largest_rect_at_least(st.counts, self._sras.n_angles) - is not None) + bool((st.counts >= self._sras.n_angles).any())) if st.crop is None: self._on_fit_union() else: self._set_crop(*st.crop) def isComplete(self) -> bool: - crop = self._wiz.state.crop - return crop is not None and crop[2] >= 1 and crop[3] >= 1 + # _set_crop is the only writer and clamps both extents to >= 1. + return self._wiz.state.crop is not None def validatePage(self) -> bool: - st = self._wiz.state - cropped = compute.crop_alignment_result(st.result, *st.crop) - plan = export.plan_export(self._sras, cropped) - - empty = [a for a in range(self._sras.n_angles) if plan.valid_px[a] == 0] + _, plan = self._wiz.cropped_plan() + empty = plan.empty_angles() if len(empty) == self._sras.n_angles: QMessageBox.warning( self, "Empty crop", @@ -1039,14 +1116,11 @@ class RoiPage(QWizardPage): f"this crop and would be written as padding.\n\nContinue anyway?") if answer != QMessageBox.StandardButton.Yes: return False - - st.cropped_result = cropped return True def cleanupPage(self): """Going back means the canvas geometry may change under this crop.""" self._wiz.state.crop = None - self._wiz.state.cropped_result = None # ---- drawing / presets -------------------------------------------- @@ -1062,30 +1136,19 @@ class RoiPage(QWizardPage): f"Overlap count — drag the crop rectangle ({n} angles)", colorbar_label="angles overlapping", cb_ticks=ticks, norm=norm) - def _coarse_rect_to_canvas(self, rect) -> tuple[int, int, int, int]: - """A rectangle in coarse preview indices, as final canvas pixels. - - Inset by one coarse block on each side. The coarse grid samples canvas - pixels 0, fx, 2fx, …, so a coarse pixel reported as fully covered - stands for a block whose far edge may not be; shrinking by a block keeps - a "fit to full overlap" rectangle honestly inside the overlap region. - """ + def _coarse_rect_to_canvas(self, rect, *, inset_blocks: int + ) -> tuple[int, int, int, int]: st = self._wiz.state - fy, fx = st.downsample - row0, col0, nr, nc = rect - n_rows, n_cols = st.result.canvas_shape - r0 = min(n_rows - 1, row0 * fy + fy) - c0 = min(n_cols - 1, col0 * fx + fx) - nr_c = max(1, min(n_rows - r0, nr * fy - 2 * fy)) - nc_c = max(1, min(n_cols - c0, nc * fx - 2 * fx)) - return r0, c0, nr_c, nc_c + return compute.coarse_rect_to_canvas(rect, st.downsample, + st.result.canvas_shape, + inset_blocks=inset_blocks) def _on_fit_overlap(self): st = self._wiz.state rect = compute.largest_rect_at_least(st.counts, self._sras.n_angles) if rect is None: return - self._set_crop(*self._coarse_rect_to_canvas(rect)) + self._set_crop(*self._coarse_rect_to_canvas(rect, inset_blocks=1)) def _on_fit_union(self): st = self._wiz.state @@ -1093,13 +1156,9 @@ class RoiPage(QWizardPage): if rows.size == 0: self._on_whole() return - fy, fx = st.downsample - n_rows, n_cols = st.result.canvas_shape - r0 = int(rows.min()) * fy - c0 = int(cols.min()) * fx - r1 = min(n_rows, (int(rows.max()) + 1) * fy) - c1 = min(n_cols, (int(cols.max()) + 1) * fx) - self._set_crop(r0, c0, max(1, r1 - r0), max(1, c1 - c0)) + r0, c0 = int(rows.min()), int(cols.min()) + rect = (r0, c0, int(rows.max()) - r0 + 1, int(cols.max()) - c0 + 1) + self._set_crop(*self._coarse_rect_to_canvas(rect, inset_blocks=0)) def _on_whole(self): n_rows, n_cols = self._wiz.state.result.canvas_shape @@ -1171,14 +1230,13 @@ class RoiPage(QWizardPage): f"X {min(x0, x1):.3f} … {max(x0, x1):.3f} mm " f"Y {min(y0, y1):.3f} … {max(y0, y1):.3f} mm") - cropped = compute.crop_alignment_result(st.result, *st.crop) - plan = export.plan_export(self._sras, cropped) + _, plan = self._wiz.cropped_plan() self.lbl_size.setText(f"Estimated file size: {_humanize(plan.total_bytes)}" f" ({self._sras.n_angles} angles)") self.lbl_coverage.setText("Real data per angle: " + ", ".join( f"{a}: {plan.coverage_frac(a) * 100:.0f}%" for a in range(self._sras.n_angles))) - empty = [a for a in range(self._sras.n_angles) if plan.valid_px[a] == 0] + empty = plan.empty_angles() self.lbl_warn.setText( f"Angle(s) {', '.join(map(str, empty))} have no data here and would " f"be written as padding." if empty else "") @@ -1276,17 +1334,16 @@ class SavePage(QWizardPage): # ---- summary ------------------------------------------------------ def _update_summary(self): - st = self._wiz.state - plan = export.plan_export(self._sras, st.cropped_result) - x0, y0 = st.cropped_result.canvas_origin_mm + cropped, plan = self._wiz.cropped_plan() + x0, y0 = cropped.canvas_origin_mm self.lbl_summary.setText( f"Format: .sras v6, no precomputed cache (the viewer recomputes " f"DC/FFT on first open).\n" f"Angles: {plan.n_angles}, all sharing one grid of " f"{plan.n_frames:,} × {plan.n_rows:,} px.\n" f"Origin: X {x0:.4f} mm, Y {y0:.4f} mm · " - f"pitch {st.cropped_result.canvas_dx_mm * 1000:.2f} × " - f"{abs(st.cropped_result.canvas_dy_mm) * 1000:.2f} µm.\n" + f"pitch {cropped.canvas_dx_mm * 1000:.2f} × " + f"{abs(cropped.canvas_dy_mm) * 1000:.2f} µm.\n" f"Stage angles, calibration preambles and the background waveform " f"are carried over unchanged.\n" f"Size: {_humanize(plan.bytes_per_angle)} per angle, " @@ -1317,7 +1374,8 @@ class SavePage(QWizardPage): if self._busy or not st.out_path: return - worker = AlignedExportWorker(self._sras, st.cropped_result, st.out_path) + cropped, _ = self._wiz.cropped_plan() + worker = AlignedExportWorker(self._sras, cropped, st.out_path) self._busy = True self.completeChanged.emit() self.btn_export.setEnabled(False) @@ -1325,7 +1383,7 @@ class SavePage(QWizardPage): self.progress.setValue(0) self.lbl_status.setText(f"Writing {Path(st.out_path).name}…") - started = self._wiz._parent._run_worker( + started = self._wiz.run_worker( Jobs.ALIGN_EXPORT, worker, connect=(("progress", self.progress.setValue), ("finished", self._on_export_finished))) @@ -1370,29 +1428,6 @@ class SavePage(QWizardPage): _SNAP_TOL = 1e-6 -def _combo(items) -> QComboBox: - """A combo box whose size hint does not depend on its longest entry. - - By default a QComboBox asks for enough width to show its widest item. These - hold descriptive phrases, and the panel lives in a fixed-width scroll area - with the horizontal scrollbar off (`_scroll_panel`) — so an unconstrained - hint pushes the inner widget past the panel and everything on the right, - including the hint text, is silently clipped instead of scrolling. - - *items* is a sequence of (label, data) pairs, or of plain labels. - """ - combo = QComboBox() - combo.setSizeAdjustPolicy( - QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) - combo.setMinimumContentsLength(10) - for item in items: - if isinstance(item, tuple): - combo.addItem(item[0], item[1]) - else: - combo.addItem(item) - return combo - - def _snap_edge(coord: float) -> int: """A fractional pixel-index edge, as the nearest pixel boundary index. diff --git a/sras_viewer/common.py b/sras_viewer/common.py index 7d957f6..ad587e9 100644 --- a/sras_viewer/common.py +++ b/sras_viewer/common.py @@ -2,8 +2,8 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( - QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QLabel, QScrollArea, - QSizePolicy, QVBoxLayout, QWidget, + QComboBox, QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QLabel, + QScrollArea, QSizePolicy, QVBoxLayout, QWidget, ) from sras_format import CH1_IDX, CH3_IDX, CH4_IDX @@ -85,6 +85,29 @@ def _make_dspin(lo: float, hi: float, decimals: int, *, suffix: str = "", return spin +def _combo(items=(), *, min_chars: int = 10) -> QComboBox: + """A combo box whose size hint does not depend on its longest entry. + + By default a QComboBox asks for enough width to show its widest item. These + hold descriptive phrases, and the side panels are fixed-width — in a scroll + area with the horizontal scrollbar off (`_scroll_panel`) an unconstrained + hint pushes the inner widget past the panel and everything on the right, + including the hint text, is silently clipped instead of scrolling. + + *items* is a sequence of (label, data) pairs, or of plain labels. + """ + combo = QComboBox() + combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) + combo.setMinimumContentsLength(min_chars) + for item in items: + if isinstance(item, tuple): + combo.addItem(item[0], item[1]) + else: + combo.addItem(item) + return combo + + def _axes_extent(x_axis, y_axis, dx: float, dy: float) -> list[float]: """Matplotlib imshow extent with half-pixel margins, Y flipped so row 0 renders at the top.""" diff --git a/sras_viewer/main_window.py b/sras_viewer/main_window.py index d37f4f9..7345064 100644 --- a/sras_viewer/main_window.py +++ b/sras_viewer/main_window.py @@ -30,7 +30,8 @@ from .canvases import ImageCanvas, WaveformCanvas from .common import ( CH1_DERIVED_MODES, CH_LABELS, CMAPS, VELOCITY_MODE_IDX, _CHANNEL_DISPLAY, _CSS_BUSY, _axes_extent, _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, _LEFT_PANEL_W, - _RIGHT_PANEL_W, Jobs, _form, _group, _make_dspin, _scroll_panel, _wrap_label, + _RIGHT_PANEL_W, Jobs, _combo, _form, _group, _make_dspin, _scroll_panel, + _wrap_label, ) from .align_wizard import AlignmentWizard from .dialogs import FftOptionsDialog, RowAverageFftOptionsDialog @@ -97,7 +98,6 @@ class SrasViewerWindow(QMainWindow): # Angle alignment ("Fusion" menu) self._alignment_result = None - self._alignment_generation: int = 0 self._aligned_cache: dict[tuple, np.ndarray] = {} self._align_wizard: AlignmentWizard | None = None @@ -242,14 +242,10 @@ class SrasViewerWindow(QMainWindow): ar.addStretch() view_form.addRow("Angle:", angle_field) - self.combo_channel = QComboBox() - self.combo_channel.addItems(CH_LABELS) + self.combo_channel = _combo(CH_LABELS, min_chars=12) self.combo_channel.setEnabled(False) self.combo_channel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.combo_channel.setSizeAdjustPolicy( - QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) - self.combo_channel.setMinimumContentsLength(12) self.combo_channel.currentIndexChanged.connect(self._on_channel_changed) view_form.addRow("Channel:", self.combo_channel) vl.addLayout(view_form) @@ -632,7 +628,8 @@ class SrasViewerWindow(QMainWindow): back through a real FFT — worth saying out loud rather than leaving the user to wonder why a file they batch-computed got slow. - Deliberately mirrors cached_rf_image's accept rule; the display + Asks compute for the reasons rather than restating the accept rule, + so a new provenance field can only be added in one place. The display always asks for a raw per-pixel image, since row-averaging is a batch option with no display control. """ @@ -640,17 +637,9 @@ class SrasViewerWindow(QMainWindow): if s is None or all(x is None for x in s.precomputed_freq_mhz): return [] - reasons = [] - if compute.pad_factor_for(s, self._current_n_fft()) != s.precomputed_pad_factor: - reasons.append(f"stored at pad {s.precomputed_pad_factor}x, " - f"viewing at pad {self._fft_pad_factor}x") - if s.precomputed_bg_sub != (self.chk_bg_sub.isChecked() - and s.background is not None): - reasons.append("stored with background subtraction " - f"{'on' if s.precomputed_bg_sub else 'off'}") - if s.precomputed_row_avg_n: - reasons.append(f"stored row-averaged (n={s.precomputed_row_avg_n}), " - "the display shows raw per-pixel FFTs") + reasons = compute.cache_mismatch_reasons( + s, n_fft=self._current_n_fft(), + apply_bg_sub=self.chk_bg_sub.isChecked(), row_avg_n=0) if not reasons: return [] return ["! Cached FFT unusable for this view — " + "; ".join(reasons) @@ -1358,21 +1347,19 @@ class SrasViewerWindow(QMainWindow): ref_idx = 0 threshold_mv = self.spin_threshold_mv.value() - seed: dict[int, ManualAngleParams] = {} - # Seed only from a previously *saved* alignment, never from - # self._alignment_result: the wizard's first page starts every angle - # pre-rotated from the stage angles and expects to own the parameters - # from there, so inheriting a half-edited in-memory state would make - # "Reset to pre-rotation only" mean something different each time. + # Only the threshold carries over from a saved alignment. The wizard's + # first page starts every angle pre-rotated from the stage angles and + # owns the parameters from there, so inheriting saved per-angle values + # would make "Reset to pre-rotation only" mean something different + # each time. sidecar = load_manual_alignment(self._sras) if sidecar is not None and sidecar.ref_angle_idx == ref_idx: - seed = dict(sidecar.per_angle) threshold_mv = sidecar.dc_threshold_mv cached_dc4 = {a: img for (a, ch), img in self._dc_cache.items() if ch == CH4_IDX} wiz = AlignmentWizard( self, self._sras, ref_angle_idx=ref_idx, dc_threshold_mv=threshold_mv, - seed_per_angle=seed, cached_dc4_mv=cached_dc4) + cached_dc4_mv=cached_dc4) wiz.alignment_ready.connect(self._on_wizard_finished) wiz.finished.connect(self._on_wizard_closed) wiz.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) @@ -1411,15 +1398,12 @@ class SrasViewerWindow(QMainWindow): if self._current_image is not None: self._refresh_display() - def _apply_alignment_result(self, result, *, view_checked: bool, - bump_generation: bool = True): + def _apply_alignment_result(self, result, *, view_checked: bool): """Install (or clear, with result=None) the active alignment: reset the aligned-image cache and set the Aligned View checkbox without firing its change signal.""" self._alignment_result = result self._aligned_cache = {} - if bump_generation: - self._alignment_generation += 1 with QSignalBlocker(self.chk_aligned_view): self.chk_aligned_view.setChecked(view_checked) self.chk_aligned_view.setEnabled(result is not None) diff --git a/sras_workers.py b/sras_workers.py index bacf896..d3761f7 100644 --- a/sras_workers.py +++ b/sras_workers.py @@ -361,21 +361,17 @@ class CrossCorrelateWorker(_PooledWorker): angle_done = pyqtSignal(int, float, float, float, float, str) def __init__(self, sras: SrasFile, ref_angle_idx: int, angle_indices: list[int], - dc4_mv: dict[int, np.ndarray], *, - sources: tuple[str, ...], dc_threshold_mv: float, - search_deg: float, reg_kwargs: dict | None = None): - """*reg_kwargs* is splatted into register_angle_to_reference on top of - the named arguments — the wizard's rotation-search controls (seed, - signs, refine, grid sizes) go through here, so exposing another knob - needs no change to this class.""" + dc4_mv: dict[int, np.ndarray], *, reg_kwargs: dict | None = None): + """*reg_kwargs* is splatted into register_angle_to_reference — every + registration setting the wizard exposes (sources, threshold, search + width, seed, signs, refine, grid sizes) travels in it, so this class + holds no opinion about which knobs exist and exposing another needs no + change here.""" super().__init__() self._sras = sras self._ref = ref_angle_idx self._angles = angle_indices self._dc4_mv = dc4_mv - self._sources = sources - self._threshold = dc_threshold_mv - self._search_deg = search_deg self._reg_kwargs = dict(reg_kwargs or {}) def _plan(self) -> int: @@ -386,9 +382,7 @@ class CrossCorrelateWorker(_PooledWorker): def _one(self, a: int) -> tuple[int, compute.RigidFit]: return a, compute.register_angle_to_reference( - self._sras, a, self._ref, self._dc4_mv, - dc_threshold_mv=self._threshold, sources=self._sources, - search_deg=self._search_deg, **self._reg_kwargs) + self._sras, a, self._ref, self._dc4_mv, **self._reg_kwargs) def _emit(self, result): a, fit = result diff --git a/tests/test_gui.py b/tests/test_gui.py index c6f0c57..5624e6a 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -392,6 +392,14 @@ def test_wizard_nudges(ctx): assert len(wiz.state.layers) == s.n_angles, \ "stack rebuilt for every angle after a rotation nudge" + # A nudge only reprojects the angle that moved, patching the overlap counts + # in place. That shortcut is only sound if it lands on exactly what a full + # rebuild would have produced. + incremental = wiz.state.counts.copy() + wiz.rebuild_stack() + assert np.array_equal(wiz.state.counts, incremental), \ + "incremental nudge update matches a full stack rebuild" + # Real key-event wiring (keyPressEvent -> signal -> slot). before = wiz.state.params[active].shift_mm QTest.keyClick(p1.canvas, Qt.Key.Key_Right) @@ -529,7 +537,8 @@ def test_wizard_crop_dropped_when_going_back(ctx): pump(120) assert wiz.currentId() == wiz.PAGE_CORRELATE assert wiz.state.crop is None, "cleanupPage discarded the stale crop" - assert wiz.state.cropped_result is None + assert wiz.cropped_plan() == (None, None), \ + "nothing derived from the dropped crop survives either" wiz.next() pump(150) assert wiz.state.crop is not None, "a fresh default crop is offered again" @@ -546,8 +555,9 @@ def test_wizard_export(ctx): pump(150) assert wiz.currentId() == wiz.PAGE_SAVE, "advanced to the save page" ctx.p3 = p3 = wiz.page(wiz.PAGE_SAVE) - assert wiz.state.cropped_result is not None, "crop applied on leaving page 2" - assert wiz.state.cropped_result.canvas_shape == ctx.crop[2:], \ + cropped, _ = wiz.cropped_plan() + assert cropped is not None, "crop applied on leaving page 2" + assert cropped.canvas_shape == ctx.crop[2:], \ "cropped result carries the chosen shape" assert not p3.isComplete(), "Finish unavailable before anything is written" assert p3.lbl_summary.text(), "a summary of what will be written is shown"