← back to Sister Parish Onboarding
scripts/extract_dominant_hex.py
97 lines
#!/usr/bin/env python3
"""
Per-product dominant-color extraction for Sister Parish catalog.
Standing rule (memory: feedback_sort_skill_canonical): real-pixel hex beats
tag-derived hex. Run sips-style "shrink to 1x1, read pixel" via Pillow.
Strategy per image:
- Download primary_image to /tmp/sp_imgs/
- Resize to 200x200 (preserve detail), then bucket to dominant color
using k=5 KMeans-lite via Pillow.quantize (mode P, 5 colors)
- Persist {dominant_hex, palette[]} to dw_unified.sisterparish_catalog.colors JSONB
"""
import os, json, subprocess, time, urllib.request, ssl
from pathlib import Path
from PIL import Image
from collections import Counter
ROOT = Path.home() / 'Projects' / 'sister-parish-onboarding'
IMGDIR = Path('/tmp/sp_imgs'); IMGDIR.mkdir(exist_ok=True)
CTX = ssl.create_default_context()
def fetch(url, dest):
if dest.exists() and dest.stat().st_size > 1000:
return True
try:
req = urllib.request.Request(url, headers={'User-Agent':'Mozilla/5.0 (DW)'})
with urllib.request.urlopen(req, timeout=20, context=CTX) as r, dest.open('wb') as f:
f.write(r.read())
return True
except Exception as e:
print(f" fetch fail: {e}")
return False
def dominant_palette(path, k=5):
img = Image.open(path).convert('RGB')
img.thumbnail((200,200))
pal = img.quantize(colors=k, method=Image.Quantize.MEDIANCUT).convert('RGB')
pixels = list(pal.getdata())
cnt = Counter(pixels)
total = sum(cnt.values())
top = cnt.most_common(k)
palette = [{'hex': '#{:02x}{:02x}{:02x}'.format(*rgb), 'pct': round(n/total*100, 2)} for rgb,n in top]
return palette
def list_products():
sql = "SELECT json_agg(t) FROM (SELECT sp_product_id, title, primary_image FROM sisterparish_catalog WHERE primary_image IS NOT NULL ORDER BY title) t;"
out = subprocess.run(['psql','dw_unified','-At','-q','-c',sql], capture_output=True, text=True, check=True).stdout.strip()
return json.loads(out)
def ensure_column():
subprocess.run(['psql','dw_unified','-q','-c',
"ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS colors JSONB; ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS dominant_hex TEXT;"], check=True)
def save_to_pg(sp_id, dominant_hex, palette):
sql = f"UPDATE sisterparish_catalog SET dominant_hex='{dominant_hex}', colors='{json.dumps(palette).replace(chr(39), chr(39)+chr(39))}'::jsonb WHERE sp_product_id={sp_id};"
subprocess.run(['psql','dw_unified','-q','-c',sql], check=True)
def main():
ensure_column()
products = list_products()
print(f"{len(products)} products with primary_image")
sql_lines = []
failed = 0
for i, p in enumerate(products, 1):
url = p['primary_image']
ext = url.split('?')[0].rsplit('.', 1)[-1].lower()
if ext not in ('jpg','jpeg','png','webp'):
ext = 'jpg'
dest = IMGDIR / f"sp_{p['sp_product_id']}.{ext}"
ok = fetch(url, dest)
if not ok:
failed += 1
print(f"[{i:03}/{len(products)}] ✗ {p['title'][:50]}")
continue
try:
palette = dominant_palette(dest)
dominant = palette[0]['hex']
sql_lines.append((p['sp_product_id'], dominant, palette))
print(f"[{i:03}/{len(products)}] {dominant} {p['title'][:50]}")
except Exception as e:
failed += 1
print(f"[{i:03}/{len(products)}] palette err: {e} {p['title'][:50]}")
# Bulk update
fp = Path('/tmp/sp_colors.sql')
with fp.open('w') as f:
f.write("BEGIN;\n")
for sp_id, dh, pal in sql_lines:
pal_str = json.dumps(pal).replace("'", "''")
f.write(f"UPDATE sisterparish_catalog SET dominant_hex='{dh}', colors='{pal_str}'::jsonb WHERE sp_product_id={sp_id};\n")
f.write("COMMIT;\n")
subprocess.run(['psql','dw_unified','-q','-f', str(fp)], check=True)
fp.unlink()
print(f"\nUpdated {len(sql_lines)} rows · failed {failed}")
if __name__ == '__main__':
main()