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:
+29
-58
@@ -81,19 +81,22 @@ class ExportPlan:
|
||||
px = self.n_rows * self.n_frames
|
||||
return (self.valid_px.get(angle_idx, 0) / px) if px else 0.0
|
||||
|
||||
def empty_angles(self) -> list[int]:
|
||||
"""Angles that would be written as pure padding — no output pixel of
|
||||
theirs has a source pixel."""
|
||||
return [a for a in range(self.n_angles) if not self.valid_px.get(a, 0)]
|
||||
|
||||
|
||||
def _src_coords(t, rows, n_cols: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Fractional source (row, col) coordinates for whole output rows.
|
||||
|
||||
*rows* may be a scalar row index or an array of them; the result is shaped
|
||||
(len(rows), n_cols), or (n_cols,) for a scalar.
|
||||
*rows* is an array of output row indices; both results are shaped
|
||||
(len(rows), n_cols).
|
||||
"""
|
||||
cols = np.arange(n_cols, dtype=np.float64)
|
||||
r = np.atleast_1d(np.asarray(rows, dtype=np.float64))[:, None]
|
||||
r = np.asarray(rows, dtype=np.float64)[:, None]
|
||||
sr = t.matrix[0, 0] * r + t.matrix[0, 1] * cols + t.offset[0]
|
||||
sc = t.matrix[1, 0] * r + t.matrix[1, 1] * cols + t.offset[1]
|
||||
if np.isscalar(rows) or np.asarray(rows).ndim == 0:
|
||||
return sr[0], sc[0]
|
||||
return sr, sc
|
||||
|
||||
|
||||
@@ -104,7 +107,7 @@ def _round_idx(coord: np.ndarray) -> np.ndarray:
|
||||
from zero while np.rint rounds them to even, and these have to be the same
|
||||
source pixels the apply_alignment(order=0) preview drew. Exact halves are
|
||||
not a corner case here — the canvas is snapped to the reference angle's own
|
||||
pixel grid (canvas_for_params snap=True), so an unrotated angle lands on
|
||||
pixel grid (see canvas_for_params), so an unrotated angle lands on
|
||||
half-integers wherever its row pitch differs from the reference's.
|
||||
"""
|
||||
return np.floor(coord + 0.5).astype(np.int64)
|
||||
@@ -181,6 +184,13 @@ def plan_export(sras: SrasFile, result: AlignmentResult) -> ExportPlan:
|
||||
bytes_per_angle = (n_rows * sras.n_channels * n_cols
|
||||
* sras.samples_per_frame * sras.bytes_per_sample)
|
||||
|
||||
# Built before the remaining warnings so they can be phrased with the
|
||||
# plan's own coverage_frac rather than a second copy of the same division.
|
||||
plan = ExportPlan(n_rows=n_rows, n_frames=n_cols, n_angles=n_angles,
|
||||
bytes_per_angle=bytes_per_angle,
|
||||
total_bytes=bytes_per_angle * n_angles,
|
||||
valid_px=valid_px, warnings=warnings)
|
||||
|
||||
if n_rows > _MAX_ROWS:
|
||||
warnings.append(
|
||||
f"Crop is {n_rows} rows; the .sras geometry table caps rows at "
|
||||
@@ -188,7 +198,7 @@ def plan_export(sras: SrasFile, result: AlignmentResult) -> ExportPlan:
|
||||
if n_cols > _MAX_FRAMES:
|
||||
warnings.append(f"Crop is {n_cols} frames; the cap is {_MAX_FRAMES}.")
|
||||
for a in range(n_angles):
|
||||
frac = valid_px[a] / (n_rows * n_cols) if n_rows and n_cols else 0.0
|
||||
frac = plan.coverage_frac(a)
|
||||
if frac == 0.0:
|
||||
warnings.append(
|
||||
f"Angle {a} has no data inside this crop — it will be written "
|
||||
@@ -202,55 +212,12 @@ def plan_export(sras: SrasFile, result: AlignmentResult) -> ExportPlan:
|
||||
"is written, which makes background subtraction a no-op.")
|
||||
if sras.version != 6:
|
||||
warnings.append(f"Input is v{sras.version}; the export is written as v6.")
|
||||
if getattr(sras, "scan_aborted", False):
|
||||
if sras.scan_aborted:
|
||||
warnings.append(
|
||||
f"Input scan was aborted: only its {n_angles} complete angle(s) "
|
||||
f"are exported.")
|
||||
|
||||
return ExportPlan(n_rows=n_rows, n_frames=n_cols, n_angles=n_angles,
|
||||
bytes_per_angle=bytes_per_angle,
|
||||
total_bytes=bytes_per_angle * n_angles,
|
||||
valid_px=valid_px, warnings=warnings)
|
||||
|
||||
|
||||
def _encode_preambles(sras: SrasFile) -> bytes:
|
||||
"""The Preamble Blocks section for the output file.
|
||||
|
||||
v6/v7 inputs carry the on-disk span verbatim (sras.preambles_raw), which is
|
||||
both cheaper and lossless. Legacy inputs don't keep that span, and a v2 file
|
||||
has no preambles at all — there, empty strings are written. That is not a
|
||||
silent downgrade: _parse_preamble("") returns {} and _set_calibration falls
|
||||
back to the hardcoded scope constants, which is exactly the calibration a v2
|
||||
file already gets, so mV values round-trip unchanged.
|
||||
"""
|
||||
raw = getattr(sras, "preambles_raw", None)
|
||||
if raw is not None:
|
||||
return raw
|
||||
strings = sras.preambles or [""] * sras.n_channels
|
||||
out = bytearray()
|
||||
for s in strings:
|
||||
encoded = s.encode("utf-8")
|
||||
out += struct.pack(">H", len(encoded)) + encoded
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _encode_background(sras: SrasFile) -> bytes:
|
||||
"""The Background Block for the output file.
|
||||
|
||||
When the input has none (v2/v3), write samples_per_frame zeros rather than a
|
||||
zero-length block. Every consumer guards on `background is not None` and
|
||||
then subtracts it from a (spf,)-shaped row, so a length-0 array would
|
||||
broadcast-fail at the first background-subtracted FFT; zeros make the
|
||||
subtraction a correct no-op instead.
|
||||
"""
|
||||
raw = getattr(sras, "background_raw", None)
|
||||
if raw is not None:
|
||||
return raw
|
||||
if sras.background is None:
|
||||
samples = np.zeros(sras.samples_per_frame, dtype=np.int8)
|
||||
else:
|
||||
samples = np.rint(sras.background).astype(np.int8)
|
||||
return struct.pack(">I", samples.size) + samples.tobytes()
|
||||
return plan
|
||||
|
||||
|
||||
def _fill_row(sras: SrasFile, n_cols: int, dtype) -> np.ndarray:
|
||||
@@ -335,7 +302,8 @@ class _SourceReader:
|
||||
|
||||
|
||||
def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path,
|
||||
*, progress_cb=None, should_stop=None) -> Path:
|
||||
*, progress_cb=None, should_stop=None,
|
||||
budget: int | None = None) -> Path:
|
||||
"""Write *sras*, aligned per *result* and cropped to its canvas, to a new
|
||||
v6 .sras file. Returns the path written.
|
||||
|
||||
@@ -384,7 +352,7 @@ def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path,
|
||||
|
||||
# Reference-only header fields. v6/v7 inputs have real ones to carry over;
|
||||
# for a legacy input describe the canvas we are actually writing.
|
||||
if hasattr(sras, "x_start_nominal_mm"):
|
||||
if sras.x_start_nominal_mm is not None:
|
||||
nominal = (sras.x_start_nominal_mm, sras.y_start_nominal_mm,
|
||||
sras.x_delta_nominal_mm, sras.y_delta_nominal_mm,
|
||||
sras.row_spacing_mm)
|
||||
@@ -407,7 +375,7 @@ def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path,
|
||||
geo = struct.pack(GEO_FMT_V6, float(x0_mm), float(sras.pixel_x_mm),
|
||||
int(n_cols), int(n_rows)) * n_angles
|
||||
|
||||
budget = compute._TOTAL_BYTES_BUDGET
|
||||
budget = compute.memory_budget_bytes() if budget is None else max(1, budget)
|
||||
total_chunks = max(1, n_angles * ((n_rows + _ROW_CHUNK - 1) // _ROW_CHUNK))
|
||||
done_chunks = 0
|
||||
cancelled = False
|
||||
@@ -419,8 +387,8 @@ def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path,
|
||||
fout.write(sras.angles_deg.astype(">f4").tobytes())
|
||||
fout.write(geo)
|
||||
fout.write(y_rows.tobytes() * n_angles)
|
||||
fout.write(_encode_preambles(sras))
|
||||
fout.write(_encode_background(sras))
|
||||
fout.write(sras.encoded_preambles())
|
||||
fout.write(sras.encoded_background())
|
||||
|
||||
pad = _fill_row(sras, n_cols, dtype)
|
||||
for a in range(n_angles):
|
||||
@@ -461,7 +429,10 @@ def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path,
|
||||
out[:, keep, :] = band[
|
||||
idx_r[i][keep] - base, :, idx_c[i][keep], :
|
||||
].transpose(1, 0, 2)
|
||||
fout.write(out.tobytes())
|
||||
# out is C-contiguous, so the buffer protocol
|
||||
# writes it straight out — .tobytes() would
|
||||
# copy a full row per row written.
|
||||
fout.write(out)
|
||||
done_chunks += 1
|
||||
if progress_cb is not None:
|
||||
progress_cb(int(done_chunks / total_chunks * 100))
|
||||
|
||||
+96
-41
@@ -285,6 +285,13 @@ _TOTAL_BYTES_BUDGET = int(os.environ.get("SRAS_MEM_BUDGET_MB", 1024)) * 1024 * 1
|
||||
_CHUNK_ROWS_MAX = 32 # cap for small scans (original behavior)
|
||||
_MAX_WORKERS = int(os.environ.get("SRAS_MAX_WORKERS", 0)) or (os.cpu_count() or 4)
|
||||
|
||||
def memory_budget_bytes() -> int:
|
||||
"""The module-wide ceiling on concurrently-live working buffers, for
|
||||
callers outside this module that read the same data (the aligned exporter
|
||||
sizes its source-band reader against it)."""
|
||||
return _TOTAL_BYTES_BUDGET
|
||||
|
||||
|
||||
def _chunk_rows_for(n_frames: int, samples_per_frame: int,
|
||||
budget: int = _TOTAL_BYTES_BUDGET) -> int:
|
||||
bytes_per_row = max(1, n_frames * samples_per_frame * 4) # float32
|
||||
@@ -500,6 +507,36 @@ def pad_factor_for(sras: SrasFile, n_fft: int | None) -> int:
|
||||
return max(1, n_fft // spf)
|
||||
|
||||
|
||||
def cache_mismatch_reasons(sras: SrasFile, *, n_fft: int | None,
|
||||
apply_bg_sub: bool, row_avg_n: int) -> list[str]:
|
||||
"""Why this file's stored FFT images can't answer a request, as human-
|
||||
readable phrases; empty means they can.
|
||||
|
||||
Padding, background subtraction and row-averaging are all baked into
|
||||
the stored numbers, so a request that differs in any of them has to go
|
||||
back through a real FFT. This is the single accept rule: cached_rf_image
|
||||
serves a stored image iff this returns nothing, and callers that want to
|
||||
*explain* the miss (rather than silently recompute) format these same
|
||||
strings, so the two can't drift apart.
|
||||
"""
|
||||
reasons = []
|
||||
pad = pad_factor_for(sras, n_fft)
|
||||
if pad != sras.precomputed_pad_factor:
|
||||
want = f"pad {pad}x" if pad else "a ragged n_fft"
|
||||
reasons.append(f"stored at pad {sras.precomputed_pad_factor}x, "
|
||||
f"requested at {want}")
|
||||
if sras.precomputed_bg_sub != (apply_bg_sub and sras.background is not None):
|
||||
reasons.append("stored with background subtraction "
|
||||
f"{'on' if sras.precomputed_bg_sub else 'off'}")
|
||||
if sras.precomputed_row_avg_n != row_avg_n:
|
||||
have = (f"row-averaged (n={sras.precomputed_row_avg_n})"
|
||||
if sras.precomputed_row_avg_n else "raw per-pixel")
|
||||
want = (f"row-averaged (n={row_avg_n})"
|
||||
if row_avg_n else "raw per-pixel")
|
||||
reasons.append(f"stored {have}, requested {want}")
|
||||
return reasons
|
||||
|
||||
|
||||
def cached_rf_image(sras: SrasFile, angle_idx: int,
|
||||
dc_threshold_mv: float | None,
|
||||
apply_bg_sub: bool = True,
|
||||
@@ -536,15 +573,15 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
|
||||
fall through to a real compute rather than block.
|
||||
"""
|
||||
cached_freq = sras.precomputed_freq_mhz[angle_idx]
|
||||
if (cached_freq is None
|
||||
or pad_factor_for(sras, n_fft) != sras.precomputed_pad_factor
|
||||
or sras.precomputed_bg_sub != (apply_bg_sub and sras.background is not None)
|
||||
or sras.precomputed_row_avg_n != row_avg_n):
|
||||
if cached_freq is None or cache_mismatch_reasons(
|
||||
sras, n_fft=n_fft, apply_bg_sub=apply_bg_sub, row_avg_n=row_avg_n):
|
||||
return None
|
||||
freq_img = cached_freq.copy()
|
||||
dc4_img = None
|
||||
if dc_threshold_mv is not None:
|
||||
# DC4 mask, in priority order: already-cached DC block, caller-
|
||||
# supplied image, or a fresh (cheap — no FFT) recompute.
|
||||
# supplied image, or a fresh (cheap — no FFT) recompute. Resolved
|
||||
# before the copy below so the allow_dc_recompute bail-out doesn't
|
||||
# allocate a full image it is about to throw away.
|
||||
dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
|
||||
if dc4_img is None:
|
||||
if dc4_mv is not None:
|
||||
@@ -554,6 +591,8 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
|
||||
*sras.cal(CH4_IDX))
|
||||
else:
|
||||
return None
|
||||
freq_img = cached_freq.copy()
|
||||
if dc4_img is not None:
|
||||
freq_img[dc4_img < dc_threshold_mv] = 0.0
|
||||
return freq_img
|
||||
|
||||
@@ -778,6 +817,9 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||
try:
|
||||
if mode not in ("dc", "fft", "fft_rowavg"):
|
||||
return f"unknown cache mode {mode!r} (expected 'dc', 'fft', or 'fft_rowavg')"
|
||||
# write_v7_cache enforces this bound too, but only once every angle's
|
||||
# FFT has already been computed. Checking up front is the difference
|
||||
# between a bad argument costing nothing and costing the whole run.
|
||||
if not (1 <= pad_factor <= MAX_PAD_FACTOR):
|
||||
return f"pad_factor must be 1-{MAX_PAD_FACTOR}, got {pad_factor}"
|
||||
set_fft_backend(fft_backend)
|
||||
@@ -1492,24 +1534,17 @@ def register_angle_to_reference(
|
||||
def canvas_for_params(sras: SrasFile, ref_angle_idx: int,
|
||||
pitch_mm: tuple[float, float],
|
||||
per_angle_params: dict[int, ManualAngleParams],
|
||||
*, margin_frac: float = 0.0, snap: bool = True
|
||||
) -> tuple[tuple[float, float], tuple[int, int]]:
|
||||
"""Shared-canvas origin (stage mm) and (n_rows, n_cols) at *pitch_mm* that
|
||||
contains every angle's footprint after its own rigid transform. Angles
|
||||
missing from per_angle_params default to identity (e.g. a sidecar saved
|
||||
before a rescan added more angles).
|
||||
|
||||
snap=True aligns the canvas grid with the reference angle's own pixel grid,
|
||||
so the reference lands on integer canvas pixels and is resampled by an
|
||||
exact integer translation — the concrete meaning of "the canvas carries
|
||||
angle 0's X/Y coordinates". It requires pitch_mm to be the reference's own
|
||||
pitch; the manual-alignment preview passes a coarser pitch and snap=False.
|
||||
|
||||
margin_frac pads the box on every side: 0 for a final canvas, nonzero for
|
||||
the wizard's preview canvas, which needs headroom so an ordinary
|
||||
translation nudge never has to trigger a full canvas resize (an extreme
|
||||
nudge can still push content past this padding; accepted, and cheap to
|
||||
recover from by re-opening the dialog).
|
||||
The canvas grid is aligned with the reference angle's own pixel grid, so
|
||||
the reference lands on integer canvas pixels and is resampled by an exact
|
||||
integer translation — the concrete meaning of "the canvas carries angle
|
||||
0's X/Y coordinates". This requires pitch_mm to be the reference's own
|
||||
pitch.
|
||||
"""
|
||||
dx, dy = pitch_mm
|
||||
corners = np.vstack([
|
||||
@@ -1520,27 +1555,17 @@ def canvas_for_params(sras: SrasFile, ref_angle_idx: int,
|
||||
for a in range(sras.n_angles)])
|
||||
x_min, y_min = corners.min(axis=0)
|
||||
x_max, y_max = corners.max(axis=0)
|
||||
if margin_frac:
|
||||
pad_x, pad_y = (x_max - x_min) * margin_frac, (y_max - y_min) * margin_frac
|
||||
x_min, x_max = x_min - pad_x, x_max + pad_x
|
||||
y_min, y_max = y_min - pad_y, y_max + pad_y
|
||||
|
||||
center = ref_center_mm(sras, ref_angle_idx)
|
||||
if snap:
|
||||
# Express the box in the reference's own pixel indices and grow it
|
||||
# outward to whole pixels, so canvas index k lands exactly where the
|
||||
# reference's own pixel (k + const) does.
|
||||
cy, cx = _center_idx(sras, ref_angle_idx)
|
||||
cols = sorted((x_min / dx + cx, x_max / dx + cx))
|
||||
rows = sorted((y_min / dy + cy, y_max / dy + cy))
|
||||
col0, col1 = int(np.floor(cols[0])), int(np.ceil(cols[1]))
|
||||
row0, row1 = int(np.floor(rows[0])), int(np.ceil(rows[1]))
|
||||
origin_ref = np.array([(col0 - cx) * dx, (row0 - cy) * dy])
|
||||
shape = (row1 - row0 + 1, col1 - col0 + 1)
|
||||
else:
|
||||
origin_ref = np.array([x_min, y_min if dy > 0 else y_max])
|
||||
shape = (int(np.ceil((y_max - y_min) / abs(dy))) + 1,
|
||||
int(np.ceil((x_max - x_min) / dx)) + 1)
|
||||
# Express the box in the reference's own pixel indices and grow it
|
||||
# outward to whole pixels, so canvas index k lands exactly where the
|
||||
# reference's own pixel (k + const) does.
|
||||
cy, cx = _center_idx(sras, ref_angle_idx)
|
||||
cols = sorted((x_min / dx + cx, x_max / dx + cx))
|
||||
rows = sorted((y_min / dy + cy, y_max / dy + cy))
|
||||
col0, col1 = int(np.floor(cols[0])), int(np.ceil(cols[1]))
|
||||
row0, row1 = int(np.floor(rows[0])), int(np.ceil(rows[1]))
|
||||
origin_ref = np.array([(col0 - cx) * dx, (row0 - cy) * dy])
|
||||
shape = (row1 - row0 + 1, col1 - col0 + 1)
|
||||
|
||||
origin_stage = origin_ref + center
|
||||
return (float(origin_stage[0]), float(origin_stage[1])), shape
|
||||
@@ -1594,7 +1619,7 @@ def _result_from_params(sras: SrasFile, ref_angle_idx: int,
|
||||
thread on every manual edit."""
|
||||
pitch = pixel_pitch_mm(sras, ref_angle_idx)
|
||||
canvas_origin_mm, canvas_shape = canvas_for_params(
|
||||
sras, ref_angle_idx, pitch, params, snap=True)
|
||||
sras, ref_angle_idx, pitch, params)
|
||||
|
||||
extra = extra or {}
|
||||
per_angle: dict[int, AngleTransform] = {}
|
||||
@@ -1653,6 +1678,36 @@ def crop_alignment_result(result: AlignmentResult, row0: int, col0: int,
|
||||
origin, per_angle)
|
||||
|
||||
|
||||
def coarse_rect_to_canvas(rect: tuple[int, int, int, int],
|
||||
downsample: tuple[int, int],
|
||||
canvas_shape: tuple[int, int],
|
||||
*, inset_blocks: int = 0
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""A rectangle in coarse (block-mean preview) indices as canvas pixels,
|
||||
both as (row0, col0, n_rows, n_cols) and clamped to the canvas.
|
||||
|
||||
The single place the preview grid's relation to the real canvas is
|
||||
written down: the coarse grid samples canvas pixels 0, f, 2f, …, so a
|
||||
coarse pixel stands for a whole block and every caller has to agree on
|
||||
which canvas pixels that block means, or a crop the user approved on the
|
||||
preview lands a few pixels off in the export.
|
||||
|
||||
*inset_blocks* shrinks the rectangle by that many coarse blocks on each
|
||||
side. A coarse pixel reported as fully covered stands for a block whose
|
||||
far edge may not be, so "fit to full overlap" insets by 1 to stay honestly
|
||||
inside the overlap region; a union rectangle insets by 0 because it wants
|
||||
to contain the region rather than fit inside it.
|
||||
"""
|
||||
row0, col0, nr, nc = rect
|
||||
fy, fx = downsample
|
||||
n_rows, n_cols = canvas_shape
|
||||
r0 = min(n_rows - 1, (row0 + inset_blocks) * fy)
|
||||
c0 = min(n_cols - 1, (col0 + inset_blocks) * fx)
|
||||
r1 = min(n_rows, (row0 + nr - inset_blocks) * fy)
|
||||
c1 = min(n_cols, (col0 + nc - inset_blocks) * fx)
|
||||
return r0, c0, max(1, r1 - r0), max(1, c1 - c0)
|
||||
|
||||
|
||||
def overlap_stats(counts: np.ndarray, n_angles: int) -> dict:
|
||||
"""Summarize a per-pixel "how many angles cover this pixel" image — the
|
||||
number the alignment wizard's mask-stack view is colored by.
|
||||
@@ -1798,8 +1853,8 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
|
||||
# to call synchronously on the GUI thread on every edit. The only genuinely
|
||||
# expensive per-pixel operation anywhere in this flow is reproject_mask, and
|
||||
# only the wizard's own downsampled mask stack calls that per keystroke
|
||||
# — see that class's docstring for how it limits each nudge to reprojecting only
|
||||
# the actively-edited angle.
|
||||
# — see AlignmentWizard.rebuild_stack for how it limits a nudge to reprojecting
|
||||
# only the actively-edited angle.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def reproject_mask(sras: SrasFile, angle_idx: int, ref_angle_idx: int,
|
||||
|
||||
@@ -254,6 +254,48 @@ class SrasFile:
|
||||
# be served from it — see sras_compute.cached_rf_image.
|
||||
self.precomputed_pad_factor: int = 1
|
||||
|
||||
def encoded_preambles(self) -> bytes:
|
||||
"""This file's Preamble Blocks section, as bytes a writer can emit.
|
||||
|
||||
v6/v7 files kept the on-disk span verbatim, which is both cheaper and
|
||||
lossless; legacy files did not keep it, and a v2 file has no preambles
|
||||
at all, so those are re-encoded from the parsed strings (empty ones for
|
||||
v2). Empty is not a silent downgrade: _parse_preamble("") returns {} and
|
||||
_set_calibration falls back to the hardcoded scope constants, which is
|
||||
exactly the calibration a v2 file already gets, so mV values round-trip
|
||||
unchanged.
|
||||
|
||||
Lives here rather than at each writer so the version fan-out sits next
|
||||
to the parser that creates it, and no writer has to probe the object
|
||||
to find out which shape it got.
|
||||
"""
|
||||
raw = getattr(self, "preambles_raw", None)
|
||||
if raw is not None:
|
||||
return raw
|
||||
out = bytearray()
|
||||
for s in self.preambles or [""] * self.n_channels:
|
||||
encoded = s.encode("utf-8")
|
||||
out += struct.pack(">H", len(encoded)) + encoded
|
||||
return bytes(out)
|
||||
|
||||
def encoded_background(self) -> bytes:
|
||||
"""This file's Background Block, as bytes a writer can emit.
|
||||
|
||||
When there is none (v2/v3), this is samples_per_frame zeros rather than
|
||||
a zero-length block. Every consumer guards on `background is not None`
|
||||
and then subtracts it from a (spf,)-shaped row, so a length-0 array
|
||||
would broadcast-fail at the first background-subtracted FFT; zeros make
|
||||
the subtraction a correct no-op instead.
|
||||
"""
|
||||
raw = getattr(self, "background_raw", None)
|
||||
if raw is not None:
|
||||
return raw
|
||||
if self.background is None:
|
||||
samples = np.zeros(self.samples_per_frame, dtype=np.int8)
|
||||
else:
|
||||
samples = np.rint(self.background).astype(np.int8)
|
||||
return struct.pack(">I", samples.size) + samples.tobytes()
|
||||
|
||||
def cached_dc_mv(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
|
||||
"""A stored DC image (already in mV) for (angle, channel), or None."""
|
||||
store = self.precomputed_dc3_mv if ch_idx == CH3_IDX else self.precomputed_dc4_mv
|
||||
@@ -284,6 +326,15 @@ class SrasFile:
|
||||
self.scan_aborted = False
|
||||
self.n_angles_declared = n_angles
|
||||
|
||||
# Pre-v6 files carry no nominal ROI. Defined as None rather than
|
||||
# left absent so the object's shape does not depend on its version
|
||||
# and writers can ask instead of probing with hasattr.
|
||||
self.x_start_nominal_mm = None
|
||||
self.y_start_nominal_mm = None
|
||||
self.x_delta_nominal_mm = None
|
||||
self.y_delta_nominal_mm = None
|
||||
self.row_spacing_mm = None
|
||||
|
||||
angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4").astype(np.float32)
|
||||
y_pos = np.frombuffer(f.read(n_rows * 4), dtype=">f4").astype(np.float32)
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
+25
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import (
|
||||
QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QLabel, QScrollArea,
|
||||
QSizePolicy, QVBoxLayout, QWidget,
|
||||
QComboBox, QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QLabel,
|
||||
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX
|
||||
@@ -85,6 +85,29 @@ def _make_dspin(lo: float, hi: float, decimals: int, *, suffix: str = "",
|
||||
return spin
|
||||
|
||||
|
||||
def _combo(items=(), *, min_chars: int = 10) -> QComboBox:
|
||||
"""A combo box whose size hint does not depend on its longest entry.
|
||||
|
||||
By default a QComboBox asks for enough width to show its widest item. These
|
||||
hold descriptive phrases, and the side panels are fixed-width — in a scroll
|
||||
area with the horizontal scrollbar off (`_scroll_panel`) an unconstrained
|
||||
hint pushes the inner widget past the panel and everything on the right,
|
||||
including the hint text, is silently clipped instead of scrolling.
|
||||
|
||||
*items* is a sequence of (label, data) pairs, or of plain labels.
|
||||
"""
|
||||
combo = QComboBox()
|
||||
combo.setSizeAdjustPolicy(
|
||||
QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
|
||||
combo.setMinimumContentsLength(min_chars)
|
||||
for item in items:
|
||||
if isinstance(item, tuple):
|
||||
combo.addItem(item[0], item[1])
|
||||
else:
|
||||
combo.addItem(item)
|
||||
return combo
|
||||
|
||||
|
||||
def _axes_extent(x_axis, y_axis, dx: float, dy: float) -> list[float]:
|
||||
"""Matplotlib imshow extent with half-pixel margins, Y flipped so row 0
|
||||
renders at the top."""
|
||||
|
||||
+15
-31
@@ -30,7 +30,8 @@ from .canvases import ImageCanvas, WaveformCanvas
|
||||
from .common import (
|
||||
CH1_DERIVED_MODES, CH_LABELS, CMAPS, VELOCITY_MODE_IDX, _CHANNEL_DISPLAY,
|
||||
_CSS_BUSY, _axes_extent, _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, _LEFT_PANEL_W,
|
||||
_RIGHT_PANEL_W, Jobs, _form, _group, _make_dspin, _scroll_panel, _wrap_label,
|
||||
_RIGHT_PANEL_W, Jobs, _combo, _form, _group, _make_dspin, _scroll_panel,
|
||||
_wrap_label,
|
||||
)
|
||||
from .align_wizard import AlignmentWizard
|
||||
from .dialogs import FftOptionsDialog, RowAverageFftOptionsDialog
|
||||
@@ -97,7 +98,6 @@ class SrasViewerWindow(QMainWindow):
|
||||
|
||||
# Angle alignment ("Fusion" menu)
|
||||
self._alignment_result = None
|
||||
self._alignment_generation: int = 0
|
||||
self._aligned_cache: dict[tuple, np.ndarray] = {}
|
||||
self._align_wizard: AlignmentWizard | None = None
|
||||
|
||||
@@ -242,14 +242,10 @@ class SrasViewerWindow(QMainWindow):
|
||||
ar.addStretch()
|
||||
view_form.addRow("Angle:", angle_field)
|
||||
|
||||
self.combo_channel = QComboBox()
|
||||
self.combo_channel.addItems(CH_LABELS)
|
||||
self.combo_channel = _combo(CH_LABELS, min_chars=12)
|
||||
self.combo_channel.setEnabled(False)
|
||||
self.combo_channel.setSizePolicy(QSizePolicy.Policy.Expanding,
|
||||
QSizePolicy.Policy.Fixed)
|
||||
self.combo_channel.setSizeAdjustPolicy(
|
||||
QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
|
||||
self.combo_channel.setMinimumContentsLength(12)
|
||||
self.combo_channel.currentIndexChanged.connect(self._on_channel_changed)
|
||||
view_form.addRow("Channel:", self.combo_channel)
|
||||
vl.addLayout(view_form)
|
||||
@@ -632,7 +628,8 @@ class SrasViewerWindow(QMainWindow):
|
||||
back through a real FFT — worth saying out loud rather than leaving
|
||||
the user to wonder why a file they batch-computed got slow.
|
||||
|
||||
Deliberately mirrors cached_rf_image's accept rule; the display
|
||||
Asks compute for the reasons rather than restating the accept rule,
|
||||
so a new provenance field can only be added in one place. The display
|
||||
always asks for a raw per-pixel image, since row-averaging is a batch
|
||||
option with no display control.
|
||||
"""
|
||||
@@ -640,17 +637,9 @@ class SrasViewerWindow(QMainWindow):
|
||||
if s is None or all(x is None for x in s.precomputed_freq_mhz):
|
||||
return []
|
||||
|
||||
reasons = []
|
||||
if compute.pad_factor_for(s, self._current_n_fft()) != s.precomputed_pad_factor:
|
||||
reasons.append(f"stored at pad {s.precomputed_pad_factor}x, "
|
||||
f"viewing at pad {self._fft_pad_factor}x")
|
||||
if s.precomputed_bg_sub != (self.chk_bg_sub.isChecked()
|
||||
and s.background is not None):
|
||||
reasons.append("stored with background subtraction "
|
||||
f"{'on' if s.precomputed_bg_sub else 'off'}")
|
||||
if s.precomputed_row_avg_n:
|
||||
reasons.append(f"stored row-averaged (n={s.precomputed_row_avg_n}), "
|
||||
"the display shows raw per-pixel FFTs")
|
||||
reasons = compute.cache_mismatch_reasons(
|
||||
s, n_fft=self._current_n_fft(),
|
||||
apply_bg_sub=self.chk_bg_sub.isChecked(), row_avg_n=0)
|
||||
if not reasons:
|
||||
return []
|
||||
return ["! Cached FFT unusable for this view — " + "; ".join(reasons)
|
||||
@@ -1358,21 +1347,19 @@ class SrasViewerWindow(QMainWindow):
|
||||
|
||||
ref_idx = 0
|
||||
threshold_mv = self.spin_threshold_mv.value()
|
||||
seed: dict[int, ManualAngleParams] = {}
|
||||
# Seed only from a previously *saved* alignment, never from
|
||||
# self._alignment_result: the wizard's first page starts every angle
|
||||
# pre-rotated from the stage angles and expects to own the parameters
|
||||
# from there, so inheriting a half-edited in-memory state would make
|
||||
# "Reset to pre-rotation only" mean something different each time.
|
||||
# Only the threshold carries over from a saved alignment. The wizard's
|
||||
# first page starts every angle pre-rotated from the stage angles and
|
||||
# owns the parameters from there, so inheriting saved per-angle values
|
||||
# would make "Reset to pre-rotation only" mean something different
|
||||
# each time.
|
||||
sidecar = load_manual_alignment(self._sras)
|
||||
if sidecar is not None and sidecar.ref_angle_idx == ref_idx:
|
||||
seed = dict(sidecar.per_angle)
|
||||
threshold_mv = sidecar.dc_threshold_mv
|
||||
|
||||
cached_dc4 = {a: img for (a, ch), img in self._dc_cache.items() if ch == CH4_IDX}
|
||||
wiz = AlignmentWizard(
|
||||
self, self._sras, ref_angle_idx=ref_idx, dc_threshold_mv=threshold_mv,
|
||||
seed_per_angle=seed, cached_dc4_mv=cached_dc4)
|
||||
cached_dc4_mv=cached_dc4)
|
||||
wiz.alignment_ready.connect(self._on_wizard_finished)
|
||||
wiz.finished.connect(self._on_wizard_closed)
|
||||
wiz.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
@@ -1411,15 +1398,12 @@ class SrasViewerWindow(QMainWindow):
|
||||
if self._current_image is not None:
|
||||
self._refresh_display()
|
||||
|
||||
def _apply_alignment_result(self, result, *, view_checked: bool,
|
||||
bump_generation: bool = True):
|
||||
def _apply_alignment_result(self, result, *, view_checked: bool):
|
||||
"""Install (or clear, with result=None) the active alignment: reset
|
||||
the aligned-image cache and set the Aligned View checkbox without
|
||||
firing its change signal."""
|
||||
self._alignment_result = result
|
||||
self._aligned_cache = {}
|
||||
if bump_generation:
|
||||
self._alignment_generation += 1
|
||||
with QSignalBlocker(self.chk_aligned_view):
|
||||
self.chk_aligned_view.setChecked(view_checked)
|
||||
self.chk_aligned_view.setEnabled(result is not None)
|
||||
|
||||
+7
-13
@@ -361,21 +361,17 @@ class CrossCorrelateWorker(_PooledWorker):
|
||||
angle_done = pyqtSignal(int, float, float, float, float, str)
|
||||
|
||||
def __init__(self, sras: SrasFile, ref_angle_idx: int, angle_indices: list[int],
|
||||
dc4_mv: dict[int, np.ndarray], *,
|
||||
sources: tuple[str, ...], dc_threshold_mv: float,
|
||||
search_deg: float, reg_kwargs: dict | None = None):
|
||||
"""*reg_kwargs* is splatted into register_angle_to_reference on top of
|
||||
the named arguments — the wizard's rotation-search controls (seed,
|
||||
signs, refine, grid sizes) go through here, so exposing another knob
|
||||
needs no change to this class."""
|
||||
dc4_mv: dict[int, np.ndarray], *, reg_kwargs: dict | None = None):
|
||||
"""*reg_kwargs* is splatted into register_angle_to_reference — every
|
||||
registration setting the wizard exposes (sources, threshold, search
|
||||
width, seed, signs, refine, grid sizes) travels in it, so this class
|
||||
holds no opinion about which knobs exist and exposing another needs no
|
||||
change here."""
|
||||
super().__init__()
|
||||
self._sras = sras
|
||||
self._ref = ref_angle_idx
|
||||
self._angles = angle_indices
|
||||
self._dc4_mv = dc4_mv
|
||||
self._sources = sources
|
||||
self._threshold = dc_threshold_mv
|
||||
self._search_deg = search_deg
|
||||
self._reg_kwargs = dict(reg_kwargs or {})
|
||||
|
||||
def _plan(self) -> int:
|
||||
@@ -386,9 +382,7 @@ class CrossCorrelateWorker(_PooledWorker):
|
||||
|
||||
def _one(self, a: int) -> tuple[int, compute.RigidFit]:
|
||||
return a, compute.register_angle_to_reference(
|
||||
self._sras, a, self._ref, self._dc4_mv,
|
||||
dc_threshold_mv=self._threshold, sources=self._sources,
|
||||
search_deg=self._search_deg, **self._reg_kwargs)
|
||||
self._sras, a, self._ref, self._dc4_mv, **self._reg_kwargs)
|
||||
|
||||
def _emit(self, result):
|
||||
a, fit = result
|
||||
|
||||
+13
-3
@@ -392,6 +392,14 @@ def test_wizard_nudges(ctx):
|
||||
assert len(wiz.state.layers) == s.n_angles, \
|
||||
"stack rebuilt for every angle after a rotation nudge"
|
||||
|
||||
# A nudge only reprojects the angle that moved, patching the overlap counts
|
||||
# in place. That shortcut is only sound if it lands on exactly what a full
|
||||
# rebuild would have produced.
|
||||
incremental = wiz.state.counts.copy()
|
||||
wiz.rebuild_stack()
|
||||
assert np.array_equal(wiz.state.counts, incremental), \
|
||||
"incremental nudge update matches a full stack rebuild"
|
||||
|
||||
# Real key-event wiring (keyPressEvent -> signal -> slot).
|
||||
before = wiz.state.params[active].shift_mm
|
||||
QTest.keyClick(p1.canvas, Qt.Key.Key_Right)
|
||||
@@ -529,7 +537,8 @@ def test_wizard_crop_dropped_when_going_back(ctx):
|
||||
pump(120)
|
||||
assert wiz.currentId() == wiz.PAGE_CORRELATE
|
||||
assert wiz.state.crop is None, "cleanupPage discarded the stale crop"
|
||||
assert wiz.state.cropped_result is None
|
||||
assert wiz.cropped_plan() == (None, None), \
|
||||
"nothing derived from the dropped crop survives either"
|
||||
wiz.next()
|
||||
pump(150)
|
||||
assert wiz.state.crop is not None, "a fresh default crop is offered again"
|
||||
@@ -546,8 +555,9 @@ def test_wizard_export(ctx):
|
||||
pump(150)
|
||||
assert wiz.currentId() == wiz.PAGE_SAVE, "advanced to the save page"
|
||||
ctx.p3 = p3 = wiz.page(wiz.PAGE_SAVE)
|
||||
assert wiz.state.cropped_result is not None, "crop applied on leaving page 2"
|
||||
assert wiz.state.cropped_result.canvas_shape == ctx.crop[2:], \
|
||||
cropped, _ = wiz.cropped_plan()
|
||||
assert cropped is not None, "crop applied on leaving page 2"
|
||||
assert cropped.canvas_shape == ctx.crop[2:], \
|
||||
"cropped result carries the chosen shape"
|
||||
assert not p3.isComplete(), "Finish unavailable before anything is written"
|
||||
assert p3.lbl_summary.text(), "a summary of what will be written is shown"
|
||||
|
||||
Reference in New Issue
Block a user