← back to Dw Photo Capture
PDP color-dot color-index: /apps/color-index endpoint + generator (catalog-wide 10% CIELAB tolerance)
602bb7fd75dcc45be6e45f1280f0ce46db3c1d22 · 2026-07-09 09:09:49 -0700 · Steve Abrams
New public (CORS-guarded, allowlisted) endpoint POST /apps/color-index on dwphoto:
takes {hex,k}, returns catalog-wide products within a perceptual 10% tolerance
(CIELAB deltaE76, named COLOR_INDEX_TOLERANCE_PCT=0.10 -> deltaE ceiling 10),
nearest-first, public-safe fields only. Backed by a static color-index.json
(one dominant color per ACTIVE product w/ precomputed LAB, 60,960 products) built
by scripts/build-color-index.cjs from dw_unified.product_colors -- no DB at request
time. Index loaded once + mtime-hot-reload. Staged/local; do NOT deploy publicly
without Steve's go.
Verified: forest #3C5B49 -> 97 in-tolerance products, all returned deltaE<=6.3.
Files touched
M .gitignoreA scripts/build-color-index.cjsM server.js
Diff
commit 602bb7fd75dcc45be6e45f1280f0ce46db3c1d22
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 9 09:09:49 2026 -0700
PDP color-dot color-index: /apps/color-index endpoint + generator (catalog-wide 10% CIELAB tolerance)
New public (CORS-guarded, allowlisted) endpoint POST /apps/color-index on dwphoto:
takes {hex,k}, returns catalog-wide products within a perceptual 10% tolerance
(CIELAB deltaE76, named COLOR_INDEX_TOLERANCE_PCT=0.10 -> deltaE ceiling 10),
nearest-first, public-safe fields only. Backed by a static color-index.json
(one dominant color per ACTIVE product w/ precomputed LAB, 60,960 products) built
by scripts/build-color-index.cjs from dw_unified.product_colors -- no DB at request
time. Index loaded once + mtime-hot-reload. Staged/local; do NOT deploy publicly
without Steve's go.
Verified: forest #3C5B49 -> 97 in-tolerance products, all returned deltaE<=6.3.
---
.gitignore | 3 ++
scripts/build-color-index.cjs | 74 +++++++++++++++++++++++++++++++
server.js | 101 +++++++++++++++++++++++++++++++++++++++++-
3 files changed, 177 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index fca3b5c..3264dff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,6 @@ tunnel.err.log
# all-dw feed cache (generated at runtime)
data/all-catalog.json
+
+# generated color-index artifact (rebuild via scripts/build-color-index.cjs)
+data/color-index.json
diff --git a/scripts/build-color-index.cjs b/scripts/build-color-index.cjs
new file mode 100644
index 0000000..1bc9bf5
--- /dev/null
+++ b/scripts/build-color-index.cjs
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+/**
+ * build-color-index.cjs — generate the catalog-wide color index for the PDP
+ * color-dot "index of many products within 10% tolerance" feature.
+ *
+ * Source of truth: dw_unified.product_colors (one dominant color per product,
+ * with a PRECOMPUTED CIELAB lab_l/a/b) joined to the live storefront handle +
+ * image. We emit ONE row per ACTIVE product with a real hex + LAB + handle +
+ * image, so the /apps/color-index endpoint can do a pure perceptual ΔE filter
+ * with no DB dependency at request time.
+ *
+ * Output: data/color-index.json → { generated_at, count, items:[
+ * { h:"handle", t:"title", v:"vendor", x:"#rrggbb", l:LAB_L, a:LAB_A, b:LAB_B, i:"image_url" } ] }
+ * Short keys keep the file small (~60k rows).
+ *
+ * Run where pg resolves (dwphoto's symlinked node_modules has it):
+ * cd ~/Projects/dw-photo-capture && node scripts/build-color-index.cjs
+ * Refreshes are cheap + idempotent; safe to schedule. $0 (local PG).
+ */
+const fs = require('fs');
+const path = require('path');
+const { Pool } = require('pg');
+
+// Same connection convention as the DW cadence scripts (Unix socket, peer auth).
+const pool = new Pool(
+ process.env.DATABASE_URL
+ ? { connectionString: process.env.DATABASE_URL }
+ : { host: '/tmp', database: 'dw_unified' }
+);
+
+const OUT = path.join(__dirname, '..', 'data', 'color-index.json');
+
+(async () => {
+ const t0 = Date.now();
+ // status is case-mixed in this table ('ACTIVE' + 'active'); match case-insensitively.
+ // One row per handle: product_colors already carries one dominant color per product,
+ // but guard against dupes with DISTINCT ON (handle) keeping the highest-lightness…
+ // actually keep the first — any dominant color for the product is fine.
+ const q = `
+ SELECT DISTINCT ON (handle)
+ handle, title, vendor, hex, lab_l, lab_a, lab_b, image_url
+ FROM product_colors
+ WHERE lower(status) = 'active'
+ AND lab_l IS NOT NULL AND lab_a IS NOT NULL AND lab_b IS NOT NULL
+ AND handle IS NOT NULL AND handle <> ''
+ AND image_url IS NOT NULL AND image_url <> ''
+ AND hex ~* '^#?[0-9a-f]{6}$'
+ ORDER BY handle, updated_at DESC NULLS LAST
+ `;
+ const { rows } = await pool.query(q);
+ await pool.end();
+
+ const items = rows.map(r => ({
+ h: r.handle,
+ t: (r.title || '').slice(0, 120),
+ v: (r.vendor || '').slice(0, 60),
+ x: r.hex.startsWith('#') ? r.hex.toLowerCase() : ('#' + r.hex.toLowerCase()),
+ l: Math.round(r.lab_l * 10) / 10,
+ a: Math.round(r.lab_a * 10) / 10,
+ b: Math.round(r.lab_b * 10) / 10,
+ i: r.image_url
+ }));
+
+ const payload = {
+ generated_at: new Date().toISOString(),
+ count: items.length,
+ // NAMED TUNABLE tolerance the endpoint reads too (kept here for provenance).
+ tolerance_pct: 0.10,
+ items
+ };
+ fs.writeFileSync(OUT, JSON.stringify(payload));
+ const kb = Math.round(fs.statSync(OUT).size / 1024);
+ console.log(`color-index: ${items.length} products → ${OUT} (${kb} KB) in ${Date.now() - t0}ms`);
+})().catch(e => { console.error('build-color-index FAILED:', e.message); process.exit(1); });
diff --git a/server.js b/server.js
index 227c1d1..41e2236 100644
--- a/server.js
+++ b/server.js
@@ -361,6 +361,41 @@ function send(res, code, body, headers = {}) {
res.end(typeof body === 'string' ? body : JSON.stringify(body));
}
+// ── Color-index (PDP color-dot → catalog-wide "index of many within 10%") ──────
+// ONE named tolerance knob. "10%" is expressed as a fraction of the practical
+// perceptual CIELAB ΔE76 range (~100 for two clearly-different colors), so 10%
+// maps to a ΔE ceiling of ~10 — tight enough to stay same-family, loose enough
+// to pull MANY products (verified: 41–1001 in-tolerance across sampled hues).
+// Change COLOR_INDEX_TOLERANCE_PCT alone to retune; the ceiling derives from it.
+const COLOR_INDEX_TOLERANCE_PCT = 0.10;
+const COLOR_INDEX_DELTA_E_CEILING = COLOR_INDEX_TOLERANCE_PCT * 100; // = 10 (ΔE76)
+const COLOR_INDEX_PATH = require('path').join(__dirname, 'data', 'color-index.json');
+let _colorIndex = null, _colorIndexMtime = 0;
+// Load the static index once; hot-reload if the file is regenerated (mtime change).
+function loadColorIndex() {
+ const fs = require('fs');
+ const st = fs.statSync(COLOR_INDEX_PATH); // throws if missing → handler 503s
+ if (_colorIndex && st.mtimeMs === _colorIndexMtime) return _colorIndex;
+ const j = JSON.parse(fs.readFileSync(COLOR_INDEX_PATH, 'utf8'));
+ _colorIndex = Array.isArray(j.items) ? j.items : [];
+ _colorIndexMtime = st.mtimeMs;
+ return _colorIndex;
+}
+// sRGB hex → CIELAB (D65), matching how product_colors LAB was computed upstream.
+function hexToLab(hex) {
+ hex = hex.replace('#', '');
+ let r = parseInt(hex.slice(0, 2), 16) / 255,
+ g = parseInt(hex.slice(2, 4), 16) / 255,
+ b = parseInt(hex.slice(4, 6), 16) / 255;
+ const lin = c => (c > 0.04045 ? Math.pow((c + 0.055) / 1.055, 2.4) : c / 12.92);
+ r = lin(r); g = lin(g); b = lin(b);
+ let X = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047;
+ let Y = (r * 0.2126 + g * 0.7152 + b * 0.0722);
+ let Z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
+ const f = t => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
+ return { l: 116 * f(Y) - 16, a: 500 * (f(X) - f(Y)), b: 200 * (f(Y) - f(Z)) };
+}
+
// ── OCR → ranked SKU candidates ────────────────────────────────────────────────
// Allocated ONCE at load (the live scanner hits /api/ocr ~every 600ms, so we never
// want to rebuild these regexes per request). analyzeOcr() is pure — it takes parsed
@@ -866,7 +901,7 @@ function pipeUpstreamImage(src, res, headers) {
const appHandler = (req, res) => {
// public (no-auth) paths: health + the home-screen-install assets iOS fetches without creds
- const PUBLIC = ['/healthz', '/icon-180.png', '/icon-512.png', '/apple-touch-icon.png', '/manifest.webmanifest', '/apps/similar'];
+ const PUBLIC = ['/healthz', '/icon-180.png', '/icon-512.png', '/apple-touch-icon.png', '/manifest.webmanifest', '/apps/similar', '/apps/color-index'];
const _p = req.url.split('?')[0];
if (!PUBLIC.includes(_p) && !checkAuth(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Photo Capture"' });
@@ -929,6 +964,70 @@ const appHandler = (req, res) => {
return;
}
+ // ── PDP color-dot "index of many products within 10% tolerance" ─────────────
+ // (GATED-STEP-2, staged) Steve 2026-07-09: "bring up an index of many images,
+ // not just that exact hex. tolerance 10% for color to pull more."
+ // A clicked palette hex resolves to a CATALOG-WIDE grid of products whose
+ // dominant color is within a perceptual 10% tolerance (CIELAB ΔE76) of the hex,
+ // drawn from data/color-index.json (one dominant color per ACTIVE product,
+ // with PRECOMPUTED LAB; generated by scripts/build-color-index.cjs). No DB at
+ // request time — the index is loaded once and held in memory.
+ // · Public (no basic-auth) but tightly bounded, same posture as /apps/similar.
+ // · Input allowlist: ONLY {hex, k}; k capped at 60. Nothing else is read.
+ // · Output allowlist: only public catalog fields already visible on the store.
+ // · CORS: reflect an ALLOWED storefront origin only.
+ // · 10% tolerance is ONE named constant (COLOR_INDEX_TOLERANCE_PCT) → ΔE ceiling.
+ if (u.pathname === '/apps/color-index') {
+ const ALLOWED_ORIGINS = new Set([
+ 'https://designerwallcoverings.com',
+ 'https://www.designerwallcoverings.com',
+ 'https://designer-laboratory-sandbox.myshopify.com'
+ ]);
+ const origin = req.headers.origin || '';
+ const corsOrigin = ALLOWED_ORIGINS.has(origin) ? origin : 'https://designerwallcoverings.com';
+ const CORS = {
+ 'Access-Control-Allow-Origin': corsOrigin,
+ 'Vary': 'Origin',
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type'
+ };
+ if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
+ if (req.method !== 'POST') return send(res, 405, { err: 'POST required' }, CORS);
+ let body = '';
+ req.on('data', c => { body += c; if (body.length > 4096) req.destroy(); });
+ req.on('end', () => {
+ let p; try { p = JSON.parse(body || '{}'); } catch (e) { return send(res, 400, { err: 'bad json' }, CORS); }
+ const rawHex = (typeof p.hex === 'string') ? p.hex.trim() : '';
+ const hex = /^#?[0-9a-fA-F]{6}$/.test(rawHex) ? ('#' + rawHex.replace('#', '').toLowerCase()) : '';
+ if (!hex) return send(res, 400, { err: 'hex required (#rrggbb)' }, CORS);
+ const k = Math.max(1, Math.min(parseInt(p.k, 10) || 36, 60));
+ try {
+ const idx = loadColorIndex(); // cached, loaded once
+ const target = hexToLab(hex);
+ const ceil = COLOR_INDEX_DELTA_E_CEILING; // 10% → ΔE76 ceiling
+ const within = [];
+ for (let i = 0; i < idx.length; i++) {
+ const it = idx[i];
+ const dl = it.l - target.l, da = it.a - target.a, db = it.b - target.b;
+ const de = Math.sqrt(dl * dl + da * da + db * db);
+ if (de <= ceil) within.push({ it, de });
+ }
+ within.sort((x, y) => x.de - y.de); // nearest first
+ const results = within.slice(0, k).map(w => ({
+ handle: w.it.h, title: w.it.t, vendor: w.it.v,
+ hex: w.it.x, image: w.it.i, delta_e: Math.round(w.de * 10) / 10
+ }));
+ return send(res, 200, {
+ ok: true, hex, tolerance_pct: COLOR_INDEX_TOLERANCE_PCT,
+ delta_e_ceiling: ceil, total_in_tolerance: within.length, results
+ }, CORS);
+ } catch (e) {
+ return send(res, 503, { ok: false, err: 'color index unavailable', results: [] }, CORS);
+ }
+ });
+ return;
+ }
+
// ── Remote-shutter pairing routes ──
// SSE stream for the PHONE cam page. Registers the cam role; listens for `shoot`.
if (u.pathname === '/cam/events') {
← 41d871a CLIP /similar: DW-safe guard — only return storefront produc
·
back to Dw Photo Capture
·
auto-save: 2026-07-09T09:09:49 (1 files) — visual-search/__p 8ef6e5f →