Python Image and Video Libraries
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Baseline date: 2026-10-07. Versions are the latest on PyPI on that date: Pillow 12.3.0, pillow-heif 1.8.0, pillow-avif-plugin 1.6.0, pyvips 3.2.0, MoviePy 2.2.1, blurhash-python 1.2.2. Check the release notes of the exact versions you install, since format support depends on how the wheels were built.
Image libraries
| Library | Licence | Notes |
|---|---|---|
MIT-CMU (HPND style) |
The standard imaging library. JPEG, PNG, WebP, GIF, TIFF. AVIF read and write is built in from Pillow 11.3 when the wheel includes libavif, so check |
|
BSD-3-Clause |
Adds HEIF and HEIC (and AVIF) reading and writing to Pillow, using libheif. Needed to read iPhone uploads. |
|
MIT |
AVIF support for older Pillow versions. Treat it as a stopgap: with a current Pillow, use the built-in AVIF support. |
|
MIT |
Binding to libvips. Lower memory and higher throughput than Pillow on big images, because it streams. Needs libvips installed, or the binary wheel. |
Pillow: thumbnails, formats and EXIF
pip install Pillow pillow-heif
from PIL import Image, ImageOps, features
from pillow_heif import register_heif_opener
register_heif_opener() # lets Image.open read HEIC and HEIF
def make_variants(src: str, out_dir: str, widths=(320, 640, 1280)) -> list[str]:
written = []
with Image.open(src) as im:
im = ImageOps.exif_transpose(im) # apply the orientation, then drop the tag
im = im.convert("RGB") # sRGB, no alpha, no palette
for width in widths:
if width >= im.width:
width = im.width # never upscale
height = round(im.height * width / im.width)
resized = im.resize((width, height), Image.Resampling.LANCZOS)
resized.save(f"{out_dir}/w{width}.jpg", "JPEG", quality=80, optimize=True, progressive=True)
resized.save(f"{out_dir}/w{width}.webp", "WEBP", quality=78, method=5)
if features.check("avif"):
resized.save(f"{out_dir}/w{width}.avif", "AVIF", quality=60, speed=6)
written += [f"w{width}.jpg", f"w{width}.webp"]
return written
Image.thumbnailw, h resizes in place keeping the aspect ratio, and ImageOps.fit crops to an exact aspect ratio.
Image.open is lazy, and Pillow protects against decompression bombs with Image.MAX_IMAGE_PIXELS: leave the limit on and
catch DecompressionBombError. Saving a Pillow image without passing exif= drops the EXIF block, which is how
metadata gets stripped. For a colour profile, convert with ImageCms to sRGB before saving.
References: Pillow image file formats, ImageOps and pillow-heif documentation.
pyvips
import pyvips
def avif_variant(src: str, dst: str, width: int) -> None:
# thumbnail() shrinks on load, so it is much cheaper than open-then-resize.
image = pyvips.Image.thumbnail(src, width, height=10_000_000, size="down") # width bound only
image.write_to_file(dst, Q=60, effort=5, strip=True)
Reference: pyvips documentation.
BlurHash
pip install blurhash-python
import blurhash
from PIL import Image
with Image.open("original.jpg") as im:
im = im.convert("RGB")
im.thumbnail((64, 64)) # downscale first
hash_ = blurhash.encode(im, x_components=4, y_components=3)
The blurhash-python package from the woltapp repository accepts a Pillow image or a file object. The blurhash package
on PyPI is the other implementation, and has a different call signature (it takes a list of pixel rows), so check
the README of the one you install. References: woltapp/blurhash-python and
halcy/blurhash-python. The algorithm is described in
the BlurHash page.
Video libraries
| Library | Status | Notes |
|---|---|---|
Unmaintained since 2019 (0.2.0) |
A builder for FFmpeg command lines. Still works for simple graphs, but it is not updated for newer FFmpeg options. Avoid it for new code. |
|
Maintained (2.2.1) |
Python editing library on top of FFmpeg. Suited to editing, overlays and clips, not to bulk transcoding, since it decodes frames in Python. |
|
|
n/a |
A direct call to the |
subprocess
import json
import subprocess
def probe(path: str) -> dict:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_name,width,height",
"-of", "json", path],
check=True, capture_output=True, text=True, timeout=30,
)
return json.loads(out.stdout)
def transcode(src: str, dst: str, height: int = 720) -> None:
subprocess.run(
["ffmpeg", "-y", "-i", src, "-vf", f"scale=-2:{height}",
"-c:v", "libx264", "-preset", "slow", "-crf", "22", "-g", "48", "-keyint_min", "48",
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", dst],
check=True, capture_output=True, timeout=3600,
)
def cover(src: str, dst: str, seconds: float) -> None:
subprocess.run(
["ffmpeg", "-y", "-ss", str(seconds), "-i", src, "-frames:v", "1", "-q:v", "2", dst],
check=True, capture_output=True, timeout=120,
)
Pass the arguments as a list, never as a shell string, so file names from users cannot inject options or commands, and always set a timeout. Reference: Python subprocess and the commands in video encoding fundamentals.
MoviePy v2
MoviePy 2 renamed much of its API: moviepy.editor is gone (import from moviepy), resize became resized,
subclip became subclipped, and the set_* methods became with_*.
from moviepy import VideoFileClip
with VideoFileClip("input.mov") as clip:
small = clip.resized(height=360).subclipped(0, 10) # first 10 seconds at 360p
small.write_videofile("preview.mp4", codec="libx264", audio_codec="aac", fps=24)
clip.save_frame("cover.jpg", t=clip.duration * 0.1) # frame at 10 percent
Use it for previews and montage, and use FFmpeg directly for the renditions. Reference: MoviePy documentation.
A FastAPI upload endpoint
The framework side is covered in forms and file uploads. For media, the endpoint should not receive the file at all when a pre-signed URL can be used, see upload pipelines. For small images, accepting the upload directly is fine:
from fastapi import BackgroundTasks, FastAPI, File, HTTPException, UploadFile
import uuid
app = FastAPI()
MAX_BYTES = 10 * 1024 * 1024
@app.post("/images", status_code=202)
async def upload_image(background: BackgroundTasks, file: UploadFile = File(...)):
data = await file.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large")
media_id = str(uuid.uuid4())
save_original(media_id, data) # object storage, private
background.add_task(process_image, media_id) # small, fast work only
return {"id": media_id, "status": "processing"}
@app.get("/images/{media_id}")
def image_status(media_id: str):
return load_status(media_id)
BackgroundTasks runs the function in the same process after the response is sent. That is fine for resizing a small
image. It is not fine for video, for three reasons: a transcode can take minutes and would hold a worker slot, the work
is lost if the process restarts or the pod is rescheduled, and there are no retries, backoff, dead-letter queue or
visibility into progress. For video (and for any heavy image work), publish a job to a real queue (Celery, RQ, Dramatiq, or a
cloud queue) and process it in a separate worker deployment. See
settings, lifespan and background tasks for what
BackgroundTasks does and does not do, and the queue patterns in
upload pipelines.
Reference: FastAPI, background tasks and FastAPI, request files.