Note

This page was generated from a Jupyter notebook. To run and interact with it, you can download it here.

Drone mosaic correction and evaluation

This notebook uses homonim to correct a drone mosaic image to surface reflectance using a Sentinel-2 SR reference. It demonstrates the use of geedim for obtaining reference images. Results are evaluated by comparison with a Landsat-8 reference.

The drone mosaic is supplied by Open Aerial Map under the CC BY 4.0 license. It is a 5 cm resolution RGB ortho-image captured on 8 Feb 2022, covering small diverse area in Pereira, Colombia.

Setup

geedim, gdal and matplotlib are required to run the notebook. You can uncomment the cell below to install them, if they aren’t installed already.

[1]:
# import sys
# if 'conda' in sys.prefix:
#     # install into the conda environment the notebook is being run from
#     !conda install --yes --prefix {sys.prefix} -c conda-forge geedim gdal matplotlib
# else:
#     # install into the python environment the notebook is being run from
#     !{sys.executable} -m pip install geedim gdal matplotlib
[2]:
# imports used by more than one cell
import logging
import warnings
from pathlib import Path
from matplotlib import pyplot
import numpy as np
import rasterio as rio
from tqdm.auto import tqdm

import geedim as gd
from homonim import RasterCompare

logging.basicConfig(level=logging.ERROR)

Download drone image

In this step, we create an images sub-folder, then download the drone ortho-image into it.

[3]:
from urllib import request

# create images dir and source image path
src_url = (
    'https://oin-hotosm.s3.amazonaws.com/6202ec307b3a500007430480/0/'
    '6202ec307b3a500007430481.tif'
)
images_path = Path('images')
images_path.mkdir(exist_ok=True)

src_path = images_path.joinpath('ES_WMM3_2022_02_08_Pereira_RGB.tif')
[4]:
# download
response = request.urlopen(src_url)
with tqdm(
    total=response.length, desc='Download', unit='B', unit_scale=True,
    dynamic_ncols=True
) as pbar, open(src_path, 'wb')  as fout:
    for chunk in response:
        fout.write(chunk)
        pbar.update(len(chunk))

Search for reference image

Now we search for a Sentinel-2 SR reference image using geedim. Sentinel-2 is chosen for its high spatial resolution (10 m).

[5]:
gd.Initialize()
# create a search region that covers the source image
region = gd.utils.get_bounds(src_path)

# search the Sentinel-2 SR collection for >50% cloudless images
s2_coll = gd.MaskedCollection.from_name('COPERNICUS/S2_SR')
s2_coll = s2_coll.search(
    '2022-01-01', '2022-03-01', region, cloudless_portion=50,
)
print('Image property descriptions:\n\n' + s2_coll.schema_table)
print('\nSearch Results:\n\n' + s2_coll.properties_table)

# equivalent geedim command line:
# !geedim search -c s2-sr -s 2022-01-01 -e 2022-03-01 -r {src_mosaic_path} -cp 50
Image property descriptions:

ABBREV     NAME                             DESCRIPTION
---------  -------------------------------  ----------------------------------------------
ID         system:id                        Earth Engine image id
DATE       system:time_start                Image capture date/time (UTC)
FILL       FILL_PORTION                     Portion of region pixels that are valid (%)
CLOUDLESS  CLOUDLESS_PORTION                Portion of filled pixels that are cloud/shadow
                                            free (%)
RADQ       RADIOMETRIC_QUALITY              Radiometric quality check
GEOMQ      GEOMETRIC_QUALITY                Geometric quality check
SAA        MEAN_SOLAR_AZIMUTH_ANGLE         Solar azimuth angle (deg)
SZA        MEAN_SOLAR_ZENITH_ANGLE          Solar zenith angle (deg)
VAA        MEAN_INCIDENCE_AZIMUTH_ANGLE_B1  View (B1) azimuth angle (deg)
VZA        MEAN_INCIDENCE_ZENITH_ANGLE_B1   View (B1) zenith angle (deg)

Search Results:

ID                                                      DATE             FILL CLOUDLESS RADQ   GEOMQ     SAA   SZA    VAA  VZA
------------------------------------------------------- ---------------- ---- --------- ------ ------ ------ ----- ------ ----
COPERNICUS/S2_SR/20220118T152641_20220118T153117_T18NVL 2022-01-18 15:31  100     66.63 PASSED PASSED 136.70 35.36 103.18 6.72
COPERNICUS/S2_SR/20220128T152641_20220128T153044_T18NVL 2022-01-28 15:31  100     95.31 PASSED PASSED 132.91 34.23 103.26 6.71
COPERNICUS/S2_SR/20220202T152639_20220202T152919_T18NVL 2022-02-02 15:31  100    100.00 PASSED PASSED 130.73 33.51 104.46 6.74
COPERNICUS/S2_SR/20220212T152639_20220212T153104_T18NVL 2022-02-12 15:31  100     90.07 PASSED PASSED 125.88 31.77 104.20 6.72
COPERNICUS/S2_SR/20220222T152639_20220222T152811_T18NVL 2022-02-22 15:31  100     98.03 PASSED PASSED 120.23 29.78 104.36 6.71

Download reference image

Let’s download COPERNICUS/S2_SR/20220202T152639_20220202T152919_T18NVL which is 100% cloudless and fairly close to the source capture date.

[6]:
ref_path = Path('images/s2_sr_reference.tif')
gd_image = gd.MaskedImage.from_id(
    'COPERNICUS/S2_SR/20220202T152639_20220202T152919_T18NVL', mask=True
)
gd_image.download(ref_path, region=region, overwrite=True)

# equivalent geedim command line:
# !geedim download -i COPERNICUS/S2_SR/20220202T152639_20220202T152919_T18NVL --mask -r {src_path}

Surface reflectance correction

This is the main step, where we correct the drone image by fusion with the Sentinel-2 reference.

The scale of variation that correction can adjust for is roughly the size of the kernel. With a large disparity between the source (5 cm) and reference spatial (10 m) resolutions, it is best to keep the kernel size small. For this example, we use the gain-blk-offset model and a kernel shape of 3 x 3 pixels. Of the tested settings, these produced the best accuracy.

[7]:
from homonim import RasterFuse, Model
corr_path = images_path.joinpath(src_path.stem + '_FUSE.tif')
[8]:
with RasterFuse(src_path, ref_path) as raster_fuse:
    print(f'{corr_path.name}:')
    # incease max_block_mem below so that there is one block (and offset)
    # per band
    raster_fuse.process(
        corr_path, Model.gain_blk_offset, (3, 3),
        block_config=dict(max_block_mem=1024),
        out_profile=dict(dtype='uint16', nodata=0),
        overwrite=True
    )

    # equivalent homonim command line:
    # !homonim fuse -m gain-blk-offset -k 3 3 -o {src_path} {ref_path}
ES_WMM3_2022_02_08_Pereira_RGB_FUSE.tif:

Visualisation

Next, we display matching extents of the source, reference and corrected images, in the reference CRS.

[9]:
from rasterio.plot import show
from rasterio.vrt import WarpedVRT
from rasterio.enums import Resampling

fig, axes = pyplot.subplots(
    3, 1, sharex=True, sharey=True, tight_layout=True, figsize=(8, 12),
    dpi=72,
)

# get the reference CRS to reproject source & corrected into
with rio.open(ref_path, 'r') as ds:
    dst_crs = ds.crs

# loop over source, reference and corrected images, and their corresponding
# settings
for im_file, ds_fact, sc_off, indexes, ax, label in zip(
    [src_path, ref_path, corr_path],
    [4, 1, 4],                           # downsampling factors
    [[255, 0], [3500, .2], [3500, .2]],  # colour scale & offset
    [None, [4, 3, 2], None],             # RGB band indices
    axes,
    ['Source', 'Reference', 'Corrected'],
):
    # reproject into reference CRS
    with rio.open(im_file, 'r') as _ds, WarpedVRT(
        _ds, crs=dst_crs, resampling=Resampling.bilinear,
    ) as ds:
        # read and downsample image
        ds_shape = tuple(np.round(np.array(ds.shape) / ds_fact).astype('int'))
        transform = ds.transform * rio.Affine.scale(ds_fact)
        array = ds.read(indexes=indexes, out_dtype='float32', out_shape=ds_shape)

        # change nodata value to nan
        mask = np.any((array == ds.nodata) | np.isnan(array), axis=(0))
        array[:, mask] = np.nan

        # scale and offset pixel values
        array = np.clip((array / sc_off[0]) - sc_off[1], 0, 1)

    # display image
    ax = show(array, transform=transform, interpolation='nearest', ax=ax)
    ax.set_title(label, fontweight='bold')
    ax.axis('off')

# fig.savefig('../case_studies/_drone_mosaic-src_ref_corr.jpg', dpi=92)
../_images/tutorials_drone_correction_15_0.png

The corrected and reference image colours correspond visually. The next section quantifies the surface reflectance differences between source and corrected images.

Evaluation

Here we compare the source and corrected images with a surface reflectance reference to get an idea of accuracy. To avoid biasing accuracy estimates in the case of overfitting, we compare with an “independent” Landsat-8 reference i.e. a reference not used for correction.

Download comparison reference

We use geedim again to search for and download a Landsat-8 reference.

[10]:
# search the Landsat-8 collection for >50% cloudless images
l8_coll = gd.MaskedCollection.from_name('LANDSAT/LC08/C02/T1_L2')
l8_coll = l8_coll.search(
    '2022-01-01', '2022-03-01', region, cloudless_portion=50,
)
print('Image property descriptions:\n\n' + l8_coll.schema_table)
print('\nSearch Results:\n\n' + l8_coll.properties_table)

# equivalent geedim command line:
# !geedim search -c l8-c2-l2 -s 2022-01-01 -e 2022-03-01 -r {src_path} -cp 50

# download LANDSAT/LC08/C02/T1_L2/LC08_009057_20220215
cmp_ref_path = Path('images/l8_reference.tif')
gd_image = gd.MaskedImage.from_id(
    'LANDSAT/LC08/C02/T1_L2/LC08_009057_20220215', mask=True
)
gd_image.download(cmp_ref_path, region=region, overwrite=True)

# equivalent geedim command line:
# !geedim download -i LANDSAT/LC08/C02/T1_L2/LC08_009057_20220215 --mask -r {src_path}

Image property descriptions:

ABBREV     NAME                  DESCRIPTION
---------  --------------------  ----------------------------------------------
ID         system:id             Earth Engine image id
DATE       system:time_start     Image capture date/time (UTC)
FILL       FILL_PORTION          Portion of region pixels that are valid (%)
CLOUDLESS  CLOUDLESS_PORTION     Portion of filled pixels that are cloud/shadow
                                 free (%)
GRMSE      GEOMETRIC_RMSE_MODEL  Orthorectification RMSE (m)
SAA        SUN_AZIMUTH           Solar azimuth angle (deg)
SEA        SUN_ELEVATION         Solar elevation angle (deg)

Search Results:

ID                                          DATE              FILL CLOUDLESS GRMSE    SAA   SEA
------------------------------------------- ---------------- ----- --------- ----- ------ -----
LANDSAT/LC08/C02/T1_L2/LC08_009057_20220215 2022-02-15 15:18 99.85       100  7.94 120.07 55.94

Comparison

In this section we compare the source, and corrected similarity with the Landsat-8 reference.

To start, we produce comparison tables using the RasterCompare class.

[11]:
print(RasterCompare.schema_table())

# loop over the source and corrected image files
for im_path, im_label in zip(
    [src_path, corr_path],
    ['Source', 'Corrected'],
):
    with RasterCompare(
        im_path, cmp_ref_path,
    ) as compare:
        # print a table of comparison statistics (the typical way of using
        # RasterCompare)
        print(f'{im_label}:')
        stats_dict = compare.process()
        print(f'{im_label} comparison:\n\n' + compare.stats_table(stats_dict))

    # equivalent homonim command line:
    # !homonim compare {im_path} {cmp_ref_path}
ABBREV DESCRIPTION
------ -----------------------------------------
r²     Pearson's correlation coefficient squared
RMSE   Root Mean Square Error
rRMSE  Relative RMSE (RMSE/mean(ref))
N      Number of pixels
Source:
Source comparison:

 Band    r²      RMSE rRMSE   N
----- ----- --------- ----- ---
SR_B4 0.461 10694.354 1.013 405
SR_B3 0.383 10729.497 1.007 405
SR_B2 0.463  9474.853 1.010 405
 Mean 0.436 10299.568 1.010 405
Corrected:
Corrected comparison:

 Band    r²     RMSE rRMSE   N
----- ----- -------- ----- ---
SR_B4 0.908 8596.260 0.814 405
SR_B3 0.905 8574.129 0.805 405
SR_B2 0.866 7435.287 0.793 405
 Mean 0.893 8201.892 0.804 405

Now we create scatter plots for each of the spectral bands.

[12]:
fig, axes = pyplot.subplots(
    2, 3, sharex='all', sharey='row', tight_layout=True, figsize=(9, 6), dpi=92
)

# loop over the source and corrected image files and corresponding axes etc
for im_i, im_path, im_label in zip(
    range(2),
    [src_path, corr_path],
    ['Source', 'Corrected'],
):
    with RasterCompare(im_path, cmp_ref_path) as compare:
        # produce per-band scatter plots of source/corrected - reference
        # surface reflectance
        # (note that in RasterCompare.block_pairs a 'block' takes the size of a
        # band by default)
        for band_i, block_pair, band_label in zip(
            range(3),
            compare.block_pairs(),
            ['Red', 'Green', 'Blue']
        ):
            # read source/corrected - reference band pair, and reproject the
            # source/corrected band to the reference CRS and pixel grid
            src_ra, ref_ra = compare.read(block_pair)
            src_ra = src_ra.reproject(
                **ref_ra.proj_profile, resampling='average'
            )

            # vectors of valid pixels in the source/corrected and reference bands
            mask = src_ra.mask & ref_ra.mask  # mask of valid pixels
            src_v, ref_v = src_ra.array[mask], ref_ra.array[mask]
            r2 = np.corrcoef(src_v, ref_v)[0, 1] ** 2

            # create scatter plot
            ax = axes[im_i, band_i]
            ax.plot(ref_v, src_v, '.', alpha=0.25)
            ax.set_xlabel('Reference')
            ax.set_ylabel(im_label)
            ax.set_title(band_label, fontweight='bold')
            ax.text(.7, .1, f'$r^2={r2:.2f}$', transform=ax.transAxes)

# fig.savefig(
#     '../case_studies/drone_mosaic-eval.png', facecolor='white',
#     transparent=False, dpi=92,
# )
../_images/tutorials_drone_correction_22_0.png

The tables and scatter plots show an improvement in correlation with the Landsat-8 reference after correction.