← 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 →