Files
yingqing/core/backend/apps/ai/tryon_prompt.py
T

1014 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模特上身图结构化提示词规划器(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,
)