← back to Sister Parish Onboarding

scripts/cross_match_pd.py

125 lines

#!/usr/bin/env python3
"""
For each Sister Parish pattern, find the 3 nearest public-domain pattern
images (from wallco.ai's pd_source_designs) by color distance in CIE Lab space.

Lab distance is perceptually meaningful where raw RGB or HSL is not —
a sage green and an olive green are close in Lab, far apart in RGB.

Output: dw_unified.sp_pd_color_matches join table.
Idempotent: TRUNCATE + re-insert each run.
"""
import json
import subprocess
from typing import List, Tuple

def psql_json(sql: str):
    out = subprocess.run(['psql', 'dw_unified', '-At', '-q', '-c', sql],
                         capture_output=True, text=True, check=True).stdout.strip()
    return json.loads(out) if out else []

def hex_to_rgb(h: str):
    if not h or not h.startswith('#'): return None
    h = h.lstrip('#')
    if len(h) != 6: return None
    return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)

# sRGB → linear → XYZ → Lab (D65). Standard formulas.
def srgb_to_linear(c):
    c /= 255.0
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4

def rgb_to_lab(rgb):
    r, g, b = (srgb_to_linear(v) for v in rgb)
    # sRGB D65 matrix
    x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375
    y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750
    z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041
    # D65 reference white
    xn, yn, zn = 0.95047, 1.00000, 1.08883
    def f(t):
        return t ** (1/3) if t > 0.008856 else (7.787 * t + 16/116)
    L = 116 * f(y / yn) - 16
    a = 500 * (f(x / xn) - f(y / yn))
    b2 = 200 * (f(y / yn) - f(z / zn))
    return (L, a, b2)

def lab_dist(a, b):
    return ((a[0]-b[0])**2 + (a[1]-b[1])**2 + (a[2]-b[2])**2) ** 0.5

# Pull data
sp = psql_json("SELECT COALESCE(json_agg(t),'[]'::json) FROM (SELECT sp_product_id, title, dominant_hex FROM sisterparish_catalog WHERE dominant_hex IS NOT NULL ORDER BY sp_product_id) t;")
pd = psql_json("SELECT COALESCE(json_agg(t),'[]'::json) FROM (SELECT id, source, title, dominant_hex, source_url, local_path FROM pd_source_designs WHERE dominant_hex IS NOT NULL) t;")

print(f"SP patterns with hex: {len(sp)}")
print(f"PD images  with hex: {len(pd)}")
if not sp or not pd:
    print("Need both corpora populated; exiting")
    raise SystemExit(0)

# Pre-compute PD Lab values
pd_lab = []
for p in pd:
    rgb = hex_to_rgb(p['dominant_hex'])
    if rgb is None: continue
    pd_lab.append((p, rgb_to_lab(rgb)))

# Match each SP
matches = []
for s in sp:
    rgb = hex_to_rgb(s['dominant_hex'])
    if rgb is None: continue
    sl = rgb_to_lab(rgb)
    scored = [(lab_dist(sl, p_lab), p) for (p, p_lab) in pd_lab]
    scored.sort(key=lambda x: x[0])
    for rank, (dist, pmatch) in enumerate(scored[:3], 1):
        matches.append((s['sp_product_id'], s['title'], s['dominant_hex'],
                        pmatch['id'], pmatch['source'], pmatch['title'],
                        pmatch['dominant_hex'], pmatch['source_url'], pmatch['local_path'],
                        rank, round(dist, 2)))

# Write to PG
ddl = """
CREATE TABLE IF NOT EXISTS sp_pd_color_matches (
  sp_product_id   BIGINT NOT NULL,
  sp_title        TEXT,
  sp_hex          TEXT,
  pd_id           BIGINT NOT NULL,
  pd_source       TEXT,
  pd_title        TEXT,
  pd_hex          TEXT,
  pd_source_url   TEXT,
  pd_local_path   TEXT,
  rank            INTEGER,
  lab_distance    NUMERIC(8,2),
  computed_at     TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (sp_product_id, pd_id)
);
TRUNCATE sp_pd_color_matches;
"""
subprocess.run(['psql', 'dw_unified', '-q', '-c', ddl], check=True)

def esc(v):
    if v is None: return 'NULL'
    return "'" + str(v).replace("'", "''") + "'"

with open('/tmp/sp_pd_matches.sql', 'w') as f:
    f.write("BEGIN;\n")
    for m in matches:
        sp_id, sp_t, sp_h, pd_id, pd_src, pd_t, pd_h, pd_u, pd_lp, rank, dist = m
        f.write(f"INSERT INTO sp_pd_color_matches (sp_product_id,sp_title,sp_hex,pd_id,pd_source,pd_title,pd_hex,pd_source_url,pd_local_path,rank,lab_distance) "
                f"VALUES ({sp_id},{esc(sp_t)},{esc(sp_h)},{pd_id},{esc(pd_src)},{esc(pd_t)},{esc(pd_h)},{esc(pd_u)},{esc(pd_lp)},{rank},{dist});\n")
    f.write("COMMIT;\n")
subprocess.run(['psql', 'dw_unified', '-q', '-f', '/tmp/sp_pd_matches.sql'], check=True)
import os; os.unlink('/tmp/sp_pd_matches.sql')

print(f"\nWrote {len(matches)} matches ({len(sp)} SP × 3 nearest PD)")

# Spot-check 5
print("\nSpot-check (5 SP patterns + nearest PD):")
out = subprocess.run(['psql', 'dw_unified', '-c',
    "SELECT sp_title, sp_hex, pd_title, pd_hex, pd_source, lab_distance "
    "FROM sp_pd_color_matches WHERE rank=1 ORDER BY lab_distance LIMIT 5;"],
    capture_output=True, text=True)
print(out.stdout)