Skip to content

Instantly share code, notes, and snippets.

@basperheim
Created June 11, 2026 13:34
Show Gist options
  • Select an option

  • Save basperheim/206a53a8f4be3a5010809c4ac7fe5f39 to your computer and use it in GitHub Desktop.

Select an option

Save basperheim/206a53a8f4be3a5010809c4ac7fe5f39 to your computer and use it in GitHub Desktop.
Create SVG images from map with black borders
from __future__ import annotations
import argparse
from pathlib import Path
import cv2
import numpy as np
import svgwrite
def contour_to_svg_path(contour: np.ndarray) -> str:
points = contour.reshape(-1, 2)
if len(points) < 3:
raise ValueError("A contour must contain at least three points.")
commands = [f"M {int(points[0][0])} {int(points[0][1])}"]
for x, y in points[1:]:
commands.append(f"L {int(x)} {int(y)}")
commands.append("Z")
return " ".join(commands)
def touches_edge(
left: int,
top: int,
width: int,
height: int,
image_width: int,
image_height: int,
margin: int,
) -> bool:
return (
left <= margin
or top <= margin
or left + width >= image_width - margin
or top + height >= image_height - margin
)
def representative_fill_color(
image: np.ndarray,
component_mask: np.ndarray,
) -> str:
pixels = image[component_mask > 0]
if len(pixels) == 0:
return "#cccccc"
median_bgr = np.median(pixels, axis=0).astype(np.uint8)
blue, green, red = (int(value) for value in median_bgr)
return f"#{red:02x}{green:02x}{blue:02x}"
def create_region_preview(
labels: np.ndarray,
accepted_labels: list[int],
output_path: Path,
) -> None:
preview = np.zeros((*labels.shape, 3), dtype=np.uint8)
rng = np.random.default_rng(seed=42)
for label in accepted_labels:
color = rng.integers(50, 256, size=3, dtype=np.uint8)
preview[labels == label] = color
cv2.imwrite(str(output_path), preview)
def extract_regions(
input_path: Path,
output_path: Path,
minimum_area: int,
border_threshold: int,
border_dilation: int,
simplification_ratio: float,
edge_margin: int,
include_edge_regions: bool,
preview_path: Path | None,
) -> None:
image = cv2.imread(str(input_path), cv2.IMREAD_COLOR)
if image is None:
raise FileNotFoundError(f"Could not read image: {input_path}")
image_height, image_width = image.shape[:2]
grayscale = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# Dark pixels are treated as borders/walls.
border_mask = np.where(
grayscale <= border_threshold,
255,
0,
).astype(np.uint8)
if border_dilation > 0:
kernel_size = border_dilation * 2 + 1
kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
border_mask = cv2.dilate(border_mask, kernel, iterations=1)
# Every connected non-border region is a candidate territory.
open_region_mask = cv2.bitwise_not(border_mask)
component_count, labels, stats, _centroids = cv2.connectedComponentsWithStats(
open_region_mask,
connectivity=4,
)
drawing = svgwrite.Drawing(
filename=str(output_path),
size=(image_width, image_height),
viewBox=f"0 0 {image_width} {image_height}",
)
territory_group = drawing.g(id="territories")
drawing.add(territory_group)
accepted_labels: list[int] = []
territory_index = 0
for label in range(1, component_count):
left = int(stats[label, cv2.CC_STAT_LEFT])
top = int(stats[label, cv2.CC_STAT_TOP])
width = int(stats[label, cv2.CC_STAT_WIDTH])
height = int(stats[label, cv2.CC_STAT_HEIGHT])
area = int(stats[label, cv2.CC_STAT_AREA])
if area < minimum_area:
continue
if (
not include_edge_regions
and touches_edge(
left,
top,
width,
height,
image_width,
image_height,
edge_margin,
)
):
# Usually ocean/background.
continue
component_mask = np.where(labels == label, 255, 0).astype(np.uint8)
contours, _hierarchy = cv2.findContours(
component_mask,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE,
)
if not contours:
continue
# A connected component should normally have one outer contour.
contour = max(contours, key=cv2.contourArea)
perimeter = cv2.arcLength(contour, closed=True)
epsilon = simplification_ratio * perimeter
simplified = cv2.approxPolyDP(
contour,
epsilon,
closed=True,
)
if len(simplified) < 3:
continue
territory_index += 1
territory_id = f"territory-{territory_index}"
fill_color = representative_fill_color(image, component_mask)
territory_path = drawing.path(
id=territory_id,
d=contour_to_svg_path(simplified),
fill=fill_color,
stroke="#222222",
stroke_width=1,
class_="territory",
)
# svgwrite rejects custom data-* attributes during constructor validation.
# Add them directly to the element attribute dictionary instead.
# Keep only standard SVG attributes. The path id is enough to identify
# the territory in JavaScript: event.target.id.
territory_group.add(territory_path)
accepted_labels.append(label)
drawing.save()
if preview_path is not None:
create_region_preview(labels, accepted_labels, preview_path)
print(f"Created {output_path}")
print(f"Extracted {territory_index} enclosed regions")
if preview_path is not None:
print(f"Created diagnostic preview {preview_path}")
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Convert dark-bordered enclosed map regions into separate SVG paths."
)
)
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument(
"--minimum-area",
type=int,
default=100,
help="Ignore connected regions smaller than this pixel area.",
)
parser.add_argument(
"--border-threshold",
type=int,
default=70,
help=(
"Grayscale values at or below this are treated as borders. "
"Increase if border lines are dark gray rather than black."
),
)
parser.add_argument(
"--border-dilation",
type=int,
default=1,
help=(
"Expand detected borders by this many pixels to close antialiased gaps. "
"Use 0 to disable."
),
)
parser.add_argument(
"--simplification",
type=float,
default=0.002,
help="Contour simplification ratio. Higher values produce fewer points.",
)
parser.add_argument(
"--edge-margin",
type=int,
default=1,
help="Treat regions within this many pixels of an edge as background.",
)
parser.add_argument(
"--include-edge-regions",
action="store_true",
help="Do not automatically discard regions touching the image edge.",
)
parser.add_argument(
"--preview",
type=Path,
help="Optional PNG showing each detected region in a distinct color.",
)
return parser.parse_args()
def main() -> None:
arguments = parse_arguments()
extract_regions(
input_path=arguments.input,
output_path=arguments.output,
minimum_area=arguments.minimum_area,
border_threshold=arguments.border_threshold,
border_dilation=arguments.border_dilation,
simplification_ratio=arguments.simplification,
edge_margin=arguments.edge_margin,
include_edge_regions=arguments.include_edge_regions,
preview_path=arguments.preview,
)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment