From 1f093723dc8085912580c5c2f1ca90c743592078 Mon Sep 17 00:00:00 2001 From: Peter Jacobson Date: Mon, 15 Jun 2026 16:13:52 +1000 Subject: [PATCH 1/2] Merge ROI + Masks into one sidebar tab; reorder tabs to fit labels The image viewer had 6 sidebar tabs (View, Process, ROI, Masks, Measure, Export); the labels truncated ("Proc...", "Ma...", "Mea...") and the ROI/Masks tabs were mostly empty forms. Combine them and reorder so labels fit. - ROI and Masks now share a single "ROI/Mask" tab, each as a collapsible section (reusing _collapsible_section, the same widget as the View tab's "Spectroscopy overlay"). "Regions of interest" is expanded by default; "Masks" starts collapsed. See the _sidebar_tab + panel wiring in probeflow/gui/dialogs/image_viewer_build_mixin.py. - Tab order is now View, Process, Measure, ROI/Mask, Export. - The old "masks" tab key is aliased to the merged tab's index (no extra rail entry); _show_sidebar_tab("masks") selects the tab and expands the Masks section via self._mask_section_btn (image_viewer_chrome_mixin.py), so any future mask-driven navigation still reveals the controls. - Keyboard shortcuts renumbered to match the new on-screen order: Ctrl+1 View, Ctrl+2 Process, Ctrl+3 Measure, Ctrl+4 ROI/Mask, Ctrl+5 Export (probeflow/gui/viewer/shortcuts.py); the command->tab lambdas are unchanged. Test: test_sidebar_merges_roi_mask_and_orders_tabs. Co-Authored-By: Claude Opus 4.8 --- .../gui/dialogs/image_viewer_build_mixin.py | 49 ++++++++++++------- .../gui/dialogs/image_viewer_chrome_mixin.py | 6 +++ probeflow/gui/viewer/shortcuts.py | 4 +- tests/test_gui_processing_panel.py | 25 ++++++++++ 4 files changed, 64 insertions(+), 20 deletions(-) diff --git a/probeflow/gui/dialogs/image_viewer_build_mixin.py b/probeflow/gui/dialogs/image_viewer_build_mixin.py index 2252519..5d0d654 100644 --- a/probeflow/gui/dialogs/image_viewer_build_mixin.py +++ b/probeflow/gui/dialogs/image_viewer_build_mixin.py @@ -312,18 +312,20 @@ def _sidebar_tab(key: str, label: str, tip: str = "") -> tuple[QWidget, QVBoxLay "processing", "Process", "Line corrections, background subtraction, filters and FFT tools.", ) - _roi_tab, roi_lay = _sidebar_tab( - "roi", "ROI", - "Create, edit and combine regions of interest.", - ) - _masks_tab, masks_lay = _sidebar_tab( - "masks", "Masks", - "Active mask layer: edge-detection output, cleanup, and conversion to ROIs.", - ) _measurements_tab, measurements_lay = _sidebar_tab( "measurements", "Measure", "Distances, angles, ROI statistics, features and results.", ) + # ROI and Masks share one tab; each is a collapsible section (built when + # the panels are created, below). + _roimask_tab, roimask_lay = _sidebar_tab( + "roi", "ROI/Mask", + "Regions of interest and the active mask layer (edge-detection " + "output, cleanup, conversion to ROIs).", + ) + # Legacy alias so any "masks" navigation lands on the merged tab; no extra + # rail entry (the rail is driven by _sidebar_tab_meta only). + self._sidebar_tab_indices["masks"] = self._sidebar_tab_indices["roi"] _export_tab, export_lay = _sidebar_tab( "export", "Export", "Save images (PNG/PDF/SXM/GWY), provenance and hand-off to tools.", @@ -787,9 +789,8 @@ def _summary_row(row: int, name: str, attr: str, *, elide: bool = False) -> QLab roi_hint_lbl.setFont(ui_font(8)) roi_hint_lbl.setWordWrap(True) roi_hint_lbl.setStyleSheet("color: palette(mid);") - roi_lay.addWidget(roi_hint_lbl) - # The ROI manager panel itself is added to ``roi_lay`` after it is - # constructed below (it needs the ROI-set getter and callbacks). + # The hint and the ROI manager panel are added to the ROI collapsible + # section of the "ROI/Mask" tab after the panel is constructed below. # The Measure tab is driven entirely by the ImageMeasurementsPanel's own # tool menu (added to ``measurements_lay`` after it is constructed below); @@ -831,8 +832,8 @@ def _summary_row(row: int, name: str, attr: str, *, elide: bool = False) -> QLab rail_lay.addWidget(rail_sep) _rail_abbrev = { - "View": "View", "Process": "Proc", "ROI": "ROI", - "Measure": "Meas", "Export": "Exp", + "View": "View", "Process": "Proc", "Measure": "Meas", + "ROI/Mask": "R/M", "Export": "Exp", } for _key, _label, _tip in self._sidebar_tab_meta: rail_btn = QToolButton() @@ -956,18 +957,30 @@ def _summary_row(row: int, name: str, attr: str, *, elide: bool = False) -> QLab ) self._mask_panel.setObjectName("imageViewerMaskManagerPanel") + # ROI and Masks share the "ROI/Mask" tab as two collapsible sections + # (same pattern as the View tab's "Spectroscopy overlay"). ROI is open by + # default; Masks starts collapsed. + _roi_btn, _roi_body, roi_section_lay = _collapsible_section( + roimask_lay, "Regions of interest", expanded=True + ) + roi_section_lay.addWidget(roi_hint_lbl) + roi_section_lay.addWidget(self._roi_panel, 1) + + self._mask_section_btn, _mask_body, mask_section_lay = _collapsible_section( + roimask_lay, "Masks", expanded=False + ) mask_hint = QLabel( "Masks come from Advanced Edge Detection (Process tab). The active " "mask (●) restricts statistics and can become ROI(s)." ) mask_hint.setWordWrap(True) mask_hint.setFont(ui_font(8)) - masks_lay.addWidget(mask_hint) - masks_lay.addWidget(self._mask_panel, 1) + mask_section_lay.addWidget(mask_hint) + mask_section_lay.addWidget(self._mask_panel, 1) + roimask_lay.addStretch(1) - # ROI manager and measurements now live in their sidebar tabs (built - # above) rather than in separate floating docks. - roi_lay.addWidget(self._roi_panel, 1) + # Measurements now lives in its sidebar tab (built above) rather than in a + # separate floating dock. measurements_lay.addWidget(self._measurement_panel, 1) # Manager for floating, dismissible tool panels over the canvas. diff --git a/probeflow/gui/dialogs/image_viewer_chrome_mixin.py b/probeflow/gui/dialogs/image_viewer_chrome_mixin.py index dc81c36..e0398d1 100644 --- a/probeflow/gui/dialogs/image_viewer_chrome_mixin.py +++ b/probeflow/gui/dialogs/image_viewer_chrome_mixin.py @@ -651,6 +651,12 @@ def _show_sidebar_tab(self, key: str) -> None: idx = self._sidebar_tab_indices.get(key) if idx is not None: self._sidebar_tabs.setCurrentIndex(idx) + # ROI and Masks share one tab; a request to show "masks" expands that + # collapsible section so the mask controls are revealed. + if key == "masks": + btn = getattr(self, "_mask_section_btn", None) + if btn is not None: + btn.setChecked(True) def _show_sidebar_tool(self, title: str, widget, on_close=None) -> None: """Host an interactive tool's controls in the sidebar column (page 1). diff --git a/probeflow/gui/viewer/shortcuts.py b/probeflow/gui/viewer/shortcuts.py index 6df56f3..c074a93 100644 --- a/probeflow/gui/viewer/shortcuts.py +++ b/probeflow/gui/viewer/shortcuts.py @@ -25,8 +25,8 @@ class ViewerCommand: ), ViewerCommand("panel.view", "Histogram / Contrast", "View", ("Ctrl+1",), "Show the View sidebar tab.", aliases=("display", "contrast", "histogram")), ViewerCommand("panel.process", "Processing panel", "View", ("Ctrl+2",), "Show the Process sidebar tab.", aliases=("process", "processing")), - ViewerCommand("panel.roi", "ROI panel", "View", ("Ctrl+3",), "Show the ROI sidebar tab.", aliases=("roi", "selection")), - ViewerCommand("panel.measure", "Measurements panel", "View", ("Ctrl+4",), "Show the Measure sidebar tab.", aliases=("measure", "measurement")), + ViewerCommand("panel.measure", "Measurements panel", "View", ("Ctrl+3",), "Show the Measure sidebar tab.", aliases=("measure", "measurement")), + ViewerCommand("panel.roi", "ROI / Mask panel", "View", ("Ctrl+4",), "Show the ROI/Mask sidebar tab.", aliases=("roi", "mask", "selection")), ViewerCommand("panel.export", "Export panel", "View", ("Ctrl+5",), "Show the Export sidebar tab.", aliases=("save", "write")), ViewerCommand("view.fit", "Fit image to window", "View", ("Ctrl+0",), "Fit the image to the visible canvas.", aliases=("zoom", "fit")), ViewerCommand("view.one_to_one", "View at 1:1", "View", ("Ctrl+Shift+0",), "View the image at native raster size.", aliases=("native", "actual size")), diff --git a/tests/test_gui_processing_panel.py b/tests/test_gui_processing_panel.py index 63f7f29..8f446ae 100644 --- a/tests/test_gui_processing_panel.py +++ b/tests/test_gui_processing_panel.py @@ -188,6 +188,31 @@ def test_viewer_full_panel_round_trips_standard_processing_state(qapp): ) +def test_sidebar_merges_roi_mask_and_orders_tabs(qapp, monkeypatch): + from probeflow.gui import ImageViewerDialog, SxmFile, THEMES + + monkeypatch.setattr(ImageViewerDialog, "_load_current", lambda self: None) + entry = SxmFile(path=Path("/tmp/example.sxm"), stem="example", Nx=8, Ny=8) + dlg = ImageViewerDialog(entry, [entry], "gray", THEMES["dark"]) + try: + tabs = dlg._sidebar_tabs + assert [tabs.tabText(i) for i in range(tabs.count())] == [ + "View", "Process", "Measure", "ROI/Mask", "Export", + ] + # The old "masks" key aliases onto the merged tab (no separate tab). + assert dlg._sidebar_tab_indices["masks"] == dlg._sidebar_tab_indices["roi"] + # ROI section open, Masks section collapsed by default. + assert dlg._mask_section_btn.isChecked() is False + # Navigating to "masks" selects the merged tab and reveals the section. + dlg._show_sidebar_tab("masks") + assert tabs.tabText(tabs.currentIndex()) == "ROI/Mask" + assert dlg._mask_section_btn.isChecked() is True + finally: + dlg.close() + dlg.deleteLater() + qapp.processEvents() + + def test_format_gaussian_readout_pure(): from probeflow.gui.processing import format_gaussian_readout From e875a6432deb1ae818cfd68364dab62043af7583 Mon Sep 17 00:00:00 2001 From: Peter Jacobson Date: Mon, 15 Jun 2026 16:29:20 +1000 Subject: [PATCH 2/2] Hide raw/auxiliary channels in the Browse preview panel Createc qPlus files declare many channels (e.g. createc_scan_preview_120nm.dat has Channels=40): 8 named signals (Z fwd/bwd, Current fwd/bwd, Frequency shift, Amplitude, Drive, Phase) followed by dozens of generic "Raw channel N" DAC slots (createc_dat.py). The read is correct, but the Browse info panel rendered a thumbnail for every plane, flooding the grid with auxiliary channels. Filter them out where the previews are produced: - Add is_raw_channel_name() in probeflow/gui/models.py (matches the generated "Raw channel N" / "Raw column N" placeholder names). - ChannelPreviewLoader.work() (probeflow/gui/workers.py) now skips raw planes: meta_ready carries only the named channels and previews are emitted by contiguous display position, so the panel slots line up unchanged. Falls back to showing all planes if none are named (never a blank grid). ChannelPreviewLoader is browse-only and meta_ready has a single consumer (ScanInfoPanel), so no panel/viewer changes are needed. The full header is untouched, so "Show all metadata" still reports every channel, and raw planes remain selectable by opening the file in the viewer. Tests: test_channel_preview_loader_hides_raw_channels, test_channel_preview_loader_keeps_all_when_all_raw, test_is_raw_channel_name. Co-Authored-By: Claude Opus 4.8 --- probeflow/gui/models.py | 11 ++++++ probeflow/gui/workers.py | 25 ++++++++++-- tests/test_browse_worker_seams.py | 63 +++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/probeflow/gui/models.py b/probeflow/gui/models.py index b35f3ca..a94a5bb 100644 --- a/probeflow/gui/models.py +++ b/probeflow/gui/models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from dataclasses import dataclass, field from pathlib import Path from typing import Optional @@ -11,6 +12,16 @@ # ── Data model ──────────────────────────────────────────────────────────────── PLANE_NAMES = ["Z fwd", "Z bwd", "I fwd", "I bwd"] +# Placeholder name given to unnamed/auxiliary scan planes (e.g. createc DAC +# channels beyond the known signals — see createc_dat.py). These flood the Browse +# preview with dozens of duplicate raw ADC slots, so they are hidden there. +_RAW_CHANNEL_RE = re.compile(r"^raw (channel|column) \d+$") + + +def is_raw_channel_name(name: str) -> bool: + """True for generic 'Raw channel N' / 'Raw column N' placeholder planes.""" + return bool(_RAW_CHANNEL_RE.match(str(name).strip().lower())) + @dataclass class SxmFile: diff --git a/probeflow/gui/workers.py b/probeflow/gui/workers.py index 788fc65..860773d 100644 --- a/probeflow/gui/workers.py +++ b/probeflow/gui/workers.py @@ -15,7 +15,12 @@ from PySide6.QtGui import QImage from PySide6.QtWidgets import QApplication -from probeflow.gui.models import SxmFile, VertFile, browse_entry_key +from probeflow.gui.models import ( + SxmFile, + VertFile, + browse_entry_key, + is_raw_channel_name, +) from probeflow.core.resources import FILE_CUSHIONS_DIR from probeflow.core.scan_loader import SUPPORTED_SUFFIXES as _SCAN_SUFFIXES from probeflow.gui.rendering import ( @@ -307,9 +312,21 @@ def work(self): names = list(scan.plane_names or []) or [ f"Channel {i}" for i in range(scan.n_planes) ] + # Hide generic 'Raw channel N' auxiliary planes (e.g. createc DAC slots + # beyond the known signals) so the Browse preview shows only meaningful + # channels. Keep all of them if none are named, so the grid is never + # blank. ``visible`` pairs each kept plane's source index with its name; + # previews are emitted by display position so they line up with the slots + # the panel builds from the (filtered) names. + visible = [ + (i, name) for i, name in enumerate(names) + if not is_raw_channel_name(name) + ] + if not visible: + visible = list(enumerate(names)) header = dict(getattr(scan, "header", {}) or {}) - self.signals.meta_ready.emit(names, header, self.token) - for i in range(scan.n_planes): + self.signals.meta_ready.emit([name for _, name in visible], header, self.token) + for slot, (i, _name) in enumerate(visible): # Guard each plane independently: one unusual plane must emit a # null preview for its slot, not kill the worker mid-stream and # leave the remaining slots on their placeholders with no @@ -330,7 +347,7 @@ def work(self): self.entry.path, exc, ) qimg = QImage() - self.signals.loaded.emit(i, qimg, self.token) + self.signals.loaded.emit(slot, qimg, self.token) # ── Worker: channel thumbnails ──────────────────────────────────────────────── diff --git a/tests/test_browse_worker_seams.py b/tests/test_browse_worker_seams.py index c5101b7..e42e9dc 100644 --- a/tests/test_browse_worker_seams.py +++ b/tests/test_browse_worker_seams.py @@ -155,6 +155,69 @@ def flaky_render(**_kw): assert not failed assert all(img.isNull() for _idx, img, _tok in loaded) + def test_channel_preview_loader_hides_raw_channels(self, qapp, monkeypatch): + """Generic 'Raw channel N' auxiliary planes (e.g. createc DAC slots) are + filtered out of the Browse preview; meta_ready carries only the named + channels and previews are emitted by contiguous display position.""" + import probeflow.core.scan_loader as scan_loader + import probeflow.gui.workers as workers + + names = ["Z forward", "Current forward", "Frequency shift"] + [ + f"Raw channel {k}" for k in range(3, 12) + ] + scan = SimpleNamespace( + n_planes=len(names), plane_names=names, header={"Channels": len(names)}, + planes=[np.ones((4, 4))] * len(names), + ) + monkeypatch.setattr(scan_loader, "load_scan", lambda p: scan) + monkeypatch.setattr(workers, "render_scan_image", lambda **_kw: np.ones((4, 4))) + + meta, loaded = [], [] + loader = workers.ChannelPreviewLoader(_scan_entry(), "gray", object(), 8, 8) + loader.signals.meta_ready.connect(lambda *a: meta.append(a)) + loader.signals.loaded.connect(lambda *a: loaded.append(a)) + loader.work() + + assert meta[0][0] == ["Z forward", "Current forward", "Frequency shift"] + # Previews emitted by display position 0..2 (line up with the panel slots). + assert sorted(idx for idx, _img, _tok in loaded) == [0, 1, 2] + # Header is untouched, so "Show all metadata" still reports every channel. + assert meta[0][1]["Channels"] == len(names) + + def test_channel_preview_loader_keeps_all_when_all_raw(self, qapp, monkeypatch): + """If every plane is a raw placeholder, show them all rather than a blank + grid.""" + import probeflow.core.scan_loader as scan_loader + import probeflow.gui.workers as workers + + names = [f"Raw channel {k}" for k in range(3)] + scan = SimpleNamespace( + n_planes=3, plane_names=names, header={}, planes=[np.ones((4, 4))] * 3, + ) + monkeypatch.setattr(scan_loader, "load_scan", lambda p: scan) + monkeypatch.setattr(workers, "render_scan_image", lambda **_kw: np.ones((4, 4))) + + meta, loaded = [], [] + loader = workers.ChannelPreviewLoader(_scan_entry(), "gray", object(), 8, 8) + loader.signals.meta_ready.connect(lambda *a: meta.append(a)) + loader.signals.loaded.connect(lambda *a: loaded.append(a)) + loader.work() + + assert meta[0][0] == names + assert len(loaded) == 3 + + +def test_is_raw_channel_name(): + from probeflow.gui.models import is_raw_channel_name + + assert is_raw_channel_name("Raw channel 12") + assert is_raw_channel_name("raw column 3") + assert is_raw_channel_name(" Raw channel 0 ") + assert not is_raw_channel_name("Z forward") + assert not is_raw_channel_name("Current backward") + assert not is_raw_channel_name("Frequency shift") + assert not is_raw_channel_name("Raw channels") # no index + # ── Thumbnail grid: timer-sliced card build ───────────────────────────────────