David Baba commited on
Commit
0437edf
·
1 Parent(s): 2164bac
Files changed (40) hide show
  1. Dockerfile +25 -0
  2. README.md +3 -2
  3. app/__init__.py +0 -0
  4. app/__pycache__/__init__.cpython-313.pyc +0 -0
  5. app/__pycache__/jobs.cpython-313.pyc +0 -0
  6. app/__pycache__/main.cpython-313.pyc +0 -0
  7. app/__pycache__/mentions.cpython-313.pyc +0 -0
  8. app/__pycache__/schemas.cpython-313.pyc +0 -0
  9. app/analysis/__init__.py +20 -0
  10. app/analysis/__pycache__/__init__.cpython-313.pyc +0 -0
  11. app/analysis/__pycache__/config.cpython-313.pyc +0 -0
  12. app/analysis/__pycache__/masks.cpython-313.pyc +0 -0
  13. app/analysis/__pycache__/owlv2_client.cpython-313.pyc +0 -0
  14. app/analysis/__pycache__/registry.cpython-313.pyc +0 -0
  15. app/analysis/__pycache__/runner.cpython-313.pyc +0 -0
  16. app/analysis/__pycache__/strategies.cpython-313.pyc +0 -0
  17. app/analysis/__pycache__/tracks.cpython-313.pyc +0 -0
  18. app/analysis/__pycache__/video.cpython-313.pyc +0 -0
  19. app/analysis/config.py +79 -0
  20. app/analysis/masks.py +76 -0
  21. app/analysis/owlv2_client.py +156 -0
  22. app/analysis/registry.py +125 -0
  23. app/analysis/runner.py +157 -0
  24. app/analysis/strategies.py +490 -0
  25. app/analysis/tracks.py +175 -0
  26. app/analysis/video.py +69 -0
  27. app/jobs.py +55 -0
  28. app/main.py +139 -0
  29. app/mentions.py +109 -0
  30. app/schemas.py +109 -0
  31. app/similarity/__init__.py +35 -0
  32. app/similarity/__pycache__/__init__.cpython-313.pyc +0 -0
  33. app/similarity/__pycache__/clip.cpython-313.pyc +0 -0
  34. app/similarity/__pycache__/dino.cpython-313.pyc +0 -0
  35. app/similarity/__pycache__/vlm.cpython-313.pyc +0 -0
  36. app/similarity/clip.py +43 -0
  37. app/similarity/dino.py +110 -0
  38. app/similarity/vlm.py +53 -0
  39. pyproject.toml +21 -0
  40. uv.lock +0 -0
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend image for Railway (or any Docker host).
2
+ FROM python:3.12-slim
3
+
4
+ # ffmpeg/ffprobe are required at runtime (frame sampling, transcode, audio for whisper).
5
+ RUN apt-get update \
6
+ && apt-get install -y --no-install-recommends ffmpeg \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ # uv for fast, reproducible dependency installs.
10
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
11
+
12
+ WORKDIR /app
13
+
14
+ # Install dependencies first for better layer caching. Use the committed lock for a
15
+ # reproducible build (`requests` etc. come in transitively and are pinned there).
16
+ COPY pyproject.toml uv.lock ./
17
+ RUN uv sync --frozen --no-dev
18
+
19
+ # Application code.
20
+ COPY app ./app
21
+
22
+ # Railway injects $PORT; default to 8000 for local `docker run`.
23
+ ENV PORT=8000
24
+ EXPOSE 8000
25
+ CMD uv run uvicorn app.main:app --host 0.0.0.0 --port ${PORT}
README.md CHANGED
@@ -1,9 +1,10 @@
1
  ---
2
  title: Creator Vision Demo
3
- emoji: 🏆
 
 
4
  colorFrom: gray
5
  colorTo: red
6
- sdk: docker
7
  pinned: false
8
  license: mit
9
  short_description: analyze brand presence with a suite of ai tools
 
1
  ---
2
  title: Creator Vision Demo
3
+ emoji: 🎥
4
+ sdk: docker
5
+ app_port: 8000
6
  colorFrom: gray
7
  colorTo: red
 
8
  pinned: false
9
  license: mit
10
  short_description: analyze brand presence with a suite of ai tools
app/__init__.py ADDED
File without changes
app/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (165 Bytes). View file
 
app/__pycache__/jobs.cpython-313.pyc ADDED
Binary file (3.64 kB). View file
 
app/__pycache__/main.cpython-313.pyc ADDED
Binary file (7.49 kB). View file
 
app/__pycache__/mentions.cpython-313.pyc ADDED
Binary file (6.32 kB). View file
 
app/__pycache__/schemas.cpython-313.pyc ADDED
Binary file (5.21 kB). View file
 
app/analysis/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Analysis pipeline (multi-product; several detection modes).
2
+
3
+ Each analysis tracks N products. A mode's `DetectionStrategy` produces a per-product box
4
+ track; mentions (caption/audio/OCR) are counted globally against the shared keyword list.
5
+ Modes are registered in `registry.py`; the similarity backends (DINOv2/v3, CLIP, OpenAI)
6
+ live in `app/similarity`. See DesignDoc.md for the result schema.
7
+ """
8
+
9
+ from .registry import BuildOpts, owlv2_needs_images, requires_name, requires_reference
10
+ from .runner import run_analysis
11
+ from .strategies import ProductInput
12
+
13
+ __all__ = [
14
+ "run_analysis",
15
+ "ProductInput",
16
+ "requires_reference",
17
+ "requires_name",
18
+ "owlv2_needs_images",
19
+ "BuildOpts",
20
+ ]
app/analysis/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (873 Bytes). View file
 
app/analysis/__pycache__/config.cpython-313.pyc ADDED
Binary file (1.77 kB). View file
 
app/analysis/__pycache__/masks.cpython-313.pyc ADDED
Binary file (5.41 kB). View file
 
app/analysis/__pycache__/owlv2_client.cpython-313.pyc ADDED
Binary file (8.48 kB). View file
 
app/analysis/__pycache__/registry.cpython-313.pyc ADDED
Binary file (6.35 kB). View file
 
app/analysis/__pycache__/runner.cpython-313.pyc ADDED
Binary file (8.17 kB). View file
 
app/analysis/__pycache__/strategies.cpython-313.pyc ADDED
Binary file (30.1 kB). View file
 
app/analysis/__pycache__/tracks.cpython-313.pyc ADDED
Binary file (11.2 kB). View file
 
app/analysis/__pycache__/video.cpython-313.pyc ADDED
Binary file (4.54 kB). View file
 
app/analysis/config.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pipeline tunables — fps, fal endpoints, per-mode thresholds, concurrency, and flags.
2
+
3
+ Model-intrinsic DINO config (the v2/v3 repos, DINO_THRESHOLD, DINO_MULTI_REF) lives with the
4
+ model in ``app/similarity/dino.py``. Everything here is about *how the pipeline samples,
5
+ segments, crops, and gates*.
6
+ """
7
+
8
+ import os
9
+
10
+ # --- sampling fps ---
11
+ VIDEO_FPS = 3 # fps the video is downsampled to for the SAM-text tracking modes
12
+ FRAME_FPS = 4 # fps frames are sampled at for the per-frame segment+match modes
13
+
14
+ MAX_WIDTH = 640 # frames/video are scaled to this width before upload
15
+ DETECTION_THRESHOLD = 0.3 # SAM 3 text detection threshold
16
+ GAP_MERGE_FRAMES = 1 # bridge appearance runs separated by ≤1 missing frame
17
+
18
+ # --- fal concurrency ---
19
+ # fal account's max concurrent inference requests. Upgrade fal -> increase concurrency
20
+ # -> hamemr down latency. Should get 429's if this is increased, but we don't, we just
21
+ # notice it gets throttled.
22
+ FAL_CONCURRENCY = 10
23
+
24
+ # --- fal endpoints ---
25
+ SAM3_ENDPOINT = "fal-ai/sam-3/video-rle"
26
+ SAM3_1_ENDPOINT = "fal-ai/sam-3-1/video-rle" # drop-in, faster + better (Object Multiplex)
27
+ AUTOSEG_ENDPOINT = "fal-ai/sam2/auto-segment" # SAM 2 segment-everything
28
+ EVF_SAM_ENDPOINT = "fal-ai/evf-sam" # text-grounded segmentation (Grounding DINO backbone)
29
+
30
+ # --- segment-per-frame modes (sam2_dino, sam3_clip): one auto-segment call per frame ---
31
+ SEGMENT_FRAME_WORKERS = FAL_CONCURRENCY # concurrent auto-segment inference calls (≤ fal limit)
32
+ SEGMENT_MASK_WORKERS = 16 # mask downloads in flight per frame (fal CDN, NOT inference-limited)
33
+
34
+ # --- sam3_clip (SAM 2 segment-everything + CLIP) ---
35
+ # CLIP image-image cosines are high/weakly-calibrated, so keep the top-K most similar
36
+ # segments per frame gated by a high absolute floor.
37
+ CLIP_THRESHOLD = 0.85
38
+ CLIP_TOPK = 1
39
+ CLIP_MIN_AREA_FRAC = 0.0001 # ~ the old per-dimension 1% filter, as an area floor
40
+
41
+ # --- sam2_dino (SAM 2 segment-everything + DINO) ---
42
+ SAM2_DINO_THRESHOLD = 0.33 # default similarity threshold (the UI slider starts here)
43
+ SAM2_DINO_CANDIDATE_FLOOR = 0.0 # keep best-per-frame above this so the slider can go below
44
+ SAM2_DINO_TOPK = 2 # keep the best-matching segments per product per frame
45
+ SAM2_MIN_AREA_FRAC = 0.004 # drop segments smaller than this fraction of the frame area
46
+
47
+ # --- gdino_text_dino (EVF-SAM text segmentation + DINO) ---
48
+ GDINO_DINO_THRESHOLD = 0.33 # default similarity threshold (the UI slider starts here)
49
+ GDINO_CANDIDATE_FLOOR = 0.0 # keep detections above this so the slider can go below
50
+ GDINO_MIN_AREA_FRAC = 0.004 # drop a segmentation smaller than this fraction of the frame
51
+ # gdino runs in two stages: (1) network — EVF call + mask download per (frame, product), run
52
+ # concurrently up to GDINO_FRAME_WORKERS; (2) CPU — DINO embeds the surviving crops in fat
53
+ # batches. Throughput is maximized AT your fal concurrency limit: fewer underutilizes it, more
54
+ # triggers 429 throttling (measured ~2.75× slower at 64 vs the limit). So pin it to the limit;
55
+ # the `[gdino] ... s/call` log lets you confirm. (If you ever raise your fal tier, raise this.)
56
+ GDINO_FRAME_WORKERS = FAL_CONCURRENCY # concurrent EVF calls (one inference each) — ≤ fal limit
57
+ GDINO_EMBED_CHUNK = 64 # crops per batched DINO ONNX pass in the CPU stage (CPU, not fal)
58
+
59
+ # --- shared DINO crop input ---
60
+ # True: mask the segment's background before embedding (object-only crop → fewer false
61
+ # matches). False: embed the plain bounding-box crop. Applies to the modes with a segment
62
+ # mask (sam2_dino, gdino_text_dino); only changes the embedding input, not the overlay.
63
+ # (Paired with similarity.dino.DINO_MULTI_REF, which controls reference combination.)
64
+ DINO_MASK_BG = False
65
+
66
+ # --- scene-cut splitting ---
67
+ SCENE_CUT_DIST = 0.45 # L1 distance between normalized 16-bin/channel color histograms
68
+
69
+ # --- owlv2 (hosted HF Inference Endpoint; see owlv2_endpoint/) ---
70
+ OWLV2_ENDPOINT_URL = os.getenv("OWLV2_ENDPOINT_URL", "") # required for the owlv2 mode
71
+ OWLV2_FRAME_WORKERS = 4 # concurrent endpoint calls (≤ the endpoint's serving concurrency)
72
+ OWLV2_FRAME_BATCH = 6 # frames per endpoint request — one GPU forward pass serves the batch.
73
+ # Bounded by endpoint GPU memory (B×960×960 ViT) and the 120s request timeout; raise if the
74
+ # endpoint has headroom, lower if you see OOM/timeouts.
75
+ OWLV2_EMBED_CHUNK = 64 # crops per batched DINO ONNX pass in the optional DINO-on-top stage
76
+ OWLV2_SCORE_FLOOR = 0.05 # keep detections with confidence ≥ this; UI sliders re-threshold up
77
+ OWL_CONF_THRESHOLD = 0.2 # default for the confidence slider
78
+ OWL_OBJ_THRESHOLD = 0.2 # default for the objectness slider
79
+ OWL_SIM_THRESHOLD = 0.33 # default for the (optional) DINO-on-top similarity slider
app/analysis/masks.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Segmentation-mask + crop utilities: download masks, derive bboxes, RLE-encode for the
2
+ overlay, and produce the crops fed to the embedders."""
3
+
4
+ import io
5
+ import time
6
+
7
+ import numpy as np
8
+ import requests
9
+ from PIL import Image
10
+
11
+ from ..schemas import Box, Mask
12
+ from .config import DINO_MASK_BG
13
+
14
+ # Neutral fill = ImageNet mean ×255, so masked-out pixels normalize to ~0 (in-distribution
15
+ # for DINO, unlike pure black). Embedding the object on this fill — not the raw bbox crop —
16
+ # keeps background/neighboring objects from contaminating the similarity score.
17
+ _DINO_BG = (124, 116, 104)
18
+
19
+
20
+ def fetch_mask(mask_url: str, attempts: int = 3) -> Image.Image | None:
21
+ """Download an auto-segment mask PNG as a full-frame grayscale image. Tolerant of
22
+ transient network blips (DNS/connection resets on the fal CDN): retries with backoff,
23
+ and returns None on persistent failure so the caller skips that one segment instead of
24
+ failing the whole analysis."""
25
+ for i in range(attempts):
26
+ try:
27
+ resp = requests.get(mask_url, timeout=30)
28
+ resp.raise_for_status()
29
+ return Image.open(io.BytesIO(resp.content)).convert("L")
30
+ except Exception: # noqa: BLE001 — transient network / decode error
31
+ if i == attempts - 1:
32
+ return None
33
+ time.sleep(0.4 * (i + 1))
34
+ return None
35
+
36
+
37
+ def mask_bbox(mask_url: str) -> tuple[int, int, int, int] | None:
38
+ """Download a mask PNG and return its (left, upper, right, lower) bbox."""
39
+ m = fetch_mask(mask_url)
40
+ return m.getbbox() if m is not None else None
41
+
42
+
43
+ def encode_rle(mask: Image.Image, target_w: int = 256) -> Mask:
44
+ """Downscale a full-frame binary mask and RLE-encode it row-major (alternating runs
45
+ starting with background). Self-contained — no fal URL leaves the backend."""
46
+ w, h = mask.size
47
+ tw = min(target_w, w)
48
+ th = max(1, round(h * tw / w))
49
+ flat = (
50
+ np.asarray(mask.resize((tw, th), Image.NEAREST)) > 127
51
+ ).astype(np.uint8).ravel() # row-major
52
+ bounds = np.concatenate(([0], np.flatnonzero(np.diff(flat)) + 1, [flat.size]))
53
+ runs = np.diff(bounds).astype(int).tolist()
54
+ if flat.size and flat[0] == 1:
55
+ runs = [0, *runs] # counts must start with a background run
56
+ return Mask(w=tw, h=th, counts=runs)
57
+
58
+
59
+ def masked_crop(frame: Image.Image, mask: Image.Image, bbox: tuple) -> Image.Image:
60
+ """Composite the segmented object over a neutral fill, then crop to its bbox — so DINO
61
+ embeds the object alone, not whatever else shares the bounding rectangle. When
62
+ DINO_MASK_BG is False, returns the plain bbox crop (background kept)."""
63
+ if not DINO_MASK_BG:
64
+ return frame.crop(bbox)
65
+ if mask.size != frame.size:
66
+ mask = mask.resize(frame.size, Image.NEAREST)
67
+ bg = Image.new("RGB", frame.size, _DINO_BG)
68
+ return Image.composite(frame.convert("RGB"), bg, mask).crop(bbox)
69
+
70
+
71
+ def crop_box(frame: Image.Image, box: Box) -> Image.Image:
72
+ """Crop a normalized [0,1] Box region out of a frame (for box-track scoring)."""
73
+ w, h = frame.size
74
+ left, top = box.x * w, box.y * h
75
+ right, bottom = (box.x + box.w) * w, (box.y + box.h) * h
76
+ return frame.crop((max(0, left), max(0, top), min(w, right), min(h, bottom)))
app/analysis/owlv2_client.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin client for the hosted OWLv2 Inference Endpoint (see owlv2_endpoint/handler.py).
2
+
3
+ One call per frame returns per-product detections (box + confidence + objectness). The query
4
+ payload per product is built once by the strategy and reused across frames.
5
+ """
6
+
7
+ import base64
8
+ import io
9
+ import os
10
+ import statistics
11
+ import threading
12
+ import time
13
+
14
+ import requests
15
+ from PIL import Image
16
+
17
+ from .config import OWLV2_ENDPOINT_URL
18
+
19
+ # Scale-from-zero cold starts can take a minute+; poll a 503 endpoint this often, up to
20
+ # this total, before giving up — separate from the small transient-error attempt budget.
21
+ COLD_START_POLL_S = 10.0
22
+ COLD_START_BUDGET_S = 4.0 # 180.0
23
+
24
+ # Per-frame timing fields collected into the Profiler. Client stages are measured here;
25
+ # `server_*` come from the handler's `timing` dict (see owlv2_endpoint/handler.py).
26
+ _PROFILE_FIELDS = (
27
+ "frame_encode_ms", # client: JPEG+base64 the frame
28
+ "network_ms", # client: POST round trip (handler compute + transfer + queue)
29
+ "server_total", # handler: end-to-end inside __call__
30
+ "server_decode", # handler: base64 -> PIL
31
+ "server_forward", # handler: vision backbone + box/objectness heads
32
+ "server_queries", # handler: per-product query embedding (cache miss = expensive)
33
+ "server_classify", # handler: class_predictor + NMS + box decode
34
+ "overhead_ms", # derived: network_ms - server_total (transfer + queue wait)
35
+ "dino_ms", # strategy: DINO crop embedding for this frame (0 if no DINO)
36
+ )
37
+
38
+
39
+ class Profiler:
40
+ """Thread-safe accumulator of per-frame timing records, logged as one summary at the
41
+ end of a detect() run. Concurrent workers append; aggregation happens once at the end."""
42
+
43
+ def __init__(self) -> None:
44
+ self._lock = threading.Lock()
45
+ self._records: list[dict] = []
46
+
47
+ def add(self, record: dict) -> None:
48
+ with self._lock:
49
+ self._records.append(record)
50
+
51
+ def log_summary(self, wall_s: float, n_products: int,
52
+ extra: dict | None = None) -> None:
53
+ recs = self._records
54
+ if not recs:
55
+ return
56
+ lines = [
57
+ f"[owlv2] {len(recs)} endpoint-calls, {n_products} product(s), "
58
+ f"wall={wall_s:.1f}s ({len(recs) / wall_s:.1f} calls/s)",
59
+ ]
60
+ for k, v in (extra or {}).items():
61
+ lines.append(f" {k}: {v}")
62
+ lines.append(f" {'stage':<16}{'sum_s':>9}{'mean_ms':>9}"
63
+ f"{'p50':>8}{'p95':>8}{'max':>8}")
64
+ for field in _PROFILE_FIELDS:
65
+ vals = [r[field] for r in recs if r.get(field) is not None]
66
+ if not vals:
67
+ continue
68
+ p = sorted(vals)
69
+ lines.append(
70
+ f" {field:<16}{sum(vals) / 1000:>9.1f}{statistics.mean(vals):>9.1f}"
71
+ f"{p[len(p) // 2]:>8.1f}{p[min(len(p) - 1, int(len(p) * 0.95))]:>8.1f}"
72
+ f"{max(vals):>8.1f}"
73
+ )
74
+ hits = sum(r.get("server_cache_hits", 0) for r in recs)
75
+ lines.append(f" query-cache hits: {hits}/{n_products * len(recs)}")
76
+ print("\n".join(lines))
77
+
78
+
79
+ def image_b64(img: Image.Image, fmt: str = "JPEG", quality: int = 90) -> str:
80
+ buf = io.BytesIO()
81
+ img.convert("RGB").save(buf, format=fmt, quality=quality)
82
+ return base64.b64encode(buf.getvalue()).decode()
83
+
84
+
85
+ def file_b64(path: str) -> str:
86
+ return image_b64(Image.open(path))
87
+
88
+
89
+ def owlv2_detect_batch(
90
+ frames: list[Image.Image],
91
+ products_query: list[dict],
92
+ ref_type: str,
93
+ floor: float,
94
+ attempts: int = 3,
95
+ ) -> tuple[list[list[list[dict]]], dict]:
96
+ """POST a batch of frames + all products' queries in one request; return
97
+ (per_frame_detections, timing_record). per_frame_detections[f][p] = product p's list of
98
+ {box:[x,y,w,h] normalized, confidence, objectness} in frame f. One forward pass on the
99
+ endpoint serves the whole batch. timing_record holds this call's client + server stage
100
+ timings (see _PROFILE_FIELDS). Retries transient errors."""
101
+ if not OWLV2_ENDPOINT_URL:
102
+ raise RuntimeError("OWLV2_ENDPOINT_URL is not set (see owlv2_endpoint/README.md).")
103
+ token = os.getenv("HF_TOKEN", "")
104
+ headers = {"Content-Type": "application/json"}
105
+ if token:
106
+ headers["Authorization"] = f"Bearer {token}"
107
+ t_enc = time.perf_counter()
108
+ payload = {
109
+ "inputs": {
110
+ "images": [image_b64(f) for f in frames],
111
+ "ref_type": ref_type,
112
+ "products": products_query,
113
+ "score_floor": floor,
114
+ }
115
+ }
116
+ encode_ms = (time.perf_counter() - t_enc) * 1000
117
+ last: Exception | None = None
118
+ cold_waited = 0.0
119
+ i = 0
120
+ while i < attempts:
121
+ try:
122
+ t_net = time.perf_counter()
123
+ resp = requests.post(OWLV2_ENDPOINT_URL, headers=headers, json=payload, timeout=120)
124
+ if resp.status_code == 503 and cold_waited < COLD_START_BUDGET_S:
125
+ # Endpoint scaled to zero / still booting. A scale-from-zero cold start
126
+ # takes far longer than the generic retry backoff, so wait it out without
127
+ # spending an attempt — otherwise every run that hits a cold endpoint fails.
128
+ time.sleep(COLD_START_POLL_S)
129
+ cold_waited += COLD_START_POLL_S
130
+ continue
131
+ if not resp.ok:
132
+ # HF surfaces handler exceptions in the body; include it so the real
133
+ # cause (not just "400 Bad Request") reaches the caller.
134
+ raise RuntimeError(f"{resp.status_code} {resp.reason}: {resp.text[:2000]}")
135
+ network_ms = (time.perf_counter() - t_net) * 1000
136
+ body = resp.json()
137
+ srv = body.get("timing") or {}
138
+ record = {
139
+ "frame_encode_ms": round(encode_ms, 2),
140
+ "network_ms": round(network_ms, 2),
141
+ "server_total": srv.get("total"),
142
+ "server_decode": srv.get("decode"),
143
+ "server_forward": srv.get("forward"),
144
+ "server_queries": srv.get("queries"),
145
+ "server_classify": srv.get("classify"),
146
+ "server_cache_hits": srv.get("query_cache_hits", 0),
147
+ "frames_in_batch": len(frames),
148
+ "overhead_ms": round(network_ms - srv["total"], 2) if "total" in srv else None,
149
+ }
150
+ return body.get("frames", []), record
151
+ except Exception as exc: # noqa: BLE001 — transient endpoint error
152
+ last = exc
153
+ i += 1
154
+ if i < attempts:
155
+ time.sleep(1.0 * i) # short backoff for genuinely transient failures
156
+ raise RuntimeError(f"OWLv2 endpoint call failed: {last}")
app/analysis/registry.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The single source of truth mapping each DetectionMode to how it's built and validated.
2
+
3
+ Add or change a mode here by composing existing strategies + similarity backends — the
4
+ runner and `main.py`'s request validation both read from this table.
5
+ """
6
+
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+
10
+ from ..schemas import DetectionMode, ScoreSpec
11
+ from ..similarity import ClipEmbedder, DinoEmbedder
12
+ from .config import (
13
+ CLIP_MIN_AREA_FRAC,
14
+ CLIP_THRESHOLD,
15
+ CLIP_TOPK,
16
+ GDINO_DINO_THRESHOLD,
17
+ SAM2_DINO_CANDIDATE_FLOOR,
18
+ SAM2_DINO_THRESHOLD,
19
+ SAM2_DINO_TOPK,
20
+ SAM2_MIN_AREA_FRAC,
21
+ SAM3_1_ENDPOINT,
22
+ SAM3_ENDPOINT,
23
+ )
24
+ from .strategies import (
25
+ DetectionStrategy,
26
+ DinoRunRefiner,
27
+ GroundedStrategy,
28
+ OpenAiRefiner,
29
+ OwlV2Strategy,
30
+ SamTextStrategy,
31
+ SegmentMatchStrategy,
32
+ )
33
+
34
+
35
+ def _sim(default: float) -> list[ScoreSpec]:
36
+ """The single 'similarity' slider used by the DINO/CLIP match modes."""
37
+ return [ScoreSpec(key="similarity", label="Similarity", default=default)]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class BuildOpts:
42
+ """Per-request knobs threaded into a strategy's construction."""
43
+
44
+ dino_variant: str = "v2" # DINO backbone for the *_dino modes
45
+ owl_ref_type: str = "text" # OWLv2 reference type: text | image | both
46
+ owl_dino: str = "none" # OWLv2 DINO-on-top: none | v2 | v3
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ModeSpec:
51
+ # build(opts) -> strategy. None for the debug "none" mode (handled in runner).
52
+ build: Callable[["BuildOpts"], DetectionStrategy | None]
53
+ requires_name: bool = True
54
+ requires_reference: bool = False
55
+
56
+
57
+ REGISTRY: dict[DetectionMode, ModeSpec] = {
58
+ DetectionMode.sam3_text: ModeSpec(
59
+ lambda o: SamTextStrategy(SAM3_ENDPOINT),
60
+ ),
61
+ DetectionMode.sam3_text_dino: ModeSpec(
62
+ lambda o: SamTextStrategy(SAM3_ENDPOINT, DinoRunRefiner(DinoEmbedder(o.dino_variant))),
63
+ requires_reference=True,
64
+ ),
65
+ DetectionMode.sam3_1_text_dino: ModeSpec(
66
+ lambda o: SamTextStrategy(SAM3_1_ENDPOINT, DinoRunRefiner(DinoEmbedder(o.dino_variant))),
67
+ requires_reference=True,
68
+ ),
69
+ DetectionMode.sam3_text_openai: ModeSpec(
70
+ lambda o: SamTextStrategy(SAM3_ENDPOINT, OpenAiRefiner()),
71
+ ),
72
+ DetectionMode.sam3_clip: ModeSpec(
73
+ lambda o: SegmentMatchStrategy(
74
+ ClipEmbedder(), threshold=CLIP_THRESHOLD, topk=CLIP_TOPK,
75
+ min_area_frac=CLIP_MIN_AREA_FRAC, mask_overlay=False, smooth=False,
76
+ ),
77
+ requires_name=False, # reference-only; name is just a label
78
+ requires_reference=True,
79
+ ),
80
+ DetectionMode.sam2_dino: ModeSpec(
81
+ lambda o: SegmentMatchStrategy(
82
+ DinoEmbedder(o.dino_variant), threshold=SAM2_DINO_CANDIDATE_FLOOR,
83
+ topk=SAM2_DINO_TOPK, min_area_frac=SAM2_MIN_AREA_FRAC,
84
+ mask_overlay=True, smooth=True, score_specs=_sim(SAM2_DINO_THRESHOLD),
85
+ ),
86
+ requires_reference=True,
87
+ ),
88
+ DetectionMode.gdino_text_dino: ModeSpec(
89
+ lambda o: GroundedStrategy(DinoEmbedder(o.dino_variant), _sim(GDINO_DINO_THRESHOLD)),
90
+ requires_reference=True,
91
+ ),
92
+ DetectionMode.owlv2: ModeSpec(
93
+ lambda o: OwlV2Strategy(
94
+ o.owl_ref_type,
95
+ DinoEmbedder(o.owl_dino) if o.owl_dino != "none" else None,
96
+ ),
97
+ requires_name=True, # the product name labels it + is the text query (text/both)
98
+ requires_reference=False, # image refs only when image/both/DINO-on-top (owlv2_needs_images)
99
+ ),
100
+ DetectionMode.none: ModeSpec(lambda o: None, requires_name=False), # debug, runner-handled
101
+ }
102
+
103
+
104
+ def spec_for(mode: DetectionMode) -> ModeSpec:
105
+ return REGISTRY[mode]
106
+
107
+
108
+ def strategy_for(mode: DetectionMode, opts: "BuildOpts") -> DetectionStrategy | None:
109
+ return REGISTRY[mode].build(opts)
110
+
111
+
112
+ def requires_reference(mode: DetectionMode) -> bool:
113
+ return REGISTRY[mode].requires_reference
114
+
115
+
116
+ def requires_name(mode: DetectionMode) -> bool:
117
+ return REGISTRY[mode].requires_name
118
+
119
+
120
+ def owlv2_needs_images(mode: DetectionMode, opts: BuildOpts) -> bool:
121
+ """OWLv2 needs reference images when matching by image (image/both ref types) or when
122
+ DINO-on-top is enabled (it scores crops vs an image reference)."""
123
+ return mode == DetectionMode.owlv2 and (
124
+ opts.owl_ref_type in ("image", "both") or opts.owl_dino != "none"
125
+ )
app/analysis/runner.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Background entrypoint: validate inputs, run the mode's strategy, assemble the result.
2
+
3
+ The whole pipeline is: validate → strategy.detect (per-product boxes) → build_product_result
4
+ per product (+ scene-cut split / cut-frame export) → enrich mentions → store.
5
+ """
6
+
7
+ from ..jobs import store
8
+ from ..mentions import (
9
+ count_keywords,
10
+ count_keywords_breakdown,
11
+ count_ocr_mentions,
12
+ parse_keywords,
13
+ transcribe,
14
+ )
15
+ from ..schemas import AnalysisResult, Box, DetectionMode, JobStatus, ProductResult
16
+ from .registry import BuildOpts, owlv2_needs_images, requires_name, requires_reference, strategy_for
17
+ from .strategies import ProductInput
18
+ from .tracks import build_product_result, detect_cut_frames
19
+ from .video import probe_duration
20
+
21
+
22
+ def _validate(mode: DetectionMode, products: list[ProductInput], opts: BuildOpts) -> None:
23
+ if not products:
24
+ raise ValueError("At least one product is required.")
25
+ if requires_name(mode) and any(not p.name.strip() for p in products):
26
+ raise ValueError(f"{mode.value} needs a name for every product.")
27
+ needs_ref = requires_reference(mode) or owlv2_needs_images(mode, opts)
28
+ if needs_ref and any(not p.exemplar_paths for p in products):
29
+ raise ValueError(f"{mode.value} needs a reference image for every product.")
30
+
31
+
32
+ def run_analysis(
33
+ job_id: str,
34
+ video_path: str,
35
+ products: list[ProductInput],
36
+ caption: str,
37
+ mention_keywords: str,
38
+ mode: DetectionMode,
39
+ split_on_cut: bool = False,
40
+ dino_variant: str = "v2",
41
+ owl_ref_type: str = "text",
42
+ owl_dino: str = "none",
43
+ ) -> None:
44
+ """Background entrypoint. Updates the job store in place."""
45
+ store.set_status(job_id, JobStatus.running)
46
+
47
+ # --- DEBUG: no-op mode → dummy result (delete this block to remove) ------ #
48
+ if mode == DetectionMode.none:
49
+ store.set_done(job_id, _dummy_result())
50
+ return
51
+ # ------------------------------------------------------------------------- #
52
+
53
+ try:
54
+ opts = BuildOpts(
55
+ dino_variant=dino_variant, owl_ref_type=owl_ref_type, owl_dino=owl_dino
56
+ )
57
+ _validate(mode, products, opts)
58
+ strategy = strategy_for(mode, opts)
59
+ duration = probe_duration(video_path)
60
+ fps = strategy.fps
61
+
62
+ per_product = strategy.detect(video_path, products) # {name: {frame: [Box]}}
63
+
64
+ # Slider modes re-filter/recount in the UI, so they own the scene-cut split too:
65
+ # hand the UI the cut boundaries and let it split at any threshold.
66
+ specs = strategy.score_specs
67
+ is_slider = bool(specs)
68
+ cut_frames: list[int] = []
69
+ if split_on_cut and is_slider:
70
+ union = sorted({int(k) for boxes in per_product.values() for k in boxes})
71
+ cut_frames = detect_cut_frames(video_path, fps, union)
72
+
73
+ result = AnalysisResult(
74
+ fps=float(fps),
75
+ duration_sec=duration,
76
+ mode=mode,
77
+ products=[
78
+ build_product_result(
79
+ p.name, per_product.get(p.name, {}), fps, duration, video_path,
80
+ split_on_cut and not is_slider, # backend split only for non-slider modes
81
+ )
82
+ for p in products
83
+ ],
84
+ brand_name=", ".join(p.name for p in products) or None,
85
+ score_specs=specs,
86
+ cut_frames=cut_frames,
87
+ )
88
+ _enrich_mentions(
89
+ result, video_path, caption, [p.name for p in products], mention_keywords
90
+ )
91
+ store.set_done(job_id, result)
92
+ except Exception as exc: # noqa: BLE001 — surface any failure to the client
93
+ store.set_error(job_id, f"{type(exc).__name__}: {exc}")
94
+
95
+
96
+ def _enrich_mentions(
97
+ result: AnalysisResult,
98
+ video_path: str,
99
+ caption: str,
100
+ product_names: list[str],
101
+ mention_keywords: str,
102
+ ) -> None:
103
+ """Phase 4: transcript + mention counts, driven by the dedicated keyword list (falling
104
+ back to the product names). Each step degrades to None on failure so a flaky model never
105
+ loses the visual analysis."""
106
+ keywords = parse_keywords(mention_keywords) or [
107
+ n.strip() for n in product_names if n.strip()
108
+ ]
109
+ if keywords:
110
+ result.caption_mentions = count_keywords(caption, keywords)
111
+ result.caption_mention_counts = count_keywords_breakdown(caption, keywords)
112
+ try:
113
+ result.transcript = transcribe(video_path)
114
+ if keywords:
115
+ result.audio_mentions = count_keywords(result.transcript, keywords)
116
+ result.audio_mention_counts = count_keywords_breakdown(
117
+ result.transcript, keywords
118
+ )
119
+ except Exception: # noqa: BLE001
120
+ pass
121
+ if keywords:
122
+ try:
123
+ result.ocr_mentions, result.ocr_mention_counts = count_ocr_mentions(
124
+ video_path, keywords
125
+ )
126
+ except Exception: # noqa: BLE001
127
+ pass
128
+
129
+
130
+ # --- DEBUG: dummy result for the no-analysis "none" mode (delete to remove) -- #
131
+ def _dummy_result() -> AnalysisResult:
132
+ """Fabricated result so the UI can be exercised with zero fal/ML calls."""
133
+ from .config import VIDEO_FPS
134
+
135
+ fps, duration = float(VIDEO_FPS), 10.0
136
+
137
+ def track(name: str, frames, conf: float) -> ProductResult:
138
+ boxes = {
139
+ str(f): [Box(x=0.2 + 0.01 * (f % 10), y=0.3, w=0.25, h=0.3,
140
+ scores={"similarity": conf})]
141
+ for f in frames
142
+ }
143
+ return build_product_result(name, boxes, fps, duration)
144
+
145
+ return AnalysisResult(
146
+ fps=fps, duration_sec=duration, mode=DetectionMode.none,
147
+ products=[
148
+ track("debug product A", range(0, 7), 0.92),
149
+ track("debug product B", list(range(12, 19)) + list(range(24, 28)), 0.74),
150
+ ],
151
+ brand_name="debug product A, debug product B",
152
+ transcript="(debug) no transcription was run.",
153
+ audio_mentions=3, caption_mentions=1, ocr_mentions=2,
154
+ audio_mention_counts={"debug": 3},
155
+ caption_mention_counts={"debug": 1},
156
+ ocr_mention_counts={"debug": 2},
157
+ )
app/analysis/strategies.py ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Detection strategies — each produces per-product per-frame boxes for a video.
2
+
3
+ Every mode is one of four shapes, composed with an injected similarity backend:
4
+ - SamTextStrategy: SAM 3/3.1 text video-track per product, optional appearance refiner.
5
+ - SegmentMatchStrategy: SAM 2 segment-everything per frame, match each crop with an Embedder.
6
+ - GroundedStrategy: EVF-SAM (Grounding DINO) text-segment per product, score with an Embedder.
7
+ - (the debug "none" mode is handled in the runner, not here)
8
+ """
9
+
10
+ import tempfile
11
+ import time
12
+ from abc import ABC, abstractmethod
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ import fal_client
18
+ import numpy as np
19
+ from PIL import Image
20
+
21
+ from ..schemas import Box, ScoreSpec
22
+ from ..similarity import DINO_THRESHOLD, Embedder, OpenAiVerifier
23
+ from .config import (
24
+ AUTOSEG_ENDPOINT,
25
+ DETECTION_THRESHOLD,
26
+ EVF_SAM_ENDPOINT,
27
+ FRAME_FPS,
28
+ GDINO_CANDIDATE_FLOOR,
29
+ GDINO_EMBED_CHUNK,
30
+ GDINO_FRAME_WORKERS,
31
+ GDINO_MIN_AREA_FRAC,
32
+ OWL_CONF_THRESHOLD,
33
+ OWL_OBJ_THRESHOLD,
34
+ OWL_SIM_THRESHOLD,
35
+ OWLV2_EMBED_CHUNK,
36
+ OWLV2_FRAME_BATCH,
37
+ OWLV2_FRAME_WORKERS,
38
+ OWLV2_SCORE_FLOOR,
39
+ SEGMENT_FRAME_WORKERS,
40
+ SEGMENT_MASK_WORKERS,
41
+ VIDEO_FPS,
42
+ )
43
+ from .masks import crop_box, encode_rle, fetch_mask, masked_crop
44
+ from .owlv2_client import Profiler, file_b64, owlv2_detect_batch
45
+ from .tracks import FrameBoxes, fill_micro_gaps, frame_runs
46
+ from .video import downsample_video, extract_frame, sample_frames
47
+
48
+
49
+ @dataclass
50
+ class ProductInput:
51
+ """One product to track: a SAM text prompt + its reference image paths."""
52
+
53
+ name: str
54
+ exemplar_paths: list[str] = field(default_factory=list)
55
+
56
+
57
+ class DetectionStrategy(ABC):
58
+ """Produces ``{product_name: {frame_index: [Box]}}`` for a video.
59
+
60
+ `score_specs` declares which named scores this strategy's boxes carry that should get a
61
+ post-hoc UI slider (empty = none). The runner copies it into the result."""
62
+
63
+ fps: float = VIDEO_FPS
64
+ score_specs: list[ScoreSpec] = []
65
+
66
+ @abstractmethod
67
+ def detect(
68
+ self, video_path: str, products: list[ProductInput]
69
+ ) -> dict[str, FrameBoxes]: ...
70
+
71
+
72
+ # --------------------------------------------------------------------------- #
73
+ # SAM 3 / 3.1 text tracking (+ optional per-appearance refiner)
74
+ # --------------------------------------------------------------------------- #
75
+ class AppearanceRefiner(ABC):
76
+ """Re-scores/filters a per-product box track after SAM-text detection."""
77
+
78
+ @abstractmethod
79
+ def refine(
80
+ self, video_path: str, fps: float, boxes: FrameBoxes, product: ProductInput
81
+ ) -> FrameBoxes: ...
82
+
83
+
84
+ class DinoRunRefiner(AppearanceRefiner):
85
+ """Score *every* frame of each appearance with DINO; keep runs whose mean clears the
86
+ threshold, annotating each kept box with its per-frame `similarity` score (the
87
+ per-appearance average is filled later by build_product_result)."""
88
+
89
+ def __init__(self, embedder: Embedder):
90
+ self.embedder = embedder
91
+
92
+ def refine(self, video_path, fps, boxes, product):
93
+ ref = self.embedder.reference(product.exemplar_paths)
94
+ kept: FrameBoxes = {}
95
+ for a, b in frame_runs([int(k) for k in boxes]):
96
+ candidates = [f for f in range(a, b + 1) if str(f) in boxes]
97
+ scores = {
98
+ f: self.embedder.score(
99
+ ref, [crop_box(extract_frame(video_path, f / fps), boxes[str(f)][0])]
100
+ )[0]
101
+ for f in candidates
102
+ }
103
+ if float(np.mean(list(scores.values()))) < DINO_THRESHOLD:
104
+ continue
105
+ for f in candidates:
106
+ kept[str(f)] = [
107
+ boxes[str(f)][0].model_copy(
108
+ update={"scores": {"similarity": round(scores[f], 3)}}
109
+ )
110
+ ]
111
+ return kept
112
+
113
+
114
+ class OpenAiRefiner(AppearanceRefiner):
115
+ """Keep only appearances whose representative frame passes the OpenAI yes/no check."""
116
+
117
+ def refine(self, video_path, fps, boxes, product):
118
+ verifier = OpenAiVerifier(product.name)
119
+ kept: FrameBoxes = {}
120
+ for a, b in frame_runs([int(k) for k in boxes]):
121
+ candidates = [f for f in range(a, b + 1) if str(f) in boxes]
122
+ mid = (a + b) // 2
123
+ rep = min(candidates, key=lambda f: abs(f - mid))
124
+ if verifier.verify(extract_frame(video_path, rep / fps), boxes[str(rep)][0]):
125
+ for f in candidates:
126
+ kept[str(f)] = boxes[str(f)]
127
+ return kept
128
+
129
+
130
+ class SamTextStrategy(DetectionStrategy):
131
+ fps = VIDEO_FPS
132
+
133
+ def __init__(self, endpoint: str, refiner: AppearanceRefiner | None = None):
134
+ self.endpoint = endpoint
135
+ self.refiner = refiner
136
+
137
+ def detect(self, video_path, products):
138
+ out: dict[str, FrameBoxes] = {}
139
+ for p in products:
140
+ boxes = self._text_boxes(video_path, p.name)
141
+ if self.refiner is not None:
142
+ boxes = self.refiner.refine(video_path, self.fps, boxes, p)
143
+ out[p.name] = boxes
144
+ return out
145
+
146
+ def _text_boxes(self, video_path: str, prompt: str) -> FrameBoxes:
147
+ with tempfile.TemporaryDirectory() as tmp:
148
+ small = downsample_video(video_path, VIDEO_FPS, Path(tmp))
149
+ url = fal_client.upload_file(small)
150
+ result = fal_client.subscribe(
151
+ self.endpoint,
152
+ arguments={"video_url": url, "prompt": prompt,
153
+ "detection_threshold": DETECTION_THRESHOLD},
154
+ with_logs=False,
155
+ )
156
+ boxes: FrameBoxes = {}
157
+ for m in result.get("metadata") or []:
158
+ box = m.get("box")
159
+ if box:
160
+ s = m.get("score")
161
+ boxes[str(int(m["index"]))] = [
162
+ Box(x=box[0], y=box[1], w=box[2], h=box[3],
163
+ scores={"detection": s} if s is not None else {})
164
+ ]
165
+ return boxes
166
+
167
+
168
+ # --------------------------------------------------------------------------- #
169
+ # SAM 2 segment-everything + embedder match (sam2_dino, sam3_clip)
170
+ # --------------------------------------------------------------------------- #
171
+ class SegmentMatchStrategy(DetectionStrategy):
172
+ fps = FRAME_FPS
173
+
174
+ def __init__(
175
+ self,
176
+ embedder: Embedder,
177
+ *,
178
+ threshold: float,
179
+ topk: int,
180
+ min_area_frac: float,
181
+ mask_overlay: bool,
182
+ smooth: bool,
183
+ score_specs: list[ScoreSpec] = [],
184
+ ):
185
+ self.embedder = embedder
186
+ self.threshold = threshold
187
+ self.topk = topk
188
+ self.min_area_frac = min_area_frac
189
+ self.mask_overlay = mask_overlay
190
+ self.smooth = smooth
191
+ self.score_specs = score_specs
192
+
193
+ def detect(self, video_path, products):
194
+ refs = {p.name: self.embedder.reference(p.exemplar_paths) for p in products}
195
+ out: dict[str, FrameBoxes] = {p.name: {} for p in products}
196
+ with tempfile.TemporaryDirectory() as tmp:
197
+ files = sample_frames(video_path, self.fps, Path(tmp))
198
+ with ThreadPoolExecutor(max_workers=SEGMENT_FRAME_WORKERS) as pool:
199
+ for n, frame_out in pool.map(
200
+ lambda nf: self._frame(nf[0], nf[1], refs), enumerate(files)
201
+ ):
202
+ for name, boxes in frame_out.items():
203
+ out[name][str(n)] = boxes
204
+ if self.smooth:
205
+ for boxes in out.values():
206
+ fill_micro_gaps(boxes)
207
+ return out
208
+
209
+ def _frame(self, n, frame_path, refs):
210
+ frame = Image.open(frame_path).convert("RGB")
211
+ fw, fh = frame.size
212
+ seg = fal_client.subscribe(
213
+ AUTOSEG_ENDPOINT,
214
+ arguments={"image_url": fal_client.upload_file(str(frame_path))},
215
+ with_logs=False,
216
+ )
217
+ metas = seg.get("individual_masks") or []
218
+ with ThreadPoolExecutor(max_workers=SEGMENT_MASK_WORKERS) as pool:
219
+ survivors = [
220
+ s for s in pool.map(lambda mm: self._survivor(mm, frame, fw, fh), metas) if s
221
+ ]
222
+ if not survivors:
223
+ return n, {}
224
+
225
+ rects = [s[0] for s in survivors]
226
+ crops = [s[1] for s in survivors]
227
+ masks = [s[2] for s in survivors]
228
+ frame_out: FrameBoxes = {}
229
+ for name, ref in refs.items():
230
+ sims = self.embedder.score(ref, crops) # best cosine over the product's refs
231
+ scored = sorted(
232
+ ((sims[i], rects[i], masks[i]) for i in range(len(rects))),
233
+ key=lambda t: t[0],
234
+ reverse=True,
235
+ )
236
+ matched = [
237
+ Box(
238
+ x=left / fw, y=upper / fh,
239
+ w=(right - left) / fw, h=(lower - upper) / fh,
240
+ scores={"similarity": round(cos, 3)},
241
+ mask=encode_rle(m) if self.mask_overlay else None,
242
+ )
243
+ for cos, (left, upper, right, lower), m in scored[: self.topk]
244
+ if cos >= self.threshold
245
+ ]
246
+ if matched:
247
+ frame_out[name] = matched
248
+ return n, frame_out
249
+
250
+ def _survivor(self, mask_meta, frame, fw, fh):
251
+ m = fetch_mask(mask_meta["url"])
252
+ if m is None or (bbox := m.getbbox()) is None:
253
+ return None
254
+ left, upper, right, lower = bbox
255
+ if (right - left) * (lower - upper) < self.min_area_frac * fw * fh:
256
+ return None
257
+ crop = masked_crop(frame, m, bbox) if self.mask_overlay else frame.crop(bbox)
258
+ return bbox, crop, m
259
+
260
+
261
+ # --------------------------------------------------------------------------- #
262
+ # EVF-SAM text-grounded segmentation + embedder score (gdino_text_dino)
263
+ # --------------------------------------------------------------------------- #
264
+ class GroundedStrategy(DetectionStrategy):
265
+ """Two-stage to keep DINO off the network threads:
266
+ (1) network — EVF + mask download per (frame, product), highly concurrent;
267
+ (2) CPU — embed the surviving crops in fat batches. This avoids running 100s of
268
+ multi-threaded ONNX inferences at once (which oversubscribes the CPU and gets *slower*
269
+ with more workers), and replaces batch-of-1 embeds with a few large ones."""
270
+
271
+ fps = FRAME_FPS
272
+
273
+ def __init__(self, embedder: Embedder, score_specs: list[ScoreSpec] = []):
274
+ self.embedder = embedder
275
+ self.score_specs = score_specs
276
+
277
+ def detect(self, video_path, products):
278
+ t0 = time.time()
279
+ refs = {p.name: self.embedder.reference(p.exemplar_paths) for p in products}
280
+ out: dict[str, FrameBoxes] = {p.name: {} for p in products}
281
+ with tempfile.TemporaryDirectory() as tmp:
282
+ files = sample_frames(video_path, self.fps, Path(tmp))
283
+ # Stage 1 (network): upload each frame once, then EVF + mask per (frame, product).
284
+ with ThreadPoolExecutor(max_workers=GDINO_FRAME_WORKERS) as pool:
285
+ urls = list(pool.map(lambda fp: fal_client.upload_file(str(fp)), files))
286
+ t_up = time.time()
287
+ tasks = [(n, p) for n in range(len(files)) for p in products]
288
+ candidates = [
289
+ c for c in pool.map(
290
+ lambda t: self._candidate(t[0], urls[t[0]], files[t[0]], t[1]), tasks
291
+ ) if c is not None
292
+ ]
293
+ t_net = time.time()
294
+
295
+ # Stage 2 (CPU): batch-embed each product's crops, gate, build boxes.
296
+ for name, ref in refs.items():
297
+ cand = [c for c in candidates if c[0] == name]
298
+ if not cand:
299
+ continue
300
+ sims = self._scores(ref, [c[3] for c in cand])
301
+ for (_, n, coords, _crop, mask), cos in zip(cand, sims):
302
+ if cos < GDINO_CANDIDATE_FLOOR: # UI slider re-thresholds above this
303
+ continue
304
+ x, y, w, h = coords
305
+ out[name][str(n)] = [
306
+ Box(x=x, y=y, w=w, h=h,
307
+ scores={"similarity": round(cos, 3)}, mask=mask)
308
+ ]
309
+ for boxes in out.values():
310
+ fill_micro_gaps(boxes)
311
+ # DEBUG timing (remove when tuned): splits upload vs EVF so we can see fal's ceiling.
312
+ n_evf = len(files) * len(products)
313
+ print(
314
+ f"[gdino] {len(files)} frames × {len(products)} products | "
315
+ f"{len(candidates)} candidates | upload {t_up - t0:.1f}s + "
316
+ f"evf {t_net - t_up:.1f}s ({n_evf} calls, {(t_net - t_up) / max(n_evf, 1):.2f}s/call) "
317
+ f"+ embed {time.time() - t_net:.1f}s | workers={GDINO_FRAME_WORKERS}",
318
+ flush=True,
319
+ )
320
+ return out
321
+
322
+ def _candidate(self, n, image_url, frame_path, product):
323
+ """EVF-segment one product in one frame → (name, n, coords, crop, rle) or None.
324
+ Network/I-O + cropping only; no embedding (that's the batched stage 2)."""
325
+ try:
326
+ seg = fal_client.subscribe(
327
+ EVF_SAM_ENDPOINT,
328
+ arguments={"image_url": image_url, "prompt": product.name,
329
+ "use_grounding_dino": True, "mask_only": True},
330
+ with_logs=False,
331
+ )
332
+ except fal_client.FalClientHTTPError as exc:
333
+ # EVF-SAM 422s when the prompt matches nothing in the frame — normal "absent".
334
+ if exc.status_code == 422 and "No objects matching" in str(exc):
335
+ return None
336
+ raise
337
+ img = seg.get("image") or {}
338
+ if not img.get("url"):
339
+ return None
340
+ m = fetch_mask(img["url"])
341
+ if m is None or (bbox := m.getbbox()) is None:
342
+ return None
343
+ fw, fh = m.size # full-frame mask shares the frame's dimensions
344
+ left, upper, right, lower = bbox
345
+ if (right - left) * (lower - upper) < GDINO_MIN_AREA_FRAC * fw * fh:
346
+ return None
347
+ frame = Image.open(frame_path).convert("RGB")
348
+ coords = (left / fw, upper / fh, (right - left) / fw, (lower - upper) / fh)
349
+ return product.name, n, coords, masked_crop(frame, m, bbox), encode_rle(m)
350
+
351
+ def _scores(self, ref, crops):
352
+ """Embed crops in chunks — a few fat ONNX passes instead of one tiny call each."""
353
+ sims: list[float] = []
354
+ for i in range(0, len(crops), GDINO_EMBED_CHUNK):
355
+ sims.extend(self.embedder.score(ref, crops[i : i + GDINO_EMBED_CHUNK]))
356
+ return sims
357
+
358
+
359
+ # --------------------------------------------------------------------------- #
360
+ # OWLv2 hosted detection (text / image / both refs) + optional DINO-on-top
361
+ # --------------------------------------------------------------------------- #
362
+ class OwlV2Strategy(DetectionStrategy):
363
+ """Per frame, one call to the hosted OWLv2 endpoint returns each product's best detection
364
+ with a `confidence` + `objectness`. With DINO-on-top, the crop is also scored vs the
365
+ product's image reference, adding a `similarity`. Each score gets its own slider."""
366
+
367
+ fps = FRAME_FPS
368
+
369
+ def __init__(self, ref_type: str, dino: Embedder | None):
370
+ self.ref_type = ref_type
371
+ self.dino = dino
372
+ self.score_specs = [
373
+ ScoreSpec(key="confidence", label="Confidence", default=OWL_CONF_THRESHOLD),
374
+ ScoreSpec(key="objectness", label="Objectness", default=OWL_OBJ_THRESHOLD),
375
+ ]
376
+ if dino is not None:
377
+ self.score_specs.append(
378
+ ScoreSpec(key="similarity", label="Similarity", default=OWL_SIM_THRESHOLD)
379
+ )
380
+
381
+ def _query(self, p: ProductInput) -> dict:
382
+ q: dict = {}
383
+ if self.ref_type in ("text", "both"):
384
+ q["texts"] = [p.name]
385
+ if self.ref_type in ("image", "both"):
386
+ q["images"] = [file_b64(path) for path in p.exemplar_paths]
387
+ return q
388
+
389
+ def detect(self, video_path, products):
390
+ # Two-stage, mirroring GroundedStrategy: (1) network — endpoint calls (each a batch of
391
+ # frames, one GPU forward pass) collecting best-per-product boxes (+ crops if DINO);
392
+ # (2) CPU — embed all crops in fat batches per product, not a batch-of-1 per frame.
393
+ queries = [self._query(p) for p in products] # built once, reused per frame
394
+ dino_on = self.dino is not None
395
+ dino_refs = (
396
+ {p.name: self.dino.reference(p.exemplar_paths) for p in products}
397
+ if dino_on else {}
398
+ )
399
+ prof = Profiler()
400
+ out: dict[str, FrameBoxes] = {p.name: {} for p in products}
401
+ pending: list[tuple[str, Box, Image.Image]] = [] # (product, box, crop) for stage 2
402
+ wall0 = time.perf_counter()
403
+ with tempfile.TemporaryDirectory() as tmp:
404
+ t_sample = time.perf_counter()
405
+ files = sample_frames(video_path, self.fps, Path(tmp))
406
+ sample_s = time.perf_counter() - t_sample
407
+ batches = [
408
+ list(range(i, min(i + OWLV2_FRAME_BATCH, len(files))))
409
+ for i in range(0, len(files), OWLV2_FRAME_BATCH)
410
+ ]
411
+
412
+ def consume(batch_results):
413
+ for n, frame_out, crops in batch_results:
414
+ for name, boxes in frame_out.items():
415
+ out[name][str(n)] = boxes
416
+ pending.extend(crops)
417
+
418
+ # Run the first batch alone to warm the endpoint's per-product query cache, so the
419
+ # concurrent fan-out that follows all hits it (no first-wave recompute race). The
420
+ # warm-up batch is real work — its detections are kept, nothing wasted.
421
+ if batches:
422
+ consume(self._batch(batches[0], files, products, queries, dino_on, prof))
423
+ if len(batches) > 1:
424
+ with ThreadPoolExecutor(max_workers=OWLV2_FRAME_WORKERS) as pool:
425
+ for results in pool.map(
426
+ lambda idxs: self._batch(idxs, files, products, queries, dino_on, prof),
427
+ batches[1:],
428
+ ):
429
+ consume(results)
430
+
431
+ # Stage 2 (CPU): batch-embed each product's crops and write similarity into its boxes.
432
+ dino_s = 0.0
433
+ if dino_on:
434
+ t_dino = time.perf_counter()
435
+ self._score_dino(dino_refs, pending)
436
+ dino_s = time.perf_counter() - t_dino
437
+
438
+ for boxes in out.values():
439
+ fill_micro_gaps(boxes)
440
+ extra = {"ref_type": self.ref_type, "dino": dino_on, "frames": len(files),
441
+ "batch_size": OWLV2_FRAME_BATCH, "frame_sampling_s": round(sample_s, 1),
442
+ "workers": OWLV2_FRAME_WORKERS}
443
+ if dino_on:
444
+ extra["dino_batched_s"] = round(dino_s, 1)
445
+ extra["dino_crops"] = len(pending)
446
+ prof.log_summary(time.perf_counter() - wall0, len(products), extra=extra)
447
+ return out
448
+
449
+ def _batch(self, idxs, files, products, queries, dino_on, prof):
450
+ """Stage 1 (network): one endpoint call over a batch of frames. Returns a list of
451
+ (frame_index, frame_out, crops); crops are deferred to the batched stage 2 (see
452
+ detect). No embedding here."""
453
+ frames = [Image.open(files[i]).convert("RGB") for i in idxs]
454
+ per_frame_dets, record = owlv2_detect_batch(
455
+ frames, queries, self.ref_type, OWLV2_SCORE_FLOOR
456
+ )
457
+ prof.add(record)
458
+ results: list[tuple[int, FrameBoxes, list[tuple[str, Box, Image.Image]]]] = []
459
+ for n, frame, dets in zip(idxs, frames, per_frame_dets):
460
+ frame_out: FrameBoxes = {}
461
+ crops: list[tuple[str, Box, Image.Image]] = []
462
+ for p, prod_dets in zip(products, dets):
463
+ if not prod_dets:
464
+ continue
465
+ best = max(prod_dets, key=lambda d: d["confidence"]) # one track per product
466
+ x, y, w, h = best["box"]
467
+ box = Box(x=x, y=y, w=w, h=h, scores={
468
+ "confidence": round(float(best["confidence"]), 3),
469
+ "objectness": round(float(best["objectness"]), 3),
470
+ })
471
+ frame_out[p.name] = [box]
472
+ if dino_on:
473
+ crops.append((p.name, box, crop_box(frame, Box(x=x, y=y, w=w, h=h))))
474
+ results.append((n, frame_out, crops))
475
+ return results
476
+
477
+ def _score_dino(self, dino_refs, pending):
478
+ """Group crops by product, embed in fat chunks (a few large ONNX passes instead of
479
+ one tiny call per frame), and write the similarity into each box in place."""
480
+ by_product: dict[str, list[tuple[Box, Image.Image]]] = {}
481
+ for name, box, crop in pending:
482
+ by_product.setdefault(name, []).append((box, crop))
483
+ for name, items in by_product.items():
484
+ ref = dino_refs[name]
485
+ crops = [c for _, c in items]
486
+ sims: list[float] = []
487
+ for i in range(0, len(crops), OWLV2_EMBED_CHUNK):
488
+ sims.extend(self.dino.score(ref, crops[i : i + OWLV2_EMBED_CHUNK]))
489
+ for (box, _), cos in zip(items, sims):
490
+ box.scores["similarity"] = round(float(cos), 3)
app/analysis/tracks.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Track post-processing shared by every mode: group per-frame boxes into contiguous
2
+ appearances (bridging micro-gaps, optionally splitting at scene cuts), compute per-appearance
3
+ averages, and assemble the per-product ProductResult.
4
+ """
5
+
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ from PIL import Image
11
+
12
+ from ..schemas import Box, ProductResult
13
+ from .config import GAP_MERGE_FRAMES, SCENE_CUT_DIST
14
+ from .video import extract_frame, sample_frames
15
+
16
+ FrameBoxes = dict[str, list[Box]]
17
+
18
+
19
+ # --- contiguous runs / appearances ------------------------------------------- #
20
+ def frame_runs(frames: list[int]) -> list[tuple[int, int]]:
21
+ """Group present frame indices into contiguous (start,end) runs, bridging gaps."""
22
+ frames = sorted(set(frames))
23
+ runs: list[tuple[int, int]] = []
24
+ for f in frames:
25
+ if runs and f - runs[-1][1] <= GAP_MERGE_FRAMES + 1:
26
+ runs[-1] = (runs[-1][0], f)
27
+ else:
28
+ runs.append((f, f))
29
+ return runs
30
+
31
+
32
+ def runs_to_appearances(
33
+ runs: list[tuple[int, int]], fps: float, duration: float
34
+ ) -> list[tuple[float, float]]:
35
+ """(start,end) seconds for each (first,last) frame-index run."""
36
+ out: list[tuple[float, float]] = []
37
+ for a, b in runs:
38
+ start = round(a / fps, 2)
39
+ end = round(min((b + 1) / fps, duration or (b + 1) / fps), 2)
40
+ out.append((start, end))
41
+ return out
42
+
43
+
44
+ # --- scene-cut splitting ----------------------------------------------------- #
45
+ def frame_hist(img: Image.Image) -> np.ndarray:
46
+ arr = np.asarray(img.convert("RGB").resize((64, 64), Image.BILINEAR), dtype=np.float32)
47
+ hist = np.concatenate(
48
+ [np.histogram(arr[..., c], bins=16, range=(0, 255))[0] for c in range(3)]
49
+ ).astype(np.float32)
50
+ return hist / (hist.sum() + 1e-8)
51
+
52
+
53
+ def split_run_on_cuts(
54
+ present: list[int], fps: float, video_path: str, threshold: float = SCENE_CUT_DIST
55
+ ) -> list[tuple[int, int]]:
56
+ """Split one run of present frame indices wherever a hard cut sits between frames."""
57
+ runs: list[tuple[int, int]] = []
58
+ seg_start = prev = present[0]
59
+ prev_h = frame_hist(extract_frame(video_path, prev / fps))
60
+ for f in present[1:]:
61
+ h = frame_hist(extract_frame(video_path, f / fps))
62
+ if float(np.abs(h - prev_h).sum()) > threshold:
63
+ runs.append((seg_start, prev))
64
+ seg_start = f
65
+ prev, prev_h = f, h
66
+ runs.append((seg_start, prev))
67
+ return runs
68
+
69
+
70
+ def split_runs_on_cuts(
71
+ boxes: FrameBoxes, fps: float, video_path: str
72
+ ) -> list[tuple[int, int]]:
73
+ """All frame-index runs, with each time-contiguous run further split at cuts."""
74
+ runs: list[tuple[int, int]] = []
75
+ for a, b in frame_runs([int(k) for k in boxes]):
76
+ present = [f for f in range(a, b + 1) if str(f) in boxes]
77
+ runs.extend(split_run_on_cuts(present, fps, video_path))
78
+ return runs
79
+
80
+
81
+ def detect_cut_frames(video_path: str, fps: float, frames: list[int]) -> list[int]:
82
+ """Frame indices where a hard scene cut *begins* — threshold-independent (a property of
83
+ the pixels), so the UI can re-split appearances at these boundaries after the similarity
84
+ slider moves. Only adjacent, bridgeable frames are compared. One ffmpeg dump (frame n →
85
+ index n, matching the detection pass) keeps it cheap."""
86
+ frames = sorted(set(frames))
87
+ if len(frames) < 2:
88
+ return []
89
+ with tempfile.TemporaryDirectory() as tmp:
90
+ files = sample_frames(video_path, fps, Path(tmp))
91
+
92
+ def hist_at(idx: int) -> np.ndarray | None:
93
+ return frame_hist(Image.open(files[idx])) if 0 <= idx < len(files) else None
94
+
95
+ cuts: list[int] = []
96
+ prev, prev_h = frames[0], hist_at(frames[0])
97
+ for f in frames[1:]:
98
+ h = hist_at(f)
99
+ if (
100
+ prev_h is not None and h is not None
101
+ and f - prev <= GAP_MERGE_FRAMES + 1
102
+ and float(np.abs(h - prev_h).sum()) > SCENE_CUT_DIST
103
+ ):
104
+ cuts.append(f)
105
+ prev, prev_h = f, h
106
+ return cuts
107
+
108
+
109
+ # --- per-appearance averages / gap fill -------------------------------------- #
110
+ def set_avg_scores(boxes: FrameBoxes, runs: list[tuple[int, int]]) -> None:
111
+ """For each run, write every box's `avg_scores[key]` = mean of that key's per-frame score
112
+ over the run — one pass, every score key. Run-scoped so scene-cut-split appearances get
113
+ independent averages; the UI recomputes the same way as sliders move."""
114
+ for a, b in runs:
115
+ frames = [f for f in range(a, b + 1) if str(f) in boxes]
116
+ keys = {k for f in frames for k in boxes[str(f)][0].scores}
117
+ for key in keys:
118
+ vals = [
119
+ boxes[str(f)][0].scores[key] for f in frames if key in boxes[str(f)][0].scores
120
+ ]
121
+ if not vals:
122
+ continue
123
+ avg = round(float(np.mean(vals)), 3)
124
+ for f in frames:
125
+ if key in boxes[str(f)][0].scores:
126
+ boxes[str(f)][0].avg_scores[key] = avg
127
+
128
+
129
+ def fill_micro_gaps(boxes: FrameBoxes, max_gap: int = 1) -> None:
130
+ """Fill single-frame (≤ max_gap) holes between two present detections by linearly
131
+ interpolating the box (and averaging each shared score), so a one-frame miss doesn't
132
+ flicker or split a run."""
133
+ present = sorted(int(k) for k in boxes)
134
+ for a, b in zip(present, present[1:]):
135
+ if not (1 <= b - a - 1 <= max_gap):
136
+ continue
137
+ ba, bb = boxes[str(a)][0], boxes[str(b)][0]
138
+ shared = ba.scores.keys() & bb.scores.keys()
139
+ for f in range(a + 1, b):
140
+ t = (f - a) / (b - a)
141
+ boxes[str(f)] = [
142
+ Box(
143
+ x=ba.x + (bb.x - ba.x) * t,
144
+ y=ba.y + (bb.y - ba.y) * t,
145
+ w=ba.w + (bb.w - ba.w) * t,
146
+ h=ba.h + (bb.h - ba.h) * t,
147
+ scores={k: round((ba.scores[k] + bb.scores[k]) / 2, 3) for k in shared},
148
+ )
149
+ ]
150
+
151
+
152
+ # --- assembly ---------------------------------------------------------------- #
153
+ def build_product_result(
154
+ name: str,
155
+ boxes: FrameBoxes,
156
+ fps: float,
157
+ duration: float,
158
+ video_path: str | None = None,
159
+ split_on_cut: bool = False,
160
+ ) -> ProductResult:
161
+ """Assemble one product's track: group frames into appearances (optionally split at scene
162
+ cuts), set per-appearance average scores, and return the ProductResult."""
163
+ if split_on_cut and boxes and video_path:
164
+ runs = split_runs_on_cuts(boxes, fps, video_path)
165
+ else:
166
+ runs = frame_runs([int(k) for k in boxes])
167
+ set_avg_scores(boxes, runs) # per-appearance averages, every score key
168
+ appearances = runs_to_appearances(runs, fps, duration)
169
+ return ProductResult(
170
+ name=name,
171
+ boxes=boxes,
172
+ appearances=appearances,
173
+ contiguous_appearances=len(appearances),
174
+ total_on_screen_sec=round(sum(e - s for s, e in appearances), 2),
175
+ )
app/analysis/video.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ffmpeg video I/O: probe duration, extract a single frame, and sample frames to disk."""
2
+
3
+ import json
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from PIL import Image
9
+
10
+ from .config import MAX_WIDTH
11
+
12
+
13
+ def probe_duration(path: str) -> float:
14
+ try:
15
+ out = subprocess.run(
16
+ ["ffprobe", "-v", "error", "-show_entries", "format=duration",
17
+ "-of", "json", path],
18
+ capture_output=True, text=True, check=True,
19
+ )
20
+ return round(float(json.loads(out.stdout)["format"]["duration"]), 3)
21
+ except Exception:
22
+ return 0.0
23
+
24
+
25
+ def sample_frames(video_path: str, fps: float, dest: Path, width: int = MAX_WIDTH) -> list[Path]:
26
+ """Dump frames at `fps` into `dest` (frame n → file index n) and return them sorted.
27
+ The single ffmpeg pass shared by every per-frame mode and by cut detection."""
28
+ subprocess.run(
29
+ ["ffmpeg", "-y", "-i", video_path, "-r", str(fps),
30
+ "-vf", f"scale={width}:-2", str(dest / "f%05d.jpg")],
31
+ check=True, capture_output=True,
32
+ )
33
+ return sorted(dest.glob("f*.jpg"))
34
+
35
+
36
+ def downsample_video(video_path: str, fps: float, dest: Path, width: int = MAX_WIDTH) -> str:
37
+ """Downsample + scale to a small mp4 (no audio) for upload to a hosted video model."""
38
+ out = str(dest / "ds.mp4")
39
+ subprocess.run(
40
+ ["ffmpeg", "-y", "-i", video_path, "-r", str(fps),
41
+ "-vf", f"scale={width}:-2", "-an", out],
42
+ check=True, capture_output=True,
43
+ )
44
+ return out
45
+
46
+
47
+ def extract_frame(video_path: str, t: float) -> Image.Image:
48
+ """Pull a single frame near time `t` (seconds) as a PIL image.
49
+
50
+ Robust to seeks that land at/just past EOF: sampled frame indices can round a hair
51
+ beyond the real duration, and ffmpeg input-seek past the end yields no frame and a
52
+ non-zero exit. So we retry a touch earlier, then fall back to the final frame."""
53
+ with tempfile.TemporaryDirectory() as tmp:
54
+ out = Path(tmp) / "f.jpg"
55
+
56
+ def grab(seek_args: list[str]) -> bool:
57
+ res = subprocess.run(
58
+ ["ffmpeg", "-y", *seek_args, "-frames:v", "1",
59
+ "-vf", "scale=720:-2", str(out)],
60
+ capture_output=True,
61
+ )
62
+ return res.returncode == 0 and out.exists() and out.stat().st_size > 0
63
+
64
+ for ss in (t, max(0.0, t - 0.2), max(0.0, t - 0.5)):
65
+ if grab(["-ss", f"{ss:.3f}", "-i", video_path]):
66
+ return Image.open(out).copy()
67
+ if grab(["-sseof", "-0.2", "-i", video_path]): # last frame, seeking from end
68
+ return Image.open(out).copy()
69
+ raise RuntimeError(f"ffmpeg could not extract a frame near t={t:.3f}s")
app/jobs.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory job store.
2
+
3
+ Single-process, single-user spike — a plain dict guarded by a lock is enough.
4
+ Swap for Redis/DB if we ever need persistence or multiple workers.
5
+ """
6
+
7
+ import threading
8
+ import uuid
9
+ from dataclasses import dataclass, field
10
+
11
+ from .schemas import AnalysisResult, JobStatus
12
+
13
+
14
+ @dataclass
15
+ class Job:
16
+ id: str
17
+ status: JobStatus = JobStatus.queued
18
+ result: AnalysisResult | None = None
19
+ error: str | None = None
20
+
21
+
22
+ class JobStore:
23
+ def __init__(self) -> None:
24
+ self._jobs: dict[str, Job] = {}
25
+ self._lock = threading.Lock()
26
+
27
+ def create(self) -> Job:
28
+ job = Job(id=uuid.uuid4().hex)
29
+ with self._lock:
30
+ self._jobs[job.id] = job
31
+ return job
32
+
33
+ def get(self, job_id: str) -> Job | None:
34
+ with self._lock:
35
+ return self._jobs.get(job_id)
36
+
37
+ def set_status(self, job_id: str, status: JobStatus) -> None:
38
+ with self._lock:
39
+ if job := self._jobs.get(job_id):
40
+ job.status = status
41
+
42
+ def set_done(self, job_id: str, result: AnalysisResult) -> None:
43
+ with self._lock:
44
+ if job := self._jobs.get(job_id):
45
+ job.status = JobStatus.done
46
+ job.result = result
47
+
48
+ def set_error(self, job_id: str, message: str) -> None:
49
+ with self._lock:
50
+ if job := self._jobs.get(job_id):
51
+ job.status = JobStatus.error
52
+ job.error = message
53
+
54
+
55
+ store = JobStore()
app/main.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Creator Vision API.
2
+
3
+ Phase 1: POST /analyze (BackgroundTasks + in-memory job store) and
4
+ GET /status/{job_id}. The analysis is currently stubbed (see analysis.py);
5
+ Phase 2 wires real SAM 3. See DesignDoc.md for the result schema.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import shutil
11
+ import uuid
12
+ from pathlib import Path
13
+
14
+ from dotenv import load_dotenv
15
+ from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+
18
+ from .analysis import (
19
+ BuildOpts,
20
+ ProductInput,
21
+ owlv2_needs_images,
22
+ requires_name,
23
+ requires_reference,
24
+ run_analysis,
25
+ )
26
+ from .jobs import store
27
+ from .schemas import DetectionMode, JobResponse, StatusResponse
28
+
29
+ # Load backend/.env (and fall back to repo-root .env).
30
+ load_dotenv()
31
+
32
+ UPLOAD_DIR = Path(__file__).resolve().parent.parent / "uploads"
33
+ UPLOAD_DIR.mkdir(exist_ok=True)
34
+
35
+ app = FastAPI(title="Creator Vision API", version="0.1.0")
36
+
37
+ # Allowed browser origins. Defaults to the local Next.js dev server; in prod set
38
+ # ALLOWED_ORIGINS to your deployed frontend URL(s), comma-separated.
39
+ _origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000")
40
+ app.add_middleware(
41
+ CORSMiddleware,
42
+ allow_origins=[o.strip() for o in _origins.split(",") if o.strip()],
43
+ allow_methods=["*"],
44
+ allow_headers=["*"],
45
+ )
46
+
47
+
48
+ @app.get("/health")
49
+ def health() -> dict:
50
+ """Liveness + config sanity check (does the SAM3 key exist?)."""
51
+ return {
52
+ "status": "ok",
53
+ "fal_key_configured": bool(os.getenv("FAL_KEY")),
54
+ }
55
+
56
+
57
+ def _save_upload(upload: UploadFile, dest_dir: Path) -> str:
58
+ """Persist an UploadFile under dest_dir with a collision-proof name."""
59
+ suffix = Path(upload.filename or "").suffix
60
+ dest = dest_dir / f"{uuid.uuid4().hex}{suffix}"
61
+ with dest.open("wb") as f:
62
+ shutil.copyfileobj(upload.file, f)
63
+ return str(dest)
64
+
65
+
66
+ @app.post("/analyze", response_model=JobResponse)
67
+ def analyze(
68
+ background_tasks: BackgroundTasks,
69
+ video: UploadFile = File(...),
70
+ # `products` is JSON: [{"name": str, "image_count": int}, ...]. `exemplars` is a
71
+ # flat file list in product order, sliced back per product by `image_count`.
72
+ products: str = Form(...),
73
+ exemplars: list[UploadFile] = File(default=[]),
74
+ caption: str = Form(default=""),
75
+ mention_keywords: str = Form(default=""),
76
+ mode: DetectionMode = Form(default=DetectionMode.sam3_text),
77
+ split_on_cut: bool = Form(default=False),
78
+ dino_variant: str = Form(default="v2"),
79
+ owl_ref_type: str = Form(default="text"), # owlv2: text | image | both
80
+ owl_dino: str = Form(default="none"), # owlv2 DINO-on-top: none | v2 | v3
81
+ ) -> JobResponse:
82
+ """Accept the uploads, kick off background analysis, return a job id."""
83
+ if not video.filename:
84
+ raise HTTPException(status_code=400, detail="A video file is required.")
85
+
86
+ try:
87
+ product_meta = json.loads(products)
88
+ assert isinstance(product_meta, list) and product_meta
89
+ except (json.JSONDecodeError, AssertionError):
90
+ raise HTTPException(status_code=400, detail="`products` must be a non-empty JSON list.")
91
+
92
+ job = store.create()
93
+ job_dir = UPLOAD_DIR / job.id
94
+ job_dir.mkdir(parents=True, exist_ok=True)
95
+
96
+ video_path = _save_upload(video, job_dir)
97
+ exemplar_files = [ex for ex in exemplars if ex.filename]
98
+
99
+ # Slice the flat exemplar list back into per-product groups by image_count.
100
+ product_inputs: list[ProductInput] = []
101
+ cursor = 0
102
+ for p in product_meta:
103
+ name = str(p.get("name", "")).strip()
104
+ count = int(p.get("image_count", 0))
105
+ group = exemplar_files[cursor : cursor + count]
106
+ cursor += count
107
+ paths = [_save_upload(ex, job_dir) for ex in group]
108
+ product_inputs.append(ProductInput(name=name, exemplar_paths=paths))
109
+
110
+ # Validation is driven by the mode registry (single source of truth).
111
+ opts = BuildOpts(dino_variant=dino_variant, owl_ref_type=owl_ref_type, owl_dino=owl_dino)
112
+ if requires_name(mode) and any(not p.name for p in product_inputs):
113
+ raise HTTPException(status_code=400, detail="Every product needs a name in this mode.")
114
+ if (requires_reference(mode) or owlv2_needs_images(mode, opts)) and any(
115
+ not p.exemplar_paths for p in product_inputs
116
+ ):
117
+ raise HTTPException(
118
+ status_code=400,
119
+ detail=f"{mode.value} mode requires a reference image for every product.",
120
+ )
121
+
122
+ variant = dino_variant if dino_variant in ("v2", "v3") else "v2"
123
+ owl_dino_v = owl_dino if owl_dino in ("none", "v2", "v3") else "none"
124
+ owl_ref = owl_ref_type if owl_ref_type in ("text", "image", "both") else "text"
125
+ background_tasks.add_task(
126
+ run_analysis, job.id, video_path, product_inputs, caption,
127
+ mention_keywords, mode, split_on_cut, variant, owl_ref, owl_dino_v,
128
+ )
129
+ return JobResponse(job_id=job.id)
130
+
131
+
132
+ @app.get("/status/{job_id}", response_model=StatusResponse)
133
+ def status(job_id: str) -> StatusResponse:
134
+ job = store.get(job_id)
135
+ if job is None:
136
+ raise HTTPException(status_code=404, detail="Unknown job_id.")
137
+ return StatusResponse(
138
+ job_id=job.id, status=job.status, result=job.result, error=job.error
139
+ )
app/mentions.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 4 — mention counting (independent of the visual detection mode).
2
+
3
+ - caption / audio: case-insensitive count of the brand name in text.
4
+ - transcript: faster-whisper (local, CPU int8 — no PyTorch).
5
+ - on-screen text: RapidOCR (ONNX) over sampled frames.
6
+
7
+ Models load lazily as singletons; failures degrade to None rather than failing
8
+ the whole job.
9
+ """
10
+
11
+ import re
12
+ import subprocess
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ OCR_FPS = 1 # frames/sec to OCR
17
+ OCR_WIDTH = 720 # OCR likes a bit more resolution than the tracking pass
18
+ WHISPER_MODEL = "base"
19
+
20
+ _whisper = None
21
+ _ocr = None
22
+
23
+
24
+ def _get_whisper():
25
+ global _whisper
26
+ if _whisper is None:
27
+ from faster_whisper import WhisperModel
28
+
29
+ _whisper = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
30
+ return _whisper
31
+
32
+
33
+ def _get_ocr():
34
+ global _ocr
35
+ if _ocr is None:
36
+ from rapidocr_onnxruntime import RapidOCR
37
+
38
+ _ocr = RapidOCR()
39
+ return _ocr
40
+
41
+
42
+ def parse_keywords(raw: str) -> list[str]:
43
+ """Split a comma-separated keyword string into a clean list."""
44
+ return [k.strip() for k in raw.split(",") if k.strip()]
45
+
46
+
47
+ def count_mentions(text: str, keyword: str) -> int:
48
+ """Case-insensitive, non-overlapping count of `keyword` in `text`."""
49
+ if not text or not keyword.strip():
50
+ return 0
51
+ return len(re.findall(re.escape(keyword.strip()), text, flags=re.IGNORECASE))
52
+
53
+
54
+ def count_keywords(text: str, keywords: list[str]) -> int:
55
+ """Total mentions of any keyword in `text`."""
56
+ return sum(count_mentions(text, k) for k in keywords)
57
+
58
+
59
+ def count_keywords_breakdown(text: str, keywords: list[str]) -> dict[str, int]:
60
+ """Per-keyword mention counts in `text` (sums to count_keywords)."""
61
+ return {k: count_mentions(text, k) for k in keywords}
62
+
63
+
64
+ def transcribe(video_path: str) -> str:
65
+ """Full transcript of the video's audio."""
66
+ segments, _info = _get_whisper().transcribe(video_path)
67
+ return " ".join(seg.text.strip() for seg in segments).strip()
68
+
69
+
70
+ def count_ocr_mentions(
71
+ video_path: str, keywords: list[str]
72
+ ) -> tuple[int, dict[str, int]]:
73
+ """OCR sampled frames once and report (total, per-keyword) on-screen appearances.
74
+
75
+ Contiguous sampled frames are collapsed into a single appearance, so a keyword
76
+ that stays on screen for many frames counts once (a new appearance starts only on
77
+ a rising edge — present now, absent in the previous frame). `total` counts
78
+ appearances where *any* keyword is on screen; the per-keyword map counts each
79
+ keyword's appearances independently (a frame can match several keywords, so the
80
+ map need not sum to `total`)."""
81
+ if not keywords:
82
+ return 0, {}
83
+ ocr = _get_ocr()
84
+ needles = {k: k.lower() for k in keywords}
85
+ per_keyword = {k: 0 for k in keywords}
86
+ prev_present = {k: False for k in keywords}
87
+ hits = 0
88
+ prev_any = False
89
+ with tempfile.TemporaryDirectory() as tmp:
90
+ tmpd = Path(tmp)
91
+ subprocess.run(
92
+ ["ffmpeg", "-y", "-i", video_path, "-r", str(OCR_FPS),
93
+ "-vf", f"scale={OCR_WIDTH}:-2", str(tmpd / "f%05d.jpg")],
94
+ check=True, capture_output=True,
95
+ )
96
+ for frame in sorted(tmpd.glob("f*.jpg")):
97
+ result, _ = ocr(str(frame))
98
+ text = " ".join((line[1] or "") for line in (result or [])).lower()
99
+ matched_any = False
100
+ for k, needle in needles.items():
101
+ present = needle in text
102
+ if present and not prev_present[k]: # rising edge → new appearance
103
+ per_keyword[k] += 1
104
+ prev_present[k] = present
105
+ matched_any = matched_any or present
106
+ if matched_any and not prev_any:
107
+ hits += 1
108
+ prev_any = matched_any
109
+ return hits, per_keyword
app/schemas.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models — the FE/BE contract. Mirrors the result schema in DesignDoc.md."""
2
+
3
+ from enum import Enum
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class JobStatus(str, Enum):
9
+ queued = "queued"
10
+ running = "running"
11
+ done = "done"
12
+ error = "error"
13
+
14
+
15
+ class DetectionMode(str, Enum):
16
+ sam3_text = "sam3_text" # SAM 3 promptable by the product name (text)
17
+ sam3_clip = "sam3_clip" # SAM 2 segment-everything + CLIP match vs reference image
18
+ sam3_text_dino = "sam3_text_dino" # SAM 3 text + DINOv2 per-appearance verification
19
+ sam3_text_openai = "sam3_text_openai" # SAM 3 text + OpenAI VLM per-appearance check
20
+ sam3_1_text_dino = "sam3_1_text_dino" # SAM 3.1 text + DINOv2 (faster/better endpoint)
21
+ sam2_dino = "sam2_dino" # SAM 2 segment-everything + DINOv2 match vs reference image
22
+ gdino_text_dino = "gdino_text_dino" # Grounding DINO text seg (EVF-SAM) + DINOv2 score
23
+ owlv2 = "owlv2" # OWLv2 detection (text/image/both refs) + confidence/objectness (+DINO)
24
+ none = "none" # DEBUG: no analysis, returns a dummy result (easy to comment out)
25
+
26
+
27
+ class Mask(BaseModel):
28
+ """A binary segmentation mask over a `w`×`h` grid (the full frame, downscaled),
29
+ run-length encoded row-major as alternating runs starting with background (0)."""
30
+
31
+ w: int
32
+ h: int
33
+ counts: list[int]
34
+
35
+
36
+ class Box(BaseModel):
37
+ """A detection box, normalized to [0, 1] relative to frame size.
38
+
39
+ Carries one or more *named* scores (e.g. {"similarity": .8} for the DINO modes, or
40
+ {"confidence": .7, "objectness": .5, "similarity": .8} for OWLv2). `scores` are per-frame;
41
+ `avg_scores` are the per-appearance means (filled by build_product_result / the UI). Each
42
+ score key gets its own display + threshold + slider (see AnalysisResult.score_specs)."""
43
+
44
+ x: float
45
+ y: float
46
+ w: float
47
+ h: float
48
+ scores: dict[str, float] = Field(default_factory=dict)
49
+ avg_scores: dict[str, float] = Field(default_factory=dict)
50
+ mask: Mask | None = None # target-object segmentation mask (segment/grounded modes)
51
+
52
+
53
+ class ScoreSpec(BaseModel):
54
+ """Declares one score dimension a mode emits, and its post-hoc slider default."""
55
+
56
+ key: str # matches Box.scores keys, e.g. "similarity" | "confidence" | "objectness"
57
+ label: str # UI label, e.g. "Similarity"
58
+ default: float # slider start (and the threshold the backend's candidate view uses)
59
+
60
+
61
+ class ProductResult(BaseModel):
62
+ """Per-product visual track (Phase 2b — N products per analysis)."""
63
+
64
+ name: str
65
+ boxes: dict[str, list[Box]] = Field(default_factory=dict) # keyed by frame index
66
+ appearances: list[tuple[float, float]] = Field(default_factory=list)
67
+ contiguous_appearances: int = 0
68
+ total_on_screen_sec: float = 0.0
69
+
70
+
71
+ class AnalysisResult(BaseModel):
72
+ # video metadata
73
+ fps: float = 0.0
74
+ duration_sec: float = 0.0
75
+ # visual tracking — one entry per product (Phase 2b)
76
+ products: list[ProductResult] = Field(default_factory=list)
77
+ # mentions (Phase 4)
78
+ transcript: str | None = None
79
+ audio_mentions: int | None = None
80
+ caption_mentions: int | None = None
81
+ ocr_mentions: int | None = None # contiguous on-screen-text appearances of the brand
82
+ # per-keyword breakdowns (keyword -> count), parallel to the totals above
83
+ audio_mention_counts: dict[str, int] | None = None
84
+ caption_mention_counts: dict[str, int] | None = None
85
+ ocr_mention_counts: dict[str, int] | None = None
86
+ # echo of the inputs we matched against
87
+ brand_name: str | None = None
88
+ mode: DetectionMode | None = None
89
+ # one entry per post-hoc slider (empty = no sliders for this mode). Boxes carry per-frame
90
+ # `scores`, so the UI re-filters/recounts live across all of them.
91
+ score_specs: list[ScoreSpec] = Field(default_factory=list)
92
+ # frame indices where a scene cut begins (only when split-on-cut is on for a slider
93
+ # mode); lets the UI re-split appearances at cuts after re-thresholding.
94
+ cut_frames: list[int] = Field(default_factory=list)
95
+
96
+
97
+ class JobResponse(BaseModel):
98
+ """Returned by POST /analyze."""
99
+
100
+ job_id: str
101
+
102
+
103
+ class StatusResponse(BaseModel):
104
+ """Returned by GET /status/{job_id}."""
105
+
106
+ job_id: str
107
+ status: JobStatus
108
+ result: AnalysisResult | None = None
109
+ error: str | None = None
app/similarity/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Similarity backends — interchangeable scorers reused across detection modes.
2
+
3
+ `Embedder` (DinoEmbedder, ClipEmbedder) encodes reference image(s) and scores crops by
4
+ cosine similarity; `OpenAiVerifier` is a frame-level yes/no verifier. Detection strategies
5
+ inject whichever they need.
6
+ """
7
+
8
+ from typing import Protocol
9
+
10
+ import numpy as np
11
+ from PIL import Image
12
+
13
+ from .clip import ClipEmbedder
14
+ from .dino import DEFAULT_DINO_VARIANT, DINO_THRESHOLD, DinoEmbedder
15
+ from .vlm import OpenAiVerifier
16
+
17
+
18
+ class Embedder(Protocol):
19
+ """Encodes references and scores crops against them (best similarity per crop)."""
20
+
21
+ def reference(self, paths: list[str]) -> np.ndarray:
22
+ """Encode reference image path(s) into rows (R, D) to match candidates against."""
23
+
24
+ def score(self, ref: np.ndarray, crops: list[Image.Image]) -> list[float]:
25
+ """Best similarity of each crop vs the reference rows."""
26
+
27
+
28
+ __all__ = [
29
+ "Embedder",
30
+ "DinoEmbedder",
31
+ "ClipEmbedder",
32
+ "OpenAiVerifier",
33
+ "DINO_THRESHOLD",
34
+ "DEFAULT_DINO_VARIANT",
35
+ ]
app/similarity/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (1.77 kB). View file
 
app/similarity/__pycache__/clip.cpython-313.pyc ADDED
Binary file (3.09 kB). View file
 
app/similarity/__pycache__/dino.cpython-313.pyc ADDED
Binary file (7.53 kB). View file
 
app/similarity/__pycache__/vlm.cpython-313.pyc ADDED
Binary file (2.85 kB). View file
 
app/similarity/clip.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLIP image embedder (fastembed ONNX) — the original sam3_clip similarity backend.
2
+
3
+ Exposes the same `Embedder` interface as DinoEmbedder, but `reference()` returns a single
4
+ averaged centroid (1, D) — CLIP image-image cosines are weakly calibrated, so the historical
5
+ behavior matched the mean reference with a high absolute threshold (see config.CLIP_*).
6
+ """
7
+
8
+ import numpy as np
9
+ from PIL import Image
10
+
11
+ CLIP_MODEL = "Qdrant/clip-ViT-B-32-vision"
12
+ _model = None # lazy singleton (first use downloads ~350MB to the HF cache)
13
+
14
+
15
+ def _embedder():
16
+ global _model
17
+ if _model is None:
18
+ from fastembed import ImageEmbedding
19
+
20
+ _model = ImageEmbedding(CLIP_MODEL)
21
+ return _model
22
+
23
+
24
+ def _embed(images: list) -> np.ndarray:
25
+ """(N, D) L2-normalized rows. fastembed accepts PIL images or paths directly."""
26
+ if not images:
27
+ return np.zeros((0, 1), dtype=np.float32)
28
+ embs = np.stack([np.asarray(e, dtype=np.float32) for e in _embedder().embed(images)])
29
+ return embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-8)
30
+
31
+
32
+ class ClipEmbedder:
33
+ """An `Embedder` backed by CLIP, matching against an averaged reference centroid."""
34
+
35
+ def reference(self, paths: list[str]) -> np.ndarray:
36
+ ref = _embed(paths).mean(axis=0)
37
+ return (ref / (np.linalg.norm(ref) + 1e-8))[None, :] # (1, D)
38
+
39
+ def score(self, ref: np.ndarray, crops: list[Image.Image]) -> list[float]:
40
+ if not crops:
41
+ return []
42
+ sims = (_embed(crops) @ ref.T).max(axis=1)
43
+ return [float(s) for s in sims]
app/similarity/dino.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DINOv2 / DINOv3 image embedder (ONNX, local, no PyTorch).
2
+
3
+ Two interchangeable backbones, picked per analysis with `variant`:
4
+ "v2" — Xenova/dinov2-small (DINOv2 ViT-S/14, Apache-2.0, ungated).
5
+ "v3" — onnx-community/dinov3-vits16-pretrain-lvd1689m-ONNX (DINOv3 ViT-S/16,
6
+ dinov3-license; this re-export downloads ungated, though the upstream Facebook
7
+ weights are gated). Weights are external data (model.onnx_data) fetched beside the
8
+ graph; the graph adds 4 register tokens after CLS, which the CLS-at-index-0 read
9
+ ignores.
10
+ Both share preprocessing (224px, ImageNet norm) and CLS-token extraction; same 384-d
11
+ embedding, so v2/v3 scores are directly comparable.
12
+ """
13
+
14
+ import numpy as np
15
+ from PIL import Image
16
+
17
+ DINO_REPOS = {
18
+ "v2": "Xenova/dinov2-small",
19
+ "v3": "onnx-community/dinov3-vits16-pretrain-lvd1689m-ONNX",
20
+ }
21
+ DEFAULT_DINO_VARIANT = "v2"
22
+ DINO_THRESHOLD = 0.0 # cosine gate for the SAM-text DINO refiner (same-object ~0.8)
23
+ # True: score a candidate against the BEST matching reference (max over views) — distinct
24
+ # reference angles/lighting stay distinct. False: collapse references to one averaged
25
+ # centroid. (Paired with analysis.config.DINO_MASK_BG, which controls the crop input.)
26
+ DINO_MULTI_REF = False
27
+
28
+ _MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
29
+ _STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
30
+ _sessions: dict = {} # variant -> onnxruntime.InferenceSession (lazy)
31
+
32
+
33
+ def _session(variant: str = DEFAULT_DINO_VARIANT):
34
+ if variant not in _sessions:
35
+ import onnxruntime as ort
36
+ from huggingface_hub import hf_hub_download
37
+
38
+ repo = DINO_REPOS.get(variant, DINO_REPOS[DEFAULT_DINO_VARIANT])
39
+ path = hf_hub_download(repo, "onnx/model.onnx")
40
+ # Some exports (DINOv3) store weights as external data beside the graph; pull the
41
+ # companion into the same snapshot dir so onnxruntime can resolve the relative path.
42
+ try:
43
+ hf_hub_download(repo, "onnx/model.onnx_data")
44
+ except Exception: # self-contained graph (e.g. DINOv2) — nothing to fetch
45
+ pass
46
+ _sessions[variant] = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
47
+ return _sessions[variant]
48
+
49
+
50
+ def _preprocess(img: Image.Image) -> np.ndarray:
51
+ img = img.convert("RGB").resize((224, 224), Image.BICUBIC)
52
+ arr = (np.asarray(img, dtype=np.float32) / 255.0 - _MEAN) / _STD
53
+ return arr.transpose(2, 0, 1) # CHW
54
+
55
+
56
+ def _cls_tokens(sess, outputs: list[np.ndarray]) -> np.ndarray:
57
+ """(N, D) CLS embeddings, robust to output ordering/naming across the v2 and v3
58
+ exports. DINOv3 prepends register tokens after CLS, but CLS stays at index 0."""
59
+ names = [o.name for o in sess.get_outputs()]
60
+ for arr, name in zip(outputs, names):
61
+ if "last_hidden_state" in name and arr.ndim == 3:
62
+ return arr[:, 0]
63
+ for arr in outputs:
64
+ if arr.ndim == 3: # (N, tokens, D) -> CLS at 0
65
+ return arr[:, 0]
66
+ if arr.ndim == 2: # already pooled (N, D)
67
+ return arr
68
+ return outputs[0][:, 0]
69
+
70
+
71
+ def _embed_batch(imgs: list[Image.Image], variant: str) -> np.ndarray:
72
+ """Embed many crops in a single ONNX run; returns (N, D) L2-normalized rows."""
73
+ if not imgs:
74
+ return np.zeros((0, 1), dtype=np.float32)
75
+ batch = np.stack([_preprocess(im) for im in imgs])
76
+ sess = _session(variant)
77
+ out = sess.run(None, {sess.get_inputs()[0].name: batch})
78
+ v = _cls_tokens(sess, out)
79
+ return v / (np.linalg.norm(v, axis=1, keepdims=True) + 1e-8)
80
+
81
+
82
+ def _reference_set(paths: list[str], variant: str) -> np.ndarray:
83
+ """Per-reference normalized embeddings (R, D) to match a candidate against with the MAX
84
+ cosine over rows. When DINO_MULTI_REF is False, collapse to one averaged-centroid row
85
+ (1, D) so the max degenerates to the old single-cosine behavior."""
86
+ embs = _embed_batch([Image.open(p) for p in paths], variant)
87
+ if not DINO_MULTI_REF and embs.shape[0] > 1:
88
+ ref = embs.mean(axis=0)
89
+ return (ref / (np.linalg.norm(ref) + 1e-8))[None, :]
90
+ return embs
91
+
92
+
93
+ def _max_sim(ref: np.ndarray, embs: np.ndarray) -> np.ndarray:
94
+ """(N,) best cosine of each row of `embs` (N, D) against any reference row `ref` (R, D)."""
95
+ return (embs @ ref.T).max(axis=1)
96
+
97
+
98
+ class DinoEmbedder:
99
+ """An `Embedder`: encodes references and scores crops by best cosine over reference views."""
100
+
101
+ def __init__(self, variant: str = DEFAULT_DINO_VARIANT):
102
+ self.variant = variant if variant in DINO_REPOS else DEFAULT_DINO_VARIANT
103
+
104
+ def reference(self, paths: list[str]) -> np.ndarray:
105
+ return _reference_set(paths, self.variant)
106
+
107
+ def score(self, ref: np.ndarray, crops: list[Image.Image]) -> list[float]:
108
+ if not crops:
109
+ return []
110
+ return [float(s) for s in _max_sim(ref, _embed_batch(crops, self.variant))]
app/similarity/vlm.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenAI vision verifier — asks a VLM "is <product> in this frame?" (frame-level yes/no).
2
+
3
+ Unlike the embedders, this is a `FrameVerifier`: it judges a whole frame, not a crop, so it
4
+ returns a bool rather than a similarity. Used by the sam3_text_openai mode.
5
+ """
6
+
7
+ import base64
8
+ import io
9
+ import os
10
+
11
+ from PIL import Image
12
+
13
+ OPENAI_MODEL = "gpt-4o-mini"
14
+ _client = None
15
+
16
+
17
+ def _openai_client():
18
+ global _client
19
+ if _client is None:
20
+ from openai import OpenAI
21
+
22
+ if not os.getenv("OPENAI_API_KEY"):
23
+ raise RuntimeError("OPENAI_API_KEY is not set (see GettingStarted.md).")
24
+ _client = OpenAI()
25
+ return _client
26
+
27
+
28
+ class OpenAiVerifier:
29
+ """Frame-level verifier for a single product/brand."""
30
+
31
+ def __init__(self, brand: str):
32
+ self.brand = brand
33
+
34
+ def verify(self, frame: Image.Image, box=None) -> bool: # noqa: ARG002 — frame-level
35
+ buf = io.BytesIO()
36
+ frame.convert("RGB").save(buf, format="JPEG", quality=85)
37
+ b64 = base64.b64encode(buf.getvalue()).decode()
38
+ resp = _openai_client().chat.completions.create(
39
+ model=OPENAI_MODEL,
40
+ max_tokens=3,
41
+ messages=[
42
+ {
43
+ "role": "user",
44
+ "content": [
45
+ {"type": "text",
46
+ "text": f"Is {self.brand} visible in this image? Answer only 'yes' or 'no'."},
47
+ {"type": "image_url",
48
+ "image_url": {"url": f"data:image/jpeg;base64,{b64}"}},
49
+ ],
50
+ }
51
+ ],
52
+ )
53
+ return "yes" in (resp.choices[0].message.content or "").strip().lower()
pyproject.toml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "creator-vision-backend"
3
+ version = "0.1.0"
4
+ description = "Creator Vision API — brand-presence analysis for video"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "fastapi>=0.115",
8
+ "uvicorn[standard]>=0.32",
9
+ "python-multipart>=0.0.12",
10
+ "python-dotenv>=1.0",
11
+ "fal-client>=0.5",
12
+ "pillow>=11.0",
13
+ "fastembed>=0.4",
14
+ "numpy>=1.26",
15
+ "faster-whisper>=1.0",
16
+ "rapidocr-onnxruntime>=1.3",
17
+ "openai>=1.50",
18
+ ]
19
+
20
+ [tool.uv]
21
+ package = false
uv.lock ADDED
The diff for this file is too large to render. See raw diff