← back to Wallco Ai
scripts/clean-edge-upscale.js
342 lines
#!/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 };