dimagent-linux-arm64
Advanced tools
| interface: | ||
| display_name: "Hatch Pet" | ||
| short_description: "Hatch style-flexible DimAgent pets" | ||
| default_prompt: "Use $hatch-pet to create a DimAgent-compatible v2 pet with all standard animations and 16 look directions." |
| Apache License | ||
| Version 2.0, January 2004 | ||
| http://www.apache.org/licenses/ | ||
| TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | ||
| 1. Definitions. | ||
| "License" shall mean the terms and conditions for use, reproduction, | ||
| and distribution as defined by Sections 1 through 9 of this document. | ||
| "Licensor" shall mean the copyright owner or entity authorized by | ||
| the copyright owner that is granting the License. | ||
| "Legal Entity" shall mean the union of the acting entity and all | ||
| other entities that control, are controlled by, or are under common | ||
| control with that entity. For the purposes of this definition, | ||
| "control" means (i) the power, direct or indirect, to cause the | ||
| direction or management of such entity, whether by contract or | ||
| otherwise, or (ii) ownership of fifty percent (50%) or more of the | ||
| outstanding shares, or (iii) beneficial ownership of such entity. | ||
| "You" (or "Your") shall mean an individual or Legal Entity | ||
| exercising permissions granted by this License. | ||
| "Source" form shall mean the preferred form for making modifications, | ||
| including but not limited to software source code, documentation | ||
| source, and configuration files. | ||
| "Object" form shall mean any form resulting from mechanical | ||
| transformation or translation of a Source form, including but | ||
| not limited to compiled object code, generated documentation, | ||
| and conversions to other media types. | ||
| "Work" shall mean the work of authorship, whether in Source or | ||
| Object form, made available under the License, as indicated by a | ||
| copyright notice that is included in or attached to the work | ||
| (an example is provided in the Appendix below). | ||
| "Derivative Works" shall mean any work, whether in Source or Object | ||
| form, that is based on (or derived from) the Work and for which the | ||
| editorial revisions, annotations, elaborations, or other modifications | ||
| represent, as a whole, an original work of authorship. For the purposes | ||
| of this License, Derivative Works shall not include works that remain | ||
| separable from, or merely link (or bind by name) to the interfaces of, | ||
| the Work and Derivative Works thereof. | ||
| "Contribution" shall mean any work of authorship, including | ||
| the original version of the Work and any modifications or additions | ||
| to that Work or Derivative Works thereof, that is intentionally | ||
| submitted to Licensor for inclusion in the Work by the copyright owner | ||
| or by an individual or Legal Entity authorized to submit on behalf of | ||
| the copyright owner. For the purposes of this definition, "submitted" | ||
| means any form of electronic, verbal, or written communication sent | ||
| to the Licensor or its representatives, including but not limited to | ||
| communication on electronic mailing lists, source code control systems, | ||
| and issue tracking systems that are managed by, or on behalf of, the | ||
| Licensor for the purpose of discussing and improving the Work, but | ||
| excluding communication that is conspicuously marked or otherwise | ||
| designated in writing by the copyright owner as "Not a Contribution." | ||
| "Contributor" shall mean Licensor and any individual or Legal Entity | ||
| on behalf of whom a Contribution has been received by Licensor and | ||
| subsequently incorporated within the Work. | ||
| 2. Grant of Copyright License. Subject to the terms and conditions of | ||
| this License, each Contributor hereby grants to You a perpetual, | ||
| worldwide, non-exclusive, no-charge, royalty-free, irrevocable | ||
| copyright license to reproduce, prepare Derivative Works of, | ||
| publicly display, publicly perform, sublicense, and distribute the | ||
| Work and such Derivative Works in Source or Object form. | ||
| 3. Grant of Patent License. Subject to the terms and conditions of | ||
| this License, each Contributor hereby grants to You a perpetual, | ||
| worldwide, non-exclusive, no-charge, royalty-free, irrevocable | ||
| (except as stated in this section) patent license to make, have made, | ||
| use, offer to sell, sell, import, and otherwise transfer the Work, | ||
| where such license applies only to those patent claims licensable | ||
| by such Contributor that are necessarily infringed by their | ||
| Contribution(s) alone or by combination of their Contribution(s) | ||
| with the Work to which such Contribution(s) was submitted. If You | ||
| institute patent litigation against any entity (including a | ||
| cross-claim or counterclaim in a lawsuit) alleging that the Work | ||
| or a Contribution incorporated within the Work constitutes direct | ||
| or contributory patent infringement, then any patent licenses | ||
| granted to You under this License for that Work shall terminate | ||
| as of the date such litigation is filed. | ||
| 4. Redistribution. You may reproduce and distribute copies of the | ||
| Work or Derivative Works thereof in any medium, with or without | ||
| modifications, and in Source or Object form, provided that You | ||
| meet the following conditions: | ||
| (a) You must give any other recipients of the Work or | ||
| Derivative Works a copy of this License; and | ||
| (b) You must cause any modified files to carry prominent notices | ||
| stating that You changed the files; and | ||
| (c) You must retain, in the Source form of any Derivative Works | ||
| that You distribute, all copyright, patent, trademark, and | ||
| attribution notices from the Source form of the Work, | ||
| excluding those notices that do not pertain to any part of | ||
| the Derivative Works; and | ||
| (d) If the Work includes a "NOTICE" text file as part of its | ||
| distribution, then any Derivative Works that You distribute must | ||
| include a readable copy of the attribution notices contained | ||
| within such NOTICE file, excluding those notices that do not | ||
| pertain to any part of the Derivative Works, in at least one | ||
| of the following places: within a NOTICE text file distributed | ||
| as part of the Derivative Works; within the Source form or | ||
| documentation, if provided along with the Derivative Works; or, | ||
| within a display generated by the Derivative Works, if and | ||
| wherever such third-party notices normally appear. The contents | ||
| of the NOTICE file are for informational purposes only and | ||
| do not modify the License. You may add Your own attribution | ||
| notices within Derivative Works that You distribute, alongside | ||
| or as an addendum to the NOTICE text from the Work, provided | ||
| that such additional attribution notices cannot be construed | ||
| as modifying the License. | ||
| You may add Your own copyright statement to Your modifications and | ||
| may provide additional or different license terms and conditions | ||
| for use, reproduction, or distribution of Your modifications, or | ||
| for any such Derivative Works as a whole, provided Your use, | ||
| reproduction, and distribution of the Work otherwise complies with | ||
| the conditions stated in this License. | ||
| 5. Submission of Contributions. Unless You explicitly state otherwise, | ||
| any Contribution intentionally submitted for inclusion in the Work | ||
| by You to the Licensor shall be under the terms and conditions of | ||
| this License, without any additional terms or conditions. | ||
| Notwithstanding the above, nothing herein shall supersede or modify | ||
| the terms of any separate license agreement you may have executed | ||
| with Licensor regarding such Contributions. | ||
| 6. Trademarks. This License does not grant permission to use the trade | ||
| names, trademarks, service marks, or product names of the Licensor, | ||
| except as required for reasonable and customary use in describing the | ||
| origin of the Work and reproducing the content of the NOTICE file. | ||
| 7. Disclaimer of Warranty. Unless required by applicable law or | ||
| agreed to in writing, Licensor provides the Work (and each | ||
| Contributor provides its Contributions) on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | ||
| implied, including, without limitation, any warranties or conditions | ||
| of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | ||
| PARTICULAR PURPOSE. You are solely responsible for determining the | ||
| appropriateness of using or redistributing the Work and assume any | ||
| risks associated with Your exercise of permissions under this License. | ||
| 8. Limitation of Liability. In no event and under no legal theory, | ||
| whether in tort (including negligence), contract, or otherwise, | ||
| unless required by applicable law (such as deliberate and grossly | ||
| negligent acts) or agreed to in writing, shall any Contributor be | ||
| liable to You for damages, including any direct, indirect, special, | ||
| incidental, or consequential damages of any character arising as a | ||
| result of this License or out of the use or inability to use the | ||
| Work (including but not limited to damages for loss of goodwill, | ||
| work stoppage, computer failure or malfunction, or any and all | ||
| other commercial damages or losses), even if such Contributor | ||
| has been advised of the possibility of such damages. | ||
| 9. Accepting Warranty or Additional Liability. While redistributing | ||
| the Work or Derivative Works thereof, You may choose to offer, | ||
| and charge a fee for, acceptance of support, warranty, indemnity, | ||
| or other liability obligations and/or rights consistent with this | ||
| License. However, in accepting such obligations, You may act only | ||
| on Your own behalf and on Your sole responsibility, not on behalf of | ||
| any other Contributor, and only if You agree to indemnify, | ||
| defend, and hold each Contributor harmless for any liability | ||
| incurred by, or claims asserted against, such Contributor by reason | ||
| of your accepting any such warranty or additional liability. | ||
| END OF TERMS AND CONDITIONS | ||
| APPENDIX: How to apply the Apache License to your work. | ||
| To apply the Apache License to your work, attach the following | ||
| boilerplate notice, with the fields enclosed by brackets "[]" | ||
| replaced with your own identifying information. (Don\'t include | ||
| the brackets!) The text should be enclosed in the appropriate | ||
| comment syntax for the file format. We also recommend that a | ||
| file or class name and description of purpose be included on the | ||
| same "printed page" as the copyright notice for easier | ||
| identification within third-party archives. | ||
| Copyright [yyyy] [name of copyright owner] | ||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. |
| # V2 Animation Rows | ||
| Every newly hatched pet uses an 8-column x 11-row atlas with 192x208 cells. The final atlas is 1536x2288 and its Codex manifest uses `spriteVersionNumber: 2`. | ||
| | Row | State | Used columns | Durations | | ||
| | --- | ----------------- | -----------: | ------------------------------------------------------ | | ||
| | 0 | idle | 0-5 | 280, 110, 110, 140, 140, 320 ms | | ||
| | 1 | move_left | 0-7 | 120 ms each, final 220 ms | | ||
| | 2 | move_right | 0-7 | 120 ms each, final 220 ms | | ||
| | 3 | waving | 0-3 | 140 ms each, final 280 ms | | ||
| | 4 | jumping | 0-4 | 140 ms each, final 280 ms | | ||
| | 5 | failed | 0-7 | 140 ms each, final 240 ms | | ||
| | 6 | waiting | 0-5 | 150 ms each, final 260 ms | | ||
| | 7 | running | 0-5 | 120 ms each, final 220 ms | | ||
| | 8 | review | 0-5 | 150 ms each, final 280 ms | | ||
| | 9 | look directions A | 0-7 | 000, 022.5, 045, 067.5, 090, 112.5, 135, 157.5 degrees | | ||
| | 10 | look directions B | 0-7 | 180, 202.5, 225, 247.5, 270, 292.5, 315, 337.5 degrees | | ||
| Unused cells after each standard animation row's final used column must be fully transparent. All look-row cells are used. | ||
| `000` degrees means looking up / 12 o'clock. Neutral/front is the pointer deadzone and falls back to the normal idle animation. | ||
| ## Row Purposes | ||
| - `idle`: calm, low-distraction breathing/blinking loop and reduced-motion first frame. | ||
| - `move_left`: locomotion to the left with a readable alternating cadence. Mirror from `move_right` only when identity and prop handedness remain correct, preserving frame order. | ||
| - `move_right`: locomotion to the right with a readable alternating cadence. | ||
| - `waving`: greeting or attention gesture with a clear start, raised gesture, and return. | ||
| - `jumping`: anticipation, lift, peak, descent, and settle. | ||
| - `failed`: readable error, sad, or deflated reaction without noisy detached effects. | ||
| - `waiting`: expectant asking pose for approval, help, or user input. | ||
| - `running`: active task work or processing, not literal foot-running. | ||
| - `review`: focused inspection of completed output. | ||
| - rows `9-10`: one continuous clockwise 16-pose look loop using pet-specific eye, head, body, appendage, and prop mechanics. |
| # Codex V2 Pet Contract | ||
| ## Sprite Atlas | ||
| - Version: Codex v2 (`pet.json` contains `spriteVersionNumber: 2`). | ||
| - File: WebP. | ||
| - Dimensions: `1536x2288`. | ||
| - Grid: 8 columns x 11 rows. | ||
| - Cell: `192x208`. | ||
| - Background: transparent. | ||
| - Rows `0-8`: standard animation states. | ||
| - Rows `9-10`: 16 clockwise look directions. | ||
| - Unused standard-row cells: fully transparent. | ||
| The 8x9 `1536x1872` atlas is an intermediate assembly artifact only. Never package it as a newly hatched pet. | ||
| ## Look Directions | ||
| - Row `9`: `000`, `022.5`, `045`, `067.5`, `090`, `112.5`, `135`, `157.5` degrees. | ||
| - Row `10`: `180`, `202.5`, `225`, `247.5`, `270`, `292.5`, `315`, `337.5` degrees. | ||
| - `000` means up / 12 o'clock, not neutral/front. | ||
| - Neutral/front is the no-vector deadzone and falls back to idle. | ||
| ## Local Custom Pet Package | ||
| Place files under: | ||
| ```text | ||
| ${DIMCODE_HOME:-$HOME/.dimcode/v2}/pets/<pet-id>/ | ||
| ├── pet.json | ||
| └── spritesheet.webp | ||
| ``` | ||
| Required manifest shape: | ||
| ```json | ||
| { | ||
| "id": "pet-id", | ||
| "displayName": "Pet Name", | ||
| "description": "A short sentence describing the Pet.", | ||
| "spriteVersionNumber": 2, | ||
| "spritesheetPath": "spritesheet.webp" | ||
| } | ||
| ``` | ||
| The app reads the row layout and playback cadence from its own animation contract. Omitting `spriteVersionNumber` means Codex v1 with 9 rows (`1536x1872`); newly generated Pets set it to `2` for 11 rows (`1536x2288`). `spritesheetPath` must be `spritesheet.webp`. Newly generated Pets include a non-empty `description` of at most 160 Unicode code points. Custom pet ids may not start with `bundled-`. |
| # V2 Pet QA Rubric | ||
| Do not package a pet until every section passes. | ||
| ## Geometry And Package | ||
| - Final atlas is exactly `1536x2288`, 8 columns x 11 rows, with `192x208` cells. | ||
| - `pet.json` follows Codex v2: valid `id`, `displayName`, a short `description`, `spriteVersionNumber: 2`, and `spritesheetPath: "spritesheet.webp"`. | ||
| - Used cells are non-empty; unused standard-row cells are transparent. | ||
| - Fully transparent pixels have zero RGB residue. | ||
| - The 8x9 intermediate atlas is never packaged. | ||
| - `qa/review.json` has no errors. | ||
| - Standard rows use component extraction unless `stable-slots` was deliberately approved after playback review. | ||
| - Coherent look rows recover their ordered pose groups and pass near-edge clipping checks after shared-scale registration into final cells. | ||
| ## Character And Style | ||
| - Silhouette, proportions, face, expression language, material, palette, lighting, markings, and props remain the same across all 11 rows. | ||
| - The pet reads clearly inside a `192x208` cell in the chosen style. | ||
| - No frame introduces an unintended character, object, logo, text, scene, or effect. | ||
| ## Standard Animation | ||
| - Rows `0-8` contain the exact required frame counts and recognizable state semantics. | ||
| - Loops do not pop, reverse cadence, face the wrong direction, or remain effectively static. | ||
| - The first idle frame works as a reduced-motion still. | ||
| - `waiting`, `running`, `review`, and `failed` remain visually distinct. | ||
| ## Look Directions | ||
| - All 16 directions are present in fixed clockwise order and visibly distinct from neutral/rest. | ||
| - Cardinal directions read unmistakably as up, right, down, and left; diagonals and intermediates read in the correct quadrant. | ||
| - `qa/look-directions.png` includes full-body and zoomed head/upper-body views. | ||
| - `qa/direction-semantics.json` records `pass`, `expected`, `observed`, and `reason` for every direction. | ||
| - `qa/look-continuity.json` has no unexplained holes, center jumps, area jumps, or local difference outliers. | ||
| - Eyes, eyelids, head, body, appendages, and props follow the pet-specific look mechanics plan. | ||
| - No whole-sprite rotation, replacement/googly eyes, visual clipping, seam bands, or transparent interior holes. | ||
| - A repaired direction is approved by an independent visual QA worker or explicit user inspection, not the repairing parent alone. | ||
| ## Installation | ||
| - `scripts/package_pet.py` performs the final install. | ||
| - The target Pet directory is created by atomic rename from a sibling temporary directory. | ||
| - Existing Pet IDs fail without overwrite. | ||
| - The installed directory contains exactly `pet.json` and `spritesheet.webp`. | ||
| - Reference images, prompts, QA files, and intermediate atlases remain outside the installed directory. | ||
| ## Repair Policy | ||
| Repair the smallest packaging-eligible scope: one standard row or one complete coherent look row. Never mix an individually generated repair cell into a new pet's final look row. Re-run assembly, deterministic validation, direction QA, continuity measurement, and semantic review after every relevant repair. |
| #!/usr/bin/env python3 | ||
| """Assemble a standard DimAgent pet atlas plus 16 look-direction frames.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import json | ||
| import math | ||
| import re | ||
| import sys | ||
| from pathlib import Path | ||
| from statistics import median | ||
| from PIL import Image | ||
| sys.path.insert(0, str(Path(__file__).resolve().parent)) | ||
| from extract_strip_frames import component_frame_groups, component_group_image | ||
| COLUMNS = 8 | ||
| STANDARD_ROWS = 9 | ||
| EXTENDED_ROWS = 11 | ||
| CELL_WIDTH = 192 | ||
| CELL_HEIGHT = 208 | ||
| ATLAS_WIDTH = COLUMNS * CELL_WIDTH | ||
| STANDARD_ATLAS_HEIGHT = STANDARD_ROWS * CELL_HEIGHT | ||
| EXTENDED_ATLAS_HEIGHT = EXTENDED_ROWS * CELL_HEIGHT | ||
| LOOK_DIRECTION_LABELS = [ | ||
| "000", | ||
| "022.5", | ||
| "045", | ||
| "067.5", | ||
| "090", | ||
| "112.5", | ||
| "135", | ||
| "157.5", | ||
| "180", | ||
| "202.5", | ||
| "225", | ||
| "247.5", | ||
| "270", | ||
| "292.5", | ||
| "315", | ||
| "337.5", | ||
| ] | ||
| IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"} | ||
| MIN_DETACHED_COMPONENT_PIXELS = 128 | ||
| DEFAULT_EDGE_MARGIN = 2 | ||
| DEFAULT_EDGE_PIXEL_THRESHOLD = 24 | ||
| class CellGeometry: | ||
| def __init__(self, height: int, lower_center_x: float, bottom: int) -> None: | ||
| self.height = height | ||
| self.lower_center_x = lower_center_x | ||
| self.bottom = bottom | ||
| def edge_alpha_count(image: Image.Image, margin: int) -> int: | ||
| alpha = image.getchannel("A") | ||
| width, height = alpha.size | ||
| total = 0 | ||
| for box in ( | ||
| (0, 0, width, margin), | ||
| (0, height - margin, width, height), | ||
| (0, 0, margin, height), | ||
| (width - margin, 0, width, height), | ||
| ): | ||
| total += sum(alpha.crop(box).histogram()[1:]) | ||
| return total | ||
| def parse_hex_color(value: str) -> tuple[int, int, int]: | ||
| if not re.fullmatch(r"#[0-9a-fA-F]{6}", value): | ||
| raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB") | ||
| return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5)) | ||
| def color_distance( | ||
| red: int, | ||
| green: int, | ||
| blue: int, | ||
| key: tuple[int, int, int], | ||
| ) -> float: | ||
| return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2) | ||
| def remove_chroma_background( | ||
| image: Image.Image, | ||
| chroma_key: tuple[int, int, int], | ||
| threshold: float, | ||
| ) -> Image.Image: | ||
| rgba = image.convert("RGBA") | ||
| pixels = rgba.load() | ||
| for y in range(rgba.height): | ||
| for x in range(rgba.width): | ||
| red, green, blue, alpha = pixels[x, y] | ||
| if color_distance(red, green, blue, chroma_key) <= threshold: | ||
| pixels[x, y] = (0, 0, 0, 0) | ||
| return rgba | ||
| def fit_to_cell(image: Image.Image) -> Image.Image: | ||
| bbox = image.getbbox() | ||
| target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) | ||
| if bbox is None: | ||
| return target | ||
| sprite = image.crop(bbox).convert("RGBA") | ||
| max_width = CELL_WIDTH - 10 | ||
| max_height = CELL_HEIGHT - 10 | ||
| scale = min(max_width / sprite.width, max_height / sprite.height, 1.0) | ||
| if scale != 1.0: | ||
| sprite = sprite.resize( | ||
| (max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))), | ||
| Image.Resampling.LANCZOS, | ||
| ) | ||
| left = (CELL_WIDTH - sprite.width) // 2 | ||
| top = (CELL_HEIGHT - sprite.height) // 2 | ||
| target.alpha_composite(sprite, (left, top)) | ||
| return remove_small_detached_components(target) | ||
| def remove_small_detached_components(image: Image.Image) -> Image.Image: | ||
| rgba = image.convert("RGBA") | ||
| alpha = rgba.getchannel("A") | ||
| width, height = rgba.size | ||
| visited: set[tuple[int, int]] = set() | ||
| components: list[list[tuple[int, int]]] = [] | ||
| for y in range(height): | ||
| for x in range(width): | ||
| if (x, y) in visited or alpha.getpixel((x, y)) <= 16: | ||
| continue | ||
| component: list[tuple[int, int]] = [] | ||
| stack = [(x, y)] | ||
| visited.add((x, y)) | ||
| while stack: | ||
| current_x, current_y = stack.pop() | ||
| component.append((current_x, current_y)) | ||
| for next_x, next_y in ( | ||
| (current_x - 1, current_y), | ||
| (current_x + 1, current_y), | ||
| (current_x, current_y - 1), | ||
| (current_x, current_y + 1), | ||
| ): | ||
| if ( | ||
| next_x < 0 | ||
| or next_x >= width | ||
| or next_y < 0 | ||
| or next_y >= height | ||
| or (next_x, next_y) in visited | ||
| or alpha.getpixel((next_x, next_y)) <= 16 | ||
| ): | ||
| continue | ||
| visited.add((next_x, next_y)) | ||
| stack.append((next_x, next_y)) | ||
| components.append(component) | ||
| if not components: | ||
| return rgba | ||
| largest = max(len(component) for component in components) | ||
| pixels = rgba.load() | ||
| for component in components: | ||
| if len(component) == largest or len(component) >= MIN_DETACHED_COMPONENT_PIXELS: | ||
| continue | ||
| for x, y in component: | ||
| pixels[x, y] = (0, 0, 0, 0) | ||
| return rgba | ||
| def opaque_points(image: Image.Image) -> list[tuple[int, int]]: | ||
| alpha = image.getchannel("A") | ||
| width, height = image.size | ||
| return [(x, y) for y in range(height) for x in range(width) if alpha.getpixel((x, y)) > 16] | ||
| def lower_band_points( | ||
| points: list[tuple[int, int]], top: int, bottom: int | ||
| ) -> list[tuple[int, int]]: | ||
| threshold = top + (bottom - top) * 0.72 | ||
| return [(x, y) for x, y in points if y >= threshold] or points | ||
| def cell_geometry(cell: Image.Image) -> CellGeometry | None: | ||
| points = opaque_points(cell) | ||
| if not points: | ||
| return None | ||
| ys = [y for _, y in points] | ||
| top = min(ys) | ||
| bottom = max(ys) + 1 | ||
| lower_points = lower_band_points(points, top, bottom) | ||
| return CellGeometry( | ||
| height=bottom - top, | ||
| lower_center_x=sum(x for x, _ in lower_points) / len(lower_points), | ||
| bottom=bottom, | ||
| ) | ||
| def normalize_cell_to_geometry( | ||
| cell: Image.Image, | ||
| target: CellGeometry, | ||
| scale: float, | ||
| ) -> Image.Image: | ||
| bbox = cell.getbbox() | ||
| if bbox is None: | ||
| return cell | ||
| source_geometry = cell_geometry(cell) | ||
| if source_geometry is None or source_geometry.height <= 0: | ||
| return cell | ||
| left, top, right, bottom = bbox | ||
| crop = cell.crop(bbox) | ||
| scaled_width = max(1, round(crop.width * scale)) | ||
| scaled_height = max(1, round(crop.height * scale)) | ||
| if crop.size != (scaled_width, scaled_height): | ||
| crop = crop.resize((scaled_width, scaled_height), Image.Resampling.LANCZOS) | ||
| local_lower_center_x = source_geometry.lower_center_x - left | ||
| target_left = round(target.lower_center_x - local_lower_center_x * scale) | ||
| target_top = target.bottom - scaled_height | ||
| output = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) | ||
| output.alpha_composite(crop, (target_left, target_top)) | ||
| return remove_small_detached_components(output) | ||
| def normalize_cells_to_reference( | ||
| cells: list[Image.Image], | ||
| reference_cell: Image.Image, | ||
| scale: float | None = None, | ||
| ) -> list[Image.Image]: | ||
| target = cell_geometry(reference_cell) | ||
| if target is None: | ||
| raise SystemExit("neutral reference cell must contain visible pixels") | ||
| if scale is None: | ||
| scale = normalization_scale(cells, target) | ||
| return [normalize_cell_to_geometry(cell, target, scale) for cell in cells] | ||
| def normalization_scale(cells: list[Image.Image], target: CellGeometry) -> float: | ||
| geometries = [cell_geometry(cell) for cell in cells] | ||
| if any(geometry is None for geometry in geometries): | ||
| raise SystemExit("look direction cells must contain visible pixels") | ||
| visible_geometries = [geometry for geometry in geometries if geometry is not None] | ||
| max_height = max(geometry.height for geometry in visible_geometries) | ||
| max_width = max(cell.getbbox()[2] - cell.getbbox()[0] for cell in cells) | ||
| scale_limits = [ | ||
| target.height / max_height, | ||
| (CELL_WIDTH - 10) / max_width, | ||
| (CELL_HEIGHT - 10) / max_height, | ||
| 1.0, | ||
| ] | ||
| margin = 5 | ||
| for cell, geometry in zip(cells, geometries): | ||
| if geometry is None: | ||
| continue | ||
| left, _, right, _ = cell.getbbox() | ||
| local_lower_center_x = geometry.lower_center_x - left | ||
| left_extent = local_lower_center_x | ||
| right_extent = right - left - local_lower_center_x | ||
| if left_extent > 0: | ||
| scale_limits.append((target.lower_center_x - margin) / left_extent) | ||
| if right_extent > 0: | ||
| scale_limits.append((CELL_WIDTH - margin - target.lower_center_x) / right_extent) | ||
| scale_limits.append((target.bottom - margin) / geometry.height) | ||
| return min(scale_limits) | ||
| def scale_to_registered_row( | ||
| source_cells: list[Image.Image], | ||
| registered_cells: list[Image.Image], | ||
| ) -> float: | ||
| def visible_area(cell: Image.Image) -> int: | ||
| return sum(cell.getchannel("A").histogram()[17:]) | ||
| source_area = median(visible_area(cell) for cell in source_cells) | ||
| target_area = median(visible_area(cell) for cell in registered_cells) | ||
| if source_area <= 0 or target_area <= 0: | ||
| raise SystemExit("look direction rows must contain visible pixels") | ||
| return min(1.0, math.sqrt(target_area / source_area)) | ||
| def clear_transparent_rgb(image: Image.Image) -> Image.Image: | ||
| rgba = image.convert("RGBA") | ||
| data = bytearray(rgba.tobytes()) | ||
| for index in range(0, len(data), 4): | ||
| if data[index + 3] == 0: | ||
| data[index] = 0 | ||
| data[index + 1] = 0 | ||
| data[index + 2] = 0 | ||
| return Image.frombytes("RGBA", rgba.size, bytes(data)) | ||
| def load_base_rows(base_atlas_path: Path) -> Image.Image: | ||
| with Image.open(base_atlas_path) as opened: | ||
| base = opened.convert("RGBA") | ||
| if base.width != ATLAS_WIDTH or base.height not in { | ||
| STANDARD_ATLAS_HEIGHT, | ||
| EXTENDED_ATLAS_HEIGHT, | ||
| }: | ||
| raise SystemExit( | ||
| f"base atlas must be 1536x1872 or 1536x2288; got {base.width}x{base.height}" | ||
| ) | ||
| extended = Image.new("RGBA", (ATLAS_WIDTH, EXTENDED_ATLAS_HEIGHT), (0, 0, 0, 0)) | ||
| standard_region = base.crop((0, 0, ATLAS_WIDTH, STANDARD_ATLAS_HEIGHT)) | ||
| extended.alpha_composite(standard_region, (0, 0)) | ||
| return extended | ||
| def extract_row_strip_cells( | ||
| row_strip_path: Path, | ||
| chroma_key: tuple[int, int, int], | ||
| threshold: float, | ||
| ) -> list[Image.Image]: | ||
| with Image.open(row_strip_path) as opened: | ||
| strip = remove_chroma_background(opened, chroma_key, threshold) | ||
| groups = component_frame_groups(strip, COLUMNS) | ||
| if groups is None: | ||
| raise SystemExit( | ||
| f"could not identify {COLUMNS} ordered pose groups in {row_strip_path}; " | ||
| "resynthesize the complete source row with separated poses" | ||
| ) | ||
| return [component_group_image(strip, group) for group in groups] | ||
| def validate_normalized_look_cells( | ||
| cells: list[Image.Image], | ||
| direction_offset: int, | ||
| edge_margin: int, | ||
| edge_pixel_threshold: int, | ||
| ) -> None: | ||
| for index, cell in enumerate(cells): | ||
| edge_pixels = edge_alpha_count(cell, edge_margin) | ||
| if edge_pixels > edge_pixel_threshold: | ||
| label = LOOK_DIRECTION_LABELS[direction_offset + index] | ||
| raise SystemExit( | ||
| f"look direction {label} has {edge_pixels} non-transparent pixels near " | ||
| "its final cell edge after deterministic registration; resynthesize the " | ||
| "complete source row" | ||
| ) | ||
| def normalized_label(value: str) -> str: | ||
| return value.lower().replace(".", "").replace("-", "").replace("_", "") | ||
| def labels_for_path(path: Path) -> list[str]: | ||
| normalized_stem = normalized_label(path.stem) | ||
| exact_matches = [ | ||
| label for label in LOOK_DIRECTION_LABELS if normalized_label(label) == normalized_stem | ||
| ] | ||
| if exact_matches: | ||
| return exact_matches | ||
| numeric_tokens = [float(token) for token in re.findall(r"\d+(?:\.\d+)?", path.stem)] | ||
| return [ | ||
| label | ||
| for label in LOOK_DIRECTION_LABELS | ||
| if any(float(label) == token for token in numeric_tokens) | ||
| ] | ||
| def image_files(path: Path) -> list[Path]: | ||
| return sorted(p for p in path.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES) | ||
| def load_look_cells_from_dir( | ||
| cells_dir: Path, | ||
| chroma_key: tuple[int, int, int], | ||
| threshold: float, | ||
| ) -> list[Image.Image]: | ||
| files = image_files(cells_dir) | ||
| cells_by_label: dict[str, Path] = {} | ||
| for path in files: | ||
| for label in labels_for_path(path): | ||
| cells_by_label.setdefault(label, path) | ||
| if len(cells_by_label) == len(LOOK_DIRECTION_LABELS): | ||
| ordered_files = [cells_by_label[label] for label in LOOK_DIRECTION_LABELS] | ||
| elif len(files) >= len(LOOK_DIRECTION_LABELS): | ||
| ordered_files = files[: len(LOOK_DIRECTION_LABELS)] | ||
| else: | ||
| raise SystemExit( | ||
| f"look cells dir must contain 16 labeled or sortable images; found {len(files)}" | ||
| ) | ||
| cells: list[Image.Image] = [] | ||
| for path in ordered_files: | ||
| with Image.open(path) as opened: | ||
| cell = remove_chroma_background(opened, chroma_key, threshold) | ||
| cells.append(cell) | ||
| return cells | ||
| def load_look_cells( | ||
| args: argparse.Namespace, | ||
| chroma_key: tuple[int, int, int], | ||
| ) -> list[Image.Image]: | ||
| if args.look_cells_dir: | ||
| return load_look_cells_from_dir( | ||
| Path(args.look_cells_dir).expanduser().resolve(), | ||
| chroma_key, | ||
| args.chroma_threshold, | ||
| ) | ||
| if not args.look_row_9: | ||
| raise SystemExit("provide either --look-cells-dir or --look-row-9") | ||
| row_9_cells = extract_row_strip_cells( | ||
| Path(args.look_row_9).expanduser().resolve(), | ||
| chroma_key, | ||
| args.chroma_threshold, | ||
| ) | ||
| if not args.look_row_10: | ||
| return row_9_cells | ||
| row_10_cells = extract_row_strip_cells( | ||
| Path(args.look_row_10).expanduser().resolve(), | ||
| chroma_key, | ||
| args.chroma_threshold, | ||
| ) | ||
| return [*row_9_cells, *row_10_cells] | ||
| def load_registered_row(path: Path) -> list[Image.Image]: | ||
| with Image.open(path) as opened: | ||
| row = opened.convert("RGBA") | ||
| if row.size != (ATLAS_WIDTH, CELL_HEIGHT): | ||
| raise SystemExit( | ||
| f"registered row must be {ATLAS_WIDTH}x{CELL_HEIGHT}; got {row.width}x{row.height}" | ||
| ) | ||
| return [ | ||
| row.crop( | ||
| ( | ||
| column * CELL_WIDTH, | ||
| 0, | ||
| (column + 1) * CELL_WIDTH, | ||
| CELL_HEIGHT, | ||
| ) | ||
| ) | ||
| for column in range(COLUMNS) | ||
| ] | ||
| def load_registration_scale(path: Path) -> float: | ||
| data = json.loads(path.read_text(encoding="utf-8")) | ||
| scale = data.get("scale") | ||
| if not isinstance(scale, int | float) or scale <= 0: | ||
| raise SystemExit(f"registration manifest has invalid scale: {scale!r}") | ||
| return float(scale) | ||
| def load_neutral_cell( | ||
| neutral_cell_path: Path | None, | ||
| atlas: Image.Image, | ||
| chroma_key: tuple[int, int, int], | ||
| threshold: float, | ||
| ) -> Image.Image: | ||
| if neutral_cell_path is None: | ||
| return base_neutral_cell(atlas) | ||
| with Image.open(neutral_cell_path) as opened: | ||
| return fit_to_cell(remove_chroma_background(opened, chroma_key, threshold)) | ||
| def atlas_cell(atlas: Image.Image, row: int, column: int) -> Image.Image: | ||
| return atlas.crop( | ||
| ( | ||
| column * CELL_WIDTH, | ||
| row * CELL_HEIGHT, | ||
| (column + 1) * CELL_WIDTH, | ||
| (row + 1) * CELL_HEIGHT, | ||
| ) | ||
| ) | ||
| def base_neutral_cell(atlas: Image.Image) -> Image.Image: | ||
| for column in [0, 1, 2, 3, 4, 5]: | ||
| cell = atlas_cell(atlas, 0, column) | ||
| if cell_geometry(cell) is not None: | ||
| return cell | ||
| raise SystemExit("base atlas must contain a visible idle or neutral frame") | ||
| def paste_look_cells(atlas: Image.Image, cells: list[Image.Image]) -> None: | ||
| if len(cells) != len(LOOK_DIRECTION_LABELS): | ||
| raise SystemExit(f"expected 16 look cells, got {len(cells)}") | ||
| for index, cell in enumerate(cells): | ||
| row = STANDARD_ROWS + index // COLUMNS | ||
| column = index % COLUMNS | ||
| atlas.alpha_composite(cell, (column * CELL_WIDTH, row * CELL_HEIGHT)) | ||
| def write_manifest(path: Path, atlas_path: Path) -> None: | ||
| manifest = { | ||
| "spritesheetPath": atlas_path.name, | ||
| "spritesheetLayout": { | ||
| "columns": COLUMNS, | ||
| "rows": EXTENDED_ROWS, | ||
| "cellWidth": CELL_WIDTH, | ||
| "cellHeight": CELL_HEIGHT, | ||
| "lookDirectionCount": len(LOOK_DIRECTION_LABELS), | ||
| }, | ||
| "lookDirections": [ | ||
| { | ||
| "degrees": float(label), | ||
| "rowIndex": STANDARD_ROWS + index // COLUMNS, | ||
| "columnIndex": index % COLUMNS, | ||
| } | ||
| for index, label in enumerate(LOOK_DIRECTION_LABELS) | ||
| ], | ||
| } | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") | ||
| def save_registered_row(cells: list[Image.Image], path: Path) -> None: | ||
| if len(cells) != COLUMNS: | ||
| raise SystemExit(f"expected {COLUMNS} registered look cells, got {len(cells)}") | ||
| row = Image.new("RGBA", (ATLAS_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) | ||
| for column, cell in enumerate(cells): | ||
| row.alpha_composite(cell, (column * CELL_WIDTH, 0)) | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| clear_transparent_rgb(row).save(path) | ||
| print(f"wrote {path}") | ||
| def write_registration_manifest(path: Path, scale: float) -> None: | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| path.write_text(json.dumps({"scale": scale}, indent=2) + "\n", encoding="utf-8") | ||
| print(f"wrote {path}") | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--base-atlas", required=True) | ||
| source = parser.add_mutually_exclusive_group(required=True) | ||
| source.add_argument("--look-cells-dir") | ||
| source.add_argument("--look-row-9") | ||
| source.add_argument("--registered-row-9") | ||
| parser.add_argument("--look-row-10") | ||
| parser.add_argument("--row-9-registration") | ||
| parser.add_argument( | ||
| "--neutral-cell", | ||
| help="optional external neutral/default cell; defaults to the neutral cell in the base atlas", | ||
| ) | ||
| parser.add_argument("--output") | ||
| parser.add_argument( | ||
| "--registered-row-output", | ||
| help="validate and write registered row 9 before row 10 is generated", | ||
| ) | ||
| parser.add_argument("--registration-manifest-output") | ||
| parser.add_argument("--webp-output") | ||
| parser.add_argument("--manifest-output") | ||
| parser.add_argument("--chroma-key", default="#00FF00") | ||
| parser.add_argument("--chroma-threshold", type=float, default=96.0) | ||
| parser.add_argument("--edge-margin", type=int, default=DEFAULT_EDGE_MARGIN) | ||
| parser.add_argument( | ||
| "--edge-pixel-threshold", | ||
| type=int, | ||
| default=DEFAULT_EDGE_PIXEL_THRESHOLD, | ||
| ) | ||
| args = parser.parse_args() | ||
| chroma_key = parse_hex_color(args.chroma_key) | ||
| atlas = load_base_rows(Path(args.base_atlas).expanduser().resolve()) | ||
| neutral = load_neutral_cell( | ||
| Path(args.neutral_cell).expanduser().resolve() if args.neutral_cell else None, | ||
| atlas, | ||
| chroma_key, | ||
| args.chroma_threshold, | ||
| ) | ||
| if args.registered_row_9: | ||
| if not args.look_row_10 or not args.row_9_registration: | ||
| raise SystemExit("--registered-row-9 requires --look-row-10 and --row-9-registration") | ||
| row_9_cells = load_registered_row(Path(args.registered_row_9).expanduser().resolve()) | ||
| row_10_cells = extract_row_strip_cells( | ||
| Path(args.look_row_10).expanduser().resolve(), | ||
| chroma_key, | ||
| args.chroma_threshold, | ||
| ) | ||
| load_registration_scale(Path(args.row_9_registration).expanduser().resolve()) | ||
| row_10_cells = normalize_cells_to_reference( | ||
| row_10_cells, | ||
| neutral, | ||
| scale_to_registered_row(row_10_cells, row_9_cells), | ||
| ) | ||
| validate_normalized_look_cells( | ||
| row_10_cells, | ||
| COLUMNS, | ||
| args.edge_margin, | ||
| args.edge_pixel_threshold, | ||
| ) | ||
| cells = [*row_9_cells, *row_10_cells] | ||
| else: | ||
| cells = load_look_cells(args, chroma_key) | ||
| target = cell_geometry(neutral) | ||
| if target is None: | ||
| raise SystemExit("neutral reference cell must contain visible pixels") | ||
| scale = normalization_scale(cells, target) | ||
| cells = normalize_cells_to_reference(cells, neutral, scale) | ||
| validate_normalized_look_cells( | ||
| cells, | ||
| 0, | ||
| args.edge_margin, | ||
| args.edge_pixel_threshold, | ||
| ) | ||
| if not args.look_cells_dir and not args.look_row_10: | ||
| if not args.registered_row_output: | ||
| raise SystemExit("--look-row-9 without --look-row-10 requires --registered-row-output") | ||
| save_registered_row( | ||
| cells, | ||
| Path(args.registered_row_output).expanduser().resolve(), | ||
| ) | ||
| if not args.registration_manifest_output: | ||
| raise SystemExit("--registered-row-output requires --registration-manifest-output") | ||
| write_registration_manifest( | ||
| Path(args.registration_manifest_output).expanduser().resolve(), | ||
| scale, | ||
| ) | ||
| return | ||
| if not args.output: | ||
| raise SystemExit("--output is required when assembling the extended atlas") | ||
| paste_look_cells(atlas, cells) | ||
| atlas = clear_transparent_rgb(atlas) | ||
| output = Path(args.output).expanduser().resolve() | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| atlas.save(output) | ||
| print(f"wrote {output}") | ||
| if args.webp_output: | ||
| webp_output = Path(args.webp_output).expanduser().resolve() | ||
| webp_output.parent.mkdir(parents=True, exist_ok=True) | ||
| atlas.save(webp_output, format="WEBP", lossless=True, quality=100, method=6, exact=True) | ||
| print(f"wrote {webp_output}") | ||
| if args.manifest_output: | ||
| manifest_output = Path(args.manifest_output).expanduser().resolve() | ||
| write_manifest( | ||
| manifest_output, Path(args.webp_output or args.output).expanduser().resolve() | ||
| ) | ||
| print(f"wrote {manifest_output}") | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Combine independent blind direction verdicts by strict per-cell majority.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import hashlib | ||
| import json | ||
| from collections import Counter | ||
| from pathlib import Path | ||
| def load_pairs(path: str) -> dict[str, dict[str, object]]: | ||
| payload = json.loads(Path(path).expanduser().resolve().read_text(encoding="utf-8")) | ||
| return {entry["pair"]: entry for entry in payload.get("pairs", [])} | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--verdicts", action="append", required=True) | ||
| parser.add_argument("--json-out", required=True) | ||
| args = parser.parse_args() | ||
| if len(args.verdicts) < 3 or len(args.verdicts) % 2 == 0: | ||
| raise SystemExit("provide an odd number of at least three verdict files") | ||
| reviews = [load_pairs(path) for path in args.verdicts] | ||
| pair_ids = set(reviews[0]) | ||
| if any(set(review) != pair_ids for review in reviews[1:]): | ||
| raise SystemExit("all verdict files must contain the same pair ids") | ||
| threshold = len(reviews) // 2 + 1 | ||
| combined = [] | ||
| for pair_id in reviews[0]: | ||
| result: dict[str, object] = {"pair": pair_id} | ||
| vote_summary: dict[str, object] = {} | ||
| for slot in ("A", "B"): | ||
| votes = [review[pair_id].get(slot) for review in reviews] | ||
| counts = Counter(votes) | ||
| direction, count = counts.most_common(1)[0] | ||
| result[slot] = direction if count >= threshold else "ambiguous" | ||
| vote_summary[slot] = dict(counts) | ||
| result["reason"] = "strict majority of independent blind reviews" | ||
| result["votes"] = vote_summary | ||
| combined.append(result) | ||
| output = Path(args.json_out).expanduser().resolve() | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| output.write_text( | ||
| json.dumps( | ||
| { | ||
| "review_count": len(args.verdicts), | ||
| "input_sha256": [ | ||
| hashlib.sha256( | ||
| Path(path).expanduser().resolve().read_bytes() | ||
| ).hexdigest() | ||
| for path in args.verdicts | ||
| ], | ||
| "pairs": combined, | ||
| }, | ||
| indent=2, | ||
| ) | ||
| + "\n", | ||
| encoding="utf-8", | ||
| ) | ||
| print(output) | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Compose or normalize a DimAgent pet spritesheet atlas.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| from pathlib import Path | ||
| from PIL import Image | ||
| COLUMNS = 8 | ||
| ROWS = 9 | ||
| CELL_WIDTH = 192 | ||
| CELL_HEIGHT = 208 | ||
| ATLAS_WIDTH = COLUMNS * CELL_WIDTH | ||
| ATLAS_HEIGHT = ROWS * CELL_HEIGHT | ||
| ATLAS_ASPECT_RATIO = ATLAS_WIDTH / ATLAS_HEIGHT | ||
| ROW_SPECS = [ | ||
| ("idle", 0, 6), | ||
| ("move_left", 1, 8), | ||
| ("move_right", 2, 8), | ||
| ("waving", 3, 4), | ||
| ("jumping", 4, 5), | ||
| ("failed", 5, 8), | ||
| ("waiting", 6, 6), | ||
| ("running", 7, 6), | ||
| ("review", 8, 6), | ||
| ] | ||
| IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"} | ||
| def image_files(path: Path) -> list[Path]: | ||
| return sorted(p for p in path.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES) | ||
| def find_row_frames(root: Path, state: str, row_index: int) -> list[Path]: | ||
| candidates = [ | ||
| root / state, | ||
| root / f"row-{row_index}", | ||
| root / f"row{row_index}", | ||
| root / f"{row_index}-{state}", | ||
| ] | ||
| for candidate in candidates: | ||
| if candidate.is_dir(): | ||
| files = image_files(candidate) | ||
| if files: | ||
| return files | ||
| globs = [ | ||
| f"{state}_*", | ||
| f"{state}-*", | ||
| f"row{row_index}_*", | ||
| f"row-{row_index}-*", | ||
| ] | ||
| files: list[Path] = [] | ||
| for pattern in globs: | ||
| files.extend(p for p in root.glob(pattern) if p.suffix.lower() in IMAGE_SUFFIXES) | ||
| return sorted(set(files)) | ||
| def paste_centered(atlas: Image.Image, source: Image.Image, row: int, column: int) -> None: | ||
| frame = source.convert("RGBA") | ||
| if frame.size != (CELL_WIDTH, CELL_HEIGHT): | ||
| frame.thumbnail((CELL_WIDTH, CELL_HEIGHT), Image.Resampling.LANCZOS) | ||
| left = column * CELL_WIDTH + (CELL_WIDTH - frame.width) // 2 | ||
| top = row * CELL_HEIGHT + (CELL_HEIGHT - frame.height) // 2 | ||
| atlas.alpha_composite(frame, (left, top)) | ||
| def compose_from_source_atlas(path: Path, resize_source: bool) -> Image.Image: | ||
| with Image.open(path) as opened: | ||
| source = opened.convert("RGBA") | ||
| if source.size != (ATLAS_WIDTH, ATLAS_HEIGHT): | ||
| if not resize_source: | ||
| raise SystemExit( | ||
| f"source atlas must be {ATLAS_WIDTH}x{ATLAS_HEIGHT}; got {source.width}x{source.height}" | ||
| ) | ||
| source_ratio = source.width / source.height | ||
| if abs(source_ratio - ATLAS_ASPECT_RATIO) > 0.02: | ||
| raise SystemExit( | ||
| "refusing to resize source atlas because its aspect ratio does not match " | ||
| f"the DimAgent atlas ratio {ATLAS_ASPECT_RATIO:.3f}; got {source_ratio:.3f}. " | ||
| "Generate exact atlas dimensions or use --frames-root." | ||
| ) | ||
| source = source.resize((ATLAS_WIDTH, ATLAS_HEIGHT), Image.Resampling.LANCZOS) | ||
| atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0)) | ||
| for _state, row, frame_count in ROW_SPECS: | ||
| for column in range(frame_count): | ||
| left = column * CELL_WIDTH | ||
| top = row * CELL_HEIGHT | ||
| cell = source.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT)) | ||
| atlas.alpha_composite(cell, (left, top)) | ||
| return atlas | ||
| def compose_from_frames(root: Path) -> Image.Image: | ||
| atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0)) | ||
| for state, row, frame_count in ROW_SPECS: | ||
| files = find_row_frames(root, state, row) | ||
| if len(files) < frame_count: | ||
| raise SystemExit( | ||
| f"{state} row needs {frame_count} frames, found {len(files)} under {root}" | ||
| ) | ||
| for column, frame_path in enumerate(files[:frame_count]): | ||
| with Image.open(frame_path) as frame: | ||
| paste_centered(atlas, frame, row, column) | ||
| return atlas | ||
| def clear_transparent_rgb(image: Image.Image) -> Image.Image: | ||
| rgba = image.convert("RGBA") | ||
| data = bytearray(rgba.tobytes()) | ||
| for index in range(0, len(data), 4): | ||
| if data[index + 3] == 0: | ||
| data[index] = 0 | ||
| data[index + 1] = 0 | ||
| data[index + 2] = 0 | ||
| return Image.frombytes("RGBA", rgba.size, bytes(data)) | ||
| def save_outputs(atlas: Image.Image, output: Path, webp_output: Path | None) -> None: | ||
| atlas = clear_transparent_rgb(atlas) | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| atlas.save(output) | ||
| if webp_output is not None: | ||
| webp_output.parent.mkdir(parents=True, exist_ok=True) | ||
| atlas.save( | ||
| webp_output, | ||
| format="WEBP", | ||
| lossless=True, | ||
| quality=100, | ||
| method=6, | ||
| exact=True, | ||
| ) | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| source = parser.add_mutually_exclusive_group(required=True) | ||
| source.add_argument("--source-atlas") | ||
| source.add_argument("--frames-root") | ||
| parser.add_argument("--output", required=True) | ||
| parser.add_argument("--webp-output") | ||
| parser.add_argument( | ||
| "--resize-source", | ||
| action="store_true", | ||
| help="Resize a lower-resolution source atlas only when it already has the DimAgent atlas aspect ratio.", | ||
| ) | ||
| args = parser.parse_args() | ||
| if args.source_atlas: | ||
| atlas = compose_from_source_atlas( | ||
| Path(args.source_atlas).expanduser().resolve(), args.resize_source | ||
| ) | ||
| else: | ||
| atlas = compose_from_frames(Path(args.frames_root).expanduser().resolve()) | ||
| save_outputs( | ||
| atlas, | ||
| Path(args.output).expanduser().resolve(), | ||
| Path(args.webp_output).expanduser().resolve() if args.webp_output else None, | ||
| ) | ||
| print(f"wrote {Path(args.output).expanduser().resolve()}") | ||
| if args.webp_output: | ||
| print(f"wrote {Path(args.webp_output).expanduser().resolve()}") | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Compose approved cardinal reference cells.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| from pathlib import Path | ||
| from PIL import Image | ||
| CELL_SIZE = (192, 208) | ||
| CARDINALS = ("000", "090", "180", "270") | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--anchors-dir", required=True) | ||
| parser.add_argument("--output", required=True) | ||
| args = parser.parse_args() | ||
| anchors_dir = Path(args.anchors_dir).expanduser().resolve() | ||
| output = Path(args.output).expanduser().resolve() | ||
| strip = Image.new("RGBA", (CELL_SIZE[0] * len(CARDINALS), CELL_SIZE[1])) | ||
| for index, direction in enumerate(CARDINALS): | ||
| path = anchors_dir / f"{direction}.png" | ||
| if not path.is_file(): | ||
| raise SystemExit(f"missing approved cardinal reference: {path}") | ||
| with Image.open(path) as opened: | ||
| reference = opened.convert("RGBA") | ||
| if reference.size != CELL_SIZE: | ||
| raise SystemExit(f"{path} is {reference.size}; expected {CELL_SIZE}") | ||
| if reference.getbbox() is None: | ||
| raise SystemExit(f"approved cardinal reference is empty: {path}") | ||
| strip.alpha_composite(reference, (index * CELL_SIZE[0], 0)) | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| strip.save(output) | ||
| print(f"wrote {output}") | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Conditionally derive move_left by mirroring the approved move_right strip.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import json | ||
| from datetime import datetime, timezone | ||
| from pathlib import Path | ||
| from PIL import Image, ImageOps | ||
| RUNNING_FRAME_COUNT = 8 | ||
| def load_manifest(run_dir: Path) -> dict[str, object]: | ||
| path = run_dir / "imagegen-jobs.json" | ||
| if not path.exists(): | ||
| raise SystemExit(f"job manifest not found: {path}") | ||
| return json.loads(path.read_text(encoding="utf-8")) | ||
| def job_list(manifest: dict[str, object]) -> list[dict[str, object]]: | ||
| jobs = manifest.get("jobs") | ||
| if not isinstance(jobs, list): | ||
| raise SystemExit("invalid imagegen-jobs.json: jobs must be a list") | ||
| return [job for job in jobs if isinstance(job, dict)] | ||
| def find_job(manifest: dict[str, object], job_id: str) -> dict[str, object]: | ||
| for job in job_list(manifest): | ||
| if job.get("id") == job_id: | ||
| return job | ||
| raise SystemExit(f"unknown job id: {job_id}") | ||
| def image_metadata(path: Path) -> dict[str, object]: | ||
| with Image.open(path) as image: | ||
| image.verify() | ||
| with Image.open(path) as image: | ||
| return { | ||
| "width": image.width, | ||
| "height": image.height, | ||
| "mode": image.mode, | ||
| "format": image.format, | ||
| } | ||
| def manifest_relative(path: Path, run_dir: Path) -> str: | ||
| return str(path.resolve().relative_to(run_dir.resolve())) | ||
| def mirror_strip_preserving_frame_order( | ||
| source: Image.Image, | ||
| frame_count: int = RUNNING_FRAME_COUNT, | ||
| ) -> Image.Image: | ||
| rgba = source.convert("RGBA") | ||
| mirrored = Image.new("RGBA", rgba.size, (0, 0, 0, 0)) | ||
| slot_width = rgba.width / frame_count | ||
| for index in range(frame_count): | ||
| left = round(index * slot_width) | ||
| right = round((index + 1) * slot_width) | ||
| mirrored.alpha_composite( | ||
| ImageOps.mirror(rgba.crop((left, 0, right, rgba.height))), | ||
| (left, 0), | ||
| ) | ||
| return mirrored | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--run-dir", required=True) | ||
| parser.add_argument( | ||
| "--confirm-appropriate-mirror", | ||
| action="store_true", | ||
| help="Required after visually confirming the rightward strip can be mirrored without identity/prop issues.", | ||
| ) | ||
| parser.add_argument( | ||
| "--decision-note", | ||
| required=True, | ||
| help="Short note explaining why mirroring is acceptable for this pet.", | ||
| ) | ||
| parser.add_argument("--force", action="store_true") | ||
| args = parser.parse_args() | ||
| if not args.confirm_appropriate_mirror: | ||
| raise SystemExit("refusing to mirror without --confirm-appropriate-mirror") | ||
| if not args.decision_note.strip(): | ||
| raise SystemExit("--decision-note must explain why mirroring is appropriate") | ||
| run_dir = Path(args.run_dir).expanduser().resolve() | ||
| manifest_path = run_dir / "imagegen-jobs.json" | ||
| manifest = load_manifest(run_dir) | ||
| right_job = find_job(manifest, "move_right") | ||
| left_job = find_job(manifest, "move_left") | ||
| if right_job.get("status") != "complete": | ||
| raise SystemExit("move_right must be complete before deriving move_left") | ||
| mirror_policy = left_job.get("mirror_policy") | ||
| if ( | ||
| not isinstance(mirror_policy, dict) | ||
| or mirror_policy.get("may_derive_from") != "move_right" | ||
| ): | ||
| raise SystemExit("move_left is not configured for conditional mirroring") | ||
| source = run_dir / "decoded" / "move_right.png" | ||
| output = run_dir / "decoded" / "move_left.png" | ||
| if not source.is_file(): | ||
| raise SystemExit(f"move_right decoded strip not found: {source}") | ||
| if output.exists() and not args.force: | ||
| raise SystemExit(f"{output} already exists; pass --force to replace it") | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| with Image.open(source) as image: | ||
| mirrored = mirror_strip_preserving_frame_order(image) | ||
| mirrored.save(output) | ||
| left_job["status"] = "complete" | ||
| left_job["source_path"] = manifest_relative(source, run_dir) | ||
| left_job["derived_from"] = "move_right" | ||
| left_job["completed_at"] = datetime.now(timezone.utc).isoformat() | ||
| left_job["metadata"] = image_metadata(output) | ||
| left_job["mirror_decision"] = { | ||
| "approved": True, | ||
| "approved_at": left_job["completed_at"], | ||
| "note": args.decision_note.strip(), | ||
| "transform": "framewise-horizontal-mirror-preserving-order", | ||
| } | ||
| for key in [ | ||
| "last_error", | ||
| "repair_reason", | ||
| "queued_at", | ||
| ]: | ||
| left_job.pop(key, None) | ||
| manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") | ||
| print( | ||
| json.dumps( | ||
| { | ||
| "ok": True, | ||
| "job_id": "move_left", | ||
| "derived_from": "move_right", | ||
| "output": str(output), | ||
| "decision_note": args.decision_note.strip(), | ||
| "transform": "framewise-horizontal-mirror-preserving-order", | ||
| }, | ||
| indent=2, | ||
| ) | ||
| ) | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Remove chroma-key matte contamination from transparent sprite edges.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import json | ||
| import re | ||
| from pathlib import Path | ||
| from PIL import Image, ImageFilter | ||
| CELL_WIDTH = 192 | ||
| CELL_HEIGHT = 208 | ||
| ALGORITHM = "edge-local-chroma-spill-suppression" | ||
| def parse_hex_color(value: str) -> tuple[int, int, int]: | ||
| if not re.fullmatch(r"#[0-9a-fA-F]{6}", value): | ||
| raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB") | ||
| return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5)) | ||
| def srgb_to_linear(value: float) -> float: | ||
| if value <= 0.04045: | ||
| return value / 12.92 | ||
| return ((value + 0.055) / 1.055) ** 2.4 | ||
| def linear_to_srgb(value: float) -> float: | ||
| if value <= 0.0031308: | ||
| return value * 12.92 | ||
| return 1.055 * value ** (1 / 2.4) - 0.055 | ||
| def edge_band(alpha: Image.Image, radius: int) -> list[bool]: | ||
| visible = [value > 0 for value in alpha.getdata()] | ||
| transparent = Image.new("L", alpha.size) | ||
| transparent.putdata([0 if value else 255 for value in visible]) | ||
| expanded = transparent.filter(ImageFilter.MaxFilter(radius * 2 + 1)) | ||
| return [is_visible and nearby > 0 for is_visible, nearby in zip(visible, expanded.getdata())] | ||
| def atlas_edge_band(alpha: Image.Image, radius: int) -> list[bool]: | ||
| width, height = alpha.size | ||
| boundary = edge_band(alpha, radius) | ||
| if width % CELL_WIDTH or height % CELL_HEIGHT: | ||
| return boundary | ||
| for top in range(0, height, CELL_HEIGHT): | ||
| for left in range(0, width, CELL_WIDTH): | ||
| cell = alpha.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT)) | ||
| for index, is_boundary in enumerate(edge_band(cell, radius)): | ||
| if is_boundary: | ||
| x = left + index % CELL_WIDTH | ||
| y = top + index // CELL_WIDTH | ||
| boundary[y * width + x] = True | ||
| return boundary | ||
| def chroma_similarity( | ||
| color: tuple[float, float, float], | ||
| key: tuple[float, float, float], | ||
| ) -> float: | ||
| color_mean = sum(color) / 3 | ||
| key_mean = sum(key) / 3 | ||
| color_chroma = tuple(channel - color_mean for channel in color) | ||
| key_chroma = tuple(channel - key_mean for channel in key) | ||
| denominator = sum(channel * channel for channel in color_chroma) * sum( | ||
| channel * channel for channel in key_chroma | ||
| ) | ||
| if denominator <= 1e-12: | ||
| return -1 | ||
| return ( | ||
| sum( | ||
| color_channel * key_channel | ||
| for color_channel, key_channel in zip(color_chroma, key_chroma) | ||
| ) | ||
| / denominator**0.5 | ||
| ) | ||
| def chroma_saturation(color: tuple[float, float, float]) -> float: | ||
| maximum = max(color) | ||
| if maximum <= 0: | ||
| return 0 | ||
| return (maximum - min(color)) / maximum | ||
| def suppress_boundary_spill( | ||
| pixels: list[tuple[int, int, int, int]], | ||
| *, | ||
| size: tuple[int, int], | ||
| boundary: list[bool], | ||
| key_linear: tuple[float, float, float], | ||
| strength: float, | ||
| edge_radius: int, | ||
| spill_tolerance: float, | ||
| minimum_saturation: float, | ||
| ) -> tuple[list[tuple[int, int, int, int]], list[bool]]: | ||
| width, height = size | ||
| colors_linear = [ | ||
| tuple(srgb_to_linear(channel / 255) for channel in pixel[:3]) for pixel in pixels | ||
| ] | ||
| similarity_threshold = 1 - min(spill_tolerance, 1) | ||
| pending = [ | ||
| pixel[3] > 0 | ||
| and is_boundary | ||
| and ( | ||
| pixel[3] < 250 | ||
| or ( | ||
| chroma_saturation(color) >= minimum_saturation | ||
| and chroma_similarity(color, key_linear) >= similarity_threshold | ||
| ) | ||
| ) | ||
| for pixel, color, is_boundary in zip(pixels, colors_linear, boundary) | ||
| ] | ||
| filled = [pixel[3] > 0 and not is_pending for pixel, is_pending in zip(pixels, pending)] | ||
| output = pixels.copy() | ||
| suppressed = [False] * len(pixels) | ||
| cell_width = CELL_WIDTH if width % CELL_WIDTH == 0 else width | ||
| cell_height = CELL_HEIGHT if height % CELL_HEIGHT == 0 else height | ||
| for _ in range(edge_radius * 2 + 1): | ||
| updates: list[tuple[int, tuple[float, float, float]]] = [] | ||
| for index, is_pending in enumerate(pending): | ||
| if not is_pending: | ||
| continue | ||
| x = index % width | ||
| y = index // width | ||
| cell_left = x // cell_width * cell_width | ||
| cell_top = y // cell_height * cell_height | ||
| references = [] | ||
| for neighbor_y in range( | ||
| max(cell_top, y - 1), | ||
| min(cell_top + cell_height, y + 2), | ||
| ): | ||
| for neighbor_x in range( | ||
| max(cell_left, x - 1), | ||
| min(cell_left + cell_width, x + 2), | ||
| ): | ||
| neighbor = neighbor_y * width + neighbor_x | ||
| if neighbor != index and filled[neighbor]: | ||
| references.append(colors_linear[neighbor]) | ||
| if not references: | ||
| continue | ||
| reference = tuple( | ||
| sum(color[channel] for color in references) / len(references) | ||
| for channel in range(3) | ||
| ) | ||
| observed = colors_linear[index] | ||
| cleaned = tuple( | ||
| channel + (reference_channel - channel) * strength | ||
| for channel, reference_channel in zip(observed, reference) | ||
| ) | ||
| updates.append((index, cleaned)) | ||
| if not updates: | ||
| break | ||
| for index, cleaned in updates: | ||
| colors_linear[index] = cleaned | ||
| filled[index] = True | ||
| pending[index] = False | ||
| output[index] = ( | ||
| *(round(linear_to_srgb(min(1, max(0, channel))) * 255) for channel in cleaned), | ||
| pixels[index][3], | ||
| ) | ||
| suppressed[index] = output[index] != pixels[index] | ||
| for index, is_pending in enumerate(pending): | ||
| if not is_pending: | ||
| continue | ||
| observed = colors_linear[index] | ||
| luminance = sum(observed) / 3 | ||
| cleaned = tuple(channel + (luminance - channel) * strength for channel in observed) | ||
| output[index] = ( | ||
| *(round(linear_to_srgb(min(1, max(0, channel))) * 255) for channel in cleaned), | ||
| pixels[index][3], | ||
| ) | ||
| suppressed[index] = output[index] != pixels[index] | ||
| return output, suppressed | ||
| def decontaminate_image( | ||
| image: Image.Image, | ||
| *, | ||
| chroma_key: tuple[int, int, int], | ||
| strength: float = 1, | ||
| edge_radius: int = 5, | ||
| spill_tolerance: float = 0.15, | ||
| minimum_saturation: float = 0.1, | ||
| ) -> tuple[Image.Image, dict[str, object]]: | ||
| if not 0 <= strength <= 1: | ||
| raise ValueError("strength must be between 0 and 1") | ||
| if edge_radius < 1: | ||
| raise ValueError("edge_radius must be at least 1") | ||
| if spill_tolerance < 0: | ||
| raise ValueError("spill_tolerance must not be negative") | ||
| if minimum_saturation < 0: | ||
| raise ValueError("minimum_saturation must not be negative") | ||
| rgba = image.convert("RGBA") | ||
| width, _ = rgba.size | ||
| source = list(rgba.getdata()) | ||
| boundary = atlas_edge_band(rgba.getchannel("A"), edge_radius) | ||
| key_linear = tuple(srgb_to_linear(channel / 255) for channel in chroma_key) | ||
| output_pixels, suppressed = suppress_boundary_spill( | ||
| source, | ||
| size=rgba.size, | ||
| boundary=boundary, | ||
| key_linear=key_linear, | ||
| strength=strength, | ||
| edge_radius=edge_radius, | ||
| spill_tolerance=spill_tolerance, | ||
| minimum_saturation=minimum_saturation, | ||
| ) | ||
| output_pixels = [ | ||
| (0, 0, 0, 0) if pixel[3] == 0 else output_pixel | ||
| for pixel, output_pixel in zip(source, output_pixels) | ||
| ] | ||
| decontaminated_pixels = sum( | ||
| is_suppressed and pixel[3] < 255 for pixel, is_suppressed in zip(source, suppressed) | ||
| ) | ||
| spill_suppressed_pixels = sum(suppressed) | ||
| changed_by_cell: dict[str, int] = {} | ||
| for index, (source_pixel, output_pixel) in enumerate(zip(source, output_pixels)): | ||
| if output_pixel != source_pixel: | ||
| x = index % width | ||
| y = index // width | ||
| cell = f"r{y // CELL_HEIGHT}c{x // CELL_WIDTH}" | ||
| changed_by_cell[cell] = changed_by_cell.get(cell, 0) + 1 | ||
| output = Image.new("RGBA", rgba.size) | ||
| output.putdata(output_pixels) | ||
| return output, { | ||
| "algorithm": ALGORITHM, | ||
| "strength": strength, | ||
| "edge_radius": edge_radius, | ||
| "spill_tolerance": spill_tolerance, | ||
| "minimum_saturation": minimum_saturation, | ||
| "changed_pixels": sum(changed_by_cell.values()), | ||
| "decontaminated_pixels": decontaminated_pixels, | ||
| "spill_suppressed_pixels": spill_suppressed_pixels, | ||
| "rejected_pixels": 0, | ||
| "changed_by_cell": dict( | ||
| sorted(changed_by_cell.items(), key=lambda item: item[1], reverse=True) | ||
| ), | ||
| "alpha_preserved": True, | ||
| } | ||
| def save_image(image: Image.Image, path: Path) -> None: | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| if path.suffix.lower() == ".webp": | ||
| image.save(path, format="WEBP", lossless=True, quality=100, method=6, exact=True) | ||
| else: | ||
| image.save(path) | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("input") | ||
| parser.add_argument("--output", required=True) | ||
| parser.add_argument("--webp-output") | ||
| parser.add_argument("--json-out") | ||
| parser.add_argument("--chroma-key", required=True) | ||
| parser.add_argument("--strength", type=float, default=1) | ||
| parser.add_argument("--edge-radius", type=int, default=5) | ||
| parser.add_argument("--spill-tolerance", type=float, default=0.15) | ||
| parser.add_argument("--minimum-saturation", type=float, default=0.1) | ||
| args = parser.parse_args() | ||
| input_path = Path(args.input).expanduser().resolve() | ||
| with Image.open(input_path) as opened: | ||
| cleaned, report = decontaminate_image( | ||
| opened, | ||
| chroma_key=parse_hex_color(args.chroma_key), | ||
| strength=args.strength, | ||
| edge_radius=args.edge_radius, | ||
| spill_tolerance=args.spill_tolerance, | ||
| minimum_saturation=args.minimum_saturation, | ||
| ) | ||
| output_path = Path(args.output).expanduser().resolve() | ||
| save_image(cleaned, output_path) | ||
| if args.webp_output: | ||
| save_image(cleaned, Path(args.webp_output).expanduser().resolve()) | ||
| result = { | ||
| "ok": True, | ||
| "input": str(input_path), | ||
| "output": str(output_path), | ||
| "chroma_key": args.chroma_key.upper(), | ||
| **report, | ||
| } | ||
| if args.json_out: | ||
| json_path = Path(args.json_out).expanduser().resolve() | ||
| json_path.parent.mkdir(parents=True, exist_ok=True) | ||
| json_path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") | ||
| print(json.dumps(result, indent=2)) | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Extract and validate a four-pose cardinal anchor strip.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import json | ||
| import math | ||
| import re | ||
| from pathlib import Path | ||
| from PIL import Image | ||
| CARDINALS = ["000", "090", "180", "270"] | ||
| CELL_WIDTH = 192 | ||
| CELL_HEIGHT = 208 | ||
| def parse_hex_color(value: str) -> tuple[int, int, int]: | ||
| if not re.fullmatch(r"#[0-9a-fA-F]{6}", value): | ||
| raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB") | ||
| return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5)) | ||
| def color_distance( | ||
| red: int, | ||
| green: int, | ||
| blue: int, | ||
| key: tuple[int, int, int], | ||
| ) -> float: | ||
| return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2) | ||
| def remove_chroma_background( | ||
| image: Image.Image, | ||
| chroma_key: tuple[int, int, int], | ||
| threshold: float, | ||
| ) -> Image.Image: | ||
| rgba = image.convert("RGBA") | ||
| pixels = rgba.load() | ||
| for y in range(rgba.height): | ||
| for x in range(rgba.width): | ||
| red, green, blue, _alpha = pixels[x, y] | ||
| if color_distance(red, green, blue, chroma_key) <= threshold: | ||
| pixels[x, y] = (0, 0, 0, 0) | ||
| return rgba | ||
| def alpha_count(image: Image.Image) -> int: | ||
| alpha = image if image.mode == "L" else image.getchannel("A") | ||
| return sum(alpha.histogram()[1:]) | ||
| def edge_alpha_count(image: Image.Image, margin: int) -> int: | ||
| alpha = image.getchannel("A") | ||
| width, height = alpha.size | ||
| return sum( | ||
| alpha_count(alpha.crop(box)) | ||
| for box in ( | ||
| (0, 0, width, margin), | ||
| (0, height - margin, width, height), | ||
| (0, 0, margin, height), | ||
| (width - margin, 0, width, height), | ||
| ) | ||
| ) | ||
| def fit_to_cell(image: Image.Image) -> Image.Image: | ||
| bbox = image.getbbox() | ||
| target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) | ||
| if bbox is None: | ||
| return target | ||
| sprite = image.crop(bbox) | ||
| scale = min((CELL_WIDTH - 10) / sprite.width, (CELL_HEIGHT - 10) / sprite.height, 1.0) | ||
| if scale != 1.0: | ||
| sprite = sprite.resize( | ||
| (max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))), | ||
| Image.Resampling.LANCZOS, | ||
| ) | ||
| target.alpha_composite( | ||
| sprite, | ||
| ((CELL_WIDTH - sprite.width) // 2, (CELL_HEIGHT - sprite.height) // 2), | ||
| ) | ||
| return target | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--strip", required=True) | ||
| parser.add_argument("--output-dir", required=True) | ||
| parser.add_argument("--json-out", required=True) | ||
| parser.add_argument("--chroma-key", required=True) | ||
| parser.add_argument("--chroma-threshold", type=float, default=96.0) | ||
| parser.add_argument("--edge-margin", type=int, default=2) | ||
| parser.add_argument("--edge-pixel-threshold", type=int, default=24) | ||
| parser.add_argument("--min-used-pixels", type=int, default=400) | ||
| args = parser.parse_args() | ||
| strip_path = Path(args.strip).expanduser().resolve() | ||
| output_dir = Path(args.output_dir).expanduser().resolve() | ||
| output_dir.mkdir(parents=True, exist_ok=True) | ||
| with Image.open(strip_path) as opened: | ||
| strip = remove_chroma_background( | ||
| opened, | ||
| parse_hex_color(args.chroma_key), | ||
| args.chroma_threshold, | ||
| ) | ||
| slot_width = strip.width / len(CARDINALS) | ||
| anchors = [] | ||
| errors = [] | ||
| for index, label in enumerate(CARDINALS): | ||
| left = round(index * slot_width) | ||
| right = round((index + 1) * slot_width) | ||
| source_cell = strip.crop((left, 0, right, strip.height)) | ||
| used_pixels = alpha_count(source_cell) | ||
| edge_pixels = edge_alpha_count(source_cell, args.edge_margin) | ||
| output = output_dir / f"{label}.png" | ||
| fit_to_cell(source_cell).save(output) | ||
| if used_pixels < args.min_used_pixels: | ||
| errors.append(f"{label} is empty or too sparse ({used_pixels} pixels)") | ||
| if edge_pixels > args.edge_pixel_threshold: | ||
| errors.append( | ||
| f"{label} has {edge_pixels} non-transparent pixels near its source slot edge" | ||
| ) | ||
| anchors.append( | ||
| { | ||
| "direction": label, | ||
| "source_box": [left, 0, right, strip.height], | ||
| "used_pixels": used_pixels, | ||
| "edge_pixels": edge_pixels, | ||
| "output": str(output), | ||
| } | ||
| ) | ||
| result = { | ||
| "ok": not errors, | ||
| "strip": str(strip_path), | ||
| "directions": CARDINALS, | ||
| "errors": errors, | ||
| "anchors": anchors, | ||
| } | ||
| json_out = Path(args.json_out).expanduser().resolve() | ||
| json_out.parent.mkdir(parents=True, exist_ok=True) | ||
| json_out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") | ||
| print(json.dumps({key: value for key, value in result.items() if key != "anchors"}, indent=2)) | ||
| raise SystemExit(0 if result["ok"] else 1) | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Extract generated horizontal row strips into 192x208 sprite frames.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import json | ||
| import math | ||
| import re | ||
| from pathlib import Path | ||
| from PIL import Image | ||
| CELL_WIDTH = 192 | ||
| CELL_HEIGHT = 208 | ||
| ROW_FRAME_COUNTS = { | ||
| "idle": 6, | ||
| "move_left": 8, | ||
| "move_right": 8, | ||
| "waving": 4, | ||
| "jumping": 5, | ||
| "failed": 8, | ||
| "waiting": 6, | ||
| "running": 6, | ||
| "review": 6, | ||
| } | ||
| def parse_states(raw: str) -> list[str]: | ||
| if raw.strip().lower() == "all": | ||
| return list(ROW_FRAME_COUNTS) | ||
| states = [item.strip() for item in raw.split(",") if item.strip()] | ||
| unknown = sorted(set(states) - set(ROW_FRAME_COUNTS)) | ||
| if unknown: | ||
| raise SystemExit(f"unknown state(s): {', '.join(unknown)}") | ||
| return states | ||
| def parse_hex_color(value: str) -> tuple[int, int, int]: | ||
| if not re.fullmatch(r"#[0-9a-fA-F]{6}", value): | ||
| raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB") | ||
| return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5)) | ||
| def load_chroma_key(decoded_dir: Path, override: str | None) -> tuple[int, int, int]: | ||
| if override: | ||
| return parse_hex_color(override) | ||
| request_path = decoded_dir.parent / "pet_request.json" | ||
| if request_path.is_file(): | ||
| request = json.loads(request_path.read_text(encoding="utf-8")) | ||
| chroma_key = request.get("chroma_key") | ||
| if isinstance(chroma_key, dict) and isinstance(chroma_key.get("hex"), str): | ||
| return parse_hex_color(chroma_key["hex"]) | ||
| return parse_hex_color("#00FF00") | ||
| def color_distance( | ||
| red: int, | ||
| green: int, | ||
| blue: int, | ||
| key: tuple[int, int, int], | ||
| ) -> float: | ||
| return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2) | ||
| def remove_chroma_background( | ||
| image: Image.Image, | ||
| chroma_key: tuple[int, int, int], | ||
| threshold: float, | ||
| ) -> Image.Image: | ||
| rgba = image.convert("RGBA") | ||
| pixels = rgba.load() | ||
| for y in range(rgba.height): | ||
| for x in range(rgba.width): | ||
| red, green, blue, alpha = pixels[x, y] | ||
| if color_distance(red, green, blue, chroma_key) <= threshold: | ||
| pixels[x, y] = (0, 0, 0, 0) | ||
| return rgba | ||
| def fit_to_cell(image: Image.Image) -> Image.Image: | ||
| bbox = image.getbbox() | ||
| target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) | ||
| if bbox is None: | ||
| return target | ||
| sprite = image.crop(bbox) | ||
| max_width = CELL_WIDTH - 10 | ||
| max_height = CELL_HEIGHT - 10 | ||
| scale = min(max_width / sprite.width, max_height / sprite.height, 1.0) | ||
| if scale != 1.0: | ||
| sprite = sprite.resize( | ||
| (max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))), | ||
| Image.Resampling.LANCZOS, | ||
| ) | ||
| left = (CELL_WIDTH - sprite.width) // 2 | ||
| top = (CELL_HEIGHT - sprite.height) // 2 | ||
| target.alpha_composite(sprite, (left, top)) | ||
| return target | ||
| def fit_viewport_to_cell(image: Image.Image) -> Image.Image: | ||
| target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) | ||
| if image.getbbox() is None: | ||
| return target | ||
| viewport = image.copy() | ||
| max_width = CELL_WIDTH - 10 | ||
| max_height = CELL_HEIGHT - 10 | ||
| scale = min(max_width / viewport.width, max_height / viewport.height, 1.0) | ||
| if scale != 1.0: | ||
| viewport = viewport.resize( | ||
| (max(1, round(viewport.width * scale)), max(1, round(viewport.height * scale))), | ||
| Image.Resampling.LANCZOS, | ||
| ) | ||
| left = (CELL_WIDTH - viewport.width) // 2 | ||
| top = (CELL_HEIGHT - viewport.height) // 2 | ||
| target.alpha_composite(viewport, (left, top)) | ||
| return target | ||
| def connected_components(image: Image.Image) -> list[dict[str, object]]: | ||
| alpha = image.getchannel("A") | ||
| width, height = image.size | ||
| data = alpha.tobytes() | ||
| visited = bytearray(width * height) | ||
| components: list[dict[str, object]] = [] | ||
| for start, alpha_value in enumerate(data): | ||
| if alpha_value <= 16 or visited[start]: | ||
| continue | ||
| stack = [start] | ||
| visited[start] = 1 | ||
| pixels: list[int] = [] | ||
| min_x = width | ||
| min_y = height | ||
| max_x = 0 | ||
| max_y = 0 | ||
| while stack: | ||
| current = stack.pop() | ||
| pixels.append(current) | ||
| x = current % width | ||
| y = current // width | ||
| min_x = min(min_x, x) | ||
| min_y = min(min_y, y) | ||
| max_x = max(max_x, x) | ||
| max_y = max(max_y, y) | ||
| if x > 0: | ||
| neighbor = current - 1 | ||
| if not visited[neighbor] and data[neighbor] > 16: | ||
| visited[neighbor] = 1 | ||
| stack.append(neighbor) | ||
| if x + 1 < width: | ||
| neighbor = current + 1 | ||
| if not visited[neighbor] and data[neighbor] > 16: | ||
| visited[neighbor] = 1 | ||
| stack.append(neighbor) | ||
| if y > 0: | ||
| neighbor = current - width | ||
| if not visited[neighbor] and data[neighbor] > 16: | ||
| visited[neighbor] = 1 | ||
| stack.append(neighbor) | ||
| if y + 1 < height: | ||
| neighbor = current + width | ||
| if not visited[neighbor] and data[neighbor] > 16: | ||
| visited[neighbor] = 1 | ||
| stack.append(neighbor) | ||
| components.append( | ||
| { | ||
| "pixels": pixels, | ||
| "area": len(pixels), | ||
| "bbox": (min_x, min_y, max_x + 1, max_y + 1), | ||
| "center_x": (min_x + max_x + 1) / 2, | ||
| } | ||
| ) | ||
| return components | ||
| def component_group_image( | ||
| source: Image.Image, | ||
| components: list[dict[str, object]], | ||
| padding: int = 4, | ||
| ) -> Image.Image: | ||
| width, height = source.size | ||
| min_x = max(0, min(component["bbox"][0] for component in components) - padding) | ||
| min_y = max(0, min(component["bbox"][1] for component in components) - padding) | ||
| max_x = min(width, max(component["bbox"][2] for component in components) + padding) | ||
| max_y = min(height, max(component["bbox"][3] for component in components) + padding) | ||
| output = Image.new("RGBA", (max_x - min_x, max_y - min_y), (0, 0, 0, 0)) | ||
| source_pixels = source.load() | ||
| output_pixels = output.load() | ||
| for component in components: | ||
| for pixel_index in component["pixels"]: | ||
| x = pixel_index % width | ||
| y = pixel_index // width | ||
| output_pixels[x - min_x, y - min_y] = source_pixels[x, y] | ||
| return output | ||
| def component_frame_groups( | ||
| strip: Image.Image, | ||
| frame_count: int, | ||
| ) -> list[list[dict[str, object]]] | None: | ||
| components = connected_components(strip) | ||
| if not components: | ||
| return None | ||
| largest_area = max(component["area"] for component in components) | ||
| seed_threshold = max(120, largest_area * 0.20) | ||
| seeds = [component for component in components if component["area"] >= seed_threshold] | ||
| if len(seeds) < frame_count: | ||
| seeds = sorted(components, key=lambda component: component["area"], reverse=True)[ | ||
| :frame_count | ||
| ] | ||
| if len(seeds) < frame_count: | ||
| return None | ||
| seeds = sorted( | ||
| sorted(seeds, key=lambda component: component["area"], reverse=True)[:frame_count], | ||
| key=lambda component: component["center_x"], | ||
| ) | ||
| seed_ids = {id(seed) for seed in seeds} | ||
| groups: list[list[dict[str, object]]] = [[seed] for seed in seeds] | ||
| noise_threshold = max(12, largest_area * 0.002) | ||
| for component in components: | ||
| if id(component) in seed_ids or component["area"] < noise_threshold: | ||
| continue | ||
| nearest_index = min( | ||
| range(len(seeds)), | ||
| key=lambda index: abs(seeds[index]["center_x"] - component["center_x"]), | ||
| ) | ||
| groups[nearest_index].append(component) | ||
| return groups | ||
| def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image.Image] | None: | ||
| groups = component_frame_groups(strip, frame_count) | ||
| if groups is None: | ||
| return None | ||
| return [fit_to_cell(component_group_image(strip, group)) for group in groups] | ||
| def component_bounds(components: list[dict[str, object]]) -> tuple[int, int, int, int]: | ||
| return ( | ||
| min(component["bbox"][0] for component in components), | ||
| min(component["bbox"][1] for component in components), | ||
| max(component["bbox"][2] for component in components), | ||
| max(component["bbox"][3] for component in components), | ||
| ) | ||
| def extract_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]: | ||
| slot_width = strip.width / frame_count | ||
| frames = [] | ||
| for index in range(frame_count): | ||
| left = round(index * slot_width) | ||
| right = round((index + 1) * slot_width) | ||
| crop = strip.crop((left, 0, right, strip.height)) | ||
| frames.append(fit_to_cell(crop)) | ||
| return frames | ||
| def extract_stable_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]: | ||
| groups = component_frame_groups(strip, frame_count) | ||
| padding = 4 | ||
| if groups is not None: | ||
| bboxes = [component_bounds(group) for group in groups] | ||
| shared_top = max(0, min(bbox[1] for bbox in bboxes) - padding) | ||
| shared_bottom = min(strip.height, max(bbox[3] for bbox in bboxes) + padding) | ||
| viewport_width = max(bbox[2] - bbox[0] for bbox in bboxes) + padding * 2 | ||
| viewport_height = max(1, shared_bottom - shared_top) | ||
| frames = [] | ||
| for group, bbox in zip(groups, bboxes): | ||
| grouped = component_group_image(strip, group, padding=padding) | ||
| grouped_top = max(0, bbox[1] - padding) | ||
| viewport = Image.new( | ||
| "RGBA", | ||
| (viewport_width, viewport_height), | ||
| (0, 0, 0, 0), | ||
| ) | ||
| left = (viewport_width - grouped.width) // 2 | ||
| viewport.alpha_composite(grouped, (left, grouped_top - shared_top)) | ||
| frames.append(fit_viewport_to_cell(viewport)) | ||
| return frames | ||
| bbox = strip.getbbox() | ||
| if bbox is None: | ||
| return [ | ||
| Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) for _ in range(frame_count) | ||
| ] | ||
| shared_top = max(0, bbox[1] - padding) | ||
| shared_bottom = min(strip.height, bbox[3] + padding) | ||
| slot_width = strip.width / frame_count | ||
| frames = [] | ||
| for index in range(frame_count): | ||
| left = round(index * slot_width) | ||
| right = round((index + 1) * slot_width) | ||
| crop = strip.crop((left, shared_top, right, shared_bottom)) | ||
| frames.append(fit_viewport_to_cell(crop)) | ||
| return frames | ||
| def extract_state( | ||
| strip_path: Path, | ||
| state: str, | ||
| output_root: Path, | ||
| chroma_key: tuple[int, int, int], | ||
| threshold: float, | ||
| method: str, | ||
| ) -> dict[str, object]: | ||
| frame_count = ROW_FRAME_COUNTS[state] | ||
| with Image.open(strip_path) as opened: | ||
| strip = remove_chroma_background(opened, chroma_key, threshold) | ||
| state_dir = output_root / state | ||
| state_dir.mkdir(parents=True, exist_ok=True) | ||
| frames = None | ||
| used_method = method | ||
| if method in {"auto", "components"}: | ||
| frames = extract_component_frames(strip, frame_count) | ||
| if frames is None and method == "components": | ||
| raise SystemExit(f"could not find {frame_count} sprite components in {strip_path}") | ||
| if frames is not None: | ||
| used_method = "components" | ||
| if frames is None: | ||
| if method == "stable-slots": | ||
| frames = extract_stable_slot_frames(strip, frame_count) | ||
| used_method = "stable-slots" | ||
| else: | ||
| frames = extract_slot_frames(strip, frame_count) | ||
| used_method = "slots" | ||
| outputs = [] | ||
| for index, frame in enumerate(frames): | ||
| output = state_dir / f"{index:02d}.png" | ||
| frame.save(output) | ||
| outputs.append(str(output)) | ||
| return {"state": state, "frames": outputs, "method": used_method} | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--decoded-dir", required=True) | ||
| parser.add_argument("--output-dir", required=True) | ||
| parser.add_argument("--states", default="all") | ||
| parser.add_argument("--chroma-key", help="Override chroma key as #RRGGBB.") | ||
| parser.add_argument("--key-threshold", type=float, default=96.0) | ||
| parser.add_argument( | ||
| "--method", | ||
| choices=("auto", "components", "slots", "stable-slots"), | ||
| default="auto", | ||
| help="Use connected sprite components when possible, raw equal slots, or row-stable slot viewports.", | ||
| ) | ||
| args = parser.parse_args() | ||
| decoded_dir = Path(args.decoded_dir).expanduser().resolve() | ||
| output_dir = Path(args.output_dir).expanduser().resolve() | ||
| chroma_key = load_chroma_key(decoded_dir, args.chroma_key) | ||
| states = parse_states(args.states) | ||
| manifest = [] | ||
| for state in states: | ||
| strip_path = decoded_dir / f"{state}.png" | ||
| if not strip_path.is_file(): | ||
| raise SystemExit(f"missing generated strip for {state}: {strip_path}") | ||
| manifest.append( | ||
| extract_state( | ||
| strip_path, | ||
| state, | ||
| output_dir, | ||
| chroma_key, | ||
| args.key_threshold, | ||
| args.method, | ||
| ) | ||
| ) | ||
| (output_dir / "frames-manifest.json").write_text( | ||
| json.dumps( | ||
| { | ||
| "ok": True, | ||
| "chroma_key": { | ||
| "hex": f"#{chroma_key[0]:02X}{chroma_key[1]:02X}{chroma_key[2]:02X}", | ||
| "rgb": list(chroma_key), | ||
| "threshold": args.key_threshold, | ||
| }, | ||
| "rows": manifest, | ||
| }, | ||
| indent=2, | ||
| ) | ||
| + "\n", | ||
| encoding="utf-8", | ||
| ) | ||
| print(json.dumps({"ok": True, "frames_root": str(output_dir), "states": states}, indent=2)) | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Inspect extracted DimAgent pet frames before atlas composition.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import json | ||
| import math | ||
| from pathlib import Path | ||
| from statistics import median | ||
| from PIL import Image | ||
| CELL_WIDTH = 192 | ||
| CELL_HEIGHT = 208 | ||
| ROW_FRAME_COUNTS = { | ||
| "idle": 6, | ||
| "move_left": 8, | ||
| "move_right": 8, | ||
| "waving": 4, | ||
| "jumping": 5, | ||
| "failed": 8, | ||
| "waiting": 6, | ||
| "running": 6, | ||
| "review": 6, | ||
| } | ||
| IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"} | ||
| def parse_states(raw: str) -> list[str]: | ||
| if raw.strip().lower() == "all": | ||
| return list(ROW_FRAME_COUNTS) | ||
| states = [item.strip() for item in raw.split(",") if item.strip()] | ||
| unknown = sorted(set(states) - set(ROW_FRAME_COUNTS)) | ||
| if unknown: | ||
| raise SystemExit(f"unknown state(s): {', '.join(unknown)}") | ||
| return states | ||
| def alpha_nonzero_count(image: Image.Image) -> int: | ||
| alpha = image if image.mode == "L" else image.getchannel("A") | ||
| return sum(alpha.histogram()[1:]) | ||
| def edge_alpha_count(image: Image.Image, margin: int) -> int: | ||
| alpha = image.getchannel("A") | ||
| width, height = alpha.size | ||
| total = 0 | ||
| for box in ( | ||
| (0, 0, width, margin), | ||
| (0, height - margin, width, height), | ||
| (0, 0, margin, height), | ||
| (width - margin, 0, width, height), | ||
| ): | ||
| total += alpha_nonzero_count(alpha.crop(box)) | ||
| return total | ||
| def color_distance(left: tuple[int, int, int], right: tuple[int, int, int]) -> float: | ||
| return math.sqrt(sum((left[index] - right[index]) ** 2 for index in range(3))) | ||
| def chroma_adjacent_count( | ||
| image: Image.Image, | ||
| chroma_key: tuple[int, int, int] | None, | ||
| threshold: float, | ||
| ) -> int: | ||
| if chroma_key is None: | ||
| return 0 | ||
| rgba = image.convert("RGBA") | ||
| data = rgba.tobytes() | ||
| count = 0 | ||
| for index in range(0, len(data), 4): | ||
| red, green, blue, alpha = data[index : index + 4] | ||
| if alpha > 16 and color_distance((red, green, blue), chroma_key) <= threshold: | ||
| count += 1 | ||
| return count | ||
| def frame_files(state_dir: Path) -> list[Path]: | ||
| if not state_dir.is_dir(): | ||
| return [] | ||
| return sorted(path for path in state_dir.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES) | ||
| def load_manifest(frames_root: Path) -> dict[str, dict[str, object]]: | ||
| manifest_path = frames_root / "frames-manifest.json" | ||
| if not manifest_path.is_file(): | ||
| return {} | ||
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | ||
| rows = manifest.get("rows", []) | ||
| if not isinstance(rows, list): | ||
| return {} | ||
| return { | ||
| row["state"]: row | ||
| for row in rows | ||
| if isinstance(row, dict) and isinstance(row.get("state"), str) | ||
| } | ||
| def load_chroma_key(frames_root: Path) -> tuple[int, int, int] | None: | ||
| manifest_path = frames_root / "frames-manifest.json" | ||
| if not manifest_path.is_file(): | ||
| return None | ||
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | ||
| chroma_key = manifest.get("chroma_key") | ||
| if not isinstance(chroma_key, dict): | ||
| return None | ||
| rgb = chroma_key.get("rgb") | ||
| if ( | ||
| not isinstance(rgb, list) | ||
| or len(rgb) != 3 | ||
| or not all(isinstance(value, int) for value in rgb) | ||
| ): | ||
| return None | ||
| return (rgb[0], rgb[1], rgb[2]) | ||
| def inspect_state( | ||
| frames_root: Path, | ||
| state: str, | ||
| expected_count: int, | ||
| manifest_rows: dict[str, dict[str, object]], | ||
| chroma_key: tuple[int, int, int] | None, | ||
| args: argparse.Namespace, | ||
| ) -> dict[str, object]: | ||
| state_dir = frames_root / state | ||
| files = frame_files(state_dir) | ||
| row_errors: list[str] = [] | ||
| row_warnings: list[str] = [] | ||
| frames: list[dict[str, object]] = [] | ||
| areas: list[int] = [] | ||
| manifest_row = manifest_rows.get(state, {}) | ||
| method = manifest_row.get("method") | ||
| if len(files) != expected_count: | ||
| row_errors.append(f"expected {expected_count} frame files for {state}, found {len(files)}") | ||
| if args.require_components and method and method != "components": | ||
| if method == "stable-slots" and args.allow_stable_slots: | ||
| row_warnings.append( | ||
| f"{state} used extraction method stable-slots; confirm motion playback remains stable and unclipped" | ||
| ) | ||
| else: | ||
| row_errors.append( | ||
| f"{state} used extraction method {method}; regenerate the row or inspect slot slicing" | ||
| ) | ||
| elif method and method != "components": | ||
| row_warnings.append( | ||
| f"{state} used extraction method {method}; component extraction is preferred" | ||
| ) | ||
| for index, frame_path in enumerate(files[:expected_count]): | ||
| with Image.open(frame_path) as opened: | ||
| frame = opened.convert("RGBA") | ||
| nontransparent = alpha_nonzero_count(frame) | ||
| bbox = frame.getbbox() | ||
| edge_pixels = edge_alpha_count(frame, args.edge_margin) | ||
| chroma_adjacent_pixels = chroma_adjacent_count( | ||
| frame, | ||
| chroma_key, | ||
| args.chroma_adjacent_threshold, | ||
| ) | ||
| info = { | ||
| "index": index, | ||
| "file": str(frame_path), | ||
| "width": frame.width, | ||
| "height": frame.height, | ||
| "nontransparent_pixels": nontransparent, | ||
| "bbox": list(bbox) if bbox else None, | ||
| "edge_pixels": edge_pixels, | ||
| "chroma_adjacent_pixels": chroma_adjacent_pixels, | ||
| } | ||
| frames.append(info) | ||
| areas.append(nontransparent) | ||
| if frame.size != (CELL_WIDTH, CELL_HEIGHT): | ||
| row_errors.append( | ||
| f"{state} frame {index:02d} is {frame.width}x{frame.height}; expected {CELL_WIDTH}x{CELL_HEIGHT}" | ||
| ) | ||
| if nontransparent < args.min_used_pixels: | ||
| row_errors.append( | ||
| f"{state} frame {index:02d} is empty or too sparse ({nontransparent} pixels)" | ||
| ) | ||
| if edge_pixels > args.edge_pixel_threshold: | ||
| row_warnings.append( | ||
| f"{state} frame {index:02d} has {edge_pixels} non-transparent pixels near the cell edge" | ||
| ) | ||
| if chroma_adjacent_pixels > args.chroma_adjacent_pixel_threshold: | ||
| row_errors.append( | ||
| f"{state} frame {index:02d} has {chroma_adjacent_pixels} non-transparent pixels close to the chroma key" | ||
| ) | ||
| if areas: | ||
| row_median = median(areas) | ||
| for index, area in enumerate(areas[:expected_count]): | ||
| if row_median > 0 and area < row_median * args.small_outlier_ratio: | ||
| row_warnings.append( | ||
| f"{state} frame {index:02d} is much smaller than the row median ({area} vs {row_median:.0f})" | ||
| ) | ||
| if row_median > 0 and area > row_median * args.large_outlier_ratio: | ||
| row_warnings.append( | ||
| f"{state} frame {index:02d} is much larger than the row median ({area} vs {row_median:.0f})" | ||
| ) | ||
| return { | ||
| "state": state, | ||
| "expected_frames": expected_count, | ||
| "actual_frames": len(files), | ||
| "extraction_method": method, | ||
| "ok": not row_errors, | ||
| "errors": row_errors, | ||
| "warnings": row_warnings, | ||
| "frames": frames, | ||
| } | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--frames-root", required=True) | ||
| parser.add_argument("--json-out", required=True) | ||
| parser.add_argument("--states", default="all") | ||
| parser.add_argument("--min-used-pixels", type=int, default=400) | ||
| parser.add_argument("--edge-margin", type=int, default=2) | ||
| parser.add_argument("--edge-pixel-threshold", type=int, default=24) | ||
| parser.add_argument("--chroma-adjacent-threshold", type=float, default=150.0) | ||
| parser.add_argument("--chroma-adjacent-pixel-threshold", type=int, default=800) | ||
| parser.add_argument("--small-outlier-ratio", type=float, default=0.35) | ||
| parser.add_argument("--large-outlier-ratio", type=float, default=2.75) | ||
| parser.add_argument( | ||
| "--require-components", | ||
| action="store_true", | ||
| help="Fail rows that fell back to equal-slot extraction.", | ||
| ) | ||
| parser.add_argument( | ||
| "--allow-stable-slots", | ||
| action="store_true", | ||
| help="Permit explicitly chosen stable-slots extraction while still warning for visual review.", | ||
| ) | ||
| args = parser.parse_args() | ||
| frames_root = Path(args.frames_root).expanduser().resolve() | ||
| manifest_rows = load_manifest(frames_root) | ||
| chroma_key = load_chroma_key(frames_root) | ||
| states = parse_states(args.states) | ||
| rows = [ | ||
| inspect_state(frames_root, state, count, manifest_rows, chroma_key, args) | ||
| for state, count in ROW_FRAME_COUNTS.items() | ||
| if state in states | ||
| ] | ||
| errors = [error for row in rows for error in row["errors"]] | ||
| warnings = [warning for row in rows for warning in row["warnings"]] | ||
| result = { | ||
| "ok": not errors, | ||
| "frames_root": str(frames_root), | ||
| "states": states, | ||
| "errors": errors, | ||
| "warnings": warnings, | ||
| "rows": rows, | ||
| } | ||
| json_out = Path(args.json_out).expanduser().resolve() | ||
| json_out.parent.mkdir(parents=True, exist_ok=True) | ||
| json_out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") | ||
| print(json.dumps({k: v for k, v in result.items() if k != "rows"}, indent=2)) | ||
| raise SystemExit(0 if result["ok"] else 1) | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Create a labeled contact sheet from a DimAgent pet atlas.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| from pathlib import Path | ||
| from PIL import Image, ImageDraw, ImageFont | ||
| COLUMNS = 8 | ||
| ROWS = 9 | ||
| CELL_WIDTH = 192 | ||
| CELL_HEIGHT = 208 | ||
| LABEL_HEIGHT = 22 | ||
| ROW_NAMES = [ | ||
| "idle", | ||
| "move_left", | ||
| "move_right", | ||
| "waving", | ||
| "jumping", | ||
| "failed", | ||
| "waiting", | ||
| "running", | ||
| "review", | ||
| "look 000-157.5", | ||
| "look 180-337.5", | ||
| ] | ||
| USED_COUNTS = [6, 8, 8, 4, 5, 8, 6, 6, 6, 8, 8] | ||
| def is_used_cell(rows: int, row: int, column: int) -> bool: | ||
| return column < USED_COUNTS[row] | ||
| def frame_count_label(rows: int, row: int) -> str: | ||
| return f"{USED_COUNTS[row]} frames" | ||
| def checker(size: tuple[int, int], square: int = 16) -> Image.Image: | ||
| image = Image.new("RGB", size, "#ffffff") | ||
| draw = ImageDraw.Draw(image) | ||
| for y in range(0, size[1], square): | ||
| for x in range(0, size[0], square): | ||
| if (x // square + y // square) % 2: | ||
| draw.rectangle((x, y, x + square - 1, y + square - 1), fill="#e8e8e8") | ||
| return image | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("atlas") | ||
| parser.add_argument("--output", required=True) | ||
| parser.add_argument("--scale", type=float, default=0.5) | ||
| args = parser.parse_args() | ||
| with Image.open(Path(args.atlas).expanduser().resolve()) as opened: | ||
| atlas = opened.convert("RGBA") | ||
| rows = atlas.height // CELL_HEIGHT | ||
| if atlas.width != COLUMNS * CELL_WIDTH or rows not in {9, 11}: | ||
| raise SystemExit(f"atlas must be 1536x1872 or 1536x2288; got {atlas.width}x{atlas.height}") | ||
| cell_w = max(1, round(CELL_WIDTH * args.scale)) | ||
| cell_h = max(1, round(CELL_HEIGHT * args.scale)) | ||
| width = COLUMNS * cell_w | ||
| height = rows * (cell_h + LABEL_HEIGHT) | ||
| sheet = Image.new("RGB", (width, height), "#f7f7f7") | ||
| draw = ImageDraw.Draw(sheet) | ||
| font = ImageFont.load_default() | ||
| for row in range(rows): | ||
| y = row * (cell_h + LABEL_HEIGHT) | ||
| draw.rectangle((0, y, width, y + LABEL_HEIGHT - 1), fill="#111111") | ||
| draw.text((6, y + 5), f"row {row}: {ROW_NAMES[row]}", fill="#ffffff", font=font) | ||
| draw.text( | ||
| (width - 92, y + 5), | ||
| frame_count_label(rows, row), | ||
| fill="#ffffff", | ||
| font=font, | ||
| ) | ||
| for column in range(COLUMNS): | ||
| crop = atlas.crop( | ||
| ( | ||
| column * CELL_WIDTH, | ||
| row * CELL_HEIGHT, | ||
| (column + 1) * CELL_WIDTH, | ||
| (row + 1) * CELL_HEIGHT, | ||
| ) | ||
| ) | ||
| crop = crop.resize((cell_w, cell_h), Image.Resampling.LANCZOS) | ||
| bg = checker((cell_w, cell_h)) | ||
| bg.paste(crop, (0, 0), crop) | ||
| x = column * cell_w | ||
| sheet.paste(bg, (x, y + LABEL_HEIGHT)) | ||
| outline = "#18a058" if is_used_cell(rows, row, column) else "#cc3344" | ||
| draw.rectangle( | ||
| (x, y + LABEL_HEIGHT, x + cell_w - 1, y + LABEL_HEIGHT + cell_h - 1), | ||
| outline=outline, | ||
| ) | ||
| draw.text((x + 4, y + LABEL_HEIGHT + 4), str(column), fill="#111111", font=font) | ||
| output = Path(args.output).expanduser().resolve() | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| sheet.save(output) | ||
| print(f"wrote {output}") | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Create an unlabeled A/B direction-pair sheet and a hidden answer key.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import hashlib | ||
| import json | ||
| import random | ||
| from pathlib import Path | ||
| from PIL import Image, ImageDraw | ||
| COLUMNS = 8 | ||
| ROWS = 11 | ||
| CELL_WIDTH = 192 | ||
| CELL_HEIGHT = 208 | ||
| LOOK_ROW_INDEX = 9 | ||
| LABEL_HEIGHT = 28 | ||
| PAIR_COLUMNS = 2 | ||
| LOOK_DIRECTION_LABELS = [ | ||
| "000", | ||
| "022.5", | ||
| "045", | ||
| "067.5", | ||
| "090", | ||
| "112.5", | ||
| "135", | ||
| "157.5", | ||
| "180", | ||
| "202.5", | ||
| "225", | ||
| "247.5", | ||
| "270", | ||
| "292.5", | ||
| "315", | ||
| "337.5", | ||
| ] | ||
| AXIS_PAIRS = [ | ||
| ("horizontal", "022.5", "screen-right", "337.5", "screen-left"), | ||
| ("horizontal", "045", "screen-right", "315", "screen-left"), | ||
| ("horizontal", "067.5", "screen-right", "292.5", "screen-left"), | ||
| ("horizontal", "090", "screen-right", "270", "screen-left"), | ||
| ("horizontal", "112.5", "screen-right", "247.5", "screen-left"), | ||
| ("horizontal", "135", "screen-right", "225", "screen-left"), | ||
| ("horizontal", "157.5", "screen-right", "202.5", "screen-left"), | ||
| ("vertical", "000", "up", "180", "down"), | ||
| ("vertical", "022.5", "up", "157.5", "down"), | ||
| ("vertical", "045", "up", "135", "down"), | ||
| ("vertical", "067.5", "up", "112.5", "down"), | ||
| ("vertical", "337.5", "up", "202.5", "down"), | ||
| ("vertical", "315", "up", "225", "down"), | ||
| ("vertical", "292.5", "up", "247.5", "down"), | ||
| ] | ||
| def atlas_cell(atlas: Image.Image, label: str) -> Image.Image: | ||
| index = LOOK_DIRECTION_LABELS.index(label) | ||
| row = LOOK_ROW_INDEX + index // COLUMNS | ||
| column = index % COLUMNS | ||
| return atlas.crop( | ||
| ( | ||
| column * CELL_WIDTH, | ||
| row * CELL_HEIGHT, | ||
| (column + 1) * CELL_WIDTH, | ||
| (row + 1) * CELL_HEIGHT, | ||
| ) | ||
| ) | ||
| def paste_cell( | ||
| sheet: Image.Image, | ||
| cell: Image.Image, | ||
| *, | ||
| label: str, | ||
| column: int, | ||
| row: int, | ||
| ) -> None: | ||
| x = column * CELL_WIDTH | ||
| y = row * (CELL_HEIGHT + LABEL_HEIGHT) | ||
| sheet.alpha_composite( | ||
| Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (242, 242, 242, 255)), | ||
| (x, y + LABEL_HEIGHT), | ||
| ) | ||
| sheet.alpha_composite(cell, (x, y + LABEL_HEIGHT)) | ||
| ImageDraw.Draw(sheet).text((x + 6, y + 8), label, fill=(0, 0, 0, 255)) | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("atlas") | ||
| parser.add_argument("--output", required=True) | ||
| parser.add_argument("--answer-key", required=True) | ||
| args = parser.parse_args() | ||
| atlas_path = Path(args.atlas).expanduser().resolve() | ||
| with Image.open(atlas_path) as opened: | ||
| atlas = opened.convert("RGBA") | ||
| if atlas.size != (COLUMNS * CELL_WIDTH, ROWS * CELL_HEIGHT): | ||
| raise SystemExit(f"extended atlas must be 1536x2288; got {atlas.width}x{atlas.height}") | ||
| seed = int.from_bytes(hashlib.sha256(atlas.tobytes()).digest()[:8], "big") | ||
| rng = random.Random(seed) | ||
| sheet = Image.new( | ||
| "RGBA", | ||
| ( | ||
| PAIR_COLUMNS * CELL_WIDTH, | ||
| len(AXIS_PAIRS) * (CELL_HEIGHT + LABEL_HEIGHT), | ||
| ), | ||
| (255, 255, 255, 255), | ||
| ) | ||
| answers: list[dict[str, object]] = [] | ||
| axis_indexes = {"horizontal": 0, "vertical": 0} | ||
| for row, (axis, first_label, first_direction, second_label, second_direction) in enumerate( | ||
| AXIS_PAIRS | ||
| ): | ||
| axis_indexes[axis] += 1 | ||
| pair_id = f"{axis}-{axis_indexes[axis]}" | ||
| pair = [ | ||
| (first_label, first_direction), | ||
| (second_label, second_direction), | ||
| ] | ||
| rng.shuffle(pair) | ||
| cells: list[tuple[str, str, Image.Image]] = [] | ||
| for source_label, expected_direction in pair: | ||
| cells.append((source_label, expected_direction, atlas_cell(atlas, source_label))) | ||
| label = f"{axis.title()} pair {axis_indexes[axis]}" | ||
| paste_cell(sheet, cells[0][2], label=f"{label} A", column=0, row=row) | ||
| paste_cell(sheet, cells[1][2], label=f"{label} B", column=1, row=row) | ||
| answers.append( | ||
| { | ||
| "pair": pair_id, | ||
| "axis": axis, | ||
| "gate": ( | ||
| "hard" | ||
| if {first_label, second_label} in ({"000", "180"}, {"090", "270"}) | ||
| else "review" | ||
| ), | ||
| "A": { | ||
| "expected_direction": cells[0][1], | ||
| "source_direction": cells[0][0], | ||
| }, | ||
| "B": { | ||
| "expected_direction": cells[1][1], | ||
| "source_direction": cells[1][0], | ||
| }, | ||
| } | ||
| ) | ||
| output = Path(args.output).expanduser().resolve() | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| sheet.convert("RGB").save(output) | ||
| answer_key = Path(args.answer_key).expanduser().resolve() | ||
| answer_key.parent.mkdir(parents=True, exist_ok=True) | ||
| answer_key.write_text( | ||
| json.dumps( | ||
| { | ||
| "schema_version": 3, | ||
| "atlas_sha256": hashlib.sha256(atlas_path.read_bytes()).hexdigest(), | ||
| "instructions": "Do not provide this answer key to the blind visual QA reviewer.", | ||
| "pairs": answers, | ||
| }, | ||
| indent=2, | ||
| ) | ||
| + "\n", | ||
| encoding="utf-8", | ||
| ) | ||
| print(f"wrote {output}") | ||
| print(f"wrote {answer_key}") | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Create a focused QA sheet for extended pet look directions.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| from pathlib import Path | ||
| from PIL import Image, ImageDraw | ||
| COLUMNS = 8 | ||
| ROWS = 11 | ||
| CELL_WIDTH = 192 | ||
| CELL_HEIGHT = 208 | ||
| LOOK_ROW_INDEX = 9 | ||
| NEUTRAL_ROW_INDEX = 0 | ||
| NEUTRAL_COLUMN_INDEX = 0 | ||
| LABEL_HEIGHT = 26 | ||
| FOCUS_PADDING = 18 | ||
| LOOK_DIRECTION_LABELS = [ | ||
| ("000", "up"), | ||
| ("022.5", "up-right"), | ||
| ("045", "up-right"), | ||
| ("067.5", "up-right"), | ||
| ("090", "right"), | ||
| ("112.5", "down-right"), | ||
| ("135", "down-right"), | ||
| ("157.5", "down-right"), | ||
| ("180", "down"), | ||
| ("202.5", "down-left"), | ||
| ("225", "down-left"), | ||
| ("247.5", "down-left"), | ||
| ("270", "left"), | ||
| ("292.5", "up-left"), | ||
| ("315", "up-left"), | ||
| ("337.5", "up-left"), | ||
| ] | ||
| def paste_labeled_cell( | ||
| sheet: Image.Image, | ||
| atlas: Image.Image, | ||
| *, | ||
| label: str, | ||
| row_index: int, | ||
| column_index: int, | ||
| output_column: int, | ||
| output_row: int, | ||
| ) -> None: | ||
| draw = ImageDraw.Draw(sheet) | ||
| x = output_column * CELL_WIDTH | ||
| y = output_row * (CELL_HEIGHT + LABEL_HEIGHT) | ||
| cell = atlas.crop( | ||
| ( | ||
| column_index * CELL_WIDTH, | ||
| row_index * CELL_HEIGHT, | ||
| (column_index + 1) * CELL_WIDTH, | ||
| (row_index + 1) * CELL_HEIGHT, | ||
| ) | ||
| ) | ||
| background = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (242, 242, 242, 255)) | ||
| sheet.alpha_composite(background, (x, y + LABEL_HEIGHT)) | ||
| sheet.alpha_composite(cell, (x, y + LABEL_HEIGHT)) | ||
| draw.text((x + 6, y + 7), label, fill=(0, 0, 0, 255)) | ||
| def focused_head_cell(cell: Image.Image) -> Image.Image: | ||
| bbox = cell.getbbox() | ||
| if bbox is None: | ||
| return cell | ||
| left, top, right, bottom = bbox | ||
| sprite_height = bottom - top | ||
| focus_bottom = top + max(1, int(sprite_height * 0.52)) | ||
| crop_box = ( | ||
| max(0, left - FOCUS_PADDING), | ||
| max(0, top - FOCUS_PADDING), | ||
| min(CELL_WIDTH, right + FOCUS_PADDING), | ||
| min(CELL_HEIGHT, focus_bottom + FOCUS_PADDING), | ||
| ) | ||
| crop = cell.crop(crop_box) | ||
| focused = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) | ||
| crop.thumbnail((CELL_WIDTH, CELL_HEIGHT), Image.Resampling.LANCZOS) | ||
| focused.alpha_composite( | ||
| crop, | ||
| ((CELL_WIDTH - crop.width) // 2, (CELL_HEIGHT - crop.height) // 2), | ||
| ) | ||
| return focused | ||
| def paste_labeled_focus_cell( | ||
| sheet: Image.Image, | ||
| atlas: Image.Image, | ||
| *, | ||
| label: str, | ||
| row_index: int, | ||
| column_index: int, | ||
| output_column: int, | ||
| output_row: int, | ||
| ) -> None: | ||
| cell = atlas.crop( | ||
| ( | ||
| column_index * CELL_WIDTH, | ||
| row_index * CELL_HEIGHT, | ||
| (column_index + 1) * CELL_WIDTH, | ||
| (row_index + 1) * CELL_HEIGHT, | ||
| ) | ||
| ) | ||
| focused = focused_head_cell(cell) | ||
| background = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (242, 242, 242, 255)) | ||
| x = output_column * CELL_WIDTH | ||
| y = output_row * (CELL_HEIGHT + LABEL_HEIGHT) | ||
| sheet.alpha_composite(background, (x, y + LABEL_HEIGHT)) | ||
| sheet.alpha_composite(focused, (x, y + LABEL_HEIGHT)) | ||
| ImageDraw.Draw(sheet).text((x + 6, y + 7), label, fill=(0, 0, 0, 255)) | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("atlas") | ||
| parser.add_argument("--output", required=True) | ||
| args = parser.parse_args() | ||
| with Image.open(Path(args.atlas).expanduser().resolve()) as opened: | ||
| atlas = opened.convert("RGBA") | ||
| if atlas.size != (COLUMNS * CELL_WIDTH, ROWS * CELL_HEIGHT): | ||
| raise SystemExit(f"extended atlas must be 1536x2288; got {atlas.width}x{atlas.height}") | ||
| sheet = Image.new( | ||
| "RGBA", | ||
| (COLUMNS * CELL_WIDTH, 5 * (CELL_HEIGHT + LABEL_HEIGHT)), | ||
| (255, 255, 255, 255), | ||
| ) | ||
| paste_labeled_cell( | ||
| sheet, | ||
| atlas, | ||
| label="neutral", | ||
| row_index=NEUTRAL_ROW_INDEX, | ||
| column_index=NEUTRAL_COLUMN_INDEX, | ||
| output_column=0, | ||
| output_row=0, | ||
| ) | ||
| for index, (label, expected_direction) in enumerate(LOOK_DIRECTION_LABELS): | ||
| paste_labeled_cell( | ||
| sheet, | ||
| atlas, | ||
| label=f"{label} {expected_direction}", | ||
| row_index=LOOK_ROW_INDEX + index // COLUMNS, | ||
| column_index=index % COLUMNS, | ||
| output_column=index % COLUMNS, | ||
| output_row=1 + index // COLUMNS, | ||
| ) | ||
| paste_labeled_focus_cell( | ||
| sheet, | ||
| atlas, | ||
| label=f"zoom {label} {expected_direction}", | ||
| row_index=LOOK_ROW_INDEX + index // COLUMNS, | ||
| column_index=index % COLUMNS, | ||
| output_column=index % COLUMNS, | ||
| output_row=3 + index // COLUMNS, | ||
| ) | ||
| output = Path(args.output).expanduser().resolve() | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| sheet.convert("RGB").save(output) | ||
| print(f"wrote {output}") | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Measure adjacent-pair continuity for extended pet look directions.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import json | ||
| import statistics | ||
| from pathlib import Path | ||
| from PIL import Image, ImageChops | ||
| COLUMNS = 8 | ||
| ROWS = 11 | ||
| CELL_WIDTH = 192 | ||
| CELL_HEIGHT = 208 | ||
| LOOK_ROW_INDEX = 9 | ||
| LOOK_DIRECTION_LABELS = [ | ||
| "000", | ||
| "022.5", | ||
| "045", | ||
| "067.5", | ||
| "090", | ||
| "112.5", | ||
| "135", | ||
| "157.5", | ||
| "180", | ||
| "202.5", | ||
| "225", | ||
| "247.5", | ||
| "270", | ||
| "292.5", | ||
| "315", | ||
| "337.5", | ||
| ] | ||
| def nontransparent_pixels(image: Image.Image) -> int: | ||
| return sum(1 for alpha in image.getchannel("A").getdata() if alpha > 16) | ||
| def center_for_bbox(bbox: tuple[int, int, int, int] | None) -> tuple[float, float] | None: | ||
| if bbox is None: | ||
| return None | ||
| left, top, right, bottom = bbox | ||
| return ((left + right) / 2, (top + bottom) / 2) | ||
| def cell_from_atlas(atlas: Image.Image, index: int) -> Image.Image: | ||
| row = LOOK_ROW_INDEX + index // COLUMNS | ||
| column = index % COLUMNS | ||
| return atlas.crop( | ||
| ( | ||
| column * CELL_WIDTH, | ||
| row * CELL_HEIGHT, | ||
| (column + 1) * CELL_WIDTH, | ||
| (row + 1) * CELL_HEIGHT, | ||
| ) | ||
| ) | ||
| def transparent_hole_rows(image: Image.Image) -> list[dict[str, int]]: | ||
| alpha = list(image.getchannel("A").getdata()) | ||
| rows = [alpha[index * CELL_WIDTH : (index + 1) * CELL_WIDTH] for index in range(CELL_HEIGHT)] | ||
| holes = [] | ||
| for y in range(1, CELL_HEIGHT - 1): | ||
| prev_xs = [x for x, value in enumerate(rows[y - 1]) if value > 16] | ||
| next_xs = [x for x, value in enumerate(rows[y + 1]) if value > 16] | ||
| if not prev_xs or not next_xs: | ||
| continue | ||
| left = max(min(prev_xs), min(next_xs)) | ||
| right = min(max(prev_xs), max(next_xs)) | ||
| if right <= left: | ||
| continue | ||
| span = rows[y][left : right + 1] | ||
| transparent_pixels = sum(1 for value in span if value <= 16) | ||
| if len(span) >= 64 and transparent_pixels > max(32, int(len(span) * 0.25)): | ||
| holes.append( | ||
| { | ||
| "row": y, | ||
| "transparentPixels": transparent_pixels, | ||
| "spanPixels": len(span), | ||
| } | ||
| ) | ||
| return holes | ||
| def pair_metric(first: Image.Image, second: Image.Image) -> dict[str, float | int | None]: | ||
| first_bbox = first.getbbox() | ||
| second_bbox = second.getbbox() | ||
| first_center = center_for_bbox(first_bbox) | ||
| second_center = center_for_bbox(second_bbox) | ||
| first_pixels = nontransparent_pixels(first) | ||
| second_pixels = nontransparent_pixels(second) | ||
| diff = ImageChops.difference(first, second) | ||
| diff_pixels = nontransparent_pixels(diff) | ||
| if first_center is None or second_center is None: | ||
| center_delta = None | ||
| else: | ||
| center_delta = ( | ||
| (first_center[0] - second_center[0]) ** 2 + (first_center[1] - second_center[1]) ** 2 | ||
| ) ** 0.5 | ||
| if first_pixels == 0 or second_pixels == 0: | ||
| area_ratio = None | ||
| else: | ||
| area_ratio = max(first_pixels, second_pixels) / min(first_pixels, second_pixels) | ||
| return { | ||
| "firstPixels": first_pixels, | ||
| "secondPixels": second_pixels, | ||
| "diffPixels": diff_pixels, | ||
| "centerDelta": center_delta, | ||
| "areaRatio": area_ratio, | ||
| } | ||
| def median(values: list[float]) -> float: | ||
| if not values: | ||
| return 0 | ||
| return statistics.median(values) | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("atlas") | ||
| parser.add_argument("--json-out", required=True) | ||
| parser.add_argument("--diff-outlier-ratio", type=float, default=1.45) | ||
| parser.add_argument("--center-delta-warning", type=float, default=8) | ||
| parser.add_argument("--area-ratio-warning", type=float, default=1.15) | ||
| args = parser.parse_args() | ||
| with Image.open(Path(args.atlas).expanduser().resolve()) as opened: | ||
| atlas = opened.convert("RGBA") | ||
| if atlas.size != (COLUMNS * CELL_WIDTH, ROWS * CELL_HEIGHT): | ||
| raise SystemExit(f"extended atlas must be 1536x2288; got {atlas.width}x{atlas.height}") | ||
| cells = [cell_from_atlas(atlas, index) for index in range(len(LOOK_DIRECTION_LABELS))] | ||
| pairs = [] | ||
| for index, label in enumerate(LOOK_DIRECTION_LABELS): | ||
| next_index = (index + 1) % len(LOOK_DIRECTION_LABELS) | ||
| next_label = LOOK_DIRECTION_LABELS[next_index] | ||
| pairs.append( | ||
| { | ||
| "from": label, | ||
| "to": next_label, | ||
| **pair_metric(cells[index], cells[next_index]), | ||
| } | ||
| ) | ||
| diff_values = [float(pair["diffPixels"]) for pair in pairs] | ||
| median_diff = median(diff_values) | ||
| warnings = [] | ||
| alpha_holes = [] | ||
| for label, cell in zip(LOOK_DIRECTION_LABELS, cells, strict=True): | ||
| holes = transparent_hole_rows(cell) | ||
| if holes: | ||
| alpha_holes.append({"direction": label, "holes": holes}) | ||
| preview = ", ".join(f"y={hole['row']}" for hole in holes[:4]) | ||
| suffix = "" if len(holes) <= 4 else f", +{len(holes) - 4} more" | ||
| warnings.append(f"{label} has transparent interior hole rows ({preview}{suffix})") | ||
| for index, pair in enumerate(pairs): | ||
| diff_pixels = float(pair["diffPixels"]) | ||
| center_delta = pair["centerDelta"] | ||
| area_ratio = pair["areaRatio"] | ||
| pair_label = f"{pair['from']}->{pair['to']}" | ||
| neighbor_diff = statistics.mean( | ||
| [ | ||
| diff_values[(index - 1) % len(diff_values)], | ||
| diff_values[(index + 1) % len(diff_values)], | ||
| ] | ||
| ) | ||
| if neighbor_diff and diff_pixels > neighbor_diff * args.diff_outlier_ratio: | ||
| warnings.append( | ||
| f"{pair_label} diff is a local outlier ({diff_pixels:.0f} pixels vs neighbor average {neighbor_diff:.0f})" | ||
| ) | ||
| if isinstance(center_delta, float) and center_delta > args.center_delta_warning: | ||
| warnings.append(f"{pair_label} center shift is high ({center_delta:.1f}px)") | ||
| if isinstance(area_ratio, float) and area_ratio > args.area_ratio_warning: | ||
| warnings.append(f"{pair_label} sprite area ratio is high ({area_ratio:.2f})") | ||
| result = { | ||
| "ok": True, | ||
| "reviewRequired": bool(warnings), | ||
| "medianDiffPixels": median_diff, | ||
| "warnings": warnings, | ||
| "alphaHoles": alpha_holes, | ||
| "pairs": pairs, | ||
| } | ||
| output = Path(args.json_out).expanduser().resolve() | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") | ||
| print(json.dumps(result, indent=2)) | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Validate and atomically install a completed Codex v2 Pet run.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import hashlib | ||
| import json | ||
| import os | ||
| import re | ||
| import shutil | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| from pathlib import Path | ||
| from typing import Any | ||
| PET_ID_PATTERN = re.compile(r"^[a-z0-9._-]+$") | ||
| FORMAT = "dim-sprite-v2" | ||
| MANIFEST_FILENAME = "pet.json" | ||
| SPRITESHEET_FILENAME = "spritesheet.webp" | ||
| EXPECTED_FPS_NAMES = { | ||
| "idle", | ||
| "move_left", | ||
| "move_right", | ||
| "waving", | ||
| "jumping", | ||
| "failed", | ||
| "waiting", | ||
| "running", | ||
| "review", | ||
| } | ||
| EXPECTED_DIRECTIONS = [ | ||
| "000", | ||
| "022.5", | ||
| "045", | ||
| "067.5", | ||
| "090", | ||
| "112.5", | ||
| "135", | ||
| "157.5", | ||
| "180", | ||
| "202.5", | ||
| "225", | ||
| "247.5", | ||
| "270", | ||
| "292.5", | ||
| "315", | ||
| "337.5", | ||
| ] | ||
| MAX_MANIFEST_BYTES = 64 * 1024 | ||
| MAX_SPRITESHEET_BYTES = 20 * 1024 * 1024 | ||
| MAX_DESCRIPTION_CODE_POINTS = 160 | ||
| def fail(message: str) -> None: | ||
| raise SystemExit(message) | ||
| def read_json_object(path: Path, label: str) -> dict[str, Any]: | ||
| if not path.is_file() or path.is_symlink(): | ||
| fail(f"{label} is missing or is not a regular file: {path}") | ||
| try: | ||
| value = json.loads(path.read_text(encoding="utf-8")) | ||
| except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: | ||
| fail(f"{label} is not valid UTF-8 JSON: {path}: {exc}") | ||
| if not isinstance(value, dict): | ||
| fail(f"{label} must be a JSON object: {path}") | ||
| return value | ||
| def require_non_empty_string(value: object, label: str) -> str: | ||
| if not isinstance(value, str) or not value.strip(): | ||
| fail(f"{label} must be a non-empty string") | ||
| return value | ||
| def require_ok_report(path: Path, label: str) -> dict[str, Any]: | ||
| report = read_json_object(path, label) | ||
| if report.get("ok") is not True: | ||
| fail(f"{label} must contain ok=true: {path}") | ||
| return report | ||
| def validate_request(request: dict[str, Any]) -> dict[str, Any]: | ||
| pet_id = require_non_empty_string(request.get("pet_id"), "pet_request.pet_id") | ||
| if PET_ID_PATTERN.fullmatch(pet_id) is None: | ||
| fail("pet_request.pet_id must match [a-z0-9._-]+") | ||
| if pet_id.startswith("bundled-"): | ||
| fail('pet_request.pet_id must not use the reserved "bundled-" prefix') | ||
| name = require_non_empty_string(request.get("display_name"), "pet_request.display_name") | ||
| description = require_non_empty_string( | ||
| request.get("description"), | ||
| "pet_request.description", | ||
| ) | ||
| if len(description.strip()) > MAX_DESCRIPTION_CODE_POINTS: | ||
| fail( | ||
| "pet_request.description must be at most " | ||
| f"{MAX_DESCRIPTION_CODE_POINTS} Unicode code points" | ||
| ) | ||
| if request.get("format") != FORMAT: | ||
| fail(f'pet_request.format must equal "{FORMAT}"') | ||
| fps = request.get("fps") | ||
| if not isinstance(fps, dict): | ||
| fail("pet_request.fps must be an object") | ||
| if set(fps) != EXPECTED_FPS_NAMES: | ||
| fail("pet_request.fps must contain exactly the nine DimAgent animation names") | ||
| for animation_name, value in fps.items(): | ||
| if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 30: | ||
| fail(f"pet_request.fps.{animation_name} must be an integer between 1 and 30") | ||
| return { | ||
| "id": pet_id, | ||
| "displayName": name, | ||
| "description": description, | ||
| "spriteVersionNumber": 2, | ||
| "spritesheetPath": SPRITESHEET_FILENAME, | ||
| } | ||
| def validate_direction_semantics( | ||
| path: Path, | ||
| *, | ||
| run_id: str, | ||
| atlas_sha256: str, | ||
| ) -> None: | ||
| report = read_json_object(path, "direction semantics report") | ||
| if report.get("run_id") != run_id or report.get("atlas_sha256") != atlas_sha256: | ||
| fail("direction semantics report does not match the current run and atlas") | ||
| directions = report.get("directions") | ||
| if not isinstance(directions, list): | ||
| fail("direction semantics report must contain a directions array") | ||
| labels: list[str] = [] | ||
| for index, entry in enumerate(directions): | ||
| if not isinstance(entry, dict): | ||
| fail(f"direction semantics directions[{index}] must be an object") | ||
| label = require_non_empty_string( | ||
| entry.get("direction"), | ||
| f"direction semantics directions[{index}].direction", | ||
| ) | ||
| verdict = entry.get("verdict") | ||
| if verdict not in {"pass", "warning", "fail"}: | ||
| fail( | ||
| f"direction semantics directions[{index}].verdict " | ||
| "must be pass, warning, or fail" | ||
| ) | ||
| if verdict == "fail": | ||
| fail(f"direction semantics contains a failed direction: {label}") | ||
| require_non_empty_string( | ||
| entry.get("expected"), | ||
| f"direction semantics directions[{index}].expected", | ||
| ) | ||
| require_non_empty_string( | ||
| entry.get("observed"), | ||
| f"direction semantics directions[{index}].observed", | ||
| ) | ||
| require_non_empty_string( | ||
| entry.get("reason"), | ||
| f"direction semantics directions[{index}].reason", | ||
| ) | ||
| labels.append(label) | ||
| if labels != EXPECTED_DIRECTIONS: | ||
| fail("direction semantics must contain all 16 directions in fixed clockwise order") | ||
| def validate_blind_review( | ||
| run_dir: Path, | ||
| *, | ||
| run_id: str, | ||
| atlas_sha256: str, | ||
| ) -> None: | ||
| qa_dir = run_dir / "qa" | ||
| answer_key_path = qa_dir / "direction-blind-answer-key.json" | ||
| consensus_path = qa_dir / "direction-blind-verdicts.json" | ||
| answer_key = read_json_object(answer_key_path, "blind direction answer key") | ||
| consensus = read_json_object(consensus_path, "blind direction consensus") | ||
| validation = read_json_object( | ||
| qa_dir / "direction-blind-validation.json", | ||
| "blind direction validation", | ||
| ) | ||
| verdict_paths = [ | ||
| qa_dir / f"direction-blind-verdicts-{index}.json" for index in range(1, 4) | ||
| ] | ||
| for index, path in enumerate(verdict_paths, start=1): | ||
| verdict = read_json_object(path, f"blind direction verdict {index}") | ||
| if not isinstance(verdict.get("pairs"), list) or len(verdict["pairs"]) != 14: | ||
| fail(f"blind direction verdict {index} must contain all 14 pairs") | ||
| if answer_key.get("atlas_sha256") != atlas_sha256: | ||
| fail("blind direction answer key does not match the current atlas") | ||
| if not isinstance(answer_key.get("pairs"), list) or len(answer_key["pairs"]) != 14: | ||
| fail("blind direction answer key must contain all 14 pairs") | ||
| if consensus.get("review_count") != 3: | ||
| fail("blind direction consensus must combine exactly three isolated reviews") | ||
| expected_input_hashes = [sha256(path) for path in verdict_paths] | ||
| if consensus.get("input_sha256") != expected_input_hashes: | ||
| fail("blind direction consensus input hashes do not match the isolated reviews") | ||
| if not isinstance(consensus.get("pairs"), list) or len(consensus["pairs"]) != 14: | ||
| fail("blind direction consensus must contain all 14 pairs") | ||
| if ( | ||
| validation.get("atlas_sha256") != atlas_sha256 | ||
| or validation.get("answer_key_sha256") != sha256(answer_key_path) | ||
| or validation.get("verdicts_sha256") != sha256(consensus_path) | ||
| ): | ||
| fail("blind direction validation is stale or does not match its inputs") | ||
| if not isinstance(validation.get("pairs"), list) or len(validation["pairs"]) != 14: | ||
| fail("blind direction validation must contain all 14 pair results") | ||
| if validation.get("ok") is True: | ||
| return | ||
| resolution = read_json_object( | ||
| qa_dir / "blind-review-resolution.json", | ||
| "blind review resolution", | ||
| ) | ||
| if resolution.get("decision") != "accept" or resolution.get("severity") != "minor": | ||
| fail("failed blind direction validation requires an accepted minor resolution") | ||
| if resolution.get("run_id") != run_id or resolution.get("atlas_sha256") != atlas_sha256: | ||
| fail("blind review resolution does not match the current run and atlas") | ||
| require_non_empty_string(resolution.get("reviewed_by"), "blind review resolution.reviewed_by") | ||
| def require_file(path: Path, label: str) -> None: | ||
| if not path.is_file() or path.is_symlink(): | ||
| fail(f"{label} is missing or is not a regular file: {path}") | ||
| def prepare_final_visual_input(run_dir: Path) -> dict[str, Any]: | ||
| descriptor, output_name = tempfile.mkstemp( | ||
| prefix=".final-visual-qa-input.recheck-", | ||
| suffix=".json", | ||
| dir=run_dir / "qa", | ||
| ) | ||
| os.close(descriptor) | ||
| output = Path(output_name) | ||
| completed = subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| str(Path(__file__).resolve().parent / "prepare_final_visual_qa.py"), | ||
| "--run-dir", | ||
| str(run_dir), | ||
| "--output", | ||
| str(output), | ||
| ], | ||
| capture_output=True, | ||
| text=True, | ||
| check=False, | ||
| ) | ||
| if completed.returncode != 0: | ||
| detail = completed.stderr.strip() or completed.stdout.strip() | ||
| fail(f"final visual QA inputs are incomplete or invalid: {detail}") | ||
| try: | ||
| return read_json_object(output, "recomputed final visual QA input") | ||
| finally: | ||
| output.unlink(missing_ok=True) | ||
| def validate_qa( | ||
| run_dir: Path, | ||
| *, | ||
| run_id: str, | ||
| atlas_sha256: str, | ||
| ) -> None: | ||
| qa_dir = run_dir / "qa" | ||
| require_ok_report(qa_dir / "chroma-despill-extended.json", "chroma despill report") | ||
| require_ok_report(qa_dir / "look-continuity.json", "look continuity report") | ||
| review = read_json_object(qa_dir / "review.json", "standard-row review") | ||
| errors = review.get("errors") | ||
| if not isinstance(errors, list) or errors: | ||
| fail("standard-row review must contain an empty errors array") | ||
| validate_direction_semantics( | ||
| qa_dir / "direction-semantics.json", | ||
| run_id=run_id, | ||
| atlas_sha256=atlas_sha256, | ||
| ) | ||
| validate_blind_review(run_dir, run_id=run_id, atlas_sha256=atlas_sha256) | ||
| recorded_input_path = qa_dir / "final-visual-qa-input.json" | ||
| recorded_input = read_json_object(recorded_input_path, "final visual QA input") | ||
| recomputed_input = prepare_final_visual_input(run_dir) | ||
| if recorded_input != recomputed_input: | ||
| fail("final visual QA input is stale or does not match the current artifacts") | ||
| if ( | ||
| recorded_input.get("run_id") != run_id | ||
| or recorded_input.get("atlas_sha256") != atlas_sha256 | ||
| ): | ||
| fail("final visual QA input does not match the current run and atlas") | ||
| final_review = read_json_object( | ||
| qa_dir / "final-visual-qa.json", | ||
| "final independent visual QA report", | ||
| ) | ||
| if final_review.get("ok") is not True or final_review.get("verdict") != "pass": | ||
| fail("final independent visual QA report must pass") | ||
| if ( | ||
| final_review.get("run_id") != run_id | ||
| or final_review.get("atlas_sha256") != atlas_sha256 | ||
| or final_review.get("qa_input_sha256") != sha256(recorded_input_path) | ||
| or final_review.get("direction_semantics_sha256") | ||
| != sha256(qa_dir / "direction-semantics.json") | ||
| ): | ||
| fail("final independent visual QA report is stale or mismatched") | ||
| if final_review.get("reviewed_by") != "independent-visual-worker": | ||
| fail("final visual QA must be recorded by an independent visual worker") | ||
| require_non_empty_string(final_review.get("qa_note"), "final visual QA report.qa_note") | ||
| def sha256(path: Path) -> str: | ||
| digest = hashlib.sha256() | ||
| with path.open("rb") as stream: | ||
| for chunk in iter(lambda: stream.read(1024 * 1024), b""): | ||
| digest.update(chunk) | ||
| return digest.hexdigest() | ||
| def run_atlas_validation( | ||
| skill_dir: Path, | ||
| atlas: Path, | ||
| chroma_key: str, | ||
| report_path: Path, | ||
| ) -> dict[str, Any]: | ||
| command = [ | ||
| sys.executable, | ||
| str(skill_dir / "scripts" / "validate_atlas.py"), | ||
| str(atlas), | ||
| "--json-out", | ||
| str(report_path), | ||
| "--chroma-key", | ||
| chroma_key, | ||
| "--require-v2", | ||
| ] | ||
| completed = subprocess.run(command, capture_output=True, text=True, check=False) | ||
| if completed.returncode != 0: | ||
| detail = completed.stderr.strip() or completed.stdout.strip() | ||
| fail(f"packaged spritesheet failed v2 atlas validation: {detail}") | ||
| return require_ok_report(report_path, "package atlas validation") | ||
| def write_json(path: Path, value: dict[str, Any]) -> None: | ||
| encoded = (json.dumps(value, indent=2) + "\n").encode("utf-8") | ||
| descriptor, temporary_name = tempfile.mkstemp( | ||
| prefix=f".{path.name}.", | ||
| suffix=".tmp", | ||
| dir=path.parent, | ||
| ) | ||
| temporary = Path(temporary_name) | ||
| try: | ||
| with os.fdopen(descriptor, "wb") as stream: | ||
| stream.write(encoded) | ||
| os.replace(temporary, path) | ||
| finally: | ||
| temporary.unlink(missing_ok=True) | ||
| def default_pets_root() -> Path: | ||
| dimcode_home = os.environ.get("DIMCODE_HOME") | ||
| root = Path(dimcode_home).expanduser() if dimcode_home else Path.home() / ".dimcode" / "v2" | ||
| return root / "pets" | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--run-dir", required=True) | ||
| parser.add_argument("--pets-root", default="") | ||
| args = parser.parse_args() | ||
| run_dir = Path(args.run_dir).expanduser().resolve() | ||
| if not run_dir.is_dir(): | ||
| fail(f"run directory not found: {run_dir}") | ||
| pets_root = ( | ||
| Path(args.pets_root).expanduser().resolve() | ||
| if args.pets_root | ||
| else default_pets_root().resolve() | ||
| ) | ||
| skill_dir = Path(__file__).resolve().parents[1] | ||
| request = read_json_object(run_dir / "pet_request.json", "pet request") | ||
| manifest = validate_request(request) | ||
| run_id = require_non_empty_string(request.get("run_id"), "pet_request.run_id") | ||
| prior_validation = require_ok_report( | ||
| run_dir / "final" / "validation-extended.json", | ||
| "final atlas validation", | ||
| ) | ||
| if prior_validation.get("rows") != 11: | ||
| fail("final atlas validation must report exactly 11 rows") | ||
| spritesheet = run_dir / "final" / "spritesheet-extended.webp" | ||
| require_file(spritesheet, "final spritesheet") | ||
| if spritesheet.stat().st_size > MAX_SPRITESHEET_BYTES: | ||
| fail(f"final spritesheet exceeds {MAX_SPRITESHEET_BYTES} bytes") | ||
| atlas_sha256 = sha256(spritesheet) | ||
| validate_qa(run_dir, run_id=run_id, atlas_sha256=atlas_sha256) | ||
| chroma_key = request.get("chroma_key") | ||
| if not isinstance(chroma_key, dict): | ||
| fail("pet_request.chroma_key must be an object") | ||
| chroma_hex = require_non_empty_string(chroma_key.get("hex"), "pet_request.chroma_key.hex") | ||
| encoded_manifest = (json.dumps(manifest, indent=2) + "\n").encode("utf-8") | ||
| if len(encoded_manifest) > MAX_MANIFEST_BYTES: | ||
| fail(f"generated pet.json exceeds {MAX_MANIFEST_BYTES} bytes") | ||
| pets_root.mkdir(parents=True, exist_ok=True) | ||
| target_dir = pets_root / manifest["id"] | ||
| if target_dir.exists(): | ||
| fail(f'Pet id "{manifest["id"]}" is already installed: {target_dir}') | ||
| staged_dir = Path( | ||
| tempfile.mkdtemp(prefix=f'.pet-install-{manifest["id"]}-', dir=pets_root) | ||
| ) | ||
| package_validation_path = run_dir / "qa" / "package-validation.json" | ||
| run_summary_path = run_dir / "qa" / "run-summary.json" | ||
| pending_validation_path: Path | None = None | ||
| try: | ||
| pending_descriptor, pending_validation_name = tempfile.mkstemp( | ||
| prefix=".package-validation.pending-", | ||
| suffix=".json", | ||
| dir=run_dir / "qa", | ||
| ) | ||
| os.close(pending_descriptor) | ||
| pending_validation_path = Path(pending_validation_name) | ||
| staged_manifest = staged_dir / MANIFEST_FILENAME | ||
| staged_spritesheet = staged_dir / SPRITESHEET_FILENAME | ||
| staged_manifest.write_bytes(encoded_manifest) | ||
| shutil.copyfile(spritesheet, staged_spritesheet) | ||
| if sha256(staged_spritesheet) != atlas_sha256: | ||
| fail("staged spritesheet bytes do not match the validated source") | ||
| validation = run_atlas_validation( | ||
| skill_dir, | ||
| staged_spritesheet, | ||
| chroma_hex, | ||
| pending_validation_path, | ||
| ) | ||
| if target_dir.exists(): | ||
| fail(f'Pet id "{manifest["id"]}" was installed concurrently: {target_dir}') | ||
| validation["file"] = str(target_dir / SPRITESHEET_FILENAME) | ||
| validation["validated_sha256"] = atlas_sha256 | ||
| write_json(package_validation_path, validation) | ||
| write_json( | ||
| run_summary_path, | ||
| { | ||
| "ok": True, | ||
| "spriteVersionNumber": 2, | ||
| "run_id": run_id, | ||
| "atlas_sha256": atlas_sha256, | ||
| "run_dir": str(run_dir), | ||
| "spritesheet": str(run_dir / "final" / "spritesheet-extended.webp"), | ||
| "validation": str(package_validation_path), | ||
| "chroma_despill": str(run_dir / "qa" / "chroma-despill-extended.json"), | ||
| "contact_sheet": str(run_dir / "qa" / "contact-sheet-extended.png"), | ||
| "direction_sheet": str(run_dir / "qa" / "look-directions.png"), | ||
| "direction_semantics": str(run_dir / "qa" / "direction-semantics.json"), | ||
| "blind_direction_validation": str( | ||
| run_dir / "qa" / "direction-blind-validation.json" | ||
| ), | ||
| "continuity": str(run_dir / "qa" / "look-continuity.json"), | ||
| "review": str(run_dir / "qa" / "review.json"), | ||
| "final_visual_qa_input": str( | ||
| run_dir / "qa" / "final-visual-qa-input.json" | ||
| ), | ||
| "final_visual_qa": str(run_dir / "qa" / "final-visual-qa.json"), | ||
| "package": str(target_dir), | ||
| }, | ||
| ) | ||
| pending_validation_path.unlink(missing_ok=True) | ||
| success_output = json.dumps( | ||
| { | ||
| "ok": True, | ||
| "pet_id": manifest["id"], | ||
| "package": str(target_dir), | ||
| "manifest": str(target_dir / MANIFEST_FILENAME), | ||
| "spritesheet": str(target_dir / SPRITESHEET_FILENAME), | ||
| "qa_summary": str(run_summary_path), | ||
| }, | ||
| indent=2, | ||
| ) + "\n" | ||
| os.replace(staged_dir, target_dir) | ||
| except BaseException: | ||
| if staged_dir.exists(): | ||
| shutil.rmtree(staged_dir, ignore_errors=True) | ||
| if pending_validation_path is not None: | ||
| pending_validation_path.unlink(missing_ok=True) | ||
| package_validation_path.unlink(missing_ok=True) | ||
| run_summary_path.unlink(missing_ok=True) | ||
| raise | ||
| try: | ||
| sys.stdout.write(success_output) | ||
| sys.stdout.flush() | ||
| except OSError: | ||
| pass | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Freeze hashes and dimensions for the exact artifacts sent to final visual QA.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import hashlib | ||
| import json | ||
| from pathlib import Path | ||
| from typing import Any | ||
| from PIL import Image | ||
| EXPECTED_JOBS = { | ||
| "base", | ||
| "idle", | ||
| "move_left", | ||
| "move_right", | ||
| "waving", | ||
| "jumping", | ||
| "failed", | ||
| "waiting", | ||
| "running", | ||
| "review", | ||
| "look-cardinals", | ||
| "look-row-9", | ||
| "look-row-10", | ||
| } | ||
| EXPECTED_JOB_OUTPUTS = { | ||
| job_id: f"decoded/{job_id}.png" for job_id in EXPECTED_JOBS | ||
| } | ||
| PREVIEW_FRAMES = { | ||
| "idle": 6, | ||
| "move_left": 8, | ||
| "move_right": 8, | ||
| "waving": 4, | ||
| "jumping": 5, | ||
| "failed": 8, | ||
| "waiting": 6, | ||
| "running": 6, | ||
| "review": 6, | ||
| } | ||
| IMAGE_DIMENSIONS = { | ||
| "qa/contact-sheet.png": (768, 1134), | ||
| "qa/contact-sheet-extended.png": (768, 1386), | ||
| "qa/look-directions.png": (1536, 1170), | ||
| "qa/direction-blind-pairs.png": (384, 3304), | ||
| } | ||
| JSON_ARTIFACTS = [ | ||
| "final/validation-extended.json", | ||
| "qa/chroma-despill-extended.json", | ||
| "qa/review.json", | ||
| "qa/direction-blind-answer-key.json", | ||
| "qa/direction-blind-verdicts-1.json", | ||
| "qa/direction-blind-verdicts-2.json", | ||
| "qa/direction-blind-verdicts-3.json", | ||
| "qa/direction-blind-verdicts.json", | ||
| "qa/direction-blind-validation.json", | ||
| "qa/look-continuity.json", | ||
| ] | ||
| def fail(message: str) -> None: | ||
| raise SystemExit(message) | ||
| def read_object(path: Path, label: str) -> dict[str, Any]: | ||
| if not path.is_file() or path.is_symlink(): | ||
| fail(f"{label} is missing or is not a regular file: {path}") | ||
| try: | ||
| value = json.loads(path.read_text(encoding="utf-8")) | ||
| except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: | ||
| fail(f"{label} is not valid UTF-8 JSON: {path}: {exc}") | ||
| if not isinstance(value, dict): | ||
| fail(f"{label} must be a JSON object: {path}") | ||
| return value | ||
| def sha256(path: Path) -> str: | ||
| return hashlib.sha256(path.read_bytes()).hexdigest() | ||
| def artifact(run_dir: Path, relative_path: str) -> dict[str, object]: | ||
| path = run_dir / relative_path | ||
| if not path.is_file() or path.is_symlink(): | ||
| fail(f"QA artifact is missing or is not a regular file: {path}") | ||
| return { | ||
| "path": relative_path, | ||
| "sha256": sha256(path), | ||
| "bytes": path.stat().st_size, | ||
| } | ||
| def validate_jobs(run_dir: Path, run_id: str) -> dict[str, dict[str, object]]: | ||
| manifest = read_object(run_dir / "imagegen-jobs.json", "image generation manifest") | ||
| if manifest.get("run_id") != run_id: | ||
| fail("pet_request.json and imagegen-jobs.json run_id values must match") | ||
| jobs = manifest.get("jobs") | ||
| if not isinstance(jobs, list): | ||
| fail("image generation manifest jobs must be an array") | ||
| if len(jobs) != len(EXPECTED_JOBS) or any(not isinstance(job, dict) for job in jobs): | ||
| fail("image generation manifest must contain exactly 13 job objects") | ||
| by_id = { | ||
| job.get("id"): job | ||
| for job in jobs | ||
| if isinstance(job, dict) and isinstance(job.get("id"), str) | ||
| } | ||
| if len(by_id) != len(jobs) or set(by_id) != EXPECTED_JOBS: | ||
| fail("image generation manifest must contain exactly the 13 hatch-pet jobs") | ||
| result: dict[str, dict[str, object]] = {} | ||
| for job_id in sorted(EXPECTED_JOBS): | ||
| job = by_id[job_id] | ||
| if job.get("status") != "complete": | ||
| fail(f"image generation job is not complete: {job_id}") | ||
| output_path = job.get("output_path") | ||
| if output_path != EXPECTED_JOB_OUTPUTS[job_id]: | ||
| fail(f"image generation job has an invalid output_path: {job_id}") | ||
| completed_at = job.get("completed_at") | ||
| if not isinstance(completed_at, str) or not completed_at: | ||
| fail(f"image generation job has no completed_at provenance: {job_id}") | ||
| source_path = job.get("source_path") | ||
| if not isinstance(source_path, str) or not source_path: | ||
| fail(f"image generation job has no source_path provenance: {job_id}") | ||
| result[job_id] = artifact(run_dir, output_path) | ||
| return result | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--run-dir", required=True) | ||
| parser.add_argument("--output", default="") | ||
| args = parser.parse_args() | ||
| run_dir = Path(args.run_dir).expanduser().resolve() | ||
| request = read_object(run_dir / "pet_request.json", "pet request") | ||
| run_id = request.get("run_id") | ||
| if not isinstance(run_id, str) or not run_id: | ||
| fail("pet_request.run_id must be a non-empty string") | ||
| atlas_relative = "final/spritesheet-extended.webp" | ||
| atlas = run_dir / atlas_relative | ||
| atlas_artifact = artifact(run_dir, atlas_relative) | ||
| with Image.open(atlas) as opened: | ||
| if opened.size != (1536, 2288): | ||
| fail(f"final atlas must be 1536x2288; got {opened.width}x{opened.height}") | ||
| artifacts: dict[str, object] = {"atlas": atlas_artifact} | ||
| for relative_path, expected_size in IMAGE_DIMENSIONS.items(): | ||
| path = run_dir / relative_path | ||
| with Image.open(path) as opened: | ||
| if opened.size != expected_size: | ||
| fail( | ||
| f"{relative_path} must be {expected_size[0]}x{expected_size[1]}; " | ||
| f"got {opened.width}x{opened.height}" | ||
| ) | ||
| artifacts[relative_path] = artifact(run_dir, relative_path) | ||
| for relative_path in JSON_ARTIFACTS: | ||
| read_object(run_dir / relative_path, relative_path) | ||
| artifacts[relative_path] = artifact(run_dir, relative_path) | ||
| preview_artifacts: dict[str, object] = {} | ||
| for state, expected_frames in PREVIEW_FRAMES.items(): | ||
| relative_path = f"qa/previews/{state}.gif" | ||
| path = run_dir / relative_path | ||
| with Image.open(path) as opened: | ||
| if opened.size != (192, 208): | ||
| fail(f"{relative_path} must be 192x208") | ||
| if getattr(opened, "n_frames", 1) != expected_frames: | ||
| fail(f"{relative_path} must contain {expected_frames} frames") | ||
| preview_artifacts[state] = artifact(run_dir, relative_path) | ||
| artifacts["previews"] = preview_artifacts | ||
| canonical_base = artifact(run_dir, "references/canonical-base.png") | ||
| references: dict[str, object] = {} | ||
| raw_references = request.get("references") | ||
| if not isinstance(raw_references, list): | ||
| fail("pet_request.references must be an array") | ||
| for index, entry in enumerate(raw_references): | ||
| if not isinstance(entry, dict): | ||
| fail(f"pet_request.references[{index}] must be an object") | ||
| copied_path = entry.get("copied_path") | ||
| if not isinstance(copied_path, str): | ||
| fail(f"pet_request.references[{index}].copied_path must be a string") | ||
| path = Path(copied_path).resolve() | ||
| try: | ||
| relative_path = str(path.relative_to(run_dir)) | ||
| except ValueError: | ||
| fail(f"copied reference escapes the run directory: {path}") | ||
| references[relative_path] = artifact(run_dir, relative_path) | ||
| output = ( | ||
| Path(args.output).expanduser().resolve() | ||
| if args.output | ||
| else run_dir / "qa" / "final-visual-qa-input.json" | ||
| ) | ||
| payload = { | ||
| "schema_version": 1, | ||
| "run_id": run_id, | ||
| "atlas_sha256": atlas_artifact["sha256"], | ||
| "artifacts": artifacts, | ||
| "job_outputs": validate_jobs(run_dir, run_id), | ||
| "canonical_base": canonical_base, | ||
| "uploaded_references": references, | ||
| } | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") | ||
| print( | ||
| json.dumps( | ||
| { | ||
| "ok": True, | ||
| "run_id": run_id, | ||
| "atlas_sha256": atlas_artifact["sha256"], | ||
| "qa_input": str(output), | ||
| "qa_input_sha256": sha256(output), | ||
| }, | ||
| indent=2, | ||
| ) | ||
| ) | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Create a DimAgent pet run folder, prompts, and imagegen job manifest.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import json | ||
| import math | ||
| import re | ||
| import shutil | ||
| import uuid | ||
| from datetime import datetime, timezone | ||
| from pathlib import Path | ||
| from PIL import Image, ImageDraw | ||
| ATLAS = {"columns": 8, "rows": 11, "cell_width": 192, "cell_height": 208} | ||
| ATLAS["width"] = ATLAS["columns"] * ATLAS["cell_width"] | ||
| ATLAS["height"] = ATLAS["rows"] * ATLAS["cell_height"] | ||
| MAX_DESCRIPTION_CODE_POINTS = 160 | ||
| ROWS = [ | ||
| ("idle", 0, 6, "calm resting, breathing, and blinking loop"), | ||
| ("move_left", 1, 8, "leftward drag movement loop"), | ||
| ("move_right", 2, 8, "rightward drag movement loop"), | ||
| ("waving", 3, 4, "greeting or attention gesture"), | ||
| ("jumping", 4, 5, "hover or playful jump"), | ||
| ("failed", 5, 8, "blocked, failed, or cancelled reaction"), | ||
| ("waiting", 6, 6, "waiting for approval, help, or user input"), | ||
| ("running", 7, 6, "active task work or processing"), | ||
| ("review", 8, 6, "ready or completed output review"), | ||
| ] | ||
| LOOK_ROWS = [ | ||
| ( | ||
| "look-row-9", | ||
| 9, | ||
| ["000", "022.5", "045", "067.5", "090", "112.5", "135", "157.5"], | ||
| "clockwise look directions from up through down-right", | ||
| ), | ||
| ( | ||
| "look-row-10", | ||
| 10, | ||
| ["180", "202.5", "225", "247.5", "270", "292.5", "315", "337.5"], | ||
| "clockwise look directions from down through up-left", | ||
| ), | ||
| ] | ||
| LOOK_CARDINALS = [ | ||
| ("000", "up"), | ||
| ("090", "right"), | ||
| ("180", "down"), | ||
| ("270", "left"), | ||
| ] | ||
| STATE_PROMPTS = { | ||
| "idle": "Calm low-distraction resting loop: subtle breathing, tiny blink, slight head/body bob, and only quiet persona-preserving motion.", | ||
| "move_right": "Dragging-right loop: show directional movement to the right through body and limb poses only.", | ||
| "move_left": "Dragging-left loop: show directional movement to the left through body and limb poses only.", | ||
| "waving": "Greeting loop: paw or limb down, raised, tilted, and returning in a friendly attention gesture.", | ||
| "jumping": "Hover jump loop: anticipation, lift, airborne peak, descent, and settle through body height.", | ||
| "failed": "Blocked/failed loop: slumped or deflated reaction with sad or closed eyes.", | ||
| "waiting": "Needs-input loop: expectant asking pose for approval, help, or user input.", | ||
| "running": "Working loop: focused active-task processing, thinking, typing, scanning, or effortful concentration; not literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, or directional travel.", | ||
| "review": "Ready-review loop: focused inspection of completed output with lean, blink, narrowed eyes, head tilt, or paw pose.", | ||
| } | ||
| STATE_REQUIREMENTS = { | ||
| "idle": [ | ||
| "CRITICAL: idle is the low-distraction baseline state and the first frame is also used as the reduced-motion static pet.", | ||
| "Use only subtle idle motion: gentle breathing, a tiny blink, a slight head or body bob, a very small material sway, or another quiet motion that fits the pet persona.", | ||
| "Keep the pet essentially in the same pose, facing direction, silhouette, markings, palette, and prop state across all 6 frames.", | ||
| "Idle variation must stay calm but still read as animation; do not repeat effectively identical copies across the loop.", | ||
| "Do not show waving, walking, running, jumping, talking, working, reviewing, emotional reactions, large gestures, item interactions, or new props.", | ||
| "Feet, base, body, or object anchor should remain planted or nearly planted.", | ||
| "The first and last frames should be very close visually so the loop feels calm and does not pop.", | ||
| ], | ||
| "waving": [ | ||
| "Show the greeting through paw, hand, wing, or limb pose only.", | ||
| "Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the gesture.", | ||
| ], | ||
| "jumping": [ | ||
| "Show the jump through pose and vertical body position only: anticipation, lift, airborne peak, descent, settle.", | ||
| "Do not draw ground shadows, contact shadows, drop shadows, oval shadows, landing marks, dust, smears, bounce pads, or motion marks under the pet.", | ||
| "Keep the background outside the pet perfectly flat chroma key with no darker key-colored patches.", | ||
| ], | ||
| "failed": [ | ||
| "Show failure through slumped pose, drooping ears/limbs, closed or sad eyes, and lower body position.", | ||
| "Tears, small smoke puffs, or tiny stars are allowed only if attached to or overlapping the pet silhouette and kept inside the same frame slot.", | ||
| "Do not draw red X marks, floating symbols, detached stars, separated smoke clouds, falling tear drops, dust, or other loose effects.", | ||
| ], | ||
| "waiting": [ | ||
| "Show that DimAgent needs approval, help, or user input through an expectant asking pose.", | ||
| "Keep the motion patient and readable, without turning it into ordinary idle or review.", | ||
| ], | ||
| "running": [ | ||
| "Show the pet actively working or processing, as if running a task: focused posture, busy hands or paws, purposeful bobbing, thinking motion, tool or prop motion only if already part of the pet identity, or other non-locomotion activity.", | ||
| "Do not show literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, directional travel, speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.", | ||
| ], | ||
| "review": [ | ||
| "Show review through lean, blink, narrowed eyes, head tilt, or paw/hand position.", | ||
| "Do not add magnifying glasses, papers, code, UI, punctuation, symbols, or other new props unless they already exist in the base pet identity.", | ||
| ], | ||
| "move_right": [ | ||
| "Show directional drag movement to the right through body, limb, and prop movement only.", | ||
| "The row must unmistakably face and travel right.", | ||
| "The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.", | ||
| "Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.", | ||
| ], | ||
| "move_left": [ | ||
| "Show directional drag movement to the left through body, limb, and prop movement only.", | ||
| "The row must unmistakably face and travel left.", | ||
| "The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.", | ||
| "Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.", | ||
| ], | ||
| } | ||
| NON_DERIVABLE_STATES = { | ||
| "waving", | ||
| "jumping", | ||
| "failed", | ||
| "waiting", | ||
| "running", | ||
| "review", | ||
| } | ||
| # Default per-animation FPS matching the DimAgent bundled pet. | ||
| DEFAULT_FPS = { | ||
| "idle": 1, | ||
| "move_left": 8, | ||
| "move_right": 8, | ||
| "waving": 6, | ||
| "jumping": 8, | ||
| "failed": 6, | ||
| "waiting": 6, | ||
| "running": 8, | ||
| "review": 6, | ||
| } | ||
| PET_SAFE_STYLE = ( | ||
| "Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, " | ||
| "clear silhouette, simple face, stable palette/materials, and crisp edges " | ||
| "for chroma-key extraction." | ||
| ) | ||
| STYLE_PRESETS = { | ||
| "auto": ( | ||
| "Infer the most appropriate pet-safe style from the user request and " | ||
| "reference images, then keep that exact style consistent across every row." | ||
| ), | ||
| "pixel": ( | ||
| "Pixel-art-adjacent digital mascot with a chunky silhouette, simple dark " | ||
| "outline, limited palette, flat cel shading, and visible stepped edges." | ||
| ), | ||
| "plush": ( | ||
| "Soft plush toy mascot with rounded stitched forms, fuzzy fabric feel, " | ||
| "simple sewn details, and readable toy-like proportions." | ||
| ), | ||
| "clay": ( | ||
| "Handmade clay or polymer-clay mascot with rounded sculpted forms, soft " | ||
| "material texture, simple features, and clean readable edges." | ||
| ), | ||
| "sticker": ( | ||
| "Polished sticker mascot with bold clean shapes, crisp outline, flat " | ||
| "colors, and minimal highlight detail." | ||
| ), | ||
| "flat-vector": ( | ||
| "Flat vector-style mascot with simple geometric forms, crisp color areas, " | ||
| "clean outline, and minimal shading." | ||
| ), | ||
| "3d-toy": ( | ||
| "Stylized 3D toy mascot with smooth rounded forms, simple materials, " | ||
| "clear silhouette, and no photoreal complexity." | ||
| ), | ||
| "painterly": ( | ||
| "Painterly mascot with simplified brush texture, readable forms, stable " | ||
| "palette, and enough edge clarity for clean extraction." | ||
| ), | ||
| "brand-inspired": ( | ||
| "Brand-inspired mascot using approved public or user-provided brand cues " | ||
| "such as colors, mascot themes, and vibe while avoiding readable text or " | ||
| "logo copying unless explicitly approved." | ||
| ), | ||
| } | ||
| CHROMA_KEY_CANDIDATES = [ | ||
| ("magenta", "#FF00FF"), | ||
| ("cyan", "#00FFFF"), | ||
| ("yellow", "#FFFF00"), | ||
| ("blue", "#0000FF"), | ||
| ("orange", "#FF7F00"), | ||
| ("green", "#00FF00"), | ||
| ] | ||
| DEFAULT_PET_NAME = "Sprout" | ||
| CANONICAL_BASE_PATH = "references/canonical-base.png" | ||
| BRAND_DISCOVERY_PATH = "references/brand-discovery.md" | ||
| LAYOUT_GUIDE_DIR = "references/layout-guides" | ||
| LAYOUT_GUIDE_SAFE_MARGIN_X = 18 | ||
| LAYOUT_GUIDE_SAFE_MARGIN_Y = 16 | ||
| def slugify(value: str) -> str: | ||
| value = value.strip().lower() | ||
| value = re.sub(r"[^a-z0-9]+", "-", value) | ||
| value = re.sub(r"-{2,}", "-", value) | ||
| return value.strip("-") | ||
| def display_from_slug(value: str) -> str: | ||
| words = [word for word in re.split(r"[^a-zA-Z0-9]+", value.strip()) if word] | ||
| return " ".join(word.capitalize() for word in words) | ||
| def concept_words(value: str) -> list[str]: | ||
| stop_words = { | ||
| "a", | ||
| "an", | ||
| "and", | ||
| "app", | ||
| "based", | ||
| "codex", | ||
| "dim", | ||
| "dimagent", | ||
| "dimcode", | ||
| "compact", | ||
| "digital", | ||
| "for", | ||
| "from", | ||
| "in", | ||
| "of", | ||
| "on", | ||
| "pet", | ||
| "ready", | ||
| "small", | ||
| "the", | ||
| "to", | ||
| "with", | ||
| } | ||
| words = [ | ||
| word.lower() | ||
| for word in re.findall(r"[a-zA-Z0-9]+", value) | ||
| if word.lower() not in stop_words | ||
| ] | ||
| return words | ||
| def infer_name(args: argparse.Namespace, reference_paths: list[Path]) -> str: | ||
| for raw_value in [args.display_name, args.pet_name]: | ||
| value = raw_value.strip() | ||
| if value: | ||
| return value | ||
| if args.pet_id.strip(): | ||
| display = display_from_slug(args.pet_id) | ||
| if display: | ||
| return display | ||
| for raw_value in [args.pet_notes, args.description, args.brand_name]: | ||
| words = concept_words(raw_value) | ||
| if words: | ||
| return words[0].capitalize() | ||
| for path in reference_paths: | ||
| display = display_from_slug(path.stem) | ||
| if display: | ||
| return display | ||
| return DEFAULT_PET_NAME | ||
| def sentence(value: str) -> str: | ||
| value = " ".join(value.strip().split()) | ||
| if not value: | ||
| return value | ||
| if value[-1] not in ".!?": | ||
| value += "." | ||
| return value | ||
| def infer_description(args: argparse.Namespace, reference_paths: list[Path]) -> str: | ||
| if args.description.strip(): | ||
| return sentence(args.description) | ||
| if args.pet_notes.strip(): | ||
| return sentence(f"A compact DimAgent pet: {args.pet_notes}") | ||
| if args.brand_name.strip(): | ||
| return sentence(f"A compact DimAgent pet inspired by {args.brand_name}") | ||
| if reference_paths: | ||
| return "A compact DimAgent pet based on the provided reference image." | ||
| return "A compact original DimAgent pet ready for animation." | ||
| def infer_pet_notes(args: argparse.Namespace, reference_paths: list[Path]) -> str: | ||
| if args.pet_notes.strip(): | ||
| return args.pet_notes.strip() | ||
| if args.description.strip(): | ||
| return args.description.strip().rstrip(".") | ||
| if args.brand_name.strip(): | ||
| return f"a compact mascot inspired by {args.brand_name.strip()}" | ||
| if reference_paths: | ||
| return "the pet shown in the reference image(s)" | ||
| return "a compact original DimAgent pet" | ||
| def default_output_dir(pet_id: str) -> Path: | ||
| timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") | ||
| return Path.cwd() / "output" / "hatch-pet" / f"{pet_id}-{timestamp}" | ||
| def rel(path: Path, root: Path) -> str: | ||
| return str(path.resolve().relative_to(root.resolve())) | ||
| def image_metadata(path: Path) -> dict[str, object]: | ||
| with Image.open(path) as image: | ||
| return { | ||
| "path": str(path), | ||
| "width": image.width, | ||
| "height": image.height, | ||
| "mode": image.mode, | ||
| "format": image.format, | ||
| } | ||
| def draw_dashed_line( | ||
| draw: ImageDraw.ImageDraw, | ||
| start: tuple[int, int], | ||
| end: tuple[int, int], | ||
| *, | ||
| fill: str, | ||
| dash: int = 8, | ||
| gap: int = 6, | ||
| ) -> None: | ||
| x1, y1 = start | ||
| x2, y2 = end | ||
| if x1 == x2: | ||
| step = dash + gap | ||
| for y in range(min(y1, y2), max(y1, y2), step): | ||
| draw.line((x1, y, x2, min(y + dash, max(y1, y2))), fill=fill) | ||
| return | ||
| if y1 == y2: | ||
| step = dash + gap | ||
| for x in range(min(x1, x2), max(x1, x2), step): | ||
| draw.line((x, y1, min(x + dash, max(x1, x2)), y2), fill=fill) | ||
| return | ||
| raise ValueError("draw_dashed_line only supports horizontal or vertical lines") | ||
| def create_layout_guide(path: Path, state: str, frames: int) -> dict[str, object]: | ||
| width = frames * ATLAS["cell_width"] | ||
| height = ATLAS["cell_height"] | ||
| cell_width = ATLAS["cell_width"] | ||
| image = Image.new("RGB", (width, height), "#f7f7f7") | ||
| draw = ImageDraw.Draw(image) | ||
| for index in range(frames): | ||
| left = index * cell_width | ||
| right = left + cell_width - 1 | ||
| draw.rectangle((left, 0, right, height - 1), outline="#111111", width=2) | ||
| safe_left = left + LAYOUT_GUIDE_SAFE_MARGIN_X | ||
| safe_top = LAYOUT_GUIDE_SAFE_MARGIN_Y | ||
| safe_right = right - LAYOUT_GUIDE_SAFE_MARGIN_X | ||
| safe_bottom = height - 1 - LAYOUT_GUIDE_SAFE_MARGIN_Y | ||
| draw.rectangle( | ||
| (safe_left, safe_top, safe_right, safe_bottom), | ||
| outline="#2f80ed", | ||
| width=2, | ||
| ) | ||
| center_x = left + cell_width // 2 | ||
| center_y = height // 2 | ||
| draw_dashed_line( | ||
| draw, | ||
| (center_x, safe_top), | ||
| (center_x, safe_bottom), | ||
| fill="#b8b8b8", | ||
| ) | ||
| draw_dashed_line( | ||
| draw, | ||
| (safe_left, center_y), | ||
| (safe_right, center_y), | ||
| fill="#b8b8b8", | ||
| ) | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| image.save(path) | ||
| return { | ||
| "state": state, | ||
| "path": str(path), | ||
| "width": width, | ||
| "height": height, | ||
| "frames": frames, | ||
| "cell_width": ATLAS["cell_width"], | ||
| "cell_height": ATLAS["cell_height"], | ||
| "safe_margin_x": LAYOUT_GUIDE_SAFE_MARGIN_X, | ||
| "safe_margin_y": LAYOUT_GUIDE_SAFE_MARGIN_Y, | ||
| "usage": "layout guide input only; do not copy visible guide lines into generated sprite strips", | ||
| } | ||
| def create_layout_guides(run_dir: Path) -> list[dict[str, object]]: | ||
| guide_dir = run_dir / LAYOUT_GUIDE_DIR | ||
| standard_guides = [ | ||
| create_layout_guide(guide_dir / f"{state}.png", state, frames) | ||
| for state, _row, frames, _purpose in ROWS | ||
| ] | ||
| look_guides = [ | ||
| create_layout_guide(guide_dir / f"{state}.png", state, len(directions)) | ||
| for state, _row, directions, _purpose in LOOK_ROWS | ||
| ] | ||
| cardinal_guide = create_layout_guide( | ||
| guide_dir / "look-cardinals.png", "look-cardinals", len(LOOK_CARDINALS) | ||
| ) | ||
| return [*standard_guides, *look_guides, cardinal_guide] | ||
| def parse_hex_color(value: str) -> tuple[int, int, int]: | ||
| if not re.fullmatch(r"#[0-9a-fA-F]{6}", value): | ||
| raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB") | ||
| return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5)) | ||
| def rgb_to_hex(rgb: tuple[int, int, int]) -> str: | ||
| return f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}" | ||
| def color_distance(left: tuple[int, int, int], right: tuple[int, int, int]) -> float: | ||
| return math.sqrt(sum((left[index] - right[index]) ** 2 for index in range(3))) | ||
| def sampled_reference_pixels(paths: list[Path]) -> list[tuple[int, int, int]]: | ||
| pixels: list[tuple[int, int, int]] = [] | ||
| for path in paths: | ||
| with Image.open(path) as opened: | ||
| image = opened.convert("RGBA") | ||
| image.thumbnail((128, 128), Image.Resampling.LANCZOS) | ||
| data = image.tobytes() | ||
| for index in range(0, len(data), 4): | ||
| red, green, blue, alpha = data[index : index + 4] | ||
| if alpha <= 16: | ||
| continue | ||
| pixels.append((red, green, blue)) | ||
| non_background = [ | ||
| pixel for pixel in pixels if not (pixel[0] > 244 and pixel[1] > 244 and pixel[2] > 244) | ||
| ] | ||
| return non_background or pixels | ||
| def choose_chroma_key(reference_paths: list[Path], requested: str) -> dict[str, object]: | ||
| if requested.lower() != "auto": | ||
| rgb = parse_hex_color(requested) | ||
| return { | ||
| "hex": rgb_to_hex(rgb), | ||
| "rgb": list(rgb), | ||
| "name": "user-selected", | ||
| "selection": "manual", | ||
| } | ||
| pixels = sampled_reference_pixels(reference_paths) | ||
| if not pixels: | ||
| rgb = parse_hex_color("#FF00FF") | ||
| return { | ||
| "hex": "#FF00FF", | ||
| "rgb": list(rgb), | ||
| "name": "magenta", | ||
| "selection": "fallback", | ||
| } | ||
| scored: list[tuple[float, int, str, tuple[int, int, int]]] = [] | ||
| for preference_index, (name, hex_color) in enumerate(CHROMA_KEY_CANDIDATES): | ||
| rgb = parse_hex_color(hex_color) | ||
| distances = sorted(color_distance(rgb, pixel) for pixel in pixels) | ||
| percentile_index = max(0, min(len(distances) - 1, int(len(distances) * 0.01))) | ||
| scored.append((distances[percentile_index], -preference_index, name, rgb)) | ||
| score, _preference, name, rgb = max(scored) | ||
| return { | ||
| "hex": rgb_to_hex(rgb), | ||
| "rgb": list(rgb), | ||
| "name": name, | ||
| "selection": "auto", | ||
| "score": round(score, 2), | ||
| } | ||
| def write_text(path: Path, text: str) -> None: | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| path.write_text(text.rstrip() + "\n", encoding="utf-8") | ||
| def resolved_style_contract(style_preset: str, raw_style_notes: str) -> str: | ||
| style_preset = style_preset.strip().lower() | ||
| if style_preset not in STYLE_PRESETS: | ||
| allowed = ", ".join(sorted(STYLE_PRESETS)) | ||
| raise SystemExit(f"invalid style preset: {style_preset}; expected one of: {allowed}") | ||
| raw_style_notes = raw_style_notes.strip() | ||
| preset_contract = STYLE_PRESETS[style_preset] | ||
| if not raw_style_notes: | ||
| return f"{PET_SAFE_STYLE} Style `{style_preset}`: {preset_contract}" | ||
| return ( | ||
| f"{PET_SAFE_STYLE} Style `{style_preset}`: {preset_contract} " | ||
| f"User style notes: {raw_style_notes}." | ||
| ) | ||
| def compact(value: str) -> str: | ||
| return " ".join(value.strip().split()) | ||
| def brand_inspiration_line(args: argparse.Namespace) -> str: | ||
| brand_name = compact(args.brand_name) | ||
| brand_brief = compact(args.brand_brief) | ||
| if not brand_name and not brand_brief: | ||
| return "" | ||
| prefix = f"{brand_name}: " if brand_name else "" | ||
| if brand_brief: | ||
| return ( | ||
| f"{prefix}{brand_brief} Use only broad mascot-safe cues; do not copy " | ||
| "readable logos, marks, UI screenshots, or text." | ||
| ) | ||
| return ( | ||
| f"{prefix}Use only broad mascot-safe brand cues. Do not copy readable " | ||
| "logos, marks, UI screenshots, or text." | ||
| ) | ||
| def base_pet_prompt(args: argparse.Namespace) -> str: | ||
| pet_notes = args.pet_notes or "the pet shown in the reference image(s)" | ||
| style_contract = resolved_style_contract(args.style_preset, args.style_notes) | ||
| brand_line = brand_inspiration_line(args) | ||
| brand_block = f"\nBrand inspiration: {brand_line}\n" if brand_line else "\n" | ||
| chroma_key = args.chroma_key["hex"] | ||
| chroma_name = args.chroma_key["name"] | ||
| return f"""Create one clean full-body reference sprite for DimAgent pet {args.display_name}. | ||
| Pet identity: {pet_notes}. | ||
| Style: {style_contract} | ||
| {brand_block} | ||
| Place a single centered pose on a perfectly flat pure {chroma_name} {chroma_key} chroma-key background. Keep the full pet visible, compact, readable at 192x208, and easy to animate. Preserve approved reference identity cues. No scenery, text, borders, checkerboard transparency, shadows, glows, detached effects, or extra props. Keep {chroma_key} and close colors out of the pet, props, highlights, and effects.""" | ||
| def row_prompt(args: argparse.Namespace, state: str, row: int, frames: int, purpose: str) -> str: | ||
| pet_notes = args.pet_notes or "the same pet from the approved base reference" | ||
| style_contract = resolved_style_contract(args.style_preset, args.style_notes) | ||
| chroma_key = args.chroma_key["hex"] | ||
| chroma_name = args.chroma_key["name"] | ||
| state_prompt = STATE_PROMPTS[state] | ||
| state_requirements = "\n".join(f"- {line}" for line in STATE_REQUIREMENTS[state]) | ||
| return f"""Create one horizontal animation strip for DimAgent pet `{args.pet_id}`, state `{state}`. | ||
| Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide. | ||
| Output exactly {frames} full-body frames in one left-to-right row on flat pure {chroma_name} {chroma_key}. Treat the row as {frames} invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders. | ||
| Identity: same pet in every frame: {pet_notes}. Preserve silhouette, face, proportions, markings, palette, material, style, and props. | ||
| Style: {style_contract} | ||
| Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame. | ||
| State action: {state_prompt} | ||
| State requirements: | ||
| {state_requirements} | ||
| Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet.""" | ||
| def retry_row_prompt( | ||
| args: argparse.Namespace, state: str, row: int, frames: int, purpose: str | ||
| ) -> str: | ||
| pet_notes = args.pet_notes or "the canonical base pet" | ||
| chroma_key = args.chroma_key["hex"] | ||
| chroma_name = args.chroma_key["name"] | ||
| state_prompt = STATE_PROMPTS[state] | ||
| state_requirements = "\n".join(f"- {line}" for line in STATE_REQUIREMENTS[state]) | ||
| return f"""Create DimAgent pet row `{state}` for `{args.pet_id}`: exactly {frames} full-body frames in one horizontal strip on flat pure {chroma_name} {chroma_key}. | ||
| Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: {pet_notes}. Preserve silhouette, face, palette, material, proportions, markings, and props. | ||
| Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. | ||
| Action: {state_prompt} | ||
| State requirements: | ||
| {state_requirements} | ||
| One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or {chroma_key} colors in the pet.""" | ||
| def look_row_boundary_contract(row: int) -> str: | ||
| if row == 9: | ||
| return ( | ||
| "ROW-BOUNDARY LOCK: 157.5 must be one even 22.5-degree step before 180. " | ||
| "Match the approved 180 pose's body size, baseline, planted anchor, " | ||
| "expression, and construction. Preserve the overall right-hand arc, but " | ||
| "do not distort pupils, nose, or body geometry merely to exaggerate the subtle horizontal component." | ||
| ) | ||
| return ( | ||
| "ROW-BOUNDARY LOCK: 180 must continue directly from row 9's 157.5, matching " | ||
| "its body size, baseline, planted anchor, expression, and construction. " | ||
| "337.5 must be one even 22.5-degree step before 000: nearly up-facing while " | ||
| "remaining on the overall left-hand arc. Do not distort pupils, nose, or body " | ||
| "geometry merely to exaggerate the subtle horizontal component." | ||
| ) | ||
| def look_row_layout_contract() -> str: | ||
| return """HARD LAYOUT AND CONTINUITY CONTRACT — DETERMINISTIC REGISTRATION: draw exactly eight separated pose groups in left-to-right direction order. Keep enough chroma-only space between neighboring poses that each complete pose can be detected without cutting through foreground. Approximate the guide's equal spacing, but do not distort a pose merely to hit an exact source-canvas coordinate; deterministic assembly will crop the eight ordered groups, then apply one shared scale and baseline. | ||
| Use the same body height, head size, baseline, and planted-body position across the generated family. Never overlap neighboring poses, merge two poses into one connected group, crop foreground at the outer canvas edge, or resize one pose independently. | ||
| Keep the feet, base, or lower torso planted at the same coordinates across all eight frames. Express direction through the eyes, face, head, upper body, and physically appropriate prop movement, not by moving, rotating, or rescaling the entire sprite.""" | ||
| def look_row_screen_coordinate_contract(row: int) -> str: | ||
| if row == 9: | ||
| return ( | ||
| "SCREEN-COORDINATE LOCK: screen-right means the viewer's right image edge, " | ||
| "never the character's own right. The row should travel naturally through " | ||
| "the right half of the loop. Near-vertical 022.5 and 157.5 may have subtle " | ||
| "horizontal cues; prioritize a coherent arc over exact pupil or nose placement." | ||
| ) | ||
| return ( | ||
| "SCREEN-COORDINATE LOCK: screen-left means the viewer's left image edge, never " | ||
| "the character's own left. The row should travel naturally through the left half " | ||
| "of the loop. Near-vertical 202.5 and 337.5 may have subtle horizontal cues; " | ||
| "prioritize a coherent arc over exact pupil or nose placement." | ||
| ) | ||
| def look_row_axis_contract(row: int) -> str: | ||
| if row == 9: | ||
| slots = [ | ||
| "1. `000`: vertical UP; no horizontal requirement.", | ||
| "2. `022.5`: horizontal SCREEN-RIGHT and vertical UP.", | ||
| "3. `045`: horizontal SCREEN-RIGHT and vertical UP.", | ||
| "4. `067.5`: horizontal SCREEN-RIGHT and vertical UP.", | ||
| "5. `090`: horizontal SCREEN-RIGHT; no vertical requirement.", | ||
| "6. `112.5`: horizontal SCREEN-RIGHT and vertical DOWN.", | ||
| "7. `135`: horizontal SCREEN-RIGHT and vertical DOWN.", | ||
| "8. `157.5`: horizontal SCREEN-RIGHT and vertical DOWN.", | ||
| ] | ||
| else: | ||
| slots = [ | ||
| "1. `180`: vertical DOWN; no horizontal requirement.", | ||
| "2. `202.5`: horizontal SCREEN-LEFT and vertical DOWN.", | ||
| "3. `225`: horizontal SCREEN-LEFT and vertical DOWN.", | ||
| "4. `247.5`: horizontal SCREEN-LEFT and vertical DOWN.", | ||
| "5. `270`: horizontal SCREEN-LEFT; no vertical requirement.", | ||
| "6. `292.5`: horizontal SCREEN-LEFT and vertical UP.", | ||
| "7. `315`: horizontal SCREEN-LEFT and vertical UP.", | ||
| "8. `337.5`: horizontal SCREEN-LEFT and vertical UP.", | ||
| ] | ||
| return """DIRECTION TARGETS — use these to shape the coherent row, not as pixel-level landmark gates: | ||
| {slots} | ||
| Cardinals must be unmistakable. Intermediate poses should broadly occupy the intended quadrant and advance naturally through the ordered loop. Minor pupil, nose, eyelid, or aiming-feature deviations are acceptable when the overall direction, continuity, identity, and motion remain coherent. Do not deform the character merely to make every intermediate axis independently obvious.""".format( | ||
| slots="\n".join(slots) | ||
| ) | ||
| def look_row_pre_return_check(row: int) -> str: | ||
| boundary_check = ( | ||
| "157.5 does not flow evenly into 180" | ||
| if row == 9 | ||
| else "180 does not continue from 157.5 or 337.5 does not flow evenly into 000" | ||
| ) | ||
| return f"""PRE-RETURN CHECK: reject this result if it does not contain eight separated pose groups in the required order; neighboring poses overlap; foreground is cropped at the outer canvas edge; any frame changes sprite scale, body or head size, baseline, or planted-body position; the row visibly reverses into the wrong half of the loop; or {boundary_check}. Minor intermediate pupil or nose deviations are not rejection reasons. Exact cell cropping, resizing, and recentering happen deterministically after generation.""" | ||
| def look_row_prompt( | ||
| args: argparse.Namespace, | ||
| row: int, | ||
| directions: list[str], | ||
| ) -> str: | ||
| direction_list = ", ".join(directions) | ||
| chroma_key = args.chroma_key["hex"] | ||
| chroma_name = args.chroma_key["name"] | ||
| reference_instruction = ( | ||
| "The approved cardinal strip is authoritative for the up, screen-right, down, " | ||
| "and screen-left pose families. Interpolate the intermediate directions as " | ||
| "even 22.5-degree steps between those anchors." | ||
| if row == 9 | ||
| else "The approved cardinal strip and completed coherent row 9 are authoritative. " | ||
| "Use the cardinals for direction meaning and row 9 for cross-row identity, scale, " | ||
| "registration, and continuity." | ||
| ) | ||
| return f"""Create one horizontal look-direction strip for DimAgent pet `{args.pet_id}`, atlas row {row}. | ||
| Use the attached canonical base, completed standard contact sheet, layout guide, and approved four-cardinal strip for identity, scale, registration, spacing, direction semantics, and cross-row continuity. Read `qa/look-mechanics.md` and follow its pet-specific movement and eye/prop mechanics. {reference_instruction} | ||
| COHERENT SYNTHESIS LOCK: produce one unified eight-pose row. Do not paste, tile, or independently restyle individual cells. Every final cell must be drawn together with the same face construction, body proportions, line/render quality, lighting, materials, scale, baseline, and registration. | ||
| Output exactly 8 complete full-body frames in this exact left-to-right order: {direction_list}. Degrees are clockwise: 000 is up, 090 right, 180 down, and 270 left. Neutral/front is not part of this row. | ||
| {look_row_axis_contract(row)} | ||
| {look_row_screen_coordinate_contract(row)} | ||
| {look_row_layout_contract()} | ||
| Place one centered pose in each invisible equal-width slot on flat pure {chroma_name} {chroma_key}. Change only the natural parts needed to express gaze: eyes, eyelids, head, face, neck, upper body, appendages, and constrained prop follow-through. Keep identity, silhouette, materials, palette, markings, and props consistent. | ||
| {look_row_boundary_contract(row)} | ||
| {look_row_pre_return_check(row)} | ||
| Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement/googly eyes, labels, degree text, arrows, clocks, grids, shadows, glows, scenery, detached effects, or chroma-key colors inside the pet.""" | ||
| def retry_look_row_prompt( | ||
| args: argparse.Namespace, | ||
| row: int, | ||
| directions: list[str], | ||
| ) -> str: | ||
| direction_list = ", ".join(directions) | ||
| chroma_key = args.chroma_key["hex"] | ||
| chroma_name = args.chroma_key["name"] | ||
| return f"""Create DimAgent v2 pet look row {row} for `{args.pet_id}` as exactly 8 full-body frames in this order: {direction_list}. | ||
| Use the canonical base, standard contact sheet, layout guide, approved four-cardinal strip, and `qa/look-mechanics.md`. Draw the complete eight-pose row as one coherent animation family, interpolating even 22.5-degree steps between the cardinal pose families. Keep the same pet identity, face construction, materials, palette, markings, and props. Each direction must read correctly at pet size and join continuously at the 000 and 180 boundaries. | ||
| {look_row_axis_contract(row)} | ||
| {look_row_layout_contract()} | ||
| {look_row_boundary_contract(row)} | ||
| {look_row_pre_return_check(row)} | ||
| Use a flat pure {chroma_name} {chroma_key} background. One complete unclipped pose per invisible slot. No whole-sprite rotation, replacement eyes, labels, guide marks, shadows, glows, scenery, detached effects, or {chroma_key} colors in the pet.""" | ||
| def look_cardinal_prompt(args: argparse.Namespace) -> str: | ||
| chroma_key = args.chroma_key["hex"] | ||
| chroma_name = args.chroma_key["name"] | ||
| return f"""Create one horizontal four-cardinal anchor strip for DimAgent pet `{args.pet_id}`. | ||
| Use the attached canonical base, completed standard contact sheet, and layout guide for exact identity, style, scale, baseline, face construction, materials, palette, markings, props, and spacing. Read `qa/look-mechanics.md` and use the pet's natural gaze mechanism. | ||
| Output exactly four centered complete full-body poses in this exact left-to-right order: `000 up`, `090 screen-right`, `180 down`, `270 screen-left`. Screen-left and screen-right always mean the viewer's image edges, never the character's own left or right. | ||
| For `000`, keep the face broadly frontal and point the eyes and natural head mechanism toward the TOP edge. For `090`, put the nose tip, pupils, face surface, or natural aiming feature on the screen-right side of the head center. For `180`, keep the face broadly frontal and point toward the BOTTOM edge. For `270`, apply the inverse screen-left landmark rule. Every cardinal must be unmistakable without labels. | ||
| Place one pose in each invisible equal-width slot on a flat pure {chroma_name} {chroma_key} background with generous padding. Keep scale, feet/base, lower body, and registration consistent across all four slots. | ||
| Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement eyes, labels, degree text, arrows, boxes, guide marks, shadows, scenery, detached effects, or chroma-key colors inside the pet.""" | ||
| def look_cardinal_repair_prompt( | ||
| args: argparse.Namespace, | ||
| label: str, | ||
| expected_direction: str, | ||
| ) -> str: | ||
| chroma_key = args.chroma_key["hex"] | ||
| chroma_name = args.chroma_key["name"] | ||
| screen_rule = { | ||
| "000": "Keep the face broadly frontal and point the eyes and natural head mechanism toward the TOP edge.", | ||
| "090": "Put the nose tip, pupils, face surface, or natural aiming feature on the screen-right side of the head center.", | ||
| "180": "Keep the face broadly frontal and point the eyes and natural head mechanism toward the BOTTOM edge.", | ||
| "270": "Put the nose tip, pupils, face surface, or natural aiming feature on the screen-left side of the head center.", | ||
| }[label] | ||
| return f"""Repair one cardinal anchor for DimAgent pet `{args.pet_id}`: `{label}` means looking {expected_direction}. | ||
| Use the canonical base, completed standard contact sheet, approved cardinal-strip cells, and `qa/look-mechanics.md` for identity, scale, registration, and pet-specific gaze mechanics. {screen_rule} Screen coordinates are viewer-relative. | ||
| Output one centered complete full-body pose on a flat pure {chroma_name} {chroma_key} background with generous padding. Keep the feet/base and lower body registered to the approved anchors. The requested cardinal must be unmistakable at final 192x208 display size. | ||
| Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement eyes, labels, arrows, guide marks, shadows, scenery, detached effects, or chroma-key colors inside the pet.""" | ||
| def make_jobs( | ||
| run_dir: Path, | ||
| copied_refs: list[dict[str, object]], | ||
| ) -> list[dict[str, object]]: | ||
| reference_inputs = [ | ||
| {"path": rel(Path(str(ref["copied_path"])), run_dir), "role": "pet reference"} | ||
| for ref in copied_refs | ||
| ] | ||
| identity_reference_paths = [CANONICAL_BASE_PATH] | ||
| jobs: list[dict[str, object]] = [ | ||
| { | ||
| "id": "base", | ||
| "kind": "base-pet", | ||
| "status": "pending", | ||
| "prompt_file": "prompts/base-pet.md", | ||
| "input_images": reference_inputs, | ||
| "output_path": "decoded/base.png", | ||
| "depends_on": [], | ||
| "generation_skill": "dim-image", | ||
| "requires_grounded_generation": bool(reference_inputs), | ||
| "allow_prompt_only_generation": not reference_inputs, | ||
| } | ||
| ] | ||
| for state, _row, frames, _purpose in ROWS: | ||
| depends_on = ["base"] | ||
| extra_inputs: list[dict[str, str]] = [] | ||
| derivation_policy: dict[str, object] = { | ||
| "may_derive": False, | ||
| "reason": "state requires its own generated animation semantics", | ||
| } | ||
| if state == "move_left": | ||
| depends_on.append("move_right") | ||
| extra_inputs.append( | ||
| { | ||
| "path": "decoded/move_right.png", | ||
| "role": "rightward gait reference for leftward row decision", | ||
| } | ||
| ) | ||
| derivation_policy = { | ||
| "may_derive": True, | ||
| "may_derive_from": "move_right", | ||
| "derivation": "framewise-horizontal-mirror-preserving-order", | ||
| "requires_explicit_approval": True, | ||
| "fallback_generation_skill": "dim-image", | ||
| } | ||
| elif state not in NON_DERIVABLE_STATES: | ||
| derivation_policy["reason"] = "no deterministic derivation is configured for this state" | ||
| jobs.append( | ||
| { | ||
| "id": state, | ||
| "kind": "row-strip", | ||
| "status": "pending", | ||
| "prompt_file": f"prompts/rows/{state}.md", | ||
| "retry_prompt_file": f"prompts/row-retries/{state}.md", | ||
| "input_images": [ | ||
| *reference_inputs, | ||
| { | ||
| "path": f"{LAYOUT_GUIDE_DIR}/{state}.png", | ||
| "role": f"layout guide for {frames} frame slots; use for spacing only, do not copy guide lines", | ||
| }, | ||
| { | ||
| "path": CANONICAL_BASE_PATH, | ||
| "role": "canonical identity reference", | ||
| }, | ||
| *extra_inputs, | ||
| ], | ||
| "output_path": f"decoded/{state}.png", | ||
| "depends_on": depends_on, | ||
| "generation_skill": "dim-image", | ||
| "requires_grounded_generation": True, | ||
| "allow_prompt_only_generation": False, | ||
| "identity_reference_paths": identity_reference_paths, | ||
| "parallelizable_after": depends_on, | ||
| "derivation_policy": derivation_policy, | ||
| "mirror_policy": derivation_policy if state == "move_left" else {}, | ||
| } | ||
| ) | ||
| standard_job_ids = [state for state, _row, _frames, _purpose in ROWS] | ||
| jobs.append( | ||
| { | ||
| "id": "look-cardinals", | ||
| "kind": "look-cardinal-strip", | ||
| "status": "pending", | ||
| "prompt_file": "prompts/look-cardinals.md", | ||
| "repair_prompt_files": { | ||
| label: f"prompts/look-anchor-repairs/{label}.md" | ||
| for label, _direction in LOOK_CARDINALS | ||
| }, | ||
| "input_images": [ | ||
| *reference_inputs, | ||
| { | ||
| "path": f"{LAYOUT_GUIDE_DIR}/look-cardinals.png", | ||
| "role": "layout guide for four cardinal slots; use for spacing only, do not copy guide lines", | ||
| }, | ||
| { | ||
| "path": CANONICAL_BASE_PATH, | ||
| "role": "canonical identity reference", | ||
| }, | ||
| { | ||
| "path": "qa/contact-sheet.png", | ||
| "role": "approved standard-row identity, scale, and baseline reference", | ||
| }, | ||
| ], | ||
| "output_path": "decoded/look-cardinals.png", | ||
| "extracted_output_paths": [ | ||
| f"decoded/look-anchors/{label}.png" for label, _direction in LOOK_CARDINALS | ||
| ], | ||
| "approved_strip_path": "decoded/look-anchors-approved.png", | ||
| "depends_on": standard_job_ids, | ||
| "generation_skill": "dim-image", | ||
| "requires_grounded_generation": True, | ||
| "allow_prompt_only_generation": False, | ||
| "identity_reference_paths": identity_reference_paths, | ||
| "look_mechanics_file": "qa/look-mechanics.md", | ||
| "directions": [label for label, _direction in LOOK_CARDINALS], | ||
| "packaging_eligible": False, | ||
| "parallelizable_after": standard_job_ids, | ||
| "derivation_policy": { | ||
| "may_derive": False, | ||
| "reason": "cardinal directions require grounded pet-specific generation", | ||
| }, | ||
| } | ||
| ) | ||
| for state, row, directions, _purpose in LOOK_ROWS: | ||
| depends_on = ["look-cardinals"] if row == 9 else ["look-cardinals", "look-row-9"] | ||
| continuity_inputs = ( | ||
| [] | ||
| if row == 9 | ||
| else [ | ||
| { | ||
| "path": "decoded/look-row-9.png", | ||
| "role": "completed first half of the clockwise look loop for row 10 continuity", | ||
| } | ||
| ] | ||
| ) | ||
| jobs.append( | ||
| { | ||
| "id": state, | ||
| "kind": "look-row-strip", | ||
| "status": "pending", | ||
| "prompt_file": f"prompts/rows/{state}.md", | ||
| "retry_prompt_file": f"prompts/row-retries/{state}.md", | ||
| "input_images": [ | ||
| *reference_inputs, | ||
| { | ||
| "path": f"{LAYOUT_GUIDE_DIR}/{state}.png", | ||
| "role": "layout guide for 8 direction slots; use for spacing only, do not copy guide lines", | ||
| }, | ||
| { | ||
| "path": CANONICAL_BASE_PATH, | ||
| "role": "canonical identity reference", | ||
| }, | ||
| { | ||
| "path": "qa/contact-sheet.png", | ||
| "role": "approved standard-row identity, scale, and baseline reference", | ||
| }, | ||
| { | ||
| "path": "decoded/look-anchors-approved.png", | ||
| "role": "approved cardinal reference strip in order 000 up, 090 screen-right, 180 down, 270 screen-left; interpolate intermediate directions evenly", | ||
| }, | ||
| *continuity_inputs, | ||
| ], | ||
| "output_path": f"decoded/{state}.png", | ||
| "depends_on": depends_on, | ||
| "generation_skill": "dim-image", | ||
| "requires_grounded_generation": True, | ||
| "allow_prompt_only_generation": False, | ||
| "identity_reference_paths": identity_reference_paths, | ||
| "look_mechanics_file": "qa/look-mechanics.md", | ||
| "directions": directions, | ||
| "parallelizable_after": depends_on, | ||
| "derivation_policy": { | ||
| "may_derive": False, | ||
| "reason": "look directions require grounded pet-specific generation", | ||
| }, | ||
| "coherent_synthesis_required": True, | ||
| "individual_cell_packaging_allowed": False, | ||
| "packaging_eligible": True, | ||
| } | ||
| ) | ||
| return jobs | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument( | ||
| "--pet-name", | ||
| default="", | ||
| help="User-facing pet name. Ask the user for this when practical; otherwise choose a short appropriate name.", | ||
| ) | ||
| parser.add_argument( | ||
| "--pet-id", | ||
| default="", | ||
| help="Stable pet folder/id slug. Defaults to the slugified pet name.", | ||
| ) | ||
| parser.add_argument( | ||
| "--display-name", | ||
| default="", | ||
| help="Display label. Defaults to the pet name.", | ||
| ) | ||
| parser.add_argument("--description", default="") | ||
| parser.add_argument("--reference", action="append", default=[]) | ||
| parser.add_argument("--output-dir", default="") | ||
| parser.add_argument("--pet-notes", default="") | ||
| parser.add_argument( | ||
| "--brand-name", | ||
| default="", | ||
| help="Brand, company, or product name used for broad mascot inspiration.", | ||
| ) | ||
| parser.add_argument( | ||
| "--brand-brief", | ||
| default="", | ||
| help="Compact researched brand cue sentence for the base pet only.", | ||
| ) | ||
| parser.add_argument( | ||
| "--brand-source", | ||
| action="append", | ||
| default=[], | ||
| help="Source URL used to produce the brand brief. May be passed multiple times.", | ||
| ) | ||
| parser.add_argument( | ||
| "--brand-discovery-file", | ||
| default="", | ||
| help="Optional markdown discovery brief to copy into the run for review.", | ||
| ) | ||
| parser.add_argument( | ||
| "--style-preset", | ||
| default="auto", | ||
| choices=sorted(STYLE_PRESETS), | ||
| help="Pet-safe style preset to use across the base and all animation rows.", | ||
| ) | ||
| parser.add_argument("--style-notes", default="") | ||
| parser.add_argument( | ||
| "--chroma-key", | ||
| default="auto", | ||
| help="Chroma key as #RRGGBB, or auto to choose a safe key from reference colors.", | ||
| ) | ||
| parser.add_argument("--force", action="store_true") | ||
| args = parser.parse_args() | ||
| raw_reference_paths = [Path(raw_path).expanduser().resolve() for raw_path in args.reference] | ||
| raw_brand_discovery_path = ( | ||
| Path(args.brand_discovery_file).expanduser().resolve() | ||
| if args.brand_discovery_file.strip() | ||
| else None | ||
| ) | ||
| args.display_name = infer_name(args, raw_reference_paths) | ||
| args.pet_name = (args.pet_name or args.display_name).strip() | ||
| args.description = infer_description(args, raw_reference_paths) | ||
| if len(args.description.strip()) > MAX_DESCRIPTION_CODE_POINTS: | ||
| raise SystemExit( | ||
| "pet description must be at most " | ||
| f"{MAX_DESCRIPTION_CODE_POINTS} Unicode code points" | ||
| ) | ||
| args.pet_notes = infer_pet_notes(args, raw_reference_paths) | ||
| args.pet_id = slugify(args.pet_id or args.pet_name or args.display_name) | ||
| args.style_preset = args.style_preset.strip().lower() | ||
| args.style_contract = resolved_style_contract(args.style_preset, args.style_notes) | ||
| args.brand_name = compact(args.brand_name) | ||
| args.brand_brief = compact(args.brand_brief) | ||
| args.brand_source = [compact(source) for source in args.brand_source if compact(source)] | ||
| if not args.pet_id: | ||
| raise SystemExit("pet id must contain at least one letter or digit") | ||
| if args.pet_id.startswith("bundled-"): | ||
| raise SystemExit('custom pet id must not use the reserved "bundled-" prefix') | ||
| run_dir = ( | ||
| Path(args.output_dir).expanduser().resolve() | ||
| if args.output_dir | ||
| else default_output_dir(args.pet_id).resolve() | ||
| ) | ||
| if run_dir.exists() and any(run_dir.iterdir()) and not args.force: | ||
| raise SystemExit(f"{run_dir} already exists and is not empty; pass --force to replace it") | ||
| if run_dir.exists() and args.force: | ||
| protected_inputs = [ | ||
| *raw_reference_paths, | ||
| *([] if raw_brand_discovery_path is None else [raw_brand_discovery_path]), | ||
| ] | ||
| for protected_input in protected_inputs: | ||
| try: | ||
| protected_input.relative_to(run_dir) | ||
| except ValueError: | ||
| continue | ||
| raise SystemExit( | ||
| f"refusing --force because input is inside the run directory: {protected_input}" | ||
| ) | ||
| shutil.rmtree(run_dir) | ||
| run_dir.mkdir(parents=True, exist_ok=True) | ||
| run_id = str(uuid.uuid4()) | ||
| ref_dir = run_dir / "references" | ||
| prompt_dir = run_dir / "prompts" | ||
| row_prompt_dir = prompt_dir / "rows" | ||
| row_retry_prompt_dir = prompt_dir / "row-retries" | ||
| look_anchor_repair_prompt_dir = prompt_dir / "look-anchor-repairs" | ||
| for directory in [ | ||
| ref_dir, | ||
| prompt_dir, | ||
| row_prompt_dir, | ||
| row_retry_prompt_dir, | ||
| look_anchor_repair_prompt_dir, | ||
| run_dir / "decoded", | ||
| run_dir / "qa", | ||
| ]: | ||
| directory.mkdir(parents=True, exist_ok=True) | ||
| copied_refs: list[dict[str, object]] = [] | ||
| copied_ref_paths: list[Path] = [] | ||
| for index, source in enumerate(raw_reference_paths, start=1): | ||
| if not source.is_file(): | ||
| raise SystemExit(f"reference not found: {source}") | ||
| suffix = source.suffix.lower() or ".png" | ||
| copied = ref_dir / f"reference-{index:02d}{suffix}" | ||
| shutil.copy2(source, copied) | ||
| meta = image_metadata(copied) | ||
| meta["source_path"] = str(source) | ||
| meta["copied_path"] = str(copied) | ||
| copied_refs.append(meta) | ||
| copied_ref_paths.append(copied) | ||
| brand_discovery_path = "" | ||
| if raw_brand_discovery_path is not None: | ||
| if not raw_brand_discovery_path.is_file(): | ||
| raise SystemExit(f"brand discovery file not found: {raw_brand_discovery_path}") | ||
| copied_discovery = run_dir / BRAND_DISCOVERY_PATH | ||
| shutil.copy2(raw_brand_discovery_path, copied_discovery) | ||
| brand_discovery_path = rel(copied_discovery, run_dir) | ||
| args.chroma_key = choose_chroma_key(copied_ref_paths, args.chroma_key) | ||
| layout_guides = create_layout_guides(run_dir) | ||
| request = { | ||
| "run_id": run_id, | ||
| "pet_id": args.pet_id, | ||
| "display_name": args.display_name, | ||
| "description": args.description, | ||
| "created_at": datetime.now(timezone.utc).isoformat(), | ||
| "format": "dim-sprite-v2", | ||
| "fps": DEFAULT_FPS, | ||
| "atlas": ATLAS, | ||
| "rows": [ | ||
| {"state": state, "row": row, "frames": frames, "purpose": purpose} | ||
| for state, row, frames, purpose in ROWS | ||
| ] | ||
| + [ | ||
| { | ||
| "state": state, | ||
| "row": row, | ||
| "frames": len(directions), | ||
| "directions": directions, | ||
| "purpose": purpose, | ||
| } | ||
| for state, row, directions, purpose in LOOK_ROWS | ||
| ], | ||
| "layout_guides": [ | ||
| {**guide, "path": rel(Path(str(guide["path"])), run_dir)} for guide in layout_guides | ||
| ], | ||
| "references": copied_refs, | ||
| "chroma_key": args.chroma_key, | ||
| "pet_notes": args.pet_notes, | ||
| "style_preset": args.style_preset, | ||
| "style_notes": args.style_notes, | ||
| "style_contract": args.style_contract, | ||
| "brand_name": args.brand_name, | ||
| "brand_brief": args.brand_brief, | ||
| "brand_sources": args.brand_source, | ||
| "pet_safe_style": PET_SAFE_STYLE, | ||
| "primary_generation_skill": "dim-image", | ||
| } | ||
| if brand_discovery_path: | ||
| request["brand_discovery_path"] = brand_discovery_path | ||
| (run_dir / "pet_request.json").write_text( | ||
| json.dumps(request, indent=2) + "\n", encoding="utf-8" | ||
| ) | ||
| write_text(prompt_dir / "base-pet.md", base_pet_prompt(args)) | ||
| for state, row, frames, purpose in ROWS: | ||
| write_text( | ||
| row_prompt_dir / f"{state}.md", | ||
| row_prompt(args, state, row, frames, purpose), | ||
| ) | ||
| write_text( | ||
| row_retry_prompt_dir / f"{state}.md", | ||
| retry_row_prompt(args, state, row, frames, purpose), | ||
| ) | ||
| for state, row, directions, _purpose in LOOK_ROWS: | ||
| write_text( | ||
| row_prompt_dir / f"{state}.md", | ||
| look_row_prompt(args, row, directions), | ||
| ) | ||
| write_text( | ||
| row_retry_prompt_dir / f"{state}.md", | ||
| retry_look_row_prompt(args, row, directions), | ||
| ) | ||
| write_text(prompt_dir / "look-cardinals.md", look_cardinal_prompt(args)) | ||
| for label, expected_direction in LOOK_CARDINALS: | ||
| write_text( | ||
| look_anchor_repair_prompt_dir / f"{label}.md", | ||
| look_cardinal_repair_prompt(args, label, expected_direction), | ||
| ) | ||
| jobs = { | ||
| "schema_version": 1, | ||
| "run_id": run_id, | ||
| "created_at": datetime.now(timezone.utc).isoformat(), | ||
| "run_dir": str(run_dir), | ||
| "primary_generation_skill": "dim-image", | ||
| "jobs": make_jobs(run_dir, copied_refs), | ||
| } | ||
| (run_dir / "imagegen-jobs.json").write_text(json.dumps(jobs, indent=2) + "\n", encoding="utf-8") | ||
| print( | ||
| json.dumps( | ||
| { | ||
| "ok": True, | ||
| "run_dir": str(run_dir), | ||
| "request": str(run_dir / "pet_request.json"), | ||
| "jobs": str(run_dir / "imagegen-jobs.json"), | ||
| "ready_jobs": ["base"], | ||
| }, | ||
| indent=2, | ||
| ) | ||
| ) | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Render lightweight animated QA previews from extracted DimAgent pet frames.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import json | ||
| from pathlib import Path | ||
| from PIL import Image | ||
| ROW_DURATIONS = { | ||
| "idle": [280, 110, 110, 140, 140, 320], | ||
| "move_left": [120, 120, 120, 120, 120, 120, 120, 220], | ||
| "move_right": [120, 120, 120, 120, 120, 120, 120, 220], | ||
| "waving": [140, 140, 140, 280], | ||
| "jumping": [140, 140, 140, 140, 280], | ||
| "failed": [140, 140, 140, 140, 140, 140, 140, 240], | ||
| "waiting": [150, 150, 150, 150, 150, 260], | ||
| "running": [120, 120, 120, 120, 120, 220], | ||
| "review": [150, 150, 150, 150, 150, 280], | ||
| } | ||
| IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"} | ||
| def frame_files(state_dir: Path) -> list[Path]: | ||
| if not state_dir.is_dir(): | ||
| return [] | ||
| return sorted(path for path in state_dir.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES) | ||
| def load_frames(frames_root: Path, state: str, expected_count: int) -> list[Image.Image]: | ||
| files = frame_files(frames_root / state) | ||
| if len(files) != expected_count: | ||
| raise SystemExit( | ||
| f"{state} preview needs {expected_count} frames, found {len(files)} under {frames_root / state}" | ||
| ) | ||
| frames = [] | ||
| for path in files: | ||
| with Image.open(path) as opened: | ||
| frames.append(opened.convert("RGBA")) | ||
| return frames | ||
| def save_preview(frames: list[Image.Image], durations: list[int], output: Path) -> None: | ||
| output.parent.mkdir(parents=True, exist_ok=True) | ||
| frames[0].save( | ||
| output, | ||
| save_all=True, | ||
| append_images=frames[1:], | ||
| duration=durations, | ||
| loop=0, | ||
| disposal=2, | ||
| optimize=False, | ||
| ) | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--frames-root", required=True) | ||
| parser.add_argument("--output-dir", required=True) | ||
| args = parser.parse_args() | ||
| frames_root = Path(args.frames_root).expanduser().resolve() | ||
| output_dir = Path(args.output_dir).expanduser().resolve() | ||
| previews = [] | ||
| for state, durations in ROW_DURATIONS.items(): | ||
| frames = load_frames(frames_root, state, len(durations)) | ||
| output = output_dir / f"{state}.gif" | ||
| save_preview(frames, durations, output) | ||
| previews.append({"state": state, "path": str(output), "frames": len(frames)}) | ||
| result = {"ok": True, "output_dir": str(output_dir), "previews": previews} | ||
| print(json.dumps(result, indent=2)) | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Validate a DimAgent pet spritesheet atlas.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import json | ||
| import math | ||
| import re | ||
| from collections import defaultdict | ||
| from pathlib import Path | ||
| from PIL import Image, ImageFilter | ||
| COLUMNS = 8 | ||
| ROWS = 9 | ||
| EXTENDED_ROWS = 11 | ||
| CELL_WIDTH = 192 | ||
| CELL_HEIGHT = 208 | ||
| ATLAS_WIDTH = COLUMNS * CELL_WIDTH | ||
| ATLAS_HEIGHT = ROWS * CELL_HEIGHT | ||
| EXTENDED_ATLAS_HEIGHT = EXTENDED_ROWS * CELL_HEIGHT | ||
| ROW_BY_INDEX = { | ||
| 0: ("idle", 6), | ||
| 1: ("move_left", 8), | ||
| 2: ("move_right", 8), | ||
| 3: ("waving", 4), | ||
| 4: ("jumping", 5), | ||
| 5: ("failed", 8), | ||
| 6: ("waiting", 6), | ||
| 7: ("running", 6), | ||
| 8: ("review", 6), | ||
| 9: ("look-000-to-157.5", 8), | ||
| 10: ("look-180-to-337.5", 8), | ||
| } | ||
| def parse_hex_color(value: str) -> tuple[int, int, int]: | ||
| if not re.fullmatch(r"#[0-9a-fA-F]{6}", value): | ||
| raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB") | ||
| return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5)) | ||
| def alpha_nonzero_count(image: Image.Image) -> int: | ||
| alpha = image.getchannel("A") | ||
| return sum(alpha.histogram()[1:]) | ||
| def transparent_rgb_residue_count(image: Image.Image) -> int: | ||
| rgba = image.convert("RGBA") | ||
| data = rgba.tobytes() | ||
| count = 0 | ||
| for index in range(0, len(data), 4): | ||
| red, green, blue, alpha = data[index : index + 4] | ||
| if alpha == 0 and (red or green or blue): | ||
| count += 1 | ||
| return count | ||
| def color_distance( | ||
| red: int, | ||
| green: int, | ||
| blue: int, | ||
| key: tuple[int, int, int], | ||
| ) -> float: | ||
| return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2) | ||
| def opaque_chroma_key_count( | ||
| image: Image.Image, | ||
| chroma_key: tuple[int, int, int], | ||
| threshold: float, | ||
| ) -> int: | ||
| rgba = image.convert("RGBA") | ||
| data = rgba.tobytes() | ||
| count = 0 | ||
| for index in range(0, len(data), 4): | ||
| red, green, blue, alpha = data[index : index + 4] | ||
| if alpha > 16 and color_distance(red, green, blue, chroma_key) <= threshold: | ||
| count += 1 | ||
| return count | ||
| def is_chroma_contaminated( | ||
| color: tuple[int, int, int], | ||
| chroma_key: tuple[int, int, int], | ||
| distance_threshold: float, | ||
| ) -> bool: | ||
| return color_distance(*color, chroma_key) <= distance_threshold | ||
| def chroma_fringe_count( | ||
| image: Image.Image, | ||
| *, | ||
| chroma_key: tuple[int, int, int], | ||
| distance_threshold: float, | ||
| edge_radius: int, | ||
| alpha_minimum: int, | ||
| ) -> int: | ||
| rgba = image.convert("RGBA") | ||
| alpha = rgba.getchannel("A") | ||
| visible = [value > 0 for value in alpha.getdata()] | ||
| transparent = Image.new("L", alpha.size) | ||
| transparent.putdata([255 if not value else 0 for value in visible]) | ||
| expanded = transparent.filter(ImageFilter.MaxFilter(edge_radius * 2 + 1)) | ||
| return sum( | ||
| alpha_value >= alpha_minimum | ||
| and nearby_transparency > 0 | ||
| and is_chroma_contaminated( | ||
| color[:3], | ||
| chroma_key, | ||
| distance_threshold, | ||
| ) | ||
| for color, alpha_value, nearby_transparency in zip( | ||
| rgba.getdata(), alpha.getdata(), expanded.getdata() | ||
| ) | ||
| ) | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("atlas") | ||
| parser.add_argument("--json-out") | ||
| parser.add_argument("--min-used-pixels", type=int, default=50) | ||
| parser.add_argument("--near-opaque-threshold", type=float, default=0.95) | ||
| parser.add_argument("--chroma-key", default="#00FF00") | ||
| parser.add_argument("--chroma-leak-threshold", type=float, default=36.0) | ||
| parser.add_argument("--max-chroma-leak-pixels", type=int, default=400) | ||
| parser.add_argument("--chroma-fringe-threshold", type=float, default=96.0) | ||
| parser.add_argument("--chroma-fringe-edge-radius", type=int, default=2) | ||
| parser.add_argument("--chroma-fringe-alpha-minimum", type=int, default=16) | ||
| parser.add_argument("--max-chroma-fringe-pixels", type=int, default=0) | ||
| parser.add_argument("--allow-opaque", action="store_true") | ||
| parser.add_argument("--allow-near-opaque-used-cells", action="store_true") | ||
| parser.add_argument("--allow-chroma-leak", action="store_true") | ||
| parser.add_argument("--allow-chroma-fringe", action="store_true") | ||
| parser.add_argument("--require-v2", action="store_true") | ||
| args = parser.parse_args() | ||
| atlas_path = Path(args.atlas).expanduser().resolve() | ||
| chroma_key = parse_hex_color(args.chroma_key) | ||
| errors: list[str] = [] | ||
| warnings: list[str] = [] | ||
| near_opaque_used_cells: dict[str, list[int]] = defaultdict(list) | ||
| cells: list[dict[str, object]] = [] | ||
| try: | ||
| with Image.open(atlas_path) as opened: | ||
| source_mode = opened.mode | ||
| source_format = opened.format | ||
| image = opened.convert("RGBA") | ||
| except Exception as exc: # noqa: BLE001 | ||
| result = {"ok": False, "errors": [f"could not open atlas: {exc}"], "warnings": []} | ||
| print(json.dumps(result, indent=2)) | ||
| raise SystemExit(1) from exc | ||
| expected_heights = ( | ||
| {EXTENDED_ATLAS_HEIGHT} | ||
| if args.require_v2 | ||
| else { | ||
| ATLAS_HEIGHT, | ||
| EXTENDED_ATLAS_HEIGHT, | ||
| } | ||
| ) | ||
| if image.width != ATLAS_WIDTH or image.height not in expected_heights: | ||
| expected = ( | ||
| f"{ATLAS_WIDTH}x{EXTENDED_ATLAS_HEIGHT} for a v2 pet" | ||
| if args.require_v2 | ||
| else f"{ATLAS_WIDTH}x{ATLAS_HEIGHT} or {ATLAS_WIDTH}x{EXTENDED_ATLAS_HEIGHT}" | ||
| ) | ||
| errors.append(f"expected {expected}, got {image.width}x{image.height}") | ||
| if source_format not in {"PNG", "WEBP"}: | ||
| errors.append(f"expected PNG or WebP, got {source_format}") | ||
| if "A" not in source_mode and not args.allow_opaque: | ||
| errors.append("atlas does not have an alpha channel") | ||
| row_count = image.height // CELL_HEIGHT | ||
| is_extended_atlas = image.height == EXTENDED_ATLAS_HEIGHT | ||
| for row_index in range(row_count): | ||
| state, frame_count = ROW_BY_INDEX[row_index] | ||
| for column_index in range(COLUMNS): | ||
| left = column_index * CELL_WIDTH | ||
| top = row_index * CELL_HEIGHT | ||
| cell = image.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT)) | ||
| nontransparent = alpha_nonzero_count(cell) | ||
| used = column_index < frame_count | ||
| cell_info = { | ||
| "state": state, | ||
| "row": row_index, | ||
| "column": column_index, | ||
| "used": used, | ||
| "nontransparent_pixels": nontransparent, | ||
| } | ||
| chroma_leak_pixels = opaque_chroma_key_count( | ||
| cell, | ||
| chroma_key, | ||
| args.chroma_leak_threshold, | ||
| ) | ||
| cell_info["opaque_chroma_key_pixels"] = chroma_leak_pixels | ||
| chroma_fringe_pixels = chroma_fringe_count( | ||
| cell, | ||
| chroma_key=chroma_key, | ||
| distance_threshold=args.chroma_fringe_threshold, | ||
| edge_radius=args.chroma_fringe_edge_radius, | ||
| alpha_minimum=args.chroma_fringe_alpha_minimum, | ||
| ) | ||
| cell_info["chroma_fringe_pixels"] = chroma_fringe_pixels | ||
| cells.append(cell_info) | ||
| if used and nontransparent < args.min_used_pixels: | ||
| errors.append( | ||
| f"{state} row {row_index} column {column_index} is empty or too sparse ({nontransparent} pixels)" | ||
| ) | ||
| if used and chroma_leak_pixels > args.max_chroma_leak_pixels: | ||
| message = ( | ||
| f"{state} row {row_index} column {column_index} has {chroma_leak_pixels} " | ||
| f"opaque pixels near chroma key {args.chroma_key}; this usually means " | ||
| "the sprite background was not removed" | ||
| ) | ||
| if args.allow_chroma_leak: | ||
| warnings.append(message) | ||
| else: | ||
| errors.append(message) | ||
| if used and chroma_fringe_pixels > args.max_chroma_fringe_pixels: | ||
| message = ( | ||
| f"{state} row {row_index} column {column_index} has {chroma_fringe_pixels} " | ||
| f"visible edge pixels contaminated by chroma key {args.chroma_key}" | ||
| ) | ||
| if args.allow_chroma_fringe: | ||
| warnings.append(message) | ||
| else: | ||
| errors.append(message) | ||
| if used and nontransparent > CELL_WIDTH * CELL_HEIGHT * args.near_opaque_threshold: | ||
| near_opaque_used_cells[f"{state} row {row_index}"].append(column_index) | ||
| if not used and nontransparent != 0: | ||
| errors.append( | ||
| f"{state} row {row_index} unused column {column_index} is not transparent ({nontransparent} pixels)" | ||
| ) | ||
| for row_label, columns in near_opaque_used_cells.items(): | ||
| message = ( | ||
| f"{row_label} has {len(columns)} nearly opaque used cells; " | ||
| "this usually means the sprite has a non-transparent background" | ||
| ) | ||
| if args.allow_near_opaque_used_cells: | ||
| warnings.append(message) | ||
| else: | ||
| errors.append(message) | ||
| alpha_count = alpha_nonzero_count(image) | ||
| if alpha_count == ATLAS_WIDTH * ATLAS_HEIGHT: | ||
| message = "atlas is fully opaque; custom pets require a transparent sprite background" | ||
| if args.allow_opaque: | ||
| warnings.append(message) | ||
| else: | ||
| errors.append(message) | ||
| transparent_rgb_residue = transparent_rgb_residue_count(image) | ||
| if transparent_rgb_residue: | ||
| errors.append( | ||
| f"atlas has {transparent_rgb_residue} fully transparent pixels with non-zero RGB residue" | ||
| ) | ||
| result = { | ||
| "ok": not errors, | ||
| "file": str(atlas_path), | ||
| "format": source_format, | ||
| "mode": source_mode, | ||
| "columns": COLUMNS, | ||
| "rows": row_count, | ||
| "pet_format": "dim-sprite-v2" if is_extended_atlas else "dim-sprite-v1", | ||
| "width": image.width, | ||
| "height": image.height, | ||
| "transparent_rgb_residue_pixels": transparent_rgb_residue, | ||
| "errors": errors, | ||
| "warnings": warnings, | ||
| "cells": cells, | ||
| } | ||
| if args.json_out: | ||
| Path(args.json_out).expanduser().resolve().write_text( | ||
| json.dumps(result, indent=2) + "\n", encoding="utf-8" | ||
| ) | ||
| print(json.dumps({k: v for k, v in result.items() if k != "cells"}, indent=2)) | ||
| raise SystemExit(0 if result["ok"] else 1) | ||
| if __name__ == "__main__": | ||
| main() |
| #!/usr/bin/env python3 | ||
| """Compare blind A/B direction classifications with the hidden answer key.""" | ||
| from __future__ import annotations | ||
| import argparse | ||
| import hashlib | ||
| import json | ||
| from pathlib import Path | ||
| ALLOWED_DIRECTIONS = {"screen-left", "screen-right", "up", "down", "ambiguous"} | ||
| def load_json(path: str) -> dict[str, object]: | ||
| return json.loads(Path(path).expanduser().resolve().read_text(encoding="utf-8")) | ||
| def main() -> None: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--answer-key", required=True) | ||
| parser.add_argument("--verdicts", required=True) | ||
| parser.add_argument("--json-out", required=True) | ||
| args = parser.parse_args() | ||
| answer_key = load_json(args.answer_key) | ||
| verdicts = load_json(args.verdicts) | ||
| expected_by_pair = {entry["pair"]: entry for entry in answer_key.get("pairs", [])} | ||
| verdict_by_pair = {entry["pair"]: entry for entry in verdicts.get("pairs", [])} | ||
| errors: list[str] = [] | ||
| warnings: list[str] = [] | ||
| unconfirmed: list[str] = [] | ||
| results: list[dict[str, object]] = [] | ||
| for pair_id, expected in expected_by_pair.items(): | ||
| verdict = verdict_by_pair.get(pair_id) | ||
| if verdict is None: | ||
| errors.append(f"missing blind verdict for {pair_id}") | ||
| continue | ||
| axis = expected.get("axis", "horizontal") | ||
| gate = expected.get("gate", "hard") | ||
| if gate not in {"hard", "review"}: | ||
| errors.append(f"{pair_id} has invalid gate: {gate!r}") | ||
| gate = "hard" | ||
| result: dict[str, object] = {"pair": pair_id, "axis": axis, "gate": gate} | ||
| for slot in ("A", "B"): | ||
| observed = verdict.get(slot) | ||
| expected_direction = expected[slot].get( | ||
| "expected_direction", | ||
| expected[slot].get("expected_horizontal"), | ||
| ) | ||
| if observed not in ALLOWED_DIRECTIONS: | ||
| errors.append(f"{pair_id} {slot} has invalid classification: {observed!r}") | ||
| elif observed == "ambiguous": | ||
| message = f"{pair_id} {slot} {axis} axis is ambiguous" | ||
| warnings.append(message) | ||
| if gate == "hard": | ||
| unconfirmed.append(message) | ||
| elif observed != expected_direction: | ||
| message = f"{pair_id} {slot} classified {observed}; expected {expected_direction}" | ||
| if gate == "hard": | ||
| errors.append(message) | ||
| else: | ||
| warnings.append(message) | ||
| result[slot] = { | ||
| "observed": observed, | ||
| "expected": expected_direction, | ||
| "source_direction": expected[slot]["source_direction"], | ||
| "pass": observed == expected_direction, | ||
| } | ||
| if verdict.get("A") == verdict.get("B") and verdict.get("A") != "ambiguous": | ||
| message = f"{pair_id} A and B were classified as the same {axis} direction" | ||
| if gate == "hard": | ||
| errors.append(message) | ||
| else: | ||
| warnings.append(message) | ||
| results.append(result) | ||
| extra_pairs = sorted(set(verdict_by_pair) - set(expected_by_pair)) | ||
| if extra_pairs: | ||
| errors.append(f"unexpected blind verdict pairs: {', '.join(extra_pairs)}") | ||
| output = { | ||
| "ok": not errors and not unconfirmed, | ||
| "atlas_sha256": answer_key.get("atlas_sha256"), | ||
| "answer_key_sha256": hashlib.sha256( | ||
| Path(args.answer_key).expanduser().resolve().read_bytes() | ||
| ).hexdigest(), | ||
| "verdicts_sha256": hashlib.sha256( | ||
| Path(args.verdicts).expanduser().resolve().read_bytes() | ||
| ).hexdigest(), | ||
| "errors": errors, | ||
| "warnings": warnings, | ||
| "unconfirmed": unconfirmed, | ||
| "reviewRequired": bool(warnings), | ||
| "pairs": results, | ||
| } | ||
| output_path = Path(args.json_out).expanduser().resolve() | ||
| output_path.parent.mkdir(parents=True, exist_ok=True) | ||
| output_path.write_text(json.dumps(output, indent=2) + "\n", encoding="utf-8") | ||
| print(json.dumps(output, indent=2)) | ||
| if errors or unconfirmed: | ||
| raise SystemExit(1) | ||
| if __name__ == "__main__": | ||
| main() |
Sorry, the diff of this file is too big to display
| import importlib.util | ||
| import json | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| import unittest | ||
| from pathlib import Path | ||
| from PIL import Image, ImageDraw | ||
| SKILL_DIR = Path(__file__).resolve().parents[1] | ||
| ASSEMBLER_PATH = SKILL_DIR / "scripts" / "assemble_extended_atlas.py" | ||
| VALIDATOR_PATH = SKILL_DIR / "scripts" / "validate_atlas.py" | ||
| sys.path.insert(0, str(ASSEMBLER_PATH.parent)) | ||
| SPEC = importlib.util.spec_from_file_location("assemble_extended_atlas", ASSEMBLER_PATH) | ||
| if SPEC is None or SPEC.loader is None: | ||
| raise RuntimeError(f"Unable to load {ASSEMBLER_PATH}") | ||
| ASSEMBLER = importlib.util.module_from_spec(SPEC) | ||
| SPEC.loader.exec_module(ASSEMBLER) | ||
| class DimCodeAtlasContractTest(unittest.TestCase): | ||
| def run_validator(self, atlas: Image.Image) -> tuple[subprocess.CompletedProcess[str], dict]: | ||
| temporary_directory = tempfile.TemporaryDirectory() | ||
| self.addCleanup(temporary_directory.cleanup) | ||
| root = Path(temporary_directory.name) | ||
| atlas_path = root / "atlas.png" | ||
| report_path = root / "validation.json" | ||
| atlas.save(atlas_path) | ||
| completed = subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| str(VALIDATOR_PATH), | ||
| str(atlas_path), | ||
| "--json-out", | ||
| str(report_path), | ||
| "--chroma-key", | ||
| "#FF00FF", | ||
| "--require-v2", | ||
| ], | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| return completed, json.loads(report_path.read_text()) | ||
| def test_v2_assembly_does_not_fill_unused_idle_column_six(self) -> None: | ||
| atlas = Image.new("RGBA", (1536, 2288), (0, 0, 0, 0)) | ||
| look_cell = Image.new("RGBA", (192, 208), (0, 0, 0, 0)) | ||
| ImageDraw.Draw(look_cell).rectangle((30, 30, 80, 100), fill="white") | ||
| ASSEMBLER.paste_look_cells(atlas, [look_cell] * 16) | ||
| unused_idle = atlas.crop((6 * 192, 0, 7 * 192, 208)) | ||
| self.assertIsNone(unused_idle.getbbox()) | ||
| self.assertIsNotNone(atlas.crop((0, 9 * 208, 192, 10 * 208)).getbbox()) | ||
| def test_v2_validator_rejects_a_nontransparent_unused_idle_cell(self) -> None: | ||
| atlas = Image.new("RGBA", (1536, 2288), (0, 0, 0, 0)) | ||
| draw = ImageDraw.Draw(atlas) | ||
| frame_counts = [6, 8, 8, 4, 5, 8, 6, 6, 6, 8, 8] | ||
| for row, frame_count in enumerate(frame_counts): | ||
| for column in range(frame_count): | ||
| left = column * 192 + 60 | ||
| top = row * 208 + 60 | ||
| draw.rectangle((left, top, left + 40, top + 60), fill="black") | ||
| draw.rectangle((6 * 192 + 60, 60, 6 * 192 + 100, 120), fill="black") | ||
| completed, report = self.run_validator(atlas) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertFalse(report["ok"]) | ||
| self.assertTrue( | ||
| any("unused column 6 is not transparent" in error for error in report["errors"]) | ||
| ) | ||
| def test_v2_validator_rejects_a_nine_row_atlas(self) -> None: | ||
| atlas = Image.new("RGBA", (1536, 1872), (0, 0, 0, 0)) | ||
| completed, report = self.run_validator(atlas) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertFalse(report["ok"]) | ||
| self.assertTrue(any("for a v2 pet" in error for error in report["errors"])) | ||
| if __name__ == "__main__": | ||
| unittest.main() |
| import hashlib | ||
| import json | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| import unittest | ||
| from pathlib import Path | ||
| from PIL import Image, ImageDraw | ||
| SKILL_DIR = Path(__file__).resolve().parents[1] | ||
| SCRIPTS_DIR = SKILL_DIR / "scripts" | ||
| PACKAGE = SCRIPTS_DIR / "package_pet.py" | ||
| PREPARE_FINAL_QA = SCRIPTS_DIR / "prepare_final_visual_qa.py" | ||
| FRAME_COUNTS = [6, 8, 8, 4, 5, 8, 6, 6, 6, 8, 8] | ||
| STATES = [ | ||
| "idle", | ||
| "move_left", | ||
| "move_right", | ||
| "waving", | ||
| "jumping", | ||
| "failed", | ||
| "waiting", | ||
| "running", | ||
| "review", | ||
| ] | ||
| JOB_IDS = { | ||
| "base", | ||
| *STATES, | ||
| "look-cardinals", | ||
| "look-row-9", | ||
| "look-row-10", | ||
| } | ||
| DIRECTIONS = [ | ||
| "000", | ||
| "022.5", | ||
| "045", | ||
| "067.5", | ||
| "090", | ||
| "112.5", | ||
| "135", | ||
| "157.5", | ||
| "180", | ||
| "202.5", | ||
| "225", | ||
| "247.5", | ||
| "270", | ||
| "292.5", | ||
| "315", | ||
| "337.5", | ||
| ] | ||
| FPS = { | ||
| "idle": 8, | ||
| "move_left": 12, | ||
| "move_right": 12, | ||
| "waving": 10, | ||
| "jumping": 12, | ||
| "failed": 8, | ||
| "waiting": 8, | ||
| "running": 12, | ||
| "review": 8, | ||
| } | ||
| def write_json(path: Path, value: object) -> None: | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") | ||
| def read_json(path: Path) -> dict: | ||
| return json.loads(path.read_text(encoding="utf-8")) | ||
| def sha256(path: Path) -> str: | ||
| return hashlib.sha256(path.read_bytes()).hexdigest() | ||
| def run_script(script_name: str, *arguments: object) -> None: | ||
| subprocess.run( | ||
| [sys.executable, str(SCRIPTS_DIR / script_name), *(str(value) for value in arguments)], | ||
| check=True, | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| def save_preview(path: Path, frame_count: int) -> None: | ||
| frames: list[Image.Image] = [] | ||
| for index in range(frame_count): | ||
| frame = Image.new("RGBA", (192, 208), (0, 0, 0, 0)) | ||
| draw = ImageDraw.Draw(frame) | ||
| draw.rectangle( | ||
| (50 + index, 55, 110 + index, 145), | ||
| fill=(20, 40 + index, 80, 255), | ||
| ) | ||
| frames.append(frame) | ||
| frames[0].save( | ||
| path, | ||
| format="GIF", | ||
| save_all=True, | ||
| append_images=frames[1:], | ||
| duration=80, | ||
| loop=0, | ||
| disposal=2, | ||
| optimize=False, | ||
| ) | ||
| def create_valid_run(root: Path) -> Path: | ||
| run_dir = root / "run" | ||
| final_dir = run_dir / "final" | ||
| qa_dir = run_dir / "qa" | ||
| references_dir = run_dir / "references" | ||
| decoded_dir = run_dir / "decoded" | ||
| final_dir.mkdir(parents=True) | ||
| (qa_dir / "previews").mkdir(parents=True) | ||
| references_dir.mkdir(parents=True) | ||
| decoded_dir.mkdir(parents=True) | ||
| atlas = Image.new("RGBA", (1536, 2288), (0, 0, 0, 0)) | ||
| draw = ImageDraw.Draw(atlas) | ||
| for row, frame_count in enumerate(FRAME_COUNTS): | ||
| for column in range(frame_count): | ||
| left = column * 192 + 60 | ||
| top = row * 208 + 60 | ||
| draw.rectangle( | ||
| (left, top, left + 50, top + 70), | ||
| fill=(20 + row, 40 + column, 80, 255), | ||
| ) | ||
| extended_atlas = final_dir / "spritesheet-extended.webp" | ||
| atlas.save( | ||
| extended_atlas, | ||
| format="WEBP", | ||
| lossless=True, | ||
| quality=100, | ||
| method=6, | ||
| exact=True, | ||
| ) | ||
| atlas.crop((0, 0, 1536, 1872)).save( | ||
| final_dir / "spritesheet.webp", | ||
| format="WEBP", | ||
| lossless=True, | ||
| quality=100, | ||
| method=6, | ||
| exact=True, | ||
| ) | ||
| run_id = "test-run-001" | ||
| write_json( | ||
| run_dir / "pet_request.json", | ||
| { | ||
| "run_id": run_id, | ||
| "pet_id": "upload-buddy", | ||
| "display_name": "Upload Buddy", | ||
| "description": "A helpful uploaded companion.", | ||
| "format": "dim-sprite-v2", | ||
| "fps": FPS, | ||
| "chroma_key": {"hex": "#FF00FF", "name": "magenta"}, | ||
| "references": [], | ||
| }, | ||
| ) | ||
| canonical_base = references_dir / "canonical-base.png" | ||
| Image.new("RGBA", (192, 208), (20, 40, 80, 255)).save(canonical_base) | ||
| jobs = [] | ||
| for index, job_id in enumerate(sorted(JOB_IDS)): | ||
| output_path = decoded_dir / f"{job_id}.png" | ||
| Image.new("RGBA", (32, 32), (20 + index, 40, 80, 255)).save(output_path) | ||
| jobs.append( | ||
| { | ||
| "id": job_id, | ||
| "status": "complete", | ||
| "output_path": str(output_path.relative_to(run_dir)), | ||
| "source_path": f"/generated/{job_id}.png", | ||
| "completed_at": "2026-07-28T00:00:00Z", | ||
| } | ||
| ) | ||
| write_json(run_dir / "imagegen-jobs.json", {"run_id": run_id, "jobs": jobs}) | ||
| write_json(final_dir / "validation-extended.json", {"ok": True, "rows": 11}) | ||
| write_json(qa_dir / "chroma-despill-extended.json", {"ok": True}) | ||
| write_json(qa_dir / "look-continuity.json", {"ok": True, "warnings": []}) | ||
| write_json(qa_dir / "review.json", {"errors": [], "warnings": []}) | ||
| run_script( | ||
| "make_contact_sheet.py", | ||
| final_dir / "spritesheet.webp", | ||
| "--output", | ||
| qa_dir / "contact-sheet.png", | ||
| ) | ||
| run_script( | ||
| "make_contact_sheet.py", | ||
| extended_atlas, | ||
| "--output", | ||
| qa_dir / "contact-sheet-extended.png", | ||
| ) | ||
| run_script( | ||
| "make_direction_qa_sheet.py", | ||
| extended_atlas, | ||
| "--output", | ||
| qa_dir / "look-directions.png", | ||
| ) | ||
| run_script( | ||
| "make_direction_blind_qa_sheet.py", | ||
| extended_atlas, | ||
| "--output", | ||
| qa_dir / "direction-blind-pairs.png", | ||
| "--answer-key", | ||
| qa_dir / "direction-blind-answer-key.json", | ||
| ) | ||
| answer_key = read_json(qa_dir / "direction-blind-answer-key.json") | ||
| verdict_pairs = [ | ||
| { | ||
| "pair": entry["pair"], | ||
| "A": entry["A"]["expected_direction"], | ||
| "B": entry["B"]["expected_direction"], | ||
| "reason": "test landmark evidence", | ||
| } | ||
| for entry in answer_key["pairs"] | ||
| ] | ||
| verdict_paths = [] | ||
| for index in range(1, 4): | ||
| verdict_path = qa_dir / f"direction-blind-verdicts-{index}.json" | ||
| write_json(verdict_path, {"pairs": verdict_pairs}) | ||
| verdict_paths.append(verdict_path) | ||
| combine_arguments: list[object] = [] | ||
| for verdict_path in verdict_paths: | ||
| combine_arguments.extend(["--verdicts", verdict_path]) | ||
| run_script( | ||
| "combine_direction_blind_verdicts.py", | ||
| *combine_arguments, | ||
| "--json-out", | ||
| qa_dir / "direction-blind-verdicts.json", | ||
| ) | ||
| run_script( | ||
| "validate_direction_blind_verdicts.py", | ||
| "--answer-key", | ||
| qa_dir / "direction-blind-answer-key.json", | ||
| "--verdicts", | ||
| qa_dir / "direction-blind-verdicts.json", | ||
| "--json-out", | ||
| qa_dir / "direction-blind-validation.json", | ||
| ) | ||
| for state, frame_count in zip(STATES, FRAME_COUNTS[:9], strict=True): | ||
| save_preview(qa_dir / "previews" / f"{state}.gif", frame_count) | ||
| run_script("prepare_final_visual_qa.py", "--run-dir", run_dir) | ||
| atlas_sha256 = sha256(extended_atlas) | ||
| write_json( | ||
| qa_dir / "direction-semantics.json", | ||
| { | ||
| "run_id": run_id, | ||
| "atlas_sha256": atlas_sha256, | ||
| "directions": [ | ||
| { | ||
| "direction": direction, | ||
| "verdict": "pass", | ||
| "expected": "test direction", | ||
| "observed": "test landmark", | ||
| "reason": "test acceptance", | ||
| } | ||
| for direction in DIRECTIONS | ||
| ], | ||
| }, | ||
| ) | ||
| qa_input = qa_dir / "final-visual-qa-input.json" | ||
| direction_semantics = qa_dir / "direction-semantics.json" | ||
| write_json( | ||
| qa_dir / "final-visual-qa.json", | ||
| { | ||
| "ok": True, | ||
| "verdict": "pass", | ||
| "run_id": run_id, | ||
| "atlas_sha256": atlas_sha256, | ||
| "qa_input_sha256": sha256(qa_input), | ||
| "direction_semantics_sha256": sha256(direction_semantics), | ||
| "reviewed_by": "independent-visual-worker", | ||
| "qa_note": "All test visuals are acceptable.", | ||
| }, | ||
| ) | ||
| return run_dir | ||
| def package(run_dir: Path, pets_root: Path) -> subprocess.CompletedProcess[str]: | ||
| return subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| str(PACKAGE), | ||
| "--run-dir", | ||
| str(run_dir), | ||
| "--pets-root", | ||
| str(pets_root), | ||
| ], | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| class PackagePetTest(unittest.TestCase): | ||
| def test_installs_only_manifest_and_spritesheet_atomically(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| run_dir = create_valid_run(root) | ||
| pets_root = root / "pets" | ||
| completed = package(run_dir, pets_root) | ||
| self.assertEqual(completed.returncode, 0, completed.stderr) | ||
| result = json.loads(completed.stdout) | ||
| pet_dir = pets_root / "upload-buddy" | ||
| manifest = read_json(pet_dir / "pet.json") | ||
| self.assertEqual( | ||
| sorted(path.name for path in pet_dir.iterdir()), | ||
| ["pet.json", "spritesheet.webp"], | ||
| ) | ||
| self.assertEqual( | ||
| manifest, | ||
| { | ||
| "id": "upload-buddy", | ||
| "displayName": "Upload Buddy", | ||
| "description": "A helpful uploaded companion.", | ||
| "spriteVersionNumber": 2, | ||
| "spritesheetPath": "spritesheet.webp", | ||
| }, | ||
| ) | ||
| self.assertEqual(Path(result["package"]), pet_dir.resolve()) | ||
| self.assertTrue((run_dir / "qa" / "package-validation.json").is_file()) | ||
| summary = read_json(run_dir / "qa" / "run-summary.json") | ||
| self.assertEqual(summary["run_id"], "test-run-001") | ||
| self.assertEqual( | ||
| summary["atlas_sha256"], | ||
| sha256(run_dir / "final" / "spritesheet-extended.webp"), | ||
| ) | ||
| self.assertEqual(list(pets_root.glob(".pet-install-upload-buddy-*")), []) | ||
| def test_rejects_description_longer_than_160_code_points(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| run_dir = create_valid_run(root) | ||
| pets_root = root / "pets" | ||
| request_path = run_dir / "pet_request.json" | ||
| request = read_json(request_path) | ||
| request["description"] = "😀" * 161 | ||
| write_json(request_path, request) | ||
| completed = package(run_dir, pets_root) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertIn("at most 160 Unicode code points", completed.stderr) | ||
| self.assertFalse((pets_root / "upload-buddy").exists()) | ||
| def test_failed_qa_leaves_no_partial_pet_or_temp_directory(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| run_dir = create_valid_run(root) | ||
| pets_root = root / "pets" | ||
| (run_dir / "qa" / "direction-semantics.json").unlink() | ||
| completed = package(run_dir, pets_root) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertFalse((pets_root / "upload-buddy").exists()) | ||
| self.assertEqual( | ||
| list(pets_root.glob(".pet-install-upload-buddy-*")) | ||
| if pets_root.exists() | ||
| else [], | ||
| [], | ||
| ) | ||
| def test_existing_pet_id_fails_without_overwrite(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| run_dir = create_valid_run(root) | ||
| pets_root = root / "pets" | ||
| existing = pets_root / "upload-buddy" | ||
| existing.mkdir(parents=True) | ||
| marker = existing / "keep.txt" | ||
| marker.write_text("keep", encoding="utf-8") | ||
| completed = package(run_dir, pets_root) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertEqual(marker.read_text(encoding="utf-8"), "keep") | ||
| self.assertEqual(list(pets_root.glob(".pet-install-upload-buddy-*")), []) | ||
| def test_stale_final_visual_report_fails_before_install(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| run_dir = create_valid_run(root) | ||
| pets_root = root / "pets" | ||
| report_path = run_dir / "qa" / "final-visual-qa.json" | ||
| report = read_json(report_path) | ||
| report["atlas_sha256"] = "0" * 64 | ||
| write_json(report_path, report) | ||
| completed = package(run_dir, pets_root) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertIn("stale or mismatched", completed.stderr) | ||
| self.assertFalse((pets_root / "upload-buddy").exists()) | ||
| def test_direction_semantics_modified_after_review_fails_before_install(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| run_dir = create_valid_run(root) | ||
| pets_root = root / "pets" | ||
| semantics_path = run_dir / "qa" / "direction-semantics.json" | ||
| semantics = read_json(semantics_path) | ||
| semantics["directions"][0]["reason"] = "modified after independent review" | ||
| write_json(semantics_path, semantics) | ||
| completed = package(run_dir, pets_root) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertIn("stale or mismatched", completed.stderr) | ||
| self.assertFalse((pets_root / "upload-buddy").exists()) | ||
| def test_placeholder_visual_artifact_fails_before_install(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| run_dir = create_valid_run(root) | ||
| pets_root = root / "pets" | ||
| Image.new("RGB", (16, 16), "white").save( | ||
| run_dir / "qa" / "contact-sheet-extended.png" | ||
| ) | ||
| completed = package(run_dir, pets_root) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertIn("must be 768x1386", completed.stderr) | ||
| self.assertFalse((pets_root / "upload-buddy").exists()) | ||
| def test_incomplete_generation_job_fails_before_install(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| run_dir = create_valid_run(root) | ||
| pets_root = root / "pets" | ||
| jobs_path = run_dir / "imagegen-jobs.json" | ||
| manifest = read_json(jobs_path) | ||
| manifest["jobs"][0]["status"] = "pending" | ||
| write_json(jobs_path, manifest) | ||
| completed = package(run_dir, pets_root) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertIn("job is not complete", completed.stderr) | ||
| self.assertFalse((pets_root / "upload-buddy").exists()) | ||
| def test_success_reports_are_written_before_atomic_install(self) -> None: | ||
| source = PACKAGE.read_text(encoding="utf-8") | ||
| package_report = source.index("write_json(package_validation_path, validation)") | ||
| summary_report = source.index("write_json(\n run_summary_path") | ||
| install = source.index("os.replace(staged_dir, target_dir)") | ||
| self.assertLess(package_report, install) | ||
| self.assertLess(summary_report, install) | ||
| self.assertNotIn("write_json(", source[install:]) | ||
| if __name__ == "__main__": | ||
| unittest.main() |
| import json | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| import unittest | ||
| from pathlib import Path | ||
| from PIL import Image | ||
| SKILL_DIR = Path(__file__).resolve().parents[1] | ||
| PREPARE = SKILL_DIR / "scripts" / "prepare_pet_run.py" | ||
| STANDARD_STATES = [ | ||
| "idle", | ||
| "move_left", | ||
| "move_right", | ||
| "waving", | ||
| "jumping", | ||
| "failed", | ||
| "waiting", | ||
| "running", | ||
| "review", | ||
| ] | ||
| class PreparePetRunTest(unittest.TestCase): | ||
| def test_uploaded_reference_grounds_every_generated_row(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| reference = root / "my-character.png" | ||
| Image.new("RGBA", (96, 96), (20, 40, 80, 255)).save(reference) | ||
| run_dir = root / "run" | ||
| subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| str(PREPARE), | ||
| "--pet-name", | ||
| "Upload Buddy", | ||
| "--reference", | ||
| str(reference), | ||
| "--output-dir", | ||
| str(run_dir), | ||
| ], | ||
| check=True, | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| request = json.loads((run_dir / "pet_request.json").read_text()) | ||
| jobs = json.loads((run_dir / "imagegen-jobs.json").read_text())["jobs"] | ||
| self.assertEqual(request["format"], "dim-sprite-v2") | ||
| self.assertNotIn("sprite_version_number", request) | ||
| self.assertEqual( | ||
| [row["state"] for row in request["rows"][:9]], | ||
| STANDARD_STATES, | ||
| ) | ||
| self.assertEqual(set(request["fps"]), set(STANDARD_STATES)) | ||
| self.assertEqual(len(request["references"]), 1) | ||
| base = next(job for job in jobs if job["id"] == "base") | ||
| self.assertTrue(base["requires_grounded_generation"]) | ||
| self.assertFalse(base["allow_prompt_only_generation"]) | ||
| self.assertEqual(base["input_images"][0]["path"], "references/reference-01.png") | ||
| for job in jobs: | ||
| if job["id"] == "base": | ||
| continue | ||
| self.assertTrue(job["requires_grounded_generation"], job["id"]) | ||
| self.assertFalse(job["allow_prompt_only_generation"], job["id"]) | ||
| paths = {image["path"] for image in job["input_images"]} | ||
| self.assertIn("references/reference-01.png", paths, job["id"]) | ||
| self.assertIn("references/canonical-base.png", paths, job["id"]) | ||
| move_left = next(job for job in jobs if job["id"] == "move_left") | ||
| self.assertIn("move_right", move_left["depends_on"]) | ||
| self.assertEqual( | ||
| move_left["mirror_policy"]["may_derive_from"], | ||
| "move_right", | ||
| ) | ||
| def test_reserved_custom_pet_id_fails_before_creating_a_run(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| run_dir = Path(temporary_directory) / "run" | ||
| completed = subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| str(PREPARE), | ||
| "--pet-id", | ||
| "bundled-user-pet", | ||
| "--output-dir", | ||
| str(run_dir), | ||
| ], | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertIn('must not use the reserved "bundled-" prefix', completed.stderr) | ||
| self.assertFalse(run_dir.exists()) | ||
| def test_long_description_fails_before_creating_a_run(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| run_dir = Path(temporary_directory) / "run" | ||
| completed = subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| str(PREPARE), | ||
| "--description", | ||
| "😀" * 161, | ||
| "--output-dir", | ||
| str(run_dir), | ||
| ], | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertIn("at most 160 Unicode code points", completed.stderr) | ||
| self.assertFalse(run_dir.exists()) | ||
| def test_missing_uploaded_reference_fails_explicitly(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| completed = subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| str(PREPARE), | ||
| "--reference", | ||
| str(root / "missing.png"), | ||
| "--output-dir", | ||
| str(root / "run"), | ||
| ], | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertIn("reference not found", completed.stderr) | ||
| def test_force_replaces_the_entire_prior_run_and_assigns_a_new_run_id(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| first_reference = root / "first.png" | ||
| second_reference = root / "second.png" | ||
| Image.new("RGBA", (32, 32), (10, 20, 30, 255)).save(first_reference) | ||
| Image.new("RGBA", (32, 32), (40, 50, 60, 255)).save(second_reference) | ||
| run_dir = root / "run" | ||
| subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| str(PREPARE), | ||
| "--pet-name", | ||
| "First Pet", | ||
| "--reference", | ||
| str(first_reference), | ||
| "--output-dir", | ||
| str(run_dir), | ||
| ], | ||
| check=True, | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| first_request = json.loads((run_dir / "pet_request.json").read_text()) | ||
| stale_artifact = run_dir / "final" / "stale.webp" | ||
| stale_artifact.parent.mkdir() | ||
| stale_artifact.write_bytes(b"stale") | ||
| subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| str(PREPARE), | ||
| "--pet-name", | ||
| "Second Pet", | ||
| "--reference", | ||
| str(second_reference), | ||
| "--output-dir", | ||
| str(run_dir), | ||
| "--force", | ||
| ], | ||
| check=True, | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| second_request = json.loads((run_dir / "pet_request.json").read_text()) | ||
| self.assertNotEqual(first_request["run_id"], second_request["run_id"]) | ||
| self.assertEqual(second_request["display_name"], "Second Pet") | ||
| self.assertFalse(stale_artifact.exists()) | ||
| self.assertEqual( | ||
| Path(second_request["references"][0]["source_path"]), | ||
| second_reference.resolve(), | ||
| ) | ||
| def test_force_rejects_an_input_inside_the_run_directory(self) -> None: | ||
| with tempfile.TemporaryDirectory() as temporary_directory: | ||
| root = Path(temporary_directory) | ||
| run_dir = root / "run" | ||
| run_dir.mkdir() | ||
| reference = run_dir / "input.png" | ||
| Image.new("RGBA", (32, 32), (10, 20, 30, 255)).save(reference) | ||
| completed = subprocess.run( | ||
| [ | ||
| sys.executable, | ||
| str(PREPARE), | ||
| "--reference", | ||
| str(reference), | ||
| "--output-dir", | ||
| str(run_dir), | ||
| "--force", | ||
| ], | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| self.assertNotEqual(completed.returncode, 0) | ||
| self.assertIn("input is inside the run directory", completed.stderr) | ||
| self.assertTrue(reference.exists()) | ||
| if __name__ == "__main__": | ||
| unittest.main() |
| import unittest | ||
| from pathlib import Path | ||
| SKILL = Path(__file__).resolve().parents[1] / "SKILL.md" | ||
| class SingleFinalChromaPassTest(unittest.TestCase): | ||
| def test_cleanup_runs_only_after_v2_assembly(self) -> None: | ||
| instructions = SKILL.read_text() | ||
| self.assertEqual(instructions.count("scripts/despill_chroma_edges.py"), 1) | ||
| self.assertNotIn("chroma-despill-standard.json", instructions) | ||
| self.assertLess( | ||
| instructions.index("scripts/assemble_extended_atlas.py"), | ||
| instructions.index("scripts/despill_chroma_edges.py"), | ||
| ) | ||
| if __name__ == "__main__": | ||
| unittest.main() |
@@ -9,3 +9,3 @@ --- | ||
| Use this skill only in DimAgent Desktop. It drives the user's real Chrome | ||
| through the built-in `chrome` tool (Desktop → Native Messaging → the DimCode | ||
| through the built-in `chrome` tool (Desktop → Native Messaging → the DimAgent | ||
| Chrome extension → CDP). It is separate from the in-app Desktop preview. | ||
@@ -12,0 +12,0 @@ |
| interface: | ||
| display_name: "Dim Modality" | ||
| short_description: "Use DimCode multimodal CLI" | ||
| default_prompt: "Use $dim-modality to create or process media with the DimCode multimodal CLI." | ||
| short_description: "Use DimAgent multimodal CLI" | ||
| default_prompt: "Use $dim-modality to create or process media with the DimAgent multimodal CLI." |
| --- | ||
| name: dim-modality | ||
| description: Use DimCode multimodal CLI when the user asks the agent to generate or edit images, generate video, synthesize speech, transcribe audio, generate music or sound effects, create 3D assets or motion data, extract exact text from images or PDFs with OCR, or inspect available multimodal defaults through `dim modality`. This skill teaches when to use the CLI, the main execution path, failure handling, and model-specific notes. | ||
| description: Use DimAgent multimodal CLI when the user asks the agent to generate or edit images, generate video, synthesize speech, transcribe audio, generate music or sound effects, create 3D assets or motion data, extract exact text from images or PDFs with OCR, or inspect available multimodal defaults through `dim modality`. This skill teaches when to use the CLI, the main execution path, failure handling, and model-specific notes. | ||
| --- | ||
@@ -8,3 +8,3 @@ | ||
| Use this skill when media work should go through the DimCode multimodal CLI instead of legacy image tools or ad hoc provider calls. | ||
| Use this skill when media work should go through the DimAgent multimodal CLI instead of legacy image tools or ad hoc provider calls. | ||
@@ -117,3 +117,3 @@ ## When To Use | ||
| | Unsupported capability | Use a model that supports the capability, or ask the user to configure one | | ||
| | OCR runtime unavailable (`local_runtime_unavailable`) | Tell the user the bundled OCR engine is missing or corrupt; recommend reinstalling DimCode. Do not attempt a remote or Python fallback | | ||
| | OCR runtime unavailable (`local_runtime_unavailable`) | Tell the user the bundled OCR engine is missing or corrupt; recommend reinstalling DimAgent. Do not attempt a remote or Python fallback | | ||
| | Output file missing | Do not report success; explain the CLI JSON error and stderr | | ||
@@ -120,0 +120,0 @@ | Video timeout | Report timeout details and operation id when available | |
@@ -12,3 +12,3 @@ --- | ||
| In DimCode Desktop the `officecli` binary is bundled and already on the agent | ||
| In DimAgent Desktop the `officecli` binary is bundled and already on the agent | ||
| shell PATH — there is nothing to install. Verify availability before first use: | ||
@@ -20,6 +20,6 @@ | ||
| If the command is missing, tell the user to update DimCode Desktop to a build | ||
| If the command is missing, tell the user to update DimAgent Desktop to a build | ||
| that bundles OfficeCLI. Do NOT attempt to download or replace the bundled | ||
| binary yourself — DimCode owns its lifecycle and disables its self-update | ||
| mechanism at startup. (Outside DimCode, `curl -fsSL https://d.officecli.ai/install.sh | bash` still works.) | ||
| binary yourself — DimAgent owns its lifecycle and disables its self-update | ||
| mechanism at startup. (Outside DimAgent, `curl -fsSL https://d.officecli.ai/install.sh | bash` still works.) | ||
@@ -375,3 +375,3 @@ --- | ||
| ## DimCode DOCX Safety Overrides | ||
| ## DimAgent DOCX Safety Overrides | ||
@@ -422,3 +422,3 @@ These rules override conflicting upstream specialized skills. | ||
| In DimCode you can also discover and enable the related Office capability from | ||
| In DimAgent you can also discover and enable the related Office capability from | ||
| the **Skills** panel — the bundled `officecli` base skill is the entry point, | ||
@@ -425,0 +425,0 @@ and the specialized skills above remain available through `officecli load_skill` |
@@ -8,7 +8,7 @@ --- | ||
| Use when the user asks to create, modify, apply, inspect, list, or switch a DimCode Desktop skin or theme. | ||
| Use when the user asks to create, modify, apply, inspect, list, or switch a DimAgent Desktop skin or theme. | ||
| ## Availability | ||
| Desktop sessions only. Every skin operation uses `dim skin ... --json`. If the result reports `DESKTOP_HOST_UNAVAILABLE`, explain that this capability requires DimCode Desktop and stop. Do not write `{DIMCODE_HOME}/skins`, edit Desktop config, or call renderer APIs. | ||
| Desktop sessions only. Every skin operation uses `dim skin ... --json`. If the result reports `DESKTOP_HOST_UNAVAILABLE`, explain that this capability requires DimAgent Desktop and stop. Do not write `{DIMCODE_HOME}/skins`, edit Desktop config, or call renderer APIs. | ||
@@ -45,3 +45,3 @@ Skin operations may run from a subagent or a workflow `api.agent()` child session: the host automatically elevates them to the main (top-level parent) session, which is where skin state and user-uploaded images live. If you are a subagent generating a background image, you can run `dim image generate` yourself and then either run `dim skin create` (it will apply in the main session) or return the generated image path to the main session for it to finish. `PERMISSION_DENIED` on a skin call means the main session could not be resolved — hand the intermediate result back to the main session and re-run there. | ||
| | Get installed skin details | `dim skin get --target-skin-id <id> --json` | | ||
| | Create new skin | `dim skin create --name "<name>" (--image-id <id> \| --generated-image-path <path>) [options] --composer-opacity 0 --activate --json` | | ||
| | Create new skin | `dim skin create --name "<name>" (--image-id <id> \| --generated-image-path <path>) [options] --activate --json` | | ||
| | Update installed skin | `dim skin update --target-skin-id <id> [patch options] --json` | | ||
@@ -48,0 +48,0 @@ | Activate installed skin | `dim skin activate --target-skin-id <id> --json` | |
+1
-1
| { | ||
| "name": "dimagent-linux-arm64", | ||
| "version": "0.3.6", | ||
| "version": "0.3.9", | ||
| "description": "dimagent binary for Linux ARM64", | ||
@@ -5,0 +5,0 @@ "os": [ |
Sorry, the diff of this file is not supported yet
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
169703388
0.27%339
9.35%40182
15.11%