← back to Wallco Ai
Add clean-edge vector-trace 150-DPI upscale pipeline (potrace + LANCZOS fallback)
d2aa744967b10fb03fe5b36c3a5e6d38b50de327 · 2026-06-03 22:34:13 -0700 · Steve
Traces flat 2-4 color designs per-color via potrace (DTD-CRISP params:
turdsize=4, alphamax=0.8, native trace) into a composited SVG, rasterizes
at 5400x5400 (36in @ 150 DPI). LANCZOS fallback for trace failures/degenerate.
Source PNG round-1 sacred; outputs to data/generated/clean_<id>_<ts>.png.
Validated on #53677/#53690/#53720 - razor edges, palette preserved, no ghost halo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A scripts/clean-edge-upscale.js
Diff
commit d2aa744967b10fb03fe5b36c3a5e6d38b50de327
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jun 3 22:34:13 2026 -0700
Add clean-edge vector-trace 150-DPI upscale pipeline (potrace + LANCZOS fallback)
Traces flat 2-4 color designs per-color via potrace (DTD-CRISP params:
turdsize=4, alphamax=0.8, native trace) into a composited SVG, rasterizes
at 5400x5400 (36in @ 150 DPI). LANCZOS fallback for trace failures/degenerate.
Source PNG round-1 sacred; outputs to data/generated/clean_<id>_<ts>.png.
Validated on #53677/#53690/#53720 - razor edges, palette preserved, no ghost halo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
scripts/clean-edge-upscale.js | 341 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 341 insertions(+)
diff --git a/scripts/clean-edge-upscale.js b/scripts/clean-edge-upscale.js
new file mode 100644
index 0000000..c0dcaf7
--- /dev/null
+++ b/scripts/clean-edge-upscale.js
@@ -0,0 +1,341 @@
+#!/usr/bin/env node
+/**
+ * clean-edge-upscale.js — vector-trace clean-edge / 150-DPI print upscaler for
+ * flat 2–4 color wallco.ai designs.
+ *
+ * WHY: Comfy/SDXL designs render at 1024px (~28 DPI for a 36" tile). At print
+ * standard 150 DPI a 36" tile is 5400×5400px, so a naive bicubic upscale turns
+ * the anti-aliased silhouette edges into visible stair-step / jaggy borders.
+ * For these flat 2–3 color designs the right tool is vector tracing (potrace):
+ * each solid color becomes a crisp SVG path, so edges are mathematically smooth
+ * at any output size. LANCZOS pixel upscale is kept as a fallback for when a
+ * trace fails or produces a degenerate result.
+ *
+ * APPROACH (per design):
+ * 1. quantize to the design's actual solid palette (2–4 colors, MEDIANCUT),
+ * 2. for each non-background color: build a 1-bit mask → potrace → SVG <path>
+ * filled with that color,
+ * 3. composite the color layers (background rect first) into ONE SVG,
+ * 4. rasterize the SVG at 5400×5400 via `magick` → data/generated/clean_<id>_<ts>.png,
+ * 5. LANCZOS fallback (1024→5400 via PIL) if a trace fails / looks degenerate,
+ * 6. ROUND-1 SACRED: never overwrite the source PNG; only write new clean_* outputs.
+ *
+ * potrace params (DTD verdict 2026-06-03 = "CRISP"):
+ * turdsize=4 alphamax=0.8 native-trace (NO mkbitmap pre-upsample).
+ * turdsize=4 kills AA speckles without eating legit small motif detail;
+ * alphamax=0.8 sharpens corners vs the 1.0 default without going polygonal;
+ * native trace preserves true source geometry (pre-upsampling only magnifies
+ * stair-steps). Override via --turdsize / --alphamax.
+ *
+ * USAGE:
+ * node scripts/clean-edge-upscale.js <designId> # resolve via local server / PG
+ * node scripts/clean-edge-upscale.js --path <image.png> # trace a file directly
+ * node scripts/clean-edge-upscale.js 53677 53690 53720 # batch
+ * Flags:
+ * --size 5400 output edge px (default 5400 = 36" @ 150 DPI)
+ * --colors 4 max palette colors to quantize to (default 4)
+ * --turdsize 4 potrace despeckle threshold
+ * --alphamax 0.8 potrace corner roundness (0=polygonal, 1=round)
+ * --out <dir> output dir (default data/generated)
+ * --base <url> local server base (default http://127.0.0.1:9905)
+ * --force-lanczos skip tracing, just LANCZOS upscale (for comparison)
+ * --keep-svg keep the intermediate SVG next to the PNG
+ *
+ * LOCAL ONLY — writes new clean_* PNGs, never touches the catalog/DB/prod.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { execFileSync } = require('child_process');
+
+const ROOT = path.join(__dirname, '..');
+const POTRACE = '/opt/homebrew/bin/potrace';
+const MAGICK = 'magick';
+
+// ---- arg parsing -----------------------------------------------------------
+function parseArgs(argv) {
+ const opts = {
+ size: 5400, colors: 4, turdsize: 4, alphamax: 0.8,
+ out: path.join(ROOT, 'data', 'generated'),
+ base: process.env.WALLCO_BASE || 'http://127.0.0.1:9905',
+ forceLanczos: false, keepSvg: false, path: null, ids: [],
+ };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--path') opts.path = argv[++i];
+ else if (a === '--size') opts.size = parseInt(argv[++i], 10);
+ else if (a === '--colors') opts.colors = parseInt(argv[++i], 10);
+ else if (a === '--turdsize') opts.turdsize = parseInt(argv[++i], 10);
+ else if (a === '--alphamax') opts.alphamax = parseFloat(argv[++i]);
+ else if (a === '--out') opts.out = argv[++i];
+ else if (a === '--base') opts.base = argv[++i];
+ else if (a === '--force-lanczos') opts.forceLanczos = true;
+ else if (a === '--keep-svg') opts.keepSvg = true;
+ else if (/^\d+$/.test(a)) opts.ids.push(parseInt(a, 10));
+ else if (a.startsWith('--')) { /* ignore unknown */ }
+ }
+ return opts;
+}
+
+// ---- source resolution -----------------------------------------------------
+// Resolve a design id → a local PNG path, reusing the server's by-id route
+// (the canonical resolver: handles cold cache, PG fallback, prod-prefix swap).
+function resolveSource(id, base) {
+ const tmp = path.join(os.tmpdir(), `ce_src_${id}.png`);
+ try {
+ execFileSync('curl', ['-sf', '-o', tmp, `${base}/designs/img/by-id/${id}`], { stdio: 'pipe' });
+ if (fs.existsSync(tmp) && fs.statSync(tmp).size > 0) return tmp;
+ } catch { /* fall through */ }
+ // Fallback: scan data/generated for a file whose basename starts with the id
+ // (some rows store local_path with the id embedded; cheap best-effort).
+ const gen = path.join(ROOT, 'data', 'generated');
+ if (fs.existsSync(gen)) {
+ const hit = fs.readdirSync(gen).find(f => f.includes(String(id)) && /\.png$/i.test(f));
+ if (hit) return path.join(gen, hit);
+ }
+ return null;
+}
+
+// ---- palette extraction (PIL) ---------------------------------------------
+// Returns [{ hex, pct }] for the dominant solid colors, sorted by coverage
+// descending (so [0] is the background / largest area).
+function extractPalette(src, maxColors) {
+ // Quantize at a generous k (16) so minority accent colors survive the cut,
+ // then greedily merge near-duplicate clusters (RGB distance < 24) into the
+ // larger one, and finally keep the top `maxColors` real solids whose coverage
+ // is >= 1.5%. This preserves a 10%-area gold accent that a hard k=4 median-cut
+ // would have folded into the navy/cream majority.
+ const py = `
+import sys, json
+from PIL import Image
+from collections import Counter
+src = sys.argv[1]; k = int(sys.argv[2])
+im = Image.open(src).convert('RGB')
+q = im.quantize(colors=16, method=Image.MEDIANCUT).convert('RGB')
+c = Counter(q.getdata()); tot = sum(c.values())
+clusters = [] # [r,g,b,count]
+for rgb, n in c.most_common():
+ merged = False
+ for cl in clusters:
+ dr, dg, db = cl[0]-rgb[0], cl[1]-rgb[1], cl[2]-rgb[2]
+ if dr*dr + dg*dg + db*db < 24*24: # near-duplicate -> fold in
+ cl[3] += n; merged = True; break
+ if not merged:
+ clusters.append([rgb[0], rgb[1], rgb[2], n])
+clusters.sort(key=lambda x: -x[3])
+out = []
+for r, g, b, n in clusters:
+ pct = 100.0 * n / tot
+ if pct < 1.5: continue
+ out.append({'hex': '#%02x%02x%02x' % (r, g, b), 'rgb': [r, g, b], 'pct': round(pct, 2)})
+ if len(out) >= max(2, k): break
+print(json.dumps(out))
+`;
+ const res = execFileSync('python3', ['-c', py, src, String(maxColors)], { encoding: 'utf8' });
+ return JSON.parse(res.trim());
+}
+
+// ---- per-color 1-bit mask (PIL) -------------------------------------------
+// Build a black-on-white PBM mask where the target color's pixels (nearest by
+// Euclidean RGB distance among the palette) are BLACK (potrace traces black).
+function buildColorMask(src, palette, targetIdx, maskPath) {
+ const py = `
+import sys, json
+from PIL import Image
+import numpy as np
+src, pal_json, idx, out = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]
+pal = json.loads(pal_json)
+cols = np.array([p['rgb'] for p in pal], dtype=np.int16)
+im = np.asarray(Image.open(src).convert('RGB')).astype(np.int16)
+h, w, _ = im.shape
+flat = im.reshape(-1, 3)
+# nearest palette index per pixel
+d = ((flat[:, None, :] - cols[None, :, :]) ** 2).sum(2)
+nearest = d.argmin(1).reshape(h, w)
+# target color -> black(0), everything else white. potrace traces the black
+# region. Save as 1-bit PBM (mode '1') which is potrace's native bitmap input.
+mask = np.where(nearest == idx, 0, 255).astype('uint8')
+Image.fromarray(mask, 'L').convert('1', dither=Image.NONE).save(out)
+`;
+ execFileSync('python3', ['-c', py, src, JSON.stringify(palette), String(targetIdx), maskPath], { stdio: 'pipe' });
+}
+
+// ---- trace one mask to an SVG path body via potrace -----------------------
+// We emit potrace as SVG, then lift the <path d="..."> + transform so we can
+// recolor + composite manually. potrace's --color sets fill on its own path.
+function tracePath(maskPath, fillHex, opts) {
+ const svgPath = maskPath.replace(/\.pbm$/, '.svg');
+ execFileSync(POTRACE, [
+ '-s', // SVG backend
+ '-t', String(opts.turdsize), // turdsize (despeckle)
+ '-a', String(opts.alphamax), // alphamax (corner roundness)
+ '-C', fillHex, // fill color
+ '--flat', // single combined path (faster compose)
+ '-o', svgPath,
+ maskPath,
+ ], { stdio: 'pipe' });
+ const svg = fs.readFileSync(svgPath, 'utf8');
+ fs.unlinkSync(svgPath);
+ // Pull the <g ...> wrapper (carries the transform that maps potrace units
+ // back to pixel space) plus the inner <path>. Easiest robust approach: lift
+ // everything between <svg ...> and </svg>, dropping the metadata block.
+ const inner = svg
+ .replace(/[\s\S]*?<svg[^>]*>/, '')
+ .replace(/<\/svg>[\s\S]*$/, '')
+ .replace(/<metadata>[\s\S]*?<\/metadata>/, '')
+ .trim();
+ return inner;
+}
+
+// ---- LANCZOS fallback (PIL) ------------------------------------------------
+function lanczosUpscale(src, size, outPng) {
+ const py = `
+import sys
+from PIL import Image
+src, size, out = sys.argv[1], int(sys.argv[2]), sys.argv[3]
+im = Image.open(src).convert('RGB')
+im.resize((size, size), Image.LANCZOS).save(out)
+`;
+ execFileSync('python3', ['-c', py, src, String(size), outPng], { stdio: 'pipe' });
+}
+
+// ---- degenerate-trace guard ------------------------------------------------
+// A trace is "degenerate" if it collapsed the design — e.g. nearly all area is
+// the background fill (mask traced to almost nothing) or the SVG is empty.
+function traceLooksDegenerate(svgBodies) {
+ // Measure the total length of actual path geometry (the `d="..."` payloads).
+ // A real trace emits hundreds–thousands of coordinate chars; a collapsed
+ // trace emits little or nothing.
+ let pathChars = 0;
+ const re = /\bd="([^"]*)"/g;
+ for (const body of svgBodies) {
+ let m;
+ while ((m = re.exec(body)) !== null) pathChars += m[1].length;
+ }
+ return pathChars < 32; // essentially no path data emitted
+}
+
+// ---- main pipeline ---------------------------------------------------------
+function processOne(srcInput, label, opts) {
+ const ts = Date.now();
+ const outName = `clean_${label}_${ts}.png`;
+ const outPng = path.join(opts.out, outName);
+ fs.mkdirSync(opts.out, { recursive: true });
+
+ // Source PNG is ROUND-1 SACRED — we only ever read it.
+ const src = srcInput;
+ if (!fs.existsSync(src)) return { ok: false, label, error: 'source not found' };
+
+ if (opts.forceLanczos) {
+ lanczosUpscale(src, opts.size, outPng);
+ return { ok: true, label, method: 'lanczos', out: outPng };
+ }
+
+ let palette;
+ try {
+ palette = extractPalette(src, opts.colors);
+ } catch (e) {
+ lanczosUpscale(src, opts.size, outPng);
+ return { ok: true, label, method: 'lanczos(palette-fail)', out: outPng, note: e.message };
+ }
+
+ if (palette.length < 2) {
+ // single solid color — nothing to trace, LANCZOS is exact
+ lanczosUpscale(src, opts.size, outPng);
+ return { ok: true, label, method: 'lanczos(mono)', out: outPng, palette };
+ }
+
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ce_trace_'));
+ try {
+ // background = largest-area color → full-canvas rect; trace every OTHER color.
+ const bg = palette[0];
+ const bodies = [];
+ for (let i = 1; i < palette.length; i++) {
+ const maskPbm = path.join(tmpDir, `mask_${i}.pbm`);
+ buildColorMask(src, palette, i, maskPbm);
+ bodies.push(tracePath(maskPbm, palette[i].hex, opts));
+ }
+
+ if (traceLooksDegenerate(bodies)) {
+ lanczosUpscale(src, opts.size, outPng);
+ return { ok: true, label, method: 'lanczos(degenerate-trace)', out: outPng, palette };
+ }
+
+ // Compose one SVG: bg rect first, then each traced color layer on top.
+ // potrace path bodies already carry their own <g transform> mapping to the
+ // 1024-unit source space, so we set the SVG viewBox to the source dims and
+ // let the rasterizer scale to opts.size.
+ const dim = imgDim(src);
+ const svg =
+`<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="${opts.size}" height="${opts.size}" viewBox="0 0 ${dim.w} ${dim.h}">
+<rect x="0" y="0" width="${dim.w}" height="${dim.h}" fill="${bg.hex}"/>
+${bodies.join('\n')}
+</svg>`;
+ const svgPath = outPng.replace(/\.png$/, '.svg');
+ fs.writeFileSync(svgPath, svg);
+
+ // Rasterize at full print size. -background none + -flatten keeps fills exact.
+ execFileSync(MAGICK, [
+ '-density', '288', // high render density for crisp edges
+ '-background', 'none',
+ svgPath,
+ '-resize', `${opts.size}x${opts.size}`,
+ '-flatten',
+ outPng,
+ ], { stdio: 'pipe' });
+
+ if (!opts.keepSvg) fs.unlinkSync(svgPath);
+
+ return { ok: true, label, method: 'potrace-vector', out: outPng, palette,
+ svg: opts.keepSvg ? svgPath : null };
+ } catch (e) {
+ // Any trace/raster failure → LANCZOS so we always produce an output.
+ try { lanczosUpscale(src, opts.size, outPng); } catch { /* */ }
+ return { ok: fs.existsSync(outPng), label, method: 'lanczos(trace-error)', out: outPng, error: e.message };
+ } finally {
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
+ }
+}
+
+function imgDim(src) {
+ const out = execFileSync(MAGICK, ['identify', '-format', '%w %h', src], { encoding: 'utf8' });
+ const [w, h] = out.trim().split(/\s+/).map(Number);
+ return { w, h };
+}
+
+// ---- entry -----------------------------------------------------------------
+function main() {
+ const opts = parseArgs(process.argv.slice(2));
+ const jobs = [];
+
+ if (opts.path) {
+ const label = path.basename(opts.path).replace(/\.[^.]+$/, '');
+ jobs.push({ src: opts.path, label });
+ }
+ for (const id of opts.ids) {
+ const src = resolveSource(id, opts.base);
+ if (!src) { console.error(`[skip] id ${id}: source not found`); continue; }
+ jobs.push({ src, label: String(id) });
+ }
+
+ if (!jobs.length) {
+ console.error('No inputs. Usage: node scripts/clean-edge-upscale.js <id...> | --path <file>');
+ process.exit(1);
+ }
+
+ for (const job of jobs) {
+ const r = processOne(job.src, job.label, opts);
+ if (r.ok) {
+ console.log(`[ok] ${r.label} method=${r.method} -> ${r.out}` +
+ (r.palette ? ` colors=${r.palette.length}` : ''));
+ } else {
+ console.error(`[fail] ${r.label}: ${r.error}`);
+ }
+ }
+}
+
+if (require.main === module) main();
+module.exports = { extractPalette, processOne, resolveSource };
← 9bf32a7 damask line: purge 1207 luxe-animal junk + publish 2 settlem
·
back to Wallco Ai
·
bulk-action 'digital': exempt designs with a built room-size 5499527 →