← 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()