"""Default point-cloud datasets and dataset containers.
These generic adapters load preprocessed per-event directories of ``.npy``
assets, then hand flat dictionaries to the configured transform pipeline. More
detector-specific datasets in this package preserve the same output contract so
the model and collation code can stay shared.
Author: Xiaoyang Wu (xiaoyang.wu.cs@gmail.com)
Please cite our work if the code is helpful to you.
"""
import os
import glob
import json
from re import split
import numpy as np
from torch.utils.data import Dataset
from collections.abc import Sequence
from pimm.utils.logger import get_root_logger
from pimm.utils.cache import shared_dict
from .builder import DATASETS, build_dataset
from .test_fragments import build_test_fragments
from .transform import Compose, TRANSFORMS
[docs]
@DATASETS.register_module()
class DefaultDataset(Dataset):
"""Generic point-cloud dataset over preprocessed per-event ``.npy`` assets.
Reads one directory per event, loading every ``<asset>.npy`` whose stem is in
:attr:`VALID_ASSETS` (``coord``, ``color``, ``normal``, ``strength``,
``segment``, ``instance``, ``pose``). Each item is a flat ``dict`` keyed by
asset name and always carries ``coord``, ``segment``, ``instance``, ``name``,
and ``split``; missing ``segment``/``instance`` labels are filled with
``-1`` arrays (the ``ignore_index`` convention). After collation a batch adds
the ``offset`` key delimiting per-sample point spans. Registered as
``DefaultDataset`` -- use as ``type`` under ``data.train``/``data.val``/
``data.test``.
Args:
split (str | Sequence[str]): Split name(s). A name is treated as a JSON
split file under ``data_root`` if such a file exists, otherwise as a
subdirectory whose children are event directories. Defaults to
``"train"``.
data_root (str): Root directory holding the split files/subdirectories.
Defaults to ``"data/dataset"``.
transform (list[dict]): List of transform configs (NOT a prebuilt
``Compose``); assembled internally. Defaults to ``None``.
test_mode (bool): When ``True``, emit voxelized/augmented test fragments
instead of a single transformed sample, and force ``loop = 1``.
Defaults to ``False``.
test_cfg (object): Test-time config providing ``voxelize``, ``crop``,
``post_transform``, and ``aug_transform``. Required when
``test_mode`` is ``True``. Defaults to ``None``.
cache (bool): When ``True``, read each event from a shared-memory cache
keyed by sample name instead of from disk. Defaults to ``False``.
ignore_index (int): Label value for ignored/unlabelled points. Defaults
to ``-1``.
loop (int): Train-time epoch multiplier applied to the sample count
(forced to ``1`` in test mode). Defaults to ``1``.
Note:
Loader settings (``batch_size``, ``num_worker``) live at the top level of
the config, not on the dataset constructor.
Example:
.. code-block:: python
>>> from pimm.datasets.builder import build_dataset
>>> ds = build_dataset(dict(type="DefaultDataset", split="train",
... data_root="data/dataset", transform=[]))
>>> sample = ds[0]
>>> # get_data always returns these keys (coord/segment/instance,
>>> # with segment/instance filled to -1 when absent on disk):
>>> # coord (N, 3), segment (N,), instance (N,), name, split
>>> # plus any present optional assets: color, normal, strength, pose
>>> sorted(sample) # doctest: +SKIP
['coord', 'instance', 'name', 'segment', 'split']
"""
VALID_ASSETS = [
"coord",
"color",
"normal",
"strength",
"segment",
"instance",
"pose",
]
def __init__(
self,
split="train",
data_root="data/dataset",
transform=None,
test_mode=False,
test_cfg=None,
cache=False,
ignore_index=-1,
loop=1,
):
"""Initialize data discovery, transform pipeline, and test transforms."""
super(DefaultDataset, self).__init__()
self.data_root = data_root
self.split = split
self.transform = Compose(transform)
self.cache = cache
self.ignore_index = ignore_index
self.loop = (
loop if not test_mode else 1
) # force make loop = 1 while in test mode
self.test_mode = test_mode
self.test_cfg = test_cfg if test_mode else None
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]
self.data_list = self.get_data_list()
logger = get_root_logger()
logger.info(
"Totally {} x {} samples in {} {} set.".format(
len(self.data_list), self.loop, os.path.basename(self.data_root), split
)
)
[docs]
def get_data_list(self):
"""Resolve split names or split JSON files into event directories."""
if isinstance(self.split, str):
split_list = [self.split]
elif isinstance(self.split, Sequence):
split_list = self.split
else:
raise NotImplementedError
data_list = []
for split in split_list:
if os.path.isfile(os.path.join(self.data_root, split)):
with open(os.path.join(self.data_root, split)) as f:
data_list += [
os.path.join(self.data_root, data) for data in json.load(f)
]
else:
data_list += glob.glob(os.path.join(self.data_root, split, "*"))
return data_list
[docs]
def get_data(self, idx):
"""Read one event directory into a flat numpy dictionary."""
data_path = self.data_list[idx % len(self.data_list)]
name = self.get_data_name(idx)
split = self.get_split_name(idx)
if self.cache:
cache_name = f"pimm-{name}"
return shared_dict(cache_name)
data_dict = {}
assets = os.listdir(data_path)
for asset in assets:
if not asset.endswith(".npy"):
continue
if asset[:-4] not in self.VALID_ASSETS:
continue
data_dict[asset[:-4]] = np.load(os.path.join(data_path, asset))
data_dict["name"] = name
data_dict["split"] = split
if "coord" in data_dict.keys():
data_dict["coord"] = data_dict["coord"].astype(np.float32)
if "color" in data_dict.keys():
data_dict["color"] = data_dict["color"].astype(np.float32)
if "normal" in data_dict.keys():
data_dict["normal"] = data_dict["normal"].astype(np.float32)
if "segment" in data_dict.keys():
data_dict["segment"] = data_dict["segment"].reshape([-1]).astype(np.int32)
else:
data_dict["segment"] = (
np.ones(data_dict["coord"].shape[0], dtype=np.int32) * -1
)
if "instance" in data_dict.keys():
data_dict["instance"] = data_dict["instance"].reshape([-1]).astype(np.int32)
else:
data_dict["instance"] = (
np.ones(data_dict["coord"].shape[0], dtype=np.int32) * -1
)
return data_dict
[docs]
def get_data_name(self, idx):
"""Return the basename used for logs, predictions, and cache keys."""
return os.path.basename(self.data_list[idx % len(self.data_list)])
[docs]
def get_split_name(self, idx):
"""Return the split directory name for the indexed sample."""
return os.path.basename(
os.path.dirname(self.data_list[idx % len(self.data_list)])
)
[docs]
def prepare_train_data(self, idx):
"""Load one sample and apply the train transform chain."""
data_dict = self.get_data(idx)
data_dict = self.transform(data_dict)
return data_dict
[docs]
def prepare_test_data(self, idx):
"""Build augmented and voxelized fragments for test-time inference."""
data_dict = self.get_data(idx)
data_dict = self.transform(data_dict)
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")
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,
add_index_without_voxelize=True,
)
return result_dict
def __getitem__(self, idx):
"""Return a transformed train item or a fragmented test item."""
if self.test_mode:
return self.prepare_test_data(idx)
else:
return self.prepare_train_data(idx)
def __len__(self):
"""Return length after applying the train-time loop multiplier."""
return len(self.data_list) * self.loop
[docs]
@DATASETS.register_module()
class ConcatDataset(Dataset):
"""Concatenate several configured datasets into a single index space.
Builds each child dataset from its config and flattens them into a shared
index of ``(dataset_index, local_index)`` pairs, so a single sampler can draw
from all of them. Items pass through unchanged from the owning child, so the
emitted keys (``coord``, ``segment``, ``instance``, ``offset`` after
collation, ...) are whatever the children emit. Registered as
``ConcatDataset`` -- use as ``type`` under ``data.train``/``data.val``/
``data.test``.
Args:
datasets (list[dict]): List of child dataset configs, each built with
:func:`build_dataset`.
loop (int): Epoch multiplier applied to the concatenated length. Also
consumed by ``MultiDatasetDataloader`` as the main-dataset epoch
multiplier; per-child ``loop`` values can act as sampling ratios when
mixed batches are built. Defaults to ``1``.
Example:
.. code-block:: python
>>> from pimm.datasets.builder import build_dataset
>>> ds = build_dataset(dict(type="ConcatDataset", datasets=[
... dict(type="PILArNetH5Dataset", revision="v2", split="train", transform=[]),
... dict(type="PILArNetH5Dataset", revision="v2", split="val", transform=[]),
... ]))
>>> len(ds) # sum of child lengths (x loop) # doctest: +SKIP
1234567
>>> sample = ds[0] # item passes through unchanged from the owning child
>>> # keys are whatever that child emits (here PILArNet: coord, energy,
>>> # segment_motif, instance_particle, name, split, ...)
"""
def __init__(self, datasets, loop=1):
"""Build child datasets from configs and flatten their index space."""
super(ConcatDataset, self).__init__()
self.datasets = [build_dataset(dataset) for dataset in datasets]
self.loop = loop
self.data_list = self.get_data_list()
logger = get_root_logger()
logger.info(
"Totally {} x {} samples in the concat set.".format(
len(self.data_list), self.loop
)
)
[docs]
def get_data_list(self):
"""Return ``(dataset_index, local_index)`` pairs for all children."""
data_list = []
for i in range(len(self.datasets)):
data_list.extend(
zip(
np.ones(len(self.datasets[i]), dtype=int) * i,
np.arange(len(self.datasets[i])),
)
)
return data_list
[docs]
def get_data(self, idx):
"""Dispatch a global index to the owning child dataset."""
dataset_idx, data_idx = self.data_list[idx % len(self.data_list)]
return self.datasets[dataset_idx][data_idx]
[docs]
def get_data_name(self, idx):
"""Return the child dataset's sample name for a global index."""
dataset_idx, data_idx = self.data_list[idx % len(self.data_list)]
return self.datasets[dataset_idx].get_data_name(data_idx)
def __getitem__(self, idx):
"""Return the child dataset item for a global index."""
return self.get_data(idx)
def __len__(self):
"""Return concatenated length after this container's loop multiplier."""
return len(self.data_list) * self.loop