Integrate Qwen image prompt enhancers
This commit is contained in:
+182
-5
@@ -146,6 +146,18 @@ 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_PROMPT_I2I_URL = os.environ.get(
|
||||
"IMAGE_PROMPT_I2I_URL", "").rstrip("/")
|
||||
IMAGE_PROMPT_T2I_URL = os.environ.get(
|
||||
"IMAGE_PROMPT_T2I_URL", "").rstrip("/")
|
||||
IMAGE_PROMPT_I2I_SYSTEM_FILE = os.environ.get(
|
||||
"IMAGE_PROMPT_I2I_SYSTEM_FILE",
|
||||
"/etc/mike-ai/qwen-image-pe-i2i-system-prompt.txt")
|
||||
IMAGE_PROMPT_T2I_SYSTEM_FILE = os.environ.get(
|
||||
"IMAGE_PROMPT_T2I_SYSTEM_FILE",
|
||||
"/etc/mike-ai/qwen-image-pe-t2i-system-prompt.txt")
|
||||
IMAGE_PROMPT_ENHANCER_TIMEOUT = float(os.environ.get(
|
||||
"IMAGE_PROMPT_ENHANCER_TIMEOUT", "180"))
|
||||
IMAGE_MODEL_NAME = os.environ.get(
|
||||
"IMAGE_MODEL_NAME", "Qwen-Image-2.1-int8")
|
||||
IMAGE_INFERENCE_STEPS = int(os.environ.get("IMAGE_INFERENCE_STEPS", "25"))
|
||||
@@ -182,6 +194,16 @@ IMAGE_QUALITY = {"standard": IMAGE_INFERENCE_STEPS,
|
||||
IMAGE_DEFAULT_QUALITY = "standard"
|
||||
IMAGE_MAX_N = 4
|
||||
|
||||
IMAGE_RATIO_SIZES = {
|
||||
"1:1": (1024, 1024),
|
||||
"3:2": (1536, 1024),
|
||||
"2:3": (1024, 1536),
|
||||
"4:3": (1536, 1024),
|
||||
"3:4": (1024, 1536),
|
||||
"16:9": (1920, 1088),
|
||||
"9:16": (1088, 1920),
|
||||
}
|
||||
|
||||
# --- Sprachausgabe (Qwen3-TTS über das interne Normalisierungs-Gateway) ---
|
||||
TTS_WORKER_URL = os.environ.get("TTS_WORKER_URL", "http://127.0.0.1:8085")
|
||||
TTS_TIMEOUT = float(os.environ.get("TTS_TIMEOUT", "300")) # s, pro Synthese
|
||||
@@ -1386,11 +1408,146 @@ def _restore_qwen(profile: str) -> None:
|
||||
RUNTIME.save(last_profile=profile, phase="idle")
|
||||
|
||||
|
||||
def _image_data_url(path: str) -> str:
|
||||
"""Read one already validated local reference image as a data URL."""
|
||||
with open(path, "rb") as handle:
|
||||
data = handle.read()
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
mime = "image/png"
|
||||
elif data.startswith(b"\xff\xd8\xff"):
|
||||
mime = "image/jpeg"
|
||||
elif data.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
||||
mime = "image/webp"
|
||||
else:
|
||||
raise RuntimeError(f"Referenzbild hat ein unbekanntes Format: {path}")
|
||||
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
|
||||
|
||||
|
||||
def _parse_prompt_enhancer_result(content: object) -> dict:
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise RuntimeError("Prompt-Enhancer lieferte keine Antwort")
|
||||
text = content.strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\s*```$", "", text)
|
||||
try:
|
||||
result = json.loads(text)
|
||||
except ValueError:
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
raise RuntimeError("Prompt-Enhancer lieferte kein JSON")
|
||||
try:
|
||||
result = json.loads(text[start:end + 1])
|
||||
except ValueError as exc:
|
||||
raise RuntimeError("Prompt-Enhancer lieferte ungültiges JSON") from exc
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError("Prompt-Enhancer lieferte kein JSON-Objekt")
|
||||
rewritten = result.get("rewritten_prompt")
|
||||
if not isinstance(rewritten, str) or not rewritten.strip():
|
||||
raise RuntimeError("Prompt-Enhancer lieferte keinen rewritten_prompt")
|
||||
if len(rewritten) > 8000:
|
||||
raise RuntimeError("Aufbereiteter Bildprompt ist länger als 8000 Zeichen")
|
||||
result["rewritten_prompt"] = rewritten.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _enhance_image_prompt(prompt: str, source_files: list[str]) -> tuple[str, dict]:
|
||||
"""Run the official Qwen Image 2.1 prompt enhancer on the RTX 3060."""
|
||||
editing = bool(source_files)
|
||||
kind = "image-prompt-i2i" if editing else "image-prompt-t2i"
|
||||
url = IMAGE_PROMPT_I2I_URL if editing else IMAGE_PROMPT_T2I_URL
|
||||
system_file = (IMAGE_PROMPT_I2I_SYSTEM_FILE if editing
|
||||
else IMAGE_PROMPT_T2I_SYSTEM_FILE)
|
||||
model = "qwen-image-pe-i2i" if editing else "qwen-image-pe-t2i"
|
||||
if not PROFILE_CONTROL_URL or not url:
|
||||
raise RuntimeError("Qwen-Image-Prompt-Enhancer ist nicht konfiguriert")
|
||||
try:
|
||||
with open(system_file, encoding="utf-8") as handle:
|
||||
system_prompt = handle.read().strip()
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Systemprompt des Prompt-Enhancers fehlt: {exc}") from exc
|
||||
|
||||
started = time.monotonic()
|
||||
_profile_controller_request("POST", f"/workers/{kind}/start")
|
||||
try:
|
||||
deadline = time.monotonic() + IMAGE_START_TIMEOUT
|
||||
while True:
|
||||
try:
|
||||
with urllib.request.urlopen(url + "/health", timeout=3) as response:
|
||||
health = json.load(response)
|
||||
if health.get("status") == "ok":
|
||||
break
|
||||
except (OSError, urllib.error.URLError, TimeoutError, ValueError):
|
||||
pass
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("Prompt-Enhancer hat nicht gestartet")
|
||||
time.sleep(1)
|
||||
|
||||
if editing:
|
||||
user_content: object = [
|
||||
{"type": "image_url", "image_url": {"url": _image_data_url(path)}}
|
||||
for path in source_files
|
||||
]
|
||||
user_content.append({"type": "text", "text": prompt})
|
||||
else:
|
||||
user_content = prompt
|
||||
request_body = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": 1.0,
|
||||
"top_p": 0.95,
|
||||
"top_k": 20,
|
||||
"presence_penalty": 0.0 if editing else 1.5,
|
||||
"max_tokens": 4096,
|
||||
"thinking_budget_tokens": 2048,
|
||||
"chat_template_kwargs": {
|
||||
"enable_thinking": True,
|
||||
"reasoning_effort": "low",
|
||||
},
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
url + "/v1/chat/completions",
|
||||
data=json.dumps(request_body).encode(),
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
req, timeout=IMAGE_PROMPT_ENHANCER_TIMEOUT) as response:
|
||||
payload = json.load(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read(4096).decode(errors="replace")
|
||||
raise RuntimeError(
|
||||
f"Prompt-Enhancer HTTP {exc.code}: {detail[-500:]}") from exc
|
||||
try:
|
||||
content = payload["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise RuntimeError("Prompt-Enhancer-Antwort ist unvollständig") from exc
|
||||
result = _parse_prompt_enhancer_result(content)
|
||||
metadata = {
|
||||
"model": model,
|
||||
"quantization": "Q5_K_M",
|
||||
"seconds": round(time.monotonic() - started, 3),
|
||||
"wh_ratio": result.get("wh_ratio"),
|
||||
"ratio_follow": result.get("ratio_follow"),
|
||||
}
|
||||
return result["rewritten_prompt"], metadata
|
||||
finally:
|
||||
try:
|
||||
_profile_controller_request("POST", f"/workers/{kind}/stop")
|
||||
except Exception as exc:
|
||||
log.warning("Prompt-Enhancer ließ sich nicht stoppen: %s", exc)
|
||||
|
||||
|
||||
def generate_image(prompt: str, width: int, height: int, steps: int,
|
||||
guidance: float, seed: int | None, n: int,
|
||||
quality: str = "standard",
|
||||
source_files: list[str] | None = None,
|
||||
model: str = IMAGE_MODEL_NAME,
|
||||
size_explicit: bool = False,
|
||||
) -> tuple[list[str], str | None]:
|
||||
"""Orchestriert die Bildgenerierung inkl. Qwen-Hotswap.
|
||||
|
||||
@@ -1411,6 +1568,8 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
|
||||
warning: str | None = None
|
||||
img.last_error = None
|
||||
img.current_model = model
|
||||
original_prompt = prompt
|
||||
prompt_enhancer: dict | None = None
|
||||
# Qwen wird gestoppt → für Chats nicht verfügbar (die warten).
|
||||
_set_qwen_unavailable(True)
|
||||
try:
|
||||
@@ -1433,11 +1592,26 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
|
||||
f"(Exit {proc.returncode}): {out[-500:]}")
|
||||
_wait_upstream_down(time.monotonic() + 60)
|
||||
|
||||
# 2) Worker starten (Modell wird beim ersten generate geladen).
|
||||
# 2) Den knappen Benutzerprompt mit dem offiziellen Qwen-
|
||||
# Prompt-Enhancer auf der RTX 3060 in einen belastbaren
|
||||
# Produktionsprompt umschreiben. Danach wird der Enhancer wieder
|
||||
# beendet, bevor der eigentliche Bildworker startet.
|
||||
img.phase = "enhancing-prompt"
|
||||
prompt, prompt_enhancer = _enhance_image_prompt(
|
||||
original_prompt, source_files or [])
|
||||
ratio = prompt_enhancer.get("wh_ratio")
|
||||
if not size_explicit and isinstance(ratio, str):
|
||||
width, height = IMAGE_RATIO_SIZES.get(
|
||||
ratio.strip(), (width, height))
|
||||
log.info("Bildprompt aufbereitet (%s, %.1f s, Verhältnis %s)",
|
||||
prompt_enhancer["model"],
|
||||
prompt_enhancer["seconds"], ratio)
|
||||
|
||||
# 3) Worker starten (Modell wird beim ersten generate geladen).
|
||||
img.phase = "loading-image"
|
||||
worker = _worker()
|
||||
|
||||
# 3) Generieren.
|
||||
# 4) Generieren.
|
||||
for i in range(n):
|
||||
img.phase = "generating"
|
||||
filename = time.strftime("%Y%m%d-%H%M%S") + \
|
||||
@@ -1465,6 +1639,8 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
|
||||
# Metadaten speichern (Sidecar-JSON).
|
||||
meta = {
|
||||
"prompt": prompt,
|
||||
"original_prompt": original_prompt,
|
||||
"prompt_enhancer": prompt_enhancer,
|
||||
"seed": seed,
|
||||
"width": width,
|
||||
"height": height,
|
||||
@@ -1493,7 +1669,7 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
|
||||
if removed:
|
||||
log.info("Bild-Retention: %d alte Bilder entfernt", len(removed))
|
||||
|
||||
# 4) Worker vollständig beenden (VRAM + CUDA-Kontext freigeben).
|
||||
# 5) Worker vollständig beenden (VRAM + CUDA-Kontext freigeben).
|
||||
img.phase = "unloading-image"
|
||||
worker.stop()
|
||||
img.worker = None
|
||||
@@ -1510,7 +1686,7 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
|
||||
img.worker = None
|
||||
raise
|
||||
finally:
|
||||
# 5) Qwen immer wiederherstellen.
|
||||
# 6) Qwen immer wiederherstellen.
|
||||
img.phase = "restoring-qwen"
|
||||
try:
|
||||
_restore_qwen(profile)
|
||||
@@ -2358,6 +2534,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
# Größe
|
||||
size_explicit = "size" in data
|
||||
size = data.get("size", "1024x1024")
|
||||
if size not in IMAGE_SIZES:
|
||||
self._send_error(
|
||||
@@ -2425,7 +2602,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
results, warning = generate_image(
|
||||
prompt.strip(), width, height, steps, guidance, seed, n,
|
||||
quality, source_files, model)
|
||||
quality, source_files, model, size_explicit)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
self._send_error(503, str(e), "server_error", "image_generation_failed")
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user