Landsat#

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 accepts xarray raster data structures and then re-renders the data into whatever new bounding box and resolution the user requests. 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 (version >1.9), GeoViews, and Bokeh.

import numpy as np
import rioxarray
import holoviews as hv
import geoviews as gv
import datashader as ds
import cartopy.crs as ccrs
from holoviews import opts
from holoviews.operation.datashader import rasterize, shade

hv.extension('bokeh')

Load LandSat Data#

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

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"])
band_info
Name Wavelength Range (µm) Nominal Wavelength (µm) Resolution (m) Description
Band
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
file_path = './data/MERCATOR_LC80210392016114LGN00_B%s.TIF'
bands = list(range(1, 12)) + ['QA']
bands = [rioxarray.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:

opts.defaults(opts.RGB(width=600, height=600))

nodata= 1

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

tiles = hv.element.tiles.StamenToner()
tiles * shade(rasterize(one_band(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:

from datashader.utils import ngjit
nodata= 1

@ngjit
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)
    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:

true_color = combine_bands(bands[3], bands[2], bands[1]).relabel("True Color (R=Red, G=Green, B=Blue)")
tiles * rasterize(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:

combos = pd.DataFrame([
    (4,3,2,"True color",""),
    (7,6,4,"Urban","False color"),
    (5,4,3,"Vegetation","Color Infrared"),
    (6,5,2,"Agriculture",""),
    (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"])
combos

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

layout = combo("Urban") + combo("Vegetation") + combo("Agriculture") + combo("Land vs. Water")

layout.opts(
    opts.RGB(width=350, height=350, xaxis=None, yaxis=None, framewise=True)).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:

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

def spectrum(x, y):
    try:
        spectrum_vals = band_map.apply.sample(x=x, y=y).collapse()['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])
    except Exception:
        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()

# x and y give the location in Web Mercator coordinates
spectrum(x=-9880000, y=3570000).opts(width=800, height=300, logx=True)

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:

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

layout = tiles * rasterize(true_color) + spectrum_curve
layout.opts(
    opts.Curve(width=450, height=450, logx=True),
    opts.RGB(width=450, height=450))

(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.