diff --git a/app/ai_edit.py b/app/ai_edit.py new file mode 100644 index 0000000..16cf030 --- /dev/null +++ b/app/ai_edit.py @@ -0,0 +1,125 @@ +"""Generative AI-Bildbearbeitung via OpenRouter (Gemini/GPT Image-Modelle). + +Stark bei kreativen Edits: Hintergrund tauschen, Retusche, Restauration, +freie Prompts. NICHT geeignet fuer echtes Freistellen (kein Alpha-Kanal) oder +pixeltreues Upscaling – dafuer die lokalen Funktionen in enhance.py nutzen. +""" +from __future__ import annotations + +import base64 +import io +import time + +import httpx +from PIL import Image, ImageOps + +from . import config +from .enhance import Result + +# Vordefinierte Presets (Prompt-Bausteine) +PRESETS: dict[str, str] = { + "studio": ( + "Replace the background with a clean, professional photo-studio backdrop " + "(soft neutral gradient, subtle vignette). Keep the subject exactly the " + "same – same pose, face, clothing, colors. Photorealistic, high quality." + ), + "retouch": ( + "Professionally retouch this photo: even out skin tones, remove blemishes " + "and sensor noise, balance exposure and color, gentle natural sharpening. " + "Preserve the person's identity and natural look. Photorealistic." + ), + "restore": ( + "Restore this old or damaged photo: remove scratches, dust and creases, " + "fix fading and color casts, recover detail. Keep it faithful and natural, " + "do not add new elements. Photorealistic." + ), +} + +PRESET_LABELS = { + "studio": "Studio-Hintergrund", + "retouch": "AI-Retusche", + "restore": "Foto-Restaurierung", +} + + +def _prep_data_uri(image_bytes: bytes) -> str: + pil = Image.open(io.BytesIO(image_bytes)) + pil = ImageOps.exif_transpose(pil).convert("RGB") + w, h = pil.size + longest = max(w, h) + if longest > config.AI_EDIT_MAX_EDGE: + s = config.AI_EDIT_MAX_EDGE / longest + pil = pil.resize((int(w * s), int(h * s)), Image.LANCZOS) + buf = io.BytesIO() + pil.save(buf, format="JPEG", quality=92) + b64 = base64.b64encode(buf.getvalue()).decode() + return f"data:image/jpeg;base64,{b64}" + + +def _extract_image(message: dict) -> bytes | None: + imgs = message.get("images") or [] + for im in imgs: + url = (im.get("image_url") or {}).get("url") if isinstance(im, dict) else None + if url and url.startswith("data:"): + return base64.b64decode(url.split(",", 1)[1]) + # Manche Modelle liefern Bildteile im content-Array + content = message.get("content") + if isinstance(content, list): + for part in content: + if isinstance(part, dict): + url = (part.get("image_url") or {}).get("url", "") + if url.startswith("data:"): + return base64.b64decode(url.split(",", 1)[1]) + return None + + +def _call(instruction: str, data_uri: str) -> dict: + body = { + "model": config.OPENROUTER_MODEL, + "modalities": ["image", "text"], + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": instruction}, + {"type": "image_url", "image_url": {"url": data_uri}}, + ], + }], + } + headers = { + "Authorization": f"Bearer {config.OPENROUTER_API_KEY}", + "Content-Type": "application/json", + "X-Title": "Klarbildbot", + } + with httpx.Client(timeout=config.OPENROUTER_TIMEOUT) as client: + r = client.post(f"{config.OPENROUTER_BASE}/chat/completions", + json=body, headers=headers) + r.raise_for_status() + return r.json() + + +def edit(image_bytes: bytes, instruction: str) -> Result: + """Fuehrt einen generativen Edit aus und gibt das Ergebnisbild zurueck.""" + t0 = time.time() + data_uri = _prep_data_uri(image_bytes) + + resp = _call(instruction, data_uri) + msg = resp["choices"][0]["message"] + out = _extract_image(msg) + + if out is None: + # Ein Nachfassen: Modell explizit zur Bildausgabe zwingen + resp = _call(instruction + "\n\nOutput ONLY the edited image.", data_uri) + msg = resp["choices"][0]["message"] + out = _extract_image(msg) + + if out is None: + text = (msg.get("content") or "")[:200] if isinstance(msg.get("content"), str) else "" + raise RuntimeError( + "Das AI-Modell hat kein Bild geliefert" + (f" (Antwort: {text})" if text else "") + ) + + pil = Image.open(io.BytesIO(out)) + w, h = pil.size + fmt = "jpg" if out[:2] == b"\xff\xd8" else "png" + return Result(out, fmt, w, h, time.time() - t0, + f"openrouter/{config.OPENROUTER_MODEL}") diff --git a/app/bot.py b/app/bot.py index b1cdf20..74e5c27 100644 --- a/app/bot.py +++ b/app/bot.py @@ -23,7 +23,7 @@ from telegram.ext import ( filters, ) -from . import config, enhance, picdrop +from . import ai_edit, config, enhance, picdrop logging.basicConfig( format="%(asctime)s %(levelname)s %(name)s | %(message)s", @@ -63,13 +63,21 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: uid = update.effective_user.id if update.effective_user else "?" if not _allowed(update): return await _deny(update) + ai_block = "" + if config.AI_EDIT_ENABLED: + ai_block = ( + "• 🎨 *Studio-Hintergrund* / 🪄 *AI-Retusche* / 🖼 *Restaurierung*\n" + "• 💬 *Eigener AI-Prompt*: Bild mit Bildunterschrift senden " + "(z. B. 'Hintergrund zu Strand', 'mach es schwarz-weiß')\n" + ) await update.effective_message.reply_text( "👋 *Klarbildbot* – AI-Bildaufbereitung\n\n" "Schick mir ein Bild, dann kannst du wählen:\n" - "• 🔍 *Klarbild ×2 / ×4* – AI-Upscaling + Schärfen\n" - "• ✂️ *Freistellen* – Hintergrund per AI entfernen (PNG)\n" - "• ✨ *Freistellen + Klarbild* – beides\n\n" - "💡 Für beste Qualität das Bild als *Datei* senden " + "• 🔍 *Klarbild ×2 / ×4* – Upscaling + Schärfen (lokal, originaltreu)\n" + "• ✂️ *Freistellen* – Hintergrund entfernen, echtes transparentes PNG (lokal)\n" + "• ✨ *Freistellen + Klarbild* – beides\n" + + ai_block + + "\n💡 Für beste Qualität das Bild als *Datei* senden " "(Büroklammer → Datei), nicht als komprimiertes Foto.\n\n" f"Deine Telegram-ID: `{uid}`", parse_mode="Markdown", @@ -97,12 +105,81 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: # Bild empfangen -> Aktionsmenue # --------------------------------------------------------------------------- def _menu() -> InlineKeyboardMarkup: - return InlineKeyboardMarkup([ + rows = [ [InlineKeyboardButton("🔍 Klarbild ×2", callback_data="up:2"), InlineKeyboardButton("🔍 Klarbild ×4", callback_data="up:4")], [InlineKeyboardButton("✂️ Freistellen", callback_data="cut:0")], [InlineKeyboardButton("✨ Freistellen + Klarbild ×2", callback_data="cutup:2")], - ]) + ] + if config.AI_EDIT_ENABLED: + rows += [ + [InlineKeyboardButton("🎨 Studio-Hintergrund", callback_data="ai:studio"), + InlineKeyboardButton("🪄 AI-Retusche", callback_data="ai:retouch")], + [InlineKeyboardButton("🖼 Foto-Restaurierung", callback_data="ai:restore")], + ] + return InlineKeyboardMarkup(rows) + + +def _label(action: str, scale: int = 0, prompt: str = "") -> str: + return { + "up": f"Klarbild ×{scale}", + "cut": "Freistellen", + "cutup": f"Freistellen + Klarbild ×{scale}", + "ai": ai_edit.PRESET_LABELS.get(prompt, prompt), + "aiprompt": f"AI-Edit: {prompt[:40]}", + }.get(action, action) + + +def _do_job(action: str, scale: int, prompt: str, image_bytes: bytes) -> enhance.Result: + if action == "up": + return enhance.upscale(image_bytes, scale) + if action == "cut": + return enhance.cutout(image_bytes) + if action == "cutup": + return enhance.cutout_then_upscale(image_bytes, scale) + if action == "ai": + return ai_edit.edit(image_bytes, ai_edit.PRESETS[prompt]) + if action == "aiprompt": + return ai_edit.edit(image_bytes, prompt) + raise ValueError(action) + + +async def _process(ctx: ContextTypes.DEFAULT_TYPE, chat_id: int, status, + file_id: str, action: str, scale: int, prompt: str) -> None: + """Bild laden, Job im Executor ausfuehren, Ergebnis als Dokument senden. + `status` ist eine Telegram-Message, deren Text als Fortschritt editiert wird.""" + label = _label(action, scale, prompt) + await status.edit_text(f"⏳ {label} läuft … (kann bei großen Bildern dauern)") + try: + tg_file = await ctx.bot.get_file(file_id) + image_bytes = bytes(await tg_file.download_as_bytearray()) + except Exception as exc: + log.exception("Download fehlgeschlagen") + return await status.edit_text( + f"❌ Konnte das Bild nicht laden: {exc}\n" + "(Telegram-Bots können Dateien bis 20 MB laden.)") + + await ctx.bot.send_chat_action(chat_id, ChatAction.UPLOAD_DOCUMENT) + loop = asyncio.get_running_loop() + try: + result = await loop.run_in_executor( + EXECUTOR, _do_job, action, scale, prompt, image_bytes) + except Exception as exc: + log.exception("Verarbeitung fehlgeschlagen") + return await status.edit_text(f"❌ Fehler bei der Verarbeitung: {exc}") + + caption = (f"✅ {label}\n{result.width}×{result.height}px · {result.seconds:.1f}s" + + (f"\n{result.note}" if result.note else "")) + try: + await ctx.bot.send_document( + chat_id=chat_id, + document=InputFile(result.data, filename=f"klarbild.{result.fmt}"), + caption=caption, + ) + await status.edit_text(f"✅ Fertig: {label}") + except Exception as exc: + log.exception("Senden fehlgeschlagen") + await status.edit_text(f"❌ Konnte Ergebnis nicht senden: {exc}") async def on_image(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: @@ -119,8 +196,16 @@ async def on_image(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: else: return await msg.reply_text("Bitte ein Bild senden.") + # Bild mit Bildunterschrift + AI aktiv => Unterschrift als freier AI-Prompt + caption = (msg.caption or "").strip() + if caption and config.AI_EDIT_ENABLED: + status = await msg.reply_text("🪄 AI-Edit vorbereiten …") + return await _process(ctx, msg.chat_id, status, file_id, "aiprompt", 0, caption) + PENDING[update.effective_user.id] = {"file_id": file_id, "source": source} hint = "" if source == "document" else "\n_(Tipp: als Datei senden = volle Auflösung)_" + if config.AI_EDIT_ENABLED: + hint += "\n💬 _Oder Bild mit Bildunterschrift senden = eigener AI-Prompt._" await msg.reply_text( "Was soll ich damit machen?" + hint, reply_markup=_menu(), @@ -145,54 +230,11 @@ async def on_action(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: action, _, arg = query.data.partition(":") scale = int(arg) if arg.isdigit() else 0 - - labels = {"up": f"Klarbild ×{scale}", "cut": "Freistellen", - "cutup": f"Freistellen + Klarbild ×{scale}"} - await query.edit_message_text(f"⏳ {labels.get(action, action)} läuft … " - "(kann bei großen Bildern etwas dauern)") - - # Original holen (volle Aufloesung) - try: - tg_file = await ctx.bot.get_file(pend["file_id"]) - image_bytes = bytes(await tg_file.download_as_bytearray()) - except Exception as exc: - log.exception("Download fehlgeschlagen") - return await query.edit_message_text( - f"❌ Konnte das Bild nicht laden: {exc}\n" - "(Telegram-Bots können Dateien bis 20 MB laden.)") - - await ctx.bot.send_chat_action(update.effective_chat.id, ChatAction.UPLOAD_DOCUMENT) - loop = asyncio.get_running_loop() - - def work() -> enhance.Result: - if action == "up": - return enhance.upscale(image_bytes, scale) - if action == "cut": - return enhance.cutout(image_bytes) - if action == "cutup": - return enhance.cutout_then_upscale(image_bytes, scale) - raise ValueError(action) + prompt = arg if action == "ai" else "" # bei AI-Presets ist arg der Preset-Key try: - result = await loop.run_in_executor(EXECUTOR, work) - except Exception as exc: - log.exception("Verarbeitung fehlgeschlagen") - return await query.edit_message_text(f"❌ Fehler bei der Verarbeitung: {exc}") - - fname = f"klarbild.{result.fmt}" - caption = (f"✅ {labels.get(action, action)}\n" - f"{result.width}×{result.height}px · {result.seconds:.1f}s" - + (f"\n{result.note}" if result.note else "")) - try: - await ctx.bot.send_document( - chat_id=update.effective_chat.id, - document=InputFile(result.data, filename=fname), - caption=caption, - ) - await query.edit_message_text(f"✅ Fertig: {labels.get(action, action)}") - except Exception as exc: - log.exception("Senden fehlgeschlagen") - await query.edit_message_text(f"❌ Konnte Ergebnis nicht senden: {exc}") + await _process(ctx, update.effective_chat.id, query.message, + pend["file_id"], action, scale, prompt) finally: PENDING.pop(uid, None) diff --git a/app/config.py b/app/config.py index b34a7b4..76f54eb 100644 --- a/app/config.py +++ b/app/config.py @@ -63,6 +63,17 @@ POST_SHARPEN: bool = _get_bool("POST_SHARPEN", True) # JPEG-Qualitaet fuer zurueckgesendete Fotos (Freistellen liefert immer PNG). JPEG_QUALITY: int = _get_int("JPEG_QUALITY", 95) +# --- OpenRouter (generative AI-Edits: Hintergrund, Retusche, freier Prompt) -- +# Achtung: US-gehostet (Google/OpenAI via OpenRouter). Bewusst gesetzt. +OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "").strip() +OPENROUTER_BASE: str = os.getenv("OPENROUTER_BASE", "https://openrouter.ai/api/v1") +OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "google/gemini-3.1-flash-image") +OPENROUTER_TIMEOUT: int = _get_int("OPENROUTER_TIMEOUT", 120) +# Laengste Kante, auf die Eingangsbilder vor dem Senden verkleinert werden +# (Kosten/Tempo). Das Modell gibt ohnehin ~1024px-Ausgaben zurueck. +AI_EDIT_MAX_EDGE: int = _get_int("AI_EDIT_MAX_EDGE", 1536) +AI_EDIT_ENABLED: bool = bool(OPENROUTER_API_KEY) + # --- Health-Server ---------------------------------------------------------- HEALTH_PORT: int = _get_int("PORT", 8080) diff --git a/requirements.txt b/requirements.txt index 8ff8624..b53b802 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ opencv-contrib-python-headless==4.10.0.84 numpy==1.26.4 pillow==10.4.0 aiohttp==3.10.10 +httpx==0.27.2 paramiko==3.5.0 pymatting==1.1.12 scipy==1.13.1