imageio
imageio gives you one function to read any image into a NumPy array and one to write an array back out, regardless of what the format underneath is. iio.imread works the same whether the target is a PNG on disk, a JPEG at a URL, a multi-page TIFF, a DICOM series, a FITS file from a telescope, an animated GIF, or an MP4. It does almost none of this itself: it is a plugin manager that inspects the resource, picks a backend (Pillow for common formats, tifffile for TIFF, ffmpeg or PyAV for video, ITK and astropy for scientific data), applies sensible defaults, and hands back a plain ndarray. The v3 API is six functions: imread, imwrite, imiter, improps, immeta, and imopen. The core install depends only on NumPy and Pillow; every backend beyond that is an optional extra you install separately.
The right choice when the format is not known ahead of time or the format is scientific, and the two-dependency core makes it cheap to add. If your inputs are only PNG and JPEG, or your workload is really video, the specific library underneath it is the better tool.
Use it if
- Your code has to accept whatever file a user hands it and you do not want a chain of if-extension branches picking between Pillow, tifffile, and a video decoder
- You work with scientific or volumetric data (DICOM, NIfTI, FITS, OME-TIFF, multi-page TIFF) and want the same call signature you use for PNGs, with the frames stacked into one array
- You need to write an animated GIF or a short MP4 out of an array of frames and do not want to learn a codec API to do it
- Your input is not always a path: imread takes bytes, a file-like object, an http URL, or a pathlib.Path, and imwrite can return encoded bytes with the '<bytes>' target instead of touching the disk
- You are in a NumPy pipeline already (scikit-image, SciPy, PyTorch) so an ndarray is the shape you want anyway, not a library-specific image object
- You only handle PNG and JPEG and you also need to resize, crop, convert color modes, or draw. imageio does zero image processing, so you install it, get an array, and then install Pillow or OpenCV anyway. Pillow is already a required dependency of imageio, so using it directly costs you one fewer package and gives you the operations
- Video is the main job. The two video backends both come with real costs: imageio-ffmpeg spawns an ffmpeg subprocess and pipes raw frames through a pipe, which is slow and gives you no frame-accurate seeking, and the pyav backend is a thin wrapper over PyAV, at which point using PyAV directly gets you container, stream, and codec control instead of a lowest-common-denominator array API
- You already depend on OpenCV. cv2.imread plus cv2.VideoCapture covers most of the same ground with one dependency and considerably faster decoding, as long as you remember it hands you BGR and silently returns None instead of raising when a read fails
- You want the base install to just work. pip install imageio cannot open an MP4, an HEIC photo, a camera raw file, a DICOM volume, or a FITS image. Each needs a separate extra, and you find out at call time with 'Could not find a backend to open ... Based on the extension, the following plugins might add capable backends', not at import
- You are following an old tutorial. The library has two live APIs: imageio.imread is v2 and emits a DeprecationWarning saying the behavior will change in v3, while imageio.v3.imread is the current one. Most search results are v2, the argument names differ, and mixing them in one codebase is how you end up with a GIF read as one frame in one place and as a stacked batch in another
- You need predictable behavior across formats. The plugin manager is the feature and also the catch: the same imread call returns a (H, W, 3) array for a PNG and a (N, H, W, 3) array for a GIF because the chosen plugin decides, and keyword arguments you pass through go to whichever backend answered
Setup reality
pip install imageio pulls only NumPy and Pillow and needs Python 3.10 or newer, which is genuinely light for what it covers. The extras are where the real decision is. Video needs pip install 'imageio[ffmpeg]' (which brings imageio-ffmpeg, a wheel with a bundled ffmpeg binary of roughly 20 to 30 MB depending on platform, plus psutil) or pip install 'imageio[pyav]' for the PyAV backend. TIFF beyond what Pillow handles needs tifffile, HEIC needs pillow-heif, camera raw needs rawpy, FITS needs astropy, and medical volumes need itk or SimpleITK. There is an 'imageio[all-plugins]' target and a 'full' extra if you would rather not think about it, at the cost of a heavy environment. Two things bite people after install. First, missing backends fail at call time, not import time, with an OSError that at least names the pip command you need. Second, imwrite validates nothing about your dtype: hand it a float64 array and Pillow raises 'Cannot handle this data type', so convert to uint8 yourself. Start every new file with import imageio.v3 as iio, because the bare imageio namespace is the v2 API and will warn at you. The imageio: URI prefix loads bundled sample images such as imageio:chelsea.png, which is handy for testing and fetches over the network the first time.
Patterns
Read anything into an array, write it back outread-and-write
import imageio.v3 as iio
im = iio.imread("photo.jpg") # ndarray, shape (H, W, 3), dtype uint8
im.shape, im.dtype
iio.imwrite("photo.png", im) # format comes from the extension
iio.imwrite("small.jpg", im, quality=50) # kwargs go to the backend (Pillow here)
# force grayscale on read
gray = iio.imread("photo.jpg", mode="L") # shape (H, W)
# bundled sample images, useful in tests
chelsea = iio.imread("imageio:chelsea.png")Always import imageio.v3 as iio. The bare imageio.imread is the v2 API and raises a DeprecationWarning saying the behavior will switch. imwrite does not convert dtypes for you: passing a float array to a PNG gives 'TypeError: Cannot handle this data type', so do (arr * 255).astype('uint8') yourself. Extra keyword arguments are forwarded to whichever plugin answered, so quality works for JPEG and means nothing for PNG.
The index argument, and why a GIF is a 4D arraymulti-frame-index
import imageio.v3 as iio
frames = iio.imread("anim.gif") # (N, H, W, 3) plugin decided: all frames
first = iio.imread("anim.gif", index=0) # (H, W, 3) one frame
stack = iio.imread("anim.gif", index=...) # (N, H, W, 3) explicitly all frames
page3 = iio.imread("scan.tif", index=3) # page 3 of a multi-page TIFFindex=None (the default) means 'let the plugin decide', and the Pillow plugin decides to stack every frame of a GIF, so code written for still images gets a 4D array and breaks somewhere downstream. Be explicit: index=0 for one frame, index=... for the batch. The same argument controls improps and immeta. With v2's imageio.imread the same GIF returns only the first frame, which is exactly the inconsistency the deprecation warning is about.
Iterate frames instead of loading everythingstream-large-files
import imageio.v3 as iio
import numpy as np
total = None
count = 0
for frame in iio.imiter("long_video.mp4", plugin="pyav"):
total = frame.astype(np.float64) if total is None else total + frame
count += 1
mean_frame = (total / count).astype(np.uint8)imread with index=... reads every frame into one array, which for a few minutes of 1080p video is tens of gigabytes. imiter yields them one at a time and is the only sane option above a few hundred frames. The yielded array is converted with np.asarray each iteration, and some backends reuse their internal buffer, so copy the frame if you intend to keep it past the next loop step.
Write a GIF or an MP4 from a stack of frameswrite-animation
import imageio.v3 as iio
import numpy as np
frames = np.stack([make_frame(t) for t in range(60)]) # (60, H, W, 3) uint8
# GIF: duration is per frame in milliseconds, loop=0 means forever
iio.imwrite("out.gif", frames, duration=100, loop=0)
# MP4: needs pip install 'imageio[ffmpeg]' or 'imageio[pyav]'
iio.imwrite("out.mp4", frames, fps=30)
# incremental writing, so you never hold every frame in memory
with iio.imopen("out.mp4", "w", plugin="pyav") as f:
f.init_video_stream("libx264", fps=30)
for t in range(6000):
f.write_frame(make_frame(t))GIF duration is milliseconds per frame in current versions; older tutorials pass seconds and produce an animation 1000 times too slow. H.264 encoders generally require both dimensions to be even, so an odd-height array fails inside ffmpeg with a message that does not mention your array. Without the ffmpeg or pyav extra the MP4 write raises OSError listing the exact pip command, which is the most helpful part of the whole error surface.
Sources other than a file pathbytes-and-urls
import imageio.v3 as iio
import requests
# http(s) URL
im = iio.imread("https://example.com/cat.png")
# raw bytes, e.g. from a request or a database blob
blob = requests.get("https://example.com/cat.png").content
im = iio.imread(blob)
# encode to bytes without writing a file (extension picks the format)
png_bytes = iio.imwrite("<bytes>", im, extension=".png")
# any file-like object
with open("cat.png", "rb") as fh:
im = iio.imread(fh)The literal string '<bytes>' as the target is what makes imwrite return the encoded image instead of None, and you must pass extension so it knows which encoder to use. When reading raw bytes there is no filename to sniff, so pass extension='.tif' if detection picks the wrong backend. URL reads are not cached and have no timeout you control from here, so fetch with requests yourself for anything that runs in production.
improps for standardized fields, immeta for everything elsemetadata
import imageio.v3 as iio
props = iio.improps("scan.tif")
props.shape, props.dtype, props.n_images, props.is_batch, props.spacing
meta = iio.immeta("photo.jpg")
meta.get("EXIF_MAIN", {}).get("Orientation")
# per-frame metadata in a multi-image file
iio.immeta("anim.gif", index=2)improps is deliberately format-independent and tries to avoid decoding pixels, so it is the cheap way to get the shape of a large file. immeta returns whatever the backend exposes, so the keys differ between a JPEG, a TIFF, and a DICOM and you cannot write code against them without checking the plugin's docs. Neither call applies EXIF rotation for you: imread returns the pixels as stored, so a phone photo may come back sideways with Orientation sitting in the metadata.
Pin the backend when the automatic choice is wrongchoose-a-plugin
import imageio.v3 as iio
iio.imread("scan.tif", plugin="tifffile") # not Pillow's limited TIFF support
iio.imread("clip.mp4", plugin="pyav") # not the ffmpeg subprocess backend
iio.imread("clip.mp4", plugin="FFMPEG")
# a file with a wrong or missing extension
iio.imread(unnamed_bytes, extension=".png")
# keep one handle open across many calls
with iio.imopen("scan.tif", "r", plugin="tifffile") as f:
n = f.properties(index=...).n_images
pages = [f.read(index=i) for i in range(n)]plugin takes priority over extension, and extension only reorders the candidate list rather than forcing a decoder. If nothing can open the file you get OSError: 'Could not find a backend to open ... Based on the extension, the following plugins might add capable backends', followed by the pip commands, which is usually the real answer. imopen avoids reopening and reparsing the header for every page, which matters for large multi-page TIFFs.
Pull specific frames out of a videovideo-frames
import imageio.v3 as iio
props = iio.improps("clip.mp4", plugin="pyav")
props.shape # (n_frames, H, W, 3) when the container reports a count
frame_100 = iio.imread("clip.mp4", index=100, plugin="pyav")
meta = iio.immeta("clip.mp4", plugin="pyav")
meta.get("fps"), meta.get("duration")
# every 30th frame without decoding into memory twice
keep = [f for i, f in enumerate(iio.imiter("clip.mp4", plugin="pyav")) if i % 30 == 0]Frame indexing into video is approximate by nature: with the FFMPEG backend index=100 means decoding the first hundred frames and throwing them away, and with pyav it becomes a container seek to the nearest keyframe followed by decoding forward, so a variable-frame-rate file can hand you a neighbouring frame. If exact frame identity matters, use PyAV directly and work with presentation timestamps. Frame counts reported by containers are often estimates too.
Keep 16-bit data 16-bitdtype-and-bit-depth
import imageio.v3 as iio
import numpy as np
depth = iio.imread("depth16.png")
depth.dtype # uint16, preserved
# writing 16-bit: PNG and TIFF keep it, JPEG cannot
iio.imwrite("out.png", depth)
iio.imwrite("out.tif", depth, plugin="tifffile")
# float data needs an explicit conversion before a uint8 format
float_img = np.clip(some_float_array, 0, 1)
iio.imwrite("out.png", (float_img * 255).astype(np.uint8))
# scientific float TIFF, no scaling
iio.imwrite("raw.tif", some_float_array.astype(np.float32), plugin="tifffile")imageio never rescales silently, which is the behavior you want and also the source of most write failures: a float64 array handed to Pillow raises 'Cannot handle this data type: (1, 1, 3), <f8'. JPEG is 8-bit only, so a uint16 array written to .jpg either errors or is truncated depending on the backend. tifffile is the plugin to use for float or multi-channel scientific data, and it is a separate install.
Translating the v2 calls you found on Stack Overflowmigrate-from-v2
# v2 (deprecated) -> v3
# imageio.imread(f) -> iio.imread(f, index=0)
# imageio.mimread(f) -> iio.imread(f, index=...)
# imageio.volread(f) -> iio.imread(f, index=...)
# imageio.imwrite(f, im) -> iio.imwrite(f, im)
# imageio.mimwrite(f, frames) -> iio.imwrite(f, frames)
# imageio.get_reader(f) -> iio.imiter(f) / iio.imopen(f, 'r')
# imageio.get_writer(f) -> iio.imopen(f, 'w')
import imageio.v2 as iio2 # explicit v2 import: no deprecation warning
import imageio.v3 as iio # what new code should useIf you need to keep v2 behavior for now, import imageio.v2 as iio2 rather than plain imageio; the explicit import is supported and silences the warning, so you can migrate file by file. The trap while migrating is imread: v2 returns the first frame of a multi-frame file, v3 lets the plugin decide and usually returns all of them. v2 also returns an Array subclass of ndarray carrying a .meta attribute, which v3 dropped in favour of immeta.
Batch convert a folder, handling the failuresconvert-directory
from pathlib import Path
import imageio.v3 as iio
src, dst = Path("raw"), Path("web")
dst.mkdir(exist_ok=True)
for p in sorted(src.iterdir()):
try:
im = iio.imread(p)
except OSError as e: # no backend for this format
print(f"skip {p.name}: {e}")
continue
except Exception as e: # truncated or corrupt file
print(f"bad {p.name}: {type(e).__name__}")
continue
if im.ndim == 4: # a GIF or multi-page file slipped in
im = im[0]
iio.imwrite(dst / f"{p.stem}.webp", im, quality=80)Two guards earn their keep in any batch job. The ndim == 4 check catches multi-frame files that imread stacked, which is otherwise a confusing error inside the writer. And catch OSError separately from everything else: a missing backend is a fixable environment problem that should be reported loudly, while a corrupt input is just a bad file. Pillow may also raise on truncated JPEGs unless you set ImageFile.LOAD_TRUNCATED_IMAGES.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pillow | PyPI | You handle common web formats and need to resize, crop, convert, or draw, since imageio depends on Pillow anyway and gives you no processing of its own |
| opencv-python | PyPI | You are already doing computer vision and want fast decoding plus processing plus video capture in one dependency, and you can live with BGR channel order |
| av | PyPI | Video is the actual workload and you need seeking, timestamps, audio streams, or codec options rather than a pipe of raw frames |