memvid--memvid
287 行
9.4 KiB
Python
287 行
9.4 KiB
Python
"""
|
|
MemvidRetriever - Fast semantic search, QR frame extraction, and context assembly
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Tuple, Optional
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import time
|
|
from functools import lru_cache
|
|
import cv2
|
|
|
|
from .utils import (
|
|
extract_frame, decode_qr, batch_extract_and_decode,
|
|
extract_and_decode_cached
|
|
)
|
|
from .index import IndexManager
|
|
from .config import get_default_config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MemvidRetriever:
|
|
"""Fast retrieval from QR code videos using semantic search"""
|
|
|
|
def __init__(self, video_file: str, index_file: str,
|
|
config: Optional[Dict[str, Any]] = None):
|
|
"""
|
|
Initialize MemvidRetriever
|
|
|
|
Args:
|
|
video_file: Path to QR code video
|
|
index_file: Path to index file
|
|
config: Optional configuration
|
|
"""
|
|
self.video_file = str(Path(video_file).absolute())
|
|
self.index_file = str(Path(index_file).absolute())
|
|
self.config = config or get_default_config()
|
|
|
|
# Load index
|
|
self.index_manager = IndexManager(self.config)
|
|
self.index_manager.load(str(Path(index_file).with_suffix('')))
|
|
|
|
# Cache for decoded frames
|
|
self._frame_cache = {}
|
|
self._cache_size = self.config["retrieval"]["cache_size"]
|
|
|
|
# Verify video file
|
|
self._verify_video()
|
|
|
|
logger.info(f"Initialized retriever with {self.index_manager.get_stats()['total_chunks']} chunks")
|
|
|
|
def _verify_video(self):
|
|
"""Verify video file is accessible"""
|
|
cap = cv2.VideoCapture(self.video_file)
|
|
if not cap.isOpened():
|
|
raise ValueError(f"Cannot open video file: {self.video_file}")
|
|
|
|
self.total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
self.fps = cap.get(cv2.CAP_PROP_FPS)
|
|
cap.release()
|
|
|
|
logger.info(f"Video has {self.total_frames} frames at {self.fps} fps")
|
|
|
|
def search(self, query: str, top_k: int = 5) -> List[str]:
|
|
"""
|
|
Search for relevant chunks using semantic search
|
|
|
|
Args:
|
|
query: Search query
|
|
top_k: Number of results to return
|
|
|
|
Returns:
|
|
List of relevant text chunks
|
|
"""
|
|
start_time = time.time()
|
|
|
|
# Semantic search in index
|
|
search_results = self.index_manager.search(query, top_k)
|
|
|
|
# Extract unique frame numbers
|
|
frame_numbers = list(set(result[2]["frame"] for result in search_results))
|
|
|
|
# Decode frames in parallel
|
|
decoded_frames = self._decode_frames_parallel(frame_numbers)
|
|
|
|
# Extract text from decoded data
|
|
results = []
|
|
for chunk_id, distance, metadata in search_results:
|
|
frame_num = metadata["frame"]
|
|
if frame_num in decoded_frames:
|
|
try:
|
|
chunk_data = json.loads(decoded_frames[frame_num])
|
|
results.append(chunk_data["text"])
|
|
except (json.JSONDecodeError, KeyError):
|
|
# Fallback to metadata text
|
|
results.append(metadata["text"])
|
|
else:
|
|
# Fallback to metadata text if decode failed
|
|
results.append(metadata["text"])
|
|
|
|
elapsed = time.time() - start_time
|
|
logger.info(f"Search completed in {elapsed:.3f}s for query: '{query[:50]}...'")
|
|
|
|
return results
|
|
|
|
def get_chunk_by_id(self, chunk_id: int) -> Optional[str]:
|
|
"""
|
|
Get specific chunk by ID
|
|
|
|
Args:
|
|
chunk_id: Chunk ID
|
|
|
|
Returns:
|
|
Chunk text or None
|
|
"""
|
|
metadata = self.index_manager.get_chunk_by_id(chunk_id)
|
|
if metadata:
|
|
frame_num = metadata["frame"]
|
|
decoded = self._decode_single_frame(frame_num)
|
|
if decoded:
|
|
try:
|
|
chunk_data = json.loads(decoded)
|
|
return chunk_data["text"]
|
|
except (json.JSONDecodeError, KeyError):
|
|
pass
|
|
return metadata["text"]
|
|
return None
|
|
|
|
def _decode_single_frame(self, frame_number: int) -> Optional[str]:
|
|
"""Decode single frame with caching"""
|
|
# Check cache
|
|
if frame_number in self._frame_cache:
|
|
return self._frame_cache[frame_number]
|
|
|
|
# Decode frame
|
|
result = extract_and_decode_cached(self.video_file, frame_number)
|
|
|
|
# Update cache
|
|
if result and len(self._frame_cache) < self._cache_size:
|
|
self._frame_cache[frame_number] = result
|
|
|
|
return result
|
|
|
|
def _decode_frames_parallel(self, frame_numbers: List[int]) -> Dict[int, str]:
|
|
"""
|
|
Decode multiple frames in parallel
|
|
|
|
Args:
|
|
frame_numbers: List of frame numbers to decode
|
|
|
|
Returns:
|
|
Dict mapping frame number to decoded data
|
|
"""
|
|
# Check cache first
|
|
results = {}
|
|
uncached_frames = []
|
|
|
|
for frame_num in frame_numbers:
|
|
if frame_num in self._frame_cache:
|
|
results[frame_num] = self._frame_cache[frame_num]
|
|
else:
|
|
uncached_frames.append(frame_num)
|
|
|
|
if not uncached_frames:
|
|
return results
|
|
|
|
# Decode uncached frames in parallel
|
|
max_workers = self.config["retrieval"]["max_workers"]
|
|
decoded = batch_extract_and_decode(
|
|
self.video_file,
|
|
uncached_frames,
|
|
max_workers=max_workers
|
|
)
|
|
|
|
# Update results and cache
|
|
for frame_num, data in decoded.items():
|
|
results[frame_num] = data
|
|
if len(self._frame_cache) < self._cache_size:
|
|
self._frame_cache[frame_num] = data
|
|
|
|
return results
|
|
|
|
def search_with_metadata(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search with full metadata
|
|
|
|
Args:
|
|
query: Search query
|
|
top_k: Number of results
|
|
|
|
Returns:
|
|
List of result dictionaries with text, score, and metadata
|
|
"""
|
|
start_time = time.time()
|
|
|
|
# Semantic search
|
|
search_results = self.index_manager.search(query, top_k)
|
|
|
|
# Extract frame numbers
|
|
frame_numbers = list(set(result[2]["frame"] for result in search_results))
|
|
|
|
# Decode frames
|
|
decoded_frames = self._decode_frames_parallel(frame_numbers)
|
|
|
|
# Build results with metadata
|
|
results = []
|
|
for chunk_id, distance, metadata in search_results:
|
|
frame_num = metadata["frame"]
|
|
|
|
# Get text from decoded frame or metadata
|
|
if frame_num in decoded_frames:
|
|
try:
|
|
chunk_data = json.loads(decoded_frames[frame_num])
|
|
text = chunk_data["text"]
|
|
except (json.JSONDecodeError, KeyError):
|
|
text = metadata["text"]
|
|
else:
|
|
text = metadata["text"]
|
|
|
|
results.append({
|
|
"text": text,
|
|
"score": 1.0 / (1.0 + distance), # Convert distance to similarity score
|
|
"chunk_id": chunk_id,
|
|
"frame": frame_num,
|
|
"metadata": metadata
|
|
})
|
|
|
|
elapsed = time.time() - start_time
|
|
logger.info(f"Search with metadata completed in {elapsed:.3f}s")
|
|
|
|
return results
|
|
|
|
def get_context_window(self, chunk_id: int, window_size: int = 2) -> List[str]:
|
|
"""
|
|
Get chunk with surrounding context
|
|
|
|
Args:
|
|
chunk_id: Central chunk ID
|
|
window_size: Number of chunks before/after to include
|
|
|
|
Returns:
|
|
List of chunks in context window
|
|
"""
|
|
chunks = []
|
|
|
|
# Get chunks in window
|
|
for offset in range(-window_size, window_size + 1):
|
|
target_id = chunk_id + offset
|
|
chunk = self.get_chunk_by_id(target_id)
|
|
if chunk:
|
|
chunks.append(chunk)
|
|
|
|
return chunks
|
|
|
|
def prefetch_frames(self, frame_numbers: List[int]):
|
|
"""
|
|
Prefetch frames into cache for faster retrieval
|
|
|
|
Args:
|
|
frame_numbers: List of frame numbers to prefetch
|
|
"""
|
|
# Only prefetch frames not in cache
|
|
to_prefetch = [f for f in frame_numbers if f not in self._frame_cache]
|
|
|
|
if to_prefetch:
|
|
logger.info(f"Prefetching {len(to_prefetch)} frames...")
|
|
decoded = self._decode_frames_parallel(to_prefetch)
|
|
logger.info(f"Prefetched {len(decoded)} frames")
|
|
|
|
def clear_cache(self):
|
|
"""Clear frame cache"""
|
|
self._frame_cache.clear()
|
|
extract_and_decode_cached.cache_clear()
|
|
logger.info("Cleared frame cache")
|
|
|
|
def get_stats(self) -> Dict[str, Any]:
|
|
"""Get retriever statistics"""
|
|
return {
|
|
"video_file": self.video_file,
|
|
"total_frames": self.total_frames,
|
|
"fps": self.fps,
|
|
"cache_size": len(self._frame_cache),
|
|
"max_cache_size": self._cache_size,
|
|
"index_stats": self.index_manager.get_stats()
|
|
} |