Files
debye_bec/debye_bec/bec_widgets/widgets/data_viewer/viewer.py
T
hitz_s d4edb4e3af
CI for debye_bec / test (push) Successful in 1m2s
proof of concept
2026-06-11 15:29:13 +02:00

531 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
HDF5 Viewer — PyQt5 + pyqtgraph + h5py
Usage: python hdf5_viewer.py <filename.h5>
"""
import sys
import os
import numpy as np
import h5py
from qtpy.QtWidgets import (
QApplication, QMainWindow, QSplitter, QTreeWidget, QTreeWidgetItem,
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget,
QTableWidgetItem, QAbstractItemView, QSizePolicy, QStatusBar,
QRadioButton, QGroupBox, QHeaderView,
)
from qtpy.QtCore import Qt
from qtpy.QtGui import QFont, QColor
import pyqtgraph as pg
# ── Palette ────────────────────────────────────────────────────────────────
BG_DARK = "#1C1E26"
BG_MID = "#252731"
BG_PANEL = "#2D3040"
ACCENT = "#7C9CF5"
TEXT_PRI = "#E8EAF2"
TEXT_SEC = "#8A8FA8"
BORDER = "#3A3D50"
SEL_BG = "#3A4268"
GROUP_BG = "#21232D"
SUCCESS = "#5FCEA8"
# pyqtgraph color tuples (R, G, B)
PG_BG = (28, 30, 38)
PG_PLOT_BG = (45, 48, 64)
PG_ACCENT = (124, 156, 245)
PG_GRID = (58, 61, 80)
PG_TEXT = (138, 143, 168)
STYLESHEET = f"""
QMainWindow, QWidget {{
background-color: {BG_DARK};
color: {TEXT_PRI};
font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', sans-serif;
font-size: 13px;
}}
QSplitter::handle {{
background: {BORDER};
width: 1px; height: 1px;
}}
QTreeWidget {{
background-color: {BG_MID};
border: 1px solid {BORDER};
border-radius: 6px;
outline: none;
padding: 4px;
}}
QTreeWidget::item {{ padding: 4px 6px; border-radius: 4px; }}
QTreeWidget::item:hover {{ background: {BG_PANEL}; }}
QTreeWidget::item:selected {{ background: {SEL_BG}; color: {TEXT_PRI}; }}
QHeaderView::section {{
background-color: {BG_PANEL}; color: {TEXT_SEC};
border: none; padding: 4px 6px;
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
}}
QTableWidget {{
background-color: {BG_MID};
border: 1px solid {BORDER};
border-radius: 6px;
gridline-color: {BORDER};
outline: none;
}}
QTableWidget::item {{ padding: 3px 8px; color: {TEXT_PRI}; }}
QTableWidget::item:selected {{ background: {SEL_BG}; }}
QTableCornerButton::section {{ background: {BG_PANEL}; border: none; }}
QLabel#section_title {{
font-size: 11px; font-weight: 600; color: {TEXT_SEC};
text-transform: uppercase; letter-spacing: 0.08em; padding: 2px 0 6px 2px;
}}
QLabel#info_label {{ color: {TEXT_SEC}; font-size: 12px; padding: 4px; }}
QLabel#path_label {{ color: {ACCENT}; font-size: 12px; font-weight: 500; padding: 2px 4px; }}
QPushButton {{
background-color: {BG_PANEL}; color: {TEXT_PRI};
border: 1px solid {BORDER}; border-radius: 5px; padding: 5px 14px; font-size: 12px;
}}
QPushButton:hover {{ background-color: {SEL_BG}; border-color: {ACCENT}; }}
QPushButton:pressed {{ background-color: {ACCENT}; color: white; }}
QRadioButton {{ color: {TEXT_PRI}; spacing: 6px; font-size: 12px; }}
QRadioButton::indicator {{
width: 14px; height: 14px; border-radius: 7px;
border: 2px solid {BORDER}; background: {BG_MID};
}}
QRadioButton::indicator:checked {{ background: {ACCENT}; border-color: {ACCENT}; }}
QGroupBox {{
background: {GROUP_BG}; border: 1px solid {BORDER}; border-radius: 6px;
margin-top: 10px; padding: 8px; font-size: 11px; color: {TEXT_SEC};
}}
QGroupBox::title {{
subcontrol-origin: margin; left: 8px; padding: 0 4px;
color: {TEXT_SEC}; text-transform: uppercase; letter-spacing: 0.06em; font-size: 10px;
}}
QStatusBar {{
background: {BG_PANEL}; color: {TEXT_SEC};
border-top: 1px solid {BORDER}; font-size: 11px;
}}
QScrollBar:vertical {{ background: {BG_MID}; width: 8px; border-radius: 4px; }}
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 4px; min-height: 20px; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
QScrollBar:horizontal {{ background: {BG_MID}; height: 8px; border-radius: 4px; }}
QScrollBar::handle:horizontal {{ background: {BORDER}; border-radius: 4px; min-width: 20px; }}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }}
"""
# ── pyqtgraph global config ────────────────────────────────────────────────
pg.setConfigOptions(
background=pg.mkColor(*PG_BG),
foreground=pg.mkColor(*PG_TEXT),
antialias=True,
useOpenGL=False, # safer default; set True for large datasets if GPU available
)
def _styled_plot_widget(**kwargs) -> pg.PlotWidget:
"""Return a PlotWidget already themed to match the dark palette."""
pw = pg.PlotWidget(**kwargs)
pw.setBackground(pg.mkColor(*PG_PLOT_BG))
ax = pw.getPlotItem()
for side in ("left", "bottom", "right", "top"):
ax.getAxis(side).setPen(pg.mkPen(color=PG_GRID, width=1))
ax.getAxis(side).setTextPen(pg.mkPen(color=PG_TEXT))
ax.showGrid(x=True, y=True, alpha=0.25)
return pw
# ── HDF5 Tree helpers ──────────────────────────────────────────────────────
TYPE_ICONS = {"group": "📂", "dataset": "📊"}
def dtype_tag(ds):
d = ds.dtype
if np.issubdtype(d, np.integer): return "int"
if np.issubdtype(d, np.floating): return "float"
if np.issubdtype(d, np.complexfloating): return "complex"
if d.kind in ("S", "U", "O"): return "str"
return str(d)
def populate_tree(parent_item, h5_obj):
if not isinstance(h5_obj, h5py.Group):
return
for key in h5_obj.keys():
child = h5_obj[key]
if isinstance(child, h5py.Group):
label = f"{TYPE_ICONS['group']} {key}"
item = QTreeWidgetItem(parent_item, [label, "Group", ""])
item.setData(0, Qt.UserRole, child.name)
item.setData(0, Qt.UserRole + 1, "group")
item.setForeground(0, QColor(ACCENT))
populate_tree(item, child)
elif isinstance(child, h5py.Dataset):
shape_str = "×".join(str(s) for s in child.shape) or "scalar"
label = f"{TYPE_ICONS['dataset']} {key}"
item = QTreeWidgetItem(parent_item, [label, dtype_tag(child), shape_str])
item.setData(0, Qt.UserRole, child.name)
item.setData(0, Qt.UserRole + 1, "dataset")
item.setForeground(1, QColor(SUCCESS))
item.setForeground(2, QColor(TEXT_SEC))
# ── Data / Plot panel ──────────────────────────────────────────────────────
class DataPanel(QWidget):
def __init__(self):
super().__init__()
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(8)
# Header
hdr = QHBoxLayout()
self.path_label = QLabel("Select a dataset from the tree")
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)
# View-mode selector
mode_box = QGroupBox("View mode")
mode_layout = QHBoxLayout(mode_box)
mode_layout.setContentsMargins(8, 4, 8, 4)
self.rb_auto = QRadioButton("Auto")
self.rb_plot = QRadioButton("Plot")
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):
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._current_data = None
self._show_empty()
# ── helpers ───────────────────────────────────────────────────────────
def _clear_stack(self):
while self.stack_layout.count():
item = self.stack_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
def _show_empty(self):
self._clear_stack()
lbl = QLabel("No data selected")
lbl.setObjectName("info_label")
lbl.setAlignment(Qt.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())
def _active_mode(self):
if self.rb_plot.isChecked(): return "plot"
if self.rb_image.isChecked(): return "image"
if self.rb_table.isChecked(): return "table"
return "auto"
# ── public ────────────────────────────────────────────────────────────
def display(self, data, path=""):
self._current_data = data
self.path_label.setText(path)
if not isinstance(data, np.ndarray):
data = np.array(data)
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 == 2 and min(data.shape) > 1:
mode = "image"
else:
mode = "table"
if mode == "plot":
self._show_plot_1d(data)
elif mode == "image":
self._show_image_2d(data)
else:
self._show_table(data)
# ── 1-D line plot ──────────────────────────────────────────────────────
def _show_plot_1d(self, data):
self._clear_stack()
flat = data.flatten().astype(float)
pw = _styled_plot_widget()
pw.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Filled area under curve
x = np.arange(len(flat), dtype=float)
fill = pg.FillBetweenItem(
pg.PlotDataItem(x, flat),
pg.PlotDataItem(x, np.zeros_like(flat)),
brush=pg.mkBrush(124, 156, 245, 35),
)
pw.addItem(fill)
pw.plot(x, flat,
pen=pg.mkPen(color=PG_ACCENT, width=1.6),
antialias=True)
pw.getPlotItem().setLabel("bottom", "index")
pw.getPlotItem().setLabel("left", "value")
self.stack_layout.addWidget(pw)
# ── 2-D image ──────────────────────────────────────────────────────────
def _show_image_2d(self, data):
self._clear_stack()
squeezed = np.squeeze(data)
if squeezed.ndim > 2:
squeezed = squeezed.reshape(-1, squeezed.shape[-1])
# complex → magnitude
img_data = np.abs(squeezed) if np.iscomplexobj(squeezed) else squeezed.astype(float)
# ImageView gives us colorbar + histogram + zoom for free
iv = pg.ImageView()
iv.setBackground(pg.mkColor(*PG_BG))
iv.ui.histogram.setBackground(pg.mkColor(*PG_PLOT_BG))
iv.ui.roiBtn.hide()
iv.ui.menuBtn.hide()
# Use 'inferno'-like LUT
iv.setColorMap(pg.colormap.get("inferno", source="matplotlib"))
# pyqtgraph ImageView expects (cols, rows) — transpose so row 0 is at top
iv.setImage(img_data.T, autoLevels=True, autoHistogramRange=True)
iv.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.stack_layout.addWidget(iv)
# ── 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:
flat = data.reshape(-1, 1)
elif data.ndim == 2:
flat = data
else:
flat = data.reshape(-1, data.shape[-1])
rows, cols = flat.shape
show_rows = min(rows, MAX_ROWS)
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.setObjectName("info_label")
note.setAlignment(Qt.AlignCenter)
self.stack_layout.addWidget(note)
table = QTableWidget(show_rows, show_cols)
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
table.setSelectionMode(QAbstractItemView.ContiguousSelection)
table.horizontalHeader().setSectionResizeMode(QHeaderView.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)
for r in range(show_rows):
for c in range(show_cols):
val = flat[r, c]
txt = f"{val:.6g}" if is_float else (f"{val:.4g}" if is_complex else str(val))
cell = QTableWidgetItem(txt)
cell.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
table.setItem(r, c, cell)
self.stack_layout.addWidget(table)
# ── Attributes panel ───────────────────────────────────────────────────────
class AttrsPanel(QWidget):
def __init__(self):
super().__init__()
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
title = QLabel("Attributes")
title.setObjectName("section_title")
layout.addWidget(title)
self.table = QTableWidget(0, 2)
self.table.setHorizontalHeaderLabels(["Key", "Value"])
self.table.horizontalHeader().setStretchLastSection(True)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.verticalHeader().hide()
self.table.verticalHeader().setDefaultSectionSize(22)
layout.addWidget(self.table)
def set_attrs(self, attrs_dict):
self.table.setRowCount(0)
for key, val in attrs_dict.items():
row = self.table.rowCount()
self.table.insertRow(row)
self.table.setItem(row, 0, QTableWidgetItem(str(key)))
self.table.setItem(row, 1, QTableWidgetItem(str(val)))
# ── Main window ────────────────────────────────────────────────────────────
class HDF5Viewer(QMainWindow):
def __init__(self, filepath=None):
super().__init__()
self.filepath = filepath
self.h5file = None
# self.setWindowTitle(f"HDF5 Viewer — {os.path.basename(filepath)}")
# self.resize(1280, 800)
self._build_ui()
def load_file(self, f):
self.filepath = f
self.h5file = h5py.File(f, "r")
self._load_tree()
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root_layout = QVBoxLayout(central)
root_layout.setContentsMargins(10, 10, 10, 6)
root_layout.setSpacing(6)
# Top bar
top_bar = QHBoxLayout()
# file_label = QLabel(f"📁 {self.filepath}")
# file_label.setObjectName("path_label")
# top_bar.addWidget(file_label)
top_bar.addStretch()
root_layout.addLayout(top_bar)
# Main horizontal splitter
h_split = QSplitter(Qt.Horizontal)
h_split.setHandleWidth(2)
# ── Left: tree + attrs ──
left_pane = QWidget()
left_layout = QVBoxLayout(left_pane)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(6)
tree_title = QLabel("Structure")
tree_title.setObjectName("section_title")
left_layout.addWidget(tree_title)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Name", "Type", "Shape"])
self.tree.header().setStretchLastSection(False)
self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
self.tree.header().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.tree.header().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.tree.setIndentation(16)
self.tree.itemClicked.connect(self._on_item_clicked)
left_layout.addWidget(self.tree, 3)
self.attrs_panel = AttrsPanel()
left_layout.addWidget(self.attrs_panel, 1)
h_split.addWidget(left_pane)
# ── Right: data panel ──
self.data_panel = DataPanel()
h_split.addWidget(self.data_panel)
h_split.setStretchFactor(0, 1)
h_split.setStretchFactor(1, 3)
h_split.setSizes([320, 960])
root_layout.addWidget(h_split, 1)
# self.status = QStatusBar()
# self.setStatusBar(self.status)
# self.status.showMessage(f"Loaded: {self.filepath}")
def _load_tree(self):
self.tree.clear()
root_item = QTreeWidgetItem(self.tree, ["📂 /", "Group", ""])
root_item.setData(0, Qt.UserRole, "/")
root_item.setData(0, Qt.UserRole + 1, "group")
root_item.setForeground(0, QColor(ACCENT))
populate_tree(root_item, self.h5file)
self.tree.addTopLevelItem(root_item)
# Expand first 2 levels
self.tree.expandItem(root_item)
for i in range(root_item.childCount()):
self.tree.expandItem(root_item.child(i))
self.tree.setCurrentItem(root_item)
def _on_item_clicked(self, item, _col):
path = item.data(0, Qt.UserRole)
kind = item.data(0, Qt.UserRole + 1)
if not path:
return
obj = self.h5file[path] if path != "/" else self.h5file
attrs = dict(obj.attrs) if obj.attrs else {}
self.attrs_panel.set_attrs(attrs)
if kind == "dataset":
ds = self.h5file[path]
data = ds[()]
self.data_panel.display(data, path)
self.status.showMessage(
f"{path} | shape {ds.shape} | dtype {ds.dtype}"
)
else:
n = len(obj) if isinstance(obj, h5py.Group) else 0
# self.status.showMessage(
# f"{path} | Group | {n} items | {len(attrs)} attributes"
# )
def closeEvent(self, event):
self.h5file.close()
super().closeEvent(event)
# ── Entry point ────────────────────────────────────────────────────────────
def main():
if len(sys.argv) < 2:
print("Usage: python hdf5_viewer.py <file.h5>")
sys.exit(1)
filepath = sys.argv[1]
if not os.path.isfile(filepath):
print(f"Error: file not found — {filepath}")
sys.exit(1)
app = QApplication(sys.argv)
app.setApplicationName("HDF5 Viewer")
app.setStyleSheet(STYLESHEET)
window = HDF5Viewer(filepath)
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()