Skip to content

earthlib.Unmix

Routines for performing spectral unmixing on earth engine images.

BVN(img, **normalization)

Unmixes using Burned-Vegetation-NonphotosyntheticVegetation (BVN) endmembers.

Parameters:

Name Type Description Default
img ee.Image

the ee.Image to unmix.

required
**normalization

keyword arguments to pass to fractionalCover(), like shade_normalize=True.

{}

Returns:

Name Type Description
unmixed ee.Image

a 4-band image file in order of (burned-veg-npv-soil).

Source code in earthlib/Unmix.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
def BVN(img: ee.Image, **normalization) -> ee.Image:
    """Unmixes using Burned-Vegetation-NonphotosyntheticVegetation (BVN) endmembers.

    Args:
        img: the ee.Image to unmix.
        **normalization: keyword arguments to pass to fractionalCover(),
            like shade_normalize=True.

    Returns:
        unmixed: a 4-band image file in order of (burned-veg-npv-soil).
    """
    endmembers = [burn, pv, npv]
    endmember_names = ["burned", "pv", "npv"]
    unmixed = fractionalCover(img, endmembers, endmember_names, **normalization)

    return unmixed

Initialize(sensor, n=30, bands=None)

Initializes sensor-specific global variables.

This must be run before any of the specific unmixing routines are run.

Parameters:

Name Type Description Default
sensor str

the name of the sensor (from earthlib.listSensors()).

required
n int

the number of iterations for unmixing.

30
bands list

a list of bands to select (from earthlib.getBands(sensor)).

None
Source code in earthlib/Unmix.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def Initialize(sensor: str, n: int = 30, bands: list = None) -> None:
    """Initializes sensor-specific global variables.

    This must be run before any of the specific unmixing routines are run.

    Args:
        sensor: the name of the sensor (from earthlib.listSensors()).
        n: the number of iterations for unmixing.
        bands: a list of bands to select (from earthlib.getBands(sensor)).
    """
    pv_list = selectSpectra("vegetation", sensor, n, bands)
    npv_list = selectSpectra("npv", sensor, n, bands)
    soil_list = selectSpectra("bare", sensor, n, bands)
    burn_list = selectSpectra("burn", sensor, n, bands)
    urban_list = selectSpectra("urban", sensor, n, bands)

    # create a series of global variables for later
    global pv
    global npv
    global soil
    global burn
    global urban

    # then convert them to ee lists
    pv = [ee.List(pv_spectra.tolist()) for pv_spectra in pv_list]
    npv = [ee.List(npv_spectra.tolist()) for npv_spectra in npv_list]
    soil = [ee.List(soil_spectra.tolist()) for soil_spectra in soil_list]
    burn = [ee.List(burn_spectra.tolist()) for burn_spectra in burn_list]
    urban = [ee.List(urban_spectra.tolist()) for urban_spectra in urban_list]

SVN(img, **normalization)

Unmixes using Soil-Vegetation-NonphotosyntheticVegetation (SVN) endmembers.

Parameters:

Name Type Description Default
img ee.Image

the ee.Image to unmix.

required
**normalization

keyword arguments to pass to fractionalCover(), like shade_normalize=True.

{}

Returns:

Name Type Description
unmixed ee.Image

a 3-band image file in order of (soil-veg-npv).

Source code in earthlib/Unmix.py
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
def SVN(img: ee.Image, **normalization) -> ee.Image:
    """Unmixes using Soil-Vegetation-NonphotosyntheticVegetation (SVN) endmembers.

    Args:
        img: the ee.Image to unmix.
        **normalization: keyword arguments to pass to fractionalCover(),
            like shade_normalize=True.

    Returns:
        unmixed: a 3-band image file in order of (soil-veg-npv).
    """
    endmembers = [soil, pv, npv]
    endmember_names = ["soil", "pv", "npv"]
    unmixed = fractionalCover(img, endmembers, endmember_names, **normalization)

    return unmixed

VIS(img, **normalization)

Unmixes according to the Vegetation-Impervious-Soil (VIS) approach.

Parameters:

Name Type Description Default
img ee.Image

the ee.Image to unmix.

required
**normalization

keyword arguments to pass to fractionalCover(), like shade_normalize=True.

{}

Returns:

Type Description
ee.Image

a 3-band image file in order of (soil-veg-impervious).

Source code in earthlib/Unmix.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
def VIS(img: ee.Image, **normalization) -> ee.Image:
    """Unmixes according to the Vegetation-Impervious-Soil (VIS) approach.

    Args:
        img: the ee.Image to unmix.
        **normalization: keyword arguments to pass to fractionalCover(),
            like shade_normalize=True.

    Returns:
        a 3-band image file in order of (soil-veg-impervious).
    """
    endmembers = [soil, pv, urban]
    endmember_names = ["soil", "pv", "impervious"]
    unmixed = fractionalCover(img, endmembers, endmember_names, **normalization)

    return unmixed

computeModeledSpectra(endmembers, fractions)

Constructs a modeled spectrum for each pixel based on endmember fractions.

Parameters:

Name Type Description Default
endmembers list

a list of ee.List() items, each representing an endmember spectrum.

required
fractions ee.Image

ee.Image output from .unmix() with the same number of bands as items in endmembers.

required

Returns:

Type Description
ee.Image

an ee.Image with n_bands equal to the number of endmember bands.

Source code in earthlib/Unmix.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def computeModeledSpectra(endmembers: list, fractions: ee.Image) -> ee.Image:
    """Constructs a modeled spectrum for each pixel based on endmember fractions.

    Args:
        endmembers: a list of ee.List() items, each representing an endmember spectrum.
        fractions: ee.Image output from .unmix() with the same number of bands as items in `endmembers`.

    Returns:
        an ee.Image with n_bands equal to the number of endmember bands.
    """
    # compute the number of endmember bands
    nb = int(endmembers[0].length().getInfo())
    band_range = list(range(nb))
    band_names = [f"M{band:02d}" for band in band_range]

    # create a list to store each reflectance fraction
    refl_fraction_images = list()

    # loop through each endmember and mulitply the fraction estimated by the reflectance value
    for i, endmember in enumerate(endmembers):
        fraction = fractions.select([i])
        refl_fraction_list = [
            fraction.multiply(ee.Image(endmember.get(band).getInfo()))
            for band in band_range
        ]
        refl_fraction_images.append(
            ee.ImageCollection.fromImages(refl_fraction_list)
            .toBands()
            .select(band_range, band_names)
        )

    # convert these images to an image collection and sum them together to reconstruct the spectrum
    modeled_reflectance = (
        ee.ImageCollection.fromImages(refl_fraction_images)
        .sum()
        .toFloat()
        .select(band_range, band_names)
    )

    return modeled_reflectance

computeSpectralRMSE(measured, modeled)

Computes root mean squared error between measured and modeled spectra.

Parameters:

Name Type Description Default
measured ee.Image

an ee.Image of measured reflectance.

required
modeled ee.Image

an ee.Image of modeled reflectance.

required

Returns:

Name Type Description
rmse ee.Image

a floating point ee.Image with pixel-wise RMSE values.

Source code in earthlib/Unmix.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def computeSpectralRMSE(measured: ee.Image, modeled: ee.Image) -> ee.Image:
    """Computes root mean squared error between measured and modeled spectra.

    Args:
        measured: an ee.Image of measured reflectance.
        modeled: an ee.Image of modeled reflectance.

    Returns:
        rmse: a floating point ee.Image with pixel-wise RMSE values.
    """
    # harmonize band info to ensure element-wise computation
    band_names = list(measured.bandNames().getInfo())
    band_range = list(range(len(band_names)))

    # compute rmse
    rmse = (
        measured.select(band_range, band_names)
        .subtract(modeled.select(band_range, band_names))
        .pow(2)
        .reduce(ee.Reducer.sum())
        .sqrt()
        .select([0], ["RMSE"])
        .toFloat()
    )

    return rmse

computeWeight(fractions, rmse_sum)

Computes the relative weight for an image's RMSE based on the sum of the global RMSE.

Parameters:

Name Type Description Default
fractions ee.Image

a multi-band ee.Image object with an 'RMSE' band.

required
rmse_sum ee.Image

a single-band ee.Image object with the global RMSE value.

required

Returns:

Type Description
ee.Image

the input fractions image with a 'weight' band added.

Source code in earthlib/Unmix.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def computeWeight(fractions: ee.Image, rmse_sum: ee.Image) -> ee.Image:
    """Computes the relative weight for an image's RMSE based on the sum of the global RMSE.

    Args:
        fractions: a multi-band ee.Image object with an 'RMSE' band.
        rmse_sum: a single-band ee.Image object with the global RMSE value.

    Returns:
        the input `fractions` image with a 'weight' band added.
    """
    rmse = fractions.select(["RMSE"])
    ratio = rmse.divide(rmse_sum).toFloat().select([0], ["ratio"])
    weight = ee.Image(1).subtract(ratio).select([0], ["weight"])
    unweighted = fractions.addBands([weight, ratio])

    return unweighted

fractionalCover(img, endmembers, endmember_names, shade_normalize=True)

Computes the percent cover of each endmember spectra.

Parameters:

Name Type Description Default
img ee.Image

the ee.Image to unmix.

required
endmembers list

lists of ee.List objects, each element corresponding to a subType.

required
endmember_names list

list of names for each endmember. must match the number of lists passed.

required
shade_normalize bool

flag to apply shade normalization during unmixing.

True

Returns:

Name Type Description
unmixed ee.Image

a 3-band image file in order of (soil-veg-impervious).

Source code in earthlib/Unmix.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def fractionalCover(
    img: ee.Image,
    endmembers: list,
    endmember_names: list,
    shade_normalize: bool = True,
) -> ee.Image:
    """Computes the percent cover of each endmember spectra.

    Args:
        img: the ee.Image to unmix.
        endmembers: lists of ee.List objects, each element corresponding to a subType.
        endmember_names: list of names for each endmember. must match the number of lists passed.
        shade_normalize: flag to apply shade normalization during unmixing.

    Returns:
        unmixed: a 3-band image file in order of (soil-veg-impervious).
    """
    n_bands = len(list(img.bandNames().getInfo()))
    n_classes = len(endmembers)
    n_endmembers = len(endmembers[0])
    band_numbers = list(range(n_classes))
    shade = ee.List([0] * n_bands)

    # create a list of images to append and later convert to an image collection
    unmixed = list()

    # loop through each iteration and unmix each
    for spectra in tqdm(list(zip(*endmembers)), total=n_endmembers, desc="Unmixing"):

        if shade_normalize:
            spectra += (shade,)

        unmixed_iter = img.unmix(spectra, True, True).toFloat()

        # run the forward model to evaluate the fractional cover fit
        modeled_reflectance = computeModeledSpectra(spectra, unmixed_iter)
        rmse = computeSpectralRMSE(img, modeled_reflectance)

        # normalize by the observed shade fraction
        if shade_normalize:
            shade_fraction = unmixed_iter.select([n_classes]).subtract(1).abs()
            unmixed_iter = unmixed_iter.divide(shade_fraction)

        # rename the bands and append an rmse band
        unmixed.append(
            unmixed_iter.select(band_numbers, endmember_names).addBands(rmse)
        )

    # use the sum of rmse to weight each estimate
    rmse_sum = ee.Image(
        ee.ImageCollection.fromImages(unmixed)
        .select(["RMSE"])
        .sum()
        .select([0], ["SUM"])
        .toFloat()
    )
    unscaled = [computeWeight(fractions, rmse_sum) for fractions in unmixed]

    # use these weights to scale each unmixing estimate
    weight_sum = ee.Image(
        ee.ImageCollection.fromImages(unscaled).select(["weight"]).sum().toFloat()
    )
    scaled = [weightedAverage(fractions, weight_sum) for fractions in unscaled]

    # reduce it to a single image and return
    unmixed = ee.ImageCollection.fromImages(scaled).sum().toFloat()

    return unmixed

weightedAverage(fractions, weight_sum)

Computes an RMSE-weighted fractional cover image.

Parameters:

Name Type Description Default
fractions ee.Image

a multi-band ee.Image object with a 'weight' band.

required
weight_sum ee.Image

a single-band ee.Image object with the global weight sum.

required

Returns:

Type Description
ee.Image

a scaled fractional cover image.

Source code in earthlib/Unmix.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def weightedAverage(fractions: ee.Image, weight_sum: ee.Image) -> ee.Image:
    """Computes an RMSE-weighted fractional cover image.

    Args:
        fractions: a multi-band ee.Image object with a 'weight' band.
        weight_sum: a single-band ee.Image object with the global weight sum.

    Returns:
        a scaled fractional cover image.
    """
    # harmonize band info
    band_names = list(fractions.bandNames().getInfo())
    band_names.pop(band_names.index("weight"))
    band_range = list(range(len(band_names)))

    scaler = fractions.select(["weight"]).divide(weight_sum)
    weighted = fractions.select(band_range, band_names).multiply(scaler)

    return weighted