[object Object]

← back to Dw Material Reclassify

Add Wave-2 vision classifier (Gemini 2.0 Flash, canary-tested, timestamped run log); waits for free-tag job

c77a1dfa606e598a25a9f18a531cf038cd008e54 · 2026-07-22 09:35:45 -0700 · Steve

Files touched

Diff

commit c77a1dfa606e598a25a9f18a531cf038cd008e54
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 22 09:35:45 2026 -0700

    Add Wave-2 vision classifier (Gemini 2.0 Flash, canary-tested, timestamped run log); waits for free-tag job
---
 classify.py        |   4 +-
 vision_classify.py | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 151 insertions(+), 2 deletions(-)

diff --git a/classify.py b/classify.py
index e8331b3..c9720ce 100644
--- a/classify.py
+++ b/classify.py
@@ -86,7 +86,7 @@ SQL_TMPL = r"""
 with wc as (
   select id, coalesce(shopify_id,'') shopify_id, title, coalesce(vendor,'') vendor,
          coalesce(dw_sku,'') dw_sku, coalesce(pattern_name,'') pattern_name,
-         coalesce(body_html,'') body_html,
+         coalesce(image_url,'') image_url, coalesce(body_html,'') body_html,
          lower(regexp_replace(tags,'color:[a-z ]+','','g')) as t
   from shopify_products
   where product_type='Wallcovering' and status='ACTIVE'
@@ -94,7 +94,7 @@ with wc as (
 )
 select json_agg(json_build_object(
   'id',id,'shopify_id',shopify_id,'title',title,'vendor',vendor,'dw_sku',dw_sku,
-  'pattern_name',pattern_name,'body_html',body_html))
+  'pattern_name',pattern_name,'image_url',image_url,'body_html',body_html))
 from wc
 where not (t ~ '(cork|grasscloth|paperweave|paper weave|sisal|jute|hemp|abaca|raffia|seagrass|arrowroot|silk|linen|leather|glass bead|beaded|flock|metallic|mica|mylar|foil|tedlar|wood veneer|veneer|paulownia|vinyl|type 2|type ii|20 oz|natural wallcovering|naturals|natural fiber|natural textile|natural resource)')
   and (t ~ '(woven|textured|texture)');
diff --git a/vision_classify.py b/vision_classify.py
new file mode 100644
index 0000000..53f81d9
--- /dev/null
+++ b/vision_classify.py
@@ -0,0 +1,149 @@
+#!/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 = "gemini-2.0-flash"
+RATE  = 0.0006            # $/image (cost-tracker pricing)
+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_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"]
+    imgs=p.get("images") or []
+    return imgs[0]["src"] if imgs else ""
+
+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 gemini_label(img_url, key):
+    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:
+        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
+    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))
+    target = rows[:n]
+    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("-"*70)
+
+    tok = A.token()   # always needed now (live image fetch is an Admin GET)
+    from collections import Counter
+    split=Counter(); done=0; errors=0
+    for nid,title in target:
+        try:
+            img = live_image(nid, tok)
+            if not img: raise RuntimeError("no image")
+            label = gemini_label(img, key)
+        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: A.apply_one(nid, A.slug(label), 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}")
+    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"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)})
+        # 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()

← 5a9fa69 gitignore __pycache__  ·  back to Dw Material Reclassify  ·  Wave-2 vision -> LOCAL qwen2.5vl (Gemini generateContent dep bf0ffaa →