mirror of
https://github.com/SikongJueluo/Mini-Nav.git
synced 2026-07-12 20:15:31 +08:00
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:
479
tests/test_scenegraph_builder.py
Normal file
479
tests/test_scenegraph_builder.py
Normal 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=[],
|
||||
)
|
||||
Reference in New Issue
Block a user