← back to Dw Material Reclassify

vision_classify.py

164 lines

#!/usr/bin/env python3
"""
Wave-2 VISION reclassify — Gemini 2.0 Flash classifies the material of each
Textured/Woven residual product from its image, then writes a material-group tag.

Vision sees APPEARANCE, not substrate, so the prompt forces a conservative default:
a flat/printed pattern (not a genuine 3D textured material) -> "Non-Woven".

  python3 vision_classify.py --canary 5     # classify 5, NO writes, show cost
  python3 vision_classify.py --apply        # classify + tag all residual, running cost
  python3 vision_classify.py --apply --limit 500   # bounded batch

Reads GEMINI_API_KEY + SHOPIFY_ADMIN_TOKEN from ~/Projects/secrets-manager/.env.
Every run appends a timestamped record to data/vision-runs.jsonl.
"""
import os, re, sys, json, time, base64, subprocess, urllib.request, urllib.error
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import classify as C
import apply_tags as A   # reuse slug() + api() + token()

MODEL = "qwen2.5vl:7b"   # LOCAL Ollama vision (Gemini generateContent was deprecated;
                         # local also matches Steve's local-first rule + is $0)
OLLAMA = "http://localhost:11434/api/generate"
RATE  = 0.0             # $0 — local inference
ENV   = os.path.expanduser("~/Projects/secrets-manager/.env")
HERE  = os.path.dirname(os.path.abspath(__file__))
RUNLOG = os.path.join(HERE, "data", "vision-runs.jsonl")

LABELS = ["Grasscloth","Paperweave","Natural Fiber","Cork","Silk","Linen","Leather",
          "Glass Bead","Flock / Velvet","Metallic / Foil","Wood Veneer",
          "Vinyl / Type II","Non-Woven","Textured"]
PROMPT = (
  "You are classifying the physical MATERIAL of a wallcovering from its product image.\n"
  "Choose EXACTLY ONE label from this list:\n" + ", ".join(LABELS) + ".\n"
  "Rules: pick a specific natural material (Grasscloth, Silk, Cork, Linen, Natural Fiber, "
  "Wood Veneer, Glass Bead, Leather, Metallic/Foil, Flock) ONLY if the image clearly shows "
  "that real 3D texture/weave. If it looks like a FLAT PRINTED design that merely depicts a "
  "texture, or you are unsure, answer 'Non-Woven'. Answer with the label text only, nothing else."
)

def env(name):
    for line in open(ENV):
        if line.startswith(name+"="):
            return line.split("=",1)[1].strip().strip('"').strip("'")
    return ""

def residual():
    """rows classify() marks needs_vision, with a numeric Shopify id."""
    out=[]
    for r in C.load_rows(scope_all=True):
        tier,bucket,ev = C.classify(r)
        if bucket is None and tier=="needs_vision":
            gid=r.get("shopify_id") or ""; nid=gid.rsplit("/",1)[-1]
            if nid.isdigit():
                out.append((nid, r["title"]))
    out.sort(key=lambda x: x[0])   # deterministic order for reproducible canary/batches
    return out

def live_product(nid, tok):
    """(image_src, already_material_tagged) from the Admin API — avoids stale mirror
    URLs AND lets us skip anything the free-tag pass already classified (safe to run
    concurrently with the free job; no double-tagging)."""
    p=A.api("GET", f"products/{nid}.json?fields=id,images,tags", tok)["product"]
    imgs=p.get("images") or []
    src=imgs[0]["src"] if imgs else ""
    tags=[t.strip() for t in (p.get("tags") or "").split(",") if t.strip()]
    already=any(t.startswith("material-group-") for t in tags)
    return src, already, tags

def write_tag(nid, tag, tags, tok):
    """Single PUT using the tags we already fetched (no re-GET/verify). 2 REST
    calls/product total keeps us under Shopify's 2 req/s cap."""
    A.api("PUT", f"products/{nid}.json", tok, {"product":{"id":int(nid),"tags":", ".join(tags+[tag])}})

def small(url):
    # ask Shopify CDN for a smaller render to keep the call light
    if "cdn.shopify.com" in url and "width=" not in url:
        return url + (("&" if "?" in url else "?") + "width=384")
    return url

def vision_label(img_url, key=None):
    raw = urllib.request.urlopen(small(img_url), timeout=30).read()
    b64 = base64.b64encode(raw).decode()
    body = {"model":MODEL,"prompt":PROMPT,"images":[b64],"stream":False,
            "options":{"temperature":0,"num_predict":20}}
    req = urllib.request.Request(OLLAMA, method="POST",
        headers={"Content-Type":"application/json"}, data=json.dumps(body).encode())
    resp = json.load(urllib.request.urlopen(req, timeout=120))
    txt = (resp.get("response") or "").strip()
    for L in LABELS:                     # snap to a known label
        if L.lower() in txt.lower() or txt.lower() in L.lower():
            return L
    return "Non-Woven"                   # unparseable -> conservative default

def logrun(rec):
    os.makedirs(os.path.dirname(RUNLOG), exist_ok=True)
    with open(RUNLOG,"a") as f: f.write(json.dumps(rec)+"\n")

def main():
    canary = int(sys.argv[sys.argv.index("--canary")+1]) if "--canary" in sys.argv else None
    limit  = int(sys.argv[sys.argv.index("--limit")+1]) if "--limit" in sys.argv else None
    do_write = "--apply" in sys.argv
    rows = residual()
    print(f"Vision residual: {len(rows)} products")
    n = canary if canary is not None else (limit or len(rows))
    target = rows[:n]
    est = len(target)*RATE
    started = datetime.now(timezone.utc).isoformat()
    print(f"Run start (UTC): {started}")
    print(f"Batch: {len(target)}   cost: $0 (LOCAL {MODEL})   mode: {'WRITE' if do_write else 'DRY (canary)'}")
    print("-"*70)

    tok = A.token()   # always needed now (live image fetch is an Admin GET)
    from collections import Counter
    split=Counter(); done=0; errors=0; skipped=0; ghosts=0
    for nid,title in target:
        try:
            img, already, tags = live_product(nid, tok)
        except Exception as e:
            ghosts+=1                       # 404 = ghost/staged/deleted -> skip, $0
            if canary is not None: print(f"  GHOST {str(e)[:30]:30s} {title[:34]}")
            continue
        if already:                          # free-pass already tagged it -> don't double-tag
            skipped+=1
            if canary is not None: print(f"  skip(already-tagged)  {title[:40]}")
            continue
        try:
            if not img: raise RuntimeError("no image")
            label = vision_label(img)
        except Exception as e:
            errors+=1
            if canary is not None: print(f"  ERR {str(e)[:40]:40s} {title[:34]}")
            continue
        split[label]+=1; done+=1
        if do_write:
            try: write_tag(nid, A.slug(label), tags, tok)
            except Exception: errors+=1
        if canary is not None:
            print(f"  {label:18s} {title[:44]}")
        if done % 250 == 0:
            print(f"  ...{done} done  ($0 local)")
    ended = datetime.now(timezone.utc).isoformat()
    actual = done*RATE
    print("-"*70)
    print("split:", dict(split))
    print(f"classified {done}, skipped(already-tagged) {skipped}, ghosts(404) {ghosts}, errors {errors}")
    print(f"ACTUAL cost: $0 (local {MODEL}); {done} images classified")
    print(f"Run start {started}  ->  end {ended}")
    if do_write:
        logrun({"wave":"wave2-vision","model":MODEL,"started":started,"ended":ended,
                "classified":done,"skipped_already":skipped,"ghosts":ghosts,"errors":errors,
                "cost_usd":round(actual,4),"split":dict(split)})
        # log to the shared cost ledger too
        try:
            subprocess.run(["node", os.path.expanduser("~/.claude/skills/cost-tracker/log.js"),
                "--provider","gemini","--op","wave2-material-vision",
                "--qty",str(done),"--cost",f"{actual:.4f}"], capture_output=True, timeout=10)
        except Exception: pass
        print(f"logged run -> {RUNLOG}")

if __name__=="__main__":
    main()