feat: serve VPR (EigenPlaces) via /v1/embeddings as 'vpr'/'eigenplaces'

Implements REQUEST-vpr-embeddings.md: a visual place recognition model that turns
a photo into ONE L2-normalised descriptor trained so two images of the SAME place
land close — the building-identity discrimination dinov2/gme/geoclip lack (they
rate similar-looking houses as matches). HomeHunter uses it to match listing
exteriors against Street View panoramas.

Backed by EigenPlaces (gmberton, ResNet50, 2048-d) loaded via torch.hub. Chosen
over SALAD/MixVPR for a clean dependency footprint: torch + torchvision only (no
pytorch_lightning). ImageNet-normalised, 512x512 eval transform (matches training
crops); output L2-normalised (idempotent — the net already ends in an L2 layer).
On GPU (nvidia engine device); CPU only as fallback.

The image data URI arrives in `input` (per the contract) or the `image` field.
torch.hub cache pinned to a persistent TORCH_HOME (/cache/torchhub) with the repo
+ weights + trusted_list pre-seeded, so the engine loads offline and
non-interactively (a cold torch.hub.load would otherwise hit an interactive trust
prompt that EOF-crashes a server).

Verified: 2048-d L2=1.0, deterministic, one vector/image; and the decisive
ordering test — same-place pairs (min cos 0.479) rank strictly above every
different-place pair (max 0.349), zero overlap. Config: models.json 'eigenplaces'
+ alias 'vpr', engine nvidia. SALAD (pytorch_lightning, subprocess-isolated) to
follow as a second, higher-accuracy option.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoSpEthysqmseCc6Geizty
parent fd3221c6
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.1.65" __version__ = "0.1.66"
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
# expandable_segments lets the allocator return freed pages to the driver even # expandable_segments lets the allocator return freed pages to the driver even
......
...@@ -130,6 +130,11 @@ class _EmbeddingModel: ...@@ -130,6 +130,11 @@ class _EmbeddingModel:
obj = self.model[0] if self.model else None obj = self.model[0] if self.model else None
if obj is not None and hasattr(obj, 'to'): if obj is not None and hasattr(obj, 'to'):
obj.to('cpu') obj.to('cpu')
elif self.backend == 'vpr':
# model is (net, device, transform); move the net off GPU
net = self.model[0] if self.model else None
if net is not None and hasattr(net, 'to'):
net.to('cpu')
except Exception: except Exception:
pass pass
self.model = None self.model = None
...@@ -334,6 +339,63 @@ def _geoclip_embed_images(model, pil_images, dimensions=None): ...@@ -334,6 +339,63 @@ def _geoclip_embed_images(model, pil_images, dimensions=None):
return _truncate_dims(results, dimensions) return _truncate_dims(results, dimensions)
# ---------------------------------------------------------------- VPR
# Visual Place Recognition: turn a photo into ONE L2-normalised descriptor trained
# so two images of the SAME place land close — discriminating building identity
# under viewpoint/lighting/season change, which dinov2 / gme / geoclip do NOT
# (REQUEST-vpr-embeddings.md: HomeHunter matches listing photos to Street View
# panoramas). Served as 'vpr' / 'eigenplaces'. Backed by EigenPlaces (gmberton,
# ResNet50, 2048-d) via torch.hub — clean deps (torch + torchvision, no
# pytorch_lightning, unlike SALAD/MixVPR).
_VPR_IDS = {'vpr', 'eigenplaces', 'eigenplaces-resnet50'}
def _is_vpr(model_name) -> bool:
return str(model_name).strip().lower().replace('_', '-') in _VPR_IDS
def _load_vpr(model_name, device, model_config=None):
import os
import torch
cfg = model_config or {}
raw = cfg.get('_raw_cfg') if isinstance(cfg.get('_raw_cfg'), dict) else {}
# Persistent torch.hub cache: the repo code, weights, and trusted_list live
# here so the engine loads OFFLINE and NON-INTERACTIVELY across restarts (a
# cold torch.hub.load would otherwise hit the network and an interactive trust
# prompt that EOF-crashes a server). Pre-seeded during setup.
os.environ.setdefault('TORCH_HOME',
os.environ.get('CODERAI_TORCH_HOME', '/cache/torchhub'))
backbone = cfg.get('vpr_backbone') or raw.get('vpr_backbone') or 'ResNet50'
fc_dim = int(cfg.get('vpr_dim') or raw.get('vpr_dim') or 2048)
net = torch.hub.load('gmberton/eigenplaces', 'get_trained_model',
backbone=backbone, fc_output_dim=fc_dim)
net = net.to(device).eval()
# EigenPlaces eval transform: resize to a square + ImageNet normalise (matches
# the 512x512 training crops). The network's final layer already L2-normalises;
# we normalise again downstream to guarantee the contract regardless.
import torchvision.transforms as T
size = int(cfg.get('vpr_image_size') or raw.get('vpr_image_size') or 512)
tfm = T.Compose([
T.Resize((size, size)),
T.ToTensor(),
T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
return _EmbeddingModel('vpr', (net, device, tfm))
def _vpr_embed_images(model, pil_images, dimensions=None):
"""Embed images -> one L2-normalised place descriptor each (pooled, not patch
tokens). Deterministic: same image -> same vector."""
import torch
import torch.nn.functional as F
net, device, tfm = model
batch = torch.stack([tfm(im.convert('RGB')) for im in pil_images]).to(device)
with torch.no_grad():
feats = F.normalize(net(batch), dim=-1)
results = [row.detach().cpu().tolist() for row in feats]
return _truncate_dims(results, dimensions)
def _load_embedding_model(model_name: str, device: str, model_config: dict = None): def _load_embedding_model(model_name: str, device: str, model_config: dict = None):
from codai.models.hf_loading import build_from_pretrained_kwargs from codai.models.hf_loading import build_from_pretrained_kwargs
trust = _trust_remote_code(model_config) trust = _trust_remote_code(model_config)
...@@ -343,6 +405,10 @@ def _load_embedding_model(model_name: str, device: str, model_config: dict = Non ...@@ -343,6 +405,10 @@ def _load_embedding_model(model_name: str, device: str, model_config: dict = Non
if _is_geoclip(model_name): if _is_geoclip(model_name):
return _load_geoclip(model_name, device) return _load_geoclip(model_name, device)
# Visual place recognition (EigenPlaces). Logical id, not a repo/file.
if _is_vpr(model_name):
return _load_vpr(model_name, device, model_config)
# GGUF file → llama.cpp in embedding mode (works on whatever backend this # GGUF file → llama.cpp in embedding mode (works on whatever backend this
# build targets: Vulkan on the radeon engine, CUDA on nvidia). Text-only — # build targets: Vulkan on the radeon engine, CUDA on nvidia). Text-only —
# llama.cpp's embedding path has no image tower wired here. # llama.cpp's embedding path has no image tower wired here.
...@@ -575,7 +641,7 @@ def _supports_images(model_obj) -> bool: ...@@ -575,7 +641,7 @@ def _supports_images(model_obj) -> bool:
return model[1] == 'image' return model[1] == 'image'
except Exception: except Exception:
return False return False
if backend in ('clip', 'vision', 'qwenvl', 'llama-vl', 'dinov2cpp'): if backend in ('clip', 'vision', 'qwenvl', 'llama-vl', 'dinov2cpp', 'vpr', 'vpr-server'):
return True return True
if backend != 'sentence_transformers': if backend != 'sentence_transformers':
return False return False
...@@ -883,6 +949,10 @@ def _embed_texts(model_obj, texts: List[str], dimensions=None) -> List[List[floa ...@@ -883,6 +949,10 @@ def _embed_texts(model_obj, texts: List[str], dimensions=None) -> List[List[floa
# image model: the HomeHunter contract sends the image data URI in 'input' # image model: the HomeHunter contract sends the image data URI in 'input'
return _geoclip_embed_images( return _geoclip_embed_images(
model, [_decode_image(t) for t in texts], dimensions) model, [_decode_image(t) for t in texts], dimensions)
elif backend == 'vpr':
# VPR is image-only; the contract sends the image data URI in 'input'.
return _vpr_embed_images(
model, [_decode_image(t) for t in texts], dimensions)
elif backend == 'llama': elif backend == 'llama':
# llama.cpp embedding mode. With a pooling type baked into the GGUF the # llama.cpp embedding mode. With a pooling type baked into the GGUF the
# result is one vector per input; without, per-token vectors — mean-pool # result is one vector per input; without, per-token vectors — mean-pool
...@@ -1023,6 +1093,8 @@ def _embed_images(model_obj, images: List[str], dimensions=None) -> List[List[fl ...@@ -1023,6 +1093,8 @@ def _embed_images(model_obj, images: List[str], dimensions=None) -> List[List[fl
dimensions) dimensions)
elif backend == 'geoclip': elif backend == 'geoclip':
return _geoclip_embed_images(model, pil_images, dimensions) return _geoclip_embed_images(model, pil_images, dimensions)
elif backend == 'vpr':
return _vpr_embed_images(model, pil_images, dimensions)
elif backend == 'dinov2cpp': elif backend == 'dinov2cpp':
# dinov2-embed subprocess: temp-file the PIL images, send paths, read # dinov2-embed subprocess: temp-file the PIL images, send paths, read
# JSON lines back; normalize (the binary emits raw CLS values). # JSON lines back; normalize (the binary emits raw CLS values).
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment