├── LICENSE ├── README.md ├── __info.json ├── extension.lua ├── package.json └── scripts └── scaler.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 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 | # K-Centroid-Aseprite 2 | A new downsampling algorithm using k-means, implemented in Aseprite 3 | 4 | Download as .zip file, and use Aseprite's extension menu to add it. 5 | New script can be found under "Sprite" -> "K-Centroid Resize" 6 | 7 | Some image test examples, Original, Bilinear, Nearest Neighbor, K-Centroid 8 | ![1984BR-Full](https://user-images.githubusercontent.com/61034487/233877189-a60e6829-3555-46b6-be6d-0ab0c4230707.png) 9 | ![lena_color_512-Full](https://user-images.githubusercontent.com/61034487/233877195-951889b8-0e63-4b8d-a4a2-caf7d2c3c45c.png) 10 | ![peppers-Full](https://user-images.githubusercontent.com/61034487/233877200-4e63c601-09bd-4dc9-979d-f88e4899cdd6.png) 11 | ![SamuraiChamploo-Full](https://user-images.githubusercontent.com/61034487/233877205-8266fac9-7fa9-4168-b6b7-47793195cef8.png) 12 | -------------------------------------------------------------------------------- /__info.json: -------------------------------------------------------------------------------- 1 | {"installedFiles": ["extension.lua", "package.json", "scripts/", "scripts/scaler.lua"]} -------------------------------------------------------------------------------- /extension.lua: -------------------------------------------------------------------------------- 1 | local scaler = dofile("./scripts/scaler.lua") 2 | 3 | local dRatio = true 4 | 5 | function math.round(number) 6 | return math.floor(number + 0.5) 7 | end 8 | 9 | function init(plugin) 10 | plugin:newCommand{id = "kcentroid", 11 | title = "K-Centroid Resize", 12 | group = "sprite_size", 13 | onenabled = function() 14 | return app.activeSprite ~= nil 15 | end, 16 | onclick = function() 17 | local width = app.activeSprite.selection.bounds.width 18 | local height = app.activeSprite.selection.bounds.height 19 | if app.activeSprite.selection.bounds == Rectangle(0,0,0,0) then 20 | width = app.activeCel.bounds.width 21 | height = app.activeCel.bounds.height 22 | end 23 | if app.activeImage.colorMode == ColorMode.RGB then 24 | local dialog = Dialog("K-Centroid Resize") 25 | dialog 26 | :separator{ 27 | text = "Pixels:" 28 | } 29 | :number{ 30 | id = "width", 31 | label = "Width:", 32 | text = tostring(width), 33 | decimals = 0, 34 | focus = true, 35 | onchange = function() 36 | dialog:modify{ 37 | id = "widthp", 38 | text = tostring(math.min(100,(dialog.data.width/width)*100)) 39 | } 40 | if dialog.data.ratio then 41 | dialog:modify{ 42 | id = "heightp", 43 | text = tostring(math.min(100,dialog.data.widthp)) 44 | } 45 | dialog:modify{ 46 | id = "height", 47 | text = tostring(math.min(height,math.round((dialog.data.heightp/100)*height))) 48 | } 49 | end 50 | end 51 | } 52 | :number{ 53 | id = "widthp", 54 | text = tostring(100), 55 | decimals = 4, 56 | onchange = function() 57 | dialog:modify{ 58 | id = "width", 59 | text = tostring(math.min(width,math.round((dialog.data.widthp/100)*width))) 60 | } 61 | if dialog.data.ratio then 62 | dialog:modify{ 63 | id = "heightp", 64 | text = tostring(math.min(100,dialog.data.widthp)) 65 | } 66 | dialog:modify{ 67 | id = "height", 68 | text = tostring(math.min(height,math.round((dialog.data.heightp/100)*height))) 69 | } 70 | end 71 | end 72 | } 73 | :number{ 74 | id = "height", 75 | label = "Height:", 76 | text = tostring(height), 77 | decimals = 0, 78 | onchange = function() 79 | dialog:modify{ 80 | id = "heightp", 81 | text = tostring(math.min(100,(dialog.data.height/height)*100)) 82 | } 83 | if dialog.data.ratio then 84 | dialog:modify{ 85 | id = "widthp", 86 | text = tostring(math.min(100,dialog.data.heightp)) 87 | } 88 | dialog:modify{ 89 | id = "width", 90 | text = tostring(math.min(width,math.round((dialog.data.widthp/100)*width))) 91 | } 92 | end 93 | end 94 | } 95 | :number{ 96 | id = "heightp", 97 | text = tostring(100), 98 | decimals = 4, 99 | onchange = function() 100 | dialog:modify{ 101 | id = "height", 102 | text = tostring(math.min(height,math.round((dialog.data.heightp/100)*height))) 103 | } 104 | if dialog.data.ratio then 105 | dialog:modify{ 106 | id = "widthp", 107 | text = tostring(math.min(100,dialog.data.heightp)) 108 | } 109 | dialog:modify{ 110 | id = "width", 111 | text = tostring(math.min(width,math.round((dialog.data.widthp/100)*width))) 112 | } 113 | end 114 | end 115 | } 116 | :check{ 117 | id = "ratio", 118 | text = "Lock Ratio", 119 | selected = dRatio, 120 | onclick = function() 121 | if dialog.data.ratio then 122 | dialog:modify{ 123 | id = "heightp", 124 | text = tostring(math.min(100,dialog.data.widthp)) 125 | } 126 | dialog:modify{ 127 | id = "height", 128 | text = tostring(math.min(height,math.round((dialog.data.heightp/100)*height))) 129 | } 130 | dialog:modify{ 131 | id = "width", 132 | text = tostring(math.min(width,math.round((dialog.data.widthp/100)*width))) 133 | } 134 | end 135 | end 136 | } 137 | :separator{ 138 | text = "K-Means:" 139 | } 140 | :slider{ 141 | id = "centroids", 142 | label = "Centroids", 143 | min = 2, 144 | max = 16, 145 | value = 2, 146 | } 147 | :slider{ 148 | id = "accuracy", 149 | label = "Itterations", 150 | min = 1, 151 | max = 20, 152 | value = 3, 153 | } 154 | :separator() 155 | :button{ 156 | text = "OK", 157 | onclick = function() 158 | dialog:modify{ 159 | id = "width", 160 | text = tostring(math.min(width,dialog.data.width)) 161 | } 162 | dialog:modify{ 163 | id = "height", 164 | text = tostring(math.min(height,dialog.data.height)) 165 | } 166 | --nClock = os.clock() 167 | scaler:kCenter(dialog.data.width, dialog.data.height, dialog.data.centroids, dialog.data.accuracy) 168 | --print("Elapsed time is: " .. os.clock()-nClock) 169 | dRatio = dialog.data.ratio 170 | dialog:close() 171 | end 172 | } 173 | :button{text = "Cancel"} 174 | dialog:show{wait = false} 175 | end 176 | end 177 | } 178 | 179 | end 180 | 181 | function exit(plugin) 182 | end -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "K-Centroid", 3 | "displayName": "K-Centroid Downsampling", 4 | "description": "Clean downscaling for pixel art references.", 5 | "version": "1.0.0", 6 | "author": { 7 | "name": "Astropulse" 8 | }, 9 | "contributors": [], 10 | "publisher": "Astropulse", 11 | "license": "CC-BY-4.0", 12 | "categories": [ 13 | "Scripts" 14 | ], 15 | "contributes": { 16 | "scripts": [ 17 | { 18 | "path": "./extension.lua" 19 | } 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /scripts/scaler.lua: -------------------------------------------------------------------------------- 1 | local scaler = {} 2 | 3 | function scaler:kCenter(width, height, colors, accuracy) 4 | app.transaction( 5 | function() 6 | local image = app.activeCel.image 7 | cel = app.activeSprite:newCel(app.activeSprite:newLayer(), app.activeFrame) 8 | cel.layer.name = "K-Tiles" 9 | 10 | cel2 = app.activeSprite:newCel(app.activeSprite:newLayer(), app.activeFrame) 11 | cel2.layer.name = "K-Centroid" 12 | 13 | local tiles = {} 14 | local newImg = Image(image.width, image.height) 15 | local newImg2 = Image(width, height) 16 | 17 | local wFactor = image.width/width 18 | local hFactor = image.height/height 19 | 20 | -- Create a table of image tiles and colors 21 | for x = 0, width - 1 do 22 | for y = 0, height - 1 do 23 | tiles[#tiles+1] = kMeans(Image(image, Rectangle(x*wFactor, y*hFactor, wFactor, hFactor)), colors, accuracy) 24 | 25 | newImg:drawImage(tiles[#tiles][1], Point(x*wFactor, y*hFactor)) 26 | newImg2:drawPixel(x, y, tiles[#tiles][2]) 27 | end 28 | end 29 | cel.image = newImg 30 | cel2.image = newImg2 31 | app.refresh() 32 | end) 33 | end 34 | 35 | function kMeans(image, k, accuracy) 36 | 37 | -- Lock the random seed for consistent results 38 | math.randomseed(1) 39 | 40 | -- Convert the pixel data to a table of RGB values 41 | local pixels = {} 42 | pixels.size = image.width * image.height 43 | for i = 1, pixels.size do 44 | local pixel = image:getPixel((i - 1) % image.width, math.floor((i - 1) / image.width)) 45 | pixels[i] = {r = app.pixelColor.rgbaR(pixel), g = app.pixelColor.rgbaG(pixel), b = app.pixelColor.rgbaB(pixel)} 46 | end 47 | 48 | -- Initialize the centroids randomly 49 | local centroids = {} 50 | for i = 1, k do 51 | table.insert(centroids, pixels[math.random(#pixels)]) 52 | end 53 | 54 | -- Iterate untill accuracy is exceeded 55 | for iter = 1, accuracy do 56 | -- Assign each pixel to its nearest centroid 57 | local clusters = {} 58 | for i, pixel in ipairs(pixels) do 59 | local min_dist = math.huge 60 | local nearest_centroid 61 | for j, centroid in ipairs(centroids) do 62 | local dist = distance(pixel, centroid) 63 | if dist < min_dist then 64 | min_dist = dist 65 | nearest_centroid = centroid 66 | end 67 | end 68 | if not clusters[nearest_centroid] then 69 | clusters[nearest_centroid] = {} 70 | end 71 | table.insert(clusters[nearest_centroid], pixel) 72 | end 73 | 74 | -- Recalculate the centroids 75 | local new_centroids = {} 76 | for centroid, cluster in pairs(clusters) do 77 | local sum_r, sum_g, sum_b = 0, 0, 0 78 | for i, pixel in ipairs(cluster) do 79 | sum_r = sum_r + pixel.r 80 | sum_g = sum_g + pixel.g 81 | sum_b = sum_b + pixel.b 82 | end 83 | local num_pixels = #cluster 84 | table.insert(new_centroids, {r = sum_r / num_pixels, g = sum_g / num_pixels, b = sum_b / num_pixels, count = num_pixels}) 85 | end 86 | 87 | centroids = new_centroids 88 | end 89 | 90 | -- Find the largest centroid 91 | local biggest_centroid = centroids[1] 92 | for n, centroid in pairs(centroids) do 93 | if centroid.count > biggest_centroid.count then 94 | biggest_centroid = centroid 95 | end 96 | end 97 | 98 | -- Replace each pixel with the nearest centroid 99 | for y = 0, image.height - 1 do 100 | for x = 0, image.width - 1 do 101 | local pixel = image:getPixel(x, y) 102 | local pixel = {r = app.pixelColor.rgbaR(pixel), g = app.pixelColor.rgbaG(pixel), b = app.pixelColor.rgbaB(pixel)} 103 | local min_dist = math.huge 104 | local nearest_centroid 105 | for i, centroid in ipairs(centroids) do 106 | local dist = distance(pixel, centroid) 107 | if dist < min_dist then 108 | min_dist = dist 109 | nearest_centroid = centroid 110 | end 111 | end 112 | image:drawPixel(x, y, app.pixelColor.rgba(nearest_centroid.r, nearest_centroid.g, 113 | nearest_centroid.b, 255)) 114 | end 115 | end 116 | 117 | -- Return a table with a tile image and a color 118 | return {image, app.pixelColor.rgba(biggest_centroid.r, biggest_centroid.g, biggest_centroid.b, 255)} 119 | end 120 | 121 | -- Define a function to calculate the Euclidean distance between two colors 122 | function distance(color1, color2) 123 | local dr, dg, db = color1.r - color2.r, color1.g - color2.g, color1.b - color2.b 124 | return dr * dr + dg * dg + db * db 125 | end 126 | 127 | return scaler --------------------------------------------------------------------------------