7c23c3f4c7
- Telegram-Bot (Polling) mit Inline-Menue: Klarbild x2/x4, Freistellen, kombiniert - Upscaling via OpenCV dnn_superres (FSRCNN default, EDSR optional), gekachelt - Freistellen via rembg (isnet-general-use, Alpha-Matting) - Picdrop-Batch via SFTP (/picdrop) - Health-Server (aiohttp) fuer Coolify, Zugriffsschutz via ALLOWED_USER_IDS - Dockerfile backt Modelle (SR von raw.githubusercontent, rembg von HF-Mirror) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
275 lines
9.4 KiB
Python
275 lines
9.4 KiB
Python
"""AI-Bildverarbeitung: Freistellen (rembg) + Upscaling (dnn_superres / Real-ESRGAN).
|
||
|
||
Alle Operationen laufen lokal auf CPU. Keine externen/US-APIs.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import time
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import cv2
|
||
import numpy as np
|
||
from PIL import Image
|
||
|
||
from . import config
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Lazy-geladene, wiederverwendete Modelle (teuer beim ersten Mal).
|
||
# ---------------------------------------------------------------------------
|
||
_rembg_session = None
|
||
_sr_cache: dict[tuple[str, int], "cv2.dnn_superres.DnnSuperResImpl"] = {}
|
||
_ort_session = None
|
||
|
||
|
||
@dataclass
|
||
class Result:
|
||
data: bytes
|
||
fmt: str # "png" oder "jpg"
|
||
width: int
|
||
height: int
|
||
seconds: float
|
||
note: str = ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Hilfsfunktionen
|
||
# ---------------------------------------------------------------------------
|
||
def _load_bgr(image_bytes: bytes) -> np.ndarray:
|
||
"""Bytes -> BGR uint8 (EXIF-Rotation beruecksichtigt)."""
|
||
pil = Image.open(io.BytesIO(image_bytes))
|
||
try:
|
||
from PIL import ImageOps
|
||
|
||
pil = ImageOps.exif_transpose(pil)
|
||
except Exception:
|
||
pass
|
||
pil = pil.convert("RGB")
|
||
rgb = np.array(pil)
|
||
return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
||
|
||
|
||
def _limit_input(img: np.ndarray, max_edge: int) -> np.ndarray:
|
||
h, w = img.shape[:2]
|
||
longest = max(h, w)
|
||
if longest <= max_edge:
|
||
return img
|
||
scale = max_edge / longest
|
||
new = (int(round(w * scale)), int(round(h * scale)))
|
||
return cv2.resize(img, new, interpolation=cv2.INTER_AREA)
|
||
|
||
|
||
def _encode(img_bgr_or_bgra: np.ndarray, fmt: str) -> bytes:
|
||
if fmt == "png":
|
||
ok, buf = cv2.imencode(".png", img_bgr_or_bgra,
|
||
[cv2.IMWRITE_PNG_COMPRESSION, 6])
|
||
else:
|
||
ok, buf = cv2.imencode(".jpg", img_bgr_or_bgra,
|
||
[cv2.IMWRITE_JPEG_QUALITY, config.JPEG_QUALITY])
|
||
if not ok:
|
||
raise RuntimeError("Bild-Encoding fehlgeschlagen")
|
||
return buf.tobytes()
|
||
|
||
|
||
def _post_process(img: np.ndarray) -> np.ndarray:
|
||
"""Leichte Entrauschung + Unsharp-Mask fuer den 'Klarbild'-Look."""
|
||
out = img
|
||
if config.POST_DENOISE:
|
||
out = cv2.fastNlMeansDenoisingColored(out, None, 3, 3, 7, 21)
|
||
if config.POST_SHARPEN:
|
||
blur = cv2.GaussianBlur(out, (0, 0), 1.2)
|
||
out = cv2.addWeighted(out, 1.5, blur, -0.5, 0)
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Upscaling
|
||
# ---------------------------------------------------------------------------
|
||
def _get_sr_model(model: str, scale: int):
|
||
key = (model, scale)
|
||
if key in _sr_cache:
|
||
return _sr_cache[key]
|
||
fname = f"{model.upper()}_x{scale}.pb"
|
||
path = config.MODELS_DIR / fname
|
||
if not path.exists():
|
||
raise FileNotFoundError(f"SR-Modell fehlt: {path}")
|
||
sr = cv2.dnn_superres.DnnSuperResImpl_create()
|
||
sr.readModel(str(path))
|
||
sr.setModel(model.lower(), scale)
|
||
_sr_cache[key] = sr
|
||
return sr
|
||
|
||
|
||
def _tiled_sr(img: np.ndarray, sr, scale: int, tile: int) -> np.ndarray:
|
||
"""Kachelweises Upscaling mit Ueberlappung gegen Kachel-Kanten."""
|
||
h, w = img.shape[:2]
|
||
if tile <= 0 or (h <= tile and w <= tile):
|
||
return sr.upsample(img)
|
||
|
||
pad = 16 # Ueberlappung
|
||
out = np.zeros((h * scale, w * scale, 3), dtype=np.uint8)
|
||
for y in range(0, h, tile):
|
||
for x in range(0, w, tile):
|
||
y0, x0 = max(0, y - pad), max(0, x - pad)
|
||
y1, x1 = min(h, y + tile + pad), min(w, x + tile + pad)
|
||
patch = img[y0:y1, x0:x1]
|
||
up = sr.upsample(patch)
|
||
# gueltigen (nicht-ueberlappenden) Bereich zurueckschneiden
|
||
ty0, tx0 = (y - y0) * scale, (x - x0) * scale
|
||
vy, vx = min(tile, h - y), min(tile, w - x)
|
||
crop = up[ty0:ty0 + vy * scale, tx0:tx0 + vx * scale]
|
||
out[y * scale:y * scale + vy * scale,
|
||
x * scale:x * scale + vx * scale] = crop
|
||
return out
|
||
|
||
|
||
def _upscale_opencv(img: np.ndarray, scale: int) -> tuple[np.ndarray, str]:
|
||
"""scale in {2,4}. Nutzt konfiguriertes Modell, faellt auf fsrcnn zurueck."""
|
||
model = config.UPSCALE_MODEL
|
||
try:
|
||
sr = _get_sr_model(model, scale)
|
||
used = model
|
||
except FileNotFoundError:
|
||
sr = _get_sr_model("fsrcnn", scale)
|
||
used = "fsrcnn"
|
||
out = _tiled_sr(img, sr, scale, config.TILE_SIZE)
|
||
return out, f"dnn_superres/{used}_x{scale}"
|
||
|
||
|
||
def _upscale_realesrgan(img: np.ndarray) -> tuple[np.ndarray, str]:
|
||
"""Real-ESRGAN x4 via onnxruntime (optional, fuer starke Boxen/GPU)."""
|
||
global _ort_session
|
||
import onnxruntime as ort # lokal importieren
|
||
|
||
if _ort_session is None:
|
||
_ort_session = ort.InferenceSession(
|
||
str(config.REALESRGAN_ONNX),
|
||
providers=["CPUExecutionProvider"],
|
||
)
|
||
sess = _ort_session
|
||
iname = sess.get_inputs()[0].name
|
||
|
||
def run(patch_bgr: np.ndarray) -> np.ndarray:
|
||
rgb = cv2.cvtColor(patch_bgr, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
|
||
inp = np.transpose(rgb, (2, 0, 1))[None, ...]
|
||
out = sess.run(None, {iname: inp})[0]
|
||
out = np.clip(out[0], 0, 1)
|
||
out = np.transpose(out, (1, 2, 0))
|
||
out = (out * 255.0).round().astype(np.uint8)
|
||
return cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
|
||
|
||
h, w = img.shape[:2]
|
||
tile, pad, scale = config.TILE_SIZE, 16, 4
|
||
out = np.zeros((h * scale, w * scale, 3), dtype=np.uint8)
|
||
for y in range(0, h, tile):
|
||
for x in range(0, w, tile):
|
||
y0, x0 = max(0, y - pad), max(0, x - pad)
|
||
y1, x1 = min(h, y + tile + pad), min(w, x + tile + pad)
|
||
up = run(img[y0:y1, x0:x1])
|
||
ty0, tx0 = (y - y0) * scale, (x - x0) * scale
|
||
vy, vx = min(tile, h - y), min(tile, w - x)
|
||
crop = up[ty0:ty0 + vy * scale, tx0:tx0 + vx * scale]
|
||
out[y * scale:y * scale + vy * scale,
|
||
x * scale:x * scale + vx * scale] = crop
|
||
return out, "real-esrgan_x4(onnx)"
|
||
|
||
|
||
def upscale(image_bytes: bytes, scale: int) -> Result:
|
||
t0 = time.time()
|
||
img = _load_bgr(image_bytes)
|
||
img = _limit_input(img, config.MAX_INPUT_EDGE)
|
||
h, w = img.shape[:2]
|
||
|
||
# Ausgabe-Megapixel deckeln
|
||
out_mp = (h * scale) * (w * scale) / 1_000_000
|
||
note = ""
|
||
if out_mp > config.MAX_OUTPUT_MP and scale == 4:
|
||
scale = 2
|
||
note = "Auf ×2 begrenzt (Speicherschutz)."
|
||
|
||
use_realesrgan = (
|
||
config.ENABLE_REALESRGAN and config.REALESRGAN_ONNX.exists()
|
||
)
|
||
if use_realesrgan and scale == 4:
|
||
up, backend = _upscale_realesrgan(img)
|
||
elif use_realesrgan and scale == 2:
|
||
up, backend = _upscale_realesrgan(img)
|
||
up = cv2.resize(up, (w * 2, h * 2), interpolation=cv2.INTER_AREA)
|
||
backend = "real-esrgan_x4->x2"
|
||
else:
|
||
up, backend = _upscale_opencv(img, scale)
|
||
|
||
up = _post_process(up)
|
||
data = _encode(up, "jpg")
|
||
oh, ow = up.shape[:2]
|
||
return Result(data, "jpg", ow, oh, time.time() - t0,
|
||
(f"{backend} · {note}").strip(" ·"))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Freistellen (Hintergrund entfernen)
|
||
# ---------------------------------------------------------------------------
|
||
def _get_rembg():
|
||
global _rembg_session
|
||
if _rembg_session is None:
|
||
from rembg import new_session
|
||
|
||
_rembg_session = new_session(config.REMBG_MODEL)
|
||
return _rembg_session
|
||
|
||
|
||
def cutout(image_bytes: bytes) -> Result:
|
||
"""Hintergrund per rembg entfernen -> PNG mit Transparenz."""
|
||
from rembg import remove
|
||
|
||
t0 = time.time()
|
||
session = _get_rembg()
|
||
out_png = remove(
|
||
image_bytes,
|
||
session=session,
|
||
alpha_matting=True,
|
||
alpha_matting_foreground_threshold=240,
|
||
alpha_matting_background_threshold=15,
|
||
alpha_matting_erode_size=8,
|
||
)
|
||
pil = Image.open(io.BytesIO(out_png)).convert("RGBA")
|
||
w, h = pil.size
|
||
return Result(out_png, "png", w, h, time.time() - t0,
|
||
f"rembg/{config.REMBG_MODEL}")
|
||
|
||
|
||
def cutout_then_upscale(image_bytes: bytes, scale: int) -> Result:
|
||
"""Erst freistellen, dann das freigestellte Motiv hochskalieren (RGBA)."""
|
||
t0 = time.time()
|
||
cut = cutout(image_bytes)
|
||
rgba = Image.open(io.BytesIO(cut.data)).convert("RGBA")
|
||
alpha = np.array(rgba.split()[-1])
|
||
rgb = cv2.cvtColor(np.array(rgba.convert("RGB")), cv2.COLOR_RGB2BGR)
|
||
|
||
up = upscale(_encode(rgb, "png"), scale) # nutzt gesamte Pipeline
|
||
up_bgr = cv2.imdecode(np.frombuffer(up.data, np.uint8), cv2.IMREAD_COLOR)
|
||
|
||
# Alpha passend hochskalieren und wieder anlegen
|
||
oh, ow = up_bgr.shape[:2]
|
||
alpha_up = cv2.resize(alpha, (ow, oh), interpolation=cv2.INTER_LINEAR)
|
||
bgra = cv2.cvtColor(up_bgr, cv2.COLOR_BGR2BGRA)
|
||
bgra[:, :, 3] = alpha_up
|
||
data = _encode(bgra, "png")
|
||
return Result(data, "png", ow, oh, time.time() - t0,
|
||
f"{cut.note} + {up.note}")
|
||
|
||
|
||
def warmup() -> None:
|
||
"""Modelle beim Start vorladen, damit die erste Anfrage schnell ist."""
|
||
try:
|
||
_get_rembg()
|
||
except Exception as exc: # pragma: no cover
|
||
print(f"[warmup] rembg nicht geladen: {exc}", flush=True)
|
||
for scale in (2, 4):
|
||
try:
|
||
_get_sr_model(config.UPSCALE_MODEL, scale)
|
||
except Exception as exc: # pragma: no cover
|
||
print(f"[warmup] SR {config.UPSCALE_MODEL} x{scale}: {exc}", flush=True)
|