はじめに
実験計測や数値解析では,サンプリング周波数が高い時系列データをHDF5に保存することがよくあります。
例えば,1 MHz〜100 MHzで1〜10秒程度の信号を保存すると,データ点数は簡単に数百万〜10億点規模になります。
このサイズになると,普段の感覚で
plt.plot(t, y)
のように全点をMatplotlibで描画するのはかなり厳しくなります。
HDF5ファイル自体は問題なく保存できても,「ちょっと中身を確認したい」だけで描画が重くなり,解析のテンポが悪くなります。
研究室では長くIgorというソフトを使って描画を行い,これによりこうしたストレスは低減できていましたが,Macのサポート終了がアナウンスされ,これまでMac上でIgorを使ってグラフを描くことを原則とするルールが崩れつつあります。
自分一人ならなんとでもなりますが,研究室内で気軽に配れる無料ツールとしては,もう少し小さくて単純なものが欲しくなりました。
そこで,HDF5中の1次元データセットを素早く眺めるための簡易ビューアを,Python + PyQtGraph + h5pyで作りました。
この記事では,その設計方針と実装の概要を紹介します。
作ったもの
名前はひとまず hdf5-wave-viewer としました。
できることは以下です。
- HDF5ファイルを開く
- ファイル内の1D datasetを一覧表示する
- 複数のdatasetを同時にプロットする
- 最大8系列程度まで同時表示する
- サンプリング周波数を指定して,横軸を時間に変換する
- Hz / kHz / MHz / GHzで周波数単位を選べる
- ns / us / ms / sで時間範囲の単位を選べる
- 表示範囲
t0〜t1を指定できる - 単純間引き表示とmin/max envelope表示を切り替えられる
- マウスホイールによる意図しないズームを無効化できる
- pip / uv でインストールできる
研究室内の学生に配ることを考えて,ひとまずPythonパッケージとして使える構成にしています。
なぜMatplotlibだけではつらいのか
例えば100 MHzで10秒分を保存すると,
100 MHz × 10 s = 1,000,000,000 points
となります。
画面の横幅はせいぜい数千ピクセルなので,10億点をすべて線として描いても,ほとんどの点は画面上で区別できません。
それにもかかわらず,全点をプロットしようとすると,データ転送,描画,GUI更新のすべてが重くなります。
したがって,この種のビューアでは,
表示に必要な点だけを読む・描く
ことが重要です。
基本方針
今回のビューアでは,HDF5側には生データだけを保存しておきます。
事前に間引き済みデータセットやピラミッド構造は作りません。
代わりに,表示時に以下のどちらかの方法で軽量化します。
1. stride mode
指定時間範囲から,一定間隔でデータを読む方式です。
stride = max(1, ceil(n_selected / target_points))
y_view = dset[i0:i1:stride]
HDF5のスライスで直接読めるので速いです。
全体像をざっくり見る用途に向いています。
ただし,短時間のスパイクは見落とす可能性があります。
2. envelope mode
指定時間範囲を複数のビンに分け,各ビンの最小値・最大値を表示する方式です。
y_min[k] = min(y[a:b])
y_max[k] = max(y[a:b])
単純間引きよりもピークを落としにくく,実験波形の確認にはこちらの方が安心な場合があります。
ただし,現在の実装では指定範囲を一度メモリに読み込んでから集約しているため,最初に全区間を表示する用途では stride mode の方が軽いです。
プロジェクト構成
最小構成は以下です。
hdf5-wave-viewer/
├── pyproject.toml
├── README.md
├── LICENSE
└── src/
└── hdf5_wave_viewer/
├── __init__.py
├── app.py
├── viewer.py
├── io.py
└── downsample.py
役割は以下のように分けています。
| ファイル | 役割 |
|---|---|
app.py |
コマンドラインエントリポイント |
viewer.py |
PySide6 / PyQtGraphによるGUI本体 |
io.py |
HDF5ファイル内の1D dataset探索 |
downsample.py |
stride / envelopeによる表示用データ生成 |
pyproject.toml |
pip / uvでインストールするための設定 |
pyproject.toml
pip install . や uv run で使えるように,pyproject.toml は以下のようにします。
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[project]
name = "hdf5-wave-viewer"
version = "0.1.0"
description = "A lightweight HDF5 time-series viewer for large 1D datasets"
requires-python = ">=3.10"
dependencies = [
"numpy>=1.23",
"h5py>=3.8",
"pyqtgraph>=0.13",
"PySide6>=6.5",
]
[project.scripts]
hdf5-wave-viewer = "hdf5_wave_viewer.app:main"
[tool.hatch.build.targets.wheel]
packages = ["src/hdf5_wave_viewer"]
これで,インストール後に
hdf5-wave-viewer
というコマンドでGUIを起動できます。
HDF5中の1D datasetを探す
io.py では,HDF5ファイル内を再帰的に調べ,1次元データセットだけを一覧にします。
from dataclasses import dataclass
import h5py
@dataclass(frozen=True)
class DatasetInfo:
path: str
shape: tuple[int, ...]
dtype: str
sampling_rate: float | None = None
def list_1d_datasets(filename: str) -> list[DatasetInfo]:
results: list[DatasetInfo] = []
def visitor(name: str, obj):
if isinstance(obj, h5py.Dataset) and obj.ndim == 1:
fs = None
for key in ("sampling_rate", "fs", "sample_rate", "SamplingRate"):
if key in obj.attrs:
try:
fs = float(obj.attrs[key])
except Exception:
fs = None
break
results.append(
DatasetInfo(
path="/" + name,
shape=tuple(obj.shape),
dtype=str(obj.dtype),
sampling_rate=fs,
)
)
with h5py.File(filename, "r") as f:
f.visititems(visitor)
return sorted(results, key=lambda item: item.path)
サンプリング周波数はHDF5属性に入っていれば自動取得します。
今は以下の属性名を候補にしています。
sampling_rate
fs
sample_rate
SamplingRate
表示用データを作る
downsample.py では,HDF5 datasetから表示用の軽量データを作ります。
stride mode
import numpy as np
def read_stride(dset, fs, t0, t1, target_points=50_000):
i0 = int(round(t0 * fs))
i1 = int(round(t1 * fs))
i0 = max(0, min(i0, dset.shape[0] - 1))
i1 = max(i0 + 1, min(i1, dset.shape[0]))
nsel = i1 - i0
stride = max(1, int(np.ceil(nsel / target_points)))
y = np.asarray(dset[i0:i1:stride])
indices = i0 + np.arange(len(y), dtype=np.float64) * stride
t = indices / fs
return t, y
この方式では,HDF5から必要な点だけをスライスで読みます。
全範囲をざっと見るときに便利です。
envelope mode
import numpy as np
def read_envelope(dset, fs, t0, t1, target_bins=5_000):
i0 = int(round(t0 * fs))
i1 = int(round(t1 * fs))
i0 = max(0, min(i0, dset.shape[0] - 1))
i1 = max(i0 + 1, min(i1, dset.shape[0]))
y_full = np.asarray(dset[i0:i1])
n = len(y_full)
target_bins = min(target_bins, n)
bin_size = max(1, n // target_bins)
n_use = bin_size * target_bins
yy = y_full[:n_use].reshape(target_bins, bin_size)
y_min = yy.min(axis=1)
y_max = yy.max(axis=1)
y_mid = 0.5 * (y_min + y_max)
idx = i0 + (np.arange(target_bins) + 0.5) * bin_size
t = idx / fs
return t, y_mid, y_min, y_max
この方式では,画面上の各ビンに対して最小値・最大値を残すため,単純間引きよりスパイクを見落としにくくなります。
PyQtGraphで描画する
描画にはPyQtGraphを使います。
Matplotlibよりもインタラクティブ表示が軽く,ズーム・パンしながら波形を見る用途に向いています。
今回のビューアでは,背景を白にして,系列ごとに自動で色を変えるようにしました。
import pyqtgraph as pg
pg.setConfigOptions(antialias=False)
plot_widget = pg.PlotWidget()
plot_widget.setBackground("w")
plot_widget.showGrid(x=True, y=True, alpha=0.25)
plot_widget.setLabel("bottom", "Time", units="s")
plot_widget.setLabel("left", "Signal")
複数系列の色は,例えば以下のように自動で回します。
COLORS = [
(31, 119, 180),
(255, 127, 14),
(44, 160, 44),
(214, 39, 40),
(148, 103, 189),
(140, 86, 75),
(227, 119, 194),
(127, 127, 127),
]
pen = pg.mkPen(COLORS[i % len(COLORS)], width=1.2)
plot_widget.plot(t, y, pen=pen, name=dataset_path)
マウスホイールズームを無効化する
PyQtGraphはデフォルトでマウスホイールによるズームが有効です。
ただ,大規模時系列を眺めていると,少し触れただけで表示範囲を見失うことがあります。
そこで,ホイールズームを無効化できる ViewBox を作りました。
import pyqtgraph as pg
class WheelControlledViewBox(pg.ViewBox):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.wheel_zoom_enabled = False
def set_wheel_zoom_enabled(self, enabled: bool) -> None:
self.wheel_zoom_enabled = bool(enabled)
def wheelEvent(self, ev, axis=None):
if self.wheel_zoom_enabled:
super().wheelEvent(ev, axis=axis)
else:
ev.ignore()
普段は無効にしておき,必要なときだけGUI上のチェックボックスで有効にします。
単位選択
実験データでは,100 MHz,10 ms,数 usなどの指定が多く,すべてをHzと秒で入力するのは面倒です。
そこで,GUI上では単位を選べるようにしました。
周波数は以下です。
Hz, kHz, MHz, GHz
時間は以下です。
ns, us, ms, s
内部ではすべてHzと秒に変換して扱います。
FREQ_UNITS = {
"Hz": 1.0,
"kHz": 1e3,
"MHz": 1e6,
"GHz": 1e9,
}
TIME_UNITS = {
"ns": 1e-9,
"us": 1e-6,
"ms": 1e-3,
"s": 1.0,
}
例えばGUI上で
fs = 100 MHz
t0 = 0 ms
t1 = 10 ms
と指定した場合,内部では
fs = 100000000 Hz
t0 = 0.0 s
t1 = 0.01 s
として扱います。
インストール方法
ローカルで試す場合は,以下です。
git clone https://github.com/takakiba/hdf5-wave-viewer.git
cd hdf5-wave-viewer
pip install .
hdf5-wave-viewer
uv を使う場合は,
git clone https://github.com/takakiba/hdf5-wave-viewer.git
cd hdf5-wave-viewer
uv sync
uv run hdf5-wave-viewer
HDF5ファイルを指定して起動する場合は,
uv run hdf5-wave-viewer path/to/data.h5
です。
使い方
-
Open HDF5...でHDF5ファイルを開く - 左側のdataset一覧から1D datasetを選ぶ
- 複数選択したい場合は,Ctrl / Cmd / Shiftで複数選ぶ
- サンプリング周波数と単位を指定する
-
t0,t1と時間単位を指定する -
strideまたはenvelopeを選ぶ -
Reloadで描画する
全体をまず見るときは stride,狭い時間範囲でピークを見たいときは envelope が使いやすいです。
実装してみて良かった点
1. 「読む前に減らす」のが効く
Matplotlibで全点を読んでから間引くのではなく,HDF5のスライスで最初から必要点だけ読むと,かなり快適になります。
y = dset[i0:i1:stride]
この形にできるのがHDF5 + h5pyの便利なところです。
2. PyQtGraphは探索用途に向いている
論文図の最終調整はMatplotlibやVeuszの方が便利な場面もありますが,波形をぐりぐり見ながら確認する用途ではPyQtGraphがかなり軽快でした。
3. pip/uvで配れる形にすると研究室内で使いやすい
Githubに(Publicリポジトリとして)置いておけば,学生側では以下だけで使えます。
git clone https://github.com/takakiba/hdf5-wave-viewer.git
cd hdf5-wave-viewer
uv run hdf5-wave-viewer
Pythonある程度市民権を得た今なら,この配布方法はかなり楽です。
今後やりたいこと
現時点では簡易ビューアですが,今後は以下を追加したいです。
- 現在表示している範囲をCSVやPNGにエクスポートする機能
- PyInstallerによる実行ファイル配布
まとめ
大規模時系列データを眺めるときは,すべての点を描画する必要はありません。
画面表示に必要な点数まで減らしてから描画するだけで,かなり快適になります。
今回作った hdf5-wave-viewer は,HDF5に保存された1D時系列データを,PyQtGraphで軽快に表示するための小さなツールです。
ポイントは以下です。
- HDF5には生データだけを保存する
- 表示時に必要な範囲だけ読む
- 全体表示にはstride modeを使う
- ピーク確認にはmin/max envelopeを使う
- PyQtGraphでインタラクティブに表示する
- pip / uvで研究室内配布しやすくする
実験データや数値解析データをHDF5で管理していて,「とりあえず波形を軽く見たい」という用途には,このくらいの小さなビューアでもかなり役に立ちそうです。