模特上身图提示词重构 + 图片创作页 UI 调整
后端(模特上身图提示词): - build_model_tryon_prompt_refs 重写:穿戴/非穿戴分流(穿戴=真实穿身替换原衣, 非穿戴=手持/佩戴/使用不动原衣)、每张按 index 变化动作/场景/镜头、负面词尾接、 多图参考序号自适应(参考图1~N=商品,参考图N+1=模特) - 新增 _product_reference_urls:商品参考图真实上传图优先、排除 AI 生成图、可多张(≤3), 无真实图回落 cover - worker run_standalone_image_task 模特分支改用多图取图 + 传 index/n_product 前端(图片创作/工作室): - 生成数量改 1/2/4;图片比例新增「手动输入」(宽:高 两输入框) - 临时隐藏「商品库」按钮 - 模特卡:去掉 // 真人模特,标题改图片底部白字遮罩层 + 单行省略 - 工作室壳负边距对齐 .content padding,修复上下被遮挡/裁切 其他:并入此前未提交的商品页改动、脚本 Agent/格式实测文档与 demo
This commit is contained in:
@@ -752,6 +752,35 @@ def _product_cover_url(product) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _product_reference_urls(product, limit: int = 3) -> list[str]:
|
||||
"""模特上身图的商品参考图(可多张):**真实上传图优先,排除 AI 生成图**——避免拿生成图当真相
|
||||
再喂回模型造成误差累积。按主图/排序取前 limit 张真实上传图;一张都没有时回落 cover(即便是 AI 图,
|
||||
至少保证能走 image_edit 而不是纯文生图)。"""
|
||||
if product is None:
|
||||
return []
|
||||
from apps.assets.models import Asset
|
||||
|
||||
urls: list[str] = []
|
||||
seen: set[str] = set()
|
||||
rels = list(product.images.select_related("asset").all())
|
||||
rels.sort(key=lambda im: (not im.is_primary, im.sort_order))
|
||||
for im in rels:
|
||||
a = im.asset
|
||||
if a is None or getattr(a, "source", "") == Asset.Source.AI_GENERATED:
|
||||
continue
|
||||
u = _asset_preview_url(a)
|
||||
if u and u not in seen:
|
||||
seen.add(u)
|
||||
urls.append(u)
|
||||
if len(urls) >= limit:
|
||||
return urls
|
||||
if not urls: # 无任何真实上传图 → 回落 cover(可能是 AI 图,但好过纯文生图)
|
||||
cover = _product_cover_url(product)
|
||||
if cover:
|
||||
urls.append(cover)
|
||||
return urls
|
||||
|
||||
|
||||
def quality_words(stage: str, slot: str = "quality") -> list[str]:
|
||||
"""平台单层质量词配置(QualityWord)。无配置/表不存在 → 返回 [],调用方回落写死值,保证零回归。"""
|
||||
try:
|
||||
@@ -815,26 +844,69 @@ def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str:
|
||||
return render_prompt("product_triview", default, 商品=name, 补充=(base_prompt or "").strip())
|
||||
|
||||
|
||||
def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str:
|
||||
# 模特上身图:按品类分「穿戴 / 非穿戴」注入不同的商品-模特关系;每张按序号变化动作/场景/镜头。
|
||||
_TRYON_WEARABLE_HINTS = ("服", "衣", "裤", "裙", "鞋", "帽", "袜", "围巾", "外套", "卫衣", "内衣", "文胸", "胸罩", "泳", "bra")
|
||||
_TRYON_VARIATIONS = [
|
||||
{"action": "模特正面自然展示该商品", "scene": "干净的室内空间", "shot": "半身近景,商品清晰可见"},
|
||||
{"action": "模特正在穿着 / 使用该商品", "scene": "生活化的真实居家场景", "shot": "侧面角度,突出穿着或使用方式"},
|
||||
{"action": "模特手持或局部展示商品细节", "scene": "明亮的时尚生活场景", "shot": "中近景,商品占比较高"},
|
||||
{"action": "模特与商品自然互动", "scene": "温暖时尚的生活场景", "shot": "半身,强调使用情境"},
|
||||
]
|
||||
_TRYON_NEGATIVE = (
|
||||
"不要换人,不要改商品设计,不要改商品颜色与结构,不要生成错误或乱码的 Logo 与文字,"
|
||||
"不要把商品改成相似款,不要多余的商品堆叠,不要多余文字,不要水印,不要边框,"
|
||||
"不要低清模糊,不要过度磨皮,不要畸变,不要扭曲身体,不要夸张滤镜"
|
||||
)
|
||||
|
||||
|
||||
def _is_wearable_product(product) -> bool:
|
||||
"""据类目/标题判断是否「穿戴类」(服饰鞋帽内衣) → 真实穿到身上;否则非穿戴 → 手持/佩戴/使用。"""
|
||||
blob = f"{getattr(product, 'category', '') or ''} {getattr(product, 'title', '') or ''}".lower()
|
||||
return any(h in blob for h in _TRYON_WEARABLE_HINTS)
|
||||
|
||||
|
||||
def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "", index: int = 0, n_product: int = 1) -> str:
|
||||
"""模特上身图 image_edit 提示词(refs 版):
|
||||
参考图1=商品真实主图(锁商品外形/品牌/配色),参考图2=选中模特(锁人脸/身形/气质)。
|
||||
生成「该模特自然展示/使用该商品」的电商效果图。"""
|
||||
参考图1~N=商品真实图(多角度,锁外形/品牌/配色),参考图N+1=选中模特(锁人脸/身形/气质)。
|
||||
按品类分穿戴/非穿戴(穿戴=真实穿身上、替换原衣;非穿戴=手持/佩戴/使用,不动原衣);
|
||||
`index` 让每张图动作/场景/镜头不同;`n_product` 让参考图序号自适应。"""
|
||||
name = (getattr(product, "title", "") or "商品").strip()
|
||||
lines = [f"参考图1是「{name}」的真实商品。"]
|
||||
if has_model:
|
||||
lines += [
|
||||
"参考图2是出镜模特。请生成参考图2中的这位模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
|
||||
"模特的五官、发型、肤色、身形与气质必须与参考图2高度一致,不要换人;",
|
||||
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
|
||||
]
|
||||
n_product = max(1, int(n_product or 1))
|
||||
# 参考图序号自适应:N 张商品图 → 参考图1~N=商品, 参考图N+1=模特
|
||||
if n_product <= 1:
|
||||
intro = f"参考图1是「{name}」的真实商品图,是该商品外观的唯一依据。"
|
||||
prod_ref = "参考图1"
|
||||
model_idx = 2
|
||||
else:
|
||||
lines += [
|
||||
"请生成一位真人模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
|
||||
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
|
||||
]
|
||||
rng = f"1-{n_product}" if n_product > 2 else "1、2"
|
||||
intro = f"参考图{rng}是「{name}」同一件真实商品的不同角度图,是该商品外观的唯一依据,请综合这些角度还原商品。"
|
||||
prod_ref = f"参考图{rng}"
|
||||
model_idx = n_product + 1
|
||||
|
||||
if _is_wearable_product(product):
|
||||
relation = (
|
||||
f"真实穿着{prod_ref}中的这件商品,替换掉模特原本的衣服,让商品自然合身地穿在身上,"
|
||||
"而不是放在一旁展示,保持商品的版型、领口、袖型、长度、纹样不变"
|
||||
)
|
||||
else:
|
||||
relation = (
|
||||
f"自然地手持 / 在合适位置佩戴 / 正在使用{prod_ref}中的这件商品(如为耳机则佩戴在耳朵上),"
|
||||
"不要改动模特原本的服装,不要把商品强行穿到身上"
|
||||
)
|
||||
|
||||
var = _TRYON_VARIATIONS[index % len(_TRYON_VARIATIONS)]
|
||||
lines = [intro]
|
||||
if has_model:
|
||||
lines.append(f"参考图{model_idx}是出镜模特。请生成参考图{model_idx}中这位模特{relation}的电商详情页效果图。")
|
||||
lines.append(f"模特的五官、发型、肤色、身形、年龄与气质必须与参考图{model_idx}(模特图)高度一致,不要换人,不要自行生成另一位模特。")
|
||||
else:
|
||||
lines.append(f"请生成一位真人模特{relation}的电商详情页效果图。")
|
||||
lines.append(f"商品的外形、配色、材质、品牌文字与 Logo、图案必须与{prod_ref}(商品图)严格一致,不要重新设计、不要改样、不要生成相似款。")
|
||||
lines.append(f"本张画面:{var['action']};场景:{var['scene']};镜头:{var['shot']}。")
|
||||
lines.append(quality_suffix("model_tryon", "自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。"))
|
||||
if base_prompt and base_prompt.strip():
|
||||
lines.append(base_prompt.strip())
|
||||
lines.append("请规避:" + _TRYON_NEGATIVE)
|
||||
return " ".join(lines)
|
||||
|
||||
|
||||
@@ -1985,13 +2057,18 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
if model_asset is not None:
|
||||
model_url = _asset_preview_url(model_asset)
|
||||
product_url = _product_cover_url(product) if product is not None else ""
|
||||
# 模特上身图:真实上传图优先、排除 AI 生成图,可多张(多角度更易锁外形/品牌)
|
||||
product_urls = _product_reference_urls(product, limit=3) if product is not None else []
|
||||
|
||||
edit_images: list[str] = []
|
||||
edit_prompt = ""
|
||||
if mode == "model" and can_edit and product_url:
|
||||
# 模特上身图:商品图必有,模特图可缺(缺则让模型自取真人模特)
|
||||
edit_images = [product_url] + ([model_url] if model_url else [])
|
||||
edit_prompt = build_model_tryon_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt)
|
||||
if mode == "model" and can_edit and product_urls:
|
||||
# 模特上身图:参考图1~N=商品真实图(多角度),参考图N+1=模特(模特图可缺则让模型自取真人模特)
|
||||
edit_images = product_urls + ([model_url] if model_url else [])
|
||||
edit_prompt = build_model_tryon_prompt_refs(
|
||||
product, has_model=bool(model_url), base_prompt=prompt,
|
||||
index=index, n_product=len(product_urls),
|
||||
)
|
||||
elif mode == "cover" and can_edit and product_url:
|
||||
# 平台套图:参考图1=商品真实主图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形)
|
||||
edit_images = [product_url] + ([model_url] if model_url else [])
|
||||
|
||||
@@ -57,17 +57,24 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
product = self.get_object()
|
||||
team = product.team
|
||||
assets = list(
|
||||
# 真因不是 OR,而是 select_related("origin_task__project") 把每个资产关联的 AITask 整行拉过来,
|
||||
# 含 request_payload / response_payload 两个巨型 JSON 列(实测 114 行就要 ~66s 纯传输)。
|
||||
# 视图与序列化器只用到 origin_task.project(取项目名/id),从不读 payload → .defer() 掉这两列。
|
||||
base = (
|
||||
Asset.objects.filter(team=team, is_deleted=False)
|
||||
.filter(
|
||||
Q(metadata__product_id=str(product.id))
|
||||
| Q(origin_task__project__product_id=product.id)
|
||||
| Q(product_images__product_id=product.id)
|
||||
)
|
||||
.select_related("origin_task__project")
|
||||
.defer("origin_task__request_payload", "origin_task__response_payload")
|
||||
.prefetch_related("files")
|
||||
.distinct()
|
||||
)
|
||||
by_id = {}
|
||||
for qs in (
|
||||
base.filter(metadata__product_id=str(product.id)),
|
||||
base.filter(origin_task__project__product_id=product.id),
|
||||
base.filter(product_images__product_id=product.id),
|
||||
):
|
||||
for a in qs:
|
||||
by_id[a.id] = a
|
||||
assets = sorted(by_id.values(), key=lambda a: (a.created_at is not None, a.created_at), reverse=True)
|
||||
|
||||
def ser(a):
|
||||
return AssetSerializer(a).data
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env python
|
||||
"""出格式能力探针 · 三家模型 × 三种策略 → 真·normalize_draft 验收。
|
||||
|
||||
测什么:在"按现在的代码"的前提下,豆包 / GPT-5.x / Gemini 各自能不能稳定吐出一份
|
||||
符合 ScriptDraft 契约的结构,以及三种约束策略各自的成功率与"原始输出干净度"。
|
||||
|
||||
三家(都走 OpenAI 兼容 chat/completions,凭证读 settings.PROVIDER_KEYS / PROVIDER_BASE_URLS):
|
||||
- 豆包 doubao → provider "volcengine" 直连
|
||||
- GPT gpt → provider "tokenssr" 中转
|
||||
- Gemini gemini → provider "tokenssr" 中转(一把 key 通吃,见 settings.base:198)
|
||||
|
||||
三种策略:
|
||||
A. freeform —— 现状:skill 提示词 + 自由文本,完全靠后端 normalize_draft 兜底抽取
|
||||
B. structured —— response_format=json_schema(strict),解码期约束输出为契约 JSON
|
||||
C. tool —— function-calling,tool_choice 强制调用 emit_script_draft(参数=契约)
|
||||
|
||||
每发产出都做两层判定:
|
||||
raw_clean : 原始输出本身就是合法且含 segments 的 JSON(= 约束真的生效,没靠 normalize 抢救)
|
||||
normalized : 喂进真·apps.ai.script_agent.normalize_draft 后能产出非空分镜(= 最终下游能用)
|
||||
|
||||
用法:
|
||||
cd core/backend && python probe_format_support.py
|
||||
# 改模型名(默认值见 DEFAULT_MODELS):
|
||||
PROBE_GPT_MODEL=gpt-5.5 PROBE_GEMINI_MODEL=gemini-2.5-flash python probe_format_support.py
|
||||
# 只测某几家 / 某几策略:
|
||||
python probe_format_support.py --only doubao,gpt --strategies freeform,tool
|
||||
|
||||
不写库、不计费、不依赖 DB:只调 normalize_draft / load_ecommerce_skill(纯函数 + 读文件)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import django
|
||||
import requests
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
|
||||
django.setup()
|
||||
|
||||
from django.conf import settings # noqa: E402
|
||||
|
||||
from types import SimpleNamespace # noqa: E402
|
||||
|
||||
from apps.ai.script_agent import ( # noqa: E402
|
||||
_SEGMENT_ARRAY_KEYS,
|
||||
_extract_json,
|
||||
_resolve_segments,
|
||||
build_agent_messages,
|
||||
normalize_draft,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 目标模型:label → (provider.name, 默认模型名, env 覆盖键)
|
||||
# --------------------------------------------------------------------------- #
|
||||
DEFAULT_MODELS = {
|
||||
"doubao": ("volcengine", "doubao-seed-2-0-pro-260215", "PROBE_DOUBAO_MODEL"),
|
||||
"gpt": ("tokenssr", "gpt-5.5", "PROBE_GPT_MODEL"),
|
||||
"gemini": ("tokenssr", "gemini-3.1-pro-preview", "PROBE_GEMINI_MODEL"),
|
||||
}
|
||||
|
||||
ASPECT_RATIO = "9:16"
|
||||
TOTAL_DURATION = 60 # → 4 镜
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 模拟数据:一款保温杯,塑成 Django 模型同形对象,喂进真·build_agent_messages
|
||||
# (复现"全自动①"真实场景:系统提示词=skill+_OUTPUT_PROTOCOL,user=真 _product_context)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _FakeManager:
|
||||
"""仿 product.selling_points:支持 .all() / .filter(id__in=...),够 _product_context 用。"""
|
||||
|
||||
def __init__(self, items):
|
||||
self._items = list(items)
|
||||
|
||||
def all(self):
|
||||
return self._items
|
||||
|
||||
def filter(self, id__in=None, **_):
|
||||
if id__in is None:
|
||||
return self._items
|
||||
return [s for s in self._items if s.id in set(id__in)]
|
||||
|
||||
|
||||
def _make_fake_project():
|
||||
selling = [
|
||||
SimpleNamespace(id="sp1", title="长效保温", detail="晚上灌开水,第二天早上还温热"),
|
||||
SimpleNamespace(id="sp2", title="单手弹盖", detail="开会/带娃腾不出第二只手也能喝"),
|
||||
SimpleNamespace(id="sp3", title="防漏密封", detail="塞进通勤包横放也不洒"),
|
||||
]
|
||||
product = SimpleNamespace(
|
||||
title="暖岚 316 不锈钢保温杯 500ml",
|
||||
brand="暖岚",
|
||||
category="水具/保温杯",
|
||||
target_audience="久坐办公室的年轻白领、熬夜党",
|
||||
description="24 小时长效保温,一键弹盖单手开,食品级 316 内胆,防漏防烫。",
|
||||
selling_points=_FakeManager(selling),
|
||||
)
|
||||
return SimpleNamespace(product=product, metadata={})
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ScriptDraft JSON Schema(契约的可机读版本,供 structured / tool 两策略约束解码)
|
||||
# 与 script_agent.normalize_draft 期望的字段对齐;故意保留 strict 友好形态。
|
||||
# --------------------------------------------------------------------------- #
|
||||
SEGMENT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"index": {"type": "integer"},
|
||||
"duration": {"type": "integer"},
|
||||
"role": {"type": "string", "enum": ["钩子", "痛点", "卖点", "CTA"]},
|
||||
"narration": {"type": "string", "description": "这一镜口播/旁白,≤55字"},
|
||||
"visual": {"type": "string", "description": "画面:主体+动作+景别/运镜+变化,40-70字"},
|
||||
"product_exposure": {"type": "string"},
|
||||
"entity_refs": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["index", "duration", "role", "narration", "visual", "product_exposure", "entity_refs"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
ENTITY_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["character", "scene", "product"]},
|
||||
"name": {"type": "string"},
|
||||
"visual_prompt": {"type": "string"},
|
||||
"ref_index": {"type": "integer"},
|
||||
},
|
||||
"required": ["id", "type", "name", "visual_prompt", "ref_index"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
DRAFT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hook": {"type": "string"},
|
||||
"tone": {"type": "string", "enum": ["种草", "测评", "剧情", "痛点"]},
|
||||
"aspect_ratio": {"type": "string"},
|
||||
"total_duration": {"type": "integer", "description": "总时长,取 15/30/60/90 之一"},
|
||||
"segment_count": {"type": "integer"},
|
||||
"entities": {"type": "array", "items": ENTITY_SCHEMA},
|
||||
"segments": {"type": "array", "items": SEGMENT_SCHEMA},
|
||||
},
|
||||
"required": ["hook", "tone", "aspect_ratio", "total_duration", "segment_count", "entities", "segments"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
TOOL_DEF = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "emit_script_draft",
|
||||
"description": "提交一份符合 AirShelf 契约的带货短视频脚本草稿",
|
||||
"parameters": DRAFT_SCHEMA,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _creds(provider_name: str) -> tuple[str | None, str | None]:
|
||||
base = settings.PROVIDER_BASE_URLS.get(provider_name)
|
||||
key = settings.PROVIDER_KEYS.get(provider_name)
|
||||
return (base or None), (key or None)
|
||||
|
||||
|
||||
def _messages() -> list[dict]:
|
||||
# 走真·build_agent_messages(全自动①):与线上发给模型的 system+user 一字不差
|
||||
# (system = skill + _OUTPUT_PROTOCOL;user = 真 _product_context 拼的商品上下文)
|
||||
return build_agent_messages(
|
||||
project=_make_fake_project(),
|
||||
mode="auto",
|
||||
user_prompt="",
|
||||
selling_point_ids=None,
|
||||
base_draft=None,
|
||||
aspect_ratio=ASPECT_RATIO,
|
||||
total_duration=TOTAL_DURATION,
|
||||
)
|
||||
|
||||
|
||||
def _post(base_url: str, api_key: str, body: dict) -> dict:
|
||||
resp = requests.post(
|
||||
f"{base_url.rstrip('/')}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json=body,
|
||||
timeout=240,
|
||||
)
|
||||
if not resp.ok:
|
||||
# 抽真实报错(不支持 response_format / tool 等会在这里暴露)
|
||||
detail = ""
|
||||
try:
|
||||
detail = json.dumps(resp.json().get("error") or resp.json(), ensure_ascii=False)[:300]
|
||||
except Exception:
|
||||
detail = (resp.text or "")[:300]
|
||||
raise RuntimeError(f"HTTP {resp.status_code}: {detail}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _build_body(model: str, strategy: str) -> dict:
|
||||
body: dict = {"model": model, "messages": _messages(), "temperature": 0.85, "stream": False}
|
||||
if strategy == "structured":
|
||||
body["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "script_draft", "strict": True, "schema": DRAFT_SCHEMA},
|
||||
}
|
||||
elif strategy == "tool":
|
||||
body["tools"] = [TOOL_DEF]
|
||||
body["tool_choice"] = {"type": "function", "function": {"name": "emit_script_draft"}}
|
||||
return body
|
||||
|
||||
|
||||
def _extract_output(data: dict, strategy: str) -> str:
|
||||
"""取这一发的"待判定文本":tool 取 arguments,其余取 content。"""
|
||||
choice = (data.get("choices") or [{}])[0]
|
||||
msg = choice.get("message") or {}
|
||||
if strategy == "tool":
|
||||
calls = msg.get("tool_calls") or []
|
||||
if not calls:
|
||||
return "" # 没吐 tool_call = 约束没生效
|
||||
return calls[0].get("function", {}).get("arguments") or ""
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list): # 个别网关把 content 拆成块
|
||||
return "".join(c.get("text", "") for c in content if isinstance(c, dict))
|
||||
return content or ""
|
||||
|
||||
|
||||
def _raw_is_clean(text: str) -> bool:
|
||||
"""原始输出本身就是合法且含 segments 的 JSON(= 约束真生效,无需 normalize 抢救)。"""
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return isinstance(obj, dict) and isinstance(obj.get("segments"), list) and len(obj["segments"]) > 0
|
||||
|
||||
|
||||
def _raw_structure(text: str) -> dict:
|
||||
"""用生产同款 _extract_json/_resolve_segments 解析模型**原始**输出,提取它真实用的
|
||||
segments 数组键名 + 每镜键集 + 每实体键集 + 顶层键集。用于跨模型逐键比对(normalize 之前)。"""
|
||||
blob = _extract_json(text)
|
||||
if not blob:
|
||||
return {"parse": False}
|
||||
try:
|
||||
obj = json.loads(blob)
|
||||
except (ValueError, TypeError):
|
||||
return {"parse": False}
|
||||
if not isinstance(obj, dict):
|
||||
return {"parse": False}
|
||||
seg_array_key = next(
|
||||
(k for k in _SEGMENT_ARRAY_KEYS if isinstance(obj.get(k), list) and obj.get(k)), None
|
||||
)
|
||||
segs = _resolve_segments(obj)
|
||||
seg_keys = sorted({k for s in segs if isinstance(s, dict) for k in s})
|
||||
ents = obj.get("entities") if isinstance(obj.get("entities"), list) else []
|
||||
ent_keys = sorted({k for e in ents if isinstance(e, dict) for k in e})
|
||||
return {
|
||||
"parse": True,
|
||||
"seg_array_key": seg_array_key,
|
||||
"top_keys": sorted(obj.keys()),
|
||||
"seg_keys": seg_keys,
|
||||
"ent_keys": ent_keys,
|
||||
"n_seg": len(segs),
|
||||
}
|
||||
|
||||
|
||||
def _seg_quality(draft: dict) -> tuple[int, int]:
|
||||
"""(总镜数, 旁白+画面都非空的镜数)——衡量 normalize 后是否真有内容,不是空骨架。"""
|
||||
segs = draft.get("segments") or []
|
||||
full = sum(1 for s in segs if (s.get("narration") or "").strip() and (s.get("visual") or "").strip())
|
||||
return len(segs), full
|
||||
|
||||
|
||||
def run_cell(label: str, model: str, base_url: str, api_key: str, strategy: str) -> dict:
|
||||
t0 = time.time()
|
||||
cell = {"label": label, "model": model, "strategy": strategy, "ok": False, "raw_clean": False,
|
||||
"segs": 0, "full": 0, "secs": 0.0, "note": "", "raw_preview": "", "draft": None,
|
||||
"raw_struct": {"parse": False}}
|
||||
try:
|
||||
data = _post(base_url, api_key, _build_body(model, strategy))
|
||||
text = _extract_output(data, strategy)
|
||||
if not text.strip():
|
||||
cell["note"] = "空输出(tool 未触发 / content 为空)"
|
||||
return cell
|
||||
cell["raw_preview"] = text.strip()[:600] # 原始输出头部(看模型有没有裹散文/围栏)
|
||||
cell["raw_struct"] = _raw_structure(text) # 原始 JSON 结构签名(segments/entity 键集),供逐键 diff
|
||||
cell["raw_clean"] = _raw_is_clean(text)
|
||||
# 真·生产 normalizer 验收
|
||||
draft = normalize_draft(text, aspect_ratio=ASPECT_RATIO, total_duration=TOTAL_DURATION)
|
||||
cell["draft"] = draft # 存下规范化后的成稿,供 dump 渲染
|
||||
segs, full = _seg_quality(draft)
|
||||
cell["segs"], cell["full"] = segs, full
|
||||
cell["ok"] = segs == (TOTAL_DURATION // 15) and full == segs # 镜数对 + 每镜都有词有画面
|
||||
if not cell["ok"]:
|
||||
cell["note"] = f"normalize 后 {full}/{segs} 镜有完整内容(期望 {TOTAL_DURATION // 15} 镜全满)"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
cell["note"] = str(exc)[:200]
|
||||
finally:
|
||||
cell["secs"] = round(time.time() - t0, 1)
|
||||
return cell
|
||||
|
||||
|
||||
def _render_draft_md(draft: dict) -> str:
|
||||
"""把规范化后的 ScriptDraft 渲染成可读 markdown(hook/tone/实体/逐镜旁白+画面)。"""
|
||||
if not draft:
|
||||
return "_(无成稿)_"
|
||||
lines = [
|
||||
f"- **hook**:{draft.get('hook', '')}",
|
||||
f"- **tone**:{draft.get('tone', '')} · **时长**:{draft.get('total_duration')}s · **画幅**:{draft.get('aspect_ratio')}",
|
||||
]
|
||||
ents = draft.get("entities") or []
|
||||
if ents:
|
||||
ent_str = "、".join(f"{e.get('name')}({e.get('type')})" for e in ents)
|
||||
lines.append(f"- **实体**({len(ents)}):{ent_str}")
|
||||
lines.append("")
|
||||
lines.append("| 镜 | role | narration(口播) | visual(画面) |")
|
||||
lines.append("| -- | ---- | ------------- | ----------- |")
|
||||
for s in draft.get("segments") or []:
|
||||
nar = (s.get("narration") or "").replace("|", "\\|").replace("\n", " ")
|
||||
vis = (s.get("visual") or "").replace("|", "\\|").replace("\n", " ")
|
||||
lines.append(f"| {s.get('index')} | {s.get('role', '')} | {nar} | {vis} |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_dump(path: str, results: list[dict], strategies: list[str]) -> None:
|
||||
import datetime
|
||||
|
||||
out = [
|
||||
"# 出格式能力探针 · 模型实际产出汇总",
|
||||
"",
|
||||
f"> 生成时间:{datetime.datetime.now():%Y-%m-%d %H:%M} · 模拟商品:暖岚 316 保温杯 · 期望 {TOTAL_DURATION // 15} 镜 / 画幅 {ASPECT_RATIO}",
|
||||
"",
|
||||
"判定:`✅`=normalize 后镜数对且每镜有词有画面 `raw_clean`=模型原始输出本身就是合规 JSON(约束真生效,未靠后端抢救)",
|
||||
"",
|
||||
"## 速览矩阵",
|
||||
"",
|
||||
"| 模型 | " + " | ".join(strategies) + " |",
|
||||
"| ---- | " + " | ".join(["----"] * len(strategies)) + " |",
|
||||
]
|
||||
labels = []
|
||||
for r in results:
|
||||
if r["label"] not in labels:
|
||||
labels.append(r["label"])
|
||||
for label in labels:
|
||||
cells = []
|
||||
for s in strategies:
|
||||
c = next((r for r in results if r["label"] == label and r["strategy"] == s), None)
|
||||
if not c:
|
||||
cells.append("-"); continue
|
||||
mark = ("✅" if c["ok"] else "❌") + ("·raw✓" if c["raw_clean"] else "·raw✗") + f" {c['secs']}s"
|
||||
cells.append(mark)
|
||||
model = next((r["model"] for r in results if r["label"] == label), "")
|
||||
out.append(f"| **{label}**<br>`{model}` | " + " | ".join(cells) + " |")
|
||||
out.append("")
|
||||
|
||||
# 原始结构一致性(normalize 之前):逐策略比三家模型原始 JSON 的键集是否同构
|
||||
out.append("## 原始结构一致性(normalize 之前 · 逐键 diff)\n")
|
||||
out.append("> 看的是模型**原始吐出**的 JSON 用什么键,不是 normalize 抹平后的。`seg_array_key`=模型装分镜用的数组键名;"
|
||||
"`seg_keys`=每镜的键集;`ent_keys`=每实体的键集。三家在同一策略下若键集相同即「同构」。\n")
|
||||
for s in strategies:
|
||||
out.append(f"### 策略:{s}\n")
|
||||
out.append("| 模型 | parse | seg_array_key | segments 每镜键集 | entities 每实体键集 |")
|
||||
out.append("| ---- | ----- | ------------- | ---------------- | ------------------ |")
|
||||
sig_segs, sig_ents = [], []
|
||||
for label in labels:
|
||||
c = next((r for r in results if r["label"] == label and r["strategy"] == s), None)
|
||||
st = (c or {}).get("raw_struct") or {"parse": False}
|
||||
if not st.get("parse"):
|
||||
out.append(f"| {label} | ❌ 解析失败 | - | - | - |")
|
||||
continue
|
||||
sk = ",".join(st["seg_keys"]); ek = ",".join(st["ent_keys"])
|
||||
sig_segs.append(sk); sig_ents.append(ek)
|
||||
out.append(f"| {label} | ✓ | `{st['seg_array_key']}` | `{sk}` | `{ek}` |")
|
||||
same_seg = len(set(sig_segs)) <= 1 and len(sig_segs) == len([l for l in labels])
|
||||
same_ent = len(set(sig_ents)) <= 1 and len(sig_ents) == len([l for l in labels])
|
||||
verdict = []
|
||||
verdict.append("segments 键集" + ("**完全同构**✅" if same_seg else "**有差异**⚠️"))
|
||||
verdict.append("entities 键集" + ("**完全同构**✅" if same_ent else "**有差异**⚠️"))
|
||||
out.append(f"\n→ {' · '.join(verdict)}\n")
|
||||
out.append("")
|
||||
|
||||
# 逐发明细
|
||||
for label in labels:
|
||||
model = next((r["model"] for r in results if r["label"] == label), "")
|
||||
out.append(f"\n---\n\n## {label} · `{model}`\n")
|
||||
for s in strategies:
|
||||
c = next((r for r in results if r["label"] == label and r["strategy"] == s), None)
|
||||
if not c:
|
||||
continue
|
||||
flag = "✅" if c["ok"] else "❌"
|
||||
# 模型名写进每个子标题:成稿表格很长,滚到中间时 ## 大标题已滚出屏,子标题须自带身份
|
||||
out.append(f"### {flag} 【{label} · {model}】策略:{s} · {c['secs']}s · raw_clean={'是' if c['raw_clean'] else '否'}")
|
||||
if c["note"]:
|
||||
out.append(f"\n> ⚠️ {c['note']}\n")
|
||||
out.append("\n**规范化成稿:**\n")
|
||||
out.append(_render_draft_md(c["draft"]))
|
||||
if c["raw_preview"]:
|
||||
out.append("\n<details><summary>原始输出预览(前 600 字)</summary>\n")
|
||||
out.append("\n```\n" + c["raw_preview"] + "\n```\n")
|
||||
out.append("</details>")
|
||||
out.append("")
|
||||
Path(path).write_text("\n".join(out), encoding="utf-8")
|
||||
print(f"\n📄 已写出:{path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--only", default="", help="逗号分隔:doubao,gpt,gemini")
|
||||
ap.add_argument("--strategies", default="freeform,structured,tool")
|
||||
ap.add_argument("--dump", default="", help="把每发真实产出写成 markdown 报告到此路径")
|
||||
args = ap.parse_args()
|
||||
|
||||
only = {x.strip() for x in args.only.split(",") if x.strip()} or set(DEFAULT_MODELS)
|
||||
strategies = [s.strip() for s in args.strategies.split(",") if s.strip()]
|
||||
|
||||
print(f"\n出格式能力探针 · 模拟商品=暖岚保温杯 · 期望 {TOTAL_DURATION // 15} 镜 · 画幅 {ASPECT_RATIO}\n")
|
||||
|
||||
targets = []
|
||||
for label, (provider_name, default_model, env_key) in DEFAULT_MODELS.items():
|
||||
if label not in only:
|
||||
continue
|
||||
base_url, api_key = _creds(provider_name)
|
||||
model = os.getenv(env_key, default_model)
|
||||
if not base_url or not api_key:
|
||||
print(f" ⏭ {label:8s} 跳过:provider '{provider_name}' 凭证缺失(检查 .env)")
|
||||
continue
|
||||
targets.append((label, provider_name, model, base_url, api_key))
|
||||
|
||||
if not targets:
|
||||
print("没有可测目标(凭证全缺)。"); return
|
||||
|
||||
results: list[dict] = []
|
||||
for label, provider_name, model, base_url, api_key in targets:
|
||||
print(f"\n▶ {label} ({provider_name} · {model})")
|
||||
for strategy in strategies:
|
||||
cell = run_cell(label, model, base_url, api_key, strategy)
|
||||
results.append(cell)
|
||||
flag = "✅" if cell["ok"] else "❌"
|
||||
raw = "raw_clean✓" if cell["raw_clean"] else "raw_clean✗"
|
||||
line = f" {flag} {strategy:10s} {raw} segs {cell['full']}/{cell['segs']} {cell['secs']}s"
|
||||
if cell["note"]:
|
||||
line += f" · {cell['note']}"
|
||||
print(line)
|
||||
|
||||
# 汇总矩阵
|
||||
print("\n" + "=" * 72)
|
||||
print("汇总(✅=normalize 后镜数对且每镜有词有画面 / raw=原始输出本身就合规)")
|
||||
print("=" * 72)
|
||||
header = f"{'model':24s} " + " ".join(f"{s:>12s}" for s in strategies)
|
||||
print(header)
|
||||
for label, (_, _, _) in DEFAULT_MODELS.items():
|
||||
if label not in only:
|
||||
continue
|
||||
row = next((r for r in results if r["label"] == label), None)
|
||||
if row is None:
|
||||
continue
|
||||
cells = []
|
||||
for s in strategies:
|
||||
c = next((r for r in results if r["label"] == label and r["strategy"] == s), None)
|
||||
if c is None:
|
||||
cells.append(f"{'-':>12s}"); continue
|
||||
mark = ("✅" if c["ok"] else "❌") + ("R" if c["raw_clean"] else " ")
|
||||
cells.append(f"{mark:>12s}")
|
||||
print(f"{label:24s} " + " ".join(cells))
|
||||
print()
|
||||
|
||||
if args.dump:
|
||||
write_dump(args.dump, results, strategies)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python
|
||||
"""故事板「画风锚点」对比 demo。
|
||||
|
||||
目的:直观演示"锁画风"——同一组分镜,各生成两版:
|
||||
A. 无锚点(no_anchor) —— 模拟现状故事板提示词,只说"导演故事板…画面清晰",不规定画风
|
||||
B. 有锚点(with_anchor)—— 同样内容 + 注入一段「统一画风锚点」(写实电商摄影棚风/统一色调光线构图)
|
||||
|
||||
每版各出 4 帧(钩子/痛点/卖点/CTA),存盘后肉眼对比:
|
||||
- no_anchor 组:帧与帧画风易漂(这次写实、那次插画、景别色调各异)
|
||||
- with_anchor 组:4 帧像同一套片子(统一风格)
|
||||
|
||||
走真·图像模型(yunqi/gpt-image-2,纯文生图,隔离出"锚点"这一个变量,不掺参考图)。
|
||||
输出:../docs/storyboard-style-demo/{no_anchor,with_anchor}/frame{N}.png + prompts.md
|
||||
|
||||
用法:cd backend && python storyboard_style_demo.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import django
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
|
||||
django.setup()
|
||||
|
||||
from apps.ai.models import ModelConfig # noqa: E402
|
||||
from apps.ai.providers import VolcanoArkProvider # noqa: E402 — media_to_bytes 复用
|
||||
from apps.ai.services import build_provider, get_default_model # noqa: E402
|
||||
|
||||
OUT = BASE_DIR.parent / "docs" / "storyboard-style-demo"
|
||||
SIZE = "1024x1536" # 9:16 竖屏,与故事板一致
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 模拟数据:保温杯 4 分镜(钩子→痛点→卖点→CTA),每帧一句画面描述
|
||||
# --------------------------------------------------------------------------- #
|
||||
FRAMES = [
|
||||
("钩子", "年轻女白领坐在办公室工位,皱眉摸了摸桌上凉掉的水杯,抬头看镜头一脸无奈"),
|
||||
("痛点", "女白领从通勤包里拿出漏水的旧保温杯,纸巾擦被打湿的笔记本,表情懊恼"),
|
||||
("卖点", "女白领单手按下保温杯一键弹盖,杯口冒出热气,桌面横放杯子滴水不漏"),
|
||||
("CTA", "女白领手持保温杯对镜头微笑展示,画面右下角出现购物车引导点击"),
|
||||
]
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# A. 无锚点:模拟现状故事板提示词(无任何画风约束)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def prompt_no_anchor(role: str, scene: str) -> str:
|
||||
return (
|
||||
f"根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。\n"
|
||||
f"【镜头功能】{role}\n【画面】{scene}\n"
|
||||
f"电商竖屏 9:16 导演故事板,画面清晰。"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# B. 有锚点:同内容 + 一段「统一画风锚点」(每帧逐字相同,把风格焊死)
|
||||
# --------------------------------------------------------------------------- #
|
||||
STYLE_ANCHOR = (
|
||||
"【统一画风 · 所有分镜必须严格一致,不可逐帧漂移】\n"
|
||||
"· 风格:写实电商摄影棚实拍质感(photorealistic),禁止插画/线稿/漫画/3D 卡通;\n"
|
||||
"· 布光:统一柔和影棚顺光,同一色温(暖白);\n"
|
||||
"· 色彩:统一明亮干净的电商色彩分级,低饱和高级灰背景;\n"
|
||||
"· 景别构图:统一中近景、人物居中、相同画面留白比例与镜头高度;\n"
|
||||
"· 人物:全片同一位年轻女白领(同一张脸、同一发型妆容着装);\n"
|
||||
"· 商品:全片同一只白色保温杯(同一外形/配色/logo)。"
|
||||
)
|
||||
|
||||
|
||||
def prompt_with_anchor(role: str, scene: str) -> str:
|
||||
return (
|
||||
f"根据以下画面生成一个导演故事板分镜图,用于指导短视频生成。\n"
|
||||
f"【镜头功能】{role}\n【画面】{scene}\n"
|
||||
f"{STYLE_ANCHOR}\n"
|
||||
f"电商竖屏 9:16 导演故事板,画面清晰。"
|
||||
)
|
||||
|
||||
|
||||
def gen_one(provider, model, prompt: str, save_path: Path) -> str:
|
||||
resp = provider.image_generation(model=model.name, endpoint=model.endpoint, prompt=prompt, size=SIZE)
|
||||
media = provider.extract_first_media_url(resp)
|
||||
fileobj, _ct = VolcanoArkProvider.media_to_bytes(media)
|
||||
save_path.write_bytes(fileobj.getvalue())
|
||||
return str(save_path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
model = get_default_model(ModelConfig.Capability.IMAGE)
|
||||
provider = build_provider(model)
|
||||
print(f"图像模型:{model.provider.name}/{model.name} · 尺寸 {SIZE}")
|
||||
(OUT / "no_anchor").mkdir(parents=True, exist_ok=True)
|
||||
(OUT / "with_anchor").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
prompts_md = ["# 故事板画风锚点 demo · 用到的提示词\n",
|
||||
f"> 图像模型:{model.provider.name}/{model.name} · 模拟商品:保温杯 · 4 分镜\n"]
|
||||
|
||||
for variant, builder in (("no_anchor", prompt_no_anchor), ("with_anchor", prompt_with_anchor)):
|
||||
label = "无锚点(现状)" if variant == "no_anchor" else "有锚点(锁画风)"
|
||||
print(f"\n===== {label} =====")
|
||||
prompts_md.append(f"\n## {label}\n")
|
||||
for i, (role, scene) in enumerate(FRAMES, 1):
|
||||
prompt = builder(role, scene)
|
||||
path = OUT / variant / f"frame{i}_{role}.png"
|
||||
t0 = time.time()
|
||||
try:
|
||||
gen_one(provider, model, prompt, path)
|
||||
print(f" ✅ 第{i}镜 {role} {round(time.time()-t0,1)}s → {path.name}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" ❌ 第{i}镜 {role} {str(exc)[:160]}")
|
||||
prompts_md.append(f"\n### 第{i}镜 · {role}\n\n```\n{prompt}\n```\n")
|
||||
|
||||
(OUT / "prompts.md").write_text("\n".join(prompts_md), encoding="utf-8")
|
||||
print(f"\n📁 图片+提示词已存:{OUT}")
|
||||
print(" 对比:no_anchor/ 各帧画风易漂 vs with_anchor/ 4 帧同一套风格")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""模特上身图 · 新提示词效果测试脚本(一次性,可删)。
|
||||
|
||||
设计:**提示词模板固定**(下面 TEMPLATE/变化表/负面词都是常量),
|
||||
**商品参数是变量**(按 PRODUCT_ID 从库里查出来填进模板)。
|
||||
|
||||
跑法:
|
||||
.venv/bin/python tryon_prompt_test.py
|
||||
产物:把生成图写到 OUT_DIR,顺带打印每张实际发出去的提示词。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import django
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
|
||||
django.setup()
|
||||
|
||||
from apps.products.models import Product # noqa: E402
|
||||
from apps.assets.models import Asset # noqa: E402
|
||||
from apps.ai.models import ModelConfig # noqa: E402
|
||||
from apps.ai.services import ( # noqa: E402
|
||||
get_default_model,
|
||||
get_image_provider,
|
||||
_product_cover_url,
|
||||
_asset_preview_url,
|
||||
_ratio_to_image_size,
|
||||
)
|
||||
from apps.ai.providers.volcano import VolcanoArkProvider # noqa: E402
|
||||
|
||||
# ───────────────────────── 可调输入(变量) ─────────────────────────
|
||||
PRODUCT_ID = "045ad8d6-8b15-486b-a4a9-f6968eb1555f" # 南卡 Lite Pro 蓝牙耳机
|
||||
# 商品参考图(可多张):图1=真实上传(干净外形) + 图2=NANK 多角度(带品牌)。两张一起喂,锁外形+锁品牌。
|
||||
PRODUCT_IMAGE_IDS = [
|
||||
"c8c47a2e-6f07-43b2-b776-1be2a401538a", # 真实上传 · 图1
|
||||
"463a826c-2adc-43c8-b776-2eea973f0449", # NANK 多角度(带 Logo)
|
||||
]
|
||||
MODEL_ID = "ad362810-cf3e-49c0-9426-8142a74b75b2" # 一位真人模特
|
||||
RATIO = "3:4"
|
||||
COUNT = 3 # 生成张数(每张动作/场景/镜头不同)
|
||||
OUT_DIR = "/Users/maidong/Desktop/zyc/tryon_prompt_test2"
|
||||
|
||||
# ═════════════════════════ 固定提示词模板 ═════════════════════════
|
||||
# 占位符全部由商品/模特变量填入;模板本身不随商品变。
|
||||
TEMPLATE = (
|
||||
"{product_intro}"
|
||||
"请生成参考图{model_idx}中这位模特{relation}的电商详情页效果图。"
|
||||
"模特要求:五官、发型、肤色、身形、年龄与气质必须与参考图{model_idx}(模特图)高度一致,不要换人,不要自行生成另一位模特。"
|
||||
"商品要求:外形、配色、材质、品牌文字与 Logo、图案必须与{product_ref_label}(商品图)严格一致,不要重新设计、不要改样、不要生成相似款。"
|
||||
"本张画面:{action};场景:{scene};镜头:{shot}。"
|
||||
"画面风格:真实电商摄影,自然光,干净背景,主体突出,商品细节清晰,模特姿态自然,单人,高分辨率,{ratio} 构图。"
|
||||
"(商品类目:{category};卖点:{selling_points}。以上文字仅供理解商品,不要据此凭空改变商品外观。)"
|
||||
" 请规避:{negative}"
|
||||
)
|
||||
|
||||
# 穿戴 / 非穿戴 两套「商品-模特关系」从句(固定)
|
||||
RELATION_WEARABLE = (
|
||||
"真实穿着参考图1中的这件商品,替换掉模特原本的衣服,让商品自然合身地穿在身上,"
|
||||
"而不是把商品放在一旁展示;保持商品的版型、领口、袖型、长度、纹样不变"
|
||||
)
|
||||
RELATION_NON_WEARABLE = (
|
||||
"自然地手持 / 在合适位置佩戴 / 正在使用参考图1中的这件商品(如为耳机则佩戴在耳朵上),"
|
||||
"不要改动模特原本的服装,不要把商品强行穿到身上,商品以真实合理的方式出现在模特手中或身上"
|
||||
)
|
||||
|
||||
# 每张按序号变化(固定的变化表;非穿戴默认)
|
||||
VARIATIONS = [
|
||||
{"action": "模特正面自然展示该商品", "scene": "干净的室内空间", "shot": "半身近景,商品清晰可见"},
|
||||
{"action": "模特正在使用该商品", "scene": "生活化的真实居家场景", "shot": "侧面角度,突出使用方式"},
|
||||
{"action": "模特手持并展示商品细节", "scene": "明亮的时尚生活场景", "shot": "中近景,商品占比较高"},
|
||||
{"action": "模特与商品自然互动", "scene": "温暖时尚的生活场景", "shot": "半身,强调使用情境"},
|
||||
]
|
||||
|
||||
NEGATIVE = (
|
||||
"不要换人,不要改商品设计,不要改商品颜色与结构,不要生成错误或乱码的 Logo 与文字,"
|
||||
"不要把商品改成相似款,不要多余的商品堆叠,不要多余文字,不要水印,不要边框,"
|
||||
"不要低清模糊,不要过度磨皮,不要畸变,不要扭曲身体,不要夸张滤镜"
|
||||
)
|
||||
|
||||
# 穿戴类类目关键词(命中即按穿戴从句)
|
||||
WEARABLE_HINTS = ("服", "衣", "裤", "裙", "鞋", "帽", "袜", "围巾", "外套", "T恤", "卫衣", "内衣", "泳")
|
||||
|
||||
|
||||
def is_wearable(category: str, title: str) -> bool:
|
||||
blob = f"{category} {title}"
|
||||
return any(h in blob for h in WEARABLE_HINTS)
|
||||
|
||||
|
||||
def build_product_intro(name: str, n_product: int) -> tuple[str, int, str]:
|
||||
"""据商品参考图数量自适应序号:N 张商品 → 参考图1~N=商品, 参考图N+1=模特。
|
||||
返回 (intro, 模特图序号, 商品图序号标签)。"""
|
||||
if n_product <= 1:
|
||||
intro = f"参考图1是「{name}」的真实商品图,是该商品外观的唯一依据。参考图2是出镜模特。"
|
||||
return intro, 2, "参考图1"
|
||||
rng = f"1-{n_product}" if n_product > 2 else "1、2"
|
||||
intro = (
|
||||
f"参考图{rng}是「{name}」同一件真实商品的不同角度图,是该商品外观的唯一依据,"
|
||||
f"请综合这些角度还原商品。参考图{n_product + 1}是出镜模特。"
|
||||
)
|
||||
return intro, n_product + 1, f"参考图{rng}"
|
||||
|
||||
|
||||
def build_prompt(product, index: int, ratio: str, n_product: int) -> str:
|
||||
name = (product.title or "商品").strip()
|
||||
category = (product.category or "").strip()
|
||||
points = list(product.selling_points.all().values_list("title", flat=True))
|
||||
selling = "、".join(points) if points else "(无)"
|
||||
relation = RELATION_WEARABLE if is_wearable(category, name) else RELATION_NON_WEARABLE
|
||||
var = VARIATIONS[index % len(VARIATIONS)]
|
||||
product_intro, model_idx, product_ref_label = build_product_intro(name, n_product)
|
||||
return TEMPLATE.format(
|
||||
product_intro=product_intro,
|
||||
model_idx=model_idx,
|
||||
product_ref_label=product_ref_label,
|
||||
relation=relation,
|
||||
action=var["action"],
|
||||
scene=var["scene"],
|
||||
shot=var["shot"],
|
||||
ratio=ratio,
|
||||
category=category or "(未填)",
|
||||
selling_points=selling,
|
||||
negative=NEGATIVE,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
product = Product.objects.get(id=PRODUCT_ID)
|
||||
model_asset = Asset.objects.get(id=MODEL_ID)
|
||||
product_urls = []
|
||||
for aid in PRODUCT_IMAGE_IDS:
|
||||
a = Asset.objects.get(id=aid)
|
||||
u = _asset_preview_url(a)
|
||||
if u:
|
||||
product_urls.append(u)
|
||||
print(f"商品参考图: {a.name} | {a.source} | {u[:80]}")
|
||||
model_url = _asset_preview_url(model_asset)
|
||||
if not product_urls:
|
||||
print("❌ 没有可用的商品参考图。")
|
||||
sys.exit(1)
|
||||
n_product = len(product_urls)
|
||||
print(f"商品: {product.title} | 类目: {product.category} | 穿戴类: {is_wearable(product.category or '', product.title)} | 商品参考图数: {n_product}")
|
||||
print(f"模特: {model_asset.name} | {model_url[:80]}")
|
||||
|
||||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||||
provider = get_image_provider(model_config)
|
||||
print(f"模型: {model_config.provider.name}:{model_config.name}\n")
|
||||
|
||||
images = product_urls + [model_url] # 参考图1~N=商品多角度, 参考图N+1=模特
|
||||
size = _ratio_to_image_size(RATIO)
|
||||
|
||||
for i in range(COUNT):
|
||||
prompt = build_prompt(product, i, RATIO, n_product)
|
||||
print(f"━━━ 第 {i + 1} 张 ━━━\n{prompt}\n")
|
||||
try:
|
||||
resp = provider.image_edit(model=model_config.name, prompt=prompt, images=images, size=size)
|
||||
media = provider.extract_first_media_url(resp)
|
||||
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
|
||||
ext = ".jpg" if "jpeg" in content_type else (".webp" if "webp" in content_type else ".png")
|
||||
path = os.path.join(OUT_DIR, f"tryon_{i + 1}{ext}")
|
||||
with open(path, "wb") as f:
|
||||
f.write(fileobj.getvalue())
|
||||
print(f"✅ 第 {i + 1} 张 → {path}\n")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"❌ 第 {i + 1} 张失败: {exc}\n")
|
||||
|
||||
print(f"完成。产物目录: {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""内衣品类(文胸)测试:对比两条路线能不能出图。
|
||||
路线A · 真人上身(穿戴类) —— 验证 gpt-image-2 内容审核会不会拦(safety=[sexual])
|
||||
路线B · 隐形人台/平铺(无真人) —— 验证绕开审核的安全做法
|
||||
|
||||
跑法: .venv/bin/python tryon_underwear_test.py
|
||||
产物: /Users/maidong/Desktop/zyc/underwear_test
|
||||
"""
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
|
||||
django.setup()
|
||||
|
||||
from apps.products.models import Product # noqa: E402
|
||||
from apps.assets.models import Asset # noqa: E402
|
||||
from apps.ai.models import ModelConfig # noqa: E402
|
||||
from apps.ai.services import ( # noqa: E402
|
||||
get_default_model, get_image_provider, _asset_preview_url, _ratio_to_image_size,
|
||||
)
|
||||
from apps.ai.providers.volcano import VolcanoArkProvider # noqa: E402
|
||||
|
||||
PRODUCT_ID = "42078cdc-8714-4fc7-be5c-6aa42b7fc995" # 棉居莫代尔无痕内衣(真实主图)
|
||||
MODEL_ID = "ad362810-cf3e-49c0-9426-8142a74b75b2" # 一位真人模特
|
||||
RATIO = "3:4"
|
||||
OUT_DIR = "/Users/maidong/Desktop/zyc/underwear_test"
|
||||
|
||||
# 路线A · 真人上身(穿戴类从句)
|
||||
PROMPT_ON_BODY = (
|
||||
"参考图1是「{name}」的真实商品图,是商品外观的唯一依据。参考图2是出镜模特。"
|
||||
"请生成参考图2中这位模特真实穿着参考图1中这件内衣的电商详情页效果图,得体、健康、非裸露,"
|
||||
"覆盖充分,运动内衣风格,商品的颜色、版型、领口、肩带、纹样、Logo 必须与参考图1严格一致。"
|
||||
"模特五官/发型/肤色/身形与参考图2一致,不要换人。真实电商摄影,自然光,干净背景,单人,{ratio} 构图。"
|
||||
" 请规避:不要裸露,不要性暗示,不要换人,不要改商品设计与颜色,不要乱码文字,不要水印。"
|
||||
)
|
||||
|
||||
# 路线B · 隐形人台 / 平铺(无真人,绕开审核)
|
||||
PROMPT_GHOST = (
|
||||
"参考图1是「{name}」的真实商品图。请生成该内衣的电商主图:采用隐形人台(ghost mannequin)立体悬浮效果,"
|
||||
"衣服呈现被穿着时的自然立体版型与轮廓,但画面中没有真人、没有人体、不出现任何皮肤或身体部位。"
|
||||
"商品的颜色、版型、肩带、领口、纹理、Logo 必须与参考图1严格一致,不要重新设计。"
|
||||
"柔和自然光,干净纯净背景,商品居中,细节清晰,高分辨率,{ratio} 构图。"
|
||||
" 请规避:不要出现人、不要皮肤、不要模特、不要改商品颜色与版型、不要乱码文字、不要水印。"
|
||||
)
|
||||
|
||||
|
||||
def run(provider, model_name, label, prompt, images, size):
|
||||
print(f"\n━━━ {label} ━━━\n{prompt}\n图数={len(images)}")
|
||||
try:
|
||||
resp = provider.image_edit(model=model_name, prompt=prompt, images=images, size=size)
|
||||
media = provider.extract_first_media_url(resp)
|
||||
fileobj, ct = VolcanoArkProvider.media_to_bytes(media)
|
||||
ext = ".jpg" if "jpeg" in ct else (".webp" if "webp" in ct else ".png")
|
||||
path = os.path.join(OUT_DIR, f"{label}{ext}")
|
||||
with open(path, "wb") as f:
|
||||
f.write(fileobj.getvalue())
|
||||
print(f"✅ 出图成功 → {path}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
msg = str(exc)
|
||||
flagged = any(k in msg.lower() for k in ("moderation", "safety", "sexual", "blocked", "content_policy", "rejected"))
|
||||
print(f"❌ 失败{' · 命中内容审核' if flagged else ''}: {msg[:600]}")
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
p = Product.objects.get(id=PRODUCT_ID)
|
||||
product_url = _asset_preview_url(p.cover_asset)
|
||||
model_url = _asset_preview_url(Asset.objects.get(id=MODEL_ID))
|
||||
mc = get_default_model(ModelConfig.Capability.IMAGE)
|
||||
provider = get_image_provider(mc)
|
||||
size = _ratio_to_image_size(RATIO)
|
||||
print(f"商品: {p.title} | 类目: {p.category}")
|
||||
print(f"模型: {mc.provider.name}:{mc.name}")
|
||||
print(f"商品图: {product_url[:80]}")
|
||||
|
||||
# 路线A:真人上身(商品图 + 模特图)
|
||||
run(provider, mc.name, "A_真人上身", PROMPT_ON_BODY.format(name=p.title, ratio=RATIO),
|
||||
[product_url, model_url], size)
|
||||
# 路线B:隐形人台(仅商品图)
|
||||
run(provider, mc.name, "B_隐形人台", PROMPT_GHOST.format(name=p.title, ratio=RATIO),
|
||||
[product_url], size)
|
||||
|
||||
print(f"\n完成。产物: {OUT_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Reference in New Issue
Block a user