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/__init__.py +11 -0
- upscalex/cli.py +296 -0
- upscalex/engine.py +697 -0
- upscalex/server.py +241 -0
- upscalex/utils.py +198 -0
- upscalex-0.1.0.dist-info/METADATA +268 -0
- upscalex-0.1.0.dist-info/RECORD +10 -0
- upscalex-0.1.0.dist-info/WHEEL +4 -0
- upscalex-0.1.0.dist-info/entry_points.txt +3 -0
- upscalex-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|