Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 31 additions & 18 deletions probeflow/gui/dialogs/image_viewer_build_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions probeflow/gui/dialogs/image_viewer_chrome_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
11 changes: 11 additions & 0 deletions probeflow/gui/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions probeflow/gui/viewer/shortcuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
25 changes: 21 additions & 4 deletions probeflow/gui/workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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 ────────────────────────────────────────────────
Expand Down
63 changes: 63 additions & 0 deletions tests/test_browse_worker_seams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────

Expand Down
25 changes: 25 additions & 0 deletions tests/test_gui_processing_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading