← 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
M classify.pyA vision_classify.py
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 →