Files
sras-viewer/sras_viewer.py
T
Thomas Ales 9a7cc396e7 Bound nested parallelism, make compute cancellable, harden batch pool
Three defects found by testing the parallel paths end to end.

Nested parallelism was unbounded. DcPrecomputeWorker and the alignment
driver both parallelise over angles, and each angle's compute_dc_image
then parallelised over rows again: 14 x 14 threads, and — worse — every
angle sized its chunk against the *whole* memory budget, so worst-case
live buffers were ~13.9 GB against a 1 GB budget. Capping the inner
worker count alone does not fix it; the chunk is still sized against the
full budget. plan_angle_level now hands each angle both max_workers=1
and its share of the budget, which bounds the real file at 1002 MB.

Chunk planning could not produce concurrency at all. _plan_chunks sized
chunk_rows first, and _chunk_rows_for spends whatever budget it is given
— so a single chunk consumed the lot and budget//chunk_bytes came back
as one worker. It now picks the worker count first and sizes the chunk to
it, giving 7-16 workers on the real file instead of 1.

Cancellation was too coarse to shut down. stop() was only checked between
angles, and one angle of the real scan is ~40 s, so closing the window
sat through it and returned with threads still running. should_stop is
now polled per row chunk and closeEvent signals every worker before
waiting: close went from 4.0 s with two live threads to 1.04 s with both
finished. A cancelled ComputeWorker emits None so a half-filled image is
never cached.

Also: BatchCacheWorker no longer reports every file as failed when the
process pool itself dies (unguarded __main__, frozen build, sandbox) —
it retries those files in-process. Batches below 512 MB skip the pool
entirely, since interpreter startup would otherwise make small jobs
slower. cache_file rejects an unknown mode instead of silently treating
it as "fft".

Measured on the real 496 GB scan, angle 3, 32 rows:
  DC   2.94s -> 0.75s   RF  6.10s -> 1.99s   peak RSS 1.40 -> 1.93 GB
Batch convert, 24 files / 1 GB: 4.03s -> 2.36s.
Memory budget defaults to 1024 MB (SRAS_MEM_BUDGET_MB), the measured knee.

Testing: tools/test_refactor.py (70 checks: cache round-trip and block
carry-forward, partial-cache handling, parallel-vs-serial identity,
no-mask path, ROI mask vs full-grid, v2/v3/v4 parsing, sras_average
round-trip incl. remainder handling) and tools/test_gui.py (46 checks
driving the real widgets and workers headlessly). check_equivalence.py
still reports all 167 outputs identical to ed0eba4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 00:09:46 -05:00

1914 lines
77 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
SRAS Scan File Viewer
PyQt6 application for visualizing channel data from .sras binary scan files.
Channel semantics (fixed by sc3_aui_app.py acquisition settings):
CH1 — RF Acoustic Packet (AC-coupled, 100 mV/div): FFT → peak frequency
CH3 — Bias A (DC-coupled, 50 mV/div): waveform mean
CH4 — Bias B (DC-coupled, 50 mV/div): waveform mean
RF images are masked: pixels where CH4_dc < dc_threshold show 0.
File parsing lives in sras_format, image/alignment math in sras_compute, and
background workers in sras_workers — the first two import neither Qt nor
matplotlib so multiprocessing children can load them cheaply.
"""
import faulthandler
import sys
from pathlib import Path
import numpy as np
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
from matplotlib.figure import Figure
from matplotlib.patches import Polygon
from matplotlib.path import Path as MplPath
from PyQt6.QtCore import QObject, Qt, QThread, pyqtSignal
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import (
QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
QDoubleSpinBox, QFileDialog, QFrame, QGroupBox, QHBoxLayout, QLabel,
QMainWindow, QProgressDialog, QPushButton, QRadioButton, QSizePolicy,
QSpinBox, QSplitter, QVBoxLayout, QWidget,
)
import sras_compute as compute
from sras_compute import PYFFTW_AVAILABLE, apply_alignment
from sras_format import (
CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv, mv_to_adc,
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
)
from sras_workers import (
AngleAlignmentWorker, BatchCacheWorker, ComputeWorker, DcPrecomputeWorker,
LoadWorker,
)
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
# ---------------------------------------------------------------------------
# Display constants
# ---------------------------------------------------------------------------
CH_LABELS = [
"CH1 — RF (FFT peak freq)",
"CH3 — Bias A (DC mean)",
"CH4 — Bias B (DC mean)",
"CH1 — Velocity (SRAS)",
]
# Combo index for the derived velocity mode (uses CH1_IDX data)
VELOCITY_MODE_IDX = 3
# All modes that operate on CH1 waveforms
CH1_DERIVED_MODES = (CH1_IDX, VELOCITY_MODE_IDX)
CMAPS = ["gray", "viridis", "plasma", "inferno", "hot", "jet", "RdBu_r", "seismic"]
# (mode_str, status-bar unit, colorbar label) per channel index
_CHANNEL_DISPLAY = {
CH1_IDX: ("RF", "Peak frequency (MHz)", "MHz"),
CH3_IDX: ("DC", "DC mean (mV)", "mV"),
CH4_IDX: ("DC", "DC mean (mV)", "mV"),
VELOCITY_MODE_IDX: ("Velocity", "Velocity (m/s)", "m/s"),
}
_CSS_HINT = "font-size: 11px; color: #aaa;"
_CSS_INFO = "font-size: 11px;"
_CSS_MUTED = "color: #888; font-size: 11px;"
_CSS_WARN = "color: #e07000; font-size: 11px;"
_CSS_BUSY = "color: #4a90d9; font-size: 11px;"
# ---------------------------------------------------------------------------
# ROI (free quadrilateral in data coordinates)
# ---------------------------------------------------------------------------
class RoiQuad:
"""Free quadrilateral defined in data coordinates (mm).
Stored as 4 corner points (shape (4, 2)) in CCW order: BL, BR, TR, TL.
Each corner can be positioned independently, allowing skewed /
non-orthogonal regions of interest. Because it lives in scan/data
coords it persists unchanged when the displayed channel/mode switches.
"""
def __init__(self, pts: np.ndarray):
"""pts : array-like, shape (4, 2)."""
self._pts = np.asarray(pts, dtype=np.float64).reshape(4, 2).copy()
@classmethod
def from_bbox(cls, x0: float, y0: float, x1: float, y1: float) -> "RoiQuad":
"""Create an axis-aligned rectangle from two opposite corners."""
lx, rx = min(x0, x1), max(x0, x1)
by, ty = min(y0, y1), max(y0, y1)
return cls(np.array([[lx, by], [rx, by], [rx, ty], [lx, ty]]))
def copy(self) -> "RoiQuad":
return RoiQuad(self._pts.copy())
def corners(self) -> np.ndarray:
"""World-coord corners, shape (4, 2), CCW: BL, BR, TR, TL."""
return self._pts.copy()
def centroid(self) -> np.ndarray:
return self._pts.mean(axis=0)
def bbox_size(self) -> np.ndarray:
"""Width and height of the axis-aligned bounding box, shape (2,)."""
return self._pts.max(axis=0) - self._pts.min(axis=0)
def contains(self, x: float, y: float) -> bool:
return bool(MplPath(self._pts).contains_point((x, y)))
def mask_for_grid(self, x_axis: np.ndarray,
y_axis: np.ndarray) -> np.ndarray:
"""Boolean mask (n_rows, n_frames) of pixels whose centres lie
inside the quadrilateral.
Only the quad's axis-aligned bounding box is tested — meshgrid and
contains_points over the *whole* grid would be tens of millions of
point-in-polygon tests (and hundreds of MB of float64 temporaries)
on a large scan, on every ROI edit.
"""
x = np.asarray(x_axis, dtype=np.float64)
y = np.asarray(y_axis, dtype=np.float64)
mask = np.zeros((y.size, x.size), dtype=bool)
(x0, y0), (x1, y1) = self._pts.min(axis=0), self._pts.max(axis=0)
cols = np.nonzero((x >= x0) & (x <= x1))[0]
rows = np.nonzero((y >= y0) & (y <= y1))[0]
if cols.size == 0 or rows.size == 0:
return mask
c0, c1 = int(cols[0]), int(cols[-1]) + 1
r0, r1 = int(rows[0]), int(rows[-1]) + 1
X, Y = np.meshgrid(x[c0:c1], y[r0:r1])
inside = MplPath(self._pts).contains_points(
np.column_stack([X.ravel(), Y.ravel()]))
mask[r0:r1, c0:c1] = inside.reshape(X.shape)
return mask
# ---------------------------------------------------------------------------
# Matplotlib canvases
# ---------------------------------------------------------------------------
class ImageCanvas(FigureCanvasQTAgg):
pixel_clicked = pyqtSignal(int, int) # row_idx, frame_idx
roi_changed = pyqtSignal() # ROI created / edited / cleared
draw_mode_changed = pyqtSignal(bool) # "draw new ROI" arm toggled
# Interaction state values
_IDLE = "idle"
_DRAW_NEW = "draw_new"
_MOVE = "move"
_DRAG_CORNER = "drag_corner"
# Hit tolerance (display pixels) for handles.
_HANDLE_PX = 12
_CLICK_THRESH_PX = 4 # releases within this of press count as a click
def __init__(self, parent=None):
fig = Figure(figsize=(7, 5), tight_layout=True)
self.ax = fig.add_subplot(111)
super().__init__(fig)
self.setParent(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._extent = None
self._img_shape = None
# ROI state
self._roi: RoiQuad | None = None
self._roi_artists: list = []
self._state = self._IDLE
self._draw_mode = False
# Per-interaction snapshots / anchors
self._press_xy: tuple[float, float] | None = None
self._press_pixel: tuple[float, float] | None = None
self._press_button = None
self._snapshot: RoiQuad | None = None
self._drag_corner_idx: int = -1
self._move_anchor = None # press-point in world coords
self._draw_previous: RoiQuad | None = None
self.mpl_connect("button_press_event", self._on_press)
self.mpl_connect("motion_notify_event", self._on_motion)
self.mpl_connect("button_release_event", self._on_release)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def show_image(self, img: np.ndarray, extent: list[float], cmap: str,
vmin: float, vmax: float, xlabel: str, ylabel: str, title: str,
colorbar_label: str = ""):
self.figure.clf()
self.ax = self.figure.add_subplot(111)
# Patches and lines are destroyed by figure.clf(); drop stale refs.
self._roi_artists = []
self._extent = extent
self._img_shape = img.shape
im = self.ax.imshow(
img, aspect="auto", origin="upper",
extent=extent, cmap=cmap, vmin=vmin, vmax=vmax,
interpolation="nearest",
)
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04)
if colorbar_label:
cb.set_label(colorbar_label)
self.ax.set_xlabel(xlabel)
self.ax.set_ylabel(ylabel)
self.ax.set_title(title)
# Re-draw the ROI (if any) on top of the fresh image so it persists
# unchanged across mode / angle / channel switches.
self._draw_roi()
self.draw()
def get_roi(self) -> RoiQuad | None:
return self._roi
def set_roi(self, roi: RoiQuad | None):
self._roi = roi.copy() if roi is not None else None
self._draw_roi()
self.draw_idle()
self.roi_changed.emit()
def clear_roi(self):
self._roi = None
self._remove_roi_artists()
self.draw_idle()
self.roi_changed.emit()
def start_drawing(self):
"""Arm the next click+drag on the image to create a new ROI,
replacing any existing one."""
self._draw_mode = True
self.setCursor(Qt.CursorShape.CrossCursor)
self.draw_mode_changed.emit(True)
def cancel_drawing(self):
if self._draw_mode:
self._draw_mode = False
self.setCursor(Qt.CursorShape.ArrowCursor)
self.draw_mode_changed.emit(False)
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def _remove_roi_artists(self):
for a in self._roi_artists:
try:
a.remove()
except (ValueError, AttributeError, NotImplementedError):
pass
self._roi_artists = []
def _draw_roi(self):
self._remove_roi_artists()
if self._roi is None or self.ax is None:
return
corners = self._roi.corners()
# Filled quad, then a sharp unfilled edge for visibility over bright
# images, then draggable corner handles.
for kwargs in (
dict(fill=True, facecolor="#ffd93a", edgecolor="#e53935",
alpha=0.22, linewidth=2.0, zorder=10),
dict(fill=False, edgecolor="#e53935", linewidth=1.8, zorder=11),
):
patch = Polygon(corners, closed=True, **kwargs)
self.ax.add_patch(patch)
self._roi_artists.append(patch)
self._roi_artists.append(self.ax.scatter(
corners[:, 0], corners[:, 1], s=60, c="white",
edgecolors="#e53935", linewidths=1.6, zorder=13))
# ------------------------------------------------------------------
# Hit testing (display pixels for handles, data coords for "inside")
# ------------------------------------------------------------------
def _hit_test(self, event) -> tuple[str, int | None] | None:
if self._roi is None or self.ax is None:
return None
if event.x is None or event.y is None:
return None
corners_disp = self.ax.transData.transform(self._roi.corners())
click = np.array([event.x, event.y])
for i in range(4):
if np.hypot(*(corners_disp[i] - click)) <= self._HANDLE_PX:
return ("corner", i)
if event.xdata is not None and event.ydata is not None:
if self._roi.contains(event.xdata, event.ydata):
return ("inside", None)
return None
# ------------------------------------------------------------------
# Mouse event handlers
# ------------------------------------------------------------------
def _on_press(self, event):
if event.inaxes is not self.ax or self._extent is None:
return
if event.button != 1: # only left mouse button
return
# If the matplotlib toolbar is in pan / zoom mode, let it handle
# the interaction instead of starting a ROI manipulation.
tb = getattr(self, "toolbar", None)
if tb is not None and getattr(tb, "mode", ""):
return
self._press_xy = (event.xdata, event.ydata)
self._press_pixel = (event.x, event.y)
self._press_button = event.button
if self._draw_mode:
self._draw_previous = self._roi.copy() if self._roi else None
self._roi = RoiQuad.from_bbox(event.xdata, event.ydata,
event.xdata, event.ydata)
self._state = self._DRAW_NEW
self._draw_roi()
self.draw_idle()
return
hit = self._hit_test(event)
if hit is None:
self._state = self._IDLE
return
kind, idx = hit
self._snapshot = self._roi.copy()
if kind == "corner":
self._state = self._DRAG_CORNER
self._drag_corner_idx = idx
else:
self._state = self._MOVE
self._move_anchor = (event.xdata, event.ydata)
def _on_motion(self, event):
if self._state == self._IDLE:
return
if event.xdata is None or event.ydata is None:
return
if event.inaxes is not self.ax:
return
if self._state == self._DRAW_NEW:
x0, y0 = self._press_xy
self._roi = RoiQuad.from_bbox(x0, y0, event.xdata, event.ydata)
elif self._state == self._MOVE:
delta = np.array([event.xdata - self._move_anchor[0],
event.ydata - self._move_anchor[1]])
self._roi._pts = self._snapshot.corners() + delta
elif self._state == self._DRAG_CORNER:
self._roi._pts[self._drag_corner_idx] = [event.xdata, event.ydata]
self._draw_roi()
self.draw_idle()
def _on_release(self, event):
if event.button != 1 and self._press_button != 1:
return
prev_state = self._state
self._state = self._IDLE
try:
if prev_state == self._DRAW_NEW:
self._finish_draw()
elif prev_state in (self._MOVE, self._DRAG_CORNER):
self._draw_roi()
self.draw_idle()
self.roi_changed.emit()
else:
self._maybe_emit_pixel_click(event)
finally:
self._press_xy = self._press_pixel = None
self._press_button = None
def _finish_draw(self):
"""Commit (or reject) a freshly-dragged quad."""
if self._extent is not None:
x0, x1, y_bot, y_top = self._extent
min_w = abs(x1 - x0) * 0.01 # minimum: 1% of each axis range
min_h = abs(y_bot - y_top) * 0.01
else:
min_w = min_h = 1e-6
if self._roi is None:
too_small = True
else:
bbox = self._roi.bbox_size()
too_small = bbox[0] < min_w or bbox[1] < min_h
if too_small:
self._roi = self._draw_previous
self._draw_previous = None
self.cancel_drawing()
self._draw_roi()
self.draw_idle()
self.roi_changed.emit()
def _maybe_emit_pixel_click(self, event):
"""A release close enough to its press counts as a pixel click."""
if (self._press_pixel is None or event.x is None or event.y is None
or self._extent is None or event.inaxes is not self.ax
or event.xdata is None):
return
dx_px = event.x - self._press_pixel[0]
dy_px = event.y - self._press_pixel[1]
if dx_px * dx_px + dy_px * dy_px > self._CLICK_THRESH_PX ** 2:
return
x0, x1, y_bot, y_top = self._extent
n_rows, n_frames = self._img_shape
col = int((event.xdata - x0) / (x1 - x0) * n_frames)
row = int((event.ydata - y_top) / (y_bot - y_top) * n_rows)
self.pixel_clicked.emit(max(0, min(row, n_rows - 1)),
max(0, min(col, n_frames - 1)))
class WaveformCanvas(FigureCanvasQTAgg):
def __init__(self, parent=None):
fig = Figure(figsize=(8, 3), tight_layout=True)
self.ax_wave = fig.add_subplot(121)
self.ax_right = fig.add_subplot(122)
super().__init__(fig)
self.setParent(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def show_rf_waveform(self, sras: SrasFile, angle_idx: int,
row_idx: int, frame_idx: int,
apply_bg_sub: bool = True):
"""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.
"""
data = sras.data[angle_idx]
waveform = data[row_idx, CH1_IDX, frame_idx, :].astype(np.float32)
t_ns = sras.time_axis_ns()
f_mhz = sras.freq_axis_mhz()
dc3_val = data[row_idx, CH3_IDX, frame_idx, :].astype(np.float32).mean()
dc4_val = data[row_idx, CH4_IDX, frame_idx, :].astype(np.float32).mean()
bg = sras.background if (apply_bg_sub and sras.background is not None) else None
waveform_plot = waveform - bg if bg is not None else waveform
self.ax_wave.cla()
self.ax_right.cla()
if bg is not None:
self.ax_wave.plot(t_ns, waveform, linewidth=0.5, color="#aaaaaa",
label="raw", zorder=1)
self.ax_wave.plot(t_ns, bg, linewidth=0.5, color="#e07030",
linestyle="--", label="background", zorder=2)
self.ax_wave.plot(t_ns, waveform_plot, linewidth=0.7, color="#4488cc",
label="subtracted", zorder=3)
self.ax_wave.legend(fontsize=7, loc="upper right")
else:
self.ax_wave.plot(t_ns, waveform, linewidth=0.7, color="#4488cc")
self.ax_wave.set_xlabel("Time (ns)")
self.ax_wave.set_ylabel("ADC counts")
bg_tag = " [bg sub]" if bg is not None else ""
dc3_mv = adc_to_mv(dc3_val, *sras.cal(CH3_IDX))
dc4_mv = adc_to_mv(dc4_val, *sras.cal(CH4_IDX))
self.ax_wave.set_title(
f"CH1 RF row={row_idx} frame={frame_idx}{bg_tag}\n"
f"CH3={dc3_val:.1f} CH4={dc4_val:.1f} "
f"({dc3_mv:.2f} / {dc4_mv:.2f} mV)",
fontsize=8,
)
# 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))]
if bg is not None:
# Also show the unsubtracted FFT for reference
power_raw = np.abs(np.fft.rfft(waveform)) ** 2
power_raw[0] = 0.0
self.ax_right.plot(f_mhz, power_raw, linewidth=0.5, color="#aaaaaa",
label="raw FFT", zorder=1)
self.ax_right.plot(f_mhz, power_sub, linewidth=0.7, color="#4488cc",
label="subtracted FFT" if bg is not None else None, zorder=2)
self.ax_right.axvline(peak_mhz, color="tomato", linestyle="--",
linewidth=1.2, label=f"peak = {peak_mhz:.1f} MHz")
self.ax_right.set_xlabel("Frequency (MHz)")
self.ax_right.set_ylabel("Power (arb.)")
self.ax_right.set_title("FFT Power Spectrum")
self.ax_right.set_xlim(0, 500)
self.ax_right.legend(fontsize=8)
self.draw()
def show_dc_waveform(self, sras: SrasFile, angle_idx: int, ch_idx: int,
row_idx: int, frame_idx: int):
"""CH3 or CH4 DC: time-domain + mean annotation."""
waveform = sras.data[angle_idx][row_idx, ch_idx, frame_idx, :].astype(np.float32)
mean_val = float(waveform.mean())
mean_mv = adc_to_mv(mean_val, *sras.cal(ch_idx))
self.ax_wave.cla()
self.ax_right.cla()
self.ax_wave.plot(sras.time_axis_ns(), waveform, linewidth=0.7, color="#4488cc")
self.ax_wave.axhline(mean_val, color="tomato", linestyle="--",
linewidth=1.2, label=f"mean = {mean_val:.2f} ADC")
self.ax_wave.set_xlabel("Time (ns)")
self.ax_wave.set_ylabel("ADC counts")
self.ax_wave.set_title(
f"{CH_NAMES[ch_idx]} DC row={row_idx} frame={frame_idx}")
self.ax_wave.legend(fontsize=8)
self.ax_right.text(
0.5, 0.5,
f"DC mode\n\nmean = {mean_val:.3f} ADC\n = {mean_mv:.3f} mV",
ha="center", va="center",
transform=self.ax_right.transAxes, fontsize=11,
)
self.ax_right.set_axis_off()
self.draw()
# ---------------------------------------------------------------------------
# FFT Options dialog
# ---------------------------------------------------------------------------
class FftOptionsDialog(QDialog):
"""Configure FFT backend and zero-padding.
Changes take effect only when the user clicks Apply. Cancel discards
all pending edits. The live 'frequency resolution' label updates as
the user adjusts the pad factor so they can see the trade-off before
committing.
"""
def __init__(self, parent=None, *,
current_backend: str,
current_pad_factor: int,
samples_per_frame: int | None,
sample_rate_hz: float | None,
grating_um: float):
super().__init__(parent)
self.setWindowTitle("FFT Options")
self.setModal(True)
self.setMinimumWidth(380)
self._samples_per_frame = samples_per_frame
self._sample_rate_hz = sample_rate_hz
self._grating_um = grating_um
layout = QVBoxLayout(self)
# ---- Backend ---------------------------------------------------
grp_backend = QGroupBox("FFT Backend")
bl = QVBoxLayout(grp_backend)
self._btn_numpy = QRadioButton("NumPy FFT (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_numpy, 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_numpy.setChecked(True)
bl.addWidget(self._btn_numpy)
bl.addWidget(self._btn_pyfftw)
layout.addWidget(grp_backend)
# ---- Zero-padding ----------------------------------------------
grp_zp = QGroupBox("Zero-Padding")
zl = QVBoxLayout(grp_zp)
pad_row = QHBoxLayout()
pad_row.addWidget(QLabel("Pad factor:"))
self._spin_pad = QSpinBox()
self._spin_pad.setRange(1, 256)
self._spin_pad.setValue(max(1, current_pad_factor))
self._spin_pad.setToolTip(
"Multiply the waveform length by this factor via zero-padding\n"
"before computing the FFT.\n"
"1 = no padding (natural length).\n"
"Powers of 2 (2, 4, 8 …) give the best performance."
)
self._spin_pad.valueChanged.connect(self._update_info)
pad_row.addWidget(self._spin_pad)
zl.addLayout(pad_row)
self._lbl_nfft = QLabel()
self._lbl_freq_res = QLabel()
self._lbl_vel_res = QLabel()
for lbl in (self._lbl_nfft, self._lbl_freq_res, self._lbl_vel_res):
lbl.setStyleSheet(_CSS_HINT)
zl.addWidget(lbl)
layout.addWidget(grp_zp)
# ---- Buttons ---------------------------------------------------
buttons = QDialogButtonBox()
buttons.addButton("Apply", QDialogButtonBox.ButtonRole.AcceptRole
).clicked.connect(self.accept)
buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole
).clicked.connect(self.reject)
layout.addWidget(buttons)
self._update_info()
def _update_info(self):
spf = self._samples_per_frame
sr = self._sample_rate_hz
pad = self._spin_pad.value()
if spf is None or sr is None:
self._lbl_nfft.setText("Load a file to preview FFT parameters.")
self._lbl_freq_res.setText("")
self._lbl_vel_res.setText("")
return
n_fft = spf * pad
freq_res_hz = sr / n_fft
freq_res_mhz = freq_res_hz / 1e6
# v (m/s) = freq (MHz) × grating (µm)
vel_res_ms = freq_res_mhz * self._grating_um
self._lbl_nfft.setText(f"FFT points: {spf} × {pad} = {n_fft:,}")
self._lbl_freq_res.setText(
f"Frequency bin: {freq_res_mhz:.4f} MHz ({freq_res_hz / 1e3:.2f} kHz)")
self._lbl_vel_res.setText(
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 "numpy"
def get_pad_factor(self) -> int:
return max(1, self._spin_pad.value())
# ---------------------------------------------------------------------------
# Main window
# ---------------------------------------------------------------------------
class SrasViewerWindow(QMainWindow):
def __init__(self, initial_path: str | None = None):
super().__init__()
self.setWindowTitle("SRAS Scan Viewer")
self.resize(1560, 840)
self.setAcceptDrops(True)
self._sras: SrasFile | None = None
self._current_image: np.ndarray | None = None
self._current_angle: int = 0
self._current_ch: int = 0
self._pending_angle: int = 0
self._pending_ch: int = 0
self._pending_bg_sub: bool = True
self._pending_threshold: float = 50.0 # mV
self._pending_fft_pad_factor: int = 1
# Live background jobs, keyed by role — see _run_worker.
self._jobs: dict[str, tuple] = {}
self._progress_dlgs: dict[str, QProgressDialog] = {}
# FFT settings (configured via FFT Options dialog)
self._fft_pad_factor: int = 1 # 1 = no padding
# Convert menu: batch DC/FFT compute-and-store (v6 -> v7)
self._batch_errors: list[str] = []
# Display-only settings (colormap, grating) never trigger a
# recompute — they're applied to cached data on redraw. DC images
# (CH3/CH4) are cheap and precomputed for every angle in the
# background right after load. CH1/Velocity FFT images are
# 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, bg_sub, n_fft, threshold) so revisiting the
# same combination is free.
self._dc_cache: dict[tuple[int, int], np.ndarray] = {}
self._fft_cache: dict[tuple[int, bool, int | None, float], np.ndarray] = {}
self._dc_precompute_worker: DcPrecomputeWorker | None = None
self._dc_generation: int = 0
# Angle alignment ("Fusion" menu)
self._alignment_result = None
self._alignment_generation: int = 0
self._aligned_cache: dict[tuple, np.ndarray] = {}
self._build_ui()
if initial_path:
self._load_file(initial_path)
# ------------------------------------------------------------------
# Background job plumbing
# ------------------------------------------------------------------
def _run_worker(self, key: str, worker: QObject, *,
connect: tuple = (), quit_on: tuple = ("finished",),
on_done=None) -> bool:
"""Move *worker* onto its own QThread and start it. Returns False if
a job under *key* is already running.
Centralises two lifetime hazards that each cost a process abort:
1. The job is claimed in self._jobs *before* start() and before
anything below that can pump the Qt event loop (a
QProgressDialog.show() does on first display). If it weren't, a
re-entrant editingFinished could slip past the busy check, start a
second thread, and then have the first call's own assignment
clobber — and destroy while still running — that second QThread.
2. thread.finished fires as the thread winds down but does not
guarantee the OS thread has joined. Dropping the last reference to
a QThread whose thread is still running logs "QThread: Destroyed
while thread is still running" and aborts, so wait() first.
"""
if key in self._jobs:
return False
thread = QThread()
self._jobs[key] = (thread, worker, on_done) # claim before anything pumps
worker.moveToThread(thread)
thread.started.connect(worker.run)
for signal_name, slot in connect:
getattr(worker, signal_name).connect(slot)
for signal_name in quit_on:
getattr(worker, signal_name).connect(thread.quit)
thread.finished.connect(lambda k=key: self._on_job_finished(k))
thread.start()
return True
def _on_job_finished(self, key: str):
job = self._jobs.pop(key, None)
if job is None:
return
thread, _worker, on_done = job
thread.wait() # join before releasing our last reference
if on_done is not None:
on_done()
def _job_running(self, key: str) -> bool:
return key in self._jobs
# ------------------------------------------------------------------
# UI construction
# ------------------------------------------------------------------
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root = QHBoxLayout(central)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(8)
root.addWidget(self._build_left_panel())
root.addWidget(self._build_canvases(), stretch=1)
root.addWidget(self._build_right_panel())
self.statusBar().showMessage("Open an .sras file to begin.")
self._build_menus()
def _build_left_panel(self) -> QWidget:
panel = QWidget()
panel.setFixedWidth(260)
panel_layout = QVBoxLayout(panel)
panel_layout.setContentsMargins(0, 0, 0, 0)
panel_layout.setSpacing(6)
# ---- File -------------------------------------------------------
grp_file = QGroupBox("File")
fl = QVBoxLayout(grp_file)
self.btn_open = QPushButton("Open .sras…")
self.btn_open.clicked.connect(self._on_open)
self.lbl_filename = QLabel("No file loaded")
self.lbl_filename.setWordWrap(True)
self.lbl_filename.setStyleSheet(_CSS_MUTED)
fl.addWidget(self.btn_open)
fl.addWidget(self.lbl_filename)
panel_layout.addWidget(grp_file)
# ---- Scan info --------------------------------------------------
grp_info = QGroupBox("Scan Info")
il = QVBoxLayout(grp_info)
self._info = {}
for key in ("Angles", "Rows", "Frames / row", "Samples / frame",
"Sample rate", "X start", "Pixel Δx", "Laser freq"):
lbl = QLabel(f"{key}: —")
lbl.setWordWrap(True)
lbl.setStyleSheet(_CSS_INFO)
il.addWidget(lbl)
self._info[key] = lbl
self.lbl_frame_warn = QLabel("") # frame-count / format notes
self.lbl_frame_warn.setWordWrap(True)
self.lbl_frame_warn.setStyleSheet(_CSS_WARN)
il.addWidget(self.lbl_frame_warn)
self.lbl_dc_precompute = QLabel("") # background DC-precompute progress
self.lbl_dc_precompute.setWordWrap(True)
self.lbl_dc_precompute.setStyleSheet(_CSS_BUSY)
il.addWidget(self.lbl_dc_precompute)
panel_layout.addWidget(grp_info)
# ---- View settings ----------------------------------------------
grp_view = QGroupBox("View Settings")
vl = QVBoxLayout(grp_view)
ar = QHBoxLayout()
ar.addWidget(QLabel("Angle:"))
self.spin_angle = QSpinBox()
self.spin_angle.setRange(0, 0)
self.spin_angle.setEnabled(False)
self.spin_angle.editingFinished.connect(self._on_view_changed)
self.lbl_angle_deg = QLabel("")
ar.addWidget(self.spin_angle)
ar.addWidget(self.lbl_angle_deg)
vl.addLayout(ar)
cr = QHBoxLayout()
cr.addWidget(QLabel("Channel:"))
self.combo_channel = QComboBox()
self.combo_channel.addItems(CH_LABELS)
self.combo_channel.setEnabled(False)
self.combo_channel.currentIndexChanged.connect(self._on_channel_changed)
cr.addWidget(self.combo_channel)
vl.addLayout(cr)
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet("color: #555;")
vl.addWidget(sep)
# DC threshold (for RF / CH1 masking)
self.grp_threshold = QGroupBox("RF Mask Threshold (CH1 only)")
tl = QVBoxLayout(self.grp_threshold)
thr_row = QHBoxLayout()
thr_row.addWidget(QLabel("DC threshold:"))
self.spin_threshold_mv = QDoubleSpinBox()
self.spin_threshold_mv.setRange(-500.0, 500.0)
self.spin_threshold_mv.setDecimals(3)
self.spin_threshold_mv.setSingleStep(0.025)
self.spin_threshold_mv.setSuffix(" mV")
self.spin_threshold_mv.setValue(50.0)
self.spin_threshold_mv.setEnabled(False)
self.spin_threshold_mv.editingFinished.connect(self._on_threshold_changed)
thr_row.addWidget(self.spin_threshold_mv)
tl.addLayout(thr_row)
self.lbl_threshold_adc = QLabel(f"{mv_to_adc(50.0):.1f} ADC counts")
self.lbl_threshold_adc.setStyleSheet(_CSS_MUTED)
tl.addWidget(self.lbl_threshold_adc)
vl.addWidget(self.grp_threshold)
# Background subtraction (v4+ files only)
self.chk_bg_sub = QCheckBox("Background subtraction (CH1 only)")
self.chk_bg_sub.setChecked(True)
self.chk_bg_sub.setEnabled(False)
self.chk_bg_sub.setToolTip(
"Subtract the stored background waveform from each CH1 frame\n"
"before computing the FFT (v4+ files only)."
)
self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled)
vl.addWidget(self.chk_bg_sub)
# Aligned View (Fusion → Angle Alignment result)
self.chk_aligned_view = QCheckBox("Aligned View (Fusion)")
self.chk_aligned_view.setChecked(False)
self.chk_aligned_view.setEnabled(False)
self.chk_aligned_view.setToolTip(
"Show the current angle/channel resampled onto the shared,\n"
"rotation+translation-aligned canvas from Fusion → Angle\n"
"Alignment. Uncheck to see the raw per-angle scan grid."
)
self.chk_aligned_view.toggled.connect(self._on_aligned_view_toggled)
vl.addWidget(self.chk_aligned_view)
self.btn_export_csv = QPushButton("Export Image as CSV…")
self.btn_export_csv.setEnabled(False)
self.btn_export_csv.setToolTip(
"Save the current CH1 image (one scan row per CSV line).")
self.btn_export_csv.clicked.connect(self._on_export_csv)
vl.addWidget(self.btn_export_csv)
panel_layout.addWidget(grp_view)
# ---- ROI ---------------------------------------------------------
grp_roi = QGroupBox("ROI (Region of Interest)")
rl = QVBoxLayout(grp_roi)
self.btn_draw_roi = QPushButton("Draw ROI")
self.btn_draw_roi.setCheckable(True)
self.btn_draw_roi.setEnabled(False)
self.btn_draw_roi.setToolTip(
"Arm next click+drag on the image to draw a new ROI\n"
"(replaces any existing one). Click again to cancel.\n"
"After drawing, drag inside to move, or grab corners to reshape.\n"
"The ROI is persistent across channels / modes / angles."
)
self.btn_draw_roi.toggled.connect(self._on_draw_roi_toggled)
rl.addWidget(self.btn_draw_roi)
self.btn_clear_roi = QPushButton("Clear ROI")
self.btn_clear_roi.setEnabled(False)
self.btn_clear_roi.clicked.connect(self._on_clear_roi)
rl.addWidget(self.btn_clear_roi)
self.btn_export_roi = QPushButton("Export ROI as CSV…")
self.btn_export_roi.setEnabled(False)
self.btn_export_roi.setToolTip(
"Save every pixel whose centre lies inside the ROI as CSV.\n"
"Columns: row, frame, x_mm, y_mm, value.\n"
"Corner coordinates of the quad are written in the file header."
)
self.btn_export_roi.clicked.connect(self._on_export_roi_csv)
rl.addWidget(self.btn_export_roi)
self.lbl_roi_center = QLabel("centroid: —")
self.lbl_roi_size = QLabel("bbox: —")
self.lbl_roi_npix = QLabel("pixels inside: —")
for lbl in (self.lbl_roi_center, self.lbl_roi_size, self.lbl_roi_npix):
lbl.setStyleSheet(_CSS_HINT)
rl.addWidget(lbl)
panel_layout.addWidget(grp_roi)
panel_layout.addStretch()
return panel
def _build_canvases(self) -> QWidget:
splitter = QSplitter(Qt.Orientation.Vertical)
img_widget = QWidget()
img_vl = QVBoxLayout(img_widget)
img_vl.setContentsMargins(0, 0, 0, 0)
self.image_canvas = ImageCanvas()
self.image_canvas.pixel_clicked.connect(self._on_pixel_clicked)
self.image_canvas.roi_changed.connect(self._update_roi_ui)
self.image_canvas.draw_mode_changed.connect(self._on_draw_mode_changed)
img_vl.addWidget(NavigationToolbar2QT(self.image_canvas, img_widget))
img_vl.addWidget(self.image_canvas)
splitter.addWidget(img_widget)
wave_widget = QWidget()
wave_vl = QVBoxLayout(wave_widget)
wave_vl.setContentsMargins(0, 0, 0, 0)
self.lbl_wave_hint = QLabel(
"Click a pixel in the image above to inspect its waveform.")
self.lbl_wave_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_wave_hint.setStyleSheet(_CSS_MUTED)
self.wave_canvas = WaveformCanvas()
wave_vl.addWidget(self.lbl_wave_hint)
wave_vl.addWidget(self.wave_canvas)
splitter.addWidget(wave_widget)
splitter.setSizes([580, 250])
return splitter
def _build_right_panel(self) -> QWidget:
# Velocity settings (visible only in velocity mode)
self.grp_velocity = QGroupBox("Velocity Settings (CH1 only)")
vel_l = QVBoxLayout(self.grp_velocity)
grat_row = QHBoxLayout()
grat_row.addWidget(QLabel("Grating size:"))
self.spin_grating_um = QDoubleSpinBox()
self.spin_grating_um.setRange(0.1, 1000.0)
self.spin_grating_um.setDecimals(2)
self.spin_grating_um.setSingleStep(0.5)
self.spin_grating_um.setSuffix(" µm")
self.spin_grating_um.setValue(25)
self.spin_grating_um.setEnabled(False)
self.spin_grating_um.editingFinished.connect(self._on_grating_changed)
grat_row.addWidget(self.spin_grating_um)
vel_l.addLayout(grat_row)
lbl_formula = QLabel("v (m/s) = freq (MHz) × grating (µm)")
lbl_formula.setStyleSheet("font-size: 10px; color: #888;")
vel_l.addWidget(lbl_formula)
self.grp_velocity.setVisible(False)
grp_display = QGroupBox("Display Options")
dl = QVBoxLayout(grp_display)
cmr = QHBoxLayout()
cmr.addWidget(QLabel("Colormap:"))
self.combo_cmap = QComboBox()
self.combo_cmap.addItems(CMAPS)
self.combo_cmap.setCurrentText("gray")
self.combo_cmap.setEnabled(False)
self.combo_cmap.currentIndexChanged.connect(self._on_cmap_changed)
cmr.addWidget(self.combo_cmap)
dl.addLayout(cmr)
self.chk_auto = QCheckBox("Auto-scale colormap")
self.chk_auto.setChecked(True)
self.chk_auto.toggled.connect(self._on_autoscale_toggled)
dl.addWidget(self.chk_auto)
for label, attr in (("min:", "spin_vmin"), ("max:", "spin_vmax")):
row = QHBoxLayout()
row.addWidget(QLabel(label))
spin = QDoubleSpinBox()
spin.setRange(-1e9, 1e9)
spin.setDecimals(4)
spin.setEnabled(False)
spin.editingFinished.connect(self._on_manual_range_changed)
setattr(self, attr, spin)
row.addWidget(spin)
dl.addLayout(row)
right_panel = QWidget()
right_panel.setFixedWidth(270)
layout = QVBoxLayout(right_panel)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
layout.addWidget(self.grp_velocity)
layout.addWidget(grp_display)
layout.addStretch()
return right_panel
def _build_menus(self):
menubar = self.menuBar()
fft_menu = menubar.addMenu("&FFT")
fft_act = QAction("FFT &Options…", self)
fft_act.setStatusTip("Configure FFT backend and zero-padding")
fft_act.triggered.connect(self._on_fft_options)
fft_menu.addAction(fft_act)
fusion_menu = menubar.addMenu("&Fusion")
self._alignment_act = QAction("Angle &Alignment", self)
self._alignment_act.setStatusTip(
"Compute a rotation+translation alignment across all angles "
"(from CH4 masks) and enable Aligned View. Requires >1 angle.")
self._alignment_act.setEnabled(False)
self._alignment_act.triggered.connect(self._on_angle_alignment)
fusion_menu.addAction(self._alignment_act)
convert_menu = menubar.addMenu("&Convert")
self._batch_dc_act = QAction("Batch Compute DC and &Store…", self)
self._batch_dc_act.setStatusTip(
"Select .sras files and compute+store DC images (CH3/CH4 mean) "
"for every angle, converting v6 files to v7 in place.")
self._batch_dc_act.triggered.connect(lambda: self._on_batch_compute("dc"))
convert_menu.addAction(self._batch_dc_act)
self._batch_fft_act = QAction("Batch Compute FFT and Sto&re…", self)
self._batch_fft_act.setStatusTip(
"Select .sras files and compute+store FFT peak-frequency images "
"for every angle, converting v6 files to v7 in place.")
self._batch_fft_act.triggered.connect(lambda: self._on_batch_compute("fft"))
convert_menu.addAction(self._batch_fft_act)
# ------------------------------------------------------------------
# Drag-and-drop
# ------------------------------------------------------------------
def dragEnterEvent(self, event):
urls = event.mimeData().urls()
if urls and urls[0].toLocalFile().lower().endswith(".sras"):
event.acceptProposedAction()
def dropEvent(self, event):
self._load_file(event.mimeData().urls()[0].toLocalFile())
# ------------------------------------------------------------------
# File loading
# ------------------------------------------------------------------
def _on_open(self):
path, _ = QFileDialog.getOpenFileName(
self, "Open SRAS File", "", "SRAS Files (*.sras);;All Files (*)")
if path:
self._load_file(path)
def _load_file(self, path: str):
started = self._run_worker(
"load", LoadWorker(path),
connect=(
("finished", self._on_load_done),
("error", lambda msg: self.statusBar().showMessage(f"Error: {msg}")),
),
)
if not started:
return
self.btn_open.setEnabled(False)
self.statusBar().showMessage(f"Loading {Path(path).name}")
self._show_progress("main", f"Loading {Path(path).name}")
def _on_load_done(self, sras):
self._close_progress("main")
self.btn_open.setEnabled(True)
if sras is None:
return
self._sras = sras
self._current_image = None
# Caches (and any in-flight DC precompute) belong to the previous
# file's geometry — discard and start fresh. Bumping the generation
# counters makes any still-running worker's result get dropped when
# it lands.
self._dc_cache = {}
self._fft_cache = {}
self._dc_generation += 1
self.lbl_dc_precompute.setText("")
self._alignment_result = None
self._aligned_cache = {}
self._alignment_generation += 1
self.chk_aligned_view.blockSignals(True)
self.chk_aligned_view.setChecked(False)
self.chk_aligned_view.setEnabled(False)
self.chk_aligned_view.blockSignals(False)
# A ROI from the previous file no longer matches the new scan's
# geometry, so discard it on every load.
self.image_canvas.clear_roi()
self.lbl_filename.setText(sras.path.name)
self.spin_angle.blockSignals(True)
self.spin_angle.setRange(0, max(0, sras.n_angles - 1))
self.spin_angle.setValue(0)
self.spin_angle.blockSignals(False)
# DC channels are cheap and give an instant, fluid overview of a
# scan; CH1/Velocity require an FFT per pixel that can take minutes
# on a large scan, so don't default to it.
self.combo_channel.blockSignals(True)
self.combo_channel.setCurrentIndex(CH4_IDX)
self.combo_channel.blockSignals(False)
self._update_controls_enabled(True)
self._on_threshold_changed() # refresh ADC label with file calibration
self._on_view_changed()
self._start_dc_precompute()
# ------------------------------------------------------------------
# Scan info panel
# ------------------------------------------------------------------
def _update_scan_info_labels(self):
s = self._sras
if s is None:
return
a = self.spin_angle.value()
for key, text in (
("Angles", f"{s.n_angles}"),
("Rows", f"{s.n_rows[a]}"),
("Frames / row", f"{s.n_frames[a]}"),
("Samples / frame", f"{s.samples_per_frame}"),
("Sample rate", f"{s.sample_rate_hz / 1e9:.4g} GS/s"),
("X start", f"{s.x_start_mm[a]:.4g} mm"),
("Pixel Δx", f"{s.pixel_x_mm * 1e3:.3g} µm"),
("Laser freq", f"{s.laser_freq_hz / 1e3:.4g} kHz"),
):
self._info[key].setText(f"{key}: {text}")
notes = []
if s.frame_count_mismatch:
notes.append(f"! Header n_frames={s.n_frames_header}, "
f"actual={s.n_frames[a]} (scanner bug — corrected)")
if s.scan_aborted:
notes.append(f"! Scan aborted: {s.n_angles}/{s.n_angles_declared} "
"angles complete")
if s.background is not None:
notes.append(f"Background waveform: {len(s.background)} samples")
if s.version in (6, 7):
notes.append("v6/v7 format: rows / frames / x_start are per-angle")
n_dc = sum(1 for x in s.precomputed_dc4_mv if x is not None)
n_fft = sum(1 for x in s.precomputed_freq_mhz if x is not None)
if n_dc or n_fft:
bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)"
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 ''} "
"— display is instant for cached angles")
elif s.version == 7:
notes.append("v7 format: no cache blocks stored yet")
self.lbl_frame_warn.setText("\n".join(notes))
# ------------------------------------------------------------------
# Controls
# ------------------------------------------------------------------
def _update_controls_enabled(self, enabled: bool):
s = self._sras
has_file = enabled and s is not None
ch_idx = self.combo_channel.currentIndex()
is_ch1 = enabled and ch_idx in CH1_DERIVED_MODES
is_vel = enabled and ch_idx == VELOCITY_MODE_IDX
self.spin_angle.setEnabled(has_file and s.n_angles > 1)
self.combo_channel.setEnabled(enabled)
self.combo_cmap.setEnabled(enabled)
self.chk_auto.setEnabled(enabled)
manual = enabled and not self.chk_auto.isChecked()
self.spin_vmin.setEnabled(manual)
self.spin_vmax.setEnabled(manual)
# Threshold and bg-sub apply to all CH1 modes
self.spin_threshold_mv.setEnabled(is_ch1)
self.chk_bg_sub.setEnabled(has_file and s.background is not None and is_ch1)
self.spin_grating_um.setEnabled(is_vel)
self.grp_velocity.setVisible(is_vel)
self.btn_export_csv.setEnabled(is_ch1 and self._current_image is not None)
# ROI: always usable once a file is loaded (independent of channel)
self.btn_draw_roi.setEnabled(has_file)
# Batch Convert actions pick their own files, independent of
# whatever's currently open — only gated on no batch already running.
can_batch = not self._job_running("batch")
self._batch_dc_act.setEnabled(can_batch)
self._batch_fft_act.setEnabled(can_batch)
self._alignment_act.setEnabled(
has_file and s.n_angles > 1 and not self._job_running("align"))
self.chk_aligned_view.setEnabled(enabled and self._alignment_result is not None)
self._update_roi_ui()
def _on_channel_changed(self):
self._update_controls_enabled(self._sras is not None)
self._on_view_changed()
def _on_bg_sub_toggled(self):
# Background subtraction changes the FFT input, so it genuinely
# invalidates the cached raw FFT (the cache key includes it) —
# _refresh_display() recomputes only on a miss for the new state.
if self._sras is not None and self.combo_channel.currentIndex() in CH1_DERIVED_MODES:
self._refresh_display()
def _on_grating_changed(self):
# Grating is a pure post-multiply on the cached frequency image —
# never needs a recompute.
if self._sras is not None and self.combo_channel.currentIndex() == VELOCITY_MODE_IDX:
self._refresh_display()
def _on_threshold_changed(self):
mv = self.spin_threshold_mv.value()
cal = (self._sras.cal(CH4_IDX) if self._sras is not None
else (_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC, 0.0))
self.lbl_threshold_adc.setText(f"{mv_to_adc(mv, *cal):.1f} ADC counts")
# Threshold decides which pixels get an FFT at all, so changing it is
# a genuine cache-key change — but the recompute reuses the cached DC4
# image to skip masked-out pixels.
if self._sras is not None and self.combo_channel.currentIndex() in CH1_DERIVED_MODES:
self._refresh_display()
def _on_autoscale_toggled(self, checked: bool):
manual = not checked
self.spin_vmin.setEnabled(manual and self._sras is not None)
self.spin_vmax.setEnabled(manual and self._sras is not None)
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)
def _on_cmap_changed(self):
# Colormap is purely how the existing image is rendered.
if self._current_image is not None:
self._redraw_image(self._current_image)
def _on_view_changed(self):
if self._sras is None:
return
idx = self.spin_angle.value()
self.lbl_angle_deg.setText(f"({self._sras.angles_deg[idx]:.1f}°)")
self._update_scan_info_labels()
self._refresh_display()
def _on_aligned_view_toggled(self, checked: bool):
if self._current_image is not None:
self._redraw_image(self._current_image)
# ------------------------------------------------------------------
# CSV export
# ------------------------------------------------------------------
def _on_export_csv(self):
if self._current_image is None or self._sras is None:
return
default_name = (f"{self._sras.path.stem}_angle{self._current_angle}"
f"_{CH_NAMES[self._current_ch]}.csv")
path, _ = QFileDialog.getSaveFileName(
self, "Export Image as CSV",
str(self._sras.path.parent / default_name),
"CSV files (*.csv);;All files (*)")
if not path:
return
np.savetxt(path, self._current_image, delimiter=",", fmt="%.6g")
self.statusBar().showMessage(f"Exported {Path(path).name}")
def _on_export_roi_csv(self):
if self._current_image is None or self._sras is None:
return
roi = self.image_canvas.get_roi()
if roi is None:
self.statusBar().showMessage("No ROI — draw one first")
return
s = self._sras
x_axis = s.x_axis_mm(self._current_angle)
y_axis = s.y_positions_mm(self._current_angle)
mask = roi.mask_for_grid(x_axis, y_axis)
if not mask.any():
self.statusBar().showMessage("ROI does not overlap any pixel")
return
img = self._current_image
if img.shape != mask.shape:
self.statusBar().showMessage(
f"ROI shape {mask.shape} does not match image {img.shape}")
return
X, Y = np.meshgrid(np.asarray(x_axis, dtype=np.float64),
np.asarray(y_axis, dtype=np.float64))
rows_idx, frames_idx = np.where(mask)
n_pix = int(mask.sum())
ch_name = CH_NAMES[self._current_ch]
angle = self._current_angle
default_name = f"{s.path.stem}_angle{angle}_{ch_name}_ROI.csv"
path, _ = QFileDialog.getSaveFileName(
self, "Export ROI as CSV",
str(s.path.parent / default_name),
"CSV files (*.csv);;All files (*)")
if not path:
return
corners_str = " ".join(f"({p[0]:.6g},{p[1]:.6g})" for p in roi.corners())
header = (
f"# ROI quad corners (BL BR TR TL) mm: {corners_str}\n"
f"# source: {s.path.name}, channel={ch_name}, "
f"angle_idx={angle}, angle_deg={s.angles_deg[angle]:.4g}\n"
f"# n_pixels={n_pix}\n"
"row,frame,x_mm,y_mm,value"
)
data = np.column_stack([
rows_idx.astype(np.int64), frames_idx.astype(np.int64),
X[mask], Y[mask], img[mask].astype(np.float64),
])
# integer columns first, floats after — use a per-column format list
np.savetxt(path, data, delimiter=",",
fmt=["%d", "%d", "%.6g", "%.6g", "%.6g"],
header=header, comments="")
self.statusBar().showMessage(
f"Exported ROI ({n_pix} pixels) to {Path(path).name}")
# ------------------------------------------------------------------
# ROI
# ------------------------------------------------------------------
def _on_draw_roi_toggled(self, checked: bool):
if checked:
self.image_canvas.start_drawing()
self.statusBar().showMessage(
"Click and drag on the image to draw a new rectangle.")
else:
self.image_canvas.cancel_drawing()
def _on_draw_mode_changed(self, active: bool):
# Keep the toggle button's visual state in sync with the canvas.
self.btn_draw_roi.blockSignals(True)
self.btn_draw_roi.setChecked(active)
self.btn_draw_roi.blockSignals(False)
def _on_clear_roi(self):
self.image_canvas.clear_roi()
self.statusBar().showMessage("ROI cleared")
def _update_roi_ui(self):
roi = self.image_canvas.get_roi()
if roi is None:
self.lbl_roi_center.setText("centroid: —")
self.lbl_roi_size.setText("bbox: —")
self.lbl_roi_npix.setText("pixels inside: —")
self.btn_clear_roi.setEnabled(False)
self.btn_export_roi.setEnabled(False)
return
cen = roi.centroid()
bbox = roi.bbox_size()
self.lbl_roi_center.setText(f"centroid: ({cen[0]:.3f}, {cen[1]:.3f}) mm")
self.lbl_roi_size.setText(f"bbox: {bbox[0]:.3f} × {bbox[1]:.3f} mm")
npix = 0
if self._sras is not None:
try:
# Deliberately always the raw per-angle grid, even when
# Aligned View is on: _on_export_roi_csv also exports on the
# raw grid (never synthetically-resampled pixels), so this
# readout must match what Export ROI actually writes.
mask = roi.mask_for_grid(
self._sras.x_axis_mm(self._current_angle),
self._sras.y_positions_mm(self._current_angle))
npix = int(mask.sum())
except Exception:
npix = 0
self.lbl_roi_npix.setText(f"pixels inside: {npix}")
self.btn_clear_roi.setEnabled(True)
self.btn_export_roi.setEnabled(self._current_image is not None and npix > 0)
# ------------------------------------------------------------------
# Display
# ------------------------------------------------------------------
def _current_n_fft(self) -> int | None:
if self._fft_pad_factor <= 1 or self._sras is None:
return None
return self._sras.samples_per_frame * self._fft_pad_factor
def _scale_for_display(self, freq_mhz: np.ndarray, ch_idx: int) -> np.ndarray:
"""Velocity is a pure post-multiply of the (already DC-masked)
cached frequency image — never worth a recompute on its own."""
if ch_idx == VELOCITY_MODE_IDX:
return freq_mhz * self.spin_grating_um.value()
return freq_mhz
def _fft_cache_key(self, angle_idx: int) -> tuple:
return (angle_idx, self.chk_bg_sub.isChecked(), self._current_n_fft(),
self.spin_threshold_mv.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
never shown after bg_sub/threshold/pad/grating changes."""
if ch_idx in CH1_DERIVED_MODES:
return (*self._fft_cache_key(angle_idx), ch_idx,
self.spin_grating_um.value() if ch_idx == VELOCITY_MODE_IDX else None)
return (angle_idx, ch_idx)
def _aligned_canvas_axes(self) -> tuple[np.ndarray, np.ndarray]:
r = self._alignment_result
n_rows, n_cols = r.canvas_shape
return (r.canvas_origin_mm[0] + np.arange(n_cols) * r.canvas_dx_mm,
r.canvas_origin_mm[1] + np.arange(n_rows) * r.canvas_dy_mm)
def _get_aligned_display_image(self, raw_img: np.ndarray, angle_idx: int,
ch_idx: int) -> np.ndarray:
key = self._aligned_cache_key(angle_idx, ch_idx)
cached = self._aligned_cache.get(key)
if cached is None:
cached = apply_alignment(self._alignment_result, angle_idx, raw_img)
self._aligned_cache[key] = cached
return cached
def _refresh_display(self):
"""Show the image for the current angle/channel/threshold, using
cached data whenever possible and only falling back to a background
compute (with progress popup) when genuinely nothing is cached yet."""
if self._sras is None:
return
angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex()
if ch_idx in CH1_DERIVED_MODES:
raw = self._fft_cache.get(self._fft_cache_key(angle_idx))
if raw is not None:
self._show_image_now(self._scale_for_display(raw, ch_idx),
angle_idx, ch_idx)
return
else:
cached = self._dc_cache.get((angle_idx, ch_idx))
if cached is not None:
self._show_image_now(cached, angle_idx, ch_idx)
return
# Nothing cached for these settings — need a real compute. Changing
# the DC threshold changes *which* pixels get an FFT at all, so it
# can't be satisfied from the cache — but with the DC map already
# known, the recompute skips the FFT for masked-out pixels.
self._start_compute()
def _show_image_now(self, img: np.ndarray, angle_idx: int, ch_idx: int):
"""Display an already-available image with no compute involved."""
self._current_image = img
self._current_angle = angle_idx
self._current_ch = ch_idx
self.btn_export_csv.setEnabled(ch_idx in CH1_DERIVED_MODES)
self._redraw_image(img)
self._update_roi_ui()
def _redraw_image(self, img: np.ndarray):
s = self._sras
angle_idx = self._current_angle
ch_idx = self._current_ch
aligned = (self.chk_aligned_view.isChecked()
and self._alignment_result is not None
and angle_idx in self._alignment_result.per_angle)
if aligned:
display_img = self._get_aligned_display_image(img, angle_idx, ch_idx)
x_axis, y_axis = self._aligned_canvas_axes()
else:
display_img = img
x_axis = s.x_axis_mm(angle_idx)
y_axis = s.y_positions_mm(angle_idx)
dx = x_axis[1] - x_axis[0] if len(x_axis) > 1 else s.pixel_x_mm
dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0
extent = [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
y_axis[-1] + dy / 2, y_axis[0] - dy / 2]
if self.chk_auto.isChecked():
vmin, vmax = float(display_img.min()), float(display_img.max())
for spin, val in ((self.spin_vmin, vmin), (self.spin_vmax, vmax)):
spin.blockSignals(True)
spin.setValue(val)
spin.blockSignals(False)
else:
vmin, vmax = self.spin_vmin.value(), self.spin_vmax.value()
angle_deg = s.angles_deg[angle_idx]
mode_str, unit, colorbar_label = _CHANNEL_DISPLAY[ch_idx]
if ch_idx == VELOCITY_MODE_IDX:
ch_label = f"Velocity [grating={self.spin_grating_um.value():.2f} µm]"
else:
ch_label = CH_LABELS[ch_idx]
title = f"{CH_NAMES[ch_idx]} | {mode_str} | {angle_deg:.1f}°"
if aligned:
title += " [Aligned]"
self.image_canvas.show_image(
display_img, extent,
cmap=self.combo_cmap.currentText(),
vmin=vmin, vmax=vmax,
xlabel="X (mm)", ylabel="Y (mm)",
title=title, colorbar_label=colorbar_label,
)
self.statusBar().showMessage(
f"{s.path.name} | {ch_label} @ {angle_deg:.1f}° "
f"| {display_img.shape[1]} × {display_img.shape[0]} px | {unit}"
f"{' | Aligned' if aligned else ''}"
)
# ------------------------------------------------------------------
# Background compute (only reached on a genuine cache miss)
# ------------------------------------------------------------------
def _start_compute(self):
if self._sras is None or self._job_running("compute"):
return # re-checked when the running compute finishes
angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex()
is_fft = ch_idx in CH1_DERIVED_MODES
self._pending_angle = angle_idx
self._pending_ch = ch_idx
self._pending_bg_sub = self.chk_bg_sub.isChecked()
self._pending_threshold = self.spin_threshold_mv.value()
self._pending_fft_pad_factor = self._fft_pad_factor
worker = ComputeWorker(
self._sras, angle_idx, ch_idx,
apply_bg_sub=self._pending_bg_sub,
n_fft=self._current_n_fft(),
dc_threshold_mv=self._pending_threshold,
# Reuse the cached DC4 image (if the precompute has reached this
# angle) so the FFT skips masked-out pixels entirely and doesn't
# need to re-read the CH4 channel from disk.
dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)),
is_fft_mode=is_fft,
)
if not self._run_worker(
"compute", worker,
connect=(
("finished", self._on_compute_done),
("error", lambda msg: self.statusBar().showMessage(
f"Compute error: {msg}")),
),
on_done=self._after_compute):
return
if is_fft:
self.statusBar().showMessage("Computing FFT…")
self._show_progress(
"main",
f"Computing FFT for angle {angle_idx}\n"
"This can take a while on a large scan — result is cached "
"so revisiting this angle/mode/threshold will be instant.")
else:
self.statusBar().showMessage("Computing DC image…")
self._show_progress("main", f"Computing DC image for angle {angle_idx}")
def _after_compute(self):
"""If settings changed while the compute was running, re-dispatch
through the cache-aware path — the now-current combination may
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._pending_angle, self._pending_ch, self._pending_bg_sub,
self._pending_threshold, self._pending_fft_pad_factor):
self._refresh_display()
def _on_compute_done(self, result):
self._close_progress("main")
if result is None:
return # cancelled mid-compute; the partial image must not cache
angle_idx = self._pending_angle
ch_idx = self._pending_ch
if ch_idx in CH1_DERIVED_MODES:
self._fft_cache[(angle_idx, self._pending_bg_sub,
self._current_n_fft(), self._pending_threshold)] = result
img = self._scale_for_display(result, ch_idx)
else:
img = result
self._dc_cache[(angle_idx, ch_idx)] = img
self._show_image_now(img, angle_idx, ch_idx)
# ------------------------------------------------------------------
# Background DC precompute (all angles, so switching is fluid)
# ------------------------------------------------------------------
def _start_dc_precompute(self):
if self._sras is None:
return
generation = self._dc_generation
n_angles = self._sras.n_angles
worker = DcPrecomputeWorker(self._sras)
self._dc_precompute_worker = worker
started = self._run_worker(
"dc_precompute", worker,
connect=(
("angle_done", lambda a, dc3, dc4, g=generation:
self._on_dc_precompute_angle_done(g, a, dc3, dc4, n_angles)),
("error", lambda msg: self.statusBar().showMessage(
f"DC precompute error: {msg}", 5000)),
),
quit_on=("finished", "error"),
on_done=lambda: setattr(self, "_dc_precompute_worker", None),
)
if not started:
self._dc_precompute_worker = None
def _on_dc_precompute_angle_done(self, generation: int, angle_idx: int,
dc3_mv: np.ndarray, dc4_mv: np.ndarray,
n_angles: int):
if generation != self._dc_generation:
return # stale result from a previously-loaded file — discard
self._dc_cache[(angle_idx, CH3_IDX)] = dc3_mv
self._dc_cache[(angle_idx, CH4_IDX)] = dc4_mv
done = sum(1 for a in range(n_angles) if (a, CH4_IDX) in self._dc_cache)
self.lbl_dc_precompute.setText(
f"Precomputing DC images: {done}/{n_angles} angles ready…"
if done < n_angles else "DC images ready for all angles.")
# If we just finished the angle/channel the user is currently looking
# at and it wasn't shown yet (they switched here before the precompute
# caught up and are still waiting), show it now.
current_ch = self.combo_channel.currentIndex()
if (angle_idx == self.spin_angle.value()
and not self._job_running("compute")
and current_ch in (CH3_IDX, CH4_IDX)
and (self._current_angle != angle_idx or self._current_ch != current_ch)):
self._refresh_display()
# ------------------------------------------------------------------
# Pixel inspector
# ------------------------------------------------------------------
def _on_pixel_clicked(self, row_idx: int, frame_idx: int):
if self._sras is None or self._current_image is None:
return
angle_idx = self._current_angle
if (self.chk_aligned_view.isChecked() and self._alignment_result is not None
and angle_idx in self._alignment_result.per_angle):
# The click landed on the shared aligned canvas — invert the same
# canvas->raw affine used to display it back to a raw (row, frame)
# index before looking up the waveform.
t = self._alignment_result.per_angle[angle_idx]
raw = t.matrix @ np.array([row_idx, frame_idx], dtype=np.float64) + t.offset
row_idx, frame_idx = int(round(raw[0])), int(round(raw[1]))
n_rows_a, n_frames_a = self._sras.image_shape(angle_idx)
if not (0 <= row_idx < n_rows_a and 0 <= frame_idx < n_frames_a):
self.statusBar().showMessage(
"No source waveform here (padding region of the aligned canvas).")
return
self.lbl_wave_hint.hide()
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())
else:
self.wave_canvas.show_dc_waveform(
self._sras, angle_idx, self._current_ch, row_idx, frame_idx)
# ------------------------------------------------------------------
# Progress dialogs
# ------------------------------------------------------------------
def _show_progress(self, key: str, message: str, maximum: int = 0):
"""Show (or relabel) the progress dialog under *key*. maximum=0 gives
an indeterminate busy indicator."""
dlg = self._progress_dlgs.get(key)
if dlg is not None:
dlg.setLabelText(message)
return
dlg = QProgressDialog(message, "", 0, maximum, self)
dlg.setWindowTitle("Please wait…")
dlg.setCancelButton(None)
dlg.setWindowModality(Qt.WindowModality.WindowModal)
dlg.setMinimumDuration(300) # only appears if it takes > 300 ms
dlg.show()
self._progress_dlgs[key] = dlg
def _set_progress(self, key: str, pct: int):
dlg = self._progress_dlgs.get(key)
if dlg is not None:
dlg.setValue(pct)
def _close_progress(self, key: str):
dlg = self._progress_dlgs.pop(key, None)
if dlg is not None:
dlg.close()
# ------------------------------------------------------------------
# Convert menu: batch DC/FFT compute-and-store (v6 -> v7)
# ------------------------------------------------------------------
def _on_batch_compute(self, mode: str):
if self._job_running("batch"):
return
label = "DC" if mode == "dc" else "FFT"
paths, _ = QFileDialog.getOpenFileNames(
self, f"Select .sras files to batch-compute {label}", "",
"SRAS files (*.sras);;All files (*)")
if not paths:
return
self._batch_errors = []
worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked())
started = self._run_worker(
"batch", worker,
connect=(
("progress", lambda pct: self._set_progress("batch", pct)),
("file_done", self._on_batch_file_done),
("finished", lambda p=paths: self._on_batch_finished(p)),
),
on_done=self._after_batch,
)
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._show_progress(
"batch", f"Batch computing {label} for {len(paths)} file(s)…",
maximum=100)
def _on_batch_file_done(self, path: str, err: str):
if err:
self._batch_errors.append(f"{Path(path).name}{err}")
self._show_progress("batch", f"Processed {Path(path).name}")
def _on_batch_finished(self, paths: list[str]):
self._close_progress("batch")
n_total = len(paths)
n_failed = len(self._batch_errors)
n_ok = n_total - n_failed
if n_failed:
summary = (f"Batch store: {n_ok}/{n_total} file(s) updated, "
f"{n_failed} failed: {'; '.join(self._batch_errors)}")
else:
summary = f"Batch store: {n_ok}/{n_total} file(s) updated."
self.statusBar().showMessage(summary)
self._batch_errors = []
# If the currently-open file was in this batch, reload it so the GUI
# picks up the newly-written v7 cache instead of stale state.
if self._sras is not None and str(self._sras.path) in paths:
self._load_file(str(self._sras.path))
def _after_batch(self):
self._batch_dc_act.setEnabled(True)
self._batch_fft_act.setEnabled(True)
# ------------------------------------------------------------------
# Fusion: angle alignment
# ------------------------------------------------------------------
def _on_angle_alignment(self):
if self._sras is None or self._sras.n_angles <= 1:
return
ref_idx = 0
threshold_mv = self.spin_threshold_mv.value()
generation = self._alignment_generation
started = self._run_worker(
"align", AngleAlignmentWorker(self._sras, ref_idx, threshold_mv),
connect=(
("progress", lambda pct: self._set_progress("main", pct)),
("finished", lambda result, err, g=generation:
self._on_alignment_done(g, result, err)),
),
on_done=lambda: self._update_controls_enabled(self._sras is not None),
)
if not started:
return
self._alignment_act.setEnabled(False)
self._show_progress(
"main",
f"Computing angle alignment ({self._sras.n_angles} angles, "
f"ref=angle 0, CH4 mask ≥ {threshold_mv:.3f} mV)…",
maximum=100)
def _on_alignment_done(self, generation: int, result, error_msg: str):
self._close_progress("main")
if generation != self._alignment_generation:
return # a new file was loaded while this was computing — discard
if error_msg:
self.statusBar().showMessage(f"Angle alignment failed: {error_msg}")
return
self._alignment_result = result
self._aligned_cache = {}
self.chk_aligned_view.setEnabled(True)
self.chk_aligned_view.blockSignals(True)
self.chk_aligned_view.setChecked(True)
self.chk_aligned_view.blockSignals(False)
nr, nc = result.canvas_shape
self.statusBar().showMessage(
f"Angle alignment computed ({self._sras.n_angles} angles, "
f"canvas {nc}×{nr} px).")
self._refresh_display()
# ------------------------------------------------------------------
# FFT Options
# ------------------------------------------------------------------
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,
grating_um=self.spin_grating_um.value(),
)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
compute.set_fft_backend(dlg.get_backend())
self._fft_pad_factor = dlg.get_pad_factor()
# Pad factor changes the FFT bin count, so it genuinely invalidates
# the cached raw FFT (part of the cache key) — _refresh_display()
# recomputes only on a cache miss.
if self._sras is not None and self.combo_channel.currentIndex() in CH1_DERIVED_MODES:
self._refresh_display()
# ------------------------------------------------------------------
def closeEvent(self, event):
# Signal every cancellable worker first, then wait. Waiting without
# signalling means sitting out whatever is in flight — on a large
# scan a single angle is ~40 s.
jobs = list(self._jobs.values())
for _thread, worker, _on_done in jobs:
stop = getattr(worker, "stop", None)
if callable(stop):
stop()
for thread, _worker, _on_done in jobs:
thread.quit()
thread.wait(5000)
super().closeEvent(event)
# ---------------------------------------------------------------------------
def main():
app = QApplication(sys.argv)
window = SrasViewerWindow(
initial_path=sys.argv[1] if len(sys.argv) > 1 else None)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()