Last active
September 16, 2025 06:31
-
-
Save GameEgg/3d8ad9b4622ddd551717cfa908022818 to your computer and use it in GitHub Desktop.
Aseprite Script - Pixel Art Unscaler (made with cursor)
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
| -- 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