@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,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()
|