Spaces:
Sleeping
Sleeping
David Baba commited on
Commit ·
0437edf
1
Parent(s): 2164bac
backend
Browse files- Dockerfile +25 -0
- README.md +3 -2
- app/__init__.py +0 -0
- app/__pycache__/__init__.cpython-313.pyc +0 -0
- app/__pycache__/jobs.cpython-313.pyc +0 -0
- app/__pycache__/main.cpython-313.pyc +0 -0
- app/__pycache__/mentions.cpython-313.pyc +0 -0
- app/__pycache__/schemas.cpython-313.pyc +0 -0
- app/analysis/__init__.py +20 -0
- app/analysis/__pycache__/__init__.cpython-313.pyc +0 -0
- app/analysis/__pycache__/config.cpython-313.pyc +0 -0
- app/analysis/__pycache__/masks.cpython-313.pyc +0 -0
- app/analysis/__pycache__/owlv2_client.cpython-313.pyc +0 -0
- app/analysis/__pycache__/registry.cpython-313.pyc +0 -0
- app/analysis/__pycache__/runner.cpython-313.pyc +0 -0
- app/analysis/__pycache__/strategies.cpython-313.pyc +0 -0
- app/analysis/__pycache__/tracks.cpython-313.pyc +0 -0
- app/analysis/__pycache__/video.cpython-313.pyc +0 -0
- app/analysis/config.py +79 -0
- app/analysis/masks.py +76 -0
- app/analysis/owlv2_client.py +156 -0
- app/analysis/registry.py +125 -0
- app/analysis/runner.py +157 -0
- app/analysis/strategies.py +490 -0
- app/analysis/tracks.py +175 -0
- app/analysis/video.py +69 -0
- app/jobs.py +55 -0
- app/main.py +139 -0
- app/mentions.py +109 -0
- app/schemas.py +109 -0
- app/similarity/__init__.py +35 -0
- app/similarity/__pycache__/__init__.cpython-313.pyc +0 -0
- app/similarity/__pycache__/clip.cpython-313.pyc +0 -0
- app/similarity/__pycache__/dino.cpython-313.pyc +0 -0
- app/similarity/__pycache__/vlm.cpython-313.pyc +0 -0
- app/similarity/clip.py +43 -0
- app/similarity/dino.py +110 -0
- app/similarity/vlm.py +53 -0
- pyproject.toml +21 -0
- 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
|
|
|