#!/usr/bin/env python3 """ Convert a black/white mask image to a Godot .tres polygon resource. """ import argparse import secrets import sys from pathlib import Path import cv2 import numpy as np from shapely.geometry import Polygon from shapely.ops import unary_union ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz" def to_base36(num: int) -> str: """Convert integer to lowercase base36 (like Godot).""" if num == 0: return "0" chars = [] while num: num, rem = divmod(num, 36) chars.append(ALPHABET[rem]) return "".join(reversed(chars)) def generate_godot_uid() -> str: """Generate a random Godot-compatible UID.""" value = secrets.randbits(64) return to_base36(value) def find_largest_contour(contours: list) -> np.ndarray: """Find the largest contour by area.""" if not contours: return None return max(contours, key=cv2.contourArea) def contours_to_polygon(contours: list, mode: str) -> np.ndarray | None: """ Convert contours to a single polygon. Args: contours: List of contours from cv2.findContours mode: "convex_hull", "largest_only", or "multiple" Returns: Single contour as numpy array (or None for "multiple" mode) """ if not contours: return None if mode == "largest_only": return find_largest_contour(contours) if mode == "convex_hull": all_points = np.vstack(contours) return cv2.convexHull(all_points) if mode == "multiple": return None raise ValueError(f"Unknown mode: {mode}") def process_contour( contour: np.ndarray, padding: float, target_points: int ) -> np.ndarray: """ Process a single contour: pad, simplify, and ensure clockwise. """ padded = pad_polygon(contour, padding) simplified = simplify_to_target_points(padded, target_points) return ensure_clockwise(simplified) def simplify_to_target_points(contour: np.ndarray, target_points: int) -> np.ndarray: """ Simplify a contour to approximately target number of points. Uses Douglas-Peucker algorithm with iterative epsilon adjustment. """ if len(contour) <= target_points: return contour contour = contour.squeeze() if len(contour.shape) == 1: return contour.reshape(-1, 1, 2) perimeter = cv2.arcLength(contour, True) epsilon_low = 0.0 epsilon_high = perimeter * 0.1 best_result = contour for _ in range(50): epsilon_mid = (epsilon_low + epsilon_high) / 2 simplified = cv2.approxPolyDP(contour, epsilon_mid, True) if len(simplified) == target_points: return simplified if len(simplified) > target_points: epsilon_low = epsilon_mid else: epsilon_high = epsilon_mid best_result = simplified if abs(len(simplified) - target_points) <= 1: best_result = simplified break return best_result def pad_polygon(points: np.ndarray, padding: float) -> np.ndarray: """ Expand polygon outward by padding pixels using Shapely buffer. """ if padding <= 0: return points points_2d = points.squeeze() if len(points_2d.shape) == 1: return points polygon = Polygon(points_2d) if not polygon.is_valid: polygon = polygon.buffer(0) buffered = polygon.buffer(padding) if buffered.is_empty: return points if buffered.geom_type == "MultiPolygon": buffered = max(buffered.geoms, key=lambda p: p.area) exterior_coords = list(buffered.exterior.coords)[:-1] return np.array(exterior_coords, dtype=np.float32).reshape(-1, 1, 2) def ensure_clockwise(points: np.ndarray) -> np.ndarray: """ Ensure points are in clockwise order (visually). In image coordinates (Y-down), clockwise means positive area. """ points_2d = points.squeeze() if len(points_2d.shape) == 1: return points area = 0.0 n = len(points_2d) for i in range(n): j = (i + 1) % n area += points_2d[i][0] * points_2d[j][1] area -= points_2d[j][0] * points_2d[i][1] if area < 0: return points[::-1] return points def write_preview( original_image: np.ndarray, points: np.ndarray, output_path: Path ) -> None: """ Write a preview image showing the polygon drawn on the mask. """ preview = cv2.cvtColor(original_image, cv2.COLOR_GRAY2BGR) pts = points.squeeze().astype(np.int32).reshape(-1, 1, 2) cv2.polylines(preview, [pts], True, (0, 255, 0), 2) for i, pt in enumerate(pts): cv2.circle(preview, tuple(pt[0]), 4, (0, 0, 255), -1) cv2.putText( preview, str(i), (pt[0][0] + 5, pt[0][1] - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 0), 1, ) cv2.imwrite(str(output_path), preview) def write_tres(points: np.ndarray, output_path: Path, uid: str) -> None: """ Write polygon points to a Godot .tres resource file. """ points_2d = points.squeeze() if len(points_2d.shape) == 1: raise ValueError("Invalid points array") coords = [] for pt in points_2d: coords.extend([round(pt[0], 2), round(pt[1], 2)]) coords_str = ", ".join(str(c) for c in coords) script_uid = "uid://dtemboas3bi8y" content = f"""[gd_resource type="Resource" script_class="PolygonPointsResource" format=3 uid="uid://{uid}"] [ext_resource type="Script" uid="{script_uid}" path="res://PolygonPointsResource.gd" id="1_ppr"] [resource] script = ExtResource("1_ppr") points = PackedVector2Array({coords_str}) metadata/_custom_type_script = "{script_uid}" """ output_path.write_text(content) def main(): parser = argparse.ArgumentParser( description="Convert a black/white mask to a Godot polygon .tres resource" ) parser.add_argument("image", type=Path, help="Input mask image (black/white)") parser.add_argument( "--padding", type=float, default=0, help="Pixels to expand polygon outward (default: 0)", ) parser.add_argument( "--target-points", type=int, default=10, help="Target number of polygon points (default: 10)", ) parser.add_argument( "--output", type=Path, default=None, help="Output .tres file path (default: .tres)", ) parser.add_argument( "--mode", choices=["convex_hull", "largest_only", "multiple"], default="convex_hull", help="How to handle multiple regions: convex_hull, largest_only, or multiple (default: convex_hull)", ) parser.add_argument( "--preview", type=Path, default=None, help="Output preview PNG showing polygon on mask", ) parser.add_argument( "--min-area", type=int, default=150, help="Minimum contour area to include (default: 150)", ) args = parser.parse_args() if not args.image.exists(): print(f"Error: Image not found: {args.image}", file=sys.stderr) sys.exit(1) image = cv2.imread(str(args.image), cv2.IMREAD_GRAYSCALE) if image is None: print(f"Error: Could not load image: {args.image}", file=sys.stderr) sys.exit(1) _, binary = cv2.threshold(image, 127, 255, cv2.THRESH_BINARY) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: print("Error: No contours found in mask", file=sys.stderr) sys.exit(1) contours = [c for c in contours if cv2.contourArea(c) >= args.min_area] if not contours: print("Error: No contours meet minimum area requirement", file=sys.stderr) sys.exit(1) if args.mode == "multiple": contours = sorted(contours, key=cv2.contourArea, reverse=True) output_base = args.output if args.output else args.image.with_suffix("") output_dir = output_base.parent output_stem = output_base.stem all_points = [] for i, contour in enumerate(contours): processed = process_contour(contour, args.padding, args.target_points) all_points.append(processed) output_path = output_dir / f"{output_stem}_{i}.tres" uid = generate_godot_uid() write_tres(processed, output_path, uid) print(f"Created: {output_path}") print(f" Points: {len(processed.squeeze())}") print(f" UID: uid://{uid}") if args.preview: preview = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) colors = [ (0, 255, 0), (255, 0, 0), (0, 0, 255), (255, 255, 0), (255, 0, 255), (0, 255, 255), ] for i, points in enumerate(all_points): color = colors[i % len(colors)] pts = points.squeeze().astype(np.int32).reshape(-1, 1, 2) cv2.polylines(preview, [pts], True, color, 2) for j, pt in enumerate(pts): cv2.circle(preview, tuple(pt[0]), 4, (0, 0, 255), -1) cv2.putText( preview, f"{i}.{j}", (pt[0][0] + 5, pt[0][1] - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 0), 1, ) cv2.imwrite(str(args.preview), preview) print(f"Preview: {args.preview}") print(f"Total polygons: {len(all_points)}") else: polygon = contours_to_polygon(contours, args.mode) if polygon is None: print("Error: No polygon generated", file=sys.stderr) sys.exit(1) padded = pad_polygon(polygon, args.padding) simplified = simplify_to_target_points(padded, args.target_points) clockwise = ensure_clockwise(simplified) output_path = args.output if output_path is None: output_path = args.image.with_suffix(".tres") uid = generate_godot_uid() write_tres(clockwise, output_path, uid) if args.preview: write_preview(image, clockwise, args.preview) print(f"Preview: {args.preview}") print(f"Created: {output_path}") print(f"Points: {len(clockwise.squeeze())}") print(f"UID: uid://{uid}") if __name__ == "__main__": main()