Spaces:
Runtime error
Runtime error
Aguilar Elizondo commited on
Commit ·
6bfa765
0
Parent(s):
Initial commit: Architecture AI Enhancer v1.0.0
Browse files- README.md +59 -0
- app.py +230 -0
- backend/__init__.py +0 -0
- backend/config.py +160 -0
- backend/services/__init__.py +0 -0
- backend/services/diffusion_pipeline.py +340 -0
- backend/services/postprocess.py +294 -0
- backend/services/progress_tracker.py +146 -0
- backend/services/training_engine.py +369 -0
- backend/services/upscaler.py +197 -0
- requirements.txt +27 -0
README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Architecture AI Enhancer
|
| 3 |
+
emoji: 🏗️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
python_version: 3.10
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# Architecture AI Enhancer
|
| 15 |
+
|
| 16 |
+
Transform your architectural renders with AI-powered enhancement using Stable Diffusion 1.5.
|
| 17 |
+
|
| 18 |
+
## Features
|
| 19 |
+
|
| 20 |
+
- 🎨 AI image enhancement with Stable Diffusion
|
| 21 |
+
- 📈 2x upscaling with ESRGAN
|
| 22 |
+
- 🎭 Photographic post-processing
|
| 23 |
+
- ⚙️ Configurable parameters
|
| 24 |
+
- 🚀 GPU-accelerated inference
|
| 25 |
+
|
| 26 |
+
## Usage
|
| 27 |
+
|
| 28 |
+
1. Upload an architectural render
|
| 29 |
+
2. Adjust enhancement settings (optional)
|
| 30 |
+
3. Click "Enhance Image"
|
| 31 |
+
4. Wait 30-60 seconds for processing
|
| 32 |
+
5. Download your enhanced result
|
| 33 |
+
|
| 34 |
+
## Technical Details
|
| 35 |
+
|
| 36 |
+
- **Model**: runwayml/stable-diffusion-v1-5
|
| 37 |
+
- **Framework**: PyTorch + Diffusers
|
| 38 |
+
- **Interface**: Gradio 4.44.0
|
| 39 |
+
- **Version**: 1.0.0
|
| 40 |
+
|
| 41 |
+
## Settings Guide
|
| 42 |
+
|
| 43 |
+
- **Strength** (0.1-0.8): Controls how much the AI modifies the image
|
| 44 |
+
- 0.2-0.3: Subtle enhancements
|
| 45 |
+
- 0.4-0.5: Moderate changes
|
| 46 |
+
- 0.6-0.8: Dramatic transformations
|
| 47 |
+
|
| 48 |
+
- **Guidance Scale** (1-15): How closely the AI follows the prompt
|
| 49 |
+
- 4-6: Natural, balanced results
|
| 50 |
+
- 7-10: More stylized
|
| 51 |
+
- 11-15: Very strong prompt adherence
|
| 52 |
+
|
| 53 |
+
## Credits
|
| 54 |
+
|
| 55 |
+
Built with:
|
| 56 |
+
- [Stable Diffusion](https://github.com/CompVis/stable-diffusion)
|
| 57 |
+
- [Diffusers](https://github.com/huggingface/diffusers)
|
| 58 |
+
- [Gradio](https://gradio.app)
|
| 59 |
+
- [PyTorch](https://pytorch.org)
|
app.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Architecture AI Enhancer - Gradio App for Hugging Face Spaces
|
| 3 |
+
|
| 4 |
+
This file creates a Gradio interface for the enhancement pipeline.
|
| 5 |
+
"""
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import torch
|
| 8 |
+
from PIL import Image
|
| 9 |
+
import logging
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import sys
|
| 12 |
+
|
| 13 |
+
# Add backend to path
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).parent / "backend"))
|
| 15 |
+
|
| 16 |
+
from services.diffusion_pipeline import DiffusionPipeline
|
| 17 |
+
from services.upscaler import ESRGANUpscaler
|
| 18 |
+
from services.post_processor import PostProcessor
|
| 19 |
+
from config import settings
|
| 20 |
+
|
| 21 |
+
# Configure logging
|
| 22 |
+
logging.basicConfig(level=logging.INFO)
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# Initialize pipelines (lazy loading)
|
| 26 |
+
diffusion_pipeline = None
|
| 27 |
+
upscaler = None
|
| 28 |
+
post_processor = None
|
| 29 |
+
|
| 30 |
+
def initialize_models():
|
| 31 |
+
"""Initialize all models on first use"""
|
| 32 |
+
global diffusion_pipeline, upscaler, post_processor
|
| 33 |
+
|
| 34 |
+
if diffusion_pipeline is None:
|
| 35 |
+
logger.info("Loading Stable Diffusion pipeline...")
|
| 36 |
+
diffusion_pipeline = DiffusionPipeline()
|
| 37 |
+
|
| 38 |
+
if upscaler is None:
|
| 39 |
+
logger.info("Loading upscaler...")
|
| 40 |
+
upscaler = ESRGANUpscaler()
|
| 41 |
+
|
| 42 |
+
if post_processor is None:
|
| 43 |
+
post_processor = PostProcessor()
|
| 44 |
+
|
| 45 |
+
def enhance_image(
|
| 46 |
+
input_image: Image.Image,
|
| 47 |
+
strength: float = 0.3,
|
| 48 |
+
guidance_scale: float = 5.5,
|
| 49 |
+
custom_prompt: str = "",
|
| 50 |
+
use_upscaler: bool = True,
|
| 51 |
+
use_postprocess: bool = True,
|
| 52 |
+
progress=gr.Progress()
|
| 53 |
+
) -> Image.Image:
|
| 54 |
+
"""
|
| 55 |
+
Enhance architectural image using AI pipeline
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
input_image: Input PIL Image
|
| 59 |
+
strength: Denoising strength (0.1-0.8)
|
| 60 |
+
guidance_scale: CFG scale (1.0-15.0)
|
| 61 |
+
custom_prompt: Optional custom prompt
|
| 62 |
+
use_upscaler: Apply upscaling
|
| 63 |
+
use_postprocess: Apply post-processing
|
| 64 |
+
progress: Gradio progress tracker
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
Enhanced PIL Image
|
| 68 |
+
"""
|
| 69 |
+
try:
|
| 70 |
+
# Initialize models
|
| 71 |
+
progress(0, desc="Loading models...")
|
| 72 |
+
initialize_models()
|
| 73 |
+
|
| 74 |
+
# Step 1: AI Enhancement
|
| 75 |
+
progress(0.1, desc="Preprocessing image...")
|
| 76 |
+
|
| 77 |
+
prompt = custom_prompt if custom_prompt else settings.DEFAULT_PROMPT
|
| 78 |
+
|
| 79 |
+
progress(0.2, desc="Enhancing with AI (this may take a few minutes)...")
|
| 80 |
+
|
| 81 |
+
enhanced = diffusion_pipeline.run_inference(
|
| 82 |
+
image=input_image,
|
| 83 |
+
prompt=prompt,
|
| 84 |
+
negative_prompt=settings.NEGATIVE_PROMPT,
|
| 85 |
+
strength=strength,
|
| 86 |
+
guidance_scale=guidance_scale,
|
| 87 |
+
num_inference_steps=30
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
progress(0.7, desc="AI enhancement complete!")
|
| 91 |
+
|
| 92 |
+
# Step 2: Optional Upscaling
|
| 93 |
+
if use_upscaler:
|
| 94 |
+
progress(0.75, desc="Upscaling image...")
|
| 95 |
+
enhanced = upscaler.upscale(enhanced)
|
| 96 |
+
progress(0.85, desc="Upscaling complete!")
|
| 97 |
+
|
| 98 |
+
# Step 3: Optional Post-processing
|
| 99 |
+
if use_postprocess:
|
| 100 |
+
progress(0.9, desc="Applying final touches...")
|
| 101 |
+
enhanced = post_processor.process(enhanced)
|
| 102 |
+
progress(0.95, desc="Post-processing complete!")
|
| 103 |
+
|
| 104 |
+
progress(1.0, desc="Done!")
|
| 105 |
+
return enhanced
|
| 106 |
+
|
| 107 |
+
except Exception as e:
|
| 108 |
+
logger.error(f"Enhancement failed: {e}", exc_info=True)
|
| 109 |
+
raise gr.Error(f"Enhancement failed: {str(e)}")
|
| 110 |
+
|
| 111 |
+
# Create Gradio interface
|
| 112 |
+
with gr.Blocks(title="Architecture AI Enhancer", theme=gr.themes.Soft()) as demo:
|
| 113 |
+
gr.Markdown("""
|
| 114 |
+
# 🏗️ Architecture AI Enhancer
|
| 115 |
+
|
| 116 |
+
Transform your architectural renders with AI-powered enhancement using Stable Diffusion 1.5.
|
| 117 |
+
|
| 118 |
+
**Upload an image** and adjust the settings below to enhance your architectural visualization.
|
| 119 |
+
""")
|
| 120 |
+
|
| 121 |
+
with gr.Row():
|
| 122 |
+
with gr.Column():
|
| 123 |
+
input_image = gr.Image(
|
| 124 |
+
label="📤 Input Image",
|
| 125 |
+
type="pil",
|
| 126 |
+
height=400
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
with gr.Accordion("⚙️ Advanced Settings", open=False):
|
| 130 |
+
strength = gr.Slider(
|
| 131 |
+
minimum=0.1,
|
| 132 |
+
maximum=0.8,
|
| 133 |
+
value=0.3,
|
| 134 |
+
step=0.05,
|
| 135 |
+
label="Denoising Strength",
|
| 136 |
+
info="Lower = more faithful to input, Higher = more creative"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
guidance_scale = gr.Slider(
|
| 140 |
+
minimum=1.0,
|
| 141 |
+
maximum=15.0,
|
| 142 |
+
value=5.5,
|
| 143 |
+
step=0.5,
|
| 144 |
+
label="Guidance Scale",
|
| 145 |
+
info="How closely to follow the prompt"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
custom_prompt = gr.Textbox(
|
| 149 |
+
label="Custom Prompt (optional)",
|
| 150 |
+
placeholder="professional architectural photography, detailed, high quality...",
|
| 151 |
+
lines=3
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
use_upscaler = gr.Checkbox(
|
| 155 |
+
label="Enable Upscaling (2x)",
|
| 156 |
+
value=True
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
use_postprocess = gr.Checkbox(
|
| 160 |
+
label="Enable Post-Processing",
|
| 161 |
+
value=True,
|
| 162 |
+
info="Adds photographic enhancements"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
enhance_btn = gr.Button("✨ Enhance Image", variant="primary", size="lg")
|
| 166 |
+
|
| 167 |
+
with gr.Column():
|
| 168 |
+
output_image = gr.Image(
|
| 169 |
+
label="✅ Enhanced Result",
|
| 170 |
+
type="pil",
|
| 171 |
+
height=400
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
gr.Markdown("""
|
| 175 |
+
### 📝 Tips for Best Results:
|
| 176 |
+
- Use high-quality architectural renders as input
|
| 177 |
+
- Start with default settings and adjust if needed
|
| 178 |
+
- Lower strength for subtle enhancements
|
| 179 |
+
- Higher strength for more dramatic changes
|
| 180 |
+
- Processing takes 2-5 minutes on CPU, ~30 seconds on GPU
|
| 181 |
+
""")
|
| 182 |
+
|
| 183 |
+
gr.Markdown("""
|
| 184 |
+
---
|
| 185 |
+
### 🔧 Technical Details:
|
| 186 |
+
- **Model**: Stable Diffusion 1.5
|
| 187 |
+
- **Upscaler**: ESRGAN (optional)
|
| 188 |
+
- **Processing**: CPU/GPU automatic detection
|
| 189 |
+
- **Version**: 1.0.0
|
| 190 |
+
|
| 191 |
+
### 📚 Resources:
|
| 192 |
+
- [GitHub Repository](#)
|
| 193 |
+
- [Documentation](#)
|
| 194 |
+
- [Report Issues](#)
|
| 195 |
+
""")
|
| 196 |
+
|
| 197 |
+
# Connect the enhance button
|
| 198 |
+
enhance_btn.click(
|
| 199 |
+
fn=enhance_image,
|
| 200 |
+
inputs=[
|
| 201 |
+
input_image,
|
| 202 |
+
strength,
|
| 203 |
+
guidance_scale,
|
| 204 |
+
custom_prompt,
|
| 205 |
+
use_upscaler,
|
| 206 |
+
use_postprocess
|
| 207 |
+
],
|
| 208 |
+
outputs=output_image
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
# Example images
|
| 212 |
+
gr.Examples(
|
| 213 |
+
examples=[
|
| 214 |
+
["examples/render1.jpg", 0.3, 5.5, "", True, True],
|
| 215 |
+
["examples/render2.jpg", 0.4, 6.0, "", True, True],
|
| 216 |
+
],
|
| 217 |
+
inputs=[input_image, strength, guidance_scale, custom_prompt, use_upscaler, use_postprocess],
|
| 218 |
+
outputs=output_image,
|
| 219 |
+
fn=enhance_image,
|
| 220 |
+
cache_examples=False
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
# Launch configuration
|
| 224 |
+
if __name__ == "__main__":
|
| 225 |
+
demo.queue(max_size=10) # Enable queue for multiple users
|
| 226 |
+
demo.launch(
|
| 227 |
+
server_name="0.0.0.0",
|
| 228 |
+
server_port=7860,
|
| 229 |
+
share=False
|
| 230 |
+
)
|
backend/__init__.py
ADDED
|
File without changes
|
backend/config.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration module for Architecture AI Enhancer
|
| 3 |
+
Centralized configuration management for the entire application
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from pydantic_settings import BaseSettings
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Settings(BaseSettings):
|
| 12 |
+
"""
|
| 13 |
+
Application settings with environment variable support
|
| 14 |
+
|
| 15 |
+
All settings can be overridden via environment variables
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
# Application Metadata
|
| 19 |
+
APP_NAME: str = "Architecture AI Enhancer"
|
| 20 |
+
APP_VERSION: str = "1.0.0"
|
| 21 |
+
DEBUG: bool = False
|
| 22 |
+
|
| 23 |
+
# Server Configuration
|
| 24 |
+
HOST: str = "0.0.0.0"
|
| 25 |
+
PORT: int = 8000
|
| 26 |
+
WORKERS: int = 1
|
| 27 |
+
|
| 28 |
+
# CORS Settings
|
| 29 |
+
CORS_ORIGINS: list = [
|
| 30 |
+
"http://localhost:3000",
|
| 31 |
+
"http://localhost:5173",
|
| 32 |
+
"http://127.0.0.1:3000",
|
| 33 |
+
"http://127.0.0.1:5173"
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
# Path Configuration
|
| 37 |
+
BASE_DIR: Path = Path(__file__).parent
|
| 38 |
+
MODELS_DIR: Path = BASE_DIR / "models"
|
| 39 |
+
LORA_DIR: Path = MODELS_DIR / "lora"
|
| 40 |
+
BASE_MODEL_DIR: Path = MODELS_DIR / "base"
|
| 41 |
+
DATASETS_DIR: Path = BASE_DIR / "datasets"
|
| 42 |
+
INPUT_DIR: Path = DATASETS_DIR / "input"
|
| 43 |
+
TARGET_DIR: Path = DATASETS_DIR / "target"
|
| 44 |
+
PROCESSED_DIR: Path = DATASETS_DIR / "processed"
|
| 45 |
+
OUTPUT_DIR: Path = BASE_DIR / "output"
|
| 46 |
+
ENHANCED_DIR: Path = OUTPUT_DIR / "enhanced"
|
| 47 |
+
LOGS_DIR: Path = OUTPUT_DIR / "logs"
|
| 48 |
+
|
| 49 |
+
# Model Configuration
|
| 50 |
+
BASE_MODEL: str = "runwayml/stable-diffusion-v1-5" # Lighter model (~4GB RAM vs ~12GB for SDXL)
|
| 51 |
+
LORA_MODEL_NAME: str = "office_style.safetensors"
|
| 52 |
+
VAE_MODEL: Optional[str] = None
|
| 53 |
+
|
| 54 |
+
# Training Hyperparameters
|
| 55 |
+
LORA_RANK: int = 8
|
| 56 |
+
LEARNING_RATE: float = 1e-4
|
| 57 |
+
TRAIN_STEPS: int = 1000
|
| 58 |
+
BATCH_SIZE: int = 1
|
| 59 |
+
GRADIENT_ACCUMULATION_STEPS: int = 4
|
| 60 |
+
MAX_GRAD_NORM: float = 1.0
|
| 61 |
+
WARMUP_STEPS: int = 100
|
| 62 |
+
SAVE_STEPS: int = 250
|
| 63 |
+
|
| 64 |
+
# Inference Configuration
|
| 65 |
+
IMG2IMG_STRENGTH: float = 0.3 # Range: 0.2-0.35
|
| 66 |
+
GUIDANCE_SCALE: float = 5.5 # Range: 4-7
|
| 67 |
+
NUM_INFERENCE_STEPS: int = 30
|
| 68 |
+
|
| 69 |
+
# Image Processing
|
| 70 |
+
MAX_IMAGE_SIZE: int = 2048
|
| 71 |
+
UPSCALE_FACTOR: int = 2
|
| 72 |
+
TARGET_RESOLUTION: int = 512 # SD 1.5 works best at 512x512
|
| 73 |
+
|
| 74 |
+
# Prompts
|
| 75 |
+
DEFAULT_PROMPT: str = (
|
| 76 |
+
"ultra realistic architectural visualization, "
|
| 77 |
+
"professional photography, high detail, sharp focus, "
|
| 78 |
+
"natural lighting, modern office interior, "
|
| 79 |
+
"clean lines, photorealistic rendering"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
NEGATIVE_PROMPT: str = (
|
| 83 |
+
"distorted walls, cartoon, illustration, painting, drawing, "
|
| 84 |
+
"unrealistic proportions, blurry, low quality, artifacts, "
|
| 85 |
+
"oversaturated, noise, grain, ugly, deformed"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Device Configuration
|
| 89 |
+
DEVICE: str = "cuda" # "cuda" or "cpu" - auto-detected at runtime
|
| 90 |
+
MIXED_PRECISION: str = "fp16" # "fp16", "bf16", or "no"
|
| 91 |
+
ENABLE_ATTENTION_SLICING: bool = True # Reduce VRAM usage
|
| 92 |
+
ENABLE_VAE_SLICING: bool = True # Reduce VRAM usage
|
| 93 |
+
|
| 94 |
+
# Upload Limits
|
| 95 |
+
MAX_UPLOAD_SIZE: int = 25 * 1024 * 1024 # 25 MB
|
| 96 |
+
ALLOWED_EXTENSIONS: set = {".png", ".jpg", ".jpeg", ".webp"}
|
| 97 |
+
|
| 98 |
+
class Config:
|
| 99 |
+
env_file = ".env"
|
| 100 |
+
case_sensitive = True
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# Global settings instance
|
| 104 |
+
settings = Settings()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def ensure_directories():
|
| 108 |
+
"""
|
| 109 |
+
Create all necessary directories if they don't exist
|
| 110 |
+
|
| 111 |
+
This function should be called on application startup
|
| 112 |
+
"""
|
| 113 |
+
directories = [
|
| 114 |
+
settings.MODELS_DIR,
|
| 115 |
+
settings.LORA_DIR,
|
| 116 |
+
settings.BASE_MODEL_DIR,
|
| 117 |
+
settings.DATASETS_DIR,
|
| 118 |
+
settings.INPUT_DIR,
|
| 119 |
+
settings.TARGET_DIR,
|
| 120 |
+
settings.PROCESSED_DIR,
|
| 121 |
+
settings.OUTPUT_DIR,
|
| 122 |
+
settings.ENHANCED_DIR,
|
| 123 |
+
settings.LOGS_DIR,
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
for directory in directories:
|
| 127 |
+
directory.mkdir(parents=True, exist_ok=True)
|
| 128 |
+
print(f"✓ Ensured directory exists: {directory}")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def get_lora_path() -> Optional[Path]:
|
| 132 |
+
"""
|
| 133 |
+
Get the path to the trained LoRA model if it exists
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
Path to LoRA model or None if not found
|
| 137 |
+
"""
|
| 138 |
+
lora_path = settings.LORA_DIR / settings.LORA_MODEL_NAME
|
| 139 |
+
return lora_path if lora_path.exists() else None
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def validate_image_file(filename: str) -> bool:
|
| 143 |
+
"""
|
| 144 |
+
Validate if a file has an allowed image extension
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
filename: Name of the file to validate
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
True if valid, False otherwise
|
| 151 |
+
"""
|
| 152 |
+
return Path(filename).suffix.lower() in settings.ALLOWED_EXTENSIONS
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
if __name__ == "__main__":
|
| 156 |
+
# Test configuration
|
| 157 |
+
ensure_directories()
|
| 158 |
+
print(f"\n{settings.APP_NAME} v{settings.APP_VERSION}")
|
| 159 |
+
print(f"Base Model: {settings.BASE_MODEL}")
|
| 160 |
+
print(f"LoRA Path: {get_lora_path()}")
|
backend/services/__init__.py
ADDED
|
File without changes
|
backend/services/diffusion_pipeline.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Diffusion Pipeline Service
|
| 3 |
+
|
| 4 |
+
Orchestrates the complete image enhancement pipeline:
|
| 5 |
+
1. Load and preprocess input image
|
| 6 |
+
2. Run Stable Diffusion img2img with optional LoRA
|
| 7 |
+
3. Apply upscaling
|
| 8 |
+
4. Apply post-processing effects
|
| 9 |
+
"""
|
| 10 |
+
import logging
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Optional, Callable
|
| 13 |
+
import torch
|
| 14 |
+
from PIL import Image
|
| 15 |
+
from diffusers import (
|
| 16 |
+
StableDiffusionImg2ImgPipeline,
|
| 17 |
+
AutoencoderKL,
|
| 18 |
+
DPMSolverMultistepScheduler
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
from config import settings, get_lora_path
|
| 22 |
+
from services.upscaler import upscale_image
|
| 23 |
+
from services.postprocess import postprocess_image
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class DiffusionPipelineManager:
|
| 30 |
+
"""
|
| 31 |
+
Manages the Stable Diffusion pipeline for image enhancement
|
| 32 |
+
|
| 33 |
+
This class handles lazy loading of models and provides a clean
|
| 34 |
+
interface for image-to-image enhancement.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(self):
|
| 38 |
+
self.pipe = None
|
| 39 |
+
self.lora_loaded = False
|
| 40 |
+
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 41 |
+
logger.info(f"Pipeline will use device: {self.device}")
|
| 42 |
+
logger.info(f"CUDA available: {torch.cuda.is_available()}")
|
| 43 |
+
if torch.cuda.is_available():
|
| 44 |
+
logger.info(f"CUDA device: {torch.cuda.get_device_name(0)}")
|
| 45 |
+
|
| 46 |
+
def load_pipeline(self):
|
| 47 |
+
"""
|
| 48 |
+
Load the Stable Diffusion img2img pipeline with optimizations
|
| 49 |
+
|
| 50 |
+
This method initializes the Stable Diffusion pipeline with:
|
| 51 |
+
- Mixed precision for faster inference
|
| 52 |
+
- Memory-efficient attention
|
| 53 |
+
- Optional VAE for better image quality
|
| 54 |
+
"""
|
| 55 |
+
if self.pipe is not None:
|
| 56 |
+
return
|
| 57 |
+
|
| 58 |
+
logger.info("Loading Stable Diffusion 1.5 pipeline...")
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
# Determine dtype based on device
|
| 62 |
+
dtype = torch.float32 # Always use float32 for CPU compatibility
|
| 63 |
+
|
| 64 |
+
logger.info(f"Loading model with dtype: {dtype}, device: {self.device}")
|
| 65 |
+
|
| 66 |
+
# Initialize the pipeline (SD 1.5) - let diffusers handle all components
|
| 67 |
+
self.pipe = StableDiffusionImg2ImgPipeline.from_pretrained(
|
| 68 |
+
settings.BASE_MODEL,
|
| 69 |
+
torch_dtype=dtype,
|
| 70 |
+
safety_checker=None,
|
| 71 |
+
requires_safety_checker=False,
|
| 72 |
+
low_cpu_mem_usage=False # Load everything properly
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Optimize scheduler
|
| 76 |
+
self.pipe.scheduler = DPMSolverMultistepScheduler.from_config(
|
| 77 |
+
self.pipe.scheduler.config
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Move to device
|
| 81 |
+
self.pipe = self.pipe.to(self.device)
|
| 82 |
+
|
| 83 |
+
# Enable memory optimizations
|
| 84 |
+
if self.device == "cuda":
|
| 85 |
+
if settings.ENABLE_ATTENTION_SLICING:
|
| 86 |
+
self.pipe.enable_attention_slicing()
|
| 87 |
+
logger.info("Enabled attention slicing")
|
| 88 |
+
if settings.ENABLE_VAE_SLICING:
|
| 89 |
+
self.pipe.enable_vae_slicing()
|
| 90 |
+
logger.info("Enabled VAE slicing")
|
| 91 |
+
|
| 92 |
+
logger.info("Pipeline loaded successfully")
|
| 93 |
+
logger.info(f"Model: {settings.BASE_MODEL}")
|
| 94 |
+
logger.info(f"Device: {self.device}")
|
| 95 |
+
|
| 96 |
+
except Exception as e:
|
| 97 |
+
logger.error(f"Failed to load pipeline: {e}", exc_info=True)
|
| 98 |
+
raise
|
| 99 |
+
|
| 100 |
+
def load_lora(self, lora_path: Path):
|
| 101 |
+
"""
|
| 102 |
+
Load a custom LoRA adapter into the pipeline
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
lora_path: Path to the LoRA weights file (.safetensors)
|
| 106 |
+
"""
|
| 107 |
+
if self.pipe is None:
|
| 108 |
+
self.load_pipeline()
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
logger.info(f"Loading LoRA from: {lora_path}")
|
| 112 |
+
self.pipe.load_lora_weights(str(lora_path.parent), weight_name=lora_path.name)
|
| 113 |
+
self.lora_loaded = True
|
| 114 |
+
logger.info("LoRA loaded successfully")
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.warning(f"Failed to load LoRA (continuing without it): {e}")
|
| 118 |
+
self.lora_loaded = False
|
| 119 |
+
|
| 120 |
+
def enhance(
|
| 121 |
+
self,
|
| 122 |
+
image: Image.Image,
|
| 123 |
+
prompt: str,
|
| 124 |
+
negative_prompt: str,
|
| 125 |
+
strength: float,
|
| 126 |
+
guidance_scale: float,
|
| 127 |
+
num_inference_steps: int = 30,
|
| 128 |
+
progress_callback: Optional[Callable[[int, str], None]] = None
|
| 129 |
+
) -> Image.Image:
|
| 130 |
+
"""
|
| 131 |
+
Run image-to-image enhancement
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
image: Input PIL Image
|
| 135 |
+
prompt: Positive prompt
|
| 136 |
+
negative_prompt: Negative prompt
|
| 137 |
+
strength: Denoising strength (0-1)
|
| 138 |
+
guidance_scale: CFG scale
|
| 139 |
+
num_inference_steps: Number of diffusion steps
|
| 140 |
+
progress_callback: Optional callback for progress updates
|
| 141 |
+
|
| 142 |
+
Returns:
|
| 143 |
+
Enhanced PIL Image
|
| 144 |
+
"""
|
| 145 |
+
if self.pipe is None:
|
| 146 |
+
if progress_callback:
|
| 147 |
+
progress_callback(30, "Loading AI model...")
|
| 148 |
+
self.load_pipeline()
|
| 149 |
+
|
| 150 |
+
if progress_callback:
|
| 151 |
+
progress_callback(40, "Preprocessing image...")
|
| 152 |
+
|
| 153 |
+
logger.info(f"Running enhancement - Strength: {strength}, Guidance: {guidance_scale}")
|
| 154 |
+
|
| 155 |
+
# Ensure image is in RGB mode
|
| 156 |
+
if image.mode != "RGB":
|
| 157 |
+
image = image.convert("RGB")
|
| 158 |
+
|
| 159 |
+
# Resize if too large
|
| 160 |
+
max_size = settings.MAX_IMAGE_SIZE
|
| 161 |
+
if max(image.size) > max_size:
|
| 162 |
+
ratio = max_size / max(image.size)
|
| 163 |
+
new_size = tuple(int(dim * ratio) for dim in image.size)
|
| 164 |
+
image = image.resize(new_size, Image.LANCZOS)
|
| 165 |
+
logger.info(f"Resized input to: {new_size}")
|
| 166 |
+
|
| 167 |
+
if progress_callback:
|
| 168 |
+
progress_callback(50, f"Starting generation ({num_inference_steps} steps)...")
|
| 169 |
+
|
| 170 |
+
# Callback to track diffusion progress
|
| 171 |
+
def step_callback(pipe, step_index, timestep, callback_kwargs):
|
| 172 |
+
if progress_callback:
|
| 173 |
+
# Calculate progress: 50-85% range mapped to steps
|
| 174 |
+
progress = 50 + int((step_index / num_inference_steps) * 35)
|
| 175 |
+
progress_callback(progress, f"Step {step_index+1}/{num_inference_steps}")
|
| 176 |
+
return callback_kwargs
|
| 177 |
+
|
| 178 |
+
# Run inference
|
| 179 |
+
with torch.inference_mode():
|
| 180 |
+
result = self.pipe(
|
| 181 |
+
prompt=prompt,
|
| 182 |
+
negative_prompt=negative_prompt,
|
| 183 |
+
image=image,
|
| 184 |
+
strength=strength,
|
| 185 |
+
guidance_scale=guidance_scale,
|
| 186 |
+
num_inference_steps=num_inference_steps,
|
| 187 |
+
callback_on_step_end=step_callback if progress_callback else None
|
| 188 |
+
).images[0]
|
| 189 |
+
|
| 190 |
+
if progress_callback:
|
| 191 |
+
progress_callback(85, "Finalizing...")
|
| 192 |
+
|
| 193 |
+
return result
|
| 194 |
+
|
| 195 |
+
def unload(self):
|
| 196 |
+
"""
|
| 197 |
+
Unload the pipeline to free memory
|
| 198 |
+
"""
|
| 199 |
+
if self.pipe is not None:
|
| 200 |
+
del self.pipe
|
| 201 |
+
self.pipe = None
|
| 202 |
+
self.lora_loaded = False
|
| 203 |
+
torch.cuda.empty_cache()
|
| 204 |
+
logger.info("Pipeline unloaded")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# Global pipeline instance (singleton pattern)
|
| 208 |
+
_pipeline_manager = None
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def get_pipeline_manager() -> DiffusionPipelineManager:
|
| 212 |
+
"""
|
| 213 |
+
Get or create the global pipeline manager instance
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
DiffusionPipelineManager instance
|
| 217 |
+
"""
|
| 218 |
+
global _pipeline_manager
|
| 219 |
+
if _pipeline_manager is None:
|
| 220 |
+
_pipeline_manager = DiffusionPipelineManager()
|
| 221 |
+
return _pipeline_manager
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def preprocess_image(image_path: Path) -> Image.Image:
|
| 225 |
+
"""
|
| 226 |
+
Load and preprocess an input image
|
| 227 |
+
|
| 228 |
+
This function:
|
| 229 |
+
- Loads the image
|
| 230 |
+
- Converts to RGB
|
| 231 |
+
- Normalizes exposure/gamma if needed
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
image_path: Path to input image
|
| 235 |
+
|
| 236 |
+
Returns:
|
| 237 |
+
Preprocessed PIL Image
|
| 238 |
+
"""
|
| 239 |
+
logger.info(f"Loading image from: {image_path}")
|
| 240 |
+
|
| 241 |
+
image = Image.open(image_path)
|
| 242 |
+
|
| 243 |
+
# Convert to RGB if necessary
|
| 244 |
+
if image.mode != "RGB":
|
| 245 |
+
image = image.convert("RGB")
|
| 246 |
+
|
| 247 |
+
# Optional: Add gamma/exposure normalization here
|
| 248 |
+
# This would analyze the histogram and adjust brightness
|
| 249 |
+
|
| 250 |
+
return image
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def enhance_image(
|
| 254 |
+
input_image_path: Path,
|
| 255 |
+
output_path: Path,
|
| 256 |
+
strength: float = settings.IMG2IMG_STRENGTH,
|
| 257 |
+
guidance_scale: float = settings.GUIDANCE_SCALE,
|
| 258 |
+
custom_prompt: Optional[str] = None,
|
| 259 |
+
use_upscaler: bool = True,
|
| 260 |
+
use_postprocess: bool = True,
|
| 261 |
+
progress_callback: Optional[Callable[[int, str], None]] = None
|
| 262 |
+
) -> Path:
|
| 263 |
+
"""
|
| 264 |
+
Complete image enhancement pipeline
|
| 265 |
+
|
| 266 |
+
This is the main entry point for enhancing images. It orchestrates:
|
| 267 |
+
1. Image preprocessing
|
| 268 |
+
2. Stable Diffusion img2img with optional LoRA
|
| 269 |
+
3. Upscaling (optional)
|
| 270 |
+
4. Post-processing (optional)
|
| 271 |
+
|
| 272 |
+
Args:
|
| 273 |
+
input_image_path: Path to input image
|
| 274 |
+
output_path: Path to save enhanced image
|
| 275 |
+
strength: Image-to-image strength
|
| 276 |
+
guidance_scale: CFG scale
|
| 277 |
+
custom_prompt: Optional custom prompt
|
| 278 |
+
use_upscaler: Whether to apply upscaling
|
| 279 |
+
use_postprocess: Whether to apply post-processing
|
| 280 |
+
progress_callback: Optional callback for progress updates
|
| 281 |
+
|
| 282 |
+
Returns:
|
| 283 |
+
Path to the saved enhanced image
|
| 284 |
+
"""
|
| 285 |
+
try:
|
| 286 |
+
# Step 1: Load and preprocess
|
| 287 |
+
if progress_callback:
|
| 288 |
+
progress_callback(25, "Loading and preprocessing image...")
|
| 289 |
+
logger.info("Step 1/4: Preprocessing image")
|
| 290 |
+
image = preprocess_image(input_image_path)
|
| 291 |
+
|
| 292 |
+
# Step 2: Get pipeline and load LoRA if available
|
| 293 |
+
logger.info("Step 2/4: Running diffusion model")
|
| 294 |
+
pipeline = get_pipeline_manager()
|
| 295 |
+
|
| 296 |
+
lora_path = get_lora_path()
|
| 297 |
+
if lora_path and not pipeline.lora_loaded:
|
| 298 |
+
pipeline.load_lora(lora_path)
|
| 299 |
+
|
| 300 |
+
# Determine prompt
|
| 301 |
+
prompt = custom_prompt if custom_prompt else settings.DEFAULT_PROMPT
|
| 302 |
+
|
| 303 |
+
# Run enhancement
|
| 304 |
+
enhanced = pipeline.enhance(
|
| 305 |
+
image=image,
|
| 306 |
+
prompt=prompt,
|
| 307 |
+
negative_prompt=settings.NEGATIVE_PROMPT,
|
| 308 |
+
strength=strength,
|
| 309 |
+
guidance_scale=guidance_scale,
|
| 310 |
+
num_inference_steps=settings.NUM_INFERENCE_STEPS,
|
| 311 |
+
progress_callback=progress_callback
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
# Step 3: Upscaling
|
| 315 |
+
if use_upscaler:
|
| 316 |
+
if progress_callback:
|
| 317 |
+
progress_callback(92, "Upscaling image...")
|
| 318 |
+
logger.info("Step 3/4: Upscaling image")
|
| 319 |
+
enhanced = upscale_image(enhanced, scale=settings.UPSCALE_FACTOR)
|
| 320 |
+
else:
|
| 321 |
+
logger.info("Step 3/4: Skipping upscaling")
|
| 322 |
+
|
| 323 |
+
# Step 4: Post-processing
|
| 324 |
+
if use_postprocess:
|
| 325 |
+
if progress_callback:
|
| 326 |
+
progress_callback(94, "Applying final touches...")
|
| 327 |
+
logger.info("Step 4/4: Applying post-processing")
|
| 328 |
+
enhanced = postprocess_image(enhanced)
|
| 329 |
+
else:
|
| 330 |
+
logger.info("Step 4/4: Skipping post-processing")
|
| 331 |
+
|
| 332 |
+
# Save result
|
| 333 |
+
enhanced.save(output_path, quality=95, optimize=True)
|
| 334 |
+
logger.info(f"Enhanced image saved to: {output_path}")
|
| 335 |
+
|
| 336 |
+
return output_path
|
| 337 |
+
|
| 338 |
+
except Exception as e:
|
| 339 |
+
logger.error(f"Enhancement pipeline failed: {e}", exc_info=True)
|
| 340 |
+
raise
|
backend/services/postprocess.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Post-Processing Service
|
| 3 |
+
|
| 4 |
+
Applies subtle photographic enhancements to the generated images:
|
| 5 |
+
- Local contrast enhancement (CLAHE)
|
| 6 |
+
- Film grain simulation
|
| 7 |
+
- Subtle vignette effect
|
| 8 |
+
- Color grading adjustments
|
| 9 |
+
"""
|
| 10 |
+
import logging
|
| 11 |
+
import numpy as np
|
| 12 |
+
from PIL import Image, ImageEnhance, ImageFilter
|
| 13 |
+
import cv2
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def apply_clahe(image: Image.Image, clip_limit: float = 2.0) -> Image.Image:
|
| 20 |
+
"""
|
| 21 |
+
Apply Contrast Limited Adaptive Histogram Equalization (CLAHE)
|
| 22 |
+
|
| 23 |
+
This enhances local contrast without over-amplifying noise.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
image: Input PIL Image
|
| 27 |
+
clip_limit: Threshold for contrast limiting (higher = more contrast)
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
Enhanced PIL Image
|
| 31 |
+
"""
|
| 32 |
+
# Convert to numpy array
|
| 33 |
+
img_np = np.array(image)
|
| 34 |
+
|
| 35 |
+
# Convert to LAB color space
|
| 36 |
+
lab = cv2.cvtColor(img_np, cv2.COLOR_RGB2LAB)
|
| 37 |
+
|
| 38 |
+
# Split channels
|
| 39 |
+
l, a, b = cv2.split(lab)
|
| 40 |
+
|
| 41 |
+
# Apply CLAHE to L channel
|
| 42 |
+
clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(8, 8))
|
| 43 |
+
l_clahe = clahe.apply(l)
|
| 44 |
+
|
| 45 |
+
# Merge channels
|
| 46 |
+
lab_clahe = cv2.merge([l_clahe, a, b])
|
| 47 |
+
|
| 48 |
+
# Convert back to RGB
|
| 49 |
+
rgb = cv2.cvtColor(lab_clahe, cv2.COLOR_LAB2RGB)
|
| 50 |
+
|
| 51 |
+
return Image.fromarray(rgb)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def add_film_grain(
|
| 55 |
+
image: Image.Image,
|
| 56 |
+
intensity: float = 0.02,
|
| 57 |
+
grain_size: float = 1.0
|
| 58 |
+
) -> Image.Image:
|
| 59 |
+
"""
|
| 60 |
+
Add subtle film grain for a more organic look
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
image: Input PIL Image
|
| 64 |
+
intensity: Strength of the grain effect (0.01-0.05 recommended)
|
| 65 |
+
grain_size: Size of grain particles
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
Image with film grain
|
| 69 |
+
"""
|
| 70 |
+
img_np = np.array(image).astype(np.float32) / 255.0
|
| 71 |
+
|
| 72 |
+
# Generate noise
|
| 73 |
+
noise = np.random.normal(0, intensity, img_np.shape)
|
| 74 |
+
|
| 75 |
+
# Optional: blur noise for larger grain
|
| 76 |
+
if grain_size > 1.0:
|
| 77 |
+
noise = cv2.GaussianBlur(noise, (0, 0), grain_size)
|
| 78 |
+
|
| 79 |
+
# Add noise to image
|
| 80 |
+
noisy = img_np + noise
|
| 81 |
+
noisy = np.clip(noisy, 0, 1)
|
| 82 |
+
|
| 83 |
+
# Convert back to uint8
|
| 84 |
+
result = (noisy * 255).astype(np.uint8)
|
| 85 |
+
|
| 86 |
+
return Image.fromarray(result)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def apply_vignette(
|
| 90 |
+
image: Image.Image,
|
| 91 |
+
strength: float = 0.3,
|
| 92 |
+
radius: float = 0.8
|
| 93 |
+
) -> Image.Image:
|
| 94 |
+
"""
|
| 95 |
+
Apply a subtle vignette effect
|
| 96 |
+
|
| 97 |
+
Darkens the corners and edges of the image to draw focus to the center.
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
image: Input PIL Image
|
| 101 |
+
strength: Vignette intensity (0-1)
|
| 102 |
+
radius: Radius of the unaffected center area (0-1)
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
Image with vignette
|
| 106 |
+
"""
|
| 107 |
+
width, height = image.size
|
| 108 |
+
img_np = np.array(image).astype(np.float32)
|
| 109 |
+
|
| 110 |
+
# Create coordinate grids
|
| 111 |
+
x = np.linspace(-1, 1, width)
|
| 112 |
+
y = np.linspace(-1, 1, height)
|
| 113 |
+
X, Y = np.meshgrid(x, y)
|
| 114 |
+
|
| 115 |
+
# Calculate distance from center
|
| 116 |
+
distance = np.sqrt(X**2 + Y**2)
|
| 117 |
+
|
| 118 |
+
# Create vignette mask
|
| 119 |
+
vignette = 1 - np.clip((distance - radius) / (1 - radius), 0, 1) * strength
|
| 120 |
+
vignette = vignette[:, :, np.newaxis] # Add channel dimension
|
| 121 |
+
|
| 122 |
+
# Apply vignette
|
| 123 |
+
result = img_np * vignette
|
| 124 |
+
result = np.clip(result, 0, 255).astype(np.uint8)
|
| 125 |
+
|
| 126 |
+
return Image.fromarray(result)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def enhance_colors(
|
| 130 |
+
image: Image.Image,
|
| 131 |
+
saturation: float = 1.1,
|
| 132 |
+
contrast: float = 1.05,
|
| 133 |
+
brightness: float = 1.0
|
| 134 |
+
) -> Image.Image:
|
| 135 |
+
"""
|
| 136 |
+
Apply subtle color grading adjustments
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
image: Input PIL Image
|
| 140 |
+
saturation: Saturation multiplier (1.0 = no change)
|
| 141 |
+
contrast: Contrast multiplier (1.0 = no change)
|
| 142 |
+
brightness: Brightness multiplier (1.0 = no change)
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
Color-graded image
|
| 146 |
+
"""
|
| 147 |
+
# Adjust saturation
|
| 148 |
+
if saturation != 1.0:
|
| 149 |
+
enhancer = ImageEnhance.Color(image)
|
| 150 |
+
image = enhancer.enhance(saturation)
|
| 151 |
+
|
| 152 |
+
# Adjust contrast
|
| 153 |
+
if contrast != 1.0:
|
| 154 |
+
enhancer = ImageEnhance.Contrast(image)
|
| 155 |
+
image = enhancer.enhance(contrast)
|
| 156 |
+
|
| 157 |
+
# Adjust brightness
|
| 158 |
+
if brightness != 1.0:
|
| 159 |
+
enhancer = ImageEnhance.Brightness(image)
|
| 160 |
+
image = enhancer.enhance(brightness)
|
| 161 |
+
|
| 162 |
+
return image
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def sharpen_image(image: Image.Image, strength: float = 1.0) -> Image.Image:
|
| 166 |
+
"""
|
| 167 |
+
Apply subtle sharpening
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
image: Input PIL Image
|
| 171 |
+
strength: Sharpening strength (0-2 recommended)
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
Sharpened image
|
| 175 |
+
"""
|
| 176 |
+
if strength <= 0:
|
| 177 |
+
return image
|
| 178 |
+
|
| 179 |
+
# Use UnsharpMask for better control
|
| 180 |
+
from PIL import ImageFilter
|
| 181 |
+
|
| 182 |
+
# Blend between original and sharpened
|
| 183 |
+
sharpened = image.filter(ImageFilter.UnsharpMask(radius=1, percent=150, threshold=3))
|
| 184 |
+
|
| 185 |
+
if strength < 1.0:
|
| 186 |
+
# Blend with original
|
| 187 |
+
return Image.blend(image, sharpened, strength)
|
| 188 |
+
else:
|
| 189 |
+
return sharpened
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def postprocess_image(
|
| 193 |
+
image: Image.Image,
|
| 194 |
+
apply_contrast: bool = True,
|
| 195 |
+
apply_grain: bool = True,
|
| 196 |
+
apply_vignette_effect: bool = True,
|
| 197 |
+
apply_color_grading: bool = True,
|
| 198 |
+
apply_sharpening: bool = True
|
| 199 |
+
) -> Image.Image:
|
| 200 |
+
"""
|
| 201 |
+
Apply complete post-processing pipeline
|
| 202 |
+
|
| 203 |
+
This function orchestrates all post-processing effects in the optimal order:
|
| 204 |
+
1. Local contrast enhancement (CLAHE)
|
| 205 |
+
2. Color grading
|
| 206 |
+
3. Sharpening
|
| 207 |
+
4. Film grain
|
| 208 |
+
5. Vignette
|
| 209 |
+
|
| 210 |
+
Args:
|
| 211 |
+
image: Input PIL Image
|
| 212 |
+
apply_contrast: Enable local contrast enhancement
|
| 213 |
+
apply_grain: Enable film grain
|
| 214 |
+
apply_vignette_effect: Enable vignette
|
| 215 |
+
apply_color_grading: Enable color adjustments
|
| 216 |
+
apply_sharpening: Enable sharpening
|
| 217 |
+
|
| 218 |
+
Returns:
|
| 219 |
+
Post-processed PIL Image
|
| 220 |
+
"""
|
| 221 |
+
logger.info("Starting post-processing")
|
| 222 |
+
|
| 223 |
+
try:
|
| 224 |
+
# Step 1: Local contrast
|
| 225 |
+
if apply_contrast:
|
| 226 |
+
logger.debug("Applying CLAHE")
|
| 227 |
+
image = apply_clahe(image, clip_limit=2.0)
|
| 228 |
+
|
| 229 |
+
# Step 2: Color grading
|
| 230 |
+
if apply_color_grading:
|
| 231 |
+
logger.debug("Applying color grading")
|
| 232 |
+
image = enhance_colors(
|
| 233 |
+
image,
|
| 234 |
+
saturation=1.08, # Slightly more saturated
|
| 235 |
+
contrast=1.03, # Slightly more contrast
|
| 236 |
+
brightness=1.0 # No brightness change
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
# Step 3: Sharpening
|
| 240 |
+
if apply_sharpening:
|
| 241 |
+
logger.debug("Applying sharpening")
|
| 242 |
+
image = sharpen_image(image, strength=0.6)
|
| 243 |
+
|
| 244 |
+
# Step 4: Film grain
|
| 245 |
+
if apply_grain:
|
| 246 |
+
logger.debug("Adding film grain")
|
| 247 |
+
image = add_film_grain(image, intensity=0.015, grain_size=1.2)
|
| 248 |
+
|
| 249 |
+
# Step 5: Vignette
|
| 250 |
+
if apply_vignette_effect:
|
| 251 |
+
logger.debug("Applying vignette")
|
| 252 |
+
image = apply_vignette(image, strength=0.2, radius=0.85)
|
| 253 |
+
|
| 254 |
+
logger.info("Post-processing completed")
|
| 255 |
+
return image
|
| 256 |
+
|
| 257 |
+
except Exception as e:
|
| 258 |
+
logger.error(f"Error in post-processing: {e}", exc_info=True)
|
| 259 |
+
logger.warning("Returning original image")
|
| 260 |
+
return image
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def create_comparison(
|
| 264 |
+
original: Image.Image,
|
| 265 |
+
processed: Image.Image,
|
| 266 |
+
padding: int = 10
|
| 267 |
+
) -> Image.Image:
|
| 268 |
+
"""
|
| 269 |
+
Create a side-by-side comparison image
|
| 270 |
+
|
| 271 |
+
Useful for visualizing before/after results.
|
| 272 |
+
|
| 273 |
+
Args:
|
| 274 |
+
original: Original image
|
| 275 |
+
processed: Processed image
|
| 276 |
+
padding: Space between images in pixels
|
| 277 |
+
|
| 278 |
+
Returns:
|
| 279 |
+
Combined comparison image
|
| 280 |
+
"""
|
| 281 |
+
# Ensure both images are the same size
|
| 282 |
+
if original.size != processed.size:
|
| 283 |
+
processed = processed.resize(original.size, Image.LANCZOS)
|
| 284 |
+
|
| 285 |
+
width, height = original.size
|
| 286 |
+
|
| 287 |
+
# Create new image with space for both
|
| 288 |
+
comparison = Image.new('RGB', (width * 2 + padding, height), color='white')
|
| 289 |
+
|
| 290 |
+
# Paste images
|
| 291 |
+
comparison.paste(original, (0, 0))
|
| 292 |
+
comparison.paste(processed, (width + padding, 0))
|
| 293 |
+
|
| 294 |
+
return comparison
|
backend/services/progress_tracker.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Progress Tracking System
|
| 3 |
+
|
| 4 |
+
Manages progress updates for long-running enhancement tasks
|
| 5 |
+
Supports Server-Sent Events (SSE) for real-time frontend updates
|
| 6 |
+
"""
|
| 7 |
+
import asyncio
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Dict, Optional
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ProgressTracker:
|
| 17 |
+
"""
|
| 18 |
+
Tracks progress for enhancement operations
|
| 19 |
+
Allows multiple clients to subscribe to progress updates
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
def __init__(self):
|
| 23 |
+
self.tasks: Dict[str, Dict] = {}
|
| 24 |
+
self.subscribers: Dict[str, list] = {}
|
| 25 |
+
|
| 26 |
+
def create_task(self, task_id: str, total_steps: int = 100):
|
| 27 |
+
"""Initialize a new task for progress tracking"""
|
| 28 |
+
self.tasks[task_id] = {
|
| 29 |
+
"id": task_id,
|
| 30 |
+
"status": "initializing",
|
| 31 |
+
"progress": 0,
|
| 32 |
+
"total_steps": total_steps,
|
| 33 |
+
"current_step": 0,
|
| 34 |
+
"message": "Starting enhancement...",
|
| 35 |
+
"started_at": datetime.now().isoformat(),
|
| 36 |
+
"error": None
|
| 37 |
+
}
|
| 38 |
+
self.subscribers[task_id] = []
|
| 39 |
+
logger.info(f"Created progress tracker for task {task_id}")
|
| 40 |
+
|
| 41 |
+
def update_progress(
|
| 42 |
+
self,
|
| 43 |
+
task_id: str,
|
| 44 |
+
current_step: int,
|
| 45 |
+
message: str,
|
| 46 |
+
status: str = "processing"
|
| 47 |
+
):
|
| 48 |
+
"""Update progress for a task (synchronous)"""
|
| 49 |
+
if task_id not in self.tasks:
|
| 50 |
+
logger.warning(f"Task {task_id} not found for progress update")
|
| 51 |
+
return
|
| 52 |
+
|
| 53 |
+
task = self.tasks[task_id]
|
| 54 |
+
task["current_step"] = current_step
|
| 55 |
+
task["progress"] = int((current_step / task["total_steps"]) * 100)
|
| 56 |
+
task["message"] = message
|
| 57 |
+
task["status"] = status
|
| 58 |
+
|
| 59 |
+
logger.info(f"Task {task_id}: {task['progress']}% - {message}")
|
| 60 |
+
|
| 61 |
+
def complete_task(self, task_id: str, result_url: Optional[str] = None):
|
| 62 |
+
"""Mark a task as completed"""
|
| 63 |
+
if task_id not in self.tasks:
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
self.tasks[task_id].update({
|
| 67 |
+
"status": "completed",
|
| 68 |
+
"progress": 100,
|
| 69 |
+
"message": "Enhancement completed!",
|
| 70 |
+
"result_url": result_url,
|
| 71 |
+
"completed_at": datetime.now().isoformat()
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
# Notify subscribers if event loop is running
|
| 75 |
+
try:
|
| 76 |
+
asyncio.create_task(self._notify_subscribers(task_id))
|
| 77 |
+
except RuntimeError:
|
| 78 |
+
# No event loop running (called from thread)
|
| 79 |
+
pass
|
| 80 |
+
|
| 81 |
+
logger.info(f"Task {task_id} completed")
|
| 82 |
+
|
| 83 |
+
def fail_task(self, task_id: str, error: str):
|
| 84 |
+
"""Mark a task as failed"""
|
| 85 |
+
if task_id not in self.tasks:
|
| 86 |
+
return
|
| 87 |
+
|
| 88 |
+
self.tasks[task_id].update({
|
| 89 |
+
"status": "failed",
|
| 90 |
+
"message": f"Error: {error}",
|
| 91 |
+
"error": error,
|
| 92 |
+
"failed_at": datetime.now().isoformat()
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
# Notify subscribers if event loop is running
|
| 96 |
+
try:
|
| 97 |
+
asyncio.create_task(self._notify_subscribers(task_id))
|
| 98 |
+
except RuntimeError:
|
| 99 |
+
# No event loop running (called from thread)
|
| 100 |
+
pass
|
| 101 |
+
|
| 102 |
+
logger.error(f"Task {task_id} failed: {error}")
|
| 103 |
+
|
| 104 |
+
def get_task_status(self, task_id: str) -> Optional[Dict]:
|
| 105 |
+
"""Get current status of a task"""
|
| 106 |
+
return self.tasks.get(task_id)
|
| 107 |
+
|
| 108 |
+
def subscribe(self, task_id: str, queue: asyncio.Queue):
|
| 109 |
+
"""Subscribe to progress updates for a task"""
|
| 110 |
+
if task_id not in self.subscribers:
|
| 111 |
+
self.subscribers[task_id] = []
|
| 112 |
+
self.subscribers[task_id].append(queue)
|
| 113 |
+
logger.debug(f"New subscriber for task {task_id}")
|
| 114 |
+
|
| 115 |
+
def unsubscribe(self, task_id: str, queue: asyncio.Queue):
|
| 116 |
+
"""Unsubscribe from progress updates"""
|
| 117 |
+
if task_id in self.subscribers and queue in self.subscribers[task_id]:
|
| 118 |
+
self.subscribers[task_id].remove(queue)
|
| 119 |
+
|
| 120 |
+
async def _notify_subscribers(self, task_id: str):
|
| 121 |
+
"""Send current task status to all subscribers"""
|
| 122 |
+
if task_id not in self.subscribers:
|
| 123 |
+
return
|
| 124 |
+
|
| 125 |
+
task_data = self.tasks.get(task_id)
|
| 126 |
+
if not task_data:
|
| 127 |
+
return
|
| 128 |
+
|
| 129 |
+
# Send to all subscribers
|
| 130 |
+
for queue in self.subscribers[task_id]:
|
| 131 |
+
try:
|
| 132 |
+
await queue.put(task_data.copy())
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"Error notifying subscriber: {e}")
|
| 135 |
+
|
| 136 |
+
def cleanup_task(self, task_id: str):
|
| 137 |
+
"""Remove task data after completion"""
|
| 138 |
+
if task_id in self.tasks:
|
| 139 |
+
del self.tasks[task_id]
|
| 140 |
+
if task_id in self.subscribers:
|
| 141 |
+
del self.subscribers[task_id]
|
| 142 |
+
logger.debug(f"Cleaned up task {task_id}")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# Global progress tracker instance
|
| 146 |
+
progress_tracker = ProgressTracker()
|
backend/services/training_engine.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Training Engine for LoRA Models
|
| 3 |
+
|
| 4 |
+
This module handles the complete training pipeline for custom LoRA adapters:
|
| 5 |
+
- Dataset preparation and loading
|
| 6 |
+
- Model initialization
|
| 7 |
+
- Training loop with checkpointing
|
| 8 |
+
- Model saving and validation
|
| 9 |
+
"""
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Optional
|
| 14 |
+
import torch
|
| 15 |
+
from torch.utils.data import Dataset, DataLoader
|
| 16 |
+
from PIL import Image
|
| 17 |
+
from diffusers import (
|
| 18 |
+
StableDiffusionXLPipeline,
|
| 19 |
+
AutoencoderKL,
|
| 20 |
+
UNet2DConditionModel
|
| 21 |
+
)
|
| 22 |
+
from transformers import CLIPTextModel, CLIPTokenizer
|
| 23 |
+
from accelerate import Accelerator
|
| 24 |
+
from accelerate.utils import set_seed
|
| 25 |
+
from peft import LoraConfig, get_peft_model
|
| 26 |
+
import torch.nn.functional as F
|
| 27 |
+
|
| 28 |
+
from config import settings
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ArchitectureDataset(Dataset):
|
| 35 |
+
"""
|
| 36 |
+
Custom dataset for architectural image pairs
|
| 37 |
+
|
| 38 |
+
Loads input-target pairs for LoRA training.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
def __init__(self, input_dir: Path, target_dir: Path, size: int = 1024):
|
| 42 |
+
"""
|
| 43 |
+
Initialize dataset
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
input_dir: Directory containing input images
|
| 47 |
+
target_dir: Directory containing target images
|
| 48 |
+
size: Target image size
|
| 49 |
+
"""
|
| 50 |
+
self.input_dir = input_dir
|
| 51 |
+
self.target_dir = target_dir
|
| 52 |
+
self.size = size
|
| 53 |
+
|
| 54 |
+
# Load all pairs
|
| 55 |
+
self.pairs = self._load_pairs()
|
| 56 |
+
|
| 57 |
+
logger.info(f"Loaded {len(self.pairs)} training pairs")
|
| 58 |
+
|
| 59 |
+
def _load_pairs(self):
|
| 60 |
+
"""
|
| 61 |
+
Match input and target images by their pair ID
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
List of (input_path, target_path) tuples
|
| 65 |
+
"""
|
| 66 |
+
pairs = []
|
| 67 |
+
|
| 68 |
+
# Get all input files
|
| 69 |
+
input_files = sorted(self.input_dir.glob("*_input.*"))
|
| 70 |
+
|
| 71 |
+
for input_file in input_files:
|
| 72 |
+
# Extract pair ID
|
| 73 |
+
pair_id = input_file.stem.replace("_input", "")
|
| 74 |
+
|
| 75 |
+
# Find matching target file
|
| 76 |
+
target_files = list(self.target_dir.glob(f"{pair_id}_target.*"))
|
| 77 |
+
|
| 78 |
+
if target_files:
|
| 79 |
+
pairs.append((input_file, target_files[0]))
|
| 80 |
+
else:
|
| 81 |
+
logger.warning(f"No target found for input: {input_file}")
|
| 82 |
+
|
| 83 |
+
return pairs
|
| 84 |
+
|
| 85 |
+
def __len__(self):
|
| 86 |
+
return len(self.pairs)
|
| 87 |
+
|
| 88 |
+
def __getitem__(self, idx):
|
| 89 |
+
"""
|
| 90 |
+
Get a training pair
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
Dictionary with 'input' and 'target' tensors
|
| 94 |
+
"""
|
| 95 |
+
input_path, target_path = self.pairs[idx]
|
| 96 |
+
|
| 97 |
+
# Load images
|
| 98 |
+
input_img = Image.open(input_path).convert("RGB")
|
| 99 |
+
target_img = Image.open(target_path).convert("RGB")
|
| 100 |
+
|
| 101 |
+
# Resize to target size
|
| 102 |
+
input_img = input_img.resize((self.size, self.size), Image.LANCZOS)
|
| 103 |
+
target_img = target_img.resize((self.size, self.size), Image.LANCZOS)
|
| 104 |
+
|
| 105 |
+
# Convert to tensors and normalize to [-1, 1]
|
| 106 |
+
input_tensor = torch.from_numpy(
|
| 107 |
+
(torch.tensor(input_img).float() / 127.5 - 1.0).numpy()
|
| 108 |
+
).permute(2, 0, 1)
|
| 109 |
+
|
| 110 |
+
target_tensor = torch.from_numpy(
|
| 111 |
+
(torch.tensor(target_img).float() / 127.5 - 1.0).numpy()
|
| 112 |
+
).permute(2, 0, 1)
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
"input": input_tensor,
|
| 116 |
+
"target": target_tensor,
|
| 117 |
+
"input_path": str(input_path),
|
| 118 |
+
"target_path": str(target_path)
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def prepare_lora_config(rank: int = 8) -> LoraConfig:
|
| 123 |
+
"""
|
| 124 |
+
Create LoRA configuration
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
rank: LoRA rank (dimensionality of adaptation matrices)
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
LoraConfig object
|
| 131 |
+
"""
|
| 132 |
+
return LoraConfig(
|
| 133 |
+
r=rank,
|
| 134 |
+
lora_alpha=rank, # Often set equal to rank
|
| 135 |
+
target_modules=[
|
| 136 |
+
"to_q", "to_k", "to_v", "to_out.0", # Attention layers
|
| 137 |
+
"proj_in", "proj_out", # Projections
|
| 138 |
+
"ff.net.0.proj", "ff.net.2" # Feed-forward layers
|
| 139 |
+
],
|
| 140 |
+
lora_dropout=0.1,
|
| 141 |
+
bias="none",
|
| 142 |
+
task_type="CAUSAL_LM"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def train_lora_model(
|
| 147 |
+
train_steps: int = settings.TRAIN_STEPS,
|
| 148 |
+
learning_rate: float = settings.LEARNING_RATE,
|
| 149 |
+
lora_rank: int = settings.LORA_RANK,
|
| 150 |
+
batch_size: int = settings.BATCH_SIZE,
|
| 151 |
+
lock_file: Optional[Path] = None
|
| 152 |
+
):
|
| 153 |
+
"""
|
| 154 |
+
Main training function for LoRA model
|
| 155 |
+
|
| 156 |
+
This function orchestrates the complete training process:
|
| 157 |
+
1. Initialize accelerator and models
|
| 158 |
+
2. Prepare dataset and dataloader
|
| 159 |
+
3. Training loop with gradient accumulation
|
| 160 |
+
4. Save final model
|
| 161 |
+
|
| 162 |
+
Args:
|
| 163 |
+
train_steps: Total number of training steps
|
| 164 |
+
learning_rate: Learning rate for optimizer
|
| 165 |
+
lora_rank: Rank for LoRA adaptation
|
| 166 |
+
batch_size: Batch size for training
|
| 167 |
+
lock_file: Optional lock file to remove after training
|
| 168 |
+
"""
|
| 169 |
+
try:
|
| 170 |
+
logger.info("=" * 60)
|
| 171 |
+
logger.info("Starting LoRA Training")
|
| 172 |
+
logger.info("=" * 60)
|
| 173 |
+
logger.info(f"Configuration:")
|
| 174 |
+
logger.info(f" - Train Steps: {train_steps}")
|
| 175 |
+
logger.info(f" - Learning Rate: {learning_rate}")
|
| 176 |
+
logger.info(f" - LoRA Rank: {lora_rank}")
|
| 177 |
+
logger.info(f" - Batch Size: {batch_size}")
|
| 178 |
+
logger.info(f" - Base Model: {settings.BASE_MODEL}")
|
| 179 |
+
|
| 180 |
+
# Initialize accelerator
|
| 181 |
+
accelerator = Accelerator(
|
| 182 |
+
mixed_precision=settings.MIXED_PRECISION,
|
| 183 |
+
gradient_accumulation_steps=settings.GRADIENT_ACCUMULATION_STEPS,
|
| 184 |
+
log_with="tensorboard",
|
| 185 |
+
project_dir=str(settings.LOGS_DIR)
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# Set random seed for reproducibility
|
| 189 |
+
set_seed(42)
|
| 190 |
+
|
| 191 |
+
# Load dataset
|
| 192 |
+
logger.info("Loading training dataset...")
|
| 193 |
+
dataset = ArchitectureDataset(
|
| 194 |
+
input_dir=settings.INPUT_DIR,
|
| 195 |
+
target_dir=settings.TARGET_DIR,
|
| 196 |
+
size=settings.TARGET_RESOLUTION
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
if len(dataset) == 0:
|
| 200 |
+
raise ValueError("No training data found!")
|
| 201 |
+
|
| 202 |
+
dataloader = DataLoader(
|
| 203 |
+
dataset,
|
| 204 |
+
batch_size=batch_size,
|
| 205 |
+
shuffle=True,
|
| 206 |
+
num_workers=0, # Set to 0 for Windows compatibility
|
| 207 |
+
pin_memory=True
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
# Load base model components
|
| 211 |
+
logger.info("Loading Stable Diffusion XL components...")
|
| 212 |
+
|
| 213 |
+
# Load UNet (the main model we'll adapt)
|
| 214 |
+
unet = UNet2DConditionModel.from_pretrained(
|
| 215 |
+
settings.BASE_MODEL,
|
| 216 |
+
subfolder="unet",
|
| 217 |
+
torch_dtype=torch.float16 if accelerator.device.type == "cuda" else torch.float32
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Load VAE for encoding images
|
| 221 |
+
vae = AutoencoderKL.from_pretrained(
|
| 222 |
+
settings.BASE_MODEL,
|
| 223 |
+
subfolder="vae",
|
| 224 |
+
torch_dtype=torch.float16 if accelerator.device.type == "cuda" else torch.float32
|
| 225 |
+
)
|
| 226 |
+
vae.requires_grad_(False) # Freeze VAE
|
| 227 |
+
|
| 228 |
+
# Load text encoder
|
| 229 |
+
text_encoder = CLIPTextModel.from_pretrained(
|
| 230 |
+
settings.BASE_MODEL,
|
| 231 |
+
subfolder="text_encoder"
|
| 232 |
+
)
|
| 233 |
+
text_encoder.requires_grad_(False) # Freeze text encoder
|
| 234 |
+
|
| 235 |
+
tokenizer = CLIPTokenizer.from_pretrained(
|
| 236 |
+
settings.BASE_MODEL,
|
| 237 |
+
subfolder="tokenizer"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
# Apply LoRA to UNet
|
| 241 |
+
logger.info(f"Applying LoRA with rank {lora_rank}...")
|
| 242 |
+
lora_config = prepare_lora_config(rank=lora_rank)
|
| 243 |
+
unet = get_peft_model(unet, lora_config)
|
| 244 |
+
unet.print_trainable_parameters()
|
| 245 |
+
|
| 246 |
+
# Setup optimizer
|
| 247 |
+
optimizer = torch.optim.AdamW(
|
| 248 |
+
unet.parameters(),
|
| 249 |
+
lr=learning_rate,
|
| 250 |
+
betas=(0.9, 0.999),
|
| 251 |
+
weight_decay=1e-2,
|
| 252 |
+
eps=1e-8
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
# Prepare with accelerator
|
| 256 |
+
unet, optimizer, dataloader = accelerator.prepare(
|
| 257 |
+
unet, optimizer, dataloader
|
| 258 |
+
)
|
| 259 |
+
vae = vae.to(accelerator.device)
|
| 260 |
+
text_encoder = text_encoder.to(accelerator.device)
|
| 261 |
+
|
| 262 |
+
# Training loop
|
| 263 |
+
logger.info("Starting training loop...")
|
| 264 |
+
global_step = 0
|
| 265 |
+
progress_bar = range(train_steps)
|
| 266 |
+
|
| 267 |
+
# Encode prompt for all samples
|
| 268 |
+
prompt = settings.DEFAULT_PROMPT
|
| 269 |
+
text_inputs = tokenizer(
|
| 270 |
+
prompt,
|
| 271 |
+
padding="max_length",
|
| 272 |
+
max_length=tokenizer.model_max_length,
|
| 273 |
+
truncation=True,
|
| 274 |
+
return_tensors="pt"
|
| 275 |
+
)
|
| 276 |
+
text_embeddings = text_encoder(text_inputs.input_ids.to(accelerator.device))[0]
|
| 277 |
+
|
| 278 |
+
unet.train()
|
| 279 |
+
|
| 280 |
+
for epoch in range(100): # Large number, will break when steps reached
|
| 281 |
+
for batch in dataloader:
|
| 282 |
+
with accelerator.accumulate(unet):
|
| 283 |
+
# Encode images to latent space
|
| 284 |
+
with torch.no_grad():
|
| 285 |
+
latents_input = vae.encode(batch["input"].to(accelerator.device)).latent_dist.sample()
|
| 286 |
+
latents_target = vae.encode(batch["target"].to(accelerator.device)).latent_dist.sample()
|
| 287 |
+
|
| 288 |
+
# Scale latents
|
| 289 |
+
latents_input = latents_input * vae.config.scaling_factor
|
| 290 |
+
latents_target = latents_target * vae.config.scaling_factor
|
| 291 |
+
|
| 292 |
+
# Sample random timestep
|
| 293 |
+
timesteps = torch.randint(
|
| 294 |
+
0, 1000, (latents_input.shape[0],),
|
| 295 |
+
device=accelerator.device
|
| 296 |
+
).long()
|
| 297 |
+
|
| 298 |
+
# Add noise to target latents
|
| 299 |
+
noise = torch.randn_like(latents_target)
|
| 300 |
+
noisy_latents = latents_target # Simplified for img2img
|
| 301 |
+
|
| 302 |
+
# Predict noise
|
| 303 |
+
model_pred = unet(
|
| 304 |
+
noisy_latents,
|
| 305 |
+
timesteps,
|
| 306 |
+
text_embeddings.repeat(latents_input.shape[0], 1, 1)
|
| 307 |
+
).sample
|
| 308 |
+
|
| 309 |
+
# Calculate loss (MSE between prediction and target)
|
| 310 |
+
loss = F.mse_loss(model_pred.float(), latents_target.float(), reduction="mean")
|
| 311 |
+
|
| 312 |
+
# Backpropagation
|
| 313 |
+
accelerator.backward(loss)
|
| 314 |
+
|
| 315 |
+
if accelerator.sync_gradients:
|
| 316 |
+
accelerator.clip_grad_norm_(unet.parameters(), settings.MAX_GRAD_NORM)
|
| 317 |
+
|
| 318 |
+
optimizer.step()
|
| 319 |
+
optimizer.zero_grad()
|
| 320 |
+
|
| 321 |
+
if accelerator.sync_gradients:
|
| 322 |
+
global_step += 1
|
| 323 |
+
|
| 324 |
+
if global_step % 50 == 0:
|
| 325 |
+
logger.info(f"Step {global_step}/{train_steps} - Loss: {loss.item():.4f}")
|
| 326 |
+
|
| 327 |
+
# Save checkpoint
|
| 328 |
+
if global_step % settings.SAVE_STEPS == 0:
|
| 329 |
+
logger.info(f"Saving checkpoint at step {global_step}")
|
| 330 |
+
save_path = settings.LORA_DIR / f"checkpoint_{global_step}"
|
| 331 |
+
accelerator.unwrap_model(unet).save_pretrained(save_path)
|
| 332 |
+
|
| 333 |
+
# Check if training complete
|
| 334 |
+
if global_step >= train_steps:
|
| 335 |
+
break
|
| 336 |
+
|
| 337 |
+
if global_step >= train_steps:
|
| 338 |
+
break
|
| 339 |
+
|
| 340 |
+
# Save final model
|
| 341 |
+
logger.info("Training completed! Saving final model...")
|
| 342 |
+
final_path = settings.LORA_DIR / settings.LORA_MODEL_NAME.replace(".safetensors", "")
|
| 343 |
+
accelerator.unwrap_model(unet).save_pretrained(final_path)
|
| 344 |
+
|
| 345 |
+
# Also save as safetensors
|
| 346 |
+
from safetensors.torch import save_file
|
| 347 |
+
state_dict = accelerator.unwrap_model(unet).state_dict()
|
| 348 |
+
safetensors_path = settings.LORA_DIR / settings.LORA_MODEL_NAME
|
| 349 |
+
save_file(state_dict, safetensors_path)
|
| 350 |
+
|
| 351 |
+
logger.info(f"Model saved to: {safetensors_path}")
|
| 352 |
+
logger.info("=" * 60)
|
| 353 |
+
logger.info("Training Complete!")
|
| 354 |
+
logger.info("=" * 60)
|
| 355 |
+
|
| 356 |
+
except Exception as e:
|
| 357 |
+
logger.error(f"Training failed: {e}", exc_info=True)
|
| 358 |
+
raise
|
| 359 |
+
|
| 360 |
+
finally:
|
| 361 |
+
# Remove lock file
|
| 362 |
+
if lock_file and lock_file.exists():
|
| 363 |
+
lock_file.unlink()
|
| 364 |
+
logger.info("Training lock removed")
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
if __name__ == "__main__":
|
| 368 |
+
# Test training
|
| 369 |
+
train_lora_model(train_steps=100)
|
backend/services/upscaler.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Upscaler Service
|
| 3 |
+
|
| 4 |
+
Provides image upscaling functionality using Real-ESRGAN or similar models.
|
| 5 |
+
This enhances the resolution of generated images for higher quality output.
|
| 6 |
+
"""
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Optional
|
| 9 |
+
import numpy as np
|
| 10 |
+
from PIL import Image
|
| 11 |
+
import cv2
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Global upscaler instance
|
| 18 |
+
_upscaler = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_upscaler():
|
| 22 |
+
"""
|
| 23 |
+
Get or create the upscaler model instance
|
| 24 |
+
|
| 25 |
+
This uses Real-ESRGAN for high-quality upscaling.
|
| 26 |
+
Falls back to simple interpolation if Real-ESRGAN is not available.
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
Upscaler instance or None
|
| 30 |
+
"""
|
| 31 |
+
global _upscaler
|
| 32 |
+
|
| 33 |
+
if _upscaler is not None:
|
| 34 |
+
return _upscaler
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
from realesrgan import RealESRGANer
|
| 38 |
+
from basicsr.archs.rrdbnet_arch import RRDBNet
|
| 39 |
+
|
| 40 |
+
logger.info("Loading Real-ESRGAN model...")
|
| 41 |
+
|
| 42 |
+
# Initialize the model
|
| 43 |
+
# Using RealESRGAN_x4plus for 4x upscaling
|
| 44 |
+
model = RRDBNet(
|
| 45 |
+
num_in_ch=3,
|
| 46 |
+
num_out_ch=3,
|
| 47 |
+
num_feat=64,
|
| 48 |
+
num_block=23,
|
| 49 |
+
num_grow_ch=32,
|
| 50 |
+
scale=4
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# You can download the model weights from:
|
| 54 |
+
# https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth
|
| 55 |
+
model_path = "models/base/RealESRGAN_x4plus.pth"
|
| 56 |
+
|
| 57 |
+
_upscaler = RealESRGANer(
|
| 58 |
+
scale=4,
|
| 59 |
+
model_path=model_path,
|
| 60 |
+
model=model,
|
| 61 |
+
tile=400, # Tile size for processing large images
|
| 62 |
+
tile_pad=10,
|
| 63 |
+
pre_pad=0,
|
| 64 |
+
half=True # Use FP16 for faster inference
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
logger.info("Real-ESRGAN loaded successfully")
|
| 68 |
+
return _upscaler
|
| 69 |
+
|
| 70 |
+
except ImportError:
|
| 71 |
+
logger.warning("Real-ESRGAN not available. Will use fallback upscaling.")
|
| 72 |
+
return None
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.warning(f"Failed to load Real-ESRGAN: {e}. Using fallback.")
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def upscale_with_realesrgan(image: Image.Image, scale: int = 2) -> Image.Image:
|
| 79 |
+
"""
|
| 80 |
+
Upscale image using Real-ESRGAN
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
image: Input PIL Image
|
| 84 |
+
scale: Upscaling factor
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
Upscaled PIL Image
|
| 88 |
+
"""
|
| 89 |
+
upscaler = get_upscaler()
|
| 90 |
+
|
| 91 |
+
if upscaler is None:
|
| 92 |
+
raise RuntimeError("Real-ESRGAN not available")
|
| 93 |
+
|
| 94 |
+
# Convert PIL to numpy array (BGR for OpenCV)
|
| 95 |
+
img_np = np.array(image)
|
| 96 |
+
img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
|
| 97 |
+
|
| 98 |
+
# Upscale
|
| 99 |
+
output, _ = upscaler.enhance(img_bgr, outscale=scale)
|
| 100 |
+
|
| 101 |
+
# Convert back to PIL (RGB)
|
| 102 |
+
output_rgb = cv2.cvtColor(output, cv2.COLOR_BGR2RGB)
|
| 103 |
+
result = Image.fromarray(output_rgb)
|
| 104 |
+
|
| 105 |
+
return result
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def upscale_with_lanczos(image: Image.Image, scale: int = 2) -> Image.Image:
|
| 109 |
+
"""
|
| 110 |
+
Fallback upscaling using Lanczos interpolation
|
| 111 |
+
|
| 112 |
+
This is a simple but effective upscaling method when Real-ESRGAN
|
| 113 |
+
is not available.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
image: Input PIL Image
|
| 117 |
+
scale: Upscaling factor
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Upscaled PIL Image
|
| 121 |
+
"""
|
| 122 |
+
width, height = image.size
|
| 123 |
+
new_size = (width * scale, height * scale)
|
| 124 |
+
|
| 125 |
+
logger.info(f"Upscaling with Lanczos: {image.size} -> {new_size}")
|
| 126 |
+
|
| 127 |
+
return image.resize(new_size, Image.LANCZOS)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def upscale_image(image: Image.Image, scale: int = 2) -> Image.Image:
|
| 131 |
+
"""
|
| 132 |
+
Upscale an image using the best available method
|
| 133 |
+
|
| 134 |
+
Tries Real-ESRGAN first, falls back to Lanczos if unavailable.
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
image: Input PIL Image
|
| 138 |
+
scale: Upscaling factor (2 or 4 recommended)
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
Upscaled PIL Image
|
| 142 |
+
"""
|
| 143 |
+
try:
|
| 144 |
+
# Try Real-ESRGAN
|
| 145 |
+
logger.info(f"Upscaling image by {scale}x")
|
| 146 |
+
result = upscale_with_realesrgan(image, scale=scale)
|
| 147 |
+
logger.info("Upscaling completed with Real-ESRGAN")
|
| 148 |
+
return result
|
| 149 |
+
|
| 150 |
+
except (RuntimeError, Exception) as e:
|
| 151 |
+
# Fallback to Lanczos
|
| 152 |
+
logger.info(f"Using fallback upscaling method: {e}")
|
| 153 |
+
return upscale_with_lanczos(image, scale=scale)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def adaptive_upscale(
|
| 157 |
+
image: Image.Image,
|
| 158 |
+
target_size: Optional[int] = None,
|
| 159 |
+
max_scale: int = 4
|
| 160 |
+
) -> Image.Image:
|
| 161 |
+
"""
|
| 162 |
+
Upscale image adaptively to reach a target size
|
| 163 |
+
|
| 164 |
+
This function calculates the appropriate scale factor to reach
|
| 165 |
+
the target size without exceeding max_scale.
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
image: Input PIL Image
|
| 169 |
+
target_size: Target resolution for the longest edge
|
| 170 |
+
max_scale: Maximum upscaling factor
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
Upscaled PIL Image
|
| 174 |
+
"""
|
| 175 |
+
if target_size is None:
|
| 176 |
+
target_size = 2048 # Default target
|
| 177 |
+
|
| 178 |
+
current_size = max(image.size)
|
| 179 |
+
|
| 180 |
+
if current_size >= target_size:
|
| 181 |
+
logger.info("Image already at target size or larger")
|
| 182 |
+
return image
|
| 183 |
+
|
| 184 |
+
# Calculate required scale
|
| 185 |
+
required_scale = target_size / current_size
|
| 186 |
+
|
| 187 |
+
# Clamp to max_scale and round to nearest power of 2
|
| 188 |
+
if required_scale <= 1:
|
| 189 |
+
scale = 1
|
| 190 |
+
elif required_scale <= 2:
|
| 191 |
+
scale = 2
|
| 192 |
+
else:
|
| 193 |
+
scale = min(4, max_scale)
|
| 194 |
+
|
| 195 |
+
logger.info(f"Adaptive upscaling: {scale}x (current: {current_size}px, target: {target_size}px)")
|
| 196 |
+
|
| 197 |
+
return upscale_image(image, scale=scale)
|
requirements.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Requirements for Hugging Face Spaces deployment
|
| 2 |
+
|
| 3 |
+
# Core ML libraries
|
| 4 |
+
torch>=2.0.0
|
| 5 |
+
torchvision>=0.15.0
|
| 6 |
+
diffusers>=0.25.0
|
| 7 |
+
transformers>=4.36.0
|
| 8 |
+
accelerate>=0.25.0
|
| 9 |
+
|
| 10 |
+
# Image processing
|
| 11 |
+
Pillow>=10.0.0
|
| 12 |
+
opencv-python-headless>=4.8.0
|
| 13 |
+
numpy>=1.24.0
|
| 14 |
+
|
| 15 |
+
# Upscaling
|
| 16 |
+
basicsr>=1.4.2
|
| 17 |
+
|
| 18 |
+
# Gradio interface
|
| 19 |
+
gradio>=4.44.0
|
| 20 |
+
|
| 21 |
+
# Configuration
|
| 22 |
+
pydantic>=2.5.0
|
| 23 |
+
pydantic-settings>=2.1.0
|
| 24 |
+
|
| 25 |
+
# Utilities
|
| 26 |
+
tqdm>=4.66.0
|
| 27 |
+
safetensors>=0.4.0
|