@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,409 @@
1
+ #!/usr/bin/env python3
2
+ """Extract generated horizontal row strips into 192x208 sprite 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 json
10
+ import math
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from PIL import Image
15
+
16
+ CELL_WIDTH = 192
17
+ CELL_HEIGHT = 208
18
+ ROW_FRAME_COUNTS = {
19
+ "idle": 6,
20
+ "running-right": 8,
21
+ "running-left": 8,
22
+ "waving": 4,
23
+ "jumping": 5,
24
+ "failed": 8,
25
+ "waiting": 6,
26
+ "running": 6,
27
+ "review": 6,
28
+ }
29
+
30
+
31
+ def parse_states(raw: str) -> list[str]:
32
+ if raw.strip().lower() == "all":
33
+ return list(ROW_FRAME_COUNTS)
34
+ states = [item.strip() for item in raw.split(",") if item.strip()]
35
+ unknown = sorted(set(states) - set(ROW_FRAME_COUNTS))
36
+ if unknown:
37
+ raise SystemExit(f"unknown state(s): {', '.join(unknown)}")
38
+ return states
39
+
40
+
41
+ def parse_hex_color(value: str) -> tuple[int, int, int]:
42
+ if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
43
+ raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
44
+ return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
45
+
46
+
47
+ def load_chroma_key(decoded_dir: Path, override: str | None) -> tuple[int, int, int]:
48
+ if override:
49
+ return parse_hex_color(override)
50
+ request_path = decoded_dir.parent / "pet_request.json"
51
+ if request_path.is_file():
52
+ request = json.loads(request_path.read_text(encoding="utf-8"))
53
+ chroma_key = request.get("chroma_key")
54
+ if isinstance(chroma_key, dict) and isinstance(chroma_key.get("hex"), str):
55
+ return parse_hex_color(chroma_key["hex"])
56
+ return parse_hex_color("#00FF00")
57
+
58
+
59
+ def color_distance(
60
+ red: int,
61
+ green: int,
62
+ blue: int,
63
+ key: tuple[int, int, int],
64
+ ) -> float:
65
+ return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2)
66
+
67
+
68
+ def remove_chroma_background(
69
+ image: Image.Image,
70
+ chroma_key: tuple[int, int, int],
71
+ threshold: float,
72
+ ) -> Image.Image:
73
+ rgba = image.convert("RGBA")
74
+ pixels = rgba.load()
75
+ for y in range(rgba.height):
76
+ for x in range(rgba.width):
77
+ red, green, blue, alpha = pixels[x, y]
78
+ if color_distance(red, green, blue, chroma_key) <= threshold:
79
+ pixels[x, y] = (0, 0, 0, 0)
80
+ return rgba
81
+
82
+
83
+ def fit_to_cell(image: Image.Image) -> Image.Image:
84
+ bbox = image.getbbox()
85
+ target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
86
+ if bbox is None:
87
+ return target
88
+
89
+ sprite = image.crop(bbox)
90
+ max_width = CELL_WIDTH - 10
91
+ max_height = CELL_HEIGHT - 10
92
+ scale = min(max_width / sprite.width, max_height / sprite.height, 1.0)
93
+ if scale != 1.0:
94
+ sprite = sprite.resize(
95
+ (max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
96
+ Image.Resampling.LANCZOS,
97
+ )
98
+ left = (CELL_WIDTH - sprite.width) // 2
99
+ top = (CELL_HEIGHT - sprite.height) // 2
100
+ target.alpha_composite(sprite, (left, top))
101
+ return target
102
+
103
+
104
+ def fit_viewport_to_cell(image: Image.Image) -> Image.Image:
105
+ target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
106
+ if image.getbbox() is None:
107
+ return target
108
+
109
+ viewport = image.copy()
110
+ max_width = CELL_WIDTH - 10
111
+ max_height = CELL_HEIGHT - 10
112
+ scale = min(max_width / viewport.width, max_height / viewport.height, 1.0)
113
+ if scale != 1.0:
114
+ viewport = viewport.resize(
115
+ (max(1, round(viewport.width * scale)), max(1, round(viewport.height * scale))),
116
+ Image.Resampling.LANCZOS,
117
+ )
118
+ left = (CELL_WIDTH - viewport.width) // 2
119
+ top = (CELL_HEIGHT - viewport.height) // 2
120
+ target.alpha_composite(viewport, (left, top))
121
+ return target
122
+
123
+
124
+ def connected_components(image: Image.Image) -> list[dict[str, object]]:
125
+ alpha = image.getchannel("A")
126
+ width, height = image.size
127
+ data = alpha.tobytes()
128
+ visited = bytearray(width * height)
129
+ components: list[dict[str, object]] = []
130
+
131
+ for start, alpha_value in enumerate(data):
132
+ if alpha_value <= 16 or visited[start]:
133
+ continue
134
+
135
+ stack = [start]
136
+ visited[start] = 1
137
+ pixels: list[int] = []
138
+ min_x = width
139
+ min_y = height
140
+ max_x = 0
141
+ max_y = 0
142
+
143
+ while stack:
144
+ current = stack.pop()
145
+ pixels.append(current)
146
+ x = current % width
147
+ y = current // width
148
+ min_x = min(min_x, x)
149
+ min_y = min(min_y, y)
150
+ max_x = max(max_x, x)
151
+ max_y = max(max_y, y)
152
+
153
+ if x > 0:
154
+ neighbor = current - 1
155
+ if not visited[neighbor] and data[neighbor] > 16:
156
+ visited[neighbor] = 1
157
+ stack.append(neighbor)
158
+ if x + 1 < width:
159
+ neighbor = current + 1
160
+ if not visited[neighbor] and data[neighbor] > 16:
161
+ visited[neighbor] = 1
162
+ stack.append(neighbor)
163
+ if y > 0:
164
+ neighbor = current - width
165
+ if not visited[neighbor] and data[neighbor] > 16:
166
+ visited[neighbor] = 1
167
+ stack.append(neighbor)
168
+ if y + 1 < height:
169
+ neighbor = current + width
170
+ if not visited[neighbor] and data[neighbor] > 16:
171
+ visited[neighbor] = 1
172
+ stack.append(neighbor)
173
+
174
+ components.append(
175
+ {
176
+ "pixels": pixels,
177
+ "area": len(pixels),
178
+ "bbox": (min_x, min_y, max_x + 1, max_y + 1),
179
+ "center_x": (min_x + max_x + 1) / 2,
180
+ }
181
+ )
182
+
183
+ return components
184
+
185
+
186
+ def component_group_image(
187
+ source: Image.Image,
188
+ components: list[dict[str, object]],
189
+ padding: int = 4,
190
+ ) -> Image.Image:
191
+ width, height = source.size
192
+ min_x = max(0, min(component["bbox"][0] for component in components) - padding)
193
+ min_y = max(0, min(component["bbox"][1] for component in components) - padding)
194
+ max_x = min(width, max(component["bbox"][2] for component in components) + padding)
195
+ max_y = min(height, max(component["bbox"][3] for component in components) + padding)
196
+
197
+ output = Image.new("RGBA", (max_x - min_x, max_y - min_y), (0, 0, 0, 0))
198
+ source_pixels = source.load()
199
+ output_pixels = output.load()
200
+ for component in components:
201
+ for pixel_index in component["pixels"]:
202
+ x = pixel_index % width
203
+ y = pixel_index // width
204
+ output_pixels[x - min_x, y - min_y] = source_pixels[x, y]
205
+ return output
206
+
207
+
208
+ def component_frame_groups(
209
+ strip: Image.Image,
210
+ frame_count: int,
211
+ ) -> list[list[dict[str, object]]] | None:
212
+ components = connected_components(strip)
213
+ if not components:
214
+ return None
215
+
216
+ largest_area = max(component["area"] for component in components)
217
+ seed_threshold = max(120, largest_area * 0.20)
218
+ seeds = [component for component in components if component["area"] >= seed_threshold]
219
+ if len(seeds) < frame_count:
220
+ seeds = sorted(components, key=lambda component: component["area"], reverse=True)[
221
+ :frame_count
222
+ ]
223
+ if len(seeds) < frame_count:
224
+ return None
225
+
226
+ seeds = sorted(
227
+ sorted(seeds, key=lambda component: component["area"], reverse=True)[:frame_count],
228
+ key=lambda component: component["center_x"],
229
+ )
230
+ seed_ids = {id(seed) for seed in seeds}
231
+ groups: list[list[dict[str, object]]] = [[seed] for seed in seeds]
232
+ noise_threshold = max(12, largest_area * 0.002)
233
+
234
+ for component in components:
235
+ if id(component) in seed_ids or component["area"] < noise_threshold:
236
+ continue
237
+ nearest_index = min(
238
+ range(len(seeds)),
239
+ key=lambda index: abs(seeds[index]["center_x"] - component["center_x"]),
240
+ )
241
+ groups[nearest_index].append(component)
242
+
243
+ return groups
244
+
245
+
246
+ def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image.Image] | None:
247
+ groups = component_frame_groups(strip, frame_count)
248
+ if groups is None:
249
+ return None
250
+ return [fit_to_cell(component_group_image(strip, group)) for group in groups]
251
+
252
+
253
+ def component_bounds(components: list[dict[str, object]]) -> tuple[int, int, int, int]:
254
+ return (
255
+ min(component["bbox"][0] for component in components),
256
+ min(component["bbox"][1] for component in components),
257
+ max(component["bbox"][2] for component in components),
258
+ max(component["bbox"][3] for component in components),
259
+ )
260
+
261
+
262
+ def extract_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]:
263
+ slot_width = strip.width / frame_count
264
+ frames = []
265
+ for index in range(frame_count):
266
+ left = round(index * slot_width)
267
+ right = round((index + 1) * slot_width)
268
+ crop = strip.crop((left, 0, right, strip.height))
269
+ frames.append(fit_to_cell(crop))
270
+ return frames
271
+
272
+
273
+ def extract_stable_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]:
274
+ groups = component_frame_groups(strip, frame_count)
275
+ padding = 4
276
+ if groups is not None:
277
+ bboxes = [component_bounds(group) for group in groups]
278
+ shared_top = max(0, min(bbox[1] for bbox in bboxes) - padding)
279
+ shared_bottom = min(strip.height, max(bbox[3] for bbox in bboxes) + padding)
280
+ viewport_width = max(bbox[2] - bbox[0] for bbox in bboxes) + padding * 2
281
+ viewport_height = max(1, shared_bottom - shared_top)
282
+ frames = []
283
+ for group, bbox in zip(groups, bboxes):
284
+ grouped = component_group_image(strip, group, padding=padding)
285
+ grouped_top = max(0, bbox[1] - padding)
286
+ viewport = Image.new(
287
+ "RGBA",
288
+ (viewport_width, viewport_height),
289
+ (0, 0, 0, 0),
290
+ )
291
+ left = (viewport_width - grouped.width) // 2
292
+ viewport.alpha_composite(grouped, (left, grouped_top - shared_top))
293
+ frames.append(fit_viewport_to_cell(viewport))
294
+ return frames
295
+
296
+ bbox = strip.getbbox()
297
+ if bbox is None:
298
+ return [
299
+ Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) for _ in range(frame_count)
300
+ ]
301
+
302
+ shared_top = max(0, bbox[1] - padding)
303
+ shared_bottom = min(strip.height, bbox[3] + padding)
304
+ slot_width = strip.width / frame_count
305
+ frames = []
306
+ for index in range(frame_count):
307
+ left = round(index * slot_width)
308
+ right = round((index + 1) * slot_width)
309
+ crop = strip.crop((left, shared_top, right, shared_bottom))
310
+ frames.append(fit_viewport_to_cell(crop))
311
+ return frames
312
+
313
+
314
+ def extract_state(
315
+ strip_path: Path,
316
+ state: str,
317
+ output_root: Path,
318
+ chroma_key: tuple[int, int, int],
319
+ threshold: float,
320
+ method: str,
321
+ ) -> dict[str, object]:
322
+ frame_count = ROW_FRAME_COUNTS[state]
323
+ with Image.open(strip_path) as opened:
324
+ strip = remove_chroma_background(opened, chroma_key, threshold)
325
+
326
+ state_dir = output_root / state
327
+ state_dir.mkdir(parents=True, exist_ok=True)
328
+
329
+ frames = None
330
+ used_method = method
331
+ if method in {"auto", "components"}:
332
+ frames = extract_component_frames(strip, frame_count)
333
+ if frames is None and method == "components":
334
+ raise SystemExit(f"could not find {frame_count} sprite components in {strip_path}")
335
+ if frames is not None:
336
+ used_method = "components"
337
+
338
+ if frames is None:
339
+ if method == "stable-slots":
340
+ frames = extract_stable_slot_frames(strip, frame_count)
341
+ used_method = "stable-slots"
342
+ else:
343
+ frames = extract_slot_frames(strip, frame_count)
344
+ used_method = "slots"
345
+
346
+ outputs = []
347
+ for index, frame in enumerate(frames):
348
+ output = state_dir / f"{index:02d}.png"
349
+ frame.save(output)
350
+ outputs.append(str(output))
351
+ return {"state": state, "frames": outputs, "method": used_method}
352
+
353
+
354
+ def main() -> None:
355
+ parser = argparse.ArgumentParser(description=__doc__)
356
+ parser.add_argument("--decoded-dir", required=True)
357
+ parser.add_argument("--output-dir", required=True)
358
+ parser.add_argument("--states", default="all")
359
+ parser.add_argument("--chroma-key", help="Override chroma key as #RRGGBB.")
360
+ parser.add_argument("--key-threshold", type=float, default=96.0)
361
+ parser.add_argument(
362
+ "--method",
363
+ choices=("auto", "components", "slots", "stable-slots"),
364
+ default="auto",
365
+ help="Use connected sprite components when possible, raw equal slots, or row-stable slot viewports.",
366
+ )
367
+ args = parser.parse_args()
368
+
369
+ decoded_dir = Path(args.decoded_dir).expanduser().resolve()
370
+ output_dir = Path(args.output_dir).expanduser().resolve()
371
+ chroma_key = load_chroma_key(decoded_dir, args.chroma_key)
372
+ states = parse_states(args.states)
373
+ manifest = []
374
+ for state in states:
375
+ strip_path = decoded_dir / f"{state}.png"
376
+ if not strip_path.is_file():
377
+ raise SystemExit(f"missing generated strip for {state}: {strip_path}")
378
+ manifest.append(
379
+ extract_state(
380
+ strip_path,
381
+ state,
382
+ output_dir,
383
+ chroma_key,
384
+ args.key_threshold,
385
+ args.method,
386
+ )
387
+ )
388
+
389
+ (output_dir / "frames-manifest.json").write_text(
390
+ json.dumps(
391
+ {
392
+ "ok": True,
393
+ "chroma_key": {
394
+ "hex": f"#{chroma_key[0]:02X}{chroma_key[1]:02X}{chroma_key[2]:02X}",
395
+ "rgb": list(chroma_key),
396
+ "threshold": args.key_threshold,
397
+ },
398
+ "rows": manifest,
399
+ },
400
+ indent=2,
401
+ )
402
+ + "\n",
403
+ encoding="utf-8",
404
+ )
405
+ print(json.dumps({"ok": True, "frames_root": str(output_dir), "states": states}, indent=2))
406
+
407
+
408
+ if __name__ == "__main__":
409
+ main()
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env python3
2
+ """Inspect extracted Codex pet frames before atlas composition."""
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 json
10
+ import math
11
+ from pathlib import Path
12
+ from statistics import median
13
+
14
+ from PIL import Image
15
+
16
+ CELL_WIDTH = 192
17
+ CELL_HEIGHT = 208
18
+ ROW_FRAME_COUNTS = {
19
+ "idle": 6,
20
+ "running-right": 8,
21
+ "running-left": 8,
22
+ "waving": 4,
23
+ "jumping": 5,
24
+ "failed": 8,
25
+ "waiting": 6,
26
+ "running": 6,
27
+ "review": 6,
28
+ }
29
+ IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
30
+
31
+
32
+ def parse_states(raw: str) -> list[str]:
33
+ if raw.strip().lower() == "all":
34
+ return list(ROW_FRAME_COUNTS)
35
+ states = [item.strip() for item in raw.split(",") if item.strip()]
36
+ unknown = sorted(set(states) - set(ROW_FRAME_COUNTS))
37
+ if unknown:
38
+ raise SystemExit(f"unknown state(s): {', '.join(unknown)}")
39
+ return states
40
+
41
+
42
+ def alpha_nonzero_count(image: Image.Image) -> int:
43
+ alpha = image if image.mode == "L" else image.getchannel("A")
44
+ return sum(alpha.histogram()[1:])
45
+
46
+
47
+ def edge_alpha_count(image: Image.Image, margin: int) -> int:
48
+ alpha = image.getchannel("A")
49
+ width, height = alpha.size
50
+ total = 0
51
+ for box in (
52
+ (0, 0, width, margin),
53
+ (0, height - margin, width, height),
54
+ (0, 0, margin, height),
55
+ (width - margin, 0, width, height),
56
+ ):
57
+ total += alpha_nonzero_count(alpha.crop(box))
58
+ return total
59
+
60
+
61
+ def color_distance(left: tuple[int, int, int], right: tuple[int, int, int]) -> float:
62
+ return math.sqrt(sum((left[index] - right[index]) ** 2 for index in range(3)))
63
+
64
+
65
+ def chroma_adjacent_count(
66
+ image: Image.Image,
67
+ chroma_key: tuple[int, int, int] | None,
68
+ threshold: float,
69
+ ) -> int:
70
+ if chroma_key is None:
71
+ return 0
72
+ rgba = image.convert("RGBA")
73
+ data = rgba.tobytes()
74
+ count = 0
75
+ for index in range(0, len(data), 4):
76
+ red, green, blue, alpha = data[index : index + 4]
77
+ if alpha > 16 and color_distance((red, green, blue), chroma_key) <= threshold:
78
+ count += 1
79
+ return count
80
+
81
+
82
+ def frame_files(state_dir: Path) -> list[Path]:
83
+ if not state_dir.is_dir():
84
+ return []
85
+ return sorted(path for path in state_dir.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES)
86
+
87
+
88
+ def load_manifest(frames_root: Path) -> dict[str, dict[str, object]]:
89
+ manifest_path = frames_root / "frames-manifest.json"
90
+ if not manifest_path.is_file():
91
+ return {}
92
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
93
+ rows = manifest.get("rows", [])
94
+ if not isinstance(rows, list):
95
+ return {}
96
+ return {
97
+ row["state"]: row
98
+ for row in rows
99
+ if isinstance(row, dict) and isinstance(row.get("state"), str)
100
+ }
101
+
102
+
103
+ def load_chroma_key(frames_root: Path) -> tuple[int, int, int] | None:
104
+ manifest_path = frames_root / "frames-manifest.json"
105
+ if not manifest_path.is_file():
106
+ return None
107
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
108
+ chroma_key = manifest.get("chroma_key")
109
+ if not isinstance(chroma_key, dict):
110
+ return None
111
+ rgb = chroma_key.get("rgb")
112
+ if (
113
+ not isinstance(rgb, list)
114
+ or len(rgb) != 3
115
+ or not all(isinstance(value, int) for value in rgb)
116
+ ):
117
+ return None
118
+ return (rgb[0], rgb[1], rgb[2])
119
+
120
+
121
+ def inspect_state(
122
+ frames_root: Path,
123
+ state: str,
124
+ expected_count: int,
125
+ manifest_rows: dict[str, dict[str, object]],
126
+ chroma_key: tuple[int, int, int] | None,
127
+ args: argparse.Namespace,
128
+ ) -> dict[str, object]:
129
+ state_dir = frames_root / state
130
+ files = frame_files(state_dir)
131
+ row_errors: list[str] = []
132
+ row_warnings: list[str] = []
133
+ frames: list[dict[str, object]] = []
134
+ areas: list[int] = []
135
+ manifest_row = manifest_rows.get(state, {})
136
+ method = manifest_row.get("method")
137
+
138
+ if len(files) != expected_count:
139
+ row_errors.append(f"expected {expected_count} frame files for {state}, found {len(files)}")
140
+
141
+ if args.require_components:
142
+ if method == "components":
143
+ pass
144
+ elif method == "stable-slots" and args.allow_stable_slots:
145
+ row_warnings.append(
146
+ f"{state} used extraction method stable-slots; confirm motion playback remains stable and unclipped"
147
+ )
148
+ elif method is None:
149
+ row_errors.append(
150
+ f"{state} is missing extraction provenance; regenerate the row before requiring components"
151
+ )
152
+ else:
153
+ row_errors.append(
154
+ f"{state} used extraction method {method}; regenerate the row or inspect slot slicing"
155
+ )
156
+ elif method and method != "components":
157
+ row_warnings.append(
158
+ f"{state} used extraction method {method}; component extraction is preferred"
159
+ )
160
+
161
+ for index, frame_path in enumerate(files[:expected_count]):
162
+ with Image.open(frame_path) as opened:
163
+ frame = opened.convert("RGBA")
164
+ nontransparent = alpha_nonzero_count(frame)
165
+ bbox = frame.getbbox()
166
+ edge_pixels = edge_alpha_count(frame, args.edge_margin)
167
+ chroma_adjacent_pixels = chroma_adjacent_count(
168
+ frame,
169
+ chroma_key,
170
+ args.chroma_adjacent_threshold,
171
+ )
172
+ info = {
173
+ "index": index,
174
+ "file": str(frame_path),
175
+ "width": frame.width,
176
+ "height": frame.height,
177
+ "nontransparent_pixels": nontransparent,
178
+ "bbox": list(bbox) if bbox else None,
179
+ "edge_pixels": edge_pixels,
180
+ "chroma_adjacent_pixels": chroma_adjacent_pixels,
181
+ }
182
+ frames.append(info)
183
+ areas.append(nontransparent)
184
+
185
+ if frame.size != (CELL_WIDTH, CELL_HEIGHT):
186
+ row_errors.append(
187
+ f"{state} frame {index:02d} is {frame.width}x{frame.height}; expected {CELL_WIDTH}x{CELL_HEIGHT}"
188
+ )
189
+ if nontransparent < args.min_used_pixels:
190
+ row_errors.append(
191
+ f"{state} frame {index:02d} is empty or too sparse ({nontransparent} pixels)"
192
+ )
193
+ if edge_pixels > args.edge_pixel_threshold:
194
+ row_warnings.append(
195
+ f"{state} frame {index:02d} has {edge_pixels} non-transparent pixels near the cell edge"
196
+ )
197
+ if chroma_adjacent_pixels > args.chroma_adjacent_pixel_threshold:
198
+ row_errors.append(
199
+ f"{state} frame {index:02d} has {chroma_adjacent_pixels} non-transparent pixels close to the chroma key"
200
+ )
201
+
202
+ if areas:
203
+ row_median = median(areas)
204
+ for index, area in enumerate(areas[:expected_count]):
205
+ if row_median > 0 and area < row_median * args.small_outlier_ratio:
206
+ row_warnings.append(
207
+ f"{state} frame {index:02d} is much smaller than the row median ({area} vs {row_median:.0f})"
208
+ )
209
+ if row_median > 0 and area > row_median * args.large_outlier_ratio:
210
+ row_warnings.append(
211
+ f"{state} frame {index:02d} is much larger than the row median ({area} vs {row_median:.0f})"
212
+ )
213
+
214
+ return {
215
+ "state": state,
216
+ "expected_frames": expected_count,
217
+ "actual_frames": len(files),
218
+ "extraction_method": method,
219
+ "ok": not row_errors,
220
+ "errors": row_errors,
221
+ "warnings": row_warnings,
222
+ "frames": frames,
223
+ }
224
+
225
+
226
+ def main() -> None:
227
+ parser = argparse.ArgumentParser(description=__doc__)
228
+ parser.add_argument("--frames-root", required=True)
229
+ parser.add_argument("--json-out", required=True)
230
+ parser.add_argument("--states", default="all")
231
+ parser.add_argument("--min-used-pixels", type=int, default=400)
232
+ parser.add_argument("--edge-margin", type=int, default=2)
233
+ parser.add_argument("--edge-pixel-threshold", type=int, default=24)
234
+ parser.add_argument("--chroma-adjacent-threshold", type=float, default=150.0)
235
+ parser.add_argument("--chroma-adjacent-pixel-threshold", type=int, default=800)
236
+ parser.add_argument("--small-outlier-ratio", type=float, default=0.35)
237
+ parser.add_argument("--large-outlier-ratio", type=float, default=2.75)
238
+ parser.add_argument(
239
+ "--require-components",
240
+ action="store_true",
241
+ help="Fail rows that fell back to equal-slot extraction.",
242
+ )
243
+ parser.add_argument(
244
+ "--allow-stable-slots",
245
+ action="store_true",
246
+ help="Permit explicitly chosen stable-slots extraction while still warning for visual review.",
247
+ )
248
+ args = parser.parse_args()
249
+
250
+ frames_root = Path(args.frames_root).expanduser().resolve()
251
+ manifest_rows = load_manifest(frames_root)
252
+ chroma_key = load_chroma_key(frames_root)
253
+ states = parse_states(args.states)
254
+ rows = [
255
+ inspect_state(frames_root, state, count, manifest_rows, chroma_key, args)
256
+ for state, count in ROW_FRAME_COUNTS.items()
257
+ if state in states
258
+ ]
259
+ errors = [error for row in rows for error in row["errors"]]
260
+ warnings = [warning for row in rows for warning in row["warnings"]]
261
+ result = {
262
+ "ok": not errors,
263
+ "frames_root": str(frames_root),
264
+ "states": states,
265
+ "errors": errors,
266
+ "warnings": warnings,
267
+ "rows": rows,
268
+ }
269
+
270
+ json_out = Path(args.json_out).expanduser().resolve()
271
+ json_out.parent.mkdir(parents=True, exist_ok=True)
272
+ json_out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
273
+ print(json.dumps({k: v for k, v in result.items() if k != "rows"}, indent=2))
274
+ raise SystemExit(0 if result["ok"] else 1)
275
+
276
+
277
+ if __name__ == "__main__":
278
+ main()