feat: 优化模特上身图提示词

This commit is contained in:
hh
2026-07-14 15:18:54 +08:00
parent da58f99293
commit 0a58b89919
8 changed files with 1973 additions and 15 deletions
+298 -1
View File
@@ -173,7 +173,7 @@ class NormalizeDraftTests(SimpleTestCase):
from io import BytesIO
from unittest.mock import patch
from django.test import TestCase
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@@ -392,6 +392,212 @@ class StandaloneImageReferenceTests(TestCase):
self.assertEqual(prov.image_edit.call_args.kwargs["images"], ["http://x/cover.png", "http://x/model.png"])
prov.image_generation.assert_not_called()
@override_settings(MODEL_TRYON_PROMPT_V2_ENABLED=True)
def test_model_tryon_v2_uses_structured_prompt_and_persists_trace(self):
prov = self._patch_provider()
self.product.title = "中蓝色高腰宽腿牛仔裤"
self.product.category = "服饰内衣"
self.product.save(update_fields=["title", "category"])
self.product.selling_points.create(title="显高显瘦", sort_order=0)
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="m-v2.png", bucket="b", content_type="image/png",
preview_url="http://x/model-v2.png", is_primary=True,
)
user_prompt = "参考图1的女生穿上图2的牛仔裤,全身站姿,双腿完整露出,浅灰纯色背景"
submitted = enqueue_standalone_images(
team=self.team,
user=self.user,
prompt=user_prompt,
mode="model",
count=2,
product_id=str(self.product.id),
model_id=str(model_asset.id),
ratio="7:10",
)
tasks = list(AITask.objects.filter(id__in=[task.id for task in submitted]).order_by("request_payload__index"))
self.assertEqual(len(prov.image_edit.call_args_list), 2)
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("严格使用 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])
self.assertEqual(used_sizes, ["1024x1536", "1024x1536"])
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["rollout_source"], "global")
self.assertEqual(trace["effective_prompt"], used_prompts[index])
self.assertEqual(trace["shot_index"], index)
self.assertEqual(trace["batch_count"], 2)
self.assertEqual(trace["requested_ratio"], "7:10")
self.assertEqual(trace["resolved_ratio"], "7:10")
self.assertEqual(trace["reference_roles"]["product_numbers"], [1])
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")
@override_settings(
MODEL_TRYON_PROMPT_V2_ENABLED=False,
MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS=frozenset(),
)
def test_model_tryon_flag_off_keeps_legacy_prompt(self):
prov = self._patch_provider()
self.product.title = "女装裤子"
self.product.save(update_fields=["title"])
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="m-legacy.png", bucket="b", content_type="image/png",
preview_url="http://x/model-legacy.png", is_primary=True,
)
submitted = enqueue_standalone_images(
team=self.team, user=self.user, prompt="模特上身图", mode="model", count=1,
product_id=str(self.product.id), model_id=str(model_asset.id), ratio="3:4",
)
used_prompt = prov.image_edit.call_args.kwargs["prompt"]
self.assertIn("半身近景", used_prompt)
task = AITask.objects.get(id=submitted[0].id)
self.assertNotIn("tryon_prompt", task.request_payload)
@override_settings(MODEL_TRYON_PROMPT_V2_ENABLED=False)
def test_model_tryon_canary_team_uses_v22_and_records_source(self):
prov = self._patch_provider()
self.product.title = "女装裤子"
self.product.save(update_fields=["title"])
with self.settings(MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS={str(self.team.id).upper()}):
submitted = enqueue_standalone_images(
team=self.team,
user=self.user,
prompt="全身站姿,双腿完整露出",
mode="model",
count=1,
product_id=str(self.product.id),
ratio="3:4",
)
used_prompt = prov.image_edit.call_args.kwargs["prompt"]
self.assertIn("商品关键结构优先", used_prompt)
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["rollout_source"], "canary")
@override_settings(
MODEL_TRYON_PROMPT_V2_ENABLED=False,
MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS={"not-a-team-uuid"},
)
def test_model_tryon_non_canary_team_fails_closed_to_legacy_prompt(self):
prov = self._patch_provider()
self.product.title = "女装裤子"
self.product.save(update_fields=["title"])
submitted = enqueue_standalone_images(
team=self.team,
user=self.user,
prompt="模特上身图",
mode="model",
count=1,
product_id=str(self.product.id),
ratio="3:4",
)
used_prompt = prov.image_edit.call_args.kwargs["prompt"]
self.assertIn("半身近景", used_prompt)
task = AITask.objects.get(id=submitted[0].id)
self.assertNotIn("tryon_prompt", task.request_payload)
@override_settings(MODEL_TRYON_PROMPT_V2_ENABLED=False)
def test_model_tryon_canary_without_product_reference_records_safe_fallback(self):
prov = self._patch_provider()
self.product.cover_asset = None
self.product.save(update_fields=["cover_asset"])
with self.settings(MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS={str(self.team.id)}):
submitted = enqueue_standalone_images(
team=self.team,
user=self.user,
prompt="模特上身图",
mode="model",
count=1,
product_id=str(self.product.id),
ratio="3:4",
)
prov.image_generation.assert_called_once()
task = AITask.objects.get(id=submitted[0].id)
trace = task.request_payload["tryon_prompt"]
self.assertFalse(trace["applied"])
self.assertEqual(trace["rollout_source"], "canary")
self.assertEqual(trace["fallback_reason"], "no_product_reference")
@override_settings(MODEL_TRYON_PROMPT_V2_ENABLED=True)
def test_model_tryon_v22_uses_category_default_ratio_when_user_omits_ratio(self):
prov = self._patch_provider()
self.product.title = "女装裤子"
self.product.save(update_fields=["title"])
submitted = enqueue_standalone_images(
team=self.team,
user=self.user,
prompt="模特上身图",
mode="model",
count=1,
product_id=str(self.product.id),
)
self.assertEqual(prov.image_edit.call_args.kwargs["size"], "1024x1536")
task = AITask.objects.get(id=submitted[0].id)
trace = task.request_payload["tryon_prompt"]
self.assertEqual(trace["requested_ratio"], None)
self.assertEqual(trace["resolved_ratio"], "3:4")
self.assertEqual(trace["ratio_source"], "default")
self.assertEqual(trace["rollout_source"], "global")
@override_settings(MODEL_TRYON_PROMPT_V2_ENABLED=True)
def test_model_tryon_old_queued_task_without_v2_metadata_falls_back_safely(self):
from apps.ai.services import run_standalone_image_task
prov = self._patch_provider()
self.product.title = "女装裤子"
self.product.save(update_fields=["title"])
with patch("apps.ai.tasks.generate_standalone_image_task.delay"):
task = enqueue_standalone_images(
team=self.team,
user=self.user,
prompt="模特上身图",
mode="model",
count=1,
product_id=str(self.product.id),
ratio="3:4",
)[0]
legacy_payload = dict(task.request_payload)
legacy_payload.pop("tryon_batch_count", None)
legacy_payload.pop("tryon_classification", None)
task.request_payload = legacy_payload
task.save(update_fields=["request_payload", "updated_at"])
run_standalone_image_task(task_id=str(task.id))
task.refresh_from_db()
used_prompt = prov.image_edit.call_args.kwargs["prompt"]
self.assertIn("半身近景", used_prompt)
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
trace = task.request_payload["tryon_prompt"]
self.assertFalse(trace["applied"])
self.assertEqual(trace["rollout_source"], "global")
self.assertEqual(trace["fallback_reason"], "unsupported_tryon_batch_count")
def test_cover_mode_falls_back_to_t2i_without_main_image(self):
prov = self._patch_provider()
self.product.cover_asset = None
@@ -443,6 +649,66 @@ class StandaloneImageReferenceTests(TestCase):
# 任务最终落在支持 image_edit 的模型(gpt-image),而不是用户选的火山 Seedream
self.assertIn("gpt-image", tasks[0].model_config.name)
def test_model_tryon_keeps_each_user_selected_model_even_with_extra_refs(self):
"""模特上身图由专用 Worker 向两类模型传商品/人物参考图,不能沿用自由创作的自动换模逻辑。"""
vp, _ = ModelProvider.objects.get_or_create(
name="volcengine",
defaults={"display_name": "火山"},
)
seedream = ModelConfig.objects.create(
provider=vp,
name="seedream-v22-user-choice",
display_name="Seedream V2.2 user choice",
capability=ModelConfig.Capability.IMAGE,
)
gpt = ModelConfig.objects.filter(
capability=ModelConfig.Capability.IMAGE,
name__icontains="gpt-image",
).first()
self.assertIsNotNone(gpt)
extra_ref = Asset.objects.create(
team=self.team,
created_by=self.user,
name="额外参考",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.UPLOAD,
)
AssetFile.objects.create(
asset=extra_ref,
object_key="extra.png",
bucket="b",
content_type="image/png",
preview_url="http://x/extra.png",
is_primary=True,
)
seedream_task = enqueue_standalone_images(
team=self.team,
user=self.user,
prompt="模特上身图",
mode="model",
count=1,
product_id=str(self.product.id),
image_model=f"{vp.name}:{seedream.name}",
reference_image_ids=[str(extra_ref.id)],
dispatch=False,
)[0]
gpt_task = enqueue_standalone_images(
team=self.team,
user=self.user,
prompt="模特上身图",
mode="model",
count=1,
product_id=str(self.product.id),
image_model=f"{gpt.provider.name}:{gpt.name}",
reference_image_ids=[str(extra_ref.id)],
dispatch=False,
)[0]
self.assertEqual(seedream_task.model_config_id, seedream.id)
self.assertEqual(gpt_task.model_config_id, gpt.id)
class ImageConversationTests(TestCase):
"""图片创作「对话」实体:CRUD + 团队隔离 + 软删 + 生图自动归属对话 + 任务回填。"""
@@ -766,6 +1032,37 @@ class StandaloneCategoryTests(TestCase):
self.assertEqual(a.category, Asset.Category.MODEL_PORTRAIT)
self.assertFalse(a.in_library)
@override_settings(MODEL_TRYON_PROMPT_V2_ENABLED=True)
def test_model_tryon_v2_provider_failure_releases_reserved_credit(self):
from apps.ai.services import run_standalone_image_task
self.product.title = "女装裤子"
self.product.save(update_fields=["title"])
prov = self._patch_provider()
prov.image_edit.side_effect = ValueError("生成失败")
with patch("apps.ai.tasks.generate_standalone_image_task.delay"):
task = enqueue_standalone_images(
team=self.team,
user=self.user,
prompt="全身站姿,双腿完整露出",
mode="model",
count=1,
product_id=str(self.product.id),
ratio="3:4",
)[0]
run_standalone_image_task(task_id=str(task.id))
task.refresh_from_db()
account = CreditAccount.objects.get(team=self.team)
self.assertEqual(task.status, AITask.Status.FAILED)
self.assertEqual(float(task.actual_cost), 0.0)
self.assertEqual(float(account.balance), 100.0)
self.assertEqual(float(account.reserved_balance), 0.0)
self.assertTrue(task.request_payload["tryon_prompt"]["applied"])
self.assertTrue(
CreditLedger.objects.filter(task=task, ledger_type=CreditLedger.Type.RELEASE).exists()
)
class TriviewModelDecouplingTests(TestCase):
"""项目角色三视图只归项目,不自动创建或更新团队模特。"""