Aguilar Elizondo commited on
Commit
6bfa765
·
0 Parent(s):

Initial commit: Architecture AI Enhancer v1.0.0

Browse files
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