186 lines
6.6 KiB
Python
186 lines
6.6 KiB
Python
"""多模型图生图 / 图像编辑服务。
|
||
|
||
按模型路由到不同的 DashScope 调用形态:
|
||
- wanx2.1-imageedit → ImageSynthesis(function=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-imageedit:ImageSynthesis 同步调用。"""
|
||
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)
|