← 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);
}