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
+29 -7
View File
@@ -22,7 +22,7 @@ import sras_compute as compute
from sras_align_export import write_aligned_sras
from sras_compute import cache_file, compute_rf_image, dc_image_mv
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile
from sras_render import export_view_image
from sras_render import export_view_image, sanitize_figsize
# Concurrency caps. Batch conversion runs one process per file, and each of
# those processes threads internally, so the two must be divided rather than
@@ -344,11 +344,16 @@ class ExportChannel:
def _render_map_png(img: np.ndarray, extent: list[float], *, cmap: str,
vmin: float, vmax: float, title: str, colorbar_label: str,
out_path: str):
out_path: str, figsize: tuple[float, float] | None = None):
"""Render one map image to *out_path* with a fixed vmin/vmax, using a
headless Agg canvas so this never touches the GUI thread's interactive
matplotlib backend. Layout mirrors ImageCanvas.show_image."""
fig = Figure(figsize=(7, 5), tight_layout=True)
matplotlib backend. Layout mirrors ImageCanvas.show_image.
*figsize* is the live canvas's size in inches, so the PNG comes out at
the shape the view was being read at instead of a fixed 7x5 -- with
aspect="auto" below, the figure box is what sets the map's proportions.
None falls back to sras_render.DEFAULT_FIGSIZE."""
fig = Figure(figsize=sanitize_figsize(figsize), tight_layout=True)
FigureCanvasAgg(fig)
ax = fig.add_subplot(111)
im = ax.imshow(img, aspect="auto", origin="upper", extent=extent,
@@ -372,6 +377,18 @@ class BatchExportWorker(CancellableWorker):
not an arbitrary batch of files, so there is no need to reopen it in a
subprocess the way BatchCacheWorker does.
Every setting that decides what an FFT-derived pixel *is* has to arrive
here explicitly, because compute_rf_image defaults each one to "off" and
an omitted argument is therefore not a no-op -- it silently exports a
different image than the one on screen. *min_freq_mhz* is the one with
teeth: dropping it does not just skip a mask, it also makes this file's
stored cache (written by Batch Compute FFT at some earlier floor) look
like a match, so the export hands back the pre-floor peaks the user
raised the floor to get rid of. It mirrors
SrasViewerWindow._stored_fft_image, which takes the floor live for the
same reason -- re-masking a stored image against a *higher* floor is
free, so the displayed map always honors the spin box.
Emits progress(int) (0-100 over all angle x channel pairs), file_done(str,
str) (output path, error message or ""), and finished() -- same shape as
BatchCacheWorker.
@@ -383,7 +400,9 @@ class BatchExportWorker(CancellableWorker):
def __init__(self, sras: SrasFile, channels: list[ExportChannel],
output_dir: str, prefix: str, *, cmap: str,
apply_bg_sub: bool, dc_threshold_mv: float,
n_fft: int | None, grating_um: float):
n_fft: int | None, grating_um: float,
min_freq_mhz: float = 0.0,
figsize: tuple[float, float] | None = None):
super().__init__()
self._sras = sras
self._channels = channels
@@ -394,6 +413,8 @@ class BatchExportWorker(CancellableWorker):
self._dc_threshold_mv = dc_threshold_mv
self._n_fft = n_fft
self._grating_um = grating_um
self._min_freq_mhz = min_freq_mhz
self._figsize = figsize
def _angle_extent(self, angle_idx: int) -> list[float]:
s = self._sras
@@ -423,7 +444,8 @@ class BatchExportWorker(CancellableWorker):
freq_mhz = compute_rf_image(
s, angle_idx, dc_threshold_mv=self._dc_threshold_mv,
apply_bg_sub=self._apply_bg_sub, n_fft=self._n_fft,
should_stop=self._stopped, row_avg_n=s.precomputed_row_avg_n)
should_stop=self._stopped, row_avg_n=s.precomputed_row_avg_n,
min_freq_mhz=self._min_freq_mhz)
for channel in self._channels:
if self._stop:
@@ -444,7 +466,7 @@ class BatchExportWorker(CancellableWorker):
img, extent, cmap=self._cmap,
vmin=channel.vmin, vmax=channel.vmax,
title=title, colorbar_label=colorbar_label,
out_path=str(out_path))
out_path=str(out_path), figsize=self._figsize)
self.file_done.emit(str(out_path), "")
except Exception as exc:
self.file_done.emit(str(out_path), str(exc))