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

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

File diff suppressed because it is too large Load Diff