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 mnemelabs.nepa-ai.com
Batch too big for one workstation? Cloud GPU vs Local RTX 3090 explains when renting capacity makes sense, and the Google Drive to Cloud GPU rclone pipeline handles moving the images.



