""" Similarity Search Find similar images using embeddings for temporal geolocation """ from typing import Dict, List, Optional, Tuple from dataclasses import dataclass import numpy as np @dataclass class SimilarityMatch: """Similarity match result""" image_id: str similarity: float # 0-1 location: Optional[Tuple[float, float]] timestamp: Optional[str] metadata: Dict class SimilaritySearch: """ Find similar images using embeddings Uses: - Visual embeddings (from CLIP) - Scene embeddings - Object embeddings - Combined embeddings """ def __init__(self, embedding_dim: int = 512): self.embedding_dim = embedding_dim self.embeddings: Dict[str, np.ndarray] = {} self.metadata: Dict[str, Dict] = {} # In production, use FAISS or similar for efficient search self.use_faiss = False try: import faiss self.faiss_index = faiss.IndexFlatIP(embedding_dim) # Inner product for cosine similarity self.use_faiss = True except ImportError: pass def add_image( self, image_id: str, embedding: np.ndarray, location: Optional[Tuple[float, float]] = None, timestamp: Optional[str] = None, metadata: Dict = None ): """Add image to similarity index""" # Normalize embedding for cosine similarity embedding = embedding / np.linalg.norm(embedding) self.embeddings[image_id] = embedding self.metadata[image_id] = { "location": location, "timestamp": timestamp, "metadata": metadata or {} } # Add to FAISS if available if self.use_faiss: self.faiss_index.add(embedding.reshape(1, -1)) def search( self, query_embedding: np.ndarray, k: int = 5, min_similarity: float = 0.7 ) -> List[SimilarityMatch]: """ Search for similar images Args: query_embedding: Embedding of query image k: Number of results to return min_similarity: Minimum similarity threshold Returns: List of similarity matches """ # Normalize query embedding query_embedding = query_embedding / np.linalg.norm(query_embedding) if self.use_faiss and len(self.embeddings) > 0: # Use FAISS for fast search similarities, indices = self.faiss_index.search( query_embedding.reshape(1, -1), k ) results = [] image_ids = list(self.embeddings.keys()) for i, (sim, idx) in enumerate(zip(similarities[0], indices[0])): if sim >= min_similarity and idx < len(image_ids): image_id = image_ids[idx] meta = self.metadata[image_id] results.append(SimilarityMatch( image_id=image_id, similarity=float(sim), location=meta["location"], timestamp=meta["timestamp"], metadata=meta["metadata"] )) return results else: # Brute force search return self._brute_force_search(query_embedding, k, min_similarity) def _brute_force_search( self, query_embedding: np.ndarray, k: int, min_similarity: float ) -> List[SimilarityMatch]: """Brute force similarity search""" results = [] for image_id, embedding in self.embeddings.items(): # Cosine similarity similarity = np.dot(query_embedding, embedding) if similarity >= min_similarity: meta = self.metadata[image_id] results.append(SimilarityMatch( image_id=image_id, similarity=float(similarity), location=meta["location"], timestamp=meta["timestamp"], metadata=meta["metadata"] )) # Sort by similarity results.sort(key=lambda x: x.similarity, reverse=True) return results[:k] def find_temporal_matches( self, query_embedding: np.ndarray, location: Tuple[float, float], radius: float = 100.0, # meters k: int = 5 ) -> List[SimilarityMatch]: """ Find temporal matches at same location Useful for detecting changes over time """ # First find all matches all_matches = self.search(query_embedding, k=k * 2) # Filter by location temporal_matches = [] for match in all_matches: if match.location: # Calculate distance distance = self._haversine_distance( location[0], location[1], match.location[0], match.location[1] ) if distance <= radius: temporal_matches.append(match) return temporal_matches[:k] def detect_changes( self, current_embedding: np.ndarray, location: Tuple[float, float], radius: float = 50.0 ) -> Dict: """ Detect changes between current image and historical matches Returns: Dict with change analysis """ # Find historical matches historical = self.find_temporal_matches( current_embedding, location, radius, k=10 ) if not historical: return { "status": "no_history", "message": "No historical observations at this location" } # Find best match best_match = max(historical, key=lambda x: x.similarity) # Calculate change score # Lower similarity = more change change_score = 1 - best_match.similarity return { "status": "change_detected", "change_score": change_score, "best_match": { "image_id": best_match.image_id, "similarity": best_match.similarity, "timestamp": best_match.timestamp }, "historical_count": len(historical), "severity": "high" if change_score > 0.5 else "medium" if change_score > 0.3 else "low" } def _haversine_distance(self, lat1, lng1, lat2, lng2) -> float: """Calculate distance between two coordinates in meters""" import math R = 6371000 # Earth radius in meters phi1 = math.radians(lat1) phi2 = math.radians(lat2) delta_phi = math.radians(lat2 - lat1) delta_lambda = math.radians(lng2 - lng1) a = math.sin(delta_phi / 2) ** 2 + \ math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2 c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return R * c # Example usage def test_similarity_search(): """Test similarity search""" print("=== Testing Similarity Search ===\n") search = SimilaritySearch(embedding_dim=512) # Add some historical images np.random.seed(42) for i in range(10): embedding = np.random.randn(512) search.add_image( image_id=f"hist_{i:03d}", embedding=embedding, location=(13.7563 + np.random.randn() * 0.001, 100.5018 + np.random.randn() * 0.001), timestamp=f"2026-03-{i+1:02d}T10:00:00Z", metadata={"scene_type": "street_view"} ) # Query with similar embedding (very close to hist_000) query_embedding = search.embeddings["hist_000"] + np.random.randn(512) * 0.01 # Search results = search.search(query_embedding, k=5) print(f"Found {len(results)} similar images:") for match in results: print(f" - {match.image_id}: {match.similarity:.3f} similarity") if match.location: print(f" Location: {match.location}") if match.timestamp: print(f" Timestamp: {match.timestamp}") print() # Test temporal matching temporal = search.find_temporal_matches( query_embedding, location=(13.7563, 100.5018), radius=100 ) print(f"Found {len(temporal)} temporal matches at location") # Test change detection changes = search.detect_changes( query_embedding, location=(13.7563, 100.5018) ) print(f"\nChange detection:") print(f" Status: {changes['status']}") if 'change_score' in changes: print(f" Change score: {changes['change_score']:.3f}") print(f" Severity: {changes['severity']}") return results if __name__ == '__main__': test_similarity_search()