feat: 优化模特上身图提示词

This commit is contained in:
hh
2026-07-14 15:18:54 +08:00
parent da58f99293
commit 0a58b89919
8 changed files with 1973 additions and 15 deletions
+215 -14
View File
@@ -779,14 +779,29 @@ def _ratio_to_image_size(ratio: str) -> str:
这里只能取最接近的「竖/横/方」近似;要拿到精确像素比例需在出图后做主体保护裁切/补边(见优化文档 §7.1
normalize_output_image,属待部署的后处理项)。修复点:4:5 原会 fallback 成 1024x1024(方图)→ 比例错误,
现归到竖图 1024x1536。"""
return {
known = {
"1:1": "1024x1024",
"3:4": "1024x1536", # 近似竖图(网关无精确 3:4)
"4:5": "1024x1536", # 近似竖图(原 fallback 成方图是比例 bug)
"9:16": "1024x1536", # 近似竖图(网关无精确 9:16)
"4:3": "1536x1024",
"16:9": "1536x864", # 真 16:9(原来误用 1536x1024 = 3:2)
}.get((ratio or "").strip(), "1024x1024")
}
normalized = (ratio or "").strip()
if normalized in known:
return known[normalized]
# 手动宽高比不能被静默当成方图。GPT 网关不接受任意精确尺寸时,至少保持横/竖方向;
# 原始比例仍写进有效提示词,供应商获得的是它支持的最近画布。
try:
width, height = (float(part.strip()) for part in normalized.split(":", 1))
if width > 0 and height > 0:
if width < height:
return "1024x1536"
if width > height:
return "1536x1024"
except (TypeError, ValueError):
pass
return "1024x1024"
def _ratio_to_volcano_size(ratio: str) -> str:
@@ -990,6 +1005,93 @@ def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "
return " ".join(lines)
def _model_tryon_prompt_v2_rollout_source(*, team_id, payload: dict) -> str | None:
"""返回 V2.2 启用来源;未命中时失败关闭并继续使用旧提示词。"""
if payload.get("tryon_prompt_v2_override") is True:
return "internal_ab"
if getattr(settings, "MODEL_TRYON_PROMPT_V2_ENABLED", False):
return "global"
raw_allowlist = getattr(settings, "MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS", ()) or ()
if isinstance(raw_allowlist, str):
raw_allowlist = raw_allowlist.split(",")
allowed_team_ids = {
str(value).strip().lower()
for value in raw_allowlist
if str(value).strip()
}
team_key = str(team_id or "").strip().lower()
return "canary" if team_key and team_key in allowed_team_ids else None
def _build_model_tryon_prompt_v2(
*,
product,
payload: dict,
has_model: bool,
index: int,
n_product: int,
rollout_source: str,
):
"""使用任务创建时的分类快照构建单张有效提示词,并返回可追溯信息。"""
from apps.ai.tryon_prompt import (
ClassificationResult,
ProductContext,
build_tryon_prompt_plan,
classify_product_details,
default_ratio_for_kind,
)
batch_count = int(payload.get("tryon_batch_count") or 0)
if batch_count not in (1, 2, 4):
raise ValueError("unsupported_tryon_batch_count")
if index < 0 or index >= batch_count:
raise ValueError("tryon_index_out_of_range")
context = ProductContext.create(
title=getattr(product, "title", "") or "商品",
category=getattr(product, "category", "") or "",
description=getattr(product, "description", "") or "",
selling_points=tuple(product.selling_points.values_list("title", flat=True)[:8]),
)
classification = ClassificationResult.from_payload(payload.get("tryon_classification"))
if classification is None:
classification = classify_product_details(context, str(payload.get("prompt") or ""))
requested_ratio = str(payload.get("ratio") or "").strip()
resolved_ratio = requested_ratio or default_ratio_for_kind(classification.kind)
plan = build_tryon_prompt_plan(
context=context,
user_prompt=str(payload.get("prompt") or ""),
count=batch_count,
ratio=resolved_ratio,
product_reference_count=n_product,
has_model_portrait=has_model,
has_model_triview=False,
classification=classification,
)
prompt = plan.prompts[index]
trace = {
"version": "v2.2",
"applied": True,
"rollout_source": rollout_source,
"effective_prompt": prompt,
"shot_index": index,
"batch_count": batch_count,
"requested_ratio": requested_ratio or None,
"resolved_ratio": resolved_ratio,
"ratio_source": "user" if requested_ratio else "default",
"reference_roles": {
"product_numbers": list(plan.references.product_numbers),
"model_portrait_number": plan.references.model_portrait_number,
"model_triview_number": plan.references.model_triview_number,
},
}
return prompt, trace, resolved_ratio
# 平台套图同批多张要「同款商品、不同版式」,否则 N 张文案/构图雷同(PMC#24)。
# 锁死商品一致性,只让排版/构图/视角/配色基调按张变化。
# 注:仅纯文生图回落路径(无参考图)仍用这个简表;refs 版改用下面的 slot 体系。
@@ -2649,7 +2751,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, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: 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, platform_id: str | None = None, batch_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, dispatch: bool = True) -> list[AITask]:
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
@@ -2666,10 +2768,10 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
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]
# 带参考图的图片创作必须走 image_edit(gpt-image)才真的还原上传素材——火山 Seedream 的图生图只松散
# 借色调、不锁主体(与三视图/模特上身图同理,那两条流程也强制 image_edit)。当前模型不支持 image_edit
# 就自动切到 gpt-image-2,否则用户选了火山却传了参考图 → 生成完全不参考素材(用户实测的根因)
if ref_ids and not hasattr(build_provider(model_config), "image_edit"):
# 普通图片创作带用户上传参考图时,当前模型不支持 image_edit 就沿用既有保护切到 gpt-image。
# 模特上身图例外:商品图和模特图由专用 Worker 作为多参考图传入 Seedream/GPT,必须尊重用户自选模型;
# 即使旧客户端额外带了 reference_image_ids,也不得在任务创建阶段静默覆盖 image_model
if ref_ids and mode != "model" and not hasattr(build_provider(model_config), "image_edit"):
alt = resolve_image_model("gpt-image")
if alt is not None:
model_config = alt
@@ -2691,10 +2793,49 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
platform_name = _PLATFORM_NAMES.get(platform_key, "")
from apps.billing.pricing import quote_flat
# Step 2.1:只为“模特上身图 + 商品”记录一次确定性分类快照。这里不读取图片、不调用模型;
# Step 3 的 Worker 根据 MODEL_TRYON_PROMPT_V2_ENABLED 决定使用 V2 或旧提示词。
tryon_classification: dict[str, str | None] | None = None
if mode == "model" and product_id:
from apps.ai.tryon_prompt import ClassificationResult, ProductContext, ProductKind, classify_product_details
from apps.products.models import Product
product_for_classification = Product.objects.filter(id=product_id, team=team).only(
"title", "category", "description"
).first()
if product_for_classification is None:
classification = ClassificationResult(
ProductKind.UNKNOWN,
"fallback",
None,
"product_not_found",
)
else:
classification = classify_product_details(
ProductContext.create(
title=product_for_classification.title,
category=product_for_classification.category,
description=product_for_classification.description,
),
prompt,
)
tryon_classification = classification.as_payload()
tasks: list[AITask] = []
for index in range(count):
quote = quote_flat(model_config, team=team)
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, "platform_id": platform_key or None, "platform_name": platform_name or None}
if tryon_classification is not None:
request_payload["tryon_classification"] = dict(tryon_classification)
request_payload["tryon_batch_count"] = count
# 仅供内部 A/B 工具使用;GenerateImageView 不接收这两个参数,用户请求无法绕过全局开关。
if tryon_prompt_v2_override:
request_payload["tryon_prompt_v2_override"] = True
if tryon_ab:
request_payload["tryon_ab"] = {
"experiment_id": str(tryon_ab.get("experiment_id") or ""),
"variant": str(tryon_ab.get("variant") or ""),
}
# 只在重跑/补图时落键(不落 False):workbench 用 KeyTextTransform 抽文本,"false" 字符串也是真值,会误判
if is_append:
request_payload["batch_append"] = True
@@ -2719,8 +2860,9 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
task.save(update_fields=["status", "updated_at"])
tasks.append(task)
# 额度都预留成功后再统一派发,避免"派发了任务但后面某张预留失败"的半成品状态
for task in tasks:
generate_standalone_image_task.delay(str(task.id))
if dispatch:
for task in tasks:
generate_standalone_image_task.delay(str(task.id))
return tasks
@@ -2732,7 +2874,7 @@ def run_standalone_image_task(*, task_id: str) -> None:
return
team = task.team
user = task.created_by
payload = task.request_payload or {}
payload = dict(task.request_payload or {})
prompt = str(payload.get("prompt") or "")
mode = str(payload.get("mode") or "image")
index = int(payload.get("index") or 0)
@@ -2755,7 +2897,7 @@ def run_standalone_image_task(*, task_id: str) -> None:
if product_id:
from apps.products.models import Product
product = Product.objects.filter(id=product_id).first()
product = Product.objects.filter(id=product_id, team=team).first()
can_edit = hasattr(provider, "image_edit")
model_url = ""
if payload.get("model_id"):
@@ -2777,13 +2919,49 @@ def run_standalone_image_task(*, task_id: str) -> None:
# 参考图收集与 provider 无关:先把「该用哪些参考图 + 哪条提示词」定下来,再按模型能力选调用方式。
edit_images: list[str] = []
edit_prompt = ""
output_ratio = str(payload.get("ratio") or "")
tryon_prompt_trace: dict | None = None
tryon_rollout_source = (
_model_tryon_prompt_v2_rollout_source(team_id=team.id, payload=payload)
if mode == "model"
else None
)
if mode == "model" and product_urls:
# 模特上身图:参考图1~N=商品真实图(多角度),参考图N+1=模特(模特图可缺则让模型自取真人模特)
edit_images = product_urls + ([model_url] if model_url else [])
edit_prompt = build_model_tryon_prompt_refs(
legacy_prompt = build_model_tryon_prompt_refs(
product, has_model=bool(model_url), base_prompt=prompt,
index=index, n_product=len(product_urls),
)
edit_prompt = legacy_prompt
if tryon_rollout_source is not None:
try:
edit_prompt, tryon_prompt_trace, output_ratio = _build_model_tryon_prompt_v2(
product=product,
payload=payload,
has_model=bool(model_url),
index=index,
n_product=len(product_urls),
rollout_source=tryon_rollout_source,
)
except Exception as exc: # noqa: BLE001 — 规划器异常时安全回落旧提示词,不让已预扣任务悬空
tryon_prompt_trace = {
"version": "v2.2",
"applied": False,
"rollout_source": tryon_rollout_source,
"effective_prompt": legacy_prompt,
"shot_index": index,
"batch_count": payload.get("tryon_batch_count"),
"requested_ratio": output_ratio or None,
"resolved_ratio": output_ratio or None,
"ratio_source": "user" if output_ratio else None,
"fallback_reason": str(exc) if isinstance(exc, ValueError) else type(exc).__name__,
"reference_roles": {
"product_numbers": list(range(1, len(product_urls) + 1)),
"model_portrait_number": len(product_urls) + 1 if model_url else None,
"model_triview_number": None,
},
}
elif mode == "cover" and product_urls:
# 平台套图:参考图1~N=商品真实图(多角度,_product_reference_urls 已优先真实上传图/排除 AI 图),
# 有模特则参考图N+1=模特(锁人脸/身形)。platform_id 注入平台版式块(优化版)。
@@ -2804,19 +2982,42 @@ def run_standalone_image_task(*, task_id: str) -> None:
# 否则图生图只会松散借个色调、不真的还原上传的素材(=用户反馈的「没参考我的图」)。
edit_images = ref_urls
edit_prompt = build_free_reference_prompt(prompt, n_refs=len(ref_urls), index=index)
elif mode == "model" and product_id and tryon_rollout_source is not None:
# 没有真实商品参考图时维持原纯文生图行为,但明确留下未应用原因,不能伪装成 V2 已生效。
tryon_prompt_trace = {
"version": "v2.2",
"applied": False,
"rollout_source": tryon_rollout_source,
"effective_prompt": prompt,
"shot_index": index,
"batch_count": payload.get("tryon_batch_count"),
"requested_ratio": output_ratio or None,
"resolved_ratio": output_ratio or None,
"ratio_source": "user" if output_ratio else None,
"fallback_reason": "no_product_reference",
"reference_roles": {
"product_numbers": [],
"model_portrait_number": None,
"model_triview_number": None,
},
}
use_edit = bool(edit_images)
try:
if tryon_prompt_trace is not None:
payload["tryon_prompt"] = tryon_prompt_trace
task.request_payload = payload
task.save(update_fields=["request_payload", "updated_at"])
if use_edit and can_edit:
# gpt-image 等支持 image_edit(多图参考编辑接口)
if payload.get("reference_product"):
size = "1536x1024" # 三视图固定横向
else:
size = _ratio_to_image_size(str(payload.get("ratio") or "")) # 模特图按选中比例
size = _ratio_to_image_size(output_ratio) # 模特图按用户比例或 V2 默认比例
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=参考图(图生图多参考);
# 尺寸按选中比例换算成火山可接受的 ~2K 尺寸(三视图固定横向)
vsize = "2304x1728" if payload.get("reference_product") else _ratio_to_volcano_size(str(payload.get("ratio") or ""))
vsize = "2304x1728" if payload.get("reference_product") else _ratio_to_volcano_size(output_ratio)
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=edit_prompt, image=edit_images, size=vsize)
else:
# 纯文生图同批多张要不同构图,否则雷同(PMC#24);index>0 追加换版式指令。