Files
till cfe5d36c70 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
2026-07-23 10:46:38 +00:00

126 lines
4.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}")