← back to Carnegie Import
enrich_palette.py
139 lines
#!/usr/bin/env python3
"""Carnegie palette enrichment — free local PIL ($0). Per sellable SKU:
download swatch image local, extract full palette (hex + coverage %), nearest
colorway name, primary hex + bucket, color_tags; then set activation_pass per
the 5-field + full-palette gate. Caches by image_url to avoid redundant fetches.
"""
import io, json, os, subprocess, sys, time, urllib.request
from PIL import Image
DSN = "host=/tmp dbname=dw_unified"
IMGDIR = os.path.expanduser("~/Projects/carnegie-import/images")
os.makedirs(IMGDIR, exist_ok=True)
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
# compact interior-designer colorway lexicon (name, r,g,b)
LEX = [
("White",245,245,245),("Alabaster",237,234,224),("Cream",243,235,215),("Ivory",250,246,228),
("Oatmeal",223,213,193),("Sand",214,196,166),("Beige",210,196,171),("Taupe",180,165,145),
("Greige",176,168,156),("Khaki",160,145,110),("Camel",176,140,98),("Gold",196,160,70),
("Yellow",220,200,60),("Mustard",190,150,40),("Orange",210,120,50),("Rust",150,80,45),
("Terracotta",180,95,70),("Red",180,50,50),("Burgundy",110,35,45),("Pink",210,150,160),
("Blush",220,190,185),("Purple",110,75,140),("Plum",95,60,90),("Navy",35,45,80),
("Blue",60,95,150),("Denim",75,105,140),("Sky",150,180,205),("Teal",40,120,120),
("Aqua",120,180,180),("Green",70,120,70),("Sage",150,165,140),("Olive",110,110,60),
("Forest",45,80,55),("Grey",150,150,150),("Silver",190,190,192),("Slate",100,110,120),
("Charcoal",70,72,78),("Graphite",55,55,58),("Black",30,30,30),("Brown",110,80,55),("Chocolate",75,55,40),
]
def nearest(r,g,b):
best=None;bd=1e9
for n,rr,gg,bb in LEX:
d=(r-rr)**2+(g-gg)**2+(b-bb)**2
if d<bd:bd=d;best=n
return best
def bucket(r,g,b):
mx,mn=max(r,g,b),min(r,g,b)
if mx<60:return "Dark"
if mn>200:return "Light"
if mx-mn<25:return "Neutral"
if r>=g and r>=b:return "Warm"
if b>=r and b>=g:return "Cool"
return "Warm" if g>=b else "Cool"
def palette_of(img):
im=img.convert("RGB");im.thumbnail((120,120))
q=im.quantize(colors=8,method=Image.FASTOCTREE)
pal=q.getpalette();counts=q.getcolors() or []
total=sum(c for c,_ in counts) or 1
out=[]
for cnt,idx in sorted(counts,reverse=True):
r,g,b=pal[idx*3:idx*3+3]
pct=round(100.0*cnt/total,1)
if pct<2:continue
out.append({"hex":"#%02x%02x%02x"%(r,g,b),"name":nearest(r,g,b),
"bucket":bucket(r,g,b),"pct":pct,"rgb":[r,g,b]})
# merge same-name
merged={}
for c in out:
k=c["name"]
if k in merged:merged[k]["pct"]=round(merged[k]["pct"]+c["pct"],1)
else:merged[k]=c
return sorted(merged.values(),key=lambda x:-x["pct"])
def fetch(url):
req=urllib.request.Request(url,headers={"User-Agent":UA})
with urllib.request.urlopen(req,timeout=40) as r:return r.read()
def psql(sql):
p=subprocess.run(["psql",DSN,"-v","ON_ERROR_STOP=1","-q"],input=sql,text=True,capture_output=True)
if p.returncode!=0:
sys.stderr.write(p.stderr[:1500]);raise SystemExit("psql fail")
def rows():
out=subprocess.run(["psql",DSN,"-tAF","\t","-c",
"SELECT id,dw_sku,image_url FROM carnegie_catalog WHERE palette IS NULL AND image_url IS NOT NULL ORDER BY id"],
text=True,capture_output=True).stdout.strip()
return [l.split("\t") for l in out.splitlines() if l]
def q(v):
if v is None:return "NULL"
if isinstance(v,(dict,list)):return "'"+json.dumps(v).replace("'","''")+"'"
return "'"+str(v).replace("'","''")+"'"
def arr(l):
if not l:return "NULL"
return "'{"+",".join('"'+str(x).replace('"','\\"')+'"' for x in l)+"}'"
def main():
todo=rows();n=len(todo);print(f"[enrich] {n} SKUs need palette",flush=True)
cache={};done=0;fail=0;buf=[]
for rid,dwsku,url in todo:
try:
if url in cache:
pal,lp=cache[url]
else:
b=fetch(url);img=Image.open(io.BytesIO(b))
pal=palette_of(img)
ext=".png" if (url.lower().split("?")[0].endswith("png")) else ".jpg"
lp=os.path.join(IMGDIR,f"{dwsku}{ext}")
with open(lp,"wb") as f:f.write(b)
cache[url]=(pal,lp)
time.sleep(0.15)
if not pal:raise ValueError("empty palette")
ctags=[c["name"] for c in pal[:3]]
phex=pal[0]["hex"];pbucket=pal[0]["bucket"]
buf.append((rid,pal,ctags,phex,pbucket,os.path.basename(lp)))
done+=1
except Exception:
buf.append((rid,None,None,None,None,None));fail+=1
if len(buf)>=100:
flush(buf);buf=[]
print(f"[enrich] {done+fail}/{n} ok={done} fail={fail}",flush=True)
if buf:flush(buf)
# activation gate
psql("""UPDATE carnegie_catalog SET
activation_pass = (local_image IS NOT NULL AND price>0 AND description_text IS NOT NULL
AND array_length(style_tags,1)>=2 AND palette IS NOT NULL AND array_length(color_tags,1)>=1),
activation_reasons = array_remove(ARRAY[
CASE WHEN local_image IS NULL THEN 'no_local_image' END,
CASE WHEN price IS NULL OR price<=0 THEN 'no_price' END,
CASE WHEN description_text IS NULL THEN 'no_desc' END,
CASE WHEN array_length(style_tags,1)<2 THEN 'lt2_style_tags' END,
CASE WHEN palette IS NULL THEN 'no_palette' END,
CASE WHEN array_length(color_tags,1)<1 THEN 'no_color_tags' END], NULL);""")
print(json.dumps({"enriched":done,"failed":fail,"total":n}),flush=True)
def flush(buf):
parts=[]
for rid,pal,ctags,phex,pbucket,lp in buf:
if pal is None:
parts.append(f"({rid},NULL,NULL,NULL,NULL,NULL)")
else:
parts.append(f"({rid},{q(pal)}::jsonb,{arr(ctags)},{q(phex)},{q(pbucket)},{q(lp)})")
sql=("UPDATE carnegie_catalog c SET palette=v.palette, color_tags=v.color_tags::text[], "
"primary_hex=v.primary_hex, color_bucket=v.color_bucket, local_image=v.local_image, updated_at=now() "
"FROM (VALUES\n"+",\n".join(parts)+
"\n) AS v(id,palette,color_tags,primary_hex,color_bucket,local_image) WHERE c.id=v.id;")
psql(sql)
if __name__=="__main__":main()