fix: 优化裤装上身图裤长与景别
This commit is contained in:
@@ -33,6 +33,23 @@ class ProductKind(str, Enum):
|
||||
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
|
||||
@@ -159,10 +176,64 @@ class ClassificationResult:
|
||||
)
|
||||
|
||||
|
||||
@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, ...]
|
||||
@@ -551,13 +622,45 @@ def _back_view_shot(kind: ProductKind, *, full_body: bool) -> str:
|
||||
return shot
|
||||
|
||||
|
||||
def plan_shots(kind: ProductKind, count: int, user_prompt: str = "") -> tuple[str, ...]:
|
||||
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)
|
||||
shots = _full_body_shots(kind) if full_body else _POLICIES[kind].shots
|
||||
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[:count]
|
||||
return shots
|
||||
|
||||
back_shot = _back_view_shot(kind, full_body=full_body)
|
||||
if count == 1:
|
||||
@@ -601,33 +704,118 @@ def _selling_points_instruction(context: ProductContext) -> str:
|
||||
|
||||
_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(
|
||||
context: ProductContext,
|
||||
kind: ProductKind,
|
||||
user_prompt: str,
|
||||
facts: TrouserFacts | None,
|
||||
) -> str:
|
||||
if kind not in {ProductKind.LOWER, ProductKind.FULL_BODY}:
|
||||
if facts is None:
|
||||
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 (
|
||||
"裤长人体落点:裤脚相对脚踝、脚背、鞋面或地面的结束位置必须与商品参考图一致;"
|
||||
"同批保持腰线、裆位、裤腿宽度、裤脚开口和裤长类别一致,不得在九分裤、长裤、拖地裤之间变化。"
|
||||
"姿态和角度只允许产生真实透视,不得改变裤长;无法判断时以商品参考图为准,不臆造固定尺寸。"
|
||||
)
|
||||
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] = {
|
||||
@@ -664,7 +852,42 @@ def _editing_boundary_instruction(kind: ProductKind, references: ReferenceRoles)
|
||||
)
|
||||
|
||||
|
||||
def _safe_margin_instruction(shot: str) -> str:
|
||||
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% 安全留白,"
|
||||
@@ -684,6 +907,7 @@ def _build_prompt(
|
||||
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:
|
||||
@@ -694,6 +918,9 @@ def _build_prompt(
|
||||
else:
|
||||
identity = "使用同一位真人写实模特,人体比例和表情自然,批次内身份保持一致。"
|
||||
|
||||
visibility = _visibility_instruction(policy, shot, trouser_facts)
|
||||
negative = _negative_instruction(policy, shot, trouser_facts)
|
||||
|
||||
product_parts = [
|
||||
references.instruction(),
|
||||
(
|
||||
@@ -702,15 +929,15 @@ def _build_prompt(
|
||||
"同批保持同一款,不重新设计、改款或生成相似款。"
|
||||
),
|
||||
_selling_points_instruction(context),
|
||||
f"穿戴或使用:{policy.placement};必须可见:{policy.visibility}。",
|
||||
_trouser_length_anchor_instruction(context, kind, user_prompt),
|
||||
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),
|
||||
_safe_margin_instruction(shot, trouser_facts),
|
||||
f"输出画幅:严格使用 {ratio} 比例;比例不得裁掉必须展示的商品部位。",
|
||||
(
|
||||
"场景与光线:用户已指定时严格沿用;否则使用浅灰或米白纯色背景、"
|
||||
@@ -718,7 +945,7 @@ def _build_prompt(
|
||||
),
|
||||
"画质:真人写实、高分辨率,肤色和材质自然,商品为视觉重点,人物与商品比例协调。",
|
||||
(
|
||||
f"硬失败规避:{policy.negative};禁止多余商品或肢体、畸形手脚、身体扭曲、低清模糊、"
|
||||
f"硬失败规避:{negative};禁止多余商品或肢体、畸形手脚、身体扭曲、低清模糊、"
|
||||
"过度磨皮、夸张滤镜、水印和边框。"
|
||||
),
|
||||
(
|
||||
@@ -744,6 +971,7 @@ def build_tryon_prompt_plan(
|
||||
has_model_portrait: bool = True,
|
||||
has_model_triview: bool = False,
|
||||
classification: ClassificationResult | None = None,
|
||||
trouser_facts: TrouserFacts | None = None,
|
||||
) -> TryonPromptPlan:
|
||||
"""生成一批确定性提示词;只规划,不调用图片模型。"""
|
||||
|
||||
@@ -753,12 +981,13 @@ def build_tryon_prompt_plan(
|
||||
|
||||
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)
|
||||
shots = plan_shots(kind, count, user_prompt, trouser_facts)
|
||||
policy = _POLICIES[kind]
|
||||
prompts = tuple(
|
||||
_build_prompt(
|
||||
@@ -769,7 +998,16 @@ def build_tryon_prompt_plan(
|
||||
user_prompt=user_prompt,
|
||||
ratio=normalized_ratio,
|
||||
shot=shot,
|
||||
trouser_facts=trouser_facts,
|
||||
)
|
||||
for shot in shots
|
||||
)
|
||||
return TryonPromptPlan(kind, classification, normalized_ratio, references, shots, prompts)
|
||||
return TryonPromptPlan(
|
||||
kind,
|
||||
classification,
|
||||
trouser_facts,
|
||||
normalized_ratio,
|
||||
references,
|
||||
shots,
|
||||
prompts,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user