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