refactoring
CI for debye_bec / test (push) Successful in 58s

This commit is contained in:
x01da
2026-06-23 13:45:37 +02:00
parent 48b6225ded
commit b75f027a77
4 changed files with 129 additions and 251 deletions
@@ -138,8 +138,7 @@ class DataViewer(BECWidget, QWidget):
days, remainder = divmod(seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, remainder = divmod(remainder, 60)
seconds, _ = divmod(remainder, 60)
minutes, seconds = divmod(remainder, 60)
parts = []
if days:
@@ -202,7 +201,7 @@ class DataViewer(BECWidget, QWidget):
tags.append((comment, get_accent_colors().warning.name()))
if status == "closed":
tags.append((status, get_accent_colors().success.name()))
elif status == "halted":
elif status == "halted" or status == "aborted":
tags.append((status, get_accent_colors().emergency.name()))
else:
tags.append((status, "#656365"))
@@ -13,7 +13,7 @@ import numpy as np
class NodeInfo:
"""Describes a single node (group or dataset) inside a loaded file."""
__slots__ = ("name", "path", "kind", "dtype", "shape", "display_hint")
__slots__ = ("name", "path", "kind", "dtype", "shape")
def __init__(
self,
@@ -22,14 +22,12 @@ class NodeInfo:
kind: Literal["group", "dataset"],
dtype: str = "",
shape: tuple[int, ...] = (),
display_hint: str = "",
):
self.name = name
self.path = path
self.kind = kind
self.dtype = dtype # e.g. "float", "int", "str", …
self.shape = shape # empty tuple for scalars / groups
self.display_hint = display_hint # e.g. "image_native" — passed through to DataPanel
class BaseFileLoader(ABC):
@@ -74,11 +72,6 @@ class BaseFileLoader(ABC):
return sum(1 for _ in self.iter_nodes(path))
# ══════════════════════════════════════════════════════════════════════════════
# HDF5 loader (replaces the inline h5py logic that was in HDF5Viewer)
# ══════════════════════════════════════════════════════════════════════════════
class HDF5Loader(BaseFileLoader):
"""Loader for HDF5 / NeXus files (.h5, .hdf5, .nxs, .nx)."""
@@ -132,7 +125,7 @@ class HDF5Loader(BaseFileLoader):
def read_dataset(self, path: str) -> np.ndarray:
assert self._file is not None, "File not open"
return self._file[path][()], "h5"
return self._file[path][()]
def child_count(self, path: str) -> int:
assert self._file is not None, "File not open"
@@ -145,9 +138,7 @@ class ImageLoader(BaseFileLoader):
Loader for raster image files.
The file is treated as a single, flat dataset. ``iter_nodes`` yields one
leaf node whose ``display_hint`` is set to ``"image_native"`` so that
``DataPanel`` knows to render it as a native Qt image rather than pushing
it through the pyqtgraph pipeline.
leaf node.
Requires: Pillow (``pip install Pillow``)
"""
@@ -194,34 +185,22 @@ class ImageLoader(BaseFileLoader):
mode = img.mode # e.g. "RGB", "RGBA", "L", …
name = os.path.basename(self._filepath)
yield NodeInfo(
name=name,
path="/image",
kind="dataset",
dtype=mode,
shape=(h, w),
display_hint="image_native",
)
yield NodeInfo(name=name, path="/image", kind="dataset", dtype=mode, shape=(h, w))
def read_dataset(self, path: str) -> np.ndarray:
"""Return the image as a uint8 numpy array (H × W × C or H × W)."""
"""Return the image as a uint8 numpy array (H x W x C or H x W)."""
from PIL import Image as _PILImage
with _PILImage.open(self._filepath) as img: # type: ignore[arg-type]
# Animated GIF → first frame only
if hasattr(img, "n_frames") and img.n_frames > 1:
img.seek(0)
return np.asarray(img), "image_native"
return np.asarray(img)
def child_count(self, path: str) -> int:
return 1 if path == "/" else 0
# ══════════════════════════════════════════════════════════════════════════════
# Loader registry
# ══════════════════════════════════════════════════════════════════════════════
class LoaderRegistry:
"""Maps file extensions to loader classes."""
@@ -245,6 +224,6 @@ class LoaderRegistry:
# Default global registry — pre-populated with built-in loaders.
_registry = LoaderRegistry()
_registry.register(HDF5Loader)
_registry.register(ImageLoader)
registry = LoaderRegistry()
registry.register(HDF5Loader)
registry.register(ImageLoader)
@@ -1,5 +1,5 @@
"""
Data viewer
Data viewer displaying the data
"""
from typing import Literal, Optional
@@ -32,23 +32,24 @@ from qtpy.QtWidgets import (
logger = bec_logger.logger
MAX_ROWS = 2000
MAX_COLS = 500
class DataView(QWidget):
def __init__(self):
super().__init__()
self._layout = QVBoxLayout(self)
# Header
hdr = QHBoxLayout()
self.path_label = QLabel("Select a dataset from the tree")
header = QHBoxLayout()
self.path_label = QLabel("")
self.path_label.setObjectName("path_label")
self.info_label = QLabel("")
self.info_label.setObjectName("info_label")
hdr.addWidget(self.path_label, 1)
hdr.addWidget(self.info_label)
self._layout.addLayout(hdr)
header.addWidget(self.path_label, 1)
header.addWidget(self.info_label)
self._layout.addLayout(header)
# View-mode selector
mode_box = QGroupBox("View mode")
mode_layout = QHBoxLayout(mode_box)
mode_layout.setContentsMargins(8, 4, 8, 4)
@@ -57,39 +58,71 @@ class DataView(QWidget):
self.rb_image = QRadioButton("Image")
self.rb_table = QRadioButton("Table")
self.rb_auto.setChecked(True)
for rb in (self.rb_auto, self.rb_plot, self.rb_image, self.rb_table):
for rb in (self.rb_auto, self.rb_image, self.rb_plot, self.rb_table):
mode_layout.addWidget(rb)
rb.toggled.connect(self._on_mode_change)
mode_layout.addStretch()
self._layout.addWidget(mode_box)
# Content stack
self.stack = QWidget()
self.stack_layout = QVBoxLayout(self.stack)
self.stack_layout.setContentsMargins(0, 0, 0, 0)
self._layout.addWidget(self.stack, 1)
self.content = QWidget()
self.content_layout = QVBoxLayout(self.content)
self.content_layout.setContentsMargins(0, 0, 0, 0)
self._layout.addWidget(self.content, 1)
self.plot_widget = None
self.image_widget = None
self._current_data = None
self.show_empty()
# ── helpers ───────────────────────────────────────────────────────────
def apply_theme(self, theme: Optional[Literal["dark", "light"]] = None):
"""
Apply the theme
Args:
theme (Optional[str]): Theme, either "dark", "light", or None. Defaults to None.
"""
if theme is None:
app = QApplication.instance()
theme = app.theme.theme # type: ignore
bg_color = pg.getConfigOption("background")
fg_color = pg.getConfigOption("foreground")
if self.plot_widget is not None:
n_curves = len(self.plot_widget.listDataItems())
colors = Colors.golden_angle_color(
colormap="plasma", num=max(10, n_curves + 1), format="HEX"
)
for idx, curve in enumerate(self.plot_widget.listDataItems()):
curve.setPen(pg.mkPen(color=colors[idx]))
# Background
self.plot_widget.setBackground(bg_color)
# Axes (tick marks, tick labels, axis line)
for axis in ["left", "bottom", "right", "top"]:
ax = self.plot_widget.getAxis(axis)
ax.setPen(pg.mkPen(color=fg_color))
ax.setTextPen(pg.mkPen(color=fg_color))
if self.image_widget is not None:
self.image_widget.getView().setBackgroundColor(bg_color)
self.image_widget.ui.histogram.setBackground(bg_color)
def _clear_stack(self):
while self.stack_layout.count():
item = self.stack_layout.takeAt(0)
while self.content_layout.count():
item = self.content_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self.plot_widget = None
self.image_widget = None
def show_empty(self):
"""Empties the content area."""
self._clear_stack()
lbl = QLabel("No data selected")
lbl.setObjectName("info_label")
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.stack_layout.addWidget(lbl)
empty_label = QLabel("No data selected")
empty_label.setObjectName("info_label")
empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.path_label.setText("")
self.info_label.setText("")
self.content_layout.addWidget(empty_label)
def show_unsupported(self, path: str = "") -> None:
"""Display a friendly 'not implemented' message for unknown file types."""
@@ -100,7 +133,7 @@ class DataView(QWidget):
lbl = QLabel("File type not supported")
lbl.setObjectName("info_label")
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.stack_layout.addWidget(lbl)
self.content_layout.addWidget(lbl)
def _on_mode_change(self):
if self._current_data is not None:
@@ -115,8 +148,7 @@ class DataView(QWidget):
return "table"
return "auto"
# ── public ────────────────────────────────────────────────────────────
def display(self, data, path: str = "", display_hint: str = "") -> None:
def display(self, data, path: str = "") -> None:
"""
Render *data* in the panel.
@@ -126,10 +158,6 @@ class DataView(QWidget):
A ``numpy.ndarray`` (or anything convertible to one).
path:
Human-readable label shown in the header.
display_hint:
Optional hint from the loader. ``"image_native"`` bypasses the
pyqtgraph pipeline and renders via a Qt ``QLabel``/``QPixmap``
instead, which correctly handles RGB/RGBA colour images.
"""
self._current_data = data
self.path_label.setText(path)
@@ -137,15 +165,13 @@ class DataView(QWidget):
if not isinstance(data, np.ndarray):
data = np.array(data)
self.info_label.setText(
f"shape {data.shape} · dtype {data.dtype} · {data.size:,} elements"
)
self.info_label.setText(f"shape {data.shape}, dtype {data.dtype}, {data.size} elements")
mode = self._active_mode()
if mode == "auto":
if data.ndim <= 1 and data.size > 1:
mode = "plot"
elif data.ndim <= 4 and min(data.shape) > 1:
elif data.ndim <= 4 and min(data.shape, default=0) > 1:
mode = "image"
else:
mode = "table"
@@ -157,76 +183,13 @@ class DataView(QWidget):
else:
self._show_table(data)
# ── Native raster image (RGB / RGBA / greyscale) ───────────────────────
def _show_native_image(self, data: np.ndarray) -> None:
"""
Render a uint8 numpy array (H×W, H×W×3, or H×W×4) using a plain
Qt QLabel so that colour images display correctly without pyqtgraph's
colourmap pipeline. The image is scaled to fit the available space
while preserving the aspect ratio.
"""
from qtpy.QtGui import QImage
self._clear_stack()
arr = data
if arr.dtype != np.uint8:
# Normalise to 0255 for display
lo, hi = arr.min(), arr.max()
arr = ((arr - lo) / max(hi - lo, 1) * 255).astype(np.uint8)
if arr.ndim == 2:
# Greyscale → replicate to RGB so QImage is straightforward
arr = np.stack([arr, arr, arr], axis=-1)
if arr.ndim == 3 and arr.shape[2] == 4:
fmt = QImage.Format.Format_RGBA8888
else:
if arr.shape[2] != 3:
arr = arr[:, :, :3]
fmt = QImage.Format.Format_RGB888
h, w, ch = arr.shape
bytes_per_line = ch * w
arr_contiguous = np.ascontiguousarray(arr)
qimg = QImage(arr_contiguous.data, w, h, bytes_per_line, fmt)
pixmap = QPixmap.fromImage(qimg)
label = QLabel()
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
label.setMinimumSize(1, 1)
label.setPixmap(
pixmap.scaled(
label.size(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
)
# Re-scale when the panel is resized
def _on_resize(event, _lbl=label, _px=pixmap):
_lbl.setPixmap(
_px.scaled(
_lbl.size(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
)
super(QLabel, _lbl).resizeEvent(event) # type: ignore[arg-type]
label.resizeEvent = _on_resize # type: ignore[method-assign]
self.stack_layout.addWidget(label)
# ── 1-D line plot ──────────────────────────────────────────────────────
def _show_plot_1d(self, data):
self._clear_stack()
is_2d = data.ndim == 2
if is_2d:
n_rows, n_cols = data.shape
n_rows, _ = data.shape
row_data = data[0].astype(np.float32)
else:
row_data = data.reshape(-1).astype(np.float32)
@@ -253,7 +216,6 @@ class DataView(QWidget):
plot_item.enableAutoRange() # type: ignore[attr-defined]
if is_2d:
# --- Slider ---
slider = QSlider(Qt.Orientation.Vertical)
slider.setMinimum(0)
slider.setMaximum(n_rows - 1)
@@ -294,45 +256,12 @@ class DataView(QWidget):
h_layout.addWidget(slider_col)
h_layout.addWidget(self.plot_widget)
self.stack_layout.addWidget(container)
self.content_layout.addWidget(container)
else:
self.stack_layout.addWidget(self.plot_widget)
self.content_layout.addWidget(self.plot_widget)
self.apply_theme()
def apply_theme(self, theme: Optional[Literal["dark", "light"]] = None):
"""
Apply the theme
Args:
theme (Optional[str]): Theme, either "dark", "light", or None. Defaults to None.
"""
if theme is None:
app = QApplication.instance()
theme = app.theme.theme # type: ignore
bg_color = pg.getConfigOption("background")
fg_color = pg.getConfigOption("foreground")
if self.plot_widget is not None:
n_curves = len(self.plot_widget.listDataItems())
colors = Colors.golden_angle_color(
colormap="plasma", num=max(10, n_curves + 1), format="HEX"
)
for idx, curve in enumerate(self.plot_widget.listDataItems()):
curve.setPen(pg.mkPen(color=colors[idx]))
# Background
self.plot_widget.setBackground(bg_color)
# Axes (tick marks, tick labels, axis line)
for axis in ["left", "bottom", "right", "top"]:
ax = self.plot_widget.getAxis(axis)
ax.setPen(pg.mkPen(color=fg_color))
ax.setTextPen(pg.mkPen(color=fg_color))
if self.image_widget is not None:
self.image_widget.getView().setBackgroundColor(bg_color)
self.image_widget.ui.histogram.setBackground(bg_color)
# ── 2-D image ──────────────────────────────────────────────────────────
def _show_image_2d(self, data):
self._clear_stack()
@@ -372,11 +301,9 @@ class DataView(QWidget):
)
set_image(img)
self.image_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
if stacked:
# --- Slider ---
slider = QSlider(Qt.Orientation.Vertical)
slider.setMinimum(0)
slider.setMaximum(n_images - 1)
@@ -414,18 +341,15 @@ class DataView(QWidget):
h_layout.addWidget(slider_col)
h_layout.addWidget(self.image_widget)
self.stack_layout.addWidget(container)
self.content_layout.addWidget(container)
else:
self.stack_layout.addWidget(self.image_widget)
self.content_layout.addWidget(self.image_widget)
self.apply_theme()
# ── Table ──────────────────────────────────────────────────────────────
def _show_table(self, data):
self._clear_stack()
MAX_ROWS, MAX_COLS = 2000, 500
if data.ndim == 0:
flat = data.reshape(1, 1)
elif data.ndim == 1:
@@ -440,18 +364,15 @@ class DataView(QWidget):
show_cols = min(cols, MAX_COLS)
if rows > MAX_ROWS or cols > MAX_COLS:
note = QLabel(f"⚠ Showing {show_rows}/{rows} rows × {show_cols}/{cols} cols")
note = QLabel(f"⚠ Showing {show_rows}/{rows} rows x {show_cols}/{cols} columns")
note.setObjectName("info_label")
note.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.stack_layout.addWidget(note)
self.content_layout.addWidget(note)
table = QTableWidget(show_rows, show_cols)
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
table.setSelectionMode(QAbstractItemView.SelectionMode.ContiguousSelection)
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
table.horizontalHeader().setDefaultSectionSize(90)
table.verticalHeader().setDefaultSectionSize(22)
table.setFont(QFont("JetBrains Mono, Consolas, monospace", 10))
is_float = np.issubdtype(flat.dtype, np.floating)
is_complex = np.iscomplexobj(flat)
@@ -469,4 +390,4 @@ class DataView(QWidget):
cell.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
table.setItem(r, c, cell)
self.stack_layout.addWidget(table)
self.content_layout.addWidget(table)
@@ -1,10 +1,7 @@
"""
File Viewer — qtpy + pyqtgraph
Supports pluggable file-format loaders (HDF5 built-in; extend via BaseFileLoader).
Scan viewer. Displays files of one or more scans in a tree view
"""
from __future__ import annotations
import os
from typing import Literal, Optional
@@ -28,7 +25,9 @@ from qtpy.QtWidgets import (
QWidget,
)
from ..loaders import BaseFileLoader, LoaderRegistry, _registry
# pylint: disable=E0402
from ..loaders import BaseFileLoader, registry
from ..widgets.qt_widgets import Group
from .data_view import DataView
logger = bec_logger.logger
@@ -38,25 +37,54 @@ ICON_SIZE = 20
class ScanViewer(QMainWindow):
"""
Generic file viewer. Supports any format registered in *registry*.
Generic scan viewer. Supports any format registered in *registry*.
Parameters
----------
filepath:
Optional path to open on startup.
registry:
``LoaderRegistry`` to use. Defaults to the module-level ``_registry``
which ships with ``HDF5Loader`` pre-registered.
Args:
filepath(str): Optional path to open on startup.
"""
def __init__(self, filepath: Optional[str] = None, registry: Optional[LoaderRegistry] = None):
def __init__(self, filepath: Optional[str] = None):
super().__init__()
self._registry = registry or _registry
self.registry = registry
# filepath -> (loader_instance, display_name)
self._open_files: dict[str, tuple[BaseFileLoader, str]] = {}
self._build_ui()
central = QWidget()
self.setCentralWidget(central)
root_layout = QHBoxLayout(central)
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.setChildrenCollapsible(False)
left_pane = QWidget()
left_layout = QVBoxLayout(left_pane)
self.tree = QTreeWidget()
self.tree.setMinimumWidth(250)
self.tree.setHeaderLabels(["Name", "Type", "Shape"])
self.tree.header().setStretchLastSection(False)
self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
self.tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
self.tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self.tree.itemClicked.connect(self._on_item_clicked)
left_layout.addWidget(self.tree, 1)
self.data_panel = DataView()
splitter.addWidget(left_pane)
splitter.addWidget(self.data_panel)
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 3)
splitter.setSizes([300, 900])
splitter.setHandleWidth(6)
splitter.setChildrenCollapsible(False)
self.scan_view_group = Group("Scan view", [splitter])
root_layout.addWidget(self.scan_view_group)
if filepath:
self.load_files([filepath])
@@ -69,17 +97,15 @@ class ScanViewer(QMainWindow):
"""
self.data_panel.apply_theme(theme)
# ── public API ────────────────────────────────────────────────────────
def load_files(self, filepaths: list[str]) -> None:
"""Open one or more files and add each as a top-level tree node."""
for fp in filepaths:
if fp in self._open_files:
continue # already loaded
loader = self._registry.get_loader(fp)
loader = self.registry.get_loader(fp)
if loader is None:
supported = ", ".join(self._registry.supported_extensions)
supported = ", ".join(self.registry.supported_extensions)
logger.warning("No loader found for %r. Supported extensions: %s", fp, supported)
continue
@@ -102,53 +128,12 @@ class ScanViewer(QMainWindow):
self.data_panel.show_empty()
def closeEvent(self, event):
"""Close all"""
for loader, _ in self._open_files.values():
loader.close()
self._open_files.clear()
super().closeEvent(event)
# ── UI construction ───────────────────────────────────────────────────
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root_layout = QHBoxLayout(central)
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.setChildrenCollapsible(False)
# ── Left pane ──
left_pane = QWidget()
left_layout = QVBoxLayout(left_pane)
self.tree = QTreeWidget()
self.tree.setMinimumWidth(250)
self.tree.setHeaderLabels(["Name", "Type", "Shape"])
self.tree.header().setStretchLastSection(False)
self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
self.tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
self.tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self.tree.itemClicked.connect(self._on_item_clicked)
left_layout.addWidget(self.tree, 1)
# ── Right pane ──
self.data_panel = DataView()
splitter.addWidget(left_pane)
splitter.addWidget(self.data_panel)
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 3)
splitter.setSizes([300, 900])
splitter.setHandleWidth(6)
splitter.setChildrenCollapsible(False)
root_layout.addWidget(splitter)
# ── Tree helpers ──────────────────────────────────────────────────────
def _add_file_to_tree(self, filepath: str, loader: BaseFileLoader, display_name: str) -> None:
"""Add a single file as a new top-level node in the tree."""
dataset_icon = material_icon(
@@ -163,7 +148,7 @@ class ScanViewer(QMainWindow):
self._populate_tree(root_item, loader, "/")
self.tree.addTopLevelItem(root_item)
# Expand first 2 levels
# Expand first 2 levels by default
self.tree.expandItem(root_item)
for i in range(root_item.childCount()):
self.tree.expandItem(root_item.child(i))
@@ -206,8 +191,6 @@ class ScanViewer(QMainWindow):
item.setForeground(1, QBrush(QColor(get_accent_colors().success.name())))
item.setForeground(2, QBrush(QColor("#656365")))
# ── Event handling ────────────────────────────────────────────────────
def _get_filepath_for_item(self, item: QTreeWidgetItem) -> str:
"""Walk up the tree to find the filepath stored on the root node."""
node = item
@@ -226,9 +209,5 @@ class ScanViewer(QMainWindow):
loader, _ = self._open_files[filepath]
if kind == "dataset":
data, ftype = loader.read_dataset(path)
self.data_panel.display(data, path, ftype)
else:
# Group selected — show child count in status bar (optional)
count = loader.child_count(path)
self.statusBar().showMessage(f"{path} ({count} items)")
data = loader.read_dataset(path)
self.data_panel.display(data, path)