← back to Dw Photo Capture
visual-search: optional pattern-sibling stage (PATTERN_SIBLINGS=1 + body siblings:true) using dw-pairs-well colour-invariant dHash + WS-1 pattern_id, vendor+pattern_name fallback (TK-12090 Lane P)
95babfeb4c555917412a821993672717540843bb · 2026-09-23 14:54:57 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
Files touched
A visual-search/pattern_siblings.pyM visual-search/search_service.py
Diff
commit 95babfeb4c555917412a821993672717540843bb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 14:54:57 2026 -0700
visual-search: optional pattern-sibling stage (PATTERN_SIBLINGS=1 + body siblings:true) using dw-pairs-well colour-invariant dHash + WS-1 pattern_id, vendor+pattern_name fallback (TK-12090 Lane P)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
---
visual-search/pattern_siblings.py | 130 ++++++++++++++++++++++++++++++++++++++
visual-search/search_service.py | 18 +++++-
2 files changed, 146 insertions(+), 2 deletions(-)
diff --git a/visual-search/pattern_siblings.py b/visual-search/pattern_siblings.py
new file mode 100644
index 0000000..37091f6
--- /dev/null
+++ b/visual-search/pattern_siblings.py
@@ -0,0 +1,130 @@
+#!/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]])
diff --git a/visual-search/search_service.py b/visual-search/search_service.py
index a0511ad..56c3c79 100644
--- a/visual-search/search_service.py
+++ b/visual-search/search_service.py
@@ -24,6 +24,11 @@ HOST = os.environ.get("VS_HOST", "127.0.0.1")
# so filtering happens in SQL and cosine in-process over just the filtered rows. OFF = byte-for-byte
# the previous behaviour, so the running :9914 service is unaffected until restarted with the flag.
PG_FINGERPRINTS = os.environ.get("PG_FINGERPRINTS") == "1"
+# PATTERN_SIBLINGS=1 (default OFF): optional second stage — when a photo matches a COLORWAY, attach the
+# pattern-level siblings (other colorways) of the top result, confirmed by the colour-invariant dHash
+# ported from dw-pairs-well/tools (see pattern_siblings.py). Per request it is used only if the body
+# also asks for it ({"siblings": true}), so enabling the flag changes no existing response by itself.
+PATTERN_SIBLINGS = os.environ.get("PATTERN_SIBLINGS") == "1"
_MODEL = {"model": None, "preprocess": None}
_MODEL_LOCK = threading.Lock()
@@ -197,14 +202,23 @@ class H(BaseHTTPRequestHandler):
if PG_FINGERPRINTS and isinstance(flt, dict):
res = pg_search(q, k, vendor=flt.get("vendor"), active_only=bool(flt.get("active_only")),
dw_sku_prefix=flt.get("dw_sku_prefix"))
- return self._send(200, {"ok": True, "source": "pg", "filter": flt, "results": res})
+ out = {"ok": True, "source": "pg", "filter": flt, "results": res}
+ if PATTERN_SIBLINGS and p.get("siblings") and res:
+ import pattern_siblings
+ with LOCK: meta_now = STATE["meta"]
+ out["pattern_siblings"] = pattern_siblings.siblings(res[0], meta_now)
+ return self._send(200, out)
with LOCK: M, meta = STATE["M"], STATE["meta"]
if M is None or not len(M): return self._send(200, {"ok": True, "results": [], "n": 0})
sims = M @ q # cosine (both L2-normalized)
idx = np.argpartition(-sims, min(k, len(sims)-1))[:k]
idx = idx[np.argsort(-sims[idx])]
res = [{**meta[i], "score": round(float(sims[i]), 4)} for i in idx]
- self._send(200, {"ok": True, "n": STATE["n"], "results": res})
+ out = {"ok": True, "n": STATE["n"], "results": res}
+ if PATTERN_SIBLINGS and p.get("siblings") and res:
+ import pattern_siblings
+ out["pattern_siblings"] = pattern_siblings.siblings(res[0], meta)
+ self._send(200, out)
except Exception as e:
self._send(500, {"ok": False, "err": str(e)})
← 6a07319 visual-search: product_image_fingerprints PG table + disk-gu
·
back to Dw Photo Capture
·
deploy.conf: exclude visual-search venv + CLIP weights + tes 409e338 →