From 7a1e1ccf3f0602e66bc4842c5a9b5e96c65dcd8e Mon Sep 17 00:00:00 2001 From: SikongJueluo Date: Sun, 31 May 2026 14:29:05 +0800 Subject: [PATCH] feat(scenegraph): add depth-based 3D positioning via pinhole projection - Add bbox_depth_center position strategy in SceneGraphBuilder using depth at bbox centre and configurable camera_hfov_degrees for pinhole projection. - Add optional depth_sensor_uuid to HabitatSimulatorConfig; create depth sensor spec alongside RGB sensor. - Add camera_position/camera_rotation fields to RoomView; capture pose from sensor_states when depth sensor is available. - Update flatten_room_views for backward compatibility with legacy tuple format. - Wired in depth sensor and bbox_depth_center strategy in verification notebook. - Add tests for depth sensor support and new position strategies. --- mini-nav/scenegraph/builder.py | 269 ++++- mini-nav/simulator/habitat.py | 56 +- mini-nav/simulator/views.py | 49 +- notebooks/verification.py | 49 +- tests/test_habitat_depth_sensor.py | 183 ++++ tests/test_notebook_verification_static.py | 67 +- tests/test_scenegraph_builder.py | 1094 ++++++++++++++++++++ 7 files changed, 1729 insertions(+), 38 deletions(-) create mode 100644 tests/test_habitat_depth_sensor.py diff --git a/mini-nav/scenegraph/builder.py b/mini-nav/scenegraph/builder.py index 3af53a6..c3cc7ef 100644 --- a/mini-nav/scenegraph/builder.py +++ b/mini-nav/scenegraph/builder.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from math import radians, tan from typing import Any, Literal, Sequence import numpy as np @@ -18,31 +19,43 @@ class SceneGraphBuildConfig: """Configuration for the scene graph builder. Attributes: - position_strategy: How object positions are assigned. Only 'room_center' - is supported in M0. + position_strategy: How object positions are assigned. + 'room_center' uses the room's center point. + 'bbox_depth_center' uses depth at the bbox center to compute + a 3D position via pinhole projection. inference_batch_size: Batch size passed to the pipeline. enable_fusion: Whether to fuse overlapping detections (not yet supported). fusion_hash_similarity_threshold: Hash similarity threshold for fusion. fusion_distance_threshold_m: Distance threshold in meters for fusion. + camera_hfov_degrees: Horizontal field of view of the camera in degrees. + Only used when position_strategy='bbox_depth_center'. """ - position_strategy: Literal["room_center"] = "room_center" + position_strategy: Literal["room_center", "bbox_depth_center"] = "room_center" inference_batch_size: int = 4 enable_fusion: bool = False fusion_hash_similarity_threshold: float = 0.95 fusion_distance_threshold_m: float = 0.5 + camera_hfov_degrees: float = 90.0 def __post_init__(self) -> None: - if self.position_strategy != "room_center": + valid_strategies = {"room_center", "bbox_depth_center"} + if self.position_strategy not in valid_strategies: raise ValueError( - f"position_strategy must be 'room_center', got {self.position_strategy!r}" + f"position_strategy must be one of {valid_strategies}, " + f"got {self.position_strategy!r}" + ) + if not 0 < self.camera_hfov_degrees < 180: + raise ValueError( + f"camera_hfov_degrees must be in (0, 180), " + f"got {self.camera_hfov_degrees}" ) if self.inference_batch_size <= 0: raise ValueError( f"inference_batch_size must be positive, got {self.inference_batch_size}" ) if self.enable_fusion: - raise ValueError("fusion is not yet supported in M0") + raise ValueError("fusion is not yet supported in M0/M1") @dataclass @@ -247,10 +260,15 @@ class SceneGraphBuilder: meta=meta, selected_idx=selected_idx, ) + position, position_confidence = self._position_for_detection( + view=view, + room=room, + bbox_xyxy=bbox_xyxy, + ) return ObjectNode( obj_id=obj_id, room_id=view.room_id, - position=room.center.copy(), + position=position, visual_hash=hash_bytes, semantic_hash=hash_bytes, hit_count=1, @@ -259,7 +277,7 @@ class SceneGraphBuilder: confidence=confidence, bbox_xyxy=bbox_xyxy, source_view_id=self._source_view_id(view), - position_confidence=0.0, + position_confidence=position_confidence, ) def _metadata_for_detection( @@ -287,7 +305,234 @@ class SceneGraphBuilder: ) return values[selected_idx] - def _normalize_bbox(self, box: Any) -> tuple[float, float, float, float]: - if len(box) != 4: - raise ValueError(f"bbox entry has length {len(box)}, expected 4") - return tuple(float(x) for x in box) + def _normalize_bbox(self, box: Any) -> tuple[float, float, float, float] | None: + try: + if len(box) != 4: + return None + result = tuple(float(x) for x in box) + if not all(np.isfinite(result)): + return None + return result + except (TypeError, ValueError): + return None + + def _position_for_detection( + self, + *, + view: Any, + room: RoomNode, + bbox_xyxy: tuple[float, float, float, float] | None, + ) -> tuple[np.ndarray, float]: + """Compute object position and confidence based on the configured strategy. + + Returns: + A tuple of (position: np.ndarray (3,), confidence: float). + """ + if self._config.position_strategy == "room_center": + return room.center.copy(), 0.0 + + # bbox_depth_center strategy + if bbox_xyxy is not None: + position = self._compute_bbox_depth_center_position(view, bbox_xyxy) + if position is not None: + return position, 1.0 + + # Fallback: room center with 0.0 confidence + return room.center.copy(), 0.0 + + def _compute_bbox_depth_center_position( + self, + view: Any, + bbox_xyxy: tuple[float, float, float, float], + ) -> np.ndarray | None: + """Compute 3D world position from depth at the bbox centre. + + Uses the configured camera_hfov_degrees for pinhole projection. + Prefers camera pose (camera_position/camera_rotation) when available + on the view; falls back to room center + 0.0 confidence when camera + pose is absent (agent pose alone is insufficient because the Habitat + depth sensor is offset from the agent body by sensor_height). + + Returns: + A (3,) float32 position array, or None if computation fails. + """ + depth = getattr(view, "depth", None) + + if depth is None: + return None + + # Prefer camera pose when available. Agent pose alone is insufficient + # because the Habitat depth sensor is offset [0.0, sensor_height, 0.0] + # from the agent body, and we have no way to recover the offset. + camera_position = getattr(view, "camera_position", None) + camera_rotation = getattr(view, "camera_rotation", None) + + if camera_position is not None and camera_rotation is not None: + origin_position = camera_position + origin_rotation = camera_rotation + else: + # No camera pose – cannot confidently place in world space. + return None + + # Coerce depth to ndarray; require 2D + try: + depth = np.asarray(depth) + except (ValueError, TypeError): + return None + if depth.ndim != 2: + return None + + height, width = depth.shape + + # Validate bbox values are finite before rounding + try: + x1, y1, x2, y2 = map(float, bbox_xyxy) + except (ValueError, TypeError): + return None + if not all(np.isfinite(v) for v in (x1, y1, x2, y2)): + return None + + u = int(round((x1 + x2) / 2.0)) + v = int(round((y1 + y2) / 2.0)) + + # Out of bounds check + if not (0 <= u < width and 0 <= v < height): + return None + + depth_value = depth[v, u] + + # Depth must be finite and positive + if not np.isfinite(depth_value) or depth_value <= 0: + return None + + # Convert rotation to matrix + rotation_matrix = self._rotation_to_matrix(origin_rotation) + if rotation_matrix is None: + return None + + # Coerce origin_position safely + try: + origin_pos = np.asarray(origin_position, dtype=np.float32).reshape(3) + except (ValueError, TypeError, RuntimeError): + return None + if not np.all(np.isfinite(origin_pos)): + return None + + # Pinhole projection + camera_point = self._pixel_depth_to_camera_point( + u=u, v=v, depth=depth_value, width=width, height=height, + ) + + # World position + world_position = origin_pos + rotation_matrix @ camera_point + + if not np.all(np.isfinite(world_position)): + return None + + return np.asarray(world_position, dtype=np.float32) + + def _pixel_depth_to_camera_point( + self, + u: int, + v: int, + depth: float, + width: int, + height: int, + ) -> np.ndarray: + """Convert a pixel with depth to a camera-space 3D point via pinhole model. + + M1 assumes Habitat camera forward is local -Z; this convention should be + validated with a real Habitat sanity check. + + Args: + u: Pixel column (x) coordinate. + v: Pixel row (y) coordinate. + depth: Depth value at the pixel (positive, finite). + width: Image width in pixels. + height: Image height in pixels. + + Returns: + A (3,) float32 array [x, y, z] in camera space. + """ + hfov = radians(self._config.camera_hfov_degrees) + fx = width / (2.0 * tan(hfov / 2.0)) + fy = fx + cx = (width - 1) / 2.0 + cy = (height - 1) / 2.0 + + x = (u - cx) / fx * depth + y = -(v - cy) / fy * depth + z = -depth + + return np.array([x, y, z], dtype=np.float32) + + def _rotation_to_matrix( + self, + rotation: Any, + ) -> np.ndarray | None: + """Convert a rotation object to a (3, 3) float32 rotation matrix. + + Supports: + - Objects with a ``.rotation_matrix`` attribute (e.g. habitat_sim style). + - ndarray or array-like objects directly convertible to (3, 3). + - ``numpy-quaternion`` quaternion objects (optional dependency; uses + ``importlib`` to load ``quaternion.as_rotation_matrix``). + - Objects with a ``.transform_vector()`` method (quaternion-like); + constructs the matrix columns by transforming local basis vectors. + + Returns: + A (3, 3) float32 matrix, or None if conversion fails. + """ + try: + # Path a: object with .rotation_matrix attribute + if hasattr(rotation, "rotation_matrix"): + mat = self._validate_matrix(rotation.rotation_matrix) + if mat is not None: + return mat + return None + + # Path b: direct ndarray or array-like (3, 3) + mat = self._validate_matrix(rotation) + if mat is not None: + return mat + + # Path c: numpy-quaternion quaternion object (optional dependency) + try: + import importlib as _il + qmod = _il.import_module("quaternion") + if hasattr(qmod, "as_rotation_matrix"): + mat = self._validate_matrix(qmod.as_rotation_matrix(rotation)) + if mat is not None: + return mat + except (ImportError, AttributeError, TypeError, ValueError): + pass + + # Path d: quaternion-like with .transform_vector method + if hasattr(rotation, "transform_vector"): + eye = np.eye(3, dtype=np.float32) + cols: list[np.ndarray] = [] + for i in range(3): + col_vec = rotation.transform_vector(eye[:, i]) + col = np.asarray(col_vec, dtype=np.float32).ravel() + if col.shape != (3,) or not np.all(np.isfinite(col)): + return None + cols.append(col) + return np.column_stack(cols) + + return None + except (ValueError, TypeError, RuntimeError): + return None + + @staticmethod + def _validate_matrix(mat: Any) -> np.ndarray | None: + """Validate and convert a candidate to a (3, 3) float32 matrix. + + Returns the matrix if it is (3, 3) with all finite values, else None. + """ + try: + mat = np.asarray(mat, dtype=np.float32) + if mat.shape == (3, 3) and np.all(np.isfinite(mat)): + return mat + except (ValueError, TypeError, RuntimeError): + pass + return None diff --git a/mini-nav/simulator/habitat.py b/mini-nav/simulator/habitat.py index ec15fce..a5a527f 100644 --- a/mini-nav/simulator/habitat.py +++ b/mini-nav/simulator/habitat.py @@ -15,6 +15,26 @@ class HabitatSimulatorConfig: enable_physics: bool = False sensor_uuid: str = "color_sensor" agent_id: int = 0 + depth_sensor_uuid: str | None = "depth_sensor" + hfov_degrees: float = 90.0 + + +def _camera_sensor_spec( + habitat_sim_module: Any, + *, + uuid: str, + sensor_type: Any, + image_size: int, + sensor_height: float, + hfov_degrees: float, +) -> Any: + spec = habitat_sim_module.CameraSensorSpec() + spec.uuid = uuid + spec.sensor_type = sensor_type + spec.resolution = [image_size, image_size] + spec.position = [0.0, sensor_height, 0.0] + spec.hfov = float(hfov_degrees) + return spec def create_habitat_simulator( @@ -30,6 +50,11 @@ def create_habitat_simulator( if config.move_forward_step <= 0: raise ValueError("move_forward_step must be greater than 0") + if not (0 < config.hfov_degrees < 180): + raise ValueError( + f"hfov_degrees must be in (0, 180), got {config.hfov_degrees}" + ) + if habitat_sim_module is None: habitat_sim_module = import_module("habitat_sim") @@ -38,12 +63,31 @@ def create_habitat_simulator( sim_cfg.enable_physics = config.enable_physics agent_cfg = habitat_sim_module.agent.AgentConfiguration() - rgb_sensor_spec = habitat_sim_module.CameraSensorSpec() - rgb_sensor_spec.uuid = config.sensor_uuid - rgb_sensor_spec.sensor_type = habitat_sim_module.SensorType.COLOR - rgb_sensor_spec.resolution = [config.image_size, config.image_size] - rgb_sensor_spec.position = [0.0, config.sensor_height, 0.0] - agent_cfg.sensor_specifications = [rgb_sensor_spec] + + sensor_specs = [ + _camera_sensor_spec( + habitat_sim_module, + uuid=config.sensor_uuid, + sensor_type=habitat_sim_module.SensorType.COLOR, + image_size=config.image_size, + sensor_height=config.sensor_height, + hfov_degrees=config.hfov_degrees, + ), + ] + + if config.depth_sensor_uuid is not None: + sensor_specs.append( + _camera_sensor_spec( + habitat_sim_module, + uuid=config.depth_sensor_uuid, + sensor_type=habitat_sim_module.SensorType.DEPTH, + image_size=config.image_size, + sensor_height=config.sensor_height, + hfov_degrees=config.hfov_degrees, + ), + ) + + agent_cfg.sensor_specifications = sensor_specs turn_angle = 360.0 / config.views_per_room agent_cfg.action_space = { diff --git a/mini-nav/simulator/views.py b/mini-nav/simulator/views.py index 8fefbd5..043e38c 100644 --- a/mini-nav/simulator/views.py +++ b/mini-nav/simulator/views.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy from dataclasses import dataclass, field from importlib import import_module from pathlib import Path @@ -20,16 +21,32 @@ class RoomView: depth: Any | None = field(default=None, compare=False) agent_position: np.ndarray | None = field(default=None, compare=False) agent_rotation: Any | None = field(default=None, compare=False) + camera_position: Any | None = field(default=None, compare=False) + camera_rotation: Any | None = field(default=None, compare=False) def flatten_room_views(room_views_by_room: RoomViewsByRoom) -> list[RoomView]: result: list[RoomView] = [] for room_id, views in room_views_by_room.items(): - for idx, rgb in enumerate(views): - result.append(RoomView(room_id=room_id, view_idx=idx, rgb=rgb)) + for idx, item in enumerate(views): + if isinstance(item, RoomView): + result.append(item) + else: + result.append(RoomView(room_id=room_id, view_idx=idx, rgb=item)) return result +def _snapshot_rotation(rotation: Any) -> Any: + """Create an independent copy of *rotation* to prevent mutation aliasing. + + If the value exposes a ``.copy()`` method (e.g. numpy arrays) it is + preferred; otherwise a stdlib shallow copy is used as a fallback. + """ + if hasattr(rotation, "copy") and callable(rotation.copy): + return rotation.copy() + return copy.copy(rotation) + + def collect_room_views_by_room( agent: Any, sim: Any, @@ -38,6 +55,7 @@ def collect_room_views_by_room( *, habitat_sim_module: Any | None = None, sensor_uuid: str = "color_sensor", + depth_sensor_uuid: str | None = None, turn_action: str = "turn_left", progress_description: str = "Collecting room views", progress_track: ProgressTrack = track, @@ -55,9 +73,32 @@ def collect_room_views_by_room( agent.set_state(agent_state) room_views = [] - for _ in range(views_per_room): + for view_idx in range(views_per_room): observations = sim.get_sensor_observations() - room_views.append(observations[sensor_uuid]) + current_state = agent.get_state() + agent_position = np.asarray(current_state.position, dtype=np.float32).copy() + + # Capture camera pose from sensor_states when depth sensor is available + camera_position = None + camera_rotation = None + if depth_sensor_uuid is not None: + sensor_states = getattr(current_state, "sensor_states", None) + if sensor_states is not None and depth_sensor_uuid in sensor_states: + sensor_state = sensor_states[depth_sensor_uuid] + camera_position = np.asarray(sensor_state.position, dtype=np.float32).copy() + camera_rotation = _snapshot_rotation(sensor_state.rotation) + + room_view = RoomView( + room_id=room_node.room_id, + view_idx=view_idx, + rgb=observations[sensor_uuid], + depth=observations[depth_sensor_uuid] if depth_sensor_uuid is not None else None, + agent_position=agent_position, + agent_rotation=_snapshot_rotation(current_state.rotation), + camera_position=camera_position, + camera_rotation=camera_rotation, + ) + room_views.append(room_view) sim.step(turn_action) all_room_views[room_node.room_id] = room_views diff --git a/notebooks/verification.py b/notebooks/verification.py index b53ed14..b601734 100644 --- a/notebooks/verification.py +++ b/notebooks/verification.py @@ -24,10 +24,9 @@ def base_dependencies(): """Basic dependencies for data processing.""" import numpy as np import polars as pl - import torch from PIL import Image - return Image, np, pl, torch + return Image, np, pl @app.cell @@ -99,16 +98,15 @@ def habitat_setup(HabitatSimulatorConfig, RoomNode, create_habitat_simulator, np views_per_room = 12 meters_per_pixel = 0.05 - sim, agent = create_habitat_simulator( - HabitatSimulatorConfig( - scene_path=_scene_path, - views_per_room=views_per_room, - image_size=_image_size, - sensor_height=1.5, - move_forward_step=0.25, - enable_physics=False, - ) + habitat_config = HabitatSimulatorConfig( + scene_path=_scene_path, + views_per_room=views_per_room, + image_size=_image_size, + sensor_height=1.5, + move_forward_step=0.25, + enable_physics=False, ) + sim, agent = create_habitat_simulator(habitat_config) room_nodes = [] for idx in range(_num_rooms): @@ -125,7 +123,7 @@ def habitat_setup(HabitatSimulatorConfig, RoomNode, create_habitat_simulator, np for _node in room_nodes: print(f" {_node.room_id}: {_node.center}") - return agent, meters_per_pixel, room_nodes, sim, views_per_room + return agent, habitat_config, meters_per_pixel, room_nodes, sim, views_per_room @app.cell @@ -164,6 +162,7 @@ def collect_views( agent, collect_room_views_by_room, flatten_room_views, + habitat_config, numpy_to_pil, room_nodes, sim, @@ -174,6 +173,7 @@ def collect_views( sim=sim, room_nodes=room_nodes, views_per_room=views_per_room, + depth_sensor_uuid=habitat_config.depth_sensor_uuid, ) room_views = flatten_room_views(all_room_views) @@ -193,6 +193,7 @@ def build_scene_graph( SceneGraphBuildConfig, SceneGraphBuilder, cfg_manager, + habitat_config, mo, numpy_to_pil, pipeline, @@ -207,7 +208,11 @@ def build_scene_graph( builder = SceneGraphBuilder( pipeline=pipeline, - config=SceneGraphBuildConfig(inference_batch_size=4), + config=SceneGraphBuildConfig( + inference_batch_size=4, + position_strategy="bbox_depth_center", + camera_hfov_degrees=habitat_config.hfov_degrees, + ), ) pil_room_views = [ @@ -218,6 +223,8 @@ def build_scene_graph( depth=_view.depth, agent_position=_view.agent_position, agent_rotation=_view.agent_rotation, + camera_position=_view.camera_position, + camera_rotation=_view.camera_rotation, ) for _view in room_views ] @@ -303,6 +310,8 @@ def build_tables(pl, scene_graph): "position_x": float(obj.position[0]) if obj.position is not None else None, "position_y": float(obj.position[1]) if obj.position is not None else None, "position_z": float(obj.position[2]) if obj.position is not None else None, + "bbox_xyxy": str(obj.bbox_xyxy) if obj.bbox_xyxy is not None else None, + "position_confidence": obj.position_confidence, } for obj in scene_graph.objects.values() ] @@ -313,6 +322,19 @@ def build_tables(pl, scene_graph): return objects_df, rooms_df +@app.cell +def display_tables(mo, objects_df, rooms_df): + mo.vstack( + [ + mo.md("## Rooms"), + mo.ui.table(rooms_df), + mo.md("## Objects"), + mo.ui.table(objects_df), + ] + ) + return + + @app.cell def upload_query(mo): file_upload = mo.ui.file( @@ -329,7 +351,6 @@ def query_matching( Image, file_upload, mo, - object_images, pipeline, query_image_against_scene_graph, scene_graph, diff --git a/tests/test_habitat_depth_sensor.py b/tests/test_habitat_depth_sensor.py new file mode 100644 index 0000000..760a231 --- /dev/null +++ b/tests/test_habitat_depth_sensor.py @@ -0,0 +1,183 @@ +"""Tests for optional Habitat depth sensor support in create_habitat_simulator.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +MINI_NAV_DIR = Path(__file__).resolve().parents[1] / "mini-nav" +sys.path.insert(0, str(MINI_NAV_DIR)) + +from simulator.habitat import ( # noqa: E402 + HabitatSimulatorConfig, + create_habitat_simulator, +) + +# --------------------------------------------------------------------------- +# Fake habitat_sim module +# --------------------------------------------------------------------------- + +_specs_created: list = [] + + +class _FakeCameraSensorSpec: + """Fake CameraSensorSpec that records itself in _specs_created.""" + + def __init__(self) -> None: + self.uuid = None + self.sensor_type = None + self.resolution = None + self.position = None + self.hfov = None + _specs_created.append(self) + + +class _FakeSensorType: + COLOR = "COLOR" + DEPTH = "DEPTH" + + +class _FakeSimulator: + def __init__(self, cfg: object) -> None: + self.cfg = cfg + + @staticmethod + def initialize_agent(agent_id: int) -> SimpleNamespace: + return SimpleNamespace(agent_id=agent_id) + + @staticmethod + def close() -> None: + return None + + +def _make_fake_habitat_sim_module() -> SimpleNamespace: + """Return a fake ``habitat_sim`` module for testing.""" + global _specs_created + _specs_created = [] + + hab_sim = SimpleNamespace( + SimulatorConfiguration=SimpleNamespace, + CameraSensorSpec=_FakeCameraSensorSpec, + SensorType=_FakeSensorType, + Configuration=lambda sim_cfg, agent_cfgs: SimpleNamespace( + sim_cfg=sim_cfg, + agent_cfgs=agent_cfgs, + ), + Simulator=_FakeSimulator, + ) + + class _FakeActionSpec: + def __init__(self, name: str, actuation: object) -> None: + self.name = name + self.actuation = actuation + + class _FakeActuationSpec: + def __init__(self, amount: float | None = None) -> None: + self.amount = amount + + hab_sim.agent = SimpleNamespace( + AgentConfiguration=SimpleNamespace, + ActionSpec=_FakeActionSpec, + ActuationSpec=_FakeActuationSpec, + ) + + return hab_sim + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_habitat_sim() -> SimpleNamespace: + """Fixture providing a fresh fake habitat_sim module per test.""" + return _make_fake_habitat_sim_module() + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_default_config_creates_color_and_depth_sensor_specs( + fake_habitat_sim: SimpleNamespace, +) -> None: + """Default config should produce two specs with expected UUIDs and types.""" + config = HabitatSimulatorConfig(scene_path="scene.glb") + create_habitat_simulator(config, fake_habitat_sim) + + assert len(_specs_created) == 2 + assert [s.uuid for s in _specs_created] == ["color_sensor", "depth_sensor"] + assert [s.sensor_type for s in _specs_created] == ["COLOR", "DEPTH"] + + +def test_create_habitat_simulator_adds_matching_rgb_and_depth_specs( + fake_habitat_sim: SimpleNamespace, +) -> None: + """Depth sensor spec should mirror the RGB spec when depth_sensor_uuid is set.""" + config = HabitatSimulatorConfig( + scene_path="scene.glb", + image_size=320, + sensor_height=1.25, + sensor_uuid="rgb", + depth_sensor_uuid="depth", + hfov_degrees=75.0, + ) + + create_habitat_simulator(config, fake_habitat_sim) + + assert len(_specs_created) == 2, "Expected 2 sensor specs (RGB + depth)" + + rgb_spec, depth_spec = _specs_created + + # UUIDs + assert rgb_spec.uuid == "rgb" + assert depth_spec.uuid == "depth" + + # Sensor types + assert rgb_spec.sensor_type == "COLOR" + assert depth_spec.sensor_type == "DEPTH" + + # Resolutions + assert rgb_spec.resolution == [320, 320] + assert depth_spec.resolution == [320, 320] + + # Positions + assert rgb_spec.position == [0.0, 1.25, 0.0] + assert depth_spec.position == [0.0, 1.25, 0.0] + + # HFOV + assert rgb_spec.hfov == 75.0 + assert depth_spec.hfov == 75.0 + + +def test_create_habitat_simulator_can_disable_depth_sensor( + fake_habitat_sim: SimpleNamespace, +) -> None: + """``depth_sensor_uuid=None`` should produce only a single color sensor spec.""" + config = HabitatSimulatorConfig( + scene_path="scene.glb", + depth_sensor_uuid=None, + ) + + create_habitat_simulator(config, fake_habitat_sim) + + assert len(_specs_created) == 1 + assert _specs_created[0].uuid == "color_sensor" + + +def test_habitat_config_rejects_invalid_hfov( + fake_habitat_sim: SimpleNamespace, +) -> None: + """``hfov_degrees=0`` should raise ``ValueError`` containing ``hfov_degrees``.""" + config = HabitatSimulatorConfig( + scene_path="scene.glb", + hfov_degrees=0.0, + ) + + with pytest.raises(ValueError, match="hfov_degrees"): + create_habitat_simulator(config, fake_habitat_sim) diff --git a/tests/test_notebook_verification_static.py b/tests/test_notebook_verification_static.py index 2aea43d..7046853 100644 --- a/tests/test_notebook_verification_static.py +++ b/tests/test_notebook_verification_static.py @@ -1,11 +1,23 @@ from __future__ import annotations +import ast from pathlib import Path NOTEBOOK = Path(__file__).resolve().parents[1] / "notebooks" / "verification.py" +def _find_calls(tree: ast.AST, func_name: str) -> list[ast.Call]: + """Find all Call nodes for a named function (simple name, not attribute).""" + return [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == func_name + ] + + def test_verification_notebook_uses_scenegraph_query_api(): source = NOTEBOOK.read_text() @@ -20,7 +32,6 @@ def test_verification_notebook_uses_hash_codec_for_object_hashes(): # the builder handles it internally. assert "bits_tensor_to_hash_bytes" not in source assert "np.packbits" not in source - assert "scene_graph.objects[_obj_id] = ObjectNode" not in source def test_verification_notebook_uses_scene_graph_builder(): @@ -30,4 +41,56 @@ def test_verification_notebook_uses_scene_graph_builder(): assert "SceneGraphBuildConfig" in source assert "flatten_room_views" in source assert "scene_graph, build_artifacts = builder.build_from_room_views" in source - assert "scene_graph.objects[_obj_id] = ObjectNode" not in source + + # Verify no direct ObjectNode construction (builder handles it internally) + tree = ast.parse(source) + object_node_calls = _find_calls(tree, "ObjectNode") + assert len(object_node_calls) == 0, ( + "Notebook should not construct ObjectNode() directly; " + "the builder handles it" + ) + + +def test_verification_notebook_uses_bbox_depth_center_positioning(): + source = NOTEBOOK.read_text() + tree = ast.parse(source) + + # --- SceneGraphBuildConfig must use position_strategy='bbox_depth_center' --- + sg_config_calls = _find_calls(tree, "SceneGraphBuildConfig") + assert len(sg_config_calls) >= 1, ( + "Notebook must instantiate SceneGraphBuildConfig" + ) + config_kwargs = { + kw.arg: kw.value for kw in sg_config_calls[0].keywords if kw.arg is not None + } + + assert "position_strategy" in config_kwargs + pos_val = config_kwargs["position_strategy"] + assert isinstance(pos_val, ast.Constant) and pos_val.value == "bbox_depth_center", ( + f"Expected position_strategy='bbox_depth_center', got {pos_val!r}" + ) + + assert "camera_hfov_degrees" in config_kwargs, ( + "SceneGraphBuildConfig should include camera_hfov_degrees" + ) + + # --- object_rows dict literals must include bbox_xyxy and position_confidence --- + found_bbox = False + found_pos_conf = False + for node in ast.walk(tree): + if isinstance(node, ast.Dict): + keys = {k.value for k in node.keys if isinstance(k, ast.Constant)} + if "obj_id" in keys: + if "bbox_xyxy" in keys: + found_bbox = True + if "position_confidence" in keys: + found_pos_conf = True + + assert found_bbox, "object_rows must include bbox_xyxy column" + assert found_pos_conf, "object_rows must include position_confidence column" + + # --- No direct ObjectNode( construction --- + object_node_calls = _find_calls(tree, "ObjectNode") + assert len(object_node_calls) == 0, ( + "Notebook should not construct ObjectNode() directly" + ) diff --git a/tests/test_scenegraph_builder.py b/tests/test_scenegraph_builder.py index 86a75d7..63480ec 100644 --- a/tests/test_scenegraph_builder.py +++ b/tests/test_scenegraph_builder.py @@ -477,3 +477,1097 @@ def test_builder_raises_on_debug_meta_length_mismatch(): room_views=room_views, text_labels=[], ) + + +# --------------------------------------------------------------------------- +# Scene graph depth positioning M1 — depth + pose capture in RoomView +# --------------------------------------------------------------------------- + + +class FakeAgentState: + """Fake agent state with position, rotation, and optional sensor_states.""" + + def __init__(self, position=None, rotation=None, sensor_states=None): + self.position = position if position is not None else np.array([0.0, 0.0, 0.0], dtype=np.float32) + self.rotation = rotation if rotation is not None else np.eye(3, dtype=np.float32) + self.sensor_states = sensor_states + + +class FakeHabitatModuleForViews: + """Fake habitat_sim module exposing AgentState.""" + + AgentState = FakeAgentState + + +class FakeAgentForViews: + """Fake agent that tracks set_state and returns copied state from get_state.""" + + def __init__(self): + self._state = FakeAgentState() + self.set_state_calls = [] + + def set_state(self, state): + # Preserve sensor_states from the old state when the new state does not + # have any. This mimics real habitat behaviour: creating a bare + # AgentState() and calling set_state() does not destroy the sim's + # sensor configuration. + if state.sensor_states is None and hasattr(self, "_state"): + old_ss = getattr(self._state, "sensor_states", None) + if old_ss is not None: + state.sensor_states = old_ss + self._state = state + self.set_state_calls.append(state) + + def get_state(self): + import copy + return copy.copy(self._state) + + +class FakeSimForViews: + """Fake simulator returning observation dicts and recording steps.""" + + def __init__(self, observations_by_step=None): + self.observations_by_step = observations_by_step or {} + self.steps = [] + + def get_sensor_observations(self): + step_key = len(self.steps) + if step_key in self.observations_by_step: + return self.observations_by_step[step_key] + return {"color_sensor": np.zeros((4, 4, 3), dtype=np.uint8), + "depth_sensor": np.zeros((4, 4), dtype=np.float32)} + + def step(self, action): + self.steps.append(action) + + +def test_collect_room_views_by_room_populates_depth_and_pose(): + """collect_room_views_by_room with depth_sensor_uuid should populate RoomView depth/pose fields.""" + from types import SimpleNamespace + + from scenegraph import RoomNode + from simulator import RoomView, collect_room_views_by_room + + rgb = np.ones((4, 4, 3), dtype=np.uint8) * 127 + depth = np.full((4, 4), 0.5, dtype=np.float32) + + room_node = RoomNode(room_id="room_a", center=np.array([4, 5, 6], dtype=np.float32), bbox_extent=[2, 2, 2]) + room_nodes = [room_node] + + sim = FakeSimForViews(observations_by_step={ + 0: {"color_sensor": rgb, "depth_sensor": depth}, + }) + + agent_pos = np.array([4, 5, 6], dtype=np.float32) + camera_pos = np.array([4.0, 6.5, 6.0], dtype=np.float32) # sensor offset [0, 1.5, 0] + camera_rot = np.eye(3, dtype=np.float32) + + agent = FakeAgentForViews() + agent.set_state(FakeAgentState( + position=agent_pos.copy(), + rotation=np.eye(3, dtype=np.float32), + sensor_states={ + "depth_sensor": SimpleNamespace( + position=camera_pos.copy(), + rotation=camera_rot.copy(), + ), + }, + )) + + result = collect_room_views_by_room( + agent=agent, + sim=sim, + room_nodes=room_nodes, + views_per_room=1, + habitat_sim_module=FakeHabitatModuleForViews, + sensor_uuid="color_sensor", + depth_sensor_uuid="depth_sensor", + turn_action="turn_left", + progress_track=lambda x, desc: x, + ) + + room_views = result["room_a"] + assert len(room_views) == 1 + + item = room_views[0] + assert isinstance(item, RoomView) + assert item.rgb is rgb + assert item.depth is depth + np.testing.assert_array_equal(item.agent_position, agent_pos) + np.testing.assert_array_equal(item.agent_rotation, np.eye(3, dtype=np.float32)) + # Camera fields populated from sensor_states + np.testing.assert_array_equal(item.camera_position, camera_pos) + np.testing.assert_array_equal(item.camera_rotation, camera_rot) + assert sim.steps == ["turn_left"] + + +def test_collect_room_views_by_room_snapshots_pose_independently(): + """Collected RoomViews must have independent snapshots of agent_rotation (not aliased).""" + from scenegraph import RoomNode + from simulator import RoomView, collect_room_views_by_room + + rgb = np.ones((4, 4, 3), dtype=np.uint8) * 127 + depth = np.full((4, 4), 0.5, dtype=np.float32) + + room_node = RoomNode( + room_id="room_a", + center=np.array([1.0, 2.0, 3.0], dtype=np.float32), + bbox_extent=[2, 2, 2], + ) + + class _MutatingFakeSim(FakeSimForViews): + """Fake sim that mutates agent rotation in-place on step (like real habitat).""" + + def __init__(self, agent, observations_by_step=None): + super().__init__(observations_by_step) + self.agent = agent + + def step(self, action): + super().step(action) + # In-place mutation – simulates habitat_sim rotating the agent's state + step_idx = len(self.steps) + self.agent._state.rotation[:] = np.full_like( + self.agent._state.rotation, float(step_idx) + ) + + agent = FakeAgentForViews() + sim = _MutatingFakeSim( + agent=agent, + observations_by_step={ + 0: {"color_sensor": rgb, "depth_sensor": depth}, + 1: {"color_sensor": rgb, "depth_sensor": depth}, + }, + ) + + result = collect_room_views_by_room( + agent=agent, + sim=sim, + room_nodes=[room_node], + views_per_room=2, + habitat_sim_module=FakeHabitatModuleForViews, + sensor_uuid="color_sensor", + depth_sensor_uuid="depth_sensor", + turn_action="turn_left", + progress_track=lambda x, desc: x, + ) + + room_views = result["room_a"] + assert len(room_views) == 2 + + rv0, rv1 = room_views[0], room_views[1] + + # Each snapshot should reflect rotation *at capture time* + # – view 0 captured before any step → identity + identity = np.eye(3, dtype=np.float32) + np.testing.assert_array_equal(rv0.agent_rotation, identity) + + # – view 1 captured after one in-place mutation → all 1.0 + expected_mutated = np.full((3, 3), 1.0, dtype=np.float32) + np.testing.assert_array_equal(rv1.agent_rotation, expected_mutated) + + # rv0 must NOT have been affected by the in-place mutation + np.testing.assert_array_equal(rv0.agent_rotation, identity) + + # Positions are also independent snapshots + expected_pos = np.array([1.0, 2.0, 3.0], dtype=np.float32) + np.testing.assert_array_equal(rv0.agent_position, expected_pos) + np.testing.assert_array_equal(rv1.agent_position, expected_pos) + + # Post-hoc mutation of one snapshot must not affect the other + rv0.agent_rotation[:] = 99.0 + np.testing.assert_array_equal(rv1.agent_rotation, expected_mutated) + + +def test_collect_room_views_by_room_snapshots_camera_pose_independently(): + """Camera pose from sensor_states must be independently snapshotted (not aliased).""" + from types import SimpleNamespace + + from scenegraph import RoomNode + from simulator import RoomView, collect_room_views_by_room + + rgb = np.ones((4, 4, 3), dtype=np.uint8) * 127 + depth = np.full((4, 4), 0.5, dtype=np.float32) + + room_node = RoomNode( + room_id="room_a", + center=np.array([1.0, 2.0, 3.0], dtype=np.float32), + bbox_extent=[2, 2, 2], + ) + + agent_pos = np.array([1.0, 2.0, 3.0], dtype=np.float32) + camera_pos_base = np.array([1.0, 3.5, 3.0], dtype=np.float32) # sensor at +1.5 Y + camera_rot_base = np.eye(3, dtype=np.float32) + + class _MutatingCameraSim(FakeSimForViews): + """Fake sim that mutates sensor_states rotation in-place on step.""" + + def __init__(self, agent, observations_by_step=None): + super().__init__(observations_by_step) + self.agent = agent + + def step(self, action): + super().step(action) + # Mutate the sensor rotation in-place – like real habitat might + step_idx = len(self.steps) + ds = self.agent._state.sensor_states["depth_sensor"] + ds.rotation[:] = np.full_like(ds.rotation, float(step_idx)) + + agent = FakeAgentForViews() + agent._state = FakeAgentState( + position=agent_pos.copy(), + rotation=np.eye(3, dtype=np.float32), + sensor_states={ + "depth_sensor": SimpleNamespace( + position=camera_pos_base.copy(), + rotation=camera_rot_base.copy(), + ), + }, + ) + + sim = _MutatingCameraSim( + agent=agent, + observations_by_step={ + 0: {"color_sensor": rgb, "depth_sensor": depth}, + 1: {"color_sensor": rgb, "depth_sensor": depth}, + }, + ) + + result = collect_room_views_by_room( + agent=agent, + sim=sim, + room_nodes=[room_node], + views_per_room=2, + habitat_sim_module=FakeHabitatModuleForViews, + sensor_uuid="color_sensor", + depth_sensor_uuid="depth_sensor", + turn_action="turn_left", + progress_track=lambda x, desc: x, + ) + + room_views = result["room_a"] + assert len(room_views) == 2 + + rv0, rv1 = room_views[0], room_views[1] + + # view 0 captured before any step → identity rotation + np.testing.assert_array_equal(rv0.camera_rotation, np.eye(3, dtype=np.float32)) + + # view 1 captured after one in-place mutation → all 1.0 + expected_mutated = np.full((3, 3), 1.0, dtype=np.float32) + np.testing.assert_array_equal(rv1.camera_rotation, expected_mutated) + + # rv0 rotation must NOT have been affected by in-place mutation + np.testing.assert_array_equal(rv0.camera_rotation, np.eye(3, dtype=np.float32)) + + # Both camera positions are the same (position not mutated in this test) + np.testing.assert_array_equal(rv0.camera_position, camera_pos_base) + np.testing.assert_array_equal(rv1.camera_position, camera_pos_base) + + # Post-hoc mutation of one snapshot must not affect the other + rv0.camera_rotation[:] = 99.0 + np.testing.assert_array_equal(rv1.camera_rotation, expected_mutated) + + +def test_flatten_room_views_preserves_existing_room_view_payloads(): + """flatten_room_views should pass through existing RoomView instances unchanged.""" + from simulator import RoomView, flatten_room_views + + depth = np.full((4, 4), 0.5, dtype=np.float32) + agent_pos = np.array([1.0, 2.0, 3.0], dtype=np.float32) + + existing = RoomView( + room_id="room_a", + view_idx=0, + rgb=np.zeros((4, 4, 3), dtype=np.uint8), + depth=depth, + agent_position=agent_pos, + agent_rotation=np.eye(3, dtype=np.float32), + ) + + result = flatten_room_views({"room_a": [existing]}) + + assert len(result) == 1 + assert result[0] is existing + assert result[0].depth is depth + assert result[0].agent_position is agent_pos + + +# --------------------------------------------------------------------------- +# Scene graph depth positioning M1 — config + builder tests +# --------------------------------------------------------------------------- + + +def test_config_accepts_bbox_depth_center_strategy(): + """Config should accept position_strategy='bbox_depth_center' with valid camera_hfov.""" + from scenegraph import SceneGraphBuildConfig + + config = SceneGraphBuildConfig( + position_strategy='bbox_depth_center', + camera_hfov_degrees=90.0, + ) + assert config.position_strategy == 'bbox_depth_center' + assert config.camera_hfov_degrees == 90.0 + + +def test_config_rejects_invalid_camera_hfov(): + """Config should reject camera_hfov_degrees outside (0, 180).""" + from scenegraph import SceneGraphBuildConfig + + with pytest.raises(ValueError, match="camera_hfov_degrees"): + SceneGraphBuildConfig(camera_hfov_degrees=180.0) + + with pytest.raises(ValueError, match="camera_hfov_degrees"): + SceneGraphBuildConfig(camera_hfov_degrees=0.0) + + with pytest.raises(ValueError, match="camera_hfov_degrees"): + SceneGraphBuildConfig(camera_hfov_degrees=-10.0) + + +def test_scene_graph_builder_uses_bbox_depth_center_position_with_identity_pose(): + """Bbox depth center with identity rotation should project depth correctly. + + Setup: + - 5x5 depth image, depth=2.0 everywhere + - bbox [2,2,2,2] → centre pixel (u=2, v=2) + - camera_position [1, 2, 3], identity rotation + - hfov=90° → fx = 5 / (2*tan(45°)) = 2.5, cx=2, cy=2 + + Expected camera point: [(2-2)/2.5*2, -(2-2)/2.5*2, -2] = [0, 0, -2] + Expected world position: [1, 2, 3] + [0, 0, -2] = [1, 2, 1] + """ + from scenegraph import RoomNode, SceneGraphBuilder, SceneGraphBuildConfig + from simulator import RoomView + + room_node = RoomNode(room_id="room_a", center=[10, 0, 20], bbox_extent=[5, 3, 5]) + room_nodes = [room_node] + + depth = np.full((5, 5), 2.0, dtype=np.float32) + rgb = np.zeros((5, 5, 3), dtype=np.uint8) + + cam_pos = np.array([1.0, 2.0, 3.0], dtype=np.float32) + cam_rot = np.eye(3, dtype=np.float32) + + room_view = RoomView( + room_id="room_a", + view_idx=0, + rgb=rgb, + depth=depth, + agent_position=cam_pos.copy(), + agent_rotation=cam_rot.copy(), + camera_position=cam_pos.copy(), + camera_rotation=cam_rot.copy(), + ) + room_views = [room_view] + + text_labels = ["a chair"] + + hash_bits = torch.zeros(1, 512, dtype=torch.uint8) + hash_bits[0, 3] = 1 + + crop = "crop_data_0" + + debug_meta_entry = { + "selected_indices": [0], + "boxes_xyxy": [[2.0, 2.0, 2.0, 2.0]], + "scores": [0.91], + "labels": ["a chair"], + "masks": [np.zeros((5, 5), dtype=np.uint8)], + "num_selected": 1, + } + + output = SimpleNamespace( + cropped_images=[crop], + hash_bits=hash_bits, + debug_meta=[debug_meta_entry], + ) + + pipeline = FakePipeline(output=output, calls=[]) + config = SceneGraphBuildConfig( + position_strategy='bbox_depth_center', + camera_hfov_degrees=90.0, + ) + builder = SceneGraphBuilder(pipeline=pipeline, config=config) + + graph, artifacts = builder.build_from_room_views( + room_nodes=room_nodes, + room_views=room_views, + text_labels=text_labels, + ) + + obj = graph.objects["room_a_v000_m00"] + np.testing.assert_array_almost_equal( + obj.position, + np.array([1.0, 2.0, 1.0], dtype=np.float32), + ) + assert obj.position_confidence == 1.0 + + +# --------------------------------------------------------------------------- +# Camera pose with non-zero sensor height +# --------------------------------------------------------------------------- + + +def test_bbox_depth_center_uses_camera_pose_with_sensor_height(): + """Bbox depth center with camera pose at non-zero sensor height. + + Demonstrates that the camera's sensor_height offset (Y direction) is + correctly honored: the depth back-projection uses camera_position + (which includes [0, sensor_height, 0]) instead of agent_position + (which does not). + + Setup: + - camera_position [1, 3.5, 3] (e.g. agent at [1, 2, 3] + sensor_height 1.5) + - identity rotation + - depth=2.0, hfov=90°, image 5x5, bbox [2,2,2,2] + + Expected camera point: [0, 0, -2] + Expected world position: [1, 3.5, 3] + [0, 0, -2] = [1, 3.5, 1] + (NOT [1, 2, 0] which would result from using agent_position [1,2,3]) + """ + from scenegraph import RoomNode, SceneGraphBuilder, SceneGraphBuildConfig + from simulator import RoomView + + room_node = RoomNode(room_id="room_a", center=[10, 0, 20], bbox_extent=[5, 3, 5]) + room_nodes = [room_node] + + depth = np.full((5, 5), 2.0, dtype=np.float32) + rgb = np.zeros((5, 5, 3), dtype=np.uint8) + + room_view = RoomView( + room_id="room_a", + view_idx=0, + rgb=rgb, + depth=depth, + agent_position=np.array([1.0, 2.0, 3.0], dtype=np.float32), # agent body + agent_rotation=np.eye(3, dtype=np.float32), + camera_position=np.array([1.0, 3.5, 3.0], dtype=np.float32), # sensor at +1.5 Y + camera_rotation=np.eye(3, dtype=np.float32), + ) + room_views = [room_view] + + text_labels = ["a chair"] + + hash_bits = torch.zeros(1, 512, dtype=torch.uint8) + hash_bits[0, 3] = 1 + + crop = "crop_data_0" + + debug_meta_entry = { + "selected_indices": [0], + "boxes_xyxy": [[2.0, 2.0, 2.0, 2.0]], + "scores": [0.91], + "labels": ["a chair"], + "masks": [np.zeros((5, 5), dtype=np.uint8)], + "num_selected": 1, + } + + output = SimpleNamespace( + cropped_images=[crop], + hash_bits=hash_bits, + debug_meta=[debug_meta_entry], + ) + + pipeline = FakePipeline(output=output, calls=[]) + config = SceneGraphBuildConfig( + position_strategy='bbox_depth_center', + camera_hfov_degrees=90.0, + ) + builder = SceneGraphBuilder(pipeline=pipeline, config=config) + + graph, artifacts = builder.build_from_room_views( + room_nodes=room_nodes, + room_views=room_views, + text_labels=text_labels, + ) + + obj = graph.objects["room_a_v000_m00"] + # [1, 3.5, 3] + [0, 0, -2] = [1, 3.5, 1] + np.testing.assert_array_almost_equal( + obj.position, + np.array([1.0, 3.5, 1.0], dtype=np.float32), + ) + assert obj.position_confidence == 1.0 + + +def test_bbox_depth_center_falls_back_without_camera_pose(): + """Bbox depth center with only agent_pose (no camera_pose) falls back to room center. + + When a RoomView has agent_position/agent_rotation but no + camera_position/camera_rotation, the builder must NOT use agent_pose + for depth positioning (because sensor offset is unknowable). The + position must fall back to room center with 0.0 confidence. + """ + from scenegraph import RoomNode, SceneGraphBuilder, SceneGraphBuildConfig + from simulator import RoomView + + room_node = RoomNode(room_id="room_a", center=[10, 0, 20], bbox_extent=[5, 3, 5]) + room_nodes = [room_node] + + depth = np.full((5, 5), 2.0, dtype=np.float32) + rgb = np.zeros((5, 5, 3), dtype=np.uint8) + + # Has agent_pose but NO camera_pose — builder must NOT use agent_pose for depth + room_view = RoomView( + room_id="room_a", + view_idx=0, + rgb=rgb, + depth=depth, + agent_position=np.array([1.0, 2.0, 3.0], dtype=np.float32), + agent_rotation=np.eye(3, dtype=np.float32), + ) + room_views = [room_view] + + text_labels = ["a chair"] + + hash_bits = torch.zeros(1, 512, dtype=torch.uint8) + hash_bits[0, 3] = 1 + + crop = "crop_data_0" + + debug_meta_entry = { + "selected_indices": [0], + "boxes_xyxy": [[2.0, 2.0, 2.0, 2.0]], + "scores": [0.91], + "labels": ["a chair"], + "masks": [np.zeros((5, 5), dtype=np.uint8)], + "num_selected": 1, + } + + output = SimpleNamespace( + cropped_images=[crop], + hash_bits=hash_bits, + debug_meta=[debug_meta_entry], + ) + + pipeline = FakePipeline(output=output, calls=[]) + config = SceneGraphBuildConfig(position_strategy='bbox_depth_center') + builder = SceneGraphBuilder(pipeline=pipeline, config=config) + + graph, artifacts = builder.build_from_room_views( + room_nodes=room_nodes, + room_views=room_views, + text_labels=text_labels, + ) + + obj = graph.objects["room_a_v000_m00"] + # Falls back to room center with 0.0 confidence + np.testing.assert_array_equal(obj.position, np.array([10, 0, 20], dtype=np.float32)) + assert obj.position_confidence == 0.0 + + +def test_scene_graph_builder_falls_back_to_room_center_without_depth_pose(): + """Bbox depth strategy without depth/pose should fall back to room center + 0.0 confidence.""" + from scenegraph import RoomNode, SceneGraphBuilder, SceneGraphBuildConfig + from simulator import RoomView + + room_node = RoomNode(room_id="room_a", center=[10, 0, 20], bbox_extent=[5, 3, 5]) + room_nodes = [room_node] + + rgb = np.zeros((32, 32, 3), dtype=np.uint8) + room_view = RoomView(room_id="room_a", view_idx=0, rgb=rgb) # no depth/pose + room_views = [room_view] + + text_labels = ["a chair"] + + hash_bits = torch.zeros(1, 512, dtype=torch.uint8) + hash_bits[0, 3] = 1 + + crop = "crop_data_0" + + debug_meta_entry = { + "selected_indices": [0], + "boxes_xyxy": [[10.0, 20.0, 30.0, 40.0]], + "scores": [0.91], + "labels": ["a chair"], + "masks": [np.zeros((32, 32), dtype=np.uint8)], + "num_selected": 1, + } + + output = SimpleNamespace( + cropped_images=[crop], + hash_bits=hash_bits, + debug_meta=[debug_meta_entry], + ) + + pipeline = FakePipeline(output=output, calls=[]) + config = SceneGraphBuildConfig(position_strategy='bbox_depth_center') + builder = SceneGraphBuilder(pipeline=pipeline, config=config) + + graph, artifacts = builder.build_from_room_views( + room_nodes=room_nodes, + room_views=room_views, + text_labels=text_labels, + ) + + obj = graph.objects["room_a_v000_m00"] + np.testing.assert_array_equal(obj.position, np.array([10, 0, 20], dtype=np.float32)) + assert obj.position_confidence == 0.0 + + +def test_scene_graph_builder_falls_back_on_nonfinite_depth(): + """Non-finite depth value should trigger fallback to room center + 0.0 confidence.""" + from scenegraph import RoomNode, SceneGraphBuilder, SceneGraphBuildConfig + from simulator import RoomView + + room_node = RoomNode(room_id="room_a", center=[10, 0, 20], bbox_extent=[5, 3, 5]) + room_nodes = [room_node] + + depth = np.full((5, 5), np.nan, dtype=np.float32) # non-finite depth + rgb = np.zeros((5, 5, 3), dtype=np.uint8) + + room_view = RoomView( + room_id="room_a", + view_idx=0, + rgb=rgb, + depth=depth, + agent_position=np.array([1.0, 2.0, 3.0], dtype=np.float32), + agent_rotation=np.eye(3, dtype=np.float32), + ) + room_views = [room_view] + + text_labels = ["a chair"] + + hash_bits = torch.zeros(1, 512, dtype=torch.uint8) + hash_bits[0, 3] = 1 + + crop = "crop_data_0" + + debug_meta_entry = { + "selected_indices": [0], + "boxes_xyxy": [[2.0, 2.0, 2.0, 2.0]], + "scores": [0.91], + "labels": ["a chair"], + "masks": [np.zeros((5, 5), dtype=np.uint8)], + "num_selected": 1, + } + + output = SimpleNamespace( + cropped_images=[crop], + hash_bits=hash_bits, + debug_meta=[debug_meta_entry], + ) + + pipeline = FakePipeline(output=output, calls=[]) + config = SceneGraphBuildConfig(position_strategy='bbox_depth_center') + builder = SceneGraphBuilder(pipeline=pipeline, config=config) + + graph, artifacts = builder.build_from_room_views( + room_nodes=room_nodes, + room_views=room_views, + text_labels=text_labels, + ) + + obj = graph.objects["room_a_v000_m00"] + np.testing.assert_array_equal(obj.position, np.array([10, 0, 20], dtype=np.float32)) + assert obj.position_confidence == 0.0 + + +def test_bbox_depth_center_falls_back_on_invalid_bbox_length(): + """Bbox with wrong length should fall back to room center + 0.0 confidence.""" + from scenegraph import RoomNode, SceneGraphBuilder, SceneGraphBuildConfig + from simulator import RoomView + + room_node = RoomNode(room_id="room_a", center=[10, 0, 20], bbox_extent=[5, 3, 5]) + room_nodes = [room_node] + + depth = np.full((5, 5), 2.0, dtype=np.float32) + rgb = np.zeros((5, 5, 3), dtype=np.uint8) + + room_view = RoomView( + room_id="room_a", + view_idx=0, + rgb=rgb, + depth=depth, + agent_position=np.array([1.0, 2.0, 3.0], dtype=np.float32), + agent_rotation=np.eye(3, dtype=np.float32), + ) + room_views = [room_view] + + text_labels = ["a chair"] + hash_bits = torch.zeros(1, 512, dtype=torch.uint8) + hash_bits[0, 3] = 1 + crop = "crop_data_0" + + # bbox with length 2 instead of 4 + debug_meta_entry = { + "selected_indices": [0], + "boxes_xyxy": [[10.0, 20.0]], + "scores": [0.91], + "labels": ["a chair"], + "masks": [np.zeros((5, 5), dtype=np.uint8)], + "num_selected": 1, + } + + output = SimpleNamespace( + cropped_images=[crop], + hash_bits=hash_bits, + debug_meta=[debug_meta_entry], + ) + + pipeline = FakePipeline(output=output, calls=[]) + config = SceneGraphBuildConfig(position_strategy='bbox_depth_center') + builder = SceneGraphBuilder(pipeline=pipeline, config=config) + + graph, artifacts = builder.build_from_room_views( + room_nodes=room_nodes, + room_views=room_views, + text_labels=text_labels, + ) + + obj = graph.objects["room_a_v000_m00"] + np.testing.assert_array_equal(obj.position, np.array([10, 0, 20], dtype=np.float32)) + assert obj.position_confidence == 0.0 + + +def test_bbox_depth_center_falls_back_on_nonfinite_bbox(): + """Bbox with non-finite values should fall back to room center + 0.0 confidence.""" + from scenegraph import RoomNode, SceneGraphBuilder, SceneGraphBuildConfig + from simulator import RoomView + + room_node = RoomNode(room_id="room_a", center=[10, 0, 20], bbox_extent=[5, 3, 5]) + room_nodes = [room_node] + + depth = np.full((5, 5), 2.0, dtype=np.float32) + rgb = np.zeros((5, 5, 3), dtype=np.uint8) + + room_view = RoomView( + room_id="room_a", + view_idx=0, + rgb=rgb, + depth=depth, + agent_position=np.array([1.0, 2.0, 3.0], dtype=np.float32), + agent_rotation=np.eye(3, dtype=np.float32), + ) + room_views = [room_view] + + text_labels = ["a chair"] + hash_bits = torch.zeros(1, 512, dtype=torch.uint8) + hash_bits[0, 3] = 1 + crop = "crop_data_0" + + # bbox with NaN value + debug_meta_entry = { + "selected_indices": [0], + "boxes_xyxy": [[float('nan'), 2.0, 2.0, 2.0]], + "scores": [0.91], + "labels": ["a chair"], + "masks": [np.zeros((5, 5), dtype=np.uint8)], + "num_selected": 1, + } + + output = SimpleNamespace( + cropped_images=[crop], + hash_bits=hash_bits, + debug_meta=[debug_meta_entry], + ) + + pipeline = FakePipeline(output=output, calls=[]) + config = SceneGraphBuildConfig(position_strategy='bbox_depth_center') + builder = SceneGraphBuilder(pipeline=pipeline, config=config) + + graph, artifacts = builder.build_from_room_views( + room_nodes=room_nodes, + room_views=room_views, + text_labels=text_labels, + ) + + obj = graph.objects["room_a_v000_m00"] + np.testing.assert_array_equal(obj.position, np.array([10, 0, 20], dtype=np.float32)) + assert obj.position_confidence == 0.0 + + +def test_bbox_depth_center_falls_back_on_invalid_depth(): + """Depth without ndim attribute (e.g. plain list) should fall back.""" + from scenegraph import RoomNode, SceneGraphBuilder, SceneGraphBuildConfig + from simulator import RoomView + + room_node = RoomNode(room_id="room_a", center=[10, 0, 20], bbox_extent=[5, 3, 5]) + room_nodes = [room_node] + + # Plain list — no .ndim attribute; old code would AttributeError + depth = [1.0, 2.0] + rgb = np.zeros((5, 5, 3), dtype=np.uint8) + + room_view = RoomView( + room_id="room_a", + view_idx=0, + rgb=rgb, + depth=depth, + agent_position=np.array([1.0, 2.0, 3.0], dtype=np.float32), + agent_rotation=np.eye(3, dtype=np.float32), + ) + room_views = [room_view] + + text_labels = ["a chair"] + hash_bits = torch.zeros(1, 512, dtype=torch.uint8) + hash_bits[0, 3] = 1 + crop = "crop_data_0" + + debug_meta_entry = { + "selected_indices": [0], + "boxes_xyxy": [[2.0, 2.0, 2.0, 2.0]], + "scores": [0.91], + "labels": ["a chair"], + "masks": [np.zeros((5, 5), dtype=np.uint8)], + "num_selected": 1, + } + + output = SimpleNamespace( + cropped_images=[crop], + hash_bits=hash_bits, + debug_meta=[debug_meta_entry], + ) + + pipeline = FakePipeline(output=output, calls=[]) + config = SceneGraphBuildConfig(position_strategy='bbox_depth_center') + builder = SceneGraphBuilder(pipeline=pipeline, config=config) + + graph, artifacts = builder.build_from_room_views( + room_nodes=room_nodes, + room_views=room_views, + text_labels=text_labels, + ) + + obj = graph.objects["room_a_v000_m00"] + np.testing.assert_array_equal(obj.position, np.array([10, 0, 20], dtype=np.float32)) + assert obj.position_confidence == 0.0 + + +def test_bbox_depth_center_falls_back_on_invalid_agent_position(): + """Agent position with wrong shape should fall back to room center.""" + from scenegraph import RoomNode, SceneGraphBuilder, SceneGraphBuildConfig + from simulator import RoomView + + room_node = RoomNode(room_id="room_a", center=[10, 0, 20], bbox_extent=[5, 3, 5]) + room_nodes = [room_node] + + depth = np.full((5, 5), 2.0, dtype=np.float32) + rgb = np.zeros((5, 5, 3), dtype=np.uint8) + + # Position with shape (2,) — can't reshape to (3,) + room_view = RoomView( + room_id="room_a", + view_idx=0, + rgb=rgb, + depth=depth, + agent_position=np.array([1.0, 2.0]), + agent_rotation=np.eye(3, dtype=np.float32), + ) + room_views = [room_view] + + text_labels = ["a chair"] + hash_bits = torch.zeros(1, 512, dtype=torch.uint8) + hash_bits[0, 3] = 1 + crop = "crop_data_0" + + debug_meta_entry = { + "selected_indices": [0], + "boxes_xyxy": [[2.0, 2.0, 2.0, 2.0]], + "scores": [0.91], + "labels": ["a chair"], + "masks": [np.zeros((5, 5), dtype=np.uint8)], + "num_selected": 1, + } + + output = SimpleNamespace( + cropped_images=[crop], + hash_bits=hash_bits, + debug_meta=[debug_meta_entry], + ) + + pipeline = FakePipeline(output=output, calls=[]) + config = SceneGraphBuildConfig(position_strategy='bbox_depth_center') + builder = SceneGraphBuilder(pipeline=pipeline, config=config) + + graph, artifacts = builder.build_from_room_views( + room_nodes=room_nodes, + room_views=room_views, + text_labels=text_labels, + ) + + obj = graph.objects["room_a_v000_m00"] + np.testing.assert_array_equal(obj.position, np.array([10, 0, 20], dtype=np.float32)) + assert obj.position_confidence == 0.0 + + +def test_bbox_depth_center_uses_quaternion_like_rotation(): + """Rotation with transform_vector (quaternion-like) using non-identity mapping. + + FakeQuaternionRotation implements a 90° Y-rotation: + basis x → z, basis y → y, basis z → -x + → matrix = [[0, 0, -1], [0, 1, 0], [1, 0, 0]] + + Camera point (u=2, v=2, depth=2.0): [0, 0, -2] + Rotated: [2, 0, 0] + World: [1, 2, 3] + [2, 0, 0] = [3, 2, 3] + """ + from scenegraph import RoomNode, SceneGraphBuilder, SceneGraphBuildConfig + from simulator import RoomView + + class FakeQuaternionRotation: + def transform_vector(self, v): + v = np.asarray(v, dtype=np.float32).ravel() + # 90° Y-rotation: x→[0,0,1], y→[0,1,0], z→[-1,0,0] + if np.allclose(v, [1, 0, 0]): + return np.array([0, 0, 1], dtype=np.float32) + if np.allclose(v, [0, 1, 0]): + return np.array([0, 1, 0], dtype=np.float32) + if np.allclose(v, [0, 0, 1]): + return np.array([-1, 0, 0], dtype=np.float32) + return v.copy() + + room_node = RoomNode(room_id="room_a", center=[10, 0, 20], bbox_extent=[5, 3, 5]) + room_nodes = [room_node] + + depth = np.full((5, 5), 2.0, dtype=np.float32) + rgb = np.zeros((5, 5, 3), dtype=np.uint8) + + room_view = RoomView( + room_id="room_a", + view_idx=0, + rgb=rgb, + depth=depth, + agent_position=np.array([1.0, 2.0, 3.0], dtype=np.float32), + agent_rotation=FakeQuaternionRotation(), + camera_position=np.array([1.0, 2.0, 3.0], dtype=np.float32), + camera_rotation=FakeQuaternionRotation(), + ) + room_views = [room_view] + + text_labels = ["a chair"] + + hash_bits = torch.zeros(1, 512, dtype=torch.uint8) + hash_bits[0, 3] = 1 + + crop = "crop_data_0" + + debug_meta_entry = { + "selected_indices": [0], + "boxes_xyxy": [[2.0, 2.0, 2.0, 2.0]], + "scores": [0.91], + "labels": ["a chair"], + "masks": [np.zeros((5, 5), dtype=np.uint8)], + "num_selected": 1, + } + + output = SimpleNamespace( + cropped_images=[crop], + hash_bits=hash_bits, + debug_meta=[debug_meta_entry], + ) + + pipeline = FakePipeline(output=output, calls=[]) + config = SceneGraphBuildConfig( + position_strategy='bbox_depth_center', + camera_hfov_degrees=90.0, + ) + builder = SceneGraphBuilder(pipeline=pipeline, config=config) + + graph, artifacts = builder.build_from_room_views( + room_nodes=room_nodes, + room_views=room_views, + text_labels=text_labels, + ) + + obj = graph.objects["room_a_v000_m00"] + np.testing.assert_array_almost_equal( + obj.position, + np.array([3.0, 2.0, 3.0], dtype=np.float32), + ) + assert obj.position_confidence == 1.0 + + + + +def test_bbox_depth_center_uses_numpy_quaternion_rotation(monkeypatch): + """quaternion.as_rotation_matrix path works via monkeypatched quaternion module. + + Monkeypatches sys.modules['quaternion'] with a fake module where + as_rotation_matrix returns a non-identity 3x3 for a sentinel quaternion object. + + Rotation: 90° Y-rotation → matrix = [[0,0,-1],[0,1,0],[1,0,0]] + Camera point (u=2, v=2, depth=2.0): [0, 0, -2] + Rotated: [2, 0, 0] + World: [1, 2, 3] + [2, 0, 0] = [3, 2, 3] + """ + import types + + from scenegraph import RoomNode, SceneGraphBuilder, SceneGraphBuildConfig + from simulator import RoomView + + class FakeQuaternion: + """Sentinel quaternion object — no .rotation_matrix or transform_vector.""" + pass + + sentinel_q = FakeQuaternion() + + fake_module = types.ModuleType("quaternion") + + def as_rotation_matrix(q): + assert q is sentinel_q + return np.array([[0, 0, -1], + [0, 1, 0], + [1, 0, 0]], dtype=np.float32) + + fake_module.as_rotation_matrix = as_rotation_matrix + monkeypatch.setitem(sys.modules, "quaternion", fake_module) + + room_node = RoomNode(room_id="room_a", center=[10, 0, 20], bbox_extent=[5, 3, 5]) + room_nodes = [room_node] + + depth = np.full((5, 5), 2.0, dtype=np.float32) + rgb = np.zeros((5, 5, 3), dtype=np.uint8) + + room_view = RoomView( + room_id="room_a", + view_idx=0, + rgb=rgb, + depth=depth, + agent_position=np.array([1.0, 2.0, 3.0], dtype=np.float32), + agent_rotation=sentinel_q, + camera_position=np.array([1.0, 2.0, 3.0], dtype=np.float32), + camera_rotation=sentinel_q, + ) + room_views = [room_view] + + text_labels = ["a chair"] + + hash_bits = torch.zeros(1, 512, dtype=torch.uint8) + hash_bits[0, 3] = 1 + + crop = "crop_data_0" + + debug_meta_entry = { + "selected_indices": [0], + "boxes_xyxy": [[2.0, 2.0, 2.0, 2.0]], + "scores": [0.91], + "labels": ["a chair"], + "masks": [np.zeros((5, 5), dtype=np.uint8)], + "num_selected": 1, + } + + output = SimpleNamespace( + cropped_images=[crop], + hash_bits=hash_bits, + debug_meta=[debug_meta_entry], + ) + + pipeline = FakePipeline(output=output, calls=[]) + config = SceneGraphBuildConfig( + position_strategy='bbox_depth_center', + camera_hfov_degrees=90.0, + ) + builder = SceneGraphBuilder(pipeline=pipeline, config=config) + + graph, artifacts = builder.build_from_room_views( + room_nodes=room_nodes, + room_views=room_views, + text_labels=text_labels, + ) + + obj = graph.objects["room_a_v000_m00"] + np.testing.assert_array_almost_equal( + obj.position, + np.array([3.0, 2.0, 3.0], dtype=np.float32), + ) + assert obj.position_confidence == 1.0