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:
+156
-121
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user