From db64038b255ca1faf3fac4de10332807b66186dc Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 27 Oct 2026 11:54:19 -0700 Subject: [PATCH] refactor(model_resolution_picker): move the family table to families.json with a validator - ship the table as families.json with per-family notes from the old source comments - add table.py: load_table, pure validate_table and build_families - families.py loads and validates at import, raising RuntimeError on problems - include *.json as package data --- .../model_resolution_picker/families.json | 1848 +++++++++++++++++ .../tools/model_resolution_picker/families.py | 849 +------- .../tools/model_resolution_picker/table.py | 265 +++ pyproject.toml | 3 + .../model_resolution_picker/test_table.py | 211 ++ 5 files changed, 2367 insertions(+), 809 deletions(-) create mode 100644 kikotools/tools/model_resolution_picker/families.json create mode 100644 kikotools/tools/model_resolution_picker/table.py create mode 100644 tests/unit/tools/model_resolution_picker/test_table.py diff --git a/kikotools/tools/model_resolution_picker/families.json b/kikotools/tools/model_resolution_picker/families.json new file mode 100644 index 0000000..6fb9760 --- /dev/null +++ b/kikotools/tools/model_resolution_picker/families.json @@ -0,0 +1,1848 @@ +{ + "version": 1, + "chips": [ + "1:1", + "4:5", + "5:4", + "3:4", + "4:3", + "2:3", + "3:2", + "9:16", + "16:9", + "21:9", + "9:21" + ], + "mirror": { + "1:1": "1:1", + "4:5": "5:4", + "5:4": "4:5", + "3:4": "4:3", + "4:3": "3:4", + "2:3": "3:2", + "3:2": "2:3", + "9:16": "16:9", + "16:9": "9:16", + "21:9": "9:21", + "9:21": "21:9" + }, + "default": "SDXL", + "families": [ + { + "label": "SD 1.5", + "aliases": [], + "models": "SD 1.5 and SD 1.5 finetunes", + "group": "image", + "vaeRatio": 8, + "step": 64, + "megapixels": [ + 0.25, + 0.5 + ], + "default": [ + 512, + 512 + ], + "sizes": { + "1:1": [ + [ + 512, + 512 + ], + [ + 704, + 704 + ], + [ + 768, + 768 + ] + ], + "4:5": [ + [ + 448, + 576 + ], + [ + 640, + 832 + ] + ], + "5:4": [ + [ + 576, + 448 + ], + [ + 832, + 640 + ] + ], + "3:4": [ + [ + 576, + 768 + ], + [ + 768, + 1024 + ] + ], + "4:3": [ + [ + 768, + 576 + ], + [ + 1024, + 768 + ] + ], + "2:3": [ + [ + 448, + 640 + ], + [ + 512, + 768 + ], + [ + 576, + 896 + ] + ], + "3:2": [ + [ + 640, + 448 + ], + [ + 768, + 512 + ], + [ + 896, + 576 + ] + ], + "9:16": [ + [ + 384, + 704 + ], + [ + 512, + 960 + ] + ], + "16:9": [ + [ + 704, + 384 + ], + [ + 960, + 512 + ] + ], + "21:9": [ + [ + 768, + 320 + ], + [ + 1088, + 448 + ] + ], + "9:21": [ + [ + 320, + 768 + ], + [ + 448, + 1088 + ] + ] + }, + "notes": "generated at tiers 0.25, 0.5 MP, step 64, plus the 512/768 native sizes" + }, + { + "label": "SDXL", + "aliases": [], + "models": "SDXL, Pony and other SDXL finetunes", + "group": "image", + "vaeRatio": 8, + "step": 64, + "megapixels": [ + 1.0 + ], + "default": [ + 1024, + 1024 + ], + "sizes": { + "1:1": [ + [ + 1024, + 1024 + ] + ], + "4:5": [ + [ + 832, + 1024 + ] + ], + "5:4": [ + [ + 1024, + 832 + ] + ], + "3:4": [ + [ + 896, + 1152 + ] + ], + "4:3": [ + [ + 1152, + 896 + ] + ], + "2:3": [ + [ + 832, + 1216 + ] + ], + "3:2": [ + [ + 1216, + 832 + ] + ], + "9:16": [ + [ + 768, + 1344 + ] + ], + "16:9": [ + [ + 1344, + 768 + ] + ], + "21:9": [ + [ + 1536, + 640 + ] + ], + "9:21": [ + [ + 640, + 1536 + ] + ] + }, + "notes": "SDXL model card / community bucket list" + }, + { + "label": "Illustrious XL", + "aliases": [], + "models": "Illustrious XL v1.0 and later, NoobAI, WAI and other Illustrious finetunes", + "group": "image", + "vaeRatio": 8, + "step": 32, + "megapixels": [ + 1.0, + 1.5, + 2.25 + ], + "default": [ + 1024, + 1024 + ], + "sizes": { + "1:1": [ + [ + 1024, + 1024 + ], + [ + 1536, + 1536 + ] + ], + "4:5": [ + [ + 832, + 1024 + ], + [ + 1248, + 1536 + ] + ], + "5:4": [ + [ + 1024, + 832 + ], + [ + 1536, + 1248 + ] + ], + "3:4": [ + [ + 896, + 1152 + ], + [ + 1344, + 1792 + ] + ], + "4:3": [ + [ + 1152, + 896 + ], + [ + 1792, + 1344 + ] + ], + "2:3": [ + [ + 832, + 1216 + ], + [ + 1024, + 1536 + ], + [ + 1248, + 1824 + ] + ], + "3:2": [ + [ + 1216, + 832 + ], + [ + 1536, + 1024 + ], + [ + 1824, + 1248 + ] + ], + "9:16": [ + [ + 768, + 1344 + ], + [ + 1152, + 2048 + ] + ], + "16:9": [ + [ + 1344, + 768 + ], + [ + 2048, + 1152 + ] + ], + "21:9": [ + [ + 1536, + 640 + ] + ], + "9:21": [ + [ + 640, + 1536 + ] + ] + }, + "notes": "Illustrious XL v1.0 release notes resolution list (SDXL list plus larger buckets)" + }, + { + "label": "Anima", + "aliases": [], + "models": "Anima", + "group": "image", + "vaeRatio": 8, + "step": 16, + "megapixels": [ + 0.5, + 1.0, + 1.5 + ], + "default": [ + 1024, + 1024 + ], + "sizes": { + "1:1": [ + [ + 720, + 720 + ], + [ + 1024, + 1024 + ], + [ + 1248, + 1248 + ] + ], + "4:5": [ + [ + 640, + 816 + ], + [ + 912, + 1152 + ], + [ + 1120, + 1408 + ] + ], + "5:4": [ + [ + 816, + 640 + ], + [ + 1152, + 912 + ], + [ + 1408, + 1120 + ] + ], + "3:4": [ + [ + 624, + 832 + ], + [ + 880, + 1184 + ], + [ + 1088, + 1456 + ] + ], + "4:3": [ + [ + 832, + 624 + ], + [ + 1184, + 880 + ], + [ + 1456, + 1088 + ] + ], + "2:3": [ + [ + 592, + 880 + ], + [ + 832, + 1248 + ], + [ + 1024, + 1536 + ] + ], + "3:2": [ + [ + 880, + 592 + ], + [ + 1248, + 832 + ], + [ + 1536, + 1024 + ] + ], + "9:16": [ + [ + 544, + 960 + ], + [ + 768, + 1360 + ] + ], + "16:9": [ + [ + 960, + 544 + ], + [ + 1360, + 768 + ] + ], + "21:9": [], + "9:21": [] + }, + "notes": "generated at tiers 0.5, 1.0, 1.5 MP, step 16, sides 512-1536" + }, + { + "label": "Flux.1", + "aliases": [], + "models": "Flux.1 dev / schnell / Krea, Chroma, HiDream I1, SD3.5, Lumina 2", + "group": "image", + "vaeRatio": 8, + "step": 16, + "megapixels": [ + 0.5, + 1.0, + 1.5, + 2.0 + ], + "default": [ + 1024, + 1024 + ], + "sizes": { + "1:1": [ + [ + 720, + 720 + ], + [ + 1024, + 1024 + ], + [ + 1248, + 1248 + ], + [ + 1456, + 1456 + ] + ], + "4:5": [ + [ + 640, + 816 + ], + [ + 912, + 1152 + ], + [ + 1120, + 1408 + ], + [ + 1296, + 1616 + ] + ], + "5:4": [ + [ + 816, + 640 + ], + [ + 1152, + 912 + ], + [ + 1408, + 1120 + ], + [ + 1616, + 1296 + ] + ], + "3:4": [ + [ + 624, + 832 + ], + [ + 880, + 1184 + ], + [ + 1088, + 1456 + ], + [ + 1248, + 1680 + ] + ], + "4:3": [ + [ + 832, + 624 + ], + [ + 1184, + 880 + ], + [ + 1456, + 1088 + ], + [ + 1680, + 1248 + ] + ], + "2:3": [ + [ + 592, + 880 + ], + [ + 832, + 1248 + ], + [ + 1024, + 1536 + ], + [ + 1184, + 1776 + ] + ], + "3:2": [ + [ + 880, + 592 + ], + [ + 1248, + 832 + ], + [ + 1536, + 1024 + ], + [ + 1776, + 1184 + ] + ], + "9:16": [ + [ + 544, + 960 + ], + [ + 768, + 1360 + ], + [ + 944, + 1680 + ], + [ + 1088, + 1920 + ] + ], + "16:9": [ + [ + 960, + 544 + ], + [ + 1360, + 768 + ], + [ + 1680, + 944 + ], + [ + 1920, + 1088 + ] + ], + "21:9": [ + [ + 1104, + 480 + ], + [ + 1568, + 672 + ], + [ + 1920, + 816 + ], + [ + 2208, + 944 + ] + ], + "9:21": [ + [ + 480, + 1104 + ], + [ + 672, + 1568 + ], + [ + 816, + 1920 + ], + [ + 944, + 2208 + ] + ] + }, + "notes": "generated at tiers 0.5, 1.0, 1.5, 2.0 MP, step 16; 16:9 2.0 MP set to 1920x1088" + }, + { + "label": "Z-Image", + "aliases": [], + "models": "Z-Image Turbo and Z-Image Base", + "group": "image", + "vaeRatio": 8, + "step": 16, + "megapixels": [ + 1.0, + 1.6 + ], + "default": [ + 1024, + 1024 + ], + "sizes": { + "1:1": [ + [ + 1024, + 1024 + ], + [ + 1280, + 1280 + ] + ], + "4:5": [], + "5:4": [], + "3:4": [ + [ + 864, + 1152 + ], + [ + 896, + 1152 + ], + [ + 1120, + 1440 + ], + [ + 1104, + 1472 + ] + ], + "4:3": [ + [ + 1152, + 864 + ], + [ + 1152, + 896 + ], + [ + 1440, + 1120 + ], + [ + 1472, + 1104 + ] + ], + "2:3": [ + [ + 832, + 1248 + ], + [ + 1024, + 1536 + ] + ], + "3:2": [ + [ + 1248, + 832 + ], + [ + 1536, + 1024 + ] + ], + "9:16": [ + [ + 720, + 1280 + ], + [ + 896, + 1600 + ] + ], + "16:9": [ + [ + 1280, + 720 + ], + [ + 1600, + 896 + ] + ], + "21:9": [ + [ + 1344, + 576 + ], + [ + 1680, + 720 + ] + ], + "9:21": [ + [ + 576, + 1344 + ], + [ + 720, + 1680 + ] + ] + }, + "notes": "Z-Image model card resolution list" + }, + { + "label": "Qwen-Image", + "aliases": [], + "models": "Qwen-Image and Qwen-Image 2512", + "group": "image", + "vaeRatio": 8, + "step": 16, + "megapixels": [ + 1.76 + ], + "default": [ + 1328, + 1328 + ], + "sizes": { + "1:1": [ + [ + 1328, + 1328 + ] + ], + "4:5": [], + "5:4": [], + "3:4": [ + [ + 1104, + 1472 + ] + ], + "4:3": [ + [ + 1472, + 1104 + ] + ], + "2:3": [ + [ + 1056, + 1584 + ] + ], + "3:2": [ + [ + 1584, + 1056 + ] + ], + "9:16": [ + [ + 928, + 1664 + ] + ], + "16:9": [ + [ + 1664, + 928 + ] + ], + "21:9": [], + "9:21": [] + }, + "notes": "Qwen-Image model card resolution list. 4:3 is 1472x1104, the project maintainer's correction of the README's 1140 (QwenLM/Qwen-Image issue #7, \"resolutions used in our final training stage\")." + }, + { + "label": "Flux.2 / Klein / MOSS", + "aliases": [], + "models": "Flux.2 dev, Flux.2 Klein, Flux.3 Klein, MOSS-Image, Ideogram 4", + "group": "image", + "vaeRatio": 16, + "step": 16, + "megapixels": [ + 1.0, + 2.0, + 4.0 + ], + "default": [ + 1024, + 1024 + ], + "sizes": { + "1:1": [ + [ + 1024, + 1024 + ], + [ + 1456, + 1456 + ], + [ + 2048, + 2048 + ] + ], + "4:5": [ + [ + 912, + 1152 + ], + [ + 1296, + 1616 + ], + [ + 1824, + 2288 + ] + ], + "5:4": [ + [ + 1152, + 912 + ], + [ + 1616, + 1296 + ], + [ + 2288, + 1824 + ] + ], + "3:4": [ + [ + 880, + 1184 + ], + [ + 1248, + 1680 + ], + [ + 1776, + 2368 + ] + ], + "4:3": [ + [ + 1184, + 880 + ], + [ + 1680, + 1248 + ], + [ + 2368, + 1776 + ] + ], + "2:3": [ + [ + 832, + 1248 + ], + [ + 1184, + 1776 + ], + [ + 1680, + 2512 + ] + ], + "3:2": [ + [ + 1248, + 832 + ], + [ + 1776, + 1184 + ], + [ + 2512, + 1680 + ] + ], + "9:16": [ + [ + 768, + 1360 + ], + [ + 1088, + 1920 + ], + [ + 1536, + 2736 + ] + ], + "16:9": [ + [ + 1360, + 768 + ], + [ + 1920, + 1088 + ], + [ + 2736, + 1536 + ] + ], + "21:9": [ + [ + 1568, + 672 + ], + [ + 2208, + 944 + ], + [ + 3136, + 1344 + ] + ], + "9:21": [ + [ + 672, + 1568 + ], + [ + 944, + 2208 + ], + [ + 1344, + 3136 + ] + ] + }, + "notes": "generated at tiers 1.0, 2.0, 4.0 MP, step 16; 16:9 2.0 MP set to 1920x1088" + }, + { + "label": "Qwen-Image 2.1", + "aliases": [], + "models": "Qwen-Image 2.1 and 2.1 Turbo", + "group": "image", + "vaeRatio": 16, + "step": 32, + "megapixels": [ + 1.0, + 4.0 + ], + "default": [ + 1024, + 1024 + ], + "sizes": { + "1:1": [ + [ + 1024, + 1024 + ], + [ + 2048, + 2048 + ] + ], + "4:5": [ + [ + 928, + 1152 + ] + ], + "5:4": [ + [ + 1152, + 928 + ] + ], + "3:4": [ + [ + 896, + 1184 + ], + [ + 1792, + 2400 + ] + ], + "4:3": [ + [ + 1184, + 896 + ], + [ + 2400, + 1792 + ] + ], + "2:3": [ + [ + 832, + 1248 + ], + [ + 1696, + 2528 + ] + ], + "3:2": [ + [ + 1248, + 832 + ], + [ + 2528, + 1696 + ] + ], + "9:16": [ + [ + 768, + 1376 + ], + [ + 1536, + 2752 + ] + ], + "16:9": [ + [ + 1376, + 768 + ], + [ + 2752, + 1536 + ] + ], + "21:9": [ + [ + 1568, + 672 + ] + ], + "9:21": [ + [ + 672, + 1568 + ] + ] + }, + "notes": "Qwen-Image 2.1 model card list, plus generated at tier 1.0 MP, step 32" + }, + { + "label": "HunyuanImage 2.1", + "aliases": [], + "models": "HunyuanImage 2.1", + "group": "image", + "vaeRatio": 32, + "step": 32, + "megapixels": [ + 4.0 + ], + "default": [ + 2048, + 2048 + ], + "sizes": { + "1:1": [ + [ + 2048, + 2048 + ] + ], + "4:5": [], + "5:4": [], + "3:4": [ + [ + 1792, + 2304 + ] + ], + "4:3": [ + [ + 2304, + 1792 + ] + ], + "2:3": [ + [ + 1664, + 2496 + ] + ], + "3:2": [ + [ + 2496, + 1664 + ] + ], + "9:16": [ + [ + 1536, + 2560 + ] + ], + "16:9": [ + [ + 2560, + 1536 + ] + ], + "21:9": [], + "9:21": [] + }, + "notes": "HunyuanImage 2.1 model card resolution list" + }, + { + "label": "Wan 2.2 (keyframes)", + "aliases": [], + "models": "Wan 2.2 keyframes: frame sizes for first/last-frame generation; the latent output is an image latent", + "group": "video", + "vaeRatio": 16, + "step": 16, + "megapixels": [ + 0.4, + 0.9 + ], + "default": [ + 1280, + 720 + ], + "sizes": { + "1:1": [ + [ + 640, + 640 + ], + [ + 960, + 960 + ], + [ + 976, + 976 + ] + ], + "4:5": [ + [ + 576, + 720 + ], + [ + 864, + 1088 + ] + ], + "5:4": [ + [ + 720, + 576 + ], + [ + 1088, + 864 + ] + ], + "3:4": [ + [ + 560, + 752 + ], + [ + 848, + 1120 + ] + ], + "4:3": [ + [ + 752, + 560 + ], + [ + 1120, + 848 + ] + ], + "2:3": [ + [ + 528, + 800 + ], + [ + 800, + 1184 + ] + ], + "3:2": [ + [ + 800, + 528 + ], + [ + 1184, + 800 + ] + ], + "9:16": [ + [ + 480, + 832 + ], + [ + 480, + 864 + ], + [ + 704, + 1280 + ], + [ + 720, + 1280 + ], + [ + 736, + 1296 + ] + ], + "16:9": [ + [ + 832, + 480 + ], + [ + 864, + 480 + ], + [ + 1280, + 704 + ], + [ + 1280, + 720 + ], + [ + 1296, + 736 + ] + ], + "21:9": [ + [ + 992, + 416 + ], + [ + 1488, + 640 + ] + ], + "9:21": [ + [ + 416, + 992 + ], + [ + 640, + 1488 + ] + ] + }, + "notes": "Wan 2.2 official sizes plus generated at tiers 0.4, 0.9 MP, step 16" + }, + { + "label": "LTX-2 (keyframes)", + "aliases": [], + "models": "LTX-2 keyframes: frame sizes for first/last-frame generation; the latent output is an image latent", + "group": "video", + "vaeRatio": 32, + "step": 32, + "megapixels": [ + 0.4, + 0.9 + ], + "default": [ + 768, + 512 + ], + "sizes": { + "1:1": [ + [ + 640, + 640 + ], + [ + 960, + 960 + ] + ], + "4:5": [ + [ + 864, + 1088 + ] + ], + "5:4": [ + [ + 1088, + 864 + ] + ], + "3:4": [ + [ + 576, + 736 + ], + [ + 832, + 1120 + ] + ], + "4:3": [ + [ + 736, + 576 + ], + [ + 1120, + 832 + ] + ], + "2:3": [ + [ + 512, + 768 + ], + [ + 544, + 800 + ], + [ + 800, + 1184 + ] + ], + "3:2": [ + [ + 768, + 512 + ], + [ + 800, + 544 + ], + [ + 1184, + 800 + ] + ], + "9:16": [ + [ + 480, + 864 + ], + [ + 544, + 960 + ], + [ + 736, + 1280 + ] + ], + "16:9": [ + [ + 864, + 480 + ], + [ + 960, + 544 + ], + [ + 1280, + 736 + ] + ], + "21:9": [ + [ + 992, + 416 + ], + [ + 1472, + 640 + ] + ], + "9:21": [ + [ + 416, + 992 + ], + [ + 640, + 1472 + ] + ] + }, + "notes": "LTX-2 official sizes plus generated at tiers 0.4, 0.9 MP, step 32" + }, + { + "label": "HunyuanVideo 1.5 (keyframes)", + "aliases": [], + "models": "HunyuanVideo 1.5 keyframes: frame sizes for first/last-frame generation; the latent output is an image latent", + "group": "video", + "vaeRatio": 16, + "step": 16, + "megapixels": [ + 0.4, + 0.9 + ], + "default": [ + 1280, + 720 + ], + "sizes": { + "1:1": [ + [ + 640, + 640 + ], + [ + 976, + 976 + ] + ], + "4:5": [ + [ + 576, + 720 + ], + [ + 864, + 1088 + ] + ], + "5:4": [ + [ + 720, + 576 + ], + [ + 1088, + 864 + ] + ], + "3:4": [ + [ + 560, + 752 + ], + [ + 848, + 1120 + ] + ], + "4:3": [ + [ + 752, + 560 + ], + [ + 1120, + 848 + ] + ], + "2:3": [ + [ + 528, + 800 + ], + [ + 800, + 1184 + ] + ], + "3:2": [ + [ + 800, + 528 + ], + [ + 1184, + 800 + ] + ], + "9:16": [ + [ + 480, + 848 + ], + [ + 480, + 864 + ], + [ + 720, + 1280 + ], + [ + 736, + 1296 + ] + ], + "16:9": [ + [ + 848, + 480 + ], + [ + 864, + 480 + ], + [ + 1280, + 720 + ], + [ + 1296, + 736 + ] + ], + "21:9": [ + [ + 992, + 416 + ], + [ + 1488, + 640 + ] + ], + "9:21": [ + [ + 416, + 992 + ], + [ + 640, + 1488 + ] + ] + }, + "notes": "HunyuanVideo 1.5 official sizes plus generated at tiers 0.4, 0.9 MP, step 16" + }, + { + "label": "MiniMax H3 (keyframes)", + "aliases": [], + "models": "MiniMax H3 keyframes: frame sizes for first/last-frame generation; the latent output is an image latent", + "group": "video", + "vaeRatio": 16, + "step": 32, + "megapixels": [ + 0.4, + 1.0 + ], + "default": [ + 864, + 480 + ], + "sizes": { + "1:1": [ + [ + 640, + 640 + ], + [ + 1024, + 1024 + ] + ], + "4:5": [ + [ + 928, + 1152 + ] + ], + "5:4": [ + [ + 1152, + 928 + ] + ], + "3:4": [ + [ + 576, + 736 + ], + [ + 896, + 1184 + ] + ], + "4:3": [ + [ + 736, + 576 + ], + [ + 1184, + 896 + ] + ], + "2:3": [ + [ + 544, + 800 + ], + [ + 832, + 1248 + ] + ], + "3:2": [ + [ + 800, + 544 + ], + [ + 1248, + 832 + ] + ], + "9:16": [ + [ + 480, + 864 + ], + [ + 768, + 1376 + ] + ], + "16:9": [ + [ + 864, + 480 + ], + [ + 1376, + 768 + ] + ], + "21:9": [ + [ + 992, + 416 + ], + [ + 1568, + 672 + ] + ], + "9:21": [ + [ + 416, + 992 + ], + [ + 672, + 1568 + ] + ] + }, + "notes": "generated at tiers 0.4, 1.0 MP, step 32, plus the 864x480 default" + } + ] +} diff --git a/kikotools/tools/model_resolution_picker/families.py b/kikotools/tools/model_resolution_picker/families.py index 5fbab43..66d752e 100644 --- a/kikotools/tools/model_resolution_picker/families.py +++ b/kikotools/tools/model_resolution_picker/families.py @@ -1,819 +1,50 @@ """Model family table for the Model Resolution Picker node. -Plain data: sizes are literals, authored once with ``logic.generate_size`` and -official lists. Every list is sorted by pixel count and mirrored exactly. +The data lives in ``families.json`` next to this module; it is loaded and +validated once at import (see ``table.py``). A shipped file that fails +validation is a startup error. """ -from typing import Any, Dict, List, NamedTuple, Optional, Tuple +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple -CHIPS: Tuple[str, ...] = ( - "1:1", - "4:5", - "5:4", - "3:4", - "4:3", - "2:3", - "3:2", - "9:16", - "16:9", - "21:9", - "9:21", -) -MIRROR: Dict[str, str] = { - "1:1": "1:1", - "4:5": "5:4", - "5:4": "4:5", - "3:4": "4:3", - "4:3": "3:4", - "2:3": "3:2", - "3:2": "2:3", - "9:16": "16:9", - "16:9": "9:16", - "21:9": "9:21", - "9:21": "21:9", -} -Size = Tuple[int, int] - - -class Family(NamedTuple): - """One model family: grid constraints, size tiers and the chip size lists.""" - - label: str - aliases: Tuple[str, ...] - models: str - group: str - vae_ratio: int - step: int - megapixels: Tuple[float, ...] - default: Size - sizes: Dict[str, Tuple[Size, ...]] - - -FAMILIES: Tuple[Family, ...] = ( - # generated at tiers 0.25, 0.5 MP, step 64, plus the 512/768 native sizes - Family( - label="SD 1.5", - aliases=(), - models="SD 1.5 and SD 1.5 finetunes", - group="image", - vae_ratio=8, - step=64, - megapixels=( - 0.25, - 0.5, - ), - default=(512, 512), - sizes={ - "1:1": ( - (512, 512), - (704, 704), - (768, 768), - ), - "4:5": ( - (448, 576), - (640, 832), - ), - "5:4": ( - (576, 448), - (832, 640), - ), - "3:4": ( - (576, 768), - (768, 1024), - ), - "4:3": ( - (768, 576), - (1024, 768), - ), - "2:3": ( - (448, 640), - (512, 768), - (576, 896), - ), - "3:2": ( - (640, 448), - (768, 512), - (896, 576), - ), - "9:16": ( - (384, 704), - (512, 960), - ), - "16:9": ( - (704, 384), - (960, 512), - ), - "21:9": ( - (768, 320), - (1088, 448), - ), - "9:21": ( - (320, 768), - (448, 1088), - ), - }, - ), - # SDXL model card / community bucket list - Family( - label="SDXL", - aliases=(), - models="SDXL, Pony and other SDXL finetunes", - group="image", - vae_ratio=8, - step=64, - megapixels=(1.0,), - default=(1024, 1024), - sizes={ - "1:1": ((1024, 1024),), - "4:5": ((832, 1024),), - "5:4": ((1024, 832),), - "3:4": ((896, 1152),), - "4:3": ((1152, 896),), - "2:3": ((832, 1216),), - "3:2": ((1216, 832),), - "9:16": ((768, 1344),), - "16:9": ((1344, 768),), - "21:9": ((1536, 640),), - "9:21": ((640, 1536),), - }, - ), - # Illustrious XL v1.0 release notes resolution list (SDXL list plus larger buckets) - Family( - label="Illustrious XL", - aliases=(), - models="Illustrious XL v1.0 and later, NoobAI, WAI and other Illustrious finetunes", - group="image", - vae_ratio=8, - step=32, - megapixels=( - 1.0, - 1.5, - 2.25, - ), - default=(1024, 1024), - sizes={ - "1:1": ( - (1024, 1024), - (1536, 1536), - ), - "4:5": ( - (832, 1024), - (1248, 1536), - ), - "5:4": ( - (1024, 832), - (1536, 1248), - ), - "3:4": ( - (896, 1152), - (1344, 1792), - ), - "4:3": ( - (1152, 896), - (1792, 1344), - ), - "2:3": ( - (832, 1216), - (1024, 1536), - (1248, 1824), - ), - "3:2": ( - (1216, 832), - (1536, 1024), - (1824, 1248), - ), - "9:16": ( - (768, 1344), - (1152, 2048), - ), - "16:9": ( - (1344, 768), - (2048, 1152), - ), - "21:9": ((1536, 640),), - "9:21": ((640, 1536),), - }, - ), - # generated at tiers 0.5, 1.0, 1.5 MP, step 16, sides 512-1536 - Family( - label="Anima", - aliases=(), - models="Anima", - group="image", - vae_ratio=8, - step=16, - megapixels=( - 0.5, - 1.0, - 1.5, - ), - default=(1024, 1024), - sizes={ - "1:1": ( - (720, 720), - (1024, 1024), - (1248, 1248), - ), - "4:5": ( - (640, 816), - (912, 1152), - (1120, 1408), - ), - "5:4": ( - (816, 640), - (1152, 912), - (1408, 1120), - ), - "3:4": ( - (624, 832), - (880, 1184), - (1088, 1456), - ), - "4:3": ( - (832, 624), - (1184, 880), - (1456, 1088), - ), - "2:3": ( - (592, 880), - (832, 1248), - (1024, 1536), - ), - "3:2": ( - (880, 592), - (1248, 832), - (1536, 1024), - ), - "9:16": ( - (544, 960), - (768, 1360), - ), - "16:9": ( - (960, 544), - (1360, 768), - ), - "21:9": (), - "9:21": (), - }, - ), - # generated at tiers 0.5, 1.0, 1.5, 2.0 MP, step 16; 16:9 2.0 MP set to 1920x1088 - Family( - label="Flux.1", - aliases=(), - models="Flux.1 dev / schnell / Krea, Chroma, HiDream I1, SD3.5, Lumina 2", - group="image", - vae_ratio=8, - step=16, - megapixels=( - 0.5, - 1.0, - 1.5, - 2.0, - ), - default=(1024, 1024), - sizes={ - "1:1": ( - (720, 720), - (1024, 1024), - (1248, 1248), - (1456, 1456), - ), - "4:5": ( - (640, 816), - (912, 1152), - (1120, 1408), - (1296, 1616), - ), - "5:4": ( - (816, 640), - (1152, 912), - (1408, 1120), - (1616, 1296), - ), - "3:4": ( - (624, 832), - (880, 1184), - (1088, 1456), - (1248, 1680), - ), - "4:3": ( - (832, 624), - (1184, 880), - (1456, 1088), - (1680, 1248), - ), - "2:3": ( - (592, 880), - (832, 1248), - (1024, 1536), - (1184, 1776), - ), - "3:2": ( - (880, 592), - (1248, 832), - (1536, 1024), - (1776, 1184), - ), - "9:16": ( - (544, 960), - (768, 1360), - (944, 1680), - (1088, 1920), - ), - "16:9": ( - (960, 544), - (1360, 768), - (1680, 944), - (1920, 1088), - ), - "21:9": ( - (1104, 480), - (1568, 672), - (1920, 816), - (2208, 944), - ), - "9:21": ( - (480, 1104), - (672, 1568), - (816, 1920), - (944, 2208), - ), - }, - ), - # Z-Image model card resolution list - Family( - label="Z-Image", - aliases=(), - models="Z-Image Turbo and Z-Image Base", - group="image", - vae_ratio=8, - step=16, - megapixels=( - 1.0, - 1.6, - ), - default=(1024, 1024), - sizes={ - "1:1": ( - (1024, 1024), - (1280, 1280), - ), - "4:5": (), - "5:4": (), - "3:4": ( - (864, 1152), - (896, 1152), - (1120, 1440), - (1104, 1472), - ), - "4:3": ( - (1152, 864), - (1152, 896), - (1440, 1120), - (1472, 1104), - ), - "2:3": ( - (832, 1248), - (1024, 1536), - ), - "3:2": ( - (1248, 832), - (1536, 1024), - ), - "9:16": ( - (720, 1280), - (896, 1600), - ), - "16:9": ( - (1280, 720), - (1600, 896), - ), - "21:9": ( - (1344, 576), - (1680, 720), - ), - "9:21": ( - (576, 1344), - (720, 1680), - ), - }, - ), - # Qwen-Image model card resolution list. 4:3 is 1472x1104, the project - # maintainer's correction of the README's 1140 (QwenLM/Qwen-Image issue #7, - # "resolutions used in our final training stage"). - Family( - label="Qwen-Image", - aliases=(), - models="Qwen-Image and Qwen-Image 2512", - group="image", - vae_ratio=8, - step=16, - megapixels=(1.76,), - default=(1328, 1328), - sizes={ - "1:1": ((1328, 1328),), - "4:5": (), - "5:4": (), - "3:4": ((1104, 1472),), - "4:3": ((1472, 1104),), - "2:3": ((1056, 1584),), - "3:2": ((1584, 1056),), - "9:16": ((928, 1664),), - "16:9": ((1664, 928),), - "21:9": (), - "9:21": (), - }, - ), - # generated at tiers 1.0, 2.0, 4.0 MP, step 16; 16:9 2.0 MP set to 1920x1088 - Family( - label="Flux.2 / Klein / MOSS", - aliases=(), - models="Flux.2 dev, Flux.2 Klein, Flux.3 Klein, MOSS-Image, Ideogram 4", - group="image", - vae_ratio=16, - step=16, - megapixels=( - 1.0, - 2.0, - 4.0, - ), - default=(1024, 1024), - sizes={ - "1:1": ( - (1024, 1024), - (1456, 1456), - (2048, 2048), - ), - "4:5": ( - (912, 1152), - (1296, 1616), - (1824, 2288), - ), - "5:4": ( - (1152, 912), - (1616, 1296), - (2288, 1824), - ), - "3:4": ( - (880, 1184), - (1248, 1680), - (1776, 2368), - ), - "4:3": ( - (1184, 880), - (1680, 1248), - (2368, 1776), - ), - "2:3": ( - (832, 1248), - (1184, 1776), - (1680, 2512), - ), - "3:2": ( - (1248, 832), - (1776, 1184), - (2512, 1680), - ), - "9:16": ( - (768, 1360), - (1088, 1920), - (1536, 2736), - ), - "16:9": ( - (1360, 768), - (1920, 1088), - (2736, 1536), - ), - "21:9": ( - (1568, 672), - (2208, 944), - (3136, 1344), - ), - "9:21": ( - (672, 1568), - (944, 2208), - (1344, 3136), - ), - }, - ), - # Qwen-Image 2.1 model card list, plus generated at tier 1.0 MP, step 32 - Family( - label="Qwen-Image 2.1", - aliases=(), - models="Qwen-Image 2.1 and 2.1 Turbo", - group="image", - vae_ratio=16, - step=32, - megapixels=( - 1.0, - 4.0, - ), - default=(1024, 1024), - sizes={ - "1:1": ( - (1024, 1024), - (2048, 2048), - ), - "4:5": ((928, 1152),), - "5:4": ((1152, 928),), - "3:4": ( - (896, 1184), - (1792, 2400), - ), - "4:3": ( - (1184, 896), - (2400, 1792), - ), - "2:3": ( - (832, 1248), - (1696, 2528), - ), - "3:2": ( - (1248, 832), - (2528, 1696), - ), - "9:16": ( - (768, 1376), - (1536, 2752), - ), - "16:9": ( - (1376, 768), - (2752, 1536), - ), - "21:9": ((1568, 672),), - "9:21": ((672, 1568),), - }, - ), - # HunyuanImage 2.1 model card resolution list - Family( - label="HunyuanImage 2.1", - aliases=(), - models="HunyuanImage 2.1", - group="image", - vae_ratio=32, - step=32, - megapixels=(4.0,), - default=(2048, 2048), - sizes={ - "1:1": ((2048, 2048),), - "4:5": (), - "5:4": (), - "3:4": ((1792, 2304),), - "4:3": ((2304, 1792),), - "2:3": ((1664, 2496),), - "3:2": ((2496, 1664),), - "9:16": ((1536, 2560),), - "16:9": ((2560, 1536),), - "21:9": (), - "9:21": (), - }, - ), - # Wan 2.2 official sizes plus generated at tiers 0.4, 0.9 MP, step 16 - Family( - label="Wan 2.2 (keyframes)", - aliases=(), - models="Wan 2.2 keyframes: frame sizes for first/last-frame generation; the latent output is an image latent", - group="video", - vae_ratio=16, - step=16, - megapixels=( - 0.4, - 0.9, - ), - default=(1280, 720), - sizes={ - "1:1": ( - (640, 640), - (960, 960), - (976, 976), - ), - "4:5": ( - (576, 720), - (864, 1088), - ), - "5:4": ( - (720, 576), - (1088, 864), - ), - "3:4": ( - (560, 752), - (848, 1120), - ), - "4:3": ( - (752, 560), - (1120, 848), - ), - "2:3": ( - (528, 800), - (800, 1184), - ), - "3:2": ( - (800, 528), - (1184, 800), - ), - "9:16": ( - (480, 832), - (480, 864), - (704, 1280), - (720, 1280), - (736, 1296), - ), - "16:9": ( - (832, 480), - (864, 480), - (1280, 704), - (1280, 720), - (1296, 736), - ), - "21:9": ( - (992, 416), - (1488, 640), - ), - "9:21": ( - (416, 992), - (640, 1488), - ), - }, - ), - # LTX-2 official sizes plus generated at tiers 0.4, 0.9 MP, step 32 - Family( - label="LTX-2 (keyframes)", - aliases=(), - models="LTX-2 keyframes: frame sizes for first/last-frame generation; the latent output is an image latent", - group="video", - vae_ratio=32, - step=32, - megapixels=( - 0.4, - 0.9, - ), - default=(768, 512), - sizes={ - "1:1": ( - (640, 640), - (960, 960), - ), - "4:5": ((864, 1088),), - "5:4": ((1088, 864),), - "3:4": ( - (576, 736), - (832, 1120), - ), - "4:3": ( - (736, 576), - (1120, 832), - ), - "2:3": ( - (512, 768), - (544, 800), - (800, 1184), - ), - "3:2": ( - (768, 512), - (800, 544), - (1184, 800), - ), - "9:16": ( - (480, 864), - (544, 960), - (736, 1280), - ), - "16:9": ( - (864, 480), - (960, 544), - (1280, 736), - ), - "21:9": ( - (992, 416), - (1472, 640), - ), - "9:21": ( - (416, 992), - (640, 1472), - ), - }, - ), - # HunyuanVideo 1.5 official sizes plus generated at tiers 0.4, 0.9 MP, step 16 - Family( - label="HunyuanVideo 1.5 (keyframes)", - aliases=(), - models="HunyuanVideo 1.5 keyframes: frame sizes for first/last-frame generation; the latent output is an image latent", - group="video", - vae_ratio=16, - step=16, - megapixels=( - 0.4, - 0.9, - ), - default=(1280, 720), - sizes={ - "1:1": ( - (640, 640), - (976, 976), - ), - "4:5": ( - (576, 720), - (864, 1088), - ), - "5:4": ( - (720, 576), - (1088, 864), - ), - "3:4": ( - (560, 752), - (848, 1120), - ), - "4:3": ( - (752, 560), - (1120, 848), - ), - "2:3": ( - (528, 800), - (800, 1184), - ), - "3:2": ( - (800, 528), - (1184, 800), - ), - "9:16": ( - (480, 848), - (480, 864), - (720, 1280), - (736, 1296), - ), - "16:9": ( - (848, 480), - (864, 480), - (1280, 720), - (1296, 736), - ), - "21:9": ( - (992, 416), - (1488, 640), - ), - "9:21": ( - (416, 992), - (640, 1488), - ), - }, - ), - # generated at tiers 0.4, 1.0 MP, step 32, plus the 864x480 default - Family( - label="MiniMax H3 (keyframes)", - aliases=(), - models="MiniMax H3 keyframes: frame sizes for first/last-frame generation; the latent output is an image latent", - group="video", - vae_ratio=16, - step=32, - megapixels=( - 0.4, - 1.0, - ), - default=(864, 480), - sizes={ - "1:1": ( - (640, 640), - (1024, 1024), - ), - "4:5": ((928, 1152),), - "5:4": ((1152, 928),), - "3:4": ( - (576, 736), - (896, 1184), - ), - "4:3": ( - (736, 576), - (1184, 896), - ), - "2:3": ( - (544, 800), - (832, 1248), - ), - "3:2": ( - (800, 544), - (1248, 832), - ), - "9:16": ( - (480, 864), - (768, 1376), - ), - "16:9": ( - (864, 480), - (1376, 768), - ), - "21:9": ( - (992, 416), - (1568, 672), - ), - "9:21": ( - (416, 992), - (672, 1568), - ), - }, - ), +from .table import ( + CHIPS, + MIRROR, + Family, + Size, + build_families, + load_table, + validate_table, ) -DEFAULT_FAMILY = "SDXL" +__all__ = [ + "CHIPS", + "MIRROR", + "Family", + "Size", + "FAMILIES", + "DEFAULT_FAMILY", + "family_labels", + "resolve_family", + "families_payload", + "load_shipped_families", +] + +TABLE_PATH = Path(__file__).with_name("families.json") + + +def load_shipped_families(path: Path = TABLE_PATH) -> Tuple[Tuple[Family, ...], str]: + """Load and validate ``path``; return its families and default label.""" + data = load_table(path) + problems = validate_table(data) + if problems: + listing = "\n - ".join(problems) + raise RuntimeError(f"invalid family table {path}:\n - {listing}") + return build_families(data), data["default"] + + +FAMILIES, DEFAULT_FAMILY = load_shipped_families() def family_labels() -> List[str]: diff --git a/kikotools/tools/model_resolution_picker/table.py b/kikotools/tools/model_resolution_picker/table.py new file mode 100644 index 0000000..171ca57 --- /dev/null +++ b/kikotools/tools/model_resolution_picker/table.py @@ -0,0 +1,265 @@ +"""Load, validate and build the Model Resolution Picker family table. + +The table is plain JSON (shipped as ``families.json``). ``validate_table`` is a +pure function that returns human-readable problems and never raises for data +problems, so the same check can guard the shipped file and a user override. +Unknown keys, such as a per-family ``"notes"`` string, are ignored. +""" + +import json +from pathlib import Path +from typing import Any, Dict, List, NamedTuple, Tuple + +CHIPS: Tuple[str, ...] = ( + "1:1", + "4:5", + "5:4", + "3:4", + "4:3", + "2:3", + "3:2", + "9:16", + "16:9", + "21:9", + "9:21", +) +MIRROR: Dict[str, str] = { + "1:1": "1:1", + "4:5": "5:4", + "5:4": "4:5", + "3:4": "4:3", + "4:3": "3:4", + "2:3": "3:2", + "3:2": "2:3", + "9:16": "16:9", + "16:9": "9:16", + "21:9": "9:21", + "9:21": "21:9", +} +TABLE_VERSION = 1 +GROUPS = ("image", "video") +VAE_RATIOS = (8, 16, 32) +CHIP_TOLERANCE = 0.07 + +Size = Tuple[int, int] + + +class Family(NamedTuple): + """One model family: grid constraints, size tiers and the chip size lists.""" + + label: str + aliases: Tuple[str, ...] + models: str + group: str + vae_ratio: int + step: int + megapixels: Tuple[float, ...] + default: Size + sizes: Dict[str, Tuple[Size, ...]] + + +def load_table(path: Path) -> Dict[str, Any]: + """Read ``path`` as JSON; raise ``ValueError`` naming the path on failure.""" + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise ValueError(f"cannot read family table {path}: {error}") from error + if not isinstance(data, dict): + raise ValueError(f"family table {path} must be a JSON object") + return data + + +def _is_size(value: Any) -> bool: + return ( + isinstance(value, (list, tuple)) + and len(value) == 2 + and all(isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value) + ) + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _chip_ratio(chip: str) -> float: + left, right = chip.split(":") + return int(left) / int(right) + + +def _validate_top_level(data: Dict[str, Any]) -> List[str]: + problems: List[str] = [] + if data.get("version") != TABLE_VERSION: + problems.append(f"version must be {TABLE_VERSION}") + if data.get("chips") != list(CHIPS): + problems.append(f"chips must equal {list(CHIPS)}") + if data.get("mirror") != MIRROR: + problems.append("mirror must pair every chip with its mirrored chip") + if not isinstance(data.get("families"), list) or not data["families"]: + problems.append("families must be a non-empty list") + return problems + + +def _validate_names(families: List[Any]) -> List[str]: + problems: List[str] = [] + seen: Dict[str, str] = {} + for index, fam in enumerate(families): + label = fam.get("label") + if not isinstance(label, str) or not label: + problems.append(f"family #{index + 1}: label must be a non-empty string") + continue + aliases = fam.get("aliases", []) + if not isinstance(aliases, list) or not all( + isinstance(a, str) for a in aliases + ): + problems.append(f"family {label!r}: aliases must be a list of strings") + aliases = [] + for name in [label, *aliases]: + if name in seen: + problems.append( + f"family {label!r}: duplicate label or alias {name!r} " + f"(already used by {seen[name]!r})" + ) + else: + seen[name] = label + return problems + + +def _validate_scalars(label: str, fam: Dict[str, Any]) -> List[str]: + problems: List[str] = [] + if not isinstance(fam.get("models"), str): + problems.append(f"family {label!r}: models must be a string") + if fam.get("group") not in GROUPS: + problems.append(f"family {label!r}: group must be one of {list(GROUPS)}") + if fam.get("vaeRatio") not in VAE_RATIOS: + problems.append(f"family {label!r}: vaeRatio must be one of {list(VAE_RATIOS)}") + step = fam.get("step") + if not _is_number(step) or step <= 0 or int(step) != step: + problems.append(f"family {label!r}: step must be a positive integer") + mp = fam.get("megapixels") + if not isinstance(mp, list) or not mp or not all(_is_number(m) for m in mp): + problems.append(f"family {label!r}: megapixels must be a non-empty number list") + return problems + + +def _validate_chip( + label: str, chip: str, entries: Any, fam: Dict[str, Any] +) -> List[str]: + where = f"family {label!r}, chip {chip}" + if not isinstance(entries, list) or not all(_is_size(s) for s in entries): + return [f"{where}: sizes must be a list of [width, height] integer pairs"] + problems: List[str] = [] + step = fam.get("step") + step_ok = _is_number(step) and step > 0 and int(step) == step + target = _chip_ratio(chip) + for w, h in entries: + if step_ok and (w % step or h % step): + problems.append(f"{where}: {w}x{h} is not a multiple of step {int(step)}") + if abs(w / h - target) / target > CHIP_TOLERANCE: + problems.append(f"{where}: {w}x{h} is more than 7% off the {chip} ratio") + areas = [w * h for w, h in entries] + if areas != sorted(areas): + problems.append(f"{where}: sizes must be in ascending pixel order") + return problems + + +def _validate_sizes(label: str, fam: Dict[str, Any]) -> List[str]: + sizes = fam.get("sizes") + if not isinstance(sizes, dict): + return [f"family {label!r}: sizes must be an object keyed by chip"] + problems: List[str] = [] + for chip in CHIPS: + if chip not in sizes: + problems.append(f"family {label!r}: sizes is missing chip {chip}") + else: + problems.extend(_validate_chip(label, chip, sizes[chip], fam)) + valid = { + chip: [tuple(s) for s in sizes[chip]] + for chip in CHIPS + if chip in sizes + and isinstance(sizes[chip], list) + and all(_is_size(s) for s in sizes[chip]) + } + problems.extend(_validate_mirrors(label, valid)) + problems.extend(_validate_default(label, fam, valid)) + return problems + + +def _validate_mirrors(label: str, sizes: Dict[str, List[Size]]) -> List[str]: + problems: List[str] = [] + owner: Dict[Size, str] = {} + for chip, entries in sizes.items(): + mirrored = sizes.get(MIRROR[chip]) + for w, h in entries: + if mirrored is not None and (h, w) not in mirrored: + problems.append( + f"family {label!r}, chip {chip}: {w}x{h} has no mirror " + f"{h}x{w} under {MIRROR[chip]}" + ) + if (w, h) in owner: + problems.append( + f"family {label!r}, chip {chip}: {w}x{h} is listed under more " + f"than one chip (also {owner[(w, h)]})" + ) + else: + owner[(w, h)] = chip + return problems + + +def _validate_default( + label: str, fam: Dict[str, Any], sizes: Dict[str, Any] +) -> List[str]: + default = fam.get("default") + if not _is_size(default): + return [f"family {label!r}: default must be a [width, height] pair"] + if not any(tuple(default) in entries for entries in sizes.values()): + return [f"family {label!r}: default {list(default)} is not in its sizes"] + return [] + + +def validate_table(data: Any) -> List[str]: + """Return every problem found in ``data``; an empty list means it is valid.""" + if not isinstance(data, dict): + return ["table must be a JSON object"] + problems = _validate_top_level(data) + families = data.get("families") + if not isinstance(families, list): + return problems + entries = [f for f in families if isinstance(f, dict)] + for index, fam in enumerate(families): + if not isinstance(fam, dict): + problems.append(f"family #{index + 1}: must be an object") + problems.extend(_validate_names(entries)) + for fam in entries: + label = fam.get("label") + if not isinstance(label, str) or not label: + continue + problems.extend(_validate_scalars(label, fam)) + problems.extend(_validate_sizes(label, fam)) + labels = {f.get("label") for f in entries} + aliases = { + a for f in entries if isinstance(f.get("aliases"), list) for a in f["aliases"] + } + if data.get("default") not in labels | aliases: + problems.append(f"default {data.get('default')!r} is not a family label") + return problems + + +def build_families(data: Dict[str, Any]) -> Tuple[Family, ...]: + """Build ``Family`` records from a table that passed ``validate_table``.""" + return tuple( + Family( + label=fam["label"], + aliases=tuple(fam.get("aliases", ())), + models=fam["models"], + group=fam["group"], + vae_ratio=fam["vaeRatio"], + step=int(fam["step"]), + megapixels=tuple(fam["megapixels"]), + default=(fam["default"][0], fam["default"][1]), + sizes={ + chip: tuple((w, h) for w, h in fam["sizes"].get(chip, ())) + for chip in CHIPS + }, + ) + for fam in data["families"] + ) diff --git a/pyproject.toml b/pyproject.toml index 1d9b2d8..001dfc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ Repository = "https://github.com/ComfyAssets/ComfyUI-KikoTools" include = ["kikotools*"] exclude = ["tests*", "web*"] +[tool.setuptools.package-data] +kikotools = ["**/*.json"] + [tool.comfy] PublisherId = "kiko9" DisplayName = "ComfyUI-KikoTools" diff --git a/tests/unit/tools/model_resolution_picker/test_table.py b/tests/unit/tools/model_resolution_picker/test_table.py new file mode 100644 index 0000000..2e7452d --- /dev/null +++ b/tests/unit/tools/model_resolution_picker/test_table.py @@ -0,0 +1,211 @@ +import copy +import json +from pathlib import Path +from typing import Any, Callable, Dict + +import pytest + +from kikotools.tools.model_resolution_picker import families as families_module +from kikotools.tools.model_resolution_picker.table import ( + build_families, + load_table, + validate_table, +) + +SHIPPED = Path(families_module.__file__).with_name("families.json") + + +def shipped_data() -> Dict[str, Any]: + return copy.deepcopy(json.loads(SHIPPED.read_text(encoding="utf-8"))) + + +def family(data: Dict[str, Any], label: str) -> Dict[str, Any]: + return next(f for f in data["families"] if f["label"] == label) + + +def off_step(data: Dict[str, Any]) -> None: + family(data, "SD 1.5")["sizes"]["1:1"][1] = [700, 700] + + +def off_ratio(data: Dict[str, Any]) -> None: + sizes = family(data, "SD 1.5")["sizes"]["1:1"] + sizes[1:1] = [[576, 704], [704, 576]] + + +def missing_mirror(data: Dict[str, Any]) -> None: + family(data, "SD 1.5")["sizes"]["4:5"].pop() + + +def unsorted(data: Dict[str, Any]) -> None: + family(data, "SD 1.5")["sizes"]["1:1"].reverse() + + +def duplicate_size(data: Dict[str, Any]) -> None: + sizes = family(data, "SD 1.5")["sizes"] + for chip, size in (("3:4", [640, 832]), ("4:3", [832, 640])): + sizes[chip].append(size) + sizes[chip].sort(key=lambda s: s[0] * s[1]) + + +def duplicate_label(data: Dict[str, Any]) -> None: + data["families"][2]["label"] = data["families"][0]["label"] + + +def missing_chip(data: Dict[str, Any]) -> None: + del family(data, "SD 1.5")["sizes"]["21:9"] + + +def default_not_listed(data: Dict[str, Any]) -> None: + family(data, "SD 1.5")["default"] = [520, 520] + + +def bad_vae_ratio(data: Dict[str, Any]) -> None: + family(data, "SD 1.5")["vaeRatio"] = 7 + + +def bad_group(data: Dict[str, Any]) -> None: + family(data, "SD 1.5")["group"] = "audio" + + +def bad_step(data: Dict[str, Any]) -> None: + family(data, "SD 1.5")["step"] = 0 + + +def empty_megapixels(data: Dict[str, Any]) -> None: + family(data, "SD 1.5")["megapixels"] = [] + + +def duplicate_alias(data: Dict[str, Any]) -> None: + family(data, "SD 1.5")["aliases"] = ["SDXL"] + + +def test_validate_table_accepts_the_shipped_file(): + assert validate_table(shipped_data()) == [] + + +@pytest.mark.parametrize( + "mutate, needle, chip, count", + [ + (off_step, "step", "1:1", 1), + (off_ratio, "7%", "1:1", 2), + (missing_mirror, "mirror", "5:4", 1), + (unsorted, "ascending", "1:1", 1), + (duplicate_size, "more than one chip", ("3:4", "4:3"), 2), + (missing_chip, "missing", "21:9", 1), + (default_not_listed, "default", None, 1), + (bad_vae_ratio, "vaeRatio", None, 1), + (bad_group, "group", None, 1), + (bad_step, "step", None, 1), + (empty_megapixels, "megapixels", None, 1), + ], +) +def test_validate_table_reports_each_invariant( + mutate: Callable[[Dict[str, Any]], None], + needle: str, + chip: Any, + count: int, +): + data = shipped_data() + mutate(data) + + problems = validate_table(data) + + assert len(problems) == count, problems + for problem in problems: + assert "SD 1.5" in problem and needle in problem + if chip: + assert any( + c in problem for c in ([chip] if isinstance(chip, str) else chip) + ) + + +@pytest.mark.parametrize( + "mutate, needle", + [(duplicate_label, "SD 1.5"), (duplicate_alias, "SDXL")], +) +def test_validate_table_reports_duplicate_names( + mutate: Callable[[Dict[str, Any]], None], needle: str +): + data = shipped_data() + mutate(data) + + problems = validate_table(data) + + assert len(problems) == 1 and "duplicate" in problems[0] and needle in problems[0] + + +def test_validate_table_checks_top_level_keys(): + data = shipped_data() + data["version"] = 2 + data["chips"] = data["chips"][:-1] + data["default"] = "Nope" + + problems = validate_table(data) + + assert any("version" in p for p in problems) + assert any("chips" in p for p in problems) + assert any("default" in p for p in problems) + + +def test_validate_table_never_raises_on_garbage(): + assert validate_table([]) != [] # type: ignore[arg-type] + assert validate_table({}) != [] + data = shipped_data() + data["families"][0] = "oops" + data["families"][1]["sizes"] = {"1:1": [[1, "x"]]} + assert validate_table(data) != [] + + +def test_validate_table_ignores_unknown_keys(): + data = shipped_data() + data["extra"] = 1 + family(data, "SD 1.5")["notes"] = "kept for humans" + family(data, "SD 1.5")["whatever"] = {"a": 1} + + assert validate_table(data) == [] + assert build_families(data)[0].label == "SD 1.5" + + +def test_load_table_bad_json_names_the_path(tmp_path: Path): + bad = tmp_path / "broken.json" + bad.write_text("{not json", encoding="utf-8") + + with pytest.raises(ValueError) as error: + load_table(bad) + + assert str(bad) in str(error.value) + + +def test_load_table_missing_file_names_the_path(tmp_path: Path): + missing = tmp_path / "absent.json" + + with pytest.raises(ValueError) as error: + load_table(missing) + + assert str(missing) in str(error.value) + + +def test_families_module_is_built_from_json(): + data = shipped_data() + + assert [f.label for f in families_module.FAMILIES] == [ + f["label"] for f in data["families"] + ] + assert families_module.DEFAULT_FAMILY == data["default"] + assert families_module.families_payload()["families"][0]["sizes"] == { + chip: sizes for chip, sizes in data["families"][0]["sizes"].items() + } + + +def test_families_module_raises_runtime_error_on_invalid_shipped_file( + tmp_path: Path, +): + data = shipped_data() + bad_vae_ratio(data) + path = tmp_path / "families.json" + path.write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(RuntimeError) as error: + families_module.load_shipped_families(path) + + assert "SD 1.5" in str(error.value) and "vaeRatio" in str(error.value)