Source code for pimm.datasets.lucid_dataset

"""LUCiDDataset: dataset for Water Cherenkov detector simulation output.

Loads PMT sensor data and/or 3D track segments from co-indexed HDF5 files.
Produces flat dicts compatible with pimm's transform/collation pipeline.

Example configs:

    # PMT event classification (sensor response as fixed-geometry point cloud)
    data = dict(train=dict(type="LUCiDDataset", data_root="dataset_wc",
        modalities=("sensor",), dataset_name="wc", ...))

    # Per-sensor instance separation (sparse per-particle entries)
    data = dict(train=dict(type="LUCiDDataset", data_root="dataset_wc",
        modalities=("sensor",), include_labels=True, ...))

    # 3D track reconstruction
    data = dict(train=dict(type="LUCiDDataset", data_root="dataset_wc",
        modalities=("seg",), ...))
"""

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

from pimm.utils.logger import get_root_logger
from .builder import DATASETS
from .test_fragments import build_test_fragments
from .transform import Compose, TRANSFORMS
from .readers.lucid_seg_reader import LUCiDSegReader
from .readers.lucid_sensor_reader import LUCiDSensorReader


[docs] @DATASETS.register_module() class LUCiDDataset(Dataset): """Water Cherenkov detector dataset over co-indexed LUCiD HDF5 files. Reads PMT sensor response and/or 3D track segments from event-aligned shard families (``sensor/`` and/or ``seg/``) and emits flat dicts for the pimm transform/collation pipeline. The public keys depend on ``output_mode`` for the sensor modality: ``"response"`` emits one entry per PMT with ``coord`` (3D PMT position or a 1D sensor index), ``energy`` (total PE) and ``time``; ``"labels"`` emits sparse per-particle entries with ``coord``, ``energy``, ``segment`` (category) and ``instance`` (particle index); ``"separate"`` keeps the raw reader keys (``pmt_coord``, ``pmt_pe``, ``pp_*``). After collation a batch adds ``offset``. Registered as ``LUCiDDataset`` -- use as ``type`` under ``data.train``/``data.val``/``data.test``. Args: data_root (str): Root directory holding ``seg/`` and/or ``sensor/`` subdirectories. split (str): Split name used for shard discovery. Defaults to ``""``. transform (list[dict]): List of transform configs (NOT a prebuilt ``Compose``). Defaults to ``None``. modalities (tuple[str]): Which modalities to load, any of ``"sensor"``, ``"seg"``. Defaults to ``("sensor",)``. dataset_name (str): Shard filename prefix (e.g. ``"wc"`` for ``wc_seg_0000.h5``). Defaults to ``"wc"``. output_mode (str): Sensor output contract, one of ``"response"``, ``"labels"``, ``"separate"`` (see above). Defaults to ``"response"``. include_labels (bool): Whether the sensor reader loads the per-particle decomposition (needed for ``output_mode="labels"``). Defaults to ``True``. pe_threshold (float): Minimum PE used to sparsify per-particle PE. Defaults to ``0.0``. min_segments (int): Minimum segments per event (seg reader filter). Defaults to ``0``. max_len (int): Cap on event count before the loop multiplier (-1 = no cap). Defaults to ``-1``. loop (int): Train-time epoch multiplier. Defaults to ``1``. 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``. Note: The dataset length is the minimum event count across the active readers (they must be co-indexed). Loader settings (``batch_size``, ``num_worker``) live at the top level of the config. Example: .. code-block:: python >>> from pimm.datasets.builder import build_dataset >>> # data root not in this env -> shown with doctest +SKIP >>> ds = build_dataset(dict(type="LUCiDDataset", data_root="dataset_wc", ... modalities=("sensor",), output_mode="response", ... transform=[])) # doctest: +SKIP >>> sample = ds[0] # doctest: +SKIP >>> # output_mode="response" sample keys: coord (N, 3 PMT pos, or N, 1 >>> # sensor index), energy (N, 1 total PE), time (N, 1), name, split >>> # output_mode="labels": coord, energy, segment (category), >>> # instance (particle idx), time, name, split >>> # output_mode="separate": raw reader keys (pmt_coord, pmt_pe, pp_*) >>> # + name, split """ def __init__( self, data_root, split='', transform=None, modalities=('sensor',), dataset_name='wc', output_mode='response', include_labels=True, pe_threshold=0.0, min_segments=0, max_len=-1, loop=1, test_mode=False, test_cfg=None, ): """Create modality readers and derive the co-indexed dataset length.""" super().__init__() self.data_root = data_root self.split = split self.modalities = tuple(modalities) self.dataset_name = dataset_name self.output_mode = output_mode self.max_len = max_len self.loop = loop if not test_mode else 1 self.test_mode = test_mode self.test_cfg = test_cfg if test_mode else None self.transform = Compose(transform) if test_mode and test_cfg is not None: 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] # Readers own raw HDF5 decoding; this wrapper owns output formatting. self.seg_reader = None self.sensor_reader = None if 'seg' in self.modalities: seg_root = self._modality_root('seg') self.seg_reader = LUCiDSegReader( data_root=seg_root, split=split, dataset_name=dataset_name, min_segments=min_segments) if 'sensor' in self.modalities: sensor_root = self._modality_root('sensor') self.sensor_reader = LUCiDSensorReader( data_root=sensor_root, split=split, dataset_name=dataset_name, include_labels=include_labels, pe_threshold=pe_threshold) # Canonical reader and length active_readers = [r for r in (self.seg_reader, self.sensor_reader) if r is not None] if not active_readers: raise ValueError(f"Need 'seg' or 'sensor' in modalities, got {self.modalities}") self._canonical_reader = active_readers[0] self._n_events = min(len(r) for r in active_readers) logger = get_root_logger() logger.info(f"LUCiDDataset: {self._n_events} events, " f"modalities={self.modalities}, output_mode={output_mode}") def _modality_root(self, modality): """Resolve root directory for a modality shard family.""" mod_dir = os.path.join(self.data_root, modality) if os.path.isdir(mod_dir): return mod_dir return self.data_root
[docs] def get_data(self, idx): """Load one event and choose the public output contract by modality.""" data_dict = {} # --- Seg (3D track segments) --- if self.seg_reader is not None: seg_data = self.seg_reader.read_event(idx) if self.sensor_reader is not None and self.output_mode == 'separate': # Prefix 3D keys to avoid collision with sensor coord for k, v in seg_data.items(): data_dict[f'seg3d.{k}'] = v else: data_dict.update(seg_data) # --- Sensor (PMT response + optional per-particle labels) --- if self.sensor_reader is not None: sensor_data = self.sensor_reader.read_event(idx) if self.output_mode == 'response': # PMT response: one entry per sensor. n = len(sensor_data['pmt_pe']) if 'pmt_coord' in sensor_data: data_dict['coord'] = sensor_data['pmt_coord'] else: # No 3D positions: use sensor index as 1D coord. data_dict['coord'] = np.arange(n, dtype=np.float32)[:, None] data_dict['energy'] = sensor_data['pmt_pe'][:, None] data_dict['time'] = sensor_data['pmt_t'][:, None] elif self.output_mode == 'labels': # Sparse per-particle entries if 'pp_sensor_idx' in sensor_data: sidx = sensor_data['pp_sensor_idx'] if 'pmt_coord' in sensor_data: data_dict['coord'] = sensor_data['pmt_coord'][sidx] else: data_dict['coord'] = sidx.astype(np.float32)[:, None] data_dict['energy'] = sensor_data['pp_pe'][:, None] data_dict['segment'] = sensor_data['pp_category'] data_dict['instance'] = sensor_data['pp_particle_idx'] if 'pp_t' in sensor_data: data_dict['time'] = sensor_data['pp_t'][:, None] elif self.output_mode == 'separate': data_dict.update(sensor_data) # Metadata data_dict['name'] = self.get_data_name(idx) data_dict['split'] = self.split if isinstance(self.split, str) else 'custom' return data_dict
[docs] def get_data_name(self, idx): """Return a stable shard/event name for logging and prediction files.""" reader = self._canonical_reader file_idx = int(np.searchsorted(reader.cumulative_lengths, idx, side='right')) local = idx - (int(reader.cumulative_lengths[file_idx - 1]) if file_idx > 0 else 0) event_num = reader.indices[file_idx][local] fname = os.path.basename(reader.h5_files[file_idx]) return f"{fname}_evt{event_num:03d}"
[docs] def prepare_train_data(self, idx): """Load one event and apply the train transform pipeline.""" return self.transform(self.get_data(idx % len(self)))
[docs] def prepare_test_data(self, idx): """Build augmented and voxelized fragments for test-time inference.""" data_dict = self.get_data(idx % len(self)) if self.transform is not None: data_dict = self.transform(data_dict) result_dict = dict(name=data_dict.pop("name")) if "segment" in data_dict: result_dict["segment"] = data_dict.pop("segment") result_dict["fragment_list"] = build_test_fragments( data_dict, aug_transform=self.aug_transform, test_voxelize=self.test_voxelize, test_crop=self.test_crop, post_transform=self.post_transform, ) return result_dict
def __getitem__(self, idx): """Return a transformed train item or a fragmented test item.""" real_idx = idx % len(self) if self.test_mode: return self.prepare_test_data(real_idx) return self.prepare_train_data(real_idx) def __len__(self): """Return event count after max_len and loop are applied.""" n = self._n_events if self.max_len > 0: n = min(n, self.max_len) return n * self.loop def __del__(self): """Close any reader handles still owned by this dataset instance.""" for reader in (self.seg_reader, self.sensor_reader): if reader is not None: reader.close()