Spaces:
Sleeping
Sleeping
Afterimage live backend (FastAPI + FastEmbed + embedded Qdrant)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- Dockerfile +33 -0
- README.md +23 -5
- backend/app/__init__.py +1 -0
- backend/app/config.py +36 -0
- backend/app/embedder.py +77 -0
- backend/app/main.py +124 -0
- backend/app/manifest.py +92 -0
- backend/app/qdrant_store.py +114 -0
- backend/app/schemas.py +38 -0
- backend/app/services.py +264 -0
- backend/requirements.txt +10 -0
- backend/scripts/seed.py +113 -0
- backend/scripts/verify.py +110 -0
- data/assets/.gitkeep +1 -0
- data/assets/bank/crops/bank_curb_baseline.jpg +0 -0
- data/assets/bank/crops/bank_curb_incident.jpg +0 -0
- data/assets/bank/crops/variants/bank_curb_v01.jpg +0 -0
- data/assets/bank/crops/variants/bank_curb_v02.jpg +0 -0
- data/assets/bank/crops/variants/bank_curb_v03.jpg +0 -0
- data/assets/bank/crops/variants/bank_curb_v04.jpg +0 -0
- data/assets/bank/crops/variants/bank_curb_v05.jpg +0 -0
- data/assets/bank/crops/variants/bank_curb_v06.jpg +3 -0
- data/assets/bank/crops/variants/bank_curb_v07.jpg +0 -0
- data/assets/bank/crops/variants/bank_curb_v08.jpg +0 -0
- data/assets/bank/crops/variants/bank_curb_v09.jpg +0 -0
- data/assets/bank/crops/variants/bank_curb_v10.jpg +0 -0
- data/assets/bank/frames/street_cam/incident.jpg +0 -0
- data/assets/bank/frames/street_cam/normal.jpg +0 -0
- data/assets/museum/crops/museum_pedestal_baseline.jpg +0 -0
- data/assets/museum/crops/museum_pedestal_incident.jpg +0 -0
- data/assets/museum/crops/variants/museum_pedestal_v01.jpg +0 -0
- data/assets/museum/crops/variants/museum_pedestal_v02.jpg +0 -0
- data/assets/museum/crops/variants/museum_pedestal_v03.jpg +0 -0
- data/assets/museum/crops/variants/museum_pedestal_v04.jpg +0 -0
- data/assets/museum/crops/variants/museum_pedestal_v05.jpg +0 -0
- data/assets/museum/crops/variants/museum_pedestal_v06.jpg +0 -0
- data/assets/museum/crops/variants/museum_pedestal_v07.jpg +0 -0
- data/assets/museum/crops/variants/museum_pedestal_v08.jpg +0 -0
- data/assets/museum/crops/variants/museum_pedestal_v09.jpg +0 -0
- data/assets/museum/crops/variants/museum_pedestal_v10.jpg +0 -0
- data/assets/museum/frames/museum_cam/incident.jpg +0 -0
- data/assets/museum/frames/museum_cam/normal.jpg +0 -0
- data/assets/vault/crops/variants/vault_pedestal_v01.jpg +0 -0
- data/assets/vault/crops/variants/vault_pedestal_v02.jpg +0 -0
- data/assets/vault/crops/variants/vault_pedestal_v03.jpg +0 -0
- data/assets/vault/crops/variants/vault_pedestal_v04.jpg +0 -0
- data/assets/vault/crops/variants/vault_pedestal_v05.jpg +0 -0
- data/assets/vault/crops/variants/vault_pedestal_v06.jpg +0 -0
- data/assets/vault/crops/variants/vault_pedestal_v07.jpg +0 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/assets/bank/crops/variants/bank_curb_v06.jpg filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Afterimage live backend — FastAPI + FastEmbed CLIP + embedded Qdrant.
|
| 2 |
+
# Built for a Hugging Face Docker Space (runs as UID 1000, serves on :7860).
|
| 3 |
+
FROM python:3.11-slim
|
| 4 |
+
|
| 5 |
+
RUN useradd -m -u 1000 user && mkdir -p /app && chown user:user /app
|
| 6 |
+
USER user
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
ENV HOME=/home/user \
|
| 10 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 11 |
+
PYTHONUNBUFFERED=1 \
|
| 12 |
+
AFTERIMAGE_ASSET_ROOT=/app/data/assets \
|
| 13 |
+
AFTERIMAGE_MANIFEST=/app/data/manifest.json \
|
| 14 |
+
QDRANT_URL=path:/app/.qdrant-local \
|
| 15 |
+
AFTERIMAGE_ALLOW_FAKE_EMBEDDINGS=0 \
|
| 16 |
+
AFTERIMAGE_CORS_ORIGINS=* \
|
| 17 |
+
HF_HOME=/home/user/.cache/huggingface \
|
| 18 |
+
FASTEMBED_CACHE_DIR=/home/user/.cache/fastembed
|
| 19 |
+
|
| 20 |
+
COPY --chown=user backend/requirements.txt ./backend/requirements.txt
|
| 21 |
+
RUN pip install --no-cache-dir --user -r backend/requirements.txt
|
| 22 |
+
|
| 23 |
+
COPY --chown=user backend/ ./backend/
|
| 24 |
+
COPY --chown=user data/ ./data/
|
| 25 |
+
|
| 26 |
+
# Bake the model cache + seeded embedded Qdrant into the image so boots are fast.
|
| 27 |
+
# (Downloads CLIP ViT-B/32, embeds the 36 region/baseline/incident points.)
|
| 28 |
+
RUN cd backend && python scripts/seed.py \
|
| 29 |
+
&& python -c "from app.config import get_settings; from app.embedder import ImageEmbedder; ImageEmbedder(get_settings()).embed_text('warm up the clip text tower')" || echo "text-tower preload skipped"
|
| 30 |
+
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
WORKDIR /app/backend
|
| 33 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,28 @@
|
|
| 1 |
---
|
| 2 |
-
title: Afterimage
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Afterimage API
|
| 3 |
+
emoji: 🛰️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Afterimage — live Qdrant backend
|
| 13 |
+
|
| 14 |
+
The real FastAPI + FastEmbed CLIP + embedded **Qdrant** backend behind
|
| 15 |
+
[Afterimage](https://afterimage-qdrant.vercel.app) — searchable visual memory for
|
| 16 |
+
physical spaces.
|
| 17 |
+
|
| 18 |
+
Every request runs live against Qdrant:
|
| 19 |
+
|
| 20 |
+
- `POST /api/anomaly/scan?scenario=vault` — `query_points` filtered nearest-neighbour;
|
| 21 |
+
flags the region when its live crop falls below the data-derived floor (`mean − 3σ`).
|
| 22 |
+
- `GET /api/outliers?scenario=vault` — `RecommendQuery(best_score)` outlier vs baselines.
|
| 23 |
+
- `GET /api/matrix?scenario=vault` — `search_matrix_pairs` distance matrix.
|
| 24 |
+
- `GET /api/text_search?q=a+van` — open-vocabulary CLIP text→image search.
|
| 25 |
+
- `GET /api/health` — collection + embedding mode.
|
| 26 |
+
|
| 27 |
+
512-dim cosine vectors, one `object_memory` collection, no training, no labels.
|
| 28 |
+
Interactive docs at `/docs`.
|
backend/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from pydantic import Field
|
| 4 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Settings(BaseSettings):
|
| 8 |
+
qdrant_url: str = Field(default="http://localhost:6333", alias="QDRANT_URL")
|
| 9 |
+
qdrant_api_key: str | None = Field(default=None, alias="QDRANT_API_KEY")
|
| 10 |
+
asset_root: Path = Field(default=Path("../data/assets"), alias="AFTERIMAGE_ASSET_ROOT")
|
| 11 |
+
manifest_path: Path = Field(default=Path("../data/manifest.json"), alias="AFTERIMAGE_MANIFEST")
|
| 12 |
+
allow_fake_embeddings: bool = Field(default=False, alias="AFTERIMAGE_ALLOW_FAKE_EMBEDDINGS")
|
| 13 |
+
cors_origins: str = Field(default="http://localhost:5173", alias="AFTERIMAGE_CORS_ORIGINS")
|
| 14 |
+
collection_name: str = "object_memory"
|
| 15 |
+
vector_size: int = 512
|
| 16 |
+
model_name: str = "Qdrant/clip-ViT-B-32-vision"
|
| 17 |
+
text_model_name: str = "Qdrant/clip-ViT-B-32-text"
|
| 18 |
+
|
| 19 |
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def cors_origin_list(self) -> list[str]:
|
| 23 |
+
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
| 24 |
+
|
| 25 |
+
@property
|
| 26 |
+
def resolved_asset_root(self) -> Path:
|
| 27 |
+
return self.asset_root.expanduser().resolve()
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def resolved_manifest_path(self) -> Path:
|
| 31 |
+
return self.manifest_path.expanduser().resolve()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@lru_cache
|
| 35 |
+
def get_settings() -> Settings:
|
| 36 |
+
return Settings()
|
backend/app/embedder.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
import hashlib
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
from .config import Settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ImageEmbedder:
|
| 11 |
+
def __init__(self, settings: Settings):
|
| 12 |
+
self.settings = settings
|
| 13 |
+
self._model = None
|
| 14 |
+
self._text_model = None
|
| 15 |
+
self.mode = "fastembed"
|
| 16 |
+
|
| 17 |
+
def _load_model(self):
|
| 18 |
+
if self._model is not None:
|
| 19 |
+
return self._model
|
| 20 |
+
try:
|
| 21 |
+
from fastembed import ImageEmbedding
|
| 22 |
+
|
| 23 |
+
self._model = ImageEmbedding(model_name=self.settings.model_name)
|
| 24 |
+
return self._model
|
| 25 |
+
except Exception:
|
| 26 |
+
if not self.settings.allow_fake_embeddings:
|
| 27 |
+
raise
|
| 28 |
+
self.mode = "deterministic-fallback"
|
| 29 |
+
self._model = False
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
def _load_text_model(self):
|
| 33 |
+
if self._text_model is not None:
|
| 34 |
+
return self._text_model
|
| 35 |
+
from fastembed import TextEmbedding
|
| 36 |
+
|
| 37 |
+
self._text_model = TextEmbedding(model_name=self.settings.text_model_name)
|
| 38 |
+
return self._text_model
|
| 39 |
+
|
| 40 |
+
def embed_text(self, query: str) -> list[float]:
|
| 41 |
+
# CLIP text tower — shares the image vectors' 512-d space, so a text
|
| 42 |
+
# query can search the visual memory directly.
|
| 43 |
+
model = self._load_text_model()
|
| 44 |
+
vector = next(iter(model.embed([query])))
|
| 45 |
+
return self._normalize(np.asarray(vector, dtype=np.float32))
|
| 46 |
+
|
| 47 |
+
def embed_path(self, path: Path) -> list[float]:
|
| 48 |
+
if not path.exists():
|
| 49 |
+
raise FileNotFoundError(f"Image not found: {path}")
|
| 50 |
+
model = self._load_model()
|
| 51 |
+
if model is None:
|
| 52 |
+
return self._fallback_embedding(path)
|
| 53 |
+
image = Image.open(path).convert("RGB")
|
| 54 |
+
vector = next(iter(model.embed([image])))
|
| 55 |
+
return self._normalize(np.asarray(vector, dtype=np.float32))
|
| 56 |
+
|
| 57 |
+
def _fallback_embedding(self, path: Path) -> list[float]:
|
| 58 |
+
image = Image.open(path).convert("RGB").resize((64, 64))
|
| 59 |
+
arr = np.asarray(image, dtype=np.float32) / 255.0
|
| 60 |
+
means = arr.mean(axis=(0, 1))
|
| 61 |
+
stds = arr.std(axis=(0, 1))
|
| 62 |
+
hist = []
|
| 63 |
+
for channel in range(3):
|
| 64 |
+
values, _ = np.histogram(arr[:, :, channel], bins=32, range=(0, 1), density=True)
|
| 65 |
+
hist.extend(values.tolist())
|
| 66 |
+
digest = hashlib.sha256(path.read_bytes()).digest()
|
| 67 |
+
noise = np.frombuffer(digest * 16, dtype=np.uint8)[:410].astype(np.float32) / 255.0
|
| 68 |
+
vector = np.concatenate([means, stds, np.array(hist, dtype=np.float32), noise])
|
| 69 |
+
return self._normalize(vector[: self.settings.vector_size])
|
| 70 |
+
|
| 71 |
+
def _normalize(self, vector: np.ndarray) -> list[float]:
|
| 72 |
+
if vector.shape[0] != self.settings.vector_size:
|
| 73 |
+
raise ValueError(f"Expected {self.settings.vector_size}-dim vector, got {vector.shape[0]}")
|
| 74 |
+
norm = np.linalg.norm(vector)
|
| 75 |
+
if norm == 0:
|
| 76 |
+
return vector.tolist()
|
| 77 |
+
return (vector / norm).astype(float).tolist()
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from threading import Lock
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI, HTTPException, Query
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from fastapi.staticfiles import StaticFiles
|
| 6 |
+
|
| 7 |
+
from .config import get_settings
|
| 8 |
+
from .schemas import ObjectSearchRequest, TrailRequest
|
| 9 |
+
from .services import AfterimageService
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
settings = get_settings()
|
| 13 |
+
_service_lock = Lock()
|
| 14 |
+
_service_instance: AfterimageService | None = None
|
| 15 |
+
app = FastAPI(title="Afterimage API", version="0.1.0")
|
| 16 |
+
app.add_middleware(
|
| 17 |
+
CORSMiddleware,
|
| 18 |
+
allow_origins=settings.cors_origin_list,
|
| 19 |
+
allow_credentials=False,
|
| 20 |
+
allow_methods=["*"],
|
| 21 |
+
allow_headers=["*"],
|
| 22 |
+
)
|
| 23 |
+
app.mount("/assets", StaticFiles(directory=settings.resolved_asset_root), name="assets")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def service() -> AfterimageService:
|
| 27 |
+
global _service_instance
|
| 28 |
+
if _service_instance is None:
|
| 29 |
+
with _service_lock:
|
| 30 |
+
if _service_instance is None:
|
| 31 |
+
_service_instance = AfterimageService(settings)
|
| 32 |
+
return _service_instance
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def bad_request(exc: Exception) -> HTTPException:
|
| 36 |
+
return HTTPException(status_code=400, detail=str(exc))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@app.get("/api/health")
|
| 40 |
+
def health():
|
| 41 |
+
try:
|
| 42 |
+
return service().health()
|
| 43 |
+
except Exception as exc:
|
| 44 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@app.get("/api/scenarios")
|
| 48 |
+
def scenarios():
|
| 49 |
+
try:
|
| 50 |
+
return service().scenarios()
|
| 51 |
+
except Exception as exc:
|
| 52 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.get("/api/memories")
|
| 56 |
+
def memories(
|
| 57 |
+
scenario: str = Query(default="vault", max_length=60),
|
| 58 |
+
zone: str | None = Query(default=None),
|
| 59 |
+
region_id: str | None = Query(default=None),
|
| 60 |
+
is_baseline: bool | None = Query(default=None),
|
| 61 |
+
):
|
| 62 |
+
try:
|
| 63 |
+
return service().memories(scenario=scenario, zone=zone, region_id=region_id, is_baseline=is_baseline)
|
| 64 |
+
except ValueError as exc:
|
| 65 |
+
raise bad_request(exc) from exc
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@app.post("/api/anomaly/scan")
|
| 69 |
+
def anomaly_scan(scenario: str = Query(default="vault", max_length=60)):
|
| 70 |
+
try:
|
| 71 |
+
return service().scan_anomalies(scenario=scenario)
|
| 72 |
+
except (FileNotFoundError, ValueError) as exc:
|
| 73 |
+
raise bad_request(exc) from exc
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@app.post("/api/object/search")
|
| 77 |
+
def object_search(request: ObjectSearchRequest):
|
| 78 |
+
try:
|
| 79 |
+
return service().object_search(
|
| 80 |
+
scenario=request.scenario,
|
| 81 |
+
asset_id=request.asset_id or request.crop_id,
|
| 82 |
+
image_ref=request.image_ref,
|
| 83 |
+
filters=request.filters,
|
| 84 |
+
limit=request.limit,
|
| 85 |
+
)
|
| 86 |
+
except (FileNotFoundError, ValueError) as exc:
|
| 87 |
+
raise bad_request(exc) from exc
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.post("/api/trail")
|
| 91 |
+
def trail(request: TrailRequest):
|
| 92 |
+
try:
|
| 93 |
+
return service().trail(
|
| 94 |
+
scenario=request.scenario,
|
| 95 |
+
asset_id=request.asset_id or request.crop_id,
|
| 96 |
+
image_ref=request.image_ref,
|
| 97 |
+
score_cutoff=request.score_cutoff,
|
| 98 |
+
)
|
| 99 |
+
except (FileNotFoundError, ValueError) as exc:
|
| 100 |
+
raise bad_request(exc) from exc
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@app.get("/api/outliers")
|
| 104 |
+
def outliers(scenario: str = Query(default="vault", max_length=60)):
|
| 105 |
+
try:
|
| 106 |
+
return service().outliers(scenario=scenario)
|
| 107 |
+
except ValueError as exc:
|
| 108 |
+
raise bad_request(exc) from exc
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@app.get("/api/matrix")
|
| 112 |
+
def matrix(scenario: str = Query(default="vault", max_length=60), sample: int = Query(default=24, ge=1, le=100)):
|
| 113 |
+
try:
|
| 114 |
+
return service().matrix(scenario=scenario, sample=sample)
|
| 115 |
+
except ValueError as exc:
|
| 116 |
+
raise bad_request(exc) from exc
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@app.get("/api/text_search")
|
| 120 |
+
def text_search(q: str = Query(..., max_length=120), scenario: str | None = Query(default=None, max_length=60)):
|
| 121 |
+
try:
|
| 122 |
+
return service().text_search(query=q, scenario=scenario)
|
| 123 |
+
except (FileNotFoundError, ValueError) as exc:
|
| 124 |
+
raise bad_request(exc) from exc
|
backend/app/manifest.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
import json
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from .config import Settings
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Manifest:
|
| 9 |
+
def __init__(self, settings: Settings):
|
| 10 |
+
self.settings = settings
|
| 11 |
+
with settings.resolved_manifest_path.open("r", encoding="utf-8") as handle:
|
| 12 |
+
self.data: dict[str, Any] = json.load(handle)
|
| 13 |
+
|
| 14 |
+
@property
|
| 15 |
+
def scenarios(self) -> list[dict[str, Any]]:
|
| 16 |
+
if "scenarios" in self.data:
|
| 17 |
+
return self.data["scenarios"]
|
| 18 |
+
legacy = {**self.data, "id": "trail", "title": "Trail", "kind": "trail"}
|
| 19 |
+
return [legacy]
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def default_scenario(self) -> str:
|
| 23 |
+
return self.data.get("default_scenario", self.scenarios[0]["id"])
|
| 24 |
+
|
| 25 |
+
def scenario(self, scenario_id: str | None = None) -> dict[str, Any]:
|
| 26 |
+
selected = scenario_id or self.default_scenario
|
| 27 |
+
for scenario in self.scenarios:
|
| 28 |
+
if scenario["id"] == selected:
|
| 29 |
+
return scenario
|
| 30 |
+
raise ValueError(f"Unknown scenario: {selected}")
|
| 31 |
+
|
| 32 |
+
def scenario_summaries(self) -> list[dict[str, Any]]:
|
| 33 |
+
return [
|
| 34 |
+
{
|
| 35 |
+
"id": item["id"],
|
| 36 |
+
"title": item.get("title", item["id"]),
|
| 37 |
+
"setting": item.get("setting", ""),
|
| 38 |
+
"kind": item.get("kind", "anomaly"),
|
| 39 |
+
"anomaly_type": item.get("anomaly_type"),
|
| 40 |
+
"summary": item.get("summary", ""),
|
| 41 |
+
"clips": item.get("clips"),
|
| 42 |
+
"hero_frame": item.get("hero_frame") or item.get("cameras", [{}])[0].get("baseline_frame"),
|
| 43 |
+
"artifact_asset_id": item.get("artifact_asset_id"),
|
| 44 |
+
}
|
| 45 |
+
for item in self.scenarios
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
def cameras(self, scenario_id: str | None = None) -> list[dict[str, Any]]:
|
| 49 |
+
return self.scenario(scenario_id).get("cameras", [])
|
| 50 |
+
|
| 51 |
+
def regions(self, scenario_id: str | None = None) -> list[dict[str, Any]]:
|
| 52 |
+
return self.scenario(scenario_id).get("regions", [])
|
| 53 |
+
|
| 54 |
+
def objects(self, scenario_id: str | None = None) -> list[dict[str, Any]]:
|
| 55 |
+
return self.scenario(scenario_id).get("objects", [])
|
| 56 |
+
|
| 57 |
+
def floorplan(self, scenario_id: str | None = None) -> dict[str, Any]:
|
| 58 |
+
return self.scenario(scenario_id).get("floorplan", {})
|
| 59 |
+
|
| 60 |
+
def artifact_query_crop(self, scenario_id: str | None = None) -> str:
|
| 61 |
+
return self.scenario(scenario_id)["artifact_query_crop"]
|
| 62 |
+
|
| 63 |
+
def asset_path(self, relative: str) -> Path:
|
| 64 |
+
clean = relative.removeprefix("/assets/").lstrip("/")
|
| 65 |
+
path = Path(clean)
|
| 66 |
+
if path.is_absolute() or ".." in path.parts:
|
| 67 |
+
raise ValueError("Asset path must stay inside data/assets.")
|
| 68 |
+
if path.suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp"}:
|
| 69 |
+
raise ValueError("Asset path must reference an image file.")
|
| 70 |
+
resolved = (self.settings.resolved_asset_root / path).resolve()
|
| 71 |
+
if not resolved.is_relative_to(self.settings.resolved_asset_root):
|
| 72 |
+
raise ValueError("Asset path escaped the configured asset root.")
|
| 73 |
+
return resolved
|
| 74 |
+
|
| 75 |
+
def asset_url(self, relative: str) -> str:
|
| 76 |
+
return f"/assets/{relative}"
|
| 77 |
+
|
| 78 |
+
def object_by_asset(self, scenario_id: str, asset_id: str) -> dict[str, Any] | None:
|
| 79 |
+
for item in self.objects(scenario_id):
|
| 80 |
+
if item.get("asset_id") == asset_id:
|
| 81 |
+
return item
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
def crop_for_ref(self, scenario_id: str, asset_id: str | None, image_ref: str | None) -> Path:
|
| 85 |
+
if asset_id:
|
| 86 |
+
item = self.object_by_asset(scenario_id, asset_id)
|
| 87 |
+
if item is None:
|
| 88 |
+
raise ValueError(f"Unknown asset_id: {asset_id}")
|
| 89 |
+
return self.asset_path(item["crop"])
|
| 90 |
+
if image_ref:
|
| 91 |
+
return self.asset_path(image_ref.removeprefix("/assets/"))
|
| 92 |
+
return self.asset_path(self.artifact_query_crop(scenario_id))
|
backend/app/qdrant_store.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
from uuid import NAMESPACE_URL, uuid5
|
| 3 |
+
|
| 4 |
+
from qdrant_client import QdrantClient, models
|
| 5 |
+
|
| 6 |
+
from .config import Settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def point_id(asset_id: str) -> str:
|
| 10 |
+
return str(uuid5(NAMESPACE_URL, f"afterimage:{asset_id}"))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class QdrantStore:
|
| 14 |
+
def __init__(self, settings: Settings):
|
| 15 |
+
self.settings = settings
|
| 16 |
+
if settings.qdrant_url.startswith("path:"):
|
| 17 |
+
self.client = QdrantClient(path=settings.qdrant_url.removeprefix("path:"))
|
| 18 |
+
else:
|
| 19 |
+
self.client = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key or None)
|
| 20 |
+
|
| 21 |
+
def ensure_collection(self, recreate: bool = False) -> None:
|
| 22 |
+
name = self.settings.collection_name
|
| 23 |
+
exists = self.client.collection_exists(name)
|
| 24 |
+
if exists and recreate:
|
| 25 |
+
self.client.delete_collection(name)
|
| 26 |
+
exists = False
|
| 27 |
+
if not exists:
|
| 28 |
+
self.client.create_collection(
|
| 29 |
+
collection_name=name,
|
| 30 |
+
vectors_config=models.VectorParams(
|
| 31 |
+
size=self.settings.vector_size,
|
| 32 |
+
distance=models.Distance.COSINE,
|
| 33 |
+
),
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
def upsert(self, points: list[models.PointStruct]) -> None:
|
| 37 |
+
self.client.upsert(collection_name=self.settings.collection_name, points=points, wait=True)
|
| 38 |
+
|
| 39 |
+
def count(self) -> int:
|
| 40 |
+
return self.client.count(collection_name=self.settings.collection_name, exact=True).count
|
| 41 |
+
|
| 42 |
+
def search(self, vector: Any, query_filter: models.Filter | None, limit: int = 8):
|
| 43 |
+
return self.client.query_points(
|
| 44 |
+
collection_name=self.settings.collection_name,
|
| 45 |
+
query=vector,
|
| 46 |
+
query_filter=query_filter,
|
| 47 |
+
limit=limit,
|
| 48 |
+
with_payload=True,
|
| 49 |
+
with_vectors=False,
|
| 50 |
+
).points
|
| 51 |
+
|
| 52 |
+
def recommend_best_score(self, negative_ids: list[str], query_filter: models.Filter | None, limit: int = 8):
|
| 53 |
+
if not negative_ids:
|
| 54 |
+
raise ValueError("Recommendation query requires at least one negative example.")
|
| 55 |
+
query = models.RecommendQuery(
|
| 56 |
+
recommend=models.RecommendInput(
|
| 57 |
+
negative=negative_ids,
|
| 58 |
+
strategy=models.RecommendStrategy.BEST_SCORE,
|
| 59 |
+
)
|
| 60 |
+
)
|
| 61 |
+
return self.client.query_points(
|
| 62 |
+
collection_name=self.settings.collection_name,
|
| 63 |
+
query=query,
|
| 64 |
+
query_filter=query_filter,
|
| 65 |
+
limit=limit,
|
| 66 |
+
with_payload=True,
|
| 67 |
+
with_vectors=False,
|
| 68 |
+
).points
|
| 69 |
+
|
| 70 |
+
def search_matrix_pairs(self, query_filter: models.Filter | None = None, limit: int = 3, sample: int = 24):
|
| 71 |
+
return self.client.search_matrix_pairs(
|
| 72 |
+
collection_name=self.settings.collection_name,
|
| 73 |
+
query_filter=query_filter,
|
| 74 |
+
limit=limit,
|
| 75 |
+
sample=sample,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
def scroll(self, query_filter: models.Filter | None = None, limit: int = 100):
|
| 79 |
+
points, _ = self.client.scroll(
|
| 80 |
+
collection_name=self.settings.collection_name,
|
| 81 |
+
scroll_filter=query_filter,
|
| 82 |
+
limit=limit,
|
| 83 |
+
with_payload=True,
|
| 84 |
+
with_vectors=False,
|
| 85 |
+
)
|
| 86 |
+
return points
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def match_filter(**conditions: Any) -> models.Filter | None:
|
| 90 |
+
must = []
|
| 91 |
+
for key, value in conditions.items():
|
| 92 |
+
if value is None:
|
| 93 |
+
continue
|
| 94 |
+
must.append(models.FieldCondition(key=key, match=models.MatchValue(value=value)))
|
| 95 |
+
return models.Filter(must=must) if must else None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def payload_filter(filters: dict[str, Any]) -> models.Filter | None:
|
| 99 |
+
must = []
|
| 100 |
+
for key in ("scenario", "camera_id", "zone", "is_baseline", "memory_type"):
|
| 101 |
+
value = filters.get(key)
|
| 102 |
+
if value is not None:
|
| 103 |
+
must.append(models.FieldCondition(key=key, match=models.MatchValue(value=value)))
|
| 104 |
+
time_range = filters.get("time_range")
|
| 105 |
+
if time_range:
|
| 106 |
+
must.append(models.FieldCondition(key="timestamp", range=models.Range(gte=time_range[0], lte=time_range[1])))
|
| 107 |
+
return models.Filter(must=must) if must else None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def inspector_results(points) -> list[dict[str, Any]]:
|
| 111 |
+
results = []
|
| 112 |
+
for point in points:
|
| 113 |
+
results.append({"id": point.id, "score": point.score, "payload": point.payload})
|
| 114 |
+
return results
|
backend/app/schemas.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class QueryFilters(BaseModel):
|
| 7 |
+
scenario: str | None = None
|
| 8 |
+
camera_id: str | None = None
|
| 9 |
+
zone: str | None = None
|
| 10 |
+
is_baseline: bool | None = None
|
| 11 |
+
memory_type: str | None = None
|
| 12 |
+
time_range: tuple[int, int] | None = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ObjectSearchRequest(BaseModel):
|
| 16 |
+
scenario: str = Field(default="vault", max_length=60)
|
| 17 |
+
crop_id: str | None = Field(default=None, max_length=140)
|
| 18 |
+
asset_id: str | None = Field(default=None, max_length=140)
|
| 19 |
+
image_ref: str | None = Field(default=None, max_length=220)
|
| 20 |
+
filters: QueryFilters = Field(default_factory=QueryFilters)
|
| 21 |
+
limit: int = Field(default=8, ge=1, le=50)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TrailRequest(BaseModel):
|
| 25 |
+
scenario: str = Field(default="vault", max_length=60)
|
| 26 |
+
crop_id: str | None = Field(default=None, max_length=140)
|
| 27 |
+
asset_id: str | None = Field(default=None, max_length=140)
|
| 28 |
+
image_ref: str | None = Field(default=None, max_length=220)
|
| 29 |
+
score_cutoff: float = Field(default=0.2, ge=0, le=1)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Inspector(BaseModel):
|
| 33 |
+
api: str
|
| 34 |
+
collection: str
|
| 35 |
+
filter: dict[str, Any] | None = None
|
| 36 |
+
params: dict[str, Any]
|
| 37 |
+
results: list[dict[str, Any]]
|
| 38 |
+
took_ms: float | None = None
|
backend/app/services.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from time import perf_counter
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from .config import Settings
|
| 5 |
+
from .embedder import ImageEmbedder
|
| 6 |
+
from .manifest import Manifest
|
| 7 |
+
from .qdrant_store import QdrantStore, inspector_results, match_filter, payload_filter
|
| 8 |
+
from .schemas import Inspector, QueryFilters
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AfterimageService:
|
| 12 |
+
def __init__(self, settings: Settings):
|
| 13 |
+
self.settings = settings
|
| 14 |
+
self.manifest = Manifest(settings)
|
| 15 |
+
self.embedder = ImageEmbedder(settings)
|
| 16 |
+
self.store = QdrantStore(settings)
|
| 17 |
+
|
| 18 |
+
def health(self) -> dict[str, Any]:
|
| 19 |
+
collections = self.store.client.get_collections()
|
| 20 |
+
return {
|
| 21 |
+
"ok": True,
|
| 22 |
+
"collection": self.settings.collection_name,
|
| 23 |
+
"point_count": self.store.count() if self.store.client.collection_exists(self.settings.collection_name) else 0,
|
| 24 |
+
"embedding_mode": self.embedder.mode,
|
| 25 |
+
"collections": [item.name for item in collections.collections],
|
| 26 |
+
"default_scenario": self.manifest.default_scenario,
|
| 27 |
+
"scenarios": self.manifest.scenario_summaries(),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
def scenarios(self) -> dict[str, Any]:
|
| 31 |
+
return {"default_scenario": self.manifest.default_scenario, "scenarios": self.manifest.scenario_summaries()}
|
| 32 |
+
|
| 33 |
+
def memories(self, scenario: str, zone: str | None, region_id: str | None, is_baseline: bool | None) -> dict[str, Any]:
|
| 34 |
+
self.manifest.scenario(scenario)
|
| 35 |
+
query_filter = match_filter(scenario=scenario, zone=zone, region_id=region_id, is_baseline=is_baseline)
|
| 36 |
+
points = self.store.scroll(query_filter=query_filter, limit=200)
|
| 37 |
+
return {
|
| 38 |
+
"points": [self._point_view(point) for point in points],
|
| 39 |
+
"floorplan": self.manifest.floorplan(scenario),
|
| 40 |
+
"scenario": self.manifest.scenario(scenario),
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
def scan_anomalies(self, scenario: str) -> dict[str, Any]:
|
| 44 |
+
self.manifest.scenario(scenario)
|
| 45 |
+
regions = []
|
| 46 |
+
inspector_blocks = []
|
| 47 |
+
for region in self.manifest.regions(scenario):
|
| 48 |
+
crop = self.manifest.asset_path(region["incident_crop"])
|
| 49 |
+
vector = self.embedder.embed_path(crop)
|
| 50 |
+
query_filter = match_filter(scenario=scenario, region_id=region["region_id"], is_baseline=True)
|
| 51 |
+
t0 = perf_counter()
|
| 52 |
+
points = self.store.search(vector, query_filter=query_filter, limit=8)
|
| 53 |
+
took_ms = round((perf_counter() - t0) * 1000, 2)
|
| 54 |
+
top_score = points[0].score if points else 0.0
|
| 55 |
+
normal_band = self._normal_band(scenario, region["region_id"])
|
| 56 |
+
threshold = normal_band["floor"] if normal_band["source"] == "baseline_variants" else float(region.get("threshold", 0.78))
|
| 57 |
+
alarm_enabled = bool(region.get("alarm_enabled", True))
|
| 58 |
+
status = "anomalous" if alarm_enabled and top_score < threshold else "normal"
|
| 59 |
+
result = {
|
| 60 |
+
"region_id": region["region_id"],
|
| 61 |
+
"region_label": region["region_label"],
|
| 62 |
+
"camera_id": region["camera_id"],
|
| 63 |
+
"zone": region["zone"],
|
| 64 |
+
"score": top_score,
|
| 65 |
+
"threshold": threshold,
|
| 66 |
+
"normal_band": normal_band,
|
| 67 |
+
"alarm_enabled": alarm_enabled,
|
| 68 |
+
"status": status,
|
| 69 |
+
"incident_crop_url": self.manifest.asset_url(region["incident_crop"]),
|
| 70 |
+
"nearest": [self._point_view(point) for point in points],
|
| 71 |
+
}
|
| 72 |
+
regions.append(result)
|
| 73 |
+
inspector_blocks.append(
|
| 74 |
+
self._inspector(
|
| 75 |
+
api="query_points",
|
| 76 |
+
params={
|
| 77 |
+
"limit": 8,
|
| 78 |
+
"query": "incident_region_crop",
|
| 79 |
+
"region_id": region["region_id"],
|
| 80 |
+
"alarm_enabled": alarm_enabled,
|
| 81 |
+
"normal_band": normal_band,
|
| 82 |
+
},
|
| 83 |
+
query_filter={"scenario": scenario, "region_id": region["region_id"], "is_baseline": True},
|
| 84 |
+
points=points,
|
| 85 |
+
took_ms=took_ms,
|
| 86 |
+
)
|
| 87 |
+
)
|
| 88 |
+
return {"regions": regions, "inspector": inspector_blocks}
|
| 89 |
+
|
| 90 |
+
def object_search(self, scenario: str, asset_id: str | None, image_ref: str | None, filters: QueryFilters, limit: int = 8):
|
| 91 |
+
self.manifest.scenario(scenario)
|
| 92 |
+
crop = self.manifest.crop_for_ref(scenario, asset_id, image_ref)
|
| 93 |
+
vector = self.embedder.embed_path(crop)
|
| 94 |
+
filter_dict = filters.model_dump()
|
| 95 |
+
filter_dict["scenario"] = scenario
|
| 96 |
+
if filter_dict.get("memory_type") is None:
|
| 97 |
+
filter_dict["memory_type"] = "object_sighting"
|
| 98 |
+
query_filter = payload_filter(filter_dict)
|
| 99 |
+
points = self.store.search(vector, query_filter=query_filter, limit=limit)
|
| 100 |
+
inspector = self._inspector(
|
| 101 |
+
api="query_points",
|
| 102 |
+
params={"limit": limit, "query_crop": str(crop.relative_to(self.settings.resolved_asset_root))},
|
| 103 |
+
query_filter=filter_dict,
|
| 104 |
+
points=points,
|
| 105 |
+
)
|
| 106 |
+
return {"results": [self._point_view(point) for point in points], "inspector": inspector}
|
| 107 |
+
|
| 108 |
+
def trail(self, scenario: str, asset_id: str | None, image_ref: str | None, score_cutoff: float):
|
| 109 |
+
search = self.object_search(
|
| 110 |
+
scenario=scenario,
|
| 111 |
+
asset_id=asset_id,
|
| 112 |
+
image_ref=image_ref,
|
| 113 |
+
filters=QueryFilters(scenario=scenario, memory_type="object_sighting"),
|
| 114 |
+
limit=12,
|
| 115 |
+
)
|
| 116 |
+
by_zone: dict[str, dict[str, Any]] = {}
|
| 117 |
+
for result in search["results"]:
|
| 118 |
+
payload = result["payload"]
|
| 119 |
+
if result["score"] < score_cutoff:
|
| 120 |
+
continue
|
| 121 |
+
zone = payload["zone"]
|
| 122 |
+
current = by_zone.get(zone)
|
| 123 |
+
if current is None or result["score"] > current["score"]:
|
| 124 |
+
by_zone[zone] = result
|
| 125 |
+
sightings = sorted(by_zone.values(), key=lambda item: item["payload"]["timestamp"])
|
| 126 |
+
path = []
|
| 127 |
+
floor_coords = self._floor_coords(scenario)
|
| 128 |
+
for item in sightings:
|
| 129 |
+
payload = item["payload"]
|
| 130 |
+
path.append({**item, "floorplan_xy": floor_coords[payload["zone"]]})
|
| 131 |
+
return {"trail": path, "ranked_results": search["results"], "inspector": search["inspector"]}
|
| 132 |
+
|
| 133 |
+
def outliers(self, scenario: str):
|
| 134 |
+
self.manifest.scenario(scenario)
|
| 135 |
+
negative_filter = match_filter(scenario=scenario, memory_type="region_baseline", alarm_enabled=True)
|
| 136 |
+
negative_points = self.store.scroll(query_filter=negative_filter, limit=50)
|
| 137 |
+
negative_ids = [str(point.id) for point in negative_points]
|
| 138 |
+
candidate_filter = match_filter(scenario=scenario, memory_type="region_incident", alarm_enabled=True)
|
| 139 |
+
t0 = perf_counter()
|
| 140 |
+
points = self.store.recommend_best_score(
|
| 141 |
+
negative_ids=negative_ids,
|
| 142 |
+
query_filter=candidate_filter,
|
| 143 |
+
limit=8,
|
| 144 |
+
)
|
| 145 |
+
took_ms = round((perf_counter() - t0) * 1000, 2)
|
| 146 |
+
return {
|
| 147 |
+
"strategy": "RecommendQuery best_score: alarm-zone normal memories as negatives, alarm-zone incidents as candidates",
|
| 148 |
+
"results": [self._point_view(point) for point in points],
|
| 149 |
+
"inspector": self._inspector(
|
| 150 |
+
api="RecommendQuery(best_score)",
|
| 151 |
+
params={"candidate_scope": "alarm_enabled regions only", "negative_example_count": len(negative_ids), "limit": 8},
|
| 152 |
+
query_filter={"scenario": scenario, "memory_type": "region_incident", "alarm_enabled": True},
|
| 153 |
+
points=points,
|
| 154 |
+
took_ms=took_ms,
|
| 155 |
+
),
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
def text_search(self, query: str, scenario: str | None = None, limit: int = 8):
|
| 159 |
+
query = (query or "").strip()
|
| 160 |
+
if not query:
|
| 161 |
+
raise ValueError("Empty query.")
|
| 162 |
+
vector = self.embedder.embed_text(query)
|
| 163 |
+
query_filter = match_filter(scenario=scenario) if scenario else None
|
| 164 |
+
t0 = perf_counter()
|
| 165 |
+
points = self.store.search(vector, query_filter=query_filter, limit=limit)
|
| 166 |
+
took_ms = round((perf_counter() - t0) * 1000, 2)
|
| 167 |
+
return {
|
| 168 |
+
"query": query,
|
| 169 |
+
"results": [self._point_view(point) for point in points],
|
| 170 |
+
"inspector": self._inspector(
|
| 171 |
+
api="query_points · text",
|
| 172 |
+
params={"limit": limit, "query": query, "encoder": "clip-ViT-B-32-text"},
|
| 173 |
+
query_filter={"scenario": scenario} if scenario else None,
|
| 174 |
+
points=points,
|
| 175 |
+
took_ms=took_ms,
|
| 176 |
+
),
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
def matrix(self, scenario: str, sample: int = 24):
|
| 180 |
+
self.manifest.scenario(scenario)
|
| 181 |
+
query_filter = match_filter(scenario=scenario)
|
| 182 |
+
points = self.store.scroll(query_filter=query_filter, limit=sample)
|
| 183 |
+
matrix = self.store.search_matrix_pairs(query_filter=query_filter, limit=3, sample=min(sample, len(points) or 1))
|
| 184 |
+
nodes = []
|
| 185 |
+
floor_coords = self._floor_coords(scenario)
|
| 186 |
+
for index, point in enumerate(points):
|
| 187 |
+
payload = point.payload or {}
|
| 188 |
+
zone = payload.get("zone", "main_hall")
|
| 189 |
+
base = floor_coords.get(zone, [120, 120])
|
| 190 |
+
nodes.append(
|
| 191 |
+
{
|
| 192 |
+
"id": point.id,
|
| 193 |
+
"x": base[0] + (index % 4) * 9,
|
| 194 |
+
"y": base[1] + (index // 4) * 9,
|
| 195 |
+
"payload": payload,
|
| 196 |
+
}
|
| 197 |
+
)
|
| 198 |
+
pairs = [{"a": str(pair.a), "b": str(pair.b), "score": pair.score} for pair in matrix.pairs]
|
| 199 |
+
return {"nodes": nodes, "pairs": pairs}
|
| 200 |
+
|
| 201 |
+
def _inspector(
|
| 202 |
+
self,
|
| 203 |
+
api: str,
|
| 204 |
+
params: dict[str, Any],
|
| 205 |
+
query_filter: dict[str, Any] | None,
|
| 206 |
+
points,
|
| 207 |
+
took_ms: float | None = None,
|
| 208 |
+
) -> dict[str, Any]:
|
| 209 |
+
return Inspector(
|
| 210 |
+
api=api,
|
| 211 |
+
collection=self.settings.collection_name,
|
| 212 |
+
filter=query_filter,
|
| 213 |
+
params=params,
|
| 214 |
+
results=inspector_results(points),
|
| 215 |
+
took_ms=took_ms,
|
| 216 |
+
).model_dump()
|
| 217 |
+
|
| 218 |
+
def _floor_coords(self, scenario: str) -> dict[str, list[int]]:
|
| 219 |
+
zones = self.manifest.floorplan(scenario).get("zones", [])
|
| 220 |
+
return {zone["id"]: zone.get("floorplan_xy", [120, 120]) for zone in zones}
|
| 221 |
+
|
| 222 |
+
def _normal_band(self, scenario: str, region_id: str) -> dict[str, Any]:
|
| 223 |
+
query_filter = match_filter(scenario=scenario, region_id=region_id, is_baseline=True)
|
| 224 |
+
baseline_points = self.store.scroll(query_filter=query_filter, limit=40)
|
| 225 |
+
peer_scores = []
|
| 226 |
+
for point in baseline_points:
|
| 227 |
+
neighbors = self.store.search(str(point.id), query_filter=query_filter, limit=min(len(baseline_points), 8))
|
| 228 |
+
for neighbor in neighbors:
|
| 229 |
+
if str(neighbor.id) != str(point.id) and neighbor.score is not None:
|
| 230 |
+
peer_scores.append(float(neighbor.score))
|
| 231 |
+
break
|
| 232 |
+
if len(peer_scores) < 2:
|
| 233 |
+
return {
|
| 234 |
+
"source": "manifest_threshold",
|
| 235 |
+
"count": len(peer_scores),
|
| 236 |
+
"mean": None,
|
| 237 |
+
"min": None,
|
| 238 |
+
"std": None,
|
| 239 |
+
"floor": None,
|
| 240 |
+
}
|
| 241 |
+
mean = sum(peer_scores) / len(peer_scores)
|
| 242 |
+
variance = sum((score - mean) ** 2 for score in peer_scores) / len(peer_scores)
|
| 243 |
+
std = variance ** 0.5
|
| 244 |
+
# Data-derived control limit: three sigma below the mean baseline self-similarity.
|
| 245 |
+
# No hand-tuned constant — the band widens or tightens with the footage itself.
|
| 246 |
+
floor = max(0.0, mean - 3.0 * std)
|
| 247 |
+
return {
|
| 248 |
+
"source": "baseline_variants",
|
| 249 |
+
"count": len(peer_scores),
|
| 250 |
+
"mean": mean,
|
| 251 |
+
"min": min(peer_scores),
|
| 252 |
+
"std": std,
|
| 253 |
+
"floor": floor,
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
def _point_view(self, point) -> dict[str, Any]:
|
| 257 |
+
payload = dict(point.payload or {})
|
| 258 |
+
return {
|
| 259 |
+
"id": point.id,
|
| 260 |
+
"score": getattr(point, "score", None),
|
| 261 |
+
"payload": payload,
|
| 262 |
+
"crop_url": payload.get("crop_url"),
|
| 263 |
+
"frame_url": payload.get("frame_url"),
|
| 264 |
+
}
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.136.3
|
| 2 |
+
starlette==1.0.1
|
| 3 |
+
uvicorn[standard]==0.38.0
|
| 4 |
+
qdrant-client[fastembed]==1.18.0
|
| 5 |
+
fastembed==0.8.0
|
| 6 |
+
pillow==12.2.0
|
| 7 |
+
numpy==2.3.5
|
| 8 |
+
pydantic==2.12.5
|
| 9 |
+
pydantic-settings==2.12.0
|
| 10 |
+
python-dotenv==1.2.2
|
backend/scripts/seed.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
from qdrant_client import models
|
| 5 |
+
|
| 6 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 7 |
+
sys.path.insert(0, str(ROOT))
|
| 8 |
+
|
| 9 |
+
from app.config import get_settings # noqa: E402
|
| 10 |
+
from app.embedder import ImageEmbedder # noqa: E402
|
| 11 |
+
from app.manifest import Manifest # noqa: E402
|
| 12 |
+
from app.qdrant_store import QdrantStore, point_id # noqa: E402
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def payload_base(manifest: Manifest, scenario: dict, item: dict, crop: str, memory_type: str) -> dict:
|
| 16 |
+
camera = next(cam for cam in manifest.cameras(scenario["id"]) if cam["id"] == item["camera_id"])
|
| 17 |
+
return {
|
| 18 |
+
"scenario": scenario["id"],
|
| 19 |
+
"camera_id": item["camera_id"],
|
| 20 |
+
"zone": item.get("zone", camera["zone"]),
|
| 21 |
+
"timestamp": int(item.get("timestamp", 1716900000)),
|
| 22 |
+
"frame_url": manifest.asset_url(item.get("frame", camera.get("incident_frame", camera["baseline_frame"]))),
|
| 23 |
+
"crop_url": manifest.asset_url(crop),
|
| 24 |
+
"bbox": item.get("bbox", [0, 0, 0, 0]),
|
| 25 |
+
"asset_id": item["asset_id"],
|
| 26 |
+
"memory_type": memory_type,
|
| 27 |
+
"is_baseline": bool(item.get("is_baseline", False)),
|
| 28 |
+
"is_incident": bool(item.get("is_incident", False)),
|
| 29 |
+
"object_label": item.get("object_label", "region"),
|
| 30 |
+
"track_id": item.get("track_id"),
|
| 31 |
+
"region_id": item.get("region_id"),
|
| 32 |
+
"region_label": item.get("region_label"),
|
| 33 |
+
"alarm_enabled": item.get("alarm_enabled"),
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def region_points(manifest: Manifest, embedder: ImageEmbedder, scenario: dict):
|
| 38 |
+
points = []
|
| 39 |
+
for region in manifest.regions(scenario["id"]):
|
| 40 |
+
camera = next(cam for cam in manifest.cameras(scenario["id"]) if cam["id"] == region["camera_id"])
|
| 41 |
+
for state, crop, baseline in [
|
| 42 |
+
("baseline", region["baseline_crop"], True),
|
| 43 |
+
("incident", region["incident_crop"], False),
|
| 44 |
+
]:
|
| 45 |
+
asset_id = f"{region['region_id']}_{state}"
|
| 46 |
+
payload = payload_base(
|
| 47 |
+
manifest,
|
| 48 |
+
scenario,
|
| 49 |
+
{
|
| 50 |
+
**region,
|
| 51 |
+
"asset_id": asset_id,
|
| 52 |
+
"frame": camera[f"{state}_frame"],
|
| 53 |
+
"is_baseline": baseline,
|
| 54 |
+
"is_incident": not baseline,
|
| 55 |
+
"object_label": region.get(f"{state}_label", region["region_id"]),
|
| 56 |
+
},
|
| 57 |
+
crop,
|
| 58 |
+
f"region_{state}",
|
| 59 |
+
)
|
| 60 |
+
vector = embedder.embed_path(manifest.asset_path(crop))
|
| 61 |
+
points.append(models.PointStruct(id=point_id(f"{scenario['id']}:{asset_id}"), vector=vector, payload=payload))
|
| 62 |
+
if baseline:
|
| 63 |
+
for index, variant_crop in enumerate(baseline_variants(manifest, crop, region.get("variant_prefix")), start=1):
|
| 64 |
+
variant_id = f"{asset_id}_v{index}"
|
| 65 |
+
variant_payload = {
|
| 66 |
+
**payload,
|
| 67 |
+
"asset_id": variant_id,
|
| 68 |
+
"crop_url": manifest.asset_url(variant_crop),
|
| 69 |
+
"memory_variant": index,
|
| 70 |
+
}
|
| 71 |
+
vector = embedder.embed_path(manifest.asset_path(variant_crop))
|
| 72 |
+
points.append(models.PointStruct(id=point_id(f"{scenario['id']}:{variant_id}"), vector=vector, payload=variant_payload))
|
| 73 |
+
return points
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def baseline_variants(manifest: Manifest, crop: str, variant_prefix: str | None = None) -> list[str]:
|
| 77 |
+
path = Path(crop)
|
| 78 |
+
stem = variant_prefix or path.stem
|
| 79 |
+
variant_dir = manifest.settings.resolved_asset_root / path.parent / "variants"
|
| 80 |
+
if not variant_dir.exists():
|
| 81 |
+
return []
|
| 82 |
+
return [
|
| 83 |
+
f"{path.parent.as_posix()}/variants/{variant.name}"
|
| 84 |
+
for variant in sorted(variant_dir.glob(f"{stem}_v*.jpg"))
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def object_points(manifest: Manifest, embedder: ImageEmbedder, scenario: dict):
|
| 89 |
+
points = []
|
| 90 |
+
for item in manifest.objects(scenario["id"]):
|
| 91 |
+
payload = payload_base(manifest, scenario, item, item["crop"], item["memory_type"])
|
| 92 |
+
vector = embedder.embed_path(manifest.asset_path(item["crop"]))
|
| 93 |
+
points.append(models.PointStruct(id=point_id(f"{scenario['id']}:{item['asset_id']}"), vector=vector, payload=payload))
|
| 94 |
+
return points
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def main() -> int:
|
| 98 |
+
settings = get_settings()
|
| 99 |
+
manifest = Manifest(settings)
|
| 100 |
+
embedder = ImageEmbedder(settings)
|
| 101 |
+
store = QdrantStore(settings)
|
| 102 |
+
store.ensure_collection(recreate=True)
|
| 103 |
+
points = []
|
| 104 |
+
for scenario in manifest.scenarios:
|
| 105 |
+
points.extend(region_points(manifest, embedder, scenario))
|
| 106 |
+
points.extend(object_points(manifest, embedder, scenario))
|
| 107 |
+
store.upsert(points)
|
| 108 |
+
print(f"Seeded {len(points)} points into {settings.collection_name} using {embedder.mode}.")
|
| 109 |
+
return 0
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
raise SystemExit(main())
|
backend/scripts/verify.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 5 |
+
sys.path.insert(0, str(ROOT))
|
| 6 |
+
|
| 7 |
+
from app.config import get_settings # noqa: E402
|
| 8 |
+
from app.qdrant_store import match_filter # noqa: E402
|
| 9 |
+
from app.services import AfterimageService # noqa: E402
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# scenario -> (region_id, expected sole outlier asset_id)
|
| 13 |
+
SCENARIOS = {
|
| 14 |
+
"vault": ("vault_pedestal", "vault_pedestal_incident"),
|
| 15 |
+
"museum": ("museum_pedestal", "museum_pedestal_incident"),
|
| 16 |
+
"bank": ("bank_curb", "bank_curb_incident"),
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def fail(message: str) -> int:
|
| 21 |
+
print(f"FAIL: {message}")
|
| 22 |
+
return 1
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def held_out_normal_score(service: AfterimageService, scenario: str, region_id: str) -> float | None:
|
| 26 |
+
"""Embed the canonical baseline crop — a held-out NORMAL frame, distinct from
|
| 27 |
+
the seeded baseline variants — and score it against the region's learned band.
|
| 28 |
+
This is the false-positive control: a normal frame must stay above the floor."""
|
| 29 |
+
region = next(
|
| 30 |
+
(r for r in service.manifest.regions(scenario) if r["region_id"] == region_id),
|
| 31 |
+
None,
|
| 32 |
+
)
|
| 33 |
+
if not region or not region.get("baseline_crop"):
|
| 34 |
+
return None
|
| 35 |
+
vector = service.embedder.embed_path(service.manifest.asset_path(region["baseline_crop"]))
|
| 36 |
+
query_filter = match_filter(scenario=scenario, region_id=region_id, is_baseline=True)
|
| 37 |
+
points = service.store.search(vector, query_filter=query_filter, limit=8)
|
| 38 |
+
# Exclude the exact self-match (~1.0) so this is a true held-out peer
|
| 39 |
+
# similarity — a normal frame compared against the *other* baselines.
|
| 40 |
+
peer = next((p for p in points if p.score is not None and p.score < 0.9999), None)
|
| 41 |
+
return float(peer.score) if peer else None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def verify_scenario(service: AfterimageService, scenario: str, region_id: str, incident_id: str) -> int:
|
| 45 |
+
scan = service.scan_anomalies(scenario)
|
| 46 |
+
regions = {item["region_id"]: item for item in scan["regions"]}
|
| 47 |
+
region = regions.get(region_id)
|
| 48 |
+
if region is None:
|
| 49 |
+
return fail(f"{scenario}: missing region {region_id}")
|
| 50 |
+
|
| 51 |
+
band = region["normal_band"]
|
| 52 |
+
print(
|
| 53 |
+
f"{scenario}/{region_id}: score={region['score']:.4f} "
|
| 54 |
+
f"floor={region['threshold']:.4f} margin={region['threshold'] - region['score']:+.4f} "
|
| 55 |
+
f"status={region['status']}"
|
| 56 |
+
)
|
| 57 |
+
if band["source"] != "baseline_variants":
|
| 58 |
+
return fail(f"{scenario}: normal band is not data-derived (source={band['source']})")
|
| 59 |
+
if region["status"] != "anomalous" or region["score"] >= region["threshold"]:
|
| 60 |
+
return fail(f"{scenario}: incident did not cross the learned normal band")
|
| 61 |
+
|
| 62 |
+
# False-positive control: a held-out NORMAL frame must stay inside the band.
|
| 63 |
+
# Without this, the detector could be one that simply always alarms.
|
| 64 |
+
normal_top = held_out_normal_score(service, scenario, region_id)
|
| 65 |
+
if normal_top is not None:
|
| 66 |
+
if normal_top < region["threshold"]:
|
| 67 |
+
return fail(
|
| 68 |
+
f"{scenario}: held-out NORMAL frame scored {normal_top:.4f} < floor "
|
| 69 |
+
f"{region['threshold']:.4f} — false positive"
|
| 70 |
+
)
|
| 71 |
+
print(
|
| 72 |
+
f" control: normal frame {normal_top:.4f} >= floor {region['threshold']:.4f} "
|
| 73 |
+
f"(stays in band) | incident {region['score']:.4f} breaches"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
outliers = service.outliers(scenario)
|
| 77 |
+
if outliers["inspector"]["api"] != "RecommendQuery(best_score)":
|
| 78 |
+
return fail(f"{scenario}: outliers not backed by RecommendQuery(best_score)")
|
| 79 |
+
outlier_ids = [item["payload"]["asset_id"] for item in outliers["results"]]
|
| 80 |
+
if outlier_ids != [incident_id]:
|
| 81 |
+
return fail(f"{scenario}: expected sole outlier {incident_id!r}, got {outlier_ids}")
|
| 82 |
+
return 0
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def main() -> int:
|
| 86 |
+
service = AfterimageService(get_settings())
|
| 87 |
+
health = service.health()
|
| 88 |
+
|
| 89 |
+
if health["embedding_mode"] != "fastembed":
|
| 90 |
+
return fail(f"embeddings are not real CLIP (mode={health['embedding_mode']})")
|
| 91 |
+
scenario_ids = {item["id"] for item in health["scenarios"]}
|
| 92 |
+
if set(SCENARIOS).issubset(scenario_ids) is False:
|
| 93 |
+
return fail(f"missing required scenarios, got {sorted(scenario_ids)}")
|
| 94 |
+
if health["point_count"] < 30:
|
| 95 |
+
return fail(f"expected at least 30 points, got {health['point_count']}")
|
| 96 |
+
|
| 97 |
+
for scenario, (region_id, incident_id) in SCENARIOS.items():
|
| 98 |
+
result = verify_scenario(service, scenario, region_id, incident_id)
|
| 99 |
+
if result:
|
| 100 |
+
return result
|
| 101 |
+
|
| 102 |
+
print(
|
| 103 |
+
"Verification passed: all three incidents flagged, all three held-out "
|
| 104 |
+
"normals stayed in band, all three outliers correct, real CLIP."
|
| 105 |
+
)
|
| 106 |
+
return 0
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
if __name__ == "__main__":
|
| 110 |
+
raise SystemExit(main())
|
data/assets/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
data/assets/bank/crops/bank_curb_baseline.jpg
ADDED
|
data/assets/bank/crops/bank_curb_incident.jpg
ADDED
|
data/assets/bank/crops/variants/bank_curb_v01.jpg
ADDED
|
data/assets/bank/crops/variants/bank_curb_v02.jpg
ADDED
|
data/assets/bank/crops/variants/bank_curb_v03.jpg
ADDED
|
data/assets/bank/crops/variants/bank_curb_v04.jpg
ADDED
|
data/assets/bank/crops/variants/bank_curb_v05.jpg
ADDED
|
data/assets/bank/crops/variants/bank_curb_v06.jpg
ADDED
|
Git LFS Details
|
data/assets/bank/crops/variants/bank_curb_v07.jpg
ADDED
|
data/assets/bank/crops/variants/bank_curb_v08.jpg
ADDED
|
data/assets/bank/crops/variants/bank_curb_v09.jpg
ADDED
|
data/assets/bank/crops/variants/bank_curb_v10.jpg
ADDED
|
data/assets/bank/frames/street_cam/incident.jpg
ADDED
|
data/assets/bank/frames/street_cam/normal.jpg
ADDED
|
data/assets/museum/crops/museum_pedestal_baseline.jpg
ADDED
|
data/assets/museum/crops/museum_pedestal_incident.jpg
ADDED
|
data/assets/museum/crops/variants/museum_pedestal_v01.jpg
ADDED
|
data/assets/museum/crops/variants/museum_pedestal_v02.jpg
ADDED
|
data/assets/museum/crops/variants/museum_pedestal_v03.jpg
ADDED
|
data/assets/museum/crops/variants/museum_pedestal_v04.jpg
ADDED
|
data/assets/museum/crops/variants/museum_pedestal_v05.jpg
ADDED
|
data/assets/museum/crops/variants/museum_pedestal_v06.jpg
ADDED
|
data/assets/museum/crops/variants/museum_pedestal_v07.jpg
ADDED
|
data/assets/museum/crops/variants/museum_pedestal_v08.jpg
ADDED
|
data/assets/museum/crops/variants/museum_pedestal_v09.jpg
ADDED
|
data/assets/museum/crops/variants/museum_pedestal_v10.jpg
ADDED
|
data/assets/museum/frames/museum_cam/incident.jpg
ADDED
|
data/assets/museum/frames/museum_cam/normal.jpg
ADDED
|
data/assets/vault/crops/variants/vault_pedestal_v01.jpg
ADDED
|
data/assets/vault/crops/variants/vault_pedestal_v02.jpg
ADDED
|
data/assets/vault/crops/variants/vault_pedestal_v03.jpg
ADDED
|
data/assets/vault/crops/variants/vault_pedestal_v04.jpg
ADDED
|
data/assets/vault/crops/variants/vault_pedestal_v05.jpg
ADDED
|
data/assets/vault/crops/variants/vault_pedestal_v06.jpg
ADDED
|
data/assets/vault/crops/variants/vault_pedestal_v07.jpg
ADDED
|