← back to Novasuede Onboard

classify.py

166 lines

#!/usr/bin/env python3
"""
Lexicon-grounded enrichment for Novasuede solid suedes.
Pipeline per product:
  1. fetch image -> Pillow ground-truth dominant hex (enrich-palette.py)
  2. qwen2.5vl (LOCAL, $0) maps hex -> a NAMED colorway from the interior-designer
     lexicon, picks undertone, 2-3 interior styles, and writes a 1-sentence description.
  3. emit clean structured record for review / staging.

These are SOLID microfiber suedes: no motif. Color is the protagonist; the vendor
name (Acorn / Adobe / African Violet) is decorative and NOT descriptive, so we
classify from real pixels + image, never from the vendor name.

Usage: classify.py <active_index.ndjson> <out.ndjson> [limit]
Env:   NS_OLLAMA_URL (default http://localhost:11434)  NS_VL_MODEL (default qwen2.5vl:7b)
"""
import sys, os, json, io, base64, time, urllib.request, subprocess, pathlib, colorsys

ROOT = pathlib.Path(__file__).resolve().parent
PALETTE_PY = pathlib.Path.home() / "Projects/enrich-local-hybrid/enrich-palette.py"
OLLAMA = os.environ.get("NS_OLLAMA_URL", "http://localhost:11434")
MODEL  = os.environ.get("NS_VL_MODEL", "qwen2.5vl:7b")

# --- controlled vocabulary (subset of interior-designer SKILL.md lexicon) ---
COLORWAYS = (
  "Chalk Alabaster Cotton Marshmallow Swiss-Coffee Ivory Eggshell Bone Cream Linen Porcelain Antique-White "
  "Oatmeal Flax String Greige Mushroom Putty Stone Fawn Wheat Sand Biscuit Almond Taupe Pebble "
  "Buff Chamois Honey Tan Camel Khaki Caramel Pecan Cognac Chestnut Tobacco Walnut Mocha Espresso "
  "Dove Silver Ash Fog Smoke Pewter Nickel Flannel Slate Zinc Graphite Gunmetal Charcoal "
  "Ink Noir Onyx Ebony Jet Obsidian Coal "
  "Celadon Pistachio Sage Eucalyptus Sea-Glass Moss Fern Olive Loden Juniper Hunter Forest Emerald Verdigris Bottle-Green "
  "Powder Sky Wedgwood Cornflower French-Blue Cerulean Denim Slate-Blue Teal Peacock Prussian Indigo Navy Midnight "
  "Buttercream Champagne Maize Citrine Saffron Amber Ochre Mustard Gold Brass "
  "Apricot Peach Clay Terracotta Persimmon Pumpkin Marmalade Copper Rust Sienna "
  "Blush Rose Dusty-Rose Coral Cinnabar Brick Oxblood Claret Garnet Burgundy Crimson "
  "Lavender Lilac Wisteria Orchid Heather Mauve Amethyst Plum Aubergine"
)
# Interior styles a SOLID suede realistically suits (mood/application driven)
STYLES = ("Mid-Century Modern, Organic Modern, Transitional, Minimalist, Contemporary, Scandinavian, "
          "Japandi, Hollywood Regency, Art Deco, Glam, Industrial, Coastal, Traditional, Wabi-Sabi, "
          "Bohemian, Maximalist, Mediterranean, Desert Modern")

# ---- deterministic designer logic from the measured dominant hex (HSL) ----
def hsl_of(hexstr):
    h = hexstr.lstrip("#")
    r, g, b = (int(h[i:i+2], 16) / 255 for i in (0, 2, 4))
    H, L, S = colorsys.rgb_to_hls(r, g, b)
    return H * 360, S, L  # hue 0-360, sat 0-1, light 0-1

def depth_of(L):
    return ("pale" if L >= .80 else "light" if L >= .62 else
            "medium" if L >= .42 else "deep" if L >= .25 else "dark")

def hue_family(H, S, L):
    if S < 0.12:                      # near-neutral: split by lightness
        return "neutral_light" if L >= .62 else "neutral_mid" if L >= .30 else "neutral_dark"
    if H < 18 or H >= 345: return "red"
    if H < 45:  return "orange"        # earths / terracotta / caramel
    if H < 70:  return "yellow"
    if H < 170: return "green"
    if H < 255: return "blue"
    if H < 290: return "purple"
    return "pink"                      # 290-345 magenta/pink

def undertone_of(H, S, L):
    if S < 0.10: return "neutral"
    return "warm" if (H < 75 or H >= 330) else "cool"

# designer hue-family + depth -> 2-3 interior styles (controlled lexicon)
def styles_for(fam, depth):
    deep = depth in ("deep", "dark")
    table = {
        "red":          (["Hollywood Regency","Art Deco","Maximalist"] if deep else ["Transitional","Bohemian","Eclectic"]),
        "orange":       (["Mediterranean","Organic Modern","Mid-Century Modern"] if deep else ["Desert Modern","Organic Modern","Bohemian"]),
        "yellow":       ["Mid-Century Modern","Organic Modern","Transitional"],
        "green":        (["Art Deco","Maximalist","Traditional"] if deep else ["Organic Modern","Biophilic","Transitional"]),
        "blue":         (["Hollywood Regency","Art Deco","Traditional"] if deep else ["Coastal","Transitional","Contemporary"]),
        "purple":       ["Hollywood Regency","Art Deco","Glam"],
        "pink":         ["Glam","Transitional","Contemporary"],
        "neutral_light":["Scandinavian","Minimalist","Japandi"],
        "neutral_mid":  ["Contemporary","Industrial","Minimalist"],
        "neutral_dark": ["Industrial","Contemporary","Minimalist"],
    }
    return table.get(fam, ["Contemporary","Transitional","Minimalist"])

def fetch(url, tries=4):
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    for i in range(tries):
        try:
            with urllib.request.urlopen(req, timeout=30) as r:
                return r.read()
        except Exception:
            time.sleep(1.5)
    raise RuntimeError(f"fetch failed: {url}")

def palette(img_bytes):
    tmp = ROOT / f"/tmp/ns-{os.getpid()}.img"
    pathlib.Path(tmp).write_bytes(img_bytes)
    r = subprocess.run(["python3", str(PALETTE_PY), str(tmp), "6"],
                       capture_output=True, text=True, timeout=40)
    pathlib.Path(tmp).unlink(missing_ok=True)
    if r.returncode != 0:
        raise RuntimeError("palette: " + r.stderr)
    return json.loads(r.stdout)  # {palette:[{hex,percentage}], image_b64}

def vl(image_b64, pal, vendor_name, depth, undertone, styles):
    dom = pal[0]["hex"] if pal else ""
    prompt = (
      "You are a senior interior designer cataloguing a SOLID microfiber-suede "
      "wallcovering/upholstery swatch. It has NO printed motif — it is one solid suede color.\n"
      f"The vendor's decorative name is \"{vendor_name}\" — NOT a reliable color descriptor; "
      "classify ONLY from the image + pixel colors.\n"
      f"Exact pixel colors (hex + area%): {json.dumps(pal)}\n"
      f"MEASURED facts you must stay faithful to: dominant hex {dom}, depth=\"{depth}\", "
      f"undertone=\"{undertone}\". Do NOT call a medium/deep color a pale/white name, and do "
      "NOT flip a warm color to a cool name.\n\n"
      "Return JSON:\n"
      "- primary_colorway: the SINGLE best-fit named colorway for the dominant suede color, "
      f"matching the measured depth & undertone, chosen ONLY from: [{COLORWAYS}]\n"
      "- secondary_colorway: a second named colorway from the SAME list ONLY if a clear second hue "
      "exists, else \"\"\n"
      f"- description: ONE editorial showroom sentence. Lead with the color & mood, note it is a "
      f"microfiber suede, and that it suits {styles[0]} / {styles[1]} interiors. Do NOT name the "
      "vendor's decorative name.\n"
    )
    schema = {"type":"object","properties":{
        "primary_colorway":{"type":"string"},
        "secondary_colorway":{"type":"string"},
        "description":{"type":"string"}},
        "required":["primary_colorway","description"]}
    body = json.dumps({"model":MODEL,"prompt":prompt,"images":[image_b64],"stream":False,
                       "format":schema,"keep_alive":"15m","options":{"temperature":0.1}}).encode()
    req = urllib.request.Request(OLLAMA+"/api/generate", data=body,
                                 headers={"Content-Type":"application/json"})
    with urllib.request.urlopen(req, timeout=300) as r:
        return json.loads(json.loads(r.read())["response"])

def main():
    src = pathlib.Path(sys.argv[1]); out = pathlib.Path(sys.argv[2])
    limit = int(sys.argv[3]) if len(sys.argv) > 3 else 10_000
    rows = [json.loads(l) for l in src.read_text().splitlines()][:limit]
    f = out.open("w")
    for i, r in enumerate(rows, 1):
        try:
            img = fetch(r["img"])
            pal = palette(img)
            dom = pal["palette"][0]["hex"]
            H, S, L = hsl_of(dom)
            depth = depth_of(L); undertone = undertone_of(H, S, L)
            fam = hue_family(H, S, L); styles = styles_for(fam, depth)
            res = vl(pal["image_b64"], pal["palette"], r["vendor_name"], depth, undertone, styles)
            rec = {**r, "palette": pal["palette"], "dominant_hex": dom,
                   "depth": depth, "undertone": undertone, "hue_family": fam,
                   "styles": styles, **res, "_ok": True}
            print(f"[{i}/{len(rows)}] {r['vendor_name']:>18} -> "
                  f"{res['primary_colorway']:>14} ({undertone}/{depth})  styles={styles}")
        except Exception as e:
            rec = {**r, "_ok": False, "_err": str(e)}
            print(f"[{i}/{len(rows)}] {r['vendor_name']:>18} -> ERROR {e}")
        f.write(json.dumps(rec) + "\n"); f.flush()
    f.close()
    print(f"\nwrote {out}")

if __name__ == "__main__":
    main()