apairo-preprocess 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ from apairo_preprocess.traversability import TraversabilityFromLabels, TraversabilityFromTrajectory
2
+ from apairo_preprocess.ground import GroundSegmentationCSF, GroundSegmentationFromLabels, GroundSegmentationRANSAC
3
+ from apairo_preprocess.priors import GroundHeightFromLabels, TrajectoryDistance
4
+ from apairo_preprocess.odometry import GICPOdometry, KissICPOdometry
5
+ from apairo_preprocess.pointcloud import VoxeliseCoords, VoxeliseLabels, VoxelisePointCloud, RemoveRobotPoints
6
+ from apairo_preprocess.projection import BEVRasterisation, ImageMaskFromPointLabels, LidarCameraProjection, PointFeaturesFromImage, to_uint8_image
7
+
8
+ __all__ = [
9
+ "TraversabilityFromLabels",
10
+ "TraversabilityFromTrajectory",
11
+ "GroundSegmentationCSF",
12
+ "GroundSegmentationFromLabels",
13
+ "GroundSegmentationRANSAC",
14
+ "GroundHeightFromLabels",
15
+ "TrajectoryDistance",
16
+ "GICPOdometry",
17
+ "KissICPOdometry",
18
+ "VoxelisePointCloud",
19
+ "VoxeliseLabels",
20
+ "VoxeliseCoords",
21
+ "RemoveRobotPoints",
22
+ "LidarCameraProjection",
23
+ "PointFeaturesFromImage",
24
+ "ImageMaskFromPointLabels",
25
+ "BEVRasterisation",
26
+ "to_uint8_image",
27
+ ]
@@ -0,0 +1,9 @@
1
+ from apairo_preprocess.ground.csf import GroundSegmentationCSF
2
+ from apairo_preprocess.ground.from_labels import GroundSegmentationFromLabels
3
+ from apairo_preprocess.ground.ransac import GroundSegmentationRANSAC
4
+
5
+ __all__ = [
6
+ "GroundSegmentationCSF",
7
+ "GroundSegmentationFromLabels",
8
+ "GroundSegmentationRANSAC",
9
+ ]
@@ -0,0 +1,95 @@
1
+ """Ground segmentation via Cloth Simulation Filter (CSF)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ import numpy as np
8
+
9
+ from apairo.core.preprocessor import FramePreprocessor
10
+ from apairo.core.sample import Sample
11
+
12
+
13
+ def _run_csf(
14
+ xyz: np.ndarray,
15
+ cloth_resolution: float,
16
+ class_threshold: float,
17
+ rigidness: int,
18
+ ) -> tuple[list[int], list[int]]:
19
+ """Run CSF and return (ground_indices, non_ground_indices).
20
+
21
+ Requires ``pip install CSF``.
22
+ """
23
+ try:
24
+ import CSF # type: ignore[import-untyped]
25
+ except ImportError as exc:
26
+ raise ImportError(
27
+ "GroundSegmentationCSF requires the CSF package: pip install CSF"
28
+ ) from exc
29
+
30
+ csf = CSF.CSF()
31
+ csf.params.bSloopSmooth = False
32
+ csf.params.cloth_resolution = cloth_resolution
33
+ csf.params.rigidness = rigidness
34
+ csf.params.time_step = 0.65
35
+ csf.params.class_threshold = class_threshold
36
+ csf.params.interations = 500
37
+
38
+ csf.setPointCloud(xyz.tolist())
39
+ ground_idx = CSF.VecInt()
40
+ non_ground_idx = CSF.VecInt()
41
+ csf.do_filtering(ground_idx, non_ground_idx)
42
+ return list(ground_idx), list(non_ground_idx)
43
+
44
+
45
+ class GroundSegmentationCSF(FramePreprocessor):
46
+ """Per-point ground/non-ground labels via Cloth Simulation Filter.
47
+
48
+ Runs CSF on the voxelised point cloud. More accurate than RANSAC on
49
+ uneven terrain; requires ``pip install CSF``.
50
+
51
+ Output labels: **0 = ground, 1 = non-ground**.
52
+
53
+ Args:
54
+ voxelised_key: Input channel (voxelised point cloud, shape (N, D≥3)).
55
+ cloth_resolution: CSF cloth resolution (metres). Default 0.5 m.
56
+ class_threshold: CSF point-to-cloth distance threshold (metres).
57
+ Default 0.5 m.
58
+ rigidness: CSF cloth rigidness (1–3). 3 = flat terrain.
59
+ Default 3.
60
+ output_key: Override the default channel name ``"ground_csf"``.
61
+ """
62
+
63
+ output_key: ClassVar[str] = "ground_csf"
64
+ input_keys: ClassVar[list[str]] = ["voxelised"]
65
+ timestamps_from: ClassVar[str] = "voxelised"
66
+ sources: ClassVar[list[str]] = ["voxelised"]
67
+
68
+ def __init__(
69
+ self,
70
+ voxelised_key: str = "voxelised",
71
+ cloth_resolution: float = 0.5,
72
+ class_threshold: float = 0.5,
73
+ rigidness: int = 3,
74
+ output_key: str | None = None,
75
+ ) -> None:
76
+ self._voxelised_key = voxelised_key
77
+ self._cloth_resolution = cloth_resolution
78
+ self._class_threshold = class_threshold
79
+ self._rigidness = rigidness
80
+ self.input_keys = [voxelised_key]
81
+ self.sources = [voxelised_key]
82
+ if output_key is not None:
83
+ self.output_key = output_key
84
+
85
+ def __call__(self, sample: Sample) -> np.ndarray:
86
+ xyz = np.asarray(sample.data[self._voxelised_key], dtype=np.float64)[:, :3]
87
+ ground_idx, _ = _run_csf(
88
+ xyz,
89
+ cloth_resolution=self._cloth_resolution,
90
+ class_threshold=self._class_threshold,
91
+ rigidness=self._rigidness,
92
+ )
93
+ labels = np.ones(len(xyz), dtype=np.uint8)
94
+ labels[ground_idx] = 0
95
+ return labels
@@ -0,0 +1,55 @@
1
+ """Ground segmentation from semantic class IDs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ import numpy as np
8
+
9
+ from apairo.core.preprocessor import FramePreprocessor
10
+ from apairo.core.sample import Sample
11
+
12
+ _RELLIS_GROUND_IDS: frozenset[int] = frozenset({1, 3, 10, 23, 31, 33})
13
+
14
+
15
+ class GroundSegmentationFromLabels(FramePreprocessor):
16
+ """Per-point ground/non-ground labels from semantic class IDs.
17
+
18
+ Each point is labelled ground (0) if its semantic class ID is in
19
+ ``ground_ids``, non-ground (1) otherwise.
20
+
21
+ Output labels: **0 = ground, 1 = non-ground** — same convention as
22
+ ``GroundSegmentationCSF`` and ``GroundSegmentationRANSAC``.
23
+
24
+ Default ground IDs for RELLIS-3D::
25
+
26
+ {1: dirt, 3: grass, 10: asphalt, 23: concrete, 31: puddle, 33: mud}
27
+
28
+ Args:
29
+ labels_key: Input channel for per-point semantic labels.
30
+ ground_ids: Set of semantic class IDs considered ground.
31
+ Defaults to the RELLIS-3D ground classes.
32
+ output_key: Override the default channel name ``"ground_labels"``.
33
+ """
34
+
35
+ output_key: ClassVar[str] = "ground_labels"
36
+ input_keys: ClassVar[list[str]] = ["labels"]
37
+ timestamps_from: ClassVar[str] = "lidar"
38
+ sources: ClassVar[list[str]] = ["labels"]
39
+
40
+ def __init__(
41
+ self,
42
+ labels_key: str = "labels",
43
+ ground_ids: frozenset[int] | None = None,
44
+ output_key: str | None = None,
45
+ ) -> None:
46
+ self._labels_key = labels_key
47
+ self._ground_ids = ground_ids if ground_ids is not None else _RELLIS_GROUND_IDS
48
+ self.input_keys = [labels_key]
49
+ self.sources = [labels_key]
50
+ if output_key is not None:
51
+ self.output_key = output_key
52
+
53
+ def __call__(self, sample: Sample) -> np.ndarray:
54
+ labels = np.asarray(sample.data[self._labels_key])
55
+ return (~np.isin(labels, list(self._ground_ids))).astype(np.uint8)
@@ -0,0 +1,95 @@
1
+ """Ground segmentation via RANSAC plane fitting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ import numpy as np
8
+
9
+ from apairo.core.preprocessor import FramePreprocessor
10
+ from apairo.core.sample import Sample
11
+
12
+
13
+ def _ransac_plane(
14
+ xyz: np.ndarray,
15
+ n_iter: int = 200,
16
+ inlier_threshold: float = 0.15,
17
+ seed: int = 0,
18
+ ) -> tuple[np.ndarray, float]:
19
+ """Fit a ground plane with RANSAC.
20
+
21
+ Returns (normal, d) for the plane equation n · x + d = 0,
22
+ with the normal oriented upward (positive z component).
23
+ """
24
+ rng = np.random.default_rng(seed)
25
+ n = len(xyz)
26
+ best_inliers = -1
27
+ best_normal = np.array([0.0, 0.0, 1.0])
28
+ best_d = float(-np.median(xyz[:, 2]))
29
+
30
+ for _ in range(n_iter):
31
+ idx = rng.choice(n, 3, replace=False)
32
+ p0, p1, p2 = xyz[idx]
33
+ normal = np.cross(p1 - p0, p2 - p0)
34
+ norm = np.linalg.norm(normal)
35
+ if norm < 1e-8:
36
+ continue
37
+ normal = normal / norm
38
+ if normal[2] < 0:
39
+ normal = -normal
40
+ d = -float(np.dot(normal, p0))
41
+
42
+ dist = np.abs(xyz @ normal + d)
43
+ n_inl = int((dist < inlier_threshold).sum())
44
+ if n_inl > best_inliers:
45
+ best_inliers = n_inl
46
+ best_normal = normal
47
+ best_d = d
48
+
49
+ return best_normal, best_d
50
+
51
+
52
+ class GroundSegmentationRANSAC(FramePreprocessor):
53
+ """Per-point ground/non-ground labels via RANSAC plane fitting.
54
+
55
+ Fits a plane to the voxelised point cloud with RANSAC. Points whose
56
+ distance to the plane is within ``inlier_threshold`` are labelled ground
57
+ (0); all others are labelled non-ground (1).
58
+
59
+ Output labels: **0 = ground, 1 = non-ground**.
60
+
61
+ Args:
62
+ voxelised_key: Input channel (voxelised point cloud, shape (N, D≥3)).
63
+ n_iter: Number of RANSAC iterations. Default 200.
64
+ inlier_threshold: Distance threshold (metres) for a point to be ground.
65
+ Default 0.15 m.
66
+ output_key: Override the default channel name ``"ground_ransac"``.
67
+ """
68
+
69
+ output_key: ClassVar[str] = "ground_ransac"
70
+ input_keys: ClassVar[list[str]] = ["voxelised"]
71
+ timestamps_from: ClassVar[str] = "voxelised"
72
+ sources: ClassVar[list[str]] = ["voxelised"]
73
+
74
+ def __init__(
75
+ self,
76
+ voxelised_key: str = "voxelised",
77
+ n_iter: int = 200,
78
+ inlier_threshold: float = 0.15,
79
+ output_key: str | None = None,
80
+ ) -> None:
81
+ self._voxelised_key = voxelised_key
82
+ self._n_iter = n_iter
83
+ self._inlier_threshold = inlier_threshold
84
+ self.input_keys = [voxelised_key]
85
+ self.sources = [voxelised_key]
86
+ if output_key is not None:
87
+ self.output_key = output_key
88
+
89
+ def __call__(self, sample: Sample) -> np.ndarray:
90
+ xyz = np.asarray(sample.data[self._voxelised_key], dtype=np.float64)[:, :3]
91
+ normal, d = _ransac_plane(
92
+ xyz, n_iter=self._n_iter, inlier_threshold=self._inlier_threshold
93
+ )
94
+ dist = np.abs(xyz @ normal + d)
95
+ return (dist > self._inlier_threshold).astype(np.uint8)
@@ -0,0 +1,4 @@
1
+ from apairo_preprocess.odometry.gicp import GICPOdometry
2
+ from apairo_preprocess.odometry.kissicp import KissICPOdometry
3
+
4
+ __all__ = ["GICPOdometry", "KissICPOdometry"]
@@ -0,0 +1,96 @@
1
+ """Incremental scan-to-scan GICP pose estimation.
2
+
3
+ Produces one (4, 4) float64 world pose per scan, accumulated via
4
+ point-to-plane ICP. The first scan is the world origin (identity).
5
+
6
+ Cross-sequence boundaries (when the dataset mixes multiple recording
7
+ sessions) the accumulated drift will jump, but for single-session
8
+ splits the poses are consistent.
9
+
10
+ Output channel: ``gicp_poses`` (npys — one .npy per scan)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import ClassVar
16
+
17
+ import numpy as np
18
+
19
+ from apairo.core.preprocessor import FramePreprocessor
20
+ from apairo.core.sample import Sample
21
+
22
+ try:
23
+ import open3d as o3d
24
+ _O3D_OK = True
25
+ except ImportError:
26
+ _O3D_OK = False
27
+
28
+
29
+ class GICPOdometry(FramePreprocessor):
30
+ """Stateful scan-to-scan GICP → one (4, 4) world pose per frame.
31
+
32
+ Uses Open3D Generalized ICP (point-to-plane). Each call to
33
+ :meth:`process` registers the current scan against the previous one
34
+ and returns the accumulated world pose T_world_sensor as a (4, 4)
35
+ float64 array.
36
+
37
+ Args:
38
+ lidar_key: Input channel for point cloud data.
39
+ voxel_size: Down-sampling voxel size (metres).
40
+ max_range: Range filter applied before registration (metres).
41
+ max_corr: Maximum correspondence distance for ICP (metres).
42
+ output_key: Override the default output channel name ``"gicp_poses"``.
43
+ """
44
+
45
+ output_key: ClassVar[str] = "gicp_poses"
46
+ input_keys: ClassVar[list[str]] = ["lidar"]
47
+ timestamps_from: ClassVar[str] = "lidar"
48
+ sources: ClassVar[list[str]] = ["lidar"]
49
+
50
+ def __init__(
51
+ self,
52
+ lidar_key: str = "lidar",
53
+ voxel_size: float = 0.3,
54
+ max_range: float = 50.0,
55
+ max_corr: float = 1.0,
56
+ output_key: str | None = None,
57
+ ) -> None:
58
+ self._lidar_key = lidar_key
59
+ self.input_keys = [lidar_key]
60
+ self.sources = [lidar_key]
61
+ if output_key is not None:
62
+ self.output_key = output_key
63
+ if not _O3D_OK:
64
+ raise ImportError("open3d is required for GICPOdometry.")
65
+ self._voxel_size = voxel_size
66
+ self._max_range = max_range
67
+ self._max_corr = max_corr
68
+ self._prev_pcd = None
69
+ self._T_accum = np.eye(4, dtype=np.float64)
70
+
71
+ def __call__(self, sample: Sample) -> np.ndarray:
72
+ pc = np.asarray(sample.data[self._lidar_key])
73
+ xyz = pc[:, :3].astype(np.float64)
74
+ xyz = xyz[np.linalg.norm(xyz, axis=1) < self._max_range]
75
+
76
+ pcd = o3d.geometry.PointCloud()
77
+ pcd.points = o3d.utility.Vector3dVector(xyz)
78
+ pcd = pcd.voxel_down_sample(self._voxel_size)
79
+ pcd.estimate_normals(
80
+ o3d.geometry.KDTreeSearchParamHybrid(radius=self._voxel_size * 3, max_nn=30)
81
+ )
82
+
83
+ if self._prev_pcd is not None:
84
+ result = o3d.pipelines.registration.registration_generalized_icp(
85
+ pcd,
86
+ self._prev_pcd,
87
+ self._max_corr,
88
+ np.eye(4),
89
+ o3d.pipelines.registration.TransformationEstimationForGeneralizedICP(),
90
+ )
91
+ # result.transformation: source → target (current → prev)
92
+ # T_world_cur = T_world_prev @ T_cur_to_prev
93
+ self._T_accum = self._T_accum @ result.transformation
94
+
95
+ self._prev_pcd = pcd
96
+ return self._T_accum.copy()
@@ -0,0 +1,84 @@
1
+ """LiDAR odometry via KISS-ICP.
2
+
3
+ Produces one (4, 4) float64 world pose per scan using KISS-ICP
4
+ (https://github.com/PRBonn/kiss-icp). Much faster than Open3D GICP (~100x)
5
+ and handles motion deskewing natively when per-point timestamps are available.
6
+
7
+ Output channel: ``kissicp_poses`` (npys — one .npy per scan)
8
+
9
+ Typical usage::
10
+
11
+ dataset.run_preprocess(
12
+ KissICPOdometry(voxel_size=1.0),
13
+ split_dir,
14
+ )
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import ClassVar
20
+
21
+ import numpy as np
22
+
23
+ from apairo.core.preprocessor import FramePreprocessor
24
+ from apairo.core.sample import Sample
25
+
26
+ try:
27
+ from kiss_icp.config import KISSConfig
28
+ from kiss_icp.kiss_icp import KissICP
29
+ _KISS_OK = True
30
+ except ImportError:
31
+ _KISS_OK = False
32
+
33
+
34
+ class KissICPOdometry(FramePreprocessor):
35
+ """Stateful KISS-ICP odometry → one (4, 4) world pose per frame.
36
+
37
+ Each call to :meth:`process` registers the current scan and returns
38
+ the accumulated T_world_lidar pose. The first scan is the world origin.
39
+
40
+ Args:
41
+ lidar_key: Input channel for point cloud data.
42
+ max_range: Maximum point range kept before registration (metres).
43
+ min_range: Minimum point range kept (metres).
44
+ voxel_size: Map voxel size (metres). Controls both resolution and speed.
45
+ deskew: Enable motion deskewing. Requires per-point timestamps in
46
+ the lidar channel (4th column).
47
+ output_key: Override the default output channel name ``"kissicp_poses"``.
48
+ """
49
+
50
+ output_key: ClassVar[str] = "kissicp_poses"
51
+ input_keys: ClassVar[list[str]] = ["lidar"]
52
+ timestamps_from: ClassVar[str] = "lidar"
53
+ sources: ClassVar[list[str]] = ["lidar"]
54
+
55
+ def __init__(
56
+ self,
57
+ lidar_key: str = "lidar",
58
+ max_range: float = 50.0,
59
+ min_range: float = 1.0,
60
+ voxel_size: float = 1.0,
61
+ deskew: bool = False,
62
+ output_key: str | None = None,
63
+ ) -> None:
64
+ self._lidar_key = lidar_key
65
+ self.input_keys = [lidar_key]
66
+ self.sources = [lidar_key]
67
+ if output_key is not None:
68
+ self.output_key = output_key
69
+ if not _KISS_OK:
70
+ raise ImportError("kiss-icp is required for KissICPOdometry. pip install kiss-icp")
71
+ cfg = KISSConfig()
72
+ cfg.data.max_range = max_range
73
+ cfg.data.min_range = min_range
74
+ cfg.data.deskew = deskew
75
+ cfg.mapping.voxel_size = float(voxel_size)
76
+ self._kiss = KissICP(config=cfg)
77
+ self._deskew = deskew
78
+
79
+ def __call__(self, sample: Sample) -> np.ndarray:
80
+ pc = np.asarray(sample.data[self._lidar_key], dtype=np.float64)
81
+ xyz = pc[:, :3]
82
+ timestamps = pc[:, 3] if self._deskew and pc.shape[1] > 3 else np.zeros(len(xyz))
83
+ self._kiss.register_frame(xyz, timestamps)
84
+ return self._kiss.last_pose.copy() # (4, 4) float64
@@ -0,0 +1,4 @@
1
+ from apairo_preprocess.pointcloud.voxelise import VoxeliseCoords, VoxeliseLabels, VoxelisePointCloud
2
+ from apairo_preprocess.pointcloud.robot_filter import RemoveRobotPoints
3
+
4
+ __all__ = ["VoxelisePointCloud", "VoxeliseLabels", "VoxeliseCoords", "RemoveRobotPoints"]
@@ -0,0 +1,121 @@
1
+ """Filtering of LiDAR points that originate from the robot's own body.
2
+
3
+ When the sensor is mounted on the robot, some returns will hit the robot
4
+ chassis. This preprocessor removes those self-hits by masking out points
5
+ that fall inside a geometric approximation of the robot shape (axis-aligned
6
+ box or upright cylinder), expressed in the sensor frame.
7
+
8
+ Typical usage::
9
+
10
+ dataset.run_preprocess(
11
+ RemoveRobotPoints(
12
+ shape="box",
13
+ x_range=(-0.6, 0.6),
14
+ y_range=(-0.4, 0.4),
15
+ z_range=(-0.5, 0.2),
16
+ ),
17
+ split_dir,
18
+ )
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import ClassVar, Literal
24
+
25
+ import numpy as np
26
+
27
+ from apairo.core.preprocessor import FramePreprocessor
28
+ from apairo.core.sample import Sample
29
+
30
+ _Shape = Literal["box", "cylinder"]
31
+
32
+
33
+ class RemoveRobotPoints(FramePreprocessor):
34
+ """Remove scan points that lie inside the robot's body volume.
35
+
36
+ Points whose coordinates (in the sensor frame) fall inside the given
37
+ geometric shape are discarded. All other points and their extra channels
38
+ (intensity, ring, …) are preserved unchanged.
39
+
40
+ Args:
41
+ lidar_key: Input channel for point cloud data.
42
+ shape: Approximation geometry for the robot body:
43
+
44
+ * ``"box"`` — axis-aligned bounding box defined by
45
+ ``x_range``, ``y_range``, and ``z_range``.
46
+ * ``"cylinder"`` — upright cylinder defined by ``radius``
47
+ and ``z_range``.
48
+
49
+ x_range: ``(min, max)`` extents along X (metres). Box only.
50
+ y_range: ``(min, max)`` extents along Y (metres). Box only.
51
+ z_range: ``(min, max)`` extents along Z (metres). Both shapes.
52
+ radius: Radius of the cylinder in the XY plane (metres).
53
+ Cylinder only.
54
+ output_key: Override the default output channel name
55
+ ``"lidar_no_robot"``.
56
+ """
57
+
58
+ output_key: ClassVar[str] = "lidar_no_robot"
59
+ input_keys: ClassVar[list[str]] = ["lidar"]
60
+ timestamps_from: ClassVar[str] = "lidar"
61
+ sources: ClassVar[list[str]] = ["lidar"]
62
+
63
+ def __init__(
64
+ self,
65
+ lidar_key: str = "lidar",
66
+ shape: _Shape = "box",
67
+ x_range: tuple[float, float] = (-1.0, 1.0),
68
+ y_range: tuple[float, float] = (-1.0, 1.0),
69
+ z_range: tuple[float, float] = (-1.0, 1.0),
70
+ radius: float = 1.0,
71
+ output_key: str | None = None,
72
+ ) -> None:
73
+ if shape not in ("box", "cylinder"):
74
+ raise ValueError(f"shape must be 'box' or 'cylinder', got {shape!r}")
75
+ if shape == "box":
76
+ if x_range[0] >= x_range[1]:
77
+ raise ValueError(f"x_range must satisfy min < max, got {x_range}")
78
+ if y_range[0] >= y_range[1]:
79
+ raise ValueError(f"y_range must satisfy min < max, got {y_range}")
80
+ if z_range[0] >= z_range[1]:
81
+ raise ValueError(f"z_range must satisfy min < max, got {z_range}")
82
+ if shape == "cylinder" and radius <= 0:
83
+ raise ValueError(f"radius must be positive, got {radius}")
84
+
85
+ self._lidar_key = lidar_key
86
+ self._shape = shape
87
+ self._x_range = x_range
88
+ self._y_range = y_range
89
+ self._z_range = z_range
90
+ self._radius = float(radius)
91
+
92
+ self.input_keys = [lidar_key]
93
+ self.sources = [lidar_key]
94
+ if output_key is not None:
95
+ self.output_key = output_key
96
+
97
+ def __call__(self, sample: Sample) -> np.ndarray:
98
+ pc = np.asarray(sample.data[self._lidar_key], dtype=np.float64)
99
+ if pc.ndim != 2 or pc.shape[1] < 3:
100
+ raise ValueError(f"Point cloud must be (N, D>=3), got {pc.shape}")
101
+
102
+ xyz = pc[:, :3]
103
+
104
+ if self._shape == "box":
105
+ inside = (
106
+ (xyz[:, 0] >= self._x_range[0])
107
+ & (xyz[:, 0] <= self._x_range[1])
108
+ & (xyz[:, 1] >= self._y_range[0])
109
+ & (xyz[:, 1] <= self._y_range[1])
110
+ & (xyz[:, 2] >= self._z_range[0])
111
+ & (xyz[:, 2] <= self._z_range[1])
112
+ )
113
+ else:
114
+ dist_xy = np.linalg.norm(xyz[:, :2], axis=1)
115
+ inside = (
116
+ (dist_xy <= self._radius)
117
+ & (xyz[:, 2] >= self._z_range[0])
118
+ & (xyz[:, 2] <= self._z_range[1])
119
+ )
120
+
121
+ return pc[~inside]