"""模特上身图结构化提示词规划器(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, )