Added support for multiple files within scan. Improved image support.
CI for debye_bec / test (push) Successful in 57s
CI for debye_bec / test (push) Successful in 57s
This commit is contained in:
@@ -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 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
|
||||
|
||||
Reference in New Issue
Block a user