From 1545e645f1598dbd8e7e15ffbc848fd69448dad8 Mon Sep 17 00:00:00 2001 From: Peter Jacobson Date: Fri, 5 Jun 2026 15:43:05 +1000 Subject: [PATCH] Fix three GUI runtime errors surfaced while browsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. FFT reconstruct mode combo crash. currentIndexChanged emits the int index, which was wired straight to _update_reconstruct_status(res=...) — so changing the mode called int.imag_residual_norm and raised AttributeError. Drop the index via a lambda. 2. stm_background ROI preview crash. A step with fit_region='active_roi' fit to an ROI crashed every thumbnail/preview render (render_scan_image failed): apply_processing_state had no roi_set, _resolve_mask_roi_param warned "ROI fit mask ignored" and returned None, but the kernel still received active_roi + mask=None and raised. Degrade to a whole-image fit when the mask can't be resolved, matching the warning. 3. "missing font family 'Courier'" warning + ~95 ms alias lookup. Many labels used QFont("Courier", n); "Courier" is only an alias on macOS/Windows. Add typography.mono_font() backed by the real platform fixed-width family (QFontDatabase FixedFont, like the existing ui_font) and route every mono label through it. Drop now-unused QFont/Path imports. Tests: active_roi-without-roi_set degrade (real kernel) and a reconstruct-mode toggle that exercises the combo signal. Co-Authored-By: Claude Opus 4.8 --- probeflow/gui/convert/__init__.py | 4 +- probeflow/gui/dialogs/fft_viewer.py | 14 +++---- .../dialogs/fft_viewer_reconstruct_mixin.py | 6 ++- probeflow/gui/dialogs/image_info.py | 8 ++-- probeflow/gui/dialogs/periodic_filter.py | 5 +-- .../gui/lattice_grid/real_space_panel.py | 8 ++-- probeflow/gui/typography.py | 41 ++++++++++++++++++- probeflow/processing/state.py | 10 ++++- tests/test_fft_phase_gui.py | 13 ++++++ tests/test_processing_state.py | 29 +++++++++++++ 10 files changed, 114 insertions(+), 24 deletions(-) diff --git a/probeflow/gui/convert/__init__.py b/probeflow/gui/convert/__init__.py index 9adb3b2..9889733 100644 --- a/probeflow/gui/convert/__init__.py +++ b/probeflow/gui/convert/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from probeflow.gui.typography import ui_font +from probeflow.gui.typography import mono_font, ui_font from PySide6.QtCore import Qt from PySide6.QtGui import QCursor, QFont from PySide6.QtWidgets import ( @@ -95,7 +95,7 @@ def __init__(self, t: dict, cfg: dict, parent=None): self.log_text = QTextEdit() self.log_text.setReadOnly(True) - self.log_text.setFont(QFont("Courier", 11)) + self.log_text.setFont(mono_font(11)) lay.addWidget(self.log_text, 1) if cfg.get("input_dir"): diff --git a/probeflow/gui/dialogs/fft_viewer.py b/probeflow/gui/dialogs/fft_viewer.py index 1f2c802..e3d2310 100644 --- a/probeflow/gui/dialogs/fft_viewer.py +++ b/probeflow/gui/dialogs/fft_viewer.py @@ -5,7 +5,7 @@ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg from matplotlib.figure import Figure -from probeflow.gui.typography import ui_font +from probeflow.gui.typography import mono_font, ui_font from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QAction, QActionGroup, QFont from PySide6.QtWidgets import ( @@ -456,7 +456,7 @@ def _build_fft_column(self) -> QVBoxLayout: cursor_title = QLabel("Cursor") cursor_title.setFont(ui_font(9, weight=QFont.Bold)) self._cursor_readout_lbl = QLabel("Move over the FFT") - self._cursor_readout_lbl.setFont(QFont("Courier", 8)) + self._cursor_readout_lbl.setFont(mono_font(8)) self._cursor_readout_lbl.setWordWrap(True) side_lay.addWidget(cursor_title) side_lay.addWidget(self._cursor_readout_lbl) @@ -507,7 +507,7 @@ def _build_fft_column(self) -> QVBoxLayout: # Measurement summary — always visible at the top self._grid_measure_lbl = QLabel("No grid — click Draw Grid to start") - self._grid_measure_lbl.setFont(QFont("Courier", 9)) + self._grid_measure_lbl.setFont(mono_font(9)) self._grid_measure_lbl.setWordWrap(True) self._grid_measure_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) grid_outer_lay.addWidget(self._grid_measure_lbl) @@ -648,7 +648,7 @@ def _build_fft_column(self) -> QVBoxLayout: ref_grid.setColumnStretch(1, 1) ref_grid.setColumnStretch(3, 1) self._bragg_radius_lbl = QLabel("Shells: —") - self._bragg_radius_lbl.setFont(QFont("Courier", 8)) + self._bragg_radius_lbl.setFont(mono_font(8)) self._bragg_radius_lbl.setWordWrap(True) self._bragg_radius_lbl.setMaximumHeight(34) self._bragg_radius_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) @@ -665,7 +665,7 @@ def _build_fft_column(self) -> QVBoxLayout: self._fft_measured_lbl = QLabel( "Draw a grid and select a known structure to see the comparison." ) - self._fft_measured_lbl.setFont(QFont("Courier", 8)) + self._fft_measured_lbl.setFont(mono_font(8)) self._fft_measured_lbl.setWordWrap(True) self._fft_measured_lbl.setMinimumHeight(32) self._fft_measured_lbl.setMaximumHeight(54) @@ -728,7 +728,7 @@ def _build_fft_column(self) -> QVBoxLayout: self._fft_fill_combo.addItems(["NaN", "Background", "Zero"]) self._fft_correction_lbl = QLabel("Align a reciprocal grid to compute correction factors.") - self._fft_correction_lbl.setFont(QFont("Courier", 8)) + self._fft_correction_lbl.setFont(mono_font(8)) self._fft_correction_lbl.setWordWrap(True) self._fft_correction_lbl.setMinimumHeight(34) self._fft_correction_lbl.setMaximumHeight(60) @@ -880,7 +880,7 @@ def _build(self): info_lay.setContentsMargins(8, 6, 8, 4) info_lay.setSpacing(2) self._info_lbl = QLabel("") - self._info_lbl.setFont(QFont("Courier", 9)) + self._info_lbl.setFont(mono_font(9)) self._info_lbl.setAlignment(Qt.AlignTop | Qt.AlignLeft) info_lay.addWidget(self._info_lbl) left_col.addWidget(info_frame) diff --git a/probeflow/gui/dialogs/fft_viewer_reconstruct_mixin.py b/probeflow/gui/dialogs/fft_viewer_reconstruct_mixin.py index 41998af..4e473fa 100644 --- a/probeflow/gui/dialogs/fft_viewer_reconstruct_mixin.py +++ b/probeflow/gui/dialogs/fft_viewer_reconstruct_mixin.py @@ -95,7 +95,11 @@ def _build_reconstruct_tab(self) -> QWidget: "Remove selected: suppress the selected Fourier features and rebuild " "the corrected image. Keep selected: rebuild only the selected " "periodic component (everything else removed).")) - self._recon_mode_combo.currentIndexChanged.connect(self._update_reconstruct_status) + # Drop the combo's int index — _update_reconstruct_status treats its + # first positional arg as a reconstruction result, so passing the index + # straight through made it call res.imag_residual_norm on an int. + self._recon_mode_combo.currentIndexChanged.connect( + lambda _idx: self._update_reconstruct_status()) self._recon_mode_combo.setMaximumWidth(140) mode_row.addWidget(self._recon_mode_combo) mode_row.addStretch(1) diff --git a/probeflow/gui/dialogs/image_info.py b/probeflow/gui/dialogs/image_info.py index 77f498e..2636655 100644 --- a/probeflow/gui/dialogs/image_info.py +++ b/probeflow/gui/dialogs/image_info.py @@ -2,11 +2,9 @@ from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING from PySide6.QtCore import Qt -from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QDialog, QFormLayout, @@ -19,6 +17,8 @@ QWidget, ) +from probeflow.gui.typography import mono_font + if TYPE_CHECKING: from probeflow.core.metadata import ScanMetadata @@ -168,9 +168,7 @@ def _row(field: str, text: str) -> None: hist_lay.setContentsMargins(4, 4, 4, 4) hist_text = QPlainTextEdit() hist_text.setReadOnly(True) - mono_font = QFont("Menlo" if Path("/System/Library/Fonts/Menlo.ttc").exists() else "Courier") - mono_font.setPointSize(9) - hist_text.setFont(mono_font) + hist_text.setFont(mono_font(9)) hist_text.setPlainText(processing_history_text or "(No processing history)") hist_lay.addWidget(hist_text) tabs.addTab(hist_widget, "Processing History") diff --git a/probeflow/gui/dialogs/periodic_filter.py b/probeflow/gui/dialogs/periodic_filter.py index a3c0887..bfd5914 100644 --- a/probeflow/gui/dialogs/periodic_filter.py +++ b/probeflow/gui/dialogs/periodic_filter.py @@ -4,9 +4,8 @@ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg from matplotlib.figure import Figure -from probeflow.gui.typography import ui_font +from probeflow.gui.typography import mono_font, ui_font from PySide6.QtCore import Qt -from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QFrame, QHBoxLayout, QLabel, QPushButton, QSlider, QVBoxLayout, QWidget, ) @@ -97,7 +96,7 @@ def _build(self): info_lay.setContentsMargins(8, 6, 8, 4) info_lay.setSpacing(2) self._info_lbl = QLabel("") - self._info_lbl.setFont(QFont("Courier", 9)) + self._info_lbl.setFont(mono_font(9)) self._info_lbl.setAlignment(Qt.AlignTop | Qt.AlignLeft) info_lay.addWidget(self._info_lbl) info_lay.addStretch(1) diff --git a/probeflow/gui/lattice_grid/real_space_panel.py b/probeflow/gui/lattice_grid/real_space_panel.py index 2c93902..536febc 100644 --- a/probeflow/gui/lattice_grid/real_space_panel.py +++ b/probeflow/gui/lattice_grid/real_space_panel.py @@ -8,7 +8,7 @@ import numpy as np -from probeflow.gui.typography import ui_font +from probeflow.gui.typography import mono_font, ui_font from PySide6.QtCore import Qt from PySide6.QtGui import QFont from PySide6.QtWidgets import ( @@ -280,7 +280,7 @@ def _spin_row( meas_lay = QVBoxLayout(meas_grp) meas_lay.setContentsMargins(6, 6, 6, 4) self._meas_lbl = QLabel("") - self._meas_lbl.setFont(QFont("Courier", 8)) + self._meas_lbl.setFont(mono_font(8)) self._meas_lbl.setAlignment(Qt.AlignTop | Qt.AlignLeft) self._meas_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) self._meas_lbl.setWordWrap(True) @@ -393,7 +393,7 @@ def _spin_row( meas_dist_lay = QVBoxLayout(meas_dist_grp) meas_dist_lay.setContentsMargins(6, 6, 6, 4) self._measured_lbl = QLabel("(tune grid above)") - self._measured_lbl.setFont(QFont("Courier", 8)) + self._measured_lbl.setFont(mono_font(8)) self._measured_lbl.setAlignment(Qt.AlignTop | Qt.AlignLeft) self._measured_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) meas_dist_lay.addWidget(self._measured_lbl) @@ -405,7 +405,7 @@ def _spin_row( corr_lay = QVBoxLayout(corr_grp) corr_lay.setContentsMargins(6, 6, 6, 4) self._correction_lbl = QLabel("(enter ideal lattice above)") - self._correction_lbl.setFont(QFont("Courier", 8)) + self._correction_lbl.setFont(mono_font(8)) self._correction_lbl.setAlignment(Qt.AlignTop | Qt.AlignLeft) self._correction_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) self._correction_lbl.setWordWrap(True) diff --git a/probeflow/gui/typography.py b/probeflow/gui/typography.py index 598d4f6..48cbb3c 100644 --- a/probeflow/gui/typography.py +++ b/probeflow/gui/typography.py @@ -30,6 +30,7 @@ } _UI_FAMILY: str | None = None +_MONO_FAMILY: str | None = None def ui_family() -> str: @@ -47,6 +48,27 @@ def ui_family() -> str: return _UI_FAMILY +def mono_family() -> str: + """Return the platform fixed-width font family (Menlo on macOS), cached. + + Asking Qt for the real system monospace family avoids the costly font-alias + lookup — and the "missing font family 'Courier'" warning — that an explicit + ``QFont("Courier", ...)`` triggers on macOS/Windows, where "Courier" is only + an alias rather than an installed family. + """ + global _MONO_FAMILY + if _MONO_FAMILY is None: + family = "" + try: + from PySide6.QtGui import QFontDatabase + + family = QFontDatabase.systemFont(QFontDatabase.FixedFont).family() + except Exception: + family = "" + _MONO_FAMILY = family or "Monospace" + return _MONO_FAMILY + + def scaled(pt: int) -> int: """Map a legacy point size onto the comfortable scale.""" return _SIZE_MAP.get(int(pt), int(pt)) @@ -64,4 +86,21 @@ def ui_font(pt: int, *, bold: bool = False, weight=None) -> "QFont": return f -__all__ = ["ui_family", "scaled", "ui_font"] +def mono_font(pt: int, *, bold: bool = False, weight=None) -> "QFont": + """Build a fixed-width font in the real platform monospace family. + + The point size is preserved verbatim (not run through ``scaled``): these are + compact numeric readouts whose layout depends on the existing size — the fix + here is only the font *family*, to silence the "Courier" alias warning. + """ + from PySide6.QtGui import QFont + + f = QFont(mono_family(), pt) + if weight is not None: + f.setWeight(weight) + elif bold: + f.setBold(True) + return f + + +__all__ = ["ui_family", "mono_family", "scaled", "ui_font", "mono_font"] diff --git a/probeflow/processing/state.py b/probeflow/processing/state.py index c2ca362..81e6efb 100644 --- a/probeflow/processing/state.py +++ b/probeflow/processing/state.py @@ -475,10 +475,18 @@ def apply_processing_state( ) elif step.op == "stm_background": fit_mask = _resolve_mask_roi_param(p, "fit", a.shape, roi_set) + fit_region = str(p.get("fit_region", "whole_image")) + if fit_region == "active_roi" and fit_mask is None: + # The ROI mask could not be resolved (no roi_set, missing ROI, + # or non-area ROI) — _resolve_mask_roi_param already warned and + # returned None. Degrade to a whole-image fit instead of letting + # the kernel raise (active_roi mandates a mask), so thumbnail / + # preview renders without a roi_set don't crash. + fit_region = "whole_image" a = _proc.apply_stm_background( a, _proc.STMBackgroundParams( - fit_region=str(p.get("fit_region", "whole_image")), + fit_region=fit_region, line_statistic=str(p.get("line_statistic", "median")), model=str(p.get("model", "linear")), linear_x_first=bool(p.get("linear_x_first", False)), diff --git a/tests/test_fft_phase_gui.py b/tests/test_fft_phase_gui.py index 664aae5..7b81155 100644 --- a/tests/test_fft_phase_gui.py +++ b/tests/test_fft_phase_gui.py @@ -40,6 +40,19 @@ def _set_view(dlg, text): dlg._fft_view_combo.setCurrentIndex(dlg._fft_view_combo.findText(text)) +class TestReconstructStatus: + def test_changing_reconstruct_mode_does_not_crash(self, qapp): + # currentIndexChanged emits the int index; _update_reconstruct_status + # treats its first positional arg as a reconstruction result, so the + # connection must not pass the index straight through (it used to call + # int.imag_residual_norm and raise AttributeError). + dlg = _dialog(qapp) + other = 1 - dlg._recon_mode_combo.currentIndex() + dlg._recon_mode_combo.setCurrentIndex(other) # fires currentIndexChanged + assert dlg._recon_mode_combo.currentIndex() == other + dlg.deleteLater() + + class TestPhaseView: def test_default_is_magnitude(self, qapp): dlg = _dialog(qapp) diff --git a/tests/test_processing_state.py b/tests/test_processing_state.py index 7739a49..2d9eec1 100644 --- a/tests/test_processing_state.py +++ b/tests/test_processing_state.py @@ -1039,3 +1039,32 @@ def test_apply_processing_state_to_scan_preserves_extent_after_scale(self): ) assert scan.planes[0].shape == (32, 24) np.testing.assert_allclose(scan.scan_range_m, (3e-9, 5e-9)) + + +def test_stm_background_active_roi_without_roi_set_degrades_to_whole_image(): + """A constant-Δf/STM background step fit over an ROI must not crash a + thumbnail/preview render that has no roi_set: the unresolved ROI mask + degrades to a whole-image fit instead of raising in the kernel.""" + + rng = np.random.default_rng(0) + arr = rng.standard_normal((24, 24)).astype(np.float64) + np.linspace(0, 5, 24) + + state = ProcessingState( + [ + ProcessingStep( + "stm_background", + { + "fit_region": "active_roi", + "fit_roi_id": "be8e9cc6-84b2-4e4d-9990-24bdc4476258", + "model": "linear", + "line_statistic": "median", + }, + ) + ] + ) + + with pytest.warns(UserWarning, match="ROI fit mask ignored"): + out = apply_processing_state(arr, state, roi_set=None) + + assert out.shape == arr.shape + assert np.isfinite(out).all()