Land use Classification#

holoviewsbokehdaskxarraykerastensorflow
Published: November 30, 2018 · Modified: December 6, 2024


Satellite images often need to be classified (assigned to a fixed set of types) or to be used for detection of various features of interest. Here we will look at the classification case, using labelled satellite images from various categories from the UCMerced LandUse dataset. scikit-learn is useful for general numeric data types, but it doesn’t have significant support for working with images. Luckily, there are various deep-learning and convolutional-network libraries that do support images well, including Keras (backed by TensorFlow) as we will use here.

import intake
import numpy as np
import holoviews as hv
import pandas as pd
import random

np.random.seed(101)

from holoviews import opts
hv.extension('bokeh')

Get the classes and files#

All of the labeled image classification data is in a public bucket on s3 with a corresponding intake catalog. This catalog provides several ways to access the data. You can access one image by landuse and id, access all the images for a given landuse, or access all the images.

cat = intake.open_catalog('./catalog.yml')
list(cat)
['UCMerced_LandUse_all',
 'UCMerced_LandUse_by_landuse',
 'UCMerced_LandUse_by_image']

The first time you run the cell below it will download all the images which takes about 3 minutes on my machine. After that, the images are cached and it’ll take about 100 ms.

%%time
da = cat.UCMerced_LandUse_all().to_dask()
da
CPU times: user 16 s, sys: 2.75 s, total: 18.8 s
Wall time: 1min 35s
<xarray.DataArray 'imread-d08352d6a5af7c4f4d7c401871bbdc62' (id: 100,
                                                             landuse: 21,
                                                             y: 256, x: 256,
                                                             channel: 3)>
dask.array<transpose, shape=(100, 21, 256, 256, 3), dtype=uint8, chunksize=(1, 21, 256, 256, 3), chunktype=numpy.ndarray>
Coordinates:
  * id       (id) int64 0 1 2 3 4 5 6 7 8 9 10 ... 90 91 92 93 94 95 96 97 98 99
  * landuse  (landuse) object 'agricultural' 'airplane' ... 'tenniscourt'
  * y        (y) int64 0 1 2 3 4 5 6 7 8 ... 247 248 249 250 251 252 253 254 255
  * x        (x) int64 0 1 2 3 4 5 6 7 8 ... 247 248 249 250 251 252 253 254 255
  * channel  (channel) int64 0 1 2

We can see what’s going on more easily if we convert the data to a dataset with each data variable representing a different landuse.

ds = da.to_dataset(dim='landuse')
ds
<xarray.Dataset>
Dimensions:            (id: 100, y: 256, x: 256, channel: 3)
Coordinates:
  * id                 (id) int64 0 1 2 3 4 5 6 7 8 ... 92 93 94 95 96 97 98 99
  * y                  (y) int64 0 1 2 3 4 5 6 7 ... 249 250 251 252 253 254 255
  * x                  (x) int64 0 1 2 3 4 5 6 7 ... 249 250 251 252 253 254 255
  * channel            (channel) int64 0 1 2
Data variables: (12/21)
    agricultural       (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>
    airplane           (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>
    baseballdiamond    (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>
    beach              (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>
    buildings          (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>
    chaparral          (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>
    ...                 ...
    parkinglot         (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>
    river              (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>
    runway             (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>
    sparseresidential  (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>
    storagetanks       (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>
    tenniscourt        (id, y, x, channel) uint8 dask.array<chunksize=(1, 256, 256, 3), meta=np.ndarray>

Split files into train and test sets#

In order to accurately test the performance of the classifier we are building we will split the data into training and test sets with an 80/20 split. We randomly sample the images in each category and assign them either to the training or test set:

train_set = np.random.choice(ds.id, int(0.8 * len(ds.id)), False)
test_set = np.setdiff1d(ds.id, train_set)

Define function to sample from train or test set#

landuses = da.landuse.data
landuse_list = list(landuses)

Next we define a function that randomly samples an image, either from the training or test set and ant

def get_sample(landuse=None, set='training'):
    landuse = landuse or np.random.choice(landuses)
    i = random.choice(train_set if set == 'training' else train_set)
    return ds[landuse].sel(id=i)

def plot(data):
    options = opts.RGB(xaxis=None, yaxis=None)
    title = '{}: {}'.format(data.name, data.id.item())
    plot = hv.RGB(data.data.compute())
    return plot.options(options).relabel(title)

We can inspect the data on one of these samples to see that the data is loaded as an xarray.DataArray.

data = get_sample()
data
<xarray.DataArray 'sparseresidential' (y: 256, x: 256, channel: 3)>
dask.array<getitem, shape=(256, 256, 3), dtype=uint8, chunksize=(256, 256, 3), chunktype=numpy.ndarray>
Coordinates:
    id       int64 59
  * y        (y) int64 0 1 2 3 4 5 6 7 8 ... 247 248 249 250 251 252 253 254 255
  * x        (x) int64 0 1 2 3 4 5 6 7 8 ... 247 248 249 250 251 252 253 254 255
  * channel  (channel) int64 0 1 2

We can plot this array as a holoviews RGB image so we can visualize it:

plot(data)
hv.Layout(list(map(plot, map(get_sample, np.random.choice(landuses, 4))))).cols(2)

Define the model#

Now it’s time to define a model. The code snippet below defines a convolutional neural network containing a stack of 3 convolution layers with a ReLU activation and followed by max-pooling layers. This is very similar to the architectures that Yann LeCun advocated in the 1990s for image classification (with the exception of ReLU).

from keras.models import Sequential
from keras.layers import Conv2D, MaxPooling2D
from keras.layers import Activation, Dropout, Flatten, Dense

size = (150, 150)

model = Sequential()
model.add(Conv2D(32, (3, 3), input_shape=(*size, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Conv2D(32, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Conv2D(64, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Flatten())  # this converts our 3D feature maps to 1D feature vectors
model.add(Dense(64))
model.add(Activation('relu'))
model.add(Dropout(0.5))
model.add(Dense(21))
model.add(Activation('softmax'))

model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
2024-12-06 07:02:26.274614: I tensorflow/core/platform/cpu_feature_guard.cc:182] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: SSE4.1 SSE4.2 AVX AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
2024-12-06 07:02:27.560937: I tensorflow/core/common_runtime/process_util.cc:146] Creating new thread pool with default inter op setting: 2. Tune using inter_op_parallelism_threads for best performance.

Declare the data#

The dataset of images we have is relatively small so to avoid overfitting we will want to apply some augmentation to it. The code below defines a generator that randomly samples from either the training or test set and generates a randomly rotated and cropped 150x150 window onto the 256x256 images along with a label array:

def get_array(array, size=(150, 150)):
    # Randomly flip
    if random.getrandbits(1):               # equivalent to random.choice([True, False]) but much faster
        array = array.transpose(1, 0, 2)
    if random.getrandbits(1):
        array = array[::-1]
    if random.getrandbits(1):
        array = array[:, ::-1]
    # Randomly crop
    sh, sw = size
    h, w = array.shape[:2]
    b = np.random.randint(h-sh)
    l = np.random.randint(w-sw)
    array = array[b:b+sh, l:l+sw]
    return array/255.

# set up an mapping to an identity matrix to use for one-hot encoding
one_hot_mapping = dict(zip(landuses, np.eye(21)))

def gen_samples(set='training', labels=None):
    "Generates random arrays along with landuse labels"
    while True:
        choice = get_sample(set=set)
        if labels is not None:
            labels.append(choice.name)
        one_hot = one_hot_mapping[choice.name]
        data = choice.data.compute()
        yield get_array(data)[np.newaxis, :], one_hot[np.newaxis, :]

Run the model#

Before we start running the model let’s set up a keras Callback to build a dashboard to monitor the accuracy and loss during training:

import time
from keras.callbacks import Callback

class MonitorCallback(Callback):
    """
    Builds a streaming dashboard to monitor the accuracy
    and loss during training using HoloViews streams.
    """

    _format = '%s - Epoch: %d - Elapsed time: %.2fs'

    def __init__(self, metrics=['acc', 'loss']):
        super().__init__()
        sample = {'Epoch': np.array([])}
        for metric in metrics:
            sample[metric] = np.array([])
        self.buffer = hv.streams.Buffer(sample)
        dmaps = []
        for metric in metrics:
            def cb(data, metric=metric):
                return hv.Curve(
                    data, 'Epoch', metric, label=self._format
                    % (metric, self.epoch, self.elapsed_time))
            dmap = hv.DynamicMap(cb, streams=[self.buffer])
            dmaps.append(dmap)
        self.layout = hv.Layout(dmaps)
        self.metrics = metrics
        self.start_time = None
        self.epoch = 0

    def on_train_begin(self, logs={}):
        self.start_time = time.time()

    @property
    def elapsed_time(self):
        if self.start_time is None:
            return 0
        else:
            return time.time() - self.start_time

    def on_epoch_end(self, epoch, logs=None):
        self.epoch += 1
        data = {'Epoch': [self.epoch]}
        for metric in self.metrics:
            data[metric] = [logs.get(metric)]
        self.buffer.send(data)

Now we can create an instance of the callback and display the dashboard, which will at first appear blank.

monitor = MonitorCallback()
monitor.layout.options('Curve', width=450)

Next we will fit the model with our training data generator, as the model is running it will update the dashboard above:

%%time
history = model.fit(gen_samples('training'), steps_per_epoch=50, epochs=500, verbose=False, callbacks=[monitor])
2024-12-06 07:02:27.955009: I tensorflow/core/common_runtime/executor.cc:1197] [/device:CPU:0] (DEBUG INFO) Executor start aborting (this does not indicate an error and you can ignore this message): INVALID_ARGUMENT: You must feed a value for placeholder tensor 'Placeholder/_0' with dtype int32
	 [[{{node Placeholder/_0}}]]
CPU times: user 49min 42s, sys: 1h 7min 23s, total: 1h 57min 6s
Wall time: 30min 22s

Evaluate the model#

First we will have a look at the monitoring output but smooth it slightly so we can make out the overall trend:

from holoviews.operation.timeseries import rolling
monitor.layout.options('Curve', width=400).map(rolling, hv.Curve)

Now let us test the predictions on the test set, first visually:

def set_title_color(color, *args):
    """Helper function to set title color"""
    args[0].handles['plot'].title.text_color = color
def get_prediction(cls):
    sample = get_sample(cls, 'test')
    array = get_array(sample.data.compute())[np.newaxis, ...]
    p = model.predict(array, verbose=0).argmax()
    p = landuses[p]
    return (plot(sample)
            .relabel('Predicted: %s - Actual: %s' % (p, cls))
            .options(hooks=[lambda *args: set_title_color('red' if p!=cls else 'blue', *args)]))

options = dict(fontsize={'title': '8pt'}, width=250, height=250)
hv.Layout([get_prediction(landuse).options(**options) for landuse in landuses]).cols(3)