Created
June 11, 2026 13:34
-
-
Save basperheim/206a53a8f4be3a5010809c4ac7fe5f39 to your computer and use it in GitHub Desktop.
Create SVG images from map with black borders
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
| 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