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.
This commit is contained in:
2026-05-31 14:29:05 +08:00
parent a127032e18
commit 7a1e1ccf3f
7 changed files with 1729 additions and 38 deletions

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from math import radians, tan
from typing import Any, Literal, Sequence from typing import Any, Literal, Sequence
import numpy as np import numpy as np
@@ -18,31 +19,43 @@ class SceneGraphBuildConfig:
"""Configuration for the scene graph builder. """Configuration for the scene graph builder.
Attributes: Attributes:
position_strategy: How object positions are assigned. Only 'room_center' position_strategy: How object positions are assigned.
is supported in M0. '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. inference_batch_size: Batch size passed to the pipeline.
enable_fusion: Whether to fuse overlapping detections (not yet supported). enable_fusion: Whether to fuse overlapping detections (not yet supported).
fusion_hash_similarity_threshold: Hash similarity threshold for fusion. fusion_hash_similarity_threshold: Hash similarity threshold for fusion.
fusion_distance_threshold_m: Distance threshold in meters 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 inference_batch_size: int = 4
enable_fusion: bool = False enable_fusion: bool = False
fusion_hash_similarity_threshold: float = 0.95 fusion_hash_similarity_threshold: float = 0.95
fusion_distance_threshold_m: float = 0.5 fusion_distance_threshold_m: float = 0.5
camera_hfov_degrees: float = 90.0
def __post_init__(self) -> None: 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( 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: if self.inference_batch_size <= 0:
raise ValueError( raise ValueError(
f"inference_batch_size must be positive, got {self.inference_batch_size}" f"inference_batch_size must be positive, got {self.inference_batch_size}"
) )
if self.enable_fusion: if self.enable_fusion:
raise ValueError("fusion is not yet supported in M0") raise ValueError("fusion is not yet supported in M0/M1")
@dataclass @dataclass
@@ -247,10 +260,15 @@ class SceneGraphBuilder:
meta=meta, meta=meta,
selected_idx=selected_idx, selected_idx=selected_idx,
) )
position, position_confidence = self._position_for_detection(
view=view,
room=room,
bbox_xyxy=bbox_xyxy,
)
return ObjectNode( return ObjectNode(
obj_id=obj_id, obj_id=obj_id,
room_id=view.room_id, room_id=view.room_id,
position=room.center.copy(), position=position,
visual_hash=hash_bytes, visual_hash=hash_bytes,
semantic_hash=hash_bytes, semantic_hash=hash_bytes,
hit_count=1, hit_count=1,
@@ -259,7 +277,7 @@ class SceneGraphBuilder:
confidence=confidence, confidence=confidence,
bbox_xyxy=bbox_xyxy, bbox_xyxy=bbox_xyxy,
source_view_id=self._source_view_id(view), source_view_id=self._source_view_id(view),
position_confidence=0.0, position_confidence=position_confidence,
) )
def _metadata_for_detection( def _metadata_for_detection(
@@ -287,7 +305,234 @@ class SceneGraphBuilder:
) )
return values[selected_idx] return values[selected_idx]
def _normalize_bbox(self, box: Any) -> tuple[float, float, float, float]: def _normalize_bbox(self, box: Any) -> tuple[float, float, float, float] | None:
try:
if len(box) != 4: if len(box) != 4:
raise ValueError(f"bbox entry has length {len(box)}, expected 4") return None
return tuple(float(x) for x in box) 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

View File

@@ -15,6 +15,26 @@ class HabitatSimulatorConfig:
enable_physics: bool = False enable_physics: bool = False
sensor_uuid: str = "color_sensor" sensor_uuid: str = "color_sensor"
agent_id: int = 0 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( def create_habitat_simulator(
@@ -30,6 +50,11 @@ def create_habitat_simulator(
if config.move_forward_step <= 0: if config.move_forward_step <= 0:
raise ValueError("move_forward_step must be greater than 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: if habitat_sim_module is None:
habitat_sim_module = import_module("habitat_sim") habitat_sim_module = import_module("habitat_sim")
@@ -38,12 +63,31 @@ def create_habitat_simulator(
sim_cfg.enable_physics = config.enable_physics sim_cfg.enable_physics = config.enable_physics
agent_cfg = habitat_sim_module.agent.AgentConfiguration() agent_cfg = habitat_sim_module.agent.AgentConfiguration()
rgb_sensor_spec = habitat_sim_module.CameraSensorSpec()
rgb_sensor_spec.uuid = config.sensor_uuid sensor_specs = [
rgb_sensor_spec.sensor_type = habitat_sim_module.SensorType.COLOR _camera_sensor_spec(
rgb_sensor_spec.resolution = [config.image_size, config.image_size] habitat_sim_module,
rgb_sensor_spec.position = [0.0, config.sensor_height, 0.0] uuid=config.sensor_uuid,
agent_cfg.sensor_specifications = [rgb_sensor_spec] 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 turn_angle = 360.0 / config.views_per_room
agent_cfg.action_space = { agent_cfg.action_space = {

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import copy
from dataclasses import dataclass, field from dataclasses import dataclass, field
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
@@ -20,16 +21,32 @@ class RoomView:
depth: Any | None = field(default=None, compare=False) depth: Any | None = field(default=None, compare=False)
agent_position: np.ndarray | 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) 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]: def flatten_room_views(room_views_by_room: RoomViewsByRoom) -> list[RoomView]:
result: list[RoomView] = [] result: list[RoomView] = []
for room_id, views in room_views_by_room.items(): for room_id, views in room_views_by_room.items():
for idx, rgb in enumerate(views): for idx, item in enumerate(views):
result.append(RoomView(room_id=room_id, view_idx=idx, rgb=rgb)) if isinstance(item, RoomView):
result.append(item)
else:
result.append(RoomView(room_id=room_id, view_idx=idx, rgb=item))
return result 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( def collect_room_views_by_room(
agent: Any, agent: Any,
sim: Any, sim: Any,
@@ -38,6 +55,7 @@ def collect_room_views_by_room(
*, *,
habitat_sim_module: Any | None = None, habitat_sim_module: Any | None = None,
sensor_uuid: str = "color_sensor", sensor_uuid: str = "color_sensor",
depth_sensor_uuid: str | None = None,
turn_action: str = "turn_left", turn_action: str = "turn_left",
progress_description: str = "Collecting room views", progress_description: str = "Collecting room views",
progress_track: ProgressTrack = track, progress_track: ProgressTrack = track,
@@ -55,9 +73,32 @@ def collect_room_views_by_room(
agent.set_state(agent_state) agent.set_state(agent_state)
room_views = [] room_views = []
for _ in range(views_per_room): for view_idx in range(views_per_room):
observations = sim.get_sensor_observations() 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) sim.step(turn_action)
all_room_views[room_node.room_id] = room_views all_room_views[room_node.room_id] = room_views

View File

@@ -24,10 +24,9 @@ def base_dependencies():
"""Basic dependencies for data processing.""" """Basic dependencies for data processing."""
import numpy as np import numpy as np
import polars as pl import polars as pl
import torch
from PIL import Image from PIL import Image
return Image, np, pl, torch return Image, np, pl
@app.cell @app.cell
@@ -99,8 +98,7 @@ def habitat_setup(HabitatSimulatorConfig, RoomNode, create_habitat_simulator, np
views_per_room = 12 views_per_room = 12
meters_per_pixel = 0.05 meters_per_pixel = 0.05
sim, agent = create_habitat_simulator( habitat_config = HabitatSimulatorConfig(
HabitatSimulatorConfig(
scene_path=_scene_path, scene_path=_scene_path,
views_per_room=views_per_room, views_per_room=views_per_room,
image_size=_image_size, image_size=_image_size,
@@ -108,7 +106,7 @@ def habitat_setup(HabitatSimulatorConfig, RoomNode, create_habitat_simulator, np
move_forward_step=0.25, move_forward_step=0.25,
enable_physics=False, enable_physics=False,
) )
) sim, agent = create_habitat_simulator(habitat_config)
room_nodes = [] room_nodes = []
for idx in range(_num_rooms): for idx in range(_num_rooms):
@@ -125,7 +123,7 @@ def habitat_setup(HabitatSimulatorConfig, RoomNode, create_habitat_simulator, np
for _node in room_nodes: for _node in room_nodes:
print(f" {_node.room_id}: {_node.center}") 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 @app.cell
@@ -164,6 +162,7 @@ def collect_views(
agent, agent,
collect_room_views_by_room, collect_room_views_by_room,
flatten_room_views, flatten_room_views,
habitat_config,
numpy_to_pil, numpy_to_pil,
room_nodes, room_nodes,
sim, sim,
@@ -174,6 +173,7 @@ def collect_views(
sim=sim, sim=sim,
room_nodes=room_nodes, room_nodes=room_nodes,
views_per_room=views_per_room, views_per_room=views_per_room,
depth_sensor_uuid=habitat_config.depth_sensor_uuid,
) )
room_views = flatten_room_views(all_room_views) room_views = flatten_room_views(all_room_views)
@@ -193,6 +193,7 @@ def build_scene_graph(
SceneGraphBuildConfig, SceneGraphBuildConfig,
SceneGraphBuilder, SceneGraphBuilder,
cfg_manager, cfg_manager,
habitat_config,
mo, mo,
numpy_to_pil, numpy_to_pil,
pipeline, pipeline,
@@ -207,7 +208,11 @@ def build_scene_graph(
builder = SceneGraphBuilder( builder = SceneGraphBuilder(
pipeline=pipeline, 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 = [ pil_room_views = [
@@ -218,6 +223,8 @@ def build_scene_graph(
depth=_view.depth, depth=_view.depth,
agent_position=_view.agent_position, agent_position=_view.agent_position,
agent_rotation=_view.agent_rotation, agent_rotation=_view.agent_rotation,
camera_position=_view.camera_position,
camera_rotation=_view.camera_rotation,
) )
for _view in room_views 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_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_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, "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() for obj in scene_graph.objects.values()
] ]
@@ -313,6 +322,19 @@ def build_tables(pl, scene_graph):
return objects_df, rooms_df 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 @app.cell
def upload_query(mo): def upload_query(mo):
file_upload = mo.ui.file( file_upload = mo.ui.file(
@@ -329,7 +351,6 @@ def query_matching(
Image, Image,
file_upload, file_upload,
mo, mo,
object_images,
pipeline, pipeline,
query_image_against_scene_graph, query_image_against_scene_graph,
scene_graph, scene_graph,

View File

@@ -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)

View File

@@ -1,11 +1,23 @@
from __future__ import annotations from __future__ import annotations
import ast
from pathlib import Path from pathlib import Path
NOTEBOOK = Path(__file__).resolve().parents[1] / "notebooks" / "verification.py" 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(): def test_verification_notebook_uses_scenegraph_query_api():
source = NOTEBOOK.read_text() source = NOTEBOOK.read_text()
@@ -20,7 +32,6 @@ def test_verification_notebook_uses_hash_codec_for_object_hashes():
# the builder handles it internally. # the builder handles it internally.
assert "bits_tensor_to_hash_bytes" not in source assert "bits_tensor_to_hash_bytes" not in source
assert "np.packbits" 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(): 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 "SceneGraphBuildConfig" in source
assert "flatten_room_views" in source assert "flatten_room_views" in source
assert "scene_graph, build_artifacts = builder.build_from_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"
)

File diff suppressed because it is too large Load Diff