feat: 开发编辑工作台

This commit is contained in:
R524809
2026-08-14 18:27:45 +08:00
parent b27e42dc75
commit c18d70017e
50 changed files with 5496 additions and 57 deletions
+9 -1
View File
@@ -1,5 +1,13 @@
from fastapi import APIRouter
from schemas.image_edit import ImageEditRequest, ImageEditResponse
from services.image_edit import edit_image
router = APIRouter(prefix="/api/image", tags=["image"])
# Phase 2: watermark / white background / img2img proxy
@router.post("/edit", response_model=ImageEditResponse, summary="AI 图生图(wanx2.1-imageedit")
async def create_image_edit(body: ImageEditRequest) -> ImageEditResponse:
return await edit_image(body)
# 后续扩展点:白底 / 水印等服务端处理可继续在此文件下新增路由。
+5 -1
View File
@@ -20,8 +20,12 @@ class Settings(BaseSettings):
deepseek_api_key: str = ""
openai_api_key: str = ""
dashscope_api_key: str = ""
# 华北2(北京)业务空间时需填:https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1
# 普通 API Key 调用留空即可。
dashscope_base_http_api_url: str = ""
host: str = "127.0.0.1"
port: int = 8000
port: int = 8800
cors_origins: str = ""
@property
+1
View File
@@ -4,3 +4,4 @@ httpx>=0.27.0
pydantic-settings>=2.6.0
python-dotenv>=1.0.0
PyYAML>=6.0.0
dashscope>=1.23.8
+125
View File
@@ -0,0 +1,125 @@
"""图生图 / 图像编辑请求响应模型(多模型)。"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field, field_validator
# 支持的模型白名单
SUPPORTED_MODELS: frozenset[str] = frozenset(
{
"wanx2.1-imageedit",
"wan2.6-image",
"qwen-image-edit",
"qwen-image-edit-plus",
"qwen-image-edit-plus-2025-10-30",
}
)
# 万相 2.1 通用图像编辑支持的 function 白名单
# description_edit :文本指令编辑(无掩码,即“图生图”)
# description_edit_with_mask :局部重绘(需 mask
# stylization_local :局部风格化
# stylization_all :整体风格化
ImageEditFunction = Literal[
"description_edit",
"description_edit_with_mask",
"stylization_local",
"stylization_all",
]
# 单张 base64 图片(编码后)上限,超出直接拒绝,避免无谓占用内存。
_MAX_BASE64_LENGTH = 15 * 1024 * 1024
class ImageEditRequest(BaseModel):
"""图生图请求。图片以 data URLdata:image/...;base64,...)或公网 URL 传入。"""
base_image: str = Field(..., description="待编辑原图:data URL 或公网 http(s) URL")
prompt: str = Field(..., description="编辑指令,例如“把背景换成纯白色摄影棚”")
model: str = Field(
default="wanx2.1-imageedit",
description="模型 code,见 SUPPORTED_MODELS",
)
mask_image: str | None = Field(
default=None, description="掩码图(仅 function=description_edit_with_mask 时使用)"
)
function: ImageEditFunction = Field(
default="description_edit", description="编辑能力类型,见 ImageEditFunction"
)
n: int = Field(default=1, ge=1, le=4, description="生成数量")
size: str = Field(default="", description="输出尺寸,如 1024*1024;留空由模型决定")
seed: int | None = Field(default=None, ge=0, description="随机种子,可选")
style: str = Field(default="", description="输出风格,如 auto/photography/anime 等,可选")
prompt_extend: bool = Field(default=True, description="是否自动扩写提示词(官方示例默认开启)")
strength: float | None = Field(
default=None,
ge=0.0,
le=1.0,
description=(
"修改幅度 0.0~1.0,官方默认 0.5;值越大改动越明显、越偏离原图。"
"新增文字、大幅修改等场景建议 0.8 左右"
),
)
@field_validator("prompt")
@classmethod
def prompt_not_empty(cls, value: str) -> str:
text = (value or "").strip()
if not text:
raise ValueError("prompt 不能为空")
return text
@field_validator("model")
@classmethod
def model_whitelist(cls, value: str) -> str:
model = (value or "").strip()
if model not in SUPPORTED_MODELS:
raise ValueError(f"不支持的模型「{model}」,可选:{sorted(SUPPORTED_MODELS)}")
return model
@field_validator("base_image")
@classmethod
def base_image_valid(cls, value: str) -> str:
image = (value or "").strip()
if not image:
raise ValueError("base_image 不能为空")
if image.startswith("data:image/"):
if len(image) > _MAX_BASE64_LENGTH:
raise ValueError("图片过大,请压缩后重试")
elif not image.startswith(("http://", "https://")):
raise ValueError("base_image 仅支持 data URL 或公网 http(s) URL")
return image
@field_validator("mask_image")
@classmethod
def mask_image_valid(cls, value: str | None) -> str | None:
if value is None or not value.strip():
return None
image = value.strip()
if image.startswith("data:image/"):
if len(image) > _MAX_BASE64_LENGTH:
raise ValueError("mask_image 图片过大")
elif not image.startswith(("http://", "https://")):
raise ValueError("mask_image 仅支持 data URL 或公网 http(s) URL")
return image
@field_validator("style")
@classmethod
def normalize_style(cls, value: str) -> str:
return (value or "").strip()
class ImageEditResult(BaseModel):
url: str = Field(..., description="生成图片 URL(有效期 24 小时)")
image_base64: str = Field(
default="",
description="生成图片的 data URL(服务端代理下载,便于前端直接合成/导出,规避跨域画布污染)",
)
class ImageEditResponse(BaseModel):
task_id: str = ""
results: list[ImageEditResult] = Field(default_factory=list)
image_count: int = 0
request_id: str = ""
+185
View File
@@ -0,0 +1,185 @@
"""多模型图生图 / 图像编辑服务。
按模型路由到不同的 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)