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/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/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/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 ─────────────────────────────────── 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