apairo-preprocess 0.2.0__tar.gz → 0.4.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.4.0}/PKG-INFO +75 -4
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/README.md +74 -3
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/__init__.py +10 -1
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/csf.py +5 -1
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/ransac.py +2 -0
- apairo_preprocess-0.4.0/apairo_preprocess/odometry/__init__.py +15 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/odometry/gicp.py +12 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/odometry/kissicp.py +15 -0
- apairo_preprocess-0.4.0/apairo_preprocess/odometry/reference.py +252 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/voxelise.py +36 -14
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/priors/ground_height.py +2 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/bev.py +25 -11
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/image_mask.py +23 -4
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/lidar_to_camera.py +30 -2
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/point_features.py +7 -3
- apairo_preprocess-0.4.0/apairo_preprocess/traversability/from_trajectory.py +319 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/PKG-INFO +75 -4
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/SOURCES.txt +3 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/pyproject.toml +1 -1
- apairo_preprocess-0.4.0/tests/test_audit_fixes.py +311 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/tests/test_image_mask_from_point_labels.py +33 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/tests/test_lidar_camera_projection.py +35 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/tests/test_point_features_from_image.py +9 -0
- apairo_preprocess-0.4.0/tests/test_reference_poses.py +288 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/tests/test_traversability_from_trajectory.py +112 -0
- apairo_preprocess-0.2.0/apairo_preprocess/odometry/__init__.py +0 -4
- apairo_preprocess-0.2.0/apairo_preprocess/traversability/from_trajectory.py +0 -181
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/LICENSE +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/from_labels.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/robot_filter.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/priors/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/priors/trajectory_distance.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/traversability/__init__.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess/traversability/from_labels.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/dependency_links.txt +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/requires.txt +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/top_level.txt +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/setup.cfg +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/tests/test_bev_rasterisation.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/tests/test_call_contract.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/tests/test_gicp_odometry.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/tests/test_kissicp_odometry.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/tests/test_projection_pipeline.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/tests/test_remove_robot_points.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.0}/tests/test_traversability_from_labels.py +0 -0
- {apairo_preprocess-0.2.0 → apairo_preprocess-0.4.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.4.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
|
|
@@ -58,13 +58,40 @@ Requires Python ≥ 3.11.
|
|
|
58
58
|
|
|
59
59
|
## Preprocessors
|
|
60
60
|
|
|
61
|
-
###
|
|
61
|
+
### Poses
|
|
62
|
+
|
|
63
|
+
Labels computed from a trajectory are only as good as the trajectory. When a
|
|
64
|
+
dataset ships its own poses (an INS, a GNSS-INS, the trajectory of its
|
|
65
|
+
release), use them: turning them into one pose per scan is interpolation and
|
|
66
|
+
composition, with fixed rules and no parameter to tune per sensor.
|
|
67
|
+
|
|
68
|
+
| Function / class | Output | Source |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `reference_poses` / `ReferencePoses` | `(N, 4, 4)` float64 poses + `(N,)` bool validity, on the scan clock | pose rows delivered with the dataset |
|
|
71
|
+
| `pose_validity` | `(N,)` bool | physical bounds: top speed, turn rate |
|
|
72
|
+
|
|
73
|
+
A scan gets a pose only when two rows bracket its time, at most `max_gap`
|
|
74
|
+
apart; nothing is extrapolated; a step faster than `max_speed` invalidates
|
|
75
|
+
both of its scans; an invalid scan's pose is NaN. The rows and the scans are on
|
|
76
|
+
two clocks, so the function is called directly and its outputs written with
|
|
77
|
+
`apairo.ChannelWriter` (see the quickstart).
|
|
78
|
+
|
|
79
|
+
Lidar odometry estimates a trajectory instead. It returns a pose even where it
|
|
80
|
+
has lost track, with no signal of it, and its drift depends on the terrain:
|
|
62
81
|
|
|
63
82
|
| Class | Output channel | Backend | Output |
|
|
64
83
|
|---|---|---|---|
|
|
65
84
|
| `KissICPOdometry` | `kissicp_poses` | [KISS-ICP](https://github.com/PRBonn/kiss-icp) | `(4, 4)` float64 pose per scan |
|
|
66
85
|
| `GICPOdometry` | `gicp_poses` | Open3D GICP | `(4, 4)` float64 pose per scan |
|
|
67
86
|
|
|
87
|
+
Measured on two TartanDrive 2.0 recordings against the vehicle's INS and the
|
|
88
|
+
release's own SLAM trajectory: the future path seen from a scan agrees within
|
|
89
|
+
5-10 cm (median) at 10 m ahead for every source, but sources drift apart with
|
|
90
|
+
the look-ahead, to 1.2-1.5 m (p95) at 40 m. In vegetation, KISS-ICP was the one
|
|
91
|
+
that drifted. Use lidar odometry for recordings without delivered poses, keep
|
|
92
|
+
`forward_distance` short (15 m), and check it against another source when one
|
|
93
|
+
exists.
|
|
94
|
+
|
|
68
95
|
### Ground segmentation
|
|
69
96
|
|
|
70
97
|
Binary ground/non-ground labels (0 = ground, 1 = non-ground). All three algorithms share the same label convention and can be compared directly.
|
|
@@ -179,9 +206,41 @@ SemanticKittiDataset.run_preprocess(
|
|
|
179
206
|
)
|
|
180
207
|
```
|
|
181
208
|
|
|
209
|
+
### Poses delivered with a dataset
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
import numpy as np
|
|
213
|
+
from apairo import ChannelWriter
|
|
214
|
+
from apairo_preprocess import ReferencePoses
|
|
215
|
+
|
|
216
|
+
rows = np.load(f"{seq}/gps_odom/odometry.npy") # [x y z qx qy qz qw, twist]
|
|
217
|
+
row_times = np.loadtxt(f"{seq}/gps_odom/timestamps.txt")
|
|
218
|
+
scan_times = np.loadtxt(f"{seq}/velodyne_0/timestamps.txt")
|
|
219
|
+
|
|
220
|
+
ins = ReferencePoses(clock_offset=-0.05, max_gap=0.1, max_speed=20.0)
|
|
221
|
+
poses, valid = ins(rows, row_times, scan_times) # relative to the first scan
|
|
222
|
+
|
|
223
|
+
with ChannelWriter(seq, "ins_poses", timestamps_from="velodyne_0", sources=["gps_odom"]) as w:
|
|
224
|
+
for i, t in enumerate(scan_times):
|
|
225
|
+
w.add(poses[i], stem=f"{i:06d}", timestamp=t)
|
|
226
|
+
with ChannelWriter(seq, "ins_valid", timestamps_from="velodyne_0", sources=["gps_odom"]) as w:
|
|
227
|
+
for i, t in enumerate(scan_times):
|
|
228
|
+
w.add(np.asarray(valid[i]), stem=f"{i:06d}", timestamp=t)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`clock_offset` is the pose clock minus the lidar's, measured once per rig;
|
|
232
|
+
`max_speed` is the robot's top speed. Pass the validity to the labeller as
|
|
233
|
+
`valid_key`: an invalid scan then cuts the look-ahead, and with
|
|
234
|
+
`unknown_label=255` its points read "unknown" rather than "not traversable".
|
|
235
|
+
|
|
182
236
|
### Traversability ground truth from trajectory
|
|
183
237
|
|
|
184
|
-
Requires poses
|
|
238
|
+
Requires poses first: delivered ones (above), or lidar odometry
|
|
239
|
+
(`KissICPOdometry`) for a recording without any.
|
|
240
|
+
|
|
241
|
+
Lidar odometry gives the pose of the *sensor*, not of the ground under the
|
|
242
|
+
robot: pass the lidar's mounting height as `pose_height`, or every ground point
|
|
243
|
+
lies below the height window and nothing is labelled traversable.
|
|
185
244
|
|
|
186
245
|
```python
|
|
187
246
|
from apairo.dataset.goose import Goose3DDataset
|
|
@@ -189,12 +248,24 @@ from apairo_preprocess import KissICPOdometry, TraversabilityFromTrajectory
|
|
|
189
248
|
|
|
190
249
|
Goose3DDataset.run_preprocess(KissICPOdometry(voxel_size=1.0), "/data/goose/seq_001")
|
|
191
250
|
Goose3DDataset.run_preprocess(
|
|
192
|
-
TraversabilityFromTrajectory(
|
|
251
|
+
TraversabilityFromTrajectory(
|
|
252
|
+
poses_key="kissicp_poses", robot_radius=0.75, pose_height=1.2
|
|
253
|
+
),
|
|
193
254
|
"/data/goose/seq_001",
|
|
194
255
|
)
|
|
195
256
|
# writes trav_traj/ (uint8: 1=traversable, 0=not)
|
|
196
257
|
```
|
|
197
258
|
|
|
259
|
+
With delivered poses and their validity:
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
TraversabilityFromTrajectory(
|
|
263
|
+
poses_key="ins_poses", valid_key="ins_valid", unknown_label=255,
|
|
264
|
+
robot_radius=0.8, pose_height=1.77, forward_distance=15.0,
|
|
265
|
+
)
|
|
266
|
+
# uint8: 1=traversable, 0=not, 255=unknown (scan without a trusted pose)
|
|
267
|
+
```
|
|
268
|
+
|
|
198
269
|
### Traversability mask in image space
|
|
199
270
|
|
|
200
271
|
Projects the trajectory ground truth into the camera: `trav_traj` labels the
|
|
@@ -26,13 +26,40 @@ Requires Python ≥ 3.11.
|
|
|
26
26
|
|
|
27
27
|
## Preprocessors
|
|
28
28
|
|
|
29
|
-
###
|
|
29
|
+
### Poses
|
|
30
|
+
|
|
31
|
+
Labels computed from a trajectory are only as good as the trajectory. When a
|
|
32
|
+
dataset ships its own poses (an INS, a GNSS-INS, the trajectory of its
|
|
33
|
+
release), use them: turning them into one pose per scan is interpolation and
|
|
34
|
+
composition, with fixed rules and no parameter to tune per sensor.
|
|
35
|
+
|
|
36
|
+
| Function / class | Output | Source |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `reference_poses` / `ReferencePoses` | `(N, 4, 4)` float64 poses + `(N,)` bool validity, on the scan clock | pose rows delivered with the dataset |
|
|
39
|
+
| `pose_validity` | `(N,)` bool | physical bounds: top speed, turn rate |
|
|
40
|
+
|
|
41
|
+
A scan gets a pose only when two rows bracket its time, at most `max_gap`
|
|
42
|
+
apart; nothing is extrapolated; a step faster than `max_speed` invalidates
|
|
43
|
+
both of its scans; an invalid scan's pose is NaN. The rows and the scans are on
|
|
44
|
+
two clocks, so the function is called directly and its outputs written with
|
|
45
|
+
`apairo.ChannelWriter` (see the quickstart).
|
|
46
|
+
|
|
47
|
+
Lidar odometry estimates a trajectory instead. It returns a pose even where it
|
|
48
|
+
has lost track, with no signal of it, and its drift depends on the terrain:
|
|
30
49
|
|
|
31
50
|
| Class | Output channel | Backend | Output |
|
|
32
51
|
|---|---|---|---|
|
|
33
52
|
| `KissICPOdometry` | `kissicp_poses` | [KISS-ICP](https://github.com/PRBonn/kiss-icp) | `(4, 4)` float64 pose per scan |
|
|
34
53
|
| `GICPOdometry` | `gicp_poses` | Open3D GICP | `(4, 4)` float64 pose per scan |
|
|
35
54
|
|
|
55
|
+
Measured on two TartanDrive 2.0 recordings against the vehicle's INS and the
|
|
56
|
+
release's own SLAM trajectory: the future path seen from a scan agrees within
|
|
57
|
+
5-10 cm (median) at 10 m ahead for every source, but sources drift apart with
|
|
58
|
+
the look-ahead, to 1.2-1.5 m (p95) at 40 m. In vegetation, KISS-ICP was the one
|
|
59
|
+
that drifted. Use lidar odometry for recordings without delivered poses, keep
|
|
60
|
+
`forward_distance` short (15 m), and check it against another source when one
|
|
61
|
+
exists.
|
|
62
|
+
|
|
36
63
|
### Ground segmentation
|
|
37
64
|
|
|
38
65
|
Binary ground/non-ground labels (0 = ground, 1 = non-ground). All three algorithms share the same label convention and can be compared directly.
|
|
@@ -147,9 +174,41 @@ SemanticKittiDataset.run_preprocess(
|
|
|
147
174
|
)
|
|
148
175
|
```
|
|
149
176
|
|
|
177
|
+
### Poses delivered with a dataset
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
import numpy as np
|
|
181
|
+
from apairo import ChannelWriter
|
|
182
|
+
from apairo_preprocess import ReferencePoses
|
|
183
|
+
|
|
184
|
+
rows = np.load(f"{seq}/gps_odom/odometry.npy") # [x y z qx qy qz qw, twist]
|
|
185
|
+
row_times = np.loadtxt(f"{seq}/gps_odom/timestamps.txt")
|
|
186
|
+
scan_times = np.loadtxt(f"{seq}/velodyne_0/timestamps.txt")
|
|
187
|
+
|
|
188
|
+
ins = ReferencePoses(clock_offset=-0.05, max_gap=0.1, max_speed=20.0)
|
|
189
|
+
poses, valid = ins(rows, row_times, scan_times) # relative to the first scan
|
|
190
|
+
|
|
191
|
+
with ChannelWriter(seq, "ins_poses", timestamps_from="velodyne_0", sources=["gps_odom"]) as w:
|
|
192
|
+
for i, t in enumerate(scan_times):
|
|
193
|
+
w.add(poses[i], stem=f"{i:06d}", timestamp=t)
|
|
194
|
+
with ChannelWriter(seq, "ins_valid", timestamps_from="velodyne_0", sources=["gps_odom"]) as w:
|
|
195
|
+
for i, t in enumerate(scan_times):
|
|
196
|
+
w.add(np.asarray(valid[i]), stem=f"{i:06d}", timestamp=t)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`clock_offset` is the pose clock minus the lidar's, measured once per rig;
|
|
200
|
+
`max_speed` is the robot's top speed. Pass the validity to the labeller as
|
|
201
|
+
`valid_key`: an invalid scan then cuts the look-ahead, and with
|
|
202
|
+
`unknown_label=255` its points read "unknown" rather than "not traversable".
|
|
203
|
+
|
|
150
204
|
### Traversability ground truth from trajectory
|
|
151
205
|
|
|
152
|
-
Requires poses
|
|
206
|
+
Requires poses first: delivered ones (above), or lidar odometry
|
|
207
|
+
(`KissICPOdometry`) for a recording without any.
|
|
208
|
+
|
|
209
|
+
Lidar odometry gives the pose of the *sensor*, not of the ground under the
|
|
210
|
+
robot: pass the lidar's mounting height as `pose_height`, or every ground point
|
|
211
|
+
lies below the height window and nothing is labelled traversable.
|
|
153
212
|
|
|
154
213
|
```python
|
|
155
214
|
from apairo.dataset.goose import Goose3DDataset
|
|
@@ -157,12 +216,24 @@ from apairo_preprocess import KissICPOdometry, TraversabilityFromTrajectory
|
|
|
157
216
|
|
|
158
217
|
Goose3DDataset.run_preprocess(KissICPOdometry(voxel_size=1.0), "/data/goose/seq_001")
|
|
159
218
|
Goose3DDataset.run_preprocess(
|
|
160
|
-
TraversabilityFromTrajectory(
|
|
219
|
+
TraversabilityFromTrajectory(
|
|
220
|
+
poses_key="kissicp_poses", robot_radius=0.75, pose_height=1.2
|
|
221
|
+
),
|
|
161
222
|
"/data/goose/seq_001",
|
|
162
223
|
)
|
|
163
224
|
# writes trav_traj/ (uint8: 1=traversable, 0=not)
|
|
164
225
|
```
|
|
165
226
|
|
|
227
|
+
With delivered poses and their validity:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
TraversabilityFromTrajectory(
|
|
231
|
+
poses_key="ins_poses", valid_key="ins_valid", unknown_label=255,
|
|
232
|
+
robot_radius=0.8, pose_height=1.77, forward_distance=15.0,
|
|
233
|
+
)
|
|
234
|
+
# uint8: 1=traversable, 0=not, 255=unknown (scan without a trusted pose)
|
|
235
|
+
```
|
|
236
|
+
|
|
166
237
|
### Traversability mask in image space
|
|
167
238
|
|
|
168
239
|
Projects the trajectory ground truth into the camera: `trav_traj` labels the
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
from apairo_preprocess.traversability import TraversabilityFromLabels, TraversabilityFromTrajectory
|
|
2
2
|
from apairo_preprocess.ground import GroundSegmentationCSF, GroundSegmentationFromLabels, GroundSegmentationRANSAC
|
|
3
3
|
from apairo_preprocess.priors import GroundHeightFromLabels, TrajectoryDistance
|
|
4
|
-
from apairo_preprocess.odometry import
|
|
4
|
+
from apairo_preprocess.odometry import (
|
|
5
|
+
GICPOdometry,
|
|
6
|
+
KissICPOdometry,
|
|
7
|
+
ReferencePoses,
|
|
8
|
+
pose_validity,
|
|
9
|
+
reference_poses,
|
|
10
|
+
)
|
|
5
11
|
from apairo_preprocess.pointcloud import VoxeliseCoords, VoxeliseLabels, VoxelisePointCloud, RemoveRobotPoints
|
|
6
12
|
from apairo_preprocess.projection import BEVRasterisation, ImageMaskFromPointLabels, LidarCameraProjection, PointFeaturesFromImage, to_uint8_image
|
|
7
13
|
|
|
@@ -15,6 +21,9 @@ __all__ = [
|
|
|
15
21
|
"TrajectoryDistance",
|
|
16
22
|
"GICPOdometry",
|
|
17
23
|
"KissICPOdometry",
|
|
24
|
+
"ReferencePoses",
|
|
25
|
+
"pose_validity",
|
|
26
|
+
"reference_poses",
|
|
18
27
|
"VoxelisePointCloud",
|
|
19
28
|
"VoxeliseLabels",
|
|
20
29
|
"VoxeliseCoords",
|
|
@@ -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):
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from apairo_preprocess.odometry.gicp import GICPOdometry
|
|
2
|
+
from apairo_preprocess.odometry.kissicp import KissICPOdometry
|
|
3
|
+
from apairo_preprocess.odometry.reference import (
|
|
4
|
+
ReferencePoses,
|
|
5
|
+
pose_validity,
|
|
6
|
+
reference_poses,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"GICPOdometry",
|
|
11
|
+
"KissICPOdometry",
|
|
12
|
+
"ReferencePoses",
|
|
13
|
+
"pose_validity",
|
|
14
|
+
"reference_poses",
|
|
15
|
+
]
|
|
@@ -8,6 +8,10 @@ sessions) the accumulated drift will jump, but for single-session
|
|
|
8
8
|
splits the poses are consistent.
|
|
9
9
|
|
|
10
10
|
Output channel: ``gicp_poses`` (npys — one .npy per scan)
|
|
11
|
+
|
|
12
|
+
This is an estimate: it returns a pose even where registration has lost
|
|
13
|
+
track, with no signal of it. When the dataset ships its own poses, prefer
|
|
14
|
+
:func:`~apairo_preprocess.reference_poses`.
|
|
11
15
|
"""
|
|
12
16
|
|
|
13
17
|
from __future__ import annotations
|
|
@@ -68,6 +72,14 @@ class GICPOdometry(FramePreprocessor):
|
|
|
68
72
|
self._prev_pcd = None
|
|
69
73
|
self._T_accum = np.eye(4, dtype=np.float64)
|
|
70
74
|
|
|
75
|
+
def start_sequence(self) -> None:
|
|
76
|
+
"""Forget the previous scan and the pose: the next scan is a new world
|
|
77
|
+
origin. Called by apairo's runner (>= 0.11) at the start of each
|
|
78
|
+
sequence of a root; without it a sequence continued the previous one's
|
|
79
|
+
pose and registered its first scan against an unrelated cloud."""
|
|
80
|
+
self._prev_pcd = None
|
|
81
|
+
self._T_accum = np.eye(4, dtype=np.float64)
|
|
82
|
+
|
|
71
83
|
def __call__(self, sample: Sample) -> np.ndarray:
|
|
72
84
|
pc = np.asarray(sample.data[self._lidar_key])
|
|
73
85
|
xyz = pc[:, :3].astype(np.float64)
|
|
@@ -6,6 +6,11 @@ and handles motion deskewing natively when per-point timestamps are available.
|
|
|
6
6
|
|
|
7
7
|
Output channel: ``kissicp_poses`` (npys — one .npy per scan)
|
|
8
8
|
|
|
9
|
+
This is an estimate: it returns a pose even where registration has lost
|
|
10
|
+
track, with no signal of it, and its drift depends on the terrain (on
|
|
11
|
+
TartanDrive 2.0, it drifted in vegetation where the INS did not). When the
|
|
12
|
+
dataset ships its own poses, prefer :func:`~apairo_preprocess.reference_poses`.
|
|
13
|
+
|
|
9
14
|
Typical usage::
|
|
10
15
|
|
|
11
16
|
dataset.run_preprocess(
|
|
@@ -73,9 +78,19 @@ class KissICPOdometry(FramePreprocessor):
|
|
|
73
78
|
cfg.data.min_range = min_range
|
|
74
79
|
cfg.data.deskew = deskew
|
|
75
80
|
cfg.mapping.voxel_size = float(voxel_size)
|
|
81
|
+
self._config = cfg
|
|
76
82
|
self._kiss = KissICP(config=cfg)
|
|
77
83
|
self._deskew = deskew
|
|
78
84
|
|
|
85
|
+
def start_sequence(self) -> None:
|
|
86
|
+
"""Forget the map and the pose: the next scan is a new world origin.
|
|
87
|
+
|
|
88
|
+
Called by apairo's runner (>= 0.11) at the start of each sequence of a
|
|
89
|
+
root. Without it the first scan of a sequence was registered against
|
|
90
|
+
the previous sequence's map and continued its pose.
|
|
91
|
+
"""
|
|
92
|
+
self._kiss = KissICP(config=self._config)
|
|
93
|
+
|
|
79
94
|
def __call__(self, sample: Sample) -> np.ndarray:
|
|
80
95
|
pc = np.asarray(sample.data[self._lidar_key], dtype=np.float64)
|
|
81
96
|
xyz = pc[:, :3]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Poses delivered with a dataset, on the lidar clock.
|
|
2
|
+
|
|
3
|
+
An INS, a GNSS-INS, or the trajectory a dataset ships with is a recorded
|
|
4
|
+
measurement: turning it into one ``(4, 4)`` pose per scan is interpolation and
|
|
5
|
+
composition, with no parameter that changes from sensor to sensor and no way
|
|
6
|
+
to diverge. That is the point of this module, as opposed to
|
|
7
|
+
:class:`~apairo_preprocess.KissICPOdometry` and
|
|
8
|
+
:class:`~apairo_preprocess.GICPOdometry`, which *estimate* a trajectory and
|
|
9
|
+
return a pose even where they have lost track.
|
|
10
|
+
|
|
11
|
+
The rules are fixed and checkable by hand:
|
|
12
|
+
|
|
13
|
+
- a scan gets a pose only when two pose rows bracket its time, at most
|
|
14
|
+
``max_gap`` seconds apart; nothing is ever extrapolated;
|
|
15
|
+
- a step between two consecutive scans faster than ``max_speed`` (or turning
|
|
16
|
+
faster than ``max_yaw_rate``) marks both of its scans invalid, since which
|
|
17
|
+
one is wrong cannot be told;
|
|
18
|
+
- an invalid scan's pose is NaN, never a pose made up from its neighbours.
|
|
19
|
+
|
|
20
|
+
The pose rows and the scans are on two clocks, which apairo's runner does not
|
|
21
|
+
pair for a preprocessor yet: :func:`reference_poses` is therefore called
|
|
22
|
+
directly, and its two outputs are written as channels with
|
|
23
|
+
``apairo.ChannelWriter``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from apairo_transform import PoseTo4x4
|
|
30
|
+
from apairo_transform.interp import Se3Interp
|
|
31
|
+
|
|
32
|
+
__all__ = ["ReferencePoses", "pose_validity", "reference_poses"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _increasing(rows: np.ndarray, times: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
36
|
+
"""Drop rows that repeat the previous timestamp (keeping the first); refuse
|
|
37
|
+
a clock that goes backwards."""
|
|
38
|
+
step = np.diff(times)
|
|
39
|
+
if np.any(step < 0):
|
|
40
|
+
k = int(np.flatnonzero(step < 0)[0])
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"pose timestamps go backwards at row {k + 1} "
|
|
43
|
+
f"({times[k + 1]!r} after {times[k]!r}); sort them first."
|
|
44
|
+
)
|
|
45
|
+
keep = np.concatenate([[True], step > 0])
|
|
46
|
+
return rows[keep], times[keep]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _yaw(poses: np.ndarray) -> np.ndarray:
|
|
50
|
+
return np.arctan2(poses[:, 1, 0], poses[:, 0, 0])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pose_validity(
|
|
54
|
+
poses: np.ndarray,
|
|
55
|
+
times: np.ndarray,
|
|
56
|
+
valid: np.ndarray | None = None,
|
|
57
|
+
*,
|
|
58
|
+
max_speed: float,
|
|
59
|
+
max_yaw_rate: float | None = None,
|
|
60
|
+
) -> np.ndarray:
|
|
61
|
+
"""``(N,)`` bool: the scans whose pose passes the physical bounds.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
poses: ``(N, 4, 4)`` poses, one per scan, NaN where there is none.
|
|
65
|
+
times: ``(N,)`` scan times, seconds.
|
|
66
|
+
valid: Scans already known valid (default: those with a finite pose).
|
|
67
|
+
max_speed: The robot's top speed, m/s. No default: it is a property of
|
|
68
|
+
the robot, not of the data.
|
|
69
|
+
max_yaw_rate: Optional bound on the turn rate, rad/s.
|
|
70
|
+
|
|
71
|
+
Consecutive *valid* scans are compared. A step that exceeds a bound marks
|
|
72
|
+
both of its scans invalid. A step with no elapsed time (a scan repeated
|
|
73
|
+
under the same stamp, as some recordings do) has no speed and is not
|
|
74
|
+
checked; a time going backwards is an error.
|
|
75
|
+
"""
|
|
76
|
+
poses = np.asarray(poses, dtype=np.float64)
|
|
77
|
+
times = np.asarray(times, dtype=np.float64)
|
|
78
|
+
if max_speed <= 0:
|
|
79
|
+
raise ValueError(f"max_speed must be positive, got {max_speed}")
|
|
80
|
+
finite = np.all(np.isfinite(poses.reshape(len(poses), -1)), axis=1)
|
|
81
|
+
out = finite if valid is None else (np.asarray(valid, dtype=bool) & finite)
|
|
82
|
+
idx = np.flatnonzero(out)
|
|
83
|
+
if len(idx) < 2:
|
|
84
|
+
return out
|
|
85
|
+
dt = np.diff(times[idx])
|
|
86
|
+
if np.any(dt < 0):
|
|
87
|
+
raise ValueError("scan times go backwards; sort them first")
|
|
88
|
+
timed = dt > 0
|
|
89
|
+
safe = np.where(timed, dt, 1.0)
|
|
90
|
+
dist = np.linalg.norm(np.diff(poses[idx, :3, 3], axis=0), axis=1)
|
|
91
|
+
bad = timed & (dist / safe > max_speed)
|
|
92
|
+
if max_yaw_rate is not None:
|
|
93
|
+
dyaw = np.abs(np.angle(np.exp(1j * np.diff(_yaw(poses[idx])))))
|
|
94
|
+
bad |= timed & (dyaw / safe > max_yaw_rate)
|
|
95
|
+
out = out.copy()
|
|
96
|
+
out[idx[:-1][bad]] = False
|
|
97
|
+
out[idx[1:][bad]] = False
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def reference_poses(
|
|
102
|
+
rows: np.ndarray,
|
|
103
|
+
row_times: np.ndarray,
|
|
104
|
+
scan_times: np.ndarray,
|
|
105
|
+
*,
|
|
106
|
+
clock_offset: float = 0.0,
|
|
107
|
+
extrinsic: np.ndarray | None = None,
|
|
108
|
+
max_gap: float = 0.1,
|
|
109
|
+
max_speed: float | None = None,
|
|
110
|
+
max_yaw_rate: float | None = None,
|
|
111
|
+
relative: bool = True,
|
|
112
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
113
|
+
"""One pose per scan from delivered pose rows.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
rows: ``(M, >=7)`` rows ``[x, y, z, qx, qy, qz, qw, ...]``: the body's
|
|
117
|
+
pose in the world frame. Extra columns (a twist) are ignored.
|
|
118
|
+
row_times: ``(M,)`` row times, seconds. A row repeating the previous
|
|
119
|
+
time is dropped; a time going backwards is an error.
|
|
120
|
+
scan_times: ``(N,)`` scan times, seconds.
|
|
121
|
+
clock_offset: Added to the scan times before looking the poses up:
|
|
122
|
+
the pose source's clock minus the lidar's, as measured once for
|
|
123
|
+
the rig.
|
|
124
|
+
extrinsic: ``(4, 4)`` pose of the sensor in the body frame, composed on
|
|
125
|
+
the right (``T_world_sensor = T_world_body @ extrinsic``). ``None``
|
|
126
|
+
when the sensor is the body frame.
|
|
127
|
+
max_gap: Largest time between the two rows bracketing a scan, seconds.
|
|
128
|
+
max_speed: If given, :func:`pose_validity` is applied with it.
|
|
129
|
+
max_yaw_rate: Passed to :func:`pose_validity`.
|
|
130
|
+
relative: Express every pose relative to the first valid one, in
|
|
131
|
+
float64. Positions recorded in a global frame (UTM: ~1e6 m) keep
|
|
132
|
+
their millimetres only this way once a consumer casts to float32.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
``(poses, valid)``: ``(N, 4, 4)`` float64, NaN where ``valid`` is False,
|
|
136
|
+
and ``(N,)`` bool.
|
|
137
|
+
"""
|
|
138
|
+
rows = np.asarray(rows, dtype=np.float64)
|
|
139
|
+
row_times = np.asarray(row_times, dtype=np.float64)
|
|
140
|
+
scan_times = np.asarray(scan_times, dtype=np.float64)
|
|
141
|
+
if rows.ndim != 2 or rows.shape[1] < 7:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"rows must be (M, >=7) [x y z qx qy qz qw ...], got {rows.shape}"
|
|
144
|
+
)
|
|
145
|
+
if len(rows) != len(row_times):
|
|
146
|
+
raise ValueError(f"{len(rows)} rows but {len(row_times)} row times")
|
|
147
|
+
if max_gap <= 0:
|
|
148
|
+
raise ValueError(f"max_gap must be positive, got {max_gap}")
|
|
149
|
+
rows, row_times = _increasing(rows[:, :7], row_times)
|
|
150
|
+
|
|
151
|
+
n = len(scan_times)
|
|
152
|
+
poses = np.full((n, 4, 4), np.nan)
|
|
153
|
+
valid = np.zeros(n, dtype=bool)
|
|
154
|
+
if len(rows) == 0 or n == 0:
|
|
155
|
+
return poses, valid
|
|
156
|
+
|
|
157
|
+
tq = scan_times + clock_offset
|
|
158
|
+
hi = np.searchsorted(row_times, tq, side="left")
|
|
159
|
+
exact = (hi < len(row_times)) & (
|
|
160
|
+
row_times[np.minimum(hi, len(row_times) - 1)] == tq
|
|
161
|
+
)
|
|
162
|
+
interp = Se3Interp()
|
|
163
|
+
to4 = PoseTo4x4()
|
|
164
|
+
for i in range(n):
|
|
165
|
+
if exact[i]:
|
|
166
|
+
pose7 = rows[hi[i]]
|
|
167
|
+
else:
|
|
168
|
+
j = int(hi[i])
|
|
169
|
+
if j == 0 or j == len(row_times):
|
|
170
|
+
continue # outside the recorded span: no extrapolation
|
|
171
|
+
t0, t1 = row_times[j - 1], row_times[j]
|
|
172
|
+
if t1 - t0 > max_gap:
|
|
173
|
+
continue
|
|
174
|
+
pose7 = interp(tq[i], t0, rows[j - 1], t1, rows[j])
|
|
175
|
+
poses[i] = to4(pose7)
|
|
176
|
+
valid[i] = True
|
|
177
|
+
|
|
178
|
+
if extrinsic is not None:
|
|
179
|
+
extrinsic = np.asarray(extrinsic, dtype=np.float64)
|
|
180
|
+
if extrinsic.shape != (4, 4):
|
|
181
|
+
raise ValueError(f"extrinsic must be (4, 4), got {extrinsic.shape}")
|
|
182
|
+
poses[valid] = poses[valid] @ extrinsic
|
|
183
|
+
if max_speed is not None:
|
|
184
|
+
valid = pose_validity(
|
|
185
|
+
poses, scan_times, valid, max_speed=max_speed, max_yaw_rate=max_yaw_rate
|
|
186
|
+
)
|
|
187
|
+
poses[~valid] = np.nan
|
|
188
|
+
if relative and valid.any():
|
|
189
|
+
first = poses[int(np.flatnonzero(valid)[0])]
|
|
190
|
+
poses[valid] = np.linalg.inv(first) @ poses[valid]
|
|
191
|
+
return poses, valid
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ReferencePoses:
|
|
195
|
+
""":func:`reference_poses` with its parameters fixed once for a rig.
|
|
196
|
+
|
|
197
|
+
Example::
|
|
198
|
+
|
|
199
|
+
ins = ReferencePoses(clock_offset=-0.05, max_gap=0.1, max_speed=20.0)
|
|
200
|
+
poses, valid = ins(rows, row_times, scan_times)
|
|
201
|
+
# then write both with apairo.ChannelWriter, on the lidar clock
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
*,
|
|
207
|
+
clock_offset: float = 0.0,
|
|
208
|
+
extrinsic: np.ndarray | None = None,
|
|
209
|
+
max_gap: float = 0.1,
|
|
210
|
+
max_speed: float | None = None,
|
|
211
|
+
max_yaw_rate: float | None = None,
|
|
212
|
+
relative: bool = True,
|
|
213
|
+
) -> None:
|
|
214
|
+
self.clock_offset = float(clock_offset)
|
|
215
|
+
self.extrinsic = (
|
|
216
|
+
None if extrinsic is None else np.asarray(extrinsic, dtype=np.float64)
|
|
217
|
+
)
|
|
218
|
+
self.max_gap = float(max_gap)
|
|
219
|
+
self.max_speed = max_speed
|
|
220
|
+
self.max_yaw_rate = max_yaw_rate
|
|
221
|
+
self.relative = relative
|
|
222
|
+
|
|
223
|
+
def __call__(
|
|
224
|
+
self, rows: np.ndarray, row_times: np.ndarray, scan_times: np.ndarray
|
|
225
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
226
|
+
return reference_poses(
|
|
227
|
+
rows,
|
|
228
|
+
row_times,
|
|
229
|
+
scan_times,
|
|
230
|
+
clock_offset=self.clock_offset,
|
|
231
|
+
extrinsic=self.extrinsic,
|
|
232
|
+
max_gap=self.max_gap,
|
|
233
|
+
max_speed=self.max_speed,
|
|
234
|
+
max_yaw_rate=self.max_yaw_rate,
|
|
235
|
+
relative=self.relative,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def get_config(self) -> dict:
|
|
239
|
+
return {
|
|
240
|
+
"clock_offset": self.clock_offset,
|
|
241
|
+
"extrinsic": None if self.extrinsic is None else self.extrinsic.tolist(),
|
|
242
|
+
"max_gap": self.max_gap,
|
|
243
|
+
"max_speed": self.max_speed,
|
|
244
|
+
"max_yaw_rate": self.max_yaw_rate,
|
|
245
|
+
"relative": self.relative,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def __repr__(self) -> str:
|
|
249
|
+
return (
|
|
250
|
+
f"ReferencePoses(clock_offset={self.clock_offset}, max_gap={self.max_gap}, "
|
|
251
|
+
f"max_speed={self.max_speed})"
|
|
252
|
+
)
|