Skip to content

Instantly share code, notes, and snippets.

@johannschopplich
Created March 31, 2026 14:50
Show Gist options
  • Select an option

  • Save johannschopplich/dbd4e075e5e8dcf5cb9ce86df1e7a1ab to your computer and use it in GitHub Desktop.

Select an option

Save johannschopplich/dbd4e075e5e8dcf5cb9ce86df1e7a1ab to your computer and use it in GitHub Desktop.
"""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