← back to Dw Color Image Audit
scripts/color-classify.js
287 lines
'use strict';
// Color classifier for DW image-integrity audit.
// Maps (a) a declared color NAME -> hue family, and (b) an image HEX -> hue family,
// then decides HIGH (non-adjacent family) / REVIEW (adjacent or deltaE) / OK.
// Pure local, $0. No paid calls.
// ---- 12-step hue wheel families (chromatic) + neutrals ----
// Ordered so adjacency = |i-j| (mod 12) <= 1.
const HUE_WHEEL = ['red','orange','yellow','chartreuse','green','teal','cyan','blue','indigo','violet','magenta','pink'];
// neutrals handled separately
const NEUTRALS = ['white','gray','black','beige','brown'];
// ---- name -> family lexicon (interior-designer colorway vocabulary) ----
// Each entry maps a lowercased color-name token to a family.
// brown/beige/taupe treated as warm-neutral; we flag chromatic-vs-neutral conflicts.
const NAME_FAMILY = {
// reds
red:'red', scarlet:'red', crimson:'red', cherry:'red', ruby:'red', cardinal:'red', poppy:'red', vermilion:'red', tomato:'red', brick:'red', cinnabar:'red', garnet:'red', wine:'red', burgundy:'red', claret:'red', maroon:'red', oxblood:'red', rust:'orange', terracotta:'orange', paprika:'red',
// pinks
pink:'pink', rose:'pink', blush:'pink', fuchsia:'magenta', magenta:'magenta', salmon:'pink', coral:'orange', peony:'pink', flamingo:'pink', petal:'pink', rosewood:'pink', dusty:'pink',
// oranges
orange:'orange', tangerine:'orange', apricot:'orange', amber:'orange', pumpkin:'orange', persimmon:'orange', marmalade:'orange', clay:'orange', copper:'orange', spice:'orange', cinnamon:'orange', ginger:'orange', sienna:'orange', ochre:'orange', mango:'orange',
// yellows
yellow:'yellow', gold:'yellow', golden:'yellow', butter:'yellow', lemon:'yellow', mustard:'yellow', honey:'yellow', citrine:'yellow', saffron:'yellow', daffodil:'yellow', maize:'yellow', sunflower:'yellow', canary:'yellow', flax:'yellow',
// greens
green:'green', emerald:'green', olive:'chartreuse', moss:'green', sage:'green', fern:'green', forest:'green', jade:'green', mint:'green', pistachio:'chartreuse', celadon:'green', verdigris:'teal', basil:'green', pine:'green', hunter:'green', kelly:'green', avocado:'chartreuse', leaf:'green', grass:'green', lime:'chartreuse', chartreuse:'chartreuse', apple:'chartreuse', loden:'green', juniper:'green', seaglass:'teal', seafoam:'teal',
// teals / cyans / aquas
teal:'teal', aqua:'cyan', aquamarine:'cyan', turquoise:'cyan', cyan:'cyan', lagoon:'teal', peacock:'teal', ocean:'teal', spruce:'teal',
// blues
blue:'blue', navy:'blue', cobalt:'blue', sapphire:'blue', azure:'blue', cerulean:'blue', denim:'blue', indigo:'indigo', sky:'blue', powder:'blue', cornflower:'blue', periwinkle:'indigo', delft:'blue', prussian:'blue', marine:'blue', slate:'blue', steel:'blue', chambray:'blue', wedgwood:'blue', midnight:'blue',
// violets / purples
violet:'violet', purple:'violet', lavender:'violet', lilac:'violet', plum:'violet', amethyst:'violet', mauve:'violet', orchid:'magenta', aubergine:'violet', eggplant:'violet', heather:'violet', iris:'violet', grape:'violet',
// neutrals
white:'white', ivory:'white', cream:'white', alabaster:'white', chalk:'white', snow:'white', pearl:'white', porcelain:'white', linen:'white', eggshell:'white', vanilla:'white', bone:'white', oyster:'white', dove:'white',
gray:'gray', grey:'gray', graphite:'gray', charcoal:'gray', ash:'gray', smoke:'gray', pewter:'gray', flannel:'gray', stone:'gray', silver:'gray', nickel:'gray', gunmetal:'gray', zinc:'gray', cement:'gray', concrete:'gray', fog:'gray', mist:'gray', cloud:'gray',
black:'black', ebony:'black', onyx:'black', jet:'black', noir:'black', obsidian:'black', raven:'black', ink:'black', coal:'black', soot:'black',
beige:'beige', tan:'beige', sand:'beige', taupe:'beige', greige:'beige', oatmeal:'beige', oat:'beige', wheat:'beige', khaki:'beige', camel:'beige', biscuit:'beige', parchment:'beige', natural:'beige', flax2:'beige', putty:'beige', mushroom:'beige', driftwood:'beige', fawn:'beige', buff:'beige', nude:'beige', champagne:'beige', straw:'beige', hemp:'beige', jute:'beige', raffia:'beige',
brown:'brown', chocolate:'brown', espresso:'brown', coffee:'brown', mocha:'brown', walnut:'brown', chestnut:'brown', mahogany:'brown', cocoa:'brown', umber:'brown', bronze:'brown', tobacco:'brown', caramel:'brown', toffee:'brown', hazelnut:'brown', pecan:'brown', truffle:'brown', bark:'brown', earth:'brown', leather:'brown', saddle:'brown', sepia:'brown', fudge:'brown',
};
function tokens(s){ return (s||'').toLowerCase().replace(/[^a-z ]/g,' ').split(/\s+/).filter(Boolean); }
// Pure material / finish / substrate words that name the SUBSTRATE, never the colorway.
// DW titles read "PatternName - Colorway MaterialType" (e.g. "Baroque - Orange Vegan Leather").
// "leather" maps to brown in the lexicon, so as a LAST token it used to hijack the family and
// bury the real colorway. We strip these — but only when a real color token still remains, so a
// product whose colorway genuinely IS a material-ish word (e.g. "- Linen") is preserved.
// NOTE: linen / silk / raffia / jute / grasscloth are intentionally NOT here — they double as
// legitimate beige colorways.
const MATERIAL_STOPWORDS = new Set([
'vegan','leather','suede','vinyl','velvet','wallcovering','wallpaper',
'commercial','contract','fabric','paper','cloth','faux','textile','weave',
]);
// Botanical / place / motif homonyms that ARE in the color lexicon but, in a DW title,
// almost always name the PATTERN / theme / material — not the declared colorway:
// grass (grasscloth substrate) ocean (coastal theme)
// marine ("Cote Marine" place name) leaf (foliage motif)
// apple ("Big Apple" motif) forest ("Fir Forest"/"Virgin Forest" motif)
// They only mislead on the NO-DASH / whole-title path: with no " - " separator the whole
// title is scanned and one of these can become the (wrong) family when it's the sole color
// signal. On the clean after-dash colorway segment ("Pattern - Ocean") the word genuinely IS
// the colorway, so demotion is gated to the no-dash path only (see nameToFamily). When a real
// colorway co-occurs it's the trailing token and already wins, so demotion only bites when the
// homonym stands alone — in which case we fall through to UNKNOWN rather than assert a wrong
// family (DTD verdict A, 3/3 Claude+Codex+Qwen, 2026-06-21). Deliberately conservative: common
// real colorways (sage/olive/coral/pine/fern/forest-green via a surviving "green") are NOT here.
const AMBIGUOUS_COLORWORDS = new Set([
'grass','ocean','marine','leaf','apple','forest',
]);
// Multi-word colorways whose family is NOT the family of any single token.
// "Sea Green" reads as the trailing token "green" -> green, but sea-green is perceptually
// teal/cyan (consistent with the lexicon's seafoam/seaglass -> teal). Checked as bigrams
// BEFORE single tokens so the more specific phrase wins.
const MULTIWORD_FAMILY = {
'sea green':'teal', 'sea foam':'teal', 'sea glass':'teal', 'sea blue':'teal',
};
// Isolate the colorway phrase from a DW title's left segment.
// DW convention is "PatternName - Colorway MaterialType"; the colorway sits AFTER the last
// " - " separator. When there is no separator, the whole segment is the candidate.
function colorwaySegment(name){
const s = (name||'').trim();
const idx = s.lastIndexOf(' - ');
return idx >= 0 ? s.slice(idx + 3) : s;
}
// Resolve a family from a single phrase (multi-word colorway first, else material-aware last token).
// demoteAmbiguous: when true (no-dash / whole-title path), a botanical/place/motif homonym does
// NOT establish the family on its own — prefer a genuine colorway, else fall through to UNKNOWN.
function _familyFromPhrase(phrase, demoteAmbiguous){
const ts = tokens(phrase);
// multi-word colorway (scan bigrams; last match wins, consistent with the last-token rule)
let mwFam = null, mwTok = null;
for (let i = 0; i + 1 < ts.length; i++){
const bg = ts[i] + ' ' + ts[i+1];
if (MULTIWORD_FAMILY[bg]){ mwFam = MULTIWORD_FAMILY[bg]; mwTok = bg; }
}
if (mwFam) return { family: mwFam, token: mwTok };
// single-token, material-stopword-aware
const mappable = ts.filter(t => NAME_FAMILY[t]);
const nonMaterial = mappable.filter(t => !MATERIAL_STOPWORDS.has(t));
let pool = nonMaterial.length ? nonMaterial : mappable; // keep a material word only if it's the sole color
if (demoteAmbiguous){
const nonAmbiguous = pool.filter(t => !AMBIGUOUS_COLORWORDS.has(t));
if (nonAmbiguous.length) pool = nonAmbiguous; // a real colorway survives -> use it
else return null; // only pattern/place/material homonyms -> UNKNOWN
}
let fam = null, matched = null;
for (const t of pool){ fam = NAME_FAMILY[t]; matched = t; } // LAST in pool
return fam ? { family: fam, token: matched } : null;
}
// Pick a declared family from a title's left segment.
// 1. prefer the colorway phrase after the last " - " (DW "Pattern - Colorway Material" convention)
// 2. if that phrase has no resolvable color (colorway lives in the pattern half, or the after-dash
// part is a non-color descriptor), FALL BACK to the whole segment so real signal isn't lost.
function nameToFamily(name){
const seg = colorwaySegment(name);
const hasDash = seg !== (name||'').trim();
// After-dash colorway is the declared colorway verbatim -> no demotion ("X - Ocean" IS teal).
// No-dash whole title mixes pattern+colorway -> demote ambiguous homonyms.
const r = _familyFromPhrase(seg, !hasDash);
if (r) return r;
if (hasDash) return _familyFromPhrase(name, true); // fallback over whole title -> demote ambiguous
return null;
}
// ---- hex -> family ----
function hexToRgb(hex){
if(!hex) return null;
const m = String(hex).trim().replace('#','');
if(m.length<6) return null;
return [parseInt(m.slice(0,2),16),parseInt(m.slice(2,4),16),parseInt(m.slice(4,6),16)];
}
function rgbToHsl(r,g,b){
r/=255;g/=255;b/=255;
const max=Math.max(r,g,b),min=Math.min(r,g,b);let h,s,l=(max+min)/2;
if(max===min){h=s=0;}else{const d=max-min;s=l>0.5?d/(2-max-min):d/(max+min);
switch(max){case r:h=(g-b)/d+(g<b?6:0);break;case g:h=(b-r)/d+2;break;default:h=(r-g)/d+4;}h/=6;}
return [h*360,s,l];
}
function hexToFamily(hex){
const rgb = hexToRgb(hex); if(!rgb) return null;
const [h,s,l] = rgbToHsl(...rgb);
// RGB channel spread is a robust chroma proxy near black/white where HSL saturation explodes.
const spread = (Math.max(...rgb) - Math.min(...rgb)) / 255; // 0..1
// Very light with small absolute spread = cream/ivory/off-white neutral (HSL sat lies here).
if (l > 0.86 && spread < 0.22){
return { family: 'white', chroma: spread, l };
}
// Very dark with small spread = near-black neutral.
if (l < 0.16 && spread < 0.22){
return { family: 'black', chroma: spread, l };
}
// near-neutral test: low saturation OR very light/dark with modest sat
if (s < 0.15 || (l > 0.88 && s < 0.25) || (l < 0.14 && s < 0.4)){
if (l > 0.82) return {family:'white', chroma:s, l};
if (l < 0.16) return {family:'black', chroma:s, l};
return {family:'gray', chroma:s, l};
}
// low-mid saturation warm (covers tan/taupe/espresso/brown/cream) -> beige/brown neutrals
// Cream/ivory like #FFFDD0 / #F5F5DC are very light warm tones: treat as beige (warm-neutral), not yellow.
if (s < 0.45 && h>=18 && h<=65){
if (l > 0.90) return { family:'white', chroma:s, l }; // cream/ivory ~ off-white
return { family: l>0.55 ? 'beige' : 'brown', chroma:s, l };
}
// very light warm-yellow with modest sat (#F5F5DC beige, #FFFDD0 cream) -> beige neutral
if (s < 0.30 && h>65 && h<=70 && l>0.85){
return { family:'beige', chroma:s, l };
}
// chromatic: map hue degrees -> 12 families
// red ~0/345, orange 25, yellow 55, chartreuse 80, green 120, teal 165, cyan 185, blue 215, indigo 250, violet 275, magenta 305, pink 330
const ranges = [
[345,361,'red'],[0,15,'red'],[15,40,'orange'],[40,67,'yellow'],[67,95,'chartreuse'],
[95,150,'green'],[150,175,'teal'],[175,195,'cyan'],[195,240,'blue'],[240,265,'indigo'],
[265,290,'violet'],[290,320,'magenta'],[320,345,'pink']
];
for(const [a,b,f] of ranges){ if(h>=a && h<b) return {family:f, chroma:s, l}; }
return {family:'red', chroma:s, l};
}
// ---- compare ----
function wheelIndex(f){ return HUE_WHEEL.indexOf(f); }
function isChromatic(f){ return HUE_WHEEL.includes(f); }
function isNeutral(f){ return NEUTRALS.includes(f); }
function hueDistance(a,b){ // circular distance on 12 wheel
const ia=wheelIndex(a), ib=wheelIndex(b); if(ia<0||ib<0) return null;
const d=Math.abs(ia-ib); return Math.min(d,12-d);
}
// Adjacency of two neutrals (gray/black/white close; beige/brown close)
const NEUTRAL_GROUPS = { white:'light', gray:'mid', black:'dark', beige:'warm', brown:'warm' };
function classify(declaredName, hex){
const dn = nameToFamily(declaredName);
const im = hexToFamily(hex);
if(!dn || !im) return { verdict:'UNKNOWN', reason: !dn?'name-unmapped':'hex-unparsed', declaredFamily:dn&&dn.family, imageFamily:im&&im.family };
const df = dn.family, imf = im.family;
if (df === imf) return { verdict:'OK', declaredFamily:df, imageFamily:imf, reason:'same-family' };
const dChrom = isChromatic(df), iChrom = isChromatic(imf);
// CHROMA FLOOR: a near-white / very-pale-ground dominant pixel has a meaningless hue.
// Most DW patterns are light-ground with a delicate motif; the dominant hex is the FIELD,
// not the named accent. Do NOT hard-flag those as a hue mismatch — downgrade to REVIEW.
// im.l = lightness, im.chroma = saturation from hexToFamily.
const paleGround = (im.l != null && im.l > 0.80 && (im.chroma == null || im.chroma < 0.22));
const lowChroma = (im.chroma != null && im.chroma < 0.18);
// both chromatic: use hue-wheel distance
if (dChrom && iChrom){
const d = hueDistance(df,imf);
if (paleGround || lowChroma) return { verdict:'REVIEW', declaredFamily:df, imageFamily:imf, reason:`pale/low-chroma dominant (likely light-ground motif), hue unreliable (dist ${d})` };
if (d >= 2) return { verdict:'HIGH', declaredFamily:df, imageFamily:imf, reason:`non-adjacent hue families (dist ${d})` };
return { verdict:'REVIEW', declaredFamily:df, imageFamily:imf, reason:`adjacent hue families (dist ${d})` };
}
// chromatic vs neutral
if (dChrom !== iChrom){
// declared chromatic but image is strongly neutral OR declared neutral but image strongly chromatic
const imgChroma = im.chroma!=null?im.chroma:0;
// Pale/white dominant on a chromatic-named product = light-ground motif artifact -> REVIEW, not HIGH.
if (dChrom && (imf==='white' || (imf==='gray' && im.l!=null && im.l>0.72))){
return { verdict:'REVIEW', declaredFamily:df, imageFamily:imf, reason:'chromatic name vs pale dominant (likely light-ground motif)' };
}
// If declared is a real color (blue/green/etc) but image reads dark gray/black -> HIGH (color truly absent)
if (dChrom && (imf==='gray'||imf==='black')) return { verdict:'HIGH', declaredFamily:df, imageFamily:imf, reason:'declared chromatic, image dark-neutral (color missing/washed)' };
// declared neutral but image strongly chromatic -> HIGH
if (!dChrom && iChrom && imgChroma>0.45) return { verdict:'HIGH', declaredFamily:df, imageFamily:imf, reason:'declared neutral, image strongly chromatic' };
// declared chromatic but image is beige/brown (warm neutral) - softer
if (dChrom && (imf==='beige'||imf==='brown')){
// blue/green/violet declared but warm-brown image = real mismatch
if (['blue','indigo','violet','cyan','teal','green'].includes(df)) return { verdict:'HIGH', declaredFamily:df, imageFamily:imf, reason:'cool color declared, warm-neutral image' };
return { verdict:'REVIEW', declaredFamily:df, imageFamily:imf, reason:'warm color vs warm-neutral image' };
}
return { verdict:'REVIEW', declaredFamily:df, imageFamily:imf, reason:'chromatic/neutral boundary' };
}
// both neutral
const ga = NEUTRAL_GROUPS[df], gb = NEUTRAL_GROUPS[imf];
if (ga===gb) return { verdict:'OK', declaredFamily:df, imageFamily:imf, reason:'same neutral group' };
// white vs black is a real flag; warm(beige/brown) vs cool-neutral(gray) softer
if ((df==='white'&&imf==='black')||(df==='black'&&imf==='white')) return { verdict:'HIGH', declaredFamily:df, imageFamily:imf, reason:'opposite-value neutrals (white vs black)' };
return { verdict:'REVIEW', declaredFamily:df, imageFamily:imf, reason:`neutral family shift ${df}->${imf}` };
}
module.exports = { classify, nameToFamily, hexToFamily };
if (require.main === module){
// quick self-test
const tests = [
['Charcoal','#6b6e72'], ['Espresso','#3a2a1e'], ['Celadon','#acc9a8'],
['Navy Blue','#1b2a4a'], ['Navy Blue','#8a5a2a'], ['Sage Green','#9fae8b'],
['Sky Blue','#c2402a'], ['Ivory','#f4f0e6'], ['Emerald','#0e6b3a'], ['Emerald','#8a8d90']
];
for(const [n,h] of tests) console.log(n, h, '->', JSON.stringify(classify(n,h)));
// ---- assertion harness: no-dash ambiguous-homonym demotion (Option A) ----
// Each case asserts the DECLARED family resolved from the title (image hex irrelevant here).
const famOf = (n) => { const r = nameToFamily(n); return r && r.family; };
const cases = [
// no-dash, ambiguous homonym is the SOLE color signal -> UNKNOWN (null family)
['Faux Leaf Squares', null],
['Westport Conn Forest Wallcovering', null],
['Alana Abaca Tight Woven Grass', null],
['Cote Marine Durable Vinyl', null],
['Big Apple Squares', null],
['Voyage Ocean Coastal', null],
// no-dash, a GENUINE colorway co-occurs -> real color survives (homonym demoted)
['Big Blue Apple', 'blue'],
['Miramar Navy Wallcovering', 'blue'],
['Sage Plain', 'green'], // sage is NOT ambiguous -> preserved
['Seahorse Mangrove Spring Green Wallcovering', 'green'],
// after-dash colorway: homonym genuinely IS the colorway -> NOT demoted
['Pinpoint - Ocean Vegan Leather', 'teal'],
['Lazur Wallcovering - Sea Green Wallcovering', 'teal'],
['Baroque - Orange Vegan Leather', 'orange'],
];
let fails = 0;
for (const [n, want] of cases){
const got = famOf(n) || null;
const ok = got === want;
if (!ok){ fails++; console.error(`SELFTEST FAIL: "${n}" -> ${got} (want ${want})`); }
}
console.log(fails ? `SELF-TEST: ${fails} FAILED` : `SELF-TEST: ${cases.length}/${cases.length} PASS`);
if (fails) process.exit(1);
}