c30c8b1815
- Extract cache mismatch logic into reusable cache_mismatch_reasons() function for cleaner validation and consistent miss reporting - Expose memory_budget_bytes() for external callers (aligned exporter) - Optimize rebuild_stack() with incremental layer updates when only one angle parameters change, avoiding full reprojection overhead - Remove unused seed_per_angle parameter from alignment wizard - Simplify cached_rf_image() DC masking with cleaner early exit logic - Clean up internal state tracking with explicit origin caching Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
179 lines
6.4 KiB
Python
179 lines
6.4 KiB
Python
"""Shared constants and small layout helpers for the viewer widgets."""
|
|
|
|
from PyQt6.QtCore import Qt
|
|
from PyQt6.QtWidgets import (
|
|
QComboBox, QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QLabel,
|
|
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Display constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CH_LABELS = [
|
|
"CH1 — RF (FFT peak freq)",
|
|
"CH3 — Bias A (DC mean)",
|
|
"CH4 — Bias B (DC mean)",
|
|
"CH1 — Velocity (SRAS)",
|
|
]
|
|
|
|
# Combo index for the derived velocity mode (uses CH1_IDX data)
|
|
VELOCITY_MODE_IDX = 3
|
|
# All modes that operate on CH1 waveforms
|
|
CH1_DERIVED_MODES = (CH1_IDX, VELOCITY_MODE_IDX)
|
|
|
|
CMAPS = ["gray", "viridis", "plasma", "inferno", "hot", "jet", "RdBu_r", "seismic"]
|
|
|
|
# (mode_str, status-bar unit, colorbar label) per channel index
|
|
_CHANNEL_DISPLAY = {
|
|
CH1_IDX: ("RF", "Peak frequency (MHz)", "MHz"),
|
|
CH3_IDX: ("DC", "DC mean (mV)", "mV"),
|
|
CH4_IDX: ("DC", "DC mean (mV)", "mV"),
|
|
VELOCITY_MODE_IDX: ("Velocity", "Velocity (m/s)", "m/s"),
|
|
}
|
|
|
|
_CSS_HINT = "font-size: 11px; color: #aaa;"
|
|
_CSS_INFO = "font-size: 11px;"
|
|
_CSS_MUTED = "color: #888; font-size: 11px;"
|
|
_CSS_WARN = "color: #e07000; font-size: 11px;"
|
|
_CSS_BUSY = "color: #4a90d9; font-size: 11px;"
|
|
|
|
# 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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class Jobs:
|
|
"""Keys for SrasViewerWindow's background-job registry (_run_worker /
|
|
_job_running) and its progress dialogs — one place instead of string
|
|
literals scattered across window and dialogs."""
|
|
LOAD = "load"
|
|
COMPUTE = "compute"
|
|
DC_PRECOMPUTE = "dc_precompute"
|
|
BATCH = "batch"
|
|
# The alignment wizard's three background steps: fetching each angle's CH4
|
|
# image for the mask stack, registering the angles, and writing the aligned
|
|
# export. Separate keys because a retry of one must not be blocked by
|
|
# another having run, and _run_worker's busy check is per key.
|
|
ALIGN_MASKS = "align_masks"
|
|
ALIGN_CORRELATE = "align_correlate"
|
|
ALIGN_EXPORT = "align_export"
|
|
|
|
|
|
def _make_dspin(lo: float, hi: float, decimals: int, *, suffix: str = "",
|
|
value: float | None = None, step: float | None = None) -> QDoubleSpinBox:
|
|
"""A QDoubleSpinBox with the panel-standard construction."""
|
|
spin = QDoubleSpinBox()
|
|
spin.setRange(lo, hi)
|
|
spin.setDecimals(decimals)
|
|
if suffix:
|
|
spin.setSuffix(suffix)
|
|
if step is not None:
|
|
spin.setSingleStep(step)
|
|
if value is not None:
|
|
spin.setValue(value)
|
|
spin.setMinimumWidth(_SPIN_MIN_W)
|
|
return spin
|
|
|
|
|
|
def _combo(items=(), *, min_chars: int = 10) -> QComboBox:
|
|
"""A combo box whose size hint does not depend on its longest entry.
|
|
|
|
By default a QComboBox asks for enough width to show its widest item. These
|
|
hold descriptive phrases, and the side panels are fixed-width — in a scroll
|
|
area with the horizontal scrollbar off (`_scroll_panel`) an unconstrained
|
|
hint pushes the inner widget past the panel and everything on the right,
|
|
including the hint text, is silently clipped instead of scrolling.
|
|
|
|
*items* is a sequence of (label, data) pairs, or of plain labels.
|
|
"""
|
|
combo = QComboBox()
|
|
combo.setSizeAdjustPolicy(
|
|
QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
|
|
combo.setMinimumContentsLength(min_chars)
|
|
for item in items:
|
|
if isinstance(item, tuple):
|
|
combo.addItem(item[0], item[1])
|
|
else:
|
|
combo.addItem(item)
|
|
return combo
|
|
|
|
|
|
def _axes_extent(x_axis, y_axis, dx: float, dy: float) -> list[float]:
|
|
"""Matplotlib imshow extent with half-pixel margins, Y flipped so row 0
|
|
renders at the top."""
|
|
return [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
|
|
y_axis[-1] + dy / 2, y_axis[0] - dy / 2]
|
|
|
|
|
|
def _wrap_label(text: str = "", css: str | None = None) -> QLabel:
|
|
"""A word-wrapped QLabel that reports its *wrapped* height to the layout.
|
|
|
|
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
|
|
|