From 1a40a8cb4b2126981dc6339f7406fadbee3d0af3 Mon Sep 17 00:00:00 2001 From: hh <2587203630@qq.com> Date: Tue, 14 Jul 2026 17:16:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=A8=A1=E7=89=B9?= =?UTF-8?q?=E4=B8=8A=E8=BA=AB=E5=9B=BE=E5=A4=9A=E5=9B=BE=E4=B8=8E=E8=A3=A4?= =?UTF-8?q?=E9=95=BF=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/services.py | 6 +- core/backend/apps/ai/test_tryon_prompt.py | 116 ++++++++++- core/backend/apps/ai/tests.py | 139 ++++++++++++- core/backend/apps/ai/tryon_prompt.py | 230 ++++++++++++++++------ 4 files changed, 425 insertions(+), 66 deletions(-) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index dc6cf09..e4b6d49 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -1074,7 +1074,7 @@ def _build_model_tryon_prompt_v2( ) prompt = plan.prompts[index] trace = { - "version": "v2.2", + "version": "v2.5", "applied": True, "rollout_source": rollout_source, "effective_prompt": prompt, @@ -2946,7 +2946,7 @@ def run_standalone_image_task(*, task_id: str) -> None: ) except Exception as exc: # noqa: BLE001 — 规划器异常时安全回落旧提示词,不让已预扣任务悬空 tryon_prompt_trace = { - "version": "v2.2", + "version": "v2.5", "applied": False, "rollout_source": tryon_rollout_source, "effective_prompt": legacy_prompt, @@ -2985,7 +2985,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.2", + "version": "v2.5", "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 221680d..b680c8b 100644 --- a/core/backend/apps/ai/test_tryon_prompt.py +++ b/core/backend/apps/ai/test_tryon_prompt.py @@ -100,7 +100,7 @@ class ReferenceRoleTests(SimpleTestCase): self.assertEqual(roles.product_numbers, (1,)) self.assertEqual(roles.model_portrait_number, 2) self.assertIsNone(roles.model_triview_number) - self.assertIn("参考图1是同一商品", roles.instruction()) + self.assertIn("商品图=参考图1(同一商品真实图)", roles.instruction()) self.assertIn("参考图2(模特肖像)", roles.instruction()) def test_three_products_portrait_and_triview(self): @@ -142,13 +142,123 @@ class TryonPromptPlanTests(SimpleTestCase): self.assertEqual(plan.ratio, ratio) self.assertIn(f"严格使用 {ratio} 比例", plan.prompts[0]) + def test_prompt_is_compact_and_uses_exactly_three_core_layers(self): + prompt = self._plan( + title="宽松针织上衣", + count=1, + prompt="模特上身展示,自然光,真实质感,电商主图", + ).prompts[0] + lines = prompt.splitlines() + + self.assertEqual(len(lines), 3) + self.assertTrue(lines[0].startswith("【商品事实】")) + self.assertTrue(lines[1].startswith("【人物身份】")) + self.assertTrue(lines[2].startswith("【构图执行】")) + self.assertLess(len(prompt), 1050) + for heading in ("【商品事实】", "【人物身份】", "【构图执行】"): + self.assertEqual(prompt.count(heading), 1) + self.assertIn("电商主图。本张镜头", prompt) + + def test_trouser_length_anchor_is_identical_for_one_two_and_four_outputs(self): + for count in (1, 2, 4): + with self.subTest(count=count): + plan = self._plan( + title="舒适柔软面料女裤", + count=count, + prompt="模特上身展示,自然光,真实质感,电商主图", + ) + self.assertEqual(len(plan.prompts), count) + for prompt in plan.prompts: + self.assertEqual(prompt.count("裤长人体落点"), 1) + self.assertIn("裤脚相对脚踝、脚背、鞋面或地面的结束位置", prompt) + self.assertIn("不得在九分裤、长裤、拖地裤之间变化", prompt) + self.assertIn("姿态和角度只允许产生真实透视,不得改变裤长", prompt) + self.assertIn("不臆造固定尺寸", prompt) + + def test_trouser_length_anchor_supports_jumpsuits_and_user_prompt_evidence(self): + jumpsuit = self._plan(title="高腰连体裤", count=1) + generic_lower = self._plan( + title="女装下装", + count=1, + prompt="让模特穿上这条宽腿裤", + ) + + self.assertEqual(jumpsuit.kind, ProductKind.FULL_BODY) + self.assertIn("裤长人体落点", jumpsuit.prompts[0]) + self.assertEqual(generic_lower.kind, ProductKind.LOWER) + self.assertIn("裤长人体落点", generic_lower.prompts[0]) + + def test_trouser_length_anchor_does_not_pollute_skirts_or_other_categories(self): + cases = ( + ("高腰半身裙", "服饰内衣"), + ("长款连衣裙", "服饰内衣"), + ("男士纯棉内裤", "服饰内衣"), + ("宽松针织上衣", "服饰内衣"), + ("女式运动鞋", "鞋靴"), + ) + 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)) + def test_lower_garment_shots_never_use_conflicting_templates(self): plan = self._plan(count=4) combined = "\n".join(plan.shots) - for forbidden in ("半身", "手持", "领口", "袖型", "生活场景"): + 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_back_view_requires_explicit_user_intent(self): + for term in ("背面", "后袋", "后腰", "后视图", "前后展示", "正侧背", "四面展示"): + with self.subTest(term=term): + plan = self._plan(count=4, prompt=f"电商模特上身图,展示{term}") + self.assertNotIn("背面", "\n".join(plan.shots[:3])) + self.assertIn("背面", plan.shots[3]) + + def test_explicit_back_view_respects_one_two_and_four_counts(self): + one = self._plan(count=1, prompt="只生成背面展示") + two = self._plan(count=2, prompt="需要前后展示") + four = self._plan(count=4, prompt="需要四面展示") + + self.assertIn("背面", one.shots[0]) + self.assertIn("正面", two.shots[0]) + self.assertIn("背面", two.shots[1]) + self.assertIn("背面", four.shots[3]) + + def test_background_and_multi_angle_do_not_accidentally_request_back_view(self): + for prompt in ( + "浅灰背景,多角度展示", + "米白背景,正面自然站姿", + "背心商品上身图", + "后袋设计牛仔裤项目,模特上身展示", + "背面印花上衣项目,模特上身展示", + ): + with self.subTest(prompt=prompt): + plan = self._plan(count=4, prompt=prompt) + self.assertNotIn("背面", "\n".join(plan.shots)) + + def test_product_critical_side_views_remain_for_footwear_and_headwear(self): + footwear = self._plan(title="女式运动鞋", category="鞋靴", count=4) + headwear = self._plan(title="棒球帽", category="服饰配件", count=4) + + self.assertTrue(any("侧面" in shot for shot in footwear.shots)) + self.assertTrue(any("侧面" in shot for shot in headwear.shots)) + self.assertNotIn("背面", "\n".join(footwear.shots)) + self.assertNotIn("背面", "\n".join(headwear.shots)) + + def test_explicit_back_for_handheld_turns_product_not_model_away(self): + plan = self._plan( + title="便携无线小风扇", + category="家电", + count=1, + prompt="展示商品背面", + ) + self.assertEqual(plan.kind, ProductKind.HANDHELD) + self.assertIn("商品背面朝向镜头", plan.shots[0]) def test_user_hard_requirement_precedes_default_shot(self): hard_requirement = "全身站姿,双腿完整露出,浅灰纯色背景" @@ -192,6 +302,8 @@ class TryonPromptPlanTests(SimpleTestCase): self.assertEqual(plan.kind, ProductKind.UPPER) 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)) + self.assertNotIn("侧面全身", "\n".join(plan.shots)) def test_wrist_product_is_worn_not_held(self): plan = self._plan(title="智能运动手表", category="数码配件", count=4) diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 7383078..171c2da 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -171,7 +171,7 @@ class NormalizeDraftTests(SimpleTestCase): from io import BytesIO -from unittest.mock import patch +from unittest.mock import MagicMock, patch from django.test import TestCase, override_settings @@ -425,7 +425,7 @@ class StandaloneImageReferenceTests(TestCase): used_prompts = [call.kwargs["prompt"] for call in prov.image_edit.call_args_list] used_sizes = [call.kwargs["size"] for call in prov.image_edit.call_args_list] self.assertTrue(all(user_prompt in prompt for prompt in used_prompts)) - self.assertTrue(all("参考图角色(最高优先级)" in prompt for prompt in used_prompts)) + self.assertTrue(all("参考图角色(固定语义)" in prompt for prompt in used_prompts)) self.assertTrue(all("严格使用 7:10 比例" in prompt for prompt in used_prompts)) self.assertTrue(all("半身近景" not in prompt and "手持或局部" not in prompt for prompt in used_prompts)) self.assertNotEqual(used_prompts[0], used_prompts[1]) @@ -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.2") + self.assertEqual(trace["version"], "v2.5") self.assertEqual(trace["rollout_source"], "global") self.assertEqual(trace["effective_prompt"], used_prompts[index]) self.assertEqual(trace["shot_index"], index) @@ -446,6 +446,137 @@ class StandaloneImageReferenceTests(TestCase): self.assertIsNone(trace["reference_roles"]["model_triview_number"]) self.assertEqual(task.request_payload["tryon_classification"]["kind"], "lower") + @override_settings(MODEL_TRYON_PROMPT_V2_ENABLED=True) + def test_model_tryon_v25_zero_cost_model_count_ratio_matrix(self): + """Mock 全矩阵:两个模型都覆盖 1/2/4 张与预设/自定义比例,不调用真实供应商。""" + CreditAccount.objects.filter(team=self.team).update(balance="5000.0000") + self.product.title = "舒适柔软面料女裤" + self.product.category = "服饰内衣" + self.product.save(update_fields=["title", "category"]) + model_asset = Asset.objects.create( + team=self.team, + created_by=self.user, + name="矩阵模特", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.PERSON, + ) + AssetFile.objects.create( + asset=model_asset, + object_key="matrix-model.png", + bucket="b", + content_type="image/png", + preview_url="http://x/matrix-model.png", + is_primary=True, + ) + + volcano, _ = ModelProvider.objects.get_or_create( + name="volcengine", + defaults={"display_name": "火山"}, + ) + seedream_config = ModelConfig.objects.create( + provider=volcano, + name="seedream-v25-matrix", + display_name="Seedream V2.5 matrix", + capability=ModelConfig.Capability.IMAGE, + unit_price="1.0000", + ) + gpt_config = ModelConfig.objects.filter( + capability=ModelConfig.Capability.IMAGE, + name__icontains="gpt-image", + ).first() + self.assertIsNotNone(gpt_config) + + seedream = MagicMock() + del seedream.image_edit + seedream.image_generation.return_value = {"data": [{"url": "http://x/seedream-matrix.png"}]} + seedream.extract_first_media_url.return_value = "http://x/seedream-matrix.png" + gpt = MagicMock() + gpt.image_edit.return_value = {"data": [{"url": "http://x/gpt-matrix.png"}]} + gpt.extract_first_media_url.return_value = "http://x/gpt-matrix.png" + + def provider_for(model_config, *args, **kwargs): + del args, kwargs + return seedream if model_config.id == seedream_config.id else gpt + + stored = MagicMock(object_key="matrix.png", bucket="b", content_type="image/png", size_bytes=3) + ratios = ("1:1", "3:4", "9:16", "7:10") + models = ( + (seedream_config, seedream.image_generation, "image"), + (gpt_config, gpt.image_edit, "images"), + ) + with ( + patch("apps.ai.services.get_image_provider", side_effect=provider_for), + patch( + "apps.ai.services.VolcanoArkProvider.media_to_bytes", + return_value=(BytesIO(b"img"), "image/png"), + ), + patch("apps.ai.services.TosStorage") as storage, + ): + storage.return_value.upload_fileobj.return_value = stored + 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): + 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}", + ) + + self.assertEqual(len(tasks), count) + self.assertEqual(len(provider_method.call_args_list), count) + for index, (task, call) in enumerate(zip(tasks, provider_method.call_args_list)): + task.refresh_from_db() + 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.assertTrue(trace["applied"]) + self.assertEqual(trace["rollout_source"], "global") + self.assertEqual(trace["shot_index"], index) + self.assertEqual(trace["batch_count"], count) + self.assertEqual(trace["requested_ratio"], ratio) + self.assertEqual(trace["resolved_ratio"], ratio) + self.assertEqual(trace["ratio_source"], "user") + 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.assertIn(f"严格使用 {ratio} 比例", effective_prompt) + self.assertIn("裤长人体落点", effective_prompt) + self.assertNotIn("背面", 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]) + @override_settings( MODEL_TRYON_PROMPT_V2_ENABLED=False, MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS=frozenset(), @@ -492,7 +623,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.2") + self.assertEqual(trace["version"], "v2.5") 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 c19ee4c..d2688b2 100644 --- a/core/backend/apps/ai/tryon_prompt.py +++ b/core/backend/apps/ai/tryon_prompt.py @@ -104,10 +104,9 @@ class ReferenceRoles: def instruction(self) -> str: return ( - f"参考图角色(最高优先级):{self.product_label}是同一商品的真实商品图," - f"{self.model_label}。商品图只决定商品外观,人物图只决定人物身份与体型。" - "用户原文若使用了与实际上传顺序不同的‘图1/图2’编号,不按数字机械执行:" - "人物、女生、模特相关要求统一对应人物参考图,商品、衣物、配件相关要求统一对应商品参考图。" + f"参考图角色(固定语义):商品图={self.product_label}(同一商品真实图);人物图={self.model_label}。" + "商品图定义外观,人物图定义身份与体型。用户原文若使用了与实际顺序不同的图号," + "按角色语义解析:人物、女生、模特对应人物图,商品、衣物、配件对应商品图。" ) @@ -171,6 +170,31 @@ class TryonPromptPlan: _FULL_BODY_TERMS = ("全身", "从头到脚", "头到脚", "双腿完整", "脚部完整", "完整站姿") +_BACK_VIEW_TERMS = ( + "背面图", + "背面视图", + "背面视角", + "背后角度", + "从背面", + "从背后", + "后视图", + "后视角", + "前后展示", + "前后两面", + "正侧背", + "四面展示", + "四面图", + "四视图", + "前后左右", +) +_BACK_VIEW_FEATURES = ("背面", "背后", "后背", "后腰", "后袋") +_BACK_VIEW_ACTIONS = ("展示", "看清", "突出", "呈现", "查看", "拍摄", "生成", "需要", "增加", "提供") +_FACE_FORWARD_APPAREL_KINDS = { + ProductKind.LOWER, + ProductKind.UPPER, + ProductKind.FULL_BODY, + ProductKind.UNDERWEAR, +} _POLICIES: dict[ProductKind, DisplayPolicy] = { @@ -179,9 +203,9 @@ _POLICIES: dict[ProductKind, DisplayPolicy] = { visibility="完整展示腰线、门襟、口袋、臀腿版型、裤腿或裙摆、长度和垂坠感;双腿及下装末端不得出画", shots=( "正面全身站姿,从头到脚完整入镜,双腿自然分开,清楚展示下装正面版型", - "三分之四角度全身站姿,从头到脚完整入镜,展示腰臀过渡、腿部轮廓与垂坠感", - "侧面全身站姿,从头到脚完整入镜,展示腰线、侧缝、裤长或裙长", - "背面全身站姿,从头到脚完整入镜,展示后腰、后袋、臀腿和下装末端", + "左前约30度全身站姿,从头到脚完整入镜,轻微转身,展示腰臀过渡、腿部轮廓与垂坠感", + "右前约30度全身站姿,从头到脚完整入镜,自然调整重心,展示下装版型且保持脸部清晰", + "近正面全身站姿,从头到脚完整入镜,姿态自然且仅轻微变化,双手不遮挡腰头和口袋", ), negative="禁止裁掉双腿、脚部、腰头或下装末端;禁止把下装改成手持商品", ), @@ -190,9 +214,9 @@ _POLICIES: dict[ProductKind, DisplayPolicy] = { visibility="清楚展示领口、肩线、袖型、门襟、下摆、图案和面料垂感", shots=( "正面站姿,腰部以上完整入镜,双臂自然放松,展示上衣正面结构", - "三分之四角度站姿,腰部以上完整入镜,展示肩线、袖型和衣身轮廓", - "侧面站姿,腰部以上完整入镜,展示侧缝、衣长和面料垂感", - "背面站姿,腰部以上完整入镜,展示后领、后背和下摆", + "左前约30度站姿,腰部以上完整入镜,展示肩线、袖型和衣身轮廓", + "右前约30度站姿,腰部以上完整入镜,展示衣身轮廓并保持脸部清晰", + "近正面自然站姿,腰部以上完整入镜,姿态轻微变化,双臂不遮挡领口、袖口和下摆", ), negative="禁止把上衣拿在手中或叠放展示;禁止遮挡领口、袖口和下摆", ), @@ -201,9 +225,9 @@ _POLICIES: dict[ProductKind, DisplayPolicy] = { visibility="从肩部到服装下摆完整展示,连体结构、腰线、长度和整体廓形不得被裁切", shots=( "正面全身站姿,从头到脚完整入镜,展示服装完整正面廓形", - "三分之四角度全身站姿,从头到脚完整入镜,展示腰线和整体垂坠感", - "侧面全身站姿,从头到脚完整入镜,展示侧面轮廓与服装长度", - "背面全身站姿,从头到脚完整入镜,展示完整后背结构与下摆", + "左前约30度全身站姿,从头到脚完整入镜,展示腰线和整体垂坠感", + "右前约30度全身站姿,从头到脚完整入镜,展示完整廓形并保持脸部清晰", + "近正面全身站姿,从头到脚完整入镜,姿态自然且仅轻微变化,服装主体与下摆无遮挡", ), negative="禁止截断服装主体或下摆;禁止拆成上下两件或擅自改变连体结构", ), @@ -212,9 +236,9 @@ _POLICIES: dict[ProductKind, DisplayPolicy] = { visibility="在合规、克制的电商展示中清楚呈现商品轮廓、边缘、面料和支撑结构", shots=( "正面自然站姿,商品完整入镜,姿态克制,展示正面结构和贴合度", - "三分之四角度自然站姿,商品完整入镜,展示侧翼、肩带或腰线", - "侧面自然站姿,商品完整入镜,展示厚度、包覆和贴合轮廓", - "背面自然站姿,商品完整入镜,展示背带、搭扣或后腰结构", + "左前约30度自然站姿,商品完整入镜,展示侧翼、肩带或腰线", + "右前约30度自然站姿,商品完整入镜,展示包覆和贴合轮廓并保持脸部清晰", + "近正面自然站姿,商品完整入镜,姿态克制变化,关键结构无遮挡", ), negative="禁止色情化姿势、夸张身体曲线或透明化处理;禁止遮挡商品关键结构", ), @@ -236,7 +260,7 @@ _POLICIES: dict[ProductKind, DisplayPolicy] = { "正面胸部以上人像,头饰完整入镜,展示正面形态", "三分之四角度胸部以上人像,头饰完整入镜,展示帽檐和帽冠", "侧面胸部以上人像,头饰完整入镜,展示侧面结构", - "背面胸部以上人像,头饰完整入镜,展示后部调节或收口结构", + "近正面胸部以上人像,头饰完整入镜,姿态轻微变化,突出材质与正面结构", ), negative="禁止把头饰拿在手中、悬浮在头顶或遮挡整个面部", ), @@ -313,7 +337,7 @@ _POLICIES: dict[ProductKind, DisplayPolicy] = { "正面自然站姿,商品正确穿戴并完整入镜", "三分之四角度自然站姿,展示商品轮廓与穿戴关系", "侧面自然站姿,展示商品厚度、长度和贴合方式", - "背面自然站姿,展示商品后部结构与固定方式", + "近正面自然站姿,商品正确穿戴并完整入镜,姿态轻微变化且不遮挡关键结构", ), negative="禁止把穿戴商品改成手持展示;禁止错误穿戴位置或改变用途", ), @@ -353,6 +377,26 @@ _POLICIES: dict[ProductKind, DisplayPolicy] = { } +_BACK_VIEW_SHOTS: dict[ProductKind, str] = { + ProductKind.LOWER: "背面全身站姿,从头到脚完整入镜,展示后腰、后袋、臀腿和下装末端", + ProductKind.UPPER: "背面站姿,腰部以上完整入镜,展示后领、后背和下摆", + ProductKind.FULL_BODY: "背面全身站姿,从头到脚完整入镜,展示完整后背结构与下摆", + ProductKind.UNDERWEAR: "背面自然站姿,商品完整入镜,展示背带、搭扣或后腰结构", + ProductKind.FOOTWEAR: "背面全身站姿,从头到脚完整入镜,展示两只鞋的鞋跟、后部结构和落地关系", + ProductKind.HEADWEAR: "背面胸部以上人像,头饰完整入镜,展示后部调节或收口结构", + ProductKind.EYEWEAR: "后侧三分之四角度肩部以上人像,眼镜完整入镜,展示镜腿与耳部的贴合关系", + ProductKind.WRIST: "手腕后侧自然人像,商品保持正确佩戴,展示表带、扣具和皮肤接触关系", + ProductKind.HEAD_AUDIO: "背面胸部以上人像,耳机正确佩戴,展示头梁后部、左右耳罩和佩戴关系", + ProductKind.EAR_AUDIO: "耳部后侧近景,商品保持正确佩戴,展示耳挂、柄部或后侧连接结构", + ProductKind.JEWELRY: "佩戴位置后侧自然人像,商品保持正确佩戴,展示连接件、扣具或背面结构", + ProductKind.BAG: "背面自然站姿,商品按真实包型正确携带,展示包身背面、肩带或提手结构", + ProductKind.WEARABLE_GENERIC: "背面自然站姿,商品正确穿戴并完整入镜,展示后部结构与固定方式", + ProductKind.HANDHELD: "正面自然人像,模特按真实用途拿稳商品并让商品背面朝向镜头,主体无遮挡", + ProductKind.NONWEARABLE: "自然电商人像,商品背面朝向镜头并保持真实使用关系,主体完整清晰", + ProductKind.UNKNOWN: "自然电商人像,商品按判断出的唯一真实用途展示,背面朝向镜头且主体完整清晰", +} + + _KEYWORDS: tuple[tuple[ProductKind, tuple[str, ...]], ...] = ( (ProductKind.FULL_BODY, ("连衣裙", "连体裤", "连体衣", "连身裙", "套装")), (ProductKind.LOWER, ("牛仔裤", "裤", "半身裙", "短裙", "长裙", "下装")), @@ -462,25 +506,65 @@ def _requires_full_body(user_prompt: str) -> bool: return any(term in lowered for term in _FULL_BODY_TERMS) +def _requests_back_view(user_prompt: str) -> bool: + lowered = (user_prompt or "").lower() + if any(term in lowered for term in _BACK_VIEW_TERMS): + return True + + stripped = lowered.strip(" ,。;;、::") + if stripped in _BACK_VIEW_FEATURES: + return True + return any( + f"{action}{feature}" in lowered + or f"{action}商品{feature}" in lowered + or f"{feature}{action}" in lowered + for feature in _BACK_VIEW_FEATURES + for action in _BACK_VIEW_ACTIONS + ) + + def _full_body_shots(kind: ProductKind) -> tuple[str, str, str, str]: subject = { ProductKind.FOOTWEAR: "商品与两只鞋", ProductKind.LOWER: "下装、双腿与脚部", ProductKind.FULL_BODY: "整件服装与下摆", }.get(kind, "人物与商品") + if kind in _FACE_FORWARD_APPAREL_KINDS: + return ( + f"正面全身站姿,从头到脚完整入镜,{subject}完整清晰", + f"左前约30度全身站姿,从头到脚完整入镜,{subject}完整清晰并保持脸部自然", + f"右前约30度全身站姿,从头到脚完整入镜,{subject}完整清晰并保持脸部自然", + f"近正面全身站姿,从头到脚完整入镜,{subject}完整清晰,姿态自然且仅做轻微变化", + ) return ( f"正面全身站姿,从头到脚完整入镜,{subject}完整清晰", f"三分之四角度全身站姿,从头到脚完整入镜,{subject}完整清晰", f"侧面全身站姿,从头到脚完整入镜,{subject}完整清晰", - f"背面全身站姿,从头到脚完整入镜,{subject}完整清晰", + f"近正面全身站姿,从头到脚完整入镜,{subject}完整清晰,姿态自然且仅做轻微变化", ) +def _back_view_shot(kind: ProductKind, *, full_body: bool) -> str: + shot = _BACK_VIEW_SHOTS[kind] + if full_body and "全身" not in shot: + return f"从头到脚完整入镜的全身构图;{shot}" + return shot + + def plan_shots(kind: ProductKind, count: int, user_prompt: str = "") -> tuple[str, ...]: if count not in (1, 2, 4): raise ValueError("模特上身图生成数量只支持 1、2、4") - shots = _full_body_shots(kind) if _requires_full_body(user_prompt) else _POLICIES[kind].shots - return shots[:count] + full_body = _requires_full_body(user_prompt) + shots = _full_body_shots(kind) if full_body else _POLICIES[kind].shots + if not _requests_back_view(user_prompt): + return shots[:count] + + back_shot = _back_view_shot(kind, full_body=full_body) + if count == 1: + return (back_shot,) + if count == 2: + return (shots[0], back_shot) + return (*shots[:3], back_shot) def default_ratio_for_kind(kind: ProductKind) -> str: @@ -510,8 +594,39 @@ def _selling_points_instruction(context: ProductContext) -> str: return "" points = "、".join(context.selling_points) return ( - f"商品文字卖点(仅用于理解展示意图):{points}。" - "文字卖点不是商品外观证据,不得据此臆造参考图中看不到的颜色、结构、材质或功能。" + f"文字卖点:{points};仅用于理解展示意图,不是商品外观证据," + "不得据此臆造参考图中不可见的属性。" + ) + + +_TROUSER_TERMS = ("裤", "pants", "trouser", "jeans", "leggings", "joggers", "jumpsuit") +_NON_TROUSER_PRODUCT_TERMS = ("内裤", "underpants", "briefs") + + +def _trouser_length_anchor_instruction( + context: ProductContext, + kind: ProductKind, + user_prompt: str, +) -> str: + if kind not in {ProductKind.LOWER, ProductKind.FULL_BODY}: + 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 ( + "裤长人体落点:裤脚相对脚踝、脚背、鞋面或地面的结束位置必须与商品参考图一致;" + "同批保持腰线、裆位、裤腿宽度、裤脚开口和裤长类别一致,不得在九分裤、长裤、拖地裤之间变化。" + "姿态和角度只允许产生真实透视,不得改变裤长;无法判断时以商品参考图为准,不臆造固定尺寸。" ) @@ -539,12 +654,12 @@ def _editing_boundary_instruction(kind: ProductKind, references: ReferenceRoles) target_area = _TARGET_AREAS[kind] if references.model_portrait_number is None: return ( - f"区域编辑边界:只修改或生成{target_area};除目标商品正常穿戴或使用所必需的区域外," - "不得连带重做非目标服饰、发型、妆容或配饰,不重新设计整套穿搭。" + f"区域编辑边界:只修改或生成{target_area};除商品正确穿戴或使用所必需的区域外," + "不重做非目标服饰、发型、妆容、配饰或整套穿搭。" ) return ( - f"区域编辑边界:只替换或修改{target_area}。参考图{references.model_portrait_number}中的非目标服饰、" - "发型、妆容和已有配饰保持不变,不重新设计整套穿搭。非目标服饰上的文字或图案清楚可辨时," + f"区域编辑边界:只替换或修改{target_area};参考图{references.model_portrait_number}中的非目标服饰、" + "发型、妆容和配饰保持不变。非目标服饰上的文字或图案清楚可辨时," "保持原内容、拼写、位置和颜色;无法辨认时不得猜写相似文字、乱码或新增文案。" ) @@ -552,12 +667,11 @@ def _editing_boundary_instruction(kind: ProductKind, references: ReferenceRoles) def _safe_margin_instruction(shot: str) -> str: if "全身" in shot or "从头到脚" in shot: return ( - "构图安全边界:头顶、双脚、裤脚、裙摆或商品末端必须完整处于画布内," - "与画布上下左右边缘保留明显安全留白(约画布短边的 3%-6%);不得裁切、触边或贴边。" + "构图安全边界:头顶、双脚和商品末端完整入画,四周保留约画布短边的 3%-6% 安全留白," + "不得裁切、触边或贴边。" ) return ( - "构图安全边界:商品主体及所有必须展示的边缘完整处于画布内并保留明显安全留白," - "不得裁切、触边或贴边。" + "构图安全边界:商品主体和关键边缘完整入画并保留安全留白,不得裁切、触边或贴边。" ) @@ -574,48 +688,50 @@ def _build_prompt( hard_requirement = (user_prompt or "").strip() or "生成真实、可用的电商模特商品展示图。" if references.model_portrait_number is not None: identity = ( - f"人物身份锁定:严格保持参考图{references.model_portrait_number}中人物的脸型、五官、发型、" - "肤色、年龄感、身材比例和气质,不换人、不混合其他人物特征。" + f"保持参考图{references.model_portrait_number}中人物的脸型、五官、发型、肤色、年龄感、" + "身材比例和自然气质,不换人或混合其他人物特征。" ) else: - identity = "人物要求:使用同一位真人写实模特,人体比例自然,批次内人物身份保持一致。" + identity = "使用同一位真人写实模特,人体比例和表情自然,批次内身份保持一致。" - sections = [ + product_parts = [ references.instruction(), - f"用户硬性要求(高于默认镜头与默认场景):{hard_requirement}", - identity, ( - f"商品保真:{references.product_label}是“{context.title}”外观的唯一事实依据。" - "严格保持商品颜色、版型、结构、材质纹理、长度、口袋、拉链、缝线、图案、文字与 Logo;" - "多张商品图需综合理解为同一件商品,不重新设计、不改款、不生成相似款。" - ), - _editing_boundary_instruction(kind, references), - ( - "商品关键结构优先:商品参考图中可见的独特轮廓、部件数量与位置关系、开合结构、口袋、" - "拉链、缝线和五金,优先于姿态变化、修身美化与风格化;同一批每张必须保持为同一款商品。" - "若商品结构与复杂姿态或镜头变化冲突,减少姿态和镜头变化,绝不牺牲商品关键结构。" + f"{references.product_label}是“{context.title}”的唯一外观依据;保持可见的颜色、版型、材质、" + "长度、图案、文字与 Logo、独特轮廓、部件数量与位置关系、开合结构、口袋、拉链、缝线和五金;" + "同批保持同一款,不重新设计、改款或生成相似款。" ), _selling_points_instruction(context), - f"穿戴或使用关系:{policy.placement}。", - f"商品可见性:{policy.visibility}。", + f"穿戴或使用:{policy.placement};必须可见:{policy.visibility}。", + _trouser_length_anchor_instruction(context, kind, user_prompt), + "商品关键结构优先于姿态、美化和风格;发生冲突时减少姿态和镜头变化,不牺牲参考图可见结构。", + ] + person_parts = [identity, _editing_boundary_instruction(kind, references)] + composition_parts = [ + f"用户硬性要求(最高优先,图号按参考图角色解析):{hard_requirement}。", f"本张镜头:{shot}。", _safe_margin_instruction(shot), - f"输出画幅:严格使用 {ratio} 比例;比例只影响构图,不得因此裁掉必须完整展示的商品部位。", + f"输出画幅:严格使用 {ratio} 比例;比例不得裁掉必须展示的商品部位。", ( - "场景与光线:若用户硬性要求指定了背景、场景或光线,严格沿用用户要求;" - "否则使用浅灰或米白纯色背景、柔和均匀棚拍光、干净克制的电商布光。" + "场景与光线:用户已指定时严格沿用;否则使用浅灰或米白纯色背景、" + "柔和均匀棚拍光和干净电商布光。" ), - "画质:真人写实,高分辨率,肤色和面料质感自然,商品为视觉重点,人物与商品比例真实协调。", + "画质:真人写实、高分辨率,肤色和材质自然,商品为视觉重点,人物与商品比例协调。", ( - "冲突处理优先级:用户硬性展示要求最高(其中图号按参考图角色语义解析),随后依次为人物身份、" - "商品身份与关键结构、正确穿戴或使用关系、必须展示区域;姿态、镜头变化、背景装饰和美化不得覆盖上述硬约束。" + f"硬失败规避:{policy.negative};禁止多余商品或肢体、畸形手脚、身体扭曲、低清模糊、" + "过度磨皮、夸张滤镜、水印和边框。" ), ( - f"禁止项:{policy.negative};禁止换人、改变商品设计、错误 Logo 或乱码文字、多余商品、" - "多余肢体、畸形手脚、身体扭曲、低清模糊、过度磨皮、夸张滤镜、水印和边框。" + "冲突处理优先级:用户要求 > 人物身份 > 商品事实与关键结构 > 正确穿戴或使用及必须可见范围 " + "> 镜头、背景和美化。" ), ] - return "\n".join(section for section in sections if section) + sections = ( + "【商品事实】" + "".join(part for part in product_parts if part), + "【人物身份】" + "".join(part for part in person_parts if part), + "【构图执行】" + "".join(part for part in composition_parts if part), + ) + return "\n".join(sections) def build_tryon_prompt_plan(