← back to Fischbacher Onboard
auto-save: 2026-07-22T11:43:42 (1 files) — enrich_palette.py
eb71455d845a34ec522470ae08b93c621d05776e · 2026-07-22 11:43:44 -0700 · Steve Abrams
Files touched
Diff
commit eb71455d845a34ec522470ae08b93c621d05776e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 11:43:44 2026 -0700
auto-save: 2026-07-22T11:43:42 (1 files) — enrich_palette.py
---
enrich_palette.py | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 88 insertions(+)
diff --git a/enrich_palette.py b/enrich_palette.py
new file mode 100644
index 0000000..6483f65
--- /dev/null
+++ b/enrich_palette.py
@@ -0,0 +1,88 @@
+#!/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)
← c22ea4b chore: lint, refactor, v1.0.0 (session close)
·
back to Fischbacher Onboard
·
(newest)