KryptosK commited on
Commit
850c319
·
verified ·
1 Parent(s): 6391be0

Afterimage live backend (FastAPI + FastEmbed + embedded Qdrant)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. Dockerfile +33 -0
  3. README.md +23 -5
  4. backend/app/__init__.py +1 -0
  5. backend/app/config.py +36 -0
  6. backend/app/embedder.py +77 -0
  7. backend/app/main.py +124 -0
  8. backend/app/manifest.py +92 -0
  9. backend/app/qdrant_store.py +114 -0
  10. backend/app/schemas.py +38 -0
  11. backend/app/services.py +264 -0
  12. backend/requirements.txt +10 -0
  13. backend/scripts/seed.py +113 -0
  14. backend/scripts/verify.py +110 -0
  15. data/assets/.gitkeep +1 -0
  16. data/assets/bank/crops/bank_curb_baseline.jpg +0 -0
  17. data/assets/bank/crops/bank_curb_incident.jpg +0 -0
  18. data/assets/bank/crops/variants/bank_curb_v01.jpg +0 -0
  19. data/assets/bank/crops/variants/bank_curb_v02.jpg +0 -0
  20. data/assets/bank/crops/variants/bank_curb_v03.jpg +0 -0
  21. data/assets/bank/crops/variants/bank_curb_v04.jpg +0 -0
  22. data/assets/bank/crops/variants/bank_curb_v05.jpg +0 -0
  23. data/assets/bank/crops/variants/bank_curb_v06.jpg +3 -0
  24. data/assets/bank/crops/variants/bank_curb_v07.jpg +0 -0
  25. data/assets/bank/crops/variants/bank_curb_v08.jpg +0 -0
  26. data/assets/bank/crops/variants/bank_curb_v09.jpg +0 -0
  27. data/assets/bank/crops/variants/bank_curb_v10.jpg +0 -0
  28. data/assets/bank/frames/street_cam/incident.jpg +0 -0
  29. data/assets/bank/frames/street_cam/normal.jpg +0 -0
  30. data/assets/museum/crops/museum_pedestal_baseline.jpg +0 -0
  31. data/assets/museum/crops/museum_pedestal_incident.jpg +0 -0
  32. data/assets/museum/crops/variants/museum_pedestal_v01.jpg +0 -0
  33. data/assets/museum/crops/variants/museum_pedestal_v02.jpg +0 -0
  34. data/assets/museum/crops/variants/museum_pedestal_v03.jpg +0 -0
  35. data/assets/museum/crops/variants/museum_pedestal_v04.jpg +0 -0
  36. data/assets/museum/crops/variants/museum_pedestal_v05.jpg +0 -0
  37. data/assets/museum/crops/variants/museum_pedestal_v06.jpg +0 -0
  38. data/assets/museum/crops/variants/museum_pedestal_v07.jpg +0 -0
  39. data/assets/museum/crops/variants/museum_pedestal_v08.jpg +0 -0
  40. data/assets/museum/crops/variants/museum_pedestal_v09.jpg +0 -0
  41. data/assets/museum/crops/variants/museum_pedestal_v10.jpg +0 -0
  42. data/assets/museum/frames/museum_cam/incident.jpg +0 -0
  43. data/assets/museum/frames/museum_cam/normal.jpg +0 -0
  44. data/assets/vault/crops/variants/vault_pedestal_v01.jpg +0 -0
  45. data/assets/vault/crops/variants/vault_pedestal_v02.jpg +0 -0
  46. data/assets/vault/crops/variants/vault_pedestal_v03.jpg +0 -0
  47. data/assets/vault/crops/variants/vault_pedestal_v04.jpg +0 -0
  48. data/assets/vault/crops/variants/vault_pedestal_v05.jpg +0 -0
  49. data/assets/vault/crops/variants/vault_pedestal_v06.jpg +0 -0
  50. 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: indigo
5
- colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 152db4b929c8ad616a0d2eae43b13a9c5c270d2da53370eafdab8e8e4a888364
  • Pointer size: 131 Bytes
  • Size of remote file: 101 kB
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