[object Object]

← 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

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 →