← 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
A onboarding/sangetsu-lilycolor/scripts/enrich-pilot.pyA onboarding/sangetsu-lilycolor/scripts/lexicon-colorway.py
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 →