From 59e1dd911c089c3db1b1474049bf701d9e0c703d Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 22 Jun 2026 15:37:24 +0200 Subject: [PATCH] Added support for multiple files within scan. Improved image support. --- .../widgets/data_viewer/data_viewer.py | 21 +- .../bec_widgets/widgets/data_viewer/viewer.py | 669 +++++++++++++++--- 2 files changed, 590 insertions(+), 100 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py index ba79919..3ad7c35 100644 --- a/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py +++ b/debye_bec/bec_widgets/widgets/data_viewer/data_viewer.py @@ -118,9 +118,24 @@ class DataViewer(BECWidget, QWidget): ): # TODO: Check scan file components for combined xas/xrd scans. Is the Pilatus file in there as well? if len(self.history) > 0: scan = self.history[self.current_row] - file = scan["file_components"][0] + b"_master." + scan["file_components"][1] - logger.info(file.decode()) - self.viewer.load_files([file.decode()]) + base_filepath = scan["file_components"][0].decode().rsplit("/", 1)[0] + filenames = [ + f + for f in os.listdir(base_filepath) + if os.path.isfile(os.path.join(base_filepath, f)) + ] + + def sort_priority(name): + if "master" in name: + return 0 + if name.endswith(".h5"): + return 1 + return 2 + + sorted_files = [ + f"{base_filepath}/{name}" for name in sorted(filenames, key=sort_priority) + ] + self.viewer.load_files(sorted_files) @SafeSlot() def unload_all_datasets(self, *_): diff --git a/debye_bec/bec_widgets/widgets/data_viewer/viewer.py b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py index 93312fe..a222669 100644 --- a/debye_bec/bec_widgets/widgets/data_viewer/viewer.py +++ b/debye_bec/bec_widgets/widgets/data_viewer/viewer.py @@ -1,8 +1,13 @@ """ -HDF5 Viewer — qtpy + pyqtgraph + h5py +File Viewer — qtpy + pyqtgraph +Supports pluggable file-format loaders (HDF5 built-in; extend via BaseFileLoader). """ -from typing import Literal, Optional, cast +from __future__ import annotations + +import os +from abc import ABC, abstractmethod +from typing import Any, Iterator, Literal, Optional import h5py import numpy as np @@ -13,7 +18,7 @@ from bec_widgets.utils.colors import Colors, get_accent_colors from qtpy.QtCore import Qt # pylint: disable=E0611 -from qtpy.QtGui import QBrush, QColor, QFont +from qtpy.QtGui import QBrush, QColor, QFont, QPixmap # pylint: disable=E0611 from qtpy.QtWidgets import ( @@ -41,7 +46,299 @@ logger = bec_logger.logger ICON_SIZE = 20 -# ── Data / Plot panel ────────────────────────────────────────────────────── +# ══════════════════════════════════════════════════════════════════════════════ +# Loader plugin interface +# ══════════════════════════════════════════════════════════════════════════════ + + +class NodeInfo: + """Describes a single node (group or dataset) inside a loaded file.""" + + __slots__ = ("name", "path", "kind", "dtype", "shape", "display_hint") + + def __init__( + self, + name: str, + path: str, + 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): + """ + Abstract base class for file-format loaders. + + Subclass this to add support for a new format. Three things are required: + + 1. ``EXTENSIONS`` — tuple of lowercase extensions this loader handles, + e.g. ``(".h5", ".hdf5")``. + + 2. ``open(filepath)`` — open the file and keep any handles alive. + + 3. ``iter_nodes(path)`` — yield ``NodeInfo`` objects for the direct + children of *path* (depth-1 walk; the tree widget calls this + recursively as the user expands nodes). + + 4. ``read_dataset(path)`` — return the dataset at *path* as a + ``numpy.ndarray``. + + 5. ``close()`` — release any open file handles. + + 6. ``child_count(path)`` — return the number of direct children of a + group node (used for the status label; override if cheap to compute). + """ + + EXTENSIONS: tuple[str, ...] = () + + @abstractmethod + def open(self, filepath: str) -> None: ... + + @abstractmethod + def iter_nodes(self, path: str) -> Iterator[NodeInfo]: ... + + @abstractmethod + def read_dataset(self, path: str) -> np.ndarray: ... + + @abstractmethod + def close(self) -> None: ... + + def child_count(self, path: str) -> int: + 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).""" + + EXTENSIONS = (".h5", ".hdf5", ".hdf", ".nxs", ".nx") + + def __init__(self): + self._file: Optional[h5py.File] = None + + def open(self, filepath: str) -> None: + self._file = h5py.File(filepath, "r") + + def close(self) -> None: + if self._file is not None: + self._file.close() + self._file = None + + def iter_nodes(self, path: str) -> Iterator[NodeInfo]: + assert self._file is not None, "File not open" + obj = self._file[path] if path != "/" else self._file + + if not isinstance(obj, h5py.Group): + return + + for key in obj.keys(): + try: + child = obj[key] + except Exception: + continue + + child_path = child.name # h5py always gives the absolute path + + if isinstance(child, h5py.Group): + yield NodeInfo(name=key, path=child_path, kind="group") + + elif isinstance(child, h5py.Dataset): + shape_tuple = child.shape + d = child.dtype + dtype = "unknown" + if np.issubdtype(d, np.integer): + dtype = "int" + if np.issubdtype(d, np.floating): + dtype = "float" + if np.issubdtype(d, np.complexfloating): + dtype = "complex" + if d.kind in ("S", "U", "O"): + dtype = "str" + + yield NodeInfo( + name=key, path=child_path, kind="dataset", dtype=dtype, shape=shape_tuple + ) + + def read_dataset(self, path: str) -> np.ndarray: + assert self._file is not None, "File not open" + return self._file[path][()], "h5" + + def child_count(self, path: str) -> int: + assert self._file is not None, "File not open" + obj = self._file[path] if path != "/" else self._file + return len(obj) if isinstance(obj, h5py.Group) else 0 + + +# ══════════════════════════════════════════════════════════════════════════════ +# Example stub: NumPy .npz loader +# (Uncomment / flesh out to add .npz support) +# ══════════════════════════════════════════════════════════════════════════════ + +# class NpzLoader(BaseFileLoader): +# """Loader for NumPy .npz archives.""" +# +# EXTENSIONS = (".npz",) +# +# def __init__(self): +# self._archive: Optional[np.lib.npyio.NpzFile] = None +# +# def open(self, filepath: str) -> None: +# self._archive = np.load(filepath, allow_pickle=False) +# +# def close(self) -> None: +# if self._archive is not None: +# self._archive.close() +# self._archive = None +# +# def iter_nodes(self, path: str) -> Iterator[NodeInfo]: +# assert self._archive is not None +# # .npz has no hierarchy — treat all arrays as top-level datasets +# if path != "/": +# return +# for key in self._archive.files: +# arr = self._archive[key] +# yield NodeInfo(name=key, path=f"/{key}", kind="dataset", +# dtype=str(arr.dtype), shape=arr.shape) +# +# def read_dataset(self, path: str) -> np.ndarray: +# assert self._archive is not None +# key = path.lstrip("/") +# return self._archive[key] + + +# ══════════════════════════════════════════════════════════════════════════════ +# Image loader (.jpg, .png, .gif, .tiff, …) +# ══════════════════════════════════════════════════════════════════════════════ + + +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. + + Requires: Pillow (``pip install Pillow``) + """ + + EXTENSIONS = ( + ".jpg", + ".jpeg", + ".png", + ".gif", + ".tiff", + ".tif", + ".bmp", + ".webp", + ".ico", + ".ppm", + ".pgm", + ".pbm", + ) + + def __init__(self): + self._filepath: Optional[str] = None + + def open(self, filepath: str) -> None: + # Validate that Pillow can open it; keep only the path. + try: + from PIL import Image as _PILImage # noqa: F401 — existence check + + _PILImage.open(filepath).verify() + except Exception as exc: + raise OSError(f"Cannot open image {filepath!r}: {exc}") from exc + self._filepath = filepath + + def close(self) -> None: + self._filepath = None + + def iter_nodes(self, path: str) -> Iterator[NodeInfo]: + """Images have no internal hierarchy — yield a single leaf node.""" + if path != "/" or self._filepath is None: + return + from PIL import Image as _PILImage + + with _PILImage.open(self._filepath) as img: + w, h = img.size + 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", + ) + + def read_dataset(self, path: str) -> np.ndarray: + """Return the image as a uint8 numpy array (H × W × C or H × 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" + + def child_count(self, path: str) -> int: + return 1 if path == "/" else 0 + + +# ══════════════════════════════════════════════════════════════════════════════ +# Loader registry +# ══════════════════════════════════════════════════════════════════════════════ + + +class LoaderRegistry: + """Maps file extensions to loader classes.""" + + def __init__(self): + self._registry: dict[str, type[BaseFileLoader]] = {} + + def register(self, loader_cls: type[BaseFileLoader]) -> None: + """Register a loader class for all extensions it declares.""" + for ext in loader_cls.EXTENSIONS: + self._registry[ext.lower()] = loader_cls + + def get_loader(self, filepath: str) -> Optional[BaseFileLoader]: + """Return a fresh loader instance for *filepath*, or None if unsupported.""" + ext = os.path.splitext(filepath)[1].lower() + cls = self._registry.get(ext) + return cls() if cls is not None else None + + @property + def supported_extensions(self) -> list[str]: + return sorted(self._registry) + + +# Default global registry — pre-populated with built-in loaders. +_registry = LoaderRegistry() +_registry.register(HDF5Loader) +_registry.register(ImageLoader) +# _registry.register(NpzLoader) ← uncomment once fleshed out + + +# ══════════════════════════════════════════════════════════════════════════════ +# Data / Plot panel (unchanged from original) +# ══════════════════════════════════════════════════════════════════════════════ + + class DataPanel(QWidget): def __init__(self): super().__init__() @@ -100,6 +397,17 @@ class DataPanel(QWidget): lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) self.stack_layout.addWidget(lbl) + def show_unsupported(self, path: str = "") -> None: + """Display a friendly 'not implemented' message for unknown file types.""" + self._clear_stack() + self._current_data = None + self.path_label.setText(path) + self.info_label.setText("") + lbl = QLabel("File type not supported") + lbl.setObjectName("info_label") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.stack_layout.addWidget(lbl) + def _on_mode_change(self): if self._current_data is not None: self.display(self._current_data, self.path_label.text()) @@ -114,7 +422,21 @@ class DataPanel(QWidget): return "auto" # ── public ──────────────────────────────────────────────────────────── - def display(self, data, path=""): + def display(self, data, path: str = "", display_hint: str = "") -> None: + """ + Render *data* in the panel. + + Parameters + ---------- + data: + 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) @@ -125,11 +447,16 @@ class DataPanel(QWidget): f"shape {data.shape} · dtype {data.dtype} · {data.size:,} elements" ) + # Native-image hint takes priority over the radio-button mode selector. + # if display_hint == "image_native": + # self._show_native_image(data) + # return + mode = self._active_mode() if mode == "auto": if data.ndim <= 1 and data.size > 1: mode = "plot" - elif data.ndim == 2 and min(data.shape) > 1: + elif data.ndim <= 4 and min(data.shape) > 1: mode = "image" else: mode = "table" @@ -141,6 +468,68 @@ class DataPanel(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 0–255 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() @@ -183,12 +572,16 @@ class DataPanel(QWidget): slider.setFixedWidth(32) slider.setPageStep(1) - row_label = QLabel("0") - row_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - row_label.setFixedWidth(32) + current_row_label = QLabel("0") + current_row_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + current_row_label.setFixedWidth(32) + + max_row_label = QLabel(f"0:{n_rows-1}") + max_row_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + max_row_label.setFixedWidth(32) def on_row_changed(row): - row_label.setText(str(row)) + current_row_label.setText(str(row)) new_data = data[row].astype(np.float32) new_x = np.arange(new_data.size, dtype=np.float32) curve.setData(new_x, new_data) @@ -201,8 +594,9 @@ class DataPanel(QWidget): col_layout = QVBoxLayout(slider_col) col_layout.setContentsMargins(0, 0, 0, 0) col_layout.setSpacing(2) - col_layout.addWidget(row_label) + col_layout.addWidget(max_row_label) col_layout.addWidget(slider) + col_layout.addWidget(current_row_label) container = QWidget() h_layout = QHBoxLayout(container) @@ -253,26 +647,87 @@ class DataPanel(QWidget): def _show_image_2d(self, data): self._clear_stack() - squeezed = np.squeeze(data) - if squeezed.ndim > 2: - squeezed = squeezed.reshape(-1, squeezed.shape[-1]) + stacked = False + n_images = 0 + rgb = False + img = data + if data.ndim == 3: + if data.shape[-1] in (3, 4): + rgb = True + else: + stacked = True + n_images = data.shape[0] + img = data[0, :] + elif data.ndim == 4: + if data.shape[-1] in (3, 4): + rgb = True + stacked = True + n_images = data.shape[0] + img = data[0, :] - # complex → magnitude - img_data = np.abs(squeezed) if np.iscomplexobj(squeezed) else squeezed.astype(float) - - # ImageView gives us colorbar + histogram + zoom for free self.image_widget = pg.ImageView() + self.image_widget.ui.roiBtn.hide() self.image_widget.ui.menuBtn.hide() - # Use 'inferno'-like LUT - self.image_widget.setColorMap(pg.colormap.get("inferno", source="matplotlib")) + if not rgb: + self.image_widget.setColorMap(pg.colormap.get("inferno", source="matplotlib")) + + def set_image(img, autoLevels=True, autoHistogramRange=True): + if rgb: + self.image_widget.imageItem.setOpts(axisOrder="row-major") + self.image_widget.setImage(img) + else: + self.image_widget.setImage( + img.T, autoLevels=autoLevels, autoHistogramRange=autoHistogramRange + ) + + set_image(img) - # pyqtgraph ImageView expects (cols, rows) — transpose so row 0 is at top - self.image_widget.setImage(img_data.T, autoLevels=True, autoHistogramRange=True) self.image_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self.stack_layout.addWidget(self.image_widget) + if stacked: + # --- Slider --- + slider = QSlider(Qt.Orientation.Vertical) + slider.setMinimum(0) + slider.setMaximum(n_images - 1) + slider.setValue(0) + slider.setFixedWidth(32) + slider.setPageStep(1) + + current_image_label = QLabel("0") + current_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + current_image_label.setFixedWidth(32) + + max_image_label = QLabel(f"0:{n_images-1}") + max_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + max_image_label.setFixedWidth(32) + + def on_image_changed(row): + current_image_label.setText(str(row)) + set_image(data[row, :], autoLevels=False, autoHistogramRange=False) + + slider.valueChanged.connect(on_image_changed) + + slider_col = QWidget() + slider_col.setFixedWidth(36) + col_layout = QVBoxLayout(slider_col) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.setSpacing(2) + col_layout.addWidget(max_image_label) + col_layout.addWidget(slider) + col_layout.addWidget(current_image_label) + + container = QWidget() + h_layout = QHBoxLayout(container) + h_layout.setContentsMargins(0, 0, 0, 0) + h_layout.setSpacing(4) + h_layout.addWidget(slider_col) + h_layout.addWidget(self.image_widget) + + self.stack_layout.addWidget(container) + else: + self.stack_layout.addWidget(self.image_widget) self.apply_theme() @@ -328,11 +783,31 @@ class DataPanel(QWidget): self.stack_layout.addWidget(table) -# ── Main window ──────────────────────────────────────────────────────────── -class HDF5Viewer(QMainWindow): - def __init__(self, filepath=None): +# ══════════════════════════════════════════════════════════════════════════════ +# Main window +# ══════════════════════════════════════════════════════════════════════════════ + + +class FileViewer(QMainWindow): + """ + Generic file 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. + """ + + def __init__(self, filepath: Optional[str] = None, registry: Optional[LoaderRegistry] = None): super().__init__() - self.h5files = {} # filepath -> h5py.File + self._registry = registry or _registry + + # filepath -> (loader_instance, display_name) + self._open_files: dict[str, tuple[BaseFileLoader, str]] = {} + self._build_ui() if filepath: self.load_files([filepath]) @@ -346,13 +821,45 @@ class HDF5Viewer(QMainWindow): """ self.data_panel.apply_theme(theme) - def load_files(self, filepaths: list[str]): - """Open one or more HDF5 files and add each as a top-level tree node.""" - for f in filepaths: - if f in self.h5files: + # ── 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 - self.h5files[f] = h5py.File(f, "r") - self._add_file_to_tree(f) + + loader = self._registry.get_loader(fp) + if loader is None: + supported = ", ".join(self._registry.supported_extensions) + logger.warning("No loader found for %r. Supported extensions: %s", fp, supported) + continue + + try: + loader.open(fp) + except Exception as exc: + logger.error("Failed to open %r: %s", fp, exc) + continue + + display_name = os.path.basename(fp) + self._open_files[fp] = (loader, display_name) + self._add_file_to_tree(fp, loader, display_name) + + def clear_files(self) -> None: + """Close all open files and reset the tree.""" + for loader, _ in self._open_files.values(): + loader.close() + self._open_files.clear() + self.tree.clear() + self.data_panel.show_empty() + + def closeEvent(self, event): + for loader, _ in self._open_files.values(): + loader.close() + self._open_files.clear() + super().closeEvent(event) + + # ── UI construction ─────────────────────────────────────────────────── def _build_ui(self): central = QWidget() @@ -392,21 +899,20 @@ class HDF5Viewer(QMainWindow): root_layout.addWidget(splitter) - def _add_file_to_tree(self, filepath: str): - """Add a single file as a new top-level node in the tree.""" - h5file = self.h5files[filepath] - filename = filepath.split("/")[-1] + # ── 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( "dataset", size=(ICON_SIZE, ICON_SIZE), color=get_accent_colors().default.name() ) - root_item = QTreeWidgetItem(self.tree, [filename, "Group", ""]) + root_item = QTreeWidgetItem(self.tree, [display_name, "Group", ""]) root_item.setIcon(0, dataset_icon) - root_item.setData(0, Qt.ItemDataRole.UserRole, "/") - root_item.setData(0, Qt.ItemDataRole.UserRole + 1, "group") - root_item.setData(0, Qt.ItemDataRole.UserRole + 2, filepath) # so clicks know which file + root_item.setData(0, Qt.ItemDataRole.UserRole, "/") # path + root_item.setData(0, Qt.ItemDataRole.UserRole + 1, "group") # kind + root_item.setData(0, Qt.ItemDataRole.UserRole + 2, filepath) # file key - self.populate_tree(root_item, h5file) + self._populate_tree(root_item, loader, "/") self.tree.addTopLevelItem(root_item) # Expand first 2 levels @@ -416,7 +922,9 @@ class HDF5Viewer(QMainWindow): self.tree.setCurrentItem(root_item) - def populate_tree(self, parent_item, h5_obj): + def _populate_tree( + self, parent_item: QTreeWidgetItem, loader: BaseFileLoader, path: str + ) -> None: folder_icon = material_icon("folder", size=(ICON_SIZE, ICON_SIZE), color="#2980b9") vector_icon = material_icon("show_chart", size=(ICON_SIZE, ICON_SIZE), color="#2980b9") array_icon = material_icon( @@ -425,57 +933,33 @@ class HDF5Viewer(QMainWindow): scalar_icon = material_icon("point_scan", size=(ICON_SIZE, ICON_SIZE), color="#2980b9") str_icon = material_icon("text_snippet", size=(ICON_SIZE, ICON_SIZE), color="#2980b9") - if not isinstance(h5_obj, h5py.Group): - return + for node in loader.iter_nodes(path): + shape_str = "x".join(str(s) for s in node.shape) if node.shape else "scalar" - for key in h5_obj.keys(): - try: - child = h5_obj[key] - except Exception: - continue - - if isinstance(child, h5py.Group): - item = QTreeWidgetItem(parent_item, [key, "Group", ""]) + if node.kind == "group": + item = QTreeWidgetItem(parent_item, [node.name, "Group", ""]) item.setIcon(0, folder_icon) - - item.setData(0, Qt.ItemDataRole.UserRole, child.name) + item.setData(0, Qt.ItemDataRole.UserRole, node.path) item.setData(0, Qt.ItemDataRole.UserRole + 1, "group") + self._populate_tree(item, loader, node.path) - self.populate_tree(item, child) - - elif isinstance(child, h5py.Dataset): - shape_str = "x".join(str(s) for s in child.shape) or "scalar" - - d = child.dtype - dtype = "unknown" - if np.issubdtype(d, np.integer): - dtype = "int" - if np.issubdtype(d, np.floating): - dtype = "float" - if np.issubdtype(d, np.complexfloating): - dtype = "complex" - if d.kind in ("S", "U", "O"): - dtype = "str" - + else: # dataset if shape_str == "scalar": - if dtype == "str": - icon = str_icon - else: - icon = scalar_icon + icon = str_icon if node.dtype == "str" else scalar_icon elif "x" in shape_str: icon = array_icon else: icon = vector_icon - item = QTreeWidgetItem(parent_item, [key, dtype, shape_str]) + item = QTreeWidgetItem(parent_item, [node.name, node.dtype, shape_str]) item.setIcon(0, icon) - - item.setData(0, Qt.ItemDataRole.UserRole, child.name) + item.setData(0, Qt.ItemDataRole.UserRole, node.path) item.setData(0, Qt.ItemDataRole.UserRole + 1, "dataset") - 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 @@ -483,7 +967,7 @@ class HDF5Viewer(QMainWindow): node = node.parent() return node.data(0, Qt.ItemDataRole.UserRole + 2) - def _on_item_clicked(self, item, _col): + def _on_item_clicked(self, item: QTreeWidgetItem, _col: int) -> None: path = item.data(0, Qt.ItemDataRole.UserRole) kind = item.data(0, Qt.ItemDataRole.UserRole + 1) filepath = self._get_filepath_for_item(item) @@ -491,25 +975,16 @@ class HDF5Viewer(QMainWindow): if not path or not filepath: return - h5file = self.h5files[filepath] - obj = h5file[path] if path != "/" else h5file + loader, _ = self._open_files[filepath] if kind == "dataset": - data = h5file[path][()] - self.data_panel.display(data, path) + data, ftype = loader.read_dataset(path) + self.data_panel.display(data, path, ftype) else: - n = len(obj) if isinstance(obj, h5py.Group) else 0 + # Group selected — show child count in status bar (optional) + count = loader.child_count(path) + self.statusBar().showMessage(f"{path} ({count} items)") - def clear_files(self): - """Close all open HDF5 files and reset the tree.""" - for h5file in self.h5files.values(): - h5file.close() - self.h5files.clear() - self.tree.clear() - self.data_panel.show_empty() - def closeEvent(self, event): - for h5file in self.h5files.values(): - h5file.close() - self.h5files.clear() - super().closeEvent(event) +# Backwards-compatible alias for code that imported HDF5Viewer by name. +HDF5Viewer = FileViewer