diff --git a/.gitignore b/TWEET/.gitignore similarity index 100% rename from .gitignore rename to TWEET/.gitignore diff --git a/README.md b/TWEET/README.md similarity index 100% rename from README.md rename to TWEET/README.md diff --git a/config/ai_model.json b/TWEET/config/ai_model.json similarity index 100% rename from config/ai_model.json rename to TWEET/config/ai_model.json diff --git a/config/content_gen.json b/TWEET/config/content_gen.json similarity index 100% rename from config/content_gen.json rename to TWEET/config/content_gen.json diff --git a/TWEET/config/poster_gen.json b/TWEET/config/poster_gen.json new file mode 100644 index 0000000..22c3559 --- /dev/null +++ b/TWEET/config/poster_gen.json @@ -0,0 +1,68 @@ +{ + "poster_system_prompt": "resource/prompt/generatePosterContent/system.txt", + "poster_user_prompt": "resource/prompt/generatePosterContent/user.txt", + "enable_poster_generation": true, + "anti_duplicate_hash": false, + "model": { + "temperature": 0.5, + "top_p": 0.7, + "presence_penalty": 1.1 + }, + "poster_jobs": [ + { + "topic_index": 0, + "variant_index": 0, + "template": "vibrant", + "size": [900, 1200], + "generate_text": true, + "text_generation_params": { + "user_prompt_path": "resource/prompt/poster/vibrant_user.txt", + "content_data": ["蔚蓝海岸", "夏日", "天堂", "探索"] + }, + "params": { + "image_path": "resource/data/images/scenery1.jpg" + } + }, + { + "topic_index": 0, + "variant_index": 1, + "template": "business", + "size": [900, 1200], + "generate_text": false, + "params": { + "top_image_path": "resource/data/images/hotel1.jpg", + "bottom_image_path": "resource/data/images/hotel2.jpg", + "small_image_paths": [ + "resource/data/images/details1.jpg", + "resource/data/images/details2.jpg", + "resource/data/images/details3.jpg" + ], + "hotel_info": { + "title": "豪华商务酒店", + "feature": "五星级设施与服务", + "info_sections": [ + {"title": "客房", "text": "200间豪华客房,享受极致舒适。"}, + {"title": "餐饮", "text": "米其林星级主厨,提供全球美食。"}, + {"title": "会议", "text": "1000平米无柱宴会厅,满足各种需求。"} + ] + } + } + }, + { + "topic_index": 1, + "variant_index": 0, + "template": "collage", + "size": [900, 1200], + "generate_text": false, + "params": { + "image_paths": [ + "resource/data/images/scenery1.jpg", + "resource/data/images/scenery2.jpg", + "resource/data/images/hotel1.jpg", + "resource/data/images/details1.jpg" + ], + "style": "asymmetric" + } + } + ] +} \ No newline at end of file diff --git a/config/resource.json b/TWEET/config/resource.json similarity index 100% rename from config/resource.json rename to TWEET/config/resource.json diff --git a/config/system.json b/TWEET/config/system.json similarity index 100% rename from config/system.json rename to TWEET/config/system.json diff --git a/config/topic_gen.json b/TWEET/config/topic_gen.json similarity index 100% rename from config/topic_gen.json rename to TWEET/config/topic_gen.json diff --git a/core/__init__.py b/TWEET/core/__init__.py similarity index 100% rename from core/__init__.py rename to TWEET/core/__init__.py diff --git a/core/__pycache__/__init__.cpython-310.pyc b/TWEET/core/__pycache__/__init__.cpython-310.pyc similarity index 100% rename from core/__pycache__/__init__.cpython-310.pyc rename to TWEET/core/__pycache__/__init__.cpython-310.pyc diff --git a/core/__pycache__/__init__.cpython-312.pyc b/TWEET/core/__pycache__/__init__.cpython-312.pyc similarity index 100% rename from core/__pycache__/__init__.cpython-312.pyc rename to TWEET/core/__pycache__/__init__.cpython-312.pyc diff --git a/core/__pycache__/ai_agent.cpython-310.pyc b/TWEET/core/__pycache__/ai_agent.cpython-310.pyc similarity index 100% rename from core/__pycache__/ai_agent.cpython-310.pyc rename to TWEET/core/__pycache__/ai_agent.cpython-310.pyc diff --git a/core/__pycache__/ai_agent.cpython-312.pyc b/TWEET/core/__pycache__/ai_agent.cpython-312.pyc similarity index 100% rename from core/__pycache__/ai_agent.cpython-312.pyc rename to TWEET/core/__pycache__/ai_agent.cpython-312.pyc diff --git a/core/__pycache__/base_template.cpython-312.pyc b/TWEET/core/__pycache__/base_template.cpython-312.pyc similarity index 100% rename from core/__pycache__/base_template.cpython-312.pyc rename to TWEET/core/__pycache__/base_template.cpython-312.pyc diff --git a/core/__pycache__/config_manager.cpython-312.pyc b/TWEET/core/__pycache__/config_manager.cpython-312.pyc similarity index 100% rename from core/__pycache__/config_manager.cpython-312.pyc rename to TWEET/core/__pycache__/config_manager.cpython-312.pyc diff --git a/core/__pycache__/contentGen.cpython-310.pyc b/TWEET/core/__pycache__/contentGen.cpython-310.pyc similarity index 100% rename from core/__pycache__/contentGen.cpython-310.pyc rename to TWEET/core/__pycache__/contentGen.cpython-310.pyc diff --git a/core/__pycache__/contentGen.cpython-312.pyc b/TWEET/core/__pycache__/contentGen.cpython-312.pyc similarity index 100% rename from core/__pycache__/contentGen.cpython-312.pyc rename to TWEET/core/__pycache__/contentGen.cpython-312.pyc diff --git a/core/__pycache__/exceptions.cpython-312.pyc b/TWEET/core/__pycache__/exceptions.cpython-312.pyc similarity index 100% rename from core/__pycache__/exceptions.cpython-312.pyc rename to TWEET/core/__pycache__/exceptions.cpython-312.pyc diff --git a/core/__pycache__/posterGen.cpython-312.pyc b/TWEET/core/__pycache__/posterGen.cpython-312.pyc similarity index 100% rename from core/__pycache__/posterGen.cpython-312.pyc rename to TWEET/core/__pycache__/posterGen.cpython-312.pyc diff --git a/core/__pycache__/poster_gen.cpython-312.pyc b/TWEET/core/__pycache__/poster_gen.cpython-312.pyc similarity index 100% rename from core/__pycache__/poster_gen.cpython-312.pyc rename to TWEET/core/__pycache__/poster_gen.cpython-312.pyc diff --git a/core/__pycache__/poster_manager.cpython-312.pyc b/TWEET/core/__pycache__/poster_manager.cpython-312.pyc similarity index 100% rename from core/__pycache__/poster_manager.cpython-312.pyc rename to TWEET/core/__pycache__/poster_manager.cpython-312.pyc diff --git a/core/__pycache__/poster_template_manager.cpython-312.pyc b/TWEET/core/__pycache__/poster_template_manager.cpython-312.pyc similarity index 100% rename from core/__pycache__/poster_template_manager.cpython-312.pyc rename to TWEET/core/__pycache__/poster_template_manager.cpython-312.pyc diff --git a/core/__pycache__/simple_collage.cpython-312.pyc b/TWEET/core/__pycache__/simple_collage.cpython-312.pyc similarity index 100% rename from core/__pycache__/simple_collage.cpython-312.pyc rename to TWEET/core/__pycache__/simple_collage.cpython-312.pyc diff --git a/core/__pycache__/topic_parser.cpython-310.pyc b/TWEET/core/__pycache__/topic_parser.cpython-310.pyc similarity index 100% rename from core/__pycache__/topic_parser.cpython-310.pyc rename to TWEET/core/__pycache__/topic_parser.cpython-310.pyc diff --git a/core/__pycache__/topic_parser.cpython-312.pyc b/TWEET/core/__pycache__/topic_parser.cpython-312.pyc similarity index 100% rename from core/__pycache__/topic_parser.cpython-312.pyc rename to TWEET/core/__pycache__/topic_parser.cpython-312.pyc diff --git a/core/ai/__init__.py b/TWEET/core/ai/__init__.py similarity index 100% rename from core/ai/__init__.py rename to TWEET/core/ai/__init__.py diff --git a/core/ai/__pycache__/__init__.cpython-310.pyc b/TWEET/core/ai/__pycache__/__init__.cpython-310.pyc similarity index 100% rename from core/ai/__pycache__/__init__.cpython-310.pyc rename to TWEET/core/ai/__pycache__/__init__.cpython-310.pyc diff --git a/core/ai/__pycache__/__init__.cpython-312.pyc b/TWEET/core/ai/__pycache__/__init__.cpython-312.pyc similarity index 100% rename from core/ai/__pycache__/__init__.cpython-312.pyc rename to TWEET/core/ai/__pycache__/__init__.cpython-312.pyc diff --git a/core/ai/__pycache__/ai_agent.cpython-310.pyc b/TWEET/core/ai/__pycache__/ai_agent.cpython-310.pyc similarity index 100% rename from core/ai/__pycache__/ai_agent.cpython-310.pyc rename to TWEET/core/ai/__pycache__/ai_agent.cpython-310.pyc diff --git a/core/ai/__pycache__/ai_agent.cpython-312.pyc b/TWEET/core/ai/__pycache__/ai_agent.cpython-312.pyc similarity index 100% rename from core/ai/__pycache__/ai_agent.cpython-312.pyc rename to TWEET/core/ai/__pycache__/ai_agent.cpython-312.pyc diff --git a/core/ai/ai_agent.py b/TWEET/core/ai/ai_agent.py similarity index 100% rename from core/ai/ai_agent.py rename to TWEET/core/ai/ai_agent.py diff --git a/core/config/__init__.py b/TWEET/core/config/__init__.py similarity index 100% rename from core/config/__init__.py rename to TWEET/core/config/__init__.py diff --git a/TWEET/core/config/__pycache__/__init__.cpython-310.pyc b/TWEET/core/config/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..7995ddb Binary files /dev/null and b/TWEET/core/config/__pycache__/__init__.cpython-310.pyc differ diff --git a/core/config/__pycache__/__init__.cpython-312.pyc b/TWEET/core/config/__pycache__/__init__.cpython-312.pyc similarity index 100% rename from core/config/__pycache__/__init__.cpython-312.pyc rename to TWEET/core/config/__pycache__/__init__.cpython-312.pyc diff --git a/TWEET/core/config/__pycache__/manager.cpython-310.pyc b/TWEET/core/config/__pycache__/manager.cpython-310.pyc new file mode 100644 index 0000000..3a5d5f8 Binary files /dev/null and b/TWEET/core/config/__pycache__/manager.cpython-310.pyc differ diff --git a/core/config/__pycache__/manager.cpython-312.pyc b/TWEET/core/config/__pycache__/manager.cpython-312.pyc similarity index 100% rename from core/config/__pycache__/manager.cpython-312.pyc rename to TWEET/core/config/__pycache__/manager.cpython-312.pyc diff --git a/TWEET/core/config/__pycache__/models.cpython-310.pyc b/TWEET/core/config/__pycache__/models.cpython-310.pyc new file mode 100644 index 0000000..ce6ca7b Binary files /dev/null and b/TWEET/core/config/__pycache__/models.cpython-310.pyc differ diff --git a/core/config/__pycache__/models.cpython-312.pyc b/TWEET/core/config/__pycache__/models.cpython-312.pyc similarity index 100% rename from core/config/__pycache__/models.cpython-312.pyc rename to TWEET/core/config/__pycache__/models.cpython-312.pyc diff --git a/core/config/manager.py b/TWEET/core/config/manager.py similarity index 100% rename from core/config/manager.py rename to TWEET/core/config/manager.py diff --git a/core/config/models.py b/TWEET/core/config/models.py similarity index 100% rename from core/config/models.py rename to TWEET/core/config/models.py diff --git a/core/exception/__init__.py b/TWEET/core/exception/__init__.py similarity index 100% rename from core/exception/__init__.py rename to TWEET/core/exception/__init__.py diff --git a/core/exception/__pycache__/__init__.cpython-312.pyc b/TWEET/core/exception/__pycache__/__init__.cpython-312.pyc similarity index 100% rename from core/exception/__pycache__/__init__.cpython-312.pyc rename to TWEET/core/exception/__pycache__/__init__.cpython-312.pyc diff --git a/core/exception/__pycache__/exceptions.cpython-312.pyc b/TWEET/core/exception/__pycache__/exceptions.cpython-312.pyc similarity index 100% rename from core/exception/__pycache__/exceptions.cpython-312.pyc rename to TWEET/core/exception/__pycache__/exceptions.cpython-312.pyc diff --git a/core/exception/exceptions.py b/TWEET/core/exception/exceptions.py similarity index 100% rename from core/exception/exceptions.py rename to TWEET/core/exception/exceptions.py diff --git a/docs/使用指南.md b/TWEET/docs/使用指南.md similarity index 100% rename from docs/使用指南.md rename to TWEET/docs/使用指南.md diff --git a/docs/开发指南.md b/TWEET/docs/开发指南.md similarity index 100% rename from docs/开发指南.md rename to TWEET/docs/开发指南.md diff --git a/main.py b/TWEET/main.py similarity index 100% rename from main.py rename to TWEET/main.py diff --git a/requirements.txt b/TWEET/requirements.txt similarity index 100% rename from requirements.txt rename to TWEET/requirements.txt diff --git a/resource/prompt/Demand/亲子向文旅需求.md b/TWEET/resource/prompt/Demand/亲子向文旅需求.md similarity index 100% rename from resource/prompt/Demand/亲子向文旅需求.md rename to TWEET/resource/prompt/Demand/亲子向文旅需求.md diff --git a/resource/prompt/Demand/周边游文旅需求.md b/TWEET/resource/prompt/Demand/周边游文旅需求.md similarity index 100% rename from resource/prompt/Demand/周边游文旅需求.md rename to TWEET/resource/prompt/Demand/周边游文旅需求.md diff --git a/resource/prompt/Demand/高奢酒店文旅需求.md b/TWEET/resource/prompt/Demand/高奢酒店文旅需求.md similarity index 100% rename from resource/prompt/Demand/高奢酒店文旅需求.md rename to TWEET/resource/prompt/Demand/高奢酒店文旅需求.md diff --git a/resource/prompt/Refer/2025各月节日宣传节点时间表.md b/TWEET/resource/prompt/Refer/2025各月节日宣传节点时间表.md similarity index 100% rename from resource/prompt/Refer/2025各月节日宣传节点时间表.md rename to TWEET/resource/prompt/Refer/2025各月节日宣传节点时间表.md diff --git a/resource/prompt/Refer/标题参考格式.json b/TWEET/resource/prompt/Refer/标题参考格式.json similarity index 100% rename from resource/prompt/Refer/标题参考格式.json rename to TWEET/resource/prompt/Refer/标题参考格式.json diff --git a/resource/prompt/Refer/正文范文参考.json b/TWEET/resource/prompt/Refer/正文范文参考.json similarity index 100% rename from resource/prompt/Refer/正文范文参考.json rename to TWEET/resource/prompt/Refer/正文范文参考.json diff --git a/resource/prompt/Style/攻略风文案提示词.md b/TWEET/resource/prompt/Style/攻略风文案提示词.md similarity index 100% rename from resource/prompt/Style/攻略风文案提示词.md rename to TWEET/resource/prompt/Style/攻略风文案提示词.md diff --git a/resource/prompt/Style/极力推荐风文案提示词.md b/TWEET/resource/prompt/Style/极力推荐风文案提示词.md similarity index 100% rename from resource/prompt/Style/极力推荐风文案提示词.md rename to TWEET/resource/prompt/Style/极力推荐风文案提示词.md diff --git a/resource/prompt/generateContent/.ipynb_checkpoints/poster_content_systemPrompt-checkpoint.txt b/TWEET/resource/prompt/generateContent/.ipynb_checkpoints/poster_content_systemPrompt-checkpoint.txt similarity index 100% rename from resource/prompt/generateContent/.ipynb_checkpoints/poster_content_systemPrompt-checkpoint.txt rename to TWEET/resource/prompt/generateContent/.ipynb_checkpoints/poster_content_systemPrompt-checkpoint.txt diff --git a/resource/prompt/generateContent/posterContentSystem.txt b/TWEET/resource/prompt/generateContent/posterContentSystem.txt similarity index 100% rename from resource/prompt/generateContent/posterContentSystem.txt rename to TWEET/resource/prompt/generateContent/posterContentSystem.txt diff --git a/resource/prompt/generateContent/system.txt b/TWEET/resource/prompt/generateContent/system.txt similarity index 100% rename from resource/prompt/generateContent/system.txt rename to TWEET/resource/prompt/generateContent/system.txt diff --git a/resource/prompt/generateContent/user.txt b/TWEET/resource/prompt/generateContent/user.txt similarity index 100% rename from resource/prompt/generateContent/user.txt rename to TWEET/resource/prompt/generateContent/user.txt diff --git a/resource/prompt/generateTopics/system.txt b/TWEET/resource/prompt/generateTopics/system.txt similarity index 100% rename from resource/prompt/generateTopics/system.txt rename to TWEET/resource/prompt/generateTopics/system.txt diff --git a/resource/prompt/generateTopics/user.txt b/TWEET/resource/prompt/generateTopics/user.txt similarity index 100% rename from resource/prompt/generateTopics/user.txt rename to TWEET/resource/prompt/generateTopics/user.txt diff --git a/resource/prompt/judgeContent/system.txt b/TWEET/resource/prompt/judgeContent/system.txt similarity index 100% rename from resource/prompt/judgeContent/system.txt rename to TWEET/resource/prompt/judgeContent/system.txt diff --git a/resource/prompt/judgeContent/user.txt b/TWEET/resource/prompt/judgeContent/user.txt similarity index 100% rename from resource/prompt/judgeContent/user.txt rename to TWEET/resource/prompt/judgeContent/user.txt diff --git a/utils/__init__.py b/TWEET/utils/__init__.py similarity index 100% rename from utils/__init__.py rename to TWEET/utils/__init__.py diff --git a/utils/__pycache__/__init__.cpython-310.pyc b/TWEET/utils/__pycache__/__init__.cpython-310.pyc similarity index 100% rename from utils/__pycache__/__init__.cpython-310.pyc rename to TWEET/utils/__pycache__/__init__.cpython-310.pyc diff --git a/utils/__pycache__/__init__.cpython-312.pyc b/TWEET/utils/__pycache__/__init__.cpython-312.pyc similarity index 100% rename from utils/__pycache__/__init__.cpython-312.pyc rename to TWEET/utils/__pycache__/__init__.cpython-312.pyc diff --git a/utils/__pycache__/content_generator.cpython-312.pyc b/TWEET/utils/__pycache__/content_generator.cpython-312.pyc similarity index 100% rename from utils/__pycache__/content_generator.cpython-312.pyc rename to TWEET/utils/__pycache__/content_generator.cpython-312.pyc diff --git a/utils/__pycache__/content_judger.cpython-312.pyc b/TWEET/utils/__pycache__/content_judger.cpython-312.pyc similarity index 100% rename from utils/__pycache__/content_judger.cpython-312.pyc rename to TWEET/utils/__pycache__/content_judger.cpython-312.pyc diff --git a/TWEET/utils/__pycache__/file_io.cpython-310.pyc b/TWEET/utils/__pycache__/file_io.cpython-310.pyc new file mode 100644 index 0000000..374fb1c Binary files /dev/null and b/TWEET/utils/__pycache__/file_io.cpython-310.pyc differ diff --git a/utils/__pycache__/file_io.cpython-312.pyc b/TWEET/utils/__pycache__/file_io.cpython-312.pyc similarity index 100% rename from utils/__pycache__/file_io.cpython-312.pyc rename to TWEET/utils/__pycache__/file_io.cpython-312.pyc diff --git a/utils/__pycache__/generator.cpython-312.pyc b/TWEET/utils/__pycache__/generator.cpython-312.pyc similarity index 100% rename from utils/__pycache__/generator.cpython-312.pyc rename to TWEET/utils/__pycache__/generator.cpython-312.pyc diff --git a/utils/__pycache__/image_processor.cpython-312.pyc b/TWEET/utils/__pycache__/image_processor.cpython-312.pyc similarity index 100% rename from utils/__pycache__/image_processor.cpython-312.pyc rename to TWEET/utils/__pycache__/image_processor.cpython-312.pyc diff --git a/utils/__pycache__/output_handler.cpython-312.pyc b/TWEET/utils/__pycache__/output_handler.cpython-312.pyc similarity index 100% rename from utils/__pycache__/output_handler.cpython-312.pyc rename to TWEET/utils/__pycache__/output_handler.cpython-312.pyc diff --git a/TWEET/utils/__pycache__/pipeline.cpython-310.pyc b/TWEET/utils/__pycache__/pipeline.cpython-310.pyc new file mode 100644 index 0000000..11f84c6 Binary files /dev/null and b/TWEET/utils/__pycache__/pipeline.cpython-310.pyc differ diff --git a/utils/__pycache__/pipeline.cpython-312.pyc b/TWEET/utils/__pycache__/pipeline.cpython-312.pyc similarity index 100% rename from utils/__pycache__/pipeline.cpython-312.pyc rename to TWEET/utils/__pycache__/pipeline.cpython-312.pyc diff --git a/utils/__pycache__/poster_generator_enhanced.cpython-312.pyc b/TWEET/utils/__pycache__/poster_generator_enhanced.cpython-312.pyc similarity index 100% rename from utils/__pycache__/poster_generator_enhanced.cpython-312.pyc rename to TWEET/utils/__pycache__/poster_generator_enhanced.cpython-312.pyc diff --git a/utils/__pycache__/poster_notes_creator.cpython-312.pyc b/TWEET/utils/__pycache__/poster_notes_creator.cpython-312.pyc similarity index 100% rename from utils/__pycache__/poster_notes_creator.cpython-312.pyc rename to TWEET/utils/__pycache__/poster_notes_creator.cpython-312.pyc diff --git a/utils/__pycache__/prompt_manager.cpython-312.pyc b/TWEET/utils/__pycache__/prompt_manager.cpython-312.pyc similarity index 100% rename from utils/__pycache__/prompt_manager.cpython-312.pyc rename to TWEET/utils/__pycache__/prompt_manager.cpython-312.pyc diff --git a/utils/__pycache__/prompts.cpython-312.pyc b/TWEET/utils/__pycache__/prompts.cpython-312.pyc similarity index 100% rename from utils/__pycache__/prompts.cpython-312.pyc rename to TWEET/utils/__pycache__/prompts.cpython-312.pyc diff --git a/utils/__pycache__/resource_loader.cpython-310.pyc b/TWEET/utils/__pycache__/resource_loader.cpython-310.pyc similarity index 100% rename from utils/__pycache__/resource_loader.cpython-310.pyc rename to TWEET/utils/__pycache__/resource_loader.cpython-310.pyc diff --git a/utils/__pycache__/resource_loader.cpython-312.pyc b/TWEET/utils/__pycache__/resource_loader.cpython-312.pyc similarity index 100% rename from utils/__pycache__/resource_loader.cpython-312.pyc rename to TWEET/utils/__pycache__/resource_loader.cpython-312.pyc diff --git a/utils/__pycache__/tweet_generator.cpython-310.pyc b/TWEET/utils/__pycache__/tweet_generator.cpython-310.pyc similarity index 100% rename from utils/__pycache__/tweet_generator.cpython-310.pyc rename to TWEET/utils/__pycache__/tweet_generator.cpython-310.pyc diff --git a/utils/__pycache__/tweet_generator.cpython-312.pyc b/TWEET/utils/__pycache__/tweet_generator.cpython-312.pyc similarity index 100% rename from utils/__pycache__/tweet_generator.cpython-312.pyc rename to TWEET/utils/__pycache__/tweet_generator.cpython-312.pyc diff --git a/utils/file_io.py b/TWEET/utils/file_io.py similarity index 100% rename from utils/file_io.py rename to TWEET/utils/file_io.py diff --git a/utils/pipeline.py b/TWEET/utils/pipeline.py similarity index 100% rename from utils/pipeline.py rename to TWEET/utils/pipeline.py diff --git a/utils/prompts.py b/TWEET/utils/prompts.py similarity index 100% rename from utils/prompts.py rename to TWEET/utils/prompts.py diff --git a/utils/tweet/__init__.py b/TWEET/utils/tweet/__init__.py similarity index 100% rename from utils/tweet/__init__.py rename to TWEET/utils/tweet/__init__.py diff --git a/utils/tweet/__pycache__/__init__.cpython-312.pyc b/TWEET/utils/tweet/__pycache__/__init__.cpython-312.pyc similarity index 100% rename from utils/tweet/__pycache__/__init__.cpython-312.pyc rename to TWEET/utils/tweet/__pycache__/__init__.cpython-312.pyc diff --git a/utils/tweet/__pycache__/content_generator.cpython-312.pyc b/TWEET/utils/tweet/__pycache__/content_generator.cpython-312.pyc similarity index 100% rename from utils/tweet/__pycache__/content_generator.cpython-312.pyc rename to TWEET/utils/tweet/__pycache__/content_generator.cpython-312.pyc diff --git a/utils/tweet/__pycache__/content_judger.cpython-312.pyc b/TWEET/utils/tweet/__pycache__/content_judger.cpython-312.pyc similarity index 100% rename from utils/tweet/__pycache__/content_judger.cpython-312.pyc rename to TWEET/utils/tweet/__pycache__/content_judger.cpython-312.pyc diff --git a/utils/tweet/__pycache__/topic_generator.cpython-312.pyc b/TWEET/utils/tweet/__pycache__/topic_generator.cpython-312.pyc similarity index 100% rename from utils/tweet/__pycache__/topic_generator.cpython-312.pyc rename to TWEET/utils/tweet/__pycache__/topic_generator.cpython-312.pyc diff --git a/utils/tweet/__pycache__/topic_parser.cpython-312.pyc b/TWEET/utils/tweet/__pycache__/topic_parser.cpython-312.pyc similarity index 100% rename from utils/tweet/__pycache__/topic_parser.cpython-312.pyc rename to TWEET/utils/tweet/__pycache__/topic_parser.cpython-312.pyc diff --git a/utils/tweet/content_generator.py b/TWEET/utils/tweet/content_generator.py similarity index 100% rename from utils/tweet/content_generator.py rename to TWEET/utils/tweet/content_generator.py diff --git a/utils/tweet/content_judger.py b/TWEET/utils/tweet/content_judger.py similarity index 100% rename from utils/tweet/content_judger.py rename to TWEET/utils/tweet/content_judger.py diff --git a/utils/tweet/topic_generator.py b/TWEET/utils/tweet/topic_generator.py similarity index 100% rename from utils/tweet/topic_generator.py rename to TWEET/utils/tweet/topic_generator.py diff --git a/utils/tweet/topic_parser.py b/TWEET/utils/tweet/topic_parser.py similarity index 100% rename from utils/tweet/topic_parser.py rename to TWEET/utils/tweet/topic_parser.py diff --git a/__pycache__/contentGen.cpython-312.pyc b/__pycache__/contentGen.cpython-312.pyc deleted file mode 100644 index b12e94f..0000000 Binary files a/__pycache__/contentGen.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/posterGen.cpython-312.pyc b/__pycache__/posterGen.cpython-312.pyc deleted file mode 100644 index 4c47be4..0000000 Binary files a/__pycache__/posterGen.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/simple_collage.cpython-312.pyc b/__pycache__/simple_collage.cpython-312.pyc deleted file mode 100644 index 7d97071..0000000 Binary files a/__pycache__/simple_collage.cpython-312.pyc and /dev/null differ diff --git a/demo_refactored_templates.py b/demo_refactored_templates.py new file mode 100644 index 0000000..e44c886 --- /dev/null +++ b/demo_refactored_templates.py @@ -0,0 +1,4220 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +重构模板演示程序 +测试活力模板和商务模板的功能 +""" + +import os +import sys +import random +from PIL import Image + +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +活力模板 +基于海洋模块的毛玻璃渐变效果 +完全兼容原版海洋模块的布局逻辑 +""" + +import os +import random +import math +import numpy as np +from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageEnhance +from typing import Dict, List, Tuple, Optional, Any + +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +基础模板类 +定义所有海报模板的通用接口和基础功能 +""" + +from abc import ABC, abstractmethod +from typing import Dict, List, Tuple, Optional, Any +from PIL import Image +import numpy as np + + + +""" +统一图像处理器 +整合了酒店、海洋、通用模块中的图像处理功能 +""" + +import os +import numpy as np +from PIL import Image, ImageFilter, ImageEnhance +import cv2 +from typing import Tuple, Optional, Union + + +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +统一文字渲染器 +整合了酒店、海洋、通用模块中的文字渲染功能 +""" + +import os +import random +import math +from PIL import Image, ImageDraw, ImageFont +from typing import Dict, List, Tuple, Optional, Union + + +class TextRenderer: + """统一的文字渲染类""" + + def __init__(self, font_dir: str = "/root/autodl-tmp/posterGenerator/assets/fonts"): + """ + 初始化文字渲染器 + + Args: + font_dir: 字体文件目录 + """ + self.font_dir = font_dir + self.default_fonts = [ + "兰亭粗黑简.TTF", + "华康海报体简.ttc", + "方正粗黑宋简体.ttf" + ] + + # 默认字体大小配置 + self.font_sizes = { + 'title': 80, + 'subtitle': 36, + 'content': 24, + 'price': 120, + 'small': 18 + } + + def get_available_fonts(self) -> List[str]: + """ + 获取可用的字体列表 + + Returns: + 可用字体文件名列表 + """ + if not os.path.exists(self.font_dir): + return [] + + font_files = [] + for file in os.listdir(self.font_dir): + if file.lower().endswith(('.ttf', '.otf', '.ttc')): + font_files.append(file) + + return font_files + + def get_font_path(self, font_name: Optional[str] = None) -> str: + """ + 获取字体文件路径 + + Args: + font_name: 字体文件名,如果为None则使用默认字体 + + Returns: + 字体文件路径 + """ + available_fonts = self.get_available_fonts() + + if font_name and font_name in available_fonts: + return os.path.join(self.font_dir, font_name) + + # 尝试使用默认字体 + for default_font in self.default_fonts: + if default_font in available_fonts: + return os.path.join(self.font_dir, default_font) + + # 如果没有找到默认字体,使用第一个可用字体 + if available_fonts: + return os.path.join(self.font_dir, available_fonts[0]) + + # 如果没有任何字体,返回空字符串(会使用系统默认字体) + return "" + + def load_font(self, size: int, font_name: Optional[str] = None) -> ImageFont.FreeTypeFont: + """ + 加载字体 + + Args: + size: 字体大小 + font_name: 字体文件名 + + Returns: + PIL字体对象 + """ + try: + font_path = self.get_font_path(font_name) + if font_path: + return ImageFont.truetype(font_path, size) + else: + return ImageFont.load_default() + except Exception as e: + print(f"加载字体失败: {e}") + return ImageFont.load_default() + + def calculate_optimal_font_size(self, text: str, + target_width: int, + font_name: Optional[str] = None, + max_size: int = 120, + min_size: int = 10) -> int: + """ + 计算最适合的字体大小 + + Args: + text: 文字内容 + target_width: 目标宽度 + font_name: 字体文件名 + max_size: 最大字体大小 + min_size: 最小字体大小 + + Returns: + 最适合的字体大小 + """ + if not text.strip(): + return min_size + + font_path = self.get_font_path(font_name) + + # 二分查找最佳字体大小 + left, right = min_size, max_size + best_size = min_size + + while left <= right: + mid_size = (left + right) // 2 + + try: + if font_path: + font = ImageFont.truetype(font_path, mid_size) + else: + font = ImageFont.load_default() + + # 获取文字边界框 + bbox = font.getbbox(text) + text_width = bbox[2] - bbox[0] + + if text_width <= target_width: + best_size = mid_size + left = mid_size + 1 + else: + right = mid_size - 1 + + except Exception: + right = mid_size - 1 + + return best_size + + def get_text_size(self, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]: + """ + 获取文字的尺寸 + + Args: + text: 文字内容 + font: 字体对象 + + Returns: + 文字尺寸 (width, height) + """ + bbox = font.getbbox(text) + return bbox[2] - bbox[0], bbox[3] - bbox[1] + + def draw_text_with_outline(self, draw: ImageDraw.Draw, + position: Tuple[int, int], + text: str, + font: ImageFont.FreeTypeFont, + text_color: Tuple[int, int, int, int] = (255, 255, 255, 255), + outline_color: Tuple[int, int, int, int] = (0, 0, 0, 255), + outline_width: int = 2): + """ + 绘制带描边的文字 + + Args: + draw: PIL绘图对象 + position: 文字位置 + text: 文字内容 + font: 字体对象 + text_color: 文字颜色 + outline_color: 描边颜色 + outline_width: 描边宽度 + """ + x, y = position + + # 绘制描边 + for offset_x in range(-outline_width, outline_width + 1): + for offset_y in range(-outline_width, outline_width + 1): + if offset_x == 0 and offset_y == 0: + continue + draw.text((x + offset_x, y + offset_y), text, font=font, fill=outline_color) + + # 绘制文字 + draw.text(position, text, font=font, fill=text_color) + + def draw_text_with_shadow(self, draw: ImageDraw.Draw, + position: Tuple[int, int], + text: str, + font: ImageFont.FreeTypeFont, + text_color: Tuple[int, int, int, int] = (255, 255, 255, 255), + shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 128), + shadow_offset: Tuple[int, int] = (2, 2)): + """ + 绘制带阴影的文字 + + Args: + draw: PIL绘图对象 + position: 文字位置 + text: 文字内容 + font: 字体对象 + text_color: 文字颜色 + shadow_color: 阴影颜色 + shadow_offset: 阴影偏移 + """ + x, y = position + shadow_x, shadow_y = shadow_offset + + # 绘制阴影 + draw.text((x + shadow_x, y + shadow_y), text, font=font, fill=shadow_color) + + # 绘制文字 + draw.text(position, text, font=font, fill=text_color) + + def wrap_text(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]: + """ + 文字换行处理 + + Args: + text: 原始文字 + font: 字体对象 + max_width: 最大宽度 + + Returns: + 换行后的文字列表 + """ + if not text.strip(): + return [] + + lines = [] + words = text.split() + + if not words: + return [text] + + current_line = "" + + for word in words: + test_line = current_line + (" " if current_line else "") + word + bbox = font.getbbox(test_line) + test_width = bbox[2] - bbox[0] + + if test_width <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + else: + # 单个词太长,强制换行 + lines.append(word) + + if current_line: + lines.append(current_line) + + return lines + + def draw_multiline_text(self, draw: ImageDraw.Draw, + position: Tuple[int, int], + text: str, + font: ImageFont.FreeTypeFont, + max_width: int, + line_spacing: int = 5, + align: str = "left", + text_color: Tuple[int, int, int, int] = (255, 255, 255, 255), + outline_color: Optional[Tuple[int, int, int, int]] = None, + outline_width: int = 1): + """ + 绘制多行文字 + + Args: + draw: PIL绘图对象 + position: 起始位置 + text: 文字内容 + font: 字体对象 + max_width: 最大宽度 + line_spacing: 行间距 + align: 对齐方式 ("left", "center", "right") + text_color: 文字颜色 + outline_color: 描边颜色(可选) + outline_width: 描边宽度 + """ + lines = self.wrap_text(text, font, max_width) + if not lines: + return + + x, y = position + + # 计算行高 + bbox = font.getbbox("测试") + line_height = bbox[3] - bbox[1] + line_spacing + + for i, line in enumerate(lines): + line_y = y + i * line_height + + # 计算x位置(根据对齐方式) + if align == "center": + bbox = font.getbbox(line) + line_width = bbox[2] - bbox[0] + line_x = x - line_width // 2 + elif align == "right": + bbox = font.getbbox(line) + line_width = bbox[2] - bbox[0] + line_x = x - line_width + else: # left + line_x = x + + # 绘制文字 + if outline_color: + self.draw_text_with_outline( + draw, (line_x, line_y), line, font, + text_color, outline_color, outline_width + ) + else: + draw.text((line_x, line_y), line, font=font, fill=text_color) + + def draw_rounded_rectangle(self, draw: ImageDraw.Draw, + position: Tuple[int, int], + size: Tuple[int, int], + radius: int, + fill_color: Tuple[int, int, int, int], + outline_color: Optional[Tuple[int, int, int, int]] = None, + outline_width: int = 0): + """ + 绘制圆角矩形 + + Args: + draw: PIL绘图对象 + position: 左上角位置 + size: 矩形大小 + radius: 圆角半径 + fill_color: 填充颜色 + outline_color: 边框颜色 + outline_width: 边框宽度 + """ + x, y = position + width, height = size + + # 确保尺寸有效 + if width <= 0 or height <= 0: + return + + # 限制圆角半径 + radius = min(radius, width // 2, height // 2) + + # 创建圆角矩形路径 + # 这是一个简化版本,PIL的较新版本有更好的圆角矩形支持 + if radius > 0: + # 绘制中心矩形 + draw.rectangle([x + radius, y, x + width - radius, y + height], fill=fill_color) + draw.rectangle([x, y + radius, x + width, y + height - radius], fill=fill_color) + + # 绘制四个圆角 + draw.pieslice([x, y, x + 2*radius, y + 2*radius], 180, 270, fill=fill_color) + draw.pieslice([x + width - 2*radius, y, x + width, y + 2*radius], 270, 360, fill=fill_color) + draw.pieslice([x, y + height - 2*radius, x + 2*radius, y + height], 90, 180, fill=fill_color) + draw.pieslice([x + width - 2*radius, y + height - 2*radius, x + width, y + height], 0, 90, fill=fill_color) + else: + # 普通矩形 + draw.rectangle([x, y, x + width, y + height], fill=fill_color) + + # 绘制边框(如果需要) + if outline_color and outline_width > 0: + # 简化的边框绘制 - 使用线条而不是矩形避免坐标错误 + for i in range(outline_width): + offset = i + # 确保坐标有效 + if radius > 0: + # 上边 + if x + radius + offset < x + width - radius - offset: + draw.line([x + radius + offset, y + offset, + x + width - radius - offset, y + offset], + fill=outline_color, width=1) + # 下边 + if x + radius + offset < x + width - radius - offset and y + height - offset >= y + offset: + draw.line([x + radius + offset, y + height - offset, + x + width - radius - offset, y + height - offset], + fill=outline_color, width=1) + # 左边 + if y + radius + offset < y + height - radius - offset: + draw.line([x + offset, y + radius + offset, + x + offset, y + height - radius - offset], + fill=outline_color, width=1) + # 右边 + if y + radius + offset < y + height - radius - offset: + draw.line([x + width - offset, y + radius + offset, + x + width - offset, y + height - radius - offset], + fill=outline_color, width=1) + else: + # 普通矩形边框 + draw.rectangle([x + offset, y + offset, x + width - offset, y + height - offset], + outline=outline_color, width=1) + + def create_text_background(self, size: Tuple[int, int], + color: Tuple[int, int, int, int] = (0, 0, 0, 128), + radius: int = 10) -> Image.Image: + """ + 创建文字背景 + + Args: + size: 背景尺寸 + color: 背景颜色 + radius: 圆角半径 + + Returns: + 背景图像 + """ + background = Image.new('RGBA', size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(background) + + if radius > 0: + self.draw_rounded_rectangle(draw, (0, 0), size, radius, color) + else: + draw.rectangle([0, 0, size[0], size[1]], fill=color) + + return background + + def render_text_with_background(self, canvas: Image.Image, + text: str, + position: Tuple[int, int], + font_size: int, + max_width: int, + text_color: Tuple[int, int, int, int] = (255, 255, 255, 255), + bg_color: Tuple[int, int, int, int] = (0, 0, 0, 128), + padding: int = 10, + align: str = "center", + font_name: Optional[str] = None) -> Image.Image: + """ + 渲染带背景的文字 + + Args: + canvas: 画布 + text: 文字内容 + position: 位置 + font_size: 字体大小 + max_width: 最大宽度 + text_color: 文字颜色 + bg_color: 背景颜色 + padding: 内边距 + align: 对齐方式 + font_name: 字体名称 + + Returns: + 渲染后的画布 + """ + if not text.strip(): + return canvas + + # 加载字体 + font = self.load_font(font_size, font_name) + + # 计算文字行 + lines = self.wrap_text(text, font, max_width - 2 * padding) + if not lines: + return canvas + + # 计算总尺寸 + bbox = font.getbbox("测试") + line_height = bbox[3] - bbox[1] + total_height = len(lines) * line_height + (len(lines) - 1) * 5 + 2 * padding + + max_line_width = 0 + for line in lines: + bbox = font.getbbox(line) + line_width = bbox[2] - bbox[0] + max_line_width = max(max_line_width, line_width) + + total_width = max_line_width + 2 * padding + + # 创建文字背景 + bg_image = self.create_text_background((total_width, total_height), bg_color, 10) + + # 绘制文字 + draw = ImageDraw.Draw(bg_image) + start_y = padding + + for i, line in enumerate(lines): + line_y = start_y + i * (line_height + 5) + + if align == "center": + bbox = font.getbbox(line) + line_width = bbox[2] - bbox[0] + line_x = (total_width - line_width) // 2 + elif align == "right": + bbox = font.getbbox(line) + line_width = bbox[2] - bbox[0] + line_x = total_width - line_width - padding + else: # left + line_x = padding + + draw.text((line_x, line_y), line, font=font, fill=text_color) + + # 合成到画布上 + x, y = position + if align == "center": + x = x - total_width // 2 + elif align == "right": + x = x - total_width + + canvas = canvas.copy() + canvas.paste(bg_image, (x, y), bg_image) + + return canvas +""" +统一颜色提取器 +基于酒店模块的完整实现,包含颜色和谐化算法 +""" + +import random +import math +import numpy as np +from PIL import Image +from collections import Counter +from typing import Tuple, List, Dict, Optional + + +class ColorExtractor: + """统一的颜色处理类""" + + # 预定义的颜色主题(顶部色,底部色) + COLOR_THEMES = { + "blue_gradient": [(35, 85, 150), (80, 160, 240)], # 蓝色渐变 + "sunset": [(200, 60, 20), (250, 180, 90)], # 日落色彩 + "forest": [(20, 80, 30), (120, 180, 70)], # 森林绿色 + "ocean": [(0, 60, 100), (100, 210, 255)], # 海洋蓝色 + "purple_dream": [(60, 20, 90), (180, 120, 240)], # 紫色梦幻 + "elegant": [(40, 40, 60), (180, 180, 200)], # 优雅灰色 + "ocean_deep": [(0, 30, 80), (20, 120, 220)], # 深海蓝渐变 + "warm_sunset": [(255, 94, 77), (255, 154, 0)], # 暖色日落 + "cool_mint": [(64, 224, 208), (127, 255, 212)], # 清凉薄荷 + "royal_purple": [(75, 0, 130), (138, 43, 226)], # 皇家紫 + } + + @staticmethod + def extract_dominant_color(image: Image.Image, + sample_size: int = 200, + sample_method: str = "grid") -> Tuple[int, int, int]: + """ + 从图像中提取主要颜色 + + Args: + image: PIL Image对象 + sample_size: 采样点数量 + sample_method: 采样方法 ("random", "grid", "edge") + + Returns: + 主要颜色的RGB元组 + """ + # 转换为RGB模式以简化处理 + if image.mode != 'RGB': + image = image.convert('RGB') + + width, height = image.size + pixels = [] + + # 根据不同的采样方法收集像素 + if sample_method == "random": + # 随机采样 + for _ in range(sample_size): + x = random.randint(0, width-1) + y = random.randint(0, height-1) + pixel = image.getpixel((x, y)) + # 忽略接近白色和接近黑色的像素 + if sum(pixel) > 50 and sum(pixel) < 700: + pixels.append(pixel) + + elif sample_method == "grid": + # 均匀网格采样,覆盖整个图像 + grid_size = int(math.sqrt(sample_size)) + x_step = max(1, width // grid_size) + y_step = max(1, height // grid_size) + + for y in range(0, height, y_step): + for x in range(0, width, x_step): + if len(pixels) < sample_size: + pixel = image.getpixel((x, y)) + # 过滤近黑近白颜色 + if sum(pixel) > 50 and sum(pixel) < 700: + pixels.append(pixel) + + elif sample_method == "edge": + # 边缘优先采样,图像四周区域 + edge_width = min(width, height) // 4 + + # 采样四个边缘 + edges = [ + # 顶部边缘 + [(x, y) for y in range(0, edge_width) + for x in range(0, width, width // (sample_size // 4))], + # 底部边缘 + [(x, y) for y in range(height - edge_width, height) + for x in range(0, width, width // (sample_size // 4))], + # 左边缘 + [(x, y) for x in range(0, edge_width) + for y in range(0, height, height // (sample_size // 4))], + # 右边缘 + [(x, y) for x in range(width - edge_width, width) + for y in range(0, height, height // (sample_size // 4))] + ] + + for edge_points in edges: + for x, y in edge_points: + if x < width and y < height and len(pixels) < sample_size: + pixel = image.getpixel((x, y)) + if sum(pixel) > 50 and sum(pixel) < 700: + pixels.append(pixel) + + # 如果没有采样到合适的像素,返回默认颜色 + if not pixels: + return ColorExtractor.get_default_color() + + # 计算最常见的颜色 + color_counter = Counter(pixels) + color_candidates = color_counter.most_common(5) + + # 从候选颜色中选择饱和度适中的颜色 + best_color = ColorExtractor.select_best_color(color_candidates) + + # 调整颜色,使其更适合作为背景 + adjusted_color = ColorExtractor.adjust_color_for_background(best_color) + + return adjusted_color + + @staticmethod + def select_best_color(color_candidates: List[Tuple[Tuple[int, int, int], int]]) -> Tuple[int, int, int]: + """ + 从候选颜色中选择最适合的颜色(考虑饱和度和亮度) + + Args: + color_candidates: 颜色候选列表,每个元素为 ((R,G,B), count) + + Returns: + 最佳颜色的RGB元组 + """ + if not color_candidates: + return ColorExtractor.get_default_color() + + best_color = None + best_score = -1 + + for color, count in color_candidates: + r, g, b = color + + # 计算饱和度 + max_val = max(r, g, b) + min_val = min(r, g, b) + saturation = (max_val - min_val) / max_val if max_val > 0 else 0 + + # 计算亮度 + brightness = (r + g + b) / 3 + + # 综合评分:考虑饱和度、亮度和出现频率 + # 偏好中等饱和度、中等亮度的颜色 + saturation_score = 1 - abs(saturation - 0.6) # 最佳饱和度为0.6 + brightness_score = 1 - abs(brightness - 128) / 128 # 最佳亮度为128 + frequency_score = count / color_candidates[0][1] # 相对频率 + + # 综合评分 + total_score = (saturation_score * 0.4 + + brightness_score * 0.4 + + frequency_score * 0.2) + + if total_score > best_score: + best_score = total_score + best_color = color + + return best_color if best_color else ColorExtractor.get_default_color() + + @staticmethod + def adjust_color_for_background(color: Tuple[int, int, int]) -> Tuple[int, int, int]: + """ + 调整颜色,使其更适合作为背景 + + Args: + color: 输入颜色的RGB元组 + + Returns: + 调整后的颜色RGB元组 + """ + r, g, b = color + + # 计算当前亮度 + brightness = (r + g + b) / 3 + + # 如果颜色太亮,适当降低亮度 + if brightness > 200: + factor = 0.7 + r = int(r * factor) + g = int(g * factor) + b = int(b * factor) + + # 如果颜色太暗,适当提高亮度 + elif brightness < 50: + factor = 1.5 + r = min(255, int(r * factor)) + g = min(255, int(g * factor)) + b = min(255, int(b * factor)) + + # 增加一些饱和度,让颜色更鲜艳 + # 找到最大和最小值 + max_val = max(r, g, b) + min_val = min(r, g, b) + + if max_val > min_val: + # 增强饱和度 + mid_val = (max_val + min_val) / 2 + + if r == max_val: + r = min(255, int(r + (r - mid_val) * 0.2)) + elif r == min_val: + r = max(0, int(r - (mid_val - r) * 0.2)) + + if g == max_val: + g = min(255, int(g + (g - mid_val) * 0.2)) + elif g == min_val: + g = max(0, int(g - (mid_val - g) * 0.2)) + + if b == max_val: + b = min(255, int(b + (b - mid_val) * 0.2)) + elif b == min_val: + b = max(0, int(b - (mid_val - b) * 0.2)) + + return (r, g, b) + + @staticmethod + def get_default_color() -> Tuple[int, int, int]: + """ + 获取默认颜色 + + Returns: + 默认颜色的RGB元组 + """ + return (100, 150, 200) # 柔和的蓝色 + + @staticmethod + def ensure_colors_harmony(top_color: Tuple[int, int, int], + bottom_color: Tuple[int, int, int], + harmony_threshold: int = 30) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]: + """ + 确保两个颜色之间的和谐性 + + Args: + top_color: 顶部颜色 + bottom_color: 底部颜色 + harmony_threshold: 和谐阈值 + + Returns: + 调整后的颜色对 + """ + def color_distance(c1, c2): + """计算两个颜色的欧几里得距离""" + return math.sqrt(sum((a - b) ** 2 for a, b in zip(c1, c2))) + + def adjust_color_harmony(base_color, target_color, factor=0.3): + """调整目标颜色使其与基准颜色更和谐""" + adjusted = [] + for i in range(3): + # 向基准颜色靠拢 + new_val = target_color[i] + (base_color[i] - target_color[i]) * factor + adjusted.append(int(max(0, min(255, new_val)))) + return tuple(adjusted) + + # 计算当前颜色距离 + distance = color_distance(top_color, bottom_color) + + # 如果距离太小(颜色太相似),增加差异 + if distance < harmony_threshold: + # 让底部颜色更亮一些 + bottom_adjusted = [] + for val in bottom_color: + new_val = min(255, val + harmony_threshold) + bottom_adjusted.append(new_val) + bottom_color = tuple(bottom_adjusted) + + # 如果距离太大(颜色差异太大),增加和谐性 + elif distance > harmony_threshold * 3: + bottom_color = adjust_color_harmony(top_color, bottom_color, 0.4) + + return top_color, bottom_color + + @staticmethod + def get_theme_colors(theme_name: Optional[str] = None) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]: + """ + 获取主题颜色 + + Args: + theme_name: 主题名称,如果为None则随机选择 + + Returns: + 主题颜色对 (top_color, bottom_color) + """ + if theme_name is None or theme_name not in ColorExtractor.COLOR_THEMES: + theme_name = random.choice(list(ColorExtractor.COLOR_THEMES.keys())) + + colors = ColorExtractor.COLOR_THEMES[theme_name] + print(f"使用主题颜色: {theme_name}") + return colors[0], colors[1] + + @staticmethod + def create_gradient_colors(base_color: Tuple[int, int, int], + variation: float = 0.3) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]: + """ + 基于基础颜色创建渐变色彩 + + Args: + base_color: 基础颜色 + variation: 变化幅度 (0-1) + + Returns: + 渐变色彩对 (darker_color, lighter_color) + """ + r, g, b = base_color + + # 创建较暗的颜色 + darker_r = max(0, int(r * (1 - variation))) + darker_g = max(0, int(g * (1 - variation))) + darker_b = max(0, int(b * (1 - variation))) + darker_color = (darker_r, darker_g, darker_b) + + # 创建较亮的颜色 + lighter_r = min(255, int(r * (1 + variation))) + lighter_g = min(255, int(g * (1 + variation))) + lighter_b = min(255, int(b * (1 + variation))) + lighter_color = (lighter_r, lighter_g, lighter_b) + + return darker_color, lighter_color + + @staticmethod + def get_complementary_color(color: Tuple[int, int, int]) -> Tuple[int, int, int]: + """ + 获取补色 + + Args: + color: 输入颜色 + + Returns: + 补色 + """ + r, g, b = color + return (255 - r, 255 - g, 255 - b) + + @staticmethod + def get_analogous_colors(color: Tuple[int, int, int], count: int = 2) -> List[Tuple[int, int, int]]: + """ + 获取类似色 + + Args: + color: 基础颜色 + count: 类似色数量 + + Returns: + 类似色列表 + """ + import colorsys + + r, g, b = [x / 255.0 for x in color] + h, s, v = colorsys.rgb_to_hsv(r, g, b) + + analogous = [] + step = 30 / 360 # 30度的色相差 + + for i in range(1, count + 1): + # 正负方向各生成一些类似色 + for direction in [-1, 1]: + new_h = (h + direction * step * i) % 1.0 + new_r, new_g, new_b = colorsys.hsv_to_rgb(new_h, s, v) + analogous_color = ( + int(new_r * 255), + int(new_g * 255), + int(new_b * 255) + ) + analogous.append(analogous_color) + + if len(analogous) >= count: + break + + if len(analogous) >= count: + break + + return analogous[:count] + +class ImageProcessor: + """统一的图像处理类""" + + @staticmethod + def resize_image(image: Union[Image.Image, np.ndarray], target_width: int) -> Image.Image: + """ + 调整图像大小,保持原始高宽比 + + Args: + image: PIL Image对象或numpy数组 + target_width: 目标宽度 + + Returns: + 调整后的PIL Image对象 + """ + if isinstance(image, np.ndarray): + image = Image.fromarray(image) + + orig_aspect = image.width / image.height + target_height = int(target_width / orig_aspect) + return image.resize((target_width, target_height), Image.LANCZOS) + + @staticmethod + def ensure_rgba(image: Image.Image) -> Image.Image: + """ + 确保图像是RGBA模式 + + Args: + image: PIL Image对象 + + Returns: + RGBA模式的PIL Image对象 + """ + if image.mode == 'RGBA': + return image + elif image.mode == 'RGB': + # 转换为RGBA模式 + rgba_image = Image.new('RGBA', image.size, (0, 0, 0, 0)) + rgba_image.paste(image, (0, 0)) + return rgba_image + else: + return image.convert('RGBA') + + @staticmethod + def resize_and_crop(image: Image.Image, target_size: Tuple[int, int]) -> Image.Image: + """ + 调整图像大小并居中裁剪到目标尺寸 + + Args: + image: PIL Image对象 + target_size: 目标尺寸 (width, height) + + Returns: + 调整后的PIL Image对象 + """ + target_width, target_height = target_size + + # 计算缩放比例,确保图像能完全覆盖目标区域 + scale_width = target_width / image.width + scale_height = target_height / image.height + scale = max(scale_width, scale_height) + + # 计算新尺寸 + new_width = int(image.width * scale) + new_height = int(image.height * scale) + + # 调整大小 + resized = image.resize((new_width, new_height), Image.LANCZOS) + + # 居中裁剪 + start_x = (new_width - target_width) // 2 + start_y = (new_height - target_height) // 2 + + cropped = resized.crop(( + start_x, + start_y, + start_x + target_width, + start_y + target_height + )) + + return cropped + + @staticmethod + def enhance_image(image: Union[Image.Image, np.ndarray], + contrast: float = 1.0, + brightness: float = 1.0, + saturation: float = 1.0) -> Image.Image: + """ + 增强图像效果 + + Args: + image: PIL Image对象或numpy数组 + contrast: 对比度增强系数 + brightness: 亮度增强系数 + saturation: 饱和度增强系数 + + Returns: + 增强后的PIL Image对象 + """ + if isinstance(image, np.ndarray): + image = Image.fromarray(image.astype('uint8')) + + # 对比度增强 + if contrast != 1.0: + enhancer = ImageEnhance.Contrast(image) + image = enhancer.enhance(contrast) + + # 亮度增强 + if brightness != 1.0: + enhancer = ImageEnhance.Brightness(image) + image = enhancer.enhance(brightness) + + # 饱和度增强 + if saturation != 1.0: + enhancer = ImageEnhance.Color(image) + image = enhancer.enhance(saturation) + + return image + + @staticmethod + def load_image(image_path: str) -> Optional[Image.Image]: + """ + 加载图像文件 + + Args: + image_path: 图像文件路径 + + Returns: + PIL Image对象,如果加载失败返回None + """ + try: + if not os.path.exists(image_path): + print(f"图像文件不存在: {image_path}") + return None + + image = Image.open(image_path) + print(f"已加载图像: {os.path.basename(image_path)}, 尺寸: {image.size}") + return image + + except Exception as e: + print(f"加载图像失败: {e}") + return None + + @staticmethod + def apply_blur(image: Image.Image, radius: float = 2.0) -> Image.Image: + """ + 应用模糊效果 + + Args: + image: PIL Image对象 + radius: 模糊半径 + + Returns: + 模糊后的PIL Image对象 + """ + return image.filter(ImageFilter.GaussianBlur(radius=radius)) + + @staticmethod + def create_canvas(size: Tuple[int, int], color: Tuple[int, int, int, int] = (255, 255, 255, 255)) -> Image.Image: + """ + 创建指定尺寸和颜色的画布 + + Args: + size: 画布尺寸 (width, height) + color: 背景颜色 (R, G, B, A) + + Returns: + PIL Image对象 + """ + return Image.new('RGBA', size, color) + + @staticmethod + def paste_image(canvas: Image.Image, image: Image.Image, position: Tuple[int, int], mask: Optional[Image.Image] = None) -> Image.Image: + """ + 将图像粘贴到画布上 + + Args: + canvas: 目标画布 + image: 要粘贴的图像 + position: 粘贴位置 (x, y) + mask: 可选的蒙版 + + Returns: + 粘贴后的画布 + """ + canvas.paste(image, position, mask) + return canvas + + @staticmethod + def alpha_composite(base: Image.Image, overlay: Image.Image) -> Image.Image: + """ + Alpha合成两个图像 + + Args: + base: 底层图像 + overlay: 覆盖层图像 + + Returns: + 合成后的图像 + """ + # 确保两个图像都是RGBA模式 + base = ImageProcessor.ensure_rgba(base) + overlay = ImageProcessor.ensure_rgba(overlay) + + # 确保尺寸一致 + if base.size != overlay.size: + overlay = overlay.resize(base.size, Image.LANCZOS) + + return Image.alpha_composite(base, overlay) + +class BaseTemplate(ABC): + """海报模板基类""" + + def __init__(self, size: Tuple[int, int] = (900, 1200)): + """ + 初始化基础模板 + + Args: + size: 海报尺寸 (width, height) + """ + self.size = size + self.width, self.height = size + + # 初始化核心组件 + self.image_processor = ImageProcessor() + self.color_extractor = ColorExtractor() + self.text_renderer = TextRenderer() + + # 默认配置 + self.default_config = { + 'background_color': (255, 255, 255, 255), + 'text_color': (0, 0, 0, 255), + 'font_size': 36, + 'padding': 20, + 'margin': 10 + } + + @abstractmethod + def generate(self, **kwargs) -> Image.Image: + """ + 生成海报的抽象方法 + + Args: + **kwargs: 生成参数 + + Returns: + 生成的海报图像 + """ + pass + + @abstractmethod + def get_template_info(self) -> Dict[str, Any]: + """ + 获取模板信息的抽象方法 + + Returns: + 模板信息字典 + """ + pass + + def create_canvas(self, background_color: Optional[Tuple[int, int, int, int]] = None) -> Image.Image: + """ + 创建画布 + + Args: + background_color: 背景颜色 + + Returns: + 画布图像 + """ + if background_color is None: + background_color = self.default_config['background_color'] + + return self.image_processor.create_canvas(self.size, background_color) + + def create_gradient_background(self, + top_color: Tuple[int, int, int], + bottom_color: Tuple[int, int, int], + direction: str = "vertical") -> Image.Image: + """ + 创建渐变背景 + + Args: + top_color: 顶部颜色 + bottom_color: 底部颜色 + direction: 渐变方向 ("vertical", "horizontal", "diagonal") + + Returns: + 渐变背景图像 + """ + # 创建渐变数组 + gradient = np.zeros((self.height, self.width, 3), dtype=np.uint8) + + if direction == "vertical": + # 垂直渐变 + for y in range(self.height): + ratio = y / (self.height - 1) + color = [ + int(top_color[i] * (1 - ratio) + bottom_color[i] * ratio) + for i in range(3) + ] + gradient[y, :] = color + + elif direction == "horizontal": + # 水平渐变 + for x in range(self.width): + ratio = x / (self.width - 1) + color = [ + int(top_color[i] * (1 - ratio) + bottom_color[i] * ratio) + for i in range(3) + ] + gradient[:, x] = color + + elif direction == "diagonal": + # 对角线渐变 + for y in range(self.height): + for x in range(self.width): + ratio = (x + y) / (self.width + self.height - 2) + color = [ + int(top_color[i] * (1 - ratio) + bottom_color[i] * ratio) + for i in range(3) + ] + gradient[y, x] = color + + # 转换为PIL图像 + gradient_image = Image.fromarray(gradient, 'RGB') + return gradient_image.convert('RGBA') + + def apply_transparency_gradient(self, + image: Image.Image, + direction: str = "vertical", + start_alpha: int = 255, + end_alpha: int = 0, + start_ratio: float = 0.0, + end_ratio: float = 1.0) -> Image.Image: + """ + 应用透明度渐变 + + Args: + image: 输入图像 + direction: 渐变方向 + start_alpha: 起始透明度 + end_alpha: 结束透明度 + start_ratio: 起始位置比例 + end_ratio: 结束位置比例 + + Returns: + 应用透明度渐变后的图像 + """ + image = self.image_processor.ensure_rgba(image) + width, height = image.size + + # 创建透明度蒙版 + mask = Image.new('L', (width, height), 255) + mask_array = np.array(mask) + + if direction == "vertical": + start_y = int(height * start_ratio) + end_y = int(height * end_ratio) + + for y in range(start_y, end_y): + if end_y > start_y: + ratio = (y - start_y) / (end_y - start_y) + alpha = int(start_alpha * (1 - ratio) + end_alpha * ratio) + mask_array[y, :] = alpha + + elif direction == "horizontal": + start_x = int(width * start_ratio) + end_x = int(width * end_ratio) + + for x in range(start_x, end_x): + if end_x > start_x: + ratio = (x - start_x) / (end_x - start_x) + alpha = int(start_alpha * (1 - ratio) + end_alpha * ratio) + mask_array[:, x] = alpha + + # 应用蒙版 + mask = Image.fromarray(mask_array, 'L') + image.putalpha(mask) + + return image + + def add_text_layer(self, + canvas: Image.Image, + text: str, + position: Tuple[int, int], + font_size: int, + color: Tuple[int, int, int, int] = (255, 255, 255, 255), + font_name: Optional[str] = None, + align: str = "center", + max_width: Optional[int] = None, + with_outline: bool = False, + outline_color: Tuple[int, int, int, int] = (0, 0, 0, 255), + outline_width: int = 2) -> Image.Image: + """ + 添加文字层 + + Args: + canvas: 画布 + text: 文字内容 + position: 位置 + font_size: 字体大小 + color: 文字颜色 + font_name: 字体名称 + align: 对齐方式 + max_width: 最大宽度 + with_outline: 是否添加描边 + outline_color: 描边颜色 + outline_width: 描边宽度 + + Returns: + 添加文字后的画布 + """ + if not text.strip(): + return canvas + + # 如果没有指定最大宽度,使用画布宽度减去边距 + if max_width is None: + max_width = self.width - 2 * self.default_config['padding'] + + # 加载字体 + font = self.text_renderer.load_font(font_size, font_name) + + # 创建绘图对象 + from PIL import ImageDraw + draw = ImageDraw.Draw(canvas) + + # 绘制文字 + if with_outline: + self.text_renderer.draw_text_with_outline( + draw, position, text, font, color, outline_color, outline_width + ) + else: + # 处理多行文字 + if max_width and len(text) > 10: # 长文本自动换行 + self.text_renderer.draw_multiline_text( + draw, position, text, font, max_width, + align=align, text_color=color + ) + else: + # 单行文字,根据对齐方式调整位置 + if align == "center": + text_width, _ = self.text_renderer.get_text_size(text, font) + position = (position[0] - text_width // 2, position[1]) + elif align == "right": + text_width, _ = self.text_renderer.get_text_size(text, font) + position = (position[0] - text_width, position[1]) + + draw.text(position, text, font=font, fill=color) + + return canvas + + def add_image_layer(self, + canvas: Image.Image, + image: Image.Image, + position: Tuple[int, int], + size: Optional[Tuple[int, int]] = None, + fit_mode: str = "contain", + opacity: float = 1.0) -> Image.Image: + """ + 添加图像层 + + Args: + canvas: 画布 + image: 要添加的图像 + position: 位置 + size: 目标尺寸 + fit_mode: 适应模式 ("contain", "cover", "stretch") + opacity: 不透明度 (0-1) + + Returns: + 添加图像后的画布 + """ + if size: + if fit_mode == "stretch": + # 拉伸到指定尺寸 + image = image.resize(size, Image.LANCZOS) + elif fit_mode == "cover": + # 覆盖模式,裁剪适应 + image = self.image_processor.resize_and_crop(image, size) + else: # contain + # 包含模式,保持比例 + image = self.image_processor.resize_image(image, size[0]) + if image.height > size[1]: + # 如果高度超出,按高度缩放 + scale = size[1] / image.height + new_width = int(image.width * scale) + image = image.resize((new_width, size[1]), Image.LANCZOS) + + # 应用透明度 + if opacity < 1.0: + image = self.image_processor.ensure_rgba(image) + alpha = image.split()[-1] + alpha = alpha.point(lambda p: int(p * opacity)) + image.putalpha(alpha) + + # 粘贴到画布上 + image = self.image_processor.ensure_rgba(image) + canvas.paste(image, position, image) + + return canvas + + def save_poster(self, image: Image.Image, output_path: str, quality: int = 95): + """ + 保存海报 + + Args: + image: 海报图像 + output_path: 输出路径 + quality: 图像质量 (1-100) + """ + try: + # 如果是RGBA模式且要保存为JPEG,需要转换 + if image.mode == 'RGBA' and output_path.lower().endswith(('.jpg', '.jpeg')): + # 创建白色背景 + background = Image.new('RGB', image.size, (255, 255, 255)) + background.paste(image, mask=image.split()[-1]) + background.save(output_path, 'JPEG', quality=quality) + else: + image.save(output_path, quality=quality) + + print(f"海报已保存到: {output_path}") + + except Exception as e: + print(f"保存海报失败: {e}") + + def validate_inputs(self, **kwargs) -> bool: + """ + 验证输入参数 + + Args: + **kwargs: 输入参数 + + Returns: + 验证是否通过 + """ + # 基础验证逻辑,子类可以重写 + return True + + def get_layout_areas(self) -> Dict[str, Tuple[int, int, int, int]]: + """ + 获取布局区域定义 + + Returns: + 布局区域字典,格式为 {"区域名": (x, y, width, height)} + """ + # 默认布局区域 + padding = self.default_config['padding'] + + return { + "header": (padding, padding, self.width - 2*padding, self.height // 4), + "content": (padding, self.height // 4, self.width - 2*padding, self.height // 2), + "footer": (padding, 3*self.height // 4, self.width - 2*padding, self.height // 4) + } + + def apply_filter(self, image: Image.Image, filter_type: str, **params) -> Image.Image: + """ + 应用图像滤镜 + + Args: + image: 输入图像 + filter_type: 滤镜类型 + **params: 滤镜参数 + + Returns: + 应用滤镜后的图像 + """ + if filter_type == "blur": + radius = params.get("radius", 2.0) + return self.image_processor.apply_blur(image, radius) + + elif filter_type == "enhance": + contrast = params.get("contrast", 1.0) + brightness = params.get("brightness", 1.0) + saturation = params.get("saturation", 1.0) + return self.image_processor.enhance_image(image, contrast, brightness, saturation) + + else: + return image + + def get_color_palette(self, image: Image.Image, count: int = 5) -> List[Tuple[int, int, int]]: + """ + 从图像中提取颜色调色板 + + Args: + image: 输入图像 + count: 颜色数量 + + Returns: + 颜色列表 + """ + # 提取主色调 + dominant_color = self.color_extractor.extract_dominant_color(image) + + # 生成调色板 + palette = [dominant_color] + + # 添加类似色 + analogous_colors = self.color_extractor.get_analogous_colors(dominant_color, count - 2) + palette.extend(analogous_colors) + + # 添加补色 + if len(palette) < count: + complementary = self.color_extractor.get_complementary_color(dominant_color) + palette.append(complementary) + + return palette[:count] + +class VibrantTemplate(BaseTemplate): + """活力风格海报模板(基于海洋模块)""" + + def __init__(self, size: Tuple[int, int] = (900, 1200)): + """ + 初始化活力模板 + + Args: + size: 海报尺寸,默认为海洋海报的比例 + """ + super().__init__(size) + + # 海洋模块原版配置 + self.ocean_config = { + 'gradient_height_ratio': 1/3, + 'ocean_colors': { + 'ocean_deep': [(0, 30, 80), (20, 120, 220)], # 深海蓝渐变 + 'sunset_warm': [(255, 94, 77), (255, 154, 0)], + 'cool_mint': [(64, 224, 208), (127, 255, 212)], + 'royal_purple': [(75, 0, 130), (138, 43, 226)], + 'forest_green': [(34, 139, 34), (144, 238, 144)], + 'fire_red': [(220, 20, 60), (255, 69, 0)], + "gray_gradient": [(128, 128, 128), (211, 211, 211)], + "drak_gray":[(15,15,15),(30,30,30)] + }, + 'glass_effect': { + 'max_opacity': 240, + 'blur_radius': 22, + 'transition_height': 80, + 'intensity_multiplier': 1.5 # 毛玻璃强度倍数,可调节 + }, + 'font_sizes': { + 'title': 120, + 'subtitle': 54, + 'price': 180, + 'normal': 36, + 'small': 24 + } + } + + def generate(self, + image_path: str, + ocean_info: Optional[Dict[str, Any]] = None, + theme_color: str = "ocean_deep", + glass_intensity: float = 1.5, # 毛玻璃强度倍数,默认1.5倍 + output_path: str = "vibrant_poster.png", + **kwargs) -> Image.Image: + """ + 生成活力风格海报(兼容原版海洋模块接口) + + Args: + image_path: 主图片路径 + ocean_info: 海洋主题信息字典(与原版兼容) + theme_color: 主题颜色 + glass_intensity: 毛玻璃效果强度倍数(1.0为标准强度,2.0为双倍强度) + output_path: 输出路径 + **kwargs: 其他参数 + + Returns: + 生成的海报图像 + """ + print("开始生成活力风格海报(海洋模式)...") + + # 设置毛玻璃强度 + self.ocean_config['glass_effect']['intensity_multiplier'] = glass_intensity + print(f"毛玻璃效果强度设置为: {glass_intensity}倍") + + # 如果没有提供ocean_info,使用默认值 + if ocean_info is None: + ocean_info = self._get_default_ocean_info() + + # 1. 加载主图片 + main_image = self.image_processor.load_image(image_path) + if not main_image: + raise ValueError(f"无法加载图片: {image_path}") + + print(f"已加载底板图片: {image_path}") + + # 2. 调整图像大小并居中裁剪到目标尺寸(与海洋模板完全一致) + main_image = self.image_processor.resize_and_crop(main_image, (self.width, self.height)) + print(f"调整后图像尺寸: {self.width}x{self.height}") + + # 3. 预估文本内容所需高度 + estimated_height = self._estimate_content_height(ocean_info) + print(f"预估文本内容高度: {estimated_height}像素") + + # 4. 动态检测渐变起始位置(与原版逻辑一致) + gradient_start = self._detect_gradient_start_position(main_image, estimated_height) + print(f"渐变层起始位置: 距顶部{gradient_start}像素") + + # 5. 创建复合图像 + canvas = self._create_composite_image(main_image, gradient_start, theme_color) + + # 6. 渲染文本内容(使用原版布局逻辑) + canvas = self._render_ocean_texts_original_layout(canvas, ocean_info, gradient_start) + + # 7. 最终调整尺寸并保存 + final_image = canvas.resize((1350, 1800), Image.LANCZOS) + self.save_poster(final_image, output_path) + + print("活力风格海报生成完成!") + return final_image + + def _get_default_ocean_info(self) -> Dict[str, Any]: + """获取默认的海洋信息""" + return { + "title": "正佳极地海洋世界", + "slogan": "都说海洋馆是约会圣地!那锦峰夜场将是绝杀!", + "price": "199", + "ticket_type": "夜场票", + "content_button": "套餐内容", + "content_items": [ + "正佳极地海洋世界夜场票1张", + "有效期至2025.06.02", + "多种动物表演全部免费" + ], + "remarks": [ + "工作日可直接入园", + "周末请提前1天预约" + ], + "tag": "#520特惠", + "pagination": "" + } + + def _detect_gradient_start_position(self, image: Image.Image, estimated_height: int) -> int: + """ + 动态检测渐变起始位置(与原版海洋模块逻辑一致) + + Args: + image: 主图片 + estimated_height: 预估内容高度 + + Returns: + 渐变起始位置 + """ + width, height = image.size + center_x = width // 2 + + # 从中间开始向下扫描,寻找合适的渐变起始位置 + gradient_start = None + for y in range(height // 2, height): + try: + pixel = image.getpixel((center_x, y)) + # 简化检测逻辑,避免复杂的alpha通道判断 + if isinstance(pixel, (tuple, list)) and len(pixel) >= 3: + # 检查是否是明显的前景色(非背景色) + brightness = sum(pixel[:3]) / 3 + if brightness > 50: # 亮度阈值 + gradient_start = max(y - 20, height // 2) + break + except: + continue + + # 如果没有找到明显的渐变起始位置,使用预估方法 + if gradient_start is None: + bottom_margin = 60 + gradient_start = max(height - estimated_height - bottom_margin, height // 2) + + return gradient_start + + def _estimate_content_height(self, ocean_info: Dict[str, Any]) -> int: + """预估内容高度(与原版逻辑一致)""" + # 标准间距 + standard_margin = 25 + + # 1. 标题高度估算 + title_height = 100 + + # 2. 副标题高度估算 + subtitle_height = 80 + + # 3. 套餐内容按钮 + button_height = 40 + + # 4. 套餐内容列表 + content_items = ocean_info.get("content_items", []) + content_line_height = 32 # 22 + 10 + content_list_height = len(content_items) * content_line_height + + # 5. 价格和票种区域 + price_height = 90 + ticket_height = 60 + + # 6. 备注区域 + remarks = ocean_info.get("remarks", []) + if isinstance(remarks, str): + remarks = [remarks] + remarks_height = len(remarks) * 25 + 10 + + # 7. 页脚高度 + footer_height = 40 + + # 计算总高度 + total_height = ( + 20 + # 初始顶部边距 + title_height + standard_margin + # 标题 + subtitle_height + standard_margin + # 副标题 + button_height + 15 + # 套餐内容按钮 + content_list_height + # 套餐内容列表 + price_height + # 价格区域 + ticket_height + # 票种区域 + remarks_height + # 备注 + footer_height + # 页脚 + 30 # 底部额外留白 + ) + + return total_height + + def _create_composite_image(self, main_image: Image.Image, + gradient_start: int, + theme_color: str) -> Image.Image: + """创建复合图像""" + # 获取主题颜色 + if theme_color in self.ocean_config['ocean_colors']: + top_color, bottom_color = self.ocean_config['ocean_colors'][theme_color] + else: + # 从图片中提取颜色 + top_color, bottom_color = self._extract_glass_colors_from_image( + main_image, gradient_start + ) + + print(f"使用毛玻璃颜色: 顶部={top_color}, 底部={bottom_color}") + + # 创建渐变透明覆盖层 + gradient_overlay = self._create_frosted_glass_overlay( + top_color, bottom_color, gradient_start + ) + + # 合成图像 + composite_img = Image.new('RGBA', (self.width, self.height), (0, 0, 0, 0)) + composite_img.paste(main_image, (0, 0)) + composite_img = Image.alpha_composite(composite_img, gradient_overlay) + + return composite_img + + def _extract_glass_colors_from_image(self, image: Image.Image, + gradient_start: int) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]: + """从图像中提取毛玻璃颜色""" + # 转换为RGB模式 + if image.mode != 'RGB': + image = image.convert('RGB') + + width, height = image.size + + # 在渐变区域采样颜色 + top_samples = [] + bottom_samples = [] + + # 顶部区域采样(渐变开始位置) + top_y = min(gradient_start + 20, height - 1) + for x in range(0, width, 20): + try: + pixel = image.getpixel((x, top_y)) + if sum(pixel) > 30: # 避免纯黑色 + top_samples.append(pixel) + except: + continue + + # 底部区域采样 + bottom_y = min(height - 50, height - 1) + for x in range(0, width, 20): + try: + pixel = image.getpixel((x, bottom_y)) + if sum(pixel) > 30: # 避免纯黑色 + bottom_samples.append(pixel) + except: + continue + + # 计算平均颜色并调暗 + if top_samples: + top_avg = tuple(int(sum(c[i] for c in top_samples) / len(top_samples)) for i in range(3)) + top_color = tuple(max(0, int(c * 0.1)) for c in top_avg) # 调暗到10% + else: + top_color = (0, 5, 15) + + if bottom_samples: + bottom_avg = tuple(int(sum(c[i] for c in bottom_samples) / len(bottom_samples)) for i in range(3)) + bottom_color = tuple(max(0, int(c * 0.2)) for c in bottom_avg) # 调暗到20% + else: + bottom_color = (0, 25, 50) + + return top_color, bottom_color + + def _create_frosted_glass_overlay(self, top_color: Tuple[int, int, int], + bottom_color: Tuple[int, int, int], + gradient_start: int) -> Image.Image: + """创建毛玻璃效果覆盖层""" + overlay = Image.new('RGBA', (self.width, self.height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + gradient_height = self.height - gradient_start + max_opacity = self.ocean_config['glass_effect']['max_opacity'] + transition_height = self.ocean_config['glass_effect']['transition_height'] + intensity_multiplier = self.ocean_config['glass_effect']['intensity_multiplier'] + + # 应用强度倍数 + enhanced_max_opacity = min(255, int(max_opacity * intensity_multiplier)) + enhanced_blur_radius = int(self.ocean_config['glass_effect']['blur_radius'] * intensity_multiplier) + + print(f"毛玻璃效果参数: 最大透明度={enhanced_max_opacity}, 模糊半径={enhanced_blur_radius}") + + # 确保颜色是三元组 + if len(top_color) > 3: + top_color = top_color[:3] + if len(bottom_color) > 3: + bottom_color = bottom_color[:3] + + # 增强颜色饱和度(基于强度倍数) + def enhance_color(color, multiplier): + r, g, b = color + # 增强饱和度和深度 + factor = min(1.5, 1.0 + (multiplier - 1.0) * 0.3) + enhanced_r = min(255, max(0, int(r * factor))) + enhanced_g = min(255, max(0, int(g * factor))) + enhanced_b = min(255, max(0, int(b * factor))) + return (enhanced_r, enhanced_g, enhanced_b) + + enhanced_top_color = enhance_color(top_color, intensity_multiplier) + enhanced_bottom_color = enhance_color(bottom_color, intensity_multiplier) + + top_color_array = np.array(enhanced_top_color) + bottom_color_array = np.array(enhanced_bottom_color) + + # 为每一行计算渐变颜色和透明度 + for y in range(gradient_start, self.height): + relative_y = y - gradient_start + ratio = relative_y / gradient_height if gradient_height > 0 else 0 + + # 使用余弦插值实现更自然的渐变效果 + smooth_ratio = 0.5 - 0.5 * math.cos(ratio * math.pi) + + # 计算当前行的颜色 + color = (1 - smooth_ratio) * top_color_array + smooth_ratio * bottom_color_array + + # 计算透明度 - 使用更平滑的衰减曲线,并应用强度倍数 + alpha_smooth = ratio ** (1.1 / intensity_multiplier) # 强度越高,衰减越缓慢 + alpha = int(enhanced_max_opacity * (0.02 + 0.98 * alpha_smooth)) + + # 在过渡区域内应用额外的平滑效果 + if relative_y < transition_height: + transition_ratio = relative_y / transition_height + smooth_transition = 0.5 - 0.5 * math.cos(transition_ratio * math.pi) + alpha = int(alpha * smooth_transition) + + r, g, b = [int(c) for c in color] + color_tuple = (r, g, b, alpha) + + # 绘制当前行 + draw.line([(0, y), (self.width, y)], fill=color_tuple) + + # 应用增强的模糊效果 + overlay = overlay.filter(ImageFilter.GaussianBlur(radius=enhanced_blur_radius)) + + return overlay + + def _render_ocean_texts_original_layout(self, canvas: Image.Image, + ocean_info: Dict[str, Any], + gradient_start: int) -> Image.Image: + """ + 渲染海洋主题文本(完全使用原版布局逻辑) + """ + draw = ImageDraw.Draw(canvas) + width, height = canvas.size + center_x = width // 2 + + # 加载字体 + fonts = self._load_ocean_fonts() + font_path = self.text_renderer.get_font_path() + + # 计算边距和布局(与原版完全一致) + left_margin, right_margin = self._calculate_content_margins( + ocean_info, width, center_x, font_path + ) + + print(f"内容区域边距: 左={left_margin}, 右={right_margin}, 宽度={right_margin - left_margin}") + + # 1. 渲染页脚(标签和分页) + bottom_margin = 30 + footer_y = height - bottom_margin + self._render_footer_original(draw, ocean_info, footer_y, left_margin, right_margin, fonts) + + # 2. 渲染标题和副标题 + title_y = gradient_start + 40 # 标题边距 + current_y = self._render_title_subtitle_original( + draw, ocean_info, title_y, center_x, left_margin, right_margin, fonts, font_path + ) + + # 3. 计算两栏布局 + content_area_width = right_margin - left_margin + left_column_width = int(content_area_width * 0.5) + right_column_x = left_margin + left_column_width + + # 4. 渲染左栏(套餐内容) + content_start_y = current_y + 30 # 内容间距 + self._render_left_column_original( + draw, ocean_info, content_start_y, left_margin, left_column_width, fonts, font_path + ) + + # 5. 渲染右栏(价格和票种) + self._render_right_column_original( + draw, ocean_info, content_start_y, right_column_x, right_margin, + footer_y, fonts, font_path + ) + + return canvas + + def _calculate_content_margins(self, ocean_info: Dict[str, Any], width: int, + center_x: int, font_path: str) -> Tuple[int, int]: + """计算内容区域边距(优化版本)""" + # 计算标题位置 + title_text = ocean_info["title"] + # 增大标题目标宽度比例,使用更大的区域 + title_target_width = int(width * 0.95) + title_size, title_width = self._calculate_optimal_font_size_simple( + title_text, font_path, title_target_width, min_size=40, max_size=130 + ) + title_x = center_x - title_width // 2 + + # 计算副标题位置 + subtitle_text = ocean_info["slogan"] + # 增大副标题目标宽度比例 + subtitle_target_width = int(width * 0.9) + subtitle_size, subtitle_width = self._calculate_optimal_font_size_simple( + subtitle_text, font_path, subtitle_target_width, max_size=50, min_size=20 + ) + subtitle_x = center_x - subtitle_width // 2 + + # 计算内容区域边距 - 减小额外的边距,让内容区域更宽 + padding = 20 # 从30减小到20 + content_left_margin = min(title_x, subtitle_x) - padding + content_right_margin = max(title_x + title_width, subtitle_x + subtitle_width) + padding + + # 确保边距不超出合理范围,但允许更宽的内容区域 + content_left_margin = max(40, content_left_margin) + content_right_margin = min(width - 40, content_right_margin) + + # 如果内容区域太窄,强制使用更宽的区域 + min_content_width = int(width * 0.75) # 至少使用75%的宽度 + current_width = content_right_margin - content_left_margin + if current_width < min_content_width: + extra_width = min_content_width - current_width + content_left_margin = max(30, content_left_margin - extra_width // 2) + content_right_margin = min(width - 30, content_right_margin + extra_width // 2) + + return content_left_margin, content_right_margin + + def _calculate_optimal_font_size_simple(self, text: str, font_path: str, + target_width: int, max_size: int = 120, + min_size: int = 10) -> Tuple[int, int]: + """ + 计算文本的最佳字体大小,使其宽度接近目标宽度(与海洋模板完全一致) + + 返回: + (字体大小, 实际文本宽度) + """ + # 二分查找最佳字体大小 + low = min_size + high = max_size + best_size = min_size + best_width = 0 + tolerance = 0.08 # 降低容差值,从0.15改为0.08,使文本宽度更接近目标值 + + # 首先尝试最大字体大小 + try: + font = ImageFont.truetype(font_path, max_size) + bbox = font.getbbox(text) + max_width = bbox[2] - bbox[0] + except: + max_width = target_width * 2 # 如果出错,设置一个大值 + + # 如果最大字体大小下的宽度仍小于目标宽度的108%,直接使用最大字体 + if max_width < target_width * (1 + tolerance): + best_size = max_size + best_width = max_width + else: + # 记录最接近目标宽度的字体大小 + closest_size = min_size + closest_diff = target_width + + while low <= high: + mid = (low + high) // 2 + try: + font = ImageFont.truetype(font_path, mid) + bbox = font.getbbox(text) + width = bbox[2] - bbox[0] + except: + width = target_width * 2 # 如果出错,设置一个大值 + + # 计算与目标宽度的差距 + diff = abs(width - target_width) + + # 更新最接近的字体大小 + if diff < closest_diff: + closest_diff = diff + closest_size = mid + + # 如果宽度在目标宽度的允许范围内,认为找到了最佳匹配 + if target_width * (1 - tolerance) <= width <= target_width * (1 + tolerance): + best_size = mid + best_width = width + break + + # 如果当前宽度小于目标宽度,尝试更大的字体 + if width < target_width: + if width > best_width: + best_width = width + best_size = mid + low = mid + 1 + else: + # 如果当前宽度大于目标宽度,尝试更小的字体 + high = mid - 1 + + # 如果没有找到在容差范围内的字体大小,使用最接近的字体大小 + if best_width == 0: + best_size = closest_size + + # 确保返回的宽度是使用最终字体计算的实际宽度 + try: + best_font = ImageFont.truetype(font_path, best_size) + final_bbox = best_font.getbbox(text) + final_width = final_bbox[2] - final_bbox[0] + except: + final_width = best_width + + print(f"文本'{text[:min(10, len(text))]}...'的最佳字体大小: {best_size},目标宽度: {target_width},实际宽度: {final_width},差距: {abs(final_width-target_width)}") + + return best_size, final_width + + def _render_footer_original(self, draw: ImageDraw.Draw, ocean_info: Dict[str, Any], + footer_y: int, left_margin: int, right_margin: int, + fonts: Dict) -> None: + """渲染页脚(原版逻辑)""" + footer_font = fonts.get('small', self.text_renderer.load_font(18)) + + # 标签(左下角) + tag_text = ocean_info.get("tag", "") + if tag_text: + draw.text((left_margin, footer_y), tag_text, font=footer_font, fill=(255, 255, 255)) + + # 分页(右下角) + pagination_text = ocean_info.get("pagination", "") + if pagination_text: + try: + pagination_bbox = footer_font.getbbox(pagination_text) + pagination_width = pagination_bbox[2] - pagination_bbox[0] + pagination_x = right_margin - pagination_width + draw.text((pagination_x, footer_y), pagination_text, + font=footer_font, fill=(255, 255, 255)) + except: + pass + + def _render_title_subtitle_original(self, draw: ImageDraw.Draw, ocean_info: Dict[str, Any], + title_y: int, center_x: int, left_margin: int, + right_margin: int, fonts: Dict, font_path: str) -> int: + """渲染标题和副标题(原版逻辑)""" + # 标题 + title_text = ocean_info["title"] + # 增大标题目标宽度比例,从0.95改为0.98 + title_target_width = int((right_margin - left_margin) * 0.98) + title_size, title_width = self._calculate_optimal_font_size_simple( + title_text, font_path, title_target_width, min_size=40, max_size=140 + ) + title_font = self.text_renderer.load_font(title_size) + title_x = center_x - title_width // 2 + + # 渲染标题(带描边) + self.text_renderer.draw_text_with_outline( + draw, (title_x, title_y), title_text, title_font, + text_color=(255, 255, 255, 255), + outline_color=(0, 30, 80, 200), + outline_width=4 + ) + + title_bbox = title_font.getbbox(title_text) + title_height = title_bbox[3] - title_bbox[1] + + # 副标题 + subtitle_text = ocean_info["slogan"] + # 增大副标题目标宽度比例,从0.9改为0.95 + subtitle_target_width = int((right_margin - left_margin) * 0.95) + subtitle_size, subtitle_width = self._calculate_optimal_font_size_simple( + subtitle_text, font_path, subtitle_target_width, max_size=75, min_size=20 + ) + subtitle_font = self.text_renderer.load_font(subtitle_size) + subtitle_x = center_x - subtitle_width // 2 + + title_spacing = 30 + subtitle_y = title_y + title_height + title_spacing + + # 渲染副标题(带阴影) + self.text_renderer.draw_text_with_shadow( + draw, (subtitle_x, subtitle_y), subtitle_text, subtitle_font, + text_color=(255, 255, 255, 255), + shadow_color=(0, 0, 0, 180), + shadow_offset=(2, 2) + ) + + subtitle_bbox = subtitle_font.getbbox(subtitle_text) + subtitle_height = subtitle_bbox[3] - subtitle_bbox[1] + + # 分隔线 + title_line_y = subtitle_y - title_spacing // 2 + line_width = int(title_width * 1.1) + line_start_x = center_x - line_width // 2 + line_end_x = center_x + line_width // 2 + + draw.line( + [(line_start_x, title_line_y), (line_end_x, title_line_y)], + fill=(255, 255, 255, 100), width=2 + ) + + return subtitle_y + subtitle_height + + def _render_left_column_original(self, draw: ImageDraw.Draw, ocean_info: Dict[str, Any], + content_start_y: int, left_margin: int, + left_column_width: int, fonts: Dict, font_path: str) -> None: + """渲染左栏内容(原版逻辑,与海洋模板完全一致)""" + # 套餐内容按钮 + button_font = self.text_renderer.load_font(30) # 从24增大到30 + button_text = ocean_info.get("content_button", "套餐内容") + + try: + button_bbox = button_font.getbbox(button_text) + button_width = button_bbox[2] - button_bbox[0] + 40 + except: + button_width = 200 + + button_height = 50 # 从40增大到50 + + # 绘制圆角矩形背景 + self.text_renderer.draw_rounded_rectangle( + draw, (left_margin, content_start_y), (button_width, button_height), 20, + (0, 140, 210, 180), (255, 255, 255, 255), 1 + ) + + # 绘制按钮文字 + button_text_x = left_margin + 20 + button_text_y = content_start_y + (button_height - 30) // 2 # 调整垂直居中 + draw.text((button_text_x, button_text_y), button_text, + font=button_font, fill=(255, 255, 255)) + + # 内容列表 - 与海洋模板完全一致的动态行距计算 + content_font = self.text_renderer.load_font(28) # 从22增大到28 + content_items = ocean_info.get("content_items", []) + content_list_start_y = content_start_y + button_height + 20 # 从15增大到20 + + # 计算内容区域可用高度(需要知道footer位置) + # 这里使用一个估算值,实际应该传入footer_y参数 + canvas_height = self.height + bottom_margin = 30 + footer_y = canvas_height - bottom_margin + remarks_y = footer_y - 15 + + # 计算内容区域可用高度(从按钮下方到remarks_y上方) + available_height = remarks_y - content_list_start_y - 20 # 20是底部边距 + + # 根据内容项数量动态计算行距(与海洋模板完全一致) + if len(content_items) > 0: + # 基础行距 + min_line_spacing = 8 # 从5增大到8 + max_line_spacing = 25 # 从20增大到25 + + # 计算每项内容的平均高度 + content_item_height = 28 + min_line_spacing # 28是字体大小 + total_items_height = len(content_items) * content_item_height + + # 计算额外可分配的空间 + extra_space = max(0, available_height - total_items_height) + + # 每项内容可以额外分配的空间 + extra_per_item = min(max_line_spacing - min_line_spacing, + extra_space / max(1, len(content_items) - 1)) + + # 最终行距 + content_line_spacing = min_line_spacing + extra_per_item + else: + content_line_spacing = 12 # 从10增大到12 + + content_line_height = 28 + content_line_spacing # 28是字体大小 + bullet_indent = 0 + content_indent = 15 + + for i, item in enumerate(content_items): + item_y = content_list_start_y + i * content_line_height + + # # 项目符号 + # draw.text((left_margin + bullet_indent , item_y), "•", + # font=content_font, fill=(255, 255, 255)) + + # 项目文本 + draw.text((left_margin-5, item_y), item, + font=content_font, fill=(255, 255, 255)) + + def _render_right_column_original(self, draw: ImageDraw.Draw, ocean_info: Dict[str, Any], + content_start_y: int, right_column_x: int, right_margin: int, + footer_y: int, fonts: Dict, font_path: str) -> None: + """渲染右栏内容(原版逻辑,与海洋模板完全一致)""" + right_column_width = right_margin - right_column_x + + # 价格 + price_text = ocean_info['price'] + price_suffix = "CNY起" + price_target_width = int(right_column_width * 0.7) + price_size, price_width = self._calculate_optimal_font_size_simple( + price_text, font_path, price_target_width, max_size=120, min_size=40 + ) + price_font = self.text_renderer.load_font(price_size) + + # 货币符号 + currency_font_size = int(price_size * 0.3) + currency_font = self.text_renderer.load_font(currency_font_size) + try: + currency_bbox = currency_font.getbbox(price_suffix) + currency_width = currency_bbox[2] - currency_bbox[0] + except: + currency_width = 30 + + # 价格位置(右对齐) + total_width = price_width + currency_width + price_x = right_margin - total_width + price_y = content_start_y + + # 渲染价格 + self.text_renderer.draw_text_with_shadow( + draw, (price_x, price_y), price_text, price_font, + text_color=(255, 255, 255, 255), + shadow_color=(0, 0, 0, 150), + shadow_offset=(2, 2) + ) + + # 渲染货币符号 + try: + price_bbox = price_font.getbbox(price_text) + price_height = price_bbox[3] - price_bbox[1] + currency_y = price_y + price_height - currency_bbox[3] + draw.text((price_x + price_width, currency_y), price_suffix, + font=currency_font, fill=(255, 255, 255)) + except: + price_height = price_size + + # 票种 + ticket_text = ocean_info["ticket_type"] + ticket_target_width = int(right_column_width * 0.7) + ticket_size, ticket_width = self._calculate_optimal_font_size_simple( + ticket_text, font_path, ticket_target_width, max_size=60, min_size=30 + ) + ticket_font = self.text_renderer.load_font(ticket_size) + + ticket_x = right_margin - ticket_width + try: + price_bbox = price_font.getbbox(price_text) + price_height = price_bbox[3] - price_bbox[1] + ticket_y = price_y + price_height + 35 + except: + ticket_y = price_y + 90 + + self.text_renderer.draw_text_with_shadow( + draw, (ticket_x, ticket_y), ticket_text, ticket_font, + text_color=(255, 255, 255, 255), + shadow_color=(0, 0, 0, 150), + shadow_offset=(2, 2) + ) + + # 价格下划线 + try: + underline_y = price_y + price_height + 18 + line_start_x = price_x - 10 + line_end_x = price_x + price_width + currency_width + draw.line([(line_start_x, underline_y), (line_end_x, underline_y)], + fill=(255, 255, 255, 80), width=2) + except: + pass + + # 备注(与海洋模板完全一致的逻辑) + remarks = ocean_info.get("remarks", []) + if remarks: + if isinstance(remarks, str): + remarks = [remarks] + + remarks_font = self.text_renderer.load_font(16) + try: + ticket_bbox = ticket_font.getbbox(ticket_text) + ticket_height = ticket_bbox[3] - ticket_bbox[1] + remarks_y = ticket_y + ticket_height + 30 # 增大与票种的间距,从15到30 + except: + remarks_y = ticket_y + 60 + + # 渲染每一行备注,右对齐(与海洋模板完全一致) + for i, remark in enumerate(remarks): + try: + remarks_bbox = remarks_font.getbbox(remark) + remarks_width = remarks_bbox[2] - remarks_bbox[0] + remarks_x = right_margin - remarks_width # 右对齐 + line_y = remarks_y + i * (16 + 5) # 16是字体大小,5是行距 + draw.text((remarks_x, line_y), remark, + font=remarks_font, fill=(255, 255, 255, 200)) + except: + continue + + def _load_ocean_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: + """加载海洋主题字体""" + fonts = {} + font_sizes = self.ocean_config['font_sizes'] + + for size_name, size in font_sizes.items(): + fonts[size_name] = self.text_renderer.load_font(size) + + return fonts + + def get_template_info(self) -> Dict[str, Any]: + """获取模板信息""" + return { + "name": "活力模板(海洋模式)", + "version": "1.0.0", + "description": "基于海洋模块的毛玻璃渐变效果,完全兼容原版ocean_info参数结构", + "features": [ + "毛玻璃渐变效果", + "原版两栏布局", + "动态渐变检测", + "精确边距对齐", + "价格展示区域", + "内容项目列表", + "备注和标签支持", + "智能字体大小调整" + ], + "recommended_size": (900, 1200), + "final_size": (1350, 1800), + "style": "海洋活力风格", + "theme_colors": list(self.ocean_config['ocean_colors'].keys()), + "compatible_with": "原版poster_ocean模块" + } + + def validate_inputs(self, **kwargs) -> bool: + """验证输入参数""" + # 检查必需的图片 + image_path = kwargs.get('image_path') + if not image_path or not os.path.exists(image_path): + print("错误: 图片路径无效") + return False + + # 检查ocean_info结构(可选) + ocean_info = kwargs.get('ocean_info') + if ocean_info and not isinstance(ocean_info, dict): + print("警告: ocean_info应该是字典类型") + + return True + + def _apply_glass_effect(self, image: Image.Image, intensity: float = 1.0) -> Image.Image: + """ + 应用毛玻璃效果(与海洋模板完全一致的算法) + + Args: + image: 输入图像 + intensity: 毛玻璃强度 (0.0-3.0) + + Returns: + 应用毛玻璃效果后的图像 + """ + if intensity <= 0: + return image + + # 与海洋模板完全一致的强度计算 + # 基础模糊半径 + base_blur_radius = 2.0 + + # 根据强度计算实际模糊半径 + # intensity 1.0 -> radius 2.0 + # intensity 2.0 -> radius 4.0 + # intensity 3.0 -> radius 6.0 + blur_radius = base_blur_radius * intensity + + # 限制最大模糊半径防止过度模糊 + max_blur_radius = 8.0 + blur_radius = min(blur_radius, max_blur_radius) + + print(f"应用毛玻璃效果,强度: {intensity:.1f}, 模糊半径: {blur_radius:.1f}") + + try: + # 应用高斯模糊 + blurred = image.filter(ImageFilter.GaussianBlur(radius=blur_radius)) + + # 与海洋模板一致的亮度调整 + # 轻微降低亮度以模拟毛玻璃的半透明效果 + brightness_factor = 1.0 - (intensity * 0.05) # 最多降低15%亮度 + brightness_factor = max(0.85, brightness_factor) # 确保不会过暗 + + enhancer = ImageEnhance.Brightness(blurred) + result = enhancer.enhance(brightness_factor) + + print(f"毛玻璃效果应用完成,亮度调整系数: {brightness_factor:.2f}") + return result + + except Exception as e: + print(f"毛玻璃效果应用失败: {e}") + return image + +class BusinessTemplate(BaseTemplate): + """商务风格海报模板(基于酒店模块)""" + + def __init__(self, size: Tuple[int, int] = (900, 1200)): + """ + 初始化商务模板 + + Args: + size: 海报尺寸,默认为酒店模块的尺寸 (900, 1200) + """ + super().__init__(size) + + # 酒店模块原版配置 + self.config = { + 'total_parts': 4.0, # 1 + 2 + 1 的布局比例 + 'center_pure_height_ratio': 0.1, # 中心纯色区域高度比例 + 'text_area_start_ratio': 0.1, # 文本区域起始位置(图像高度的1/5) + 'standard_margin': 30, # 标准间距 + 'transparent_ratio': 0.5, # 透明度效果比例 + # 新增:活力模板的动态分布配置 + 'dynamic_spacing': { + 'min_line_spacing': 8, # 最小行距 + 'max_line_spacing': 25, # 最大行距 + 'content_margin': 20, # 内容边距 + 'section_spacing': 35, # 区段间距 + 'bottom_reserve': 40 # 底部保留空间 + } + } + + # 预定义颜色主题 - 更新为现代高端配色 + self.color_themes = { + "modern_blue": [(25, 52, 85), (65, 120, 180)], # 深蓝到亮蓝,更现代 + "warm_sunset": [(45, 25, 20), (180, 100, 60)], # 暖色调,更柔和 + "fresh_green": [(15, 45, 25), (90, 140, 80)], # 清新绿色 + "deep_ocean": [(20, 40, 70), (70, 140, 200)], # 深海蓝 + "elegant_purple": [(35, 25, 55), (120, 90, 160)], # 优雅紫色 + "classic_gray": [(30, 35, 40), (120, 130, 140)], # 经典灰色 + "premium_gold": [(60, 50, 30), (160, 140, 100)], # 高端金色 + "tech_gradient": [(20, 30, 50), (80, 100, 140)] # 科技感配色 + } + + def generate(self, + top_image_path: str, + bottom_image_path: str, + small_image_paths: Optional[List[str]] = None, + hotel_info: Optional[Dict[str, Any]] = None, + color_theme: Optional[str] = None, + output_path: str = "business_poster.png", + **kwargs) -> Image.Image: + """ + 生成商务海报 + + Args: + top_image_path: 顶部图像路径 + bottom_image_path: 底部图像路径 + small_image_paths: 小图像路径列表(可选) + hotel_info: 酒店信息字典 + color_theme: 颜色主题名称(可选) + output_path: 输出路径 + **kwargs: 其他参数 + + Returns: + 生成的海报图像 + """ + try: + # 使用默认信息如果未提供 + if hotel_info is None: + hotel_info = self._get_default_hotel_info() + + # 1. 加载和处理图像 + top_img = Image.open(top_image_path) + bottom_img = Image.open(bottom_image_path) + + # 2. 调整图像大小 + top_img = self._resize_image(top_img, self.size[0]) + bottom_img = self._resize_image(bottom_img, self.size[0]) + + print(f"调整后图像尺寸: 宽度={self.size[0]}") + + # 3. 提取主要颜色 + if color_theme and color_theme in self.color_themes: + top_color, bottom_color = self.color_themes[color_theme] + print(f"使用预设主题 '{color_theme}' 的颜色") + else: + top_color = self._extract_dominant_color_edge(top_img) + bottom_color = self._extract_dominant_color_edge(bottom_img) + print(f"从图像提取的颜色: 上={top_color}, 下={bottom_color}") + + # 确保颜色和谐 + top_color, bottom_color = self._ensure_colors_harmony(top_color, bottom_color) + + print(f"最终使用的颜色: 上={top_color}, 下={bottom_color}") + + # 4. 创建渐变背景 + base_img = self._create_gradient_background( + self.size[0], self.size[1], top_color, bottom_color + ) + + # 保存背景颜色信息供后续使用 + self._current_background_colors = (top_color, bottom_color) + + # 5. 应用透明度效果 + top_img = self._ensure_rgba(top_img) + bottom_img = self._ensure_rgba(bottom_img) + + print("应用透明度效果") + top_img_with_transparency = self._apply_top_transparency(top_img) + bottom_img_with_transparency = self._apply_bottom_transparency(bottom_img) + + # 6. 计算区域高度 + section_heights = self._calculate_section_heights() + + # 7. 合成图像 + composite_img = self._compose_images_hotel_style( + base_img, + top_img_with_transparency, + bottom_img_with_transparency, + section_heights + ) + + # 8. 添加小图 + # small_img_info = None + # if small_image_paths: + # composite_img, small_img_info = self._add_small_images( + # composite_img, small_image_paths, section_heights + # ) + + # 9. 添加装饰元素(在文本之前) + composite_img = self._add_decorative_elements(composite_img) + + # 10. 创建文本背景卡片 + # text_area = (0, section_heights['middle_start'], self.size[0], section_heights['middle_height']) + # composite_img = self._create_text_background_card(composite_img, text_area) + + # 11. 渲染文本 + composite_img = self._render_hotel_texts_original( + composite_img, hotel_info, None + ) + + # 12. 保存结果 + composite_img.save(output_path) + print(f"商务海报已保存至: {output_path}") + + return composite_img + + except Exception as e: + print(f"生成商务海报时出错: {e}") + # 返回一个基础的错误图像 + error_img = Image.new('RGB', self.size, (128, 128, 128)) + return error_img + + def _get_default_hotel_info(self) -> Dict[str, Any]: + """获取默认酒店信息""" + return { + "name": "商务精选酒店", + "feature": "专业商务服务 | 高端品质体验", + "slogan": "为您的商务之旅提供完美住宿体验", + "price": "1288", + "info_list": [ + "【住】商务套房2晚(可拆分使用)", + "【食】每日精致商务早餐", + "【服务】24小时商务中心服务" + ], + "footer": [ + "预订时间:即日起-2025年5月31日", + "入住时间:即日起-2025年6月30日", + "注:节假日可能需要补差价,具体以预订页面为准" + ] + } + + def _extract_dominant_color_edge(self, image: Image.Image) -> Tuple[int, int, int]: + """ + 从图像边缘提取主要颜色(酒店模块方法) + + Args: + image: 输入图像 + + Returns: + 提取的RGB颜色元组 + """ + # 转换为RGB模式 + if image.mode != 'RGB': + image = image.convert('RGB') + + width, height = image.size + pixels = [] + + # 边缘优先采样 + edge_width = min(width, height) // 4 + sample_size = 200 + + # 顶部边缘 + for y in range(0, edge_width): + for x in range(0, width, width // (sample_size // 4)): + if x < width and len(pixels) < sample_size: + pixel = image.getpixel((x, y)) + if sum(pixel) > 50 and sum(pixel) < 700: + pixels.append(pixel) + + # 底部边缘 + for y in range(height - edge_width, height): + for x in range(0, width, width // (sample_size // 4)): + if x < width and len(pixels) < sample_size: + pixel = image.getpixel((x, y)) + if sum(pixel) > 50 and sum(pixel) < 700: + pixels.append(pixel) + + # 左边缘 + for x in range(0, edge_width): + for y in range(0, height, height // (sample_size // 4)): + if y < height and len(pixels) < sample_size: + pixel = image.getpixel((x, y)) + if sum(pixel) > 50 and sum(pixel) < 700: + pixels.append(pixel) + + # 右边缘 + for x in range(width - edge_width, width): + for y in range(0, height, height // (sample_size // 4)): + if y < height and len(pixels) < sample_size: + pixel = image.getpixel((x, y)) + if sum(pixel) > 50 and sum(pixel) < 700: + pixels.append(pixel) + + # 如果没有采样到合适的像素,返回默认颜色 + if not pixels: + return (80, 120, 160) # 默认蓝色 + + # 计算最常见的颜色 + color_counter = Counter(pixels) + color_candidates = color_counter.most_common(5) + + # 选择最佳颜色 + best_color = self._select_best_color(color_candidates) + + # 调整颜色 + adjusted_color = self._adjust_color_for_background(best_color) + + return adjusted_color + + def _select_best_color(self, color_candidates: List[Tuple[Tuple[int, int, int], int]]) -> Tuple[int, int, int]: + """选择最佳颜色""" + if not color_candidates: + return (80, 120, 160) + + if len(color_candidates) == 1: + return color_candidates[0][0] + + best_score = -1 + best_color = None + + for color, count in color_candidates: + r, g, b = color + + # 计算亮度和饱和度 + brightness = (r * 299 + g * 587 + b * 114) / 1000 + max_c = max(r, g, b) + min_c = min(r, g, b) + saturation = (max_c - min_c) / max_c if max_c > 0 else 0 + + # 计算分数 + brightness_score = 1.0 - abs((brightness - 130) / 130) + saturation_score = 0 + if 0.3 <= saturation <= 0.8: + saturation_score = (saturation - 0.3) / 0.5 + elif saturation > 0.8: + saturation_score = 1.0 - (saturation - 0.8) / 0.2 + else: + saturation_score = saturation / 0.3 + + score = brightness_score * 0.4 + saturation_score * 0.6 + + if score > best_score: + best_score = score + best_color = color + + return best_color or color_candidates[0][0] + + def _adjust_color_for_background(self, color: Tuple[int, int, int]) -> Tuple[int, int, int]: + """调整颜色使其更适合作为背景""" + r, g, b = color + + # 计算亮度和饱和度 + brightness = (r * 299 + g * 587 + b * 114) / 1000 + max_c = max(r, g, b) + min_c = min(r, g, b) + saturation = (max_c - min_c) / max_c if max_c > 0 else 0 + + # 调整亮度 + target_brightness = 120 + brightness_factor = target_brightness / brightness if brightness > 0 else 1 + brightness_factor = max(0.7, min(1.3, brightness_factor)) + + # 调整饱和度 + if saturation > 0.6: + saturation_factor = 0.85 + elif saturation < 0.2: + saturation_factor = 1.3 + else: + saturation_factor = 1.0 + + # 应用调整 + adjusted_r = max(0, min(255, int(r * brightness_factor))) + adjusted_g = max(0, min(255, int(g * brightness_factor))) + adjusted_b = max(0, min(255, int(b * brightness_factor))) + + # 应用饱和度调整 + if saturation_factor != 1.0: + avg = (adjusted_r + adjusted_g + adjusted_b) / 3 + adjusted_r = int(avg + (adjusted_r - avg) * saturation_factor) + adjusted_g = int(avg + (adjusted_g - avg) * saturation_factor) + adjusted_b = int(avg + (adjusted_b - avg) * saturation_factor) + + adjusted_r = max(0, min(255, adjusted_r)) + adjusted_g = max(0, min(255, adjusted_g)) + adjusted_b = max(0, min(255, adjusted_b)) + + return (adjusted_r, adjusted_g, adjusted_b) + + def _ensure_colors_harmony(self, top_color: Tuple[int, int, int], + bottom_color: Tuple[int, int, int]) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]: + """确保颜色和谐""" + def color_distance(c1, c2): + return sum(abs(a - b) for a, b in zip(c1, c2)) + + # 计算颜色差异 + color_diff = color_distance(top_color, bottom_color) + top_brightness = sum(top_color) / 3 + bottom_brightness = sum(bottom_color) / 3 + brightness_diff = abs(top_brightness - bottom_brightness) + + # 如果颜色差异太小,调整以增加对比度 + if color_diff < 30 or brightness_diff < 10: + if top_brightness > bottom_brightness: + factor_top = 1.1 + factor_bottom = 0.9 + else: + factor_top = 0.9 + factor_bottom = 1.1 + + top_color = tuple(max(0, min(255, int(c * factor_top))) for c in top_color) + bottom_color = tuple(max(0, min(255, int(c * factor_bottom))) for c in bottom_color) + + # 如果颜色差异过大,适当减小差异 + elif color_diff > 150 or brightness_diff > 150: + mid_r = (top_color[0] + bottom_color[0]) // 2 + mid_g = (top_color[1] + bottom_color[1]) // 2 + mid_b = (top_color[2] + bottom_color[2]) // 2 + + top_color = ( + int(top_color[0] * 0.8 + mid_r * 0.2), + int(top_color[1] * 0.8 + mid_g * 0.2), + int(top_color[2] * 0.8 + mid_b * 0.2) + ) + + bottom_color = ( + int(bottom_color[0] * 0.8 + mid_r * 0.2), + int(bottom_color[1] * 0.8 + mid_g * 0.2), + int(bottom_color[2] * 0.8 + mid_b * 0.2) + ) + + return top_color, bottom_color + + def _calculate_section_heights(self) -> Dict[str, int]: + """ + 计算各区域高度(1:2:1的比例) + + Returns: + 包含各区域高度的字典 + """ + total_height = self.size[1] + total_parts = self.config['total_parts'] # 1 + 2 + 1 + + top_section_height = int(total_height / total_parts) # 1/4 + middle_section_height = int(total_height * 2 / total_parts) # 2/4 + bottom_section_height = int(total_height / total_parts) # 1/4 + + # 确保总高度为预期值 + remaining_height = total_height - (top_section_height + middle_section_height + bottom_section_height) + middle_section_height += remaining_height + + return { + 'top_height': top_section_height, + 'middle_height': middle_section_height, + 'bottom_height': bottom_section_height, + 'top_end': top_section_height, + 'middle_start': top_section_height, + 'middle_end': top_section_height + middle_section_height, + 'bottom_start': top_section_height + middle_section_height, + 'total_height': total_height + } + + def _apply_top_transparency(self, image: Image.Image) -> Image.Image: + """对上部图像应用透明度效果""" + if image.mode != 'RGBA': + image = self._ensure_rgba(image) + + img_width, img_height = image.size + temp_img = Image.new('RGBA', (img_width, img_height), (0, 0, 0, 0)) + temp_img.paste(image, (0, 0), image if image.mode == 'RGBA' else None) + + img_array = np.array(temp_img) + transparent_ratio = self.config['transparent_ratio'] + transparent_start = int(img_height * (1 - transparent_ratio)) + + for y in range(transparent_start, img_height): + relative_position = (y - transparent_start) / (img_height - transparent_start) + alpha_factor = relative_position * relative_position * 3 + + for x in range(img_width): + original_color = img_array[y, x] + if len(original_color) == 4: + original_alpha = original_color[3] + new_alpha = max(0, min(255, int(original_alpha * (1 - alpha_factor)))) + else: + new_alpha = max(0, min(255, int(255 * (1 - alpha_factor)))) + + img_array[y, x][3] = new_alpha + + return Image.fromarray(img_array) + + def _apply_bottom_transparency(self, image: Image.Image) -> Image.Image: + """对下部图像应用透明度效果""" + if image.mode != 'RGBA': + image = self._ensure_rgba(image) + + img_width, img_height = image.size + temp_img = Image.new('RGBA', (img_width, img_height), (0, 0, 0, 0)) + temp_img.paste(image, (0, 0), image if image.mode == 'RGBA' else None) + + img_array = np.array(temp_img) + transparent_ratio = self.config['transparent_ratio'] + transparent_end = int(img_height * transparent_ratio) + + for y in range(0, transparent_end): + relative_position = 1.0 - (y / transparent_end) + alpha_factor = relative_position * relative_position * 3 + + for x in range(img_width): + original_color = img_array[y, x] + if len(original_color) == 4: + original_alpha = original_color[3] + new_alpha = max(0, min(255, int(original_alpha * (1 - alpha_factor)))) + else: + new_alpha = max(0, min(255, int(255 * (1 - alpha_factor)))) + + img_array[y, x][3] = new_alpha + + return Image.fromarray(img_array) + + def _compose_images_hotel_style(self, base_img: Image.Image, + top_img: Image.Image, + bottom_img: Image.Image, + section_heights: Dict[str, int]) -> Image.Image: + """ + 按照酒店模块风格合成图像 + + Args: + base_img: 基础背景图像 + top_img: 处理后的顶部图像 + bottom_img: 处理后的底部图像 + section_heights: 区域高度信息 + + Returns: + 合成后的图像 + """ + width = self.size[0] + height = self.size[1] + + # 获取图像尺寸 + top_img_width, top_img_height = top_img.size + bottom_img_width, bottom_img_height = bottom_img.size + + # 计算图像位置(水平居中) + top_x_pos = (width - top_img_width) // 2 + top_y_pos = 0 + + bottom_x_pos = (width - bottom_img_width) // 2 + bottom_y_pos = height - bottom_img_height + + # 粘贴处理后的图像到底板 + base_img.paste(top_img, (top_x_pos, top_y_pos), top_img) + base_img.paste(bottom_img, (bottom_x_pos, bottom_y_pos), bottom_img) + + return base_img + + def _add_small_images(self, canvas: Image.Image, + small_image_paths: List[str], + section_heights: Dict[str, int]) -> Tuple[Image.Image, Optional[Dict]]: + """ + 添加小图像(酒店模块风格) + + Args: + canvas: 画布图像 + small_image_paths: 小图像路径列表 + section_heights: 区域高度信息 + + Returns: + 更新后的画布和小图信息 + """ + if not small_image_paths or len(small_image_paths) < 3: + return canvas, None + + width = self.size[0] + + # 计算文本区域起始位置 + text_area_start_y = int(self.size[1] * self.config['text_area_start_ratio']) + + # 预估上部文本高度(标题+副标题+标语+间距) + estimated_upper_text_height = 200 # 预估值 + + # 小图位置 + small_img_height = 150 + small_img_width = int(width * 0.9) + small_img_y = text_area_start_y + estimated_upper_text_height + self.config['standard_margin'] + + # 处理小图片 + for i, img_path in enumerate(small_image_paths[:3]): + try: + small_img = Image.open(img_path) + single_img_width = int((small_img_width - 40) / 3) + small_img_size = (single_img_width, small_img_height) + small_img = small_img.resize(small_img_size, Image.LANCZOS) + small_img = self._ensure_rgba(small_img) + + # 水平排列三张小图,居中对齐 + start_x = int((width - small_img_width) / 2) + x_pos = start_x + i * (single_img_width + 20) + canvas.paste(small_img, (x_pos, int(small_img_y)), small_img) + except Exception as e: + print(f"处理小图出错: {e}") + + # 返回小图信息 + small_img_info = { + 'y_pos': small_img_y, + 'width': small_img_width, + 'height': small_img_height + } + + return canvas, small_img_info + + def _render_hotel_texts_original(self, canvas: Image.Image, + hotel_info: Dict[str, Any], + small_img_info: Optional[Tuple] = None) -> Image.Image: + """ + 按照酒店模块原版逻辑渲染文本,集成活力模板的动态分布算法 + + Args: + canvas: 画布图像 + hotel_info: 酒店信息 + small_img_info: 小图信息 + + Returns: + 渲染文本后的图像 + """ + draw = ImageDraw.Draw(canvas) + width, height = canvas.size + center_x = width // 2 + + # 加载字体 + font_path = "/root/autodl-tmp/posterGenerator/assets/fonts/兰亭粗黑简.TTF" + + try: + # 1. 计算布局区域 + section_heights = self._calculate_section_heights() + + # 2. 确定文本区域范围(中间无图像区域) + text_start_y = section_heights['top_end'] # 上部图像结束位置 + text_end_y = section_heights['bottom_start'] # 下部图像开始位置 + + print(f"文本区域范围: {text_start_y} - {text_end_y} (高度: {text_end_y - text_start_y})") + + # 3. 预估内容高度(活力模板算法) + estimated_content_height = self._estimate_business_content_height(hotel_info, font_path, width) + print(f"预估内容总高度: {estimated_content_height}") + + # 4. 计算可用空间和动态间距 + available_height = text_end_y - text_start_y - self.config['dynamic_spacing']['content_margin'] * 2 + print(f"可用文本高度: {available_height}") + + # 5. 动态调整布局参数 + layout_params = self._calculate_dynamic_layout_params( + estimated_content_height, available_height + ) + print(f"动态布局参数: {layout_params}") + + # 6. 开始渲染文本内容 + current_y = text_start_y + self.config['dynamic_spacing']['content_margin'] + available_width = int(width * 0.9) + margin_x = (width - available_width) // 2 + + # 渲染标题 + current_y = self._render_hotel_title_dynamic( + draw, hotel_info["name"], current_y, center_x, width, font_path, layout_params + ) + + # 渲染特色描述 + current_y = self._render_hotel_feature_dynamic( + draw, hotel_info["feature"], current_y, center_x, width, available_width, + font_path, layout_params + ) + + # 渲染信息区域(使用活力模板的动态分布算法) + current_y = self._render_hotel_info_section_dynamic( + draw, hotel_info, current_y, margin_x + 10, available_width, + font_path, layout_params, text_end_y + ) + + except Exception as e: + print(f"渲染文本时出错: {e}") + + return canvas + + def _estimate_business_content_height(self, hotel_info: Dict[str, Any], + font_path: str, width: int) -> int: + """ + 预估商务模板内容高度(基于活力模板算法) + + Args: + hotel_info: 酒店信息 + font_path: 字体路径 + width: 画布宽度 + + Returns: + 预估的内容总高度 + """ + # 基础间距配置 + standard_margin = self.config['dynamic_spacing']['section_spacing'] + + # 1. 标题高度估算 + title_text = hotel_info["name"] + title_target_width = int(width * 0.85) + title_size = self._estimate_font_size(title_text, font_path, title_target_width, max_size=80) + title_height = int(title_size * 1.2) # 字体高度 + 行距 + + # 2. 特色描述高度估算 + feature_text = hotel_info["feature"] + feature_target_width = int(width * 0.6) + feature_size = self._estimate_font_size(feature_text, font_path, feature_target_width, max_size=40) + feature_height = int(feature_size * 1.5) + 50 # 包含背景框高度 + + # 3. 信息列表高度估算 + info_list = hotel_info.get("info_list", []) + info_line_height = 35 # 基础行高 + info_list_height = len(info_list) * info_line_height + + # 4. 价格区域高度估算 + price_height = 80 + + # 计算总高度 + total_height = ( + 20 + # 初始顶部边距 + title_height + standard_margin + # 标题 + feature_height + standard_margin + # 特色描述 + info_list_height + # 信息列表 + price_height + # 价格区域 + self.config['dynamic_spacing']['bottom_reserve'] # 底部保留空间 + ) + + return total_height + + def _estimate_font_size(self, text: str, font_path: str, target_width: int, + max_size: int = 120, min_size: int = 10) -> int: + """快速估算字体大小""" + # 简化的字体大小估算,避免创建实际字体对象 + char_count = len(text) + if char_count == 0: + return min_size + + # 基于字符数量和目标宽度的粗略估算 + estimated_size = int(target_width / (char_count * 0.6)) + return max(min_size, min(max_size, estimated_size)) + + def _calculate_dynamic_layout_params(self, estimated_height: int, + available_height: int) -> Dict[str, Any]: + """ + 计算动态布局参数(活力模板核心算法) + + Args: + estimated_height: 预估内容高度 + available_height: 可用空间高度 + + Returns: + 布局参数字典 + """ + spacing_config = self.config['dynamic_spacing'] + + # 计算空间利用率 + space_ratio = estimated_height / available_height if available_height > 0 else 1.0 + + print(f"空间利用率: {space_ratio:.2f}") + + if space_ratio <= 0.8: + # 空间充足,使用较大间距 + line_spacing_factor = 1.2 + section_spacing_factor = 1.3 + comfort_level = "spacious" + elif space_ratio <= 1.0: + # 空间适中,使用标准间距 + line_spacing_factor = 1.0 + section_spacing_factor = 1.0 + comfort_level = "normal" + else: + # 空间紧张,使用较小间距 + line_spacing_factor = 0.8 + section_spacing_factor = 0.7 + comfort_level = "compact" + + # 计算动态间距 + dynamic_line_spacing = int(spacing_config['min_line_spacing'] + + (spacing_config['max_line_spacing'] - spacing_config['min_line_spacing']) * + line_spacing_factor) + + dynamic_section_spacing = int(spacing_config['section_spacing'] * section_spacing_factor) + + return { + 'line_spacing': dynamic_line_spacing, + 'section_spacing': dynamic_section_spacing, + 'comfort_level': comfort_level, + 'space_ratio': space_ratio, + 'line_spacing_factor': line_spacing_factor + } + + def _render_hotel_title_dynamic(self, draw: ImageDraw.Draw, title: str, y: int, + center_x: int, width: int, font_path: str, + layout_params: Dict[str, Any]) -> int: + """渲染酒店标题(动态间距版本)""" + title_target_width = int(width * 0.85) + font_size, title_font = self._calculate_optimal_font_size( + title, font_path, title_target_width, min_size=20 + ) + + # 计算文本位置 + bbox = title_font.getbbox(title) + title_width = bbox[2] - bbox[0] + title_height = bbox[3] - bbox[1] + title_x = center_x - title_width // 2 + + # 渲染标题(带描边效果) + self._add_text_with_outline( + draw, (title_x, y), title, title_font, + text_color=(255, 240, 200), + outline_color=(0, 0, 0, 200), + outline_width=2 + ) + + # 使用动态间距 + return y + title_height + layout_params['section_spacing'] + + def _render_hotel_feature_dynamic(self, draw: ImageDraw.Draw, feature: str, y: int, + center_x: int, width: int, available_width: int, + font_path: str, layout_params: Dict[str, Any]) -> int: + """渲染特色描述(动态间距版本,智能颜色选择)""" + subtitle_target_width = int(width * 0.6) + font_size, subtitle_font = self._calculate_optimal_font_size( + feature, font_path, subtitle_target_width, max_size=60, min_size=16 + ) + + bbox = subtitle_font.getbbox(feature) + subtitle_width = bbox[2] - bbox[0] + subtitle_x = center_x - subtitle_width // 2 + + # 添加圆角背景 + subtitle_bg_padding = 15 + subtitle_bg_height = 50 + subtitle_bg_width = min(subtitle_width + subtitle_bg_padding * 2, available_width) + + self._add_rounded_rectangle( + draw, + (center_x - subtitle_bg_width // 2, y - 5), + (subtitle_bg_width, subtitle_bg_height), + radius=20, + fill_color=(50, 50, 50, 180) + ) + + # 智能选择feature文本颜色 + if hasattr(self, '_current_background_colors'): + feature_color = self._get_smart_feature_color(self._current_background_colors) + else: + feature_color = (255, 255, 255) # 默认白色 + + # 渲染文字 + draw.text((subtitle_x, y), feature, font=subtitle_font, fill=feature_color) + + # 使用动态间距 + return y + subtitle_bg_height + layout_params['section_spacing'] + + def _render_hotel_info_section_dynamic(self, draw: ImageDraw.Draw, hotel_info: Dict[str, Any], + y: int, left_align_x: int, available_width: int, + font_path: str, layout_params: Dict[str, Any], + max_y: int) -> int: + """ + 渲染信息区域(集成活力模板的动态分布算法,添加info|price分隔线) + + Args: + draw: 绘图对象 + hotel_info: 酒店信息 + y: 起始Y坐标 + left_align_x: 左对齐X坐标 + available_width: 可用宽度 + font_path: 字体路径 + layout_params: 布局参数 + max_y: 最大Y坐标(不能超过此位置) + + Returns: + 渲染后的Y坐标 + """ + # 计算剩余可用高度 + remaining_height = max_y - y - self.config['dynamic_spacing']['bottom_reserve'] + + print(f"信息区域可用高度: {remaining_height}") + + if remaining_height <= 0: + print("警告: 信息区域可用高度不足") + return y + + # 获取信息列表 + info_texts = hotel_info.get("info_list", []) + if not info_texts: + return y + + # 计算字体大小 + longest_info = max(info_texts, key=len) + info_target_width = int(available_width * 0.55) + font_size, info_font = self._calculate_optimal_font_size( + longest_info, font_path, info_target_width, max_size=30, min_size=14 + ) + + # 计算价格字体大小 - 增大尺寸 + price_text = f"¥{hotel_info['price']}" + price_target_width = int(available_width * 0.35) # 增加目标宽度 + price_font_size, price_font = self._calculate_optimal_font_size( + price_text, font_path, price_target_width, max_size=200, min_size=60 # 增大字体范围 + ) + + # 计算CNY标识符字体大小 + cny_text = "CNY" + cny_font_size = price_font_size // 4 * 3 + cny_font = ImageFont.truetype(font_path, cny_font_size) + + # 获取文本高度 + info_bbox = info_font.getbbox(longest_info) + info_line_height = info_bbox[3] - info_bbox[1] + + price_bbox = price_font.getbbox(price_text) + price_height = price_bbox[3] - price_bbox[1] + + cny_bbox = cny_font.getbbox(cny_text) + cny_height = cny_bbox[3] - cny_bbox[1] + + # 使用活力模板的动态行距算法 + info_count = len(info_texts) + if info_count > 0: + # 基础行距配置 + min_line_spacing = layout_params['line_spacing'] + max_line_spacing = layout_params['line_spacing'] + 15 + + # 计算基础内容高度 + base_content_height = info_count * info_line_height + + # 计算额外可分配空间 + extra_space = max(0, remaining_height - base_content_height - price_height - cny_height - 30) + + # 每行额外分配的空间 + if info_count > 1: + extra_per_line = min(max_line_spacing - min_line_spacing, + extra_space / (info_count - 1)) + else: + extra_per_line = 0 + + # 最终行距 + final_line_spacing = min_line_spacing + extra_per_line + + print(f"动态行距计算: 基础={min_line_spacing}, 额外={extra_per_line:.1f}, 最终={final_line_spacing:.1f}") + else: + final_line_spacing = layout_params['line_spacing'] + + # 渲染信息列表 - 左侧对齐,使用动态行距 + info_y = y + for i, info in enumerate(info_texts): + current_line_y = info_y + i * (info_line_height + final_line_spacing) + draw.text( + (left_align_x, int(current_line_y)), + info, font=info_font, fill=(255, 255, 255) + ) + + # 计算info区域的结束位置 + info_end_y = info_y + info_count * (info_line_height + final_line_spacing) + + # 计算价格位置(右侧对齐,与信息顶部对齐) + price_width = price_bbox[2] - price_bbox[0] + right_margin = left_align_x + available_width - 10 + price_x = right_margin - price_width + + # 价格与信息列表顶部对齐 + price_y = info_y + + # 计算分隔线位置和样式 + divider_x = left_align_x + available_width * 0.6 # 分隔线位置 + divider_top_y = info_y - 10 # 分隔线顶部 + divider_bottom_y = max(info_end_y, price_y + price_height + cny_height + 15) - 10 # 分隔线底部 + divider_height = divider_bottom_y - divider_top_y + + # 智能选择分隔线颜色 + if hasattr(self, '_current_background_colors'): + divider_color = self._get_smart_feature_color(self._current_background_colors) + else: + divider_color = (255, 255, 255) + + # 绘制主分隔线 - 改为虚线效果 + line_width = 2 + dash_length = 8 # 虚线段长度 + gap_length = 4 # 间隔长度 + + # 计算虚线段数量 + total_length = divider_bottom_y - divider_top_y + segment_length = dash_length + gap_length + num_segments = int(total_length / segment_length) + + # XXX: 关闭了划线 + # # 绘制虚线 + # for i in range(num_segments + 1): + # dash_start_y = divider_top_y + i * segment_length + # dash_end_y = min(dash_start_y + dash_length, divider_bottom_y) + + # if dash_start_y < divider_bottom_y: + # draw.rectangle([ + # divider_x - line_width // 2, dash_start_y, + # divider_x + line_width // 2, dash_end_y + # ], fill=divider_color + (150,)) # 半透明 + + # # 添加点划线装饰(在虚线两侧) + # dot_size = 1 + # dot_spacing = 12 + # side_offset = 8 + + # # 左侧点线 + # for i in range(0, int(total_length), dot_spacing): + # dot_y = divider_top_y + i + # if dot_y < divider_bottom_y: + # draw.ellipse([ + # divider_x - side_offset - dot_size, dot_y - dot_size, + # divider_x - side_offset + dot_size, dot_y + dot_size + # ], fill=divider_color + (100,)) + + # # 右侧点线 + # for i in range(0, int(total_length), dot_spacing): + # dot_y = divider_top_y + i + # if dot_y < divider_bottom_y: + # draw.ellipse([ + # divider_x + side_offset - dot_size, dot_y - dot_size, + # divider_x + side_offset + dot_size, dot_y + dot_size + # ], fill=divider_color + (100,)) + + # # 添加中心装饰元素 + # mid_y = (divider_top_y + divider_bottom_y) // 2 + + # # 中心小圆形 + # center_dot_size = 3 + # draw.ellipse([ + # divider_x - center_dot_size, mid_y - center_dot_size, + # divider_x + center_dot_size, mid_y + center_dot_size + # ], fill=divider_color + (200,)) + + # # 中心周围的小装饰点 + # small_dot_size = 1 + # decoration_radius = 8 + # for angle in [0, 45, 90, 135, 180, 225, 270, 315]: # 8个方向 + # import math + # angle_rad = math.radians(angle) + # decoration_x = divider_x + decoration_radius * math.cos(angle_rad) + # decoration_y = mid_y + decoration_radius * math.sin(angle_rad) + + # draw.ellipse([ + # decoration_x - small_dot_size, decoration_y - small_dot_size, + # decoration_x + small_dot_size, decoration_y + small_dot_size + # ], fill=divider_color + (120,)) + + # 渲染价格 + self._add_text_with_shadow( + draw, (price_x, int(price_y)), price_text, price_font, + text_color=(255, 255, 255, 255), shadow_color=(0, 0, 0, 150), + shadow_offset=(2, 2) + ) + + # 计算CNY位置(在价格下方,右对齐) + cny_width = cny_bbox[2] - cny_bbox[0] + cny_x = right_margin - cny_width + cny_y = price_y + price_height + 15 # 价格下方15像素间距 + + # 渲染CNY标识符 + self._add_text_with_shadow( + draw, (cny_x, int(cny_y)), cny_text, cny_font, + text_color=(250, 250, 210, 255), shadow_color=(0, 0, 0, 120), + shadow_offset=(1, 1) + ) + + # 返回最终位置 + info_section_height = info_count * (info_line_height + final_line_spacing) + price_section_height = price_height + cny_height + 15 # 价格区域总高度 + final_y = max(info_y + info_section_height, price_y + price_section_height) + + print(f"信息区域渲染完成: 起始Y={y}, 结束Y={final_y:.1f}, 使用高度={final_y - y:.1f}") + print(f"价格字体大小: {price_font_size}, CNY字体大小: {cny_font_size}") + print(f"分隔线位置: x={divider_x}, 高度={divider_height}") + + return int(final_y) + + def get_template_info(self) -> Dict[str, Any]: + """获取模板信息""" + return { + "name": "商务模板", + "description": "基于酒店模块的商务风格海报模板", + "version": "2.0.0", + "author": "PosterGenerator", + "features": [ + "1:2:1布局比例", + "双图像透明度融合", + "小图支持", + "智能颜色提取", + "文本自适应布局" + ], + "supported_formats": ["PNG", "JPEG"], + "default_size": self.size, + "required_params": ["top_image_path", "bottom_image_path"], + "optional_params": ["small_image_paths", "hotel_info", "color_theme"] + } + + def validate_inputs(self, **kwargs) -> bool: + """验证输入参数""" + required_params = ["top_image_path", "bottom_image_path"] + + for param in required_params: + if param not in kwargs or not kwargs[param]: + print(f"缺少必要参数: {param}") + return False + + # 检查文件是否存在 + if not os.path.exists(kwargs[param]): + print(f"文件不存在: {kwargs[param]}") + return False + + # 检查小图路径(如果提供) + if "small_image_paths" in kwargs and kwargs["small_image_paths"]: + for path in kwargs["small_image_paths"]: + if not os.path.exists(path): + print(f"小图文件不存在: {path}") + return False + + print("输入参数验证通过") + return True + + def get_layout_areas(self) -> Dict[str, Tuple[int, int, int, int]]: + """获取布局区域信息""" + section_heights = self._calculate_section_heights() + width = self.size[0] + + return { + "top_section": (0, 0, width, section_heights['top_height']), + "middle_section": (0, section_heights['middle_start'], width, section_heights['middle_height']), + "bottom_section": (0, section_heights['bottom_start'], width, section_heights['bottom_height']), + "text_area": (0, int(self.size[1] * 0.2), width, int(self.size[1] * 0.6)), + "full_canvas": (0, 0, width, self.size[1]) + } + + # 工具方法 + def _resize_image(self, image: Image.Image, target_width: int) -> Image.Image: + """调整图像大小,保持原始高宽比""" + orig_aspect = image.width / image.height + return image.resize((target_width, int(target_width / orig_aspect)), Image.LANCZOS) + + def _ensure_rgba(self, image: Image.Image) -> Image.Image: + """确保图像是RGBA模式""" + if image.mode == 'RGBA': + return image + elif image.mode == 'RGB': + rgba_image = Image.new('RGBA', image.size, (0, 0, 0, 0)) + rgba_image.paste(image, (0, 0)) + return rgba_image + else: + return image.convert('RGBA') + + def _create_gradient_background(self, width: int, height: int, + top_color: Tuple[int, int, int], + bottom_color: Tuple[int, int, int]) -> Image.Image: + """创建现代化的多层渐变背景""" + background = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + + # 确保颜色对比度 + top_brightness = sum(top_color) / 3 + bottom_brightness = sum(bottom_color) / 3 + + if abs(top_brightness - bottom_brightness) < 20: + if top_brightness > 128: + top_color = tuple(max(0, c - 50) for c in top_color) + else: + top_color = tuple(min(255, c + 50) for c in top_color) + + # 创建多层渐变效果 + top_color_array = np.array(top_color) + bottom_color_array = np.array(bottom_color) + + # 添加中间过渡色,使渐变更自然 + mid_color_array = (top_color_array + bottom_color_array) / 2 + # 稍微调整中间色的饱和度 + mid_color_array = mid_color_array * 0.9 + np.array([20, 20, 30]) # 增加一点暖色调 + mid_color_array = np.clip(mid_color_array, 0, 255) + + for y in range(height): + ratio = y / height + + # 使用三段式渐变:上部-中部-下部 + if ratio < 0.4: # 上部区域 + smooth_ratio = ratio / 0.4 + # 使用缓动函数让过渡更自然 + smooth_ratio = smooth_ratio * smooth_ratio * (3.0 - 2.0 * smooth_ratio) # smoothstep + color = (1 - smooth_ratio) * top_color_array + smooth_ratio * mid_color_array + else: # 下部区域 + smooth_ratio = (ratio - 0.4) / 0.6 + # 使用不同的缓动函数 + smooth_ratio = 0.5 * (1 + math.sin((smooth_ratio - 0.5) * math.pi)) + color = (1 - smooth_ratio) * mid_color_array + smooth_ratio * bottom_color_array + + # 添加微妙的噪点效果 + noise_factor = (random.random() - 0.5) * 8 # 减小噪点强度 + color = np.clip(color + noise_factor, 0, 255) + + color_tuple = tuple(color.astype(np.uint8)) + (255,) + + for x in range(width): + # 添加径向渐变效果 + center_x, center_y = width // 2, height // 2 + distance_from_center = math.sqrt((x - center_x)**2 + (y - center_y)**2) + max_distance = math.sqrt(center_x**2 + center_y**2) + radial_factor = 1.0 - (distance_from_center / max_distance) * 0.15 # 轻微的径向效果 + + final_color = tuple(int(c * radial_factor) for c in color_tuple[:3]) + (255,) + background.putpixel((x, y), final_color) + + return background + + def _add_text_with_shadow(self, draw: ImageDraw.Draw, position: Tuple[int, int], + text: str, font: ImageFont.FreeTypeFont, + text_color: Tuple[int, int, int] = (255, 255, 255), + shadow_color: Tuple[int, int, int, int] = (0, 0, 0, 150), + shadow_offset: Tuple[int, int] = (2, 2)) -> None: + """添加带阴影的文字""" + shadow_position = (position[0] + shadow_offset[0], position[1] + shadow_offset[1]) + draw.text(shadow_position, text, font=font, fill=shadow_color) + draw.text(position, text, font=font, fill=text_color) + + def _add_text_with_outline(self, draw: ImageDraw.Draw, position: Tuple[int, int], + text: str, font: ImageFont.FreeTypeFont, + text_color: Tuple[int, int, int] = (255, 255, 255), + outline_color: Tuple[int, int, int, int] = (0, 0, 0, 200), + outline_width: int = 2) -> None: + """添加带描边的文字""" + x, y = position + + for offset_x in range(-outline_width, outline_width + 1): + for offset_y in range(-outline_width, outline_width + 1): + if offset_x == 0 and offset_y == 0: + continue + draw.text((x + offset_x, y + offset_y), text, font=font, fill=outline_color) + + draw.text(position, text, font=font, fill=text_color) + + def _add_rounded_rectangle(self, draw: ImageDraw.Draw, position: Tuple[int, int], + size: Tuple[int, int], radius: int, + fill_color: Tuple[int, int, int, int], + outline_color: Optional[Tuple[int, int, int, int]] = None, + outline_width: int = 0) -> None: + """绘制圆角矩形""" + x1, y1 = position + x2, y2 = x1 + size[0], y1 + size[1] + draw.rounded_rectangle([x1, y1, x2, y2], radius=radius, + fill=fill_color, outline=outline_color, width=outline_width) + + def _calculate_optimal_font_size(self, text: str, font_path: str, + target_width: int, max_size: int = 120, + min_size: int = 10) -> int: + """计算最佳字体大小""" + low = min_size + high = max_size + best_size = min_size + best_width = 0 + + while low <= high: + mid = (low + high) // 2 + font = ImageFont.truetype(font_path, mid) + bbox = font.getbbox(text) + width = bbox[2] - bbox[0] + + if width < target_width: + if width > best_width: + best_width = width + best_size = mid + low = mid + 1 + else: + high = mid - 1 + + best_font = ImageFont.truetype(font_path, best_size) + return best_size, best_font + + def _add_decorative_elements(self, canvas: Image.Image) -> Image.Image: + """添加现代化装饰元素""" + width, height = canvas.size + + # 创建装饰层 + overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # 1. 添加顶部装饰线条 + line_y = height // 4 - 20 + gradient_width = width // 3 + start_x = (width - gradient_width) // 2 + + for i in range(gradient_width): + alpha = int(255 * (1 - abs(i - gradient_width//2) / (gradient_width//2)) * 0.3) + draw.line([(start_x + i, line_y), (start_x + i, line_y + 2)], + fill=(255, 255, 255, alpha), width=1) + + # 2. 添加几何装饰 + # 左上角装饰 + corner_size = 80 + corner_alpha = 40 + draw.arc([20, 20, 20 + corner_size, 20 + corner_size], + start=180, end=270, fill=(255, 255, 255, corner_alpha), width=3) + + # 右下角装饰 + draw.arc([width - corner_size - 20, height - corner_size - 20, + width - 20, height - 20], + start=0, end=90, fill=(255, 255, 255, corner_alpha), width=3) + + # 3. 添加中心区域的微妙光效 + center_x, center_y = width // 2, height // 2 + light_radius = 150 + for r in range(light_radius, 0, -5): + alpha = int(10 * (1 - r / light_radius)) + draw.ellipse([center_x - r, center_y - r, center_x + r, center_y + r], + fill=(255, 255, 255, alpha)) + + # 将装饰层合成到原图 + canvas = Image.alpha_composite(canvas, overlay) + return canvas + + def _create_text_background_card(self, canvas: Image.Image, + text_area: Tuple[int, int, int, int]) -> Image.Image: + """为文本区域创建卡片式背景""" + x, y, w, h = text_area + + # 创建卡片层 + card_overlay = Image.new('RGBA', canvas.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(card_overlay) + + # 卡片背景 - 使用磨砂玻璃效果 + card_padding = 40 + card_x = x + card_padding + card_y = y + card_padding + card_w = w - card_padding * 2 + card_h = h - card_padding * 2 + + # 添加阴影 + shadow_offset = 8 + self._add_rounded_rectangle( + draw, + (card_x + shadow_offset, card_y + shadow_offset), + (card_w, card_h), + radius=25, + fill_color=(0, 0, 0, 30) # 阴影 + ) + + # 主卡片背景 + self._add_rounded_rectangle( + draw, + (card_x, card_y), + (card_w, card_h), + radius=25, + fill_color=(255, 255, 255, 25), # 半透明白色 + outline_color=(255, 255, 255, 80), + outline_width=1 + ) + + # 添加内部光效 + inner_glow_size = 20 + self._add_rounded_rectangle( + draw, + (card_x + inner_glow_size, card_y + inner_glow_size), + (card_w - inner_glow_size * 2, card_h - inner_glow_size * 2), + radius=15, + fill_color=(255, 255, 255, 10) + ) + + return Image.alpha_composite(canvas, card_overlay) + + def _get_smart_text_color(self, background_colors: Tuple[Tuple[int, int, int], Tuple[int, int, int]]) -> Tuple[int, int, int]: + """ + 根据背景颜色智能选择文本颜色 + + Args: + background_colors: (top_color, bottom_color) 背景颜色元组 + + Returns: + 最适合的文本颜色 + """ + top_color, bottom_color = background_colors + + # 计算背景的平均亮度 + avg_brightness = ( + (sum(top_color) + sum(bottom_color)) / 2 + ) / 3 + + # 根据亮度选择对比色 + if avg_brightness > 140: # 背景较亮 + # 选择深色文本 + return (45, 55, 75) # 深蓝灰色 + elif avg_brightness > 80: # 背景中等 + # 选择浅色文本 + return (240, 245, 255) # 浅蓝白色 + else: # 背景较暗 + # 选择亮色文本 + return (255, 248, 235) # 暖白色 + + def _get_smart_feature_color(self, background_colors: Tuple[Tuple[int, int, int], Tuple[int, int, int]]) -> Tuple[int, int, int]: + """ + 为feature文本智能选择颜色 + + Args: + background_colors: (top_color, bottom_color) 背景颜色元组 + + Returns: + feature文本的最佳颜色 + """ + top_color, bottom_color = background_colors + return (255, 255, 255) + # 计算背景色的色调特征 + def get_color_tone(color): + r, g, b = color + max_c = max(r, g, b) + if max_c == r: + return 'warm' # 红色调 + elif max_c == g: + return 'fresh' # 绿色调 + else: + return 'cool' # 蓝色调 + + # 获取主导色调 + top_tone = get_color_tone(top_color) + bottom_tone = get_color_tone(bottom_color) + + # 计算平均亮度 + avg_brightness = (sum(top_color) + sum(bottom_color)) / 6 + + # 根据色调和亮度选择feature颜色 + if avg_brightness > 120: # 背景较亮 + if top_tone == 'cool' or bottom_tone == 'cool': + return (65, 105, 155) # 深蓝色 + elif top_tone == 'warm' or bottom_tone == 'warm': + return (155, 85, 65) # 深橙色 + else: + return (85, 125, 85) # 深绿色 + else: # 背景较暗 + if top_tone == 'cool' or bottom_tone == 'cool': + return (135, 185, 235) # 亮蓝色 + elif top_tone == 'warm' or bottom_tone == 'warm': + return (255, 195, 135) # 亮橙色 + else: + return (155, 215, 155) # 亮绿色 + +def preprocess_image(image_path, target_width=900, target_height=1200, crop_position='center'): + """ + 预处理图像:调整大小并裁剪到指定尺寸 + + 参数: + image_path: 图像文件路径 + target_width: 目标宽度 + target_height: 目标高度 + crop_position: 裁剪位置,可选 'center'(中心)、'top'(顶部)、'bottom'(底部) + """ + try: + with Image.open(image_path) as img: + # 获取原始尺寸 + orig_width, orig_height = img.size + print(f"原始图像尺寸: {orig_width}x{orig_height}") + + # 计算宽高比 + orig_ratio = orig_width / orig_height + target_ratio = target_width / target_height + + # 根据宽高比决定如何调整大小和裁剪 + if orig_ratio > target_ratio: + # 图像较宽,按高度缩放后裁剪宽度 + new_height = target_height + new_width = int(orig_width * (target_height / orig_height)) + img_resized = img.resize((new_width, new_height), Image.LANCZOS) + + # 根据指定位置裁剪 + left = 0 + if crop_position == 'center': + left = (new_width - target_width) // 2 + elif crop_position == 'right': + left = new_width - target_width + right = left + target_width + img_cropped = img_resized.crop((left, 0, right, target_height)) + else: + # 图像较高,按宽度缩放后裁剪高度 + new_width = target_width + new_height = int(orig_height * (target_width / orig_width)) + img_resized = img.resize((new_width, new_height), Image.LANCZOS) + + # 根据指定位置裁剪 + top = 0 + if crop_position == 'center': + top = (new_height - target_height) // 2 + elif crop_position == 'bottom': + top = new_height - target_height + bottom = top + target_height + img_cropped = img_resized.crop((0, top, target_width, bottom)) + + # 保存处理后的图像 + processed_path = f"{os.path.splitext(image_path)[0]}_processed.png" + img_cropped.save(processed_path) + print(f"图像已处理并保存: {processed_path}") + return processed_path + except Exception as e: + print(f"图像预处理失败: {e}") + return None + + +def get_random_images(directory: str, count: int = 2): + """从指定目录随机选择图片""" + if not os.path.exists(directory): + print(f"目录不存在: {directory}") + return [] + + image_files = [f for f in os.listdir(directory) + if f.lower().endswith(('.png', '.jpg', '.jpeg'))] + + if len(image_files) < count: + print(f"目录中图片数量不足,需要{count}张,只有{len(image_files)}张") + return [] + + selected = random.sample(image_files, count) + return [os.path.join(directory, f) for f in selected] + + +def test_vibrant_template(): + """测试活力模板""" + print("=" * 50) + print("测试活力模板") + print("=" * 50) + + # 准备图片 - 使用与商务模板相同的图像目录 + picture_dir = "/root/autodl-tmp/posterGenerator/assets/馥桂萌宠园" + images = get_random_images(picture_dir, 1) + + if not images: + print("无法获取足够的图片进行测试") + return + + # 验证图像文件完整性 + try: + with Image.open(images[0]) as img: + # 强制加载图像以验证完整性 + img.load() + print(f"图像验证成功: {images[0]}") + except Exception as e: + print(f"图像文件损坏: {images[0]}, 错误: {e}") + print("尝试查找其他可用图像...") + # 尝试查找更多图像 + all_images = [os.path.join(picture_dir, f) for f in os.listdir(picture_dir) + if f.lower().endswith(('.png', '.jpg', '.jpeg'))] + valid_image = None + for img_path in all_images: + try: + with Image.open(img_path) as img: + img.load() + valid_image = img_path + print(f"找到可用图像: {valid_image}") + break + except: + continue + + if valid_image: + images = [valid_image] + else: + print("无法找到可用的图像文件") + return + + # 预处理图像 + processed_image = preprocess_image(images[0], crop_position='top') + if not processed_image: + print("图像预处理失败,无法继续测试") + return + + # 海洋信息数据 + ocean_info = { + "title": "馥桂萌宠总动员", + "slogan": "30+萌宠零距离互动,泼水派对嗨翻天", + "price": "92", + "ticket_type": "亲子套票", + "content_button": "套餐内容", + "content_items": [ + "1大1小门票(含投喂包)", + "30+萌宠亲密互动体验", + "泼水大战+泡沫派对", + "夜场精酿啤酒畅饮" + ], + "remarks": [ + "无需预约,随时可退", + "免费停车+电瓶车接送" + ], + "tag": "", + "pagination": "" +} + + + + + + + + # 创建活力模板实例 + vibrant_template = VibrantTemplate() + + # 生成海报 + output_path = "/root/autodl-tmp/posterGenerator/template_test_output/test_vibrant_poster.png" + try: + poster = vibrant_template.generate( + image_path=processed_image, + ocean_info=ocean_info, + glass_intensity=1.5, # 测试毛玻璃强度 + output_path=output_path, + theme_color="drak_gray" + ) + print(f"活力模板海报生成成功: {output_path}") + + # 显示模板信息 + info = vibrant_template.get_template_info() + print(f"模板信息: {info['name']} v{info['version']}") + print(f"特性: {', '.join(info['features'])}") + + except Exception as e: + print(f"活力模板测试失败: {e}") + + +def test_business_template(): + """测试商务模板""" + print("=" * 50) + print("测试商务模板") + print("=" * 50) + + # 准备图片 + picture_dir = "/root/autodl-tmp/posterGenerator/assets/馥桂萌宠园" + images = get_random_images(picture_dir, 5) # 需要更多图片用于小图 + + if len(images) < 2: + print("无法获取足够的图片进行测试") + return + + # 验证图像文件完整性 + valid_images = [] + for img_path in images: + processed = preprocess_image(img_path, crop_position='top') + if processed: + valid_images.append(processed) + else: + print(f"图像 {img_path} 预处理失败") + + if len(valid_images) < 2: + print("可用的有效图像不足,无法继续测试") + return + + processed_images = valid_images + + # 酒店信息数据 + hotel_info = { + "name": "张家界定制旅行管家", + "feature": "一对一专属服务 | 深度行程规划", + "slogan": "您的私人导游,开启专属张家界之旅", + "price": "私信查询", + "info_list": [ + "【住】可推荐武陵源/市区高端酒店任选", + "【食】含特色土家风味餐+每日早餐", + "【服务】资深导游+行程定制师全程跟进", + "【设施】景区VIP通道+快速接驳车服务" + ], + "footer": [ + "预订方式:点击【立即咨询】发送人数+天数", + "有效日期:即日起至2025年12月31日" + ] +} + + # 创建商务模板实例 + business_template = BusinessTemplate() + print(f"business_info:{hotel_info}") + # 生成海报 + output_path = "/root/autodl-tmp/posterGenerator/template_test_output/test_business_poster.png" + try: + # 准备小图(如果有足够的图片) + small_images = processed_images[2:5] if len(processed_images) >= 5 else None + + poster = business_template.generate( + top_image_path=processed_images[0], + bottom_image_path=processed_images[1], + small_image_paths=small_images, + hotel_info=hotel_info, + color_theme="blue_gradient", # 测试预设主题 + output_path=output_path + ) + print(f"商务模板海报生成成功: {output_path}") + + # 显示模板信息 + info = business_template.get_template_info() + print(f"模板信息: {info['name']} v{info['version']}") + print(f"特性: {', '.join(info['features'])}") + + # 显示布局区域 + areas = business_template.get_layout_areas() + print("布局区域:") + for area_name, area_coords in areas.items(): + print(f" {area_name}: {area_coords}") + + except Exception as e: + print(f"商务模板测试失败: {e}") + + +def test_both_templates(): + """测试两个模板的对比""" + print("=" * 50) + print("模板对比测试") + print("=" * 50) + + # 准备相同的图片 + picture_dir = "/root/autodl-tmp/posterGenerator/assets/馥桂萌宠园" + images = get_random_images(picture_dir, 5) + + if len(images) < 2: + print("无法获取足够的图片进行对比测试") + return + + # 验证图像文件完整性 + valid_images = [] + for img_path in images: + processed = preprocess_image(img_path, crop_position='top') + if processed: + valid_images.append(processed) + else: + print(f"图像 {img_path} 预处理失败") + + if len(valid_images) < 2: + print("可用的有效图像不足,无法继续对比测试") + return + + processed_images = valid_images + + # 使用相同的图片生成两种风格的海报 + base_image = processed_images[0] + + # 活力模板数据 + ocean_info = { + "title": "【商务海洋套餐】", + "slogan": "商务与休闲的完美结合", + "content_items": [ + "🏢 商务会议室使用", + "🌊 海景房住宿", + "🍽️ 商务午餐", + "⛵ 海上商务活动" + ], + "price": "2688", + "ticket_type": "商务套餐", + "remarks": ["含税含服务费"], + "tag": "限时特惠", + "pagination": "1/2" + } + + # 酒店信息数据 + hotel_info = { + "name": "海景商务酒店", + "feature": "海景与商务的完美融合 | 高端定制服务", + "slogan": "", + "price": "2688", + "info_list": [ + "【住】海景商务套房2晚", + "【食】海鲜商务套餐", + "【会】专业会议室服务", + "【娱】海上商务活动" + ], + "footer": [ + ] + } + + # 生成活力风格海报 + try: + vibrant_template = VibrantTemplate() + vibrant_poster = vibrant_template.generate( + image_path=base_image, + ocean_info=ocean_info, + glass_intensity=2.0, + output_path="comparison_vibrant.png", + theme_color="elegant" + ) + print("活力风格海报生成成功: comparison_vibrant.png") + except Exception as e: + print(f"活力风格生成失败: {e}") + + # 生成商务风格海报 + try: + business_template = BusinessTemplate() + small_images = processed_images[2:5] if len(processed_images) >= 5 else None + business_poster = business_template.generate( + top_image_path=base_image, + bottom_image_path=processed_images[1] if len(processed_images) > 1 else base_image, + small_image_paths=small_images, + hotel_info=hotel_info, + color_theme="elegant", + output_path="comparison_business.png" + ) + print("商务风格海报生成成功: comparison_business.png") + except Exception as e: + print(f"商务风格生成失败: {e}") + + print("\n对比测试完成!") + print("活力模板特点:单图背景,毛玻璃效果,两栏布局") + print("商务模板特点:双图融合,透明度效果,垂直布局") + + +def main(): + """主函数""" + print("重构模板演示程序") + print("=" * 60) + + # 检查必要目录 + picture_dir = "/root/autodl-tmp/posterGenerator/assets/馥桂萌宠园" + if not os.path.exists(picture_dir): + print(f"错误:图片目录不存在 {picture_dir}") + return + + # 创建输出目录 + os.makedirs("template_test_output", exist_ok=True) + os.chdir("template_test_output") + + try: + # 测试活力模板 + test_vibrant_template() + print() + + # 测试商务模板 + # test_business_template() + # print() + + # 对比测试 + # test_both_templates() + + except KeyboardInterrupt: + print("\n用户中断测试") + except Exception as e: + print(f"测试过程中出错: {e}") + + print("\n所有测试完成!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/utils/poster/poster_notes_creator.py b/poster_template.py similarity index 52% rename from utils/poster/poster_notes_creator.py rename to poster_template.py index 5f74f50..0b75ea2 100644 --- a/utils/poster/poster_notes_creator.py +++ b/poster_template.py @@ -1,3 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import time +import logging +import random +import traceback +import simplejson as json +from datetime import datetime +import sys +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from core.ai_agent import AI_Agent + +import os +import logging +from PIL import Image +import numpy as np +from typing import Tuple, Union, Optional +import psutil +import gc # 添加垃圾回收模块 + +import os +import random +import traceback +import math +from pathlib import Path +from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageOps +import logging # Import logging module + import os import random import logging @@ -24,6 +54,1541 @@ except ImportError: logger = logging.getLogger(__name__) + +class ContentGenerator: + """ + 海报文本内容生成器 + 使用AI_Agent代替直接管理OpenAI客户端,简化代码结构 + """ + def __init__(self, + output_dir="/root/autodl-tmp/poster_generate_result", + model_name="qwenQWQ", + base_url="http://localhost:8000/v1", + api_key="EMPTY", + temperature=0.7, + top_p=0.8, + presence_penalty=1.2): + """ + 初始化内容生成器 + + 参数: + output_dir: 输出结果保存目录 + temperature: 生成温度参数 + top_p: top_p参数 + presence_penalty: 惩罚参数 + """ + self.output_dir = output_dir + self.temperature = temperature + self.top_p = top_p + self.presence_penalty = presence_penalty + self.add_description = "" + + self.model_name = model_name + self.base_url = base_url + self.api_key = api_key + # 设置日志 + logging.basicConfig(level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + self.logger = logging.getLogger(__name__) + + def load_infomation(self, info_directory_path): + """ + 加载额外描述文件 + + 参数: + info_directory_path: 信息目录路径列表 + """ + self.add_description = "" # 重置描述文本 + for path in info_directory_path: + try: + with open(path, "r", encoding="utf-8") as f: + self.add_description += f.read() + self.logger.info(f"成功加载描述文件: {path}") + except Exception as e: + self.logger.warning(f"加载描述文件失败: {path}, 错误: {e}") + self.add_description = "" + + def split_content(self, content): + """ + 分割结果, 返回去除 + ```json + ```的json内容 + + 参数: + content: 需要分割的内容 + + 返回: + 分割后的json内容 + """ + try: + # 记录原始内容的前200个字符(用于调试) + self.logger.debug(f"解析内容,原始内容前200字符: {content[:200]}") + + # 首先尝试直接解析整个内容,以防已经是干净的 JSON + try: + parsed_data = json.loads(content) + + # 验证解析后的数据格式 + if isinstance(parsed_data, list): + # 如果是列表,验证每个元素是否符合预期结构 + for item in parsed_data: + if isinstance(item, dict) and ('main_title' in item or 'texts' in item): + # 至少有一个元素符合海报配置结构 + self.logger.info("成功直接解析为JSON格式列表,符合预期结构") + return parsed_data + + # 如果到这里,说明列表内没有符合结构的元素 + if len(parsed_data) > 0 and isinstance(parsed_data[0], str): + self.logger.warning(f"解析到JSON列表,但内容是字符串列表: {parsed_data}") + # 将字符串列表返回供后续修复 + return parsed_data + + self.logger.warning("解析到JSON列表,但结构不符合预期") + + elif isinstance(parsed_data, dict) and ('main_title' in parsed_data or 'texts' in parsed_data): + # 单个字典结构符合预期 + self.logger.info("成功直接解析为JSON字典,符合预期结构") + return parsed_data + + # 如果结构不符合预期,记录但仍返回解析结果,交给后续函数修复 + self.logger.warning(f"解析到JSON,但结构不完全符合预期: {parsed_data}") + return parsed_data + + except json.JSONDecodeError: + # 不是完整有效的JSON,继续尝试提取 + self.logger.debug("直接JSON解析失败,尝试提取结构化内容") + + # 常规模式:查找 ```json 和 ``` 之间的内容 + if "```json" in content: + json_str = content.split("```json")[1].split("```")[0].strip() + try: + parsed_json = json.loads(json_str) + self.logger.info("成功从```json```代码块提取JSON") + return parsed_json + except json.JSONDecodeError as e: + self.logger.warning(f"从```json```提取的内容解析失败: {e}, 尝试其他方法") + + # 备用模式1:查找连续的 [ 开头和 ] 结尾的部分 + import re + json_pattern = r'(\[(?:\s*\{.*?\}\s*,?)+\s*\])' # 更严格的模式,要求[]内至少有一个{}对象 + json_matches = re.findall(json_pattern, content, re.DOTALL) + + for match in json_matches: + try: + result = json.loads(match) + if isinstance(result, list) and len(result) > 0: + # 验证结构 + for item in result: + if isinstance(item, dict) and ('main_title' in item or 'texts' in item): + self.logger.info("成功从正则表达式提取JSON数组") + return result + self.logger.warning("从正则表达式提取的JSON数组不符合预期结构") + except Exception as e: + self.logger.warning(f"解析正则匹配的内容失败: {e}") + continue + + # 备用模式2:查找 [ 开头 和 ] 结尾,并尝试解析 + content = content.strip() + square_bracket_start = content.find('[') + square_bracket_end = content.rfind(']') + + if square_bracket_start != -1 and square_bracket_end != -1 and square_bracket_end > square_bracket_start: + potential_json = content[square_bracket_start:square_bracket_end + 1] + try: + result = json.loads(potential_json) + if isinstance(result, list): + # 检查列表内容 + self.logger.info(f"成功从方括号内容提取列表: {result}") + return result + except Exception as e: + self.logger.warning(f"尝试提取方括号内容失败: {e}") + + # 最后一种尝试:查找所有可能的 JSON 结构并尝试解析 + json_structures = re.findall(r'({.*?})', content, re.DOTALL) + if json_structures: + items = [] + for i, struct in enumerate(json_structures): + try: + item = json.loads(struct) + # 验证结构包含预期字段 + if isinstance(item, dict) and ('main_title' in item or 'texts' in item): + items.append(item) + except Exception as e: + self.logger.warning(f"解析可能的JSON结构 {i+1} 失败: {e}") + continue + + if items: + self.logger.info(f"成功从文本中提取 {len(items)} 个JSON对象") + return items + + # 如果以上所有方法都失败,尝试简单字符串处理 + if "|" in content or "必打卡" in content or "性价比" in content: + # 这可能是一个简单的标签字符串 + self.logger.warning(f"无法提取标准JSON,但发现可能的标签字符串: {content}") + return content.strip() + + # 都失败了,打印错误并引发异常 + self.logger.error(f"无法解析内容,返回原始文本: {content[:200]}...") + raise ValueError("无法从响应中提取有效的 JSON 格式") + + except Exception as e: + self.logger.error(f"解析内容时出错: {e}") + self.logger.debug(f"原始内容: {content[:200]}...") # 仅显示前200个字符 + return content.strip() # 返回原始内容,让后续验证函数处理 + + def _preprocess_for_json(self, text): + """预处理文本,将换行符转换为\\n形式,保证JSON安全""" + if not isinstance(text, str): + return text + # 将所有实际换行符替换为\\n字符串 + return text.replace('\n', '\\n').replace('\r', '\\r') + + def generate_posters(self, poster_num, content_data_list, system_prompt=None, + api_url=None, model_name=None, api_key=None, timeout=120, max_retries=3): + """ + 生成海报配置 + + Args: + poster_num: 生成的海报数量 + content_data_list: 内容数据列表 + system_prompt: 系统提示词(可选) + api_url: API基础URL(可选) + model_name: 模型名称(可选) + api_key: API密钥(可选) + + Returns: + str: 生成的配置JSON字符串 + """ + # 更新API设置 + if api_url: + self.base_url = api_url + if model_name: + self.model_name = model_name + if api_key: + self.api_key = api_key + + # 使用系统提示或默认提示 + if system_prompt: + self.system_prompt = system_prompt + elif not self.system_prompt: + self.system_prompt = """你是一名专业的旅游景点海报文案创作专家。你的任务是根据提供的旅游景点信息和推文内容,生成海报文案配置。你的回复必须是一个JSON数组,每一项表示一个海报配置,包含'index'、'main_title'和'texts'三个字段,其中'texts'是一个字符串数组。海报文案要简洁有力,突出景点特色和吸引力。""" + + # 提取内容文本(如果是列表内容数据) + tweet_content = "" + if isinstance(content_data_list, list): + for item in content_data_list: + if isinstance(item, dict): + # 对标题和内容进行预处理,替换换行符 + title = self._preprocess_for_json(item.get('title', '')) + content = self._preprocess_for_json(item.get('content', '')) + tweet_content += f"