Added support for multiple files within scan. Improved image support.
CI for debye_bec / test (push) Successful in 57s

This commit is contained in:
x01da
2026-06-22 15:37:24 +02:00
parent ca3ca5193a
commit 59e1dd911c
2 changed files with 590 additions and 100 deletions
@@ -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, *_):
@@ -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 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()
@@ -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