dask

Introduction to Dask-image#

Dask-image is a Dask sub-package specialized for handling images. It supports many of the functions from scipy.ndimage for Dask.arrays

  • dask_image.imread

  • dask_image.ndfilters

  • dask_image.ndfourier

  • dask_image.ndinterp

  • dask_image.ndmeasure

  • dask_image.ndmorph

In this notebook, we will use Dask-image for lazy loading a large image and filtering it.

Acknowledgements

This notebook was part of the 2023 workshop on Accelerated large-scale image procesing in Python. We kindly acknowledge them for sharing their training material with us!

Lazy loading with Dask_image.imread#

import os
import dask.array as da
import dask_image.imread
import urllib.request
import numpy as np
import skimage.io
import itertools
from pathlib import Path
import matplotlib.pyplot as plt
def show_images(images, zoom=False, titles=None, cmap="gray", figsize=(20,10)):
    zoom_mask = (slice(200, 800), slice(2320, 2750))
    if cmap is None or isinstance(cmap, str):
        cmap = [cmap, ] * len(images)
    if titles is None or isinstance(titles, str):
        titles = [titles, ] * len(images)
    if zoom is None or isinstance(zoom, bool):
        zoom = [zoom, ] * len(images)
    fig, axs = plt.subplots(1, len(images), figsize=figsize)
    for ax, image in enumerate(images):
        if zoom[ax]:
            axs[ax].imshow(image[zoom_mask], cmap=cmap[ax])
            axs[ax].set_title(titles[ax] + "(zoom)")
        else:
            axs[ax].imshow(image, cmap=cmap[ax])
            axs[ax].set_title(titles[ax])
        axs[ax].axis("off")
    fig.tight_layout()
# Download image -- this can take a bit (approx. 100Mb)
url = "https://stsci-opo.org/STScI-01EVSZWCFZVP2R5ZRV7HEZAGP6.tif"

data_path = Path(os.path.expanduser(
    os.path.join(os.getenv("XDG_DATA_HOME", "~"), ".field-guide")
))

fname = 'hubble.tif'

hubble_image = os.path.join(data_path, fname)
urllib.request.urlretrieve(url, hubble_image)

def chunk_image(image, chunk_size, output_dir):
    shape = np.array(image.shape)
    chunk = np.array(chunk_size)
    chunk_counts = shape // chunk
    for ids in itertools.product(*[np.arange(0, count) for count in chunk_counts]):
        slices = tuple([slice(i * ch, (i + 1) * ch) for i, ch in zip(ids, chunk)])
        string = "-".join([str(i).zfill(2) for i in ids])
        skimage.io.imsave(
            os.path.join(output_dir, f"image-{string}.png"), (image[slices] * 255).astype(np.uint8),
            check_contrast=False
        )

# Crop/chunk size
chunk_size = (256, 256, 3)

# Save directory
output_dir = os.path.join(data_path, "hubble")
Path(os.path.join(data_path, "hubble")).mkdir(exist_ok=True)

from skimage.io import imread
from skimage.util import img_as_float

img = img_as_float(imread(hubble_image))

# Run cropping function
chunk_image(img, chunk_size, output_dir)
filename_pattern = os.path.join(data_path, "hubble", "image-*.png")
tiled_hubble_images = dask_image.imread.imread(filename_pattern)

# Convert image to float
tiled_hubble_images = tiled_hubble_images.astype(float) / 255.0
tiled_hubble_images
Array Chunk
Bytes 0.98 GiB 1.50 MiB
Shape (672, 256, 256, 3) (1, 256, 256, 3)
Dask graph 672 chunks in 5 graph layers
Data type float64 numpy.ndarray
672 1 3 256 256
# REMINDER: Dask is lazy by default
from sys import getsizeof
print(f"Size of `dask-image`: {getsizeof(tiled_hubble_images)} bytes")
Size of `dask-image`: 80 bytes
show_images(
    [
        tiled_hubble_images[0],
        tiled_hubble_images[5],
        tiled_hubble_images[10],
        tiled_hubble_images[15],
        tiled_hubble_images[20],
    ]
)
../../../_images/ec4462711ae00c6685d2b725e0e2460bf5c84fa5b25f9ccfa225b2c6e584d028.png

2) Applying your own custom function to images#

Next you’ll want to do some image processing, and apply a function to your images.

We’ll use a very simple example: converting an RGB image to grayscale.

To convert our image to grayscale, we’ll use the equation to calculate luminance:

Y = 0.2125 R + 0.7154 G + 0.0721 B

We’ll write the function for this equation as follows:

def grayscale(rgb):
    result = (rgb[..., 0] * 0.2125) + (rgb[..., 1] * 0.7154) + (rgb[..., 2] * 0.0721)
    return result

While NumPy attempts to optimize memory usage, in-place operations (where memory is reused) aren’t always possible, especially when dealing with complex arithmetic operations. During the computation within the grayscale function, NumPy creates temporary arrays. When you perform operations like rgb[..., 0] * 0.2125, it results in intermediate arrays being created in memory.

# Demo: Temporary Arrays and In-Place Operations

As you can see, this operation requires to have an extra copy of (one channel of) the input image in memory. Many operations require the creation, even temporarily, of array of the same size as the image. This can easily pose a bottleneck in our pipeline!

Let’s apply our custom function to the a single chunk of our image and visualize the computation graph.

TIP: Visualizing the computation graph isn’t necessary but most of the time it’s helpful to know what dask is doing under the hood, and it can also be very useful for debugging problems.

hubble_image = tiled_hubble_images[20].persist()

plt.imshow(hubble_image)
<matplotlib.image.AxesImage at 0x77e7103bef40>
../../../_images/9b9ce0d4bc346c7a6cd6da5f3492ff74a496d58c90fae4b2746b9fdff63fe47d.png
result_single_image = grayscale(hubble_image)
print(result_single_image)
result_single_image.visualize()
dask.array<add, shape=(256, 256), dtype=float64, chunksize=(256, 256), chunktype=numpy.ndarray>
../../../_images/901ab5131abf7cf378038f0f3ce5fdd0c625b03d4f3614180a0d0e3eb841d88a.png
print("Original image dimensions: ", hubble_image.shape)
print("Processed image dimensions:", result_single_image.shape)

show_images(
    [hubble_image, result_single_image],
    titles=["Original image", "Processed image"],
    cmap=[None, "gray"],
)
Original image dimensions:  (256, 256, 3)
Processed image dimensions: (256, 256)
../../../_images/a9cf7acfa678852812fb0630c3df8c49bd4bce7a194b8dc8b3a482174f3ec623.png

Embarrassingly parallel problems#

The syntax is identical to apply a function to multiple images or Dask chunks. This is an example of an embarrassingly parallel problem, and we see that Dask automatically creates a computation graph for each image chunk.

result = grayscale(tiled_hubble_images[:5].persist())
print(result)
result.visualize()
dask.array<add, shape=(5, 256, 256), dtype=float64, chunksize=(1, 256, 256), chunktype=numpy.ndarray>
../../../_images/f68db9a25332ea45cd17001d81c5d7b259514af85dcbb3ae4b020a5e662970aa.png
result = grayscale(tiled_hubble_images)
show_images(
    [
        result[0],
        result[5],
        result[10],
        result[15],
        result[20],
    ]
)
../../../_images/1f86ff28645daa175f9c19aa289df035338b6467cd46cf88815c9ac381d15853.png

Let’s do some Dask-image processing#

For illustration purposes, let’s perform some edge enhancement of our sky image.

A very simple approach for edge enhancement is the following:

\[\hat{\mathbf{I}} = (1-\alpha) \mathbf{I} + \alpha \vert \mathbf{I} - \bar{\mathbf{I}}_{\sigma} \vert \]

Where:

  • \(\hat{\mathbf{I}}\) is the enhenced image,

  • \(\mathbf{I}\) is the original image,

  • \(\bar{\mathbf{I}}_{\sigma} \) is a smoothed version of \(\mathbf{I}\), with smoothing parameter \(\sigma\), and

  • \(\alpha \in [0, 1]\) controls the level of enhancement.

Let’s complete the following function edge_enhancement(image, sigma) that uses dask_image.ndfilters.gaussian_filter:

def edge_enhancement(image, sigma, alpha):
    """    
    Parameters
    ----------
    image: Dask.array (2d)
        Input image to enhance.
    sigma: float
        Smoothing parameter.
    alpha: float [0, 1]
        
        
    Returns
    -------
    out_image: Dask.array (2d)
        Edge enhanced image.
    """
    
    return NotImplementedError
import dask_image.ndfilters

def edge_enhancement(image, sigma, alpha):
    """    
    Parameters
    ----------
    image: Dask.array (2d)
        Input image to enhance.
    sigma: float
        Smoothing parameter.
    alpha: float [0, 1]
        
        
    Returns
    -------
    out_image: Dask.array (2d)
        Edge enhanced image.
    """
    assert sigma > 0
    assert  0 <= alpha <= 1
    smoothed_image = dask_image.ndfilters.gaussian_filter(image, sigma=sigma)
    edge_enhanced_image = abs(image - smoothed_image)
    edge_enhanced_image += alpha * image
    return edge_enhanced_image

Join processed chunks#

Because our edge_enhancement function relies on a convolution (with a smoothing kernel), we need to concatenate the small image chunks together into the shape of the image.

This is important when doing convolutions or other analysis that process the neighborhood of each pixel.

This joined image will still be a lazy array, but each chunk will access the edge values of the neighboring chunks.

Note: We could have done this in the beginning too.

# Toy Dataset for visualisation
data = [result[20], result[21]]
toy_combined_image = da.block(data).persist()
toy_combined_image
Array Chunk
Bytes 1.00 MiB 512.00 kiB
Shape (256, 512) (256, 256)
Dask graph 2 chunks in 1 graph layer
Data type float64 numpy.ndarray
512 256
edge_enhanced_toy = edge_enhancement(toy_combined_image.persist(), 
                                     sigma=20, 
                                     alpha=0.5)

Now try to match the different parts of the task graph with the edge enhancement equation:

\[\hat{\mathbf{I}} = (1-\alpha) \mathbf{I} + \alpha \vert \mathbf{I} - \bar{\mathbf{I}}_{\sigma} \vert \]
edge_enhanced_toy
Array Chunk
Bytes 1.00 MiB 512.00 kiB
Shape (256, 512) (256, 256)
Dask graph 2 chunks in 9 graph layers
Data type float64 numpy.ndarray
512 256
edge_enhanced_toy.visualize(rankdir="LR")
../../../_images/ad87879418853cbccf860df75987dcde3581719594aff189d1e087d51a3773c0.png

The toy dataset is useful to understand the task graph, and it can also be used to select good values for \(\sigma\) and \(\alpha\):

alphas = [0.1, 0.5]
sigmas = [1, 10, 20]

for alpha, sigma in itertools.product(alphas, sigmas):
    edge_enhanced_toy = edge_enhancement(toy_combined_image, 
                                     sigma=sigma, 
                                     alpha=alpha)
    # Normalize between 0 and 1
    edge_enhanced_toy -= edge_enhanced_toy.min()
    edge_enhanced_toy /= edge_enhanced_toy.max()
    
    show_images(
        [toy_combined_image, edge_enhanced_toy],
        titles=["Original", fr"Enh ($\sigma={sigma}$, $\alpha={alpha}$)"],
        cmap="cubehelix"
    )
../../../_images/cea09dc75b47256f35580cd92e99f4691923978f29794b1713b2fdeeafe84864.png ../../../_images/864bf4f4a4ea0af8452ed932f52072db7158008fe74fbfc10c4c69bed975bd9c.png ../../../_images/2934537f7b789fce30643fbd40f121936e22a025e0c3d310d10d8b8f45c5498c.png ../../../_images/47e50dd689700f329697c2a8fd1b8ede378a89b418e2e65d0197c281a5fb1877.png ../../../_images/4b784fd7946aee76f8492b4f52ffee39c057e650b8ecd00ae5dcee8ac766ccf3.png ../../../_images/949df142fb2710f20adcdf31a4a7b131d2f4c6ce9fb956516391b95a240ede0c.png

Final step: run full pipeline on RGB dataset, and save for later.#

# Enhancing parameters
sigma = 20
alpha = 0.5

# Full Dataset
edge_enhanced_image = []
combined_image = []
# For each RGB
for j in range(3):
    # Join chunks to have access to neighborhoods
    data = [
        [img[..., j] for img in tiled_hubble_images[i * 28 : (i + 1) * 28]]
        for i in range(24)
    ]
    combined_image.append(da.block(data))
    
    # Join chunks to have access to neighborhoods
    edge_enh = edge_enhancement(combined_image[j], sigma=sigma, alpha=alpha)
     # Normalize between 0 and 1
    edge_enh -= edge_enh.min()
    edge_enh /= edge_enh.max()
    edge_enhanced_image.append(edge_enh)

# Convert list to dask array
edge_enhanced_image = da.dstack(edge_enhanced_image)
combined_image = da.dstack(combined_image)
edge_enhanced_image
Array Chunk
Bytes 0.98 GiB 512.00 kiB
Shape (6144, 7168, 3) (256, 256, 1)
Dask graph 2016 chunks in 2862 graph layers
Data type float64 numpy.ndarray
3 7168 6144
show_images(
    [
        combined_image.blocks[0, 20],
        edge_enhanced_image.blocks[0, 20],
        abs((combined_image-edge_enhanced_image).blocks[0, 20]).clip(0,1),
    ],
    titles=["Original", fr"Edge Enhanced ($\sigma={sigma}$, $\alpha={alpha}$)", "Difference"],
)
../../../_images/2e239e305e22add203fd8beb47953cb96317c18143bb8e4133797b8f307dce3e.png