[object Object]

← back to Dw Material Reclassify

Wave-2 vision -> LOCAL qwen2.5vl (Gemini generateContent deprecated); $0, verified classifies live products

bf0ffaa4e4456546e4db629d5df78879ba0e267a · 2026-07-22 09:51:49 -0700 · Steve

Files touched

Diff

commit bf0ffaa4e4456546e4db629d5df78879ba0e267a
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 22 09:51:49 2026 -0700

    Wave-2 vision -> LOCAL qwen2.5vl (Gemini generateContent deprecated); $0, verified classifies live products
---
 vision_classify.py | 80 ++++++++++++++++++++++++++++++++----------------------
 1 file changed, 47 insertions(+), 33 deletions(-)

diff --git a/vision_classify.py b/vision_classify.py
index 53f81d9..bc8b83d 100644
--- a/vision_classify.py
+++ b/vision_classify.py
@@ -19,8 +19,10 @@ 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 = "gemini-2.0-flash"
-RATE  = 0.0006            # $/image (cost-tracker pricing)
+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")
@@ -55,11 +57,21 @@ def residual():
     out.sort(key=lambda x: x[0])   # deterministic order for reproducible canary/batches
     return out
 
-def live_image(nid, tok):
-    """Current image src from the Admin API (avoids stale mirror URLs)."""
-    p=A.api("GET", f"products/{nid}.json?fields=id,images", tok)["product"]
+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 []
-    return imgs[0]["src"] if imgs else ""
+    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
@@ -67,22 +79,19 @@ def small(url):
         return url + (("&" if "?" in url else "?") + "width=384")
     return url
 
-def gemini_label(img_url, key):
+def vision_label(img_url, key=None):
     raw = urllib.request.urlopen(small(img_url), timeout=30).read()
     b64 = base64.b64encode(raw).decode()
-    mime = "image/png" if img_url.lower().split("?")[0].endswith("png") else "image/jpeg"
-    body = {"contents":[{"parts":[{"text":PROMPT},{"inline_data":{"mime_type":mime,"data":b64}}]}],
-            "generationConfig":{"temperature":0,"maxOutputTokens":20}}
-    req = urllib.request.Request(
-        f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent?key={key}",
-        method="POST", headers={"Content-Type":"application/json"}, data=json.dumps(body).encode())
-    resp = json.load(urllib.request.urlopen(req, timeout=60))
-    txt = resp["candidates"][0]["content"]["parts"][0]["text"].strip()
-    # snap to a known label
-    for L in LABELS:
+    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
+    return "Non-Woven"                   # unparseable -> conservative default
 
 def logrun(rec):
     os.makedirs(os.path.dirname(RUNLOG), exist_ok=True)
@@ -92,9 +101,6 @@ 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
-    key = env("GEMINI_API_KEY")
-    if not key: sys.exit("no GEMINI_API_KEY")
-
     rows = residual()
     print(f"Vision residual: {len(rows)} products")
     n = canary if canary is not None else (limit or len(rows))
@@ -102,41 +108,49 @@ def main():
     est = len(target)*RATE
     started = datetime.now(timezone.utc).isoformat()
     print(f"Run start (UTC): {started}")
-    print(f"Batch: {len(target)}   est cost: ${est:.2f} @ ${RATE}/img"
-          f"   mode: {'WRITE' if do_write else 'DRY (canary)'}")
+    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
+    split=Counter(); done=0; errors=0; skipped=0; ghosts=0
     for nid,title in target:
         try:
-            img = live_image(nid, tok)
+            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 = gemini_label(img, key)
+            label = vision_label(img)
         except Exception as e:
-            errors+=1;
+            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: A.apply_one(nid, A.slug(label), tok)
+            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  running cost ${done*RATE:.2f}")
+            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}, errors {errors}")
-    print(f"ACTUAL cost: ${actual:.2f}  ({done} img x ${RATE})")
+    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,"errors":errors,"cost_usd":round(actual,4),
-                "split":dict(split)})
+                "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"),

← c77a1df Add Wave-2 vision classifier (Gemini 2.0 Flash, canary-teste  ·  back to Dw Material Reclassify  ·  5x: card feature clean twice (0 own-errors); M3 red = pre-ex 4e3a15b →