← back to Japan Enrich

enrich_sangetsu.py

122 lines

#!/usr/bin/env python3
"""
Sangetsu enricher — pattern-level vision (style/material) + per-colorway Pillow hex.
Sangetsu rows are PATTERNS holding {colorway_skus:[...], sku_images:{sku:url}}.
  style/material : qwen2.5vl ONCE per pattern (same design across colorways), fanned out
  hex/color_family: Pillow per colorway image (each colorway = a different color)

Emits out/sangetsu-enriched.jsonl — ONE line per colorway SKU:
  {"mfr_sku": "<colorway_sku>", "source":"sangetsu", "enrich": {...}}
so the viewer's ENRICH index (keyed by normSku(mfr_sku)) picks it up per colorway card.

$0 local. Downloads images to images/sangetsu/<sku>.jpg (concurrency, skip-existing, polite).
"""
import os, json, urllib.request, concurrent.futures as cf
from enrich import hex_palette, dominant_family, vision_tags, norm_sku

IMG_DIR = "images/sangetsu"
STAGING = "staging/sangetsu-staging.jsonl"
OUT = "out/sangetsu-enriched.jsonl"
MIN_IMAGE_BYTES = 200   # reject empty/error responses (HTML 404s are ~150 B)
os.makedirs(IMG_DIR, exist_ok=True)

# Vision (style/material via qwen2.5vl) is the SLOW leg (~90s+/pattern on this box).
# color_family+hex (the primary goal) come from fast Pillow and never need it.
# Default vision OFF so the color pass completes in minutes; opt in with SANGETSU_VISION=1.
VISION = os.environ.get("SANGETSU_VISION", "0") != "0"

def fetch(sku, url):
    dst = os.path.join(IMG_DIR, norm_sku(sku) + ".jpg")
    if os.path.exists(dst) and os.path.getsize(dst) > 0: return dst
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        data = urllib.request.urlopen(req, timeout=40).read()
        if len(data) < MIN_IMAGE_BYTES: return None
        with open(dst, "wb") as f: f.write(data)
        return dst
    except Exception:
        return None

def main():
    rows = [json.loads(l) for l in open(STAGING) if l.strip()]
    # 1) gather all (sku,url) and download with modest concurrency
    jobs = []
    for r in rows:
        imgs = r.get("sku_images") or {}
        for sku, url in imgs.items():
            if url: jobs.append((sku, url))
    print(f"sangetsu: {len(rows)} patterns, {len(jobs)} colorway images to fetch  (vision={'on' if VISION else 'off'})", flush=True)
    paths = {}
    done = 0
    with cf.ThreadPoolExecutor(max_workers=8) as ex:
        futs = {ex.submit(fetch, s, u): s for s, u in jobs}
        for fut in cf.as_completed(futs):
            s = futs[fut]; p = fut.result()
            if p: paths[norm_sku(s)] = p
            done += 1
            if done % 500 == 0: print(f"  fetched {done}/{len(jobs)} ({len(paths)} ok)", flush=True)
    print(f"download complete: {len(paths)} images on disk", flush=True)

    # Resume-safe: load any prior enrichment so a re-run carries it forward
    # (skips the slow vision leg for patterns already done) and NEVER regresses.
    existing = {}
    if os.path.exists(OUT):
        for l in open(OUT):
            l = l.strip()
            if not l: continue
            try:
                o = json.loads(l)
                if o.get("mfr_sku"): existing[o["mfr_sku"]] = o
            except Exception:
                pass
    print(f"carry-forward: {len(existing)} colorways already enriched", flush=True)

    TMP = OUT + ".tmp"
    def checkpoint(lines, processed):
        # Atomic write of a SUPERSET: everything processed so far PLUS any prior
        # entry not yet reprocessed. temp+rename => a mid-run death can't truncate.
        leftover = [json.dumps(o, ensure_ascii=False) for s, o in existing.items() if s not in processed]
        with open(TMP, "w") as f:
            f.write("\n".join(lines + leftover))
            if lines or leftover: f.write("\n")
        os.replace(TMP, OUT)

    # 2) enrich: vision once per pattern (rep image), Pillow per colorway
    out_lines = []; processed = set(); n_pat = 0; n_sku = 0; n_vision = 0
    for r in rows:
        skus = r.get("colorway_skus") or []
        have = [(s, paths[norm_sku(s)]) for s in skus if norm_sku(s) in paths]
        if not have: continue
        # reuse style/material from any already-enriched colorway (skip slow vision)
        style = material = None
        for s, _ in have:
            e = (existing.get(s) or {}).get("enrich") or {}
            if e.get("style") is not None or e.get("material") is not None:
                style, material = e.get("style"), e.get("material"); break
        if VISION and style is None and material is None:
            style, material = vision_tags(have[0][1]); n_vision += 1
        n_pat += 1
        for s, p in have:
            if s in existing:
                out_lines.append(json.dumps(existing[s], ensure_ascii=False))
                processed.add(s); n_sku += 1; continue
            try:
                hexes = hex_palette(p)
            except Exception:
                hexes = []
            enrich = {"hex": hexes, "color_family": dominant_family(hexes),
                      "style": style, "material": material,
                      "img": os.path.basename(p),
                      "engine": "local-hybrid:pillow+qwen2.5vl:7b(pattern-vision)"}
            out_lines.append(json.dumps({"mfr_sku": s, "source": "sangetsu", "enrich": enrich}, ensure_ascii=False))
            processed.add(s); n_sku += 1
        if n_pat % 25 == 0:
            checkpoint(out_lines, processed)
            print(f"  enriched {n_pat} patterns / {n_sku} colorway skus  ({n_vision} vision calls; last: {r.get('pattern')} -> {style}/{material})", flush=True)
    checkpoint(out_lines, processed)
    total = len(set(processed) | set(existing))
    print(f"\nDONE: {n_sku} colorways this pass across {n_pat} patterns ({n_vision} vision calls); {total} total enriched -> {OUT}", flush=True)

if __name__ == "__main__":
    main()