Last active
June 12, 2026 06:58
-
-
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 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