← back to Dw Photo Capture

visual-search/embed_catalog.py

91 lines

#!/usr/bin/env python3
# Batch CLIP-embed the dw_unified catalog images for visual product search.
# Downloads each vendor_catalog.image_url, embeds with open_clip ViT-B-32 (laion2b), stores the
# L2-normalized float32 vector in image_embeddings. Resumable: rows already in image_embeddings
# (success OR permanent-fail placeholder with NULL embedding) are skipped. $0 (local CPU).
#
# Usage:  DW_UNIFIED_DB=<uri> python3 embed_catalog.py [--limit N] [--batch 32] [--workers 24]
import os, sys, io, time, struct, 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")

import torch as _t, os as _o
_ft = _o.path.join(_o.path.dirname(_o.path.abspath(__file__)), "dw_clip_ft.pt")
if _o.path.exists(_ft):
    model.load_state_dict(_t.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):
    vc_id, dw_sku, mfr, vc, pat, 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 (vc_id, dw_sku, mfr, vc, pat, url, preprocess(img))
    except Exception:
        return (vc_id, dw_sku, mfr, vc, pat, url, None)   # permanent-fail placeholder

def main():
    conn = psycopg2.connect(DB); conn.autocommit = False
    total_done = 0; t0 = time.time()
    while True:
        cur = conn.cursor()
        cur.execute("""select v.id, v.dw_sku, v.mfr_sku, v.vendor_code, v.pattern_name, v.image_url
                       from vendor_catalog v left join image_embeddings e on e.vc_id=v.id
                       where v.image_url is not null and v.image_url<>'' and e.vc_id is null
                       limit %s""", (args.chunk,))
        rows = cur.fetchall(); cur.close()
        if not rows: break
        # download concurrently
        got = []
        with cf.ThreadPoolExecutor(max_workers=args.workers) as ex:
            got = list(ex.map(fetch, rows))
        ok = [g for g in got if g[6] is not None]
        bad = [g for g in got if g[6] is None]
        # embed in batches
        recs = []
        for i in range(0, len(ok), args.batch):
            b = ok[i:i+args.batch]
            with torch.no_grad():
                t = torch.stack([g[6] 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):
                recs.append((g[0], g[1], g[2], g[3], g[4], g[5], DIM, psycopg2.Binary(emb.tobytes())))
        # write successes + permanent-fail placeholders (NULL embedding) so they aren't retried
        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,image_url,embedding) values (%s,%s,NULL)
                               on conflict (vc_id) do nothing""", [(g[0], g[5]) 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()