|
16 | 16 | """Rest everything follows.""" |
17 | 17 |
|
18 | 18 | import copy |
| 19 | +from collections.abc import Callable |
19 | 20 |
|
20 | 21 | import numpy as np |
21 | 22 | import pytest |
|
24 | 25 | import omni.replicator.core as rep |
25 | 26 | from pxr import Gf |
26 | 27 |
|
| 28 | +import isaaclab.cloner as lab_cloner |
27 | 29 | import isaaclab.sim as sim_utils |
| 30 | +from isaaclab.cloner import ClonePlan |
28 | 31 | from isaaclab.sensors.camera import Camera, CameraCfg |
29 | 32 | from isaaclab.sensors.ray_caster import MultiMeshRayCasterCamera, MultiMeshRayCasterCameraCfg, patterns |
30 | 33 | from isaaclab.sim import PinholeCameraCfg |
31 | 34 | from isaaclab.terrains.trimesh.utils import make_plane |
32 | 35 | from isaaclab.terrains.utils import create_prim_from_mesh |
33 | 36 |
|
| 37 | +from isaaclab_assets.robots.anymal import ANYMAL_C_CFG |
| 38 | +from isaaclab_assets.robots.spot import SPOT_CFG |
| 39 | + |
34 | 40 | # sample camera poses (quaternions in xyzw format) |
35 | 41 | POSITION = [2.5, 2.5, 2.5] |
36 | 42 | QUAT_ROS = [0.33985114, 0.82047325, -0.42470819, -0.17591989] |
@@ -433,6 +439,174 @@ def test_output_equal_to_usdcamera(setup_simulation, data_types): |
433 | 439 | del camera_usd, camera_warp |
434 | 440 |
|
435 | 441 |
|
| 442 | +@pytest.mark.isaacsim_ci |
| 443 | +def test_depth_output_equal_to_usd_camera_heterogeneous_scene(setup_simulation): |
| 444 | + """Compare ray-caster and USD depth cameras in a heterogeneous cloned scene. |
| 445 | +
|
| 446 | + The scene contains 16 environments with alternating Spot / ANYmal-C robot |
| 447 | + prototypes and alternating cube / sphere objects. The ray-caster consumes |
| 448 | + the same clone plan used to build the USD scene and should match the batched |
| 449 | + USD camera's stable ``distance_to_image_plane`` pixels for every environment. |
| 450 | + """ |
| 451 | + sim, dt, _ = setup_simulation |
| 452 | + num_envs = 16 |
| 453 | + stage = sim_utils.get_current_stage() |
| 454 | + env_fmt = "/World/envs/env_{}" |
| 455 | + env_ids = torch.arange(num_envs, dtype=torch.long, device=sim.device) |
| 456 | + env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=4.0, device=sim.device) |
| 457 | + |
| 458 | + sim_utils.create_prim("/World/envs", "Xform", stage=stage) |
| 459 | + for env_id, origin in enumerate(env_origins.cpu().tolist()): |
| 460 | + sim_utils.create_prim(env_fmt.format(env_id), "Xform", translation=tuple(origin), stage=stage) |
| 461 | + |
| 462 | + # Prototype rows: even environments use Spot + cube, odd environments use ANYmal-C + sphere. |
| 463 | + robot_mask = torch.zeros((2, num_envs), dtype=torch.bool, device=sim.device) |
| 464 | + robot_mask[0, 0::2] = True |
| 465 | + robot_mask[1, 1::2] = True |
| 466 | + object_mask = robot_mask.clone() |
| 467 | + |
| 468 | + spot_spawn = copy.deepcopy(SPOT_CFG.spawn) |
| 469 | + anymal_spawn = copy.deepcopy(ANYMAL_C_CFG.spawn) |
| 470 | + spot_spawn.func(env_fmt.format(0) + "/Robot", spot_spawn, translation=SPOT_CFG.init_state.pos) |
| 471 | + anymal_spawn.func(env_fmt.format(1) + "/Robot", anymal_spawn, translation=ANYMAL_C_CFG.init_state.pos) |
| 472 | + |
| 473 | + cube_cfg = sim_utils.CuboidCfg( |
| 474 | + size=(0.35, 0.25, 0.25), |
| 475 | + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.7, 0.2, 0.2)), |
| 476 | + ) |
| 477 | + sphere_cfg = sim_utils.SphereCfg( |
| 478 | + radius=0.18, |
| 479 | + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.2, 0.7)), |
| 480 | + ) |
| 481 | + cube_spawn = cube_cfg.func |
| 482 | + sphere_spawn = sphere_cfg.func |
| 483 | + assert isinstance(cube_spawn, Callable) |
| 484 | + assert isinstance(sphere_spawn, Callable) |
| 485 | + cube_spawn(env_fmt.format(0) + "/Object", cube_cfg, translation=(0.45, 0.0, 0.25)) |
| 486 | + sphere_spawn(env_fmt.format(1) + "/Object", sphere_cfg, translation=(0.45, 0.0, 0.25)) |
| 487 | + |
| 488 | + lab_cloner.usd_replicate( |
| 489 | + stage, |
| 490 | + [env_fmt.format(i) + f"/{asset_name}" for asset_name in ("Robot", "Object") for i in range(2)], |
| 491 | + [env_fmt + "/Robot", env_fmt + "/Robot", env_fmt + "/Object", env_fmt + "/Object"], |
| 492 | + env_ids, |
| 493 | + mask=torch.cat([robot_mask, object_mask], dim=0), |
| 494 | + ) |
| 495 | + sim.set_clone_plan( |
| 496 | + ClonePlan( |
| 497 | + sources=[ |
| 498 | + env_fmt.format(0) + "/Robot", |
| 499 | + env_fmt.format(1) + "/Robot", |
| 500 | + env_fmt.format(0) + "/Object", |
| 501 | + env_fmt.format(1) + "/Object", |
| 502 | + ], |
| 503 | + destinations=[ |
| 504 | + env_fmt + "/Robot", |
| 505 | + env_fmt + "/Robot", |
| 506 | + env_fmt + "/Object", |
| 507 | + env_fmt + "/Object", |
| 508 | + ], |
| 509 | + clone_mask=torch.cat([robot_mask, object_mask], dim=0), |
| 510 | + ) |
| 511 | + ) |
| 512 | + sim_utils.update_stage() |
| 513 | + |
| 514 | + height, width = 96, 128 |
| 515 | + camera_pattern_cfg = patterns.PinholeCameraPatternCfg( |
| 516 | + focal_length=24.0, |
| 517 | + horizontal_aperture=20.955, |
| 518 | + height=height, |
| 519 | + width=width, |
| 520 | + ) |
| 521 | + mesh_prim_paths = [ |
| 522 | + "/World/defaultGroundPlane", |
| 523 | + MultiMeshRayCasterCameraCfg.RaycastTargetCfg( |
| 524 | + prim_expr="/World/envs/env_.*/Object", |
| 525 | + track_mesh_transforms=False, |
| 526 | + ), |
| 527 | + MultiMeshRayCasterCameraCfg.RaycastTargetCfg( |
| 528 | + prim_expr="/World/envs/env_.*/Robot/.+", |
| 529 | + track_mesh_transforms=True, |
| 530 | + ), |
| 531 | + ] |
| 532 | + camera_cfg_warp = MultiMeshRayCasterCameraCfg( |
| 533 | + prim_path="/World/envs/env_.*/RayCasterCamera", |
| 534 | + mesh_prim_paths=mesh_prim_paths, |
| 535 | + update_period=0, |
| 536 | + debug_vis=False, |
| 537 | + pattern_cfg=camera_pattern_cfg, |
| 538 | + max_distance=25.0, |
| 539 | + data_types=["distance_to_image_plane"], |
| 540 | + depth_clipping_behavior="max", |
| 541 | + update_mesh_ids=True, |
| 542 | + ) |
| 543 | + camera_warp = MultiMeshRayCasterCamera(camera_cfg_warp) |
| 544 | + |
| 545 | + camera_cfg_usd = CameraCfg( |
| 546 | + height=height, |
| 547 | + width=width, |
| 548 | + prim_path="/World/envs/env_.*/UsdCamera", |
| 549 | + update_period=0, |
| 550 | + data_types=["distance_to_image_plane"], |
| 551 | + spawn=PinholeCameraCfg( |
| 552 | + focal_length=24.0, |
| 553 | + focus_distance=400.0, |
| 554 | + horizontal_aperture=20.955, |
| 555 | + clipping_range=(0.01, 25.0), |
| 556 | + ), |
| 557 | + ) |
| 558 | + camera_usd = Camera(camera_cfg_usd) |
| 559 | + |
| 560 | + sim.reset() |
| 561 | + sim.play() |
| 562 | + |
| 563 | + eyes = env_origins + torch.tensor((1.8, -2.5, 2.5), dtype=torch.float32, device=sim.device) |
| 564 | + targets = env_origins + torch.tensor((0.0, 0.0, 0.0), dtype=torch.float32, device=sim.device) |
| 565 | + camera_warp.set_world_poses_from_view(eyes=eyes, targets=targets) |
| 566 | + camera_usd.set_world_poses_from_view(eyes=eyes, targets=targets) |
| 567 | + |
| 568 | + for _ in range(5): |
| 569 | + sim.render() |
| 570 | + |
| 571 | + camera_usd.update(dt) |
| 572 | + camera_warp.update(dt) |
| 573 | + |
| 574 | + ray_depth = camera_warp.data.output["distance_to_image_plane"] |
| 575 | + usd_depth = camera_usd.data.output["distance_to_image_plane"] |
| 576 | + assert ray_depth.shape == (num_envs, height, width, 1) |
| 577 | + assert usd_depth.shape == ray_depth.shape |
| 578 | + depth_diff = (ray_depth - usd_depth).abs() |
| 579 | + mesh_ids = getattr(camera_warp.data, "image_mesh_ids", None) |
| 580 | + assert mesh_ids is not None |
| 581 | + assert torch.any(mesh_ids == 1), "Expected object pixels in the heterogeneous scene" |
| 582 | + assert torch.any(mesh_ids > 1), "Expected robot pixels in the heterogeneous scene" |
| 583 | + |
| 584 | + # The RTX and ray-cast backends can disagree by a pixel along complex robot |
| 585 | + # silhouettes. Compare the stable ground pixels after dilating object/robot |
| 586 | + # edges and depth discontinuities. |
| 587 | + target_mask = mesh_ids[..., 0] != 0 |
| 588 | + discontinuity_mask = torch.zeros_like(target_mask) |
| 589 | + for depth in (ray_depth, usd_depth): |
| 590 | + depth_image = depth[..., 0] |
| 591 | + discontinuity_mask[:, 1:, :] |= (depth_image[:, 1:, :] - depth_image[:, :-1, :]).abs() > 0.3 |
| 592 | + discontinuity_mask[:, :, 1:] |= (depth_image[:, :, 1:] - depth_image[:, :, :-1]).abs() > 0.3 |
| 593 | + edge_mask = target_mask | discontinuity_mask |
| 594 | + silhouette_mask = torch.nn.functional.max_pool2d( |
| 595 | + edge_mask[:, None, :, :].float(), kernel_size=21, stride=1, padding=10 |
| 596 | + ).to(dtype=torch.bool) |
| 597 | + stable_mask = ~silhouette_mask[:, 0, :, :, None] |
| 598 | + assert stable_mask.float().mean() > 0.7 |
| 599 | + stable_ray_depth = ray_depth[stable_mask] |
| 600 | + stable_usd_depth = usd_depth[stable_mask] |
| 601 | + stable_depth_diff = depth_diff[stable_mask] |
| 602 | + stable_close = torch.isclose(stable_ray_depth, stable_usd_depth, atol=5e-5, rtol=5e-6) |
| 603 | + assert stable_close.float().mean() > 0.999 |
| 604 | + assert torch.quantile(stable_depth_diff, 0.999) < 5.0e-5 |
| 605 | + assert torch.quantile(depth_diff, 0.99) < 5.0e-3 |
| 606 | + |
| 607 | + del camera_usd, camera_warp |
| 608 | + |
| 609 | + |
436 | 610 | @pytest.mark.isaacsim_ci |
437 | 611 | def test_output_equal_to_usdcamera_offset(setup_simulation): |
438 | 612 | """Test that ray caster camera output equals USD camera output with offset.""" |
|
0 commit comments