"""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}")