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

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