Download app.py from Yankkee/vectorise: direct link, hf CLI and curl.
- Browser
- Download file 13.9 kB
-
https://huggingface.co/spaces/Yankkee/vectorise/resolve/main/app.py
- Command line
-
hf download hf://spaces/Yankkee/vectorise/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Yankkee/vectorise/resolve/main/app.py
13.9 kB
| """ | |
| Vector Studio — PNG/JPG to SVG | |
| Clean line work for generated images. | |
| Hugging Face Space (Gradio SDK). Run locally: python app.py | |
| """ | |
| import os | |
| import re | |
| import base64 | |
| import tempfile | |
| import uuid | |
| import numpy as np | |
| from PIL import Image, ImageOps, ImageFilter | |
| import gradio as gr | |
| import vtracer | |
| MAX_SIDE = 2600 # Cap after upscaling, keeps free CPU Spaces from timing out | |
| OUT_DIR = os.path.join(tempfile.gettempdir(), "svg_out") | |
| os.makedirs(OUT_DIR, exist_ok=True) | |
| # -------------------------------------------------------------------------- | |
| # Image preparation | |
| # -------------------------------------------------------------------------- | |
| def flatten(img: Image.Image) -> Image.Image: | |
| """Composite alpha onto white so transparency isn't traced as black.""" | |
| img = ImageOps.exif_transpose(img) | |
| if img.mode in ("RGBA", "LA", "P"): | |
| img = img.convert("RGBA") | |
| bg = Image.new("RGBA", img.size, (255, 255, 255, 255)) | |
| img = Image.alpha_composite(bg, img) | |
| return img.convert("RGB") | |
| def otsu_threshold(arr: np.ndarray) -> int: | |
| hist = np.bincount(arr.ravel(), minlength=256).astype(np.float64) | |
| total = hist.sum() | |
| omega = np.cumsum(hist) / total | |
| mu = np.cumsum(hist * np.arange(256)) / total | |
| mu_t = mu[-1] | |
| denom = omega * (1.0 - omega) | |
| denom[denom == 0] = 1e-12 | |
| sigma_b = (mu_t * omega - mu) ** 2 / denom | |
| return int(np.argmax(sigma_b)) | |
| def binarize(gray: Image.Image, method: str, level: int, block: int, offset: int) -> np.ndarray: | |
| """Return a boolean mask where True means ink.""" | |
| arr = np.asarray(gray, dtype=np.uint8) | |
| if method.startswith("Adaptive"): | |
| radius = max(1, int(block) // 2) | |
| local = np.asarray(gray.filter(ImageFilter.BoxBlur(radius)), dtype=np.int16) | |
| return arr.astype(np.int16) < (local - int(offset)) | |
| if method.startswith("Otsu"): | |
| level = otsu_threshold(arr) | |
| return arr < int(level) | |
| def zhang_suen(mask: np.ndarray, max_iter: int = 60) -> np.ndarray: | |
| """Skeletonize: thin every stroke down to a 1 px centerline.""" | |
| img = mask.copy() | |
| for _ in range(max_iter): | |
| changed = False | |
| for step in (0, 1): | |
| P = np.pad(img, 1, constant_values=False) | |
| P2, P3, P4 = P[:-2, 1:-1], P[:-2, 2:], P[1:-1, 2:] | |
| P5, P6, P7 = P[2:, 2:], P[2:, 1:-1], P[2:, :-2] | |
| P8, P9 = P[1:-1, :-2], P[:-2, :-2] | |
| ring = [P2, P3, P4, P5, P6, P7, P8, P9, P2] | |
| B = sum(x.astype(np.uint8) for x in ring[:8]) | |
| A = np.zeros(img.shape, np.uint8) | |
| for i in range(8): | |
| A += (~ring[i] & ring[i + 1]).astype(np.uint8) | |
| base = img & (B >= 2) & (B <= 6) & (A == 1) | |
| if step == 0: | |
| cond = base & ~(P2 & P4 & P6) & ~(P4 & P6 & P8) | |
| else: | |
| cond = base & ~(P2 & P4 & P8) & ~(P2 & P6 & P8) | |
| if cond.any(): | |
| img &= ~cond | |
| changed = True | |
| if not changed: | |
| break | |
| return img | |
| def set_stroke_width(mask: np.ndarray, width: int) -> np.ndarray: | |
| """Grow the skeleton back out to one uniform stroke width.""" | |
| if width <= 1: | |
| return mask | |
| ink = Image.fromarray(np.where(mask, 255, 0).astype(np.uint8)) | |
| size = width if width % 2 == 1 else width + 1 | |
| ink = ink.filter(ImageFilter.MaxFilter(size)) | |
| return np.asarray(ink) > 127 | |
| def cap_size(img: Image.Image) -> Image.Image: | |
| w, h = img.size | |
| if max(w, h) <= MAX_SIDE: | |
| return img | |
| s = MAX_SIDE / max(w, h) | |
| return img.resize((max(1, int(w * s)), max(1, int(h * s))), Image.LANCZOS) | |
| # -------------------------------------------------------------------------- | |
| # SVG post-processing | |
| # -------------------------------------------------------------------------- | |
| def strip_background(svg: str) -> str: | |
| """Drop near-white fills, which is what the background becomes in color mode.""" | |
| def is_light(hexcol: str) -> bool: | |
| r, g, b = (int(hexcol[i:i + 2], 16) for i in (1, 3, 5)) | |
| return r > 243 and g > 243 and b > 243 | |
| return re.sub( | |
| r'<path[^>]*fill="(#[0-9a-fA-F]{6})"[^>]*/>\s*', | |
| lambda m: "" if is_light(m.group(1)) else m.group(0), | |
| svg, | |
| ) | |
| def recolor(svg: str, color: str) -> str: | |
| return re.sub(r'fill="#000000"', f'fill="{color}"', svg) | |
| def rescale_root(svg: str, out_w: int, out_h: int) -> str: | |
| """Add a viewBox and set the display size back to the source dimensions.""" | |
| m = re.search(r'<svg([^>]*)width="(\d+)"\s+height="(\d+)"', svg) | |
| if not m: | |
| return svg | |
| vw, vh = m.group(2), m.group(3) | |
| new_tag = ( | |
| f'<svg{m.group(1)}width="{out_w}" height="{out_h}" ' | |
| f'viewBox="0 0 {vw} {vh}"' | |
| ) | |
| return svg[: m.start()] + new_tag + svg[m.end():] | |
| def preview_html(svg: str, label: str) -> str: | |
| if len(svg) > 4_000_000: | |
| return ( | |
| f"<div class='vs-note'>{label} is large ({len(svg) // 1024} KB). " | |
| "Download it instead of previewing here, or raise " | |
| "<em>Filter small specks</em> to cut the path count.</div>" | |
| ) | |
| b64 = base64.b64encode(svg.encode("utf-8")).decode("ascii") | |
| return ( | |
| "<div class='vs-stage'>" | |
| f"<img src='data:image/svg+xml;base64,{b64}' alt='{label}'/>" | |
| "</div>" | |
| ) | |
| # -------------------------------------------------------------------------- | |
| # Main pipeline | |
| # -------------------------------------------------------------------------- | |
| def vectorize( | |
| image, | |
| mode, | |
| invert, | |
| thr_method, | |
| thr_level, | |
| block, | |
| offset, | |
| denoise, | |
| upscale, | |
| uniform, | |
| stroke_w, | |
| curve_mode, | |
| speckle, | |
| corner, | |
| length_thr, | |
| splice, | |
| precision, | |
| colors, | |
| layer_diff, | |
| hierarchical, | |
| drop_bg, | |
| line_color, | |
| progress=gr.Progress(), | |
| ): | |
| if image is None: | |
| raise gr.Error("Upload a PNG or JPG first.") | |
| progress(0.1, desc="Preparing image") | |
| src = flatten(image) | |
| orig_w, orig_h = src.size | |
| work = src | |
| if upscale > 1: | |
| work = work.resize( | |
| (int(orig_w * upscale), int(orig_h * upscale)), Image.LANCZOS | |
| ) | |
| work = cap_size(work) | |
| line_mode = mode.startswith("Line") | |
| if line_mode: | |
| progress(0.3, desc="Isolating lines") | |
| gray = ImageOps.autocontrast(work.convert("L"), cutoff=1) | |
| if invert: | |
| gray = ImageOps.invert(gray) | |
| if denoise > 0: | |
| gray = gray.filter(ImageFilter.MedianFilter(int(denoise) * 2 + 1)) | |
| mask = binarize(gray, thr_method, thr_level, block, offset) | |
| if uniform: | |
| progress(0.45, desc="Finding centerlines") | |
| mask = set_stroke_width(zhang_suen(mask), int(stroke_w)) | |
| prepped = Image.fromarray(np.where(mask, 0, 255).astype(np.uint8)).convert("RGB") | |
| else: | |
| progress(0.35, desc="Reducing color areas") | |
| prepped = work | |
| if denoise > 0: | |
| prepped = prepped.filter(ImageFilter.MedianFilter(int(denoise) * 2 + 1)) | |
| stem = uuid.uuid4().hex[:10] | |
| tmp_png = os.path.join(OUT_DIR, f"{stem}.png") | |
| out_svg = os.path.join(OUT_DIR, "vector.svg" if line_mode else "vector-color.svg") | |
| prepped.save(tmp_png) | |
| progress(0.6, desc="Tracing paths") | |
| vtracer.convert_image_to_svg_py( | |
| tmp_png, | |
| out_svg, | |
| colormode="binary" if line_mode else "color", | |
| hierarchical="cutout" if hierarchical == "Cutout" else "stacked", | |
| mode="polygon" if curve_mode.startswith("Polygon") else "spline", | |
| filter_speckle=int(speckle), | |
| color_precision=int(colors), | |
| layer_difference=int(layer_diff), | |
| corner_threshold=int(corner), | |
| length_threshold=float(length_thr), | |
| max_iterations=10, | |
| splice_threshold=int(splice), | |
| path_precision=int(precision), | |
| ) | |
| progress(0.85, desc="Cleaning up SVG") | |
| svg = open(out_svg, "r", encoding="utf-8").read() | |
| if drop_bg: | |
| svg = strip_background(svg) | |
| if line_mode: | |
| svg = recolor(svg, line_color) | |
| svg = rescale_root(svg, orig_w, orig_h) | |
| with open(out_svg, "w", encoding="utf-8") as f: | |
| f.write(svg) | |
| try: | |
| os.remove(tmp_png) | |
| except OSError: | |
| pass | |
| n_paths = svg.count("<path") | |
| stats = ( | |
| f"**{n_paths} path{'' if n_paths == 1 else 's'}** · {len(svg) / 1024:.1f} KB · " | |
| f"canvas {orig_w}×{orig_h} px · traced at {prepped.size[0]}×{prepped.size[1]} px" | |
| ) | |
| return preview_html(svg, "Vector result"), out_svg, stats | |
| # -------------------------------------------------------------------------- | |
| # Interface | |
| # -------------------------------------------------------------------------- | |
| CSS = """ | |
| .vs-stage { | |
| background-color: #f7f5f2; | |
| background-image: | |
| linear-gradient(45deg, #e6e2dc 25%, transparent 25%, transparent 75%, #e6e2dc 75%), | |
| linear-gradient(45deg, #e6e2dc 25%, transparent 25%, transparent 75%, #e6e2dc 75%); | |
| background-size: 18px 18px; | |
| background-position: 0 0, 9px 9px; | |
| border-radius: 10px; padding: 14px; min-height: 240px; | |
| display: flex; align-items: center; justify-content: center; | |
| } | |
| .vs-stage img { max-width: 100%; max-height: 62vh; } | |
| .vs-note { padding: 18px; font-size: 0.9rem; line-height: 1.5; } | |
| footer { display: none !important; } | |
| """ | |
| # Gradio 6 wants theme/css on launch(), Gradio 5 on the Blocks constructor. | |
| _MAJOR = int(gr.__version__.split(".")[0]) | |
| _STYLE = {"theme": gr.themes.Soft(), "css": CSS} | |
| _BLOCKS_KW = {} if _MAJOR >= 6 else _STYLE | |
| _LAUNCH_KW = _STYLE if _MAJOR >= 6 else {} | |
| with gr.Blocks(title="PNG/JPG to SVG", **_BLOCKS_KW) as demo: | |
| gr.Markdown( | |
| "## PNG / JPG to SVG\n" | |
| "Turn bitmaps into clean, scalable paths. " | |
| "Built for AI-generated drawings, logos and sketches." | |
| ) | |
| with gr.Row(): | |
| # ---------------- left column: input and controls ---------------- | |
| with gr.Column(scale=4): | |
| image = gr.Image(label="Image", type="pil", sources=["upload", "clipboard"], | |
| image_mode="RGBA", height=300) | |
| mode = gr.Radio( | |
| ["Line art (black & white)", "Color (filled shapes)"], | |
| value="Line art (black & white)", | |
| label="Mode", | |
| ) | |
| run = gr.Button("Vectorize", variant="primary") | |
| with gr.Accordion("Isolate lines", open=True) as line_box: | |
| thr_method = gr.Radio( | |
| ["Otsu (automatic)", "Global", "Adaptive (uneven lighting)"], | |
| value="Otsu (automatic)", | |
| label="Threshold", | |
| ) | |
| thr_level = gr.Slider(0, 255, 128, step=1, label="Cutoff (Global only)") | |
| block = gr.Slider(5, 151, 41, step=2, label="Window size (Adaptive only)") | |
| offset = gr.Slider(0, 40, 8, step=1, label="Sensitivity (Adaptive only)") | |
| invert = gr.Checkbox(False, label="Invert (light lines on dark background)") | |
| denoise = gr.Slider(0, 3, 1, step=1, label="Smooth noise") | |
| upscale = gr.Slider(1, 4, 2, step=1, | |
| label="Upscale before tracing (smoother curves)") | |
| uniform = gr.Checkbox( | |
| False, label="Uniform stroke width (redraw from centerlines)" | |
| ) | |
| stroke_w = gr.Slider(1, 9, 3, step=2, label="Stroke width in px") | |
| with gr.Accordion("Path quality", open=False): | |
| curve_mode = gr.Radio( | |
| ["Spline (smooth curves)", "Polygon (hard edges)"], | |
| value="Spline (smooth curves)", label="Curve type", | |
| ) | |
| speckle = gr.Slider(0, 40, 6, step=1, label="Filter small specks") | |
| corner = gr.Slider(0, 180, 60, step=1, label="Corner threshold") | |
| length_thr = gr.Slider(0.5, 10, 4, step=0.5, label="Shortest segment") | |
| splice = gr.Slider(0, 180, 45, step=1, label="Curve splicing") | |
| precision = gr.Slider(1, 8, 4, step=1, label="Coordinate precision") | |
| with gr.Accordion("Color and output", open=False): | |
| colors = gr.Slider(1, 8, 6, step=1, label="Color depth (color mode)") | |
| layer_diff = gr.Slider(0, 64, 16, step=1, label="Layer spacing (color mode)") | |
| hierarchical = gr.Radio( | |
| ["Stacked", "Cutout"], value="Stacked", label="Layer structure" | |
| ) | |
| drop_bg = gr.Checkbox(True, label="Remove white background") | |
| line_color = gr.ColorPicker("#111111", label="Line color") | |
| # ---------------- right column: result ---------------- | |
| with gr.Column(scale=6): | |
| preview = gr.HTML("<div class='vs-stage'><span class='vs-note'>" | |
| "Your result appears here.</span></div>", | |
| label="Preview") | |
| stats = gr.Markdown("") | |
| download = gr.File(label="Download SVG", height=90) | |
| gr.Markdown( | |
| "**For clean lines:** upscale 2–3×, use spline curves, set speck filtering to 6–12. " | |
| "If a drawing comes out ragged, turn on *Uniform stroke width* — it pulls every " | |
| "line onto a centerline and redraws it at a constant weight." | |
| ) | |
| def toggle(m): | |
| return gr.update(open=m.startswith("Line")) | |
| mode.change(toggle, mode, line_box) | |
| inputs = [image, mode, invert, thr_method, thr_level, block, offset, denoise, | |
| upscale, uniform, stroke_w, curve_mode, speckle, corner, length_thr, | |
| splice, precision, colors, layer_diff, hierarchical, drop_bg, line_color] | |
| run.click(vectorize, inputs, [preview, download, stats]) | |
| if __name__ == "__main__": | |
| demo.queue(max_size=20).launch( | |
| server_name="0.0.0.0", | |
| server_port=int(os.environ.get("PORT", 7860)), | |
| ssr_mode=False, | |
| **_LAUNCH_KW, | |
| ) | |