[object Object]

← back to Designer Wallcoverings

local color enrichment for uncovered active products: local-palette.py (PIL median-cut → exact hex + % + curated name, $0 no model) + enrich-active-colors.js (writes custom.color_hex + custom.color_details metafields DIRECTLY via Admin API, resumable via color_enrich_todo). Coverage was 54,884/60,075 active (91.4%); 5,157 uncovered w/ image queued. 3-product live test passed (0 userErrors).

31f12cb9e3b365e6e2ac439ebffcd30607c9884c · 2026-06-13 09:31:51 -0700 · Steve Abrams

Files touched

Diff

commit 31f12cb9e3b365e6e2ac439ebffcd30607c9884c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jun 13 09:31:51 2026 -0700

    local color enrichment for uncovered active products: local-palette.py (PIL median-cut → exact hex + % + curated name, $0 no model) + enrich-active-colors.js (writes custom.color_hex + custom.color_details metafields DIRECTLY via Admin API, resumable via color_enrich_todo). Coverage was 54,884/60,075 active (91.4%); 5,157 uncovered w/ image queued. 3-product live test passed (0 userErrors).
---
 DW-Programming/enrich-active-colors.js | 86 ++++++++++++++++++++++++++++++++++
 DW-Programming/local-palette.py        | 71 ++++++++++++++++++++++++++++
 2 files changed, 157 insertions(+)

diff --git a/DW-Programming/enrich-active-colors.js b/DW-Programming/enrich-active-colors.js
new file mode 100644
index 00000000..bcd4f1f9
--- /dev/null
+++ b/DW-Programming/enrich-active-colors.js
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+/**
+ * Enrich uncovered ACTIVE products with local color hex + full breakdown.
+ * - Reads work list from dw_unified.color_enrich_todo (done_at IS NULL).
+ * - Extracts palette LOCALLY (local-palette.py — PIL median-cut, $0, no model).
+ * - Writes Shopify metafields custom.color_hex (single_line) + custom.color_details (json)
+ *   DIRECTLY via Admin GraphQL (same path the color-cleaner uses — NOT the stale queue).
+ * - Marks each row done in color_enrich_todo (resumable).
+ * Usage: node enrich-active-colors.js [--limit N] [--conc 4] [--dry-run]
+ */
+const https = require('https');
+const { Pool } = require('pg');
+const { execFile } = require('child_process');
+const path = require('path');
+
+const SHOPIFY_DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const API_VERSION = '2024-01';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const DB_URL = process.env.TODO_DB_URL || 'postgres://stevestudio2@/dw_unified?host=/tmp';
+const args = process.argv.slice(2);
+const DRY = args.includes('--dry-run');
+const LIMIT = parseInt(args.find((_, i, a) => a[i-1] === '--limit') || '0', 10) || 0;
+const CONC = parseInt(args.find((_, i, a) => a[i-1] === '--conc') || '4', 10) || 4;
+const pool = new Pool({ connectionString: DB_URL });
+
+function palette(url) {
+  return new Promise((resolve) => {
+    execFile('python3', [path.join(__dirname, 'local-palette.py'), url], { timeout: 30000, maxBuffer: 1<<20 },
+      (err, stdout) => { if (err) return resolve(null); try { resolve(JSON.parse(stdout)); } catch { resolve(null); } });
+  });
+}
+function shopifyGraphQL(query, variables) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify({ query, variables });
+    const req = https.request({ hostname: SHOPIFY_DOMAIN, path: `/admin/api/${API_VERSION}/graphql.json`, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) }, timeout: 30000 }, (res) => {
+      let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
+    });
+    req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('gql timeout')); }); req.write(body); req.end();
+  });
+}
+async function writeMetafields(numericId, colorHex, aiColors) {
+  const mutation = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ field message } } }`;
+  const owner = `gid://shopify/Product/${numericId}`;
+  const mf = [
+    { ownerId: owner, namespace: 'custom', key: 'color_hex', type: 'single_line_text_field', value: colorHex },
+    { ownerId: owner, namespace: 'custom', key: 'color_details', type: 'json', value: JSON.stringify(aiColors) },
+  ];
+  const r = await shopifyGraphQL(mutation, { mf });
+  const ue = r?.data?.metafieldsSet?.userErrors;
+  if (ue && ue.length) throw new Error(ue[0].message);
+  if (r?.errors) throw new Error(JSON.stringify(r.errors).slice(0, 120));
+}
+
+(async () => {
+  if (!TOKEN) { console.error('Missing SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+  const { rows } = await pool.query(
+    `SELECT numeric_id, image_url FROM color_enrich_todo WHERE done_at IS NULL AND image_url LIKE 'http%' ${LIMIT ? 'LIMIT ' + LIMIT : ''}`);
+  console.log(`Color enrich — ${DRY ? 'DRY' : 'LIVE'} — ${rows.length} products, conc ${CONC}`);
+  let done = 0, failed = 0, idx = 0;
+  const queue = [...rows];
+  async function worker() {
+    while (queue.length) {
+      const p = queue.shift(); const n = ++idx;
+      const pal = await palette(p.image_url);
+      if (!pal || !pal.ai_colors?.length) {
+        failed++; await pool.query(`UPDATE color_enrich_todo SET err=$2 WHERE numeric_id=$1`, [p.numeric_id, 'no-palette']);
+        if (n % 50 === 0) console.log(`  [${n}] FAIL palette ${p.numeric_id}`); continue;
+      }
+      try {
+        if (!DRY) await writeMetafields(p.numeric_id, pal.color_hex, pal.ai_colors);
+        await pool.query(`UPDATE color_enrich_todo SET color_hex=$2, ai_colors=$3, done_at=now(), err=NULL WHERE numeric_id=$1`,
+          [p.numeric_id, pal.color_hex, JSON.stringify(pal.ai_colors)]);
+        done++;
+        if (n <= 10 || n % 100 === 0) console.log(`  [${n}] OK ${p.numeric_id} ${pal.color_hex} (${pal.ai_colors.length} colors)`);
+      } catch (e) {
+        failed++; await pool.query(`UPDATE color_enrich_todo SET err=$2 WHERE numeric_id=$1`, [p.numeric_id, String(e.message).slice(0,80)]);
+        if (String(e.message).includes('Throttled') || String(e.message).includes('429')) { await new Promise(r => setTimeout(r, 3000)); }
+        if (n % 50 === 0) console.log(`  [${n}] WRITE-ERR ${p.numeric_id}: ${e.message}`);
+      }
+    }
+  }
+  await Promise.all(Array.from({ length: CONC }, worker));
+  console.log(`\nDone: ${done} enriched, ${failed} failed.`);
+  await pool.end();
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/DW-Programming/local-palette.py b/DW-Programming/local-palette.py
new file mode 100644
index 00000000..165058cd
--- /dev/null
+++ b/DW-Programming/local-palette.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""Deterministic local color-palette extractor — $0, no model.
+Usage: local-palette.py <image_url_or_path>
+Output (stdout JSON): {"color_hex": "#RRGGBB", "ai_colors": [{"name","hex","percentage"}, ...]}
+Uses PIL median-cut quantization for dominant colors + pixel-share percentages,
+and a curated decor color-name table (nearest in RGB) for human-readable names.
+"""
+import sys, json, io, urllib.request
+from PIL import Image
+
+# Curated decor-friendly named colors (name -> RGB). Nearest match by Euclidean RGB.
+NAMED = {
+    "White": (255,255,255), "Ivory": (255,250,240), "Cream": (245,238,220), "Beige": (224,210,180),
+    "Off White": (240,238,230), "Tan": (210,180,140), "Sand": (200,178,140), "Taupe": (170,155,140),
+    "Khaki": (189,183,107), "Gold": (212,175,55), "Mustard": (200,160,40), "Yellow": (240,220,70),
+    "Cream Yellow": (250,240,180), "Orange": (230,140,50), "Terracotta": (200,110,75), "Rust": (170,90,55),
+    "Coral": (240,128,110), "Peach": (245,190,160), "Salmon": (235,150,130), "Pink": (235,150,175),
+    "Blush": (235,200,200), "Rose": (210,120,140), "Magenta": (190,60,130), "Red": (190,45,45),
+    "Burgundy": (120,40,55), "Maroon": (110,40,50), "Wine": (115,35,60), "Purple": (120,70,150),
+    "Lavender": (190,170,215), "Lilac": (200,170,210), "Plum": (110,60,95), "Aubergine": (70,40,70),
+    "Navy": (35,45,85), "Blue": (55,90,170), "Royal Blue": (50,75,180), "Sky Blue": (140,180,225),
+    "Cobalt": (40,70,160), "Teal": (40,120,130), "Indigo": (55,50,110), "Powder Blue": (185,205,225),
+    "Green": (70,130,70), "Emerald": (40,140,90), "Sage": (150,165,135), "Olive": (110,115,60),
+    "Forest Green": (45,90,55), "Mint": (170,210,180), "Hunter Green": (50,90,65), "Lime": (170,200,90),
+    "Brown": (110,75,50), "Chocolate": (75,50,35), "Mocha": (120,95,75), "Espresso": (60,45,35),
+    "Walnut": (95,65,45), "Chestnut": (130,80,55), "Black": (25,25,25), "Charcoal": (55,55,60),
+    "Gray": (130,130,130), "Grey": (130,130,130), "Light Gray": (200,200,200), "Slate": (95,105,115),
+    "Silver": (190,190,195), "Pewter": (140,140,145), "Gunmetal": (75,80,90), "Bronze": (140,110,70),
+    "Copper": (175,110,75), "Brass": (180,150,90),
+}
+
+def nearest_name(rgb):
+    r,g,b = rgb
+    best, bd = "Gray", 1e18
+    for name,(nr,ng,nb) in NAMED.items():
+        d = (r-nr)**2 + (g-ng)**2 + (b-nb)**2
+        if d < bd: bd, best = d, name
+    return best
+
+def load(src):
+    if src.startswith("http"):
+        req = urllib.request.Request(src, headers={"User-Agent":"Mozilla/5.0"})
+        data = urllib.request.urlopen(req, timeout=20).read()
+        return Image.open(io.BytesIO(data))
+    return Image.open(src)
+
+def main():
+    src = sys.argv[1]
+    img = load(src).convert("RGB")
+    img.thumbnail((220,220))
+    q = img.quantize(colors=8, method=Image.MEDIANCUT)
+    pal = q.getpalette()
+    counts = sorted(q.getcolors(), key=lambda c: -c[0])  # [(count, idx), ...]
+    total = sum(c for c,_ in counts) or 1
+    colors, seen = [], set()
+    for count, idx in counts:
+        rgb = tuple(pal[idx*3:idx*3+3])
+        pct = round(100.0*count/total, 1)
+        if pct < 3.0: continue                      # drop trace colors
+        hexv = "#%02X%02X%02X" % rgb
+        name = nearest_name(rgb)
+        if name in seen: continue                    # merge same-named buckets
+        seen.add(name)
+        colors.append({"name": name, "hex": hexv, "percentage": pct})
+    if not colors:                                   # fallback: single dominant
+        count, idx = counts[0]; rgb = tuple(pal[idx*3:idx*3+3])
+        colors = [{"name": nearest_name(rgb), "hex": "#%02X%02X%02X" % rgb, "percentage": 100.0}]
+    print(json.dumps({"color_hex": colors[0]["hex"], "ai_colors": colors}))
+
+if __name__ == "__main__":
+    main()

← 49cb6051 nightly runner: add best-effort George email on sweep comple  ·  back to Designer Wallcoverings  ·  local-palette: expand to ~115-name DESIGNER/decor color tabl 4c0fb021 →