1962 lines
82 KiB
Python
Executable File
1962 lines
82 KiB
Python
Executable File
#!/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.
|
||
"""
|
||
|
||
import re
|
||
import sys
|
||
import struct
|
||
import numpy as np
|
||
from pathlib import Path
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
import os
|
||
from scipy.signal import butter, sosfiltfilt, decimate as sp_decimate, hilbert
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||
QGroupBox, QLabel, QPushButton, QComboBox, QSpinBox, QDoubleSpinBox,
|
||
QFileDialog, QSizePolicy, QSplitter, QCheckBox, QFrame, QProgressDialog,
|
||
)
|
||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject
|
||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
|
||
from matplotlib.figure import Figure
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SAW signal processing pipeline
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class SawPipeline:
|
||
"""Modular EMI-cleaning and SAW extraction pipeline.
|
||
|
||
Stages (each independently bypassable):
|
||
1. EMI gate — cosine-taper first `emi_gate_ns` ns to suppress the
|
||
laser-firing burst at t≈0; leaves SAW packet untouched.
|
||
2. Bandpass — 6th-order Butterworth zero-phase (sosfiltfilt), default
|
||
85–200 MHz. Matches hardware bandpass already applied.
|
||
3. Decimate — optional; reduces to ~781 MS/s (factor-8) before the
|
||
matched filter without losing SAW information.
|
||
4. Matched filter — FFT cross-correlation with a Hann-windowed template
|
||
built from the average of N clean shots.
|
||
5. Analytic — Hilbert transform of MF output → amplitude envelope and
|
||
instantaneous phase.
|
||
|
||
Typical usage::
|
||
|
||
pipe = SawPipeline(sras.sample_rate_hz)
|
||
waveforms = sras.data[angle, :, CH1_IDX, :, :].reshape(-1, spf)
|
||
pipe.build_template(waveforms[:50])
|
||
result = pipe.process_shot(waveform)
|
||
# result["envelope"], result["peak_amplitude"], result["peak_time_ns"]
|
||
"""
|
||
|
||
DECIMATE_FACTOR = 8 # 6250 MS/s → 781.25 MS/s (~4× SAW BW of 200 MHz)
|
||
|
||
def __init__(self, sample_rate_hz: float,
|
||
emi_gate_ns: float = 50.0,
|
||
bp_lo_mhz: float = 85.0,
|
||
bp_hi_mhz: float = 200.0,
|
||
saw_window_ns: tuple[float, float] = (80.0, 350.0),
|
||
decimate_enable: bool = False):
|
||
self.sample_rate_hz = float(sample_rate_hz)
|
||
self.emi_gate_ns = float(emi_gate_ns)
|
||
self.bp_lo_mhz = float(bp_lo_mhz)
|
||
self.bp_hi_mhz = float(bp_hi_mhz)
|
||
self.saw_window_ns = (float(saw_window_ns[0]), float(saw_window_ns[1]))
|
||
self.decimate_enable = decimate_enable
|
||
self.template: np.ndarray | None = None
|
||
self._emi_gate_samples: int = 0
|
||
self._sos = None
|
||
self._effective_sr = self.sample_rate_hz
|
||
self._build_filter()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Setup
|
||
# ------------------------------------------------------------------
|
||
|
||
def _build_filter(self):
|
||
self._emi_gate_samples = max(1, int(round(
|
||
self.emi_gate_ns * 1e-9 * self.sample_rate_hz)))
|
||
nyq = self.sample_rate_hz / 2.0
|
||
lo = np.clip(self.bp_lo_mhz * 1e6 / nyq, 1e-6, 0.999)
|
||
hi = np.clip(self.bp_hi_mhz * 1e6 / nyq, lo + 1e-6, 0.9999)
|
||
# 6th-order Butterworth → 12th-order bandpass; ~120 dB/decade rolloff
|
||
self._sos = butter(6, [lo, hi], btype='bandpass', output='sos')
|
||
self._effective_sr = (self.sample_rate_hz / self.DECIMATE_FACTOR
|
||
if self.decimate_enable else self.sample_rate_hz)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Individual stages
|
||
# ------------------------------------------------------------------
|
||
|
||
def gate_emi(self, signal: np.ndarray) -> np.ndarray:
|
||
"""Cosine-taper (raised cosine 0→1) the first `emi_gate_samples` samples.
|
||
|
||
The taper rolls up smoothly from zero so the abrupt EMI burst is
|
||
suppressed without introducing a step discontinuity at the gate edge.
|
||
"""
|
||
n = min(self._emi_gate_samples, len(signal))
|
||
out = signal.copy()
|
||
out[:n] *= 0.5 * (1.0 - np.cos(np.pi * np.arange(n) / n))
|
||
return out
|
||
|
||
def bandpass(self, signal: np.ndarray) -> np.ndarray:
|
||
"""Zero-phase IIR Butterworth bandpass (sosfiltfilt)."""
|
||
return sosfiltfilt(self._sos, signal.astype(np.float64)).astype(np.float32)
|
||
|
||
def decimate_signal(self, signal: np.ndarray) -> np.ndarray:
|
||
"""Decimate by DECIMATE_FACTOR with scipy anti-alias filter."""
|
||
return sp_decimate(signal.astype(np.float64), self.DECIMATE_FACTOR,
|
||
zero_phase=True).astype(np.float32)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Template construction
|
||
# ------------------------------------------------------------------
|
||
|
||
def build_template(self, waveforms: np.ndarray) -> None:
|
||
"""Build Hann-windowed average template.
|
||
|
||
Parameters
|
||
----------
|
||
waveforms : ndarray, shape (N, n_samples)
|
||
Raw or pre-processed CH1 waveforms. EMI gating + bandpass are
|
||
applied here before averaging so the template is clean.
|
||
"""
|
||
processed = np.stack([
|
||
self.bandpass(self.gate_emi(w.astype(np.float32)))
|
||
for w in waveforms
|
||
])
|
||
avg = processed.mean(axis=0)
|
||
|
||
# Hann window restricted to the declared SAW window region
|
||
n = len(avg)
|
||
t_ns = np.arange(n) / self.sample_rate_hz * 1e9
|
||
i0 = max(0, int(np.searchsorted(t_ns, self.saw_window_ns[0])))
|
||
i1 = min(n, int(np.searchsorted(t_ns, self.saw_window_ns[1])))
|
||
windowed = np.zeros(n, dtype=np.float32)
|
||
win_len = i1 - i0
|
||
if win_len > 0:
|
||
windowed[i0:i1] = avg[i0:i1] * np.hanning(win_len)
|
||
self.template = windowed
|
||
|
||
# ------------------------------------------------------------------
|
||
# Matched filter
|
||
# ------------------------------------------------------------------
|
||
|
||
def matched_filter(self, signal: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||
"""FFT cross-correlation with template.
|
||
|
||
Returns
|
||
-------
|
||
mf_output : float32 ndarray, length = len(signal)
|
||
envelope : float32 ndarray, Hilbert amplitude envelope of mf_output
|
||
"""
|
||
if self.template is None:
|
||
raise RuntimeError("No template — call build_template() first")
|
||
n = len(signal)
|
||
nfft = 1 << (n + len(self.template) - 1).bit_length()
|
||
S = np.fft.rfft(signal.astype(np.float64), nfft)
|
||
T = np.fft.rfft(self.template.astype(np.float64), nfft)
|
||
mf = np.fft.irfft(S * np.conj(T), nfft)[:n]
|
||
env = np.abs(hilbert(mf))
|
||
return mf.astype(np.float32), env.astype(np.float32)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Full pipeline for a single waveform
|
||
# ------------------------------------------------------------------
|
||
|
||
def process_shot(self, signal: np.ndarray) -> dict:
|
||
"""Run EMI gate → bandpass → (decimate) → matched filter on one shot.
|
||
|
||
Returns a dict with keys:
|
||
raw, gated, filtered, [decimated], mf_output, envelope,
|
||
peak_amplitude (float), peak_sample (int), peak_time_ns (float),
|
||
snr (float), sample_rate_hz (float).
|
||
"""
|
||
raw = signal.astype(np.float32)
|
||
gated = self.gate_emi(raw)
|
||
filtered = self.bandpass(gated)
|
||
|
||
if self.decimate_enable:
|
||
proc = self.decimate_signal(filtered)
|
||
sr = self._effective_sr
|
||
else:
|
||
proc = filtered
|
||
sr = self.sample_rate_hz
|
||
|
||
if self.template is not None:
|
||
mf_out, env = self.matched_filter(proc)
|
||
else:
|
||
mf_out = proc.copy()
|
||
env = np.abs(hilbert(proc)).astype(np.float32)
|
||
|
||
t_ns = np.arange(len(env)) / sr * 1e9
|
||
|
||
# Peak within SAW window
|
||
s0, s1 = self.saw_window_ns
|
||
roi = (t_ns >= s0) & (t_ns <= s1)
|
||
if roi.any():
|
||
idx_in_roi = np.argmax(env[roi])
|
||
peak_sample = int(np.where(roi)[0][idx_in_roi])
|
||
else:
|
||
peak_sample = int(np.argmax(env))
|
||
peak_amplitude = float(env[peak_sample])
|
||
peak_time_ns = float(peak_sample / sr * 1e9)
|
||
|
||
# SNR: peak / RMS of noise floor in the gated EMI region (after bandpass)
|
||
noise_seg = filtered[:self._emi_gate_samples]
|
||
noise_rms = float(np.sqrt(np.mean(noise_seg ** 2))) if len(noise_seg) > 0 else 1.0
|
||
snr = peak_amplitude / noise_rms if noise_rms > 0 else 0.0
|
||
|
||
return {
|
||
"raw": raw,
|
||
"gated": gated,
|
||
"filtered": filtered,
|
||
"mf_output": mf_out,
|
||
"envelope": env,
|
||
"peak_amplitude": peak_amplitude,
|
||
"peak_sample": peak_sample,
|
||
"peak_time_ns": peak_time_ns,
|
||
"snr": snr,
|
||
"sample_rate_hz": sr,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SRAS format
|
||
# ---------------------------------------------------------------------------
|
||
|
||
HDR_FMT = ">4sBHHffffIIdBB"
|
||
HDR_SIZE = struct.calcsize(HDR_FMT) # 43 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)",
|
||
"CH1 — SAW Amplitude (matched filter)",
|
||
"CH1 — SAW Arrival time (matched filter)",
|
||
]
|
||
CH_NAMES = ["CH1", "CH3", "CH4", "VEL", "SAW-AMP", "SAW-TOF"]
|
||
|
||
# Combo indices for derived modes (all use CH1_IDX data)
|
||
VELOCITY_MODE_IDX = 3
|
||
SAW_MODE_AMP_IDX = 4
|
||
SAW_MODE_TOF_IDX = 5
|
||
SAW_MODES = (SAW_MODE_AMP_IDX, SAW_MODE_TOF_IDX)
|
||
# All modes that operate on CH1 waveforms
|
||
CH1_DERIVED_MODES = (CH1_IDX, VELOCITY_MODE_IDX) + SAW_MODES
|
||
|
||
# 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 v2/v3/v4 .sras file."""
|
||
|
||
def __init__(self, path: str):
|
||
self.path = Path(path)
|
||
self._parse()
|
||
|
||
def _parse(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
|
||
|
||
if magic != b"SRAS":
|
||
raise ValueError(f"Bad magic bytes: {magic!r}")
|
||
if ver not in (2, 3, 4):
|
||
raise ValueError(f"Unsupported version: {ver}")
|
||
|
||
self.n_angles = n_angles
|
||
self.n_rows = n_rows
|
||
self.x_start_mm = float(x_start)
|
||
self.x_delta_mm = float(x_delta)
|
||
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
|
||
|
||
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
|
||
|
||
raw = f.read()
|
||
|
||
total_samples = len(raw) // bps
|
||
samples_per_row_per_ch = n_ch * spf
|
||
|
||
# Compute actual frames per channel from the file size — the scanner
|
||
# writes the configured frame count in the header before acquisition
|
||
# begins, but ACQuire:NUMFRAMESACQuired may be lower.
|
||
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.n_frames = actual_n_frames # actual, use this for all indexing
|
||
self.n_frames_header = n_frames_hdr
|
||
self.frame_count_mismatch = (actual_n_frames != n_frames_hdr)
|
||
self.n_frames_remainder = remainder # partial last-row samples
|
||
|
||
# Reshape using the actual count; discard any fractional last row
|
||
dtype = np.int8 if bps == 1 else ">i2"
|
||
good = n_angles * n_rows * n_ch * actual_n_frames * spf
|
||
data = np.frombuffer(raw[:good * bps], dtype=dtype)
|
||
data = data.reshape(n_angles, n_rows, n_ch, actual_n_frames, spf)
|
||
self.data = data.astype(np.int16 if bps == 2 else np.int8)
|
||
|
||
self.angles_deg = angles
|
||
self.y_positions_mm = y_pos
|
||
|
||
# ------------------------------------------------------------------
|
||
# Axes helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
@property
|
||
def pixel_x_mm(self) -> float:
|
||
return self.velocity_mm_s / self.laser_freq_hz
|
||
|
||
def x_axis_mm(self) -> np.ndarray:
|
||
return self.x_start_mm + np.arange(self.n_frames) * self.pixel_x_mm
|
||
|
||
def time_axis_ns(self) -> np.ndarray:
|
||
return np.arange(self.samples_per_frame) / self.sample_rate_hz * 1e9
|
||
|
||
def freq_axis_mhz(self) -> np.ndarray:
|
||
return np.fft.rfftfreq(self.samples_per_frame, d=1.0 / self.sample_rate_hz) / 1e6
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Image computation (vectorised)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
|
||
def compute_dc_image(sras: SrasFile, angle_idx: int, ch_idx: int) -> np.ndarray:
|
||
"""Mean of each waveform → (n_rows, n_frames) float32."""
|
||
return sras.data[angle_idx, :, ch_idx, :, :].astype(np.float32).mean(axis=-1)
|
||
|
||
|
||
def compute_rf_image(sras: SrasFile, angle_idx: int,
|
||
dc_threshold_mv: float,
|
||
apply_bg_sub: bool = True,
|
||
gate_start_ns: float | None = None,
|
||
gate_end_ns: float | 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; FFT is skipped for
|
||
those pixels entirely. DC is always computed before any FFT work.
|
||
The threshold and DC mean are both in mV, using per-channel calibration
|
||
from the file (or fallback constants for v2 files).
|
||
|
||
If apply_bg_sub is True and the file contains a background waveform
|
||
(v4+), each CH1 waveform has the background subtracted before the FFT.
|
||
|
||
gate_start_ns / gate_end_ns: when either is set, samples outside the
|
||
[start, end] time window are zeroed before the FFT (time-domain gating).
|
||
"""
|
||
# --- Step 1: compute CH4 DC mask before any FFT work ---
|
||
dc4_mv = adc_to_mv(compute_dc_image(sras, angle_idx, CH4_IDX),
|
||
sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX],
|
||
sras.ch_yzero_mv[CH4_IDX])
|
||
mask = dc4_mv < dc_threshold_mv # True = below threshold = skip FFT
|
||
valid = ~mask # pixels that require FFT
|
||
|
||
img = np.zeros(mask.shape, dtype=np.float32)
|
||
|
||
if valid.any():
|
||
# --- Step 2: FFT only on pixels that passed the DC threshold ---
|
||
waveforms = sras.data[angle_idx, :, CH1_IDX, :, :].astype(np.float32)
|
||
# shape: (n_rows, n_frames, samples_per_frame)
|
||
|
||
if apply_bg_sub and sras.background is not None:
|
||
waveforms = waveforms - sras.background[np.newaxis, np.newaxis, :]
|
||
|
||
if gate_start_ns is not None or gate_end_ns is not None:
|
||
t_ns = sras.time_axis_ns()
|
||
keep = np.ones(len(t_ns), dtype=bool)
|
||
if gate_start_ns is not None:
|
||
keep &= t_ns >= gate_start_ns
|
||
if gate_end_ns is not None:
|
||
keep &= t_ns <= gate_end_ns
|
||
waveforms = waveforms.copy()
|
||
waveforms[..., ~keep] = 0.0
|
||
|
||
valid_waves = waveforms[valid] # (n_valid, spf)
|
||
fft_pow = np.abs(np.fft.rfft(valid_waves, axis=-1)) ** 2
|
||
fft_pow[:, 0] = 0.0 # suppress DC bin
|
||
peak_bins = np.argmax(fft_pow, axis=-1) # (n_valid,)
|
||
img[valid] = sras.freq_axis_mhz()[peak_bins]
|
||
|
||
return img
|
||
|
||
|
||
def compute_saw_image(sras: SrasFile, angle_idx: int, dc_threshold_mv: float,
|
||
pipeline: SawPipeline, mode: str,
|
||
apply_bg_sub: bool = True) -> np.ndarray:
|
||
"""Run the SAW matched-filter pipeline over every pixel.
|
||
|
||
mode : "amplitude" → MF envelope peak in SAW window
|
||
"tof" → arrival time (ns) of that peak
|
||
Returns (n_rows, n_frames) float32, DC-masked.
|
||
"""
|
||
# --- Step 1: compute CH4 DC mask before running the pipeline ---
|
||
dc4_mv = adc_to_mv(compute_dc_image(sras, angle_idx, CH4_IDX),
|
||
sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX],
|
||
sras.ch_yzero_mv[CH4_IDX])
|
||
mask = dc4_mv < dc_threshold_mv # True = below threshold = skip pipeline
|
||
valid = ~mask
|
||
|
||
img = np.zeros(mask.shape, dtype=np.float32)
|
||
|
||
if valid.any():
|
||
# --- Step 2: run pipeline only on pixels that passed the DC threshold ---
|
||
waveforms = sras.data[angle_idx, :, CH1_IDX, :, :].astype(np.float32)
|
||
if apply_bg_sub and sras.background is not None:
|
||
waveforms = waveforms - sras.background[np.newaxis, np.newaxis, :]
|
||
|
||
valid_waves = waveforms[valid] # (n_valid, spf)
|
||
n_workers = min(os.cpu_count() or 4, len(valid_waves))
|
||
with ThreadPoolExecutor(max_workers=n_workers) as executor:
|
||
results = list(executor.map(pipeline.process_shot, valid_waves))
|
||
|
||
if mode == "amplitude":
|
||
vals = np.array([r["peak_amplitude"] for r in results], dtype=np.float32)
|
||
else:
|
||
vals = np.array([r["peak_time_ns"] for r in results], dtype=np.float32)
|
||
|
||
img[valid] = vals
|
||
|
||
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 = 12.5,
|
||
apply_bg_sub: bool = True,
|
||
gate_start_ns: float | None = None,
|
||
gate_end_ns: float | None = None,
|
||
saw_pipeline: "SawPipeline | 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._gate_start = gate_start_ns
|
||
self._gate_end = gate_end_ns
|
||
self._saw_pipeline = saw_pipeline
|
||
|
||
def run(self):
|
||
try:
|
||
if self._ch == CH1_IDX:
|
||
img = compute_rf_image(self._sras, self._angle, self._threshold,
|
||
self._apply_bg_sub,
|
||
self._gate_start, self._gate_end)
|
||
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,
|
||
self._gate_start, self._gate_end)
|
||
img = img * self._grating_um
|
||
elif self._ch in SAW_MODES:
|
||
if self._saw_pipeline is None or self._saw_pipeline.template is None:
|
||
raise RuntimeError(
|
||
"SAW pipeline: no template built yet.\n"
|
||
"Use \"Build Template\" in the SAW Pipeline panel first.")
|
||
mode = "amplitude" if self._ch == SAW_MODE_AMP_IDX else "tof"
|
||
img = compute_saw_image(
|
||
self._sras, self._angle, self._threshold,
|
||
self._saw_pipeline, mode, self._apply_bg_sub)
|
||
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 TemplateBuildWorker(QObject):
|
||
"""Background thread worker that calls SawPipeline.build_template()."""
|
||
finished = pyqtSignal()
|
||
error = pyqtSignal(str)
|
||
|
||
def __init__(self, pipeline: SawPipeline, waveforms: np.ndarray):
|
||
super().__init__()
|
||
self._pipeline = pipeline
|
||
self._waveforms = waveforms
|
||
|
||
def run(self):
|
||
try:
|
||
self._pipeline.build_template(self._waveforms)
|
||
self.finished.emit()
|
||
except Exception as exc:
|
||
self.error.emit(str(exc))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Matplotlib canvases
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class ImageCanvas(FigureCanvasQTAgg):
|
||
pixel_clicked = pyqtSignal(int, int) # row_idx, frame_idx
|
||
|
||
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
|
||
self.mpl_connect("button_press_event", self._on_click)
|
||
|
||
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)
|
||
|
||
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)
|
||
self.draw()
|
||
|
||
def _on_click(self, event):
|
||
if event.inaxes is not self.ax or self._extent is None:
|
||
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)
|
||
col = max(0, min(col, n_frames - 1))
|
||
row = max(0, min(row, n_rows - 1))
|
||
self.pixel_clicked.emit(row, col)
|
||
|
||
|
||
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,
|
||
gate_start_ns: float | None = None,
|
||
gate_end_ns: float | None = None):
|
||
"""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.
|
||
"""
|
||
waveform = sras.data[angle_idx, row_idx, CH1_IDX, frame_idx, :].astype(np.float32)
|
||
t_ns = sras.time_axis_ns()
|
||
f_mhz = sras.freq_axis_mhz()
|
||
dc3_val = sras.data[angle_idx, row_idx, CH3_IDX, frame_idx, :].astype(np.float32).mean()
|
||
dc4_val = sras.data[angle_idx, 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")
|
||
|
||
# Draw gate boundaries if active
|
||
if gate_start_ns is not None:
|
||
self.ax_wave.axvline(gate_start_ns, color="#22cc44", linestyle="--",
|
||
linewidth=1.0, label=f"gate start {gate_start_ns:.0f} ns")
|
||
if gate_end_ns is not None:
|
||
self.ax_wave.axvline(gate_end_ns, color="#cc4422", linestyle="--",
|
||
linewidth=1.0, label=f"gate end {gate_end_ns:.0f} ns")
|
||
if gate_start_ns is not None or gate_end_ns is not None:
|
||
t_ns = sras.time_axis_ns()
|
||
lo = gate_start_ns if gate_start_ns is not None else t_ns[0]
|
||
hi = gate_end_ns if gate_end_ns is not None else t_ns[-1]
|
||
self.ax_wave.axvspan(t_ns[0], lo, alpha=0.10, color="#cc4422")
|
||
self.ax_wave.axvspan(hi, t_ns[-1], alpha=0.10, color="#cc4422")
|
||
|
||
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()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SAW diagnostic window
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class SawDiagnosticWindow(QMainWindow):
|
||
"""6-panel matplotlib window showing every SAW pipeline stage for one pixel.
|
||
|
||
Panels:
|
||
1. Raw signal with zone shading (EMI gate / noise region / SAW window)
|
||
2. After EMI gating (cosine taper) — same zone shading
|
||
3. After bandpass filter (time domain) — zone shading
|
||
4. Frequency spectrum of bandpass output — passband shading + raw PSD
|
||
5. Matched filter output + Hilbert envelope + metrics — zone shading
|
||
6. Shot-to-shot overlay (up to 20 frames from the same row)
|
||
"""
|
||
|
||
# Zone colour constants (all panels use the same palette)
|
||
_C_EMI = "#e05030" # red — EMI gate
|
||
_C_NOISE = "#ccaa00" # amber — noise / inter-packet region
|
||
_C_SAW = "#30c060" # green — SAW window
|
||
|
||
def __init__(self, sras: SrasFile, pipeline: SawPipeline,
|
||
angle_idx: int, row_idx: int, frame_idx: int,
|
||
parent=None):
|
||
super().__init__(parent)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||
self.setWindowTitle(
|
||
f"SAW Diagnostics angle={angle_idx} row={row_idx} frame={frame_idx}")
|
||
self.resize(1400, 940)
|
||
|
||
# Store references for refresh
|
||
self._sras = sras
|
||
self._pipeline = pipeline
|
||
self._angle_idx = angle_idx
|
||
self._row_idx = row_idx
|
||
self._frame_idx = frame_idx
|
||
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
vl = QVBoxLayout(central)
|
||
vl.setContentsMargins(4, 4, 4, 4)
|
||
vl.setSpacing(4)
|
||
|
||
# Toolbar row: matplotlib toolbar + refresh button
|
||
toolbar_row = QHBoxLayout()
|
||
fig = Figure(figsize=(14, 9), tight_layout=True)
|
||
self._canvas = FigureCanvasQTAgg(fig)
|
||
mpl_toolbar = NavigationToolbar2QT(self._canvas, central)
|
||
toolbar_row.addWidget(mpl_toolbar, stretch=1)
|
||
btn_refresh = QPushButton("Re-apply filter & refresh PSDs")
|
||
btn_refresh.setToolTip(
|
||
"Re-run the current pipeline on this pixel and redraw all panels.\n"
|
||
"Use after rebuilding the template or changing pipeline parameters.")
|
||
btn_refresh.clicked.connect(self._on_refresh)
|
||
toolbar_row.addWidget(btn_refresh)
|
||
vl.addLayout(toolbar_row)
|
||
vl.addWidget(self._canvas)
|
||
|
||
self._plot()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Zone shading helper — call on any time-domain axes
|
||
# ------------------------------------------------------------------
|
||
|
||
def _shade_time_zones(self, ax, t_full: np.ndarray,
|
||
emi_end_ns: float, s0: float, s1: float,
|
||
show_legend: bool = False):
|
||
"""Shade EMI gate, noise region, and SAW window on a time-domain axes."""
|
||
t0, t_end = float(t_full[0]), float(t_full[-1])
|
||
ax.axvspan(t0, emi_end_ns, alpha=0.15, color=self._C_EMI,
|
||
label=f"EMI gate 0–{emi_end_ns:.0f} ns")
|
||
if emi_end_ns < s0:
|
||
ax.axvspan(emi_end_ns, s0, alpha=0.08, color=self._C_NOISE,
|
||
label=f"noise {emi_end_ns:.0f}–{s0:.0f} ns")
|
||
ax.axvspan(s0, min(s1, t_end), alpha=0.10, color=self._C_SAW,
|
||
label=f"SAW {s0:.0f}–{s1:.0f} ns")
|
||
if show_legend:
|
||
ax.legend(fontsize=7, loc="upper right")
|
||
|
||
# ------------------------------------------------------------------
|
||
|
||
def _on_refresh(self):
|
||
self._plot()
|
||
|
||
def _plot(self):
|
||
sras = self._sras
|
||
pipeline = self._pipeline
|
||
angle_idx = self._angle_idx
|
||
row_idx = self._row_idx
|
||
frame_idx = self._frame_idx
|
||
|
||
raw_adc = sras.data[angle_idx, row_idx, CH1_IDX, frame_idx, :].astype(np.float32)
|
||
t_full = sras.time_axis_ns()
|
||
result = pipeline.process_shot(raw_adc)
|
||
sr = result["sample_rate_hz"]
|
||
t_proc = np.arange(len(result["envelope"])) / sr * 1e9
|
||
|
||
# Collect up to 20 frames from the same row for the overlay panel
|
||
n_overlay = min(20, sras.n_frames)
|
||
overlay_idxs = np.linspace(0, sras.n_frames - 1, n_overlay, dtype=int)
|
||
overlays = []
|
||
for fi in overlay_idxs:
|
||
sig = sras.data[angle_idx, row_idx, CH1_IDX, fi, :].astype(np.float32)
|
||
r = pipeline.process_shot(sig)
|
||
overlays.append(r["envelope"])
|
||
|
||
# Shot-to-shot peak amplitude variance
|
||
peak_amps = [float(e[np.argmax(e)]) if len(e) > 0 else 0.0
|
||
for e in overlays]
|
||
peak_var = float(np.var(peak_amps))
|
||
|
||
fig = self._canvas.figure
|
||
fig.clf()
|
||
axes = fig.subplots(3, 2)
|
||
ax_raw, ax_gated = axes[0]
|
||
ax_filt, ax_spec = axes[1]
|
||
ax_mf, ax_over = axes[2]
|
||
|
||
emi_end_ns = pipeline.emi_gate_ns
|
||
s0, s1 = pipeline.saw_window_ns
|
||
|
||
# --- 1. Raw signal ---
|
||
ax_raw.plot(t_full, raw_adc, lw=0.6, color="#4488cc", zorder=3)
|
||
self._shade_time_zones(ax_raw, t_full, emi_end_ns, s0, s1, show_legend=True)
|
||
ax_raw.set_xlabel("Time (ns)")
|
||
ax_raw.set_ylabel("ADC counts")
|
||
ax_raw.set_title(f"1 — Raw signal (row={row_idx}, frame={frame_idx})")
|
||
|
||
# --- 2. After EMI gating ---
|
||
ax_gated.plot(t_full[:len(result["gated"])], result["gated"],
|
||
lw=0.6, color="#cc8833", zorder=3)
|
||
self._shade_time_zones(ax_gated, t_full, emi_end_ns, s0, s1)
|
||
ax_gated.set_xlabel("Time (ns)")
|
||
ax_gated.set_ylabel("Amplitude")
|
||
ax_gated.set_title("2 — After EMI gating (cosine taper)")
|
||
|
||
# --- 3. After bandpass ---
|
||
ax_filt.plot(t_full[:len(result["filtered"])], result["filtered"],
|
||
lw=0.6, color="#44aa44", zorder=3)
|
||
self._shade_time_zones(ax_filt, t_full, emi_end_ns, s0, s1)
|
||
ax_filt.set_xlabel("Time (ns)")
|
||
ax_filt.set_ylabel("Amplitude")
|
||
ax_filt.set_title(
|
||
f"3 — After bandpass ({pipeline.bp_lo_mhz:.0f}–{pipeline.bp_hi_mhz:.0f} MHz, "
|
||
f"6th-order Butterworth, zero-phase)")
|
||
|
||
# --- 4. Frequency spectrum — raw PSD + post-bandpass PSD ---
|
||
raw_sig = raw_adc.astype(np.float64)
|
||
filt_sig = result["filtered"]
|
||
f_hz = np.fft.rfftfreq(len(filt_sig), d=1.0 / sras.sample_rate_hz)
|
||
f_mhz = f_hz / 1e6
|
||
spec_raw = np.abs(np.fft.rfft(raw_sig, n=len(filt_sig))) ** 2
|
||
spec_filt = np.abs(np.fft.rfft(filt_sig)) ** 2
|
||
spec_raw[0] = 0.0
|
||
spec_filt[0] = 0.0
|
||
ax_spec.plot(f_mhz, spec_raw, lw=0.5, color="#aaaaaa", alpha=0.7,
|
||
label="raw PSD", zorder=1)
|
||
ax_spec.plot(f_mhz, spec_filt, lw=0.8, color="#44aa44",
|
||
label="bandpass PSD", zorder=2)
|
||
ax_spec.axvspan(pipeline.bp_lo_mhz, pipeline.bp_hi_mhz,
|
||
alpha=0.14, color=self._C_SAW, label="passband", zorder=0)
|
||
ax_spec.set_xlabel("Frequency (MHz)")
|
||
ax_spec.set_ylabel("Power (arb.)")
|
||
ax_spec.set_title("4 — FFT PSD: raw vs. after bandpass")
|
||
ax_spec.set_xlim(0, min(600.0, sras.sample_rate_hz / 2e6))
|
||
ax_spec.legend(fontsize=7)
|
||
|
||
# --- 5. Matched filter output + envelope ---
|
||
ax_mf.plot(t_proc, result["mf_output"], lw=0.5, color="#8855cc",
|
||
alpha=0.55, label="MF output", zorder=3)
|
||
ax_mf.plot(t_proc, result["envelope"], lw=1.3, color="#cc4488",
|
||
label="envelope (Hilbert)", zorder=4)
|
||
if pipeline.template is not None:
|
||
ax_mf.axvline(result["peak_time_ns"], color="#ffaa00",
|
||
linestyle="--", lw=1.2, zorder=5,
|
||
label=f"peak {result['peak_time_ns']:.1f} ns")
|
||
self._shade_time_zones(ax_mf, t_proc, emi_end_ns, s0, s1)
|
||
ax_mf.set_xlabel("Time (ns)")
|
||
ax_mf.set_ylabel("Amplitude")
|
||
ax_mf.set_title(
|
||
f"5 — Matched filter | "
|
||
f"A = {result['peak_amplitude']:.3f} | "
|
||
f"SNR = {result['snr']:.1f} | "
|
||
f"t = {result['peak_time_ns']:.1f} ns")
|
||
ax_mf.legend(fontsize=7)
|
||
|
||
# --- 6. Shot-to-shot overlay ---
|
||
for env in overlays:
|
||
t_ov = np.arange(len(env)) / sr * 1e9
|
||
ax_over.plot(t_ov, env, lw=0.5, alpha=0.45, color="#cc4488", zorder=3)
|
||
self._shade_time_zones(ax_over, t_proc, emi_end_ns, s0, s1)
|
||
ax_over.set_xlabel("Time (ns)")
|
||
ax_over.set_ylabel("MF envelope amplitude")
|
||
ax_over.set_title(
|
||
f"6 — Shot-to-shot overlay (row {row_idx}, {n_overlay} frames) | "
|
||
f"peak-amp variance = {peak_var:.4g}")
|
||
|
||
# Bottom metrics bar
|
||
fig.text(
|
||
0.5, 0.005,
|
||
f"Peak amplitude: {result['peak_amplitude']:.4g} | "
|
||
f"Arrival time: {result['peak_time_ns']:.2f} ns | "
|
||
f"SNR: {result['snr']:.1f} | "
|
||
f"Shot-to-shot peak-amp variance ({n_overlay} shots): {peak_var:.4g}",
|
||
ha="center", va="bottom", fontsize=9,
|
||
bbox=dict(boxstyle="round,pad=0.3", facecolor="#2a2a2a", alpha=0.85),
|
||
color="#e8e8e8",
|
||
)
|
||
self._canvas.draw()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 = 12.5 # µm
|
||
self._pending_bg_sub: bool = True
|
||
self._pending_gate_enabled: bool = False
|
||
self._pending_gate_start: float = 0.0
|
||
self._pending_gate_end: float = 200.0
|
||
self._progress_dlg: QProgressDialog | None = None
|
||
|
||
# SAW pipeline state
|
||
self._saw_pipeline: SawPipeline | None = None
|
||
self._template_thread: QThread | None = None
|
||
self._diag_window: SawDiagnosticWindow | None = None
|
||
self._last_row: int | None = None
|
||
self._last_frame: int | 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.valueChanged.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.valueChanged.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)
|
||
|
||
# Time gate (for FFT; CH1/velocity only)
|
||
self.grp_gate = QGroupBox("Time Gate (CH1 only)")
|
||
gl = QVBoxLayout(self.grp_gate)
|
||
self.chk_gate = QCheckBox("Enable time gate")
|
||
self.chk_gate.setChecked(False)
|
||
self.chk_gate.setEnabled(False)
|
||
self.chk_gate.setToolTip(
|
||
"Zero-out samples outside the specified time window before\n"
|
||
"computing the FFT (useful for isolating a specific acoustic packet)."
|
||
)
|
||
self.chk_gate.toggled.connect(self._on_gate_toggled)
|
||
gl.addWidget(self.chk_gate)
|
||
|
||
gate_start_row = QHBoxLayout()
|
||
gate_start_row.addWidget(QLabel("Start:"))
|
||
self.spin_gate_start = QDoubleSpinBox()
|
||
self.spin_gate_start.setRange(0.0, 100000.0)
|
||
self.spin_gate_start.setDecimals(1)
|
||
self.spin_gate_start.setSingleStep(10.0)
|
||
self.spin_gate_start.setSuffix(" ns")
|
||
self.spin_gate_start.setValue(50.0)
|
||
self.spin_gate_start.setEnabled(False)
|
||
self.spin_gate_start.valueChanged.connect(self._on_gate_changed)
|
||
gate_start_row.addWidget(self.spin_gate_start)
|
||
gl.addLayout(gate_start_row)
|
||
|
||
gate_end_row = QHBoxLayout()
|
||
gate_end_row.addWidget(QLabel("End:"))
|
||
self.spin_gate_end = QDoubleSpinBox()
|
||
self.spin_gate_end.setRange(0.0, 100000.0)
|
||
self.spin_gate_end.setDecimals(1)
|
||
self.spin_gate_end.setSingleStep(10.0)
|
||
self.spin_gate_end.setSuffix(" ns")
|
||
self.spin_gate_end.setValue(200.0)
|
||
self.spin_gate_end.setEnabled(False)
|
||
self.spin_gate_end.valueChanged.connect(self._on_gate_changed)
|
||
gate_end_row.addWidget(self.spin_gate_end)
|
||
gl.addLayout(gate_end_row)
|
||
|
||
vl.addWidget(self.grp_gate)
|
||
|
||
# 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(12.5)
|
||
self.spin_grating_um.setEnabled(False)
|
||
self.spin_grating_um.valueChanged.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)
|
||
vl.addWidget(self.grp_velocity)
|
||
|
||
# Export
|
||
sep2 = QFrame()
|
||
sep2.setFrameShape(QFrame.Shape.HLine)
|
||
sep2.setStyleSheet("color: #555;")
|
||
vl.addWidget(sep2)
|
||
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)
|
||
|
||
# ---- SAW Pipeline panel ----------------------------------------
|
||
grp_saw = QGroupBox("SAW Pipeline (CH1 only)")
|
||
sl = QVBoxLayout(grp_saw)
|
||
|
||
# EMI gate end
|
||
emi_row = QHBoxLayout()
|
||
emi_row.addWidget(QLabel("EMI gate end:"))
|
||
self.spin_saw_emi_ns = QDoubleSpinBox()
|
||
self.spin_saw_emi_ns.setRange(1.0, 10000.0)
|
||
self.spin_saw_emi_ns.setDecimals(1)
|
||
self.spin_saw_emi_ns.setSingleStep(5.0)
|
||
self.spin_saw_emi_ns.setSuffix(" ns")
|
||
self.spin_saw_emi_ns.setValue(50.0)
|
||
emi_row.addWidget(self.spin_saw_emi_ns)
|
||
sl.addLayout(emi_row)
|
||
|
||
# SAW window
|
||
sl.addWidget(QLabel("SAW window (ns):"))
|
||
saw_win_row = QHBoxLayout()
|
||
self.spin_saw_win_start = QDoubleSpinBox()
|
||
self.spin_saw_win_start.setRange(0.0, 100000.0)
|
||
self.spin_saw_win_start.setDecimals(1)
|
||
self.spin_saw_win_start.setSuffix(" ns")
|
||
self.spin_saw_win_start.setValue(80.0)
|
||
saw_win_row.addWidget(self.spin_saw_win_start)
|
||
saw_win_row.addWidget(QLabel("–"))
|
||
self.spin_saw_win_end = QDoubleSpinBox()
|
||
self.spin_saw_win_end.setRange(0.0, 100000.0)
|
||
self.spin_saw_win_end.setDecimals(1)
|
||
self.spin_saw_win_end.setSuffix(" ns")
|
||
self.spin_saw_win_end.setValue(350.0)
|
||
saw_win_row.addWidget(self.spin_saw_win_end)
|
||
sl.addLayout(saw_win_row)
|
||
|
||
# Bandpass limits
|
||
sl.addWidget(QLabel("Bandpass (MHz):"))
|
||
bp_row = QHBoxLayout()
|
||
self.spin_saw_bp_lo = QDoubleSpinBox()
|
||
self.spin_saw_bp_lo.setRange(1.0, 3000.0)
|
||
self.spin_saw_bp_lo.setDecimals(1)
|
||
self.spin_saw_bp_lo.setSuffix(" MHz")
|
||
self.spin_saw_bp_lo.setValue(85.0)
|
||
bp_row.addWidget(self.spin_saw_bp_lo)
|
||
bp_row.addWidget(QLabel("–"))
|
||
self.spin_saw_bp_hi = QDoubleSpinBox()
|
||
self.spin_saw_bp_hi.setRange(1.0, 3000.0)
|
||
self.spin_saw_bp_hi.setDecimals(1)
|
||
self.spin_saw_bp_hi.setSuffix(" MHz")
|
||
self.spin_saw_bp_hi.setValue(200.0)
|
||
bp_row.addWidget(self.spin_saw_bp_hi)
|
||
sl.addLayout(bp_row)
|
||
|
||
# Template shots
|
||
tmpl_row = QHBoxLayout()
|
||
tmpl_row.addWidget(QLabel("Template shots:"))
|
||
self.spin_saw_n_shots = QSpinBox()
|
||
self.spin_saw_n_shots.setRange(1, 10000)
|
||
self.spin_saw_n_shots.setValue(50)
|
||
tmpl_row.addWidget(self.spin_saw_n_shots)
|
||
sl.addLayout(tmpl_row)
|
||
|
||
self.btn_build_template = QPushButton("Build Template")
|
||
self.btn_build_template.setEnabled(False)
|
||
self.btn_build_template.setToolTip(
|
||
"Average N shots (EMI-gated + bandpass-filtered) to form a\n"
|
||
"Hann-windowed template for the matched filter.")
|
||
self.btn_build_template.clicked.connect(self._on_build_template_clicked)
|
||
sl.addWidget(self.btn_build_template)
|
||
|
||
self.lbl_saw_status = QLabel("No template")
|
||
self.lbl_saw_status.setStyleSheet("font-size: 11px; color: #888;")
|
||
self.lbl_saw_status.setWordWrap(True)
|
||
sl.addWidget(self.lbl_saw_status)
|
||
|
||
# ---- Apply matched filter controls ----
|
||
sep_mf = QFrame()
|
||
sep_mf.setFrameShape(QFrame.Shape.HLine)
|
||
sep_mf.setStyleSheet("color: #555;")
|
||
sl.addWidget(sep_mf)
|
||
|
||
sl.addWidget(QLabel("Apply matched filter:"))
|
||
|
||
mf_mode_row = QHBoxLayout()
|
||
mf_mode_row.addWidget(QLabel("Output:"))
|
||
self.combo_mf_mode = QComboBox()
|
||
self.combo_mf_mode.addItems(["Amplitude", "Time-of-Flight"])
|
||
self.combo_mf_mode.setEnabled(False)
|
||
mf_mode_row.addWidget(self.combo_mf_mode)
|
||
sl.addLayout(mf_mode_row)
|
||
|
||
self.btn_apply_mf = QPushButton("Apply Filter → Image")
|
||
self.btn_apply_mf.setEnabled(False)
|
||
self.btn_apply_mf.setToolTip(
|
||
"Switch to the SAW matched-filter channel and compute the image.\n"
|
||
"Requires a template to be built first.")
|
||
self.btn_apply_mf.clicked.connect(self._on_apply_mf_clicked)
|
||
sl.addWidget(self.btn_apply_mf)
|
||
|
||
panel_layout.addStretch()
|
||
|
||
# Colormap
|
||
sep2 = QFrame()
|
||
sep2.setFrameShape(QFrame.Shape.HLine)
|
||
sep2.setStyleSheet("color: #555;")
|
||
vl.addWidget(sep2)
|
||
|
||
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)
|
||
vl.addLayout(cmr)
|
||
|
||
# Auto-scale
|
||
self.chk_auto = QCheckBox("Auto-scale colormap")
|
||
self.chk_auto.setChecked(True)
|
||
self.chk_auto.toggled.connect(self._on_autoscale_toggled)
|
||
vl.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.valueChanged.connect(self._on_manual_range_changed)
|
||
setattr(self, attr, spin)
|
||
row.addWidget(spin)
|
||
vl.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)
|
||
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()
|
||
self.btn_saw_diag = QPushButton("Open SAW Diagnostics…")
|
||
self.btn_saw_diag.setEnabled(False)
|
||
self.btn_saw_diag.setToolTip(
|
||
"Show the 6-panel SAW pipeline diagnostic for the clicked pixel.\n"
|
||
"Requires a SAW template to be built first.")
|
||
self.btn_saw_diag.clicked.connect(self._on_open_diagnostics)
|
||
wave_vl.addWidget(self.lbl_wave_hint)
|
||
wave_vl.addWidget(self.btn_saw_diag)
|
||
wave_vl.addWidget(self.wave_canvas)
|
||
splitter.addWidget(wave_widget)
|
||
|
||
splitter.setSizes([580, 250])
|
||
|
||
# ---- Right control panel (SAW pipeline) ----------------------------
|
||
right_panel = QWidget()
|
||
right_panel.setFixedWidth(260)
|
||
right_panel_layout = QVBoxLayout(right_panel)
|
||
right_panel_layout.setContentsMargins(0, 0, 0, 0)
|
||
right_panel_layout.setSpacing(6)
|
||
right_panel_layout.addWidget(grp_saw)
|
||
right_panel_layout.addStretch()
|
||
root.addWidget(right_panel)
|
||
|
||
self.statusBar().showMessage("Open an .sras file to begin.")
|
||
|
||
# ------------------------------------------------------------------
|
||
# 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
|
||
self.btn_open.setEnabled(False)
|
||
self.statusBar().showMessage(f"Loading {Path(path).name}…")
|
||
self._show_progress(f"Loading {Path(path).name}…")
|
||
|
||
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(lambda: setattr(self, "_load_thread", None))
|
||
self._load_thread.start()
|
||
|
||
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
|
||
|
||
s = sras
|
||
self.lbl_filename.setText(s.path.name)
|
||
self._info["Angles"].setText(f"Angles: {s.n_angles}")
|
||
self._info["Rows"].setText(f"Rows: {s.n_rows}")
|
||
self._info["Frames / row"].setText(f"Frames / row: {s.n_frames}")
|
||
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:.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} (scanner bug — corrected)"
|
||
)
|
||
if s.background is not None:
|
||
notes.append(f"Background waveform: {len(s.background)} samples")
|
||
self.lbl_frame_warn.setText("\n".join(notes))
|
||
|
||
self.spin_angle.blockSignals(True)
|
||
self.spin_angle.setRange(0, max(0, s.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.spin_threshold_mv.value())
|
||
self._on_view_changed()
|
||
|
||
# ------------------------------------------------------------------
|
||
# 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
|
||
is_fft = enabled and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX)
|
||
is_saw = enabled and ch_idx in SAW_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)
|
||
# Time gate only for legacy FFT modes (SAW pipeline has its own gating)
|
||
self.chk_gate.setEnabled(is_fft)
|
||
gate_active = is_fft and self.chk_gate.isChecked()
|
||
self.spin_gate_start.setEnabled(gate_active)
|
||
self.spin_gate_end.setEnabled(gate_active)
|
||
# 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)
|
||
# SAW pipeline build button
|
||
has_file = enabled and s is not None
|
||
self.btn_build_template.setEnabled(has_file)
|
||
# Diagnostics button: need template + a clicked pixel
|
||
has_template = self._saw_pipeline is not None and self._saw_pipeline.template is not None
|
||
has_pixel = self._last_row is not None
|
||
self.btn_saw_diag.setEnabled(has_file and has_template and has_pixel)
|
||
# Apply filter button: need file + template
|
||
self.btn_apply_mf.setEnabled(has_file and has_template)
|
||
self.combo_mf_mode.setEnabled(has_file and has_template)
|
||
# CSV export: enabled when a CH1-derived image is displayed
|
||
self.btn_export_csv.setEnabled(is_ch1 and self._current_image is not None)
|
||
|
||
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
|
||
is_fft = ch_idx in (CH1_IDX, VELOCITY_MODE_IDX)
|
||
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)
|
||
self.chk_gate.setEnabled(is_fft and has_file)
|
||
gate_active = is_fft and has_file and self.chk_gate.isChecked()
|
||
self.spin_gate_start.setEnabled(gate_active)
|
||
self.spin_gate_end.setEnabled(gate_active)
|
||
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_gate_toggled(self, checked: bool):
|
||
self.spin_gate_start.setEnabled(checked)
|
||
self.spin_gate_end.setEnabled(checked)
|
||
if self._sras is not None:
|
||
ch_idx = self.combo_channel.currentIndex()
|
||
if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
|
||
self._start_compute()
|
||
|
||
def _on_gate_changed(self):
|
||
if self._sras is not None and self.chk_gate.isChecked():
|
||
ch_idx = self.combo_channel.currentIndex()
|
||
if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
|
||
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}")
|
||
|
||
def _on_threshold_changed(self, mv: float):
|
||
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._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()
|
||
gate_enabled = self.chk_gate.isChecked()
|
||
gate_start = self.spin_gate_start.value() if gate_enabled else None
|
||
gate_end = self.spin_gate_end.value() if gate_enabled 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_gate_enabled = gate_enabled
|
||
self._pending_gate_start = self.spin_gate_start.value()
|
||
self._pending_gate_end = self.spin_gate_end.value()
|
||
|
||
self.statusBar().showMessage("Computing image…")
|
||
self._show_progress("Computing image…")
|
||
|
||
self._compute_worker = ComputeWorker(
|
||
self._sras, angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub,
|
||
gate_start_ns=gate_start, gate_end_ns=gate_end,
|
||
saw_pipeline=self._saw_pipeline,
|
||
)
|
||
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._compute_thread.start()
|
||
|
||
def _on_compute_thread_finished(self):
|
||
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()
|
||
gate_enabled = self.chk_gate.isChecked()
|
||
if (angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub,
|
||
gate_enabled,
|
||
self.spin_gate_start.value(), self.spin_gate_end.value()) != (
|
||
self._pending_angle, self._pending_ch,
|
||
self._pending_threshold, self._pending_grating_um,
|
||
self._pending_bg_sub,
|
||
self._pending_gate_enabled,
|
||
self._pending_gate_start, self._pending_gate_end):
|
||
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)
|
||
|
||
def _redraw_image(self, img: np.ndarray):
|
||
s = self._sras
|
||
x_axis = s.x_axis_mm()
|
||
y_axis = s.y_positions_mm
|
||
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]"
|
||
elif ch_idx == SAW_MODE_AMP_IDX:
|
||
mode_str = "SAW-AMP"
|
||
unit = "MF envelope peak (arb.)"
|
||
colorbar_label = "amplitude"
|
||
elif ch_idx == SAW_MODE_TOF_IDX:
|
||
mode_str = "SAW-TOF"
|
||
unit = "SAW arrival time (ns)"
|
||
colorbar_label = "ns"
|
||
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._last_row = row_idx
|
||
self._last_frame = frame_idx
|
||
self.lbl_wave_hint.hide()
|
||
ch_idx = self._current_ch
|
||
if ch_idx in CH1_DERIVED_MODES:
|
||
gate_enabled = self.chk_gate.isChecked() and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX)
|
||
self.wave_canvas.show_rf_waveform(
|
||
self._sras, self._current_angle, row_idx, frame_idx,
|
||
apply_bg_sub=self.chk_bg_sub.isChecked(),
|
||
gate_start_ns=self.spin_gate_start.value() if gate_enabled else None,
|
||
gate_end_ns=self.spin_gate_end.value() if gate_enabled else None,
|
||
)
|
||
else:
|
||
self.wave_canvas.show_dc_waveform(
|
||
self._sras, self._current_angle, ch_idx, row_idx, frame_idx
|
||
)
|
||
# Update diagnostics button availability
|
||
has_template = (self._saw_pipeline is not None and
|
||
self._saw_pipeline.template is not None)
|
||
self.btn_saw_diag.setEnabled(
|
||
self._sras is not None and has_template and True)
|
||
|
||
# ------------------------------------------------------------------
|
||
# SAW pipeline management
|
||
# ------------------------------------------------------------------
|
||
|
||
def _on_build_template_clicked(self):
|
||
if self._sras is None or self._template_thread is not None:
|
||
return
|
||
|
||
# (Re-)create pipeline with current settings
|
||
sr = self._sras.sample_rate_hz
|
||
self._saw_pipeline = SawPipeline(
|
||
sample_rate_hz = sr,
|
||
emi_gate_ns = self.spin_saw_emi_ns.value(),
|
||
bp_lo_mhz = self.spin_saw_bp_lo.value(),
|
||
bp_hi_mhz = self.spin_saw_bp_hi.value(),
|
||
saw_window_ns = (self.spin_saw_win_start.value(),
|
||
self.spin_saw_win_end.value()),
|
||
decimate_enable = False,
|
||
)
|
||
|
||
angle_idx = self.spin_angle.value()
|
||
|
||
if self._last_row is not None and self._last_frame is not None:
|
||
# Build from the single selected pixel's waveform
|
||
waveforms = self._sras.data[
|
||
angle_idx, self._last_row, CH1_IDX,
|
||
self._last_frame:self._last_frame + 1, :
|
||
].astype(np.float32)
|
||
n_shots = 1
|
||
src_desc = f"selected pixel (row={self._last_row}, frame={self._last_frame})"
|
||
else:
|
||
# No pixel selected — sample N shots spread across the whole scan
|
||
n_shots = min(self.spin_saw_n_shots.value(),
|
||
self._sras.n_frames * self._sras.n_rows)
|
||
waveforms_all = self._sras.data[angle_idx, :, CH1_IDX, :, :].astype(np.float32)
|
||
waveforms_flat = waveforms_all.reshape(-1, waveforms_all.shape[-1])
|
||
indices = np.linspace(0, len(waveforms_flat) - 1, n_shots, dtype=int)
|
||
waveforms = waveforms_flat[indices]
|
||
src_desc = f"{n_shots} shots (full scan)"
|
||
|
||
if (self._sras.background is not None and self.chk_bg_sub.isChecked()):
|
||
waveforms = waveforms - self._sras.background[np.newaxis, :]
|
||
|
||
self.btn_build_template.setEnabled(False)
|
||
self.lbl_saw_status.setText(f"Building template from {src_desc}…")
|
||
self._show_progress("Building SAW template…")
|
||
|
||
self._template_worker = TemplateBuildWorker(self._saw_pipeline, waveforms)
|
||
self._template_thread = QThread()
|
||
self._template_worker.moveToThread(self._template_thread)
|
||
self._template_thread.started.connect(self._template_worker.run)
|
||
self._template_worker.finished.connect(self._on_template_built)
|
||
self._template_worker.error.connect(self._on_template_error)
|
||
self._template_worker.finished.connect(self._template_thread.quit)
|
||
self._template_worker.error.connect(self._template_thread.quit)
|
||
self._template_thread.finished.connect(
|
||
lambda: setattr(self, "_template_thread", None))
|
||
self._template_thread.start()
|
||
|
||
def _on_template_built(self):
|
||
self._close_progress()
|
||
self.btn_build_template.setEnabled(True)
|
||
if self._last_row is not None and self._last_frame is not None:
|
||
src = f"pixel row={self._last_row} frame={self._last_frame}"
|
||
else:
|
||
src = f"{self.spin_saw_n_shots.value()} shots"
|
||
self.lbl_saw_status.setText(
|
||
f"Template ready ({src})\n"
|
||
f"EMI gate: {self.spin_saw_emi_ns.value():.0f} ns "
|
||
f"BP: {self.spin_saw_bp_lo.value():.0f}–{self.spin_saw_bp_hi.value():.0f} MHz")
|
||
self.lbl_saw_status.setStyleSheet("font-size: 11px; color: #44cc66;")
|
||
has_pixel = self._last_row is not None
|
||
self.btn_saw_diag.setEnabled(self._sras is not None and has_pixel)
|
||
self.btn_apply_mf.setEnabled(True)
|
||
self.combo_mf_mode.setEnabled(True)
|
||
|
||
def _on_template_error(self, msg: str):
|
||
self._close_progress()
|
||
self.btn_build_template.setEnabled(True)
|
||
self.lbl_saw_status.setText(f"Error: {msg}")
|
||
self.lbl_saw_status.setStyleSheet("font-size: 11px; color: #e05030;")
|
||
|
||
def _on_open_diagnostics(self):
|
||
if (self._sras is None or self._saw_pipeline is None or
|
||
self._last_row is None):
|
||
return
|
||
if self._diag_window is not None:
|
||
try:
|
||
self._diag_window.close()
|
||
except RuntimeError:
|
||
pass # C++ object already deleted (user closed the window)
|
||
self._diag_window = None
|
||
self._diag_window = SawDiagnosticWindow(
|
||
self._sras, self._saw_pipeline,
|
||
self._current_angle, self._last_row, self._last_frame,
|
||
parent=None, # free-floating window
|
||
)
|
||
# Clear our reference when the user closes the window so we never
|
||
# call into a deleted C++ object again.
|
||
self._diag_window.destroyed.connect(
|
||
lambda: setattr(self, "_diag_window", None))
|
||
self._diag_window.show()
|
||
|
||
def _on_apply_mf_clicked(self):
|
||
if self._sras is None or self._saw_pipeline is None:
|
||
return
|
||
if self._saw_pipeline.template is None:
|
||
self.statusBar().showMessage(
|
||
"No template built yet — use 'Build Template' first.")
|
||
return
|
||
target_ch = (SAW_MODE_AMP_IDX
|
||
if self.combo_mf_mode.currentIndex() == 0
|
||
else SAW_MODE_TOF_IDX)
|
||
self.combo_channel.blockSignals(True)
|
||
self.combo_channel.setCurrentIndex(target_ch)
|
||
self.combo_channel.blockSignals(False)
|
||
self._on_channel_changed()
|
||
|
||
# ------------------------------------------------------------------
|
||
# 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
|
||
|
||
# ------------------------------------------------------------------
|
||
|
||
def closeEvent(self, event):
|
||
for attr in ("_load_thread", "_compute_thread", "_template_thread"):
|
||
t = getattr(self, attr, None)
|
||
if t is not None:
|
||
t.quit()
|
||
t.wait(2000)
|
||
if self._diag_window is not None:
|
||
try:
|
||
self._diag_window.close()
|
||
except RuntimeError:
|
||
pass
|
||
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()
|