← back to Dw Color Image Audit
color-image-audit: fix family-mapper trailing-material hijack (leather->brown) + multi-word colorways
307493e8936df0c590d5168f80e8929575d21c4d · 2026-06-21 14:50:13 -0700 · Steve
Root cause: nameToFamily/name_family kept the LAST mappable token in a DW title's
color segment. DW titles read 'Pattern - Colorway MaterialType' so the trailing
'Vegan Leather' (leather->brown in lexicon) overrode the true colorway. 27 Tier-1
HIGH rows mis-bucketed brown.
Fix (DTD verdict A, 3/3 Claude+Codex+Qwen): dash-aware colorway parse (segment
after last ' - '), material-stopword skip gated on a real color surviving,
multi-word colorway map (sea green->teal), fallback to whole segment when the
after-dash part has no color (preserves pattern-half colorways). Same fix mirrored
in JS (color-classify.js) and both Python mappers (reextract.py/reextract2.py).
Added reextract2 __main__ guard so the read-only rerun harness imports the real code.
Re-run on existing flagged set (cached hexes, no image re-download, $0): 88
declaredFamily corrected, 17 false-positive HIGHs cleared (incl. Baroque-Orange,
Pinpoint-Sea Green, Crushed Coral-Amber), 21 previously-MASKED real mismatches
unmasked (Sapphire/Azure/Lagoon/Peacock... on off-hue images). Real alarm preserved
(Hawksbill-Walnut brown vs orange still HIGH). No DB/Shopify writes.
Files touched
M scripts/audit-shard.jsM scripts/color-classify.jsM scripts/reextract.pyM scripts/reextract2.pyA scripts/rerun_remap.py
Diff
commit 307493e8936df0c590d5168f80e8929575d21c4d
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jun 21 14:50:13 2026 -0700
color-image-audit: fix family-mapper trailing-material hijack (leather->brown) + multi-word colorways
Root cause: nameToFamily/name_family kept the LAST mappable token in a DW title's
color segment. DW titles read 'Pattern - Colorway MaterialType' so the trailing
'Vegan Leather' (leather->brown in lexicon) overrode the true colorway. 27 Tier-1
HIGH rows mis-bucketed brown.
Fix (DTD verdict A, 3/3 Claude+Codex+Qwen): dash-aware colorway parse (segment
after last ' - '), material-stopword skip gated on a real color surviving,
multi-word colorway map (sea green->teal), fallback to whole segment when the
after-dash part has no color (preserves pattern-half colorways). Same fix mirrored
in JS (color-classify.js) and both Python mappers (reextract.py/reextract2.py).
Added reextract2 __main__ guard so the read-only rerun harness imports the real code.
Re-run on existing flagged set (cached hexes, no image re-download, $0): 88
declaredFamily corrected, 17 false-positive HIGHs cleared (incl. Baroque-Orange,
Pinpoint-Sea Green, Crushed Coral-Amber), 21 previously-MASKED real mismatches
unmasked (Sapphire/Azure/Lagoon/Peacock... on off-hue images). Real alarm preserved
(Hawksbill-Walnut brown vs orange still HIGH). No DB/Shopify writes.
---
scripts/audit-shard.js | 7 +++--
scripts/color-classify.js | 63 ++++++++++++++++++++++++++++++++-----
scripts/reextract.py | 34 ++++++++++++++++++--
scripts/reextract2.py | 80 +++++++++++++++++++++++++++++++++--------------
scripts/rerun_remap.py | 78 +++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 225 insertions(+), 37 deletions(-)
diff --git a/scripts/audit-shard.js b/scripts/audit-shard.js
index f1eb663..bec725a 100644
--- a/scripts/audit-shard.js
+++ b/scripts/audit-shard.js
@@ -4,9 +4,10 @@
const { classify } = require('./color-classify.js');
const readline = require('readline');
-// Extract the declared color phrase from a DW title "Pattern Name Real Color Name | Brand".
-// The color is the segment before the pipe; we feed the whole left segment to the name mapper
-// which picks the last mappable color token.
+// Extract the declared color phrase from a DW title "Pattern Name - Colorway MaterialType | Brand".
+// We feed the whole left segment (before the pipe) to the name mapper; nameToFamily() then
+// isolates the colorway after the last " - ", resolves multi-word colorways, and ignores pure
+// material/finish words so a trailing "Vegan Leather" can't hijack the family.
function declaredFrom(title){
if(!title) return '';
const left = title.split('|')[0].trim();
diff --git a/scripts/color-classify.js b/scripts/color-classify.js
index 590cb1e..e415bce 100644
--- a/scripts/color-classify.js
+++ b/scripts/color-classify.js
@@ -40,17 +40,66 @@ const NAME_FAMILY = {
function tokens(s){ return (s||'').toLowerCase().replace(/[^a-z ]/g,' ').split(/\s+/).filter(Boolean); }
-// Pick a declared family from a color phrase: take the LAST color token that maps
-// (modifiers like "dusty blue", "sea green" -> the head color usually last).
-function nameToFamily(name){
- const ts = tokens(name);
- let fam = null, matched = null;
- for (const t of ts){
- if (NAME_FAMILY[t]){ fam = NAME_FAMILY[t]; matched = t; }
+// 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',
+]);
+
+// 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).
+function _familyFromPhrase(phrase){
+ 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));
+ const pool = nonMaterial.length ? nonMaterial : mappable; // keep a material word only if it's the sole color
+ 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 r = _familyFromPhrase(seg);
+ if (r) return r;
+ if (seg !== (name||'').trim()) return _familyFromPhrase(name); // fallback to full segment
+ return null;
+}
+
// ---- hex -> family ----
function hexToRgb(hex){
if(!hex) return null;
diff --git a/scripts/reextract.py b/scripts/reextract.py
index e5fc7fd..6922756 100644
--- a/scripts/reextract.py
+++ b/scripts/reextract.py
@@ -28,10 +28,38 @@ def fam(r,g,b):
NAME_FAMILY = json.loads(open(sys.argv[1]).read()) if len(sys.argv)>1 else {}
-def name_family(name):
+# Substrate/finish words that name the material, never the colorway. As a trailing token
+# "leather"->brown used to hijack the family (DW titles read "Pattern - Colorway Material").
+# linen/silk/raffia/jute/grasscloth intentionally excluded — they double as beige colorways.
+MATERIAL_STOPWORDS = {"vegan","leather","suede","vinyl","velvet","wallcovering","wallpaper",
+ "commercial","contract","fabric","paper","cloth","faux","textile","weave"}
+# 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"}
+
+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):
+ toks=''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in phrase).split()
+ mw=None
+ for i in range(len(toks)-1):
+ bg=toks[i]+" "+toks[i+1]
+ if bg in MULTIWORD_FAMILY: mw=MULTIWORD_FAMILY[bg]
+ if mw: return mw
+ 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
fam_=None
- for t in ''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in name).split():
- if t in NAME_FAMILY: fam_=NAME_FAMILY[t]
+ 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
return fam_
def is_chrom(f): return f in CHROM
diff --git a/scripts/reextract2.py b/scripts/reextract2.py
index f4542b7..add6c38 100644
--- a/scripts/reextract2.py
+++ b/scripts/reextract2.py
@@ -37,10 +37,38 @@ def fam(r,g,b):
NAME_FAMILY = json.loads(open(sys.argv[1]).read()) if len(sys.argv)>1 else {}
-def name_family(name):
+# Substrate/finish words that name the material, never the colorway. As a trailing token
+# "leather"->brown used to hijack the family (DW titles read "Pattern - Colorway Material").
+# linen/silk/raffia/jute/grasscloth intentionally excluded — they double as beige colorways.
+MATERIAL_STOPWORDS = {"vegan","leather","suede","vinyl","velvet","wallcovering","wallpaper",
+ "commercial","contract","fabric","paper","cloth","faux","textile","weave"}
+# 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"}
+
+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):
+ toks=''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in phrase).split()
+ mw=None
+ for i in range(len(toks)-1):
+ bg=toks[i]+" "+toks[i+1]
+ if bg in MULTIWORD_FAMILY: mw=MULTIWORD_FAMILY[bg]
+ if mw: return mw
+ 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
fam_=None
- for t in ''.join(c.lower() if c.isalpha() or c==' ' else ' ' for c in name).split():
- if t in NAME_FAMILY: fam_=NAME_FAMILY[t]
+ 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
return fam_
def is_chrom(f): return f in CHROM
@@ -106,24 +134,28 @@ def extract(url):
dom_v2 = dom_v1 # essentially all white/black -> keep v1
return dom_v1, dom_v2
-for line in sys.stdin:
- line = line.rstrip("\n")
- if not line.strip(): continue
- parts = line.split("\t")
- if len(parts) < 7: continue
- vendor,spid,status,mfrsku,title,cached,url = parts[:7]
- declared = title.split("|")[0].strip()
- df = name_family(declared)
- rec = {"shopify_product_id":spid, "vendor":vendor, "status":status, "title":title,
- "declared":declared, "cached_hex":cached, "image_url":url, "declaredFamily":df}
- try:
- v1, v2 = extract(url)
- imf, s, l = fam(*v2)
- rec["real_hex_v1"] = "#%02X%02X%02X" % v1
- rec["real_hex"] = "#%02X%02X%02X" % v2
- rec["imageFamily"] = imf
- verdict, reason = classify(df, imf, s, l)
- rec["verdict"] = verdict; rec["reason"] = reason
- except Exception as e:
- rec["verdict"] = "ERROR"; rec["reason"] = f"{type(e).__name__}:{str(e)[:50]}"
- sys.stdout.write(json.dumps(rec)+"\n")
+def _main():
+ for line in sys.stdin:
+ line = line.rstrip("\n")
+ if not line.strip(): continue
+ parts = line.split("\t")
+ if len(parts) < 7: continue
+ vendor,spid,status,mfrsku,title,cached,url = parts[:7]
+ declared = title.split("|")[0].strip()
+ df = name_family(declared)
+ rec = {"shopify_product_id":spid, "vendor":vendor, "status":status, "title":title,
+ "declared":declared, "cached_hex":cached, "image_url":url, "declaredFamily":df}
+ try:
+ v1, v2 = extract(url)
+ imf, s, l = fam(*v2)
+ rec["real_hex_v1"] = "#%02X%02X%02X" % v1
+ rec["real_hex"] = "#%02X%02X%02X" % v2
+ rec["imageFamily"] = imf
+ verdict, reason = classify(df, imf, s, l)
+ rec["verdict"] = verdict; rec["reason"] = reason
+ except Exception as e:
+ rec["verdict"] = "ERROR"; rec["reason"] = f"{type(e).__name__}:{str(e)[:50]}"
+ sys.stdout.write(json.dumps(rec)+"\n")
+
+if __name__ == "__main__":
+ _main()
diff --git a/scripts/rerun_remap.py b/scripts/rerun_remap.py
new file mode 100644
index 0000000..412568e
--- /dev/null
+++ b/scripts/rerun_remap.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""READ-ONLY re-classification of the existing flagged set with the FIXED family-mapper.
+
+Re-runs the audit verdict for every row in out/reextracted.jsonl WITHOUT re-downloading any
+image: it re-derives the declared family from the title with the fixed name_family(), recomputes
+the image family from the already-cached real_hex via fam(), and re-runs classify(). This proves
+the false-positive collapse from the leather-hijack / sea-green bug. $0, no network, no DB/Shopify.
+
+Imports the ACTUAL shipped logic from reextract2 (name_family/fam/classify) so the test can't
+drift from the code it validates.
+"""
+import sys, os, json
+from collections import Counter
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+SCRIPTS = os.path.join(ROOT, "scripts")
+sys.path.insert(0, SCRIPTS)
+sys.argv = [sys.argv[0]] # so reextract2 import sees no argv[1]
+import reextract2 as R # noqa: E402 (importable via __main__ guard)
+R.NAME_FAMILY = json.load(open(os.path.join(SCRIPTS, "name_family.json")))
+
+def hex_rgb(h):
+ h = h.lstrip("#")
+ return int(h[0:2],16), int(h[2:4],16), int(h[4:6],16)
+
+def reclassify(row):
+ """Return (declaredFamily, imageFamily, verdict, reason) under the FIXED mapper."""
+ df = R.name_family(row.get("declared",""))
+ hx = row.get("real_hex") or row.get("cached_hex")
+ if not hx:
+ return df, row.get("imageFamily"), "ERROR", "no-hex"
+ imf, s, l = R.fam(*hex_rgb(hx))
+ v, reason = R.classify(df, imf, s, l)
+ return df, imf, v, reason
+
+def main():
+ src = os.path.join(ROOT, "out", "reextracted.jsonl")
+ rows = [json.loads(x) for x in open(src) if x.strip()]
+ out_path = os.path.join(ROOT, "out", "reextracted_remapped.jsonl")
+
+ before = Counter(); after = Counter()
+ cleared = [] # was HIGH, now not HIGH
+ new_high = [] # was not HIGH, now HIGH (regression watch)
+ fam_changed = [] # declaredFamily changed
+ with open(out_path, "w") as f:
+ for r in rows:
+ ov = r.get("verdict"); odf = r.get("declaredFamily")
+ ndf, nimf, nv, nreason = reclassify(r)
+ before[ov] += 1; after[nv] += 1
+ r2 = dict(r)
+ r2["declaredFamily_old"] = odf; r2["verdict_old"] = ov
+ r2["declaredFamily"] = ndf; r2["imageFamily"] = nimf
+ r2["verdict"] = nv; r2["reason"] = nreason
+ f.write(json.dumps(r2) + "\n")
+ if odf != ndf: fam_changed.append((r2, odf, ndf))
+ if ov == "HIGH" and nv != "HIGH": cleared.append((r2, nv, nreason))
+ if ov != "HIGH" and nv == "HIGH": new_high.append((r2, ov, nreason))
+
+ print("=== VERDICT COUNTS (all", len(rows), "re-extracted rows) ===")
+ print("BEFORE:", dict(before))
+ print("AFTER :", dict(after))
+ print(f"\ndeclaredFamily changed by fix: {len(fam_changed)} rows")
+ print(f"HIGH -> non-HIGH (false positives cleared): {len(cleared)}")
+ print(f"non-HIGH -> HIGH (new flags, regression watch): {len(new_high)}")
+
+ print("\n=== CLEARED (former Tier-1 HIGH now OK/REVIEW) ===")
+ for r, nv, why in sorted(cleared, key=lambda x: x[0].get("dwsku","")):
+ print(f" {r.get('dwsku',''):14} {r.get('declared','')[:42]:42} "
+ f"fam {r.get('declaredFamily_old','')}->{r.get('declaredFamily','')}"
+ f" img={r.get('imageFamily',''):7} -> {nv:6} ({why})")
+
+ if new_high:
+ print("\n=== NEW HIGH (introduced by fix — inspect) ===")
+ for r, ov, why in new_high:
+ print(f" {r.get('dwsku',''):14} {r.get('declared','')[:42]:42} -> HIGH ({why})")
+
+if __name__ == "__main__":
+ main()
← 496eff3 prep: verified putty flat-shots for LIN-980023/980022 wrong-
·
back to Dw Color Image Audit
·
color-image-audit: refine no-dash mapper — demote pattern/pl 30306bf →