Files
sras-viewer/sras_viewer.py
Thomas Ales 9f92d1826e Fix crash on angle switch with large v6 scans: bound chunk memory, harden thread teardown
Root cause: compute_dc_image/compute_rf_image chunked processing by a fixed
32-row count, sized for old small-format scans. A real v6 file with
spf=2500 and up to ~7500 frames/angle needed ~6-7 GB for a single chunk's
core buffers, on a 17 GB machine — pushing past available memory on every
angle and aborting the process (not a clean MemoryError) when switching to
a new angle piled more allocations on top. Chunk size is now computed from
actual scan dimensions to hit a fixed ~128 MB budget instead of a fixed row
count, cutting peak footprint by roughly 16-18x (verified against the real
532 GB / 17-angle dataset: ~400 MB peak, no crash).

Also hardens QThread lifecycle handling, found while chasing this crash:
- _on_compute_thread_finished/_on_load_thread_finished/
  _on_preprocess_thread_finished now call wait() before dropping the last
  reference to a finished QThread, avoiding "QThread: Destroyed while
  thread is still running" aborts if the OS thread hasn't fully joined
  when finished() fires.
- _start_compute/_load_file/_on_preprocess now claim their QThread
  immediately after the busy-guard check, before any call that can pump
  the Qt event loop (e.g. first QProgressDialog.show()), closing a
  reentrancy window where a second call could start a thread that the
  first call's own assignment would then clobber mid-run.
- faulthandler is enabled at startup so any future native crash prints a
  real stack trace instead of a bare "Aborted".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 20:04:39 -05:00

2353 lines
94 KiB
Python
Raw Permalink 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.
Frame-count correction: the scanner writes the *configured* frame count in the
header before acquisition, but the scope may acquire fewer frames. The actual
count is computed from the file size and used for the reshape so channels are
correctly aligned.
Scan geometry: v6 files scan a different bounding box per angle (x_start,
x_delta, n_frames, n_rows all vary by angle), so geometry is exposed per-angle
via SrasFile.n_rows / n_frames / x_start_mm arrays and the x_axis_mm() /
y_positions_mm() methods. v2v5 files have uniform geometry across angles, so
those arrays simply repeat the same value n_angles times.
"""
import re
import sys
import struct
import faulthandler
import numpy as np
from pathlib import Path
import os
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QGroupBox, QLabel, QPushButton, QComboBox, QSpinBox, QDoubleSpinBox,
QFileDialog, QSizePolicy, QSplitter, QCheckBox, QFrame, QProgressDialog,
QDialog, QDialogButtonBox, QRadioButton, QButtonGroup,
)
from PyQt6.QtGui import QAction
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
from matplotlib.figure import Figure
from matplotlib.patches import Polygon
from matplotlib.lines import Line2D
from matplotlib.path import Path as MplPath
# ---------------------------------------------------------------------------
# FFT backend
# ---------------------------------------------------------------------------
_pyfftw_available = False
try:
import pyfftw
pyfftw.interfaces.cache.enable()
_pyfftw_available = True
except ImportError:
pass
import scipy.fft as scipy_fft
# Runtime-mutable settings changed via FftOptionsDialog
_fft_backend = "numpy" # "numpy" or "pyfftw"
def _do_rfft(x: np.ndarray, n: int | None = None, axis: int = -1,
workers: int = 1) -> np.ndarray:
"""Dispatch rfft to the selected backend with optional multithreading."""
if _fft_backend == "pyfftw" and _pyfftw_available:
return pyfftw.interfaces.numpy_fft.rfft(x, n=n, axis=axis, threads=workers)
return scipy_fft.rfft(x, n=n, axis=axis, workers=workers)
# ---------------------------------------------------------------------------
# SRAS format
# ---------------------------------------------------------------------------
# v2v5: fixed header, uniform geometry across angles (43 bytes)
HDR_FMT = ">4sBHHffffIIdBB"
HDR_SIZE = struct.calcsize(HDR_FMT) # 43 bytes
# v6: fixed header, per-angle geometry in a separate table (49 bytes)
HDR_FMT_V6 = ">4sBHfffffffIdBB"
HDR_SIZE_V6 = struct.calcsize(HDR_FMT_V6) # 49 bytes
# v6: per-angle geometry table record (x_start, x_delta, n_frames, n_rows)
GEO_FMT_V6 = ">ffIH"
GEO_SIZE_V6 = struct.calcsize(GEO_FMT_V6) # 14 bytes
# Fixed-order channels in the file: index 0=CH1, 1=CH3, 2=CH4
# Fixed channel indices into the .sras data array (CH1=RF, CH3/CH4=Bias DC)
CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2
CH_LABELS = [
"CH1 — RF (FFT peak freq)",
"CH3 — Bias A (DC mean)",
"CH4 — Bias B (DC mean)",
"CH1 — Velocity (SRAS)",
]
CH_NAMES = ["CH1", "CH3", "CH4", "VEL"]
# 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)
# Fallback scope calibration used only when reading v2 files without embedded
# preambles. v3+ files carry the WFMOutpre string so these are not used.
# 50 mV/div, 8 div full-scale, int8 ADC, position = -2.72 div
# ymult = 50 mV × 8 / 256 = 1.5625 mV/count
# yoff = position × (256/8) = -2.72 × 32 = -87.04 (ADC count for 0 V)
_FALLBACK_YMULT_MV = 1.5625 # mV per ADC count
_FALLBACK_YOFF_ADC = -87.04 # ADC count that represents 0 V
CMAPS = ["gray", "viridis", "plasma", "inferno", "hot", "jet", "RdBu_r", "seismic"]
def _parse_preamble(preamble: str) -> dict[str, float]:
"""Extract YMULT, YOFF, YZERO from a Tektronix WFMOutpre string.
Returns a dict with float values for whichever keys are present.
YMULT is left in V/count as the scope reports it.
"""
result = {}
for key in ("YMULT", "YOFF", "YZERO"):
m = re.search(rf'\b{key}\s+([-+]?\d*\.?\d+(?:[Ee][+-]?\d+)?)', preamble)
if m:
result[key] = float(m.group(1))
return result
def mv_to_adc(mv: float, ymult_mv: float = _FALLBACK_YMULT_MV,
yoff_adc: float = _FALLBACK_YOFF_ADC,
yzero_mv: float = 0.0) -> float:
return (mv - yzero_mv) / ymult_mv + yoff_adc
def adc_to_mv(adc: float, ymult_mv: float = _FALLBACK_YMULT_MV,
yoff_adc: float = _FALLBACK_YOFF_ADC,
yzero_mv: float = 0.0) -> float:
return (adc - yoff_adc) * ymult_mv + yzero_mv
# ---------------------------------------------------------------------------
# File parser
# ---------------------------------------------------------------------------
class SrasFile:
"""Parsed in-memory representation of a v2v6 .sras file.
Scan geometry (rows, frames, x_start) is exposed per-angle via the
``n_rows`` / ``n_frames`` / ``x_start_mm`` arrays and the ``x_axis_mm()``
/ ``y_positions_mm()`` methods, since v6 files scan a different bounding
box per angle. v2v5 files have uniform geometry, so these arrays just
repeat the same value ``n_angles`` times. Waveform data is likewise
exposed as ``data[angle_idx]``, an array of shape
``(n_rows[a], n_channels, n_frames[a], samples_per_frame)``.
"""
def __init__(self, path: str):
self.path = Path(path)
self._parse()
def _parse(self):
with open(self.path, "rb") as f:
magic = f.read(4)
if magic != b"SRAS":
raise ValueError(f"Bad magic bytes: {magic!r}")
(version,) = struct.unpack(">B", f.read(1))
self.version = version
if version in (2, 3, 4, 5):
self._parse_legacy()
elif version == 6:
self._parse_v6()
else:
raise ValueError(f"Unsupported version: {version}")
# ------------------------------------------------------------------
# v2v5 parsing (uniform geometry, flat waveform block)
# ------------------------------------------------------------------
def _parse_legacy(self):
with open(self.path, "rb") as f:
fields = struct.unpack(HDR_FMT, f.read(HDR_SIZE))
(magic, ver, n_angles, n_rows, x_start, x_delta, vel, freq,
n_frames_hdr, spf, sr, bps, n_ch) = fields
self.n_angles = n_angles
self.velocity_mm_s = float(vel)
self.laser_freq_hz = float(freq)
self.n_frames_header = n_frames_hdr # configured count (may be wrong)
self.samples_per_frame = spf
self.sample_rate_hz = float(sr)
self.bytes_per_sample = bps
self.n_channels = n_ch
# Precomputed-image cache (populated when reading a v5 file).
# These are (n_angles, n_rows, n_frames) float32 arrays or None.
self.precomputed_freq_mhz: np.ndarray | None = None
self.precomputed_dc4_mv: np.ndarray | None = None
self.precomputed_dc3_mv: np.ndarray | None = None
self.precomputed_bg_sub: bool = False
self.scan_aborted = False
self.n_angles_declared = n_angles
with open(self.path, "rb") as f:
f.seek(HDR_SIZE)
angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4").astype(np.float32)
y_pos = np.frombuffer(f.read(n_rows * 4), dtype=">f4").astype(np.float32)
if ver >= 3:
preambles = []
for _ in range(n_ch):
(length,) = struct.unpack(">H", f.read(2))
preambles.append(f.read(length).decode("utf-8"))
self.preambles = preambles
self.ch_ymult_mv = []
self.ch_yoff_adc = []
self.ch_yzero_mv = []
for p in preambles:
cal = _parse_preamble(p)
# YMULT from scope is V/count; store as mV/count
self.ch_ymult_mv.append(cal.get("YMULT", _FALLBACK_YMULT_MV / 1000) * 1000)
self.ch_yoff_adc.append(cal.get("YOFF", _FALLBACK_YOFF_ADC))
# YZERO from scope is in V; store as mV
self.ch_yzero_mv.append(cal.get("YZERO", 0.0) * 1000)
else:
self.preambles = None
self.ch_ymult_mv = [_FALLBACK_YMULT_MV] * n_ch
self.ch_yoff_adc = [_FALLBACK_YOFF_ADC] * n_ch
self.ch_yzero_mv = [0.0] * n_ch
if ver >= 4:
(n_bg,) = struct.unpack(">I", f.read(4))
self.background = np.frombuffer(f.read(n_bg), dtype=np.int8).astype(np.float32)
else:
self.background = None
# Record the byte offset where raw waveform data begins.
# np.memmap will use this to map only the waveform section.
data_offset = f.tell()
# ---- Determine actual frame count from file size ---------------
# For v4 and earlier the header n_frames may be the *configured*
# count before acquisition; the actual count is derived from the
# bytes on disk. For v5 files a PREC tail follows the waveform
# data, so we must not include those extra bytes in the frame count.
file_size = self.path.stat().st_size
samples_per_row_per_ch = n_ch * spf
# Upper bound: bytes from data_offset to end of file
available_bytes = file_size - data_offset
if ver == 5:
actual_n_frames = n_frames_hdr
remainder = 0
else:
total_samples = available_bytes // bps
actual_n_frames = total_samples // (n_angles * n_rows * samples_per_row_per_ch)
remainder = total_samples % (n_angles * n_rows * samples_per_row_per_ch)
self.frame_count_mismatch = (actual_n_frames != n_frames_hdr)
self.n_frames_remainder = remainder
# ---- Memory-map the waveform data (zero RAM cost) --------------
# Instead of f.read() → astype() (which peaks at 2× file size),
# memmap lets the OS page only the bytes that are actually touched.
waveform_dtype = np.int8 if bps == 1 else ">i2"
waveform_shape = (n_angles, n_rows, n_ch, actual_n_frames, spf)
data5d = np.memmap(
str(self.path),
dtype=waveform_dtype,
mode="r",
offset=data_offset,
shape=waveform_shape,
)
# Expose as a list of per-angle views so downstream code shares one
# indexing convention with v6: sras.data[a][row, ch, frame, sample]
self.data = [data5d[a] for a in range(n_angles)]
# Uniform per-angle geometry, repeated so callers don't need to
# special-case legacy vs. v6 files.
self.n_rows = np.full(n_angles, n_rows, dtype=np.int64)
self.n_frames = np.full(n_angles, actual_n_frames, dtype=np.int64)
self.x_start_mm = np.full(n_angles, float(x_start), dtype=np.float64)
self._y_pos_per_angle = [y_pos] * n_angles
self.angles_deg = angles
# ---- Read v5 precomputed section if present --------------------
if ver >= 5:
waveform_bytes = actual_n_frames * n_angles * n_rows * n_ch * spf * bps
prec_offset = data_offset + waveform_bytes
if file_size > prec_offset:
self._parse_prec_section(prec_offset, n_angles, n_rows, actual_n_frames)
def _parse_prec_section(self, offset: int,
n_angles: int, n_rows: int, n_frames: int):
"""Parse the v5 PREC tail that holds precomputed images."""
_PREC_MAGIC = b"PREC"
px = n_rows * n_frames # pixels per angle image
img_bytes = px * 4 # float32
with open(self.path, "rb") as f:
f.seek(offset)
header_raw = f.read(6) # magic(4) + fmt_ver(1) + flags(1)
if len(header_raw) < 6 or header_raw[:4] != _PREC_MAGIC:
return
flags = header_raw[5]
self.precomputed_bg_sub = bool(flags & 0x01)
(n_stored,) = struct.unpack(">H", f.read(2))
if n_stored == 0:
return
freq_buf = np.zeros((n_angles, n_rows, n_frames), dtype=np.float32)
dc4_buf = np.zeros((n_angles, n_rows, n_frames), dtype=np.float32)
dc3_buf = np.zeros((n_angles, n_rows, n_frames), dtype=np.float32)
for _ in range(n_stored):
(aidx,) = struct.unpack(">H", f.read(2))
if aidx >= n_angles:
break
freq_buf[aidx] = np.frombuffer(
f.read(img_bytes), dtype=">f4").reshape(n_rows, n_frames)
dc4_buf[aidx] = np.frombuffer(
f.read(img_bytes), dtype=">f4").reshape(n_rows, n_frames)
dc3_buf[aidx] = np.frombuffer(
f.read(img_bytes), dtype=">f4").reshape(n_rows, n_frames)
self.precomputed_freq_mhz = freq_buf
self.precomputed_dc4_mv = dc4_buf
self.precomputed_dc3_mv = dc3_buf
# ------------------------------------------------------------------
# v6 parsing (per-angle geometry, ragged waveform blocks)
# ------------------------------------------------------------------
def _parse_v6(self):
with open(self.path, "rb") as f:
fields = struct.unpack(HDR_FMT_V6, f.read(HDR_SIZE_V6))
(magic, ver, n_angles, x_start_nom, y_start_nom, x_delta_nom,
y_delta_nom, row_spacing, vel, freq, spf, sr, bps, n_ch) = fields
n_angles_declared = n_angles
self.velocity_mm_s = float(vel)
self.laser_freq_hz = float(freq)
self.samples_per_frame = spf
self.sample_rate_hz = float(sr)
self.bytes_per_sample = bps
self.n_channels = n_ch
# Reference-only fields: the ROI as entered before per-angle
# bounding-box expansion. Actual per-angle geometry used for
# rendering comes from the Per-Angle Geometry Table below.
self.x_start_nominal_mm = float(x_start_nom)
self.y_start_nominal_mm = float(y_start_nom)
self.x_delta_nominal_mm = float(x_delta_nom)
self.y_delta_nominal_mm = float(y_delta_nom)
self.row_spacing_mm = float(row_spacing)
self.n_frames_header = None
self.frame_count_mismatch = False
self.n_frames_remainder = 0
self.precomputed_freq_mhz: np.ndarray | None = None
self.precomputed_dc4_mv: np.ndarray | None = None
self.precomputed_dc3_mv: np.ndarray | None = None
self.precomputed_bg_sub: bool = False
angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4").astype(np.float32)
x_start = np.empty(n_angles, dtype=np.float64)
n_frames = np.empty(n_angles, dtype=np.int64)
n_rows = np.empty(n_angles, dtype=np.int64)
for a in range(n_angles):
xs, xd, nf, nr = struct.unpack(GEO_FMT_V6, f.read(GEO_SIZE_V6))
x_start[a] = xs
n_frames[a] = nf
n_rows[a] = nr
y_pos_per_angle = []
for a in range(n_angles):
nr = int(n_rows[a])
y_pos_per_angle.append(
np.frombuffer(f.read(nr * 4), dtype=">f4").astype(np.float32))
preambles = []
for _ in range(n_ch):
(length,) = struct.unpack(">H", f.read(2))
preambles.append(f.read(length).decode("utf-8"))
self.preambles = preambles
self.ch_ymult_mv = []
self.ch_yoff_adc = []
self.ch_yzero_mv = []
for p in preambles:
cal = _parse_preamble(p)
self.ch_ymult_mv.append(cal.get("YMULT", _FALLBACK_YMULT_MV / 1000) * 1000)
self.ch_yoff_adc.append(cal.get("YOFF", _FALLBACK_YOFF_ADC))
self.ch_yzero_mv.append(cal.get("YZERO", 0.0) * 1000)
(n_bg,) = struct.unpack(">I", f.read(4))
self.background = np.frombuffer(f.read(n_bg), dtype=np.int8).astype(np.float32)
data_offset = f.tell()
# ---- Memory-map each angle's ragged waveform block -------------
# v6 gives each angle its own row/frame count, so waveform data is
# no longer one uniform (n_angles, n_rows, ...) block — each angle's
# block sits at a different offset with its own shape. An aborted
# scan truncates the file mid-angle; per the format spec we keep
# whatever complete angles are present rather than refusing to open
# the file.
file_size = self.path.stat().st_size
waveform_dtype = np.int8 if bps == 1 else ">i2"
data = []
offset = data_offset
n_complete = 0
for a in range(n_angles):
nr = int(n_rows[a])
nf = int(n_frames[a])
nbytes = nr * n_ch * nf * spf * bps
if offset + nbytes > file_size:
break
data.append(np.memmap(
str(self.path), dtype=waveform_dtype, mode="r",
offset=offset, shape=(nr, n_ch, nf, spf),
))
offset += nbytes
n_complete += 1
if n_complete == 0:
raise ValueError(
"v6 file has no complete angle blocks — scan was aborted "
"before the first angle finished.")
self.data = data
self.n_angles = n_complete
self.n_angles_declared = n_angles_declared
self.scan_aborted = n_complete < n_angles_declared
self.angles_deg = angles[:n_complete]
self.x_start_mm = x_start[:n_complete]
self.n_frames = n_frames[:n_complete]
self.n_rows = n_rows[:n_complete]
self._y_pos_per_angle = y_pos_per_angle[:n_complete]
# ------------------------------------------------------------------
# v5 writer
# ------------------------------------------------------------------
def write_v5(self, dest_path: str,
freq_images: np.ndarray,
dc4_images: np.ndarray,
dc3_images: np.ndarray,
bg_sub_applied: bool,
progress_cb=None):
"""Write a v5 .sras file to *dest_path*.
Copies the raw waveform bytes verbatim from the current file,
bumps the version byte to 5, patches n_frames_hdr to the actual
frame count, then appends the PREC section.
*freq_images* / *dc4_images* / *dc3_images*:
shape (n_angles, n_rows, n_frames) float32.
*progress_cb*: optional callable(fraction: float) for UI updates.
Only valid for v2v5 source files, which have uniform per-angle
geometry. v6 files scan a different bounding box per angle and
cannot be losslessly represented in the flat v5 layout.
"""
if self.version == 6:
raise NotImplementedError(
"Pre-process to v5 is not supported for v6 source files "
"(per-angle geometry does not fit the flat v5 layout).")
import shutil
dest = Path(dest_path)
src = self.path
n_rows0 = int(self.n_rows[0])
n_frames0 = int(self.n_frames[0])
# --- Copy the source file verbatim, then patch the header -------
shutil.copy2(str(src), str(dest))
waveform_bytes = (self.n_angles * n_rows0 * self.n_channels
* n_frames0 * self.samples_per_frame
* self.bytes_per_sample)
with open(str(dest), "r+b") as f:
# Patch version byte (offset 4 in the header struct)
f.seek(4)
f.write(struct.pack("B", 5))
# Patch n_frames_hdr (uint32, big-endian) with the actual count.
# Locate its offset: magic(4) + ver(1) + n_angles(2) + n_rows(2) = 9
# then x_start(4)+x_delta(4)+vel(4)+freq(4) = 16, total = 25
# then n_frames_hdr is at offset 25 as ">I" (4 bytes)
f.seek(25)
f.write(struct.pack(">I", n_frames0))
# Truncate anything after the waveform data (e.g. old PREC tail)
# and seek to the append position.
waveform_end = self._data_offset_for_write()
f.seek(waveform_end + waveform_bytes)
f.truncate()
# --- Write PREC section -------------------------------------
n_stored = self.n_angles
flags = 0x01 if bg_sub_applied else 0x00
f.write(b"PREC")
f.write(struct.pack("BB", 1, flags))
f.write(struct.pack(">H", n_stored))
for aidx in range(n_stored):
if progress_cb is not None:
progress_cb(aidx / n_stored)
f.write(struct.pack(">H", aidx))
f.write(freq_images[aidx].astype(">f4").tobytes())
f.write(dc4_images[aidx].astype(">f4").tobytes())
f.write(dc3_images[aidx].astype(">f4").tobytes())
if progress_cb is not None:
progress_cb(1.0)
def _data_offset_for_write(self) -> int:
"""Return the file offset where waveform data starts (used by write_v5)."""
# Re-derive the offset by walking the header fields, since we do not
# persist data_offset as an attribute from _parse.
with open(self.path, "rb") as f:
fields = struct.unpack(HDR_FMT, f.read(HDR_SIZE))
ver = fields[1]
n_ch = fields[12]
n_rows0 = int(self.n_rows[0])
with open(self.path, "rb") as f:
f.seek(HDR_SIZE)
f.read(self.n_angles * 4) # angles
f.read(n_rows0 * 4) # y_pos
if ver >= 3:
for _ in range(n_ch):
(length,) = struct.unpack(">H", f.read(2))
f.read(length)
if ver >= 4:
(n_bg,) = struct.unpack(">I", f.read(4))
f.read(n_bg)
return f.tell()
# ------------------------------------------------------------------
# Axes helpers
# ------------------------------------------------------------------
@property
def pixel_x_mm(self) -> float:
return self.velocity_mm_s / self.laser_freq_hz
def x_axis_mm(self, angle_idx: int) -> np.ndarray:
n = int(self.n_frames[angle_idx])
return self.x_start_mm[angle_idx] + np.arange(n) * self.pixel_x_mm
def y_positions_mm(self, angle_idx: int) -> np.ndarray:
return self._y_pos_per_angle[angle_idx]
def time_axis_ns(self) -> np.ndarray:
return np.arange(self.samples_per_frame) / self.sample_rate_hz * 1e9
def freq_axis_mhz(self, n_fft: int | None = None) -> np.ndarray:
n = n_fft if n_fft is not None else self.samples_per_frame
return np.fft.rfftfreq(n, d=1.0 / self.sample_rate_hz) / 1e6
# ---------------------------------------------------------------------------
# Image computation (vectorised)
# ---------------------------------------------------------------------------
# Rows are batched so the float32 working buffer for one channel's chunk
# (chunk_rows × n_frames × spf × 4 bytes) stays under this budget. A fixed
# row count (the original design) works fine for small legacy scans but is
# catastrophic for a v6 scan with a large per-angle frame/sample count —
# e.g. a 7500-frame × 2500-sample angle needs ~2.4 GB for a single 32-row
# chunk, times several such buffers alive at once for the FFT step, which
# can exceed physical RAM entirely on its own. Sizing the chunk to the
# actual dimensions keeps peak RAM bounded regardless of scan size.
_CHUNK_BYTES_BUDGET = 128 * 1024 * 1024 # ~128 MB per channel-buffer chunk
_CHUNK_ROWS_MAX = 32 # cap for small scans (old behavior)
def _chunk_rows_for(n_frames: int, samples_per_frame: int) -> int:
bytes_per_row = max(1, n_frames * samples_per_frame * 4) # float32
rows = _CHUNK_BYTES_BUDGET // bytes_per_row
return int(max(1, min(_CHUNK_ROWS_MAX, rows)))
def compute_dc_image(sras: SrasFile, angle_idx: int, ch_idx: int) -> np.ndarray:
"""Mean of each waveform → (n_rows, n_frames) float32.
Processes in row chunks sized to a fixed memory budget (see
``_chunk_rows_for``) so the float32 working buffer stays bounded
regardless of scan size.
"""
n_rows = int(sras.n_rows[angle_idx])
n_frames = int(sras.n_frames[angle_idx])
data = sras.data[angle_idx]
chunk_rows = _chunk_rows_for(n_frames, sras.samples_per_frame)
img = np.empty((n_rows, n_frames), dtype=np.float32)
for r0 in range(0, n_rows, chunk_rows):
r1 = min(r0 + chunk_rows, n_rows)
img[r0:r1] = (
data[r0:r1, ch_idx, :, :]
.astype(np.float32)
.mean(axis=-1)
)
return img
def compute_rf_image(sras: SrasFile, angle_idx: int,
dc_threshold_mv: float,
apply_bg_sub: bool = True,
n_fft: int | None = None) -> np.ndarray:
"""FFT of each CH1 waveform; pixel = peak frequency in MHz.
Pixels where CH4_dc < dc_threshold_mv are set to 0.
Fast path: if the file contains v5 precomputed peak-frequency images,
and zero-padding is not active, and the bg-sub flag matches, the stored
images are used directly — no FFT is run.
Otherwise, data is processed in row chunks sized to a fixed memory
budget (see ``_chunk_rows_for``) to bound peak RAM regardless of scan
size.
"""
n_rows = int(sras.n_rows[angle_idx])
n_frames = int(sras.n_frames[angle_idx])
data = sras.data[angle_idx]
# ---- Fast path: v5 precomputed images ----------------------------------
can_use_precomputed = (
sras.precomputed_freq_mhz is not None
and n_fft is None # no custom zero-padding
and sras.precomputed_bg_sub == (apply_bg_sub and sras.background is not None)
)
if can_use_precomputed:
freq_img = sras.precomputed_freq_mhz[angle_idx].copy()
dc4_img = sras.precomputed_dc4_mv[angle_idx]
freq_img[dc4_img < dc_threshold_mv] = 0.0
return freq_img
# ---- Chunked FFT path --------------------------------------------------
freq_axis = sras.freq_axis_mhz(n_fft)
img = np.zeros((n_rows, n_frames), dtype=np.float32)
_n_workers = os.cpu_count() or 4
n_fft_bins = n_fft if n_fft is not None else sras.samples_per_frame
chunk_rows = _chunk_rows_for(n_frames, max(sras.samples_per_frame, n_fft_bins))
for r0 in range(0, n_rows, chunk_rows):
r1 = min(r0 + chunk_rows, n_rows)
# DC mask for this chunk (float32 expansion is only chunk-sized)
dc4_raw = data[r0:r1, CH4_IDX, :, :].astype(np.float32)
dc4_mv = adc_to_mv(dc4_raw.mean(axis=-1),
sras.ch_ymult_mv[CH4_IDX],
sras.ch_yoff_adc[CH4_IDX],
sras.ch_yzero_mv[CH4_IDX])
del dc4_raw
valid = dc4_mv >= dc_threshold_mv # True = above threshold = run FFT
if not valid.any():
continue
waveforms = data[r0:r1, CH1_IDX, :, :].astype(np.float32)
if apply_bg_sub and sras.background is not None:
waveforms -= sras.background # background is 1-D (spf,)
valid_waves = waveforms[valid] # (n_valid, spf)
del waveforms
fft_pow = np.abs(_do_rfft(valid_waves, n=n_fft, axis=-1, workers=_n_workers)) ** 2
del valid_waves
fft_pow[:, 0] = 0.0 # suppress DC bin
peak_bins = np.argmax(fft_pow, axis=-1)
del fft_pow
img[r0:r1][valid] = freq_axis[peak_bins]
return img
# ---------------------------------------------------------------------------
# Background workers
# ---------------------------------------------------------------------------
class LoadWorker(QObject):
finished = pyqtSignal(object) # SrasFile | None
error = pyqtSignal(str)
def __init__(self, path: str):
super().__init__()
self._path = path
def run(self):
try:
self.finished.emit(SrasFile(self._path))
except Exception as exc:
self.error.emit(str(exc))
self.finished.emit(None)
class ComputeWorker(QObject):
finished = pyqtSignal(np.ndarray)
error = pyqtSignal(str)
def __init__(self, sras: SrasFile, angle_idx: int,
ch_idx: int, dc_threshold_mv: float,
grating_um: float = 25,
apply_bg_sub: bool = True,
n_fft: int | None = None):
super().__init__()
self._sras = sras
self._angle = angle_idx
self._ch = ch_idx
self._threshold = dc_threshold_mv
self._grating_um = grating_um
self._apply_bg_sub = apply_bg_sub
self._n_fft = n_fft
def run(self):
try:
if self._ch == CH1_IDX:
img = compute_rf_image(self._sras, self._angle, self._threshold,
self._apply_bg_sub, n_fft=self._n_fft)
elif self._ch == VELOCITY_MODE_IDX:
# velocity (m/s) = freq (MHz) × grating (µm) [units cancel to m/s]
img = compute_rf_image(self._sras, self._angle, self._threshold,
self._apply_bg_sub, n_fft=self._n_fft)
img = img * self._grating_um
else:
# DC channels: convert ADC counts → mV
adc_img = compute_dc_image(self._sras, self._angle, self._ch)
img = adc_to_mv(adc_img,
self._sras.ch_ymult_mv[self._ch],
self._sras.ch_yoff_adc[self._ch],
self._sras.ch_yzero_mv[self._ch])
self.finished.emit(img)
except Exception as exc:
self.error.emit(str(exc))
class PreprocessWorker(QObject):
"""Compute all-angle FFT and DC images and write a v5 file.
Emits ``progress(int)`` (0100) as each angle completes and
``finished(str)`` with an empty string on success or an error message
on failure. Only used for v2v5 source files (uniform geometry).
"""
progress = pyqtSignal(int) # 0100
finished = pyqtSignal(str) # empty = success, else error message
def __init__(self, sras: SrasFile, dest_path: str, apply_bg_sub: bool):
super().__init__()
self._sras = sras
self._dest_path = dest_path
self._apply_bg_sub = apply_bg_sub
def run(self):
try:
sras = self._sras
n = sras.n_angles
n_rows0 = int(sras.n_rows[0])
n_frames0 = int(sras.n_frames[0])
freq_images = np.empty((n, n_rows0, n_frames0), dtype=np.float32)
dc4_images = np.empty_like(freq_images)
dc3_images = np.empty_like(freq_images)
for aidx in range(n):
# Compute raw peak-frequency (no DC-threshold masking yet)
freq_images[aidx] = compute_rf_image(
sras, aidx, dc_threshold_mv=-1e9, # mask nothing
apply_bg_sub=self._apply_bg_sub)
# DC images (ADC counts → mV)
dc4_images[aidx] = adc_to_mv(
compute_dc_image(sras, aidx, CH4_IDX),
sras.ch_ymult_mv[CH4_IDX],
sras.ch_yoff_adc[CH4_IDX],
sras.ch_yzero_mv[CH4_IDX])
dc3_images[aidx] = adc_to_mv(
compute_dc_image(sras, aidx, CH3_IDX),
sras.ch_ymult_mv[CH3_IDX],
sras.ch_yoff_adc[CH3_IDX],
sras.ch_yzero_mv[CH3_IDX])
self.progress.emit(int((aidx + 1) / n * 90))
bg_sub_flag = self._apply_bg_sub and sras.background is not None
sras.write_v5(
self._dest_path,
freq_images, dc4_images, dc3_images,
bg_sub_applied=bg_sub_flag,
progress_cb=lambda frac: self.progress.emit(90 + int(frac * 10)),
)
self.finished.emit("")
except Exception as exc:
self.finished.emit(str(exc))
# ---------------------------------------------------------------------------
# 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)
pts = np.array([[lx, by], [rx, by], [rx, ty], [lx, ty]])
return cls(pts)
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:
"""Mean of the four corners."""
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.
"""
X, Y = np.meshgrid(np.asarray(x_axis, dtype=np.float64),
np.asarray(y_axis, dtype=np.float64))
points = np.column_stack([X.ravel(), Y.ravel()])
inside = MplPath(self._pts).contains_points(points)
return inside.reshape(X.shape)
# ---------------------------------------------------------------------------
# Matplotlib canvases
# ---------------------------------------------------------------------------
class ImageCanvas(FigureCanvasQTAgg):
pixel_clicked = pyqtSignal(int, int) # row_idx, frame_idx
roi_changed = pyqtSignal() # emitted when ROI is created / edited / cleared
draw_mode_changed = pyqtSignal(bool) # emitted when "draw new ROI" arm toggles
# 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 outline
poly = Polygon(corners, closed=True, fill=True,
facecolor="#ffd93a", edgecolor="#e53935",
alpha=0.22, linewidth=2.0, zorder=10)
self.ax.add_patch(poly)
self._roi_artists.append(poly)
# Sharp edge (no fill) for better visibility over bright images
edge = Polygon(corners, closed=True, fill=False,
edgecolor="#e53935", linewidth=1.8, zorder=11)
self.ax.add_patch(edge)
self._roi_artists.append(edge)
# Corner handles (white fill, red edge) — drag each independently
handles = self.ax.scatter(corners[:, 0], corners[:, 1],
s=60, c="white", edgecolors="#e53935",
linewidths=1.6, zorder=13)
self._roi_artists.append(handles)
# ------------------------------------------------------------------
# Hit testing (uses 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 = self._roi.corners()
corners_disp = self.ax.transData.transform(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
elif kind == "inside":
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
x1, y1 = event.xdata, event.ydata
self._roi = RoiQuad.from_bbox(x0, y0, x1, y1)
elif self._state == self._MOVE:
dx = event.xdata - self._move_anchor[0]
dy = event.ydata - self._move_anchor[1]
self._roi._pts = self._snapshot.corners() + np.array([dx, dy])
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
if prev_state == self._DRAW_NEW:
# Reject zero-area or vanishingly-small quads
if self._extent is not None:
x0, x1, y_bot, y_top = self._extent
# Minimum: 1% of each axis range
min_w = abs(x1 - x0) * 0.01
min_h = abs(y_bot - y_top) * 0.01
else:
min_w = min_h = 1e-6
if self._roi is not None:
bbox = self._roi.bbox_size()
too_small = bbox[0] < min_w or bbox[1] < min_h
else:
too_small = True
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()
self._press_xy = self._press_pixel = None
self._press_button = None
return
if prev_state in (self._MOVE, self._DRAG_CORNER):
self._draw_roi()
self.draw_idle()
self.roi_changed.emit()
self._press_xy = self._press_pixel = None
self._press_button = None
return
# IDLE → treat as pixel click if release is close to press
if (self._press_pixel is not None and event.x is not None and
event.y is not None and self._extent is not None):
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 * self._CLICK_THRESH_PX
and event.inaxes is self.ax
and event.xdata is not None):
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)
col = max(0, min(col, n_frames - 1))
row = max(0, min(row, n_rows - 1))
self.pixel_clicked.emit(row, col)
self._press_xy = self._press_pixel = None
self._press_button = None
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 ""
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"({adc_to_mv(dc3_val, sras.ch_ymult_mv[CH3_IDX], sras.ch_yoff_adc[CH3_IDX], sras.ch_yzero_mv[CH3_IDX]):.2f} / "
f"{adc_to_mv(dc4_val, sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX], sras.ch_yzero_mv[CH4_IDX]):.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_idx = int(np.argmax(power_sub))
peak_mhz = f_mhz[peak_idx]
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)
t_ns = sras.time_axis_ns()
mean_val = float(waveform.mean())
mean_mv = adc_to_mv(mean_val, sras.ch_ymult_mv[ch_idx], sras.ch_yoff_adc[ch_idx],
sras.ch_yzero_mv[ch_idx])
self.ax_wave.cla()
self.ax_right.cla()
self.ax_wave.plot(t_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\n"
f"mean = {mean_val:.3f} ADC\n"
f" = {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("font-size: 11px; color: #aaa;")
zl.addWidget(lbl)
layout.addWidget(grp_zp)
# ---- Buttons ---------------------------------------------------
buttons = QDialogButtonBox()
self._apply_btn = buttons.addButton(
"Apply", QDialogButtonBox.ButtonRole.AcceptRole)
self._cancel_btn = buttons.addButton(
"Cancel", QDialogButtonBox.ButtonRole.RejectRole)
self._apply_btn.clicked.connect(self.accept)
self._cancel_btn.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._load_thread: QThread | None = None
self._compute_thread: QThread | None = None
self._pending_angle: int = 0
self._pending_ch: int = 0
self._pending_threshold: float = 50.0 # mV
self._pending_grating_um: float = 25 # µm
self._pending_bg_sub: bool = True
self._progress_dlg: QProgressDialog | None = None
# FFT settings (configured via FFT Options dialog)
self._fft_pad_factor: int = 1 # 1 = no padding
self._pending_fft_pad_factor: int = 1
self._preprocess_thread: QThread | None = None
self._build_ui()
if initial_path:
self._load_file(initial_path)
# ------------------------------------------------------------------
# UI construction
# ------------------------------------------------------------------
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root = QHBoxLayout(central)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(8)
# ---- Left control panel ----------------------------------------
panel = QWidget()
panel.setFixedWidth(260)
panel_layout = QVBoxLayout(panel)
panel_layout.setContentsMargins(0, 0, 0, 0)
panel_layout.setSpacing(6)
root.addWidget(panel)
# 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("color: #888; font-size: 11px;")
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("font-size: 11px;")
il.addWidget(lbl)
self._info[key] = lbl
# Frame count warning (hidden until needed)
self.lbl_frame_warn = QLabel("")
self.lbl_frame_warn.setWordWrap(True)
self.lbl_frame_warn.setStyleSheet("color: #e07000; font-size: 11px;")
il.addWidget(self.lbl_frame_warn)
panel_layout.addWidget(grp_info)
# View settings
grp_view = QGroupBox("View Settings")
vl = QVBoxLayout(grp_view)
# Angle
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)
# Channel
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)
# DC threshold (for RF / CH1 masking)
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet("color: #555;")
vl.addWidget(sep)
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") # updated on file load
self.lbl_threshold_adc.setStyleSheet("font-size: 11px; color: #888;")
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)
# 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)
self.lbl_velocity_formula = QLabel("v (m/s) = freq (MHz) × grating (µm)")
self.lbl_velocity_formula.setStyleSheet("font-size: 10px; color: #888;")
vel_l.addWidget(self.lbl_velocity_formula)
self.grp_velocity.setVisible(False)
# (grp_velocity will be added to the right panel below)
# Export
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 (Region of Interest) ---------------------------------
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, grab corners to resize,\n"
"or use the handle above the top edge to rotate.\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("font-size: 11px; color: #aaa;")
rl.addWidget(lbl)
panel_layout.addWidget(grp_roi)
panel_layout.addStretch()
# ---- Display Options group (added to right panel below) ------------
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_view_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: image + waveform splitter --------------------------
splitter = QSplitter(Qt.Orientation.Vertical)
root.addWidget(splitter, stretch=1)
# Image canvas
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._on_roi_changed)
self.image_canvas.draw_mode_changed.connect(self._on_draw_mode_changed)
toolbar = NavigationToolbar2QT(self.image_canvas, img_widget)
img_vl.addWidget(toolbar)
img_vl.addWidget(self.image_canvas)
splitter.addWidget(img_widget)
# Waveform inspector
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("color: #888; font-size: 11px;")
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])
# ---- Right control panel -------------------------------------------
right_panel = QWidget()
right_panel.setFixedWidth(270)
right_panel_layout = QVBoxLayout(right_panel)
right_panel_layout.setContentsMargins(0, 0, 0, 0)
right_panel_layout.setSpacing(6)
right_panel_layout.addWidget(self.grp_velocity)
right_panel_layout.addWidget(grp_display)
right_panel_layout.addStretch()
root.addWidget(right_panel)
self.statusBar().showMessage("Open an .sras file to begin.")
# ---- Menu bar ----------------------------------------------------------
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)
fft_menu.addSeparator()
self._preprocess_act = QAction("&Pre-process and Save as v5…", self)
self._preprocess_act.setStatusTip(
"Compute FFT and DC images for all angles and save to a v5 file "
"for instant re-opening (no FFT on load). Not available for v6 files.")
self._preprocess_act.setEnabled(False)
self._preprocess_act.triggered.connect(self._on_preprocess)
fft_menu.addAction(self._preprocess_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):
if self._load_thread is not None:
return
# Claim self._load_thread before any call below that can pump the
# Qt event loop — see the comment in _start_compute for why.
self._load_worker = LoadWorker(path)
self._load_thread = QThread()
self._load_worker.moveToThread(self._load_thread)
self._load_thread.started.connect(self._load_worker.run)
self._load_worker.finished.connect(self._on_load_done)
self._load_worker.error.connect(
lambda msg: self.statusBar().showMessage(f"Error: {msg}")
)
self._load_worker.finished.connect(self._load_thread.quit)
self._load_thread.finished.connect(self._on_load_thread_finished)
self.btn_open.setEnabled(False)
self.statusBar().showMessage(f"Loading {Path(path).name}")
self._show_progress(f"Loading {Path(path).name}")
self._load_thread.start()
def _on_load_thread_finished(self):
# See the comment in _on_compute_thread_finished: wait() before
# releasing our reference to avoid destroying a QThread whose OS
# thread hasn't fully joined yet.
if self._load_thread is not None:
self._load_thread.wait()
self._load_thread = None
def _on_load_done(self, sras):
self._close_progress()
self.btn_open.setEnabled(True)
if sras is None:
return
self._sras = sras
self._current_image = None
# 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)
self._update_controls_enabled(True)
# Refresh the ADC-count label now that we have file calibration
self._on_threshold_changed()
self._on_view_changed()
# ------------------------------------------------------------------
# Scan info panel
# ------------------------------------------------------------------
def _update_scan_info_labels(self):
s = self._sras
if s is None:
return
angle_idx = self.spin_angle.value()
self._info["Angles"].setText(f"Angles: {s.n_angles}")
self._info["Rows"].setText(f"Rows: {s.n_rows[angle_idx]}")
self._info["Frames / row"].setText(f"Frames / row: {s.n_frames[angle_idx]}")
self._info["Samples / frame"].setText(f"Samples / frame: {s.samples_per_frame}")
self._info["Sample rate"].setText(f"Sample rate: {s.sample_rate_hz/1e9:.4g} GS/s")
self._info["X start"].setText(f"X start: {s.x_start_mm[angle_idx]:.4g} mm")
self._info["Pixel Δx"].setText(f"Pixel Δx: {s.pixel_x_mm*1e3:.3g} µm")
self._info["Laser freq"].setText(f"Laser freq: {s.laser_freq_hz/1e3:.4g} kHz")
notes = []
if s.frame_count_mismatch:
notes.append(
f"! Header n_frames={s.n_frames_header}, "
f"actual={s.n_frames[angle_idx]} (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.precomputed_freq_mhz is not None:
bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)"
notes.append(f"v5: precomputed images present{bg_note} — display is instant")
if s.version == 6:
notes.append("v6 format: rows / frames / x_start are per-angle")
self.lbl_frame_warn.setText("\n".join(notes))
# ------------------------------------------------------------------
# Controls
# ------------------------------------------------------------------
def _update_controls_enabled(self, enabled: bool):
s = self._sras
self.spin_angle.setEnabled(enabled and s is not None 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)
ch_idx = self.combo_channel.currentIndex()
is_ch1 = enabled and ch_idx in CH1_DERIVED_MODES
# Threshold and bg-sub apply to all CH1 modes
self.spin_threshold_mv.setEnabled(is_ch1)
has_bg = enabled and s is not None and s.background is not None
self.chk_bg_sub.setEnabled(has_bg and is_ch1)
# Velocity grating spinbox
is_vel = enabled and ch_idx == VELOCITY_MODE_IDX
self.spin_grating_um.setEnabled(is_vel)
self.grp_velocity.setVisible(is_vel)
# CSV export: enabled when a CH1-derived image is displayed
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(enabled and s is not None)
# Pre-process: available for v2v5 files when loaded and not already running
can_preprocess = (enabled and s is not None and s.version != 6
and self._preprocess_thread is None)
self._preprocess_act.setEnabled(can_preprocess)
self._update_roi_ui()
def _on_channel_changed(self):
ch_idx = self.combo_channel.currentIndex()
has_file = self._sras is not None
is_ch1 = ch_idx in CH1_DERIVED_MODES
self.spin_threshold_mv.setEnabled(is_ch1 and has_file)
has_bg = has_file and self._sras.background is not None
self.chk_bg_sub.setEnabled(has_bg and is_ch1)
is_vel = ch_idx == VELOCITY_MODE_IDX
self.spin_grating_um.setEnabled(is_vel and has_file)
self.grp_velocity.setVisible(is_vel)
self.btn_export_csv.setEnabled(is_ch1 and has_file and self._current_image is not None)
self._on_view_changed()
def _on_bg_sub_toggled(self):
if self._sras is not None:
if self.combo_channel.currentIndex() in CH1_DERIVED_MODES:
self._start_compute()
def _on_grating_changed(self):
if self._sras is not None and self.combo_channel.currentIndex() == VELOCITY_MODE_IDX:
self._start_compute()
def _on_export_csv(self):
if self._current_image is None or self._sras is None:
return
ch_idx = self._current_ch
angle = self._current_angle
ch_name = CH_NAMES[ch_idx]
default_name = (
f"{self._sras.path.stem}_angle{angle}_{ch_name}.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}")
# ------------------------------------------------------------------
# ROI (rectangle on the image)
# ------------------------------------------------------------------
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_roi_changed(self):
self._update_roi_ui()
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:
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)
def _on_clear_roi(self):
self.image_canvas.clear_roi()
self.statusBar().showMessage("ROI cleared")
def _on_roi_angle_edited(self):
pass # rotation control removed — corners are dragged individually
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)
X, Y = np.meshgrid(np.asarray(x_axis, dtype=np.float64),
np.asarray(y_axis, dtype=np.float64))
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
rows_idx, frames_idx = np.where(mask)
xs = X[mask]
ys = Y[mask]
vals = img[mask]
ch_idx = self._current_ch
ch_name = CH_NAMES[ch_idx]
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
pts = roi.corners()
corners_str = " ".join(f"({p[0]:.6g},{p[1]:.6g})" for p in pts)
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={int(mask.sum())}\n"
"row,frame,x_mm,y_mm,value"
)
data = np.column_stack([
rows_idx.astype(np.int64),
frames_idx.astype(np.int64),
xs, ys, vals.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 ({int(mask.sum())} pixels) to {Path(path).name}")
def _on_threshold_changed(self):
mv = self.spin_threshold_mv.value()
if self._sras is not None:
ymult = self._sras.ch_ymult_mv[CH4_IDX]
yoff = self._sras.ch_yoff_adc[CH4_IDX]
yzero = self._sras.ch_yzero_mv[CH4_IDX]
else:
ymult, yoff, yzero = _FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC, 0.0
self.lbl_threshold_adc.setText(f"{mv_to_adc(mv, ymult, yoff, yzero):.1f} ADC counts")
if self._sras is not None and self.combo_channel.currentIndex() in CH1_DERIVED_MODES:
self._start_compute()
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_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._start_compute()
# ------------------------------------------------------------------
# Computation
# ------------------------------------------------------------------
def _start_compute(self):
if self._sras is None:
return
if self._compute_thread is not None:
return # re-check in _on_compute_thread_finished
angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex()
threshold_mv = self.spin_threshold_mv.value()
grating_um = self.spin_grating_um.value()
apply_bg_sub = self.chk_bg_sub.isChecked()
pad_factor = self._fft_pad_factor
n_fft = (self._sras.samples_per_frame * pad_factor
if pad_factor > 1 else None)
self._pending_angle = angle_idx
self._pending_ch = ch_idx
self._pending_threshold = threshold_mv
self._pending_grating_um = grating_um
self._pending_bg_sub = apply_bg_sub
self._pending_fft_pad_factor = pad_factor
# Claim self._compute_thread *before* anything below that can pump
# the Qt event loop (e.g. QProgressDialog.show() on first display).
# If that happened first, a re-entrant editingFinished/signal could
# slip past the guard above, start a second thread, and then have
# this call's own assignment clobber (and destroy while still
# running) that second thread's QThread object — which aborts the
# process. Assigning immediately closes that window.
self._compute_worker = ComputeWorker(
self._sras, angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub,
n_fft=n_fft,
)
self._compute_thread = QThread()
self._compute_worker.moveToThread(self._compute_thread)
self._compute_thread.started.connect(self._compute_worker.run)
self._compute_worker.finished.connect(self._on_compute_done)
self._compute_worker.error.connect(
lambda msg: self.statusBar().showMessage(f"Compute error: {msg}")
)
self._compute_worker.finished.connect(self._compute_thread.quit)
self._compute_thread.finished.connect(self._on_compute_thread_finished)
self.statusBar().showMessage("Computing image…")
self._show_progress("Computing image…")
self._compute_thread.start()
def _on_compute_thread_finished(self):
# Block until the OS thread has actually joined before dropping our
# last reference — deallocating a QThread whose thread hasn't fully
# terminated yet logs "QThread: Destroyed while thread is still
# running" and aborts the process. The finished() signal fires as
# the thread is winding down but does not guarantee it has joined.
if self._compute_thread is not None:
self._compute_thread.wait()
self._compute_thread = None
angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex()
threshold_mv = self.spin_threshold_mv.value()
grating_um = self.spin_grating_um.value()
apply_bg_sub = self.chk_bg_sub.isChecked()
if (angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub,
self._fft_pad_factor) != (
self._pending_angle, self._pending_ch,
self._pending_threshold, self._pending_grating_um,
self._pending_bg_sub,
self._pending_fft_pad_factor):
self._start_compute()
def _on_compute_done(self, img: np.ndarray):
self._close_progress()
self._current_image = img
self._current_angle = self._pending_angle
self._current_ch = self._pending_ch
self.btn_export_csv.setEnabled(self._pending_ch 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
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(img.min()), float(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 = self.spin_vmin.value()
vmax = self.spin_vmax.value()
ch_idx = self._current_ch
angle_deg = s.angles_deg[self._current_angle]
ch_label = CH_LABELS[ch_idx]
if ch_idx == CH1_IDX:
mode_str = "RF"
unit = "Peak frequency (MHz)"
colorbar_label = "MHz"
elif ch_idx == VELOCITY_MODE_IDX:
grating = self.spin_grating_um.value()
mode_str = "Velocity"
unit = "Velocity (m/s)"
colorbar_label = "m/s"
ch_label = f"Velocity [grating={grating:.2f} µm]"
else:
mode_str = "DC"
unit = "DC mean (mV)"
colorbar_label = "mV"
title = f"{CH_NAMES[ch_idx]} | {mode_str} | {angle_deg:.1f}°"
self.image_canvas.show_image(
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"| {img.shape[1]} × {img.shape[0]} px | {unit}"
)
# ------------------------------------------------------------------
# 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
self.lbl_wave_hint.hide()
ch_idx = self._current_ch
if ch_idx in CH1_DERIVED_MODES:
self.wave_canvas.show_rf_waveform(
self._sras, self._current_angle, row_idx, frame_idx,
apply_bg_sub=self.chk_bg_sub.isChecked(),
)
else:
self.wave_canvas.show_dc_waveform(
self._sras, self._current_angle, ch_idx, row_idx, frame_idx
)
# ------------------------------------------------------------------
# Progress dialog helpers
# ------------------------------------------------------------------
def _show_progress(self, message: str):
if self._progress_dlg is not None:
self._progress_dlg.setLabelText(message)
return
dlg = QProgressDialog(message, "", 0, 0, self)
dlg.setWindowTitle("Please wait…")
dlg.setCancelButton(None)
dlg.setWindowModality(Qt.WindowModality.WindowModal)
dlg.setMinimumDuration(300) # only appears if operation takes > 300 ms
dlg.show()
self._progress_dlg = dlg
def _close_progress(self):
if self._progress_dlg is not None:
self._progress_dlg.close()
self._progress_dlg = None
# ------------------------------------------------------------------
# Pre-process → save v5
# ------------------------------------------------------------------
def _on_preprocess(self):
if self._sras is None:
return
if self._preprocess_thread is not None:
return
s = self._sras
if s.version == 6:
self.statusBar().showMessage(
"Pre-process to v5 is not supported for v6 files (per-angle geometry).")
return
default_name = s.path.stem + "_v5" + s.path.suffix
dest, _ = QFileDialog.getSaveFileName(
self, "Save Pre-processed v5 File",
str(s.path.parent / default_name),
"SRAS files (*.sras);;All files (*)",
)
if not dest:
return
if self._preprocess_thread is not None:
return # a second trigger snuck in while the save dialog was open
apply_bg = self.chk_bg_sub.isChecked() and s.background is not None
n_total = s.n_angles
n_px = int(s.n_rows[0]) * int(s.n_frames[0])
approx_mb = n_total * n_px * 3 * 4 / 1e6
# Claim self._preprocess_thread before any call below that can pump
# the Qt event loop — see the comment in _start_compute for why.
self._preprocess_worker = PreprocessWorker(s, dest, apply_bg)
self._preprocess_thread = QThread()
self._preprocess_worker.moveToThread(self._preprocess_thread)
self._preprocess_thread.started.connect(self._preprocess_worker.run)
self._preprocess_worker.progress.connect(self._on_preprocess_progress)
self._preprocess_worker.finished.connect(self._on_preprocess_done)
self._preprocess_worker.finished.connect(self._preprocess_thread.quit)
self._preprocess_thread.finished.connect(self._on_preprocess_thread_finished)
self._preprocess_act.setEnabled(False)
self._show_progress(
f"Pre-processing {n_total} angle(s) "
f"({int(s.n_rows[0])}×{int(s.n_frames[0])} px each, ~{approx_mb:.0f} MB output)…"
)
self._preprocess_thread.start()
def _on_preprocess_progress(self, pct: int):
if self._progress_dlg is not None:
self._progress_dlg.setValue(pct)
def _on_preprocess_done(self, error_msg: str):
self._close_progress()
if error_msg:
self.statusBar().showMessage(f"Pre-process failed: {error_msg}")
else:
self.statusBar().showMessage("v5 file written — re-open it for instant display.")
def _on_preprocess_thread_finished(self):
# See the comment in _on_compute_thread_finished: wait() before
# releasing our reference to avoid destroying a QThread whose OS
# thread hasn't fully joined yet.
if self._preprocess_thread is not None:
self._preprocess_thread.wait()
self._preprocess_thread = None
self._preprocess_act.setEnabled(
self._sras is not None and self._sras.version != 6)
# ------------------------------------------------------------------
# FFT Options
# ------------------------------------------------------------------
def _on_fft_options(self):
global _fft_backend
spf = self._sras.samples_per_frame if self._sras is not None else None
sr = self._sras.sample_rate_hz if self._sras is not None else None
dlg = FftOptionsDialog(
self,
current_backend=_fft_backend,
current_pad_factor=self._fft_pad_factor,
samples_per_frame=spf,
sample_rate_hz=sr,
grating_um=self.spin_grating_um.value(),
)
if dlg.exec() == QDialog.DialogCode.Accepted:
_fft_backend = dlg.get_backend()
self._fft_pad_factor = dlg.get_pad_factor()
if self._sras is not None and self.combo_channel.currentIndex() in (
CH1_IDX, VELOCITY_MODE_IDX):
self._start_compute()
# ------------------------------------------------------------------
def closeEvent(self, event):
for attr in ("_load_thread", "_compute_thread", "_preprocess_thread"):
t = getattr(self, attr, None)
if t is not None:
t.quit()
t.wait(2000)
super().closeEvent(event)
# ---------------------------------------------------------------------------
def main():
app = QApplication(sys.argv)
initial = sys.argv[1] if len(sys.argv) > 1 else None
window = SrasViewerWindow(initial_path=initial)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()