Skip to content

Instantly share code, notes, and snippets.

@GameEgg
Last active June 12, 2026 06:58
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 v3 (Quantizer Compare)
-- Quantization modes: kmeans / kmedoids / hybrid
--
-- UI: k_colors
--
-- Place in Aseprite scripts folder and run via Scripts menu.
local pc = app.pixelColor
local ColorMode = ColorMode
-- ---------------------------
-- Utils
-- ---------------------------
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 roundi(x)
if x >= 0 then return math.floor(x + 0.5) end
return math.ceil(x - 0.5)
end
local function luma_from_pixel(c)
local a = pc.rgbaA(c)
if a == 0 then return 0.0 end
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
local function rgba_key_rgb(r, g, b)
return r * 65536 + g * 256 + b -- <= 16777215 (safe int)
end
local function validate_dimensions(w, h)
if w <= 0 or h <= 0 then return false, "Image dimensions cannot be zero" end
if w > 10000 or h > 10000 then return false, "Image dimensions too large (max 10000x10000)" end
if w < 3 or h < 3 then return false, "Image too small (minimum 3x3)" end
return true, nil
end
local function profile_mean(profile)
if #profile == 0 then return 0.0 end
local s = 0.0
for i = 1, #profile do s = s + profile[i] end
return s / #profile
end
local function dist_sq_rgb(p, c)
local dr = p[1] - c[1]
local dg = p[2] - c[2]
local db = p[3] - c[3]
return dr * dr + dg * dg + db * db
end
-- ---------------------------
-- RNG (32-bit, safe in Aseprite Lua)
-- fixes: "number has no integer representation"
-- ---------------------------
local function _has_bit32()
return type(bit32) == "table"
end
local BXOR, BAND, LSHIFT, RSHIFT
if _has_bit32() then
BXOR = bit32.bxor
BAND = bit32.band
LSHIFT= bit32.lshift
RSHIFT= bit32.rshift
else
-- Lua 5.3+ native integer bit ops
BXOR = function(a,b) return a ~ b end
BAND = function(a,b) return a & b end
LSHIFT= function(a,b) return a << b end
RSHIFT= function(a,b) return a >> b end
end
local U32_MASK = 0xFFFFFFFF
local U32_DIV = 4294967296.0
local function make_rng32(seed)
local state = tonumber(seed or 42) or 42
state = math.floor(state)
state = BAND(state, U32_MASK)
if state == 0 then state = 0x6D2B79F5 end
local function next_u32()
-- xorshift32
state = BXOR(state, LSHIFT(state, 13)); state = BAND(state, U32_MASK)
state = BXOR(state, RSHIFT(state, 17)); state = BAND(state, U32_MASK)
state = BXOR(state, LSHIFT(state, 5)); state = BAND(state, U32_MASK)
return state
end
local function next_f()
return next_u32() / U32_DIV
end
return { next_u32 = next_u32, next_f = next_f }
end
-- ---------------------------
-- Config (Rust defaults)
-- ---------------------------
local function default_config()
return {
-- exposed
k_colors = 16,
k_seed = 42,
quantizer_mode = "kmeans", -- "kmeans" | "kmedoids" | "hybrid"
-- rust defaults (internal)
max_kmeans_iterations = 15,
peak_threshold_multiplier = 0.2,
peak_distance_filter = 4,
walker_search_window_ratio = 0.35,
walker_min_search_window = 2.0,
walker_strength_threshold = 0.5,
min_cuts_per_axis = 4,
fallback_target_segments = 64,
max_step_ratio = 1.8,
-- v3 quantize extras (internal)
kd_leaf_factor = 8, -- target leaves ~= k_colors * factor
kd_min_leaves = 32,
kd_max_leaves = 512,
kd_max_depth = 10,
kmedoids_iterations = 3,
}
end
-- ---------------------------
-- Quantize v3 (KD-Tree + mode-select clustering)
-- ---------------------------
local function build_unique_rgb_histogram(img)
local w, h = img.width, img.height
local hist = {}
local rgb_list = {}
for y = 0, h - 1 do
for x = 0, w - 1 do
local c = img:getPixel(x, y)
local a = pc.rgbaA(c)
if a ~= 0 then
local r = pc.rgbaR(c)
local g = pc.rgbaG(c)
local b = pc.rgbaB(c)
local key = rgba_key_rgb(r, g, b)
hist[key] = (hist[key] or 0) + 1
end
end
end
for key, cnt in pairs(hist) do
local r = math.floor(key / 65536) % 256
local g = math.floor(key / 256) % 256
local b = key % 256
rgb_list[#rgb_list + 1] = { r, g, b, cnt }
end
return rgb_list
end
local function ceil_log2(v)
if v <= 1 then return 0 end
local p, x = 0, 1
while x < v do
x = x * 2
p = p + 1
end
return p
end
local function build_kd_tree_weighted(points, depth, axis, leaves)
local n = #points
if n == 0 then return nil end
if depth <= 0 or n == 1 then
local sw, sr, sg, sb = 0.0, 0.0, 0.0, 0.0
for i = 1, n do
local p = points[i]
local w = p[4]
sw = sw + w
sr = sr + p[1] * w
sg = sg + p[2] * w
sb = sb + p[3] * w
end
if sw <= 0 then sw = 1 end
local leaf = {
value = { sr / sw, sg / sw, sb / sw },
weight = sw,
}
leaves[#leaves + 1] = leaf
return { axis = nil, threshold = nil, left = nil, right = nil, leaf = leaf }
end
table.sort(points, function(a, b) return a[axis] < b[axis] end)
local mid = math.floor(n / 2)
if mid < 1 then mid = 1 end
if mid >= n then mid = n - 1 end
if mid < 1 then
return build_kd_tree_weighted(points, 0, axis, leaves)
end
local threshold = points[mid + 1][axis]
local left_points, right_points = {}, {}
for i = 1, mid do left_points[#left_points + 1] = points[i] end
for i = mid + 1, n do right_points[#right_points + 1] = points[i] end
local next_axis = (axis % 3) + 1
return {
axis = axis,
threshold = threshold,
left = build_kd_tree_weighted(left_points, depth - 1, next_axis, leaves),
right = build_kd_tree_weighted(right_points, depth - 1, next_axis, leaves),
leaf = nil,
}
end
local function prepare_kd_weighted_samples(rgb_list, cfg)
local target_leaves = clamp(
cfg.k_colors * (cfg.kd_leaf_factor or 8),
cfg.kd_min_leaves or 32,
cfg.kd_max_leaves or 512
)
local kd_depth = math.min(ceil_log2(target_leaves), cfg.kd_max_depth or 10)
local points = {}
for i = 1, #rgb_list do
local p = rgb_list[i]
points[i] = { p[1], p[2], p[3], p[4] }
end
local leaves = {}
build_kd_tree_weighted(points, kd_depth, 1, leaves)
if #leaves == 0 then return nil, 0.0 end
local samples = {}
local total_weight = 0.0
for i = 1, #leaves do
local lf = leaves[i]
samples[i] = { lf.value[1], lf.value[2], lf.value[3], lf.weight }
total_weight = total_weight + lf.weight
end
return samples, total_weight
end
local function init_kmeanspp(samples, k, rng, total_weight)
local n = #samples
local function pick_by_weight()
local t = rng.next_f() * total_weight
local acc = 0.0
for i = 1, n do
acc = acc + samples[i][4]
if acc >= t then return i end
end
return n
end
local centers = {}
local first = pick_by_weight()
centers[1] = { samples[first][1], samples[first][2], samples[first][3] }
local best_d = {}
for i = 1, n do best_d[i] = 1e30 end
for _ = 2, k do
local last = centers[#centers]
local sumw = 0.0
for i = 1, n do
local d = dist_sq_rgb(samples[i], last)
if d < best_d[i] then best_d[i] = d end
sumw = sumw + best_d[i] * samples[i][4]
end
local idx = 1
if sumw > 0 then
local t = rng.next_f() * sumw
local acc = 0.0
for i = 1, n do
acc = acc + best_d[i] * samples[i][4]
if acc >= t then idx = i; break end
end
else
idx = pick_by_weight()
end
centers[#centers + 1] = { samples[idx][1], samples[idx][2], samples[idx][3] }
end
return centers
end
local function weighted_kmeans_palette(samples, k, cfg)
local n = #samples
if n == 0 then return {} end
if k > n then k = n end
local rng = make_rng32(cfg.k_seed or 42)
local total_weight = 0.0
for i = 1, n do total_weight = total_weight + samples[i][4] end
local centroids = init_kmeanspp(samples, k, rng, total_weight)
local prev = {}
for i = 1, #centroids do prev[i] = { centroids[i][1], centroids[i][2], centroids[i][3] } end
for iter = 1, cfg.max_kmeans_iterations do
local sums, wsum = {}, {}
for ci = 1, k do
sums[ci] = { 0.0, 0.0, 0.0 }
wsum[ci] = 0.0
end
for i = 1, n do
local p = samples[i]
local best_ci, best_dist = 1, 1e30
for ci = 1, k do
local d = dist_sq_rgb(p, centroids[ci])
if d < best_dist then best_dist = d; best_ci = ci end
end
local wgt = p[4]
sums[best_ci][1] = sums[best_ci][1] + p[1] * wgt
sums[best_ci][2] = sums[best_ci][2] + p[2] * wgt
sums[best_ci][3] = sums[best_ci][3] + p[3] * wgt
wsum[best_ci] = wsum[best_ci] + wgt
end
for ci = 1, k do
if wsum[ci] > 0 then
centroids[ci][1] = sums[ci][1] / wsum[ci]
centroids[ci][2] = sums[ci][2] / wsum[ci]
centroids[ci][3] = sums[ci][3] / wsum[ci]
end
end
if iter > 1 then
local max_move = 0.0
for ci = 1, k do
local d = dist_sq_rgb(centroids[ci], prev[ci])
if d > max_move then max_move = d end
end
if max_move < 0.01 then break end
end
for ci = 1, k do
prev[ci][1] = centroids[ci][1]
prev[ci][2] = centroids[ci][2]
prev[ci][3] = centroids[ci][3]
end
end
return centroids
end
local function weighted_kmedoids_palette(samples, k, cfg)
local n = #samples
if n == 0 then return {} end
if k > n then k = n end
local rng = make_rng32((cfg.k_seed or 42) + 97)
local total_weight = 0.0
for i = 1, n do total_weight = total_weight + samples[i][4] end
local centers = init_kmeanspp(samples, k, rng, total_weight)
local medoids = {}
for ci = 1, k do
local best_i, best_d = 1, 1e30
for i = 1, n do
local d = dist_sq_rgb(samples[i], centers[ci])
if d < best_d then best_d = d; best_i = i end
end
medoids[ci] = best_i
end
local labels = {}
local iters = clamp(cfg.kmedoids_iterations or 3, 1, 10)
for _ = 1, iters do
for i = 1, n do
local best_ci, best_d = 1, 1e30
for ci = 1, k do
local d = dist_sq_rgb(samples[i], samples[medoids[ci]])
if d < best_d then best_d = d; best_ci = ci end
end
labels[i] = best_ci
end
local changed = false
for ci = 1, k do
local members = {}
for i = 1, n do
if labels[i] == ci then members[#members + 1] = i end
end
if #members == 0 then
local best_i, best_d = 1, -1.0
for i = 1, n do
local near = 1e30
for cj = 1, k do
local d = dist_sq_rgb(samples[i], samples[medoids[cj]])
if d < near then near = d end
end
local score = near * samples[i][4]
if score > best_d then best_d = score; best_i = i end
end
if medoids[ci] ~= best_i then medoids[ci] = best_i; changed = true end
else
local best_medoid = medoids[ci]
local best_cost = 1e30
for mi = 1, #members do
local cand = members[mi]
local cost = 0.0
for mj = 1, #members do
local idx = members[mj]
cost = cost + dist_sq_rgb(samples[cand], samples[idx]) * samples[idx][4]
end
if cost < best_cost then best_cost = cost; best_medoid = cand end
end
if medoids[ci] ~= best_medoid then medoids[ci] = best_medoid; changed = true end
end
end
if not changed then break end
end
local palette = {}
for ci = 1, k do
local m = samples[medoids[ci]]
palette[ci] = { m[1], m[2], m[3] }
end
return palette
end
local function snap_palette_to_samples(palette, samples)
local out = {}
for ci = 1, #palette do
local best_i, best_d = 1, 1e30
for i = 1, #samples do
local d = dist_sq_rgb(samples[i], palette[ci])
if d < best_d then best_d = d; best_i = i end
end
local s = samples[best_i]
out[ci] = { s[1], s[2], s[3] }
end
return out
end
local function kmeans_quantize_image(src_img, cfg)
local w, h = src_img.width, src_img.height
local rgb_list = build_unique_rgb_histogram(src_img)
local n_unique = #rgb_list
if n_unique == 0 then return src_img:clone() end
local k = clamp(cfg.k_colors or 16, 1, n_unique)
local mode = cfg.quantizer_mode or "kmeans"
local samples = prepare_kd_weighted_samples(rgb_list, cfg)
if not samples or #samples == 0 then return src_img:clone() end
if k > #samples then k = #samples end
local palette
if mode == "kmedoids" then
palette = weighted_kmedoids_palette(samples, k, cfg)
elseif mode == "hybrid" then
local means = weighted_kmeans_palette(samples, k, cfg)
palette = snap_palette_to_samples(means, samples)
else
palette = weighted_kmeans_palette(samples, k, cfg)
end
local map_q = {}
for i = 1, n_unique do
local p = rgb_list[i]
local best_ci, best_dist = 1, 1e30
for ci = 1, #palette do
local d = dist_sq_rgb(p, palette[ci])
if d < best_dist then best_dist = d; best_ci = ci end
end
local cr = clamp(roundi(palette[best_ci][1]), 0, 255)
local cg = clamp(roundi(palette[best_ci][2]), 0, 255)
local cb = clamp(roundi(palette[best_ci][3]), 0, 255)
map_q[rgba_key_rgb(p[1], p[2], p[3])] = rgba_key_rgb(cr, cg, cb)
end
local out = Image(w, h, ColorMode.RGB)
for y = 0, h - 1 do
for x = 0, w - 1 do
local c = src_img:getPixel(x, y)
local a = pc.rgbaA(c)
if a == 0 then
out:putPixel(x, y, c)
else
local r = pc.rgbaR(c)
local g = pc.rgbaG(c)
local b = pc.rgbaB(c)
local qkey = map_q[rgba_key_rgb(r, g, b)]
if qkey then
local qr = math.floor(qkey / 65536) % 256
local qg = math.floor(qkey / 256) % 256
local qb = qkey % 256
out:putPixel(x, y, pc.rgba(qr, qg, qb, a))
else
out:putPixel(x, y, c)
end
end
end
end
return out
end
-- ---------------------------
-- Profiles (Rust compute_profiles)
-- ---------------------------
local function compute_profiles(img)
local w, h = img.width, img.height
local col, row = {}, {}
for i = 1, w do col[i] = 0.0 end
for i = 1, h do row[i] = 0.0 end
for y = 0, h - 1 do
for x = 1, w - 2 do
local left = luma_from_pixel(img:getPixel(x - 1, y))
local right = luma_from_pixel(img:getPixel(x + 1, y))
col[x + 1] = col[x + 1] + math.abs(right - left)
end
end
for x = 0, w - 1 do
for y = 1, h - 2 do
local top = luma_from_pixel(img:getPixel(x, y - 1))
local bottom = luma_from_pixel(img:getPixel(x, y + 1))
row[y + 1] = row[y + 1] + math.abs(bottom - top)
end
end
return col, row
end
-- ---------------------------
-- Step estimation (Rust estimate_step_size)
-- ---------------------------
local function estimate_step_size(profile, cfg)
local n = #profile
if n <= 0 then return nil end
local maxv = 0.0
for i = 1, n do if profile[i] > maxv then maxv = profile[i] end end
if maxv == 0.0 then return nil end
local thr = maxv * cfg.peak_threshold_multiplier
local peaks = {}
for i = 2, n - 1 do
local b = profile[i]
if b > thr and b > profile[i - 1] and b > profile[i + 1] then
table.insert(peaks, i - 1) -- 0-based
end
end
if #peaks < 2 then return nil end
local clean = { peaks[1] }
for pi = 2, #peaks do
local p = peaks[pi]
local last = clean[#clean]
if (p - last) >= cfg.peak_distance_filter then
table.insert(clean, p)
end
end
if #clean < 2 then return nil end
local diffs = {}
for i = 1, #clean - 1 do diffs[i] = (clean[i + 1] - clean[i]) * 1.0 end
table.sort(diffs)
return diffs[math.floor(#diffs / 2) + 1]
end
local function resolve_step_sizes(step_x_opt, step_y_opt, w, h, cfg)
if step_x_opt and step_y_opt then
local sx, sy = step_x_opt, step_y_opt
local ratio = (sx > sy) and (sx / sy) or (sy / sx)
if ratio > cfg.max_step_ratio then
local smaller = math.min(sx, sy)
return smaller, smaller
else
local avg = (sx + sy) / 2.0
return avg, avg
end
elseif step_x_opt then
return step_x_opt, step_x_opt
elseif step_y_opt then
return step_y_opt, step_y_opt
else
local mn = math.min(w, h)
local fallback = (mn / cfg.fallback_target_segments)
if fallback < 1.0 then fallback = 1.0 end
return fallback, fallback
end
end
-- ---------------------------
-- Cuts / Walker / Stabilize (Rust-like)
-- ---------------------------
local function sanitize_cuts(cuts, limit)
if limit <= 0 then return { 0 } end
local has0, hasL = false, false
for i = 1, #cuts do
local v = cuts[i]
if v == 0 then has0 = true end
if v >= limit then v = limit end
if v == limit then hasL = true end
cuts[i] = v
end
if not has0 then table.insert(cuts, 0) end
if not hasL then table.insert(cuts, limit) end
table.sort(cuts)
local out, last = {}, nil
for i = 1, #cuts do
local v = cuts[i]
if last == nil or v ~= last then
table.insert(out, v); last = v
end
end
return out
end
local function walk(profile, step_size, limit, cfg)
if #profile == 0 then return nil, "Cannot walk on empty profile" end
local cuts = { 0 }
local current_pos = 0.0
local search_window = math.max(step_size * cfg.walker_search_window_ratio, cfg.walker_min_search_window)
local meanv = profile_mean(profile)
while current_pos < limit do
local target = current_pos + step_size
if target >= limit then
table.insert(cuts, limit)
break
end
local start_search = math.max(math.floor(target - search_window), math.floor(current_pos + 1.0))
local end_excl = math.min(math.ceil(target + search_window), limit)
if end_excl <= start_search then
current_pos = target
else
local max_val = -1.0
local max_idx = start_search
for i = start_search, end_excl - 1 do
local v = profile[i + 1] or 0.0
if v > max_val then max_val = v; max_idx = i end
end
if max_val > meanv * cfg.walker_strength_threshold then
table.insert(cuts, max_idx)
current_pos = max_idx * 1.0
else
table.insert(cuts, math.floor(target))
current_pos = target
end
end
end
return cuts, nil
end
local function snap_uniform_cuts(profile, limit, target_step, cfg, min_required)
if limit <= 0 then return { 0 } end
if limit == 1 then return { 0, 1 } end
local desired_cells = 0
if target_step and target_step > 0 and target_step == target_step then
desired_cells = roundi(limit / target_step)
end
desired_cells = math.max(desired_cells, (min_required - 1))
desired_cells = math.max(desired_cells, 1)
desired_cells = math.min(desired_cells, limit)
local cell_w = limit / desired_cells
local search_window = math.max(cell_w * cfg.walker_search_window_ratio, cfg.walker_min_search_window)
local meanv = profile_mean(profile)
local cuts = { 0 }
for idx = 1, desired_cells - 1 do
local target = cell_w * idx
local prev = cuts[#cuts]
if prev + 1 >= limit then break end
local start = math.floor(target - search_window)
if start < (prev + 1) then start = prev + 1 end
if start < 0 then start = 0 end
local finish = math.ceil(target + search_window)
if finish > (limit - 1) then finish = limit - 1 end
if finish < start then finish = start end
local best_idx, best_val = start, -1.0
local prof_max_i = #profile - 1
for i = start, math.min(finish, prof_max_i) do
local v = profile[i + 1] or 0.0
if v > best_val then best_val = v; best_idx = i end
end
if best_val < (meanv * cfg.walker_strength_threshold) then
local fb = roundi(target)
if fb <= prev then fb = prev + 1 end
if fb >= limit then fb = limit - 1 end
best_idx = fb
end
table.insert(cuts, best_idx)
end
if cuts[#cuts] ~= limit then table.insert(cuts, limit) end
return sanitize_cuts(cuts, limit)
end
local function stabilize_cuts(profile, cuts, limit, sibling_cuts, sibling_limit, cfg)
if limit <= 0 then return { 0 } end
cuts = sanitize_cuts(cuts, limit)
local min_required = math.max(cfg.min_cuts_per_axis, 2)
min_required = math.min(min_required, limit + 1)
local axis_cells = math.max(#cuts - 1, 0)
local sibling_cells = math.max(#sibling_cuts - 1, 0)
local sibling_has_grid = (sibling_limit > 0) and (sibling_cells >= (min_required - 1)) and (sibling_cells > 0)
local steps_skewed = false
if sibling_has_grid and axis_cells > 0 then
local axis_step = limit / axis_cells
local sibling_step = sibling_limit / sibling_cells
local ratio = axis_step / sibling_step
if ratio > cfg.max_step_ratio or ratio < (1.0 / cfg.max_step_ratio) then
steps_skewed = true
end
end
if (#cuts >= min_required) and (not steps_skewed) then
return cuts
end
local target_step
if sibling_has_grid then
target_step = sibling_limit / sibling_cells
elseif cfg.fallback_target_segments and cfg.fallback_target_segments > 1 then
target_step = limit / cfg.fallback_target_segments
elseif axis_cells > 0 then
target_step = limit / axis_cells
else
target_step = limit
end
if (not target_step) or (target_step ~= target_step) or (target_step <= 0) then
target_step = 1.0
end
return snap_uniform_cuts(profile, limit, target_step, cfg, min_required)
end
local function stabilize_both_axes(profile_x, profile_y, raw_cols, raw_rows, w, h, cfg)
local col_pass1 = stabilize_cuts(profile_x, raw_cols, w, raw_rows, h, cfg)
local row_pass1 = stabilize_cuts(profile_y, raw_rows, h, raw_cols, w, cfg)
local col_cells = math.max(#col_pass1 - 1, 1)
local row_cells = math.max(#row_pass1 - 1, 1)
local col_step = w / col_cells
local row_step = h / row_cells
local ratio = (col_step > row_step) and (col_step / row_step) or (row_step / col_step)
if ratio > cfg.max_step_ratio then
local target = math.min(col_step, row_step)
local final_cols = col_pass1
if col_step > target * 1.2 then
final_cols = snap_uniform_cuts(profile_x, w, target, cfg, cfg.min_cuts_per_axis)
end
local final_rows = row_pass1
if row_step > target * 1.2 then
final_rows = snap_uniform_cuts(profile_y, h, target, cfg, cfg.min_cuts_per_axis)
end
return final_cols, final_rows
end
return col_pass1, row_pass1
end
-- ---------------------------
-- Majority resample (original-style)
-- ---------------------------
local function resample_majority(img, cols, rows)
if #cols < 2 or #rows < 2 then
return nil, "Insufficient grid cuts for resampling"
end
local w, h = img.width, img.height
local out_w = #cols - 1
local out_h = #rows - 1
local out = Image(out_w, out_h, ColorMode.RGB)
for y_i = 1, out_h do
local ys = rows[y_i]
local ye = rows[y_i + 1]
for x_i = 1, out_w do
local xs = cols[x_i]
local xe = cols[x_i + 1]
if xe > xs and ye > ys then
local counts = {}
local best_c, best_n = nil, -1
for y = ys, ye - 1 do
if y >= 0 and y < h then
for x = xs, xe - 1 do
if x >= 0 and x < w then
local c = img:getPixel(x, y)
local n = (counts[c] or 0) + 1
counts[c] = n
if n > best_n then
best_n = n; best_c = c
elseif n == best_n and best_c ~= nil and c < best_c then
best_c = c
end
end
end
end
end
if best_c == nil then best_c = pc.rgba(0, 0, 0, 0) end
out:putPixel(x_i - 1, y_i - 1, best_c)
else
out:putPixel(x_i - 1, y_i - 1, pc.rgba(0, 0, 0, 0))
end
end
end
return out, nil
end
-- ---------------------------
-- Pipeline
-- ---------------------------
local function run_pipeline(work, cfg)
local w, h = work.width, work.height
local prof_x, prof_y = compute_profiles(work)
local sx = estimate_step_size(prof_x, cfg)
local sy = estimate_step_size(prof_y, cfg)
local step_x, step_y = resolve_step_sizes(sx, sy, w, h, cfg)
local raw_cols, e1 = walk(prof_x, step_x, w, cfg)
if not raw_cols then return nil, "walk failed x: " .. (e1 or "?") end
local raw_rows, e2 = walk(prof_y, step_y, h, cfg)
if not raw_rows then return nil, "walk failed y: " .. (e2 or "?") end
local cols, rows = stabilize_both_axes(prof_x, prof_y, raw_cols, raw_rows, w, h, cfg)
local out_img, e3 = resample_majority(work, cols, rows)
if not out_img then return nil, "resample failed: " .. (e3 or "?") end
return {
out_img = out_img,
cols = cols,
rows = rows,
step_x = step_x,
step_y = step_y,
prof_x = prof_x,
prof_y = prof_y,
}, nil
end
-- ---------------------------
-- Main runner
-- ---------------------------
local function run_unscale(cfg)
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 ok, err = validate_dimensions(w, h)
if not ok then app.alert(err); return end
local work = kmeans_quantize_image(src, cfg)
local best_result = nil
local res, _ = run_pipeline(work, cfg)
if res then
best_result = res
end
if not best_result then
app.alert("실패: 그리드 추정/복원에 실패했습니다.\n(대비가 높은 입력에서 더 잘 동작합니다.)")
return
end
local out_img = best_result.out_img
local outSpr = Sprite(out_img.width, out_img.height, ColorMode.RGB)
outSpr.filename = (spr.filename or "") .. " (unscaled)"
local outLayer = outSpr.layers[1]
local frame = outSpr.frames[1]
outSpr:newCel(outLayer, frame, out_img, Point(0, 0))
app.alert(string.format(
[[Input: %dx%d -> Output: %dx%d mode=%s k=%d step_x=%.3f step_y=%.3f cols=%d rows=%d]],
w, h, out_img.width, out_img.height,
cfg.quantizer_mode,
cfg.k_colors,
best_result.step_x, best_result.step_y,
#best_result.cols, #best_result.rows
))
end
-- ---------------------------
-- UI (simple)
-- ---------------------------
local function show_dialog()
local cfg = default_config()
local dlg = Dialog{ title = "Pixel Unscale v3 (Quantizer Compare)" }
dlg:slider{ id="k_colors", label="k_colors", min=4, max=64, value=cfg.k_colors }
dlg:combobox{
id="quantizer_mode", label="mode",
options={ "kmeans", "kmedoids", "hybrid" },
option=cfg.quantizer_mode
}
dlg:button{
id="run", text="Run", focus=true,
onclick=function()
local d = dlg.data
cfg.k_colors = clamp(math.floor(tonumber(d.k_colors) or cfg.k_colors), 4, 64)
cfg.quantizer_mode = d.quantizer_mode or cfg.quantizer_mode
run_unscale(cfg)
end
}
dlg:button{ id="close", text="Close" }
dlg:show{ wait=false }
end
show_dialog()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment