@howaboua/pi-pet 0.1.1
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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/SECURITY.md +24 -0
- package/authoring/PET-GUIDE.md +65 -0
- package/authoring/hatch/HATCH-GUIDE.md +136 -0
- package/authoring/hatch/LICENSE.txt +201 -0
- package/authoring/hatch/NOTICE.md +5 -0
- package/authoring/hatch/pyproject.toml +8 -0
- package/authoring/hatch/references/animation-rows.md +29 -0
- package/authoring/hatch/references/final-assembly.md +67 -0
- package/authoring/hatch/references/look-directions.md +35 -0
- package/authoring/hatch/references/pet-contract.md +40 -0
- package/authoring/hatch/references/qa-rubric.md +34 -0
- package/authoring/hatch/references/reviewer-protocol.md +72 -0
- package/authoring/hatch/references/standard-assembly.md +21 -0
- package/authoring/hatch/references/visual-generation.md +59 -0
- package/authoring/hatch/scripts/approve_cardinals.py +110 -0
- package/authoring/hatch/scripts/assemble_extended_atlas.py +684 -0
- package/authoring/hatch/scripts/combine_direction_blind_verdicts.py +59 -0
- package/authoring/hatch/scripts/compose_atlas.py +171 -0
- package/authoring/hatch/scripts/compose_cardinal_anchor_strip.py +45 -0
- package/authoring/hatch/scripts/derive_running_left_from_running_right.py +164 -0
- package/authoring/hatch/scripts/despill_chroma_edges.py +312 -0
- package/authoring/hatch/scripts/extract_cardinal_anchors.py +153 -0
- package/authoring/hatch/scripts/extract_strip_frames.py +409 -0
- package/authoring/hatch/scripts/inspect_frames.py +278 -0
- package/authoring/hatch/scripts/make_contact_sheet.py +116 -0
- package/authoring/hatch/scripts/make_direction_blind_qa_sheet.py +179 -0
- package/authoring/hatch/scripts/make_direction_qa_sheet.py +172 -0
- package/authoring/hatch/scripts/measure_direction_continuity.py +201 -0
- package/authoring/hatch/scripts/prepare_pet_run.py +1207 -0
- package/authoring/hatch/scripts/render_animation_previews.py +80 -0
- package/authoring/hatch/scripts/validate_atlas.py +299 -0
- package/authoring/hatch/scripts/validate_direction_blind_verdicts.py +100 -0
- package/authoring/hatch/uv.lock +99 -0
- package/authoring/references/import-existing-pet.md +47 -0
- package/authoring/references/pet-format.md +88 -0
- package/changelog.ts +327 -0
- package/deploy/pi-pet-hyprland.conf +10 -0
- package/desktop/build.mjs +194 -0
- package/desktop/package-lock.json +887 -0
- package/desktop/package.json +16 -0
- package/dist/authoring/prepare.mjs +3 -0
- package/dist/authoring/rebuild.mjs +4 -0
- package/dist/authoring/validate.mjs +2 -0
- package/dist/desktop/main.mjs +5 -0
- package/dist/desktop/preload.cjs +1 -0
- package/dist/web/app.js +1 -0
- package/dist/web/catalog.json +1 -0
- package/dist/web/index.html +100 -0
- package/dist/web/manifest.webmanifest +16 -0
- package/dist/web/pet-icon.svg +6 -0
- package/dist/web/spritesheet.webp +0 -0
- package/dist/web/styles.css +592 -0
- package/docs/architecture.md +41 -0
- package/docs/pi-pet.png +0 -0
- package/docs/research.md +17 -0
- package/extensions/desktop-command.ts +249 -0
- package/extensions/desktop-config.ts +152 -0
- package/extensions/desktop-launcher.ts +380 -0
- package/extensions/gippity.ts +24 -0
- package/extensions/index.ts +110 -0
- package/extensions/remote-app.ts +61 -0
- package/package.json +101 -0
- package/pets/clawa/PET.md +27 -0
- package/pets/clawa/pet.json +7 -0
- package/pets/clawa/spritesheet.webp +0 -0
- package/pets/clawa/validation.json +808 -0
- package/src/desktop/attention.ts +87 -0
- package/src/desktop/bridge.ts +31 -0
- package/src/desktop/config.ts +145 -0
- package/src/desktop/cursor-provider.ts +150 -0
- package/src/desktop/main.ts +459 -0
- package/src/desktop/preload.ts +19 -0
- package/src/pet-image-size.ts +72 -0
- package/src/pet-loader.ts +278 -0
- package/src/pet-storage.ts +240 -0
- package/src/protocol/index.ts +81 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Assemble a standard Codex pet atlas plus 16 look-direction frames."""
|
|
3
|
+
|
|
4
|
+
# Modified from the Apache-2.0 Hatch Pet workflow for the Pi Pet authoring package.
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from PIL import Image
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
from extract_strip_frames import component_frame_groups, component_group_image
|
|
20
|
+
|
|
21
|
+
COLUMNS = 8
|
|
22
|
+
STANDARD_ROWS = 9
|
|
23
|
+
EXTENDED_ROWS = 11
|
|
24
|
+
CELL_WIDTH = 192
|
|
25
|
+
CELL_HEIGHT = 208
|
|
26
|
+
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
|
|
27
|
+
STANDARD_ATLAS_HEIGHT = STANDARD_ROWS * CELL_HEIGHT
|
|
28
|
+
EXTENDED_ATLAS_HEIGHT = EXTENDED_ROWS * CELL_HEIGHT
|
|
29
|
+
LOOK_DIRECTION_LABELS = [
|
|
30
|
+
"000",
|
|
31
|
+
"022.5",
|
|
32
|
+
"045",
|
|
33
|
+
"067.5",
|
|
34
|
+
"090",
|
|
35
|
+
"112.5",
|
|
36
|
+
"135",
|
|
37
|
+
"157.5",
|
|
38
|
+
"180",
|
|
39
|
+
"202.5",
|
|
40
|
+
"225",
|
|
41
|
+
"247.5",
|
|
42
|
+
"270",
|
|
43
|
+
"292.5",
|
|
44
|
+
"315",
|
|
45
|
+
"337.5",
|
|
46
|
+
]
|
|
47
|
+
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
|
|
48
|
+
MIN_DETACHED_COMPONENT_PIXELS = 128
|
|
49
|
+
DEFAULT_EDGE_MARGIN = 2
|
|
50
|
+
DEFAULT_EDGE_PIXEL_THRESHOLD = 24
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CellGeometry:
|
|
54
|
+
def __init__(self, height: int, lower_center_x: float, bottom: int) -> None:
|
|
55
|
+
self.height = height
|
|
56
|
+
self.lower_center_x = lower_center_x
|
|
57
|
+
self.bottom = bottom
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def edge_alpha_count(image: Image.Image, margin: int) -> int:
|
|
61
|
+
alpha = image.getchannel("A")
|
|
62
|
+
width, height = alpha.size
|
|
63
|
+
total = 0
|
|
64
|
+
for box in (
|
|
65
|
+
(0, 0, width, margin),
|
|
66
|
+
(0, height - margin, width, height),
|
|
67
|
+
(0, 0, margin, height),
|
|
68
|
+
(width - margin, 0, width, height),
|
|
69
|
+
):
|
|
70
|
+
total += sum(alpha.crop(box).histogram()[1:])
|
|
71
|
+
return total
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
|
75
|
+
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
|
76
|
+
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
|
|
77
|
+
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def color_distance(
|
|
81
|
+
red: int,
|
|
82
|
+
green: int,
|
|
83
|
+
blue: int,
|
|
84
|
+
key: tuple[int, int, int],
|
|
85
|
+
) -> float:
|
|
86
|
+
return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def remove_chroma_background(
|
|
90
|
+
image: Image.Image,
|
|
91
|
+
chroma_key: tuple[int, int, int],
|
|
92
|
+
threshold: float,
|
|
93
|
+
) -> Image.Image:
|
|
94
|
+
rgba = image.convert("RGBA")
|
|
95
|
+
pixels = rgba.load()
|
|
96
|
+
for y in range(rgba.height):
|
|
97
|
+
for x in range(rgba.width):
|
|
98
|
+
red, green, blue, alpha = pixels[x, y]
|
|
99
|
+
if color_distance(red, green, blue, chroma_key) <= threshold:
|
|
100
|
+
pixels[x, y] = (0, 0, 0, 0)
|
|
101
|
+
return rgba
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fit_to_cell(image: Image.Image) -> Image.Image:
|
|
105
|
+
bbox = image.getbbox()
|
|
106
|
+
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
|
107
|
+
if bbox is None:
|
|
108
|
+
return target
|
|
109
|
+
|
|
110
|
+
sprite = image.crop(bbox).convert("RGBA")
|
|
111
|
+
max_width = CELL_WIDTH - 10
|
|
112
|
+
max_height = CELL_HEIGHT - 10
|
|
113
|
+
scale = min(max_width / sprite.width, max_height / sprite.height, 1.0)
|
|
114
|
+
if scale != 1.0:
|
|
115
|
+
sprite = sprite.resize(
|
|
116
|
+
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
|
|
117
|
+
Image.Resampling.LANCZOS,
|
|
118
|
+
)
|
|
119
|
+
left = (CELL_WIDTH - sprite.width) // 2
|
|
120
|
+
top = (CELL_HEIGHT - sprite.height) // 2
|
|
121
|
+
target.alpha_composite(sprite, (left, top))
|
|
122
|
+
return remove_small_detached_components(target)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def remove_small_detached_components(image: Image.Image) -> Image.Image:
|
|
126
|
+
rgba = image.convert("RGBA")
|
|
127
|
+
alpha = rgba.getchannel("A")
|
|
128
|
+
width, height = rgba.size
|
|
129
|
+
visited: set[tuple[int, int]] = set()
|
|
130
|
+
components: list[list[tuple[int, int]]] = []
|
|
131
|
+
|
|
132
|
+
for y in range(height):
|
|
133
|
+
for x in range(width):
|
|
134
|
+
if (x, y) in visited or alpha.getpixel((x, y)) <= 16:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
component: list[tuple[int, int]] = []
|
|
138
|
+
stack = [(x, y)]
|
|
139
|
+
visited.add((x, y))
|
|
140
|
+
while stack:
|
|
141
|
+
current_x, current_y = stack.pop()
|
|
142
|
+
component.append((current_x, current_y))
|
|
143
|
+
for next_x, next_y in (
|
|
144
|
+
(current_x - 1, current_y),
|
|
145
|
+
(current_x + 1, current_y),
|
|
146
|
+
(current_x, current_y - 1),
|
|
147
|
+
(current_x, current_y + 1),
|
|
148
|
+
):
|
|
149
|
+
if (
|
|
150
|
+
next_x < 0
|
|
151
|
+
or next_x >= width
|
|
152
|
+
or next_y < 0
|
|
153
|
+
or next_y >= height
|
|
154
|
+
or (next_x, next_y) in visited
|
|
155
|
+
or alpha.getpixel((next_x, next_y)) <= 16
|
|
156
|
+
):
|
|
157
|
+
continue
|
|
158
|
+
visited.add((next_x, next_y))
|
|
159
|
+
stack.append((next_x, next_y))
|
|
160
|
+
components.append(component)
|
|
161
|
+
|
|
162
|
+
if not components:
|
|
163
|
+
return rgba
|
|
164
|
+
|
|
165
|
+
largest = max(len(component) for component in components)
|
|
166
|
+
pixels = rgba.load()
|
|
167
|
+
for component in components:
|
|
168
|
+
if len(component) == largest or len(component) >= MIN_DETACHED_COMPONENT_PIXELS:
|
|
169
|
+
continue
|
|
170
|
+
for x, y in component:
|
|
171
|
+
pixels[x, y] = (0, 0, 0, 0)
|
|
172
|
+
return rgba
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def opaque_points(image: Image.Image) -> list[tuple[int, int]]:
|
|
176
|
+
alpha = image.getchannel("A")
|
|
177
|
+
width, height = image.size
|
|
178
|
+
return [(x, y) for y in range(height) for x in range(width) if alpha.getpixel((x, y)) > 16]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def lower_band_points(
|
|
182
|
+
points: list[tuple[int, int]], top: int, bottom: int
|
|
183
|
+
) -> list[tuple[int, int]]:
|
|
184
|
+
threshold = top + (bottom - top) * 0.72
|
|
185
|
+
return [(x, y) for x, y in points if y >= threshold] or points
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cell_geometry(cell: Image.Image) -> CellGeometry | None:
|
|
189
|
+
points = opaque_points(cell)
|
|
190
|
+
if not points:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
ys = [y for _, y in points]
|
|
194
|
+
top = min(ys)
|
|
195
|
+
bottom = max(ys) + 1
|
|
196
|
+
lower_points = lower_band_points(points, top, bottom)
|
|
197
|
+
return CellGeometry(
|
|
198
|
+
height=bottom - top,
|
|
199
|
+
lower_center_x=sum(x for x, _ in lower_points) / len(lower_points),
|
|
200
|
+
bottom=bottom,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def normalize_cell_to_geometry(
|
|
205
|
+
cell: Image.Image,
|
|
206
|
+
target: CellGeometry,
|
|
207
|
+
scale: float,
|
|
208
|
+
) -> Image.Image:
|
|
209
|
+
bbox = cell.getbbox()
|
|
210
|
+
if bbox is None:
|
|
211
|
+
return cell
|
|
212
|
+
|
|
213
|
+
source_geometry = cell_geometry(cell)
|
|
214
|
+
if source_geometry is None or source_geometry.height <= 0:
|
|
215
|
+
return cell
|
|
216
|
+
|
|
217
|
+
left, top, right, bottom = bbox
|
|
218
|
+
crop = cell.crop(bbox)
|
|
219
|
+
scaled_width = max(1, round(crop.width * scale))
|
|
220
|
+
scaled_height = max(1, round(crop.height * scale))
|
|
221
|
+
if crop.size != (scaled_width, scaled_height):
|
|
222
|
+
crop = crop.resize((scaled_width, scaled_height), Image.Resampling.LANCZOS)
|
|
223
|
+
|
|
224
|
+
local_lower_center_x = source_geometry.lower_center_x - left
|
|
225
|
+
target_left = round(target.lower_center_x - local_lower_center_x * scale)
|
|
226
|
+
target_top = target.bottom - scaled_height
|
|
227
|
+
|
|
228
|
+
output = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
|
229
|
+
output.alpha_composite(crop, (target_left, target_top))
|
|
230
|
+
return remove_small_detached_components(output)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def normalize_cells_to_reference(
|
|
234
|
+
cells: list[Image.Image],
|
|
235
|
+
reference_cell: Image.Image,
|
|
236
|
+
scale: float | None = None,
|
|
237
|
+
) -> list[Image.Image]:
|
|
238
|
+
target = cell_geometry(reference_cell)
|
|
239
|
+
if target is None:
|
|
240
|
+
raise SystemExit("neutral reference cell must contain visible pixels")
|
|
241
|
+
|
|
242
|
+
if scale is None:
|
|
243
|
+
scale = normalization_scale(cells, target)
|
|
244
|
+
return [normalize_cell_to_geometry(cell, target, scale) for cell in cells]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def normalization_scale(cells: list[Image.Image], target: CellGeometry) -> float:
|
|
248
|
+
|
|
249
|
+
geometries = [cell_geometry(cell) for cell in cells]
|
|
250
|
+
if any(geometry is None for geometry in geometries):
|
|
251
|
+
raise SystemExit("look direction cells must contain visible pixels")
|
|
252
|
+
|
|
253
|
+
visible_geometries = [geometry for geometry in geometries if geometry is not None]
|
|
254
|
+
max_height = max(geometry.height for geometry in visible_geometries)
|
|
255
|
+
max_width = max(cell.getbbox()[2] - cell.getbbox()[0] for cell in cells)
|
|
256
|
+
scale_limits = [
|
|
257
|
+
target.height / max_height,
|
|
258
|
+
(CELL_WIDTH - 10) / max_width,
|
|
259
|
+
(CELL_HEIGHT - 10) / max_height,
|
|
260
|
+
1.0,
|
|
261
|
+
]
|
|
262
|
+
margin = 5
|
|
263
|
+
for cell, geometry in zip(cells, geometries):
|
|
264
|
+
if geometry is None:
|
|
265
|
+
continue
|
|
266
|
+
left, _, right, _ = cell.getbbox()
|
|
267
|
+
local_lower_center_x = geometry.lower_center_x - left
|
|
268
|
+
left_extent = local_lower_center_x
|
|
269
|
+
right_extent = right - left - local_lower_center_x
|
|
270
|
+
if left_extent > 0:
|
|
271
|
+
scale_limits.append((target.lower_center_x - margin) / left_extent)
|
|
272
|
+
if right_extent > 0:
|
|
273
|
+
scale_limits.append((CELL_WIDTH - margin - target.lower_center_x) / right_extent)
|
|
274
|
+
scale_limits.append((target.bottom - margin) / geometry.height)
|
|
275
|
+
return min(scale_limits)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def clear_transparent_rgb(image: Image.Image) -> Image.Image:
|
|
279
|
+
rgba = image.convert("RGBA")
|
|
280
|
+
data = bytearray(rgba.tobytes())
|
|
281
|
+
for index in range(0, len(data), 4):
|
|
282
|
+
if data[index + 3] == 0:
|
|
283
|
+
data[index] = 0
|
|
284
|
+
data[index + 1] = 0
|
|
285
|
+
data[index + 2] = 0
|
|
286
|
+
return Image.frombytes("RGBA", rgba.size, bytes(data))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def load_base_rows(base_atlas_path: Path) -> Image.Image:
|
|
290
|
+
with Image.open(base_atlas_path) as opened:
|
|
291
|
+
base = opened.convert("RGBA")
|
|
292
|
+
if base.width != ATLAS_WIDTH or base.height not in {
|
|
293
|
+
STANDARD_ATLAS_HEIGHT,
|
|
294
|
+
EXTENDED_ATLAS_HEIGHT,
|
|
295
|
+
}:
|
|
296
|
+
raise SystemExit(
|
|
297
|
+
f"base atlas must be 1536x1872 or 1536x2288; got {base.width}x{base.height}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
extended = Image.new("RGBA", (ATLAS_WIDTH, EXTENDED_ATLAS_HEIGHT), (0, 0, 0, 0))
|
|
301
|
+
standard_region = base.crop((0, 0, ATLAS_WIDTH, STANDARD_ATLAS_HEIGHT))
|
|
302
|
+
extended.alpha_composite(standard_region, (0, 0))
|
|
303
|
+
return extended
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def extract_row_strip_cells(
|
|
307
|
+
row_strip_path: Path,
|
|
308
|
+
chroma_key: tuple[int, int, int],
|
|
309
|
+
threshold: float,
|
|
310
|
+
) -> list[Image.Image]:
|
|
311
|
+
with Image.open(row_strip_path) as opened:
|
|
312
|
+
strip = remove_chroma_background(opened, chroma_key, threshold)
|
|
313
|
+
|
|
314
|
+
groups = component_frame_groups(strip, COLUMNS)
|
|
315
|
+
if groups is None:
|
|
316
|
+
raise SystemExit(
|
|
317
|
+
f"could not identify {COLUMNS} ordered pose groups in {row_strip_path}; "
|
|
318
|
+
"resynthesize the complete source row with separated poses"
|
|
319
|
+
)
|
|
320
|
+
return [component_group_image(strip, group) for group in groups]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def validate_normalized_look_cells(
|
|
324
|
+
cells: list[Image.Image],
|
|
325
|
+
direction_offset: int,
|
|
326
|
+
edge_margin: int,
|
|
327
|
+
edge_pixel_threshold: int,
|
|
328
|
+
) -> None:
|
|
329
|
+
for index, cell in enumerate(cells):
|
|
330
|
+
edge_pixels = edge_alpha_count(cell, edge_margin)
|
|
331
|
+
if edge_pixels > edge_pixel_threshold:
|
|
332
|
+
label = LOOK_DIRECTION_LABELS[direction_offset + index]
|
|
333
|
+
raise SystemExit(
|
|
334
|
+
f"look direction {label} has {edge_pixels} non-transparent pixels near "
|
|
335
|
+
"its final cell edge after deterministic registration; resynthesize the "
|
|
336
|
+
"complete source row"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def normalized_label(value: str) -> str:
|
|
341
|
+
return value.lower().replace(".", "").replace("-", "").replace("_", "")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def labels_for_path(path: Path) -> list[str]:
|
|
345
|
+
normalized_stem = normalized_label(path.stem)
|
|
346
|
+
exact_matches = [
|
|
347
|
+
label for label in LOOK_DIRECTION_LABELS if normalized_label(label) == normalized_stem
|
|
348
|
+
]
|
|
349
|
+
if exact_matches:
|
|
350
|
+
return exact_matches
|
|
351
|
+
|
|
352
|
+
numeric_tokens = [float(token) for token in re.findall(r"\d+(?:\.\d+)?", path.stem)]
|
|
353
|
+
return [
|
|
354
|
+
label
|
|
355
|
+
for label in LOOK_DIRECTION_LABELS
|
|
356
|
+
if any(float(label) == token for token in numeric_tokens)
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def image_files(path: Path) -> list[Path]:
|
|
361
|
+
return sorted(p for p in path.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def load_look_cells_from_dir(
|
|
365
|
+
cells_dir: Path,
|
|
366
|
+
chroma_key: tuple[int, int, int],
|
|
367
|
+
threshold: float,
|
|
368
|
+
) -> list[Image.Image]:
|
|
369
|
+
files = image_files(cells_dir)
|
|
370
|
+
cells_by_label: dict[str, Path] = {}
|
|
371
|
+
for path in files:
|
|
372
|
+
for label in labels_for_path(path):
|
|
373
|
+
cells_by_label.setdefault(label, path)
|
|
374
|
+
|
|
375
|
+
if len(cells_by_label) == len(LOOK_DIRECTION_LABELS):
|
|
376
|
+
ordered_files = [cells_by_label[label] for label in LOOK_DIRECTION_LABELS]
|
|
377
|
+
elif len(files) >= len(LOOK_DIRECTION_LABELS):
|
|
378
|
+
ordered_files = files[: len(LOOK_DIRECTION_LABELS)]
|
|
379
|
+
else:
|
|
380
|
+
raise SystemExit(
|
|
381
|
+
f"look cells dir must contain 16 labeled or sortable images; found {len(files)}"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
cells: list[Image.Image] = []
|
|
385
|
+
for path in ordered_files:
|
|
386
|
+
with Image.open(path) as opened:
|
|
387
|
+
cell = remove_chroma_background(opened, chroma_key, threshold)
|
|
388
|
+
cells.append(cell)
|
|
389
|
+
return cells
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def load_look_cells(
|
|
393
|
+
args: argparse.Namespace,
|
|
394
|
+
chroma_key: tuple[int, int, int],
|
|
395
|
+
) -> list[Image.Image]:
|
|
396
|
+
if args.look_cells_dir:
|
|
397
|
+
return load_look_cells_from_dir(
|
|
398
|
+
Path(args.look_cells_dir).expanduser().resolve(),
|
|
399
|
+
chroma_key,
|
|
400
|
+
args.chroma_threshold,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if not args.look_row_9:
|
|
404
|
+
raise SystemExit("provide either --look-cells-dir or --look-row-9")
|
|
405
|
+
|
|
406
|
+
row_9_cells = extract_row_strip_cells(
|
|
407
|
+
Path(args.look_row_9).expanduser().resolve(),
|
|
408
|
+
chroma_key,
|
|
409
|
+
args.chroma_threshold,
|
|
410
|
+
)
|
|
411
|
+
if not args.look_row_10:
|
|
412
|
+
return row_9_cells
|
|
413
|
+
|
|
414
|
+
row_10_cells = extract_row_strip_cells(
|
|
415
|
+
Path(args.look_row_10).expanduser().resolve(),
|
|
416
|
+
chroma_key,
|
|
417
|
+
args.chroma_threshold,
|
|
418
|
+
)
|
|
419
|
+
return [*row_9_cells, *row_10_cells]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def load_registered_row(path: Path) -> list[Image.Image]:
|
|
423
|
+
with Image.open(path) as opened:
|
|
424
|
+
row = opened.convert("RGBA")
|
|
425
|
+
if row.size != (ATLAS_WIDTH, CELL_HEIGHT):
|
|
426
|
+
raise SystemExit(
|
|
427
|
+
f"registered row must be {ATLAS_WIDTH}x{CELL_HEIGHT}; got {row.width}x{row.height}"
|
|
428
|
+
)
|
|
429
|
+
return [
|
|
430
|
+
row.crop(
|
|
431
|
+
(
|
|
432
|
+
column * CELL_WIDTH,
|
|
433
|
+
0,
|
|
434
|
+
(column + 1) * CELL_WIDTH,
|
|
435
|
+
CELL_HEIGHT,
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
for column in range(COLUMNS)
|
|
439
|
+
]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def file_sha256(path: Path) -> str:
|
|
443
|
+
digest = hashlib.sha256()
|
|
444
|
+
with path.open("rb") as file:
|
|
445
|
+
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
|
446
|
+
digest.update(chunk)
|
|
447
|
+
return digest.hexdigest()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def load_registration_scale(path: Path, registered_row_path: Path) -> float:
|
|
451
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
452
|
+
scale = data.get("scale")
|
|
453
|
+
if not isinstance(scale, int | float) or scale <= 0:
|
|
454
|
+
raise SystemExit(f"registration manifest has invalid scale: {scale!r}")
|
|
455
|
+
registered_row = data.get("registeredRow")
|
|
456
|
+
if not isinstance(registered_row, dict):
|
|
457
|
+
raise SystemExit("registration manifest is not bound to a registered row")
|
|
458
|
+
if registered_row.get("sha256") != file_sha256(registered_row_path):
|
|
459
|
+
raise SystemExit("registration manifest does not match the registered row")
|
|
460
|
+
if registered_row.get("width") != ATLAS_WIDTH or registered_row.get("height") != CELL_HEIGHT:
|
|
461
|
+
raise SystemExit("registration manifest has invalid registered-row dimensions")
|
|
462
|
+
return float(scale)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def load_neutral_cell(
|
|
466
|
+
neutral_cell_path: Path | None,
|
|
467
|
+
atlas: Image.Image,
|
|
468
|
+
chroma_key: tuple[int, int, int],
|
|
469
|
+
threshold: float,
|
|
470
|
+
) -> Image.Image:
|
|
471
|
+
if neutral_cell_path is None:
|
|
472
|
+
return base_neutral_cell(atlas)
|
|
473
|
+
|
|
474
|
+
with Image.open(neutral_cell_path) as opened:
|
|
475
|
+
return fit_to_cell(remove_chroma_background(opened, chroma_key, threshold))
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def atlas_cell(atlas: Image.Image, row: int, column: int) -> Image.Image:
|
|
479
|
+
return atlas.crop(
|
|
480
|
+
(
|
|
481
|
+
column * CELL_WIDTH,
|
|
482
|
+
row * CELL_HEIGHT,
|
|
483
|
+
(column + 1) * CELL_WIDTH,
|
|
484
|
+
(row + 1) * CELL_HEIGHT,
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def base_neutral_cell(atlas: Image.Image) -> Image.Image:
|
|
490
|
+
for column in [6, 0, 1, 2, 3, 4, 5, 7]:
|
|
491
|
+
cell = atlas_cell(atlas, 0, column)
|
|
492
|
+
if cell_geometry(cell) is not None:
|
|
493
|
+
return cell
|
|
494
|
+
|
|
495
|
+
raise SystemExit("base atlas must contain a visible idle or neutral frame")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def paste_look_cells(atlas: Image.Image, cells: list[Image.Image]) -> None:
|
|
499
|
+
if len(cells) != len(LOOK_DIRECTION_LABELS):
|
|
500
|
+
raise SystemExit(f"expected 16 look cells, got {len(cells)}")
|
|
501
|
+
for index, cell in enumerate(cells):
|
|
502
|
+
row = STANDARD_ROWS + index // COLUMNS
|
|
503
|
+
column = index % COLUMNS
|
|
504
|
+
atlas.alpha_composite(cell, (column * CELL_WIDTH, row * CELL_HEIGHT))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def paste_neutral_cell(
|
|
508
|
+
atlas: Image.Image,
|
|
509
|
+
neutral: Image.Image,
|
|
510
|
+
) -> None:
|
|
511
|
+
atlas.alpha_composite(neutral, (6 * CELL_WIDTH, 0))
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def write_manifest(path: Path, atlas_path: Path) -> None:
|
|
515
|
+
manifest = {
|
|
516
|
+
"spritesheetPath": atlas_path.name,
|
|
517
|
+
"spritesheetLayout": {
|
|
518
|
+
"columns": COLUMNS,
|
|
519
|
+
"rows": EXTENDED_ROWS,
|
|
520
|
+
"cellWidth": CELL_WIDTH,
|
|
521
|
+
"cellHeight": CELL_HEIGHT,
|
|
522
|
+
"lookDirectionCount": len(LOOK_DIRECTION_LABELS),
|
|
523
|
+
"neutralLookFrame": {"rowIndex": 0, "columnIndex": 6},
|
|
524
|
+
},
|
|
525
|
+
"lookDirections": [
|
|
526
|
+
{
|
|
527
|
+
"degrees": float(label),
|
|
528
|
+
"rowIndex": STANDARD_ROWS + index // COLUMNS,
|
|
529
|
+
"columnIndex": index % COLUMNS,
|
|
530
|
+
}
|
|
531
|
+
for index, label in enumerate(LOOK_DIRECTION_LABELS)
|
|
532
|
+
],
|
|
533
|
+
}
|
|
534
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
535
|
+
path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def save_registered_row(cells: list[Image.Image], path: Path) -> None:
|
|
539
|
+
if len(cells) != COLUMNS:
|
|
540
|
+
raise SystemExit(f"expected {COLUMNS} registered look cells, got {len(cells)}")
|
|
541
|
+
|
|
542
|
+
row = Image.new("RGBA", (ATLAS_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
|
543
|
+
for column, cell in enumerate(cells):
|
|
544
|
+
row.alpha_composite(cell, (column * CELL_WIDTH, 0))
|
|
545
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
546
|
+
clear_transparent_rgb(row).save(path)
|
|
547
|
+
print(f"wrote {path}")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def write_registration_manifest(path: Path, scale: float, registered_row_path: Path) -> None:
|
|
551
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
552
|
+
value = {
|
|
553
|
+
"schemaVersion": 1,
|
|
554
|
+
"scale": scale,
|
|
555
|
+
"registeredRow": {
|
|
556
|
+
"sha256": file_sha256(registered_row_path),
|
|
557
|
+
"width": ATLAS_WIDTH,
|
|
558
|
+
"height": CELL_HEIGHT,
|
|
559
|
+
},
|
|
560
|
+
}
|
|
561
|
+
path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
|
|
562
|
+
print(f"wrote {path}")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def main() -> None:
|
|
566
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
567
|
+
parser.add_argument("--base-atlas", required=True)
|
|
568
|
+
source = parser.add_mutually_exclusive_group(required=True)
|
|
569
|
+
source.add_argument("--look-cells-dir")
|
|
570
|
+
source.add_argument("--look-row-9")
|
|
571
|
+
source.add_argument("--registered-row-9")
|
|
572
|
+
parser.add_argument("--look-row-10")
|
|
573
|
+
parser.add_argument("--row-9-registration")
|
|
574
|
+
parser.add_argument(
|
|
575
|
+
"--neutral-cell",
|
|
576
|
+
help="optional external neutral/default cell; defaults to the neutral cell in the base atlas",
|
|
577
|
+
)
|
|
578
|
+
parser.add_argument("--output")
|
|
579
|
+
parser.add_argument(
|
|
580
|
+
"--registered-row-output",
|
|
581
|
+
help="validate and write registered row 9 before row 10 is generated",
|
|
582
|
+
)
|
|
583
|
+
parser.add_argument("--registration-manifest-output")
|
|
584
|
+
parser.add_argument("--webp-output")
|
|
585
|
+
parser.add_argument("--manifest-output")
|
|
586
|
+
parser.add_argument("--chroma-key", default="#00FF00")
|
|
587
|
+
parser.add_argument("--chroma-threshold", type=float, default=96.0)
|
|
588
|
+
parser.add_argument("--edge-margin", type=int, default=DEFAULT_EDGE_MARGIN)
|
|
589
|
+
parser.add_argument(
|
|
590
|
+
"--edge-pixel-threshold",
|
|
591
|
+
type=int,
|
|
592
|
+
default=DEFAULT_EDGE_PIXEL_THRESHOLD,
|
|
593
|
+
)
|
|
594
|
+
args = parser.parse_args()
|
|
595
|
+
|
|
596
|
+
chroma_key = parse_hex_color(args.chroma_key)
|
|
597
|
+
atlas = load_base_rows(Path(args.base_atlas).expanduser().resolve())
|
|
598
|
+
neutral = load_neutral_cell(
|
|
599
|
+
Path(args.neutral_cell).expanduser().resolve() if args.neutral_cell else None,
|
|
600
|
+
atlas,
|
|
601
|
+
chroma_key,
|
|
602
|
+
args.chroma_threshold,
|
|
603
|
+
)
|
|
604
|
+
if args.registered_row_9:
|
|
605
|
+
if not args.look_row_10 or not args.row_9_registration:
|
|
606
|
+
raise SystemExit("--registered-row-9 requires --look-row-10 and --row-9-registration")
|
|
607
|
+
registered_row_path = Path(args.registered_row_9).expanduser().resolve()
|
|
608
|
+
row_9_cells = load_registered_row(registered_row_path)
|
|
609
|
+
row_10_cells = extract_row_strip_cells(
|
|
610
|
+
Path(args.look_row_10).expanduser().resolve(),
|
|
611
|
+
chroma_key,
|
|
612
|
+
args.chroma_threshold,
|
|
613
|
+
)
|
|
614
|
+
row_10_cells = normalize_cells_to_reference(
|
|
615
|
+
row_10_cells,
|
|
616
|
+
neutral,
|
|
617
|
+
load_registration_scale(
|
|
618
|
+
Path(args.row_9_registration).expanduser().resolve(),
|
|
619
|
+
registered_row_path,
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
validate_normalized_look_cells(
|
|
623
|
+
row_10_cells,
|
|
624
|
+
COLUMNS,
|
|
625
|
+
args.edge_margin,
|
|
626
|
+
args.edge_pixel_threshold,
|
|
627
|
+
)
|
|
628
|
+
cells = [*row_9_cells, *row_10_cells]
|
|
629
|
+
else:
|
|
630
|
+
cells = load_look_cells(args, chroma_key)
|
|
631
|
+
target = cell_geometry(neutral)
|
|
632
|
+
if target is None:
|
|
633
|
+
raise SystemExit("neutral reference cell must contain visible pixels")
|
|
634
|
+
scale = normalization_scale(cells, target)
|
|
635
|
+
cells = normalize_cells_to_reference(cells, neutral, scale)
|
|
636
|
+
validate_normalized_look_cells(
|
|
637
|
+
cells,
|
|
638
|
+
0,
|
|
639
|
+
args.edge_margin,
|
|
640
|
+
args.edge_pixel_threshold,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
if not args.look_cells_dir and not args.look_row_10:
|
|
644
|
+
if not args.registered_row_output:
|
|
645
|
+
raise SystemExit("--look-row-9 without --look-row-10 requires --registered-row-output")
|
|
646
|
+
registered_row_path = Path(args.registered_row_output).expanduser().resolve()
|
|
647
|
+
save_registered_row(cells, registered_row_path)
|
|
648
|
+
if not args.registration_manifest_output:
|
|
649
|
+
raise SystemExit("--registered-row-output requires --registration-manifest-output")
|
|
650
|
+
write_registration_manifest(
|
|
651
|
+
Path(args.registration_manifest_output).expanduser().resolve(),
|
|
652
|
+
scale,
|
|
653
|
+
registered_row_path,
|
|
654
|
+
)
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
if not args.output:
|
|
658
|
+
raise SystemExit("--output is required when assembling the extended atlas")
|
|
659
|
+
|
|
660
|
+
paste_look_cells(atlas, cells)
|
|
661
|
+
paste_neutral_cell(atlas, neutral)
|
|
662
|
+
atlas = clear_transparent_rgb(atlas)
|
|
663
|
+
|
|
664
|
+
output = Path(args.output).expanduser().resolve()
|
|
665
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
666
|
+
atlas.save(output)
|
|
667
|
+
print(f"wrote {output}")
|
|
668
|
+
|
|
669
|
+
if args.webp_output:
|
|
670
|
+
webp_output = Path(args.webp_output).expanduser().resolve()
|
|
671
|
+
webp_output.parent.mkdir(parents=True, exist_ok=True)
|
|
672
|
+
atlas.save(webp_output, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
|
673
|
+
print(f"wrote {webp_output}")
|
|
674
|
+
|
|
675
|
+
if args.manifest_output:
|
|
676
|
+
manifest_output = Path(args.manifest_output).expanduser().resolve()
|
|
677
|
+
write_manifest(
|
|
678
|
+
manifest_output, Path(args.webp_output or args.output).expanduser().resolve()
|
|
679
|
+
)
|
|
680
|
+
print(f"wrote {manifest_output}")
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
if __name__ == "__main__":
|
|
684
|
+
main()
|