fix: 优化裤装上身图裤长与景别

This commit is contained in:
hh
2026-07-15 00:22:36 +08:00
parent 1a40a8cb4b
commit ca3b09c62e
4 changed files with 612 additions and 83 deletions
+31 -6
View File
@@ -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,
+141 -17
View File
@@ -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
+150 -8
View File
@@ -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(
+270 -32
View File
@@ -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,
)