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.
- apairo_preprocess/__init__.py +27 -0
- apairo_preprocess/ground/__init__.py +9 -0
- apairo_preprocess/ground/csf.py +95 -0
- apairo_preprocess/ground/from_labels.py +55 -0
- apairo_preprocess/ground/ransac.py +95 -0
- apairo_preprocess/odometry/__init__.py +4 -0
- apairo_preprocess/odometry/gicp.py +96 -0
- apairo_preprocess/odometry/kissicp.py +84 -0
- apairo_preprocess/pointcloud/__init__.py +4 -0
- apairo_preprocess/pointcloud/robot_filter.py +121 -0
- apairo_preprocess/pointcloud/voxelise.py +281 -0
- apairo_preprocess/priors/__init__.py +7 -0
- apairo_preprocess/priors/ground_height.py +60 -0
- apairo_preprocess/priors/trajectory_distance.py +97 -0
- apairo_preprocess/projection/__init__.py +12 -0
- apairo_preprocess/projection/bev.py +166 -0
- apairo_preprocess/projection/image_mask.py +181 -0
- apairo_preprocess/projection/lidar_to_camera.py +177 -0
- apairo_preprocess/projection/point_features.py +100 -0
- apairo_preprocess/traversability/__init__.py +7 -0
- apairo_preprocess/traversability/from_labels.py +61 -0
- apairo_preprocess/traversability/from_trajectory.py +181 -0
- apairo_preprocess-0.2.0.dist-info/METADATA +255 -0
- apairo_preprocess-0.2.0.dist-info/RECORD +27 -0
- apairo_preprocess-0.2.0.dist-info/WHEEL +5 -0
- apairo_preprocess-0.2.0.dist-info/licenses/LICENSE +21 -0
- apairo_preprocess-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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,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,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]
|