← back to Dw Pairs Well

tools/pattern-id-titlefirst-final.py

157 lines

#!/usr/bin/env python3
"""WS-1 FINAL: TITLE-FIRST + COLOR-INVARIANT IMAGE-CONFIRMED pattern_id.

Same title-first derivation as the dry-run, but the image-confirm step uses the
COLOR-INVARIANT signature pair from tools/ci-hashes.jsonl (dHash + tonal inverse),
so light-on-dark vs dark-on-light colorways of one motif stay merged while genuinely
different designs sharing a title split apart.

Two products are the same pattern when:
    min( ham(A.dh,B.dh), ham(A.inv,B.inv), ham(A.dh,B.inv), ham(A.inv,B.dh) ) <= THRESH

Default THRESH=12 (the pattern-image-verify.py value tuned for the CI pair).

Modes:
  (default) print before/after diff + worst-offender spotlight
  --emit    write tools/pattern-id-titlefirst-by-dwsku.json {dw_sku:{pid,sid}}
            for the gated re-backfill (sid carried over from the old map).

Input on stdin: dw_sku<TAB>vendor<TAB>title<TAB>mfr_sku<TAB>image_url
"""
import sys, json, re, os
from collections import defaultdict, Counter

THRESH = int(os.environ.get("HAM_THRESH", "30"))  # WS-1 chosen 2026-06-26: lowest TH that fully merges Yakatore(1)+La Corka(1); over-merge flat at 42, under-merge 5682->559
HERE = os.path.dirname(__file__)
CI_PATH  = os.path.join(HERE, "ci-hashes.jsonl")
OLD_MAP  = os.path.join(HERE, "pattern-id-by-dwsku.json")
EMIT = "--emit" in sys.argv
EMIT_PATH = os.path.join(HERE, "pattern-id-titlefirst-by-dwsku.json")

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']
CW = 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 concord cyan dandelion mushroom seaweed'.split())
DENY = set(['','various','assorted','sample','default','tbd','test','n a','na'])

def nt(t):
    s = t.split('|')[0].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 ' '.join(w for w in s.split() if w not in CW).strip() or s
def vs(v):
    s = re.sub(r'[^a-z0-9]+', '-', (v or '').lower()).strip('-'); return s or 'no-vendor'
def mfr_root(raw):
    if not raw: return None
    s = str(raw).strip().upper()
    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) or None
def title_key(ven, title, mfr):
    t2 = nt(title)
    if t2 and t2 not in DENY and len(t2) >= 3: return f"{vs(ven)}::t2::{t2}"
    r = mfr_root(mfr)
    return f"{vs(ven)}::mfr::{r}" if r else f"{vs(ven)}::t2::{t2 or 'ungrouped'}"

def ham(a, b): return bin(int(a,16) ^ int(b,16)).count("1")
def ci_dist(A, B):
    return min(ham(A[0],B[0]), ham(A[1],B[1]), ham(A[0],B[1]), ham(A[1],B[0]))

# load color-invariant hashes by url
ci = {}
if os.path.exists(CI_PATH):
    for line in open(CI_PATH):
        try:
            r = json.loads(line)
            if r.get("status") == "ok": ci[r["url"]] = (r["dhash"], r["inv"])
        except Exception: pass

old = {}
try:
    for k, v in json.load(open(OLD_MAP)).items():
        if v and v.get("pid"): old[k] = (v["pid"], v.get("sid"))
except Exception: pass

rows = []
for line in sys.stdin:
    p = line.rstrip("\n").split("\t")
    if len(p) < 5: continue
    dw, ven, title, mfr, url = p[0], p[1], p[2], p[3], p[4]
    if not dw: continue
    rows.append((dw, ven, title, mfr, url, ci.get(url)))

# stage 1: title key
tkey = {r[0]: title_key(r[1], r[2], r[3]) for r in rows}
bykey = defaultdict(list)
for r in rows: bykey[tkey[r[0]]].append(r)

# stage 2: color-invariant confirm within each title group
final = {}; img_splits = 0; conf = 0; unconf = 0
for key, members in bykey.items():
    hashed = [m for m in members if m[5]]
    nohash = [m for m in members if not m[5]]
    conf += len(hashed); unconf += len(nohash)
    if len(members) == 1 or len(hashed) <= 1:
        for m in members: final[m[0]] = key
        continue
    clusters = []  # (rep_sig, [skus])
    for m in hashed:
        placed = False
        for cl in clusters:
            if ci_dist(m[5], cl[0]) <= THRESH: cl[1].append(m[0]); placed = True; break
        if not placed: clusters.append((m[5], [m[0]]))
    if len(clusters) == 1:
        for m in members: final[m[0]] = key
    else:
        img_splits += 1
        clusters.sort(key=lambda c: -len(c[1]))
        for ci_i, (rep, sk) in enumerate(clusters):
            sub = key if ci_i == 0 else f"{key}#v{ci_i+1}"
            for s in sk: final[s] = sub
        for m in nohash: final[m[0]] = key  # unhashed -> largest cluster

def stats(getkey):
    k2t = defaultdict(set); vt2k = defaultdict(set)
    for (dw, ven, title, mfr, url, sig) in rows:
        k = getkey(dw)
        if not k: continue
        n = nt(title)
        if n: k2t[k].add(n); vt2k[(ven.lower(), n)].add(k)
    return (len(k2t), sum(1 for ts in k2t.values() if len(ts) > 1),
            sum(1 for ks in vt2k.values() if len(ks) > 1))

b = stats(lambda dw: old.get(dw, (None,))[0])
a = stats(lambda dw: final.get(dw))
print("="*70)
print("TITLE-FIRST + COLOR-INVARIANT IMAGE-CONFIRMED  —  FINAL (no writes)")
print(f"rows {len(rows)} · THRESH {THRESH} · CI-hashed {conf} / title-only {unconf} "
      f"({conf*100//max(1,len(rows))}%) · image splits {img_splits}")
print("="*70)
print(f"{'metric':<42}{'BEFORE':>14}{'AFTER':>12}")
print(f"{'  distinct pattern tiles':<42}{b[0]:>14}{a[0]:>12}")
print(f"{'  OVER-merge keys (diff designs lumped)':<42}{b[1]:>14}{a[1]:>12}")
print(f"{'  UNDER-merge (1 design fragmented)':<42}{b[2]:>14}{a[2]:>12}")
print("="*70)

def show(label, match):
    bc, ac = Counter(), Counter()
    for (dw, ven, title, mfr, url, sig) in rows:
        if not match(ven, title): continue
        bc[old.get(dw,('(none)',))[0]] += 1; ac[final.get(dw,'(none)')] += 1
    print(f"\n— {label} —")
    print(f"   BEFORE {len(bc)} key(s): " + ", ".join(f"{k.split('::')[-1]}×{n}" for k,n in bc.most_common(4)))
    print(f"   AFTER  {len(ac)} key(s): " + ", ".join(f"{k.split('::')[-1]}×{n}" for k,n in ac.most_common(4)))

show("Yakatore (real colorways — MUST stay together)", lambda v,t:'phillipe' in v.lower() and 'yakatore' in t.lower())
show("La Corka (real 11-colorway — MUST stay together)", lambda v,t:'phillipe' in v.lower() and 'corka' in t.lower())
show("Ultrasuede 5538 (93 designs — MUST split)", lambda v,t:'ultrasuede' in v.lower())
show("Phillipe Romano CORK (16 designs — MUST split)", lambda v,t:'phillipe' in v.lower() and 'cork' in t.lower() and 'corka' not in t.lower())

if EMIT:
    out = {}
    for dw, k in final.items():
        out[dw] = {"pid": k, "sid": old.get(dw, (None, None))[1]}
    json.dump(out, open(EMIT_PATH, "w"))
    print(f"\n[emit] wrote {len(out)} keys -> {EMIT_PATH}")