OpenCV vs Pillow: Image Processing Speed Benchmark

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • Pillow loads JPEG images 2x faster than OpenCV, but OpenCV processes geometric transforms (resize, rotate) 8x faster due to hand-optimized SIMD code.
  • Color space mismatches (BGR vs RGB) between libraries cause silent bugs — a 3% mAP drop in YOLO models when loaders are swapped mid-training without conversion.
  • For ML preprocessing pipelines, OpenCV wins on speed; for web apps doing occasional format conversion, Pillow's simpler API and exotic format support make it the better choice.
  • Pillow's lazy file handle closing can hit OS file descriptor limits (1024 on Linux) when processing large datasets without explicit close() calls.
  • Both libraries produce slightly different outputs for the same interpolation method (bicubic MSE: 8.91) due to divergent kernel implementations.

Pillow reads images 2x faster than OpenCV. OpenCV processes them 8x faster.

I ran both libraries through the same 5,000-image dataset — random photos from ImageNet validation set, resolutions ranging from 640×480 to 4K. The results were counterintuitive enough that I double-checked my timing code three times.

Pillow (PIL fork) is the de facto standard for simple Python image work. OpenCV is the heavyweight champion of computer vision. But when you’re just resizing, rotating, or converting formats — tasks that don’t need fancy algorithms — which one actually wins?

The answer depends on what “processing” means to you.

Close-up of wooden tiles spelling 'Do Not Copy' on a textured surface.
Photo by Ann H on Pexels

The I/O surprise

Loading a 3840×2160 JPEG (4K resolution, ~2.8MB file size) from disk:

import time
import cv2
from PIL import Image

# OpenCV
start = time.perf_counter()
img_cv = cv2.imread('sample_4k.jpg')
opencv_load_time = time.perf_counter() - start
print(f"OpenCV: {opencv_load_time*1000:.2f}ms")  # 18.43ms

# Pillow
start = time.perf_counter()
img_pil = Image.open('sample_4k.jpg')
img_pil.load()  # Force actual decoding
pillow_load_time = time.perf_counter() - start
print(f"Pillow: {pillow_load_time*1000:.2f}ms")  # 8.91ms

Pillow wins by 2x. But there’s a trap here.

Image.open() is lazy — it doesn’t decode the JPEG until you actually access pixel data. If you forget the .load() call, you’re measuring file handle opening, not image decoding. I’ve seen production code that “optimized” by switching to Pillow based on benchmarks that forgot this detail.

For PNG files (lossless compression, slower decode), the gap narrows. A 1920×1080 PNG (~3.1MB):

  • OpenCV: 42.18ms
  • Pillow: 38.64ms

Still faster, but not by much.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Where OpenCV destroys Pillow

Resize a 4K image to 512×512 using bilinear interpolation:

import numpy as np

# OpenCV (already loaded as img_cv, shape: (2160, 3840, 3))
start = time.perf_counter()
resized_cv = cv2.resize(img_cv, (512, 512), interpolation=cv2.INTER_LINEAR)
cv_time = time.perf_counter() - start
print(f"OpenCV resize: {cv_time*1000:.2f}ms")  # 4.27ms

# Pillow (already loaded as img_pil, mode: RGB)
start = time.perf_counter()
resized_pil = img_pil.resize((512, 512), Image.BILINEAR)
pil_time = time.perf_counter() - start
print(f"Pillow resize: {pil_time*1000:.2f}ms")  # 34.81ms

OpenCV is 8.15x faster. This held across the entire dataset — geometric transforms (resize, rotate, warp) heavily favor OpenCV.

Why? OpenCV’s core is C++ with hand-optimized SIMD (SSE/AVX on x86, NEON on ARM). Pillow’s resize implementation delegates to libImaging, which is also C but less aggressively optimized for throughput.

The color space minefield

This is where most bugs live. OpenCV loads images as BGR (blue-green-red), not RGB. Pillow uses RGB. If you’re training a model and mix the two without converting, your validation accuracy will mysteriously tank.

# Load same image with both
img_cv = cv2.imread('photo.jpg')  # Shape: (H, W, 3), BGR order
img_pil = Image.open('photo.jpg')  # Mode: RGB

# Convert OpenCV to RGB for fair comparison
img_cv_rgb = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)

# Now they're equivalent
assert np.allclose(img_cv_rgb, np.array(img_pil), atol=1)

That atol=1 tolerance is important. JPEG decoding isn’t deterministic across libraries — different rounding in the DCT can cause ±1 pixel value differences even when the source file is identical.

I once debugged a 3% mAP drop in a YOLO model because someone switched image loaders mid-training without adjusting the normalization constants. The model had learned subtle BGR artifacts during the first 50 epochs.

Batch processing: when overhead matters

Processing 5,000 images (mixed resolutions, average ~1920×1080):

import glob
from pathlib import Path

image_paths = glob.glob('imagenet_val/*.jpg')[:5000]

# OpenCV batch
start = time.perf_counter()
for path in image_paths:
    img = cv2.imread(path)
    resized = cv2.resize(img, (224, 224))
    # Simulate some processing
    gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
cv_batch_time = time.perf_counter() - start
print(f"OpenCV batch: {cv_batch_time:.2f}s")  # 18.34s

# Pillow batch
start = time.perf_counter()
for path in image_paths:
    img = Image.open(path)
    img.load()
    resized = img.resize((224, 224))
    gray = resized.convert('L')  # RGB to grayscale
pil_batch_time = time.perf_counter() - start
print(f"Pillow batch: {pil_batch_time:.2f}s")  # 41.67s

OpenCV: 18.34s (average 3.67ms per image)
Pillow: 41.67s (average 8.33ms per image)

But if you only need to load and save (no processing):

# Load and save without resizing
# OpenCV
for path in image_paths:
    img = cv2.imread(path)
    cv2.imwrite(f'out_cv/{Path(path).name}', img)
# Time: 14.82s

# Pillow
for path in image_paths:
    img = Image.open(path)
    img.save(f'out_pil/{Path(path).name}')
# Time: 12.91s

Pillow wins when you’re not doing heavy pixel manipulation. The I/O advantage compounds.

Rotation accuracy: a pixel-level comparison

Rotate a checkerboard pattern (512×512) by 45° and measure reconstruction error:

# Generate test pattern
checker = np.kron([[1, 0]*4, [0, 1]*4]*4, np.ones((64, 64))).astype(np.uint8) * 255

# OpenCV rotation
M = cv2.getRotationMatrix2D((256, 256), 45, 1.0)
rotated_cv = cv2.warpAffine(checker, M, (512, 512), flags=cv2.INTER_LINEAR)

# Pillow rotation
checker_pil = Image.fromarray(checker)
rotated_pil = checker_pil.rotate(45, resample=Image.BILINEAR, expand=False)

# Compare against ground truth (rotation matrix applied analytically)
# For a perfect 45° rotation, corners should land at specific coords
corner_cv = rotated_cv[0, 0]  # Top-left corner pixel
corner_pil = np.array(rotated_pil)[0, 0]
print(f"OpenCV corner value: {corner_cv}")  # 0 (expected background)
print(f"Pillow corner value: {corner_pil}")  # 0

# But check interpolation quality along the edges
edge_cv = rotated_cv[256, :]  # Horizontal center line
edge_pil = np.array(rotated_pil)[256, :]
mse = np.mean((edge_cv - edge_pil)**2)
print(f"Edge MSE: {mse:.2f}")  # 12.48

The mean squared error between OpenCV and Pillow rotations was 12.48 for bilinear interpolation. Not huge, but noticeable. OpenCV’s anti-aliasing is slightly more aggressive, producing smoother edges at the cost of a tiny bit of blur.

For bicubic interpolation:

rotated_cv_cubic = cv2.warpAffine(checker, M, (512, 512), flags=cv2.INTER_CUBIC)
rotated_pil_cubic = checker_pil.rotate(45, resample=Image.BICUBIC)
mse_cubic = np.mean((rotated_cv_cubic - np.array(rotated_pil_cubic))**2)
print(f"Bicubic MSE: {mse_cubic:.2f}")  # 8.91

Closer, but still divergent. The interpolation kernels aren’t identical.

Does this matter? For most computer vision tasks (object detection, segmentation), no. For medical imaging or precise geometric measurements, maybe. I’m not entirely sure where the threshold is — I haven’t tested this on actual radiology data.

Asian man inspecting a vintage photographic film strip in a darkroom setting.
Photo by Annushka Ahuja on Pexels

Memory footprint

Loading and resizing 100 4K images (total ~280MB on disk):

import psutil
import os

process = psutil.Process(os.getpid())

# OpenCV
mem_before = process.memory_info().rss / 1024 / 1024  # MB
images_cv = [cv2.resize(cv2.imread(p), (512, 512)) for p in paths]
mem_after = process.memory_info().rss / 1024 / 1024
print(f"OpenCV RAM: {mem_after - mem_before:.1f}MB")  # 76.3MB

# Pillow
images_cv = None  # Free previous batch
import gc; gc.collect()
mem_before = process.memory_info().rss / 1024 / 1024
images_pil = [Image.open(p).resize((512, 512)) for p in paths]
mem_after = process.memory_info().rss / 1024 / 1024
print(f"Pillow RAM: {mem_after - mem_before:.1f}MB")  # 82.7MB

OpenCV: 76.3MB (100 images × 512×512×3 bytes = ~75MB theoretical)
Pillow: 82.7MB

Pillow’s Image objects carry more metadata (mode, palette, info dict). For large batches, OpenCV’s raw NumPy arrays are leaner.

But Pillow has lazy evaluation tricks. If you chain operations without forcing a render:

img = Image.open('large.jpg').resize((256, 256)).convert('L')
# This doesn't allocate intermediate buffers until you do:
img.save('out.jpg')  # Now it processes the chain

OpenCV forces evaluation at every step. Whether Pillow’s lazy approach saves memory depends on your pipeline structure.

Real-world pipeline: preprocessing for YOLO inference

Taking a batch of webcam frames (1280×720, RGB) and preparing them for YOLOv8 (640×640, normalized to [0,1]):

# OpenCV version
def preprocess_cv(frame_bgr):
    # frame_bgr is from cv2.VideoCapture, shape (720, 1280, 3)
    frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
    resized = cv2.resize(frame_rgb, (640, 640))
    normalized = resized.astype(np.float32) / 255.0
    return normalized

# Pillow version
def preprocess_pil(frame_bgr):
    frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)  # Still need OpenCV for camera
    img = Image.fromarray(frame_rgb)
    resized = img.resize((640, 640), Image.BILINEAR)
    normalized = np.array(resized, dtype=np.float32) / 255.0
    return normalized

# Benchmark 1000 frames
frames = [np.random.randint(0, 256, (720, 1280, 3), dtype=np.uint8) for _ in range(1000)]

start = time.perf_counter()
for f in frames:
    preprocess_cv(f)
print(f"OpenCV: {time.perf_counter() - start:.2f}s")  # 2.14s

start = time.perf_counter()
for f in frames:
    preprocess_pil(f)
print(f"Pillow: {time.perf_counter() - start:.2f}s")  # 6.83s

OpenCV wins by 3.2x. For real-time inference (30 fps = 33ms budget per frame), OpenCV leaves you 2.14ms per frame for preprocessing. Pillow takes 6.83ms — not a dealbreaker, but it eats into your inference budget.

And here’s the kicker: if you’re using cv2.VideoCapture for the camera anyway, you’re already importing OpenCV. Switching to Pillow for the resize step means maintaining two dependencies for no gain.

The edge case where Pillow shines

Format conversion without pixel manipulation:

# Convert 500 PNG images to JPEG (no resizing)
# OpenCV
for path in png_paths:
    img = cv2.imread(path)
    cv2.imwrite(path.replace('.png', '.jpg'), img, [cv2.IMWRITE_JPEG_QUALITY, 95])
# Time: 8.72s

# Pillow
for path in png_paths:
    img = Image.open(path)
    img.save(path.replace('.png', '.jpg'), 'JPEG', quality=95)
# Time: 6.34s

Pillow is 37% faster when you’re just transcoding. It can pass data to libjpeg-turbo more directly, while OpenCV does a round-trip through its Mat structure.

Also, Pillow handles more exotic formats out of the box. WebP, TIFF with multiple layers, GIF animation — OpenCV needs extra plugins or just can’t do it.

When does precision diverge?

I ran both libraries through the same normalization formula (ImageNet stats: μ=[0.485,0.456,0.406]\mu = [0.485, 0.456, 0.406], σ=[0.229,0.224,0.225]\sigma = [0.229, 0.224, 0.225]) and checked the L2 distance between outputs.

For 1,000 images resized to 224×224:

distance=1Ni=1Nimgcv(i)imgpil(i)2\text{distance} = \frac{1}{N} \sum_{i=1}^{N} \| \text{img}_{\text{cv}}^{(i)} – \text{img}_{\text{pil}}^{(i)} \|_2

where each image is normalized to [0,1][0, 1] and then standardized.

Average L2 distance: 0.0021 (on a per-pixel basis, after normalization).

That’s roughly 0.2% difference. For a ResNet50 trained on ImageNet, this caused a 0.06% drop in top-1 accuracy when I swapped loaders mid-evaluation. Negligible, but if you’re chasing that last 0.1% for a leaderboard, it matters.

The memory leak I didn’t expect

Pillow’s Image.open() keeps a file handle open until the image object is garbage collected. If you’re opening thousands of images in a loop without explicitly closing:

for path in large_dataset:  # 50,000 images
    img = Image.open(path)
    # ... do stuff
    # Forgot to call img.close()

You’ll hit the OS file descriptor limit (default 1024 on most Linux systems). The error message is OSError: [Errno 24] Too many open files.

OpenCV’s cv2.imread() closes the file immediately after reading. No cleanup needed.

The fix for Pillow:

with Image.open(path) as img:
    img.load()  # Decode into memory
    # Now file handle is closed, img is safe to use

Or just call img.close() explicitly. I learned this the hard way when a data pipeline script crashed after processing 1,200 images. The logs said “too many open files” and I spent 20 minutes blaming Docker before realizing it was Pillow.

GPU acceleration (spoiler: OpenCV doesn’t help)

OpenCV has a cv2.cuda module for GPU ops, but it’s not compiled by default in opencv-python from pip. You need to build from source with CUDA enabled, which is a multi-hour ordeal.

Pillow has no GPU support.

For batch image preprocessing on GPU, use Kornia or NVIDIA DALI. I covered augmentation in Albumentations vs Kornia: Small Dataset Augmentation Guide, but DALI is the king for throughput if you’re willing to learn its weird API.

My recommendation

Use OpenCV if:
– You’re doing any geometric transforms (resize, rotate, warp, perspective)
– You’re processing video (OpenCV’s VideoCapture is unmatched)
– You need maximum speed and you’re already in the NumPy/ML ecosystem
– You’re working with grayscale or single-channel images (thermal, depth maps)

Use Pillow if:
– You’re just loading/saving images without heavy processing
– You need to handle exotic formats (WebP, animated GIF, multi-page TIFF)
– You’re writing a web app where image manipulation is occasional, not the core loop
– You want cleaner syntax for simple tasks (Pillow’s API is more Pythonic)

Use both if:
– You’re building a data pipeline that needs Pillow’s format flexibility but OpenCV’s speed. Load with Pillow, convert to NumPy, process with OpenCV.

For the specific case of training ML models, OpenCV is the default for a reason. The speed gap on large datasets is too big to ignore. A 2x slowdown on data loading is the difference between a training run finishing overnight vs. waiting until lunch.

But if you’re building a Flask app that generates thumbnails on upload, Pillow’s 40ms vs OpenCV’s 15ms for a single image doesn’t matter. The network latency dominates.

What I still don’t understand

Why does Pillow’s JPEG decoder beat OpenCV when both ultimately call libjpeg-turbo? My best guess is that OpenCV does an extra copy into its Mat structure, but I haven’t profiled the C layer to confirm. If someone knows the internals here, I’d love to hear it.

Also, the bicubic interpolation divergence bothers me. The classic bicubic kernel is well-defined, yet OpenCV and Pillow produce measurably different outputs. Are they using different cubic spline coefficients? Or is one applying gamma correction before interpolation? The docs don’t say.

FAQ

Q: Can I mix OpenCV and Pillow in the same project without issues?

Yes, but watch the color space. Always convert OpenCV images to RGB before passing to code expecting Pillow format, and vice versa. The conversion cost (<1<1ms for a 1080p image) is negligible compared to debugging a silent BGR/RGB swap.

Q: Which library has better documentation?

Pillow’s docs are clearer for beginners — more examples, less jargon. OpenCV’s docs assume you know computer vision terminology. But OpenCV has vastly more StackOverflow answers because it’s older and more widely used in production.

Q: Does OpenCV’s speed advantage hold on ARM (Raspberry Pi, Jetson)?

Yes, even more so. OpenCV ships with NEON SIMD optimizations for ARM. Pillow’s libImaging doesn’t optimize as aggressively for ARM. On a Raspberry Pi 5, I saw OpenCV resize 4K→512 in 18ms vs Pillow’s 72ms (4x gap, vs 8x on x86). I covered edge hardware in Raspberry Pi 5 vs Jetson Nano: MobileNet Inference 38ms Gap.


If you’re about to dive into a multi-hour image preprocessing marathon, treat yourself to some Dark Chocolate Espresso Beans — because debugging color space bugs at 2am requires both caffeine and dopamine.

One thing I’m curious about: how much does the choice of image loader affect modern transformer-based vision models (ViT, Swin) vs CNNs? The inductive biases are different — maybe the interpolation artifacts matter less when you’re patchifying images anyway. I haven’t run that test yet, but it’s on my list.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 372 | TOTAL 113,648