Skip to content

Instantly share code, notes, and snippets.

@GameEgg
Last active September 16, 2025 06:31
Show Gist options
  • Select an option

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

Select an option

Save GameEgg/3d8ad9b4622ddd551717cfa908022818 to your computer and use it in GitHub Desktop.
Aseprite Script - Pixel Art Unscaler (made with cursor)
-- Pixel Unscale (Non-integer scaled pixel-art → crisp original grid)
-- No palette dependency; processes as RGBA.
-- Place this file in Aseprite scripts folder and run via Scripts menu.
local ColorMode = ColorMode
local pc = app.pixelColor
local function clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
return v
end
local function lumaFromRGBA(c)
local r = pc.rgbaR(c)
local g = pc.rgbaG(c)
local b = pc.rgbaB(c)
return 0.299 * r + 0.587 * g + 0.114 * b
end
-- Build 1-based luma buffer for easier length(#) handling
local function buildLumaBuffer(img)
local w, h = img.width, img.height
local L = {}
for y = 1, h do
local row = {}
for x = 1, w do
row[x] = lumaFromRGBA(img:getPixel(x - 1, y - 1))
end
L[y] = row
end
return L
end
-- Project gradient magnitudes onto axes → Sx (per column), Sy (per row)
local function projectGradientSignals(img)
local w, h = img.width, img.height
local L = buildLumaBuffer(img)
local Sx, Sy = {}, {}
for x = 1, w do Sx[x] = 0 end
for y = 1, h do Sy[y] = 0 end
for y = 2, h - 1 do
for x = 2, w - 1 do
local gx = math.abs(L[y][x + 1] - L[y][x - 1])
local gy = math.abs(L[y + 1][x] - L[y - 1][x])
Sx[x] = Sx[x] + gx
Sy[y] = Sy[y] + gy
end
end
return Sx, Sy
end
local function autocorrTopCandidate(S, minLag, maxLag)
local n = #S
minLag = clamp(minLag, 2, math.max(2, math.floor(n / 2)))
maxLag = clamp(maxLag, minLag, math.max(minLag, math.floor(n / 2)))
local bestLag, bestScore = nil, -math.huge
for lag = minLag, maxLag do
local sum = 0
for i = 1, n - lag do
sum = sum + (S[i] or 0) * (S[i + lag] or 0)
end
if sum > bestScore then bestScore, bestLag = sum, lag end
end
return bestLag, bestScore
end
-- Simple non-maximum suppression peak detector on 1D signal
local function detectPeaks1D(S)
local n = #S
local peaks = {}
-- robust threshold: median absolute deviation proxy via percentile
local vals = {}
for i = 1, n do vals[i] = S[i] or 0 end
table.sort(vals)
local p90 = vals[math.max(1, math.floor(0.9 * n))] or 0
local thr = p90 * 0.25
for i = 2, n - 1 do
local a, b, c = S[i - 1] or 0, S[i] or 0, S[i + 1] or 0
if b > a and b >= c and b > thr then
table.insert(peaks, i)
end
end
return peaks
end
-- Phase fitting: choose phase in [0,P) minimizing squared distances from grid lines to nearest peaks.
local function fitPhaseFromPeaks(peaks, P, n)
if not P or P <= 0 or #peaks == 0 then return 0 end
-- search with fine step, then refine
local step = math.max(0.05, P / 64)
local bestPhase, bestCost = 0, math.huge
local function costForPhase(ph)
local cost = 0
-- grid line positions from ph, allow boundary clipping by ignoring lines farther than P/2 from any peak near edges
local k = 0
while true do
local pos = ph + k * P
if pos > n then break end
if pos >= 0 then
-- find nearest peak index via local search around expected position
local nearestDist = math.huge
-- binary search would be nice; linear scan acceptable as peaks are sparse
for _, pk in ipairs(peaks) do
local d = math.abs(pk - pos)
if d < nearestDist then nearestDist = d end
end
-- near image bounds, tolerate half period without penalizing hard
local boundTol = P * 0.5
if (pos < boundTol) or (n - pos < boundTol) then
nearestDist = math.max(0, nearestDist - boundTol * 0.5)
end
cost = cost + nearestDist * nearestDist
end
k = k + 1
end
return cost
end
for ph = 0, P, step do
local c = costForPhase(ph)
if c < bestCost then bestCost, bestPhase = c, ph end
end
-- small parabolic refine around bestPhase
local function refine(ph)
local c0 = costForPhase(ph - step)
local c1 = costForPhase(ph)
local c2 = costForPhase(ph + step)
local x0, x1, x2 = ph - step, ph, ph + step
local denom = (x0 - x1) * (x0 - x2) * (x1 - x2)
if denom == 0 then return ph end
local A = (x2 * (c1 - c0) + x1 * (c0 - c2) + x0 * (c2 - c1)) / denom
local B = (x2 * x2 * (c0 - c1) + x1 * x1 * (c2 - c0) + x0 * x0 * (c1 - c2)) / denom
if A == 0 then return ph end
local vx = -B / (2 * A)
if vx < 0 or vx > P then return ph end
return vx
end
bestPhase = refine(bestPhase)
return bestPhase % P
end
local function estimateGridParams(S)
local n = #S
local P = autocorrTopCandidate(S, 2, math.max(2, math.floor(n / 2)))
if not P then return nil, nil end
local peaks = detectPeaks1D(S)
local phase = fitPhaseFromPeaks(peaks, P, n)
return P, phase
end
local function computeOutputSizeClipped(n, P, phase)
-- Count number of cells that fit between first and last grid lines allowing partial clipping at borders.
if not P or P <= 0 then return nil end
local first = phase
while first < 0 do first = first + P end
while first >= P do first = first - P end
-- position of first interior boundary >= 0
if first > 0 then first = first end
local last = phase
while last < n do last = last + P end
last = last - P
local cells = math.floor((n - first) / P + 0.0001)
return math.max(1, cells)
end
local function cellBox(ox, oy, Px, Py, i, j, mx, my)
local x0 = ox + i * Px
local x1 = ox + (i + 1) * Px
local y0 = oy + j * Py
local y1 = oy + (j + 1) * Py
local ix0 = math.ceil(x0 + mx)
local iy0 = math.ceil(y0 + my)
local ix1 = math.floor(x1 - mx)
local iy1 = math.floor(y1 - my)
return ix0, iy0, ix1, iy1
end
local function chooseCellColorRGB(img, ix0, iy0, ix1, iy1, alphaThreshold)
local w, h = img.width, img.height
if ix0 > ix1 or iy0 > iy1 then
local cx = clamp(math.floor((ix0 + ix1) / 2), 0, w - 1)
local cy = clamp(math.floor((iy0 + iy1) / 2), 0, h - 1)
return img:getPixel(cx, cy)
end
ix0 = clamp(ix0, 0, w - 1)
ix1 = clamp(ix1, 0, w - 1)
iy0 = clamp(iy0, 0, h - 1)
iy1 = clamp(iy1, 0, h - 1)
local opaqueCount, totalCount = 0, 0
local buckets = {}
local sums = {}
local bestAlpha, bestAlphaColor = -1, pc.rgba(0, 0, 0, 0)
for y = iy0, iy1 do
for x = ix0, ix1 do
totalCount = totalCount + 1
local c = img:getPixel(x, y)
local a = pc.rgbaA(c)
if a > bestAlpha then bestAlpha, bestAlphaColor = a, c end
if a / 255.0 >= alphaThreshold then
opaqueCount = opaqueCount + 1
local r = pc.rgbaR(c)
local g = pc.rgbaG(c)
local b = pc.rgbaB(c)
local r5 = math.floor(r / 8)
local g5 = math.floor(g / 8)
local b5 = math.floor(b / 8)
local key = r5 * 2048 + g5 * 64 + b5
local cnt = buckets[key] or 0
buckets[key] = cnt + 1
local acc = sums[key]
if not acc then acc = {0, 0, 0, 0} end
acc[1] = acc[1] + r
acc[2] = acc[2] + g
acc[3] = acc[3] + b
acc[4] = acc[4] + a
sums[key] = acc
end
end
end
if opaqueCount == 0 or opaqueCount / math.max(1, totalCount) < 0.1 then
return bestAlphaColor
end
local bestKey, bestCnt = nil, -1
for key, cnt in pairs(buckets) do
if cnt > bestCnt then bestCnt, bestKey = cnt, key end
end
local acc = sums[bestKey]
local r = math.floor(acc[1] / bestCnt + 0.5)
local g = math.floor(acc[2] / bestCnt + 0.5)
local b = math.floor(acc[3] / bestCnt + 0.5)
local a = math.floor(acc[4] / bestCnt + 0.5)
return pc.rgba(clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255), clamp(a, 0, 255))
end
local function runUnscale(opts)
local spr = app.activeSprite
local cel = app.activeCel
if not spr or not cel then
app.alert("활성 스프라이트/셀을 찾을 수 없습니다.")
return
end
local src = cel.image:clone()
local w, h = src.width, src.height
local Sx, Sy = projectGradientSignals(src)
local Px, phx = estimateGridParams(Sx)
local Py, phy = estimateGridParams(Sy)
if not Px or not Py or Px <= 1 or Py <= 1 then
app.alert("격자 추정에 실패했습니다. 대비가 높은 이미지에서 시도해보세요.")
return
end
-- Map phase samples (1D signal index) to pixel-space offset: index 1 → pixel 0
local ox = (phx or 0) - 1
local oy = (phy or 0) - 1
local outW = computeOutputSizeClipped(w, Px, phx)
local outH = computeOutputSizeClipped(h, Py, phy)
if not outW or not outH or outW <= 0 or outH <= 0 or outW > w or outH > h then
app.alert("출력 크기가 유효하지 않습니다.")
return
end
local mx = clamp(Px * opts.marginRatio, 0, 3)
local my = clamp(Py * opts.marginRatio, 0, 3)
local outImg = Image(outW, outH, ColorMode.RGB)
for j = 0, outH - 1 do
for i = 0, outW - 1 do
local ix0, iy0, ix1, iy1 = cellBox(ox, oy, Px, Py, i, j, mx, my)
if ix1 < ix0 or iy1 < iy0 then
iy0 = clamp(math.ceil(oy + j * Py), 0, h - 1)
iy1 = clamp(math.floor(oy + (j + 1) * Py), 0, h - 1)
ix0 = clamp(math.ceil(ox + i * Px), 0, w - 1)
ix1 = clamp(math.floor(ox + (i + 1) * Px), 0, w - 1)
end
local c = chooseCellColorRGB(src, ix0, iy0, ix1, iy1, opts.alphaThreshold)
outImg:putPixel(i, j, c)
end
end
local outSpr = Sprite(outW, outH, ColorMode.RGB)
outSpr.filename = (spr.filename or "") .. " (unscaled)"
local outLayer = outSpr.layers[1]
local frame = outSpr.frames[1]
outSpr:newCel(outLayer, frame, outImg, Point(0, 0))
end
local function showDialog()
local dlg = Dialog{ title = "Pixel Unscale" }
dlg:slider{ id = "edge", label = "Edge ignore %", min = 0, max = 40, value = 15 }
dlg:button{ id = "run", text = "Run", focus = true, onclick = function()
local edgePercent = dlg.data.edge or 15
local marginRatio = clamp(edgePercent / 100.0, 0, 0.4)
local alphaThreshold = 0.01
runUnscale({ marginRatio = marginRatio, alphaThreshold = alphaThreshold })
end }
dlg:button{ id = "close", text = "Close" }
dlg:show{ wait = false }
end
showDialog()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment