|
18 | 18 | import numpy.typing as npt |
19 | 19 | import pytest |
20 | 20 |
|
| 21 | +from autoware_ml.transforms.camera.annotations2d import LoadAnnotations2DFromBoxes3D |
21 | 22 | from autoware_ml.transforms.camera.distortion import UndistortImage |
22 | 23 | from autoware_ml.transforms.camera.masking import GridMask |
23 | 24 | from autoware_ml.transforms.camera.normalize import NormalizeMultiviewImage |
@@ -281,3 +282,210 @@ def test_grid_mask_handles_chw_stack() -> None: |
281 | 282 |
|
282 | 283 | assert output["img"].shape == images.shape |
283 | 284 | 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 |
0 commit comments