apairo-preprocess 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → apairo_preprocess-0.3.0}/PKG-INFO +8 -2
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/README.md +7 -1
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/ground/csf.py +5 -1
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/ground/ransac.py +2 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/odometry/gicp.py +8 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/odometry/kissicp.py +10 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/pointcloud/voxelise.py +36 -14
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/priors/ground_height.py +2 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/projection/bev.py +25 -11
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/projection/image_mask.py +23 -4
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/projection/lidar_to_camera.py +30 -2
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/projection/point_features.py +7 -3
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/traversability/from_trajectory.py +119 -22
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess.egg-info/PKG-INFO +8 -2
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess.egg-info/SOURCES.txt +1 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/pyproject.toml +1 -1
- apairo_preprocess-0.3.0/tests/test_audit_fixes.py +311 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_image_mask_from_point_labels.py +33 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_lidar_camera_projection.py +35 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_point_features_from_image.py +9 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_traversability_from_trajectory.py +112 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/LICENSE +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/ground/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/ground/from_labels.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/odometry/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/pointcloud/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/pointcloud/robot_filter.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/priors/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/priors/trajectory_distance.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/projection/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/traversability/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/traversability/from_labels.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess.egg-info/dependency_links.txt +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess.egg-info/requires.txt +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess.egg-info/top_level.txt +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/setup.cfg +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_bev_rasterisation.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_call_contract.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_gicp_odometry.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_kissicp_odometry.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_projection_pipeline.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_remove_robot_points.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_traversability_from_labels.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/tests/test_voxelise_coords.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: apairo-preprocess
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Preprocessing for apairo datasets: odometry, ground segmentation, projection, traversability labels
|
|
5
5
|
Author-email: Augustin Bresset <augustin.bresset@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -183,13 +183,19 @@ SemanticKittiDataset.run_preprocess(
|
|
|
183
183
|
|
|
184
184
|
Requires poses to be computed first (e.g. with `KissICPOdometry`).
|
|
185
185
|
|
|
186
|
+
Lidar odometry gives the pose of the *sensor*, not of the ground under the
|
|
187
|
+
robot: pass the lidar's mounting height as `pose_height`, or every ground point
|
|
188
|
+
lies below the height window and nothing is labelled traversable.
|
|
189
|
+
|
|
186
190
|
```python
|
|
187
191
|
from apairo.dataset.goose import Goose3DDataset
|
|
188
192
|
from apairo_preprocess import KissICPOdometry, TraversabilityFromTrajectory
|
|
189
193
|
|
|
190
194
|
Goose3DDataset.run_preprocess(KissICPOdometry(voxel_size=1.0), "/data/goose/seq_001")
|
|
191
195
|
Goose3DDataset.run_preprocess(
|
|
192
|
-
TraversabilityFromTrajectory(
|
|
196
|
+
TraversabilityFromTrajectory(
|
|
197
|
+
poses_key="kissicp_poses", robot_radius=0.75, pose_height=1.2
|
|
198
|
+
),
|
|
193
199
|
"/data/goose/seq_001",
|
|
194
200
|
)
|
|
195
201
|
# writes trav_traj/ (uint8: 1=traversable, 0=not)
|
|
@@ -151,13 +151,19 @@ SemanticKittiDataset.run_preprocess(
|
|
|
151
151
|
|
|
152
152
|
Requires poses to be computed first (e.g. with `KissICPOdometry`).
|
|
153
153
|
|
|
154
|
+
Lidar odometry gives the pose of the *sensor*, not of the ground under the
|
|
155
|
+
robot: pass the lidar's mounting height as `pose_height`, or every ground point
|
|
156
|
+
lies below the height window and nothing is labelled traversable.
|
|
157
|
+
|
|
154
158
|
```python
|
|
155
159
|
from apairo.dataset.goose import Goose3DDataset
|
|
156
160
|
from apairo_preprocess import KissICPOdometry, TraversabilityFromTrajectory
|
|
157
161
|
|
|
158
162
|
Goose3DDataset.run_preprocess(KissICPOdometry(voxel_size=1.0), "/data/goose/seq_001")
|
|
159
163
|
Goose3DDataset.run_preprocess(
|
|
160
|
-
TraversabilityFromTrajectory(
|
|
164
|
+
TraversabilityFromTrajectory(
|
|
165
|
+
poses_key="kissicp_poses", robot_radius=0.75, pose_height=1.2
|
|
166
|
+
),
|
|
161
167
|
"/data/goose/seq_001",
|
|
162
168
|
)
|
|
163
169
|
# writes trav_traj/ (uint8: 1=traversable, 0=not)
|
|
@@ -35,10 +35,14 @@ def _run_csf(
|
|
|
35
35
|
csf.params.class_threshold = class_threshold
|
|
36
36
|
csf.params.interations = 500
|
|
37
37
|
|
|
38
|
+
if len(xyz) == 0:
|
|
39
|
+
return [], []
|
|
38
40
|
csf.setPointCloud(xyz.tolist())
|
|
39
41
|
ground_idx = CSF.VecInt()
|
|
40
42
|
non_ground_idx = CSF.VecInt()
|
|
41
|
-
|
|
43
|
+
# exportCloth=False: by default CSF writes cloth_nodes.txt in the current
|
|
44
|
+
# directory, on every frame.
|
|
45
|
+
csf.do_filtering(ground_idx, non_ground_idx, False)
|
|
42
46
|
return list(ground_idx), list(non_ground_idx)
|
|
43
47
|
|
|
44
48
|
|
|
@@ -25,6 +25,8 @@ def _ransac_plane(
|
|
|
25
25
|
n = len(xyz)
|
|
26
26
|
best_inliers = -1
|
|
27
27
|
best_normal = np.array([0.0, 0.0, 1.0])
|
|
28
|
+
if n < 3: # no plane to fit: a horizontal one through what there is
|
|
29
|
+
return best_normal, (float(-np.median(xyz[:, 2])) if n else 0.0)
|
|
28
30
|
best_d = float(-np.median(xyz[:, 2]))
|
|
29
31
|
|
|
30
32
|
for _ in range(n_iter):
|
|
@@ -68,6 +68,14 @@ class GICPOdometry(FramePreprocessor):
|
|
|
68
68
|
self._prev_pcd = None
|
|
69
69
|
self._T_accum = np.eye(4, dtype=np.float64)
|
|
70
70
|
|
|
71
|
+
def start_sequence(self) -> None:
|
|
72
|
+
"""Forget the previous scan and the pose: the next scan is a new world
|
|
73
|
+
origin. Called by apairo's runner (>= 0.11) at the start of each
|
|
74
|
+
sequence of a root; without it a sequence continued the previous one's
|
|
75
|
+
pose and registered its first scan against an unrelated cloud."""
|
|
76
|
+
self._prev_pcd = None
|
|
77
|
+
self._T_accum = np.eye(4, dtype=np.float64)
|
|
78
|
+
|
|
71
79
|
def __call__(self, sample: Sample) -> np.ndarray:
|
|
72
80
|
pc = np.asarray(sample.data[self._lidar_key])
|
|
73
81
|
xyz = pc[:, :3].astype(np.float64)
|
|
@@ -73,9 +73,19 @@ class KissICPOdometry(FramePreprocessor):
|
|
|
73
73
|
cfg.data.min_range = min_range
|
|
74
74
|
cfg.data.deskew = deskew
|
|
75
75
|
cfg.mapping.voxel_size = float(voxel_size)
|
|
76
|
+
self._config = cfg
|
|
76
77
|
self._kiss = KissICP(config=cfg)
|
|
77
78
|
self._deskew = deskew
|
|
78
79
|
|
|
80
|
+
def start_sequence(self) -> None:
|
|
81
|
+
"""Forget the map and the pose: the next scan is a new world origin.
|
|
82
|
+
|
|
83
|
+
Called by apairo's runner (>= 0.11) at the start of each sequence of a
|
|
84
|
+
root. Without it the first scan of a sequence was registered against
|
|
85
|
+
the previous sequence's map and continued its pose.
|
|
86
|
+
"""
|
|
87
|
+
self._kiss = KissICP(config=self._config)
|
|
88
|
+
|
|
79
89
|
def __call__(self, sample: Sample) -> np.ndarray:
|
|
80
90
|
pc = np.asarray(sample.data[self._lidar_key], dtype=np.float64)
|
|
81
91
|
xyz = pc[:, :3]
|
{apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/pointcloud/voxelise.py
RENAMED
|
@@ -47,9 +47,14 @@ def _filter_and_quantize(
|
|
|
47
47
|
inverse: Per-point voxel index array, shape (N_kept,).
|
|
48
48
|
n_voxels: Number of occupied voxels.
|
|
49
49
|
"""
|
|
50
|
-
|
|
50
|
+
# A return without a position (NaN or inf) belongs to no voxel: it used to
|
|
51
|
+
# become a voxel at the int32 minimum, with a NaN row in the output.
|
|
52
|
+
mask: np.ndarray | None = np.all(np.isfinite(xyz), axis=1)
|
|
51
53
|
if max_range is not None:
|
|
52
|
-
mask
|
|
54
|
+
mask &= np.linalg.norm(xyz, axis=1) < max_range
|
|
55
|
+
if mask.all():
|
|
56
|
+
mask = None
|
|
57
|
+
else:
|
|
53
58
|
xyz = xyz[mask]
|
|
54
59
|
|
|
55
60
|
if len(xyz) == 0:
|
|
@@ -76,6 +81,8 @@ class VoxelisePointCloud(FramePreprocessor):
|
|
|
76
81
|
max_range: Discard points farther than this distance from the sensor
|
|
77
82
|
origin (metres). ``None`` keeps all points.
|
|
78
83
|
reduction: How to pick the representative point per voxel.
|
|
84
|
+
seed: Seed of ``reduction="random"``. With a seed, the same scan
|
|
85
|
+
gives the same points on every run; ``None`` draws anew.
|
|
79
86
|
output_key: Override the default output channel name ``"voxelised"``.
|
|
80
87
|
"""
|
|
81
88
|
|
|
@@ -91,11 +98,13 @@ class VoxelisePointCloud(FramePreprocessor):
|
|
|
91
98
|
max_range: float | None = None,
|
|
92
99
|
reduction: _Reduction = "centroid",
|
|
93
100
|
output_key: str | None = None,
|
|
101
|
+
seed: int | None = None,
|
|
94
102
|
) -> None:
|
|
95
103
|
if voxel_size <= 0:
|
|
96
104
|
raise ValueError(f"voxel_size must be positive, got {voxel_size}")
|
|
97
105
|
if max_range is not None and max_range <= 0:
|
|
98
106
|
raise ValueError(f"max_range must be positive, got {max_range}")
|
|
107
|
+
self.seed = seed
|
|
99
108
|
if reduction not in ("centroid", "first", "random"):
|
|
100
109
|
raise ValueError(
|
|
101
110
|
f"reduction must be 'centroid', 'first', or 'random', got {reduction!r}"
|
|
@@ -134,13 +143,14 @@ class VoxelisePointCloud(FramePreprocessor):
|
|
|
134
143
|
return out
|
|
135
144
|
|
|
136
145
|
if self._reduction == "first":
|
|
137
|
-
|
|
138
|
-
#
|
|
139
|
-
|
|
140
|
-
|
|
146
|
+
# The lowest input index of each voxel (a plain `out[inverse] = pc`
|
|
147
|
+
# keeps the last point written, not the first).
|
|
148
|
+
first = np.full(n_voxels, len(pc), dtype=np.int64)
|
|
149
|
+
np.minimum.at(first, inverse, np.arange(len(pc)))
|
|
150
|
+
return pc[first]
|
|
141
151
|
|
|
142
152
|
# random: shuffle so a random point ends up last (and thus selected)
|
|
143
|
-
rng = np.random.default_rng()
|
|
153
|
+
rng = np.random.default_rng(self.seed)
|
|
144
154
|
chosen = np.empty(n_voxels, dtype=np.int64)
|
|
145
155
|
perm = rng.permutation(len(pc))
|
|
146
156
|
chosen[inverse[perm]] = perm
|
|
@@ -212,16 +222,28 @@ class VoxeliseLabels(FramePreprocessor):
|
|
|
212
222
|
if n_voxels == 0:
|
|
213
223
|
return np.empty(0, dtype=labels.dtype)
|
|
214
224
|
|
|
225
|
+
# Both aggregations go through one sort of the (voxel, label) pairs,
|
|
226
|
+
# so they hold for any label values: negative ids (an ignore label of
|
|
227
|
+
# -1), and large ones (SemanticKITTI's instance << 16 | class), which a
|
|
228
|
+
# table of n_voxels x max_label counters cannot.
|
|
229
|
+
order = np.lexsort((labels, inverse))
|
|
230
|
+
voxel, label = inverse[order], labels[order]
|
|
231
|
+
out = np.empty(n_voxels, dtype=labels.dtype)
|
|
215
232
|
if self._aggregation == "max":
|
|
216
|
-
|
|
217
|
-
|
|
233
|
+
last = np.r_[voxel[1:] != voxel[:-1], True] # highest label of each voxel
|
|
234
|
+
out[voxel[last]] = label[last]
|
|
218
235
|
return out
|
|
219
236
|
|
|
220
|
-
# majority:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
237
|
+
# majority: runs of equal (voxel, label), the longest per voxel; ties
|
|
238
|
+
# go to the smallest label.
|
|
239
|
+
start = np.r_[True, (voxel[1:] != voxel[:-1]) | (label[1:] != label[:-1])]
|
|
240
|
+
starts = np.flatnonzero(start)
|
|
241
|
+
counts = np.diff(np.r_[starts, len(voxel)])
|
|
242
|
+
run_voxel, run_label = voxel[starts], label[starts]
|
|
243
|
+
best = np.lexsort((run_label, -counts, run_voxel))
|
|
244
|
+
pick = best[np.r_[True, run_voxel[best][1:] != run_voxel[best][:-1]]]
|
|
245
|
+
out[run_voxel[pick]] = run_label[pick]
|
|
246
|
+
return out
|
|
225
247
|
|
|
226
248
|
|
|
227
249
|
class VoxeliseCoords(FramePreprocessor):
|
{apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/priors/ground_height.py
RENAMED
|
@@ -49,6 +49,8 @@ class GroundHeightFromLabels(FramePreprocessor):
|
|
|
49
49
|
def __call__(self, sample: Sample) -> np.ndarray:
|
|
50
50
|
xyz = np.asarray(sample.data[self._voxelised_key], dtype=np.float64)[:, :3]
|
|
51
51
|
labels = np.asarray(sample.data[self._ground_key])
|
|
52
|
+
if len(xyz) == 0:
|
|
53
|
+
return np.empty(0, dtype=np.float32)
|
|
52
54
|
ground_pts = xyz[labels == 0]
|
|
53
55
|
|
|
54
56
|
if len(ground_pts) == 0:
|
|
@@ -29,17 +29,33 @@ from apairo.core.preprocessor import FramePreprocessor
|
|
|
29
29
|
from apairo.core.sample import Sample
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _grid_size(bounds, resolution) -> tuple[int, int]:
|
|
33
|
+
"""``(h, w)`` of the grid covering *bounds*: the last row or column is cut
|
|
34
|
+
by the bound when the extent is not a multiple of the resolution. (Rounding
|
|
35
|
+
the count dropped points inside the bounds, or took in points outside.)"""
|
|
36
|
+
xmin, xmax, ymin, ymax = bounds
|
|
37
|
+
w = int(np.ceil(round((xmax - xmin) / resolution, 9)))
|
|
38
|
+
h = int(np.ceil(round((ymax - ymin) / resolution, 9)))
|
|
39
|
+
return h, w
|
|
40
|
+
|
|
41
|
+
|
|
32
42
|
def _rasterise(points, values, *, bounds, resolution, reduce, background):
|
|
33
43
|
xmin, xmax, ymin, ymax = bounds
|
|
34
|
-
w =
|
|
35
|
-
h = int(round((ymax - ymin) / resolution))
|
|
44
|
+
h, w = _grid_size(bounds, resolution)
|
|
36
45
|
|
|
37
|
-
xy = np.asarray(points)[:, :2]
|
|
46
|
+
xy = np.asarray(points)[:, :2].astype(np.float64)
|
|
38
47
|
vals = np.asarray(values, dtype=np.float64)
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
# The tiny offset keeps a point lying exactly on a cell edge in its cell:
|
|
50
|
+
# 0.3 / 0.1 is 2.9999999999999996 in floating point.
|
|
51
|
+
col = np.floor((xy[:, 0] - xmin) / resolution + 1e-9).astype(np.int64)
|
|
52
|
+
row = np.floor((ymax - xy[:, 1]) / resolution + 1e-9).astype(np.int64)
|
|
53
|
+
inside = (
|
|
54
|
+
(xy[:, 0] >= xmin) & (xy[:, 0] < xmax) & (xy[:, 1] > ymin) & (xy[:, 1] <= ymax)
|
|
55
|
+
& (col >= 0) & (col < w) & (row >= 0) & (row < h)
|
|
56
|
+
# A value that is not a number says nothing about its cell.
|
|
57
|
+
& np.isfinite(vals)
|
|
58
|
+
)
|
|
43
59
|
cell = row[inside] * w + col[inside]
|
|
44
60
|
vals = vals[inside]
|
|
45
61
|
|
|
@@ -51,7 +67,8 @@ def _rasterise(points, values, *, bounds, resolution, reduce, background):
|
|
|
51
67
|
# no point touched.
|
|
52
68
|
acc = np.full(h * w, -np.inf)
|
|
53
69
|
np.maximum.at(acc, cell, vals)
|
|
54
|
-
hit = np.
|
|
70
|
+
hit = np.zeros(h * w, dtype=bool)
|
|
71
|
+
hit[cell] = True
|
|
55
72
|
grid[hit] = acc[hit]
|
|
56
73
|
elif reduce == "last":
|
|
57
74
|
grid[cell] = vals
|
|
@@ -125,10 +142,7 @@ class BEVRasterisation(FramePreprocessor):
|
|
|
125
142
|
@property
|
|
126
143
|
def shape(self) -> tuple[int, int]:
|
|
127
144
|
"""``(height, width)`` of the output grid, from ``bounds``/``resolution``."""
|
|
128
|
-
|
|
129
|
-
w = int(round((xmax - xmin) / self._resolution))
|
|
130
|
-
h = int(round((ymax - ymin) / self._resolution))
|
|
131
|
-
return h, w
|
|
145
|
+
return _grid_size(self._bounds, self._resolution)
|
|
132
146
|
|
|
133
147
|
def __call__(self, sample: Sample) -> np.ndarray:
|
|
134
148
|
points = np.asarray(sample.data[self._points_key])
|
{apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/projection/image_mask.py
RENAMED
|
@@ -50,7 +50,9 @@ class ImageMaskFromPointLabels(FramePreprocessor):
|
|
|
50
50
|
labels_key: Input channel with per-point integer labels ``(N,)``,
|
|
51
51
|
row-aligned with the projection.
|
|
52
52
|
projection_key: Input channel with ``(N, 3)`` ``[u, v, depth]`` rows
|
|
53
|
-
from :class:`LidarCameraProjection`.
|
|
53
|
+
from :class:`LidarCameraProjection`. A point paints
|
|
54
|
+
pixel ``(floor(v), floor(u))``; rows that are NaN or
|
|
55
|
+
whose pixel falls outside the image are not painted.
|
|
54
56
|
radius: Splat radius in pixels. ``0`` (default) paints one
|
|
55
57
|
pixel per point; ``r`` paints a disc of radius ``r``,
|
|
56
58
|
densifying the sparse lidar coverage.
|
|
@@ -121,14 +123,31 @@ class ImageMaskFromPointLabels(FramePreprocessor):
|
|
|
121
123
|
height, width = self._height, self._width
|
|
122
124
|
mask = np.full((height, width), self._unknown, dtype=np.uint8)
|
|
123
125
|
|
|
124
|
-
|
|
126
|
+
# Pixel index = floor of the stored coordinate, and a point is painted
|
|
127
|
+
# only if that index is inside the image: the bound test and the index
|
|
128
|
+
# come from the same values, so a point on (or past) the edge is
|
|
129
|
+
# dropped as a whole instead of splatting its in-image neighbours or
|
|
130
|
+
# aliasing into another occlusion bin.
|
|
131
|
+
with np.errstate(invalid="ignore"):
|
|
132
|
+
cols_f = np.floor(uv[:, 0])
|
|
133
|
+
rows_f = np.floor(uv[:, 1])
|
|
134
|
+
valid = (
|
|
135
|
+
np.isfinite(cols_f) & np.isfinite(rows_f)
|
|
136
|
+
& (cols_f >= 0) & (cols_f < width)
|
|
137
|
+
& (rows_f >= 0) & (rows_f < height)
|
|
138
|
+
)
|
|
125
139
|
if not valid.any():
|
|
126
140
|
return mask
|
|
127
141
|
|
|
128
|
-
cols =
|
|
129
|
-
rows =
|
|
142
|
+
cols = cols_f[valid].astype(np.intp)
|
|
143
|
+
rows = rows_f[valid].astype(np.intp)
|
|
130
144
|
depth = uv[valid, 2].astype(np.float64)
|
|
131
145
|
vals = labels[valid]
|
|
146
|
+
if vals.dtype.kind == "f" and not np.all(vals == np.floor(vals)):
|
|
147
|
+
raise ValueError(
|
|
148
|
+
"labels must be integers (class ids); got fractional values, "
|
|
149
|
+
"which a mask would truncate."
|
|
150
|
+
)
|
|
132
151
|
if vals.min() < 0 or vals.max() > 255:
|
|
133
152
|
raise ValueError(
|
|
134
153
|
f"labels must fit uint8, got range [{vals.min()}, {vals.max()}]"
|
{apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/projection/lidar_to_camera.py
RENAMED
|
@@ -52,6 +52,22 @@ def _distort_plumb_bob(x: np.ndarray, y: np.ndarray, d: np.ndarray) -> tuple:
|
|
|
52
52
|
return xd, yd
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def _monotonic_limit(d: np.ndarray) -> float:
|
|
56
|
+
"""The squared radius below which the radial model is one-to-one.
|
|
57
|
+
|
|
58
|
+
The distorted radius is ``r * (1 + k1 r^2 + k2 r^4 + k3 r^6)``. Past the
|
|
59
|
+
radius where its derivative first reaches zero the polynomial turns back:
|
|
60
|
+
a point far outside the field of view is folded onto the image, as a
|
|
61
|
+
valid-looking pixel. That limit, as ``r^2``; ``inf`` when the model never
|
|
62
|
+
turns back.
|
|
63
|
+
"""
|
|
64
|
+
k1, k2, _, _, k3 = d
|
|
65
|
+
# d/dr [r (1 + k1 s + k2 s^2 + k3 s^3)] with s = r^2
|
|
66
|
+
roots = np.roots([7.0 * k3, 5.0 * k2, 3.0 * k1, 1.0])
|
|
67
|
+
positive = [float(r.real) for r in roots if abs(r.imag) < 1e-12 and r.real > 0]
|
|
68
|
+
return min(positive) if positive else float("inf")
|
|
69
|
+
|
|
70
|
+
|
|
55
71
|
class LidarCameraProjection(FramePreprocessor):
|
|
56
72
|
"""Per-point pixel coordinates of a lidar scan in a camera image.
|
|
57
73
|
|
|
@@ -134,8 +150,10 @@ class LidarCameraProjection(FramePreprocessor):
|
|
|
134
150
|
f"distortion must be (k1, k2, p1, p2[, k3]), got {d.size} values"
|
|
135
151
|
)
|
|
136
152
|
self._dist = d
|
|
153
|
+
self._r2_limit = _monotonic_limit(d)
|
|
137
154
|
else:
|
|
138
155
|
self._dist = None
|
|
156
|
+
self._r2_limit = float("inf")
|
|
139
157
|
|
|
140
158
|
self._K = K
|
|
141
159
|
self._T = T
|
|
@@ -158,6 +176,13 @@ class LidarCameraProjection(FramePreprocessor):
|
|
|
158
176
|
|
|
159
177
|
out = np.full((n, 3), np.nan, dtype=np.float32)
|
|
160
178
|
front = z > 1e-9
|
|
179
|
+
if self._dist is not None:
|
|
180
|
+
# Outside the radius where the distortion model is one-to-one, a
|
|
181
|
+
# point is not in the image, whatever pixel the polynomial gives:
|
|
182
|
+
# far off-axis lidar returns were folded back into the picture.
|
|
183
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
184
|
+
r2 = (cam[:, 0] ** 2 + cam[:, 1] ** 2) / (z * z)
|
|
185
|
+
front &= r2 < self._r2_limit
|
|
161
186
|
if not front.any():
|
|
162
187
|
return out
|
|
163
188
|
|
|
@@ -166,8 +191,11 @@ class LidarCameraProjection(FramePreprocessor):
|
|
|
166
191
|
if self._dist is not None:
|
|
167
192
|
x, y = _distort_plumb_bob(x, y, self._dist)
|
|
168
193
|
|
|
169
|
-
|
|
170
|
-
|
|
194
|
+
# The bound test runs on the float32 values actually returned: a u
|
|
195
|
+
# just below the width in float64 can round up to exactly the width
|
|
196
|
+
# in float32, and must then be outside, not one pixel past the edge.
|
|
197
|
+
u = (self._K[0, 0] * x + self._K[0, 1] * y + self._K[0, 2]).astype(np.float32)
|
|
198
|
+
v = (self._K[1, 1] * y + self._K[1, 2]).astype(np.float32)
|
|
171
199
|
|
|
172
200
|
in_image = (u >= 0) & (u < self._width) & (v >= 0) & (v < self._height)
|
|
173
201
|
idx = np.flatnonzero(front)[in_image]
|
{apairo_preprocess-0.2.0 → apairo_preprocess-0.3.0}/apairo_preprocess/projection/point_features.py
RENAMED
|
@@ -89,9 +89,13 @@ class PointFeaturesFromImage(FramePreprocessor):
|
|
|
89
89
|
out = np.full((n, channels), self._fill_value, dtype=img.dtype)
|
|
90
90
|
|
|
91
91
|
valid = np.isfinite(uv[:, 0])
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
# floor, not astype: truncation would send u in (-1, 0) to column 0,
|
|
93
|
+
# and a negative index would silently wrap to the opposite edge.
|
|
94
|
+
cols = np.floor(uv[valid, 0]).astype(np.intp)
|
|
95
|
+
rows = np.floor(uv[valid, 1]).astype(np.intp)
|
|
96
|
+
if len(cols) and (
|
|
97
|
+
cols.min() < 0 or rows.min() < 0 or cols.max() >= width or rows.max() >= height
|
|
98
|
+
):
|
|
95
99
|
raise ValueError(
|
|
96
100
|
f"projection exceeds image bounds ({height}, {width}) — the "
|
|
97
101
|
f"projection was computed for a different image_size."
|
|
@@ -31,6 +31,20 @@ from apairo.core.preprocessor import SequencePreprocessor
|
|
|
31
31
|
from apairo.core.sample import Sample
|
|
32
32
|
from apairo_transform import RangeFilter
|
|
33
33
|
|
|
34
|
+
class _OrderNorm:
|
|
35
|
+
"""``(N, 3) -> (N,)`` norm of a given order (``2`` a sphere, ``np.inf`` a
|
|
36
|
+
cube): the callable :class:`~apairo_transform.RangeFilter` takes."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, order: float) -> None:
|
|
39
|
+
self.order = float(order)
|
|
40
|
+
|
|
41
|
+
def __call__(self, points: np.ndarray) -> np.ndarray:
|
|
42
|
+
return np.linalg.norm(points, ord=self.order, axis=1)
|
|
43
|
+
|
|
44
|
+
def __repr__(self) -> str:
|
|
45
|
+
return f"_OrderNorm({self.order})"
|
|
46
|
+
|
|
47
|
+
|
|
34
48
|
class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
35
49
|
"""Label each point traversable if it lies in the robot's forward footprint.
|
|
36
50
|
|
|
@@ -44,10 +58,33 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
44
58
|
float64. Apply ``PoseTo4x4()`` from ``apairo_transform``
|
|
45
59
|
if needed.
|
|
46
60
|
robot_radius: Half-width of the robot footprint in XY (metres).
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
Isotropic circle test, ignored when ``robot_length`` /
|
|
62
|
+
``robot_width`` are given.
|
|
63
|
+
robot_length: Full length of the robot footprint along its heading
|
|
64
|
+
(metres). Must be given together with ``robot_width``.
|
|
65
|
+
When set, the footprint is a heading-aligned rectangle
|
|
66
|
+
(matching the physical robot, e.g. its wheel-contact
|
|
67
|
+
box) instead of ``robot_radius``'s isotropic circle —
|
|
68
|
+
use this whenever the robot is not roughly as wide as
|
|
69
|
+
it is long, since a circle sized to the robot's length
|
|
70
|
+
over-labels its width (and vice versa). Heading is read
|
|
71
|
+
from each future pose's own rotation matrix, not
|
|
72
|
+
finite-differenced from consecutive positions, so it
|
|
73
|
+
stays accurate even where the robot is nearly
|
|
74
|
+
stationary (position noise dominates finite differences
|
|
75
|
+
there).
|
|
76
|
+
robot_width: Full width of the robot footprint, lateral to its
|
|
77
|
+
heading (metres). See ``robot_length``.
|
|
78
|
+
height_min: Lowest point height, relative to the ground under a
|
|
79
|
+
future pose, to be traversable (metres, ≤ 0).
|
|
80
|
+
height_max: Highest point height relative to that ground
|
|
81
|
+
(metres, ≥ 0).
|
|
82
|
+
pose_height: Height of the pose frame's origin above the ground
|
|
83
|
+
(metres). ``0`` (default) for poses given at
|
|
84
|
+
ground level. With lidar odometry (KISS-ICP, GICP)
|
|
85
|
+
the pose is the sensor's: pass the lidar's
|
|
86
|
+
mounting height, or every ground point lies below
|
|
87
|
+
the window and no point is ever traversable.
|
|
51
88
|
forward_window: Maximum number of future poses to look ahead.
|
|
52
89
|
``None`` (default) uses the entire remaining trajectory.
|
|
53
90
|
forward_distance: Maximum *path length* (metres) of future trajectory
|
|
@@ -64,10 +101,10 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
64
101
|
(e.g. humans) close to the robot. The shape of the
|
|
65
102
|
exclusion zone is controlled by ``near_exclusion_norm``.
|
|
66
103
|
``0.0`` disables the exclusion (default).
|
|
67
|
-
near_exclusion_norm: Norm order for the exclusion distance
|
|
68
|
-
|
|
69
|
-
``np.inf``
|
|
70
|
-
|
|
104
|
+
near_exclusion_norm: Norm order for the exclusion distance: ``np.inf``
|
|
105
|
+
(L∞) for a cube, ``2`` for a sphere. Defaults to
|
|
106
|
+
``np.inf``. A callable ``(N, 3) -> (N,)`` is
|
|
107
|
+
accepted too.
|
|
71
108
|
output_key: Override the default output channel name ``"trav_traj"``.
|
|
72
109
|
"""
|
|
73
110
|
|
|
@@ -81,6 +118,8 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
81
118
|
lidar_key: str = "lidar",
|
|
82
119
|
poses_key: str = "poses",
|
|
83
120
|
robot_radius: float = 0.75,
|
|
121
|
+
robot_length: float | None = None,
|
|
122
|
+
robot_width: float | None = None,
|
|
84
123
|
height_min: float = -0.3,
|
|
85
124
|
height_max: float = 0.5,
|
|
86
125
|
forward_window: int | None = None,
|
|
@@ -89,17 +128,35 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
89
128
|
near_exclusion_radius: float = 0.0,
|
|
90
129
|
near_exclusion_norm: float = np.inf,
|
|
91
130
|
output_key: str | None = None,
|
|
131
|
+
pose_height: float = 0.0,
|
|
92
132
|
) -> None:
|
|
133
|
+
if (robot_length is None) != (robot_width is None):
|
|
134
|
+
raise ValueError("robot_length and robot_width must be given together")
|
|
135
|
+
|
|
93
136
|
self._lidar_key = lidar_key
|
|
94
137
|
self._poses_key = poses_key
|
|
95
138
|
self._robot_radius = robot_radius
|
|
96
|
-
self.
|
|
97
|
-
self.
|
|
139
|
+
self._robot_length = robot_length
|
|
140
|
+
self._robot_width = robot_width
|
|
141
|
+
self._oriented = robot_length is not None
|
|
142
|
+
# Heights are tested against the pose origin: the window is shifted
|
|
143
|
+
# down by the origin's height above the ground.
|
|
144
|
+
self.pose_height = float(pose_height)
|
|
145
|
+
self._height_min = height_min - self.pose_height
|
|
146
|
+
self._height_max = height_max - self.pose_height
|
|
98
147
|
self._forward_window = forward_window
|
|
99
148
|
self._forward_distance = forward_distance
|
|
100
149
|
self._sequence_gap = sequence_gap
|
|
101
150
|
self._near_filter = (
|
|
102
|
-
RangeFilter(
|
|
151
|
+
RangeFilter(
|
|
152
|
+
min=near_exclusion_radius,
|
|
153
|
+
max=None,
|
|
154
|
+
norm=(
|
|
155
|
+
near_exclusion_norm
|
|
156
|
+
if callable(near_exclusion_norm)
|
|
157
|
+
else _OrderNorm(near_exclusion_norm)
|
|
158
|
+
),
|
|
159
|
+
)
|
|
103
160
|
if near_exclusion_radius > 0
|
|
104
161
|
else None
|
|
105
162
|
)
|
|
@@ -112,6 +169,8 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
112
169
|
def __call__(self, frames: Iterator[Sample]) -> np.ndarray:
|
|
113
170
|
all_samples = list(frames)
|
|
114
171
|
n = len(all_samples)
|
|
172
|
+
if n == 0:
|
|
173
|
+
return np.empty(0, dtype=object)
|
|
115
174
|
|
|
116
175
|
poses = np.asarray(
|
|
117
176
|
np.stack([s.data[self._poses_key] for s in all_samples]), dtype=np.float64
|
|
@@ -132,10 +191,22 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
132
191
|
# Cumulative path length, for the forward_distance cap.
|
|
133
192
|
path = np.concatenate([[0.0], np.cumsum(dists)])
|
|
134
193
|
|
|
194
|
+
if self._oriented:
|
|
195
|
+
# Yaw from each pose's own rotation matrix -- robust where the robot is
|
|
196
|
+
# nearly stationary (finite-differenced heading is noise-dominated there).
|
|
197
|
+
headings = np.arctan2(poses[:, 1, 0], poses[:, 0, 0])
|
|
198
|
+
half_length = self._robot_length / 2.0
|
|
199
|
+
half_width = self._robot_width / 2.0
|
|
200
|
+
covering_radius = float(np.hypot(half_length, half_width))
|
|
201
|
+
|
|
135
202
|
results = []
|
|
136
203
|
for idx, sample in enumerate(all_samples):
|
|
137
204
|
pc = np.asarray(sample.data[self._lidar_key])
|
|
138
|
-
|
|
205
|
+
xyz_all = pc[:, :3].astype(np.float64)
|
|
206
|
+
# A return without a position (NaN or inf) is never traversable; it
|
|
207
|
+
# used to abort the whole sequence in the nearest-pose search.
|
|
208
|
+
finite = np.all(np.isfinite(xyz_all), axis=1)
|
|
209
|
+
xyz_sensor = xyz_all[finite]
|
|
139
210
|
n_pts = len(xyz_sensor)
|
|
140
211
|
|
|
141
212
|
T = poses[idx]
|
|
@@ -153,23 +224,49 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
153
224
|
future_pos = poses[idx + 1 : end, :3, 3]
|
|
154
225
|
|
|
155
226
|
if len(future_pos) == 0:
|
|
156
|
-
results.append(np.zeros(
|
|
227
|
+
results.append(np.zeros(len(xyz_all), dtype=np.uint8))
|
|
157
228
|
continue
|
|
158
229
|
|
|
230
|
+
# Every future pose whose footprint may hold the point is tested, in
|
|
231
|
+
# both modes: a path that passes over a point at its own height and
|
|
232
|
+
# crosses the same spot again higher up (a ramp, a bridge, vertical
|
|
233
|
+
# drift on a loop) still drove over it.
|
|
234
|
+
reach = covering_radius if self._oriented else float(self._robot_radius)
|
|
159
235
|
tree = KDTree(future_pos[:, :2])
|
|
160
236
|
# workers=-1 deadlocks when called from a ThreadPoolExecutor (e.g. viewer)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
237
|
+
cand_lists = tree.query_ball_point(xyz_world[:, :2], r=reach)
|
|
238
|
+
lengths = np.fromiter((len(c) for c in cand_lists), dtype=np.int64, count=n_pts)
|
|
239
|
+
trav = np.zeros(n_pts, dtype=bool)
|
|
240
|
+
n_pairs = int(lengths.sum())
|
|
241
|
+
if n_pairs > 0:
|
|
242
|
+
point_idx = np.repeat(np.arange(n_pts), lengths)
|
|
243
|
+
cand_idx = np.fromiter(
|
|
244
|
+
(c for cl in cand_lists for c in cl), dtype=np.int64, count=n_pairs
|
|
245
|
+
)
|
|
246
|
+
ddx = xyz_world[point_idx, 0] - future_pos[cand_idx, 0]
|
|
247
|
+
ddy = xyz_world[point_idx, 1] - future_pos[cand_idx, 1]
|
|
248
|
+
dz_pair = xyz_world[point_idx, 2] - future_pos[cand_idx, 2]
|
|
249
|
+
if self._oriented:
|
|
250
|
+
th = headings[idx + 1 : end][cand_idx]
|
|
251
|
+
c, s = np.cos(th), np.sin(th)
|
|
252
|
+
# world delta -> candidate pose's local frame (rotate by -heading)
|
|
253
|
+
dx = c * ddx + s * ddy
|
|
254
|
+
dy = -s * ddx + c * ddy
|
|
255
|
+
footprint = (np.abs(dx) <= half_length) & (np.abs(dy) <= half_width)
|
|
256
|
+
else:
|
|
257
|
+
footprint = np.hypot(ddx, ddy) < self._robot_radius
|
|
258
|
+
inside = (
|
|
259
|
+
footprint
|
|
260
|
+
& (dz_pair >= self._height_min)
|
|
261
|
+
& (dz_pair <= self._height_max)
|
|
262
|
+
)
|
|
263
|
+
np.logical_or.at(trav, point_idx[inside], True)
|
|
169
264
|
if self._near_filter is not None:
|
|
170
265
|
trav &= self._near_filter.compute_mask(xyz_sensor)
|
|
171
266
|
|
|
172
|
-
|
|
267
|
+
labels = np.zeros(len(xyz_all), dtype=np.uint8)
|
|
268
|
+
labels[finite] = trav
|
|
269
|
+
results.append(labels)
|
|
173
270
|
|
|
174
271
|
# Uniform scans stack to (N, P) as before; ragged scans (real lidar,
|
|
175
272
|
# variable return count) become an object array of per-frame rows,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: apairo-preprocess
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Preprocessing for apairo datasets: odometry, ground segmentation, projection, traversability labels
|
|
5
5
|
Author-email: Augustin Bresset <augustin.bresset@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -183,13 +183,19 @@ SemanticKittiDataset.run_preprocess(
|
|
|
183
183
|
|
|
184
184
|
Requires poses to be computed first (e.g. with `KissICPOdometry`).
|
|
185
185
|
|
|
186
|
+
Lidar odometry gives the pose of the *sensor*, not of the ground under the
|
|
187
|
+
robot: pass the lidar's mounting height as `pose_height`, or every ground point
|
|
188
|
+
lies below the height window and nothing is labelled traversable.
|
|
189
|
+
|
|
186
190
|
```python
|
|
187
191
|
from apairo.dataset.goose import Goose3DDataset
|
|
188
192
|
from apairo_preprocess import KissICPOdometry, TraversabilityFromTrajectory
|
|
189
193
|
|
|
190
194
|
Goose3DDataset.run_preprocess(KissICPOdometry(voxel_size=1.0), "/data/goose/seq_001")
|
|
191
195
|
Goose3DDataset.run_preprocess(
|
|
192
|
-
TraversabilityFromTrajectory(
|
|
196
|
+
TraversabilityFromTrajectory(
|
|
197
|
+
poses_key="kissicp_poses", robot_radius=0.75, pose_height=1.2
|
|
198
|
+
),
|
|
193
199
|
"/data/goose/seq_001",
|
|
194
200
|
)
|
|
195
201
|
# writes trav_traj/ (uint8: 1=traversable, 0=not)
|