← back to Dw Color Image Audit

scripts/vision_gate.py

182 lines

#!/usr/bin/env python3
"""
Gemini-vision YES/NO gate for the non-PR Tier-1 color-image defect set (DTD Q1=b).
For each row: fetch image_url, ask Gemini 2.0 Flash whether the wallcovering's
DOMINANT color is consistent with the declared colorway name, or clearly different.
Drops family-parser / busy-pattern FALSE POSITIVES; keeps real-defect candidates.

Read-only against catalog (only GETs the public Shopify image + calls Gemini).
Writes results to out/vision_gate_results.jsonl + out/vision_gate_summary.csv.
"""
import csv, json, os, sys, time, base64, urllib.request, urllib.error, re

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CSV_IN = os.path.join(ROOT, "out", "tier1_confirmed_defects.csv")
JSONL_OUT = os.path.join(ROOT, "out", "vision_gate_results.jsonl")
CSV_OUT = os.path.join(ROOT, "out", "vision_gate_summary.csv")
V2_JSONL = os.path.join(ROOT, "full", "reextracted_full.jsonl")

MODEL = "gemini-2.5-flash-lite"
COST_PER_CALL = 0.0006  # ~$ per product image (always-show-costs rule)

def get_key():
    env = os.path.expanduser("~/Projects/secrets-manager/.env")
    with open(env) as f:
        for line in f:
            if line.startswith("GEMINI_API_KEY="):
                return line.split("=", 1)[1].strip().strip('"').strip("'")
    sys.exit("no GEMINI_API_KEY")

API_KEY = get_key()
URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent?key={API_KEY}"

def load_v2_verdicts():
    """Map image_url -> v2 verdict so we can tag survivors-of-v2 vs cleared."""
    m = {}
    if not os.path.exists(V2_JSONL):
        return m
    with open(V2_JSONL) as f:
        for line in f:
            try:
                r = json.loads(line)
                m[r.get("image_url")] = r.get("verdict")
            except Exception:
                pass
    return m

def fetch_image(url, tries=3):
    for i in range(tries):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
            with urllib.request.urlopen(req, timeout=30) as r:
                data = r.read()
                ct = r.headers.get("Content-Type", "image/jpeg")
                if "image" not in ct:
                    ct = "image/jpeg"
                return data, ct
        except Exception as e:
            if i == tries - 1:
                return None, str(e)
            time.sleep(2)
    return None, "exhausted"

PROMPT_TMPL = (
    "This is a product image for a wallcovering/fabric that is merchandised online "
    "under the color name \"{declared}\". Look ONLY at the actual wallcovering/fabric "
    "surface (ignore any white studio background or border). "
    "Is the DOMINANT color of the wallcovering consistent with the name \"{declared}\", "
    "or is the image CLEARLY a different color than that name implies? "
    "A multi-color or busy pattern where \"{declared}\" is plausibly one of the inks "
    "counts as CONSISTENT (matches_declared=true). Only answer false when the image is "
    "unmistakably the wrong color for that name. "
    "Respond with STRICT JSON only, no prose: "
    '{{"dominant_color":"<plain english>","matches_declared":true|false,'
    '"confidence":<0.0-1.0>,"note":"<=12 words"}}'
)

def ask_gemini(img_bytes, ct, declared):
    prompt = PROMPT_TMPL.format(declared=declared)
    body = {
        "contents": [{
            "parts": [
                {"text": prompt},
                {"inline_data": {"mime_type": ct, "data": base64.b64encode(img_bytes).decode()}},
            ]
        }],
        "generationConfig": {"temperature": 0, "maxOutputTokens": 200},
    }
    req = urllib.request.Request(
        URL, data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"})
    for i in range(3):
        try:
            with urllib.request.urlopen(req, timeout=60) as r:
                resp = json.loads(r.read())
            txt = resp["candidates"][0]["content"]["parts"][0]["text"]
            mt = re.search(r"\{.*\}", txt, re.S)
            if mt:
                return json.loads(mt.group(0)), None
            return None, "no-json:" + txt[:120]
        except urllib.error.HTTPError as e:
            err = e.read().decode()[:200]
            if e.code == 429:
                time.sleep(15)
                continue
            if i == 2:
                return None, f"http{e.code}:{err}"
            time.sleep(3)
        except Exception as e:
            if i == 2:
                return None, str(e)[:160]
            time.sleep(3)
    return None, "exhausted"

def main():
    v2 = load_v2_verdicts()
    rows = []
    with open(CSV_IN) as f:
        for r in csv.DictReader(f):
            if r["vendor"] != "Phillipe Romano":
                rows.append(r)
    print(f"non-PR Tier-1 rows: {len(rows)}", file=sys.stderr)

    results = []
    calls = 0
    for i, r in enumerate(rows, 1):
        declared = r["declared_color"]
        img_bytes, ct = fetch_image(r["image_url"])
        rec = {
            "vendor": r["vendor"], "dw_sku": r["dw_sku"], "mfr_sku": r["mfr_sku"],
            "declared_color": declared, "declared_family": r["declared_family"],
            "image_family": r["real_image_family"], "image_hex": r["real_image_hex"],
            "image_url": r["image_url"], "handle": r["handle"],
            "v2_verdict": v2.get(r["image_url"]),
        }
        if img_bytes is None:
            rec.update({"gate": "ERROR", "error": "fetch:" + str(ct)})
            results.append(rec)
            print(f"[{i}/{len(rows)}] FETCH-ERR {r['vendor']} {r['dw_sku']}", file=sys.stderr)
            continue
        out, err = ask_gemini(img_bytes, ct, declared)
        calls += 1
        if out is None:
            rec.update({"gate": "ERROR", "error": err})
        else:
            matches = bool(out.get("matches_declared"))
            rec.update({
                "gate": "FALSE_POSITIVE" if matches else "SURVIVOR",
                "vision_dominant": out.get("dominant_color"),
                "vision_matches": matches,
                "vision_confidence": out.get("confidence"),
                "vision_note": out.get("note"),
            })
        results.append(rec)
        g = rec.get("gate")
        print(f"[{i}/{len(rows)}] {g:14s} {r['vendor'][:18]:18s} {r['dw_sku']:14s} "
              f"declared='{declared[:30]}' vision='{rec.get('vision_dominant','')}'", file=sys.stderr)
        time.sleep(0.3)

    with open(JSONL_OUT, "w") as f:
        for r in results:
            f.write(json.dumps(r) + "\n")
    fields = ["vendor", "dw_sku", "mfr_sku", "declared_color", "declared_family",
              "image_family", "v2_verdict", "gate", "vision_dominant",
              "vision_matches", "vision_confidence", "vision_note", "image_url", "handle"]
    with open(CSV_OUT, "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
        w.writeheader()
        for r in results:
            w.writerow(r)

    surv = [r for r in results if r.get("gate") == "SURVIVOR"]
    fp = [r for r in results if r.get("gate") == "FALSE_POSITIVE"]
    errs = [r for r in results if r.get("gate") == "ERROR"]
    print("\n==== VISION GATE SUMMARY ====", file=sys.stderr)
    print(f"total={len(results)} SURVIVOR={len(surv)} FALSE_POSITIVE={len(fp)} ERROR={len(errs)}", file=sys.stderr)
    print(f"gemini calls={calls}  est cost=${calls*COST_PER_CALL:.4f}", file=sys.stderr)
    from collections import Counter
    print("survivors by vendor:", dict(Counter(r["vendor"] for r in surv)), file=sys.stderr)

if __name__ == "__main__":
    main()