660 lines
32 KiB
Python
660 lines
32 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}是同一商品的真实商品图,"
|
|
f"{self.model_label}。商品图只决定商品外观,人物图只决定人物身份与体型。"
|
|
"用户原文若使用了与实际上传顺序不同的‘图1/图2’编号,不按数字机械执行:"
|
|
"人物、女生、模特相关要求统一对应人物参考图,商品、衣物、配件相关要求统一对应商品参考图。"
|
|
)
|
|
|
|
|
|
@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 = ("全身", "从头到脚", "头到脚", "双腿完整", "脚部完整", "完整站姿")
|
|
|
|
|
|
_POLICIES: dict[ProductKind, DisplayPolicy] = {
|
|
ProductKind.LOWER: DisplayPolicy(
|
|
placement="商品必须真实、自然地穿在模特下半身,位置和人体结构正确,不得手持、悬挂或摆在身旁",
|
|
visibility="完整展示腰线、门襟、口袋、臀腿版型、裤腿或裙摆、长度和垂坠感;双腿及下装末端不得出画",
|
|
shots=(
|
|
"正面全身站姿,从头到脚完整入镜,双腿自然分开,清楚展示下装正面版型",
|
|
"三分之四角度全身站姿,从头到脚完整入镜,展示腰臀过渡、腿部轮廓与垂坠感",
|
|
"侧面全身站姿,从头到脚完整入镜,展示腰线、侧缝、裤长或裙长",
|
|
"背面全身站姿,从头到脚完整入镜,展示后腰、后袋、臀腿和下装末端",
|
|
),
|
|
negative="禁止裁掉双腿、脚部、腰头或下装末端;禁止把下装改成手持商品",
|
|
),
|
|
ProductKind.UPPER: DisplayPolicy(
|
|
placement="商品必须真实、自然地穿在模特上半身,正确替换对应上衣,不覆盖无关服装区域",
|
|
visibility="清楚展示领口、肩线、袖型、门襟、下摆、图案和面料垂感",
|
|
shots=(
|
|
"正面站姿,腰部以上完整入镜,双臂自然放松,展示上衣正面结构",
|
|
"三分之四角度站姿,腰部以上完整入镜,展示肩线、袖型和衣身轮廓",
|
|
"侧面站姿,腰部以上完整入镜,展示侧缝、衣长和面料垂感",
|
|
"背面站姿,腰部以上完整入镜,展示后领、后背和下摆",
|
|
),
|
|
negative="禁止把上衣拿在手中或叠放展示;禁止遮挡领口、袖口和下摆",
|
|
),
|
|
ProductKind.FULL_BODY: DisplayPolicy(
|
|
placement="商品必须作为一件完整服装真实穿在模特身上,肩部、腰部与下摆位置符合人体结构",
|
|
visibility="从肩部到服装下摆完整展示,连体结构、腰线、长度和整体廓形不得被裁切",
|
|
shots=(
|
|
"正面全身站姿,从头到脚完整入镜,展示服装完整正面廓形",
|
|
"三分之四角度全身站姿,从头到脚完整入镜,展示腰线和整体垂坠感",
|
|
"侧面全身站姿,从头到脚完整入镜,展示侧面轮廓与服装长度",
|
|
"背面全身站姿,从头到脚完整入镜,展示完整后背结构与下摆",
|
|
),
|
|
negative="禁止截断服装主体或下摆;禁止拆成上下两件或擅自改变连体结构",
|
|
),
|
|
ProductKind.UNDERWEAR: DisplayPolicy(
|
|
placement="商品必须作为贴身服饰正确穿着,肩带、罩杯、腰头等结构与身体位置自然贴合",
|
|
visibility="在合规、克制的电商展示中清楚呈现商品轮廓、边缘、面料和支撑结构",
|
|
shots=(
|
|
"正面自然站姿,商品完整入镜,姿态克制,展示正面结构和贴合度",
|
|
"三分之四角度自然站姿,商品完整入镜,展示侧翼、肩带或腰线",
|
|
"侧面自然站姿,商品完整入镜,展示厚度、包覆和贴合轮廓",
|
|
"背面自然站姿,商品完整入镜,展示背带、搭扣或后腰结构",
|
|
),
|
|
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="禁止同时执行互斥的穿着、佩戴或手持关系;禁止在无法确认时擅自替换模特服装或改变商品用途",
|
|
),
|
|
}
|
|
|
|
|
|
_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 _full_body_shots(kind: ProductKind) -> tuple[str, str, str, str]:
|
|
subject = {
|
|
ProductKind.FOOTWEAR: "商品与两只鞋",
|
|
ProductKind.LOWER: "下装、双腿与脚部",
|
|
ProductKind.FULL_BODY: "整件服装与下摆",
|
|
}.get(kind, "人物与商品")
|
|
return (
|
|
f"正面全身站姿,从头到脚完整入镜,{subject}完整清晰",
|
|
f"三分之四角度全身站姿,从头到脚完整入镜,{subject}完整清晰",
|
|
f"侧面全身站姿,从头到脚完整入镜,{subject}完整清晰",
|
|
f"背面全身站姿,从头到脚完整入镜,{subject}完整清晰",
|
|
)
|
|
|
|
|
|
def plan_shots(kind: ProductKind, count: int, user_prompt: str = "") -> tuple[str, ...]:
|
|
if count not in (1, 2, 4):
|
|
raise ValueError("模特上身图生成数量只支持 1、2、4")
|
|
shots = _full_body_shots(kind) if _requires_full_body(user_prompt) else _POLICIES[kind].shots
|
|
return shots[:count]
|
|
|
|
|
|
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}。"
|
|
"文字卖点不是商品外观证据,不得据此臆造参考图中看不到的颜色、结构、材质或功能。"
|
|
)
|
|
|
|
|
|
_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 = "人物要求:使用同一位真人写实模特,人体比例自然,批次内人物身份保持一致。"
|
|
|
|
sections = [
|
|
references.instruction(),
|
|
f"用户硬性要求(高于默认镜头与默认场景):{hard_requirement}",
|
|
identity,
|
|
(
|
|
f"商品保真:{references.product_label}是“{context.title}”外观的唯一事实依据。"
|
|
"严格保持商品颜色、版型、结构、材质纹理、长度、口袋、拉链、缝线、图案、文字与 Logo;"
|
|
"多张商品图需综合理解为同一件商品,不重新设计、不改款、不生成相似款。"
|
|
),
|
|
_editing_boundary_instruction(kind, references),
|
|
(
|
|
"商品关键结构优先:商品参考图中可见的独特轮廓、部件数量与位置关系、开合结构、口袋、"
|
|
"拉链、缝线和五金,优先于姿态变化、修身美化与风格化;同一批每张必须保持为同一款商品。"
|
|
"若商品结构与复杂姿态或镜头变化冲突,减少姿态和镜头变化,绝不牺牲商品关键结构。"
|
|
),
|
|
_selling_points_instruction(context),
|
|
f"穿戴或使用关系:{policy.placement}。",
|
|
f"商品可见性:{policy.visibility}。",
|
|
f"本张镜头:{shot}。",
|
|
_safe_margin_instruction(shot),
|
|
f"输出画幅:严格使用 {ratio} 比例;比例只影响构图,不得因此裁掉必须完整展示的商品部位。",
|
|
(
|
|
"场景与光线:若用户硬性要求指定了背景、场景或光线,严格沿用用户要求;"
|
|
"否则使用浅灰或米白纯色背景、柔和均匀棚拍光、干净克制的电商布光。"
|
|
),
|
|
"画质:真人写实,高分辨率,肤色和面料质感自然,商品为视觉重点,人物与商品比例真实协调。",
|
|
(
|
|
"冲突处理优先级:用户硬性展示要求最高(其中图号按参考图角色语义解析),随后依次为人物身份、"
|
|
"商品身份与关键结构、正确穿戴或使用关系、必须展示区域;姿态、镜头变化、背景装饰和美化不得覆盖上述硬约束。"
|
|
),
|
|
(
|
|
f"禁止项:{policy.negative};禁止换人、改变商品设计、错误 Logo 或乱码文字、多余商品、"
|
|
"多余肢体、畸形手脚、身体扭曲、低清模糊、过度磨皮、夸张滤镜、水印和边框。"
|
|
),
|
|
]
|
|
return "\n".join(section for section in sections if section)
|
|
|
|
|
|
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)
|