apairo-preprocess 0.2.0__tar.gz → 0.4.0__tar.gz

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