Files
2026-08-14 18:27:45 +08:00

186 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""多模型图生图 / 图像编辑服务。
按模型路由到不同的 DashScope 调用形态:
- wanx2.1-imageedit → ImageSynthesisfunction=description_edit
- wan2.6-image / qwen-image-edit(-plus) → MultiModalConversation(多模态生成)
职责单一:把请求翻译成 DashScope 调用并返回结果。当前不落盘、不存储图片;
后续接入七牛云时,可在返回结果 URL 之后增加「下载并转存」步骤,而不改 api 层与前端契约。
"""
from __future__ import annotations
import asyncio
import base64
from http import HTTPStatus
import httpx
from fastapi import HTTPException
from config import get_settings
from schemas.image_edit import ImageEditRequest, ImageEditResponse, ImageEditResult
# 服务端代理下载结果图时的体积上限,超出则跳过 base64(前端回退用 url)
_MAX_PROXY_BYTES = 20 * 1024 * 1024
# 走 ImageSynthesis 的模型(其余统一走 MultiModalConversation
_WANX_IMAGEEDIT = "wanx2.1-imageedit"
def _download_to_data_url(url: str) -> str:
"""把生成结果图下载并转成 data URL。
目的:阿里云结果 URL 未开放 CORS,前端直接绘制到 canvas 会导致画布被污染、
无法 toDataURL 导出;由服务端代下载可彻底规避。失败时返回空串,前端回退用 url。
"""
try:
with httpx.Client(timeout=60.0, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
content_type = (resp.headers.get("content-type") or "image/png").split(";")[0].strip()
if not content_type.startswith("image/"):
content_type = "image/png"
payload = resp.content
if len(payload) > _MAX_PROXY_BYTES:
return ""
b64 = base64.b64encode(payload).decode("ascii")
return f"data:{content_type};base64,{b64}"
except Exception: # noqa: BLE001 - 下载失败不影响主流程
return ""
def _apply_base_url(settings) -> None:
"""仅华北2(北京)业务空间需要配置 base_http_api_url。"""
import dashscope
url = (settings.dashscope_base_http_api_url or "").strip()
if url:
dashscope.base_http_api_url = url
def _fail(rsp, label: str) -> None:
code = getattr(rsp, "code", None)
message = getattr(rsp, "message", "")
status = getattr(rsp, "status_code", "?")
raise HTTPException(status_code=502, detail=f"{label}失败(HTTP {status}code={code}):{message}")
def _to_results(urls: list[str]) -> list[ImageEditResult]:
return [ImageEditResult(url=u, image_base64=_download_to_data_url(u)) for u in urls]
def _call_wanx(req: ImageEditRequest, api_key: str) -> ImageEditResponse:
"""wanx2.1-imageeditImageSynthesis 同步调用。"""
from dashscope import ImageSynthesis
kwargs: dict = {
"api_key": api_key,
"model": "wanx2.1-imageedit",
"function": req.function,
"prompt": req.prompt,
"base_image_url": req.base_image,
"n": req.n,
}
if req.mask_image:
kwargs["mask_image_url"] = req.mask_image
if req.size:
kwargs["size"] = req.size
if req.seed is not None:
kwargs["seed"] = req.seed
if req.style:
kwargs["style"] = req.style
if req.prompt_extend:
kwargs["prompt_extend"] = True
if req.strength is not None:
# strength 走 SDK 的 **kwargs,最终落入请求体的 parameters
kwargs["strength"] = req.strength
try:
rsp = ImageSynthesis.call(**kwargs)
except Exception as exc: # noqa: BLE001 - SDK 抛错类型不统一,统一转为 502
raise HTTPException(status_code=502, detail=f"DashScope 调用异常:{exc}") from exc
if rsp.status_code != HTTPStatus.OK:
_fail(rsp, "图生图")
urls: list[str] = []
for item in getattr(rsp.output, "results", None) or []:
url = item.get("url") if isinstance(item, dict) else getattr(item, "url", None)
if url:
urls.append(url)
if not urls:
raise HTTPException(status_code=502, detail="模型未返回结果图片 URL")
usage = getattr(rsp, "usage", None)
image_count = int(getattr(usage, "image_count", 0) or 0) if usage else len(urls)
return ImageEditResponse(
task_id=getattr(rsp.output, "task_id", ""),
results=_to_results(urls),
image_count=image_count or len(urls),
request_id=getattr(rsp, "request_id", ""),
)
def _call_multimodal(req: ImageEditRequest, api_key: str) -> ImageEditResponse:
"""wan2.6-image / qwen-image-edit(-plus[-快照])MultiModalConversation。"""
from dashscope import MultiModalConversation
messages = [{"role": "user", "content": [{"image": req.base_image}, {"text": req.prompt}]}]
kwargs: dict = {
"api_key": api_key,
"model": req.model,
"messages": messages,
"prompt_extend": req.prompt_extend,
}
if req.n:
kwargs["n"] = req.n
if req.size:
kwargs["size"] = req.size
try:
rsp = MultiModalConversation.call(**kwargs)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"DashScope 调用异常:{exc}") from exc
if rsp.status_code != HTTPStatus.OK:
_fail(rsp, "图生图")
urls: list[str] = []
try:
content = rsp.output.choices[0].message.content
except (AttributeError, IndexError, KeyError, TypeError):
content = []
if isinstance(content, list):
for item in content:
img = item.get("image") if isinstance(item, dict) else None
if img:
urls.append(img)
if not urls:
raise HTTPException(status_code=502, detail="模型未返回结果图片")
return ImageEditResponse(
task_id="",
results=_to_results(urls),
image_count=len(urls),
request_id=getattr(rsp, "request_id", ""),
)
def _call_dashscope(req: ImageEditRequest, api_key: str) -> ImageEditResponse:
"""同步调用 DashScope(在线程池中执行),按模型路由。"""
if req.model == _WANX_IMAGEEDIT:
return _call_wanx(req, api_key)
return _call_multimodal(req, api_key)
async def edit_image(req: ImageEditRequest) -> ImageEditResponse:
"""入口:校验密钥后在独立线程中执行同步 SDK 调用。"""
settings = get_settings()
api_key = (settings.dashscope_api_key or "").strip()
if not api_key:
raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY,请写入 .env")
_apply_base_url(settings)
return await asyncio.to_thread(_call_dashscope, req, api_key)