diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index e4b6d49..d0c70e3 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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( - title=product_for_classification.title, - category=product_for_classification.category, - description=product_for_classification.description, + 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, diff --git a/core/backend/apps/ai/test_tryon_prompt.py b/core/backend/apps/ai/test_tryon_prompt.py index b680c8b..75a1645 100644 --- a/core/backend/apps/ai/test_tryon_prompt.py +++ b/core/backend/apps/ai/test_tryon_prompt.py @@ -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) - 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]) + + 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.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 diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 171c2da..a9b5cb7 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -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,33 +550,174 @@ 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): - 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), - ratio="3:4", - 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), 4) - self.assertEqual(len(prompts), 4) - self.assertTrue(all("背面全身站姿" not in prompt for prompt in prompts[:3])) - self.assertIn("背面全身站姿", prompts[3]) + 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), + ratio="3:4", + 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), 4) + self.assertEqual(len(prompts), 4) + 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( diff --git a/core/backend/apps/ai/tryon_prompt.py b/core/backend/apps/ai/tryon_prompt.py index d2688b2..1229dae 100644 --- a/core/backend/apps/ai/tryon_prompt.py +++ b/core/backend/apps/ai/tryon_prompt.py @@ -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, + )