"""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]