feat(scenegraph): add SceneGraphBuilder for pipeline-driven graph construction

Introduce SceneGraphBuilder + SceneGraphBuildConfig to decouple scene graph
construction from the verification notebook. The builder handles batch
inference, hash encoding, and object node creation internally.

- Add SceneGraphBuilder.build_from_room_views() as the main entry point
- Add SceneGraphBuildConfig for inference_batch_size and position strategy
- Add SceneGraphBuildArtifacts to carry cropped images and debug metadata
- Extend ObjectNode with optional detection metadata (label, confidence,
  bbox_xyxy, source_view_id, position_confidence)
- Add RoomView frozen dataclass as a structured view container
- Add flatten_room_views() utility to replace inline list comprehensions
- Refactor notebooks/verification.py to use the new builder API

BREAKING CHANGE: ObjectNode now accepts additional optional fields; direct
scene_graph.objects[obj_id] = ObjectNode(...) construction in the notebook
is replaced by builder.build_from_room_views(...).
This commit is contained in:
2026-05-30 15:40:58 +08:00
parent 97e53d44f8
commit a127032e18
8 changed files with 910 additions and 129 deletions

View File

@@ -16,5 +16,18 @@ def test_verification_notebook_uses_scenegraph_query_api():
def test_verification_notebook_uses_hash_codec_for_object_hashes():
source = NOTEBOOK.read_text()
assert "bits_tensor_to_hash_bytes" in source
# Notebook should no longer do hash conversion directly;
# 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():
source = NOTEBOOK.read_text()
assert "SceneGraphBuilder" in source
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

View File

@@ -0,0 +1,479 @@
"""Tests for scenegraph builder / ObjectNode."""
from __future__ import annotations
import sys
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
import torch
MINI_NAV_DIR = Path(__file__).resolve().parents[1] / "mini-nav"
sys.path.insert(0, str(MINI_NAV_DIR))
from scenegraph.hash_codec import bits_tensor_to_hash_bytes # noqa: E402
from scenegraph.objectnode import ObjectNode # noqa: E402
def _hash_bytes() -> bytes:
"""Create a 512-bit hash with bit 0 set to 1."""
bits = torch.zeros(512, dtype=torch.uint8)
bits[0] = 1
return bits_tensor_to_hash_bytes(bits)
def test_object_node_accepts_optional_detection_metadata():
"""ObjectNode should accept and preserve optional detection metadata."""
hb = _hash_bytes()
node = ObjectNode(
obj_id="room_a_v000_m00",
room_id="room_a",
position=np.array([1, 2, 3], dtype=np.float32),
visual_hash=hb,
semantic_hash=hb,
hit_count=1,
last_seen_frame=0,
label="a chair",
confidence=0.87,
bbox_xyxy=(10, 20, 30, 40),
source_view_id="room_a_v000",
position_confidence=0.0,
)
assert node.obj_id == "room_a_v000_m00"
assert node.room_id == "room_a"
np.testing.assert_array_equal(node.position, np.array([1, 2, 3], dtype=np.float32))
assert node.visual_hash == hb
assert node.semantic_hash == hb
assert node.hit_count == 1
assert node.last_seen_frame == 0
assert node.label == "a chair"
assert node.confidence == 0.87
assert node.bbox_xyxy == (10, 20, 30, 40)
assert node.source_view_id == "room_a_v000"
assert node.position_confidence == 0.0
def test_room_view_equality_with_numpy_rgb_does_not_compare_payload():
"""RoomView equality should not attempt element-wise numpy comparison."""
from simulator import RoomView # noqa: E402
rgb_a = np.zeros((2, 2, 3), dtype=np.uint8)
rgb_b = np.ones((2, 2, 3), dtype=np.uint8)
# Two views with different numpy payloads — still equal because
# rgb is excluded from equality (only room_id, view_idx matter).
v1 = RoomView(room_id="room_a", view_idx=0, rgb=rgb_a)
v2 = RoomView(room_id="room_a", view_idx=0, rgb=rgb_b)
assert v1 == v2
# Different room_id → not equal
v3 = RoomView(room_id="room_b", view_idx=0, rgb=rgb_a)
assert v1 != v3
# Different view_idx → not equal
v4 = RoomView(room_id="room_a", view_idx=1, rgb=rgb_a)
assert v1 != v4
def test_flatten_room_views_with_numpy_arrays_safe_equality():
"""flatten_room_views with numpy arrays should not raise ambiguous truth value errors."""
from simulator import RoomView, flatten_room_views # noqa: E402
rgb_a = np.zeros((2, 2, 3), dtype=np.uint8)
rgb_b = np.ones((2, 2, 3), dtype=np.uint8)
views = flatten_room_views({"room_a": [rgb_a, rgb_b]})
# Compare with expected list — uses identity-field comparison only
expected = [
RoomView(room_id="room_a", view_idx=0, rgb=rgb_a),
RoomView(room_id="room_a", view_idx=1, rgb=rgb_b),
]
assert views == expected
# Even with wildly different numpy arrays, comparison is safe
swapped = [
RoomView(room_id="room_a", view_idx=0, rgb=rgb_b),
RoomView(room_id="room_a", view_idx=1, rgb=rgb_a),
]
assert views == swapped # rgb payload ignored
# Mismatched identity fields still differ
different_room = [
RoomView(room_id="room_b", view_idx=0, rgb=rgb_a),
RoomView(room_id="room_a", view_idx=1, rgb=rgb_b),
]
assert views != different_room
def test_flatten_room_views_preserves_room_and_view_identity():
from PIL import Image # noqa: E402
from simulator import RoomView, flatten_room_views # noqa: E402
first = Image.new("RGB", (4, 4), "red")
second = Image.new("RGB", (4, 4), "blue")
views = flatten_room_views({"room_a": [first, second]})
# Ensure the returned objects are the same instances
assert views[0].rgb is first
assert views[1].rgb is second
# Equality compares only room_id and view_idx (payload excluded).
# Different PIL images with same room_id/view_idx compare equal.
assert views == [
RoomView(room_id="room_a", view_idx=0, rgb=first),
RoomView(room_id="room_a", view_idx=1, rgb=second),
]
assert views == [
RoomView(room_id="room_a", view_idx=0, rgb=second), # rgb ignored
RoomView(room_id="room_a", view_idx=1, rgb=first), # rgb ignored
]
# Different room_id or view_idx makes them unequal
assert views != [
RoomView(room_id="room_b", view_idx=0, rgb=first),
RoomView(room_id="room_a", view_idx=1, rgb=second),
]
# ---------------------------------------------------------------------------
# SceneGraphBuilder M0 tests
# ---------------------------------------------------------------------------
class FakePipeline:
"""Fake pipeline that records calls and returns a pre-set output."""
def __init__(self, output, calls, hash_bits=512):
self.output = output
self.calls = calls
self._hash_bits = hash_bits
def process_batch(self, images, text_labels, batch_size=32, return_debug_details=False):
self.calls.append({
"images": images,
"text_labels": text_labels,
"batch_size": batch_size,
"return_debug_details": return_debug_details,
})
return self.output
def test_scene_graph_builder_creates_objects_from_pipeline_debug_meta():
"""Build a graph from one room view with one detection and verify all fields."""
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)
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=[], hash_bits=512)
config = SceneGraphBuildConfig(inference_batch_size=7)
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,
)
# Pipeline was called correctly
assert len(pipeline.calls) == 1
call = pipeline.calls[0]
assert call["return_debug_details"] is True
assert call["batch_size"] == 7
assert len(call["images"]) == 1
assert call["images"][0] is rgb
assert call["text_labels"] == text_labels
# Graph rooms
assert list(graph.rooms.keys()) == ["room_a"]
assert graph.rooms["room_a"] is room_node
# Graph objects
assert list(graph.objects.keys()) == ["room_a_v000_m00"]
obj = graph.objects["room_a_v000_m00"]
assert obj.room_id == "room_a"
np.testing.assert_array_equal(obj.position, np.array([10, 0, 20], dtype=np.float32))
expected_hash = bits_tensor_to_hash_bytes(hash_bits[0])
assert obj.visual_hash == expected_hash
assert obj.semantic_hash == expected_hash
assert obj.label == "a chair"
assert obj.confidence == 0.91
assert obj.bbox_xyxy == (10.0, 20.0, 30.0, 40.0)
assert obj.source_view_id == "room_a_v000"
assert obj.position_confidence == 0.0
# Artifacts
assert artifacts.object_images["room_a_v000_m00"] is crop
assert artifacts.debug_meta == [debug_meta_entry]
def test_scene_graph_builder_handles_empty_pipeline_output():
"""Pipeline returning no detections should produce rooms-only graph."""
from scenegraph import RoomNode, SceneGraphBuilder
from simulator import RoomView
room_node = RoomNode(room_id="room_a", center=[0, 0, 0], 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)
room_views = [room_view]
text_labels: list[str] = []
hash_bits = torch.zeros(0, 512, dtype=torch.uint8)
debug_meta_entry = {
"selected_indices": [],
"boxes_xyxy": [],
"scores": [],
"labels": [],
"masks": [],
"num_selected": 0,
}
output = SimpleNamespace(
cropped_images=[],
hash_bits=hash_bits,
debug_meta=[debug_meta_entry],
)
pipeline = FakePipeline(output=output, calls=[])
builder = SceneGraphBuilder(pipeline=pipeline)
graph, artifacts = builder.build_from_room_views(
room_nodes=room_nodes,
room_views=room_views,
text_labels=text_labels,
)
# Only rooms, no objects
assert list(graph.rooms.keys()) == ["room_a"]
assert graph.objects == {}
assert artifacts.object_images == {}
def test_config_rejects_enable_fusion():
"""SceneGraphBuildConfig should reject enable_fusion=True."""
from scenegraph import SceneGraphBuildConfig
with pytest.raises(ValueError, match="fusion"):
SceneGraphBuildConfig(enable_fusion=True)
def test_config_rejects_non_room_center_strategy():
"""SceneGraphBuildConfig should reject non-room_center position_strategy."""
from scenegraph import SceneGraphBuildConfig
with pytest.raises(ValueError, match="room_center"):
SceneGraphBuildConfig(position_strategy="global")
def test_config_rejects_zero_batch_size():
"""SceneGraphBuildConfig should reject inference_batch_size <= 0."""
from scenegraph import SceneGraphBuildConfig
with pytest.raises(ValueError):
SceneGraphBuildConfig(inference_batch_size=0)
def test_scene_graph_builder_public_method_stays_small():
"""build_from_room_views should stay an orchestration method, not own all details."""
import inspect
from scenegraph import SceneGraphBuilder
source = inspect.getsource(SceneGraphBuilder.build_from_room_views)
code_lines = [
line for line in source.splitlines()
if line.strip() and not line.strip().startswith("#")
]
assert len(code_lines) <= 45
for helper_name in [
"_prepare_graph",
"_run_pipeline",
"_build_artifacts",
"_validate_pipeline_output",
"_add_objects_to_graph",
]:
assert helper_name in source
# ---------------------------------------------------------------------------
# Validation error tests
# ---------------------------------------------------------------------------
def test_builder_raises_on_selected_indices_num_selected_mismatch():
"""selected_indices length != num_selected should raise ValueError."""
from scenegraph import RoomNode, SceneGraphBuilder
from simulator import RoomView
room_node = RoomNode(room_id="room_a", center=[0, 0, 0], 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)
room_views = [room_view]
debug_meta_entry = {
"selected_indices": [0], # len 1
"boxes_xyxy": [[10.0, 20.0, 30.0, 40.0]],
"scores": [0.91],
"labels": ["a chair"],
"num_selected": 0, # mismatch!
}
output = SimpleNamespace(
cropped_images=["crop"],
hash_bits=torch.zeros(1, 512, dtype=torch.uint8),
debug_meta=[debug_meta_entry],
)
pipeline = FakePipeline(output=output, calls=[])
builder = SceneGraphBuilder(pipeline=pipeline)
with pytest.raises(ValueError, match="selected_indices"):
builder.build_from_room_views(
room_nodes=room_nodes,
room_views=room_views,
text_labels=[],
)
def test_builder_raises_on_selected_idx_out_of_labels_range():
"""selected_idx >= len(labels) should raise ValueError."""
from scenegraph import RoomNode, SceneGraphBuilder
from simulator import RoomView
room_node = RoomNode(room_id="room_a", center=[0, 0, 0], 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)
room_views = [room_view]
debug_meta_entry = {
"selected_indices": [5], # index 5, but labels has only 1 entry
"boxes_xyxy": [[10.0, 20.0, 30.0, 40.0]],
"scores": [0.91],
"labels": ["a chair"],
"num_selected": 1,
}
output = SimpleNamespace(
cropped_images=["crop"],
hash_bits=torch.zeros(1, 512, dtype=torch.uint8),
debug_meta=[debug_meta_entry],
)
pipeline = FakePipeline(output=output, calls=[])
builder = SceneGraphBuilder(pipeline=pipeline)
with pytest.raises(ValueError, match="selected_idx.*labels"):
builder.build_from_room_views(
room_nodes=room_nodes,
room_views=room_views,
text_labels=[],
)
def test_builder_raises_on_missing_room_before_pipeline():
"""Unknown room_id in room_view should raise ValueError before pipeline call."""
from scenegraph import RoomNode, SceneGraphBuilder
from simulator import RoomView
room_node = RoomNode(room_id="room_a", center=[0, 0, 0], bbox_extent=[5, 3, 5])
room_nodes = [room_node]
rgb = np.zeros((32, 32, 3), dtype=np.uint8)
room_view = RoomView(room_id="room_b", view_idx=0, rgb=rgb) # not in room_nodes!
room_views = [room_view]
output = SimpleNamespace(
cropped_images=[],
hash_bits=torch.zeros(0, 512, dtype=torch.uint8),
debug_meta=[],
)
pipeline = FakePipeline(output=output, calls=[])
builder = SceneGraphBuilder(pipeline=pipeline)
with pytest.raises(ValueError, match="room_b"):
builder.build_from_room_views(
room_nodes=room_nodes,
room_views=room_views,
text_labels=[],
)
# Pipeline should NOT have been called — error raised before pipeline
assert len(pipeline.calls) == 0
def test_builder_raises_on_debug_meta_length_mismatch():
"""Mismatched debug_meta length should raise ValueError."""
from scenegraph import RoomNode, SceneGraphBuilder
from simulator import RoomView
room_node = RoomNode(room_id="room_a", center=[0, 0, 0], 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)
room_views = [room_view]
debug_meta_entry = {
"selected_indices": [],
"boxes_xyxy": [],
"scores": [],
"labels": [],
"num_selected": 0,
}
# 2 debug_meta entries but only 1 room_view
output = SimpleNamespace(
cropped_images=[],
hash_bits=torch.zeros(0, 512, dtype=torch.uint8),
debug_meta=[debug_meta_entry, debug_meta_entry],
)
pipeline = FakePipeline(output=output, calls=[])
builder = SceneGraphBuilder(pipeline=pipeline)
with pytest.raises(ValueError, match="debug_meta"):
builder.build_from_room_views(
room_nodes=room_nodes,
room_views=room_views,
text_labels=[],
)