fix: 优化裤装上身图裤长与景别
This commit is contained in:
@@ -1039,6 +1039,7 @@ def _build_model_tryon_prompt_v2(
|
||||
from apps.ai.tryon_prompt import (
|
||||
ClassificationResult,
|
||||
ProductContext,
|
||||
TrouserFacts,
|
||||
build_tryon_prompt_plan,
|
||||
classify_product_details,
|
||||
default_ratio_for_kind,
|
||||
@@ -1059,6 +1060,7 @@ def _build_model_tryon_prompt_v2(
|
||||
classification = ClassificationResult.from_payload(payload.get("tryon_classification"))
|
||||
if classification is None:
|
||||
classification = classify_product_details(context, str(payload.get("prompt") or ""))
|
||||
trouser_facts = TrouserFacts.from_payload(payload.get("tryon_trouser_facts"))
|
||||
|
||||
requested_ratio = str(payload.get("ratio") or "").strip()
|
||||
resolved_ratio = requested_ratio or default_ratio_for_kind(classification.kind)
|
||||
@@ -1071,10 +1073,11 @@ def _build_model_tryon_prompt_v2(
|
||||
has_model_portrait=has_model,
|
||||
has_model_triview=False,
|
||||
classification=classification,
|
||||
trouser_facts=trouser_facts,
|
||||
)
|
||||
prompt = plan.prompts[index]
|
||||
trace = {
|
||||
"version": "v2.5",
|
||||
"version": "v2.6",
|
||||
"applied": True,
|
||||
"rollout_source": rollout_source,
|
||||
"effective_prompt": prompt,
|
||||
@@ -1088,6 +1091,7 @@ def _build_model_tryon_prompt_v2(
|
||||
"model_portrait_number": plan.references.model_portrait_number,
|
||||
"model_triview_number": plan.references.model_triview_number,
|
||||
},
|
||||
"trouser_facts": plan.trouser_facts.as_payload() if plan.trouser_facts is not None else None,
|
||||
}
|
||||
return prompt, trace, resolved_ratio
|
||||
|
||||
@@ -2796,8 +2800,15 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
||||
# Step 2.1:只为“模特上身图 + 商品”记录一次确定性分类快照。这里不读取图片、不调用模型;
|
||||
# Step 3 的 Worker 根据 MODEL_TRYON_PROMPT_V2_ENABLED 决定使用 V2 或旧提示词。
|
||||
tryon_classification: dict[str, str | None] | None = None
|
||||
tryon_trouser_facts: 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.ai.tryon_prompt import (
|
||||
ClassificationResult,
|
||||
ProductContext,
|
||||
ProductKind,
|
||||
classify_product_details,
|
||||
extract_trouser_facts,
|
||||
)
|
||||
from apps.products.models import Product
|
||||
|
||||
product_for_classification = Product.objects.filter(id=product_id, team=team).only(
|
||||
@@ -2811,14 +2822,26 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
||||
"product_not_found",
|
||||
)
|
||||
else:
|
||||
classification = classify_product_details(
|
||||
ProductContext.create(
|
||||
classification_context = ProductContext.create(
|
||||
title=product_for_classification.title,
|
||||
category=product_for_classification.category,
|
||||
description=product_for_classification.description,
|
||||
)
|
||||
product_context = ProductContext.create(
|
||||
title=classification_context.title,
|
||||
category=classification_context.category,
|
||||
description=classification_context.description,
|
||||
selling_points=tuple(
|
||||
product_for_classification.selling_points.values_list("title", flat=True)[:8]
|
||||
),
|
||||
)
|
||||
classification = classify_product_details(
|
||||
classification_context,
|
||||
prompt,
|
||||
)
|
||||
trouser_facts = extract_trouser_facts(product_context, classification.kind, prompt)
|
||||
if trouser_facts is not None:
|
||||
tryon_trouser_facts = trouser_facts.as_payload()
|
||||
tryon_classification = classification.as_payload()
|
||||
|
||||
tasks: list[AITask] = []
|
||||
@@ -2827,6 +2850,8 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
||||
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)
|
||||
if tryon_trouser_facts is not None:
|
||||
request_payload["tryon_trouser_facts"] = dict(tryon_trouser_facts)
|
||||
request_payload["tryon_batch_count"] = count
|
||||
# 仅供内部 A/B 工具使用;GenerateImageView 不接收这两个参数,用户请求无法绕过全局开关。
|
||||
if tryon_prompt_v2_override:
|
||||
@@ -2946,7 +2971,7 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — 规划器异常时安全回落旧提示词,不让已预扣任务悬空
|
||||
tryon_prompt_trace = {
|
||||
"version": "v2.5",
|
||||
"version": "v2.6",
|
||||
"applied": False,
|
||||
"rollout_source": tryon_rollout_source,
|
||||
"effective_prompt": legacy_prompt,
|
||||
@@ -2985,7 +3010,7 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
elif mode == "model" and product_id and tryon_rollout_source is not None:
|
||||
# 没有真实商品参考图时维持原纯文生图行为,但明确留下未应用原因,不能伪装成 V2 已生效。
|
||||
tryon_prompt_trace = {
|
||||
"version": "v2.5",
|
||||
"version": "v2.6",
|
||||
"applied": False,
|
||||
"rollout_source": tryon_rollout_source,
|
||||
"effective_prompt": prompt,
|
||||
|
||||
@@ -10,10 +10,14 @@ from apps.ai.tryon_prompt import (
|
||||
ProductContext,
|
||||
ProductKind,
|
||||
ReferenceRoles,
|
||||
TrouserFacts,
|
||||
TrouserLength,
|
||||
TrouserSilhouette,
|
||||
build_tryon_prompt_plan,
|
||||
classify_product,
|
||||
classify_product_details,
|
||||
default_ratio_for_kind,
|
||||
extract_trouser_facts,
|
||||
)
|
||||
|
||||
|
||||
@@ -93,6 +97,20 @@ class ProductClassificationTests(SimpleTestCase):
|
||||
self.assertEqual(default_ratio_for_kind(ProductKind.BAG), "3:4")
|
||||
self.assertEqual(default_ratio_for_kind(ProductKind.UNKNOWN), "1:1")
|
||||
|
||||
def test_trouser_facts_payload_round_trip_is_json_safe(self):
|
||||
facts = TrouserFacts(
|
||||
length=TrouserLength.FULL_LENGTH,
|
||||
silhouette=TrouserSilhouette.WIDE_LEG,
|
||||
length_source="product_text",
|
||||
silhouette_source="user_prompt",
|
||||
length_rule="长裤",
|
||||
silhouette_rule="宽腿",
|
||||
)
|
||||
payload = facts.as_payload()
|
||||
self.assertEqual(json.loads(json.dumps(payload, ensure_ascii=False)), payload)
|
||||
self.assertEqual(TrouserFacts.from_payload(payload), facts)
|
||||
self.assertIsNone(TrouserFacts.from_payload({"length": "invalid", "silhouette": "straight"}))
|
||||
|
||||
|
||||
class ReferenceRoleTests(SimpleTestCase):
|
||||
def test_single_product_then_model_portrait(self):
|
||||
@@ -159,7 +177,7 @@ class TryonPromptPlanTests(SimpleTestCase):
|
||||
self.assertEqual(prompt.count(heading), 1)
|
||||
self.assertIn("电商主图。本张镜头", prompt)
|
||||
|
||||
def test_trouser_length_anchor_is_identical_for_one_two_and_four_outputs(self):
|
||||
def test_trouser_geometry_anchor_is_identical_without_candidate_leakage(self):
|
||||
for count in (1, 2, 4):
|
||||
with self.subTest(count=count):
|
||||
plan = self._plan(
|
||||
@@ -168,14 +186,15 @@ class TryonPromptPlanTests(SimpleTestCase):
|
||||
prompt="模特上身展示,自然光,真实质感,电商主图",
|
||||
)
|
||||
self.assertEqual(len(plan.prompts), count)
|
||||
self.assertEqual(plan.trouser_facts, TrouserFacts())
|
||||
for prompt in plan.prompts:
|
||||
self.assertEqual(prompt.count("裤长人体落点"), 1)
|
||||
self.assertIn("裤脚相对脚踝、脚背、鞋面或地面的结束位置", prompt)
|
||||
self.assertIn("不得在九分裤、长裤、拖地裤之间变化", prompt)
|
||||
self.assertIn("姿态和角度只允许产生真实透视,不得改变裤长", prompt)
|
||||
self.assertIn("不臆造固定尺寸", prompt)
|
||||
self.assertEqual(prompt.count("裤装几何保真"), 1)
|
||||
self.assertIn("腰头至裤脚的纵向比例", prompt)
|
||||
self.assertIn("所有镜头复用同一裤长和版型事实", prompt)
|
||||
for leaked in ("九分裤", "长裤", "拖地裤", "脚踝", "脚背", "鞋面或地面"):
|
||||
self.assertNotIn(leaked, prompt)
|
||||
|
||||
def test_trouser_length_anchor_supports_jumpsuits_and_user_prompt_evidence(self):
|
||||
def test_trouser_geometry_anchor_supports_jumpsuits_and_user_prompt_evidence(self):
|
||||
jumpsuit = self._plan(title="高腰连体裤", count=1)
|
||||
generic_lower = self._plan(
|
||||
title="女装下装",
|
||||
@@ -184,9 +203,42 @@ class TryonPromptPlanTests(SimpleTestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(jumpsuit.kind, ProductKind.FULL_BODY)
|
||||
self.assertIn("裤长人体落点", jumpsuit.prompts[0])
|
||||
self.assertIn("裤装几何保真", jumpsuit.prompts[0])
|
||||
self.assertEqual(generic_lower.kind, ProductKind.LOWER)
|
||||
self.assertIn("裤长人体落点", generic_lower.prompts[0])
|
||||
self.assertIn("裤装几何保真", generic_lower.prompts[0])
|
||||
self.assertEqual(generic_lower.trouser_facts.silhouette, TrouserSilhouette.WIDE_LEG)
|
||||
self.assertIn("文字已明确为宽腿廓形", generic_lower.prompts[0])
|
||||
|
||||
def test_trouser_text_facts_use_product_first_and_ignore_negated_candidates(self):
|
||||
product_facts = extract_trouser_facts(
|
||||
ProductContext.create(title="宽腿长裤"),
|
||||
ProductKind.LOWER,
|
||||
"不要改成九分裤,也不要改成紧身裤",
|
||||
)
|
||||
self.assertEqual(product_facts.length, TrouserLength.FULL_LENGTH)
|
||||
self.assertEqual(product_facts.silhouette, TrouserSilhouette.WIDE_LEG)
|
||||
self.assertEqual(product_facts.length_source, "product_text")
|
||||
self.assertEqual(product_facts.silhouette_source, "product_text")
|
||||
|
||||
user_facts = extract_trouser_facts(
|
||||
ProductContext.create(title="舒适女裤"),
|
||||
ProductKind.LOWER,
|
||||
"保持直筒版型,不要九分裤",
|
||||
)
|
||||
self.assertEqual(user_facts.length, TrouserLength.UNKNOWN)
|
||||
self.assertEqual(user_facts.silhouette, TrouserSilhouette.STRAIGHT)
|
||||
self.assertIsNone(user_facts.length_rule)
|
||||
self.assertEqual(user_facts.silhouette_source, "user_prompt")
|
||||
|
||||
def test_explicit_single_length_fact_adds_only_one_positive_conclusion(self):
|
||||
plan = self._plan(title="宽腿长裤", count=4)
|
||||
self.assertEqual(plan.trouser_facts.length, TrouserLength.FULL_LENGTH)
|
||||
self.assertEqual(plan.trouser_facts.silhouette, TrouserSilhouette.WIDE_LEG)
|
||||
for prompt in plan.prompts:
|
||||
self.assertIn("文字已明确裤长为全长款", prompt)
|
||||
self.assertIn("文字已明确为宽腿廓形", prompt)
|
||||
for leaked in ("九分裤", "拖地裤", "脚踝", "脚背", "鞋面或地面"):
|
||||
self.assertNotIn(leaked, prompt)
|
||||
|
||||
def test_trouser_length_anchor_does_not_pollute_skirts_or_other_categories(self):
|
||||
cases = (
|
||||
@@ -199,18 +251,55 @@ class TryonPromptPlanTests(SimpleTestCase):
|
||||
for title, category in cases:
|
||||
with self.subTest(title=title):
|
||||
plan = self._plan(title=title, category=category, count=4)
|
||||
self.assertNotIn("裤长人体落点", "\n".join(plan.prompts))
|
||||
self.assertNotIn("裤装几何保真", "\n".join(plan.prompts))
|
||||
self.assertIsNone(plan.trouser_facts)
|
||||
|
||||
def test_lower_garment_shots_never_use_conflicting_templates(self):
|
||||
plan = self._plan(count=4)
|
||||
combined = "\n".join(plan.shots)
|
||||
for forbidden in ("半身", "手持", "领口", "袖型", "生活场景", "背面", "侧面全身"):
|
||||
def test_trouser_default_shots_restore_count_specific_full_and_medium_roles(self):
|
||||
one = self._plan(count=1)
|
||||
two = self._plan(count=2)
|
||||
four = self._plan(count=4)
|
||||
|
||||
self.assertEqual(len(one.shots), 1)
|
||||
self.assertIn("正面标准全身", one.shots[0])
|
||||
|
||||
self.assertEqual(len(two.shots), 2)
|
||||
self.assertIn("正面标准全身", two.shots[0])
|
||||
self.assertIn("正面人物中景", two.shots[1])
|
||||
|
||||
self.assertEqual(len(four.shots), 4)
|
||||
self.assertTrue(all("全身" in shot for shot in four.shots[:2]))
|
||||
self.assertTrue(all("人物中景" in shot for shot in four.shots[2:]))
|
||||
self.assertIn("左前约25度", four.shots[1])
|
||||
self.assertIn("单手自然插袋", four.shots[3])
|
||||
combined = "\n".join(four.shots)
|
||||
for forbidden in ("手持", "领口", "袖型", "生活场景", "背面", "侧面全身"):
|
||||
self.assertNotIn(forbidden, combined)
|
||||
|
||||
def test_trouser_medium_prompt_allows_hem_crop_without_conflicting_full_body_rules(self):
|
||||
prompt = self._plan(count=2).prompts[1]
|
||||
self.assertIn("人物中景", prompt)
|
||||
self.assertIn("裤脚允许自然出画", prompt)
|
||||
self.assertIn("画面下缘允许在膝部附近自然裁切", prompt)
|
||||
self.assertIn("禁止裁掉人物脸部、腰头、门襟、口袋或臀腿主要区域", prompt)
|
||||
self.assertNotIn("下装末端不得出画", prompt)
|
||||
self.assertNotIn("禁止裁掉双腿、脚部、腰头或下装末端", prompt)
|
||||
|
||||
def test_trouser_explicit_full_body_requirement_overrides_medium_defaults(self):
|
||||
for count in (1, 2, 4):
|
||||
with self.subTest(count=count):
|
||||
plan = self._plan(
|
||||
count=count,
|
||||
prompt="每张全身站姿,从头到脚完整展示,双腿完整露出",
|
||||
)
|
||||
self.assertTrue(all("全身站姿" in shot for shot in plan.shots))
|
||||
self.assertTrue(all("从头到脚完整入镜" in shot for shot in plan.shots))
|
||||
self.assertIn("左前约30度", plan.shots[1])
|
||||
self.assertIn("右前约30度", plan.shots[2])
|
||||
self.assertIn("近正面", plan.shots[3])
|
||||
self.assertNotIn("人物中景", "\n".join(plan.shots))
|
||||
|
||||
def test_non_trouser_lower_and_jumpsuit_do_not_inherit_trouser_medium_roles(self):
|
||||
skirt = self._plan(title="高腰半身裙", count=4)
|
||||
jumpsuit = self._plan(title="高腰连体裤", count=4)
|
||||
self.assertNotIn("人物中景", "\n".join(skirt.shots))
|
||||
self.assertNotIn("人物中景", "\n".join(jumpsuit.shots))
|
||||
|
||||
def test_back_view_requires_explicit_user_intent(self):
|
||||
for term in ("背面", "后袋", "后腰", "后视图", "前后展示", "正侧背", "四面展示"):
|
||||
@@ -227,6 +316,9 @@ class TryonPromptPlanTests(SimpleTestCase):
|
||||
self.assertIn("背面", one.shots[0])
|
||||
self.assertIn("正面", two.shots[0])
|
||||
self.assertIn("背面", two.shots[1])
|
||||
self.assertTrue(all("全身" in shot for shot in two.shots))
|
||||
self.assertTrue(all("全身" in shot for shot in four.shots[:2]))
|
||||
self.assertIn("人物中景", four.shots[2])
|
||||
self.assertIn("背面", four.shots[3])
|
||||
|
||||
def test_background_and_multi_angle_do_not_accidentally_request_back_view(self):
|
||||
@@ -407,6 +499,37 @@ class TryonClassificationPersistenceTests(TestCase):
|
||||
self.assertTrue(all(task.request_payload["prompt"] == "模特上身展示,自然光,电商主图" for task in tasks))
|
||||
self.assertEqual(delay.call_count, 2)
|
||||
|
||||
def test_model_trouser_batch_persists_same_text_facts_once(self):
|
||||
from apps.ai.services import enqueue_standalone_images
|
||||
|
||||
self.product.title = "柔软宽腿长裤"
|
||||
self.product.category = "服饰内衣"
|
||||
self.product.description = "宽松垂坠"
|
||||
self.product.save(update_fields=["title", "category", "description"])
|
||||
|
||||
with patch("apps.ai.tasks.generate_standalone_image_task.delay"):
|
||||
tasks = enqueue_standalone_images(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
prompt="模特上身展示,自然光,电商主图,不要改成九分裤",
|
||||
mode="model",
|
||||
count=4,
|
||||
product_id=str(self.product.id),
|
||||
ratio="3:4",
|
||||
)
|
||||
|
||||
expected = {
|
||||
"length": "full_length",
|
||||
"silhouette": "wide_leg",
|
||||
"length_source": "product_text",
|
||||
"silhouette_source": "product_text",
|
||||
"length_rule": "长裤",
|
||||
"silhouette_rule": "宽腿",
|
||||
"rule_version": "v1",
|
||||
}
|
||||
self.assertTrue(all(task.request_payload["tryon_trouser_facts"] == expected for task in tasks))
|
||||
self.assertEqual(len({json.dumps(task.request_payload["tryon_trouser_facts"], sort_keys=True) for task in tasks}), 1)
|
||||
|
||||
def test_non_tryon_task_does_not_gain_classification_field(self):
|
||||
from apps.ai.services import enqueue_standalone_images
|
||||
|
||||
@@ -419,6 +542,7 @@ class TryonClassificationPersistenceTests(TestCase):
|
||||
count=1,
|
||||
)[0]
|
||||
self.assertNotIn("tryon_classification", task.request_payload)
|
||||
self.assertNotIn("tryon_trouser_facts", task.request_payload)
|
||||
|
||||
def test_internal_ab_override_and_experiment_metadata_are_namespaced(self):
|
||||
from apps.ai.services import enqueue_standalone_images
|
||||
|
||||
@@ -434,7 +434,7 @@ class StandaloneImageReferenceTests(TestCase):
|
||||
for index, task in enumerate(tasks):
|
||||
trace = task.request_payload["tryon_prompt"]
|
||||
self.assertTrue(trace["applied"])
|
||||
self.assertEqual(trace["version"], "v2.5")
|
||||
self.assertEqual(trace["version"], "v2.6")
|
||||
self.assertEqual(trace["rollout_source"], "global")
|
||||
self.assertEqual(trace["effective_prompt"], used_prompts[index])
|
||||
self.assertEqual(trace["shot_index"], index)
|
||||
@@ -445,9 +445,10 @@ class StandaloneImageReferenceTests(TestCase):
|
||||
self.assertEqual(trace["reference_roles"]["model_portrait_number"], 2)
|
||||
self.assertIsNone(trace["reference_roles"]["model_triview_number"])
|
||||
self.assertEqual(task.request_payload["tryon_classification"]["kind"], "lower")
|
||||
self.assertEqual(trace["trouser_facts"], task.request_payload["tryon_trouser_facts"])
|
||||
|
||||
@override_settings(MODEL_TRYON_PROMPT_V2_ENABLED=True)
|
||||
def test_model_tryon_v25_zero_cost_model_count_ratio_matrix(self):
|
||||
def test_model_tryon_v26_zero_cost_model_count_ratio_matrix(self):
|
||||
"""Mock 全矩阵:两个模型都覆盖 1/2/4 张与预设/自定义比例,不调用真实供应商。"""
|
||||
CreditAccount.objects.filter(team=self.team).update(balance="5000.0000")
|
||||
self.product.title = "舒适柔软面料女裤"
|
||||
@@ -538,7 +539,7 @@ class StandaloneImageReferenceTests(TestCase):
|
||||
trace = task.request_payload["tryon_prompt"]
|
||||
effective_prompt = call.kwargs["prompt"]
|
||||
self.assertEqual(task.model_config_id, model_config.id)
|
||||
self.assertEqual(trace["version"], "v2.5")
|
||||
self.assertEqual(trace["version"], "v2.6")
|
||||
self.assertTrue(trace["applied"])
|
||||
self.assertEqual(trace["rollout_source"], "global")
|
||||
self.assertEqual(trace["shot_index"], index)
|
||||
@@ -549,22 +550,153 @@ class StandaloneImageReferenceTests(TestCase):
|
||||
self.assertEqual(trace["effective_prompt"], effective_prompt)
|
||||
self.assertEqual(trace["reference_roles"]["product_numbers"], [1])
|
||||
self.assertEqual(trace["reference_roles"]["model_portrait_number"], 2)
|
||||
self.assertEqual(trace["trouser_facts"], task.request_payload["tryon_trouser_facts"])
|
||||
self.assertEqual(trace["trouser_facts"]["length"], "unknown")
|
||||
self.assertEqual(trace["trouser_facts"]["silhouette"], "unknown")
|
||||
self.assertIn(f"严格使用 {ratio} 比例", effective_prompt)
|
||||
self.assertIn("裤长人体落点", effective_prompt)
|
||||
self.assertIn("裤装几何保真", effective_prompt)
|
||||
self.assertNotIn("不得在九分裤、长裤、拖地裤之间变化", effective_prompt)
|
||||
self.assertNotIn("背面", effective_prompt)
|
||||
if count == 1:
|
||||
self.assertIn("正面标准全身", effective_prompt)
|
||||
elif count == 2:
|
||||
expected_role = "正面标准全身" if index == 0 else "正面人物中景"
|
||||
self.assertIn(expected_role, effective_prompt)
|
||||
else:
|
||||
expected_role = "全身" if index < 2 else "人物中景"
|
||||
self.assertIn(expected_role, effective_prompt)
|
||||
self.assertEqual(
|
||||
call.kwargs[reference_key],
|
||||
["http://x/cover.png", "http://x/matrix-model.png"],
|
||||
)
|
||||
|
||||
for model_config, provider_method, _reference_key in models:
|
||||
with self.subTest(model=model_config.name, explicit_back=True):
|
||||
for count in (1, 2, 4):
|
||||
with self.subTest(
|
||||
model=model_config.name,
|
||||
count=count,
|
||||
default_ratio=True,
|
||||
):
|
||||
provider_method.reset_mock()
|
||||
tasks = enqueue_standalone_images(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
prompt="模特上身展示,自然光,真实质感,电商主图",
|
||||
mode="model",
|
||||
count=count,
|
||||
product_id=str(self.product.id),
|
||||
model_id=str(model_asset.id),
|
||||
image_model=f"{model_config.provider.name}:{model_config.name}",
|
||||
)
|
||||
self.assertEqual(len(tasks), count)
|
||||
for task, call in zip(tasks, provider_method.call_args_list):
|
||||
task.refresh_from_db()
|
||||
trace = task.request_payload["tryon_prompt"]
|
||||
self.assertIsNone(trace["requested_ratio"])
|
||||
self.assertEqual(trace["resolved_ratio"], "3:4")
|
||||
self.assertEqual(trace["ratio_source"], "default")
|
||||
self.assertIn("严格使用 3:4 比例", call.kwargs["prompt"])
|
||||
|
||||
for model_config, provider_method, _reference_key in models:
|
||||
for count in (1, 2, 4):
|
||||
for ratio in ratios:
|
||||
with self.subTest(
|
||||
model=model_config.name,
|
||||
count=count,
|
||||
ratio=ratio,
|
||||
explicit_full_body=True,
|
||||
):
|
||||
provider_method.reset_mock()
|
||||
tasks = enqueue_standalone_images(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
prompt="每张全身站姿,从头到脚完整展示,双腿完整露出",
|
||||
mode="model",
|
||||
count=count,
|
||||
product_id=str(self.product.id),
|
||||
model_id=str(model_asset.id),
|
||||
ratio=ratio,
|
||||
image_model=f"{model_config.provider.name}:{model_config.name}",
|
||||
)
|
||||
prompts = [call.kwargs["prompt"] for call in provider_method.call_args_list]
|
||||
self.assertEqual(len(tasks), count)
|
||||
self.assertEqual(len(prompts), count)
|
||||
for task, effective_prompt in zip(tasks, prompts):
|
||||
task.refresh_from_db()
|
||||
trace = task.request_payload["tryon_prompt"]
|
||||
self.assertEqual(task.model_config_id, model_config.id)
|
||||
self.assertEqual(trace["version"], "v2.6")
|
||||
self.assertEqual(trace["resolved_ratio"], ratio)
|
||||
self.assertIn(f"严格使用 {ratio} 比例", effective_prompt)
|
||||
self.assertIn("全身站姿", effective_prompt)
|
||||
self.assertIn("从头到脚完整入镜", effective_prompt)
|
||||
self.assertNotIn("人物中景", effective_prompt)
|
||||
self.assertNotIn("背面", effective_prompt)
|
||||
self.assertNotIn("不得在九分裤、长裤、拖地裤之间变化", effective_prompt)
|
||||
|
||||
for model_config, provider_method, _reference_key in models:
|
||||
for count in (1, 2, 4):
|
||||
for ratio in ratios:
|
||||
with self.subTest(
|
||||
model=model_config.name,
|
||||
count=count,
|
||||
ratio=ratio,
|
||||
explicit_back=True,
|
||||
):
|
||||
provider_method.reset_mock()
|
||||
tasks = enqueue_standalone_images(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
prompt="模特上身展示,完整展示后袋",
|
||||
mode="model",
|
||||
count=count,
|
||||
product_id=str(self.product.id),
|
||||
model_id=str(model_asset.id),
|
||||
ratio=ratio,
|
||||
image_model=f"{model_config.provider.name}:{model_config.name}",
|
||||
)
|
||||
prompts = [call.kwargs["prompt"] for call in provider_method.call_args_list]
|
||||
self.assertEqual(len(tasks), count)
|
||||
self.assertEqual(len(prompts), count)
|
||||
self.assertTrue(all("背面全身站姿" not in prompt for prompt in prompts[:-1]))
|
||||
self.assertIn("背面全身站姿", prompts[-1])
|
||||
for task, effective_prompt in zip(tasks, prompts):
|
||||
task.refresh_from_db()
|
||||
trace = task.request_payload["tryon_prompt"]
|
||||
self.assertEqual(task.model_config_id, model_config.id)
|
||||
self.assertEqual(trace["version"], "v2.6")
|
||||
self.assertEqual(trace["resolved_ratio"], ratio)
|
||||
self.assertIn(f"严格使用 {ratio} 比例", effective_prompt)
|
||||
if count == 2:
|
||||
self.assertTrue(all("全身" in prompt for prompt in prompts))
|
||||
if count == 4:
|
||||
self.assertTrue(all("全身" in prompt for prompt in prompts[:2]))
|
||||
self.assertIn("人物中景", prompts[2])
|
||||
|
||||
cross_category_cases = (
|
||||
("高腰半身裙", "服饰内衣", "lower"),
|
||||
("宽松针织上衣", "服饰内衣", "upper"),
|
||||
("法式收腰连衣裙", "女装", "full_body"),
|
||||
("女式运动鞋", "鞋靴", "footwear"),
|
||||
("智能运动手表", "数码配件", "wrist"),
|
||||
("便携无线小风扇", "家电", "handheld"),
|
||||
)
|
||||
for title, category, expected_kind in cross_category_cases:
|
||||
self.product.title = title
|
||||
self.product.category = category
|
||||
self.product.save(update_fields=["title", "category"])
|
||||
for model_config, provider_method, _reference_key in models:
|
||||
with self.subTest(
|
||||
model=model_config.name,
|
||||
product=title,
|
||||
cross_category=True,
|
||||
):
|
||||
provider_method.reset_mock()
|
||||
tasks = enqueue_standalone_images(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
prompt="模特上身展示,自然光,真实质感,电商主图",
|
||||
mode="model",
|
||||
count=4,
|
||||
product_id=str(self.product.id),
|
||||
model_id=str(model_asset.id),
|
||||
@@ -574,8 +706,18 @@ class StandaloneImageReferenceTests(TestCase):
|
||||
prompts = [call.kwargs["prompt"] for call in provider_method.call_args_list]
|
||||
self.assertEqual(len(tasks), 4)
|
||||
self.assertEqual(len(prompts), 4)
|
||||
self.assertTrue(all("背面全身站姿" not in prompt for prompt in prompts[:3]))
|
||||
self.assertIn("背面全身站姿", prompts[3])
|
||||
self.assertTrue(all(task.model_config_id == model_config.id for task in tasks))
|
||||
self.assertTrue(
|
||||
all(
|
||||
task.request_payload["tryon_classification"]["kind"] == expected_kind
|
||||
for task in tasks
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all("tryon_trouser_facts" not in task.request_payload for task in tasks)
|
||||
)
|
||||
self.assertNotIn("裤装几何保真", "\n".join(prompts))
|
||||
self.assertNotIn("人物中景", "\n".join(prompts))
|
||||
|
||||
@override_settings(
|
||||
MODEL_TRYON_PROMPT_V2_ENABLED=False,
|
||||
@@ -623,7 +765,7 @@ class StandaloneImageReferenceTests(TestCase):
|
||||
task = AITask.objects.get(id=submitted[0].id)
|
||||
trace = task.request_payload["tryon_prompt"]
|
||||
self.assertTrue(trace["applied"])
|
||||
self.assertEqual(trace["version"], "v2.5")
|
||||
self.assertEqual(trace["version"], "v2.6")
|
||||
self.assertEqual(trace["rollout_source"], "canary")
|
||||
|
||||
@override_settings(
|
||||
|
||||
@@ -33,6 +33,23 @@ class ProductKind(str, Enum):
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class TrouserLength(str, Enum):
|
||||
UNKNOWN = "unknown"
|
||||
FULL_LENGTH = "full_length"
|
||||
CROPPED = "cropped"
|
||||
FLOOR_LENGTH = "floor_length"
|
||||
SHORTS = "shorts"
|
||||
|
||||
|
||||
class TrouserSilhouette(str, Enum):
|
||||
UNKNOWN = "unknown"
|
||||
WIDE_LEG = "wide_leg"
|
||||
STRAIGHT = "straight"
|
||||
TAPERED = "tapered"
|
||||
SLIM = "slim"
|
||||
FLARED = "flared"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProductContext:
|
||||
title: str
|
||||
@@ -159,10 +176,64 @@ class ClassificationResult:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrouserFacts:
|
||||
"""仅由现有文字字段确定性提取的裤长/版型批次快照。"""
|
||||
|
||||
length: TrouserLength = TrouserLength.UNKNOWN
|
||||
silhouette: TrouserSilhouette = TrouserSilhouette.UNKNOWN
|
||||
length_source: str | None = None
|
||||
silhouette_source: str | None = None
|
||||
length_rule: str | None = None
|
||||
silhouette_rule: str | None = None
|
||||
rule_version: str = "v1"
|
||||
|
||||
def as_payload(self) -> dict[str, str | None]:
|
||||
return {
|
||||
"length": self.length.value,
|
||||
"silhouette": self.silhouette.value,
|
||||
"length_source": self.length_source,
|
||||
"silhouette_source": self.silhouette_source,
|
||||
"length_rule": self.length_rule,
|
||||
"silhouette_rule": self.silhouette_rule,
|
||||
"rule_version": self.rule_version,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: object) -> "TrouserFacts | None":
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
try:
|
||||
length = TrouserLength(str(payload.get("length") or ""))
|
||||
silhouette = TrouserSilhouette(str(payload.get("silhouette") or ""))
|
||||
except ValueError:
|
||||
return None
|
||||
return cls(
|
||||
length=length,
|
||||
silhouette=silhouette,
|
||||
length_source=(
|
||||
str(payload["length_source"]) if payload.get("length_source") is not None else None
|
||||
),
|
||||
silhouette_source=(
|
||||
str(payload["silhouette_source"])
|
||||
if payload.get("silhouette_source") is not None
|
||||
else None
|
||||
),
|
||||
length_rule=(str(payload["length_rule"]) if payload.get("length_rule") is not None else None),
|
||||
silhouette_rule=(
|
||||
str(payload["silhouette_rule"])
|
||||
if payload.get("silhouette_rule") is not None
|
||||
else None
|
||||
),
|
||||
rule_version=str(payload.get("rule_version") or "v1"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TryonPromptPlan:
|
||||
kind: ProductKind
|
||||
classification: ClassificationResult
|
||||
trouser_facts: TrouserFacts | None
|
||||
ratio: str
|
||||
references: ReferenceRoles
|
||||
shots: tuple[str, ...]
|
||||
@@ -551,13 +622,45 @@ def _back_view_shot(kind: ProductKind, *, full_body: bool) -> str:
|
||||
return shot
|
||||
|
||||
|
||||
def plan_shots(kind: ProductKind, count: int, user_prompt: str = "") -> tuple[str, ...]:
|
||||
def _trouser_default_shots(count: int) -> tuple[str, ...]:
|
||||
full_front = (
|
||||
"正面标准全身站姿,从头到脚完整入镜,完整展示腰头、门襟、口袋、双腿、裤脚和正面版型"
|
||||
)
|
||||
full_angle = (
|
||||
"左前约25度全身站姿,从头到脚完整入镜,轻微转身,展示腰臀过渡、腿部轮廓和垂坠感"
|
||||
)
|
||||
medium_front = (
|
||||
"正面人物中景,保留人物脸部并从头部展示至膝部附近,"
|
||||
"清楚展示腰头、门襟、口袋、臀腿和裤腿大半"
|
||||
)
|
||||
medium_angle = (
|
||||
"小角度近正面人物中景,保留人物脸部并从头部展示至膝部附近,"
|
||||
"允许单手自然插袋或轻微调整重心,清楚展示腰头、口袋、臀腿和裤腿大半"
|
||||
)
|
||||
if count == 1:
|
||||
return (full_front,)
|
||||
if count == 2:
|
||||
return (full_front, medium_front)
|
||||
return (full_front, full_angle, medium_front, medium_angle)
|
||||
|
||||
|
||||
def plan_shots(
|
||||
kind: ProductKind,
|
||||
count: int,
|
||||
user_prompt: str = "",
|
||||
trouser_facts: TrouserFacts | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
if count not in (1, 2, 4):
|
||||
raise ValueError("模特上身图生成数量只支持 1、2、4")
|
||||
full_body = _requires_full_body(user_prompt)
|
||||
shots = _full_body_shots(kind) if full_body else _POLICIES[kind].shots
|
||||
if full_body:
|
||||
shots = _full_body_shots(kind)[:count]
|
||||
elif kind == ProductKind.LOWER and trouser_facts is not None:
|
||||
shots = _trouser_default_shots(count)
|
||||
else:
|
||||
shots = _POLICIES[kind].shots[:count]
|
||||
if not _requests_back_view(user_prompt):
|
||||
return shots[:count]
|
||||
return shots
|
||||
|
||||
back_shot = _back_view_shot(kind, full_body=full_body)
|
||||
if count == 1:
|
||||
@@ -601,33 +704,118 @@ def _selling_points_instruction(context: ProductContext) -> str:
|
||||
|
||||
_TROUSER_TERMS = ("裤", "pants", "trouser", "jeans", "leggings", "joggers", "jumpsuit")
|
||||
_NON_TROUSER_PRODUCT_TERMS = ("内裤", "underpants", "briefs")
|
||||
_NEGATION_MARKERS = ("不要", "不得", "禁止", "避免", "拒绝", "不是", "并非", "不可", "非", "not ")
|
||||
_NEGATION_CONNECTORS = ("", "改成", "变成", "生成", "成为", "做成", "使用", "出现", "为", "是", "做", "过度", "a", "an")
|
||||
|
||||
_TROUSER_LENGTH_RULES: tuple[tuple[TrouserLength, tuple[str, ...]], ...] = (
|
||||
(TrouserLength.FLOOR_LENGTH, ("拖地裤", "拖地", "落地裤", "及地裤", "floor-length")),
|
||||
(TrouserLength.CROPPED, ("九分裤", "九分", "八分裤", "八分", "七分裤", "七分", "cropped")),
|
||||
(TrouserLength.SHORTS, ("短裤", "五分裤", "五分", "热裤", "shorts")),
|
||||
(TrouserLength.FULL_LENGTH, ("全长裤", "全长", "长裤", "full-length")),
|
||||
)
|
||||
_TROUSER_SILHOUETTE_RULES: tuple[tuple[TrouserSilhouette, tuple[str, ...]], ...] = (
|
||||
(TrouserSilhouette.WIDE_LEG, ("阔腿", "宽腿", "宽裤腿", "wide-leg", "wide leg")),
|
||||
(TrouserSilhouette.FLARED, ("喇叭裤", "喇叭", "微喇", "flared", "bootcut")),
|
||||
(TrouserSilhouette.TAPERED, ("锥形裤", "锥形", "小脚裤", "小脚", "束脚裤", "束脚", "tapered")),
|
||||
(TrouserSilhouette.SLIM, ("紧身裤", "紧身", "修身裤", "铅笔裤", "skinny", "leggings")),
|
||||
(TrouserSilhouette.STRAIGHT, ("直筒裤", "直筒", "straight-leg", "straight leg")),
|
||||
)
|
||||
|
||||
_TROUSER_LENGTH_PROMPTS = {
|
||||
TrouserLength.FULL_LENGTH: "文字已明确裤长为全长款;所有镜头保持这一长度事实。",
|
||||
TrouserLength.CROPPED: "文字已明确裤长为九分长度;所有镜头保持这一长度事实。",
|
||||
TrouserLength.FLOOR_LENGTH: "文字已明确裤长为及地长度;所有镜头保持这一长度事实。",
|
||||
TrouserLength.SHORTS: "文字已明确裤长为短款;所有镜头保持这一长度事实。",
|
||||
}
|
||||
_TROUSER_SILHOUETTE_PROMPTS = {
|
||||
TrouserSilhouette.WIDE_LEG: "文字已明确为宽腿廓形;保持裤腿宽松度和裤脚开口。",
|
||||
TrouserSilhouette.STRAIGHT: "文字已明确为直筒廓形;保持裤腿上下宽度关系。",
|
||||
TrouserSilhouette.TAPERED: "文字已明确为锥形廓形;保持原有收束关系。",
|
||||
TrouserSilhouette.SLIM: "文字已明确为修身廓形;保持原有贴合度。",
|
||||
TrouserSilhouette.FLARED: "文字已明确为喇叭廓形;保持裤脚展开关系。",
|
||||
}
|
||||
|
||||
|
||||
def _is_negated_match(text: str, start: int) -> bool:
|
||||
prefix = text[max(0, start - 10) : start]
|
||||
for marker in _NEGATION_MARKERS:
|
||||
marker_index = prefix.rfind(marker)
|
||||
if marker_index < 0:
|
||||
continue
|
||||
connector = prefix[marker_index + len(marker) :].strip(" ,。;;、::")
|
||||
if connector in _NEGATION_CONNECTORS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _match_text_fact(text: str, rules: tuple[tuple[Enum, tuple[str, ...]], ...]):
|
||||
lowered = (text or "").lower()
|
||||
for value, terms in rules:
|
||||
for term in terms:
|
||||
start = lowered.find(term)
|
||||
while start >= 0:
|
||||
if not _is_negated_match(lowered, start):
|
||||
return value, term
|
||||
start = lowered.find(term, start + len(term))
|
||||
return None, None
|
||||
|
||||
|
||||
def extract_trouser_facts(
|
||||
context: ProductContext,
|
||||
kind: ProductKind,
|
||||
user_prompt: str = "",
|
||||
) -> TrouserFacts | None:
|
||||
"""从商品文字优先、用户文字补充地解析一次批次事实;不读图、不调用模型。"""
|
||||
|
||||
if kind not in {ProductKind.LOWER, ProductKind.FULL_BODY}:
|
||||
return None
|
||||
product_text = " ".join(
|
||||
(context.title, context.category, context.description, *context.selling_points)
|
||||
).lower()
|
||||
if any(term in product_text for term in _NON_TROUSER_PRODUCT_TERMS):
|
||||
return None
|
||||
combined = f"{product_text} {user_prompt or ''}".lower()
|
||||
if not any(term in combined for term in _TROUSER_TERMS):
|
||||
return None
|
||||
|
||||
length, length_rule = _match_text_fact(product_text, _TROUSER_LENGTH_RULES)
|
||||
length_source = "product_text" if length is not None else None
|
||||
if length is None:
|
||||
length, length_rule = _match_text_fact(user_prompt, _TROUSER_LENGTH_RULES)
|
||||
length_source = "user_prompt" if length is not None else None
|
||||
|
||||
silhouette, silhouette_rule = _match_text_fact(product_text, _TROUSER_SILHOUETTE_RULES)
|
||||
silhouette_source = "product_text" if silhouette is not None else None
|
||||
if silhouette is None:
|
||||
silhouette, silhouette_rule = _match_text_fact(user_prompt, _TROUSER_SILHOUETTE_RULES)
|
||||
silhouette_source = "user_prompt" if silhouette is not None else None
|
||||
|
||||
return TrouserFacts(
|
||||
length=length or TrouserLength.UNKNOWN,
|
||||
silhouette=silhouette or TrouserSilhouette.UNKNOWN,
|
||||
length_source=length_source,
|
||||
silhouette_source=silhouette_source,
|
||||
length_rule=length_rule,
|
||||
silhouette_rule=silhouette_rule,
|
||||
)
|
||||
|
||||
|
||||
def _trouser_length_anchor_instruction(
|
||||
context: ProductContext,
|
||||
kind: ProductKind,
|
||||
user_prompt: str,
|
||||
facts: TrouserFacts | None,
|
||||
) -> str:
|
||||
if kind not in {ProductKind.LOWER, ProductKind.FULL_BODY}:
|
||||
if facts is None:
|
||||
return ""
|
||||
product_blob = " ".join(
|
||||
(
|
||||
context.title,
|
||||
context.category,
|
||||
context.description,
|
||||
*context.selling_points,
|
||||
)
|
||||
).lower()
|
||||
if any(term in product_blob for term in _NON_TROUSER_PRODUCT_TERMS):
|
||||
return ""
|
||||
blob = f"{product_blob} {user_prompt or ''}".lower()
|
||||
if not any(term in blob for term in _TROUSER_TERMS):
|
||||
return ""
|
||||
return (
|
||||
"裤长人体落点:裤脚相对脚踝、脚背、鞋面或地面的结束位置必须与商品参考图一致;"
|
||||
"同批保持腰线、裆位、裤腿宽度、裤脚开口和裤长类别一致,不得在九分裤、长裤、拖地裤之间变化。"
|
||||
"姿态和角度只允许产生真实透视,不得改变裤长;无法判断时以商品参考图为准,不臆造固定尺寸。"
|
||||
)
|
||||
parts = [
|
||||
"裤装几何保真:保持商品参考图中腰头至裤脚的纵向比例、裤腿宽度、裤脚开口和垂坠关系;"
|
||||
"所有镜头复用同一裤长和版型事实,不因姿态、美化或构图改变这些比例。"
|
||||
]
|
||||
length_prompt = _TROUSER_LENGTH_PROMPTS.get(facts.length)
|
||||
if length_prompt:
|
||||
parts.append(length_prompt)
|
||||
silhouette_prompt = _TROUSER_SILHOUETTE_PROMPTS.get(facts.silhouette)
|
||||
if silhouette_prompt:
|
||||
parts.append(silhouette_prompt)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
_TARGET_AREAS: dict[ProductKind, str] = {
|
||||
@@ -664,7 +852,42 @@ def _editing_boundary_instruction(kind: ProductKind, references: ReferenceRoles)
|
||||
)
|
||||
|
||||
|
||||
def _safe_margin_instruction(shot: str) -> str:
|
||||
def _is_trouser_medium_shot(shot: str, trouser_facts: TrouserFacts | None) -> bool:
|
||||
return trouser_facts is not None and "人物中景" in shot
|
||||
|
||||
|
||||
def _visibility_instruction(
|
||||
policy: DisplayPolicy,
|
||||
shot: str,
|
||||
trouser_facts: TrouserFacts | None,
|
||||
) -> str:
|
||||
if _is_trouser_medium_shot(shot, trouser_facts):
|
||||
return (
|
||||
"人物脸部、腰头、门襟、口袋、臀腿和大部分裤腿必须清楚可见;"
|
||||
"本张为人物中景,裤脚允许自然出画,不要求从头到脚完整入镜"
|
||||
)
|
||||
return policy.visibility
|
||||
|
||||
|
||||
def _negative_instruction(
|
||||
policy: DisplayPolicy,
|
||||
shot: str,
|
||||
trouser_facts: TrouserFacts | None,
|
||||
) -> str:
|
||||
if _is_trouser_medium_shot(shot, trouser_facts):
|
||||
return (
|
||||
"禁止裁掉人物脸部、腰头、门襟、口袋或臀腿主要区域;"
|
||||
"禁止把下装改成手持商品"
|
||||
)
|
||||
return policy.negative
|
||||
|
||||
|
||||
def _safe_margin_instruction(shot: str, trouser_facts: TrouserFacts | None = None) -> str:
|
||||
if _is_trouser_medium_shot(shot, trouser_facts):
|
||||
return (
|
||||
"构图安全边界:人物脸部、腰头、口袋、臀腿和画面内裤腿保留安全留白;"
|
||||
"画面下缘允许在膝部附近自然裁切,必须展示区域不得触边。"
|
||||
)
|
||||
if "全身" in shot or "从头到脚" in shot:
|
||||
return (
|
||||
"构图安全边界:头顶、双脚和商品末端完整入画,四周保留约画布短边的 3%-6% 安全留白,"
|
||||
@@ -684,6 +907,7 @@ def _build_prompt(
|
||||
user_prompt: str,
|
||||
ratio: str,
|
||||
shot: str,
|
||||
trouser_facts: TrouserFacts | None,
|
||||
) -> str:
|
||||
hard_requirement = (user_prompt or "").strip() or "生成真实、可用的电商模特商品展示图。"
|
||||
if references.model_portrait_number is not None:
|
||||
@@ -694,6 +918,9 @@ def _build_prompt(
|
||||
else:
|
||||
identity = "使用同一位真人写实模特,人体比例和表情自然,批次内身份保持一致。"
|
||||
|
||||
visibility = _visibility_instruction(policy, shot, trouser_facts)
|
||||
negative = _negative_instruction(policy, shot, trouser_facts)
|
||||
|
||||
product_parts = [
|
||||
references.instruction(),
|
||||
(
|
||||
@@ -702,15 +929,15 @@ def _build_prompt(
|
||||
"同批保持同一款,不重新设计、改款或生成相似款。"
|
||||
),
|
||||
_selling_points_instruction(context),
|
||||
f"穿戴或使用:{policy.placement};必须可见:{policy.visibility}。",
|
||||
_trouser_length_anchor_instruction(context, kind, user_prompt),
|
||||
f"穿戴或使用:{policy.placement};必须可见:{visibility}。",
|
||||
_trouser_length_anchor_instruction(trouser_facts),
|
||||
"商品关键结构优先于姿态、美化和风格;发生冲突时减少姿态和镜头变化,不牺牲参考图可见结构。",
|
||||
]
|
||||
person_parts = [identity, _editing_boundary_instruction(kind, references)]
|
||||
composition_parts = [
|
||||
f"用户硬性要求(最高优先,图号按参考图角色解析):{hard_requirement}。",
|
||||
f"本张镜头:{shot}。",
|
||||
_safe_margin_instruction(shot),
|
||||
_safe_margin_instruction(shot, trouser_facts),
|
||||
f"输出画幅:严格使用 {ratio} 比例;比例不得裁掉必须展示的商品部位。",
|
||||
(
|
||||
"场景与光线:用户已指定时严格沿用;否则使用浅灰或米白纯色背景、"
|
||||
@@ -718,7 +945,7 @@ def _build_prompt(
|
||||
),
|
||||
"画质:真人写实、高分辨率,肤色和材质自然,商品为视觉重点,人物与商品比例协调。",
|
||||
(
|
||||
f"硬失败规避:{policy.negative};禁止多余商品或肢体、畸形手脚、身体扭曲、低清模糊、"
|
||||
f"硬失败规避:{negative};禁止多余商品或肢体、畸形手脚、身体扭曲、低清模糊、"
|
||||
"过度磨皮、夸张滤镜、水印和边框。"
|
||||
),
|
||||
(
|
||||
@@ -744,6 +971,7 @@ def build_tryon_prompt_plan(
|
||||
has_model_portrait: bool = True,
|
||||
has_model_triview: bool = False,
|
||||
classification: ClassificationResult | None = None,
|
||||
trouser_facts: TrouserFacts | None = None,
|
||||
) -> TryonPromptPlan:
|
||||
"""生成一批确定性提示词;只规划,不调用图片模型。"""
|
||||
|
||||
@@ -753,12 +981,13 @@ def build_tryon_prompt_plan(
|
||||
|
||||
classification = classification or classify_product_details(context, user_prompt)
|
||||
kind = classification.kind
|
||||
trouser_facts = trouser_facts or extract_trouser_facts(context, kind, user_prompt)
|
||||
references = ReferenceRoles.create(
|
||||
product_count=product_reference_count,
|
||||
has_model_portrait=has_model_portrait,
|
||||
has_model_triview=has_model_triview,
|
||||
)
|
||||
shots = plan_shots(kind, count, user_prompt)
|
||||
shots = plan_shots(kind, count, user_prompt, trouser_facts)
|
||||
policy = _POLICIES[kind]
|
||||
prompts = tuple(
|
||||
_build_prompt(
|
||||
@@ -769,7 +998,16 @@ def build_tryon_prompt_plan(
|
||||
user_prompt=user_prompt,
|
||||
ratio=normalized_ratio,
|
||||
shot=shot,
|
||||
trouser_facts=trouser_facts,
|
||||
)
|
||||
for shot in shots
|
||||
)
|
||||
return TryonPromptPlan(kind, classification, normalized_ratio, references, shots, prompts)
|
||||
return TryonPromptPlan(
|
||||
kind,
|
||||
classification,
|
||||
trouser_facts,
|
||||
normalized_ratio,
|
||||
references,
|
||||
shots,
|
||||
prompts,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user