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