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 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-08-09 14:30:02 -05:00
parent 8348ad313c
commit c30c8b1815
8 changed files with 392 additions and 269 deletions
+156 -121
View File
@@ -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.
+25 -2
View File
@@ -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."""
+15 -31
View File
@@ -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)