← back to Wallco Ai
Add flat-color vectorize pipeline (potrace) + edge-fix comparison proof
040d45364e6d1907804e85e2bcd64abe5ff96c84 · 2026-05-27 14:12:24 -0700 · Steve Abrams
DTD task#32: potrace vector-trace + nearest-ink snap is the standing clean-edge
fix for sub-DPI ComfyUI renders, NOT LANCZOS. On #53677, LANCZOS exploded 3 inks
into 111,029 colors (3.77% gradient ramp, violates solid-screen-print); potrace
holds exactly 3 solid inks (0% ramp) with smooth vector curves.
- scripts/vectorize/vectorize.py: dominant-ink quantize -> potrace per ink ->
compose SVG -> cairosvg 5400px -> snap to N inks (median-cut dropped the gold
ink, so quantize on population-dominant colors instead)
- scripts/vectorize/compare.py: auto-find busiest window, 3-up crop strip +
AA-ramp metric
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M .gitignoreA scripts/vectorize/compare.pyA scripts/vectorize/vectorize.py
Diff
commit 040d45364e6d1907804e85e2bcd64abe5ff96c84
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 27 14:12:24 2026 -0700
Add flat-color vectorize pipeline (potrace) + edge-fix comparison proof
DTD task#32: potrace vector-trace + nearest-ink snap is the standing clean-edge
fix for sub-DPI ComfyUI renders, NOT LANCZOS. On #53677, LANCZOS exploded 3 inks
into 111,029 colors (3.77% gradient ramp, violates solid-screen-print); potrace
holds exactly 3 solid inks (0% ramp) with smooth vector curves.
- scripts/vectorize/vectorize.py: dominant-ink quantize -> potrace per ink ->
compose SVG -> cairosvg 5400px -> snap to N inks (median-cut dropped the gold
ink, so quantize on population-dominant colors instead)
- scripts/vectorize/compare.py: auto-find busiest window, 3-up crop strip +
AA-ramp metric
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
.gitignore | 1 +
scripts/vectorize/compare.py | 124 ++++++++++++++++++++++++++++
scripts/vectorize/vectorize.py | 181 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 306 insertions(+)
diff --git a/.gitignore b/.gitignore
index 5dd4ac7..960bad6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -56,3 +56,4 @@ data/crontab.backup-*
# soft-deleted scratch (per Steve's destructive-action defaults rule)
data/quarantine/
data/tif/
+scripts/vectorize/.venv/
diff --git a/scripts/vectorize/compare.py b/scripts/vectorize/compare.py
new file mode 100644
index 0000000..b84330b
--- /dev/null
+++ b/scripts/vectorize/compare.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""
+compare.py — apples-to-apples clean-edge comparison for a flat-color design.
+
+Given the 1024px ORIGINAL plus its LANCZOS->5400 and potrace->5400 renders,
+auto-find the busiest (highest edge-density) region and emit three crops of the
+SAME physical area at equal display size, plus a labeled side-by-side strip.
+
+ (a) ORIGINAL 1024 -> NEAREST upscale (shows the true stair-step jaggies)
+ (b) LANCZOS 5400 -> area-avg downscale to display size
+ (c) potrace 5400 -> area-avg downscale to display size
+
+Also prints an objective edge-sharpness metric per render: the fraction of
+edge-band pixels that are "intermediate" (neither ink) = the anti-alias / blur
+ramp width. Lower = crisper, solid-er screen-print edges.
+"""
+import argparse
+from pathlib import Path
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+
+
+def edge_density_window(arr1024, win=200):
+ """Return (x0,y0) of the win x win window with the most color transitions."""
+ g = arr1024.astype(np.int32).sum(2)
+ diff = np.zeros(g.shape, dtype=np.int32)
+ diff[:, :-1] += (np.abs(np.diff(g, axis=1)) > 30).astype(np.int32)
+ diff[:-1, :] += (np.abs(np.diff(g, axis=0)) > 30).astype(np.int32)
+ # integral image for fast window sums
+ ii = np.zeros((diff.shape[0] + 1, diff.shape[1] + 1), dtype=np.int64)
+ ii[1:, 1:] = np.cumsum(np.cumsum(diff, 0), 1)
+ H, W = g.shape
+ best, bxy = -1, (0, 0)
+ for y in range(0, H - win, 16):
+ for x in range(0, W - win, 16):
+ s = ii[y + win, x + win] - ii[y, x + win] - ii[y + win, x] + ii[y, x]
+ if s > best:
+ best, bxy = s, (x, y)
+ return bxy
+
+
+def aa_ramp_fraction(arr, inks, tol=20):
+ """Fraction of edge-band pixels that are NOT within tol of any ink = blur/AA ramp."""
+ pal = np.array(inks, dtype=np.int32)
+ flat = arr.reshape(-1, 3).astype(np.int32)
+ out = np.empty(flat.shape[0])
+ step = 2_000_000
+ for s in range(0, flat.shape[0], step):
+ ch = flat[s:s + step]
+ d = np.sqrt(((ch[:, None, :] - pal[None, :, :]) ** 2).sum(2)).min(1)
+ out[s:s + step] = d
+ return float((out > tol).mean())
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--orig", required=True)
+ ap.add_argument("--lanczos", required=True)
+ ap.add_argument("--potrace", required=True)
+ ap.add_argument("--inks", nargs="+", required=True, help="ink hexes e.g. 010345 fefcea b9934e")
+ ap.add_argument("--win", type=int, default=200, help="crop size in ORIGINAL px")
+ ap.add_argument("--disp", type=int, default=900, help="display px per crop")
+ ap.add_argument("--outdir", default="/tmp/edgefix")
+ args = ap.parse_args()
+
+ inks = [tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) for h in args.inks]
+ outdir = Path(args.outdir)
+
+ orig = Image.open(args.orig).convert("RGB")
+ lanc = Image.open(args.lanczos).convert("RGB")
+ potr = Image.open(args.potrace).convert("RGB")
+ O = orig.size[0]; T = lanc.size[0]; scale = T / O
+
+ x0, y0 = edge_density_window(np.array(orig), args.win)
+ w = args.win
+ print(f"[compare] busiest {w}x{w} window @ ({x0},{y0}) in {O}px space")
+
+ # (a) original crop -> NEAREST upscale (true pixels / jaggies)
+ a = orig.crop((x0, y0, x0 + w, y0 + w)).resize((args.disp, args.disp), Image.NEAREST)
+ # (b)/(c) hi-res crops -> high-quality downscale to display size
+ bx0, by0 = round(x0 * scale), round(y0 * scale); bw = round(w * scale)
+ b = lanc.crop((bx0, by0, bx0 + bw, by0 + bw)).resize((args.disp, args.disp), Image.LANCZOS)
+ c = potr.crop((bx0, by0, bx0 + bw, by0 + bw)).resize((args.disp, args.disp), Image.LANCZOS)
+
+ a.save(outdir / "crop_a_original_1024.png")
+ b.save(outdir / "crop_b_lanczos_5400.png")
+ c.save(outdir / "crop_c_potrace_5400.png")
+
+ # full-frame AA-ramp metric (objective edge crispness, lower=crisper)
+ fa = aa_ramp_fraction(np.array(orig), inks)
+ fb = aa_ramp_fraction(np.array(lanc), inks)
+ fc = aa_ramp_fraction(np.array(potr), inks)
+ print(f"[compare] AA/blur-ramp fraction (lower=crisper, more solid):")
+ print(f" (a) original 1024 : {fa*100:6.3f}%")
+ print(f" (b) LANCZOS 5400 : {fb*100:6.3f}%")
+ print(f" (c) potrace 5400 : {fc*100:6.3f}%")
+
+ # labeled side-by-side strip
+ pad, lab = 24, 46
+ W = args.disp * 3 + pad * 4
+ H = args.disp + lab + pad * 2
+ strip = Image.new("RGB", (W, H), (245, 245, 245))
+ d = ImageDraw.Draw(strip)
+ try:
+ font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 26)
+ except Exception:
+ font = ImageFont.load_default()
+ labels = [
+ ("(a) ORIGINAL 1024px (~28 DPI)", a, fa),
+ ("(b) LANCZOS -> 5400px", b, fb),
+ ("(c) potrace VECTOR -> 5400px", c, fc),
+ ]
+ for i, (txt, im, frac) in enumerate(labels):
+ x = pad + i * (args.disp + pad)
+ d.text((x, pad // 2), txt, fill=(20, 20, 20), font=font)
+ strip.paste(im, (x, lab + pad))
+ d.text((x, lab + pad + args.disp - 34), f"AA-ramp {frac*100:.2f}%",
+ fill=(220, 30, 30), font=font)
+ strip.save(outdir / "compare_strip_53677.png")
+ print(f"[compare] wrote {outdir/'compare_strip_53677.png'}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/vectorize/vectorize.py b/scripts/vectorize/vectorize.py
new file mode 100644
index 0000000..6d7346f
--- /dev/null
+++ b/scripts/vectorize/vectorize.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+"""
+vectorize.py — flat-color wallpaper vectorizer (potrace pipeline).
+
+Pipeline for solid-screen-print designs that were rendered too small
+(e.g. ComfyUI 1024px for a 36" tile = ~28 DPI -> jagged stair-stepped edges):
+
+ 1. Quantize to N solid inks (median-cut, NO dither -> stays solid).
+ 2. For each ink (painted back-to-front, largest area first):
+ build a 1-bit mask -> potrace -> SVG path.
+ 3. Compose one SVG: full-canvas rect of the base ink + each ink's path on top.
+ 4. Rasterize the vector at TARGET px (default 5400 = 150 DPI for 36").
+ 5. Snap the rasterized output back to the N inks (nearest-color) so cairo's
+ 1px edge anti-aliasing is removed -> truly solid screen-print colors,
+ clean GEOMETRIC edges (no stair-step, no gradient ramp).
+
+Vector edges are resolution-independent, so the stair-step is gone for good
+(not blurred into an anti-aliased ramp the way a LANCZOS upscale does it).
+
+Usage:
+ vectorize.py SRC.png OUT.png [--inks N] [--target 5400]
+ [--turdsize T] [--alphamax A] [--keep-aa] [--svg OUT.svg]
+"""
+import argparse, subprocess, sys, tempfile, os, re
+from pathlib import Path
+import numpy as np
+from PIL import Image
+import cairosvg
+
+
+def quantize_dominant(img: Image.Image, n: int, merge_dist: float = 24.0):
+ """Population-dominant inks: pick the n most frequent colors that are each
+ > merge_dist (RGB euclidean) from every already-picked ink, then assign every
+ pixel to its nearest ink. Faithful for FLAT screen-print designs where the
+ real inks are exact dominant colors and the rest is just edge anti-alias fringe
+ (median-cut wrongly splits a big flat region into 2 boxes and drops a small ink)."""
+ arr = np.array(img.convert("RGB"))
+ flat = arr.reshape(-1, 3)
+ uniq, counts = np.unique(flat, axis=0, return_counts=True)
+ order = np.argsort(-counts)
+ inks = []
+ for i in order:
+ c = uniq[i].astype(np.int32)
+ if all(np.sqrt(((c - np.array(k, dtype=np.int32)) ** 2).sum()) > merge_dist for k in inks):
+ inks.append(tuple(int(x) for x in c))
+ if len(inks) >= n:
+ break
+ pal = np.array(inks, dtype=np.int32)
+ d = ((flat[:, None, :].astype(np.int32) - pal[None, :, :]) ** 2).sum(2)
+ labels = d.argmin(1).reshape(arr.shape[:2]).astype(np.int32)
+ return labels, [tuple(int(x) for x in c) for c in inks]
+
+
+def quantize_mediancut(img: Image.Image, n: int):
+ q = img.convert("RGB").quantize(colors=n, method=Image.MEDIANCUT, dither=Image.NONE)
+ pal = q.getpalette()[: n * 3]
+ colors = [tuple(pal[i * 3 : i * 3 + 3]) for i in range(n)]
+ return np.array(q), colors
+
+
+def potrace_layer(mask: np.ndarray, turdsize: int, alphamax: float, tmpdir: str, tag: str):
+ """mask: bool HxW (True = this ink). Returns the inner SVG path string(s)."""
+ h, w = mask.shape
+ # potrace: black (0) = foreground it traces. Selected ink -> black.
+ pbm = np.where(mask, 0, 255).astype(np.uint8)
+ pbm_path = os.path.join(tmpdir, f"layer_{tag}.pbm")
+ svg_path = os.path.join(tmpdir, f"layer_{tag}.svg")
+ Image.fromarray(pbm, mode="L").convert("1").save(pbm_path)
+ subprocess.run(
+ ["potrace", pbm_path, "-b", "svg", "-o", svg_path,
+ "-t", str(turdsize), "-a", str(alphamax), "--flat"],
+ check=True, capture_output=True,
+ )
+ svg = Path(svg_path).read_text()
+ # Pull out the <g ...> ... </g> body (transform + paths) potrace emits.
+ m = re.search(r"<g\s+([^>]*)>(.*?)</g>", svg, re.S)
+ if not m:
+ return None, None
+ g_attrs, body = m.group(1), m.group(2)
+ tm = re.search(r'transform="([^"]+)"', g_attrs)
+ transform = tm.group(1) if tm else ""
+ paths = re.findall(r"<path\b[^>]*\bd=\"([^\"]+)\"", body)
+ return transform, paths
+
+
+def build_svg(labels, colors, turdsize, alphamax, tmpdir):
+ h, w = labels.shape
+ # paint order: largest area first (base), so its rect underlays everything.
+ order = sorted(range(len(colors)), key=lambda i: int((labels == i).sum()), reverse=True)
+ base = order[0]
+ br, bg, bb = colors[base]
+ parts = [
+ f'<?xml version="1.0" encoding="UTF-8"?>',
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" '
+ f'viewBox="0 0 {w} {h}" shape-rendering="crispEdges">',
+ f'<rect x="0" y="0" width="{w}" height="{h}" fill="#{br:02x}{bg:02x}{bb:02x}"/>',
+ ]
+ transform = None
+ for idx in order[1:]:
+ mask = labels == idx
+ if not mask.any():
+ continue
+ tr, paths = potrace_layer(mask, turdsize, alphamax, tmpdir, str(idx))
+ if not paths:
+ continue
+ transform = tr
+ r, g, b = colors[idx]
+ d = " ".join(paths)
+ parts.append(f'<g transform="{tr}" fill="#{r:02x}{g:02x}{b:02x}" stroke="none">'
+ f'<path d="{d}"/></g>')
+ parts.append("</svg>")
+ return "\n".join(parts), colors
+
+
+def snap_to_inks(arr: np.ndarray, colors):
+ """Nearest-color snap to the N inks -> removes edge AA, enforces solid screen print."""
+ pal = np.array(colors, dtype=np.int32) # N x 3
+ flat = arr.reshape(-1, 3).astype(np.int32) # P x 3
+ # chunk to keep memory sane at 5400^2
+ out = np.empty((flat.shape[0],), dtype=np.int32)
+ step = 1_000_000
+ for s in range(0, flat.shape[0], step):
+ chunk = flat[s : s + step]
+ d = ((chunk[:, None, :] - pal[None, :, :]) ** 2).sum(2)
+ out[s : s + step] = d.argmin(1)
+ snapped = pal[out].astype(np.uint8).reshape(arr.shape)
+ return snapped
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("src")
+ ap.add_argument("out")
+ ap.add_argument("--inks", type=int, default=4)
+ ap.add_argument("--method", choices=["dominant", "mediancut"], default="dominant",
+ help="dominant = population-dominant inks (best for flat screen-print); "
+ "mediancut = PIL median-cut")
+ ap.add_argument("--target", type=int, default=5400)
+ ap.add_argument("--turdsize", type=int, default=2)
+ ap.add_argument("--alphamax", type=float, default=1.0)
+ ap.add_argument("--keep-aa", action="store_true",
+ help="skip the nearest-ink snap (keep cairo edge anti-aliasing)")
+ ap.add_argument("--svg", default=None, help="also write the intermediate SVG here")
+ args = ap.parse_args()
+
+ img = Image.open(args.src).convert("RGB")
+ labels, colors = (quantize_dominant(img, args.inks) if args.method == "dominant"
+ else quantize_mediancut(img, args.inks))
+ # drop unused palette slots
+ used = sorted({int(x) for x in np.unique(labels)})
+ remap = {old: new for new, old in enumerate(used)}
+ labels = np.vectorize(remap.get)(labels).astype(np.int32)
+ colors = [colors[o] for o in used]
+ print(f"[vectorize] {Path(args.src).name}: {img.size} -> {len(colors)} inks "
+ f"{['#%02x%02x%02x' % c for c in colors]}")
+
+ with tempfile.TemporaryDirectory() as td:
+ svg, colors = build_svg(labels, colors, args.turdsize, args.alphamax, td)
+ if args.svg:
+ Path(args.svg).write_text(svg)
+ print(f"[vectorize] wrote SVG -> {args.svg} ({len(svg)} bytes)")
+
+ png_bytes = cairosvg.svg2png(bytestring=svg.encode(), output_width=args.target,
+ output_height=args.target)
+ tmp_png = args.out + ".aa.tmp.png"
+ Path(tmp_png).write_bytes(png_bytes)
+ arr = np.array(Image.open(tmp_png).convert("RGB"))
+ os.remove(tmp_png)
+
+ if not args.keep_aa:
+ before = len(np.unique(arr.reshape(-1, 3), axis=0))
+ arr = snap_to_inks(arr, colors)
+ after = len(np.unique(arr.reshape(-1, 3), axis=0))
+ print(f"[vectorize] ink-snap: {before} colors -> {after} colors (solid screen print)")
+
+ Image.fromarray(arr).save(args.out)
+ print(f"[vectorize] wrote {args.target}x{args.target} -> {args.out}")
+
+
+if __name__ == "__main__":
+ main()
← 7462d55 drunk-curator (Hot or Not): fix votes silently dropped + sam
·
back to Wallco Ai
·
100% publish gate: no pattern goes live without passing ever 7eb9c7b →