Setting the file. One moment. Inspect Frames · Hatch Pet · openai/skills · Skills Docs16.11
Validate Atlas
Lines256scripts/inspect_frames.py
Python·256 lines·9 KB
13
14CELL_WIDTH = 192
15CELL_HEIGHT = 208
16ROW_FRAME_COUNTS = {
17 "idle": 6,
18 "running-right": 8,
19 "running-left": 8,
20 "waving": 4,
21 "jumping": 5,
22 "failed": 8,
23 "waiting": 6,
24 "running": 6,
25 "review": 6,
26}
27IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
28
29
30def alpha_nonzero_count(image: Image.Image) -> int:
31 alpha = image if image.mode == "L" else image.getchannel("A")
32 return sum(alpha.histogram()[1:])
33
34
35def edge_alpha_count(image: Image.Image, margin: int) -> int:
36 alpha = image.getchannel("A")
37 width, height = alpha.size
38 total = 0
39 for box in (
40 (0, 0, width, margin),
41 (0, height - margin, width, height),
42 (0, 0, margin, height),
43 (width - margin, 0, width, height),
44 ):
45 total += alpha_nonzero_count(alpha.crop(box))
46 return total
47
48
49def color_distance(left: tuple[int, int, int], right: tuple[int, int, int]) -> float:
50 return math.sqrt(sum((left[index] - right[index]) ** 2 for index in range(3)))
51
52
53def chroma_adjacent_count(
54 image: Image.Image,
55 chroma_key: tuple[int, int, int] | None,
56 threshold: float,
57) -> int:
58 if chroma_key is None:
59 return 0
60 rgba = image.convert("RGBA")
61 data = rgba.tobytes()
62 count = 0
63 for index in range(0, len(data), 4):
64 red, green, blue, alpha = data[index : index + 4]
65 if alpha > 16 and color_distance((red, green, blue), chroma_key) <= threshold:
66 count += 1
67 return count
68
69
70def frame_files(state_dir: Path) -> list[Path]:
71 if not state_dir.is_dir():
72 return []
73 return sorted(path for path in state_dir.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES)
74
75
76def load_manifest(frames_root: Path) -> dict[str, dict[str, object]]:
77 manifest_path = frames_root / "frames-manifest.json"
78 if not manifest_path.is_file():
79 return {}
80 manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
81 rows = manifest.get("rows", [])
82 if not isinstance(rows, list):
83 return {}
84 return {
85 row["state"]: row
86 for row in rows
87 if isinstance(row, dict) and isinstance(row.get("state"), str)
88 }
89
90
91def load_chroma_key(frames_root: Path) -> tuple[int, int, int] | None:
92 manifest_path = frames_root / "frames-manifest.json"
93 if not manifest_path.is_file():
94 return None
95 manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
96 chroma_key = manifest.get("chroma_key")
97 if not isinstance(chroma_key, dict):
98 return None
99 rgb = chroma_key.get("rgb")
100 if (
101 not isinstance(rgb, list)
102 or len(rgb) != 3
103 or not all(isinstance(value, int) for value in rgb)
104 ):
105 return None
106 return (rgb[0], rgb[1], rgb[2])
107
108
109def inspect_state(
110 frames_root: Path,
111 state: str,
112 expected_count: int,
113 manifest_rows: dict[str, dict[str, object]],
114 chroma_key: tuple[int, int, int] | None,
115 args: argparse.Namespace,
116) -> dict[str, object]:
117 state_dir = frames_root / state
118 files = frame_files(state_dir)
119 row_errors: list[str] = []
120 row_warnings: list[str] = []
121 frames: list[dict[str, object]] = []
122 areas: list[int] = []
123 manifest_row = manifest_rows.get(state, {})
124 method = manifest_row.get("method")
125
126 if len(files) != expected_count:
127 row_errors.append(f"expected {expected_count} frame files for {state}, found {len(files)}")
128
129 if args.require_components and method and method != "components":
130 if method == "stable-slots" and args.allow_stable_slots:
131 row_warnings.append(
132 f"{state} used extraction method stable-slots; confirm motion playback remains stable and unclipped"
133 )
134 else:
135 row_errors.append(
136 f"{state} used extraction method {method}; regenerate the row or inspect slot slicing"
137 )
138 elif method and method != "components":
139 row_warnings.append(
140 f"{state} used extraction method {method}; component extraction is preferred"
141 )
142
143 for index, frame_path in enumerate(files[:expected_count]):
144 with Image.open(frame_path) as opened:
145 frame = opened.convert("RGBA")
146 nontransparent = alpha_nonzero_count(frame)
147 bbox = frame.getbbox()
148 edge_pixels = edge_alpha_count(frame, args.edge_margin)
149 chroma_adjacent_pixels = chroma_adjacent_count(
150 frame,
151 chroma_key,
152 args.chroma_adjacent_threshold,
153 )
154 info = {
155 "index": index,
156 "file": str(frame_path),
157 "width": frame.width,
158 "height": frame.height,
159 "nontransparent_pixels": nontransparent,
160 "bbox": list(bbox) if bbox else None,
161 "edge_pixels": edge_pixels,
162 "chroma_adjacent_pixels": chroma_adjacent_pixels,
163 }
164 frames.append(info)
165 areas.append(nontransparent)
166
167 if frame.size != (CELL_WIDTH, CELL_HEIGHT):
168 row_errors.append(
169 f"{state} frame {index:02d} is {frame.width}x{frame.height}; expected {CELL_WIDTH}x{CELL_HEIGHT}"
170 )
171 if nontransparent < args.min_used_pixels:
172 row_errors.append(
173 f"{state} frame {index:02d} is empty or too sparse ({nontransparent} pixels)"
174 )
175 if edge_pixels > args.edge_pixel_threshold:
176 row_warnings.append(
177 f"{state} frame {index:02d} has {edge_pixels} non-transparent pixels near the cell edge"
178 )
179 if chroma_adjacent_pixels > args.chroma_adjacent_pixel_threshold:
180 row_errors.append(
181 f"{state} frame {index:02d} has {chroma_adjacent_pixels} non-transparent pixels close to the chroma key"
182 )
183
184 if areas:
185 row_median = median(areas)
186 for index, area in enumerate(areas[:expected_count]):
187 if row_median > 0 and area < row_median * args.small_outlier_ratio:
188 row_warnings.append(
189 f"{state} frame {index:02d} is much smaller than the row median ({area} vs {row_median:.0f})"
190 )
191 if row_median > 0 and area > row_median * args.large_outlier_ratio:
192 row_warnings.append(
193 f"{state} frame {index:02d} is much larger than the row median ({area} vs {row_median:.0f})"
194 )
195
196 return {
197 "state": state,
198 "expected_frames": expected_count,
199 "actual_frames": len(files),
200 "extraction_method": method,
201 "ok": not row_errors,
202 "errors": row_errors,
203 "warnings": row_warnings,
204 "frames": frames,
205 }
206
207
208def main() -> None:
209 parser = argparse.ArgumentParser(description=__doc__)
210 parser.add_argument("--frames-root", required=True)
211 parser.add_argument("--json-out", required=True)
212 parser.add_argument("--min-used-pixels", type=int, default=400)
213 parser.add_argument("--edge-margin", type=int, default=2)
214 parser.add_argument("--edge-pixel-threshold", type=int, default=24)
215 parser.add_argument("--chroma-adjacent-threshold", type=float, default=150.0)
216 parser.add_argument("--chroma-adjacent-pixel-threshold", type=int, default=800)
217 parser.add_argument("--small-outlier-ratio", type=float, default=0.35)
218 parser.add_argument("--large-outlier-ratio", type=float, default=2.75)
219 parser.add_argument(
220 "--require-components",
221 action="store_true",
222 help="Fail rows that fell back to equal-slot extraction.",
223 )
224 parser.add_argument(
225 "--allow-stable-slots",
226 action="store_true",
227 help="Permit explicitly chosen stable-slots extraction while still warning for visual review.",
228 )
229 args = parser.parse_args()
230
231 frames_root = Path(args.frames_root).expanduser().resolve()
232 manifest_rows = load_manifest(frames_root)
233 chroma_key = load_chroma_key(frames_root)
234 rows = [
235 inspect_state(frames_root, state, count, manifest_rows, chroma_key, args)
236 for state, count in ROW_FRAME_COUNTS.items()
237 ]
238 errors = [error for row in rows for error in row["errors"]]
239 warnings = [warning for row in rows for warning in row["warnings"]]
240 result = {
241 "ok": not errors,
242 "frames_root": str(frames_root),
243 "errors": errors,
244 "warnings": warnings,
245 "rows": rows,
246 }
247
248 json_out = Path(args.json_out).expanduser().resolve()
249 json_out.parent.mkdir(parents=True, exist_ok=True)
250 json_out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
251 print(json.dumps({k: v for k, v in result.items() if k != "rows"}, indent=2))
252 raise SystemExit(0 if result["ok"] else 1)
253
254
255if __name__ == "__main__":
256 main()