83 lines
3.2 KiB
Python
83 lines
3.2 KiB
Python
"""Three colours per album, from its cover art.
|
|
|
|
The frontend paints an album card with these and the LED strips run an effect in them,
|
|
so a web-started album looks the same on the shelf as it does on the screen. Extraction
|
|
happens once per album ever - the result is cached next to the cover.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import colorsys
|
|
import hashlib
|
|
import logging
|
|
from io import BytesIO
|
|
from typing import cast
|
|
|
|
from musicmouse.library.models import AlbumColors
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
__all__ = ["colors_from_cover", "colors_from_id"]
|
|
|
|
#: Quantize to this many candidates before ranking. More just returns near-duplicates.
|
|
_PALETTE_SIZE = 8
|
|
#: Downscale first: colour proportions survive, the decode gets ~100x cheaper.
|
|
_SAMPLE_SIZE = (64, 64)
|
|
|
|
#: Greys and near-blacks read as mud on an RGBW strip and as dirt on a card, so a
|
|
#: candidate has to clear both bars to count as one of an album's colours.
|
|
_MIN_SATURATION = 0.25
|
|
_MIN_VALUE = 0.25
|
|
|
|
|
|
def _hex(r: int, g: int, b: int) -> str:
|
|
return f"#{r:02x}{g:02x}{b:02x}"
|
|
|
|
|
|
def colors_from_id(album_id: str) -> AlbumColors:
|
|
"""Synthesise a palette when there is no cover to take one from.
|
|
|
|
Analogous plus complementary off one hashed hue: distinct per album, never muddy.
|
|
"""
|
|
hue = int(hashlib.sha1(album_id.encode()).hexdigest()[:4], 16) % 360
|
|
out: list[str] = []
|
|
for offset, saturation, value in ((0, 0.72, 0.95), (30, 0.62, 0.80), (180, 0.70, 0.90)):
|
|
r, g, b = colorsys.hsv_to_rgb(((hue + offset) % 360) / 360, saturation, value)
|
|
out.append(_hex(int(r * 255), int(g * 255), int(b * 255)))
|
|
return out[0], out[1], out[2]
|
|
|
|
|
|
def colors_from_cover(image_data: bytes, album_id: str) -> AlbumColors:
|
|
"""Rank the cover's dominant colours, dropping the ones that would read as mud.
|
|
|
|
Falls back to :func:`colors_from_id` for the slots that do not fill - a black-and-
|
|
white cover legitimately has no three usable colours.
|
|
"""
|
|
try:
|
|
from PIL import Image
|
|
|
|
with Image.open(BytesIO(image_data)) as image:
|
|
sample = image.convert("RGB").resize(_SAMPLE_SIZE)
|
|
quantized = sample.quantize(colors=_PALETTE_SIZE, method=Image.Quantize.MAXCOVERAGE)
|
|
palette: list[int] = quantized.getpalette() or []
|
|
# On a quantized ("P" mode) image getcolors() yields (count, palette index).
|
|
# Pillow's annotation covers every mode, hence the cast.
|
|
counts = cast("list[tuple[int, int]]", quantized.getcolors() or [])
|
|
except Exception: # pragma: no cover - defensive around arbitrary embedded art
|
|
_log.debug("Cover of %s could not be read; using a synthesised palette", album_id)
|
|
return colors_from_id(album_id)
|
|
|
|
picked: list[str] = []
|
|
for _count, index in sorted(counts, reverse=True):
|
|
r, g, b = palette[index * 3 : index * 3 + 3]
|
|
_, saturation, value = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
|
|
if saturation < _MIN_SATURATION or value < _MIN_VALUE:
|
|
continue
|
|
picked.append(_hex(r, g, b))
|
|
if len(picked) == 3:
|
|
return picked[0], picked[1], picked[2]
|
|
|
|
fallback = colors_from_id(album_id)
|
|
filled = picked + list(fallback[len(picked) :])
|
|
return filled[0], filled[1], filled[2]
|