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.
Files changed (48) hide show
  1. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/PKG-INFO +68 -3
  2. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/README.md +67 -2
  3. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/__init__.py +10 -1
  4. apairo_preprocess-0.4.0/apairo_preprocess/odometry/__init__.py +15 -0
  5. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/odometry/gicp.py +4 -0
  6. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/odometry/kissicp.py +5 -0
  7. apairo_preprocess-0.4.0/apairo_preprocess/odometry/reference.py +252 -0
  8. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/traversability/from_trajectory.py +46 -5
  9. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/PKG-INFO +68 -3
  10. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/SOURCES.txt +2 -0
  11. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/pyproject.toml +1 -1
  12. apairo_preprocess-0.4.0/tests/test_reference_poses.py +288 -0
  13. apairo_preprocess-0.3.0/apairo_preprocess/odometry/__init__.py +0 -4
  14. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/LICENSE +0 -0
  15. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/__init__.py +0 -0
  16. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/csf.py +0 -0
  17. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/from_labels.py +0 -0
  18. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/ground/ransac.py +0 -0
  19. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/__init__.py +0 -0
  20. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/robot_filter.py +0 -0
  21. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/pointcloud/voxelise.py +0 -0
  22. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/priors/__init__.py +0 -0
  23. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/priors/ground_height.py +0 -0
  24. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/priors/trajectory_distance.py +0 -0
  25. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/__init__.py +0 -0
  26. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/bev.py +0 -0
  27. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/image_mask.py +0 -0
  28. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/lidar_to_camera.py +0 -0
  29. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/projection/point_features.py +0 -0
  30. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/traversability/__init__.py +0 -0
  31. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess/traversability/from_labels.py +0 -0
  32. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/dependency_links.txt +0 -0
  33. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/requires.txt +0 -0
  34. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/apairo_preprocess.egg-info/top_level.txt +0 -0
  35. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/setup.cfg +0 -0
  36. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_audit_fixes.py +0 -0
  37. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_bev_rasterisation.py +0 -0
  38. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_call_contract.py +0 -0
  39. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_gicp_odometry.py +0 -0
  40. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_image_mask_from_point_labels.py +0 -0
  41. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_kissicp_odometry.py +0 -0
  42. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_lidar_camera_projection.py +0 -0
  43. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_point_features_from_image.py +0 -0
  44. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_projection_pipeline.py +0 -0
  45. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_remove_robot_points.py +0 -0
  46. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_traversability_from_labels.py +0 -0
  47. {apairo_preprocess-0.3.0 → apairo_preprocess-0.4.0}/tests/test_traversability_from_trajectory.py +0 -0
  48. {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.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
- ### Odometry
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 to be computed first (e.g. with `KissICPOdometry`).
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
- ### Odometry
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 to be computed first (e.g. with `KissICPOdometry`).
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 GICPOdometry, KissICPOdometry
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
- self.input_keys = [lidar_key, poses_key]
165
- self.sources = [lidar_key, poses_key]
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
- boundaries = np.where(dists > self._sequence_gap)[0]
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
- path = np.concatenate([[0.0], np.cumsum(dists)])
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.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
- ### Odometry
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 to be computed first (e.g. with `KissICPOdometry`).
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.3.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))
@@ -1,4 +0,0 @@
1
- from apairo_preprocess.odometry.gicp import GICPOdometry
2
- from apairo_preprocess.odometry.kissicp import KissICPOdometry
3
-
4
- __all__ = ["GICPOdometry", "KissICPOdometry"]