← back to Dw Photo Capture

visual-search/load_pg_fingerprints.py

105 lines

#!/usr/bin/env python3
"""Populate product_image_fingerprints (LOCAL Mac2 dw_unified mirror) from the existing CLIP
fingerprint store (image_embeddings — the table search_service.py loads into RAM). TK-12090 Lane P.

NOTE: vc_ids can be NEGATIVE (embed_shopify_gap.py placeholder keys for shopify-only actives,
~27k rows) — the scan cursor starts at bigint MIN and skips already-loaded rows by anti-join.

Set-based, idempotent (ON CONFLICT (source_vc_id, model_tag) DO NOTHING), resumable by vc_id.
Adds what image_embeddings lacks per the handbag-matcher design: shopify_product_id linkage,
product_status for SQL pre-filtering, model_tag versioning, created_at.

  DW_UNIFIED_DB=<uri> python3 load_pg_fingerprints.py --limit 5000            # sample run
  DW_UNIFIED_DB=<uri> python3 load_pg_fingerprints.py --vc-ids ids.txt        # explicit rows
  DW_UNIFIED_DB=<uri> python3 load_pg_fingerprints.py --full                  # all ~336k (disk-guarded)

A full load duplicates ~1 GB of vectors; it REFUSES unless the data volume has >= 3x the
estimated size free (Mac2 ran at 99% full on 2026-09-23). Undo: DROP TABLE product_image_fingerprints.
"""
import argparse, os, shutil, sys, time
import psycopg2

DB = os.environ.get("DW_UNIFIED_DB") or "postgresql://%s@/dw_unified?host=/tmp" % (
    os.environ.get("PGUSER") or os.environ.get("USER") or "")
# The catalog was embedded with open_clip ViT-B-32/laion2b + dw_clip_ft.pt when present
# (embed_catalog.py / search_service._get_model). Tag the weights so a re-embed never mixes spaces.
HERE = os.path.dirname(os.path.abspath(__file__))
MODEL_TAG = os.environ.get("FP_MODEL_TAG") or (
    "open_clip:ViT-B-32/laion2b_s34b_b79k" + ("+dw_clip_ft" if os.path.exists(os.path.join(HERE, "dw_clip_ft.pt")) else ""))

INSERT = """
INSERT INTO product_image_fingerprints
  (source_vc_id, shopify_product_id, dw_sku, mfr_sku, vendor_code, pattern_name, image_url,
   product_status, model_tag, dim, embedding)
SELECT e.vc_id,
       NULLIF(regexp_replace(sp.shopify_id, '\\D', '', 'g'), '')::bigint,
       e.dw_sku, e.mfr_sku, e.vendor_code, e.pattern_name, e.image_url,
       sp.status, %(tag)s, COALESCE(e.dim, 512), e.embedding
FROM image_embeddings e
LEFT JOIN LATERAL (
  SELECT s.shopify_id, s.status FROM shopify_products s
  WHERE s.dw_sku = e.dw_sku AND e.dw_sku IS NOT NULL
  ORDER BY (s.status = 'ACTIVE') DESC, s.id DESC LIMIT 1) sp ON true
WHERE e.embedding IS NOT NULL AND {where}
ON CONFLICT (source_vc_id, model_tag) DO NOTHING
"""

def main():
    ap = argparse.ArgumentParser()
    g = ap.add_mutually_exclusive_group(required=True)
    g.add_argument("--limit", type=int)
    g.add_argument("--vc-ids")
    g.add_argument("--full", action="store_true")
    ap.add_argument("--batch", type=int, default=20000)
    ap.add_argument("--check-disk", action="store_true", help="with --full: run only the disk guard, load nothing")
    a = ap.parse_args()
    conn = psycopg2.connect(DB); conn.autocommit = False; cur = conn.cursor()
    cur.execute("select to_regclass('product_image_fingerprints')")
    if cur.fetchone()[0] is None:
        sys.exit("product_image_fingerprints missing — apply sql/product_image_fingerprints.sql first")
    t0 = time.time(); inserted = 0
    if a.vc_ids:
        ids = [int(x) for x in open(a.vc_ids).read().split() if x.strip().lstrip("-").isdigit()]   # vc_ids may be negative
        cur.execute(INSERT.format(where="e.vc_id = ANY(%(ids)s)"), {"tag": MODEL_TAG, "ids": ids})
        inserted = cur.rowcount; conn.commit()
    else:
        if a.full:
            cur.execute("select pg_total_relation_size('image_embeddings'), current_setting('data_directory')")
            est, datadir = cur.fetchone()
            du = shutil.disk_usage(datadir)
            # Need room for table + WAL churn (~3x est) AND must leave >= 10% of the volume free
            # afterwards. (A plain `free < 3*est` test PASSED at 99% full on 2026-09-23 — 9 GB free
            # vs a 1 GB table — and pushed the disk to 3.4 GB free. Headroom is judged vs the VOLUME.)
            after = du.free - 3 * est
            if after < 0.10 * du.total:
                sys.exit("REFUSING --full: %.1f GB free, needs ~%.1f GB, would leave %.1f GB < 10%% of %.0f GB volume (%s)"
                         % (du.free / 1e9, 3 * est / 1e9, after / 1e9, du.total / 1e9, datadir))
            if a.check_disk:
                print("disk guard OK: %.1f GB would remain" % (after / 1e9)); return
        # Cursor starts at bigint MIN every run (vc_ids can be negative) and skips rows already
        # loaded via an anti-join — so an earlier out-of-order --vc-ids load can't make a max()
        # cursor jump past unloaded rows.
        last = -9223372036854775808
        remaining = a.limit if a.limit else None
        while remaining is None or remaining > 0:
            n = min(a.batch, remaining) if remaining is not None else a.batch
            cur.execute("select e.vc_id from image_embeddings e where e.vc_id > %s and e.embedding is not null "
                        "and not exists (select 1 from product_image_fingerprints f where f.source_vc_id = e.vc_id and f.model_tag = %s) "
                        "order by e.vc_id limit %s", (last, MODEL_TAG, n))
            ids = [r[0] for r in cur.fetchall()]
            if not ids: break
            cur.execute(INSERT.format(where="e.vc_id = ANY(%(ids)s)"), {"tag": MODEL_TAG, "ids": ids})
            inserted += cur.rowcount; conn.commit(); last = ids[-1]
            if remaining is not None: remaining -= len(ids)
            print("  batch -> vc_id<=%s inserted_total=%d" % (last, inserted), flush=True)
    cur.execute("select count(*), count(shopify_product_id), count(*) filter (where product_status='ACTIVE'), "
                "pg_size_pretty(pg_total_relation_size('product_image_fingerprints')) "
                "from product_image_fingerprints where model_tag=%s", (MODEL_TAG,))
    total, linked, active, size = cur.fetchone()
    print("model_tag=%s inserted=%d table_rows=%d linked_shopify=%d active=%d size=%s in %.1fs"
          % (MODEL_TAG, inserted, total, linked, active, size, time.time() - t0))
    conn.close()

if __name__ == "__main__":
    main()