[object Object]

← back to Wallco Ai

add read-only color-name vs dominant-hex drift audit (10171 root-cause)

5713b85223adda54e0e7365ff9660ebba6d85a9e · 2026-06-01 16:35:06 -0700 · Steve Abrams

Files touched

Diff

commit 5713b85223adda54e0e7365ff9660ebba6d85a9e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 16:35:06 2026 -0700

    add read-only color-name vs dominant-hex drift audit (10171 root-cause)
---
 scripts/audit-colorname-hex-drift.py | 195 +++++++++++++++++++++++++++++++++++
 1 file changed, 195 insertions(+)

diff --git a/scripts/audit-colorname-hex-drift.py b/scripts/audit-colorname-hex-drift.py
new file mode 100644
index 0000000..4ff6123
--- /dev/null
+++ b/scripts/audit-colorname-hex-drift.py
@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+"""audit-colorname-hex-drift.py — READ-ONLY scope audit for the color-name vs
+dominant-hex decoupling Steve flagged on /design/10171 ("Crimson Studio /
+Midnight Navy" displayed over a #FFCFD8 pale-pink chip).
+
+Root cause: the LIVE prod data/designs.json snapshot carries titles +
+"style · colorway" categories whose color WORD was generated by an OLDER
+refresh_designs_snapshot.py — before the lightness-aware colorway_name() and
+ground_hex() ground-sampling fixes landed (both 2026-05-31). The current Mac2
+script already derives the color name from dominant_hex correctly. So the fix
+is to REBUILD the snapshot on Mac2 with the current script (no code change
+needed for the colorway logic) and ship it.
+
+This script reproduces the LIVE display name (as the prod payload shows it) and
+the CORRECTED name the current colorway_name() would produce, and reports how
+many published rows would change. NO writes. Pure audit.
+
+Compares against the LIVE prod catalog pulled via the public /api/designs API
+(no auth, no DB write) so the number reflects what shoppers actually see.
+
+Usage:
+  python3 scripts/audit-colorname-hex-drift.py            # pulls live API
+  python3 scripts/audit-colorname-hex-drift.py /tmp/prod_all.json   # cached
+"""
+import sys, json, re, colorsys, urllib.request
+
+# ── current snapshot colorway_name() (verbatim from refresh_designs_snapshot.py)
+COLORWAYS = {
+    'bands': [
+        (15,  {'dark': 'Oxblood',     'mid': 'Claret',   'light': 'Blush'}),
+        (35,  {'dark': 'Terracotta',  'mid': 'Amber',    'light': 'Apricot'}),
+        (55,  {'dark': 'Ochre',       'mid': 'Honey',    'light': 'Chamois'}),
+        (80,  {'dark': 'Loden',       'mid': 'Olive',    'light': 'Chartreuse'}),
+        (155, {'dark': 'Bottle Green','mid': 'Sage',     'light': 'Celadon'}),
+        (200, {'dark': 'Verdigris',   'mid': 'Teal',     'light': 'Eau de Nil'}),
+        (250, {'dark': 'Prussian',    'mid': 'Sapphire', 'light': 'Delft'}),
+        (290, {'dark': 'Aubergine',   'mid': 'Amethyst', 'light': 'Wisteria'}),
+        (330, {'dark': 'Damson',      'mid': 'Plum',     'light': 'Mauve'}),
+        (361, {'dark': 'Garnet',      'mid': 'Rosewood', 'light': 'Rose'}),
+    ],
+    'neutral': {'vlight': 'Alabaster', 'light': 'Oyster', 'mid': 'Greige',
+                'dark': 'Pewter', 'vdark': 'Charcoal'},
+}
+
+def hex_to_hls(hex_):
+    if not hex_ or not hex_.startswith('#'):
+        return None
+    h = hex_.lstrip('#')
+    if len(h) == 3:
+        h = ''.join(c * 2 for c in h)
+    if len(h) < 6 or any(c not in '0123456789abcdefABCDEF' for c in h[:6]):
+        return None
+    r, g, b = int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255
+    return colorsys.rgb_to_hls(r, g, b)
+
+def colorway_name(hex_):
+    hls = hex_to_hls(hex_)
+    if hls is None:
+        return None
+    h, l, s = hls
+    if s < 0.10:
+        n = COLORWAYS['neutral']
+        if l > 0.85: return n['vlight']
+        if l > 0.65: return n['light']
+        if l > 0.42: return n['mid']
+        if l > 0.22: return n['dark']
+        return n['vdark']
+    deg = h * 360
+    for maxd, shades in COLORWAYS['bands']:
+        if deg < maxd:
+            return shades['light' if l > 0.62 else 'dark' if l < 0.34 else 'mid']
+    return 'Rose'
+
+# coarse chromatic zone, for "hard" (>=2 buckets apart) disagreement
+def zone(hex_):
+    hls = hex_to_hls(hex_)
+    if hls is None:
+        return None
+    h, s, l = hls
+    if s < 0.12: return 'Neutral'
+    d = h * 360
+    if d >= 345 or d < 15: return 'Red'
+    if d < 60:  return 'Warm'
+    if d < 170: return 'Green'
+    if d < 260: return 'Blue'
+    if d < 290: return 'Violet'
+    return 'Pink'
+
+# named color word → coarse zone (titles + category slugs)
+WORD_ZONE = {
+    'crimson': 'Red', 'scarlet': 'Red', 'oxblood': 'Red', 'bordeaux': 'Red',
+    'garnet': 'Red', 'ruby': 'Red', 'claret': 'Red', 'rosewood': 'Red',
+    'terracotta': 'Warm', 'amber': 'Warm', 'rust': 'Warm', 'copper': 'Warm',
+    'honey': 'Warm', 'gold': 'Warm', 'brass': 'Warm', 'ochre': 'Warm',
+    'apricot': 'Warm', 'chamois': 'Warm', 'saffron': 'Warm', 'mustard': 'Warm',
+    'olive': 'Green', 'loden': 'Green', 'moss': 'Green', 'forest': 'Green',
+    'sage': 'Green', 'celadon': 'Green', 'emerald': 'Green', 'jade': 'Green',
+    'chartreuse': 'Green', 'verdant': 'Green',
+    'teal': 'Blue', 'verdigris': 'Blue', 'navy': 'Blue', 'indigo': 'Blue',
+    'sapphire': 'Blue', 'cobalt': 'Blue', 'azure': 'Blue', 'midnight': 'Blue',
+    'prussian': 'Blue', 'delft': 'Blue',
+    'violet': 'Violet', 'plum': 'Violet', 'aubergine': 'Violet',
+    'amethyst': 'Violet', 'wisteria': 'Violet', 'damson': 'Violet',
+    'rose': 'Pink', 'blush': 'Pink', 'pink': 'Pink', 'mauve': 'Pink',
+    'fuchsia': 'Pink', 'magenta': 'Pink',
+}
+
+SUFFIXES = r'(Studio|Atelier|Folio|Salon|Reverie|House|Manor|Origin|Drift|' \
+           r'Daydream|Halcyon|Vespers|Idyll|Repose|Slumber|Haze|Lullaby)'
+TITLE_RE = re.compile(r'^([A-Za-z]+)\s+' + SUFFIXES + r'\s+No\.\d+', re.I)
+IN_RE    = re.compile(r'\bin\s+([A-Za-z][A-Za-z\- ]*?)\s*$', re.I)
+
+def title_color_word(title):
+    if not title:
+        return None
+    m = TITLE_RE.match(title.strip())
+    if m:
+        return m.group(1).lower()
+    m = IN_RE.search(title.strip())
+    if m:
+        return m.group(1).strip().split()[-1].lower()
+    return None
+
+def load():
+    if len(sys.argv) > 1:
+        return json.load(open(sys.argv[1]))
+    items, page = [], 1
+    while True:
+        url = f'https://wallco.ai/api/designs?limit=2000&page={page}'
+        with urllib.request.urlopen(url, timeout=30) as r:
+            d = json.loads(r.read())
+        items += d['items']
+        if page >= d['pages']:
+            break
+        page += 1
+    seen = {}
+    for it in items:
+        seen[it['id']] = it
+    return list(seen.values())
+
+def main():
+    items = load()
+    pub = [d for d in items if d.get('is_published') and not d.get('user_removed')]
+
+    title_total = title_hard = 0
+    cat_total = cat_hard = 0
+    would_rename = 0
+    examples = []
+
+    for d in pub:
+        hexd = d.get('dominant_hex') or ''
+        corrected = colorway_name(hexd)
+        zh = zone(hexd)
+
+        # title color word
+        tw = title_color_word(d.get('title') or '')
+        if tw and tw in WORD_ZONE:
+            title_total += 1
+            zw = WORD_ZONE[tw]
+            if zh and zw != 'Neutral' and zh != 'Neutral' and zw != zh:
+                title_hard += 1
+                if corrected and len(examples) < 30:
+                    examples.append((d['id'], d.get('title'), hexd, zw, zh, corrected))
+
+        # category "style · slug" color word
+        cat = d.get('category') or ''
+        if ' · ' in cat:
+            cw = cat.split(' · ', 1)[1].strip().lower().split('-')[0]
+            if cw in WORD_ZONE:
+                cat_total += 1
+                zw = WORD_ZONE[cw]
+                if zh and zw != 'Neutral' and zh != 'Neutral' and zw != zh:
+                    cat_hard += 1
+
+        # would the current snapshot give a DIFFERENT color word than what's displayed?
+        if corrected and tw and corrected.lower() != tw:
+            would_rename += 1
+
+    print(f"LIVE published (not user_removed):           {len(pub)}")
+    print()
+    print(f"TITLE color-word vs hex (evaluable):          {title_total}")
+    print(f"  HARD chromatic mismatch (Steve's 10171):    {title_hard}")
+    print(f"CATEGORY 'style · slug' color vs hex (eval):  {cat_total}")
+    print(f"  HARD chromatic mismatch:                    {cat_hard}")
+    print()
+    print(f"Rows whose displayed color WORD != the name   ")
+    print(f"the CURRENT colorway_name(hex) would produce: {would_rename}")
+    print(f"  (== records a Mac2 snapshot rebuild would visibly re-title)")
+    print()
+    print("Sample hard mismatches (id | live title | hex | named-zone -> hex-zone | corrected):")
+    for e in examples:
+        print(f"  {e[0]:>6} | {e[1]:<28} | {e[2]:<9} | {e[3]}->{e[4]:<7} | {e[5]}")
+
+if __name__ == '__main__':
+    main()

← 4746851 PDP: explicit Wallpaper/Wall Mural format toggle on every de  ·  back to Wallco Ai  ·  PDP v11-best (live theme): Wallpaper/Wall Mural toggle on ev bd05114 →