[object Object]

← 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

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 →