776 lines
37 KiB
Python
776 lines
37 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"
|
||
|
||
|
||
@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 TryonPromptPlan:
|
||
kind: ProductKind
|
||
classification: ClassificationResult
|
||
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 plan_shots(kind: ProductKind, count: int, user_prompt: str = "") -> tuple[str, ...]:
|
||
if count not in (1, 2, 4):
|
||
raise ValueError("模特上身图生成数量只支持 1、2、4")
|
||
full_body = _requires_full_body(user_prompt)
|
||
shots = _full_body_shots(kind) if full_body else _POLICIES[kind].shots
|
||
if not _requests_back_view(user_prompt):
|
||
return shots[:count]
|
||
|
||
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")
|
||
|
||
|
||
def _trouser_length_anchor_instruction(
|
||
context: ProductContext,
|
||
kind: ProductKind,
|
||
user_prompt: str,
|
||
) -> str:
|
||
if kind not in {ProductKind.LOWER, ProductKind.FULL_BODY}:
|
||
return ""
|
||
product_blob = " ".join(
|
||
(
|
||
context.title,
|
||
context.category,
|
||
context.description,
|
||
*context.selling_points,
|
||
)
|
||
).lower()
|
||
if any(term in product_blob for term in _NON_TROUSER_PRODUCT_TERMS):
|
||
return ""
|
||
blob = f"{product_blob} {user_prompt or ''}".lower()
|
||
if not any(term in blob for term in _TROUSER_TERMS):
|
||
return ""
|
||
return (
|
||
"裤长人体落点:裤脚相对脚踝、脚背、鞋面或地面的结束位置必须与商品参考图一致;"
|
||
"同批保持腰线、裆位、裤腿宽度、裤脚开口和裤长类别一致,不得在九分裤、长裤、拖地裤之间变化。"
|
||
"姿态和角度只允许产生真实透视,不得改变裤长;无法判断时以商品参考图为准,不臆造固定尺寸。"
|
||
)
|
||
|
||
|
||
_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 _safe_margin_instruction(shot: str) -> str:
|
||
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,
|
||
) -> str:
|
||
hard_requirement = (user_prompt or "").strip() or "生成真实、可用的电商模特商品展示图。"
|
||
if references.model_portrait_number is not None:
|
||
identity = (
|
||
f"保持参考图{references.model_portrait_number}中人物的脸型、五官、发型、肤色、年龄感、"
|
||
"身材比例和自然气质,不换人或混合其他人物特征。"
|
||
)
|
||
else:
|
||
identity = "使用同一位真人写实模特,人体比例和表情自然,批次内身份保持一致。"
|
||
|
||
product_parts = [
|
||
references.instruction(),
|
||
(
|
||
f"{references.product_label}是“{context.title}”的唯一外观依据;保持可见的颜色、版型、材质、"
|
||
"长度、图案、文字与 Logo、独特轮廓、部件数量与位置关系、开合结构、口袋、拉链、缝线和五金;"
|
||
"同批保持同一款,不重新设计、改款或生成相似款。"
|
||
),
|
||
_selling_points_instruction(context),
|
||
f"穿戴或使用:{policy.placement};必须可见:{policy.visibility}。",
|
||
_trouser_length_anchor_instruction(context, kind, user_prompt),
|
||
"商品关键结构优先于姿态、美化和风格;发生冲突时减少姿态和镜头变化,不牺牲参考图可见结构。",
|
||
]
|
||
person_parts = [identity, _editing_boundary_instruction(kind, references)]
|
||
composition_parts = [
|
||
f"用户硬性要求(最高优先,图号按参考图角色解析):{hard_requirement}。",
|
||
f"本张镜头:{shot}。",
|
||
_safe_margin_instruction(shot),
|
||
f"输出画幅:严格使用 {ratio} 比例;比例不得裁掉必须展示的商品部位。",
|
||
(
|
||
"场景与光线:用户已指定时严格沿用;否则使用浅灰或米白纯色背景、"
|
||
"柔和均匀棚拍光和干净电商布光。"
|
||
),
|
||
"画质:真人写实、高分辨率,肤色和材质自然,商品为视觉重点,人物与商品比例协调。",
|
||
(
|
||
f"硬失败规避:{policy.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,
|
||
) -> 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
|
||
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)
|
||
policy = _POLICIES[kind]
|
||
prompts = tuple(
|
||
_build_prompt(
|
||
context=context,
|
||
kind=kind,
|
||
policy=policy,
|
||
references=references,
|
||
user_prompt=user_prompt,
|
||
ratio=normalized_ratio,
|
||
shot=shot,
|
||
)
|
||
for shot in shots
|
||
)
|
||
return TryonPromptPlan(kind, classification, normalized_ratio, references, shots, prompts)
|