jgitsolutions commited on
Commit
c0f4292
·
verified ·
1 Parent(s): 15aec7f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +587 -587
app.py CHANGED
@@ -1,587 +1,587 @@
1
- import gradio as gr
2
- import torch
3
- import gc
4
- from PIL import Image
5
- import numpy as np
6
- import logging
7
- import io
8
- import os
9
- import requests
10
- from spandrel import ModelLoader
11
- from abc import ABC, abstractmethod
12
- from typing import Optional, Tuple, Dict
13
- import psutil
14
- import time
15
- import traceback
16
-
17
- # --- Configuration ---
18
- class Config:
19
- """Configuration settings for the application."""
20
- MODEL_DIR = "weights"
21
- REALESRGAN_URL = "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth"
22
- REALESRGAN_FILENAME = "RealESRGAN_x2plus.pth"
23
-
24
- # SOTA Models (2025)
25
- SPAN_URL = "https://huggingface.co/Phips/2xNomosUni_span_multijpg/resolve/main/2xNomosUni_span_multijpg.safetensors"
26
- SPAN_FILENAME = "2xNomosUni_span_multijpg.safetensors"
27
- HATS_URL = "https://huggingface.co/Phips/4xNomos8kSCHAT-S/resolve/main/4xNomos8kSCHAT-S.safetensors"
28
- HATS_FILENAME = "4xNomos8kSCHAT-S.safetensors"
29
-
30
- DEVICE = "cpu" # Force CPU for this demo, can be "cuda" if available
31
-
32
- @staticmethod
33
- def ensure_model_dir():
34
- if not os.path.exists(Config.MODEL_DIR):
35
- os.makedirs(Config.MODEL_DIR)
36
-
37
- # --- Logging Setup ---
38
- class LogCapture(io.StringIO):
39
- """Custom StringIO to capture logs."""
40
- pass
41
-
42
- log_capture_string = LogCapture()
43
- ch = logging.StreamHandler(log_capture_string)
44
- ch.setLevel(logging.INFO)
45
- formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
46
- ch.setFormatter(formatter)
47
-
48
- logger = logging.getLogger("UpscalerApp")
49
- logger.setLevel(logging.INFO)
50
- logger.addHandler(ch)
51
-
52
- def get_logs() -> str:
53
- """Retrieve captured logs."""
54
- return log_capture_string.getvalue()
55
-
56
- # --- System Monitoring ---
57
- def get_system_usage() -> str:
58
- """Returns current CPU and RAM usage."""
59
- cpu_percent = psutil.cpu_percent()
60
- ram_percent = psutil.virtual_memory().percent
61
- ram_used_gb = psutil.virtual_memory().used / (1024 ** 3)
62
- return f"CPU: {cpu_percent}% | RAM: {ram_percent}% ({ram_used_gb:.1f} GB used)"
63
-
64
- # --- Abstract Base Class for Models ---
65
- class UpscalerStrategy(ABC):
66
- """Abstract base class for upscaling strategies."""
67
-
68
- def __init__(self):
69
- self.model = None
70
- self.name = "Unknown"
71
-
72
- @abstractmethod
73
- def load(self) -> None:
74
- """Load the model into memory."""
75
- pass
76
-
77
- @abstractmethod
78
- def upscale(self, image: Image.Image, **kwargs) -> Image.Image:
79
- """Upscale the given image."""
80
- pass
81
-
82
- def unload(self) -> None:
83
- """Unload the model to free memory."""
84
- if self.model is not None:
85
- del self.model
86
- self.model = None
87
- gc.collect()
88
- logger.info(f"Unloaded {self.name}")
89
-
90
- # --- Helper Functions for Optimization ---
91
- def manual_tile_upscale(model, img_tensor, tile_size=256, tile_pad=10, scale=2):
92
- """
93
- Low-level tiling implementation for custom models.
94
- Prevents OOM by processing image in chunks.
95
- """
96
- B, C, H, W = img_tensor.shape
97
-
98
- # Calculate tile dimensions
99
- tile_h = (H + tile_size - 1) // tile_size
100
- tile_w = (W + tile_size - 1) // tile_size
101
-
102
- output = torch.zeros(B, C, H * scale, W * scale,
103
- device=img_tensor.device, dtype=img_tensor.dtype)
104
-
105
- for th in range(tile_h):
106
- for tw in range(tile_w):
107
- # Calculate input tile coordinates with padding
108
- x1 = th * tile_size
109
- y1 = tw * tile_size
110
- x2 = min((th + 1) * tile_size, H)
111
- y2 = min((tw + 1) * tile_size, W)
112
-
113
- # Add halo for context
114
- x1_pad = max(0, x1 - tile_pad)
115
- y1_pad = max(0, y1 - tile_pad)
116
- x2_pad = min(H, x2 + tile_pad)
117
- y2_pad = min(W, y2 + tile_pad)
118
-
119
- # Extract padded tile
120
- tile = img_tensor[:, :, x1_pad:x2_pad, y1_pad:y2_pad]
121
-
122
- # Process tile
123
- with torch.no_grad():
124
- tile_out = model(tile)
125
-
126
- # Calculate output crop region (remove halo)
127
- halo_x1 = (x1 - x1_pad) * scale
128
- halo_y1 = (y1 - y1_pad) * scale
129
- out_x2 = halo_x1 + (x2 - x1) * scale
130
- out_y2 = halo_y1 + (y2 - y1) * scale
131
-
132
- # Place in output
133
- output[:, :, x1*scale:x2*scale, y1*scale:y2*scale] = \
134
- tile_out[:, :, halo_x1:out_x2, halo_y1:out_y2]
135
-
136
- return output
137
-
138
- def select_tile_config(height, width):
139
- """
140
- Dynamically select tile size based on image resolution.
141
- """
142
- megapixels = (height * width) / (1024 ** 2)
143
-
144
- if megapixels < 2: # < 1080p
145
- return {'tile': 512, 'tile_pad': 10}
146
- elif megapixels < 6: # < 4K
147
- return {'tile': 384, 'tile_pad': 15}
148
- elif megapixels < 16: # < 8K
149
- return {'tile': 256, 'tile_pad': 20}
150
- else: # 8K+
151
- return {'tile': 128, 'tile_pad': 25}
152
-
153
- # --- Concrete Implementations ---
154
-
155
- class RealESRGANStrategy(UpscalerStrategy):
156
- def __init__(self):
157
- super().__init__()
158
- self.name = "RealESRGAN x2"
159
- self.compiled = False
160
-
161
- def load(self) -> None:
162
- if self.model is None:
163
- logger.info(f"Loading {self.name}...")
164
- Config.ensure_model_dir()
165
- model_path = os.path.join(Config.MODEL_DIR, Config.REALESRGAN_FILENAME)
166
-
167
- if not os.path.exists(model_path):
168
- logger.info(f"Downloading {Config.REALESRGAN_FILENAME}...")
169
- try:
170
- response = requests.get(Config.REALESRGAN_URL, stream=True)
171
- response.raise_for_status()
172
- with open(model_path, 'wb') as f:
173
- for chunk in response.iter_content(chunk_size=8192):
174
- f.write(chunk)
175
- logger.info("Download complete.")
176
- except Exception as e:
177
- logger.error(f"Failed to download model: {e}")
178
- raise
179
-
180
- try:
181
- self.model = ModelLoader().load_from_file(model_path)
182
- self.model.eval()
183
- self.model.to(Config.DEVICE)
184
-
185
- # Optimization: torch.compile
186
- if not self.compiled:
187
- try:
188
- # 'reduce-overhead' uses CUDA graphs, so only use it on CUDA
189
- if Config.DEVICE == 'cuda':
190
- self.model = torch.compile(self.model, mode='reduce-overhead')
191
- logger.info("[INFO] torch.compile enabled (reduce-overhead mode)")
192
- elif os.name == 'nt' and Config.DEVICE == 'cpu':
193
- # Windows requires MSVC for Inductor (default cpu backend)
194
- # We skip it to avoid "Compiler: cl is not found" error unless user has it.
195
- logger.info("[INFO] Skipping torch.compile on Windows CPU to avoid MSVC requirement.")
196
- elif (psutil.cpu_count(logical=False) or 0) < 4 and Config.DEVICE == 'cpu':
197
- # Skip compilation on weak CPUs (e.g. HF Spaces Free Tier) to avoid long startup times
198
- logger.info("[INFO] Skipping torch.compile on low-core CPU to prevent timeout.")
199
- else:
200
- # On Linux/Mac CPU, use default mode or skip if problematic. Default is usually safe.
201
- self.model = torch.compile(self.model)
202
- logger.info("[SUCCESS] torch.compile enabled (default mode)")
203
-
204
- self.compiled = True
205
- except Exception as e:
206
- logger.warning(f"[WARNING] torch.compile not available or failed: {e}")
207
- self.compiled = True # Mark as tried
208
-
209
- logger.info(f"{self.name} loaded successfully.")
210
- except Exception as e:
211
- logger.error(f"Failed to load model architecture: {e}")
212
- raise
213
-
214
- def upscale(self, image: Image.Image, **kwargs) -> Image.Image:
215
- if self.model is None:
216
- self.load()
217
-
218
- logger.info(f"Starting inference with {self.name}...")
219
- start_time = time.time()
220
-
221
- img_np = np.array(image).astype(np.float32) / 255.0
222
- img_tensor = torch.from_numpy(img_np).permute(2, 0, 1).unsqueeze(0).to(Config.DEVICE)
223
-
224
- # Optimization: Dynamic Tiling
225
- h, w = img_np.shape[:2]
226
- tile_config = select_tile_config(h, w)
227
- logger.info(f"Using tile config: {tile_config}")
228
-
229
- # Optimization: Mixed Precision (AMP)
230
- # Use bfloat16 for CPU if supported, else float32 (autocast handles this mostly)
231
- # For CUDA, float16 is standard.
232
- dtype = torch.float16 if Config.DEVICE == 'cuda' else torch.bfloat16
233
-
234
- try:
235
- # Explicitly disable autocast on CPU for RealESRGAN to avoid "PythonFallbackKernel" errors
236
- # This seems to be a regression in recent PyTorch versions on CPU with some ops
237
- context = torch.autocast(device_type=Config.DEVICE, dtype=dtype) if Config.DEVICE != 'cpu' else torch.no_grad()
238
-
239
- with context:
240
- if tile_config['tile'] > 0:
241
- output_tensor = manual_tile_upscale(
242
- self.model,
243
- img_tensor,
244
- tile_size=tile_config['tile'],
245
- tile_pad=tile_config['tile_pad'],
246
- scale=2
247
- )
248
- else:
249
- output_tensor = self.model(img_tensor) # type: ignore
250
- except Exception as e:
251
- logger.warning(f"AMP/Tiling failed, falling back to standard FP32: {e}")
252
- # Fallback to standard execution
253
- output_tensor = self.model(img_tensor) # type: ignore
254
-
255
- output_np = output_tensor.squeeze(0).permute(1, 2, 0).clamp(0, 1).float().cpu().numpy()
256
- output_np = (output_np * 255.0).round().astype(np.uint8)
257
-
258
- elapsed = time.time() - start_time
259
- logger.info(f"Inference finished in {elapsed:.2f}s")
260
-
261
- # Benchmark info (from doc)
262
- output_megapixels = (output_np.shape[0] * output_np.shape[1]) / (1024 ** 2)
263
- throughput = output_megapixels / elapsed
264
- logger.info(f"Speed: {throughput:.2f} MP/s")
265
-
266
- return Image.fromarray(output_np)
267
-
268
- class SpanStrategy(UpscalerStrategy):
269
- def __init__(self):
270
- super().__init__()
271
- self.name = "SPAN (NomosUni) x2"
272
- self.compiled = False
273
-
274
- def load(self) -> None:
275
- if self.model is None:
276
- logger.info(f"Loading {self.name}...")
277
- Config.ensure_model_dir()
278
- model_path = os.path.join(Config.MODEL_DIR, Config.SPAN_FILENAME)
279
-
280
- if not os.path.exists(model_path):
281
- logger.info(f"Downloading {Config.SPAN_FILENAME}...")
282
- try:
283
- response = requests.get(Config.SPAN_URL, stream=True)
284
- response.raise_for_status()
285
- with open(model_path, 'wb') as f:
286
- for chunk in response.iter_content(chunk_size=8192):
287
- f.write(chunk)
288
- logger.info("Download complete.")
289
- except Exception as e:
290
- logger.error(f"Failed to download model: {e}")
291
- raise
292
-
293
- try:
294
- self.model = ModelLoader().load_from_file(model_path)
295
- self.model.eval()
296
- self.model.to(Config.DEVICE)
297
-
298
- # Optimization: torch.compile
299
- if not self.compiled:
300
- try:
301
- if Config.DEVICE == 'cuda':
302
- self.model = torch.compile(self.model, mode='reduce-overhead')
303
- logger.info("[INFO] torch.compile enabled (reduce-overhead mode)")
304
- elif os.name == 'nt' and Config.DEVICE == 'cpu':
305
- logger.info("[INFO] Skipping torch.compile on Windows CPU.")
306
- elif (psutil.cpu_count(logical=False) or 0) < 4 and Config.DEVICE == 'cpu':
307
- logger.info("[INFO] Skipping torch.compile on low-core CPU.")
308
- else:
309
- # SPAN architecture uses .data.clone() in forward pass which breaks torch.compile/inductor
310
- logger.info("[INFO] Skipping torch.compile for SPAN (incompatible architecture).")
311
- # self.model = torch.compile(self.model)
312
- self.compiled = True
313
- except Exception:
314
- self.compiled = True
315
-
316
- logger.info(f"{self.name} loaded successfully.")
317
- except Exception as e:
318
- logger.error(f"Failed to load model architecture: {e}")
319
- raise
320
-
321
- def upscale(self, image: Image.Image, **kwargs) -> Image.Image:
322
- if self.model is None:
323
- self.load()
324
-
325
- logger.info(f"Starting inference with {self.name}...")
326
- start_time = time.time()
327
-
328
- img_np = np.array(image).astype(np.float32) / 255.0
329
- img_tensor = torch.from_numpy(img_np).permute(2, 0, 1).unsqueeze(0).to(Config.DEVICE)
330
-
331
- # SPAN is very efficient, but we still use tiling for safety on huge images
332
- h, w = img_np.shape[:2]
333
- tile_config = select_tile_config(h, w)
334
-
335
- # Disable AMP for SPAN on CPU to avoid "UntypedStorage" weakref errors in inductor
336
- # SPAN architecture seems sensitive to autocast + compile on CPU
337
- dtype = torch.float32 if Config.DEVICE == 'cpu' else torch.float16
338
-
339
- try:
340
- # Only use autocast if not CPU or if explicitly desired
341
- context = torch.autocast(device_type=Config.DEVICE, dtype=dtype) if Config.DEVICE != 'cpu' else torch.no_grad()
342
-
343
- with context:
344
- if tile_config['tile'] > 0:
345
- output_tensor = manual_tile_upscale(
346
- self.model,
347
- img_tensor,
348
- tile_size=tile_config['tile'],
349
- tile_pad=tile_config['tile_pad'],
350
- scale=2
351
- )
352
- else:
353
- output_tensor = self.model(img_tensor) # type: ignore
354
- except Exception as e:
355
- logger.warning(f"AMP/Tiling failed, falling back: {e}")
356
- output_tensor = self.model(img_tensor) # type: ignore
357
-
358
- output_np = output_tensor.squeeze(0).permute(1, 2, 0).clamp(0, 1).float().cpu().numpy()
359
- output_np = (output_np * 255.0).round().astype(np.uint8)
360
-
361
- elapsed = time.time() - start_time
362
- logger.info(f"Inference finished in {elapsed:.2f}s")
363
- return Image.fromarray(output_np)
364
-
365
- class HatsStrategy(UpscalerStrategy):
366
- def __init__(self):
367
- super().__init__()
368
- self.name = "HAT-S x4"
369
- self.compiled = False
370
-
371
- def load(self) -> None:
372
- if self.model is None:
373
- logger.info(f"Loading {self.name}...")
374
- Config.ensure_model_dir()
375
- model_path = os.path.join(Config.MODEL_DIR, Config.HATS_FILENAME)
376
-
377
- if not os.path.exists(model_path):
378
- logger.info(f"Downloading {Config.HATS_FILENAME}...")
379
- try:
380
- response = requests.get(Config.HATS_URL, stream=True)
381
- response.raise_for_status()
382
- with open(model_path, 'wb') as f:
383
- for chunk in response.iter_content(chunk_size=8192):
384
- f.write(chunk)
385
- logger.info("Download complete.")
386
- except Exception as e:
387
- logger.error(f"Failed to download model: {e}")
388
- raise
389
-
390
- try:
391
- self.model = ModelLoader().load_from_file(model_path)
392
- self.model.eval()
393
- self.model.to(Config.DEVICE)
394
-
395
- if not self.compiled:
396
- try:
397
- if Config.DEVICE == 'cuda':
398
- self.model = torch.compile(self.model, mode='reduce-overhead')
399
- elif os.name == 'nt' and Config.DEVICE == 'cpu':
400
- pass
401
- elif (psutil.cpu_count(logical=False) or 0) < 4 and Config.DEVICE == 'cpu':
402
- pass
403
- else:
404
- # HAT architecture also triggers "UntypedStorage" weakref errors with inductor on CPU
405
- logger.info("[INFO] Skipping torch.compile for HAT-S (incompatible architecture).")
406
- # self.model = torch.compile(self.model)
407
- self.compiled = True
408
- except Exception:
409
- self.compiled = True
410
-
411
- logger.info(f"{self.name} loaded successfully.")
412
- except Exception as e:
413
- logger.error(f"Failed to load model architecture: {e}")
414
- raise
415
-
416
- def upscale(self, image: Image.Image, **kwargs) -> Image.Image:
417
- if self.model is None:
418
- self.load()
419
-
420
- logger.info(f"Starting inference with {self.name}...")
421
- start_time = time.time()
422
-
423
- img_np = np.array(image).astype(np.float32) / 255.0
424
- img_tensor = torch.from_numpy(img_np).permute(2, 0, 1).unsqueeze(0).to(Config.DEVICE)
425
-
426
- h, w = img_np.shape[:2]
427
- tile_config = select_tile_config(h, w)
428
-
429
- dtype = torch.float16 if Config.DEVICE == 'cuda' else torch.float32
430
-
431
- try:
432
- context = torch.autocast(device_type=Config.DEVICE, dtype=dtype) if Config.DEVICE != 'cpu' else torch.no_grad()
433
- with context:
434
- if tile_config['tile'] > 0:
435
- output_tensor = manual_tile_upscale(
436
- self.model,
437
- img_tensor,
438
- tile_size=tile_config['tile'],
439
- tile_pad=tile_config['tile_pad'],
440
- scale=4 # HAT-S is x4
441
- )
442
- else:
443
- output_tensor = self.model(img_tensor) # type: ignore
444
- except Exception as e:
445
- logger.warning(f"AMP/Tiling failed, falling back: {e}")
446
- output_tensor = self.model(img_tensor) # type: ignore
447
-
448
- output_np = output_tensor.squeeze(0).permute(1, 2, 0).clamp(0, 1).float().cpu().numpy()
449
- output_np = (output_np * 255.0).round().astype(np.uint8)
450
-
451
- elapsed = time.time() - start_time
452
- logger.info(f"Inference finished in {elapsed:.2f}s")
453
- return Image.fromarray(output_np)
454
-
455
- # --- Model Manager (Singleton-ish) ---
456
- class UpscalerManager:
457
- """Manages model lifecycle and selection."""
458
- def __init__(self):
459
- self.strategies: Dict[str, UpscalerStrategy] = {
460
- "SPAN (NomosUni) x2": SpanStrategy(),
461
- "RealESRGAN x2": RealESRGANStrategy(),
462
- "HAT-S x4": HatsStrategy()
463
- }
464
- self.current_model_name: Optional[str] = None
465
-
466
- def get_strategy(self, name: str) -> UpscalerStrategy:
467
- if name not in self.strategies:
468
- raise ValueError(f"Model {name} not found.")
469
-
470
- # Memory Optimization for Free Tier (16GB RAM limit):
471
- # Ensure only one model is loaded at a time.
472
- if self.current_model_name != name:
473
- if self.current_model_name is not None:
474
- logger.info(f"Switching models: Unloading {self.current_model_name}...")
475
- self.strategies[self.current_model_name].unload()
476
- self.current_model_name = name
477
-
478
- return self.strategies[name]
479
-
480
- def unload_all(self):
481
- """Unload all models to free memory."""
482
- for strategy in self.strategies.values():
483
- strategy.unload()
484
- gc.collect()
485
- logger.info("All models unloaded.")
486
-
487
- manager = UpscalerManager()
488
-
489
- # --- Gradio Interface Logic ---
490
- def process_image(input_img: Image.Image, model_name: str, output_format: str) -> Tuple[Optional[str], str, str]:
491
- if input_img is None:
492
- return None, get_logs(), get_system_usage()
493
-
494
- try:
495
- strategy = manager.get_strategy(model_name)
496
-
497
- output_img = strategy.upscale(input_img)
498
-
499
- # Save to temp file with correct extension
500
- output_path = f"output.{output_format.lower()}"
501
-
502
- # Convert to RGB if saving as JPEG (doesn't support alpha)
503
- if output_format.lower() in ['jpeg', 'jpg'] and output_img.mode == 'RGBA':
504
- output_img = output_img.convert('RGB')
505
-
506
- output_img.save(output_path, format=output_format)
507
-
508
- # Explicit GC after heavy operations
509
- gc.collect()
510
-
511
- return output_path, get_logs(), get_system_usage()
512
- except Exception as e:
513
- error_msg = f"Critical Error: {str(e)}\n{traceback.format_exc()}"
514
- logger.error(error_msg)
515
- return None, get_logs() + "\n\n" + error_msg, get_system_usage()
516
-
517
- def unload_models():
518
- manager.unload_all()
519
- return get_logs(), get_system_usage()
520
-
521
- # --- UI Construction ---
522
- desc = """
523
- # Universal Upscaler Pro (CPU Optimized)
524
-
525
- This application provides state-of-the-art (SOTA) image upscaling running entirely on CPU, optimized for free-tier cloud environments.
526
-
527
- ### Available Models
528
-
529
- | Model | Scale | Best For | License |
530
- | :--- | :--- | :--- | :--- |
531
- | **SPAN (NomosUni)** | x2 | **Speed & General Use**. Extremely fast, parameter-free attention network. | Apache 2.0 |
532
- | **RealESRGAN** | x2 | **Robustness**. Excellent at removing JPEG artifacts and noise. | BSD 3-Clause |
533
- | **HAT-S** | x4 | **Texture Detail**. Hybrid Attention Transformer for high-fidelity restoration. | MIT |
534
-
535
- ### Attributions & Credits
536
-
537
- * **Real-ESRGAN**: [Wang et al., 2021](https://github.com/xinntao/Real-ESRGAN). *Real-ESRGAN: Training Real-World Blind Super-Resolution with Pure Synthetic Data*.
538
- * **SPAN**: [Zhang et al., 2023](https://github.com/hongyuanyu/SPAN). *Swift Parameter-free Attention Network for Efficient Super-Resolution*.
539
- * **HAT**: [Chen et al., 2023](https://github.com/XPixelGroup/HAT). *Activating Activation Functions for Image Restoration*.
540
- * **NomosUni**: Custom SPAN training by [Phhofm](https://github.com/Phhofm).
541
- """
542
-
543
- with gr.Blocks(title="Universal Upscaler Pro") as iface:
544
- gr.Markdown(desc)
545
-
546
- with gr.Row():
547
- with gr.Column(scale=1, min_width=300):
548
- input_image = gr.Image(type="pil", label="Input Image", height=400)
549
-
550
- with gr.Row():
551
- model_selector = gr.Dropdown(
552
- choices=list(manager.strategies.keys()),
553
- value="SPAN (NomosUni) x2",
554
- label="Model Architecture",
555
- scale=2
556
- )
557
- output_format = gr.Dropdown(
558
- choices=["PNG", "JPEG", "WEBP"],
559
- value="PNG",
560
- label="Output Format",
561
- scale=1
562
- )
563
-
564
- submit_btn = gr.Button("Upscale Image", variant="primary", size="lg")
565
-
566
- with gr.Accordion("Advanced Settings", open=False):
567
- unload_btn = gr.Button("Unload All Models (Free RAM)", variant="secondary")
568
- system_info = gr.Label(value=get_system_usage(), label="System Status")
569
-
570
- with gr.Column(scale=1, min_width=300):
571
- output_image = gr.Image(type="filepath", label="Upscaled Result", height=400)
572
- logs_output = gr.TextArea(label="Execution Logs", interactive=False, lines=8)
573
-
574
- # Event Wiring
575
- submit_btn.click(
576
- fn=process_image,
577
- inputs=[input_image, model_selector, output_format],
578
- outputs=[output_image, logs_output, system_info]
579
- )
580
-
581
- unload_btn.click(
582
- fn=unload_models,
583
- inputs=[],
584
- outputs=[logs_output, system_info]
585
- )
586
-
587
- iface.launch()
 
1
+ import gradio as gr
2
+ import torch
3
+ import gc
4
+ from PIL import Image
5
+ import numpy as np
6
+ import logging
7
+ import io
8
+ import os
9
+ import requests
10
+ from spandrel import ModelLoader
11
+ from abc import ABC, abstractmethod
12
+ from typing import Optional, Tuple, Dict
13
+ import psutil
14
+ import time
15
+ import traceback
16
+
17
+ # --- Configuration ---
18
+ class Config:
19
+ """Configuration settings for the application."""
20
+ MODEL_DIR = "."
21
+ REALESRGAN_URL = "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth"
22
+ REALESRGAN_FILENAME = "RealESRGAN_x2plus.pth"
23
+
24
+ # SOTA Models (2025)
25
+ SPAN_URL = "https://huggingface.co/Phips/2xNomosUni_span_multijpg/resolve/main/2xNomosUni_span_multijpg.safetensors"
26
+ SPAN_FILENAME = "2xNomosUni_span_multijpg.safetensors"
27
+ HATS_URL = "https://huggingface.co/Phips/4xNomos8kSCHAT-S/resolve/main/4xNomos8kSCHAT-S.safetensors"
28
+ HATS_FILENAME = "4xNomos8kSCHAT-S.safetensors"
29
+
30
+ DEVICE = "cpu" # Force CPU for this demo, can be "cuda" if available
31
+
32
+ @staticmethod
33
+ def ensure_model_dir():
34
+ if not os.path.exists(Config.MODEL_DIR):
35
+ os.makedirs(Config.MODEL_DIR)
36
+
37
+ # --- Logging Setup ---
38
+ class LogCapture(io.StringIO):
39
+ """Custom StringIO to capture logs."""
40
+ pass
41
+
42
+ log_capture_string = LogCapture()
43
+ ch = logging.StreamHandler(log_capture_string)
44
+ ch.setLevel(logging.INFO)
45
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
46
+ ch.setFormatter(formatter)
47
+
48
+ logger = logging.getLogger("UpscalerApp")
49
+ logger.setLevel(logging.INFO)
50
+ logger.addHandler(ch)
51
+
52
+ def get_logs() -> str:
53
+ """Retrieve captured logs."""
54
+ return log_capture_string.getvalue()
55
+
56
+ # --- System Monitoring ---
57
+ def get_system_usage() -> str:
58
+ """Returns current CPU and RAM usage."""
59
+ cpu_percent = psutil.cpu_percent()
60
+ ram_percent = psutil.virtual_memory().percent
61
+ ram_used_gb = psutil.virtual_memory().used / (1024 ** 3)
62
+ return f"CPU: {cpu_percent}% | RAM: {ram_percent}% ({ram_used_gb:.1f} GB used)"
63
+
64
+ # --- Abstract Base Class for Models ---
65
+ class UpscalerStrategy(ABC):
66
+ """Abstract base class for upscaling strategies."""
67
+
68
+ def __init__(self):
69
+ self.model = None
70
+ self.name = "Unknown"
71
+
72
+ @abstractmethod
73
+ def load(self) -> None:
74
+ """Load the model into memory."""
75
+ pass
76
+
77
+ @abstractmethod
78
+ def upscale(self, image: Image.Image, **kwargs) -> Image.Image:
79
+ """Upscale the given image."""
80
+ pass
81
+
82
+ def unload(self) -> None:
83
+ """Unload the model to free memory."""
84
+ if self.model is not None:
85
+ del self.model
86
+ self.model = None
87
+ gc.collect()
88
+ logger.info(f"Unloaded {self.name}")
89
+
90
+ # --- Helper Functions for Optimization ---
91
+ def manual_tile_upscale(model, img_tensor, tile_size=256, tile_pad=10, scale=2):
92
+ """
93
+ Low-level tiling implementation for custom models.
94
+ Prevents OOM by processing image in chunks.
95
+ """
96
+ B, C, H, W = img_tensor.shape
97
+
98
+ # Calculate tile dimensions
99
+ tile_h = (H + tile_size - 1) // tile_size
100
+ tile_w = (W + tile_size - 1) // tile_size
101
+
102
+ output = torch.zeros(B, C, H * scale, W * scale,
103
+ device=img_tensor.device, dtype=img_tensor.dtype)
104
+
105
+ for th in range(tile_h):
106
+ for tw in range(tile_w):
107
+ # Calculate input tile coordinates with padding
108
+ x1 = th * tile_size
109
+ y1 = tw * tile_size
110
+ x2 = min((th + 1) * tile_size, H)
111
+ y2 = min((tw + 1) * tile_size, W)
112
+
113
+ # Add halo for context
114
+ x1_pad = max(0, x1 - tile_pad)
115
+ y1_pad = max(0, y1 - tile_pad)
116
+ x2_pad = min(H, x2 + tile_pad)
117
+ y2_pad = min(W, y2 + tile_pad)
118
+
119
+ # Extract padded tile
120
+ tile = img_tensor[:, :, x1_pad:x2_pad, y1_pad:y2_pad]
121
+
122
+ # Process tile
123
+ with torch.no_grad():
124
+ tile_out = model(tile)
125
+
126
+ # Calculate output crop region (remove halo)
127
+ halo_x1 = (x1 - x1_pad) * scale
128
+ halo_y1 = (y1 - y1_pad) * scale
129
+ out_x2 = halo_x1 + (x2 - x1) * scale
130
+ out_y2 = halo_y1 + (y2 - y1) * scale
131
+
132
+ # Place in output
133
+ output[:, :, x1*scale:x2*scale, y1*scale:y2*scale] = \
134
+ tile_out[:, :, halo_x1:out_x2, halo_y1:out_y2]
135
+
136
+ return output
137
+
138
+ def select_tile_config(height, width):
139
+ """
140
+ Dynamically select tile size based on image resolution.
141
+ """
142
+ megapixels = (height * width) / (1024 ** 2)
143
+
144
+ if megapixels < 2: # < 1080p
145
+ return {'tile': 512, 'tile_pad': 10}
146
+ elif megapixels < 6: # < 4K
147
+ return {'tile': 384, 'tile_pad': 15}
148
+ elif megapixels < 16: # < 8K
149
+ return {'tile': 256, 'tile_pad': 20}
150
+ else: # 8K+
151
+ return {'tile': 128, 'tile_pad': 25}
152
+
153
+ # --- Concrete Implementations ---
154
+
155
+ class RealESRGANStrategy(UpscalerStrategy):
156
+ def __init__(self):
157
+ super().__init__()
158
+ self.name = "RealESRGAN x2"
159
+ self.compiled = False
160
+
161
+ def load(self) -> None:
162
+ if self.model is None:
163
+ logger.info(f"Loading {self.name}...")
164
+ Config.ensure_model_dir()
165
+ model_path = os.path.join(Config.MODEL_DIR, Config.REALESRGAN_FILENAME)
166
+
167
+ if not os.path.exists(model_path):
168
+ logger.info(f"Downloading {Config.REALESRGAN_FILENAME}...")
169
+ try:
170
+ response = requests.get(Config.REALESRGAN_URL, stream=True)
171
+ response.raise_for_status()
172
+ with open(model_path, 'wb') as f:
173
+ for chunk in response.iter_content(chunk_size=8192):
174
+ f.write(chunk)
175
+ logger.info("Download complete.")
176
+ except Exception as e:
177
+ logger.error(f"Failed to download model: {e}")
178
+ raise
179
+
180
+ try:
181
+ self.model = ModelLoader().load_from_file(model_path)
182
+ self.model.eval()
183
+ self.model.to(Config.DEVICE)
184
+
185
+ # Optimization: torch.compile
186
+ if not self.compiled:
187
+ try:
188
+ # 'reduce-overhead' uses CUDA graphs, so only use it on CUDA
189
+ if Config.DEVICE == 'cuda':
190
+ self.model = torch.compile(self.model, mode='reduce-overhead')
191
+ logger.info("[INFO] torch.compile enabled (reduce-overhead mode)")
192
+ elif os.name == 'nt' and Config.DEVICE == 'cpu':
193
+ # Windows requires MSVC for Inductor (default cpu backend)
194
+ # We skip it to avoid "Compiler: cl is not found" error unless user has it.
195
+ logger.info("[INFO] Skipping torch.compile on Windows CPU to avoid MSVC requirement.")
196
+ elif (psutil.cpu_count(logical=False) or 0) < 4 and Config.DEVICE == 'cpu':
197
+ # Skip compilation on weak CPUs (e.g. HF Spaces Free Tier) to avoid long startup times
198
+ logger.info("[INFO] Skipping torch.compile on low-core CPU to prevent timeout.")
199
+ else:
200
+ # On Linux/Mac CPU, use default mode or skip if problematic. Default is usually safe.
201
+ self.model = torch.compile(self.model)
202
+ logger.info("[SUCCESS] torch.compile enabled (default mode)")
203
+
204
+ self.compiled = True
205
+ except Exception as e:
206
+ logger.warning(f"[WARNING] torch.compile not available or failed: {e}")
207
+ self.compiled = True # Mark as tried
208
+
209
+ logger.info(f"{self.name} loaded successfully.")
210
+ except Exception as e:
211
+ logger.error(f"Failed to load model architecture: {e}")
212
+ raise
213
+
214
+ def upscale(self, image: Image.Image, **kwargs) -> Image.Image:
215
+ if self.model is None:
216
+ self.load()
217
+
218
+ logger.info(f"Starting inference with {self.name}...")
219
+ start_time = time.time()
220
+
221
+ img_np = np.array(image).astype(np.float32) / 255.0
222
+ img_tensor = torch.from_numpy(img_np).permute(2, 0, 1).unsqueeze(0).to(Config.DEVICE)
223
+
224
+ # Optimization: Dynamic Tiling
225
+ h, w = img_np.shape[:2]
226
+ tile_config = select_tile_config(h, w)
227
+ logger.info(f"Using tile config: {tile_config}")
228
+
229
+ # Optimization: Mixed Precision (AMP)
230
+ # Use bfloat16 for CPU if supported, else float32 (autocast handles this mostly)
231
+ # For CUDA, float16 is standard.
232
+ dtype = torch.float16 if Config.DEVICE == 'cuda' else torch.bfloat16
233
+
234
+ try:
235
+ # Explicitly disable autocast on CPU for RealESRGAN to avoid "PythonFallbackKernel" errors
236
+ # This seems to be a regression in recent PyTorch versions on CPU with some ops
237
+ context = torch.autocast(device_type=Config.DEVICE, dtype=dtype) if Config.DEVICE != 'cpu' else torch.no_grad()
238
+
239
+ with context:
240
+ if tile_config['tile'] > 0:
241
+ output_tensor = manual_tile_upscale(
242
+ self.model,
243
+ img_tensor,
244
+ tile_size=tile_config['tile'],
245
+ tile_pad=tile_config['tile_pad'],
246
+ scale=2
247
+ )
248
+ else:
249
+ output_tensor = self.model(img_tensor) # type: ignore
250
+ except Exception as e:
251
+ logger.warning(f"AMP/Tiling failed, falling back to standard FP32: {e}")
252
+ # Fallback to standard execution
253
+ output_tensor = self.model(img_tensor) # type: ignore
254
+
255
+ output_np = output_tensor.squeeze(0).permute(1, 2, 0).clamp(0, 1).float().cpu().numpy()
256
+ output_np = (output_np * 255.0).round().astype(np.uint8)
257
+
258
+ elapsed = time.time() - start_time
259
+ logger.info(f"Inference finished in {elapsed:.2f}s")
260
+
261
+ # Benchmark info (from doc)
262
+ output_megapixels = (output_np.shape[0] * output_np.shape[1]) / (1024 ** 2)
263
+ throughput = output_megapixels / elapsed
264
+ logger.info(f"Speed: {throughput:.2f} MP/s")
265
+
266
+ return Image.fromarray(output_np)
267
+
268
+ class SpanStrategy(UpscalerStrategy):
269
+ def __init__(self):
270
+ super().__init__()
271
+ self.name = "SPAN (NomosUni) x2"
272
+ self.compiled = False
273
+
274
+ def load(self) -> None:
275
+ if self.model is None:
276
+ logger.info(f"Loading {self.name}...")
277
+ Config.ensure_model_dir()
278
+ model_path = os.path.join(Config.MODEL_DIR, Config.SPAN_FILENAME)
279
+
280
+ if not os.path.exists(model_path):
281
+ logger.info(f"Downloading {Config.SPAN_FILENAME}...")
282
+ try:
283
+ response = requests.get(Config.SPAN_URL, stream=True)
284
+ response.raise_for_status()
285
+ with open(model_path, 'wb') as f:
286
+ for chunk in response.iter_content(chunk_size=8192):
287
+ f.write(chunk)
288
+ logger.info("Download complete.")
289
+ except Exception as e:
290
+ logger.error(f"Failed to download model: {e}")
291
+ raise
292
+
293
+ try:
294
+ self.model = ModelLoader().load_from_file(model_path)
295
+ self.model.eval()
296
+ self.model.to(Config.DEVICE)
297
+
298
+ # Optimization: torch.compile
299
+ if not self.compiled:
300
+ try:
301
+ if Config.DEVICE == 'cuda':
302
+ self.model = torch.compile(self.model, mode='reduce-overhead')
303
+ logger.info("[INFO] torch.compile enabled (reduce-overhead mode)")
304
+ elif os.name == 'nt' and Config.DEVICE == 'cpu':
305
+ logger.info("[INFO] Skipping torch.compile on Windows CPU.")
306
+ elif (psutil.cpu_count(logical=False) or 0) < 4 and Config.DEVICE == 'cpu':
307
+ logger.info("[INFO] Skipping torch.compile on low-core CPU.")
308
+ else:
309
+ # SPAN architecture uses .data.clone() in forward pass which breaks torch.compile/inductor
310
+ logger.info("[INFO] Skipping torch.compile for SPAN (incompatible architecture).")
311
+ # self.model = torch.compile(self.model)
312
+ self.compiled = True
313
+ except Exception:
314
+ self.compiled = True
315
+
316
+ logger.info(f"{self.name} loaded successfully.")
317
+ except Exception as e:
318
+ logger.error(f"Failed to load model architecture: {e}")
319
+ raise
320
+
321
+ def upscale(self, image: Image.Image, **kwargs) -> Image.Image:
322
+ if self.model is None:
323
+ self.load()
324
+
325
+ logger.info(f"Starting inference with {self.name}...")
326
+ start_time = time.time()
327
+
328
+ img_np = np.array(image).astype(np.float32) / 255.0
329
+ img_tensor = torch.from_numpy(img_np).permute(2, 0, 1).unsqueeze(0).to(Config.DEVICE)
330
+
331
+ # SPAN is very efficient, but we still use tiling for safety on huge images
332
+ h, w = img_np.shape[:2]
333
+ tile_config = select_tile_config(h, w)
334
+
335
+ # Disable AMP for SPAN on CPU to avoid "UntypedStorage" weakref errors in inductor
336
+ # SPAN architecture seems sensitive to autocast + compile on CPU
337
+ dtype = torch.float32 if Config.DEVICE == 'cpu' else torch.float16
338
+
339
+ try:
340
+ # Only use autocast if not CPU or if explicitly desired
341
+ context = torch.autocast(device_type=Config.DEVICE, dtype=dtype) if Config.DEVICE != 'cpu' else torch.no_grad()
342
+
343
+ with context:
344
+ if tile_config['tile'] > 0:
345
+ output_tensor = manual_tile_upscale(
346
+ self.model,
347
+ img_tensor,
348
+ tile_size=tile_config['tile'],
349
+ tile_pad=tile_config['tile_pad'],
350
+ scale=2
351
+ )
352
+ else:
353
+ output_tensor = self.model(img_tensor) # type: ignore
354
+ except Exception as e:
355
+ logger.warning(f"AMP/Tiling failed, falling back: {e}")
356
+ output_tensor = self.model(img_tensor) # type: ignore
357
+
358
+ output_np = output_tensor.squeeze(0).permute(1, 2, 0).clamp(0, 1).float().cpu().numpy()
359
+ output_np = (output_np * 255.0).round().astype(np.uint8)
360
+
361
+ elapsed = time.time() - start_time
362
+ logger.info(f"Inference finished in {elapsed:.2f}s")
363
+ return Image.fromarray(output_np)
364
+
365
+ class HatsStrategy(UpscalerStrategy):
366
+ def __init__(self):
367
+ super().__init__()
368
+ self.name = "HAT-S x4"
369
+ self.compiled = False
370
+
371
+ def load(self) -> None:
372
+ if self.model is None:
373
+ logger.info(f"Loading {self.name}...")
374
+ Config.ensure_model_dir()
375
+ model_path = os.path.join(Config.MODEL_DIR, Config.HATS_FILENAME)
376
+
377
+ if not os.path.exists(model_path):
378
+ logger.info(f"Downloading {Config.HATS_FILENAME}...")
379
+ try:
380
+ response = requests.get(Config.HATS_URL, stream=True)
381
+ response.raise_for_status()
382
+ with open(model_path, 'wb') as f:
383
+ for chunk in response.iter_content(chunk_size=8192):
384
+ f.write(chunk)
385
+ logger.info("Download complete.")
386
+ except Exception as e:
387
+ logger.error(f"Failed to download model: {e}")
388
+ raise
389
+
390
+ try:
391
+ self.model = ModelLoader().load_from_file(model_path)
392
+ self.model.eval()
393
+ self.model.to(Config.DEVICE)
394
+
395
+ if not self.compiled:
396
+ try:
397
+ if Config.DEVICE == 'cuda':
398
+ self.model = torch.compile(self.model, mode='reduce-overhead')
399
+ elif os.name == 'nt' and Config.DEVICE == 'cpu':
400
+ pass
401
+ elif (psutil.cpu_count(logical=False) or 0) < 4 and Config.DEVICE == 'cpu':
402
+ pass
403
+ else:
404
+ # HAT architecture also triggers "UntypedStorage" weakref errors with inductor on CPU
405
+ logger.info("[INFO] Skipping torch.compile for HAT-S (incompatible architecture).")
406
+ # self.model = torch.compile(self.model)
407
+ self.compiled = True
408
+ except Exception:
409
+ self.compiled = True
410
+
411
+ logger.info(f"{self.name} loaded successfully.")
412
+ except Exception as e:
413
+ logger.error(f"Failed to load model architecture: {e}")
414
+ raise
415
+
416
+ def upscale(self, image: Image.Image, **kwargs) -> Image.Image:
417
+ if self.model is None:
418
+ self.load()
419
+
420
+ logger.info(f"Starting inference with {self.name}...")
421
+ start_time = time.time()
422
+
423
+ img_np = np.array(image).astype(np.float32) / 255.0
424
+ img_tensor = torch.from_numpy(img_np).permute(2, 0, 1).unsqueeze(0).to(Config.DEVICE)
425
+
426
+ h, w = img_np.shape[:2]
427
+ tile_config = select_tile_config(h, w)
428
+
429
+ dtype = torch.float16 if Config.DEVICE == 'cuda' else torch.float32
430
+
431
+ try:
432
+ context = torch.autocast(device_type=Config.DEVICE, dtype=dtype) if Config.DEVICE != 'cpu' else torch.no_grad()
433
+ with context:
434
+ if tile_config['tile'] > 0:
435
+ output_tensor = manual_tile_upscale(
436
+ self.model,
437
+ img_tensor,
438
+ tile_size=tile_config['tile'],
439
+ tile_pad=tile_config['tile_pad'],
440
+ scale=4 # HAT-S is x4
441
+ )
442
+ else:
443
+ output_tensor = self.model(img_tensor) # type: ignore
444
+ except Exception as e:
445
+ logger.warning(f"AMP/Tiling failed, falling back: {e}")
446
+ output_tensor = self.model(img_tensor) # type: ignore
447
+
448
+ output_np = output_tensor.squeeze(0).permute(1, 2, 0).clamp(0, 1).float().cpu().numpy()
449
+ output_np = (output_np * 255.0).round().astype(np.uint8)
450
+
451
+ elapsed = time.time() - start_time
452
+ logger.info(f"Inference finished in {elapsed:.2f}s")
453
+ return Image.fromarray(output_np)
454
+
455
+ # --- Model Manager (Singleton-ish) ---
456
+ class UpscalerManager:
457
+ """Manages model lifecycle and selection."""
458
+ def __init__(self):
459
+ self.strategies: Dict[str, UpscalerStrategy] = {
460
+ "SPAN (NomosUni) x2": SpanStrategy(),
461
+ "RealESRGAN x2": RealESRGANStrategy(),
462
+ "HAT-S x4": HatsStrategy()
463
+ }
464
+ self.current_model_name: Optional[str] = None
465
+
466
+ def get_strategy(self, name: str) -> UpscalerStrategy:
467
+ if name not in self.strategies:
468
+ raise ValueError(f"Model {name} not found.")
469
+
470
+ # Memory Optimization for Free Tier (16GB RAM limit):
471
+ # Ensure only one model is loaded at a time.
472
+ if self.current_model_name != name:
473
+ if self.current_model_name is not None:
474
+ logger.info(f"Switching models: Unloading {self.current_model_name}...")
475
+ self.strategies[self.current_model_name].unload()
476
+ self.current_model_name = name
477
+
478
+ return self.strategies[name]
479
+
480
+ def unload_all(self):
481
+ """Unload all models to free memory."""
482
+ for strategy in self.strategies.values():
483
+ strategy.unload()
484
+ gc.collect()
485
+ logger.info("All models unloaded.")
486
+
487
+ manager = UpscalerManager()
488
+
489
+ # --- Gradio Interface Logic ---
490
+ def process_image(input_img: Image.Image, model_name: str, output_format: str) -> Tuple[Optional[str], str, str]:
491
+ if input_img is None:
492
+ return None, get_logs(), get_system_usage()
493
+
494
+ try:
495
+ strategy = manager.get_strategy(model_name)
496
+
497
+ output_img = strategy.upscale(input_img)
498
+
499
+ # Save to temp file with correct extension
500
+ output_path = f"output.{output_format.lower()}"
501
+
502
+ # Convert to RGB if saving as JPEG (doesn't support alpha)
503
+ if output_format.lower() in ['jpeg', 'jpg'] and output_img.mode == 'RGBA':
504
+ output_img = output_img.convert('RGB')
505
+
506
+ output_img.save(output_path, format=output_format)
507
+
508
+ # Explicit GC after heavy operations
509
+ gc.collect()
510
+
511
+ return output_path, get_logs(), get_system_usage()
512
+ except Exception as e:
513
+ error_msg = f"Critical Error: {str(e)}\n{traceback.format_exc()}"
514
+ logger.error(error_msg)
515
+ return None, get_logs() + "\n\n" + error_msg, get_system_usage()
516
+
517
+ def unload_models():
518
+ manager.unload_all()
519
+ return get_logs(), get_system_usage()
520
+
521
+ # --- UI Construction ---
522
+ desc = """
523
+ # Universal Upscaler Pro (CPU Optimized)
524
+
525
+ This application provides state-of-the-art (SOTA) image upscaling running entirely on CPU, optimized for free-tier cloud environments.
526
+
527
+ ### Available Models
528
+
529
+ | Model | Scale | Best For | License |
530
+ | :--- | :--- | :--- | :--- |
531
+ | **SPAN (NomosUni)** | x2 | **Speed & General Use**. Extremely fast, parameter-free attention network. | Apache 2.0 |
532
+ | **RealESRGAN** | x2 | **Robustness**. Excellent at removing JPEG artifacts and noise. | BSD 3-Clause |
533
+ | **HAT-S** | x4 | **Texture Detail**. Hybrid Attention Transformer for high-fidelity restoration. | MIT |
534
+
535
+ ### Attributions & Credits
536
+
537
+ * **Real-ESRGAN**: [Wang et al., 2021](https://github.com/xinntao/Real-ESRGAN). *Real-ESRGAN: Training Real-World Blind Super-Resolution with Pure Synthetic Data*.
538
+ * **SPAN**: [Zhang et al., 2023](https://github.com/hongyuanyu/SPAN). *Swift Parameter-free Attention Network for Efficient Super-Resolution*.
539
+ * **HAT**: [Chen et al., 2023](https://github.com/XPixelGroup/HAT). *Activating Activation Functions for Image Restoration*.
540
+ * **NomosUni**: Custom SPAN training by [Phhofm](https://github.com/Phhofm).
541
+ """
542
+
543
+ with gr.Blocks(title="Universal Upscaler Pro") as iface:
544
+ gr.Markdown(desc)
545
+
546
+ with gr.Row():
547
+ with gr.Column(scale=1, min_width=300):
548
+ input_image = gr.Image(type="pil", label="Input Image", height=400)
549
+
550
+ with gr.Row():
551
+ model_selector = gr.Dropdown(
552
+ choices=list(manager.strategies.keys()),
553
+ value="SPAN (NomosUni) x2",
554
+ label="Model Architecture",
555
+ scale=2
556
+ )
557
+ output_format = gr.Dropdown(
558
+ choices=["PNG", "JPEG", "WEBP"],
559
+ value="PNG",
560
+ label="Output Format",
561
+ scale=1
562
+ )
563
+
564
+ submit_btn = gr.Button("Upscale Image", variant="primary", size="lg")
565
+
566
+ with gr.Accordion("Advanced Settings", open=False):
567
+ unload_btn = gr.Button("Unload All Models (Free RAM)", variant="secondary")
568
+ system_info = gr.Label(value=get_system_usage(), label="System Status")
569
+
570
+ with gr.Column(scale=1, min_width=300):
571
+ output_image = gr.Image(type="filepath", label="Upscaled Result", height=400)
572
+ logs_output = gr.TextArea(label="Execution Logs", interactive=False, lines=8)
573
+
574
+ # Event Wiring
575
+ submit_btn.click(
576
+ fn=process_image,
577
+ inputs=[input_image, model_selector, output_format],
578
+ outputs=[output_image, logs_output, system_info]
579
+ )
580
+
581
+ unload_btn.click(
582
+ fn=unload_models,
583
+ inputs=[],
584
+ outputs=[logs_output, system_info]
585
+ )
586
+
587
+ iface.launch()