[object Object]

← back to Enrich Local Hybrid

fix: normalize image through Pillow→JPEG before VL (catalog CMYK/WebP images broke ollama image loader)

204b3e23f2706e0967e0ea3e47d6277bd35877e5 · 2026-06-24 09:08:45 -0700 · Steve

Files touched

Diff

commit 204b3e23f2706e0967e0ea3e47d6277bd35877e5
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 24 09:08:45 2026 -0700

    fix: normalize image through Pillow→JPEG before VL (catalog CMYK/WebP images broke ollama image loader)
---
 enrich-local.js   |  6 ++++--
 enrich-palette.py | 33 ++++++++++++++++++++-------------
 2 files changed, 24 insertions(+), 15 deletions(-)

diff --git a/enrich-local.js b/enrich-local.js
index d49082a..0d261e9 100644
--- a/enrich-local.js
+++ b/enrich-local.js
@@ -81,8 +81,10 @@ async function localAnalyze(imageUrl, mode, fetchBuffer) {
   const tmp = path.join(os.tmpdir(), `enrich-${process.pid}-${Date.now()}.img`);
   fs.writeFileSync(tmp, buf);
   try {
-    const palette = samplePalette(tmp, MAX_COLORS);          // ground-truth hex + %
-    const vl = await ollamaVL(buf.toString('base64'), palette);
+    const sampled = samplePalette(tmp, MAX_COLORS);          // {palette, image_b64 (normalized JPEG)}
+    const palette = sampled.palette || [];
+    if (!sampled.image_b64) throw new Error('palette: no normalized image');
+    const vl = await ollamaVL(sampled.image_b64, palette);   // normalized → ollama always loads it
     if (vl.usable === false) {
       return { image_rejected: true, rejection_reason: vl.rejectionReason || 'not a usable product image' };
     }
diff --git a/enrich-palette.py b/enrich-palette.py
index 2cc51a8..bde239a 100644
--- a/enrich-palette.py
+++ b/enrich-palette.py
@@ -1,24 +1,28 @@
 #!/usr/bin/env python3
-"""Ground-truth color palette: exact hex + area-percentage from real pixels.
-More accurate than any VLM for hex. Merges near-duplicate buckets. Usage: enrich-palette.py <image> [k]"""
-import sys, json
+"""Ground-truth palette + a NORMALIZED image for the VL.
+- palette: exact hex + area-% from real pixels (more accurate than any VLM), near-dup merged.
+- image_b64: the image re-encoded as a clean RGB JPEG, long-edge-capped. Normalizing through
+  Pillow guarantees ollama/llama-server can load it (catalog images are often CMYK JPEG / WebP /
+  odd encodings that Pillow reads but the VL image loader rejects → "Failed to load image").
+Usage: enrich-palette.py <image> [k]   → prints {"palette":[...], "image_b64":"..."}"""
+import sys, json, io, base64
 from PIL import Image
 
 def _dist(a, b):
     return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5
 
-def palette(path, k=6, merge_thresh=30):
-    im = Image.open(path).convert("RGB")
-    im.thumbnail((220, 220))
-    q = im.quantize(colors=16, method=Image.MEDIANCUT)   # over-quantize, then merge
-    pal = q.getpalette()
-    counts = sorted(q.getcolors(), reverse=True)
+def analyze(path, k=6, merge_thresh=30, vl_max=1024):
+    im = Image.open(path).convert("RGB")            # CMYK/RGBA/P/WebP -> RGB
+    # --- palette from a small copy ---
+    small = im.copy(); small.thumbnail((220, 220))
+    q = small.quantize(colors=16, method=Image.MEDIANCUT)
+    pal = q.getpalette(); counts = sorted(q.getcolors(), reverse=True)
     total = sum(c for c, _ in counts) or 1
-    buckets = []  # [{rgb:(r,g,b), pct:float}]
+    buckets = []
     for count, idx in counts:
         rgb = tuple(pal[idx*3:idx*3+3]); pct = count / total * 100
         for b in buckets:
-            if _dist(rgb, b["rgb"]) <= merge_thresh:     # merge into nearest existing
+            if _dist(rgb, b["rgb"]) <= merge_thresh:
                 b["pct"] += pct; break
         else:
             buckets.append({"rgb": rgb, "pct": pct})
@@ -26,7 +30,10 @@ def palette(path, k=6, merge_thresh=30):
     out = [{"hex": "#%02X%02X%02X" % b["rgb"], "percentage": round(b["pct"])} for b in buckets]
     diff = 100 - sum(c["percentage"] for c in out)
     if out: out[0]["percentage"] += diff
-    return out
+    # --- normalized VL image (clean JPEG ollama always loads) ---
+    vl = im.copy(); vl.thumbnail((vl_max, vl_max))
+    buf = io.BytesIO(); vl.save(buf, format="JPEG", quality=90)
+    return {"palette": out, "image_b64": base64.b64encode(buf.getvalue()).decode()}
 
 if __name__ == "__main__":
-    print(json.dumps(palette(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 6)))
+    print(json.dumps(analyze(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 6)))

← 168999c DEPLOY.md: exact go-live = one line in full-monte/.env (Phas  ·  back to Enrich Local Hybrid  ·  enrich-local: raise VL timeout 120s→300s + keep_alive 15m to 79237c9 →