Skip to content

Commit 2bdd538

Browse files
committed
feat(streampetr): project 2D annotations and fold augmentations into ego poses
LoadAnnotations2DFromBoxes3D projects the augmented 3D boxes onto every camera for the auxiliary 2D head; the camera geometric augmentations fold their transforms into the ego poses so the temporal memory warp stays consistent; train-time camera-order shuffling follows the reference recipe. Signed-off-by: vividf <yihsiang.fang@tier4.jp>
1 parent 7a02cde commit 2bdd538

7 files changed

Lines changed: 518 additions & 6 deletions

File tree

autoware_ml/tests/transforms/test_camera.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import numpy.typing as npt
1919
import pytest
2020

21+
from autoware_ml.transforms.camera.annotations2d import LoadAnnotations2DFromBoxes3D
2122
from autoware_ml.transforms.camera.distortion import UndistortImage
2223
from autoware_ml.transforms.camera.masking import GridMask
2324
from autoware_ml.transforms.camera.normalize import NormalizeMultiviewImage
@@ -281,3 +282,210 @@ def test_grid_mask_handles_chw_stack() -> None:
281282

282283
assert output["img"].shape == images.shape
283284
assert (output["img"] == 0).any()
285+
286+
287+
def test_load_annotations_2d_treats_box_z_as_gravity_center() -> None:
288+
"""The projected 2D box and center must straddle z, not sit above it.
289+
290+
``gt_boxes`` stores the gravity center. Treating that z as the bottom face
291+
(building corners from z upward, or adding dz/2 before projecting the
292+
center) silently lifts every 2D auxiliary target by half an object height,
293+
which is invisible in the loss but wrong everywhere.
294+
"""
295+
# Pinhole camera: fx = fy = 100, principal point at (50, 50).
296+
cam2img = np.eye(4)
297+
cam2img[0, 0] = cam2img[1, 1] = 100.0
298+
cam2img[0, 2] = cam2img[1, 2] = 50.0
299+
# Lidar (x forward, y left, z up) -> camera (x right, y down, z forward).
300+
lidar2cam = np.array(
301+
[
302+
[0.0, -1.0, 0.0, 0.0],
303+
[0.0, 0.0, -1.0, 0.0],
304+
[1.0, 0.0, 0.0, 0.0],
305+
[0.0, 0.0, 0.0, 1.0],
306+
]
307+
)
308+
# One 2 m cube centered on the optical axis, 10 m ahead.
309+
gt_boxes = np.array([[10.0, 0.0, 0.0, 2.0, 2.0, 2.0, 0.0, 0.0, 0.0]], dtype=np.float32)
310+
311+
output = LoadAnnotations2DFromBoxes3D()(
312+
{
313+
"img": np.zeros((1, 3, 100, 100), dtype=np.float32),
314+
"gt_boxes": gt_boxes,
315+
"gt_labels": np.array([0]),
316+
"lidar2cam": [lidar2cam],
317+
"camera_intrinsics": [cam2img],
318+
}
319+
)
320+
321+
center = output["centers_2d"][0][0]
322+
x1, y1, x2, y2 = output["gt_bboxes_2d"][0][0]
323+
# z = 0 is on the optical axis, so the center projects to the principal
324+
# point. The bottom-face reading would put it at y = 40.
325+
assert center == pytest.approx([50.0, 50.0], abs=1e-3)
326+
# The cube spans 9-11 m in depth, so its near face projects largest and
327+
# sets the extent: 100 * 1 / 9 either side of the principal point.
328+
extent = 100.0 / 9.0
329+
assert (y1, y2) == pytest.approx((50.0 - extent, 50.0 + extent), abs=1e-3)
330+
assert (x1, x2) == pytest.approx((50.0 - extent, 50.0 + extent), abs=1e-3)
331+
# The center sits at the middle of the box, not on its top edge.
332+
assert center[1] == pytest.approx((y1 + y2) / 2, abs=1e-3)
333+
334+
335+
def _pinhole_setup(image_width: int = 160, image_height: int = 96) -> dict:
336+
"""One forward-looking pinhole camera in a lidar frame (x fwd, y left, z up)."""
337+
cam2img = np.eye(4)
338+
cam2img[0, 0] = cam2img[1, 1] = 100.0
339+
cam2img[0, 2] = image_width / 2
340+
cam2img[1, 2] = image_height / 2
341+
lidar2cam = np.array(
342+
[
343+
[0.0, -1.0, 0.0, 0.0],
344+
[0.0, 0.0, -1.0, 0.0],
345+
[1.0, 0.0, 0.0, 0.0],
346+
[0.0, 0.0, 0.0, 1.0],
347+
]
348+
)
349+
return {
350+
"img": np.zeros((1, 3, image_height, image_width), dtype=np.float32),
351+
"gt_labels": np.array([0]),
352+
"lidar2cam": [lidar2cam],
353+
"camera_intrinsics": [cam2img],
354+
}
355+
356+
357+
def test_load_annotations_2d_projects_box_in_front_of_camera() -> None:
358+
input_dict = _pinhole_setup()
359+
input_dict["gt_boxes"] = np.array(
360+
[[8.0, 0.0, -1.0, 4.0, 2.0, 1.5, 0.0, 0.0, 0.0]], dtype=np.float32
361+
)
362+
result = LoadAnnotations2DFromBoxes3D()(input_dict)
363+
assert result["gt_bboxes_2d"][0].shape == (1, 4)
364+
assert result["centers_2d"][0].shape == (1, 2)
365+
assert result["gt_labels_2d"][0].tolist() == [0]
366+
x1, y1, x2, y2 = result["gt_bboxes_2d"][0][0]
367+
assert 0 <= x1 < x2 <= 160
368+
assert 0 <= y1 < y2 <= 96
369+
370+
371+
def test_load_annotations_2d_drops_box_behind_camera() -> None:
372+
input_dict = _pinhole_setup()
373+
input_dict["gt_boxes"] = np.array(
374+
[[-10.0, 0.0, 0.0, 2.0, 2.0, 2.0, 0.0, 0.0, 0.0]], dtype=np.float32
375+
)
376+
result = LoadAnnotations2DFromBoxes3D()(input_dict)
377+
assert result["gt_bboxes_2d"][0].shape == (0, 4)
378+
assert result["centers_2d"][0].shape == (0, 2)
379+
assert result["gt_labels_2d"][0].shape == (0,)
380+
381+
382+
def test_load_annotations_2d_drops_box_whose_center_leaves_the_image() -> None:
383+
"""Corners still visible but the projected center is off-image -> dropped.
384+
385+
Matches the reference recipe: a clamped center could land outside its own
386+
clipped box and would distort the center-based 2D assignment.
387+
"""
388+
input_dict = _pinhole_setup()
389+
# Wide box far to the left: some corners project inside, the center at
390+
# y = 9 m projects to x = 80 - 100 * 9/10 = -10 (outside).
391+
input_dict["gt_boxes"] = np.array(
392+
[[10.0, 9.0, 0.0, 2.0, 6.0, 2.0, 0.0, 0.0, 0.0]], dtype=np.float32
393+
)
394+
result = LoadAnnotations2DFromBoxes3D()(input_dict)
395+
assert result["gt_bboxes_2d"][0].shape == (0, 4)
396+
397+
398+
def test_load_annotations_2d_clips_partially_visible_box_to_the_image() -> None:
399+
input_dict = _pinhole_setup()
400+
# Center projects at x = 80 - 100 * 6.5/10 = 15 (inside); the near-left
401+
# corners project far outside the left edge and must be clipped to 0.
402+
input_dict["gt_boxes"] = np.array(
403+
[[10.0, 6.5, 0.0, 2.0, 6.0, 2.0, 0.0, 0.0, 0.0]], dtype=np.float32
404+
)
405+
result = LoadAnnotations2DFromBoxes3D()(input_dict)
406+
assert result["gt_bboxes_2d"][0].shape == (1, 4)
407+
x1, y1, x2, y2 = result["gt_bboxes_2d"][0][0]
408+
assert x1 == 0.0
409+
assert 0 < x2 <= 160
410+
center = result["centers_2d"][0][0]
411+
assert 0 <= center[0] < 160
412+
413+
414+
def test_load_annotations_2d_assigns_boxes_per_camera() -> None:
415+
input_dict = _pinhole_setup()
416+
# Second camera looks backward (rotate lidar->cam by 180 deg around z).
417+
backward = np.array(
418+
[
419+
[0.0, 1.0, 0.0, 0.0],
420+
[0.0, 0.0, -1.0, 0.0],
421+
[-1.0, 0.0, 0.0, 0.0],
422+
[0.0, 0.0, 0.0, 1.0],
423+
]
424+
)
425+
input_dict["lidar2cam"] = [input_dict["lidar2cam"][0], backward]
426+
input_dict["camera_intrinsics"] = [input_dict["camera_intrinsics"][0]] * 2
427+
input_dict["gt_boxes"] = np.array(
428+
[
429+
[10.0, 0.0, 0.0, 2.0, 2.0, 2.0, 0.0, 0.0, 0.0],
430+
[-10.0, 0.0, 0.0, 2.0, 2.0, 2.0, 0.0, 0.0, 0.0],
431+
],
432+
dtype=np.float32,
433+
)
434+
input_dict["gt_labels"] = np.array([0, 1])
435+
result = LoadAnnotations2DFromBoxes3D()(input_dict)
436+
assert result["gt_labels_2d"][0].tolist() == [0]
437+
assert result["gt_labels_2d"][1].tolist() == [1]
438+
439+
440+
def test_load_annotations_2d_handles_empty_and_invalid_gt() -> None:
441+
input_dict = _pinhole_setup()
442+
input_dict["gt_boxes"] = np.zeros((0, 9), dtype=np.float32)
443+
input_dict["gt_labels"] = np.zeros((0,), dtype=np.int64)
444+
result = LoadAnnotations2DFromBoxes3D()(input_dict)
445+
assert result["gt_bboxes_2d"][0].shape == (0, 4)
446+
assert result["centers_2d"][0].shape == (0, 2)
447+
448+
input_dict["gt_boxes"] = np.zeros((3, 5), dtype=np.float32)
449+
input_dict["gt_labels"] = np.zeros((3,), dtype=np.int64)
450+
with pytest.raises(ValueError, match="gt_boxes"):
451+
LoadAnnotations2DFromBoxes3D()(input_dict)
452+
453+
454+
def test_multiview_loader_shuffle_order_keeps_sample_consistent(tmp_path) -> None:
455+
import random
456+
457+
import cv2
458+
459+
from autoware_ml.transforms.camera.loading import LoadMultiViewImagesFromFiles
460+
461+
camera_order = [f"CAM_{i}" for i in range(5)]
462+
images_meta = {}
463+
for index, name in enumerate(camera_order):
464+
path = tmp_path / f"{name}.png"
465+
cv2.imwrite(str(path), np.full((4, 6, 3), index * 10, dtype=np.uint8))
466+
intrinsics = np.eye(3, dtype=np.float32) * (index + 1)
467+
images_meta[name] = {
468+
"img_path": str(path),
469+
"cam2img": intrinsics,
470+
"lidar2cam": np.eye(4, dtype=np.float32),
471+
}
472+
473+
loader = LoadMultiViewImagesFromFiles(normalize_to_unit=False, shuffle_order=True)
474+
random.seed(3)
475+
shuffled_seen = False
476+
for _ in range(8):
477+
out = loader({"images": images_meta, "camera_order": camera_order})
478+
names = out["camera_names"]
479+
assert sorted(names) == sorted(camera_order)
480+
if names != camera_order:
481+
shuffled_seen = True
482+
for position, name in enumerate(names):
483+
index = camera_order.index(name)
484+
# Image content and intrinsics must follow the shuffled order.
485+
assert float(out["img"][position].mean()) == index * 10
486+
assert out["camera_intrinsics"][position][0, 0] == index + 1
487+
assert shuffled_seen
488+
489+
fixed_loader = LoadMultiViewImagesFromFiles(normalize_to_unit=False)
490+
out = fixed_loader({"images": images_meta, "camera_order": camera_order})
491+
assert out["camera_names"] == camera_order

autoware_ml/tests/transforms/test_geometry3d.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import numpy as np
2727
import pytest
2828

29+
from autoware_ml.transforms import geometry3d as g3d
2930
from autoware_ml.transforms.camera import geometry as cam
3031
from autoware_ml.transforms.camera_lidar import geometry as cam_lidar
3132
from autoware_ml.transforms.point_cloud import geometry as pc
@@ -102,6 +103,67 @@ def test_random_flip_shared_math_matches_across_namespaces(seed: int) -> None:
102103
assert np.allclose(out_cam["points"], _sample()["points"])
103104

104105

106+
def _ego_pose() -> np.ndarray:
107+
# Non-trivial lidar->global pose: yaw + translation.
108+
yaw = 0.7
109+
pose = np.eye(4, dtype=np.float64)
110+
pose[:2, :2] = [[np.cos(yaw), -np.sin(yaw)], [np.sin(yaw), np.cos(yaw)]]
111+
pose[:3, 3] = [10.0, -4.0, 1.2]
112+
return pose
113+
114+
115+
@pytest.mark.parametrize("transform_cls", ["rot_scale_trans", "flip"])
116+
def test_camera_transforms_fold_augmentation_into_ego_poses(transform_cls: str) -> None:
117+
"""After augmentation, ego_pose must map *augmented* lidar coords to the
118+
same global point as before (StreamPETR warps temporal memory through it)."""
119+
sample = _sample()
120+
sample["ego_pose"] = _ego_pose()
121+
sample["ego_pose_inv"] = np.linalg.inv(sample["ego_pose"])
122+
point_lidar = np.array([3.0, -2.0, 0.5, 1.0])
123+
point_global = sample["ego_pose"] @ point_lidar
124+
125+
np.random.seed(3)
126+
if transform_cls == "rot_scale_trans":
127+
out = cam.GlobalRotScaleTrans(
128+
rot_range=[-0.5, 0.5], scale_ratio_range=[0.9, 1.1], translation_std=[0.5, 0.5, 0.2]
129+
)(sample)
130+
augmentation = out["global_aug_matrix"]
131+
else:
132+
out = cam.RandomFlip3D(flip_ratio_bev_horizontal=1.0, flip_ratio_bev_vertical=1.0)(sample)
133+
augmentation = out["bev_flip_matrix"]
134+
135+
point_augmented = augmentation @ point_lidar
136+
assert np.allclose(out["ego_pose"] @ point_augmented, point_global, atol=1e-5)
137+
assert np.allclose(out["ego_pose_inv"] @ point_global, point_augmented, atol=1e-5)
138+
# The pair stays mutually inverse.
139+
assert np.allclose(out["ego_pose"] @ out["ego_pose_inv"], np.eye(4), atol=1e-5)
140+
141+
142+
def test_camera_and_camera_lidar_fold_ego_poses_identically() -> None:
143+
def _sample_with_pose() -> dict:
144+
sample = _sample()
145+
sample["ego_pose"] = _ego_pose()
146+
sample["ego_pose_inv"] = np.linalg.inv(sample["ego_pose"])
147+
return sample
148+
149+
kwargs = dict(rot_range=[-0.5, 0.5], scale_ratio_range=[0.9, 1.1])
150+
np.random.seed(11)
151+
out_cam = cam.GlobalRotScaleTrans(**kwargs)(_sample_with_pose())
152+
np.random.seed(11)
153+
out_cl = cam_lidar.GlobalRotScaleTrans(**kwargs)(_sample_with_pose())
154+
assert np.allclose(out_cam["ego_pose"], out_cl["ego_pose"])
155+
assert np.allclose(out_cam["ego_pose_inv"], out_cl["ego_pose_inv"])
156+
157+
158+
def test_transform_boxes_scales_velocity_with_the_world() -> None:
159+
input_dict = {
160+
"gt_boxes": np.array([[1.0, 2.0, 0.0, 4.0, 2.0, 1.0, 0.3, 1.5, -0.5]], dtype=np.float32)
161+
}
162+
identity = np.eye(3, dtype=np.float32)
163+
g3d.transform_boxes(input_dict, identity, 0.0, 2.0, np.zeros((1, 3), dtype=np.float32))
164+
assert np.allclose(input_dict["gt_boxes"][0, 7:9], [3.0, -1.0])
165+
166+
105167
def test_point_cloud_requires_a_point_representation() -> None:
106168
with pytest.raises(KeyError):
107169
pc.GlobalRotScaleTrans(rot_range=[0.1, 0.1], scale_ratio_range=[1.0, 1.0])(

0 commit comments

Comments
 (0)