apairo-preprocess 0.3.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.3.0 → apairo_preprocess-0.4.0}/PKG-INFO +68 -3
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/README.md +67 -2
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/__init__.py +10 -1
- apairo_preprocess-0.4.0/apairo_preprocess/odometry/__init__.py +15 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/odometry/gicp.py +4 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/odometry/kissicp.py +5 -0
- apairo_preprocess-0.4.0/apairo_preprocess/odometry/reference.py +252 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/traversability/from_trajectory.py +46 -5
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/PKG-INFO +68 -3
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/SOURCES.txt +2 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/pyproject.toml +1 -1
- apairo_preprocess-0.4.0/tests/test_reference_poses.py +288 -0
- apairo_preprocess-0.3.0/apairo_preprocess/odometry/__init__.py +0 -4
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/LICENSE +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/__init__.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/csf.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/from_labels.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/ransac.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/__init__.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/robot_filter.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/voxelise.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/priors/__init__.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/priors/ground_height.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/priors/trajectory_distance.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/__init__.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/bev.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/image_mask.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/lidar_to_camera.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/point_features.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/traversability/__init__.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/traversability/from_labels.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/dependency_links.txt +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/requires.txt +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/top_level.txt +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/setup.cfg +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_audit_fixes.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_bev_rasterisation.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_call_contract.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_gicp_odometry.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_image_mask_from_point_labels.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_kissicp_odometry.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_lidar_camera_projection.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_point_features_from_image.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_projection_pipeline.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_remove_robot_points.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_traversability_from_labels.py +0 -0
- {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_traversability_from_trajectory.py +0 -0
- {apairo_preprocess-0.3.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,37 @@ 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.
|
|
185
240
|
|
|
186
241
|
Lidar odometry gives the pose of the *sensor*, not of the ground under the
|
|
187
242
|
robot: pass the lidar's mounting height as `pose_height`, or every ground point
|
|
@@ -201,6 +256,16 @@ Goose3DDataset.run_preprocess(
|
|
|
201
256
|
# writes trav_traj/ (uint8: 1=traversable, 0=not)
|
|
202
257
|
```
|
|
203
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
|
+
|
|
204
269
|
### Traversability mask in image space
|
|
205
270
|
|
|
206
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,37 @@ 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.
|
|
153
208
|
|
|
154
209
|
Lidar odometry gives the pose of the *sensor*, not of the ground under the
|
|
155
210
|
robot: pass the lidar's mounting height as `pose_height`, or every ground point
|
|
@@ -169,6 +224,16 @@ Goose3DDataset.run_preprocess(
|
|
|
169
224
|
# writes trav_traj/ (uint8: 1=traversable, 0=not)
|
|
170
225
|
```
|
|
171
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
|
+
|
|
172
237
|
### Traversability mask in image space
|
|
173
238
|
|
|
174
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",
|
|
@@ -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
|
|
@@ -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(
|
|
@@ -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
|
+
)
|
|
@@ -106,6 +106,16 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
106
106
|
``np.inf``. A callable ``(N, 3) -> (N,)`` is
|
|
107
107
|
accepted too.
|
|
108
108
|
output_key: Override the default output channel name ``"trav_traj"``.
|
|
109
|
+
valid_key: Channel holding one bool per scan: whether its pose
|
|
110
|
+
can be trusted (e.g. ``reference_poses``' second
|
|
111
|
+
output). An invalid scan cuts the trajectory as
|
|
112
|
+
``sequence_gap`` does -- no look-ahead across it --
|
|
113
|
+
and none of its points is traversable. Without it,
|
|
114
|
+
a non-finite pose is an error.
|
|
115
|
+
unknown_label: Label of every point of an invalid scan, e.g. 255
|
|
116
|
+
for "unknown". ``None`` (default) labels them 0,
|
|
117
|
+
which a consumer cannot tell from "not
|
|
118
|
+
traversable".
|
|
109
119
|
"""
|
|
110
120
|
|
|
111
121
|
output_key: ClassVar[str] = "trav_traj"
|
|
@@ -129,6 +139,8 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
129
139
|
near_exclusion_norm: float = np.inf,
|
|
130
140
|
output_key: str | None = None,
|
|
131
141
|
pose_height: float = 0.0,
|
|
142
|
+
valid_key: str | None = None,
|
|
143
|
+
unknown_label: int | None = None,
|
|
132
144
|
) -> None:
|
|
133
145
|
if (robot_length is None) != (robot_width is None):
|
|
134
146
|
raise ValueError("robot_length and robot_width must be given together")
|
|
@@ -161,8 +173,15 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
161
173
|
else None
|
|
162
174
|
)
|
|
163
175
|
|
|
164
|
-
|
|
165
|
-
|
|
176
|
+
if unknown_label is not None and not 0 <= int(unknown_label) <= 255:
|
|
177
|
+
raise ValueError(f"unknown_label must fit in uint8, got {unknown_label}")
|
|
178
|
+
if unknown_label is not None and int(unknown_label) in (0, 1):
|
|
179
|
+
raise ValueError("unknown_label must differ from the labels 0 and 1")
|
|
180
|
+
self._valid_key = valid_key
|
|
181
|
+
self._unknown = None if unknown_label is None else int(unknown_label)
|
|
182
|
+
keys = [lidar_key, poses_key] + ([valid_key] if valid_key is not None else [])
|
|
183
|
+
self.input_keys = keys
|
|
184
|
+
self.sources = list(keys)
|
|
166
185
|
if output_key is not None:
|
|
167
186
|
self.output_key = output_key
|
|
168
187
|
|
|
@@ -181,15 +200,33 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
181
200
|
f"Got {poses.shape}"
|
|
182
201
|
)
|
|
183
202
|
|
|
203
|
+
pose_finite = np.all(np.isfinite(poses.reshape(n, -1)), axis=1)
|
|
204
|
+
if self._valid_key is None:
|
|
205
|
+
if not pose_finite.all():
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"{int((~pose_finite).sum())} pose(s) are not finite. Pass the "
|
|
208
|
+
f"validity of each pose as valid_key (reference_poses gives "
|
|
209
|
+
f"it), so that those scans are left out."
|
|
210
|
+
)
|
|
211
|
+
valid = pose_finite
|
|
212
|
+
else:
|
|
213
|
+
valid = np.array(
|
|
214
|
+
[bool(np.asarray(s.data[self._valid_key])) for s in all_samples]
|
|
215
|
+
) & pose_finite
|
|
216
|
+
|
|
184
217
|
# Sequence boundary detection — done here where poses are available.
|
|
218
|
+
# A step is broken by a gap larger than sequence_gap or by an invalid
|
|
219
|
+
# scan at either end; NaN distances (invalid poses) are broken too.
|
|
185
220
|
positions = poses[:, :3, 3]
|
|
186
221
|
dists = np.linalg.norm(np.diff(positions, axis=0), axis=1)
|
|
187
|
-
|
|
222
|
+
broken = ~(dists <= self._sequence_gap) | ~valid[:-1] | ~valid[1:]
|
|
223
|
+
boundaries = np.where(broken)[0]
|
|
188
224
|
ends = np.concatenate([boundaries, [n - 1]])
|
|
189
225
|
seq_end = ends[np.searchsorted(ends, np.arange(n))]
|
|
190
226
|
|
|
191
|
-
# Cumulative path length, for the forward_distance cap.
|
|
192
|
-
|
|
227
|
+
# Cumulative path length, for the forward_distance cap. A broken step
|
|
228
|
+
# adds nothing: no look-ahead crosses it.
|
|
229
|
+
path = np.concatenate([[0.0], np.cumsum(np.where(broken, 0.0, dists))])
|
|
193
230
|
|
|
194
231
|
if self._oriented:
|
|
195
232
|
# Yaw from each pose's own rotation matrix -- robust where the robot is
|
|
@@ -202,6 +239,10 @@ class TraversabilityFromTrajectory(SequencePreprocessor):
|
|
|
202
239
|
results = []
|
|
203
240
|
for idx, sample in enumerate(all_samples):
|
|
204
241
|
pc = np.asarray(sample.data[self._lidar_key])
|
|
242
|
+
if not valid[idx]:
|
|
243
|
+
fill = 0 if self._unknown is None else self._unknown
|
|
244
|
+
results.append(np.full(len(pc), fill, dtype=np.uint8))
|
|
245
|
+
continue
|
|
205
246
|
xyz_all = pc[:, :3].astype(np.float64)
|
|
206
247
|
# A return without a position (NaN or inf) is never traversable; it
|
|
207
248
|
# used to abort the whole sequence in the nearest-pose search.
|
|
@@ -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,37 @@ 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.
|
|
185
240
|
|
|
186
241
|
Lidar odometry gives the pose of the *sensor*, not of the ground under the
|
|
187
242
|
robot: pass the lidar's mounting height as `pose_height`, or every ground point
|
|
@@ -201,6 +256,16 @@ Goose3DDataset.run_preprocess(
|
|
|
201
256
|
# writes trav_traj/ (uint8: 1=traversable, 0=not)
|
|
202
257
|
```
|
|
203
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
|
+
|
|
204
269
|
### Traversability mask in image space
|
|
205
270
|
|
|
206
271
|
Projects the trajectory ground truth into the camera: `trav_traj` labels the
|
|
@@ -14,6 +14,7 @@ apairo_preprocess/ground/ransac.py
|
|
|
14
14
|
apairo_preprocess/odometry/__init__.py
|
|
15
15
|
apairo_preprocess/odometry/gicp.py
|
|
16
16
|
apairo_preprocess/odometry/kissicp.py
|
|
17
|
+
apairo_preprocess/odometry/reference.py
|
|
17
18
|
apairo_preprocess/pointcloud/__init__.py
|
|
18
19
|
apairo_preprocess/pointcloud/robot_filter.py
|
|
19
20
|
apairo_preprocess/pointcloud/voxelise.py
|
|
@@ -37,6 +38,7 @@ tests/test_kissicp_odometry.py
|
|
|
37
38
|
tests/test_lidar_camera_projection.py
|
|
38
39
|
tests/test_point_features_from_image.py
|
|
39
40
|
tests/test_projection_pipeline.py
|
|
41
|
+
tests/test_reference_poses.py
|
|
40
42
|
tests/test_remove_robot_points.py
|
|
41
43
|
tests/test_traversability_from_labels.py
|
|
42
44
|
tests/test_traversability_from_trajectory.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "apairo-preprocess"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Preprocessing for apairo datasets: odometry, ground segmentation, projection, traversability labels"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Delivered poses on the lidar clock: interpolation, validity, labelling.
|
|
2
|
+
|
|
3
|
+
Each test states the physical expectation first.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pytest
|
|
8
|
+
from apairo.core.sample import Sample
|
|
9
|
+
from scipy.spatial.transform import Rotation
|
|
10
|
+
|
|
11
|
+
from apairo_preprocess import (
|
|
12
|
+
ReferencePoses,
|
|
13
|
+
TraversabilityFromTrajectory,
|
|
14
|
+
pose_validity,
|
|
15
|
+
reference_poses,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _rows(t, x, y, z, yaw):
|
|
20
|
+
"""Rows [x y z qx qy qz qw] of a body yawing in the plane."""
|
|
21
|
+
q = Rotation.from_euler("z", np.asarray(yaw, dtype=float).reshape(-1, 1)).as_quat()
|
|
22
|
+
return np.column_stack([x, y, z, q])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _true_pose(x, y, z, yaw):
|
|
26
|
+
T = np.eye(4)
|
|
27
|
+
T[:3, :3] = Rotation.from_euler("z", yaw).as_matrix()
|
|
28
|
+
T[:3, 3] = [x, y, z]
|
|
29
|
+
return T
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------ interpolation
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_a_straight_line_is_interpolated_exactly():
|
|
36
|
+
# 5 m/s along x, rows at 50 Hz, scans at 10 Hz in between the rows.
|
|
37
|
+
t = np.arange(0, 2.0001, 0.02)
|
|
38
|
+
rows = _rows(t, 5 * t, 0 * t, 0 * t, 0 * t)
|
|
39
|
+
scans = np.arange(0.005, 1.9, 0.1)
|
|
40
|
+
poses, valid = reference_poses(rows, t, scans, relative=False)
|
|
41
|
+
assert valid.all()
|
|
42
|
+
np.testing.assert_allclose(poses[:, 0, 3], 5 * scans, atol=1e-9)
|
|
43
|
+
np.testing.assert_allclose(
|
|
44
|
+
poses[:, :3, :3], np.tile(np.eye(3), (len(scans), 1, 1)), atol=1e-12
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_a_turn_is_interpolated_on_position_and_heading():
|
|
49
|
+
# Constant yaw rate: both position (lerp between close rows) and heading
|
|
50
|
+
# (slerp) match the truth at mid-row times.
|
|
51
|
+
t = np.arange(0, 2.0001, 0.02)
|
|
52
|
+
yaw = 0.5 * t
|
|
53
|
+
rows = _rows(t, np.cos(yaw), np.sin(yaw), 0 * t, yaw)
|
|
54
|
+
scans = np.array([0.31, 0.97, 1.53])
|
|
55
|
+
poses, valid = reference_poses(rows, t, scans, relative=False)
|
|
56
|
+
assert valid.all()
|
|
57
|
+
got_yaw = np.arctan2(poses[:, 1, 0], poses[:, 0, 0])
|
|
58
|
+
np.testing.assert_allclose(got_yaw, 0.5 * scans, atol=1e-9)
|
|
59
|
+
# lerp of a circle's chord: error below r * (dtheta)^2 / 8 = 1.25e-5 m
|
|
60
|
+
np.testing.assert_allclose(poses[:, 0, 3], np.cos(0.5 * scans), atol=2e-5)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_the_clock_offset_shifts_the_lookup():
|
|
64
|
+
# The pose clock runs 0.05 s behind the lidar's: the pose of a scan at t
|
|
65
|
+
# is the row at t - 0.05.
|
|
66
|
+
t = np.arange(0, 2.0001, 0.02)
|
|
67
|
+
rows = _rows(t, 5 * t, 0 * t, 0 * t, 0 * t)
|
|
68
|
+
scans = np.array([0.5, 1.0])
|
|
69
|
+
poses, _ = reference_poses(rows, t, scans, clock_offset=-0.05, relative=False)
|
|
70
|
+
np.testing.assert_allclose(poses[:, 0, 3], 5 * (scans - 0.05), atol=1e-9)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_utm_scale_positions_keep_their_millimetres_relative_to_the_first():
|
|
74
|
+
# Northing 4.48e6 m: relative to the first scan, in float64, the 1 mm
|
|
75
|
+
# steps survive; a float32 cast of the absolute value would not.
|
|
76
|
+
t = np.arange(0, 1.0001, 0.02)
|
|
77
|
+
x = 4.48e6 + 0.001 * np.arange(len(t))
|
|
78
|
+
rows = _rows(t, x, -6.03e5 + 0 * t, 300 + 0 * t, 0 * t)
|
|
79
|
+
poses, valid = reference_poses(rows, t, t[:10])
|
|
80
|
+
assert valid.all()
|
|
81
|
+
np.testing.assert_allclose(poses[:, 0, 3], 0.001 * np.arange(10), atol=1e-7)
|
|
82
|
+
np.testing.assert_allclose(poses[0], np.eye(4), atol=1e-12)
|
|
83
|
+
assert np.ptp(poses[:, 0, 3].astype(np.float32)) > 0.008
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_an_extrinsic_is_composed_on_the_right():
|
|
87
|
+
# Body at (10, 0, 0) facing +y; sensor 1 m ahead of the body, 2 m up:
|
|
88
|
+
# in the world it sits at (10, 1, 2), facing +y.
|
|
89
|
+
t = np.array([0.0, 0.1])
|
|
90
|
+
rows = _rows(
|
|
91
|
+
t, np.array([10.0, 10.0]), np.zeros(2), np.zeros(2), np.full(2, np.pi / 2)
|
|
92
|
+
)
|
|
93
|
+
sensor = np.eye(4)
|
|
94
|
+
sensor[:3, 3] = [1.0, 0.0, 2.0]
|
|
95
|
+
poses, _ = reference_poses(
|
|
96
|
+
rows, t, np.array([0.05]), extrinsic=sensor, relative=False
|
|
97
|
+
)
|
|
98
|
+
np.testing.assert_allclose(poses[0, :3, 3], [10.0, 1.0, 2.0], atol=1e-12)
|
|
99
|
+
np.testing.assert_allclose(
|
|
100
|
+
poses[0, :3, :3], Rotation.from_euler("z", np.pi / 2).as_matrix(), atol=1e-12
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------- validity
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_scans_inside_a_hole_in_the_rows_have_no_pose():
|
|
108
|
+
# Rows stop for 0.5 s: the scans inside are invalid, not interpolated
|
|
109
|
+
# across the hole; their pose is NaN.
|
|
110
|
+
t = np.concatenate([np.arange(0, 1.0, 0.02), np.arange(1.5, 2.5, 0.02)])
|
|
111
|
+
rows = _rows(t, 5 * t, 0 * t, 0 * t, 0 * t)
|
|
112
|
+
scans = np.array([0.5, 1.2, 1.3, 2.0])
|
|
113
|
+
poses, valid = reference_poses(rows, t, scans, max_gap=0.1)
|
|
114
|
+
assert valid.tolist() == [True, False, False, True]
|
|
115
|
+
assert np.isnan(poses[1:3]).all() and np.isfinite(poses[[0, 3]]).all()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_nothing_is_extrapolated_outside_the_recorded_span():
|
|
119
|
+
t = np.arange(1.0, 2.0001, 0.02)
|
|
120
|
+
rows = _rows(t, t, 0 * t, 0 * t, 0 * t)
|
|
121
|
+
_, valid = reference_poses(rows, t, np.array([0.9, 1.5, 2.1]))
|
|
122
|
+
assert valid.tolist() == [False, True, False]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_a_repeated_row_is_dropped_and_a_clock_going_back_is_refused():
|
|
126
|
+
t = np.array([0.0, 0.02, 0.02, 0.04])
|
|
127
|
+
rows = _rows(t, t, 0 * t, 0 * t, 0 * t)
|
|
128
|
+
_, valid = reference_poses(rows, t, np.array([0.01, 0.03]))
|
|
129
|
+
assert valid.all()
|
|
130
|
+
with pytest.raises(ValueError, match="backwards"):
|
|
131
|
+
reference_poses(rows, np.array([0.0, 0.02, 0.01, 0.04]), np.array([0.01]))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_a_jump_beyond_the_top_speed_invalidates_both_scans_of_the_step():
|
|
135
|
+
# 5 m/s, except one row 5 m off: the step into it and the step out of it
|
|
136
|
+
# both exceed 15 m/s; the scan before, the jumped one and the one after
|
|
137
|
+
# are invalid.
|
|
138
|
+
t = np.arange(0, 2.0001, 0.1)
|
|
139
|
+
x = 5 * t
|
|
140
|
+
x[10] += 5.0
|
|
141
|
+
rows = _rows(t, x, 0 * t, 0 * t, 0 * t)
|
|
142
|
+
poses, valid = reference_poses(rows, t, t, max_speed=15.0)
|
|
143
|
+
assert np.flatnonzero(~valid).tolist() == [9, 10, 11]
|
|
144
|
+
assert np.isnan(poses[~valid]).all()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_pose_validity_needs_a_positive_top_speed_and_checks_turns():
|
|
148
|
+
t = np.arange(0, 1.0001, 0.1)
|
|
149
|
+
yaw = np.zeros_like(t)
|
|
150
|
+
yaw[5] = 1.0 # a 1 rad flick in 0.1 s
|
|
151
|
+
poses = np.stack([_true_pose(ti, 0, 0, a) for ti, a in zip(t, yaw)])
|
|
152
|
+
assert pose_validity(poses, t, max_speed=15.0).all()
|
|
153
|
+
assert np.flatnonzero(
|
|
154
|
+
~pose_validity(poses, t, max_speed=15.0, max_yaw_rate=2.0)
|
|
155
|
+
).tolist() == [4, 5, 6]
|
|
156
|
+
with pytest.raises(ValueError):
|
|
157
|
+
pose_validity(poses, t, max_speed=0.0)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_a_scan_repeated_under_the_same_stamp_is_not_a_jump():
|
|
161
|
+
# Real recordings repeat a scan under the same stamp: no time elapses,
|
|
162
|
+
# there is no speed to check, and both copies keep their pose.
|
|
163
|
+
t = np.arange(0, 1.0001, 0.02)
|
|
164
|
+
rows = _rows(t, 5 * t, 0 * t, 0 * t, 0 * t)
|
|
165
|
+
scans = np.array([0.1, 0.2, 0.2, 0.3])
|
|
166
|
+
poses, valid = reference_poses(rows, t, scans, max_speed=15.0)
|
|
167
|
+
assert valid.all()
|
|
168
|
+
np.testing.assert_array_equal(poses[1], poses[2])
|
|
169
|
+
with pytest.raises(ValueError, match="backwards"):
|
|
170
|
+
pose_validity(poses, np.array([0.1, 0.2, 0.15, 0.3]), max_speed=15.0)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_reference_poses_class_keeps_its_configuration():
|
|
174
|
+
rp = ReferencePoses(clock_offset=-0.05, max_gap=0.1, max_speed=20.0)
|
|
175
|
+
assert rp.get_config()["clock_offset"] == -0.05
|
|
176
|
+
t = np.arange(0, 1.0001, 0.02)
|
|
177
|
+
rows = _rows(t, t, 0 * t, 0 * t, 0 * t)
|
|
178
|
+
a = rp(rows, t, np.array([0.5]))
|
|
179
|
+
b = reference_poses(rows, t, np.array([0.5]), clock_offset=-0.05, max_speed=20.0)
|
|
180
|
+
np.testing.assert_array_equal(a[0], b[0])
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ----------------------------------------------------------------- labelling
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _frames(poses, valid, xyz, valid_key="valid"):
|
|
187
|
+
xyz = np.asarray(xyz, dtype=np.float32)
|
|
188
|
+
return iter(
|
|
189
|
+
[
|
|
190
|
+
Sample(data={"lidar": xyz, "poses": p, valid_key: np.bool_(v)})
|
|
191
|
+
for p, v in zip(poses, valid)
|
|
192
|
+
]
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _line(n, step):
|
|
197
|
+
poses = np.tile(np.eye(4), (n, 1, 1))
|
|
198
|
+
poses[:, 0, 3] = np.arange(n) * step
|
|
199
|
+
return poses
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_a_non_finite_pose_without_valid_key_is_an_error():
|
|
203
|
+
poses = _line(4, 1.0)
|
|
204
|
+
poses[2] = np.nan
|
|
205
|
+
frames = iter(
|
|
206
|
+
[
|
|
207
|
+
Sample(data={"lidar": np.zeros((1, 3), np.float32), "poses": p})
|
|
208
|
+
for p in poses
|
|
209
|
+
]
|
|
210
|
+
)
|
|
211
|
+
with pytest.raises(ValueError, match="valid_key"):
|
|
212
|
+
TraversabilityFromTrajectory(robot_radius=0.5)(frames)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_an_invalid_scan_cuts_the_look_ahead_and_is_marked_unknown():
|
|
216
|
+
# Straight line, 1 m steps, a point 2 m ahead of each scan, on the path.
|
|
217
|
+
# Scan 1 is invalid. From scan 0 the point lies on scan 2, but the
|
|
218
|
+
# look-ahead stops at scan 1: not traversable. Scan 1's own points are
|
|
219
|
+
# unknown (255). From scan 2 the point lies on scan 4: traversable.
|
|
220
|
+
poses = _line(6, 1.0)
|
|
221
|
+
poses[1] = np.nan
|
|
222
|
+
valid = [True, False, True, True, True, True]
|
|
223
|
+
xyz = [[2.0, 0.0, 0.0]]
|
|
224
|
+
lab = TraversabilityFromTrajectory(
|
|
225
|
+
robot_radius=0.3, valid_key="valid", unknown_label=255
|
|
226
|
+
)(_frames(poses, valid, xyz))
|
|
227
|
+
assert lab[0].tolist() == [0]
|
|
228
|
+
assert lab[1].tolist() == [255]
|
|
229
|
+
assert lab[2].tolist() == [1]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_without_unknown_label_an_invalid_scan_is_zero():
|
|
233
|
+
poses = _line(4, 1.0)
|
|
234
|
+
poses[1] = np.nan
|
|
235
|
+
lab = TraversabilityFromTrajectory(robot_radius=0.3, valid_key="valid")(
|
|
236
|
+
_frames(poses, [True, False, True, True], [[1.0, 0.0, 0.0]])
|
|
237
|
+
)
|
|
238
|
+
assert lab[1].tolist() == [0]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_unknown_label_must_not_collide_with_the_labels():
|
|
242
|
+
for bad in (0, 1, 256):
|
|
243
|
+
with pytest.raises(ValueError):
|
|
244
|
+
TraversabilityFromTrajectory(valid_key="valid", unknown_label=bad)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_valid_key_is_an_input_and_a_source():
|
|
248
|
+
p = TraversabilityFromTrajectory(valid_key="ins_valid")
|
|
249
|
+
assert p.input_keys == ["lidar", "poses", "ins_valid"]
|
|
250
|
+
assert p.sources == ["lidar", "poses", "ins_valid"]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_end_to_end_delivered_rows_give_the_labels_of_the_true_poses():
|
|
254
|
+
# A drive with a turn, recorded by a 50 Hz INS 1.2 m above the ground,
|
|
255
|
+
# scans at 10 Hz on a lidar clock 0.05 s ahead: labelling the poses
|
|
256
|
+
# rebuilt from the rows gives exactly the labels of the true poses.
|
|
257
|
+
t = np.arange(0, 12.0001, 0.02)
|
|
258
|
+
yaw = np.where(t < 6, 0.0, 0.3 * (t - 6))
|
|
259
|
+
x = np.cumsum(np.r_[0, 3.0 * np.cos(yaw[:-1]) * 0.02])
|
|
260
|
+
y = np.cumsum(np.r_[0, 3.0 * np.sin(yaw[:-1]) * 0.02])
|
|
261
|
+
rows = _rows(t, x, y, np.full_like(t, 1.2), yaw)
|
|
262
|
+
scans = np.arange(0.1, 11.0, 0.1)
|
|
263
|
+
poses, valid = reference_poses(rows, t, scans, clock_offset=-0.05, max_speed=10.0)
|
|
264
|
+
assert valid.all()
|
|
265
|
+
|
|
266
|
+
ts = scans - 0.05
|
|
267
|
+
truth = np.stack(
|
|
268
|
+
[
|
|
269
|
+
_true_pose(
|
|
270
|
+
np.interp(s, t, x), np.interp(s, t, y), 1.2, np.interp(s, t, yaw)
|
|
271
|
+
)
|
|
272
|
+
for s in ts
|
|
273
|
+
]
|
|
274
|
+
)
|
|
275
|
+
truth = np.linalg.inv(truth[0]) @ truth
|
|
276
|
+
rng = np.random.default_rng(0)
|
|
277
|
+
ground = np.column_stack(
|
|
278
|
+
[rng.uniform(0, 12, 400), rng.uniform(-2, 2, 400), np.full(400, -1.2)]
|
|
279
|
+
).astype(np.float32)
|
|
280
|
+
kw = dict(robot_radius=0.6, pose_height=1.2, forward_distance=15.0)
|
|
281
|
+
a = TraversabilityFromTrajectory(**kw)(
|
|
282
|
+
iter([Sample(data={"lidar": ground, "poses": p}) for p in poses])
|
|
283
|
+
)
|
|
284
|
+
b = TraversabilityFromTrajectory(**kw)(
|
|
285
|
+
iter([Sample(data={"lidar": ground, "poses": p}) for p in truth])
|
|
286
|
+
)
|
|
287
|
+
assert int(np.asarray(b).sum()) > 0
|
|
288
|
+
np.testing.assert_array_equal(np.asarray(a), np.asarray(b))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/__init__.py
RENAMED
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/robot_filter.py
RENAMED
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/voxelise.py
RENAMED
|
File without changes
|
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/priors/ground_height.py
RENAMED
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/priors/trajectory_distance.py
RENAMED
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/image_mask.py
RENAMED
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/lidar_to_camera.py
RENAMED
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/point_features.py
RENAMED
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/traversability/__init__.py
RENAMED
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/traversability/from_labels.py
RENAMED
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_image_mask_from_point_labels.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_traversability_from_labels.py
RENAMED
|
File without changes
|
{apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_traversability_from_trajectory.py
RENAMED
|
File without changes
|
|
File without changes
|