ecommerce-material-studio
电商素材工坊(中英双语)。用户需要生成电商主图、详情图、场景图、产品图合成、品牌叠加时使用。自动识别品类→匹配风格→场景感知合成→统一文字→自动质检→多平台适配→标准化交付。E-commerce product image studio: category detection, style matching, scene-aware compositing, text overlay, quality check, multi-platform adaptation, batch delivery.
Install
npx skills add https://github.com/davepoon/buildwithclaude/tree/main/plugins/all-skills/skills/ecommerce-material-studio
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install davepoon-buildwithclaude@llmmart
git clone https://github.com/davepoon/buildwithclaude.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole davepoon/buildwithclaude collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
电商素材工坊 / E-commerce Material Studio
生成电商产品素材(主图/详情图/场景图)的一站式工具链。输入产品图片 → 自动完成品类识别、风格匹配、场景合成、文字叠加、质检、多平台适配、批量交付。
One-stop pipeline for e-commerce product images: category detection → style matching → scene compositing → text overlay → quality check → platform adaptation → batch delivery.
何时使用 / When to use
- 用户需要生成电商主图/详情图(如"帮我做一套剃须刀主图")
- 需要产品图合成到场景(白底图 → 场景图)
- 需要品牌叠加(Logo/保障条/卖点文字/徽章)
- 需要多平台尺寸适配(淘宝/快手/抖音/拼多多/京东等 7 平台)
- 需要批量生成多个产品素材
使用流程 / Workflow
# 0. 环境依赖(一次性)
pip install Pillow numpy scipy
# 1. 品类识别:输入产品图 → 识别品类/风格
python3 scripts/category_detector.py --image product.png
# 2. 风格匹配:品类+价位+平台+品牌 → 推荐模板
python3 scripts/style_matcher.py --category 个护电器 --sub-category 剃须刀 --price 169 --platform kuaishou
# 3. 场景感知合成(核心):库调用(SceneAwareCompositor 是 Python 库,非 CLI)
python3 -c "
from PIL import Image
from scripts.scene_aware_compositor import SceneAwareCompositor
c = SceneAwareCompositor()
scene = Image.open('scene.jpg'); product = Image.open('product.png')
result = c.composite(scene_image=scene, product_image=product, scene_type='lifestyle_bathroom', position=(0.5, 0.45))
result.save('result.png')
"
# 4. 统一文字叠加(处理 plan.json 里所有文字层)
python3 scripts/text_engine.py --plan output/plan.json --brand langke --scene-tone dark
# 5. 自动质检(读取 plan.json + 检查成品图)
python3 scripts/quality_check.py --plan output/plan.json
# 6. 多平台尺寸适配
python3 scripts/platform_adapter.py --input-dir ./output --platforms kuaishou --output-dir ./platform_output
# 7. 标准化交付打包(自动生成使用指南+清单+zip)
python3 scripts/delivery_packager.py --project-dir ./output --product-name "示例产品"
# 8. 批量处理(多产品,断点续传)
python3 scripts/batch_processor.py --input products.json --output-dir ./batch --prepare
模块清单 / Modules
| 模块 | 功能 | 依赖 |
|---|---|---|
category_detector.py |
品类识别(色调/材质→子品类) | Pillow |
style_matcher.py |
风格匹配(品类+价位+平台+品牌→模板) | 无 |
brand_loader.py |
品牌配置加载(多品牌/Logo选择) | 无 |
text_engine.py |
统一文字引擎(z-index/避让/对比度) | Pillow |
quality_check.py |
自动质检(分辨率/可读性/Logo/完整/重叠) | Pillow |
preference_memory.py |
偏好记忆(跨项目复用风格) | 无 |
batch_processor.py |
批量处理(断点续传/重试/报告) | 无 |
platform_adapter.py |
7 平台尺寸适配(resize/crop/压缩) | Pillow |
delivery_packager.py |
交付打包(使用指南+清单+zip) | Pillow |
layout_engine.py |
布局引擎(物理尺寸→像素比例) | 无 |
scene_aware_compositor.py |
场景感知合成(参照物尺度/透视/景深) | Pillow+numpy+scipy |
数据文件 / Data (references/)
category_templates.json— 12 品类场景模板库(推荐场景/prompt/配色/文字风格)product_profiles.json— 产品档案库(示例:example_shaver)brand_profiles/langke.json— 示例品牌配置(朗科=示例品牌,非真实)brand_config_template.json— 新建品牌模板user_preferences.json— 偏好记忆库(模板)
注意事项 / Notes
- 字体:
text_engine.py需要中文字体(macOS:/System/Library/Fonts/PingFang.ttc,Linux: NotoSansCJK,Windows: msyh.ttc)——按需修改FONT_PATHS_BOLD/FONT_PATHS_REGULAR常量 - 品牌配置:用
brand_config_template.json新建自己的品牌(含 Logo 路径/色系/保障条) - 合成模式:
scene_aware_compositor.py支持场景感知模式(自动算尺度)和兼容模式(固定 scale) - 参考数据中的"朗科/LangKe"为示例品牌,可直接替换为自己的品牌配置
Files (buildwithclaude)
-
references
-
brand_profiles
-
langke.json 923 B
{ "brand_name": "LangKe", "brand_cn": "朗科", "logo_variants": { "white": { "path": "brand_logos/langke/white_cn_left.png", "scene_tone": "dark", "description": "白色中文左对齐Logo,适用于深色场景" }, "blue": { "path": "brand_logos/langke/blue_cn_left.png", "scene_tone": "light", "description": "蓝色中文左对齐Logo,适用于浅色场景" } }, "colors": { "primary": "#0066CC", "accent": "#D4AF6A", "text_on_dark": "#FFFFFF", "text_on_light": "#1A1A1A" }, "guarantee_bar": { "labels": ["官方正品", "全国联保", "售后无忧", "现货速发"], "style": "rounded_pill" }, "style_constraints": { "forbidden_elements": ["过于花哨的装饰", "非品牌色系的渐变"], "preferred_scenes": ["tech_gradient", "lifestyle_bathroom", "minimalist"], "tone_range": ["dark", "light"] } }
-
-
brand_config_template.json 798 B
{ "brand_name": "示例品牌", "logo": { "path": "/path/to/your/logo.png", "max_width_ratio": 0.25, "slogan": "品牌slogan(可选,显示在logo下方)" }, "badge": { "path": "/path/to/your/badge.png", "max_width_ratio": 0.14 }, "guarantee_bar": { "labels": ["官方正品", "全国联保", "售后无忧", "现货速发"], "height_ratio": 0.055 }, "colors": { "accent": "#D4AF6A", "text_primary": "#FFFFFF", "text_secondary": "#CCCCCC", "bar_bg_dark": "#0A0A0F", "bar_bg_light": "#FFFFFF", "bar_text_dark": "#C8C8C8", "bar_text_light": "#505050" }, "logo_margin_ratio": 0.03, "brand_zone_top_ratio": 0.14, "content_zone_bottom_ratio": 0.88, "guarantee_zone_bottom_ratio": 0.98, "safe_margin_ratio": 0.02 } -
category_templates.json 14.7 KB
{ "version": "1.0", "description": "品类场景模板库 — 不同品类的最佳场景风格、色调、光影经验", "categories": { "剃须刀": { "scene_style": "tech_gradient", "scene_prompt_keywords": [ "dark gray gradient", "blue glow", "sleek modern surface" ], "color_palette": { "primary": "#1a1a2e", "accent": "#c9a84c", "text": "#ffffff" }, "lighting": "侧光+顶部聚光,金属质感", "background_style": "深色系科技感,突出产品金属质感", "text_style": "金色/白色粗体无衬线", "best_practices": [ "暗色背景比浅色更能突出剃须刀质感", "金色文字搭配深色背景提升高端感", "产品正面或45°侧面最佳,展示刀网纹理", "Type-C口/按键特写适合细节图" ], "design_principles": { "1_product_size": { "rule": "产品占画面≥40%宽度", "application": "主图产品居中,占画面高度60%以上;细节图产品占满画面" }, "2_unique_selling_point": { "rule": "每张图只打一个核心卖点", "suggested_points": [ "90天续航", "Type-C快充", "IPX7全身水洗", "70g轻巧便携", "旅行锁" ] }, "3_quantified_proof": { "rule": "优先用数字而非形容词", "examples": { "好": "90天续航", "差": "超长续航" }, "examples_2": { "好": "70g轻巧", "差": "非常轻便" } }, "4_scene_immersion": { "rule": "用户买的是场景,不是产品", "target_scenes": [ "商务出差-行李箱旁", "晨间洗漱-洗手台", "旅行途中-酒店浴室", "送礼场景-精美包装" ] }, "5_strong_contrast": { "rule": "问题vs解决方案并置", "contrast_examples": [ { "problem": "出差3天没电", "solution": "90天续航" }, { "problem": "充电线杂乱", "solution": "Type-C通用" }, { "problem": "清洗麻烦", "solution": "IPX7全身水洗" } ] }, "6_three_color_limit": { "rule": "主图≤3种颜色", "palette_guidance": "深色背景(#1a1a2e)+金色点缀(#c9a84c)+白色文字(#ffffff)", "avoid": "避免超过3种颜色,颜色越少信息越清晰" }, "7_human_element": { "rule": "可用人物吸引眼球,但不能抢产品风头", "suggested_usage": [ "手持特写(不露脸,聚焦产品)", "手部握持展示尺寸感", "避免:完整人物/模特面部" ] }, "8_problem_driven": { "rule": "先讲问题,再讲产品如何解决", "story_structure": [ "痛点场景→产品出场→卖点展示→结果呈现" ], "example_flow": "出差没电尴尬→示例机型-2218出场→90天续航特写→自信出行" } }, "three_directions": { "direction_1": { "name": "白底清爽版", "description": "纯白/浅灰背景,产品居中,干净利落", "use_case": "电商平台主图、详情页首屏", "focus": "产品外观+核心参数" }, "direction_2": { "name": "场景代入版", "description": "商务出差/晨间洗漱场景,产品融入生活", "use_case": "种草图、社交媒体", "focus": "使用场景+情感共鸣" }, "direction_3": { "name": "痛点对比版", "description": "问题vs解决方案对比,突出卖点", "use_case": "详情页中段、转化图", "focus": "量化卖点+前后对比" } }, "source": "示例机型-2218实战经验+Cooper 8原则", "verified": true, "updated": "2026-07-29" }, "空气炸锅": { "scene_style": "warm_wood", "scene_prompt_keywords": [ "warm wooden kitchen counter", "soft warm lighting", "cozy kitchen" ], "color_palette": { "primary": "#3d2b1f", "accent": "#e8a87c", "text": "#ffffff" }, "lighting": "暖色调厨房灯光,营造家的感觉", "background_style": "暖色木纹+厨具点缀,强调生活场景", "text_style": "白色或奶黄色,温暖亲切", "best_practices": [ "暖色厨房场景最贴合使用场景", "搭配食材道具增加生活感", "俯视角度展示锅篮容量", "强调'健康烹饪'卖点" ], "source": "行业通用经验", "verified": false }, "护肤品": { "scene_style": "topdown_greenery", "scene_prompt_keywords": [ "mint green surface", "succulent plants", "clean minimalist" ], "color_palette": { "primary": "#2d5a27", "accent": "#a8d8a8", "text": "#1a1a1a" }, "lighting": "自然柔和日光,清爽感", "background_style": "清新绿色系+白色,强调天然成分", "text_style": "深绿色或深灰色,自然清新", "best_practices": [ "绿植点缀暗示天然成分", "俯视平铺展示系列产品", "瓶身倒影增加精致感", "避免过于花哨的背景" ], "source": "行业通用经验", "verified": false }, "3C数码": { "scene_style": "tech_gradient", "scene_prompt_keywords": [ "dark gradient", "subtle blue glow", "sleek surface" ], "color_palette": { "primary": "#0a0a0a", "accent": "#4a9eff", "text": "#ffffff" }, "lighting": "冷色调+蓝色氛围光,科技感", "background_style": "深色渐变,突出产品发光效果", "text_style": "白色+科技蓝点缀", "best_practices": [ "深色背景让屏幕/LED效果更突出", "蓝色光晕增加科技氛围", "侧面/45°展示产品厚度", "接口特写展示功能性" ], "source": "行业通用经验", "verified": false }, "卫浴用品": { "scene_style": "lifestyle_bathroom", "scene_prompt_keywords": [ "white bathroom counter", "green plant", "folded towel", "bright daylight" ], "color_palette": { "primary": "#ffffff", "accent": "#4a90d9", "text": "#333333" }, "lighting": "明亮自然光,干净清爽", "background_style": "白色浴室台面+绿植+毛巾,生活场景", "text_style": "深灰或深蓝色,干净利落", "best_practices": [ "浴室场景最直观展示使用场景", "搭配绿植和毛巾增加生活感", "白色为主保持干净感", "防水/水洗卖点可搭配水滴效果" ], "source": "行业通用经验", "verified": false }, "小家电通用": { "scene_style": "modern_marble", "scene_prompt_keywords": [ "white marble", "subtle gray veining", "clean bright" ], "color_palette": { "primary": "#f5f5f5", "accent": "#333333", "text": "#1a1a1a" }, "lighting": "均匀明亮,产品细节清晰", "background_style": "白色大理石,高端简洁", "text_style": "黑色或深灰色,清晰可读", "best_practices": [ "白色背景百搭,适合不确定场景时", "大理石纹理比纯白更有质感", "产品阴影要自然,避免生硬", "保持画面简洁,产品是主角" ], "source": "行业通用经验", "verified": false }, "美妆护肤": { "scene_style": "modern_marble", "scene_prompt_keywords": [ "white marble vanity", "soft natural light", "rose gold accents", "fresh flowers" ], "color_palette": { "primary": "#fdf2f8", "accent": "#d4a5a5", "text": "#2d2d2d" }, "lighting": "柔和自然光+微微粉色氛围,营造精致感", "background_style": "白色/浅粉大理石台面,搭配鲜花、绿叶、镜子等精致道具", "text_style": "深灰/玫瑰金色,优雅衬线或细体无衬线", "best_practices": [ "浅色干净背景突出产品质感", "搭配鲜花/绿叶暗示天然成分", "瓶身倒影增加精致感", "平铺展示系列产品更有说服力", "避免过于花哨的背景抢产品风头", "玫瑰金/裸粉色调最适合美妆" ], "source": "行业通用经验", "verified": false }, "食品": { "scene_style": "warm_wood", "scene_prompt_keywords": [ "rustic wooden table", "warm golden light", "fresh ingredients scattered", "appetizing food styling" ], "color_palette": { "primary": "#5c3d2e", "accent": "#e8a87c", "text": "#ffffff" }, "lighting": "暖色调45°侧光,营造食材质感和食欲", "background_style": "原木/ rustic 木桌+新鲜食材点缀+暖色灯光", "text_style": "白色或暖黄色粗体,有食欲感", "best_practices": [ "暖色调是食品的灵魂,冷色会让人倒胃口", "搭配新鲜食材(蔬菜/水果/香料)增加新鲜感", "蒸汽/烟雾效果增加食欲感(AI可后期加)", "俯视角度展示菜品全貌最佳", "红色/橙色系促进食欲", "包装食品要展示正面+背面成分表" ], "source": "行业通用经验", "verified": false }, "服装鞋包": { "scene_style": "minimalist", "scene_prompt_keywords": [ "clean minimal backdrop", "soft gradient", "fashion editorial style", "elegant lighting" ], "color_palette": { "primary": "#f5f0eb", "accent": "#8b7355", "text": "#2d2d2d" }, "lighting": "柔和棚拍光+侧面补光,突出面料质感", "background_style": "极简纯色/渐变背景,高级感留白", "text_style": "深灰/黑色,简洁高级的无衬线字体", "best_practices": [ "极简背景让产品成为唯一焦点", "服装需要展示细节(面料纹理/走线/纽扣)", "鞋子45°侧拍展示轮廓线条", "包袋打开展示内部结构", "莫兰迪色系百搭不挑品类", "可以用衣架/模特半身(不露脸)展示版型" ], "source": "行业通用经验", "verified": false }, "母婴用品": { "scene_style": "topdown_greenery", "scene_prompt_keywords": [ "soft pastel surface", "mint green and cream tones", "cute toys as props", "gentle diffused light" ], "color_palette": { "primary": "#e8f5e9", "accent": "#81c784", "text": "#333333" }, "lighting": "柔和漫射光,温暖安全感", "background_style": "浅粉/浅绿/奶油色系,搭配可爱玩偶/积木等婴儿元素", "text_style": "深灰/深绿色,圆润亲和的字体", "best_practices": [ "马卡龙/莫兰迪色系传递安全温暖感", "搭配可爱道具(小熊/积木/奶瓶)增加场景感", "避免尖锐/冷色调元素", "产品材质感(柔软/安全/无毒)要通过画面传达", "妈妈的手+宝宝的手是最佳参照物", "成分/安全认证标识要清晰展示" ], "source": "行业通用经验", "verified": false }, "家居家装": { "scene_style": "warm_wood", "scene_prompt_keywords": [ "cozy living room setting", "warm natural light through window", "neutral tones", "scandinavian interior" ], "color_palette": { "primary": "#f5f0e8", "accent": "#c4956a", "text": "#2d2d2d" }, "lighting": "自然窗光+暖色台灯,营造家的氛围", "background_style": "北欧/日式简约家居场景,木质+棉麻+绿植", "text_style": "深棕/深灰色,自然温暖的字体", "best_practices": [ "家居品要融入真实家居场景", "北欧/日式简约风格最受电商用户欢迎", "搭配棉麻/陶瓷/绿植等自然材质", "尺寸感很重要(搭配沙发/桌子等参照物)", "暖色调比冷色更有购买欲", "展示产品在空间中的比例关系" ], "source": "行业通用经验", "verified": false }, "数码配件": { "scene_style": "tech_gradient", "scene_prompt_keywords": [ "dark matte surface", "subtle neon accent glow", "clean desk setup", "modern tech aesthetic" ], "color_palette": { "primary": "#1a1a2e", "accent": "#4a9eff", "text": "#ffffff" }, "lighting": "冷色调LED氛围光+边缘高光,科技感", "background_style": "深色桌面+科技配件点缀+蓝色/紫色氛围灯", "text_style": "白色+科技蓝,简洁现代的无衬线字体", "best_practices": [ "深色背景让产品发光效果更突出", "搭配手机/笔记本/键盘等数码配件做参照", "充电线/接口的特写很重要", "蓝色/紫色氛围光增加科技高级感", "展示产品在实际使用场景中的位置", "参数可视化(充电速度/电池容量用图形展示)" ], "source": "行业通用经验", "verified": false } }, "metadata": { "last_updated": "2026-08-07", "total_categories": 12, "verified_entries": 1, "framework_version": "Cooper 8原则 + 3方向工作流 v1.0", "contribution_log": [ { "date": "2026-07-29", "category": "剃须刀", "source_product": "示例品牌示例机型-2218", "framework": "Cooper 8原则", "experience": "融入8设计原则(产品大/卖点唯一/数字证明/场景代入/强烈对比/三色原则/人物系/问题驱动)+ 3方向出图(白底/场景/对比)" }, { "date": "2026-07-27", "category": "剃须刀", "source_product": "示例品牌示例机型-2218", "experience": "暗色科技风+金色文字效果好;产品必须用实拍图保真,AI生成容易变形;rembg去底+PIL合成比img2img更可控" } ] } } -
product_profiles.json 991 B
{ "version": "1.0", "description": "产品档案库 — 记录每次制作的产品信息、使用参数和效果评价", "products": { "example_shaver": { "brand": "ExampleBrand", "model": "ES-100", "category": "剃须刀", "name_cn": "示例电动剃须刀", "physical": { "width_mm": 74, "height_mm": 39, "depth_mm": 39, "weight_g": 70 }, "key_selling_points": [ "Type-C快充", "长续航", "全身水洗", "轻巧便携" ], "price": 99, "target_audience": "示例人群", "production_history": [ { "date": "2026-01-01", "task": "示例:全套11张电商素材", "scenes_used": [ "minimalist", "tech_gradient", "warm_wood" ] } ] } }, "metadata": { "last_updated": "2026-07-27", "total_products": 1, "total_productions": 1 } } -
user_preferences.json 231 B
{ "version": "1.0", "description": "用户偏好记忆库 — 记录每次项目的风格偏好,跨项目复用", "preferences": [], "metadata": { "created_at": "", "last_updated": "", "total_entries": 0 } }
-
-
scripts
-
batch_processor.py 23.7 KB
#!/usr/bin/env python3 """ batch_processor.py — 批量处理引擎 ================================== 批量处理多个产品的素材生成,支持进度追踪、断点续传和失败重试。 核心职责: 1. 解析产品列表 2. 为每个产品调用 category_detector + style_matcher 生成配置 3. 输出标准化的任务队列 4. 追踪完成状态(实时更新状态文件) 5. 支持断点续传和失败重试 注意: 本脚本负责流程编排和输入准备,实际的生图和文字渲染 由主流程的 AI agent 执行。 Usage: # 生成任务队列(预处理阶段) python scripts/batch_processor.py \\ --input products.json \\ --output-dir ./batch_output \\ --status-file status.json \\ --prepare # 查看当前状态 python scripts/batch_processor.py \\ --status-file status.json \\ --status # 标记产品完成 python scripts/batch_processor.py \\ --status-file status.json \\ --mark-complete product_001 \\ --output-subdir ./batch_output/product_001 \\ --images-count 11 # 标记产品失败 python scripts/batch_processor.py \\ --status-file status.json \\ --mark-failed product_002 \\ --error "AI融合生图超时" # 获取下一个待处理产品 python scripts/batch_processor.py \\ --status-file status.json \\ --next # 重试失败的产品 python scripts/batch_processor.py \\ --status-file status.json \\ --retry-failed # 生成汇总报告 python scripts/batch_processor.py \\ --status-file status.json \\ --report 依赖: 无外部依赖(纯Python + json) """ import argparse import json import os import sys import subprocess from pathlib import Path from datetime import datetime from typing import Dict, List, Optional, Any # ============================================================================ # 路径常量 # ============================================================================ SKILL_DIR = Path(__file__).parent.parent SCRIPTS_DIR = SKILL_DIR / "scripts" # ============================================================================ # 状态管理 # ============================================================================ def create_status_file(batch_id: str, products: List[dict], status_file: str) -> dict: """ 创建初始状态文件。 Args: batch_id: 批次ID products: 产品列表 status_file: 状态文件路径 Returns: 状态数据字典 """ status = { "batch_id": batch_id, "created_at": datetime.now().isoformat(), "last_updated": datetime.now().isoformat(), "total": len(products), "completed": 0, "failed": 0, "in_progress": 0, "pending": len(products), "products": [] } for product in products: pid = product.get("id", f"product_{products.index(product)}") status["products"].append({ "id": pid, "product_image": product.get("product_image", ""), "brand": product.get("brand", ""), "model": product.get("model", ""), "category": product.get("category", ""), "price": product.get("price", 0), "platform": product.get("platform", "general"), "style": product.get("style", ""), "selling_points": product.get("selling_points", []), "status": "pending", "output_dir": None, "images_count": 0, "error": None, "current_step": None, "config_file": None, "started_at": None, "completed_at": None, }) _save_status(status, status_file) return status def load_status(status_file: str) -> dict: """加载状态文件""" if not os.path.isfile(status_file): print(f"Error: 状态文件不存在: {status_file}", file=sys.stderr) sys.exit(1) with open(status_file, "r", encoding="utf-8") as f: return json.load(f) def _save_status(status: dict, status_file: str): """保存状态文件""" status["last_updated"] = datetime.now().isoformat() # 重新计算统计 statuses = [p["status"] for p in status["products"]] status["completed"] = statuses.count("completed") status["failed"] = statuses.count("failed") status["in_progress"] = statuses.count("in_progress") status["pending"] = statuses.count("pending") output_path = Path(status_file) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: json.dump(status, f, ensure_ascii=False, indent=2) def load_product_list(input_file: str) -> List[dict]: """ 加载产品列表JSON。 Args: input_file: 产品列表JSON路径 Returns: 产品列表 """ if not os.path.isfile(input_file): print(f"Error: 产品列表文件不存在: {input_file}", file=sys.stderr) sys.exit(1) with open(input_file, "r", encoding="utf-8") as f: products = json.load(f) if not isinstance(products, list): print("Error: 产品列表必须是JSON数组", file=sys.stderr) sys.exit(1) # 验证必填字段 for i, product in enumerate(products): if "product_image" not in product: print(f"Error: 产品[{i}]缺少必填字段 product_image", file=sys.stderr) sys.exit(1) if "id" not in product: product["id"] = f"product_{i+1:03d}" return products # ============================================================================ # 预处理:为每个产品生成配置 # ============================================================================ def prepare_product_config(product: dict, output_dir: str) -> dict: """ 为单个产品生成配置文件(调用category_detector + style_matcher)。 Args: product: 产品配置字典 output_dir: 输出目录 Returns: 配置结果字典 """ pid = product["id"] product_dir = Path(output_dir) / pid product_dir.mkdir(parents=True, exist_ok=True) config = { "product_id": pid, "product_image": product.get("product_image", ""), "brand": product.get("brand", ""), "model": product.get("model", ""), "price": product.get("price", 0), "platform": product.get("platform", "general"), "selling_points": product.get("selling_points", []), } # Step 1: 品类识别(如果用户未指定品类) category_result = None if not product.get("category"): category_file = product_dir / "category.json" cmd = [ sys.executable, str(SCRIPTS_DIR / "category_detector.py"), "--image", product["product_image"], "--output", str(category_file) ] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode == 0 and category_file.exists(): with open(category_file, "r", encoding="utf-8") as f: category_result = json.load(f) config["category"] = category_result.get("category", "家居日用") config["sub_category"] = category_result.get("sub_category", "") config["category_attributes"] = category_result.get("attributes", {}) else: config["category"] = "家居日用" config["sub_category"] = "" config["category_note"] = "品类识别失败,使用默认品类" except Exception as e: config["category"] = "家居日用" config["sub_category"] = "" config["category_note"] = f"品类识别异常: {e}" else: config["category"] = product["category"] config["sub_category"] = product.get("sub_category", product["category"]) # Step 2: 风格匹配 style_result = None if config.get("category"): style_file = product_dir / "style_recommendation.json" cmd = [ sys.executable, str(SCRIPTS_DIR / "style_matcher.py"), "--category", config["category"], "--sub-category", config.get("sub_category", config["category"]), "--price", str(config.get("price", 100)), "--platform", config.get("platform", "general"), "--output", str(style_file) ] if product.get("brand"): cmd.extend(["--brand", product["brand"]]) try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode == 0 and style_file.exists(): with open(style_file, "r", encoding="utf-8") as f: style_result = json.load(f) config["style_recommendation"] = style_result # 如果用户未指定风格,使用推荐的第一模板 if not product.get("style"): templates = style_result.get("recommended_templates", []) if templates: config["style"] = templates[0].get("template_id", "minimalist") except Exception as e: config["style_note"] = f"风格匹配异常: {e}" if not config.get("style"): config["style"] = product.get("style", "minimalist") # 保存配置 config_file = product_dir / "product_config.json" with open(config_file, "w", encoding="utf-8") as f: json.dump(config, f, ensure_ascii=False, indent=2) config["config_file"] = str(config_file) config["output_dir"] = str(product_dir) return config def prepare_batch(product_list: List[dict], output_dir: str, status_file: str) -> dict: """ 批量预处理:为所有产品生成配置并创建状态文件。 Args: product_list: 产品列表 output_dir: 输出根目录 status_file: 状态文件路径 Returns: 状态数据字典 """ batch_id = f"batch_{datetime.now().strftime('%Y%m%d_%H%M%S')}" # 创建状态文件 status = create_status_file(batch_id, product_list, status_file) print(f"批次ID: {batch_id}") print(f"产品总数: {len(product_list)}") print(f"输出目录: {output_dir}") print(f"状态文件: {status_file}") print() # 为每个产品生成配置 for i, product in enumerate(product_list): pid = product["id"] print(f"[{i+1}/{len(product_list)}] 预处理: {pid} ...", end=" ") try: config = prepare_product_config(product, output_dir) # 更新状态文件 for p in status["products"]: if p["id"] == pid: p["config_file"] = config.get("config_file") p["output_dir"] = config.get("output_dir") p["category"] = config.get("category", "") p["style"] = config.get("style", "") break print(f"✓ (品类: {config.get('category', '?')}, 风格: {config.get('style', '?')})") except Exception as e: print(f"✗ ({e})") for p in status["products"]: if p["id"] == pid: p["status"] = "failed" p["error"] = f"预处理失败: {e}" break _save_status(status, status_file) # 打印汇总 print(f"\n预处理完成:") print(f" 成功: {sum(1 for p in status['products'] if p['status'] == 'pending')}") print(f" 失败: {sum(1 for p in status['products'] if p['status'] == 'failed')}") return status # ============================================================================ # 状态操作 # ============================================================================ def show_status(status_file: str): """显示当前状态""" status = load_status(status_file) print(f"=== 批次 {status['batch_id']} ===") print(f"总计: {status['total']} | 完成: {status['completed']} | " f"进行中: {status['in_progress']} | 失败: {status['failed']} | " f"待处理: {status['pending']}") print() for p in status["products"]: status_icon = { "completed": "✅", "in_progress": "🔄", "failed": "❌", "pending": "⏳" }.get(p["status"], "?") line = f" {status_icon} {p['id']}" if p.get("category"): line += f" ({p['category']})" if p.get("current_step"): line += f" [步骤: {p['current_step']}]" if p["status"] == "completed": line += f" → {p.get('images_count', 0)}张图" elif p["status"] == "failed" and p.get("error"): line += f" — {p['error'][:50]}" print(line) def get_next_product(status_file: str) -> Optional[dict]: """ 获取下一个待处理的产品。 Returns: 下一个产品的配置信息,或None """ status = load_status(status_file) for p in status["products"]: if p["status"] == "pending": # 标记为进行中 p["status"] = "in_progress" p["current_step"] = "category_detect" p["started_at"] = datetime.now().isoformat() _save_status(status, status_file) # 输出产品配置(供AI agent读取) if p.get("config_file") and os.path.isfile(p["config_file"]): with open(p["config_file"], "r", encoding="utf-8") as f: config = json.load(f) print(json.dumps(config, ensure_ascii=False, indent=2)) else: print(json.dumps(p, ensure_ascii=False, indent=2)) return p print("所有产品已处理完毕!") return None def mark_complete(status_file: str, product_id: str, output_subdir: str = None, images_count: int = 0): """标记产品处理完成""" status = load_status(status_file) found = False for p in status["products"]: if p["id"] == product_id: p["status"] = "completed" p["completed_at"] = datetime.now().isoformat() p["current_step"] = None if output_subdir: p["output_dir"] = output_subdir p["images_count"] = images_count found = True break if not found: print(f"Error: 未找到产品 {product_id}", file=sys.stderr) sys.exit(1) _save_status(status, status_file) print(f"✅ {product_id} 已标记完成 ({images_count}张图)") def mark_failed(status_file: str, product_id: str, error: str = ""): """标记产品处理失败""" status = load_status(status_file) found = False for p in status["products"]: if p["id"] == product_id: p["status"] = "failed" p["error"] = error p["current_step"] = None found = True break if not found: print(f"Error: 未找到产品 {product_id}", file=sys.stderr) sys.exit(1) _save_status(status, status_file) print(f"❌ {product_id} 已标记失败: {error}") def update_step(status_file: str, product_id: str, step: str): """更新产品当前处理步骤""" status = load_status(status_file) for p in status["products"]: if p["id"] == product_id: p["current_step"] = step break _save_status(status, status_file) print(f"🔄 {product_id} 当前步骤: {step}") def retry_failed(status_file: str) -> List[str]: """ 重置失败的产品为待处理状态。 Returns: 被重置的产品ID列表 """ status = load_status(status_file) retried = [] for p in status["products"]: if p["status"] == "failed": p["status"] = "pending" p["error"] = None p["current_step"] = None p["started_at"] = None p["completed_at"] = None retried.append(p["id"]) _save_status(status, status_file) if retried: print(f"已重置 {len(retried)} 个失败产品为待处理:") for pid in retried: print(f" ⏳ {pid}") else: print("没有失败的产品需要重试") return retried def generate_report(status_file: str) -> dict: """ 生成批次处理汇总报告。 Returns: 报告字典 """ status = load_status(status_file) completed = [p for p in status["products"] if p["status"] == "completed"] failed = [p for p in status["products"] if p["status"] == "failed"] pending = [p for p in status["products"] if p["status"] == "pending"] total_images = sum(p.get("images_count", 0) for p in completed) report = { "batch_id": status["batch_id"], "created_at": status.get("created_at", ""), "last_updated": status.get("last_updated", ""), "summary": { "total": status["total"], "completed": len(completed), "failed": len(failed), "pending": len(pending), "success_rate": f"{len(completed)/max(status['total'],1)*100:.1f}%", "total_images": total_images, }, "completed_products": [ { "id": p["id"], "category": p.get("category", ""), "style": p.get("style", ""), "images_count": p.get("images_count", 0), "output_dir": p.get("output_dir", ""), } for p in completed ], "failed_products": [ { "id": p["id"], "error": p.get("error", "未知错误"), } for p in failed ], "pending_products": [ {"id": p["id"]} for p in pending ] } # 打印可读报告 print("=" * 60) print(f" 批次处理报告 — {status['batch_id']}") print("=" * 60) print(f" 总产品数: {status['total']}") print(f" 完成: {len(completed)} | 失败: {len(failed)} | 待处理: {len(pending)}") print(f" 成功率: {report['summary']['success_rate']}") print(f" 总产出图数: {total_images}") print() if completed: print("✅ 已完成:") for p in completed: print(f" {p['id']} — {p.get('images_count', 0)}张图 → {p.get('output_dir', '?')}") if failed: print("\n❌ 失败:") for p in failed: print(f" {p['id']} — {p.get('error', '?')}") if pending: print(f"\n⏳ 待处理: {', '.join(p['id'] for p in pending)}") print("=" * 60) return report # ============================================================================ # CLI 入口 # ============================================================================ def main(): parser = argparse.ArgumentParser( description="批量处理引擎 — 多产品素材生成流程编排", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: # 预处理:生成任务队列 python scripts/batch_processor.py \\ --input products.json --output-dir ./batch_output \\ --status-file status.json --prepare # 查看状态 python scripts/batch_processor.py --status-file status.json --status # 获取下一个待处理 python scripts/batch_processor.py --status-file status.json --next # 标记完成 python scripts/batch_processor.py --status-file status.json \\ --mark-complete product_001 --output-subdir ./batch_output/product_001 \\ --images-count 11 # 标记失败 python scripts/batch_processor.py --status-file status.json \\ --mark-failed product_002 --error "生图超时" # 重试失败 python scripts/batch_processor.py --status-file status.json --retry-failed # 生成报告 python scripts/batch_processor.py --status-file status.json --report """ ) # 输入参数 parser.add_argument( "--input", default=None, help="产品列表JSON路径" ) parser.add_argument( "--output-dir", default=None, help="输出根目录" ) parser.add_argument( "--status-file", default=None, help="状态文件路径" ) # 操作模式(互斥) group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--prepare", action="store_true", help="预处理模式:生成任务队列") group.add_argument("--status", action="store_true", help="查看当前状态") group.add_argument("--next", action="store_true", help="获取下一个待处理产品") group.add_argument("--mark-complete", metavar="PRODUCT_ID", help="标记产品处理完成") group.add_argument("--mark-failed", metavar="PRODUCT_ID", help="标记产品处理失败") group.add_argument("--update-step", metavar="STEP", help="更新当前处理步骤(需配合 --product-id)") group.add_argument("--retry-failed", action="store_true", help="重试所有失败产品") group.add_argument("--report", action="store_true", help="生成汇总报告") # 辅助参数 parser.add_argument("--product-id", default=None, help="产品ID(配合 --update-step 使用)") parser.add_argument("--output-subdir", default=None, help="产品输出子目录(配合 --mark-complete 使用)") parser.add_argument("--images-count", type=int, default=0, help="产出图片数(配合 --mark-complete 使用)") parser.add_argument("--error", default=None, help="错误信息(配合 --mark-failed 使用)") args = parser.parse_args() # 验证必要参数 if args.prepare: if not args.input or not args.output_dir or not args.status_file: parser.error("--prepare 模式需要 --input, --output-dir, --status-file") product_list = load_product_list(args.input) prepare_batch(product_list, args.output_dir, args.status_file) elif args.status: if not args.status_file: parser.error("--status 需要 --status-file") show_status(args.status_file) elif args.next: if not args.status_file: parser.error("--next 需要 --status-file") get_next_product(args.status_file) elif args.mark_complete: if not args.status_file: parser.error("--mark-complete 需要 --status-file") mark_complete(args.status_file, args.mark_complete, args.output_subdir, args.images_count) elif args.mark_failed: if not args.status_file: parser.error("--mark-failed 需要 --status-file") mark_failed(args.status_file, args.mark_failed, args.error or "未知错误") elif args.update_step: if not args.status_file or not args.product_id: parser.error("--update-step 需要 --status-file 和 --product-id") update_step(args.status_file, args.product_id, args.update_step) elif args.retry_failed: if not args.status_file: parser.error("--retry-failed 需要 --status-file") retry_failed(args.status_file) elif args.report: if not args.status_file: parser.error("--report 需要 --status-file") generate_report(args.status_file) if __name__ == "__main__": main() -
brand_loader.py 12.9 KB
#!/usr/bin/env python3 """ brand_loader.py — 品牌视觉规范加载器 ===================================== 从 references/brand_profiles/ 加载品牌配置,支持多品牌管理和自动Logo选择。 用法: # 加载品牌配置 python brand_loader.py load --brand langke # 自动选择Logo(根据场景色调) python brand_loader.py select-logo --brand langke --scene-tone dark # 列出已录入品牌 python brand_loader.py list # 导出为 brand_overlay.py 兼容的 config 格式 python brand_loader.py export --brand langke --scene-tone dark --output brand_config.json 依赖: 无外部依赖(纯Python + json) """ import argparse import json import os import sys from pathlib import Path from dataclasses import dataclass, field from typing import Optional, Dict, List # 品牌配置目录 BRAND_PROFILES_DIR = Path(__file__).parent.parent / "references" / "brand_profiles" BRAND_LOGOS_DIR = Path(__file__).parent.parent / "references" / "brand_logos" @dataclass class LogoVariant: """Logo变体配置""" path: str scene_tone: str # "dark" or "light" description: str = "" @property def absolute_path(self) -> str: """获取Logo的绝对路径""" full_path = BRAND_LOGOS_DIR.parent / self.path return str(full_path) @dataclass class GuaranteeBarConfig: """保障条配置""" labels: List[str] = field(default_factory=lambda: ["官方正品", "全国联保", "售后无忧", "现货速发"]) style: str = "rounded_pill" @dataclass class StyleConstraints: """风格约束""" forbidden_elements: List[str] = field(default_factory=list) preferred_scenes: List[str] = field(default_factory=list) tone_range: List[str] = field(default_factory=lambda: ["dark", "light"]) @dataclass class BrandConfig: """品牌完整配置""" brand_name: str brand_cn: str = "" logo_variants: Dict[str, LogoVariant] = field(default_factory=dict) colors: Dict[str, str] = field(default_factory=dict) guarantee_bar: GuaranteeBarConfig = field(default_factory=GuaranteeBarConfig) style_constraints: StyleConstraints = field(default_factory=StyleConstraints) def get_logo_path(self, scene_tone: str = "dark") -> Optional[str]: """ 根据场景色调自动选择合适的Logo Args: scene_tone: "dark" 或 "light" Returns: Logo文件绝对路径,若无匹配则返回None """ # 优先精确匹配 for variant_name, variant in self.logo_variants.items(): if variant.scene_tone == scene_tone: path = variant.absolute_path if Path(path).exists(): return path # 降级:返回第一个存在的 for variant_name, variant in self.logo_variants.items(): path = variant.absolute_path if Path(path).exists(): return path return None def to_overlay_config(self, scene_tone: str = "dark") -> dict: """ 导出为 brand_overlay.py 兼容的配置格式 Args: scene_tone: 场景色调 Returns: 兼容 brand_overlay.py 的 dict """ logo_path = self.get_logo_path(scene_tone) # 确定文字颜色 if scene_tone == "dark": text_primary = self.colors.get("text_on_dark", "#FFFFFF") text_secondary = "#CCCCCC" else: text_primary = self.colors.get("text_on_light", "#1A1A1A") text_secondary = "#505050" return { "brand_name": self.brand_name, "logo": { "path": logo_path or "", "max_width_ratio": 0.25, }, "badge": { "path": "", # 由外部指定 "max_width_ratio": 0.14, }, "guarantee_bar": { "labels": self.guarantee_bar.labels, "height_ratio": 0.055, }, "colors": { "accent": self.colors.get("accent", "#D4AF6A"), "text_primary": text_primary, "text_secondary": text_secondary, "bar_bg_dark": "#0A0A0F", "bar_bg_light": "#FFFFFF", "bar_text_dark": "#C8C8C8", "bar_text_light": "#505050", }, "logo_margin_ratio": 0.03, "brand_zone_top_ratio": 0.14, "content_zone_bottom_ratio": 0.88, "guarantee_zone_bottom_ratio": 0.98, "safe_margin_ratio": 0.02, } def load_brand(brand_name: str) -> BrandConfig: """ 加载品牌配置 Args: brand_name: 品牌名称(不区分大小写,如 "langke", "朗科") Returns: BrandConfig 对象 Raises: FileNotFoundError: 品牌配置文件不存在 """ brand_name_lower = brand_name.lower() # 查找配置文件 profile_path = BRAND_PROFILES_DIR / f"{brand_name_lower}.json" if not profile_path.exists(): # 尝试中文名称匹配 for p in BRAND_PROFILES_DIR.glob("*.json"): with open(p, "r", encoding="utf-8") as f: data = json.load(f) if data.get("brand_cn", "").lower() == brand_name_lower or \ data.get("brand_name", "").lower() == brand_name_lower: profile_path = p break else: raise FileNotFoundError( f"品牌 '{brand_name}' 配置文件不存在。\n" f"已录入品牌目录: {BRAND_PROFILES_DIR}\n" f"可用品牌: {list_brands()}" ) with open(profile_path, "r", encoding="utf-8") as f: data = json.load(f) # 解析 Logo variants logo_variants = {} for name, variant_data in data.get("logo_variants", {}).items(): logo_variants[name] = LogoVariant( path=variant_data.get("path", ""), scene_tone=variant_data.get("scene_tone", "dark"), description=variant_data.get("description", ""), ) # 解析保障条 bar_data = data.get("guarantee_bar", {}) guarantee_bar = GuaranteeBarConfig( labels=bar_data.get("labels", ["官方正品", "全国联保", "售后无忧", "现货速发"]), style=bar_data.get("style", "rounded_pill"), ) # 解析风格约束 sc_data = data.get("style_constraints", {}) style_constraints = StyleConstraints( forbidden_elements=sc_data.get("forbidden_elements", []), preferred_scenes=sc_data.get("preferred_scenes", []), tone_range=sc_data.get("tone_range", ["dark", "light"]), ) return BrandConfig( brand_name=data.get("brand_name", brand_name), brand_cn=data.get("brand_cn", ""), logo_variants=logo_variants, colors=data.get("colors", {}), guarantee_bar=guarantee_bar, style_constraints=style_constraints, ) def auto_select_logo(brand_name: str, scene_tone: str) -> Optional[str]: """ 自动选择合适的Logo Args: brand_name: 品牌名称 scene_tone: 场景色调 ("dark" 或 "light") Returns: Logo文件绝对路径,若无匹配则返回None """ try: config = load_brand(brand_name) return config.get_logo_path(scene_tone) except FileNotFoundError: return None def list_brands() -> List[dict]: """ 列出所有已录入的品牌 Returns: 品牌信息列表 [{"name": ..., "brand_cn": ..., "has_logos": ...}, ...] """ brands = [] if not BRAND_PROFILES_DIR.exists(): return brands for profile_path in BRAND_PROFILES_DIR.glob("*.json"): try: with open(profile_path, "r", encoding="utf-8") as f: data = json.load(f) brand_name = data.get("brand_name", profile_path.stem) brand_cn = data.get("brand_cn", "") # 检查Logo文件是否存在 has_logos = False for variant_data in data.get("logo_variants", {}).values(): logo_path = BRAND_LOGOS_DIR.parent / variant_data.get("path", "") if logo_path.exists(): has_logos = True break brands.append({ "name": brand_name, "brand_cn": brand_cn, "profile_file": str(profile_path), "has_logos": has_logos, }) except Exception as e: brands.append({ "name": profile_path.stem, "brand_cn": "", "profile_file": str(profile_path), "has_logos": False, "error": str(e), }) return brands # ============================================================================ # CLI # ============================================================================ def main(): parser = argparse.ArgumentParser( description="品牌视觉规范加载器", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: # 加载朗科品牌配置 python brand_loader.py load --brand langke # 为深色场景选择Logo python brand_loader.py select-logo --brand langke --scene-tone dark # 列出所有已录入品牌 python brand_loader.py list # 导出为brand_overlay.py兼容格式 python brand_loader.py export --brand langke --scene-tone dark --output brand_config.json """ ) subparsers = parser.add_subparsers(dest="command", help="子命令") # load 命令 load_parser = subparsers.add_parser("load", help="加载品牌配置") load_parser.add_argument("--brand", required=True, help="品牌名称(如 langke, 朗科)") # select-logo 命令 logo_parser = subparsers.add_parser("select-logo", help="自动选择Logo") logo_parser.add_argument("--brand", required=True, help="品牌名称") logo_parser.add_argument("--scene-tone", default="dark", choices=["dark", "light"], help="场景色调 (default: dark)") # list 命令 subparsers.add_parser("list", help="列出已录入品牌") # export 命令 export_parser = subparsers.add_parser("export", help="导出为brand_overlay.py兼容格式") export_parser.add_argument("--brand", required=True, help="品牌名称") export_parser.add_argument("--scene-tone", default="dark", choices=["dark", "light"], help="场景色调") export_parser.add_argument("--output", required=True, help="输出JSON文件路径") args = parser.parse_args() if args.command == "load": try: config = load_brand(args.brand) print(json.dumps({ "brand_name": config.brand_name, "brand_cn": config.brand_cn, "colors": config.colors, "guarantee_bar": { "labels": config.guarantee_bar.labels, "style": config.guarantee_bar.style, }, "logo_variants": { name: {"path": v.absolute_path, "scene_tone": v.scene_tone} for name, v in config.logo_variants.items() }, "style_constraints": { "forbidden_elements": config.style_constraints.forbidden_elements, "preferred_scenes": config.style_constraints.preferred_scenes, "tone_range": config.style_constraints.tone_range, }, }, ensure_ascii=False, indent=2)) except FileNotFoundError as e: print(f"❌ {e}", file=sys.stderr) sys.exit(1) elif args.command == "select-logo": logo_path = auto_select_logo(args.brand, args.scene_tone) if logo_path: print(logo_path) else: print(f"❌ 未找到品牌 '{args.brand}' 在 {args.scene_tone} 场景下的Logo", file=sys.stderr) sys.exit(1) elif args.command == "list": brands = list_brands() if not brands: print("📭 暂无已录入品牌") else: print(f"📦 已录入 {len(brands)} 个品牌:\n") for b in brands: status = "✅" if b.get("has_logos") else "⚠️ 缺少Logo文件" print(f" {status} {b['name']} ({b['brand_cn']})") print(f" 配置文件: {b['profile_file']}") elif args.command == "export": try: config = load_brand(args.brand) overlay_config = config.to_overlay_config(args.scene_tone) output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: json.dump(overlay_config, f, ensure_ascii=False, indent=2) print(f"✅ 已导出到 {output_path}") except FileNotFoundError as e: print(f"❌ {e}", file=sys.stderr) sys.exit(1) else: parser.print_help() sys.exit(0) if __name__ == "__main__": main() -
category_detector.py 27.2 KB
#!/usr/bin/env python3 """ category_detector.py — 品类识别引擎 ==================================== 输入产品图片,通过视觉特征分析自动识别品类,输出标准化品类识别结果。 核心逻辑: 1. 读取产品图片,提取基础视觉特征(主色调、材质判断、尺寸比例) 2. 基于预定义规则和视觉特征匹配品类 3. 输出标准化JSON Usage: python scripts/category_detector.py \\ --image <产品图路径> \\ --output <输出JSON路径> Output: 品类识别结果JSON,包含 category, sub_category, attributes, suggested_scene_styles, suggested_price_range, confidence, analysis_notes 依赖: Pillow (PIL) """ import argparse import json import math import os import sys from pathlib import Path from typing import Dict, List, Tuple, Optional from collections import Counter try: from PIL import Image except ImportError: print("Error: Pillow is required. Install via: pip install Pillow", file=sys.stderr) sys.exit(1) # ============================================================================ # 品类特征库 — 基于形状/颜色/材质的规则匹配 # ============================================================================ # 主色调 → 品类权重映射 # 每种颜色类别对各个品类的贡献权重 (0-1) COLOR_CATEGORY_WEIGHTS: Dict[str, Dict[str, float]] = { "dark_metallic": { "3C数码": 0.3, "个护电器": 0.35, "清洁电器": 0.1, "厨房小家电": 0.1, "食品饮料": 0.0, "美妆护肤": 0.05 }, "bright_white": { "美妆护肤": 0.25, "母婴用品": 0.2, "卫浴用品": 0.2, "个护电器": 0.15, "家居日用": 0.1, "小家电通用": 0.1 }, "warm_wood_tone": { "家居日用": 0.3, "厨房小家电": 0.25, "食品饮料": 0.2, "家居家装": 0.15, "个护电器": 0.0, "3C数码": 0.0 }, "pastel_soft": { "母婴用品": 0.35, "美妆护肤": 0.3, "个护电器": 0.1, "家居日用": 0.1, "食品饮料": 0.05, "服饰鞋包": 0.05 }, "vibrant_colorful": { "食品饮料": 0.2, "美妆护肤": 0.2, "服饰鞋包": 0.2, "母婴用品": 0.15, "家居日用": 0.1, "3C数码": 0.05 }, "tech_blue_glow": { "3C数码": 0.35, "个护电器": 0.2, "清洁电器": 0.15, "厨房小家电": 0.1, "数码配件": 0.15, "家居日用": 0.0 }, "natural_green": { "母婴用品": 0.2, "美妆护肤": 0.25, "食品饮料": 0.15, "家居日用": 0.15, "个护电器": 0.1, "清洁电器": 0.05 }, "luxury_gold": { "美妆护肤": 0.2, "食品饮料": 0.15, "服饰鞋包": 0.15, "个护电器": 0.15, "家居日用": 0.1, "3C数码": 0.05 } } # 子品类视觉特征规则 # 每个子品类有颜色、比例、材质等特征 SUB_CATEGORY_RULES: Dict[str, Dict[str, dict]] = { "剃须刀": { "parent_category": "个护电器", "color_hints": ["dark_metallic", "tech_blue_glow"], "aspect_ratio_range": (0.2, 0.6), # 窄高型 "material_hints": ["metallic", "glossy"], "typical_colors": ["#333333", "#555555", "#1a1a2e", "#4a4a4a"], }, "电吹风": { "parent_category": "个护电器", "color_hints": ["dark_metallic", "tech_blue_glow", "pastel_soft"], "aspect_ratio_range": (0.3, 0.8), "material_hints": ["glossy", "matte"], "typical_colors": ["#2d2d2d", "#f5f5f5", "#c9a84c"], }, "电动牙刷": { "parent_category": "个护电器", "color_hints": ["bright_white", "pastel_soft", "tech_blue_glow"], "aspect_ratio_range": (0.1, 0.35), "material_hints": ["glossy", "matte"], "typical_colors": ["#ffffff", "#4a90d9", "#81c784"], }, "空气炸锅": { "parent_category": "厨房小家电", "color_hints": ["dark_metallic", "warm_wood_tone"], "aspect_ratio_range": (0.6, 1.2), "material_hints": ["matte", "glossy"], "typical_colors": ["#2d2d2d", "#3d3d3d", "#1a1a1a"], }, "电饭煲": { "parent_category": "厨房小家电", "color_hints": ["bright_white", "warm_wood_tone"], "aspect_ratio_range": (0.7, 1.3), "material_hints": ["glossy", "matte"], "typical_colors": ["#f5f5f5", "#ffffff", "#c4956a"], }, "破壁机": { "parent_category": "厨房小家电", "color_hints": ["bright_white", "warm_wood_tone"], "aspect_ratio_range": (0.3, 0.6), "material_hints": ["glossy", "transparent"], "typical_colors": ["#f5f5f5", "#e0e0e0", "#333333"], }, "咖啡机": { "parent_category": "厨房小家电", "color_hints": ["dark_metallic", "warm_wood_tone", "luxury_gold"], "aspect_ratio_range": (0.4, 0.8), "material_hints": ["metallic", "glossy"], "typical_colors": ["#2d2d2d", "#5c3d2e", "#c9a84c"], }, "扫地机器人": { "parent_category": "清洁电器", "color_hints": ["dark_metallic", "bright_white"], "aspect_ratio_range": (0.8, 1.5), # 扁平圆形 "material_hints": ["glossy", "matte"], "typical_colors": ["#f5f5f5", "#2d2d2d", "#333333"], }, "洗地机": { "parent_category": "清洁电器", "color_hints": ["dark_metallic", "bright_white"], "aspect_ratio_range": (0.15, 0.4), # 细长杆状 "material_hints": ["glossy", "matte"], "typical_colors": ["#f5f5f5", "#2d2d2d", "#4a90d9"], }, "手机": { "parent_category": "3C数码", "color_hints": ["dark_metallic", "tech_blue_glow"], "aspect_ratio_range": (0.4, 0.55), "material_hints": ["glossy", "metallic"], "typical_colors": ["#1a1a1a", "#333333", "#c9a84c"], }, "耳机": { "parent_category": "3C数码", "color_hints": ["dark_metallic", "tech_blue_glow", "bright_white"], "aspect_ratio_range": (0.5, 1.5), "material_hints": ["glossy", "matte"], "typical_colors": ["#f5f5f5", "#2d2d2d", "#1a1a1a"], }, "充电宝": { "parent_category": "数码配件", "color_hints": ["dark_metallic", "tech_blue_glow", "bright_white"], "aspect_ratio_range": (0.3, 0.7), "material_hints": ["glossy", "matte", "metallic"], "typical_colors": ["#f5f5f5", "#2d2d2d", "#1a1a2e"], }, "键盘": { "parent_category": "数码配件", "color_hints": ["dark_metallic", "tech_blue_glow", "bright_white"], "aspect_ratio_range": (1.5, 3.0), # 宽扁型 "material_hints": ["matte", "metallic"], "typical_colors": ["#2d2d2d", "#f5f5f5", "#1a1a2e"], }, "面霜": { "parent_category": "美妆护肤", "color_hints": ["pastel_soft", "bright_white", "luxury_gold"], "aspect_ratio_range": (0.6, 1.4), "material_hints": ["glossy", "transparent", "matte"], "typical_colors": ["#fdf2f8", "#ffffff", "#d4a5a5"], }, "精华液": { "parent_category": "美妆护肤", "color_hints": ["pastel_soft", "luxury_gold", "natural_green"], "aspect_ratio_range": (0.15, 0.4), "material_hints": ["glossy", "transparent"], "typical_colors": ["#fdf2f8", "#d4a5a5", "#a8d8a8"], }, "面膜": { "parent_category": "美妆护肤", "color_hints": ["pastel_soft", "natural_green", "bright_white"], "aspect_ratio_range": (0.5, 1.2), "material_hints": ["matte"], "typical_colors": ["#e8f5e9", "#ffffff", "#f5f5f5"], }, "奶粉": { "parent_category": "母婴用品", "color_hints": ["pastel_soft", "bright_white", "natural_green"], "aspect_ratio_range": (0.4, 0.8), "material_hints": ["matte"], "typical_colors": ["#f5f5f5", "#e8f5e9", "#fff3e0"], }, "婴儿洗护": { "parent_category": "母婴用品", "color_hints": ["pastel_soft", "natural_green"], "aspect_ratio_range": (0.2, 0.5), "material_hints": ["glossy", "matte"], "typical_colors": ["#e8f5e9", "#fff9c4", "#f8bbd0"], }, "茶叶": { "parent_category": "食品饮料", "color_hints": ["natural_green", "warm_wood_tone", "luxury_gold"], "aspect_ratio_range": (0.4, 1.0), "material_hints": ["matte"], "typical_colors": ["#2d5a27", "#5c3d2e", "#c9a84c"], }, "坚果零食": { "parent_category": "食品饮料", "color_hints": ["warm_wood_tone", "vibrant_colorful"], "aspect_ratio_range": (0.5, 1.5), "material_hints": ["matte"], "typical_colors": ["#8d6e63", "#e8a87c", "#f5f5f5"], } } # 品类→父品类映射 PARENT_CATEGORY_MAP: Dict[str, str] = { "剃须刀": "个护电器", "电吹风": "个护电器", "电动牙刷": "个护电器", "空气炸锅": "厨房小家电", "电饭煲": "厨房小家电", "破壁机": "厨房小家电", "咖啡机": "厨房小家电", "扫地机器人": "清洁电器", "洗地机": "清洁电器", "手机": "3C数码", "耳机": "3C数码", "充电宝": "数码配件", "键盘": "数码配件", "面霜": "美妆护肤", "精华液": "美妆护肤", "面膜": "美妆护肤", "奶粉": "母婴用品", "婴儿洗护": "母婴用品", "茶叶": "食品饮料", "坚果零食": "食品饮料", } # 所有支持的品类 ALL_CATEGORIES = [ "3C数码", "个护电器", "美妆护肤", "厨房小家电", "清洁电器", "食品饮料", "家居日用", "服饰鞋包", "母婴用品" ] ALL_SUB_CATEGORIES = list(SUB_CATEGORY_RULES.keys()) # 价位范围建议(按品类) PRICE_RANGES: Dict[str, str] = { "个护电器": "50-500", "厨房小家电": "100-800", "清洁电器": "200-3000", "3C数码": "50-5000", "数码配件": "20-500", "美妆护肤": "30-2000", "母婴用品": "30-1000", "食品饮料": "10-500", "家居日用": "20-1000", "服饰鞋包": "50-5000", } # 场景风格推荐(按品类+价位段) SCENE_STYLE_MAP: Dict[str, Dict[str, List[str]]] = { "个护电器": { "low": ["clean_light", "minimalist"], "mid": ["tech_gradient", "minimalist", "lifestyle_bathroom"], "high": ["tech_gradient", "premium", "lifestyle_bathroom"], "luxury": ["luxury_dark", "tech_gradient"], }, "厨房小家电": { "low": ["warm_wood", "minimalist"], "mid": ["warm_wood", "modern_marble", "cozy_living"], "high": ["modern_marble", "cozy_living", "warm_wood"], "luxury": ["modern_marble", "luxury_dark"], }, "清洁电器": { "low": ["minimalist", "clean_light"], "mid": ["tech_gradient", "modern_marble", "minimalist"], "high": ["tech_gradient", "modern_marble"], "luxury": ["tech_gradient", "luxury_dark"], }, "3C数码": { "low": ["minimalist", "desk_tech"], "mid": ["tech_gradient", "desk_tech", "minimalist"], "high": ["tech_gradient", "luxury_dark", "desk_tech"], "luxury": ["luxury_dark", "tech_gradient"], }, "数码配件": { "low": ["minimalist", "desk_tech"], "mid": ["desk_tech", "tech_gradient", "minimalist"], "high": ["tech_gradient", "desk_tech"], "luxury": ["luxury_dark", "tech_gradient"], }, "美妆护肤": { "low": ["topdown_greenery", "minimalist"], "mid": ["modern_marble", "topdown_greenery", "minimalist"], "high": ["modern_marble", "luxury_dark", "topdown_greenery"], "luxury": ["luxury_dark", "modern_marble"], }, "母婴用品": { "low": ["pastel_baby", "topdown_greenery"], "mid": ["pastel_baby", "topdown_greenery", "minimalist"], "high": ["pastel_baby", "topdown_greenery"], "luxury": ["pastel_baby", "modern_marble"], }, "食品饮料": { "low": ["warm_wood", "rustic_food"], "mid": ["rustic_food", "warm_wood", "cozy_living"], "high": ["rustic_food", "luxury_dark", "warm_wood"], "luxury": ["luxury_dark", "rustic_food"], }, "家居日用": { "low": ["warm_wood", "minimalist"], "mid": ["cozy_living", "warm_wood", "minimalist"], "high": ["cozy_living", "modern_marble", "warm_wood"], "luxury": ["luxury_dark", "cozy_living"], }, "服饰鞋包": { "low": ["minimalist", "fashion_editorial"], "mid": ["fashion_editorial", "minimalist", "cozy_living"], "high": ["fashion_editorial", "luxury_dark", "minimalist"], "luxury": ["luxury_dark", "fashion_editorial"], }, } # ============================================================================ # 图像分析函数 # ============================================================================ def extract_dominant_colors(image: Image.Image, n_colors: int = 5) -> List[Tuple[Tuple[int,int,int], float]]: """ 提取图片主色调(通过缩小+量化实现简易KMeans效果)。 Args: image: PIL Image对象 n_colors: 提取的颜色数量 Returns: 颜色列表 [(R,G,B), 占比],按占比降序排列 """ # 缩小到 50x50 加速计算 small = image.copy().resize((50, 50), Image.LANCZOS) # 转为 RGB if small.mode == "RGBA": # 过滤透明背景 pixels = [] for pixel in small.getdata(): if pixel[3] > 128: # 不透明像素 pixels.append(pixel[:3]) elif small.mode == "RGB": pixels = list(small.getdata()) else: small = small.convert("RGB") pixels = list(small.getdata()) if not pixels: return [((255, 255, 255), 1.0)] # 简易量化:将每个通道量化到 8 级 quantized = [] for r, g, b in pixels: qr = (r // 32) * 32 qg = (g // 32) * 32 qb = (b // 32) * 32 quantized.append((qr, qg, qb)) # 统计颜色频次 color_counts = Counter(quantized) total = len(quantized) # 返回前n个主色 result = [] for color, count in color_counts.most_common(n_colors): result.append((color, count / total)) return result def classify_color_tone(color: Tuple[int,int,int]) -> str: """ 将RGB颜色归类为色调类别。 Args: color: (R, G, B) 元组 Returns: 色调类别字符串 """ r, g, b = color brightness = (r * 0.299 + g * 0.587 + b * 0.114) saturation = max(r, g, b) - min(r, g, b) # 金色/铜色系 if r > 150 and g > 100 and b < 100 and (r - b) > 60: return "luxury_gold" # 绿色系 if g > r and g > b and saturation > 40: return "natural_green" # 蓝色系 if b > r and b > g and saturation > 40: return "tech_blue_glow" # 粉/紫柔和色 if saturation < 80 and brightness > 150 and (r > b or g > 100): if r > 180 and b > 150: return "pastel_soft" # 暖木色 if r > 100 and g > 60 and b < 80 and saturation > 30 and brightness > 80: return "warm_wood_tone" # 深金属色 if brightness < 100 and saturation < 60: return "dark_metallic" # 明亮白色系 if brightness > 220 and saturation < 40: return "bright_white" # 鲜艳彩色 if saturation > 80: return "vibrant_colorful" # 默认根据亮度判断 if brightness > 180: return "bright_white" elif brightness > 100: return "warm_wood_tone" else: return "dark_metallic" def analyze_aspect_ratio(image: Image.Image) -> float: """ 分析产品的宽高比(通过检测非透明区域)。 Args: image: PIL Image对象 Returns: 宽高比 (width/height),对于全图则返回图片本身的宽高比 """ if image.mode == "RGBA": # 检测非透明区域的bbox bbox = image.getbbox() if bbox: w = bbox[2] - bbox[0] h = bbox[3] - bbox[1] if h > 0: return w / h # 回退:使用图片原始宽高比 w, h = image.size return w / h if h > 0 else 1.0 def analyze_material(image: Image.Image, dominant_colors: list) -> str: """ 根据视觉特征推断材质。 Args: image: PIL Image对象 dominant_colors: 主色调列表 Returns: 材质描述字符串 """ materials = [] # 检查高光和反光(通过亮度分布) small = image.copy().resize((50, 50), Image.LANCZOS).convert("RGB") pixels = list(small.getdata()) bright_pixels = sum(1 for p in pixels if max(p) > 220) ratio = bright_pixels / len(pixels) if ratio > 0.15: materials.append("glossy") # 检查是否有金属质感(深灰+高光组合) has_dark = any(c[1] > 0.1 and classify_color_tone(c[0]) == "dark_metallic" for c in dominant_colors) has_bright = ratio > 0.08 if has_dark and has_bright: materials.append("metallic") if not materials: materials.append("matte") return "+".join(materials) def infer_form_factor(aspect_ratio: float) -> str: """ 根据宽高比推断产品形态。 Args: aspect_ratio: 宽高比 (w/h) Returns: 形态描述 """ if aspect_ratio < 0.3: return "细长杆状" elif aspect_ratio < 0.5: return "手持式" elif aspect_ratio < 0.8: return "紧凑型" elif aspect_ratio < 1.2: return "方正型" elif aspect_ratio < 1.8: return "宽扁型" else: return "超宽型" def infer_dominant_shape(aspect_ratio: float) -> str: """ 根据宽高比推断主导形状。 Args: aspect_ratio: 宽高比 (w/h) Returns: 形状描述 """ if aspect_ratio < 0.25: return "细长圆柱形" elif aspect_ratio < 0.5: return "圆柱形" elif aspect_ratio < 0.8: return "椭圆/圆角矩形" elif aspect_ratio < 1.2: return "方形/圆形" elif aspect_ratio < 2.0: return "横向矩形" else: return "超宽扁形" def match_sub_category(dominant_tones: List[str], aspect_ratio: float, material: str) -> List[Tuple[str, float]]: """ 根据视觉特征匹配子品类,返回候选列表和置信度。 Args: dominant_tones: 主色调类别列表 aspect_ratio: 产品宽高比 material: 材质描述 Returns: [(子品类名, 置信度)] 按置信度降序 """ scores: Dict[str, float] = {} for sub_cat, rules in SUB_CATEGORY_RULES.items(): score = 0.0 # 颜色匹配 (权重 0.4) color_match = sum(1 for t in dominant_tones if t in rules.get("color_hints", [])) color_score = min(color_match / max(len(rules.get("color_hints", [])), 1), 1.0) score += color_score * 0.4 # 比例匹配 (权重 0.35) ratio_range = rules.get("aspect_ratio_range", (0, 10)) if ratio_range[0] <= aspect_ratio <= ratio_range[1]: # 在范围内,根据与中心的距离给分 center = (ratio_range[0] + ratio_range[1]) / 2 span = (ratio_range[1] - ratio_range[0]) / 2 if span > 0: distance = abs(aspect_ratio - center) / span ratio_score = max(0, 1.0 - distance * 0.5) else: ratio_score = 1.0 else: # 在范围外,距离越远分数越低 distance = min(abs(aspect_ratio - ratio_range[0]), abs(aspect_ratio - ratio_range[1])) ratio_score = max(0, 1.0 - distance * 0.3) score += ratio_score * 0.35 # 材质匹配 (权重 0.25) material_hints = rules.get("material_hints", []) if material_hints: material_match = sum(1 for m in material_hints if m in material) mat_score = material_match / len(material_hints) else: mat_score = 0.3 # 无材质规则时给基础分 score += mat_score * 0.25 scores[sub_cat] = score # 排序返回 sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True) return sorted_scores def match_parent_category(dominant_tones: List[str]) -> List[Tuple[str, float]]: """ 根据主色调匹配父品类。 Args: dominant_tones: 主色调类别列表 Returns: [(父品类名, 置信度)] 按置信度降序 """ scores: Dict[str, float] = {} for tone in dominant_tones: weights = COLOR_CATEGORY_WEIGHTS.get(tone, {}) for cat, weight in weights.items(): scores[cat] = scores.get(cat, 0) + weight # 归一化 total = sum(scores.values()) or 1.0 normalized = {cat: s / total for cat, s in scores.items()} # 补齐缺失品类 for cat in ALL_CATEGORIES: if cat not in normalized: normalized[cat] = 0.0 sorted_scores = sorted(normalized.items(), key=lambda x: x[1], reverse=True) return sorted_scores # ============================================================================ # 主检测流程 # ============================================================================ def detect_category(image_path: str) -> dict: """ 完整品类检测流程。 Args: image_path: 产品图片路径 Returns: 标准化品类识别结果字典 """ # 1. 加载图片 try: image = Image.open(image_path) except Exception as e: return { "error": f"无法加载图片: {e}", "category": "未知", "confidence": 0.0 } # 2. 提取视觉特征 dominant_colors = extract_dominant_colors(image, n_colors=6) dominant_tones = list(dict.fromkeys( classify_color_tone(c) for c, _ in dominant_colors if _ > 0.03 )) aspect_ratio = analyze_aspect_ratio(image) material = analyze_material(image, dominant_colors) form_factor = infer_form_factor(aspect_ratio) dominant_shape = infer_dominant_shape(aspect_ratio) # 3. 匹配子品类 sub_cat_scores = match_sub_category(dominant_tones, aspect_ratio, material) # 4. 匹配父品类 parent_cat_scores = match_parent_category(dominant_tones) # 5. 确定最终结果 best_sub_cat, sub_confidence = sub_cat_scores[0] if sub_cat_scores else ("未知", 0.0) best_parent_cat = PARENT_CATEGORY_MAP.get(best_sub_cat, parent_cat_scores[0][0] if parent_cat_scores else "家居日用") # 综合置信度:子品类置信度和父品类置信度的加权平均 parent_confidence = next((s for c, s in parent_cat_scores if c == best_parent_cat), 0.0) overall_confidence = sub_confidence * 0.6 + parent_confidence * 0.4 # 如果置信度太低,标注需要人工确认 confidence = round(min(overall_confidence, 0.99), 2) # 6. 颜色描述 top_colors = dominant_colors[:3] color_names = [] for color, ratio in top_colors: if ratio < 0.05: continue r, g, b = color tone = classify_color_tone(color) tone_cn = { "dark_metallic": "深灰/金属色", "bright_white": "白色/浅色", "warm_wood_tone": "暖棕/木色", "pastel_soft": "柔和粉色", "vibrant_colorful": "鲜艳彩色", "tech_blue_glow": "科技蓝", "natural_green": "自然绿", "luxury_gold": "金色", }.get(tone, f"RGB({r},{g},{b})") color_names.append(tone_cn) color_desc = "+".join(color_names) if color_names else "混合色" # 7. 推荐场景风格 # 先根据价位段获取,默认mid price_tier = "mid" # 品类检测阶段不知道价位,给通用推荐 scene_styles = SCENE_STYLE_MAP.get(best_parent_cat, {}).get( price_tier, ["minimalist", "warm_wood"] ) # 8. 价位范围建议 suggested_price = PRICE_RANGES.get(best_parent_cat, "50-500") # 9. 分析备注 analysis_notes = f"{best_sub_cat},{material}材质,{form_factor}," if confidence >= 0.7: analysis_notes += f"适合{scene_styles[0]}风格" else: analysis_notes += "视觉特征不够明确,建议人工确认品类" result = { "category": best_parent_cat, "sub_category": best_sub_cat, "attributes": { "color": color_desc, "material": material, "style": "科技感" if "dark_metallic" in dominant_tones or "tech_blue_glow" in dominant_tones else "自然清新" if "natural_green" in dominant_tones or "pastel_soft" in dominant_tones else "简约现代", "form_factor": form_factor, "dominant_shape": dominant_shape, "aspect_ratio": round(aspect_ratio, 3), }, "suggested_scene_styles": scene_styles[:3], "suggested_price_range": suggested_price, "confidence": confidence, "analysis_notes": analysis_notes, # 附加调试信息 "_debug": { "dominant_tones": dominant_tones, "sub_category_scores": {k: round(v, 3) for k, v in sub_cat_scores[:5]}, "parent_category_scores": {k: round(v, 3) for k, v in parent_cat_scores[:5]}, } } return result # ============================================================================ # CLI 入口 # ============================================================================ def main(): parser = argparse.ArgumentParser( description="品类识别引擎 — 输入产品图片,输出标准化品类识别结果", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: python scripts/category_detector.py --image product.png --output category.json python scripts/category_detector.py --image /path/to/shaver.png """ ) parser.add_argument( "--image", required=True, help="产品图片路径(支持 PNG/JPG/JPEG/WebP)" ) parser.add_argument( "--output", default=None, help="输出JSON路径(不指定则输出到标准输出)" ) parser.add_argument( "--pretty", action="store_true", default=True, help="美化JSON输出(默认开启)" ) args = parser.parse_args() # 验证输入文件 if not os.path.isfile(args.image): print(f"Error: 图片文件不存在: {args.image}", file=sys.stderr) sys.exit(1) # 执行品类检测 result = detect_category(args.image) # 输出结果 indent = 2 if args.pretty else None output_json = json.dumps(result, ensure_ascii=False, indent=indent) if args.output: output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: f.write(output_json) print(f"品类识别结果已保存到: {args.output}") # 输出摘要到终端 conf_label = "高" if result["confidence"] >= 0.7 else ("中" if result["confidence"] >= 0.4 else "低") print(f" 品类: {result['category']} > {result['sub_category']}") print(f" 置信度: {result['confidence']} ({conf_label})") print(f" 推荐风格: {', '.join(result['suggested_scene_styles'])}") else: print(output_json) if __name__ == "__main__": main() -
delivery_packager.py 26.7 KB
#!/usr/bin/env python3 """ delivery_packager.py — 标准化交付打包器 ======================================== 将整个项目的产物标准化打包,自动生成使用指南和素材清单。 核心功能: 1. 扫描项目目录,识别各类产物(成品图/文案/布局方案/场景图/品牌素材) 2. 可选调用 platform_adapter.py 生成多平台版本 3. 生成标准化目录结构的使用指南和素材清单 4. 打包为zip文件交付 Usage: python scripts/delivery_packager.py \\ --project-dir <项目目录> \\ --brand <品牌名> \\ --product-name <产品名称> \\ --platforms taobao,kuaishou \\ --output <输出zip路径> \\ --include-copywriting \\ --include-brand-assets \\ --include-source 依赖: Pillow (可选), zipfile (标准库) """ import argparse import json import os import shutil import subprocess import sys import zipfile from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Any, Tuple from PIL import Image # ============================================================================ # 常量 # ============================================================================ # 平台中文名映射 PLATFORM_NAMES = { "taobao": "淘宝天猫", "kuaishou": "快手", "xiaohongshu": "小红书", "douyin": "抖音", "pinduoduo": "拼多多", "jd": "京东", "wechat_shop": "微信小商店", } # 各平台上传指引 PLATFORM_GUIDES = { "taobao": { "main_upload": "商品管理 → 主图 → 上传800×800主图(第一张建议白底)", "detail_upload": "商品详情 → 源码编辑 → 按顺序插入详情图片", "notes": [ "主图第一张建议白底,审核通过率更高", "主图文件大小 < 500KB", "详情图宽度建议750px,单张高度不超过1200px", "标题复制使用指南中的SEO标题,注意不超过60字", ] }, "kuaishou": { "main_upload": "商品发布 → 商品图片 → 上传800×800", "detail_upload": "商品详情 → 上传750×1000详情图", "notes": [ "主图建议白底或浅色背景", "主图文件大小 < 500KB", "标题建议包含品牌名+核心关键词", ] }, "xiaohongshu": { "main_upload": "发布笔记 → 上传图片(建议3:4竖图1080×1440)", "detail_upload": "详情页图使用1080×1440尺寸", "notes": [ "小红书优先使用3:4竖版图,视觉冲击力更强", "首图决定点击率,选择最有吸引力的场景图", "文案使用copywriting.md中的小红书种草文案", "记得添加话题标签", "图片文件大小 < 5MB", ] }, "douyin": { "main_upload": "商品发布 → 商品主图 → 上传800×800", "detail_upload": "商品详情 → 上传750×1000", "notes": [ "主图800×800,文件大小 < 500KB", "短视频素材可使用口播文案", "直播话术参考copywriting.md中的直播部分", ] }, "pinduoduo": { "main_upload": "商品管理 → 商品图片 → 上传750×750", "detail_upload": "商品详情 → 自由比例详情图", "notes": [ "主图750×750,文件大小 < 300KB", "标题建议包含核心搜索关键词", "详情图宽度750px,高度自由", "拼多多用户重视性价比,标题和描述突出性价比", ] }, "jd": { "main_upload": "商品管理 → 商品主图 → 上传800×800", "detail_upload": "商品详情 → 上传750宽详情图", "notes": [ "主图800×800,文件大小 < 500KB", "京东审核严格,主图不能有牛皮癣(过多文字装饰)", "详情图宽度不超过1000px", ] }, "wechat_shop": { "main_upload": "商品管理 → 主图 → 上传750×750", "detail_upload": "商品详情 → 自由比例详情图", "notes": [ "主图750×750,文件大小 < 500KB", "适合微信生态内分享传播", ] }, } # ============================================================================ # 产物扫描 # ============================================================================ def scan_project(project_dir: Path) -> Dict[str, Any]: """ 扫描项目目录,识别各类产物。 Returns: { "main_images": [Path, ...], # 主图(1:1) "detail_images": [Path, ...], # 详情图(3:4) "other_images": [Path, ...], # 其他图片 "copywriting": Optional[Path], # 文案文件 "plan": Optional[Path], # 布局方案 "scenes_dir": Optional[Path], # 场景图目录 "brand_assets": [Path, ...], # 品牌素材 "quality_report": Optional[Path],# 质检报告 } """ result: Dict[str, Any] = { "main_images": [], "detail_images": [], "other_images": [], "copywriting": None, "plan": None, "scenes_dir": None, "brand_assets": [], "quality_report": None, } image_extensions = {".png", ".jpg", ".jpeg", ".webp"} # 扫描output目录(成品图) output_dir = project_dir / "output" if output_dir.is_dir(): for f in sorted(output_dir.iterdir()): if f.suffix.lower() not in image_extensions: continue try: with Image.open(f) as img: w, h = img.size ratio = w / h if h > 0 else 1.0 if abs(ratio - 1.0) < 0.10: result["main_images"].append(f) elif abs(ratio - 0.75) < 0.10 or abs(ratio - 1.33) < 0.10: result["detail_images"].append(f) else: result["other_images"].append(f) except Exception: result["other_images"].append(f) # 如果output目录不存在,直接扫描项目根目录 if not output_dir.is_dir(): for f in sorted(project_dir.iterdir()): if f.suffix.lower() in image_extensions: try: with Image.open(f) as img: w, h = img.size ratio = w / h if h > 0 else 1.0 if abs(ratio - 1.0) < 0.10: result["main_images"].append(f) elif abs(ratio - 0.75) < 0.10: result["detail_images"].append(f) else: result["other_images"].append(f) except Exception: result["other_images"].append(f) # 扫描文案文件 for name in ["copywriting.md", "copywriting.txt"]: f = project_dir / name if f.is_file(): result["copywriting"] = f break # 扫描布局方案 for name in ["plan.json", "layout_plan.json"]: f = project_dir / name if f.is_file(): result["plan"] = f break # 扫描场景图目录 scenes_dir = project_dir / "scenes" if scenes_dir.is_dir(): result["scenes_dir"] = scenes_dir # 扫描品牌素材 brand_dir = project_dir / "brand_assets" if brand_dir.is_dir(): for f in sorted(brand_dir.iterdir()): if f.is_file(): result["brand_assets"].append(f) # 扫描质检报告 for name in ["quality_report.json", "quality_summary.txt"]: f = project_dir / name if f.is_file(): result["quality_report"] = f break return result # ============================================================================ # 多平台适配调用 # ============================================================================ def run_platform_adapter( project_dir: Path, platforms: List[str], output_base: Path ) -> bool: """ 调用 platform_adapter.py 生成多平台版本。 Args: project_dir: 项目目录 platforms: 目标平台列表 output_base: 适配输出基础目录 Returns: 是否成功 """ # 确定成品图目录 output_dir = project_dir / "output" if not output_dir.is_dir(): output_dir = project_dir # 定位platform_adapter.py adapter_path = Path(__file__).parent / "platform_adapter.py" if not adapter_path.is_file(): print(f"[WARN] platform_adapter.py 不存在: {adapter_path}", file=sys.stderr) return False platform_output = output_base / "多平台适配" cmd = [ sys.executable, str(adapter_path), "--input-dir", str(output_dir), "--platforms", ",".join(platforms), "--output-dir", str(platform_output), "--resize-mode", "fit", ] print(f"🔄 调用多平台适配引擎...") try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) if result.returncode != 0: print(f"[WARN] 平台适配失败: {result.stderr}", file=sys.stderr) return False print(result.stdout) return True except subprocess.TimeoutExpired: print("[WARN] 平台适配超时", file=sys.stderr) return False except Exception as e: print(f"[WARN] 平台适配异常: {e}", file=sys.stderr) return False # ============================================================================ # 文档生成 # ============================================================================ def generate_usage_guide( brand: str, product_name: str, scan_result: Dict[str, Any], platforms: Optional[List[str]], plan_data: Optional[Dict[str, Any]] ) -> str: """ 生成使用指南Markdown内容。 """ now = datetime.now().strftime("%Y-%m-%d %H:%M") main_count = len(scan_result["main_images"]) detail_count = len(scan_result["detail_images"]) lines = [ f"# {product_name} 电商素材使用指南\n", f"## 素材概览\n", f"| 项目 | 内容 |", f"|------|------|", f"| 品牌 | {brand} |", f"| 产品 | {product_name} |", f"| 生成日期 | {now} |", f"| 主图数量 | {main_count} 张 |", f"| 详情图数量 | {detail_count} 张 |", ] if platforms: platform_str = "、".join(PLATFORM_NAMES.get(p, p) for p in platforms) lines.append(f"| 适配平台 | {platform_str} |") lines.append("") # 各平台使用说明 if platforms: lines.append("## 各平台使用说明\n") for platform_key in platforms: guide = PLATFORM_GUIDES.get(platform_key, {}) platform_name = PLATFORM_NAMES.get(platform_key, platform_key) lines.append(f"### {platform_name}\n") if "main_upload" in guide: lines.append(f"**主图上传:** {guide['main_upload']}\n") if "detail_upload" in guide: lines.append(f"**详情图上传:** {guide['detail_upload']}\n") if guide.get("notes"): lines.append("**注意事项:**") for note in guide["notes"]: lines.append(f"- {note}") lines.append("") else: # 没有指定平台时,给通用建议 lines.append("## 通用上传建议\n") lines.append("1. **主图**:建议第一张使用白底图,审核通过率更高") lines.append("2. **详情图**:按顺序上传,第一张为情境开篇图") lines.append("3. **文件大小**:一般电商平台要求单张 < 500KB") lines.append("4. **图片格式**:统一使用 JPG 格式,兼容性最好") lines.append("") # 文案使用建议 if scan_result.get("copywriting"): lines.extend([ "## 文案使用建议\n", "- **商品标题**:直接复制 `文案素材/copywriting.md` 中的SEO标题,根据平台字数限制适当删减", "- **五点描述**:逐条复制到商品描述区域", "- **小红书文案**:完整复制到小红书笔记正文,记得保留emoji和话题标签", "- **短视频脚本**:参考口播文案录制短视频,注意控制60-90秒", "- **直播话术**:按开场→留人→逼单→促转化的节奏使用", "", ]) # 修改建议 lines.extend([ "## 修改建议\n", "### 如需修改文字内容", "- 文字由 `text_engine.py` 渲染在图片上", "- 如需修改,请重新运行文字渲染流程或使用图片编辑工具覆盖", "- 建议保留原始PNG文件(如有源文件包),方便重新渲染", "", "### 如需更换场景背景", "- 场景底图保存在 `源文件/scenes/` 目录(如有)", "- 可重新生成场景底图后,用 `text_engine.py` 重新合成", "- 修改 `plan.json` 中的 `scene_prompt` 后重新走 Step 4 生图流程", "", "### 如需适配更多平台", "- 运行 `platform_adapter.py` 可快速生成其他平台尺寸", "```bash", "python scripts/platform_adapter.py \\", " --input-dir <成品图目录> \\", " --platforms douyin,pinduoduo \\", " --output-dir <输出目录> \\", " --resize-mode fit", "```", "", ]) # 文件结构说明 lines.extend([ "## 素材包目录结构\n", "```", f"{product_name}_电商素材包/", "├── 使用指南.md ← 你正在阅读的文件", "├── 素材清单.md ← 所有文件清单+尺寸+大小", "├── 成品图/", "│ ├── 主图(1:1)/ ← 正方形主图", "│ └── 详情图(3:4)/ ← 竖版详情图", ]) if platforms: lines.append("├── 多平台适配/ ← 各平台尺寸适配版本") if scan_result.get("copywriting"): lines.append("├── 文案素材/ ← 全平台文案素材") if scan_result.get("brand_assets"): lines.append("├── 品牌素材/ ← logo等品牌资源") lines.extend([ "└── 源文件/ ← plan.json + 场景底图", "```", "", "---", f"*本素材包由电商素材一站式工坊自动生成 · {now}*", ]) return "\n".join(lines) def generate_inventory( scan_result: Dict[str, Any], package_root_name: str ) -> str: """ 生成素材清单Markdown内容。 """ lines = [ "# 素材清单\n", "| 序号 | 文件名 | 类型 | 尺寸 | 大小 |", "|------|--------|------|------|------|", ] idx = 0 # 主图 for img_path in scan_result["main_images"]: idx += 1 try: with Image.open(img_path) as img: w, h = img.size size_str = f"{w}×{h}" except Exception: size_str = "N/A" file_size = _format_file_size(img_path) lines.append(f"| {idx} | {img_path.name} | 主图 | {size_str} | {file_size} |") # 详情图 for img_path in scan_result["detail_images"]: idx += 1 try: with Image.open(img_path) as img: w, h = img.size size_str = f"{w}×{h}" except Exception: size_str = "N/A" file_size = _format_file_size(img_path) lines.append(f"| {idx} | {img_path.name} | 详情图 | {size_str} | {file_size} |") # 其他图片 for img_path in scan_result["other_images"]: idx += 1 try: with Image.open(img_path) as img: w, h = img.size size_str = f"{w}×{h}" except Exception: size_str = "N/A" file_size = _format_file_size(img_path) lines.append(f"| {idx} | {img_path.name} | 其他 | {size_str} | {file_size} |") # 文案 if scan_result.get("copywriting"): idx += 1 file_size = _format_file_size(scan_result["copywriting"]) lines.append(f"| {idx} | copywriting.md | 文案 | - | {file_size} |") # 布局方案 if scan_result.get("plan"): idx += 1 file_size = _format_file_size(scan_result["plan"]) lines.append(f"| {idx} | plan.json | 布局方案 | - | {file_size} |") # 品牌素材 for f in scan_result["brand_assets"]: idx += 1 file_size = _format_file_size(f) lines.append(f"| {idx} | {f.name} | 品牌素材 | - | {file_size} |") lines.extend([ "", f"**共计 {idx} 个文件**", "", "---", f"*生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}*", ]) return "\n".join(lines) def _format_file_size(path: Path) -> str: """格式化文件大小显示""" try: size = path.stat().st_size if size < 1024: return f"{size}B" elif size < 1024 * 1024: return f"{size / 1024:.1f}KB" else: return f"{size / (1024 * 1024):.1f}MB" except Exception: return "N/A" # ============================================================================ # 打包流程 # ============================================================================ def create_package( project_dir: Path, brand: str, product_name: str, platforms: Optional[List[str]], output_zip: Path, include_copywriting: bool, include_brand_assets: bool, include_source: bool ) -> Path: """ 创建标准化交付包并打包为zip。 Returns: zip文件路径 """ # 扫描项目产物 print(f"📂 扫描项目目录: {project_dir}") scan_result = scan_project(project_dir) print(f" 主图: {len(scan_result['main_images'])} 张") print(f" 详情图: {len(scan_result['detail_images'])} 张") print(f" 文案: {'有' if scan_result['copywriting'] else '无'}") print(f" 布局方案: {'有' if scan_result['plan'] else '无'}") # 加载plan.json(用于生成使用指南) plan_data = None if scan_result["plan"]: try: with open(scan_result["plan"], "r", encoding="utf-8") as f: plan_data = json.load(f) except Exception as e: print(f"[WARN] 无法读取plan.json: {e}", file=sys.stderr) # 创建临时打包目录 package_name = f"{product_name}_电商素材包" temp_dir = output_zip.parent / f"_packaging_temp_{product_name}" # 清理已存在的临时目录 if temp_dir.exists(): shutil.rmtree(temp_dir) temp_dir.mkdir(parents=True) try: package_dir = temp_dir / package_name package_dir.mkdir() # 1. 成品图 finished_dir = package_dir / "成品图" main_dir = finished_dir / "主图(1:1)" detail_dir = finished_dir / "详情图(3:4)" main_dir.mkdir(parents=True) detail_dir.mkdir(parents=True) for img_path in scan_result["main_images"]: shutil.copy2(img_path, main_dir / img_path.name) for img_path in scan_result["detail_images"]: shutil.copy2(img_path, detail_dir / img_path.name) # 其他图片也放入成品图 if scan_result["other_images"]: other_dir = finished_dir / "其他" other_dir.mkdir(parents=True, exist_ok=True) for img_path in scan_result["other_images"]: shutil.copy2(img_path, other_dir / img_path.name) # 2. 多平台适配 if platforms: print(f"\n🔄 生成多平台适配版本...") success = run_platform_adapter(project_dir, platforms, package_dir) if not success: print("[WARN] 多平台适配未成功,素材包不含平台适配版本", file=sys.stderr) # 3. 文案素材 if include_copywriting and scan_result.get("copywriting"): copywriting_dir = package_dir / "文案素材" copywriting_dir.mkdir() shutil.copy2(scan_result["copywriting"], copywriting_dir / scan_result["copywriting"].name) # 4. 品牌素材 if include_brand_assets and scan_result.get("brand_assets"): brand_dir_pkg = package_dir / "品牌素材" brand_dir_pkg.mkdir() for f in scan_result["brand_assets"]: shutil.copy2(f, brand_dir_pkg / f.name) # 5. 源文件 if include_source: source_dir = package_dir / "源文件" source_dir.mkdir() if scan_result.get("plan"): shutil.copy2(scan_result["plan"], source_dir / scan_result["plan"].name) if scan_result.get("scenes_dir") and scan_result["scenes_dir"].is_dir(): scenes_dest = source_dir / "scenes" shutil.copytree(scan_result["scenes_dir"], scenes_dest, dirs_exist_ok=True) # 6. 生成使用指南 print(f"\n📝 生成使用指南...") guide_content = generate_usage_guide( brand, product_name, scan_result, platforms, plan_data ) guide_path = package_dir / "使用指南.md" with open(guide_path, "w", encoding="utf-8") as f: f.write(guide_content) # 7. 生成素材清单 print(f"📝 生成素材清单...") inventory_content = generate_inventory(scan_result, package_name) inventory_path = package_dir / "素材清单.md" with open(inventory_path, "w", encoding="utf-8") as f: f.write(inventory_content) # 8. 打包为zip print(f"\n📦 打包为zip...") if output_zip.exists(): output_zip.unlink() with zipfile.ZipFile(str(output_zip), "w", zipfile.ZIP_DEFLATED) as zf: for root, dirs, files in os.walk(package_dir): for file in files: file_path = Path(root) / file arcname = file_path.relative_to(temp_dir) zf.write(str(file_path), str(arcname)) zip_size = _format_file_size(output_zip) print(f" ✅ 打包完成: {output_zip} ({zip_size})") return output_zip finally: # 清理临时目录 if temp_dir.exists(): shutil.rmtree(temp_dir, ignore_errors=True) # ============================================================================ # CLI入口 # ============================================================================ def main(): parser = argparse.ArgumentParser( description="标准化交付打包器 - 将项目产物打包为标准化电商素材包", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: # 基础打包(只含成品图) python scripts/delivery_packager.py \\ --project-dir ./my_project \\ --brand 朗科 \\ --product-name 剃须刀ES-100 \\ --output ./delivery.zip # 完整打包(含多平台+文案+品牌+源文件) python scripts/delivery_packager.py \\ --project-dir ./my_project \\ --brand 朗科 \\ --product-name 剃须刀ES-100 \\ --platforms taobao,kuaishou,xiaohongshu \\ --output ./delivery.zip \\ --include-copywriting \\ --include-brand-assets \\ --include-source # 只打包多平台适配(不含源文件) python scripts/delivery_packager.py \\ --project-dir ./my_project \\ --brand 米家 \\ --product-name 空气炸锅 \\ --platforms taobao,pinduoduo,douyin \\ --output ./delivery.zip \\ --include-copywriting """ ) parser.add_argument( "--project-dir", required=True, help="项目目录路径(包含output/、copywriting.md等产物)" ) parser.add_argument( "--brand", required=True, help="品牌名称" ) parser.add_argument( "--product-name", required=True, help="产品名称" ) parser.add_argument( "--platforms", default=None, help="目标平台,逗号分隔。可选: taobao,kuaishou,xiaohongshu,douyin,pinduoduo,jd,wechat_shop" ) parser.add_argument( "--output", required=True, help="输出zip文件路径" ) parser.add_argument( "--include-copywriting", action="store_true", help="包含文案素材" ) parser.add_argument( "--include-brand-assets", action="store_true", help="包含品牌素材(logo等)" ) parser.add_argument( "--include-source", action="store_true", help="包含源文件(plan.json、场景底图等)" ) args = parser.parse_args() # 验证项目目录 project_dir = Path(args.project_dir) if not project_dir.is_dir(): print(f"[ERROR] 项目目录不存在: {project_dir}", file=sys.stderr) sys.exit(1) # 解析平台 platforms = None if args.platforms: platforms = [p.strip() for p in args.platforms.split(",") if p.strip()] # 使用本地定义的PLATFORM_NAMES作为合法平台列表 valid_platforms = set(PLATFORM_NAMES.keys()) invalid = [p for p in platforms if p not in valid_platforms] if invalid: print(f"[ERROR] 不支持的平台: {', '.join(invalid)}", file=sys.stderr) print(f"[INFO] 支持的平台: {', '.join(valid_platforms)}", file=sys.stderr) sys.exit(1) # 验证输出路径 output_zip = Path(args.output) if not output_zip.suffix.lower() == ".zip": output_zip = output_zip.with_suffix(".zip") output_zip.parent.mkdir(parents=True, exist_ok=True) # 创建打包 print(f"\n{'='*50}") print(f"📦 电商素材标准化交付打包") print(f"{'='*50}") print(f" 品牌: {args.brand}") print(f" 产品: {args.product_name}") if platforms: platform_names = "、".join(PLATFORM_NAMES.get(p, p) for p in platforms) print(f" 平台: {platform_names}") print(f" 文案: {'✓' if args.include_copywriting else '✗'}") print(f" 品牌素材: {'✓' if args.include_brand_assets else '✗'}") print(f" 源文件: {'✓' if args.include_source else '✗'}") print(f"{'='*50}\n") result = create_package( project_dir=project_dir, brand=args.brand, product_name=args.product_name, platforms=platforms, output_zip=output_zip, include_copywriting=args.include_copywriting, include_brand_assets=args.include_brand_assets, include_source=args.include_source, ) print(f"\n{'='*50}") print(f"✅ 交付打包完成!") print(f" 文件: {result}") print(f" 大小: {_format_file_size(result)}") print(f"{'='*50}") if __name__ == "__main__": main() -
layout_engine.py 38.7 KB
#!/usr/bin/env python3 """ layout_engine.py — 素材工坊布局引擎 v1.0 ========================================== 核心职责:将分散的比例计算、场景构图、文字排版、品牌叠加统一到一个布局引擎中, 输出标准化的 LayoutPlan JSON。 架构来源: - calc_scale.py:物理尺寸→像素比例 - compose_v12.py:6种文字布局策略 - brand_assets_v3.py:品牌元素布局参数(margin=3%, logo=25%, badge=14%, bar_h=5.5%) - 视觉布局原则_v1.md:四层空间模型 + 安全区域规则 作者:素材工坊 """ import json import os import sys from dataclasses import dataclass, field, asdict # 尝试导入场景感知合成器的场景配置(用于参照物 prompt) try: _engine_dir = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, _engine_dir) from scene_aware_compositor import SCENE_CONFIGS as _SCENE_CONFIGS HAS_SCENE_CONFIGS = True except ImportError: HAS_SCENE_CONFIGS = False _SCENE_CONFIGS = {} from typing import Optional # ============================================================================ # 数据结构 # ============================================================================ @dataclass class LayoutPlan: """布局方案——布局引擎的最终输出""" canvas_w: int canvas_h: int product_bbox: dict # {"x1","y1","x2","y2","scale_ratio"} 绝对像素 text_zones: list # [{"id","bbox":{"x1","y1","x2","y2"},"layout_type","max_lines"}] brand_zones: dict # {"logo":{"x1","y1","x2","y2"},"guarantee_bar":{...},"badge_365":{...}} scene_tone: str # "dark" or "light" scene_prompt_suffix: str # 场景生成时的空间约束prompt safety_margin: float # 元素间最小间距(占画布宽度比例) image_id: str = "" image_type: str = "" position_strategy: str = "" scale_ratio: float = 0.0 # 产品占画布比例 layout_strategy: str = "" # 文字布局策略名称 def to_dict(self): return asdict(self) def to_json(self, indent=2): return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent) # ============================================================================ # 产品位置策略表 # ============================================================================ PRODUCT_POSITION_STRATEGIES = { # 主图:产品主导 "main_01": {"position": "center", "scale_range": (0.45, 0.60), "priority": "product", "text_hint": "bottom"}, "main_02": {"position": "center-right", "scale_range": (0.50, 0.65), "priority": "product", "text_hint": "left"}, "main_03": {"position": "center", "scale_range": (0.55, 0.70), "priority": "product", "text_hint": "left"}, "main_04": {"position": "center", "scale_range": (0.40, 0.50), "priority": "balanced", "text_hint": "top"}, "main_05": {"position": "center", "scale_range": (0.50, 0.65), "priority": "product", "text_hint": "bottom"}, # 详情图:图文并排 "detail_01": {"position": "center", "scale_range": (0.0, 0.0), "priority": "text", "text_hint": "center", "note": "痛点图不放产品"}, "detail_02": {"position": "center-right","scale_range": (0.35, 0.45), "priority": "balanced", "text_hint": "left"}, "detail_03": {"position": "center-right","scale_range": (0.35, 0.45), "priority": "balanced", "text_hint": "top"}, "detail_04": {"position": "center", "scale_range": (0.40, 0.50), "priority": "balanced", "text_hint": "bottom"}, "detail_05": {"position": "center", "scale_range": (0.35, 0.45), "priority": "balanced", "text_hint": "top"}, "detail_06": {"position": "center", "scale_range": (0.35, 0.45), "priority": "balanced", "text_hint": "bottom"}, } # 文字布局策略名称映射(与compose_v12的6种布局对应) LAYOUT_STRATEGY_MAP = { "bottom_band": {"name": "底部色带", "num": 1}, "top_left_minimal": {"name": "左上极简", "num": 2}, "left_column": {"name": "左侧栏", "num": 3}, "right_column": {"name": "右侧栏", "num": 4}, "bottom_clean": {"name": "底部干净区","num": 5}, "top_band": {"name": "顶部色带", "num": 6}, } # ============================================================================ # 比例计算(源自 calc_scale.py) # ============================================================================ def calculate_scale(product_width_mm, product_height_mm=None, ref_width_mm=80, canvas_width=1024, canvas_height=1024, product_ratio=0.42): """ 根据产品物理尺寸计算在画布上的像素比例。 与 calc_scale.py 完全一致的计算逻辑: - width_ratio = product_width_mm / ref_width_mm - product_canvas_ratio = width_ratio * product_ratio, clamped to [0.1, 0.8] Args: product_width_mm: 产品宽度(mm) product_height_mm: 产品高度(mm),可选 ref_width_mm: 参照物宽度(mm),默认80mm(手掌宽) canvas_width: 画布宽度(px) canvas_height: 画布高度(px) product_ratio: 产品占参照物比例 Returns: dict with product_canvas_ratio, pixel_width, pixel_height """ width_ratio = product_width_mm / ref_width_mm product_canvas_ratio = round(width_ratio * product_ratio, 4) product_canvas_ratio = max(0.1, min(0.8, product_canvas_ratio)) result = { "product_canvas_ratio": product_canvas_ratio, "pixel_width": int(canvas_width * product_canvas_ratio), "canvas_width": canvas_width, "canvas_height": canvas_height, "product_width_mm": product_width_mm, "ref_width_mm": ref_width_mm, } if product_height_mm: aspect = product_height_mm / product_width_mm result["pixel_height"] = int(result["pixel_width"] * aspect) result["product_height_mm"] = product_height_mm return result # ============================================================================ # 布局引擎核心类 # ============================================================================ class LayoutEngine: """ 素材工坊布局引擎 v1.0 核心方法 plan() 的输出是一个完整的 LayoutPlan,涵盖: 1. 产品位置与比例(基于物理尺寸 + 位置策略) 2. 文字安全区域(根据产品位置自动避让) 3. 品牌元素位置(logo / 保障条 / 365标识) 4. 场景生成空间约束 prompt 5. 冲突检测与自动修正 """ # ---------- 品牌区参数(源自 brand_assets_v3.py v17验证值)---------- LOGO_MAX_WIDTH_RATIO = 0.25 # Logo最大宽度 = 25%画布宽 LOGO_MARGIN_RATIO = 0.03 # Logo边距 = 3%画布宽 BADGE_MAX_WIDTH_RATIO = 0.14 # 365标识最大宽度 = 14%画布宽 GUARANTEE_BAR_HEIGHT_RATIO = 0.055 # 保障条高度 = 5.5%画布高 SAFE_MARGIN_RATIO = 0.05 # 元素间最小间距 = 5%画布宽 BRAND_ZONE_TOP_RATIO = 0.14 # 品牌区上边界 = 14%画布高 GUARANTEE_ZONE_BOTTOM_RATIO = 0.98 # 保障区下边界 = 98%画布高 def __init__(self, canvas_w=1000, canvas_h=1000, brand_kit_path=None): """ 初始化布局引擎。 Args: canvas_w: 画布宽度(px) canvas_h: 画布高度(px) brand_kit_path: 品牌资源包路径(可选,用于精确计算logo/badge实际尺寸) """ self.canvas_w = canvas_w self.canvas_h = canvas_h self.brand_kit_path = brand_kit_path # ================================================================ # 公开接口 # ================================================================ def plan(self, product_width_mm, product_height_mm=None, canvas_w=None, canvas_h=None, image_type="main", image_id="main_01", scene_type="tech_gradient", texts=None, brand_name=None, product_view="front", ref_width_mm=80, product_ratio=0.42) -> LayoutPlan: """ 核心方法:计算完整布局方案。 流程: 1. 用calc_scale逻辑算产品比例(物理尺寸→像素比例) 2. 根据image_type+image_id确定产品位置策略 3. 计算product_bbox 4. 根据product_bbox计算text_zones(文字安全区域) 5. 计算brand_zones(logo/保障条/365标识区域) 6. 生成scene_prompt_suffix(空间约束prompt) 7. 冲突检测(确保zones互不重叠) Args: product_width_mm: 产品宽度(mm) product_height_mm: 产品高度(mm),可选 canvas_w: 覆盖默认画布宽度 canvas_h: 覆盖默认画布高度 image_type: "main" 或 "detail" image_id: 图片ID(如 "main_01", "detail_03") scene_type: 场景类型 texts: 文字内容列表 [{"content":..., "style":...}] brand_name: 品牌名(默认不直接使用,品牌区用Logo PNG) product_view: 产品视角 ("front","left","right","bottom","tilted_45") ref_width_mm: 参照物宽度(mm) product_ratio: 产品占参照物比例 Returns: LayoutPlan """ # 允许覆盖画布尺寸 if canvas_w is not None: self.canvas_w = canvas_w if canvas_h is not None: self.canvas_h = canvas_h cw, ch = self.canvas_w, self.canvas_h # Step 1: 比例计算 scale_info = calculate_scale( product_width_mm, product_height_mm, ref_width_mm=ref_width_mm, canvas_width=cw, canvas_height=ch, product_ratio=product_ratio ) calc_ratio = scale_info["product_canvas_ratio"] # Step 2: 获取位置策略 strategy = self._get_position_strategy(image_type, image_id) # 确定最终scale_ratio:在策略范围内结合calc_ratio scale_lo, scale_hi = strategy["scale_range"] if scale_hi == 0.0 and scale_lo == 0.0: # 不放产品的特殊图(如痛点图) final_scale = 0.0 else: # calc_ratio作为基础参考,但受策略范围约束 final_scale = max(scale_lo, min(scale_hi, calc_ratio)) # 如果calc_ratio超出策略范围,使用策略范围的中值 if calc_ratio < scale_lo or calc_ratio > scale_hi: final_scale = (scale_lo + scale_hi) / 2 # Step 3: 计算product_bbox product_bbox = self._compute_product_bbox( final_scale, strategy, cw, ch, product_height_mm, product_width_mm ) # Step 4: 计算text_zones text_zones = self._compute_text_zones( product_bbox, image_type, image_id, texts, strategy ) # Step 5: 计算brand_zones brand_zones = self._compute_brand_zones(cw, ch, product_bbox) # Step 6: 场景色调 scene_tone = self._infer_scene_tone(scene_type, image_id) # Step 7: 生成空间约束prompt(含参照物描述) scene_prompt_suffix = self._generate_scene_prompt_suffix(product_bbox, cw, ch, scene_type=scene_type) # Step 8: 冲突检测与修正 plan = LayoutPlan( canvas_w=cw, canvas_h=ch, product_bbox=product_bbox, text_zones=text_zones, brand_zones=brand_zones, scene_tone=scene_tone, scene_prompt_suffix=scene_prompt_suffix, safety_margin=self.SAFE_MARGIN_RATIO, image_id=image_id, image_type=image_type, position_strategy=strategy["position"], scale_ratio=final_scale, layout_strategy=self._get_layout_strategy_name( product_bbox, cw, ch, strategy ), ) # 冲突检测 conflicts = self._detect_conflicts(plan) if conflicts: plan = self._resolve_conflicts(plan, conflicts) return plan # ================================================================ # 内部方法:位置策略 # ================================================================ def _get_position_strategy(self, image_type, image_id): """获取产品位置策略""" key = image_id if key in PRODUCT_POSITION_STRATEGIES: return PRODUCT_POSITION_STRATEGIES[key] # fallback if image_type == "detail": return PRODUCT_POSITION_STRATEGIES["detail_06"] return PRODUCT_POSITION_STRATEGIES["main_01"] def _get_layout_strategy_name(self, product_bbox, cw, ch, strategy): """根据产品位置确定文字布局策略名称""" if product_bbox["x1"] == 0 and product_bbox["x2"] == 0: # 无产品图 return "bottom_band" center_x_ratio = (product_bbox["x1"] + product_bbox["x2"]) / 2 / cw center_y_ratio = (product_bbox["y1"] + product_bbox["y2"]) / 2 / ch top_ratio = product_bbox["y1"] / ch bottom_ratio = product_bbox["y2"] / ch hint = strategy.get("text_hint", "") # 根据hint和product位置综合判断 if hint == "left" or center_x_ratio > 0.55: return "left_column" elif hint == "top" or (top_ratio > 0.35 and center_y_ratio < 0.4): return "top_band" elif hint == "bottom" or bottom_ratio < 0.65: return "bottom_band" elif center_x_ratio < 0.45: return "right_column" elif hint == "center": return "bottom_band" else: return "left_column" # ================================================================ # 内部方法:产品bbox计算 # ================================================================ def _compute_product_bbox(self, scale_ratio, strategy, cw, ch, product_height_mm=None, product_width_mm=None): """ 计算产品的绝对像素bbox。 Args: scale_ratio: 产品占画布比例 strategy: 位置策略dict cw, ch: 画布尺寸 product_height_mm: 产品高度(用于计算宽高比) product_width_mm: 产品宽度(用于计算宽高比) Returns: dict: {"x1","y1","x2","y2","scale_ratio"} """ if scale_ratio == 0.0: return {"x1": 0, "y1": 0, "x2": 0, "y2": 0, "scale_ratio": 0.0} # 产品宽度(像素) pw = int(cw * scale_ratio) # 产品高度:如果有物理尺寸,按比例算;否则默认1:1 if product_height_mm and product_width_mm and product_width_mm > 0: aspect = product_height_mm / product_width_mm ph = int(pw * aspect) else: ph = pw # 默认正方形 # 产品不应超过画布高度的80% max_ph = int(ch * 0.80) if ph > max_ph: ph = max_ph # 反算宽度保持比例 if product_height_mm and product_width_mm and product_height_mm > 0: pw = int(ph * product_width_mm / product_height_mm) # 根据position策略确定中心点 position = strategy["position"] cx, cy = self._resolve_position(position, cw, ch, pw, ph) x1 = cx - pw // 2 y1 = cy - ph // 2 x2 = x1 + pw y2 = y1 + ph # 边界修正:不超出画布 if x1 < 0: x2 -= x1 x1 = 0 if y1 < 0: y2 -= y1 y1 = 0 if x2 > cw: x1 -= (x2 - cw) x2 = cw if y2 > ch: y1 -= (y2 - ch) y2 = ch return { "x1": x1, "y1": y1, "x2": x2, "y2": y2, "scale_ratio": round(scale_ratio, 4), } def _resolve_position(self, position, cw, ch, pw, ph): """ 根据位置策略计算产品中心点。 位置策略: - "center": 画面正中 - "center-right": 水平偏右(55%),垂直居中 - "right": 靠右(70%),垂直居中 - "left": 靠左(35%),垂直居中 - "center-left": 水平偏左(45%),垂直居中 """ position_map = { "center": (0.50, 0.45), "center-right": (0.55, 0.48), "right": (0.68, 0.48), "left": (0.35, 0.48), "center-left": (0.42, 0.48), } rx, ry = position_map.get(position, (0.50, 0.45)) return int(cw * rx), int(ch * ry) # ================================================================ # 内部方法:文字区域计算 # ================================================================ def _compute_text_zones(self, product_bbox, image_type, image_id, texts, strategy): """ 根据产品位置,自动选择文字布局并计算安全区域。 决策树(与视觉布局原则v1一致): 1. 无产品 → 全画面文字区 2. 产品居中偏右(center_x > 0.55) → 文字放左侧 3. 产品居中偏左(center_x < 0.45) → 文字放右侧 4. 产品占满中心 → 文字放顶部或底部色带 5. 产品在上方(bottom < 0.6) → 文字放底部 6. 产品在下方(top > 0.35) → 文字放顶部 每种情况计算text_zone bbox,确保: - 不覆盖product_bbox(+5%安全间距) - 不覆盖brand_zones - 宽度足够容纳文字(至少20%画布宽度) """ cw, ch = self.canvas_w, self.canvas_h margin_px = int(cw * self.SAFE_MARGIN_RATIO) # 无产品的特殊图(痛点图) pw = product_bbox["x2"] - product_bbox["x1"] if pw == 0: return [self._make_text_zone( "main_text", int(cw * 0.06), int(ch * 0.06), int(cw * 0.94), int(ch * 0.40), "center_full", max_lines=4 )] # 计算产品归一化位置 pcx = (product_bbox["x1"] + product_bbox["x2"]) / 2 / cw pcy = (product_bbox["y1"] + product_bbox["y2"]) / 2 / ch p_top = product_bbox["y1"] / ch p_bottom = product_bbox["y2"] / ch p_left = product_bbox["x1"] / cw p_right = product_bbox["x2"] / cw hint = strategy.get("text_hint", "") zones = [] # ---------- 策略A: 文字在左侧 ---------- if hint == "left" or (pcx > 0.55 and hint != "top" and hint != "bottom"): # 左侧文字区域:从左边距到产品左边界-间距 text_right = product_bbox["x1"] - margin_px text_left = int(cw * 0.04) text_top = max(int(ch * 0.16), product_bbox["y1"]) # 不低于产品顶部 text_bottom = min(int(ch * 0.85), product_bbox["y2"]) # 不高于产品底部 # 确保最小宽度 min_w = int(cw * 0.20) if text_right - text_left < min_w: text_left = max(0, text_right - min_w) zones.append(self._make_text_zone( "main_text", text_left, text_top, text_right, text_bottom, "left_column", max_lines=4 )) # ---------- 策略B: 文字在右侧 ---------- elif hint == "right" or pcx < 0.45: text_left = product_bbox["x2"] + margin_px text_right = int(cw * 0.96) text_top = max(int(ch * 0.16), product_bbox["y1"]) text_bottom = min(int(ch * 0.85), product_bbox["y2"]) min_w = int(cw * 0.20) if text_right - text_left < min_w: text_right = min(cw, text_left + min_w) zones.append(self._make_text_zone( "main_text", text_left, text_top, text_right, text_bottom, "right_column", max_lines=4 )) # ---------- 策略C: 文字在顶部色带 ---------- elif hint == "top" or p_top > 0.35: # 顶部色带区域:品牌区下方,产品上方 text_top = int(ch * 0.03) text_bottom = min(product_bbox["y1"] - margin_px, int(ch * 0.18)) if text_bottom - text_top < int(ch * 0.08): text_bottom = int(ch * 0.18) # 顶部色带文字从logo右侧开始(避开logo区) text_left = int(cw * 0.38) text_right = int(cw * 0.95) zones.append(self._make_text_zone( "main_text", text_left, text_top, text_right, text_bottom, "top_band", max_lines=2 )) # ---------- 策略D: 文字在底部色带 ---------- elif hint == "bottom" or p_bottom < 0.65: # 底部色带:产品下方到保障条上方 text_top = max(product_bbox["y2"] + margin_px, int(ch * 0.70)) text_bottom = int(ch * 0.88) # 保障条上边界 text_left = int(cw * 0.06) text_right = int(cw * 0.94) if text_bottom - text_top < int(ch * 0.08): # 空间不够,使用overlay式底部色带 text_top = int(ch * 0.82) text_bottom = int(ch * 0.88) zones.append(self._make_text_zone( "main_text", text_left, text_top, text_right, text_bottom, "bottom_band", max_lines=2 )) # ---------- 策略E: 默认(底部色带兜底)---------- else: text_top = max(product_bbox["y2"] + margin_px, int(ch * 0.75)) text_bottom = int(ch * 0.88) text_left = int(cw * 0.06) text_right = int(cw * 0.94) zones.append(self._make_text_zone( "main_text", text_left, text_top, text_right, text_bottom, "bottom_band", max_lines=2 )) # 如果有副文本(多行副标题),添加辅助文字区 if texts and len(texts) > 1: # 副文本放在主文本下方 main_zone = zones[0] sub_top = main_zone["bbox"]["y2"] + int(ch * 0.01) sub_bottom = min(sub_top + int(ch * 0.10), int(ch * 0.88)) if sub_bottom > sub_top: zones.append(self._make_text_zone( "sub_text", main_zone["bbox"]["x1"], sub_top, main_zone["bbox"]["x2"], sub_bottom, "sub_text", max_lines=2 )) return zones def _make_text_zone(self, zone_id, x1, y1, x2, y2, layout_type, max_lines=2): """构造文字区域dict""" return { "id": zone_id, "bbox": { "x1": max(0, x1), "y1": max(0, y1), "x2": min(self.canvas_w, x2), "y2": min(self.canvas_h, y2), }, "layout_type": layout_type, "max_lines": max_lines, } # ================================================================ # 内部方法:品牌区域计算 # ================================================================ def _compute_brand_zones(self, cw, ch, product_bbox): """ 计算品牌元素的绝对像素位置。 固定规则(源自brand_assets_v3.py v17验证参数): - Logo: 左上角,max_width = 25%画布宽,margin = 3% - 保障条: 底部,高度5.5%画布高,右边界 = badge_x - margin - 365标识: 右下角,max_width = 14%画布宽 - 关键:保障条右边界要给365标识留空,不能重叠 四层空间模型(源自视觉布局原则v1): - 品牌区: 0~14% 高度 - 内容区: 14%~88% 高度 - 保障区: 88%~98% 高度 - 安全边距: 98%~100% 高度 """ margin = int(cw * self.LOGO_MARGIN_RATIO) # 3% # === Logo区域 === logo_max_w = int(cw * self.LOGO_MAX_WIDTH_RATIO) # 25% # Logo高宽比约0.4(含slogan),估算实际高度 logo_h = int(logo_max_w * 0.4) logo_zone = { "x1": margin, "y1": margin, "x2": margin + logo_max_w, "y2": margin + logo_h, } # === 365标识区域 === badge_max_w = int(cw * self.BADGE_MAX_WIDTH_RATIO) # 14% # badge高宽比约1.0(圆形/方形) badge_h = int(badge_max_w * 0.85) badge_x = cw - badge_max_w - margin # 保障条高度 bar_h = int(ch * self.GUARANTEE_BAR_HEIGHT_RATIO) # 5.5% bar_y = ch - bar_h - margin # badge底部在保障条上方,留2%间距 badge_y = bar_y - badge_h - int(ch * 0.02) badge_zone = { "x1": badge_x, "y1": badge_y, "x2": badge_x + badge_max_w, "y2": badge_y + badge_h, } # === 保障条区域 === # 保障条右边界 = badge左边界 - margin(给365标识留空) bar_right = badge_x - margin guarantee_bar_zone = { "x1": 0, "y1": bar_y, "x2": bar_right, "y2": ch - margin, } return { "logo": logo_zone, "guarantee_bar": guarantee_bar_zone, "badge_365": badge_zone, } # ================================================================ # 内部方法:场景色调推断 # ================================================================ def _infer_scene_tone(self, scene_type, image_id): """根据场景类型推断色调""" dark_scenes = [ "tech_gradient", "tech_dark", "dark_business", "hotel_night", "luxury_dark", "brushed_metal", "carbon_fiber" ] light_scenes = [ "white_clean", "white_studio", "bright_natural", "bathroom_bright" ] if scene_type in dark_scenes: return "dark" elif scene_type in light_scenes: return "light" # 根据image_id推断 if image_id in ("main_05", "detail_06"): return "light" # 参数图通常白底 return "dark" # 默认暗色 # ================================================================ # 内部方法:空间约束prompt生成 # ================================================================ def _generate_scene_prompt_suffix(self, product_bbox, cw, ch, scene_type=None): """ 生成场景生成时的空间约束prompt。 v1.1 改进: - 不再使用 "keep area empty"(会导致 AI 生成黑色方块) - 改为 "continuous scene texture, no prominent objects" - 如果 scene_type 有对应的参照物配置,自动追加参照物描述 Args: product_bbox: 产品区域的像素坐标 cw, ch: 画布宽高 scene_type: 场景类型(可选,用于追加参照物描述) """ pw = product_bbox["x2"] - product_bbox["x1"] if pw == 0: return "full frame scene, no reserved area needed" # 核心约束:连续纹理 + 无突出物体(替代旧的 "keep empty" 方案) prompt = ( "should have continuous scene texture and soft lighting " "matching the overall scene style, no prominent objects" ) # 如果有场景类型的参照物配置,追加参照物描述 if scene_type and HAS_SCENE_CONFIGS and scene_type in _SCENE_CONFIGS: config = _SCENE_CONFIGS[scene_type] if config.reference_objects: ref_descriptions = ", ".join( ref.prompt_desc for ref in config.reference_objects ) prompt += f", {ref_descriptions}" return prompt # ================================================================ # 冲突检测与修正 # ================================================================ def _detect_conflicts(self, plan): """ 检测所有zones之间是否有重叠。 检查: - product_bbox vs text_zones - product_bbox vs brand_zones - text_zones vs brand_zones - brand_zones之间(logo vs guarantee vs badge) Returns: list of conflict tuples: (zone_a_name, zone_a_bbox, zone_b_name, zone_b_bbox) """ conflicts = [] bboxes = {} bboxes["product"] = plan.product_bbox for i, tz in enumerate(plan.text_zones): bboxes[f"text_{i}"] = tz["bbox"] for key, bz in plan.brand_zones.items(): bboxes[f"brand_{key}"] = bz # 两两检测重叠 names = list(bboxes.keys()) for i in range(len(names)): for j in range(i + 1, len(names)): a_name = names[i] b_name = names[j] a = bboxes[a_name] b = bboxes[b_name] # 跳过空bbox if (a["x2"] - a["x1"]) <= 0 or (b["x2"] - b["x1"]) <= 0: continue # AABB重叠检测 if self._bbox_overlap(a, b): overlap_area = self._overlap_area(a, b) # 只在重叠面积大于小区域5%时报告冲突 min_area = min( (a["x2"]-a["x1"]) * (a["y2"]-a["y1"]), (b["x2"]-b["x1"]) * (b["y2"]-b["y1"]) ) if min_area > 0 and overlap_area / min_area > 0.05: conflicts.append((a_name, a, b_name, b)) return conflicts def _bbox_overlap(self, a, b): """AABB重叠检测""" return not ( a["x2"] <= b["x1"] or b["x2"] <= a["x1"] or a["y2"] <= b["y1"] or b["y2"] <= a["y1"] ) def _overlap_area(self, a, b): """计算重叠面积""" ox1 = max(a["x1"], b["x1"]) oy1 = max(a["y1"], b["y1"]) ox2 = min(a["x2"], b["x2"]) oy2 = min(a["y2"], b["y2"]) if ox2 <= ox1 or oy2 <= oy1: return 0 return (ox2 - ox1) * (oy2 - oy1) def _resolve_conflicts(self, plan, conflicts): """ 尝试自动修正冲突。 修正策略: 1. text vs product: 缩小文字区域,使其不覆盖产品 2. text vs brand: 缩小文字区域,避开品牌区 3. product vs brand: 产品优先,品牌区不动(品牌区是固定的) 4. brand内部: 保障条右边界收缩 """ for a_name, a_bbox, b_name, b_bbox in conflicts: # 文字区 vs 产品区:缩小文字区 if a_name.startswith("text_") and b_name == "product": self._shrink_text_zone(plan, a_name, b_bbox) elif a_name == "product" and b_name.startswith("text_"): self._shrink_text_zone(plan, b_name, a_bbox) # 文字区 vs 品牌区:缩小文字区 elif a_name.startswith("text_") and b_name.startswith("brand_"): self._shrink_text_zone(plan, a_name, b_bbox) elif a_name.startswith("brand_") and b_name.startswith("text_"): self._shrink_text_zone(plan, b_name, a_bbox) # 保障条 vs 365标识:收缩保障条右边界 elif a_name == "brand_guarantee_bar" and b_name == "brand_badge_365": for tz in plan.text_zones: pass # brand_zones已在_compute_brand_zones中处理 # 直接修正保障条右边界 plan.brand_zones["guarantee_bar"]["x2"] = ( plan.brand_zones["badge_365"]["x1"] - int(self.canvas_w * self.LOGO_MARGIN_RATIO) ) return plan def _shrink_text_zone(self, plan, zone_name, obstacle_bbox): """缩小文字区域以避开障碍区域""" for tz in plan.text_zones: if f"text_{plan.text_zones.index(tz)}" == zone_name: bbox = tz["bbox"] # 尝试从右侧收缩 if bbox["x1"] < obstacle_bbox["x2"] < bbox["x2"]: tz["bbox"]["x1"] = obstacle_bbox["x2"] + int(self.canvas_w * self.SAFE_MARGIN_RATIO) # 尝试从左侧收缩 elif bbox["x1"] < obstacle_bbox["x1"] < bbox["x2"]: tz["bbox"]["x2"] = obstacle_bbox["x1"] - int(self.canvas_w * self.SAFE_MARGIN_RATIO) # 尝试从底部收缩 if bbox["y1"] < obstacle_bbox["y2"] < bbox["y2"]: tz["bbox"]["y1"] = obstacle_bbox["y2"] + int(self.canvas_h * self.SAFE_MARGIN_RATIO) # 尝试从顶部收缩 elif bbox["y1"] < obstacle_bbox["y1"] < bbox["y2"]: tz["bbox"]["y2"] = obstacle_bbox["y1"] - int(self.canvas_h * self.SAFE_MARGIN_RATIO) break # ============================================================================ # 可视化报告(文本形式) # ============================================================================ def print_layout_report(plan): """打印文本形式的布局示意图""" cw, ch = plan.canvas_w, plan.canvas_h print(f"\n{'='*60}") print(f" LayoutPlan 可视化报告 — {plan.image_id}") print(f"{'='*60}") print(f" 画布: {cw}×{ch}px | 色调: {plan.scene_tone} | 安全间距: {plan.safety_margin:.0%}") print(f" 产品比例: {plan.scale_ratio:.1%} | 位置策略: {plan.position_strategy}") print(f" 文字布局: {plan.layout_strategy}") print() # 用ASCII画布可视化 W = 40 # 终端宽度(字符) H = 30 # 终端高度(字符) # 创建画布 canvas = [["·" for _ in range(W)] for _ in range(H)] def draw_rect(r1, c1, r2, c2, char, fill=None): """在ASCII画布上画矩形""" for r in range(max(0, r1), min(H, r2)): for c in range(max(0, c1), min(W, c2)): canvas[r][c] = char if fill is None else fill def to_grid(bbox): """像素坐标→网格坐标""" return ( int(bbox["y1"] / ch * H), int(bbox["x1"] / cw * W), int(bbox["y2"] / ch * H), int(bbox["x2"] / cw * W), ) # 1. 绘制品牌区边界 brand_line = int(ch * 0.14 / ch * H) for c in range(W): canvas[brand_line][c] = "─" # 2. 绘制保障区边界 guarantee_line = int(ch * 0.88 / ch * H) for c in range(W): canvas[guarantee_line][c] = "─" # 3. 绘制产品区域 pb = plan.product_bbox if pb["x2"] - pb["x1"] > 0: r1, c1, r2, c2 = to_grid(pb) draw_rect(r1, c1, r2, c2, "█") # 4. 绘制文字区域 for tz in plan.text_zones: r1, c1, r2, c2 = to_grid(tz["bbox"]) draw_rect(r1, c1, r2, c2, "░") # 5. 绘制品牌元素 logo = plan.brand_zones["logo"] r1, c1, r2, c2 = to_grid(logo) draw_rect(r1, c1, r2, c2, "L") badge = plan.brand_zones["badge_365"] r1, c1, r2, c2 = to_grid(badge) draw_rect(r1, c1, r2, c2, "B") gbar = plan.brand_zones["guarantee_bar"] r1, c1, r2, c2 = to_grid(gbar) draw_rect(r1, c1, r2, c2, "▬") # 打印画布 print(f" {'┌' + '─'*W + '┐'}") for row in canvas: print(f" │{''.join(row)}│") print(f" {'└' + '─'*W + '┘'}") print() # 图例 print(f" 图例: █=产品 ░=文字 L=Logo B=365标识 ▬=保障条") print(f" ─=品牌区边界(14%) ─=保障区边界(88%)") print() # 详细数据 print(f" ── 产品区域 ──") print(f" 像素: ({pb['x1']}, {pb['y1']}) → ({pb['x2']}, {pb['y2']})") print(f" 比例: ({pb['x1']/cw:.1%}, {pb['y1']/ch:.1%}) → ({pb['x2']/cw:.1%}, {pb['y2']/ch:.1%})") print(f" 尺寸: {pb['x2']-pb['x1']}×{pb['y2']-pb['y1']}px (scale={pb['scale_ratio']:.1%})") print() print(f" ── 文字区域 ──") for tz in plan.text_zones: b = tz["bbox"] print(f" [{tz['id']}] ({b['x1']},{b['y1']})→({b['x2']},{b['y2']}) " f"| {tz['layout_type']} | max_lines={tz['max_lines']}") print() print(f" ── 品牌区域 ──") for key, bz in plan.brand_zones.items(): print(f" [{key}] ({bz['x1']},{bz['y1']})→({bz['x2']},{bz['y2']})") print() print(f" ── 空间约束Prompt ──") print(f" {plan.scene_prompt_suffix}") print() # 冲突检测结果 conflicts = plan.scene_prompt_suffix # 临时占位 print(f" ── 冲突检测 ──") detected = LayoutEngine(cw, ch)._detect_conflicts(plan) if detected: for a_name, a, b_name, b in detected: print(f" ⚠ 冲突: {a_name} vs {b_name}") else: print(f" ✓ 无冲突,所有区域互不重叠") print(f"{'='*60}\n") # ============================================================================ # 便捷函数 # ============================================================================ def plan_from_config(img_config, engine=None): """ 从plan_v7.json的单个图片配置生成LayoutPlan。 Args: img_config: plan_v7.json中的单个image dict engine: 已有的LayoutEngine实例(可选) Returns: LayoutPlan """ image_id = img_config["id"] image_type = "detail" if image_id.startswith("detail") else "main" size = img_config.get("size", [1000, 1000]) comp = img_config.get("product_composition", {}) # 从plan_v7.json获取场景类型 scene_prompt = img_config.get("scene_prompt", "") scene_type = "tech_dark" if "white" in scene_prompt.lower() or "bright" in scene_prompt.lower(): scene_type = "white_clean" elif "dark" in scene_prompt.lower(): scene_type = "tech_dark" elif "hotel" in scene_prompt.lower(): scene_type = "hotel_night" elif "bathroom" in scene_prompt.lower(): scene_type = "bright_natural" elif "metal" in scene_prompt.lower(): scene_type = "brushed_metal" if engine is None: engine = LayoutEngine(canvas_w=size[0], canvas_h=size[1]) else: engine.canvas_w = size[0] engine.canvas_h = size[1] plan = engine.plan( product_width_mm=74, product_height_mm=39, image_type=image_type, image_id=image_id, scene_type=scene_type, texts=img_config.get("texts"), product_view=comp.get("angle", "front"), ) return plan # ============================================================================ # CLI # ============================================================================ if __name__ == "__main__": import sys # 默认跑一个示例 engine = LayoutEngine(1000, 1000) plan = engine.plan( product_width_mm=74, product_height_mm=39, image_type="main", image_id="main_01", scene_type="tech_gradient", ) print(plan.to_json()) print_layout_report(plan) -
platform_adapter.py 22.8 KB
#!/usr/bin/env python3 """ platform_adapter.py — 多平台尺寸适配引擎 ========================================== 将已生成的成品图自动适配不同电商平台要求的尺寸规格。 核心功能: 1. 自动识别成品图中的主图(1:1)和详情图(3:4) 2. 按目标平台规格进行 resize/crop/pad 3. 支持三种适配模式:fit(等比+padding)、fill(等比+裁切)、stretch(拉伸) 4. 自动文件大小控制(超标时降低JPEG质量) 5. 输出适配报告JSON Usage: python scripts/platform_adapter.py \\ --input-dir <成品图目录> \\ --platforms taobao,kuaishou,xiaohongshu,douyin,pinduoduo \\ --output-dir <输出根目录> \\ --resize-mode fit \\ --bg-color "#ffffff" 依赖: Pillow """ import argparse import json import os import sys from pathlib import Path from typing import Dict, List, Optional, Tuple, Any from PIL import Image # ============================================================================ # 平台尺寸规格 # ============================================================================ PLATFORM_SPECS: Dict[str, Dict[str, Any]] = { "taobao": { "name": "淘宝/天猫", "main_image": {"width": 800, "height": 800, "ratio": "1:1"}, "detail_image": {"width": 750, "height": "auto", "ratio": "3:4", "max_height": 1200}, "long_detail": {"width": 790, "height": "auto", "ratio": "自由高度"}, "max_file_size_kb": 500, "format": "jpg", "color_profile": "sRGB" }, "kuaishou": { "name": "快手", "main_image": {"width": 800, "height": 800, "ratio": "1:1"}, "detail_image": {"width": 750, "height": 1000, "ratio": "3:4"}, "max_file_size_kb": 500, "format": "jpg", "color_profile": "sRGB" }, "xiaohongshu": { "name": "小红书", "main_image": {"width": 1080, "height": 1440, "ratio": "3:4"}, "square_image": {"width": 1080, "height": 1080, "ratio": "1:1"}, "detail_image": {"width": 1080, "height": 1440, "ratio": "3:4"}, "max_file_size_kb": 5000, "format": "jpg", "color_profile": "sRGB" }, "douyin": { "name": "抖音", "main_image": {"width": 800, "height": 800, "ratio": "1:1"}, "detail_image": {"width": 750, "height": 1000, "ratio": "3:4"}, "max_file_size_kb": 500, "format": "jpg", "color_profile": "sRGB" }, "pinduoduo": { "name": "拼多多", "main_image": {"width": 750, "height": 750, "ratio": "1:1"}, "detail_image": {"width": 750, "height": "auto", "ratio": "自由比例"}, "max_file_size_kb": 300, "format": "jpg", "color_profile": "sRGB" }, "jd": { "name": "京东", "main_image": {"width": 800, "height": 800, "ratio": "1:1"}, "detail_image": {"width": 750, "height": "auto", "ratio": "自由高度", "max_width": 1000}, "max_file_size_kb": 500, "format": "jpg", "color_profile": "sRGB" }, "wechat_shop": { "name": "微信小商店", "main_image": {"width": 750, "height": 750, "ratio": "1:1"}, "detail_image": {"width": 750, "height": "auto", "ratio": "自由比例"}, "max_file_size_kb": 500, "format": "jpg", "color_profile": "sRGB" } } # ============================================================================ # 图片分类 # ============================================================================ def classify_images(input_dir: Path) -> Dict[str, List[Path]]: """ 扫描输入目录,按图片比例分类为主图(1:1)和详情图(3:4)。 Args: input_dir: 成品图所在目录 Returns: {"main": [path, ...], "detail": [path, ...], "unknown": [path, ...]} """ result: Dict[str, List[Path]] = {"main": [], "detail": [], "unknown": []} image_extensions = {".png", ".jpg", ".jpeg", ".webp"} for f in sorted(input_dir.iterdir()): if f.suffix.lower() not in image_extensions: continue try: with Image.open(f) as img: w, h = img.size if w <= 0 or h <= 0: result["unknown"].append(f) continue ratio = w / h # 1:1 ± 10% 判定为主图 if abs(ratio - 1.0) < 0.10: result["main"].append(f) # 3:4(0.75) ± 10% 判定为详情图 elif abs(ratio - 0.75) < 0.10: result["detail"].append(f) # 4:3(1.33) ± 10% 也归为详情图(横版) elif abs(ratio - 1.33) < 0.10: result["detail"].append(f) else: result["unknown"].append(f) except Exception as e: print(f" [WARN] 无法读取 {f.name}: {e}", file=sys.stderr) result["unknown"].append(f) return result def get_image_type_from_filename(filename: str) -> Optional[str]: """ 从文件名推断类型(main_xx / detail_xx)。 作为比例分类的补充。 """ name_lower = filename.lower() if "main" in name_lower: return "main" elif "detail" in name_lower: return "detail" return None # ============================================================================ # 尺寸计算 # ============================================================================ def resolve_target_size( spec: Dict[str, Any], image_type: str, source_ratio: float ) -> Tuple[int, int]: """ 根据平台规格和图片类型,计算目标尺寸。 Args: spec: 单个平台的规格定义(PLATFORM_SPECS中的值) image_type: "main" 或 "detail" source_ratio: 源图的宽高比 Returns: (target_width, target_height) """ if image_type == "main": key = "main_image" # 小红书的主图是3:4,需要匹配square_image if "square_image" in spec and spec["main_image"]["ratio"] == "3:4": key = "square_image" else: key = "detail_image" target_spec = spec.get(key) if target_spec is None: # 回退到main_image target_spec = spec.get("main_image", {}) target_w = target_spec["width"] target_h = target_spec["height"] if target_h == "auto": # 自由高度模式:按源图比例计算,受max_height限制 target_h = int(target_w / source_ratio) if source_ratio > 0 else target_w max_h = target_spec.get("max_height") if max_h and target_h > max_h: target_h = max_h # 自由比例模式也做宽度限制 max_w = target_spec.get("max_width") if max_w and target_w > max_w: target_w = max_w target_h = int(target_w / source_ratio) if source_ratio > 0 else target_w return target_w, target_h # ============================================================================ # 缩放模式实现 # ============================================================================ def resize_fit( img: Image.Image, target_w: int, target_h: int, bg_color: Tuple[int, int, int] ) -> Image.Image: """ fit模式:等比缩放 + 白底/指定色padding到目标尺寸。 保证图片完整显示,不裁切任何内容。 """ src_w, src_h = img.size # 计算等比缩放的scale scale = min(target_w / src_w, target_h / src_h) new_w = max(1, int(src_w * scale)) new_h = max(1, int(src_h * scale)) resized = img.resize((new_w, new_h), Image.LANCZOS) # 创建目标尺寸的背景画布 canvas = Image.new("RGB", (target_w, target_h), bg_color) # 居中粘贴 paste_x = (target_w - new_w) // 2 paste_y = (target_h - new_h) // 2 if resized.mode == "RGBA": canvas.paste(resized, (paste_x, paste_y), resized) else: canvas.paste(resized, (paste_x, paste_y)) return canvas def resize_fill( img: Image.Image, target_w: int, target_h: int ) -> Image.Image: """ fill模式:等比缩放 + 居中裁切到目标尺寸。 填满目标区域,可能裁切边缘。 """ src_w, src_h = img.size # 计算填满的scale scale = max(target_w / src_w, target_h / src_h) new_w = max(1, int(src_w * scale)) new_h = max(1, int(src_h * scale)) resized = img.resize((new_w, new_h), Image.LANCZOS) # 居中裁切 left = (new_w - target_w) // 2 top = (new_h - target_h) // 2 cropped = resized.crop((left, top, left + target_w, top + target_h)) return cropped def resize_stretch( img: Image.Image, target_w: int, target_h: int ) -> Image.Image: """ stretch模式:直接拉伸到目标尺寸(不推荐,但某些平台需要精确像素)。 """ return img.resize((target_w, target_h), Image.LANCZOS) # ============================================================================ # 文件大小控制 # ============================================================================ def save_with_size_control( img: Image.Image, output_path: Path, max_size_kb: int, fmt: str = "jpg" ) -> int: """ 保存图片,如果文件超过max_size_kb则逐步降低JPEG质量。 Args: img: PIL Image对象 output_path: 输出路径 max_size_kb: 最大文件大小(KB) fmt: 输出格式 Returns: 最终文件大小(字节) """ # 确保是RGB模式(JPEG不支持RGBA) if img.mode == "RGBA": # 合成到白色背景 bg = Image.new("RGB", img.size, (255, 255, 255)) bg.paste(img, mask=img.split()[3]) img = bg elif img.mode != "RGB": img = img.convert("RGB") max_size_bytes = max_size_kb * 1024 # 尝试不同质量等级 quality_levels = [92, 85, 78, 70, 60, 50, 40] for quality in quality_levels: img.save(str(output_path), "JPEG", quality=quality, optimize=True) file_size = output_path.stat().st_size if file_size <= max_size_bytes: return file_size # 所有质量级别都超标,使用最低质量 img.save(str(output_path), "JPEG", quality=quality_levels[-1], optimize=True) file_size = output_path.stat().st_size return file_size # ============================================================================ # 核心适配流程 # ============================================================================ def adapt_image( img_path: Path, platform_key: str, image_type: str, output_dir: Path, resize_mode: str, bg_color: Tuple[int, int, int], max_size_kb: int ) -> Dict[str, Any]: """ 将单张图片适配到指定平台。 Args: img_path: 源图片路径 platform_key: 平台标识 image_type: "main" 或 "detail" output_dir: 平台输出目录 resize_mode: "fit" / "fill" / "stretch" bg_color: 背景色RGB元组 max_size_kb: 最大文件大小(KB) Returns: 适配报告字典 """ spec = PLATFORM_SPECS[platform_key] report: Dict[str, Any] = { "source_file": str(img_path), "platform": platform_key, "image_type": image_type, "resize_mode": resize_mode } try: with Image.open(img_path) as img: src_w, src_h = img.size report["source_size"] = f"{src_w}x{src_h}" source_ratio = src_w / src_h if src_h > 0 else 1.0 # 计算目标尺寸 target_w, target_h = resolve_target_size(spec, image_type, source_ratio) report["target_size"] = f"{target_w}x{target_h}" # 执行缩放 if resize_mode == "fill": result = resize_fill(img, target_w, target_h) elif resize_mode == "stretch": result = resize_stretch(img, target_w, target_h) else: # fit result = resize_fit(img, target_w, target_h, bg_color) # 输出文件名 output_name = img_path.stem + "." + spec["format"] output_path = output_dir / output_name # 保存并控制文件大小 final_size = save_with_size_control( result, output_path, max_size_kb, spec["format"] ) report["output_file"] = str(output_path) report["final_size_bytes"] = final_size report["final_size_kb"] = round(final_size / 1024, 1) report["compliant"] = final_size <= max_size_kb * 1024 final_w, final_h = result.size report["final_dimensions"] = f"{final_w}x{final_h}" except Exception as e: report["error"] = str(e) report["compliant"] = False return report def adapt_for_platform( images: Dict[str, List[Path]], platform_key: str, output_dir: Path, resize_mode: str, bg_color: Tuple[int, int, int] ) -> List[Dict[str, Any]]: """ 将所有图片适配到单个平台。 Args: images: classify_images的输出 platform_key: 平台标识 output_dir: 平台输出目录 resize_mode: 缩放模式 bg_color: 背景色 Returns: 该平台所有图片的适配报告列表 """ spec = PLATFORM_SPECS[platform_key] max_size_kb = spec["max_file_size_kb"] platform_output_dir = output_dir / platform_key platform_output_dir.mkdir(parents=True, exist_ok=True) reports: List[Dict[str, Any]] = [] # 处理主图 for img_path in images["main"]: report = adapt_image( img_path, platform_key, "main", platform_output_dir, resize_mode, bg_color, max_size_kb ) reports.append(report) # 处理详情图 for img_path in images["detail"]: report = adapt_image( img_path, platform_key, "detail", platform_output_dir, resize_mode, bg_color, max_size_kb ) reports.append(report) # 处理未分类图片(按文件名推断) for img_path in images["unknown"]: inferred_type = get_image_type_from_filename(img_path.name) if inferred_type: report = adapt_image( img_path, platform_key, inferred_type, platform_output_dir, resize_mode, bg_color, max_size_kb ) reports.append(report) else: # 默认当主图处理 report = adapt_image( img_path, platform_key, "main", platform_output_dir, resize_mode, bg_color, max_size_kb ) report["warning"] = "未识别图片类型,按主图处理" reports.append(report) return reports # ============================================================================ # 颜色解析 # ============================================================================ def parse_color(color_str: str) -> Tuple[int, int, int]: """ 解析颜色字符串为RGB元组。 支持: "#ffffff", "#fff", "255,255,255", "white" """ color_str = color_str.strip() # hex格式 if color_str.startswith("#"): hex_str = color_str[1:] if len(hex_str) == 3: hex_str = "".join(c * 2 for c in hex_str) if len(hex_str) == 6: r = int(hex_str[0:2], 16) g = int(hex_str[2:4], 16) b = int(hex_str[4:6], 16) return (r, g, b) # RGB格式 if "," in color_str: parts = color_str.split(",") if len(parts) == 3: return tuple(int(p.strip()) for p in parts) # 预定义颜色 color_map = { "white": (255, 255, 255), "black": (0, 0, 0), "gray": (128, 128, 128), "lightgray": (230, 230, 230), } return color_map.get(color_str.lower(), (255, 255, 255)) # ============================================================================ # 适配报告汇总 # ============================================================================ def generate_summary_report( all_reports: Dict[str, List[Dict[str, Any]]], platforms: List[str] ) -> Dict[str, Any]: """ 生成汇总适配报告。 """ summary = { "generated_at": __import__("datetime").datetime.now().isoformat(), "platforms": {}, "overall": { "total_images": 0, "compliant_count": 0, "non_compliant_count": 0, "errors": [] } } for platform_key in platforms: reports = all_reports.get(platform_key, []) spec = PLATFORM_SPECS.get(platform_key, {}) platform_summary = { "platform_name": spec.get("name", platform_key), "total_images": len(reports), "compliant": sum(1 for r in reports if r.get("compliant")), "non_compliant": sum(1 for r in reports if not r.get("compliant")), "max_size_kb": spec.get("max_file_size_kb", 0), "images": [] } for r in reports: img_info = { "source": Path(r["source_file"]).name, "type": r["image_type"], "target_size": r.get("target_size", "N/A"), "final_size_kb": r.get("final_size_kb", 0), "compliant": r.get("compliant", False) } if "error" in r: img_info["error"] = r["error"] summary["overall"]["errors"].append( f"{platform_key}/{r['source_file']}: {r['error']}" ) if "warning" in r: img_info["warning"] = r["warning"] platform_summary["images"].append(img_info) summary["platforms"][platform_key] = platform_summary summary["overall"]["total_images"] += platform_summary["total_images"] summary["overall"]["compliant_count"] += platform_summary["compliant"] summary["overall"]["non_compliant_count"] += platform_summary["non_compliant"] return summary # ============================================================================ # CLI入口 # ============================================================================ def main(): parser = argparse.ArgumentParser( description="多平台尺寸适配引擎 - 将成品图自动适配到各电商平台尺寸规格", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: # 适配到淘宝和快手 python scripts/platform_adapter.py \\ --input-dir ./output \\ --platforms taobao,kuaishou \\ --output-dir ./platform_output \\ --resize-mode fit # 适配到全平台,用浅灰背景 python scripts/platform_adapter.py \\ --input-dir ./output \\ --platforms taobao,kuaishou,xiaohongshu,douyin,pinduoduo \\ --output-dir ./platform_output \\ --resize-mode fill \\ --bg-color "#f0f0f0" 支持平台: taobao, kuaishou, xiaohongshu, douyin, pinduoduo, jd, wechat_shop """ ) parser.add_argument( "--input-dir", required=True, help="成品图所在目录" ) parser.add_argument( "--platforms", required=True, help="目标平台,逗号分隔。可选: taobao,kuaishou,xiaohongshu,douyin,pinduoduo,jd,wechat_shop" ) parser.add_argument( "--output-dir", required=True, help="输出根目录(每个平台一个子目录)" ) parser.add_argument( "--resize-mode", default="fit", choices=["fit", "fill", "stretch"], help="缩放模式: fit(等比+padding,默认), fill(等比+裁切), stretch(拉伸)" ) parser.add_argument( "--bg-color", default="#ffffff", help='背景色,fit模式使用。支持 "#ffffff"、"white"、"255,255,255",默认白色' ) parser.add_argument( "--report", default=None, help="适配报告JSON输出路径(默认输出到 output-dir/adaptation_report.json)" ) args = parser.parse_args() # 验证输入目录 input_dir = Path(args.input_dir) if not input_dir.is_dir(): print(f"[ERROR] 输入目录不存在: {input_dir}", file=sys.stderr) sys.exit(1) # 解析平台列表 platforms = [p.strip() for p in args.platforms.split(",") if p.strip()] invalid = [p for p in platforms if p not in PLATFORM_SPECS] if invalid: print(f"[ERROR] 不支持的平台: {', '.join(invalid)}", file=sys.stderr) print(f"[INFO] 支持的平台: {', '.join(PLATFORM_SPECS.keys())}", file=sys.stderr) sys.exit(1) # 解析背景色 bg_color = parse_color(args.bg_color) # 创建输出目录 output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) # 扫描和分类图片 print(f"📂 扫描成品图目录: {input_dir}") images = classify_images(input_dir) print(f" 主图(1:1): {len(images['main'])} 张") print(f" 详情图(3:4): {len(images['detail'])} 张") if images["unknown"]: print(f" 未识别: {len(images['unknown'])} 张") if not images["main"] and not images["detail"] and not images["unknown"]: print("[ERROR] 未找到任何图片文件", file=sys.stderr) sys.exit(1) # 逐平台适配 all_reports: Dict[str, List[Dict[str, Any]]] = {} for platform_key in platforms: platform_name = PLATFORM_SPECS[platform_key]["name"] print(f"\n🔄 适配到 [{platform_name}] ({platform_key})...") reports = adapt_for_platform( images, platform_key, output_dir, args.resize_mode, bg_color ) all_reports[platform_key] = reports # 打印该平台结果 compliant = sum(1 for r in reports if r.get("compliant")) print(f" ✅ 完成: {len(reports)} 张图片, {compliant}/{len(reports)} 合规") for r in reports: status = "✅" if r.get("compliant") else "⚠️" size_info = f"{r.get('final_size_kb', '?')}KB" dim_info = r.get("final_dimensions", "?") err_info = f" ERROR: {r['error']}" if "error" in r else "" print(f" {status} {Path(r['source_file']).name} → {dim_info} ({size_info}){err_info}") # 生成汇总报告 summary = generate_summary_report(all_reports, platforms) report_path = args.report or str(output_dir / "adaptation_report.json") with open(report_path, "w", encoding="utf-8") as f: json.dump(summary, f, ensure_ascii=False, indent=2) print(f"\n📊 适配报告已保存: {report_path}") # 汇总输出 total = summary["overall"]["total_images"] compliant_total = summary["overall"]["compliant_count"] print(f"\n{'='*50}") print(f"📦 多平台适配完成") print(f" 总图片数: {total}") print(f" 合规数: {compliant_total}/{total}") if summary["overall"]["errors"]: print(f" 错误数: {len(summary['overall']['errors'])}") for err in summary["overall"]["errors"]: print(f" ❌ {err}") print(f" 输出目录: {output_dir}") print(f"{'='*50}") if __name__ == "__main__": main() -
preference_memory.py 14.8 KB
#!/usr/bin/env python3 """ preference_memory.py — 用户偏好记忆模块 ======================================== 存储和检索用户的风格偏好,跨项目复用。 功能: 1. 保存项目偏好(风格、品牌、色调、布局等) 2. 按关键词/标签检索历史偏好 3. 列出所有偏好记录 4. 导出偏好统计和推荐 Usage: # 保存偏好 python scripts/preference_memory.py \\ --action save \\ --project-name "示例项目" \\ --preferences '{"style":"tech_gradient","brand":"langke"}' \\ --tags '["剃须刀","朗科","科技风"]' # 检索偏好 python scripts/preference_memory.py \\ --action search \\ --query "剃须刀科技风格" # 列出所有偏好 python scripts/preference_memory.py --action list # 导出偏好统计 python scripts/preference_memory.py --action stats 依赖: 无外部依赖(纯Python + json) """ import argparse import json import os import sys import uuid from pathlib import Path from datetime import datetime from typing import Dict, List, Optional, Any from collections import Counter # ============================================================================ # 路径常量 # ============================================================================ SKILL_DIR = Path(__file__).parent.parent REFERENCES_DIR = SKILL_DIR / "references" PREFERENCES_FILE = REFERENCES_DIR / "user_preferences.json" # 初始模板 INITIAL_PREFERENCES_DATA = { "version": "1.0", "description": "用户偏好记忆库 — 记录每次项目的风格偏好,跨项目复用", "preferences": [], "metadata": { "created_at": None, "last_updated": None, "total_entries": 0 } } # ============================================================================ # 工具函数 # ============================================================================ def load_preferences() -> dict: """加载偏好数据,不存在则创建初始文件""" if PREFERENCES_FILE.exists(): with open(PREFERENCES_FILE, "r", encoding="utf-8") as f: return json.load(f) # 创建初始文件 data = INITIAL_PREFERENCES_DATA.copy() data["metadata"]["created_at"] = datetime.now().isoformat() save_preferences(data) return data def save_preferences(data: dict): """保存偏好数据""" REFERENCES_DIR.mkdir(parents=True, exist_ok=True) with open(PREFERENCES_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def generate_id() -> str: """生成唯一ID""" return f"pref_{datetime.now().strftime('%Y%m%d')}_{uuid.uuid4().hex[:8]}" # ============================================================================ # 核心功能 # ============================================================================ def save_preference(project_name: str, preferences: dict, tags: Optional[List[str]] = None) -> dict: """ 保存一条偏好记录。 Args: project_name: 项目名称 preferences: 偏好配置字典 tags: 标签列表 Returns: 保存的记录 """ data = load_preferences() entry = { "id": generate_id(), "project_name": project_name, "timestamp": datetime.now().isoformat(), "preferences": preferences, "tags": tags or [], "usage_count": 0, "last_used": None } data["preferences"].append(entry) data["metadata"]["last_updated"] = datetime.now().isoformat() data["metadata"]["total_entries"] = len(data["preferences"]) save_preferences(data) return entry def search_preferences(query: str, top_k: int = 5) -> List[dict]: """ 搜索偏好记录(基于标签匹配 + 偏好字段模糊匹配)。 Args: query: 搜索关键词 top_k: 返回前K个结果 Returns: 匹配的偏好记录列表(按相关度排序) """ data = load_preferences() entries = data.get("preferences", []) if not entries: return [] query_lower = query.lower().strip() query_terms = set(query_lower.split()) scored = [] for entry in entries: score = 0.0 # 1. 标签匹配(权重最高) tags = [t.lower() for t in entry.get("tags", [])] tag_match = sum(1 for term in query_terms if any(term in tag or tag in term for tag in tags)) score += tag_match * 3.0 # 2. 项目名称匹配 name = entry.get("project_name", "").lower() if query_lower in name or name in query_lower: score += 2.0 for term in query_terms: if term in name: score += 1.0 # 3. 偏好字段匹配 prefs = entry.get("preferences", {}) for key, value in prefs.items(): value_str = str(value).lower() for term in query_terms: if term in value_str or value_str in term: score += 1.5 # 4. 使用频率加成(常用偏好优先) usage = entry.get("usage_count", 0) score += min(usage * 0.1, 1.0) if score > 0: scored.append((entry, score)) # 排序并取Top-K scored.sort(key=lambda x: x[1], reverse=True) results = [] for entry, score in scored[:top_k]: result = entry.copy() result["_relevance_score"] = round(score, 2) results.append(result) # 自动更新使用次数 if results: _update_usage(results[0]["id"]) return results def _update_usage(pref_id: str): """更新偏好记录的使用次数和最后使用时间""" data = load_preferences() for entry in data["preferences"]: if entry["id"] == pref_id: entry["usage_count"] = entry.get("usage_count", 0) + 1 entry["last_used"] = datetime.now().isoformat() break data["metadata"]["last_updated"] = datetime.now().isoformat() save_preferences(data) def list_preferences() -> List[dict]: """列出所有偏好记录""" data = load_preferences() entries = data.get("preferences", []) # 按时间降序排列 entries.sort(key=lambda x: x.get("timestamp", ""), reverse=True) return entries def compute_stats() -> dict: """ 计算偏好统计信息,输出推荐偏好。 Returns: 统计结果字典 """ data = load_preferences() entries = data.get("preferences", []) if not entries: return { "total_entries": 0, "message": "暂无偏好记录", "recommended": {} } # 统计各字段频率 style_counter = Counter() brand_counter = Counter() color_tone_counter = Counter() text_layout_counter = Counter() tag_counter = Counter() platform_counter = Counter() for entry in entries: prefs = entry.get("preferences", {}) style = prefs.get("style", "") if style: style_counter[style] += 1 brand = prefs.get("brand", "") if brand: brand_counter[brand] += 1 color_tone = prefs.get("color_tone", "") if color_tone: color_tone_counter[color_tone] += 1 text_layout = prefs.get("text_layout", "") if text_layout: text_layout_counter[text_layout] += 1 platform = prefs.get("platform", "") if platform: platform_counter[platform] += 1 for tag in entry.get("tags", []): tag_counter[tag] += 1 # 推荐偏好(各维度最常用值) recommended = { "style": style_counter.most_common(1)[0][0] if style_counter else None, "brand": brand_counter.most_common(1)[0][0] if brand_counter else None, "color_tone": color_tone_counter.most_common(1)[0][0] if color_tone_counter else None, "text_layout": text_layout_counter.most_common(1)[0][0] if text_layout_counter else None, "platform": platform_counter.most_common(1)[0][0] if platform_counter else None, } # 清理None值 recommended = {k: v for k, v in recommended.items() if v is not None} stats = { "total_entries": len(entries), "date_range": { "earliest": min(e.get("timestamp", "") for e in entries) if entries else None, "latest": max(e.get("timestamp", "") for e in entries) if entries else None, }, "top_styles": style_counter.most_common(5), "top_brands": brand_counter.most_common(5), "top_color_tones": color_tone_counter.most_common(5), "top_text_layouts": text_layout_counter.most_common(5), "top_platforms": platform_counter.most_common(5), "top_tags": tag_counter.most_common(10), "recommended": recommended, "most_used": { "entry": max(entries, key=lambda x: x.get("usage_count", 0)).get("project_name", "") if entries else None, "usage_count": max(e.get("usage_count", 0) for e in entries) if entries else 0, } } return stats def delete_preference(pref_id: str) -> bool: """ 删除一条偏好记录。 Args: pref_id: 偏好记录ID Returns: 是否删除成功 """ data = load_preferences() original_len = len(data["preferences"]) data["preferences"] = [e for e in data["preferences"] if e["id"] != pref_id] if len(data["preferences"]) < original_len: data["metadata"]["last_updated"] = datetime.now().isoformat() data["metadata"]["total_entries"] = len(data["preferences"]) save_preferences(data) return True return False # ============================================================================ # CLI 入口 # ============================================================================ def main(): parser = argparse.ArgumentParser( description="用户偏好记忆模块 — 存储和检索风格偏好,跨项目复用", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: # 保存偏好 python scripts/preference_memory.py \\ --action save \\ --project-name "示例项目" \\ --preferences '{"style":"tech_gradient","brand":"langke","color_tone":"dark"}' \\ --tags '["剃须刀","朗科","科技风"]' # 检索偏好 python scripts/preference_memory.py --action search --query "剃须刀科技风格" # 列出所有偏好 python scripts/preference_memory.py --action list # 统计信息 python scripts/preference_memory.py --action stats # 删除偏好 python scripts/preference_memory.py --action delete --id pref_20260809_abc123 """ ) parser.add_argument( "--action", required=True, choices=["save", "search", "list", "stats", "delete"], help="操作类型:save(保存)/search(搜索)/list(列表)/stats(统计)/delete(删除)" ) parser.add_argument( "--project-name", default=None, help="项目名称(save时使用)" ) parser.add_argument( "--preferences", default=None, help='偏好配置JSON字符串(save时使用,如 \'{"style":"tech_gradient"}\')' ) parser.add_argument( "--tags", default=None, help='标签JSON数组字符串(save时使用,如 \'["剃须刀","朗科"]\')' ) parser.add_argument( "--query", default=None, help="搜索关键词(search时使用)" ) parser.add_argument( "--top-k", type=int, default=5, help="搜索结果数量(search时使用,默认5)" ) parser.add_argument( "--id", default=None, help="偏好记录ID(delete时使用)" ) parser.add_argument( "--pretty", action="store_true", default=True, help="美化JSON输出(默认开启)" ) args = parser.parse_args() # ─── save ─── if args.action == "save": if not args.project_name: print("Error: save操作需要 --project-name", file=sys.stderr) sys.exit(1) # 解析偏好 try: prefs = json.loads(args.preferences) if args.preferences else {} except json.JSONDecodeError as e: print(f"Error: --preferences JSON解析失败: {e}", file=sys.stderr) sys.exit(1) # 解析标签 try: tags = json.loads(args.tags) if args.tags else [] except json.JSONDecodeError as e: print(f"Error: --tags JSON解析失败: {e}", file=sys.stderr) sys.exit(1) entry = save_preference(args.project_name, prefs, tags) indent = 2 if args.pretty else None print("偏好已保存:") print(json.dumps(entry, ensure_ascii=False, indent=indent)) # ─── search ─── elif args.action == "search": if not args.query: print("Error: search操作需要 --query", file=sys.stderr) sys.exit(1) results = search_preferences(args.query, top_k=args.top_k) indent = 2 if args.pretty else None if results: print(f"找到 {len(results)} 条相关偏好记录:") print(json.dumps(results, ensure_ascii=False, indent=indent)) else: print(f"未找到与 \"{args.query}\" 相关的偏好记录") # ─── list ─── elif args.action == "list": entries = list_preferences() indent = 2 if args.pretty else None if entries: print(f"共 {len(entries)} 条偏好记录:") for i, entry in enumerate(entries, 1): print(f"\n[{i}] {entry['project_name']}") print(f" ID: {entry['id']}") print(f" 时间: {entry['timestamp'][:19]}") print(f" 标签: {', '.join(entry.get('tags', []))}") print(f" 偏好: {json.dumps(entry.get('preferences', {}), ensure_ascii=False)}") print(f" 使用次数: {entry.get('usage_count', 0)}") else: print("暂无偏好记录") # ─── stats ─── elif args.action == "stats": stats = compute_stats() indent = 2 if args.pretty else None print("=== 偏好统计 ===") print(json.dumps(stats, ensure_ascii=False, indent=indent)) # ─── delete ─── elif args.action == "delete": if not args.id: print("Error: delete操作需要 --id", file=sys.stderr) sys.exit(1) success = delete_preference(args.id) if success: print(f"已删除偏好记录: {args.id}") else: print(f"未找到偏好记录: {args.id}") if __name__ == "__main__": main() -
quality_check.py 20.4 KB
#!/usr/bin/env python3 """ quality_check.py — 自动质检清单 ================================ 出图前自动检查常见问题,输出JSON质检报告 + 人类可读checklist。 检查项: 1. 分辨率检查:主图≥1000×1000,详情图≥1200×1600 2. 文字可读性:采样文字区域背景色和文字色,计算对比度 3. 品牌Logo检查:检测logo区域是否有非透明像素 4. 文件完整性:检查output_dir中是否有完整的11张图 5. 文字重叠检测:检查text_zones和product_bbox是否有重叠 用法: # 完整质检(读取plan.json + 检查成品图) python quality_check.py --plan /path/to/plan.json # 只检查文件完整性 python quality_check.py --plan /path/to/plan.json --check-only files # 检查单张图片 python quality_check.py --image /path/to/image.png --type main # 输出详细JSON报告 python quality_check.py --plan /path/to/plan.json --format json --output report.json 依赖: Pillow (可选: numpy用于更精确的对比度检测) """ import argparse import json import os import sys from pathlib import Path from typing import Optional, List, Dict, Any, Tuple from datetime import datetime from PIL import Image # ============================================================================ # 常量 # ============================================================================ # 标准尺寸 MAIN_IMAGE_SIZE = (1000, 1000) DETAIL_IMAGE_SIZE = (1200, 1600) EXPECTED_MAIN_COUNT = 5 EXPECTED_DETAIL_COUNT = 6 EXPECTED_TOTAL = EXPECTED_MAIN_COUNT + EXPECTED_DETAIL_COUNT # 对比度阈值(WCAG AA) MIN_CONTRAST_RATIO = 4.5 # 允许的分辨率误差(像素) RESOLUTION_TOLERANCE = 50 # ============================================================================ # 颜色工具 # ============================================================================ def relative_luminance(r: int, g: int, b: int) -> float: """计算RGB的相对亮度""" def linearize(c: int) -> float: s = c / 255.0 return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4 return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b) def contrast_ratio(c1: Tuple[int, int, int], c2: Tuple[int, int, int]) -> float: """计算两个RGB颜色的对比度""" l1 = relative_luminance(*c1) l2 = relative_luminance(*c2) lighter = max(l1, l2) darker = min(l1, l2) return (lighter + 0.05) / (darker + 0.05) def sample_region_avg(img: Image.Image, bbox: Tuple[int, int, int, int]) -> Tuple[int, int, int]: """采样区域平均颜色""" x1, y1, x2, y2 = bbox x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(img.width, x2), min(img.height, y2) if x2 <= x1 or y2 <= y1: return (128, 128, 128) region = img.crop((x1, y1, x2, y2)).convert("RGB") pixels = list(region.getdata()) if not pixels: return (128, 128, 128) return ( sum(p[0] for p in pixels) // len(pixels), sum(p[1] for p in pixels) // len(pixels), sum(p[2] for p in pixels) // len(pixels), ) # ============================================================================ # 检查器 # ============================================================================ class CheckResult: """单项检查结果""" def __init__(self, name: str, passed: bool, message: str, severity: str = "info", details: Optional[Dict] = None): self.name = name self.passed = passed self.message = message self.severity = severity # "error", "warning", "info" self.details = details or {} def to_dict(self) -> Dict: return { "name": self.name, "passed": self.passed, "message": self.message, "severity": self.severity, "details": self.details, } def check_resolution(image_path: str, image_type: str) -> CheckResult: """ 检查图片分辨率 Args: image_path: 图片路径 image_type: "main" 或 "detail" """ try: img = Image.open(image_path) w, h = img.size except Exception as e: return CheckResult("分辨率检查", False, f"无法打开图片: {e}", "error") if image_type == "main": expected_w, expected_h = MAIN_IMAGE_SIZE name = "主图" else: expected_w, expected_h = DETAIL_IMAGE_SIZE name = "详情图" w_ok = w >= expected_w - RESOLUTION_TOLERANCE h_ok = h >= expected_h - RESOLUTION_TOLERANCE if w_ok and h_ok: return CheckResult( f"{name}分辨率检查", True, f"{w}×{h} ✅ (要求≥{expected_w}×{expected_h})" ) else: return CheckResult( f"{name}分辨率检查", False, f"{w}×{h} ❌ (要求≥{expected_w}×{expected_h})", "error", {"actual": [w, h], "expected": [expected_w, expected_h]} ) def check_text_readability(image_path: str, text_zones: List[Dict], text_colors: List[Tuple[int, int, int]]) -> CheckResult: """ 检查文字可读性(对比度) Args: image_path: 成品图路径 text_zones: 文字区域列表 [{x1, y1, x2, y2}, ...] text_colors: 对应的文字颜色列表 """ if not text_zones: return CheckResult("文字可读性", True, "无文字区域,跳过检查") try: img = Image.open(image_path).convert("RGB") except Exception as e: return CheckResult("文字可读性", False, f"无法打开图片: {e}", "error") issues = [] min_ratio = float('inf') for i, zone in enumerate(text_zones): if isinstance(zone, dict) and "bbox" in zone: bbox = zone["bbox"] else: bbox = zone x1, y1 = bbox.get("x1", 0), bbox.get("y1", 0) x2, y2 = bbox.get("x2", img.width), bbox.get("y2", img.height) # 采样文字区域周围的背景色 # 取文字区域上方和左方的背景 sample_bboxes = [ (max(0, x1 - 20), max(0, y1 - 20), x1, y1), # 左上角外侧 (x2, max(0, y1 - 20), min(img.width, x2 + 20), y1), # 右上角外侧 ] bg_colors = [sample_region_avg(img, sb) for sb in sample_bboxes] if not bg_colors: continue avg_bg = ( sum(c[0] for c in bg_colors) // len(bg_colors), sum(c[1] for c in bg_colors) // len(bg_colors), sum(c[2] for c in bg_colors) // len(bg_colors), ) text_color = text_colors[i] if i < len(text_colors) else (255, 255, 255) ratio = contrast_ratio(avg_bg, text_color) min_ratio = min(min_ratio, ratio) if ratio < MIN_CONTRAST_RATIO: issues.append({ "zone_id": zone.get("id", f"zone_{i}"), "contrast_ratio": round(ratio, 2), "bg_color": avg_bg, "text_color": text_color, "minimum_required": MIN_CONTRAST_RATIO, }) if issues: return CheckResult( "文字可读性", False, f"{len(issues)}个文字区域对比度不足 (最低: {min_ratio:.1f}:1, 要求≥{MIN_CONTRAST_RATIO}:1)", "warning", {"issues": issues} ) return CheckResult( "文字可读性", True, f"所有文字区域对比度合格 (最低: {min_ratio:.1f}:1)" ) def check_logo_presence(image_path: str, logo_zone: Optional[Dict]) -> CheckResult: """ 检查Logo是否正确叠加 Args: image_path: 成品图路径 logo_zone: Logo区域 {x1, y1, x2, y2} """ if not logo_zone: return CheckResult("Logo检查", True, "无Logo区域配置,跳过检查") try: img = Image.open(image_path).convert("RGBA") except Exception as e: return CheckResult("Logo检查", False, f"无法打开图片: {e}", "error") x1 = logo_zone.get("x1", 0) y1 = logo_zone.get("y1", 0) x2 = logo_zone.get("x2", img.width) y2 = logo_zone.get("y2", img.height) # 检查Logo区域是否有非白色/非透明像素 region = img.crop((x1, y1, x2, y2)) pixels = list(region.getdata()) non_white_count = 0 for p in pixels: if len(p) >= 4 and p[3] > 50: # 有alpha if p[0] < 240 or p[1] < 240 or p[2] < 240: # 非纯白 non_white_count += 1 elif len(p) < 4: if p[0] < 240 or p[1] < 240 or p[2] < 240: non_white_count += 1 total_pixels = len(pixels) ratio = non_white_count / total_pixels if total_pixels > 0 else 0 if ratio > 0.05: # 至少5%的像素是Logo内容 return CheckResult( "Logo检查", True, f"Logo区域检测到内容 ({ratio*100:.1f}%非背景像素)" ) else: return CheckResult( "Logo检查", False, f"Logo区域几乎无内容 ({ratio*100:.1f}%非背景像素),可能未叠加", "warning", {"zone": logo_zone, "non_bg_ratio": round(ratio, 4)} ) def check_file_completeness(output_dir: str, plan: Dict) -> CheckResult: """ 检查输出文件完整性 Args: output_dir: 输出目录 plan: plan.json数据 """ if not output_dir or not Path(output_dir).exists(): return CheckResult("文件完整性", False, f"输出目录不存在: {output_dir}", "error") expected_files = [] for img_cfg in plan.get("main_images", []): expected_files.append(f"{img_cfg['id']}.png") for img_cfg in plan.get("detail_images", []): expected_files.append(f"{img_cfg['id']}.png") missing = [] present = [] for f in expected_files: if Path(output_dir, f).exists(): present.append(f) else: missing.append(f) if missing: return CheckResult( "文件完整性", False, f"缺少 {len(missing)}/{len(expected_files)} 张图", "error", {"missing": missing, "present": present} ) return CheckResult( "文件完整性", True, f"全部 {len(expected_files)} 张图已生成" ) def check_text_product_overlap(plan_image: Dict) -> CheckResult: """ 检查文字区域与产品bbox是否重叠 Args: plan_image: 单张图的plan配置 """ product_bbox = plan_image.get("product_bbox") text_zones = plan_image.get("text_zones", []) if not product_bbox: return CheckResult("文字避让", True, "无产品bbox,跳过检查") if not text_zones: return CheckResult("文字避让", True, "无text_zones配置,跳过检查") overlaps = [] for zone in text_zones: zone_bbox = zone.get("bbox", {}) if not zone_bbox: continue # 检查重叠 x1 = max(product_bbox.get("x1", 0), zone_bbox.get("x1", 0)) y1 = max(product_bbox.get("y1", 0), zone_bbox.get("y1", 0)) x2 = min(product_bbox.get("x2", 0), zone_bbox.get("x2", 0)) y2 = min(product_bbox.get("y2", 0), zone_bbox.get("y2", 0)) if x2 > x1 and y2 > y1: overlap_area = (x2 - x1) * (y2 - y1) zone_area = (zone_bbox.get("x2", 0) - zone_bbox.get("x1", 0)) * \ (zone_bbox.get("y2", 0) - zone_bbox.get("y1", 0)) if zone_area > 0: overlap_ratio = overlap_area / zone_area if overlap_ratio > 0.2: # 超过20%面积重叠 overlaps.append({ "zone_id": zone.get("id", "unknown"), "overlap_ratio": round(overlap_ratio, 3), "overlap_area": overlap_area, }) if overlaps: return CheckResult( "文字避让", False, f"{len(overlaps)}个文字区域与产品区域重叠", "warning", {"overlaps": overlaps} ) return CheckResult("文字避让", True, "文字区域与产品无重叠") # ============================================================================ # 质检报告生成 # ============================================================================ def run_full_check(plan_path: str, check_type: Optional[str] = None) -> Dict: """ 运行完整质检 Args: plan_path: plan.json路径 check_type: 限定检查类型 ("files", "resolution", "readability", "logo", "overlap") Returns: 质检报告dict """ with open(plan_path, "r", encoding="utf-8") as f: plan = json.load(f) output_dir = plan.get("output_dir", "") results: List[CheckResult] = [] all_images = [] for kind, items in [("main", plan.get("main_images", [])), ("detail", plan.get("detail_images", []))]: for img_cfg in items: all_images.append((kind, img_cfg)) # 1. 文件完整性 if not check_type or check_type == "files": results.append(check_file_completeness(output_dir, plan)) # 逐图检查 for kind, img_cfg in all_images: img_id = img_cfg.get("id", "unknown") img_path = str(Path(output_dir) / f"{img_id}.png") if not Path(img_path).exists(): if not check_type or check_type == "resolution": results.append(CheckResult( f"{img_id}分辨率", False, "文件不存在,跳过检查", "error" )) continue # 2. 分辨率检查 if not check_type or check_type == "resolution": results.append(check_resolution(img_path, kind)) # 3. 文字可读性 if not check_type or check_type == "readability": text_zones = img_cfg.get("text_zones", []) # 提取文字颜色 text_colors = [] for text_cfg in img_cfg.get("texts", []): color_str = text_cfg.get("color", "#FFFFFF") if isinstance(color_str, str): c = color_str.lstrip("#") if len(c) == 6: text_colors.append((int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))) else: text_colors.append((255, 255, 255)) else: text_colors.append(tuple(color_str[:3])) results.append(check_text_readability(img_path, text_zones, text_colors)) # 4. Logo检查 if not check_type or check_type == "logo": brand_zones = img_cfg.get("brand_zones", {}) logo_zone = brand_zones.get("logo") results.append(check_logo_presence(img_path, logo_zone)) # 5. 文字重叠检测 if not check_type or check_type == "overlap": results.append(check_text_product_overlap(img_cfg)) # 生成报告 report = { "timestamp": datetime.now().isoformat(), "plan_path": plan_path, "product": plan.get("product", {}).get("name", "unknown"), "total_checks": len(results), "passed": sum(1 for r in results if r.passed), "failed": sum(1 for r in results if not r.passed), "errors": sum(1 for r in results if not r.passed and r.severity == "error"), "warnings": sum(1 for r in results if not r.passed and r.severity == "warning"), "results": [r.to_dict() for r in results], } report["overall_pass"] = all( r.passed or r.severity != "error" for r in results ) return report def format_human_readable(report: Dict) -> str: """生成人类可读的质检清单""" lines = [] lines.append("=" * 60) lines.append(f"📋 质检报告 — {report.get('product', 'unknown')}") lines.append(f"📅 {report.get('timestamp', '')}") lines.append("=" * 60) lines.append("") total = report.get("total_checks", 0) passed = report.get("passed", 0) failed = report.get("failed", 0) errors = report.get("errors", 0) warnings = report.get("warnings", 0) status_emoji = "✅" if report.get("overall_pass") else "⚠️" lines.append(f"总评: {status_emoji} {'通过' if report.get('overall_pass') else '有问题需关注'}") lines.append(f"检查项: {total} | 通过: {passed} | 失败: {failed} (错误: {errors}, 警告: {warnings})") lines.append("") lines.append("-" * 40) for r in report.get("results", []): icon = "✅" if r["passed"] else ("❌" if r["severity"] == "error" else "⚠️") lines.append(f" {icon} {r['name']}: {r['message']}") lines.append("") lines.append("=" * 60) if not report.get("overall_pass"): lines.append("") lines.append("⚠️ 需要处理的问题:") for r in report.get("results", []): if not r["passed"] and r["severity"] == "error": lines.append(f" ❌ {r['name']}: {r['message']}") lines.append("") lines.append("💡 建议:") if any("分辨率" in r["name"] for r in report["results"] if not r["passed"]): lines.append(" - 重新生成低分辨率的图片,确保主图≥1000×1000,详情图≥1200×1600") if any("文件完整性" in r["name"] for r in report["results"] if not r["passed"]): lines.append(" - 检查缺失的文件并重新渲染") if any("可读性" in r["name"] for r in report["results"] if not r["passed"]): lines.append(" - 为对比度不足的文字添加背景块或描边,或使用text_engine.py的自动可读性增强") if any("Logo" in r["name"] for r in report["results"] if not r["passed"]): lines.append(" - 检查Logo文件路径是否正确,或重新运行品牌叠加") return "\n".join(lines) # ============================================================================ # CLI # ============================================================================ def main(): parser = argparse.ArgumentParser( description="自动质检清单 — 电商素材一站式工坊", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: # 完整质检 python quality_check.py --plan /path/to/plan.json # 只检查文件完整性 python quality_check.py --plan /path/to/plan.json --check-only files # 检查单张图片 python quality_check.py --image /path/to/image.png --type main # 输出JSON报告 python quality_check.py --plan /path/to/plan.json --format json --output report.json 检查项: 1. 分辨率检查:主图≥1000×1000,详情图≥1200×1600 2. 文字可读性:对比度≥4.5:1 (WCAG AA) 3. Logo检查:Logo区域是否有内容 4. 文件完整性:11张图是否齐全 5. 文字重叠:text_zones与product_bbox无重叠 """ ) parser.add_argument("--plan", help="plan.json 路径") parser.add_argument("--image", help="检查单张图片") parser.add_argument("--type", choices=["main", "detail"], help="图片类型(配合--image使用)") parser.add_argument("--check-only", choices=["files", "resolution", "readability", "logo", "overlap"], help="只运行特定检查") parser.add_argument("--format", default="text", choices=["text", "json"], help="输出格式 (default: text)") parser.add_argument("--output", help="输出文件路径") args = parser.parse_args() if args.image: # 单图模式 if not args.type: print("❌ 请指定 --type main 或 --type detail", file=sys.stderr) sys.exit(1) results = [check_resolution(args.image, args.type)] report = { "timestamp": datetime.now().isoformat(), "image": args.image, "total_checks": len(results), "passed": sum(1 for r in results if r.passed), "failed": sum(1 for r in results if not r.passed), "results": [r.to_dict() for r in results], "overall_pass": all(r.passed for r in results), } elif args.plan: report = run_full_check(args.plan, args.check_only) else: parser.print_help() sys.exit(0) # 输出 if args.format == "json": output_str = json.dumps(report, ensure_ascii=False, indent=2) else: output_str = format_human_readable(report) if args.output: Path(args.output).parent.mkdir(parents=True, exist_ok=True) with open(args.output, "w", encoding="utf-8") as f: f.write(output_str) print(f"📄 报告已保存到: {args.output}") else: print(output_str) # 退出码 sys.exit(0 if report.get("overall_pass", True) else 1) if __name__ == "__main__": main() -
scene_aware_compositor.py 49.1 KB
#!/usr/bin/env python3 """ scene_aware_compositor.py — 场景感知合成引擎 ============================================= 替代固定 scale 的 smart_composite,根据场景中的参照物几何关系自动计算产品大小, 并施加透视匹配、景深模拟、产品突出等后处理。 核心改进: 1. 参照物系统 → 每个场景模板定义已知尺寸的参照物 2. 像素尺度计算 → 根据参照物在画面中的预期比例反推 px/cm 3. 产品尺寸计算 → 真实物理尺寸 × px/cm → 像素尺寸 4. 透视匹配 → 根据场景视角对产品施加透视变换 5. 景深模拟 → 根据纵深位置施加高斯模糊 6. 产品突出 → 锐度/亮度/对比度增强 兼容: - layout_engine.py 的 LayoutPlan - ecommerce_suite_v2.py 的调用方式 Author: 素材工坊 """ import math import numpy as np from dataclasses import dataclass, field from typing import Optional, Dict, List, Tuple from PIL import Image, ImageFilter, ImageEnhance from scipy.ndimage import gaussian_filter # ============================================================================ # §1 数据结构 # ============================================================================ @dataclass class ReferenceObject: """ 参照物定义。 每个参照物有已知的真实世界尺寸,以及在场景生成 prompt 中的预期位置比例。 预期位置比例用于在没有实际检测能力时,根据 prompt 设计反推像素/厘米比。 Attributes: name: 参照物名称(如 "牙刷"、"盆栽") real_height_cm: 真实高度 (cm) real_width_cm: 真实宽度 (cm) prompt_desc: 在 AI prompt 中的描述(英文,用于生成场景时让 AI 画出参照物) expected_x_range: 在画面中预期的水平范围 (归一化 0~1),如 (0.05, 0.15) expected_y_range: 在画面中预期的垂直范围 (归一化 0~1) """ name: str real_height_cm: float real_width_cm: float prompt_desc: str expected_x_range: Tuple[float, float] expected_y_range: Tuple[float, float] @dataclass class SceneConfig: """ 场景模板的完整配置,包含参照物系统和视角参数。 Attributes: name: 场景显示名 base_prompt: 场景基础 prompt(不含参照物) view_angle: 视角类型 ("topdown", "slight_topdown", "flat", "eye_level") view_angle_deg: 视角倾斜角度(0=正俯视, 90=正平视) reference_objects: 参照物列表 depth_layers: 纵深层次定义 {"foreground": (z_near, z_far), ...} ref_object_prompt_suffix: 拼接到 base_prompt 后的参照物描述 """ name: str base_prompt: str view_angle: str view_angle_deg: float # 0=正俯视, 45=微俯, 90=平视 reference_objects: List[ReferenceObject] depth_layers: Dict[str, Tuple[float, float]] = field(default_factory=dict) canvas_w: int = 1024 canvas_h: int = 1024 @dataclass class ProductSpec: """ 产品物理规格。 Attributes: name: 产品名称 height_cm: 真实高度 (cm) width_cm: 真实宽度 (cm) depth_cm: 真实深度 (cm),用于圆柱形产品的直径 shape: 形状描述 ("cylinder", "box", "irregular") primary_color: 主体颜色描述 """ name: str height_cm: float width_cm: float depth_cm: float = 0.0 shape: str = "cylinder" primary_color: str = "dark gray" @dataclass class CompositeResult: """合成结果""" image: Image.Image product_bbox: Tuple[int, int, int, int] # (x, y, w, h) pixel_per_cm: float # 像素/厘米比 product_scale_ratio: float # 产品占画布宽度比 perspective_params: Optional[Dict] = None depth_blur_sigma: float = 0.0 # ============================================================================ # §2 场景模板配置 — 6个场景的参照物系统 # ============================================================================ # HDB1 剃须刀产品规格 HDB1_PRODUCT = ProductSpec( name="HDB1 剃须刀", height_cm=16.0, width_cm=6.0, depth_cm=6.0, shape="cylinder", primary_color="dark gray with gold top", ) def _build_scene_configs() -> Dict[str, SceneConfig]: """ 构建6个场景模板的完整配置。 每个场景的参照物选择原则: 1. 参照物必须是该场景中「自然出现」的物品 2. 参照物的真实尺寸必须是大众熟知的标准尺寸 3. 参照物在画面中的位置要分散(最好在产品两侧),便于反推尺度 """ # ── 1. 极简白棚 ── minimalist = SceneConfig( name="极简白棚", base_prompt=( "flat lay top-down view, pure white seamless paper backdrop, " "soft even studio lighting, empty clean surface, " "product photography background, no text" ), view_angle="flat", view_angle_deg=10, # 近乎正俯视,微倾斜 reference_objects=[ ReferenceObject( name="标准A4纸", real_height_cm=29.7, real_width_cm=21.0, prompt_desc="a white A4 sheet of paper placed at the left side", expected_x_range=(0.05, 0.30), expected_y_range=(0.20, 0.70), ), ReferenceObject( name="标准信用卡", real_height_cm=5.4, real_width_cm=8.6, prompt_desc="a standard credit card placed at the bottom right area", expected_x_range=(0.65, 0.82), expected_y_range=(0.72, 0.80), ), ], depth_layers={ "foreground": (0.0, 0.3), "midground": (0.3, 0.7), "background": (0.7, 1.0), }, ) # ── 2. 俯视绿植桌面 ── topdown_greenery = SceneConfig( name="俯视绿植桌面", base_prompt=( "top-down view of a clean mint-green wooden desktop surface, " "flat lay style, soft daylight, minimalist, no text" ), view_angle="topdown", view_angle_deg=0, # 正俯视 reference_objects=[ ReferenceObject( name="多肉盆栽(小)", real_height_cm=8.0, real_width_cm=8.0, prompt_desc="a small round potted succulent plant (8cm diameter pot) at the upper-left corner", expected_x_range=(0.02, 0.18), expected_y_range=(0.02, 0.18), ), ReferenceObject( name="多肉盆栽(小)", real_height_cm=8.0, real_width_cm=8.0, prompt_desc="another small round potted succulent plant (8cm diameter pot) at the lower-right corner", expected_x_range=(0.82, 0.98), expected_y_range=(0.82, 0.98), ), ReferenceObject( name="标准铅笔", real_height_cm=19.0, real_width_cm=0.7, prompt_desc="a standard pencil (19cm long) lying horizontally near the right edge", expected_x_range=(0.70, 0.95), expected_y_range=(0.40, 0.45), ), ], depth_layers={ "foreground": (0.0, 0.3), "midground": (0.3, 0.7), "background": (0.7, 1.0), }, ) # ── 3. 暖色木纹桌面 ── warm_wood = SceneConfig( name="暖色木纹桌面", base_prompt=( "top-down view of a warm dark walnut wooden desk surface, " "natural wood grain texture, empty clean table, " "warm soft lighting, no text" ), view_angle="topdown", view_angle_deg=5, # 近乎正俯视 reference_objects=[ ReferenceObject( name="标准咖啡杯", real_height_cm=10.0, real_width_cm=8.0, prompt_desc="a standard coffee cup (8cm diameter, 10cm tall) with saucer at the upper right area", expected_x_range=(0.72, 0.90), expected_y_range=(0.08, 0.25), ), ReferenceObject( name="标准钢笔", real_height_cm=14.0, real_width_cm=1.2, prompt_desc="a classic fountain pen (14cm long) lying diagonally at the lower left", expected_x_range=(0.08, 0.30), expected_y_range=(0.70, 0.85), ), ], depth_layers={ "foreground": (0.0, 0.3), "midground": (0.3, 0.7), "background": (0.7, 1.0), }, ) # ── 4. 现代大理石 ── modern_marble = SceneConfig( name="现代大理石", base_prompt=( "top-down view of white marble countertop surface, " "subtle gray veining pattern, clean empty surface, " "bright even lighting, no text" ), view_angle="topdown", view_angle_deg=5, reference_objects=[ ReferenceObject( name="大理石皂", real_height_cm=3.0, real_width_cm=9.0, prompt_desc="a rectangular soap bar (9cm x 6cm x 3cm) at the left side", expected_x_range=(0.04, 0.18), expected_y_range=(0.35, 0.50), ), ReferenceObject( name="标准化妆刷", real_height_cm=17.0, real_width_cm=1.5, prompt_desc="a makeup brush (17cm long) lying at the lower right area", expected_x_range=(0.68, 0.92), expected_y_range=(0.72, 0.85), ), ], depth_layers={ "foreground": (0.0, 0.3), "midground": (0.3, 0.7), "background": (0.7, 1.0), }, ) # ── 5. 科技渐变 ── tech_gradient = SceneConfig( name="科技渐变", base_prompt=( "smooth dark gray to black gradient background, " "subtle radial blue light glow from center, " "sleek modern empty surface, no text" ), view_angle="flat", view_angle_deg=75, # 接近平视,略带俯视 reference_objects=[], # 科技场景不放参照物,用产品本身和光影做尺度 depth_layers={ "foreground": (0.0, 0.4), "midground": (0.4, 0.7), "background": (0.7, 1.0), }, ) # ── 6. 生活浴室场景 ── lifestyle_bathroom = SceneConfig( name="生活浴室场景", base_prompt=( "top-down view of a clean white bathroom countertop, " "bright natural daylight, no text" ), view_angle="slight_topdown", view_angle_deg=25, # 微俯视,能看到台面纵深 reference_objects=[ ReferenceObject( name="标准牙刷", real_height_cm=17.0, real_width_cm=2.0, prompt_desc="a standard toothbrush (17cm long) placed vertically on the left side", expected_x_range=(0.08, 0.14), expected_y_range=(0.20, 0.65), ), ReferenceObject( name="折叠毛巾", real_height_cm=3.0, real_width_cm=25.0, prompt_desc="a folded small towel (25cm x 15cm, 3cm thick) at the right side", expected_x_range=(0.72, 0.95), expected_y_range=(0.15, 0.40), ), ReferenceObject( name="小盆栽", real_height_cm=12.0, real_width_cm=10.0, prompt_desc="a small green potted plant (10cm pot, 12cm total height) at the upper right corner", expected_x_range=(0.78, 0.96), expected_y_range=(0.02, 0.20), ), ], depth_layers={ "foreground": (0.0, 0.3), "midground": (0.3, 0.7), "background": (0.7, 1.0), }, ) return { "minimalist": minimalist, "topdown_greenery": topdown_greenery, "warm_wood": warm_wood, "modern_marble": modern_marble, "tech_gradient": tech_gradient, "lifestyle_bathroom": lifestyle_bathroom, } # 全局场景配置注册表 SCENE_CONFIGS: Dict[str, SceneConfig] = _build_scene_configs() # ============================================================================ # §3 像素尺度计算器 # ============================================================================ class PixelScaleCalculator: """ 根据参照物的预期像素范围计算 像素/厘米 比率。 数学原理: ───────── 已知参照物 A 的真实宽度 W_real (cm),以及它在画面中预期占据的 水平范围 [x1_ratio, x2_ratio](归一化 0~1),则: W_pixel = (x2_ratio - x1_ratio) × canvas_width ... (1) 像素/厘米比 = W_pixel / W_real ... (2) 当有多个参照物时,取加权平均。权重基于参照物在画面中的 「可信度」——尺寸越大的参照物,预期位置越精确,权重越高。 对于有实际参照物检测结果(bbox)的场景,也可以直接传入 检测到的像素尺寸来计算。 """ def __init__(self, canvas_w: int, canvas_h: int): self.canvas_w = canvas_w self.canvas_h = canvas_h def estimate_from_expected_positions( self, scene_config: SceneConfig ) -> float: """ 根据参照物的预期位置比例估算像素/厘米比。 对每个参照物,分别计算水平方向和垂直方向的 px/cm, 然后取加权平均。 Returns: 像素/厘米比 (float) """ if not scene_config.reference_objects: # 无参照物场景(如科技渐变),使用默认值 # 默认假设:产品占画布高度 ~50%,产品真实高度 16cm # → 画布高度 1024px 对应 ~32cm → 32 px/cm return self.canvas_h / 32.0 px_per_cm_estimates = [] weights = [] for ref_obj in scene_config.reference_objects: # 水平方向 x_span = ref_obj.expected_x_range[1] - ref_obj.expected_x_range[0] w_pixel = x_span * self.canvas_w if ref_obj.real_width_cm > 0: px_cm_h = w_pixel / ref_obj.real_width_cm px_per_cm_estimates.append(px_cm_h) # 权重:参照物越大越可信 weights.append(ref_obj.real_width_cm) # 垂直方向 y_span = ref_obj.expected_y_range[1] - ref_obj.expected_y_range[0] h_pixel = y_span * self.canvas_h if ref_obj.real_height_cm > 0: px_cm_v = h_pixel / ref_obj.real_height_cm px_per_cm_estimates.append(px_cm_v) weights.append(ref_obj.real_height_cm) if not px_per_cm_estimates: return self.canvas_h / 32.0 # 加权平均 total_weight = sum(weights) weighted_sum = sum(e * w for e, w in zip(px_per_cm_estimates, weights)) return weighted_sum / total_weight def estimate_from_detected_bboxes( self, detected_bboxes: List[Tuple[str, Tuple[int, int, int, int], float, float]] ) -> float: """ 根据实际检测到的参照物 bbox 计算像素/厘米比。 Args: detected_bboxes: 列表,每项为 (name, (x1, y1, x2, y2), real_width_cm, real_height_cm) Returns: 像素/厘米比 (float) """ estimates = [] weights = [] for name, (x1, y1, x2, y2), real_w, real_h in detected_bboxes: w_px = x2 - x1 h_px = y2 - y1 if real_w > 0: estimates.append(w_px / real_w) weights.append(real_w) if real_h > 0: estimates.append(h_px / real_h) weights.append(real_h) if not estimates: return self.canvas_h / 32.0 total_weight = sum(weights) return sum(e * w for e, w in zip(estimates, weights)) / total_weight # ============================================================================ # §4 透视匹配器 # ============================================================================ class PerspectiveMatcher: """ 根据场景视角对产品图像施加透视变换,使其与场景的透视一致。 数学原理: ───────── 透视变换通过 3×3 单应性矩阵 H 实现: [x'] [h11 h12 h13] [x] [y'] = λ × [h21 h22 h23] [y] [w'] [h31 h32 h33] [1] 对于俯视场景(view_angle_deg ≈ 0°~20°): - 产品顶部(远离观察者)应比底部略窄 - 收缩量 = tan(view_angle) × product_height / 2 实现方式:通过 PIL 的 transform 方法 + 四点映射, 定义输入/输出的四角对应关系来构造单应性矩阵。 """ @staticmethod def apply_perspective( product_img: Image.Image, view_angle_deg: float, product_layer: str = "midground" ) -> Image.Image: """ 对产品图像施加透视变换。 Args: product_img: 产品图像(RGBA 或 RGB) view_angle_deg: 场景视角角度(0=正俯视, 90=正平视) product_layer: 产品所在纵深层 ("foreground", "midground", "background") Returns: 透视变换后的图像 """ w, h = product_img.size # 将视角转换为透视收缩因子 # 0° (正俯视) → 产品顶部收缩最大 # 90° (正平视) → 无透视收缩 # 使用 sin 函数映射:shrink = sin(90° - angle) = cos(angle) angle_rad = math.radians(view_angle_deg) # 透视收缩量:顶部宽度减少的比例 # cos(0°) = 1.0 (全收缩), cos(90°) = 0 (无收缩) # 实际收缩比例要小一些,避免过度变形 top_shrink_ratio = math.cos(angle_rad) * 0.15 # 最大 15% 收缩 if top_shrink_ratio < 0.01: return product_img # 几乎无透视效果,跳过 # 计算四角偏移 # 顶部两角向内收缩 top_offset = int(w * top_shrink_ratio / 2) # 原始四角 (左上, 右上, 右下, 左下) src_corners = [ (0, 0), (w - 1, 0), (w - 1, h - 1), (0, h - 1), ] # 目标四角:顶部向内收缩 dst_corners = [ (top_offset, 0), # 左上 → 右移 (w - 1 - top_offset, 0), # 右上 → 左移 (w - 1, h - 1), # 右下 → 不变 (0, h - 1), # 左下 → 不变 ] # 使用 PIL 的透视变换 # PIL transform 需要的是 dst → src 的映射(逆向映射) try: coeffs = PerspectiveMatcher._find_perspective_coeffs( dst_corners, src_corners ) result = product_img.transform( (w, h), Image.PERSPECTIVE, coeffs, Image.BICUBIC, ) return result except Exception: return product_img @staticmethod def _find_perspective_coeffs( src: List[Tuple[int, int]], dst: List[Tuple[int, int]] ) -> Tuple[float, ...]: """ 计算透视变换系数(8个参数)。 使用四点法求解单应性矩阵: 给定4对对应点 (src_i → dst_i),求解 8 个参数使得: dst_x = (c0*src_x + c1*src_y + c2) / (c6*src_x + c7*src_y + 1) dst_y = (c3*src_x + c4*src_y + c5) / (c6*src_x + c7*src_y + 1) 这转化为一个 8×8 线性方程组。 """ matrix = [] for (x, y), (X, Y) in zip(src, dst): matrix.append([x, y, 1, 0, 0, 0, -X * x, -X * y]) matrix.append([0, 0, 0, x, y, 1, -Y * x, -Y * y]) A = np.matrix(matrix, dtype=float) B = np.array([c for pair in dst for c in pair], dtype=float) try: coeffs = np.linalg.solve(A, B).flatten().tolist() return tuple(coeffs) + (1.0,) # PIL 需要 8 个系数 except np.linalg.LinAlgError: # 退化情况:返回恒等变换 return (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0) # ============================================================================ # §5 景深模拟器 # ============================================================================ class DepthOfFieldSimulator: """ 根据产品在场景中的纵深位置施加高斯模糊。 数学原理: ───────── 景深(DOF)模拟的核心是:离焦程度与物距的偏差成正比。 blur_sigma = k × |z_product - z_focus| 其中: - z_product: 产品所在纵深位置(归一化 0~1) - z_focus: 焦点位置(通常=产品位置,所以产品自身清晰) - k: 模糊系数,控制模糊强度 实际实现中: - 前景 (z < 0.3): sigma = 0~0.5 (几乎清晰) - 中景 (0.3 ≤ z < 0.7): sigma = 0.5~1.5 (轻微模糊) - 远景 (z ≥ 0.7): sigma = 1.5~3.0 (明显模糊) 但产品本身应该是焦点,所以产品的 sigma 很低, 而场景背景根据纵深有不同的模糊程度(已在场景生成时体现)。 这里主要处理产品本身的轻微景深效果。 """ # 纵深层次 → 模糊 sigma 映射 LAYER_BLUR_MAP = { "foreground": 0.0, # 前景清晰 "midground": 0.3, # 中景轻微模糊 "background": 0.8, # 远景模糊 } @staticmethod def apply_dof( product_img: Image.Image, depth_layer: str = "midground", custom_sigma: Optional[float] = None ) -> Image.Image: """ 对产品施加景深模糊。 Args: product_img: 产品图像 depth_layer: 纵深层次 ("foreground", "midground", "background") custom_sigma: 自定义模糊 sigma(覆盖默认值) Returns: 模糊处理后的图像 """ sigma = custom_sigma if custom_sigma is not None else DepthOfFieldSimulator.LAYER_BLUR_MAP.get(depth_layer, 0.0) if sigma <= 0: return product_img # 使用 PIL 的 GaussianBlur(比 scipy 更适合处理 RGBA) # 分离 alpha 通道,只对 RGB 模糊 if product_img.mode == "RGBA": r, g, b, a = product_img.split() rgb = Image.merge("RGB", (r, g, b)) rgb_blurred = rgb.filter(ImageFilter.GaussianBlur(radius=sigma)) return Image.merge("RGBA", (*rgb_blurred.split(), a)) else: return product_img.filter(ImageFilter.GaussianBlur(radius=sigma)) # ============================================================================ # §6 产品突出处理器 # ============================================================================ class ProductEmphasis: """ 增强产品的视觉突出感,使其从场景中「跳出来」。 策略: 1. 轻微亮度提升 (+3~5%) 2. 轻微对比度增强 (+5~10%) 3. 轻微锐度增强 (+10~20%) 数学原理: ───────── - 亮度: pixel_out = pixel_in × (1 + brightness_factor) - 对比度: pixel_out = 128 + (pixel_in - 128) × (1 + contrast_factor) - 锐度: USM 锐化 = original + amount × (original - blurred) 注意:所有增强都是轻微的,避免过度导致失真。 """ @staticmethod def emphasize( product_img: Image.Image, brightness: float = 1.04, contrast: float = 1.08, sharpness: float = 1.15, ) -> Image.Image: """ 产品突出处理。 Args: product_img: 产品图像 brightness: 亮度因子(1.0=不变, >1.0=变亮) contrast: 对比度因子 sharpness: 锐度因子 Returns: 增强后的图像 """ if product_img.mode != "RGBA": product_img = product_img.convert("RGBA") r, g, b, a = product_img.split() rgb = Image.merge("RGB", (r, g, b)) # 亮度增强 enhancer = ImageEnhance.Brightness(rgb) rgb = enhancer.enhance(brightness) # 对比度增强 enhancer = ImageEnhance.Contrast(rgb) rgb = enhancer.enhance(contrast) # 锐度增强 enhancer = ImageEnhance.Sharpness(rgb) rgb = enhancer.enhance(sharpness) return Image.merge("RGBA", (*rgb.split(), a)) # ============================================================================ # §7 场景感知合成器(主类) # ============================================================================ class SceneAwareCompositor: """ 场景感知合成器 — 替代固定 scale 的 smart_composite。 核心流程: 1. 根据场景配置中的参照物计算像素/厘米比 2. 根据产品真实尺寸和像素/厘米比计算产品像素尺寸 3. 根据纵深层次调整产品大小(前景略大,远景略小) 4. 对产品施加透视变换 5. 使用高斯模糊混合将产品融入场景 6. 施加景深模糊 7. 产品突出处理 使用示例: compositor = SceneAwareCompositor() result = compositor.composite( scene_image=scene_img, product_image=product_img, scene_type="lifestyle_bathroom", product_spec=HDB1_PRODUCT, position=(0.5, 0.45), ) """ def __init__(self, canvas_w: int = 1024, canvas_h: int = 1024): self.canvas_w = canvas_w self.canvas_h = canvas_h self.scale_calculator = PixelScaleCalculator(canvas_w, canvas_h) self.perspective_matcher = PerspectiveMatcher() self.dof_simulator = DepthOfFieldSimulator() self.product_emphasis = ProductEmphasis() def composite( self, scene_image: Image.Image, product_image: Image.Image, scene_type: str = "lifestyle_bathroom", product_spec: Optional[ProductSpec] = None, position: Tuple[float, float] = (0.5, 0.45), depth_layer: str = "midground", do_perspective: bool = True, do_dof: bool = True, do_emphasis: bool = True, edge_blur_sigma: float = 8.0, perspective_override: Optional[float] = None, ) -> CompositeResult: """ 场景感知合成主入口。 Args: scene_image: 场景背景图 product_image: 产品图(白底,将被抠出) scene_type: 场景类型(对应 SCENE_CONFIGS 的 key) product_spec: 产品规格(默认 HDB1) position: 产品在画面中的归一化位置 (x_ratio, y_ratio) depth_layer: 产品纵深层次 do_perspective: 是否做透视变换 do_dof: 是否做景深模糊 do_emphasis: 是否做产品突出 edge_blur_sigma: 边缘混合模糊 sigma perspective_override: 覆盖视角角度(用于测试) Returns: CompositeResult """ if product_spec is None: product_spec = HDB1_PRODUCT # 获取场景配置 scene_config = SCENE_CONFIGS.get(scene_type) if scene_config is None: raise ValueError(f"Unknown scene type: {scene_type}. " f"Available: {list(SCENE_CONFIGS.keys())}") # ── Step 1: 计算像素/厘米比 ── pixel_per_cm = self.scale_calculator.estimate_from_expected_positions(scene_config) # ── Step 2: 计算产品像素尺寸 ── product_w_cm = product_spec.width_cm product_h_cm = product_spec.height_cm target_w_px = int(product_w_cm * pixel_per_cm) target_h_px = int(product_h_cm * pixel_per_cm) # 约束:产品不应超过画布的 70% 宽度或 80% 高度 max_w = int(self.canvas_w * 0.70) max_h = int(self.canvas_h * 0.80) if target_w_px > max_w: scale_down = max_w / target_w_px target_w_px = max_w target_h_px = int(target_h_px * scale_down) if target_h_px > max_h: scale_down = max_h / target_h_px target_h_px = max_h target_w_px = int(target_w_px * scale_down) # 最小尺寸约束:至少占画布 15% 宽 min_w = int(self.canvas_w * 0.15) if target_w_px < min_w: scale_up = min_w / target_w_px target_w_px = min_w target_h_px = int(target_h_px * scale_up) # ── Step 3: 纵深层次调整 ── # 前景产品放大 5%,远景缩小 5% depth_scale_factors = { "foreground": 1.05, "midground": 1.0, "background": 0.90, } depth_factor = depth_scale_factors.get(depth_layer, 1.0) target_w_px = int(target_w_px * depth_factor) target_h_px = int(target_h_px * depth_factor) # ── Step 4: 缩放产品 ── prod_resized = product_image.resize( (target_w_px, target_h_px), Image.LANCZOS ) # ── Step 5: 透视变换 ── if do_perspective: view_angle = perspective_override if perspective_override is not None else scene_config.view_angle_deg prod_resized = self.perspective_matcher.apply_perspective( prod_resized, view_angle, depth_layer ) # ── Step 6: 景深模糊 ── if do_dof: prod_resized = self.dof_simulator.apply_dof( prod_resized, depth_layer ) # ── Step 7: 产品突出 ── if do_emphasis: prod_resized = self.product_emphasis.emphasize( prod_resized, brightness=1.04, contrast=1.08, sharpness=1.15, ) # ── Step 8: 抠图 & 合成到场景 ── # 生成 alpha mask(去白底) mask = self._remove_white_bg(prod_resized) # 计算放置位置(绝对像素) px = int(position[0] * self.canvas_w) - target_w_px // 2 py = int(position[1] * self.canvas_h) - target_h_px // 2 # 边界修正 px = max(0, min(px, self.canvas_w - target_w_px)) py = max(0, min(py, self.canvas_h - target_h_px)) # 边缘融合合成 result_image = self._alpha_blend_composite( scene_image, prod_resized, mask, px, py, edge_blur_sigma ) # 计算产品占画布比例 product_scale_ratio = target_w_px / self.canvas_w return CompositeResult( image=result_image, product_bbox=(px, py, target_w_px, target_h_px), pixel_per_cm=pixel_per_cm, product_scale_ratio=product_scale_ratio, perspective_params={ "view_angle_deg": scene_config.view_angle_deg, "depth_layer": depth_layer, }, depth_blur_sigma=DepthOfFieldSimulator.LAYER_BLUR_MAP.get(depth_layer, 0.0), ) def _remove_white_bg(self, image: Image.Image, threshold: int = 30) -> np.ndarray: """ 去除白色背景,生成前景 alpha mask。 如果图像有 alpha 通道,优先使用 alpha 作为基础 mask, 同时去除白底像素(处理半透明白边)。 无 alpha 时回退到距离法:mask = 1 if ||RGB - (255,255,255)|| >= threshold else 0 """ if image.mode == "RGBA": # 有 alpha 通道:以 alpha 为基础,同时排除接近白色的像素 rgba = np.array(image).astype(float) alpha_mask = rgba[:, :, 3] / 255.0 # 0~1 rgb_dist = np.linalg.norm(rgba[:, :, :3] - 255, axis=2) white_exclude = (rgb_dist >= threshold).astype(float) # 两者取交集:alpha 不透明 且 不是白色 return np.minimum(alpha_mask, white_exclude).astype(np.float32) else: # 无 alpha 通道:回退到距离法 arr = np.array(image.convert("RGB")).astype(float) dist = np.linalg.norm(arr - 255, axis=2) return (dist >= threshold).astype(np.float32) def _alpha_blend_composite( self, scene: Image.Image, product: Image.Image, mask: np.ndarray, px: int, py: int, blur_sigma: float = 8.0, ) -> Image.Image: """ 将产品通过软遮罩混合到场景中。 核心是模糊 mask 的边缘,使产品与场景的过渡自然。 与 smart_composite 的混合方式一致: final = product × blurred_mask + scene × (1 - blurred_mask) Args: scene: 场景背景图 product: 产品图(已缩放/透视/增强) mask: 前景 mask(0/1 二值) px, py: 产品放置位置 blur_sigma: 边缘模糊 sigma Returns: 合成后的图像 """ w, h = scene.size arr_scene = np.array(scene.convert("RGB")).astype(float) # 如果产品有 alpha 通道,用 alpha 预乘 RGB,避免透明区黑色渗入边缘 if product.mode == "RGBA": rgba = np.array(product).astype(float) alpha_norm = rgba[:, :, 3:4] / 255.0 prod_arr = rgba[:, :, :3] # 保留原始 RGB(透明区可能为任意值) # 预乘 alpha:透明区域的 RGB 被 alpha 加权为 0,不会污染混合结果 prod_arr = prod_arr * alpha_norm else: prod_arr = np.array(product.convert("RGB")).astype(float) # 确保产品图尺寸与 mask 一致 if prod_arr.shape[:2] != mask.shape[:2]: mask = np.array( Image.fromarray((mask * 255).astype(np.uint8)).resize( (prod_arr.shape[1], prod_arr.shape[0]), Image.LANCZOS ) ).astype(float) / 255.0 # 裁剪 mask 和 product 到不超出场景边界 pw, ph = prod_arr.shape[1], prod_arr.shape[0] crop_x2 = min(px + pw, w) crop_y2 = min(py + ph, h) crop_pw = crop_x2 - px crop_ph = crop_y2 - py if crop_pw <= 0 or crop_ph <= 0: return scene # 产品完全在画面外 mask_crop = mask[:crop_ph, :crop_pw] prod_crop = prod_arr[:crop_ph, :crop_pw] # 边缘模糊 mask_blur = gaussian_filter(mask_crop, sigma=blur_sigma) mask_blur = np.clip(mask_blur, 0, 1) # Alpha 混合 scene_crop = arr_scene[py:py + crop_ph, px:px + crop_pw].copy() final_arr = arr_scene.copy() for c in range(3): final_arr[py:py + crop_ph, px:px + crop_pw, c] = ( prod_crop[:, :, c] * mask_blur + scene_crop[:, :, c] * (1 - mask_blur) ) return Image.fromarray(final_arr.astype(np.uint8)) # ================================================================ # 辅助方法 # ================================================================ def generate_scene_prompt_with_refs(self, scene_type: str) -> str: """ 生成包含参照物描述的完整场景 prompt。 用于场景图生成阶段,让 AI 在画面中画出参照物。 Args: scene_type: 场景类型 Returns: 完整的场景 prompt(含参照物描述) """ config = SCENE_CONFIGS.get(scene_type) if not config: return "" prompt = config.base_prompt if config.reference_objects: ref_descriptions = ", ".join( ref.prompt_desc for ref in config.reference_objects ) prompt += f", {ref_descriptions}" return prompt def calculate_product_scale( self, scene_type: str, product_spec: Optional[ProductSpec] = None, ) -> Dict: """ 预计算产品在指定场景中的缩放参数(不执行实际合成)。 用于调试和预览。 Returns: dict with keys: pixel_per_cm, target_w_px, target_h_px, scale_ratio, depth_layer """ if product_spec is None: product_spec = HDB1_PRODUCT config = SCENE_CONFIGS.get(scene_type) if not config: raise ValueError(f"Unknown scene: {scene_type}") pixel_per_cm = self.scale_calculator.estimate_from_expected_positions(config) target_w = int(product_spec.width_cm * pixel_per_cm) target_h = int(product_spec.height_cm * pixel_per_cm) # 约束 max_w = int(self.canvas_w * 0.70) max_h = int(self.canvas_h * 0.80) if target_w > max_w: ratio = max_w / target_w target_w = max_w target_h = int(target_h * ratio) if target_h > max_h: ratio = max_h / target_h target_h = max_h target_w = int(target_w * ratio) return { "scene_type": scene_type, "scene_name": config.name, "view_angle_deg": config.view_angle_deg, "pixel_per_cm": round(pixel_per_cm, 2), "target_w_px": target_w, "target_h_px": target_h, "scale_ratio": round(target_w / self.canvas_w, 4), "ref_objects_count": len(config.reference_objects), "ref_objects": [ { "name": r.name, "real_size": f"{r.real_width_cm}x{r.real_height_cm}cm", "expected_position": f"({r.expected_x_range[0]:.0%}~{r.expected_x_range[1]:.0%}, " f"{r.expected_y_range[0]:.0%}~{r.expected_y_range[1]:.0%})", } for r in config.reference_objects ], } # ============================================================================ # §8 兼容层 — 与原 smart_composite 接口一致 # ============================================================================ def scene_aware_composite( scene: Image.Image, product: Image.Image, scene_type: str = "lifestyle_bathroom", product_spec: Optional[ProductSpec] = None, scale: Optional[float] = None, y_ratio: float = 0.50, blur_sigma: float = 8.0, do_perspective: bool = True, do_dof: bool = True, do_emphasis: bool = True, ) -> Tuple[Image.Image, Tuple[int, int, int, int]]: """ 与原 smart_composite 兼容的函数接口。 如果传入 scale,则使用 scale 模式(与旧接口一致,退化为固定 scale)。 如果传入 scene_type,则使用场景感知模式(推荐)。 Args: scene: 场景背景图 product: 产品图 scene_type: 场景类型 product_spec: 产品规格 scale: 覆盖自动计算的 scale(兼容旧接口) y_ratio: 产品垂直位置比例 blur_sigma: 边缘模糊 sigma do_perspective: 透视变换 do_dof: 景深 do_emphasis: 产品突出 Returns: (合成图, (px, py, w, h)) """ w, h = scene.size compositor = SceneAwareCompositor(w, h) if scale is not None: # 兼容模式:使用固定 scale(退化为原始 smart_composite 的行为) # 但仍然可以叠加透视、景深、突出效果 product_spec = product_spec or HDB1_PRODUCT target_w = int(w * scale) prod_scale_factor = target_w / product.size[0] target_h = int(product.size[1] * prod_scale_factor) prod_resized = product.resize((target_w, target_h), Image.LANCZOS) if do_perspective: config = SCENE_CONFIGS.get(scene_type) angle = config.view_angle_deg if config else 15 prod_resized = PerspectiveMatcher.apply_perspective(prod_resized, angle) if do_dof: prod_resized = DepthOfFieldSimulator.apply_dof(prod_resized, "midground") if do_emphasis: prod_resized = ProductEmphasis.emphasize(prod_resized) mask = compositor._remove_white_bg(prod_resized) px = (w - target_w) // 2 py = max(0, min(h - target_h, int(h * y_ratio))) result = compositor._alpha_blend_composite( scene, prod_resized, mask, px, py, blur_sigma ) return result, (px, py, target_w, target_h) else: # 场景感知模式(推荐) position_x = 0.5 # 居中 position_y = y_ratio result = compositor.composite( scene_image=scene, product_image=product, scene_type=scene_type, product_spec=product_spec, position=(position_x, position_y), do_perspective=do_perspective, do_dof=do_dof, do_emphasis=do_emphasis, edge_blur_sigma=blur_sigma, ) return result.image, result.product_bbox # ============================================================================ # §9 独立测试 & 演示 # ============================================================================ def _create_test_product(width: int = 300, height: int = 800) -> Image.Image: """ 创建一个模拟的剃须刀产品图(用于测试)。 深灰色圆柱体 + 金色顶部。 """ img = Image.new("RGBA", (width, height), (255, 255, 255, 0)) arr = np.zeros((height, width, 4), dtype=np.uint8) # 主体:深灰色圆柱(椭圆渐变模拟立体感) cx = width // 2 for y in range(int(height * 0.12), int(height * 0.92)): for x in range(width): dx = (x - cx) / (width * 0.38) if abs(dx) <= 1.0: # 圆柱体光照:cos 渐变 brightness = int(80 + 60 * math.cos(dx * math.pi / 2)) arr[y, x] = [brightness, brightness, brightness + 5, 255] # 顶部:金色刀网 for y in range(0, int(height * 0.12)): for x in range(width): dx = (x - cx) / (width * 0.35) if abs(dx) <= 1.0: gold_r = int(180 + 40 * math.cos(dx * math.pi / 2)) gold_g = int(150 + 30 * math.cos(dx * math.pi / 2)) gold_b = int(60 + 20 * math.cos(dx * math.pi / 2)) arr[y, x] = [gold_r, gold_g, gold_b, 255] # 底部 LangKe logo 区域 for y in range(int(height * 0.92), height): for x in range(width): dx = (x - cx) / (width * 0.38) if abs(dx) <= 1.0: arr[y, x] = [50, 50, 55, 255] img = Image.fromarray(arr) return img def _create_test_scene( width: int = 1024, height: int = 1024, style: str = "bathroom" ) -> Image.Image: """创建一个简单的测试场景图。""" arr = np.zeros((height, width, 3), dtype=np.uint8) if style == "bathroom": # 白色台面 + 微渐变 for y in range(height): for x in range(width): # 基础白色台面 base = 220 + int(15 * math.sin(x / 100) * math.cos(y / 80)) arr[y, x] = [base, base + 2, base + 5] elif style == "dark": # 深色渐变背景 for y in range(height): for x in range(width): r = int(20 + 30 * (y / height)) g = int(20 + 25 * (y / height)) b = int(30 + 40 * (y / height)) arr[y, x] = [r, g, b] else: # 木纹色 for y in range(height): for x in range(width): base_r = 120 + int(20 * math.sin(x / 30 + y / 50)) base_g = 80 + int(15 * math.sin(x / 25 + y / 45)) base_b = 50 + int(10 * math.sin(x / 20 + y / 40)) arr[y, x] = [base_r, base_g, base_b] return Image.fromarray(arr) def main(): """独立测试入口。""" import os output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scene_aware_test_output") os.makedirs(output_dir, exist_ok=True) print("=" * 60) print(" Scene-Aware Compositor — 独立测试") print("=" * 60) # 创建测试素材 product_img = _create_test_product(300, 800) product_img.save(os.path.join(output_dir, "test_product.png")) print("\n✓ 测试产品图已生成") # ── 测试 1: 预计算各场景的产品缩放参数 ── print("\n" + "─" * 50) print("测试 1: 各场景的产品缩放参数") print("─" * 50) compositor = SceneAwareCompositor(1024, 1024) for scene_type in SCENE_CONFIGS: info = compositor.calculate_product_scale(scene_type, HDB1_PRODUCT) print(f"\n [{info['scene_name']}] ({scene_type})") print(f" 视角: {info['view_angle_deg']}°") print(f" 像素/厘米比: {info['pixel_per_cm']}") print(f" 产品像素尺寸: {info['target_w_px']} × {info['target_h_px']}") print(f" 占画布比例: {info['scale_ratio']:.1%}") if info['ref_objects']: print(f" 参照物 ({info['ref_objects_count']}个):") for ref in info['ref_objects']: print(f" - {ref['name']} ({ref['real_size']}) @ {ref['expected_position']}") # ── 测试 2: 各场景合成测试 ── print("\n" + "─" * 50) print("测试 2: 场景感知合成") print("─" * 50) test_cases = [ ("lifestyle_bathroom", "bathroom", (0.5, 0.45), "midground"), ("topdown_greenery", "bathroom", (0.5, 0.45), "midground"), ("warm_wood", "wood", (0.5, 0.45), "midground"), ("tech_gradient", "dark", (0.5, 0.50), "foreground"), ("modern_marble", "bathroom", (0.5, 0.45), "midground"), ("minimalist", "bathroom", (0.5, 0.45), "midground"), ] for scene_type, bg_style, pos, depth in test_cases: scene_img = _create_test_scene(1024, 1024, bg_style) result = compositor.composite( scene_image=scene_img, product_image=product_img, scene_type=scene_type, product_spec=HDB1_PRODUCT, position=pos, depth_layer=depth, do_perspective=True, do_dof=True, do_emphasis=True, ) filename = f"test_{scene_type}.png" result.image.save(os.path.join(output_dir, filename)) px, py, pw, ph = result.product_bbox print(f"\n ✓ {scene_type}") print(f" 产品位置: ({px}, {py}), 尺寸: {pw}×{ph}") print(f" px/cm: {result.pixel_per_cm:.2f}") print(f" scale_ratio: {result.product_scale_ratio:.3f}") # ── 测试 3: 兼容接口测试 ── print("\n" + "─" * 50) print("测试 3: 兼容 smart_composite 接口") print("─" * 50) scene_img = _create_test_scene(1024, 1024, "bathroom") # 3a: 使用 scene_type(场景感知模式) result_img, bbox = scene_aware_composite( scene=scene_img, product=product_img, scene_type="lifestyle_bathroom", ) result_img.save(os.path.join(output_dir, "test_compat_scene_aware.png")) print(f"\n ✓ 场景感知模式: bbox={bbox}") # 3b: 使用 scale(兼容模式) result_img, bbox = scene_aware_composite( scene=scene_img, product=product_img, scene_type="lifestyle_bathroom", scale=0.42, y_ratio=0.50, ) result_img.save(os.path.join(output_dir, "test_compat_fixed_scale.png")) print(f" ✓ 固定 scale 兼容模式: bbox={bbox}") # ── 测试 4: 生成含参照物的场景 prompt ── print("\n" + "─" * 50) print("测试 4: 含参照物的场景 Prompt") print("─" * 50) for scene_type in SCENE_CONFIGS: prompt = compositor.generate_scene_prompt_with_refs(scene_type) print(f"\n [{scene_type}]") print(f" {prompt[:120]}...") # ── 测试 5: 透视变换可视化对比 ── print("\n" + "─" * 50) print("测试 5: 透视变换效果对比") print("─" * 50) for angle in [0, 15, 30, 60, 85]: transformed = PerspectiveMatcher.apply_perspective(product_img, angle) transformed.save(os.path.join(output_dir, f"perspective_{angle}deg.png")) print(f" ✓ {angle}° 透视变换已保存") # ── 测试 6: 景深模糊对比 ── print("\n" + "─" * 50) print("测试 6: 景深模糊效果对比") print("─" * 50) for layer in ["foreground", "midground", "background"]: blurred = DepthOfFieldSimulator.apply_dof(product_img, layer) blurred.save(os.path.join(output_dir, f"dof_{layer}.png")) sigma = DepthOfFieldSimulator.LAYER_BLUR_MAP[layer] print(f" ✓ {layer} (sigma={sigma}) 已保存") # ── 总结 ── print("\n" + "=" * 60) print(f" 所有测试完成!输出目录: {output_dir}") print("=" * 60) print("\n生成的文件:") for f in sorted(os.listdir(output_dir)): fpath = os.path.join(output_dir, f) size_kb = os.path.getsize(fpath) / 1024 print(f" {f} ({size_kb:.1f} KB)") if __name__ == "__main__": main() -
style_matcher.py 19.8 KB
#!/usr/bin/env python3 """ style_matcher.py — 风格自动匹配引擎 ==================================== 根据品类识别结果+价位+平台+品牌,自动推荐完整的风格方案。 输出可直接被 plan.json 消费的配置。 Usage: python scripts/style_matcher.py \\ --category <品类> \\ --sub-category <子品类> \\ --price <价位> \\ --platform <平台> \\ --brand <品牌名,可选> \\ --output <输出JSON路径> Output: 完整风格方案JSON,包含 recommended_templates, scene_style_config, layout_recommendations, platform_config, price_tier, brand_override 依赖: 无外部依赖(纯Python + json) """ import argparse import json import os import sys from pathlib import Path from typing import Dict, List, Optional, Any # ============================================================================ # 路径常量 # ============================================================================ SKILL_DIR = Path(__file__).parent.parent KB_DIR = SKILL_DIR / "references" / "knowledge_base" BRAND_DIR = SKILL_DIR / "references" / "brand_profiles" CATEGORY_TEMPLATES_FILE = KB_DIR / "category_templates.json" PRODUCT_PROFILES_FILE = KB_DIR / "product_profiles.json" # ============================================================================ # 平台配置 # ============================================================================ PLATFORM_CONFIGS: Dict[str, Dict[str, Any]] = { "taobao": { "main_image_size": [800, 800], "detail_image_size": [750, 1000], "text_max_length_per_line": 18, "max_title_length": 60, "notes": "淘宝主图800×800,详情宽度750" }, "pinduoduo": { "main_image_size": [750, 750], "detail_image_size": [750, 1000], "text_max_length_per_line": 16, "max_title_length": 50, "notes": "拼多多主图750×750" }, "xiaohongshu": { "main_image_size": [1080, 1440], "detail_image_size": [1080, 1440], "text_max_length_per_line": 22, "max_title_length": 30, "notes": "小红书3:4竖图,文字宜精不宜多" }, "douyin": { "main_image_size": [800, 800], "detail_image_size": [750, 1000], "text_max_length_per_line": 16, "max_title_length": 30, "notes": "抖音电商主图800×800" }, "kuaishou": { "main_image_size": [800, 800], "detail_image_size": [750, 1000], "text_max_length_per_line": 16, "max_title_length": 30, "notes": "快手电商主图800×800" }, "general": { "main_image_size": [1024, 1024], "detail_image_size": [750, 1000], "text_max_length_per_line": 20, "max_title_length": 40, "notes": "通用尺寸,适合AI生图输出" } } # 价位分档 PRICE_TIERS = { "low": (0, 100), # < 100 "mid": (100, 500), # 100 - 500 "high": (500, 2000), # 500 - 2000 "luxury": (2000, 999999), # > 2000 } PRICE_TIER_CN = { "low": "低价位(< 100元)", "mid": "中价位(100-500元)", "high": "高价位(500-2000元)", "luxury": "奢华价位(> 2000元)", } # 价位 → 风格映射 PRICE_STYLE_MAP: Dict[str, Dict[str, List[str]]] = { "low": { "default_templates": ["minimalist", "clean_light"], "color_strategy": "明亮浅色系为主,突出性价比和清爽感", "text_style": "粗体无衬线,高对比度,字号偏大", }, "mid": { "default_templates": ["tech_gradient", "modern_marble", "warm_wood"], "color_strategy": "深色/质感系,突出品质感", "text_style": "金色/白色搭配,精致无衬线", }, "high": { "default_templates": ["modern_marble", "luxury_dark", "cozy_living"], "color_strategy": "高端质感色系,突出品牌溢价", "text_style": "衬线体/细体无衬线,优雅留白", }, "luxury": { "default_templates": ["luxury_dark", "modern_marble"], "color_strategy": "深色+金属色点缀,极致奢华感", "text_style": "金色/玫瑰金,衬线体,大留白", } } # 11张图标准布局建议 LAYOUT_TEMPLATE_11: Dict[str, Dict[str, Any]] = { "main_images": { "main_01": { "type": "封面主图", "text_position": "bottom", "product_ratio": 0.45, "text_layout_template": "hero", "scene_priority": ["minimalist", "tech_gradient", "topdown_greenery"] }, "main_02": { "type": "卖点主图A", "text_position": "left", "product_ratio": 0.55, "text_layout_template": "selling_point", "scene_priority": ["minimalist"] }, "main_03": { "type": "卖点主图B", "text_position": "left", "product_ratio": 0.50, "text_layout_template": "selling_point", "scene_priority": ["warm_wood", "cozy_living", "modern_marble"] }, "main_04": { "type": "使用场景图", "text_position": "top", "product_ratio": 0.40, "text_layout_template": "lifestyle", "scene_priority": ["lifestyle_bathroom", "cozy_living", "warm_wood", "topdown_greenery"] }, "main_05": { "type": "参数/功能图", "text_position": "bottom", "product_ratio": 0.50, "text_layout_template": "specs", "scene_priority": ["minimalist"] }, }, "detail_images": { "detail_01": { "type": "情境开篇图", "text_position": "top", "product_ratio": 0.30, "text_layout_template": "premium", "scene_priority": ["cozy_living", "warm_wood", "lifestyle_bathroom"] }, "detail_02": { "type": "卖点详情1", "text_position": "top_or_left", "product_ratio": 0.35, "text_layout_template": "premium", "scene_priority": ["modern_marble", "tech_gradient"] }, "detail_03": { "type": "卖点详情2", "text_position": "top", "product_ratio": 0.35, "text_layout_template": "premium", "scene_priority": ["warm_wood", "modern_marble"] }, "detail_04": { "type": "卖点详情3", "text_position": "bottom", "product_ratio": 0.40, "text_layout_template": "premium", "scene_priority": ["minimalist", "modern_marble"] }, "detail_05": { "type": "场景应用图", "text_position": "top", "product_ratio": 0.35, "text_layout_template": "lifestyle", "scene_priority": ["cozy_living", "topdown_greenery", "warm_wood"] }, "detail_06": { "type": "规格参数图", "text_position": "bottom", "product_ratio": 0.35, "text_layout_template": "specs", "scene_priority": ["minimalist"] }, } } # ============================================================================ # 工具函数 # ============================================================================ def load_json(filepath: Path) -> dict: """加载JSON文件""" if filepath.exists(): with open(filepath, "r", encoding="utf-8") as f: return json.load(f) return {} def get_price_tier(price: float) -> str: """根据价格返回价位档""" for tier, (low, high) in PRICE_TIERS.items(): if low <= price < high: return tier return "mid" def load_category_template(category: str, sub_category: str) -> dict: """ 加载品类模板,先查子品类,再查父品类。 Args: category: 父品类(如"个护电器") sub_category: 子品类(如"剃须刀") Returns: 品类模板配置 """ data = load_json(CATEGORY_TEMPLATES_FILE) categories = data.get("categories", {}) # 优先查子品类 if sub_category in categories: return categories[sub_category] # 再查父品类 if category in categories: return categories[category] # 回退到小家电通用 if "小家电通用" in categories: return categories["小家电通用"] return {} def load_brand_profile(brand: str) -> Optional[dict]: """ 加载品牌配置文件。 Args: brand: 品牌名称(如"langke") Returns: 品牌配置或None """ if not brand: return None brand_file = BRAND_DIR / f"{brand.lower()}.json" if brand_file.exists(): return load_json(brand_file) return None def compute_template_suitability(template_id: str, category_template: dict, price_tier: str, brand_profile: Optional[dict]) -> tuple: """ 计算模板适合度。 Returns: (suitability_score, reason) """ score = 0.5 # 基础分 reasons = [] # 品类模板推荐场景匹配 recommended_scene = category_template.get("scene_style", "") if template_id == recommended_scene: score += 0.35 reasons.append(f"{category_template.get('scene_style', '')}是该品类推荐场景") # 价位匹配 tier_config = PRICE_STYLE_MAP.get(price_tier, {}) tier_templates = tier_config.get("default_templates", []) if template_id in tier_templates: score += 0.15 rank = tier_templates.index(template_id) + 1 reasons.append(f"符合{PRICE_TIER_CN.get(price_tier, '')}风格定位") # 品牌偏好覆盖 if brand_profile: style_constraints = brand_profile.get("style_constraints", {}) preferred_scenes = style_constraints.get("preferred_scenes", []) if template_id in preferred_scenes: score += 0.1 reasons.append(f"品牌推荐场景") forbidden = style_constraints.get("forbidden_elements", []) # 检查模板是否触犯品牌禁忌(简化处理) if "花哨" in str(forbidden) and template_id in ["pastel_baby", "fashion_editorial"]: score -= 0.2 reasons.append("与品牌风格约束冲突") score = round(max(0.05, min(score, 0.99)), 2) reason = ";".join(reasons) if reasons else "通用模板" return (score, reason) # ============================================================================ # 主匹配流程 # ============================================================================ def match_style(category: str, sub_category: str, price: float, platform: str, brand: Optional[str] = None) -> dict: """ 完整风格匹配流程。 Args: category: 父品类 sub_category: 子品类 price: 价位 platform: 目标平台 brand: 品牌名(可选) Returns: 完整风格方案字典 """ # 1. 基础参数 price_tier = get_price_tier(price) platform_key = platform.lower() if platform else "general" platform_config = PLATFORM_CONFIGS.get(platform_key, PLATFORM_CONFIGS["general"]) # 2. 加载品类模板 category_template = load_category_template(category, sub_category) # 3. 加载品牌配置 brand_profile = load_brand_profile(brand) if brand else None brand_override = None if brand_profile: brand_colors = brand_profile.get("colors", {}) brand_override = { "brand_name": brand_profile.get("brand_cn", brand), "primary_color": brand_colors.get("primary", ""), "accent_color": brand_colors.get("accent", ""), "text_on_dark": brand_colors.get("text_on_dark", "#FFFFFF"), "text_on_light": brand_colors.get("text_on_light", "#1A1A1A"), "preferred_scenes": brand_profile.get("style_constraints", {}).get("preferred_scenes", []), } # 4. 计算模板适合度 all_templates = [ "tech_gradient", "minimalist", "warm_wood", "modern_marble", "topdown_greenery", "lifestyle_bathroom", "cozy_living", "rustic_food", "pastel_baby", "fashion_editorial", "desk_tech", "luxury_dark" ] template_scores = [] for tid in all_templates: score, reason = compute_template_suitability( tid, category_template, price_tier, brand_profile ) template_scores.append({ "template_id": tid, "suitability": score, "reason": reason }) # 按适合度排序 template_scores.sort(key=lambda x: x["suitability"], reverse=True) recommended_templates = template_scores[:5] # Top 5 # 5. 构建场景风格配置 tier_config = PRICE_STYLE_MAP.get(price_tier, PRICE_STYLE_MAP["mid"]) # 场景prompt关键词:品类模板优先,品牌覆盖 scene_prompt_keywords = category_template.get("scene_prompt_keywords", [ "clean product photography", "professional lighting" ]) # 配色方案:品牌优先 > 品类默认 > 价位默认 if brand_override and brand_override.get("primary_color"): color_palette = { "primary": brand_override["primary_color"], "accent": brand_override.get("accent_color", "#c9a84c"), "text": brand_override.get("text_on_dark", "#ffffff"), } else: color_palette = category_template.get("color_palette", { "primary": "#1a1a2e", "accent": "#c9a84c", "text": "#ffffff", }) lighting = category_template.get("lighting", "柔和均匀光线") text_style = category_template.get("text_style", tier_config.get("text_style", "白色粗体无衬线")) scene_style_config = { "scene_prompt_keywords": scene_prompt_keywords, "color_palette": color_palette, "lighting": lighting, "text_style": text_style, "color_strategy": tier_config.get("color_strategy", ""), } # 6. 构建布局建议 layout_recommendations = {} # 主图布局 main_layout = {} for img_id, config in LAYOUT_TEMPLATE_11["main_images"].items(): # 根据推荐模板调整场景优先级 adjusted_priority = [] for scene in config["scene_priority"]: if any(t["template_id"] == scene and t["suitability"] >= 0.5 for t in recommended_templates): adjusted_priority.append(scene) if not adjusted_priority: adjusted_priority = [recommended_templates[0]["template_id"]] if recommended_templates else ["minimalist"] main_layout[img_id] = { "type": config["type"], "text_position": config["text_position"], "product_ratio": config["product_ratio"], "text_layout_template": config["text_layout_template"], "scene_priority": adjusted_priority } # 详情图布局 detail_layout = {} for img_id, config in LAYOUT_TEMPLATE_11["detail_images"].items(): adjusted_priority = [] for scene in config["scene_priority"]: if any(t["template_id"] == scene and t["suitability"] >= 0.4 for t in recommended_templates): adjusted_priority.append(scene) if not adjusted_priority: adjusted_priority = [recommended_templates[0]["template_id"]] if recommended_templates else ["minimalist"] detail_layout[img_id] = { "type": config["type"], "text_position": config["text_position"], "product_ratio": config["product_ratio"], "text_layout_template": config["text_layout_template"], "scene_priority": adjusted_priority } layout_recommendations = { "main_images": { "text_position": "bottom", "product_ratio": 0.42, "text_layout_template": "tech_dark" if price_tier in ["mid", "high", "luxury"] else "clean_light", "per_image": main_layout }, "detail_images": { "text_position": "top_or_left", "product_ratio": 0.35, "text_layout_template": "premium", "per_image": detail_layout } } # 7. 组装最终结果 result = { "recommended_templates": recommended_templates, "scene_style_config": scene_style_config, "layout_recommendations": layout_recommendations, "platform_config": platform_config, "price_tier": price_tier, "price_tier_cn": PRICE_TIER_CN.get(price_tier, ""), "brand_override": brand_override, "category_info": { "category": category, "sub_category": sub_category, "template_source": "品类模板" if category_template else "默认", } } return result # ============================================================================ # CLI 入口 # ============================================================================ def main(): parser = argparse.ArgumentParser( description="风格自动匹配引擎 — 根据品类+价位+平台+品牌,推荐完整风格方案", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: python scripts/style_matcher.py \\ --category 个护电器 --sub-category 剃须刀 \\ --price 169 --platform kuaishou \\ --brand langke --output style_recommendation.json python scripts/style_matcher.py \\ --category 厨房小家电 --sub-category 空气炸锅 \\ --price 299 --platform taobao """ ) parser.add_argument( "--category", required=True, help="父品类(如:个护电器、3C数码、厨房小家电)" ) parser.add_argument( "--sub-category", required=True, help="子品类(如:剃须刀、空气炸锅、耳机)" ) parser.add_argument( "--price", required=True, type=float, help="产品价位(数字,如:169)" ) parser.add_argument( "--platform", default="general", choices=["taobao", "pinduoduo", "xiaohongshu", "douyin", "kuaishou", "general"], help="目标平台(默认:general)" ) parser.add_argument( "--brand", default=None, help="品牌名(可选,如:langke)" ) parser.add_argument( "--output", default=None, help="输出JSON路径(不指定则输出到标准输出)" ) parser.add_argument( "--pretty", action="store_true", default=True, help="美化JSON输出(默认开启)" ) args = parser.parse_args() # 执行风格匹配 result = match_style( category=args.category, sub_category=args.sub_category, price=args.price, platform=args.platform, brand=args.brand ) # 输出结果 indent = 2 if args.pretty else None output_json = json.dumps(result, ensure_ascii=False, indent=indent) if args.output: output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: f.write(output_json) print(f"风格推荐结果已保存到: {args.output}") # 输出摘要 print(f" 品类: {args.category} > {args.sub_category}") print(f" 价位档: {result['price_tier_cn']}") print(f" 平台: {args.platform} ({result['platform_config']['main_image_size']})") print(f" Top 3 推荐模板:") for t in result['recommended_templates'][:3]: print(f" - {t['template_id']} (适合度: {t['suitability']}) — {t['reason']}") if result['brand_override']: print(f" 品牌覆盖: {result['brand_override']['brand_name']}") else: print(output_json) if __name__ == "__main__": main() -
text_engine.py 46.5 KB
#!/usr/bin/env python3 """ text_engine.py — 统一文字管理引擎 ================================== 将 compose.py 和 brand_overlay.py 的文字叠加统一为单 pass 引擎, 解决文字重叠、z-index混乱等问题。 核心功能: 1. 读取 plan.json,合并所有文字源(texts + brand_config) 2. 统一 z-index 分层渲染 3. 文字自动避让产品区域 (product_bbox) 4. 字号自适应(根据画布尺寸和文字长度) 5. 可读性保障(对比度检测 + 自动添加背景块/描边) z-index 分层: Layer 0: 场景背景底图 Layer 1: 产品图(已合成到scene_image中) Layer 2: 品牌Logo(左上角) Layer 3: 保障条(底部) Layer 4: 徽章(右下角) Layer 5: 卖点标题+副标题(根据text_zones布局) Layer 6: 装饰元素(金色线条、分隔符等) 用法: # 主流程:处理整个plan.json python text_engine.py --plan /path/to/plan.json [--brand-config /path/to/brand.json] # 处理单张图片 python text_engine.py --input /path/to/image.png --plan /path/to/plan.json --image-id main_01 # 指定品牌(自动加载品牌配置) python text_engine.py --plan /path/to/plan.json --brand langke # 指定场景色调 python text_engine.py --plan /path/to/plan.json --brand langke --scene-tone dark 依赖: Pillow, numpy (可选,用于对比度检测) """ import argparse import json import math import os import sys from pathlib import Path from typing import Optional, List, Tuple, Dict, Any from PIL import Image, ImageDraw, ImageFont, ImageFilter # ============================================================================ # 常量 # ============================================================================ # z-index 定义 Z_BACKGROUND = 0 Z_PRODUCT = 1 Z_LOGO = 2 Z_GUARANTEE_BAR = 3 Z_BADGE = 4 Z_TEXT_CONTENT = 5 Z_DECORATION = 6 # 字体路径候选 FONT_PATHS_BOLD = [ "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc", ] FONT_PATHS_REGULAR = [ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", ] # 对比度阈值(WCAG AA标准) MIN_CONTRAST_RATIO = 4.5 # 技能目录 SKILL_DIR = Path(__file__).parent.parent BRAND_PROFILES_DIR = SKILL_DIR / "references" / "brand_profiles" BRAND_LOGOS_DIR = SKILL_DIR / "references" / "brand_logos" # ============================================================================ # 字体管理 # ============================================================================ _FONT_CACHE: Dict[Tuple[int, bool], ImageFont.FreeTypeFont] = {} def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: """加载并缓存字体""" key = (size, bold) if key in _FONT_CACHE: return _FONT_CACHE[key] paths = FONT_PATHS_BOLD if bold else FONT_PATHS_REGULAR for fp in paths: if Path(fp).exists(): try: f = ImageFont.truetype(fp, size, index=2) _FONT_CACHE[key] = f return f except (OSError, IOError): try: f = ImageFont.truetype(fp, size) _FONT_CACHE[key] = f return f except Exception: continue f = ImageFont.load_default() _FONT_CACHE[key] = f return f # ============================================================================ # 颜色工具 # ============================================================================ def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]: """将十六进制颜色转为RGB元组""" h = hex_color.lstrip("#") if len(h) == 3: h = h[0]*2 + h[1]*2 + h[2]*2 return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) def hex_to_rgba(hex_color: str, default_alpha: int = 255) -> Tuple[int, int, int, int]: """将十六进制颜色转为RGBA元组""" h = hex_color.lstrip("#") if len(h) == 3: r, g, b = (int(x*2, 16) for x in h) return (r, g, b, default_alpha) if len(h) == 4: r, g, b, a = (int(x*2, 16) for x in h) return (r, g, b, a) if len(h) == 6: return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), default_alpha) if len(h) == 8: return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), int(h[6:8], 16)) return (0, 0, 0, default_alpha) def relative_luminance(r: int, g: int, b: int) -> float: """计算RGB的相对亮度(WCAG 2.0公式)""" def linearize(c: int) -> float: s = c / 255.0 return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4 return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b) def contrast_ratio(color1: Tuple[int, int, int], color2: Tuple[int, int, int]) -> float: """计算两个RGB颜色之间的对比度比率""" l1 = relative_luminance(*color1) l2 = relative_luminance(*color2) lighter = max(l1, l2) darker = min(l1, l2) return (lighter + 0.05) / (darker + 0.05) def sample_region_avg_color(img: Image.Image, bbox: Tuple[int, int, int, int]) -> Tuple[int, int, int]: """采样区域平均颜色""" x1, y1, x2, y2 = bbox x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(img.width, x2), min(img.height, y2) if x2 <= x1 or y2 <= y1: return (128, 128, 128) region = img.crop((x1, y1, x2, y2)).convert("RGB") pixels = list(region.getdata()) if not pixels: return (128, 128, 128) avg_r = sum(p[0] for p in pixels) // len(pixels) avg_g = sum(p[1] for p in pixels) // len(pixels) avg_b = sum(p[2] for p in pixels) // len(pixels) return (avg_r, avg_g, avg_b) # ============================================================================ # 几何工具 # ============================================================================ def bbox_overlap(bbox1: Dict, bbox2: Dict) -> bool: """检测两个bbox是否重叠(bbox格式: {x1, y1, x2, y2})""" return not (bbox1["x2"] <= bbox2["x1"] or bbox1["x1"] >= bbox2["x2"] or bbox1["y2"] <= bbox2["y1"] or bbox1["y1"] >= bbox2["y2"]) def bbox_intersection_area(bbox1: Dict, bbox2: Dict) -> int: """计算两个bbox重叠面积""" x1 = max(bbox1["x1"], bbox2["x1"]) y1 = max(bbox1["y1"], bbox2["y1"]) x2 = min(bbox1["x2"], bbox2["x2"]) y2 = min(bbox1["y2"], bbox2["y2"]) if x2 <= x1 or y2 <= y1: return 0 return (x2 - x1) * (y2 - y1) def find_safe_zone(canvas_w: int, canvas_h: int, product_bbox: Optional[Dict], preferred_position: str = "auto") -> Dict: """ 根据产品位置找到安全的文字区域 Args: canvas_w, canvas_h: 画布尺寸 product_bbox: 产品区域 {x1, y1, x2, y2},可以为None preferred_position: 偏好位置 ("auto", "left", "right", "top", "bottom") Returns: 安全区域 {x1, y1, x2, y2} """ margin = int(min(canvas_w, canvas_h) * 0.04) if product_bbox is None: # 没有产品,全画布可用 return { "x1": margin, "y1": int(canvas_h * 0.14), "x2": canvas_w - margin, "y2": int(canvas_h * 0.88), } pb = product_bbox safe_zones = [] # 左侧区域 if pb["x1"] > canvas_w * 0.15: safe_zones.append({ "x1": margin, "y1": int(canvas_h * 0.14), "x2": pb["x1"] - margin, "y2": int(canvas_h * 0.85), "position": "left", "area": (pb["x1"] - margin) * (canvas_h * 0.71), }) # 右侧区域 if canvas_w - pb["x2"] > canvas_w * 0.15: safe_zones.append({ "x1": pb["x2"] + margin, "y1": int(canvas_h * 0.14), "x2": canvas_w - margin, "y2": int(canvas_h * 0.85), "position": "right", "area": (canvas_w - pb["x2"] - margin) * (canvas_h * 0.71), }) # 顶部区域 if pb["y1"] > canvas_h * 0.15: safe_zones.append({ "x1": margin, "y1": margin, "x2": canvas_w - margin, "y2": pb["y1"] - margin, "position": "top", "area": (canvas_w - 2*margin) * (pb["y1"] - margin), }) # 底部区域 if canvas_h - pb["y2"] > canvas_h * 0.15: safe_zones.append({ "x1": margin, "y1": pb["y2"] + margin, "x2": canvas_w - margin, "y2": int(canvas_h * 0.88), "position": "bottom", "area": (canvas_w - 2*margin) * (canvas_h * 0.88 - pb["y2"] - margin), }) if not safe_zones: # 兜底:使用底部条带 return { "x1": margin, "y1": int(canvas_h * 0.75), "x2": canvas_w - margin, "y2": int(canvas_h * 0.88), "position": "bottom", } # 按偏好位置选择 if preferred_position != "auto": for zone in safe_zones: if zone["position"] == preferred_position: return zone # 自动选择最大面积的安全区 safe_zones.sort(key=lambda z: z["area"], reverse=True) return safe_zones[0] # ============================================================================ # 字号自适应 # ============================================================================ def calc_auto_font_size(canvas_w: int, canvas_h: int, text: str, zone: Dict, role: str = "title") -> int: """ 根据画布尺寸、文字长度和安全区域自动计算最佳字号 Args: canvas_w, canvas_h: 画布尺寸 text: 文字内容 zone: 安全区域 {x1, y1, x2, y2} role: "title" (主标题) 或 "subtitle" (副标题) Returns: 推荐字号(px) """ zone_w = zone["x2"] - zone["x1"] zone_h = zone["y2"] - zone["y1"] # 基准字号比例 if role == "title": base_ratio = 0.06 # 主标题 ≥ 画布宽度的5% min_ratio = 0.05 else: base_ratio = 0.035 # 副标题 ≥ 画布宽度的3% min_ratio = 0.03 base_size = int(canvas_w * base_ratio) min_size = int(canvas_w * min_ratio) # 根据文字长度调整 lines = text.split("\n") max_line_len = max(len(line) for line in lines) if lines else 1 # 中文字符约占字号宽度,英文约占0.6倍 est_char_width = base_size * 0.8 # 估算每字符宽度 est_line_width = max_line_len * est_char_width if est_line_width > zone_w * 0.9: # 文字太宽,缩小字号 scale = (zone_w * 0.9) / est_line_width base_size = int(base_size * scale) # 检查高度是否足够 line_height = int(base_size * 1.3) total_text_height = line_height * len(lines) if total_text_height > zone_h * 0.85: scale = (zone_h * 0.85) / total_text_height base_size = int(base_size * scale) return max(base_size, min_size) # ============================================================================ # 可读性增强 # ============================================================================ def ensure_readability(img: Image.Image, text_color: Tuple[int, int, int], text_bbox: Tuple[int, int, int, int], is_dark: bool) -> Dict: """ 检测文字与背景对比度,不足时返回增强方案 Args: img: 当前画布图像 text_color: 文字RGB颜色 text_bbox: 文字区域 (x1, y1, x2, y2) is_dark: 是否深色场景 Returns: {"need_background": bool, "bg_color": ..., "need_stroke": bool, "stroke_color": ...} """ bg_avg = sample_region_avg_color(img, text_bbox) ratio = contrast_ratio(text_color, bg_avg) result = { "contrast_ratio": ratio, "need_background": False, "bg_color": None, "need_stroke": False, "stroke_color": None, } if ratio >= MIN_CONTRAST_RATIO: return result # 对比度不足,添加增强 if is_dark: # 深色场景:加半透明深色背景块 + 白色描边 result["need_background"] = True result["bg_color"] = (0, 0, 0, 160) result["need_stroke"] = True result["stroke_color"] = (0, 0, 0, 200) else: # 浅色场景:加半透明白色背景块 + 深色描边 result["need_background"] = True result["bg_color"] = (255, 255, 255, 180) result["need_stroke"] = True result["stroke_color"] = (255, 255, 255, 200) return result # ============================================================================ # 品牌配置加载 # ============================================================================ def load_brand_config(brand_name: Optional[str], scene_tone: str = "dark") -> Optional[Dict]: """加载品牌配置(通过brand_loader)""" if not brand_name: return None try: sys.path.insert(0, str(Path(__file__).parent)) from brand_loader import load_brand config = load_brand(brand_name) return config.to_overlay_config(scene_tone) except (FileNotFoundError, ImportError): return None def load_brand_config_from_file(config_path: str) -> Optional[Dict]: """从JSON文件加载品牌配置""" if not config_path or not Path(config_path).exists(): return None with open(config_path, "r", encoding="utf-8") as f: return json.load(f) # ============================================================================ # 渲染引擎核心 # ============================================================================ class TextRenderItem: """一个待渲染的文字元素""" def __init__(self, z_index: int, content: str, position: Tuple[int, int], font_size: int, color: Tuple[int, int, int, int], bold: bool = False, role: str = "text", max_width: Optional[int] = None, align: str = "left"): self.z_index = z_index self.content = content self.position = position # (x, y) 左上角 self.font_size = font_size self.color = color self.bold = bold self.role = role # "logo", "title", "subtitle", "guarantee", "badge", "decoration" self.max_width = max_width self.align = align self._bbox_cache: Optional[Tuple[int, int, int, int]] = None def get_bbox(self) -> Tuple[int, int, int, int]: """获取文字区域的bbox""" if self._bbox_cache: return self._bbox_cache # 对于非文字元素(logo/badge/装饰),使用估算尺寸 if self.font_size <= 0: x1, y1 = self.position est_w = self.max_width if self.max_width else 100 est_h = 100 self._bbox_cache = (x1, y1, x1 + est_w, y1 + est_h) return self._bbox_cache font = load_font(self.font_size, self.bold) lines = self.content.split("\n") max_w = 0 total_h = 0 line_h = int(self.font_size * 1.3) for line in lines: bbox = font.getbbox(line) w = bbox[2] - bbox[0] max_w = max(max_w, w) total_h += line_h if self.max_width and max_w > self.max_width: max_w = self.max_width x1, y1 = self.position self._bbox_cache = (x1, y1, x1 + max_w, y1 + total_h) return self._bbox_cache class TextEngine: """统一文字管理引擎""" def __init__(self, canvas: Image.Image, plan_data: dict, brand_config: Optional[dict] = None, scene_tone: str = "dark"): self.canvas = canvas.convert("RGBA") self.width = canvas.width self.height = canvas.height self.plan = plan_data self.brand_config = brand_config self.scene_tone = scene_tone self.is_dark = scene_tone == "dark" self.render_items: List[TextRenderItem] = [] self.product_bbox: Optional[Dict] = None self._zone_tracker: Dict[str, int] = {} # zone_key -> y_offset accumulator def collect_items(self, image_cfg: dict): """收集所有需要渲染的文字元素""" self.product_bbox = image_cfg.get("product_bbox") # Layer 2: Logo self._collect_logo(image_cfg) # Layer 3: 保障条 self._collect_guarantee_bar(image_cfg) # Layer 4: 徽章 self._collect_badge(image_cfg) # Layer 5: 卖点文字 self._collect_selling_text(image_cfg) # Layer 6: 装饰元素 self._collect_decorations(image_cfg) # 按 z-index 排序(保持同z-index的插入顺序) self.render_items.sort(key=lambda item: item.z_index) # Python sort is stable, preserves insertion order for equal keys # 重新计算zone内堆叠偏移(排序后顺序可能改变) self._recalculate_zone_stacking() def _recalculate_zone_stacking(self): """重新计算zone内文字堆叠偏移(在排序后调用) 按zone_direction分组(top/left/right/bottom), 每组内文字从该方向的基准位置开始依次堆叠。 """ # 按zone_direction分组 zone_groups: Dict[str, List[TextRenderItem]] = {} for item in self.render_items: if item.z_index != Z_TEXT_CONTENT: continue direction = getattr(item, '_zone_direction', 'auto') if direction not in zone_groups: zone_groups[direction] = [] zone_groups[direction].append(item) # 每个方向组内依次堆叠 for direction, items in zone_groups.items(): if not items: continue # 计算该方向组的基准起始位置 if direction == "top": # 从顶部开始,logo下方 base_y = int(self.height * 0.14) # 品牌区底部 current_y = base_y for item in items: item.position = (item.position[0], current_y) item._bbox_cache = None line_height = int(item.font_size * 1.5) + 8 current_y += line_height elif direction == "bottom": # 从底部开始向上堆叠(保证条上方) bar_top = int(self.height * 0.88) # 反向:先计算总高度,再从底部往上排 total_height = sum(int(it.font_size * 1.5) + 8 for it in items) current_y = bar_top - total_height for item in items: item.position = (item.position[0], current_y) item._bbox_cache = None line_height = int(item.font_size * 1.5) + 8 current_y += line_height elif direction == "left": # 左侧从上到下堆叠 base_y = int(self.height * 0.14) current_y = base_y for item in items: item.position = (item.position[0], current_y) item._bbox_cache = None line_height = int(item.font_size * 1.5) + 8 current_y += line_height elif direction == "right": # 右侧从上到下堆叠 base_y = int(self.height * 0.14) current_y = base_y for item in items: item.position = (item.position[0], current_y) item._bbox_cache = None line_height = int(item.font_size * 1.5) + 8 current_y += line_height else: # auto / center base_y = int(self.height * 0.14) current_y = base_y for item in items: item.position = (item.position[0], current_y) item._bbox_cache = None line_height = int(item.font_size * 1.5) + 8 current_y += line_height def _collect_logo(self, image_cfg: dict): """收集Logo元素""" if not self.brand_config: return logo_path = self.brand_config.get("logo", {}).get("path", "") if not logo_path or not Path(logo_path).exists(): return max_w_ratio = self.brand_config.get("logo", {}).get("max_width_ratio", 0.25) margin = int(self.width * 0.03) # Logo放在左上角 self.render_items.append(TextRenderItem( z_index=Z_LOGO, content=f"__LOGO__:{logo_path}", position=(margin, margin), font_size=0, color=(255, 255, 255, 255), role="logo", max_width=int(self.width * max_w_ratio), )) def _collect_guarantee_bar(self, image_cfg: dict): """收集保障条元素""" if not self.brand_config: return bar_config = self.brand_config.get("guarantee_bar", {}) labels = bar_config.get("labels", []) if not labels: return bar_h_ratio = bar_config.get("height_ratio", 0.055) bar_h = int(self.height * bar_h_ratio) margin = int(self.width * 0.03) bar_y = self.height - bar_h - margin # 确定保障条右边界(为徽章让位) badge_config = self.brand_config.get("badge", {}) badge_max_w = int(self.width * badge_config.get("max_width_ratio", 0.14)) bar_right = self.width - badge_max_w - margin * 2 if badge_config.get("path") else self.width colors = self.brand_config.get("colors", {}) self.render_items.append(TextRenderItem( z_index=Z_GUARANTEE_BAR, content=f"__GUARANTEE_BAR__:{json.dumps({'labels': labels, 'bar_y': bar_y, 'bar_h': bar_h, 'bar_right': bar_right, 'colors': colors, 'is_dark': self.is_dark})}", position=(0, bar_y), font_size=max(14, int(self.width * 0.018)), color=hex_to_rgb(colors.get("bar_text_dark" if self.is_dark else "bar_text_light", "#C8C8C8")), bold=True, role="guarantee", )) def _collect_badge(self, image_cfg: dict): """收集徽章元素""" if not self.brand_config: return badge_path = self.brand_config.get("badge", {}).get("path", "") if not badge_path or not Path(badge_path).exists(): return max_w_ratio = self.brand_config.get("badge", {}).get("max_width_ratio", 0.14) margin = int(self.width * 0.03) badge_max_w = int(self.width * max_w_ratio) # 徽章放在右下角 badge_x = self.width - badge_max_w - margin # y位置在保障条上方 bar_h = int(self.height * 0.055) badge_y = self.height - bar_h - margin - int(self.height * 0.12) self.render_items.append(TextRenderItem( z_index=Z_BADGE, content=f"__BADGE__:{badge_path}", position=(badge_x, badge_y), font_size=0, color=(255, 255, 255, 255), role="badge", max_width=badge_max_w, )) def _collect_selling_text(self, image_cfg: dict): """收集卖点标题和副标题(支持zone内垂直堆叠)""" texts = image_cfg.get("texts", []) text_zones = image_cfg.get("text_zones", []) # 获取品牌名,用于检测logo冲突 brand_name = "" if self.brand_config: brand_name = self.brand_config.get("brand_name", "") or self.brand_config.get("brand_cn", "") for i, text_cfg in enumerate(texts): content_text = text_cfg.get("content", "") if not content_text: continue # 检测与品牌logo的文字冲突:如果内容包含品牌名且position在logo区域 # 则跳过该文字,避免与logo重复渲染 if brand_name and brand_name in content_text: position_key = text_cfg.get("position", "") if position_key in ("top-left", "top-center"): # 检查是否已有logo被渲染 has_logo = any(item.role == "logo" for item in self.render_items) if has_logo: print(f" ⏭️ 跳过文字'{content_text}'(与品牌logo冲突)") continue # 确定文字区域 zone = self._resolve_text_zone(text_cfg, text_zones, i) # 确定角色 role = "title" if i == 0 else "subtitle" # 自动计算字号 specified_size = text_cfg.get("font_size") if specified_size: font_size = int(specified_size) else: font_size = calc_auto_font_size( self.width, self.height, content_text, zone, role ) # 确定颜色 color_str = text_cfg.get("color", "#FFFFFF" if self.is_dark else "#1A1A1A") if isinstance(color_str, str): color = hex_to_rgba(color_str) else: color = tuple(color_str) if len(color_str) == 4 else (*color_str, 255) # 计算位置(支持zone内垂直堆叠) weight = text_cfg.get("weight", "regular") is_bold = weight == "bold" or role == "title" # 计算最终位置(堆叠偏移由排序后的_recalculate_zone_stacking处理) margin = int(min(self.width, self.height) * 0.04) x_pos = zone["x1"] + margin y_pos = zone["y1"] + margin # 保存zone方向供后续堆叠计算使用 zone_direction = self._anchor_to_direction(text_cfg.get("position", "top-left")) item = TextRenderItem( z_index=Z_TEXT_CONTENT, content=content_text, position=(x_pos, y_pos), font_size=font_size, color=color, bold=is_bold, role=role, max_width=zone["x2"] - zone["x1"], align=text_cfg.get("align", "left"), ) # 保存zone方向供后续堆叠分组 item._zone_direction = zone_direction item._anchor = text_cfg.get("anchor", "top") item._offset_y = text_cfg.get("offset_y", 0) self.render_items.append(item) def _resolve_text_zone(self, text_cfg: dict, text_zones: list, index: int) -> Dict: """解析文字区域""" # 优先使用 text_zones if text_zones and index < len(text_zones): zone = text_zones[index] bbox = zone.get("bbox", {}) if bbox and all(k in bbox for k in ["x1", "y1", "x2", "y2"]): return bbox # 使用 position 锚点推算 position = text_cfg.get("position", "top-left") offset = text_cfg.get("offset", [0, 0]) margin = int(min(self.width, self.height) * 0.04) # 根据锚点和product_bbox推算zone zone = find_safe_zone(self.width, self.height, self.product_bbox, preferred_position=self._anchor_to_direction(position)) # 应用offset if offset and len(offset) >= 2: zone["x1"] += int(offset[0]) zone["y1"] += int(offset[1]) return zone def _anchor_to_direction(self, anchor: str) -> str: """将锚点名称转为方向""" if isinstance(anchor, list): return "auto" mapping = { "top-left": "top", "top-center": "top", "top-right": "top", "middle-left": "left", "center-left": "left", "middle-right": "right", "center-right": "right", "bottom-left": "bottom", "bottom-center": "bottom", "bottom-right": "bottom", "middle-center": "auto", "center": "auto", } return mapping.get(anchor, "auto") def _resolve_text_position(self, text_cfg: dict, zone: Dict, font_size: int, content: str, is_bold: bool) -> Tuple[int, int]: """解析文字位置""" # 如果直接指定了坐标 position = text_cfg.get("position") if isinstance(position, (list, tuple)) and len(position) >= 2: return (int(position[0]), int(position[1])) # 使用zone的左上角 margin = int(min(self.width, self.height) * 0.04) return (zone["x1"] + margin, zone["y1"] + margin) def _collect_decorations(self, image_cfg: dict): """收集装饰元素(金色线条等)""" if not self.brand_config: return # 根据标题位置自动添加装饰线 for item in self.render_items: if item.role == "title" and item.z_index == Z_TEXT_CONTENT: bbox = item.get_bbox() line_y = bbox[3] + int(self.height * 0.008) line_w = int((bbox[2] - bbox[0]) * 0.4) line_h = max(4, int(self.height * 0.003)) accent_hex = self.brand_config.get("colors", {}).get("accent", "#D4AF6A") accent_rgb = hex_to_rgb(accent_hex) self.render_items.append(TextRenderItem( z_index=Z_DECORATION, content=f"__LINE__:{json.dumps({'x': bbox[0], 'y': line_y, 'w': line_w, 'h': line_h})}", position=(bbox[0], line_y), font_size=0, color=(*accent_rgb, 230), role="decoration", )) break # 只给第一个标题加装饰线 def check_and_avoid_product(self): """检查并避让产品区域""" if not self.product_bbox: return pb = self.product_bbox for item in self.render_items: if item.z_index < Z_TEXT_CONTENT: continue # 品牌元素不需要避让 item_bbox_dict = { "x1": item.position[0], "y1": item.position[1], "x2": item.get_bbox()[2], "y2": item.get_bbox()[3], } if bbox_overlap(item_bbox_dict, pb): # 需要移动文字 safe_zone = find_safe_zone( self.width, self.height, self.product_bbox, preferred_position="auto" ) item.position = (safe_zone["x1"] + 10, safe_zone["y1"] + 10) item._bbox_cache = None # 清除缓存 def render(self) -> Image.Image: """执行渲染,返回成品图""" result = self.canvas.copy() for item in self.render_items: if item.content.startswith("__LOGO__:"): self._render_logo(result, item) elif item.content.startswith("__BADGE__:"): self._render_badge(result, item) elif item.content.startswith("__GUARANTEE_BAR__:"): self._render_guarantee_bar(result, item) elif item.content.startswith("__LINE__:"): self._render_decoration_line(result, item) else: # 只渲染font_size > 0的文字元素 if item.font_size > 0: self._render_text(result, item) return result def _render_logo(self, canvas: Image.Image, item: TextRenderItem): """渲染Logo""" logo_path = item.content.split(":", 1)[1] try: logo = Image.open(logo_path).convert("RGBA") # 缩放 max_w = item.max_width or int(self.width * 0.25) lw, lh = logo.size if lw > max_w: scale = max_w / lw logo = logo.resize((int(lw * scale), int(lh * scale)), Image.LANCZOS) # 深色场景自动转白色logo if self.is_dark: import numpy as np arr = np.array(logo).astype(float) is_colored = (arr[:,:,2] > 120) & (arr[:,:,0] < 80) & (arr[:,:,3] > 50) arr[is_colored, 0] = 240 arr[is_colored, 1] = 240 arr[is_colored, 2] = 245 logo = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) canvas.paste(logo, item.position, logo) except Exception as e: print(f" ⚠️ Logo渲染失败 ({logo_path}): {e}", file=sys.stderr) def _render_badge(self, canvas: Image.Image, item: TextRenderItem): """渲染徽章""" badge_path = item.content.split(":", 1)[1] try: badge = Image.open(badge_path).convert("RGBA") max_w = item.max_width or int(self.width * 0.14) bw, bh = badge.size if bw > max_w: scale = max_w / bw badge = badge.resize((int(bw * scale), int(bh * scale)), Image.LANCZOS) canvas.paste(badge, item.position, badge) except Exception as e: print(f" ⚠️ 徽章渲染失败 ({badge_path}): {e}", file=sys.stderr) def _render_guarantee_bar(self, canvas: Image.Image, item: TextRenderItem): """渲染保障条""" data_str = item.content.split(":", 1)[1] data = json.loads(data_str) labels = data["labels"] bar_y = data["bar_y"] bar_h = data["bar_h"] bar_right = data["bar_right"] colors = data.get("colors", {}) is_dark = data.get("is_dark", self.is_dark) draw = ImageDraw.Draw(canvas, "RGBA") margin = int(self.width * 0.03) # 保障条背景 bar_bg_hex = colors.get("bar_bg_dark" if is_dark else "bar_bg_light", "#0A0A0F" if is_dark else "#FFFFFF") bar_bg = (*hex_to_rgb(bar_bg_hex), 230) draw.rectangle([(0, bar_y), (bar_right, self.height)], fill=bar_bg) # 顶部装饰线 accent_hex = colors.get("accent", "#D4AF6A") accent_rgb = hex_to_rgb(accent_hex) draw.line([(0, bar_y), (bar_right, bar_y)], fill=(*accent_rgb, 150), width=2) # 保障条文字 font = load_font(item.font_size, bold=True) text_color = item.color text_y = bar_y + int((bar_h - item.font_size) / 2) n = len(labels) text_area = bar_right - margin * 2 item_w = text_area // n if n > 0 else text_area for i, label in enumerate(labels): bbox = font.getbbox(label) tw = bbox[2] - bbox[0] tx = margin * 2 + i * item_w + (item_w - tw) // 2 draw.text((tx, text_y), label, fill=text_color, font=font) # 分隔线 if i < n - 1: sep_x = margin * 2 + (i + 1) * item_w draw.line([(sep_x, text_y + 2), (sep_x, text_y + item.font_size + 2)], fill=(120, 120, 120, 100), width=1) def _render_text(self, canvas: Image.Image, item: TextRenderItem): """渲染普通文字(标题/副标题)""" font = load_font(item.font_size, item.bold) # 处理换行 if item.max_width: lines = self._wrap_text(item.content, font, item.max_width) else: lines = item.content.split("\n") # 计算文字块尺寸 line_h = int(item.font_size * 1.3) max_w = 0 for line in lines: bbox = font.getbbox(line) w = bbox[2] - bbox[0] max_w = max(max_w, w) total_h = line_h * len(lines) text_bbox = (item.position[0], item.position[1], item.position[0] + max_w, item.position[1] + total_h) # 可读性检测 text_rgb = item.color[:3] readability = ensure_readability(canvas, text_rgb, text_bbox, self.is_dark) # 创建文字图层 text_layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) text_draw = ImageDraw.Draw(text_layer) # 绘制背景块(如需要) if readability["need_background"] and readability["bg_color"]: bg_layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) bg_draw = ImageDraw.Draw(bg_layer) pad = int(item.font_size * 0.3) bg_rect = (text_bbox[0] - pad, text_bbox[1] - pad, text_bbox[2] + pad, text_bbox[3] + pad) bg_draw.rounded_rectangle(bg_rect, radius=int(item.font_size * 0.15), fill=readability["bg_color"]) canvas.alpha_composite(bg_layer) # 描边 stroke_width = 2 if readability["need_stroke"] else 0 stroke_fill = readability.get("stroke_color") # 绘制文字 x, y = item.position for i, line in enumerate(lines): bbox = font.getbbox(line) w = bbox[2] - bbox[0] # 对齐 if item.align == "center": lx = x + (max_w - w) // 2 elif item.align == "right": lx = x + max_w - w else: lx = x ly = y + i * line_h if stroke_width > 0 and stroke_fill: text_draw.text((lx, ly), line, font=font, fill=item.color, stroke_width=stroke_width, stroke_fill=stroke_fill) else: text_draw.text((lx, ly), line, font=font, fill=item.color) # 如果有自定义背景块或描边配置(来自plan.json) # 这里保持compose.py的兼容性 canvas.alpha_composite(text_layer) def _render_decoration_line(self, canvas: Image.Image, item: TextRenderItem): """渲染装饰线条""" data_str = item.content.split(":", 1)[1] data = json.loads(data_str) draw = ImageDraw.Draw(canvas, "RGBA") x, y = data["x"], data["y"] w, h = data["w"], data["h"] draw.rounded_rectangle( [(x, y), (x + w, y + h)], radius=h // 2, fill=item.color ) def _wrap_text(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]: """自动换行""" output_lines = [] for hard_line in text.split("\n"): if not hard_line: output_lines.append("") continue current = "" for ch in hard_line: trial = current + ch bbox = font.getbbox(trial) w = bbox[2] - bbox[0] if w > max_width and current: output_lines.append(current) current = ch else: current = trial if current: output_lines.append(current) return output_lines # ============================================================================ # 处理单张图片 # ============================================================================ def process_single_image(image_cfg: dict, brand_config: Optional[dict], scene_tone: str = "dark") -> Optional[Image.Image]: """ 处理单张图片的文字叠加 Args: image_cfg: plan.json中单张图的配置 brand_config: 品牌配置(可选) scene_tone: 场景色调 Returns: 处理后的Image,失败返回None """ scene_path = image_cfg.get("scene_image", "") if not scene_path or not Path(scene_path).exists(): print(f" ❌ scene_image 不存在: {scene_path}", file=sys.stderr) return None # 加载底图 img = Image.open(scene_path).convert("RGB") target_size = image_cfg.get("size") if target_size: tw, th = int(target_size[0]), int(target_size[1]) if img.size != (tw, th): src_w, src_h = img.size scale = max(tw / src_w, th / src_h) new_w, new_h = int(src_w * scale), int(src_h * scale) img = img.resize((new_w, new_h), Image.LANCZOS) left = (new_w - tw) // 2 top = (new_h - th) // 2 img = img.crop((left, top, left + tw, top + th)) # 创建引擎并渲染 engine = TextEngine(img, {}, brand_config, scene_tone) engine.collect_items(image_cfg) engine.check_and_avoid_product() return engine.render() # ============================================================================ # 主流程 # ============================================================================ def process_plan(plan_path: str, brand_name: Optional[str] = None, brand_config_path: Optional[str] = None, scene_tone: str = "dark", only_ids: Optional[set] = None) -> int: """ 处理整个plan.json Returns: 0=成功, 1=plan不存在, 2=部分失败 """ plan_path_obj = Path(plan_path) if not plan_path_obj.exists(): print(f"❌ plan.json 不存在: {plan_path}", file=sys.stderr) return 1 with open(plan_path, "r", encoding="utf-8") as f: plan = json.load(f) output_dir = Path(plan.get("output_dir", "")) if not output_dir: print("❌ plan.output_dir 未设置", file=sys.stderr) return 1 output_dir.mkdir(parents=True, exist_ok=True) # 加载品牌配置 brand_config = None if brand_name: brand_config = load_brand_config(brand_name, scene_tone) elif brand_config_path: brand_config = load_brand_config_from_file(brand_config_path) if brand_config: print(f"🎨 品牌配置已加载: {brand_config.get('brand_name', 'unknown')}") else: print("📝 无品牌配置,仅渲染文字") # 收集所有图片 all_images = [] for kind, items in [("main", plan.get("main_images", [])), ("detail", plan.get("detail_images", []))]: for img_cfg in items: all_images.append((kind, img_cfg)) if not all_images: print("⚠️ plan 中未定义任何图片", file=sys.stderr) return 1 # 逐张处理 success, failed = [], [] for kind, img_cfg in all_images: img_id = img_cfg.get("id", "unknown") if only_ids and img_id not in only_ids: continue print(f"\n🎨 渲染 {img_id} ...") try: result = process_single_image(img_cfg, brand_config, scene_tone) if result is None: failed.append((img_id, "scene_image 加载失败")) continue out_path = output_dir / f"{img_id}.png" result.convert("RGB").save(out_path, "PNG", optimize=True) success.append((img_id, str(out_path))) print(f" ✅ {out_path} ({result.width}x{result.height})") except Exception as e: print(f" ❌ 失败: {e}", file=sys.stderr) failed.append((img_id, str(e))) # 汇总 print(f"\n{'='*60}") print(f"完成: 成功 {len(success)} / 失败 {len(failed)}") print(f"输出目录: {output_dir}") if success: print("\n成品清单:") for img_id, path in success: print(f" - {img_id}: {path}") if failed: print("\n失败清单:") for img_id, err in failed: print(f" - {img_id}: {err}") return 2 return 0 # ============================================================================ # CLI # ============================================================================ def main(): parser = argparse.ArgumentParser( description="统一文字管理引擎 — 电商素材一站式工坊", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: # 处理整个plan.json python text_engine.py --plan /path/to/plan.json --brand langke --scene-tone dark # 只处理特定图片 python text_engine.py --plan /path/to/plan.json --only main_01,main_02 # 使用外部品牌配置文件 python text_engine.py --plan /path/to/plan.json --brand-config /path/to/brand.json # 处理单张图片(直接模式) python text_engine.py --input /path/to/scene.png --plan /path/to/plan.json --image-id main_01 z-index 分层: Layer 0: 场景背景底图 Layer 1: 产品图(已合成到scene_image中) Layer 2: 品牌Logo(左上角) Layer 3: 保障条(底部) Layer 4: 徽章(右下角) Layer 5: 卖点标题+副标题(根据text_zones布局) Layer 6: 装饰元素(金色线条、分隔符等) """ ) parser.add_argument("--plan", help="plan.json 路径") parser.add_argument("--input", help="单张图片路径(直接模式)") parser.add_argument("--image-id", help="指定处理的图片ID(配合--input使用)") parser.add_argument("--output", help="输出路径(直接模式时使用)") parser.add_argument("--brand", help="品牌名称(自动加载品牌配置)") parser.add_argument("--brand-config", help="品牌配置JSON路径") parser.add_argument("--scene-tone", default="dark", choices=["dark", "light"], help="场景色调 (default: dark)") parser.add_argument("--only", help="只渲染指定ID(逗号分隔)") args = parser.parse_args() if args.input: # 直接模式:处理单张图片 if not args.plan: print("❌ 直接模式需要 --plan 参数提供文字配置", file=sys.stderr) sys.exit(1) with open(args.plan, "r", encoding="utf-8") as f: plan = json.load(f) # 找到对应图片配置 image_cfg = None for items in [plan.get("main_images", []), plan.get("detail_images", [])]: for img in items: if img.get("id") == args.image_id: image_cfg = img break if not image_cfg: print(f"❌ 未找到图片ID: {args.image_id}", file=sys.stderr) sys.exit(1) # 覆盖scene_image image_cfg["scene_image"] = args.input brand_config = None if args.brand: brand_config = load_brand_config(args.brand, args.scene_tone) elif args.brand_config: brand_config = load_brand_config_from_file(args.brand_config) result = process_single_image(image_cfg, brand_config, args.scene_tone) if result: out_path = args.output or args.input.replace(".png", "_text.png") result.convert("RGB").save(out_path, "PNG", optimize=True) print(f"✅ 已输出: {out_path}") else: sys.exit(1) elif args.plan: # 批量模式:处理整个plan only_ids = set() if args.only: only_ids = {x.strip() for x in args.only.split(",") if x.strip()} exit_code = process_plan( plan_path=args.plan, brand_name=args.brand, brand_config_path=args.brand_config, scene_tone=args.scene_tone, only_ids=only_ids, ) sys.exit(exit_code) else: parser.print_help() sys.exit(0) if __name__ == "__main__": main()
-
-
SKILL.md 4.8 KB
--- name: ecommerce-material-studio description: "电商素材工坊(中英双语)。用户需要生成电商主图、详情图、场景图、产品图合成、品牌叠加时使用。自动识别品类→匹配风格→场景感知合成→统一文字→自动质检→多平台适配→标准化交付。E-commerce product image studio: category detection, style matching, scene-aware compositing, text overlay, quality check, multi-platform adaptation, batch delivery." category: ecommerce license: MIT --- # 电商素材工坊 / E-commerce Material Studio 生成电商产品素材(主图/详情图/场景图)的一站式工具链。输入产品图片 → 自动完成品类识别、风格匹配、场景合成、文字叠加、质检、多平台适配、批量交付。 **One-stop pipeline for e-commerce product images: category detection → style matching → scene compositing → text overlay → quality check → platform adaptation → batch delivery.** ## 何时使用 / When to use - 用户需要生成**电商主图/详情图**(如"帮我做一套剃须刀主图") - 需要**产品图合成到场景**(白底图 → 场景图) - 需要**品牌叠加**(Logo/保障条/卖点文字/徽章) - 需要**多平台尺寸适配**(淘宝/快手/抖音/拼多多/京东等 7 平台) - 需要**批量生成**多个产品素材 ## 使用流程 / Workflow ```bash # 0. 环境依赖(一次性) pip install Pillow numpy scipy # 1. 品类识别:输入产品图 → 识别品类/风格 python3 scripts/category_detector.py --image product.png # 2. 风格匹配:品类+价位+平台+品牌 → 推荐模板 python3 scripts/style_matcher.py --category 个护电器 --sub-category 剃须刀 --price 169 --platform kuaishou # 3. 场景感知合成(核心):库调用(SceneAwareCompositor 是 Python 库,非 CLI) python3 -c " from PIL import Image from scripts.scene_aware_compositor import SceneAwareCompositor c = SceneAwareCompositor() scene = Image.open('scene.jpg'); product = Image.open('product.png') result = c.composite(scene_image=scene, product_image=product, scene_type='lifestyle_bathroom', position=(0.5, 0.45)) result.save('result.png') " # 4. 统一文字叠加(处理 plan.json 里所有文字层) python3 scripts/text_engine.py --plan output/plan.json --brand langke --scene-tone dark # 5. 自动质检(读取 plan.json + 检查成品图) python3 scripts/quality_check.py --plan output/plan.json # 6. 多平台尺寸适配 python3 scripts/platform_adapter.py --input-dir ./output --platforms kuaishou --output-dir ./platform_output # 7. 标准化交付打包(自动生成使用指南+清单+zip) python3 scripts/delivery_packager.py --project-dir ./output --product-name "示例产品" # 8. 批量处理(多产品,断点续传) python3 scripts/batch_processor.py --input products.json --output-dir ./batch --prepare ``` ## 模块清单 / Modules | 模块 | 功能 | 依赖 | |:---|:---|:---| | `category_detector.py` | 品类识别(色调/材质→子品类) | Pillow | | `style_matcher.py` | 风格匹配(品类+价位+平台+品牌→模板) | 无 | | `brand_loader.py` | 品牌配置加载(多品牌/Logo选择) | 无 | | `text_engine.py` | 统一文字引擎(z-index/避让/对比度) | Pillow | | `quality_check.py` | 自动质检(分辨率/可读性/Logo/完整/重叠) | Pillow | | `preference_memory.py` | 偏好记忆(跨项目复用风格) | 无 | | `batch_processor.py` | 批量处理(断点续传/重试/报告) | 无 | | `platform_adapter.py` | 7 平台尺寸适配(resize/crop/压缩) | Pillow | | `delivery_packager.py` | 交付打包(使用指南+清单+zip) | Pillow | | `layout_engine.py` | 布局引擎(物理尺寸→像素比例) | 无 | | `scene_aware_compositor.py` | 场景感知合成(参照物尺度/透视/景深) | Pillow+numpy+scipy | ## 数据文件 / Data (references/) - `category_templates.json` — 12 品类场景模板库(推荐场景/prompt/配色/文字风格) - `product_profiles.json` — 产品档案库(示例:example_shaver) - `brand_profiles/langke.json` — 示例品牌配置(朗科=示例品牌,非真实) - `brand_config_template.json` — 新建品牌模板 - `user_preferences.json` — 偏好记忆库(模板) ## 注意事项 / Notes - **字体**:`text_engine.py` 需要中文字体(macOS: `/System/Library/Fonts/PingFang.ttc`,Linux: NotoSansCJK,Windows: msyh.ttc)——按需修改 `FONT_PATHS_BOLD`/`FONT_PATHS_REGULAR` 常量 - **品牌配置**:用 `brand_config_template.json` 新建自己的品牌(含 Logo 路径/色系/保障条) - **合成模式**:`scene_aware_compositor.py` 支持场景感知模式(自动算尺度)和兼容模式(固定 scale) - 参考数据中的"朗科/LangKe"为**示例品牌**,可直接替换为自己的品牌配置
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.