feat(scenegraph): add ImageHashPipeline protocol and update query API

- Introduce ImageHashPipeline Protocol for extensible hash computation
- Rename query_crop_index to query_index for clarity
- Make query_crop nullable to handle missing crop edge case
- Add keyword-only arguments for better API clarity
- Handle empty scene graph objects gracefully
- Add comprehensive test coverage for query_image_against_scene_graph
This commit is contained in:
2026-05-21 15:03:39 +08:00
parent ba96cec406
commit 101a675ece
4 changed files with 371 additions and 30 deletions

View File

@@ -2,7 +2,7 @@
This module exports the main scenegraph objects for easy import:
from mini_nav.scenegraph import SimpleSceneGraph, RoomNode, ObjectNode
from scenegraph import SimpleSceneGraph, RoomNode, ObjectNode
"""
from .hash_codec import (
@@ -13,13 +13,18 @@ from .hash_codec import (
hash_bytes_to_cam_row,
)
from .objectnode import ObjectNode
from .query import ImageSceneGraphQueryResult, query_image_against_scene_graph
from .query import (
ImageHashPipeline,
ImageSceneGraphQueryResult,
query_image_against_scene_graph,
)
from .roomnode import RoomNode
from .scenegraph import SceneGraphMatch, SimpleSceneGraph
from .software_cam import CamMatch, SoftwareCamIndex, xnor_popcount_score
__all__ = [
"CamMatch",
"ImageHashPipeline",
"ImageSceneGraphQueryResult",
"ObjectNode",
"RoomNode",

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from typing import Any, Protocol, runtime_checkable
from PIL import Image
@@ -9,20 +9,40 @@ from .hash_codec import bits_tensor_to_hash_bytes
from .scenegraph import SceneGraphMatch, SimpleSceneGraph
class ImageHashPipelineOutput(Protocol):
"""Protocol for the output of ImageHashPipeline.process_batch."""
hash_bits: Any # torch.Tensor
cropped_images: list[Image.Image]
@runtime_checkable
class ImageHashPipeline(Protocol):
"""Protocol for an image hash computation pipeline."""
def process_batch(
self,
images: list[Image.Image],
text_labels: list[str],
batch_size: int = 32,
) -> ImageHashPipelineOutput:
...
@dataclass(frozen=True)
class ImageSceneGraphQueryResult:
query_crop_index: int
query_index: int
query_hash: bytes
query_crop: Image.Image
query_crop: Image.Image | None
matches: list[SceneGraphMatch]
def query_image_against_scene_graph(
image: Image.Image,
pipeline: Any,
*,
pipeline: ImageHashPipeline,
scene_graph: SimpleSceneGraph,
text_labels: list[str],
*,
top_k: int = 1,
batch_size: int = 1,
) -> list[ImageSceneGraphQueryResult]:
@@ -31,25 +51,24 @@ def query_image_against_scene_graph(
cropped_images = list(output.cropped_images)
if hash_bits.numel() == 0:
if cropped_images:
raise ValueError("hash_bits and cropped_images must align")
return []
if hash_bits.dim() == 1:
hash_bits = hash_bits.unsqueeze(0)
if hash_bits.shape[0] != len(cropped_images):
raise ValueError("hash_bits and cropped_images must align")
results: list[ImageSceneGraphQueryResult] = []
for crop_index, query_bits in enumerate(hash_bits):
for query_index, query_bits in enumerate(hash_bits):
query_hash = bits_tensor_to_hash_bytes(query_bits)
matches = scene_graph.query_by_visual_hash(query_hash, top_k=top_k)
if scene_graph.objects:
matches = scene_graph.query_by_visual_hash(query_hash, top_k=top_k)
else:
matches = []
crop = cropped_images[query_index] if query_index < len(cropped_images) else None
results.append(
ImageSceneGraphQueryResult(
query_crop_index=crop_index,
query_index=query_index,
query_hash=query_hash,
query_crop=cropped_images[crop_index],
query_crop=crop,
matches=matches,
)
)