@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +92 -0
  4. package/SECURITY.md +24 -0
  5. package/authoring/PET-GUIDE.md +65 -0
  6. package/authoring/hatch/HATCH-GUIDE.md +136 -0
  7. package/authoring/hatch/LICENSE.txt +201 -0
  8. package/authoring/hatch/NOTICE.md +5 -0
  9. package/authoring/hatch/pyproject.toml +8 -0
  10. package/authoring/hatch/references/animation-rows.md +29 -0
  11. package/authoring/hatch/references/final-assembly.md +67 -0
  12. package/authoring/hatch/references/look-directions.md +35 -0
  13. package/authoring/hatch/references/pet-contract.md +40 -0
  14. package/authoring/hatch/references/qa-rubric.md +34 -0
  15. package/authoring/hatch/references/reviewer-protocol.md +72 -0
  16. package/authoring/hatch/references/standard-assembly.md +21 -0
  17. package/authoring/hatch/references/visual-generation.md +59 -0
  18. package/authoring/hatch/scripts/approve_cardinals.py +110 -0
  19. package/authoring/hatch/scripts/assemble_extended_atlas.py +684 -0
  20. package/authoring/hatch/scripts/combine_direction_blind_verdicts.py +59 -0
  21. package/authoring/hatch/scripts/compose_atlas.py +171 -0
  22. package/authoring/hatch/scripts/compose_cardinal_anchor_strip.py +45 -0
  23. package/authoring/hatch/scripts/derive_running_left_from_running_right.py +164 -0
  24. package/authoring/hatch/scripts/despill_chroma_edges.py +312 -0
  25. package/authoring/hatch/scripts/extract_cardinal_anchors.py +153 -0
  26. package/authoring/hatch/scripts/extract_strip_frames.py +409 -0
  27. package/authoring/hatch/scripts/inspect_frames.py +278 -0
  28. package/authoring/hatch/scripts/make_contact_sheet.py +116 -0
  29. package/authoring/hatch/scripts/make_direction_blind_qa_sheet.py +179 -0
  30. package/authoring/hatch/scripts/make_direction_qa_sheet.py +172 -0
  31. package/authoring/hatch/scripts/measure_direction_continuity.py +201 -0
  32. package/authoring/hatch/scripts/prepare_pet_run.py +1207 -0
  33. package/authoring/hatch/scripts/render_animation_previews.py +80 -0
  34. package/authoring/hatch/scripts/validate_atlas.py +299 -0
  35. package/authoring/hatch/scripts/validate_direction_blind_verdicts.py +100 -0
  36. package/authoring/hatch/uv.lock +99 -0
  37. package/authoring/references/import-existing-pet.md +47 -0
  38. package/authoring/references/pet-format.md +88 -0
  39. package/changelog.ts +327 -0
  40. package/deploy/pi-pet-hyprland.conf +10 -0
  41. package/desktop/build.mjs +194 -0
  42. package/desktop/package-lock.json +887 -0
  43. package/desktop/package.json +16 -0
  44. package/dist/authoring/prepare.mjs +3 -0
  45. package/dist/authoring/rebuild.mjs +4 -0
  46. package/dist/authoring/validate.mjs +2 -0
  47. package/dist/desktop/main.mjs +5 -0
  48. package/dist/desktop/preload.cjs +1 -0
  49. package/dist/web/app.js +1 -0
  50. package/dist/web/catalog.json +1 -0
  51. package/dist/web/index.html +100 -0
  52. package/dist/web/manifest.webmanifest +16 -0
  53. package/dist/web/pet-icon.svg +6 -0
  54. package/dist/web/spritesheet.webp +0 -0
  55. package/dist/web/styles.css +592 -0
  56. package/docs/architecture.md +41 -0
  57. package/docs/pi-pet.png +0 -0
  58. package/docs/research.md +17 -0
  59. package/extensions/desktop-command.ts +249 -0
  60. package/extensions/desktop-config.ts +152 -0
  61. package/extensions/desktop-launcher.ts +380 -0
  62. package/extensions/gippity.ts +24 -0
  63. package/extensions/index.ts +110 -0
  64. package/extensions/remote-app.ts +61 -0
  65. package/package.json +101 -0
  66. package/pets/clawa/PET.md +27 -0
  67. package/pets/clawa/pet.json +7 -0
  68. package/pets/clawa/spritesheet.webp +0 -0
  69. package/pets/clawa/validation.json +808 -0
  70. package/src/desktop/attention.ts +87 -0
  71. package/src/desktop/bridge.ts +31 -0
  72. package/src/desktop/config.ts +145 -0
  73. package/src/desktop/cursor-provider.ts +150 -0
  74. package/src/desktop/main.ts +459 -0
  75. package/src/desktop/preload.ts +19 -0
  76. package/src/pet-image-size.ts +72 -0
  77. package/src/pet-loader.ts +278 -0
  78. package/src/pet-storage.ts +240 -0
  79. 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()