[object Object]

← back to Designer Wallcoverings

Add local swatch-enrichment pilot (50 Lilycolor swatches, $0 local qwen2.5vl + Pillow + lexicon colorway map); stages only

3499334fe1f0d853fb3b3add7f2e3bdc8346ee9c · 2026-06-30 11:31:38 -0700 · Steve

Files touched

Diff

commit 3499334fe1f0d853fb3b3add7f2e3bdc8346ee9c
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 30 11:31:38 2026 -0700

    Add local swatch-enrichment pilot (50 Lilycolor swatches, $0 local qwen2.5vl + Pillow + lexicon colorway map); stages only
---
 .../sangetsu-lilycolor/scripts/enrich-pilot.py     | 132 +++++++++++++++++++++
 .../sangetsu-lilycolor/scripts/lexicon-colorway.py |  83 +++++++++++++
 2 files changed, 215 insertions(+)

diff --git a/onboarding/sangetsu-lilycolor/scripts/enrich-pilot.py b/onboarding/sangetsu-lilycolor/scripts/enrich-pilot.py
new file mode 100644
index 00000000..0d595fc3
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/scripts/enrich-pilot.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""Pilot LOCAL swatch enrichment ($0) — reuses enrich-local-hybrid's Pillow palette +
+qwen2.5vl on Mac2 ollama (127.0.0.1). Maps dominant hex -> interior-designer lexicon colorway.
+Output: one JSONL row per swatch with SKU, hex[], bg_hex, colorway_name, styles[], material,
+patterns[]. STAGES ONLY — no publish, no DB write, no Shopify. Reversible.
+
+Usage: enrich-pilot.py <skus_file> <out_jsonl>
+  skus_file: one mfr_sku per line (image lives at HENRY/<sku>.jpg)
+"""
+import sys, os, json, io, base64, time, subprocess, urllib.request
+
+HENRY = "/Volumes/Henry/dw-lily-images"
+OLLAMA = os.environ.get("ENRICH_OLLAMA_URL", "http://127.0.0.1:11434")
+VL_MODEL = os.environ.get("ENRICH_VL_MODEL", "qwen2.5vl:7b")
+HERE = os.path.dirname(os.path.abspath(__file__))
+PALETTE_PY = os.path.expanduser("~/Projects/enrich-local-hybrid/enrich-palette.py")
+LEXICON_PY = os.path.join(HERE, "lexicon-colorway.py")
+
+def palette(img_path, k=6):
+    r = subprocess.run([sys.executable, PALETTE_PY, img_path, str(k)],
+                       capture_output=True, text=True, timeout=30)
+    if r.returncode != 0 or not r.stdout:
+        raise RuntimeError("palette failed: " + (r.stderr or "no output"))
+    return json.loads(r.stdout)  # {palette:[{hex,percentage}], image_b64}
+
+def lexicon_name(hex_str):
+    r = subprocess.run([sys.executable, LEXICON_PY, hex_str],
+                       capture_output=True, text=True, timeout=10)
+    try:
+        return json.loads(r.stdout).get("colorway")
+    except Exception:
+        return None
+
+def vl(image_b64, pal):
+    prompt = (
+        "This wallcovering swatch image was pixel-sampled into these EXACT colors "
+        f"(hex + area%): {json.dumps(pal)} . In the SAME ORDER give a designer color name "
+        "for each hex. Also: backgroundIndex (0-based index of the base/background color), "
+        "styles (interior-design styles), patterns (motif vocabulary like Damask/Floral/"
+        "Geometric/Stripe/Grasscloth-texture), material (Grasscloth/Silk/Vinyl/Paper/"
+        "Non-woven/etc), imageType (scan_swatch|scan_flatbed|photo_full|photo_crop|render), "
+        "usable (false only if blank/corrupt/not a product), description (one sentence). "
+        'Never use the word "Wallpaper" — say "Wallcovering".'
+    )
+    schema = {"type":"object","properties":{
+        "colorNames":{"type":"array","items":{"type":"string"}},
+        "backgroundIndex":{"type":"integer"},
+        "styles":{"type":"array","items":{"type":"string"}},
+        "patterns":{"type":"array","items":{"type":"string"}},
+        "material":{"type":"string"},"imageType":{"type":"string"},
+        "usable":{"type":"boolean"},"description":{"type":"string"}},
+        "required":["colorNames","backgroundIndex","styles","patterns","material","usable"]}
+    body = json.dumps({"model":VL_MODEL,"prompt":prompt,"images":[image_b64],
+                       "stream":False,"format":schema,"keep_alive":"15m",
+                       "options":{"temperature":0.1}}).encode()
+    req = urllib.request.Request(OLLAMA + "/api/generate", data=body,
+                                 headers={"Content-Type":"application/json"})
+    with urllib.request.urlopen(req, timeout=300) as resp:
+        raw = json.loads(resp.read().decode())
+    return json.loads(raw["response"])
+
+def clean(arr, cap=4):
+    out, seen = [], set()
+    for v in (arr or []):
+        s = str(v or "").strip()
+        if not s or s.lower() in ("none","n/a","na","null","undefined"): continue
+        k = s.lower()
+        if k in seen: continue
+        seen.add(k); out.append(s)
+        if len(out) >= cap: break
+    return out
+
+def enrich_one(sku):
+    img = os.path.join(HENRY, sku + ".jpg")
+    if not os.path.exists(img):
+        return {"sku": sku, "error": "image_not_found"}
+    t0 = time.time()
+    samp = palette(img, 6)
+    pal = samp.get("palette", [])
+    res = vl(samp["image_b64"], pal)
+    if res.get("usable") is False:
+        return {"sku": sku, "usable": False, "wall_s": round(time.time()-t0,1)}
+    names = res.get("colorNames", [])
+    colors = [{"name": (names[i] if i < len(names) else "").strip(),
+               "hex": c["hex"], "percentage": c.get("percentage")}
+              for i, c in enumerate(pal)]
+    bgi = res.get("backgroundIndex", 0)
+    if not (isinstance(bgi, int) and 0 <= bgi < len(colors)): bgi = 0
+    fg = [c for i, c in enumerate(colors) if i != bgi]
+    dominant = (fg[0] if fg else (colors[0] if colors else {"hex": ""}))
+    return {
+        "sku": sku,
+        "hex": [c["hex"] for c in colors],
+        "bg_hex": colors[bgi]["hex"] if colors else "",
+        "bg_name": colors[bgi]["name"] if colors else "",
+        "dominant_hex": dominant["hex"],
+        "colorway_name": lexicon_name(dominant["hex"]),
+        "vl_color_names": [c["name"] for c in colors],
+        "styles": clean(res.get("styles")),
+        "material": (res.get("material") or "").strip(),
+        "patterns": clean(res.get("patterns")),
+        "imageType": res.get("imageType"),
+        "description": (res.get("description") or "").strip().replace("Wallpaper","Wallcovering"),
+        "_provider": "local-hybrid", "_cost": "$0 (local)",
+        "wall_s": round(time.time()-t0, 1),
+    }
+
+def main():
+    skus = [l.strip() for l in open(sys.argv[1]) if l.strip()]
+    out_path = sys.argv[2]
+    ok = fail = 0
+    with open(out_path, "w") as fout:
+        for i, sku in enumerate(skus, 1):
+            try:
+                row = enrich_one(sku)
+                if row.get("error") or row.get("usable") is False:
+                    fail += 1
+                else:
+                    ok += 1
+                fout.write(json.dumps(row, ensure_ascii=False) + "\n")
+                fout.flush()
+                w = row.get("wall_s", "?")
+                print(f"[{i}/{len(skus)}] {sku} ok={ok} fail={fail} {w}s "
+                      f"cw={row.get('colorway_name')} mat={row.get('material')}", flush=True)
+            except Exception as e:
+                fail += 1
+                fout.write(json.dumps({"sku": sku, "error": str(e)[:200]}) + "\n"); fout.flush()
+                print(f"[{i}/{len(skus)}] {sku} ERROR {str(e)[:120]}", flush=True)
+    print(f"\nDONE ok={ok} fail={fail} total={len(skus)}", flush=True)
+
+if __name__ == "__main__":
+    main()
diff --git a/onboarding/sangetsu-lilycolor/scripts/lexicon-colorway.py b/onboarding/sangetsu-lilycolor/scripts/lexicon-colorway.py
new file mode 100644
index 00000000..e4208178
--- /dev/null
+++ b/onboarding/sangetsu-lilycolor/scripts/lexicon-colorway.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""Map a dominant hex -> nearest interior-designer-lexicon colorway NAME.
+Local, $0. The lexicon is the working set from ~/.claude/skills/interior-designer/SKILL.md
+(colorway vocabulary section). Each named colorway gets an approximate sRGB anchor; we pick
+the nearest by weighted-Euclidean distance in RGB. This is a deterministic naming aid that
+sits ALONGSIDE the VL's own free-text color name (we keep both)."""
+import sys, json
+
+# (name, (r,g,b)) — approximate sRGB anchors for the interior-designer colorway lexicon.
+LEXICON = [
+    # whites / off-whites
+    ("Chalk",(245,244,240)),("Alabaster","#EDEADE"),("Cotton",(252,252,250)),
+    ("Swiss Coffee","#EAE3D6"),("Ivory","#FFFFF0"),("Eggshell","#F0EAD6"),
+    ("Bone","#E3DAC9"),("Cream","#FFFDD0"),("Linen","#FAF0E6"),("Porcelain","#F5F4F0"),
+    ("Antique White","#FAEBD7"),
+    # neutrals / greige
+    ("Oatmeal","#E0D8C8"),("Flax","#EEDC82"),("Greige","#BEB6A6"),("Mushroom","#BDA697"),
+    ("Putty","#C5B9A3"),("Stone","#B6ADA1"),("Wheat","#F5DEB3"),("Sand","#D9C9A8"),
+    ("Almond","#EFDECD"),("Taupe","#8B8589"),("Pebble","#CFC8BB"),
+    # tans / camels / browns
+    ("Buff","#DAA06D"),("Honey","#D9A441"),("Tan","#D2B48C"),("Camel","#C19A6B"),
+    ("Khaki","#BDB76B"),("Caramel","#AF6E4D"),("Pecan","#9C6B30"),("Cognac","#9A463D"),
+    ("Chestnut","#954535"),("Walnut","#5C4033"),("Mocha","#3D2B1F"),("Espresso","#3B2F2F"),
+    # greys
+    ("Dove","#D7D2CB"),("Silver","#C0C0C0"),("Ash","#B2BEB5"),("Fog","#D6D6D1"),
+    ("Smoke","#A6A6A6"),("Pewter","#8C8C8C"),("Slate","#708090"),("Zinc","#71797E"),
+    ("Graphite","#41424C"),("Gunmetal","#2A3439"),("Charcoal","#36454F"),
+    # blacks
+    ("Ink","#1B1B1E"),("Noir","#0B0B0B"),("Onyx","#0F0F0F"),("Ebony","#1A1110"),
+    ("Jet","#0A0A0A"),("Obsidian","#0D0D0D"),
+    # greens
+    ("Celadon","#ACE1AF"),("Pistachio","#93C572"),("Sage","#9CAF88"),("Eucalyptus","#A2C3A4"),
+    ("Sea Glass","#C9E4DE"),("Moss","#8A9A5B"),("Fern","#4F7942"),("Olive","#808000"),
+    ("Loden","#4A5D23"),("Juniper","#3A5311"),("Hunter","#355E3B"),("Forest","#228B22"),
+    ("Emerald","#50C878"),("Verdigris","#43B3AE"),
+    # blues
+    ("Powder","#B6D0E2"),("Sky","#87CEEB"),("Wedgwood","#5C8DB8"),("Cornflower","#6495ED"),
+    ("French Blue","#0072BB"),("Cerulean","#2A52BE"),("Denim","#1560BD"),("Slate Blue","#6A5ACD"),
+    ("Teal","#008080"),("Peacock","#1B6C75"),("Prussian","#003153"),("Indigo","#3F00FF"),
+    ("Navy","#1B2A4A"),("Midnight","#191970"),
+    # yellows / golds
+    ("Buttercream","#FDF1B8"),("Champagne","#F7E7CE"),("Maize","#FBEC5D"),("Citrine","#E4D00A"),
+    ("Saffron","#F4C430"),("Amber","#FFBF00"),("Ochre","#CC7722"),("Mustard","#FFDB58"),
+    ("Gold","#D4AF37"),("Brass","#B5A642"),
+    # oranges / earths
+    ("Apricot","#FBCEB1"),("Peach","#FFE5B4"),("Clay","#B66A50"),("Terracotta","#E2725B"),
+    ("Persimmon","#EC5800"),("Pumpkin","#FF7518"),("Copper","#B87333"),("Rust","#B7410E"),
+    ("Sienna","#882D17"),
+    # reds / pinks
+    ("Blush","#DE9DAC"),("Rose","#FF007F"),("Dusty Rose","#C09098"),("Coral","#FF7F50"),
+    ("Cinnabar","#E34234"),("Brick","#8B3A2F"),("Oxblood","#4A0000"),("Claret","#7F1734"),
+    ("Garnet","#733635"),("Burgundy","#800020"),("Crimson","#DC143C"),
+    # purples
+    ("Lavender","#B57EDC"),("Lilac","#C8A2C8"),("Wisteria","#C9A0DC"),("Orchid","#DA70D6"),
+    ("Heather","#9E7E9E"),("Mauve","#E0B0FF"),("Amethyst","#9966CC"),("Plum","#8E4585"),
+    ("Aubergine","#3D0734"),
+    # metallics / leaf
+    ("Platinum","#E5E4E2"),("Gilt","#CBA135"),("Bronze","#CD7F32"),
+]
+
+def _rgb(v):
+    if isinstance(v, str):
+        v = v.lstrip("#")
+        return (int(v[0:2],16), int(v[2:4],16), int(v[4:6],16))
+    return tuple(v)
+
+ANCHORS = [(name, _rgb(rgb)) for name, rgb in LEXICON]
+
+def nearest(hex_str):
+    try:
+        r,g,b = _rgb(hex_str)
+    except Exception:
+        return None
+    # weighted euclidean (perceptual-ish: 0.30/0.59/0.11 luminance weights)
+    best, bd = None, 1e18
+    for name,(ar,ag,ab) in ANCHORS:
+        d = 0.30*(r-ar)**2 + 0.59*(g-ag)**2 + 0.11*(b-ab)**2
+        if d < bd:
+            bd, best = d, name
+    return best
+
+if __name__ == "__main__":
+    print(json.dumps({"colorway": nearest(sys.argv[1])}))

← 6319ed46 chore: product_type casing normalize scripts (Wolf Gordon 48  ·  back to Designer Wallcoverings  ·  auto-save: 2026-06-30T12:10:12 (7 files) — pending-approval/ 7d393bdd →