1014 lines
46 KiB
Python
1014 lines
46 KiB
Python
"""模特上身图结构化提示词规划器(Step 3 受控接入)。
|
||
|
||
本模块只做确定性的文本规划:
|
||
- 不读取图片、不调用任何模型;
|
||
- 不创建任务、不预占或扣除积分;
|
||
- 明确参考图角色,并按商品的真实展示方式规划 1 / 2 / 4 张图片;
|
||
- 生产 Worker 仅在 MODEL_TRYON_PROMPT_V2_ENABLED 开启时使用本规划器。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from enum import Enum
|
||
from typing import Iterable
|
||
|
||
|
||
class ProductKind(str, Enum):
|
||
LOWER = "lower"
|
||
UPPER = "upper"
|
||
FULL_BODY = "full_body"
|
||
UNDERWEAR = "underwear"
|
||
FOOTWEAR = "footwear"
|
||
HEADWEAR = "headwear"
|
||
EYEWEAR = "eyewear"
|
||
WRIST = "wrist"
|
||
HEAD_AUDIO = "head_audio"
|
||
EAR_AUDIO = "ear_audio"
|
||
JEWELRY = "jewelry"
|
||
BAG = "bag"
|
||
WEARABLE_GENERIC = "wearable_generic"
|
||
HANDHELD = "handheld"
|
||
NONWEARABLE = "nonwearable"
|
||
UNKNOWN = "unknown"
|
||
|
||
|
||
class TrouserLength(str, Enum):
|
||
UNKNOWN = "unknown"
|
||
FULL_LENGTH = "full_length"
|
||
CROPPED = "cropped"
|
||
FLOOR_LENGTH = "floor_length"
|
||
SHORTS = "shorts"
|
||
|
||
|
||
class TrouserSilhouette(str, Enum):
|
||
UNKNOWN = "unknown"
|
||
WIDE_LEG = "wide_leg"
|
||
STRAIGHT = "straight"
|
||
TAPERED = "tapered"
|
||
SLIM = "slim"
|
||
FLARED = "flared"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ProductContext:
|
||
title: str
|
||
category: str = ""
|
||
selling_points: tuple[str, ...] = ()
|
||
description: str = ""
|
||
|
||
@classmethod
|
||
def create(
|
||
cls,
|
||
*,
|
||
title: str,
|
||
category: str = "",
|
||
selling_points: Iterable[str] | None = None,
|
||
description: str = "",
|
||
) -> "ProductContext":
|
||
return cls(
|
||
title=(title or "商品").strip(),
|
||
category=(category or "").strip(),
|
||
selling_points=tuple(
|
||
point.strip() for point in (selling_points or ()) if point and point.strip()
|
||
),
|
||
description=(description or "").strip(),
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ReferenceRoles:
|
||
"""API 实际上传顺序对应的参考图角色。"""
|
||
|
||
product_numbers: tuple[int, ...]
|
||
model_portrait_number: int | None
|
||
model_triview_number: int | None
|
||
|
||
@classmethod
|
||
def create(
|
||
cls,
|
||
*,
|
||
product_count: int,
|
||
has_model_portrait: bool = True,
|
||
has_model_triview: bool = False,
|
||
) -> "ReferenceRoles":
|
||
if product_count < 1:
|
||
raise ValueError("product_count 必须至少为 1")
|
||
|
||
product_numbers = tuple(range(1, product_count + 1))
|
||
next_number = product_count + 1
|
||
portrait_number = next_number if has_model_portrait else None
|
||
if has_model_portrait:
|
||
next_number += 1
|
||
triview_number = next_number if has_model_triview else None
|
||
return cls(product_numbers, portrait_number, triview_number)
|
||
|
||
@property
|
||
def product_label(self) -> str:
|
||
if len(self.product_numbers) == 1:
|
||
return f"参考图{self.product_numbers[0]}"
|
||
start, end = self.product_numbers[0], self.product_numbers[-1]
|
||
return f"参考图{start}-{end}"
|
||
|
||
@property
|
||
def model_label(self) -> str:
|
||
labels: list[str] = []
|
||
if self.model_portrait_number is not None:
|
||
labels.append(f"参考图{self.model_portrait_number}(模特肖像)")
|
||
if self.model_triview_number is not None:
|
||
labels.append(f"参考图{self.model_triview_number}(模特三视图)")
|
||
return "、".join(labels) if labels else "无指定模特参考图"
|
||
|
||
def instruction(self) -> str:
|
||
return (
|
||
f"参考图角色(固定语义):商品图={self.product_label}(同一商品真实图);人物图={self.model_label}。"
|
||
"商品图定义外观,人物图定义身份与体型。用户原文若使用了与实际顺序不同的图号,"
|
||
"按角色语义解析:人物、女生、模特对应人物图,商品、衣物、配件对应商品图。"
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DisplayPolicy:
|
||
placement: str
|
||
visibility: str
|
||
shots: tuple[str, str, str, str]
|
||
negative: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ClassificationResult:
|
||
kind: ProductKind
|
||
source: str
|
||
matched_rule: str | None = None
|
||
fallback_reason: str | None = None
|
||
rule_version: str = "v2.1"
|
||
|
||
def as_payload(self) -> dict[str, str | None]:
|
||
"""返回只包含 JSON 原生值的任务快照。"""
|
||
|
||
return {
|
||
"kind": self.kind.value,
|
||
"source": self.source,
|
||
"matched_rule": self.matched_rule,
|
||
"fallback_reason": self.fallback_reason,
|
||
"rule_version": self.rule_version,
|
||
}
|
||
|
||
@classmethod
|
||
def from_payload(cls, payload: object) -> "ClassificationResult | None":
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
try:
|
||
kind = ProductKind(str(payload.get("kind") or ""))
|
||
except ValueError:
|
||
return None
|
||
source = str(payload.get("source") or "").strip()
|
||
if not source:
|
||
return None
|
||
return cls(
|
||
kind=kind,
|
||
source=source,
|
||
matched_rule=(str(payload["matched_rule"]) if payload.get("matched_rule") is not None else None),
|
||
fallback_reason=(
|
||
str(payload["fallback_reason"]) if payload.get("fallback_reason") is not None else None
|
||
),
|
||
rule_version=str(payload.get("rule_version") or "v2.1"),
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class TrouserFacts:
|
||
"""仅由现有文字字段确定性提取的裤长/版型批次快照。"""
|
||
|
||
length: TrouserLength = TrouserLength.UNKNOWN
|
||
silhouette: TrouserSilhouette = TrouserSilhouette.UNKNOWN
|
||
length_source: str | None = None
|
||
silhouette_source: str | None = None
|
||
length_rule: str | None = None
|
||
silhouette_rule: str | None = None
|
||
rule_version: str = "v1"
|
||
|
||
def as_payload(self) -> dict[str, str | None]:
|
||
return {
|
||
"length": self.length.value,
|
||
"silhouette": self.silhouette.value,
|
||
"length_source": self.length_source,
|
||
"silhouette_source": self.silhouette_source,
|
||
"length_rule": self.length_rule,
|
||
"silhouette_rule": self.silhouette_rule,
|
||
"rule_version": self.rule_version,
|
||
}
|
||
|
||
@classmethod
|
||
def from_payload(cls, payload: object) -> "TrouserFacts | None":
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
try:
|
||
length = TrouserLength(str(payload.get("length") or ""))
|
||
silhouette = TrouserSilhouette(str(payload.get("silhouette") or ""))
|
||
except ValueError:
|
||
return None
|
||
return cls(
|
||
length=length,
|
||
silhouette=silhouette,
|
||
length_source=(
|
||
str(payload["length_source"]) if payload.get("length_source") is not None else None
|
||
),
|
||
silhouette_source=(
|
||
str(payload["silhouette_source"])
|
||
if payload.get("silhouette_source") is not None
|
||
else None
|
||
),
|
||
length_rule=(str(payload["length_rule"]) if payload.get("length_rule") is not None else None),
|
||
silhouette_rule=(
|
||
str(payload["silhouette_rule"])
|
||
if payload.get("silhouette_rule") is not None
|
||
else None
|
||
),
|
||
rule_version=str(payload.get("rule_version") or "v1"),
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class TryonPromptPlan:
|
||
kind: ProductKind
|
||
classification: ClassificationResult
|
||
trouser_facts: TrouserFacts | None
|
||
ratio: str
|
||
references: ReferenceRoles
|
||
shots: tuple[str, ...]
|
||
prompts: tuple[str, ...]
|
||
|
||
|
||
_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] = {
|
||
ProductKind.LOWER: DisplayPolicy(
|
||
placement="商品必须真实、自然地穿在模特下半身,位置和人体结构正确,不得手持、悬挂或摆在身旁",
|
||
visibility="完整展示腰线、门襟、口袋、臀腿版型、裤腿或裙摆、长度和垂坠感;双腿及下装末端不得出画",
|
||
shots=(
|
||
"正面全身站姿,从头到脚完整入镜,双腿自然分开,清楚展示下装正面版型",
|
||
"左前约30度全身站姿,从头到脚完整入镜,轻微转身,展示腰臀过渡、腿部轮廓与垂坠感",
|
||
"右前约30度全身站姿,从头到脚完整入镜,自然调整重心,展示下装版型且保持脸部清晰",
|
||
"近正面全身站姿,从头到脚完整入镜,姿态自然且仅轻微变化,双手不遮挡腰头和口袋",
|
||
),
|
||
negative="禁止裁掉双腿、脚部、腰头或下装末端;禁止把下装改成手持商品",
|
||
),
|
||
ProductKind.UPPER: DisplayPolicy(
|
||
placement="商品必须真实、自然地穿在模特上半身,正确替换对应上衣,不覆盖无关服装区域",
|
||
visibility="清楚展示领口、肩线、袖型、门襟、下摆、图案和面料垂感",
|
||
shots=(
|
||
"正面站姿,腰部以上完整入镜,双臂自然放松,展示上衣正面结构",
|
||
"左前约30度站姿,腰部以上完整入镜,展示肩线、袖型和衣身轮廓",
|
||
"右前约30度站姿,腰部以上完整入镜,展示衣身轮廓并保持脸部清晰",
|
||
"近正面自然站姿,腰部以上完整入镜,姿态轻微变化,双臂不遮挡领口、袖口和下摆",
|
||
),
|
||
negative="禁止把上衣拿在手中或叠放展示;禁止遮挡领口、袖口和下摆",
|
||
),
|
||
ProductKind.FULL_BODY: DisplayPolicy(
|
||
placement="商品必须作为一件完整服装真实穿在模特身上,肩部、腰部与下摆位置符合人体结构",
|
||
visibility="从肩部到服装下摆完整展示,连体结构、腰线、长度和整体廓形不得被裁切",
|
||
shots=(
|
||
"正面全身站姿,从头到脚完整入镜,展示服装完整正面廓形",
|
||
"左前约30度全身站姿,从头到脚完整入镜,展示腰线和整体垂坠感",
|
||
"右前约30度全身站姿,从头到脚完整入镜,展示完整廓形并保持脸部清晰",
|
||
"近正面全身站姿,从头到脚完整入镜,姿态自然且仅轻微变化,服装主体与下摆无遮挡",
|
||
),
|
||
negative="禁止截断服装主体或下摆;禁止拆成上下两件或擅自改变连体结构",
|
||
),
|
||
ProductKind.UNDERWEAR: DisplayPolicy(
|
||
placement="商品必须作为贴身服饰正确穿着,肩带、罩杯、腰头等结构与身体位置自然贴合",
|
||
visibility="在合规、克制的电商展示中清楚呈现商品轮廓、边缘、面料和支撑结构",
|
||
shots=(
|
||
"正面自然站姿,商品完整入镜,姿态克制,展示正面结构和贴合度",
|
||
"左前约30度自然站姿,商品完整入镜,展示侧翼、肩带或腰线",
|
||
"右前约30度自然站姿,商品完整入镜,展示包覆和贴合轮廓并保持脸部清晰",
|
||
"近正面自然站姿,商品完整入镜,姿态克制变化,关键结构无遮挡",
|
||
),
|
||
negative="禁止色情化姿势、夸张身体曲线或透明化处理;禁止遮挡商品关键结构",
|
||
),
|
||
ProductKind.FOOTWEAR: DisplayPolicy(
|
||
placement="商品必须正确穿在模特双脚上,左右脚成对一致,鞋底与地面接触自然",
|
||
visibility="双脚和两只鞋必须完整清晰,展示鞋面、鞋头、鞋跟、鞋底厚度和上脚比例",
|
||
shots=(
|
||
"正面全身站姿,从头到脚完整入镜,两只鞋完整可见",
|
||
"三分之四角度全身站姿,从头到脚完整入镜,展示鞋面与鞋侧",
|
||
"侧面全身站姿,从头到脚完整入镜,展示鞋底、鞋跟和侧面轮廓",
|
||
"低机位双脚展示,两只鞋完整入镜,展示上脚细节且保持真实人体比例",
|
||
),
|
||
negative="禁止裁掉脚部、只生成一只鞋、左右鞋款不一致或把鞋拿在手中",
|
||
),
|
||
ProductKind.HEADWEAR: DisplayPolicy(
|
||
placement="商品必须正确佩戴在模特头部,尺寸、方向和受力关系自然",
|
||
visibility="完整展示帽檐、帽冠、侧面结构及与发型的自然接触关系",
|
||
shots=(
|
||
"正面胸部以上人像,头饰完整入镜,展示正面形态",
|
||
"三分之四角度胸部以上人像,头饰完整入镜,展示帽檐和帽冠",
|
||
"侧面胸部以上人像,头饰完整入镜,展示侧面结构",
|
||
"近正面胸部以上人像,头饰完整入镜,姿态轻微变化,突出材质与正面结构",
|
||
),
|
||
negative="禁止把头饰拿在手中、悬浮在头顶或遮挡整个面部",
|
||
),
|
||
ProductKind.EYEWEAR: DisplayPolicy(
|
||
placement="商品必须正确佩戴在模特眼部和鼻梁位置,镜腿自然贴合双耳",
|
||
visibility="完整展示镜框、镜片、鼻托、镜腿及上脸比例,左右结构保持对称",
|
||
shots=(
|
||
"正面肩部以上人像,眼镜完整清晰,展示正面框型",
|
||
"三分之四角度肩部以上人像,展示镜框厚度、鼻托和镜腿",
|
||
"侧面肩部以上人像,展示镜腿与耳部的正确贴合",
|
||
"正面肩部以上近景,眼镜完整入镜,突出材质细节且不过度裁切",
|
||
),
|
||
negative="禁止把眼镜拿在手中、戴在头顶、生成重影镜框或不对称镜腿",
|
||
),
|
||
ProductKind.WRIST: DisplayPolicy(
|
||
placement="商品必须正确佩戴在模特手腕上,表盘朝向、表带闭合和尺寸比例真实",
|
||
visibility="清楚展示表盘、表带、扣具及佩戴关系,同时保留自然手臂和人物身份",
|
||
shots=(
|
||
"正面自然站姿,佩戴手表的手腕抬至胸前,商品完整清晰",
|
||
"三分之四角度人像,佩戴手表的手腕自然转向镜头,展示表盘和表带",
|
||
"侧面人像,手腕自然弯曲,展示表壳厚度和扣具位置",
|
||
"手腕局部特写,商品仍正确佩戴,表盘、表带和皮肤接触关系完整",
|
||
),
|
||
negative="禁止手持商品、把手表放在掌心、生成多个表盘或错误佩戴位置",
|
||
),
|
||
ProductKind.HEAD_AUDIO: DisplayPolicy(
|
||
placement="商品必须按真实使用方式正确佩戴在头部和双耳位置,头梁、耳罩方向及尺寸比例自然",
|
||
visibility="完整展示头梁、左右耳罩、连接结构及与发型和耳部的佩戴关系",
|
||
shots=(
|
||
"正面胸部以上人像,耳机正确佩戴,左右耳罩和头梁完整可见",
|
||
"三分之四角度胸部以上人像,展示耳罩、头梁和佩戴贴合度",
|
||
"侧面胸部以上人像,展示单侧耳罩厚度与头梁弧度,另一侧结构保持合理",
|
||
"正面胸部以上近景,耳机正确佩戴并完整入镜,突出材质和结构细节",
|
||
),
|
||
negative="禁止手持耳机、挂在颈部、把头戴式耳机改成入耳式或遗漏一侧耳罩",
|
||
),
|
||
ProductKind.EAR_AUDIO: DisplayPolicy(
|
||
placement="商品必须按真实使用方式正确佩戴在耳内或耳部,左右耳对应、方向和尺寸比例自然",
|
||
visibility="清楚展示耳塞主体、柄部或耳挂结构,以及商品与耳部的真实佩戴关系",
|
||
shots=(
|
||
"正面肩部以上人像,入耳式耳机正确佩戴,左右结构合理可见",
|
||
"三分之四角度肩部以上人像,靠近镜头一侧耳机完整清晰",
|
||
"侧面肩部以上人像,展示耳机在耳内或耳部的正确方向和贴合关系",
|
||
"耳部局部特写,商品保持正确佩戴,展示结构、材质和真实尺寸",
|
||
),
|
||
negative="禁止手持耳机、把入耳式耳机改成头戴式、生成头梁或夸大耳机尺寸",
|
||
),
|
||
ProductKind.JEWELRY: DisplayPolicy(
|
||
placement="商品必须佩戴在其对应身体位置,方向、数量、尺寸和受力关系自然",
|
||
visibility="商品完整清晰,展示造型、材质、连接件以及与人物的真实比例",
|
||
shots=(
|
||
"正面自然人像,饰品正确佩戴并完整清晰",
|
||
"三分之四角度人像,展示饰品造型、厚度和佩戴关系",
|
||
"侧面自然人像,展示饰品侧面结构与连接位置",
|
||
"佩戴位置局部特写,饰品保持完整且不过度美化或放大",
|
||
),
|
||
negative="禁止手持饰品、改变佩戴位置、凭空增加数量或夸大商品尺寸",
|
||
),
|
||
ProductKind.BAG: DisplayPolicy(
|
||
placement="根据商品真实包型选择唯一正确的携带方式:手提、单肩、斜挎、双肩或腰间固定;不得同时混用多种方式",
|
||
visibility="包身、肩带或提手、开合结构和五金必须完整清晰,尺寸及与人体的比例真实",
|
||
shots=(
|
||
"正面自然站姿,商品按真实包型正确携带,包身完整朝向镜头",
|
||
"三分之四角度自然站姿,展示包身厚度、肩带或提手与人物的关系",
|
||
"侧面自然站姿,展示包型轮廓、容量厚度和正确携带位置",
|
||
"商品局部展示,包身与主要肩带或提手保持完整,突出材质、五金和开合结构",
|
||
),
|
||
negative="禁止改变包型、增加多余肩带或提手、错误背法、悬浮或复制多个包",
|
||
),
|
||
ProductKind.WEARABLE_GENERIC: DisplayPolicy(
|
||
placement="商品必须按照真实用途正确穿戴在模特身上,不得手持、悬浮或摆在身旁",
|
||
visibility="商品主体、边缘、连接结构和实际穿戴关系必须完整清晰",
|
||
shots=(
|
||
"正面自然站姿,商品正确穿戴并完整入镜",
|
||
"三分之四角度自然站姿,展示商品轮廓与穿戴关系",
|
||
"侧面自然站姿,展示商品厚度、长度和贴合方式",
|
||
"近正面自然站姿,商品正确穿戴并完整入镜,姿态轻微变化且不遮挡关键结构",
|
||
),
|
||
negative="禁止把穿戴商品改成手持展示;禁止错误穿戴位置或改变用途",
|
||
),
|
||
ProductKind.HANDHELD: DisplayPolicy(
|
||
placement="商品应由模特按真实用途自然手持或操作,握持位置不得遮挡核心结构",
|
||
visibility="商品主体完整清晰,展示外观、材质、控制区域及与手部的真实比例",
|
||
shots=(
|
||
"正面自然人像,模特单手自然拿稳商品,商品主体完整朝向镜头",
|
||
"三分之四角度人像,模特按真实用途操作商品,展示侧面结构",
|
||
"侧面自然人像,展示握持方式、商品厚度和使用关系",
|
||
"手部与商品局部特写,商品主体保持完整,清楚展示材质和操作细节",
|
||
),
|
||
negative="禁止把商品穿到身上、遮挡商品主体、生成多余手指或改变商品用途",
|
||
),
|
||
ProductKind.NONWEARABLE: DisplayPolicy(
|
||
placement="根据商品真实用途安排模特与商品的关系;不得把非穿戴商品强行穿在身上",
|
||
visibility="商品主体必须完整清晰,尺寸、结构和与人物的比例符合真实使用逻辑",
|
||
shots=(
|
||
"正面电商人像,商品完整清晰地处于真实使用位置",
|
||
"三分之四角度电商人像,展示商品侧面结构和真实使用关系",
|
||
"侧面电商人像,展示商品厚度、尺寸和操作方式",
|
||
"商品与操作部位局部特写,商品主体保持完整并突出关键结构",
|
||
),
|
||
negative="禁止把商品强行穿戴、悬浮、复制成多个或改变真实用途",
|
||
),
|
||
ProductKind.UNKNOWN: DisplayPolicy(
|
||
placement="先依据商品参考图和商品文字判断商品的真实用途,再选择且只选择一种正确的人物关系完成展示;不得同时混用穿着、佩戴、手持等互斥方式",
|
||
visibility="商品主体和关键结构必须完整清晰,尺寸、方向及与人物的关系符合参考图所体现的真实用途",
|
||
shots=(
|
||
"正面自然电商人像,商品按判断出的唯一真实用途展示并完整入镜",
|
||
"三分之四角度自然电商人像,展示商品结构和唯一正确的人物关系",
|
||
"侧面自然电商人像,展示商品厚度、尺寸和真实使用位置",
|
||
"商品及其真实使用位置局部展示,商品主体保持完整并突出关键结构",
|
||
),
|
||
negative="禁止同时执行互斥的穿着、佩戴或手持关系;禁止在无法确认时擅自替换模特服装或改变商品用途",
|
||
),
|
||
}
|
||
|
||
|
||
_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, ("牛仔裤", "裤", "半身裙", "短裙", "长裙", "下装")),
|
||
(ProductKind.UNDERWEAR, ("内衣", "文胸", "胸罩", "bra", "塑身衣", "内裤")),
|
||
(ProductKind.FOOTWEAR, ("鞋", "靴", "凉拖", "拖鞋")),
|
||
(ProductKind.HEAD_AUDIO, ("头戴式", "头戴耳机", "耳麦")),
|
||
(ProductKind.EAR_AUDIO, ("入耳式", "耳塞", "耳挂式", "真无线耳机", "蓝牙耳机", "耳机", "airpods")),
|
||
(ProductKind.WRIST, ("手表", "腕表", "手环")),
|
||
(ProductKind.EYEWEAR, ("眼镜", "墨镜", "镜框")),
|
||
(ProductKind.HEADWEAR, ("帽", "头巾", "发箍")),
|
||
(ProductKind.JEWELRY, ("项链", "耳环", "耳钉", "戒指", "手链", "脚链", "胸针")),
|
||
(ProductKind.BAG, ("双肩包", "背包", "斜挎包", "单肩包", "腰包", "手提包", "手拿包", "包袋")),
|
||
(ProductKind.UPPER, ("上衣", "t恤", "衬衫", "卫衣", "外套", "夹克", "毛衣", "背心", "吊带")),
|
||
(ProductKind.HANDHELD, ("风扇", "水壶", "水杯", "手机", "相机", "雨伞")),
|
||
(ProductKind.NONWEARABLE, ("收纳盒", "置物架", "台灯", "花瓶", "摆件", "桌椅", "餐具")),
|
||
)
|
||
|
||
_GENERIC_WEARABLE_HINTS = (
|
||
"围巾", "披肩", "领带", "领结", "袜", "腰带", "皮带", "手套", "护膝", "护腕", "服饰", "服装", "穿戴", "衣帽", "鞋服",
|
||
)
|
||
_BROAD_WEARABLE_CATEGORIES = ("服饰内衣", "服饰", "服装", "女装", "男装", "配饰")
|
||
_USER_RELATION_RULES: tuple[tuple[ProductKind, tuple[str, ...]], ...] = (
|
||
(ProductKind.HANDHELD, ("手持", "拿着", "拿在手", "握住")),
|
||
(ProductKind.WEARABLE_GENERIC, ("穿上", "穿着", "换上", "佩戴", "戴上", "戴着")),
|
||
(ProductKind.NONWEARABLE, ("正在使用", "实际使用", "操作商品")),
|
||
)
|
||
|
||
|
||
def _match_rule(blob: str) -> tuple[ProductKind, str] | None:
|
||
for kind, keywords in _KEYWORDS:
|
||
for keyword in keywords:
|
||
if keyword in blob:
|
||
return kind, keyword
|
||
return None
|
||
|
||
|
||
def _match_user_relation(user_prompt: str) -> tuple[ProductKind, str] | None:
|
||
lowered = (user_prompt or "").lower()
|
||
for kind, keywords in _USER_RELATION_RULES:
|
||
for keyword in keywords:
|
||
if keyword in lowered:
|
||
return kind, keyword
|
||
return None
|
||
|
||
|
||
def classify_product_details(context: ProductContext, user_prompt: str = "") -> ClassificationResult:
|
||
"""返回可追溯的分类结果;无法可靠判断时显式落入 UNKNOWN。"""
|
||
|
||
title_blob = " ".join((context.title, context.description)).lower()
|
||
matched = _match_rule(title_blob)
|
||
if matched:
|
||
kind, keyword = matched
|
||
return ClassificationResult(kind, "title_description", f"{kind.value}:{keyword}")
|
||
|
||
# 用户明确关系只覆盖规则表没有识别出的商品,不推翻裤装、手表等已确定的真实商品类型。
|
||
user_relation = _match_user_relation(user_prompt)
|
||
if user_relation:
|
||
kind, keyword = user_relation
|
||
return ClassificationResult(
|
||
kind,
|
||
"user_prompt",
|
||
f"relation:{keyword}",
|
||
"no_specific_product_rule",
|
||
)
|
||
|
||
for keyword in _GENERIC_WEARABLE_HINTS:
|
||
if keyword in title_blob:
|
||
return ClassificationResult(
|
||
ProductKind.WEARABLE_GENERIC,
|
||
"title_description",
|
||
f"wearable_generic:{keyword}",
|
||
"no_specific_product_rule",
|
||
)
|
||
|
||
category = context.category.lower().strip()
|
||
# “服饰内衣”是平台宽泛大类,不能仅凭其中的“内衣”二字判成贴身内衣。
|
||
for broad_category in _BROAD_WEARABLE_CATEGORIES:
|
||
if category == broad_category or broad_category in category:
|
||
return ClassificationResult(
|
||
ProductKind.WEARABLE_GENERIC,
|
||
"category",
|
||
f"broad_wearable:{broad_category}",
|
||
"broad_category_only",
|
||
)
|
||
|
||
matched = _match_rule(category)
|
||
if matched:
|
||
kind, keyword = matched
|
||
return ClassificationResult(kind, "category", f"{kind.value}:{keyword}")
|
||
|
||
return ClassificationResult(
|
||
ProductKind.UNKNOWN,
|
||
"fallback",
|
||
None,
|
||
"no_specific_rule_matched",
|
||
)
|
||
|
||
|
||
def classify_product(context: ProductContext, user_prompt: str = "") -> ProductKind:
|
||
"""按展示方式分类,而不是简单区分“服饰 / 非服饰”。"""
|
||
|
||
return classify_product_details(context, user_prompt).kind
|
||
|
||
|
||
def _requires_full_body(user_prompt: str) -> bool:
|
||
lowered = (user_prompt or "").lower()
|
||
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}完整清晰,姿态自然且仅做轻微变化",
|
||
)
|
||
|
||
|
||
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 _trouser_default_shots(count: int) -> tuple[str, ...]:
|
||
full_front = (
|
||
"正面标准全身站姿,从头到脚完整入镜,完整展示腰头、门襟、口袋、双腿、裤脚和正面版型"
|
||
)
|
||
full_angle = (
|
||
"左前约25度全身站姿,从头到脚完整入镜,轻微转身,展示腰臀过渡、腿部轮廓和垂坠感"
|
||
)
|
||
medium_front = (
|
||
"正面人物中景,保留人物脸部并从头部展示至膝部附近,"
|
||
"清楚展示腰头、门襟、口袋、臀腿和裤腿大半"
|
||
)
|
||
medium_angle = (
|
||
"小角度近正面人物中景,保留人物脸部并从头部展示至膝部附近,"
|
||
"允许单手自然插袋或轻微调整重心,清楚展示腰头、口袋、臀腿和裤腿大半"
|
||
)
|
||
if count == 1:
|
||
return (full_front,)
|
||
if count == 2:
|
||
return (full_front, medium_front)
|
||
return (full_front, full_angle, medium_front, medium_angle)
|
||
|
||
|
||
def plan_shots(
|
||
kind: ProductKind,
|
||
count: int,
|
||
user_prompt: str = "",
|
||
trouser_facts: TrouserFacts | None = None,
|
||
) -> tuple[str, ...]:
|
||
if count not in (1, 2, 4):
|
||
raise ValueError("模特上身图生成数量只支持 1、2、4")
|
||
full_body = _requires_full_body(user_prompt)
|
||
if full_body:
|
||
shots = _full_body_shots(kind)[:count]
|
||
elif kind == ProductKind.LOWER and trouser_facts is not None:
|
||
shots = _trouser_default_shots(count)
|
||
else:
|
||
shots = _POLICIES[kind].shots[:count]
|
||
if not _requests_back_view(user_prompt):
|
||
return shots
|
||
|
||
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:
|
||
"""只在用户没有传比例时使用;显式比例始终优先。"""
|
||
|
||
if kind in {
|
||
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,
|
||
}:
|
||
return "3:4"
|
||
return "1:1"
|
||
|
||
|
||
def _selling_points_instruction(context: ProductContext) -> str:
|
||
if not context.selling_points:
|
||
return ""
|
||
points = "、".join(context.selling_points)
|
||
return (
|
||
f"文字卖点:{points};仅用于理解展示意图,不是商品外观证据,"
|
||
"不得据此臆造参考图中不可见的属性。"
|
||
)
|
||
|
||
|
||
_TROUSER_TERMS = ("裤", "pants", "trouser", "jeans", "leggings", "joggers", "jumpsuit")
|
||
_NON_TROUSER_PRODUCT_TERMS = ("内裤", "underpants", "briefs")
|
||
_NEGATION_MARKERS = ("不要", "不得", "禁止", "避免", "拒绝", "不是", "并非", "不可", "非", "not ")
|
||
_NEGATION_CONNECTORS = ("", "改成", "变成", "生成", "成为", "做成", "使用", "出现", "为", "是", "做", "过度", "a", "an")
|
||
|
||
_TROUSER_LENGTH_RULES: tuple[tuple[TrouserLength, tuple[str, ...]], ...] = (
|
||
(TrouserLength.FLOOR_LENGTH, ("拖地裤", "拖地", "落地裤", "及地裤", "floor-length")),
|
||
(TrouserLength.CROPPED, ("九分裤", "九分", "八分裤", "八分", "七分裤", "七分", "cropped")),
|
||
(TrouserLength.SHORTS, ("短裤", "五分裤", "五分", "热裤", "shorts")),
|
||
(TrouserLength.FULL_LENGTH, ("全长裤", "全长", "长裤", "full-length")),
|
||
)
|
||
_TROUSER_SILHOUETTE_RULES: tuple[tuple[TrouserSilhouette, tuple[str, ...]], ...] = (
|
||
(TrouserSilhouette.WIDE_LEG, ("阔腿", "宽腿", "宽裤腿", "wide-leg", "wide leg")),
|
||
(TrouserSilhouette.FLARED, ("喇叭裤", "喇叭", "微喇", "flared", "bootcut")),
|
||
(TrouserSilhouette.TAPERED, ("锥形裤", "锥形", "小脚裤", "小脚", "束脚裤", "束脚", "tapered")),
|
||
(TrouserSilhouette.SLIM, ("紧身裤", "紧身", "修身裤", "铅笔裤", "skinny", "leggings")),
|
||
(TrouserSilhouette.STRAIGHT, ("直筒裤", "直筒", "straight-leg", "straight leg")),
|
||
)
|
||
|
||
_TROUSER_LENGTH_PROMPTS = {
|
||
TrouserLength.FULL_LENGTH: "文字已明确裤长为全长款;所有镜头保持这一长度事实。",
|
||
TrouserLength.CROPPED: "文字已明确裤长为九分长度;所有镜头保持这一长度事实。",
|
||
TrouserLength.FLOOR_LENGTH: "文字已明确裤长为及地长度;所有镜头保持这一长度事实。",
|
||
TrouserLength.SHORTS: "文字已明确裤长为短款;所有镜头保持这一长度事实。",
|
||
}
|
||
_TROUSER_SILHOUETTE_PROMPTS = {
|
||
TrouserSilhouette.WIDE_LEG: "文字已明确为宽腿廓形;保持裤腿宽松度和裤脚开口。",
|
||
TrouserSilhouette.STRAIGHT: "文字已明确为直筒廓形;保持裤腿上下宽度关系。",
|
||
TrouserSilhouette.TAPERED: "文字已明确为锥形廓形;保持原有收束关系。",
|
||
TrouserSilhouette.SLIM: "文字已明确为修身廓形;保持原有贴合度。",
|
||
TrouserSilhouette.FLARED: "文字已明确为喇叭廓形;保持裤脚展开关系。",
|
||
}
|
||
|
||
|
||
def _is_negated_match(text: str, start: int) -> bool:
|
||
prefix = text[max(0, start - 10) : start]
|
||
for marker in _NEGATION_MARKERS:
|
||
marker_index = prefix.rfind(marker)
|
||
if marker_index < 0:
|
||
continue
|
||
connector = prefix[marker_index + len(marker) :].strip(" ,。;;、::")
|
||
if connector in _NEGATION_CONNECTORS:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _match_text_fact(text: str, rules: tuple[tuple[Enum, tuple[str, ...]], ...]):
|
||
lowered = (text or "").lower()
|
||
for value, terms in rules:
|
||
for term in terms:
|
||
start = lowered.find(term)
|
||
while start >= 0:
|
||
if not _is_negated_match(lowered, start):
|
||
return value, term
|
||
start = lowered.find(term, start + len(term))
|
||
return None, None
|
||
|
||
|
||
def extract_trouser_facts(
|
||
context: ProductContext,
|
||
kind: ProductKind,
|
||
user_prompt: str = "",
|
||
) -> TrouserFacts | None:
|
||
"""从商品文字优先、用户文字补充地解析一次批次事实;不读图、不调用模型。"""
|
||
|
||
if kind not in {ProductKind.LOWER, ProductKind.FULL_BODY}:
|
||
return None
|
||
product_text = " ".join(
|
||
(context.title, context.category, context.description, *context.selling_points)
|
||
).lower()
|
||
if any(term in product_text for term in _NON_TROUSER_PRODUCT_TERMS):
|
||
return None
|
||
combined = f"{product_text} {user_prompt or ''}".lower()
|
||
if not any(term in combined for term in _TROUSER_TERMS):
|
||
return None
|
||
|
||
length, length_rule = _match_text_fact(product_text, _TROUSER_LENGTH_RULES)
|
||
length_source = "product_text" if length is not None else None
|
||
if length is None:
|
||
length, length_rule = _match_text_fact(user_prompt, _TROUSER_LENGTH_RULES)
|
||
length_source = "user_prompt" if length is not None else None
|
||
|
||
silhouette, silhouette_rule = _match_text_fact(product_text, _TROUSER_SILHOUETTE_RULES)
|
||
silhouette_source = "product_text" if silhouette is not None else None
|
||
if silhouette is None:
|
||
silhouette, silhouette_rule = _match_text_fact(user_prompt, _TROUSER_SILHOUETTE_RULES)
|
||
silhouette_source = "user_prompt" if silhouette is not None else None
|
||
|
||
return TrouserFacts(
|
||
length=length or TrouserLength.UNKNOWN,
|
||
silhouette=silhouette or TrouserSilhouette.UNKNOWN,
|
||
length_source=length_source,
|
||
silhouette_source=silhouette_source,
|
||
length_rule=length_rule,
|
||
silhouette_rule=silhouette_rule,
|
||
)
|
||
|
||
|
||
def _trouser_length_anchor_instruction(
|
||
facts: TrouserFacts | None,
|
||
) -> str:
|
||
if facts is None:
|
||
return ""
|
||
parts = [
|
||
"裤装几何保真:保持商品参考图中腰头至裤脚的纵向比例、裤腿宽度、裤脚开口和垂坠关系;"
|
||
"所有镜头复用同一裤长和版型事实,不因姿态、美化或构图改变这些比例。"
|
||
]
|
||
length_prompt = _TROUSER_LENGTH_PROMPTS.get(facts.length)
|
||
if length_prompt:
|
||
parts.append(length_prompt)
|
||
silhouette_prompt = _TROUSER_SILHOUETTE_PROMPTS.get(facts.silhouette)
|
||
if silhouette_prompt:
|
||
parts.append(silhouette_prompt)
|
||
return "".join(parts)
|
||
|
||
|
||
_TARGET_AREAS: dict[ProductKind, str] = {
|
||
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: "依据商品真实用途确定的唯一目标区域",
|
||
}
|
||
|
||
|
||
def _editing_boundary_instruction(kind: ProductKind, references: ReferenceRoles) -> str:
|
||
target_area = _TARGET_AREAS[kind]
|
||
if references.model_portrait_number is None:
|
||
return (
|
||
f"区域编辑边界:只修改或生成{target_area};除商品正确穿戴或使用所必需的区域外,"
|
||
"不重做非目标服饰、发型、妆容、配饰或整套穿搭。"
|
||
)
|
||
return (
|
||
f"区域编辑边界:只替换或修改{target_area};参考图{references.model_portrait_number}中的非目标服饰、"
|
||
"发型、妆容和配饰保持不变。非目标服饰上的文字或图案清楚可辨时,"
|
||
"保持原内容、拼写、位置和颜色;无法辨认时不得猜写相似文字、乱码或新增文案。"
|
||
)
|
||
|
||
|
||
def _is_trouser_medium_shot(shot: str, trouser_facts: TrouserFacts | None) -> bool:
|
||
return trouser_facts is not None and "人物中景" in shot
|
||
|
||
|
||
def _visibility_instruction(
|
||
policy: DisplayPolicy,
|
||
shot: str,
|
||
trouser_facts: TrouserFacts | None,
|
||
) -> str:
|
||
if _is_trouser_medium_shot(shot, trouser_facts):
|
||
return (
|
||
"人物脸部、腰头、门襟、口袋、臀腿和大部分裤腿必须清楚可见;"
|
||
"本张为人物中景,裤脚允许自然出画,不要求从头到脚完整入镜"
|
||
)
|
||
return policy.visibility
|
||
|
||
|
||
def _negative_instruction(
|
||
policy: DisplayPolicy,
|
||
shot: str,
|
||
trouser_facts: TrouserFacts | None,
|
||
) -> str:
|
||
if _is_trouser_medium_shot(shot, trouser_facts):
|
||
return (
|
||
"禁止裁掉人物脸部、腰头、门襟、口袋或臀腿主要区域;"
|
||
"禁止把下装改成手持商品"
|
||
)
|
||
return policy.negative
|
||
|
||
|
||
def _safe_margin_instruction(shot: str, trouser_facts: TrouserFacts | None = None) -> str:
|
||
if _is_trouser_medium_shot(shot, trouser_facts):
|
||
return (
|
||
"构图安全边界:人物脸部、腰头、口袋、臀腿和画面内裤腿保留安全留白;"
|
||
"画面下缘允许在膝部附近自然裁切,必须展示区域不得触边。"
|
||
)
|
||
if "全身" in shot or "从头到脚" in shot:
|
||
return (
|
||
"构图安全边界:头顶、双脚和商品末端完整入画,四周保留约画布短边的 3%-6% 安全留白,"
|
||
"不得裁切、触边或贴边。"
|
||
)
|
||
return (
|
||
"构图安全边界:商品主体和关键边缘完整入画并保留安全留白,不得裁切、触边或贴边。"
|
||
)
|
||
|
||
|
||
def _build_prompt(
|
||
*,
|
||
context: ProductContext,
|
||
kind: ProductKind,
|
||
policy: DisplayPolicy,
|
||
references: ReferenceRoles,
|
||
user_prompt: str,
|
||
ratio: str,
|
||
shot: str,
|
||
trouser_facts: TrouserFacts | None,
|
||
) -> str:
|
||
hard_requirement = (user_prompt or "").strip() or "生成真实、可用的电商模特商品展示图。"
|
||
if references.model_portrait_number is not None:
|
||
identity = (
|
||
f"保持参考图{references.model_portrait_number}中人物的脸型、五官、发型、肤色、年龄感、"
|
||
"身材比例和自然气质,不换人或混合其他人物特征。"
|
||
)
|
||
else:
|
||
identity = "使用同一位真人写实模特,人体比例和表情自然,批次内身份保持一致。"
|
||
|
||
visibility = _visibility_instruction(policy, shot, trouser_facts)
|
||
negative = _negative_instruction(policy, shot, trouser_facts)
|
||
|
||
product_parts = [
|
||
references.instruction(),
|
||
(
|
||
f"{references.product_label}是“{context.title}”的唯一外观依据;保持可见的颜色、版型、材质、"
|
||
"长度、图案、文字与 Logo、独特轮廓、部件数量与位置关系、开合结构、口袋、拉链、缝线和五金;"
|
||
"同批保持同一款,不重新设计、改款或生成相似款。"
|
||
),
|
||
_selling_points_instruction(context),
|
||
f"穿戴或使用:{policy.placement};必须可见:{visibility}。",
|
||
_trouser_length_anchor_instruction(trouser_facts),
|
||
"商品关键结构优先于姿态、美化和风格;发生冲突时减少姿态和镜头变化,不牺牲参考图可见结构。",
|
||
]
|
||
person_parts = [identity, _editing_boundary_instruction(kind, references)]
|
||
composition_parts = [
|
||
f"用户硬性要求(最高优先,图号按参考图角色解析):{hard_requirement}。",
|
||
f"本张镜头:{shot}。",
|
||
_safe_margin_instruction(shot, trouser_facts),
|
||
f"输出画幅:严格使用 {ratio} 比例;比例不得裁掉必须展示的商品部位。",
|
||
(
|
||
"场景与光线:用户已指定时严格沿用;否则使用浅灰或米白纯色背景、"
|
||
"柔和均匀棚拍光和干净电商布光。"
|
||
),
|
||
"画质:真人写实、高分辨率,肤色和材质自然,商品为视觉重点,人物与商品比例协调。",
|
||
(
|
||
f"硬失败规避:{negative};禁止多余商品或肢体、畸形手脚、身体扭曲、低清模糊、"
|
||
"过度磨皮、夸张滤镜、水印和边框。"
|
||
),
|
||
(
|
||
"冲突处理优先级:用户要求 > 人物身份 > 商品事实与关键结构 > 正确穿戴或使用及必须可见范围 "
|
||
"> 镜头、背景和美化。"
|
||
),
|
||
]
|
||
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(
|
||
*,
|
||
context: ProductContext,
|
||
user_prompt: str,
|
||
count: int,
|
||
ratio: str,
|
||
product_reference_count: int,
|
||
has_model_portrait: bool = True,
|
||
has_model_triview: bool = False,
|
||
classification: ClassificationResult | None = None,
|
||
trouser_facts: TrouserFacts | None = None,
|
||
) -> TryonPromptPlan:
|
||
"""生成一批确定性提示词;只规划,不调用图片模型。"""
|
||
|
||
normalized_ratio = (ratio or "").strip()
|
||
if not normalized_ratio or ":" not in normalized_ratio:
|
||
raise ValueError("ratio 必须是有效的宽高比字符串,例如 3:4 或 7:10")
|
||
|
||
classification = classification or classify_product_details(context, user_prompt)
|
||
kind = classification.kind
|
||
trouser_facts = trouser_facts or extract_trouser_facts(context, kind, user_prompt)
|
||
references = ReferenceRoles.create(
|
||
product_count=product_reference_count,
|
||
has_model_portrait=has_model_portrait,
|
||
has_model_triview=has_model_triview,
|
||
)
|
||
shots = plan_shots(kind, count, user_prompt, trouser_facts)
|
||
policy = _POLICIES[kind]
|
||
prompts = tuple(
|
||
_build_prompt(
|
||
context=context,
|
||
kind=kind,
|
||
policy=policy,
|
||
references=references,
|
||
user_prompt=user_prompt,
|
||
ratio=normalized_ratio,
|
||
shot=shot,
|
||
trouser_facts=trouser_facts,
|
||
)
|
||
for shot in shots
|
||
)
|
||
return TryonPromptPlan(
|
||
kind,
|
||
classification,
|
||
trouser_facts,
|
||
normalized_ratio,
|
||
references,
|
||
shots,
|
||
prompts,
|
||
)
|