← 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
M enrich-local.jsM enrich-palette.py
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 →