[object Object]

← 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

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 →