Split sras_viewer.py into a package (pure move)
The 2790-line module becomes sras_viewer/: common.py (constants + layout helpers), canvases.py (RoiQuad, ImageCanvas, WaveformCanvas, ManualAlignOverlayCanvas), dialogs.py (FftOptionsDialog, ManualAlignmentDialog), main_window.py (SrasViewerWindow + main), with __init__ re-exporting the public names and __main__ keeping `python -m sras_viewer` working. pyproject gains a `sras-viewer` console script. Code moved verbatim; only import headers are new (pyflakes-clean). tests/test_gui.py patch targets follow the classes to their new modules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -23,15 +23,18 @@ dependencies = [
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest"]
|
dev = ["pytest"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
sras-viewer = "sras_viewer.main_window:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
py-modules = [
|
py-modules = [
|
||||||
"sras_format",
|
"sras_format",
|
||||||
"sras_compute",
|
"sras_compute",
|
||||||
"sras_workers",
|
"sras_workers",
|
||||||
"sras_viewer",
|
|
||||||
"sras_average",
|
"sras_average",
|
||||||
"sras_edit_scans",
|
"sras_edit_scans",
|
||||||
]
|
]
|
||||||
|
packages = ["sras_viewer"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
-2796
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
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.
|
||||||
|
|
||||||
|
File parsing lives in sras_format, image/alignment math in sras_compute, and
|
||||||
|
background workers in sras_workers — none of which import Qt or matplotlib,
|
||||||
|
so multiprocessing children can load them cheaply.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import faulthandler
|
||||||
|
|
||||||
|
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
|
||||||
|
|
||||||
|
from .canvases import ImageCanvas, RoiQuad, WaveformCanvas # noqa: E402,F401
|
||||||
|
from .common import CH_LABELS, CMAPS, VELOCITY_MODE_IDX # noqa: E402,F401
|
||||||
|
from .dialogs import FftOptionsDialog, ManualAlignmentDialog # noqa: E402,F401
|
||||||
|
from .main_window import SrasViewerWindow, main # noqa: E402,F401
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .main_window import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
"""Matplotlib canvases and the ROI primitive."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
|
||||||
|
from matplotlib.figure import Figure
|
||||||
|
from matplotlib.patches import Polygon
|
||||||
|
from matplotlib.path import Path as MplPath
|
||||||
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
|
from PyQt6.QtGui import QKeyEvent
|
||||||
|
from PyQt6.QtWidgets import QSizePolicy
|
||||||
|
|
||||||
|
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ROI (free quadrilateral in data coordinates)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RoiQuad:
|
||||||
|
"""Free quadrilateral defined in data coordinates (mm).
|
||||||
|
|
||||||
|
Stored as 4 corner points (shape (4, 2)) in CCW order: BL, BR, TR, TL.
|
||||||
|
Each corner can be positioned independently, allowing skewed /
|
||||||
|
non-orthogonal regions of interest. Because it lives in scan/data
|
||||||
|
coords it persists unchanged when the displayed channel/mode switches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, pts: np.ndarray):
|
||||||
|
"""pts : array-like, shape (4, 2)."""
|
||||||
|
self._pts = np.asarray(pts, dtype=np.float64).reshape(4, 2).copy()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bbox(cls, x0: float, y0: float, x1: float, y1: float) -> "RoiQuad":
|
||||||
|
"""Create an axis-aligned rectangle from two opposite corners."""
|
||||||
|
lx, rx = min(x0, x1), max(x0, x1)
|
||||||
|
by, ty = min(y0, y1), max(y0, y1)
|
||||||
|
return cls(np.array([[lx, by], [rx, by], [rx, ty], [lx, ty]]))
|
||||||
|
|
||||||
|
def copy(self) -> "RoiQuad":
|
||||||
|
return RoiQuad(self._pts.copy())
|
||||||
|
|
||||||
|
def corners(self) -> np.ndarray:
|
||||||
|
"""World-coord corners, shape (4, 2), CCW: BL, BR, TR, TL."""
|
||||||
|
return self._pts.copy()
|
||||||
|
|
||||||
|
def centroid(self) -> np.ndarray:
|
||||||
|
return self._pts.mean(axis=0)
|
||||||
|
|
||||||
|
def bbox_size(self) -> np.ndarray:
|
||||||
|
"""Width and height of the axis-aligned bounding box, shape (2,)."""
|
||||||
|
return self._pts.max(axis=0) - self._pts.min(axis=0)
|
||||||
|
|
||||||
|
def contains(self, x: float, y: float) -> bool:
|
||||||
|
return bool(MplPath(self._pts).contains_point((x, y)))
|
||||||
|
|
||||||
|
def mask_for_grid(self, x_axis: np.ndarray,
|
||||||
|
y_axis: np.ndarray) -> np.ndarray:
|
||||||
|
"""Boolean mask (n_rows, n_frames) of pixels whose centres lie
|
||||||
|
inside the quadrilateral.
|
||||||
|
|
||||||
|
Only the quad's axis-aligned bounding box is tested — meshgrid and
|
||||||
|
contains_points over the *whole* grid would be tens of millions of
|
||||||
|
point-in-polygon tests (and hundreds of MB of float64 temporaries)
|
||||||
|
on a large scan, on every ROI edit.
|
||||||
|
"""
|
||||||
|
x = np.asarray(x_axis, dtype=np.float64)
|
||||||
|
y = np.asarray(y_axis, dtype=np.float64)
|
||||||
|
mask = np.zeros((y.size, x.size), dtype=bool)
|
||||||
|
|
||||||
|
(x0, y0), (x1, y1) = self._pts.min(axis=0), self._pts.max(axis=0)
|
||||||
|
cols = np.nonzero((x >= x0) & (x <= x1))[0]
|
||||||
|
rows = np.nonzero((y >= y0) & (y <= y1))[0]
|
||||||
|
if cols.size == 0 or rows.size == 0:
|
||||||
|
return mask
|
||||||
|
|
||||||
|
c0, c1 = int(cols[0]), int(cols[-1]) + 1
|
||||||
|
r0, r1 = int(rows[0]), int(rows[-1]) + 1
|
||||||
|
X, Y = np.meshgrid(x[c0:c1], y[r0:r1])
|
||||||
|
inside = MplPath(self._pts).contains_points(
|
||||||
|
np.column_stack([X.ravel(), Y.ravel()]))
|
||||||
|
mask[r0:r1, c0:c1] = inside.reshape(X.shape)
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Matplotlib canvases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ImageCanvas(FigureCanvasQTAgg):
|
||||||
|
pixel_clicked = pyqtSignal(int, int) # row_idx, frame_idx
|
||||||
|
roi_changed = pyqtSignal() # ROI created / edited / cleared
|
||||||
|
draw_mode_changed = pyqtSignal(bool) # "draw new ROI" arm toggled
|
||||||
|
|
||||||
|
# Interaction state values
|
||||||
|
_IDLE = "idle"
|
||||||
|
_DRAW_NEW = "draw_new"
|
||||||
|
_MOVE = "move"
|
||||||
|
_DRAG_CORNER = "drag_corner"
|
||||||
|
|
||||||
|
# Hit tolerance (display pixels) for handles.
|
||||||
|
_HANDLE_PX = 12
|
||||||
|
_CLICK_THRESH_PX = 4 # releases within this of press count as a click
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
fig = Figure(figsize=(7, 5), tight_layout=True)
|
||||||
|
self.ax = fig.add_subplot(111)
|
||||||
|
super().__init__(fig)
|
||||||
|
self.setParent(parent)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
self._extent = None
|
||||||
|
self._img_shape = None
|
||||||
|
|
||||||
|
# ROI state
|
||||||
|
self._roi: RoiQuad | None = None
|
||||||
|
self._roi_artists: list = []
|
||||||
|
self._state = self._IDLE
|
||||||
|
self._draw_mode = False
|
||||||
|
|
||||||
|
# Per-interaction snapshots / anchors
|
||||||
|
self._press_xy: tuple[float, float] | None = None
|
||||||
|
self._press_pixel: tuple[float, float] | None = None
|
||||||
|
self._press_button = None
|
||||||
|
self._snapshot: RoiQuad | None = None
|
||||||
|
self._drag_corner_idx: int = -1
|
||||||
|
self._move_anchor = None # press-point in world coords
|
||||||
|
self._draw_previous: RoiQuad | None = None
|
||||||
|
|
||||||
|
self.mpl_connect("button_press_event", self._on_press)
|
||||||
|
self.mpl_connect("motion_notify_event", self._on_motion)
|
||||||
|
self.mpl_connect("button_release_event", self._on_release)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def show_image(self, img: np.ndarray, extent: list[float], cmap: str,
|
||||||
|
vmin: float, vmax: float, xlabel: str, ylabel: str, title: str,
|
||||||
|
colorbar_label: str = ""):
|
||||||
|
self.figure.clf()
|
||||||
|
self.ax = self.figure.add_subplot(111)
|
||||||
|
# Patches and lines are destroyed by figure.clf(); drop stale refs.
|
||||||
|
self._roi_artists = []
|
||||||
|
|
||||||
|
self._extent = extent
|
||||||
|
self._img_shape = img.shape
|
||||||
|
|
||||||
|
im = self.ax.imshow(
|
||||||
|
img, aspect="auto", origin="upper",
|
||||||
|
extent=extent, cmap=cmap, vmin=vmin, vmax=vmax,
|
||||||
|
interpolation="nearest",
|
||||||
|
)
|
||||||
|
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04)
|
||||||
|
if colorbar_label:
|
||||||
|
cb.set_label(colorbar_label)
|
||||||
|
|
||||||
|
self.ax.set_xlabel(xlabel)
|
||||||
|
self.ax.set_ylabel(ylabel)
|
||||||
|
self.ax.set_title(title)
|
||||||
|
|
||||||
|
# Re-draw the ROI (if any) on top of the fresh image so it persists
|
||||||
|
# unchanged across mode / angle / channel switches.
|
||||||
|
self._draw_roi()
|
||||||
|
self.draw()
|
||||||
|
|
||||||
|
def get_roi(self) -> RoiQuad | None:
|
||||||
|
return self._roi
|
||||||
|
|
||||||
|
def set_roi(self, roi: RoiQuad | None):
|
||||||
|
self._roi = roi.copy() if roi is not None else None
|
||||||
|
self._draw_roi()
|
||||||
|
self.draw_idle()
|
||||||
|
self.roi_changed.emit()
|
||||||
|
|
||||||
|
def clear_roi(self):
|
||||||
|
self._roi = None
|
||||||
|
self._remove_roi_artists()
|
||||||
|
self.draw_idle()
|
||||||
|
self.roi_changed.emit()
|
||||||
|
|
||||||
|
def start_drawing(self):
|
||||||
|
"""Arm the next click+drag on the image to create a new ROI,
|
||||||
|
replacing any existing one."""
|
||||||
|
self._draw_mode = True
|
||||||
|
self.setCursor(Qt.CursorShape.CrossCursor)
|
||||||
|
self.draw_mode_changed.emit(True)
|
||||||
|
|
||||||
|
def cancel_drawing(self):
|
||||||
|
if self._draw_mode:
|
||||||
|
self._draw_mode = False
|
||||||
|
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||||
|
self.draw_mode_changed.emit(False)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Rendering
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _remove_roi_artists(self):
|
||||||
|
for a in self._roi_artists:
|
||||||
|
try:
|
||||||
|
a.remove()
|
||||||
|
except (ValueError, AttributeError, NotImplementedError):
|
||||||
|
pass
|
||||||
|
self._roi_artists = []
|
||||||
|
|
||||||
|
def _draw_roi(self):
|
||||||
|
self._remove_roi_artists()
|
||||||
|
if self._roi is None or self.ax is None:
|
||||||
|
return
|
||||||
|
corners = self._roi.corners()
|
||||||
|
|
||||||
|
# Filled quad, then a sharp unfilled edge for visibility over bright
|
||||||
|
# images, then draggable corner handles.
|
||||||
|
for kwargs in (
|
||||||
|
dict(fill=True, facecolor="#ffd93a", edgecolor="#e53935",
|
||||||
|
alpha=0.22, linewidth=2.0, zorder=10),
|
||||||
|
dict(fill=False, edgecolor="#e53935", linewidth=1.8, zorder=11),
|
||||||
|
):
|
||||||
|
patch = Polygon(corners, closed=True, **kwargs)
|
||||||
|
self.ax.add_patch(patch)
|
||||||
|
self._roi_artists.append(patch)
|
||||||
|
|
||||||
|
self._roi_artists.append(self.ax.scatter(
|
||||||
|
corners[:, 0], corners[:, 1], s=60, c="white",
|
||||||
|
edgecolors="#e53935", linewidths=1.6, zorder=13))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Hit testing (display pixels for handles, data coords for "inside")
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _hit_test(self, event) -> tuple[str, int | None] | None:
|
||||||
|
if self._roi is None or self.ax is None:
|
||||||
|
return None
|
||||||
|
if event.x is None or event.y is None:
|
||||||
|
return None
|
||||||
|
corners_disp = self.ax.transData.transform(self._roi.corners())
|
||||||
|
click = np.array([event.x, event.y])
|
||||||
|
|
||||||
|
for i in range(4):
|
||||||
|
if np.hypot(*(corners_disp[i] - click)) <= self._HANDLE_PX:
|
||||||
|
return ("corner", i)
|
||||||
|
|
||||||
|
if event.xdata is not None and event.ydata is not None:
|
||||||
|
if self._roi.contains(event.xdata, event.ydata):
|
||||||
|
return ("inside", None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Mouse event handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_press(self, event):
|
||||||
|
if event.inaxes is not self.ax or self._extent is None:
|
||||||
|
return
|
||||||
|
if event.button != 1: # only left mouse button
|
||||||
|
return
|
||||||
|
# If the matplotlib toolbar is in pan / zoom mode, let it handle
|
||||||
|
# the interaction instead of starting a ROI manipulation.
|
||||||
|
tb = getattr(self, "toolbar", None)
|
||||||
|
if tb is not None and getattr(tb, "mode", ""):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._press_xy = (event.xdata, event.ydata)
|
||||||
|
self._press_pixel = (event.x, event.y)
|
||||||
|
self._press_button = event.button
|
||||||
|
|
||||||
|
if self._draw_mode:
|
||||||
|
self._draw_previous = self._roi.copy() if self._roi else None
|
||||||
|
self._roi = RoiQuad.from_bbox(event.xdata, event.ydata,
|
||||||
|
event.xdata, event.ydata)
|
||||||
|
self._state = self._DRAW_NEW
|
||||||
|
self._draw_roi()
|
||||||
|
self.draw_idle()
|
||||||
|
return
|
||||||
|
|
||||||
|
hit = self._hit_test(event)
|
||||||
|
if hit is None:
|
||||||
|
self._state = self._IDLE
|
||||||
|
return
|
||||||
|
|
||||||
|
kind, idx = hit
|
||||||
|
self._snapshot = self._roi.copy()
|
||||||
|
if kind == "corner":
|
||||||
|
self._state = self._DRAG_CORNER
|
||||||
|
self._drag_corner_idx = idx
|
||||||
|
else:
|
||||||
|
self._state = self._MOVE
|
||||||
|
self._move_anchor = (event.xdata, event.ydata)
|
||||||
|
|
||||||
|
def _on_motion(self, event):
|
||||||
|
if self._state == self._IDLE:
|
||||||
|
return
|
||||||
|
if event.xdata is None or event.ydata is None:
|
||||||
|
return
|
||||||
|
if event.inaxes is not self.ax:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._state == self._DRAW_NEW:
|
||||||
|
x0, y0 = self._press_xy
|
||||||
|
self._roi = RoiQuad.from_bbox(x0, y0, event.xdata, event.ydata)
|
||||||
|
elif self._state == self._MOVE:
|
||||||
|
delta = np.array([event.xdata - self._move_anchor[0],
|
||||||
|
event.ydata - self._move_anchor[1]])
|
||||||
|
self._roi._pts = self._snapshot.corners() + delta
|
||||||
|
elif self._state == self._DRAG_CORNER:
|
||||||
|
self._roi._pts[self._drag_corner_idx] = [event.xdata, event.ydata]
|
||||||
|
|
||||||
|
self._draw_roi()
|
||||||
|
self.draw_idle()
|
||||||
|
|
||||||
|
def _on_release(self, event):
|
||||||
|
if event.button != 1 and self._press_button != 1:
|
||||||
|
return
|
||||||
|
prev_state = self._state
|
||||||
|
self._state = self._IDLE
|
||||||
|
try:
|
||||||
|
if prev_state == self._DRAW_NEW:
|
||||||
|
self._finish_draw()
|
||||||
|
elif prev_state in (self._MOVE, self._DRAG_CORNER):
|
||||||
|
self._draw_roi()
|
||||||
|
self.draw_idle()
|
||||||
|
self.roi_changed.emit()
|
||||||
|
else:
|
||||||
|
self._maybe_emit_pixel_click(event)
|
||||||
|
finally:
|
||||||
|
self._press_xy = self._press_pixel = None
|
||||||
|
self._press_button = None
|
||||||
|
|
||||||
|
def _finish_draw(self):
|
||||||
|
"""Commit (or reject) a freshly-dragged quad."""
|
||||||
|
if self._extent is not None:
|
||||||
|
x0, x1, y_bot, y_top = self._extent
|
||||||
|
min_w = abs(x1 - x0) * 0.01 # minimum: 1% of each axis range
|
||||||
|
min_h = abs(y_bot - y_top) * 0.01
|
||||||
|
else:
|
||||||
|
min_w = min_h = 1e-6
|
||||||
|
|
||||||
|
if self._roi is None:
|
||||||
|
too_small = True
|
||||||
|
else:
|
||||||
|
bbox = self._roi.bbox_size()
|
||||||
|
too_small = bbox[0] < min_w or bbox[1] < min_h
|
||||||
|
if too_small:
|
||||||
|
self._roi = self._draw_previous
|
||||||
|
|
||||||
|
self._draw_previous = None
|
||||||
|
self.cancel_drawing()
|
||||||
|
self._draw_roi()
|
||||||
|
self.draw_idle()
|
||||||
|
self.roi_changed.emit()
|
||||||
|
|
||||||
|
def _maybe_emit_pixel_click(self, event):
|
||||||
|
"""A release close enough to its press counts as a pixel click."""
|
||||||
|
if (self._press_pixel is None or event.x is None or event.y is None
|
||||||
|
or self._extent is None or event.inaxes is not self.ax
|
||||||
|
or event.xdata is None):
|
||||||
|
return
|
||||||
|
dx_px = event.x - self._press_pixel[0]
|
||||||
|
dy_px = event.y - self._press_pixel[1]
|
||||||
|
if dx_px * dx_px + dy_px * dy_px > self._CLICK_THRESH_PX ** 2:
|
||||||
|
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)
|
||||||
|
self.pixel_clicked.emit(max(0, min(row, n_rows - 1)),
|
||||||
|
max(0, min(col, n_frames - 1)))
|
||||||
|
|
||||||
|
|
||||||
|
class WaveformCanvas(FigureCanvasQTAgg):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
fig = Figure(figsize=(8, 3), tight_layout=True)
|
||||||
|
self.ax_wave = fig.add_subplot(121)
|
||||||
|
self.ax_right = fig.add_subplot(122)
|
||||||
|
super().__init__(fig)
|
||||||
|
self.setParent(parent)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
|
||||||
|
def show_rf_waveform(self, sras: SrasFile, angle_idx: int,
|
||||||
|
row_idx: int, frame_idx: int,
|
||||||
|
apply_bg_sub: bool = True):
|
||||||
|
"""CH1 RF: time-domain + FFT spectrum.
|
||||||
|
|
||||||
|
If apply_bg_sub is True and sras.background is not None, the background
|
||||||
|
waveform is overlaid on the time-domain plot and the FFT is computed
|
||||||
|
on the subtracted signal. The unsubtracted FFT is also shown faintly
|
||||||
|
for comparison.
|
||||||
|
"""
|
||||||
|
data = sras.data[angle_idx]
|
||||||
|
waveform = data[row_idx, CH1_IDX, frame_idx, :].astype(np.float32)
|
||||||
|
t_ns = sras.time_axis_ns()
|
||||||
|
f_mhz = sras.freq_axis_mhz()
|
||||||
|
dc3_val = data[row_idx, CH3_IDX, frame_idx, :].astype(np.float32).mean()
|
||||||
|
dc4_val = data[row_idx, CH4_IDX, frame_idx, :].astype(np.float32).mean()
|
||||||
|
|
||||||
|
bg = sras.background if (apply_bg_sub and sras.background is not None) else None
|
||||||
|
waveform_plot = waveform - bg if bg is not None else waveform
|
||||||
|
|
||||||
|
self.ax_wave.cla()
|
||||||
|
self.ax_right.cla()
|
||||||
|
|
||||||
|
if bg is not None:
|
||||||
|
self.ax_wave.plot(t_ns, waveform, linewidth=0.5, color="#aaaaaa",
|
||||||
|
label="raw", zorder=1)
|
||||||
|
self.ax_wave.plot(t_ns, bg, linewidth=0.5, color="#e07030",
|
||||||
|
linestyle="--", label="background", zorder=2)
|
||||||
|
self.ax_wave.plot(t_ns, waveform_plot, linewidth=0.7, color="#4488cc",
|
||||||
|
label="subtracted", zorder=3)
|
||||||
|
self.ax_wave.legend(fontsize=7, loc="upper right")
|
||||||
|
else:
|
||||||
|
self.ax_wave.plot(t_ns, waveform, linewidth=0.7, color="#4488cc")
|
||||||
|
|
||||||
|
self.ax_wave.set_xlabel("Time (ns)")
|
||||||
|
self.ax_wave.set_ylabel("ADC counts")
|
||||||
|
bg_tag = " [bg sub]" if bg is not None else ""
|
||||||
|
dc3_mv = adc_to_mv(dc3_val, *sras.cal(CH3_IDX))
|
||||||
|
dc4_mv = adc_to_mv(dc4_val, *sras.cal(CH4_IDX))
|
||||||
|
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"({dc3_mv:.2f} / {dc4_mv:.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_mhz = f_mhz[int(np.argmax(power_sub))]
|
||||||
|
|
||||||
|
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)
|
||||||
|
mean_val = float(waveform.mean())
|
||||||
|
mean_mv = adc_to_mv(mean_val, *sras.cal(ch_idx))
|
||||||
|
|
||||||
|
self.ax_wave.cla()
|
||||||
|
self.ax_right.cla()
|
||||||
|
|
||||||
|
self.ax_wave.plot(sras.time_axis_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\nmean = {mean_val:.3f} ADC\n = {mean_mv:.3f} mV",
|
||||||
|
ha="center", va="center",
|
||||||
|
transform=self.ax_right.transAxes, fontsize=11,
|
||||||
|
)
|
||||||
|
self.ax_right.set_axis_off()
|
||||||
|
|
||||||
|
self.draw()
|
||||||
|
|
||||||
|
|
||||||
|
class ManualAlignOverlayCanvas(FigureCanvasQTAgg):
|
||||||
|
"""Renders ManualAlignmentDialog's multi-angle mask overlay and turns
|
||||||
|
keyboard input into translate/rotate nudge requests for whichever angle
|
||||||
|
the dialog currently has active.
|
||||||
|
|
||||||
|
A pure input+render widget — it holds no alignment state and never
|
||||||
|
touches SrasFile itself; ManualAlignmentDialog owns all of that and
|
||||||
|
decides, from these signals, whether a cheap single-layer refresh or a
|
||||||
|
full preview-canvas rebuild is needed.
|
||||||
|
|
||||||
|
FigureCanvasQTAgg is a real QWidget, so keyPressEvent works like on any
|
||||||
|
other widget, but Qt only ever delivers key events to whichever widget
|
||||||
|
currently has focus — StrongFocus, plus grabbing focus on click and once
|
||||||
|
right after the dialog is shown, are both required or arrow keys
|
||||||
|
silently do nothing.
|
||||||
|
|
||||||
|
Rotate keys are letters (Q/E), not punctuation (comma/period or
|
||||||
|
brackets): Shift+letter still reports the same Qt.Key on every platform,
|
||||||
|
whereas Shift+comma/bracket can report a different virtual key
|
||||||
|
(Key_Less / Key_BraceLeft) depending on platform and keyboard layout —
|
||||||
|
which would silently break the "Shift = coarse step" modifier for
|
||||||
|
rotation specifically. Arrow keys have no such hazard.
|
||||||
|
"""
|
||||||
|
nudge_translate = pyqtSignal(int, int, bool) # dir_x, dir_y in {-1,0,1}; coarse
|
||||||
|
nudge_rotate = pyqtSignal(int, bool) # dir in {-1,1} (CCW/CW); coarse
|
||||||
|
|
||||||
|
_TRANSLATE_KEYS = {
|
||||||
|
Qt.Key.Key_Left: (-1, 0),
|
||||||
|
Qt.Key.Key_Right: (1, 0),
|
||||||
|
Qt.Key.Key_Up: (0, -1),
|
||||||
|
Qt.Key.Key_Down: (0, 1),
|
||||||
|
}
|
||||||
|
_ROTATE_KEYS = {Qt.Key.Key_Q: 1, Qt.Key.Key_E: -1} # CCW, CW
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
fig = Figure(figsize=(6, 6), tight_layout=True)
|
||||||
|
self.ax = fig.add_subplot(111)
|
||||||
|
super().__init__(fig)
|
||||||
|
self.setParent(parent)
|
||||||
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
self.mpl_connect("button_press_event", lambda _e: self.setFocus())
|
||||||
|
|
||||||
|
def show_overlay(self, rgba: np.ndarray, extent: list[float], title: str):
|
||||||
|
self.figure.clf()
|
||||||
|
self.ax = self.figure.add_subplot(111)
|
||||||
|
self.ax.imshow(rgba, extent=extent, origin="upper", aspect="auto")
|
||||||
|
self.ax.set_xlabel("X (mm)")
|
||||||
|
self.ax.set_ylabel("Y (mm)")
|
||||||
|
self.ax.set_title(title)
|
||||||
|
self.draw_idle() # coalesces rapid redraws — matters for key-repeat.
|
||||||
|
|
||||||
|
def keyPressEvent(self, event: QKeyEvent):
|
||||||
|
key = event.key()
|
||||||
|
coarse = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
|
||||||
|
if key in self._TRANSLATE_KEYS:
|
||||||
|
dx, dy = self._TRANSLATE_KEYS[key]
|
||||||
|
self.nudge_translate.emit(dx, dy, coarse)
|
||||||
|
event.accept()
|
||||||
|
elif key in self._ROTATE_KEYS:
|
||||||
|
self.nudge_rotate.emit(self._ROTATE_KEYS[key], coarse)
|
||||||
|
event.accept()
|
||||||
|
else:
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Shared constants and small layout helpers for the viewer widgets."""
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
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
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
"""FFT Options and Manual Alignment dialogs."""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import matplotlib as mpl
|
||||||
|
import numpy as np
|
||||||
|
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT
|
||||||
|
from PyQt6.QtCore import pyqtSignal
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QButtonGroup, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox,
|
||||||
|
QGroupBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QRadioButton,
|
||||||
|
QSpinBox, QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
import sras_compute as compute
|
||||||
|
from sras_compute import (
|
||||||
|
PYFFTW_AVAILABLE, ManualAngleParams, build_manual_alignment,
|
||||||
|
delete_manual_alignment, save_manual_alignment,
|
||||||
|
)
|
||||||
|
from sras_format import SrasFile
|
||||||
|
from sras_workers import Ch4MaskWorker, CrossCorrelateWorker
|
||||||
|
|
||||||
|
from .canvases import ManualAlignOverlayCanvas
|
||||||
|
from .common import (
|
||||||
|
_CSS_HINT, _CSS_MUTED, _CSS_WARN, _SPIN_MIN_W, _form, _group,
|
||||||
|
_scroll_panel, _wrap_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .main_window import SrasViewerWindow
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FFT Options dialog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FftOptionsDialog(QDialog):
|
||||||
|
"""Configure FFT backend and zero-padding.
|
||||||
|
|
||||||
|
Changes take effect only when the user clicks Apply. Cancel discards
|
||||||
|
all pending edits. The live 'frequency resolution' label updates as
|
||||||
|
the user adjusts the pad factor so they can see the trade-off before
|
||||||
|
committing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None, *,
|
||||||
|
current_backend: str,
|
||||||
|
current_pad_factor: int,
|
||||||
|
samples_per_frame: int | None,
|
||||||
|
sample_rate_hz: float | None,
|
||||||
|
grating_um: float):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("FFT Options")
|
||||||
|
self.setModal(True)
|
||||||
|
self.setMinimumWidth(380)
|
||||||
|
|
||||||
|
self._samples_per_frame = samples_per_frame
|
||||||
|
self._sample_rate_hz = sample_rate_hz
|
||||||
|
self._grating_um = grating_um
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# ---- Backend ---------------------------------------------------
|
||||||
|
grp_backend = QGroupBox("FFT Backend")
|
||||||
|
bl = QVBoxLayout(grp_backend)
|
||||||
|
|
||||||
|
self._btn_scipy = QRadioButton("SciPy FFT (pocketfft) (always available)")
|
||||||
|
self._btn_pyfftw = QRadioButton(
|
||||||
|
"pyFFTW (faster for large arrays)" if PYFFTW_AVAILABLE
|
||||||
|
else "pyFFTW (not installed — run: pip install pyfftw)")
|
||||||
|
self._btn_pyfftw.setEnabled(PYFFTW_AVAILABLE)
|
||||||
|
|
||||||
|
self._backend_group = QButtonGroup(self)
|
||||||
|
self._backend_group.addButton(self._btn_scipy, id=0)
|
||||||
|
self._backend_group.addButton(self._btn_pyfftw, id=1)
|
||||||
|
|
||||||
|
if current_backend == "pyfftw" and PYFFTW_AVAILABLE:
|
||||||
|
self._btn_pyfftw.setChecked(True)
|
||||||
|
else:
|
||||||
|
self._btn_scipy.setChecked(True)
|
||||||
|
|
||||||
|
bl.addWidget(self._btn_scipy)
|
||||||
|
bl.addWidget(self._btn_pyfftw)
|
||||||
|
layout.addWidget(grp_backend)
|
||||||
|
|
||||||
|
# ---- Zero-padding ----------------------------------------------
|
||||||
|
grp_zp = QGroupBox("Zero-Padding")
|
||||||
|
zl = QVBoxLayout(grp_zp)
|
||||||
|
|
||||||
|
pad_row = QHBoxLayout()
|
||||||
|
pad_row.addWidget(QLabel("Pad factor:"))
|
||||||
|
self._spin_pad = QSpinBox()
|
||||||
|
self._spin_pad.setRange(1, 256)
|
||||||
|
self._spin_pad.setValue(max(1, current_pad_factor))
|
||||||
|
self._spin_pad.setToolTip(
|
||||||
|
"Multiply the waveform length by this factor via zero-padding\n"
|
||||||
|
"before computing the FFT.\n"
|
||||||
|
"1 = no padding (natural length).\n"
|
||||||
|
"Powers of 2 (2, 4, 8 …) give the best performance."
|
||||||
|
)
|
||||||
|
self._spin_pad.valueChanged.connect(self._update_info)
|
||||||
|
pad_row.addWidget(self._spin_pad)
|
||||||
|
zl.addLayout(pad_row)
|
||||||
|
|
||||||
|
self._lbl_nfft = QLabel()
|
||||||
|
self._lbl_freq_res = QLabel()
|
||||||
|
self._lbl_vel_res = QLabel()
|
||||||
|
for lbl in (self._lbl_nfft, self._lbl_freq_res, self._lbl_vel_res):
|
||||||
|
lbl.setStyleSheet(_CSS_HINT)
|
||||||
|
zl.addWidget(lbl)
|
||||||
|
|
||||||
|
layout.addWidget(grp_zp)
|
||||||
|
|
||||||
|
# ---- Buttons ---------------------------------------------------
|
||||||
|
buttons = QDialogButtonBox()
|
||||||
|
buttons.addButton("Apply", QDialogButtonBox.ButtonRole.AcceptRole
|
||||||
|
).clicked.connect(self.accept)
|
||||||
|
buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole
|
||||||
|
).clicked.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
self._update_info()
|
||||||
|
|
||||||
|
def _update_info(self):
|
||||||
|
spf = self._samples_per_frame
|
||||||
|
sr = self._sample_rate_hz
|
||||||
|
pad = self._spin_pad.value()
|
||||||
|
|
||||||
|
if spf is None or sr is None:
|
||||||
|
self._lbl_nfft.setText("Load a file to preview FFT parameters.")
|
||||||
|
self._lbl_freq_res.setText("")
|
||||||
|
self._lbl_vel_res.setText("")
|
||||||
|
return
|
||||||
|
|
||||||
|
n_fft = spf * pad
|
||||||
|
freq_res_hz = sr / n_fft
|
||||||
|
freq_res_mhz = freq_res_hz / 1e6
|
||||||
|
# v (m/s) = freq (MHz) × grating (µm)
|
||||||
|
vel_res_ms = freq_res_mhz * self._grating_um
|
||||||
|
|
||||||
|
self._lbl_nfft.setText(f"FFT points: {spf} × {pad} = {n_fft:,}")
|
||||||
|
self._lbl_freq_res.setText(
|
||||||
|
f"Frequency bin: {freq_res_mhz:.4f} MHz ({freq_res_hz / 1e3:.2f} kHz)")
|
||||||
|
self._lbl_vel_res.setText(
|
||||||
|
f"Velocity bin: {vel_res_ms:.3f} m/s "
|
||||||
|
f"(at grating = {self._grating_um:.2f} µm)")
|
||||||
|
|
||||||
|
def get_backend(self) -> str:
|
||||||
|
return "pyfftw" if self._btn_pyfftw.isChecked() and PYFFTW_AVAILABLE else "scipy"
|
||||||
|
|
||||||
|
def get_pad_factor(self) -> int:
|
||||||
|
return max(1, self._spin_pad.value())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ManualAlignmentDialog(QDialog):
|
||||||
|
"""Non-modal manual angle-alignment editor (Fusion -> Manual Alignment...).
|
||||||
|
|
||||||
|
Shows every angle's binarized CH4 (Bias B) mask overlaid in a distinct
|
||||||
|
color at partial opacity on one shared canvas, so translation/rotation
|
||||||
|
misalignment is visible by eye. Reference angle (always index 0) is
|
||||||
|
ground truth and never moves; every other angle is aligned to it. The
|
||||||
|
user picks an "active" angle and nudges its rotation+translation with
|
||||||
|
the keyboard; Auto Cross-Correlate finds every non-reference angle's
|
||||||
|
rotation *and* translation by registering its image against the
|
||||||
|
reference's (see compute.register_angle_to_reference) — meant to get every
|
||||||
|
angle stacked on top of each other so keyboard nudging only has to make
|
||||||
|
small corrections, not find an alignment from scratch; Auto De-rotate is
|
||||||
|
the weaker fallback that just seeds rotation from the stage's reported
|
||||||
|
angle, leaving translation alone. Save writes a JSON sidecar next to the
|
||||||
|
.sras file and hands a freshly-built, full-resolution AlignmentResult back
|
||||||
|
to the main window — the exact same object shape compute_angle_alignment
|
||||||
|
produces, so every existing Aligned-View code path (apply_alignment,
|
||||||
|
_aligned_canvas_axes, the pixel-inspector inverse-transform) works
|
||||||
|
completely unmodified.
|
||||||
|
|
||||||
|
Non-modal by design (shown via .show(), never .exec() or setModal(True))
|
||||||
|
so the user can still interact with the main window. Talks back to
|
||||||
|
SrasViewerWindow two ways: it reuses parent._run_worker/_jobs directly
|
||||||
|
for its background mask-fetch and cross-correlate steps, so the main
|
||||||
|
window's existing shutdown/lifecycle plumbing covers both for free, and
|
||||||
|
it emits alignment_saved / alignment_cleared signals for the two moments
|
||||||
|
that should actually mutate the main window's persistent state —
|
||||||
|
everything else (nudging, Auto De-rotate, Auto Cross-Correlate, threshold
|
||||||
|
edits) stays purely local to this dialog until Save.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alignment_saved = pyqtSignal(object, str) # AlignmentResult, sidecar path (str)
|
||||||
|
alignment_cleared = pyqtSignal()
|
||||||
|
|
||||||
|
_PREVIEW_MARGIN_FRAC = 0.15
|
||||||
|
_BASE_ALPHA = 0.42
|
||||||
|
_ACTIVE_ALPHA = 0.75
|
||||||
|
_MAX_PREVIEW_DIM = 1024
|
||||||
|
|
||||||
|
# (label, sources passed to compute.register_angle_to_reference). "Both"
|
||||||
|
# registers on each and keeps whichever scores higher per angle, which
|
||||||
|
# costs roughly double but removes the failure mode where the single
|
||||||
|
# chosen source is the one that happens to be uninformative for one angle.
|
||||||
|
_CORRELATE_SOURCES = (
|
||||||
|
("Both, keep best (recommended)", ("signal", "mask")),
|
||||||
|
("Raw signal", ("signal",)),
|
||||||
|
("Thresholded mask", ("mask",)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, parent: "SrasViewerWindow", sras: SrasFile, *,
|
||||||
|
ref_angle_idx: int, dc_threshold_mv: float,
|
||||||
|
seed_per_angle: dict[int, ManualAngleParams] | None,
|
||||||
|
cached_dc4_mv: dict[int, np.ndarray]):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._parent = parent
|
||||||
|
self._sras = sras
|
||||||
|
self._ref_angle_idx = ref_angle_idx
|
||||||
|
self._downsample = (1, 1) # (rows, cols) block-mean factors
|
||||||
|
self._dc4_mv: dict[int, np.ndarray] = {}
|
||||||
|
self._masks_small: dict[int, np.ndarray] = {}
|
||||||
|
self._preview_layers: dict[int, np.ndarray] = {}
|
||||||
|
self._preview_origin_mm = (0.0, 0.0)
|
||||||
|
self._preview_shape = (1, 1)
|
||||||
|
self._preview_pitch_mm = (1.0, 1.0)
|
||||||
|
self._masks_ready = False
|
||||||
|
self._fit_notes: dict[int, tuple[float, str]] = {}
|
||||||
|
self._derotate_sign_flipped = False
|
||||||
|
|
||||||
|
self.setWindowTitle(f"Manual Alignment — {sras.path.name}")
|
||||||
|
self.resize(1150, 760)
|
||||||
|
|
||||||
|
self._seed_initial_params(seed_per_angle)
|
||||||
|
n = sras.n_angles
|
||||||
|
cmap = mpl.colormaps["tab10"] if n <= 10 else mpl.colormaps["tab20"]
|
||||||
|
self._angle_colors = {a: cmap(a % cmap.N)[:3] for a in range(n)}
|
||||||
|
self._active_angle = 1 if ref_angle_idx == 0 and n > 1 else 0
|
||||||
|
|
||||||
|
self._build_ui(dc_threshold_mv)
|
||||||
|
self._set_controls_enabled(False) # re-enabled once masks are ready
|
||||||
|
self._start_mask_prep(cached_dc4_mv)
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
super().showEvent(event)
|
||||||
|
self.canvas.setFocus()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Construction
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _seed_initial_params(self, seed_per_angle: dict[int, ManualAngleParams] | None):
|
||||||
|
seed = seed_per_angle or {}
|
||||||
|
self._angle_params: dict[int, ManualAngleParams] = {
|
||||||
|
a: (ManualAngleParams(seed[a].rotation_deg, seed[a].shift_mm)
|
||||||
|
if a in seed else ManualAngleParams())
|
||||||
|
for a in range(self._sras.n_angles)
|
||||||
|
}
|
||||||
|
self._angle_params[self._ref_angle_idx] = ManualAngleParams()
|
||||||
|
|
||||||
|
def _build_ui(self, dc_threshold_mv: float):
|
||||||
|
root = QHBoxLayout(self)
|
||||||
|
|
||||||
|
self.canvas = ManualAlignOverlayCanvas()
|
||||||
|
left = QWidget()
|
||||||
|
left_l = QVBoxLayout(left)
|
||||||
|
left_l.setContentsMargins(0, 0, 0, 0)
|
||||||
|
left_l.setSpacing(4)
|
||||||
|
left_l.addWidget(NavigationToolbar2QT(self.canvas, left))
|
||||||
|
left_l.addWidget(self.canvas)
|
||||||
|
root.addWidget(left, stretch=1)
|
||||||
|
|
||||||
|
panel = QWidget()
|
||||||
|
panel_l = QVBoxLayout(panel)
|
||||||
|
panel_l.setContentsMargins(0, 0, 0, 0)
|
||||||
|
panel_l.setSpacing(8)
|
||||||
|
|
||||||
|
# ---- Active Angle -------------------------------------------------
|
||||||
|
grp_angle, al = _group("Active Angle")
|
||||||
|
self.combo_active_angle = QComboBox()
|
||||||
|
for a in range(self._sras.n_angles):
|
||||||
|
label = f"Angle {a} ({self._sras.angles_deg[a]:.1f}°)"
|
||||||
|
if a == self._ref_angle_idx:
|
||||||
|
label += " [reference]"
|
||||||
|
self.combo_active_angle.addItem(label)
|
||||||
|
al.addWidget(self.combo_active_angle)
|
||||||
|
self.lbl_active_note = _wrap_label("", _CSS_WARN)
|
||||||
|
al.addWidget(self.lbl_active_note)
|
||||||
|
panel_l.addWidget(grp_angle)
|
||||||
|
|
||||||
|
# ---- Manual Adjustment ---------------------------------------------
|
||||||
|
self.grp_manual_adjust, mform_box = _group("Manual Adjustment")
|
||||||
|
mform = _form()
|
||||||
|
self.spin_active_rotation_deg = QDoubleSpinBox()
|
||||||
|
self.spin_active_rotation_deg.setRange(-3600.0, 3600.0)
|
||||||
|
self.spin_active_rotation_deg.setDecimals(3)
|
||||||
|
self.spin_active_rotation_deg.setSuffix(" °")
|
||||||
|
self.spin_active_rotation_deg.setMinimumWidth(_SPIN_MIN_W)
|
||||||
|
mform.addRow("Rotation:", self.spin_active_rotation_deg)
|
||||||
|
|
||||||
|
self.spin_active_shift_x_mm = QDoubleSpinBox()
|
||||||
|
self.spin_active_shift_x_mm.setRange(-1e5, 1e5)
|
||||||
|
self.spin_active_shift_x_mm.setDecimals(4)
|
||||||
|
self.spin_active_shift_x_mm.setSuffix(" mm")
|
||||||
|
self.spin_active_shift_x_mm.setMinimumWidth(_SPIN_MIN_W)
|
||||||
|
mform.addRow("Shift X:", self.spin_active_shift_x_mm)
|
||||||
|
|
||||||
|
self.spin_active_shift_y_mm = QDoubleSpinBox()
|
||||||
|
self.spin_active_shift_y_mm.setRange(-1e5, 1e5)
|
||||||
|
self.spin_active_shift_y_mm.setDecimals(4)
|
||||||
|
self.spin_active_shift_y_mm.setSuffix(" mm")
|
||||||
|
self.spin_active_shift_y_mm.setMinimumWidth(_SPIN_MIN_W)
|
||||||
|
mform.addRow("Shift Y:", self.spin_active_shift_y_mm)
|
||||||
|
mform_box.addLayout(mform)
|
||||||
|
panel_l.addWidget(self.grp_manual_adjust)
|
||||||
|
|
||||||
|
# ---- Nudge Step Sizes ------------------------------------------------
|
||||||
|
self.grp_step_sizes, sl = _group("Nudge Step Sizes")
|
||||||
|
sform = _form()
|
||||||
|
self.spin_step_translate_mm = QDoubleSpinBox()
|
||||||
|
self.spin_step_translate_mm.setRange(0.0001, 1000.0)
|
||||||
|
self.spin_step_translate_mm.setDecimals(4)
|
||||||
|
self.spin_step_translate_mm.setSuffix(" mm")
|
||||||
|
self.spin_step_translate_mm.setValue(0.01)
|
||||||
|
self.spin_step_translate_mm.setMinimumWidth(_SPIN_MIN_W)
|
||||||
|
sform.addRow("Translate step:", self.spin_step_translate_mm)
|
||||||
|
|
||||||
|
self.spin_step_rotate_deg = QDoubleSpinBox()
|
||||||
|
self.spin_step_rotate_deg.setRange(0.001, 90.0)
|
||||||
|
self.spin_step_rotate_deg.setDecimals(3)
|
||||||
|
self.spin_step_rotate_deg.setSuffix(" °")
|
||||||
|
self.spin_step_rotate_deg.setValue(0.1)
|
||||||
|
self.spin_step_rotate_deg.setMinimumWidth(_SPIN_MIN_W)
|
||||||
|
sform.addRow("Rotate step:", self.spin_step_rotate_deg)
|
||||||
|
|
||||||
|
self.spin_step_multiplier = QDoubleSpinBox()
|
||||||
|
self.spin_step_multiplier.setRange(1.0, 1000.0)
|
||||||
|
self.spin_step_multiplier.setDecimals(1)
|
||||||
|
self.spin_step_multiplier.setValue(10.0)
|
||||||
|
self.spin_step_multiplier.setMinimumWidth(_SPIN_MIN_W)
|
||||||
|
sform.addRow("Coarse × (Shift):", self.spin_step_multiplier)
|
||||||
|
sl.addLayout(sform)
|
||||||
|
sl.addWidget(_wrap_label(
|
||||||
|
"Arrow keys nudge X/Y translation; Q/E nudge rotation (CCW/CW). "
|
||||||
|
"Hold Shift for the coarse step. Click the image once so it has "
|
||||||
|
"keyboard focus.", _CSS_HINT))
|
||||||
|
panel_l.addWidget(self.grp_step_sizes)
|
||||||
|
|
||||||
|
# ---- Mask Threshold ---------------------------------------------------
|
||||||
|
self.grp_mask_threshold, tl = _group("Mask Threshold")
|
||||||
|
tform = _form()
|
||||||
|
self.spin_mask_threshold_mv = QDoubleSpinBox()
|
||||||
|
self.spin_mask_threshold_mv.setRange(-500.0, 500.0)
|
||||||
|
self.spin_mask_threshold_mv.setDecimals(3)
|
||||||
|
self.spin_mask_threshold_mv.setSuffix(" mV")
|
||||||
|
self.spin_mask_threshold_mv.setValue(dc_threshold_mv)
|
||||||
|
self.spin_mask_threshold_mv.setMinimumWidth(_SPIN_MIN_W)
|
||||||
|
tform.addRow("DC threshold:", self.spin_mask_threshold_mv)
|
||||||
|
tl.addLayout(tform)
|
||||||
|
panel_l.addWidget(self.grp_mask_threshold)
|
||||||
|
|
||||||
|
# ---- Cross-Correlate (FFT) -----------------------------------------
|
||||||
|
self.grp_correlate, cl = _group("Cross-Correlate (FFT)")
|
||||||
|
cform = _form()
|
||||||
|
self.combo_correlate_source = QComboBox()
|
||||||
|
for label, sources in self._CORRELATE_SOURCES:
|
||||||
|
self.combo_correlate_source.addItem(label, sources)
|
||||||
|
cform.addRow("Correlate on:", self.combo_correlate_source)
|
||||||
|
|
||||||
|
self.spin_correlate_search_deg = QDoubleSpinBox()
|
||||||
|
self.spin_correlate_search_deg.setRange(0.0, 180.0)
|
||||||
|
self.spin_correlate_search_deg.setSingleStep(1.0)
|
||||||
|
self.spin_correlate_search_deg.setDecimals(1)
|
||||||
|
self.spin_correlate_search_deg.setSuffix(" °")
|
||||||
|
self.spin_correlate_search_deg.setValue(6.0)
|
||||||
|
self.spin_correlate_search_deg.setMinimumWidth(_SPIN_MIN_W)
|
||||||
|
cform.addRow("Rotation search (±):", self.spin_correlate_search_deg)
|
||||||
|
cl.addLayout(cform)
|
||||||
|
self.btn_auto_correlate = QPushButton("Auto Cross-Correlate (vs Reference)")
|
||||||
|
cl.addWidget(self.btn_auto_correlate)
|
||||||
|
cl.addWidget(_wrap_label(
|
||||||
|
"Finds each non-reference angle's rotation *and* translation by "
|
||||||
|
"cross-correlating its image against the reference's — the stage's "
|
||||||
|
"reported angle is only the starting point of the search, and both "
|
||||||
|
"of its signs are tried. Run this first, then nudge only for small "
|
||||||
|
"corrections.", _CSS_HINT))
|
||||||
|
panel_l.addWidget(self.grp_correlate)
|
||||||
|
|
||||||
|
# ---- Actions ------------------------------------------------------
|
||||||
|
grp_actions, acl = _group("Actions")
|
||||||
|
self.btn_auto_derotate = QPushButton("Auto De-rotate (use known angles)")
|
||||||
|
self.btn_save = QPushButton("Save Alignment")
|
||||||
|
self.btn_clear = QPushButton("Clear Alignment…")
|
||||||
|
self.btn_close = QPushButton("Close")
|
||||||
|
for btn in (self.btn_auto_derotate, self.btn_save, self.btn_clear, self.btn_close):
|
||||||
|
acl.addWidget(btn)
|
||||||
|
panel_l.addWidget(grp_actions)
|
||||||
|
|
||||||
|
self.lbl_status = _wrap_label("", _CSS_MUTED)
|
||||||
|
panel_l.addWidget(self.lbl_status)
|
||||||
|
panel_l.addStretch()
|
||||||
|
|
||||||
|
root.addWidget(_scroll_panel(panel, 320))
|
||||||
|
|
||||||
|
self.combo_active_angle.currentIndexChanged.connect(self._on_active_angle_changed)
|
||||||
|
self.spin_active_rotation_deg.editingFinished.connect(self._on_rotation_spin_edited)
|
||||||
|
self.spin_active_shift_x_mm.editingFinished.connect(self._on_shift_spin_edited)
|
||||||
|
self.spin_active_shift_y_mm.editingFinished.connect(self._on_shift_spin_edited)
|
||||||
|
self.spin_mask_threshold_mv.editingFinished.connect(self._on_mask_threshold_edited)
|
||||||
|
self.btn_auto_derotate.clicked.connect(self._on_auto_derotate)
|
||||||
|
self.btn_auto_correlate.clicked.connect(self._on_auto_correlate)
|
||||||
|
self.btn_save.clicked.connect(self._on_save)
|
||||||
|
self.btn_clear.clicked.connect(self._on_clear)
|
||||||
|
self.btn_close.clicked.connect(self.close)
|
||||||
|
self.canvas.nudge_translate.connect(self._on_nudge_translate)
|
||||||
|
self.canvas.nudge_rotate.connect(self._on_nudge_rotate)
|
||||||
|
|
||||||
|
self.combo_active_angle.blockSignals(True)
|
||||||
|
self.combo_active_angle.setCurrentIndex(self._active_angle)
|
||||||
|
self.combo_active_angle.blockSignals(False)
|
||||||
|
self._on_active_angle_changed(self._active_angle)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Mask preparation (initial CH4 fetch + threshold + downsample)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _start_mask_prep(self, cached_dc4_mv: dict[int, np.ndarray]):
|
||||||
|
self._dc4_mv = dict(cached_dc4_mv)
|
||||||
|
missing = [a for a in range(self._sras.n_angles) if a not in self._dc4_mv]
|
||||||
|
if not missing:
|
||||||
|
self._finish_mask_prep()
|
||||||
|
return
|
||||||
|
self.lbl_status.setText(f"Preparing masks: 0/{len(missing)} angle(s) needed…")
|
||||||
|
started = self._parent._run_worker(
|
||||||
|
"manual_align_masks", Ch4MaskWorker(self._sras, missing),
|
||||||
|
connect=(
|
||||||
|
("angle_done", self._on_mask_angle_done),
|
||||||
|
("error", lambda msg: self.lbl_status.setText(f"Mask prep error: {msg}")),
|
||||||
|
),
|
||||||
|
on_done=self._finish_mask_prep)
|
||||||
|
if not started:
|
||||||
|
self.lbl_status.setText(
|
||||||
|
"Could not start mask preparation (busy) — close and reopen.")
|
||||||
|
|
||||||
|
def _on_mask_angle_done(self, angle_idx: int, dc4_mv: np.ndarray):
|
||||||
|
self._dc4_mv[angle_idx] = dc4_mv
|
||||||
|
self.lbl_status.setText(
|
||||||
|
f"Preparing masks: {len(self._dc4_mv)}/{self._sras.n_angles} ready…")
|
||||||
|
|
||||||
|
def _finish_mask_prep(self):
|
||||||
|
if len(self._dc4_mv) < self._sras.n_angles:
|
||||||
|
return # a mask-worker error left some angles unfetched
|
||||||
|
# Rows and columns get their own factor. A real scan is ~7500 frames
|
||||||
|
# wide but only ~750 rows tall, so one shared factor sized for the
|
||||||
|
# frames would throw away 8x more row detail than the preview needs and
|
||||||
|
# leave the overlay too coarse in y to judge alignment by eye.
|
||||||
|
max_rows = max(img.shape[0] for img in self._dc4_mv.values())
|
||||||
|
max_cols = max(img.shape[1] for img in self._dc4_mv.values())
|
||||||
|
self._downsample = (
|
||||||
|
max(1, int(np.ceil(max_rows / self._MAX_PREVIEW_DIM))),
|
||||||
|
max(1, int(np.ceil(max_cols / self._MAX_PREVIEW_DIM))))
|
||||||
|
self._recompute_masks_small()
|
||||||
|
self._rebuild_preview_canvas()
|
||||||
|
self._set_controls_enabled(True)
|
||||||
|
self.lbl_status.setText("Ready.")
|
||||||
|
|
||||||
|
def _recompute_masks_small(self):
|
||||||
|
"""Threshold + downsample every angle's already-in-memory full-res
|
||||||
|
CH4 mV image. Cheap (a compare + block-mean), so this re-runs in
|
||||||
|
full whenever the mask-threshold spin box changes — no re-fetch.
|
||||||
|
Purely for the overlay's visuals: no alignment geometry depends on this
|
||||||
|
threshold, only which pixels the overlay paints."""
|
||||||
|
threshold = self.spin_mask_threshold_mv.value()
|
||||||
|
fy, fx = self._downsample
|
||||||
|
self._masks_small = {
|
||||||
|
a: compute.block_mean_2d((img >= threshold).astype(np.float32), fy, fx)
|
||||||
|
for a, img in self._dc4_mv.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Preview canvas: full rebuild vs. incremental single-layer refresh
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _rebuild_preview_canvas(self):
|
||||||
|
"""Full geometry rebuild: recomputes the shared preview canvas's
|
||||||
|
origin/shape (rotation can grow the union bbox — translation alone
|
||||||
|
cannot, per the padding baked in via _PREVIEW_MARGIN_FRAC) and every
|
||||||
|
angle's reprojected mask layer. Triggered by: dialog open,
|
||||||
|
mask-threshold change, Auto De-rotate, a rotation nudge/edit of the
|
||||||
|
active angle. NOT triggered by a translation-only nudge — see
|
||||||
|
_refresh_active_preview_layer."""
|
||||||
|
dx_ref, dy_ref = compute.pixel_pitch_mm(self._sras, self._ref_angle_idx)
|
||||||
|
fy, fx = self._downsample
|
||||||
|
pitch = (dx_ref * fx, dy_ref * fy)
|
||||||
|
origin, shape = compute.canvas_for_params(
|
||||||
|
self._sras, self._ref_angle_idx, pitch, self._angle_params,
|
||||||
|
margin_frac=self._PREVIEW_MARGIN_FRAC, snap=False)
|
||||||
|
self._preview_origin_mm, self._preview_shape = origin, shape
|
||||||
|
self._preview_pitch_mm = pitch
|
||||||
|
self._preview_layers = {
|
||||||
|
a: self._reproject(a) for a in range(self._sras.n_angles)
|
||||||
|
}
|
||||||
|
self._redraw_overlay()
|
||||||
|
|
||||||
|
def _reproject(self, angle_idx: int) -> np.ndarray:
|
||||||
|
"""One angle's downsampled mask on the current preview canvas.
|
||||||
|
src_downsample must match _masks_small's block-mean factors, or the
|
||||||
|
layer lands magnified and offset instead of where the alignment
|
||||||
|
actually puts it."""
|
||||||
|
p = self._angle_params[angle_idx]
|
||||||
|
return compute.reproject_mask(
|
||||||
|
self._sras, angle_idx, self._ref_angle_idx,
|
||||||
|
self._masks_small[angle_idx], p.rotation_deg, p.shift_mm,
|
||||||
|
self._preview_pitch_mm, self._preview_origin_mm, self._preview_shape,
|
||||||
|
src_downsample=self._downsample)
|
||||||
|
|
||||||
|
def _refresh_active_preview_layer(self):
|
||||||
|
"""Cheap path for a translation-only nudge/edit of the active angle:
|
||||||
|
reproject just that one angle's downsampled mask onto the *existing*
|
||||||
|
preview canvas — every other angle's cached layer is untouched."""
|
||||||
|
self._preview_layers[self._active_angle] = self._reproject(self._active_angle)
|
||||||
|
self._redraw_overlay()
|
||||||
|
|
||||||
|
def _redraw_overlay(self):
|
||||||
|
"""Alpha-composite every angle's colored mask layer into one RGBA
|
||||||
|
image ("all thresholds overlaid with varying opacity"). Each angle
|
||||||
|
keeps a fixed, distinct color regardless of which is active; the
|
||||||
|
active angle is drawn last (on top) at a visibly higher alpha so
|
||||||
|
it's easy to track while nudging."""
|
||||||
|
if not self._preview_layers:
|
||||||
|
return # mask prep hasn't finished yet — nothing to draw
|
||||||
|
n_rows, n_cols = self._preview_shape
|
||||||
|
rgba = np.zeros((n_rows, n_cols, 4), dtype=np.float32)
|
||||||
|
order = sorted(range(self._sras.n_angles), key=lambda a: a == self._active_angle)
|
||||||
|
for a in order:
|
||||||
|
layer = self._preview_layers.get(a)
|
||||||
|
if layer is None:
|
||||||
|
continue
|
||||||
|
alpha = self._ACTIVE_ALPHA if a == self._active_angle else self._BASE_ALPHA
|
||||||
|
color = self._angle_colors[a]
|
||||||
|
fg_a = layer * alpha
|
||||||
|
for c in range(3):
|
||||||
|
rgba[..., c] = color[c] * fg_a + rgba[..., c] * rgba[..., 3] * (1 - fg_a)
|
||||||
|
rgba[..., 3] = fg_a + rgba[..., 3] * (1 - fg_a)
|
||||||
|
|
||||||
|
x0, y0 = self._preview_origin_mm
|
||||||
|
dx, dy = self._preview_pitch_mm
|
||||||
|
x_axis = x0 + np.arange(n_cols) * dx
|
||||||
|
y_axis = y0 + np.arange(n_rows) * dy
|
||||||
|
extent = [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
|
||||||
|
y_axis[-1] + dy / 2, y_axis[0] - dy / 2]
|
||||||
|
title = (f"Angle {self._active_angle} active "
|
||||||
|
f"({self._sras.angles_deg[self._active_angle]:.1f}°)")
|
||||||
|
self.canvas.show_overlay(rgba, extent, title)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Angle selection / nudge / edit handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_active_angle_changed(self, angle_idx: int):
|
||||||
|
self._active_angle = angle_idx
|
||||||
|
is_ref = angle_idx == self._ref_angle_idx
|
||||||
|
self.grp_manual_adjust.setEnabled(self._masks_ready and not is_ref)
|
||||||
|
self.lbl_active_note.setText(
|
||||||
|
"Reference angle — defines the shared origin, not adjustable." if is_ref else "")
|
||||||
|
self._sync_active_spinboxes()
|
||||||
|
self._redraw_overlay()
|
||||||
|
|
||||||
|
def _sync_active_spinboxes(self):
|
||||||
|
p = self._angle_params[self._active_angle]
|
||||||
|
for spin, val in ((self.spin_active_rotation_deg, p.rotation_deg),
|
||||||
|
(self.spin_active_shift_x_mm, p.shift_mm[0]),
|
||||||
|
(self.spin_active_shift_y_mm, p.shift_mm[1])):
|
||||||
|
spin.blockSignals(True)
|
||||||
|
spin.setValue(val)
|
||||||
|
spin.blockSignals(False)
|
||||||
|
|
||||||
|
def _on_nudge_translate(self, dir_x: int, dir_y: int, coarse: bool):
|
||||||
|
if not self._masks_ready or self._active_angle == self._ref_angle_idx:
|
||||||
|
return
|
||||||
|
step = self.spin_step_translate_mm.value()
|
||||||
|
if coarse:
|
||||||
|
step *= self.spin_step_multiplier.value()
|
||||||
|
p = self._angle_params[self._active_angle]
|
||||||
|
p.shift_mm = (p.shift_mm[0] + dir_x * step, p.shift_mm[1] + dir_y * step)
|
||||||
|
self._sync_active_spinboxes()
|
||||||
|
self._refresh_active_preview_layer()
|
||||||
|
|
||||||
|
def _on_nudge_rotate(self, direction: int, coarse: bool):
|
||||||
|
if not self._masks_ready or self._active_angle == self._ref_angle_idx:
|
||||||
|
return
|
||||||
|
step = self.spin_step_rotate_deg.value()
|
||||||
|
if coarse:
|
||||||
|
step *= self.spin_step_multiplier.value()
|
||||||
|
self._angle_params[self._active_angle].rotation_deg += direction * step
|
||||||
|
self._sync_active_spinboxes()
|
||||||
|
self._rebuild_preview_canvas()
|
||||||
|
|
||||||
|
def _on_rotation_spin_edited(self):
|
||||||
|
if self._active_angle == self._ref_angle_idx:
|
||||||
|
return
|
||||||
|
self._angle_params[self._active_angle].rotation_deg = self.spin_active_rotation_deg.value()
|
||||||
|
self._rebuild_preview_canvas()
|
||||||
|
|
||||||
|
def _on_shift_spin_edited(self):
|
||||||
|
if self._active_angle == self._ref_angle_idx:
|
||||||
|
return
|
||||||
|
p = self._angle_params[self._active_angle]
|
||||||
|
p.shift_mm = (self.spin_active_shift_x_mm.value(), self.spin_active_shift_y_mm.value())
|
||||||
|
self._refresh_active_preview_layer()
|
||||||
|
|
||||||
|
def _on_mask_threshold_edited(self):
|
||||||
|
if not self._masks_ready:
|
||||||
|
return
|
||||||
|
self._recompute_masks_small()
|
||||||
|
self._rebuild_preview_canvas()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Actions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_auto_derotate(self):
|
||||||
|
"""Seed every angle's rotation from the stage's reported angle.
|
||||||
|
|
||||||
|
A starting point for nudging by eye, not an alignment: the stage's
|
||||||
|
sign convention relative to this module's is not knowable from the
|
||||||
|
file, so the sign that lines the scans up is whichever of the two looks
|
||||||
|
right in the overlay. Auto Cross-Correlate decides that from the images
|
||||||
|
instead, and is the button to reach for first.
|
||||||
|
"""
|
||||||
|
sign = -1.0 if self._derotate_sign_flipped else 1.0
|
||||||
|
self._derotate_sign_flipped = not self._derotate_sign_flipped
|
||||||
|
n_changed = 0
|
||||||
|
for a in range(self._sras.n_angles):
|
||||||
|
if a == self._ref_angle_idx:
|
||||||
|
continue
|
||||||
|
self._angle_params[a].rotation_deg = sign * compute.nominal_delta_deg(
|
||||||
|
self._sras, a, self._ref_angle_idx)
|
||||||
|
n_changed += 1
|
||||||
|
self._sync_active_spinboxes()
|
||||||
|
self._rebuild_preview_canvas()
|
||||||
|
self.lbl_status.setText(
|
||||||
|
f"Rotation set to the stage angle ({'−' if sign < 0 else '+'}delta) "
|
||||||
|
f"for {n_changed} angle(s); translation untouched. Click again to "
|
||||||
|
"try the opposite sign.")
|
||||||
|
|
||||||
|
def _on_auto_correlate(self):
|
||||||
|
if not self._masks_ready:
|
||||||
|
return
|
||||||
|
angles = [a for a in range(self._sras.n_angles) if a != self._ref_angle_idx]
|
||||||
|
if not angles:
|
||||||
|
return
|
||||||
|
worker = CrossCorrelateWorker(
|
||||||
|
self._sras, self._ref_angle_idx, angles, self._dc4_mv,
|
||||||
|
sources=self.combo_correlate_source.currentData(),
|
||||||
|
dc_threshold_mv=self.spin_mask_threshold_mv.value(),
|
||||||
|
search_deg=self.spin_correlate_search_deg.value())
|
||||||
|
self._correlate_done_count = 0
|
||||||
|
self._correlate_total = len(angles)
|
||||||
|
self._fit_notes = {}
|
||||||
|
self._set_controls_enabled(False)
|
||||||
|
self.lbl_status.setText(f"Cross-correlating: 0/{self._correlate_total} angle(s)…")
|
||||||
|
started = self._parent._run_worker(
|
||||||
|
"manual_align_correlate", worker,
|
||||||
|
connect=(
|
||||||
|
("angle_done", self._on_correlate_angle_done),
|
||||||
|
("error", self._on_correlate_error),
|
||||||
|
),
|
||||||
|
on_done=self._finish_auto_correlate)
|
||||||
|
if not started:
|
||||||
|
self._set_controls_enabled(True)
|
||||||
|
self.lbl_status.setText("Could not start cross-correlation (busy) — try again.")
|
||||||
|
|
||||||
|
def _on_correlate_angle_done(self, angle_idx: int, rotation_deg: float,
|
||||||
|
shift_x_mm: float, shift_y_mm: float,
|
||||||
|
score: float, source: str):
|
||||||
|
self._angle_params[angle_idx] = ManualAngleParams(rotation_deg, (shift_x_mm, shift_y_mm))
|
||||||
|
self._fit_notes[angle_idx] = (score, source)
|
||||||
|
self._correlate_done_count += 1
|
||||||
|
self.lbl_status.setText(
|
||||||
|
f"Cross-correlating: {self._correlate_done_count}/{self._correlate_total} angle(s)…")
|
||||||
|
|
||||||
|
def _on_correlate_error(self, msg: str):
|
||||||
|
self.lbl_status.setText(f"Cross-correlation error: {msg}")
|
||||||
|
|
||||||
|
def _finish_auto_correlate(self):
|
||||||
|
self._sync_active_spinboxes()
|
||||||
|
self._rebuild_preview_canvas()
|
||||||
|
self._set_controls_enabled(True)
|
||||||
|
self.lbl_status.setText(
|
||||||
|
f"Cross-correlated {self._correlate_done_count} angle(s) against "
|
||||||
|
f"Angle {self._ref_angle_idx}.\n" + self._fit_report())
|
||||||
|
|
||||||
|
def _fit_report(self) -> str:
|
||||||
|
"""Per-angle registration quality, worst first.
|
||||||
|
|
||||||
|
Surfaced rather than buried because a single bad acquisition (stage
|
||||||
|
glitch, laser dropout) registers poorly and would otherwise be fused in
|
||||||
|
silently — seeing which angle it is, is what makes dropping it with
|
||||||
|
sras_edit_scans.py actionable. The deviation from the stage's own
|
||||||
|
reported angle is shown alongside: a large one means the search and the
|
||||||
|
stage disagree, which is either a genuine mechanical error or a sign
|
||||||
|
that this angle's fit is not to be trusted.
|
||||||
|
"""
|
||||||
|
if not self._fit_notes:
|
||||||
|
return ""
|
||||||
|
rows = sorted(self._fit_notes.items(), key=lambda kv: kv[1][0])
|
||||||
|
worst = rows[0]
|
||||||
|
lines = [f"Worst fit: angle {worst[0]} (score {worst[1][0]:.3f}, "
|
||||||
|
f"{worst[1][1]})."]
|
||||||
|
drifted = []
|
||||||
|
for a, _note in rows:
|
||||||
|
nominal = compute.nominal_delta_deg(self._sras, a, self._ref_angle_idx)
|
||||||
|
got = self._angle_params[a].rotation_deg
|
||||||
|
dev = min(abs(got - nominal), abs(got + nominal))
|
||||||
|
if dev > 1.0:
|
||||||
|
drifted.append(f"{a} ({dev:.2f}°)")
|
||||||
|
if drifted:
|
||||||
|
lines.append("Rotation differs from the stage angle by >1° for "
|
||||||
|
"angle(s) " + ", ".join(drifted) + ".")
|
||||||
|
lines.append("Nudge from here for any remaining fine correction.")
|
||||||
|
return " ".join(lines)
|
||||||
|
|
||||||
|
def _on_save(self):
|
||||||
|
threshold = self.spin_mask_threshold_mv.value()
|
||||||
|
resolved = dict(self._angle_params) # already concrete floats
|
||||||
|
try:
|
||||||
|
path = save_manual_alignment(self._sras, self._ref_angle_idx, threshold, resolved)
|
||||||
|
result = build_manual_alignment(self._sras, self._ref_angle_idx,
|
||||||
|
threshold, resolved)
|
||||||
|
except OSError as exc:
|
||||||
|
QMessageBox.warning(self, "Save Alignment Failed", str(exc))
|
||||||
|
return
|
||||||
|
self.lbl_status.setText(f"Saved to {path.name}.")
|
||||||
|
self.alignment_saved.emit(result, str(path))
|
||||||
|
|
||||||
|
def _on_clear(self):
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Clear Alignment",
|
||||||
|
"This resets every angle back to raw/unaligned (0° rotation, no "
|
||||||
|
"shift) and deletes the saved alignment file for this scan, if "
|
||||||
|
"any. This cannot be undone. Continue?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No)
|
||||||
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
existed = delete_manual_alignment(self._sras)
|
||||||
|
except OSError as exc:
|
||||||
|
QMessageBox.warning(self, "Clear Alignment Failed",
|
||||||
|
f"Could not delete the saved alignment file: {exc}")
|
||||||
|
return
|
||||||
|
self._angle_params = {a: ManualAngleParams() for a in range(self._sras.n_angles)}
|
||||||
|
self._fit_notes = {}
|
||||||
|
self._sync_active_spinboxes()
|
||||||
|
self._rebuild_preview_canvas()
|
||||||
|
self.lbl_status.setText(
|
||||||
|
"Alignment cleared; saved file removed." if existed
|
||||||
|
else "Alignment cleared (there was no saved file).")
|
||||||
|
self.alignment_cleared.emit()
|
||||||
|
|
||||||
|
def _set_controls_enabled(self, enabled: bool):
|
||||||
|
self._masks_ready = enabled
|
||||||
|
self.combo_active_angle.setEnabled(enabled)
|
||||||
|
self.grp_manual_adjust.setEnabled(enabled and self._active_angle != self._ref_angle_idx)
|
||||||
|
self.grp_step_sizes.setEnabled(enabled)
|
||||||
|
self.grp_mask_threshold.setEnabled(enabled)
|
||||||
|
self.grp_correlate.setEnabled(enabled)
|
||||||
|
self.btn_auto_derotate.setEnabled(enabled)
|
||||||
|
self.btn_save.setEnabled(enabled)
|
||||||
|
self.btn_clear.setEnabled(enabled)
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -186,7 +186,7 @@ def test_roi_and_csv_export(ctx):
|
|||||||
assert win.btn_export_roi.isEnabled(), "Export ROI enabled"
|
assert win.btn_export_roi.isEnabled(), "Export ROI enabled"
|
||||||
|
|
||||||
csv_path = ctx.tmpdir / "roi.csv"
|
csv_path = ctx.tmpdir / "roi.csv"
|
||||||
with patch("sras_viewer.QFileDialog.getSaveFileName",
|
with patch("sras_viewer.main_window.QFileDialog.getSaveFileName",
|
||||||
return_value=(str(csv_path), "")):
|
return_value=(str(csv_path), "")):
|
||||||
win._on_export_roi_csv()
|
win._on_export_roi_csv()
|
||||||
assert csv_path.exists(), "ROI CSV written"
|
assert csv_path.exists(), "ROI CSV written"
|
||||||
@@ -195,7 +195,7 @@ def test_roi_and_csv_export(ctx):
|
|||||||
f"ROI CSV has {len(body)} lines for {npix} pixels (want header + one per pixel)"
|
f"ROI CSV has {len(body)} lines for {npix} pixels (want header + one per pixel)"
|
||||||
|
|
||||||
img_csv = ctx.tmpdir / "img.csv"
|
img_csv = ctx.tmpdir / "img.csv"
|
||||||
with patch("sras_viewer.QFileDialog.getSaveFileName",
|
with patch("sras_viewer.main_window.QFileDialog.getSaveFileName",
|
||||||
return_value=(str(img_csv), "")):
|
return_value=(str(img_csv), "")):
|
||||||
win._on_export_csv()
|
win._on_export_csv()
|
||||||
assert img_csv.exists(), "image CSV written"
|
assert img_csv.exists(), "image CSV written"
|
||||||
@@ -426,7 +426,7 @@ def test_stale_schema_sidecar_ignored(ctx):
|
|||||||
|
|
||||||
def test_clear_with_confirmation(ctx):
|
def test_clear_with_confirmation(ctx):
|
||||||
win, dlg, s = ctx.win, ctx.dlg, ctx.s
|
win, dlg, s = ctx.win, ctx.dlg, ctx.s
|
||||||
with patch("sras_viewer.QMessageBox.question",
|
with patch("sras_viewer.dialogs.QMessageBox.question",
|
||||||
return_value=QMessageBox.StandardButton.Yes):
|
return_value=QMessageBox.StandardButton.Yes):
|
||||||
dlg._on_clear()
|
dlg._on_clear()
|
||||||
assert not ctx.sidecar.exists(), "sidecar file deleted"
|
assert not ctx.sidecar.exists(), "sidecar file deleted"
|
||||||
|
|||||||
Reference in New Issue
Block a user