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:
@@ -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"""
|
||||
|
||||
Reference in New Issue
Block a user