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
+
+
+
+
+
+
full acceptance grid
+
All 38 classes, verified 100%
+
The final acceptance run tested every class with two prompt styles × two seeds through the
+ exact shipped user path : 152/152 passed (100%) . A second, independent sweep with 39
+ never-seen phrasings scored 99.1% . Each card below is a real accepted render —
+ hover any sprite to zoom.
+
+
+
+
+
+
+
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
+
+
+ Protocol Result What it measures
+ Full 38-class grid 152/152 = 100% 2 prompt styles × 2 seeds per class, exact shipped pipeline (verifier-guided retries on)
+ Fresh-phrasing sweep 116/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% strict 46 adversarial rephrasings, single free-run sample — the honest backbone-only number
+ Parity gate PASS NumPy 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..."
+
+
+
+ ▸ Ask-first behavior — asks clarifying questions on unknown objects or
+ conflicting words ("tiny huge sword"), refuses photos/3D/animation requests instead of hallucinating.
+ ▸ Verifier-guided sampling — every candidate sprite is checked against the
+ parsed prompt; failed seeds escalate through self-guidance (8 attempts) automatically.
+ ▸ Deterministic seeds — same prompt + seed ⇒ same sprite, forever.
+ ▸ 22 automated tests — tokenizer, gates, NumPy kernels, end-to-end: pytest tests/ -q
+
+
+
+
+
+
+
honest limitations
+
What it won't do
+
+ ✕ 16×16 only — larger canvases, spritesheets and tilemaps are refused by
+ design; scaling to 32×32 would 4× the sequence length and require a full redesign.
+ ✕ 38-class vocabulary — anything outside the trained families gets a
+ clarifying question, never a hallucinated blob.
+ ✕ Binary transparency — master-palette indices mean no soft edges/anti-aliasing.
+ ✕ Impossible combos downgrade — "emerald dagger" renders a default dagger
+ (the recipe set has no such training pair) instead of failing.
+ ✕ No animation / multi-view — roadmap items from the main PXG program.
+
+
+
+
+
+
+
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
+
+ ▸ Built-in visual "tokenizer" — sprites are grids of 32 master-palette indices,
+ so decoding is byte-exact and alpha transparency is exact by construction.
+ ▸ Self-teaching recipe — no external dataset: the procedural rasterizer makes
+ captions honest by construction; twins teach causality; paraphrases teach invariance.
+ ▸ Verifiable — quality gates calibrated so the GT corpus passes 100%; parity
+ harness proves the shipped NumPy runtime matches the torch training reference bit-for-bit.
+
+
+
+
+
+
+
+
PXG-Tiny v0.4 — offline text → pixel-sprite model
+ Created by
Chowdhury Tarul Ahsan ·
tarulahsan@gmail.com
+ Code: MIT · Weights & corpus: CC0 (self-generated)
+
+
+
+
+ every sprite on this page: generated by PXG-Tiny v0.4, accepted by its own verifier
+
+
+
+
+
+
+
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
+
+
+
+
+ Generate
+
+
+ seed
+ verifier-guided retries (8 attempts)
+
+
+
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.
+
+
+
+
+
+
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) });
+ }
+ }
+};