mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-06 17:10:55 -04:00
52ad498b8b
- Align CacheContract firered overrides with pure ROM extraction - Fix door animation extraction tile sizes, palettes, and strides - Update storage chrome, pokedex, summary, and battle chrome extractors - Ensure 100% self-contained ROM extraction without external dependencies
1227 lines
37 KiB
Lua
1227 lines
37 KiB
Lua
-- Interior demake (top-down):
|
||
-- 1) Discover room-wide 4-shade ramps per FRLG palSlot (material codebook)
|
||
-- 2) Assign each 8×8 quad to the best codebook ramp (majority-pal bias)
|
||
-- 3) Bake 2bpp against that fixed ramp only
|
||
-- Outdoor extract keeps BT.709 path in quantize.lua; this module is indoor-only.
|
||
|
||
local QuantizeLab = {}
|
||
|
||
QuantizeLab.W_L = 2.0
|
||
QuantizeLab.L_TIE = 1.0 -- |ΔL| below this → hue/chroma tie-break
|
||
QuantizeLab.NEAR_DUPE_EPS = 12.0 -- Tuned for standard ramp_dist_lab
|
||
-- Outdoor bank/merge: slightly tighter than indoor so sand/water stay apart,
|
||
-- but not so tight that buildings fragment into noisy near-dupes.
|
||
QuantizeLab.OUTDOOR_NEAR_DUPE_EPS = 8.0
|
||
QuantizeLab.MAX_SLOTS = 22 -- per tileset-pair codebook budget (sheet also ≤22)
|
||
QuantizeLab.WEIGHT_RATIO_CAP = 2 -- max 2:1 usage weights when blending
|
||
QuantizeLab.PALSLOT_ADD = 50.0 -- legacy; unused in top-down bake
|
||
QuantizeLab.OUTLIER_DE_MIN = 25.0 -- legacy per-quad path
|
||
QuantizeLab.DL_SHIFT_MAX = 10.0 -- material merge: reject if rare→blend |ΔL| exceeds
|
||
QuantizeLab.DAB_SHIFT_MAX = 4.0 -- material merge: reject if rare→blend chroma shift exceeds
|
||
QuantizeLab.KMEANS_ITERS = 6
|
||
QuantizeLab.MIN_MATERIAL_PIXELS = 4
|
||
QuantizeLab.MIXED_MIN_PIXELS = 10 -- minority palSlot ≥ this (diagnostics)
|
||
QuantizeLab.HUE_SPLIT_DE = 28.0 -- split one FRLG palSlot into 2 materials
|
||
QuantizeLab.HUE_SPLIT_MIN_FRAC = 0.18
|
||
-- Outdoor locked terrain: one 4-shade ramp family (no tip/base hue split).
|
||
QuantizeLab.OUTDOOR_TERRAIN_FAMILY = {
|
||
TREE = "TREE",
|
||
WATER = "WATER",
|
||
SAND = "SAND",
|
||
CLIFF = "CLIFF",
|
||
COAST_CLIFF = "CLIFF",
|
||
}
|
||
QuantizeLab.BLACK_RAMP = {
|
||
{ 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 },
|
||
}
|
||
|
||
local function bgr555_to_rgb(c)
|
||
c = c or 0
|
||
local r5 = c % 32
|
||
local g5 = math.floor(c / 32) % 32
|
||
local b5 = math.floor(c / 1024) % 32
|
||
return r5 * 255 / 31, g5 * 255 / 31, b5 * 255 / 31
|
||
end
|
||
|
||
-- sRGB 0..255 → XYZ (D65) → CIE L*a*b*
|
||
local function rgb_to_lab(r, g, b)
|
||
local function lin(u)
|
||
u = u / 255
|
||
if u <= 0.04045 then return u / 12.92 end
|
||
return ((u + 0.055) / 1.055) ^ 2.4
|
||
end
|
||
local R, G, B = lin(r), lin(g), lin(b)
|
||
local x = R * 0.4124564 + G * 0.3575761 + B * 0.1804375
|
||
local y = R * 0.2126729 + G * 0.7151522 + B * 0.0721750
|
||
local z = R * 0.0193339 + G * 0.1191920 + B * 0.9503041
|
||
-- D65 white
|
||
x, y, z = x / 0.95047, y / 1.0, z / 1.08883
|
||
local function f(t)
|
||
if t > 0.008856 then return t ^ (1 / 3) end
|
||
return (7.787 * t) + (16 / 116)
|
||
end
|
||
local fx, fy, fz = f(x), f(y), f(z)
|
||
local L = (116 * fy) - 16
|
||
local a = 500 * (fx - fy)
|
||
local bb = 200 * (fy - fz)
|
||
return L, a, bb
|
||
end
|
||
|
||
local function lab_to_rgb(L, a, bb)
|
||
local fy = (L + 16) / 116
|
||
local fx = a / 500 + fy
|
||
local fz = fy - bb / 200
|
||
local function finv(t)
|
||
local t3 = t * t * t
|
||
if t3 > 0.008856 then return t3 end
|
||
return (t - 16 / 116) / 7.787
|
||
end
|
||
local x = 0.95047 * finv(fx)
|
||
local y = 1.0 * finv(fy)
|
||
local z = 1.08883 * finv(fz)
|
||
local R = x * 3.2404542 + y * -1.5371385 + z * -0.4985314
|
||
local G = x * -0.9692660 + y * 1.8760108 + z * 0.0415560
|
||
local B = x * 0.0556434 + y * -0.2040259 + z * 1.0572252
|
||
local function gamma(u)
|
||
if u <= 0.0031308 then return 12.92 * u end
|
||
return 1.055 * (u ^ (1 / 2.4)) - 0.055
|
||
end
|
||
local function clamp8(u)
|
||
u = gamma(u) * 255
|
||
if u < 0 then return 0 end
|
||
if u > 255 then return 255 end
|
||
return math.floor(u + 0.5)
|
||
end
|
||
return clamp8(R), clamp8(G), clamp8(B)
|
||
end
|
||
|
||
local function chroma(a, b)
|
||
return math.sqrt(a * a + b * b)
|
||
end
|
||
|
||
-- Cosine vs fixed red reference (a=1, b=0); range [-1, 1]. Safe for hue ties.
|
||
local function red_ref_cos(a, b)
|
||
local c = chroma(a, b)
|
||
if c < 1e-6 then return 0 end
|
||
return a / c
|
||
end
|
||
|
||
function QuantizeLab.deltaE_w(L1, a1, b1, L2, a2, b2, wL)
|
||
wL = wL or QuantizeLab.W_L
|
||
local dL = (L1 - L2) * wL
|
||
local da = a1 - a2
|
||
local db = b1 - b2
|
||
return math.sqrt(dL * dL + da * da + db * db)
|
||
end
|
||
|
||
local function deltaE_w_lab(p, q, wL)
|
||
return QuantizeLab.deltaE_w(p.L, p.a, p.b, q.L, q.a, q.b, wL)
|
||
end
|
||
|
||
local function rgb_of_lab(p)
|
||
local r, g, b = lab_to_rgb(p.L, p.a, p.b)
|
||
return { r, g, b }
|
||
end
|
||
|
||
local function copy_lab(p)
|
||
return { L = p.L, a = p.a, b = p.b }
|
||
end
|
||
|
||
local function sort_centroids_light_to_dark(cents)
|
||
table.sort(cents, function(u, v)
|
||
local dL = u.L - v.L
|
||
if math.abs(dL) >= QuantizeLab.L_TIE then
|
||
return u.L > v.L -- lightest first → shade 0
|
||
end
|
||
local cu, cv = red_ref_cos(u.a, u.b), red_ref_cos(v.a, v.b)
|
||
if math.abs(cu - cv) > 1e-9 then
|
||
return cu > cv
|
||
end
|
||
return chroma(u.a, u.b) > chroma(v.a, v.b)
|
||
end)
|
||
end
|
||
|
||
local function encode_2bpp(shades)
|
||
local out = {}
|
||
for y = 0, 7 do
|
||
local low, high = 0, 0
|
||
for x = 0, 7 do
|
||
local shade = shades[y * 8 + x + 1] or 0
|
||
local bit = 7 - x
|
||
local mask = 2 ^ bit
|
||
if shade % 2 == 1 then low = low + mask end
|
||
if math.floor(shade / 2) % 2 == 1 then high = high + mask end
|
||
end
|
||
out[y * 2 + 1] = low
|
||
out[y * 2 + 2] = high
|
||
end
|
||
return out
|
||
end
|
||
|
||
--- Pad unique colours (sorted light→dark) into DMG slots 0=lightest … 3=darkest.
|
||
-- Interpolate internal slots so Pass 3 sees a natural ramp (not duplicated dummies).
|
||
local function pad_unique_to_ramp(sorted) -- light→dark LAB list
|
||
local n = #sorted
|
||
if n == 0 then
|
||
local g = { L = 50, a = 0, b = 0 }
|
||
return { g, g, g, g }
|
||
elseif n == 1 then
|
||
local c = sorted[1]
|
||
return { c, c, c, c }
|
||
elseif n == 2 then
|
||
local c1, c4 = sorted[1], sorted[2]
|
||
local c2 = {
|
||
L = c1.L * 0.66 + c4.L * 0.34,
|
||
a = c1.a * 0.66 + c4.a * 0.34,
|
||
b = c1.b * 0.66 + c4.b * 0.34,
|
||
}
|
||
local c3 = {
|
||
L = c1.L * 0.34 + c4.L * 0.66,
|
||
a = c1.a * 0.34 + c4.a * 0.66,
|
||
b = c1.b * 0.34 + c4.b * 0.66,
|
||
}
|
||
return { c1, c2, c3, c4 }
|
||
elseif n == 3 then
|
||
local c1, c2, c4 = sorted[1], sorted[2], sorted[3]
|
||
local c3 = {
|
||
L = (c2.L + c4.L) / 2,
|
||
a = (c2.a + c4.a) / 2,
|
||
b = (c2.b + c4.b) / 2,
|
||
}
|
||
return { c1, c2, c3, c4 }
|
||
else
|
||
return { sorted[1], sorted[2], sorted[3], sorted[4] }
|
||
end
|
||
end
|
||
|
||
local function gather_pixels(buf64, pal64)
|
||
local pixels = {}
|
||
local byKey = {}
|
||
local order = {}
|
||
for i = 1, 64 do
|
||
local c = buf64[i] or 0
|
||
local r, g, b = bgr555_to_rgb(c)
|
||
local L, a, bb = rgb_to_lab(r, g, b)
|
||
local palSlot = (pal64 and pal64[i]) or 0
|
||
local p = { L = L, a = a, b = bb, r = r, g = g, idx = i, key = c, palSlot = palSlot }
|
||
pixels[i] = p
|
||
if not byKey[c] then
|
||
byKey[c] = { L = L, a = a, b = bb, r = r, g = g, key = c, n = 0, palSlot = palSlot }
|
||
order[#order + 1] = c
|
||
end
|
||
byKey[c].n = byKey[c].n + 1
|
||
end
|
||
local uniques = {}
|
||
for _, k in ipairs(order) do
|
||
uniques[#uniques + 1] = byKey[k]
|
||
end
|
||
return pixels, uniques
|
||
end
|
||
|
||
local function group_by_palslot(pixels)
|
||
local groups = {} -- palSlot → { pixels }
|
||
local order = {}
|
||
for i = 1, #pixels do
|
||
local p = pixels[i]
|
||
local s = p.palSlot or 0
|
||
if not groups[s] then
|
||
groups[s] = {}
|
||
order[#order + 1] = s
|
||
end
|
||
groups[s][#groups[s] + 1] = p
|
||
end
|
||
table.sort(order, function(a, b)
|
||
if #groups[a] ~= #groups[b] then return #groups[a] > #groups[b] end
|
||
return a < b
|
||
end)
|
||
return groups, order
|
||
end
|
||
|
||
local function mean_lab(list)
|
||
local L, a, b, n = 0, 0, 0, #list
|
||
if n == 0 then return { L = 50, a = 0, b = 0 } end
|
||
for i = 1, n do
|
||
L = L + list[i].L
|
||
a = a + list[i].a
|
||
b = b + list[i].b
|
||
end
|
||
return { L = L / n, a = a / n, b = b / n }
|
||
end
|
||
|
||
--- ΔE_eff: additive palSlot penalty (not multiplicative).
|
||
local function deltaE_eff(p, cent)
|
||
local d = deltaE_w_lab(p, cent)
|
||
if (p.palSlot or 0) ~= (cent.palSlot or 0) then
|
||
d = d + QuantizeLab.PALSLOT_ADD
|
||
end
|
||
return d
|
||
end
|
||
|
||
local function nearest_centroid(p, cents)
|
||
local best, bestD = 1, 1e18
|
||
for i = 1, #cents do
|
||
local d = deltaE_eff(p, cents[i])
|
||
if d < bestD then best, bestD = i, d end
|
||
end
|
||
return best, bestD
|
||
end
|
||
|
||
--- Structural outlier: baseline = mean LAB of majority-count palSlot only.
|
||
-- Returns locked centroid (with immutable palSlot) or nil.
|
||
local function structural_outlier(pixels)
|
||
local groups, order = group_by_palslot(pixels)
|
||
if #order == 0 then return nil end
|
||
local bgSlot = order[1]
|
||
local baseline = mean_lab(groups[bgSlot])
|
||
local best, bestD = nil, -1
|
||
if #order == 1 then
|
||
for i = 1, #pixels do
|
||
local p = pixels[i]
|
||
local d = deltaE_w_lab(p, baseline)
|
||
if d > bestD then bestD, best = d, p end
|
||
end
|
||
else
|
||
for i = 1, #pixels do
|
||
local p = pixels[i]
|
||
if (p.palSlot or 0) ~= bgSlot then
|
||
local d = deltaE_w_lab(p, baseline)
|
||
if d > bestD then bestD, best = d, p end
|
||
end
|
||
end
|
||
end
|
||
if not best or bestD < QuantizeLab.OUTLIER_DE_MIN then
|
||
return nil
|
||
end
|
||
local c = copy_lab(best)
|
||
c.palSlot = best.palSlot or 0
|
||
return c
|
||
end
|
||
|
||
--- Seed k centroids with immutable palSlot from the seed pixel.
|
||
-- Pure perceptual farthest-point (k-means++); do NOT dedicate seeds to every
|
||
-- palSlot — stray 1px sprites would starve the floor gradient.
|
||
local function seed_centroids(pixels, k, locked)
|
||
local cents = {}
|
||
if locked then
|
||
for i = 1, #locked do
|
||
local c = copy_lab(locked[i])
|
||
c.palSlot = locked[i].palSlot or 0
|
||
cents[#cents + 1] = c
|
||
end
|
||
end
|
||
while #cents < k do
|
||
local bestI, bestD = 1, -1
|
||
for i = 1, #pixels do
|
||
local p = pixels[i]
|
||
local dmin = 1e18
|
||
if #cents == 0 then
|
||
dmin = 1
|
||
else
|
||
for j = 1, #cents do
|
||
-- Perceptual only; assignment phase still uses deltaE_eff (+50 palSlot).
|
||
local d = deltaE_w_lab(p, cents[j])
|
||
if d < dmin then dmin = d end
|
||
end
|
||
end
|
||
if dmin > bestD then bestD, bestI = dmin, i end
|
||
end
|
||
local c = copy_lab(pixels[bestI])
|
||
c.palSlot = pixels[bestI].palSlot or 0
|
||
cents[#cents + 1] = c
|
||
end
|
||
return cents
|
||
end
|
||
|
||
local function kmeans(pixels, k, locked)
|
||
local cents = seed_centroids(pixels, k, locked)
|
||
local lockedN = locked and #locked or 0
|
||
-- palSlot on every centroid is immutable from here on.
|
||
for _ = 1, QuantizeLab.KMEANS_ITERS do
|
||
local sums = {}
|
||
for i = 1, k do
|
||
sums[i] = { L = 0, a = 0, b = 0, w = 0 }
|
||
end
|
||
for i = 1, #pixels do
|
||
local p = pixels[i]
|
||
local ci = nearest_centroid(p, cents)
|
||
local w = (p.n or 1) * (1 + chroma(p.a, p.b) / 40)
|
||
sums[ci].L = sums[ci].L + p.L * w
|
||
sums[ci].a = sums[ci].a + p.a * w
|
||
sums[ci].b = sums[ci].b + p.b * w
|
||
sums[ci].w = sums[ci].w + w
|
||
end
|
||
for i = lockedN + 1, k do
|
||
if sums[i].w > 0 then
|
||
-- Update LAB only; keep cents[i].palSlot unchanged.
|
||
cents[i].L = sums[i].L / sums[i].w
|
||
cents[i].a = sums[i].a / sums[i].w
|
||
cents[i].b = sums[i].b / sums[i].w
|
||
end
|
||
end
|
||
end
|
||
return cents
|
||
end
|
||
|
||
local function assign_shades(pixels, cents)
|
||
local shades = {}
|
||
for i = 1, #pixels do
|
||
local ci = nearest_centroid(pixels[i], cents)
|
||
shades[i] = ci - 1 -- 0..3 light→dark
|
||
end
|
||
return shades
|
||
end
|
||
|
||
local function ramp_from_cents(cents)
|
||
local ramp = {}
|
||
for i = 1, 4 do
|
||
ramp[i] = rgb_of_lab(cents[i])
|
||
end
|
||
return ramp
|
||
end
|
||
|
||
--- Unique ≤ 4: map by L* rank into slots 0=lightest … 3=darkest with extreme padding.
|
||
-- Two colours → pixel shades 0 and 3 only (internals padded on the ramp, not as pixel ids).
|
||
local function bypass_unique(pixels, uniques)
|
||
sort_centroids_light_to_dark(uniques)
|
||
local n = #uniques
|
||
local keyToShade = {}
|
||
if n == 1 then
|
||
keyToShade[uniques[1].key] = 0
|
||
elseif n == 2 then
|
||
keyToShade[uniques[1].key] = 0 -- lightest
|
||
keyToShade[uniques[2].key] = 3 -- darkest (never slot 1)
|
||
elseif n == 3 then
|
||
keyToShade[uniques[1].key] = 0
|
||
keyToShade[uniques[2].key] = 1
|
||
keyToShade[uniques[3].key] = 3
|
||
else
|
||
for i = 1, 4 do
|
||
keyToShade[uniques[i].key] = i - 1
|
||
end
|
||
end
|
||
local shades = {}
|
||
for i = 1, #pixels do
|
||
shades[i] = keyToShade[pixels[i].key] or 0
|
||
end
|
||
local padded = pad_unique_to_ramp(uniques)
|
||
return encode_2bpp(shades), ramp_from_cents(padded), shades
|
||
end
|
||
|
||
--- Demake one 8×8 BGR555 quad → 2bpp + 4-colour RGB ramp (light→dark).
|
||
-- Legacy bottom-up helper (kept for synthetics). Extract uses discover + assign + bake.
|
||
-- pal64: optional per-pixel FRLG map palette slot (same layout as buf64).
|
||
function QuantizeLab.quadTo2bppAndRamp(buf64, pal64)
|
||
local pixels, uniques = gather_pixels(buf64, pal64)
|
||
if #uniques <= 4 then
|
||
return bypass_unique(pixels, uniques)
|
||
end
|
||
|
||
local lock = structural_outlier(pixels)
|
||
local locked = lock and { lock } or nil
|
||
local cents = kmeans(pixels, 4, locked)
|
||
sort_centroids_light_to_dark(cents)
|
||
while #cents < 4 do
|
||
local c = copy_lab(cents[#cents] or { L = 50, a = 0, b = 0 })
|
||
c.palSlot = (cents[#cents] and cents[#cents].palSlot) or 0
|
||
cents[#cents + 1] = c
|
||
end
|
||
local shades = assign_shades(pixels, cents)
|
||
|
||
-- No medoid polish: k-means centroids are optimal; mutating them caused L*
|
||
-- inversion / clamp-flat midtones and crushed stool/table contrast.
|
||
return encode_2bpp(shades), ramp_from_cents(cents), shades
|
||
end
|
||
|
||
local function ramp_to_lab(ramp)
|
||
local out = {}
|
||
for i = 1, 4 do
|
||
local c = ramp[i]
|
||
local L, a, b = rgb_to_lab(c[1] or 0, c[2] or 0, c[3] or 0)
|
||
out[i] = { L = L, a = a, b = b }
|
||
end
|
||
return out
|
||
end
|
||
|
||
function QuantizeLab.rampDistance(a, b)
|
||
local la, lb = ramp_to_lab(a), ramp_to_lab(b)
|
||
local d = 0
|
||
for i = 1, 4 do
|
||
d = d + deltaE_w_lab(la[i], lb[i])
|
||
end
|
||
return d
|
||
end
|
||
|
||
local function ramp_dist_lab(la, lb)
|
||
local d = 0
|
||
for i = 1, 4 do
|
||
d = d + deltaE_w_lab(la[i], lb[i])
|
||
end
|
||
return d
|
||
end
|
||
|
||
local function majority_pal_slot(pal64)
|
||
local counts = {}
|
||
local best, bestN = 0, -1
|
||
for i = 1, 64 do
|
||
local s = (pal64 and pal64[i]) or 0
|
||
counts[s] = (counts[s] or 0) + 1
|
||
if counts[s] > bestN or (counts[s] == bestN and s < best) then
|
||
best, bestN = s, counts[s]
|
||
end
|
||
end
|
||
return best, bestN
|
||
end
|
||
|
||
--- Build a 4-shade light→dark ramp from a list of LAB samples (optional .n weight).
|
||
local function ramp_from_lab_samples(samples)
|
||
if not samples or #samples == 0 then
|
||
return {
|
||
{ 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 },
|
||
}
|
||
end
|
||
local uniques, byKey, order = {}, {}, {}
|
||
for i = 1, #samples do
|
||
local p = samples[i]
|
||
local key = p.key
|
||
if key == nil then
|
||
key = string.format("%.3f:%.3f:%.3f", p.L, p.a, p.b)
|
||
end
|
||
if not byKey[key] then
|
||
byKey[key] = {
|
||
L = p.L, a = p.a, b = p.b,
|
||
r = p.r, g = p.g, key = key,
|
||
n = 0, palSlot = p.palSlot or 0,
|
||
}
|
||
order[#order + 1] = key
|
||
end
|
||
byKey[key].n = byKey[key].n + (p.n or 1)
|
||
end
|
||
for _, k in ipairs(order) do
|
||
uniques[#uniques + 1] = byKey[k]
|
||
end
|
||
if #uniques <= 4 then
|
||
sort_centroids_light_to_dark(uniques)
|
||
return ramp_from_cents(pad_unique_to_ramp(uniques))
|
||
end
|
||
local cents = kmeans(uniques, 4, nil)
|
||
sort_centroids_light_to_dark(cents)
|
||
while #cents < 4 do
|
||
local c = copy_lab(cents[#cents] or { L = 50, a = 0, b = 0 })
|
||
cents[#cents + 1] = c
|
||
end
|
||
return ramp_from_cents(cents)
|
||
end
|
||
|
||
--- If a FRLG palSlot holds two far-apart hues (plant green + machine teal),
|
||
-- emit two sample lists so they become separate codebook materials.
|
||
local function split_hue_modes(samples)
|
||
local chromatic = {}
|
||
local totalW = 0
|
||
for i = 1, #samples do
|
||
local p = samples[i]
|
||
local w = p.n or 1
|
||
totalW = totalW + w
|
||
if chroma(p.a, p.b) >= 8 then
|
||
chromatic[#chromatic + 1] = p
|
||
end
|
||
end
|
||
if #chromatic < 4 or totalW < QuantizeLab.MIN_MATERIAL_PIXELS * 2 then
|
||
return { samples }
|
||
end
|
||
|
||
-- Farthest-point k=2 on (a,b), then assign.
|
||
local s0 = chromatic[1]
|
||
local s1, bestD = chromatic[1], -1
|
||
for i = 1, #chromatic do
|
||
local p = chromatic[i]
|
||
local d = (p.a - s0.a) ^ 2 + (p.b - s0.b) ^ 2
|
||
if d > bestD then bestD, s1 = d, p end
|
||
end
|
||
local s2, bestD2 = s1, -1
|
||
for i = 1, #chromatic do
|
||
local p = chromatic[i]
|
||
local d = (p.a - s1.a) ^ 2 + (p.b - s1.b) ^ 2
|
||
if d > bestD2 then bestD2, s2 = d, p end
|
||
end
|
||
local c1 = { L = s1.L, a = s1.a, b = s1.b }
|
||
local c2 = { L = s2.L, a = s2.a, b = s2.b }
|
||
-- Refine means once
|
||
for _ = 1, 4 do
|
||
local a1, b1, w1, L1 = 0, 0, 0, 0
|
||
local a2, b2, w2, L2 = 0, 0, 0, 0
|
||
for i = 1, #chromatic do
|
||
local p = chromatic[i]
|
||
local w = p.n or 1
|
||
local d1 = (p.a - c1.a) ^ 2 + (p.b - c1.b) ^ 2
|
||
local d2 = (p.a - c2.a) ^ 2 + (p.b - c2.b) ^ 2
|
||
if d1 <= d2 then
|
||
a1, b1, L1, w1 = a1 + p.a * w, b1 + p.b * w, L1 + p.L * w, w1 + w
|
||
else
|
||
a2, b2, L2, w2 = a2 + p.a * w, b2 + p.b * w, L2 + p.L * w, w2 + w
|
||
end
|
||
end
|
||
if w1 > 0 then c1 = { L = L1 / w1, a = a1 / w1, b = b1 / w1 } end
|
||
if w2 > 0 then c2 = { L = L2 / w2, a = a2 / w2, b = b2 / w2 } end
|
||
end
|
||
|
||
local de = deltaE_w_lab(c1, c2)
|
||
if de < QuantizeLab.HUE_SPLIT_DE then
|
||
return { samples }
|
||
end
|
||
|
||
local g1, g2, w1, w2 = {}, {}, 0, 0
|
||
for i = 1, #samples do
|
||
local p = samples[i]
|
||
local w = p.n or 1
|
||
local d1 = deltaE_w_lab(p, c1)
|
||
local d2 = deltaE_w_lab(p, c2)
|
||
if d1 <= d2 then
|
||
g1[#g1 + 1] = p
|
||
w1 = w1 + w
|
||
else
|
||
g2[#g2 + 1] = p
|
||
w2 = w2 + w
|
||
end
|
||
end
|
||
local minW = totalW * QuantizeLab.HUE_SPLIT_MIN_FRAC
|
||
if w1 < minW or w2 < minW or #g1 == 0 or #g2 == 0 then
|
||
return { samples }
|
||
end
|
||
return { g1, g2 }
|
||
end
|
||
|
||
local function significant_pal_slots(pal64)
|
||
local counts = {}
|
||
for i = 1, 64 do
|
||
local s = (pal64 and pal64[i]) or 0
|
||
counts[s] = (counts[s] or 0) + 1
|
||
end
|
||
local sig = {}
|
||
for s, n in pairs(counts) do
|
||
if n >= QuantizeLab.MIXED_MIN_PIXELS then
|
||
sig[#sig + 1] = s
|
||
end
|
||
end
|
||
table.sort(sig)
|
||
return sig, counts
|
||
end
|
||
|
||
function QuantizeLab.isMixedQuad(pal64)
|
||
local sig = significant_pal_slots(pal64)
|
||
return #sig >= 2
|
||
end
|
||
|
||
--- Local 4-shade ramp for one quad (used for mixed / high-residual tiles).
|
||
function QuantizeLab.rampFromQuad(buf64, pal64)
|
||
local pixels = gather_pixels(buf64, pal64)
|
||
return ramp_from_lab_samples(pixels)
|
||
end
|
||
|
||
function QuantizeLab.terrainFamily(category)
|
||
return QuantizeLab.OUTDOOR_TERRAIN_FAMILY[category or ""]
|
||
end
|
||
|
||
function QuantizeLab.primaryCategory(catVotes)
|
||
if not catVotes then return nil end
|
||
local bestC, bestN = nil, -1
|
||
for c, n in pairs(catVotes) do
|
||
if n > bestN or (n == bestN and (not bestC or c < bestC)) then
|
||
bestC, bestN = c, n
|
||
end
|
||
end
|
||
return bestC
|
||
end
|
||
|
||
function QuantizeLab.entryHasCategory(entry, category)
|
||
if not category or category == "" then return true end
|
||
local votes = entry and entry.categories
|
||
if not votes then return false end
|
||
if (votes[category] or 0) > 0 then return true end
|
||
local fam = QuantizeLab.terrainFamily(category)
|
||
if not fam then return false end
|
||
for c, n in pairs(votes) do
|
||
if n > 0 and QuantizeLab.terrainFamily(c) == fam then return true end
|
||
end
|
||
return false
|
||
end
|
||
|
||
--- Best codebook index whose categories include `category` (terrain family ok).
|
||
function QuantizeLab.primaryCodebookForCategory(codebook, category)
|
||
if not codebook or not category then return nil end
|
||
local fam = QuantizeLab.terrainFamily(category) or category
|
||
local bestI, bestN = nil, -1
|
||
for i = 1, #codebook do
|
||
local e = codebook[i]
|
||
local votes = e.categories or {}
|
||
local n = 0
|
||
for c, v in pairs(votes) do
|
||
if c == category or QuantizeLab.terrainFamily(c) == fam then
|
||
n = n + v
|
||
end
|
||
end
|
||
if n > bestN or (n == bestN and (not bestI or i < bestI)) then
|
||
bestN, bestI = n, i
|
||
end
|
||
end
|
||
if bestN and bestN > 0 then return bestI end
|
||
return nil
|
||
end
|
||
|
||
local function copy_cat_votes(src)
|
||
if not src then return nil end
|
||
local out = {}
|
||
for c, n in pairs(src) do out[c] = n end
|
||
return out
|
||
end
|
||
|
||
local function add_cat_votes(dst, src)
|
||
if not src then return dst end
|
||
dst = dst or {}
|
||
for c, n in pairs(src) do
|
||
dst[c] = (dst[c] or 0) + n
|
||
end
|
||
return dst
|
||
end
|
||
|
||
--- Step 1: discover one (or hue-split) material ramp per (pair, FRLG palSlot).
|
||
-- quads: { { buf, pal, pair, category? }, ... }
|
||
-- opts.forbidHueSplit: never hue-split (outdoor: keep tip/base one foliage ramp)
|
||
-- Returns merge-ready entries { ramp, count, locked, frlgSlot, pair, categories }.
|
||
function QuantizeLab.discoverMaterialEntries(quads, opts)
|
||
opts = opts or {}
|
||
local forbidHueSplit = opts.forbidHueSplit and true or false
|
||
local buckets = {} -- key → { pair, frlgSlot, byKey, order, pixelN, quadN, catVotes }
|
||
local majCounts = {} -- key → quads with this majority
|
||
|
||
for _, q in ipairs(quads or {}) do
|
||
local pair = q.pair or ""
|
||
local maj = majority_pal_slot(q.pal)
|
||
local majKey = pair .. ":" .. tostring(maj)
|
||
majCounts[majKey] = (majCounts[majKey] or 0) + 1
|
||
local qCat = q.category
|
||
|
||
local seen = {}
|
||
for i = 1, 64 do
|
||
local c = (q.buf and q.buf[i]) or 0
|
||
local frlg = (q.pal and q.pal[i]) or 0
|
||
local key = pair .. ":" .. tostring(frlg)
|
||
local b = buckets[key]
|
||
if not b then
|
||
b = {
|
||
pair = pair, frlgSlot = frlg, byKey = {}, order = {},
|
||
pixelN = 0, catVotes = {},
|
||
}
|
||
buckets[key] = b
|
||
end
|
||
b.pixelN = b.pixelN + 1
|
||
if not b.byKey[c] then
|
||
local r, g, bb = bgr555_to_rgb(c)
|
||
local L, a, labB = rgb_to_lab(r, g, bb)
|
||
b.byKey[c] = {
|
||
L = L, a = a, b = labB, r = r, g = g, key = c, n = 0, palSlot = frlg,
|
||
}
|
||
b.order[#b.order + 1] = c
|
||
end
|
||
b.byKey[c].n = b.byKey[c].n + 1
|
||
seen[key] = true
|
||
end
|
||
for key in pairs(seen) do
|
||
local b = buckets[key]
|
||
b.quadN = (b.quadN or 0) + 1
|
||
if qCat and qCat ~= "" then
|
||
b.catVotes[qCat] = (b.catVotes[qCat] or 0) + 1
|
||
end
|
||
end
|
||
end
|
||
|
||
local keys = {}
|
||
for k in pairs(buckets) do keys[#keys + 1] = k end
|
||
table.sort(keys)
|
||
|
||
local entries = {}
|
||
for _, key in ipairs(keys) do
|
||
local b = buckets[key]
|
||
if b.pixelN >= QuantizeLab.MIN_MATERIAL_PIXELS then
|
||
local samples = {}
|
||
for _, ck in ipairs(b.order) do
|
||
samples[#samples + 1] = b.byKey[ck]
|
||
end
|
||
local primary = QuantizeLab.primaryCategory(b.catVotes)
|
||
-- Only collapse tip/base foliage. Water/sand/cliff keep hue modes so
|
||
-- foam/rocks/highlights can remain separate materials.
|
||
local skipSplit = forbidHueSplit
|
||
or (primary == "TREE")
|
||
local modes = skipSplit and { samples } or split_hue_modes(samples)
|
||
local baseWeight = majCounts[key] or b.quadN or 1
|
||
if baseWeight < 1 then baseWeight = 1 end
|
||
local cats = copy_cat_votes(b.catVotes)
|
||
for mi = 1, #modes do
|
||
local modeSamples = modes[mi]
|
||
local modeW = 0
|
||
for i = 1, #modeSamples do
|
||
modeW = modeW + (modeSamples[i].n or 1)
|
||
end
|
||
local weight = math.max(1, math.floor(baseWeight * modeW / math.max(1, b.pixelN) + 0.5))
|
||
entries[#entries + 1] = {
|
||
ramp = ramp_from_lab_samples(modeSamples),
|
||
count = weight,
|
||
locked = false,
|
||
frlgSlot = b.frlgSlot,
|
||
pair = b.pair,
|
||
categories = cats and copy_cat_votes(cats) or nil,
|
||
primaryCategory = primary,
|
||
}
|
||
end
|
||
end
|
||
end
|
||
return entries
|
||
end
|
||
|
||
local function min_shade_de(p, rampLab)
|
||
local best = 1e18
|
||
for s = 1, 4 do
|
||
local d = deltaE_w_lab(p, rampLab[s])
|
||
if d < best then best = d end
|
||
end
|
||
return best
|
||
end
|
||
|
||
local function quad_ramp_error(pixels, rampLab)
|
||
local sum = 0
|
||
for i = 1, #pixels do
|
||
local p = pixels[i]
|
||
-- Structural outlines + vibrant colours outvote flat floors/walls.
|
||
local w = 1.0
|
||
if p.L < 45 then w = w + 2.0 end
|
||
if chroma(p.a, p.b) > 10 then w = w + 1.5 end
|
||
sum = sum + min_shade_de(p, rampLab) * w
|
||
end
|
||
return sum
|
||
end
|
||
|
||
QuantizeLab.OUTDOOR_FRLG_BIAS = 50.0 -- soft prefer matching FRLG palSlot outdoors
|
||
QuantizeLab.OUTDOOR_RAMP_BIAS = 40.0 -- soft prefer semantic seed ramp on mixed edges
|
||
-- Soft push plaza grass/path away from water/sand codebook entries.
|
||
QuantizeLab.OUTDOOR_CROSS_CAT_PENALTY = 120.0
|
||
QuantizeLab.OUTDOOR_GRASS_CATS = {
|
||
SHORT_GRASS = true,
|
||
TALL_GRASS = true,
|
||
TOWN_PATH = true,
|
||
}
|
||
|
||
--- ΔE_w between two BGR555 pixels (MRF seam gating).
|
||
function QuantizeLab.pixelDeltaE(cA, cB)
|
||
local r1, g1, b1 = bgr555_to_rgb(cA)
|
||
local r2, g2, b2 = bgr555_to_rgb(cB)
|
||
local L1, a1, bb1 = rgb_to_lab(r1, g1, b1)
|
||
local L2, a2, bb2 = rgb_to_lab(r2, g2, b2)
|
||
return QuantizeLab.deltaE_w(L1, a1, bb1, L2, a2, bb2)
|
||
end
|
||
|
||
--- Step 2: pick codebook index by weighted Σ min_s ΔE_w (pair-scoped).
|
||
-- Indoor: unary ΔE only (opts nil). Outdoor may pass preferFrlgSlot / preferRamp.
|
||
-- codebook[i] = { ramp, lab?, frlgSlot?, pair?, slot? }
|
||
function QuantizeLab.majorityPalSlot(pal64)
|
||
return majority_pal_slot(pal64)
|
||
end
|
||
|
||
function QuantizeLab.quadRampError(buf64, pal64, ramp, _frlgSlot)
|
||
local pixels = gather_pixels(buf64, pal64)
|
||
return quad_ramp_error(pixels, ramp_to_lab(ramp))
|
||
end
|
||
|
||
function QuantizeLab.bestCodebookIndex(buf64, pal64, codebook, pair, opts)
|
||
if not codebook or #codebook == 0 then return 1 end
|
||
local pixels = gather_pixels(buf64, pal64)
|
||
pair = pair or ""
|
||
opts = opts or nil
|
||
local preferFrlg = opts and opts.preferFrlgSlot
|
||
local frlgBias = (opts and opts.frlgBias) or 0
|
||
local preferRamp = opts and opts.preferRamp
|
||
local rampBias = (opts and opts.rampBias) or 0
|
||
local requireCat = opts and opts.requireCategory
|
||
local categoryBias = (opts and opts.categoryBias) or 0
|
||
local itemCat = opts and opts.itemCategory
|
||
local crossPen = (opts and opts.crossCatPenalty) or 0
|
||
|
||
local function cross_penalty(e)
|
||
if crossPen <= 0 or not itemCat then return 0 end
|
||
if QuantizeLab.OUTDOOR_GRASS_CATS[itemCat] then
|
||
if QuantizeLab.entryHasCategory(e, "WATER")
|
||
or QuantizeLab.entryHasCategory(e, "SAND") then
|
||
return crossPen
|
||
end
|
||
local primary = e.primaryCategory
|
||
if primary == "WATER" or primary == "SAND" then
|
||
return crossPen
|
||
end
|
||
end
|
||
return 0
|
||
end
|
||
|
||
local function score_range(onlyMatching)
|
||
local bestI, bestD = nil, 1e18
|
||
for i = 1, #codebook do
|
||
local e = codebook[i]
|
||
if (e.pair or "") == pair then
|
||
if (not onlyMatching) or QuantizeLab.entryHasCategory(e, requireCat) then
|
||
local lab = e.lab or ramp_to_lab(e.ramp)
|
||
local d = quad_ramp_error(pixels, lab) + cross_penalty(e)
|
||
if preferFrlg ~= nil and frlgBias ~= 0 and (e.frlgSlot or 0) == preferFrlg then
|
||
d = d - frlgBias
|
||
end
|
||
if preferRamp and rampBias > 0 and e.ramp then
|
||
local rd = QuantizeLab.rampDistance(e.ramp, preferRamp)
|
||
d = d - rampBias / (1 + rd / 12)
|
||
end
|
||
if requireCat and categoryBias > 0 and QuantizeLab.entryHasCategory(e, requireCat) then
|
||
d = d - categoryBias
|
||
end
|
||
if d < bestD then bestD, bestI = d, i end
|
||
end
|
||
end
|
||
end
|
||
return bestI
|
||
end
|
||
|
||
local bestI = nil
|
||
if requireCat then
|
||
bestI = score_range(true)
|
||
end
|
||
if not bestI then
|
||
bestI = score_range(false)
|
||
end
|
||
|
||
-- Safe fallback if a pair string is missing/mismatched
|
||
if not bestI then
|
||
local bestD = 1e18
|
||
for i = 1, #codebook do
|
||
local e = codebook[i]
|
||
local lab = e.lab or ramp_to_lab(e.ramp)
|
||
local d = quad_ramp_error(pixels, lab) + cross_penalty(e)
|
||
if d < bestD then bestD, bestI = d, i end
|
||
end
|
||
end
|
||
|
||
return bestI or 1
|
||
end
|
||
|
||
--- Opaque void 2bpp: all shade 3 (atlas black). Used only when stamping FRLG
|
||
-- mid 0 / border after content quantize — not as a material-ramp lock.
|
||
function QuantizeLab.solidBlackBpp()
|
||
local shades = {}
|
||
for i = 1, 64 do shades[i] = 3 end
|
||
return encode_2bpp(shades)
|
||
end
|
||
|
||
--- Strict codebook adoption (no mixed/local bypass — prevents grid banding).
|
||
-- Void is not handled here: FRLG mid 0 is stamped post-quantize from tile data.
|
||
-- Returns ramp, sheetSlotOrNil, mode ("codebook"|"local"), codebookIndexOrNil.
|
||
function QuantizeLab.resolveQuadRamp(buf64, pal64, codebook, pair, opts)
|
||
if not codebook or #codebook == 0 then
|
||
local pixels = gather_pixels(buf64, pal64)
|
||
return ramp_from_lab_samples(pixels), nil, "local", nil
|
||
end
|
||
|
||
local ci = QuantizeLab.bestCodebookIndex(buf64, pal64, codebook, pair, opts)
|
||
local entry = codebook[ci]
|
||
return entry.ramp, entry.slot, "codebook", ci
|
||
end
|
||
|
||
--- Step 3: bake 2bpp by nearest ΔE_w shade in a fixed 4-colour ramp (light→dark).
|
||
function QuantizeLab.quadTo2bppAgainstRamp(buf64, ramp)
|
||
local rampLab = ramp_to_lab(ramp)
|
||
local shades = {}
|
||
for i = 1, 64 do
|
||
local r, g, b = bgr555_to_rgb(buf64[i] or 0)
|
||
local L, a, bb = rgb_to_lab(r, g, b)
|
||
local p = { L = L, a = a, b = bb }
|
||
local best, bestD = 0, 1e18
|
||
for s = 1, 4 do
|
||
local d = deltaE_w_lab(p, rampLab[s])
|
||
if d < bestD then best, bestD = s - 1, d end
|
||
end
|
||
shades[i] = best
|
||
end
|
||
return encode_2bpp(shades), shades
|
||
end
|
||
|
||
--- True when any pixel is exact BGR555 0 (void mixed into a content quad).
|
||
function QuantizeLab.bufHasExactBlack(buf64)
|
||
for i = 1, 64 do
|
||
if ((buf64[i] or 0) % 0x8000) == 0 then return true end
|
||
end
|
||
return false
|
||
end
|
||
|
||
--- Bake a quad; if it contains exact-black pixels, reserve shade 3 as true
|
||
-- black for *this tile only* (door-mat chamfers). Non-black pixels use shades
|
||
-- 0–2 of the material ramp. Tiles without black keep the full 4-shade ramp.
|
||
-- Returns bpp16, usedRamp, lockedBlack.
|
||
function QuantizeLab.bakeQuad(buf64, ramp)
|
||
if not QuantizeLab.bufHasExactBlack(buf64) then
|
||
local bpp = QuantizeLab.quadTo2bppAgainstRamp(buf64, ramp)
|
||
return bpp, ramp, false
|
||
end
|
||
local used = {
|
||
{ ramp[1][1], ramp[1][2], ramp[1][3] },
|
||
{ ramp[2][1], ramp[2][2], ramp[2][3] },
|
||
{ ramp[3][1], ramp[3][2], ramp[3][3] },
|
||
{ 0, 0, 0 },
|
||
}
|
||
local rampLab = ramp_to_lab(used)
|
||
local shades = {}
|
||
for i = 1, 64 do
|
||
local c = (buf64[i] or 0) % 0x8000
|
||
if c == 0 then
|
||
shades[i] = 3
|
||
else
|
||
local r, g, b = bgr555_to_rgb(c)
|
||
local L, a, bb = rgb_to_lab(r, g, b)
|
||
local p = { L = L, a = a, b = bb }
|
||
local best, bestD = 0, 1e18
|
||
for s = 1, 3 do -- shades 0–2 only; 3 is reserved black
|
||
local d = deltaE_w_lab(p, rampLab[s])
|
||
if d < bestD then best, bestD = s - 1, d end
|
||
end
|
||
shades[i] = best
|
||
end
|
||
end
|
||
return encode_2bpp(shades), used, true
|
||
end
|
||
|
||
local function clamp_weights(na, nb, cap)
|
||
cap = cap or QuantizeLab.WEIGHT_RATIO_CAP
|
||
if na < 1 then na = 1 end
|
||
if nb < 1 then nb = 1 end
|
||
if na > nb * cap then na = nb * cap end
|
||
if nb > na * cap then nb = na * cap end
|
||
return na, nb
|
||
end
|
||
|
||
local function blend_ramps(a, b, na, nb)
|
||
na, nb = clamp_weights(na, nb)
|
||
local out = {}
|
||
local den = na + nb
|
||
local la, lb = ramp_to_lab(a), ramp_to_lab(b)
|
||
for i = 1, 4 do
|
||
local L = (la[i].L * na + lb[i].L * nb) / den
|
||
local aa = (la[i].a * na + lb[i].a * nb) / den
|
||
local bb = (la[i].b * na + lb[i].b * nb) / den
|
||
local r, g, brgb = lab_to_rgb(L, aa, bb)
|
||
out[i] = { r, g, brgb }
|
||
end
|
||
return out
|
||
end
|
||
|
||
--- Reject merge if the hypothetical blend pushes the rarer ramp too far from
|
||
-- its origin (not by comparing the two source ramps to each other).
|
||
local function rare_shift_ok_lab(rareLab, blendedLab)
|
||
for i = 1, 4 do
|
||
local r, b = rareLab[i], blendedLab[i]
|
||
local dL = math.abs(r.L - b.L)
|
||
local dab = math.sqrt((r.a - b.a) ^ 2 + (r.b - b.b) ^ 2)
|
||
if dL > QuantizeLab.DL_SHIFT_MAX or dab > QuantizeLab.DAB_SHIFT_MAX then
|
||
return false
|
||
end
|
||
end
|
||
return true
|
||
end
|
||
|
||
--- Agglomerative merge of provisional ramps into ≤ maxSlots (LAB-cached, O(n²) per step).
|
||
-- entries: { { ramp, count, locked?, categories? }, ... }
|
||
-- opts.nearDupeEps: override NEAR_DUPE_EPS (outdoor uses a tighter value)
|
||
-- opts.guardTerrainFamilies: refuse merging distinct outdoor terrain families
|
||
-- Returns finalRamps (1-based list) and map oldIndex → finalSlot.
|
||
function QuantizeLab.mergeRampsToBudget(entries, maxSlots, opts)
|
||
maxSlots = maxSlots or QuantizeLab.MAX_SLOTS
|
||
opts = opts or {}
|
||
local nearEps = opts.nearDupeEps or QuantizeLab.NEAR_DUPE_EPS
|
||
local guardFam = opts.guardTerrainFamilies and true or false
|
||
if not entries or #entries == 0 then
|
||
return { { { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 } } }, {}
|
||
end
|
||
|
||
local function cluster_family(c)
|
||
local primary = QuantizeLab.primaryCategory(c.catVotes)
|
||
return primary and QuantizeLab.terrainFamily(primary) or nil
|
||
end
|
||
|
||
local function families_conflict(ca, cb)
|
||
if not guardFam then return false end
|
||
local fa, fb = cluster_family(ca), cluster_family(cb)
|
||
return fa ~= nil and fb ~= nil and fa ~= fb
|
||
end
|
||
|
||
local clusters = {}
|
||
local active = {} -- dense list of cluster ids
|
||
for i = 1, #entries do
|
||
local e = entries[i]
|
||
local ramp = {
|
||
{ e.ramp[1][1], e.ramp[1][2], e.ramp[1][3] },
|
||
{ e.ramp[2][1], e.ramp[2][2], e.ramp[2][3] },
|
||
{ e.ramp[3][1], e.ramp[3][2], e.ramp[3][3] },
|
||
{ e.ramp[4][1], e.ramp[4][2], e.ramp[4][3] },
|
||
}
|
||
clusters[i] = {
|
||
ramp = ramp,
|
||
lab = ramp_to_lab(ramp),
|
||
count = e.count or 1,
|
||
locked = e.locked and true or false,
|
||
members = { i },
|
||
alive = true,
|
||
catVotes = copy_cat_votes(e.categories),
|
||
}
|
||
active[#active + 1] = i
|
||
end
|
||
|
||
local function try_merge(ia, ib, force)
|
||
local ca, cb = clusters[ia], clusters[ib]
|
||
if not (ca and cb and ca.alive and cb.alive) then return false end
|
||
if ca.locked and cb.locked then return false end
|
||
if families_conflict(ca, cb) then return false end
|
||
|
||
-- Keep = locked or higher-count (dominant); drop = rare.
|
||
local keep, drop = ia, ib
|
||
if cb.locked and not ca.locked then
|
||
keep, drop = ib, ia
|
||
elseif not ca.locked and not cb.locked and cb.count > ca.count then
|
||
keep, drop = ib, ia
|
||
end
|
||
|
||
local ck, cd = clusters[keep], clusters[drop]
|
||
local blended, blendedLab
|
||
|
||
if force then
|
||
-- Panic merge: absorb rare into dominant without corrupting dominant colours.
|
||
blended = ck.ramp
|
||
blendedLab = ck.lab
|
||
else
|
||
blended = blend_ramps(ck.ramp, cd.ramp, ck.count, cd.count)
|
||
blendedLab = ramp_to_lab(blended)
|
||
if not rare_shift_ok_lab(cd.lab, blendedLab) then
|
||
return false
|
||
end
|
||
end
|
||
|
||
ck.ramp = blended
|
||
ck.lab = blendedLab
|
||
ck.count = ck.count + cd.count
|
||
ck.locked = ck.locked or cd.locked
|
||
ck.catVotes = add_cat_votes(ck.catVotes, cd.catVotes)
|
||
for _, m in ipairs(cd.members) do
|
||
ck.members[#ck.members + 1] = m
|
||
end
|
||
cd.alive = false
|
||
return true, keep, drop
|
||
end
|
||
|
||
-- Near-dupe: single O(n²) sweep (union into lower id)
|
||
do
|
||
local n = #active
|
||
for i = 1, n do
|
||
local a = active[i]
|
||
local ca = clusters[a]
|
||
if ca and ca.alive then
|
||
for j = i + 1, n do
|
||
local b = active[j]
|
||
local cb = clusters[b]
|
||
if cb and cb.alive and not (ca.locked and cb.locked)
|
||
and not families_conflict(ca, cb) then
|
||
if ramp_dist_lab(ca.lab, cb.lab) < nearEps then
|
||
local ok = try_merge(a, b, false)
|
||
if ok then
|
||
ca = clusters[a]
|
||
if not ca.alive then break end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
local nxt = {}
|
||
for _, id in ipairs(active) do
|
||
if clusters[id].alive then nxt[#nxt + 1] = id end
|
||
end
|
||
active = nxt
|
||
end
|
||
|
||
-- Hard cap: closest-pair (same design). Rank pairs by LAB distance, then try
|
||
-- shift-ok merges from closest; force only the nearest if all reject.
|
||
while #active > maxSlots do
|
||
local pairs = {}
|
||
for i = 1, #active do
|
||
local a = active[i]
|
||
local ca = clusters[a]
|
||
for j = i + 1, #active do
|
||
local b = active[j]
|
||
local cb = clusters[b]
|
||
if not (ca.locked and cb.locked) and not families_conflict(ca, cb) then
|
||
pairs[#pairs + 1] = { a = a, b = b, d = ramp_dist_lab(ca.lab, cb.lab) }
|
||
end
|
||
end
|
||
end
|
||
if #pairs == 0 then break end
|
||
table.sort(pairs, function(u, v)
|
||
if u.d ~= v.d then return u.d < v.d end
|
||
if u.a ~= v.a then return u.a < v.a end
|
||
return u.b < v.b
|
||
end)
|
||
local mergedOk, drop = false, nil
|
||
for _, p in ipairs(pairs) do
|
||
local ok, _, d = try_merge(p.a, p.b, false)
|
||
if ok then
|
||
mergedOk, drop = true, d
|
||
break
|
||
end
|
||
end
|
||
if not mergedOk then
|
||
local ok, _, d = try_merge(pairs[1].a, pairs[1].b, true)
|
||
if not ok then break end
|
||
drop = d
|
||
end
|
||
local nxt = {}
|
||
for _, id in ipairs(active) do
|
||
if id ~= drop and clusters[id].alive then nxt[#nxt + 1] = id end
|
||
end
|
||
active = nxt
|
||
end
|
||
|
||
table.sort(active, function(a, b)
|
||
local ca, cb = clusters[a], clusters[b]
|
||
if ca.locked ~= cb.locked then return ca.locked end
|
||
if ca.count ~= cb.count then return ca.count > cb.count end
|
||
return a < b
|
||
end)
|
||
|
||
local finalRamps = {}
|
||
local oldToSlot = {}
|
||
for slot, id in ipairs(active) do
|
||
if slot > maxSlots then break end
|
||
finalRamps[slot] = clusters[id].ramp
|
||
for _, m in ipairs(clusters[id].members) do
|
||
oldToSlot[m] = slot
|
||
end
|
||
end
|
||
for i = 1, #entries do
|
||
if not oldToSlot[i] then
|
||
local best, bestD = 1, 1e18
|
||
local lab = ramp_to_lab(entries[i].ramp)
|
||
for s = 1, #finalRamps do
|
||
local d = ramp_dist_lab(lab, ramp_to_lab(finalRamps[s]))
|
||
if d < bestD then best, bestD = s, d end
|
||
end
|
||
oldToSlot[i] = best
|
||
end
|
||
end
|
||
return finalRamps, oldToSlot
|
||
end
|
||
|
||
-- Expose helpers for tests
|
||
QuantizeLab._rgb_to_lab = rgb_to_lab
|
||
QuantizeLab._pad_unique_to_ramp = pad_unique_to_ramp
|
||
QuantizeLab._red_ref_cos = red_ref_cos
|
||
QuantizeLab._sort_centroids = sort_centroids_light_to_dark
|
||
|
||
return QuantizeLab
|