[object Object]

← back to Dw Photo Capture

auto-save: 2026-07-12T23:51:20 (5 files) — visual-search/search_service.py visual-search/__pycache__/embed_shopify_gap.cpython-312.pyc visual-search/__pycache__/search_service.cpython-312.pyc visual-search/com.steve.visual-search.plist visual-search/embed_shopify_gap.py

2890b8f30e7af0b160fea84a25583b942cdf1151 · 2026-07-12 23:51:23 -0700 · Steve Abrams

Files touched

Diff

commit 2890b8f30e7af0b160fea84a25583b942cdf1151
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 12 23:51:23 2026 -0700

    auto-save: 2026-07-12T23:51:20 (5 files) — visual-search/search_service.py visual-search/__pycache__/embed_shopify_gap.cpython-312.pyc visual-search/__pycache__/search_service.cpython-312.pyc visual-search/com.steve.visual-search.plist visual-search/embed_shopify_gap.py
---
 .../__pycache__/embed_shopify_gap.cpython-312.pyc  | Bin 0 -> 7782 bytes
 .../__pycache__/search_service.cpython-312.pyc     | Bin 0 -> 20126 bytes
 visual-search/com.steve.visual-search.plist        |  32 ++++++
 visual-search/embed_shopify_gap.py                 | 114 +++++++++++++++++++++
 visual-search/search_service.py                    |  35 +++++++
 5 files changed, 181 insertions(+)

diff --git a/visual-search/__pycache__/embed_shopify_gap.cpython-312.pyc b/visual-search/__pycache__/embed_shopify_gap.cpython-312.pyc
new file mode 100644
index 0000000..7902cde
Binary files /dev/null and b/visual-search/__pycache__/embed_shopify_gap.cpython-312.pyc differ
diff --git a/visual-search/__pycache__/search_service.cpython-312.pyc b/visual-search/__pycache__/search_service.cpython-312.pyc
new file mode 100644
index 0000000..813318a
Binary files /dev/null and b/visual-search/__pycache__/search_service.cpython-312.pyc differ
diff --git a/visual-search/com.steve.visual-search.plist b/visual-search/com.steve.visual-search.plist
new file mode 100644
index 0000000..3d2e8b3
--- /dev/null
+++ b/visual-search/com.steve.visual-search.plist
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.visual-search</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/Users/macstudio3/Projects/dw-photo-capture/visual-search/.venv-similar/bin/python</string>
+    <string>/Users/macstudio3/Projects/dw-photo-capture/visual-search/search_service.py</string>
+  </array>
+  <key>WorkingDirectory</key>
+  <string>/Users/macstudio3/Projects/dw-photo-capture/visual-search</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>DW_UNIFIED_DB</key>
+    <string>postgresql://stevestudio2@/dw_unified?host=/tmp</string>
+    <key>VS_PORT</key>
+    <string>9914</string>
+  </dict>
+  <key>RunAtLoad</key>
+  <true/>
+  <key>KeepAlive</key>
+  <true/>
+  <key>ThrottleInterval</key>
+  <integer>15</integer>
+  <key>StandardOutPath</key>
+  <string>/Users/macstudio3/Library/Logs/visual-search.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/macstudio3/Library/Logs/visual-search.log</string>
+</dict>
+</plist>
diff --git a/visual-search/embed_shopify_gap.py b/visual-search/embed_shopify_gap.py
new file mode 100644
index 0000000..82885cf
--- /dev/null
+++ b/visual-search/embed_shopify_gap.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+# Embed the ACTIVE Shopify products that have no CLIP vector yet ("the 46% gap").
+# Sibling of embed_catalog.py — same model/weights/normalization so vectors share the
+# catalog's space — but sources from shopify_products (Shopify CDN image_url) instead of
+# vendor_catalog, because ~97.5% of the gap SKUs have no vendor_catalog image row.
+#
+# Key strategy: image_embeddings PK is vc_id (vendor_catalog.id). Shopify-only rows use a
+# SYNTHETIC NEGATIVE key vc_id = -shopify_id — bigint, can never collide with real
+# vendor_catalog ids, no schema change. search_service.load() picks rows up unchanged
+# (it selects by `embedding is not null` and indexes by dw_sku).
+#
+# Resumable: a dw_sku with any embedding-NOT-NULL row is skipped; permanent failures get
+# the NULL-embedding placeholder on the SAME synthetic key so reruns skip them too.
+#
+# Usage:  DW_UNIFIED_DB=<uri> python3 embed_shopify_gap.py [--limit N] [--batch 32] [--workers 24]
+import os, sys, io, time, argparse, concurrent.futures as cf
+import urllib.request
+import numpy as np, psycopg2
+from PIL import Image
+import torch, open_clip
+
+DB = os.environ.get("DW_UNIFIED_DB") or sys.exit("set DW_UNIFIED_DB")
+ap = argparse.ArgumentParser()
+ap.add_argument("--limit", type=int, default=0)     # 0 = all remaining
+ap.add_argument("--batch", type=int, default=32)    # CLIP forward-pass batch
+ap.add_argument("--workers", type=int, default=24)  # concurrent downloads
+ap.add_argument("--chunk", type=int, default=2000)  # rows fetched per DB round
+args = ap.parse_args()
+
+print("loading CLIP ViT-B-32 (laion2b)…", flush=True)
+model, _, preprocess = open_clip.create_model_and_transforms("ViT-B-32", pretrained="laion2b_s34b_b79k")
+
+_ft = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dw_clip_ft.pt")
+if os.path.exists(_ft):
+    model.load_state_dict(torch.load(_ft, map_location="cpu"), strict=False)
+    print("loaded fine-tuned DW CLIP weights:", _ft, flush=True)
+
+model.eval()
+torch.set_num_threads(max(1, os.cpu_count() - 2))
+DIM = model.visual.output_dim
+
+def fetch(row):
+    shopify_id, dw_sku, vendor, pattern, url = row
+    try:
+        req = urllib.request.Request(url, headers={"User-Agent": "dw-visual/1.0"})
+        with urllib.request.urlopen(req, timeout=8) as r:
+            data = r.read(6_000_000)
+        img = Image.open(io.BytesIO(data)).convert("RGB")
+        return (shopify_id, dw_sku, vendor, pattern, url, preprocess(img))
+    except Exception:
+        return (shopify_id, dw_sku, vendor, pattern, url, None)   # permanent-fail placeholder
+
+# One row per gap dw_sku: the newest ACTIVE shopify product carrying that sku.
+# shopify_id is a GID string ("gid://shopify/Product/779…") — extract the numeric
+# tail here so the synthetic key (-id) is a clean bigint.
+GAP_SQL = """
+WITH act AS (
+  SELECT DISTINCT ON (sp.dw_sku)
+         (regexp_replace(sp.shopify_id, '\\D', '', 'g'))::bigint AS shopify_id,
+         sp.dw_sku, sp.vendor, sp.pattern_name, sp.image_url
+  FROM shopify_products sp
+  WHERE sp.status='ACTIVE' AND sp.image_url IS NOT NULL AND sp.handle IS NOT NULL AND sp.dw_sku IS NOT NULL
+    AND sp.shopify_id ~ '\\d'
+  ORDER BY sp.dw_sku, sp.synced_at DESC NULLS LAST
+),
+emb AS (SELECT DISTINCT dw_sku FROM image_embeddings WHERE embedding IS NOT NULL),
+tried AS (SELECT DISTINCT dw_sku FROM image_embeddings WHERE vc_id < 0 AND embedding IS NULL)
+SELECT a.shopify_id, a.dw_sku, a.vendor, a.pattern_name, a.image_url
+FROM act a
+LEFT JOIN emb e ON e.dw_sku = a.dw_sku
+LEFT JOIN tried t ON t.dw_sku = a.dw_sku
+WHERE e.dw_sku IS NULL AND t.dw_sku IS NULL
+LIMIT %s
+"""
+
+def main():
+    conn = psycopg2.connect(DB); conn.autocommit = False
+    total_done = 0; t0 = time.time()
+    while True:
+        cur = conn.cursor()
+        cur.execute(GAP_SQL, (args.chunk,))
+        rows = cur.fetchall(); cur.close()
+        if not rows: break
+        with cf.ThreadPoolExecutor(max_workers=args.workers) as ex:
+            got = list(ex.map(fetch, rows))
+        ok  = [g for g in got if g[5] is not None]
+        bad = [g for g in got if g[5] is None]
+        recs = []
+        for i in range(0, len(ok), args.batch):
+            b = ok[i:i+args.batch]
+            with torch.no_grad():
+                t = torch.stack([g[5] for g in b])
+                v = model.encode_image(t)
+                v = (v / v.norm(dim=-1, keepdim=True)).cpu().numpy().astype("float32")
+            for g, emb in zip(b, v):
+                # vc_id = -shopify_id  (synthetic key, see header)
+                recs.append((-g[0], g[1], None, g[2], g[3], g[4], DIM, psycopg2.Binary(emb.tobytes())))
+        cur = conn.cursor()
+        if recs:
+            cur.executemany("""insert into image_embeddings (vc_id,dw_sku,mfr_sku,vendor_code,pattern_name,image_url,dim,embedding)
+                               values (%s,%s,%s,%s,%s,%s,%s,%s) on conflict (vc_id) do nothing""", recs)
+        if bad:
+            cur.executemany("""insert into image_embeddings (vc_id,dw_sku,image_url,embedding) values (%s,%s,%s,NULL)
+                               on conflict (vc_id) do nothing""", [(-g[0], g[1], g[4]) for g in bad])
+        conn.commit(); cur.close()
+        total_done += len(rows)
+        rate = total_done / max(1e-6, time.time()-t0)
+        print(f"  +{len(rows)} (ok={len(ok)} fail={len(bad)}) · {total_done} done · {rate:.1f}/s", flush=True)
+        if args.limit and total_done >= args.limit: break
+    conn.close()
+    print(f"done: {total_done} processed in {time.time()-t0:.0f}s", flush=True)
+
+if __name__ == "__main__":
+    main()
diff --git a/visual-search/search_service.py b/visual-search/search_service.py
index 1ee28d1..0b9f9f4 100644
--- a/visual-search/search_service.py
+++ b/visual-search/search_service.py
@@ -143,6 +143,7 @@ class H(BaseHTTPRequestHandler):
         self._send(404, {"err": "not found"})
     def do_POST(self):
         if self.path.startswith("/similar"): return self._similar()
+        if self.path.startswith("/pairwise"): return self._pairwise()
         if not self.path.startswith("/search"): return self._send(404, {"err": "not found"})
         try:
             n = int(self.headers.get("Content-Length", 0)); p = json.loads(self.rfile.read(n) or b"{}")
@@ -160,6 +161,40 @@ class H(BaseHTTPRequestHandler):
         except Exception as e:
             self._send(500, {"ok": False, "err": str(e)})
 
+    def _pairwise(self):
+        # Bulk cosine: source dw_sku vs an explicit candidate list. Contract:
+        # {dw_sku, candidates: [dw_sku, …]} → {ok, cosines: {sku: cos}}.
+        # Consumer: dw-pairs-well /api/pairs blends these as a bounded style-world
+        # sub-score (mid-similarity sweet spot; near-duplicates get penalized there).
+        # Candidates without a stored vector are simply absent from the map (neutral).
+        try:
+            n = int(self.headers.get("Content-Length", 0)); p = json.loads(self.rfile.read(n) or b"{}")
+            dw_sku = (p.get("dw_sku") or "").strip()
+            cands = p.get("candidates") or []
+            if not dw_sku or not isinstance(cands, list):
+                return self._send(400, {"ok": False, "err": "dw_sku + candidates[] required"})
+            cands = [str(c).strip() for c in cands[:5000] if c]
+            with LOCK:
+                M, sku_ix = STATE["M"], STATE["sku_ix"]
+            if M is None or not len(M):
+                return self._send(200, {"ok": True, "cosines": {}})
+            qi = sku_ix.get(dw_sku)
+            if qi is None:
+                return self._send(404, {"ok": False, "err": f"no stored embedding for dw_sku {dw_sku}"})
+            q = M[qi]
+            ixs, skus = [], []
+            for c in cands:
+                ci = sku_ix.get(c)
+                if ci is not None and ci != qi:
+                    ixs.append(ci); skus.append(c)
+            if not ixs:
+                return self._send(200, {"ok": True, "cosines": {}})
+            sims = M[np.array(ixs)] @ q
+            out = {s: round(float(v), 4) for s, v in zip(skus, sims)}
+            self._send(200, {"ok": True, "n": len(out), "cosines": out})
+        except Exception as e:
+            self._send(500, {"ok": False, "err": str(e)})
+
     def _similar(self):
         # PDP color-dot → CLIP visual similarity. Contract: {dw_sku, hex, style, k}.
         # Looks up the product's OWN stored embedding by dw_sku (NO re-embed — the vector was

← ca63a1e auto-save: 2026-07-12T23:21:12 (1 files) — visual-search/.gi  ·  back to Dw Photo Capture  ·  visual-search: make bind host configurable via VS_HOST (defa a0e586b →