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"\n{title}\n\n\n{content}\n\n\n" + elif isinstance(item, str): + tweet_content += self._preprocess_for_json(item) + "\n\n" + elif isinstance(content_data_list, str): + tweet_content = self._preprocess_for_json(content_data_list) + + # 构建用户提示 + if self.add_description: + # 预处理景点描述 + processed_description = self._preprocess_for_json(self.add_description) + user_content = f""" + 以下是需要你处理的信息: + + 关于景点的描述: + {processed_description} + + 推文内容: + {tweet_content} + + 请根据这些信息,生成{poster_num}个海报文案配置,以JSON数组格式返回。 + """ + else: + user_content = f""" + 以下是需要你处理的推文内容: + {tweet_content} + + 请根据这些信息,生成{poster_num}个海报文案配置,以JSON数组格式返回。 + """ + + self.logger.info(f"正在生成{poster_num}个海报文案配置") + + # 创建AI_Agent实例 + ai_agent = AI_Agent( + self.base_url, + self.model_name, + self.api_key, + timeout=timeout, + max_retries=max_retries, + stream_chunk_timeout=30 # 流式块超时时间 + ) + + full_response = "" + try: + # 使用AI_Agent的non-streaming方法 + self.logger.info(f"调用AI生成海报配置,模型: {self.model_name}") + full_response, tokens, time_cost = ai_agent.work( + self.system_prompt, + user_content, + "", # 历史消息(空) + self.temperature, + self.top_p, + self.presence_penalty + ) + + self.logger.info(f"AI生成完成,耗时: {time_cost:.2f}s, 预估令牌数: {tokens}") + + if not full_response: + self.logger.warning("AI返回空响应,使用备用内容") + full_response = self._generate_fallback_content(poster_num) + except Exception as e: + self.logger.exception(f"AI生成过程发生错误: {e}") + full_response = self._generate_fallback_content(poster_num) + finally: + # 确保关闭AI Agent + ai_agent.close() + + return full_response + + def _generate_fallback_content(self, poster_num): + """生成备用内容,当API调用失败时使用""" + self.logger.info("生成备用内容") + default_configs = [] + for i in range(poster_num): + default_configs.append({ + "index": i + 1, + "main_title": "", + "texts": ["", ""] + }) + return json.dumps(default_configs, ensure_ascii=False) + + def save_result(self, full_response, custom_output_dir=None): + """ + 保存生成结果到文件 + + 参数: + full_response: 生成的完整响应内容 + custom_output_dir: 自定义输出目录(可选) + + 返回: + 结果文件路径 + """ + # 生成时间戳 + date_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + output_dir = custom_output_dir or self.output_dir + + try: + # 解析内容为JSON格式 + parsed_data = self.split_content(full_response) + + # 验证内容格式并修复 + validated_data = self._validate_and_fix_data(parsed_data) + # 创建结果文件路径 + result_path = os.path.join(output_dir, f"{date_time}.json") + os.makedirs(os.path.dirname(result_path), exist_ok=True) + + # 保存结果到文件 + with open(result_path, "w", encoding="utf-8") as f: + json.dump(validated_data, f, ensure_ascii=False, indent=4, ignore_nan=True) + + self.logger.info(f"结果已保存到: {result_path}") + return result_path + + except Exception as e: + self.logger.error(f"保存结果到文件时出错: {e}") + # 尝试创建一个简单的备用配置 + fallback_data = [{"main_title": "", "texts": ["", ""], "index": 1}] + + # 保存备用数据 + result_path = os.path.join(output_dir, f"{date_time}_fallback.json") + os.makedirs(os.path.dirname(result_path), exist_ok=True) + + with open(result_path, "w", encoding="utf-8") as f: + json.dump(fallback_data, f, ensure_ascii=False, indent=4, ignore_nan=True) + + self.logger.info(f"出错后已保存备用数据到: {result_path}") + return result_path + + def _validate_and_fix_data(self, data): + """ + 验证并修复从AI返回的数据,确保其符合期望的结构 + + Args: + data: 需要验证的数据 + + Returns: + list: 修复后的数据列表 + """ + fixed_data = [] + self.logger.info(f"验证并修复数据: {type(data)}") + + # 尝试处理字符串类型 (通常是JSON字符串) + if isinstance(data, str): + try: + # 尝试将字符串解析为JSON对象 + parsed_data = json.loads(data) + # 递归调用本函数处理解析后的数据 + return self._validate_and_fix_data(parsed_data) + except json.JSONDecodeError as e: + self.logger.warning(f"JSON解析失败: {e}") + # 可以选择尝试清理和再次解析 + try: + # 寻找字符串中第一个 [ 和最后一个 ] 之间的内容 + start_idx = data.find('[') + end_idx = data.rfind(']') + if start_idx >= 0 and end_idx > start_idx: + json_part = data[start_idx:end_idx+1] + self.logger.info(f"尝试从字符串中提取JSON部分: {json_part[:100]}...") + parsed_data = json.loads(json_part) + return self._validate_and_fix_data(parsed_data) + except: + self.logger.warning("无法从字符串中提取有效的JSON部分") + fixed_data.append({ + "index": 1, + "main_title": self._preprocess_for_json("默认标题"), # 应用预处理 + "texts": [self._preprocess_for_json("默认副标题1"), self._preprocess_for_json("默认副标题2")] # 应用预处理 + }) + + # 处理列表类型 + elif isinstance(data, list): + for idx, item in enumerate(data): + # 如果是字典,检查必须字段 + if isinstance(item, dict): + fixed_item = {} + # 设置索引 + fixed_item["index"] = item.get("index", idx + 1) + + # 处理主标题 + if "main_title" in item and item["main_title"]: + # 应用预处理,确保所有换行符被正确转义 + fixed_item["main_title"] = self._preprocess_for_json(item["main_title"]) + else: + fixed_item["main_title"] = "默认标题" + + # 处理文本列表 + if "texts" in item and isinstance(item["texts"], list) and len(item["texts"]) > 0: + # 对文本列表中的每个元素应用预处理 + fixed_item["texts"] = [self._preprocess_for_json(text) if text else "" for text in item["texts"]] + # 确保至少有两个元素 + while len(fixed_item["texts"]) < 2: + fixed_item["texts"].append("") + else: + fixed_item["texts"] = ["默认副标题1", "默认副标题2"] + + fixed_data.append(fixed_item) + + # 如果是字符串,转换为默认格式 + elif isinstance(item, str): + fixed_data.append({ + "index": idx + 1, + "main_title": self._preprocess_for_json(item), # 应用预处理 + "texts": ["", ""] + }) + + # 其他类型,使用默认值 + else: + fixed_data.append({ + "index": idx + 1, + "main_title": "默认标题", + "texts": ["", ""] + }) + + # 处理字典类型 (单个配置项) + elif isinstance(data, dict): + # 处理主标题 + main_title = self._preprocess_for_json(data.get("main_title", "默认标题")) # 应用预处理 + + # 处理文本列表 + texts = [] + if "texts" in data and isinstance(data["texts"], list): + texts = [self._preprocess_for_json(text) if text else "" for text in data["texts"]] # 应用预处理 + + # 确保文本列表至少有两个元素 + while len(texts) < 2: + texts.append("") + + fixed_data.append({ + "index": data.get("index", 1), + "main_title": main_title, + "texts": texts + }) + + # 如果数据是其他格式 + else: + self.logger.warning(f"数据格式不支持: {type(data)},将使用默认值") + fixed_data.append({ + "index": 1, + "main_title": "", + "texts": ["", ""] + }) + + # 确保至少有一个配置项 + if not fixed_data: + fixed_data.append({ + "index": 1, + "main_title": "", + "texts": ["", ""] + }) + + self.logger.info(f"修复后的数据: {fixed_data}") + return fixed_data + + def run(self, info_directory, poster_num, content_data, system_prompt=None, + api_url="http://localhost:8000/v1", model_name="qwenQWQ", api_key="EMPTY", timeout=120): + """ + 运行海报内容生成流程,并返回生成的配置数据。 + + 参数: + info_directory: 信息目录路径列表 (e.g., ['/path/to/description.txt']) + poster_num: 需要生成的海报配置数量 + content_data: 用于生成内容的文章内容(可以是字符串或字典列表) + system_prompt: 系统提示词,默认为None使用内置提示词 + api_url: API基础URL + model_name: 使用的模型名称 + api_key: API密钥 + + 返回: + list | dict | None: 生成的海报配置数据 (通常是列表),如果生成或解析失败则返回 None。 + """ + try: + # 加载描述信息 + self.load_infomation(info_directory) + + # 生成海报内容 + self.logger.info(f"开始生成海报内容,数量: {poster_num}") + full_response = self.generate_posters( + poster_num, + content_data, + system_prompt, + api_url, + model_name, + api_key, + timeout=timeout, + ) + + # 检查生成是否失败 + if not isinstance(full_response, str) or not full_response.strip(): + self.logger.error("海报内容生成失败或返回空响应") + return None + + # 从原始响应字符串中提取JSON数据 + result_data = self.split_content(full_response) + + # 验证并修复数据 + fixed_data = self._validate_and_fix_data(result_data) + + self.logger.info(f"成功生成并修复海报配置数据,包含 {len(fixed_data) if isinstance(fixed_data, list) else 1} 个项目") + return fixed_data + + except Exception as e: + self.logger.exception(f"海报内容生成过程中发生错误: {e}") + traceback.print_exc() + + # 失败后创建一个默认配置 + self.logger.info("创建默认海报配置数据") + default_configs = [] + for i in range(poster_num): + default_configs.append({ + "index": i + 1, + "main_title": "", + "texts": ["", ""] + }) + return default_configs + + def set_temperature(self, temperature): + """设置温度参数""" + self.temperature = temperature + + def set_top_p(self, top_p): + """设置top_p参数""" + self.top_p = top_p + + def set_presence_penalty(self, presence_penalty): + """设置存在惩罚参数""" + self.presence_penalty = presence_penalty + + def set_model_para(self, temperature, top_p, presence_penalty): + """一次性设置所有模型参数""" + self.temperature = temperature + self.top_p = top_p + self.presence_penalty = presence_penalty + + +class ImageProcessor: + """ + 图像处理工具类,提供智能图像加载和压缩功能 + """ + + def __init__(self, max_memory_percent=80, target_max_pixels=4000000): + """ + 初始化图像处理器 + + Args: + max_memory_percent: 最大内存使用百分比,超过此值将更激进地压缩图像 + target_max_pixels: 目标最大像素数(宽×高),用于控制图像大小 + """ + self.max_memory_percent = max_memory_percent + self.target_max_pixels = target_max_pixels + self.logger = logging.getLogger(self.__class__.__name__) + # 记录已加载的图像,用于后续清理 + self.loaded_images = [] + + def get_memory_usage(self) -> Tuple[float, float]: + """ + 获取当前内存使用情况 + + Returns: + (已用内存百分比, 可用内存MB) + """ + memory = psutil.virtual_memory() + return memory.percent, memory.available / (1024 * 1024) + + def check_memory_pressure(self) -> bool: + """ + 检查内存压力,如果内存使用率超过阈值,执行垃圾回收 + + Returns: + 是否存在内存压力 + """ + mem_percent, available_mb = self.get_memory_usage() + + # 如果内存使用率超过阈值或可用内存低于100MB,认为存在内存压力 + if mem_percent > self.max_memory_percent or available_mb < 100: + self.logger.warning(f"内存压力过大: 使用率 {mem_percent:.1f}%, 可用 {available_mb:.1f}MB,执行垃圾回收") + self.force_garbage_collection() + return True + return False + + def force_garbage_collection(self): + """ + 强制执行垃圾回收并清理已加载的图像资源 + """ + # 释放已加载的图像 + self.release_loaded_images() + + # 执行多次垃圾回收 + for _ in range(3): + gc.collect() + + # 记录垃圾回收后的内存状态 + mem_percent, available_mb = self.get_memory_usage() + self.logger.info(f"垃圾回收后内存状态: 使用率 {mem_percent:.1f}%, 可用 {available_mb:.1f}MB") + + def release_loaded_images(self): + """ + 释放所有已加载的图像资源 + """ + for img in self.loaded_images: + try: + if hasattr(img, 'close') and callable(img.close): + img.close() + except Exception as e: + self.logger.error(f"释放图像资源失败: {e}") + + # 清空列表 + self.loaded_images = [] + self.logger.info("已释放所有已加载的图像资源") + + def calculate_resize_factor(self, width: int, height: int) -> float: + """ + 根据图像尺寸计算压缩因子 + + Args: + width: 图像宽度 + height: 图像高度 + + Returns: + 压缩因子 (0.1-1.0) + """ + # 计算原始像素数 + original_pixels = width * height + + # 如果小于目标像素数,不需要压缩 + if original_pixels <= self.target_max_pixels: + return 1.0 + + # 计算基础压缩因子 + base_factor = (self.target_max_pixels / original_pixels) ** 0.5 + + # 检查内存使用情况 + mem_percent, available_mb = self.get_memory_usage() + + # 根据内存使用情况调整压缩因子 + if mem_percent > self.max_memory_percent: + # 内存紧张,增加压缩率 + memory_factor = 1.0 - ((mem_percent - self.max_memory_percent) / 20) + memory_factor = max(0.5, memory_factor) # 至少保留50%质量 + base_factor *= memory_factor + self.logger.warning(f"内存使用率高 ({mem_percent:.1f}%),增加压缩率,压缩因子调整为 {base_factor:.2f}") + + # 确保压缩因子在合理范围内 + return max(0.1, min(1.0, base_factor)) + + def smart_load_image(self, image_path: str, target_size: Optional[Tuple[int, int]] = None) -> Image.Image: + """ + 智能加载图像,根据内存情况和图像大小自动调整尺寸 + + Args: + image_path: 图像路径 + target_size: 目标尺寸,如果指定则直接调整到此尺寸 + + Returns: + 加载并调整大小后的PIL图像对象 + """ + # 先检查内存压力 + self.check_memory_pressure() + + try: + # 检查文件是否存在 + if not os.path.exists(image_path): + self.logger.error(f"图像文件不存在: {image_path}") + # 返回一个空白图像 + return Image.new('RGB', target_size or (800, 600), (240, 240, 240)) + + # 获取文件大小(MB) + file_size_mb = os.path.getsize(image_path) / (1024 * 1024) + + # 如果文件过大,先检查内存 + if file_size_mb > 10: # 大于10MB的文件 + mem_percent, available_mb = self.get_memory_usage() + self.logger.info(f"大文件 ({file_size_mb:.1f}MB), 内存使用率: {mem_percent:.1f}%, 可用: {available_mb:.1f}MB") + + if mem_percent > 90 or available_mb < file_size_mb * 5: + # 内存紧张,使用更保守的加载方式 + self.logger.warning(f"内存不足,使用保守加载方式") + result = self._conservative_load(image_path, target_size) + self.loaded_images.append(result) + return result + + # 常规加载 + with Image.open(image_path) as img: + # 获取原始尺寸 + original_width, original_height = img.size + self.logger.debug(f"原始图像尺寸: {original_width}x{original_height}") + + # 如果指定了目标尺寸,直接调整 + if target_size: + result = img.resize(target_size, Image.Resampling.LANCZOS) + self.loaded_images.append(result) + return result + + # 计算压缩因子 + resize_factor = self.calculate_resize_factor(original_width, original_height) + + if resize_factor < 1.0: + # 需要压缩 + new_width = int(original_width * resize_factor) + new_height = int(original_height * resize_factor) + self.logger.info(f"压缩图像 {image_path} 从 {original_width}x{original_height} 到 {new_width}x{new_height}") + result = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + self.loaded_images.append(result) + return result + else: + # 不需要压缩,返回原图副本 + result = img.copy() + self.loaded_images.append(result) + return result + + except Exception as e: + self.logger.error(f"加载图像 {image_path} 失败: {e}") + # 返回一个空白图像 + return Image.new('RGB', target_size or (800, 600), (240, 240, 240)) + + def _conservative_load(self, image_path: str, target_size: Optional[Tuple[int, int]] = None) -> Image.Image: + """ + 保守加载大图像,先获取尺寸,然后以较低质量加载 + + Args: + image_path: 图像路径 + target_size: 目标尺寸 + + Returns: + 加载的PIL图像对象 + """ + try: + # 先只获取图像信息,不加载像素数据 + with Image.open(image_path) as img: + original_width, original_height = img.size + format = img.format + + # 计算合适的缩小尺寸 + if target_size: + new_width, new_height = target_size + else: + # 计算一个非常保守的压缩因子 + pixels = original_width * original_height + conservative_factor = min(0.5, (2000000 / pixels) ** 0.5) + new_width = int(original_width * conservative_factor) + new_height = int(original_height * conservative_factor) + + # 使用缩略图方式加载 + with Image.open(image_path) as img: + img.thumbnail((new_width, new_height), Image.Resampling.LANCZOS) + # 创建新图像以确保释放原始文件句柄 + result = img.copy() + + self.logger.info(f"保守加载图像 {image_path} 从 {original_width}x{original_height} 到 {new_width}x{new_height}") + return result + + except Exception as e: + self.logger.error(f"保守加载图像失败: {e}") + return Image.new('RGB', target_size or (800, 600), (240, 240, 240)) + + def batch_process_directory(self, directory: str, max_images: int = 100) -> list: + """ + 批量处理目录中的图像,返回处理后的图像列表 + + Args: + directory: 图像目录 + max_images: 最大处理图像数量 + + Returns: + 处理后的PIL图像对象列表 + """ + if not os.path.exists(directory): + self.logger.error(f"目录不存在: {directory}") + return [] + + # 获取所有图像文件 + image_files = [] + for root, _, files in os.walk(directory): + for file in files: + if file.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.bmp')): + image_files.append(os.path.join(root, file)) + + # 限制图像数量 + if len(image_files) > max_images: + self.logger.warning(f"图像文件过多 ({len(image_files)}), 限制为 {max_images} 个") + image_files = image_files[:max_images] + + # 批量加载图像 + images = [] + for image_file in image_files: + img = self.smart_load_image(image_file) + if img: + images.append(img) + + # 每处理5张图片检查一次内存压力 + if len(images) % 5 == 0: + self.check_memory_pressure() + + self.logger.info(f"已处理 {len(images)}/{len(image_files)} 个图像") + return images + + def save_optimized_image(self, image: Image.Image, output_path: str, quality: int = 85) -> bool: + """ + 保存优化后的图像 + + Args: + image: PIL图像对象 + output_path: 输出路径 + quality: JPEG质量 (1-100) + + Returns: + 是否保存成功 + """ + try: + # 确保输出目录存在 + os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) + + # 检查图像模式,如果是RGBA且保存为JPEG,先转换为RGB + if image.mode == 'RGBA' and (output_path.lower().endswith('.jpg') or output_path.lower().endswith('.jpeg')): + image = image.convert('RGB') + + # 保存图像 + image.save(output_path, quality=quality, optimize=True) + self.logger.info(f"图像已保存至: {output_path}") + + # 保存后检查内存压力 + self.check_memory_pressure() + + return True + except Exception as e: + self.logger.error(f"保存图像失败: {e}") + return False + + + +class ImageCollageCreator: + def __init__(self, ): + """初始化拼图创建器""" + + # 定义可用拼接样式 + self.collage_styles = [ + "grid_2x2", # 标准2x2网格 + # "asymmetric", # 非对称布局 + # "filmstrip", # 胶片条布局 + # "circles", # 圆形布局 + "overlap", # 重叠风格 + # "mosaic", # 马赛克风格 3x3 + "fullscreen", # 全覆盖拼图样式 + "vertical_stack" # 新增:上下拼图样式 + # "polaroid", # 宝丽来风格 + ] + + def resize_and_crop(self, img, target_size): + """调整图片大小并居中裁剪为指定尺寸""" + width, height = img.size + target_width, target_height = target_size + + # 计算宽高比 + img_ratio = width / height + target_ratio = target_width / target_height + + if img_ratio > target_ratio: + # 图片较宽,以高度为基准调整 + new_height = target_height + new_width = int(width * target_height / height) + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + # 居中裁剪 + left = (new_width - target_width) // 2 + img = img.crop((left, 0, left + target_width, target_height)) + else: + # 图片较高,以宽度为基准调整 + new_width = target_width + new_height = int(height * target_width / width) + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + # 居中裁剪 + top = (new_height - target_height) // 2 + img = img.crop((0, top, target_width, top + target_height)) + + return img + + def add_border(self, img, color=(255, 255, 255, 200), width=2, no_border=True): + """给图像添加边框,可选择不添加边框""" + if no_border: + return img # 如果设置为无边框,直接返回原图 + + try: + w, h = img.size + new_img = Image.new('RGBA', (w, h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(new_img) + + # 绘制边框(在四条边上) + for i in range(width): + # 上边框 + draw.line([(i, i), (w-i-1, i)], fill=color, width=1) + # 右边框 + draw.line([(w-i-1, i), (w-i-1, h-i-1)], fill=color, width=1) + # 下边框 + draw.line([(i, h-i-1), (w-i-1, h-i-1)], fill=color, width=1) + # 左边框 + draw.line([(i, i), (i, h-i-1)], fill=color, width=1) + + # 合并原图和边框 + result = img.copy() + result.alpha_composite(new_img) + + return result + except Exception as e: + print(f"添加边框时出错: {str(e)}") + return img + + def add_polaroid_frame(self, img, margin=20, bottom_margin=60, background_color=(255, 255, 255, 255)): + """添加宝丽来风格的相框""" + try: + w, h = img.size + frame_width = w + 2 * margin + frame_height = h + margin + bottom_margin + + # 创建白色背景 + frame = Image.new('RGBA', (frame_width, frame_height), background_color) + + # 将图像粘贴到框架中 + frame.paste(img, (margin, margin)) + + # 添加稍微的阴影效果 + shadow = Image.new('RGBA', frame.size, (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow) + shadow_draw.rectangle([2, 2, frame_width-2, frame_height-2], fill=(0, 0, 0, 40)) + + # 模糊阴影 + shadow = shadow.filter(ImageFilter.GaussianBlur(3)) + + # 创建最终图像 + final = Image.new('RGBA', (frame_width+6, frame_height+6), (0, 0, 0, 0)) + final.paste(shadow, (6, 6)) + final.paste(frame, (0, 0), frame) + + return final + except Exception as e: + print(f"添加宝丽来相框时出错: {str(e)}") + return img + + def apply_image_effect(self, img, effect="none"): + """应用各种图像效果 - 所有图片适度增强对比度和亮度""" + try: + # 适度增强对比度 + contrast = ImageEnhance.Contrast(img) + enhanced = contrast.enhance(1.1) # 降低对比度系数,从1.6降至1.3 + + # 轻微增强亮度 + brightness = ImageEnhance.Brightness(enhanced) + enhanced = brightness.enhance(1.1) # 保持轻微增加亮度 + + # 轻微增强色彩饱和度 + color = ImageEnhance.Color(enhanced) + enhanced = color.enhance(1.15) # 轻微降低饱和度,从1.2降至1.15 + + return enhanced + except Exception as e: + print(f"增强图片效果时出错: {str(e)}") + return img + + def create_collage_with_style(self, input_dir, style=None, target_size=None): + """创建指定样式的拼接画布 + + 参数: + input_dir: 输入图片目录路径 + style: 拼贴样式,如不指定则随机选择 + target_size: 目标尺寸,默认为(900, 1200) + + 返回: + tuple: (拼贴图, 选择的图片名称列表),如果创建失败则返回(None, []) + """ + logging.info(f"--- Starting Collage Creation for Directory: {input_dir} ---") # Start Log + try: + # 设置默认尺寸为3:4比例 + if target_size is None: + target_size = (900, 1200) # 3:4比例 + + # 如果没有指定样式,随机选择一种 + if style is None or style not in self.collage_styles: + style = random.choice(self.collage_styles) + logging.info(f"Using collage style: {style} with target size: {target_size}") + + # 检查目录是否存在 + if not os.path.exists(input_dir): + logging.error(f"Input directory does not exist: {input_dir}") + return None, [] + + # 支持的图片格式 + image_extensions = ('.jpg', '.jpeg', '.png', '.bmp') + + # 获取目录中的所有文件 + try: + all_files = os.listdir(input_dir) + logging.info(f"Files found in directory: {all_files}") + except Exception as e: + logging.exception(f"Error listing directory {input_dir}: {e}") + return None, [] + + # 过滤图片文件 + all_images_names = [f for f in all_files + if f.lower().endswith(image_extensions) and os.path.isfile(os.path.join(input_dir, f))] + logging.info(f"Filtered image files: {all_images_names}") + + if not all_images_names: + logging.warning(f"No valid image files found in directory: {input_dir}") + return None, [] # Return None if no images found + + # 根据不同样式,确定需要的图片数量 + # ... (logic for num_images based on style) ... + num_images = 4 + if style == "mosaic": + num_images = 9 + elif style == "filmstrip": + num_images = 5 + elif style == "fullscreen": + num_images = 6 + elif style == "vertical_stack": + num_images = 2 + logging.info(f"Style '{style}' requires {num_images} images.") + + # 确保有足够的图像 (或重复使用) + selected_images_names = [] + if len(all_images_names) < num_images: + logging.warning(f"Need {num_images} images for style '{style}', but only found {len(all_images_names)}. Will repeat images.") + if len(all_images_names) > 0: + # Repeat available images to meet the count + selected_images_names = (all_images_names * (num_images // len(all_images_names) + 1))[:num_images] + else: + logging.error("Cannot select images, none were found.") # Should not happen due to earlier check + return None, [] + else: + # 随机选择指定数量的图片 + selected_images_names = random.sample(all_images_names, num_images) + + # 记录并输出被选择的图片名称 + logging.info(f"Selected images for collage: {selected_images_names}") + print(f"为拼贴图选择的图片: {selected_images_names}") + + # 加载选中的图片 + images = [] + for img_name in selected_images_names: + image_path = os.path.join(input_dir, img_name) + try: + img = Image.open(image_path).convert('RGBA') + images.append(img) + logging.debug(f"Successfully loaded image: {img_name}") + except Exception as e: + logging.exception(f"Error loading image {img_name}: {e}") + + if len(images) == 0: + logging.error("No images could be loaded. Cannot create collage.") + return None, [] + + # 确保图片数量满足要求,不足则复制已有图片 + while len(images) < num_images: + images.append(random.choice(images).copy()) + logging.debug(f"Duplicated an image to reach required count of {num_images}") + + # 根据样式创建拼图 + collage = None + if style == "grid_2x2": + collage = self._create_grid_2x2_collage(images, target_size) + elif style == "asymmetric": + collage = self._create_asymmetric_collage(images, target_size) + elif style == "filmstrip": + collage = self._create_filmstrip_collage(images, target_size) + elif style == "circles": + collage = self._create_circles_collage(images, target_size) + elif style == "overlap": + collage = self._create_overlap_collage(images, target_size) + elif style == "polaroid": + collage = self._create_polaroid_collage(images, target_size) + elif style == "mosaic": + collage = self._create_mosaic_collage(images, target_size) + elif style == "fullscreen": + collage = self._create_fullscreen_collage(images, target_size) + elif style == "vertical_stack": + collage = self._create_vertical_stack_collage(images, target_size) + + if collage: + logging.info(f"Successfully created collage with style: {style}") + else: + logging.error(f"Failed to create collage with style: {style}") + return None, [] + + # 清理内存中的原始图像 + for img in images: + if hasattr(img, 'close'): + img.close() + + return collage, selected_images_names + except Exception as e: + logging.exception(f"Error in create_collage_with_style: {e}") + traceback.print_exc() + return None, [] + + def _create_grid_2x2_collage(self, images, target_size): + """创建2x2网格拼贴画""" + collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) # 使用白色背景 + + # 计算每个块的大小 + block_width = target_size[0] // 2 + block_height = target_size[1] // 2 + + # 定义四个区域位置 + positions = [ + (0, 0), # 左上 + (block_width, 0), # 右上 + (0, block_height), # 左下 + (block_width, block_height) # 右下 + ] + + # 将图像粘贴到拼贴画位置 + for i, position in enumerate(positions): + if i < len(images): + img = images[i].copy() + # 调整大小 + img = self.resize_and_crop(img, (block_width, block_height)) + # 不应用边框,实现无缝拼接 + # 粘贴到拼贴画 + collage.paste(img, position, img) + print(f"添加拼贴画块 {i+1} 到位置: {position}") + + print(f"无缝2x2网格拼贴画创建成功,尺寸: {target_size}") + return collage + + def _create_asymmetric_collage(self, images, target_size): + """创建非对称布局拼贴画""" + collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) + width, height = target_size + + # 定义非对称区域位置 + positions = [ + (0, 0, width*2//3, height//2), # 左上 (大) + (width*2//3, 0, width, height//3), # 右上 + (width*2//3, height//3, width, height//2), # 右中 + (0, height//2, width, height) # 底部 (全宽) + ] + + # 定义不同的效果 + effects = ["none", "grayscale", "vintage", "color_boost"] + random.shuffle(effects) + + # 将图像粘贴到拼贴画位置 + for i, (x1, y1, x2, y2) in enumerate(positions): + if i < len(images): + img = images[i].copy() + # 调整大小 + img = self.resize_and_crop(img, (x2-x1, y2-y1)) + # 应用效果 + img = self.apply_image_effect(img, effects[i % len(effects)]) + # 不添加边框 + # 粘贴到拼贴画 + collage.paste(img, (x1, y1), img) + print(f"添加非对称拼贴画块 {i+1} 到位置: ({x1},{y1},{x2},{y2})") + + print(f"无缝非对称拼贴画创建成功,尺寸: {target_size}") + return collage + + def _create_filmstrip_collage(self, images, target_size): + """创建胶片条布局拼贴画""" + collage = Image.new('RGBA', target_size, (0, 0, 0, 0)) + width, height = target_size + + # 胶片条中每个图像的高度 + strip_height = height // 5 + + # 添加黑条边框 + film_border_width = 15 + + # 将图像粘贴为胶片条 + for i in range(5): + if i < len(images): + img = images[i].copy() + # 调整大小,考虑边框 + img = self.resize_and_crop(img, (width - 2*film_border_width, strip_height - 2*film_border_width)) + # 不应用效果,保持原始颜色 + + # 创建黑色胶片边框 + film_frame = Image.new('RGBA', (width, strip_height), (0, 0, 0, 255)) + # 在边框中间贴上图片 + film_frame.paste(img, (film_border_width, film_border_width), img) + + # 添加胶片冲孔 + draw = ImageDraw.Draw(film_frame) + hole_spacing = 30 + hole_radius = 5 + num_holes = width // hole_spacing + for h in range(num_holes): + hole_center_x = h * hole_spacing + hole_spacing // 2 + # 顶部和底部的冲孔 + draw.ellipse((hole_center_x - hole_radius, 3, hole_center_x + hole_radius, 13), fill=(50, 50, 50, 255)) + draw.ellipse((hole_center_x - hole_radius, strip_height - 13, hole_center_x + hole_radius, strip_height - 3), fill=(50, 50, 50, 255)) + + # 粘贴到拼贴画 + y_position = i * strip_height + collage.paste(film_frame, (0, y_position), film_frame) + print(f"添加胶片条拼贴画块 {i+1} 到位置 y={y_position}") + + print(f"胶片条拼贴画创建成功,尺寸: {target_size}") + return collage + + def _create_circles_collage(self, images, target_size): + """创建圆形布局拼贴画""" + collage = Image.new('RGBA', target_size, (0, 0, 0, 0)) + width, height = target_size + + # 定义圆形的位置和大小 + circle_positions = [ + (width//4, height//4, width//2.5), # 左上 + (width*3//4, height//4, width//3), # 右上 + (width//4, height*3//4, width//3), # 左下 + (width*3//4, height*3//4, width//2.5) # 右下 + ] + + # 为每个圆形创建蒙版 + for i, (center_x, center_y, radius) in enumerate(circle_positions): + if i < len(images): + img = images[i].copy() + # 应用效果 + img = self.apply_image_effect(img) + # 调整图像大小为圆的直径 - 确保是整数 + diam = int(radius*2) + img = self.resize_and_crop(img, (diam, diam)) + + # 创建圆形蒙版 + mask = Image.new('L', img.size, 0) + draw = ImageDraw.Draw(mask) + draw.ellipse((0, 0, mask.width, mask.height), fill=255) + # 模糊边缘 + mask = mask.filter(ImageFilter.GaussianBlur(radius=5)) + + # 应用蒙版 + img.putalpha(mask) + + # 计算粘贴位置,使圆心在定义的位置 + paste_x = int(center_x - radius) + paste_y = int(center_y - radius) + + # 粘贴到拼贴画 + collage.paste(img, (paste_x, paste_y), img) + print(f"添加圆形拼贴画块 {i+1} 到位置: ({paste_x},{paste_y})") + + # 添加轻微的渐变背景 + background = Image.new('RGBA', target_size, (245, 245, 245, 100)) + collage = Image.alpha_composite(background, collage) + + print(f"圆形拼贴画创建成功,尺寸: {target_size}") + return collage + + def _create_polaroid_collage(self, images, target_size): + """创建宝丽来风格拼贴画 - 最小化图片重叠""" + collage = Image.new('RGBA', target_size, (240, 240, 240, 255)) + width, height = target_size + + # 宝丽来照片的大小 - 适当调整尺寸,减少重叠 + polaroid_sizes = [ + (int(width//2.2), int(height//2.8)), # 大号 + (int(width//2.5), int(height//3)), # 中大号 + (int(width//2.8), int(height//3.5)), # 中号 + (int(width//3), int(height//4)) # 中小号 + ] + + # 随机打乱尺寸 + random.shuffle(polaroid_sizes) + + # 创建网格布局,降低重叠概率 + grid_cells = [ + (0, 0, width//2, height//2), # 左上 + (width//2, 0, width, height//2), # 右上 + (0, height//2, width//2, height), # 左下 + (width//2, height//2, width, height) # 右下 + ] + + # 随机打乱网格单元 + random.shuffle(grid_cells) + + # 用于记录已放置的区域 + placed_areas = [] + + for i, img_size in enumerate(polaroid_sizes): + if i < len(images) and i < len(grid_cells): + img = images[i].copy() + # 调整大小 + img = self.resize_and_crop(img, img_size) + # 应用效果 + img = self.apply_image_effect(img) + + # 添加宝丽来相框 + img = self.add_polaroid_frame(img) + + # 轻微旋转(-3到3度之间,进一步减小旋转角度) + rotation = random.uniform(-3, 3) + img = img.rotate(rotation, expand=True, resample=Image.Resampling.BICUBIC) + + # 从当前网格单元获取可用区域 + cell = grid_cells[i] + cell_x1, cell_y1, cell_x2, cell_y2 = cell + + # 确保照片至少有80%在当前网格单元内 + cell_width = cell_x2 - cell_x1 + cell_height = cell_y2 - cell_y1 + + # 计算可用的粘贴位置范围 + min_x = max(10, cell_x1 - img.width * 0.2) # 允许20%超出左边 + max_x = min(width - img.width - 10, cell_x2 - img.width * 0.8) # 确保至少80%在单元内 + + min_y = max(10, cell_y1 - img.height * 0.2) # 允许20%超出上边 + max_y = min(height - img.height - 10, cell_y2 - img.height * 0.8) # 确保至少80%在单元内 + + # 确保坐标范围有效,如果无效则使用单元中心 + if min_x >= max_x: + center_x = (cell_x1 + cell_x2) // 2 + min_x = max(10, center_x - img.width // 2) + max_x = min_x + 1 + + if min_y >= max_y: + center_y = (cell_y1 + cell_y2) // 2 + min_y = max(10, center_y - img.height // 2) + max_y = min_y + 1 + + # 在可用范围内随机选择位置 + paste_x = random.randint(int(min_x), int(max_x)) + paste_y = random.randint(int(min_y), int(max_y)) + + # 记录这个位置 + placed_areas.append((paste_x, paste_y, paste_x + img.width, paste_y + img.height)) + + # 粘贴到拼贴画 + collage.paste(img, (paste_x, paste_y), img) + print(f"添加宝丽来风格块 {i+1} 到位置: ({paste_x},{paste_y}),尺寸: {img.size},单元: {cell}") + + print(f"宝丽来风格拼贴画创建成功,尺寸: {target_size}") + return collage + + def _create_overlap_collage(self, images, target_size): + """创建无重叠风格拼贴画(不允许图片重叠)""" + collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) + width, height = target_size + + # 为了避免重叠,计算每个图像区域的大小 + grid_size = 2 # 2x2网格 + img_width = width // grid_size + img_height = height // grid_size + + # 定义网格位置 - 确保无重叠 + positions = [ + (0, 0), # 左上 + (img_width, 0), # 右上 + (0, img_height), # 左下 + (img_width, img_height) # 右下 + ] + + # 添加图片到位置 + for i, position in enumerate(positions): + if i < len(images): + img = images[i].copy() + # 调整大小 + img = self.resize_and_crop(img, (img_width, img_height)) + # 应用效果 + img = self.apply_image_effect(img) + # 粘贴到拼贴画 + collage.paste(img, position) + print(f"添加无重叠拼贴画块 {i+1} 到位置: {position},尺寸: {img_width}x{img_height}") + + print(f"无重叠拼贴画创建成功,尺寸: {target_size}") + return collage + + def _create_mosaic_collage(self, images, target_size): + """创建马赛克风格拼贴画(需要9张图片,无重叠)""" + collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) + width, height = target_size + + # 创建3x3网格,确保无重叠 + grid_width = width // 3 + grid_height = height // 3 + + # 生成网格位置 + positions = [] + for row in range(3): + for col in range(3): + positions.append((col * grid_width, row * grid_height)) + + # 将图像粘贴到马赛克位置 + for i, position in enumerate(positions): + if i < len(images): + img = images[i].copy() + # 调整大小 + img = self.resize_and_crop(img, (grid_width, grid_height)) + # 应用效果 + img = self.apply_image_effect(img) + # 粘贴到拼贴画 + collage.paste(img, position) + print(f"添加马赛克拼贴画块 {i+1} 到位置: {position},尺寸: {grid_width}x{grid_height}") + + print(f"无重叠马赛克拼贴画创建成功,尺寸: {target_size}") + return collage + + def _create_fullscreen_collage(self, images, target_size): + """创建全覆盖拼图样式(完全填满画布,无空白,无重叠)""" + width, height = target_size + collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) # 白色背景 + + # 确保至少有6张图片 + while len(images) < 6: + if images: + images.append(random.choice(images).copy()) + else: + return None + + # 定义区域划分 - 按照完全填满画布设计,确保无重叠 + regions = [ + # 左列 - 上中下三块 + (0, 0, width//2, height//3), # 左上 + (0, height//3, width//2, height*2//3), # 左中 + (0, height*2//3, width//2, height), # 左下 + + # 右列 - 上中下三块 + (width//2, 0, width, height//3), # 右上 + (width//2, height//3, width, height*2//3), # 右中 + (width//2, height*2//3, width, height) # 右下 + ] + + # 添加图片到各个区域,确保完全覆盖无重叠 + for i, (x1, y1, x2, y2) in enumerate(regions): + if i < len(images): + img = images[i].copy() + + # 调整大小以完全填充区域 + region_width = x2 - x1 + region_height = y2 - y1 + img = self.resize_and_crop(img, (region_width, region_height)) + + # 应用轻微的图像效果 + img = self.apply_image_effect(img) + + # 粘贴到画布上 + collage.paste(img, (x1, y1)) + print(f"添加全屏拼图块 {i+1} 到位置: ({x1}, {y1}, {x2}, {y2}),尺寸: {region_width}x{region_height}") + + print(f"无重叠全覆盖拼图创建成功,尺寸: {target_size}") + return collage + + def _create_vertical_stack_collage(self, images, target_size): + """创建上下拼图样式(两张图片上下排列)""" + collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) # 白色背景 + width, height = target_size + + # 确保至少有2张图片 + while len(images) < 2: + if images: + images.append(images[0].copy()) + else: + print("没有可用的图片来创建上下拼图") + return None + + # 设置间隙(可选) + gap = 0 # 无间隙拼接,设置为0 + + # 计算每张图片的高度 + img_height = (height - gap) // 2 + + # 定义图片位置 + positions = [ + (0, 0), # 上方图片 + (0, img_height + gap) # 下方图片 + ] + + # 添加图片 + for i, position in enumerate(positions): + if i < len(images) and i < 2: # 只使用前两张图片 + img = images[i].copy() + + # 调整大小以适应宽度 + img = self.resize_and_crop(img, (width, img_height)) + + # 应用轻微的图像效果 + img = self.apply_image_effect(img) + + # 粘贴到画布上 + collage.paste(img, position, img) + print(f"添加上下拼图块 {i+1} 到位置: {position}") + + # 可选:添加分隔线 + if gap > 0: + draw = ImageDraw.Draw(collage) + line_y = img_height + gap // 2 + draw.line([(0, line_y), (width, line_y)], fill=(200, 200, 200, 255), width=gap) + + print(f"上下拼图创建成功,尺寸: {target_size}") + return collage + + def save_collage(self, collage, output_path): + """保存拼贴画""" + if collage: + # 确保有背景 - 创建白色背景并将拼贴画合并上去 + background = Image.new('RGB', collage.size, (255, 255, 255)) + # 如果拼贴画有透明通道,将其合并到白色背景上 + if collage.mode == 'RGBA': + background.paste(collage, (0, 0), collage) + final_image = background + else: + final_image = collage.convert('RGB') + + final_image.save(output_path) + print(f"无缝拼贴画已保存: {output_path}") + return output_path + return None + + + def set_collage_style(self, collage_style): + """设置拼贴画样式""" + self.collage_style = collage_style + return self.collage_style + class PosterNotesCreator: """ 处理原始海报作为主图,并随机选择额外的图片作为笔记图片。 @@ -1707,4 +3272,150 @@ def select_additional_images( variation_strength, extra_effects, collage_style - ) \ No newline at end of file + ) + +def process_directory(directory_path, style=None, target_size=(900, 1200), output_count=1): + """ + 处理指定目录中的图片,创建指定数量的拼贴图。 + + 参数: + directory_path: 包含图片的目录路径 + target_size: 拼贴图目标尺寸,默认为 (900, 1200) + output_count: 需要生成的拼贴图数量,默认为 1 + + 返回: + tuple: (拼贴图列表, 使用的图片名称列表的列表),如果生成失败,返回 ([], []) + 拼贴图列表是PIL.Image对象列表 + 图片名称列表是一个列表的列表,每个子列表包含一张拼贴图使用的图片文件名 + """ + logging.info(f"处理目录中的图片并创建 {output_count} 个拼贴图: {directory_path}") + + # 创建 ImageCollageCreator 实例 + collage_creator = ImageCollageCreator() + collage_images = [] + used_image_names = [] # 存储每个拼贴图使用的图片文件名 + + # 检查目录是否存在 + if not os.path.exists(directory_path): + logging.error(f"目录不存在: {directory_path}") + return [], [] + + # 支持的图片格式 + image_extensions = ('.jpg', '.jpeg', '.png', '.bmp') + + # 获取目录中的所有有效图片文件 + try: + all_files = os.listdir(directory_path) + all_images_names = [f for f in all_files + if f.lower().endswith(image_extensions) and os.path.isfile(os.path.join(directory_path, f))] + + if not all_images_names: + logging.error(f"目录中没有有效的图片文件: {directory_path}") + return [], [] + + logging.info(f"目录中找到 {len(all_images_names)} 个有效图片文件") + except Exception as e: + logging.exception(f"列出目录内容时出错: {e}") + return [], [] + + # 尝试创建请求数量的拼贴图 + for i in range(output_count): + try: + # 创建拼贴图,使用指定样式 + collage, selected_images_names = collage_creator.create_collage_with_style( + directory_path, + style=style, + target_size=target_size + ) + + if collage: + collage_images.append(collage) + + # 从输出日志中解析出使用的图片名称 + # 由于我们修改了create_collage_with_style来打印选择的图片 + # 可能需要进一步修改为直接返回选择的图片 + used_image_names.append(selected_images_names) + + logging.info(f"成功创建拼贴图 {i+1}/{output_count}") + else: + logging.error(f"无法创建拼贴图 {i+1}/{output_count}") + except Exception as e: + logging.exception(f"创建拼贴图 {i+1}/{output_count} 时发生异常: {e}") + + logging.info(f"已处理目录 {directory_path},成功创建 {len(collage_images)}/{output_count} 个拼贴图") + return collage_images, used_image_names + +def find_main_subject(image): + # ... (keep the existing implementation) ... + pass + +def adjust_image(image, contrast=1.0, saturation=1.0): + # ... (keep the existing implementation) ... + pass + +def smart_crop_and_resize(image, target_aspect_ratio): + # ... (keep the existing implementation) ... + pass + +def main(): + """展示如何使用 ImageCollageCreator 和 process_directory 函数的示例。""" + logging.basicConfig(level=logging.INFO, + format='%(asctime)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s') + + # 示例目录路径 - 根据实际情况修改 + test_directory = "/root/autodl-tmp/sanming_img/modify/古田会议旧址" # 修改为你实际的图片目录 + + logging.info(f"测试目录: {test_directory}") + + # 方法 1: 使用 process_directory 函数 (推荐用于外部调用) + logging.info("方法 1: 使用 process_directory 函数生成拼贴图...") + collages_1, used_image_names_1 = process_directory( + directory_path=test_directory, + target_size=(900, 1200), # 默认 3:4 比例 + output_count=2 # 创建 2 张不同的拼贴图 + ) + + if collages_1: + logging.info(f"成功创建了 {len(collages_1)} 张拼贴图 (使用 process_directory)") + # 可选: 保存图片到文件 + for i, collage in enumerate(collages_1): + output_path = f"/tmp/collage_method1_{i}.png" + collage.save(output_path) + logging.info(f"拼贴图已保存到: {output_path}") + else: + logging.error("使用 process_directory 创建拼贴图失败") + + # 方法 2: 直接使用 ImageCollageCreator 类 (用于更精细的控制) + logging.info("方法 2: 直接使用 ImageCollageCreator 类...") + creator = ImageCollageCreator() + + # 指定样式创建拼贴图 (可选样式: grid_2x2, asymmetric, filmstrip, overlap, mosaic, fullscreen, vertical_stack) + styles_to_try = ["grid_2x2", "overlap", "mosaic"] + collages_2 = [] + + for style in styles_to_try: + logging.info(f"尝试使用样式: {style}") + collage, selected_images_names = creator.create_collage_with_style( + input_dir=test_directory, + style=style, + target_size=(800, 1000) # 自定义尺寸 + ) + + if collage: + collages_2.append(collage) + # 可选: 保存图片到文件 + output_path = f"/tmp/collage_method2_{style}.png" + collage.save(output_path) + logging.info(f"使用样式 '{style}' 的拼贴图已保存到: {output_path}") + else: + logging.error(f"使用样式 '{style}' 创建拼贴图失败") + + logging.info(f"总共成功创建了 {len(collages_2)} 张拼贴图 (使用 ImageCollageCreator)") + + # 比较两种方法 + logging.info("===== 拼贴图创建测试完成 =====") + logging.info(f"方法 1 (process_directory): {len(collages_1)} 张拼贴图") + logging.info(f"方法 2 (直接使用 ImageCollageCreator): {len(collages_2)} 张拼贴图") + +if __name__ == "__main__": + main() diff --git a/utils/poster/__pycache__/simple_collage.cpython-312.pyc b/utils/poster/__pycache__/simple_collage.cpython-312.pyc deleted file mode 100644 index fee98ce..0000000 Binary files a/utils/poster/__pycache__/simple_collage.cpython-312.pyc and /dev/null differ diff --git a/utils/poster/content_generator.py b/utils/poster/content_generator.py deleted file mode 100644 index e2a9b93..0000000 --- a/utils/poster/content_generator.py +++ /dev/null @@ -1,570 +0,0 @@ -#!/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 - -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"\n{title}\n\n\n{content}\n\n\n" - elif isinstance(item, str): - tweet_content += self._preprocess_for_json(item) + "\n\n" - elif isinstance(content_data_list, str): - tweet_content = self._preprocess_for_json(content_data_list) - - # 构建用户提示 - if self.add_description: - # 预处理景点描述 - processed_description = self._preprocess_for_json(self.add_description) - user_content = f""" - 以下是需要你处理的信息: - - 关于景点的描述: - {processed_description} - - 推文内容: - {tweet_content} - - 请根据这些信息,生成{poster_num}个海报文案配置,以JSON数组格式返回。 - """ - else: - user_content = f""" - 以下是需要你处理的推文内容: - {tweet_content} - - 请根据这些信息,生成{poster_num}个海报文案配置,以JSON数组格式返回。 - """ - - self.logger.info(f"正在生成{poster_num}个海报文案配置") - - # 创建AI_Agent实例 - ai_agent = AI_Agent( - self.base_url, - self.model_name, - self.api_key, - timeout=timeout, - max_retries=max_retries, - stream_chunk_timeout=30 # 流式块超时时间 - ) - - full_response = "" - try: - # 使用AI_Agent的non-streaming方法 - self.logger.info(f"调用AI生成海报配置,模型: {self.model_name}") - full_response, tokens, time_cost = ai_agent.work( - self.system_prompt, - user_content, - "", # 历史消息(空) - self.temperature, - self.top_p, - self.presence_penalty - ) - - self.logger.info(f"AI生成完成,耗时: {time_cost:.2f}s, 预估令牌数: {tokens}") - - if not full_response: - self.logger.warning("AI返回空响应,使用备用内容") - full_response = self._generate_fallback_content(poster_num) - except Exception as e: - self.logger.exception(f"AI生成过程发生错误: {e}") - full_response = self._generate_fallback_content(poster_num) - finally: - # 确保关闭AI Agent - ai_agent.close() - - return full_response - - def _generate_fallback_content(self, poster_num): - """生成备用内容,当API调用失败时使用""" - self.logger.info("生成备用内容") - default_configs = [] - for i in range(poster_num): - default_configs.append({ - "index": i + 1, - "main_title": "", - "texts": ["", ""] - }) - return json.dumps(default_configs, ensure_ascii=False) - - def save_result(self, full_response, custom_output_dir=None): - """ - 保存生成结果到文件 - - 参数: - full_response: 生成的完整响应内容 - custom_output_dir: 自定义输出目录(可选) - - 返回: - 结果文件路径 - """ - # 生成时间戳 - date_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - output_dir = custom_output_dir or self.output_dir - - try: - # 解析内容为JSON格式 - parsed_data = self.split_content(full_response) - - # 验证内容格式并修复 - validated_data = self._validate_and_fix_data(parsed_data) - # 创建结果文件路径 - result_path = os.path.join(output_dir, f"{date_time}.json") - os.makedirs(os.path.dirname(result_path), exist_ok=True) - - # 保存结果到文件 - with open(result_path, "w", encoding="utf-8") as f: - json.dump(validated_data, f, ensure_ascii=False, indent=4, ignore_nan=True) - - self.logger.info(f"结果已保存到: {result_path}") - return result_path - - except Exception as e: - self.logger.error(f"保存结果到文件时出错: {e}") - # 尝试创建一个简单的备用配置 - fallback_data = [{"main_title": "", "texts": ["", ""], "index": 1}] - - # 保存备用数据 - result_path = os.path.join(output_dir, f"{date_time}_fallback.json") - os.makedirs(os.path.dirname(result_path), exist_ok=True) - - with open(result_path, "w", encoding="utf-8") as f: - json.dump(fallback_data, f, ensure_ascii=False, indent=4, ignore_nan=True) - - self.logger.info(f"出错后已保存备用数据到: {result_path}") - return result_path - - def _validate_and_fix_data(self, data): - """ - 验证并修复从AI返回的数据,确保其符合期望的结构 - - Args: - data: 需要验证的数据 - - Returns: - list: 修复后的数据列表 - """ - fixed_data = [] - self.logger.info(f"验证并修复数据: {type(data)}") - - # 尝试处理字符串类型 (通常是JSON字符串) - if isinstance(data, str): - try: - # 尝试将字符串解析为JSON对象 - parsed_data = json.loads(data) - # 递归调用本函数处理解析后的数据 - return self._validate_and_fix_data(parsed_data) - except json.JSONDecodeError as e: - self.logger.warning(f"JSON解析失败: {e}") - # 可以选择尝试清理和再次解析 - try: - # 寻找字符串中第一个 [ 和最后一个 ] 之间的内容 - start_idx = data.find('[') - end_idx = data.rfind(']') - if start_idx >= 0 and end_idx > start_idx: - json_part = data[start_idx:end_idx+1] - self.logger.info(f"尝试从字符串中提取JSON部分: {json_part[:100]}...") - parsed_data = json.loads(json_part) - return self._validate_and_fix_data(parsed_data) - except: - self.logger.warning("无法从字符串中提取有效的JSON部分") - fixed_data.append({ - "index": 1, - "main_title": self._preprocess_for_json("默认标题"), # 应用预处理 - "texts": [self._preprocess_for_json("默认副标题1"), self._preprocess_for_json("默认副标题2")] # 应用预处理 - }) - - # 处理列表类型 - elif isinstance(data, list): - for idx, item in enumerate(data): - # 如果是字典,检查必须字段 - if isinstance(item, dict): - fixed_item = {} - # 设置索引 - fixed_item["index"] = item.get("index", idx + 1) - - # 处理主标题 - if "main_title" in item and item["main_title"]: - # 应用预处理,确保所有换行符被正确转义 - fixed_item["main_title"] = self._preprocess_for_json(item["main_title"]) - else: - fixed_item["main_title"] = "默认标题" - - # 处理文本列表 - if "texts" in item and isinstance(item["texts"], list) and len(item["texts"]) > 0: - # 对文本列表中的每个元素应用预处理 - fixed_item["texts"] = [self._preprocess_for_json(text) if text else "" for text in item["texts"]] - # 确保至少有两个元素 - while len(fixed_item["texts"]) < 2: - fixed_item["texts"].append("") - else: - fixed_item["texts"] = ["默认副标题1", "默认副标题2"] - - fixed_data.append(fixed_item) - - # 如果是字符串,转换为默认格式 - elif isinstance(item, str): - fixed_data.append({ - "index": idx + 1, - "main_title": self._preprocess_for_json(item), # 应用预处理 - "texts": ["", ""] - }) - - # 其他类型,使用默认值 - else: - fixed_data.append({ - "index": idx + 1, - "main_title": "默认标题", - "texts": ["", ""] - }) - - # 处理字典类型 (单个配置项) - elif isinstance(data, dict): - # 处理主标题 - main_title = self._preprocess_for_json(data.get("main_title", "默认标题")) # 应用预处理 - - # 处理文本列表 - texts = [] - if "texts" in data and isinstance(data["texts"], list): - texts = [self._preprocess_for_json(text) if text else "" for text in data["texts"]] # 应用预处理 - - # 确保文本列表至少有两个元素 - while len(texts) < 2: - texts.append("") - - fixed_data.append({ - "index": data.get("index", 1), - "main_title": main_title, - "texts": texts - }) - - # 如果数据是其他格式 - else: - self.logger.warning(f"数据格式不支持: {type(data)},将使用默认值") - fixed_data.append({ - "index": 1, - "main_title": "", - "texts": ["", ""] - }) - - # 确保至少有一个配置项 - if not fixed_data: - fixed_data.append({ - "index": 1, - "main_title": "", - "texts": ["", ""] - }) - - self.logger.info(f"修复后的数据: {fixed_data}") - return fixed_data - - def run(self, info_directory, poster_num, content_data, system_prompt=None, - api_url="http://localhost:8000/v1", model_name="qwenQWQ", api_key="EMPTY", timeout=120): - """ - 运行海报内容生成流程,并返回生成的配置数据。 - - 参数: - info_directory: 信息目录路径列表 (e.g., ['/path/to/description.txt']) - poster_num: 需要生成的海报配置数量 - content_data: 用于生成内容的文章内容(可以是字符串或字典列表) - system_prompt: 系统提示词,默认为None使用内置提示词 - api_url: API基础URL - model_name: 使用的模型名称 - api_key: API密钥 - - 返回: - list | dict | None: 生成的海报配置数据 (通常是列表),如果生成或解析失败则返回 None。 - """ - try: - # 加载描述信息 - self.load_infomation(info_directory) - - # 生成海报内容 - self.logger.info(f"开始生成海报内容,数量: {poster_num}") - full_response = self.generate_posters( - poster_num, - content_data, - system_prompt, - api_url, - model_name, - api_key, - timeout=timeout, - ) - - # 检查生成是否失败 - if not isinstance(full_response, str) or not full_response.strip(): - self.logger.error("海报内容生成失败或返回空响应") - return None - - # 从原始响应字符串中提取JSON数据 - result_data = self.split_content(full_response) - - # 验证并修复数据 - fixed_data = self._validate_and_fix_data(result_data) - - self.logger.info(f"成功生成并修复海报配置数据,包含 {len(fixed_data) if isinstance(fixed_data, list) else 1} 个项目") - return fixed_data - - except Exception as e: - self.logger.exception(f"海报内容生成过程中发生错误: {e}") - traceback.print_exc() - - # 失败后创建一个默认配置 - self.logger.info("创建默认海报配置数据") - default_configs = [] - for i in range(poster_num): - default_configs.append({ - "index": i + 1, - "main_title": "", - "texts": ["", ""] - }) - return default_configs - - def set_temperature(self, temperature): - """设置温度参数""" - self.temperature = temperature - - def set_top_p(self, top_p): - """设置top_p参数""" - self.top_p = top_p - - def set_presence_penalty(self, presence_penalty): - """设置存在惩罚参数""" - self.presence_penalty = presence_penalty - - def set_model_para(self, temperature, top_p, presence_penalty): - """一次性设置所有模型参数""" - self.temperature = temperature - self.top_p = top_p - self.presence_penalty = presence_penalty \ No newline at end of file diff --git a/utils/poster/image_processor.py b/utils/poster/image_processor.py deleted file mode 100644 index 87ab665..0000000 --- a/utils/poster/image_processor.py +++ /dev/null @@ -1,303 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import os -import logging -from PIL import Image -import numpy as np -from typing import Tuple, Union, Optional -import psutil -import gc # 添加垃圾回收模块 - -class ImageProcessor: - """ - 图像处理工具类,提供智能图像加载和压缩功能 - """ - - def __init__(self, max_memory_percent=80, target_max_pixels=4000000): - """ - 初始化图像处理器 - - Args: - max_memory_percent: 最大内存使用百分比,超过此值将更激进地压缩图像 - target_max_pixels: 目标最大像素数(宽×高),用于控制图像大小 - """ - self.max_memory_percent = max_memory_percent - self.target_max_pixels = target_max_pixels - self.logger = logging.getLogger(self.__class__.__name__) - # 记录已加载的图像,用于后续清理 - self.loaded_images = [] - - def get_memory_usage(self) -> Tuple[float, float]: - """ - 获取当前内存使用情况 - - Returns: - (已用内存百分比, 可用内存MB) - """ - memory = psutil.virtual_memory() - return memory.percent, memory.available / (1024 * 1024) - - def check_memory_pressure(self) -> bool: - """ - 检查内存压力,如果内存使用率超过阈值,执行垃圾回收 - - Returns: - 是否存在内存压力 - """ - mem_percent, available_mb = self.get_memory_usage() - - # 如果内存使用率超过阈值或可用内存低于100MB,认为存在内存压力 - if mem_percent > self.max_memory_percent or available_mb < 100: - self.logger.warning(f"内存压力过大: 使用率 {mem_percent:.1f}%, 可用 {available_mb:.1f}MB,执行垃圾回收") - self.force_garbage_collection() - return True - return False - - def force_garbage_collection(self): - """ - 强制执行垃圾回收并清理已加载的图像资源 - """ - # 释放已加载的图像 - self.release_loaded_images() - - # 执行多次垃圾回收 - for _ in range(3): - gc.collect() - - # 记录垃圾回收后的内存状态 - mem_percent, available_mb = self.get_memory_usage() - self.logger.info(f"垃圾回收后内存状态: 使用率 {mem_percent:.1f}%, 可用 {available_mb:.1f}MB") - - def release_loaded_images(self): - """ - 释放所有已加载的图像资源 - """ - for img in self.loaded_images: - try: - if hasattr(img, 'close') and callable(img.close): - img.close() - except Exception as e: - self.logger.error(f"释放图像资源失败: {e}") - - # 清空列表 - self.loaded_images = [] - self.logger.info("已释放所有已加载的图像资源") - - def calculate_resize_factor(self, width: int, height: int) -> float: - """ - 根据图像尺寸计算压缩因子 - - Args: - width: 图像宽度 - height: 图像高度 - - Returns: - 压缩因子 (0.1-1.0) - """ - # 计算原始像素数 - original_pixels = width * height - - # 如果小于目标像素数,不需要压缩 - if original_pixels <= self.target_max_pixels: - return 1.0 - - # 计算基础压缩因子 - base_factor = (self.target_max_pixels / original_pixels) ** 0.5 - - # 检查内存使用情况 - mem_percent, available_mb = self.get_memory_usage() - - # 根据内存使用情况调整压缩因子 - if mem_percent > self.max_memory_percent: - # 内存紧张,增加压缩率 - memory_factor = 1.0 - ((mem_percent - self.max_memory_percent) / 20) - memory_factor = max(0.5, memory_factor) # 至少保留50%质量 - base_factor *= memory_factor - self.logger.warning(f"内存使用率高 ({mem_percent:.1f}%),增加压缩率,压缩因子调整为 {base_factor:.2f}") - - # 确保压缩因子在合理范围内 - return max(0.1, min(1.0, base_factor)) - - def smart_load_image(self, image_path: str, target_size: Optional[Tuple[int, int]] = None) -> Image.Image: - """ - 智能加载图像,根据内存情况和图像大小自动调整尺寸 - - Args: - image_path: 图像路径 - target_size: 目标尺寸,如果指定则直接调整到此尺寸 - - Returns: - 加载并调整大小后的PIL图像对象 - """ - # 先检查内存压力 - self.check_memory_pressure() - - try: - # 检查文件是否存在 - if not os.path.exists(image_path): - self.logger.error(f"图像文件不存在: {image_path}") - # 返回一个空白图像 - return Image.new('RGB', target_size or (800, 600), (240, 240, 240)) - - # 获取文件大小(MB) - file_size_mb = os.path.getsize(image_path) / (1024 * 1024) - - # 如果文件过大,先检查内存 - if file_size_mb > 10: # 大于10MB的文件 - mem_percent, available_mb = self.get_memory_usage() - self.logger.info(f"大文件 ({file_size_mb:.1f}MB), 内存使用率: {mem_percent:.1f}%, 可用: {available_mb:.1f}MB") - - if mem_percent > 90 or available_mb < file_size_mb * 5: - # 内存紧张,使用更保守的加载方式 - self.logger.warning(f"内存不足,使用保守加载方式") - result = self._conservative_load(image_path, target_size) - self.loaded_images.append(result) - return result - - # 常规加载 - with Image.open(image_path) as img: - # 获取原始尺寸 - original_width, original_height = img.size - self.logger.debug(f"原始图像尺寸: {original_width}x{original_height}") - - # 如果指定了目标尺寸,直接调整 - if target_size: - result = img.resize(target_size, Image.Resampling.LANCZOS) - self.loaded_images.append(result) - return result - - # 计算压缩因子 - resize_factor = self.calculate_resize_factor(original_width, original_height) - - if resize_factor < 1.0: - # 需要压缩 - new_width = int(original_width * resize_factor) - new_height = int(original_height * resize_factor) - self.logger.info(f"压缩图像 {image_path} 从 {original_width}x{original_height} 到 {new_width}x{new_height}") - result = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - self.loaded_images.append(result) - return result - else: - # 不需要压缩,返回原图副本 - result = img.copy() - self.loaded_images.append(result) - return result - - except Exception as e: - self.logger.error(f"加载图像 {image_path} 失败: {e}") - # 返回一个空白图像 - return Image.new('RGB', target_size or (800, 600), (240, 240, 240)) - - def _conservative_load(self, image_path: str, target_size: Optional[Tuple[int, int]] = None) -> Image.Image: - """ - 保守加载大图像,先获取尺寸,然后以较低质量加载 - - Args: - image_path: 图像路径 - target_size: 目标尺寸 - - Returns: - 加载的PIL图像对象 - """ - try: - # 先只获取图像信息,不加载像素数据 - with Image.open(image_path) as img: - original_width, original_height = img.size - format = img.format - - # 计算合适的缩小尺寸 - if target_size: - new_width, new_height = target_size - else: - # 计算一个非常保守的压缩因子 - pixels = original_width * original_height - conservative_factor = min(0.5, (2000000 / pixels) ** 0.5) - new_width = int(original_width * conservative_factor) - new_height = int(original_height * conservative_factor) - - # 使用缩略图方式加载 - with Image.open(image_path) as img: - img.thumbnail((new_width, new_height), Image.Resampling.LANCZOS) - # 创建新图像以确保释放原始文件句柄 - result = img.copy() - - self.logger.info(f"保守加载图像 {image_path} 从 {original_width}x{original_height} 到 {new_width}x{new_height}") - return result - - except Exception as e: - self.logger.error(f"保守加载图像失败: {e}") - return Image.new('RGB', target_size or (800, 600), (240, 240, 240)) - - def batch_process_directory(self, directory: str, max_images: int = 100) -> list: - """ - 批量处理目录中的图像,返回处理后的图像列表 - - Args: - directory: 图像目录 - max_images: 最大处理图像数量 - - Returns: - 处理后的PIL图像对象列表 - """ - if not os.path.exists(directory): - self.logger.error(f"目录不存在: {directory}") - return [] - - # 获取所有图像文件 - image_files = [] - for root, _, files in os.walk(directory): - for file in files: - if file.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.bmp')): - image_files.append(os.path.join(root, file)) - - # 限制图像数量 - if len(image_files) > max_images: - self.logger.warning(f"图像文件过多 ({len(image_files)}), 限制为 {max_images} 个") - image_files = image_files[:max_images] - - # 批量加载图像 - images = [] - for image_file in image_files: - img = self.smart_load_image(image_file) - if img: - images.append(img) - - # 每处理5张图片检查一次内存压力 - if len(images) % 5 == 0: - self.check_memory_pressure() - - self.logger.info(f"已处理 {len(images)}/{len(image_files)} 个图像") - return images - - def save_optimized_image(self, image: Image.Image, output_path: str, quality: int = 85) -> bool: - """ - 保存优化后的图像 - - Args: - image: PIL图像对象 - output_path: 输出路径 - quality: JPEG质量 (1-100) - - Returns: - 是否保存成功 - """ - try: - # 确保输出目录存在 - os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True) - - # 检查图像模式,如果是RGBA且保存为JPEG,先转换为RGB - if image.mode == 'RGBA' and (output_path.lower().endswith('.jpg') or output_path.lower().endswith('.jpeg')): - image = image.convert('RGB') - - # 保存图像 - image.save(output_path, quality=quality, optimize=True) - self.logger.info(f"图像已保存至: {output_path}") - - # 保存后检查内存压力 - self.check_memory_pressure() - - return True - except Exception as e: - self.logger.error(f"保存图像失败: {e}") - return False \ No newline at end of file diff --git a/utils/poster/simple_collage.py b/utils/poster/simple_collage.py deleted file mode 100644 index b607da6..0000000 --- a/utils/poster/simple_collage.py +++ /dev/null @@ -1,835 +0,0 @@ -# -*- coding: utf-8 -*- -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 - -class ImageCollageCreator: - def __init__(self, ): - """初始化拼图创建器""" - - # 定义可用拼接样式 - self.collage_styles = [ - "grid_2x2", # 标准2x2网格 - # "asymmetric", # 非对称布局 - # "filmstrip", # 胶片条布局 - # "circles", # 圆形布局 - "overlap", # 重叠风格 - # "mosaic", # 马赛克风格 3x3 - "fullscreen", # 全覆盖拼图样式 - "vertical_stack" # 新增:上下拼图样式 - # "polaroid", # 宝丽来风格 - ] - - def resize_and_crop(self, img, target_size): - """调整图片大小并居中裁剪为指定尺寸""" - width, height = img.size - target_width, target_height = target_size - - # 计算宽高比 - img_ratio = width / height - target_ratio = target_width / target_height - - if img_ratio > target_ratio: - # 图片较宽,以高度为基准调整 - new_height = target_height - new_width = int(width * target_height / height) - img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - # 居中裁剪 - left = (new_width - target_width) // 2 - img = img.crop((left, 0, left + target_width, target_height)) - else: - # 图片较高,以宽度为基准调整 - new_width = target_width - new_height = int(height * target_width / width) - img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - # 居中裁剪 - top = (new_height - target_height) // 2 - img = img.crop((0, top, target_width, top + target_height)) - - return img - - def add_border(self, img, color=(255, 255, 255, 200), width=2, no_border=True): - """给图像添加边框,可选择不添加边框""" - if no_border: - return img # 如果设置为无边框,直接返回原图 - - try: - w, h = img.size - new_img = Image.new('RGBA', (w, h), (0, 0, 0, 0)) - draw = ImageDraw.Draw(new_img) - - # 绘制边框(在四条边上) - for i in range(width): - # 上边框 - draw.line([(i, i), (w-i-1, i)], fill=color, width=1) - # 右边框 - draw.line([(w-i-1, i), (w-i-1, h-i-1)], fill=color, width=1) - # 下边框 - draw.line([(i, h-i-1), (w-i-1, h-i-1)], fill=color, width=1) - # 左边框 - draw.line([(i, i), (i, h-i-1)], fill=color, width=1) - - # 合并原图和边框 - result = img.copy() - result.alpha_composite(new_img) - - return result - except Exception as e: - print(f"添加边框时出错: {str(e)}") - return img - - def add_polaroid_frame(self, img, margin=20, bottom_margin=60, background_color=(255, 255, 255, 255)): - """添加宝丽来风格的相框""" - try: - w, h = img.size - frame_width = w + 2 * margin - frame_height = h + margin + bottom_margin - - # 创建白色背景 - frame = Image.new('RGBA', (frame_width, frame_height), background_color) - - # 将图像粘贴到框架中 - frame.paste(img, (margin, margin)) - - # 添加稍微的阴影效果 - shadow = Image.new('RGBA', frame.size, (0, 0, 0, 0)) - shadow_draw = ImageDraw.Draw(shadow) - shadow_draw.rectangle([2, 2, frame_width-2, frame_height-2], fill=(0, 0, 0, 40)) - - # 模糊阴影 - shadow = shadow.filter(ImageFilter.GaussianBlur(3)) - - # 创建最终图像 - final = Image.new('RGBA', (frame_width+6, frame_height+6), (0, 0, 0, 0)) - final.paste(shadow, (6, 6)) - final.paste(frame, (0, 0), frame) - - return final - except Exception as e: - print(f"添加宝丽来相框时出错: {str(e)}") - return img - - def apply_image_effect(self, img, effect="none"): - """应用各种图像效果 - 所有图片适度增强对比度和亮度""" - try: - # 适度增强对比度 - contrast = ImageEnhance.Contrast(img) - enhanced = contrast.enhance(1.1) # 降低对比度系数,从1.6降至1.3 - - # 轻微增强亮度 - brightness = ImageEnhance.Brightness(enhanced) - enhanced = brightness.enhance(1.1) # 保持轻微增加亮度 - - # 轻微增强色彩饱和度 - color = ImageEnhance.Color(enhanced) - enhanced = color.enhance(1.15) # 轻微降低饱和度,从1.2降至1.15 - - return enhanced - except Exception as e: - print(f"增强图片效果时出错: {str(e)}") - return img - - def create_collage_with_style(self, input_dir, style=None, target_size=None): - """创建指定样式的拼接画布 - - 参数: - input_dir: 输入图片目录路径 - style: 拼贴样式,如不指定则随机选择 - target_size: 目标尺寸,默认为(900, 1200) - - 返回: - tuple: (拼贴图, 选择的图片名称列表),如果创建失败则返回(None, []) - """ - logging.info(f"--- Starting Collage Creation for Directory: {input_dir} ---") # Start Log - try: - # 设置默认尺寸为3:4比例 - if target_size is None: - target_size = (900, 1200) # 3:4比例 - - # 如果没有指定样式,随机选择一种 - if style is None or style not in self.collage_styles: - style = random.choice(self.collage_styles) - logging.info(f"Using collage style: {style} with target size: {target_size}") - - # 检查目录是否存在 - if not os.path.exists(input_dir): - logging.error(f"Input directory does not exist: {input_dir}") - return None, [] - - # 支持的图片格式 - image_extensions = ('.jpg', '.jpeg', '.png', '.bmp') - - # 获取目录中的所有文件 - try: - all_files = os.listdir(input_dir) - logging.info(f"Files found in directory: {all_files}") - except Exception as e: - logging.exception(f"Error listing directory {input_dir}: {e}") - return None, [] - - # 过滤图片文件 - all_images_names = [f for f in all_files - if f.lower().endswith(image_extensions) and os.path.isfile(os.path.join(input_dir, f))] - logging.info(f"Filtered image files: {all_images_names}") - - if not all_images_names: - logging.warning(f"No valid image files found in directory: {input_dir}") - return None, [] # Return None if no images found - - # 根据不同样式,确定需要的图片数量 - # ... (logic for num_images based on style) ... - num_images = 4 - if style == "mosaic": - num_images = 9 - elif style == "filmstrip": - num_images = 5 - elif style == "fullscreen": - num_images = 6 - elif style == "vertical_stack": - num_images = 2 - logging.info(f"Style '{style}' requires {num_images} images.") - - # 确保有足够的图像 (或重复使用) - selected_images_names = [] - if len(all_images_names) < num_images: - logging.warning(f"Need {num_images} images for style '{style}', but only found {len(all_images_names)}. Will repeat images.") - if len(all_images_names) > 0: - # Repeat available images to meet the count - selected_images_names = (all_images_names * (num_images // len(all_images_names) + 1))[:num_images] - else: - logging.error("Cannot select images, none were found.") # Should not happen due to earlier check - return None, [] - else: - # 随机选择指定数量的图片 - selected_images_names = random.sample(all_images_names, num_images) - - # 记录并输出被选择的图片名称 - logging.info(f"Selected images for collage: {selected_images_names}") - print(f"为拼贴图选择的图片: {selected_images_names}") - - # 加载选中的图片 - images = [] - for img_name in selected_images_names: - image_path = os.path.join(input_dir, img_name) - try: - img = Image.open(image_path).convert('RGBA') - images.append(img) - logging.debug(f"Successfully loaded image: {img_name}") - except Exception as e: - logging.exception(f"Error loading image {img_name}: {e}") - - if len(images) == 0: - logging.error("No images could be loaded. Cannot create collage.") - return None, [] - - # 确保图片数量满足要求,不足则复制已有图片 - while len(images) < num_images: - images.append(random.choice(images).copy()) - logging.debug(f"Duplicated an image to reach required count of {num_images}") - - # 根据样式创建拼图 - collage = None - if style == "grid_2x2": - collage = self._create_grid_2x2_collage(images, target_size) - elif style == "asymmetric": - collage = self._create_asymmetric_collage(images, target_size) - elif style == "filmstrip": - collage = self._create_filmstrip_collage(images, target_size) - elif style == "circles": - collage = self._create_circles_collage(images, target_size) - elif style == "overlap": - collage = self._create_overlap_collage(images, target_size) - elif style == "polaroid": - collage = self._create_polaroid_collage(images, target_size) - elif style == "mosaic": - collage = self._create_mosaic_collage(images, target_size) - elif style == "fullscreen": - collage = self._create_fullscreen_collage(images, target_size) - elif style == "vertical_stack": - collage = self._create_vertical_stack_collage(images, target_size) - - if collage: - logging.info(f"Successfully created collage with style: {style}") - else: - logging.error(f"Failed to create collage with style: {style}") - return None, [] - - # 清理内存中的原始图像 - for img in images: - if hasattr(img, 'close'): - img.close() - - return collage, selected_images_names - except Exception as e: - logging.exception(f"Error in create_collage_with_style: {e}") - traceback.print_exc() - return None, [] - - def _create_grid_2x2_collage(self, images, target_size): - """创建2x2网格拼贴画""" - collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) # 使用白色背景 - - # 计算每个块的大小 - block_width = target_size[0] // 2 - block_height = target_size[1] // 2 - - # 定义四个区域位置 - positions = [ - (0, 0), # 左上 - (block_width, 0), # 右上 - (0, block_height), # 左下 - (block_width, block_height) # 右下 - ] - - # 将图像粘贴到拼贴画位置 - for i, position in enumerate(positions): - if i < len(images): - img = images[i].copy() - # 调整大小 - img = self.resize_and_crop(img, (block_width, block_height)) - # 不应用边框,实现无缝拼接 - # 粘贴到拼贴画 - collage.paste(img, position, img) - print(f"添加拼贴画块 {i+1} 到位置: {position}") - - print(f"无缝2x2网格拼贴画创建成功,尺寸: {target_size}") - return collage - - def _create_asymmetric_collage(self, images, target_size): - """创建非对称布局拼贴画""" - collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) - width, height = target_size - - # 定义非对称区域位置 - positions = [ - (0, 0, width*2//3, height//2), # 左上 (大) - (width*2//3, 0, width, height//3), # 右上 - (width*2//3, height//3, width, height//2), # 右中 - (0, height//2, width, height) # 底部 (全宽) - ] - - # 定义不同的效果 - effects = ["none", "grayscale", "vintage", "color_boost"] - random.shuffle(effects) - - # 将图像粘贴到拼贴画位置 - for i, (x1, y1, x2, y2) in enumerate(positions): - if i < len(images): - img = images[i].copy() - # 调整大小 - img = self.resize_and_crop(img, (x2-x1, y2-y1)) - # 应用效果 - img = self.apply_image_effect(img, effects[i % len(effects)]) - # 不添加边框 - # 粘贴到拼贴画 - collage.paste(img, (x1, y1), img) - print(f"添加非对称拼贴画块 {i+1} 到位置: ({x1},{y1},{x2},{y2})") - - print(f"无缝非对称拼贴画创建成功,尺寸: {target_size}") - return collage - - def _create_filmstrip_collage(self, images, target_size): - """创建胶片条布局拼贴画""" - collage = Image.new('RGBA', target_size, (0, 0, 0, 0)) - width, height = target_size - - # 胶片条中每个图像的高度 - strip_height = height // 5 - - # 添加黑条边框 - film_border_width = 15 - - # 将图像粘贴为胶片条 - for i in range(5): - if i < len(images): - img = images[i].copy() - # 调整大小,考虑边框 - img = self.resize_and_crop(img, (width - 2*film_border_width, strip_height - 2*film_border_width)) - # 不应用效果,保持原始颜色 - - # 创建黑色胶片边框 - film_frame = Image.new('RGBA', (width, strip_height), (0, 0, 0, 255)) - # 在边框中间贴上图片 - film_frame.paste(img, (film_border_width, film_border_width), img) - - # 添加胶片冲孔 - draw = ImageDraw.Draw(film_frame) - hole_spacing = 30 - hole_radius = 5 - num_holes = width // hole_spacing - for h in range(num_holes): - hole_center_x = h * hole_spacing + hole_spacing // 2 - # 顶部和底部的冲孔 - draw.ellipse((hole_center_x - hole_radius, 3, hole_center_x + hole_radius, 13), fill=(50, 50, 50, 255)) - draw.ellipse((hole_center_x - hole_radius, strip_height - 13, hole_center_x + hole_radius, strip_height - 3), fill=(50, 50, 50, 255)) - - # 粘贴到拼贴画 - y_position = i * strip_height - collage.paste(film_frame, (0, y_position), film_frame) - print(f"添加胶片条拼贴画块 {i+1} 到位置 y={y_position}") - - print(f"胶片条拼贴画创建成功,尺寸: {target_size}") - return collage - - def _create_circles_collage(self, images, target_size): - """创建圆形布局拼贴画""" - collage = Image.new('RGBA', target_size, (0, 0, 0, 0)) - width, height = target_size - - # 定义圆形的位置和大小 - circle_positions = [ - (width//4, height//4, width//2.5), # 左上 - (width*3//4, height//4, width//3), # 右上 - (width//4, height*3//4, width//3), # 左下 - (width*3//4, height*3//4, width//2.5) # 右下 - ] - - # 为每个圆形创建蒙版 - for i, (center_x, center_y, radius) in enumerate(circle_positions): - if i < len(images): - img = images[i].copy() - # 应用效果 - img = self.apply_image_effect(img) - # 调整图像大小为圆的直径 - 确保是整数 - diam = int(radius*2) - img = self.resize_and_crop(img, (diam, diam)) - - # 创建圆形蒙版 - mask = Image.new('L', img.size, 0) - draw = ImageDraw.Draw(mask) - draw.ellipse((0, 0, mask.width, mask.height), fill=255) - # 模糊边缘 - mask = mask.filter(ImageFilter.GaussianBlur(radius=5)) - - # 应用蒙版 - img.putalpha(mask) - - # 计算粘贴位置,使圆心在定义的位置 - paste_x = int(center_x - radius) - paste_y = int(center_y - radius) - - # 粘贴到拼贴画 - collage.paste(img, (paste_x, paste_y), img) - print(f"添加圆形拼贴画块 {i+1} 到位置: ({paste_x},{paste_y})") - - # 添加轻微的渐变背景 - background = Image.new('RGBA', target_size, (245, 245, 245, 100)) - collage = Image.alpha_composite(background, collage) - - print(f"圆形拼贴画创建成功,尺寸: {target_size}") - return collage - - def _create_polaroid_collage(self, images, target_size): - """创建宝丽来风格拼贴画 - 最小化图片重叠""" - collage = Image.new('RGBA', target_size, (240, 240, 240, 255)) - width, height = target_size - - # 宝丽来照片的大小 - 适当调整尺寸,减少重叠 - polaroid_sizes = [ - (int(width//2.2), int(height//2.8)), # 大号 - (int(width//2.5), int(height//3)), # 中大号 - (int(width//2.8), int(height//3.5)), # 中号 - (int(width//3), int(height//4)) # 中小号 - ] - - # 随机打乱尺寸 - random.shuffle(polaroid_sizes) - - # 创建网格布局,降低重叠概率 - grid_cells = [ - (0, 0, width//2, height//2), # 左上 - (width//2, 0, width, height//2), # 右上 - (0, height//2, width//2, height), # 左下 - (width//2, height//2, width, height) # 右下 - ] - - # 随机打乱网格单元 - random.shuffle(grid_cells) - - # 用于记录已放置的区域 - placed_areas = [] - - for i, img_size in enumerate(polaroid_sizes): - if i < len(images) and i < len(grid_cells): - img = images[i].copy() - # 调整大小 - img = self.resize_and_crop(img, img_size) - # 应用效果 - img = self.apply_image_effect(img) - - # 添加宝丽来相框 - img = self.add_polaroid_frame(img) - - # 轻微旋转(-3到3度之间,进一步减小旋转角度) - rotation = random.uniform(-3, 3) - img = img.rotate(rotation, expand=True, resample=Image.Resampling.BICUBIC) - - # 从当前网格单元获取可用区域 - cell = grid_cells[i] - cell_x1, cell_y1, cell_x2, cell_y2 = cell - - # 确保照片至少有80%在当前网格单元内 - cell_width = cell_x2 - cell_x1 - cell_height = cell_y2 - cell_y1 - - # 计算可用的粘贴位置范围 - min_x = max(10, cell_x1 - img.width * 0.2) # 允许20%超出左边 - max_x = min(width - img.width - 10, cell_x2 - img.width * 0.8) # 确保至少80%在单元内 - - min_y = max(10, cell_y1 - img.height * 0.2) # 允许20%超出上边 - max_y = min(height - img.height - 10, cell_y2 - img.height * 0.8) # 确保至少80%在单元内 - - # 确保坐标范围有效,如果无效则使用单元中心 - if min_x >= max_x: - center_x = (cell_x1 + cell_x2) // 2 - min_x = max(10, center_x - img.width // 2) - max_x = min_x + 1 - - if min_y >= max_y: - center_y = (cell_y1 + cell_y2) // 2 - min_y = max(10, center_y - img.height // 2) - max_y = min_y + 1 - - # 在可用范围内随机选择位置 - paste_x = random.randint(int(min_x), int(max_x)) - paste_y = random.randint(int(min_y), int(max_y)) - - # 记录这个位置 - placed_areas.append((paste_x, paste_y, paste_x + img.width, paste_y + img.height)) - - # 粘贴到拼贴画 - collage.paste(img, (paste_x, paste_y), img) - print(f"添加宝丽来风格块 {i+1} 到位置: ({paste_x},{paste_y}),尺寸: {img.size},单元: {cell}") - - print(f"宝丽来风格拼贴画创建成功,尺寸: {target_size}") - return collage - - def _create_overlap_collage(self, images, target_size): - """创建无重叠风格拼贴画(不允许图片重叠)""" - collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) - width, height = target_size - - # 为了避免重叠,计算每个图像区域的大小 - grid_size = 2 # 2x2网格 - img_width = width // grid_size - img_height = height // grid_size - - # 定义网格位置 - 确保无重叠 - positions = [ - (0, 0), # 左上 - (img_width, 0), # 右上 - (0, img_height), # 左下 - (img_width, img_height) # 右下 - ] - - # 添加图片到位置 - for i, position in enumerate(positions): - if i < len(images): - img = images[i].copy() - # 调整大小 - img = self.resize_and_crop(img, (img_width, img_height)) - # 应用效果 - img = self.apply_image_effect(img) - # 粘贴到拼贴画 - collage.paste(img, position) - print(f"添加无重叠拼贴画块 {i+1} 到位置: {position},尺寸: {img_width}x{img_height}") - - print(f"无重叠拼贴画创建成功,尺寸: {target_size}") - return collage - - def _create_mosaic_collage(self, images, target_size): - """创建马赛克风格拼贴画(需要9张图片,无重叠)""" - collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) - width, height = target_size - - # 创建3x3网格,确保无重叠 - grid_width = width // 3 - grid_height = height // 3 - - # 生成网格位置 - positions = [] - for row in range(3): - for col in range(3): - positions.append((col * grid_width, row * grid_height)) - - # 将图像粘贴到马赛克位置 - for i, position in enumerate(positions): - if i < len(images): - img = images[i].copy() - # 调整大小 - img = self.resize_and_crop(img, (grid_width, grid_height)) - # 应用效果 - img = self.apply_image_effect(img) - # 粘贴到拼贴画 - collage.paste(img, position) - print(f"添加马赛克拼贴画块 {i+1} 到位置: {position},尺寸: {grid_width}x{grid_height}") - - print(f"无重叠马赛克拼贴画创建成功,尺寸: {target_size}") - return collage - - def _create_fullscreen_collage(self, images, target_size): - """创建全覆盖拼图样式(完全填满画布,无空白,无重叠)""" - width, height = target_size - collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) # 白色背景 - - # 确保至少有6张图片 - while len(images) < 6: - if images: - images.append(random.choice(images).copy()) - else: - return None - - # 定义区域划分 - 按照完全填满画布设计,确保无重叠 - regions = [ - # 左列 - 上中下三块 - (0, 0, width//2, height//3), # 左上 - (0, height//3, width//2, height*2//3), # 左中 - (0, height*2//3, width//2, height), # 左下 - - # 右列 - 上中下三块 - (width//2, 0, width, height//3), # 右上 - (width//2, height//3, width, height*2//3), # 右中 - (width//2, height*2//3, width, height) # 右下 - ] - - # 添加图片到各个区域,确保完全覆盖无重叠 - for i, (x1, y1, x2, y2) in enumerate(regions): - if i < len(images): - img = images[i].copy() - - # 调整大小以完全填充区域 - region_width = x2 - x1 - region_height = y2 - y1 - img = self.resize_and_crop(img, (region_width, region_height)) - - # 应用轻微的图像效果 - img = self.apply_image_effect(img) - - # 粘贴到画布上 - collage.paste(img, (x1, y1)) - print(f"添加全屏拼图块 {i+1} 到位置: ({x1}, {y1}, {x2}, {y2}),尺寸: {region_width}x{region_height}") - - print(f"无重叠全覆盖拼图创建成功,尺寸: {target_size}") - return collage - - def _create_vertical_stack_collage(self, images, target_size): - """创建上下拼图样式(两张图片上下排列)""" - collage = Image.new('RGBA', target_size, (255, 255, 255, 255)) # 白色背景 - width, height = target_size - - # 确保至少有2张图片 - while len(images) < 2: - if images: - images.append(images[0].copy()) - else: - print("没有可用的图片来创建上下拼图") - return None - - # 设置间隙(可选) - gap = 0 # 无间隙拼接,设置为0 - - # 计算每张图片的高度 - img_height = (height - gap) // 2 - - # 定义图片位置 - positions = [ - (0, 0), # 上方图片 - (0, img_height + gap) # 下方图片 - ] - - # 添加图片 - for i, position in enumerate(positions): - if i < len(images) and i < 2: # 只使用前两张图片 - img = images[i].copy() - - # 调整大小以适应宽度 - img = self.resize_and_crop(img, (width, img_height)) - - # 应用轻微的图像效果 - img = self.apply_image_effect(img) - - # 粘贴到画布上 - collage.paste(img, position, img) - print(f"添加上下拼图块 {i+1} 到位置: {position}") - - # 可选:添加分隔线 - if gap > 0: - draw = ImageDraw.Draw(collage) - line_y = img_height + gap // 2 - draw.line([(0, line_y), (width, line_y)], fill=(200, 200, 200, 255), width=gap) - - print(f"上下拼图创建成功,尺寸: {target_size}") - return collage - - def save_collage(self, collage, output_path): - """保存拼贴画""" - if collage: - # 确保有背景 - 创建白色背景并将拼贴画合并上去 - background = Image.new('RGB', collage.size, (255, 255, 255)) - # 如果拼贴画有透明通道,将其合并到白色背景上 - if collage.mode == 'RGBA': - background.paste(collage, (0, 0), collage) - final_image = background - else: - final_image = collage.convert('RGB') - - final_image.save(output_path) - print(f"无缝拼贴画已保存: {output_path}") - return output_path - return None - - - def set_collage_style(self, collage_style): - """设置拼贴画样式""" - self.collage_style = collage_style - return self.collage_style - -def process_directory(directory_path, style=None, target_size=(900, 1200), output_count=1): - """ - 处理指定目录中的图片,创建指定数量的拼贴图。 - - 参数: - directory_path: 包含图片的目录路径 - target_size: 拼贴图目标尺寸,默认为 (900, 1200) - output_count: 需要生成的拼贴图数量,默认为 1 - - 返回: - tuple: (拼贴图列表, 使用的图片名称列表的列表),如果生成失败,返回 ([], []) - 拼贴图列表是PIL.Image对象列表 - 图片名称列表是一个列表的列表,每个子列表包含一张拼贴图使用的图片文件名 - """ - logging.info(f"处理目录中的图片并创建 {output_count} 个拼贴图: {directory_path}") - - # 创建 ImageCollageCreator 实例 - collage_creator = ImageCollageCreator() - collage_images = [] - used_image_names = [] # 存储每个拼贴图使用的图片文件名 - - # 检查目录是否存在 - if not os.path.exists(directory_path): - logging.error(f"目录不存在: {directory_path}") - return [], [] - - # 支持的图片格式 - image_extensions = ('.jpg', '.jpeg', '.png', '.bmp') - - # 获取目录中的所有有效图片文件 - try: - all_files = os.listdir(directory_path) - all_images_names = [f for f in all_files - if f.lower().endswith(image_extensions) and os.path.isfile(os.path.join(directory_path, f))] - - if not all_images_names: - logging.error(f"目录中没有有效的图片文件: {directory_path}") - return [], [] - - logging.info(f"目录中找到 {len(all_images_names)} 个有效图片文件") - except Exception as e: - logging.exception(f"列出目录内容时出错: {e}") - return [], [] - - # 尝试创建请求数量的拼贴图 - for i in range(output_count): - try: - # 创建拼贴图,使用指定样式 - collage, selected_images_names = collage_creator.create_collage_with_style( - directory_path, - style=style, - target_size=target_size - ) - - if collage: - collage_images.append(collage) - - # 从输出日志中解析出使用的图片名称 - # 由于我们修改了create_collage_with_style来打印选择的图片 - # 可能需要进一步修改为直接返回选择的图片 - used_image_names.append(selected_images_names) - - logging.info(f"成功创建拼贴图 {i+1}/{output_count}") - else: - logging.error(f"无法创建拼贴图 {i+1}/{output_count}") - except Exception as e: - logging.exception(f"创建拼贴图 {i+1}/{output_count} 时发生异常: {e}") - - logging.info(f"已处理目录 {directory_path},成功创建 {len(collage_images)}/{output_count} 个拼贴图") - return collage_images, used_image_names - -def find_main_subject(image): - # ... (keep the existing implementation) ... - pass - -def adjust_image(image, contrast=1.0, saturation=1.0): - # ... (keep the existing implementation) ... - pass - -def smart_crop_and_resize(image, target_aspect_ratio): - # ... (keep the existing implementation) ... - pass - -def main(): - """展示如何使用 ImageCollageCreator 和 process_directory 函数的示例。""" - logging.basicConfig(level=logging.INFO, - format='%(asctime)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s') - - # 示例目录路径 - 根据实际情况修改 - test_directory = "/root/autodl-tmp/sanming_img/modify/古田会议旧址" # 修改为你实际的图片目录 - - logging.info(f"测试目录: {test_directory}") - - # 方法 1: 使用 process_directory 函数 (推荐用于外部调用) - logging.info("方法 1: 使用 process_directory 函数生成拼贴图...") - collages_1, used_image_names_1 = process_directory( - directory_path=test_directory, - target_size=(900, 1200), # 默认 3:4 比例 - output_count=2 # 创建 2 张不同的拼贴图 - ) - - if collages_1: - logging.info(f"成功创建了 {len(collages_1)} 张拼贴图 (使用 process_directory)") - # 可选: 保存图片到文件 - for i, collage in enumerate(collages_1): - output_path = f"/tmp/collage_method1_{i}.png" - collage.save(output_path) - logging.info(f"拼贴图已保存到: {output_path}") - else: - logging.error("使用 process_directory 创建拼贴图失败") - - # 方法 2: 直接使用 ImageCollageCreator 类 (用于更精细的控制) - logging.info("方法 2: 直接使用 ImageCollageCreator 类...") - creator = ImageCollageCreator() - - # 指定样式创建拼贴图 (可选样式: grid_2x2, asymmetric, filmstrip, overlap, mosaic, fullscreen, vertical_stack) - styles_to_try = ["grid_2x2", "overlap", "mosaic"] - collages_2 = [] - - for style in styles_to_try: - logging.info(f"尝试使用样式: {style}") - collage, selected_images_names = creator.create_collage_with_style( - input_dir=test_directory, - style=style, - target_size=(800, 1000) # 自定义尺寸 - ) - - if collage: - collages_2.append(collage) - # 可选: 保存图片到文件 - output_path = f"/tmp/collage_method2_{style}.png" - collage.save(output_path) - logging.info(f"使用样式 '{style}' 的拼贴图已保存到: {output_path}") - else: - logging.error(f"使用样式 '{style}' 创建拼贴图失败") - - logging.info(f"总共成功创建了 {len(collages_2)} 张拼贴图 (使用 ImageCollageCreator)") - - # 比较两种方法 - logging.info("===== 拼贴图创建测试完成 =====") - logging.info(f"方法 1 (process_directory): {len(collages_1)} 张拼贴图") - logging.info(f"方法 2 (直接使用 ImageCollageCreator): {len(collages_2)} 张拼贴图") - -if __name__ == "__main__": - main()