6d0e30b9ce
The alignment machinery never modified a scan: it produced an
AlignmentResult and every consumer resampled on the fly. That is right
for a viewer but means the aligned stack cannot leave the process, so no
other tool can read it and reopening the scan redoes the registration.
sras_align_export.write_aligned_sras bakes an alignment into a new v6
file: each angle is gathered onto the shared canvas by nearest neighbour,
so every output angle shares one grid and the file opens already aligned.
Registering the export against itself returns identity, which is the test
that pins the whole index chain.
Three details that are easy to get wrong and are now covered:
* Rounding must be floor(x + 0.5), not np.rint. scipy's order=0 rounds
halves away from zero, and the canvas is snapped to the reference's
pixel grid, so exact halves are common rather than hypothetical.
* Out-of-bounds must be tested on the fractional coordinate against
[0, n-1], not on the rounded index, or a one-pixel rim gets real data
everywhere the Aligned View shows padding.
* ...but with a tolerance, because the mm-space affine chain lands an
exactly-integer transform a few times 1e-13 off. A bare >= 0 drops
the *reference* angle's entire first row and last column.
Padding is the per-channel ADC code nearest 0 mV, not zero: zero ADC
decodes to ~+100 mV on real calibration and would masquerade as sample.
Source rows are served from sliding in-RAM bands. A rotated angle maps
one output row to a diagonal across the source, so indexing a memmap in
output order refaults nearly the whole angle per row — terabytes of
paging for a gigabyte of data.
Also in compute: crop_alignment_result (a crop is pure index translation,
so it folds into the affine's offset rather than becoming a second
transform), overlap_stats, largest_rect_at_least for a crop that stays
inside the overlap region, and seed/sign/refine knobs on
register_angle_to_reference whose defaults leave existing behaviour and
tests byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
5.5 KiB
Python
156 lines
5.5 KiB
Python
"""Shared constants and small layout helpers for the viewer widgets."""
|
|
|
|
from PyQt6.QtCore import Qt
|
|
from PyQt6.QtWidgets import (
|
|
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 _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
|
|
|