[object Object]

← back to Dw Pairs Well

CLIP-1: /api/similar?engine=clip — image-embedding nearest-neighbour via visual-search service, graceful fallback to tag engine (opt-in, non-breaking)

52301eab591d5f4670ecb5facf1b67170b2ec647 · 2026-07-12 23:05:46 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 52301eab591d5f4670ecb5facf1b67170b2ec647
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 12 23:05:46 2026 -0700

    CLIP-1: /api/similar?engine=clip — image-embedding nearest-neighbour via visual-search service, graceful fallback to tag engine (opt-in, non-breaking)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 server.js | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 89 insertions(+), 11 deletions(-)

diff --git a/server.js b/server.js
index 349af2f..ce8ce94 100644
--- a/server.js
+++ b/server.js
@@ -14,6 +14,12 @@ const DATABASE_URL = process.env.DATABASE_URL;
 const CORS_ORIGINS = (process.env.CORS_ORIGIN || '*')
   .split(',').map(s => s.trim()).filter(Boolean);
 
+// CLIP visual-search service (dw-photo-capture/visual-search/search_service.py).
+// Used by /api/similar?engine=clip for image-embedding nearest-neighbour. Optional:
+// if unreachable OR the source SKU has no stored vector, /api/similar transparently
+// falls back to the tag engine, so this is a pure enhancement.
+const VISUAL_SEARCH_URL = process.env.VISUAL_SEARCH_URL || 'http://127.0.0.1:9914';
+
 if (!DATABASE_URL) {
   console.error('FATAL: DATABASE_URL not set — copy .env.example to .env');
   process.exit(1);
@@ -241,6 +247,67 @@ function paletteOverlap(sourcePalette, candPalette) {
   return { score: total, pickedUp, avgDE };
 }
 
+// Batch-load enrichment rows for a set of dw_skus (for CLIP-result UI parity:
+// title, palette, vendor, handle). First row per dw_sku wins.
+async function loadRowsByDwSkus(skus) {
+  if (!skus || !skus.length) return [];
+  const q = `SELECT ${SOURCE_COLS}
+       FROM shopify_products sp
+       LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
+       WHERE sp.dw_sku = ANY($1)`;
+  const r = await pool.query(q, [skus]);
+  const seen = new Set(), out = [];
+  for (const row of r.rows) {
+    if (!row.dw_sku || seen.has(row.dw_sku)) continue;
+    seen.add(row.dw_sku); out.push(row);
+  }
+  return out;
+}
+
+// CLIP nearest-neighbour "similar" via the visual-search service. Returns an array
+// in the SAME shape pickSimilar() produces (_score/_palette/_dE/_why) so the shared
+// palette+paint response tail is reused verbatim. Returns null on any miss (service
+// down, source SKU never embedded, empty result) → caller falls back to the tag engine.
+async function clipSimilarTop(source, k) {
+  if (!source.dw_sku) return null;
+  try {
+    const body = { dw_sku: source.dw_sku, hex: source.dominant_hex || '', k };
+    const r = await fetch(`${VISUAL_SEARCH_URL}/similar`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+      signal: AbortSignal.timeout(4000)
+    });
+    if (!r.ok) return null;                              // 404 = no stored vector for this sku
+    const d = await r.json();
+    if (!d || !d.ok || !Array.isArray(d.results) || d.results.length === 0) return null;
+
+    const skus = d.results.map(x => x.dw_sku).filter(Boolean);
+    const rowByS = new Map((await loadRowsByDwSkus(skus)).map(row => [row.dw_sku, row]));
+    const sourcePalette = paletteOf(source);
+
+    return d.results.map(x => {
+      const row = rowByS.get(x.dw_sku) || null;
+      const candPalette = row ? paletteOf(row) : [];
+      const overlap = paletteOverlap(sourcePalette, candPalette);
+      const why = [`Visual match (CLIP · cosine ${Number(x.score).toFixed(3)})`];
+      if (overlap.pickedUp.length) why.push(`Picks up: ${overlap.pickedUp.slice(0, 3).join(', ')}`);
+      return {
+        dw_sku: x.dw_sku,
+        handle: x.handle || (row && row.handle) || null,
+        title: (row && row.title) || x.pattern || x.dw_sku,
+        vendor: x.vendor || (row && row.vendor) || null,
+        image_url: x.image || (row && row.image_url) || null,
+        pattern_name: x.pattern || (row && row.pattern_name) || null,
+        _score: x.score, _palette: candPalette, _dE: overlap.avgDE, _why: why
+      };
+    });
+  } catch (e) {
+    console.error('clipSimilarTop fallback →', e.message);
+    return null;
+  }
+}
+
 function pickPairs(source, candidates, k = 24) {
   const sCls = classify(parseTags(source.tags));
   const sourcePalette = paletteOf(source);
@@ -849,19 +916,29 @@ app.get('/api/similar', async (req, res) => {
     const source = await loadSource({ dw_sku, handle });
     if (!source) return res.status(404).json({ ok: false, error: 'source not found' });
 
-    // "Similar" keys on motif/material/era tags — without tags we can't compute it.
-    if (!source.tags || parseTags(source.tags).length === 0) {
-      return res.json({
-        ok: true,
-        source: { dw_sku: source.dw_sku, handle: source.handle, title: source.title, image_url: source.image_url, vendor: source.vendor },
-        similar: [],
-        reason: 'no tags on source — cannot compute similar designs'
-      });
+    const requestedK = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 12));
+    const engine = (req.query.engine === 'clip') ? 'clip' : 'tag';
+
+    // CLIP engine (opt-in): image-embedding nearest-neighbour, no tag dependency.
+    let top = null, usedEngine = 'tag';
+    if (engine === 'clip') {
+      top = await clipSimilarTop(source, requestedK);   // null → service down / sku not embedded
+      if (top) usedEngine = 'clip';
     }
 
-    const requestedK = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 12));
-    const candidates = await loadSimilarCandidates(source, 1200);
-    const top = pickSimilar(source, candidates, requestedK);
+    // Tag engine (default, and the fallback when CLIP misses). Keys on motif/material/era.
+    if (!top) {
+      if (!source.tags || parseTags(source.tags).length === 0) {
+        return res.json({
+          ok: true, engine: 'tag',
+          source: { dw_sku: source.dw_sku, handle: source.handle, title: source.title, image_url: source.image_url, vendor: source.vendor },
+          similar: [],
+          reason: 'no tags on source — cannot compute similar designs'
+        });
+      }
+      const candidates = await loadSimilarCandidates(source, 1200);
+      top = pickSimilar(source, candidates, requestedK);
+    }
 
     // Palette dots + paint chips, same shape as /api/pairs for UI parity.
     const sourcePalette = paletteOf(source);
@@ -882,6 +959,7 @@ app.get('/api/similar', async (req, res) => {
     res.set('Cache-Control', 'public, max-age=300');
     res.json({
       ok: true,
+      engine: usedEngine,
       source: {
         dw_sku: source.dw_sku, handle: source.handle, title: source.title,
         vendor: source.vendor, image_url: source.image_url, palette: paletteWithPaints(sourcePalette)

← 76a8e98 Design Coordinate: guard degenerate pattern labels (no more  ·  back to Dw Pairs Well  ·  auto-save: 2026-07-12T23:21:12 (1 files) — server.js 399cce5 →