Files
Nakano Kenji 6247d6b018 feat(qwen): 新增通义千问(Qwen)图像生成能力支持
## 改动
- 新增 qwen_executor.py 与对应模板
- mcp_server 增加 generate_qwen_image 接口
- 更新依赖(uv.lock/pyproject.toml)
2026-02-18 23:36:59 +08:00

433 lines
17 KiB
Python

from __future__ import annotations
import base64
import json
import math
import time
import uuid
import os
import io
import urllib.request
import urllib.parse
import urllib.error
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
try:
from PIL import Image
except ImportError:
Image = None
from .config import AnimaToolConfig
from .history import HistoryManager
from .anima_executor import GeneratedImage, estimate_size_from_ratio, align_dimension
def build_qwen_instruction(prompt_json: Dict[str, Any]) -> str:
"""
按照 QwenEdit 的特性,将结构化 JSON 智能组装为连贯的祈使句。
支持多重编辑连击,以及生图/修图模式下的智能取舍。
"""
parts = []
# 1. 动作连击 (修图专属)
edit_actions = prompt_json.get("edit_actions") or []
if isinstance(edit_actions, list):
for action in edit_actions:
if action and str(action).strip():
parts.append(str(action).strip())
elif isinstance(edit_actions, str) and edit_actions.strip():
parts.append(edit_actions.strip())
# 2. 角色与外观 (生图核心,修图慎用)
character = str(prompt_json.get("character_and_action") or "").strip()
if character:
parts.append(character)
appearance = str(prompt_json.get("appearance_details") or "").strip()
if appearance:
parts.append(appearance)
env = str(prompt_json.get("environment_and_lighting") or "").strip()
if env:
parts.append(env)
# 3. 风格与画质 (全局生效)
style = str(prompt_json.get("style_and_quality") or "").strip()
if style:
parts.append(style)
# 4. 文字生成 (Qwen 独家功能)
text = str(prompt_json.get("render_text") or "").strip()
if text:
# 如果用户本身没有加双引号,就帮他加一下以符合指南
if '"' not in text and "'" not in text:
parts.append(f'画面中写着文字"{text}"')
else:
parts.append(f'画面中写着文字 {text}')
# 5. 防崩坏锁 (修图核心)
constraints = str(prompt_json.get("preserve_constraints") or "").strip()
if constraints:
parts.append(constraints)
# 拼接并确保中文断句自然
# 如果没有使用中文句号,自动加上
cleaned = []
for p in parts:
if not p.endswith("。") and not p.endswith("."):
p += "。"
cleaned.append(p)
return " ".join(cleaned)
class QwenExecutor:
"""
专为 Qwen-Rapid-AIO 设计的执行器。
处理动态拓扑结构(T2I/I2I)和多图上传逻辑。
"""
def __init__(self, config: Optional[AnimaToolConfig] = None):
self.config = config or AnimaToolConfig()
self._client_id = str(uuid.uuid4())
template_path = Path(__file__).resolve().parent / "qwen_workflow_template.json"
with template_path.open("r", encoding="utf-8") as f:
self._workflow_template: Dict[str, Any] = json.load(f)
self.history = HistoryManager()
# -------------------------
# HTTP and Upload Helpers
# -------------------------
def _http_post_multipart(self, url: str, file_path: str) -> Dict[str, Any]:
"""将本地图片上传给 ComfyUI /upload/image"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"要上传的文件不存在:{file_path}")
boundary = uuid.uuid4().hex
headers = {"Content-Type": f"multipart/form-data; boundary={boundary}"}
with path.open("rb") as f:
file_data = f.read()
filename = path.name
# 简单推断 mime
mime = "image/png"
if filename.lower().endswith(".jpg") or filename.lower().endswith(".jpeg"):
mime = "image/jpeg"
elif filename.lower().endswith(".webp"):
mime = "image/webp"
# 构造 multipart/form-data body
body = io.BytesIO()
body.write(f"--{boundary}\r\n".encode("utf-8"))
body.write(f'Content-Disposition: form-data; name="image"; filename="{filename}"\r\n'.encode("utf-8"))
body.write(f"Content-Type: {mime}\r\n\r\n".encode("utf-8"))
body.write(file_data)
body.write(f"\r\n--{boundary}--\r\n".encode("utf-8"))
body_bytes = body.getvalue()
req = urllib.request.Request(url, data=body_bytes, headers=headers)
try:
with urllib.request.urlopen(req, timeout=self.config.timeout_s) as resp:
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw)
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {e.code}: {raw}") from e
def _http_post_json(self, url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self.config.timeout_s) as resp:
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw)
except Exception as e:
if hasattr(e, "read"):
raw = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {getattr(e, 'code', 'error')}: {raw}") from e
raise
def _http_get_json(self, url: str) -> Any:
try:
with urllib.request.urlopen(url, timeout=self.config.timeout_s) as resp:
raw = resp.read().decode("utf-8", errors="replace")
return json.loads(raw)
except Exception as e:
if hasattr(e, "read"):
raw = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP GET {getattr(e, 'code', 'error')}: {raw}") from e
raise
def _http_get_bytes(self, url: str) -> bytes:
try:
with urllib.request.urlopen(url, timeout=self.config.timeout_s) as resp:
return resp.read()
except Exception as e:
if hasattr(e, "read"):
raw = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP GET BYTES {getattr(e, 'code', 'error')}: {raw}") from e
raise
def _upload_image(self, file_path: str) -> str:
"""上传图片并返回服务端保存的文件名"""
url = urllib.parse.urljoin(self.config.comfyui_url.rstrip("/") + "/", "upload/image")
try:
resp = self._http_post_multipart(url, file_path)
# 兼容各种版本的 ComfyUI 返回格式
return str(resp.get("name") or resp.get("filename") or "")
except Exception as e:
raise RuntimeError(f"上传图片到 ComfyUI 失败:{file_path} -> {str(e)}") from e
# -------------------------
# Resolution Strategy
# -------------------------
def _get_image_size(self, file_path: str) -> Tuple[int, int]:
if Image is None:
# 如果没装 Pillow,随便给个兜底
return 1024, 1024
try:
with Image.open(file_path) as img:
return img.size
except Exception:
return 1024, 1024
def _resolve_dimensions(self, prompt_json: Dict[str, Any], base_image_path: Optional[str]) -> Tuple[int, int]:
"""
核心分辨率策略:
- 如果传了明确的 width/height,强制对齐 16 的倍数并使用(无论是否传图,这支持扩图)。
- 否则如果有底图,读取底图原始比例,缩放到 1 Megapixel。
- 否则如果有 aspect_ratio,缩放到 1 Megapixel。
- 兜底 1024x1024。
"""
w = prompt_json.get("width")
h = prompt_json.get("height")
ratio_str = prompt_json.get("aspect_ratio")
if w is not None and h is not None:
return align_dimension(int(w)), align_dimension(int(h))
# 尝试通过比例计算(1 Megapixel 面积)
target_ratio = None
if ratio_str:
target_ratio = str(ratio_str)
elif base_image_path and Path(base_image_path).exists():
orig_w, orig_h = self._get_image_size(base_image_path)
target_ratio = f"{orig_w}:{orig_h}"
if target_ratio:
try:
# 复用 AnimaTool 的算法
ew, eh = estimate_size_from_ratio(aspect_ratio=target_ratio, target_megapixels=1.0)
return ew, eh
except Exception:
pass
return 1024, 1024
# -------------------------
# Core workflow injection
# -------------------------
def _inject(self, prompt_json: Dict[str, Any]) -> Dict[str, Any]:
wf = deepcopy(self._workflow_template)
# 1. 解析提示词 (Qwen Edit 需要的是指令式长句)
instruction = build_qwen_instruction(prompt_json)
wf["3"]["inputs"]["prompt"] = instruction
# 2. 图片上传与动态连线处理
base_img_path = prompt_json.get("base_image")
ref_img_paths = prompt_json.get("reference_images") or []
if isinstance(ref_img_paths, str):
ref_img_paths = [ref_img_paths]
# 计算应该由 EmptyLatent 提供的尺寸
final_w, final_h = self._resolve_dimensions(prompt_json, base_img_path)
wf["9"]["inputs"]["width"] = final_w
wf["9"]["inputs"]["height"] = final_h
wf["9"]["inputs"]["batch_size"] = int(prompt_json.get("batch_size") or 1)
# -----------------------------
# 处理图片节点 (7, 8, 13)
# -----------------------------
# Node 7: base_image (Image1)
if base_img_path and Path(base_img_path).exists():
remote_name = self._upload_image(base_img_path)
wf["7"]["inputs"]["image"] = remote_name
# 图生图核心连线没有改变(因为 EmptyLatentImage 其实就是作为画布提供给 Qwen 的)
# base_image 被连入 TextEncodeQwenImageEditPlus 的 image1,由 LLM 处理视觉!
else:
# 没有 base_image -> 文生图模式
del wf["7"]
del wf["3"]["inputs"]["image1"]
# Node 8: ref_image_1 (Image2)
if len(ref_img_paths) > 0 and Path(ref_img_paths[0]).exists():
remote_name = self._upload_image(ref_img_paths[0])
wf["8"]["inputs"]["image"] = remote_name
else:
del wf["8"]
del wf["3"]["inputs"]["image2"]
# Node 13: ref_image_2 (Image3)
if len(ref_img_paths) > 1 and Path(ref_img_paths[1]).exists():
remote_name = self._upload_image(ref_img_paths[1])
wf["13"]["inputs"]["image"] = remote_name
else:
del wf["13"]
del wf["3"]["inputs"]["image3"]
# 3. KSampler 参数 (复用默认,或用户覆盖)
seed = prompt_json.get("seed")
if seed is None:
seed = int.from_bytes(uuid.uuid4().bytes[:4], "big", signed=False)
wf["2"]["inputs"]["seed"] = int(seed)
if "steps" in prompt_json:
wf["2"]["inputs"]["steps"] = int(prompt_json["steps"])
if "cfg" in prompt_json:
wf["2"]["inputs"]["cfg"] = float(prompt_json["cfg"])
# Qwen-Rapid-AIO 的模型名,如果在配置中有,也可以替换
ckpt_name = prompt_json.get("ckpt_name") or r"_Qwen\Qwen-Rapid-AIO-NSFW-v23.safetensors"
wf["1"]["inputs"]["ckpt_name"] = str(ckpt_name)
return wf
# -------------------------
# Execution Flow
# -------------------------
def queue_prompt(self, prompt: Dict[str, Any]) -> str:
url = urllib.parse.urljoin(self.config.comfyui_url.rstrip("/") + "/", "prompt")
payload = {"prompt": prompt, "client_id": self._client_id}
resp = self._http_post_json(url, payload)
prompt_id = str(resp.get("prompt_id") or "")
if not prompt_id:
error = resp.get("error") or resp.get("node_errors")
if error:
raise RuntimeError(f"ComfyUI 执行错误:{error}")
raise RuntimeError(f"ComfyUI /prompt 返回异常:{resp}")
return prompt_id
def wait_history(self, prompt_id: str) -> Dict[str, Any]:
url = urllib.parse.urljoin(self.config.comfyui_url.rstrip("/") + "/", f"history/{prompt_id}")
deadline = time.time() + float(self.config.timeout_s)
while time.time() < deadline:
data = self._http_get_json(url)
if isinstance(data, dict) and prompt_id in data:
return data[prompt_id]
time.sleep(float(self.config.poll_interval_s))
raise TimeoutError(f"等待 ComfyUI 生成超时:prompt_id={prompt_id}")
def _extract_images(self, prompt_id: str, history_item: Dict[str, Any]) -> List[GeneratedImage]:
outputs = history_item.get("outputs") or {}
images: List[GeneratedImage] = []
for node_id, node_out in outputs.items():
if not isinstance(node_out, dict):
continue
for im in node_out.get("images") or []:
filename = str(im.get("filename") or "")
subfolder = str(im.get("subfolder") or "")
folder_type = str(im.get("type") or "output")
if not filename:
continue
qs = urllib.parse.urlencode({"filename": filename, "subfolder": subfolder, "type": folder_type})
view_url = urllib.parse.urljoin(self.config.comfyui_url.rstrip("/") + "/", f"view?{qs}")
images.append(
GeneratedImage(
filename=filename,
subfolder=subfolder,
folder_type=folder_type,
view_url=view_url,
saved_path=None,
)
)
return images
def _download_images(self, images: List[GeneratedImage]) -> List[GeneratedImage]:
out_dir = Path(self.config.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
downloaded: List[GeneratedImage] = []
for im in images:
content = self._http_get_bytes(im.view_url)
saved_path = None
if self.config.download_images:
sub_dir = out_dir / (im.subfolder or "")
sub_dir.mkdir(parents=True, exist_ok=True)
dst = sub_dir / im.filename
dst.write_bytes(content)
saved_path = str(dst)
downloaded.append(
GeneratedImage(
filename=im.filename,
subfolder=im.subfolder,
folder_type=im.folder_type,
view_url=im.view_url,
saved_path=saved_path,
content=content,
)
)
return downloaded
def generate(self, prompt_json: Dict[str, Any]) -> Dict[str, Any]:
prompt = self._inject(prompt_json)
prompt_id = self.queue_prompt(prompt)
history_item = self.wait_history(prompt_id)
images = self._extract_images(prompt_id, history_item)
images = self._download_images(images)
images_data = []
for im in images:
ext = Path(im.filename).suffix.lower()
mime_type = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}.get(ext.lstrip("."), "image/png")
b64 = base64.b64encode(im.content).decode("ascii") if im.content else None
images_data.append({
"filename": im.filename,
"subfolder": im.subfolder,
"type": im.folder_type,
"url": im.view_url,
"file_path": im.saved_path,
"base64": b64,
"mime_type": mime_type,
"markdown": f"![{im.filename}]({im.view_url})",
})
actual_seed = int(prompt["2"]["inputs"]["seed"])
actual_width = int(prompt["9"]["inputs"]["width"])
actual_height = int(prompt["9"]["inputs"]["height"])
actual_instruction = str(prompt["3"]["inputs"]["prompt"])
result = {
"success": True,
"prompt_id": prompt_id,
"instruction_sent": actual_instruction,
"seed": actual_seed,
"width": actual_width,
"height": actual_height,
"images": images_data,
}
# 记录到历史(复用原有的格式)
record = self.history.add(
params=prompt_json,
positive_text=actual_instruction,
negative_text="",
prompt_id=prompt_id,
seed=actual_seed,
width=actual_width,
height=actual_height,
)
result["history_id"] = record.id
return result