Created
June 29, 2025 13:51
-
-
Save GameEgg/c17b4b27270a1e643991ffce0814c639 to your computer and use it in GitHub Desktop.
Aseprite Color Simplification Script - OKLAB-based perceptual color merging tool with real-time preview for reducing palette complexity by grouping similar colors
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --############################################################################# | |
| -- Color Difference Based Noise Removal - OKLAB Color Space | |
| --############################################################################# | |
| local MAX_MERGE = 2000 | |
| local RED_HIGHLIGHT_COLOR = app.pixelColor.rgba(255, 60, 60, 255) | |
| local BLUE_HIGHLIGHT_COLOR = app.pixelColor.rgba(60, 60, 255, 255) | |
| -- 색상 차이 기반 노이즈 제거 임계값 설정 | |
| local DEFAULT_COLOR_THRESHOLD = 0.05 -- 기본 색상 차이 임계값 (OKLAB 공간에서) | |
| local DEFAULT_SLIDER_VALUE = 1 -- 슬라이더 기본값 (1-100, 내부적으로 0.001-0.1로 변환) | |
| local spr = app.activeSprite | |
| if not spr then app.alert("스프라이트를 먼저 열어 주세요."); return end | |
| local pc = app.pixelColor | |
| local rgba, rgbaR, rgbaG, rgbaB, rgbaA = pc.rgba, pc.rgbaR, pc.rgbaG, pc.rgbaB, pc.rgbaA | |
| local ipairs, pairs, min, max, floor, huge = ipairs, pairs, math.min, math.max, math.floor, math.huge | |
| local lastCmap, lastHighlightMap = nil, nil | |
| local origMode, origPal, origImgs | |
| local closedByButton = false | |
| local totalPixelCount = 0 | |
| --============================================================================ | |
| -- OKLAB Color Space Conversion | |
| --============================================================================ | |
| local function srgb_to_linear(c) | |
| c = c / 255.0 | |
| if c <= 0.04045 then return c / 12.92 | |
| else return ((c + 0.055) / 1.055) ^ 2.4 end | |
| end | |
| local function linear_to_srgb(l) | |
| if l <= 0.0031308 then l = l * 12.92 | |
| else l = 1.055 * (l ^ (1.0/2.4)) - 0.055 end | |
| return min(255, max(0, floor(l * 255 + 0.5))) | |
| end | |
| local function sRGB_to_OKLAB(r, g, b) | |
| local l, m, s = srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b) | |
| local l_ = 0.4122214708 * l + 0.5363325363 * m + 0.0514459929 * s | |
| local m_ = 0.2119034982 * l + 0.6806995451 * m + 0.1073969566 * s | |
| local s_ = 0.0883024619 * l + 0.2817188376 * m + 0.6299787005 * s | |
| l_, m_, s_ = l_ ^ (1/3), m_ ^ (1/3), s_ ^ (1/3) | |
| local okl_L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ | |
| local okl_a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ | |
| local okl_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ | |
| return okl_L, okl_a, okl_b | |
| end | |
| local function OKLAB_to_sRGB(L, a, b) | |
| local l_ = L + 0.3963377774 * a + 0.2158037573 * b | |
| local m_ = L - 0.1055613458 * a - 0.0638541728 * b | |
| local s_ = L - 0.0894841775 * a - 1.2914855480 * b | |
| l_, m_, s_ = l_ ^ 3, m_ ^ 3, s_ ^ 3 | |
| local l = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_ | |
| local m = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_ | |
| local s = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_ | |
| return linear_to_srgb(l), linear_to_srgb(m), linear_to_srgb(s) | |
| end | |
| local function backupCurrentState() | |
| origMode = spr.colorMode | |
| origPal = nil | |
| if spr.colorMode == ColorMode.INDEXED and spr.palettes[1] then | |
| if spr.palettes[1].clone then origPal = spr.palettes[1]:clone() | |
| else origPal = Palette(spr.palettes[1]) end | |
| end | |
| origImgs = {} | |
| local currentFrame = app.activeFrame | |
| for _, cel in ipairs(spr.cels) do | |
| if cel.frame == currentFrame then | |
| origImgs[cel] = Image(cel.image) | |
| end | |
| end | |
| end | |
| local function restoreOriginal() | |
| for cel, img in pairs(origImgs) do | |
| if cel.image then cel.image:drawImage(img, 0, 0) end | |
| end | |
| if origPal then spr:setPalette(origPal) end | |
| if spr.colorMode ~= origMode then | |
| app.command.ChangePixelFormat{ | |
| format = (origMode == ColorMode.RGB and "rgb") | |
| or (origMode == ColorMode.RGBA and "rgba") | |
| or "indexed" | |
| } | |
| end | |
| app.refresh() | |
| end | |
| backupCurrentState() | |
| local function collectColors() | |
| local colorData = {} | |
| local currentFrame = app.activeFrame | |
| local selection = spr.selection | |
| local hasSelection = not selection.isEmpty | |
| totalPixelCount = 0 | |
| for _, cel in ipairs(spr.cels) do | |
| if cel.frame == currentFrame then | |
| local img = cel.image | |
| for it in img:pixels() do | |
| local x, y = it.x, it.y | |
| if not hasSelection or selection:contains(x + cel.position.x, y + cel.position.y) then | |
| local v = it() | |
| totalPixelCount = totalPixelCount + 1 | |
| if not colorData[v] then | |
| local r, g, b, a = rgbaR(v), rgbaG(v), rgbaB(v), rgbaA(v) | |
| local L, okl_a, okl_b = sRGB_to_OKLAB(r, g, b) | |
| colorData[v] = { | |
| v=v, r=r, g=g, b=b, a=a, count=1, | |
| okl_L=L, okl_a=okl_a, okl_b=okl_b | |
| } | |
| else | |
| colorData[v].count = colorData[v].count + 1 | |
| end | |
| end | |
| end | |
| end | |
| end | |
| local list = {} | |
| for _, data in pairs(colorData) do list[#list+1] = data end | |
| return list | |
| end | |
| -- 슬라이더 값을 실제 임계값으로 변환하는 함수 | |
| local function sliderToThreshold(sliderValue) | |
| -- 슬라이더 값 1-100을 0.001-0.1로 매핑 (더 민감한 범위) | |
| return sliderValue * 0.001 | |
| end | |
| -- 임계값을 슬라이더 값으로 변환하는 함수 | |
| local function thresholdToSlider(threshold) | |
| -- 0.001-0.1을 1-100으로 매핑 | |
| return math.floor(threshold / 0.001 + 0.5) | |
| end | |
| local function oklabDistance2(c1, c2) | |
| if (c1.a == 0 and c2.a ~= 0) or (c1.a ~= 0 and c2.a == 0) then return huge end | |
| local dL = c1.okl_L - c2.okl_L | |
| local da = c1.okl_a - c2.okl_a | |
| local db = c1.okl_b - c2.okl_b | |
| local d_alpha = (c1.a - c2.a) / 255.0 | |
| local alpha_weight = 0.5 | |
| return dL*dL + da*da + db*db + (d_alpha*alpha_weight)^2 | |
| end | |
| --============================================================================ | |
| -- 개선된 색상 차이 기반 노이즈 제거 | |
| --============================================================================ | |
| -- 두 색상 간의 병합 가능성을 확인 | |
| local function canMergeColors(color1, color2, threshold) | |
| local dist = math.sqrt(oklabDistance2(color1, color2)) | |
| return dist < threshold | |
| end | |
| -- 색상들을 그룹으로 묶어서 병합 | |
| local function groupSimilarColors(colors, colorThreshold) | |
| if #colors <= 1 then | |
| return {}, colors | |
| end | |
| -- 사용 빈도순으로 정렬 (많이 사용되는 색상일수록 우선) | |
| table.sort(colors, function(a, b) return a.count > b.count end) | |
| local colorGroups = {} | |
| local assigned = {} | |
| -- 각 색상을 그룹에 할당 | |
| for i, color in ipairs(colors) do | |
| if not assigned[i] then | |
| local group = { color } | |
| assigned[i] = true | |
| -- 이 색상과 병합 가능한 다른 색상들을 찾아서 같은 그룹에 추가 | |
| for j = i + 1, #colors do | |
| if not assigned[j] and canMergeColors(color, colors[j], colorThreshold) then | |
| table.insert(group, colors[j]) | |
| assigned[j] = true | |
| end | |
| end | |
| table.insert(colorGroups, group) | |
| end | |
| end | |
| return colorGroups | |
| end | |
| -- 그룹 내에서 대표 색상 선택 (가장 많이 사용되는 색상) | |
| local function selectRepresentativeColor(group) | |
| local representative = group[1] | |
| for i = 2, #group do | |
| if group[i].count > representative.count then | |
| representative = group[i] | |
| end | |
| end | |
| return representative | |
| end | |
| local function buildColorDistanceMap(colors, colorThreshold) | |
| if #colors <= 1 then | |
| return { cmap = {}, highlightMap = {}, noiseCount = 0, mainCount = #colors } | |
| end | |
| local colorGroups = groupSimilarColors(colors, colorThreshold) | |
| local cmap = {} | |
| local highlightMap = {} | |
| local noiseCount = 0 | |
| local representatives = {} | |
| -- 각 그룹에서 대표 색상을 선택하고 나머지를 병합 | |
| for _, group in ipairs(colorGroups) do | |
| if #group > 1 then | |
| local representative = selectRepresentativeColor(group) | |
| table.insert(representatives, representative) | |
| -- 그룹 내의 다른 색상들을 대표 색상으로 병합 | |
| for _, color in ipairs(group) do | |
| if color.v ~= representative.v then | |
| cmap[color.v] = representative.v | |
| highlightMap[color.v] = RED_HIGHLIGHT_COLOR | |
| noiseCount = noiseCount + 1 | |
| end | |
| end | |
| else | |
| -- 단독 색상은 그대로 유지 | |
| table.insert(representatives, group[1]) | |
| end | |
| end | |
| -- 대표 색상들은 하이라이트하지 않음 (원본 색상 유지) | |
| return { | |
| cmap = cmap, | |
| highlightMap = highlightMap, | |
| noiseCount = noiseCount, | |
| mainCount = #representatives | |
| } | |
| end | |
| --============================================================================ | |
| -- K-means 기반 노이즈 제거 (대안 방법) | |
| --============================================================================ | |
| local function kmeansNoiseRemoval(colors, targetColors) | |
| if #colors <= targetColors or targetColors <= 0 then | |
| return { cmap = {}, highlightMap = {}, noiseCount = 0, mainCount = #colors } | |
| end | |
| -- 먼저 주요 색상들로 클러스터 중심 설정 | |
| table.sort(colors, function(a, b) return a.count > b.count end) | |
| local centroids = {} | |
| for i = 1, targetColors do | |
| if i <= #colors then | |
| centroids[i] = { | |
| okl_L = colors[i].okl_L, | |
| okl_a = colors[i].okl_a, | |
| okl_b = colors[i].okl_b, | |
| a = colors[i].a, | |
| originalColor = colors[i].v | |
| } | |
| end | |
| end | |
| -- 각 색상을 가장 가까운 중심에 할당 | |
| local assignments = {} | |
| local cmap = {} | |
| local highlightMap = {} | |
| for i = 1, #colors do | |
| local minDist = huge | |
| local bestCluster = 1 | |
| for c = 1, #centroids do | |
| local dist = oklabDistance2(colors[i], centroids[c]) | |
| if dist < minDist then | |
| minDist = dist | |
| bestCluster = c | |
| end | |
| end | |
| assignments[i] = bestCluster | |
| -- 원본 색상과 다른 클러스터에 할당된 경우만 매핑 (노이즈로 간주) | |
| if colors[i].v ~= centroids[bestCluster].originalColor then | |
| cmap[colors[i].v] = centroids[bestCluster].originalColor | |
| highlightMap[colors[i].v] = RED_HIGHLIGHT_COLOR | |
| end | |
| end | |
| -- 가장 큰 두 클러스터의 중심 색상을 파란색으로 하이라이트 | |
| local clusterSizes = {} | |
| for c = 1, #centroids do clusterSizes[c] = 0 end | |
| for i = 1, #colors do | |
| local cluster = assignments[i] | |
| clusterSizes[cluster] = clusterSizes[cluster] + colors[i].count | |
| end | |
| local largest1, largest2 = 1, 2 | |
| if #centroids >= 2 then | |
| if clusterSizes[2] > clusterSizes[1] then | |
| largest1, largest2 = 2, 1 | |
| end | |
| for c = 3, #clusterSizes do | |
| if clusterSizes[c] > clusterSizes[largest1] then | |
| largest2 = largest1 | |
| largest1 = c | |
| elseif clusterSizes[c] > clusterSizes[largest2] then | |
| largest2 = c | |
| end | |
| end | |
| highlightMap[centroids[largest1].originalColor] = BLUE_HIGHLIGHT_COLOR | |
| highlightMap[centroids[largest2].originalColor] = BLUE_HIGHLIGHT_COLOR | |
| end | |
| local noiseCount = 0 | |
| for _ in pairs(cmap) do noiseCount = noiseCount + 1 end | |
| return { | |
| cmap = cmap, | |
| highlightMap = highlightMap, | |
| noiseCount = noiseCount, | |
| mainCount = targetColors | |
| } | |
| end | |
| local function applyMap(cmap, highlight, highlightMap) | |
| if not cmap then return 0 end | |
| if spr.colorMode ~= ColorMode.RGBA then | |
| app.command.ChangePixelFormat{ format="rgba" } | |
| end | |
| local affected = 0 | |
| local currentFrame = app.activeFrame | |
| local selection = spr.selection | |
| local hasSelection = not selection.isEmpty | |
| for _, cel in ipairs(spr.cels) do | |
| if cel.frame == currentFrame then | |
| local img = cel.image | |
| for it in img:pixels() do | |
| local x, y = it.x, it.y | |
| if not hasSelection or selection:contains(x + cel.position.x, y + cel.position.y) then | |
| local v = it() | |
| if highlight and highlightMap[v] then | |
| -- 하이라이트 모드: 원본 색상 대신 하이라이트 색상 표시 | |
| it(highlightMap[v]) | |
| elseif cmap[v] and cmap[v] ~= v then | |
| -- 실제 적용 모드: 노이즈 색상을 대상 색상으로 변경 | |
| affected = affected + 1 | |
| it(cmap[v]) | |
| end | |
| end | |
| end | |
| end | |
| end | |
| app.refresh() | |
| return affected | |
| end | |
| -- GUI 구성 | |
| local dlg | |
| local initialColors = collectColors() | |
| local initialTotalColors = #initialColors | |
| local resultCache = {} | |
| local lastPreviewThreshold = -1 | |
| local lastHighlight = false | |
| local function preview(colorThreshold, highlight, data) | |
| colorThreshold = tonumber(colorThreshold) or DEFAULT_SLIDER_VALUE | |
| if colorThreshold < 1 then colorThreshold = 1 end | |
| if colorThreshold > 100 then colorThreshold = 100 end | |
| -- 슬라이더 값을 실제 임계값으로 변환 | |
| local actualThreshold = sliderToThreshold(colorThreshold) | |
| -- skip if nothing changed | |
| if colorThreshold == lastPreviewThreshold and highlight == lastHighlight then | |
| return | |
| end | |
| lastPreviewThreshold = colorThreshold | |
| lastHighlight = highlight | |
| data.colorThreshold = colorThreshold | |
| -- restore original once | |
| restoreOriginal() | |
| -- cache results | |
| local cacheKey = string.format("%.3f", actualThreshold) | |
| local result = resultCache[cacheKey] | |
| if not result then | |
| result = buildColorDistanceMap(initialColors, actualThreshold) | |
| resultCache[cacheKey] = result | |
| end | |
| lastCmap, lastHighlightMap = result.cmap, result.highlightMap | |
| -- apply and repaint | |
| local affected = applyMap(lastCmap, highlight, lastHighlightMap) | |
| local finalColors = result.mainCount | |
| -- Update dialog labels | |
| dlg:modify{ id = "finalColorsLabel", text = string.format("Final Colors: %d", finalColors) } | |
| dlg:modify{ id = "noiseColorsLabel", text = string.format("Noise Colors: %d", result.noiseCount) } | |
| dlg:modify{ id = "affectedPixelsLabel", text = "Affected Pixels: " .. affected } | |
| dlg:repaint() | |
| end | |
| local function applyAndReset(data) | |
| if not lastCmap then return end | |
| -- 하이라이트 모드를 끄고 실제 변경사항만 적용 | |
| restoreOriginal() | |
| applyMap(lastCmap, false, lastHighlightMap) -- highlight = false | |
| -- clear cache so future previews start fresh | |
| resultCache = {} | |
| lastPreviewThreshold = -1 | |
| -- backup the new state | |
| backupCurrentState() | |
| -- collect colors from the new state | |
| initialColors = collectColors() | |
| initialTotalColors = #initialColors | |
| lastCmap, lastHighlightMap = nil, nil | |
| -- reset dialog state | |
| data.colorThreshold = DEFAULT_SLIDER_VALUE | |
| dlg:modify{ id = "colorThreshold", value = DEFAULT_SLIDER_VALUE } | |
| dlg:modify{ id = "colorsLabel", text = string.format("Original Colors: %d", initialTotalColors) } | |
| dlg:modify{ id = "finalColorsLabel", text = string.format("Final Colors: %d", initialTotalColors) } | |
| dlg:modify{ id = "noiseColorsLabel", text = "Noise Colors: 0" } | |
| dlg:modify{ id = "affectedPixelsLabel", text = "Affected Pixels: 0" } | |
| end | |
| ------------------------------------------------------------------------[ GUI ] | |
| dlg = Dialog{ | |
| title = "Color Difference Based Noise Removal", | |
| onclose = function() | |
| if not closedByButton then | |
| restoreOriginal() | |
| end | |
| end | |
| } | |
| dlg:slider{ | |
| id = "colorThreshold", label = "Color Distance Threshold", value = DEFAULT_SLIDER_VALUE, min = 1, max = 100, | |
| onchange = function() | |
| if dlg.data.enablePreview then | |
| preview(tonumber(dlg.data.colorThreshold) or DEFAULT_SLIDER_VALUE, dlg.data.highlight, dlg.data) | |
| else | |
| -- 프리뷰가 비활성화된 경우 대략적인 예상값만 표시 | |
| dlg:modify{ id = "finalColorsLabel", text = "Final Colors: (Preview to see)" } | |
| end | |
| end | |
| } | |
| :newrow() | |
| :label{ id = "colorsLabel", text = string.format("Original Colors: %d", initialTotalColors) } | |
| :newrow() | |
| :label{ id = "finalColorsLabel", text = string.format("Final Colors: %d", initialTotalColors) } | |
| :newrow() | |
| :label{ id = "noiseColorsLabel", text = "Noise Colors: 0" } | |
| :newrow() | |
| :label{ id = "affectedPixelsLabel", text = "Affected Pixels: 0" } | |
| :check{ | |
| id = "enablePreview", text = "Enable Live Preview", selected = true, | |
| onclick = function() | |
| if dlg.data.enablePreview then | |
| lastPreviewThreshold = -1 | |
| preview(tonumber(dlg.data.colorThreshold) or DEFAULT_SLIDER_VALUE, dlg.data.highlight, dlg.data) | |
| else | |
| lastPreviewThreshold = -1 | |
| restoreOriginal() | |
| dlg:modify{ id = "affectedPixelsLabel", text = "Affected Pixels: 0" } | |
| end | |
| end | |
| } | |
| :check{ | |
| id = "highlight", text = "Highlight: Red=Will be removed", selected = false, | |
| onclick = function() | |
| if dlg.data.enablePreview then | |
| preview(tonumber(dlg.data.colorThreshold) or DEFAULT_SLIDER_VALUE, dlg.data.highlight, dlg.data) | |
| end | |
| end | |
| } | |
| :button{ id = "apply", text = "Apply & Continue", onclick = function() | |
| applyAndReset(dlg.data) | |
| end } | |
| :button{ id = "reset", text = "Reset All", onclick = function() | |
| resultCache = {} | |
| lastPreviewThreshold = -1 | |
| restoreOriginal() | |
| backupCurrentState() | |
| dlg.data.colorThreshold = DEFAULT_SLIDER_VALUE | |
| dlg:modify{ id = "colorThreshold", value = DEFAULT_SLIDER_VALUE } | |
| dlg:modify{ id = "colorsLabel", text = string.format("Original Colors: %d", initialTotalColors) } | |
| dlg:modify{ id = "finalColorsLabel", text = string.format("Final Colors: %d", initialTotalColors) } | |
| dlg:modify{ id = "noiseColorsLabel", text = "Noise Colors: 0" } | |
| dlg:modify{ id = "affectedPixelsLabel", text = "Affected Pixels: 0" } | |
| if dlg.data.enablePreview then | |
| preview(DEFAULT_SLIDER_VALUE, dlg.data.highlight, dlg.data) | |
| end | |
| end } | |
| :button{ | |
| id = "ok", text = "OK", | |
| onclick = function() | |
| -- 하이라이트 모드를 끄고 실제 변경사항만 적용 | |
| if lastCmap then | |
| restoreOriginal() | |
| applyMap(lastCmap, false, lastHighlightMap) -- highlight = false | |
| end | |
| closedByButton = true | |
| dlg:close() | |
| end | |
| } | |
| :button{ | |
| id = "cancel", text = "Cancel", | |
| onclick = function() | |
| restoreOriginal() | |
| closedByButton = true | |
| dlg:close() | |
| end | |
| } | |
| dlg:show{ wait = false } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment