From be697a6d1cec1822dfb7b930974db01bd814a6de Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Wed, 15 Apr 2026 17:21:37 -0300 Subject: [PATCH] Refine lighting color look and feel --- luz-musica.py | 152 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 144 insertions(+), 8 deletions(-) diff --git a/luz-musica.py b/luz-musica.py index c98d4ea..02ca45d 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -2,6 +2,7 @@ from __future__ import annotations import colorsys +import math import subprocess import time from io import BytesIO @@ -20,6 +21,11 @@ SATURATION_BOOST = 1.15 MIN_SATURATION = 0.20 MIN_VALUE = 0.18 REQUEST_TIMEOUT = 10 +MAX_VALUE = 0.82 +PALETTE_SIZE = 32 +OKLCH_LIGHTNESS = 0.72 +OKLCH_CHROMA = 0.24 +MAX_BRIGHTNESS_STEP = 0.10 def run_playerctl(field: str, player: str | None = None) -> str: @@ -153,6 +159,120 @@ def image_pixels(img: Image.Image) -> list[tuple[int, int, int]]: return list(img.getdata()) +def hue_distance(a: float, b: float) -> float: + diff = abs(a - b) + return min(diff, 1.0 - diff) + + +def rgb_to_hsv(rgb: tuple[int, int, int]) -> tuple[float, float, float]: + r, g, b = rgb + return colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) + + +def hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]: + r, g, b = colorsys.hsv_to_rgb(h, s, v) + return (int(r * 255), int(g * 255), int(b * 255)) + + +def srgb_channel_to_linear(channel: float) -> float: + if channel <= 0.04045: + return channel / 12.92 + return ((channel + 0.055) / 1.055) ** 2.4 + + +def linear_channel_to_srgb(channel: float) -> float: + if channel <= 0.0031308: + return 12.92 * channel + return 1.055 * (channel ** (1 / 2.4)) - 0.055 + + +def rgb_to_oklab(rgb: tuple[int, int, int]) -> tuple[float, float, float]: + r, g, b = rgb + rl = srgb_channel_to_linear(r / 255.0) + gl = srgb_channel_to_linear(g / 255.0) + bl = srgb_channel_to_linear(b / 255.0) + + l = 0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl + m = 0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl + s = 0.0883024619 * rl + 0.2817188376 * gl + 0.6299787005 * bl + + l_ = l ** (1 / 3) + m_ = m ** (1 / 3) + s_ = s ** (1 / 3) + + return ( + 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, + 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, + 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, + ) + + +def oklab_to_rgb(lightness: float, a: float, b: float) -> tuple[int, int, int]: + l_ = lightness + 0.3963377774 * a + 0.2158037573 * b + m_ = lightness - 0.1055613458 * a - 0.0638541728 * b + s_ = lightness - 0.0894841775 * a - 1.2914855480 * b + + l = l_ ** 3 + m = m_ ** 3 + s = s_ ** 3 + + rl = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s + gl = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s + bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s + + r = min(1.0, max(0.0, linear_channel_to_srgb(rl))) + g = min(1.0, max(0.0, linear_channel_to_srgb(gl))) + b = min(1.0, max(0.0, linear_channel_to_srgb(bl))) + return (int(r * 255), int(g * 255), int(b * 255)) + + +def rgb_to_oklch(rgb: tuple[int, int, int]) -> tuple[float, float, float]: + lightness, a, b = rgb_to_oklab(rgb) + chroma = (a * a + b * b) ** 0.5 + hue = (0.0 if chroma == 0 else (math.atan2(b, a) / (2 * math.pi)) % 1.0) + return (lightness, chroma, hue) + + +def oklch_to_rgb(lightness: float, chroma: float, hue: float) -> tuple[int, int, int]: + angle = 2 * math.pi * hue + a = chroma * math.cos(angle) + b = chroma * math.sin(angle) + return oklab_to_rgb(lightness, a, b) + + +def pure_tone_palette() -> list[tuple[int, int, int]]: + return [ + oklch_to_rgb(OKLCH_LIGHTNESS, OKLCH_CHROMA, index / PALETTE_SIZE) + for index in range(PALETTE_SIZE) + ] + + +PURE_TONE_PALETTE = pure_tone_palette() + + +def clamp_brightness(rgb: tuple[int, int, int], previous_rgb: tuple[int, int, int] | None) -> tuple[int, int, int]: + if previous_rgb is None: + return rgb + + h, s, v = rgb_to_hsv(rgb) + _, _, prev_v = rgb_to_hsv(previous_rgb) + + limited_v = max(prev_v - MAX_BRIGHTNESS_STEP, min(prev_v + MAX_BRIGHTNESS_STEP, v)) + return hsv_to_rgb(h, s, limited_v) + + +def snap_to_palette(rgb: tuple[int, int, int]) -> tuple[int, int, int]: + _, _, hue = rgb_to_oklch(rgb) + + best = min( + PURE_TONE_PALETTE, + key=lambda candidate: hue_distance(hue, rgb_to_oklch(candidate)[2]), + ) + + best_lightness, _, best_hue = rgb_to_oklch(best) + return oklch_to_rgb(best_lightness, OKLCH_CHROMA, best_hue) + + def good_pixels(img: Image.Image) -> list[tuple[int, int, int]]: img = img.resize((80, 80)) pixels = image_pixels(img) @@ -167,6 +287,8 @@ def good_pixels(img: Image.Image) -> list[tuple[int, int, int]]: continue if s < MIN_SATURATION: continue + if v > MAX_VALUE and s < 0.45: + continue filtered.append((r, g, b)) @@ -190,20 +312,29 @@ def dominant_color(img: Image.Image) -> tuple[int, int, int]: key = (r // 16 * 16, g // 16 * 16, b // 16 * 16) buckets[key] = buckets.get(key, 0) + 1 - best = max(buckets.items(), key=lambda x: x[1])[0] + best = max( + buckets.items(), + key=lambda item: item[1] * bucket_weight(item[0]), + )[0] return boost_color(best) +def bucket_weight(rgb: tuple[int, int, int]) -> float: + _, saturation, value = rgb_to_hsv(rgb) + + saturation_score = 0.6 + saturation + brightness_midpoint = 0.62 + brightness_penalty = abs(value - brightness_midpoint) * 1.2 + return max(0.2, saturation_score - brightness_penalty) + + def boost_color(rgb: tuple[int, int, int]) -> tuple[int, int, int]: - r, g, b = rgb - rf, gf, bf = r / 255.0, g / 255.0, b / 255.0 - h, s, v = colorsys.rgb_to_hsv(rf, gf, bf) + h, s, v = rgb_to_hsv(rgb) s = min(1.0, s * SATURATION_BOOST) - v = max(v, 0.45) + v = min(0.74, max(v, 0.48)) - nr, ng, nb = colorsys.hsv_to_rgb(h, s, v) - return (int(nr * 255), int(ng * 255), int(nb * 255)) + return hsv_to_rgb(h, s, v) def set_light_color(rgb: tuple[int, int, int]) -> None: @@ -226,6 +357,7 @@ def set_light_color(rgb: tuple[int, int, int]) -> None: def main() -> None: print("Monitorando música atual. Ctrl+C para sair.") last_track = "" + last_rgb: tuple[int, int, int] | None = None while True: try: @@ -250,10 +382,14 @@ def main() -> None: print(f"Nova faixa detectada: {track_id}") img = load_image_from_url(art_url) - rgb = dominant_color(img) + raw_rgb = dominant_color(img) + palette_rgb = snap_to_palette(raw_rgb) + rgb = clamp_brightness(palette_rgb, last_rgb) + print(f"Cor base: {raw_rgb} -> paleta: {palette_rgb}") print(f"Aplicando cor: {rgb}") set_light_color(rgb) last_track = track_id + last_rgb = rgb time.sleep(2)