Add local FLUX image editing
This commit is contained in:
+95
-17
@@ -18,8 +18,9 @@ Virtuelle Modelle: qwen-fast, qwen-medium, qwen-large, qwen-ultra,
|
||||
Kommandos: POST /fast, /medium, /large, /ultra, /uncensored
|
||||
GET /status (Zustand)
|
||||
|
||||
Bildgenerierung (Z-Image-Turbo):
|
||||
Bildgenerierung und Editing (FLUX.2-klein-4B):
|
||||
POST /v1/images/generations (OpenAI-kompatibel)
|
||||
POST /v1/images/edits (lokal, Referenzbilder)
|
||||
GET /images (Liste)
|
||||
GET /images/<datei> (PNG-Download)
|
||||
|
||||
@@ -37,7 +38,7 @@ Der Router leitet /v1/audio/speech und /v1/audio/transcriptions
|
||||
per HTTP an die Worker weiter.
|
||||
|
||||
Der Router agiert als Modell-Orchestrator: vor der Generierung wird
|
||||
llama.cpp gestoppt, der Bild-Worker lädt Z-Image, generiert und entlädt
|
||||
llama.cpp gestoppt, der Bild-Worker lädt FLUX.2, generiert/bearbeitet und entlädt
|
||||
das Modell wieder; danach wird das vorherige Qwen-Profil wiederher-
|
||||
gestellt und erst dann geantwortet (try/finally – Qwen wird auch bei
|
||||
Fehlgeschlagener Generierung wiederhergestellt).
|
||||
@@ -116,7 +117,7 @@ CONNECT_TIMEOUT = float(os.environ.get("CONNECT_TIMEOUT", "10")) # s, Connect
|
||||
POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", "2")) # s, Polling-Intervall
|
||||
MAX_GENERATION_TOKENS = int(os.environ.get("MAX_GENERATION_TOKENS", "8192"))
|
||||
|
||||
# --- Bildgenerierung (Z-Image-Turbo) ---
|
||||
# --- Bildgenerierung und Referenzbild-Bearbeitung (FLUX.2 Klein 4B) ---
|
||||
LLAMA_SERVICE = os.environ.get("LLAMA_SERVICE", "mike-ai-llama-ui.service")
|
||||
SYSTEMCTL_BIN = os.environ.get("SYSTEMCTL_BIN", "systemctl")
|
||||
IMAGE_WORKER = os.environ.get(
|
||||
@@ -125,7 +126,7 @@ IMAGE_PYTHON = os.environ.get(
|
||||
"IMAGE_PYTHON", "/opt/mike-ai/ai-profile-router/venv/bin/python")
|
||||
IMAGE_WORKER_URL = os.environ.get("IMAGE_WORKER_URL", "").rstrip("/")
|
||||
IMAGE_WORKER_TOKEN = os.environ.get("IMAGE_WORKER_TOKEN", "").strip()
|
||||
IMAGE_MODEL_NAME = os.environ.get("IMAGE_MODEL_NAME", "Z-Image-Turbo")
|
||||
IMAGE_MODEL_NAME = os.environ.get("IMAGE_MODEL_NAME", "FLUX.2-klein-4B")
|
||||
IMAGE_DIR = os.environ.get(
|
||||
"IMAGE_DIR", "/opt/mike-ai/ai-profile-router/images")
|
||||
IMAGE_WORKER_LOG = os.environ.get(
|
||||
@@ -153,8 +154,8 @@ IMAGE_SIZES = {
|
||||
"1920x1088": (1920, 1088),
|
||||
"1088x1920": (1088, 1920),
|
||||
}
|
||||
# Z-Image-Turbo nutzt neun Scheduler-Schritte (acht DiT-Forwards) ohne CFG.
|
||||
IMAGE_QUALITY = {"standard": 9, "high": 9}
|
||||
# Das destillierte FLUX.2-klein-4B ist auf vier Schritte ausgelegt.
|
||||
IMAGE_QUALITY = {"standard": 4, "high": 4}
|
||||
IMAGE_DEFAULT_QUALITY = "standard"
|
||||
IMAGE_MAX_N = 4
|
||||
|
||||
@@ -742,7 +743,7 @@ def switch_profile(profile: str, implicit: bool = False) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bildgenerierung (Z-Image-Turbo)
|
||||
# Bildgenerierung und Editing (FLUX.2-klein-4B)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _Worker:
|
||||
@@ -986,7 +987,8 @@ def _restore_qwen(profile: str) -> None:
|
||||
|
||||
def generate_image(prompt: str, width: int, height: int, steps: int,
|
||||
guidance: float, seed: int | None, n: int,
|
||||
quality: str = "standard"
|
||||
quality: str = "standard",
|
||||
source_files: list[str] | None = None,
|
||||
) -> tuple[list[str], str | None]:
|
||||
"""Orchestriert die Bildgenerierung inkl. Qwen-Hotswap.
|
||||
|
||||
@@ -1047,6 +1049,7 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
|
||||
"guidance": guidance,
|
||||
"seed": seed,
|
||||
"output": output,
|
||||
"source_files": source_files or [],
|
||||
}, timeout=IMAGE_GEN_TIMEOUT)
|
||||
if resp.get("status") != "ok":
|
||||
raise RuntimeError(
|
||||
@@ -1065,6 +1068,8 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
|
||||
"steps": steps,
|
||||
"guidance": guidance,
|
||||
"quality": quality,
|
||||
"mode": "image-edit" if source_files else "text-to-image",
|
||||
"reference_images": len(source_files or []),
|
||||
"seconds": resp.get("seconds"),
|
||||
"model": IMAGE_MODEL_NAME,
|
||||
"created": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
@@ -1403,6 +1408,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._send_error(503, "Bildgenerierung ist nicht installiert",
|
||||
"server_error", "feature_disabled")
|
||||
elif path == "/v1/images/edits" and self.command == "POST":
|
||||
if ENABLE_IMAGE_GENERATION:
|
||||
self._image_edit()
|
||||
else:
|
||||
self._send_error(503, "Bildbearbeitung ist nicht installiert",
|
||||
"server_error", "feature_disabled")
|
||||
elif path == "/v1/audio/speech" and self.command == "POST":
|
||||
if ENABLE_TTS:
|
||||
self._speech()
|
||||
@@ -1627,22 +1638,89 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# ---------- Bildgenerierung ----------
|
||||
|
||||
def _image_generate(self) -> None:
|
||||
data = self._read_image_request()
|
||||
if data is not None:
|
||||
self._image_request(data, [])
|
||||
|
||||
def _image_edit(self) -> None:
|
||||
"""Edit with local image bytes supplied by the private Hermes plugin."""
|
||||
data = self._read_image_request()
|
||||
if data is None:
|
||||
return
|
||||
encoded: list[str] = []
|
||||
primary = data.pop("image_b64", None)
|
||||
if isinstance(primary, str) and primary:
|
||||
encoded.append(primary)
|
||||
references = data.pop("reference_images_b64", [])
|
||||
if references is None:
|
||||
references = []
|
||||
if not isinstance(references, list) or any(
|
||||
not isinstance(item, str) for item in references):
|
||||
self._send_error(400, "'reference_images_b64' muss eine Liste sein",
|
||||
"invalid_request_error", "invalid_references")
|
||||
return
|
||||
encoded.extend(references)
|
||||
if not encoded:
|
||||
self._send_error(400, "Referenzbild fehlt",
|
||||
"invalid_request_error", "missing_image")
|
||||
return
|
||||
if len(encoded) > 4:
|
||||
self._send_error(400, "höchstens vier Referenzbilder erlaubt",
|
||||
"invalid_request_error", "too_many_images")
|
||||
return
|
||||
|
||||
source_files: list[str] = []
|
||||
try:
|
||||
for item in encoded:
|
||||
if item.startswith("data:"):
|
||||
header, separator, item = item.partition(",")
|
||||
if not separator or not header.lower().startswith("data:image/"):
|
||||
raise ValueError("ungültige Bild-Data-URI")
|
||||
try:
|
||||
raw = base64.b64decode(item, validate=True)
|
||||
except Exception as exc:
|
||||
raise ValueError("ungültige Base64-Bilddaten") from exc
|
||||
if not raw or len(raw) > CHAT_IMAGE_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"Referenzbild muss 1..{CHAT_IMAGE_MAX_BYTES} Bytes groß sein")
|
||||
name = f".edit-{os.urandom(12).hex()}.ref"
|
||||
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||
with open(os.path.join(IMAGE_DIR, name), "xb") as output:
|
||||
output.write(raw)
|
||||
source_files.append(name)
|
||||
self._image_request(data, source_files)
|
||||
except ValueError as exc:
|
||||
self._send_error(400, str(exc),
|
||||
"invalid_request_error", "invalid_image")
|
||||
finally:
|
||||
for name in source_files:
|
||||
try:
|
||||
os.unlink(os.path.join(IMAGE_DIR, name))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
log.warning("temporäres Referenzbild nicht gelöscht: %s", exc)
|
||||
|
||||
def _read_image_request(self) -> dict | None:
|
||||
try:
|
||||
body = self._read_body()
|
||||
except ValueError as e:
|
||||
self._send_error(400, str(e),
|
||||
"invalid_request_error", "invalid_body")
|
||||
return
|
||||
return None
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
self._send_error(400, "ungültiges JSON",
|
||||
"invalid_request_error", "invalid_json")
|
||||
return
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
self._send_error(400, "Request muss ein JSON-Objekt sein",
|
||||
"invalid_request_error", "invalid_request")
|
||||
return
|
||||
return None
|
||||
return data
|
||||
|
||||
def _image_request(self, data: dict, source_files: list[str]) -> None:
|
||||
|
||||
prompt = data.get("prompt")
|
||||
if not isinstance(prompt, str) or not prompt.strip():
|
||||
@@ -1679,19 +1757,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"invalid_request_error", "invalid_quality")
|
||||
return
|
||||
steps = data.get("steps", IMAGE_QUALITY[quality])
|
||||
if not isinstance(steps, int) or isinstance(steps, bool) or steps != 9:
|
||||
self._send_error(400, "Z-Image-Turbo erfordert 'steps'=9",
|
||||
if not isinstance(steps, int) or isinstance(steps, bool) or steps != 4:
|
||||
self._send_error(400, "FLUX.2-klein-4B erfordert 'steps'=4",
|
||||
"invalid_request_error", "invalid_steps")
|
||||
return
|
||||
guidance = data.get("guidance", 0.0)
|
||||
guidance = data.get("guidance", 1.0)
|
||||
try:
|
||||
guidance = float(guidance)
|
||||
except (TypeError, ValueError):
|
||||
self._send_error(400, "'guidance' muss eine Zahl sein",
|
||||
"invalid_request_error", "invalid_guidance")
|
||||
return
|
||||
if guidance != 0.0:
|
||||
self._send_error(400, "Z-Image-Turbo erfordert 'guidance'=0.0",
|
||||
if guidance != 1.0:
|
||||
self._send_error(400, "FLUX.2-klein-4B erfordert 'guidance'=1.0",
|
||||
"invalid_request_error", "invalid_guidance")
|
||||
return
|
||||
|
||||
@@ -1719,7 +1797,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
results, warning = generate_image(
|
||||
prompt.strip(), width, height, steps, guidance, seed, n,
|
||||
quality)
|
||||
quality, source_files)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
self._send_error(503, str(e), "server_error", "image_generation_failed")
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user