| """ |
| Retrieval engine combining feature extraction and FAISS search. |
| |
| Provides high-level API for image retrieval. |
| """ |
|
|
| import torch |
| import time |
| from PIL import Image |
| from typing import List, Dict, Optional, Tuple |
| from dataclasses import dataclass |
|
|
| from ..features.extractor import FeatureExtractor |
| from .index import FAISSIndex |
|
|
|
|
| @dataclass |
| class RetrievalResult: |
| """Result of a single retrieval query.""" |
| indices: List[int] |
| scores: List[float] |
| query_time_ms: float |
| modality: str |
|
|
|
|
| class RetrievalEngine: |
| """ |
| High-level retrieval engine. |
| |
| Combines feature extraction with FAISS search for fast image retrieval. |
| """ |
| |
| def __init__( |
| self, |
| feature_extractor: Optional[FeatureExtractor] = None, |
| index: Optional[FAISSIndex] = None, |
| device: Optional[str] = None |
| ): |
| """ |
| Initialize retrieval engine. |
| |
| Args: |
| feature_extractor: Feature extractor (creates new if None) |
| index: FAISS index (creates new if None) |
| device: Device to use |
| """ |
| self.feature_extractor = feature_extractor or FeatureExtractor(device=device) |
| self.index = index or FAISSIndex(embed_dim=self.feature_extractor.embed_dim) |
| |
| |
| self._query_times: List[float] = [] |
| |
| def build_index( |
| self, |
| embeddings: torch.Tensor, |
| save_path: Optional[str] = None |
| ) -> None: |
| """ |
| Build index from pre-computed embeddings. |
| |
| Args: |
| embeddings: Gallery embeddings, shape (N, embed_dim) |
| save_path: Optional path to save index |
| """ |
| self.index.build(embeddings) |
| |
| if save_path: |
| self.index.save(save_path) |
| |
| def query( |
| self, |
| image: Image.Image, |
| modality: str = "optical", |
| k: int = 5 |
| ) -> RetrievalResult: |
| """ |
| Query with a single image. |
| |
| Args: |
| image: Query image |
| modality: Image modality |
| k: Number of results |
| |
| Returns: |
| RetrievalResult with indices, scores, and timing |
| """ |
| start_time = time.perf_counter() |
| |
| |
| query_embedding = self.feature_extractor.extract_features( |
| image, modality=modality, normalize=True |
| ) |
| |
| |
| scores, indices = self.index.search(query_embedding, k=k) |
| |
| elapsed_ms = (time.perf_counter() - start_time) * 1000 |
| self._query_times.append(elapsed_ms) |
| |
| return RetrievalResult( |
| indices=indices[0].tolist(), |
| scores=scores[0].tolist(), |
| query_time_ms=elapsed_ms, |
| modality=modality |
| ) |
| |
| def batch_query( |
| self, |
| images: List[Image.Image], |
| modality: str = "optical", |
| k: int = 5 |
| ) -> List[RetrievalResult]: |
| """ |
| Query with multiple images. |
| |
| Args: |
| images: List of query images |
| modality: Image modality |
| k: Number of results |
| |
| Returns: |
| List of RetrievalResult |
| """ |
| results = [] |
| |
| for image in images: |
| result = self.query(image, modality=modality, k=k) |
| results.append(result) |
| |
| return results |
| |
| def get_timing_stats(self) -> Dict[str, float]: |
| """ |
| Get timing statistics. |
| |
| Returns: |
| Dict with mean, median, p95, p99 query times |
| """ |
| if not self._query_times: |
| return {"mean": 0, "median": 0, "p95": 0, "p99": 0} |
| |
| times = sorted(self._query_times) |
| n = len(times) |
| |
| return { |
| "mean": sum(times) / n, |
| "median": times[n // 2], |
| "p95": times[int(n * 0.95)] if n >= 20 else times[-1], |
| "p99": times[int(n * 0.99)] if n >= 100 else times[-1], |
| } |
| |
| @property |
| def _query_times(self) -> List[float]: |
| """Query times list (lazy init).""" |
| if not hasattr(self, '_query_times_list'): |
| self._query_times_list = [] |
| return self._query_times_list |
|
|
|
|
| |
| if __name__ == "__main__": |
| print("Testing RetrievalEngine...") |
| |
| |
| n_gallery = 50 |
| embed_dim = 768 |
| |
| |
| embeddings = torch.randn(n_gallery, embed_dim) |
| embeddings = torch.nn.functional.normalize(embeddings, dim=1) |
| |
| |
| engine = RetrievalEngine.__new__(RetrievalEngine) |
| engine.index = FAISSIndex(embed_dim) |
| engine._query_times_list = [] |
| |
| |
| engine.build_index(embeddings) |
| print(f"Index built with {engine.index.size} embeddings") |
| |
| |
| for _ in range(10): |
| start = time.perf_counter() |
| query = torch.randn(embed_dim) |
| query = torch.nn.functional.normalize(query, dim=0) |
| scores, indices = engine.index.search(query, k=5) |
| elapsed = (time.perf_counter() - start) * 1000 |
| engine._query_times_list.append(elapsed) |
| |
| |
| stats = engine.get_timing_stats() |
| print(f"Timing stats: {stats}") |
| |
| print("\nRetrievalEngine test passed!") |
|
|