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:
@@ -5,6 +5,7 @@ This module exports the main scenegraph objects for easy import:
|
||||
from scenegraph import SimpleSceneGraph, RoomNode, ObjectNode
|
||||
"""
|
||||
|
||||
from .builder import SceneGraphBuildArtifacts, SceneGraphBuildConfig, SceneGraphBuilder
|
||||
from .hash_codec import (
|
||||
bits_tensor_to_cam_row,
|
||||
bits_tensor_to_hash_bytes,
|
||||
@@ -28,6 +29,9 @@ __all__ = [
|
||||
"ImageSceneGraphQueryResult",
|
||||
"ObjectNode",
|
||||
"RoomNode",
|
||||
"SceneGraphBuildArtifacts",
|
||||
"SceneGraphBuildConfig",
|
||||
"SceneGraphBuilder",
|
||||
"SceneGraphMatch",
|
||||
"SimpleSceneGraph",
|
||||
"SoftwareCamIndex",
|
||||
|
||||
293
mini-nav/scenegraph/builder.py
Normal file
293
mini-nav/scenegraph/builder.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Scene graph builder: converts room views + pipeline debug output into SimpleSceneGraph."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .hash_codec import bits_tensor_to_hash_bytes
|
||||
from .objectnode import ObjectNode
|
||||
from .roomnode import RoomNode
|
||||
from .scenegraph import SimpleSceneGraph
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SceneGraphBuildConfig:
|
||||
"""Configuration for the scene graph builder.
|
||||
|
||||
Attributes:
|
||||
position_strategy: How object positions are assigned. Only 'room_center'
|
||||
is supported in M0.
|
||||
inference_batch_size: Batch size passed to the pipeline.
|
||||
enable_fusion: Whether to fuse overlapping detections (not yet supported).
|
||||
fusion_hash_similarity_threshold: Hash similarity threshold for fusion.
|
||||
fusion_distance_threshold_m: Distance threshold in meters for fusion.
|
||||
"""
|
||||
|
||||
position_strategy: Literal["room_center"] = "room_center"
|
||||
inference_batch_size: int = 4
|
||||
enable_fusion: bool = False
|
||||
fusion_hash_similarity_threshold: float = 0.95
|
||||
fusion_distance_threshold_m: float = 0.5
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.position_strategy != "room_center":
|
||||
raise ValueError(
|
||||
f"position_strategy must be 'room_center', got {self.position_strategy!r}"
|
||||
)
|
||||
if self.inference_batch_size <= 0:
|
||||
raise ValueError(
|
||||
f"inference_batch_size must be positive, got {self.inference_batch_size}"
|
||||
)
|
||||
if self.enable_fusion:
|
||||
raise ValueError("fusion is not yet supported in M0")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SceneGraphBuildArtifacts:
|
||||
"""Artifacts produced during scene graph construction.
|
||||
|
||||
Attributes:
|
||||
object_images: Maps object_id -> cropped image for each detected object.
|
||||
debug_meta: Pipeline debug_metadata for each input view.
|
||||
cropped_images: Flat list of all cropped images from the pipeline.
|
||||
"""
|
||||
|
||||
object_images: dict[str, Any] = field(default_factory=dict)
|
||||
debug_meta: list[dict[str, Any]] = field(default_factory=list)
|
||||
cropped_images: list[Any] = field(default_factory=list)
|
||||
|
||||
|
||||
class SceneGraphBuilder:
|
||||
"""Builds a SimpleSceneGraph from room views and a detection pipeline."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
pipeline: Any,
|
||||
config: SceneGraphBuildConfig | None = None,
|
||||
) -> None:
|
||||
self._pipeline = pipeline
|
||||
self._config = config or SceneGraphBuildConfig()
|
||||
|
||||
def build_from_room_views(
|
||||
self,
|
||||
*,
|
||||
room_nodes: Sequence[RoomNode],
|
||||
room_views: Sequence[Any],
|
||||
text_labels: list[str],
|
||||
) -> tuple[SimpleSceneGraph, SceneGraphBuildArtifacts]:
|
||||
"""Build a scene graph from room views and a list of room nodes.
|
||||
|
||||
Args:
|
||||
room_nodes: Sequence of RoomNode objects describing each room.
|
||||
room_views: Sequence of RoomView objects (must have .rgb, .room_id,
|
||||
.view_idx attributes).
|
||||
text_labels: Text labels to pass to the pipeline.
|
||||
|
||||
Returns:
|
||||
A tuple of (SimpleSceneGraph, SceneGraphBuildArtifacts).
|
||||
|
||||
Raises:
|
||||
ValueError: If any room_view references an unknown room_id, or if the
|
||||
pipeline output is inconsistent.
|
||||
"""
|
||||
graph = self._prepare_graph(room_nodes=room_nodes, room_views=room_views)
|
||||
output = self._run_pipeline(room_views=room_views, text_labels=text_labels)
|
||||
artifacts = self._build_artifacts(output)
|
||||
prefix = self._validate_pipeline_output(output=output, room_views=room_views)
|
||||
self._add_objects_to_graph(
|
||||
graph=graph,
|
||||
artifacts=artifacts,
|
||||
room_views=room_views,
|
||||
output=output,
|
||||
prefix=prefix,
|
||||
)
|
||||
return graph, artifacts
|
||||
|
||||
def _prepare_graph(
|
||||
self,
|
||||
*,
|
||||
room_nodes: Sequence[RoomNode],
|
||||
room_views: Sequence[Any],
|
||||
) -> SimpleSceneGraph:
|
||||
rooms = {node.room_id: node for node in room_nodes}
|
||||
for view in room_views:
|
||||
if view.room_id not in rooms:
|
||||
raise ValueError(
|
||||
f"Missing/unknown room {view.room_id!r} in room_view"
|
||||
)
|
||||
return SimpleSceneGraph(rooms=rooms, objects={})
|
||||
|
||||
def _run_pipeline(self, *, room_views: Sequence[Any], text_labels: list[str]) -> Any:
|
||||
images = [view.rgb for view in room_views]
|
||||
return self._pipeline.process_batch(
|
||||
images,
|
||||
text_labels,
|
||||
batch_size=self._config.inference_batch_size,
|
||||
return_debug_details=True,
|
||||
)
|
||||
|
||||
def _build_artifacts(self, output: Any) -> SceneGraphBuildArtifacts:
|
||||
return SceneGraphBuildArtifacts(
|
||||
debug_meta=list(output.debug_meta),
|
||||
cropped_images=list(output.cropped_images),
|
||||
)
|
||||
|
||||
def _validate_pipeline_output(
|
||||
self,
|
||||
*,
|
||||
output: Any,
|
||||
room_views: Sequence[Any],
|
||||
) -> list[int]:
|
||||
self._validate_debug_meta_length(output=output, room_views=room_views)
|
||||
num_selected_list = self._validate_selected_counts(output.debug_meta)
|
||||
total_selected = sum(num_selected_list)
|
||||
self._validate_crop_and_hash_counts(output=output, total_selected=total_selected)
|
||||
return self._prefix_offsets(num_selected_list)
|
||||
|
||||
def _validate_debug_meta_length(
|
||||
self,
|
||||
*,
|
||||
output: Any,
|
||||
room_views: Sequence[Any],
|
||||
) -> None:
|
||||
if len(output.debug_meta) != len(room_views):
|
||||
raise ValueError(
|
||||
f"debug_meta length ({len(output.debug_meta)}) does not match "
|
||||
f"room_views length ({len(room_views)})"
|
||||
)
|
||||
|
||||
def _validate_selected_counts(self, debug_meta: Sequence[dict[str, Any]]) -> list[int]:
|
||||
num_selected_list: list[int] = []
|
||||
for view_idx, meta in enumerate(debug_meta):
|
||||
selected_indices = meta.get("selected_indices", [])
|
||||
num_selected = int(meta.get("num_selected", 0))
|
||||
if len(selected_indices) != num_selected:
|
||||
raise ValueError(
|
||||
f"View {view_idx}: len(selected_indices) ({len(selected_indices)}) "
|
||||
f"does not match num_selected ({num_selected})"
|
||||
)
|
||||
num_selected_list.append(num_selected)
|
||||
return num_selected_list
|
||||
|
||||
def _validate_crop_and_hash_counts(self, *, output: Any, total_selected: int) -> None:
|
||||
if total_selected != len(output.cropped_images):
|
||||
raise ValueError(
|
||||
f"total_selected ({total_selected}) does not match "
|
||||
f"len(cropped_images) ({len(output.cropped_images)})"
|
||||
)
|
||||
|
||||
hash_bits = output.hash_bits
|
||||
if hash_bits is None:
|
||||
if total_selected > 0:
|
||||
raise ValueError(
|
||||
f"hash_bits is None but total_selected ({total_selected}) > 0"
|
||||
)
|
||||
elif total_selected != hash_bits.shape[0]:
|
||||
raise ValueError(
|
||||
f"total_selected ({total_selected}) does not match "
|
||||
f"hash_bits.shape[0] ({hash_bits.shape[0]})"
|
||||
)
|
||||
|
||||
def _prefix_offsets(self, counts: Sequence[int]) -> list[int]:
|
||||
prefix: list[int] = []
|
||||
running = 0
|
||||
for count in counts:
|
||||
prefix.append(running)
|
||||
running += count
|
||||
return prefix
|
||||
|
||||
def _add_objects_to_graph(
|
||||
self,
|
||||
*,
|
||||
graph: SimpleSceneGraph,
|
||||
artifacts: SceneGraphBuildArtifacts,
|
||||
room_views: Sequence[Any],
|
||||
output: Any,
|
||||
prefix: Sequence[int],
|
||||
) -> None:
|
||||
for image_idx, (view, meta) in enumerate(zip(room_views, output.debug_meta)):
|
||||
for local_mask_idx, selected_idx in enumerate(meta.get("selected_indices", [])):
|
||||
crop_index = prefix[image_idx] + local_mask_idx
|
||||
obj_id = self._object_id(view=view, local_mask_idx=local_mask_idx)
|
||||
node = self._create_object_node(
|
||||
obj_id=obj_id,
|
||||
view=view,
|
||||
room=graph.rooms[view.room_id],
|
||||
meta=meta,
|
||||
selected_idx=selected_idx,
|
||||
hash_bits=output.hash_bits,
|
||||
crop_index=crop_index,
|
||||
)
|
||||
graph.objects[obj_id] = node
|
||||
artifacts.object_images[obj_id] = output.cropped_images[crop_index]
|
||||
|
||||
def _object_id(self, *, view: Any, local_mask_idx: int) -> str:
|
||||
return f"{view.room_id}_v{view.view_idx:03d}_m{local_mask_idx:02d}"
|
||||
|
||||
def _source_view_id(self, view: Any) -> str:
|
||||
return f"{view.room_id}_v{view.view_idx:03d}"
|
||||
|
||||
def _create_object_node(
|
||||
self,
|
||||
*,
|
||||
obj_id: str,
|
||||
view: Any,
|
||||
room: RoomNode,
|
||||
meta: dict[str, Any],
|
||||
selected_idx: int,
|
||||
hash_bits: Any,
|
||||
crop_index: int,
|
||||
) -> ObjectNode:
|
||||
hash_bytes = bits_tensor_to_hash_bytes(hash_bits[crop_index])
|
||||
label, confidence, bbox_xyxy = self._metadata_for_detection(
|
||||
meta=meta,
|
||||
selected_idx=selected_idx,
|
||||
)
|
||||
return ObjectNode(
|
||||
obj_id=obj_id,
|
||||
room_id=view.room_id,
|
||||
position=room.center.copy(),
|
||||
visual_hash=hash_bytes,
|
||||
semantic_hash=hash_bytes,
|
||||
hit_count=1,
|
||||
last_seen_frame=int(view.view_idx),
|
||||
label=label,
|
||||
confidence=confidence,
|
||||
bbox_xyxy=bbox_xyxy,
|
||||
source_view_id=self._source_view_id(view),
|
||||
position_confidence=0.0,
|
||||
)
|
||||
|
||||
def _metadata_for_detection(
|
||||
self,
|
||||
*,
|
||||
meta: dict[str, Any],
|
||||
selected_idx: int,
|
||||
) -> tuple[str | None, float | None, tuple[float, float, float, float] | None]:
|
||||
label = self._metadata_item(meta=meta, key="labels", selected_idx=selected_idx)
|
||||
score = self._metadata_item(meta=meta, key="scores", selected_idx=selected_idx)
|
||||
box = self._metadata_item(meta=meta, key="boxes_xyxy", selected_idx=selected_idx)
|
||||
|
||||
confidence = None if score is None else float(score)
|
||||
bbox_xyxy = None if box is None else self._normalize_bbox(box)
|
||||
return label, confidence, bbox_xyxy
|
||||
|
||||
def _metadata_item(self, *, meta: dict[str, Any], key: str, selected_idx: int) -> Any:
|
||||
values = meta.get(key)
|
||||
if values is None:
|
||||
return None
|
||||
if not (0 <= selected_idx < len(values)):
|
||||
raise ValueError(
|
||||
f"selected_idx {selected_idx} out of range for "
|
||||
f"metadata key '{key}' with length {len(values)}"
|
||||
)
|
||||
return values[selected_idx]
|
||||
|
||||
def _normalize_bbox(self, box: Any) -> tuple[float, float, float, float]:
|
||||
if len(box) != 4:
|
||||
raise ValueError(f"bbox entry has length {len(box)}, expected 4")
|
||||
return tuple(float(x) for x in box)
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
@@ -20,10 +22,20 @@ class ObjectNode:
|
||||
hit_count: int = 1 # 被观测到的次数。太低的可以直接过滤掉
|
||||
last_seen_frame: int = 0 # 最后一次看到的帧号或时间戳
|
||||
|
||||
# Optional detection metadata (from M0 integration)
|
||||
label: str | None = None
|
||||
confidence: float | None = None
|
||||
bbox_xyxy: tuple[float, float, float, float] | None = None
|
||||
source_view_id: str | None = None
|
||||
position_confidence: float | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.position = np.asarray(self.position, dtype=np.float32)
|
||||
if len(self.visual_hash) != 64:
|
||||
raise ValueError("visual_hash must be exactly 64 bytes (512 bits)")
|
||||
if len(self.semantic_hash) != 64:
|
||||
raise ValueError("semantic_hash must be exactly 64 bytes (512 bits)")
|
||||
if self.position.shape != (3,):
|
||||
raise ValueError("position must have shape (3,)")
|
||||
if self.bbox_xyxy is not None and len(self.bbox_xyxy) != 4:
|
||||
raise ValueError("bbox_xyxy must have exactly 4 elements")
|
||||
|
||||
@@ -5,10 +5,17 @@ from .habitat import (
|
||||
)
|
||||
from .image_save import save_object_image, save_room_view
|
||||
from .topdown import TopDownRenderStyle, TopDownSceneElements, render_topdown_scene_map
|
||||
from .views import RoomViewsByRoom, collect_room_views_by_room, collect_scene_images
|
||||
from .views import (
|
||||
RoomView,
|
||||
RoomViewsByRoom,
|
||||
collect_room_views_by_room,
|
||||
collect_scene_images,
|
||||
flatten_room_views,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"HabitatSimulatorConfig",
|
||||
"RoomView",
|
||||
"RoomViewsByRoom",
|
||||
"TopDownRenderStyle",
|
||||
"TopDownSceneElements",
|
||||
@@ -16,6 +23,7 @@ __all__ = [
|
||||
"collect_room_views_by_room",
|
||||
"collect_scene_images",
|
||||
"create_habitat_simulator",
|
||||
"flatten_room_views",
|
||||
"render_topdown_scene_map",
|
||||
"save_object_image",
|
||||
"save_room_view",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Sequence
|
||||
@@ -11,6 +12,24 @@ RoomViewsByRoom = dict[str, list[Any]]
|
||||
ProgressTrack = Callable[[Iterable[Any], str], Iterable[Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoomView:
|
||||
room_id: str
|
||||
view_idx: int
|
||||
rgb: Any = field(compare=False)
|
||||
depth: Any | 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)
|
||||
|
||||
|
||||
def flatten_room_views(room_views_by_room: RoomViewsByRoom) -> list[RoomView]:
|
||||
result: list[RoomView] = []
|
||||
for room_id, views in room_views_by_room.items():
|
||||
for idx, rgb in enumerate(views):
|
||||
result.append(RoomView(room_id=room_id, view_idx=idx, rgb=rgb))
|
||||
return result
|
||||
|
||||
|
||||
def collect_room_views_by_room(
|
||||
agent: Any,
|
||||
sim: Any,
|
||||
|
||||
@@ -36,10 +36,9 @@ def project_imports():
|
||||
from compressors import HashPipeline
|
||||
from configs import cfg_manager
|
||||
from scenegraph import (
|
||||
ObjectNode,
|
||||
RoomNode,
|
||||
SimpleSceneGraph,
|
||||
bits_tensor_to_hash_bytes,
|
||||
SceneGraphBuildConfig,
|
||||
SceneGraphBuilder,
|
||||
query_image_against_scene_graph,
|
||||
)
|
||||
from simulator import (
|
||||
@@ -47,6 +46,7 @@ def project_imports():
|
||||
TopDownSceneElements,
|
||||
collect_room_views_by_room,
|
||||
create_habitat_simulator,
|
||||
flatten_room_views,
|
||||
render_topdown_scene_map,
|
||||
save_object_image,
|
||||
save_room_view,
|
||||
@@ -56,14 +56,14 @@ def project_imports():
|
||||
return (
|
||||
HashPipeline,
|
||||
HabitatSimulatorConfig,
|
||||
ObjectNode,
|
||||
RoomNode,
|
||||
SimpleSceneGraph,
|
||||
SceneGraphBuildConfig,
|
||||
SceneGraphBuilder,
|
||||
TopDownSceneElements,
|
||||
bits_tensor_to_hash_bytes,
|
||||
cfg_manager,
|
||||
collect_room_views_by_room,
|
||||
create_habitat_simulator,
|
||||
flatten_room_views,
|
||||
numpy_to_pil,
|
||||
query_image_against_scene_graph,
|
||||
render_topdown_scene_map,
|
||||
@@ -163,6 +163,7 @@ def pipeline_init(HashPipeline):
|
||||
def collect_views(
|
||||
agent,
|
||||
collect_room_views_by_room,
|
||||
flatten_room_views,
|
||||
numpy_to_pil,
|
||||
room_nodes,
|
||||
sim,
|
||||
@@ -175,162 +176,108 @@ def collect_views(
|
||||
views_per_room=views_per_room,
|
||||
)
|
||||
|
||||
# Flatten room views into (room_id, view_idx, PIL.Image) tuples.
|
||||
room_views = flatten_room_views(all_room_views)
|
||||
|
||||
# Build dataset of (room_id, view_idx, PIL.Image) tuples.
|
||||
room_view_dataset = [
|
||||
(_room_id, _view_idx, numpy_to_pil(_rgb))
|
||||
for _room_id, _views in all_room_views.items()
|
||||
for _view_idx, _rgb in enumerate(_views)
|
||||
(_view.room_id, _view.view_idx, numpy_to_pil(_view.rgb))
|
||||
for _view in room_views
|
||||
]
|
||||
|
||||
print(f"Collected {len(room_view_dataset)} room views")
|
||||
return all_room_views, room_view_dataset
|
||||
return all_room_views, room_view_dataset, room_views
|
||||
|
||||
|
||||
@app.cell
|
||||
def build_scene_graph(
|
||||
ObjectNode,
|
||||
SimpleSceneGraph,
|
||||
bits_tensor_to_hash_bytes,
|
||||
SceneGraphBuildConfig,
|
||||
SceneGraphBuilder,
|
||||
cfg_manager,
|
||||
mo,
|
||||
np,
|
||||
numpy_to_pil,
|
||||
pipeline,
|
||||
room_nodes,
|
||||
room_view_dataset,
|
||||
room_views,
|
||||
save_object_image,
|
||||
save_room_view,
|
||||
text_labels,
|
||||
torch,
|
||||
):
|
||||
scene_graph = SimpleSceneGraph(
|
||||
rooms={_room.room_id: _room for _room in room_nodes},
|
||||
objects={},
|
||||
)
|
||||
|
||||
# Storage for cropped object images (for visualization).
|
||||
object_images = {}
|
||||
|
||||
output_dir = cfg_manager.get().output.directory / "verification"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_images = [item[2] for item in room_view_dataset]
|
||||
_metadata = [(item[0], item[1]) for item in room_view_dataset]
|
||||
builder = SceneGraphBuilder(
|
||||
pipeline=pipeline,
|
||||
config=SceneGraphBuildConfig(inference_batch_size=4),
|
||||
)
|
||||
|
||||
inference_batch_size = 4
|
||||
image_batches = [
|
||||
_images[index : index + inference_batch_size]
|
||||
for index in range(0, len(_images), inference_batch_size)
|
||||
pil_room_views = [
|
||||
type(_view)(
|
||||
room_id=_view.room_id,
|
||||
view_idx=_view.view_idx,
|
||||
rgb=numpy_to_pil(_view.rgb),
|
||||
depth=_view.depth,
|
||||
agent_position=_view.agent_position,
|
||||
agent_rotation=_view.agent_rotation,
|
||||
)
|
||||
for _view in room_views
|
||||
]
|
||||
|
||||
_cropped_images = []
|
||||
debug_meta = []
|
||||
hash_batches = []
|
||||
for _batch_images in mo.status.progress_bar(
|
||||
image_batches,
|
||||
title="Running pipeline inference on room views",
|
||||
subtitle=f"Batch size {inference_batch_size} with ETA",
|
||||
completion_title="Pipeline inference finished",
|
||||
completion_subtitle=(
|
||||
f"Processed {len(_images)} room views in {len(image_batches)} batches"
|
||||
),
|
||||
with mo.status.spinner(title="Building scene graph from room views"):
|
||||
scene_graph, build_artifacts = builder.build_from_room_views(
|
||||
room_nodes=room_nodes,
|
||||
room_views=pil_room_views,
|
||||
text_labels=text_labels,
|
||||
)
|
||||
|
||||
object_images = build_artifacts.object_images
|
||||
debug_meta = build_artifacts.debug_meta
|
||||
|
||||
# Save original room views.
|
||||
for _room_view in mo.status.progress_bar(
|
||||
pil_room_views,
|
||||
title="Saving room-view snapshots",
|
||||
subtitle="Writing original room images to disk",
|
||||
completion_title="Room-view snapshots saved",
|
||||
completion_subtitle=f"Saved {len(pil_room_views)} room views",
|
||||
show_eta=True,
|
||||
show_rate=True,
|
||||
remove_on_exit=False,
|
||||
):
|
||||
_batch_output = pipeline.process_batch(
|
||||
_batch_images,
|
||||
text_labels,
|
||||
batch_size=inference_batch_size,
|
||||
return_debug_details=True,
|
||||
)
|
||||
_cropped_images.extend(_batch_output.cropped_images)
|
||||
debug_meta.extend(_batch_output.debug_meta)
|
||||
if _batch_output.hash_bits.numel() > 0:
|
||||
hash_batches.append(_batch_output.hash_bits)
|
||||
save_room_view(output_dir, _room_view.room_id, _room_view.view_idx, _room_view.rgb)
|
||||
|
||||
if hash_batches:
|
||||
hash_tensor = torch.cat(hash_batches, dim=0)
|
||||
else:
|
||||
hash_tensor = torch.empty(
|
||||
(0, pipeline.hash_bits), dtype=torch.int32, device=pipeline.device
|
||||
# Save object crops.
|
||||
for _obj_id, _cropped in mo.status.progress_bar(
|
||||
object_images.items(),
|
||||
title="Saving object crops",
|
||||
subtitle="Writing cropped object images to disk",
|
||||
completion_title="Object crops saved",
|
||||
completion_subtitle=f"Saved {len(object_images)} object crops",
|
||||
show_eta=True,
|
||||
show_rate=True,
|
||||
remove_on_exit=False,
|
||||
):
|
||||
_node = scene_graph.objects[_obj_id]
|
||||
save_object_image(
|
||||
output_dir,
|
||||
_node.room_id,
|
||||
_obj_id,
|
||||
_node.last_seen_frame,
|
||||
0, # mask idx 0 ok for M0
|
||||
_cropped,
|
||||
)
|
||||
|
||||
from collections import Counter
|
||||
|
||||
_reasons = Counter(m["fallback_reason"] or "ok" for m in debug_meta)
|
||||
_meta_fallbacks = [_meta.get("fallback_reason") for _meta in debug_meta]
|
||||
fallback_count = sum(1 for f in _meta_fallbacks if f is not None)
|
||||
_reasons = Counter(f or "ok" for f in _meta_fallbacks)
|
||||
print(f"Fallback breakdown: {_reasons}")
|
||||
|
||||
# Save original room views.
|
||||
for _room_id, _view_idx, _image in mo.status.progress_bar(
|
||||
room_view_dataset,
|
||||
title="Saving room-view snapshots",
|
||||
subtitle="Writing original room images to disk",
|
||||
completion_title="Room-view snapshots saved",
|
||||
completion_subtitle=f"Saved {len(room_view_dataset)} room views",
|
||||
show_eta=True,
|
||||
show_rate=True,
|
||||
remove_on_exit=False,
|
||||
):
|
||||
save_room_view(output_dir, _room_id, _view_idx, _image)
|
||||
|
||||
# Prefix sum: map flat crop index to (input_image_idx, mask_idx).
|
||||
_num_selected = [_m["num_selected"] for _m in debug_meta]
|
||||
assert sum(_num_selected) == len(_cropped_images), (
|
||||
f"Sum of num_selected ({sum(_num_selected)}) != cropped_images count ({len(_cropped_images)})"
|
||||
)
|
||||
_prefix_sums = [0]
|
||||
for _n in _num_selected:
|
||||
_prefix_sums.append(_prefix_sums[-1] + _n)
|
||||
|
||||
_obj_counter = 0
|
||||
_total_crops = len(_cropped_images)
|
||||
object_tasks = []
|
||||
for _img_idx, _n_crops in enumerate(_num_selected):
|
||||
_room_id, _view_idx = _metadata[_img_idx]
|
||||
for _mask_idx in range(_n_crops):
|
||||
object_tasks.append((_img_idx, _room_id, _view_idx, _mask_idx, _n_crops))
|
||||
|
||||
for _img_idx, _room_id, _view_idx, _mask_idx, _n_crops in mo.status.progress_bar(
|
||||
object_tasks,
|
||||
title="Building scene graph objects",
|
||||
subtitle="Preparing cropped objects and hashes with ETA",
|
||||
completion_title="Scene graph build complete",
|
||||
completion_subtitle=f"Created {_total_crops} cropped object entries",
|
||||
show_eta=True,
|
||||
show_rate=True,
|
||||
remove_on_exit=False,
|
||||
):
|
||||
_crop_flat_idx = _prefix_sums[_img_idx] + _mask_idx
|
||||
_cropped = _cropped_images[_crop_flat_idx]
|
||||
_hash_bits = hash_tensor[_crop_flat_idx]
|
||||
|
||||
_obj_id = f"{_room_id}_v{_view_idx:03d}_m{_mask_idx:02d}"
|
||||
|
||||
_hash_bytes = bits_tensor_to_hash_bytes(_hash_bits)
|
||||
|
||||
object_images[_obj_id] = _cropped
|
||||
save_object_image(output_dir, _room_id, _obj_id, _view_idx, _mask_idx, _cropped)
|
||||
|
||||
scene_graph.objects[_obj_id] = ObjectNode(
|
||||
obj_id=_obj_id,
|
||||
room_id=_room_id,
|
||||
position=np.array([0.0, 0.0, 0.0], dtype=np.float32),
|
||||
visual_hash=_hash_bytes,
|
||||
semantic_hash=_hash_bytes,
|
||||
hit_count=1,
|
||||
last_seen_frame=_view_idx,
|
||||
)
|
||||
_obj_counter += 1
|
||||
|
||||
_fallback_count = sum(
|
||||
1 for _meta in debug_meta if _meta["fallback_reason"] is not None
|
||||
)
|
||||
|
||||
print(f"Created {_obj_counter} objects")
|
||||
print(f"Created {len(scene_graph.objects)} objects")
|
||||
print(f"Saved cropped images to: {output_dir}")
|
||||
print(f"Fallback frames: {_fallback_count}/{len(debug_meta)}")
|
||||
print(f"Fallback frames: {fallback_count}/{len(debug_meta)}")
|
||||
|
||||
return hash_tensor, object_images, output_dir, scene_graph
|
||||
return build_artifacts, object_images, output_dir, scene_graph
|
||||
|
||||
|
||||
@app.cell
|
||||
@@ -350,6 +297,12 @@ def build_tables(pl, scene_graph):
|
||||
"obj_id": obj.obj_id,
|
||||
"room_id": obj.room_id,
|
||||
"visual_hash": obj.visual_hash.hex()[:16] + "...",
|
||||
"label": obj.label,
|
||||
"confidence": obj.confidence,
|
||||
"source_view_id": obj.source_view_id,
|
||||
"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_z": float(obj.position[2]) if obj.position is not None else None,
|
||||
}
|
||||
for obj in scene_graph.objects.values()
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
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