← back to Dw Pairs Well
auto-save: 2026-06-26T11:45:25 (1 files) — tools/pattern-id-titlefirst-dryrun.py
84cafc24ce4fad5c3e80ce1c9627a6ca5f86b7e9 · 2026-06-26 11:45:32 -0700 · Steve Abrams
Files touched
A tools/pattern-id-titlefirst-dryrun.py
Diff
commit 84cafc24ce4fad5c3e80ce1c9627a6ca5f86b7e9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jun 26 11:45:32 2026 -0700
auto-save: 2026-06-26T11:45:25 (1 files) — tools/pattern-id-titlefirst-dryrun.py
---
tools/pattern-id-titlefirst-dryrun.py | 198 ++++++++++++++++++++++++++++++++++
1 file changed, 198 insertions(+)
diff --git a/tools/pattern-id-titlefirst-dryrun.py b/tools/pattern-id-titlefirst-dryrun.py
new file mode 100644
index 0000000..dff1844
--- /dev/null
+++ b/tools/pattern-id-titlefirst-dryrun.py
@@ -0,0 +1,198 @@
+#!/usr/bin/env python3
+"""WS-1 TITLE-FIRST + IMAGE-CONFIRMED pattern_id derivation — DRY RUN (read-only, $0 local).
+
+Per /dtd verdict (3/3 A, 2026-06-26): derive the grouping key TITLE-FIRST
+(normalized title primary, mfr-root fallback for junk/empty titles), then
+IMAGE-CONFIRM within each title group so two DIFFERENT designs that merely share
+a title (e.g. "Allegro | Kravet" vs "Allegro | Lee Jofa") split apart, while real
+colorways of one design (same pattern, different colors) stay merged.
+
+NO WRITES. Reads the mirror + tools/pattern-id-by-dwsku.json (the CURRENT, mfr-first
+authoritative keys) and prints a before/after diff so Steve can eyeball the change
+before any gated re-backfill.
+
+Input on stdin: TSV rows dw_sku <TAB> vendor <TAB> title <TAB> mfr_sku <TAB> dhash(16hex or '')
+ (dhash from image_hashes where status='success', joined on image_url)
+
+Color-invariance note: the precomputed image_hashes.dhash is a single grayscale
+dHash (already tone-insensitive for same-tone colorways). For light-on-dark vs
+dark-on-light inversions, tools/pattern-image-verify.py downloads + adds the tonal
+inverse; that finer check is reserved for flagged ambiguous groups, not this sweep.
+"""
+import sys, json, re, os
+from collections import defaultdict, Counter
+
+HAM_THRESH = int(os.environ.get("HAM_THRESH", "12")) # dHash Hamming <= => same pattern
+MAP_PATH = os.path.join(os.path.dirname(__file__), "pattern-id-by-dwsku.json")
+
+# ---- normalization (faithful port of server.js gNormTitle/gStripColors/gMfrRoot/gVendorSlug) ----
+NOISE = ['wallcovering','wallcoverings','wallpaper','wall covering','fabric','fabrics',
+ 'durable vinyl','vinyl','grasscloth','print','commercial','residential',
+ 'type ii','type iii','mural','wall mural','peel and stick','peel-and-stick',
+ 'self adhesive','self-adhesive','by phillipe romano','phillipe romano']
+COLOR_WORDS = set(['light','dark','deep','pale','soft','warm','cool','muted','bright',
+ 'antique','metallic','natural','neutral','multi','multicolor','multicolour',
+ 'black','white','grey','gray','silver','gold','beige','cream','ivory','taupe',
+ 'brown','tan','red','crimson','burgundy','pink','rose','blush','coral','orange',
+ 'rust','terracotta','yellow','ochre','mustard','green','olive','sage','emerald',
+ 'teal','aqua','turquoise','blue','navy','indigo','cobalt','azure','purple','violet',
+ 'lilac','lavender','plum','mauve','charcoal','slate','sand','stone','smoke','linen',
+ 'pearl','champagne','bronze','copper','platinum','marine','lawn','fog','snow',
+ 'azalea','cloud','cobalt','concord','cyan','dandelion','mushroom','seaweed'])
+
+def strip_vendor_suffix(title):
+ i = title.find('|')
+ return (title if i == -1 else title[:i]).strip()
+
+def norm_title(title):
+ s = strip_vendor_suffix(title or '').lower()
+ for n in NOISE: s = s.replace(n, ' ')
+ s = re.sub(r'[^a-z0-9 ]+', ' ', s)
+ s = re.sub(r'\s+', ' ', s).strip()
+ return s
+
+def strip_colors(normalized):
+ kept = [w for w in normalized.split(' ') if w and w not in COLOR_WORDS]
+ return ' '.join(kept).strip() or normalized
+
+def mfr_root(raw):
+ if not raw: return None
+ s = str(raw).strip().upper()
+ if not s: return None
+ toks = [t for t in re.split(r'[-/_.\s]+', s) if t]
+ if len(toks) >= 2 and re.fullmatch(r'\d{1,3}[A-Z]?', toks[-1]):
+ return '-'.join(toks[:-1])
+ return '-'.join(toks)
+
+def vendor_slug(v):
+ s = re.sub(r'[^a-z0-9]+', '-', (v or '').lower()).strip('-')
+ return s or 'no-vendor'
+
+DENY_TITLES = set(['','various','assorted','sample','default','tbd','test','n a','na'])
+
+def title_first_key(vendor, title, mfr):
+ """TITLE-FIRST: normalized title primary; mfr-root only when title is junk/empty."""
+ vslug = vendor_slug(vendor)
+ t2 = strip_colors(norm_title(title))
+ if t2 and t2 not in DENY_TITLES and len(t2) >= 3:
+ return f"{vslug}::t2::{t2}"
+ root = mfr_root(mfr)
+ if root:
+ return f"{vslug}::mfr::{root}"
+ return f"{vslug}::t2::{t2 or 'ungrouped'}"
+
+def ham_hex(a, b):
+ return bin(int(a, 16) ^ int(b, 16)).count("1")
+
+# ---- load CURRENT (mfr-first) authoritative keys for the before/after diff ----
+cur = {}
+try:
+ raw = json.load(open(MAP_PATH))
+ for k, v in raw.items():
+ if v and v.get('pid'): cur[k] = v['pid']
+except Exception as e:
+ print(f"WARN: could not load current map: {e}", file=sys.stderr)
+
+# ---- read rows ----
+rows = []
+for line in sys.stdin:
+ p = line.rstrip('\n').split('\t')
+ if len(p) < 4: continue
+ dw, ven, title, mfr = p[0], p[1], p[2], p[3]
+ dh = p[4].strip() if len(p) >= 5 and p[4].strip() and len(p[4].strip()) == 16 else None
+ if not dw: continue
+ rows.append((dw, ven, title, mfr, dh))
+
+# ---- stage 1: title-first key ----
+for i, (dw, ven, title, mfr, dh) in enumerate(rows):
+ rows[i] = (dw, ven, title, mfr, dh, title_first_key(ven, title, mfr))
+
+# ---- stage 2: image-confirm within each title key (split visual collisions) ----
+# Greedy clustering on precomputed dHash within a title group. Members without a
+# hash join the FIRST cluster (cannot be disconfirmed) and are counted as unconfirmed.
+bykey = defaultdict(list)
+for r in rows: bykey[r[5]].append(r)
+
+final_key = {} # dw_sku -> final (image-confirmed) key
+img_splits = 0 # title groups that split into >1 visual cluster
+confirmed_skus = 0
+unconfirmed_skus = 0
+for key, members in bykey.items():
+ hashed = [m for m in members if m[4]]
+ nohash = [m for m in members if not m[4]]
+ if len(members) == 1 or len(hashed) <= 1:
+ for m in members: final_key[m[0]] = key
+ unconfirmed_skus += len(nohash)
+ confirmed_skus += len(hashed)
+ continue
+ # greedy clusters among hashed members
+ clusters = [] # list of (rep_hash, [members])
+ for m in hashed:
+ placed = False
+ for cl in clusters:
+ if ham_hex(m[4], cl[0]) <= HAM_THRESH:
+ cl[1].append(m); placed = True; break
+ if not placed:
+ clusters.append((m[4], [m]))
+ if len(clusters) == 1:
+ for m in members: final_key[m[0]] = key
+ confirmed_skus += len(hashed); unconfirmed_skus += len(nohash)
+ else:
+ img_splits += 1
+ # assign each visual cluster a distinct sub-key; unhashed members -> largest cluster
+ clusters.sort(key=lambda c: -len(c[1]))
+ for ci, (rep, cms) in enumerate(clusters):
+ sub = key if ci == 0 else f"{key}#v{ci+1}"
+ for m in cms: final_key[m[0]] = sub
+ for m in nohash: final_key[m[0]] = key
+ confirmed_skus += len(hashed); unconfirmed_skus += len(nohash)
+
+# ---- metrics: before (current mfr-first) vs after (title-first + image) ----
+def group_stats(keyfn):
+ k2title = defaultdict(set); vt2k = defaultdict(set)
+ for (dw, ven, title, mfr, dh, _tk) in rows:
+ k = keyfn(dw, ven, title, mfr)
+ if not k: continue
+ nt = strip_colors(norm_title(title))
+ if nt: k2title[k].add(nt); vt2k[(ven.lower(), nt)].add(k)
+ over = sum(1 for k, ts in k2title.items() if len(ts) > 1) # distinct designs lumped
+ under = sum(1 for vt, ks in vt2k.items() if len(ks) > 1) # one design fragmented
+ tiles = len(k2title)
+ return tiles, over, under
+
+before = group_stats(lambda dw, ven, title, mfr: cur.get(dw))
+after = group_stats(lambda dw, ven, title, mfr: final_key.get(dw))
+
+print("="*70)
+print(f"TITLE-FIRST + IMAGE-CONFIRMED pattern_id — DRY RUN (no writes)")
+print(f"active rows: {len(rows)} · HAM_THRESH={HAM_THRESH}")
+print(f"image coverage: {confirmed_skus} hashed / {unconfirmed_skus} title-only "
+ f"({confirmed_skus*100//max(1,len(rows))}% image-confirmable)")
+print(f"image-driven splits (title groups broken into visual clusters): {img_splits}")
+print("="*70)
+print(f"{'metric':<42}{'BEFORE (mfr-first)':>16}{'AFTER':>12}")
+print(f"{' distinct pattern tiles':<42}{before[0]:>16}{after[0]:>12}")
+print(f"{' OVER-merge keys (diff designs lumped)':<42}{before[1]:>16}{after[1]:>12}")
+print(f"{' UNDER-merge groups (1 design split)':<42}{before[2]:>16}{after[2]:>12}")
+print("="*70)
+
+# ---- worst-offender spotlight ----
+def show(label, match):
+ cur_keys = Counter(); new_keys = Counter()
+ for (dw, ven, title, mfr, dh, _tk) in rows:
+ if not match(ven, title): continue
+ cur_keys[cur.get(dw, '(none)')] += 1
+ new_keys[final_key.get(dw, '(none)')] += 1
+ print(f"\n— {label} —")
+ print(f" BEFORE: {len(cur_keys)} key(s) -> " +
+ ", ".join(f"{k.split('::')[-1]}×{n}" for k, n in cur_keys.most_common(4)))
+ print(f" AFTER : {len(new_keys)} key(s) -> " +
+ ", ".join(f"{k.split('::')[-1]}×{n}" for k, n in new_keys.most_common(4)))
+
+show("Yakatore (was fragmented 7 ways)", lambda v, t: 'phillipe' in v.lower() and 'yakatore' in t.lower())
+show("Coordonne Durable (was 366 keys)", lambda v, t: 'coordonn' in t.lower())
+show("Ultrasuede 5538 (was 93 titles merged)", lambda v, t: 'ultrasuede' in v.lower())
+show("Phillipe Romano CORK (was 16 designs merged)",
+ lambda v, t: 'phillipe' in v.lower() and 'cork' in t.lower() and 'corka' not in t.lower())
+show("La Corka (real 11-colorway pattern — must stay merged)",
+ lambda v, t: 'phillipe' in v.lower() and 'corka' in t.lower())
← e22b966 auto-save: 2026-06-26T11:15:12 (1 files) — server.js
·
back to Dw Pairs Well
·
WS-1: title-first + color-invariant image-confirm pattern_id 11cab42 →