@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,116 @@
1
+ #!/usr/bin/env python3
2
+ """Create a labeled contact sheet from a Codex pet atlas."""
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
+ from pathlib import Path
10
+
11
+ from PIL import Image, ImageDraw, ImageFont
12
+
13
+ COLUMNS = 8
14
+ ROWS = 9
15
+ CELL_WIDTH = 192
16
+ CELL_HEIGHT = 208
17
+ LABEL_HEIGHT = 22
18
+ ROW_NAMES = [
19
+ "idle",
20
+ "running-right",
21
+ "running-left",
22
+ "waving",
23
+ "jumping",
24
+ "failed",
25
+ "waiting",
26
+ "running",
27
+ "review",
28
+ "look 000-157.5",
29
+ "look 180-337.5",
30
+ ]
31
+ USED_COUNTS = [6, 8, 8, 4, 5, 8, 6, 6, 6, 8, 8]
32
+ EXTENDED_NEUTRAL_LOOK_FRAME = (0, 6)
33
+
34
+
35
+ def is_used_cell(rows: int, row: int, column: int) -> bool:
36
+ return column < USED_COUNTS[row] or (
37
+ rows == 11 and (row, column) == EXTENDED_NEUTRAL_LOOK_FRAME
38
+ )
39
+
40
+
41
+ def frame_count_label(rows: int, row: int) -> str:
42
+ if rows == 11 and row == EXTENDED_NEUTRAL_LOOK_FRAME[0]:
43
+ return "6 + neutral"
44
+ return f"{USED_COUNTS[row]} frames"
45
+
46
+
47
+ def checker(size: tuple[int, int], square: int = 16) -> Image.Image:
48
+ image = Image.new("RGB", size, "#ffffff")
49
+ draw = ImageDraw.Draw(image)
50
+ for y in range(0, size[1], square):
51
+ for x in range(0, size[0], square):
52
+ if (x // square + y // square) % 2:
53
+ draw.rectangle((x, y, x + square - 1, y + square - 1), fill="#e8e8e8")
54
+ return image
55
+
56
+
57
+ def main() -> None:
58
+ parser = argparse.ArgumentParser(description=__doc__)
59
+ parser.add_argument("atlas")
60
+ parser.add_argument("--output", required=True)
61
+ parser.add_argument("--scale", type=float, default=0.5)
62
+ args = parser.parse_args()
63
+
64
+ with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
65
+ atlas = opened.convert("RGBA")
66
+ rows = atlas.height // CELL_HEIGHT
67
+ if atlas.width != COLUMNS * CELL_WIDTH or rows not in {9, 11}:
68
+ raise SystemExit(f"atlas must be 1536x1872 or 1536x2288; got {atlas.width}x{atlas.height}")
69
+
70
+ cell_w = max(1, round(CELL_WIDTH * args.scale))
71
+ cell_h = max(1, round(CELL_HEIGHT * args.scale))
72
+ width = COLUMNS * cell_w
73
+ height = rows * (cell_h + LABEL_HEIGHT)
74
+ sheet = Image.new("RGB", (width, height), "#f7f7f7")
75
+ draw = ImageDraw.Draw(sheet)
76
+ font = ImageFont.load_default()
77
+
78
+ for row in range(rows):
79
+ y = row * (cell_h + LABEL_HEIGHT)
80
+ draw.rectangle((0, y, width, y + LABEL_HEIGHT - 1), fill="#111111")
81
+ draw.text((6, y + 5), f"row {row}: {ROW_NAMES[row]}", fill="#ffffff", font=font)
82
+ draw.text(
83
+ (width - 92, y + 5),
84
+ frame_count_label(rows, row),
85
+ fill="#ffffff",
86
+ font=font,
87
+ )
88
+ for column in range(COLUMNS):
89
+ crop = atlas.crop(
90
+ (
91
+ column * CELL_WIDTH,
92
+ row * CELL_HEIGHT,
93
+ (column + 1) * CELL_WIDTH,
94
+ (row + 1) * CELL_HEIGHT,
95
+ )
96
+ )
97
+ crop = crop.resize((cell_w, cell_h), Image.Resampling.LANCZOS)
98
+ bg = checker((cell_w, cell_h))
99
+ bg.paste(crop, (0, 0), crop)
100
+ x = column * cell_w
101
+ sheet.paste(bg, (x, y + LABEL_HEIGHT))
102
+ outline = "#18a058" if is_used_cell(rows, row, column) else "#cc3344"
103
+ draw.rectangle(
104
+ (x, y + LABEL_HEIGHT, x + cell_w - 1, y + LABEL_HEIGHT + cell_h - 1),
105
+ outline=outline,
106
+ )
107
+ draw.text((x + 4, y + LABEL_HEIGHT + 4), str(column), fill="#111111", font=font)
108
+
109
+ output = Path(args.output).expanduser().resolve()
110
+ output.parent.mkdir(parents=True, exist_ok=True)
111
+ sheet.save(output)
112
+ print(f"wrote {output}")
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Create an unlabeled A/B direction-pair sheet and a hidden answer key."""
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 random
12
+ from pathlib import Path
13
+
14
+ from PIL import Image, ImageDraw
15
+
16
+ COLUMNS = 8
17
+ ROWS = 11
18
+ CELL_WIDTH = 192
19
+ CELL_HEIGHT = 208
20
+ LOOK_ROW_INDEX = 9
21
+ LABEL_HEIGHT = 28
22
+ PAIR_COLUMNS = 2
23
+
24
+ LOOK_DIRECTION_LABELS = [
25
+ "000",
26
+ "022.5",
27
+ "045",
28
+ "067.5",
29
+ "090",
30
+ "112.5",
31
+ "135",
32
+ "157.5",
33
+ "180",
34
+ "202.5",
35
+ "225",
36
+ "247.5",
37
+ "270",
38
+ "292.5",
39
+ "315",
40
+ "337.5",
41
+ ]
42
+
43
+ AXIS_PAIRS = [
44
+ ("horizontal", "022.5", "screen-right", "337.5", "screen-left"),
45
+ ("horizontal", "045", "screen-right", "315", "screen-left"),
46
+ ("horizontal", "067.5", "screen-right", "292.5", "screen-left"),
47
+ ("horizontal", "090", "screen-right", "270", "screen-left"),
48
+ ("horizontal", "112.5", "screen-right", "247.5", "screen-left"),
49
+ ("horizontal", "135", "screen-right", "225", "screen-left"),
50
+ ("horizontal", "157.5", "screen-right", "202.5", "screen-left"),
51
+ ("vertical", "000", "up", "180", "down"),
52
+ ("vertical", "022.5", "up", "157.5", "down"),
53
+ ("vertical", "045", "up", "135", "down"),
54
+ ("vertical", "067.5", "up", "112.5", "down"),
55
+ ("vertical", "337.5", "up", "202.5", "down"),
56
+ ("vertical", "315", "up", "225", "down"),
57
+ ("vertical", "292.5", "up", "247.5", "down"),
58
+ ]
59
+
60
+
61
+ def atlas_cell(atlas: Image.Image, label: str) -> Image.Image:
62
+ index = LOOK_DIRECTION_LABELS.index(label)
63
+ row = LOOK_ROW_INDEX + index // COLUMNS
64
+ column = index % COLUMNS
65
+ return atlas.crop(
66
+ (
67
+ column * CELL_WIDTH,
68
+ row * CELL_HEIGHT,
69
+ (column + 1) * CELL_WIDTH,
70
+ (row + 1) * CELL_HEIGHT,
71
+ )
72
+ )
73
+
74
+
75
+ def paste_cell(
76
+ sheet: Image.Image,
77
+ cell: Image.Image,
78
+ *,
79
+ label: str,
80
+ column: int,
81
+ row: int,
82
+ ) -> None:
83
+ x = column * CELL_WIDTH
84
+ y = row * (CELL_HEIGHT + LABEL_HEIGHT)
85
+ sheet.alpha_composite(
86
+ Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (242, 242, 242, 255)),
87
+ (x, y + LABEL_HEIGHT),
88
+ )
89
+ sheet.alpha_composite(cell, (x, y + LABEL_HEIGHT))
90
+ ImageDraw.Draw(sheet).text((x + 6, y + 8), label, fill=(0, 0, 0, 255))
91
+
92
+
93
+ def main() -> None:
94
+ parser = argparse.ArgumentParser(description=__doc__)
95
+ parser.add_argument("atlas")
96
+ parser.add_argument("--output", required=True)
97
+ parser.add_argument("--answer-key", required=True)
98
+ args = parser.parse_args()
99
+
100
+ atlas_path = Path(args.atlas).expanduser().resolve()
101
+ with Image.open(atlas_path) as opened:
102
+ atlas = opened.convert("RGBA")
103
+ if atlas.size != (COLUMNS * CELL_WIDTH, ROWS * CELL_HEIGHT):
104
+ raise SystemExit(f"extended atlas must be 1536x2288; got {atlas.width}x{atlas.height}")
105
+
106
+ seed = int.from_bytes(hashlib.sha256(atlas.tobytes()).digest()[:8], "big")
107
+ rng = random.Random(seed)
108
+ sheet = Image.new(
109
+ "RGBA",
110
+ (
111
+ PAIR_COLUMNS * CELL_WIDTH,
112
+ len(AXIS_PAIRS) * (CELL_HEIGHT + LABEL_HEIGHT),
113
+ ),
114
+ (255, 255, 255, 255),
115
+ )
116
+ answers: list[dict[str, object]] = []
117
+
118
+ axis_indexes = {"horizontal": 0, "vertical": 0}
119
+ for row, (axis, first_label, first_direction, second_label, second_direction) in enumerate(
120
+ AXIS_PAIRS
121
+ ):
122
+ axis_indexes[axis] += 1
123
+ pair_id = f"{axis}-{axis_indexes[axis]}"
124
+ pair = [
125
+ (first_label, first_direction),
126
+ (second_label, second_direction),
127
+ ]
128
+ rng.shuffle(pair)
129
+ cells: list[tuple[str, str, Image.Image]] = []
130
+ for source_label, expected_direction in pair:
131
+ cells.append((source_label, expected_direction, atlas_cell(atlas, source_label)))
132
+
133
+ label = f"{axis.title()} pair {axis_indexes[axis]}"
134
+ paste_cell(sheet, cells[0][2], label=f"{label} A", column=0, row=row)
135
+ paste_cell(sheet, cells[1][2], label=f"{label} B", column=1, row=row)
136
+ answers.append(
137
+ {
138
+ "pair": pair_id,
139
+ "axis": axis,
140
+ "gate": (
141
+ "hard"
142
+ if {first_label, second_label} in ({"000", "180"}, {"090", "270"})
143
+ else "review"
144
+ ),
145
+ "A": {
146
+ "expected_direction": cells[0][1],
147
+ "source_direction": cells[0][0],
148
+ },
149
+ "B": {
150
+ "expected_direction": cells[1][1],
151
+ "source_direction": cells[1][0],
152
+ },
153
+ }
154
+ )
155
+
156
+ output = Path(args.output).expanduser().resolve()
157
+ output.parent.mkdir(parents=True, exist_ok=True)
158
+ sheet.convert("RGB").save(output)
159
+ answer_key = Path(args.answer_key).expanduser().resolve()
160
+ answer_key.parent.mkdir(parents=True, exist_ok=True)
161
+ answer_key.write_text(
162
+ json.dumps(
163
+ {
164
+ "schema_version": 3,
165
+ "atlas_sha256": hashlib.sha256(atlas_path.read_bytes()).hexdigest(),
166
+ "instructions": "Do not provide this answer key to the blind visual QA reviewer.",
167
+ "pairs": answers,
168
+ },
169
+ indent=2,
170
+ )
171
+ + "\n",
172
+ encoding="utf-8",
173
+ )
174
+ print(f"wrote {output}")
175
+ print(f"wrote {answer_key}")
176
+
177
+
178
+ if __name__ == "__main__":
179
+ main()
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ """Create a focused QA sheet for extended pet look directions."""
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
+ from pathlib import Path
10
+
11
+ from PIL import Image, ImageDraw
12
+
13
+ COLUMNS = 8
14
+ ROWS = 11
15
+ CELL_WIDTH = 192
16
+ CELL_HEIGHT = 208
17
+ LOOK_ROW_INDEX = 9
18
+ NEUTRAL_ROW_INDEX = 0
19
+ NEUTRAL_COLUMN_INDEX = 6
20
+ LABEL_HEIGHT = 26
21
+ FOCUS_PADDING = 18
22
+ LOOK_DIRECTION_LABELS = [
23
+ ("000", "up"),
24
+ ("022.5", "up-right"),
25
+ ("045", "up-right"),
26
+ ("067.5", "up-right"),
27
+ ("090", "right"),
28
+ ("112.5", "down-right"),
29
+ ("135", "down-right"),
30
+ ("157.5", "down-right"),
31
+ ("180", "down"),
32
+ ("202.5", "down-left"),
33
+ ("225", "down-left"),
34
+ ("247.5", "down-left"),
35
+ ("270", "left"),
36
+ ("292.5", "up-left"),
37
+ ("315", "up-left"),
38
+ ("337.5", "up-left"),
39
+ ]
40
+
41
+
42
+ def paste_labeled_cell(
43
+ sheet: Image.Image,
44
+ atlas: Image.Image,
45
+ *,
46
+ label: str,
47
+ row_index: int,
48
+ column_index: int,
49
+ output_column: int,
50
+ output_row: int,
51
+ ) -> None:
52
+ draw = ImageDraw.Draw(sheet)
53
+ x = output_column * CELL_WIDTH
54
+ y = output_row * (CELL_HEIGHT + LABEL_HEIGHT)
55
+ cell = atlas.crop(
56
+ (
57
+ column_index * CELL_WIDTH,
58
+ row_index * CELL_HEIGHT,
59
+ (column_index + 1) * CELL_WIDTH,
60
+ (row_index + 1) * CELL_HEIGHT,
61
+ )
62
+ )
63
+ background = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (242, 242, 242, 255))
64
+ sheet.alpha_composite(background, (x, y + LABEL_HEIGHT))
65
+ sheet.alpha_composite(cell, (x, y + LABEL_HEIGHT))
66
+ draw.text((x + 6, y + 7), label, fill=(0, 0, 0, 255))
67
+
68
+
69
+ def focused_head_cell(cell: Image.Image) -> Image.Image:
70
+ bbox = cell.getbbox()
71
+ if bbox is None:
72
+ return cell
73
+
74
+ left, top, right, bottom = bbox
75
+ sprite_height = bottom - top
76
+ focus_bottom = top + max(1, int(sprite_height * 0.52))
77
+ crop_box = (
78
+ max(0, left - FOCUS_PADDING),
79
+ max(0, top - FOCUS_PADDING),
80
+ min(CELL_WIDTH, right + FOCUS_PADDING),
81
+ min(CELL_HEIGHT, focus_bottom + FOCUS_PADDING),
82
+ )
83
+ crop = cell.crop(crop_box)
84
+ focused = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
85
+ crop.thumbnail((CELL_WIDTH, CELL_HEIGHT), Image.Resampling.LANCZOS)
86
+ focused.alpha_composite(
87
+ crop,
88
+ ((CELL_WIDTH - crop.width) // 2, (CELL_HEIGHT - crop.height) // 2),
89
+ )
90
+ return focused
91
+
92
+
93
+ def paste_labeled_focus_cell(
94
+ sheet: Image.Image,
95
+ atlas: Image.Image,
96
+ *,
97
+ label: str,
98
+ row_index: int,
99
+ column_index: int,
100
+ output_column: int,
101
+ output_row: int,
102
+ ) -> None:
103
+ cell = atlas.crop(
104
+ (
105
+ column_index * CELL_WIDTH,
106
+ row_index * CELL_HEIGHT,
107
+ (column_index + 1) * CELL_WIDTH,
108
+ (row_index + 1) * CELL_HEIGHT,
109
+ )
110
+ )
111
+ focused = focused_head_cell(cell)
112
+ background = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (242, 242, 242, 255))
113
+ x = output_column * CELL_WIDTH
114
+ y = output_row * (CELL_HEIGHT + LABEL_HEIGHT)
115
+ sheet.alpha_composite(background, (x, y + LABEL_HEIGHT))
116
+ sheet.alpha_composite(focused, (x, y + LABEL_HEIGHT))
117
+ ImageDraw.Draw(sheet).text((x + 6, y + 7), label, fill=(0, 0, 0, 255))
118
+
119
+
120
+ def main() -> None:
121
+ parser = argparse.ArgumentParser(description=__doc__)
122
+ parser.add_argument("atlas")
123
+ parser.add_argument("--output", required=True)
124
+ args = parser.parse_args()
125
+
126
+ with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
127
+ atlas = opened.convert("RGBA")
128
+ if atlas.size != (COLUMNS * CELL_WIDTH, ROWS * CELL_HEIGHT):
129
+ raise SystemExit(f"extended atlas must be 1536x2288; got {atlas.width}x{atlas.height}")
130
+
131
+ sheet = Image.new(
132
+ "RGBA",
133
+ (COLUMNS * CELL_WIDTH, 5 * (CELL_HEIGHT + LABEL_HEIGHT)),
134
+ (255, 255, 255, 255),
135
+ )
136
+ paste_labeled_cell(
137
+ sheet,
138
+ atlas,
139
+ label="neutral",
140
+ row_index=NEUTRAL_ROW_INDEX,
141
+ column_index=NEUTRAL_COLUMN_INDEX,
142
+ output_column=0,
143
+ output_row=0,
144
+ )
145
+ for index, (label, expected_direction) in enumerate(LOOK_DIRECTION_LABELS):
146
+ paste_labeled_cell(
147
+ sheet,
148
+ atlas,
149
+ label=f"{label} {expected_direction}",
150
+ row_index=LOOK_ROW_INDEX + index // COLUMNS,
151
+ column_index=index % COLUMNS,
152
+ output_column=index % COLUMNS,
153
+ output_row=1 + index // COLUMNS,
154
+ )
155
+ paste_labeled_focus_cell(
156
+ sheet,
157
+ atlas,
158
+ label=f"zoom {label} {expected_direction}",
159
+ row_index=LOOK_ROW_INDEX + index // COLUMNS,
160
+ column_index=index % COLUMNS,
161
+ output_column=index % COLUMNS,
162
+ output_row=3 + index // COLUMNS,
163
+ )
164
+
165
+ output = Path(args.output).expanduser().resolve()
166
+ output.parent.mkdir(parents=True, exist_ok=True)
167
+ sheet.convert("RGB").save(output)
168
+ print(f"wrote {output}")
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ """Measure adjacent-pair continuity for extended pet look directions."""
3
+
4
+ # Modified from the Apache-2.0 Hatch Pet workflow for current Pillow pixel access.
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import statistics
11
+ from pathlib import Path
12
+
13
+ from PIL import Image, ImageChops
14
+
15
+ COLUMNS = 8
16
+ ROWS = 11
17
+ CELL_WIDTH = 192
18
+ CELL_HEIGHT = 208
19
+ LOOK_ROW_INDEX = 9
20
+ LOOK_DIRECTION_LABELS = [
21
+ "000",
22
+ "022.5",
23
+ "045",
24
+ "067.5",
25
+ "090",
26
+ "112.5",
27
+ "135",
28
+ "157.5",
29
+ "180",
30
+ "202.5",
31
+ "225",
32
+ "247.5",
33
+ "270",
34
+ "292.5",
35
+ "315",
36
+ "337.5",
37
+ ]
38
+
39
+
40
+ def nontransparent_pixels(image: Image.Image) -> int:
41
+ return sum(1 for alpha in image.getchannel("A").get_flattened_data() if alpha > 16)
42
+
43
+
44
+ def center_for_bbox(bbox: tuple[int, int, int, int] | None) -> tuple[float, float] | None:
45
+ if bbox is None:
46
+ return None
47
+ left, top, right, bottom = bbox
48
+ return ((left + right) / 2, (top + bottom) / 2)
49
+
50
+
51
+ def cell_from_atlas(atlas: Image.Image, index: int) -> Image.Image:
52
+ row = LOOK_ROW_INDEX + index // COLUMNS
53
+ column = index % COLUMNS
54
+ return atlas.crop(
55
+ (
56
+ column * CELL_WIDTH,
57
+ row * CELL_HEIGHT,
58
+ (column + 1) * CELL_WIDTH,
59
+ (row + 1) * CELL_HEIGHT,
60
+ )
61
+ )
62
+
63
+
64
+ def transparent_hole_rows(image: Image.Image) -> list[dict[str, int]]:
65
+ alpha = list(image.getchannel("A").get_flattened_data())
66
+ rows = [alpha[index * CELL_WIDTH : (index + 1) * CELL_WIDTH] for index in range(CELL_HEIGHT)]
67
+ holes = []
68
+ for y in range(1, CELL_HEIGHT - 1):
69
+ prev_xs = [x for x, value in enumerate(rows[y - 1]) if value > 16]
70
+ next_xs = [x for x, value in enumerate(rows[y + 1]) if value > 16]
71
+ if not prev_xs or not next_xs:
72
+ continue
73
+ left = max(min(prev_xs), min(next_xs))
74
+ right = min(max(prev_xs), max(next_xs))
75
+ if right <= left:
76
+ continue
77
+ span = rows[y][left : right + 1]
78
+ transparent_pixels = sum(1 for value in span if value <= 16)
79
+ if len(span) >= 64 and transparent_pixels > max(32, int(len(span) * 0.25)):
80
+ holes.append(
81
+ {
82
+ "row": y,
83
+ "transparentPixels": transparent_pixels,
84
+ "spanPixels": len(span),
85
+ }
86
+ )
87
+ return holes
88
+
89
+
90
+ def pair_metric(first: Image.Image, second: Image.Image) -> dict[str, float | int | None]:
91
+ first_bbox = first.getbbox()
92
+ second_bbox = second.getbbox()
93
+ first_center = center_for_bbox(first_bbox)
94
+ second_center = center_for_bbox(second_bbox)
95
+ first_pixels = nontransparent_pixels(first)
96
+ second_pixels = nontransparent_pixels(second)
97
+ diff = ImageChops.difference(first, second)
98
+ diff_pixels = nontransparent_pixels(diff)
99
+
100
+ if first_center is None or second_center is None:
101
+ center_delta = None
102
+ else:
103
+ center_delta = (
104
+ (first_center[0] - second_center[0]) ** 2 + (first_center[1] - second_center[1]) ** 2
105
+ ) ** 0.5
106
+
107
+ if first_pixels == 0 or second_pixels == 0:
108
+ area_ratio = None
109
+ else:
110
+ area_ratio = max(first_pixels, second_pixels) / min(first_pixels, second_pixels)
111
+
112
+ return {
113
+ "firstPixels": first_pixels,
114
+ "secondPixels": second_pixels,
115
+ "diffPixels": diff_pixels,
116
+ "centerDelta": center_delta,
117
+ "areaRatio": area_ratio,
118
+ }
119
+
120
+
121
+ def median(values: list[float]) -> float:
122
+ if not values:
123
+ return 0
124
+ return statistics.median(values)
125
+
126
+
127
+ def main() -> None:
128
+ parser = argparse.ArgumentParser(description=__doc__)
129
+ parser.add_argument("atlas")
130
+ parser.add_argument("--json-out", required=True)
131
+ parser.add_argument("--diff-outlier-ratio", type=float, default=1.45)
132
+ parser.add_argument("--center-delta-warning", type=float, default=8)
133
+ parser.add_argument("--area-ratio-warning", type=float, default=1.15)
134
+ args = parser.parse_args()
135
+
136
+ with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
137
+ atlas = opened.convert("RGBA")
138
+ if atlas.size != (COLUMNS * CELL_WIDTH, ROWS * CELL_HEIGHT):
139
+ raise SystemExit(f"extended atlas must be 1536x2288; got {atlas.width}x{atlas.height}")
140
+
141
+ cells = [cell_from_atlas(atlas, index) for index in range(len(LOOK_DIRECTION_LABELS))]
142
+ pairs = []
143
+ for index, label in enumerate(LOOK_DIRECTION_LABELS):
144
+ next_index = (index + 1) % len(LOOK_DIRECTION_LABELS)
145
+ next_label = LOOK_DIRECTION_LABELS[next_index]
146
+ pairs.append(
147
+ {
148
+ "from": label,
149
+ "to": next_label,
150
+ **pair_metric(cells[index], cells[next_index]),
151
+ }
152
+ )
153
+
154
+ diff_values = [float(pair["diffPixels"]) for pair in pairs]
155
+ median_diff = median(diff_values)
156
+ warnings = []
157
+ alpha_holes = []
158
+ for label, cell in zip(LOOK_DIRECTION_LABELS, cells, strict=True):
159
+ holes = transparent_hole_rows(cell)
160
+ if holes:
161
+ alpha_holes.append({"direction": label, "holes": holes})
162
+ preview = ", ".join(f"y={hole['row']}" for hole in holes[:4])
163
+ suffix = "" if len(holes) <= 4 else f", +{len(holes) - 4} more"
164
+ warnings.append(f"{label} has transparent interior hole rows ({preview}{suffix})")
165
+
166
+ for index, pair in enumerate(pairs):
167
+ diff_pixels = float(pair["diffPixels"])
168
+ center_delta = pair["centerDelta"]
169
+ area_ratio = pair["areaRatio"]
170
+ pair_label = f"{pair['from']}->{pair['to']}"
171
+ neighbor_diff = statistics.mean(
172
+ [
173
+ diff_values[(index - 1) % len(diff_values)],
174
+ diff_values[(index + 1) % len(diff_values)],
175
+ ]
176
+ )
177
+ if neighbor_diff and diff_pixels > neighbor_diff * args.diff_outlier_ratio:
178
+ warnings.append(
179
+ f"{pair_label} diff is a local outlier ({diff_pixels:.0f} pixels vs neighbor average {neighbor_diff:.0f})"
180
+ )
181
+ if isinstance(center_delta, float) and center_delta > args.center_delta_warning:
182
+ warnings.append(f"{pair_label} center shift is high ({center_delta:.1f}px)")
183
+ if isinstance(area_ratio, float) and area_ratio > args.area_ratio_warning:
184
+ warnings.append(f"{pair_label} sprite area ratio is high ({area_ratio:.2f})")
185
+
186
+ result = {
187
+ "ok": True,
188
+ "reviewRequired": bool(warnings),
189
+ "medianDiffPixels": median_diff,
190
+ "warnings": warnings,
191
+ "alphaHoles": alpha_holes,
192
+ "pairs": pairs,
193
+ }
194
+ output = Path(args.json_out).expanduser().resolve()
195
+ output.parent.mkdir(parents=True, exist_ok=True)
196
+ output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
197
+ print(json.dumps(result, indent=2))
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main()