← back to Interiordesignershowroom
scripts/enrich-colors.js
173 lines
#!/usr/bin/env node
// TK-10112 — Classify each product's dominant color FROM ITS IMAGE so the Room
// Builder's 12-color filter is meaningful. products.color was title-derived, so
// almost everything landed in 'neutral' and the color swatches returned nothing.
//
// For each product (NOT is_wall_paint, image_url present): download the image,
// downsample to 16x16, average RGB across pixels while down-weighting near-white
// background pixels (product photos are usually shot on white), then map the
// average color -> HSV -> one of the 12-color-filter buckets and UPDATE
// products.color.
//
// Buckets (must match the Room Builder filter, server.js COLORS_12 + presets):
// neutral, gray, black, brown, blue, green, pink, yellow, red
//
// Safe/reversible: only touches products.color. Resumable — pass --only-neutral
// to reprocess just the un-enriched rows. Concurrency-capped, 8s per-image
// timeout, skips (leaves color unchanged) on any download/decode failure.
const db = require('../lib/db');
const sharp = require('sharp');
const CONCURRENCY = 8;
const FETCH_TIMEOUT_MS = 8000;
const PROGRESS_EVERY = 200;
// ---- color math -----------------------------------------------------------
function rgbToHsv(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
const s = max === 0 ? 0 : d / max;
const v = max;
return { h, s, v };
}
// Map an average RGB to one of the 9 filter buckets.
function bucketFor(r, g, b) {
const { h, s, v } = rgbToHsv(r, g, b);
// Low-saturation → grayscale family (neutral / gray / black).
if (s < 0.12) {
if (v < 0.2) return 'black';
if (v < 0.65) return 'gray';
return 'neutral';
}
// Chromatic. A dark, warm, muted color reads as brown (wood/leather/taupe)
// rather than a saturated red/orange.
const warm = (h >= 345 || h < 45);
if (v < 0.45 && warm) return 'brown';
if (h >= 345 || h < 15) return 'red'; // red
if (h < 45) return 'brown'; // orange/amber → brown family
if (h < 70) return 'yellow'; // yellow
if (h < 165) return 'green'; // green
if (h < 255) return 'blue'; // cyan→blue
if (h < 285) return 'blue'; // purple → blue bucket (no purple swatch)
if (h < 345) return 'pink'; // magenta/pink
return 'red';
}
// ---- per-image analysis ---------------------------------------------------
async function fetchImage(url) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: ctrl.signal,
headers: { 'User-Agent': 'Mozilla/5.0 (color-enrich/1.0)' },
});
if (!res.ok) return null;
const ab = await res.arrayBuffer();
return Buffer.from(ab);
} catch {
return null;
} finally {
clearTimeout(t);
}
}
async function dominantBucket(buf) {
// 16x16, alpha stripped, raw RGB. `fit: inside` preserves aspect so we don't
// stretch. removeAlpha() flattens onto nothing — but raw() gives 3 channels.
const { data, info } = await sharp(buf)
.resize(16, 16, { fit: 'inside' })
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const ch = info.channels; // 3 after removeAlpha
let rw = 0, gw = 0, bw = 0, wsum = 0;
let rAll = 0, gAll = 0, bAll = 0, n = 0;
for (let i = 0; i + ch - 1 < data.length; i += ch) {
const r = data[i], g = data[i + 1], b = data[i + 2];
rAll += r; gAll += g; bAll += b; n++;
// Down-weight near-white pixels (photo background). Non-white subject
// pixels get full weight so the SUBJECT color dominates the average.
const nearWhite = r > 235 && g > 235 && b > 235;
const w = nearWhite ? 0.08 : 1;
rw += r * w; gw += g * w; bw += b * w; wsum += w;
}
if (n === 0) return null;
// If the whole image is near-white (wsum collapsed to just background weight),
// fall back to the plain average so we still classify it (as neutral/gray).
let R, G, B;
if (wsum < 0.5) {
R = rAll / n; G = gAll / n; B = bAll / n;
} else {
R = rw / wsum; G = gw / wsum; B = bw / wsum;
}
return bucketFor(R, G, B);
}
// ---- driver ---------------------------------------------------------------
async function main() {
const onlyNeutral = process.argv.includes('--only-neutral');
const where = onlyNeutral
? "image_url IS NOT NULL AND NOT is_wall_paint AND (color IS NULL OR color = 'neutral')"
: 'image_url IS NOT NULL AND NOT is_wall_paint';
const { rows } = await db.query(
`SELECT id, image_url FROM products WHERE ${where} ORDER BY id`
);
console.log(`[enrich-colors] ${rows.length} products to classify (concurrency ${CONCURRENCY})`);
let done = 0, updated = 0, skipped = 0;
const tally = {};
let idx = 0;
async function worker() {
while (idx < rows.length) {
const row = rows[idx++];
try {
const buf = await fetchImage(row.image_url);
if (!buf) { skipped++; continue; }
const bucket = await dominantBucket(buf);
if (!bucket) { skipped++; continue; }
await db.query('UPDATE products SET color = $1 WHERE id = $2', [bucket, row.id]);
updated++;
tally[bucket] = (tally[bucket] || 0) + 1;
} catch {
skipped++;
} finally {
done++;
if (done % PROGRESS_EVERY === 0) {
console.log(` ${done}/${rows.length} updated=${updated} skipped=${skipped}`);
}
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
console.log(`[enrich-colors] done. updated=${updated} skipped=${skipped}`);
console.log('[enrich-colors] this run bucketed:', JSON.stringify(tally));
await db.pool.end();
}
main().catch((e) => { console.error(e); process.exit(1); });