AI-Edit-Ebene via OpenRouter (Gemini Image): Studio-Hintergrund, Retusche, Restaurierung + freier Prompt
- app/ai_edit.py: OpenRouter chat/completions (image in/out), Presets + Prompt, Retry - bot: AI-Preset-Buttons + Bildunterschrift = freier AI-Prompt; _process refaktoriert - Freistellen/Upscaling bleiben lokal (originaltreu); AI-Edit fuer generative Edits - httpx pin, config OPENROUTER_* Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
+125
@@ -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}")
|
||||||
+95
-53
@@ -23,7 +23,7 @@ from telegram.ext import (
|
|||||||
filters,
|
filters,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import config, enhance, picdrop
|
from . import ai_edit, config, enhance, picdrop
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
format="%(asctime)s %(levelname)s %(name)s | %(message)s",
|
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 "?"
|
uid = update.effective_user.id if update.effective_user else "?"
|
||||||
if not _allowed(update):
|
if not _allowed(update):
|
||||||
return await _deny(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(
|
await update.effective_message.reply_text(
|
||||||
"👋 *Klarbildbot* – AI-Bildaufbereitung\n\n"
|
"👋 *Klarbildbot* – AI-Bildaufbereitung\n\n"
|
||||||
"Schick mir ein Bild, dann kannst du wählen:\n"
|
"Schick mir ein Bild, dann kannst du wählen:\n"
|
||||||
"• 🔍 *Klarbild ×2 / ×4* – AI-Upscaling + Schärfen\n"
|
"• 🔍 *Klarbild ×2 / ×4* – Upscaling + Schärfen (lokal, originaltreu)\n"
|
||||||
"• ✂️ *Freistellen* – Hintergrund per AI entfernen (PNG)\n"
|
"• ✂️ *Freistellen* – Hintergrund entfernen, echtes transparentes PNG (lokal)\n"
|
||||||
"• ✨ *Freistellen + Klarbild* – beides\n\n"
|
"• ✨ *Freistellen + Klarbild* – beides\n"
|
||||||
"💡 Für beste Qualität das Bild als *Datei* senden "
|
+ ai_block +
|
||||||
|
"\n💡 Für beste Qualität das Bild als *Datei* senden "
|
||||||
"(Büroklammer → Datei), nicht als komprimiertes Foto.\n\n"
|
"(Büroklammer → Datei), nicht als komprimiertes Foto.\n\n"
|
||||||
f"Deine Telegram-ID: `{uid}`",
|
f"Deine Telegram-ID: `{uid}`",
|
||||||
parse_mode="Markdown",
|
parse_mode="Markdown",
|
||||||
@@ -97,12 +105,81 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
|
|||||||
# Bild empfangen -> Aktionsmenue
|
# Bild empfangen -> Aktionsmenue
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def _menu() -> InlineKeyboardMarkup:
|
def _menu() -> InlineKeyboardMarkup:
|
||||||
return InlineKeyboardMarkup([
|
rows = [
|
||||||
[InlineKeyboardButton("🔍 Klarbild ×2", callback_data="up:2"),
|
[InlineKeyboardButton("🔍 Klarbild ×2", callback_data="up:2"),
|
||||||
InlineKeyboardButton("🔍 Klarbild ×4", callback_data="up:4")],
|
InlineKeyboardButton("🔍 Klarbild ×4", callback_data="up:4")],
|
||||||
[InlineKeyboardButton("✂️ Freistellen", callback_data="cut:0")],
|
[InlineKeyboardButton("✂️ Freistellen", callback_data="cut:0")],
|
||||||
[InlineKeyboardButton("✨ Freistellen + Klarbild ×2", callback_data="cutup:2")],
|
[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:
|
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:
|
else:
|
||||||
return await msg.reply_text("Bitte ein Bild senden.")
|
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}
|
PENDING[update.effective_user.id] = {"file_id": file_id, "source": source}
|
||||||
hint = "" if source == "document" else "\n_(Tipp: als Datei senden = volle Auflösung)_"
|
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(
|
await msg.reply_text(
|
||||||
"Was soll ich damit machen?" + hint,
|
"Was soll ich damit machen?" + hint,
|
||||||
reply_markup=_menu(),
|
reply_markup=_menu(),
|
||||||
@@ -145,54 +230,11 @@ async def on_action(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
|
|||||||
|
|
||||||
action, _, arg = query.data.partition(":")
|
action, _, arg = query.data.partition(":")
|
||||||
scale = int(arg) if arg.isdigit() else 0
|
scale = int(arg) if arg.isdigit() else 0
|
||||||
|
prompt = arg if action == "ai" else "" # bei AI-Presets ist arg der Preset-Key
|
||||||
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)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await loop.run_in_executor(EXECUTOR, work)
|
await _process(ctx, update.effective_chat.id, query.message,
|
||||||
except Exception as exc:
|
pend["file_id"], action, scale, prompt)
|
||||||
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}")
|
|
||||||
finally:
|
finally:
|
||||||
PENDING.pop(uid, None)
|
PENDING.pop(uid, None)
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ POST_SHARPEN: bool = _get_bool("POST_SHARPEN", True)
|
|||||||
# JPEG-Qualitaet fuer zurueckgesendete Fotos (Freistellen liefert immer PNG).
|
# JPEG-Qualitaet fuer zurueckgesendete Fotos (Freistellen liefert immer PNG).
|
||||||
JPEG_QUALITY: int = _get_int("JPEG_QUALITY", 95)
|
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-Server ----------------------------------------------------------
|
||||||
HEALTH_PORT: int = _get_int("PORT", 8080)
|
HEALTH_PORT: int = _get_int("PORT", 8080)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ opencv-contrib-python-headless==4.10.0.84
|
|||||||
numpy==1.26.4
|
numpy==1.26.4
|
||||||
pillow==10.4.0
|
pillow==10.4.0
|
||||||
aiohttp==3.10.10
|
aiohttp==3.10.10
|
||||||
|
httpx==0.27.2
|
||||||
paramiko==3.5.0
|
paramiko==3.5.0
|
||||||
pymatting==1.1.12
|
pymatting==1.1.12
|
||||||
scipy==1.13.1
|
scipy==1.13.1
|
||||||
|
|||||||
Reference in New Issue
Block a user