"""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=[], )