fix: 优化裤装上身图裤长与景别

This commit is contained in:
hh
2026-07-15 00:22:36 +08:00
parent 1a40a8cb4b
commit ca3b09c62e
4 changed files with 612 additions and 83 deletions
+270 -32
View File
@@ -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,
)