Not all upscaling is equal. Lanczos takes 2ms and looks fine for clean photos, while Real-ESRGAN takes 4s on a GPU but produces sharper results on low-res images.
The Contenders
| Method | Speed | VRAM | Best For | |---|---|---|---| | Lanczos | ~2ms | None | Clean photos, simple enlargement | | Bicubic | ~1ms | None | Fast previews, web images | | Real-ESRGAN | 3–6s (GPU) | 4GB | Old photos, compressed images, manga | | SwinIR | 8–15s (GPU) | 6GB | Highest quality, research use | | ESRGAN-4+ | 4–8s (GPU) | 4GB | General-purpose upscaling |
Setup
pip install basicsr facexlib gfpgan realesrgan Pillow scikit-image numpy torch
For Real-ESRGAN specifically:
pip install realesrgan
# Download model weights (auto on first use, or manually):
# https://github.com/xinntao/Real-ESRGAN/releases
Classical Upscaling: Lanczos and Bicubic
from PIL import Image
import numpy as np
def upscale_classical(
image_path: str,
scale: int = 4,
method: str = "lanczos", # lanczos, bicubic, bilinear, nearest
output_path: str = None
) -> Image.Image:
img = Image.open(image_path).convert("RGB")
original_size = img.size
new_size = (img.width * scale, img.height * scale)
resample_map = {
"lanczos": Image.LANCZOS,
"bicubic": Image.BICUBIC,
"bilinear": Image.BILINEAR,
"nearest": Image.NEAREST,
}
resampler = resample_map.get(method, Image.LANCZOS)
upscaled = img.resize(new_size, resampler)
if output_path:
upscaled.save(output_path, quality=95)
print(f"{method}: {original_size} → {new_size}")
return upscaled
# Example
img_lanczos = upscale_classical("photo.jpg", scale=4, method="lanczos", output_path="photo_4x_lanczos.png")
img_bicubic = upscale_classical("photo.jpg", scale=4, method="bicubic", output_path="photo_4x_bicubic.png")
Real-ESRGAN Upscaling
import torch
import numpy as np
from PIL import Image
from realesrgan import RealESRGANer
from basicsr.archs.rrdbnet_arch import RRDBNet
def upscale_realesrgan(
image_path: str,
scale: int = 4,
model_name: str = "RealESRGAN_x4plus", # or RealESRNet_x4plus, RealESRGAN_x4plus_anime_6B
tile_size: int = 0, # 0 = no tiling; use 512 for low VRAM
output_path: str = None,
face_enhance: bool = False # Use GFPGAN for face enhancement
) -> Image.Image:
device = "cuda" if torch.cuda.is_available() else "cpu"
model_configs = {
"RealESRGAN_x4plus": {
"model": RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32, scale=4),
"scale": 4,
"url": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth"
},
"RealESRGAN_x4plus_anime_6B": {
"model": RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64, num_block=6, num_grow_ch=32, scale=4),
"scale": 4,
"url": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/RealESRGAN_x4plus_anime_6B.pth"
},
}
cfg = model_configs.get(model_name, model_configs["RealESRGAN_x4plus"])
upsampler = RealESRGANer(
scale=cfg["scale"],
model_path=f"./weights/{model_name}.pth", # Will auto-download if missing
model=cfg["model"],
tile=tile_size,
tile_pad=10,
pre_pad=0,
half=(device == "cuda"), # fp16 for faster inference on GPU
device=device
)
img = np.array(Image.open(image_path).convert("RGB"))
output, _ = upsampler.enhance(img, outscale=scale)
result = Image.fromarray(output)
if output_path:
result.save(output_path, quality=95)
print(f"Saved: {output_path} ({result.size})")
return result
# Standard photo upscaling
img_esrgan = upscale_realesrgan(
"old_photo.jpg",
scale=4,
model_name="RealESRGAN_x4plus",
output_path="photo_4x_esrgan.png"
)
# Anime/illustration upscaling (uses specialized model)
img_anime = upscale_realesrgan(
"illustration.png",
scale=4,
model_name="RealESRGAN_x4plus_anime_6B",
output_path="illustration_4x_esrgan.png"
)
Quality Benchmarks: PSNR and SSIM
from skimage.metrics import peak_signal_noise_ratio as psnr
from skimage.metrics import structural_similarity as ssim
import numpy as np
from PIL import Image
def measure_quality(
original_path: str,
upscaled_path: str,
scale: int = 4
) -> dict:
original = np.array(Image.open(original_path).convert("RGB"))
upscaled = np.array(Image.open(upscaled_path).convert("RGB"))
if original.shape != upscaled.shape:
h, w = upscaled.shape[0:2]
original_resized = np.array(
Image.fromarray(original).resize((w, h), Image.LANCZOS)
)
else:
original_resized = original
psnr_score = psnr(original_resized, upscaled, data_range=255)
ssim_score = ssim(original_resized, upscaled, channel_axis=2, data_range=255)
return {"psnr": round(psnr_score, 2), "ssim": round(ssim_score, 4)}
# Run comparison
methods = {
"Lanczos": "photo_4x_lanczos.png",
"Bicubic": "photo_4x_bicubic.png",
"Real-ESRGAN": "photo_4x_esrgan.png",
}
print("Method PSNR (dB) SSIM")
print("-" * 35)
for name, path in methods.items():
try:
metrics = measure_quality("original_hd.jpg", path)
print(f"{name:<15} {metrics['psnr']:<10} {metrics['ssim']:.4f}")
except Exception as e:
print(f"{name:<15} Error: {e}")
Typical results on real photos (compressed JPEGs, 4x upscale):
| Method | PSNR | SSIM | |---|---|---| | Nearest neighbor | 21.4 dB | 0.61 | | Bilinear | 25.1 dB | 0.74 | | Bicubic | 26.8 dB | 0.79 | | Lanczos | 27.2 dB | 0.81 | | Real-ESRGAN | 29.6 dB | 0.87 |
The PSNR gap of ~2.4dB between Lanczos and Real-ESRGAN is visually significant — especially on compressed, noisy, or low-res source material.
When to Use What
Use Lanczos when:
- Source image is already high quality (16MP+ photo)
- You need real-time performance (thumbnails, previews)
- You're enlarging less than 2x
- You're processing thousands of images in a pipeline
Use Real-ESRGAN when:
- Source is compressed (JPEG artifacts, old scans)
- You're doing 4x+ upscaling
- Image has fine texture details (fabric, hair, text)
- You're preparing product photos for print
Batch Processing with Smart Fallback
import time
from pathlib import Path
def smart_upscale_batch(
input_dir: str,
output_dir: str,
scale: int = 4,
quality_threshold: float = 0.85, # SSIM; use AI if below this
max_megapixels: float = 4.0 # Skip AI for already large images
):
Path(output_dir).mkdir(exist_ok=True)
for img_path in Path(input_dir).glob("*.{jpg,jpeg,png,webp}"):
img = Image.open(img_path)
mp = (img.width * img.height) / 1_000_000
if mp > max_megapixels:
print(f" {img_path.name}: Large image → Lanczos")
method = "lanczos"
result = upscale_classical(str(img_path), scale, method)
else:
print(f" {img_path.name}: Small/compressed → Real-ESRGAN")
try:
result = upscale_realesrgan(str(img_path), scale)
except Exception as e:
print(f" ESRGAN failed ({e}), falling back to Lanczos")
result = upscale_classical(str(img_path), scale, "lanczos")
out_path = Path(output_dir) / (img_path.stem + f"_{scale}x.png")
result.save(str(out_path))
print("Batch complete.")
Check out my full Image Workspace with over 45 image methods and more at axon.nepa-ai.com



