← 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
A DW-Programming/enrich-active-colors.jsA DW-Programming/local-palette.py
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 →