[object Object]

← back to Designer Wallcoverings

auto-save: 2026-07-07T09:29:44 (4 files) — pending-approval/boost-filter-consolidation-2026-06-25 vendor-scrapers/china-seas-refresh shopify/scripts/enrich-color-details-local.js shopify/scripts/enrich-color-details-local.py

f168a645473ee69e7c628eb715938422479e1538 · 2026-07-07 09:29:50 -0700 · Steve Abrams

Files touched

Diff

commit f168a645473ee69e7c628eb715938422479e1538
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 7 09:29:50 2026 -0700

    auto-save: 2026-07-07T09:29:44 (4 files) — pending-approval/boost-filter-consolidation-2026-06-25 vendor-scrapers/china-seas-refresh shopify/scripts/enrich-color-details-local.js shopify/scripts/enrich-color-details-local.py
---
 shopify/scripts/enrich-color-details-local.js | 107 +++++++++++++++++++
 shopify/scripts/enrich-color-details-local.py | 145 ++++++++++++++++++++++++++
 2 files changed, 252 insertions(+)

diff --git a/shopify/scripts/enrich-color-details-local.js b/shopify/scripts/enrich-color-details-local.js
new file mode 100644
index 00000000..bc808e48
--- /dev/null
+++ b/shopify/scripts/enrich-color-details-local.js
@@ -0,0 +1,107 @@
+#!/usr/bin/env node
+/*
+ * enrich-color-details-local.js
+ * Populate custom.color_details (JSON: [{name,hex,pct}]) for DW products using a
+ * LOCAL vision model (qwen2.5vl via ollama) — $0, no paid API. Read by the PDP
+ * `color-palette` snippet (Tier 1). Percentages are normalized to sum ~100.
+ *
+ * Usage:
+ *   node enrich-color-details-local.js <productNumericId> [<productNumericId> ...]
+ *   node enrich-color-details-local.js --query "status:active vendor:Phillipe Romano" --limit 50
+ *   node enrich-color-details-local.js --dry <productNumericId>   (extract only, no write)
+ *
+ * Env (from shopify/.env): SHOPIFY_STORE_DOMAIN, SHOPIFY_ADMIN_TOKEN
+ * Local: ollama on 127.0.0.1:11434 with qwen2.5vl:7b
+ */
+const fs = require('fs');
+const path = require('path');
+
+const ENV = Object.fromEntries(
+  fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8')
+    .split('\n').filter(l => l.includes('=') && !l.trim().startsWith('#'))
+    .map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim().replace(/^["']|["']$/g, '')]; })
+);
+const STORE = ENV.SHOPIFY_STORE_DOMAIN;
+const TOKEN = ENV.SHOPIFY_ADMIN_TOKEN;
+const OLLAMA = 'http://127.0.0.1:11434';
+const MODEL = 'qwen2.5vl:7b';
+const API = `https://${STORE}/admin/api/2024-10/graphql.json`;
+
+const PROMPT = `You are a textile color analyst. Look at this wallcovering/fabric pattern image.
+Identify every DISTINCT color a designer would name (the ground/background PLUS every motif and accent color, even small ones).
+For each color give: a short interior-design color name (e.g. "Sage", "Oatmeal", "Coral", "Wedgwood Blue"), the actual muted hex code AS IT APPEARS in the image (not an idealized pure hue), and its approximate percentage of the visible design.
+Return ONLY JSON: {"colors":[{"name":"...","hex":"#RRGGBB","pct":NN}]} with dominant colors first, 3-8 colors, integer percentages.`;
+
+async function gql(query, variables) {
+  const r = await fetch(API, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables })
+  });
+  return r.json();
+}
+
+async function getProducts(ids, queryStr, limit) {
+  if (ids && ids.length) {
+    const q = `{ nodes(ids: [${ids.map(i => `"gid://shopify/Product/${i}"`).join(',')}]) { ... on Product { id title featuredImage{url} } } }`;
+    const d = await gql(q);
+    return (d.data.nodes || []).filter(Boolean);
+  }
+  const q = `query($q:String!,$n:Int!){ products(first:$n, query:$q){ edges{ node{ id title featuredImage{url} } } } }`;
+  const d = await gql(q, { q: queryStr, n: limit });
+  return (d.data.products.edges || []).map(e => e.node).filter(n => n.featuredImage);
+}
+
+async function extractColors(imageUrl) {
+  // download image → base64
+  const img = Buffer.from(await (await fetch(imageUrl)).arrayBuffer()).toString('base64');
+  const r = await fetch(`${OLLAMA}/api/generate`, {
+    method: 'POST',
+    body: JSON.stringify({ model: MODEL, stream: false, format: 'json', prompt: PROMPT, images: [img], options: { temperature: 0.1 } })
+  });
+  const j = await r.json();
+  let colors = [];
+  try { colors = (JSON.parse(j.response).colors || []); } catch (e) { return null; }
+  colors = colors
+    .filter(c => c && /^#?[0-9a-fA-F]{6}$/.test((c.hex || '').replace('#', '')))
+    .map(c => ({ name: String(c.name || '').trim().slice(0, 24), hex: '#' + (c.hex || '').replace('#', '').toLowerCase(), pct: Math.max(0, Math.round(+c.pct || 0)) }))
+    .filter(c => c.name)
+    .slice(0, 8);
+  // normalize pct to sum 100
+  const tot = colors.reduce((s, c) => s + c.pct, 0) || 1;
+  colors.forEach(c => { c.pct = Math.round(c.pct / tot * 100); });
+  return colors.length ? colors : null;
+}
+
+async function writeMetafield(productGid, colors) {
+  const q = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ metafields{ id key } userErrors{ field message } } }`;
+  const d = await gql(q, { mf: [{ ownerId: productGid, namespace: 'custom', key: 'color_details', type: 'json', value: JSON.stringify(colors) }] });
+  const errs = d.data?.metafieldsSet?.userErrors || [];
+  if (errs.length) throw new Error(JSON.stringify(errs));
+  return true;
+}
+
+(async () => {
+  const args = process.argv.slice(2);
+  const dry = args.includes('--dry');
+  let ids = args.filter(a => /^\d+$/.test(a));
+  let queryStr = null, limit = 50;
+  const qi = args.indexOf('--query'); if (qi >= 0) queryStr = args[qi + 1];
+  const li = args.indexOf('--limit'); if (li >= 0) limit = +args[li + 1];
+
+  const products = await getProducts(ids.length ? ids : null, queryStr, limit);
+  console.log(`Enriching ${products.length} product(s) with ${MODEL} (local, $0)…\n`);
+  let ok = 0, fail = 0;
+  for (const p of products) {
+    const t0 = Date.now();
+    process.stdout.write(`• ${p.title.slice(0, 50)} … `);
+    if (!p.featuredImage) { console.log('no image, skip'); fail++; continue; }
+    const colors = await extractColors(p.featuredImage.url);
+    if (!colors) { console.log('extract FAILED'); fail++; continue; }
+    const summary = colors.map(c => `${c.name} ${c.pct}%`).join(', ');
+    if (dry) { console.log(`[dry] ${summary}`); ok++; continue; }
+    try { await writeMetafield(p.id, colors); console.log(`✓ ${summary}  (${((Date.now() - t0) / 1000).toFixed(0)}s)`); ok++; }
+    catch (e) { console.log(`WRITE FAILED: ${e.message}`); fail++; }
+  }
+  console.log(`\nDone: ${ok} enriched, ${fail} failed.`);
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/shopify/scripts/enrich-color-details-local.py b/shopify/scripts/enrich-color-details-local.py
new file mode 100644
index 00000000..840c93d0
--- /dev/null
+++ b/shopify/scripts/enrich-color-details-local.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""
+enrich-color-details-local.py  —  HYBRID local color enrichment ($0, no paid API)
+
+Populates custom.color_details (JSON [{name,hex,pct}]) for DW products, read by the
+PDP `color-palette` snippet (Tier 1). Correctly separates the two things vision
+models are good/bad at:
+  • HEX + %  ← real image pixels via PIL median-cut  (accurate, not hallucinated)
+  • NAMES    ← qwen2.5vl names those exact hexes      (semantic, designer-friendly)
+
+Vision LLMs emit canonical/CSS-name hexes ("#006400" for a teal product), so we
+NEVER trust their hex — only their naming of the real pixel colors.
+
+Usage:
+  python3 enrich-color-details-local.py <productId> [<productId> ...]
+  python3 enrich-color-details-local.py --query "status:active vendor:Phillipe Romano" --limit 50
+  python3 enrich-color-details-local.py --dry <productId>        # extract only, no write
+
+Env (shopify/.env): SHOPIFY_STORE_DOMAIN, SHOPIFY_ADMIN_TOKEN
+Local: ollama qwen2.5vl:7b on 127.0.0.1:11434
+"""
+import sys, os, json, base64, urllib.request, io, time
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ENV = {}
+for line in open(os.path.join(HERE, '..', '.env')):
+    line = line.strip()
+    if '=' in line and not line.startswith('#'):
+        k, v = line.split('=', 1); ENV[k.strip()] = v.strip().strip('"\'')
+STORE = ENV['SHOPIFY_STORE_DOMAIN']; TOKEN = ENV['SHOPIFY_ADMIN_TOKEN']
+API = f"https://{STORE}/admin/api/2024-10/graphql.json"
+OLLAMA = 'http://127.0.0.1:11434/api/generate'; MODEL = 'qwen2.5vl:7b'
+
+try:
+    from PIL import Image
+except ImportError:
+    sys.exit("PIL required: pip3 install Pillow")
+
+
+def gql(query, variables=None):
+    body = json.dumps({'query': query, 'variables': variables or {}}).encode()
+    req = urllib.request.Request(API, body, {'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json'})
+    return json.load(urllib.request.urlopen(req, timeout=60))
+
+
+def get_products(ids, query_str, limit):
+    if ids:
+        gids = ','.join(f'"gid://shopify/Product/{i}"' for i in ids)
+        q = f'{{ nodes(ids:[{gids}]){{ ... on Product {{ id title featuredImage{{url}} }} }} }}'
+        return [n for n in gql(q)['data']['nodes'] if n and n.get('featuredImage')]
+    q = 'query($q:String!,$n:Int!){ products(first:$n, query:$q){ edges{ node{ id title featuredImage{url} } } } }'
+    return [e['node'] for e in gql(q, {'q': query_str, 'n': limit})['data']['products']['edges'] if e['node'].get('featuredImage')]
+
+
+def dominant_colors(img_bytes, k=6):
+    """Real dominant colors via median-cut. Returns [(hex,pct)] sorted desc, near-dupes merged."""
+    im = Image.open(io.BytesIO(img_bytes)).convert('RGB')
+    if max(im.size) > 400:
+        im.thumbnail((400, 400))
+    q = im.quantize(colors=max(8, k + 4), method=Image.MEDIANCUT)
+    pal = q.getpalette()
+    counts = q.getcolors() or []
+    tot = sum(c for c, _ in counts) or 1
+    raw = []
+    for cnt, idx in sorted(counts, reverse=True):
+        r, g, b = pal[idx * 3:idx * 3 + 3]
+        raw.append([r, g, b, cnt])
+    # merge near-duplicate clusters (Euclidean < 34)
+    merged = []
+    for r, g, b, cnt in raw:
+        for m in merged:
+            if (r - m[0]) ** 2 + (g - m[1]) ** 2 + (b - m[2]) ** 2 < 34 ** 2:
+                m[3] += cnt; break
+        else:
+            merged.append([r, g, b, cnt])
+    merged.sort(key=lambda m: -m[3])
+    merged = merged[:k]
+    out = [(f'#{r:02x}{g:02x}{b:02x}', round(cnt / tot * 100)) for r, g, b, cnt in merged]
+    # normalize to 100
+    s = sum(p for _, p in out) or 1
+    out = [(h, max(1, round(p / s * 100))) for h, p in out]
+    return out
+
+
+def name_hexes(img_b64, hexes):
+    """Ask qwen2.5vl to name each REAL hex, in order. Falls back to the hex if it fails."""
+    prompt = ("This wallcovering/fabric image has these dominant colors as hex codes: "
+              + json.dumps(hexes) + ". For EACH hex, in the SAME order, give a short interior-design "
+              "color name (e.g. Sage, Oatmeal, Wedgwood Blue, Slate, Coral). "
+              'Return ONLY JSON {"names":[...]} with the same count and order.')
+    body = json.dumps({'model': MODEL, 'stream': False, 'format': 'json', 'prompt': prompt,
+                       'images': [img_b64], 'options': {'temperature': 0.1}}).encode()
+    try:
+        r = urllib.request.urlopen(urllib.request.Request(OLLAMA, body, {'Content-Type': 'application/json'}), timeout=150)
+        names = json.loads(json.load(r)['response']).get('names', [])
+    except Exception:
+        names = []
+    out = []
+    for i, h in enumerate(hexes):
+        nm = str(names[i]).strip()[:24] if i < len(names) and names[i] else h
+        out.append(nm)
+    return out
+
+
+def write_metafield(gid, colors):
+    q = ('mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ message } } }')
+    d = gql(q, {'mf': [{'ownerId': gid, 'namespace': 'custom', 'key': 'color_details',
+                        'type': 'json', 'value': json.dumps(colors)}]})
+    errs = d['data']['metafieldsSet']['userErrors']
+    if errs:
+        raise RuntimeError(errs)
+
+
+def main():
+    args = sys.argv[1:]
+    dry = '--dry' in args
+    ids = [a for a in args if a.isdigit()]
+    query_str = None; limit = 50
+    if '--query' in args: query_str = args[args.index('--query') + 1]
+    if '--limit' in args: limit = int(args[args.index('--limit') + 1])
+
+    products = get_products(ids or None, query_str, limit)
+    print(f"Enriching {len(products)} product(s) — hybrid (PIL pixels + {MODEL} naming), $0 local\n")
+    ok = fail = 0
+    for p in products:
+        t0 = time.time()
+        title = p['title'][:48]
+        try:
+            img_bytes = urllib.request.urlopen(p['featuredImage']['url'], timeout=40).read()
+            hexpct = dominant_colors(img_bytes)
+            b64 = base64.b64encode(img_bytes).decode()
+            names = name_hexes(b64, [h for h, _ in hexpct])
+            colors = [{'name': names[i], 'hex': hexpct[i][0], 'pct': hexpct[i][1]} for i in range(len(hexpct))]
+            summary = ', '.join(f"{c['name']} {c['pct']}%" for c in colors)
+            if dry:
+                print(f"• {title} … [dry] {summary}"); ok += 1; continue
+            write_metafield(p['id'], colors)
+            print(f"• {title} … ✓ {summary}  ({time.time()-t0:.0f}s)"); ok += 1
+        except Exception as e:
+            print(f"• {title} … FAILED: {e}"); fail += 1
+    print(f"\nDone: {ok} enriched, {fail} failed.")
+
+
+if __name__ == '__main__':
+    main()

← 279d3995 color-palette: demote single-hex below canvas; renderMeta fa  ·  back to Designer Wallcoverings  ·  color-palette: fix double-json encode of data-colors (broke 71defcf6 →