├── LICENSE ├── README.md ├── debug └── debug images go here.txt ├── example_tiles ├── brick.png ├── grass.png ├── mud.png └── stone.png ├── mask ├── clean_topdown.png ├── godot3x3.png ├── platformer.png ├── ragged_topdown.png └── wang.png ├── tileset_builder.py └── util ├── requirements.txt └── utils.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Astropulse 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tileset Builder 2 | Use Retro Diffusion's API to create tilesets from two textures 3 | 4 | ![Gl3BUtGW8AAsFon](https://github.com/user-attachments/assets/9009f787-eb36-44f5-abd1-88b7d6172cac) 5 | 6 | 7 | # Installation 8 | To use this, you must have python 3.11 installed, and run `pip install requirements.txt` from the 'util' folder. 9 | Then you can start the python script, and it will open a gradio ui in your default browser. 10 | 11 | # Usage 12 | You will need an API key from [https://www.retrodiffusion.ai/](https://www.retrodiffusion.ai/). You can enter this in the gradio ui, or you can save it to the util folder in a .txt file names "api_key.txt". 13 | 14 | Now you can add the texture files you want to use (they must be the same size, and larger than 16x16 and smaller than 128x128). You can even use Retro Diffusion to generate these if you want. 15 | You don't need to use the outside and inside prompts, but it is a good idea to get the best results possible. 16 | 17 | Choose the "Master Mask" you want to use. This mask determines the shapes of the tiles in the tileset. There are a few defaults to choose from. 18 | 19 | # Adding Master Masks 20 | The master masks must be in a sepecific format and arrangement. Follow the rules below or you'll get errors and deformed tilesets: 21 | - Colors must be pure black (0,0,0), pure white (255, 255, 255), or pure magenta (255, 0, 255). 22 | - Black is the "outside" color. 23 | - White is the "inside" color. 24 | - Magenta is the "background" color. See the "platformer" tileset mask for an example of its use. 25 | - The top left tile must be a sold black tile the full size of all the tiles in the set, and it must not have other pieces of black next to it. The program uses this tile as a reference point to determine the size of the sheet. 26 | ![image](https://github.com/user-attachments/assets/39de9a2f-67c7-4a27-a38c-a51e5769350f) 27 | 28 | - The sheet must be a size multiple of the top left tile. 29 | - You can have empty tiles, simply make the whole tile solid black, solid white, or solid magenta. 30 | -------------------------------------------------------------------------------- /debug/debug images go here.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/tilesetbuilder/88ae000d2e5a2adfa5ee213931afec3b0ca2c0bc/debug/debug images go here.txt -------------------------------------------------------------------------------- /example_tiles/brick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/tilesetbuilder/88ae000d2e5a2adfa5ee213931afec3b0ca2c0bc/example_tiles/brick.png -------------------------------------------------------------------------------- /example_tiles/grass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/tilesetbuilder/88ae000d2e5a2adfa5ee213931afec3b0ca2c0bc/example_tiles/grass.png -------------------------------------------------------------------------------- /example_tiles/mud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/tilesetbuilder/88ae000d2e5a2adfa5ee213931afec3b0ca2c0bc/example_tiles/mud.png -------------------------------------------------------------------------------- /example_tiles/stone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/tilesetbuilder/88ae000d2e5a2adfa5ee213931afec3b0ca2c0bc/example_tiles/stone.png -------------------------------------------------------------------------------- /mask/clean_topdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/tilesetbuilder/88ae000d2e5a2adfa5ee213931afec3b0ca2c0bc/mask/clean_topdown.png -------------------------------------------------------------------------------- /mask/godot3x3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/tilesetbuilder/88ae000d2e5a2adfa5ee213931afec3b0ca2c0bc/mask/godot3x3.png -------------------------------------------------------------------------------- /mask/platformer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/tilesetbuilder/88ae000d2e5a2adfa5ee213931afec3b0ca2c0bc/mask/platformer.png -------------------------------------------------------------------------------- /mask/ragged_topdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/tilesetbuilder/88ae000d2e5a2adfa5ee213931afec3b0ca2c0bc/mask/ragged_topdown.png -------------------------------------------------------------------------------- /mask/wang.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/tilesetbuilder/88ae000d2e5a2adfa5ee213931afec3b0ca2c0bc/mask/wang.png -------------------------------------------------------------------------------- /tileset_builder.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import base64 3 | import io 4 | import math 5 | import random 6 | import time 7 | import os 8 | import numpy as np 9 | from PIL import Image, ImageFilter 10 | import gradio as gr 11 | 12 | from concurrent.futures import ThreadPoolExecutor, as_completed 13 | 14 | from util.utils import ( 15 | som_quantize_with_palette, 16 | classify_rgb_pixel, 17 | blend_images_with_mask, 18 | tile_has_magenta, 19 | add_noise_to_feather, 20 | determine_tile_size_from_master 21 | ) 22 | 23 | # ============================================================================= 24 | # TILE GENERATION AND SEAM MASKS 25 | # ============================================================================= 26 | 27 | def create_seam_mask_from_tile_color(tile_rgb, seam_width, feather_radius): 28 | """ 29 | Builds a mask marking boundaries between pixel labels in a tile. 30 | Then expands and feathers that boundary if requested. 31 | """ 32 | w, h = tile_rgb.size 33 | labels_2d = [] 34 | px_rgb = tile_rgb.load() 35 | 36 | # A: label each pixel 37 | for y in range(h): 38 | row = [] 39 | for x in range(w): 40 | row.append(classify_rgb_pixel(px_rgb[x,y])) 41 | labels_2d.append(row) 42 | label_arr = np.array(labels_2d, dtype=np.uint8) 43 | 44 | # B: detect boundary pixels 45 | seam_arr = np.zeros((h, w), dtype=np.uint8) 46 | directions = [(-1,0), (1,0), (0,-1), (0,1)] 47 | for y in range(h): 48 | for x in range(w): 49 | my_label = label_arr[y, x] 50 | for dy, dx in directions: 51 | ny, nx = y+dy, x+dx 52 | if 0 <= ny < h and 0 <= nx < w and label_arr[ny, nx] != my_label: 53 | seam_arr[y, x] = 255 54 | break 55 | 56 | seam_img = Image.fromarray(seam_arr, mode="L") 57 | 58 | # C: morphological expansion (MaxFilter) if seam_width > 1 59 | seam_width = int(round(seam_width)) 60 | if seam_width < 1: 61 | seam_width = 1 62 | if seam_width % 2 == 0: 63 | seam_width += 1 64 | if seam_width > 1: 65 | seam_img = seam_img.filter(ImageFilter.MaxFilter(seam_width)) 66 | 67 | # D: feather with GaussianBlur 68 | if feather_radius > 0: 69 | seam_img = seam_img.filter(ImageFilter.GaussianBlur(feather_radius)) 70 | 71 | return seam_img 72 | 73 | 74 | def stitch_tiles(tiles, grid_size, tile_size, debug_mode=False): 75 | rows, cols = grid_size 76 | stitched = Image.new("RGBA", (cols * tile_size[0], rows * tile_size[1])) 77 | 78 | if isinstance(tiles, dict): 79 | for r in range(1, rows + 1): 80 | for c in range(1, cols + 1): 81 | tile = tiles[(r, c)] 82 | box = ((c - 1) * tile_size[0], (r - 1) * tile_size[1]) 83 | stitched.paste(tile, box) 84 | else: 85 | idx = 0 86 | for r in range(rows): 87 | for c in range(cols): 88 | tile = tiles[idx] 89 | box = (c * tile_size[0], r * tile_size[1]) 90 | stitched.paste(tile, box) 91 | idx += 1 92 | 93 | if debug_mode: 94 | stitched.save("debug/debug_stitched_tiles.png") 95 | return stitched 96 | 97 | 98 | def apply_seam_replacements( 99 | tileset_image, 100 | seams_image, 101 | master_mask, 102 | grid_size, 103 | tile_size, 104 | seam_width, 105 | feather_radius, 106 | debug_mode=False 107 | ): 108 | """ 109 | 1) For each tile, build a "raw" seam mask from the tile's region in master_mask. 110 | 2) Stitch all tile masks into one large 'combined_seam_masks' and globally normalize. 111 | 3) Re-split into tiles, use each sub-mask to blend seam_image over tileset_image. 112 | """ 113 | rows, cols = grid_size 114 | final_image = tileset_image.copy() 115 | seam_masks = {} 116 | 117 | # (A) Build raw seam mask for each tile 118 | for r in range(1, rows + 1): 119 | for c in range(1, cols + 1): 120 | left = (c - 1) * tile_size[0] 121 | upper = (r - 1) * tile_size[1] 122 | box = (left, upper, left + tile_size[0], upper + tile_size[1]) 123 | 124 | tile_mask_rgb = master_mask.crop(box).convert("RGB") 125 | extrema = tile_mask_rgb.getextrema() 126 | all_mins = [pair[0] for pair in extrema] 127 | all_maxes = [pair[1] for pair in extrema] 128 | # If uniform => no seam 129 | if min(all_mins) == max(all_maxes): 130 | seam_mask = Image.new("L", tile_size, 0) 131 | else: 132 | seam_mask = create_seam_mask_from_tile_color( 133 | tile_mask_rgb, seam_width, feather_radius 134 | ) 135 | seam_masks[(r, c)] = seam_mask 136 | 137 | # (B) Stitch raw seam masks into one large mask 138 | combined_masks = stitch_tiles(seam_masks, grid_size, tile_size) 139 | combined_masks = combined_masks.convert("L") 140 | 141 | # (C) Globally normalize 142 | arr = np.array(combined_masks, dtype=np.float32) 143 | mn, mx = arr.min(), arr.max() 144 | if mx > mn: 145 | arr = ((arr - mn) / (mx - mn)) * 255.0 146 | arr = np.clip(arr, 0, 255).astype(np.uint8) 147 | normalized_combined = Image.fromarray(arr, mode="L") 148 | if debug_mode: 149 | normalized_combined.save("debug/debug_global_normalized_seam_mask.png") 150 | 151 | # (D) Re-split normalized mask and blend 152 | normed_masks = {} 153 | for r in range(1, rows + 1): 154 | for c in range(1, cols + 1): 155 | left = (c - 1) * tile_size[0] 156 | upper = (r - 1) * tile_size[1] 157 | box = (left, upper, left + tile_size[0], upper + tile_size[1]) 158 | normed_masks[(r, c)] = normalized_combined.crop(box) 159 | 160 | # (E) Blend each tile 161 | for r in range(1, rows + 1): 162 | for c in range(1, cols + 1): 163 | left = (c - 1) * tile_size[0] 164 | upper = (r - 1) * tile_size[1] 165 | box = (left, upper, left + tile_size[0], upper + tile_size[1]) 166 | 167 | tile_region = tileset_image.crop(box) 168 | seam_region = seams_image.crop(box) 169 | seam_mask = normed_masks[(r, c)] 170 | 171 | blended_tile = blend_images_with_mask(seam_region, tile_region, seam_mask) 172 | final_image.paste(blended_tile, box) 173 | 174 | if debug_mode: 175 | final_image.save("debug/debug_final_seamed_tileset.png") 176 | 177 | return final_image 178 | 179 | 180 | # ============================================================================= 181 | # PARTIAL TILE BLENDING 182 | # ============================================================================= 183 | 184 | def partial_tile_black_white_then_magenta(mask_rgb, 185 | outside_img, 186 | inside_img, 187 | feather_radius=5, 188 | noise_level=10): 189 | """ 190 | Same partial-tile code you already have, plus a simple neighbor-cleanup 191 | pass that: 192 | - looks only at the originally magenta region (label=2), 193 | - finds pixels that ended up non-magenta, 194 | - counts how many of their 8 neighbors in that region have the same color, 195 | - if fewer than 3 neighbors match => convert pixel to magenta. 196 | """ 197 | w, h = mask_rgb.size 198 | 199 | # ----------------------------------------------------------- 200 | # Step A: black vs white (ignore magenta => black) 201 | # ----------------------------------------------------------- 202 | submask_bw = Image.new("L", (w, h), 0) 203 | px_mask = mask_rgb.load() 204 | px_bw = submask_bw.load() 205 | 206 | for y in range(h): 207 | for x in range(w): 208 | label = classify_rgb_pixel(px_mask[x,y]) 209 | px_bw[x,y] = 255 if label == 1 else 0 210 | 211 | submask_bw = submask_bw.filter(ImageFilter.GaussianBlur(feather_radius)) 212 | if noise_level > 0: 213 | submask_bw = add_noise_to_feather(submask_bw, noise_level) 214 | 215 | bw_arr = np.array(submask_bw, dtype=np.float32) / 255.0 216 | 217 | outside_resized = outside_img.resize((w, h), Image.Resampling.NEAREST).convert("RGB") 218 | inside_resized = inside_img.resize((w, h), Image.Resampling.NEAREST).convert("RGB") 219 | 220 | out_arr = np.array(outside_resized, dtype=np.float32) 221 | in_arr = np.array(inside_resized, dtype=np.float32) 222 | 223 | stepA_arr = out_arr*(1 - bw_arr[...,None]) + in_arr*(bw_arr[...,None]) 224 | stepA_arr = np.clip(stepA_arr, 0, 255).astype(np.uint8) 225 | 226 | # ----------------------------------------------------------- 227 | # Step B: Randomly dither for magenta(2) 228 | # ----------------------------------------------------------- 229 | # 1) 0/255 mask for magenta 230 | mask_m = Image.new("L", (w, h), 0) 231 | px_m = mask_m.load() 232 | for y in range(h): 233 | for x in range(w): 234 | if classify_rgb_pixel(px_mask[x,y]) == 2: 235 | px_m[x,y] = 255 236 | 237 | # 2) Feather + noise => fractional probability 238 | mask_m = mask_m.filter(ImageFilter.GaussianBlur(feather_radius/3)) 239 | if noise_level > 0: 240 | mask_m = add_noise_to_feather(mask_m, noise_level) 241 | 242 | m_arr = np.array(mask_m, dtype=np.float32) / 255.0 243 | 244 | # 3) For each pixel => magenta if random < m_arr 245 | random_map = np.random.random((h, w)) 246 | stepA_float = stepA_arr.astype(np.float32) 247 | magenta_arr = np.full((h, w, 3), [255,0,255], dtype=np.float32) 248 | 249 | magenta_mask = (random_map < m_arr) 250 | final_arr = np.empty((h, w, 3), dtype=np.float32) 251 | final_arr[ magenta_mask ] = magenta_arr[ magenta_mask ] 252 | final_arr[~magenta_mask ] = stepA_float[~magenta_mask] 253 | final_arr = np.clip(final_arr, 0, 255).astype(np.uint8) 254 | 255 | # ----------------------------------------------------------- 256 | # Cleanup: ONLY in originally magenta region (label=2). 257 | # If a pixel ended up non-magenta, but doesn't have at least 258 | # 3 neighbors of the same color, set it to magenta. 259 | # ----------------------------------------------------------- 260 | # Build a boolean array: True => originally label=2 261 | orig_mag = np.zeros((h, w), dtype=bool) 262 | for y in range(h): 263 | for x in range(w): 264 | if classify_rgb_pixel(px_mask[x, y]) == 2: 265 | orig_mag[y, x] = True 266 | 267 | # Convert final_arr to a PIL image for easy neighbor checking 268 | final_img = Image.fromarray(final_arr, mode="RGB") 269 | px_final = final_img.load() # so px_final[x, y] => (R,G,B) 270 | 271 | neighbors_8 = [(-1,-1), (-1,0), (-1,1), 272 | (0,-1), (0,1), 273 | (1,-1), (1,0), (1,1)] 274 | 275 | MAGENTA = (255,0,255) 276 | def same_color(a, b): 277 | return (a[0]==b[0]) and (a[1]==b[1]) and (a[2]==b[2]) 278 | 279 | # We'll make a copy so changes don't cause chain reactions 280 | filtered_arr = final_arr.copy() 281 | 282 | for y in range(h): 283 | for x in range(w): 284 | if not orig_mag[y, x]: 285 | # Not originally magenta => skip 286 | continue 287 | 288 | current_color = px_final[x, y] 289 | if same_color(current_color, MAGENTA): 290 | # Already magenta => skip 291 | continue 292 | 293 | # Count how many neighbors in the *same region* 294 | # share the same color 295 | same_count = 0 296 | for dy, dx in neighbors_8: 297 | ny, nx = y+dy, x+dx 298 | if 0 <= ny < h and 0 <= nx < w: 299 | # We only consider neighbors that are also originally magenta 300 | if orig_mag[ny, nx]: 301 | neighbor_color = px_final[nx, ny] 302 | if same_color(neighbor_color, current_color): 303 | same_count += 1 304 | 305 | # If fewer than 3 neighbors match => convert to magenta 306 | if same_count < 3: 307 | filtered_arr[y, x] = [255,0,255] 308 | 309 | # Return final cleaned image 310 | return Image.fromarray(filtered_arr.astype(np.uint8), mode="RGB") 311 | 312 | 313 | def generate_tileset_from_master_mask( 314 | master_mask_rgb, 315 | outside_img, 316 | inside_img, 317 | feather_radius=5, 318 | noise_level=10, 319 | debug_mode=False 320 | ): 321 | """ 322 | Generates the base set of tiles from a master mask image. Each tile 323 | is either uniform or partially blended using black/white/magenta logic. 324 | """ 325 | tile_size = determine_tile_size_from_master(master_mask_rgb) 326 | tw, th = tile_size 327 | grid_cols = master_mask_rgb.width // tw 328 | grid_rows = master_mask_rgb.height // th 329 | 330 | tiles = {} 331 | for row in range(1, grid_rows+1): 332 | for col in range(1, grid_cols+1): 333 | left = (col - 1)*tw 334 | upper = (row - 1)*th 335 | box = (left, upper, left+tw, upper+th) 336 | tile_mask = master_mask_rgb.crop(box) 337 | 338 | # Classify entire tile 339 | labels = [classify_rgb_pixel(tile_mask.getpixel((x, y))) 340 | for y in range(th) for x in range(tw)] 341 | unique_labels = set(labels) 342 | 343 | if len(unique_labels) == 1: 344 | # Uniform tile 345 | only = unique_labels.pop() 346 | if only == 0: 347 | tile_img = outside_img.resize(tile_size, Image.Resampling.NEAREST).convert("RGB") 348 | elif only == 1: 349 | tile_img = inside_img.resize(tile_size, Image.Resampling.NEAREST).convert("RGB") 350 | elif only == 2: 351 | tile_img = Image.new("RGB", tile_size, (255,0,255)) 352 | else: # label=3 => treat as black 353 | tile_img = outside_img.resize(tile_size, Image.Resampling.NEAREST).convert("RGB") 354 | else: 355 | # Partial tile => black/white, then magenta 356 | tile_img = partial_tile_black_white_then_magenta( 357 | tile_mask, outside_img, inside_img, feather_radius, noise_level 358 | ) 359 | 360 | if debug_mode: 361 | tile_img.save(f"debug/debug_tile_{row}_{col}.png") 362 | tiles[(row,col)] = tile_img 363 | 364 | return tiles, (grid_rows, grid_cols), tile_size 365 | 366 | 367 | # ============================================================================= 368 | # PIPELINE FUNCTIONS 369 | # ============================================================================= 370 | 371 | def generate_images( 372 | api_key, 373 | prompt, 374 | input_image=None, 375 | strength=0.5, 376 | style="default", 377 | model="RD_FLUX", 378 | width=256, 379 | height=256, 380 | num_images=1, 381 | seed=0 382 | ): 383 | """ 384 | Example stub function calling an external API (e.g. RetroDiffusion). 385 | Modify as needed for your actual endpoint. 386 | """ 387 | if input_image is not None: 388 | buf = io.BytesIO() 389 | input_image.convert("RGB").save(buf, format="PNG") 390 | base64_input_image = base64.b64encode(buf.getvalue()).decode("utf-8") 391 | 392 | url = "https://api.retrodiffusion.ai/v1/inferences" 393 | headers = {"X-RD-Token": api_key} 394 | 395 | if input_image is not None: 396 | payload = { 397 | "prompt": prompt, 398 | "prompt_style": style, 399 | "model": model, 400 | "width": width, 401 | "height": height, 402 | "input_image": base64_input_image, 403 | "strength": strength, 404 | "num_images": num_images, 405 | "seed": seed, 406 | } 407 | else: 408 | payload = { 409 | "prompt": prompt, 410 | "prompt_style": style, 411 | "model": model, 412 | "width": width, 413 | "height": height, 414 | "num_images": num_images, 415 | "seed": seed, 416 | } 417 | 418 | response = requests.post(url, headers=headers, json=payload) 419 | images = [] 420 | if response.status_code == 200: 421 | data = response.json() 422 | base64_images = data.get("base64_images", []) 423 | if base64_images: 424 | for img_data in base64_images: 425 | img_bytes = base64.b64decode(img_data) 426 | img = Image.open(io.BytesIO(img_bytes)) 427 | images.append(img) 428 | else: 429 | print("No images returned by the API.") 430 | else: 431 | print(f"Request failed with status code {response.status_code}: {response.text}") 432 | 433 | return images 434 | 435 | 436 | def process_tile( 437 | row, 438 | col, 439 | tiles, 440 | tile_size, 441 | master_mask, 442 | outside_img, 443 | api_key, 444 | outside_prompt, 445 | inside_prompt, 446 | outside_texture, 447 | debug_mode=False 448 | ): 449 | """ 450 | Checks if a tile is uniform; if not, calls the generation API to build 451 | a "seam replacement" image. Returns (row, col, final_image_for_that_tile). 452 | """ 453 | tile = tiles[(row, col)] 454 | left = (col - 1) * tile_size[0] 455 | upper = (row - 1) * tile_size[1] 456 | box = (left, upper, left + tile_size[0], upper + tile_size[1]) 457 | 458 | # Check uniform 459 | tile_rgb = master_mask.crop(box).convert("RGB") 460 | extrema = tile_rgb.getextrema() 461 | min_vals = [c[0] for c in extrema] 462 | max_vals = [c[1] for c in extrema] 463 | if min(min_vals) == max(max_vals): 464 | # Uniform => skip generation 465 | if debug_mode: 466 | tile.save(f"debug/debug_generated_tile_{row}_{col}.png") 467 | print(f"Skipping seam generation for tile {row}:{col} (uniform).") 468 | return (row, col, tile) 469 | 470 | # Build prompt (outside on top of inside, plus magenta if needed) 471 | prompt = f"{outside_prompt} on top of {inside_prompt}" 472 | if tile_has_magenta(tile_rgb): 473 | prompt += " on a magenta background" 474 | 475 | # Generate images 476 | images = generate_images( 477 | api_key=api_key, 478 | prompt=prompt, 479 | input_image=tile, 480 | strength=0.4, 481 | model="RD_FLUX", 482 | style="mc_texture", 483 | width=outside_texture.width, 484 | height=outside_texture.height, 485 | num_images=1, 486 | seed=random.randint(1, 999999) 487 | ) 488 | print(f"Generated seam replacement for tile {row}:{col} [prompt='{prompt}']") 489 | 490 | # Post-process: downsize & SOM-quantize w/ tile as palette 491 | for image in images: 492 | image = som_quantize_with_palette(image, tile) 493 | 494 | if debug_mode: 495 | image.save(f"debug/debug_generated_tile_{row}_{col}.png") 496 | 497 | return (row, col, image) 498 | 499 | 500 | def run_pipeline( 501 | api_key, 502 | outside_prompt, 503 | inside_prompt, 504 | outside_texture, 505 | inside_texture, 506 | master_mask_choice, 507 | debug_mode 508 | ): 509 | """ 510 | High-level pipeline to: 511 | 1) Load the mask and generate base tiles 512 | 2) Generate partial seam tiles 513 | 3) Blend seams 514 | 4) Combine palette from outside+inside (and magenta if needed) 515 | 5) Final SOM quantize 516 | 6) Output final 517 | """ 518 | start_time = time.time() 519 | if outside_texture.size != inside_texture.size: 520 | raise ValueError("Outside and inside textures must be the same size.") 521 | 522 | w, h = outside_texture.size 523 | if w < 16 or h < 16 or w > 128 or h > 128: 524 | raise ValueError("Textures must be between 16x16 and 128x128.") 525 | 526 | mask_path = os.path.join("mask", master_mask_choice) 527 | master_mask_rgb = Image.open(mask_path).convert("RGB") 528 | 529 | # 1) Determine the original tile size from the master mask 530 | orig_tile_size = determine_tile_size_from_master(master_mask_rgb) 531 | grid_cols = master_mask_rgb.width // orig_tile_size[0] 532 | grid_rows = master_mask_rgb.height // orig_tile_size[1] 533 | 534 | # 2) If the master mask’s tile size is different from the texture size (w,h), 535 | # we rescale the master mask to match. Each tile becomes exactly w x h. 536 | if orig_tile_size != (w, h): 537 | new_width = grid_cols * w 538 | new_height = grid_rows * h 539 | print(f"Resizing master mask from {master_mask_rgb.size} to {(new_width, new_height)}...") 540 | master_mask_rgb = master_mask_rgb.resize((new_width, new_height), Image.Resampling.NEAREST) 541 | 542 | # Now each tile is guaranteed to be (w, h). 543 | tile_size = (w, h) 544 | 545 | feather_radius = math.sqrt(w*h) / 14 546 | tiles, grid_size, _ = generate_tileset_from_master_mask( 547 | master_mask_rgb, outside_texture, inside_texture, 548 | feather_radius, debug_mode=debug_mode 549 | ) 550 | tiles, grid_size, _ = generate_tileset_from_master_mask( 551 | master_mask_rgb, outside_texture, inside_texture, feather_radius, debug_mode=debug_mode 552 | ) 553 | raw_tileset = stitch_tiles(tiles, grid_size, tile_size, debug_mode=debug_mode) 554 | 555 | # Generate seam replacements (partial tiles) 556 | generated_tiles = {} 557 | with ThreadPoolExecutor(max_workers=16) as executor: 558 | futures = [] 559 | for r in range(1, grid_size[0]+1): 560 | for c in range(1, grid_size[1]+1): 561 | f = executor.submit( 562 | process_tile, 563 | r, c, tiles, tile_size, master_mask_rgb, 564 | outside_texture, api_key, 565 | outside_prompt, inside_prompt, 566 | outside_texture, debug_mode 567 | ) 568 | futures.append(f) 569 | 570 | for future in as_completed(futures): 571 | row, col, tile_image = future.result() 572 | generated_tiles[(row, col)] = tile_image 573 | 574 | generated_tileset = stitch_tiles(generated_tiles, grid_size, tile_size, debug_mode=debug_mode) 575 | 576 | # Apply seam replacements 577 | seam_feather = math.sqrt(w*h) / 10 578 | final_tileset = apply_seam_replacements( 579 | raw_tileset, 580 | generated_tileset, 581 | master_mask_rgb.convert("L"), 582 | grid_size, 583 | tile_size, 584 | int(seam_feather), 585 | seam_feather, 586 | debug_mode=debug_mode 587 | ).convert("RGB") 588 | 589 | # Build combined palette from outside+inside (and magenta if mask has any) 590 | combined = Image.new("RGB", (w + w, max(h, h)), (0, 0, 0)) 591 | combined.paste(outside_texture.convert("RGB"), (0, 0)) 592 | combined.paste(inside_texture.convert("RGB"), (w, 0)) 593 | if (255, 0, 255) in master_mask_rgb.getdata(): 594 | combined.putpixel((0, 0), (255,0,255)) 595 | 596 | # Final quantize 597 | final_tileset = som_quantize_with_palette(final_tileset, combined) 598 | 599 | # 2) Convert any pure magenta pixel to alpha=0 600 | rgba_img = final_tileset.convert("RGBA") 601 | arr = np.array(rgba_img, dtype=np.uint8) # shape (H, W, 4) 602 | magenta_mask = (arr[...,0] == 255) & (arr[...,1] == 0) & (arr[...,2] == 255) 603 | arr[magenta_mask, 3] = 0 604 | 605 | # Convert back to a PIL Image 606 | final_tileset = Image.fromarray(arr, mode="RGBA") 607 | 608 | # Save final outputs 609 | final_tileset.save("final_tileset.png") 610 | upscale_factor = 8 611 | final_upscaled = final_tileset.resize( 612 | (final_tileset.width * upscale_factor, final_tileset.height * upscale_factor), 613 | Image.Resampling.NEAREST 614 | ) 615 | 616 | elapsed = time.time() - start_time 617 | print(f"Total process time: {elapsed:.2f} seconds") 618 | return final_upscaled, "final_tileset.png" 619 | 620 | 621 | # ============================================================================= 622 | # GRADIO UI HELPERS 623 | # ============================================================================= 624 | 625 | def preview_mask_and_credit(mask_filename): 626 | """ 627 | Quick function: loads the mask, displays it, 628 | and calculates how many partial tiles would cost a "credit." 629 | """ 630 | mask_path = os.path.join("mask", mask_filename) 631 | mask_img = Image.open(mask_path).convert("RGB") 632 | 633 | tile_size = determine_tile_size_from_master(mask_img) 634 | grid_cols = mask_img.width // tile_size[0] 635 | grid_rows = mask_img.height // tile_size[1] 636 | 637 | credit_count = 0 638 | for row in range(grid_rows): 639 | for col in range(grid_cols): 640 | left = col * tile_size[0] 641 | upper = row * tile_size[1] 642 | tile_rgb = mask_img.crop((left, upper, left+tile_size[0], upper+tile_size[1])) 643 | 644 | # Identify unique labels 645 | labels = [classify_rgb_pixel(tile_rgb.getpixel((x, y))) 646 | for y in range(tile_rgb.height) for x in range(tile_rgb.width)] 647 | unique_l = set(labels) 648 | 649 | # If it's 1 label in {0,1,2}, no credit 650 | if len(unique_l) == 1: 651 | only = list(unique_l)[0] 652 | if only not in (0,1,2): 653 | credit_count += 1 654 | else: 655 | credit_count += 1 656 | 657 | return mask_img, f"Credits required: {credit_count}" 658 | 659 | 660 | def refresh_mask_folder(): 661 | """ 662 | Fetches new images in the 'mask' folder, updates dropdown. 663 | """ 664 | new_masks = [f for f in os.listdir("mask") if f.lower().endswith((".png", ".jpg", ".jpeg"))] 665 | if new_masks: 666 | return gr.update(choices=new_masks, value=new_masks[0]) 667 | else: 668 | return gr.update(choices=[], value=None) 669 | 670 | 671 | # ============================================================================= 672 | # GRADIO UI 673 | # ============================================================================= 674 | 675 | mask_files = [f for f in os.listdir("mask") if f.lower().endswith((".png", ".jpg", ".jpeg"))] 676 | if mask_files: 677 | mask_preview_default, credit_info_default = preview_mask_and_credit(mask_files[0]) 678 | else: 679 | mask_preview_default, credit_info_default = None, "No mask files available" 680 | 681 | with gr.Blocks(title="Tileset Generator") as demo: 682 | gr.Markdown(""" 683 | # Tileset Generator 684 | 685 | Create tilesets from two tiles and a tileset mask 686 | """) 687 | with gr.Row(): 688 | with gr.Column(): 689 | api_key_in = gr.Textbox( 690 | label="Retro Diffusion API Key", 691 | value=open("util/api_key.txt").read().strip() if os.path.exists("util/api_key.txt") else "", 692 | placeholder="Enter your API key" 693 | ) 694 | gr.Markdown(""" 695 | Get your key from [the developer tools section](https://www.retrodiffusion.ai/app/devtools) 696 | """) 697 | outside_prompt_in = gr.Textbox(label="Outside Prompt", value="") 698 | inside_prompt_in = gr.Textbox(label="Inside Prompt", value="") 699 | outside_texture_in = gr.Image(label="Outside Texture (black area)", type="pil") 700 | inside_texture_in = gr.Image(label="Inside Texture (white area)", type="pil") 701 | 702 | gr.Markdown(""" 703 | For more information on Master Masks and how to make your own go [here](https://github.com/Astropulse/tilesetbuilder?tab=readme-ov-file#adding-master-masks) 704 | """) 705 | 706 | master_mask_dropdown = gr.Dropdown( 707 | choices=mask_files, 708 | label="Master Mask (RGB with black/white/magenta)", 709 | value=mask_files[0] if mask_files else None 710 | ) 711 | refresh_masks_btn = gr.Button("Refresh Mask Folder") 712 | debug_mode_in = gr.Checkbox(label="Debug Mode", value=False) 713 | run_btn = gr.Button("Generate Tileset") 714 | 715 | with gr.Column(): 716 | mask_preview = gr.Image(label="Master Mask Preview", value=mask_preview_default) 717 | credit_info = gr.Textbox(label="Credit Cost", value=credit_info_default) 718 | final_tileset_out = gr.Image(label="Final Tileset (Upscaled)") 719 | download_file_out = gr.File(label="Save Final Tileset") 720 | 721 | master_mask_dropdown.change( 722 | fn=preview_mask_and_credit, 723 | inputs=master_mask_dropdown, 724 | outputs=[mask_preview, credit_info] 725 | ) 726 | refresh_masks_btn.click(fn=refresh_mask_folder, inputs=[], outputs=master_mask_dropdown) 727 | 728 | run_btn.click( 729 | fn=run_pipeline, 730 | inputs=[ 731 | api_key_in, 732 | outside_prompt_in, 733 | inside_prompt_in, 734 | outside_texture_in, 735 | inside_texture_in, 736 | master_mask_dropdown, 737 | debug_mode_in 738 | ], 739 | outputs=[final_tileset_out, download_file_out] 740 | ) 741 | 742 | demo.launch(inbrowser=True) 743 | -------------------------------------------------------------------------------- /util/requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==1.26.4 2 | Pillow==9.3.0 3 | matplotlib==3.10.0 4 | colour-science==0.4.6 5 | scikit-learn==1.5.1 6 | minisom==2.3.3 7 | gradio -------------------------------------------------------------------------------- /util/utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | import numpy as np 3 | from PIL import Image 4 | 5 | from sklearn.cluster import MeanShift, estimate_bandwidth 6 | from sklearn.neighbors import NearestNeighbors 7 | import colour 8 | from minisom import MiniSom 9 | 10 | 11 | 12 | 13 | def mean_shift_quantize(img: Image.Image, quantile: float = 0.06) -> Image.Image: 14 | """ 15 | Performs mean-shift quantization on the given image in the Oklab color space. 16 | This function uses scikit-learn's MeanShift and colour-science to convert 17 | between color spaces. 18 | 19 | Parameters 20 | ---------- 21 | img : Image.Image 22 | Input PIL image. 23 | quantile : float, optional 24 | Quantile for estimating the bandwidth. Default is 0.06. 25 | 26 | Returns 27 | ------- 28 | Image.Image 29 | The quantized PIL image. 30 | """ 31 | # 0. Handle RGBA: threshold alpha channel if present 32 | has_alpha = (img.mode == "RGBA") 33 | mask = None 34 | if has_alpha: 35 | rgba_arr = np.array(img, dtype=np.uint8) 36 | alpha_channel = rgba_arr[:, :, 3] 37 | mask = (alpha_channel > 50).astype(np.uint8) * 255 38 | rgba_arr[:, :, 3] = mask 39 | thresholded_img = Image.fromarray(rgba_arr, mode="RGBA") 40 | 41 | white_bg = Image.new("RGBA", thresholded_img.size, (255, 255, 255, 255)) 42 | composited = Image.alpha_composite(white_bg, thresholded_img) 43 | img_for_quant = composited.convert("RGB") 44 | else: 45 | img_for_quant = img.convert("RGB") 46 | 47 | orig_w, orig_h = img_for_quant.size 48 | x = math.sqrt(orig_w * orig_h) 49 | if x < 1: 50 | return 1.0 51 | 52 | factor = (0.00001*x**2 + 0.07*x + 14.9) / x 53 | down_w = max(1, int(round(orig_w * factor))) 54 | down_h = max(1, int(round(orig_h * factor))) 55 | 56 | print(f"Original: {orig_w}x{orig_h}, factor={factor:.3f}, down to {down_w}x{down_h}") 57 | 58 | downscaled_img = img_for_quant.resize((down_w, down_h), Image.Resampling.NEAREST) 59 | downscaled_arr = np.array(downscaled_img, dtype=np.uint8) 60 | downscaled_float = downscaled_arr.astype(np.float64) / 255.0 61 | 62 | down_xyz = colour.sRGB_to_XYZ(downscaled_float) 63 | down_oklab = colour.XYZ_to_Oklab(down_xyz) 64 | pixels_down = down_oklab.reshape(-1, 3) 65 | 66 | bandwidth = estimate_bandwidth(pixels_down, quantile=quantile) 67 | ms = MeanShift(bin_seeding=True, bandwidth=bandwidth) 68 | ms.fit(pixels_down) 69 | 70 | centers_oklab = ms.cluster_centers_ 71 | n_clusters = len(centers_oklab) 72 | print(f" Found {n_clusters} cluster(s) in downscaled image.") 73 | 74 | original_arr = np.array(img_for_quant, dtype=np.uint8) 75 | original_float = original_arr.astype(np.float64) / 255.0 76 | original_xyz = colour.sRGB_to_XYZ(original_float) 77 | original_oklab = colour.XYZ_to_Oklab(original_xyz) 78 | orig_pixels = original_oklab.reshape(-1, 3) 79 | 80 | nn = NearestNeighbors(n_neighbors=1, algorithm='ball_tree').fit(centers_oklab) 81 | distances, indices = nn.kneighbors(orig_pixels) 82 | assigned_oklab = centers_oklab[indices.flatten()].reshape(orig_h, orig_w, 3) 83 | 84 | assigned_xyz = colour.Oklab_to_XYZ(assigned_oklab) 85 | assigned_srgb = colour.XYZ_to_sRGB(assigned_xyz) 86 | assigned_srgb = np.clip(assigned_srgb, 0, 1) 87 | quantized_full = (assigned_srgb * 255).astype(np.uint8) 88 | 89 | quantized_full_img = Image.fromarray(quantized_full, mode="RGB") 90 | 91 | if has_alpha and mask is not None: 92 | quantized_full_img = quantized_full_img.convert("RGBA") 93 | quantized_full_img.putalpha(Image.fromarray(mask, mode="L")) 94 | 95 | return quantized_full_img 96 | 97 | 98 | def get_palette_oklab(palette_img: Image.Image) -> np.ndarray: 99 | """ 100 | Convert the given palette image to Oklab color space and return an 101 | N x 3 numpy array of the unique colors in that palette. 102 | """ 103 | pal_rgb = palette_img.convert("RGB") 104 | pal_pixels = np.array(pal_rgb).reshape(-1, 3) 105 | unique_colors = np.unique(pal_pixels, axis=0) 106 | unique_float = unique_colors.astype(np.float64) / 255.0 107 | xyz = colour.sRGB_to_XYZ(unique_float) 108 | oklab = colour.XYZ_to_Oklab(xyz) 109 | return oklab 110 | 111 | 112 | def determine_som_grid(k: int) -> tuple: 113 | """ 114 | Determine an approximate square grid (rows, cols) for a SOM that 115 | has exactly k nodes total. 116 | """ 117 | rows = int(math.floor(math.sqrt(k))) 118 | cols = int(math.ceil(k / rows)) 119 | return rows, cols 120 | 121 | 122 | def som_quantize_with_palette(image: Image.Image, palette_img: Image.Image, iterations: int = 71) -> Image.Image: 123 | """ 124 | Quantize 'image' so that its final colors are adapted from the given palette. 125 | 126 | Steps: 127 | 1. Convert both the image and palette to Oklab. 128 | 2. Determine a SOM grid whose number of nodes is equal to the number of palette colors. 129 | 3. Initialize the SOM nodes with the palette colors. 130 | 4. Train the SOM on the image's pixel data (in Oklab) for a number of iterations. 131 | 5. For each image pixel, find its best matching unit (BMU) in the SOM. 132 | 6. Optionally, snap each BMU’s weight vector to the nearest original palette color. 133 | 7. Convert the quantized image back to sRGB. 134 | 135 | This produces an image whose colors come from a slightly adapted version 136 | of your target palette. 137 | """ 138 | # --- Convert image to Oklab. 139 | img_rgb = image.convert("RGB") 140 | arr = np.array(img_rgb, dtype=np.uint8) 141 | float_arr = arr.astype(np.float64) / 255.0 142 | xyz_arr = colour.sRGB_to_XYZ(float_arr) 143 | oklab_arr = colour.XYZ_to_Oklab(xyz_arr) 144 | pixels = oklab_arr.reshape(-1, 3) 145 | 146 | # --- Quick check to make sure we're not trying to quantize a bajillion colors 147 | num_colors = len(palette_img.getcolors(16777216)) 148 | if num_colors > 256: 149 | palette_img = palette_img.quantize(colors=256, method=2, kmeans=256, dither=0).convert("RGB") 150 | 151 | # --- Convert palette to Oklab. 152 | palette_oklab = get_palette_oklab(palette_img) # shape (k, 3) 153 | k = len(palette_oklab) 154 | if k == 0: 155 | print("Warning: Palette image contains no colors!") 156 | return img_rgb 157 | print(f"Using a palette of {k} unique color(s).") 158 | 159 | # --- Determine SOM grid shape: use exactly k nodes. 160 | rows, cols = determine_som_grid(k) 161 | total_nodes = rows * cols 162 | print(f"Initializing SOM grid of size {rows}x{cols} (total {total_nodes} nodes).") 163 | 164 | # --- Initialize SOM with the palette colors. 165 | init_weights = np.zeros((rows, cols, 3)) 166 | for i in range(rows): 167 | for j in range(cols): 168 | idx = i * cols + j 169 | init_weights[i, j] = palette_oklab[idx % k] 170 | 171 | # --- Create and initialize the SOM. 172 | som = MiniSom(rows, cols, 3, sigma=0.22, learning_rate=0.2, random_seed=42) 173 | som._weights = init_weights.copy() 174 | 175 | # --- Train the SOM on the image's Oklab pixels. 176 | print("Training SOM...") 177 | som.train_random(pixels, iterations) 178 | 179 | # --- Snap each SOM node to the nearest original palette color. 180 | weights = som._weights.reshape(-1, 3) # shape (total_nodes, 3) 181 | nn_pal = NearestNeighbors(n_neighbors=1, algorithm='ball_tree').fit(palette_oklab) 182 | _, indices = nn_pal.kneighbors(weights) 183 | snapped_weights = palette_oklab[indices.flatten()].reshape(rows, cols, 3) 184 | 185 | # --- Quantize full-resolution image: assign each pixel to its BMU. 186 | H, W, _ = oklab_arr.shape 187 | quantized_oklab = np.zeros_like(oklab_arr) 188 | for i in range(H): 189 | for j in range(W): 190 | pixel = oklab_arr[i, j] 191 | bmu = som.winner(pixel) 192 | quantized_oklab[i, j] = snapped_weights[bmu] 193 | 194 | # --- Convert quantized Oklab image back to sRGB. 195 | quant_xyz = colour.Oklab_to_XYZ(quantized_oklab) 196 | quant_srgb = colour.XYZ_to_sRGB(quant_xyz) 197 | quant_srgb = np.clip(quant_srgb, 0, 1) 198 | out_arr = (quant_srgb * 255).astype(np.uint8) 199 | return Image.fromarray(out_arr, mode="RGB") 200 | 201 | 202 | def classify_rgb_pixel(pixel): 203 | """ 204 | 0 => black(0,0,0) 205 | 1 => white(255,255,255) 206 | 2 => magenta(255,0,255) 207 | 3 => everything else 208 | """ 209 | if pixel == (0, 0, 0): 210 | return 0 211 | elif pixel == (255, 255, 255): 212 | return 1 213 | elif pixel == (255, 0, 255): 214 | return 2 215 | else: 216 | return 3 217 | 218 | 219 | def blend_images_with_mask(image1, image2, mask): 220 | """ 221 | Blends two RGBA images using a single-channel (L) mask. 222 | """ 223 | if image1.size != mask.size or image2.size != mask.size: 224 | raise ValueError("All images (image1, image2, mask) must have the same dimensions.") 225 | 226 | arr1 = np.array(image1.convert("RGBA"), dtype=np.float32) 227 | arr2 = np.array(image2.convert("RGBA"), dtype=np.float32) 228 | mask_arr = np.array(mask.convert("L"), dtype=np.float32) / 255.0 229 | mask_arr = np.expand_dims(mask_arr, axis=-1) 230 | 231 | blended = arr1 * mask_arr + arr2 * (1 - mask_arr) 232 | blended = blended.clip(0, 255).astype(np.uint8) 233 | return Image.fromarray(blended, mode="RGBA") 234 | 235 | 236 | def tile_has_magenta(tile_rgb: Image.Image) -> bool: 237 | """Returns True if the tile has at least one (255, 0, 255) pixel.""" 238 | return (255, 0, 255) in tile_rgb.getdata() 239 | 240 | 241 | def add_noise_to_feather(mask: Image.Image, noise_level=10) -> Image.Image: 242 | """ 243 | Adds random small perturbations to the middle range of a feathered mask, 244 | to avoid harsh edges. 245 | """ 246 | arr = np.array(mask, dtype=np.int16) 247 | non_extremes = (arr > 0) & (arr < 255) 248 | noise = np.random.randint(-noise_level, noise_level + 1, arr.shape) * 2 249 | arr[non_extremes] += noise[non_extremes] 250 | arr = np.clip(arr, 0, 255).astype(np.uint8) 251 | return Image.fromarray(arr, mode="L") 252 | 253 | 254 | def determine_tile_size_from_master(master_mask_rgb: Image.Image): 255 | """ 256 | Tries to guess the tile size by scanning how far black(0) extends 257 | from the top-left corner horizontally and vertically. 258 | """ 259 | width, height = master_mask_rgb.size 260 | 261 | tile_width = 0 262 | for x in range(width): 263 | if classify_rgb_pixel(master_mask_rgb.getpixel((x, 0))) == 0: 264 | tile_width += 1 265 | else: 266 | break 267 | 268 | tile_height = 0 269 | for y in range(height): 270 | if classify_rgb_pixel(master_mask_rgb.getpixel((0, y))) == 0: 271 | tile_height += 1 272 | else: 273 | break 274 | 275 | return (tile_width, tile_height) 276 | 277 | --------------------------------------------------------------------------------