[object Object]

← 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

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 →