第4天:圖片生成功能實現

📋 第4天概述

在第3天完成文字生成功能的基礎上,第4天將重點實現圖片生成功能的完整業務邏輯,包括圖像生成服務、圖片處理工具、文件管理以及用户界面集成。

🎯 第4天目標

主要任務:構建完整的圖片生成系統,實現智能圖像生成、圖片處理和文件管理
核心需求:開發穩定可靠的圖像生成服務,支持多種風格和尺寸定製

🏗️ 架構設計

服務層架構

class ImageGenerationService:
    """圖片生成服務類"""
    
    def __init__(self, aliyun_client):
        self.client = aliyun_client
        self.image_templates = self._load_image_templates()
        self.image_storage = ImageStorage()
    
    # 核心生成方法
    def generate_image(self, prompt, style, size, **kwargs)
    def _build_image_prompt(self, base_prompt, style)
    def _process_image_response(self, response)
    
    # 圖片管理
    def save_image(self, image_data, metadata)
    def get_image_history(self, limit=10)
    def delete_image(self, image_id)
    
    # 工具方法
    def resize_image(self, image_path, target_size)
    def convert_format(self, image_path, target_format)
    def add_watermark(self, image_path, watermark_text)

🔧 具體實施步驟

步驟1:創建圖片生成服務類

創建 src/services/image_service.py

import os
import base64
import json
import time
from datetime import datetime
from typing import Dict, List, Optional, Any, Tuple
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
import io

class ImageGenerationService:
    """
    圖片生成服務類
    負責圖像生成、圖片處理和文件管理
    """
    
    def __init__(self, aliyun_client, upload_folder: str = "./uploads"):
        self.client = aliyun_client
        self.upload_folder = Path(upload_folder)
        self.image_templates = self._load_image_templates()
        self.image_history = []
        self.max_history_size = 50
        
        # 確保上傳目錄存在
        self.upload_folder.mkdir(exist_ok=True)
        
        # 創建圖片子目錄
        (self.upload_folder / "images").mkdir(exist_ok=True)
        (self.upload_folder / "thumbnails").mkdir(exist_ok=True)
    
    def _load_image_templates(self) -> Dict[str, Dict[str, Any]]:
        """加載圖片生成模板"""
        templates = {
            "卡通風格": {
                "prompt_suffix": ",卡通風格,色彩鮮豔,線條簡潔",
                "styles": ["可愛", "搞笑", "簡約", "日系", "美式"],
                "size_options": ["512x512", "768x768", "1024x1024", "1024x768", "768x1024"]
            },
            "寫實風格": {
                "prompt_suffix": ",寫實風格,細節豐富,光影真實",
                "styles": ["超寫實", "油畫", "水彩", "素描", "攝影"],
                "size_options": ["512x512", "768x768", "1024x1024", "1024x768", "768x1024"]
            },
            "奇幻風格": {
                "prompt_suffix": ",奇幻風格,充滿想象力,神秘氛圍",
                "styles": ["魔法", "神話", "科幻", "童話", "夢境"],
                "size_options": ["512x512", "768x768", "1024x1024", "1024x768", "768x1024"]
            },
            "教育風格": {
                "prompt_suffix": ",教育風格,清晰易懂,適合兒童",
                "styles": ["科普", "數學", "語言", "藝術", "歷史"],
                "size_options": ["512x512", "768x768", "1024x1024", "1024x768", "768x1024"]
            }
        }
        return templates
    
    def get_available_styles(self) -> List[str]:
        """獲取可用的圖片風格"""
        return list(self.image_templates.keys())
    
    def get_style_options(self, style: str) -> List[str]:
        """獲取指定風格的子風格選項"""
        if style in self.image_templates:
            return self.image_templates[style]["styles"]
        return []
    
    def get_size_options(self, style: str) -> List[str]:
        """獲取指定風格的尺寸選項"""
        if style in self.image_templates:
            return self.image_templates[style]["size_options"]
        return []
    
    def _build_image_prompt(self, base_prompt: str, style: str, sub_style: str = "") -> str:
        """構建圖片生成提示詞"""
        
        if style in self.image_templates:
            prompt_suffix = self.image_templates[style]["prompt_suffix"]
            
            if sub_style:
                prompt_suffix = prompt_suffix.replace("風格", f"{sub_style}風格")
            
            final_prompt = base_prompt + prompt_suffix
        else:
            final_prompt = base_prompt
        
        # 添加質量要求
        quality_requirements = """
        請確保圖片具有以下特點:
        1. 構圖合理,主體突出
        2. 色彩協調,視覺效果良好
        3. 細節豐富,避免模糊
        4. 符合兒童審美,避免恐怖內容
        5. 具有藝術性和創意性
        """
        
        return final_prompt + quality_requirements
    
    def generate_image(self, prompt: str, style: str, size: str = "1024x1024", 
                      sub_style: str = "", model: str = "wan2.5-t2i-preview", 
                      n: int = 1, **kwargs) -> Dict[str, Any]:
        """
        生成圖片
        
        Args:
            prompt: 提示詞
            style: 圖片風格
            size: 圖片尺寸
            sub_style: 子風格
            model: 模型名稱
            n: 生成數量
            **kwargs: 其他參數
        
        Returns:
            dict: 生成結果
        """
        
        # 構建完整提示詞
        full_prompt = self._build_image_prompt(prompt, style, sub_style)
        
        print(f"🎨 開始生成圖片...")
        print(f"提示詞: {prompt}")
        print(f"風格: {style} - {sub_style}")
        print(f"尺寸: {size}")
        print(f"完整提示詞長度: {len(full_prompt)} 字符")
        
        try:
            # 調用API生成圖片
            start_time = time.time()
            
            response = self.client.generate_image(
                prompt=full_prompt,
                model=model,
                size=size,
                style=sub_style or style,
                n=n,
                **kwargs
            )
            
            generation_time = time.time() - start_time
            
            # 處理響應
            processed_result = self._process_image_response(response)
            
            # 構建結果
            result = {
                "success": True,
                "prompt": prompt,
                "style": style,
                "sub_style": sub_style,
                "size": size,
                "generation_time": round(generation_time, 2),
                "timestamp": datetime.now().isoformat(),
                "model_used": model,
                **processed_result
            }
            
            # 保存圖片文件
            if processed_result.get("image_url"):
                saved_path = self._save_image_from_url(
                    processed_result["image_url"], 
                    result
                )
                result["local_path"] = str(saved_path)
                
                # 生成縮略圖
                thumbnail_path = self._create_thumbnail(saved_path)
                result["thumbnail_path"] = str(thumbnail_path)
            
            # 保存到歷史記錄
            self._save_to_history(result)
            
            print(f"✅ 圖片生成成功!")
            print(f"生成時間: {generation_time:.2f}秒")
            print(f"圖片尺寸: {size}")
            
            return result
            
        except Exception as e:
            error_result = {
                "success": False,
                "error": str(e),
                "prompt": prompt,
                "style": style,
                "size": size,
                "timestamp": datetime.now().isoformat()
            }
            
            print(f"❌ 圖片生成失敗: {e}")
            return error_result
    
    def _process_image_response(self, response: Dict[str, Any]) -> Dict[str, Any]:
        """處理圖片生成響應"""
        
        # 阿里雲文生圖API返回格式處理
        if "output" in response and "task_id" in response["output"]:
            # 異步任務模式,返回任務ID
            return {
                "task_id": response["output"]["task_id"],
                "task_status": "pending"
            }
        elif "data" in response and len(response["data"]) > 0:
            # 直接返回圖片URL
            image_data = response["data"][0]
            return {
                "image_url": image_data.get("url", ""),
                "task_status": "completed"
            }
        else:
            raise ValueError("無法從響應中提取圖片信息")
    
    def _save_image_from_url(self, image_url: str, metadata: Dict[str, Any]) -> Path:
        """從URL下載並保存圖片"""
        import requests
        
        try:
            # 下載圖片
            response = requests.get(image_url, timeout=30)
            response.raise_for_status()
            
            # 生成文件名
            timestamp = int(time.time())
            filename = f"image_{timestamp}_{metadata['style']}.png"
            filepath = self.upload_folder / "images" / filename
            
            # 保存圖片
            with open(filepath, 'wb') as f:
                f.write(response.content)
            
            # 驗證圖片有效性
            with Image.open(filepath) as img:
                img.verify()
            
            return filepath
            
        except Exception as e:
            raise Exception(f"圖片保存失敗: {e}")
    
    def _create_thumbnail(self, image_path: Path, size: Tuple[int, int] = (200, 200)) -> Path:
        """創建縮略圖"""
        
        try:
            with Image.open(image_path) as img:
                # 保持寬高比生成縮略圖
                img.thumbnail(size, Image.Resampling.LANCZOS)
                
                # 生成縮略圖文件名
                thumbnail_name = f"thumb_{image_path.stem}.png"
                thumbnail_path = self.upload_folder / "thumbnails" / thumbnail_name
                
                # 保存縮略圖
                img.save(thumbnail_path, "PNG")
                
                return thumbnail_path
                
        except Exception as e:
            raise Exception(f"縮略圖創建失敗: {e}")
    
    def _save_to_history(self, image_data: Dict[str, Any]):
        """保存圖片到歷史記錄"""
        if image_data["success"]:
            # 為圖片分配ID
            image_id = f"image_{len(self.image_history) + 1}_{int(time.time())}"
            image_data["id"] = image_id
            
            # 添加到歷史記錄
            self.image_history.append(image_data)
            
            # 限制歷史記錄大小
            if len(self.image_history) > self.max_history_size:
                self.image_history = self.image_history[-self.max_history_size:]
    
    def get_image_history(self, limit: int = 10) -> List[Dict[str, Any]]:
        """獲取圖片歷史記錄"""
        return self.image_history[-limit:]
    
    def get_image_by_id(self, image_id: str) -> Optional[Dict[str, Any]]:
        """根據ID獲取圖片"""
        for image in self.image_history:
            if image.get("id") == image_id:
                return image
        return None
    
    def delete_image(self, image_id: str) -> bool:
        """刪除指定圖片"""
        for i, image in enumerate(self.image_history):
            if image.get("id") == image_id:
                # 刪除本地文件
                if "local_path" in image:
                    try:
                        Path(image["local_path"]).unlink(missing_ok=True)
                    except:
                        pass
                
                if "thumbnail_path" in image:
                    try:
                        Path(image["thumbnail_path"]).unlink(missing_ok=True)
                    except:
                        pass
                
                # 從歷史記錄中移除
                self.image_history.pop(i)
                return True
        return False
    
    def resize_image(self, image_path: str, target_size: Tuple[int, int]) -> Path:
        """調整圖片尺寸"""
        
        try:
            with Image.open(image_path) as img:
                # 調整尺寸
                resized_img = img.resize(target_size, Image.Resampling.LANCZOS)
                
                # 生成新文件名
                original_path = Path(image_path)
                new_name = f"{original_path.stem}_{target_size[0]}x{target_size[1]}{original_path.suffix}"
                new_path = original_path.parent / new_name
                
                # 保存調整後的圖片
                resized_img.save(new_path)
                
                return new_path
                
        except Exception as e:
            raise Exception(f"圖片尺寸調整失敗: {e}")
    
    def convert_format(self, image_path: str, target_format: str) -> Path:
        """轉換圖片格式"""
        
        try:
            with Image.open(image_path) as img:
                # 生成新文件名
                original_path = Path(image_path)
                new_name = f"{original_path.stem}.{target_format.lower()}"
                new_path = original_path.parent / new_name
                
                # 轉換格式並保存
                if target_format.upper() == "JPG":
                    # 轉換為RGB模式(JPG不支持透明度)
                    if img.mode in ("RGBA", "P"):
                        img = img.convert("RGB")
                
                img.save(new_path, target_format.upper())
                
                return new_path
                
        except Exception as e:
            raise Exception(f"圖片格式轉換失敗: {e}")
    
    def add_watermark(self, image_path: str, watermark_text: str, 
                     position: str = "bottom-right", opacity: int = 50) -> Path:
        """添加水印"""
        
        try:
            with Image.open(image_path) as img:
                # 轉換為RGBA模式以支持透明度
                if img.mode != "RGBA":
                    img = img.convert("RGBA")
                
                # 創建水印圖層
                watermark = Image.new("RGBA", img.size, (0, 0, 0, 0))
                draw = ImageDraw.Draw(watermark)
                
                # 嘗試加載字體,失敗則使用默認字體
                try:
                    font = ImageFont.truetype("arial.ttf", 24)
                except:
                    font = ImageFont.load_default()
                
                # 計算水印位置
                bbox = draw.textbbox((0, 0), watermark_text, font=font)
                text_width = bbox[2] - bbox[0]
                text_height = bbox[3] - bbox[1]
                
                if position == "bottom-right":
                    x = img.width - text_width - 10
                    y = img.height - text_height - 10
                elif position == "top-left":
                    x = 10
                    y = 10
                elif position == "center":
                    x = (img.width - text_width) // 2
                    y = (img.height - text_height) // 2
                else:
                    x = 10
                    y = img.height - text_height - 10
                
                # 繪製水印
                draw.text((x, y), watermark_text, font=font, fill=(255, 255, 255, int(255 * opacity / 100)))
                
                # 合併圖片和水印
                watermarked = Image.alpha_composite(img, watermark)
                
                # 生成新文件名
                original_path = Path(image_path)
                new_name = f"{original_path.stem}_watermarked{original_path.suffix}"
                new_path = original_path.parent / new_name
                
                # 保存帶水印的圖片
                watermarked.save(new_path)
                
                return new_path
                
        except Exception as e:
            raise Exception(f"水印添加失敗: {e}")

步驟2:創建圖片生成Web路由

創建 src/web/routes/image_routes.py

from flask import Blueprint, request, jsonify, send_file
from src.services.image_service import ImageGenerationService
import os
from pathlib import Path

# 創建藍圖
image_bp = Blueprint('image', __name__)

def init_image_routes(app, aliyun_client, upload_folder: str):
    """初始化圖片生成路由"""
    image_service = ImageGenerationService(aliyun_client, upload_folder)
    
    @image_bp.route('/api/generate_image', methods=['POST'])
    def generate_image():
        """生成圖片API"""
        try:
            data = request.get_json()
            
            # 驗證必需參數
            required_fields = ['prompt', 'style']
            for field in required_fields:
                if field not in data:
                    return jsonify({
                        'success': False,
                        'error': f'缺少必需參數: {field}'
                    }), 400
            
            # 調用服務生成圖片
            result = image_service.generate_image(
                prompt=data['prompt'],
                style=data['style'],
                size=data.get('size', '1024x1024'),
                sub_style=data.get('sub_style', ''),
                model=data.get('model', 'wan2.5-t2i-preview'),
                n=int(data.get('n', 1))
            )
            
            return jsonify(result)
            
        except Exception as e:
            return jsonify({
                'success': False,
                'error': f'生成圖片失敗: {str(e)}'
            }), 500
    
    @image_bp.route('/api/image_history', methods=['GET'])
    def get_image_history():
        """獲取圖片歷史記錄"""
        try:
            limit = int(request.args.get('limit', 10))
            history = image_service.get_image_history(limit)
            return jsonify({
                'success': True,
                'history': history
            })
        except Exception as e:
            return jsonify({
                'success': False,
                'error': str(e)
            }), 500
    
    @image_bp.route('/api/image_templates', methods=['GET'])
    def get_image_templates():
        """獲取圖片模板信息"""
        try:
            styles = image_service.get_available_styles()
            templates = {}
            
            for style in styles:
                templates[style] = {
                    'sub_styles': image_service.get_style_options(style),
                    'size_options': image_service.get_size_options(style)
                }
            
            return jsonify({
                'success': True,
                'templates': templates
            })
        except Exception as e:
            return jsonify({
                'success': False,
                'error': str(e)
            }), 500
    
    @image_bp.route('/api/image/<path:filename>')
    def serve_image(filename):
        """提供圖片文件服務"""
        try:
            filepath = Path(upload_folder) / "images" / filename
            
            if not filepath.exists() or not filepath.is_file():
                return jsonify({'error': '圖片不存在'}), 404
            
            return send_file(filepath)
            
        except Exception as e:
            return jsonify({'error': str(e)}), 500
    
    @image_bp.route('/api/thumbnail/<path:filename>')
    def serve_thumbnail(filename):
        """提供縮略圖服務"""
        try:
            filepath = Path(upload_folder) / "thumbnails" / filename
            
            if not filepath.exists() or not filepath.is_file():
                return jsonify({'error': '縮略圖不存在'}), 404
            
            return send_file(filepath)
            
        except Exception as e:
            return jsonify({'error': str(e)}), 500
    
    @image_bp.route('/api/process_image', methods=['POST'])
    def process_image():
        """圖片處理API"""
        try:
            data = request.get_json()
            
            if 'image_id' not in data or 'action' not in data:
                return jsonify({'error': '缺少必需參數'}), 400
            
            image = image_service.get_image_by_id(data['image_id'])
            if not image or 'local_path' not in image:
                return jsonify({'error': '圖片不存在'}), 404
            
            result = {}
            
            if data['action'] == 'resize':
                size = tuple(map(int, data['size'].split('x')))
                new_path = image_service.resize_image(image['local_path'], size)
                result['new_path'] = str(new_path)
                
            elif data['action'] == 'convert':
                new_path = image_service.convert_format(image['local_path'], data['format'])
                result['new_path'] = str(new_path)
                
            elif data['action'] == 'watermark':
                new_path = image_service.add_watermark(
                    image['local_path'], 
                    data['text'],
                    data.get('position', 'bottom-right'),
                    int(data.get('opacity', 50))
                )
                result['new_path'] = str(new_path)
            
            return jsonify({
                'success': True,
                'result': result
            })
            
        except Exception as e:
            return jsonify({
                'success': False,
                'error': str(e)
            }), 500
    
    # 註冊藍圖
    app.register_blueprint(image_bp, url_prefix='/image')

步驟3:創建圖片生成前端界面

創建 src/web/templates/image_generation.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AI圖片生成器</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
    <style>
        .image-generator {
            max-width: 1000px;
            margin: 0 auto;
            padding: 20px;
        }
        
        .form-container {
            background: white;
            padding: 30px;
            border-radius: 15px;
            box-shadow: 0 4px 15px rgba(0,0,0,0.1);
            margin-bottom: 30px;
        }
        
        .form-group {
            margin-bottom: 25px;
        }
        
        .form-group label {
            display: block;
            margin-bottom: 8px;
            font-weight: 600;
            color: #333;
        }
        
        .form-control {
            width: 100%;
            padding: 12px;
            border: 2px solid #e0e0e0;
            border-radius: 8px;
            font-size: 16px;
            transition: border-color 0.3s;
        }
        
        .form-control:focus {
            outline: none;
            border-color: #4CAF50;
        }
        
        .btn-generate {
            background: linear-gradient(45deg, #FF6B6B, #4ECDC4);
            color: white;
            border: none;
            padding: 15px 40px;
            border-radius: 25px;
            font-size: 18px;
            font-weight: 600;
            cursor: pointer;
            transition: all 0.3s;
            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
        }
        
        .btn-generate:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 20px rgba(0,0,0,0.3);
        }
        
        .btn-generate:disabled {
            background: #ccc;
            cursor: not-allowed;
            transform: none;
        }
        
        .image-result {
            margin-top: 30px;
            text-align: center;
            display: none;
        }
        
        .generated-image {
            max-width: 100%;
            max-height: 600px;
            border-radius: 10px;
            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
            margin-bottom: 20px;
        }
        
        .image-actions {
            display: flex;
            gap: 15px;
            justify-content: center;
            flex-wrap: wrap;
        }
        
        .btn-action {
            background: #2196F3;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 20px;
            cursor: pointer;
            transition: background 0.3s;
        }
        
        .btn-action:hover {
            background: #1976D2;
        }
        
        .loading {
            text-align: center;
            padding: 40px;
            display: none;
        }
        
        .spinner {
            border: 4px solid #f3f3f3;
            border-top: 4px solid #3498db;
            border-radius: 50%;
            width: 40px;
            height: 40px;
            animation: spin 2s linear infinite;
            margin: 0 auto 20px;
        }
        
        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }
        
        .error-message {
            background: #ffebee;
            color: #c62828;
            padding: 15px;
            border-radius: 8px;
            margin: 20px 0;
            display: none;
        }
        
        .history-section {
            margin-top: 50px;
        }
        
        .history-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
            gap: 20px;
            margin-top: 20px;
        }
        
        .history-item {
            background: white;
            border-radius: 10px;
            padding: 15px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            text-align: center;
            cursor: pointer;
            transition: transform 0.3s;
        }
        
        .history-item:hover {
            transform: translateY(-5px);
        }
        
        .history-thumbnail {
            width: 100%;
            height: 120px;
            object-fit: cover;
            border-radius: 5px;
            margin-bottom: 10px;
        }
    </style>
</head>
<body>
    <div class="image-generator">
        <h1>🎨 AI圖片生成器</h1>
        
        <div class="form-container">
            <div class="form-group">
                <label for="prompt">圖片描述:</label>
                <textarea id="prompt" class="form-control" rows="3" 
                          placeholder="請輸入您想要生成的圖片描述,例如:一隻可愛的小貓在花園裏玩耍..."></textarea>
            </div>
            
            <div class="form-row">
                <div class="form-group" style="flex: 1;">
                    <label for="style">主要風格:</label>
                    <select id="style" class="form-control">
                        <option value="">請選擇風格...</option>
                    </select>
                </div>
                
                <div class="form-group" style="flex: 1;">
                    <label for="subStyle">子風格:</label>
                    <select id="subStyle" class="form-control" disabled>
                        <option value="">請先選擇主要風格</option>
                    </select>
                </div>
                
                <div class="form-group" style="flex: 1;">
                    <label for="size">圖片尺寸:</label>
                    <select id="size" class="form-control" disabled>
                        <option value="">請先選擇風格</option>
                    </select>
                </div>
            </div>
            
            <button id="generateBtn" class="btn-generate">✨ 生成圖片</button>
        </div>
        
        <div id="loading" class="loading">
            <div class="spinner"></div>
            <p>AI正在創作中,這可能需要一些時間...</p>
        </div>
        
        <div id="errorMessage" class="error-message"></div>
        
        <div id="imageResult" class="image-result">
            <h3>🖼️ 生成的圖片</h3>
            <img id="generatedImage" class="generated-image" src="" alt="生成的圖片">
            <div class="image-meta">
                <p><strong>生成時間:</strong> <span id="generationTime"></span>秒</p>
                <p><strong>圖片尺寸:</strong> <span id="imageSize"></span></p>
            </div>
            <div class="image-actions">
                <button id="downloadBtn" class="btn-action">💾 下載圖片</button>
                <button id="copyPromptBtn" class="btn-action">📋 複製提示詞</button>
                <button id="regenerateBtn" class="btn-action">🔄 重新生成</button>
            </div>
        </div>
        
        <div class="history-section">
            <h3>📚 生成歷史</h3>
            <div id="historyGrid" class="history-grid"></div>
        </div>
    </div>

    <script src="{{ url_for('static', filename='js/image_generation.js') }}"></script>
</body>
</html>