← back to Dw Color Image Audit
color-image-audit: refine no-dash mapper — demote pattern/place/material homonyms (grass/ocean/marine/leaf/apple/forest)
30306bff8fb17ef4ffb62176cd80339080e794ec · 2026-06-21 16:57:21 -0700 · Steve
Second mapper-completeness gap the dash-fix (307493e) didn't cover. On NO-DASH
titles the whole title is scanned and a botanical/place/motif homonym in the
lexicon (grass->green [grasscloth], ocean->teal, marine->blue ['Cote Marine'],
leaf->green, apple->chartreuse ['Big Apple'], forest->green ['Fir/Virgin Forest'])
was mis-read as the colorway, inflating raw-HIGH noise.
Fix (DTD verdict A, 3/3 Claude+Codex+Qwen): add AMBIGUOUS_COLORWORDS set +
demote_ambiguous flag in _familyFromPhrase / _family_from_phrase. Demotion is
gated to the NO-DASH / whole-title path ONLY — the clean after-dash colorway
segment is untouched, so 'X - Ocean' still resolves teal and Hawksbill-Forest /
Hawksbill-Ocean stay genuine HIGHs. When a real colorway co-occurs it survives
and wins; when the only signal is a homonym, fall through to UNKNOWN instead of
asserting a wrong family. Mirrored identically in JS + both Python mappers.
Read-only rerun on the existing flagged set (cached hexes, no image re-download,
$0, no network): vs the post-dash-fix baseline HIGH 629->602 (27 false-positive
HIGHs cleared), 26 homonym-only no-dash titles routed HIGH->UNKNOWN, ZERO new
HIGH regressions (new_high held at 21, all dash-fix unmaskings). JS<->Python
parity verified across all 1831 titles (0 real mismatches). Self-test 13/13 PASS.
No DB/Shopify writes; catalog disposition unchanged (~925 set stays HOLD).
Files touched
M scripts/color-classify.jsM scripts/reextract.pyM scripts/reextract2.py
Diff
commit 30306bff8fb17ef4ffb62176cd80339080e794ec
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jun 21 16:57:21 2026 -0700
color-image-audit: refine no-dash mapper — demote pattern/place/material homonyms (grass/ocean/marine/leaf/apple/forest)
Second mapper-completeness gap the dash-fix (307493e) didn't cover. On NO-DASH
titles the whole title is scanned and a botanical/place/motif homonym in the
lexicon (grass->green [grasscloth], ocean->teal, marine->blue ['Cote Marine'],
leaf->green, apple->chartreuse ['Big Apple'], forest->green ['Fir/Virgin Forest'])
was mis-read as the colorway, inflating raw-HIGH noise.
Fix (DTD verdict A, 3/3 Claude+Codex+Qwen): add AMBIGUOUS_COLORWORDS set +
demote_ambiguous flag in _familyFromPhrase / _family_from_phrase. Demotion is
gated to the NO-DASH / whole-title path ONLY — the clean after-dash colorway
segment is untouched, so 'X - Ocean' still resolves teal and Hawksbill-Forest /
Hawksbill-Ocean stay genuine HIGHs. When a real colorway co-occurs it survives
and wins; when the only signal is a homonym, fall through to UNKNOWN instead of
asserting a wrong family. Mirrored identically in JS + both Python mappers.
Read-only rerun on the existing flagged set (cached hexes, no image re-download,
$0, no network): vs the post-dash-fix baseline HIGH 629->602 (27 false-positive
HIGHs cleared), 26 homonym-only no-dash titles routed HIGH->UNKNOWN, ZERO new
HIGH regressions (new_high held at 21, all dash-fix unmaskings). JS<->Python
parity verified across all 1831 titles (0 real mismatches). Self-test 13/13 PASS.
No DB/Shopify writes; catalog disposition unchanged (~925 set stays HOLD).
---
scripts/color-classify.js | 65 ++++++++++++++++++++++++++++++++++++++++++++---
scripts/reextract.py | 21 ++++++++++++---
scripts/reextract2.py | 21 ++++++++++++---
3 files changed, 95 insertions(+), 12 deletions(-)
diff --git a/scripts/color-classify.js b/scripts/color-classify.js
index e415bce..55ff803 100644
--- a/scripts/color-classify.js
+++ b/scripts/color-classify.js
@@ -52,6 +52,23 @@ const MATERIAL_STOPWORDS = new Set([
'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
@@ -70,7 +87,9 @@ function colorwaySegment(name){
}
// Resolve a family from a single phrase (multi-word colorway first, else material-aware last token).
-function _familyFromPhrase(phrase){
+// 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;
@@ -82,7 +101,12 @@ function _familyFromPhrase(phrase){
// single-token, material-stopword-aware
const mappable = ts.filter(t => NAME_FAMILY[t]);
const nonMaterial = mappable.filter(t => !MATERIAL_STOPWORDS.has(t));
- const pool = nonMaterial.length ? nonMaterial : mappable; // keep a material word only if it's the sole color
+ 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;
@@ -94,9 +118,12 @@ function _familyFromPhrase(phrase){
// 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 r = _familyFromPhrase(seg);
+ 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 (seg !== (name||'').trim()) return _familyFromPhrase(name); // fallback to full segment
+ if (hasDash) return _familyFromPhrase(name, true); // fallback over whole title -> demote ambiguous
return null;
}
@@ -226,4 +253,34 @@ if (require.main === module){
['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);
}
diff --git a/scripts/reextract.py b/scripts/reextract.py
index 6922756..ba73d91 100644
--- a/scripts/reextract.py
+++ b/scripts/reextract.py
@@ -36,12 +36,18 @@ MATERIAL_STOPWORDS = {"vegan","leather","suede","vinyl","velvet","wallcovering",
# Multi-word colorways whose family != any single token's (sea-green is teal, per the lexicon's
# seafoam/seaglass->teal). Checked before single tokens.
MULTIWORD_FAMILY = {"sea green":"teal","sea foam":"teal","sea glass":"teal","sea blue":"teal"}
+# Botanical/place/motif homonyms that ARE in the lexicon but, in a DW title, almost always name
+# the PATTERN/theme/material, not the colorway. They only mislead on the NO-DASH / whole-title
+# path; on the clean after-dash colorway segment the word genuinely IS the colorway, so demotion
+# is gated to the no-dash path (see name_family). Demotion only bites when the homonym is the SOLE
+# color signal -> fall through to UNKNOWN (DTD verdict A 3/3, 2026-06-21). Mirror of JS.
+AMBIGUOUS_COLORWORDS = {"grass","ocean","marine","leaf","apple","forest"}
def _colorway_segment(name):
s=(name or "").strip(); i=s.rfind(" - ")
return s[i+3:] if i>=0 else s
-def _family_from_phrase(phrase):
+def _family_from_phrase(phrase, demote_ambiguous=False):
toks=''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in phrase).split()
mw=None
for i in range(len(toks)-1):
@@ -51,15 +57,22 @@ def _family_from_phrase(phrase):
mappable=[t for t in toks if t in NAME_FAMILY]
non_mat=[t for t in mappable if t not in MATERIAL_STOPWORDS]
pool=non_mat if non_mat else mappable
+ if demote_ambiguous:
+ non_amb=[t for t in pool if t not in AMBIGUOUS_COLORWORDS]
+ if non_amb: pool=non_amb # a real colorway survives -> use it
+ else: return None # only pattern/place/material homonyms -> UNKNOWN
fam_=None
for t in pool: fam_=NAME_FAMILY[t]
return fam_
def name_family(name):
seg=_colorway_segment(name)
- fam_=_family_from_phrase(seg)
- if fam_ is None and seg != (name or "").strip():
- fam_=_family_from_phrase(name) # fallback: colorway in the pattern half
+ has_dash = seg != (name or "").strip()
+ # After-dash colorway is the declared colorway verbatim -> no demotion ("X - Ocean" IS teal).
+ # No-dash whole title mixes pattern+colorway -> demote ambiguous homonyms.
+ fam_=_family_from_phrase(seg, demote_ambiguous=not has_dash)
+ if fam_ is None and has_dash:
+ fam_=_family_from_phrase(name, demote_ambiguous=True) # fallback over whole title
return fam_
def is_chrom(f): return f in CHROM
diff --git a/scripts/reextract2.py b/scripts/reextract2.py
index add6c38..f99306a 100644
--- a/scripts/reextract2.py
+++ b/scripts/reextract2.py
@@ -45,12 +45,18 @@ MATERIAL_STOPWORDS = {"vegan","leather","suede","vinyl","velvet","wallcovering",
# Multi-word colorways whose family != any single token's (sea-green is teal, per the lexicon's
# seafoam/seaglass->teal). Checked before single tokens.
MULTIWORD_FAMILY = {"sea green":"teal","sea foam":"teal","sea glass":"teal","sea blue":"teal"}
+# Botanical/place/motif homonyms that ARE in the lexicon but, in a DW title, almost always name
+# the PATTERN/theme/material, not the colorway. They only mislead on the NO-DASH / whole-title
+# path; on the clean after-dash colorway segment the word genuinely IS the colorway, so demotion
+# is gated to the no-dash path (see name_family). Demotion only bites when the homonym is the SOLE
+# color signal -> fall through to UNKNOWN (DTD verdict A 3/3, 2026-06-21). Mirror of JS.
+AMBIGUOUS_COLORWORDS = {"grass","ocean","marine","leaf","apple","forest"}
def _colorway_segment(name):
s=(name or "").strip(); i=s.rfind(" - ")
return s[i+3:] if i>=0 else s
-def _family_from_phrase(phrase):
+def _family_from_phrase(phrase, demote_ambiguous=False):
toks=''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in phrase).split()
mw=None
for i in range(len(toks)-1):
@@ -60,15 +66,22 @@ def _family_from_phrase(phrase):
mappable=[t for t in toks if t in NAME_FAMILY]
non_mat=[t for t in mappable if t not in MATERIAL_STOPWORDS]
pool=non_mat if non_mat else mappable
+ if demote_ambiguous:
+ non_amb=[t for t in pool if t not in AMBIGUOUS_COLORWORDS]
+ if non_amb: pool=non_amb # a real colorway survives -> use it
+ else: return None # only pattern/place/material homonyms -> UNKNOWN
fam_=None
for t in pool: fam_=NAME_FAMILY[t]
return fam_
def name_family(name):
seg=_colorway_segment(name)
- fam_=_family_from_phrase(seg)
- if fam_ is None and seg != (name or "").strip():
- fam_=_family_from_phrase(name) # fallback: colorway in the pattern half
+ has_dash = seg != (name or "").strip()
+ # After-dash colorway is the declared colorway verbatim -> no demotion ("X - Ocean" IS teal).
+ # No-dash whole title mixes pattern+colorway -> demote ambiguous homonyms.
+ fam_=_family_from_phrase(seg, demote_ambiguous=not has_dash)
+ if fam_ is None and has_dash:
+ fam_=_family_from_phrase(name, demote_ambiguous=True) # fallback over whole title
return fam_
def is_chrom(f): return f in CHROM
← 307493e color-image-audit: fix family-mapper trailing-material hijac
·
back to Dw Color Image Audit
·
PR raw-930 close-out: Gemini-vision spot-check on 40 stronge 7b31afd →