"""Instance, anchor, and local-geometry transforms."""
from .common import *
[docs]
@TRANSFORMS.register_module()
class ComputeAnchors(object):
"""Compute geometric anchors once per event and attach them to the sample.
Reads ``data_dict["coord"]`` and ``data_dict["energy"]`` and calls the
package ``compute_anchors`` helper (with ``ANCHOR_DEFAULT_CFG`` merged with
``cfg``) to detect salient structures (e.g. endpoints, branches, Bragg
peaks), writing the resulting dict to ``data_dict["anchors"]``. These anchors
can later bias view sampling (see :class:`MultiViewGenerator`). A no-op when
the helper is unavailable or when ``coord``/``energy`` are missing.
Registered as ``ComputeAnchors`` — use this string as the ``type`` in a
``transform=[...]`` config list.
Args:
cfg (dict, optional): Overrides merged on top of ``ANCHOR_DEFAULT_CFG``.
Defaults to ``None`` (empty dict).
Example:
.. code-block:: python
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> data = {"coord": rng.random((50, 3), dtype="f4"),
... "energy": rng.random((50, 1), dtype="f4")}
>>> out = ComputeAnchors()(data)
>>> list(out["anchors"].keys()) # dict of detected salient structures
['endpoints', 'branches_track', 'branches_shower', 'bragg', 'led']
# each value is an (M, 3) array of anchor coordinates; M varies per event
"""
def __init__(self, cfg: Optional[dict] = None):
self.cfg = cfg or dict()
def __call__(self, data_dict):
if compute_anchors is None:
return data_dict
if "coord" not in data_dict or "energy" not in data_dict:
return data_dict
xyz = data_dict["coord"].astype(np.float32)
# energy may be (N,) or (N,1); use (N,)
e = data_dict["energy"]
if e.ndim > 1 and e.shape[-1] == 1:
e = e.reshape(-1)
# Merge defaults with overrides
cfg = dict(ANCHOR_DEFAULT_CFG)
cfg.update(self.cfg)
anchors = compute_anchors(xyz=xyz, energy=e, is_shower_like=None, cfg=cfg)
data_dict["anchors"] = anchors
# Exclude LEDs from being used inadvertently elsewhere
return data_dict
[docs]
@TRANSFORMS.register_module()
class InstanceParser(object):
"""Compact instance ids and derive per-instance bounding boxes and centroids.
Reads ``data_dict["coord"]``, ``data_dict["segment"]``, and
``data_dict["instance"]``. Points whose semantic label is in
``segment_ignore_index`` have their instance set to
``instance_ignore_index``; remaining instances are relabeled to a dense
``0..K-1`` range. Writes back the compacted ``instance``, a per-point
``instance_centroid`` ``(N, 3)``, and a per-instance ``bbox`` ``(K, 8)``
(center xyz, size xyz, theta, shifted class). When ``compute_axis_stats`` is
enabled it also runs a per-instance PCA and writes per-point
``instance_axis`` ``(N, 3)``, ``instance_axis_coord``,
``instance_axis_coord_normalized``, ``instance_axis_length``,
``instance_axis_weight``, and ``instance_axis_coord_weight``. Registered as
``InstanceParser`` — use this string as the ``type`` in a ``transform=[...]``
config list.
Args:
segment_ignore_index (tuple): Semantic labels excluded from instances;
their class indices are also vacated/shifted out of the bbox class.
Defaults to ``(-1, 0, 1)``.
instance_ignore_index (int): Instance id assigned to ignored points and
used to fill uninitialized centroid/bbox entries. Defaults to
``-1``.
compute_axis_stats (bool): If ``True``, compute the per-instance
principal-axis statistics. Defaults to ``False``.
axis_min_points (int): Minimum points an instance needs for a valid PCA
axis (clamped to ``>= 1``). Defaults to ``5``.
axis_eps (float): Numerical tolerance for axis/eigenvalue validity and
normalization. Defaults to ``1e-6``.
axis_default (tuple): Fallback unit axis (normalized internally, must be
non-zero, shape ``(3,)``) used when PCA is invalid. Defaults to
``(1.0, 0.0, 0.0)``.
axis_normalize_half_extent (bool): If ``True``, normalize the axis
coordinate by the half-extent rather than the full extent. Defaults
to ``True``.
Example:
.. code-block:: python
>>> import numpy as np
>>> data = {
... "coord": np.array([[0,0,0],[1,0,0],[0,0,0],[5,5,5],[6,5,5]], dtype="f4"),
... "segment": np.array([2, 2, 1, 3, 3]), # class 1 is ignored
... "instance": np.array([10, 10, 7, 20, 20]),
... }
>>> out = InstanceParser(segment_ignore_index=(-1, 0, 1))(data)
>>> out["instance"] # ignored point -> -1, rest densified 0..K-1
array([ 0, 0, -1, 1, 1])
>>> out["bbox"].shape # (K instances, 8): center xyz, size xyz, theta, class
(2, 8)
"""
def __init__(
self,
segment_ignore_index=(-1, 0, 1),
instance_ignore_index=-1,
compute_axis_stats=False,
axis_min_points=5,
axis_eps=1e-6,
axis_default=(1.0, 0.0, 0.0),
axis_normalize_half_extent=True,
):
self.segment_ignore_index = segment_ignore_index
self.instance_ignore_index = instance_ignore_index
self.compute_axis_stats = bool(compute_axis_stats)
self.axis_min_points = max(int(axis_min_points), 1)
self.axis_eps = float(axis_eps)
axis_default = np.asarray(axis_default, dtype=np.float32)
if axis_default.shape != (3,):
raise ValueError("axis_default must have shape (3,)")
axis_norm = np.linalg.norm(axis_default)
if axis_norm <= 0:
raise ValueError("axis_default must be non-zero")
self.axis_default = axis_default / axis_norm
self.axis_normalize_half_extent = bool(axis_normalize_half_extent)
def __call__(self, data_dict):
coord = np.asarray(data_dict["coord"])
coord_dtype = coord.dtype
# ensure 1D arrays for correct boolean indexing
segment = data_dict["segment"]
if isinstance(segment, np.ndarray):
segment = segment.reshape(-1)
else:
segment = np.asarray(segment).reshape(-1)
instance = data_dict["instance"]
if isinstance(instance, np.ndarray):
instance = instance.reshape(-1)
else:
instance = np.asarray(instance).reshape(-1)
mask = ~np.in1d(segment, self.segment_ignore_index)
# mapping ignored instance to ignore index
instance[~mask] = self.instance_ignore_index
# reorder left instance
unique, inverse = np.unique(instance[mask], return_inverse=True)
instance_num = len(unique)
instance[mask] = inverse
# init instance information
centroid = np.ones((coord.shape[0], 3), dtype=coord_dtype) * self.instance_ignore_index
bbox = np.ones((instance_num, 8), dtype=coord_dtype) * self.instance_ignore_index
vacancy = [
index for index in self.segment_ignore_index if index >= 0
] # vacate class index
if self.compute_axis_stats:
axis_default = self.axis_default.astype(coord_dtype, copy=False)
axis = np.tile(axis_default, (coord.shape[0], 1))
axis_coord = np.zeros(coord.shape[0], dtype=coord_dtype)
axis_coord_normalized = np.zeros(coord.shape[0], dtype=coord_dtype)
axis_length = np.zeros(coord.shape[0], dtype=coord_dtype)
axis_weight = np.zeros(coord.shape[0], dtype=coord_dtype)
else:
axis = axis_coord = axis_coord_normalized = axis_length = axis_weight = None
for instance_id in range(instance_num):
mask_ = instance == instance_id
coord_ = coord[mask_]
bbox_min = coord_.min(0)
bbox_max = coord_.max(0)
bbox_centroid = coord_.mean(0)
bbox_center = (bbox_max + bbox_min) / 2
bbox_size = bbox_max - bbox_min
bbox_theta = np.zeros(1, dtype=coord_.dtype)
bbox_class = np.array([segment[mask_][0]], dtype=coord_.dtype)
# shift class index to fill vacate class index caused by segment ignore index
bbox_class -= np.greater(bbox_class, vacancy).sum()
centroid[mask_] = bbox_centroid.astype(coord_dtype, copy=False)
bbox_row = np.concatenate([bbox_center, bbox_size, bbox_theta, bbox_class])
bbox[instance_id] = bbox_row.astype(coord_dtype, copy=False)
if self.compute_axis_stats:
point_count = coord_.shape[0]
valid_axis = False
axis_vec = axis_default
axis_coord_local = np.zeros(point_count, dtype=coord_dtype)
axis_coord_norm_local = np.zeros(point_count, dtype=coord_dtype)
axis_length_value = 0.0
if point_count >= self.axis_min_points:
centered = coord_.astype(np.float32, copy=False) - bbox_centroid.astype(np.float32, copy=False)
if np.linalg.norm(centered, axis=1).max() > self.axis_eps:
cov = centered.T @ centered
cov /= max(point_count, 1)
eigvals, eigvecs = np.linalg.eigh(cov)
principal_index = int(np.argmax(eigvals))
principal_val = float(eigvals[principal_index])
principal_vec = eigvecs[:, principal_index].astype(np.float32, copy=False)
principal_norm = float(np.linalg.norm(principal_vec))
if principal_norm > self.axis_eps and principal_val > self.axis_eps:
axis_vec = principal_vec / principal_norm
projections = centered @ axis_vec
max_proj = float(projections.max())
min_proj = float(projections.min())
axis_length_value = max_proj - min_proj
if axis_length_value > self.axis_eps:
valid_axis = True
axis_coord_local = projections.astype(coord_dtype, copy=False)
denom = axis_length_value * 0.5 if self.axis_normalize_half_extent else axis_length_value
denom = float(denom) + self.axis_eps
axis_coord_norm_local = (axis_coord_local / denom).astype(coord_dtype, copy=False)
if not valid_axis:
axis_vec = axis_default
axis_coord_local.fill(0.0)
axis_coord_norm_local.fill(0.0)
axis_length_value = 0.0
axis[mask_] = axis_vec.astype(coord_dtype, copy=False)
axis_coord[mask_] = axis_coord_local
axis_coord_normalized[mask_] = axis_coord_norm_local
axis_length[mask_] = axis_length_value
axis_weight_value = 1.0 if valid_axis else 0.0
axis_weight[mask_] = axis_weight_value
data_dict["instance"] = instance
data_dict["instance_centroid"] = centroid
data_dict["bbox"] = bbox
if self.compute_axis_stats:
data_dict["instance_axis"] = axis
data_dict["instance_axis_coord"] = axis_coord
data_dict["instance_axis_coord_normalized"] = axis_coord_normalized
data_dict["instance_axis_length"] = axis_length
data_dict["instance_axis_weight"] = axis_weight
data_dict["instance_axis_coord_weight"] = axis_weight
return data_dict
[docs]
@TRANSFORMS.register_module()
class LocalCovarianceFeatures(object):
"""Per-point local-neighborhood covariance eigen-features.
Reads ``data_dict["coord"]``, builds a kd-tree, and for each point computes
the covariance of its ``k`` nearest neighbors (optionally Gaussian-weighted),
then its sorted (descending) eigenvalues. Writes ``out_keys[0]`` =
``local_eigvals`` ``(N, 3)`` and ``out_keys[1]`` = ``local_shape`` ``(N, 4)``
holding the anisotropy ratios ``l2/l1``, ``l3/l2``, ``l3/l1`` and the surface
variation/curvature ``l3 / (l1 + l2 + l3)``. Both keys are appended to
``data_dict["index_valid_keys"]`` so they are carried through index-based
cropping. A no-op when ``coord`` is missing or empty. Registered as
``LocalCovarianceFeatures`` — use this string as the ``type`` in a
``transform=[...]`` config list.
Args:
k (int): Number of neighbors used per point. Defaults to ``16``.
include_self (bool): If ``True``, include the point itself among its
neighbors; otherwise the nearest (self) neighbor is dropped. Defaults
to ``False``.
gaussian_weight (bool): If ``True``, weight neighbors by a Gaussian of
their distance rather than uniformly. Defaults to ``False``.
gaussian_sigma (float, optional): Fixed Gaussian bandwidth; if ``None``
a per-point median-distance bandwidth is used. Defaults to ``None``.
out_keys (tuple): Output keys ``(eigvals_key, shape_key)``. Defaults to
``("local_eigvals", "local_shape")``.
Note:
Requires ``data_dict["index_valid_keys"]`` to already exist (it is
appended to, not created).
Example:
.. code-block:: python
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> data = {"coord": rng.random((20, 3), dtype="f4"), "index_valid_keys": []}
>>> out = LocalCovarianceFeatures(k=5)(data)
>>> out["local_eigvals"].shape, out["local_shape"].shape
((20, 3), (20, 4)) # per-point sorted eigvals + (l2/l1, l3/l2, l3/l1, curvature)
>>> out["index_valid_keys"]
['local_eigvals', 'local_shape'] # registered for index-based cropping
"""
def __init__(
self,
k=16,
include_self=False,
gaussian_weight=False,
gaussian_sigma=None,
out_keys=("local_eigvals", "local_shape"),
):
self.k = int(k)
self.include_self = bool(include_self)
self.gaussian_weight = bool(gaussian_weight)
self.gaussian_sigma = gaussian_sigma
self.out_keys = out_keys
def __call__(self, data_dict):
if "coord" not in data_dict:
return data_dict
coord = np.asarray(data_dict["coord"]).astype(np.float32, copy=False)
n_points = coord.shape[0]
if n_points == 0:
return data_dict
# query kNN
k_query = min(self.k + (0 if self.include_self else 1), max(1, n_points))
kd = cKDTree(coord)
dists, idxs = kd.query(coord, k=k_query)
# ensure shape (N, K)
if dists.ndim == 1:
dists = dists[:, None]
idxs = idxs[:, None]
# drop self if requested
if not self.include_self and idxs.shape[1] > 0:
dists = dists[:, 1:]
idxs = idxs[:, 1:]
neighbors = coord[idxs] # (N, K, 3)
center = coord[:, None, :] # (N, 1, 3)
offsets = neighbors - center
if self.gaussian_weight:
if self.gaussian_sigma is None:
sigma = np.median(dists, axis=1, keepdims=True) + 1e-6
else:
sigma = float(self.gaussian_sigma)
w = np.exp(-0.5 * (dists / (sigma + 1e-6)) ** 2).astype(np.float32)
w_sum = np.sum(w, axis=1, keepdims=True) + 1e-6
mean = np.sum(offsets * w[..., None], axis=1, keepdims=True) / w_sum[..., None]
centered = offsets - mean
cov = np.einsum("nki,nkj->nij", centered * w[..., None], centered) / w_sum[..., None]
else:
mean = np.mean(offsets, axis=1, keepdims=True)
centered = offsets - mean
denom = float(max(1, centered.shape[1] - 1))
cov = np.einsum("nki,nkj->nij", centered, centered) / denom
eigvals, _ = np.linalg.eigh(cov)
eigvals = np.clip(eigvals, 0.0, None)[:, ::-1]
l1 = eigvals[:, 0] + 1e-12
l2 = eigvals[:, 1] + 1e-12
l3 = eigvals[:, 2] + 1e-12
r21 = l2 / l1
r32 = l3 / l2
r31 = l3 / l1
suml = (l1 + l2 + l3) + 1e-12
curvature = l3 / suml
local_shape = np.stack([r21, r32, r31, curvature], axis=1).astype(coord.dtype, copy=False)
local_eigvals = eigvals.astype(coord.dtype, copy=False)
data_dict[self.out_keys[0]] = local_eigvals
data_dict[self.out_keys[1]] = local_shape
data_dict["index_valid_keys"].append(self.out_keys[0])
data_dict["index_valid_keys"].append(self.out_keys[1])
return data_dict