apairo-preprocess 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,181 @@
1
+ """Rasterize per-point labels into an image mask — point cloud → image.
2
+
3
+ The forward direction of the projection composition: given any per-point
4
+ label channel (e.g. ``trav_traj`` from
5
+ :class:`~apairo_preprocess.TraversabilityFromTrajectory`) and the per-point
6
+ pixel coordinates written by :class:`LidarCameraProjection`, paint each
7
+ label at its point's pixel. The result is a sparse image-space ground-truth
8
+ mask — the traversability channel of an image dataset.
9
+
10
+ Pixels hit by several points keep the **nearest** point's label (depth
11
+ ordering), and an optional occlusion filter drops points that are
12
+ significantly behind the nearest return in their neighbourhood, so
13
+ traversable ground behind an obstacle does not bleed onto the obstacle's
14
+ pixels.
15
+
16
+ This preprocessor reads two channels, both on the lidar clock. On profiled
17
+ synchronous datasets it runs via ``run_preprocess`` directly; on
18
+ asynchronous datasets, run it over a ``synchronize()`` view and persist with
19
+ ``ChannelWriter`` (see ``examples/traversability_image_mask.py``).
20
+
21
+ Typical usage::
22
+
23
+ Rellis3DDataset.run_preprocess(
24
+ ImageMaskFromPointLabels(image_size=(1200, 1920), radius=2),
25
+ root,
26
+ )
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from typing import ClassVar
32
+
33
+ import numpy as np
34
+
35
+ from apairo.core.preprocessor import FramePreprocessor
36
+ from apairo.core.sample import Sample
37
+
38
+
39
+ class ImageMaskFromPointLabels(FramePreprocessor):
40
+ """Paint per-point labels into an ``(H, W)`` uint8 mask.
41
+
42
+ Output is ``(height, width)`` uint8: ``unknown_value`` where no point
43
+ projects, the nearest projecting point's label elsewhere. With the
44
+ default inputs (``trav_traj``) that is ``255`` = no data, ``1`` =
45
+ traversable, ``0`` = not traversable.
46
+
47
+ Args:
48
+ image_size: ``(height, width)`` of the output mask — must match
49
+ the ``image_size`` the projection was computed for.
50
+ labels_key: Input channel with per-point integer labels ``(N,)``,
51
+ row-aligned with the projection.
52
+ projection_key: Input channel with ``(N, 3)`` ``[u, v, depth]`` rows
53
+ from :class:`LidarCameraProjection`.
54
+ radius: Splat radius in pixels. ``0`` (default) paints one
55
+ pixel per point; ``r`` paints a disc of radius ``r``,
56
+ densifying the sparse lidar coverage.
57
+ unknown_value: Value for pixels no point projects to (default 255).
58
+ Labels must not collide with it.
59
+ occlusion_bin_px: Optional occlusion filtering: bin size in pixels
60
+ of a coarse depth buffer. Points deeper than
61
+ the nearest return in their bin by more than
62
+ ``occlusion_depth_margin`` are dropped before
63
+ painting. ``None`` (default) disables it.
64
+ occlusion_depth_margin: Depth slack in metres for the occlusion filter.
65
+ output_key: Override the default output channel name
66
+ ``"trav_mask"``.
67
+ """
68
+
69
+ output_key: ClassVar[str] = "trav_mask"
70
+ input_keys: ClassVar[list[str]] = ["trav_traj", "lidar_uv"]
71
+ timestamps_from: ClassVar[str] = "trav_traj"
72
+ sources: ClassVar[list[str]] = ["trav_traj", "lidar_uv"]
73
+
74
+ def __init__(
75
+ self,
76
+ image_size: tuple[int, int],
77
+ labels_key: str = "trav_traj",
78
+ projection_key: str = "lidar_uv",
79
+ radius: int = 0,
80
+ unknown_value: int = 255,
81
+ occlusion_bin_px: int | None = None,
82
+ occlusion_depth_margin: float = 0.5,
83
+ output_key: str | None = None,
84
+ ) -> None:
85
+ height, width = image_size
86
+ if height <= 0 or width <= 0:
87
+ raise ValueError(f"image_size must be positive, got {image_size}")
88
+ if radius < 0:
89
+ raise ValueError(f"radius must be >= 0, got {radius}")
90
+ if not 0 <= unknown_value <= 255:
91
+ raise ValueError(f"unknown_value must fit uint8, got {unknown_value}")
92
+ if occlusion_bin_px is not None and occlusion_bin_px <= 0:
93
+ raise ValueError(f"occlusion_bin_px must be > 0, got {occlusion_bin_px}")
94
+
95
+ self._height = int(height)
96
+ self._width = int(width)
97
+ self._labels_key = labels_key
98
+ self._projection_key = projection_key
99
+ self._radius = int(radius)
100
+ self._unknown = int(unknown_value)
101
+ self._occ_bin = occlusion_bin_px
102
+ self._occ_margin = occlusion_depth_margin
103
+
104
+ self.input_keys = [labels_key, projection_key]
105
+ self.timestamps_from = labels_key
106
+ self.sources = [labels_key, projection_key]
107
+ if output_key is not None:
108
+ self.output_key = output_key
109
+
110
+ def __call__(self, sample: Sample) -> np.ndarray:
111
+ labels = np.asarray(sample.data[self._labels_key]).ravel()
112
+ uv = np.asarray(sample.data[self._projection_key])
113
+ if uv.ndim != 2 or uv.shape[1] < 3:
114
+ raise ValueError(f"projection must be (N, 3) [u, v, depth], got {uv.shape}")
115
+ if len(labels) != len(uv):
116
+ raise ValueError(
117
+ f"labels ({len(labels)}) and projection ({len(uv)}) are not "
118
+ f"row-aligned — they must derive from the same scan."
119
+ )
120
+
121
+ height, width = self._height, self._width
122
+ mask = np.full((height, width), self._unknown, dtype=np.uint8)
123
+
124
+ valid = np.isfinite(uv[:, 0])
125
+ if not valid.any():
126
+ return mask
127
+
128
+ cols = uv[valid, 0].astype(np.intp)
129
+ rows = uv[valid, 1].astype(np.intp)
130
+ depth = uv[valid, 2].astype(np.float64)
131
+ vals = labels[valid]
132
+ if vals.min() < 0 or vals.max() > 255:
133
+ raise ValueError(
134
+ f"labels must fit uint8, got range [{vals.min()}, {vals.max()}]"
135
+ )
136
+ vals = vals.astype(np.uint8)
137
+
138
+ if self._occ_bin is not None:
139
+ keep = self._occlusion_keep(rows, cols, depth)
140
+ rows, cols, depth, vals = rows[keep], cols[keep], depth[keep], vals[keep]
141
+
142
+ # Splat each point over a disc of offsets, then resolve every pixel
143
+ # to its nearest candidate in one pass.
144
+ r = self._radius
145
+ offsets = [
146
+ (du, dv)
147
+ for du in range(-r, r + 1)
148
+ for dv in range(-r, r + 1)
149
+ if du * du + dv * dv <= r * r
150
+ ]
151
+ pix_parts, depth_parts, val_parts = [], [], []
152
+ for du, dv in offsets:
153
+ rr = rows + dv
154
+ cc = cols + du
155
+ ok = (rr >= 0) & (rr < height) & (cc >= 0) & (cc < width)
156
+ pix_parts.append(rr[ok] * width + cc[ok])
157
+ depth_parts.append(depth[ok])
158
+ val_parts.append(vals[ok])
159
+ pix = np.concatenate(pix_parts)
160
+ d = np.concatenate(depth_parts)
161
+ v = np.concatenate(val_parts)
162
+
163
+ order = np.lexsort((d, pix)) # by pixel, nearest first within a pixel
164
+ pix, v = pix[order], v[order]
165
+ first = np.ones(len(pix), dtype=bool)
166
+ first[1:] = pix[1:] != pix[:-1]
167
+ mask.flat[pix[first]] = v[first]
168
+ return mask
169
+
170
+ def _occlusion_keep(
171
+ self, rows: np.ndarray, cols: np.ndarray, depth: np.ndarray
172
+ ) -> np.ndarray:
173
+ """Keep points within ``occlusion_depth_margin`` of their bin's nearest
174
+ return, on a coarse ``occlusion_bin_px`` grid."""
175
+ b = self._occ_bin
176
+ n_bins_x = (self._width + b - 1) // b
177
+ n_bins_y = (self._height + b - 1) // b
178
+ bin_id = (rows // b) * n_bins_x + (cols // b)
179
+ nearest = np.full(n_bins_x * n_bins_y, np.inf)
180
+ np.minimum.at(nearest, bin_id, depth)
181
+ return depth <= nearest[bin_id] + self._occ_margin
@@ -0,0 +1,177 @@
1
+ """Project lidar points onto a camera image plane.
2
+
3
+ For each scan, every point is mapped through the lidar→camera extrinsic and
4
+ the pinhole intrinsics to continuous pixel coordinates. The output is
5
+ **row-aligned** with the input scan: row *i* is ``[u, v, depth]`` for point
6
+ *i*, or ``NaN`` when the point does not land in the image (behind the camera
7
+ or out of bounds). Row alignment is what makes the channel composable — any
8
+ per-point channel (traversability labels, priors, …) carries into image space
9
+ by indexing with the same row (see :class:`ImageMaskFromPointLabels` and
10
+ :class:`PointFeaturesFromImage`).
11
+
12
+ The camera frame must be the **optical** frame (+Z forward, +X right, +Y
13
+ down). Both halves of the projection come from the dataset's calibration —
14
+ the extrinsic from the transform graph, the intrinsics from the ``cameras:``
15
+ section::
16
+
17
+ cal = ds.calibration
18
+ cam = cal.get_intrinsics("camera_left") # CameraIntrinsics
19
+ T = cal.get_tf("velodyne_0", "camera_left") # (4, 4)
20
+
21
+ Typical usage::
22
+
23
+ TartanKittiDataset.run_preprocess(
24
+ LidarCameraProjection(
25
+ intrinsics=cam, # CameraIntrinsics, or a (3, 3) K
26
+ extrinsics=T, # (4, 4) T_camera_from_lidar
27
+ lidar_key="velodyne_0",
28
+ output_key="lidar_uv_left",
29
+ ),
30
+ seq_dir,
31
+ )
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from typing import ClassVar
37
+
38
+ import numpy as np
39
+
40
+ from apairo.core.config import CameraIntrinsics
41
+ from apairo.core.preprocessor import FramePreprocessor
42
+ from apairo.core.sample import Sample
43
+
44
+
45
+ def _distort_plumb_bob(x: np.ndarray, y: np.ndarray, d: np.ndarray) -> tuple:
46
+ """Apply plumb-bob (radial-tangential) distortion to normalized coords."""
47
+ k1, k2, p1, p2, k3 = d
48
+ r2 = x * x + y * y
49
+ radial = 1.0 + k1 * r2 + k2 * r2 * r2 + k3 * r2 * r2 * r2
50
+ xd = x * radial + 2.0 * p1 * x * y + p2 * (r2 + 2.0 * x * x)
51
+ yd = y * radial + p1 * (r2 + 2.0 * y * y) + 2.0 * p2 * x * y
52
+ return xd, yd
53
+
54
+
55
+ class LidarCameraProjection(FramePreprocessor):
56
+ """Per-point pixel coordinates of a lidar scan in a camera image.
57
+
58
+ Output is ``(N, 3)`` float32, row-aligned with the input scan:
59
+ ``[u, v, depth]`` — continuous pixel coordinates (``u`` = column, ``v`` =
60
+ row, origin at the top-left pixel corner) and depth along the camera's
61
+ optical axis (metres). Points behind the camera or projecting outside
62
+ ``image_size`` are ``NaN`` in all three columns, so validity is
63
+ ``np.isfinite(uv[:, 0])`` and ``int(u)``/``int(v)`` of a valid row always
64
+ index into the image.
65
+
66
+ Only the scan itself is streamed (``input_keys`` has a single channel):
67
+ extrinsics, intrinsics and image size are static rig properties, so the
68
+ preprocessor runs on asynchronous datasets as well as profiled ones.
69
+
70
+ Args:
71
+ intrinsics: A :class:`~apairo.core.config.CameraIntrinsics` from
72
+ ``ds.calibration.get_intrinsics(frame)``, or a bare
73
+ ``(3, 3)`` pinhole camera matrix K.
74
+ extrinsics: ``(4, 4)`` rigid transform ``T_camera_from_lidar`` — maps
75
+ lidar-frame points into the camera *optical* frame
76
+ (+Z forward). Typically
77
+ ``ds.calibration.get_tf(lidar_frame, camera_frame)``.
78
+ image_size: ``(height, width)`` of the target image, in pixels.
79
+ Defaults to the ``CameraIntrinsics``' recorded size;
80
+ required when passing a bare K.
81
+ distortion: Plumb-bob coefficients ``(k1, k2, p1, p2[, k3])``.
82
+ Defaults to the ``CameraIntrinsics``' coefficients;
83
+ ``None`` with a bare K means a rectified image.
84
+ lidar_key: Input channel for point cloud data.
85
+ output_key: Override the default output channel name ``"lidar_uv"``.
86
+ """
87
+
88
+ output_key: ClassVar[str] = "lidar_uv"
89
+ input_keys: ClassVar[list[str]] = ["lidar"]
90
+ timestamps_from: ClassVar[str] = "lidar"
91
+ sources: ClassVar[list[str]] = ["lidar"]
92
+
93
+ def __init__(
94
+ self,
95
+ intrinsics: np.ndarray | CameraIntrinsics,
96
+ extrinsics: np.ndarray,
97
+ image_size: tuple[int, int] | None = None,
98
+ distortion: np.ndarray | None = None,
99
+ lidar_key: str = "lidar",
100
+ output_key: str | None = None,
101
+ ) -> None:
102
+ if isinstance(intrinsics, CameraIntrinsics):
103
+ if intrinsics.model != "plumb_bob" and len(intrinsics.distortion):
104
+ raise ValueError(
105
+ f"only the plumb_bob distortion model is supported, "
106
+ f"got {intrinsics.model!r}"
107
+ )
108
+ if distortion is None and len(intrinsics.distortion):
109
+ distortion = intrinsics.distortion
110
+ if image_size is None:
111
+ if intrinsics.height is None or intrinsics.width is None:
112
+ raise ValueError(
113
+ "CameraIntrinsics records no image size — pass image_size="
114
+ )
115
+ image_size = (intrinsics.height, intrinsics.width)
116
+ intrinsics = intrinsics.K
117
+ elif image_size is None:
118
+ raise ValueError("image_size is required when intrinsics is a bare K")
119
+ K = np.asarray(intrinsics, dtype=np.float64)
120
+ if K.shape != (3, 3):
121
+ raise ValueError(f"intrinsics must be (3, 3), got {K.shape}")
122
+ T = np.asarray(extrinsics, dtype=np.float64)
123
+ if T.shape != (4, 4):
124
+ raise ValueError(f"extrinsics must be (4, 4), got {T.shape}")
125
+ height, width = image_size
126
+ if height <= 0 or width <= 0:
127
+ raise ValueError(f"image_size must be positive, got {image_size}")
128
+ if distortion is not None:
129
+ d = np.asarray(distortion, dtype=np.float64).ravel()
130
+ if d.size == 4:
131
+ d = np.append(d, 0.0) # k3 defaults to 0
132
+ if d.size != 5:
133
+ raise ValueError(
134
+ f"distortion must be (k1, k2, p1, p2[, k3]), got {d.size} values"
135
+ )
136
+ self._dist = d
137
+ else:
138
+ self._dist = None
139
+
140
+ self._K = K
141
+ self._T = T
142
+ self._height = int(height)
143
+ self._width = int(width)
144
+ self._lidar_key = lidar_key
145
+
146
+ self.input_keys = [lidar_key]
147
+ self.sources = [lidar_key]
148
+ if output_key is not None:
149
+ self.output_key = output_key
150
+
151
+ def __call__(self, sample: Sample) -> np.ndarray:
152
+ pc = np.asarray(sample.data[self._lidar_key])
153
+ xyz = pc[:, :3].astype(np.float64)
154
+ n = len(xyz)
155
+
156
+ cam = xyz @ self._T[:3, :3].T + self._T[:3, 3]
157
+ z = cam[:, 2]
158
+
159
+ out = np.full((n, 3), np.nan, dtype=np.float32)
160
+ front = z > 1e-9
161
+ if not front.any():
162
+ return out
163
+
164
+ x = cam[front, 0] / z[front]
165
+ y = cam[front, 1] / z[front]
166
+ if self._dist is not None:
167
+ x, y = _distort_plumb_bob(x, y, self._dist)
168
+
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]
171
+
172
+ in_image = (u >= 0) & (u < self._width) & (v >= 0) & (v < self._height)
173
+ idx = np.flatnonzero(front)[in_image]
174
+ out[idx, 0] = u[in_image]
175
+ out[idx, 1] = v[in_image]
176
+ out[idx, 2] = z[idx]
177
+ return out
@@ -0,0 +1,100 @@
1
+ """Sample image values at projected lidar points — image → point cloud.
2
+
3
+ The inverse direction of :class:`ImageMaskFromPointLabels`: given the
4
+ per-point pixel coordinates written by :class:`LidarCameraProjection`, read
5
+ the image at each valid point and attach the pixel's value (RGB, semantic
6
+ class, …) to the point. Output is row-aligned with the scan, so it loads as
7
+ a per-point feature channel next to ``lidar``.
8
+
9
+ Colouring the cloud with camera RGB and inspecting it in a 3-D viewer
10
+ (projector, apairo_rr) is the standard visual check that extrinsics and
11
+ intrinsics are right — miscalibration shows up immediately as colour bleeding
12
+ across object boundaries.
13
+
14
+ This preprocessor reads **two** channels on different clocks (the projection
15
+ on the lidar clock, the image on the camera clock). On profiled synchronous
16
+ datasets it runs via ``run_preprocess`` directly; on asynchronous datasets,
17
+ run it over a ``synchronize()`` view and persist with ``ChannelWriter`` (see
18
+ ``examples/traversability_image_mask.py``).
19
+
20
+ Typical usage::
21
+
22
+ Rellis3DDataset.run_preprocess(
23
+ PointFeaturesFromImage(image_key="image_left", output_key="lidar_rgb"),
24
+ root,
25
+ )
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import ClassVar
31
+
32
+ import numpy as np
33
+
34
+ from apairo.core.preprocessor import FramePreprocessor
35
+ from apairo.core.sample import Sample
36
+
37
+
38
+ class PointFeaturesFromImage(FramePreprocessor):
39
+ """Per-point image values sampled at each point's projected pixel.
40
+
41
+ Output is ``(N, C)`` in the image's dtype, row-aligned with the scan
42
+ (``C`` = image channels; a single-channel ``(H, W)`` image yields
43
+ ``(N, 1)``). Points without a valid projection get ``fill_value``.
44
+ Sampling is nearest-pixel (truncation of the continuous coordinates).
45
+
46
+ Args:
47
+ projection_key: Input channel with ``(N, 3)`` ``[u, v, depth]`` rows
48
+ from :class:`LidarCameraProjection`.
49
+ image_key: Input channel for the image, ``(H, W)`` or ``(H, W, C)``.
50
+ fill_value: Value written for points with no valid projection.
51
+ output_key: Override the default output channel name
52
+ ``"point_features"``.
53
+ """
54
+
55
+ output_key: ClassVar[str] = "point_features"
56
+ input_keys: ClassVar[list[str]] = ["lidar_uv", "image"]
57
+ timestamps_from: ClassVar[str] = "lidar_uv"
58
+ sources: ClassVar[list[str]] = ["lidar_uv", "image"]
59
+
60
+ def __init__(
61
+ self,
62
+ projection_key: str = "lidar_uv",
63
+ image_key: str = "image",
64
+ fill_value: float = 0,
65
+ output_key: str | None = None,
66
+ ) -> None:
67
+ self._projection_key = projection_key
68
+ self._image_key = image_key
69
+ self._fill_value = fill_value
70
+
71
+ self.input_keys = [projection_key, image_key]
72
+ self.timestamps_from = projection_key
73
+ self.sources = [projection_key, image_key]
74
+ if output_key is not None:
75
+ self.output_key = output_key
76
+
77
+ def __call__(self, sample: Sample) -> np.ndarray:
78
+ uv = np.asarray(sample.data[self._projection_key])
79
+ img = np.asarray(sample.data[self._image_key])
80
+ if uv.ndim != 2 or uv.shape[1] < 2:
81
+ raise ValueError(f"projection must be (N, >=2) [u, v, ...], got {uv.shape}")
82
+ if img.ndim == 2:
83
+ img = img[:, :, None]
84
+ if img.ndim != 3:
85
+ raise ValueError(f"image must be (H, W) or (H, W, C), got {img.shape}")
86
+
87
+ n = len(uv)
88
+ height, width, channels = img.shape
89
+ out = np.full((n, channels), self._fill_value, dtype=img.dtype)
90
+
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):
95
+ raise ValueError(
96
+ f"projection exceeds image bounds ({height}, {width}) — the "
97
+ f"projection was computed for a different image_size."
98
+ )
99
+ out[valid] = img[rows, cols]
100
+ return out
@@ -0,0 +1,7 @@
1
+ from apairo_preprocess.traversability.from_labels import TraversabilityFromLabels
2
+ from apairo_preprocess.traversability.from_trajectory import TraversabilityFromTrajectory
3
+
4
+ __all__ = [
5
+ "TraversabilityFromLabels",
6
+ "TraversabilityFromTrajectory",
7
+ ]
@@ -0,0 +1,61 @@
1
+ """Label-based traversability — maps semantic IDs to binary traversable/non-traversable.
2
+
3
+ Each point is labelled 1 if its semantic class ID is in ``traversable_ids``, 0 otherwise.
4
+
5
+ Default traversable IDs for RELLIS-3D::
6
+
7
+ {1: dirt, 3: grass, 10: asphalt, 23: concrete, 31: puddle, 33: mud}
8
+
9
+ Typical usage::
10
+
11
+ Rellis3DDataset.run_preprocess(
12
+ TraversabilityFromLabels(),
13
+ "/data/rellis",
14
+ )
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import ClassVar
20
+
21
+ import numpy as np
22
+
23
+ from apairo.core.preprocessor import FramePreprocessor
24
+ from apairo.core.sample import Sample
25
+
26
+ _RELLIS_TRAVERSABLE_IDS: frozenset[int] = frozenset({1, 3, 10, 23, 31, 33})
27
+
28
+
29
+ class TraversabilityFromLabels(FramePreprocessor):
30
+ """Label each point traversable based on its semantic class ID.
31
+
32
+ Args:
33
+ labels_key: Input channel for per-point semantic labels.
34
+ traversable_ids: Set of semantic class IDs considered traversable.
35
+ Defaults to the RELLIS-3D traversable classes.
36
+ output_key: Override the default output channel name ``"trav_label"``.
37
+ """
38
+
39
+ output_key: ClassVar[str] = "trav_label"
40
+ input_keys: ClassVar[list[str]] = ["labels"]
41
+ timestamps_from: ClassVar[str] = "lidar"
42
+ sources: ClassVar[list[str]] = ["labels"]
43
+
44
+ def __init__(
45
+ self,
46
+ labels_key: str = "labels",
47
+ traversable_ids: frozenset[int] | None = None,
48
+ output_key: str | None = None,
49
+ ) -> None:
50
+ self._labels_key = labels_key
51
+ self._trav_ids = (
52
+ traversable_ids if traversable_ids is not None else _RELLIS_TRAVERSABLE_IDS
53
+ )
54
+ self.input_keys = [labels_key]
55
+ self.sources = [labels_key]
56
+ if output_key is not None:
57
+ self.output_key = output_key
58
+
59
+ def __call__(self, sample: Sample) -> np.ndarray:
60
+ labels = np.asarray(sample.data[self._labels_key])
61
+ return np.isin(labels, list(self._trav_ids)).astype(np.uint8)