feat(logos): Provider-Kette, lokaler Cache und Markenfarben
- 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
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user