spawnpoint-studio 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. spawnpoint/__init__.py +3 -0
  2. spawnpoint/_data/examples/coin_hopper.json +228 -0
  3. spawnpoint/_data/examples/neon_arena.json +251 -0
  4. spawnpoint/_data/examples/pixel_arena.json +242 -0
  5. spawnpoint/_data/examples/pixel_hopper.json +221 -0
  6. spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
  7. spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
  8. spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
  9. spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
  10. spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
  11. spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
  12. spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
  13. spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
  14. spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
  15. spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
  16. spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
  17. spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
  18. spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
  19. spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
  20. spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
  21. spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
  22. spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
  23. spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
  24. spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
  25. spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
  26. spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
  27. spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
  28. spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
  29. spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
  30. spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
  31. spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
  32. spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
  33. spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
  34. spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
  35. spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
  36. spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
  37. spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
  38. spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
  39. spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
  40. spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
  41. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
  42. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
  43. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
  44. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
  45. spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
  46. spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
  47. spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
  48. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
  49. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
  50. spawnpoint/analyze.py +387 -0
  51. spawnpoint/build.py +146 -0
  52. spawnpoint/cli.py +290 -0
  53. spawnpoint/cockpit/__init__.py +0 -0
  54. spawnpoint/cockpit/server.py +473 -0
  55. spawnpoint/cockpit/static/app.css +111 -0
  56. spawnpoint/cockpit/static/app.js +327 -0
  57. spawnpoint/cockpit/static/index.html +79 -0
  58. spawnpoint/diagnostics.py +91 -0
  59. spawnpoint/expr.py +219 -0
  60. spawnpoint/gif.py +58 -0
  61. spawnpoint/godot.py +107 -0
  62. spawnpoint/graph.py +175 -0
  63. spawnpoint/level.py +157 -0
  64. spawnpoint/llm_server.py +77 -0
  65. spawnpoint/manifest.py +222 -0
  66. spawnpoint/playtest/__init__.py +104 -0
  67. spawnpoint/playtest/report.py +148 -0
  68. spawnpoint/playtest/runner.py +51 -0
  69. spawnpoint/playtest/tuner.py +151 -0
  70. spawnpoint/project.py +94 -0
  71. spawnpoint/py.typed +0 -0
  72. spawnpoint/rules.py +310 -0
  73. spawnpoint/sfx.py +352 -0
  74. spawnpoint/sprites/__init__.py +0 -0
  75. spawnpoint/sprites/ai.py +222 -0
  76. spawnpoint/sprites/contour.py +147 -0
  77. spawnpoint/sprites/generate.py +507 -0
  78. spawnpoint/sprites/importer.py +212 -0
  79. spawnpoint/sprites/resolve.py +139 -0
  80. spawnpoint/sprites/spriteset.py +159 -0
  81. spawnpoint/sprites/tiles.py +102 -0
  82. spawnpoint/studio/__init__.py +0 -0
  83. spawnpoint/studio/assemble.py +528 -0
  84. spawnpoint/studio/brief.py +197 -0
  85. spawnpoint/studio/creative.py +124 -0
  86. spawnpoint/studio/dataset.py +136 -0
  87. spawnpoint/studio/edits.py +206 -0
  88. spawnpoint/studio/levelgen.py +352 -0
  89. spawnpoint/studio/llm.py +145 -0
  90. spawnpoint/studio/orchestrator.py +341 -0
  91. spawnpoint/studio/prompts.py +122 -0
  92. spawnpoint/studio/web.py +123 -0
  93. spawnpoint/studio/webcheck.py +210 -0
  94. spawnpoint/studio_cli.py +232 -0
  95. spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
  96. spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
  97. spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
  98. spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
  99. spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
  100. spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
  101. tests/conftest.py +49 -0
  102. tests/test_ai_sprites.py +122 -0
  103. tests/test_analyze.py +116 -0
  104. tests/test_cockpit.py +148 -0
  105. tests/test_e2e.py +33 -0
  106. tests/test_expr.py +59 -0
  107. tests/test_gif.py +19 -0
  108. tests/test_godot_library.py +17 -0
  109. tests/test_graph.py +44 -0
  110. tests/test_level.py +64 -0
  111. tests/test_live_llm.py +38 -0
  112. tests/test_manifest.py +54 -0
  113. tests/test_playtest.py +129 -0
  114. tests/test_rules.py +85 -0
  115. tests/test_sfx.py +30 -0
  116. tests/test_sprites.py +261 -0
  117. tests/test_studio.py +335 -0
  118. tests/test_web.py +41 -0
@@ -0,0 +1,222 @@
1
+ """AI-drawn sprites (plug-in providers) turned into game-ready SpriteSets.
2
+
3
+ The image model only supplies the *look*: one picture on a flat magenta background. Everything else is
4
+ deterministic — background removal, cropping, downscaling to the kind's pixel size, a 16-color
5
+ palette, and animations synthesized from the single frame (diffusion models cannot keep a character
6
+ consistent across frames). Results are cached by prompt, so rebuilds cost nothing.
7
+
8
+ Providers (environment):
9
+ SPAWNPOINT_IMAGE_PROVIDER = a1111 | openai
10
+ SPAWNPOINT_IMAGE_URL = http://localhost:7860 (a1111) | https://api.openai.com/v1 (openai-compatible)
11
+ SPAWNPOINT_IMAGE_API_KEY, SPAWNPOINT_IMAGE_MODEL (openai)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import io
19
+ import json
20
+ import os
21
+ import urllib.error
22
+ import urllib.request
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Protocol
26
+
27
+ import numpy as np
28
+ from PIL import Image
29
+
30
+ from .generate import KINDS
31
+ from .spriteset import SpriteError, SpriteSet, build_sheet, finalize
32
+
33
+ CACHE = Path(os.environ.get("SPAWNPOINT_CACHE", Path.home() / ".cache" / "spawnpoint")) / "ai_sprites"
34
+ KEY_COLOR = (255, 0, 255)
35
+
36
+ KIND_HINT = {
37
+ "hero": "a small game hero character, full body, side view facing right",
38
+ "slime": "a small blob monster, side view", "hopper": "a small hopping creature, side view",
39
+ "flyer": "a small flying creature with wings, side view", "robot": "a small boxy robot enemy, side view",
40
+ "turret": "a small cannon turret", "bug": "a small bug creature seen from above",
41
+ "coin": "a single gold coin", "star": "a single star pickup", "gem": "a single gemstone",
42
+ "heart": "a single heart pickup", "orb": "a single glowing orb", "door": "a tall wooden door",
43
+ "exit": "a glowing exit doorway", "portal": "a swirling magic portal", "spikes": "a row of metal spikes",
44
+ "bullet": "a small glowing projectile",
45
+ }
46
+
47
+
48
+ class ImageProvider(Protocol):
49
+ id: str
50
+
51
+ def generate(self, prompt: str, size: int, seed: int) -> Image.Image: ...
52
+
53
+
54
+ def _post(url: str, payload: dict, headers: dict | None = None, timeout: float = 300) -> dict:
55
+ req = urllib.request.Request(url, json.dumps(payload).encode(), {"Content-Type": "application/json", **(headers or {})})
56
+ try:
57
+ with urllib.request.urlopen(req, timeout=timeout) as r:
58
+ return json.loads(r.read())
59
+ except urllib.error.HTTPError as e:
60
+ raise SpriteError(f"image provider returned {e.code}: {e.read()[:200]!r}") from e
61
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
62
+ raise SpriteError(f"cannot reach the image provider at {url}: {e}") from e
63
+
64
+
65
+ @dataclass
66
+ class A1111Provider:
67
+ """Stable Diffusion WebUI (AUTOMATIC1111 / Forge) txt2img API."""
68
+
69
+ url: str = "http://localhost:7860"
70
+ steps: int = 25
71
+
72
+ @property
73
+ def id(self) -> str:
74
+ return f"a1111:{self.url}"
75
+
76
+ def generate(self, prompt: str, size: int, seed: int) -> Image.Image:
77
+ data = _post(self.url.rstrip("/") + "/sdapi/v1/txt2img", {
78
+ "prompt": prompt, "negative_prompt": "text, watermark, frame, border, multiple characters, shadow, gradient",
79
+ "width": size, "height": size, "steps": self.steps, "seed": seed, "cfg_scale": 7})
80
+ return Image.open(io.BytesIO(base64.b64decode(data["images"][0])))
81
+
82
+
83
+ @dataclass
84
+ class OpenAIImagesProvider:
85
+ """OpenAI-compatible /images/generations (OpenAI or a local server speaking the same API)."""
86
+
87
+ base_url: str = "https://api.openai.com/v1"
88
+ api_key: str | None = None
89
+ model: str = "gpt-image-1"
90
+
91
+ @property
92
+ def id(self) -> str:
93
+ return f"openai:{self.base_url}:{self.model}"
94
+
95
+ def generate(self, prompt: str, size: int, seed: int) -> Image.Image:
96
+ headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
97
+ data = _post(self.base_url.rstrip("/") + "/images/generations",
98
+ {"model": self.model, "prompt": prompt, "size": f"{size}x{size}", "n": 1,
99
+ "response_format": "b64_json"}, headers)
100
+ return Image.open(io.BytesIO(base64.b64decode(data["data"][0]["b64_json"])))
101
+
102
+
103
+ def provider_from_env() -> ImageProvider | None:
104
+ kind = os.environ.get("SPAWNPOINT_IMAGE_PROVIDER", "").lower()
105
+ url = os.environ.get("SPAWNPOINT_IMAGE_URL")
106
+ if kind == "a1111":
107
+ return A1111Provider(url or "http://localhost:7860")
108
+ if kind == "openai":
109
+ return OpenAIImagesProvider(url or "https://api.openai.com/v1", os.environ.get("SPAWNPOINT_IMAGE_API_KEY"),
110
+ os.environ.get("SPAWNPOINT_IMAGE_MODEL", "gpt-image-1"))
111
+ return None
112
+
113
+
114
+ def build_prompt(description: str, kind: str) -> str:
115
+ return (f"pixel art sprite of {description}, {KIND_HINT.get(kind, 'a small game object')}, centered, "
116
+ f"16-bit retro game asset, crisp pixels, bold outline, on a solid flat magenta (#ff00ff) background")
117
+
118
+
119
+ # --- image -> sprite --------------------------------------------------------------------------
120
+
121
+ def remove_background(img: Image.Image, tolerance: int = 60) -> np.ndarray:
122
+ """RGBA array with the flat background made transparent (chroma key + corner flood fill)."""
123
+ rgba = np.asarray(img.convert("RGBA")).copy()
124
+ rgb = rgba[..., :3].astype(int)
125
+ h, w = rgb.shape[:2]
126
+ corners = np.array([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]])
127
+ bg = np.median(corners, axis=0)
128
+ near_bg = np.abs(rgb - bg).sum(axis=2) < tolerance
129
+ near_key = np.abs(rgb - np.array(KEY_COLOR)).sum(axis=2) < tolerance * 1.5
130
+ # Flood fill from the border through background-coloured pixels, so the same colour inside the
131
+ # character (e.g. a white eye on a white background) survives.
132
+ mask = np.zeros((h, w), dtype=bool)
133
+ stack = [(y, x) for y in (0, h - 1) for x in range(w)] + [(y, x) for x in (0, w - 1) for y in range(h)]
134
+ while stack:
135
+ y, x = stack.pop()
136
+ if mask[y, x] or not near_bg[y, x]:
137
+ continue
138
+ mask[y, x] = True
139
+ for ny, nx in ((y + 1, x), (y - 1, x), (y, x + 1), (y, x - 1)):
140
+ if 0 <= ny < h and 0 <= nx < w and not mask[ny, nx]:
141
+ stack.append((ny, nx))
142
+ rgba[mask | near_key, 3] = 0
143
+ return rgba
144
+
145
+
146
+ def to_pixel_frame(rgba: np.ndarray, size: tuple[int, int], colors: int = 16) -> np.ndarray:
147
+ """Crop to the opaque box and fit into `size` (w, h), bottom-centered, with a small palette."""
148
+ ys, xs = np.nonzero(rgba[..., 3] > 0)
149
+ if len(xs) == 0:
150
+ raise SpriteError("the generated image is empty after background removal")
151
+ crop = Image.fromarray(rgba[ys.min():ys.max() + 1, xs.min():xs.max() + 1], "RGBA")
152
+ w, h = size
153
+ pad = 1 # room for squash/stretch animation
154
+ scale = min((w - 2 * pad) / crop.width, (h - pad) / crop.height)
155
+ nw, nh = max(1, round(crop.width * scale)), max(1, round(crop.height * scale))
156
+ small = crop.resize((nw, nh), Image.BOX)
157
+ alpha = (np.asarray(small)[..., 3] > 110)
158
+ rgb = small.convert("RGB").quantize(colors=colors, method=Image.Quantize.MEDIANCUT).convert("RGB")
159
+ out = np.zeros((h, w, 4), dtype=np.uint8)
160
+ x0, y0 = (w - nw) // 2, h - nh
161
+ out[y0:y0 + nh, x0:x0 + nw, :3] = np.asarray(rgb)
162
+ out[y0:y0 + nh, x0:x0 + nw, 3] = np.where(alpha, 255, 0)
163
+ return out
164
+
165
+
166
+ def _resize_anchor(frame: np.ndarray, sx: float, sy: float, dy: int = 0) -> np.ndarray:
167
+ """Scale the opaque part around its bottom-center (squash & stretch), then shift by dy."""
168
+ h, w = frame.shape[:2]
169
+ ys, xs = np.nonzero(frame[..., 3] > 0)
170
+ crop = Image.fromarray(frame[ys.min():ys.max() + 1, xs.min():xs.max() + 1], "RGBA")
171
+ nw, nh = max(1, round(crop.width * sx)), max(1, round(crop.height * sy))
172
+ nw, nh = min(nw, w), min(nh, h)
173
+ scaled = np.asarray(crop.resize((nw, nh), Image.NEAREST))
174
+ out = np.zeros_like(frame)
175
+ x0 = (w - nw) // 2
176
+ y0 = max(0, min(h - nh, h - nh + dy))
177
+ out[y0:y0 + nh, x0:x0 + nw] = scaled
178
+ return out
179
+
180
+
181
+ def _tint(frame: np.ndarray, color=(255, 255, 255), k: float = 0.6) -> np.ndarray:
182
+ out = frame.copy()
183
+ m = out[..., 3] > 0
184
+ out[m, :3] = (out[m, :3] * (1 - k) + np.array(color) * k).astype(np.uint8)
185
+ return out
186
+
187
+
188
+ def animate(base: np.ndarray, kind: str) -> dict[str, list[np.ndarray]]:
189
+ anims = KINDS[kind].anims if kind in KINDS else {"idle": (2, 3, True)}
190
+ frames: dict[str, list[np.ndarray]] = {}
191
+ for name in anims:
192
+ if name == "idle":
193
+ frames[name] = [base, _resize_anchor(base, 1.06, 0.94)]
194
+ elif name == "run":
195
+ frames[name] = [base, _resize_anchor(base, 0.95, 1.04, -1), base, _resize_anchor(base, 1.05, 0.96)]
196
+ elif name == "jump":
197
+ frames[name] = [_resize_anchor(base, 0.88, 1.1, -1)]
198
+ elif name == "fall":
199
+ frames[name] = [_resize_anchor(base, 1.06, 0.97)]
200
+ elif name == "hurt":
201
+ frames[name] = [_tint(base, (255, 255, 255), 0.55)]
202
+ return frames
203
+
204
+
205
+ def ai_sprite(name: str, kind: str, description: str, provider: ImageProvider, seed: int = 0,
206
+ size: int = 512) -> SpriteSet:
207
+ if kind not in KINDS:
208
+ raise SpriteError(f"unknown sprite kind '{kind}'")
209
+ prompt = build_prompt(description, kind)
210
+ key = hashlib.sha256(f"{provider.id}|{prompt}|{size}|{seed}".encode()).hexdigest()[:24]
211
+ cached = CACHE / f"{key}.png"
212
+ if cached.exists():
213
+ img = Image.open(cached)
214
+ else:
215
+ img = provider.generate(prompt, size, seed)
216
+ CACHE.mkdir(parents=True, exist_ok=True)
217
+ img.save(cached)
218
+ spec = KINDS[kind]
219
+ base = to_pixel_frame(remove_background(img), spec.size)
220
+ frames = animate(base, kind)
221
+ sheet, anims = build_sheet(frames, {a: v[1] for a, v in spec.anims.items()}, {a: v[2] for a, v in spec.anims.items()})
222
+ return finalize(name, sheet, spec.size, anims, 2, spec.anchor)
@@ -0,0 +1,147 @@
1
+ """Collision outlines from pixels: alpha mask -> outer contour -> simplified polygon (numpy only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict, deque
6
+
7
+ import numpy as np
8
+
9
+ Point = tuple[float, float]
10
+
11
+
12
+ def alpha_mask(rgba: np.ndarray, threshold: int = 32) -> np.ndarray:
13
+ return rgba[..., 3] >= threshold
14
+
15
+
16
+ def bounds(mask: np.ndarray) -> tuple[int, int, int, int] | None:
17
+ """Opaque bounding box (x, y, w, h), or None for an empty mask."""
18
+ ys, xs = np.nonzero(mask)
19
+ if len(xs) == 0:
20
+ return None
21
+ return int(xs.min()), int(ys.min()), int(xs.max() - xs.min() + 1), int(ys.max() - ys.min() + 1)
22
+
23
+
24
+ def close_gaps(mask: np.ndarray, radius: int = 1) -> np.ndarray:
25
+ """Morphological closing so detached details (eyes, antennae) join the body outline."""
26
+ if radius <= 0:
27
+ return mask
28
+ padded = np.pad(mask, radius + 1)
29
+ grown = padded.copy()
30
+ for dy in range(-radius, radius + 1):
31
+ for dx in range(-radius, radius + 1):
32
+ grown |= np.roll(np.roll(padded, dy, 0), dx, 1)
33
+ shrunk = grown.copy()
34
+ for dy in range(-radius, radius + 1):
35
+ for dx in range(-radius, radius + 1):
36
+ shrunk &= np.roll(np.roll(grown, dy, 0), dx, 1)
37
+ r = radius + 1
38
+ return shrunk[r:-r, r:-r] | mask
39
+
40
+
41
+ def largest_component(mask: np.ndarray) -> np.ndarray:
42
+ h, w = mask.shape
43
+ seen = np.zeros_like(mask, dtype=bool)
44
+ best: list[tuple[int, int]] = []
45
+ for sy, sx in zip(*np.nonzero(mask)):
46
+ if seen[sy, sx]:
47
+ continue
48
+ comp = []
49
+ q = deque([(sy, sx)])
50
+ seen[sy, sx] = True
51
+ while q:
52
+ y, x = q.popleft()
53
+ comp.append((y, x))
54
+ for ny, nx in ((y + 1, x), (y - 1, x), (y, x + 1), (y, x - 1)):
55
+ if 0 <= ny < h and 0 <= nx < w and mask[ny, nx] and not seen[ny, nx]:
56
+ seen[ny, nx] = True
57
+ q.append((ny, nx))
58
+ if len(comp) > len(best):
59
+ best = comp
60
+ out = np.zeros_like(mask, dtype=bool)
61
+ for y, x in best:
62
+ out[y, x] = True
63
+ return out
64
+
65
+
66
+ def outer_contour(mask: np.ndarray) -> list[tuple[int, int]]:
67
+ """Outer boundary of a component along pixel edges (grid corners), clockwise in image space."""
68
+ h, w = mask.shape
69
+ m = np.pad(mask, 1)
70
+ edges: dict[tuple[int, int], list[tuple[int, int]]] = defaultdict(list)
71
+ for y, x in zip(*np.nonzero(mask)):
72
+ py, px = y + 1, x + 1
73
+ if not m[py - 1, px]:
74
+ edges[(x, y)].append((x + 1, y))
75
+ if not m[py, px + 1]:
76
+ edges[(x + 1, y)].append((x + 1, y + 1))
77
+ if not m[py + 1, px]:
78
+ edges[(x + 1, y + 1)].append((x, y + 1))
79
+ if not m[py, px - 1]:
80
+ edges[(x, y + 1)].append((x, y))
81
+ loops: list[list[tuple[int, int]]] = []
82
+ while edges:
83
+ start = next(iter(edges))
84
+ loop = [start]
85
+ cur = start
86
+ while True:
87
+ nxt_list = edges.get(cur)
88
+ if not nxt_list:
89
+ break
90
+ nxt = nxt_list.pop()
91
+ if not nxt_list:
92
+ del edges[cur]
93
+ if nxt == start:
94
+ break
95
+ loop.append(nxt)
96
+ cur = nxt
97
+ if len(loop) >= 3:
98
+ loops.append(loop)
99
+ if not loops:
100
+ return []
101
+ return max(loops, key=lambda lp: abs(polygon_area(lp)))
102
+
103
+
104
+ def polygon_area(pts) -> float:
105
+ a = 0.0
106
+ for (x1, y1), (x2, y2) in zip(pts, pts[1:] + pts[:1]):
107
+ a += x1 * y2 - x2 * y1
108
+ return a / 2
109
+
110
+
111
+ def simplify(points: list[Point], epsilon: float) -> list[Point]:
112
+ """Douglas–Peucker on a closed polygon."""
113
+ if len(points) < 4:
114
+ return list(points)
115
+ # Split at the two mutually farthest-ish points so the closed loop simplifies symmetrically.
116
+ first = points[0]
117
+ far = max(range(len(points)), key=lambda i: (points[i][0] - first[0]) ** 2 + (points[i][1] - first[1]) ** 2)
118
+ a = _dp(points[: far + 1], epsilon)
119
+ b = _dp(points[far:] + [first], epsilon)
120
+ return a[:-1] + b[:-1]
121
+
122
+
123
+ def _dp(pts: list[Point], eps: float) -> list[Point]:
124
+ if len(pts) < 3:
125
+ return list(pts)
126
+ (x1, y1), (x2, y2) = pts[0], pts[-1]
127
+ dx, dy = x2 - x1, y2 - y1
128
+ norm = (dx * dx + dy * dy) ** 0.5 or 1e-9
129
+ best_i, best_d = 0, -1.0
130
+ for i in range(1, len(pts) - 1):
131
+ px, py = pts[i]
132
+ d = abs(dy * px - dx * py + x2 * y1 - y2 * x1) / norm
133
+ if d > best_d:
134
+ best_i, best_d = i, d
135
+ if best_d <= eps:
136
+ return [pts[0], pts[-1]]
137
+ return _dp(pts[: best_i + 1], eps)[:-1] + _dp(pts[best_i:], eps)
138
+
139
+
140
+ def outline(rgba: np.ndarray, epsilon: float = 0.75, close_radius: int = 1) -> list[Point]:
141
+ """Simplified outer outline of the opaque pixels, in pixel coordinates."""
142
+ mask = alpha_mask(rgba)
143
+ if not mask.any():
144
+ return []
145
+ comp = largest_component(close_gaps(mask, close_radius))
146
+ poly = simplify([(float(x), float(y)) for x, y in outer_contour(comp)], epsilon)
147
+ return poly if len(poly) >= 3 else []