Continuing on from the previous post, this entry will describe a workflow for saving the satellite image mosaic created using Google Earth Engine to an RGB raster file. The image will then be processed to scale its values, and tiled for use as a web map layer.
Exporting the Image
After running the GEE script from the last post, prepare a layer for export by selecting bands from the rgb layer and sorting them in RGB format (i.e. bands 4, 3, 2). The resulting RGB layer can then be exported to a GeoTiff (requires Google Drive with sufficient storage available) by additionally running the code below and then clicking Run > Set Output in the Tasks panel. Note that the export may take quite a long time to run (in my case 39 mins), but once started the GEE editor can be closed. For starting in a fresh editor session, a full script is available here, which skips the visualization and other unneeded elements of the original script, and then runs the export.
// Reorder bands to correct 'True Color' RGB ordering.
var sentinel2mosaic = rgb.select('B4_median').rename('b4')
.addBands(rgb.select('B3_median').rename('b3'))
.addBands(rgb.select('B2_median').rename('b2'));
// Export the image, specifying the CRS, scale, and region.
Export.image.toDrive({
image: sentinel2mosaic,
description: 'sentinel2mosaic',
crs: 'EPSG:4326',
//crsTransform: affine,
region: geom,
scale: 10
});
Processing
One the export is complete, the file can be downloaded from Google Drive and processed. The intent is to tile the image using gdal2tiles, but if we check the data type of the file with gdalinfo as gdalinfo sentinel2mosaic.tif -mm, we can see the values are stored as a Float64 data type, whereas the gdal2tilesdocumentation states the following:
Inputs with non-Byte data type (i.e. Int16, UInt16,…) will be clamped to the Byte data type, causing wrong results. To avoid this it is necessary to rescale input to the Byte data type using gdal_translate utility.
This means before we can tile the image, we first need to convert the data type from Float64 to Byte, and scale the 64 bit values to fit within this greatly reduced range (0-255). As recommended above, we can usegdal_translate for this as:
Although if you then open the resulting file in QGIS, you will likely see that the image appears very dark:
To fix this, right click on the layer and select Properties > Symbology, and under Min / Max Value Settings, set Cumulative count cut, and Contrast enhancement as Stretch to MinMax, then hit Apply:
What happened here was the extreme top and bottom 2% of values were excluded from the color ramp, which were otherwise setting a too large range that allocates unexpected colors to the majority of values. This fix only applies to the layer in QGIS, however, so to fix the file itself, we too need to exclude the top and bottom 2% of values when scaling. One way to get these values can be with a simple Python script as below:
python3
import gdal
import numpy as np
ds = gdal.Open('sentinel2mosaic.tif')
for n in range(1, 4):
band = ds.GetRasterBand(n)
arr = band.ReadAsArray()
sel = np.isnan(arr)
p2 = np.percentile(arr[~sel], 2)
p98 = np.percentile(arr[~sel], 98)
print('{}: {} {}'.format(n, p2, p98))
Which returns the values:
1: 147.5 1341.0
2: 292.5 1184.0
3: 268.0 955.0
We can then re-run the scaling and override the default min-max values with the 2nd and 98th percentiles calculated above as:
I usually download, store, and process data on my own local machines, archiving the raw data, possibly ingesting it to a database, and then writing scripts for further processing and analysis. This approach can quickly become problematic for particularly large datasets, however, due to limits on available disk space for storage or network speeds for downloading, or insufficient cpu power and memory capacity for effective processing. Rather than investing in additional computing resources, one solution to this kind of problem is Google Earth Engine, a web service that can do this kind of processing for you for free, with ready access to a wide range of data sources.
One such use case is to create a cloud free satellite image mosaic from a time series of satellite imagery. Assembling this yourself would require the download of a potentially large number of images, and writing an algorithm to filter and combine them in memory, likely requiring significant computing resources.
Here, I will demonstrate using Google Earth Engine (GEE) to assemble a filtered time series of satellite image tiles from the Copernicus Sentinel-2 mission, then mask cloud cover, and mosaic (merge) them into a single high resolution RGB image. To get started with GEE, sign up for a free account at: https://signup.earthengine.google.com/ and open the code editor at: https://code.earthengine.google.com.
The GEE code editor is a web based IDE, including a text editor to write and run scripts, a console to display information and errors, an interactive map to visualize results, and built in access to examples and documentation. GEE code is written in JavaScript, utilizing a library with a variety of special functions for image processing (prefixed with ee).
Count Tiles
First, we need to choose a location for the time series, and a date range that has sufficient non-cloudy tiles (<10% cloud cover) for creating the mosaic image. From some trial and error, I found July-September 2021 to be such a time period for my selected area of Prince Edward County, Ontario.
To replicate, add the below code to your new script (the center panel) and click Run. This will load in a time series of Sentinel imagery based on the specified date, location and cloud cover filters, check how many tiles were returned, and finally visualize the results in the map panel.
// Define parameters for run: date range, extent, projection, and map location.
var minDate = ee.Date('2021-07-01');
var maxDate = ee.Date('2021-09-30');
var geom = ee.Geometry.Rectangle([-77.70, 43.82, -76.79, 44.2]);
var affine = [0.00026949458523585647, 0, -180, 0, -0.00026949458523585647, 86.0000269494563];
Map.setCenter(-77.2, 44.0, 10);
// Read the Copernicus Sentinel 2 collection, and filter by date, bounds, and cloud cover, then select the bands of interest.
var s2Sr = ee.ImageCollection('COPERNICUS/S2_SR')
.filterDate(minDate, maxDate)
.filterBounds(geom)
.filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 10))
.select(['B2', 'B3', 'B4', 'B5', 'QA60']);
// Create an image with the tile overlap count per pixel from a reference band, and clip to geom.
var count = s2Sr.select(['B2']).count().clip(geom);
// Get the min max range of the image.
var minMax = count.reduceRegion({
reducer: ee.Reducer.minMax(),
geometry: geom,
crs: 'EPSG:4326',
crsTransform: affine
});
var min = minMax.get('B2_min').getInfo();
var max = minMax.get('B2_max').getInfo();
// Visualize the layer on the map.
var countViz = {
min: min,
max: max
};
Map.addLayer(
count,
countViz,
'count'
);
Add a Legend
Although the map values are color coded, exactly what it shows is unclear without a legend. To add a legend to the map, additionally run the below code, defining a function for generating a legend and then applying it to the counts layer. There should be no less than three images present in any given location with these filters, which is enough to remove the clouds without leaving gaps in this case.
function addContinuousLegend(viz, title) {
// Create legend title.
var legendTitle = ui.Label({
value: title,
style: {
fontWeight: 'bold',
fontSize: '18px',
margin: '0 0 2px 0',
padding: '0'
}
});
// Create the legend color ramp image.
var lon = ee.Image.pixelLonLat().select('latitude');
var gradient = lon.multiply((viz.max-viz.min)/100.0).add(viz.min);
var legendImage = gradient.visualize(viz);
// Create a thumbnail from the image.
var thumbnail = ui.Thumbnail({
image: legendImage,
params: {bbox:'0,0,10,100', dimensions:'10x200'},
style: {padding: '1px', position: 'bottom-center'}
});
// Create legend with title, min-max values, and color ramp bar.
var legend = ui.Panel({
widgets: [
legendTitle,
ui.Label(viz['max']),
thumbnail,
ui.Label(viz['min'])
],
style: {
position: 'bottom-right',
padding: '10px 10px'
}
});
// Add legend to current map.
Map.add(legend);
}
addContinuousLegend(
countViz,
'count'
);
Identify Cloudy Pixels
There are a couple of examples of applying a cloud reduction algorithm to Sentinel 2 imagery provided by Google here and here. Below, I have combined and refined the two to create a mask layer with the locations of cloudy pixels. This is then overlaid on the map to show areas where at least one of the tiles is determined to have clouds present.
// Join two collections on their 'system:index' property.
// The propertyName parameter is the name of the property
// that references the joined image.
// Function taken from: https://code.earthengine.google.com/?scriptPath=Examples%3ACloud%20Masking%2FSentinel2%20Cloud%20And%20Shadow
function indexJoin(collectionA, collectionB, propertyName) {
var joined = ee.ImageCollection(ee.Join.saveFirst(propertyName).apply({
primary: collectionA,
secondary: collectionB,
condition: ee.Filter.equals({
leftField: 'system:index',
rightField: 'system:index'})
}));
// Merge the bands of the joined image.
return joined.map(function(image) {
return image.addBands(ee.Image(image.get(propertyName)));
});
}
// Cloud detection algorithm, combined from functions at:
// https://code.earthengine.google.com/?scriptPath=Examples%3ACloud%20Masking%2FSentinel2%20Cloud%20And%20Shadow
// and
// https://code.earthengine.google.com/?scriptPath=Examples%3ADatasets%2FCOPERNICUS_S2
function getCloudsMask(image) {
var cdi = ee.Algorithms.Sentinel2.CDI(image);
var cloudProb = image.select('probability');
var cirrus = image.select('B10').multiply(0.0001);
var qa = image.select('QA60');
// Bits 10 and 11 are clouds and cirrus, respectively.
var cloudBitMask = 1 << 10;
var cirrusBitMask = 1 << 11;
// Combine cloud cover layers to create a mask.
var mask =
(
qa.bitwiseAnd(cloudBitMask).gt(0)
.or(qa.bitwiseAnd(cirrusBitMask).gt(0))
).or(
cloudProb.gt(65).and(cdi.lt(-0.5))
).or(
cirrus.gt(0.01)
);
return mask;
}
// Read additional image collections for cloud detection.
var s2 = ee.ImageCollection('COPERNICUS/S2')
.filterDate(minDate, maxDate)
.filterBounds(geom)
.select(['B7', 'B8', 'B8A', 'B10']);
var s2Clouds = ee.ImageCollection('COPERNICUS/S2_CLOUD_PROBABILITY')
.filterDate(minDate, maxDate)
.filterBounds(geom);
// Join the cloud probability dataset to surface reflectance.
var withCloudProbability = indexJoin(s2Sr, s2Clouds, 'cloud_probability');
// Join the L1C data to get the bands needed for CDI.
var dataset = indexJoin(withCloudProbability, s2, 'l1c');
// Apply the cloud masking algorithm to create an image mask.
var clouds = dataset.map(getCloudsMask);
// Mask and clip the layer.
var totClouds = clouds.sum().gt(0).selfMask().clip(geom);
// Visualize the layer on the map.
var cloudsViz = {
min: 1,
max: 1,
palette: 'red'
};
Map.addLayer(totClouds, cloudsViz, 'clouds');
Remove Clouds
With the clouds mask, it is now a case of removing those pixels from the affected tiles, and finally taking an average (median) of what remains the create a cloud free mosaic. To visualize the results, a SplitPanel can be used to place two maps side by side, with a slider to switch the display between them, to see the areas where clouds have been removed, as below.
// Select RGB bands, and join clouds mask.
var withCloudMask = indexJoin(dataset.select(['B2', 'B3', 'B4']), clouds, 'QA60');
// Make another map to display the output layer.
var linkedMap = ui.Map();
// Map the cloud masking function over the joined collection.
var masked = ee.ImageCollection(withCloudMask.map(function(img) {
return img.updateMask(img.select('QA60').eq(0));
}));
// Take the median values across the timeseries, specifying a tileScale to avoid memory errors.
var median = masked.reduce(ee.Reducer.median(), 8);
// Crop layer to target geometry.
var rgb = median.clip(geom);
// Add the layer to the second map.
var viz = {
bands: ['B4_median', 'B3_median', 'B2_median'],
min: 0,
max: 3000
};
linkedMap.addLayer(rgb, viz, 'median');
// Link the default Map to the second map.
var linker = ui.Map.Linker([ui.root.widgets().get(0), linkedMap]);
// Create a SplitPanel which holds the linked maps side-by-side.
var splitPanel = ui.SplitPanel({
firstPanel: linker.get(0),
secondPanel: linker.get(1),
orientation: 'horizontal',
wipe: true,
style: {stretch: 'both'}
});
// Set the SplitPanel as the only thing in root.
ui.root.widgets().reset([splitPanel]);
The full and final script for all these steps can be accessed here.