[object Object]

← back to Designer Wallcoverings

TK-135: image-based hex+% palette extractor (bg-discount, HSV naming, $0); 200-test 4.7s/0err

6853b03afa5729d552519b06e4bbac6c0df254ed · 2026-07-27 18:18:24 -0700 · Steve

Files touched

Diff

commit 6853b03afa5729d552519b06e4bbac6c0df254ed
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 27 18:18:24 2026 -0700

    TK-135: image-based hex+% palette extractor (bg-discount, HSV naming, $0); 200-test 4.7s/0err
---
 shopify/scripts/tk135-color-local-classify.js |  52 ++++++++++++
 shopify/scripts/tk135-image-palette.py        | 116 ++++++++++++++++++++++++++
 2 files changed, 168 insertions(+)

diff --git a/shopify/scripts/tk135-color-local-classify.js b/shopify/scripts/tk135-color-local-classify.js
new file mode 100644
index 00000000..e767cf6c
--- /dev/null
+++ b/shopify/scripts/tk135-color-local-classify.js
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+/**
+ * TK-00135 — Color local-model classification (FREE, local Ollama, no API, no writes).
+ * Reads the unmatched-title TSV, classifies each into a canonical color bucket via
+ * local qwen2.5, and writes a proposed <gid>\tcolor:<Color> TSV. DRY-RUN by nature —
+ * it only proposes; the live tagsAdd is a separate gated step.
+ *
+ *   node tk135-color-local-classify.js
+ */
+const fs = require('fs'), path = require('path'), os = require('os');
+const { execFileSync } = require('child_process');
+
+const IN = path.join(__dirname, 'data/tk135/color-proposed-unmatched.tsv');
+const OUT = path.join(__dirname, 'data/tk135/color-proposed-local.tsv');
+const MODEL = 'qwen2.5:latest';
+const CHUNK = 15;
+const BUCKETS = ['White','Cream','Beige','Tan','Brown','Gray','Charcoal','Black','Blue','Navy','Green','Sage','Gold','Silver','Metallic','Red','Pink','Purple','Orange','Yellow','Multi'];
+const OK = new Set(BUCKETS.map(b => b.toLowerCase()));
+
+const rows = fs.readFileSync(IN, 'utf8').trim().split('\n')
+  .map(l => { const [gid, vendor, title] = l.split('\t'); return { gid, vendor: vendor||'', title: title||'' }; })
+  .filter(r => r.gid && r.title);
+
+const PROMPT_HEAD = `You are a color classifier for wallcovering products. For EACH numbered product title, output the single best color bucket from EXACTLY this list: ${BUCKETS.join(', ')}. Titles may be French (Vieil Or=Gold, Bleu=Blue, Vert=Green, Crème=Cream, Rose/Rosier=Pink, Prune=Purple, Ocre=Brown, Gris=Gray, Noir=Black, Blanc=White, Jaune=Yellow, Doré=Gold). If truly no color is inferable, output 'Unknown'. Respond with ONLY lines '<number>: <Color>', nothing else.\n\n`;
+
+function classify(chunk) {
+  const body = chunk.map((r, i) => `${i + 1}. ${r.title}`).join('\n');
+  let out = '';
+  try { out = execFileSync('ollama', ['run', MODEL], { input: PROMPT_HEAD + body, encoding: 'utf8', maxBuffer: 1 << 22, timeout: 120000 }); }
+  catch (e) { return chunk.map(() => null); }
+  const map = {};
+  out.split('\n').forEach(line => { const m = line.match(/^\s*(\d+)\s*[:\.\)]\s*([A-Za-z]+)/); if (m) map[+m[1]] = m[2].trim(); });
+  return chunk.map((_, i) => { const v = (map[i + 1] || '').toLowerCase(); return OK.has(v) ? v : null; });
+}
+
+(async () => {
+  const proposed = [];
+  let done = 0, resolved = 0;
+  for (let i = 0; i < rows.length; i += CHUNK) {
+    const chunk = rows.slice(i, i + CHUNK);
+    const cols = classify(chunk);
+    chunk.forEach((r, j) => {
+      if (cols[j]) { const C = cols[j][0].toUpperCase() + cols[j].slice(1);
+        proposed.push(`${r.gid}\tcolor:${C}`); resolved++; }
+    });
+    done += chunk.length;
+    if (i % (CHUNK * 10) === 0 || done >= rows.length)
+      console.log(`  ${done}/${rows.length} classified — ${resolved} resolved (${(100*resolved/done).toFixed(0)}%)`);
+  }
+  fs.writeFileSync(OUT, proposed.join('\n') + '\n');
+  console.log(`\nDONE. local-resolved=${resolved}/${rows.length}. proposed -> ${OUT}`);
+})();
diff --git a/shopify/scripts/tk135-image-palette.py b/shopify/scripts/tk135-image-palette.py
new file mode 100644
index 00000000..e980dcd8
--- /dev/null
+++ b/shopify/scripts/tk135-image-palette.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+"""
+TK-00135 — Image color palette extractor (FREE/local, DRY-RUN, no Shopify writes).
+For each active product with an image: pull a tiny CDN thumbnail, extract the
+dominant-color palette as hex + % of image (near-white/black background
+discounted so the material color wins), and derive a filterable color:<Bucket>
+tag from the dominant hex via HSV classification.
+
+Outputs a proposed-writes TSV:  <gid>\t<color:Bucket>\t<palette_json>
+It does NOT write to Shopify or PG — the live metafieldsSet + tagsAdd is a
+separate gated step.
+
+  python3 tk135-image-palette.py --in data/tk135/active-images.tsv \
+        --out data/tk135/palette-proposed.tsv [--limit N] [--workers 16]
+Resumable: gids already present in --out are skipped.
+"""
+import sys, os, io, json, argparse, collections, colorsys, urllib.request
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from PIL import Image
+
+ap = argparse.ArgumentParser()
+ap.add_argument('--in', dest='inp', default='data/tk135/active-images.tsv')
+ap.add_argument('--out', default='data/tk135/palette-proposed.tsv')
+ap.add_argument('--limit', type=int, default=0)
+ap.add_argument('--workers', type=int, default=16)
+A = ap.parse_args()
+
+def thumb(u): return u + ('&' if '?' in u else '?') + 'width=120'
+
+def is_bg(rgb):  # near-white or near-black border/background
+    r, g, b = rgb
+    return (r > 242 and g > 242 and b > 242) or (r < 16 and g < 16 and b < 16)
+
+def merge(colors, thr=34):
+    out = []
+    for rgb, p in colors:
+        for it in out:
+            if sum((a - b) ** 2 for a, b in zip(rgb, it[0])) <= thr * thr:
+                it[1] += p; break
+        else:
+            out.append([rgb, p])
+    return sorted(out, key=lambda x: -x[1])
+
+def name(rgb):
+    r, g, b = [x / 255 for x in rgb]
+    h, s, v = colorsys.rgb_to_hsv(r, g, b)
+    H = h * 360
+    if s < 0.12:  # neutral
+        if v < 0.18: return 'Black'
+        if v < 0.38: return 'Charcoal'
+        if v < 0.60: return 'Gray'
+        if v < 0.84: return 'Silver'
+        return 'Cream' if (r > b) else 'White'
+    if s < 0.25 and v > 0.55:  # muted pastel-ish
+        if 60 <= H < 170: return 'Sage'
+        if 20 <= H < 60:  return 'Beige'
+    if H < 15 or H >= 345: return 'Red' if v < 0.75 else 'Pink'
+    if H < 40:  return 'Brown' if v < 0.5 else ('Tan' if s < 0.5 else 'Orange')
+    if H < 65:  return 'Gold' if v < 0.75 else 'Yellow'
+    if H < 100: return 'Green' if v < 0.6 else 'Sage'
+    if H < 165: return 'Green'
+    if H < 195: return 'Teal'
+    if H < 255: return 'Navy' if v < 0.5 else 'Blue'
+    if H < 290: return 'Purple'
+    return 'Pink'
+
+def palette(url, k=8, top=4):
+    req = urllib.request.Request(thumb(url), headers={'User-Agent': 'Mozilla/5.0'})
+    im = Image.open(io.BytesIO(urllib.request.urlopen(req, timeout=25).read())).convert('RGB')
+    im.thumbnail((100, 100))
+    q = im.quantize(colors=k, method=Image.MEDIANCUT).convert('RGB')
+    px = list(q.get_flattened_data()); n = len(px)
+    raw = [(rgb, 100 * c / n) for rgb, c in collections.Counter(px).most_common(k)]
+    fg = [(rgb, p) for rgb, p in raw if not is_bg(rgb)]
+    use = fg if fg else raw            # if the whole image is bg-ish, keep it
+    tot = sum(p for _, p in use) or 1
+    use = [(rgb, 100 * p / tot) for rgb, p in use]   # renormalize over material
+    m = merge(use)[:top]
+    pal = [{'hex': '#%02X%02X%02X' % rgb, 'pct': round(p, 1)} for rgb, p in m]
+    return pal, name(m[0][0])
+
+def work(row):
+    gid, url = row
+    try:
+        pal, bucket = palette(url)
+        return gid, 'color:' + bucket, json.dumps(pal, separators=(',', ':'))
+    except Exception as e:
+        return gid, None, 'ERR:' + str(e)[:60]
+
+def main():
+    os.makedirs(os.path.dirname(A.out), exist_ok=True)
+    done = set()
+    if os.path.exists(A.out):
+        for l in open(A.out):
+            g = l.split('\t', 1)[0]
+            if g: done.add(g)
+    rows = []
+    for l in open(A.inp):
+        parts = l.rstrip('\n').split('\t')
+        if len(parts) >= 2 and parts[0] and parts[0] not in done and parts[1]:
+            rows.append((parts[0], parts[1]))
+    if A.limit: rows = rows[:A.limit]
+    print(f"to process: {len(rows)} (already done: {len(done)})", flush=True)
+    ok = err = 0
+    with open(A.out, 'a') as out, ThreadPoolExecutor(max_workers=A.workers) as ex:
+        futs = {ex.submit(work, r): r for r in rows}
+        for i, f in enumerate(as_completed(futs), 1):
+            gid, tag, pj = f.result()
+            if tag: out.write(f"{gid}\t{tag}\t{pj}\n"); ok += 1
+            else: err += 1
+            if i % 250 == 0:
+                out.flush(); print(f"  {i}/{len(rows)}  ok={ok} err={err}", flush=True)
+    print(f"DONE ok={ok} err={err} -> {A.out}", flush=True)
+
+if __name__ == '__main__':
+    main()

← dae671df TK-135: free/local Color-slice derivation dry-run (5,612/11,  ·  back to Designer Wallcoverings  ·  TK-135: gated apply script (metafield color_palette + color: d33e8553 →