Skip to content

Instantly share code, notes, and snippets.

@GameEgg
Created June 29, 2025 13:51
Show Gist options
  • Select an option

  • Save GameEgg/c17b4b27270a1e643991ffce0814c639 to your computer and use it in GitHub Desktop.

Select an option

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
--#############################################################################
-- 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