- simple-icons als kompakter Index im Repository (3.459 Marken, 2 MB gzip),
erzeugt von scripts/vendor_simple_icons.py bzw. `make vendor-icons`
- logo.dev und Brandfetch als optionale Adapter, ohne Schlüssel übersprungen
- Favicon-Fallback und generierter Buchstaben-Avatar als Garantie
- Bei eindeutigem Offline-Treffer unterbleiben Anfragen nach außen komplett
- Cache im Dateisystem nach SHA-256, Auslieferung nur über /api/logos/{id}
mit immutable-Header und ETag
- Markenfarbe aus SVG-Fills bzw. per k-Means (k=4) über 64x64 Pixel, dazu eine
aufgehellte Variante mit mindestens 4,5:1 Kontrast auf dunklem Grund
- Kandidatensuche mit Vorauswahl, Auswahl, Upload und Zurücksetzen
- Bildtyp wird nur noch am Inhalt bestimmt, nicht an der gemeldeten Kopfzeile
- 60 neue Tests, insgesamt 208 grün
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
268 lines
9.5 KiB
Python
268 lines
9.5 KiB
Python
"""Ermittlung der Markenfarbe aus einem Logo.
|
||
|
||
Bei SVG wird die häufigste Nicht-Graustufe aus den `fill`-Attributen genommen,
|
||
bei Rastergrafiken entscheidet ein k-Means über die Pixel. Zusätzlich wird eine
|
||
aufgehellte Variante berechnet, die auf dem dunklen Hintergrund der Oberfläche
|
||
den WCAG-Kontrast von 4,5:1 erreicht.
|
||
"""
|
||
|
||
import colorsys
|
||
import hashlib
|
||
import io
|
||
import logging
|
||
import re
|
||
from collections import Counter
|
||
from dataclasses import dataclass
|
||
|
||
from PIL import Image, UnidentifiedImageError
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Hintergrund der dunklen Oberfläche – gegen ihn wird der Kontrast geprüft.
|
||
DARK_BACKGROUND = "#0f1115"
|
||
MIN_CONTRAST = 4.5
|
||
|
||
# Ab diesem Sättigungswert gilt eine Farbe nicht mehr als Graustufe.
|
||
MIN_SATURATION = 0.12
|
||
# Cluster unterhalb dieses Anteils sind Ausreißer und werden verworfen.
|
||
MIN_CLUSTER_SHARE = 0.05
|
||
|
||
# Deterministische Ersatzfarben, wenn ein Logo rein grau ist.
|
||
FALLBACK_PALETTE = (
|
||
"#ef4444",
|
||
"#f97316",
|
||
"#eab308",
|
||
"#22c55e",
|
||
"#14b8a6",
|
||
"#3b82f6",
|
||
"#6366f1",
|
||
"#a855f7",
|
||
"#ec4899",
|
||
)
|
||
|
||
_FILL_PATTERN = re.compile(r'fill\s*[:=]\s*["\']?\s*(#[0-9a-fA-F]{3,8}|rgb\([^)]+\))', re.I)
|
||
_STOP_COLOR_PATTERN = re.compile(r'stop-color\s*[:=]\s*["\']?\s*(#[0-9a-fA-F]{3,8})', re.I)
|
||
_RGB_PATTERN = re.compile(r"rgb\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)", re.I)
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class BrandColors:
|
||
"""Markenfarbe und die für dunkle Oberflächen aufgehellte Variante."""
|
||
|
||
color: str
|
||
color_dark: str
|
||
|
||
|
||
# --- Umrechnungen --------------------------------------------------------------
|
||
|
||
|
||
def normalize_hex(value: str) -> str | None:
|
||
"""Bringt eine Farbangabe auf `#rrggbb`. Ungültiges ergibt None."""
|
||
text = value.strip()
|
||
if not text.startswith("#"):
|
||
text = f"#{text}"
|
||
digits = text[1:]
|
||
if len(digits) in (4, 8): # Alphakanal abschneiden
|
||
digits = digits[:3] if len(digits) == 4 else digits[:6]
|
||
if len(digits) == 3:
|
||
digits = "".join(char * 2 for char in digits)
|
||
if len(digits) != 6 or not all(char in "0123456789abcdefABCDEF" for char in digits):
|
||
return None
|
||
return f"#{digits.lower()}"
|
||
|
||
|
||
def hex_to_rgb(value: str) -> tuple[int, int, int]:
|
||
normalized = normalize_hex(value) or "#000000"
|
||
return tuple(int(normalized[index : index + 2], 16) for index in (1, 3, 5)) # type: ignore[return-value]
|
||
|
||
|
||
def rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
||
red, green, blue = (max(0, min(255, round(channel))) for channel in rgb)
|
||
return f"#{red:02x}{green:02x}{blue:02x}"
|
||
|
||
|
||
def relative_luminance(rgb: tuple[int, int, int]) -> float:
|
||
"""Relative Leuchtdichte nach WCAG 2.1."""
|
||
channels = []
|
||
for value in rgb:
|
||
srgb = value / 255
|
||
channels.append(srgb / 12.92 if srgb <= 0.04045 else ((srgb + 0.055) / 1.055) ** 2.4)
|
||
red, green, blue = channels
|
||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
||
|
||
|
||
def contrast_ratio(first: str, second: str) -> float:
|
||
"""Kontrastverhältnis zweier Farben nach WCAG (1:1 bis 21:1)."""
|
||
light = relative_luminance(hex_to_rgb(first))
|
||
dark = relative_luminance(hex_to_rgb(second))
|
||
if light < dark:
|
||
light, dark = dark, light
|
||
return (light + 0.05) / (dark + 0.05)
|
||
|
||
|
||
def saturation_of(rgb: tuple[int, int, int]) -> float:
|
||
_, _, saturation = colorsys.rgb_to_hls(*(channel / 255 for channel in rgb))
|
||
return saturation
|
||
|
||
|
||
def lightness_of(rgb: tuple[int, int, int]) -> float:
|
||
_, lightness, _ = colorsys.rgb_to_hls(*(channel / 255 for channel in rgb))
|
||
return lightness
|
||
|
||
|
||
def is_grayscale(rgb: tuple[int, int, int]) -> bool:
|
||
return saturation_of(rgb) < MIN_SATURATION
|
||
|
||
|
||
def lighten_for_dark_background(color: str, background: str = DARK_BACKGROUND) -> str:
|
||
"""Hellt eine Farbe im HSL-Raum auf, bis der Kontrast mindestens 4,5:1 beträgt."""
|
||
normalized = normalize_hex(color)
|
||
if normalized is None:
|
||
return "#e2e8f0"
|
||
if contrast_ratio(normalized, background) >= MIN_CONTRAST:
|
||
return normalized
|
||
|
||
hue, lightness, saturation = colorsys.rgb_to_hls(
|
||
*(channel / 255 for channel in hex_to_rgb(normalized))
|
||
)
|
||
step = 0.02
|
||
while lightness < 0.98:
|
||
lightness = min(lightness + step, 0.98)
|
||
channels = colorsys.hls_to_rgb(hue, lightness, saturation)
|
||
candidate = rgb_to_hex(tuple(round(channel * 255) for channel in channels))
|
||
if contrast_ratio(candidate, background) >= MIN_CONTRAST:
|
||
return candidate
|
||
# Selbst bei maximaler Helligkeit nicht erreichbar (sehr dunkler Farbton): neutral aufhellen.
|
||
return "#e2e8f0"
|
||
|
||
|
||
def deterministic_color(name: str) -> str:
|
||
"""Feste Farbe aus dem Namens-Hash – gleicher Name ergibt immer dieselbe Farbe."""
|
||
digest = hashlib.sha256(name.strip().lower().encode("utf-8")).digest()
|
||
return FALLBACK_PALETTE[digest[0] % len(FALLBACK_PALETTE)]
|
||
|
||
|
||
def brand_colors(color: str | None, *, fallback_name: str = "") -> BrandColors:
|
||
"""Baut das Farbpaar; ohne brauchbare Farbe greift der Namens-Hash."""
|
||
normalized = normalize_hex(color) if color else None
|
||
if normalized is None:
|
||
normalized = deterministic_color(fallback_name)
|
||
return BrandColors(color=normalized, color_dark=lighten_for_dark_background(normalized))
|
||
|
||
|
||
# --- SVG -----------------------------------------------------------------------
|
||
|
||
|
||
def color_from_svg(content: bytes) -> str | None:
|
||
"""Häufigste Nicht-Graustufe aus `fill`-Attributen und Verlaufsstopps."""
|
||
try:
|
||
text = content.decode("utf-8", errors="ignore")
|
||
except Exception: # pragma: no cover - decode mit errors="ignore" wirft nicht
|
||
return None
|
||
|
||
counter: Counter[str] = Counter()
|
||
for match in _FILL_PATTERN.finditer(text):
|
||
value = match.group(1)
|
||
if value.lower().startswith("rgb("):
|
||
numbers = _RGB_PATTERN.match(value)
|
||
if numbers is None:
|
||
continue
|
||
value = rgb_to_hex(tuple(int(part) for part in numbers.groups())) # type: ignore[arg-type]
|
||
normalized = normalize_hex(value)
|
||
if normalized and not is_grayscale(hex_to_rgb(normalized)):
|
||
counter[normalized] += 1
|
||
|
||
for match in _STOP_COLOR_PATTERN.finditer(text):
|
||
normalized = normalize_hex(match.group(1))
|
||
if normalized and not is_grayscale(hex_to_rgb(normalized)):
|
||
counter[normalized] += 1
|
||
|
||
if not counter:
|
||
return None
|
||
return counter.most_common(1)[0][0]
|
||
|
||
|
||
# --- Rastergrafiken ------------------------------------------------------------
|
||
|
||
|
||
def _kmeans(pixels: list[tuple[int, int, int]], k: int = 4, iterations: int = 20):
|
||
"""Schlanker k-Means über RGB-Tripel. Liefert (Zentrum, Anzahl) je Cluster."""
|
||
if not pixels:
|
||
return []
|
||
unique = list(dict.fromkeys(pixels))
|
||
if len(unique) <= k:
|
||
counts = Counter(pixels)
|
||
return [(color, counts[color]) for color in unique]
|
||
|
||
# Deterministische Startpunkte: gleichmäßig über die sortierten Farben verteilt.
|
||
ordered = sorted(unique, key=lambda rgb: (relative_luminance(rgb), rgb))
|
||
centers = [ordered[round(index * (len(ordered) - 1) / (k - 1))] for index in range(k)]
|
||
|
||
for _ in range(iterations):
|
||
buckets: list[list[tuple[int, int, int]]] = [[] for _ in centers]
|
||
for pixel in pixels:
|
||
best = min(
|
||
range(len(centers)),
|
||
key=lambda index: sum(
|
||
(pixel[channel] - centers[index][channel]) ** 2 for channel in range(3)
|
||
),
|
||
)
|
||
buckets[best].append(pixel)
|
||
|
||
moved = False
|
||
for index, bucket in enumerate(buckets):
|
||
if not bucket:
|
||
continue
|
||
center = tuple(round(sum(p[c] for p in bucket) / len(bucket)) for c in range(3))
|
||
if center != centers[index]:
|
||
centers[index] = center # type: ignore[call-overload]
|
||
moved = True
|
||
if not moved:
|
||
break
|
||
|
||
result = [(centers[index], len(bucket)) for index, bucket in enumerate(buckets) if bucket]
|
||
return sorted(result, key=lambda item: item[1], reverse=True)
|
||
|
||
|
||
def color_from_raster(content: bytes) -> str | None:
|
||
"""Dominante Farbe einer Rastergrafik über k-Means (k=4) auf 64×64 Pixeln."""
|
||
try:
|
||
with Image.open(io.BytesIO(content)) as image:
|
||
image = image.convert("RGBA")
|
||
image.thumbnail((64, 64), Image.Resampling.LANCZOS)
|
||
pixels = [
|
||
(red, green, blue)
|
||
for red, green, blue, alpha in image.getdata()
|
||
if alpha >= 128 # durchscheinende Ränder verfälschen die Farbe
|
||
]
|
||
except (UnidentifiedImageError, OSError, ValueError):
|
||
logger.debug("Rastergrafik konnte nicht gelesen werden.", exc_info=True)
|
||
return None
|
||
|
||
if not pixels:
|
||
return None
|
||
|
||
clusters = _kmeans(pixels, k=4)
|
||
total = sum(count for _, count in clusters)
|
||
if not total:
|
||
return None
|
||
|
||
# Nur Cluster mit nennenswertem Anteil, davon der sattteste; bei ähnlicher
|
||
# Sättigung gewinnt der hellere.
|
||
relevant = [
|
||
(color, count) for color, count in clusters if count / total >= MIN_CLUSTER_SHARE
|
||
] or clusters
|
||
farbig = [(color, count) for color, count in relevant if not is_grayscale(color)]
|
||
if not farbig:
|
||
return None
|
||
|
||
best = max(farbig, key=lambda item: saturation_of(item[0]) * 2 + lightness_of(item[0]))
|
||
return rgb_to_hex(best[0])
|
||
|
||
|
||
def extract_color(content: bytes, mime: str) -> str | None:
|
||
"""Ermittelt die Markenfarbe passend zum Dateityp."""
|
||
if mime == "image/svg+xml":
|
||
return color_from_svg(content)
|
||
return color_from_raster(content)
|