feat: 优化模特上身图提示词
This commit is contained in:
@@ -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):
|
||||
"""项目角色三视图只归项目,不自动创建或更新团队模特。"""
|
||||
|
||||
Reference in New Issue
Block a user