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.CartoLight()
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
R | G | B | Description | |
---|---|---|---|---|
Name | ||||
True color | 4 | 3 | 2 | |
Urban | 7 | 6 | 4 | False color |
Vegetation | 5 | 4 | 3 | Color Infrared |
Agriculture | 6 | 5 | 2 | |
Penetration | 7 | 6 | 5 | Atmospheric Penetration |
Healthy Vegetation | 5 | 6 | 2 | |
Land vs. Water | 5 | 6 | 4 | |
Atmosphere Removal | 7 | 5 | 3 | Natural With Atmospheric Removal |
Shortwave Infrared | 7 | 5 | 4 | |
Vegetation Analysis | 6 | 5 | 4 |
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)