← back to Dw Photo Capture

build_logo_fingerprints.py

77 lines

#!/usr/bin/env python3
"""Auto-populate the LLM's per-vendor logo knowledge: run the local VLM (qwen2.5vl, $0) over
each harvested reference logo ONCE and store a canonical fingerprint — what the logo looks
like, its typesetting, and the brand text it reads as — into the vendor profile. At scan time
this grounds /api/identify (compare the swatch's logo to the known reference) and shows on
/learn. Idempotent: skips vendors already fingerprinted unless --force. Local only. $0."""
import base64, json, os, re, sys, urllib.request

ROOT = os.path.dirname(os.path.abspath(__file__))
PROF = os.path.join(ROOT, "data", "vendor_profiles.json")
OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
MODEL = os.environ.get("OLLAMA_VISION_MODEL", "qwen2.5vl:7b")
FORCE = "--force" in sys.argv

PROMPT = ('This image is a BRAND LOGO for a wallcovering/fabric maker. Reply ONLY as compact '
          'JSON: {"reads_as":"<the brand text in the logo>","typeface":"<serif wordmark / sans '
          'caps / script / geometric / etc>","logo":"<one-line description of the mark>",'
          '"colors":"<dominant colors>"}')

def vlm(b64):
    body = json.dumps({"model": MODEL, "prompt": PROMPT, "images": [b64],
                       "stream": False, "format": "json", "options": {"temperature": 0}}).encode()
    r = urllib.request.Request(OLLAMA + "/api/generate", data=body,
                               headers={"Content-Type": "application/json"})
    d = json.load(urllib.request.urlopen(r, timeout=90))
    try: return json.loads(d.get("response", "{}"))
    except Exception: return {}

prof = json.load(open(PROF))
profiles = prof.get("profiles", {})
todo = [(v, p) for v, p in profiles.items()
        if p.get("logo_file") and (FORCE or not p.get("logo_desc"))]
print(f"fingerprinting {len(todo)} logos via {MODEL} …")

done = 0
for v, p in todo:
    fp = os.path.join(ROOT, p["logo_file"])
    if not os.path.exists(fp) or fp.lower().endswith(".svg"):   # VLM needs raster
        continue
    try:
        b64 = base64.b64encode(open(fp, "rb").read()).decode()
        out = vlm(b64)
        if out:
            reads = out.get("reads_as") or ""
            # VALIDATE: the logo must read back as THIS vendor. If the VLM sees a different
            # brand (e.g. a mislabeled file), reject the logo so nothing downstream trusts it.
            def norm(s): return re.sub(r"[^a-z0-9]", "", (s or "").lower())
            nr, nv = norm(reads), norm(v)
            # whole-WORD comparison (substring "century" in "20thcenturyfox" was the false-accept).
            vwords = {w for w in re.findall(r"[a-z0-9]+", v.lower()) if len(w) >= 4}
            rwords = {w for w in re.findall(r"[a-z0-9]+", reads.lower()) if len(w) >= 3}
            shared = vwords & rwords
            valid = bool(nr) and len(nr) >= 4 and (              # ≥4 chars: a 3-char read like "Sch" must not pass via containment
                nr == nv or nr in nv or nv in nr or            # one name contains the other
                len(shared) >= 2 or                            # ≥2 shared significant words
                (len(vwords) <= 1 and bool(shared))            # single-word vendor, exact word hit
            )
            p["logo_desc"] = out.get("logo") or ""
            p["logo_typeface"] = out.get("typeface") or ""
            p["logo_reads"] = reads
            p["logo_colors"] = out.get("colors") or ""
            p["logo_valid"] = valid
            if not valid:
                # don't let a wrong logo surface on /learn or bias /api/identify
                p.pop("logo_url", None); p.pop("logo_file", None)
                print(f"  ✗ {v:28} REJECTED — logo reads as '{reads}' (not this vendor)")
            else:
                done += 1
                print(f"  ✓ {v:28} reads='{reads}' type='{p['logo_typeface']}'")
            json.dump(prof, open(PROF, "w"), indent=0)   # save incrementally (crash-safe)
    except Exception as e:
        print(f"  ! {v}: {e}")

prof.setdefault("_meta", {})["logos_fingerprinted"] = sum(1 for p in profiles.values() if p.get("logo_desc"))
json.dump(prof, open(PROF, "w"), indent=0)
print(f"\nfingerprinted {done} logos -> profiles updated")