← 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
A shopify/scripts/tk135-color-local-classify.jsA shopify/scripts/tk135-image-palette.py
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 →