Created
March 31, 2026 14:50
-
-
Save johannschopplich/dbd4e075e5e8dcf5cb9ce86df1e7a1ab to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """Convert pixel-art PNG to SVG by tracing contours of same-color regions. | |
| Each contiguous (4-connected) region of same-color pixels becomes its own | |
| <path> element — one vector shape per visible region. | |
| """ | |
| import argparse | |
| import sys | |
| from collections import defaultdict, deque | |
| from pathlib import Path | |
| from PIL import Image | |
| type Coord = tuple[int, int] | |
| type PixelSet = set[Coord] | |
| def trace_contours(pixel_set: PixelSet) -> list[list[Coord]]: | |
| """Return closed boundary loops for a set of pixels. | |
| Each pixel (x, y) occupies the unit square (x, y)-(x+1, y+1). | |
| Boundary edges are directed so that the interior is on the right. | |
| """ | |
| edge_map: dict[Coord, list[Coord]] = defaultdict(list) | |
| for x, y in pixel_set: | |
| if (x, y - 1) not in pixel_set: # top | |
| edge_map[(x, y)].append((x + 1, y)) | |
| if (x + 1, y) not in pixel_set: # right | |
| edge_map[(x + 1, y)].append((x + 1, y + 1)) | |
| if (x, y + 1) not in pixel_set: # bottom | |
| edge_map[(x + 1, y + 1)].append((x, y + 1)) | |
| if (x - 1, y) not in pixel_set: # left | |
| edge_map[(x, y + 1)].append((x, y)) | |
| contours: list[list[Coord]] = [] | |
| while edge_map: | |
| start = next(iter(edge_map)) | |
| loop = [start] | |
| cur = start | |
| while True: | |
| nxt = edge_map[cur].pop() | |
| if not edge_map[cur]: | |
| del edge_map[cur] | |
| if nxt == start: | |
| break | |
| loop.append(nxt) | |
| cur = nxt | |
| contours.append(loop) | |
| return contours | |
| def simplify(contour: list[Coord]) -> list[Coord]: | |
| """Drop collinear intermediate vertices.""" | |
| n = len(contour) | |
| if n < 3: | |
| return contour | |
| out: list[Coord] = [] | |
| for i in range(n): | |
| ax, ay = contour[i - 1] | |
| bx, by = contour[i] | |
| cx, cy = contour[(i + 1) % n] | |
| # Keep vertex only if direction changes (cross-product != 0) | |
| if (bx - ax) * (cy - by) - (by - ay) * (cx - bx) != 0: | |
| out.append((bx, by)) | |
| return out | |
| def path_data(contour: list[Coord]) -> str: | |
| """Encode a simplified contour as compact SVG path commands (M/H/V/Z).""" | |
| if not contour: | |
| return "" | |
| parts = [f"M{contour[0][0]} {contour[0][1]}"] | |
| for i in range(1, len(contour)): | |
| x, y = contour[i] | |
| px, py = contour[i - 1] | |
| if y == py: | |
| parts.append(f"H{x}") | |
| elif x == px: | |
| parts.append(f"V{y}") | |
| else: | |
| parts.append(f"L{x} {y}") | |
| parts.append("Z") | |
| return "".join(parts) | |
| def connected_components(pixel_set: PixelSet) -> list[PixelSet]: | |
| """Split a pixel set into 4-connected components.""" | |
| remaining = set(pixel_set) | |
| components: list[PixelSet] = [] | |
| while remaining: | |
| seed = next(iter(remaining)) | |
| comp: PixelSet = set() | |
| queue = deque([seed]) | |
| remaining.discard(seed) | |
| while queue: | |
| x, y = queue.popleft() | |
| comp.add((x, y)) | |
| for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): | |
| if (nx, ny) in remaining: | |
| remaining.discard((nx, ny)) | |
| queue.append((nx, ny)) | |
| components.append(comp) | |
| return components | |
| def png_to_svg(input_path: Path | str, output_path: Path | str, scale: int = 1) -> tuple[int, int, int, int]: | |
| """Convert a pixel-art PNG to SVG. Returns (path_count, pixel_count, width, height).""" | |
| img = Image.open(input_path).convert("RGBA") | |
| w, h = img.size | |
| pixels = img.load() | |
| color_pixels: dict[tuple[int, int, int, int], PixelSet] = defaultdict(set) | |
| for y in range(h): | |
| for x in range(w): | |
| rgba = pixels[x, y] | |
| if rgba[3] == 0: | |
| continue | |
| color_pixels[rgba].add((x, y)) | |
| lines = [ | |
| '<?xml version="1.0" encoding="UTF-8"?>', | |
| f'<svg xmlns="http://www.w3.org/2000/svg" ' | |
| f'viewBox="0 0 {w} {h}" ' | |
| f'width="{w * scale}" height="{h * scale}" ' | |
| f'shape-rendering="crispEdges">', | |
| ] | |
| total_paths = 0 | |
| total_px = 0 | |
| for rgba, pxset in color_pixels.items(): | |
| r, g, b, a = rgba | |
| hex_col = f"#{r:02x}{g:02x}{b:02x}" | |
| for comp in connected_components(pxset): | |
| total_px += len(comp) | |
| d = "".join(path_data(simplify(c)) for c in trace_contours(comp)) | |
| attrs = f'd="{d}" fill="{hex_col}" fill-rule="evenodd"' | |
| if a < 255: | |
| attrs += f' fill-opacity="{round(a / 255, 3)}"' | |
| lines.append(f" <path {attrs}/>") | |
| total_paths += 1 | |
| lines.append("</svg>") | |
| with open(output_path, "w", encoding="utf-8") as f: | |
| f.write("\n".join(lines)) | |
| return total_paths, total_px, w, h | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="Convert pixel-art PNG to SVG") | |
| parser.add_argument("input", type=Path, help="Input PNG file") | |
| parser.add_argument("-o", "--output", type=Path, help="Output SVG path (default: input with .svg suffix)") | |
| parser.add_argument("-s", "--scale", type=int, default=1, help="Scale factor (default: 1)") | |
| args = parser.parse_args() | |
| output = args.output or args.input.with_suffix(".svg") | |
| total_paths, total_px, w, h = png_to_svg(args.input, output, args.scale) | |
| print(f"Done -- {total_paths} paths ({total_px} px), {w}x{h} -> {output}") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment