├── README.md ├── install.py ├── palettes ├── NES.png ├── aap-64.png ├── atari-8-bit.png ├── commodore64.png ├── endesga-32.png ├── fantasy-24.png ├── microsoft-windows.png ├── nintendo-gameboy.png ├── pico-8.png └── slso8.png ├── requirements.txt └── scripts └── palettize.py /README.md: -------------------------------------------------------------------------------- 1 | # Palettize 2 | Palettize uses k-means to reduce the number of colors in an image. This script is specifically designed for use with [Retro Diffusion pixel art model](https://astropulse.gumroad.com/l/RetroDiffusionModel). 3 | 4 | Values can be changed in the "Scripts" dropdown menu. 5 | 6 | Examples of Palettize, as well as an [Aseprite](https://www.aseprite.org/) compatible version can be found [here](https://astropulse.gumroad.com/l/RetroDiffusion). 7 | 8 | ## Installation 9 | Copy this repo's URL (https://github.com/Astropulse/sd-palettize) into the "Install from URL" section of the Extensions tab of Automatic1111's webui. 10 | You will need to restart webui to install the required dependencies. 11 | 12 | ## Palette Credit 13 | ENDESGA 14 | Adigun A. Polack 15 | Luis Miguel Maldonado 16 | PICO-8 17 | Gabriel C. 18 | Nintendo 19 | Commodore 20 | Microsoft 21 | Atari -------------------------------------------------------------------------------- /install.py: -------------------------------------------------------------------------------- 1 | import launch 2 | import os 3 | 4 | req_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "requirements.txt") 5 | 6 | with open(req_file) as file: 7 | for lib in file: 8 | lib = lib.strip() 9 | urlparts = lib.split("#egg=") 10 | if len(urlparts) > 1: 11 | packname = urlparts[1] 12 | else: 13 | packname = "hitherdither" 14 | if not launch.is_installed(packname): 15 | launch.run_pip(f"install {lib}", f"sd-palettize requirement: {lib}") 16 | -------------------------------------------------------------------------------- /palettes/NES.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/sd-palettize/02a143b3cffade75ac0cd652db74d8353f078630/palettes/NES.png -------------------------------------------------------------------------------- /palettes/aap-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/sd-palettize/02a143b3cffade75ac0cd652db74d8353f078630/palettes/aap-64.png -------------------------------------------------------------------------------- /palettes/atari-8-bit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/sd-palettize/02a143b3cffade75ac0cd652db74d8353f078630/palettes/atari-8-bit.png -------------------------------------------------------------------------------- /palettes/commodore64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/sd-palettize/02a143b3cffade75ac0cd652db74d8353f078630/palettes/commodore64.png -------------------------------------------------------------------------------- /palettes/endesga-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/sd-palettize/02a143b3cffade75ac0cd652db74d8353f078630/palettes/endesga-32.png -------------------------------------------------------------------------------- /palettes/fantasy-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/sd-palettize/02a143b3cffade75ac0cd652db74d8353f078630/palettes/fantasy-24.png -------------------------------------------------------------------------------- /palettes/microsoft-windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/sd-palettize/02a143b3cffade75ac0cd652db74d8353f078630/palettes/microsoft-windows.png -------------------------------------------------------------------------------- /palettes/nintendo-gameboy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/sd-palettize/02a143b3cffade75ac0cd652db74d8353f078630/palettes/nintendo-gameboy.png -------------------------------------------------------------------------------- /palettes/pico-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/sd-palettize/02a143b3cffade75ac0cd652db74d8353f078630/palettes/pico-8.png -------------------------------------------------------------------------------- /palettes/slso8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Astropulse/sd-palettize/02a143b3cffade75ac0cd652db74d8353f078630/palettes/slso8.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | git+https://www.github.com/hbldh/hitherdither#egg=hitherdither 2 | -------------------------------------------------------------------------------- /scripts/palettize.py: -------------------------------------------------------------------------------- 1 | import modules.scripts as scripts 2 | import hitherdither 3 | import gradio as gr 4 | 5 | import cv2 6 | import numpy as np 7 | from PIL import Image 8 | import os, requests 9 | from io import BytesIO 10 | from itertools import product 11 | 12 | from modules import images 13 | from modules.processing import process_images 14 | from modules.ui import create_refresh_button 15 | from modules.shared import opts 16 | 17 | script_dir = scripts.basedir() 18 | pal_path='./extensions/sd-palettize/palettes/' 19 | 20 | def refreshPalettes(): 21 | palettes = ["None", "Automatic"] 22 | file_names = [fn for fn in sorted(os.listdir(pal_path)) if ".preview." not in fn] 23 | palettes.extend(file_names) 24 | return palettes 25 | 26 | def adjust_gamma(image, gamma=1.0): 27 | # Create a lookup table for the gamma function 28 | gamma_map = [255 * ((i / 255.0) ** (1.0 / gamma)) for i in range(256)] 29 | gamma_table = bytes([(int(x / 255.0 * 65535.0) >> 8) for x in gamma_map] * 3) 30 | 31 | # Apply the gamma correction using the lookup table 32 | return image.point(gamma_table) 33 | 34 | def kCentroid(image: Image, width: int, height: int, centroids: int): 35 | image = image.convert("RGB") 36 | downscaled = np.zeros((height, width, 3), dtype=np.uint8) 37 | wFactor = image.width/width 38 | hFactor = image.height/height 39 | for x, y in product(range(width), range(height)): 40 | tile = image.crop((x*wFactor, y*hFactor, (x*wFactor)+wFactor, (y*hFactor)+hFactor)).quantize(colors=centroids, method=1, kmeans=centroids).convert("RGB") 41 | color_counts = tile.getcolors() 42 | most_common_color = max(color_counts, key=lambda x: x[0])[1] 43 | downscaled[y, x, :] = most_common_color 44 | return Image.fromarray(downscaled, mode='RGB') 45 | 46 | def determine_best_k(image, max_k): 47 | # Convert the image to RGB mode 48 | image = image.convert("RGB") 49 | 50 | # Prepare arrays for distortion calculation 51 | pixels = np.array(image) 52 | pixel_indices = np.reshape(pixels, (-1, 3)) 53 | 54 | # Calculate distortion for different values of k 55 | distortions = [] 56 | for k in range(1, max_k + 1): 57 | quantized_image = image.quantize(colors=k, method=2, kmeans=k, dither=0) 58 | centroids = np.array(quantized_image.getpalette()[:k * 3]).reshape(-1, 3) 59 | 60 | # Calculate distortions 61 | distances = np.linalg.norm(pixel_indices[:, np.newaxis] - centroids, axis=2) 62 | min_distances = np.min(distances, axis=1) 63 | distortions.append(np.sum(min_distances ** 2)) 64 | 65 | # Calculate the rate of change of distortions 66 | rate_of_change = np.diff(distortions) / np.array(distortions[:-1]) 67 | 68 | # Find the elbow point (best k value) 69 | if len(rate_of_change) == 0: 70 | best_k = 2 71 | else: 72 | elbow_index = np.argmax(rate_of_change) + 1 73 | best_k = elbow_index + 2 74 | 75 | return best_k 76 | 77 | # Runs cv2 k_means quantization on the provided image with "k" color indexes 78 | def palettize(input, colors, palImg, dithering, strength): 79 | img = cv2.cvtColor(input, cv2.COLOR_BGR2RGB) 80 | img = Image.fromarray(img).convert("RGB") 81 | 82 | dithering += 1 83 | 84 | if palImg is not None: 85 | palImg = cv2.cvtColor(palImg, cv2.COLOR_BGR2RGB) 86 | palImg = Image.fromarray(palImg).convert("RGB") 87 | numColors = len(palImg.getcolors(16777216)) 88 | else: 89 | numColors = colors 90 | 91 | palette = [] 92 | 93 | threshold = (16*strength)/4 94 | 95 | if palImg is not None: 96 | 97 | numColors = len(palImg.getcolors(16777216)) 98 | 99 | if strength > 0: 100 | img = adjust_gamma(img, 1.0-(0.02*strength)) 101 | for i in palImg.getcolors(16777216): 102 | palette.append(i[1]) 103 | palette = hitherdither.palette.Palette(palette) 104 | img_indexed = hitherdither.ordered.bayer.bayer_dithering(img, palette, [threshold, threshold, threshold], order=2**dithering).convert('RGB') 105 | else: 106 | for i in palImg.getcolors(16777216): 107 | palette.append(i[1][0]) 108 | palette.append(i[1][1]) 109 | palette.append(i[1][2]) 110 | palImg = Image.new('P', (256, 1)) 111 | palImg.putpalette(palette) 112 | img_indexed = img.quantize(method=1, kmeans=numColors, palette=palImg, dither=0).convert('RGB') 113 | elif colors > 0: 114 | 115 | if strength > 0: 116 | img_indexed = img.quantize(colors=colors, method=1, kmeans=colors, dither=0).convert('RGB') 117 | img = adjust_gamma(img, 1.0-(0.03*strength)) 118 | for i in img_indexed.convert("RGB").getcolors(16777216): 119 | palette.append(i[1]) 120 | palette = hitherdither.palette.Palette(palette) 121 | img_indexed = hitherdither.ordered.bayer.bayer_dithering(img, palette, [threshold, threshold, threshold], order=2**dithering).convert('RGB') 122 | 123 | else: 124 | img_indexed = img.quantize(colors=colors, method=1, kmeans=colors, dither=0).convert('RGB') 125 | 126 | result = cv2.cvtColor(np.asarray(img_indexed), cv2.COLOR_RGB2BGR) 127 | return result 128 | 129 | def updatePreview(evt: gr.SelectData): 130 | if evt.index > 1: 131 | name, ext = os.path.splitext(evt.value) 132 | preview_filename=pal_path + name + ".preview" + ext 133 | if os.path.isfile(preview_filename): 134 | return Image.open(preview_filename) 135 | return None 136 | 137 | class Script(scripts.Script): 138 | def title(self): 139 | return "Palettize" 140 | def show(self, is_img2img): 141 | return True 142 | def ui(self, is_img2img): 143 | 144 | clusters = gr.Slider(minimum=2, maximum=128, step=1, label='Colors in palette', value=24) 145 | with gr.Row(): 146 | downscale = gr.Checkbox(label='Downscale before processing', value=True) 147 | original = gr.Checkbox(label='Show original images', value=False) 148 | with gr.Row(): 149 | upscale = gr.Checkbox(label='Save 1:1 pixel image', value=False) 150 | kcentroid = gr.Checkbox(label='Use K-Centroid algorithm for downscaling', value=True) 151 | with gr.Row(): 152 | scale = gr.Slider(minimum=2, maximum=32, step=1, label='Downscale factor', value=8) 153 | with gr.Row(): 154 | dither = gr.Dropdown(choices=["Bayer 2x2", "Bayer 4x4", "Bayer 8x8"], label="Matrix Size", value="Bayer 8x8", type="index") 155 | ditherStrength = gr.Slider(minimum=0, maximum=10, step=1, label='Dithering Strength', value=0) 156 | with gr.Row(): 157 | paletteDropdown = gr.Dropdown(choices=refreshPalettes(), label="Palette", value="None", type="value") 158 | create_refresh_button(paletteDropdown, refreshPalettes, lambda: {"choices": refreshPalettes()}, None) 159 | with gr.Row(): 160 | palettePreview = gr.Image(height=120,width=464,interactive=False,show_label=False,show_download_button=False) 161 | with gr.Row(): 162 | paletteURL = gr.Textbox(max_lines=1, placeholder="Image URL (example:https://lospec.com/palette-list/pear36-1x.png)", label="Palette URL") 163 | with gr.Row(): 164 | palette = gr.Image(label="Palette image") 165 | 166 | paletteDropdown.select(updatePreview,outputs=palettePreview) 167 | 168 | return [downscale, original, upscale, kcentroid, scale, paletteDropdown, paletteURL, palette, clusters, dither, ditherStrength] 169 | 170 | def run(self, p, downscale, original, upscale, kcentroid, scale, paletteDropdown, paletteURL, palette, clusters, dither, ditherStrength): 171 | 172 | if ditherStrength > 0: 173 | print(f'Palettizing output to {clusters} colors with order {2**(dither+1)} dithering...') 174 | else: 175 | print(f'Palettizing output to {clusters} colors...') 176 | 177 | if paletteDropdown != "None" and paletteDropdown != "Automatic": 178 | palette = cv2.cvtColor(cv2.imread(pal_path+paletteDropdown), cv2.COLOR_RGB2BGR) 179 | 180 | if paletteURL != "": 181 | try: 182 | palette = np.array(Image.open(BytesIO(requests.get(paletteURL).content)).convert("RGB")).astype(np.uint8) 183 | except: 184 | print("An error occured fetching image from URL") 185 | 186 | processed = process_images(p) 187 | 188 | generations = p.batch_size*p.n_iter 189 | 190 | grid = False 191 | 192 | if opts.return_grid and p.batch_size*p.n_iter > 1: 193 | generations += 1 194 | grid = True 195 | 196 | originalImgs = [] 197 | 198 | for i in range(generations): 199 | # Converts image from "Image" type to numpy array for cv2 200 | 201 | img = np.array(processed.images[i]).astype(np.uint8) 202 | 203 | if original: 204 | originalImgs.append(processed.images[i]) 205 | 206 | if downscale: 207 | if kcentroid: 208 | img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)).convert("RGB") 209 | img = cv2.cvtColor(np.asarray(kCentroid(img, int(img.width/scale), int(img.height/scale), 2)), cv2.COLOR_RGB2BGR) 210 | else: 211 | img = cv2.resize(img, (int(img.shape[1]/scale), int(img.shape[0]/scale)), interpolation = cv2.INTER_LINEAR) 212 | 213 | if paletteDropdown == "Automatic": 214 | palImg = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)).convert("RGB") 215 | best_k = determine_best_k(palImg, 64) 216 | palette = cv2.cvtColor(np.asarray(palImg.quantize(colors=best_k, method=1, kmeans=best_k, dither=0).convert('RGB')), cv2.COLOR_RGB2BGR) 217 | 218 | tempImg = palettize(img, clusters, palette, dither, ditherStrength) 219 | 220 | if downscale: 221 | img = cv2.resize(tempImg, (int(img.shape[1]*scale), int(img.shape[0]*scale)), interpolation = cv2.INTER_NEAREST) 222 | 223 | if not upscale: 224 | tempImg = img 225 | 226 | processed.images[i] = Image.fromarray(img) 227 | images.save_image(Image.fromarray(tempImg), p.outpath_samples, "palettized", processed.seed + i, processed.prompt, opts.samples_format, info=processed.info, p=p) 228 | 229 | if grid: 230 | processed.images[0] = images.image_grid(processed.images[1:generations], p.batch_size) 231 | 232 | if opts.grid_save: 233 | images.save_image(processed.images[0], p.outpath_grids, "palettized", prompt=p.prompt, seed=processed.seed, grid=True, p=p) 234 | 235 | if original: 236 | processed.images.extend(originalImgs) 237 | 238 | return processed 239 | --------------------------------------------------------------------------------