DEV Community

Ville Vesilehto
Ville Vesilehto

Posted on • Originally published at ville.dev

Brand Tagging with VLMs

TL;DR

Build a two-stage logo pipeline:

  1. Retrieval - generate image embeddings for small crops and match against a logo dictionary with FAISS cosine search. Use SigLIP-2 (NaFlex) so logos are not distorted and small marks still pop.

  2. Verification - for top matches, ask LLaVA-OneVision-1.5 a strict JSON question ("Is this the X logo?") and accept only high-confidence "yes". It's a good model sir.

A bit longer post this time around.

Intro

Brand tagging in real-world video is hard: logos are tiny, partly occluded, moving, and often appear on textured backgrounds. A practical approach is a two-stage pipeline:

  • First retrieve likely logo crops with a fast contrastive image encoder.
  • Then verify each candidate with a vision-language model (VLM) that can read text and reason about shapes and context. Each candidate is a (frame, bbox, brand, retrieval_score) record that we pass to the VLM.

This tutorial combines a modern image-text encoder (SigLIP-2, NaFlex variant) for high-recall retrieval with a VLM from the LLaVA family for precise, structured yes/no verification.

You could train a YOLO-style detector for specific brands. This post, however, focuses on a more flexible "embedding + VLM" approach that adapts quickly to new logos without retraining. Some might find this fun.

Research

Contrastive encoders such as CLIP/SigLIP produce embeddings where similar visuals are close. FAISS makes nearest-neighbor search over many references instantaneous. You can treat logo search as nearest-neighbor lookup instead of training a custom detector.

Verification reduces false positives. A VLM can explicitly answer "Is this the Red Bull logo?" and justify the decision, improving precision on lookalikes, partial views, and blur.

Pointers to the underlying research for the initiated:

Setup

You’ll need to be comfortable with basic Python and PyTorch. No prior experience with FAISS or VLMs required.

Hardware/software requirements:

  • At least one NVIDIA GPU (at least 32 GB VRAM recommended)
  • CUDA 12.x, Python 3.10–3.12
  • Disk: 5–10 GB (models + caches)
  • Tools: ffmpeg for frames

This tutorial was built and tested on a single NVIDIA H200.

Prepare the Python environment as follows:

python3 -m venv .venv && source .venv/bin/activate
pip install -U torch torchvision torchaudio \
  "transformers>=4.45" accelerate pillow opencv-python faiss-cpu \
  numpy pydantic polars
Enter fullscreen mode Exit fullscreen mode

For simplicity we'll use ƒaiss-cpu as it's fine at this scale. It's a single logo after all. GPU for the models.

Example video (Creative Commons):

  • Title: Red Bull Racing Pit Stop Practice (2015), 44 s, 1920x1080.
  • License: CC BY-SA 4.0 - attribute ProtoplasmaKid / Wikimedia Commons.
  • It has tiny, moving logos, occlusion, uniforms, car bodywork, pit rig and acts as a great stress-test.

Architecture

frames (2–4 FPS) ──▶ crops (multi-scale grid)
         │
         ▼
 [Stage 1] Retrieval (SigLIP-2 image features + FAISS cosine)
         │      └─ top-K per frame/brand with heuristics
         ▼
 [Stage 2] VLM verification (LLaVA-OneVision-1.5 JSON verdict)
         │
         ▼
    JSONL evidence
Enter fullscreen mode Exit fullscreen mode

Models used in this post:

SigLIP-2 reports better zero-shot and retrieval performance than prior SigLIP/CLIP models on public benchmarks. NaFlex means the encoder resizes each crop to a grid of flexible patches instead of forcing a fixed square, so long thin logos don't get squashed.

OneVision-1.5 is an open VLM family. Its card reports strong benchmark leads vs other open models.

Both are Apache-2.0 licensed.

So let's do it!

Quickstart

1) Grab the video:

wget https://upload.wikimedia.org/wikipedia/commons/b/ba/Red_Bull_Racing_Pit_Stop_Practice.webm
Enter fullscreen mode Exit fullscreen mode

Then extract frames. Two FPS, scaled down to 1280x720:

mkdir -p frames
ffmpeg -i "Red_Bull_Racing_Pit_Stop_Practice.webm" -vf "fps=2,scale=1280:-1:flags=lanczos" -q:v 3 frames/f_%06d.jpg
Enter fullscreen mode Exit fullscreen mode

This yields 88 JPG files.

2) Prepare a logo dictionary

In this example we're only interested in the Red Bull logo. Create a logos directory you want to use. Grab the logo:

mkdir -p logos
wget -P logos https://upload.wikimedia.org/wikipedia/fi/a/a5/Red_Bull_logo.png
Enter fullscreen mode Exit fullscreen mode

Disclaimer: This tutorial is for educational purposes only and is not affiliated with or endorsed by Red Bull. Red Bull is a registered trademark of Red Bull GmbH. It's a decent energy drink though.

3) Build the logo index (FAISS, SigLIP-2)

Save to build_logo_index.py:

import json, faiss, torch
from pathlib import Path
from PIL import Image
import numpy as np
from transformers import AutoModel, AutoProcessor

MODEL_ID = "google/siglip2-base-patch16-naflex"  # NaFlex = native aspect ratio
OUT_DIR = Path("artifacts"); OUT_DIR.mkdir(exist_ok=True, parents=True)

def embed_images(paths, model, proc, batch=16):
    imgs = [Image.open(p).convert("RGB") for p in paths]
    feats = []
    for i in range(0, len(imgs), batch):
        chunk = imgs[i:i+batch]
        inputs = proc(images=chunk, return_tensors="pt")
        inputs = {k: v.to(model.device) for k, v in inputs.items()}
        with torch.no_grad():
            f = model.get_image_features(**inputs)  # (B, d)
            f = torch.nn.functional.normalize(f, dim=-1)
        feats.append(f.cpu())
    return torch.cat(feats, dim=0).numpy()

def main():
    model = AutoModel.from_pretrained(MODEL_ID, dtype=torch.float32,
                                      device_map="auto")
    proc  = AutoProcessor.from_pretrained(MODEL_ID)

    logo_paths = sorted(list(Path("logos").glob("*.*")))
    if not logo_paths:
        raise SystemExit("No logo files found in 'logos' directory.")
    brands = [p.stem for p in logo_paths]
    vecs = embed_images(logo_paths, model, proc)
    d = vecs.shape[1]
    index = faiss.IndexFlatIP(d); index.add(vecs)

    faiss.write_index(index, str(OUT_DIR / "logos.faiss"))
    (OUT_DIR / "logos_meta.json").write_text(
        json.dumps({"brands": brands, "files": [str(p) for p in logo_paths]},
                   indent=2)
    )
    print(f"Indexed {len(brands)} brands into {OUT_DIR/'logos.faiss'}")

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

SigLIP-2 provides get_image_features and NaFlex dynamic resizing to minimize distortion on non-square inputs — useful for narrow/wide logos.

Run the tool:

$ python build_logo_index.py
...
Indexed 1 brands into artifacts/logos.faiss
Enter fullscreen mode Exit fullscreen mode


4) Generate crops + Stage-1 retrieval

Save the following to retrieve.py:

import json, math, os, faiss, torch
from pathlib import Path
from PIL import Image, ImageDraw
import numpy as np
from transformers import AutoModel, AutoProcessor

EMB_ID = "google/siglip2-base-patch16-naflex"
ART = Path("artifacts"); ART.mkdir(exist_ok=True)

SIZES = [192, 256, 320]     # square windows
STRIDE = 0.5                # 50% overlap
TOPK = int(os.environ.get("TOPK", "3"))
COSINE_TH = float(os.environ.get("COSINE_TH", "0.7"))  # keep candidates above this

def _metric_type(index):
    try:
        return index.metric_type
    except Exception:
        return None

def _l2_to_cosine(d: np.ndarray) -> np.ndarray:
    # If vectors are L2-normalized, cos = 1 - 0.5 * ||a - b||^2
    return 1.0 - 0.5 * d

def _as_float32(x: np.ndarray) -> np.ndarray:
    if x.dtype != np.float32:
        x = x.astype(np.float32, copy=False)
    return np.ascontiguousarray(x)

def grid_crops(im: Image.Image):
    W, H = im.size
    for s in SIZES:
        step = max(1, int(s * STRIDE))
        for y in range(0, max(1, H - s + 1), step):
            for x in range(0, max(1, W - s + 1), step):
                yield (x, y, s, s)

def embed(model, proc, pil_list, bs=24):
    out = []
    for i in range(0, len(pil_list), bs):
        chunk = pil_list[i:i+bs]
        inp = proc(images=chunk, return_tensors="pt")
        inp = {k: v.to(model.device) for k, v in inp.items()}
        with torch.no_grad():
            f = model.get_image_features(**inp)
            f = torch.nn.functional.normalize(f, dim=-1)
        out.append(f.detach().to(torch.float32).cpu())
    vecs = torch.cat(out, dim=0).numpy()
    return _as_float32(vecs)

def main():
    # load logo index
    index = faiss.read_index(str(ART / "logos.faiss"))
    meta = json.loads((ART / "logos_meta.json").read_text())
    brands = meta["brands"]
    try:
        print(f"[retrieve] index ntotal={index.ntotal}, brands={len(brands)}")
    except Exception:
        pass

    # load embedder
    model = AutoModel.from_pretrained(EMB_ID, dtype=torch.float32, device_map="auto")
    proc  = AutoProcessor.from_pretrained(EMB_ID)

    frames = sorted(Path("frames").glob("f_*.jpg"))
    print(f"[retrieve] frames found={len(frames)}")
    out = []
    dump_all = bool(int(os.environ.get("DEBUG_DUMP_ALL", "0")))
    dbg_all = [] if dump_all else None
    debug_draw = dump_all and bool(int(os.environ.get("DEBUG_DRAW", "0")))
    debug_draw_dir = ART / "debug_vis"
    if debug_draw:
        debug_draw_dir.mkdir(parents=True, exist_ok=True)
    debug_draw_th = float(os.environ.get("DEBUG_DRAW_TH", "-1.0"))
    debug_draw_max = int(os.environ.get("DEBUG_DRAW_MAX", "0"))  # 0 = unlimited
    best = {"score": -1.0, "frame": None, "brand": None, "bbox": None}

    for fpath in frames:
        im = Image.open(fpath).convert("RGB")
        boxes, crops = [], []
        for (x, y, w, h) in grid_crops(im):
            boxes.append((x, y, w, h))
            crops.append(im.crop((x, y, x+w, y+h)))

        if not crops: continue
        vecs = embed(model, proc, crops)
        D, I = index.search(_as_float32(vecs), TOPK)

        mt = _metric_type(index)
        if mt == getattr(faiss, "METRIC_L2", 1):
            scores_mat = _l2_to_cosine(D)
        else:
            scores_mat = D

        # collect frame-local debug matches as well
        frame_dbg = [] if dump_all else None
        for i, (scores, ids) in enumerate(zip(scores_mat, I)):
            if dump_all:
                for r, (score, idx) in enumerate(zip(scores.tolist(), ids.tolist())):
                    rec = {
                        "frame": fpath.name,
                        "bbox": boxes[i],
                        "rank": int(r),
                        "score": float(score),
                        "brand": brands[idx]
                    }
                    dbg_all.append(rec)
                    if frame_dbg is not None:
                        frame_dbg.append(rec)
            for score, idx in zip(scores, ids):
                if score > best["score"]:
                    best.update({
                        "score": float(score),
                        "frame": fpath.name,
                        "brand": brands[idx],
                        "bbox": boxes[i]
                    })
                if score < COSINE_TH:
                    continue
                bx = boxes[i]
                out.append({
                    "frame": fpath.name,
                    "bbox": bx,
                    "score_retr": float(score),
                    "brand": brands[idx]
                })

        # draw annotations for this frame if requested
        if debug_draw and frame_dbg:
            # sort by score desc
            frame_dbg.sort(key=lambda r: r["score"], reverse=True)
            if debug_draw_max > 0:
                frame_dbg = frame_dbg[:debug_draw_max]
            canvas = im.copy()
            draw = ImageDraw.Draw(canvas)
            for rec in frame_dbg:
                if rec["score"] < debug_draw_th:
                    continue
                x, y, w, h = rec["bbox"]
                x2, y2 = x + w, y + h
                color = (255, 0, 0)
                draw.rectangle([x, y, x2, y2], outline=color, width=2)
                label = f"{rec['brand']} {rec['score']:.3f}#{rec['rank']}"
                # simple text; if background needed, draw a small filled box then text
                draw.text((x + 3, y + 3), label, fill=color)
            out_path = debug_draw_dir / f"{fpath.stem}_debug.jpg"
            canvas.save(out_path, quality=90)
            print(f"[retrieve] wrote debug visualization → {out_path}")

    Path("candidates.jsonl").write_text("\n".join(json.dumps(x) for x in out))
    print(f"wrote {len(out)} retrieval candidates → candidates.jsonl")
    if not out and best["frame"] is not None:
        print(f"[retrieve] no candidates above threshold {COSINE_TH}. "
              f"Best observed: score={best['score']:.3f}, frame={best['frame']}, "
              f"brand={best['brand']}, bbox={best['bbox']} — consider lowering COSINE_TH.")
    if dump_all and dbg_all is not None:
        Path("debug_matches.jsonl").write_text("\n".join(json.dumps(x) for x in dbg_all))
        print(f"[retrieve] wrote {len(dbg_all)} raw matches → debug_matches.jsonl (DEBUG_DUMP_ALL=1)")

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

This is quite a few things so breaking it down:

  • Crops: multi-scale grid over each frame using SIZES and STRIDE, yielding square patches and their bbox tuples.
  • Embeddings: SigLIP‑2 get_image_features on each crop batch, then L2‑normalize features.
  • Search: FAISS over the logo index; uses inner‑product on normalized vectors (cosine). If the index is L2, we convert to cosine (1 − 0.5·L2²).
  • Types: FAISS expects contiguous float32; embeddings are cast/contiguous before index.search.
  • Thresholds: keep top‑TOPK per crop, then filter by COSINE_TH. You can override at runtime:
    • TOPK=10 COSINE_TH=0.65 python retrieve.py
  • Debugging:
    • DEBUG_DUMP_ALL=1 writes every raw match to debug_matches.jsonl (ranked with scores).
    • DEBUG_DRAW=1 also saves artifacts/debug_vis/*_debug.jpg with [brand score#rank] boxes.
    • Optional: DEBUG_DRAW_TH=0.3 (only draw ≥ threshold), DEBUG_DRAW_MAX=200 (cap boxes).
  • Outputs: filtered candidates land in candidates.jsonl and are fed to the verifier stage.
  • Knobs to tune recall/precision: crop sizes, stride, TOPK, COSINE_TH. For higher recall, increase sizes or TOPK; for precision, raise the threshold and later add a margin or temporal smoothing.

Output:

$ python retrieve.py
[retrieve] index ntotal=1, brands=1
[retrieve] frames found=88
wrote 25 retrieval candidates → candidates.jsonl
Enter fullscreen mode Exit fullscreen mode

Depending on the source material this probably needs tuning for recall/precision. The debugging knobs are quite nice. Here's an example frame from the video showing how it looks like on frame #7:

Example frame with SigLIP-2 retrieval overlay: Red Bull logo detected at cosine 0.70 (rank #0) on the car sidepod

Frame from Red Bull Racing Pit Stop Practice (2015), ProtoplasmaKid / Wikimedia Commons / CC BY-SA 4.0.

5) VLM verification

Save the following to verify.py:

import json, torch, os
from pathlib import Path
from PIL import Image
from transformers import AutoProcessor, AutoModelForCausalLM

VLM_ID = "lmms-lab/LLaVA-OneVision-1.5-8B-Instruct"

SYSTEM = (
  "You are a logo verification API. "
  "Given an image crop and a target brand, answer in strict JSON with no extra text."
)

def build_user_prompt(brand: str):
    return (
      "Task: Verify whether the crop contains the specified brand's logo.\n"
      f"Brand: {brand}\n\n"
      "Return JSON only:\n"
      "{\n"
      '  "verdict": "yes" | "no",\n'
      '  "confidence": 0.0-1.0,\n'
      '  "visual_cues": "short, literal cues proving the verdict (colors/shapes/text)"\n'
      "}\n"
      "Rules: output only the JSON object; no prose before or after. "
      "Be literal; do not speculate; base confidence on how clearly the logo is visible."
    )

def _normalize_quotes(s: str) -> str:
    # Replace smart quotes with ASCII equivalents
    return (
        s.replace("\u201c", '"').replace("\u201d", '"')
         .replace("\u2018", "'").replace("\u2019", "'")
    )

def _extract_json_object(text: str):
    text = _normalize_quotes(text)
    # Find first balanced {...} block; handle braces within strings
    in_str = False
    escape = False
    depth = 0
    start = None
    for i, ch in enumerate(text):
        if ch == "\\" and not escape:
            escape = True
            continue
        if ch == '"' and not escape:
            in_str = not in_str
        escape = False
        if in_str:
            continue
        if ch == "{":
            if depth == 0:
                start = i
            depth += 1
        elif ch == "}":
            if depth > 0:
                depth -= 1
                if depth == 0 and start is not None:
                    candidate = text[start:i+1]
                    try:
                        j = json.loads(candidate)
                        if isinstance(j, dict) and "verdict" in j and "confidence" in j:
                            return j
                    except Exception:
                        pass
    return None

def run_once(proc, model, crop: Image.Image, brand: str, max_new=128):
    msgs = [
        {"role":"system", "content": SYSTEM},
        {"role":"user", "content":[{"type":"image"}, {"type":"text", "text": build_user_prompt(brand)}]}
    ]
    text = proc.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
    inputs = proc(text=[text], images=[crop], padding=True, return_tensors="pt")
    inputs = {k: (v.to(model.device) if hasattr(v,"to") else v) for k,v in inputs.items()}
    with torch.inference_mode():
        ids = model.generate(**inputs, max_new_tokens=max_new, temperature=0.0, do_sample=False)
    out = proc.batch_decode(ids, skip_special_tokens=True)[0]
    j = _extract_json_object(out)
    if j is None:
        if os.environ.get("DEBUG_VLM", "0") == "1":
            Path("vlm_raw.txt").write_text(out)
        return {"verdict":"no", "confidence":0.0, "visual_cues":"parse_error"}
    # Coerce fields
    verdict = str(j.get("verdict", "no")).strip().lower()
    if verdict not in ("yes", "no"):
        verdict = "no"
    try:
        conf = float(j.get("confidence", 0.0))
    except Exception:
        conf = 0.0
    cues = str(j.get("visual_cues", ""))
    return {"verdict": verdict, "confidence": conf, "visual_cues": cues}

def main():
    proc = AutoProcessor.from_pretrained(VLM_ID, trust_remote_code=True)
    model = AutoModelForCausalLM.from_pretrained(
        VLM_ID, dtype=torch.float32, device_map="auto", trust_remote_code=True
    )
    results = []
    for line in Path("candidates.jsonl").read_text().splitlines():
        c = json.loads(line)
        im = Image.open(Path("frames")/c["frame"]).convert("RGB")
        x,y,w,h = c["bbox"]
        crop = im.crop((x,y,x+w,y+h))
        j = run_once(proc, model, crop, c["brand"])
        results.append({
          **c,
          "verdict": j.get("verdict","no"),
          "confidence_vlm": float(j.get("confidence",0.0)),
          "rationale": j.get("visual_cues","")
        })
    Path("detections.jsonl").write_text("\n".join(json.dumps(r, ensure_ascii=False) for r in results))
    print("wrote detections.jsonl")

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Again, breaking it down:

  • Inputs: candidates.jsonl entries with frame, bbox, score_retr, brand.
  • Crop: open the frame and im.crop(bbox) per candidate.
  • Prompting: one system + one user message; user contains the target brand and an explicit "JSON only" schema.
  • Generation: temperature=0.0, do_sample=False, max_new_tokens=128 for deterministic outputs.
  • Parsing: balanced-brace JSON extraction with smart-quote normalization; on failure returns parse_error. Set DEBUG_VLM=1 to dump raw text to vlm_raw.txt.
  • Output: writes detections.jsonl with verdict, confidence_vlm, rationale merged onto each candidate.
  • Cost control: keep retrieval strict (lower TOPK, higher COSINE_TH) to limit VLM calls as that's the main runtime driver.

Output:

$ python verify.py
wrote detections.jsonl
Enter fullscreen mode Exit fullscreen mode

And what does detections.jsonl look like? It has items like this (one per line):

{
  "frame": "f_000007.jpg",
  "bbox": [
    384,
    288,
    192,
    192
  ],
  "score_retr": 0.7020304203033447,
  "brand": "Red_Bull_logo",
  "verdict": "yes",
  "confidence_vlm": 0.95,
  "rationale": "red bull charging bull silhouette, red and yellow colors"
}
Enter fullscreen mode Exit fullscreen mode

Here 0.702 is the cosine similarity between the crop and the logo embedding.

Conclusion

I hope this showcases how a simple two-stage recipe - SigLIP-2 retrieval + VLM verification - can turn a semi-noisy video into reviewable brand evidence. I do want to mention that a pipeline like this is meant as a powerful filter, not an oracle. Human in the loop needed.

On this specific 44-second clip, with the thresholds above, I get N true positives, M false positives, and 0 missed clear logos (subjective visual check).

That being said, this is probably not a production-ready detector. We trade some accuracy and runtime for simplicity and transparency. Some improvement ideas I had in mind:

  • float32 dtype could be replaced with lower precision, like bfloat16.
  • Add a margin filter (top1 − top2 ≥ 0.15) and simple temporal smoothing. Confirm across 2–3 adjacent frames.
  • Add a lightweight OCR gate for texty marks to backstop retrieval.
  • Calibrate thresholds per brand, as some logos need higher COSINE_TH or TH_VLM.
  • Expand the logo dictionary and try multi-resolution templates (flat vs curved surfaces).
  • Speed/scale: use FAISS IVF/PQ for larger dictionaries.
  • Quantize the VLM or batch crops.
  • Maybe consider a second encoder for consensus (SigLIP‑2 + CLIP) to reduce lookalikes.
  • For 4K frames, consider either down-scaling first or increasing STRIDE (e.g. 0.75) to avoid generating tens of thousands of crops per frame.

As always, credits: thanks to the SigLIP-2 authors & maintainers and the LLaVA-OneVision team for open releases. And attribution for the example video: ProtoplasmaKid / Wikimedia Commons / CC BY-SA 4.0. 

Top comments (0)