scikit-image
scikit-image (imported as skimage) is the scientific Python ecosystem's image processing library: classical algorithms like filtering, thresholding, segmentation, feature detection, morphology, and measurement, all operating on plain NumPy arrays. It is the sibling of scipy and scikit-learn in style: well-documented functions with citations to the papers they implement, aimed at analysis and measurement (microscopy, medical, satellite imagery) rather than at real-time vision or deep learning.
The best-documented classical image processing library in Python and the right default for scientific measurement work on NumPy arrays. Know what it is not: not fast enough for real-time, not GPU-aware, and not a deep learning tool, so pair it accordingly.
Use it if
- You are measuring things in images: label connected regions with measure.label and pull area, centroid, and eccentricity per object via regionprops
- You need classical algorithms with documented provenance (Otsu thresholding, watershed, Canny, CLAHE) and a gallery example to start from
- Your images are already NumPy arrays in a scipy/pandas/matplotlib workflow, and you want functions that compose with that stack instead of a separate framework
- You work with scientific formats and channels beyond RGB photos: 16-bit TIFF stacks, 3D volumes, and float images are first-class here
- You need real-time or video-rate performance: it runs classical algorithms on CPU NumPy arrays, and opencv-python is routinely several times faster for the same operation
- Your task is recognition, detection, or semantic segmentation: those are deep learning problems now, and this library deliberately stays classical; you want torchvision or a model zoo
- You are GPU-bound: there is no CUDA path here, so batch-processing large volumes on GPU points you at kornia or CuPy-based tools instead
- You copy old tutorials verbatim: years of 0.x deprecation cycles renamed common arguments (multichannel became channel_axis, selem became footprint), so pre-2022 snippets often warn or fail on 0.26
Setup reality
pip install scikit-image ships wheels for all major platforms, so installs are painless, but it drags in scipy, networkx, pillow, imageio, tifffile, and lazy-loader, which is a chunky tree if you only wanted one filter. Building from source is a different story: meson, ninja, Cython, and pythran are required, so stick to wheels or conda-forge. The recurring runtime surprise is dtype convention: functions assume float images live in [0, 1], and mixing uint8 [0, 255] arrays with float outputs without img_as_float produces silently wrong math rather than errors.
Patterns
Load an image and normalize dtyperead-image-as-float
from skimage import io, util
img = io.imread("scan.png") # usually uint8, [0, 255]
imgf = util.img_as_float(img) # float64, [0, 1]Most skimage functions assume float images sit in [0, 1]. Passing raw uint8 into math that expects floats gives wrong results silently, so convert at the boundary and stay float inside.
Convert RGB to grayscalergb-to-grayscale
from skimage.color import rgb2gray
gray = rgb2gray(img) # float in [0, 1], shape (H, W)rgb2gray rejects RGBA; strip the alpha channel first with rgba2rgb or img[..., :3]. The output is always float regardless of input dtype.
Automatic thresholding with Otsuotsu-threshold
from skimage.filters import threshold_otsu
t = threshold_otsu(gray)
mask = gray > t # boolean foreground maskOtsu assumes a bimodal histogram; on uneven illumination it fails badly, and threshold_local (adaptive) is the fix. The function returns the threshold value, not the mask.
Count and measure objects in a masklabel-and-measure-regions
from skimage import measure
labels = measure.label(mask)
props = measure.regionprops_table(
labels, properties=("label", "area", "centroid", "eccentricity"))
import pandas as pd
df = pd.DataFrame(props)regionprops_table returns plain dict-of-arrays ready for pandas, which beats looping over regionprops objects. Remember label 0 is background and is excluded.
Resize with proper anti-aliasingresize-rescale
from skimage.transform import resize, rescale
small = resize(img, (256, 256), anti_aliasing=True)
half = rescale(img, 0.5, anti_aliasing=True, channel_axis=-1)Output is float in [0, 1] even for uint8 input; use img_as_ubyte to go back. For multichannel images pass channel_axis=-1 or the channels get interpolated as a spatial dimension.
Canny edge detectionedge-detection-canny
from skimage.feature import canny
edges = canny(gray, sigma=2.0) # boolean edge mapsigma is the knob that matters: low values keep noise as edges, high values drop fine detail. Canny lives in skimage.feature, not skimage.filters, which trips people up.
Clean a binary mask with morphologymorphology-cleanup
from skimage.morphology import binary_opening, remove_small_objects, disk
clean = binary_opening(mask, footprint=disk(3))
clean = remove_small_objects(clean, min_size=64)The structuring element argument is footprint; old tutorials say selem, which is the removed pre-0.19 name. remove_small_objects wants a boolean array, not 0/255 uint8.
Split touching objects with watershedwatershed-split-touching
import numpy as np
from scipy import ndimage as ndi
from skimage.feature import peak_local_max
from skimage.segmentation import watershed
dist = ndi.distance_transform_edt(mask)
coords = peak_local_max(dist, footprint=np.ones((3, 3)), labels=mask)
markers = np.zeros(dist.shape, dtype=int)
markers[tuple(coords.T)] = np.arange(1, len(coords) + 1)
labels = watershed(-dist, markers, mask=mask)Watershed floods from markers over the negated distance map; bad markers mean over-segmentation. peak_local_max returns coordinates now, not a boolean image as in very old examples.
Denoise while keeping edgesdenoise
from skimage.restoration import denoise_tv_chambolle
smooth = denoise_tv_chambolle(imgf, weight=0.1, channel_axis=-1)Total-variation denoising preserves edges better than a Gaussian blur but flattens texture; tune weight down if results look like posterized paint. Input should be float.
Adaptive histogram equalization (CLAHE)clahe-contrast
from skimage import exposure
better = exposure.equalize_adapthist(gray, clip_limit=0.02)equalize_adapthist internally works on floats and returns [0, 1]. It amplifies noise in flat regions, so denoise first if the input is grainy.
Find a template inside an imagetemplate-matching
import numpy as np
from skimage.feature import match_template
result = match_template(gray, template)
y, x = np.unravel_index(np.argmax(result), result.shape)The peak gives the template's top-left corner, not its center. Correlation is not scale or rotation invariant; if the object varies in size, this is the wrong tool.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| opencv-python | PyPI | Speed, video capture, or real-time pipelines matter more than API clarity |
| pillow | PyPI | You only need loading, resizing, cropping, and format conversion |
| kornia | PyPI | You want image operations on GPU tensors inside a PyTorch pipeline |
| torchvision | PyPI | The task is deep learning on images rather than classical analysis |