Mapping with ipyleaflet#

Ok - enough of that matplotlib rubbish, we’re here to do some mapping! Using the excellent ipyleaflet we are going to build a basic web map, at first we will just display some data, but we will build up to allowing interaction using the skills you have practiced before. A number of the same ideas, like observers and accessing the attributes of different parts of the map, are important concepts for using ipyleaflet.

#First, lets create a map and marker:

from ipyleaflet import Map, Marker, ImageOverlay

center = (53.8008, -1.5491)

m = Map(center=center, zoom=10)

marker = Marker(location=center, draggable=True)
m.add_layer(marker)

display(m)
Intel MKL WARNING: Support of Intel(R) Streaming SIMD Extensions 4.2 (Intel(R) SSE4.2) enabled only processors has been deprecated. Intel oneAPI Math Kernel Library 2025.0 will require Intel(R) Advanced Vector Extensions (Intel(R) AVX) instructions.
Intel MKL WARNING: Support of Intel(R) Streaming SIMD Extensions 4.2 (Intel(R) SSE4.2) enabled only processors has been deprecated. Intel oneAPI Math Kernel Library 2025.0 will require Intel(R) Advanced Vector Extensions (Intel(R) AVX) instructions.

And there we have it - a map! One of the interesting things about ipyleaflet is that it uses the same widget based approach we have seen earlier, so all the interactable objects and markers can be set up with observers, callbacks, and so on. This lets you build a lot of functionality from a user’s point of view, and can let you create quite advanced features.

Compared to Matplotlib, because the ipyleaflet map is already an interactive widget you don’t need to worry so much about resetting and updating the data it displays, you can simply add and remove things from the map, and combine these with other widgets. For example:

import ipywidgets as widgets

reset_button = widgets.Button(description="Reset Marker")

def reset_marker(button):
    marker.location = center
    m.center = center

reset_button.on_click(reset_marker)
display(reset_button)

Now - we will add some data to the map. I have prepared some remote sensing data measuring NDVI and some other spectral indices. By the end of the project, we will use these data to create a heatwave forecasting map (topical!) which will show an estimate for land surface temperature across Leeds. First though, lets learn how to add raster data to the map.

import rasterio as rio
m = Map(center=center, zoom=15)

ndvi_reader = rio.open('Data/leeds_NDVI_aug_highres.tif')

metadata = ndvi_reader.profile
bounds = ndvi_reader.bounds

# ipyleaflet cant work with geotiffs directly, we have to do some covnersions before we can place the image on the map
# First, we need to convert the bounds to the correct format so the map knows where to draw the image

SW = (bounds.bottom, bounds.left)
NE = (bounds.top, bounds.right)
bounds_tuple = (SW, NE)

# Next, we need to convert the image to a format that the map can understand
# We will use the rasterio function to convert the image to a numpy array
# Then, we will convert it to a png using the PIL library
# This is because most browsers cannot show TIFF files, so adding a TIFF file will mean we can't see what is going on 
# This is a bit of a cumbersome process, but is a quirk of ipyleaflet

import numpy as np
from PIL import Image

array = ndvi_reader.read()
array = np.moveaxis(array, 0, -1)

nan_mask = ~np.isnan(array) * 1 
nan_mask *= 255
nan_mask = nan_mask.astype(np.uint8)
array = np.nan_to_num(array)
# We need to move the axis of the array so that the color channel is the last axis

# We need to scale the values in the array to be between 0 and 255 to be viewable as an image
array_max = np.max(array)
array_min = np.min(array)
array = np.clip((array - array_min) / (array_max - array_min) * 255, 0, 255)
array = array.astype(np.uint8)
# Now we can convert the array to a png
image = Image.fromarray(np.squeeze(np.stack([array, array, array, nan_mask], axis=-1)), mode="RGBA")

# Sadly, we cannot add the image array directly to the map, we have to load it from a url 
# So we need to save the file as a png

png_path_temp = image.save("temp.png", mode="png")

# Now we can add the image to the map
image_layer = ImageOverlay(url="temp.png", bounds=bounds_tuple)
m.add_layer(image_layer)
ndvi_reader.close()
display(m)

There we have it - we’ve added raster data to the map! There’s a similar process for adding vector data, but a bit less cumbersome as we don’t need to convert between different formats in the same way. There are many web apps which basically allow the user to load and view many different kinds of data. We’re going to do something similar right now, allowing a user to load and view different kinds of data.

def add_tiff_to_map(path, map, filename="temp.png"):
    reader = rio.open(path)

    metadata = reader.profile
    bounds = reader.bounds

    SW = (bounds.bottom, bounds.left)
    NE = (bounds.top, bounds.right)
    bounds_tuple = (SW, NE)

    array = reader.read()
    array = np.moveaxis(array, 0, -1)

    nan_mask = ~np.isnan(array) * 1 
    nan_mask *= 255
    nan_mask = nan_mask.astype(np.uint8)
    array = np.nan_to_num(array)


    array_max = np.max(array)
    array_min = np.min(array)
    array = np.clip((array - array_min) / (array_max - array_min) * 255, 0, 255)
    array = array.astype(np.uint8)

    image = Image.fromarray(np.squeeze(np.stack([array, array, array, nan_mask], axis=-1)), mode="RGBA")
    image.save(filename)


    # Now we can add the image to the map
    image_layer = ImageOverlay(url=filename, bounds=bounds_tuple)
    map.add_layer(image_layer)


image_path_dictionary = {'NDVI':"Data/leeds_NDVI_aug_highres.tif",
 'NDBI': "Data/leeds_NDBI_aug_highres.tif", 
 'NDWI': "Data/leeds_NDWI_aug_highres.tif"}
import ipywidgets as widgets

image_type_dropdown = widgets.Dropdown(options=image_path_dictionary.keys(), description='Raster Type:')

def update_image(change):
    
    add_tiff_to_map(image_path_dictionary[image_type_dropdown.value], m, image_type_dropdown.value + ".png")

image_type_dropdown.observe(update_image, names='value')
display(m)
display(image_type_dropdown)

Now you have an interactive map where users can choose which data to view and explore areas interesting to them. One trick we are employing here is to just load and unload data, rather than calculating NDVI or similar indices on the fly using remote sensing images. You can apply this logic to all kinds of analysis, if your model has a relatively small number of parameters, and it is possible for you to pre-compute all the options beforehand, it might be better for your app to do this and just let users view the data interactively. This saves a lot of computing resources on mobile and web devices which might not be very powerful. It also means users dont have to wait a long time for servers to fetch and return data, perform analysis, crunch numbers, and so on.

We’ll go on to some more advanced concepts in a minute, but first, we’ll do some exercises to practice what you have just learned.

# Exercise 1: Precomputing your results is much faster than computing them on the fly. In this case, we have some static data but just want to 
# display it, because tiff files cannot be shown directly, we need to conver it to a png. 
# Write a function that takes a path to a tiff file, converts it to a png, and returns the path to the png file. And use that to create a new 
# file path dictionary and dropdown menu to add data to the map. 

def convert_tiff_to_png(tiff_path):

    return None #png_path, image_bounds
# Exercise 2: Showing maps alongside charts and other kinds of visualisations can be a powerful way of communicating your results.
# Create a new map and dropdown options which allow you to select between the NDVI, NDBI, and NDWI rasters. 
# Alongside the map, use matplotlib to show a histogram of the values in the raster.
# Hint: You can use the rasterio function to read the values of the raster into a numpy array, then use matplotlib to plot the histogram.
# Bonus Hint: Use the container widgets from the earlier class to display the map and histogram side by side.
## EXERCISE 1 SOLUTION:

def convert_tiff_to_png(path, filename="temp.png"):
    reader = rio.open(path)

    bounds = reader.bounds

    SW = (bounds.bottom, bounds.left)
    NE = (bounds.top, bounds.right)
    bounds_tuple = (SW, NE)

    array = reader.read()
    array = np.moveaxis(array, 0, -1)

    nan_mask = ~np.isnan(array) * 1 
    nan_mask *= 255
    nan_mask = nan_mask.astype(np.uint8)
    array = np.nan_to_num(array)


    array_max = np.max(array)
    array_min = np.min(array)
    array = np.clip((array - array_min) / (array_max - array_min) * 255, 0, 255)
    array = array.astype(np.uint8)

    image = Image.fromarray(np.squeeze(np.stack([array, array, array, nan_mask], axis=-1)), mode="RGBA")
    image.save(filename)

    return filename, bounds_tuple
##EXERCISE 2 SOLUTION
%matplotlib inline
import matplotlib.pyplot as plt

chart_draw_widget = widgets.Output()
with chart_draw_widget:
    fig, ax = plt.subplots(figsize=(5, 5))
    flat = rio.open(image_path_dictionary[image_type_dropdown.value]).read(1).flatten()
    chart = ax.hist(flat, bins=100)
    plt.show()



image_type_dropdown = widgets.Dropdown(options=image_path_dictionary.keys(), description='Raster Type:')


def update_image(change):
    
    flat = rio.open(image_path_dictionary[image_type_dropdown.value]).read(1).flatten()

    with chart_draw_widget:
        chart_draw_widget.clear_output(wait=True)
        fig, ax = plt.subplots(figsize=(5, 5))
        chart = ax.hist(flat, bins=100)
        plt.show()
    add_tiff_to_map(image_path_dictionary[image_type_dropdown.value], m, image_type_dropdown.value + ".png")



image_type_dropdown.observe(update_image, names='value')


map_chart_container = widgets.HBox([m, chart_draw_widget])
map_ui = widgets.VBox([map_chart_container, image_type_dropdown])
display(map_ui)