bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
298 lines
9.0 KiB
Python
298 lines
9.0 KiB
Python
"""
|
|
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()
|