← back to Wallco Ai
scripts/audit-colorname-hex-drift.py
196 lines
#!/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()