Image Classification with MultiRasterIO

In this tutorial it is shown how to use the class MultiRasterIO to perform image classification. We will

  • extract a training dataset (see tutorial Extract Raster Values of Pixels Overlapping with Vector Data),

  • build a model,

  • classify the raster dataset with the model.

We will run through the first two steps and focus on the last here.

[3]:
import fnmatch
import geopandas as gpd
import matplotlib.pyplot as plt
import numpy as np
import os
import pandas as pd
from pathlib import Path
from sklearn.ensemble import RandomForestClassifier

from eobox import raster
from eobox import sampledata

Extract Reference Data

[4]:
dataset = sampledata.get_dataset("s2l1c")

src_vector = dataset["vector_file"]
burn_attribute = "pid"  # should be unique for the polygons and not contain zero
src_raster = sorted(fnmatch.filter(dataset["raster_files"], "*B0[2,3,4,8]*"))  # 10 m bands
feature_names = ["_".join(Path(src).stem.split("_")[1::]) for src in src_raster]
extraction_dir = Path("s2l1c_ref__s2l1c/s2_l1c/10m")

raster.extract(src_vector=src_vector,
                    burn_attribute=burn_attribute,
                    src_raster=src_raster,
                    dst_names=feature_names,
                    dst_dir=extraction_dir)

df_extracted = raster.load_extracted(extraction_dir)
gdf = gpd.read_file(src_vector)
df_extracted = df_extracted.merge(gdf[["pid", "cid", "comment"]], how="left", left_on="aux_vector_pid", right_on="pid")
df_extracted.head(2)
[4]:
aux_coord_y 20170216T102101_B02 20170216T102101_B03 aux_coord_x aux_vector_pid 20170216T102101_B04 20170216T102101_B08 pid cid comment
0 5822035.0 1456 1072 344545.0 17 928 672 17 1 partially out; expecting: 2 - with default ras...
1 5822035.0 1392 1040 344555.0 17 928 672 17 1 partially out; expecting: 2 - with default ras...

Build a Model

Lets train a Random Forest model with the whole reference data on four Sentinel-2 bands.

With the extracted data that can be done as follows:

[5]:
y = df_extracted["cid"].values
X = df_extracted[feature_names].values
clf = RandomForestClassifier(n_estimators=100)
clf.fit(X, y)
[5]:
RandomForestClassifier(bootstrap=True, class_weight=None, criterion='gini',
            max_depth=None, max_features='auto', max_leaf_nodes=None,
            min_impurity_decrease=0.0, min_impurity_split=None,
            min_samples_leaf=1, min_samples_split=2,
            min_weight_fraction_leaf=0.0, n_estimators=100, n_jobs=1,
            oob_score=False, random_state=None, verbose=0,
            warm_start=False)

Prediction of Probabilities

This section is the focus of this notebook. We will see show how to predict the image by using the model trained in the last section and the MultiRasterIO class. As in the tutorial on NDVI calculation with MultiRasterIO we first setup the class instance and get a window of data for developing the prediction.

[6]:
mr = raster.MultiRasterIO(layer_files=src_raster)
mr.block_windows()
arr = mr.get_arrays(0)
arr.shape
[6]:
(64, 64, 4)

We get the data as a three dimensional array where the dimensions are (X, Y, Layers). However the predict* methods of a sklearn estimator require the features in a two dimensional array where the dimensions are (Sample = Pixel, Features = Layers).

We can use the PixelClassifier from the s2cloudless package or ImagePixelClassifier from the eolearn.ml_tools package both developed by Synergise.

If you do not have these packages installed you can simply copy and paste the PixelClassifier from the PixelClassifier.py module in the sentinel2-cloud-detector project as has it been done to fill the following cell.

[7]:
class PixelClassifier(object):
    """
    Pixel classifier extends a receptive field of a classifier over an entire image.
    The classifier's receptive field is in case of PixelClassifier a pixel (i.e, it
    has dimension of (1,1))

    Pixel classifier divides the image into individual pixels, runs classifier over
    them, and finally produces a classification mask of the same size as image.

    The classifier can be of any type as long as it has the following two methods
    implemented:
        - predict(X)
        - predict_proba(X)

    This is true for all classifiers that follow scikit-learn's API.
    The APIs of scikit-learn's objects is described
    at: http://scikit-learn.org/stable/developers/contributing.html#apis-of-scikit-learn-objects.

    :param classifier: trained classifier that will be executed over an entire image
    :type classifier: any classifier with predict(X) and predict_proba(X) methods
    """

    def __init__(self, classifier):
        self.receptive_field = (1, 1)
        self._check_classifier(classifier)
        self.classifier = classifier

    def _check_classifier(self, classifier):
        """
        Check if the classifier implements predict and predict_proba methods.
        """
        predict = getattr(classifier, "predict", None)
        if not callable(predict):
            raise ValueError('Classifier does not have a predict method!')

        predict_proba = getattr(classifier, "predict_proba", None)
        if not callable(predict_proba):
            raise ValueError('Classifier does not have a predict_proba method!')

    @staticmethod
    def extract_pixels(X):
        """ Extract pixels from array X

        :param X: Array of images to be classified.
        :type X: numpy array, shape = [n_images, n_pixels_y, n_pixels_x, n_bands]
        :return: Reshaped 2D array
        :rtype: numpy array, [n_samples*n_pixels_y*n_pixels_x,n_bands]
        :raises: ValueError is input array has wrong dimensions
        """
        if len(X.shape) != 4:
            raise ValueError('Array of input images has to be a 4-dimensional array of shape'
                             '[n_images, n_pixels_y, n_pixels_x, n_bands]')

        new_shape = (X.shape[0] * X.shape[1] * X.shape[2], X.shape[3],)
        pixels = X.reshape(new_shape)
        return pixels

    def image_predict(self, X):
        """
        Predicts class label for the entire image.

        :param X: Array of images to be classified.
        :type X: numpy array, shape = [n_images, n_pixels_y, n_pixels_x, n_bands]

        :return: raster classification map
        :rtype: numpy array, [n_samples, n_pixels_y, n_pixels_x]
        """

        pixels = self.extract_pixels(X)

        predictions = self.classifier.predict(pixels)

        return predictions.reshape(X.shape[0], X.shape[1], X.shape[2])

    def image_predict_proba(self, X):
        """
        Predicts class probabilities for the entire image.

        :param X: Array of images to be classified.
        :type X: numpy array, shape = [n_images, n_pixels_y, n_pixels_x, n_bands]

        :return: classification probability map
        :rtype: numpy array, [n_samples, n_pixels_y, n_pixels_x]
        """

        pixels = self.extract_pixels(X)

        probabilities = self.classifier.predict_proba(pixels)

        return probabilities.reshape(X.shape[0], X.shape[1], X.shape[2], probabilities.shape[1])

In the docstring we can read that the prediction methods want the data in the format [n_images, n_pixels_y, n_pixels_x, n_bands]. So let us expand the dimensions to fulfil the requirement and predict the array.

[8]:
pclf = PixelClassifier(clf)
probs = pclf.image_predict_proba(np.expand_dims(arr, 0))
probs = (probs * 100).astype(np.uint8)  # lets store the probabilities as integer later
print("Shape of input array:", np.expand_dims(arr, 0).shape)
print("Shape of output arrray:", probs.shape)
Shape of input array: (1, 64, 64, 4)
Shape of output arrray: (1, 64, 64, 2)

Now we can plot the result and check if it makes sense. Since there is a small lake in the upper left corner of the image (for which we loaded the data) it seems to make sense.

[9]:
fig, axes = plt.subplots(1,2, figsize=(20, 10))

axes.flatten()[0].imshow(probs[0,:,:,0], vmin=0, vmax=100)
axes.flatten()[1].imshow(probs[0,:,:,1], vmin=0, vmax=100)
fig.tight_layout()
../../_images/examples_raster_multirasterio_classification_13_0.png

We can now wrap the code in a function and apply it on the whole raster dataset and store the probability layers to disc as follows:

[10]:
def image_predict_proba(arr, pclf):
    """A custom function for running predict_proba of a sklearn estimator on an image."""
    import numpy as np
    probs = (pclf.image_predict_proba(np.expand_dims(arr, 0)) * 100).astype(np.uint8)
    probs = [probs[0,:,:,i] for i in range(probs.shape[3])]
    return probs

probs = image_predict_proba(arr, pclf=pclf)
print("Length of probs: ", len(probs))
print("Shape of probs[0]: ", probs[0].shape)
print("Shape of probs[1]: ", probs[1].shape)
dst_dir = Path(".").absolute() / "pclf_water_predict_proba"
prob_files = [str(dst_dir / "prob_cid1.jp2"),
              str(dst_dir / "prob_cid2.jp2")]
prob_files
Length of probs:  2
Shape of probs[0]:  (64, 64)
Shape of probs[1]:  (64, 64)
[10]:
['/home/ben/Devel/Repos/eo-box/examples/raster/pclf_water_predict_proba/prob_cid1.jp2',
 '/home/ben/Devel/Repos/eo-box/examples/raster/pclf_water_predict_proba/prob_cid2.jp2']
[11]:
exit_code = mr.apply_and_save(dst_files=prob_files,
                              func=image_predict_proba,
                              pclf=pclf)

Class, Probability and Confidence Layers

Often we want as an outcome the class or prediction layer, probability layers and confidence layers. We can extend the image_predict_proba above as follows:

[12]:
def image_predict(arr, pclf):
    probs = (pclf.image_predict_proba(np.expand_dims(arr, 0)) * 100).astype(np.uint8)[0,:,:,:]
    # get the prediction image (class ids)
    pred_idx = probs.argmax(axis=2)
    pred = np.zeros_like(pred_idx).astype(np.uint8)
    for i in range(probs.shape[2]):
        pred[pred_idx==i] = pclf.classifier.classes_[i]
    # get reliability layers (maximum probability and margin, i.e. maximum probability minus second highest probability)
    probs_sorted = np.sort(probs, axis=2)
    max_prob = probs_sorted[:,:,probs_sorted.shape[2]-1]
    margin = probs_sorted[:,:,probs_sorted.shape[2]-1] - probs_sorted[:,:,probs_sorted.shape[2]-2]
    out_layers = [probs[:,:,i] for i in range(probs.shape[2])]
    out_layers.append(pred)
    out_layers.append(max_prob)
    out_layers.append(margin)
    return out_layers

out_layers = image_predict(arr, pclf=pclf)
[13]:
fig, axes = plt.subplots(2,3, figsize=(20, 10))

axes.flatten()[0].imshow(out_layers[0])
axes.flatten()[0].set_title("Probability CID 1")

axes.flatten()[1].imshow(out_layers[1])
axes.flatten()[1].set_title("Probability CID 2")

axes.flatten()[2].imshow(out_layers[2])
axes.flatten()[2].set_title("Prediction Layer: CID")

axes.flatten()[3].imshow(out_layers[3])
axes.flatten()[3].set_title("Maximum Probability")

axes.flatten()[4].imshow(out_layers[3])
axes.flatten()[4].set_title("Margin")

axes.flatten()[5] = None

fig.tight_layout()
../../_images/examples_raster_multirasterio_classification_19_0.png

And for the whole image:

[14]:
out_layer_filenames = [str(dst_dir / "prob_cid1.jp2"),
                       str(dst_dir / "prob_cid2.jp2"),
                       str(dst_dir / "predictions.jp2"),
                       str(dst_dir / "maximum_probability.jp2"),
                       str(dst_dir / "margin.jp2"),
                       ]

exit_code = mr.apply_and_save(dst_files=out_layer_filenames,
                              func=image_predict,
                              pclf=pclf)
[18]:
import rasterio
fig, ax = plt.subplots(1,1, figsize=(20, 20))
with rasterio.open(str(dst_dir / "prob_cid1.jp2")) as prob_cid1:
    arr = prob_cid1.read()
    ax.imshow(arr[0,:,:], vmin=0, vmax=100)
../../_images/examples_raster_multirasterio_classification_22_0.png