← back to Dw Color Image Audit

scripts/reextract.py

139 lines

#!/usr/bin/env python3
"""Re-extract TRUE dominant color from real product images and classify vs declared name.
Reads TSV lines on stdin: vendor, handle, dwsku, mfrsku, title, cached_hex, image_url
Writes JSON-lines on stdout. Local, free. Center-crops to avoid white borders."""
import sys, json, io, urllib.request, colorsys
from collections import Counter
try:
    from PIL import Image
except Exception as e:
    sys.stderr.write("PIL missing\n"); sys.exit(1)

CHROM = ["red","orange","yellow","chartreuse","green","teal","cyan","blue","indigo","violet","magenta","pink"]
WHEEL = CHROM
def fam(r,g,b):
    h,l,s = colorsys.rgb_to_hls(r/255,g/255,b/255)
    spread = (max(r,g,b)-min(r,g,b))/255
    if l>0.86 and spread<0.22: return "white", s, l
    if l<0.16 and spread<0.22: return "black", s, l
    if s<0.15: return "gray", s, l
    H = h*360
    if s<0.45 and 18<=H<=65: return ("beige" if l>0.55 else "brown"), s, l
    bands=[(345,361,"red"),(0,15,"red"),(15,40,"orange"),(40,67,"yellow"),(67,95,"chartreuse"),
           (95,150,"green"),(150,175,"teal"),(175,195,"cyan"),(195,240,"blue"),(240,265,"indigo"),
           (265,290,"violet"),(290,320,"magenta"),(320,345,"pink")]
    for a,b2,f in bands:
        if a<=H<b2: return f, s, l
    return "red", s, l

NAME_FAMILY = json.loads(open(sys.argv[1]).read()) if len(sys.argv)>1 else {}

# Substrate/finish words that name the material, never the colorway. As a trailing token
# "leather"->brown used to hijack the family (DW titles read "Pattern - Colorway Material").
# linen/silk/raffia/jute/grasscloth intentionally excluded — they double as beige colorways.
MATERIAL_STOPWORDS = {"vegan","leather","suede","vinyl","velvet","wallcovering","wallpaper",
                      "commercial","contract","fabric","paper","cloth","faux","textile","weave"}
# Multi-word colorways whose family != any single token's (sea-green is teal, per the lexicon's
# seafoam/seaglass->teal). Checked before single tokens.
MULTIWORD_FAMILY = {"sea green":"teal","sea foam":"teal","sea glass":"teal","sea blue":"teal"}
# Botanical/place/motif homonyms that ARE in the lexicon but, in a DW title, almost always name
# the PATTERN/theme/material, not the colorway. They only mislead on the NO-DASH / whole-title
# path; on the clean after-dash colorway segment the word genuinely IS the colorway, so demotion
# is gated to the no-dash path (see name_family). Demotion only bites when the homonym is the SOLE
# color signal -> fall through to UNKNOWN (DTD verdict A 3/3, 2026-06-21). Mirror of JS.
AMBIGUOUS_COLORWORDS = {"grass","ocean","marine","leaf","apple","forest"}

def _colorway_segment(name):
    s=(name or "").strip(); i=s.rfind(" - ")
    return s[i+3:] if i>=0 else s

def _family_from_phrase(phrase, demote_ambiguous=False):
    toks=''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in phrase).split()
    mw=None
    for i in range(len(toks)-1):
        bg=toks[i]+" "+toks[i+1]
        if bg in MULTIWORD_FAMILY: mw=MULTIWORD_FAMILY[bg]
    if mw: return mw
    mappable=[t for t in toks if t in NAME_FAMILY]
    non_mat=[t for t in mappable if t not in MATERIAL_STOPWORDS]
    pool=non_mat if non_mat else mappable
    if demote_ambiguous:
        non_amb=[t for t in pool if t not in AMBIGUOUS_COLORWORDS]
        if non_amb: pool=non_amb        # a real colorway survives -> use it
        else: return None               # only pattern/place/material homonyms -> UNKNOWN
    fam_=None
    for t in pool: fam_=NAME_FAMILY[t]
    return fam_

def name_family(name):
    seg=_colorway_segment(name)
    has_dash = seg != (name or "").strip()
    # After-dash colorway is the declared colorway verbatim -> no demotion ("X - Ocean" IS teal).
    # No-dash whole title mixes pattern+colorway -> demote ambiguous homonyms.
    fam_=_family_from_phrase(seg, demote_ambiguous=not has_dash)
    if fam_ is None and has_dash:
        fam_=_family_from_phrase(name, demote_ambiguous=True)   # fallback over whole title
    return fam_

def is_chrom(f): return f in CHROM
def hue_dist(a,b):
    ia,ib=WHEEL.index(a),WHEEL.index(b); d=abs(ia-ib); return min(d,12-d)

def classify(df, imf, chroma, l):
    if not df: return "UNKNOWN","name-unmapped"
    if df==imf: return "OK","same-family"
    dC, iC = is_chrom(df), is_chrom(imf)
    if dC and iC:
        d=hue_dist(df,imf)
        if d>=2: return "HIGH", f"non-adjacent hue (dist {d})"
        return "REVIEW", f"adjacent hue (dist {d})"
    if dC != iC:
        if dC and imf=="white": return "REVIEW","chromatic name vs pale image (light-ground)"
        if dC and imf in ("gray","black"): return "HIGH","chromatic name, dark-neutral image"
        if dC and imf in ("beige","brown"):
            if df in ("blue","indigo","violet","cyan","teal","green"): return "HIGH","cool color vs warm-neutral image"
            return "REVIEW","warm color vs warm-neutral image"
        if not dC and iC and chroma>0.45: return "HIGH","neutral name, strongly chromatic image"
        return "REVIEW","chromatic/neutral boundary"
    # both neutral
    NG={"white":"light","gray":"mid","black":"dark","beige":"warm","brown":"warm"}
    if NG.get(df)==NG.get(imf): return "OK","same neutral group"
    if {df,imf}=={"white","black"}: return "HIGH","opposite-value neutrals"
    return "REVIEW",f"neutral shift {df}->{imf}"

def dominant(url):
    req=urllib.request.Request(url,headers={"User-Agent":"Mozilla/5.0"})
    data=urllib.request.urlopen(req,timeout=25).read()
    im=Image.open(io.BytesIO(data)).convert("RGB")
    w,h=im.size; cw,ch=int(w*0.3),int(h*0.3)
    im2=im.crop((cw,ch,w-cw,h-ch)).resize((60,60))
    px=list(im2.getdata())
    # ignore near-white border bleed: drop pixels that are near-white when a chromatic majority exists
    q=Counter(tuple(c//20*20 for c in p) for p in px)
    # weighted: prefer the most-saturated frequent bucket among top 5
    top=q.most_common(6)
    best=top[0][0]; best_score=-1
    for (col,cnt) in top:
        r,g,b=col; s=(max(col)-min(col))/255.0
        score=cnt*(0.4+s)   # weight frequency but boost chromatic
        if score>best_score: best_score=score; best=col
    return best

for line in sys.stdin:
    line=line.rstrip("\n")
    if not line.strip(): continue
    parts=line.split("\t")
    if len(parts)<7: continue
    vendor,handle,dwsku,mfrsku,title,cached,url=parts[:7]
    declared=title.split("|")[0].strip()
    df=name_family(declared)
    rec={"vendor":vendor,"handle":handle,"dwsku":dwsku,"mfrsku":mfrsku,"title":title,"declared":declared,"cached_hex":cached,"image_url":url,"declaredFamily":df}
    try:
        dom=dominant(url); imf,s,l=fam(*dom)
        rec["real_hex"]="#%02X%02X%02X"%dom; rec["imageFamily"]=imf
        v,reason=classify(df,imf,s,l)
        rec["verdict"]=v; rec["reason"]=reason
    except Exception as e:
        rec["verdict"]="ERROR"; rec["reason"]=f"{type(e).__name__}:{str(e)[:50]}"
    sys.stdout.write(json.dumps(rec)+"\n")