From 48d560fb003a3eeb3c467d62eb5ef8b3375669f3 Mon Sep 17 00:00:00 2001 From: "Thomas Ales [M S E]" Date: Wed, 12 Aug 2026 12:16:45 -0500 Subject: [PATCH] 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 --- sras_render.py | 53 ++++++- sras_viewer/main_window.py | 27 +++- sras_workers.py | 36 ++++- tests/test_batch_export.py | 301 ++++++++++++++++++++++++++++++++++++- 4 files changed, 404 insertions(+), 13 deletions(-) diff --git a/sras_render.py b/sras_render.py index 65dbcb0..3531432 100644 --- a/sras_render.py +++ b/sras_render.py @@ -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, diff --git a/sras_viewer/main_window.py b/sras_viewer/main_window.py index 8eb4980..3157fd0 100644 --- a/sras_viewer/main_window.py +++ b/sras_viewer/main_window.py @@ -1716,6 +1716,25 @@ class SrasViewerWindow(QMainWindow): # Batch export view as images # ------------------------------------------------------------------ + def _view_figsize(self) -> tuple[float, float]: + """The image canvas's current figure size in inches — handed to every + image export so the PNG has the shape the view is being read at. + + The canvas draws with aspect="auto" and expands with the window, so + the map's proportions are the widget's, not the data's; a renderer + with its own hard-coded size necessarily distorts the export + relative to the screen. + + Inches rather than pixels because that is what Figure takes, and + matplotlib's Qt canvas keeps size_inches in logical units (it scales + figure.dpi by the device pixel ratio and divides physical pixels by + it), so the same window gives the same export on a HiDPI display as + on a standard one. Read at trigger time: a resize mid-batch must not + change the shape of images later in the same run. + """ + w, h = self.image_canvas.figure.get_size_inches() + return (float(w), float(h)) + def _on_batch_export_images(self): if self._job_running(Jobs.BATCH): return @@ -1751,6 +1770,7 @@ class SrasViewerWindow(QMainWindow): highlight_masked=self.chk_highlight_masked.isChecked(), mode_str=mode_str, colorbar_label=colorbar_label, mask_color=_MASKED_HIGHLIGHT_COLOR, + figsize=self._view_figsize(), ) started = self._run_worker( Jobs.BATCH, worker, @@ -1858,7 +1878,12 @@ class SrasViewerWindow(QMainWindow): cmap=self.combo_cmap.currentText(), apply_bg_sub=self.chk_bg_sub.isChecked(), dc_threshold_mv=self.spin_threshold_mv.value(), - n_fft=self._current_n_fft(), grating_um=self.spin_grating_um.value()) + n_fft=self._current_n_fft(), grating_um=self.spin_grating_um.value(), + # Live, like every other display control here and like + # _stored_fft_image: the exported RF/Velocity map has to be the + # one on screen, floor included. + min_freq_mhz=self.spin_min_freq_mhz.value(), + figsize=self._view_figsize()) started = self._run_worker( Jobs.EXPORT, worker, connect=( diff --git a/sras_workers.py b/sras_workers.py index ff8b3c7..61273c1 100644 --- a/sras_workers.py +++ b/sras_workers.py @@ -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)) diff --git a/tests/test_batch_export.py b/tests/test_batch_export.py index 22f8d7d..78b89bb 100644 --- a/tests/test_batch_export.py +++ b/tests/test_batch_export.py @@ -1,7 +1,18 @@ -"""Batch Export View as Images: does the exported PNG actually match what +"""Both batch image exports -- Convert -> Batch Export View as Images (one +PNG per *file*, via sras_render.export_view_image) and Export -> Batch +Export Images (one PNG per *angle x channel* of the open file, via +sras_workers.BatchExportWorker). Does the exported PNG actually match what the live view would show, and does the batch dispatch (menu action -> worker -> per-file render) behave like Batch Compute's proven pattern? +The recurring hazard both halves guard is a dropped view setting: every +parameter that decides what an FFT-derived pixel *is* (bg-sub, pad, +row-averaging, the min peak frequency floor) defaults to "off" in +compute_rf_image, so an argument the export forgets to forward does not +degrade gracefully -- it silently renders a different image than the screen, +and worse, makes this file's stored cache look like a match so the export +hands back peaks from an earlier compute. + sras_render.export_view_image is tested directly (no Qt) for the plumbing that decides *what* gets rendered -- pad_factor derived per file, masked- pixel NaN fill, per-file auto-scale, velocity scaling -- via a @@ -23,11 +34,14 @@ from PyQt6.QtCore import QEventLoop, QTimer from PyQt6.QtWidgets import QApplication import sras_render -from sras_compute import compute_rf_image, dc_image_mv +import sras_workers +from sras_compute import cache_file, compute_rf_image, dc_image_mv from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile -from sras_render import export_view_image +from sras_render import DEFAULT_FIGSIZE, export_view_image, sanitize_figsize from sras_viewer import SrasViewerWindow, VELOCITY_MODE_IDX -from sras_workers import BatchExportImagesWorker +from sras_workers import ( + BatchExportImagesWorker, BatchExportWorker, ExportChannel, +) import tools.make_test_sras as gen _THRESHOLD_MV = 50.0 @@ -67,6 +81,16 @@ def _kw(**overrides): return kw +def _png_size(path) -> tuple[int, int]: + """(width, height) in pixels, read straight out of the PNG's IHDR chunk + (two big-endian uint32s at byte 16) -- no image library needed just to + check the shape of an export.""" + raw = Path(path).read_bytes() + assert raw[:8] == b"\x89PNG\r\n\x1a\n", "not a valid PNG" + return (int.from_bytes(raw[16:20], "big"), + int.from_bytes(raw[20:24], "big")) + + def _spy_draw(monkeypatch): """Patches sras_render.draw_view_image to record the image array and vmin/vmax/bad_color it was called with, then delegates to the real @@ -248,6 +272,60 @@ def test_auto_scale_uses_per_file_min_max(tmp_path, monkeypatch): assert captured2["vmax"] == 5.0 +@pytest.mark.parametrize("figsize", [(12.0, 4.0), (4.0, 9.0)]) +def test_export_matches_requested_figsize(tmp_path, figsize): + """The PNG comes out at the caller's figure size, so a view the user has + sized wide (or tall) doesn't get squeezed into a fixed 7x5 -- the image + is drawn with aspect="auto", so the figure box *is* the map's shape.""" + path = tmp_path / "shape.sras" + gen.write(path, n_angles=1, seed=70, samples_per_frame=64) + out_dir = tmp_path / "out" + out_dir.mkdir() + + dpi = 100 + err, out_name = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH4_IDX, + figsize=figsize, dpi=dpi, **_kw()) + assert err == "" + + w_px, h_px = _png_size(out_dir / out_name) + # Agg truncates inches*dpi to whole pixels; a pixel of slack, not an + # aspect-ratio tolerance, is what's being allowed for here. + assert abs(w_px - figsize[0] * dpi) <= 1 + assert abs(h_px - figsize[1] * dpi) <= 1 + + +def test_export_default_figsize_when_unspecified(tmp_path): + """No figsize (a caller with no live canvas to match) still renders at + the viewer's starting size rather than failing or guessing.""" + path = tmp_path / "default_shape.sras" + gen.write(path, n_angles=1, seed=71, samples_per_frame=64) + out_dir = tmp_path / "out" + out_dir.mkdir() + + err, out_name = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH4_IDX, + dpi=100, **_kw()) + assert err == "" + w_px, h_px = _png_size(out_dir / out_name) + assert abs(w_px - DEFAULT_FIGSIZE[0] * 100) <= 1 + assert abs(h_px - DEFAULT_FIGSIZE[1] * 100) <= 1 + + +@pytest.mark.parametrize("bad", [None, (0.0, 0.0), (-3.0, 5.0), (np.nan, 5.0), + (float("inf"), 5.0), (7.0,), "7x5"]) +def test_sanitize_figsize_never_yields_an_unrenderable_size(bad): + """A degenerate canvas size (collapsed pane, minimized window) must cost + at most a wrong-looking image, never a failed export.""" + w, h = sanitize_figsize(bad) + assert np.isfinite(w) and np.isfinite(h) + assert w >= 1.0 and h >= 1.0 + + +def test_sanitize_figsize_passes_through_a_normal_canvas_size(): + assert sanitize_figsize(np.array([12.8, 6.4])) == pytest.approx((12.8, 6.4)) + + # --------------------------------------------------------------------------- # GUI dispatch: SrasViewerWindow._on_batch_export_images end-to-end # --------------------------------------------------------------------------- @@ -375,6 +453,47 @@ def test_batch_export_ignores_aligned_view_toggle(tmp_path): pump(200) +def test_batch_export_uses_the_live_canvas_aspect_ratio(tmp_path): + """The exported PNG has the shape of the view on screen, not a fixed + 7x5 -- resize the window and the export follows it.""" + path = tmp_path / "aspect.sras" + gen.write(path, n_angles=1, seed=45, samples_per_frame=64) + out_dir = tmp_path / "images" + out_dir.mkdir() + + win = _make_window(path) + try: + win.resize(1400, 700) + pump(300) # let the canvas' resizeEvent reach the figure + canvas_w, canvas_h = win.image_canvas.figure.get_size_inches() + + captured_kwargs = [] + orig_init = BatchExportImagesWorker.__init__ + + def spy_init(self, paths, **kw): + captured_kwargs.append(kw) + return orig_init(self, paths, **kw) + + with patch.object(BatchExportImagesWorker, "__init__", spy_init), \ + patch("sras_viewer.main_window.QFileDialog.getOpenFileNames", + return_value=([str(path)], "")), \ + patch("sras_viewer.main_window.QFileDialog.getExistingDirectory", + return_value=str(out_dir)): + win._on_batch_export_images() + assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran" + + assert captured_kwargs[0]["figsize"] == pytest.approx( + (canvas_w, canvas_h)), "the live canvas size must travel to the worker" + + out_files = list(out_dir.iterdir()) + assert len(out_files) == 1 + w_px, h_px = _png_size(out_files[0]) + assert w_px / h_px == pytest.approx(canvas_w / canvas_h, rel=0.01) + finally: + win.close() + pump(200) + + def test_batch_export_filename_collision_note(tmp_path): dir_a, dir_b = tmp_path / "dir_a", tmp_path / "dir_b" dir_a.mkdir() @@ -424,3 +543,177 @@ def test_batch_export_does_not_modify_source_files(tmp_path): pump(200) assert path.read_bytes() == before, "export must never write to the source file" + + +# --------------------------------------------------------------------------- +# Export menu: BatchExportWorker (every angle of the open file) +# --------------------------------------------------------------------------- + +# High enough to sit above several of the generator's synthetic peak bins +# (bin spacing is 6.25 GS/s / 64 = 97.66 MHz, peaks land at bins 3-19), so a +# floor at this value genuinely changes which peak each pixel resolves to -- +# without that, a test that the floor is honored would pass either way. +_FLOOR_MHZ = 1000.0 +_GRATING_UM = 2.0 + + +def _velocity_channel() -> ExportChannel: + return ExportChannel(ch_idx=CH1_IDX, is_velocity=True, vmin=0.0, vmax=1e5, + label="Velocity", unit="m/s", tag="VEL") + + +def _run_export_worker(monkeypatch, sras, out_dir, **overrides) -> list[np.ndarray]: + """Runs BatchExportWorker to completion on the calling thread (its run() + is a plain loop; the QThread in _run_worker is a GUI concern) and returns + the image arrays it rendered, captured at the _render_map_png seam.""" + captured = [] + orig = sras_workers._render_map_png + + def spy(img, extent, **kw): + captured.append(np.array(img, copy=True)) + return orig(img, extent, **kw) + + monkeypatch.setattr(sras_workers, "_render_map_png", spy) + kw = dict(cmap="viridis", apply_bg_sub=True, dc_threshold_mv=_THRESHOLD_MV, + n_fft=None, grating_um=_GRATING_UM, min_freq_mhz=_FLOOR_MHZ) + kw.update(overrides) + BatchExportWorker(sras, [_velocity_channel()], str(out_dir), "vel", **kw).run() + return captured + + +def test_batch_export_applies_the_min_peak_freq_floor(tmp_path, monkeypatch): + """The exported Velocity map is the floored one the viewer shows, not the + unfloored peaks the floor was raised to reject.""" + path = tmp_path / "floor.sras" + gen.write(path, n_angles=1, seed=80, samples_per_frame=64, geometry=[(6, 9)]) + out_dir = tmp_path / "out" + out_dir.mkdir() + sras = SrasFile(str(path)) + + def expected(floor): + return compute_rf_image(sras, 0, dc_threshold_mv=_THRESHOLD_MV, + apply_bg_sub=True, n_fft=None, + min_freq_mhz=floor) * _GRATING_UM + + floored, unfloored = expected(_FLOOR_MHZ), expected(0.0) + assert not np.allclose(floored, unfloored), \ + "fixture must be one where the floor changes the image" + + exported = _run_export_worker(monkeypatch, sras, out_dir) + assert len(exported) == 1 + assert np.allclose(exported[0], floored) + + +def test_batch_export_does_not_serve_an_unfloored_stored_cache(tmp_path, + monkeypatch): + """A file batch-computed before the floor existed has a stored cache with + no floor recorded. Asking for that cache at floor 0 (rather than the live + floor) makes it a match, so the export would render the stored pre-floor + peaks -- the map the user raised the floor to get rid of.""" + path = tmp_path / "stored_floor.sras" + gen.write(path, n_angles=1, seed=81, samples_per_frame=64, geometry=[(6, 9)]) + assert cache_file(str(path), "fft", apply_bg_sub=True) == "" + sras = SrasFile(str(path)) + assert sras.precomputed_freq_mhz[0] is not None, "stored cache written" + assert sras.precomputed_min_freq_mhz == 0.0 + + out_dir = tmp_path / "out" + out_dir.mkdir() + exported = _run_export_worker(monkeypatch, sras, out_dir)[0] + + # 0.0 is the "no valid peak" sentinel; anything else below the floor is a + # peak that only an unfloored search could have reported. + below = (exported > 0) & (exported < _FLOOR_MHZ * _GRATING_UM) + assert not below.any(), \ + f"{below.sum()} sub-floor pixel(s) survived the export" + + +def test_batch_export_dispatch_passes_the_live_view_settings(tmp_path): + """The Export menu hands the worker what the panel currently says -- + the floor in particular, which is a display control and so persists + across file loads while the window's own FFT cache does not.""" + path = tmp_path / "dispatch.sras" + gen.write(path, n_angles=1, seed=82, samples_per_frame=64) + out_dir = tmp_path / "out" + out_dir.mkdir() + + win = _make_window(path) + try: + win.spin_min_freq_mhz.setValue(_FLOOR_MHZ) + win.spin_grating_um.setValue(_GRATING_UM) + win.spin_threshold_mv.setValue(_THRESHOLD_MV) + + class StubDialog: + def __init__(self, parent, **kw): + pass + + def exec(self): + from PyQt6.QtWidgets import QDialog + return QDialog.DialogCode.Accepted + + def get_output_dir(self): + return str(out_dir) + + def get_prefix(self): + return "vel" + + def get_selected_channels(self): + return [_velocity_channel()] + + captured_kwargs = [] + orig_init = BatchExportWorker.__init__ + + def spy_init(self, sras, channels, output_dir, prefix, **kw): + captured_kwargs.append(kw) + return orig_init(self, sras, channels, output_dir, prefix, **kw) + + with patch.object(BatchExportWorker, "__init__", spy_init), \ + patch("sras_viewer.main_window.BatchExportDialog", StubDialog): + win._on_batch_export() + assert wait_until(lambda: not win._job_running("export"), 60000), "export ran" + + kw = captured_kwargs[0] + assert kw["min_freq_mhz"] == _FLOOR_MHZ + assert kw["grating_um"] == _GRATING_UM + assert kw["dc_threshold_mv"] == _THRESHOLD_MV + finally: + win.close() + pump(200) + + +def test_export_view_image_serves_a_row_averaged_stored_cache(tmp_path, + monkeypatch): + """Batch Export View as Images must ask this file's cache the question the + viewer asks it. _stored_fft_image reads at the file's own + precomputed_row_avg_n; requesting raw per-pixel instead makes a + row-averaged cache a mismatch, and the export silently renders a full raw + recompute where the screen shows the smoothed stored image.""" + path = tmp_path / "rowavg_cache.sras" + gen.write(path, n_angles=1, seed=83, samples_per_frame=64, geometry=[(6, 9)]) + + # A planted stored image rather than a real row-averaged compute: the + # question here is *which* source the export reads, and a distinctive + # array answers it without depending on the synthetic waveforms being + # smooth enough for averaging to move the numbers. + sras = SrasFile(str(path)) + shape = sras.image_shape(0) + stored = (100.0 + 10.0 * np.arange(shape[0] * shape[1], dtype=np.float32) + ).reshape(shape) + sras.write_v7_cache(new_freq_mhz=[stored], new_row_avg_n=5, + new_bg_sub=True, new_pad_factor=1) + + reread = SrasFile(str(path)) + raw = compute_rf_image(reread, 0, dc_threshold_mv=None, apply_bg_sub=True, + n_fft=None, row_avg_n=0, use_stored=False) + assert not np.allclose(stored, raw), "planted cache must be distinguishable" + + out_dir = tmp_path / "out" + out_dir.mkdir() + captured = _spy_draw(monkeypatch) + err, _out_name = export_view_image( + str(path), out_dir=str(out_dir), angle_idx=0, ch_idx=CH1_IDX, + **_kw(is_fft_mode=True, dc_threshold_mv=-1e9, colorbar_label="MHz", + mode_str="RF")) + assert err == "" + assert np.allclose(captured["img"], stored), \ + "export rendered a recompute instead of the stored image on screen"