[object Object]

← back to Dw Photo Capture

Local aug-model deploy pipeline (mac_deploy_embed): embed full catalog with dw_clip_ft_aug on Mac MPS → Postgres COPY file for Kamatera local ingest (gate-respecting: no Mac→prod tunnel). Reuses 90k cache, 3-state load (ok/dead-404/transient-retry) so rate-limits never false-mark images dead. Resumable

17b5c68a9a8a6e1f669a09e2e6eaa055d21ab407 · 2026-07-07 13:51:34 -0700 · Steve Abrams

Files touched

Diff

commit 17b5c68a9a8a6e1f669a09e2e6eaa055d21ab407
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 7 13:51:34 2026 -0700

    Local aug-model deploy pipeline (mac_deploy_embed): embed full catalog with dw_clip_ft_aug on Mac MPS → Postgres COPY file for Kamatera local ingest (gate-respecting: no Mac→prod tunnel). Reuses 90k cache, 3-state load (ok/dead-404/transient-retry) so rate-limits never false-mark images dead. Resumable
---
 visual-search/train/.gitignore          |   3 +
 visual-search/train/mac_deploy_embed.py | 102 ++++++++++++++++++++++++++++++++
 2 files changed, 105 insertions(+)

diff --git a/visual-search/train/.gitignore b/visual-search/train/.gitignore
index 9160e94..5e96ddc 100644
--- a/visual-search/train/.gitignore
+++ b/visual-search/train/.gitignore
@@ -3,3 +3,6 @@ cache/
 ckpt/
 logs/
 manifest.tsv
+deploy_manifest.tsv
+deploy_embeddings.copy
+deploy_done.txt
diff --git a/visual-search/train/mac_deploy_embed.py b/visual-search/train/mac_deploy_embed.py
new file mode 100644
index 0000000..d179ff8
--- /dev/null
+++ b/visual-search/train/mac_deploy_embed.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+# LOCAL deploy embedder: embed the whole catalog with the AUGMENTED model (dw_clip_ft_aug.pt) on the
+# Mac's MPS, writing a Postgres COPY file (text format) for Kamatera to ingest LOCALLY. No prod-DB
+# connection from the Mac — it only produces a data file. Reuses the 90k training image cache; dead
+# URLs get a NULL-embedding marker (so the index self-terminates, same as the daemon). Resumable.
+import os, io, sys, csv, time, threading, queue, urllib.request, urllib.parse
+os.environ.setdefault('PYTORCH_ENABLE_MPS_FALLBACK', '1')
+import torch, torch.nn.functional as F, open_clip
+from PIL import Image
+csv.field_size_limit(10_000_000); Image.MAX_IMAGE_PIXELS = None
+
+HERE = os.path.dirname(os.path.abspath(__file__)); CACHE = os.path.join(HERE, 'cache')
+MANI = os.path.join(HERE, 'deploy_manifest.tsv'); OUT = os.path.join(HERE, 'deploy_embeddings.copy')
+DONE = os.path.join(HERE, 'deploy_done.txt')
+UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
+WORKERS = 20; EMB_BATCH = 128; CHUNK = 1500
+
+model, _, pp = open_clip.create_model_and_transforms("ViT-B-32", pretrained="laion2b_s34b_b79k")
+model.load_state_dict(torch.load(os.path.join(HERE, 'ckpt', 'dw_clip_ft_aug.pt'), map_location='cpu'), strict=False)
+dev = 'mps' if torch.backends.mps.is_available() else 'cpu'
+model = model.to(dev).float().eval(); DIM = model.visual.output_dim
+print(f"AUG model loaded · device {dev} · dim {DIM}", flush=True)
+
+def small(u):
+    if 'shopify' in u:
+        pr = urllib.parse.urlparse(u); q = urllib.parse.parse_qs(pr.query); q['width'] = ['512']
+        return urllib.parse.urlunparse(pr._replace(query=urllib.parse.urlencode(q, doseq=True)))
+    return u
+def esc(s): return (s or '').replace('\\', '\\\\').replace('\t', ' ').replace('\n', ' ').replace('\r', ' ')
+
+def load_img(row):
+    # returns (row, tensor)=ok · (row,'DEAD')=genuine 404/410 → NULL marker · (row,None)=transient → retry later
+    idv, dw, mfr, vc, pat, url = row
+    cp = os.path.join(CACHE, idv + '.jpg')
+    if os.path.exists(cp) and os.path.getsize(cp) > 200:
+        try: return (row, pp(Image.open(cp).convert('RGB')))
+        except Exception: pass
+    dead = False
+    for attempt in range(3):
+        try:
+            data = urllib.request.urlopen(urllib.request.Request(small(url), headers={'User-Agent': UA}), timeout=25).read()
+            im = Image.open(io.BytesIO(data)).convert('RGB')
+            try: im.copy().save(cp, 'JPEG', quality=88)   # populate cache for resumes
+            except Exception: pass
+            return (row, pp(im))
+        except urllib.error.HTTPError as e:
+            if e.code in (404, 410, 403 if False else 0): dead = True; break   # genuinely gone
+            if e.code in (429, 500, 502, 503):            # rate-limit / transient → back off + retry
+                time.sleep(1.5 * (attempt + 1)); continue
+            dead = True; break
+        except Exception:
+            time.sleep(1.0 * (attempt + 1)); continue     # network hiccup → retry
+    return (row, 'DEAD' if dead else None)
+
+def main():
+    done = set()
+    if os.path.exists(DONE):
+        with open(DONE) as f: done = set(l.strip() for l in f if l.strip())
+    rows = []
+    with open(MANI) as f:
+        for r in csv.reader(f, delimiter='\t'):
+            if len(r) >= 6 and r[0] and r[0] not in done and r[5].startswith('http'): rows.append(r)
+    print(f"to embed: {len(rows)} (already done {len(done)})", flush=True)
+    fout = open(OUT, 'a'); fdone = open(DONE, 'a')
+    t0 = time.time(); total = 0
+    for i in range(0, len(rows), CHUNK):
+        batch = rows[i:i+CHUNK]
+        got = [None]*len(batch); q = queue.Queue()
+        for j, r in enumerate(batch): q.put((j, r))
+        lock = threading.Lock()
+        def worker():
+            while True:
+                try: j, r = q.get_nowait()
+                except queue.Empty: return
+                got[j] = load_img(r); q.task_done()
+        ts = [threading.Thread(target=worker, daemon=True) for _ in range(WORKERS)]
+        for t in ts: t.start()
+        for t in ts: t.join()
+        ok = [g for g in got if g and not isinstance(g[1], str) and g[1] is not None]
+        bad = [g for g in got if g and g[1] == 'DEAD']   # genuine 404 → NULL marker
+        # g[1] is None → transient failure: NOT written, NOT marked done → retried on the next resume pass
+        # embed the good ones in sub-batches
+        for k in range(0, len(ok), EMB_BATCH):
+            sub = ok[k:k+EMB_BATCH]
+            with torch.no_grad():
+                v = model.encode_image(torch.stack([g[1] for g in sub]).to(dev))
+                v = (v / v.norm(dim=-1, keepdim=True)).cpu().numpy().astype('float32')
+            for g, emb in zip(sub, v):
+                r = g[0]; hx = emb.tobytes().hex()
+                fout.write(f"{r[0]}\t{esc(r[1])}\t{esc(r[2])}\t{esc(r[3])}\t{esc(r[4])}\t{esc(r[5])}\t{DIM}\t\\\\x{hx}\n")
+                fdone.write(r[0] + "\n")
+        for g in bad:
+            r = g[0]
+            fout.write(f"{r[0]}\t{esc(r[1])}\t{esc(r[2])}\t{esc(r[3])}\t{esc(r[4])}\t{esc(r[5])}\t\\N\t\\N\n")
+            fdone.write(r[0] + "\n")
+        fout.flush(); fdone.flush(); total += len(batch)
+        print(f"{total}/{len(rows)} embedded  {total/max(1e-6,time.time()-t0):.0f}/s", flush=True)
+    fout.close(); fdone.close()
+    print(f"DONE — {total} rows → {OUT}", flush=True)
+
+if __name__ == '__main__':
+    main()

← 2acc59c auto-save: 2026-07-07T13:30:45 (1 files) — visual-search/tra  ·  back to Dw Photo Capture  ·  Check Sample In → incoming queue: /api/incoming-samples find 2d627ac →