← back to Dw Pairs Well
tools/ci-hash-build.py
86 lines
#!/usr/bin/env python3
"""Color-invariant image hasher for WS-1 image confirmation (read-only, $0 local).
For each (dw_sku, image_url), download (disk-cached), compute a COLOR-INVARIANT
signature = dHash of grayscale AND dHash of the tonal inverse. Two products are the
"same pattern, different color" when min(ham(dA,dB), ham(invA,invB)) is small — so a
light-on-dark vs dark-on-light colorway of one motif still matches.
Writes JSONL (resumable, append-only) to tools/ci-hashes.jsonl:
{"url": "...", "dhash": "<16hex>", "inv": "<16hex>", "status": "ok|err"}
Resumable: on restart, URLs already present in the JSONL are skipped. No DB writes,
no schema change. Input on stdin: "dw_sku<TAB>url" lines.
cut -f1,5 multi-images.tsv | python3 tools/ci-hash-build.py
"""
import sys, os, io, json, hashlib, urllib.request, ssl
from concurrent.futures import ThreadPoolExecutor, as_completed
from PIL import Image, ImageOps
OUT = os.path.join(os.path.dirname(__file__), "ci-hashes.jsonl")
CACHE = os.path.expanduser("~/.cache/ci-hash")
WORKERS = int(os.environ.get("CI_WORKERS", "16"))
HASH = 8
os.makedirs(CACHE, exist_ok=True)
_ctx = ssl.create_default_context(); _ctx.check_hostname = False; _ctx.verify_mode = ssl.CERT_NONE
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 fetch(url):
key = os.path.join(CACHE, hashlib.md5(url.encode()).hexdigest() + ".bin")
if os.path.exists(key) and os.path.getsize(key) > 0:
return open(key, "rb").read()
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 dw-ci-hash"})
with urllib.request.urlopen(req, timeout=20, context=_ctx) as r:
data = r.read()
if data: open(key, "wb").write(data)
return data
def work(url):
try:
data = fetch(url)
img = Image.open(io.BytesIO(data))
img.load()
return {"url": url, "dhash": dhash(img), "inv": inv_dhash(img), "status": "ok"}
except Exception as e:
return {"url": url, "status": "err", "error": str(e)[:80]}
# resume: skip URLs already hashed
done = set()
if os.path.exists(OUT):
for line in open(OUT):
try: done.add(json.loads(line)["url"])
except Exception: pass
urls, seen = [], set()
for line in sys.stdin:
p = line.rstrip("\n").split("\t")
if len(p) < 2: continue
u = p[1].strip()
if u and u not in seen and u not in done:
seen.add(u); urls.append(u)
total = len(urls)
print(f"[ci-hash] {total} new URLs to hash ({len(done)} already done), {WORKERS} workers", flush=True)
n = ok = err = 0
with open(OUT, "a") as out, ThreadPoolExecutor(max_workers=WORKERS) as ex:
futs = {ex.submit(work, u): u for u in urls}
for f in as_completed(futs):
rec = f.result()
out.write(json.dumps(rec) + "\n"); out.flush()
n += 1; ok += rec["status"] == "ok"; err += rec["status"] == "err"
if n % 500 == 0:
print(f"[ci-hash] {n}/{total} ok={ok} err={err}", flush=True)
print(f"[ci-hash] DONE {n}/{total} ok={ok} err={err} -> {OUT}", flush=True)