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.
- apairo_preprocess/__init__.py +27 -0
- apairo_preprocess/ground/__init__.py +9 -0
- apairo_preprocess/ground/csf.py +95 -0
- apairo_preprocess/ground/from_labels.py +55 -0
- apairo_preprocess/ground/ransac.py +95 -0
- apairo_preprocess/odometry/__init__.py +4 -0
- apairo_preprocess/odometry/gicp.py +96 -0
- apairo_preprocess/odometry/kissicp.py +84 -0
- apairo_preprocess/pointcloud/__init__.py +4 -0
- apairo_preprocess/pointcloud/robot_filter.py +121 -0
- apairo_preprocess/pointcloud/voxelise.py +281 -0
- apairo_preprocess/priors/__init__.py +7 -0
- apairo_preprocess/priors/ground_height.py +60 -0
- apairo_preprocess/priors/trajectory_distance.py +97 -0
- apairo_preprocess/projection/__init__.py +12 -0
- apairo_preprocess/projection/bev.py +166 -0
- apairo_preprocess/projection/image_mask.py +181 -0
- apairo_preprocess/projection/lidar_to_camera.py +177 -0
- apairo_preprocess/projection/point_features.py +100 -0
- apairo_preprocess/traversability/__init__.py +7 -0
- apairo_preprocess/traversability/from_labels.py +61 -0
- apairo_preprocess/traversability/from_trajectory.py +181 -0
- apairo_preprocess-0.2.0.dist-info/METADATA +255 -0
- apairo_preprocess-0.2.0.dist-info/RECORD +27 -0
- apairo_preprocess-0.2.0.dist-info/WHEEL +5 -0
- apairo_preprocess-0.2.0.dist-info/licenses/LICENSE +21 -0
- apairo_preprocess-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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,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)
|