生图模型可选(火山/gpt-image)+ 模特上身图提示词强化 + 多会话等改动

本轮(生图模型选择 + 火山接入):
- 工作室新增「生图模型」选择器(模特上身图/平台套图头部 chip + 图片创作底部 Pill),
  默认火山 Seedream,可切 gpt-image-2;选择写入 localStorage,下次进页面读回
- 后端 resolve_image_model 解析所选模型;enqueue_standalone_images 接 image_model
- worker 按模型能力分流:有 image_edit(gpt-image)走多图编辑;无(火山)走
  image_generation(image=参考图);新增 _ratio_to_volcano_size 让火山按比例出图
  → 内衣等敏感品类用火山可绕开 gpt-image 的 sexual 内容审核

模特上身图提示词:
- 穿戴/非穿戴分流、按 index 变化动作场景镜头、负面词尾接、多图参考序号自适应
- _product_reference_urls:商品参考真实上传图优先、排除 AI 生成图、可多张

其他(并入此前各会话未提交改动):
- 图片创作多会话(ImageConversation + migration 0019)、任务中心按类型过滤
- accounts/projects/assets/team/auth 等零散调整、相关测试
- 测试脚本(tryon_*.py)、测试清单(core/bug/*.xlsx)
This commit is contained in:
zyc
2026-06-27 14:00:28 +08:00
parent 0a519ca892
commit ce7eaefe1d
21 changed files with 1279 additions and 158 deletions
+75 -4
View File
@@ -1,4 +1,5 @@
import json
import math
import re
import subprocess
import tempfile
@@ -760,6 +761,33 @@ def _ratio_to_image_size(ratio: str) -> str:
}.get((ratio or "").strip(), "1024x1024")
def _ratio_to_volcano_size(ratio: str) -> str:
"""前端比例 → 火山 Seedream 尺寸(~2K 面积,各边夹在 [1024,4096] 且取 16 的倍数)。
预设比例直接给好尺寸;自定义 W:H 按 2K 面积换算;解析不到回落 '2K'"""
presets = {
"1:1": "2048x2048",
"3:4": "1728x2304",
"4:3": "2304x1728",
"9:16": "1440x2560",
"16:9": "2560x1440",
"4:5": "1664x2080",
}
r = (ratio or "").strip()
if r in presets:
return presets[r]
if ":" in r:
try:
w_str, h_str = r.split(":", 1)
w, h = float(w_str), float(h_str)
if w > 0 and h > 0:
scale = math.sqrt((2048 * 2048) / (w * h))
side = lambda v: min(max(int(round(v * scale / 16) * 16), 1024), 4096) # noqa: E731
return f"{side(w)}x{side(h)}"
except (ValueError, ZeroDivisionError):
pass
return "2K"
def _product_cover_url(product) -> str:
"""商品主图 URL:优先 cover_asset,其次标记为主图的商品图,再次首张商品图。无图返回 ''"""
if product is None:
@@ -953,6 +981,32 @@ def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str
return " ".join(lines)
def build_free_reference_prompt(base_prompt: str, n_refs: int = 1) -> str:
"""图片创作自由模式 · 带用户上传参考图时的提示词。
纯把用户原话丢给图生图,模型只会松散借个色调、不会真的保留参考图里的主体(背心/商品/人物),
这正是「没参考我上传的素材」的根因。这里显式把参考图钉成「画面主体的唯一依据」,要求严格保留其
外形/款式/配色/材质/品牌文字/图案,再在此基础上按用户要求创作。"""
base = (base_prompt or "").strip()
if n_refs <= 1:
ref_intro = "参考图是用户提供的素材,是本次画面主体(商品 / 人物 / 物体)的唯一依据。"
ref_word = "参考图"
else:
rng = f"1-{n_refs}" if n_refs > 2 else "1、2"
ref_intro = f"参考图{rng}是用户提供的素材(同一主体的不同角度 / 多个主体),是本次画面主体的唯一依据。"
ref_word = f"参考图{rng}"
lines = [
ref_intro,
f"请严格保留{ref_word}中主体的外形、款式、配色、材质、纹样、品牌文字与 Logo,"
"不要重新设计、不要换款、不要生成相似但不同的物体;主体须与参考图高度一致。",
]
if base:
lines.append(f"在此基础上,按用户要求创作:{base}")
else:
lines.append("在此基础上,生成干净、专业的电商视觉画面。")
lines.append("画面真实、构图协调、细节清晰。")
return " ".join(lines)
def build_person_frontal_prompt(description: str = "") -> str:
"""人物正面氛围图提示词:把脚本提取(或用户输入)的人物描述包成统一模板。
用户钦定格式:电商真人模特,氛围正面全身照,<描述>,自然妆容,柔和影棚光,真实质感,单人,纯色背景。"""
@@ -1998,7 +2052,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
continue
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None) -> list[AITask]:
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None) -> list[AITask]:
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
@@ -2014,6 +2068,7 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
raise ValueError("no active image model configured")
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
count = max(1, min(int(count or 1), 12))
ref_ids = [str(r) for r in (reference_image_ids or []) if r]
# 本次提交 = 一组(模特上身图组 / 平台套图组):同一 batch_id 串起这批图,前端可成组展示。
batch_id = str(uuid.uuid4())
tasks: list[AITask] = []
@@ -2023,11 +2078,12 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
team=team,
created_by=user,
project=None,
conversation=conversation,
task_type=task_type,
status=AITask.Status.CREATED,
model_config=model_config,
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None},
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids},
estimated_cost=cost,
)
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
@@ -2082,6 +2138,14 @@ def run_standalone_image_task(*, task_id: str) -> None:
product_url = _product_cover_url(product) if product is not None else ""
# 模特上身图:真实上传图优先、排除 AI 生成图,可多张(多角度更易锁外形/品牌)
product_urls = _product_reference_urls(product, limit=3) if product is not None else []
# 图片创作自由模式:用户上传的参考图(已先传成 Asset)→ 取直链,作多图参考(image_edit / 图生图)
ref_urls: list[str] = []
for rid in (payload.get("reference_image_ids") or []):
ref_asset = Asset.objects.filter(id=rid).first()
if ref_asset is not None:
u = _asset_preview_url(ref_asset)
if u:
ref_urls.append(u)
# 参考图收集与 provider 无关:先把「该用哪些参考图 + 哪条提示词」定下来,再按模型能力选调用方式。
edit_images: list[str] = []
@@ -2100,6 +2164,11 @@ def run_standalone_image_task(*, task_id: str) -> None:
elif bool(payload.get("reference_product")) and product_url:
edit_images = [product_url]
edit_prompt = build_product_triview_prompt_refs(product, "")
elif ref_urls:
# 图片创作自由模式:以用户上传的参考图为基底出图。提示词必须显式要求「保留参考图主体」,
# 否则图生图只会松散借个色调、不真的还原上传的素材(=用户反馈的「没参考我的图」)。
edit_images = ref_urls
edit_prompt = build_free_reference_prompt(prompt, n_refs=len(ref_urls))
use_edit = bool(edit_images)
try:
if use_edit and can_edit:
@@ -2110,8 +2179,10 @@ def run_standalone_image_task(*, task_id: str) -> None:
size = _ratio_to_image_size(str(payload.get("ratio") or "")) # 模特图按选中比例
response = provider.image_edit(model=model_config.name, prompt=edit_prompt, images=edit_images, size=size)
elif use_edit:
# 火山 Seedream 无 image_edit:走 image_generation 带 image=参考图(图生图多参考),size 用 2K
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=edit_prompt, image=edit_images, size="2K")
# 火山 Seedream 无 image_edit:走 image_generation 带 image=参考图(图生图多参考);
# 尺寸按选中比例换算成火山可接受的 ~2K 尺寸(三视图固定横向)
vsize = "2304x1728" if payload.get("reference_product") else _ratio_to_volcano_size(str(payload.get("ratio") or ""))
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=edit_prompt, image=edit_images, size=vsize)
else:
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
media = provider.extract_first_media_url(response)