Merge the Documents/sras-viewer working copy into this repo
The two clones had diverged from origin/main (191d1b8) and both had
grown uncommitted work. This merges the other clone's three commits and
reconciles four conflicting files.
Both clones independently grew a feature called "batch export", but they
are different sweeps and both are kept:
- BatchExportWorker (this clone) — every angle x channel of the one open
file, fixed colorbar per channel, under a new Export menu.
- BatchExportImagesWorker (other clone) — the current view across many
selected files, process-pooled, under Convert.
Conflict resolutions of note:
- sras_average.py: the other clone's memory-bounded rewrite had silently
reverted this clone's float32 -> int16 accumulator fix, which exists to
keep averaged output from landing 1 ADC count off the original tool.
Kept the rewrite, re-applied the fix, and corrected the two docstrings
that still claimed float32.
- tests/test_compute.py: kept the other clone's meta["waveforms"] key
(meta["data"] no longer exists for this fixture and would KeyError)
and its laser_freq_hz assertions, plus this clone's int16 expectation
and its dropped subprocess returncode assertions.
- ComputeWorker: row_avg_n and min_freq_mhz were added independently on
either side; both are now threaded through.
- compute_rf_image's `exact` parameter is gone, removed by the other
clone's PyFFTW peak-search rewrite. It had no remaining callers.
Verified: 149 passed with a complete dependency set. The failures seen
with this clone's own .venv are a missing scikit-image, which predates
this merge and reproduces identically on the pre-merge commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+36
-18
@@ -12,6 +12,7 @@ from PyQt6.QtGui import QKeyEvent
|
||||
from PyQt6.QtWidgets import QSizePolicy
|
||||
|
||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv
|
||||
from sras_render import draw_view_image
|
||||
|
||||
def count_colormap(n_angles: int):
|
||||
"""(cmap, norm, ticks) for an integer "how many angles cover this pixel"
|
||||
@@ -166,11 +167,14 @@ class ImageCanvas(FigureCanvasQTAgg):
|
||||
|
||||
def show_image(self, img: np.ndarray, extent: list[float], cmap,
|
||||
vmin: float, vmax: float, xlabel: str, ylabel: str, title: str,
|
||||
colorbar_label: str = "", cb_ticks=None, norm=None):
|
||||
colorbar_label: str = "", cb_ticks=None, norm=None,
|
||||
bad_color=None):
|
||||
"""*cmap* may be a name or a Colormap instance. *norm* (which overrides
|
||||
vmin/vmax) and *cb_ticks* let a caller draw a discrete integer image —
|
||||
the wizard's overlap-count view — with whole-number colorbar bands
|
||||
instead of a continuous shade."""
|
||||
instead of a continuous shade. *bad_color*, if given, is the fill for
|
||||
NaN pixels — a copy of *cmap* is made so the shared, registered
|
||||
instance is never mutated."""
|
||||
self.figure.clf()
|
||||
self.ax = self.figure.add_subplot(111)
|
||||
# Patches and lines are destroyed by figure.clf(); drop stale refs.
|
||||
@@ -179,20 +183,12 @@ class ImageCanvas(FigureCanvasQTAgg):
|
||||
self._extent = extent
|
||||
self._img_shape = img.shape
|
||||
|
||||
kw = ({"norm": norm} if norm is not None
|
||||
else {"vmin": vmin, "vmax": vmax})
|
||||
im = self.ax.imshow(
|
||||
img, aspect="auto", origin="upper",
|
||||
extent=extent, cmap=cmap, interpolation="nearest", **kw,
|
||||
)
|
||||
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04,
|
||||
ticks=cb_ticks)
|
||||
if colorbar_label:
|
||||
cb.set_label(colorbar_label)
|
||||
|
||||
self.ax.set_xlabel(xlabel)
|
||||
self.ax.set_ylabel(ylabel)
|
||||
self.ax.set_title(title)
|
||||
# Shared with the headless batch image-export worker (sras_render.py)
|
||||
# so an exported PNG can never quietly drift from what this canvas
|
||||
# shows on screen for the same settings.
|
||||
draw_view_image(self.ax, self.figure, img, extent, cmap, vmin, vmax,
|
||||
xlabel, ylabel, title, colorbar_label, cb_ticks, norm,
|
||||
bad_color)
|
||||
|
||||
# Re-draw the ROI (if any) on top of the fresh image so it persists
|
||||
# unchanged across mode / angle / channel switches.
|
||||
@@ -433,13 +429,22 @@ class WaveformCanvas(FigureCanvasQTAgg):
|
||||
|
||||
def show_rf_waveform(self, sras: SrasFile, angle_idx: int,
|
||||
row_idx: int, frame_idx: int,
|
||||
apply_bg_sub: bool = True):
|
||||
apply_bg_sub: bool = True,
|
||||
min_freq_mhz: float = 0.0):
|
||||
"""CH1 RF: time-domain + FFT spectrum.
|
||||
|
||||
If apply_bg_sub is True and sras.background is not None, the background
|
||||
waveform is overlaid on the time-domain plot and the FFT is computed
|
||||
on the subtracted signal. The unsubtracted FFT is also shown faintly
|
||||
for comparison.
|
||||
|
||||
*min_freq_mhz* > 0 restricts the labeled peak to bins at or above
|
||||
it — the same floor the image's peak search uses, so the label
|
||||
explains the map pixel instead of contradicting it — and shades the
|
||||
excluded band on the spectrum. The spectrum curves themselves stay
|
||||
complete (they are the evidence for choosing the floor). The peak
|
||||
can still legitimately differ from a padded or row-averaged map:
|
||||
this panel is always a single waveform at natural resolution.
|
||||
"""
|
||||
data = sras.data[angle_idx]
|
||||
waveform = data[row_idx, CH1_IDX, frame_idx, :].astype(np.float32)
|
||||
@@ -480,7 +485,20 @@ class WaveformCanvas(FigureCanvasQTAgg):
|
||||
# FFT of the (possibly subtracted) waveform
|
||||
power_sub = np.abs(np.fft.rfft(waveform_plot)) ** 2
|
||||
power_sub[0] = 0.0
|
||||
peak_mhz = f_mhz[int(np.argmax(power_sub))]
|
||||
# First bin at or above the floor, exactly as the image peak search
|
||||
# picks it (bin 0 always excluded). If the floor excludes every bin,
|
||||
# fall back to the unrestricted peak rather than indexing past the
|
||||
# end — the label is informational, not a mask.
|
||||
lo = max(1, int(np.searchsorted(f_mhz, min_freq_mhz)))
|
||||
if lo < len(power_sub):
|
||||
peak_mhz = f_mhz[lo + int(np.argmax(power_sub[lo:]))]
|
||||
else:
|
||||
peak_mhz = f_mhz[int(np.argmax(power_sub))]
|
||||
|
||||
if min_freq_mhz > 0.0:
|
||||
self.ax_right.axvspan(0, min_freq_mhz, color="#888888",
|
||||
alpha=0.15, zorder=0,
|
||||
label=f"< {min_freq_mhz:g} MHz excluded")
|
||||
|
||||
if bg is not None:
|
||||
# Also show the unsubtracted FFT for reference
|
||||
|
||||
@@ -6,7 +6,7 @@ from PyQt6.QtWidgets import (
|
||||
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX
|
||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, _axes_extent # noqa: F401 (re-exported)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Display constants
|
||||
@@ -26,6 +26,10 @@ CH1_DERIVED_MODES = (CH1_IDX, VELOCITY_MODE_IDX)
|
||||
|
||||
CMAPS = ["gray", "viridis", "plasma", "inferno", "hot", "jet", "RdBu_r", "seismic"]
|
||||
|
||||
# Fill color for masked (below-DC-threshold) pixels when "Highlight masked
|
||||
# pixels" is on, chosen to stand out against every colormap in CMAPS above.
|
||||
_MASKED_HIGHLIGHT_COLOR = "magenta"
|
||||
|
||||
# (mode_str, status-bar unit, colorbar label) per channel index
|
||||
_CHANNEL_DISPLAY = {
|
||||
CH1_IDX: ("RF", "Peak frequency (MHz)", "MHz"),
|
||||
@@ -109,13 +113,6 @@ def _combo(items=(), *, min_chars: int = 10) -> QComboBox:
|
||||
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."""
|
||||
return [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
|
||||
y_axis[-1] + dy / 2, y_axis[0] - dy / 2]
|
||||
|
||||
|
||||
def _wrap_label(text: str = "", css: str | None = None) -> QLabel:
|
||||
"""A word-wrapped QLabel that reports its *wrapped* height to the layout.
|
||||
|
||||
|
||||
+1
-29
@@ -14,7 +14,6 @@ from PyQt6.QtWidgets import (
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from sras_compute import PYFFTW_AVAILABLE
|
||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES
|
||||
from sras_workers import ExportChannel
|
||||
|
||||
@@ -29,7 +28,7 @@ from .common import (
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FftOptionsDialog(QDialog):
|
||||
"""Configure FFT backend and zero-padding.
|
||||
"""Configure FFT zero-padding.
|
||||
|
||||
Changes take effect only when the user clicks Apply. Cancel discards
|
||||
all pending edits. The live 'frequency resolution' label updates as
|
||||
@@ -38,7 +37,6 @@ class FftOptionsDialog(QDialog):
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, *,
|
||||
current_backend: str,
|
||||
current_pad_factor: int,
|
||||
samples_per_frame: int | None,
|
||||
sample_rate_hz: float | None,
|
||||
@@ -54,29 +52,6 @@ class FftOptionsDialog(QDialog):
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# ---- Backend ---------------------------------------------------
|
||||
grp_backend = QGroupBox("FFT Backend")
|
||||
bl = QVBoxLayout(grp_backend)
|
||||
|
||||
self._btn_scipy = QRadioButton("SciPy FFT (pocketfft) (always available)")
|
||||
self._btn_pyfftw = QRadioButton(
|
||||
"pyFFTW (faster for large arrays)" if PYFFTW_AVAILABLE
|
||||
else "pyFFTW (not installed — run: pip install pyfftw)")
|
||||
self._btn_pyfftw.setEnabled(PYFFTW_AVAILABLE)
|
||||
|
||||
self._backend_group = QButtonGroup(self)
|
||||
self._backend_group.addButton(self._btn_scipy, id=0)
|
||||
self._backend_group.addButton(self._btn_pyfftw, id=1)
|
||||
|
||||
if current_backend == "pyfftw" and PYFFTW_AVAILABLE:
|
||||
self._btn_pyfftw.setChecked(True)
|
||||
else:
|
||||
self._btn_scipy.setChecked(True)
|
||||
|
||||
bl.addWidget(self._btn_scipy)
|
||||
bl.addWidget(self._btn_pyfftw)
|
||||
layout.addWidget(grp_backend)
|
||||
|
||||
# ---- Zero-padding ----------------------------------------------
|
||||
grp_zp = QGroupBox("Zero-Padding")
|
||||
zl = QVBoxLayout(grp_zp)
|
||||
@@ -139,9 +114,6 @@ class FftOptionsDialog(QDialog):
|
||||
f"Velocity bin: {vel_res_ms:.3f} m/s "
|
||||
f"(at grating = {self._grating_um:.2f} µm)")
|
||||
|
||||
def get_backend(self) -> str:
|
||||
return "pyfftw" if self._btn_pyfftw.isChecked() and PYFFTW_AVAILABLE else "scipy"
|
||||
|
||||
def get_pad_factor(self) -> int:
|
||||
return max(1, self._spin_pad.value())
|
||||
|
||||
|
||||
+291
-56
@@ -1,6 +1,7 @@
|
||||
"""The SrasViewerWindow main window and application entry point."""
|
||||
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
@@ -23,16 +24,16 @@ from sras_format import (
|
||||
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
|
||||
)
|
||||
from sras_workers import (
|
||||
BatchCacheWorker, BatchExportWorker, ComputeWorker, DcPrecomputeWorker,
|
||||
LoadWorker,
|
||||
BatchCacheWorker, BatchExportImagesWorker, BatchExportWorker,
|
||||
ComputeWorker, DcPrecomputeWorker, LoadWorker,
|
||||
)
|
||||
|
||||
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, _combo, _form, _group, _make_dspin, _scroll_panel,
|
||||
_wrap_label,
|
||||
_MASKED_HIGHLIGHT_COLOR, _RIGHT_PANEL_W, Jobs, _combo, _form, _group, _make_dspin,
|
||||
_scroll_panel, _wrap_label,
|
||||
)
|
||||
from .align_wizard import AlignmentWizard
|
||||
from .dialogs import (
|
||||
@@ -60,6 +61,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._pending_ch: int = 0
|
||||
self._pending_bg_sub: bool = True
|
||||
self._pending_threshold: float = 50.0 # mV
|
||||
self._pending_min_freq_mhz: float = 0.0
|
||||
self._pending_fft_pad_factor: int = 1
|
||||
|
||||
# Live background jobs, keyed by role — see _run_worker.
|
||||
@@ -72,7 +74,6 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._settings = QSettings(QSettings.Format.IniFormat,
|
||||
QSettings.Scope.UserScope,
|
||||
"sras-viewer", "sras-viewer")
|
||||
compute.set_fft_backend(str(self._settings.value("fft/backend", "scipy")))
|
||||
try:
|
||||
pad = int(self._settings.value("fft/pad_factor", 1))
|
||||
except (TypeError, ValueError):
|
||||
@@ -98,13 +99,13 @@ class SrasViewerWindow(QMainWindow):
|
||||
# computed lazily (with a progress popup) the first time an
|
||||
# angle/threshold combination is viewed — using the cached DC4
|
||||
# image to skip the FFT entirely for masked-out pixels — and
|
||||
# cached per (angle, threshold) so revisiting the same combination
|
||||
# is free. bg-sub/pad are deliberately not part of the key: once an
|
||||
# angle has any FFT image (live or from the file's own stored
|
||||
# cache), it stays displayed regardless of those controls — see
|
||||
# _fft_cache_key.
|
||||
# cached per (angle, threshold, min peak freq) so revisiting the
|
||||
# same combination is free. bg-sub/pad are deliberately not part of
|
||||
# the key: once an angle has any FFT image (live or from the file's
|
||||
# own stored cache), it stays displayed regardless of those
|
||||
# controls — see _fft_cache_key.
|
||||
self._dc_cache: dict[tuple[int, int], np.ndarray] = {}
|
||||
self._fft_cache: dict[tuple[int, float], np.ndarray] = {}
|
||||
self._fft_cache: dict[tuple[int, float, float], np.ndarray] = {}
|
||||
self._dc_generation: int = 0
|
||||
|
||||
# Angle alignment ("Fusion" menu)
|
||||
@@ -278,6 +279,31 @@ class SrasViewerWindow(QMainWindow):
|
||||
self.lbl_threshold_adc = _wrap_label(
|
||||
f"≈ {mv_to_adc(50.0):.1f} ADC counts", _CSS_MUTED)
|
||||
tl.addWidget(self.lbl_threshold_adc)
|
||||
|
||||
min_freq_form = _form()
|
||||
self.spin_min_freq_mhz = _make_dspin(0.0, 10000.0, 3, suffix=" MHz",
|
||||
value=0.0, step=1.0)
|
||||
self.spin_min_freq_mhz.setEnabled(False)
|
||||
self.spin_min_freq_mhz.setToolTip(
|
||||
"Excludes every FFT bin below this frequency from the peak\n"
|
||||
"search (0 = off, only true DC is excluded). A pixel can pass\n"
|
||||
"the DC threshold above yet still carry only weak real signal —\n"
|
||||
"when that happens, the un-subtracted background's DC-leakage\n"
|
||||
"skirt can have more power than the genuine signal, so the peak\n"
|
||||
"search resolves to a near-zero frequency even though the pixel\n"
|
||||
"is real, valid data. Raising this floor above that skirt forces\n"
|
||||
"the search to report the strongest peak that is plausibly real\n"
|
||||
"signal instead.\n"
|
||||
"Raising the floor also re-masks images already shown or stored\n"
|
||||
"in this file's cache: pixels whose stored peak falls below it\n"
|
||||
"display as invalid. Lowering it below a stored cache's own\n"
|
||||
"floor needs a recompute (bins below that floor were never\n"
|
||||
"searched). Batch Compute FFT records the floor in the file and\n"
|
||||
"re-resolves masked pixels to their true above-floor peak."
|
||||
)
|
||||
self.spin_min_freq_mhz.editingFinished.connect(self._on_min_freq_changed)
|
||||
min_freq_form.addRow("Min peak freq:", self.spin_min_freq_mhz)
|
||||
tl.addLayout(min_freq_form)
|
||||
vl.addWidget(self.grp_threshold)
|
||||
|
||||
# Background subtraction (v4+ files only)
|
||||
@@ -423,6 +449,22 @@ class SrasViewerWindow(QMainWindow):
|
||||
self.chk_auto.toggled.connect(self._on_autoscale_toggled)
|
||||
dl.addWidget(self.chk_auto)
|
||||
|
||||
self.chk_highlight_masked = QCheckBox("Highlight masked (no-data) pixels")
|
||||
self.chk_highlight_masked.setChecked(True)
|
||||
self.chk_highlight_masked.setEnabled(False)
|
||||
self.chk_highlight_masked.setToolTip(
|
||||
"RF/Velocity only. A pixel below the DC threshold has no FFT\n"
|
||||
"result at all and is otherwise shown as 0 on the same grayscale\n"
|
||||
"ramp as a real, valid pixel whose peak just happens to be a low\n"
|
||||
"frequency — the two look identical (both near-black). When\n"
|
||||
"checked, masked pixels are drawn in a distinct highlight color\n"
|
||||
"instead, excluded from the colormap's data range, so real\n"
|
||||
"low-frequency pixels keep their own true shade. Uncheck to\n"
|
||||
"restore the old behavior where both blend together."
|
||||
)
|
||||
self.chk_highlight_masked.toggled.connect(self._on_highlight_masked_toggled)
|
||||
dl.addWidget(self.chk_highlight_masked)
|
||||
|
||||
range_form = _form()
|
||||
for label, attr in (("min:", "spin_vmin"), ("max:", "spin_vmax")):
|
||||
spin = _make_dspin(-1e9, 1e9, 4)
|
||||
@@ -446,7 +488,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
|
||||
fft_menu = menubar.addMenu("&FFT")
|
||||
fft_act = QAction("FFT &Options…", self)
|
||||
fft_act.setStatusTip("Configure FFT backend and zero-padding")
|
||||
fft_act.setStatusTip("Configure FFT zero-padding")
|
||||
fft_act.triggered.connect(self._on_fft_options)
|
||||
fft_menu.addAction(fft_act)
|
||||
|
||||
@@ -486,6 +528,20 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._batch_fft_rowavg_act.triggered.connect(self._on_batch_compute_row_avg)
|
||||
convert_menu.addAction(self._batch_fft_rowavg_act)
|
||||
|
||||
convert_menu.addSeparator()
|
||||
self._batch_export_images_act = QAction(
|
||||
"Batch Export View as &Images…", self)
|
||||
self._batch_export_images_act.setStatusTip(
|
||||
"Select .sras files and export the currently selected view "
|
||||
"(channel, angle, threshold, colormap, etc.) as one PNG per "
|
||||
"file, rendered the same way it is shown on screen. Read-only "
|
||||
"— never modifies the source files. Aligned View is ignored "
|
||||
"even if enabled, since an alignment result belongs to one "
|
||||
"specific file's geometry.")
|
||||
self._batch_export_images_act.triggered.connect(
|
||||
self._on_batch_export_images)
|
||||
convert_menu.addAction(self._batch_export_images_act)
|
||||
|
||||
export_menu = menubar.addMenu("&Export")
|
||||
self._batch_export_act = QAction("&Batch Export Images…", self)
|
||||
self._batch_export_act.setStatusTip(
|
||||
@@ -638,10 +694,13 @@ class SrasViewerWindow(QMainWindow):
|
||||
if s.precomputed_row_avg_n else "")
|
||||
pad_note = (f", pad {s.precomputed_pad_factor}x"
|
||||
if s.precomputed_pad_factor > 1 else "")
|
||||
floor_note = (f", floor ≥ {s.precomputed_min_freq_mhz:g} MHz"
|
||||
if s.precomputed_min_freq_mhz > 0 else "")
|
||||
notes.append(
|
||||
f"Cached images: DC {n_dc}/{s.n_angles} angles, "
|
||||
f"FFT {n_fft}/{s.n_angles} angles{bg_note if n_fft else ''}"
|
||||
f"{avg_note if n_fft else ''}{pad_note if n_fft else ''} "
|
||||
f"{avg_note if n_fft else ''}{pad_note if n_fft else ''}"
|
||||
f"{floor_note if n_fft else ''} "
|
||||
"— display is instant for cached angles")
|
||||
notes += self._cache_mismatch_notes()
|
||||
elif s.version == 7:
|
||||
@@ -650,17 +709,24 @@ class SrasViewerWindow(QMainWindow):
|
||||
|
||||
def _cache_mismatch_notes(self) -> list[str]:
|
||||
"""Informational only: whether the file's stored FFT cache was
|
||||
computed under different bg-sub/pad settings than these controls
|
||||
currently say. The display always shows the stored image as-is
|
||||
regardless (see _stored_fft_image) — these controls only affect a
|
||||
future live compute for an angle with nothing cached yet, or an
|
||||
explicit batch recompute, never what's already on screen.
|
||||
computed under different bg-sub/pad/min-freq settings than these
|
||||
controls currently say. The display always shows the stored image
|
||||
(re-masked for a raised floor) regardless — see _stored_fft_image;
|
||||
these controls only affect a future live compute for an angle with
|
||||
nothing cached yet, or an explicit batch recompute, never what's
|
||||
already on screen.
|
||||
|
||||
row_avg_n is compared against the file's own recorded value (a
|
||||
self-match), so it never contributes a reason here — there's no
|
||||
live control for it to diverge from, and the "Cached images" line
|
||||
above already reports it.
|
||||
|
||||
The min peak frequency floor gets two directions: a live floor
|
||||
*below* the stored one is a genuine mismatch reason (the stored
|
||||
search never looked below its floor), while a live floor *above*
|
||||
the stored one is servable — pixels under it are shown as masked —
|
||||
so that direction gets its own softer note.
|
||||
|
||||
Asks compute for the reasons rather than restating the accept rule,
|
||||
so a new provenance field can only be added in one place.
|
||||
"""
|
||||
@@ -668,15 +734,26 @@ class SrasViewerWindow(QMainWindow):
|
||||
if s is None or all(x is None for x in s.precomputed_freq_mhz):
|
||||
return []
|
||||
|
||||
live_floor = self.spin_min_freq_mhz.value()
|
||||
notes = []
|
||||
reasons = compute.cache_mismatch_reasons(
|
||||
s, n_fft=self._current_n_fft(),
|
||||
apply_bg_sub=self.chk_bg_sub.isChecked(),
|
||||
row_avg_n=s.precomputed_row_avg_n)
|
||||
if not reasons:
|
||||
return []
|
||||
return ["Note: current bg-sub/pad controls differ from the stored "
|
||||
"cache — " + "; ".join(reasons) + ". Shown as stored; use "
|
||||
"Batch Compute to recompute with these settings."]
|
||||
row_avg_n=s.precomputed_row_avg_n,
|
||||
min_freq_mhz=live_floor)
|
||||
if reasons:
|
||||
notes.append(
|
||||
"Note: current bg-sub/pad/min-freq controls differ from the "
|
||||
"stored cache — " + "; ".join(reasons) + ". Shown as stored; "
|
||||
"use Batch Compute to recompute with these settings.")
|
||||
if round(live_floor * 1000) > round(s.precomputed_min_freq_mhz * 1000):
|
||||
notes.append(
|
||||
f"Note: Min peak freq ({live_floor:g} MHz) is above the "
|
||||
f"stored cache's floor ({s.precomputed_min_freq_mhz:g} MHz) — "
|
||||
f"stored pixels whose peak falls below {live_floor:g} MHz are "
|
||||
"shown as masked; Batch Compute FFT re-resolves them above "
|
||||
"the floor instead.")
|
||||
return notes
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Controls
|
||||
@@ -699,7 +776,9 @@ class SrasViewerWindow(QMainWindow):
|
||||
|
||||
# Threshold and bg-sub apply to all CH1 modes
|
||||
self.spin_threshold_mv.setEnabled(is_ch1)
|
||||
self.spin_min_freq_mhz.setEnabled(is_ch1)
|
||||
self.chk_bg_sub.setEnabled(has_file and s.background is not None and is_ch1)
|
||||
self.chk_highlight_masked.setEnabled(is_ch1)
|
||||
self.spin_grating_um.setEnabled(is_vel)
|
||||
self.grp_velocity.setVisible(is_vel)
|
||||
|
||||
@@ -750,6 +829,16 @@ class SrasViewerWindow(QMainWindow):
|
||||
if self._is_fft_mode():
|
||||
self._refresh_display()
|
||||
|
||||
def _on_min_freq_changed(self):
|
||||
# Like the DC threshold, this is a genuine cache-key change that can
|
||||
# be cheaply re-applied to a stored image — tighten-only: raising the
|
||||
# floor masks stored pixels below it, while lowering it below the
|
||||
# stored cache's own floor can only be answered by a fresh compute
|
||||
# (see cached_rf_image / cache_mismatch_reasons).
|
||||
if self._is_fft_mode():
|
||||
self._update_scan_info_labels()
|
||||
self._refresh_display()
|
||||
|
||||
def _on_autoscale_toggled(self, checked: bool):
|
||||
manual = not checked
|
||||
self.spin_vmin.setEnabled(manual and self._sras is not None)
|
||||
@@ -757,6 +846,10 @@ class SrasViewerWindow(QMainWindow):
|
||||
if self._sras is not None and self._current_image is not None:
|
||||
self._redraw_image(self._current_image)
|
||||
|
||||
def _on_highlight_masked_toggled(self, checked: bool):
|
||||
if self._sras is not None and self._current_image is not None:
|
||||
self._redraw_image(self._current_image)
|
||||
|
||||
def _on_manual_range_changed(self):
|
||||
if not self.chk_auto.isChecked() and self._current_image is not None:
|
||||
self._redraw_image(self._current_image)
|
||||
@@ -1031,15 +1124,18 @@ class SrasViewerWindow(QMainWindow):
|
||||
return freq_mhz
|
||||
|
||||
def _fft_cache_key(self, angle_idx: int) -> tuple:
|
||||
"""Keyed by angle and DC threshold only. Once any FFT image exists
|
||||
for an angle this session — live-computed or pulled from the file's
|
||||
own stored cache — it stays the displayed image for that angle
|
||||
regardless of later bg-sub/pad toggles; those only affect a future
|
||||
live compute for an angle with nothing cached yet, or an explicit
|
||||
batch recompute (see _stored_fft_image). Threshold stays in the key
|
||||
because re-masking against it is free and meant to stay interactive
|
||||
(see _on_threshold_changed)."""
|
||||
return (angle_idx, self.spin_threshold_mv.value())
|
||||
"""Keyed by angle, DC threshold, and min peak frequency only. Once
|
||||
any FFT image exists for an angle this session — live-computed or
|
||||
pulled from the file's own stored cache — it stays the displayed
|
||||
image for that angle regardless of later bg-sub/pad toggles; those
|
||||
only affect a future live compute for an angle with nothing cached
|
||||
yet, or an explicit batch recompute (see _stored_fft_image).
|
||||
Threshold and min-freq are both in the key because both are cheaply
|
||||
re-applied when a stored image is served (_stored_fft_image takes
|
||||
them live), so the cached value genuinely reflects every component
|
||||
of its key and revisiting a combination is instant."""
|
||||
return (angle_idx, self.spin_threshold_mv.value(),
|
||||
self.spin_min_freq_mhz.value())
|
||||
|
||||
def _aligned_cache_key(self, angle_idx: int, ch_idx: int) -> tuple:
|
||||
"""Mirrors _fft_cache's key granularity so a stale aligned image is
|
||||
@@ -1116,9 +1212,13 @@ class SrasViewerWindow(QMainWindow):
|
||||
controls never gate whether it's used, only what a *future* compute
|
||||
produces. See _cache_mismatch_notes for the informational (non-
|
||||
blocking) note when the live controls diverge from what's shown.
|
||||
Only the DC threshold is taken live: re-masking a stored image
|
||||
against it is free, unlike bg-sub/pad/row-averaging which are baked
|
||||
irreversibly into the stored numbers.
|
||||
The DC threshold and the min peak frequency floor are taken live:
|
||||
re-masking a stored image against either is free, unlike
|
||||
bg-sub/pad/row-averaging, which are baked irreversibly into the
|
||||
stored numbers. The floor is tighten-only, though — a live floor
|
||||
*below* the stored cache's own floor is the one case where a stored
|
||||
image genuinely can't answer (cached_rf_image refuses it), and the
|
||||
caller falls through to a real compute.
|
||||
|
||||
allow_dc_recompute=False keeps this off the I/O path: if the mask
|
||||
would mean reading a whole CH4 channel, this declines and the caller
|
||||
@@ -1135,7 +1235,8 @@ class SrasViewerWindow(QMainWindow):
|
||||
n_fft=n_fft,
|
||||
row_avg_n=s.precomputed_row_avg_n,
|
||||
dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)),
|
||||
allow_dc_recompute=False)
|
||||
allow_dc_recompute=False,
|
||||
min_freq_mhz=self.spin_min_freq_mhz.value())
|
||||
|
||||
def _cached_value_image(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
|
||||
"""The already-available (no compute) image for (angle, channel):
|
||||
@@ -1211,6 +1312,24 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._redraw_image(img)
|
||||
self._update_roi_ui()
|
||||
|
||||
def _dc_validity_mask(self, angle_idx: int, aligned: bool) -> np.ndarray | None:
|
||||
"""True where a CH1/Velocity pixel passed the DC threshold and so
|
||||
actually has a real FFT result, on the same grid as display_img.
|
||||
None if the CH4 DC image for this angle isn't cached yet — the
|
||||
background DC precompute hasn't reached it, and this is deliberately
|
||||
not worth a synchronous recompute just to redraw."""
|
||||
dc4 = self._dc_cache.get((angle_idx, CH4_IDX))
|
||||
if dc4 is None:
|
||||
return None
|
||||
valid = dc4 >= self.spin_threshold_mv.value()
|
||||
if aligned:
|
||||
# apply_alignment defaults to nearest-neighbor (order=0), so a
|
||||
# 0.0/1.0 float warp stays exactly 0 or 1 - no blending at mask
|
||||
# edges to second-guess with a >= 0.5 cutoff.
|
||||
valid = apply_alignment(self._alignment_result, angle_idx,
|
||||
valid.astype(np.float32)) >= 0.5
|
||||
return valid
|
||||
|
||||
def _redraw_image(self, img: np.ndarray):
|
||||
s = self._sras
|
||||
angle_idx = self._current_angle
|
||||
@@ -1231,8 +1350,32 @@ class SrasViewerWindow(QMainWindow):
|
||||
dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0
|
||||
extent = _axes_extent(x_axis, y_axis, dx, dy)
|
||||
|
||||
# Masked-out pixels are stored as a plain 0, the same value near
|
||||
# the bottom of a linear colormap as a real low-frequency pixel -
|
||||
# the two are indistinguishable there. Pull masked pixels out to
|
||||
# NaN (drawn in a distinct highlight color, excluded from the
|
||||
# auto-scale range) so a real pixel keeps its own true shade
|
||||
# instead of disappearing into the same black as "no data". In an
|
||||
# FFT-derived mode a value of exactly 0 is itself the "no valid
|
||||
# peak" sentinel (DC-masked, below the min-freq floor, or an empty
|
||||
# spectrum — bin 0 is always excluded, so no genuine peak is ever
|
||||
# 0), so those pixels are masked by value too; that covers
|
||||
# floor-masked pixels even when no DC4 image is cached yet.
|
||||
highlight_masked = (ch_idx in CH1_DERIVED_MODES
|
||||
and self.chk_highlight_masked.isChecked())
|
||||
if highlight_masked:
|
||||
mask_valid = self._dc_validity_mask(angle_idx, aligned)
|
||||
if mask_valid is not None and mask_valid.shape != display_img.shape:
|
||||
mask_valid = None
|
||||
display_img = display_img.astype(np.float32, copy=True)
|
||||
if mask_valid is not None:
|
||||
display_img[~mask_valid] = np.nan
|
||||
display_img[display_img == 0.0] = np.nan
|
||||
|
||||
if self.chk_auto.isChecked():
|
||||
vmin, vmax = float(display_img.min()), float(display_img.max())
|
||||
vmin, vmax = float(np.nanmin(display_img)), float(np.nanmax(display_img))
|
||||
if not np.isfinite(vmin):
|
||||
vmin, vmax = 0.0, 0.0 # every pixel masked out
|
||||
for spin, val in ((self.spin_vmin, vmin), (self.spin_vmax, vmax)):
|
||||
with QSignalBlocker(spin):
|
||||
spin.setValue(val)
|
||||
@@ -1256,6 +1399,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
vmin=vmin, vmax=vmax,
|
||||
xlabel="X (mm)", ylabel="Y (mm)",
|
||||
title=title, colorbar_label=colorbar_label,
|
||||
bad_color=_MASKED_HIGHLIGHT_COLOR if highlight_masked else None,
|
||||
)
|
||||
self.statusBar().showMessage(
|
||||
f"{s.path.name} | {ch_label} @ {angle_deg:.1f}° "
|
||||
@@ -1279,6 +1423,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._pending_ch = ch_idx
|
||||
self._pending_bg_sub = self.chk_bg_sub.isChecked()
|
||||
self._pending_threshold = self.spin_threshold_mv.value()
|
||||
self._pending_min_freq_mhz = self.spin_min_freq_mhz.value()
|
||||
self._pending_fft_pad_factor = self._fft_pad_factor
|
||||
|
||||
worker = ComputeWorker(
|
||||
@@ -1295,6 +1440,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
# eligible even when this fallback compute is the one that ends
|
||||
# up serving it (e.g. before DC precompute reaches this angle).
|
||||
row_avg_n=self._sras.precomputed_row_avg_n,
|
||||
min_freq_mhz=self._pending_min_freq_mhz,
|
||||
)
|
||||
if not self._run_worker(
|
||||
Jobs.COMPUTE, worker,
|
||||
@@ -1323,9 +1469,10 @@ class SrasViewerWindow(QMainWindow):
|
||||
already be cached."""
|
||||
if (self.spin_angle.value(), self.combo_channel.currentIndex(),
|
||||
self.chk_bg_sub.isChecked(), self.spin_threshold_mv.value(),
|
||||
self._fft_pad_factor) != (
|
||||
self.spin_min_freq_mhz.value(), self._fft_pad_factor) != (
|
||||
self._pending_angle, self._pending_ch, self._pending_bg_sub,
|
||||
self._pending_threshold, self._pending_fft_pad_factor):
|
||||
self._pending_threshold, self._pending_min_freq_mhz,
|
||||
self._pending_fft_pad_factor):
|
||||
self._refresh_display()
|
||||
|
||||
def _on_compute_done(self, result):
|
||||
@@ -1336,7 +1483,8 @@ class SrasViewerWindow(QMainWindow):
|
||||
ch_idx = self._pending_ch
|
||||
|
||||
if ch_idx in CH1_DERIVED_MODES:
|
||||
self._fft_cache[(angle_idx, self._pending_threshold)] = result
|
||||
key = (angle_idx, self._pending_threshold, self._pending_min_freq_mhz)
|
||||
self._fft_cache[key] = result
|
||||
img = self._scale_for_display(result, ch_idx)
|
||||
else:
|
||||
img = result
|
||||
@@ -1415,7 +1563,8 @@ class SrasViewerWindow(QMainWindow):
|
||||
if self._current_ch in CH1_DERIVED_MODES:
|
||||
self.wave_canvas.show_rf_waveform(
|
||||
self._sras, angle_idx, row_idx, frame_idx,
|
||||
apply_bg_sub=self.chk_bg_sub.isChecked())
|
||||
apply_bg_sub=self.chk_bg_sub.isChecked(),
|
||||
min_freq_mhz=self.spin_min_freq_mhz.value())
|
||||
else:
|
||||
self.wave_canvas.show_dc_waveform(
|
||||
self._sras, angle_idx, self._current_ch, row_idx, frame_idx)
|
||||
@@ -1467,7 +1616,8 @@ class SrasViewerWindow(QMainWindow):
|
||||
# Cache the FFT at the pad the viewer is actually displaying at,
|
||||
# otherwise the batch stores images this window can never use.
|
||||
worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked(),
|
||||
pad_factor=self._fft_pad_factor)
|
||||
pad_factor=self._fft_pad_factor,
|
||||
min_freq_mhz=self.spin_min_freq_mhz.value())
|
||||
started = self._run_worker(
|
||||
Jobs.BATCH, worker,
|
||||
connect=(
|
||||
@@ -1480,9 +1630,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
if not started:
|
||||
return # a second trigger snuck in while the file dialog was open
|
||||
|
||||
self._batch_dc_act.setEnabled(False)
|
||||
self._batch_fft_act.setEnabled(False)
|
||||
self._batch_fft_rowavg_act.setEnabled(False)
|
||||
self._set_batch_actions_enabled(False)
|
||||
self._show_progress(
|
||||
Jobs.BATCH, f"Batch computing {label} for {len(paths)} file(s)…",
|
||||
maximum=100)
|
||||
@@ -1509,7 +1657,8 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._batch_errors = []
|
||||
worker = BatchCacheWorker(paths, "fft_rowavg", self.chk_bg_sub.isChecked(),
|
||||
dc_threshold_mv=threshold_mv, row_avg_n=n,
|
||||
pad_factor=self._fft_pad_factor)
|
||||
pad_factor=self._fft_pad_factor,
|
||||
min_freq_mhz=self.spin_min_freq_mhz.value())
|
||||
started = self._run_worker(
|
||||
Jobs.BATCH, worker,
|
||||
connect=(
|
||||
@@ -1522,9 +1671,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
if not started:
|
||||
return # a second trigger snuck in while a dialog was open
|
||||
|
||||
self._batch_dc_act.setEnabled(False)
|
||||
self._batch_fft_act.setEnabled(False)
|
||||
self._batch_fft_rowavg_act.setEnabled(False)
|
||||
self._set_batch_actions_enabled(False)
|
||||
self._show_progress(
|
||||
Jobs.BATCH,
|
||||
f"Batch computing row-averaged FFT (n={n}, threshold={threshold_mv:.1f} mV) "
|
||||
@@ -1555,9 +1702,100 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._load_file(str(self._sras.path))
|
||||
|
||||
def _after_batch(self):
|
||||
self._batch_dc_act.setEnabled(True)
|
||||
self._batch_fft_act.setEnabled(True)
|
||||
self._batch_fft_rowavg_act.setEnabled(True)
|
||||
self._set_batch_actions_enabled(True)
|
||||
|
||||
def _set_batch_actions_enabled(self, enabled: bool):
|
||||
"""All four Convert-menu batch actions share one Jobs.BATCH slot;
|
||||
grey out every sibling while one runs, rather than leaving one
|
||||
clickable but silently no-op'd by the busy guard."""
|
||||
for act in (self._batch_dc_act, self._batch_fft_act,
|
||||
self._batch_fft_rowavg_act, self._batch_export_images_act):
|
||||
act.setEnabled(enabled)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Batch export view as images
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_batch_export_images(self):
|
||||
if self._job_running(Jobs.BATCH):
|
||||
return
|
||||
paths, _ = QFileDialog.getOpenFileNames(
|
||||
self, "Select .sras files to export the current view from", "",
|
||||
"SRAS files (*.sras);;All files (*)")
|
||||
if not paths:
|
||||
return
|
||||
out_dir = QFileDialog.getExistingDirectory(
|
||||
self, "Select output folder for exported images")
|
||||
if not out_dir:
|
||||
return
|
||||
|
||||
angle_idx = self.spin_angle.value()
|
||||
ch_idx = self.combo_channel.currentIndex()
|
||||
is_fft_mode = ch_idx in CH1_DERIVED_MODES
|
||||
mode_str, _unit, colorbar_label = _CHANNEL_DISPLAY[ch_idx]
|
||||
|
||||
self._batch_errors = []
|
||||
self._batch_export_names = []
|
||||
worker = BatchExportImagesWorker(
|
||||
paths,
|
||||
out_dir=out_dir, angle_idx=angle_idx, ch_idx=ch_idx,
|
||||
is_fft_mode=is_fft_mode, is_velocity=(ch_idx == VELOCITY_MODE_IDX),
|
||||
dc_threshold_mv=self.spin_threshold_mv.value(),
|
||||
apply_bg_sub=self.chk_bg_sub.isChecked(),
|
||||
pad_factor=self._fft_pad_factor,
|
||||
min_freq_mhz=self.spin_min_freq_mhz.value(),
|
||||
grating_um=self.spin_grating_um.value(),
|
||||
cmap=self.combo_cmap.currentText(),
|
||||
auto_scale=self.chk_auto.isChecked(),
|
||||
vmin=self.spin_vmin.value(), vmax=self.spin_vmax.value(),
|
||||
highlight_masked=self.chk_highlight_masked.isChecked(),
|
||||
mode_str=mode_str, colorbar_label=colorbar_label,
|
||||
mask_color=_MASKED_HIGHLIGHT_COLOR,
|
||||
)
|
||||
started = self._run_worker(
|
||||
Jobs.BATCH, worker,
|
||||
connect=(
|
||||
("progress", lambda pct: self._set_progress(Jobs.BATCH, pct)),
|
||||
("file_done", self._on_batch_export_file_done),
|
||||
("finished",
|
||||
lambda p=paths, d=out_dir: self._on_batch_export_finished(p, d)),
|
||||
),
|
||||
on_done=self._after_batch,
|
||||
)
|
||||
if not started:
|
||||
return # a second trigger snuck in while a dialog was open
|
||||
|
||||
self._set_batch_actions_enabled(False)
|
||||
self._show_progress(
|
||||
Jobs.BATCH, f"Exporting images for {len(paths)} file(s)…",
|
||||
maximum=100)
|
||||
|
||||
def _on_batch_export_file_done(self, path: str, err: str, out_name: str):
|
||||
if err:
|
||||
self._batch_errors.append(f"{Path(path).name} — {err}")
|
||||
else:
|
||||
self._batch_export_names.append(out_name)
|
||||
self._show_progress(Jobs.BATCH, f"Exported {Path(path).name}…")
|
||||
|
||||
def _on_batch_export_finished(self, paths: list[str], out_dir: str):
|
||||
self._close_progress(Jobs.BATCH)
|
||||
|
||||
n_total = len(paths)
|
||||
n_failed = len(self._batch_errors)
|
||||
n_ok = n_total - n_failed
|
||||
summary = (f"Batch export: {n_ok}/{n_total} image(s) written to "
|
||||
f"{Path(out_dir).name}")
|
||||
if n_failed:
|
||||
summary += f", {n_failed} failed: {'; '.join(self._batch_errors)}"
|
||||
dupes = sum(1 for _name, count in Counter(self._batch_export_names).items()
|
||||
if count > 1)
|
||||
if dupes:
|
||||
summary += (f" | {dupes} filename collision(s) — later file(s) "
|
||||
"overwrote earlier ones with the same output name")
|
||||
self.statusBar().showMessage(summary)
|
||||
self._batch_errors = []
|
||||
self._batch_export_names = []
|
||||
# No reload: unlike Batch Compute, export never touches the source files.
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Export menu: batch image export
|
||||
@@ -1741,7 +1979,6 @@ class SrasViewerWindow(QMainWindow):
|
||||
def _on_fft_options(self):
|
||||
dlg = FftOptionsDialog(
|
||||
self,
|
||||
current_backend=compute.get_fft_backend(),
|
||||
current_pad_factor=self._fft_pad_factor,
|
||||
samples_per_frame=self._sras.samples_per_frame if self._sras else None,
|
||||
sample_rate_hz=self._sras.sample_rate_hz if self._sras else None,
|
||||
@@ -1749,9 +1986,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
)
|
||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
compute.set_fft_backend(dlg.get_backend())
|
||||
self._fft_pad_factor = dlg.get_pad_factor()
|
||||
self._settings.setValue("fft/backend", compute.get_fft_backend())
|
||||
self._settings.setValue("fft/pad_factor", self._fft_pad_factor)
|
||||
# Pad factor no longer gates the display: it only affects a future
|
||||
# live compute for an angle with nothing cached yet, or an explicit
|
||||
|
||||
Reference in New Issue
Block a user