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
20 changes: 17 additions & 3 deletions probeflow/core/indexing.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ class SubfolderEntry:
n_scans: int # scan files found within peek depth
n_specs: int # spectroscopy files found within peek depth
sample_scan_paths: tuple[Path, ...] # up to 3 paths for preview thumbnails
counts_capped: bool = False # peek budget hit — counts are lower bounds


@dataclass(frozen=True)
Expand All @@ -322,18 +323,26 @@ def _peek_subfolder(
*,
max_samples: int = 3,
peek_depth: int = 2,
max_files: int = 400,
) -> SubfolderEntry:
"""Briefly scan *folder* (BFS, capped by peek_depth) for counts and samples.

Bounded so users get a meaningful preview even for nested experiment trees,
without paying for a full recursive walk.
Bounded two ways so users get a meaningful preview even for nested
experiment trees: by depth, and by a *file budget* (``max_files``). Each
recognised-suffix file costs a content sniff — an ~8 KB read — so without
the budget, peeking the parent of a tree whose subfolders hold thousands
of scans transfers megabytes per folder card on a network drive. When the
budget runs out the walk stops and ``counts_capped`` marks the counts as
lower bounds (the grid shows "N+").
"""
n_scans = 0
n_specs = 0
files_examined = 0
capped = False
samples: list[Path] = []
queue: list[tuple[Path, int]] = [(folder, 0)]

while queue:
while queue and not capped:
current, depth = queue.pop(0)
try:
# os.scandir serves is_file()/is_dir() from the single directory
Expand All @@ -351,6 +360,10 @@ def _peek_subfolder(
except OSError:
continue
if is_file:
if files_examined >= max_files:
capped = True
break
files_examined += 1
p = Path(e.path)
ft = sniff_file_type(p)
if ft in (FileType.CREATEC_IMAGE, FileType.NANONIS_IMAGE):
Expand All @@ -368,6 +381,7 @@ def _peek_subfolder(
n_scans=n_scans,
n_specs=n_specs,
sample_scan_paths=tuple(samples),
counts_capped=capped,
)


Expand Down
1 change: 1 addition & 0 deletions probeflow/gui/browse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from probeflow.core.scan_loader import load_scan
from probeflow.gui.workers import (
ChannelLoader,
ChannelPreviewLoader,
ChannelSignals,
FolderThumbnailLoader,
SpecThumbnailLoader,
Expand Down
7 changes: 5 additions & 2 deletions probeflow/gui/browse/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,14 @@ class FolderCard(_BrowseCard):
THUMB_H = 56

def __init__(self, entry: FolderEntry, t: dict, parent=None):
# A "+" marks counts that hit the indexing peek's file budget — they
# are lower bounds, not exact totals (big network trees).
plus = "+" if getattr(entry, "counts_capped", False) else ""
meta_parts = []
if entry.n_scans:
meta_parts.append(f"{entry.n_scans} scan{'s' if entry.n_scans != 1 else ''}")
meta_parts.append(f"{entry.n_scans}{plus} scan{'s' if entry.n_scans != 1 else ''}")
if entry.n_specs:
meta_parts.append(f"{entry.n_specs} spec{'s' if entry.n_specs != 1 else ''}")
meta_parts.append(f"{entry.n_specs}{plus} spec{'s' if entry.n_specs != 1 else ''}")
meta = " | ".join(meta_parts) if meta_parts else "(empty)"
super().__init__(entry, t, meta, parent=parent)

Expand Down
93 changes: 53 additions & 40 deletions probeflow/gui/browse/panels.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
QWidget,
)

from probeflow.core.scan_loader import load_scan as _default_load_scan
from probeflow.gui.models import PLANE_NAMES, SxmFile, VertFile
from probeflow.gui.rendering import (
CMAP_KEY,
Expand All @@ -36,7 +35,7 @@
THUMBNAIL_CHANNEL_DEFAULT,
THUMBNAIL_CHANNEL_OPTIONS,
)
from probeflow.gui.workers import ChannelLoader, ChannelSignals
from probeflow.gui.workers import ChannelPreviewLoader

from .helpers import _browse_attr, _sep

Expand Down Expand Up @@ -400,8 +399,9 @@ def show_entry(self, entry: SxmFile, colormap_key: str,
self._qi["size"].setText(f"{entry.scan_nm:.1f} nm" if entry.scan_nm is not None else "—")
self._qi["bias"].setText(f"{entry.bias_mv:.0f} mV" if entry.bias_mv is not None else "—")
self._qi["setp"].setText(_setp_display(entry))
# One worker load serves both the channel previews and the metadata
# table (meta_ready → _populate_metadata_rows).
self.load_channels(entry, colormap_key, processing=None)
self._load_metadata(entry)

def show_vert_entry(self, entry: VertFile):
self.name_lbl.setText(entry.stem)
Expand Down Expand Up @@ -439,6 +439,9 @@ def _load_vert_metadata(self, entry: VertFile):

def clear(self):
self.name_lbl.setText("No scan selected")
# Invalidate any in-flight preview load so late results don't
# repopulate a panel the user just cleared.
self._ch_token = object()
for v in self._qi.values():
v.setText("—")
for lbl in self._ch_img_lbls:
Expand All @@ -453,43 +456,51 @@ def apply_theme(self, t: dict):
# ── Public ─────────────────────────────────────────────────────────────────
def load_channels(self, entry: SxmFile, colormap_key: str,
processing: dict = None):
"""Kick off the off-thread preview + header load for *entry*.

The scan is read exactly once, on a pool worker (previously this did a
synchronous full ``load_scan`` here for the previews and a second one
in the metadata loader — two full file transfers on the GUI thread per
card click, the main network-drive freeze). ``meta_ready`` populates
the slot layout and metadata table; ``loaded`` fills in each preview.
Stale deliveries are filtered by the token check in each slot.
"""
self._ch_token = object()
# One signals object for the panel's lifetime. Re-creating it per call
# left the previous instance solely owned by still-running ChannelLoader
# runnables; QThreadPool auto-deletes those on the worker thread, which
# C++-destroys the signals QObject (and its cross-thread connections)
# off the main thread — the SIGSEGV class documented on _PooledWorker.
# Stale deliveries are filtered by the token check in _on_ch_loaded.
sigs = getattr(self, "_ch_sigs", None)
if sigs is None:
Signals = _browse_attr("ChannelSignals", ChannelSignals)
sigs = Signals()
sigs.setParent(self)
sigs.loaded.connect(self._on_ch_loaded)
self._ch_sigs = sigs
planes = []
try:
scan = _browse_attr("load_scan", _default_load_scan)(entry.path)
plane_names = list(scan.plane_names)
n_planes = scan.n_planes
planes = list(getattr(scan, "planes", []) or [])
except Exception:
plane_names = list(PLANE_NAMES)
n_planes = len(plane_names)
self._set_channel_preview_slots(plane_names)
for i in range(n_planes):
arr = planes[i] if i < len(planes) else None
Loader = _browse_attr("ChannelLoader", ChannelLoader)
loader = Loader(entry, i, colormap_key,
self._ch_token, 124, 98, sigs,
self._clip_low, self._clip_high,
processing=processing,
arr=arr)
self._pool.start(loader)
self._ch_entry = entry
for lbl in self._ch_img_lbls:
lbl.clear()
lbl.setText("…")
Loader = _browse_attr("ChannelPreviewLoader", ChannelPreviewLoader)
loader = Loader(entry, colormap_key, self._ch_token, 124, 98,
self._clip_low, self._clip_high,
processing=processing)
loader.signals.meta_ready.connect(self._on_ch_meta)
loader.signals.loaded.connect(self._on_ch_loaded)
loader.signals.failed.connect(self._on_ch_failed)
self._pool.start(loader)

# Back-compat alias used internally
_load_channels = load_channels

@Slot(list, dict, object)
def _on_ch_meta(self, names: list, header: dict, token) -> None:
if token is not self._ch_token:
return
self._set_channel_preview_slots(list(names))
entry = getattr(self, "_ch_entry", None)
if entry is not None:
self._populate_metadata_rows(entry, dict(header))

@Slot(str, object)
def _on_ch_failed(self, message: str, token) -> None:
if token is not self._ch_token:
return
for lbl in self._ch_img_lbls:
lbl.clear()
lbl.setText("load failed")
self._meta_rows = []
self._filter_meta()

@Slot(int, QImage, object)
def _on_ch_loaded(self, idx: int, image: QImage, token):
# Workers emit QImage (QPixmap is GUI-thread-only); convert here.
Expand Down Expand Up @@ -540,11 +551,13 @@ def _set_channel_preview_slots(self, names: list[str]) -> None:
self._ch_img_lbls.append(img_lbl)
self._ch_name_lbls.append(nm_lbl)

def _load_metadata(self, entry: SxmFile):
try:
hdr = _browse_attr("load_scan", _default_load_scan)(entry.path).header
except Exception:
hdr = {}
def _populate_metadata_rows(self, entry: SxmFile, hdr: dict) -> None:
"""Fill the metadata table from an already-loaded header dict.

The header arrives via ``ChannelPreviewLoader.meta_ready`` so the file
is read once, off the GUI thread (this used to be a second synchronous
full ``load_scan`` per card click).
"""
priority = [
"REC_DATE", "REC_TIME", "SCAN_PIXELS", "SCAN_RANGE",
"SCAN_OFFSET", "SCAN_ANGLE", "SCAN_DIR", "BIAS",
Expand Down
Loading
Loading