← back to Sister Parish Onboarding
tick 3: normalize curly quotes in specs · extract dominant_hex + 5-color palette (73/73) · wire color-wheel/light-dark sorts + color dot in viewer
81602d326cc3e4af0beca5c6d7df052498227298 · 2026-05-11 15:11:48 -0700 · Steve
Files touched
A scripts/extract_dominant_hex.pyM server.js
Diff
commit 81602d326cc3e4af0beca5c6d7df052498227298
Author: Steve <steve@designerwallcoverings.com>
Date: Mon May 11 15:11:48 2026 -0700
tick 3: normalize curly quotes in specs · extract dominant_hex + 5-color palette (73/73) · wire color-wheel/light-dark sorts + color dot in viewer
---
scripts/extract_dominant_hex.py | 96 +++++++++++++++++++++++++++++++++++++++++
server.js | 43 +++++++++++++++++-
2 files changed, 137 insertions(+), 2 deletions(-)
diff --git a/scripts/extract_dominant_hex.py b/scripts/extract_dominant_hex.py
new file mode 100644
index 0000000..d006d87
--- /dev/null
+++ b/scripts/extract_dominant_hex.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+"""
+Per-product dominant-color extraction for Sister Parish catalog.
+Standing rule (memory: feedback_sort_skill_canonical): real-pixel hex beats
+tag-derived hex. Run sips-style "shrink to 1x1, read pixel" via Pillow.
+
+Strategy per image:
+- Download primary_image to /tmp/sp_imgs/
+- Resize to 200x200 (preserve detail), then bucket to dominant color
+ using k=5 KMeans-lite via Pillow.quantize (mode P, 5 colors)
+- Persist {dominant_hex, palette[]} to dw_unified.sisterparish_catalog.colors JSONB
+"""
+import os, json, subprocess, time, urllib.request, ssl
+from pathlib import Path
+from PIL import Image
+from collections import Counter
+
+ROOT = Path.home() / 'Projects' / 'sister-parish-onboarding'
+IMGDIR = Path('/tmp/sp_imgs'); IMGDIR.mkdir(exist_ok=True)
+CTX = ssl.create_default_context()
+
+def fetch(url, dest):
+ if dest.exists() and dest.stat().st_size > 1000:
+ return True
+ try:
+ req = urllib.request.Request(url, headers={'User-Agent':'Mozilla/5.0 (DW)'})
+ with urllib.request.urlopen(req, timeout=20, context=CTX) as r, dest.open('wb') as f:
+ f.write(r.read())
+ return True
+ except Exception as e:
+ print(f" fetch fail: {e}")
+ return False
+
+def dominant_palette(path, k=5):
+ img = Image.open(path).convert('RGB')
+ img.thumbnail((200,200))
+ pal = img.quantize(colors=k, method=Image.Quantize.MEDIANCUT).convert('RGB')
+ pixels = list(pal.getdata())
+ cnt = Counter(pixels)
+ total = sum(cnt.values())
+ top = cnt.most_common(k)
+ palette = [{'hex': '#{:02x}{:02x}{:02x}'.format(*rgb), 'pct': round(n/total*100, 2)} for rgb,n in top]
+ return palette
+
+def list_products():
+ sql = "SELECT json_agg(t) FROM (SELECT sp_product_id, title, primary_image FROM sisterparish_catalog WHERE primary_image IS NOT NULL ORDER BY title) t;"
+ out = subprocess.run(['psql','dw_unified','-At','-q','-c',sql], capture_output=True, text=True, check=True).stdout.strip()
+ return json.loads(out)
+
+def ensure_column():
+ subprocess.run(['psql','dw_unified','-q','-c',
+ "ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS colors JSONB; ALTER TABLE sisterparish_catalog ADD COLUMN IF NOT EXISTS dominant_hex TEXT;"], check=True)
+
+def save_to_pg(sp_id, dominant_hex, palette):
+ sql = f"UPDATE sisterparish_catalog SET dominant_hex='{dominant_hex}', colors='{json.dumps(palette).replace(chr(39), chr(39)+chr(39))}'::jsonb WHERE sp_product_id={sp_id};"
+ subprocess.run(['psql','dw_unified','-q','-c',sql], check=True)
+
+def main():
+ ensure_column()
+ products = list_products()
+ print(f"{len(products)} products with primary_image")
+ sql_lines = []
+ failed = 0
+ for i, p in enumerate(products, 1):
+ url = p['primary_image']
+ ext = url.split('?')[0].rsplit('.', 1)[-1].lower()
+ if ext not in ('jpg','jpeg','png','webp'):
+ ext = 'jpg'
+ dest = IMGDIR / f"sp_{p['sp_product_id']}.{ext}"
+ ok = fetch(url, dest)
+ if not ok:
+ failed += 1
+ print(f"[{i:03}/{len(products)}] ✗ {p['title'][:50]}")
+ continue
+ try:
+ palette = dominant_palette(dest)
+ dominant = palette[0]['hex']
+ sql_lines.append((p['sp_product_id'], dominant, palette))
+ print(f"[{i:03}/{len(products)}] {dominant} {p['title'][:50]}")
+ except Exception as e:
+ failed += 1
+ print(f"[{i:03}/{len(products)}] palette err: {e} {p['title'][:50]}")
+ # Bulk update
+ fp = Path('/tmp/sp_colors.sql')
+ with fp.open('w') as f:
+ f.write("BEGIN;\n")
+ for sp_id, dh, pal in sql_lines:
+ pal_str = json.dumps(pal).replace("'", "''")
+ f.write(f"UPDATE sisterparish_catalog SET dominant_hex='{dh}', colors='{pal_str}'::jsonb WHERE sp_product_id={sp_id};\n")
+ f.write("COMMIT;\n")
+ subprocess.run(['psql','dw_unified','-q','-f', str(fp)], check=True)
+ fp.unlink()
+ print(f"\nUpdated {len(sql_lines)} rows · failed {failed}")
+
+if __name__ == '__main__':
+ main()
diff --git a/server.js b/server.js
index 8531409..7c30c28 100644
--- a/server.js
+++ b/server.js
@@ -19,7 +19,7 @@ function psqlJson(sql) {
app.get('/api/products', (_req, res) => {
try {
- const raw = psqlJson(`SELECT json_agg(t) FROM (SELECT sp_product_id, handle, title, primary_image, tags, variants->0->>'vendor_retail' AS vendor_retail, variants->0->>'dw_retail' AS dw_retail, specs, shopify_product_id, shopify_handle, source_url FROM sisterparish_catalog ORDER BY title) t;`);
+ const raw = psqlJson(`SELECT json_agg(t) FROM (SELECT sp_product_id, handle, title, primary_image, tags, variants->0->>'vendor_retail' AS vendor_retail, variants->0->>'dw_retail' AS dw_retail, specs, dominant_hex, colors, shopify_product_id, shopify_handle, source_url FROM sisterparish_catalog ORDER BY title) t;`);
res.type('application/json').send(raw || '[]');
} catch (e) {
res.status(500).type('application/json').send(JSON.stringify({ error: e.message }));
@@ -53,6 +53,7 @@ app.get('/', (_req, res) => {
.card a { color:inherit; text-decoration:none; }
.badge { display:inline-block; padding:1px 6px; font-size:10px; background:#e6f0e9; color:#2d6440; border-radius:2px; margin-left:6px; }
.badge.draft { background:#fdf2dd; color:#7a5a13; }
+ .dot { display:inline-block; width:10px; height:10px; border-radius:50%; border:1px solid #00000020; vertical-align:middle; margin-right:5px; }
.empty { padding:48px; text-align:center; color:#999; }
</style>
<header>
@@ -66,6 +67,9 @@ app.get('/', (_req, res) => {
<option value="price-desc">Price ↓</option>
<option value="width">Width</option>
<option value="repeat-h">Repeat H</option>
+ <option value="color-wheel">Color Wheel</option>
+ <option value="light-dark">Light → Dark</option>
+ <option value="dark-light">Dark → Light</option>
</select>
</label>
<label>Density
@@ -88,6 +92,25 @@ const cnt = document.getElementById('count');
function priceOf(p) { return parseFloat(p.dw_retail || 0); }
function inchesOf(s) { const m = String(s||'').match(/([0-9.]+)/); return m ? parseFloat(m[1]) : 0; }
+function hexToHsl(hex) {
+ if (!hex || hex.length < 4) return [0,0,0];
+ const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
+ if (!m) return [0,0,0];
+ const r = parseInt(m[1],16)/255, g = parseInt(m[2],16)/255, b = parseInt(m[3],16)/255;
+ const max = Math.max(r,g,b), min = Math.min(r,g,b);
+ const l = (max + min) / 2;
+ let h = 0, s = 0;
+ if (max !== min) {
+ const d = max - min;
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+ if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
+ else if (max === g) h = (b - r) / d + 2;
+ else h = (r - g) / d + 4;
+ h *= 60;
+ }
+ return [h, s, l];
+}
+
function render() {
const sort = sortSel.value;
const sorted = [...products].sort((a,b) => {
@@ -95,6 +118,19 @@ function render() {
if (sort === 'price-desc') return priceOf(b) - priceOf(a);
if (sort === 'width') return inchesOf(a.specs?.width) - inchesOf(b.specs?.width);
if (sort === 'repeat-h') return inchesOf(a.specs?.horizontal_repeat) - inchesOf(b.specs?.horizontal_repeat);
+ if (sort === 'color-wheel') {
+ const ha = hexToHsl(a.dominant_hex), hb = hexToHsl(b.dominant_hex);
+ // Group by hue, then by saturation (chromatic), then by lightness within hue band
+ // Achromatic (low saturation) goes to the start
+ const isaA = ha[1] < 0.08, isaB = hb[1] < 0.08;
+ if (isaA !== isaB) return isaA ? -1 : 1;
+ if (isaA) return ha[2] - hb[2]; // both grayscale → by lightness
+ const bucketA = Math.floor(ha[0] / 30), bucketB = Math.floor(hb[0] / 30);
+ if (bucketA !== bucketB) return bucketA - bucketB;
+ return ha[2] - hb[2];
+ }
+ if (sort === 'light-dark') return hexToHsl(b.dominant_hex)[2] - hexToHsl(a.dominant_hex)[2];
+ if (sort === 'dark-light') return hexToHsl(a.dominant_hex)[2] - hexToHsl(b.dominant_hex)[2];
return (a.title||'').localeCompare(b.title||'');
});
cnt.textContent = sorted.length + ' prints · vendor/0.85 → DW';
@@ -110,13 +146,16 @@ function render() {
sp.horizontal_repeat ? 'H rpt ' + sp.horizontal_repeat : null,
sp.country_of_origin
].filter(Boolean).join(' · ');
+ const dot = p.dominant_hex
+ ? \`<span class="dot" title="\${p.dominant_hex}" style="background:\${p.dominant_hex}"></span>\`
+ : '';
return \`
<div class="card">
<a href="\${p.source_url}" target="_blank">
<div class="img" style="background-image:url('\${p.primary_image || ''}')"></div>
</a>
<div class="meta">
- <div class="title">\${p.title}\${badge}</div>
+ <div class="title">\${dot}\${p.title}\${badge}</div>
<div class="price">$\${(+p.dw_retail).toFixed(2)} <span class="ca">$\${(+p.vendor_retail).toFixed(2)}</span></div>
<div class="specs">\${specBits}</div>
</div>
← 17c8526 viewer: sister parish browse UI on :9790 (sort + density sli
·
back to Sister Parish Onboarding
·
schedule Shopify push for Wed 2am PT via launchd com.steve.s 0956a19 →