@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,312 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Remove chroma-key matte contamination from transparent sprite edges."""
|
|
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 re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from PIL import Image, ImageFilter
|
|
14
|
+
|
|
15
|
+
CELL_WIDTH = 192
|
|
16
|
+
CELL_HEIGHT = 208
|
|
17
|
+
ALGORITHM = "edge-local-chroma-spill-suppression"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
|
21
|
+
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
|
22
|
+
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
|
|
23
|
+
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def srgb_to_linear(value: float) -> float:
|
|
27
|
+
if value <= 0.04045:
|
|
28
|
+
return value / 12.92
|
|
29
|
+
return ((value + 0.055) / 1.055) ** 2.4
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def linear_to_srgb(value: float) -> float:
|
|
33
|
+
if value <= 0.0031308:
|
|
34
|
+
return value * 12.92
|
|
35
|
+
return 1.055 * value ** (1 / 2.4) - 0.055
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def edge_band(alpha: Image.Image, radius: int) -> list[bool]:
|
|
39
|
+
visible = [value > 0 for value in alpha.get_flattened_data()]
|
|
40
|
+
transparent = Image.new("L", alpha.size)
|
|
41
|
+
transparent.putdata([0 if value else 255 for value in visible])
|
|
42
|
+
expanded = transparent.filter(ImageFilter.MaxFilter(radius * 2 + 1))
|
|
43
|
+
return [
|
|
44
|
+
is_visible and nearby > 0
|
|
45
|
+
for is_visible, nearby in zip(visible, expanded.get_flattened_data())
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def atlas_edge_band(alpha: Image.Image, radius: int) -> list[bool]:
|
|
50
|
+
width, height = alpha.size
|
|
51
|
+
boundary = edge_band(alpha, radius)
|
|
52
|
+
if width % CELL_WIDTH or height % CELL_HEIGHT:
|
|
53
|
+
return boundary
|
|
54
|
+
|
|
55
|
+
for top in range(0, height, CELL_HEIGHT):
|
|
56
|
+
for left in range(0, width, CELL_WIDTH):
|
|
57
|
+
cell = alpha.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
|
|
58
|
+
for index, is_boundary in enumerate(edge_band(cell, radius)):
|
|
59
|
+
if is_boundary:
|
|
60
|
+
x = left + index % CELL_WIDTH
|
|
61
|
+
y = top + index // CELL_WIDTH
|
|
62
|
+
boundary[y * width + x] = True
|
|
63
|
+
return boundary
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def chroma_similarity(
|
|
67
|
+
color: tuple[float, float, float],
|
|
68
|
+
key: tuple[float, float, float],
|
|
69
|
+
) -> float:
|
|
70
|
+
color_mean = sum(color) / 3
|
|
71
|
+
key_mean = sum(key) / 3
|
|
72
|
+
color_chroma = tuple(channel - color_mean for channel in color)
|
|
73
|
+
key_chroma = tuple(channel - key_mean for channel in key)
|
|
74
|
+
denominator = sum(channel * channel for channel in color_chroma) * sum(
|
|
75
|
+
channel * channel for channel in key_chroma
|
|
76
|
+
)
|
|
77
|
+
if denominator <= 1e-12:
|
|
78
|
+
return -1
|
|
79
|
+
return (
|
|
80
|
+
sum(
|
|
81
|
+
color_channel * key_channel
|
|
82
|
+
for color_channel, key_channel in zip(color_chroma, key_chroma)
|
|
83
|
+
)
|
|
84
|
+
/ denominator**0.5
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def chroma_saturation(color: tuple[float, float, float]) -> float:
|
|
89
|
+
maximum = max(color)
|
|
90
|
+
if maximum <= 0:
|
|
91
|
+
return 0
|
|
92
|
+
return (maximum - min(color)) / maximum
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def suppress_boundary_spill(
|
|
96
|
+
pixels: list[tuple[int, int, int, int]],
|
|
97
|
+
*,
|
|
98
|
+
size: tuple[int, int],
|
|
99
|
+
boundary: list[bool],
|
|
100
|
+
key_linear: tuple[float, float, float],
|
|
101
|
+
strength: float,
|
|
102
|
+
edge_radius: int,
|
|
103
|
+
spill_tolerance: float,
|
|
104
|
+
minimum_saturation: float,
|
|
105
|
+
) -> tuple[list[tuple[int, int, int, int]], list[bool]]:
|
|
106
|
+
width, height = size
|
|
107
|
+
colors_linear = [
|
|
108
|
+
tuple(srgb_to_linear(channel / 255) for channel in pixel[:3]) for pixel in pixels
|
|
109
|
+
]
|
|
110
|
+
similarity_threshold = 1 - min(spill_tolerance, 1)
|
|
111
|
+
pending = [
|
|
112
|
+
pixel[3] > 0
|
|
113
|
+
and is_boundary
|
|
114
|
+
and (
|
|
115
|
+
pixel[3] < 250
|
|
116
|
+
or (
|
|
117
|
+
chroma_saturation(color) >= minimum_saturation
|
|
118
|
+
and chroma_similarity(color, key_linear) >= similarity_threshold
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
for pixel, color, is_boundary in zip(pixels, colors_linear, boundary)
|
|
122
|
+
]
|
|
123
|
+
filled = [pixel[3] > 0 and not is_pending for pixel, is_pending in zip(pixels, pending)]
|
|
124
|
+
output = pixels.copy()
|
|
125
|
+
suppressed = [False] * len(pixels)
|
|
126
|
+
cell_width = CELL_WIDTH if width % CELL_WIDTH == 0 else width
|
|
127
|
+
cell_height = CELL_HEIGHT if height % CELL_HEIGHT == 0 else height
|
|
128
|
+
|
|
129
|
+
for _ in range(edge_radius * 2 + 1):
|
|
130
|
+
updates: list[tuple[int, tuple[float, float, float]]] = []
|
|
131
|
+
for index, is_pending in enumerate(pending):
|
|
132
|
+
if not is_pending:
|
|
133
|
+
continue
|
|
134
|
+
x = index % width
|
|
135
|
+
y = index // width
|
|
136
|
+
cell_left = x // cell_width * cell_width
|
|
137
|
+
cell_top = y // cell_height * cell_height
|
|
138
|
+
references = []
|
|
139
|
+
for neighbor_y in range(
|
|
140
|
+
max(cell_top, y - 1),
|
|
141
|
+
min(cell_top + cell_height, y + 2),
|
|
142
|
+
):
|
|
143
|
+
for neighbor_x in range(
|
|
144
|
+
max(cell_left, x - 1),
|
|
145
|
+
min(cell_left + cell_width, x + 2),
|
|
146
|
+
):
|
|
147
|
+
neighbor = neighbor_y * width + neighbor_x
|
|
148
|
+
if neighbor != index and filled[neighbor]:
|
|
149
|
+
references.append(colors_linear[neighbor])
|
|
150
|
+
if not references:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
reference = tuple(
|
|
154
|
+
sum(color[channel] for color in references) / len(references)
|
|
155
|
+
for channel in range(3)
|
|
156
|
+
)
|
|
157
|
+
observed = colors_linear[index]
|
|
158
|
+
cleaned = tuple(
|
|
159
|
+
channel + (reference_channel - channel) * strength
|
|
160
|
+
for channel, reference_channel in zip(observed, reference)
|
|
161
|
+
)
|
|
162
|
+
updates.append((index, cleaned))
|
|
163
|
+
|
|
164
|
+
if not updates:
|
|
165
|
+
break
|
|
166
|
+
for index, cleaned in updates:
|
|
167
|
+
colors_linear[index] = cleaned
|
|
168
|
+
filled[index] = True
|
|
169
|
+
pending[index] = False
|
|
170
|
+
output[index] = (
|
|
171
|
+
*(round(linear_to_srgb(min(1, max(0, channel))) * 255) for channel in cleaned),
|
|
172
|
+
pixels[index][3],
|
|
173
|
+
)
|
|
174
|
+
suppressed[index] = output[index] != pixels[index]
|
|
175
|
+
|
|
176
|
+
for index, is_pending in enumerate(pending):
|
|
177
|
+
if not is_pending:
|
|
178
|
+
continue
|
|
179
|
+
observed = colors_linear[index]
|
|
180
|
+
luminance = sum(observed) / 3
|
|
181
|
+
cleaned = tuple(channel + (luminance - channel) * strength for channel in observed)
|
|
182
|
+
output[index] = (
|
|
183
|
+
*(round(linear_to_srgb(min(1, max(0, channel))) * 255) for channel in cleaned),
|
|
184
|
+
pixels[index][3],
|
|
185
|
+
)
|
|
186
|
+
suppressed[index] = output[index] != pixels[index]
|
|
187
|
+
|
|
188
|
+
return output, suppressed
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def decontaminate_image(
|
|
192
|
+
image: Image.Image,
|
|
193
|
+
*,
|
|
194
|
+
chroma_key: tuple[int, int, int],
|
|
195
|
+
strength: float = 1,
|
|
196
|
+
edge_radius: int = 5,
|
|
197
|
+
spill_tolerance: float = 0.15,
|
|
198
|
+
minimum_saturation: float = 0.1,
|
|
199
|
+
) -> tuple[Image.Image, dict[str, object]]:
|
|
200
|
+
if not 0 <= strength <= 1:
|
|
201
|
+
raise ValueError("strength must be between 0 and 1")
|
|
202
|
+
if edge_radius < 1:
|
|
203
|
+
raise ValueError("edge_radius must be at least 1")
|
|
204
|
+
if spill_tolerance < 0:
|
|
205
|
+
raise ValueError("spill_tolerance must not be negative")
|
|
206
|
+
if minimum_saturation < 0:
|
|
207
|
+
raise ValueError("minimum_saturation must not be negative")
|
|
208
|
+
|
|
209
|
+
rgba = image.convert("RGBA")
|
|
210
|
+
width, _ = rgba.size
|
|
211
|
+
source = list(rgba.get_flattened_data())
|
|
212
|
+
boundary = atlas_edge_band(rgba.getchannel("A"), edge_radius)
|
|
213
|
+
key_linear = tuple(srgb_to_linear(channel / 255) for channel in chroma_key)
|
|
214
|
+
output_pixels, suppressed = suppress_boundary_spill(
|
|
215
|
+
source,
|
|
216
|
+
size=rgba.size,
|
|
217
|
+
boundary=boundary,
|
|
218
|
+
key_linear=key_linear,
|
|
219
|
+
strength=strength,
|
|
220
|
+
edge_radius=edge_radius,
|
|
221
|
+
spill_tolerance=spill_tolerance,
|
|
222
|
+
minimum_saturation=minimum_saturation,
|
|
223
|
+
)
|
|
224
|
+
output_pixels = [
|
|
225
|
+
(0, 0, 0, 0) if pixel[3] == 0 else output_pixel
|
|
226
|
+
for pixel, output_pixel in zip(source, output_pixels)
|
|
227
|
+
]
|
|
228
|
+
decontaminated_pixels = sum(
|
|
229
|
+
is_suppressed and pixel[3] < 255 for pixel, is_suppressed in zip(source, suppressed)
|
|
230
|
+
)
|
|
231
|
+
spill_suppressed_pixels = sum(suppressed)
|
|
232
|
+
|
|
233
|
+
changed_by_cell: dict[str, int] = {}
|
|
234
|
+
for index, (source_pixel, output_pixel) in enumerate(zip(source, output_pixels)):
|
|
235
|
+
if output_pixel != source_pixel:
|
|
236
|
+
x = index % width
|
|
237
|
+
y = index // width
|
|
238
|
+
cell = f"r{y // CELL_HEIGHT}c{x // CELL_WIDTH}"
|
|
239
|
+
changed_by_cell[cell] = changed_by_cell.get(cell, 0) + 1
|
|
240
|
+
|
|
241
|
+
output = Image.new("RGBA", rgba.size)
|
|
242
|
+
output.putdata(output_pixels)
|
|
243
|
+
return output, {
|
|
244
|
+
"algorithm": ALGORITHM,
|
|
245
|
+
"strength": strength,
|
|
246
|
+
"edge_radius": edge_radius,
|
|
247
|
+
"spill_tolerance": spill_tolerance,
|
|
248
|
+
"minimum_saturation": minimum_saturation,
|
|
249
|
+
"changed_pixels": sum(changed_by_cell.values()),
|
|
250
|
+
"decontaminated_pixels": decontaminated_pixels,
|
|
251
|
+
"spill_suppressed_pixels": spill_suppressed_pixels,
|
|
252
|
+
"rejected_pixels": 0,
|
|
253
|
+
"changed_by_cell": dict(
|
|
254
|
+
sorted(changed_by_cell.items(), key=lambda item: item[1], reverse=True)
|
|
255
|
+
),
|
|
256
|
+
"alpha_preserved": True,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def save_image(image: Image.Image, path: Path) -> None:
|
|
261
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
if path.suffix.lower() == ".webp":
|
|
263
|
+
image.save(path, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
|
264
|
+
else:
|
|
265
|
+
image.save(path)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def main() -> None:
|
|
269
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
270
|
+
parser.add_argument("input")
|
|
271
|
+
parser.add_argument("--output", required=True)
|
|
272
|
+
parser.add_argument("--webp-output")
|
|
273
|
+
parser.add_argument("--json-out")
|
|
274
|
+
parser.add_argument("--chroma-key", required=True)
|
|
275
|
+
parser.add_argument("--strength", type=float, default=1)
|
|
276
|
+
parser.add_argument("--edge-radius", type=int, default=5)
|
|
277
|
+
parser.add_argument("--spill-tolerance", type=float, default=0.15)
|
|
278
|
+
parser.add_argument("--minimum-saturation", type=float, default=0.1)
|
|
279
|
+
args = parser.parse_args()
|
|
280
|
+
|
|
281
|
+
input_path = Path(args.input).expanduser().resolve()
|
|
282
|
+
with Image.open(input_path) as opened:
|
|
283
|
+
cleaned, report = decontaminate_image(
|
|
284
|
+
opened,
|
|
285
|
+
chroma_key=parse_hex_color(args.chroma_key),
|
|
286
|
+
strength=args.strength,
|
|
287
|
+
edge_radius=args.edge_radius,
|
|
288
|
+
spill_tolerance=args.spill_tolerance,
|
|
289
|
+
minimum_saturation=args.minimum_saturation,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
output_path = Path(args.output).expanduser().resolve()
|
|
293
|
+
save_image(cleaned, output_path)
|
|
294
|
+
if args.webp_output:
|
|
295
|
+
save_image(cleaned, Path(args.webp_output).expanduser().resolve())
|
|
296
|
+
|
|
297
|
+
result = {
|
|
298
|
+
"ok": True,
|
|
299
|
+
"input": str(input_path),
|
|
300
|
+
"output": str(output_path),
|
|
301
|
+
"chroma_key": args.chroma_key.upper(),
|
|
302
|
+
**report,
|
|
303
|
+
}
|
|
304
|
+
if args.json_out:
|
|
305
|
+
json_path = Path(args.json_out).expanduser().resolve()
|
|
306
|
+
json_path.parent.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
json_path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
|
308
|
+
print(json.dumps(result, indent=2))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
main()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract and validate a four-pose cardinal anchor strip."""
|
|
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
|
+
CARDINALS = ["000", "090", "180", "270"]
|
|
17
|
+
CELL_WIDTH = 192
|
|
18
|
+
CELL_HEIGHT = 208
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
|
22
|
+
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
|
23
|
+
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
|
|
24
|
+
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def color_distance(
|
|
28
|
+
red: int,
|
|
29
|
+
green: int,
|
|
30
|
+
blue: int,
|
|
31
|
+
key: tuple[int, int, int],
|
|
32
|
+
) -> float:
|
|
33
|
+
return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def remove_chroma_background(
|
|
37
|
+
image: Image.Image,
|
|
38
|
+
chroma_key: tuple[int, int, int],
|
|
39
|
+
threshold: float,
|
|
40
|
+
) -> Image.Image:
|
|
41
|
+
rgba = image.convert("RGBA")
|
|
42
|
+
pixels = rgba.load()
|
|
43
|
+
for y in range(rgba.height):
|
|
44
|
+
for x in range(rgba.width):
|
|
45
|
+
red, green, blue, _alpha = pixels[x, y]
|
|
46
|
+
if color_distance(red, green, blue, chroma_key) <= threshold:
|
|
47
|
+
pixels[x, y] = (0, 0, 0, 0)
|
|
48
|
+
return rgba
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def alpha_count(image: Image.Image) -> int:
|
|
52
|
+
alpha = image if image.mode == "L" else image.getchannel("A")
|
|
53
|
+
return sum(alpha.histogram()[1:])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def edge_alpha_count(image: Image.Image, margin: int) -> int:
|
|
57
|
+
alpha = image.getchannel("A")
|
|
58
|
+
width, height = alpha.size
|
|
59
|
+
return sum(
|
|
60
|
+
alpha_count(alpha.crop(box))
|
|
61
|
+
for box in (
|
|
62
|
+
(0, 0, width, margin),
|
|
63
|
+
(0, height - margin, width, height),
|
|
64
|
+
(0, 0, margin, height),
|
|
65
|
+
(width - margin, 0, width, height),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def fit_to_cell(image: Image.Image) -> Image.Image:
|
|
71
|
+
bbox = image.getbbox()
|
|
72
|
+
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
|
73
|
+
if bbox is None:
|
|
74
|
+
return target
|
|
75
|
+
sprite = image.crop(bbox)
|
|
76
|
+
scale = min((CELL_WIDTH - 10) / sprite.width, (CELL_HEIGHT - 10) / sprite.height, 1.0)
|
|
77
|
+
if scale != 1.0:
|
|
78
|
+
sprite = sprite.resize(
|
|
79
|
+
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
|
|
80
|
+
Image.Resampling.LANCZOS,
|
|
81
|
+
)
|
|
82
|
+
target.alpha_composite(
|
|
83
|
+
sprite,
|
|
84
|
+
((CELL_WIDTH - sprite.width) // 2, (CELL_HEIGHT - sprite.height) // 2),
|
|
85
|
+
)
|
|
86
|
+
return target
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main() -> None:
|
|
90
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
91
|
+
parser.add_argument("--strip", required=True)
|
|
92
|
+
parser.add_argument("--output-dir", required=True)
|
|
93
|
+
parser.add_argument("--json-out", required=True)
|
|
94
|
+
parser.add_argument("--chroma-key", required=True)
|
|
95
|
+
parser.add_argument("--chroma-threshold", type=float, default=96.0)
|
|
96
|
+
parser.add_argument("--edge-margin", type=int, default=2)
|
|
97
|
+
parser.add_argument("--edge-pixel-threshold", type=int, default=24)
|
|
98
|
+
parser.add_argument("--min-used-pixels", type=int, default=400)
|
|
99
|
+
args = parser.parse_args()
|
|
100
|
+
|
|
101
|
+
strip_path = Path(args.strip).expanduser().resolve()
|
|
102
|
+
output_dir = Path(args.output_dir).expanduser().resolve()
|
|
103
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
with Image.open(strip_path) as opened:
|
|
105
|
+
strip = remove_chroma_background(
|
|
106
|
+
opened,
|
|
107
|
+
parse_hex_color(args.chroma_key),
|
|
108
|
+
args.chroma_threshold,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
slot_width = strip.width / len(CARDINALS)
|
|
112
|
+
anchors = []
|
|
113
|
+
errors = []
|
|
114
|
+
for index, label in enumerate(CARDINALS):
|
|
115
|
+
left = round(index * slot_width)
|
|
116
|
+
right = round((index + 1) * slot_width)
|
|
117
|
+
source_cell = strip.crop((left, 0, right, strip.height))
|
|
118
|
+
used_pixels = alpha_count(source_cell)
|
|
119
|
+
edge_pixels = edge_alpha_count(source_cell, args.edge_margin)
|
|
120
|
+
output = output_dir / f"{label}.png"
|
|
121
|
+
fit_to_cell(source_cell).save(output)
|
|
122
|
+
if used_pixels < args.min_used_pixels:
|
|
123
|
+
errors.append(f"{label} is empty or too sparse ({used_pixels} pixels)")
|
|
124
|
+
if edge_pixels > args.edge_pixel_threshold:
|
|
125
|
+
errors.append(
|
|
126
|
+
f"{label} has {edge_pixels} non-transparent pixels near its source slot edge"
|
|
127
|
+
)
|
|
128
|
+
anchors.append(
|
|
129
|
+
{
|
|
130
|
+
"direction": label,
|
|
131
|
+
"source_box": [left, 0, right, strip.height],
|
|
132
|
+
"used_pixels": used_pixels,
|
|
133
|
+
"edge_pixels": edge_pixels,
|
|
134
|
+
"output": str(output),
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
result = {
|
|
139
|
+
"ok": not errors,
|
|
140
|
+
"strip": str(strip_path),
|
|
141
|
+
"directions": CARDINALS,
|
|
142
|
+
"errors": errors,
|
|
143
|
+
"anchors": anchors,
|
|
144
|
+
}
|
|
145
|
+
json_out = Path(args.json_out).expanduser().resolve()
|
|
146
|
+
json_out.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
json_out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
|
148
|
+
print(json.dumps({key: value for key, value in result.items() if key != "anchors"}, indent=2))
|
|
149
|
+
raise SystemExit(0 if result["ok"] else 1)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|