Datashading LandSat8 raster satellite imagery

Datashader is fundamentally a rasterizing library, turning data into rasters (image-like arrays), but it is also useful for already-rasterized data like satellite imagery. For raster data, datashader uses the separate xarray library to re-render the data to whatever new bounding box and resolution the user requests, and the rest of the datashader pipeline can then be used to visualize and analyze the data. This demo shows how to work with a set of raster satellite data, generating images as needed and overlaying them on geographic coordinates using HoloViews, GeoViews, and Bokeh.

This notebook currently relies on HoloViews 1.9 or above. Run conda install -c ioam/label/dev holoviews to install it.

In [ ]:
import numpy as np
import xarray as xr
import holoviews as hv
import geoviews as gv
import datashader as ds
import as ccrs

from holoviews.operation.datashader import regrid, shade
from bokeh.tile_providers import STAMEN_TONER

hv.extension('bokeh', width=80)

Load LandSat Data

LandSat data is measured in different frequency bands, revealing different types of information:

In [ ]:
import pandas as pd
band_info = pd.DataFrame([
        (1,  "Aerosol", " 0.43 - 0.45",    0.440,  "30",         "Coastal aerosol"),
        (2,  "Blue",    " 0.45 - 0.51",    0.480,  "30",         "Blue"),
        (3,  "Green",   " 0.53 - 0.59",    0.560,  "30",         "Green"),
        (4,  "Red",     " 0.64 - 0.67",    0.655,  "30",         "Red"),
        (5,  "NIR",     " 0.85 - 0.88",    0.865,  "30",         "Near Infrared (NIR)"),
        (6,  "SWIR1",   " 1.57 - 1.65",    1.610,  "30",         "Shortwave Infrared (SWIR) 1"),
        (7,  "SWIR2",   " 2.11 - 2.29",    2.200,  "30",         "Shortwave Infrared (SWIR) 2"),
        (8,  "Panc",    " 0.50 - 0.68",    0.590,  "15",         "Panchromatic"),
        (9,  "Cirrus",  " 1.36 - 1.38",    1.370,  "30",         "Cirrus"),
        (10, "TIRS1",   "10.60 - 11.19",   10.895, "100 * (30)", "Thermal Infrared (TIRS) 1"),
        (11, "TIRS2",   "11.50 - 12.51",   12.005, "100 * (30)", "Thermal Infrared (TIRS) 2")],
    columns=['Band', 'Name', 'Wavelength Range (µm)', 'Nominal Wavelength (µm)', 'Resolution (m)', 'Description']).set_index(["Band"])
In [ ]:
file_path = './data/MERCATOR_LC80210392016114LGN00_B%s.TIF'
bands = list(range(1, 12)) + ['QA']
bands = [xr.open_rasterio(file_path%band).load()[0] for band in bands]

Rendering LandSat data as images

The bands measured by LandSat include wavelengths covering the visible spectrum, but also other ranges, and so it's possible to visualize this data in many different ways, in both true color (using the visible spectrum directly) or false color (usually showing other bands). Some examples are shown in the sections below.

Just the Blue Band

Using datashader's default histogram-equalized colormapping, the full range of data is visible in the plot:

In [ ]:
%opts RGB [width=600 height=600]
tiles * shade(regrid(hv.Image(bands[1])), cmap=['black', 'white']).redim(x='Longitude', y='Latitude')

You will usually want to zoom in, which will re-rasterize the image if you are in a live notebook, and then re-equalize the colormap to show all the detail available. If you are on a static copy of the notebook, only the original resolution at which the image was rendered will be available, but zooming will still update the map tiles to whatever resolution is requested.

The plots below use a different type of colormap processing, implemented as a custom transfer function:

In [ ]:
from datashader.utils import ngjit
nodata= 1

def normalize_data(agg):
    out = np.zeros_like(agg)
    min_val = 0
    max_val = 2**16 - 1
    range_val = max_val - min_val
    col, rows = agg.shape
    c = 40
    th = .125
    for x in range(col):
        for y in range(rows):
            val = agg[x, y]
            norm = (val - min_val) / range_val
            norm = 1 / (1 + np.exp(c * (th - norm))) # bonus
            out[x, y] = norm * 255.0
    return out

def combine_bands(r, g, b):
    xs, ys = r['x'], r['y']
    r, g, b = [ds.utils.orient_array(img) for img in (r, g, b)]
    a = (np.where(np.logical_or(np.isnan(r),r<=nodata),0,255)).astype(np.uint8)    
    r = (normalize_data(r)).astype(np.uint8)
    g = (normalize_data(g)).astype(np.uint8)
    b = (normalize_data(b)).astype(np.uint8)
    col, rows = r.shape
    return hv.RGB((xs, ys[::-1], r, g, b, a), vdims=list('RGBA'))

True Color

Mapping the Red, Green, and Blue bands to the R, G, and B channels of an image reconstructs the image as it would appear to an ordinary camera from that viewpoint:

In [ ]:
true_color = combine_bands(bands[3], bands[2], bands[1]).relabel("True Color (R=Red, G=Green, B=Blue)")
tiles * regrid(true_color)

Again, the raster data will only refresh to a new resolution if you are running a live notebook, because that data is not actually present in the web page; it's held in a separate Python server.

False Color

Other combinations highlight particular features of interest based on the different spectral properties of reflectances from various objects and surfaces, with full data redrawing on zooming if you have a live Python process:

In [ ]:
%%opts RGB Curve [width=350 height=350 xaxis=None yaxis=None] {+framewise}

combos = pd.DataFrame([
    (4,3,2,"True color",""),
    (7,6,4,"Urban","False color"),
    (5,4,3,"Vegetation","Color Infrared"),
    (7,6,5,"Penetration","Atmospheric Penetration"),
    (5,6,2,"Healthy Vegetation",""),
    (5,6,4,"Land vs. Water",""),
    (7,5,3,"Atmosphere Removal","Natural With Atmospheric Removal"),
    (7,5,4,"Shortwave Infrared",""),
    (6,5,4,"Vegetation Analysis","")],
    columns=['R', 'G', 'B', 'Name', 'Description']).set_index(["Name"])

def combo(name):
    return regrid(combine_bands(bands[c.R-1],bands[c.G-1],bands[c.B-1])).relabel(name)

(combo("Urban") + combo("Vegetation") + combo("Agriculture") + combo("Land vs. Water")).cols(2)

All the various ways of combining aggregates supported by xarray are available for these channels, making it simple to make your own custom visualizations highlighting any combination of bands that reveal something of interest.

Revealing the spectrum

The above plots all map some of the measured data into the R,G,B channels of an image, showing all spatial locations but only a restricted set of wavelengths. Alternatively, you could sample across all the measured wavelength bands to show the full spectrum at any given location:

In [ ]:
%%opts Curve [width=800 height=300 logx=True]

band_map = hv.HoloMap({i: hv.Image(band) for i, band in enumerate(bands)})

def spectrum(x, y):
        spectrum_vals = band_map.sample(x=x, y=y)['z'][:-1]
        point = gv.Points([(x, y)], crs=ccrs.GOOGLE_MERCATOR)
        point = gv.operation.project_points(point, projection=ccrs.PlateCarree())
        label = label = 'Lon: %.3f, Lat: %.3f' % tuple(point.array()[0])
        spectrum_vals = np.zeros(11)
        label = 'Lon: -, Lat: -'
    return hv.Curve((band_info['Nominal Wavelength (µm)'].values, spectrum_vals), label=label,
                    kdims=['Wavelength (µm)'], vdims=['Luminance']).sort()

spectrum(x=-9880000, y=3570000) # Location in Web Mercator coordinates

We can now combine these two approaches to let you explore the full hyperspectral information at any location in the true-color image, updating the curve whenever you hover over an area of the image:

In [ ]:
%%opts Curve RGB [width=450 height=450] Curve [logx=True]

tap = hv.streams.PointerXY(source=true_color)
spectrum_curve = hv.DynamicMap(spectrum, streams=[tap]).redim.range(Luminance=(0, 30000))

tiles * regrid(true_color) + spectrum_curve

(Of course, just as for the raster data resolution, the plot on the right will update only in a live notebook session, because it needs to run Python code for each mouse pointer position.)

As you can see, even though datashader is not a GIS system, it can be a flexible, high-performance way to explore GIS data when combined with HoloViews, GeoViews, and Bokeh.