Refactor/parallel and dedup #1

Merged
tka merged 10 commits from refactor/parallel-and-dedup into main 2026-07-31 15:20:33 -05:00
Showing only changes of commit 013c7739a1 - Show all commits
+142 -67
View File
@@ -28,9 +28,9 @@ from PyQt6.QtCore import QObject, Qt, QThread, pyqtSignal
from PyQt6.QtGui import QAction from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
QDoubleSpinBox, QFileDialog, QFrame, QGroupBox, QHBoxLayout, QLabel, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame, QGroupBox, QHBoxLayout,
QMainWindow, QProgressDialog, QPushButton, QRadioButton, QSizePolicy, QLabel, QMainWindow, QProgressDialog, QPushButton, QRadioButton, QScrollArea,
QSpinBox, QSplitter, QVBoxLayout, QWidget, QSizePolicy, QSpinBox, QSplitter, QVBoxLayout, QWidget,
) )
import sras_compute as compute import sras_compute as compute
@@ -78,6 +78,79 @@ _CSS_MUTED = "color: #888; font-size: 11px;"
_CSS_WARN = "color: #e07000; font-size: 11px;" _CSS_WARN = "color: #e07000; font-size: 11px;"
_CSS_BUSY = "color: #4a90d9; font-size: 11px;" _CSS_BUSY = "color: #4a90d9; font-size: 11px;"
# Side-panel column widths (the scroll areas that hold the controls).
_LEFT_PANEL_W = 288
_RIGHT_PANEL_W = 272
# Minimum width for a spin box so its value + suffix are never clipped.
_SPIN_MIN_W = 96
# ---------------------------------------------------------------------------
# Small layout helpers
# ---------------------------------------------------------------------------
def _wrap_label(text: str = "", css: str | None = None) -> QLabel:
"""A word-wrapped QLabel that reports its *wrapped* height to the layout.
A plain word-wrapped QLabel advertises a single-line minimum height, so in a
fixed-width column the layout happily shrinks it and the extra lines get
clipped. Enabling height-for-width makes the box layout ask for the real
height at the column's width instead.
"""
lbl = QLabel(text)
lbl.setWordWrap(True)
sp = lbl.sizePolicy()
sp.setVerticalPolicy(QSizePolicy.Policy.Minimum)
sp.setHeightForWidth(True)
lbl.setSizePolicy(sp)
if css:
lbl.setStyleSheet(css)
return lbl
def _group(title: str) -> tuple[QGroupBox, QVBoxLayout]:
"""A group box with consistent, non-cramped internal margins."""
grp = QGroupBox(title)
lay = QVBoxLayout(grp)
lay.setContentsMargins(10, 8, 10, 10)
lay.setSpacing(6)
return grp, lay
def _form() -> QFormLayout:
"""A label/field form layout for a narrow side panel."""
form = QFormLayout()
form.setContentsMargins(0, 0, 0, 0)
form.setHorizontalSpacing(8)
form.setVerticalSpacing(6)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight
| Qt.AlignmentFlag.AlignVCenter)
form.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
form.setFieldGrowthPolicy(
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.DontWrapRows)
return form
def _scroll_panel(inner: QWidget, width: int) -> QScrollArea:
"""Put a side panel in a fixed-width scroll area.
Without this the panels are sized by the window: a short window squeezes the
controls past their minimum heights, which is what makes text overlap the
widget below it. Scrolling keeps every control at its natural size.
"""
area = QScrollArea()
area.setWidget(inner)
area.setWidgetResizable(True)
area.setFrameShape(QFrame.Shape.NoFrame)
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
area.setFixedWidth(width)
area.viewport().setAutoFillBackground(False)
inner.setAutoFillBackground(False)
return area
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ROI (free quadrilateral in data coordinates) # ROI (free quadrilateral in data coordinates)
@@ -674,6 +747,7 @@ class SrasViewerWindow(QMainWindow):
super().__init__() super().__init__()
self.setWindowTitle("SRAS Scan Viewer") self.setWindowTitle("SRAS Scan Viewer")
self.resize(1560, 840) self.resize(1560, 840)
self.setMinimumSize(960, 560)
self.setAcceptDrops(True) self.setAcceptDrops(True)
self._sras: SrasFile | None = None self._sras: SrasFile | None = None
@@ -791,69 +865,69 @@ class SrasViewerWindow(QMainWindow):
def _build_left_panel(self) -> QWidget: def _build_left_panel(self) -> QWidget:
panel = QWidget() panel = QWidget()
panel.setFixedWidth(260)
panel_layout = QVBoxLayout(panel) panel_layout = QVBoxLayout(panel)
panel_layout.setContentsMargins(0, 0, 0, 0) panel_layout.setContentsMargins(0, 0, 0, 0)
panel_layout.setSpacing(6) panel_layout.setSpacing(8)
# ---- File ------------------------------------------------------- # ---- File -------------------------------------------------------
grp_file = QGroupBox("File") grp_file, fl = _group("File")
fl = QVBoxLayout(grp_file)
self.btn_open = QPushButton("Open .sras…") self.btn_open = QPushButton("Open .sras…")
self.btn_open.clicked.connect(self._on_open) self.btn_open.clicked.connect(self._on_open)
self.lbl_filename = QLabel("No file loaded") self.lbl_filename = _wrap_label("No file loaded", _CSS_MUTED)
self.lbl_filename.setWordWrap(True)
self.lbl_filename.setStyleSheet(_CSS_MUTED)
fl.addWidget(self.btn_open) fl.addWidget(self.btn_open)
fl.addWidget(self.lbl_filename) fl.addWidget(self.lbl_filename)
panel_layout.addWidget(grp_file) panel_layout.addWidget(grp_file)
# ---- Scan info -------------------------------------------------- # ---- Scan info --------------------------------------------------
grp_info = QGroupBox("Scan Info") grp_info, il = _group("Scan Info")
il = QVBoxLayout(grp_info) il.setSpacing(3)
self._info = {} self._info = {}
for key in ("Angles", "Rows", "Frames / row", "Samples / frame", for key in ("Angles", "Rows", "Frames / row", "Samples / frame",
"Sample rate", "X start", "Pixel Δx", "Laser freq"): "Sample rate", "X start", "Pixel Δx", "Laser freq"):
lbl = QLabel(f"{key}: —") lbl = _wrap_label(f"{key}: —", _CSS_INFO)
lbl.setWordWrap(True)
lbl.setStyleSheet(_CSS_INFO)
il.addWidget(lbl) il.addWidget(lbl)
self._info[key] = lbl self._info[key] = lbl
self.lbl_frame_warn = QLabel("") # frame-count / format notes # frame-count / format notes
self.lbl_frame_warn.setWordWrap(True) self.lbl_frame_warn = _wrap_label("", _CSS_WARN)
self.lbl_frame_warn.setStyleSheet(_CSS_WARN)
il.addWidget(self.lbl_frame_warn) il.addWidget(self.lbl_frame_warn)
self.lbl_dc_precompute = QLabel("") # background DC-precompute progress # background DC-precompute progress
self.lbl_dc_precompute.setWordWrap(True) self.lbl_dc_precompute = _wrap_label("", _CSS_BUSY)
self.lbl_dc_precompute.setStyleSheet(_CSS_BUSY)
il.addWidget(self.lbl_dc_precompute) il.addWidget(self.lbl_dc_precompute)
panel_layout.addWidget(grp_info) panel_layout.addWidget(grp_info)
# ---- View settings ---------------------------------------------- # ---- View settings ----------------------------------------------
grp_view = QGroupBox("View Settings") grp_view, vl = _group("View Settings")
vl = QVBoxLayout(grp_view)
view_form = _form()
ar = QHBoxLayout()
ar.addWidget(QLabel("Angle:"))
self.spin_angle = QSpinBox() self.spin_angle = QSpinBox()
self.spin_angle.setRange(0, 0) self.spin_angle.setRange(0, 0)
self.spin_angle.setEnabled(False) self.spin_angle.setEnabled(False)
self.spin_angle.setMinimumWidth(64)
self.spin_angle.editingFinished.connect(self._on_view_changed) self.spin_angle.editingFinished.connect(self._on_view_changed)
self.lbl_angle_deg = QLabel("") self.lbl_angle_deg = QLabel("")
angle_field = QWidget()
ar = QHBoxLayout(angle_field)
ar.setContentsMargins(0, 0, 0, 0)
ar.setSpacing(6)
ar.addWidget(self.spin_angle) ar.addWidget(self.spin_angle)
ar.addWidget(self.lbl_angle_deg) ar.addWidget(self.lbl_angle_deg)
vl.addLayout(ar) ar.addStretch()
view_form.addRow("Angle:", angle_field)
cr = QHBoxLayout()
cr.addWidget(QLabel("Channel:"))
self.combo_channel = QComboBox() self.combo_channel = QComboBox()
self.combo_channel.addItems(CH_LABELS) self.combo_channel.addItems(CH_LABELS)
self.combo_channel.setEnabled(False) self.combo_channel.setEnabled(False)
self.combo_channel.setSizePolicy(QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed)
self.combo_channel.setSizeAdjustPolicy(
QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
self.combo_channel.setMinimumContentsLength(12)
self.combo_channel.currentIndexChanged.connect(self._on_channel_changed) self.combo_channel.currentIndexChanged.connect(self._on_channel_changed)
cr.addWidget(self.combo_channel) view_form.addRow("Channel:", self.combo_channel)
vl.addLayout(cr) vl.addLayout(view_form)
sep = QFrame() sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine) sep.setFrameShape(QFrame.Shape.HLine)
@@ -861,10 +935,8 @@ class SrasViewerWindow(QMainWindow):
vl.addWidget(sep) vl.addWidget(sep)
# DC threshold (for RF / CH1 masking) # DC threshold (for RF / CH1 masking)
self.grp_threshold = QGroupBox("RF Mask Threshold (CH1 only)") self.grp_threshold, tl = _group("RF Mask Threshold (CH1 only)")
tl = QVBoxLayout(self.grp_threshold) thr_form = _form()
thr_row = QHBoxLayout()
thr_row.addWidget(QLabel("DC threshold:"))
self.spin_threshold_mv = QDoubleSpinBox() self.spin_threshold_mv = QDoubleSpinBox()
self.spin_threshold_mv.setRange(-500.0, 500.0) self.spin_threshold_mv.setRange(-500.0, 500.0)
self.spin_threshold_mv.setDecimals(3) self.spin_threshold_mv.setDecimals(3)
@@ -872,11 +944,12 @@ class SrasViewerWindow(QMainWindow):
self.spin_threshold_mv.setSuffix(" mV") self.spin_threshold_mv.setSuffix(" mV")
self.spin_threshold_mv.setValue(50.0) self.spin_threshold_mv.setValue(50.0)
self.spin_threshold_mv.setEnabled(False) self.spin_threshold_mv.setEnabled(False)
self.spin_threshold_mv.setMinimumWidth(_SPIN_MIN_W)
self.spin_threshold_mv.editingFinished.connect(self._on_threshold_changed) self.spin_threshold_mv.editingFinished.connect(self._on_threshold_changed)
thr_row.addWidget(self.spin_threshold_mv) thr_form.addRow("DC threshold:", self.spin_threshold_mv)
tl.addLayout(thr_row) tl.addLayout(thr_form)
self.lbl_threshold_adc = QLabel(f"{mv_to_adc(50.0):.1f} ADC counts") self.lbl_threshold_adc = _wrap_label(
self.lbl_threshold_adc.setStyleSheet(_CSS_MUTED) f"{mv_to_adc(50.0):.1f} ADC counts", _CSS_MUTED)
tl.addWidget(self.lbl_threshold_adc) tl.addWidget(self.lbl_threshold_adc)
vl.addWidget(self.grp_threshold) vl.addWidget(self.grp_threshold)
@@ -913,8 +986,7 @@ class SrasViewerWindow(QMainWindow):
panel_layout.addWidget(grp_view) panel_layout.addWidget(grp_view)
# ---- ROI --------------------------------------------------------- # ---- ROI ---------------------------------------------------------
grp_roi = QGroupBox("ROI (Region of Interest)") grp_roi, rl = _group("ROI (Region of Interest)")
rl = QVBoxLayout(grp_roi)
self.btn_draw_roi = QPushButton("Draw ROI") self.btn_draw_roi = QPushButton("Draw ROI")
self.btn_draw_roi.setCheckable(True) self.btn_draw_roi.setCheckable(True)
@@ -943,24 +1015,26 @@ class SrasViewerWindow(QMainWindow):
self.btn_export_roi.clicked.connect(self._on_export_roi_csv) self.btn_export_roi.clicked.connect(self._on_export_roi_csv)
rl.addWidget(self.btn_export_roi) rl.addWidget(self.btn_export_roi)
self.lbl_roi_center = QLabel("centroid: —") self.lbl_roi_center = _wrap_label("centroid: —", _CSS_HINT)
self.lbl_roi_size = QLabel("bbox: —") self.lbl_roi_size = _wrap_label("bbox: —", _CSS_HINT)
self.lbl_roi_npix = QLabel("pixels inside: —") self.lbl_roi_npix = _wrap_label("pixels inside: —", _CSS_HINT)
for lbl in (self.lbl_roi_center, self.lbl_roi_size, self.lbl_roi_npix): for lbl in (self.lbl_roi_center, self.lbl_roi_size, self.lbl_roi_npix):
lbl.setStyleSheet(_CSS_HINT)
rl.addWidget(lbl) rl.addWidget(lbl)
panel_layout.addWidget(grp_roi) panel_layout.addWidget(grp_roi)
panel_layout.addStretch() panel_layout.addStretch()
return panel return _scroll_panel(panel, _LEFT_PANEL_W)
def _build_canvases(self) -> QWidget: def _build_canvases(self) -> QWidget:
splitter = QSplitter(Qt.Orientation.Vertical) splitter = QSplitter(Qt.Orientation.Vertical)
splitter.setChildrenCollapsible(False)
img_widget = QWidget() img_widget = QWidget()
img_vl = QVBoxLayout(img_widget) img_vl = QVBoxLayout(img_widget)
img_vl.setContentsMargins(0, 0, 0, 0) img_vl.setContentsMargins(0, 0, 0, 0)
img_vl.setSpacing(4)
self.image_canvas = ImageCanvas() self.image_canvas = ImageCanvas()
self.image_canvas.setMinimumHeight(220)
self.image_canvas.pixel_clicked.connect(self._on_pixel_clicked) self.image_canvas.pixel_clicked.connect(self._on_pixel_clicked)
self.image_canvas.roi_changed.connect(self._update_roi_ui) self.image_canvas.roi_changed.connect(self._update_roi_ui)
self.image_canvas.draw_mode_changed.connect(self._on_draw_mode_changed) self.image_canvas.draw_mode_changed.connect(self._on_draw_mode_changed)
@@ -971,24 +1045,26 @@ class SrasViewerWindow(QMainWindow):
wave_widget = QWidget() wave_widget = QWidget()
wave_vl = QVBoxLayout(wave_widget) wave_vl = QVBoxLayout(wave_widget)
wave_vl.setContentsMargins(0, 0, 0, 0) wave_vl.setContentsMargins(0, 0, 0, 0)
wave_vl.setSpacing(4)
self.lbl_wave_hint = QLabel( self.lbl_wave_hint = QLabel(
"Click a pixel in the image above to inspect its waveform.") "Click a pixel in the image above to inspect its waveform.")
self.lbl_wave_hint.setAlignment(Qt.AlignmentFlag.AlignCenter) self.lbl_wave_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_wave_hint.setStyleSheet(_CSS_MUTED) self.lbl_wave_hint.setStyleSheet(_CSS_MUTED)
self.wave_canvas = WaveformCanvas() self.wave_canvas = WaveformCanvas()
self.wave_canvas.setMinimumHeight(150)
wave_vl.addWidget(self.lbl_wave_hint) wave_vl.addWidget(self.lbl_wave_hint)
wave_vl.addWidget(self.wave_canvas) wave_vl.addWidget(self.wave_canvas)
splitter.addWidget(wave_widget) splitter.addWidget(wave_widget)
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(1, 1)
splitter.setSizes([580, 250]) splitter.setSizes([580, 250])
return splitter return splitter
def _build_right_panel(self) -> QWidget: def _build_right_panel(self) -> QWidget:
# Velocity settings (visible only in velocity mode) # Velocity settings (visible only in velocity mode)
self.grp_velocity = QGroupBox("Velocity Settings (CH1 only)") self.grp_velocity, vel_l = _group("Velocity Settings (CH1 only)")
vel_l = QVBoxLayout(self.grp_velocity) vel_form = _form()
grat_row = QHBoxLayout()
grat_row.addWidget(QLabel("Grating size:"))
self.spin_grating_um = QDoubleSpinBox() self.spin_grating_um = QDoubleSpinBox()
self.spin_grating_um.setRange(0.1, 1000.0) self.spin_grating_um.setRange(0.1, 1000.0)
self.spin_grating_um.setDecimals(2) self.spin_grating_um.setDecimals(2)
@@ -996,53 +1072,52 @@ class SrasViewerWindow(QMainWindow):
self.spin_grating_um.setSuffix(" µm") self.spin_grating_um.setSuffix(" µm")
self.spin_grating_um.setValue(25) self.spin_grating_um.setValue(25)
self.spin_grating_um.setEnabled(False) self.spin_grating_um.setEnabled(False)
self.spin_grating_um.setMinimumWidth(_SPIN_MIN_W)
self.spin_grating_um.editingFinished.connect(self._on_grating_changed) self.spin_grating_um.editingFinished.connect(self._on_grating_changed)
grat_row.addWidget(self.spin_grating_um) vel_form.addRow("Grating size:", self.spin_grating_um)
vel_l.addLayout(grat_row) vel_l.addLayout(vel_form)
lbl_formula = QLabel("v (m/s) = freq (MHz) × grating (µm)") vel_l.addWidget(_wrap_label("v (m/s) = freq (MHz) × grating (µm)",
lbl_formula.setStyleSheet("font-size: 10px; color: #888;") "font-size: 10px; color: #888;"))
vel_l.addWidget(lbl_formula)
self.grp_velocity.setVisible(False) self.grp_velocity.setVisible(False)
grp_display = QGroupBox("Display Options") grp_display, dl = _group("Display Options")
dl = QVBoxLayout(grp_display)
cmr = QHBoxLayout() cmap_form = _form()
cmr.addWidget(QLabel("Colormap:"))
self.combo_cmap = QComboBox() self.combo_cmap = QComboBox()
self.combo_cmap.addItems(CMAPS) self.combo_cmap.addItems(CMAPS)
self.combo_cmap.setCurrentText("gray") self.combo_cmap.setCurrentText("gray")
self.combo_cmap.setEnabled(False) self.combo_cmap.setEnabled(False)
self.combo_cmap.setSizePolicy(QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed)
self.combo_cmap.currentIndexChanged.connect(self._on_cmap_changed) self.combo_cmap.currentIndexChanged.connect(self._on_cmap_changed)
cmr.addWidget(self.combo_cmap) cmap_form.addRow("Colormap:", self.combo_cmap)
dl.addLayout(cmr) dl.addLayout(cmap_form)
self.chk_auto = QCheckBox("Auto-scale colormap") self.chk_auto = QCheckBox("Auto-scale colormap")
self.chk_auto.setChecked(True) self.chk_auto.setChecked(True)
self.chk_auto.toggled.connect(self._on_autoscale_toggled) self.chk_auto.toggled.connect(self._on_autoscale_toggled)
dl.addWidget(self.chk_auto) dl.addWidget(self.chk_auto)
range_form = _form()
for label, attr in (("min:", "spin_vmin"), ("max:", "spin_vmax")): for label, attr in (("min:", "spin_vmin"), ("max:", "spin_vmax")):
row = QHBoxLayout()
row.addWidget(QLabel(label))
spin = QDoubleSpinBox() spin = QDoubleSpinBox()
spin.setRange(-1e9, 1e9) spin.setRange(-1e9, 1e9)
spin.setDecimals(4) spin.setDecimals(4)
spin.setEnabled(False) spin.setEnabled(False)
spin.setMinimumWidth(_SPIN_MIN_W)
spin.editingFinished.connect(self._on_manual_range_changed) spin.editingFinished.connect(self._on_manual_range_changed)
setattr(self, attr, spin) setattr(self, attr, spin)
row.addWidget(spin) range_form.addRow(label, spin)
dl.addLayout(row) dl.addLayout(range_form)
right_panel = QWidget() right_panel = QWidget()
right_panel.setFixedWidth(270)
layout = QVBoxLayout(right_panel) layout = QVBoxLayout(right_panel)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6) layout.setSpacing(8)
layout.addWidget(self.grp_velocity) layout.addWidget(self.grp_velocity)
layout.addWidget(grp_display) layout.addWidget(grp_display)
layout.addStretch() layout.addStretch()
return right_panel return _scroll_panel(right_panel, _RIGHT_PANEL_W)
def _build_menus(self): def _build_menus(self):
menubar = self.menuBar() menubar = self.menuBar()