← back to Fischbacher Onboard
enrich_palette.py
89 lines
#!/usr/bin/env python3
"""Palette enrichment for the staged Christian Fischbacher catalog ($0, local PIL).
Satisfies Steve's HARD RULE: every SKU carries a full palette (all colors + hex + %).
Reads dw_unified.fischbacher_catalog image_url, median-cut quantizes -> named colorways
via the interior-designer lexicon, writes palette JSONB back to the table AND pushes an
`enrich.palette` metafield onto each draft Shopify product (via pushed_manifest.json)."""
import json, io, os, sys, time, subprocess, urllib.request, urllib.error
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from PIL import Image
# --- colorway lexicon (from ~/Projects/dw-discovery/enrich_idesign.py) ---
LEX={"Chalk":"#f3f0e9","Alabaster":"#eee9df","Ivory":"#efe7d3","Cream":"#efe2c6","Linen":"#e9e0cf","Bone":"#e3d9c6","Porcelain":"#ece9e2","Oatmeal":"#d9cdb8","Flax":"#ddceac","Greige":"#cabfae","Mushroom":"#b9ad9c","Putty":"#c8bca9","Stone":"#b8b0a3","Sand":"#d8c7a6","Taupe":"#b6a892","Pebble":"#c2bbb0","Tan":"#c9a875","Camel":"#bf9b6a","Khaki":"#b3a06e","Caramel":"#a9743f","Chestnut":"#6e4326","Tobacco":"#7a5a36","Walnut":"#5a4632","Mocha":"#6f5847","Espresso":"#3f2f25","Dove":"#cfccc6","Silver":"#c8c8c8","Ash":"#b2b2ad","Fog":"#c4c8c9","Smoke":"#9b9d9c","Pewter":"#8e9192","Slate":"#5d676d","Graphite":"#454a4d","Charcoal":"#3a3a3a","Ink":"#1f2730","Noir":"#171717","Onyx":"#222124","Ebony":"#2a2320","Jet":"#101012","Celadon":"#9bb6a1","Pistachio":"#b6c79a","Sage":"#9caa8a","Eucalyptus":"#8fa595","Moss":"#6f7d4e","Olive":"#7a7a45","Loden":"#4f5d3a","Hunter":"#33523f","Forest":"#2f4a36","Emerald":"#2f7a55","Verdigris":"#5e9a8a","Powder":"#cfe0ea","Sky":"#a9cbe0","Wedgwood":"#7fa0c0","Cornflower":"#7a93d0","French Blue":"#5b7fb0","Cerulean":"#3f7fb0","Denim":"#4b6786","Teal":"#357a7d","Peacock":"#1f6f78","Prussian":"#28415c","Indigo":"#33406b","Navy":"#27314f","Midnight":"#1b2436","Buttercream":"#f3e7b8","Champagne":"#e6d6a8","Maize":"#e6cf6b","Citrine":"#d8c047","Saffron":"#d99e2b","Amber":"#c9962f","Ochre":"#b5852f","Mustard":"#c9a227","Gold":"#c4a35a","Brass":"#b08d57","Apricot":"#e7b48a","Peach":"#e9c1a3","Clay":"#b97a55","Terracotta":"#b5683f","Persimmon":"#c75c39","Pumpkin":"#c4702a","Copper":"#a5683f","Rust":"#9a5230","Sienna":"#8a4b32","Blush":"#e7c4c2","Rose":"#d99fa6","Dusty Rose":"#c08f90","Coral":"#e3795f","Cinnabar":"#b8432f","Brick":"#9a4435","Oxblood":"#5e2526","Claret":"#6e2233","Garnet":"#7a2030","Burgundy":"#6e2233","Crimson":"#a01f2e","Lavender":"#cdc1de","Lilac":"#c7b1d6","Heather":"#a892ad","Mauve":"#a98792","Amethyst":"#8a6aa0","Plum":"#6a4768","Aubergine":"#4a2f44","Platinum":"#d6d3cb","Bronze":"#8a6a40","Gunmetal":"#4d5356"}
FAM={'Chalk':'White','Alabaster':'White','Ivory':'White','Cream':'Cream','Linen':'Cream','Bone':'White','Porcelain':'White','Oatmeal':'Beige','Flax':'Beige','Greige':'Neutral','Mushroom':'Neutral','Putty':'Neutral','Stone':'Neutral','Sand':'Beige','Taupe':'Brown','Pebble':'Neutral','Tan':'Brown','Camel':'Brown','Khaki':'Brown','Caramel':'Brown','Chestnut':'Brown','Tobacco':'Brown','Walnut':'Brown','Mocha':'Brown','Espresso':'Brown','Dove':'Gray','Silver':'Gray','Ash':'Gray','Fog':'Gray','Smoke':'Gray','Pewter':'Gray','Slate':'Gray','Graphite':'Gray','Charcoal':'Gray','Ink':'Black','Noir':'Black','Onyx':'Black','Ebony':'Black','Jet':'Black','Celadon':'Green','Pistachio':'Green','Sage':'Green','Eucalyptus':'Green','Moss':'Green','Olive':'Green','Loden':'Green','Hunter':'Green','Forest':'Green','Emerald':'Green','Verdigris':'Green','Powder':'Blue','Sky':'Blue','Wedgwood':'Blue','Cornflower':'Blue','French Blue':'Blue','Cerulean':'Blue','Denim':'Blue','Teal':'Aqua','Peacock':'Aqua','Prussian':'Blue','Indigo':'Blue','Navy':'Blue','Midnight':'Blue','Buttercream':'Yellow','Champagne':'Gold','Maize':'Yellow','Citrine':'Yellow','Saffron':'Gold','Amber':'Gold','Ochre':'Gold','Mustard':'Yellow','Gold':'Gold','Brass':'Gold','Apricot':'Orange','Peach':'Orange','Clay':'Terracotta','Terracotta':'Terracotta','Persimmon':'Orange','Pumpkin':'Orange','Copper':'Orange','Rust':'Orange','Sienna':'Brown','Blush':'Pink','Rose':'Pink','Dusty Rose':'Pink','Coral':'Coral','Cinnabar':'Red','Brick':'Red','Oxblood':'Burgundy','Claret':'Burgundy','Garnet':'Burgundy','Burgundy':'Burgundy','Crimson':'Red','Lavender':'Purple','Lilac':'Purple','Heather':'Purple','Mauve':'Purple','Amethyst':'Purple','Plum':'Purple','Aubergine':'Purple','Platinum':'Gray','Bronze':'Gold','Gunmetal':'Gray'}
hx=lambda h:tuple(int(h.lstrip('#')[i:i+2],16) for i in (0,2,4))
LV=[(n,hx(v)) for n,v in LEX.items()]
def nearest(rgb): return min(LV,key=lambda kv:sum((a-b)**2 for a,b in zip(rgb,kv[1])))[0]
def palette(url):
raw=urllib.request.urlopen(urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'}),timeout=25).read()
im=Image.open(io.BytesIO(raw)).convert('RGB').resize((100,100))
q=im.quantize(colors=8,method=Image.MEDIANCUT)
pal=q.getpalette(); idxs=list(q.getdata()); total=len(idxs); cnt=Counter(idxs)
merged={}
for idx,c in cnt.most_common():
rgb=(pal[idx*3],pal[idx*3+1],pal[idx*3+2]); name=nearest(rgb); pct=round(100*c/total)
if name in merged: merged[name]['pct']+=pct
else: merged[name]={'hex':'#%02x%02x%02x'%rgb,'name':name,'bucket':FAM.get(name,'Neutral'),'pct':pct}
return sorted([c for c in merged.values() if c['pct']>=3],key=lambda c:-c['pct'])[:6]
# --- load rows ---
sql="SELECT json_agg(json_build_object('sku',mfr_sku,'url',image_url)) FROM fischbacher_catalog WHERE image_url IS NOT NULL;"
rows=json.loads(subprocess.run(["psql","host=/tmp dbname=dw_unified","-tAc",sql],capture_output=True,text=True).stdout.strip())
print(f"enriching {len(rows)} patterns",file=sys.stderr)
def work(r):
try:
p=palette(r['url'])
return (r['sku'], p if p else None)
except Exception:
return (r['sku'], None)
res={}
with ThreadPoolExecutor(max_workers=16) as ex:
for sku,p in ex.map(work,rows):
if p: res[sku]=p
# --- write palette JSONB back to staging ---
def psql_write(sql,inp): return subprocess.run(["psql","host=/tmp dbname=dw_unified","-v","ON_ERROR_STOP=1","-c",sql],input=inp,capture_output=True,text=True)
vals=[]
for sku,p in res.items():
vals.append((sku,json.dumps(p)))
# use a temp COPY + UPDATE join
import csv
buf=io.StringIO(); w=csv.writer(buf)
for sku,pj in vals: w.writerow([sku,pj])
upd=("CREATE TEMP TABLE _pal(sku text, palette jsonb); "
"COPY _pal(sku,palette) FROM STDIN WITH (FORMAT csv); "
"UPDATE fischbacher_catalog f SET palette=_pal.palette FROM _pal WHERE f.mfr_sku=_pal.sku;")
r=psql_write(upd,buf.getvalue())
print("DB update:",("OK" if r.returncode==0 else r.stderr[:300]),file=sys.stderr)
# --- push enrich.palette metafield onto each draft Shopify product ---
STORE="designer-laboratory-sandbox.myshopify.com"; API="2024-10"
ENV="/Users/macstudio3/Projects/secrets-manager/.env"
TOKEN=next(l.split("=",1)[1].strip() for l in open(ENV,encoding="utf-8") if l.startswith("SHOPIFY_ADMIN_TOKEN="))
manifest=json.load(open("/Users/macstudio3/Projects/fischbacher-onboard/data/pushed_manifest.json"))
HDR={"X-Shopify-Access-Token":TOKEN,"Content-Type":"application/json"}
pushed=failed=0
for sku,p in res.items():
m=manifest.get(sku)
if not m: continue
pid=m["id"]; primary=p[0]
body={"metafields":[
{"namespace":"enrich","key":"palette","value":json.dumps(p),"type":"json"},
{"namespace":"enrich","key":"color_primary","value":primary["name"],"type":"single_line_text_field"},
{"namespace":"enrich","key":"color_bucket","value":primary["bucket"],"type":"single_line_text_field"},
]}
try:
req=urllib.request.Request(f"https://{STORE}/admin/api/{API}/products/{pid}.json",
method="PUT",data=json.dumps({"product":{"id":pid,**body}}).encode(),headers=HDR)
urllib.request.urlopen(req,timeout=40).read()
pushed+=1
except Exception as e:
failed+=1
if failed<=5: print(f" mf FAIL {sku}: {e}",file=sys.stderr)
time.sleep(0.55)
print(f"PALETTE DONE: computed={len(res)}/{len(rows)} · db_written={len(vals)} · metafields_pushed={pushed} failed={failed}",file=sys.stderr)