diff --git a/assets/sprites/apple.png b/assets/sprites/apple.png new file mode 100644 index 0000000000000000000000000000000000000000..0d687455fe7f3852176f7ab07455025a5f59b4b6 Binary files /dev/null and b/assets/sprites/apple.png differ diff --git a/assets/sprites/apple@8.png b/assets/sprites/apple@8.png new file mode 100644 index 0000000000000000000000000000000000000000..9d20cd72f4995c513133eebe580117cb549bd756 Binary files /dev/null and b/assets/sprites/apple@8.png differ diff --git a/assets/sprites/archer.png b/assets/sprites/archer.png new file mode 100644 index 0000000000000000000000000000000000000000..ddb2f28bd3284e6e27a41346eb18552bc7cb371d Binary files /dev/null and b/assets/sprites/archer.png differ diff --git a/assets/sprites/archer@8.png b/assets/sprites/archer@8.png new file mode 100644 index 0000000000000000000000000000000000000000..032d7fd1f382a980210a6428bfa70fff4691d6ff Binary files /dev/null and b/assets/sprites/archer@8.png differ diff --git a/assets/sprites/attr_bush_berries.png b/assets/sprites/attr_bush_berries.png new file mode 100644 index 0000000000000000000000000000000000000000..f8f30058098f7830db0a079b40778c1462e09e2d Binary files /dev/null and b/assets/sprites/attr_bush_berries.png differ diff --git a/assets/sprites/attr_bush_berries@8.png b/assets/sprites/attr_bush_berries@8.png new file mode 100644 index 0000000000000000000000000000000000000000..10476a1e32376917b55aa69c55809a8c5bcc1b8b Binary files /dev/null and b/assets/sprites/attr_bush_berries@8.png differ diff --git a/assets/sprites/attr_coin_side.png b/assets/sprites/attr_coin_side.png new file mode 100644 index 0000000000000000000000000000000000000000..46f6a365ad5dde2c6c9d87889dcca0b05678f375 Binary files /dev/null and b/assets/sprites/attr_coin_side.png differ diff --git a/assets/sprites/attr_coin_side@8.png b/assets/sprites/attr_coin_side@8.png new file mode 100644 index 0000000000000000000000000000000000000000..cbbde90653fcf8b3bce5a1dfbadd3929328c8400 Binary files /dev/null and b/assets/sprites/attr_coin_side@8.png differ diff --git a/assets/sprites/attr_gem_emerald.png b/assets/sprites/attr_gem_emerald.png new file mode 100644 index 0000000000000000000000000000000000000000..6dde630733c37aa750c95600aa5c7f4ed579bc80 Binary files /dev/null and b/assets/sprites/attr_gem_emerald.png differ diff --git a/assets/sprites/attr_gem_emerald@8.png b/assets/sprites/attr_gem_emerald@8.png new file mode 100644 index 0000000000000000000000000000000000000000..0216244adc4e8156fe399c63137bb48954625ee3 Binary files /dev/null and b/assets/sprites/attr_gem_emerald@8.png differ diff --git a/assets/sprites/attr_gem_ruby_glow.png b/assets/sprites/attr_gem_ruby_glow.png new file mode 100644 index 0000000000000000000000000000000000000000..9dfbaaa714c39405b826b39d63f98332347d896e Binary files /dev/null and b/assets/sprites/attr_gem_ruby_glow.png differ diff --git a/assets/sprites/attr_gem_ruby_glow@8.png b/assets/sprites/attr_gem_ruby_glow@8.png new file mode 100644 index 0000000000000000000000000000000000000000..e6cce27affbbf747923bc353ca45623b740a685c Binary files /dev/null and b/assets/sprites/attr_gem_ruby_glow@8.png differ diff --git a/assets/sprites/attr_house_red_roof.png b/assets/sprites/attr_house_red_roof.png new file mode 100644 index 0000000000000000000000000000000000000000..331729eeb711ea59148c096e2c7bd428f9ffb8f8 Binary files /dev/null and b/assets/sprites/attr_house_red_roof.png differ diff --git a/assets/sprites/attr_house_red_roof@8.png b/assets/sprites/attr_house_red_roof@8.png new file mode 100644 index 0000000000000000000000000000000000000000..8e8a3285ecd22942ef131ad870cd59ced9769a83 Binary files /dev/null and b/assets/sprites/attr_house_red_roof@8.png differ diff --git a/assets/sprites/attr_house_slate_roof.png b/assets/sprites/attr_house_slate_roof.png new file mode 100644 index 0000000000000000000000000000000000000000..2e9c33d0be90137c714d21169618641f3dbe4fdb Binary files /dev/null and b/assets/sprites/attr_house_slate_roof.png differ diff --git a/assets/sprites/attr_house_slate_roof@8.png b/assets/sprites/attr_house_slate_roof@8.png new file mode 100644 index 0000000000000000000000000000000000000000..1c884652b92754eaa992df9aff9771f8745df6d2 Binary files /dev/null and b/assets/sprites/attr_house_slate_roof@8.png differ diff --git a/assets/sprites/attr_oak_autumn.png b/assets/sprites/attr_oak_autumn.png new file mode 100644 index 0000000000000000000000000000000000000000..34aacf203e8d3434a32d4d2d9b31706f2c4a2238 Binary files /dev/null and b/assets/sprites/attr_oak_autumn.png differ diff --git a/assets/sprites/attr_oak_autumn@8.png b/assets/sprites/attr_oak_autumn@8.png new file mode 100644 index 0000000000000000000000000000000000000000..eae709a268c556baa153a4d6c96b27a856250b93 Binary files /dev/null and b/assets/sprites/attr_oak_autumn@8.png differ diff --git a/assets/sprites/attr_potion_green_vial.png b/assets/sprites/attr_potion_green_vial.png new file mode 100644 index 0000000000000000000000000000000000000000..79314d87e8af88391dbe2ab8323b5c5bf4ee1d8a Binary files /dev/null and b/assets/sprites/attr_potion_green_vial.png differ diff --git a/assets/sprites/attr_potion_green_vial@8.png b/assets/sprites/attr_potion_green_vial@8.png new file mode 100644 index 0000000000000000000000000000000000000000..60bdf7a6e336c9b4c50e1c73b0df24567951bc8a Binary files /dev/null and b/assets/sprites/attr_potion_green_vial@8.png differ diff --git a/assets/sprites/attr_potion_red.png b/assets/sprites/attr_potion_red.png new file mode 100644 index 0000000000000000000000000000000000000000..84e5b0a20cd508f6e6c1dc8ba899cad2f2c2b586 Binary files /dev/null and b/assets/sprites/attr_potion_red.png differ diff --git a/assets/sprites/attr_potion_red@8.png b/assets/sprites/attr_potion_red@8.png new file mode 100644 index 0000000000000000000000000000000000000000..6e736c0bdda23a57e0a94c064f0832051244fba9 Binary files /dev/null and b/assets/sprites/attr_potion_red@8.png differ diff --git a/assets/sprites/attr_rock_mossy.png b/assets/sprites/attr_rock_mossy.png new file mode 100644 index 0000000000000000000000000000000000000000..76638b0db3da60fde5ff7a9470d31d3fdd56a4ab Binary files /dev/null and b/assets/sprites/attr_rock_mossy.png differ diff --git a/assets/sprites/attr_rock_mossy@8.png b/assets/sprites/attr_rock_mossy@8.png new file mode 100644 index 0000000000000000000000000000000000000000..a4f481306d0c17a3e32898a5f4604dcfcf97a846 Binary files /dev/null and b/assets/sprites/attr_rock_mossy@8.png differ diff --git a/assets/sprites/attr_sword_gold.png b/assets/sprites/attr_sword_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..acc39a182869170d12d3cb4923e01dae81139e63 Binary files /dev/null and b/assets/sprites/attr_sword_gold.png differ diff --git a/assets/sprites/attr_sword_gold@8.png b/assets/sprites/attr_sword_gold@8.png new file mode 100644 index 0000000000000000000000000000000000000000..5e46ec394ab6c18cbfe9e090d030e6833a12bd43 Binary files /dev/null and b/assets/sprites/attr_sword_gold@8.png differ diff --git a/assets/sprites/attr_sword_iron.png b/assets/sprites/attr_sword_iron.png new file mode 100644 index 0000000000000000000000000000000000000000..5dddc207ec3d61f87eda11021670f79cf5a63dc3 Binary files /dev/null and b/assets/sprites/attr_sword_iron.png differ diff --git a/assets/sprites/attr_sword_iron@8.png b/assets/sprites/attr_sword_iron@8.png new file mode 100644 index 0000000000000000000000000000000000000000..b8e9fbde94ef263dc420c00ae4860785704c9e0c Binary files /dev/null and b/assets/sprites/attr_sword_iron@8.png differ diff --git a/assets/sprites/barrel.png b/assets/sprites/barrel.png new file mode 100644 index 0000000000000000000000000000000000000000..c13049df0bdcefe64b0a0e6d8de937f91d098e23 Binary files /dev/null and b/assets/sprites/barrel.png differ diff --git a/assets/sprites/barrel@8.png b/assets/sprites/barrel@8.png new file mode 100644 index 0000000000000000000000000000000000000000..70e730994a87568978b4410bd1a8d6607ac4c8a8 Binary files /dev/null and b/assets/sprites/barrel@8.png differ diff --git a/assets/sprites/bat.png b/assets/sprites/bat.png new file mode 100644 index 0000000000000000000000000000000000000000..1e8faca0ef24facbb88789d5a3013af6ff833e85 Binary files /dev/null and b/assets/sprites/bat.png differ diff --git a/assets/sprites/bat@8.png b/assets/sprites/bat@8.png new file mode 100644 index 0000000000000000000000000000000000000000..dc533b4411b4336b4a68be03d010758a42d3a593 Binary files /dev/null and b/assets/sprites/bat@8.png differ diff --git a/assets/sprites/bird.png b/assets/sprites/bird.png new file mode 100644 index 0000000000000000000000000000000000000000..1e8faca0ef24facbb88789d5a3013af6ff833e85 Binary files /dev/null and b/assets/sprites/bird.png differ diff --git a/assets/sprites/bird@8.png b/assets/sprites/bird@8.png new file mode 100644 index 0000000000000000000000000000000000000000..dc533b4411b4336b4a68be03d010758a42d3a593 Binary files /dev/null and b/assets/sprites/bird@8.png differ diff --git a/assets/sprites/bread.png b/assets/sprites/bread.png new file mode 100644 index 0000000000000000000000000000000000000000..b1f7656ddb1dcbb7b816eff2a510a83e38c27698 Binary files /dev/null and b/assets/sprites/bread.png differ diff --git a/assets/sprites/bread@8.png b/assets/sprites/bread@8.png new file mode 100644 index 0000000000000000000000000000000000000000..88c5872965aa710ed7159e6cdddf3c9234048896 Binary files /dev/null and b/assets/sprites/bread@8.png differ diff --git a/assets/sprites/bush.png b/assets/sprites/bush.png new file mode 100644 index 0000000000000000000000000000000000000000..b511d9a162ab5c18b5b47b69f08f5920e6d26dac Binary files /dev/null and b/assets/sprites/bush.png differ diff --git a/assets/sprites/bush@8.png b/assets/sprites/bush@8.png new file mode 100644 index 0000000000000000000000000000000000000000..81729f7ebbd7abeb66fb3e8b14246b0672472cfa Binary files /dev/null and b/assets/sprites/bush@8.png differ diff --git a/assets/sprites/cat.png b/assets/sprites/cat.png new file mode 100644 index 0000000000000000000000000000000000000000..0bb073d3c700357c340e1764d0602d04dfa087de Binary files /dev/null and b/assets/sprites/cat.png differ diff --git a/assets/sprites/cat@8.png b/assets/sprites/cat@8.png new file mode 100644 index 0000000000000000000000000000000000000000..be9312e258a2a69d225d9496786ebcbe6cd48c80 Binary files /dev/null and b/assets/sprites/cat@8.png differ diff --git a/assets/sprites/chair.png b/assets/sprites/chair.png new file mode 100644 index 0000000000000000000000000000000000000000..f32b37ebd1ded6c231dc791cd9d1e1cde4ffc49a Binary files /dev/null and b/assets/sprites/chair.png differ diff --git a/assets/sprites/chair@8.png b/assets/sprites/chair@8.png new file mode 100644 index 0000000000000000000000000000000000000000..1129610df382d29654da6680e6135567821af1e1 Binary files /dev/null and b/assets/sprites/chair@8.png differ diff --git a/assets/sprites/chest.png b/assets/sprites/chest.png new file mode 100644 index 0000000000000000000000000000000000000000..37c58ad78e4bf1035e476621cc82e06a1494eaa9 Binary files /dev/null and b/assets/sprites/chest.png differ diff --git a/assets/sprites/chest@8.png b/assets/sprites/chest@8.png new file mode 100644 index 0000000000000000000000000000000000000000..57fe523fff7534780ceeaf8467b8811603cba2a1 Binary files /dev/null and b/assets/sprites/chest@8.png differ diff --git a/assets/sprites/coin.png b/assets/sprites/coin.png new file mode 100644 index 0000000000000000000000000000000000000000..f0752fb048b64155ad617e66e2bc5eada3e4f750 Binary files /dev/null and b/assets/sprites/coin.png differ diff --git a/assets/sprites/coin@8.png b/assets/sprites/coin@8.png new file mode 100644 index 0000000000000000000000000000000000000000..cc0219366a9552e8c3f7594c3aee930773f85e21 Binary files /dev/null and b/assets/sprites/coin@8.png differ diff --git a/assets/sprites/crate.png b/assets/sprites/crate.png new file mode 100644 index 0000000000000000000000000000000000000000..873ec6138ee20aa5795a22a5f8d3c64b4072c5cf Binary files /dev/null and b/assets/sprites/crate.png differ diff --git a/assets/sprites/crate@8.png b/assets/sprites/crate@8.png new file mode 100644 index 0000000000000000000000000000000000000000..86414d59d5ccb16980f1610636eea871b43c3de5 Binary files /dev/null and b/assets/sprites/crate@8.png differ diff --git a/assets/sprites/dagger.png b/assets/sprites/dagger.png new file mode 100644 index 0000000000000000000000000000000000000000..ab89274d7cd3dc9c3a80033c925b19bba00fe6af Binary files /dev/null and b/assets/sprites/dagger.png differ diff --git a/assets/sprites/dagger@8.png b/assets/sprites/dagger@8.png new file mode 100644 index 0000000000000000000000000000000000000000..783dd7b9c7368a14ab947fc3db2c026d5fdd35a5 Binary files /dev/null and b/assets/sprites/dagger@8.png differ diff --git a/assets/sprites/deer.png b/assets/sprites/deer.png new file mode 100644 index 0000000000000000000000000000000000000000..267d9506fc16b7ab97e0f5b742b006a665ab2a06 Binary files /dev/null and b/assets/sprites/deer.png differ diff --git a/assets/sprites/deer@8.png b/assets/sprites/deer@8.png new file mode 100644 index 0000000000000000000000000000000000000000..ca4190ba130cbf443436e173c4bc058caa44ce32 Binary files /dev/null and b/assets/sprites/deer@8.png differ diff --git a/assets/sprites/dog.png b/assets/sprites/dog.png new file mode 100644 index 0000000000000000000000000000000000000000..ff050818607c60251c120db72176b6a159bbca91 Binary files /dev/null and b/assets/sprites/dog.png differ diff --git a/assets/sprites/dog@8.png b/assets/sprites/dog@8.png new file mode 100644 index 0000000000000000000000000000000000000000..28d0bad4093f160f0444ad326eae7b989c087465 Binary files /dev/null and b/assets/sprites/dog@8.png differ diff --git a/assets/sprites/fireball.png b/assets/sprites/fireball.png new file mode 100644 index 0000000000000000000000000000000000000000..90c35cff97ff7e74c62553d6a17fda769ca40006 Binary files /dev/null and b/assets/sprites/fireball.png differ diff --git a/assets/sprites/fireball@8.png b/assets/sprites/fireball@8.png new file mode 100644 index 0000000000000000000000000000000000000000..4a8d086e3ec86079e9febbaf6bd4ce93b37f3fdc Binary files /dev/null and b/assets/sprites/fireball@8.png differ diff --git a/assets/sprites/fish.png b/assets/sprites/fish.png new file mode 100644 index 0000000000000000000000000000000000000000..9ed42b6bdd181daf242f23f35000ffc6fe23e84e Binary files /dev/null and b/assets/sprites/fish.png differ diff --git a/assets/sprites/fish@8.png b/assets/sprites/fish@8.png new file mode 100644 index 0000000000000000000000000000000000000000..9e37831562112bfb76142df5027ecfa063ef9ee2 Binary files /dev/null and b/assets/sprites/fish@8.png differ diff --git a/assets/sprites/gem.png b/assets/sprites/gem.png new file mode 100644 index 0000000000000000000000000000000000000000..18d12c9e5d9c1a34776666db152f670c1fb97e45 Binary files /dev/null and b/assets/sprites/gem.png differ diff --git a/assets/sprites/gem@8.png b/assets/sprites/gem@8.png new file mode 100644 index 0000000000000000000000000000000000000000..f666b36b6cee5a80e86696b54c402ed86dc125e4 Binary files /dev/null and b/assets/sprites/gem@8.png differ diff --git a/assets/sprites/grass_tile.png b/assets/sprites/grass_tile.png new file mode 100644 index 0000000000000000000000000000000000000000..fcf3d66398ca501a8ae2f2508b64b7d35b2474f2 Binary files /dev/null and b/assets/sprites/grass_tile.png differ diff --git a/assets/sprites/grass_tile@8.png b/assets/sprites/grass_tile@8.png new file mode 100644 index 0000000000000000000000000000000000000000..784be9ec9622476ff3931d22e3020a6ee82d0081 Binary files /dev/null and b/assets/sprites/grass_tile@8.png differ diff --git a/assets/sprites/house.png b/assets/sprites/house.png new file mode 100644 index 0000000000000000000000000000000000000000..331729eeb711ea59148c096e2c7bd428f9ffb8f8 Binary files /dev/null and b/assets/sprites/house.png differ diff --git a/assets/sprites/house@8.png b/assets/sprites/house@8.png new file mode 100644 index 0000000000000000000000000000000000000000..8e8a3285ecd22942ef131ad870cd59ced9769a83 Binary files /dev/null and b/assets/sprites/house@8.png differ diff --git a/assets/sprites/key.png b/assets/sprites/key.png new file mode 100644 index 0000000000000000000000000000000000000000..b07b08011ff82ede2712306aa4e3029c1759459c Binary files /dev/null and b/assets/sprites/key.png differ diff --git a/assets/sprites/key@8.png b/assets/sprites/key@8.png new file mode 100644 index 0000000000000000000000000000000000000000..7c06867895712103c8d443d55767a4edafa0b4b0 Binary files /dev/null and b/assets/sprites/key@8.png differ diff --git a/assets/sprites/knight.png b/assets/sprites/knight.png new file mode 100644 index 0000000000000000000000000000000000000000..c1004bcfa8baa844c1ed089f9bf69cc1915d8358 Binary files /dev/null and b/assets/sprites/knight.png differ diff --git a/assets/sprites/knight@8.png b/assets/sprites/knight@8.png new file mode 100644 index 0000000000000000000000000000000000000000..aced797a6687592804af11ad147e1af4a3fc44e1 Binary files /dev/null and b/assets/sprites/knight@8.png differ diff --git a/assets/sprites/mouse.png b/assets/sprites/mouse.png new file mode 100644 index 0000000000000000000000000000000000000000..3f7710f31dba0c543cd1317bc27755ef90e27e4a Binary files /dev/null and b/assets/sprites/mouse.png differ diff --git a/assets/sprites/mouse@8.png b/assets/sprites/mouse@8.png new file mode 100644 index 0000000000000000000000000000000000000000..359e4aadcbf9159651ddd078a9f3aa16370a5f3a Binary files /dev/null and b/assets/sprites/mouse@8.png differ diff --git a/assets/sprites/oak_tree.png b/assets/sprites/oak_tree.png new file mode 100644 index 0000000000000000000000000000000000000000..755ec246d5b573113586b3b34080adb7f3d93cfd Binary files /dev/null and b/assets/sprites/oak_tree.png differ diff --git a/assets/sprites/oak_tree@8.png b/assets/sprites/oak_tree@8.png new file mode 100644 index 0000000000000000000000000000000000000000..f010b0786c10d0fae43cdd8389076640b0075c3a Binary files /dev/null and b/assets/sprites/oak_tree@8.png differ diff --git a/assets/sprites/pine_tree.png b/assets/sprites/pine_tree.png new file mode 100644 index 0000000000000000000000000000000000000000..15bf4508a3510bd5ecb6b01a0675dce03363226e Binary files /dev/null and b/assets/sprites/pine_tree.png differ diff --git a/assets/sprites/pine_tree@8.png b/assets/sprites/pine_tree@8.png new file mode 100644 index 0000000000000000000000000000000000000000..e3cf4d00930673945d23013de142dc3ef71bfe58 Binary files /dev/null and b/assets/sprites/pine_tree@8.png differ diff --git a/assets/sprites/potion_round.png b/assets/sprites/potion_round.png new file mode 100644 index 0000000000000000000000000000000000000000..84e5b0a20cd508f6e6c1dc8ba899cad2f2c2b586 Binary files /dev/null and b/assets/sprites/potion_round.png differ diff --git a/assets/sprites/potion_round@8.png b/assets/sprites/potion_round@8.png new file mode 100644 index 0000000000000000000000000000000000000000..6e736c0bdda23a57e0a94c064f0832051244fba9 Binary files /dev/null and b/assets/sprites/potion_round@8.png differ diff --git a/assets/sprites/potion_vial.png b/assets/sprites/potion_vial.png new file mode 100644 index 0000000000000000000000000000000000000000..79314d87e8af88391dbe2ab8323b5c5bf4ee1d8a Binary files /dev/null and b/assets/sprites/potion_vial.png differ diff --git a/assets/sprites/potion_vial@8.png b/assets/sprites/potion_vial@8.png new file mode 100644 index 0000000000000000000000000000000000000000..60bdf7a6e336c9b4c50e1c73b0df24567951bc8a Binary files /dev/null and b/assets/sprites/potion_vial@8.png differ diff --git a/assets/sprites/rock.png b/assets/sprites/rock.png new file mode 100644 index 0000000000000000000000000000000000000000..d91549dab601dbaa86ff10ef83ffb763d3788897 Binary files /dev/null and b/assets/sprites/rock.png differ diff --git a/assets/sprites/rock@8.png b/assets/sprites/rock@8.png new file mode 100644 index 0000000000000000000000000000000000000000..43f87d99e5d8c58bc2bb024ad8e3bc719aff87e0 Binary files /dev/null and b/assets/sprites/rock@8.png differ diff --git a/assets/sprites/shield.png b/assets/sprites/shield.png new file mode 100644 index 0000000000000000000000000000000000000000..7022cfe95d15e3039faf05c7ad5bfc01dcc5ce71 Binary files /dev/null and b/assets/sprites/shield.png differ diff --git a/assets/sprites/shield@8.png b/assets/sprites/shield@8.png new file mode 100644 index 0000000000000000000000000000000000000000..0f1ca36e8484bee128a31f3aa1188764ff2f13a6 Binary files /dev/null and b/assets/sprites/shield@8.png differ diff --git a/assets/sprites/skeleton.png b/assets/sprites/skeleton.png new file mode 100644 index 0000000000000000000000000000000000000000..c5b1b9c5825ce62cd115891a4b87b18ab1f9cd51 Binary files /dev/null and b/assets/sprites/skeleton.png differ diff --git a/assets/sprites/skeleton@8.png b/assets/sprites/skeleton@8.png new file mode 100644 index 0000000000000000000000000000000000000000..a339827201541f35528642fcb28d40c2bf4ba31d Binary files /dev/null and b/assets/sprites/skeleton@8.png differ diff --git a/assets/sprites/staff.png b/assets/sprites/staff.png new file mode 100644 index 0000000000000000000000000000000000000000..598d00a6d214d0c73b079d0295ebd87b0b706e8b Binary files /dev/null and b/assets/sprites/staff.png differ diff --git a/assets/sprites/staff@8.png b/assets/sprites/staff@8.png new file mode 100644 index 0000000000000000000000000000000000000000..60186b4fc6bfdace8b45d9a4d286d0422e212e29 Binary files /dev/null and b/assets/sprites/staff@8.png differ diff --git a/assets/sprites/stone_tile.png b/assets/sprites/stone_tile.png new file mode 100644 index 0000000000000000000000000000000000000000..e684fdcadea2fa07aabdf8067546d308c01b142b Binary files /dev/null and b/assets/sprites/stone_tile.png differ diff --git a/assets/sprites/stone_tile@8.png b/assets/sprites/stone_tile@8.png new file mode 100644 index 0000000000000000000000000000000000000000..8f9c763d2f1e66d180e55f3c9bb9aa6aa8d53d06 Binary files /dev/null and b/assets/sprites/stone_tile@8.png differ diff --git a/assets/sprites/stool.png b/assets/sprites/stool.png new file mode 100644 index 0000000000000000000000000000000000000000..948c41d9d534d6d113546ac4cd34b6f9af499e62 Binary files /dev/null and b/assets/sprites/stool.png differ diff --git a/assets/sprites/stool@8.png b/assets/sprites/stool@8.png new file mode 100644 index 0000000000000000000000000000000000000000..096714e7bace34e84368d151f03692fe7e21b7be Binary files /dev/null and b/assets/sprites/stool@8.png differ diff --git a/assets/sprites/sword.png b/assets/sprites/sword.png new file mode 100644 index 0000000000000000000000000000000000000000..acc39a182869170d12d3cb4923e01dae81139e63 Binary files /dev/null and b/assets/sprites/sword.png differ diff --git a/assets/sprites/sword@8.png b/assets/sprites/sword@8.png new file mode 100644 index 0000000000000000000000000000000000000000..5e46ec394ab6c18cbfe9e090d030e6833a12bd43 Binary files /dev/null and b/assets/sprites/sword@8.png differ diff --git a/assets/sprites/table.png b/assets/sprites/table.png new file mode 100644 index 0000000000000000000000000000000000000000..06946c119f48ff9cf5645aa2dd85ca628c6654ac Binary files /dev/null and b/assets/sprites/table.png differ diff --git a/assets/sprites/table@8.png b/assets/sprites/table@8.png new file mode 100644 index 0000000000000000000000000000000000000000..c935d915052ad4ff928b81623d4e2488cddabdf5 Binary files /dev/null and b/assets/sprites/table@8.png differ diff --git a/assets/sprites/water_tile.png b/assets/sprites/water_tile.png new file mode 100644 index 0000000000000000000000000000000000000000..fd942bc84fb275b2325bd598079e90d1f0f6d0b4 Binary files /dev/null and b/assets/sprites/water_tile.png differ diff --git a/assets/sprites/water_tile@8.png b/assets/sprites/water_tile@8.png new file mode 100644 index 0000000000000000000000000000000000000000..f957838c2a317c5cac6e1d2864acb54e23ca0227 Binary files /dev/null and b/assets/sprites/water_tile@8.png differ diff --git a/assets/sprites/wizard.png b/assets/sprites/wizard.png new file mode 100644 index 0000000000000000000000000000000000000000..c2a1002b39e8eef26738023cf8df1e355a039cb4 Binary files /dev/null and b/assets/sprites/wizard.png differ diff --git a/assets/sprites/wizard@8.png b/assets/sprites/wizard@8.png new file mode 100644 index 0000000000000000000000000000000000000000..5edeb21cbf007d2b6d53badba367dae4302dd8ef Binary files /dev/null and b/assets/sprites/wizard@8.png differ diff --git a/assets/sprites/zombie.png b/assets/sprites/zombie.png new file mode 100644 index 0000000000000000000000000000000000000000..e7e27f4fb7a09ceb313533f1ff31e4abbda33277 Binary files /dev/null and b/assets/sprites/zombie.png differ diff --git a/assets/sprites/zombie@8.png b/assets/sprites/zombie@8.png new file mode 100644 index 0000000000000000000000000000000000000000..58f27c926b5344c45ea2c8df4788960301e1f039 Binary files /dev/null and b/assets/sprites/zombie@8.png differ diff --git a/index.html b/index.html index b0c4b3666032a737f3903db53e6a8a9272483e28..358ddb349e1ef6db43ee7b18e150a97d6bb0286f 100644 --- a/index.html +++ b/index.html @@ -1,19 +1,733 @@ - - - - - - My static Space - - - -
-

Welcome to your static Space!

-

You can modify this app directly by editing index.html in the Files and versions tab.

-

- Also don't forget to check the - Spaces documentation. -

-
- + + + + + +PXG-Tiny — 483k-param offline text→pixel-sprite model + + + + + + + +
+
+
+ 483,040 parameters + 38 verified classes + CPU-only · no GPU + ~1.8 MB fp32 bundle + 0 network calls +
+

plain English →
pixel sprites — fully offline

+

PXG-Tiny is a deliberately miniature text-to-sprite model: 483k parameters, + a pure NumPy inference runtime, and a built-in verifier that checks every sprite against + your prompt before showing it. Runs on any laptop CPU in ~0.6 s per sprite.

+ +
+
every sprite on this page was generated by the final v0.4 model — prompt & seed recorded in manifest.json
+
+
+ + + +
+
+
attribute grounding
+

Prompt words actually change pixels

+

The corpus is built from a procedural rasterizer with counterfactual twin pairs — + identical geometry, one changed word — so the model learns word→pixel causality, not vibes.

+
+
+
+ +
+
+
measured, not marketed
+

Benchmarks

+
+
100%
38-class grid
152/152 prompts
+
99.1%
fresh-phrasing sweep
116/117
+
31/31
showcase gallery
shipped pipeline
+
98.89%
val top-1
teacher-forced
+
100%
INT8↔fp32 parity
max |Δlogit| 2e-05
+
+ + + + + + +
ProtocolResultWhat it measures
Full 38-class grid152/152 = 100%2 prompt styles × 2 seeds per class, exact shipped pipeline (verifier-guided retries on)
Fresh-phrasing sweep116/117 = 99.1%39 novel phrasings × 3 seeds, same pipeline — guards against overfitting to test prompts
Raw held-out (verifier off)59.4% mean · 50% strict46 adversarial rephrasings, single free-run sample — the honest backbone-only number
Parity gatePASSNumPy runtime vs trained torch model, bit-level teacher-forced comparison
+
Why the raw number is low and the shipped number is 100%: the 483k backbone knows + every class (perfect teacher-forced accuracy) but autoregressive sampling drifts on hard prompts. + The verifier-guided escalation — palette bias → spatial priors → canonical anchor — closes that gap + for the user path. Both numbers are published on purpose.
+
+
+ +
+
+
5-minute quickstart
+

Run it on your laptop

+

No GPU. No PyTorch at inference. No internet. Python ≥ 3.9 with numpy and pillow.

+
+
+
# 1. get the bundle
+git clone https://github.com/tarulahsan/PXG-Tiny-AI
+cd PXG-Tiny-AI
+pip install numpy pillow pytest
+
+# 2. single sprite
+python3 cli.py "a golden sword" -o sword.png
+
+# 3. four variations on one sheet
+python3 cli.py "an iron chest" --variations 4 \
+  --sheet chest_variants.png
+
+# 4. batch from a prompt list
+python3 cli.py --sheet-from-prompts prompts.txt \
+  --outdir sprites/
+
+
+
# Python API
+from pxg_tiny.pipeline import PXGPipeline
+
+pipe = PXGPipeline()
+grid, meta = pipe.generate_pixels(
+    "a ruby potion that glows", seed=7)
+
+if grid is None:
+    print(meta["message"])  # clarify / refuse
+else:
+    pipe.generate_png("a ruby potion that glows",
+                      "ruby_potion.png")
+
+# ask-first gate (rule-based, offline)
+PXGPipeline.ask("render a 3d photo")
+# -> "i only make 16x16 pixel-art sprites..."
+
+
+ +
+
+ +
+
+
honest limitations
+

What it won't do

+ +
+
+ +
+
+
under the hood
+

Architecture in 30 seconds

+
caption ids (32 chars) ──► intent encoder (bi-attention, fp32) ──► 8 prefix vectors
+[8 prefixes | 256 visual tokens] ──► 4× pre-LN causal blocks (d=96, 4 heads, FFN 256)
+                                    └─► 32-way softmax over master-palette indices (0 = transparent)
+
+teacher-forced top-1 98.89% · KV-cached decode ~0.6 s/sprite on 2 CPU cores
+training corpus: 97,005 fully procedural rows (CC0), counterfactual twins + paraphrase engine
+ +
+
+ + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..b2edff68c1aa7a6d0f81801558c8fdfda077ab3d --- /dev/null +++ b/manifest.json @@ -0,0 +1,394 @@ +{ + "showcase": [ + { + "cls": "wizard", + "prompt": "a wizard with a purple hat", + "seed": 0, + "attempt": 0, + "file": "wizard.png", + "gate": "accept" + }, + { + "cls": "knight", + "prompt": "a knight in iron armor", + "seed": 2026, + "attempt": 2, + "file": "knight.png", + "gate": "accept" + }, + { + "cls": "archer", + "prompt": "an archer with a green hood", + "seed": 0, + "attempt": 0, + "file": "archer.png", + "gate": "accept" + }, + { + "cls": "skeleton", + "prompt": "a skeleton warrior", + "seed": 6078, + "attempt": 6, + "file": "skeleton.png", + "gate": "accept" + }, + { + "cls": "zombie", + "prompt": "an undead with green skin", + "seed": 0, + "attempt": 0, + "file": "zombie.png", + "gate": "accept" + }, + { + "cls": "cat", + "prompt": "a small gray cat", + "seed": 0, + "attempt": 0, + "file": "cat.png", + "gate": "accept" + }, + { + "cls": "dog", + "prompt": "a brown dog", + "seed": 0, + "attempt": 0, + "file": "dog.png", + "gate": "accept" + }, + { + "cls": "bird", + "prompt": "a small blue bird", + "seed": 0, + "attempt": 0, + "file": "bird.png", + "gate": "accept" + }, + { + "cls": "fish", + "prompt": "a golden fish", + "seed": 0, + "attempt": 0, + "file": "fish.png", + "gate": "accept" + }, + { + "cls": "deer", + "prompt": "a brown deer with antlers", + "seed": 6078, + "attempt": 6, + "file": "deer.png", + "gate": "accept" + }, + { + "cls": "mouse", + "prompt": "a little gray mouse", + "seed": 1013, + "attempt": 1, + "file": "mouse.png", + "gate": "accept" + }, + { + "cls": "bat", + "prompt": "a purple bat with wings", + "seed": 0, + "attempt": 0, + "file": "bat.png", + "gate": "accept" + }, + { + "cls": "sword", + "prompt": "a golden sword", + "seed": 0, + "attempt": 0, + "file": "sword.png", + "gate": "accept" + }, + { + "cls": "dagger", + "prompt": "a sharp iron dagger", + "seed": 0, + "attempt": 0, + "file": "dagger.png", + "gate": "accept" + }, + { + "cls": "shield", + "prompt": "a round iron shield", + "seed": 0, + "attempt": 0, + "file": "shield.png", + "gate": "accept" + }, + { + "cls": "staff", + "prompt": "a magic staff with a ruby orb", + "seed": 2027, + "attempt": 2, + "file": "staff.png", + "gate": "accept" + }, + { + "cls": "potion_round", + "prompt": "a red potion in a round bottle", + "seed": 0, + "attempt": 0, + "file": "potion_round.png", + "gate": "accept" + }, + { + "cls": "potion_vial", + "prompt": "a slim vial of green potion", + "seed": 0, + "attempt": 0, + "file": "potion_vial.png", + "gate": "accept" + }, + { + "cls": "gem", + "prompt": "a shiny blue gem", + "seed": 0, + "attempt": 0, + "file": "gem.png", + "gate": "accept" + }, + { + "cls": "coin", + "prompt": "a gold coin", + "seed": 0, + "attempt": 0, + "file": "coin.png", + "gate": "accept" + }, + { + "cls": "chest", + "prompt": "a wooden chest with gold bands", + "seed": 0, + "attempt": 0, + "file": "chest.png", + "gate": "accept" + }, + { + "cls": "key", + "prompt": "an upright golden key", + "seed": 0, + "attempt": 0, + "file": "key.png", + "gate": "accept" + }, + { + "cls": "house", + "prompt": "a cottage with a red roof", + "seed": 0, + "attempt": 0, + "file": "house.png", + "gate": "accept" + }, + { + "cls": "barrel", + "prompt": "a wooden barrel", + "seed": 0, + "attempt": 0, + "file": "barrel.png", + "gate": "accept" + }, + { + "cls": "crate", + "prompt": "a wooden crate", + "seed": 0, + "attempt": 0, + "file": "crate.png", + "gate": "accept" + }, + { + "cls": "apple", + "prompt": "a red apple", + "seed": 0, + "attempt": 0, + "file": "apple.png", + "gate": "accept" + }, + { + "cls": "bread", + "prompt": "a loaf of bread", + "seed": 0, + "attempt": 0, + "file": "bread.png", + "gate": "accept" + }, + { + "cls": "fireball", + "prompt": "a large fireball", + "seed": 0, + "attempt": 0, + "file": "fireball.png", + "gate": "accept" + }, + { + "cls": "oak_tree", + "prompt": "a green oak tree", + "seed": 0, + "attempt": 0, + "file": "oak_tree.png", + "gate": "accept" + }, + { + "cls": "pine_tree", + "prompt": "a dark pine tree", + "seed": 0, + "attempt": 0, + "file": "pine_tree.png", + "gate": "accept" + }, + { + "cls": "bush", + "prompt": "a leafy bush", + "seed": 0, + "attempt": 0, + "file": "bush.png", + "gate": "accept" + }, + { + "cls": "rock", + "prompt": "a gray rock", + "seed": 1013, + "attempt": 1, + "file": "rock.png", + "gate": "accept" + }, + { + "cls": "chair", + "prompt": "a wooden chair", + "seed": 0, + "attempt": 0, + "file": "chair.png", + "gate": "accept" + }, + { + "cls": "table", + "prompt": "a wooden table", + "seed": 0, + "attempt": 0, + "file": "table.png", + "gate": "accept" + }, + { + "cls": "stool", + "prompt": "a wooden stool", + "seed": 0, + "attempt": 0, + "file": "stool.png", + "gate": "accept" + }, + { + "cls": "grass_tile", + "prompt": "a grass texture tile", + "seed": 0, + "attempt": 0, + "file": "grass_tile.png", + "gate": "accept" + }, + { + "cls": "stone_tile", + "prompt": "a stone texture tile", + "seed": 0, + "attempt": 0, + "file": "stone_tile.png", + "gate": "accept" + }, + { + "cls": "water_tile", + "prompt": "a water texture tile", + "seed": 0, + "attempt": 0, + "file": "water_tile.png", + "gate": "accept" + } + ], + "attributes": [ + { + "slug": "sword_gold", + "prompt": "a golden sword", + "seed": 0, + "file": "attr_sword_gold.png", + "gate": "accept" + }, + { + "slug": "sword_iron", + "prompt": "a sword made of pure iron", + "seed": 0, + "file": "attr_sword_iron.png", + "gate": "accept" + }, + { + "slug": "potion_red", + "prompt": "liquid potion of red brew", + "seed": 0, + "file": "attr_potion_red.png", + "gate": "accept" + }, + { + "slug": "potion_green_vial", + "prompt": "a slim vial of green potion", + "seed": 0, + "file": "attr_potion_green_vial.png", + "gate": "accept" + }, + { + "slug": "house_red_roof", + "prompt": "a cottage with a red roof", + "seed": 0, + "file": "attr_house_red_roof.png", + "gate": "accept" + }, + { + "slug": "house_slate_roof", + "prompt": "a cottage with a slate roof", + "seed": 4052, + "file": "attr_house_slate_roof.png", + "gate": "accept" + }, + { + "slug": "gem_ruby_glow", + "prompt": "a ruby gem that glows", + "seed": 0, + "file": "attr_gem_ruby_glow.png", + "gate": "accept" + }, + { + "slug": "gem_emerald", + "prompt": "an emerald gem", + "seed": 0, + "file": "attr_gem_emerald.png", + "gate": "accept" + }, + { + "slug": "rock_mossy", + "prompt": "a mossy boulder", + "seed": 3039, + "file": "attr_rock_mossy.png", + "gate": "accept" + }, + { + "slug": "oak_autumn", + "prompt": "old oak with autumn leaves", + "seed": 0, + "file": "attr_oak_autumn.png", + "gate": "accept" + }, + { + "slug": "bush_berries", + "prompt": "a bush that has berries", + "seed": 0, + "file": "attr_bush_berries.png", + "gate": "accept" + }, + { + "slug": "coin_side", + "prompt": "a gold coin seen from the side", + "seed": 0, + "file": "attr_coin_side.png", + "gate": "accept" + } + ] +} \ No newline at end of file diff --git a/play.html b/play.html new file mode 100644 index 0000000000000000000000000000000000000000..d87c8475bf473414d9591d003b8eb511fb6105bc --- /dev/null +++ b/play.html @@ -0,0 +1,138 @@ + + + + + +PXG-Tiny Playground — in-browser live demo + + + +

PXG-Tiny playground

+

the entire model runs in your browser — real NumPy inference via Pyodide, + weights (~1.8 MB) served from this page · no server, no GPU, no API

+ +
+
+ + +
+
+ + +
+ +
booting Pyodide + loading weights… first load takes ~20-40 s, then ~1-3 s per sprite
+
+
+ +

38 trained classes — characters (wizard, knight, archer, skeleton, zombie), + animals (cat, dog, bird, fish, deer, mouse, bat), weapons, potions, treasure, furniture, + trees, rocks, bushes, tiles, fireball, house. Anything else → a clarifying question.

+ +
+ model: huggingface.co/Tarul/pxg-tiny · + code: github.com/tarulahsan/PXG-Tiny-AI
+ created by Chowdhury Tarul Ahsan · tarulahsan@gmail.com · + ← back to the landing page +
+ + + + diff --git a/pxg_tiny/__init__.py b/pxg_tiny/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ab59ab56fdc51fae16f966298180e59beb334669 --- /dev/null +++ b/pxg_tiny/__init__.py @@ -0,0 +1 @@ +# PXG-Tiny browser bundle (no PIL dependency) diff --git a/pxg_tiny/config.py b/pxg_tiny/config.py new file mode 100644 index 0000000000000000000000000000000000000000..192d60f4e1deb18fbcfbc75ae2ca6e5037f1dc8d --- /dev/null +++ b/pxg_tiny/config.py @@ -0,0 +1,121 @@ +"""PXG-Tiny shared configuration: master palette, tokenizer, classes, model dims. + +Single source of truth imported by both the training pipeline and the +inference runtime. All sprites are represented as 16x16 grids of *master +palette indices* (0 = fully transparent alpha, 1..31 = opaque colors). +This makes reconstruction byte-exact by construction and keeps the +generation vocabulary tiny (32 visual symbols). +""" + +IMG = 16 # sprite resolution (16x16, even, inside the 8..256 schema band) +N_COLORS = 32 # visual vocabulary size: index 0 = transparent, 1..31 colors + +# --------------------------------------------------------------------------- +# Master palette (index -> RGBA). Hand-crafted to span the classic +# warm_fantasy / nes / cool_sci_fi flavor families while staying <= 31 opaque +# colors so a single byte encodes any pixel. +# --------------------------------------------------------------------------- +PALETTE = { + 1: (248, 248, 248), # white / specular + 2: (217, 221, 230), # pale gray + 3: (183, 191, 204), # silver + 4: (120, 129, 142), # gray + 5: (69, 76, 87), # charcoal + 6: (23, 23, 31), # ink (outline black) + 7: (212, 61, 51), # red + 8: (140, 35, 32), # maroon + 9: (232, 137, 46), # orange / terracotta + 10: (242, 193, 46), # gold + 11: (255, 232, 107), # lemon (gold highlight) + 12: (127, 201, 79), # lime + 13: (76, 154, 68), # green mid + 14: (44, 106, 53), # forest dark + 15: (55, 179, 171), # teal + 16: (159, 223, 240), # ice / cyan highlight + 17: (79, 147, 220), # sky blue + 18: (45, 71, 127), # navy deep + 19: (124, 80, 173), # purple + 20: (180, 140, 224), # light violet + 21: (168, 123, 35), # bronze (dark gold) + 22: (242, 203, 160), # peach + 23: (221, 182, 113), # sand + 24: (244, 230, 192), # cream ivory + 25: (165, 116, 74), # tan wood + 26: (122, 78, 42), # brown mid + 27: (76, 48, 26), # dark wood + 28: (111, 117, 57), # moss olive + 29: (185, 230, 197), # mint pale (slime) + 30: (93, 114, 136), # steel blue-gray + 31: (238, 173, 174), # blush soft pink +} + +# --------------------------------------------------------------------------- +# Text tokenizer (char-level with byte-fallback buckets). +# id 0 = , 1 = , 2 = , 3..6 punctuation, 7..32 a-z, +# 33..62 byte-fallback buckets (ord % 30 + 33), 63 spare. +# Deterministic, lossless round-trip on the supported charset; any other +# Unicode character folds into its byte-fallback bucket (documented lossy +# fold for exotic input, mirrors the "byte fallback" philosophy of the big +# sibling project). +# --------------------------------------------------------------------------- +CHARSET = "abcdefghijklmnopqrstuvwxyz ,:-:'" +PAD_ID, BOS_ID, SEP_ID = 0, 1, 2 +_TEXT_VOCAB = 64 # total text-side embedding width +CAP_LEN = 32 # fixed padded caption slot count (incl. ) +PREFIX_LEN = 8 # intent-encoder output prefix vectors fed to decoder + + +def encode_char(c: str) -> int: + if c == " ": return 3 + if c == ",": return 4 + if c == "-": return 5 + if c == ":": return 6 + if "a" <= c <= "z": + return 7 + ord(c) - ord("a") + if c == "'": return 39 + # byte fallback bucket for anything else (digits, unicode, etc.) + fb = c.encode("utf-8", "replace")[0] # first UTF-8 byte as int + return 33 + (fb % 30) + + +def decode_char(i: int): + if i == 3: return " " + if i == 4: return "," + if i == 5: return "-" + if i == 6: return ":" + if 7 <= i <= 32: return chr(ord("a") + i - 7) + if i == 39: return "'" + if 33 <= i <= 38 or 40 <= i <= 62: return "?" # fallback bucket: unknown glyph + return None # control specials + + +def encode_caption(text: str): + """text -> list of CAP_LEN ids, left-padded slot layout: + [...]. No / needed: the prefix encoder consumes the + whole 32-slot window positionally.""" + ids = [encode_char(c) for c in text.lower()][:CAP_LEN] + return [PAD_ID] * (CAP_LEN - len(ids)) + ids + + +def decode_caption(ids) -> str: + out = [] + for i in ids: + c = decode_char(int(i)) + if c is not None: + out.append(c) + return "".join(out).strip() + + +# --------------------------------------------------------------------------- +# Generation model geometry (extremely tiny on purpose). +# --------------------------------------------------------------------------- +D_MODEL = 96 +N_LAYERS = 4 +N_HEADS = 4 # 24-dim heads +FF_HIDDEN = 256 # ffn_mult ~= 2.7 (lean by design) +SEQ_VIS = IMG * IMG # 256 autoregressive visual positions +POS_TOTAL = PREFIX_LEN + SEQ_VIS # 264 learned positions + +SAMPLING = {"temperature": 0.85, "top_k": 10} + +APPROX_PARAMS = 483_040 # exact: encoder + 4-block decoder + heads diff --git a/pxg_tiny/quality.py b/pxg_tiny/quality.py new file mode 100644 index 0000000000000000000000000000000000000000..56ded192f27c761bb4c4b35b80e380072c8aebc0 --- /dev/null +++ b/pxg_tiny/quality.py @@ -0,0 +1,644 @@ +"""Prompt parsing + attribute grounding checks for PXG-Tiny. + +The tiny model understands English through its intent encoder; this module is +the *measurable* side of that understanding: it parses free-form prompts into +attribute constraints (material, orientation, size, glow, ...) and scores +generated sprites against them. Used by the pipeline's auto-retry sampler and +by the generalization eval. +""" +import re + +import numpy as np + +# palette-index families (see config.PALETTE) +FAMILIES = { + "gold": {10, 11, 21}, "iron": {3, 4, 5, 30}, "crystal": {16, 17, 18}, + "blue": {16, 17, 18}, "ruby": {7, 8}, "red": {7, 8}, + "emerald": {12, 13, 14}, "green": {12, 13, 14, 29}, "slime": {29, 12, 13}, + "purple": {19, 20}, "amethyst": {19, 20}, "wood": {23, 25, 26, 27}, + "copper": {8, 9}, "slate": {2, 3, 4}, "terracotta": {8, 9}, + "white": {1, 24}, "gray": {2, 3, 4}, "brown": {25, 26, 27}, + "tan": {22, 23}, "pink": {31}, +} + +SYNONYMS = { + "golden": "gold", "big": "large", "huge": "large", + "wooden": "wood", "azure": "blue", "steel": "iron", + "sapphire": "blue", "amethyst": "purple", "moss-covered": "mossy", + "boulder": "rock", "gemstone": "gem", "cottage": "house", + "stone": "gray", "shrub": "bush", "grassy": "grass", + # v0.4 character / animal synonyms + "grey": "gray", "mage": "wizard", "sorcerer": "wizard", + "warlock": "wizard", "ranger": "archer", + "hunter": "archer", "scout": "archer", "kitty": "cat", + "kitten": "cat", "puppy": "dog", "hound": "dog", + "songbird": "bird", "sparrow": "bird", "parrot": "bird", + "owl": "bird", "hawk": "bird", "eagle": "bird", + "goldfish": "fish", "undead": "zombie", "corpse": "zombie", + "fawn": "deer", "doe": "deer", "stag": "deer", "violet": "purple", + "crimson": "red", "scarlet": "red", "brass": "gold", + "bronze": "copper", "lawn": "grass", "dungeon": "stone", + "ocean": "water", "waves": "water", +} + +# generic fighter words: only become "knight" when no other class noun is +# present (prevents "skeleton warrior" / "zombie fighter" from re-classing) +FIGHTER_WORDS = {"warrior", "fighter", "soldier", "paladin", "hero"} + +# plural -> singular for every class noun the parser knows +PLURALIZE = ["wizard", "knight", "archer", "skeleton", "zombie", "cat", + "dog", "bird", "fish", "deer", "mouse", "bat", "sword", + "dagger", "shield", "staff", "chest", "barrel", "crate", + "coin", "gem", "key", "apple", "bread", "bush", "rock", + "fireball", "chair", "table", "stool", "house", "tile", + "potion", "vial", "tree"] + +# canonical corpus-style caption per class, used by the retry sampler's +# anchor stage (prompt normalization with our own parser - fully offline) +ANCHOR_CAPTIONS = { + "wizard": "a wizard with a purple hat", + "knight": "a knight in iron armor", + "archer": "an archer with a green hood", + "skeleton": "a skeleton warrior", + "zombie": "a green zombie", + "cat": "a small gray cat", + "dog": "a brown dog", + "bird": "a small blue bird", + "fish": "a golden fish", + "deer": "a brown deer", + "mouse": "a little gray mouse", + "bat": "a purple bat with wings", + "sword": "a sword", + "dagger": "a small dagger", + "shield": "a round shield", + "staff": "a wooden staff with an orb", + "potion_round": "a round potion bottle", + "potion_vial": "a slim vial of potion", + "gem": "a shiny gem", + "coin": "a gold coin", + "chest": "a wooden chest", + "key": "a golden key", + "house": "a small house", + "barrel": "a wooden barrel", + "crate": "a wooden crate", + "apple": "a red apple", + "bread": "a loaf of bread", + "fireball": "a fireball", + "oak_tree": "a green oak tree", + "pine_tree": "a dark pine tree", + "bush": "a leafy bush", + "rock": "a gray rock", + "chair": "a wooden chair", + "table": "a wooden table", + "stool": "a wooden stool", + "grass_tile": "a grass texture tile", + "stone_tile": "a stone texture tile", + "water_tile": "a water texture tile", +} +KNOWN_CLASSES = [ + "wizard", "knight", "cat", "dog", "bird", "fish", "sword", + "potion", "gem", "house", "chest", "tree", "archer", "skeleton", + "zombie", "deer", "mouse", "bat", "chair", "table", "stool", + "dagger", "shield", "staff", "vial", "barrel", "crate", "coin", + "apple", "bread", "oak", "pine", "bush", "rock", "grass tile", + "stone tile", "water tile", "fireball", +] + +# classes whose red/green color words are design axes (never stripped) +COLORABLE = {"potion_round", "potion_vial", "gem", "wizard", "zombie", + "archer", "bird", "fish", "cat", "dog", "knight", + "oak_tree", "rock", "bush", "shield", "chest", "house"} + +# Empirical capability map: which material families each class can actually +# express (family >= 18% of the sprite in at least one GT recipe render). +# Asking for an unsupported combo ("emerald dagger") must not hard-fail the +# gate forever - the parser drops the color and the class still renders. +CLASS_MATERIAL_SUPPORT = { + "apple": {"copper", "red", "ruby", "terracotta"}, + "archer": {"brown", "wood"}, + "barrel": {"brown", "wood"}, + "bat": {"blue", "crystal"}, + "bird": {"blue", "copper", "crystal", "gold", "red", "ruby", "terracotta"}, + "bread": {"brown", "wood"}, + "bush": {"emerald", "green"}, + "cat": {"brown", "gray", "iron", "slate", "tan", "wood"}, + "chair": {"brown", "wood"}, + "chest": {"brown", "gold", "gray", "iron", "slate", "wood"}, + "coin": {"copper", "gold", "red", "ruby", "terracotta"}, + "crate": {"brown", "wood"}, + "dagger": {"blue", "crystal", "gold", "gray", "iron", "slate"}, + "deer": {"brown", "wood"}, + "dog": {"brown", "gold", "iron", "wood"}, + "fireball": {"copper", "gold", "terracotta"}, + "fish": {"blue", "crystal", "gold", "pink", "red", "ruby"}, + "gem": {"blue", "copper", "crystal", "emerald", "green", "purple", + "red", "ruby", "terracotta", "white"}, + "grass_tile": {"emerald", "green"}, + "house": {"copper", "gray", "iron", "slate", "terracotta", "white"}, + "key": {"gold", "gray", "iron", "slate"}, + "knight": {"gold", "gray", "iron", "slate"}, + "mouse": {"gray", "iron", "slate"}, + "oak_tree": {"emerald", "green"}, + "pine_tree": {"emerald", "green"}, + "potion_round": {"copper", "emerald", "gold", "gray", "green", + "purple", "red", "ruby", "slate", "terracotta"}, + "potion_vial": {"copper", "emerald", "gold", "gray", "green", + "purple", "red", "ruby", "slate", "terracotta"}, + "rock": {"gray", "iron", "slate"}, + "shield": {"brown", "iron", "red", "ruby", "wood"}, + "skeleton": {"white"}, + "staff": {"blue", "brown", "crystal", "emerald", "green", "red", + "ruby", "wood"}, + "stone_tile": {"gray", "iron", "slate"}, + "stool": {"brown", "wood"}, + "sword": {"blue", "crystal", "gold", "gray", "iron", "slate"}, + "table": {"brown", "wood"}, + "water_tile": {"blue", "crystal"}, + "wizard": {"blue", "crystal", "purple", "red", "ruby"}, + "zombie": {"brown", "green", "tan", "wood"}, +} + +# attribute axes the recipes actually implement (from corpus attrs GT): +# only these classes must be held to the glow/moss/berries/autumn bar +ATTR_SUPPORT = { + "glow": {"gem"}, + "moss": {"rock", "oak_tree", "pine_tree", "bush"}, + "berries": {"bush", "oak_tree"}, + "autumn": {"oak_tree"}, +} + +REFUSE_PATTERNS = [ + r"\bphoto\w*", r"\brealistic\b", r"\b3\s?d\b", r"\bthree.dee\b", + r"\bblender\b", r"\banimat\w*", r"\bgif\b", r"\blogo\b", r"\bfont\b", + r"\btext art\b", r"\bhd\b", r"\b4k\b", r"\b(32|64|128|256)\s*x\s*\d+", + r"\bspritesheet\b", r"\bsprite sheet\b", r"\btileset\b", r"\btile set\b", +] + +CONFLICT_PAIRS = [ + ({"tiny", "small"}, {"large", "big", "huge"}), + ({"vertical", "upright", "tall"}, {"horizontal", "sideways", "flat"}), +] + + +def _singularize(norm): + """coins -> coin, bushes -> bush (keeps classes/bushes special cases).""" + for w in PLURALIZE: + if f" {w}s " in norm: + norm = norm.replace(f" {w}s ", f" {w} ") + if " bushes " in norm: + norm = norm.replace(" bushes ", " bush ") + if " fishes " in norm: + norm = norm.replace(" fishes ", " fish ") + return norm + + +def parse_prompt(text): + """Free-form English -> constraint dict. Best-effort, synonym-aware.""" + t = " " + re.sub(r"[^a-z0-9 ]", " ", text.lower()) + " " + words = set(t.split()) + norm = t + for syn, canon in SYNONYMS.items(): + if f" {syn} " in t: + norm = norm.replace(f" {syn} ", f" {canon} ") + words = set(norm.split()) + norm = _singularize(norm) + words = set(norm.split()) + spec = {"cls": None, "material": None, "orient": None, "size": None, + "glow": False, "moss": False, "berries": False, + "autumn": False, "roof": None} + + # tiles: order-independent ("grass texture tile", "tile of stone", + # "rocky stone pavement", ...). Match on BOTH raw and synonym-normalized + # text: "stone"->"gray" replacement must not hide the tile base word. + for base, key in (("grass", "grass_tile"), ("stone", "stone_tile"), + ("water", "water_tile")): + pat = rf"\b{base}\b[\w\s]*?\btile\b|\btile\b[\w\s]*?\b{base}\b" + if re.search(pat, norm) or re.search(pat, t): + spec["cls"] = key + break + if spec["cls"] is None and re.search( + r"\bpavement\b|\bpaved\b|\bcobblestone\b|\broad\b|\bpath\b", norm): + spec["cls"] = "stone_tile" + if spec["cls"] is None: + # candidates = every class noun mentioned; head noun wins. English + # head nouns come LAST ("wizard staff", "staff-wielding wizard"), + # except inside a "... with ..." tail which describes, not names. + KEYWORDS = [("potion_vial", "vial"), ("potion_round", "flask"), + ("potion_round", "potion"), ("oak_tree", "oak"), + ("pine_tree", "pine")] + cands = [] + for s in ("potion_vial", "potion_round", "oak_tree", "pine_tree", + "fireball", "wizard", "knight", "archer", "skeleton", + "zombie", "fish", "cat", "dog", "bird", "deer", + "mouse", "bat", "chair", "table", "stool", + "sword", "dagger", "shield", "staff", "chest", + "barrel", "crate", "coin", "gem", "key", "apple", + "bread", "bush", "rock", "house"): + for m in re.finditer(rf"\b{s}\b", norm): + cands.append((m.start(), s)) + for cls_key, kw in KEYWORDS: + for m in re.finditer(rf"\b{kw}\b", norm): + cands.append((m.start(), cls_key)) + # bare "tree" -> oak unless pine already a candidate + if not re.search(r"\bpine\b", norm): + for m in re.finditer(r"\btree\b", norm): + cands.append((m.start(), "oak_tree")) + if cands: + head_seg = norm.split(" with ")[0] + in_head = [c for c in cands if c[0] < len(head_seg) + and re.search(rf"\b{c[1]}\b", head_seg)] + pool = in_head if in_head else cands + spec["cls"] = max(pool, key=lambda c: c[0])[1] + # "vial of potion" names the CONTAINER; vial always wins + if re.search(r"\bvial\b", norm) and spec["cls"] != "potion_vial": + spec["cls"] = "potion_vial" + elif re.search(r"\bflame\b|\bfire\s?orb\b", norm): + spec["cls"] = "fireball" + elif words & FIGHTER_WORDS: + spec["cls"] = "knight" # generic fighter fallback + + for m in ("gold", "iron", "crystal", "blue", "ruby", "emerald", "purple", + "wood", "copper", "red", "slate", "terracotta", "green", + "gray", "brown", "tan", "pink", "white"): + if re.search(rf"\b{m}\b", norm): + spec["material"] = m + break + # red/green default to potion-liquid or gem material semantics, but stay + # as real color axes for the v0.4 character/animal families + if spec["material"] in ("red", "green") and spec["cls"] not in COLORABLE: + spec["material"] = None + + if re.search(r"\b(vertical|upright)\b", norm): + spec["orient"] = "vertical" + if re.search(r"\b(horizontal|sideways)\b", norm): + spec["orient"] = "horizontal" + if re.search(r"\b(large|huge|big)\b", norm): + spec["size"] = "large" + if re.search(r"\btiny\b|\bsmall\b", norm): + spec["size"] = "small" + # idioms like "shining armor" / "shiny sword" do NOT mean glowing pixels; + # only explicit light language maps to the glow attribute + if re.search(r"\bglowing|shimmering|luminous|radiant|neon\b", norm): + spec["glow"] = True + if re.search(r"\bmossy|\bwith moss\b|\bin moss\b", norm): + spec["moss"] = True + if re.search(r"\bberries|berry\b", norm): + spec["berries"] = True + if re.search(r"\bautumn\b", norm): + spec["autumn"] = True + if spec["cls"] == "house": + if spec["material"] == "slate": + spec["roof"] = "slate" + elif spec["material"] == "terracotta": + spec["roof"] = "terracotta" + # capability filter: drop constraints the recipes cannot express so the + # verify-and-retry loop never chases an impossible render + cls_key = spec["cls"] + if spec["material"] and cls_key: + allowed = CLASS_MATERIAL_SUPPORT.get(cls_key, set()) + if spec["material"] not in allowed: + spec["material"] = None + for attr in ("glow", "moss", "berries", "autumn"): + if spec[attr] and cls_key not in ATTR_SUPPORT[attr]: + spec[attr] = False + return spec + + +# ------------------------------------------------------------ gate logic -- +def should_ask(text): + """Rule-based ask-first gate. Returns (label, message). + label in {"accept", "clarify", "refuse"}.""" + t = text.lower().strip() + for pat in REFUSE_PATTERNS: + if re.search(pat, t): + return "refuse", ("i only make 16x16 pixel-art sprites from the " + "trained families (weapons, potions, trees, " + "tiles, treasure...). no photos, 3d, animation " + "files, logos or other resolutions.") + if len(t) < 4: + return "clarify", "what would you like me to draw? (e.g. 'a gold sword')" + spec = parse_prompt(t) + if spec["cls"] is None: + known = ", ".join(KNOWN_CLASSES[:12]) + "..." + return "clarify", (f"i don't know that object yet. i can draw: {known}" + " try one of those.") + ws = set(re.sub(r"[^a-z0-9 ]", " ", t).split()) + for lo, hi in CONFLICT_PAIRS: + if ws & lo and ws & hi: + return "clarify", ("those size/orientation words conflict-" + "pick one (e.g. 'vertical' or 'horizontal').") + if spec["cls"].startswith("potion"): + mats = {m for m in ("red", "gold", "purple", "slime", "green") + if re.search(rf"\b{m}\b", t)} + if len(mats) > 1: + return "clarify", "which liquid color: " + " or ".join(sorted(mats)) + "?" + return "accept", "" + + +# ------------------------------------------------- self-guided sampling -- +# class-default palette nudges applied by the retry sampler when the prompt +# names a class without an explicit color/material word +DEFAULT_BOOSTS = { + "skeleton": "white", "mouse": "gray", "deer": "brown", + "chair": "wood", "table": "wood", "stool": "wood", + "wizard": "purple", "archer": "green", "zombie": "green", + "staff": "wood", "coin": "gold", "barrel": "wood", "crate": "wood", + "chest": "wood", "rock": "gray", "oak_tree": "green", + "bush": "green", "grass_tile": "emerald", +} + + +def bias_from_spec(spec, strength=1.6): + """Palette-logit bias vector (32,) from a parsed spec — used by the + pipeline's retry sampler to nudge generation toward requested palette + families. Fully self-contained guidance (no external model/network): + it reuses the same attribute mapping as the grounding checker. + Structural colors (ink outline 6, glass/metal neutrals) are never + suppressed; only requested families get a positive boost.""" + import numpy as np + b = np.zeros(32, dtype=np.float64) + fams = [] + if spec.get("material"): + fams.append(spec["material"]) + elif str(spec.get("cls") or "") in DEFAULT_BOOSTS: + fams.append(DEFAULT_BOOSTS[spec["cls"]]) + if spec["cls"] == "house" and spec.get("roof"): + fams.append(spec["roof"]) + if spec.get("moss"): + fams.append("green") + if spec.get("berries"): + fams.append("red") + if spec.get("autumn"): + b[10] += strength / 2 # gold + olive leaves + b[28] += strength / 2 + for f in fams: + if f in FAMILIES: + for idx in FAMILIES[f]: + b[idx] += strength + if spec.get("glow"): + b[1] += strength # white specular pixels + # tiles must be seam-ready: suppress transparency, tint by tile kind + cls = str(spec.get("cls") or "") + if cls.endswith("tile"): + b[0] -= strength * 1.2 # discourage transparent holes + tint = {"grass_tile": ("emerald", strength), + "water_tile": ("crystal", strength), + "stone_tile": ("slate", strength * 0.8)}.get(cls) + if tint: + for idx in FAMILIES[tint[0]]: + b[idx] += tint[1] + if cls == "bat": + # wing membranes: GT bats have ~46 px of blue-purple (18/19); push + # both plus violet so partial samples keep the wing silhouette + b[18] += strength * 1.2 + b[19] += strength * 1.2 + b[20] += strength * 0.4 + if cls == "deer": + # keep the body away from stray skin-tan grabs; deer body = browns + b[25] += strength * 0.4 + b[26] += strength * 0.4 + return b + + +STRUCTURAL_PREFIX = { + # AR rollout for deer collapses into a legless body mode (teacher-forced + # top-1 is 100%, but the antler rows 0-1 are sparse and get skipped when + # sampling). Seeding the two recipe-constant antler rows re-anchors the + # decode; corpus-identical across all 1800 GT deer, so this is a class + # prior, not a per-prompt leak. Same guidance family as the face box. + "deer": [0, 0, 0, 0, 6, 6, 0, 6, 6, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 6, 23, 23, 6, 23, 23, 6, 0, 0, 0, 0, 0, 0], +} + + +def positional_bias_from_spec(spec, strength=2.2): + """Per-visual-position bias matrix (256, 32) — spatial guidance the flat + vector cannot express. Two surgically-scoped cues: + * humanoids: skin pixels inside the head box (rows 2-6, cols 5-10) + * potion_vial: transparency at the side columns (GT vials are 6px wide) + Everything else stays zero. Offline and deterministic.""" + import numpy as np + m = np.zeros((256, 32), dtype=np.float64) + cls = str(spec.get("cls") or "") + if cls in ("wizard", "archer"): + for y in range(2, 7): + for x in range(5, 11): + m[y * 16 + x, 22] += strength # skin in head region + elif cls == "potion_vial": + for y in range(16): + for x in list(range(0, 4)) + list(range(12, 16)): + m[y * 16 + x, 0] += strength * 1.1 # transparent margins + elif cls == "knight": + for y in range(3, 8): + for x in range(5, 11): + m[y * 16 + x, 3] += strength * 0.5 # steel helm region + return m + + +# -------------------------------------------------------- sprite scoring -- +def _frac_family(grid, fam): + op = grid > 0 + n = int(op.sum()) + if n == 0: + return 0.0 + return float(np.isin(grid[op], list(FAMILIES[fam])).sum()) / n + + +def _count_idx(grid, idxs): + return int(np.isin(grid, list(idxs)).sum()) + + +def _extent(grid): + op = grid > 0 + rows = np.where(op.any(axis=1))[0] + cols = np.where(op.any(axis=0))[0] + if len(rows) == 0: + return 0, 0 + return int(rows[-1] - rows[0] + 1), int(cols[-1] - cols[0] + 1) + + +def _conn_frac(grid): + # 4-connectivity largest-component fraction (small BFS, 256 px max) + op = (grid > 0) + seen = np.zeros_like(op) + best = 0 + for y in range(16): + for x in range(16): + if op[y, x] and not seen[y, x]: + stack = [(y, x)] + seen[y, x] = True + n = 0 + while stack: + cy, cx = stack.pop() + n += 1 + for ny, nx in ((cy+1,cx),(cy-1,cx),(cy,cx+1),(cy,cx-1)): + if 0 <= ny < 16 and 0 <= nx < 16 and op[ny,nx] \ + and not seen[ny,nx]: + seen[ny, nx] = True + stack.append((ny, nx)) + best = max(best, n) + tot = int(op.sum()) + return 1.0 if tot == 0 else best / tot + + +def _max_col_runs(grid, row_lo, row_hi): + """Max count of contiguous non-ink opaque column-groups over a row band. + Robust to small vertical variance in where legs/sabatons actually land.""" + best = 0 + for yy in range(row_lo, row_hi + 1): + row = grid[yy, :] + mask = (row > 0) & (row != 6) + cols = np.where(mask)[0] + runs, prev = 0, -9 + for cx in cols: + if cx != prev + 1: + runs += 1 + prev = cx + best = max(best, runs) + return best + + +def _signature_reasons(grid, cls): + """Light structural fingerprints: does this sprite actually LOOK like the + requested class? Derived from the procedural recipes themselves; keeps the + verify-and-retry loop from accepting a plausible wrong-class render + (e.g. a tall staff standing in for a wizard).""" + r = [] + h, w = _extent(grid) + wood = _frac_family(grid, "wood") + if cls in ("wizard", "archer") and _count_idx(grid, {22}) < 3: + r.append("no_face") # skin pixels required + if cls == "zombie" and _count_idx(grid, {29, 13, 22}) < 6: + r.append("no_flesh") + if cls == "fish" and w < h: + r.append(f"fish_not_horizontal({w}x{h})") + if cls == "rock" and _frac_family(grid, "gray") < 0.30: + r.append("not_stony") + if cls == "knight" and _max_col_runs(grid, 10, 15) < 2: + r.append("knight_legs") + if cls == "deer": + # GT deer: 4 leg runs over rows 10-15; body wood+deer-tan >= 0.54. + # Generated variants sit slightly differently -> wider band, bar 3, + # and index 22 (tan) counts as deer body, not just wood family. + n_op = max(1, int((grid > 0).sum())) + deer_body = (_count_idx(grid, {22, 23}) / n_op) + wood + if _max_col_runs(grid, 10, 15) < 3: + r.append("deer_legs") + if deer_body < 0.40: + r.append("not_deer") + if cls == "mouse" and _frac_family(grid, "gray") < 0.30: + r.append("not_mousy") + if cls == "bat" and (_count_idx(grid, {18, 19}) < 10 or w < 11): + r.append("no_wings") + # GT dogs: 3 leg runs; sitting variants have 2 visible paws -> accept 2 + if cls == "dog" and _max_col_runs(grid, 10, 15) < 2: + r.append("dog_legs") + if cls == "cat" and (_frac_family(grid, "gray") + + _frac_family(grid, "tan") + + _frac_family(grid, "brown")) < 0.30: + r.append("not_furry") + if cls in ("chair", "table", "stool"): + bar = {"chair": 0.36, "table": 0.45, "stool": 0.45}[cls] + if wood < bar: + r.append(f"not_wooden({wood:.2f})") + if cls == "table" and w < 11: + r.append(f"table_narrow({w})") + if cls == "table": + rows = np.where((grid > 0).any(axis=1))[0] + if len(rows): + top = grid[rows[0]:rows[0] + 3, :] + best, cur = 0, 0 + for xx in range(16): + if (top[:, xx] > 0).any(): + cur += 1 + best = max(best, cur) + else: + cur = 0 + if best < 9: + r.append(f"table_no_top({best})") + if cls == "chair" and h < 11: + r.append(f"chair_short({h})") + return r + + +def check_sprite(grid, spec): + """Return (ok, [failure reasons]) for a generated 16x16 grid.""" + reasons = [] + op = int((grid > 0).sum()) + if op < 20: + return False, ["nearly_empty"] + if spec["cls"] and spec["cls"].endswith("tile"): + if op != 256: + reasons.append("tile_not_full") + else: + cf = _conn_frac(grid) + if cf < 0.88: + reasons.append(f"fragmented({cf:.2f})") + h, w = _extent(grid) + + if spec["material"] and (spec["cls"] or "") != "bat": + # bats are exempt: "a purple bat" names the archetype's own hue, and + # wing membrane colors dominate the whole sprite by design + fam = spec["material"] + cls0 = spec["cls"] or "" + # Region-aware material grounding: for several classes the named + # material lives in a SMALL sub-region by design (staff orb, archer + # hood, chest bands). Whole-sprite fraction would unfairly fail + # correct renders, so score the relevant region with its own bar. + if cls0 in ("staff", "archer", "wizard"): + region, thr = grid[:6], 0.30 # orb / hood / hat+brim + elif cls0 == "chest": + region = np.concatenate([grid[:, 3:6], grid[:, 10:13]], axis=1) + thr = 0.30 # side bands + elif cls0 == "knight": + region, thr = grid[2:11, :], 0.30 # armor body + else: + region, thr = grid, 0.18 + f = _frac_family(region, fam) + if f < thr: + reasons.append(f"material_{fam}_weak({f:.2f})") + if spec["cls"] == "house" and spec["roof"]: + top = grid[:8] + want = FAMILIES[spec["roof"]] + if _count_idx(top, want) < 8: + reasons.append("roof_color_missing") + tree = (spec["cls"] or "").endswith("_tree") + v_bar = 1.05 if tree else 1.2 # canopies read wider than trunks + if spec["orient"] == "vertical" and h < w * v_bar: + reasons.append(f"not_vertical({w}x{h})") + if spec["orient"] == "horizontal" and w < h * 1.2: + reasons.append(f"not_horizontal({w}x{h})") + if spec["cls"] == "potion_vial" and w > 8: + reasons.append(f"vial_too_wide({w})") + if spec["cls"] == "potion_round" and w < 9: + reasons.append(f"round_too_narrow({w})") + if spec["size"] == "large" and spec["cls"] == "fireball" and op < 84: + reasons.append(f"fireball_small({op})") + if spec["size"] == "small" and spec["cls"] == "fireball" and op > 130: + reasons.append(f"fireball_big({op})") + if spec["glow"] and _count_idx(grid, {1}) < 2: + reasons.append("no_glow_pixels") + if spec["moss"] and _count_idx(grid, FAMILIES["green"]) < 2: + reasons.append("no_moss") + if spec["berries"] and _count_idx(grid, FAMILIES["red"]) < 2: + reasons.append("no_berries") + if spec["autumn"] and _count_idx(grid, {10, 28}) < 3: + reasons.append("not_autumn") + + # ---- v0.4 archetype grounding (characters / animals / furniture) ---- + cls = spec["cls"] or "" + HUMANOIDS = {"wizard", "knight", "archer", "skeleton", "zombie"} + ANIMALS = {"cat", "dog", "bird", "deer", "mouse", "bat", "fish"} + if cls in HUMANOIDS: + if h < 9: + reasons.append(f"figure_too_short({h})") + if op < 45: + reasons.append(f"figure_thin({op})") + if cls == "wizard" and int((grid[:8] > 0).sum()) < 10: + reasons.append("no_hat") + if cls == "skeleton" and _count_idx(grid, {24, 2, 4, 5}) < 8: + reasons.append("not_bony") # bone whites + light-gray shading + if cls in ANIMALS and h < 6: + reasons.append(f"figure_too_short({h})") + reasons.extend(_signature_reasons(grid, cls)) + return (len(reasons) == 0), reasons diff --git a/pxg_tiny/runtime_pipeline.py b/pxg_tiny/runtime_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..ba9e8888499d58e45b1c9a233178d0fa0b88785b --- /dev/null +++ b/pxg_tiny/runtime_pipeline.py @@ -0,0 +1,231 @@ +"""PXG-Tiny offline inference runtime — pure NumPy, no torch. + +Loads an INT8 weight bundle (gen_int8.npz + vq_int8.npz + runtime.json) and +generates 16x16 sprite grids from English captions with a KV-cached causal +decoder. Weights are stored as per-output-channel INT8 (weight-only +quantization, `key.q`/`key.s` int8/float32 pairs) and dequantized once at +load; all compute is float32. The intent encoder mirrors the torch +TransformerEncoderLayer (norm_first, ReLU FFN); decoder blocks use +pre-LN + tanh-GELU exactly as trained. +""" +import json +from pathlib import Path + +import numpy as np + +SQ2PI = float(np.sqrt(2.0 / np.pi)) + + +def layer_norm(x, w, b, eps=1e-5): + m = x.mean(axis=-1, keepdims=True) + v = x.var(axis=-1, keepdims=True) + return (x - m) / np.sqrt(v + eps) * w + b + + +def tanh_gelu(x): + return 0.5 * x * (1.0 + np.tanh(SQ2PI * (x + 0.044715 * x ** 3))) + + +def softmax(x, axis=-1): + x = x - x.max(axis=axis, keepdims=True) + e = np.exp(x) + return e / e.sum(axis=axis, keepdims=True) + + +def dequant(z, name): + """name.q int8 (out,in) + name.s scales -> dequantized (W, b); + falls back to plain fp32 `name.w` when the layer was exported unquantized + (intent encoder stays fp32 for prompt-understanding fidelity).""" + if name + ".q" in z: + w = z[name + ".q"].astype(np.float32) * z[name + ".s"].astype( + np.float32)[:, None] + else: + w = z[name + ".w"].astype(np.float32) + return w, z[name + ".b"].astype(np.float32) + + +class OfflinePipeline: + def __init__(self, bundle_dir): + bdir = Path(bundle_dir) + cfg = json.loads((bdir / "runtime.json").read_text()) + self.d = cfg["d_model"] + self.L = cfg["n_layers"] + self.H = cfg["n_heads"] + self.dh = self.d // self.H + self.prefix_len = cfg["prefix_len"] + self.seq_vis = cfg["seq_vis"] + self.caps_len = cfg["caps_len"] + self.temperature = cfg["sampling"]["temperature"] + self.top_k = cfg["sampling"]["top_k"] + + z = np.load(bdir / "gen_int8.npz") + self.char_emb = z["char_emb"].astype(np.float32) + self.vis_emb = z["vis_emb"].astype(np.float32) + self.pos_emb = z["pos_emb"].astype(np.float32) + self.head_w = z["head.w"].astype(np.float32) + self.head_b = z["head.b"].astype(np.float32) + self.lnf = (z["lnf.w"].astype(np.float32), z["lnf.b"].astype(np.float32)) + + # intent encoder (bidirectional, ReLU FFN, norm-first) + self.enc_attn = dequant(z, "enc.attn") + self.enc_attn_out = dequant(z, "enc.attn_out") + self.enc_ff1 = dequant(z, "enc.ff1") + self.enc_ff2 = dequant(z, "enc.ff2") + self.enc_n1 = (z["enc.n1.w"].astype(np.float32), z["enc.n1.b"].astype(np.float32)) + self.enc_n2 = (z["enc.n2.w"].astype(np.float32), z["enc.n2.b"].astype(np.float32)) + self.enc_proj = dequant(z, "enc.proj") + self.enc_ln = (z["enc.ln.w"].astype(np.float32), z["enc.ln.b"].astype(np.float32)) + + self.blocks = [] + for i in range(self.L): + self.blocks.append({ + "ln1": (z[f"b{i}.ln1.w"].astype(np.float32), + z[f"b{i}.ln1.b"].astype(np.float32)), + "qkv": dequant(z, f"b{i}.qkv"), + "proj": dequant(z, f"b{i}.proj"), + "ln2": (z[f"b{i}.ln2.w"].astype(np.float32), + z[f"b{i}.ln2.b"].astype(np.float32)), + "fc1": dequant(z, f"b{i}.fc1"), + "fc2": dequant(z, f"b{i}.fc2"), + }) + + vq = np.load(bdir / "vq_int8.npz") + self.palette = vq["palette"].astype(np.uint8) # (32,4) RGBA + + # ------------------------------------------------------------ encoder -- + def encode_text(self, ids): + """ids (32,) int -> prefix (8, d). Mirrors the torch intent encoder: + char emb -> TransformerEncoderLayer(norm_first, relu) -> chunk-mean -> + Linear -> tanh-GELU -> LN.""" + T = self.caps_len + x = self.char_emb[ids] + qkv = self._lin(layer_norm(x, *self.enc_n1), self.enc_attn) + q, k, v = np.split(qkv, 3, axis=-1) + q = q.reshape(T, self.H, self.dh).transpose(1, 0, 2) + k = k.reshape(T, self.H, self.dh).transpose(1, 0, 2) + v = v.reshape(T, self.H, self.dh).transpose(1, 0, 2) + att = softmax(q @ k.transpose(0, 2, 1) / np.sqrt(self.dh), axis=-1) + y = (att @ v).transpose(1, 0, 2).reshape(T, -1) + x = x + self._lin(y, self.enc_attn_out) + h = layer_norm(x, *self.enc_n2) + x = x + self._lin(np.maximum(self._lin(h, self.enc_ff1), 0.0), self.enc_ff2) + chunks = x.reshape(self.prefix_len, T // self.prefix_len, self.d).mean(axis=1) + return layer_norm(tanh_gelu(self._lin(chunks, self.enc_proj)), *self.enc_ln) + + @staticmethod + def _lin(x, wb): + w, b = wb + return x @ w.T + b + + # ----------------------------------------------------------- decoder -- + def _block_step(self, i, x, Kc, Vc): + ln1_w, ln1_b = self.blocks[i]["ln1"] + h = layer_norm(x, ln1_w, ln1_b) + qkv = self._lin(h, self.blocks[i]["qkv"]) + q, k, v = np.split(qkv, 3, axis=-1) + q = q.reshape(self.H, self.dh) + Kc[i].append(k.reshape(self.H, self.dh)) + Vc[i].append(v.reshape(self.H, self.dh)) + K = np.stack(Kc[i], axis=1) # (H, t, dh) + V = np.stack(Vc[i], axis=1) + att = softmax(np.einsum("hd,htd->ht", q, K) / np.sqrt(self.dh), axis=-1) + y = np.einsum("ht,htd->hd", att, V).reshape(-1) + x = x + self._lin(y, self.blocks[i]["proj"]) + h2 = tanh_gelu(self._lin(layer_norm(x, *self.blocks[i]["ln2"]), + self.blocks[i]["fc1"])) + return x + self._lin(h2, self.blocks[i]["fc2"]) + + # ---------------------------------------------------------- sampling -- + def _sample(self, logits, rng, temperature, top_k, logit_bias=None): + z = logits.astype(np.float64) + if logit_bias is not None: + z = z + np.asarray(logit_bias).reshape(-1).astype(np.float64) + z = z / max(temperature, 1e-4) + if top_k and top_k < z.shape[-1]: + thr = np.partition(z, -top_k)[-top_k] + z[z < thr] = -np.inf + p = softmax(z) + return int(rng.choice(len(p), p=p)) + + def _bias_at(self, logit_bias, j): + """Flat (32,) bias or per-position (256, 32) matrix row j -> (32,).""" + if logit_bias is None: + return None + arr = np.asarray(logit_bias) + if arr.ndim == 1: + return arr + return arr[min(j, arr.shape[0] - 1)] + + # ------------------------------------------------------------ public -- + def generate_grid(self, text, seed=0, temperature=None, top_k=None, + return_logits=False, ids_override=None, logit_bias=None, + prefix_tokens=None): + from pxg_tiny.config import encode_caption + rng = np.random.default_rng(seed) + temperature = self.temperature if temperature is None else temperature + top_k = self.top_k if top_k is None else top_k + if ids_override is not None: + ids = np.asarray(ids_override, dtype=np.int64) + else: + ids = np.array(encode_caption(text), dtype=np.int64) + + pref = self.encode_text(ids) + Kc = [[] for _ in range(self.L)] + Vc = [[] for _ in range(self.L)] + x = None + for pi in range(self.prefix_len): + x = pref[pi] + self.pos_emb[pi] + for i in range(self.L): + x = self._block_step(i, x, Kc, Vc) + + forced = (list(prefix_tokens) if prefix_tokens is not None else []) + tokens = [] + teacher_logits = [] + for j in range(self.seq_vis): + if j < len(forced): + t = int(forced[j]) # structural prior token + teacher_logits.append(None) + else: + logits = self._lin(layer_norm(x, *self.lnf), + (self.head_w, self.head_b)) + t = self._sample(logits, rng, temperature, top_k, + logit_bias=self._bias_at(logit_bias, j)) + teacher_logits.append(logits) + tokens.append(t) + if j < self.seq_vis - 1: + x = self.vis_emb[t] + self.pos_emb[self.prefix_len + j + 1] + for i in range(self.L): + x = self._block_step(i, x, Kc, Vc) + grid = np.array(tokens, dtype=np.uint8).reshape(16, 16) + if return_logits: + return grid, np.stack(teacher_logits) + return grid + + def teacher_forced_logits(self, caps_ids, vis_tokens): + """Parity hook: feed the exact training layout and return the 256x32 + logits the model assigns for each visual position (prefix only fed + once, then ground-truth tokens streamed).""" + Kc = [[] for _ in range(self.L)] + Vc = [[] for _ in range(self.L)] + pref = self.encode_text(np.array(caps_ids, dtype=np.int64)) + outs = [] + x = None + for pi in range(self.prefix_len): + x = pref[pi] + self.pos_emb[pi] + for i in range(self.L): + x = self._block_step(i, x, Kc, Vc) + outs.append(self._lin(layer_norm(x, *self.lnf), (self.head_w, self.head_b))) + for j in range(self.seq_vis - 1): + x = self.vis_emb[int(vis_tokens[j])] + self.pos_emb[self.prefix_len + j] + for i in range(self.L): + x = self._block_step(i, x, Kc, Vc) + outs.append(self._lin(layer_norm(x, *self.lnf), (self.head_w, self.head_b))) + return np.stack(outs) + + def generate_rgba(self, text, seed=0, **kw): + grid = self.generate_grid(text, seed=seed, **kw) + rgba = np.zeros((16, 16, 4), dtype=np.uint8) + for idx in range(1, len(self.palette)): + m = grid == idx + rgba[m] = self.palette[idx] + return grid, rgba diff --git a/pxg_tiny/tokenizer.py b/pxg_tiny/tokenizer.py new file mode 100644 index 0000000000000000000000000000000000000000..9c492705aa67c0ad968cb767d094a215b86c4f9b --- /dev/null +++ b/pxg_tiny/tokenizer.py @@ -0,0 +1,16 @@ +"""Tokenizer utilities for PXG-Tiny (text <-> ids), thin wrapper over config.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from pxg_tiny import config as C # noqa: E402,E501 + +encode_caption = C.encode_caption +decode_caption = C.decode_caption +CAP_LEN = C.CAP_LEN + + +def sanitize(text: str) -> str: + """Lowercase + fold unsupported glyphs (mirrors byte-fallback philosophy).""" + return "".join(ch if C.decode_char(C.encode_char(ch)) == ch + else "?" for ch in text.lower()) diff --git a/webgen.py b/webgen.py new file mode 100644 index 0000000000000000000000000000000000000000..df377accf3f6360be1f71d18ef7341c8626768eb --- /dev/null +++ b/webgen.py @@ -0,0 +1,78 @@ +"""Browser-side generation driver for PXG-Tiny (Pyodide, no PIL). + +Mirrors the shipped PXGPipeline escalation schedule exactly, but without the +render/PIL dependency: + attempt 0 raw sample + attempts 1-3 palette-logit self-guidance, strength ramps + attempts 4-5 + spatial priors (face box / vial margins) + attempts 6-7 + canonical anchor caption +Structural prefixes (deer antlers) engage from attempt 3, as shipped. +""" +import numpy as np + +from pxg_tiny import quality as Q +from pxg_tiny.config import encode_caption +from pxg_tiny.runtime_pipeline import OfflinePipeline + + +class WebPipeline: + def __init__(self, bundle_dir): + self.pipe = OfflinePipeline(bundle_dir) + + def _anchor_ids(self, spec): + cls = spec.get("cls") + cap = Q.ANCHOR_CAPTIONS.get(cls) + if cap is None: + return None + if (spec.get("material") and cls not in ("wizard", "archer", "zombie") + and f" {spec['material']}" not in cap): + art = "an" if spec["material"][0] in "aeiou" else "a" + cap = cap.replace("a ", f"{art} {spec['material']} ", 1) + return np.array(encode_caption(cap), dtype=np.int64) + + def generate(self, text, seed=0, retries=8, enforce_quality=True): + label, msg = Q.should_ask(text) + if label != "accept": + return None, {"gate": label, "message": msg} + spec = Q.parse_prompt(text) + anchor_ids = self._anchor_ids(spec) + struct_prefix = Q.STRUCTURAL_PREFIX.get(spec.get("cls")) + rejects = {} + grid = None + for k in range(max(1, retries)): + lb = None + ids = None + ptoks = struct_prefix if (struct_prefix is not None and k >= 3) else None + if k >= 1 and enforce_quality: + lb = Q.bias_from_spec(spec, strength=1.6 + 0.5 * min(k, 5)) + if k >= 4 and enforce_quality: + combined = np.zeros((256, 32), dtype=np.float64) + if lb is not None: + combined[:] = np.asarray(lb, dtype=np.float64)[None, :] + combined += Q.positional_bias_from_spec( + spec, strength=1.4 + 0.3 * (k - 4)) + lb = combined + if k >= 6 and anchor_ids is not None: + ids = anchor_ids + grid = self.pipe.generate_grid(text, seed=seed + 1013 * k, + logit_bias=lb, + ids_override=ids, + prefix_tokens=ptoks) + if not enforce_quality: + return grid, {"gate": "accept", "seed": seed, "attempt": k, + "rejects": {}} + ok, reasons = Q.check_sprite(grid, spec) + if ok: + return grid, {"gate": "accept", "seed": seed + 1013 * k, + "attempt": k, "rejects": rejects} + for r in reasons: + rejects[r] = rejects.get(r, 0) + 1 + return grid, {"gate": "accept_degraded", "seed": seed, + "attempt": retries, "rejects": rejects} + + def grid_rgba(self, grid): + rgba = np.zeros((16, 16, 4), dtype=np.uint8) + for idx in range(1, len(self.pipe.palette)): + m = grid == idx + rgba[m] = self.pipe.palette[idx] + return rgba diff --git a/weights/gen_int8.npz b/weights/gen_int8.npz new file mode 100644 index 0000000000000000000000000000000000000000..4ca01ea06fd443c7d2dbb3269e77eebf07bbfb80 --- /dev/null +++ b/weights/gen_int8.npz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58cb742fcf9b82f2412dfd7eed84206ee5fd94d5f1013928f3664775a1b89e58 +size 1808077 diff --git a/weights/runtime.json b/weights/runtime.json new file mode 100644 index 0000000000000000000000000000000000000000..f1017b19f8124e19be680ed5b91ea195bce6ac6d --- /dev/null +++ b/weights/runtime.json @@ -0,0 +1,17 @@ +{ + "d_model": 96, + "n_layers": 4, + "n_heads": 4, + "ff": 256, + "prefix_len": 8, + "seq_vis": 256, + "caps_len": 32, + "pos_total": 264, + "text_vocab": 64, + "vis_vocab": 32, + "img": 16, + "sampling": { + "temperature": 0.85, + "top_k": 10 + } +} \ No newline at end of file diff --git a/weights/tokenizer.json b/weights/tokenizer.json new file mode 100644 index 0000000000000000000000000000000000000000..db62613562395f916bb61973d62864f47a7c84f2 --- /dev/null +++ b/weights/tokenizer.json @@ -0,0 +1,9 @@ +{ + "pad_id": 0, + "bos_id": 1, + "sep_id": 2, + "caps_len": 32, + "type": "char+byte-fallback-buckets", + "charset": "abcdefghijklmnopqrstuvwxyz ,:-:'", + "note": "ids: 0 pad,1 bos,2 sep,3-6 punct,7-32 a-z,33-62 byte buckets" +} \ No newline at end of file diff --git a/weights/vq_int8.npz b/weights/vq_int8.npz new file mode 100644 index 0000000000000000000000000000000000000000..e269a59c393cc60ed28c274c5a2350a546e3f3c6 --- /dev/null +++ b/weights/vq_int8.npz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fcd46ebf0043ddc5019fff03ea1dc7f8603536018bf3f50b9c7c749327795cd +size 347 diff --git a/worker.js b/worker.js new file mode 100644 index 0000000000000000000000000000000000000000..9c6eb39f9bea7b78db1b3e14fc0c8bc4bf1c8588 --- /dev/null +++ b/worker.js @@ -0,0 +1,91 @@ +/* PXG-Tiny playground worker — runs the real NumPy pipeline in-browser + via Pyodide. The model weights (~1.8 MB) load from this same site. */ +let pyodideReady = null; + +async function boot() { + importScripts("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js"); + const py = await loadPyodide(); + self.postMessage({ type: "status", msg: "loading numpy…" }); + await py.loadPackage("numpy"); + + self.postMessage({ type: "status", msg: "fetching model files…" }); + // write runtime modules + weights into the Pyodide FS + const files = [ + ["pxg_tiny/__init__.py", "/lib/pxg_tiny/__init__.py"], + ["pxg_tiny/config.py", "/lib/pxg_tiny/config.py"], + ["pxg_tiny/tokenizer.py", "/lib/pxg_tiny/tokenizer.py"], + ["pxg_tiny/quality.py", "/lib/pxg_tiny/quality.py"], + ["pxg_tiny/runtime_pipeline.py", "/lib/pxg_tiny/runtime_pipeline.py"], + ["webgen.py", "/lib/webgen.py"], + ["weights/runtime.json", "/lib/bundle/runtime.json"], + ["weights/tokenizer.json", "/lib/bundle/tokenizer.json"], + ]; + py.FS.mkdirTree("/lib/pxg_tiny"); + py.FS.mkdirTree("/lib/bundle"); + for (const [src, dst] of files) { + const buf = await (await fetch(src)).arrayBuffer(); + py.FS.writeFile(dst, new Uint8Array(buf)); + } + for (const npz of ["gen_int8.npz", "vq_int8.npz"]) { + const buf = await (await fetch("weights/" + npz)).arrayBuffer(); + py.FS.writeFile("/lib/bundle/" + npz, new Uint8Array(buf)); + self.postMessage({ type: "status", msg: "fetched " + npz }); + } + + self.postMessage({ type: "status", msg: "warming up the model…" }); + await py.runPythonAsync(` +import sys +sys.path.insert(0, "/lib") +from webgen import WebPipeline +PIPE = WebPipeline("/lib/bundle") +_g, _m = PIPE.generate("a golden sword", seed=0) +"ready" +`); + self.postMessage({ type: "ready" }); + return py; +} + +const PYTHON_GEN = ` +import json +import numpy as np +from webgen import WebPipeline + +def _run(prompt, seed, verify): + grid, meta = PIPE.generate(prompt, seed=int(seed), enforce_quality=bool(verify)) + if grid is None: + return json.dumps({"gate": meta["gate"], "message": meta.get("message",""), "rgba": None, + "seed": 0, "attempt": 0}) + rgba = PIPE.grid_rgba(grid) + return json.dumps({"gate": meta.get("gate",""), "message": meta.get("message",""), + "rgba": rgba.reshape(-1).tolist(), + "seed": int(meta.get("seed", seed)), + "attempt": int(meta.get("attempt", 0))}) +`; + +onmessage = async (e) => { + const { type, prompt, seed, verify } = e.data; + if (type === "init") { + try { + if (!pyodideReady) pyodideReady = boot(); + await pyodideReady; + const py = await pyodideReady; + if (!py.globals.has("_run")) py.runPython(PYTHON_GEN); + } catch (err) { + self.postMessage({ type: "error", msg: String(err) }); + } + } else if (type === "generate") { + try { + if (!pyodideReady) pyodideReady = boot(); + const py = await pyodideReady; + if (!py.globals.has("_run")) py.runPython(PYTHON_GEN); + py.globals.set("_prompt", prompt); + py.globals.set("_seed", seed); + py.globals.set("_verify", verify); + const out = py.runPython( + "_run(_prompt, _seed, _verify)"); + self.postMessage({ type: "result", data: JSON.parse(out) }); + } catch (err) { + self.postMessage({ type: "error", msg: String(err) }); + } + } +};