[object Object]

← back to Greenland Onboard

Full-catalog steps 2-5: PIL hex+color-clean, tags, packaging, titles, alt (908 enriched)

800bae3298f20babe8300dd83184c5735b29a83e · 2026-07-12 10:16:49 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 800bae3298f20babe8300dd83184c5735b29a83e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 12 10:16:49 2026 -0700

    Full-catalog steps 2-5: PIL hex+color-clean, tags, packaging, titles, alt (908 enriched)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/full-02-enrich.py | 253 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 253 insertions(+)

diff --git a/scripts/full-02-enrich.py b/scripts/full-02-enrich.py
new file mode 100644
index 0000000..46dc0be
--- /dev/null
+++ b/scripts/full-02-enrich.py
@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+"""Steps 2,3,4,5: HEX+color-clean (PIL), TAGS, PACKAGING, TITLE, ALT.
+Deterministic, $0 local. Emits data/enriched.json (one record per dwSku).
+"""
+import json, os, re, sys
+from collections import Counter
+from PIL import Image
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+IMG_DIR = os.path.join(ROOT, "public", "img")
+
+MAP = json.load(open(os.path.join(ROOT, "data", "full-map.json")))
+SPECS = {}
+for line in open(os.path.join(ROOT, "data", "full-catalog-skus.ndjson")):
+    r = json.loads(line)
+    SPECS.setdefault(r["joinKey"], r)
+
+# ---- Colorway lexicon: name -> RGB anchor (interior-designer vocabulary) ----
+LEXICON = {
+    "Alabaster": (237,234,224), "Bone": (227,218,201), "Oatmeal": (214,201,178),
+    "Cream": (245,238,220), "Ivory": (240,234,214), "Linen White": (246,241,229),
+    "Greige": (188,178,162), "Mushroom": (169,157,140), "Fawn": (196,176,148),
+    "Camel": (176,138,92), "Tan": (200,170,120), "Sand": (210,190,150),
+    "Wheat": (222,196,140), "Honey": (214,170,96), "Amber": (196,140,60),
+    "Caramel": (170,120,64), "Toffee": (140,98,54), "Chestnut": (120,80,48),
+    "Chocolate": (74,52,38), "Espresso": (58,44,34), "Walnut": (92,66,44),
+    "Umber": (98,72,50), "Terracotta": (188,104,72), "Rust": (160,82,48),
+    "Clay": (176,120,92), "Brick": (150,72,56), "Sienna": (150,90,56),
+    "Oatmeal Grey": (198,192,182), "Dove": (196,194,190), "Silver": (196,198,200),
+    "Pewter": (140,140,142), "Slate": (100,110,120), "Charcoal": (64,66,70),
+    "Graphite": (54,56,60), "Ink": (32,36,44), "Onyx": (24,24,26),
+    "Ebony": (30,28,28), "Jet": (18,18,20),
+    "Fog": (200,204,206), "Mist": (210,214,214), "Ash": (150,150,148),
+    "Stone": (168,162,152), "Flint": (120,120,116), "Smoke": (128,128,130),
+    "Celadon": (168,190,168), "Sage": (150,168,138), "Moss": (108,124,90),
+    "Fern": (96,120,80), "Olive": (120,116,72), "Sagebrush": (140,148,120),
+    "Eucalyptus": (150,172,150), "Pine": (58,88,64), "Forest": (44,72,52),
+    "Hunter": (48,80,60), "Verdigris": (100,150,130), "Jade": (80,140,110),
+    "Wedgwood": (108,144,180), "Slate Blue": (96,120,150), "Denim": (78,104,140),
+    "Chambray": (140,164,190), "Sky": (168,196,220), "Powder Blue": (188,208,224),
+    "Cornflower": (120,150,200), "Indigo": (52,64,110), "Navy": (36,48,84),
+    "Midnight": (24,34,60), "Prussian": (40,60,90), "Teal": (44,120,130),
+    "Peacock": (34,108,120), "Aegean": (66,110,140), "Marine": (48,80,120),
+    "Aubergine": (88,58,84), "Plum": (110,72,110), "Mauve": (168,140,160),
+    "Heather": (150,132,158), "Lilac": (188,170,198), "Wisteria": (176,160,196),
+    "Amethyst": (120,90,150), "Orchid": (170,120,170), "Wine": (110,44,58),
+    "Burgundy": (96,40,52), "Merlot": (86,36,48), "Claret": (120,44,56),
+    "Blush": (224,196,190), "Rose": (206,150,150), "Dusty Rose": (196,158,156),
+    "Coral": (222,140,120), "Salmon": (228,158,138), "Apricot": (228,180,140),
+    "Peach": (240,200,168), "Champagne": (232,214,186), "Buff": (222,204,168),
+    "Gold": (198,158,72), "Brass": (176,140,72), "Bronze": (140,110,66),
+    "Copper": (168,104,68), "Antique Gold": (168,138,84),
+    "Snow": (248,248,246), "White": (250,250,248), "Pearl": (238,236,230),
+    "Taupe": (170,156,140), "Khaki": (176,160,120), "Putty": (196,186,168),
+}
+
+def hex_to_rgb(h):
+    h = h.lstrip("#")
+    return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
+
+def rgb_to_hex(rgb):
+    return "#%02x%02x%02x" % tuple(int(c) for c in rgb)
+
+def nearest_name(rgb):
+    best, bd = None, 1e9
+    for name, anchor in LEXICON.items():
+        d = sum((a - b) ** 2 for a, b in zip(rgb, anchor))
+        if d < bd:
+            bd, best = d, name
+    return best
+
+def dominant_and_top(path):
+    """median-cut dominant hex + top-3 colors with %."""
+    im = Image.open(path).convert("RGB")
+    im.thumbnail((160, 160))
+    # reduce to a palette via adaptive quantize (median-cut)
+    q = im.quantize(colors=8, method=Image.MEDIANCUT)
+    pal = q.getpalette()
+    counts = Counter(q.getdata())
+    total = sum(counts.values())
+    ranked = counts.most_common()
+    tops = []
+    for idx, cnt in ranked[:3]:
+        rgb = (pal[idx*3], pal[idx*3+1], pal[idx*3+2])
+        tops.append({"hex": rgb_to_hex(rgb), "pct": round(100*cnt/total, 1)})
+    dom = tops[0]["hex"] if tops else "#888888"
+    return dom, tops
+
+# ---- Color family bucket from hex ----
+def color_family(hexv):
+    r, g, b = hex_to_rgb(hexv)
+    mx, mn = max(r, g, b), min(r, g, b)
+    light = (r + g + b) / 3
+    sat = mx - mn
+    if light > 225 and sat < 24:
+        return "White"
+    if light < 55:
+        return "Black"
+    if sat < 28:
+        return "Neutral"
+    # chromatic
+    if r >= g and r >= b:
+        if g > b and abs(r - g) < 40 and g > 110:
+            return "Warm"   # yellow/gold
+        if b > g:
+            return "Purple" if b > 90 and r - b < 80 else "Red"
+        return "Red" if r - g > 40 else "Warm"
+    if g >= r and g >= b:
+        return "Green"
+    if b >= r and b >= g:
+        return "Purple" if r > g and r > 90 else "Blue"
+    return "Neutral"
+
+# ---- Color name cleaning ----
+TYPO = {
+    "pruple": "Purple", "dusry": "Dusty", "dusdy": "Dusty", "gery": "Grey",
+    "gray": "Gray", "beigh": "Beige", "biege": "Beige", "slver": "Silver",
+    "chacoal": "Charcoal", "chaocoal": "Charcoal", "brnze": "Bronze",
+    "goldan": "Golden", "bluu": "Blue", "grean": "Green", "greeen": "Green",
+    "brwon": "Brown", "brownn": "Brown", "natual": "Natural", "naturel": "Natural",
+    "ivroy": "Ivory", "creme": "Cream", "taupw": "Taupe", "olvie": "Olive",
+    "steal": "Steel", "sliver": "Silver", "champange": "Champagne",
+    "wheit": "White", "whte": "White", "blak": "Black", "blck": "Black",
+}
+DIM_RE = re.compile(r"(width\s*:|cm\b|meter|\d+\s*(cm|m|mm)\b|/\s*\d+|\d+['\"]|colour\s*:.*\d)", re.I)
+
+def is_dimension_junk(c):
+    if not c or not c.strip():
+        return True
+    s = c.strip()
+    if DIM_RE.search(s):
+        return True
+    # mostly digits / punctuation
+    letters = sum(ch.isalpha() for ch in s)
+    if letters < 2:
+        return True
+    return False
+
+def title_case(s):
+    small = {"of", "and", "the", "in", "on", "with", "a"}
+    words = re.split(r"(\s+|-|/)", s.strip())
+    out = []
+    for w in words:
+        if not w or w.isspace() or w in "-/":
+            out.append(w); continue
+        lw = w.lower()
+        out.append(lw if (lw in small and out) else lw.capitalize())
+    return "".join(out)
+
+def clean_color(raw, dom_hex):
+    if is_dimension_junk(raw):
+        return nearest_name(hex_to_rgb(dom_hex)), "derived"
+    s = raw.strip()
+    # fix typos token-wise
+    toks = re.split(r"(\s+)", s)
+    fixed = []
+    for t in toks:
+        key = re.sub(r"[^a-z]", "", t.lower())
+        if key in TYPO:
+            fixed.append(TYPO[key])
+        else:
+            fixed.append(t)
+    s = "".join(fixed)
+    s = title_case(s)
+    return s, "cleaned"
+
+# ---- Packaging ----
+def packaging(min_order, material):
+    mo = (min_order or "").strip()
+    m = re.search(r"(\d+)\s*yard", mo, re.I)
+    if m:
+        n = int(m.group(1))
+        return n, n
+    # blank -> default by material class
+    panel_mats = {"Specialty", "Metal Leaf", "PE Weaving / PE Woven Material",
+                  "XPE Functional Wallcoverings"}
+    if material in panel_mats or material in ("Multi-material", "Vinyl"):
+        return 1, 1
+    return 6, 6
+
+# ---- Title material word ----
+MATWORD = {
+    "Silk": "Silk", "Grass": "Grasscloth", "Wood": "Wood Veneer", "Suede": "Suede",
+    "Linen": "Linen", "Raffia": "Raffia", "Paper Weave": "Paper Weave", "Abaca": "Abaca",
+    "Hemp": "Hemp", "Sisal": "Sisal", "Jute": "Jute", "Velvet": "Velvet", "Wool": "Wool",
+    "Arrowroot": "Arrowroot", "Cotton Yarn": "Cotton", "Water hyacinth": "Water Hyacinth",
+    "Metal Leaf": "Metal Leaf",
+    # -> just Wallcovering
+    "Specialty": "", "PE Weaving / PE Woven Material": "", "XPE Functional Wallcoverings": "",
+    "Jacquard": "Jacquard", "Multi-material": "", "Vinyl": "",
+}
+
+def material_word(material):
+    return MATWORD.get(material, "")
+
+def build_title(city, color, material):
+    mw = material_word(material)
+    core = f"{city}-{color}"
+    if mw:
+        return f"{core} {mw} Wallcovering | Phillipe Romano"
+    return f"{core} Wallcovering | Phillipe Romano"
+
+def main():
+    out = []
+    n = 0
+    for r in MAP:
+        dwSku = r["dwSku"]
+        path = os.path.join(IMG_DIR, f"{dwSku}.jpg")
+        try:
+            dom_hex, tops = dominant_and_top(path)
+        except Exception as e:
+            dom_hex, tops = "#888888", [{"hex": "#888888", "pct": 100.0}]
+        color, csrc = clean_color(r.get("color", ""), dom_hex)
+        fam = color_family(dom_hex)
+        spec = SPECS.get(r["joinKey"], {})
+        mn, st = packaging(spec.get("minimumOrder", ""), r["material"])
+        title = build_title(r["city"], color, r["material"])
+        alt = f"{title} — {dwSku}"
+        # tags >=2: material, color-family, city style, brand, quotes
+        tags = [r["material"], fam, r["city"], "Phillipe Romano", "quotes"]
+        tags = [t for t in dict.fromkeys(tags) if t]  # dedupe, drop empties
+        rec = {
+            "dwSku": dwSku, "joinKey": r["joinKey"], "prefix": r["prefix"],
+            "material": r["material"], "city": r["city"],
+            "rawColor": r.get("color", ""), "cleanColor": color, "colorSource": csrc,
+            "hex": dom_hex, "topColors": tops, "colorFamily": fam,
+            "title": title, "alt": alt, "tags": tags,
+            "min": mn, "step": st, "minimumOrder": spec.get("minimumOrder", ""),
+            "mfrSku": r["mfrSku"], "mfrModel": r["mfrModel"],
+            "collectionCode": r["collectionCode"], "collectionName": r["collectionName"],
+            "width": spec.get("width", ""), "use": spec.get("use", ""),
+            "repeat": spec.get("repeat", ""), "fireRating": spec.get("fireRating", ""),
+            "backing": spec.get("backing", ""), "weight": spec.get("weight", ""),
+            "installUrl": spec.get("installUrl", ""), "catalogUrl": spec.get("catalogUrl", ""),
+            "productUrl": r.get("thumb", ""),
+            "imageLocal": f"public/img/{dwSku}.jpg",
+        }
+        out.append(rec)
+        n += 1
+    json.dump(out, open(os.path.join(ROOT, "data", "enriched.json"), "w"), indent=1)
+    # report
+    fams = Counter(x["colorFamily"] for x in out)
+    csrc = Counter(x["colorSource"] for x in out)
+    pk = Counter((x["min"], x["step"]) for x in out)
+    print(json.dumps({
+        "records": n,
+        "colorFamilies": dict(fams),
+        "colorSource": dict(csrc),
+        "packaging": {f"{k[0]}/{k[1]}": v for k, v in pk.items()},
+    }, indent=2))
+
+if __name__ == "__main__":
+    main()

← fd65197 Full-catalog step 1: localize 908 SKU images to public/img (  ·  back to Greenland Onboard  ·  auto-save: 2026-07-12T10:17:36 (1 files) — scripts/full-03-r a784bc0 →