feat: 优化模特上身图提示词
This commit is contained in:
@@ -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 追加换版式指令。
|
||||
|
||||
Reference in New Issue
Block a user