← back to Japan Enrich

enrich.py

109 lines

#!/usr/bin/env python3
"""
Japan microsite import enricher — per Steve's standing rule (2026-06-30):
extract hex# / style / material for every item so the catalog is searchable.

Engine: LOCAL HYBRID ($0) — Pillow ground-truth hex + qwen2.5vl semantics.
  hex     : Pillow median-cut palette (dominant colorway colors + coverage %)
  style   : qwen2.5vl, constrained to the interior-designer style lexicon
  material: qwen2.5vl, constrained to the surface-material lexicon

Usage: enrich.py <images_dir> <staging.jsonl> <out.jsonl> [--limit N] [--ollama URL]
Maps an image to a record by the SKU token in the filename (LIS-42003 -> LIS42003).
"""
import sys, os, json, ast, base64, re, glob, urllib.request, colorsys

OLLAMA = "http://127.0.0.1:11434"   # Mac2 failover default (Mac1 LAN wedged)
VMODEL = "qwen2.5vl:7b"

# interior-designer lexicon (constrained vocab so facets stay clean/searchable)
STYLES = ["Traditional","Modern","Floral","Damask","Geometric","Stripe","Texture/Plain",
          "Chinoiserie","Toile","Ikat","Botanical","Abstract","Animal","Trellis","Moiré",
          "Grasscloth/Natural","Metallic","Childrens","Mural/Scenic"]
MATERIALS = ["Vinyl","Non-woven","Paper","Grasscloth","Silk","Linen/Fabric-backed",
             "Cork","Mica/Mineral","Metallic foil","Wood veneer","Leather","Glass bead"]

def norm_sku(s): return re.sub(r'[^A-Za-z0-9]','',(s or '')).upper()

def hex_palette(path, k=6):
    from PIL import Image
    im = Image.open(path).convert("RGB")
    im.thumbnail((200,200))
    q = im.quantize(colors=k, method=Image.MEDIANCUT)
    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]
        out.append({"hex":"#%02X%02X%02X"%(r,g,b), "pct":round(100*cnt/total,1)})
    return out

def dominant_family(hexes):
    """coarse color-family bucket for the nav facet, from the top swatch"""
    if not hexes: return None
    h=hexes[0]["hex"].lstrip("#")
    r,g,b=[int(h[i:i+2],16)/255 for i in (0,2,4)]
    hh,ss,vv=colorsys.rgb_to_hsv(r,g,b)
    if vv<0.18: return "Black"
    if ss<0.12 and vv>0.85: return "White"
    if ss<0.15: return "Grey"
    if ss<0.30 and vv>0.55: return "Neutral/Beige"
    deg=hh*360
    for lo,hi,name in [(0,15,"Red"),(15,45,"Orange/Brown"),(45,70,"Yellow"),
                       (70,160,"Green"),(160,200,"Teal"),(200,255,"Blue"),
                       (255,290,"Purple"),(290,335,"Pink"),(335,360,"Red")]:
        if lo<=deg<hi: return name
    return "Red"

def vision_tags(path):
    with open(path,"rb") as f: b64=base64.b64encode(f.read()).decode()
    prompt=(f"This is a wallcovering product swatch. Choose the single best STYLE from "
            f"{STYLES} and the single best MATERIAL from {MATERIALS}. "
            f'Reply ONLY compact JSON: {{"style":"...","material":"..."}}')
    body=json.dumps({"model":VMODEL,"prompt":prompt,"images":[b64],"stream":False,
                     "format":"json","options":{"num_predict":60,"temperature":0}}).encode()
    req=urllib.request.Request(OLLAMA+"/api/generate",body,{"Content-Type":"application/json"})
    try:
        resp=json.load(urllib.request.urlopen(req,timeout=120)).get("response","{}")
        d=json.loads(resp)
        st=d.get("style"); mt=d.get("material")
        return (st if st in STYLES else None, mt if mt in MATERIALS else None)
    except Exception as e:
        return (None, None)

def main():
    images_dir, staging, out = sys.argv[1], sys.argv[2], sys.argv[3]
    limit = None
    if "--limit" in sys.argv: limit=int(sys.argv[sys.argv.index("--limit")+1])
    if "--ollama" in sys.argv:
        global OLLAMA; OLLAMA=sys.argv[sys.argv.index("--ollama")+1]
    # index images by normalized SKU (prefer C=colorway kind)
    img_by_sku={}
    for p in glob.glob(os.path.join(images_dir,"**","*.*"),recursive=True):
        if not p.lower().endswith((".jpg",".jpeg",".png")): continue
        tok=os.path.basename(p).split("_")[0]   # LIS-42003
        sku=norm_sku(tok)
        if "_C_" in p or sku not in img_by_sku: img_by_sku[sku]=p
    rows=[json.loads(l) for l in open(staging) if l.strip()]
    n=0; enriched=0; w=open(out,"w")
    for r in rows:
        sku=norm_sku(r.get("mfr_sku"))
        p=img_by_sku.get(sku)
        if p:
            hexes=hex_palette(p)
            style,material=vision_tags(p)
            r["enrich"]={"hex":hexes,"color_family":dominant_family(hexes),
                         "style":style,"material":material,"img":os.path.basename(p),
                         "engine":"local-hybrid:pillow+qwen2.5vl:7b"}
            enriched+=1
            print(f"  {sku:12} {dominant_family(hexes):14} {str(style):20} {str(material):18} {hexes[0]['hex'] if hexes else ''}",flush=True)
        w.write(json.dumps(r,ensure_ascii=False)+"\n")
        n+=1
        if limit and enriched>=limit:
            for rest in rows[n:]: w.write(json.dumps(rest,ensure_ascii=False)+"\n")
            break
    w.close()
    print(f"\nenriched {enriched} / {n} rows  → {out}")

if __name__=="__main__": main()