Source code for pimm.datasets.pilarnet.h5

"""PILArNet-M dataset read directly from clustered HDF5 shards."""

import glob
import os
from copy import deepcopy
from pathlib import Path
from typing import Literal

import h5py
import numpy as np
from torch.utils.data import Dataset

from pimm.utils.logger import get_root_logger

from ..builder import DATASETS
from ..transform import TRANSFORMS, Compose
from .decode import decode_event
from .overlay import PILArNetOverlayMixin


[docs] @DATASETS.register_module() class PILArNetH5Dataset(PILArNetOverlayMixin, Dataset): """PILArNet-M LArTPC dataset read directly from clustered HDF5 shards. Loads events straight from ``point``/``cluster``/``cluster_extra`` HDF5 arrays (no per-event preprocessing), expands per-cluster truth to per-point arrays, and emits a flat ``dict``. Standard keys per item: ``coord`` (N, 3), ``energy`` (N, 1, raw), ``segment_motif`` (N, 1, semantic class), ``segment_pid`` (N, 1, PID; v2/v3), ``momentum`` (N, 1; v2/v3), ``vertex`` (N, 3; v2/v3, interaction vertex in v3), ``is_primary`` (N, 1; v3 only), ``instance_particle`` and ``instance_interaction`` (N, 1, remapped contiguous ids), ``segment_interaction`` (N, 1, background flag), plus ``name``/``split``/``revision``. After collation a batch adds ``offset``. Registered as ``PILArNetH5Dataset`` -- use as ``type`` under ``data.train``/``data.val``/``data.test``. Semantic classes (``segment_motif``): 0 shower, 1 track, 2 Michel, 3 delta, 4 low-energy deposit. PID classes (``segment_pid``): 0 photon, 1 electron, 2 muon, 3 pion, 4 proton, 5 none/LED (6 when ``old_pid_mapping``). Args: data_root (str | None): Root directory of the revision's HDF5 shards. When ``None``, falls back to ``$PILARNET_DATA_ROOT_V1/_V2/_V3`` (by ``revision``) then to ``~/.cache/pimm/pilarnet/<revision>``; raises if none exist. Defaults to ``None``. split (str | Sequence[str]): Split name(s) used to glob ``*<split>/*.h5`` under ``data_root``. Defaults to ``"train"``. transform (list[dict]): List of transform configs (NOT a prebuilt ``Compose``). Defaults to ``None``. test_mode (bool): Emit voxelized/augmented test fragments and force ``loop = 1``. Defaults to ``False``. test_cfg (object): Test config (``voxelize``, ``crop``, ``post_transform``, ``aug_transform``); required when ``test_mode``. Defaults to ``None``. loop (int): Train-time epoch multiplier. Defaults to ``1``. ignore_index (int): Ignored-label value. Defaults to ``-1``. energy_threshold (float): Drop points with energy at or below this value when positive. Defaults to ``0.0``. min_points (int): Minimum points per event; smaller events are excluded from the index. Defaults to ``1024``. max_len (int): Cap on event count before the loop multiplier (-1 = no cap). Defaults to ``-1``. remove_low_energy_scatters (bool): Drop the first (LED scatter) cluster and its points. Defaults to ``False``. old_pid_mapping (bool): Map LED PID to ``6`` instead of ``5``. Defaults to ``False``. revision ({"v1", "v2", "v3"}): Dataset revision. v1 is the original PILArNet (no PID/momentum/vertex); v2 adds PID, momentum and particle vertices; v3 adds interaction-level vertices and primary-particle labels. Defaults to ``"v2"``. overlay_n_events (int | tuple[int, int]): Number (or inclusive range) of events to overlay into one point cloud; ``> 1`` enables overlay. Defaults to ``1``. overlay_prob (float): Probability of applying overlay to a given sample. Defaults to ``1.0``. overlay_allow_repeats (bool): Allow the same event to be sampled more than once when overlaying. Defaults to ``True``. Note: Loader settings (``batch_size``, ``num_worker``) live at the top level of the config, not on the dataset constructor. Split membership differs between v1 and v2/v3, so a v1-trained model evaluated on v2/v3 (or vice versa) is not seeing a comparable split. Event overlay deduplicates colliding voxels by semantic priority (track > shower > Michel > delta > LED) and rotates overlaid events by random 90-degree increments. Example: .. code-block:: python >>> from pimm.datasets.builder import build_dataset >>> ds = build_dataset(dict(type="PILArNetH5Dataset", revision="v2", ... split="train", transform=[], min_points=1024)) >>> sample = ds[0] >>> sorted(sample)[:6] ['coord', 'energy', 'instance_interaction', 'instance_particle', 'momentum', 'name'] >>> sample["coord"].shape # (N, 3) float32 (7366, 3) >>> sample["segment_motif"].shape # (N, 1) semantic class (7366, 1) >>> # in a config: >>> # data = dict(train=dict(type="PILArNetH5Dataset", split="train", >>> # revision="v2", min_points=1024, transform=transform)) """ def __init__( self, data_root: str | None = None, split="train", transform=None, test_mode=False, test_cfg=None, loop=1, ignore_index=-1, energy_threshold=0.0, min_points=1024, max_len=-1, remove_low_energy_scatters=False, old_pid_mapping=False, revision: Literal["v1", "v2", "v3"] = "v2", # event overlay parameters overlay_n_events=1, overlay_prob=1.0, overlay_allow_repeats=True, ): super().__init__() self.data_root = data_root if self.data_root is None: env_var = f"PILARNET_DATA_ROOT_{revision.upper()}" # Revision-specific env vars keep v1/v2/v3 roots independent. self.data_root = os.environ.get(env_var) if self.data_root is None: # Fall back to the default download location default_path = str(Path.home() / ".cache" / "pimm" / "pilarnet" / revision) if os.path.isdir(default_path): self.data_root = default_path else: raise RuntimeError( f"\nPILArNet data root not found for revision '{revision}'.\n\n" f"Option 1 - Download the dataset (saves to ~/.cache/pimm/pilarnet/{revision}):\n" f" python scripts/pilarnet/download.py --version {revision}\n\n" f"Option 2 - Set the environment variable:\n" f' export {env_var}="/path/to/pilarnet/{revision}/data"\n\n' f"Option 3 - Pass data_root directly in your config:\n" f' --options data.train.data_root="/path/to/data"\n' ) self.split = split self.transform = Compose(transform) self.test_mode = test_mode self.test_cfg = test_cfg if test_mode else None self.loop = loop if not test_mode else 1 self.ignore_index = ignore_index self.old_pid_mapping = old_pid_mapping self.revision = revision if test_mode: self.test_voxelize = TRANSFORMS.build(self.test_cfg.voxelize) self.test_crop = ( TRANSFORMS.build(self.test_cfg.crop) if self.test_cfg.crop else None ) self.post_transform = Compose(self.test_cfg.post_transform) self.aug_transform = [Compose(aug) for aug in self.test_cfg.aug_transform] # event overlay parameters self.overlay_n_events = overlay_n_events self.overlay_prob = overlay_prob self.overlay_allow_repeats = overlay_allow_repeats # PILArNet specific parameters self.energy_threshold = energy_threshold self.min_points = min_points self.remove_low_energy_scatters = remove_low_energy_scatters self.max_len = max_len # Get list of h5 files self.h5_files = self.get_h5_files() assert len(self.h5_files) > 0, "No h5 files found" self.initted = False self.file_events = [] # Build index for faster access self._build_index() logger = get_root_logger() logger.info( "Total number of samples in PILArNet {} set: {} x {}.".format( self.cumulative_lengths[-1], self.loop, split ) ) if self.overlay_n_events > 1 or (isinstance(self.overlay_n_events, (tuple, list)) and self.overlay_n_events[1] > 1): logger.info(f"Event overlay enabled: n_events={self.overlay_n_events}, prob={self.overlay_prob}")
[docs] def get_h5_files(self): """Get list of h5 files based on the split.""" if isinstance(self.split, str): split_pattern = f"*{self.split}/*.h5" else: split_pattern = [f"*{s}/*.h5" for s in self.split] if isinstance(split_pattern, list): h5_files = [] for pattern in split_pattern: h5_files.extend(sorted(glob.glob(os.path.join(self.data_root, pattern)))) else: h5_files = sorted(glob.glob(os.path.join(self.data_root, split_pattern))) return sorted(h5_files)
def _build_index(self): """Build an index of valid point clouds for faster access.""" log = get_root_logger() log.info("Building index for PILArNetH5Dataset") self.cumulative_lengths = [] self.indices = [] for h5_file in self.h5_files: try: # Check if points count file exists points_file = h5_file.replace(".h5", "_points.npy") if os.path.exists(points_file): npoints = np.load(points_file) index = np.argwhere(npoints >= self.min_points).flatten() else: # No points file, count on the fly log.info( f"No points count file for {h5_file}, counting points on the fly" ) with h5py.File(h5_file, "r", libver="latest", swmr=True) as f: # Get all point counts npoints = [] for i in range(f['point'].shape[0]): npoint = f['point'][i].size // 8 npoints.append(npoint) npoints = np.array(npoints) index = np.argwhere(npoints >= self.min_points).flatten() self.file_events.append(npoints.shape[0]) if os.path.exists(points_file): self.file_events.append(int(npoints.shape[0])) except Exception as e: log.warning(f"Error processing {h5_file}: {e}") index = np.array([]) self.file_events.append(0) self.cumulative_lengths.append(index.shape[0]) self.indices.append(index) self.cumulative_lengths = np.cumsum(self.cumulative_lengths) log.info( f"Found {self.cumulative_lengths[-1]} point clouds with at least {self.min_points} points" )
[docs] def h5py_worker_init(self): """Initialize h5py files for each worker.""" self.h5data = [] for h5_file in self.h5_files: self.h5data.append(h5py.File(h5_file, mode="r", libver="latest", swmr=True)) self.initted = True
[docs] def get_data(self, idx): """Load a point cloud from h5 file. Output dictionary: - coord: (N, 3) array of coordinates - energy: (N, 1) array of energies - momentum: (N, 1) array of particle momentum (v2/v3 only) - vertex: (N, 3) array of vertices (v2/v3 only; interaction vertex for v3) - is_primary: (N, 1) array of primary-particle flags (v3 only) - segment_motif: (N, 1) array of motif labels - segment_pid: (N, 1) array of PID labels (v2/v3 only) - instance_particle: (N, 1) array of particle instance labels - instance_interaction: (N, 1) array of interaction instance labels - segment_interaction: (N, 1) array of interaction labels """ if not self.initted: self.h5py_worker_init() # Find which h5 file and index the point cloud is in h5_idx = np.searchsorted(self.cumulative_lengths, idx, side="right") if h5_idx > 0: idx_in_file = idx - self.cumulative_lengths[h5_idx - 1] else: idx_in_file = idx h5_file = self.h5data[h5_idx] file_idx = self.indices[h5_idx][idx_in_file] # load raw arrays for this event and decode into a flat data_dict data_dict = decode_event( point=h5_file["point"][file_idx], cluster=h5_file["cluster"][file_idx], cluster_extra=( h5_file["cluster_extra"][file_idx] if self.revision != "v1" else None ), revision=self.revision, energy_threshold=self.energy_threshold, remove_low_energy_scatters=self.remove_low_energy_scatters, old_pid_mapping=self.old_pid_mapping, ) # add metadata h5_name = os.path.basename(self.h5_files[h5_idx]) data_dict["name"] = f"{h5_name}_{file_idx}" data_dict["split"] = self.split if isinstance(self.split, str) else "custom" data_dict["revision"] = self.revision return data_dict
[docs] def get_data_name(self, idx): """Get name for the point cloud.""" if not self.initted: self.h5py_worker_init() # Find which h5 file and index the point cloud is in h5_idx = np.searchsorted(self.cumulative_lengths, idx, side="right") if h5_idx > 0: idx_in_file = idx - self.cumulative_lengths[h5_idx - 1] else: idx_in_file = idx h5_name = os.path.basename(self.h5_files[h5_idx]) file_idx = self.indices[h5_idx][idx_in_file] return f"{h5_name}_{file_idx}"
def _num_source_events(self): """Count of distinct events (pre-``loop``); overlay samples from this.""" return int(self.cumulative_lengths[-1])
[docs] def prepare_train_data(self, idx): """Prepare training data with transforms.""" data_dict = self.get_data(idx % len(self)) data_dict = self._maybe_overlay(data_dict) return self.transform(data_dict)
[docs] def prepare_test_data(self, idx): """Prepare test data with test transforms.""" # Load data data_dict = self.get_data(idx % len(self)) data_dict = self._maybe_overlay(data_dict) # Apply transforms if self.transform is not None: data_dict = self.transform(data_dict) # Test mode specific handling result_dict = dict(segment=data_dict.pop("segment"), name=data_dict.pop("name")) if "origin_segment" in data_dict: assert "inverse" in data_dict result_dict["origin_segment"] = data_dict.pop("origin_segment") result_dict["inverse"] = data_dict.pop("inverse") data_dict_list = [] for aug in self.aug_transform: data_dict_list.append(aug(deepcopy(data_dict))) return result_dict
def __getitem__(self, idx): real_idx = idx % len(self) if self.test_mode: return self.prepare_test_data(real_idx) else: return self.prepare_train_data(real_idx) def __len__(self): if self.max_len > 0: return min(self.max_len, self.cumulative_lengths[-1]) * self.loop return self.cumulative_lengths[-1] * self.loop def __del__(self): """Clean up open h5 files.""" if hasattr(self, "initted") and self.initted: for h5_file in self.h5data: h5_file.close()