A lo largo de los años, he hecho varias “cositas” que tienen que ver, de alguna u otra forma, con Spotify. Usé la API para clasificar la música que más escucho por décadas para saber qué tan viejos eran mis gustos, para conectarla con Home Assistant, para separar la música por idioma y alguna otra por ahí. Pero en los últimos días estuve ocupado haciendo que un reloj me diga qué canción estoy escuchando.
Fue ahí donde elaboré, por primera vez, una interfaz gráfica para mi proyecto musical, dicha interfaz necesitaba de un fondo colorido para la imagen que indicaba qué canción se reproducía.
El problema es que, fijándome en la documentación del portal para desarrolladores de Spotify, en ningún lado se hacía referencia a colores y mucho menos se hacía pública esa información (cosa que sí hace Apple, por ejemplo), tampoco en su blog de ingeniería y tecnología; además, yo no tenía idea de cómo si quiera empezar a programar un algoritmo de obtención de color por lo menos similar. Tenía una idea meramente intuitiva de qué color era el que se escogía dependiendo de cómo se veía las carátulas de los álbumes, pero eso difícilmente sería suficiente.
Me puse a ver unos cuantos ejemplos, quizás así se me ocurriría algo, un punto por el cual empezar.




¿puedo evitar la fatiga?
Pensé que las imágenes seguramente se procesan en el lado del servidor ya que los colores se mantienen (en su mayoría) a través de distintas plataformas pero también estaba seguro de no haber encontrado nada al respecto en la documentación, entonces me fui al reproductor web para escudriñar las solicitudes y respuestas y sí, efectivamente, encontré que, cuando se realiza la solicitud para obtener las letras de una canción, también incluye la respuesta el color de fondo que deberían tener, color que estaba buscando.
, incluyendo color de fondo y letras (gran canción, por cierto, gracias FIFA 2004) ‘Respuesta con el color de fondo’](https://cdn.inobtenio.com/img/posts/spotify-song-colors/color-response_hu_fc258a158943dcd.webp)
La diferencia es que la dirección a la que se hace la solicitud no tiene mucho que ver con la API oficial, imagino que serán servicios internos que sólo pueden usar aplicaciones first party. La dirección tiene la forma https://spclient.wg.spotify.com/color-lyrics/v2/track/{track_id}/image/{image_url}
y sólo retorna el color esperado si Spotify (o Musixmatch) tienen registro de las letras correspondientes o si están disponibles para la región en la cual está registrada la cuenta y si la imagen a procesar también es parte de su base de datos; de lo contrario, no hay ni letra, ni color.
Sólo tenía que descifrar cómo obtener el token que se requiere para automatizar el proceso, simular ser un cliente web desde la terminal y obtener todos los colores que pueda querer. El impedimento fue mi flojera para encontrar patrones en los 10 encabezados que tenían las solicitudes y la amenaza de sentencia de muerte que me hizo Spotify, así que no continué por ese camino. Parecía prometedor.

Intentando entender a Spotify
Tenía que idear alguna otra forma, así que intenté entender qué color escoge Spotify para los fondos. ¿quizás el más colorido? ¿quizás el que ocupa una mayor región en la imagen? ¿quizás el que más resalta? ¿a lo mejor una combinación de todos los anteriores y aún más aspectos?
Veamos unas cuantas imágenes acompañadas de su color de fondo seleccionado:






- Figura 1: parece fácil, el color más vivo (o el más alejado del blanco y negro, como lo entiendo yo) es el rojo y no en una proporción despreciable. Color resultante: rojo.
- Figura 2: supongamos que ni el blanco ni el gris de las letras superiores serán elegidos, así que quedan como opciones el magenta, amarillo, celeste y hasta verde pero ninguno sobresale demasiado. Color resultante: rosa.
- Figura 3: yo esperaba que fuera negro pero, aparentemente, el algoritmo lo evita a toda costa, al igual que el blanco. Color resultante: gris.
- Figura 4: aquí hay problemas, descarto el negro pero el rojo/naranja resalta del azul, parece que el azul no es tan discriminado pero me genera dudas. Color resultante: azul.
- Figura 5: bueno, se va comprobando la hipótesis del color más colorido, aunque el amarillo pudo haber sido descartado por apagado o por poco representativo. Color resultante: magenta.
- Figura 6: se pone difícil, hay por lo menos tres colores que se podrían considerar vivos de alguna forma pero aparentemente, de entre ellos, se elige al más oscuro. Color resultante: azul.
Si tomamos en cuenta también las primeras imágenes de este artículo, la idea se suele mantener en pie salvo por dos. En el caso de la segunda, yo diría que el color predominante, en todo aspecto, debería oscilar entre el naranja y el rojo pero el fondo elegido oficialmente es azul; un poco más de lo mismo con la tercera, el color que resalta es una suerte de naranja pero resulta en azul también.
No he podido hasta ahora determinarlo del todo, pero creo que la cosa va más o menos así:
- La imagen se repartirá en
N
grupos de colores similares para no lidiar con potencialmente cientos de colores ligeramente diferentes. Esto ayuda al rendimiento del algoritmo. - Siempre se va a preferir a los colores más coloridos o vívidos (definidos más adelante) por encima del resto. Este factor de preferencia será definido por
qC
. - En igualdad de condiciones en cuanto a qué tan colorido es un color, el color más oscuro (o sea, el menos luminoso) gana. Este coeficiente de preferencia será almacenado en
qD
. - El porcentaje de la imagen que ocupa el color se tendrá en cuenta pero no se le dará tanta relevancia como su nivel de vividness, permítanme el neologismo coyuntural.Dicha relevancia será definida por
qR
. - Si el color resultante es blanco o negro se elegirá, en su lugar, un gris más claro o más oscuro dependiendo del color obtenido previamente.
- Al color obtenido finalmente se le incrementará la saturación en un factor de
S
para hacerlo contrastar más con el color blanco.
En pseudo código, ignoren la caligrafía:

Intentando copiar a Spotify
Ahora, ¿cómo calcular la (permítanme la palabreja) “vividez”? Con coordenadas RGB es un poco complicado, así que trasladaremos los colores al espacio de color CIELAB, que nos permite realizar este tipo de operaciones matemáticas de forma más simple y sin tener que recurrir a trigonometría necesariamente. Es como pasar de coordenadas cartesianas a polares. En este enlace se puede encontrar una guía de X-Rite y PANTONE para entender mejor cómo se expresan matemáticamente los colores en sus distintos sistemas.
Según la teoría, qué tan vívido percibe un humano un determinado color depende de su saturación y su luminosidad, de la siguiente forma:
\begin{aligned} V = (L^*)^2 + (C^*ab)^2 \end{aligned}
Donde L* es luminosidad y C*ab es saturación
Pero la ignoré tras descubrir que centrarme en la saturación (C*ab
) como primer componente me daba mejores resultados.
Sin embargo, para el cálculo de la luminosidad sí haremos uso del espacio RGB, ya que es más sencillo de esa forma. La ecuación (descrita en este manual del organismo de telecomunicaciones de la ONU en el apartado 3.2) es:
\begin{aligned} Y = 0.2126 * R_{lin} + 0.7152 * G_{lin} + 0.0722 * B_{lin} \end{aligned}
Donde cada componente RGB está expresado de forma lineal ―o sin la codificación gama―
Utilicé los valores de 10 para el número de grupos o clusters y de 5.0, 1.5 y 0.8 para qC
, qD
y qR
, respectivamente, después de haber hecho unas pruebas rápidas viendo qué color devolvía el algoritmo y qué color ponía Spotify en sus aplicaciones. Funcionaba bien empíricamente pero quizás había coeficientes que funcionaban mejor y que me tomarían mucho tiempo de ensayo y error encontrar. Aparte de eso, la fórmula que se me había ocurrido podría o no ser una buena alternativa para el cálculo que necesitaba.
Era momento de usar la estadística para respaldar mis suposiciones.
Intentando mejorar la copia
Con ayuda de la dirección prohibida de la cual Spotify no quería que hiciera uso que mencioné líneas más arriba (de la que hice uso con una cuenta alternativa para no morir) armé un script rápido para obtener datos clave de unas 100 canciones medianamente aleatorias (aunque seguramente influenciadas por mis gustos musicales), lo cual me permitía someter las carátulas de los álbumes a ser procesadas por el algoritmo y comparar el resultado con el color oficial, aparte de los tres aspectos que contribuyen al puntaje final sin tomar en cuenta los coeficientes qX
.
Algo más o menos así, aunque con bastantes más filas, con 10/20 resultados por id
(dependiendo de N
) y más decimales en los números:
track_id | track_name | cover_url | cluster_color⠀⠀⠀ | spotify_color⠀⠀⠀ | chroma | darkness | dominance | score | color_delta |
---|---|---|---|---|---|---|---|---|---|
id1 | name1 | url1 | (193, 53, 115) | (213, 54, 125) | 0.5519 | 0.8480 | 0.0694 | 4.0873 | 5.1784 |
︙ | ︙ | ︙ | ︙ | ︙ | ︙ | ︙ | ︙ | ︙ | ︙ |
id1 | name1 | url1 | (253, 251, 251) | (213, 54, 125) | 0.0073 | 0.0282 | 0.7031 | 0.6418 | 57.4733 |
id2 | name2 | url2 | (181, 78, 37) | (219, 59, 31) | 0.5463 | 0.8446 | 0.0699 | 4.0545 | 13.1916 |
︙ | ︙ | ︙ | ︙ | ︙ | ︙ | ︙ | ︙ | ︙ | ︙ |
id2 | name2 | url2 | (247, 248, 246) | (219, 59, 31) | 0.0057 | 0.0612 | 0.0534 | 0.1633 | 66.4606 |
chroma
hace referencia a colorfulness
en pseudocódigo. Parecía más apropiado.
En R pude realizar un par de regresiones lineales para encontrar los mejores coeficientes, por lo menos para las 100 canciones en mención, dando más peso a los casos en los que un score
mayor coincidía con un color_delta
menor, lo cual significa que la fórmula funciona (o que está en el buen camino) y quitando relevancia al resto de resultados por cada grupo de colores pertenecientes a una imagen.
Pero la fórmula podía no ser la más adecuada para el cálculo, así que también intenté un ligero cambio:
S = qC * chroma + qD * darkness + qR * dominance => S = chroma ^ qC + darkness ^ qD + dominance ^ qR
Nótese el cambio de *
a ^
, pasando de una fórmula lineal a una exponencial.
Y volví a generar datos. El formato era el mismo que el de la tabla de más arriba pero los datos, claro, cambiaron. Decidí no dar demasiadas vueltas y empezar con valores bastantes parecidos a los que la fórmula lineal, 5.0, 1.2 y 0.9, y confiar en las matemáticas.
De esta forma, sin embargo, una regresión lineal ya no sería el análisis adecuado para los datos, así que después de algo de research fui por el método de mínimos cuadrados no lineales (o NLS para los amigos).
Luego de unas cuantas rondas de fitting puse los resultados en una pequeña tabla para poder escoger mejor el camino que tomaría.

Es claro que ninguna de las dos fórmulas, ya sea usando 10 o 20 grupos de colores, es demasiado acertada. Lo más seguro, creo, es que los ingenieros de Spotify tomen en cuenta más variables de las que yo con este algoritmo, aunque no se me ocurre cuáles. También es posible que la fórmula en sí sea bastante más complicada, por supuesto.
Pero no importa, en todo ese mar de números mediocres hay un método que seguramente lo es menos que el resto, así que iré a la guerra con él aunque sólo tenga un porcentaje de aciertos del 31%.
El código resultante para la fórmula lineal ajustada de 20 clusters terminaría siendo este:
import math
import numpy as np
from PIL import Image
from sklearn.cluster import KMeans
qC = 4.9226
qD = 1.4060
qR = 0.7932
def calculate_dark_colorfulness(rgb):
red, green, blue = [x / 255.0 for x in rgb]
red_greenness = red - green # or the a component
yellow_blueness = (red + green)/2 - blue # or the b component. red + green output yellow in additive color (light)
chroma = math.sqrt(red_greenness ** 2 + yellow_blueness ** 2)
"""
Choose any of these three, bascially according to preference, the output is not going to change
significantly. If you care about accuracy, you're free to do some research to determine the best
option for your use case. Keep in mind that the overall formula coefficients might need to change too.
They're as follows:
First option: the standard way of obtaining luminance from RGB coordinates but without linearizing
Second option: same as the previous, but linear (removing gamma correction)
Third option: Independent HSP color space "percieved brightness" (https://alienryderflex.com/hsp.html)
"""
# luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue # Relative luminance
# luminance = 0.2126 * (red ** (1/2.2)) + 0.7152 * (green ** (1/2.2)) + 0.0722 * (blue ** (1/2.2))# Relative luminance without gamma correction
luminance = math.sqrt(0.299 * (red ** 2) + 0.587 * (green ** 2) + 0.114 * (blue ** 2)) # Percieved brightness (HSP)
darkness = 1 - luminance
return (chroma, darkness)
def increase_saturation(rgb, amount=0.2):
red, green, blue = [x / 255.0 for x in rgb]
hue, light, sat = colorsys.rgb_to_hls(red, green, blue)
sat = min(1.0, sat + amount) # Cap at 1.0
r_new, g_new, b_new = colorsys.hls_to_rgb(hue, light, sat)
return tuple(int(x * 255) for x in (r_new, g_new, b_new))
def improve_white_contrast(rgb, brightness_factor=0.85, saturation_boost=1.1):
red, green, blue = [x / 255 for x in rgb]
hue, sat, value = colorsys.rgb_to_hsv(red, green, blue)
sat = min(sat * saturation_boost, 1.0)
value = max(value * brightness_factor, 0.0) # darken
red, green, blue = colorsys.hsv_to_rgb(hue, sat, value)
return tuple(int(x * 255) for x in (red, green, blue))
def extract_color_clusters(num_clusters):
image = Image.open("./cover.jpg").convert("RGB")
width, height = image.size
try:
pixels = np.array(image).reshape(-1, 3) # Turn a RGB matrix into an RGB 2D array
# Cluster colors using K-Means
kmeans = KMeans(n_clusters=num_clusters, random_state=0, n_init="auto")
kmeans.fit(pixels)
labels = kmeans.labels_
colors = []
# Group colors by cluster and calculate average for each
clusters = [[] for _ in range(num_clusters)]
for i, label in enumerate(labels):
clusters[label].append(pixels[i])
for group in clusters:
color = np.mean(group, axis=0)
chroma, darkness = calculate_dark_colorfulness(color)
dominance = len(group)/(width * height)
score = chroma * qC + darkness * qD + dominance * qR
colors.append({
'color': tuple(int(c) for c in color),
'score': score,
})
return colors
except Exception as e:
print(e)
print("Black and white image?")
return [{
'color': (128,128,128),
'score': 10.0,
}]
def get_best_color():
return max(extract_color_clusters(20), key=lambda c: c['score'])
if __name__ == '__main__':
print(get_best_color())
Por supuesto, la fórmula final pasa a ser:
\begin{align} S = 4.923 * Ch + 1.406 * Dk + 0.793 * Do \end{align}