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
+29 -58
View File
@@ -81,19 +81,22 @@ class ExportPlan:
px = self.n_rows * self.n_frames px = self.n_rows * self.n_frames
return (self.valid_px.get(angle_idx, 0) / px) if px else 0.0 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]: def _src_coords(t, rows, n_cols: int) -> tuple[np.ndarray, np.ndarray]:
"""Fractional source (row, col) coordinates for whole output rows. """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 *rows* is an array of output row indices; both results are shaped
(len(rows), n_cols), or (n_cols,) for a scalar. (len(rows), n_cols).
""" """
cols = np.arange(n_cols, dtype=np.float64) 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] 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] 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 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 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 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 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. half-integers wherever its row pitch differs from the reference's.
""" """
return np.floor(coord + 0.5).astype(np.int64) 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 bytes_per_angle = (n_rows * sras.n_channels * n_cols
* sras.samples_per_frame * sras.bytes_per_sample) * 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: if n_rows > _MAX_ROWS:
warnings.append( warnings.append(
f"Crop is {n_rows} rows; the .sras geometry table caps rows at " 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: if n_cols > _MAX_FRAMES:
warnings.append(f"Crop is {n_cols} frames; the cap is {_MAX_FRAMES}.") warnings.append(f"Crop is {n_cols} frames; the cap is {_MAX_FRAMES}.")
for a in range(n_angles): 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: if frac == 0.0:
warnings.append( warnings.append(
f"Angle {a} has no data inside this crop — it will be written " 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.") "is written, which makes background subtraction a no-op.")
if sras.version != 6: if sras.version != 6:
warnings.append(f"Input is v{sras.version}; the export is written as v6.") 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( warnings.append(
f"Input scan was aborted: only its {n_angles} complete angle(s) " f"Input scan was aborted: only its {n_angles} complete angle(s) "
f"are exported.") f"are exported.")
return ExportPlan(n_rows=n_rows, n_frames=n_cols, n_angles=n_angles, return plan
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()
def _fill_row(sras: SrasFile, n_cols: int, dtype) -> np.ndarray: 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, 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 """Write *sras*, aligned per *result* and cropped to its canvas, to a new
v6 .sras file. Returns the path written. 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; # Reference-only header fields. v6/v7 inputs have real ones to carry over;
# for a legacy input describe the canvas we are actually writing. # 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, nominal = (sras.x_start_nominal_mm, sras.y_start_nominal_mm,
sras.x_delta_nominal_mm, sras.y_delta_nominal_mm, sras.x_delta_nominal_mm, sras.y_delta_nominal_mm,
sras.row_spacing_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), geo = struct.pack(GEO_FMT_V6, float(x0_mm), float(sras.pixel_x_mm),
int(n_cols), int(n_rows)) * n_angles 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)) total_chunks = max(1, n_angles * ((n_rows + _ROW_CHUNK - 1) // _ROW_CHUNK))
done_chunks = 0 done_chunks = 0
cancelled = False 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(sras.angles_deg.astype(">f4").tobytes())
fout.write(geo) fout.write(geo)
fout.write(y_rows.tobytes() * n_angles) fout.write(y_rows.tobytes() * n_angles)
fout.write(_encode_preambles(sras)) fout.write(sras.encoded_preambles())
fout.write(_encode_background(sras)) fout.write(sras.encoded_background())
pad = _fill_row(sras, n_cols, dtype) pad = _fill_row(sras, n_cols, dtype)
for a in range(n_angles): for a in range(n_angles):
@@ -461,7 +429,10 @@ def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path,
out[:, keep, :] = band[ out[:, keep, :] = band[
idx_r[i][keep] - base, :, idx_c[i][keep], : idx_r[i][keep] - base, :, idx_c[i][keep], :
].transpose(1, 0, 2) ].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 done_chunks += 1
if progress_cb is not None: if progress_cb is not None:
progress_cb(int(done_chunks / total_chunks * 100)) progress_cb(int(done_chunks / total_chunks * 100))
+96 -41
View File
@@ -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) _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) _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, def _chunk_rows_for(n_frames: int, samples_per_frame: int,
budget: int = _TOTAL_BYTES_BUDGET) -> int: budget: int = _TOTAL_BYTES_BUDGET) -> int:
bytes_per_row = max(1, n_frames * samples_per_frame * 4) # float32 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) 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, def cached_rf_image(sras: SrasFile, angle_idx: int,
dc_threshold_mv: float | None, dc_threshold_mv: float | None,
apply_bg_sub: bool = True, 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. fall through to a real compute rather than block.
""" """
cached_freq = sras.precomputed_freq_mhz[angle_idx] cached_freq = sras.precomputed_freq_mhz[angle_idx]
if (cached_freq is None if cached_freq is None or cache_mismatch_reasons(
or pad_factor_for(sras, n_fft) != sras.precomputed_pad_factor sras, n_fft=n_fft, apply_bg_sub=apply_bg_sub, row_avg_n=row_avg_n):
or sras.precomputed_bg_sub != (apply_bg_sub and sras.background is not None)
or sras.precomputed_row_avg_n != row_avg_n):
return None return None
freq_img = cached_freq.copy() dc4_img = None
if dc_threshold_mv is not None: if dc_threshold_mv is not None:
# DC4 mask, in priority order: already-cached DC block, caller- # 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) dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
if dc4_img is None: if dc4_img is None:
if dc4_mv is not None: if dc4_mv is not None:
@@ -554,6 +591,8 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
*sras.cal(CH4_IDX)) *sras.cal(CH4_IDX))
else: else:
return None return None
freq_img = cached_freq.copy()
if dc4_img is not None:
freq_img[dc4_img < dc_threshold_mv] = 0.0 freq_img[dc4_img < dc_threshold_mv] = 0.0
return freq_img return freq_img
@@ -778,6 +817,9 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
try: try:
if mode not in ("dc", "fft", "fft_rowavg"): if mode not in ("dc", "fft", "fft_rowavg"):
return f"unknown cache mode {mode!r} (expected 'dc', 'fft', or '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): if not (1 <= pad_factor <= MAX_PAD_FACTOR):
return f"pad_factor must be 1-{MAX_PAD_FACTOR}, got {pad_factor}" return f"pad_factor must be 1-{MAX_PAD_FACTOR}, got {pad_factor}"
set_fft_backend(fft_backend) set_fft_backend(fft_backend)
@@ -1492,24 +1534,17 @@ def register_angle_to_reference(
def canvas_for_params(sras: SrasFile, ref_angle_idx: int, def canvas_for_params(sras: SrasFile, ref_angle_idx: int,
pitch_mm: tuple[float, float], pitch_mm: tuple[float, float],
per_angle_params: dict[int, ManualAngleParams], per_angle_params: dict[int, ManualAngleParams],
*, margin_frac: float = 0.0, snap: bool = True
) -> tuple[tuple[float, float], tuple[int, int]]: ) -> tuple[tuple[float, float], tuple[int, int]]:
"""Shared-canvas origin (stage mm) and (n_rows, n_cols) at *pitch_mm* that """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 contains every angle's footprint after its own rigid transform. Angles
missing from per_angle_params default to identity (e.g. a sidecar saved missing from per_angle_params default to identity (e.g. a sidecar saved
before a rescan added more angles). before a rescan added more angles).
snap=True aligns the canvas grid with the reference angle's own pixel grid, The canvas grid is aligned with the reference angle's own pixel grid, so
so the reference lands on integer canvas pixels and is resampled by an the reference lands on integer canvas pixels and is resampled by an exact
exact integer translation — the concrete meaning of "the canvas carries integer translation — the concrete meaning of "the canvas carries angle
angle 0's X/Y coordinates". It requires pitch_mm to be the reference's own 0's X/Y coordinates". This requires pitch_mm to be the reference's own
pitch; the manual-alignment preview passes a coarser pitch and snap=False. pitch.
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).
""" """
dx, dy = pitch_mm dx, dy = pitch_mm
corners = np.vstack([ corners = np.vstack([
@@ -1520,27 +1555,17 @@ def canvas_for_params(sras: SrasFile, ref_angle_idx: int,
for a in range(sras.n_angles)]) for a in range(sras.n_angles)])
x_min, y_min = corners.min(axis=0) x_min, y_min = corners.min(axis=0)
x_max, y_max = corners.max(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) center = ref_center_mm(sras, ref_angle_idx)
if snap: # Express the box in the reference's own pixel indices and grow it
# 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
# outward to whole pixels, so canvas index k lands exactly where the # reference's own pixel (k + const) does.
# reference's own pixel (k + const) does. cy, cx = _center_idx(sras, ref_angle_idx)
cy, cx = _center_idx(sras, ref_angle_idx) cols = sorted((x_min / dx + cx, x_max / dx + cx))
cols = sorted((x_min / dx + cx, x_max / dx + cx)) rows = sorted((y_min / dy + cy, y_max / dy + cy))
rows = sorted((y_min / dy + cy, y_max / dy + cy)) col0, col1 = int(np.floor(cols[0])), int(np.ceil(cols[1]))
col0, col1 = int(np.floor(cols[0])), int(np.ceil(cols[1])) row0, row1 = int(np.floor(rows[0])), int(np.ceil(rows[1]))
row0, row1 = int(np.floor(rows[0])), int(np.ceil(rows[1])) origin_ref = np.array([(col0 - cx) * dx, (row0 - cy) * dy])
origin_ref = np.array([(col0 - cx) * dx, (row0 - cy) * dy]) shape = (row1 - row0 + 1, col1 - col0 + 1)
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)
origin_stage = origin_ref + center origin_stage = origin_ref + center
return (float(origin_stage[0]), float(origin_stage[1])), shape 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.""" thread on every manual edit."""
pitch = pixel_pitch_mm(sras, ref_angle_idx) pitch = pixel_pitch_mm(sras, ref_angle_idx)
canvas_origin_mm, canvas_shape = canvas_for_params( 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 {} extra = extra or {}
per_angle: dict[int, AngleTransform] = {} per_angle: dict[int, AngleTransform] = {}
@@ -1653,6 +1678,36 @@ def crop_alignment_result(result: AlignmentResult, row0: int, col0: int,
origin, per_angle) 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: def overlap_stats(counts: np.ndarray, n_angles: int) -> dict:
"""Summarize a per-pixel "how many angles cover this pixel" image — the """Summarize a per-pixel "how many angles cover this pixel" image — the
number the alignment wizard's mask-stack view is colored by. 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 # 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 # expensive per-pixel operation anywhere in this flow is reproject_mask, and
# only the wizard's own downsampled mask stack calls that per keystroke # 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 # — see AlignmentWizard.rebuild_stack for how it limits a nudge to reprojecting
# the actively-edited angle. # only the actively-edited angle.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def reproject_mask(sras: SrasFile, angle_idx: int, ref_angle_idx: int, def reproject_mask(sras: SrasFile, angle_idx: int, ref_angle_idx: int,
+51
View File
@@ -254,6 +254,48 @@ class SrasFile:
# be served from it — see sras_compute.cached_rf_image. # be served from it — see sras_compute.cached_rf_image.
self.precomputed_pad_factor: int = 1 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: 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.""" """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 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.scan_aborted = False
self.n_angles_declared = n_angles 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) 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) y_pos = np.frombuffer(f.read(n_rows * 4), dtype=">f4").astype(np.float32)
+156 -121
View File
@@ -35,7 +35,7 @@ import numpy as np
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT from matplotlib.backends.backend_qtagg import NavigationToolbar2QT
from PyQt6.QtCore import QSignalBlocker, Qt, pyqtSignal from PyQt6.QtCore import QSignalBlocker, Qt, pyqtSignal
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QCheckBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel,
QLineEdit, QMessageBox, QProgressBar, QPushButton, QSpinBox, QLineEdit, QMessageBox, QProgressBar, QPushButton, QSpinBox,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QWizard, QWizardPage, 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 .canvases import AlignOverlayCanvas, ImageCanvas, RoiQuad, count_colormap
from .common import ( from .common import (
_CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, Jobs, _axes_extent, _form, _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, Jobs, _axes_extent, _combo,
_group, _make_dspin, _scroll_panel, _wrap_label, _form, _group, _make_dspin, _scroll_panel, _wrap_label,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -66,6 +66,11 @@ _ACTIVE_ALPHA = 0.75
_PANEL_W = 372 _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 # (label, sources for register_angle_to_reference) — "both" costs roughly double
# but removes the failure mode where the one chosen source happens to be # but removes the failure mode where the one chosen source happens to be
# uninformative for a single angle. # uninformative for a single angle.
@@ -105,7 +110,6 @@ class WizardState:
preview_shape: tuple[int, int] = (1, 1) preview_shape: tuple[int, int] = (1, 1)
geometry_generation: int = 0 geometry_generation: int = 0
crop: tuple[int, int, int, int] | None = None # canvas (row0,col0,nr,nc) crop: tuple[int, int, int, int] | None = None # canvas (row0,col0,nr,nc)
cropped_result: compute.AlignmentResult | None = None
out_path: str = "" out_path: str = ""
exported_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 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. 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 window's job registry, thread joining and shutdown handling. Two rules that
module's docstring establishes and every launch here follows: disable the 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 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, *, def __init__(self, parent: "SrasViewerWindow", sras: SrasFile, *,
ref_angle_idx: int, dc_threshold_mv: float, ref_angle_idx: int, dc_threshold_mv: float,
seed_per_angle: dict[int, ManualAngleParams] | None,
cached_dc4_mv: dict[int, np.ndarray]): cached_dc4_mv: dict[int, np.ndarray]):
super().__init__(parent) super().__init__(parent)
self._parent = parent self._parent = parent
self._sras = sras self._sras = sras
self._cached_dc4 = dict(cached_dc4_mv) self._cached_dc4 = dict(cached_dc4_mv)
self._seed = dict(seed_per_angle or {})
self._closing = False 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( self.state = WizardState(
ref_angle_idx=ref_angle_idx, threshold_mv=dc_threshold_mv, ref_angle_idx=ref_angle_idx, threshold_mv=dc_threshold_mv,
@@ -170,14 +177,6 @@ class AlignmentWizard(QWizard):
def sras(self) -> SrasFile: def sras(self) -> SrasFile:
return self._sras 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): def rebuild_result(self):
"""Recompute the full AlignmentResult from the current parameters. """Recompute the full AlignmentResult from the current parameters.
@@ -189,7 +188,16 @@ class AlignmentWizard(QWizard):
st.result = build_manual_alignment( st.result = build_manual_alignment(
self._sras, st.ref_angle_idx, st.threshold_mv, st.params) 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* """Reproject every angle's mask onto a coarsened view of the *final*
canvas and sum them into an overlap-count image. 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 the crop page turn a rectangle drawn in millimetres into an exact
integer window of the real canvas, with no second coordinate frame to integer window of the real canvas, with no second coordinate frame to
reconcile. 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 st = self.state
if st.result is None or not st.masks_small: if st.result is None or not st.masks_small:
return return
fy, fx = st.downsample fy, fx = st.downsample
n_rows, n_cols = st.result.canvas_shape n_rows, n_cols = st.result.canvas_shape
st.preview_pitch_mm = (st.result.canvas_dx_mm * fx, pitch = (st.result.canvas_dx_mm * fx, st.result.canvas_dy_mm * fy)
st.result.canvas_dy_mm * fy) shape = (max(1, -(-n_rows // fy)), max(1, -(-n_cols // fx)))
st.preview_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 = {} 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): for a in range(self._sras.n_angles):
p = st.params[a] layer = self._reproject(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)
st.layers[a] = layer st.layers[a] = layer
counts += (layer > 0.5).astype(np.int16) counts += (layer > 0.5).astype(np.int16)
st.counts = counts 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]: def preview_extent(self) -> list[float]:
st = self.state st = self.state
x0, y0 = st.result.canvas_origin_mm x0, y0 = st.result.canvas_origin_mm
@@ -243,6 +294,15 @@ class AlignmentWizard(QWizard):
return (x0 + col * st.result.canvas_dx_mm, return (x0 + col * st.result.canvas_dx_mm,
y0 + row * st.result.canvas_dy_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: def job_running(self) -> bool:
return any(self._parent._job_running(k) for k in return any(self._parent._job_running(k) for k in
(Jobs.ALIGN_MASKS, Jobs.ALIGN_CORRELATE, Jobs.ALIGN_EXPORT)) (Jobs.ALIGN_MASKS, Jobs.ALIGN_CORRELATE, Jobs.ALIGN_EXPORT))
@@ -277,8 +337,8 @@ class AlignmentWizard(QWizard):
super().closeEvent(event) super().closeEvent(event)
def accept(self): def accept(self):
st = self.state cropped, _ = self.cropped_plan()
self.alignment_ready.emit(st.cropped_result, st.exported_path) self.alignment_ready.emit(cropped, self.state.exported_path)
super().accept() super().accept()
def maybe_close_after_job(self): def maybe_close_after_job(self):
@@ -510,7 +570,7 @@ class CorrelatePage(QWizardPage):
self.lbl_status.setText( self.lbl_status.setText(
f"Preparing masks: 0/{len(missing)} angle(s) needed…") f"Preparing masks: 0/{len(missing)} angle(s) needed…")
worker = Ch4MaskWorker(self._sras, missing) worker = Ch4MaskWorker(self._sras, missing)
started = self._wiz._parent._run_worker( started = self._wiz.run_worker(
Jobs.ALIGN_MASKS, worker, Jobs.ALIGN_MASKS, worker,
connect=(("angle_done", self._on_mask_done), connect=(("angle_done", self._on_mask_done),
("error", lambda m: self.lbl_status.setText( ("error", lambda m: self.lbl_status.setText(
@@ -549,10 +609,11 @@ class CorrelatePage(QWizardPage):
st.downsample = (max(1, -(-max_rows // _MAX_PREVIEW_DIM)), st.downsample = (max(1, -(-max_rows // _MAX_PREVIEW_DIM)),
max(1, -(-max_cols // _MAX_PREVIEW_DIM))) max(1, -(-max_cols // _MAX_PREVIEW_DIM)))
st.params = self._wiz.seed_params()
self._recompute_masks() self._recompute_masks()
self._masks_ready = True self._masks_ready = True
self._set_controls_enabled(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._apply_prerotation()
self.lbl_status.setText("Ready.") self.lbl_status.setText("Ready.")
self.completeChanged.emit() self.completeChanged.emit()
@@ -613,12 +674,27 @@ class CorrelatePage(QWizardPage):
st.fits = {} st.fits = {}
self._refresh() self._refresh()
def _refresh(self): def _stage_drift_deg(self, angle_idx: int) -> float:
"""Rebuild result, stack and every readout from the current params.""" """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 st = self._wiz.state
self._wiz.rebuild_result() self._wiz.rebuild_result()
st.geometry_generation += 1 st.geometry_generation += 1
self._wiz.rebuild_stack() self._wiz.rebuild_stack(only_angle)
self._sync_spins() self._sync_spins()
self._update_table() self._update_table()
self._redraw() self._redraw()
@@ -626,8 +702,14 @@ class CorrelatePage(QWizardPage):
# ---- correlation -------------------------------------------------- # ---- correlation --------------------------------------------------
def _reg_kwargs(self) -> dict: 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() seed, signs, _disp = self.combo_prerotate.currentData()
kwargs = { 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_deg": seed,
"seed_signs": signs, "seed_signs": signs,
"coarse_step_deg": self.spin_coarse_step.value(), "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 # Exactly one candidate, no hill-climb: rotation is the seed and
# only the translation is searched. # only the translation is searched.
kwargs["refine"] = False kwargs["refine"] = False
kwargs["search_deg"] = 0.0
if len(signs) > 1: if len(signs) > 1:
kwargs["seed_signs"] = (1,) kwargs["seed_signs"] = (1,)
return kwargs return kwargs
@@ -650,12 +733,8 @@ class CorrelatePage(QWizardPage):
self.lbl_status.setText("Only one angle — nothing to correlate.") self.lbl_status.setText("Only one angle — nothing to correlate.")
return return
search = 0.0 if self.chk_lock_rotation.isChecked() \
else self.spin_search_deg.value()
worker = CrossCorrelateWorker( worker = CrossCorrelateWorker(
self._sras, st.ref_angle_idx, angles, st.dc4_mv, 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()) reg_kwargs=self._reg_kwargs())
# Claim busy and disable the trigger *before* _run_worker, never after: # Claim busy and disable the trigger *before* _run_worker, never after:
@@ -669,7 +748,7 @@ class CorrelatePage(QWizardPage):
self.progress.setValue(0) self.progress.setValue(0)
self.lbl_status.setText(f"Cross-correlating: 0/{self._total} angle(s)…") 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, Jobs.ALIGN_CORRELATE, worker,
connect=(("angle_done", self._on_angle_done), connect=(("angle_done", self._on_angle_done),
("error", lambda m: self.lbl_status.setText( ("error", lambda m: self.lbl_status.setText(
@@ -728,16 +807,12 @@ class CorrelatePage(QWizardPage):
"are being treated as unrotated — lower the DC threshold, try " "are being treated as unrotated — lower the DC threshold, try "
"Raw signal, nudge them by hand, or drop them with " "Raw signal, nudge them by hand, or drop them with "
"sras_edit_scans.py.") "sras_edit_scans.py.")
drifted = [] drifted = [f"{a} ({dev:.2f}°)" for a, _ in rows
for a, _ in rows: if (dev := self._stage_drift_deg(a)) > _DRIFT_WARN_DEG]
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}°)")
if drifted: if drifted:
parts.append("Rotation differs from the stage angle by >1° for " parts.append(f"Rotation differs from the stage angle by "
"angle(s) " + ", ".join(drifted) + ".") f">{_DRIFT_WARN_DEG:g}° for angle(s) "
+ ", ".join(drifted) + ".")
return " ".join(parts) return " ".join(parts)
# ---- manual nudging ---------------------------------------------- # ---- manual nudging ----------------------------------------------
@@ -767,7 +842,7 @@ class CorrelatePage(QWizardPage):
self._wiz.state.params[self._active_angle] = ManualAngleParams( self._wiz.state.params[self._active_angle] = ManualAngleParams(
self.spin_rot.value(), self.spin_rot.value(),
(self.spin_shift_x.value(), self.spin_shift_y.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): 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: 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( self._wiz.state.params[self._active_angle] = ManualAngleParams(
p.rotation_deg, p.rotation_deg,
(p.shift_mm[0] + dir_x * step, p.shift_mm[1] + dir_y * step)) (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): def _on_nudge_rotate(self, direction: int, coarse: bool):
if not self._masks_ready or self._active_angle == self._wiz.state.ref_angle_idx: 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] p = self._wiz.state.params[self._active_angle]
self._wiz.state.params[self._active_angle] = ManualAngleParams( self._wiz.state.params[self._active_angle] = ManualAngleParams(
p.rotation_deg + direction * step, p.shift_mm) p.rotation_deg + direction * step, p.shift_mm)
self._refresh() self._refresh(self._active_angle)
# ---- drawing ------------------------------------------------------ # ---- drawing ------------------------------------------------------
@@ -868,9 +943,8 @@ class CorrelatePage(QWizardPage):
st = self._wiz.state st = self._wiz.state
for a in range(self._sras.n_angles): for a in range(self._sras.n_angles):
score, source = st.fits.get(a, (float("nan"), "")) 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 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 is_ref = a == st.ref_angle_idx
cells = [ cells = [
f"{a}" + (" [ref]" if is_ref else ""), f"{a}" + (" [ref]" if is_ref else ""),
@@ -959,6 +1033,10 @@ class RoiPage(QWizardPage):
(self.spin_rows, "Rows:")): (self.spin_rows, "Rows:")):
spin.setRange(0, 1) spin.setRange(0, 1)
spin.setMinimumWidth(96) 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) form.addRow(label, spin)
rl.addLayout(form) rl.addLayout(form)
self.btn_draw = QPushButton("Draw a new rectangle") self.btn_draw = QPushButton("Draw a new rectangle")
@@ -1008,24 +1086,23 @@ class RoiPage(QWizardPage):
with QSignalBlocker(spin): with QSignalBlocker(spin):
spin.setRange(lo, max(lo, hi)) spin.setRange(lo, max(lo, hi))
self._draw_counts() 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( self.btn_fit_overlap.setEnabled(
compute.largest_rect_at_least(st.counts, self._sras.n_angles) bool((st.counts >= self._sras.n_angles).any()))
is not None)
if st.crop is None: if st.crop is None:
self._on_fit_union() self._on_fit_union()
else: else:
self._set_crop(*st.crop) self._set_crop(*st.crop)
def isComplete(self) -> bool: def isComplete(self) -> bool:
crop = self._wiz.state.crop # _set_crop is the only writer and clamps both extents to >= 1.
return crop is not None and crop[2] >= 1 and crop[3] >= 1 return self._wiz.state.crop is not None
def validatePage(self) -> bool: def validatePage(self) -> bool:
st = self._wiz.state _, plan = self._wiz.cropped_plan()
cropped = compute.crop_alignment_result(st.result, *st.crop) empty = plan.empty_angles()
plan = export.plan_export(self._sras, cropped)
empty = [a for a in range(self._sras.n_angles) if plan.valid_px[a] == 0]
if len(empty) == self._sras.n_angles: if len(empty) == self._sras.n_angles:
QMessageBox.warning( QMessageBox.warning(
self, "Empty crop", self, "Empty crop",
@@ -1039,14 +1116,11 @@ class RoiPage(QWizardPage):
f"this crop and would be written as padding.\n\nContinue anyway?") f"this crop and would be written as padding.\n\nContinue anyway?")
if answer != QMessageBox.StandardButton.Yes: if answer != QMessageBox.StandardButton.Yes:
return False return False
st.cropped_result = cropped
return True return True
def cleanupPage(self): def cleanupPage(self):
"""Going back means the canvas geometry may change under this crop.""" """Going back means the canvas geometry may change under this crop."""
self._wiz.state.crop = None self._wiz.state.crop = None
self._wiz.state.cropped_result = None
# ---- drawing / presets -------------------------------------------- # ---- drawing / presets --------------------------------------------
@@ -1062,30 +1136,19 @@ class RoiPage(QWizardPage):
f"Overlap count — drag the crop rectangle ({n} angles)", f"Overlap count — drag the crop rectangle ({n} angles)",
colorbar_label="angles overlapping", cb_ticks=ticks, norm=norm) colorbar_label="angles overlapping", cb_ticks=ticks, norm=norm)
def _coarse_rect_to_canvas(self, rect) -> tuple[int, int, int, int]: def _coarse_rect_to_canvas(self, rect, *, inset_blocks: int
"""A rectangle in coarse preview indices, as final canvas pixels. ) -> tuple[int, int, int, int]:
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.
"""
st = self._wiz.state st = self._wiz.state
fy, fx = st.downsample return compute.coarse_rect_to_canvas(rect, st.downsample,
row0, col0, nr, nc = rect st.result.canvas_shape,
n_rows, n_cols = st.result.canvas_shape inset_blocks=inset_blocks)
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
def _on_fit_overlap(self): def _on_fit_overlap(self):
st = self._wiz.state st = self._wiz.state
rect = compute.largest_rect_at_least(st.counts, self._sras.n_angles) rect = compute.largest_rect_at_least(st.counts, self._sras.n_angles)
if rect is None: if rect is None:
return 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): def _on_fit_union(self):
st = self._wiz.state st = self._wiz.state
@@ -1093,13 +1156,9 @@ class RoiPage(QWizardPage):
if rows.size == 0: if rows.size == 0:
self._on_whole() self._on_whole()
return return
fy, fx = st.downsample r0, c0 = int(rows.min()), int(cols.min())
n_rows, n_cols = st.result.canvas_shape rect = (r0, c0, int(rows.max()) - r0 + 1, int(cols.max()) - c0 + 1)
r0 = int(rows.min()) * fy self._set_crop(*self._coarse_rect_to_canvas(rect, inset_blocks=0))
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))
def _on_whole(self): def _on_whole(self):
n_rows, n_cols = self._wiz.state.result.canvas_shape 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"X {min(x0, x1):.3f} … {max(x0, x1):.3f} mm "
f"Y {min(y0, y1):.3f} … {max(y0, y1):.3f} mm") f"Y {min(y0, y1):.3f} … {max(y0, y1):.3f} mm")
cropped = compute.crop_alignment_result(st.result, *st.crop) _, plan = self._wiz.cropped_plan()
plan = export.plan_export(self._sras, cropped)
self.lbl_size.setText(f"Estimated file size: {_humanize(plan.total_bytes)}" self.lbl_size.setText(f"Estimated file size: {_humanize(plan.total_bytes)}"
f" ({self._sras.n_angles} angles)") f" ({self._sras.n_angles} angles)")
self.lbl_coverage.setText("Real data per angle: " + ", ".join( self.lbl_coverage.setText("Real data per angle: " + ", ".join(
f"{a}: {plan.coverage_frac(a) * 100:.0f}%" f"{a}: {plan.coverage_frac(a) * 100:.0f}%"
for a in range(self._sras.n_angles))) 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( self.lbl_warn.setText(
f"Angle(s) {', '.join(map(str, empty))} have no data here and would " f"Angle(s) {', '.join(map(str, empty))} have no data here and would "
f"be written as padding." if empty else "") f"be written as padding." if empty else "")
@@ -1276,17 +1334,16 @@ class SavePage(QWizardPage):
# ---- summary ------------------------------------------------------ # ---- summary ------------------------------------------------------
def _update_summary(self): def _update_summary(self):
st = self._wiz.state cropped, plan = self._wiz.cropped_plan()
plan = export.plan_export(self._sras, st.cropped_result) x0, y0 = cropped.canvas_origin_mm
x0, y0 = st.cropped_result.canvas_origin_mm
self.lbl_summary.setText( self.lbl_summary.setText(
f"Format: .sras v6, no precomputed cache (the viewer recomputes " f"Format: .sras v6, no precomputed cache (the viewer recomputes "
f"DC/FFT on first open).\n" f"DC/FFT on first open).\n"
f"Angles: {plan.n_angles}, all sharing one grid of " f"Angles: {plan.n_angles}, all sharing one grid of "
f"{plan.n_frames:,} × {plan.n_rows:,} px.\n" f"{plan.n_frames:,} × {plan.n_rows:,} px.\n"
f"Origin: X {x0:.4f} mm, Y {y0:.4f} mm · " f"Origin: X {x0:.4f} mm, Y {y0:.4f} mm · "
f"pitch {st.cropped_result.canvas_dx_mm * 1000:.2f} × " f"pitch {cropped.canvas_dx_mm * 1000:.2f} × "
f"{abs(st.cropped_result.canvas_dy_mm) * 1000:.2f} µm.\n" f"{abs(cropped.canvas_dy_mm) * 1000:.2f} µm.\n"
f"Stage angles, calibration preambles and the background waveform " f"Stage angles, calibration preambles and the background waveform "
f"are carried over unchanged.\n" f"are carried over unchanged.\n"
f"Size: {_humanize(plan.bytes_per_angle)} per angle, " f"Size: {_humanize(plan.bytes_per_angle)} per angle, "
@@ -1317,7 +1374,8 @@ class SavePage(QWizardPage):
if self._busy or not st.out_path: if self._busy or not st.out_path:
return 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._busy = True
self.completeChanged.emit() self.completeChanged.emit()
self.btn_export.setEnabled(False) self.btn_export.setEnabled(False)
@@ -1325,7 +1383,7 @@ class SavePage(QWizardPage):
self.progress.setValue(0) self.progress.setValue(0)
self.lbl_status.setText(f"Writing {Path(st.out_path).name}…") 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, Jobs.ALIGN_EXPORT, worker,
connect=(("progress", self.progress.setValue), connect=(("progress", self.progress.setValue),
("finished", self._on_export_finished))) ("finished", self._on_export_finished)))
@@ -1370,29 +1428,6 @@ class SavePage(QWizardPage):
_SNAP_TOL = 1e-6 _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: def _snap_edge(coord: float) -> int:
"""A fractional pixel-index edge, as the nearest pixel boundary index. """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.QtCore import Qt
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QLabel, QScrollArea, QComboBox, QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QLabel,
QSizePolicy, QVBoxLayout, QWidget, QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
) )
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX 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 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]: 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 """Matplotlib imshow extent with half-pixel margins, Y flipped so row 0
renders at the top.""" renders at the top."""
+15 -31
View File
@@ -30,7 +30,8 @@ from .canvases import ImageCanvas, WaveformCanvas
from .common import ( from .common import (
CH1_DERIVED_MODES, CH_LABELS, CMAPS, VELOCITY_MODE_IDX, _CHANNEL_DISPLAY, 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, _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 .align_wizard import AlignmentWizard
from .dialogs import FftOptionsDialog, RowAverageFftOptionsDialog from .dialogs import FftOptionsDialog, RowAverageFftOptionsDialog
@@ -97,7 +98,6 @@ class SrasViewerWindow(QMainWindow):
# Angle alignment ("Fusion" menu) # Angle alignment ("Fusion" menu)
self._alignment_result = None self._alignment_result = None
self._alignment_generation: int = 0
self._aligned_cache: dict[tuple, np.ndarray] = {} self._aligned_cache: dict[tuple, np.ndarray] = {}
self._align_wizard: AlignmentWizard | None = None self._align_wizard: AlignmentWizard | None = None
@@ -242,14 +242,10 @@ class SrasViewerWindow(QMainWindow):
ar.addStretch() ar.addStretch()
view_form.addRow("Angle:", angle_field) view_form.addRow("Angle:", angle_field)
self.combo_channel = QComboBox() self.combo_channel = _combo(CH_LABELS, min_chars=12)
self.combo_channel.addItems(CH_LABELS)
self.combo_channel.setEnabled(False) self.combo_channel.setEnabled(False)
self.combo_channel.setSizePolicy(QSizePolicy.Policy.Expanding, self.combo_channel.setSizePolicy(QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed) QSizePolicy.Policy.Fixed)
self.combo_channel.setSizeAdjustPolicy(
QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
self.combo_channel.setMinimumContentsLength(12)
self.combo_channel.currentIndexChanged.connect(self._on_channel_changed) self.combo_channel.currentIndexChanged.connect(self._on_channel_changed)
view_form.addRow("Channel:", self.combo_channel) view_form.addRow("Channel:", self.combo_channel)
vl.addLayout(view_form) vl.addLayout(view_form)
@@ -632,7 +628,8 @@ class SrasViewerWindow(QMainWindow):
back through a real FFT — worth saying out loud rather than leaving back through a real FFT — worth saying out loud rather than leaving
the user to wonder why a file they batch-computed got slow. 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 always asks for a raw per-pixel image, since row-averaging is a batch
option with no display control. 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): if s is None or all(x is None for x in s.precomputed_freq_mhz):
return [] return []
reasons = [] reasons = compute.cache_mismatch_reasons(
if compute.pad_factor_for(s, self._current_n_fft()) != s.precomputed_pad_factor: s, n_fft=self._current_n_fft(),
reasons.append(f"stored at pad {s.precomputed_pad_factor}x, " apply_bg_sub=self.chk_bg_sub.isChecked(), row_avg_n=0)
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")
if not reasons: if not reasons:
return [] return []
return ["! Cached FFT unusable for this view — " + "; ".join(reasons) return ["! Cached FFT unusable for this view — " + "; ".join(reasons)
@@ -1358,21 +1347,19 @@ class SrasViewerWindow(QMainWindow):
ref_idx = 0 ref_idx = 0
threshold_mv = self.spin_threshold_mv.value() threshold_mv = self.spin_threshold_mv.value()
seed: dict[int, ManualAngleParams] = {} # Only the threshold carries over from a saved alignment. The wizard's
# Seed only from a previously *saved* alignment, never from # first page starts every angle pre-rotated from the stage angles and
# self._alignment_result: the wizard's first page starts every angle # owns the parameters from there, so inheriting saved per-angle values
# pre-rotated from the stage angles and expects to own the parameters # would make "Reset to pre-rotation only" mean something different
# from there, so inheriting a half-edited in-memory state would make # each time.
# "Reset to pre-rotation only" mean something different each time.
sidecar = load_manual_alignment(self._sras) sidecar = load_manual_alignment(self._sras)
if sidecar is not None and sidecar.ref_angle_idx == ref_idx: if sidecar is not None and sidecar.ref_angle_idx == ref_idx:
seed = dict(sidecar.per_angle)
threshold_mv = sidecar.dc_threshold_mv threshold_mv = sidecar.dc_threshold_mv
cached_dc4 = {a: img for (a, ch), img in self._dc_cache.items() if ch == CH4_IDX} cached_dc4 = {a: img for (a, ch), img in self._dc_cache.items() if ch == CH4_IDX}
wiz = AlignmentWizard( wiz = AlignmentWizard(
self, self._sras, ref_angle_idx=ref_idx, dc_threshold_mv=threshold_mv, 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.alignment_ready.connect(self._on_wizard_finished)
wiz.finished.connect(self._on_wizard_closed) wiz.finished.connect(self._on_wizard_closed)
wiz.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) wiz.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
@@ -1411,15 +1398,12 @@ class SrasViewerWindow(QMainWindow):
if self._current_image is not None: if self._current_image is not None:
self._refresh_display() self._refresh_display()
def _apply_alignment_result(self, result, *, view_checked: bool, def _apply_alignment_result(self, result, *, view_checked: bool):
bump_generation: bool = True):
"""Install (or clear, with result=None) the active alignment: reset """Install (or clear, with result=None) the active alignment: reset
the aligned-image cache and set the Aligned View checkbox without the aligned-image cache and set the Aligned View checkbox without
firing its change signal.""" firing its change signal."""
self._alignment_result = result self._alignment_result = result
self._aligned_cache = {} self._aligned_cache = {}
if bump_generation:
self._alignment_generation += 1
with QSignalBlocker(self.chk_aligned_view): with QSignalBlocker(self.chk_aligned_view):
self.chk_aligned_view.setChecked(view_checked) self.chk_aligned_view.setChecked(view_checked)
self.chk_aligned_view.setEnabled(result is not None) self.chk_aligned_view.setEnabled(result is not None)
+7 -13
View File
@@ -361,21 +361,17 @@ class CrossCorrelateWorker(_PooledWorker):
angle_done = pyqtSignal(int, float, float, float, float, str) angle_done = pyqtSignal(int, float, float, float, float, str)
def __init__(self, sras: SrasFile, ref_angle_idx: int, angle_indices: list[int], def __init__(self, sras: SrasFile, ref_angle_idx: int, angle_indices: list[int],
dc4_mv: dict[int, np.ndarray], *, dc4_mv: dict[int, np.ndarray], *, reg_kwargs: dict | None = None):
sources: tuple[str, ...], dc_threshold_mv: float, """*reg_kwargs* is splatted into register_angle_to_reference — every
search_deg: float, reg_kwargs: dict | None = None): registration setting the wizard exposes (sources, threshold, search
"""*reg_kwargs* is splatted into register_angle_to_reference on top of width, seed, signs, refine, grid sizes) travels in it, so this class
the named arguments — the wizard's rotation-search controls (seed, holds no opinion about which knobs exist and exposing another needs no
signs, refine, grid sizes) go through here, so exposing another knob change here."""
needs no change to this class."""
super().__init__() super().__init__()
self._sras = sras self._sras = sras
self._ref = ref_angle_idx self._ref = ref_angle_idx
self._angles = angle_indices self._angles = angle_indices
self._dc4_mv = dc4_mv 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 {}) self._reg_kwargs = dict(reg_kwargs or {})
def _plan(self) -> int: def _plan(self) -> int:
@@ -386,9 +382,7 @@ class CrossCorrelateWorker(_PooledWorker):
def _one(self, a: int) -> tuple[int, compute.RigidFit]: def _one(self, a: int) -> tuple[int, compute.RigidFit]:
return a, compute.register_angle_to_reference( return a, compute.register_angle_to_reference(
self._sras, a, self._ref, self._dc4_mv, self._sras, a, self._ref, self._dc4_mv, **self._reg_kwargs)
dc_threshold_mv=self._threshold, sources=self._sources,
search_deg=self._search_deg, **self._reg_kwargs)
def _emit(self, result): def _emit(self, result):
a, fit = result a, fit = result
+13 -3
View File
@@ -392,6 +392,14 @@ def test_wizard_nudges(ctx):
assert len(wiz.state.layers) == s.n_angles, \ assert len(wiz.state.layers) == s.n_angles, \
"stack rebuilt for every angle after a rotation nudge" "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). # Real key-event wiring (keyPressEvent -> signal -> slot).
before = wiz.state.params[active].shift_mm before = wiz.state.params[active].shift_mm
QTest.keyClick(p1.canvas, Qt.Key.Key_Right) QTest.keyClick(p1.canvas, Qt.Key.Key_Right)
@@ -529,7 +537,8 @@ def test_wizard_crop_dropped_when_going_back(ctx):
pump(120) pump(120)
assert wiz.currentId() == wiz.PAGE_CORRELATE assert wiz.currentId() == wiz.PAGE_CORRELATE
assert wiz.state.crop is None, "cleanupPage discarded the stale crop" 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() wiz.next()
pump(150) pump(150)
assert wiz.state.crop is not None, "a fresh default crop is offered again" 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) pump(150)
assert wiz.currentId() == wiz.PAGE_SAVE, "advanced to the save page" assert wiz.currentId() == wiz.PAGE_SAVE, "advanced to the save page"
ctx.p3 = p3 = wiz.page(wiz.PAGE_SAVE) ctx.p3 = p3 = wiz.page(wiz.PAGE_SAVE)
assert wiz.state.cropped_result is not None, "crop applied on leaving page 2" cropped, _ = wiz.cropped_plan()
assert wiz.state.cropped_result.canvas_shape == ctx.crop[2:], \ assert cropped is not None, "crop applied on leaving page 2"
assert cropped.canvas_shape == ctx.crop[2:], \
"cropped result carries the chosen shape" "cropped result carries the chosen shape"
assert not p3.isComplete(), "Finish unavailable before anything is written" assert not p3.isComplete(), "Finish unavailable before anything is written"
assert p3.lbl_summary.text(), "a summary of what will be written is shown" assert p3.lbl_summary.text(), "a summary of what will be written is shown"