Make both batch image exports reproduce the view on screen

Two strands, committed together because they touch the same four files
and answer the same question: an exported PNG must be the image the user
was looking at when they triggered the export.

Dropped view settings (bug fix):
- BatchExportWorker forwarded the DC threshold, bg-sub, pad and row-
  averaging to compute_rf_image but never min_freq_mhz, so every RF and
  Velocity map was exported with no min peak frequency floor. The pixels
  the floor exists to reject came back at their pre-floor peaks.
- That also decided *which source* the image came from: requesting floor
  0 makes a stored v7 cache look like a match (cache_mismatch_reasons
  only rejects a higher stored floor), so a batch-computed file exported
  the cached peak-frequency block verbatim - numbers from an earlier
  Batch Compute run, while the screen showed that same cache re-masked
  against the live floor. The symptom was a newly loaded file exporting
  a stale-looking velocity map, since the floor is a display control
  that survives a load while the window's own FFT cache does not.
- export_view_image had the same class of defect with a different
  parameter: it dropped row_avg_n, so a file whose stored cache is row-
  averaged displayed from the store but exported as a full raw
  recompute - a different, and much slower, image. It now reads
  sras.precomputed_row_avg_n per file, mirroring _stored_fft_image and
  _start_compute, and derived per file for the same reason n_fft is.

Export at the live canvas size:
- Both exporters now take the canvas's current figure size instead of a
  hard-coded 7x5. draw_view_image uses aspect="auto", so the figure box
  is what sets the map's proportions - a view sized wide on screen was
  being squeezed into a different shape on disk. Read at trigger time,
  in inches, so a resize mid-batch cannot change images later in the
  same run and a HiDPI display exports like a standard one.
- sanitize_figsize clamps a degenerate size (collapsed splitter pane,
  minimized window): a wrong-looking aspect ratio must never be the
  reason a batch loses an image.

Tests: BatchExportWorker had no coverage at all. Four new tests cover
the floor being applied, a stored cache never being served unfloored,
the menu-to-worker wiring, and the row-averaged cache case; each was
verified to fail against the unfixed code. 166 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales [M S E]
2026-08-12 12:16:45 -05:00
parent 2937c017a7
commit 48d560fb00
4 changed files with 404 additions and 13 deletions
+52 -1
View File
@@ -22,6 +22,36 @@ from matplotlib.figure import Figure
from sras_compute import compute_rf_image, dc_image_mv
from sras_format import CH4_IDX, CH_NAMES, SrasFile, _axes_extent
# Figure size (inches) to fall back on when a caller has no live view to
# match: the size ImageCanvas is constructed at, so a headless render with
# no canvas behind it still looks like the viewer's starting layout.
DEFAULT_FIGSIZE = (7.0, 5.0)
# The canvas size travels from the GUI as-is, so it can be degenerate (a
# collapsed splitter pane, a window minimized mid-batch) or, on a very wide
# multi-monitor window, big enough that width * dpi approaches matplotlib's
# 2**16 pixel limit. Clamp rather than fail: an export's aspect ratio is a
# presentation detail and must never be the reason a batch loses an image.
_MIN_FIG_IN = 1.0
_MAX_FIG_IN = 100.0
def sanitize_figsize(figsize) -> tuple[float, float]:
"""(width, height) in inches, clamped to something renderable.
*figsize* is None (use DEFAULT_FIGSIZE) or any 2-sequence of numbers --
including the float64 pair Figure.get_size_inches returns, which is how
the GUI hands over the live canvas's current size.
"""
try:
w, h = float(figsize[0]), float(figsize[1])
except (TypeError, ValueError, IndexError, KeyError):
return DEFAULT_FIGSIZE
if not (np.isfinite(w) and np.isfinite(h)):
return DEFAULT_FIGSIZE
return (min(max(w, _MIN_FIG_IN), _MAX_FIG_IN),
min(max(h, _MIN_FIG_IN), _MAX_FIG_IN))
def draw_view_image(ax, fig, img: np.ndarray, extent: list[float], cmap,
vmin: float, vmax: float, xlabel: str, ylabel: str,
@@ -67,6 +97,7 @@ def export_view_image(path: str, *, out_dir: str, angle_idx: int, ch_idx: int,
highlight_masked: bool, mode_str: str,
colorbar_label: str, mask_color: str = "magenta",
max_workers: int | None = None,
figsize: tuple[float, float] | None = None,
dpi: int = 150) -> tuple[str, str]:
"""One file's contribution to Batch Export View as Images: renders
(angle_idx, ch_idx) at the given display settings to a PNG under
@@ -80,11 +111,30 @@ def export_view_image(path: str, *, out_dir: str, angle_idx: int, ch_idx: int,
own data, not a cache conversion, so any version SrasFile can open
works, with no v6/v7 precondition.
*figsize* is the on-screen ImageCanvas's current size in inches, so the
PNG carries the aspect ratio the view was actually being read at. It
matters more here than it would for a plot with fixed data aspect:
draw_view_image uses aspect="auto", so the image stretches to whatever
box it is given -- rendering a view the user has sized wide into a
hard-coded 7x5 squeezes the map into a different shape than the one
they judged it by. Passing the full size, not just the ratio, also
keeps titles, tick labels and the colorbar in the same proportion to
the map as on screen; *dpi* alone then sets the output resolution.
*pad_factor* (not n_fft) travels across files deliberately: n_fft
depends on samples_per_frame, which can differ between files in the
same batch, so n_fft is derived per file, here, from *this* file's own
value -- the same reason sras_compute.cache_file does the same thing.
row_avg_n is per file for the same reason and so is not a parameter at
all: it comes from *this* file's stored cache, mirroring
SrasViewerWindow._stored_fft_image and _start_compute, which both ask at
the window size the store was written at. Leaving it at compute_rf_image's
"raw per-pixel" default would make a row-averaged cache a mismatch, so a
file the viewer displays from its store would export as a full raw
recompute instead -- a different (and much slower) image than the one the
batch was triggered to reproduce.
Returns (error, out_name). error is "" on success. out_name is the
filename this call targeted -- set as soon as it's known, even on most
failures -- so the caller can flag same-stem collisions across the
@@ -105,6 +155,7 @@ def export_view_image(path: str, *, out_dir: str, angle_idx: int, ch_idx: int,
freq = compute_rf_image(
sras, angle_idx, dc_threshold_mv=dc_threshold_mv,
apply_bg_sub=apply_bg_sub, n_fft=n_fft,
row_avg_n=sras.precomputed_row_avg_n,
min_freq_mhz=min_freq_mhz, max_workers=max_workers)
img = freq * grating_um if is_velocity else freq
else:
@@ -141,7 +192,7 @@ def export_view_image(path: str, *, out_dir: str, angle_idx: int, ch_idx: int,
title = (f"{CH_NAMES[ch_idx]} | {mode_str} | "
f"{sras.angles_deg[angle_idx]:.1f}°")
fig = Figure(figsize=(7, 5), tight_layout=True)
fig = Figure(figsize=sanitize_figsize(figsize), tight_layout=True)
FigureCanvasAgg(fig) # Agg-only: never registered with pyplot
ax = fig.add_subplot(111)
draw_view_image(ax, fig, display_img, extent, cmap, v0, v1,