feat: Logo-Upload mit automatischem Freistellen

Findet die automatische Suche kein Logo, kann pro Kunde eine Bilddatei
hochgeladen werden. Ein Upload hat Vorrang vor der Suche.

Freistellen:
- remove_logo_background() entfernt den Hintergrund per Flood Fill von
  den Bildraendern her. Bewusst kein globaler Farbvergleich - sonst
  verschwindet auch Weiss innerhalb des Logos (etwa Text in einer
  Flaeche). Weiche Kante gegen Treppeneffekte.
- Umgesetzt mit Pillow, das ueber qrcode[pil] ohnehin vorhanden ist.
  Bewusst kein ML-Modell wie rembg: der Container laeuft mit read-only
  Root-FS, ein Modell-Cache zur Laufzeit waere dort nicht moeglich.
- Bereits transparente Bilder bleiben unangetastet. Ist der Bildrand
  nicht einfarbig, wird nicht freigestellt und die Oberflaeche weist
  darauf hin, statt ein beschaedigtes Logo zu liefern.
- Checkbox "Hintergrund behalten" schaltet das Freistellen pro Kunde ab.
- Gespeichert wird immer als PNG (JPEG kann keine Transparenz), grosse
  Bilder werden auf 800px verkleinert.

Ablage unter media/<site>/logos/logo_<slot>.png, neue Route
/media/<site>/logos/<datei>. Die URL traegt einen Zeitstempel, damit
Player kein altes Logo aus dem Cache zeigen.

Nebenbei behoben: welcome_data speicherte nur die erfolgreichen Kunden
statt der Felder 1-3. Fiel Kunde 1 aus, rutschte Kunde 2 beim naechsten
Aufruf in Feld 1 und Logo wie Ansprechpartner verschoben sich mit. Jetzt
feldweise - dadurch bleibt auch ein hochgeladenes Logo beim erneuten
Generieren erhalten, solange der Kundenname im selben Feld steht.

Die drei Kundenbloecke im Formular sind zu einer Schleife
zusammengefasst, damit die Upload-Felder nicht dreifach gepflegt werden
muessen.

README, AGENTS.md und Hilfeseite nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik Thiele
2026-09-02 13:55:40 +02:00
parent 4974ad92df
commit 64db742d31
6 changed files with 272 additions and 49 deletions

View File

@@ -9,6 +9,7 @@ Zielscreens ein (Screens mit "welcome_enabled", Fallback "lobby").
import os
import json
import time
from openai import OpenAI
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -32,6 +33,159 @@ def get_openai_client():
# Brandfetch-Logo-Suche
# -------------------------------------------------
# -------------------------------------------------
# Logo-Upload: Freistellen
# -------------------------------------------------
LOGO_MAX_SIZE = 800 # Kantenlänge, auf die grosse Uploads verkleinert werden
LOGO_TOLERANCE = 32 # Farbabstand, bis zu dem ein Pixel als Hintergrund gilt
LOGO_BORDER_UNIFORMITY = 48 # Streuung, bis zu der ein Rand als einfarbig durchgeht
def _color_distance(a, b):
"""Abstand zweier RGB-Werte (Manhattan über die Kanäle, gemittelt)."""
return (abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2])) / 3.0
def _border_color(px, w, h):
"""Ermittelt die Hintergrundfarbe aus den Randpixeln.
Gibt (farbe, einfarbig) zurück - einfarbig=False, wenn der Rand zu
stark streut und ein Freistellen deshalb nicht sinnvoll ist."""
rand = []
schritt = max(1, min(w, h) // 100)
for x in range(0, w, schritt):
rand.append(px[x, 0][:3])
rand.append(px[x, h - 1][:3])
for y in range(0, h, schritt):
rand.append(px[0, y][:3])
rand.append(px[w - 1, y][:3])
mitte = tuple(sorted(k[i] for k in rand)[len(rand) // 2] for i in range(3))
streuung = sum(_color_distance(k, mitte) for k in rand) / len(rand)
return mitte, streuung <= LOGO_BORDER_UNIFORMITY
def remove_logo_background(image, tolerance=LOGO_TOLERANCE):
"""Stellt ein Logo frei: entfernt den zusammenhängenden Hintergrund von den
Rändern her und macht ihn transparent.
Bewusst ein Flood Fill von den Rändern und kein globaler Farbvergleich -
so bleibt Weiss *innerhalb* des Logos (etwa Text in einer Fläche) erhalten.
Gibt (bild, hinweis) zurück. hinweis ist None bei Erfolg, sonst ein Text
für die Oberfläche - das Bild wird dann unverändert zurückgegeben.
"""
from collections import deque
from PIL import Image
img = image.convert("RGBA")
if max(img.size) > LOGO_MAX_SIZE:
img.thumbnail((LOGO_MAX_SIZE, LOGO_MAX_SIZE), Image.LANCZOS)
# Bereits transparent? Dann nicht anfassen.
alpha = img.getchannel("A")
if alpha.getextrema()[0] < 255:
return img, None
w, h = img.size
px = img.load()
bg, einfarbig = _border_color(px, w, h)
if not einfarbig:
return img, ("Der Bildrand ist nicht einfarbig – das Logo wurde unverändert "
"übernommen. Am besten ein PNG mit transparentem Hintergrund hochladen.")
# Flood Fill von allen Randpixeln aus
besucht = bytearray(w * h)
queue = deque()
for x in range(w):
for y in (0, h - 1):
queue.append((x, y))
for y in range(h):
for x in (0, w - 1):
queue.append((x, y))
while queue:
x, y = queue.popleft()
i = y * w + x
if besucht[i]:
continue
besucht[i] = 1
if _color_distance(px[x, y], bg) > tolerance:
continue
px[x, y] = (0, 0, 0, 0)
if x > 0: queue.append((x - 1, y))
if x < w - 1: queue.append((x + 1, y))
if y > 0: queue.append((x, y - 1))
if y < h - 1: queue.append((x, y + 1))
# Weiche Kante: Pixel direkt an der Freistellung teilweise durchscheinen
# lassen, sonst bleibt ein harter Treppenrand stehen.
for y in range(h):
for x in range(w):
r, g, b, a = px[x, y]
if a == 0:
continue
nachbar_frei = (
(x > 0 and px[x - 1, y][3] == 0) or
(x < w - 1 and px[x + 1, y][3] == 0) or
(y > 0 and px[x, y - 1][3] == 0) or
(y < h - 1 and px[x, y + 1][3] == 0)
)
if not nachbar_frei:
continue
d = _color_distance((r, g, b), bg)
if d < tolerance * 2:
px[x, y] = (r, g, b, int(255 * max(0.0, (d - tolerance) / tolerance)))
if not any(px[x, y][3] == 0 for x in range(0, w, max(1, w // 50)) for y in (0, h - 1)):
return img, "Es wurde kein zusammenhängender Hintergrund gefunden."
return img, None
LOGO_DIRNAME = "logos"
LOGO_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
def save_uploaded_logo(file_storage, site, slot, freistellen=True):
"""Nimmt ein hochgeladenes Logo entgegen, stellt es optional frei und legt es
unter media/<site>/logos/logo_<slot>.png ab.
Gespeichert wird immer als PNG - JPEG kann keine Transparenz.
Gibt (url, hinweis) zurück; url ist None, wenn die Datei unbrauchbar war.
Der Zeitstempel in der URL verhindert, dass Player ein altes Logo
aus dem Cache anzeigen."""
from PIL import Image, UnidentifiedImageError
name = (file_storage.filename or "").lower()
if not name.endswith(LOGO_EXTENSIONS):
return None, f"Format nicht unterstützt ({', '.join(LOGO_EXTENSIONS)})."
try:
img = Image.open(file_storage.stream)
img.load()
except (UnidentifiedImageError, OSError) as e:
return None, f"Datei konnte nicht gelesen werden: {e}"
hinweis = None
if freistellen:
img, hinweis = remove_logo_background(img)
else:
img = img.convert("RGBA")
logo_dir = os.path.join(MEDIA_DIR, site, LOGO_DIRNAME)
os.makedirs(logo_dir, exist_ok=True)
dateiname = f"logo_{slot}.png"
img.save(os.path.join(logo_dir, dateiname), "PNG")
url = f"/media/{site}/{LOGO_DIRNAME}/{dateiname}?v={int(time.time())}"
print(f"✅ Generate - Logo hochgeladen: {url}"
+ (f" (Hinweis: {hinweis})" if hinweis else ""))
return url, hinweis
def get_brandfetch_logo(domain):
"""Versucht, ein Firmenlogo über Brandfetch CDN abzurufen.
Baut eine direkte CDN-URL: https://cdn.brandfetch.io/{domain}/logo"""