← back to Dw Photo Capture
visual-search/pattern_siblings.py
131 lines
#!/usr/bin/env python3
"""Pattern-level sibling stage for visual-search (TK-12090 Lane P) — OPTIONAL, default OFF.
When a photo matches one COLORWAY, also return the other colorways of the same PATTERN.
Ported (not forked) from ~/Projects/dw-pairs-well/tools:
* signature = ci-hash-build.py dHash(8) of grayscale + dHash of the tonal inverse
* distance = pattern-id-titlefirst-final.py ci_dist: min of the 4 cross Hamming distances,
so light-on-dark and dark-on-light colorways of one motif still match
* threshold = HAM_THRESH default 30 (WS-1 value chosen 2026-06-26 in that script)
* grouping = the WS-1 title-first + CI-confirmed map pattern-id-titlefirst-by-dwsku.json
Candidate siblings come from (1) the WS-1 pattern_id group, else (2) same vendor + same normalized
pattern_name in the search index. Each candidate is then CONFIRMED by the colour-invariant distance
when both hashes are known; unhashed candidates are returned as confirmed=None (never guessed).
Env: PAIRS_WELL_TOOLS (default ~/Projects/dw-pairs-well/tools), HAM_THRESH (30),
PATTERN_SIBLINGS_FETCH=1 to hash missing images on the fly (bounded, disk-cached, $0).
"""
import hashlib, io, json, os, re, ssl, urllib.request
from collections import defaultdict
from PIL import Image, ImageOps
TOOLS = os.environ.get("PAIRS_WELL_TOOLS", os.path.expanduser("~/Projects/dw-pairs-well/tools"))
THRESH = int(os.environ.get("HAM_THRESH", "30"))
FETCH = os.environ.get("PATTERN_SIBLINGS_FETCH") == "1"
CACHE = os.path.expanduser("~/.cache/ci-hash") # same cache dir as ci-hash-build.py
HASH = 8
# ── signature + distance: verbatim logic from dw-pairs-well/tools ─────────────────────────
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 f"{v:016x}"
def inv_dhash(img, n=HASH):
return dhash(ImageOps.invert(img.convert("L")), n)
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]))
# ── data ─────────────────────────────────────────────────────────────────────────────────
_D = {"loaded": False}
def _norm(s): return re.sub(r"[^a-z0-9]+", " ", (s or "").lower()).strip()
def load():
if _D["loaded"]: return _D
pid, members, url_of, ci = {}, defaultdict(list), {}, {}
try:
for k, v in json.load(open(os.path.join(TOOLS, "pattern-id-titlefirst-by-dwsku.json"))).items():
if v and v.get("pid"): pid[k] = v["pid"]; members[v["pid"]].append(k)
except OSError: pass
try:
for line in open(os.path.join(TOOLS, "_active-for-ci.tsv")):
p = line.rstrip("\n").split("\t")
if len(p) >= 5 and p[4].startswith("http"): url_of.setdefault(p[0], p[4])
except OSError: pass
try:
for line in open(os.path.join(TOOLS, "ci-hashes.jsonl")):
try:
r = json.loads(line)
if r.get("status") == "ok": ci[r["url"]] = (r["dhash"], r["inv"])
except ValueError: pass
except OSError: pass
_D.update(loaded=True, pid=pid, members=members, url_of=url_of, ci=ci)
return _D
def _fetch_sig(url):
os.makedirs(CACHE, exist_ok=True)
key = os.path.join(CACHE, hashlib.md5(url.encode()).hexdigest() + ".bin")
if not (os.path.exists(key) and os.path.getsize(key) > 0):
ctx = ssl.create_default_context()
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 dw-ci-hash"})
with urllib.request.urlopen(req, timeout=15, context=ctx) as r: open(key, "wb").write(r.read())
img = Image.open(io.BytesIO(open(key, "rb").read())); img.load()
return (dhash(img), inv_dhash(img))
def signature(dw_sku, image_url=None, budget=None):
d = load()
url = d["url_of"].get(dw_sku) or image_url
if not url: return None
if url in d["ci"]: return d["ci"][url]
if FETCH and budget is not None and budget[0] > 0:
budget[0] -= 1
try:
sig = _fetch_sig(url); d["ci"][url] = sig; return sig
except Exception:
return None
return None
def siblings(match, index_meta=None, limit=24):
"""match: a /search result dict ({dw_sku, vendor, pattern, image}). index_meta: the service's
STATE['meta'] list, used for the vendor+pattern_name fallback. Returns a dict for the response."""
d = load(); sku = match.get("dw_sku") or ""
group_key, cands, via = None, [], None
if sku in d["pid"]:
group_key = d["pid"][sku]; via = "ws1_pattern_id"
cands = [(s, None) for s in d["members"][group_key] if s != sku]
elif index_meta and match.get("pattern"):
pn, vn = _norm(match.get("pattern")), match.get("vendor")
group_key = f"{vn}::{pn}"; via = "vendor+pattern_name"
seen = {sku}
for m in index_meta:
s = m.get("dw_sku")
if s and s not in seen and m.get("vendor") == vn and _norm(m.get("pattern")) == pn:
seen.add(s); cands.append((s, m.get("image")))
budget = [limit]
base = signature(sku, match.get("image"), budget)
out = []
for s, img in cands[: limit * 4]:
sig = signature(s, img, budget)
dist = ci_dist(base, sig) if (base and sig) else None
out.append({"dw_sku": s, "ci_dist": dist, "confirmed": (dist <= THRESH) if dist is not None else None})
out.sort(key=lambda x: (x["confirmed"] is not True, x["ci_dist"] if x["ci_dist"] is not None else 99))
reason = None if via else "no WS-1 pattern_id and no pattern_name on the match — siblings NOT MEASURED"
return {"group": group_key, "via": via, "reason": reason, "thresh": THRESH, "query_hashed": base is not None,
"n": len(out), "confirmed": sum(1 for x in out if x["confirmed"]),
"rejected": sum(1 for x in out if x["confirmed"] is False), "siblings": out[:limit]}
if __name__ == "__main__":
import sys
for s in sys.argv[1:]:
r = siblings({"dw_sku": s})
print(json.dumps({k: v for k, v in r.items() if k != "siblings"}), [ (x["dw_sku"], x["ci_dist"]) for x in r["siblings"][:8]])