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.
- spawnpoint/__init__.py +3 -0
- spawnpoint/_data/examples/coin_hopper.json +228 -0
- spawnpoint/_data/examples/neon_arena.json +251 -0
- spawnpoint/_data/examples/pixel_arena.json +242 -0
- spawnpoint/_data/examples/pixel_hopper.json +221 -0
- spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
- spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
- spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
- spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
- spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
- spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
- spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
- spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
- spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
- spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
- spawnpoint/analyze.py +387 -0
- spawnpoint/build.py +146 -0
- spawnpoint/cli.py +290 -0
- spawnpoint/cockpit/__init__.py +0 -0
- spawnpoint/cockpit/server.py +473 -0
- spawnpoint/cockpit/static/app.css +111 -0
- spawnpoint/cockpit/static/app.js +327 -0
- spawnpoint/cockpit/static/index.html +79 -0
- spawnpoint/diagnostics.py +91 -0
- spawnpoint/expr.py +219 -0
- spawnpoint/gif.py +58 -0
- spawnpoint/godot.py +107 -0
- spawnpoint/graph.py +175 -0
- spawnpoint/level.py +157 -0
- spawnpoint/llm_server.py +77 -0
- spawnpoint/manifest.py +222 -0
- spawnpoint/playtest/__init__.py +104 -0
- spawnpoint/playtest/report.py +148 -0
- spawnpoint/playtest/runner.py +51 -0
- spawnpoint/playtest/tuner.py +151 -0
- spawnpoint/project.py +94 -0
- spawnpoint/py.typed +0 -0
- spawnpoint/rules.py +310 -0
- spawnpoint/sfx.py +352 -0
- spawnpoint/sprites/__init__.py +0 -0
- spawnpoint/sprites/ai.py +222 -0
- spawnpoint/sprites/contour.py +147 -0
- spawnpoint/sprites/generate.py +507 -0
- spawnpoint/sprites/importer.py +212 -0
- spawnpoint/sprites/resolve.py +139 -0
- spawnpoint/sprites/spriteset.py +159 -0
- spawnpoint/sprites/tiles.py +102 -0
- spawnpoint/studio/__init__.py +0 -0
- spawnpoint/studio/assemble.py +528 -0
- spawnpoint/studio/brief.py +197 -0
- spawnpoint/studio/creative.py +124 -0
- spawnpoint/studio/dataset.py +136 -0
- spawnpoint/studio/edits.py +206 -0
- spawnpoint/studio/levelgen.py +352 -0
- spawnpoint/studio/llm.py +145 -0
- spawnpoint/studio/orchestrator.py +341 -0
- spawnpoint/studio/prompts.py +122 -0
- spawnpoint/studio/web.py +123 -0
- spawnpoint/studio/webcheck.py +210 -0
- spawnpoint/studio_cli.py +232 -0
- spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
- spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
- spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
- spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
- spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
- tests/conftest.py +49 -0
- tests/test_ai_sprites.py +122 -0
- tests/test_analyze.py +116 -0
- tests/test_cockpit.py +148 -0
- tests/test_e2e.py +33 -0
- tests/test_expr.py +59 -0
- tests/test_gif.py +19 -0
- tests/test_godot_library.py +17 -0
- tests/test_graph.py +44 -0
- tests/test_level.py +64 -0
- tests/test_live_llm.py +38 -0
- tests/test_manifest.py +54 -0
- tests/test_playtest.py +129 -0
- tests/test_rules.py +85 -0
- tests/test_sfx.py +30 -0
- tests/test_sprites.py +261 -0
- tests/test_studio.py +335 -0
- tests/test_web.py +41 -0
spawnpoint/sprites/ai.py
ADDED
|
@@ -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 []
|