← back to Dw Pairs Well
tools/pattern-image-verify.py
108 lines
#!/usr/bin/env python3
"""WS-1 image confirmation (READ-ONLY, local, $0).
Reads "dw_sku<TAB>title<TAB>image_url" lines on stdin (one candidate group),
fetches each image, computes a COLOR-INVARIANT structural signature, and clusters
members by visual pattern. Same pattern in different colorways -> one cluster;
genuinely different patterns sharing a key (e.g. a templated-title vendor) ->
multiple clusters. This is the "are these really the same pattern, just different
colors?" confirmation on top of the SKU/title key.
Color invariance: image -> grayscale -> we compute dHash on BOTH the image and its
tonal inverse and take the smaller Hamming distance, so a light-on-dark vs
dark-on-light colorway of the same motif still matches.
psql ... -tAc "<query yielding sku|title|url>" | tr '|' '\\t' | \
python3 tools/pattern-image-verify.py "Coordonné" 30
"""
import sys, io, urllib.request, hashlib, os
from PIL import Image, ImageOps
LABEL = sys.argv[1] if len(sys.argv) > 1 else "group"
LIMIT = int(sys.argv[2]) if len(sys.argv) > 2 else 40
THRESH = int(os.environ.get("HAM_THRESH", "12")) # dHash Hamming <= THRESH => same pattern
HASH = 8
CACHE = "/tmp/pattern-verify-cache"
os.makedirs(CACHE, exist_ok=True)
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):
base = r * (n + 1)
for c in range(n):
v = (v << 1) | (1 if px[base + c] > px[base + c + 1] else 0)
return v
def inv_dhash(img, n=HASH):
return dhash(ImageOps.invert(img.convert("L")), n)
def ham(a, b):
return bin(a ^ b).count("1")
def fetch(url):
key = os.path.join(CACHE, hashlib.md5(url.encode()).hexdigest() + ".bin")
if os.path.exists(key):
with open(key, "rb") as f:
return f.read()
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 dw-pattern-verify"})
data = urllib.request.urlopen(req, timeout=20).read()
with open(key, "wb") as f:
f.write(data)
return data
items = []
for line in sys.stdin:
parts = line.rstrip("\n").split("\t")
if len(parts) < 3:
continue
sku, title, url = parts[0], parts[1], parts[2]
if not url.startswith("http"):
continue
items.append((sku, title, url))
if len(items) >= LIMIT:
break
print(f"=== {LABEL}: {len(items)} candidate members (thresh dHash<={THRESH}) ===")
hashed = []
for sku, title, url in items:
try:
im = Image.open(io.BytesIO(fetch(url)))
hashed.append((sku, title, dhash(im), inv_dhash(im)))
except Exception as e:
print(f" [skip] {sku}: {e}")
# single-linkage clustering: a member joins a cluster if it's within THRESH of ANY member
clusters = []
for sku, title, h, hi in hashed:
placed = False
for cl in clusters:
for (_, _, ch, _chi) in cl:
if min(ham(h, ch), ham(hi, ch)) <= THRESH:
cl.append((sku, title, h, hi))
placed = True
break
if placed:
break
if not placed:
clusters.append([(sku, title, h, hi)])
clusters.sort(key=len, reverse=True)
print(f" -> {len(hashed)} images formed {len(clusters)} distinct visual pattern(s)")
for i, cl in enumerate(clusters[:8], 1):
egs = "; ".join(t for (_, t, _, _) in cl[:3])
print(f" cluster {i}: {len(cl):>3} member(s) e.g. {egs[:90]}")
if len(clusters) > 8:
print(f" … +{len(clusters)-8} more clusters")
# verdict for this candidate group
if hashed:
biggest = len(clusters[0]) / len(hashed)
if len(clusters) == 1:
print(" VERDICT: CONFIRMED single pattern — safe to group as colorways.")
elif biggest >= 0.6:
print(f" VERDICT: MOSTLY one pattern ({len(clusters[0])}/{len(hashed)}) + {len(clusters)-1} outlier(s) to split off.")
else:
print(f" VERDICT: OVER-GROUPED — {len(clusters)} real patterns share this key. Do NOT group by key alone.")