upscalex 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
upscalex/engine.py ADDED
@@ -0,0 +1,697 @@
1
+ """
2
+ Core UpscaleX execution engine, resolution planner, cross-platform hardware resolver,
3
+ model catalog, pre-flight sanitization, and dynamic recommendation system.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import platform
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import time
16
+ import urllib.request
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
19
+
20
+ from PIL import Image, ImageFilter, ImageOps, ImageStat
21
+
22
+ # Disable PIL's DecompressionBomb check for ultra-large multi-gigapixel print renders
23
+ Image.MAX_IMAGE_PIXELS = None
24
+
25
+ from .utils import (
26
+ download_and_extract_engine,
27
+ format_bytes,
28
+ format_megapixels,
29
+ get_binary_name,
30
+ get_default_cache_dir,
31
+ get_system_diagnostics,
32
+ probe_vulkan_gpus,
33
+ )
34
+
35
+ # Comprehensive catalog of pre-packaged and downloadable open-weight models
36
+ MODEL_CATALOG = {
37
+ "realesrgan-x4plus": {
38
+ "name": "RealESRGAN X4plus",
39
+ "scale": 4,
40
+ "type": "Photos / Fine Art",
41
+ "vram_tier": "Medium (4GB VRAM)",
42
+ "min_vram_gb": 4,
43
+ "files": ["realesrgan-x4plus.bin", "realesrgan-x4plus.param"],
44
+ "download_url": None,
45
+ "description": "Default flagship model for real-world photos, fine art, architectural renders, and poster prints.",
46
+ },
47
+ "realesrgan-x4plus-anime": {
48
+ "name": "RealESRGAN Anime",
49
+ "scale": 4,
50
+ "type": "Anime / 2D Illustration",
51
+ "vram_tier": "Medium (4GB VRAM)",
52
+ "min_vram_gb": 4,
53
+ "files": ["realesrgan-x4plus-anime.bin", "realesrgan-x4plus-anime.param"],
54
+ "download_url": None,
55
+ "description": "Optimized for 2D illustrations, anime art, vectors, and digital painting with crisp edge lines.",
56
+ },
57
+ "realesr-animevideov3-x4": {
58
+ "name": "RealESRGAN Fast Video 4x",
59
+ "scale": 4,
60
+ "type": "Fast / Low VRAM",
61
+ "vram_tier": "Low (1GB-2GB VRAM / CPU)",
62
+ "min_vram_gb": 1,
63
+ "files": ["realesr-animevideov3-x4.bin", "realesr-animevideov3-x4.param"],
64
+ "download_url": None,
65
+ "description": "Lightweight 4x feed-forward model designed for rapid processing and low VRAM GPUs.",
66
+ },
67
+ "realesr-animevideov3-x2": {
68
+ "scale": 2,
69
+ "name": "RealESRGAN Fast Video 2x",
70
+ "type": "Fast / Low VRAM",
71
+ "vram_tier": "Low (1GB-2GB VRAM / CPU)",
72
+ "min_vram_gb": 1,
73
+ "files": ["realesr-animevideov3-x2.bin", "realesr-animevideov3-x2.param"],
74
+ "download_url": None,
75
+ "description": "Fast 2x upscale model for video frames, small assets, and lightweight turnaround.",
76
+ },
77
+ "realesr-animevideov3-x3": {
78
+ "scale": 3,
79
+ "name": "RealESRGAN Fast Video 3x",
80
+ "type": "Fast / Low VRAM",
81
+ "vram_tier": "Low (1GB-2GB VRAM / CPU)",
82
+ "min_vram_gb": 1,
83
+ "files": ["realesr-animevideov3-x3.bin", "realesr-animevideov3-x3.param"],
84
+ "download_url": None,
85
+ "description": "Fast 3x upscale model for video frames, web assets, and rapid turnaround.",
86
+ },
87
+ "realesr-general-x4v3": {
88
+ "name": "RealESRGAN General v3 Compact",
89
+ "scale": 4,
90
+ "type": "General / Compact",
91
+ "vram_tier": "Low (2GB VRAM)",
92
+ "min_vram_gb": 2,
93
+ "files": ["realesr-general-x4v3.pth"],
94
+ "download_url": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth",
95
+ "description": "Official compact general 4x model optimized for quick inference and lower memory consumption.",
96
+ },
97
+ "4x-ultrasharp": {
98
+ "name": "4x UltraSharp (Community Edition)",
99
+ "scale": 4,
100
+ "type": "Portraits / Ultra-Detail",
101
+ "vram_tier": "Medium-High (4GB-6GB VRAM)",
102
+ "min_vram_gb": 4,
103
+ "files": ["4x-UltraSharp.pth"],
104
+ "download_url": "https://huggingface.co/lokcx/4x-Ultrasharp/resolve/main/4x-UltraSharp.pth",
105
+ "description": "Community champion ESRGAN model famous for razor-sharp portraits, fabric, skin, and micro-textures.",
106
+ },
107
+ "4x-nmkd-superscale": {
108
+ "name": "4x NMKD Superscale",
109
+ "scale": 4,
110
+ "type": "Clean Photorealistic",
111
+ "vram_tier": "Medium-High (4GB-6GB VRAM)",
112
+ "min_vram_gb": 4,
113
+ "files": ["4x_NMKD-Superscale-SP_178000_G.pth"],
114
+ "download_url": "https://huggingface.co/uwg/upscaler/resolve/main/ESRGAN/4x_NMKD-Superscale-SP_178000_G.pth",
115
+ "description": "Trained on clean photographs; preserves fine hair, clothing threads, and foliage without artifacts.",
116
+ },
117
+ "realesrgan-anime-6b": {
118
+ "name": "RealESRGAN Anime 6-Block Lightweight",
119
+ "scale": 4,
120
+ "type": "Anime / Fast",
121
+ "vram_tier": "Low (1GB-2GB VRAM / CPU)",
122
+ "min_vram_gb": 1,
123
+ "files": ["RealESRGAN_x4plus_anime_6B.pth"],
124
+ "download_url": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/RealESRGAN_x4plus_anime_6B.pth",
125
+ "description": "Lightweight 6-block architecture for fast anime inference with low compute overhead.",
126
+ },
127
+ }
128
+
129
+ # Legacy alias dictionary for backward compatibility
130
+ AVAILABLE_MODELS = {k: {"scale": v["scale"], "type": v["type"], "description": v["description"]} for k, v in MODEL_CATALOG.items()}
131
+
132
+ class RealESRGANEngine:
133
+ """
134
+ Manages RealESRGAN binary execution, cross-platform hardware resolution (Vulkan GPU vs CPU),
135
+ dynamic model downloading, model recommendation, pre-flight sanitization, and resolution supersampling.
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ binary_path: Optional[Union[str, Path]] = None,
141
+ models_dir: Optional[Union[str, Path]] = None,
142
+ auto_download: bool = True,
143
+ ):
144
+ self.binary_path = self._resolve_binary(binary_path, auto_download=auto_download)
145
+ self.models_dir = self._resolve_models_dir(models_dir)
146
+
147
+ def _resolve_binary(
148
+ self, custom_path: Optional[Union[str, Path]], auto_download: bool = True
149
+ ) -> Path:
150
+ """Finds or downloads the RealESRGAN binary."""
151
+ if custom_path:
152
+ p = Path(custom_path)
153
+ if p.is_file() and os.access(p, os.X_OK):
154
+ return p.resolve()
155
+ if p.is_dir():
156
+ cand = p / get_binary_name()
157
+ if cand.exists():
158
+ return cand.resolve()
159
+
160
+ env_val = os.environ.get("REALESRGAN_PATH")
161
+ if env_val:
162
+ p = Path(env_val)
163
+ if p.is_file():
164
+ return p.resolve()
165
+
166
+ common_candidates = [
167
+ Path(r"C:\Companies\printing_businesses\tools\realesrgan") / get_binary_name(),
168
+ get_default_cache_dir() / get_binary_name(),
169
+ Path.cwd() / "tools" / "realesrgan" / get_binary_name(),
170
+ Path.cwd() / get_binary_name(),
171
+ ]
172
+ for cand in common_candidates:
173
+ if cand.exists():
174
+ return cand.resolve()
175
+
176
+ which_path = shutil.which(get_binary_name()) or shutil.which("realesrgan-ncnn-vulkan")
177
+ if which_path:
178
+ return Path(which_path).resolve()
179
+
180
+ if auto_download:
181
+ try:
182
+ return download_and_extract_engine().resolve()
183
+ except Exception:
184
+ pass
185
+
186
+ return get_default_cache_dir() / get_binary_name()
187
+
188
+ def _resolve_models_dir(self, custom_models_dir: Optional[Union[str, Path]]) -> Path:
189
+ """Locates the models directory."""
190
+ if custom_models_dir:
191
+ p = Path(custom_models_dir)
192
+ if p.is_dir():
193
+ return p.resolve()
194
+
195
+ adjacent = self.binary_path.parent / "models"
196
+ if adjacent.is_dir():
197
+ return adjacent.resolve()
198
+
199
+ cached = get_default_cache_dir() / "models"
200
+ if cached.is_dir():
201
+ return cached.resolve()
202
+
203
+ return adjacent
204
+
205
+ def validate_and_sanitize_input(self, image_path: Union[str, Path]) -> Tuple[Path, bool, int, int]:
206
+ """
207
+ Pre-flight image sanitization:
208
+ - Validates file existence and non-zero length.
209
+ - Corrects smartphone EXIF rotation orientation.
210
+ - Converts CMYK, Palette, and Monochrome modes to standard RGB/RGBA.
211
+ Returns: (processed_path, was_temporary, width, height)
212
+ """
213
+ in_p = Path(image_path).resolve()
214
+ if not in_p.exists():
215
+ raise FileNotFoundError(f"Image file does not exist: {image_path}")
216
+
217
+ if in_p.is_dir():
218
+ raise ValueError(f"Path is a directory, expected an image file: {image_path}")
219
+
220
+ if in_p.stat().st_size == 0:
221
+ raise ValueError(f"Image file is empty (0 bytes): {image_path}")
222
+
223
+ try:
224
+ with Image.open(in_p) as img:
225
+ orig_mode = img.mode
226
+ # Check EXIF orientation
227
+ transposed = ImageOps.exif_transpose(img) or img
228
+ needs_transposition = transposed.size != img.size or transposed is not img
229
+
230
+ # Check color mode: ncnn runtimes require RGB or RGBA
231
+ needs_conversion = orig_mode not in ("RGB", "RGBA")
232
+
233
+ width, height = transposed.size
234
+
235
+ if not needs_transposition and not needs_conversion:
236
+ return in_p, False, width, height
237
+
238
+ # Convert to clean RGB (or RGBA if transparent)
239
+ has_alpha = "A" in orig_mode or "transparency" in img.info
240
+ target_mode = "RGBA" if has_alpha else "RGB"
241
+ sanitized_img = transposed.convert(target_mode)
242
+
243
+ temp_fd, temp_path = tempfile.mkstemp(suffix=".png", prefix="sanitized_")
244
+ os.close(temp_fd)
245
+ sanitized_p = Path(temp_path)
246
+ sanitized_img.save(sanitized_p, format="PNG")
247
+ return sanitized_p, True, width, height
248
+
249
+ except Exception as e:
250
+ raise ValueError(f"File '{in_p.name}' is corrupted or not a recognized image: {e}")
251
+
252
+ def is_model_installed(self, model_id: str) -> bool:
253
+ """Checks if all required model weight files exist locally."""
254
+ if model_id not in MODEL_CATALOG:
255
+ return False
256
+ meta = MODEL_CATALOG[model_id]
257
+ return all((self.models_dir / f).exists() for f in meta["files"])
258
+
259
+ def download_model(self, model_id: str, progress_callback: Optional[Callable[[int, int], None]] = None) -> Dict[str, Any]:
260
+ """Downloads open-weight model files on demand into the models directory."""
261
+ if model_id not in MODEL_CATALOG:
262
+ valid = ", ".join(MODEL_CATALOG.keys())
263
+ raise ValueError(f"Unknown model '{model_id}'. Catalog models: {valid}")
264
+
265
+ meta = MODEL_CATALOG[model_id]
266
+ if self.is_model_installed(model_id):
267
+ return {
268
+ "success": True,
269
+ "model_id": model_id,
270
+ "name": meta["name"],
271
+ "status": "already_installed",
272
+ "message": f"Model '{meta['name']}' is already installed.",
273
+ "path": str(self.models_dir),
274
+ }
275
+
276
+ url = meta.get("download_url")
277
+ if not url:
278
+ raise RuntimeError(
279
+ f"Model '{model_id}' is part of the core binary bundle. Run 'upscalex download-engine' to fetch it."
280
+ )
281
+
282
+ self.models_dir.mkdir(parents=True, exist_ok=True)
283
+ dest_filename = meta["files"][0]
284
+ dest_path = self.models_dir / dest_filename
285
+
286
+ try:
287
+ def reporthook(block_num, block_size, total_size):
288
+ if progress_callback and total_size > 0:
289
+ progress_callback(block_num * block_size, total_size)
290
+
291
+ urllib.request.urlretrieve(url, str(dest_path), reporthook=reporthook)
292
+
293
+ return {
294
+ "success": True,
295
+ "model_id": model_id,
296
+ "name": meta["name"],
297
+ "status": "downloaded",
298
+ "message": f"Model '{meta['name']}' successfully downloaded.",
299
+ "file": dest_filename,
300
+ "path": str(dest_path),
301
+ "size_bytes": dest_path.stat().st_size,
302
+ "size_str": format_bytes(dest_path.stat().st_size),
303
+ }
304
+ except Exception as e:
305
+ if dest_path.exists():
306
+ try:
307
+ dest_path.unlink()
308
+ except OSError:
309
+ pass
310
+ raise RuntimeError(f"Failed to download model '{model_id}' from {url}: {e}")
311
+
312
+ def recommend_model(self, image_path: Union[str, Path], priority: str = "balanced") -> Dict[str, Any]:
313
+ """Inspects host hardware and image characteristics to recommend model and settings."""
314
+ sanitized_p, was_temp, orig_w, orig_h = self.validate_and_sanitize_input(image_path)
315
+ try:
316
+ diag = self.get_system_diagnostics()
317
+ meta = self.get_image_metadata(sanitized_p)
318
+
319
+ with Image.open(sanitized_p) as img:
320
+ thumb = img.convert("RGB").resize((128, 128))
321
+ stat = ImageStat.Stat(thumb)
322
+ avg_stddev = sum(stat.stddev) / 3.0
323
+ unique_colors = len(thumb.getcolors(maxcolors=16384) or [])
324
+
325
+ is_illustration = (unique_colors < 6000 and avg_stddev < 60.0) or (unique_colors < 3500)
326
+ image_class = "2D Illustration / Vector Art / Anime" if is_illustration else "Photographic / Continuous Tone / Fine Art"
327
+
328
+ has_gpu = diag["has_vulkan_gpu"]
329
+ discrete_gpus = [g for g in diag.get("detected_gpus", []) if g.get("is_discrete")]
330
+ is_discrete = len(discrete_gpus) > 0
331
+ gpu_name = discrete_gpus[0]["name"] if is_discrete else (diag.get("detected_gpus", [{}])[0].get("name") if has_gpu else "CPU")
332
+
333
+ orig_mp = meta["megapixels"]
334
+ if orig_mp >= 20.0:
335
+ rec_target = "16k"
336
+ target_reason = "Source image is >=20 MP. Native 16K (4x) upscale is ideal for large-format exhibition master prints."
337
+ elif orig_mp >= 5.0:
338
+ rec_target = "8k"
339
+ target_reason = "Source image is 5-20 MP. 8K supersampling (4x + Lanczos) delivers maximum edge clarity with zero noise."
340
+ else:
341
+ rec_target = "4k"
342
+ target_reason = "Source image is low-resolution (<5 MP). 4K target provides clean restoration without over-magnifying artifacts."
343
+
344
+ if not has_gpu:
345
+ rec_model = "realesr-animevideov3-x4" if is_illustration else "realesrgan-x4plus"
346
+ rec_tile = 128
347
+ hw_tier = "CPU Multi-Threaded"
348
+ rec_reasoning = f"No discrete GPU detected. Using {rec_model} with 128px tiling for CPU-safe execution."
349
+ elif not is_discrete:
350
+ rec_model = "realesr-animevideov3-x4" if is_illustration else "realesrgan-x4plus"
351
+ rec_tile = 128
352
+ hw_tier = "Integrated GPU (Shared System RAM)"
353
+ rec_reasoning = f"Integrated GPU detected ({gpu_name}). 128px tiling prevents system memory pressure and driver timeouts."
354
+ else:
355
+ rec_tile = 256
356
+ hw_tier = f"Dedicated GPU ({gpu_name})"
357
+ if is_illustration:
358
+ rec_model = "realesrgan-x4plus-anime"
359
+ rec_reasoning = f"Detected {image_class} on {gpu_name}. 'realesrgan-x4plus-anime' provides clean contour preservation and flat color smoothing."
360
+ else:
361
+ rec_model = "realesrgan-x4plus"
362
+ rec_reasoning = f"Detected {image_class} on {gpu_name}. 'realesrgan-x4plus' with 256px tiling delivers pristine textural and fine-art fidelity."
363
+
364
+ in_p = Path(image_path).resolve()
365
+ cmd_sample = f'upscalex upscale "{in_p.name}" --target {rec_target} --model {rec_model} --tile-size {rec_tile}'
366
+
367
+ return {
368
+ "image_path": str(in_p),
369
+ "file_name": in_p.name,
370
+ "detected_content_type": image_class,
371
+ "hardware_tier": hw_tier,
372
+ "recommended_model": rec_model,
373
+ "recommended_target": rec_target,
374
+ "recommended_tile_size": rec_tile,
375
+ "recommended_gpu_id": "auto",
376
+ "target_resolution_reasoning": target_reason,
377
+ "model_selection_reasoning": rec_reasoning,
378
+ "ready_command": cmd_sample,
379
+ "is_model_installed": self.is_model_installed(rec_model),
380
+ }
381
+ finally:
382
+ if was_temp and sanitized_p.exists():
383
+ try:
384
+ sanitized_p.unlink()
385
+ except OSError:
386
+ pass
387
+
388
+ def resolve_device(self, requested_gpu: Optional[Union[int, str]] = "auto") -> Tuple[str, Optional[int]]:
389
+ """Determines execution device with safe boundary checks."""
390
+ req_str = str(requested_gpu).strip().lower()
391
+ if req_str in ("cpu", "-1", "none"):
392
+ return "cpu", None
393
+
394
+ # Check Vulkan availability
395
+ if not self.binary_path.exists():
396
+ return "cpu", None
397
+
398
+ gpus = probe_vulkan_gpus(self.binary_path, self.models_dir)
399
+ if not gpus:
400
+ return "cpu", None
401
+
402
+ available_ids = [g["id"] for g in gpus]
403
+
404
+ if req_str.isdigit():
405
+ req_id = int(req_str)
406
+ if req_id in available_ids:
407
+ return "vulkan", req_id
408
+ # If requested GPU ID out of range, fallback safely to first available or discrete
409
+ discrete = [g for g in gpus if g["is_discrete"]]
410
+ return "vulkan", discrete[0]["id"] if discrete else gpus[0]["id"]
411
+
412
+ # Auto detection
413
+ discrete = [g for g in gpus if g["is_discrete"]]
414
+ chosen_id = discrete[0]["id"] if discrete else gpus[0]["id"]
415
+ return "vulkan", chosen_id
416
+
417
+ def get_image_metadata(self, image_path: Union[str, Path]) -> Dict[str, Union[str, int, float]]:
418
+ """Extracts comprehensive image metrics and print sizing."""
419
+ p = Path(image_path).resolve()
420
+ if not p.is_file():
421
+ raise FileNotFoundError(f"Image not found: {image_path}")
422
+
423
+ file_size = p.stat().st_size
424
+ with Image.open(p) as img:
425
+ width, height = img.size
426
+ mode = img.mode
427
+ img_format = img.format or p.suffix.lstrip(".").upper()
428
+
429
+ aspect_ratio = width / height if height > 0 else 1.0
430
+ mp = (width * height) / 1_000_000.0
431
+
432
+ print_width_inches = width / 300.0
433
+ print_height_inches = height / 300.0
434
+ print_width_cm = print_width_inches * 2.54
435
+ print_height_cm = print_height_inches * 2.54
436
+
437
+ return {
438
+ "file_name": p.name,
439
+ "file_path": str(p),
440
+ "width": width,
441
+ "height": height,
442
+ "aspect_ratio": round(aspect_ratio, 4),
443
+ "aspect_ratio_str": f"{round(aspect_ratio, 2)}:1",
444
+ "megapixels": round(mp, 2),
445
+ "megapixels_str": format_megapixels(width, height),
446
+ "mode": mode,
447
+ "format": img_format,
448
+ "file_size_bytes": file_size,
449
+ "file_size_str": format_bytes(file_size),
450
+ "print_size_300dpi_inches": f"{print_width_inches:.1f} x {print_height_inches:.1f} in",
451
+ "print_size_300dpi_cm": f"{print_width_cm:.1f} x {print_height_cm:.1f} cm",
452
+ }
453
+
454
+ def calculate_target_dimensions(
455
+ self, original_width: int, original_height: int, target: str
456
+ ) -> Tuple[int, int, str]:
457
+ """Determines target dimensions with boundary validation."""
458
+ if original_width <= 0 or original_height <= 0:
459
+ raise ValueError(f"Invalid original dimensions: {original_width}x{original_height}")
460
+
461
+ target_clean = str(target).strip().lower()
462
+
463
+ if target_clean in ("16k", "16-k"):
464
+ target_w, target_h = original_width * 4, original_height * 4
465
+ desc = "16K (4× native upscale)"
466
+ elif target_clean in ("8k", "8-k"):
467
+ target_w, target_h = original_width * 2, original_height * 2
468
+ desc = "8K (2× supersampled upscale)"
469
+ elif target_clean in ("4k", "4-k"):
470
+ scale = 4000.0 / max(original_width, original_height)
471
+ if scale <= 1.0:
472
+ target_w = max(int(original_width * scale), original_width)
473
+ target_h = max(int(original_height * scale), original_height)
474
+ else:
475
+ target_w = int(round(original_width * scale))
476
+ target_h = int(round(original_height * scale))
477
+ desc = "4K proportional"
478
+ elif target_clean in ("4x", "4×"):
479
+ target_w, target_h = original_width * 4, original_height * 4
480
+ desc = "4× upscale"
481
+ elif target_clean in ("2x", "2×"):
482
+ target_w, target_h = original_width * 2, original_height * 2
483
+ desc = "2× upscale"
484
+ elif target_clean in ("3x", "3×"):
485
+ target_w, target_h = original_width * 3, original_height * 3
486
+ desc = "3× upscale"
487
+ elif "x" in target_clean:
488
+ match = re.match(r"^(\d+)\s*[xX]\s*(\d+)$", target_clean)
489
+ if match:
490
+ target_w, target_h = int(match.group(1)), int(match.group(2))
491
+ desc = f"Custom {target_w}×{target_h}"
492
+ else:
493
+ raise ValueError(
494
+ f"Invalid custom dimension format '{target}'. Example format: '8000x6000' or '16k'."
495
+ )
496
+ else:
497
+ raise ValueError(
498
+ f"Unknown target resolution: '{target}'. Supported: '16k', '8k', '4k', '4x', '2x', '3x', or 'WIDTHxHEIGHT'."
499
+ )
500
+
501
+ if target_w <= 0 or target_h <= 0:
502
+ raise ValueError(f"Calculated dimensions must be greater than 0, got {target_w}x{target_h}")
503
+
504
+ return target_w, target_h, desc
505
+
506
+ def _upscale_cpu(
507
+ self,
508
+ in_p: Path,
509
+ out_p: Path,
510
+ target_w: int,
511
+ target_h: int,
512
+ format: str,
513
+ progress_callback: Optional[Callable[[float], None]] = None,
514
+ ):
515
+ """Multi-threaded CPU fallback upscaler."""
516
+ if progress_callback:
517
+ progress_callback(10.0)
518
+
519
+ with Image.open(in_p) as img:
520
+ rgb_img = img.convert("RGB")
521
+ if progress_callback:
522
+ progress_callback(30.0)
523
+
524
+ upscaled = rgb_img.resize((target_w, target_h), Image.Resampling.LANCZOS)
525
+ if progress_callback:
526
+ progress_callback(70.0)
527
+
528
+ sharpened = upscaled.filter(ImageFilter.UnsharpMask(radius=2, percent=120, threshold=3))
529
+ if progress_callback:
530
+ progress_callback(90.0)
531
+
532
+ sharpened.save(out_p, format=format.upper(), optimize=True)
533
+
534
+ if progress_callback:
535
+ progress_callback(100.0)
536
+
537
+ def upscale(
538
+ self,
539
+ input_path: Union[str, Path],
540
+ output_path: Optional[Union[str, Path]] = None,
541
+ target: str = "16k",
542
+ model: str = "realesrgan-x4plus",
543
+ tile_size: int = 256,
544
+ gpu_id: Optional[Union[int, str]] = "auto",
545
+ tta: bool = False,
546
+ format: str = "png",
547
+ progress_callback: Optional[Callable[[float], None]] = None,
548
+ ) -> Dict[str, Any]:
549
+ """
550
+ Executes high-resolution upscaling with pre-flight sanitization,
551
+ GPU auto-selection, and automatic CPU fallback.
552
+ """
553
+ # Pre-flight sanitization (EXIF orientation and color space)
554
+ sanitized_input, was_temp_input, orig_w, orig_h = self.validate_and_sanitize_input(input_path)
555
+
556
+ try:
557
+ if model not in MODEL_CATALOG:
558
+ valid = ", ".join(MODEL_CATALOG.keys())
559
+ raise ValueError(f"Invalid model '{model}'. Available models: {valid}")
560
+
561
+ if not self.is_model_installed(model):
562
+ if MODEL_CATALOG[model].get("download_url"):
563
+ self.download_model(model)
564
+ else:
565
+ raise FileNotFoundError(f"Model weights for '{model}' not found in {self.models_dir}.")
566
+
567
+ target_w, target_h, target_desc = self.calculate_target_dimensions(orig_w, orig_h, target)
568
+
569
+ orig_in_p = Path(input_path).resolve()
570
+ if not output_path:
571
+ tag = str(target).lower().replace(" ", "_")
572
+ out_p = orig_in_p.parent / f"{orig_in_p.stem}_{tag}.{format.lower()}"
573
+ else:
574
+ out_p = Path(output_path).resolve()
575
+
576
+ out_p.parent.mkdir(parents=True, exist_ok=True)
577
+
578
+ mode, resolved_gpu_id = self.resolve_device(gpu_id)
579
+ start_time = time.time()
580
+ used_acceleration = ""
581
+
582
+ if mode == "cpu":
583
+ self._upscale_cpu(sanitized_input, out_p, target_w, target_h, format, progress_callback)
584
+ used_acceleration = f"CPU Multi-Threaded ({os.cpu_count()} threads, Lanczos-3)"
585
+ else:
586
+ is_direct_4x = (target_w == orig_w * 4) and (target_h == orig_h * 4)
587
+ if is_direct_4x:
588
+ temp_4x_out = out_p
589
+ else:
590
+ temp_fd, temp_4x_path = tempfile.mkstemp(suffix=".png")
591
+ os.close(temp_fd)
592
+ temp_4x_out = Path(temp_4x_path)
593
+
594
+ cmd = [
595
+ str(self.binary_path),
596
+ "-i", str(sanitized_input),
597
+ "-o", str(temp_4x_out),
598
+ "-n", model,
599
+ "-m", str(self.models_dir),
600
+ "-s", "4",
601
+ "-t", str(tile_size),
602
+ "-g", str(resolved_gpu_id),
603
+ "-f", "png",
604
+ "-v",
605
+ ]
606
+ if tta:
607
+ cmd.append("-x")
608
+
609
+ vulkan_success = False
610
+ try:
611
+ proc = subprocess.Popen(
612
+ cmd,
613
+ stdout=subprocess.PIPE,
614
+ stderr=subprocess.STDOUT,
615
+ universal_newlines=True,
616
+ bufsize=1,
617
+ )
618
+
619
+ pct_pattern = re.compile(r"(\d+(?:\.\d+)?)%")
620
+ while True:
621
+ line = proc.stdout.readline()
622
+ if not line and proc.poll() is not None:
623
+ break
624
+ line = line.strip()
625
+ if line:
626
+ m = pct_pattern.search(line)
627
+ if m and progress_callback:
628
+ pct = float(m.group(1))
629
+ progress_callback(pct)
630
+
631
+ rc = proc.wait()
632
+ if rc == 0 and temp_4x_out.exists():
633
+ vulkan_success = True
634
+ used_acceleration = f"Vulkan GPU (Device {resolved_gpu_id})"
635
+ if not is_direct_4x:
636
+ with Image.open(temp_4x_out) as super_img:
637
+ downscaled = super_img.resize((target_w, target_h), Image.Resampling.LANCZOS)
638
+ downscaled.save(out_p, format=format.upper())
639
+ except Exception:
640
+ vulkan_success = False
641
+ finally:
642
+ if not is_direct_4x and temp_4x_out.exists():
643
+ try:
644
+ temp_4x_out.unlink()
645
+ except OSError:
646
+ pass
647
+
648
+ if not vulkan_success or not out_p.exists():
649
+ self._upscale_cpu(sanitized_input, out_p, target_w, target_h, format, progress_callback)
650
+ used_acceleration = "CPU Multi-Threaded (Vulkan GPU Fallback)"
651
+
652
+ duration = round(time.time() - start_time, 2)
653
+ out_size = out_p.stat().st_size
654
+
655
+ return {
656
+ "success": True,
657
+ "input_file": str(orig_in_p),
658
+ "output_file": str(out_p),
659
+ "original_resolution": f"{orig_w}x{orig_h}",
660
+ "output_resolution": f"{target_w}x{target_h}",
661
+ "original_megapixels": format_megapixels(orig_w, orig_h),
662
+ "output_megapixels": format_megapixels(target_w, target_h),
663
+ "target_spec": target_desc,
664
+ "model_used": model,
665
+ "tile_size": tile_size,
666
+ "acceleration": used_acceleration,
667
+ "gpu_id": resolved_gpu_id if mode == "vulkan" else "cpu",
668
+ "file_size_bytes": out_size,
669
+ "file_size_str": format_bytes(out_size),
670
+ "processing_seconds": duration,
671
+ }
672
+ finally:
673
+ if was_temp_input and sanitized_input.exists():
674
+ try:
675
+ sanitized_input.unlink()
676
+ except OSError:
677
+ pass
678
+
679
+ def get_system_diagnostics(self) -> Dict[str, Any]:
680
+ """Returns cross-platform environment diagnostics and detected hardware."""
681
+ return get_system_diagnostics(self.binary_path, self.models_dir)
682
+
683
+ def list_models(self) -> List[Dict[str, Any]]:
684
+ """Returns all catalog models with local installation status and specifications."""
685
+ result = []
686
+ for k, v in MODEL_CATALOG.items():
687
+ result.append({
688
+ "model_id": k,
689
+ "name": v["name"],
690
+ "scale": v["scale"],
691
+ "type": v["type"],
692
+ "vram_tier": v["vram_tier"],
693
+ "installed": self.is_model_installed(k),
694
+ "downloadable": bool(v.get("download_url")),
695
+ "description": v["description"],
696
+ })
697
+ return result