← back to Dw Pairs Well
tools/pattern-grouping-demo.py
205 lines
#!/usr/bin/env python3
"""Visual proof of the collapse-colorways feature (READ-ONLY, local, $0).
For a handful of real DW colorway families it:
1. derives a candidate pattern (strip vendor suffix + product-type noise + color words),
2. CONFIRMS with the color-invariant image hash which members are truly the same
pattern in different colors (the 'for more confirmation' step),
3. renders an HTML page showing the AFTER collection grid — one tile per pattern with
an 'N Colors' chip that expands to the confirmed colorways — plus a diagnostics
panel proving an over-grouped key (Coordonné) is correctly NOT merged.
Reads "dw_sku|title|vendor|image_url" lines on stdin. Writes tools/grouping-demo.html.
"""
import sys, io, re, os, urllib.request, hashlib, html, json
from PIL import Image, ImageOps
THRESH = int(os.environ.get("HAM_THRESH", "16"))
HASH = 8
CACHE = "/tmp/pattern-verify-cache"
os.makedirs(CACHE, exist_ok=True)
COLOR_WORDS = set("""beige black blue brown copper cream gold gray grey green ivory navy orange
peach pearl pink purple red rose rust sage salmon silver tan taupe teal turquoise white yellow
mauve plum aqua charcoal khaki lavender olive platinum bronze mustard indigo burgundy coral mint
seafoam sand stone snow mushroom natural oatmeal flax flaxen cedar flint winter chrome graphite
witchgrass hay lemongrass cotton bluegrass barley meadow caramel sunflower toast pearl smoke ash
light dark deep pale soft warm cool muted bright antique metallic neutral multi""".split())
NOISE = ["durable vinyl","wallcovering","wallcoverings","wallpaper","fabric","fabrics","vinyl",
"grasscloth","print","commercial","residential","mural","self adhesive","with surface stick",
"by phillipe romano","phillipe romano"]
def base_of(title):
t = title.split("|")[0].strip().lower()
for n in NOISE: t = t.replace(n, " ")
# split colorway after a dash if present: "albany linen-snow" -> "albany linen"
t = re.split(r"[-–]", t)[0] if re.search(r"[-–]", t) else t
words = [w for w in re.sub(r"[^a-z0-9 ]", " ", t).split() if w and w not in COLOR_WORDS]
return " ".join(words).strip()
def dhash(img, n=HASH):
g = img.convert("L").resize((n + 1, n), Image.LANCZOS)
px = list(g.getdata()); v = 0
for r in range(n):
b = r * (n + 1)
for c in range(n):
v = (v << 1) | (1 if px[b + c] > px[b + c + 1] else 0)
return v
def fetch(url):
key = os.path.join(CACHE, hashlib.md5(url.encode()).hexdigest() + ".bin")
if os.path.exists(key):
return open(key, "rb").read()
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 dw"})
d = urllib.request.urlopen(req, timeout=20).read()
open(key, "wb").write(d); return d
def sig(url):
im = Image.open(io.BytesIO(fetch(url)))
return dhash(im), dhash(ImageOps.invert(im.convert("L")))
def ham(a, b): return bin(a ^ b).count("1")
# ---- ingest ----
rows = []
for line in sys.stdin:
p = line.rstrip("\n").split("|", 3)
if len(p) == 4 and p[3].startswith("http"):
rows.append({"sku": p[0], "title": p[1], "vendor": p[2], "url": p[3]})
# group by (vendor, base)
fam = {}
for r in rows:
b = base_of(r["title"])
if not b: continue
fam.setdefault((r["vendor"], b), []).append(r)
# keep families with >=4 candidates; cap images per family
families = [(k, v[:14]) for k, v in fam.items() if len(v) >= 4]
families.sort(key=lambda kv: len(kv[1]), reverse=True)
families = families[:10]
def confirm(members):
"""Return list of clusters (each a list of member dicts) by image structure."""
sigs = []
for m in members:
try:
h, hi = sig(m["url"]); sigs.append((m, h, hi))
except Exception:
pass
clusters = []
for m, h, hi in sigs:
for cl in clusters:
if any(min(ham(h, ch), ham(hi, ch)) <= THRESH for (_, ch, _) in cl):
cl.append((m, h, hi)); break
else:
clusters.append([(m, h, hi)])
clusters.sort(key=len, reverse=True)
return clusters
tiles = []
for (vendor, base), members in families:
clusters = confirm(members)
if not clusters: continue
dom = clusters[0]
confirmed = [m for (m, _, _) in dom]
pct = len(dom) / sum(len(c) for c in clusters)
tiles.append({
"pattern": base.title(), "vendor": vendor,
"confirmed": confirmed, "n_clusters": len(clusters),
"split_off": sum(len(c) for c in clusters[1:]),
"status": "confirmed" if (len(clusters) == 1) else ("mostly" if pct >= 0.6 else "mixed"),
})
# ---- render ----
def esc(s): return html.escape(str(s or ""))
def card(m, size=120):
return (f'<a class="cw" href="https://designerwallcoverings.com/products/{esc(m["sku"])}" '
f'title="{esc(m["title"])}"><img loading="lazy" src="{esc(m["url"])}" '
f'style="width:{size}px;height:{size}px"><span>{esc(m["title"].split("|")[0])}</span></a>')
tile_html = ""
for t in tiles:
n = len(t["confirmed"])
rep = t["confirmed"][0]
badge = {"confirmed": "#1c7c3c", "mostly": "#8a6d00", "mixed": "#9a2b2b"}[t["status"]]
note = ("image-confirmed colorways" if t["status"] == "confirmed"
else f"{n} confirmed · {t['split_off']} split off as different patterns")
swatches = "".join(card(m, 92) for m in t["confirmed"])
tile_html += f'''
<div class="tile">
<img class="hero" src="{esc(rep["url"])}" loading="lazy">
<div class="meta">
<div class="pname">{esc(t["pattern"])}</div>
<div class="ven">{esc(t["vendor"])}</div>
<button class="chip" onclick="this.closest('.tile').classList.toggle('open')">
<span class="dots">{"".join('<i></i>' for _ in range(min(n,3)))}</span>
{n} Colors <span class="car">⌄</span>
</button>
<div class="note" style="color:{badge}">● {esc(note)}</div>
</div>
<div class="ways">{swatches}</div>
</div>'''
# diagnostics: Coordonné over-group correctly NOT merged
diag = ""
coord = [r for r in rows if r["vendor"] == "Coordonné"][:14]
if coord:
cl = confirm(coord)
diag = (f'<p>Candidate key grouped <b>{len(coord)}</b> Coordonné products under one generic title. '
f'The image check finds <b>{len(cl)}</b> genuinely different patterns — so they are '
f'<b>kept separate</b>, not collapsed into a false "{len(coord)} Colors" tile:</p>'
'<div class="diag">' +
"".join('<div class="dcl">' + "".join(card(m, 70) for (m, _, _) in c[:6]) +
f'<small>pattern {i+1}</small></div>' for i, c in enumerate(cl[:8])) +
'</div>')
out = f'''<!doctype html><meta charset=utf-8>
<title>DW · Collapse colorways — visual proof</title>
<style>
body{{font:15px/1.5 -apple-system,Segoe UI,sans-serif;margin:0;background:#faf8f5;color:#1a1a1a}}
header{{padding:22px 28px;background:#111;color:#fff}}
header h1{{margin:0;font-size:19px;font-weight:600;letter-spacing:.02em}}
header p{{margin:6px 0 0;color:#bbb;font-size:13px}}
.wrap{{padding:24px 28px;max-width:1280px;margin:0 auto}}
h2{{font:600 15px/1 sans-serif;text-transform:uppercase;letter-spacing:.08em;color:#666;margin:34px 0 14px}}
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:18px}}
.tile{{background:#fff;border:1px solid #e7e2da;border-radius:8px;overflow:hidden}}
.tile .hero{{width:100%;aspect-ratio:1;object-fit:cover;display:block}}
.meta{{padding:11px 13px}}
.pname{{font-weight:600;font-size:14px}}
.ven{{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:#888;margin:2px 0 9px}}
.chip{{display:inline-flex;align-items:center;gap:7px;border:1px solid #d8d2c8;background:#fff;
border-radius:999px;padding:5px 12px;font-size:13px;font-weight:600;cursor:pointer}}
.chip .dots{{display:inline-flex}}
.chip .dots i{{width:15px;height:15px;border-radius:50%;background:#cabfa9;border:2px solid #fff;margin-right:-6px;box-shadow:0 0 0 1px #d8d2c8}}
.chip .car{{transition:transform .2s}}
.tile.open .chip .car{{transform:rotate(180deg)}}
.note{{font-size:11px;margin-top:8px}}
.ways{{display:none;flex-wrap:wrap;gap:8px;padding:4px 13px 14px}}
.tile.open .ways{{display:flex}}
.cw{{display:block;text-decoration:none;color:#444;text-align:center}}
.cw img{{object-fit:cover;border-radius:5px;border:1px solid #e7e2da;display:block}}
.cw span{{display:block;font-size:9px;max-width:92px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:3px;color:#888}}
.diag{{display:flex;flex-wrap:wrap;gap:14px}}
.dcl{{border:1px dashed #c9b; border-radius:8px;padding:8px;background:#fff}}
.dcl small{{display:block;text-align:center;color:#9a2b9a;font-size:10px;margin-top:4px;text-transform:uppercase}}
.legend{{font-size:12px;color:#555;background:#fff;border:1px solid #e7e2da;border-radius:8px;padding:12px 16px}}
</style>
<header>
<h1>Designer Wallcoverings — collapse colorways into one pattern tile</h1>
<p>Real catalog data · each tile = one pattern · “N Colors” expands to the colorways · grouping confirmed by the color-invariant image check (dHash≤{THRESH}, $0 local)</p>
</header>
<div class="wrap">
<div class="legend">Click a <b>“N Colors ⌄”</b> chip to expand the confirmed colorways. The coloured dot under each tile reports the image-confirmation status:
<span style="color:#1c7c3c">● confirmed</span> · <span style="color:#8a6d00">● mostly (outliers split off)</span> · <span style="color:#9a2b2b">● mixed</span>.</div>
<h2>Collection grid — after (one tile per pattern)</h2>
<div class="grid">{tile_html}</div>
<h2>Why it's safe — over-grouping is caught, not merged</h2>
{diag or "<p>(no Coordonné sample in input)</p>"}
</div>'''
path = os.path.join(os.path.dirname(__file__), "grouping-demo.html")
open(path, "w").write(out)
print("wrote", path, "·", len(tiles), "pattern tiles")