531 lines
20 KiB
Python
531 lines
20 KiB
Python
#!/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() |