"""
Get images and organize them so they are easier to work with
geeViz.getImagesLib is the core module for setting up various imageCollections from GEE. Notably, it facilitates Landsat, Sentinel-2, and MODIS data organization. This module helps avoid many common mistakes in GEE. Most functions ease matching band names, ensuring resampling methods are properly set, date wrapping, and helping with cloud and cloud shadow masking.
"""
"""
Copyright 2026 Ian Housman
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
# %%
# Script to help with data prep, analysis, and delivery from GEE
# Intended to work within the geeViz package
######################################################################
from geeViz.geeView import *
import geeViz.cloudStorageManagerLib as cml
import geeViz.assetManagerLib as aml
import geeViz.taskManagerLib as tml
import math, ee, json, pdb, datetime
from threading import Thread
# %%
######################################################################
# Module for getting Landsat, Sentinel 2 and MODIS images/composites
# Define visualization parameters
vizParamsFalse = {
"min": 0.05,
"max": [0.5, 0.6, 0.6],
"bands": "swir1,nir,red",
"gamma": 1.6,
}
vizParamsFalse10k = {
"min": 0.05 * 10000,
"max": [0.5 * 10000, 0.6 * 10000, 0.6 * 10000],
"bands": "swir1,nir,red",
"gamma": 1.6,
}
vizParamsTrue = {"min": 0, "max": [0.2, 0.2, 0.2], "bands": "red,green,blue"}
vizParamsTrue10k = {
"min": 0,
"max": [0.2 * 10000, 0.2 * 10000, 0.2 * 10000],
"bands": "red,green,blue",
}
common_projections = {}
common_projections["NLCD_CONUS"] = {
"crs": 'PROJCS["Albers_Conical_Equal_Area",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Albers_Conic_Equal_Area"],PARAMETER["latitude_of_center",23],PARAMETER["longitude_of_center",-96],PARAMETER["standard_parallel_1",29.5],PARAMETER["standard_parallel_2",45.5],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["meters",1],AXIS["Easting",EAST],AXIS["Northing",NORTH]]',
"transform": [30, 0, -2361915.0, 0, -30, 3177735.0],
}
common_projections["NLCD_AK"] = {
"crs": 'PROJCS["Albers_Conical_Equal_Area",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9108"]],AUTHORITY["EPSG","4326"]],PROJECTION["Albers_Conic_Equal_Area"],PARAMETER["standard_parallel_1",55],PARAMETER["standard_parallel_2",65],PARAMETER["latitude_of_center",50],PARAMETER["longitude_of_center",-154],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["meters",1]]',
"transform": [30, 0, -48915.0, 0, -30, 1319415.0],
}
common_projections["NLCD_HI"] = {
"crs": 'PROJCS["Albers_Conical_Equal_Area",GEOGCS["WGS 84",DATUM["WGS_1984", SPHEROID["WGS 84", 6378137.0, 298.257223563, AUTHORITY["EPSG","7030"]], AUTHORITY["EPSG","6326"]], PRIMEM["Greenwich", 0.0], UNIT["degree", 0.017453292519943295], AXIS["Longitude", EAST], AXIS["Latitude", NORTH], AUTHORITY["EPSG","4326"]], PROJECTION["Albers_Conic_Equal_Area"], PARAMETER["central_meridian", -157.0],PARAMETER["latitude_of_origin", 3.0],PARAMETER["standard_parallel_1", 8.0],PARAMETER["false_easting", 0.0],PARAMETER["false_northing", 0.0],PARAMETER["standard_parallel_2", 18.0],UNIT["m", 1.0],AXIS["x", EAST],AXIS["y", NORTH]]',
"transform": [30, 0, -342585, 0, -30, 2127135],
}
######################################################################
# UTM zone and EPSG code utilities
######################################################################
[docs]
def getUTMZone(longitude: float) -> int:
"""Return the UTM zone number (1-60) for a given longitude.
Args:
longitude: Longitude in decimal degrees (-180 to 180).
Returns:
UTM zone number (1-60).
Examples:
>>> getUTMZone(-113.15)
12
>>> getUTMZone(2.35)
31
"""
longitude = ((longitude + 180) % 360) - 180 # normalize to [-180, 180)
zone = int((longitude + 180) / 6) + 1
return min(zone, 60)
# Mapping of datum name -> (north EPSG prefix, south EPSG prefix)
# The full code is prefix * 100 + zone (e.g. WGS84 North zone 12 = 32600 + 12 = 32612)
_UTM_DATUM_EPSG = {
"WGS84": (326, 327),
"NAD83": (269, 321), # NAD83 North: EPSG:269xx, South: EPSG:321xx
"NAD27": (267, 267), # NAD27: EPSG:267xx (North America only, N hemisphere)
"WGS72": (322, 323),
"ETRS89": (258, 258), # ETRS89: EPSG:258xx (Europe only, N hemisphere)
"GDA94": (283, 283), # GDA94: EPSG:283xx (Australia, S hemisphere)
"GDA2020": (78, 78), # GDA2020: EPSG:78xx (Australia, zones 46-56)
"SIRGAS2000": (311, 317), # SIRGAS2000 North: 311xx, South: 317xx
}
[docs]
def getUTMEpsg(location, datum: str = "WGS84") -> str:
"""Return the EPSG code string for a UTM zone given a location and datum.
Combines :func:`getUTMZone` with a datum lookup to produce the full
EPSG code (e.g. ``"EPSG:32612"`` for WGS84 UTM Zone 12N).
Args:
location: One of:
- ``[longitude, latitude]`` list/tuple (GEE convention: lon first).
- ``ee.Geometry.Point`` — coordinates are extracted via ``.getInfo()``.
datum: Datum name. One of ``"WGS84"`` (default), ``"NAD83"``, ``"NAD27"``,
``"WGS72"``, ``"ETRS89"``, ``"GDA94"``, ``"GDA2020"``, ``"SIRGAS2000"``.
Case-insensitive.
Returns:
EPSG code string, e.g. ``"EPSG:32612"``.
Raises:
ValueError: If the datum is not recognized or ``location`` is not a
supported type.
Examples:
>>> getUTMEpsg([-113.15, 47.15])
'EPSG:32612'
>>> getUTMEpsg([-113.15, 47.15], datum="NAD83")
'EPSG:26912'
>>> getUTMEpsg([151.21, -33.86])
'EPSG:32756'
>>> getUTMEpsg(ee.Geometry.Point([-113.15, 47.15]))
'EPSG:32612'
"""
# Parse location into (longitude, latitude)
if isinstance(location, ee.Geometry):
coords = location.coordinates().getInfo()
longitude, latitude = coords[0], coords[1]
elif isinstance(location, (list, tuple)) and len(location) >= 2:
longitude, latitude = location[0], location[1]
else:
raise ValueError(
f"location must be a [lon, lat] list/tuple or ee.Geometry.Point, "
f"got {type(location).__name__}"
)
datum_upper = datum.upper().replace(" ", "").replace("-", "")
if datum_upper not in _UTM_DATUM_EPSG:
raise ValueError(
f"Unknown datum '{datum}'. Supported: {', '.join(sorted(_UTM_DATUM_EPSG.keys()))}"
)
north_prefix, south_prefix = _UTM_DATUM_EPSG[datum_upper]
zone = getUTMZone(longitude)
prefix = north_prefix if latitude >= 0 else south_prefix
return f"EPSG:{prefix}{zone:02d}"
# Direction of a decrease in photosynthetic vegetation- add any that are missing
changeDirDict = {
"blue": 1,
"green": 1,
"red": 1,
"nir": -1,
"swir1": 1,
"swir2": 1,
"temp": 1,
"NDVI": -1,
"NBR": -1,
"NDMI": -1,
"NDSI": 1,
"brightness": 1,
"greenness": -1,
"wetness": -1,
"fourth": -1,
"fifth": 1,
"sixth": -1,
"ND_blue_green": -1,
"ND_blue_red": -1,
"ND_blue_nir": 1,
"ND_blue_swir1": -1,
"ND_blue_swir2": -1,
"ND_green_red": -1,
"ND_green_nir": 1,
"ND_green_swir1": -1,
"ND_green_swir2": -1,
"ND_red_swir1": -1,
"ND_red_swir2": -1,
"ND_nir_red": -1,
"ND_nir_swir1": -1,
"ND_nir_swir2": -1,
"ND_swir1_swir2": -1,
"R_swir1_nir": 1,
"R_red_swir1": -1,
"EVI": -1,
"SAVI": -1,
"IBI": 1,
"tcAngleBG": -1,
"tcAngleGW": -1,
"tcAngleBW": -1,
"tcDistBG": 1,
"tcDistGW": 1,
"tcDistBW": 1,
"NIRv": -1,
"NDCI": -1,
"NDGI": -1,
}
# Precomputed cloudscore offsets and TDOM stats
# These have been pre-computed for all CONUS for Landsat and Setinel 2 (separately)
# and are appropriate to use for any time period within the growing season
# The cloudScore offset is generally some lower percentile of cloudScores on a pixel-wise basis
# The TDOM stats are the mean and standard deviations of the two bands used in TDOM
# By default, TDOM uses the nir and swir1 bands
preComputedCloudScoreOffset = ee.ImageCollection("projects/lcms-tcc-shared/assets/CS-TDOM-Stats/cloudScore").mosaic()
preComputedTDOMStats = ee.ImageCollection("projects/lcms-tcc-shared/assets/CS-TDOM-Stats/TDOM").filter(ee.Filter.eq("endYear", 2019)).mosaic().divide(10000)
[docs]
def getPrecomputedCloudScoreOffsets(cloudScorePctl=10):
"""Retrieves precomputed cloud score offset images for Landsat and Sentinel-2.
These offsets represent a lower percentile of cloud scores on a pixel-wise basis,
precomputed for all CONUS. They are appropriate for any time period within the
growing season.
Args:
cloudScorePctl (int, optional): The cloud score percentile to use. Defaults to ``10``.
Returns:
dict: A dictionary with keys ``"landsat"`` and ``"sentinel2"``, each containing
an ``ee.Image`` of the cloud score offset for that sensor.
Examples:
>>> offsets = getPrecomputedCloudScoreOffsets(10)
>>> landsat_offset = offsets["landsat"]
>>> sentinel2_offset = offsets["sentinel2"]
"""
return {
"landsat": preComputedCloudScoreOffset.select(["Landsat_CloudScore_p{}".format(cloudScorePctl)]),
"sentinel2": preComputedCloudScoreOffset.select(["Sentinel2_CloudScore_p{}".format(cloudScorePctl)]),
}
[docs]
def getPrecomputedTDOMStats():
"""Retrieves precomputed TDOM (Temporal Dark Outlier Mask) statistics for Landsat and Sentinel-2.
Returns the mean and standard deviation of the NIR and SWIR1 bands, precomputed
for all CONUS. These are used by the TDOM cloud shadow masking algorithm.
Returns:
dict: A nested dictionary with keys ``"landsat"`` and ``"sentinel2"``, each
containing ``"mean"`` and ``"stdDev"`` keys mapped to ``ee.Image`` objects
with the corresponding band statistics.
Examples:
>>> stats = getPrecomputedTDOMStats()
>>> landsat_mean = stats["landsat"]["mean"]
>>> sentinel2_stddev = stats["sentinel2"]["stdDev"]
"""
return {
"landsat": {
"mean": preComputedTDOMStats.select(["Landsat_nir_mean", "Landsat_swir1_mean"]),
"stdDev": preComputedTDOMStats.select(["Landsat_nir_stdDev", "Landsat_swir1_stdDev"]),
},
"sentinel2": {
"mean": preComputedTDOMStats.select(["Sentinel2_nir_mean", "Sentinel2_swir1_mean"]),
"stdDev": preComputedTDOMStats.select(["Sentinel2_nir_stdDev", "Sentinel2_swir1_stdDev"]),
},
}
######################################################################
# FUNCTIONS
######################################################################
######################################################################
# Function to asynchronously print ee objects
[docs]
def printEE(eeObject, message=""):
"""Asynchronously prints an Earth Engine object by fetching its value in a background thread.
Args:
eeObject (ee.ComputedObject): Any Earth Engine object to print (e.g., ``ee.Image``,
``ee.Number``, ``ee.Dictionary``).
message (str, optional): A message to print before the object value. Defaults to ``""``.
Returns:
None
Examples:
>>> img = ee.Image("USGS/SRTMGL1_003")
>>> printEE(img.bandNames(), "Band names:")
"""
def printIt(eeObject):
print(message, eeObject.getInfo())
print()
t = Thread(target=printIt, args=(eeObject,))
t.start()
######################################################################
######################################################################
# Function to set null value for export or conversion to arrays
[docs]
def setNoData(image: ee.Image, noDataValue: float) -> ee.Image:
"""Sets null values for an image, replacing masked pixels with a constant.
Useful for preparing images for export or conversion to arrays where null
values are not supported.
Args:
image (ee.Image): The input Earth Engine image.
noDataValue (float): The value to assign to null (masked) pixels.
Returns:
ee.Image: The image with null pixels replaced by ``noDataValue``.
Examples:
>>> img = ee.Image("USGS/SRTMGL1_003")
>>> filled = setNoData(img, -9999)
"""
image = image.unmask(noDataValue, False) # .set('noDataValue', noDataValue)
return image # .set(args)
######################################################################
######################################################################
# Formats arguments as strings so can be easily set as properties
######################################################################
######################################################################
# Functions to perform basic clump and elim
[docs]
def sieve(image: ee.Image, mmu: float) -> ee.Image:
"""Performs clumping and elimination (sieving) on a classified image.
Removes patches smaller than the minimum mapping unit by replacing them
with the focal mode of surrounding pixels.
Args:
image (ee.Image): The input classified Earth Engine image.
mmu (float): The minimum mapping unit in pixels. Patches smaller than
this will be replaced by the focal mode.
Returns:
ee.Image: The sieved image with small patches eliminated.
Examples:
>>> classified = ee.Image("USGS/NLCD/NLCD2019").select("landcover")
>>> sieved = sieve(classified, 5)
"""
args = formatArgs(locals())
connected = image.connectedPixelCount(mmu + 20)
# Map.addLayer(connected,{'min':1,'max':mmu},'connected')
elim = connected.gt(mmu)
mode = image.focal_mode(mmu / 2, "circle")
mode = mode.mask(image.mask())
filled = image.where(elim.Not(), mode)
return filled.set("mmu", mmu).set(args)
# Written by Yang Z.
# ------ L8 to L7 HARMONIZATION FUNCTION -----
# slope and intercept citation: Roy, D.P., Kovalskyy, V., Zhang, H.K., Vermote, E.F., Yan, L., Kumar, S.S, Egorov, A., 2016, Characterization of Landsat-7 to Landsat-8 reflective wavelength and normalized difference vegetation index continuity, Remote Sensing of Environment, 185, 57-70.(http://dx.doi.org/10.1016/j.rse.2015.12.024); Table 2 - reduced major axis (RMA) regression coefficients
[docs]
def harmonizationRoy(oli: ee.Image) -> ee.Image:
"""Harmonizes Landsat 8 OLI to Landsat 7 ETM+ using Roy et al. (2016) coefficients.
Applies reduced major axis (RMA) regression coefficients from Roy, D.P. et al.
(2016) to transform OLI reflectance to ETM+ equivalent. Operates on the
blue, green, red, nir, swir1, and swir2 bands.
Args:
oli (ee.Image): A Landsat 8 OLI image with bands named ``"blue"``, ``"green"``,
``"red"``, ``"nir"``, ``"swir1"``, ``"swir2"``.
Returns:
ee.Image: The image with spectral bands adjusted to ETM+ equivalents.
Examples:
>>> oli_image = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200101")
>>> harmonized = harmonizationRoy(oli_image)
"""
slopes = ee.Image.constant([0.9785, 0.9542, 0.9825, 1.0073, 1.0171, 0.9949]) # create an image of slopes per band for L8 TO L7 regression line - David Roy
itcp = ee.Image.constant([-0.0095, -0.0016, -0.0022, -0.0021, -0.0030, 0.0029]) # create an image of y-intercepts per band for L8 TO L7 regression line - David Roy
bns = oli.bandNames()
includeBns = ["blue", "green", "red", "nir", "swir1", "swir2"]
otherBns = bns.removeAll(includeBns)
# create an image of y-intercepts per band for L8 TO L7 regression line - David Roy
y = oli.select(includeBns).float().subtract(itcp).divide(slopes).set("system:time_start", oli.get("system:time_start"))
y = y.addBands(oli.select(otherBns)).select(bns)
return y.float()
####################################################################
# Code to implement OLI/ETM/MSI regression
# Chastain et al 2018 coefficients
# Empirical cross sensor comparison of Sentinel-2A and 2B MSI, Landsat-8 OLI, and Landsat-7 ETM+ top of atmosphere spectral characteristics over the conterminous United States
# https://www.sciencedirect.com/science/article/pii/S0034425718305212#t0020
# Left out 8a coefficients since all sensors need to be cross- corrected with bands common to all sensors
# Dependent and Independent variables can be switched since Major Axis (Model 2) linear regression was used
chastainBandNames = ["blue", "green", "red", "nir", "swir1", "swir2"]
# From Table 4
# msi = oli*slope+intercept
# oli = (msi-intercept)/slope
msiOLISlopes = [1.0946, 1.0043, 1.0524, 0.8954, 1.0049, 1.0002]
msiOLIIntercepts = [-0.0107, 0.0026, -0.0015, 0.0033, 0.0065, 0.0046]
# From Table 5
# msi = etm*slope+intercept
# etm = (msi-intercept)/slope
msiETMSlopes = [1.10601, 0.99091, 1.05681, 1.0045, 1.03611, 1.04011]
msiETMIntercepts = [-0.0139, 0.00411, -0.0024, -0.0076, 0.00411, 0.00861]
# From Table 6
# oli = etm*slope+intercept
# etm = (oli-intercept)/slope
oliETMSlopes = [1.03501, 1.00921, 1.01991, 1.14061, 1.04351, 1.05271]
oliETMIntercepts = [-0.0055, -0.0008, -0.0021, -0.0163, -0.0045, 0.00261]
# Construct dictionary to handle all pairwise combos
chastainCoeffDict = {
"MSI_OLI": [msiOLISlopes, msiOLIIntercepts, 1],
"MSI_ETM": [msiETMSlopes, msiETMIntercepts, 1],
"OLI_ETM": [oliETMSlopes, oliETMIntercepts, 1],
"OLI_MSI": [msiOLISlopes, msiOLIIntercepts, 0],
"ETM_MSI": [msiETMSlopes, msiETMIntercepts, 0],
"ETM_OLI": [oliETMSlopes, oliETMIntercepts, 0],
}
# Function to apply model in one direction
[docs]
def dir0Regression(img, slopes, intercepts):
"""Applies a forward linear regression model: ``corrected = img * slopes + intercepts``.
Used internally by :func:`harmonizationChastain` to apply Chastain et al. (2018)
cross-sensor harmonization in the forward direction.
Args:
img (ee.Image): The input image with spectral bands to correct.
slopes (list[float]): Regression slope coefficients for each band in
``chastainBandNames``.
intercepts (list[float]): Regression intercept coefficients for each band in
``chastainBandNames``.
Returns:
ee.Image: The image with corrected spectral bands and all other bands preserved.
Examples:
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200101")
>>> corrected = dir0Regression(img, msiOLISlopes, msiOLIIntercepts)
"""
bns = img.bandNames()
nonCorrectBands = bns.removeAll(chastainBandNames)
nonCorrectedBands = img.select(nonCorrectBands)
corrected = img.select(chastainBandNames).multiply(slopes).add(intercepts)
out = corrected.addBands(nonCorrectedBands).select(bns)
return out
# Applying the model in the opposite direction
[docs]
def dir1Regression(img, slopes, intercepts):
"""Applies an inverse linear regression model: ``corrected = (img - intercepts) / slopes``.
Used internally by :func:`harmonizationChastain` to apply Chastain et al. (2018)
cross-sensor harmonization in the reverse direction.
Args:
img (ee.Image): The input image with spectral bands to correct.
slopes (list[float]): Regression slope coefficients for each band in
``chastainBandNames``.
intercepts (list[float]): Regression intercept coefficients for each band in
``chastainBandNames``.
Returns:
ee.Image: The image with corrected spectral bands and all other bands preserved.
Examples:
>>> img = ee.Image("LANDSAT/LE07/C02/T1_L2/LE07_044034_20200101")
>>> corrected = dir1Regression(img, oliETMSlopes, oliETMIntercepts)
"""
bns = img.bandNames()
nonCorrectBands = bns.removeAll(chastainBandNames)
nonCorrectedBands = img.select(nonCorrectBands)
corrected = img.select(chastainBandNames).subtract(intercepts).divide(slopes)
out = corrected.addBands(nonCorrectedBands).select(bns)
return out
# Function to correct one sensor to another
[docs]
def harmonizationChastain(img: ee.Image, fromSensor: str, toSensor: str) -> ee.Image:
"""Harmonizes cross-sensor reflectance using Chastain et al. (2018) coefficients.
Supports pairwise harmonization between MSI (Sentinel-2), OLI (Landsat 8/9),
and ETM (Landsat 7) using Model 2 (Major Axis) linear regression coefficients
from Chastain et al. (2018).
Args:
img (ee.Image): The input image with bands named ``"blue"``, ``"green"``,
``"red"``, ``"nir"``, ``"swir1"``, ``"swir2"``.
fromSensor (str): Source sensor identifier. One of ``"MSI"``, ``"OLI"``, or ``"ETM"``.
toSensor (str): Target sensor identifier. One of ``"MSI"``, ``"OLI"``, or ``"ETM"``.
Returns:
ee.Image: The harmonized image with properties ``"fromSensor"`` and ``"toSensor"`` set.
Examples:
>>> oli_img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200101")
>>> harmonized = harmonizationChastain(oli_img, "OLI", "ETM")
"""
args = formatArgs(locals())
# Get the model for the given from and to sensor
comboKey = fromSensor.upper() + "_" + toSensor.upper()
coeffList = chastainCoeffDict[comboKey]
slopes = coeffList[0]
intercepts = coeffList[1]
direction = ee.Number(coeffList[2])
# Apply the model in the respective direction
out = ee.Algorithms.If(
direction.eq(0),
dir0Regression(img, slopes, intercepts),
dir1Regression(img, slopes, intercepts),
)
out = ee.Image(out).copyProperties(img).copyProperties(img, ["system:time_start"])
out = out.set({"fromSensor": fromSensor, "toSensor": toSensor}).set(args)
return ee.Image(out)
####################################################################
# Function to create a multiband image from a collection
[docs]
def collectionToImage(collection: ee.ImageCollection) -> ee.Image:
"""Converts an image collection to a single multiband image.
.. deprecated::
Use ``ee.ImageCollection.toBands()`` instead, which is more efficient.
Iterates over the collection and stacks all bands into a single image.
Args:
collection (ee.ImageCollection): The input Earth Engine image collection.
Returns:
ee.Image: A multiband image containing all bands from all images in the collection.
Examples:
>>> col = ee.ImageCollection("LANDSAT/LC08/C02/T1_L2").limit(3)
>>> stacked = collectionToImage(col)
"""
def cIterator(img, prev):
return ee.Image(prev).addBands(img)
stack = ee.Image(collection.iterate(cIterator, ee.Image(1)))
stack = stack.select(ee.List.sequence(1, stack.bandNames().size().subtract(1)))
return stack
####################################################################
####################################################################
# Function to find the date for a given composite computed from a given set of images
# Will work on composites computed with methods that include different dates across different bands
# such as the median. For something like a medoid, only a single bands needs passed through
# A known bug is that if the same value occurs twice, it will choose only a single date
[docs]
def compositeDates(images: ee.ImageCollection, composite: ee.Image, bandNames: list = None) -> ee.Image:
"""Finds the acquisition dates corresponding to each band in a composite image.
Works on composites computed with methods that may include different dates across
different bands (e.g., median). For medoid composites, only a single band needs
to be passed through. A known limitation is that if the same pixel value occurs
on two different dates, only one date will be selected.
Args:
images (ee.ImageCollection): The original image collection used to create
the composite.
composite (ee.Image): The composite image whose per-band dates are to be found.
bandNames (list[str] or ee.List, optional): Band names to consider. If ``None``,
uses all bands from the first image in the collection. Defaults to ``None``.
Returns:
ee.Image: A multiband image where each band contains the date (as YYYYDD float)
of the source image that contributed to that band of the composite.
Examples:
>>> col = ee.ImageCollection("LANDSAT/LC08/C02/T1_L2").filterDate("2020-06-01", "2020-09-01")
>>> composite = col.median()
>>> dates = compositeDates(col, composite, ["SR_B4", "SR_B5"])
"""
if bandNames == None:
bandNames = ee.Image(images.first()).bandNames()
else:
images = images.select(bandNames)
composite = composite.select(bandNames)
def bnCat(bn):
return ee.String(bn).cat("_diff")
bns = ee.Image(images.first()).bandNames().map(bnCat)
# Function to get the abs diff from a given composite *-1
def getDiff(img):
out = img.subtract(composite).abs().multiply(-1).rename(bns)
return img.addBands(out)
# Find the diff and add a date band
images = images.map(getDiff)
images = images.map(addDateBand)
# Iterate across each band and find the corresponding date to the composite
def bnCat2(bn):
bn = ee.String(bn)
t = images.select([bn, bn.cat("_diff"), "year"]).qualityMosaic(bn.cat("_diff"))
return t.select(["year"]).rename(["YYYYDD"])
out = bandNames.map(bnCat2)
# Convert to an image and rename
out = collectionToImage(ee.ImageCollection(out))
# var outBns = bandNames.map(function(bn){return ee.String(bn).cat('YYYYDD')});
# out = out.rename(outBns);
return out
############################################################################
# Function to handle empty collections that will cause subsequent processes to fail
# If the collection is empty, will fill it with an empty image
[docs]
def fillEmptyCollections(inCollection: ee.ImageCollection, dummyImage: ee.Image) -> ee.ImageCollection:
"""Fills empty image collections with a fully-masked dummy image.
Prevents downstream errors from empty collections by substituting a single
fully-masked dummy image when the input collection contains no images.
Args:
inCollection (ee.ImageCollection): The input image collection that may be empty.
dummyImage (ee.Image): A template image whose band structure matches the expected
output. It will be fully masked (all pixels set to 0) if used.
Returns:
ee.ImageCollection: The original collection if non-empty, otherwise a collection
containing the fully-masked ``dummyImage``.
Examples:
>>> col = ee.ImageCollection("LANDSAT/LC08/C02/T1_L2").filterDate("2000-01-01", "2000-01-02")
>>> dummy = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200101")
>>> safe_col = fillEmptyCollections(col, dummy)
"""
dummyCollection = ee.ImageCollection([dummyImage.mask(ee.Image(0))])
imageCount = inCollection.toList(1).length()
return ee.ImageCollection(ee.Algorithms.If(imageCount.gt(0), inCollection, dummyCollection))
############################################################################
# Add band tracking which satellite the pixel came from
[docs]
def addSensorBand(img: ee.Image, whichProgram: str, toaOrSR: str) -> ee.Image:
"""Adds a band encoding the satellite sensor as a numeric value.
Maps satellite names (e.g., ``LANDSAT_8``, ``Sentinel-2A``) to integer codes
(e.g., 8, 21) and adds the result as a ``"sensor"`` band. Also sets the
``"sensor"`` property on the image.
Args:
img (ee.Image): The input Earth Engine image with appropriate spacecraft metadata.
whichProgram (str): The satellite program. One of ``"C1_landsat"``,
``"C2_landsat"``, or ``"sentinel2"``.
toaOrSR (str): The processing level, ``"TOA"`` or ``"SR"``.
Returns:
ee.Image: The input image with an added ``"sensor"`` band (byte type) and
``"sensor"`` property.
Examples:
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200101")
>>> with_sensor = addSensorBand(img, "C2_landsat", "SR")
"""
sensorDict = ee.Dictionary(
{
"LANDSAT_4": 4,
"LANDSAT_5": 5,
"LANDSAT_7": 7,
"LANDSAT_8": 8,
"LANDSAT_9": 9,
"Sentinel-2A": 21,
"Sentinel-2B": 22,
"Sentinel-2C": 23,
}
)
sensorPropDict = ee.Dictionary(
{
"C1_landsat": {"TOA": "SPACECRAFT_ID", "SR": "SATELLITE"},
"C2_landsat": {"TOA": "SPACECRAFT_ID", "SR": "SPACECRAFT_ID"},
"sentinel2": {"TOA": "SPACECRAFT_NAME", "SR": "SPACECRAFT_NAME"},
}
)
toaOrSR = toaOrSR.upper()
sensorProp = ee.Dictionary(sensorPropDict.get(whichProgram)).get(toaOrSR)
sensorName = img.get(sensorProp)
img = img.addBands(ee.Image.constant(sensorDict.get(sensorName)).rename(["sensor"]).byte()).set("sensor", sensorName)
return img
############################################################################
############################################################################
# Adds the float year with julian proportion to image
[docs]
def addDateBand(img: ee.Image, maskTime: bool = False) -> ee.Image:
"""Adds a ``"year"`` band containing the fractional year (year + day-of-year fraction).
The band value is computed as ``year + fraction_of_year`` (e.g., 2020.5 for
approximately July 2, 2020).
Args:
img (ee.Image): The input Earth Engine image with a ``"system:time_start"`` property.
maskTime (bool, optional): If ``True``, masks the date band to match the first
band's mask of the input image. Defaults to ``False``.
Returns:
ee.Image: The input image with an added ``"year"`` band (float).
Examples:
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200701")
>>> with_date = addDateBand(img)
>>> with_masked_date = addDateBand(img, maskTime=True)
"""
d = ee.Date(img.get("system:time_start"))
y = d.get("year")
d = y.add(d.getFraction("year"))
# d=d.getFraction('year')
db = ee.Image.constant(d).rename(["year"]).float()
if maskTime:
db = db.updateMask(img.select([0]).mask())
return img.addBands(db)
[docs]
def addYearFractionBand(img: ee.Image) -> ee.Image:
"""Adds a ``"year"`` band containing only the fractional part of the year (0 to 1).
Unlike :func:`addDateBand`, this does not include the integer year component.
A value of 0.5 corresponds to approximately July 2.
Args:
img (ee.Image): The input Earth Engine image with a ``"system:time_start"`` property.
Returns:
ee.Image: The input image with an added ``"year"`` band (float, range 0--1).
Examples:
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200701")
>>> with_frac = addYearFractionBand(img)
"""
d = ee.Date(img.get("system:time_start"))
y = d.get("year")
# d = y.add(d.getFraction('year'));
d = d.getFraction("year")
db = ee.Image.constant(d).rename(["year"]).float()
db = db # .updateMask(img.select([0]).mask())
return img.addBands(db)
[docs]
def addYearYearFractionBand(img: ee.Image) -> ee.Image:
"""Adds a ``"year"`` band containing the full fractional year (year + fraction).
Functionally equivalent to :func:`addDateBand` with ``maskTime=False``, but
computed by explicitly adding the integer year and fractional year components.
Args:
img (ee.Image): The input Earth Engine image with a ``"system:time_start"`` property.
Returns:
ee.Image: The input image with an added ``"year"`` band (float, e.g., 2020.5).
Examples:
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200701")
>>> with_year_frac = addYearYearFractionBand(img)
"""
d = ee.Date(img.get("system:time_start"))
y = d.get("year")
# d = y.add(d.getFraction('year'));
d = d.getFraction("year")
db = ee.Image.constant(y).add(ee.Image.constant(d)).rename(["year"]).float()
db = db # .updateMask(img.select([0]).mask())
return img.addBands(db)
[docs]
def addYearBand(img: ee.Image) -> ee.Image:
"""Adds a ``"year"`` band containing the integer year of the image acquisition.
Args:
img (ee.Image): The input Earth Engine image with a ``"system:time_start"`` property.
Returns:
ee.Image: The input image with an added ``"year"`` band (float, e.g., 2020.0).
Examples:
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200701")
>>> with_year = addYearBand(img)
"""
d = ee.Date(img.get("system:time_start"))
y = d.get("year")
db = ee.Image.constant(y).rename(["year"]).float()
db = db # .updateMask(img.select([0]).mask())
return img.addBands(db)
[docs]
def addJulianDayBand(img: ee.Image) -> ee.Image:
"""Adds a ``"julianDay"`` band containing the day of the year (1--366).
Args:
img (ee.Image): The input Earth Engine image with a ``"system:time_start"`` property.
Returns:
ee.Image: The input image with an added ``"julianDay"`` band (float).
Examples:
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200701")
>>> with_julian = addJulianDayBand(img)
"""
d = ee.Date(img.get("system:time_start"))
julian = ee.Image(ee.Number.parse(d.format("DD"))).rename(["julianDay"])
return img.addBands(julian).float()
[docs]
def addYearJulianDayBand(img: ee.Image) -> ee.Image:
"""Adds a ``"yearJulian"`` band encoding the 2-digit year and Julian day (YYDD).
The band value is a number in the format YYDD, where YY is the 2-digit year
and DD is the day of the year. For example, January 15, 2020 yields 2015.
Args:
img (ee.Image): The input Earth Engine image with a ``"system:time_start"`` property.
Returns:
ee.Image: The input image with an added ``"yearJulian"`` band (float).
Examples:
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200115")
>>> with_yj = addYearJulianDayBand(img)
"""
d = ee.Date(img.get("system:time_start"))
yj = ee.Image(ee.Number.parse(d.format("YYDD"))).rename(["yearJulian"])
return img.addBands(yj).float()
[docs]
def addFullYearJulianDayBand(img: ee.Image) -> ee.Image:
"""Adds a ``"yearJulian"`` band encoding the full 4-digit year and Julian day (YYYYDD).
The band value is a number in the format YYYYDD, where YYYY is the 4-digit year
and DD is the day of the year. For example, July 1, 2020 yields 2020182.
Args:
img (ee.Image): The input Earth Engine image with a ``"system:time_start"`` property.
Returns:
ee.Image: The input image with an added ``"yearJulian"`` band (int64, cast to float).
Examples:
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200701")
>>> with_full_yj = addFullYearJulianDayBand(img)
"""
d = ee.Date(img.get("system:time_start"))
yj = ee.Image(ee.Number.parse(d.format("YYYYDD"))).rename(["yearJulian"]).int64()
return img.addBands(yj).float()
[docs]
def offsetImageDate(img: ee.Image, n: int, unit: str) -> ee.Image:
"""Offsets the ``system:time_start`` property of an image by a specified amount.
Useful for shifting image dates when creating synthetic time series or aligning
images from different years.
Args:
img (ee.Image): The input Earth Engine image with a ``"system:time_start"`` property.
n (int): The number of units to offset. Can be negative to shift backward.
unit (str): The time unit for the offset. One of ``"year"``, ``"month"``,
``"week"``, ``"day"``, ``"hour"``, ``"minute"``, or ``"second"``.
Returns:
ee.Image: The image with its ``"system:time_start"`` property updated.
Examples:
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20200701")
>>> shifted = offsetImageDate(img, -1, "year")
"""
date = ee.Date(img.get("system:time_start"))
date = date.advance(n, unit)
# date = ee.Date.fromYMD(100,date.get('month'),date.get('day'))
return img.set("system:time_start", date.millis())
################################################################
################################################################
fringeCountThreshold = 279 # Define number of non null observations for pixel to not be classified as a fringe
################################################################
# Kernel used for defringing
k = ee.Kernel.fixed(
41,
41,
[
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1,
1,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
],
)
################################################################
# Algorithm to defringe Landsat scenes
[docs]
def defringeLandsat(img: ee.Image) -> ee.Image:
"""Defringes a Landsat 7 image by masking fringe pixels with insufficient valid neighbors.
Args:
img (ee.Image): The input Landsat 7 image to defringe.
Returns:
ee.Image: The defringed Landsat 7 image with fringe pixels masked out.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> l7 = ee.ImageCollection("LANDSAT/LE07/C02/T1").first()
>>> defringed = gil.defringeLandsat(l7)
"""
# Find any pixel without sufficient non null pixels (fringes)
m = img.mask().reduce(ee.Reducer.min())
# Apply kernel
kernelSum = m.reduceNeighborhood(ee.Reducer.sum(), k, "kernel")
# Map.addLayer(img,vizParams,'with fringes')
# Map.addLayer(sum,{'min':20,'max':241},'sum41',false)
# Mask pixels w/o sufficient obs
kernelSum = kernelSum.gte(fringeCountThreshold)
img = img.mask(kernelSum)
# Map.addLayer(img,vizParams,'defringed')
return img
################################################################
# Function to find unique values of a field in a collection
[docs]
def uniqueValues(collection: ee.ImageCollection, field: str) -> ee.List:
"""Finds unique values of a field in an image collection.
Args:
collection (ee.ImageCollection): The input Earth Engine image collection.
field (str): The metadata field name to extract unique values from.
Returns:
ee.List: A list of unique values for the specified field.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> col = ee.ImageCollection("LANDSAT/LC08/C02/T1").filterDate("2023-06-01", "2023-06-30")
>>> paths = gil.uniqueValues(col, "WRS_PATH")
"""
values = ee.Dictionary(collection.reduceColumns(ee.Reducer.frequencyHistogram(), [field]).get("histogram")).keys()
return values
###############################################################
# Function to simplify data into daily mosaics
# This procedure must be used for proper processing of S2 imagery
[docs]
def dailyMosaics(imgs: ee.ImageCollection) -> ee.ImageCollection:
"""Creates daily mosaics from an image collection grouped by date and orbit.
Groups images by acquisition date and Sentinel-2 orbit number, then mosaics
them to remove redundant observations in MGRS tile overlap areas.
Args:
imgs (ee.ImageCollection): The input Earth Engine image collection. Must
contain the ``SENSING_ORBIT_NUMBER`` property (Sentinel-2).
Returns:
ee.ImageCollection: A collection of daily mosaic images, one per
unique date-orbit combination.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> s2 = ee.ImageCollection("COPERNICUS/S2_HARMONIZED").filterDate("2023-06-01", "2023-06-10")
>>> daily = gil.dailyMosaics(s2)
"""
# Simplify date to exclude time of day
def propWrapper(img):
d = img.date().format("YYYY-MM-dd")
orbit = ee.Number(img.get("SENSING_ORBIT_NUMBER")).format()
return img.set({"date-orbit": d.cat(ee.String("_")).cat(orbit), "date": d})
imgs = imgs.map(propWrapper)
# Find the unique day orbits
dayOrbits = ee.Dictionary(imgs.aggregate_histogram("date-orbit")).keys()
def dayWrapper(d):
date = ee.Date(ee.String(d).split("_").get(0))
orbit = ee.Number.parse(ee.String(d).split("_").get(1))
t = imgs.filterDate(date, date.advance(1, "day")).filter(ee.Filter.eq("SENSING_ORBIT_NUMBER", orbit))
f = ee.Image(t.first())
t = t.mosaic()
t = t.set("system:time_start", date.millis())
t = t.copyProperties(f)
return t
imgs = dayOrbits.map(dayWrapper)
imgs = ee.ImageCollection.fromImages(imgs)
return imgs
################################################################
# Sentinel 1 processing
# Adapted from: https://code.earthengine.google.com/39a3ad5ac59cd8af14e3dbd78436d2b5
# Author: Warren Scott
# --------------------------------------- DEFINE SPECKLEFUNCTION---------------------------------------------------*/
# Sigma Lee filter
[docs]
def toNatural(img: ee.Image) -> ee.Image:
"""Converts a Sentinel-1 image from dB to natural (linear power) units.
Applies the formula: 10^(dB / 10).
Args:
img (ee.Image): The input Sentinel-1 image in dB units.
Returns:
ee.Image: The converted image in natural (linear power) units.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> s1 = ee.ImageCollection("COPERNICUS/S1_GRD").first()
>>> natural = gil.toNatural(s1)
"""
return ee.Image(10.0).pow(img.select(0).divide(10.0))
[docs]
def toDB(img: ee.Image) -> ee.Image:
"""Converts a Sentinel-1 image from natural (linear power) units to dB.
Applies the formula: 10 * log10(value).
Args:
img (ee.Image): The input Sentinel-1 image in natural (linear power) units.
Returns:
ee.Image: The converted image in dB units.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> natural_img = gil.toNatural(ee.ImageCollection("COPERNICUS/S1_GRD").first())
>>> db_img = gil.toDB(natural_img)
"""
return ee.Image(img).log10().multiply(10.0)
# The RL speckle filter from https://code.earthengine.google.com/2ef38463ebaf5ae133a478f173fd0ab5 by Guido Lemoine
# As coded in the SNAP 3.0 S1TBX:
[docs]
def RefinedLee(img: ee.Image) -> ee.Image:
"""Applies the Refined Lee speckle filter to a Sentinel-1 image.
Implements the Refined Lee filter as coded in SNAP 3.0 S1TBX. Uses
directional statistics in 7x7 neighborhoods to reduce speckle noise while
preserving edges. The input image must be in natural (linear) units, not dB.
Args:
img (ee.Image): The input Sentinel-1 image in natural (linear power)
units. Use ``toNatural()`` to convert from dB first.
Returns:
ee.Image: The speckle-filtered image with a single band named ``"sum"``.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> s1 = ee.ImageCollection("COPERNICUS/S1_GRD").first().select("VV")
>>> filtered = gil.RefinedLee(gil.toNatural(s1))
"""
# img must be in natural units, i.e. not in dB!
# Set up 3x3 kernels
weights3 = ee.List.repeat(ee.List.repeat(1, 3), 3)
kernel3 = ee.Kernel.fixed(3, 3, weights3, 1, 1, False)
mean3 = img.reduceNeighborhood(ee.Reducer.mean(), kernel3)
variance3 = img.reduceNeighborhood(ee.Reducer.variance(), kernel3)
# Use a sample of the 3x3 windows inside a 7x7 windows to determine gradients and directions
sample_weights = ee.List(
[
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 1, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 0],
]
)
sample_kernel = ee.Kernel.fixed(7, 7, sample_weights, 3, 3, False)
# Calculate mean and variance for the sampled windows and store as 9 bands
sample_mean = mean3.neighborhoodToBands(sample_kernel)
sample_var = variance3.neighborhoodToBands(sample_kernel)
# Determine the 4 gradients for the sampled windows
gradients = sample_mean.select(1).subtract(sample_mean.select(7)).abs()
gradients = gradients.addBands(sample_mean.select(6).subtract(sample_mean.select(2)).abs())
gradients = gradients.addBands(sample_mean.select(3).subtract(sample_mean.select(5)).abs())
gradients = gradients.addBands(sample_mean.select(0).subtract(sample_mean.select(8)).abs())
# And find the maximum gradient amongst gradient bands
max_gradient = gradients.reduce(ee.Reducer.max())
# Create a mask for band pixels that are the maximum gradient
gradmask = gradients.eq(max_gradient)
# duplicate gradmask bands: each gradient represents 2 directions
gradmask = gradmask.addBands(gradmask)
# Determine the 8 directions
directions = sample_mean.select(1).subtract(sample_mean.select(4)).gt(sample_mean.select(4).subtract(sample_mean.select(7))).multiply(1)
directions = directions.addBands(sample_mean.select(6).subtract(sample_mean.select(4)).gt(sample_mean.select(4).subtract(sample_mean.select(2))).multiply(2))
directions = directions.addBands(sample_mean.select(3).subtract(sample_mean.select(4)).gt(sample_mean.select(4).subtract(sample_mean.select(5))).multiply(3))
directions = directions.addBands(sample_mean.select(0).subtract(sample_mean.select(4)).gt(sample_mean.select(4).subtract(sample_mean.select(8))).multiply(4))
# The next 4 are the not() of the previous 4
directions = directions.addBands(directions.select(0).Not().multiply(5))
directions = directions.addBands(directions.select(1).Not().multiply(6))
directions = directions.addBands(directions.select(2).Not().multiply(7))
directions = directions.addBands(directions.select(3).Not().multiply(8))
# Mask all values that are not 1-8
directions = directions.updateMask(gradmask)
# "collapse" the stack into a singe band image (due to masking, each pixel has just one value (1-8) in it's directional band, and is otherwise masked)
directions = directions.reduce(ee.Reducer.sum())
# var pal = ['ffffff','ff0000','ffff00', '00ff00', '00ffff', '0000ff', 'ff00ff', '000000'];
# Map.addLayer(directions.reduce(ee.Reducer.sum()), {min:1, max:8, palette: pal}, 'Directions', false);
sample_stats = sample_var.divide(sample_mean.multiply(sample_mean))
# Calculate localNoiseVariance
sigmaV = sample_stats.toArray().arraySort().arraySlice(0, 0, 5).arrayReduce(ee.Reducer.mean(), [0])
# Set up the 7*7 kernels for directional statistics
rect_weights = ee.List.repeat(ee.List.repeat(0, 7), 3).cat(ee.List.repeat(ee.List.repeat(1, 7), 4))
diag_weights = ee.List(
[
[1, 0, 0, 0, 0, 0, 0],
[1, 1, 0, 0, 0, 0, 0],
[1, 1, 1, 0, 0, 0, 0],
[1, 1, 1, 1, 0, 0, 0],
[1, 1, 1, 1, 1, 0, 0],
[1, 1, 1, 1, 1, 1, 0],
[1, 1, 1, 1, 1, 1, 1],
]
)
rect_kernel = ee.Kernel.fixed(7, 7, rect_weights, 3, 3, False)
diag_kernel = ee.Kernel.fixed(7, 7, diag_weights, 3, 3, False)
# Create stacks for mean and variance using the original kernels. Mask with relevant direction.
dir_mean = img.reduceNeighborhood(ee.Reducer.mean(), rect_kernel).updateMask(directions.eq(1))
dir_var = img.reduceNeighborhood(ee.Reducer.variance(), rect_kernel).updateMask(directions.eq(1))
dir_mean = dir_mean.addBands(img.reduceNeighborhood(ee.Reducer.mean(), diag_kernel).updateMask(directions.eq(2)))
dir_var = dir_var.addBands(img.reduceNeighborhood(ee.Reducer.variance(), diag_kernel).updateMask(directions.eq(2)))
# and add the bands for rotated kernels
for i in range(1, 4):
dir_mean = dir_mean.addBands(img.reduceNeighborhood(ee.Reducer.mean(), rect_kernel.rotate(i)).updateMask(directions.eq(2 * i + 1)))
dir_var = dir_var.addBands(img.reduceNeighborhood(ee.Reducer.variance(), rect_kernel.rotate(i)).updateMask(directions.eq(2 * i + 1)))
dir_mean = dir_mean.addBands(img.reduceNeighborhood(ee.Reducer.mean(), diag_kernel.rotate(i)).updateMask(directions.eq(2 * i + 2)))
dir_var = dir_var.addBands(img.reduceNeighborhood(ee.Reducer.variance(), diag_kernel.rotate(i)).updateMask(directions.eq(2 * i + 2)))
# "collapse" the stack into a single band image (due to masking, each pixel has just one value in it's directional band, and is otherwise masked)
dir_mean = dir_mean.reduce(ee.Reducer.sum())
dir_var = dir_var.reduce(ee.Reducer.sum())
# A finally generate the filtered value
varX = dir_var.subtract(dir_mean.multiply(dir_mean).multiply(sigmaV)).divide(sigmaV.add(1.0))
b = varX.divide(dir_var)
result = dir_mean.add(b.multiply(img.subtract(dir_mean)))
return result.arrayFlatten([["sum"]])
################################################################
# Load and filter Sentinel-1 GRD data by predefined parameters
[docs]
def getS1(
studyArea: ee.Geometry | ee.Feature | ee.FeatureCollection,
startYear: int,
endYear: int,
startJulian: int,
endJulian: int,
polarization: str = "VV",
pass_direction: str = "ASCENDING",
) -> ee.ImageCollection:
"""Loads Sentinel-1 GRD data for a given area and time period.
Filters the ``COPERNICUS/S1_GRD`` collection by date, bounds, IW instrument
mode, polarization, pass direction, and 10m resolution.
Args:
studyArea (ee.Geometry | ee.Feature | ee.FeatureCollection): The
geographic area of interest.
startYear (int): The start year (inclusive).
endYear (int): The end year (inclusive).
startJulian (int): The start Julian day of year (1-365).
endJulian (int): The end Julian day of year (1-365).
polarization (str, optional): The desired polarization band. Defaults
to ``"VV"``. Other option is ``"VH"``.
pass_direction (str, optional): The orbit pass direction. Defaults to
``"ASCENDING"``. Other option is ``"DESCENDING"``.
Returns:
ee.ImageCollection: A collection of Sentinel-1 GRD images filtered
by the specified criteria.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CA"]
>>> s1 = gil.getS1(studyArea, 2023, 2023, 1, 365)
>>> print(s1.size().getInfo())
"""
collection = (
ee.ImageCollection("COPERNICUS/S1_GRD")
.filter(ee.Filter.calendarRange(startYear, endYear, "year"))
.filter(ee.Filter.calendarRange(startJulian, endJulian))
.filter(ee.Filter.eq("instrumentMode", "IW"))
.filter(ee.Filter.listContains("transmitterReceiverPolarisation", polarization))
.filter(ee.Filter.eq("orbitProperties_pass", pass_direction))
.filter(ee.Filter.eq("resolution_meters", 10))
.filterBounds(studyArea)
.select([polarization])
)
return collection
################################################################
# Sentinel-2 collections and bands to use
# 3/22 - Changed to using the _HARMONIZED collections following the introduction of a 1000 value offset from Jan 25 2022 onward
# These collections account for these differences
s2CollectionDict = {
"TOA": "COPERNICUS/S2_HARMONIZED",
"SR": "COPERNICUS/S2_SR_HARMONIZED",
}
sensorBandDict = {
"SR": [
"B1",
"B2",
"B3",
"B4",
"B5",
"B6",
"B7",
"B8",
"B8A",
"B9",
"B11",
"B12",
],
"TOA": [
"B1",
"B2",
"B3",
"B4",
"B5",
"B6",
"B7",
"B8",
"B8A",
"B9",
"B10",
"B11",
"B12",
],
}
sensorBandNameDict = {
"SR": [
"cb",
"blue",
"green",
"red",
"re1",
"re2",
"re3",
"nir",
"nir2",
"waterVapor",
"swir1",
"swir2",
],
"TOA": [
"cb",
"blue",
"green",
"red",
"re1",
"re2",
"re3",
"nir",
"nir2",
"waterVapor",
"cirrus",
"swir1",
"swir2",
],
}
# Function to get Sentinel 2 A and B data into a single collection with meaningful band names for a specified area and date range
# Will also join the S2 cloudless cloud probability collection if specified
[docs]
def getS2(
studyArea: ee.Geometry | ee.Feature | ee.FeatureCollection,
startDate: ee.Date | datetime.datetime | str,
endDate: ee.Date | datetime.datetime | str,
startJulian: int = 1,
endJulian: int = 365,
resampleMethod: str = "nearest",
toaOrSR: str = "TOA",
convertToDailyMosaics: bool = True,
addCloudProbability: bool = False,
addCloudScorePlus: bool = True,
cloudScorePlusScore: str = "cs",
) -> ee.ImageCollection:
"""Loads Sentinel-2 data for a given area and time period and joins cloud score information. Partially deprecated in favor of the simpler superSimpleGetS2.
Args:
studyArea: The geographic area of interest.
startDate: The start date of the desired data. Can be an ee.Date object, datetime object, or date string.
endDate: The end date of the desired data. Can be an ee.Date object, datetime object, or date string.
startJulian: The start Julian day of the desired data.
endJulian: The end Julian day of the desired data.
resampleMethod: The resampling method (default: "nearest").
toaOrSR: Whether to load TOA or SR data (default: "TOA").
convertToDailyMosaics: Whether to convert the data to daily mosaics (default: True).
addCloudProbability: Whether to add cloud probability data (default: False).
addCloudScorePlus: Whether to add cloud score plus data (default: True).
cloudScorePlusScore: The band name for cloud score plus (default: "cs").
Returns:
ee.ImageCollection: A collection of Sentinel-2 satellite images filtered by the specified criteria.
>>> import geeViz.getImagesLib as gil
>>> Map = gil.Map
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CA"]
>>> composite = gil.getS2(studyArea, "2024-01-01", "2024-12-31", 190, 250).median()
>>> Map.addLayer(composite, gil.vizParamsFalse, "Sentinel-2 Composite")
>>> Map.addLayer(studyArea, {"canQuery": False}, "Study Area")
>>> Map.centerObject(studyArea)
>>> Map.turnOnInspector()
>>> Map.view()
"""
args = formatArgs(locals())
toaOrSR = toaOrSR.upper()
startDate = ee.Date(startDate)
endDate = ee.Date(endDate)
# Specify S2 continuous bands if resampling is set to something other than near
s2_continuous_bands = sensorBandNameDict[toaOrSR]
def multS2(img):
t = img.select(sensorBandDict[toaOrSR]).divide(10000)
# t = t.addBands(img.select(['QA60']))
# out = t.copyProperties(img).copyProperties(img,['system:time_start'])
return img.addBands(t, None, True)
# Get some s2 data
print("Using S2 Collection:", s2CollectionDict[toaOrSR])
s2s = (
ee.ImageCollection(s2CollectionDict[toaOrSR])
.filterDate(startDate, endDate.advance(1, "day"))
.filter(ee.Filter.calendarRange(startJulian, endJulian))
.filterBounds(studyArea)
.map(multS2)
.select(["QA60"] + sensorBandDict[toaOrSR], ["QA60"] + sensorBandNameDict[toaOrSR])
)
if addCloudProbability:
print("Joining pre-computed cloud probabilities from: COPERNICUS/S2_CLOUD_PROBABILITY")
cloudProbabilities = (
ee.ImageCollection("COPERNICUS/S2_CLOUD_PROBABILITY")
# .filterDate(startDate, endDate.advance(1, "day"))
# .filter(ee.Filter.calendarRange(startJulian, endJulian))
# .filterBounds(studyArea)
.select(["probability"], ["cloud_probability"])
)
cloudProbabilitiesIds = ee.List(ee.Dictionary(cloudProbabilities.aggregate_histogram("system:index")).keys())
s2sIds = ee.List(ee.Dictionary(s2s.aggregate_histogram("system:index")).keys())
missing = s2sIds.removeAll(cloudProbabilitiesIds)
# print('Missing cloud probability ids:', missing.getInfo())
# print('N s2 images before joining with cloud prob:', s2s.size().getInfo())
# s2s = joinCollections(s2s, cloudProbabilities, False, "system:index")
s2s = s2s.linkCollection(cloudProbabilities, ["cloud_probability"])
# print('N s2 images after joining with cloud prob:', s2s.size().getInfo())
if addCloudScorePlus:
print("Joining pre-computed cloudScore+ from: GOOGLE/CLOUD_SCORE_PLUS/V1/S2_HARMONIZED")
cloudScorePlus = (
ee.ImageCollection("GOOGLE/CLOUD_SCORE_PLUS/V1/S2_HARMONIZED")
# .filterDate(startDate, endDate.advance(1, "day"))
# .filter(ee.Filter.calendarRange(startJulian, endJulian))
# .filterBounds(studyArea)
.select([cloudScorePlusScore], ["cloudScorePlus"])
)
cloudScorePlusIds = ee.List(ee.Dictionary(cloudScorePlus.aggregate_histogram("system:index")).keys())
s2sIds = ee.List(ee.Dictionary(s2s.aggregate_histogram("system:index")).keys())
missing = s2sIds.removeAll(cloudScorePlus)
# print('Missing cloud probability ids:', missing.getInfo())
# print('N s2 images before joining with cloudScore+:', s2s.size().getInfo())
# s2s = joinCollections(s2s, cloudScorePlus, False, "system:index")
s2s = s2s.linkCollection(cloudScorePlus, ["cloudScorePlus"])
# print('N s2 images after joining with cloud prob:', s2s.size().getInfo())
# Set resampling method - only sets to non nearest-neighbor for continuous bands
if resampleMethod == "bilinear" or resampleMethod == "bicubic":
print("Setting resample method to ", resampleMethod)
s2s = s2s.map(lambda img: img.addBands(img.select(s2_continuous_bands).resample(resampleMethod), None, True))
elif resampleMethod == "aggregate":
print("Setting to aggregate instead of resample ")
s2s = s2s.map(
lambda img: img.addBands(
img.select(s2_continuous_bands).reduceResolution(ee.Reducer.mean(), True, 64),
None,
True,
)
)
# Convert to daily mosaics to avoid redundant observations in MGRS overlap areas and edge artifacts for shadow masking
if convertToDailyMosaics:
print("Converting S2 data to daily mosaics")
s2s = dailyMosaics(s2s)
# This needs to occur AFTER the mosaicking to remove remaining edge artifacts.
# Update on 15 May 2024 to only include spectral bands since qa bands are null after ~Feb 2024
s2s = s2s.map(lambda img: img.updateMask(img.select(sensorBandNameDict[toaOrSR]).mask().reduce(ee.Reducer.min())))
return s2s.set(args)
getSentinel2 = getS2
##################################################################
# Set up dictionaries to manage various Landsat collections, rescale factors, band names, etc
landsat_C2_L2_rescale_dict = {
"C1": {"refl_mult": 0.0001, "refl_add": 0, "temp_mult": 0.1, "temp_add": 0},
"C2": {
"refl_mult": 0.0000275,
"refl_add": -0.2,
"temp_mult": 0.00341802,
"temp_add": 149.0,
},
}
# Specify Landsat continuous bands if resampling is set to something other than near
landsat_continuous_bands = ["blue", "green", "red", "nir", "swir1", "temp", "swir2"]
# Set up bands and corresponding band names
landsatSensorBandDict = {
"C1_L4_TOA": ["B1", "B2", "B3", "B4", "B5", "B6", "B7", "BQA"],
"C2_L4_TOA": ["B1", "B2", "B3", "B4", "B5", "B6", "B7", "QA_PIXEL"],
"C1_L5_TOA": ["B1", "B2", "B3", "B4", "B5", "B6", "B7", "BQA"],
"C2_L5_TOA": ["B1", "B2", "B3", "B4", "B5", "B6", "B7", "QA_PIXEL"],
"C1_L7_TOA": ["B1", "B2", "B3", "B4", "B5", "B6_VCID_1", "B7", "BQA"],
"C2_L7_TOA": ["B1", "B2", "B3", "B4", "B5", "B6_VCID_1", "B7", "QA_PIXEL"],
"C1_L8_TOA": ["B2", "B3", "B4", "B5", "B6", "B10", "B7", "BQA"],
"C2_L8_TOA": ["B2", "B3", "B4", "B5", "B6", "B10", "B7", "QA_PIXEL"],
"C2_L9_TOA": ["B2", "B3", "B4", "B5", "B6", "B10", "B7", "QA_PIXEL"],
"C1_L4_SR": ["B1", "B2", "B3", "B4", "B5", "B6", "B7", "pixel_qa"],
"C2_L4_SR": [
"SR_B1",
"SR_B2",
"SR_B3",
"SR_B4",
"SR_B5",
"ST_B6",
"SR_B7",
"QA_PIXEL",
],
"C1_L5_SR": ["B1", "B2", "B3", "B4", "B5", "B6", "B7", "pixel_qa"],
"C2_L5_SR": [
"SR_B1",
"SR_B2",
"SR_B3",
"SR_B4",
"SR_B5",
"ST_B6",
"SR_B7",
"QA_PIXEL",
],
"C1_L7_SR": ["B1", "B2", "B3", "B4", "B5", "B6", "B7", "pixel_qa"],
"C2_L7_SR": [
"SR_B1",
"SR_B2",
"SR_B3",
"SR_B4",
"SR_B5",
"ST_B6",
"SR_B7",
"QA_PIXEL",
],
"C1_L8_SR": ["B2", "B3", "B4", "B5", "B6", "B10", "B7", "pixel_qa"],
"C2_L8_SR": [
"SR_B2",
"SR_B3",
"SR_B4",
"SR_B5",
"SR_B6",
"ST_B10",
"SR_B7",
"QA_PIXEL",
],
"C2_L9_SR": [
"SR_B2",
"SR_B3",
"SR_B4",
"SR_B5",
"SR_B6",
"ST_B10",
"SR_B7",
"QA_PIXEL",
],
}
# Provide common band names
landsatSensorBandNameDict = {
"C1_TOA": ["blue", "green", "red", "nir", "swir1", "temp", "swir2", "BQA"],
"C1_SR": ["blue", "green", "red", "nir", "swir1", "temp", "swir2", "pixel_qa"],
"C1_SRFMASK": ["pixel_qa"],
"C2_TOA": ["blue", "green", "red", "nir", "swir1", "temp", "swir2", "QA_PIXEL"],
"C2_SR": ["blue", "green", "red", "nir", "swir1", "temp", "swir2", "QA_PIXEL"],
}
# Set up collections
landsatCollectionDict = {
"C1_L8_TOA": "LANDSAT/LC08/C01/T1",
"C1_L7_TOA": "LANDSAT/LE07/C01/T1",
"C1_L5_TOA": "LANDSAT/LT05/C01/T1",
"C1_L4_TOA": "LANDSAT/LT04/C01/T1",
"C1_L8_SR": "LANDSAT/LC08/C01/T1_SR",
"C1_L7_SR": "LANDSAT/LE07/C01/T1_SR",
"C1_L5_SR": "LANDSAT/LT05/C01/T1_SR",
"C1_L4_SR": "LANDSAT/LT04/C01/T1_SR",
"C2_L9_TOA": "LANDSAT/LC09/C02/T1",
"C2_L8_TOA": "LANDSAT/LC08/C02/T1",
"C2_L7_TOA": "LANDSAT/LE07/C02/T1",
"C2_L5_TOA": "LANDSAT/LT05/C02/T1",
"C2_L4_TOA": "LANDSAT/LT04/C02/T1",
"C2_L9_SR": "LANDSAT/LC09/C02/T1_L2",
"C2_L8_SR": "LANDSAT/LC08/C02/T1_L2",
"C2_L7_SR": "LANDSAT/LE07/C02/T1_L2",
"C2_L5_SR": "LANDSAT/LT05/C02/T1_L2",
"C2_L4_SR": "LANDSAT/LT04/C02/T1_L2",
}
# Name of cFmask qa bits band for Collections 1 and 2
landsatFmaskBandNameDict = {"C1": "pixel_qa", "C2": "QA_PIXEL"}
##################################################################
# Method for rescaling reflectance and surface temperature data to 0-1 and Kelvin respectively
# This was adapted from the method provided by Google for rescaling Collection 2:
# https://code.earthengine.google.com/?scriptPath=Examples%3ADatasets%2FLANDSAT_LC08_C02_T1_L2
[docs]
def applyScaleFactors(image, landsatCollectionVersion):
"""Rescales Landsat reflectance bands to 0-1 and thermal bands to Kelvin.
Applies collection-specific scale factors and offsets to optical and thermal
bands. Adapted from the method provided by Google for rescaling Collection 2.
Args:
image (ee.Image): The input Landsat image with common band names
(blue, green, red, nir, swir1, swir2, temp).
landsatCollectionVersion (str): The Landsat collection version, either
``"C1"`` or ``"C2"``.
Returns:
ee.Image: The image with optical bands scaled to 0-1 reflectance and
the thermal band scaled to Kelvin.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20210508")
>>> img = img.select(["SR_B2","SR_B3","SR_B4","SR_B5","SR_B6","ST_B10","SR_B7"],
... ["blue","green","red","nir","swir1","temp","swir2"])
>>> scaled = gil.applyScaleFactors(img, "C2")
"""
factor_dict = landsat_C2_L2_rescale_dict[landsatCollectionVersion]
opticalBands = image.select("blue", "green", "red", "nir", "swir1", "swir2").multiply(factor_dict["refl_mult"]).add(factor_dict["refl_add"]).float()
thermalBands = image.select("temp").multiply(factor_dict["temp_mult"]).add(factor_dict["temp_add"]).float()
return image.addBands(opticalBands, None, True).addBands(thermalBands, None, True)
##################################################################
# Function for acquiring Landsat image collections
[docs]
def getLandsat(
studyArea: ee.Geometry | ee.Feature | ee.FeatureCollection,
startDate: ee.Date | datetime.datetime | str,
endDate: ee.Date | datetime.datetime | str,
startJulian: int = 1,
endJulian: int = 365,
toaOrSR: str = "SR",
includeSLCOffL7: bool = False,
defringeL5: bool = False,
addPixelQA: bool = False,
resampleMethod: str = "near",
landsatCollectionVersion: str = "C2",
):
"""Retrieves Landsat imagery for a specified study area and date range.
Args:
studyArea (ee.Geometry, ee.Feature, or ee.FeatureCollection): The geographic area of interest.
startDate (ee.Date, datetime.datetime, or str): The start date of the desired image range.
endDate (ee.Date, datetime.datetime, or str): The end date of the desired image range.
startJulian (int, optional): The start Julian day of the desired image range. Defaults to 1.
endJulian (int, optional): The end Julian day of the desired image range. Defaults to 365.
toaOrSR (str, optional): Whether to retrieve TOA or SR data. Defaults to "SR".
includeSLCOffL7 (bool, optional): Whether to include SLC-off L7 data. Defaults to False.
defringeL5 (bool, optional): Whether to defringe L5 data. Defaults to False.
addPixelQA (bool, optional): Whether to add pixel QA band. Defaults to False.
resampleMethod (str, optional): Resampling method. Options are "near", "bilinear", or "bicubic". Defaults to "near".
landsatCollectionVersion (str, optional): Landsat collection version. Options are "C1" or "C2". Defaults to "C2".
Returns:
ee.ImageCollection: A collection of Landsat images meeting the specified criteria.
>>> import geeViz.getImagesLib as gil
>>> Map = gil.Map
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CA"]
>>> composite = gil.getLandsat(studyArea, "2024-01-01", "2024-12-31", 190, 250).median()
>>> Map.addLayer(composite, gil.vizParamsFalse, "Landsat Composite")
>>> Map.addLayer(studyArea, {"canQuery": False}, "Study Area")
>>> Map.centerObject(studyArea)
>>> Map.turnOnInspector()
>>> Map.view()
"""
args = formatArgs(locals())
toaOrSR = toaOrSR.upper()
startDate = ee.Date(startDate)
endDate = ee.Date(endDate)
def getLandsatCollection(landsatCollectionVersion, whichC, toaOrSR):
c = (
ee.ImageCollection(landsatCollectionDict[landsatCollectionVersion + "_" + whichC + "_" + toaOrSR])
.filterDate(startDate, endDate.advance(1, "day"))
.filter(ee.Filter.calendarRange(startJulian, endJulian))
.filterBounds(studyArea)
.filter(ee.Filter.lte("WRS_ROW", 120))
)
if toaOrSR.lower() == "toa":
c = c.map(ee.Algorithms.Landsat.TOA)
c = c.select(
landsatSensorBandDict[landsatCollectionVersion + "_" + whichC + "_" + toaOrSR],
landsatSensorBandNameDict[landsatCollectionVersion + "_" + toaOrSR],
)
if toaOrSR.lower() == "sr":
print("Applying scale factors for {} {} data".format(landsatCollectionVersion, whichC))
c = c.map(lambda image: applyScaleFactors(image, landsatCollectionVersion))
return c
def getLandsatCollections(toaOrSR, landsatCollectionVersion):
# Get Landsat data
l4s = getLandsatCollection(landsatCollectionVersion, "L4", toaOrSR)
l5s = getLandsatCollection(landsatCollectionVersion, "L5", toaOrSR)
if defringeL5:
print("Defringing L4 and L5")
l4s = l4s.map(defringeLandsat)
l5s = l5s.map(defringeLandsat)
l8s = getLandsatCollection(landsatCollectionVersion, "L8", toaOrSR)
# var ls; var l7s;
if includeSLCOffL7:
print("Including All Landsat 7")
l7s = getLandsatCollection(landsatCollectionVersion, "L7", toaOrSR)
else:
print("Only including SLC On Landsat 7")
l7s = getLandsatCollection(landsatCollectionVersion, "L7", toaOrSR).filterDate(
ee.Date.fromYMD(1998, 1, 1),
ee.Date.fromYMD(2003, 5, 31).advance(1, "day"),
)
# Merge collections
ls = ee.ImageCollection(l4s.merge(l5s).merge(l7s).merge(l8s))
# Bring in Landsat 9 if using Collection 2
if landsatCollectionVersion.lower() == "c2":
l9s = getLandsatCollection(landsatCollectionVersion, "L9", toaOrSR)
ls = ee.ImageCollection(ls.merge(l9s))
return ls
ls = getLandsatCollections(toaOrSR, landsatCollectionVersion)
# If TOA and Fmask need to merge Fmask qa bits with toa- this gets the qa band from the sr collections
if toaOrSR.lower() == "toa" and addPixelQA and landsatCollectionVersion.lower() == "c1":
print("Acquiring SR qa bands for applying Fmask to TOA data")
l4sTOAFMASK = (
ee.ImageCollection(landsatCollectionDict["C1_L4_SR"])
.filterDate(startDate, endDate.advance(1, "day"))
.filter(ee.Filter.calendarRange(startJulian, endJulian))
.filterBounds(studyArea)
.filter(ee.Filter.lte("WRS_ROW", 120))
.select(landsatSensorBandNameDict["C1_SRFMASK"])
)
l5sTOAFMASK = (
ee.ImageCollection(landsatCollectionDict["C1_L5_SR"])
.filterDate(startDate, endDate.advance(1, "day"))
.filter(ee.Filter.calendarRange(startJulian, endJulian))
.filterBounds(studyArea)
.filter(ee.Filter.lte("WRS_ROW", 120))
.select(landsatSensorBandNameDict["C1_SRFMASK"])
)
l8sTOAFMASK = (
ee.ImageCollection(landsatCollectionDict["C1_L8_SR"])
.filterDate(startDate, endDate.advance(1, "day"))
.filter(ee.Filter.calendarRange(startJulian, endJulian))
.filterBounds(studyArea)
.filter(ee.Filter.lte("WRS_ROW", 120))
.select(landsatSensorBandNameDict["C1_SRFMASK"])
)
if includeSLCOffL7:
print("Including All Landsat 7 for TOA QA")
l7sTOAFMASK = (
ee.ImageCollection(landsatCollectionDict["C1_L7_SR"])
.filterDate(startDate, endDate.advance(1, "day"))
.filter(ee.Filter.calendarRange(startJulian, endJulian))
.filterBounds(studyArea)
.filter(ee.Filter.lte("WRS_ROW", 120))
.select(landsatSensorBandNameDict["C1_SRFMASK"])
)
else:
print("Only including SLC On Landsat 7 for TOA QA")
l7sTOAFMASK = (
ee.ImageCollection(landsatCollectionDict["C1_L7_SR"])
.filterDate(
ee.Date.fromYMD(1998, 1, 1),
ee.Date.fromYMD(2003, 5, 31).advance(1, "day"),
)
.filterDate(startDate, endDate.advance(1, "day"))
.filter(ee.Filter.calendarRange(startJulian, endJulian))
.filterBounds(studyArea)
.filter(ee.Filter.lte("WRS_ROW", 120))
.select(landsatSensorBandNameDict["C1_SRFMASK"])
)
lsTOAFMASK = ee.ImageCollection(l4sTOAFMASK.merge(l5sTOAFMASK).merge(l7sTOAFMASK).merge(l8sTOAFMASK))
# Join the TOA with SR QA bands
print("Joining TOA with SR QA bands")
# print(ls.size(), lsTOAFMASK.size())
ls = joinCollections(ls.select([0, 1, 2, 3, 4, 5, 6]), lsTOAFMASK, False, "system:index")
# lsTOAFMASK = getLandsat('SR').select(['pixel_qa'])
# #Join the TOA with SR QA bands
# print('Joining TOA with SR QA bands')
# ls = joinCollections(ls.select([0,1,2,3,4,5,6]),lsTOAFMASK)
def dataInAllBands(img):
return img.updateMask(img.select(["blue", "green", "red", "nir", "swir1", "swir2"]).mask().reduce(ee.Reducer.min()))
# return img.multiply(multImageDict[toaOrSR]).copyProperties(img,['system:time_start']).copyProperties(img)
# Make sure all bands have data
ls = ls.map(dataInAllBands)
# Set resampling method - only sets to non nearest-neighbor for continuous bands
def setResample(img):
return img.resample(resampleMethod)
if resampleMethod in ["bilinear", "bicubic"]:
print("Setting resample method to ", resampleMethod)
ls = ls.map(
lambda img: img.addBands(
img.select(landsat_continuous_bands).resample(resampleMethod),
None,
True,
)
)
elif resampleMethod == "aggregate":
print("Setting to aggregate instead of resample ")
ls = ls.map(
lambda img: img.addBands(
img.select(landsat_continuous_bands).reduceResolution(ee.Reducer.mean(), True, 64),
None,
True,
)
)
return ls.set(args)
getImageCollection = getLandsat
###########################################################################
# Helper function to apply an expression and linearly rescale the output.
# Used in the landsatCloudScore function below.
[docs]
def rescale(img: ee.Image, thresholds: tuple) -> ee.Image:
"""Rescales pixel values in an image using min-max normalization.
Computes ``(img - min) / (max - min)`` for linear rescaling. Used internally
by cloud scoring functions.
Args:
img (ee.Image): The input Earth Engine image (typically single-band).
thresholds (tuple[float, float]): A tuple of ``(min, max)`` values for
the rescaling range.
Returns:
ee.Image: The rescaled image with values nominally between 0 and 1.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> img = ee.Image.constant(0.2)
>>> rescaled = gil.rescale(img, (0.1, 0.3))
"""
return img.subtract(thresholds[0]).divide(thresholds[1] - thresholds[0])
###########################################################################
# /***
# * Implementation of Basic cloud shadow shift
# *
# * Author: Gennadii Donchyts
# * License: Apache 2.0
# */
# Cloud heights added by Ian Housman
# yMult bug fix adapted from code written by Noel Gorelick by Ian Housman
[docs]
def projectShadows(
cloudMask,
image,
irSumThresh,
contractPixels,
dilatePixels,
cloudHeights,
yMult=None,
):
"""Projects cloud shadows based on solar geometry and masks them along with clouds.
Uses solar azimuth and zenith angles to cast shadows from a cloud mask at
multiple cloud heights, then combines the shadow mask with a dark-pixel test.
Args:
cloudMask (ee.Image): A binary cloud mask image (1 = cloud).
image (ee.Image): The input satellite image with bands ``nir``, ``swir1``,
``swir2`` and metadata properties ``MEAN_SOLAR_AZIMUTH_ANGLE`` and
``MEAN_SOLAR_ZENITH_ANGLE``.
irSumThresh (float): Threshold for the sum of infrared bands to identify
dark pixels (e.g., 0.35).
contractPixels (float): Number of pixels to erode the shadow mask.
dilatePixels (float): Number of pixels to dilate the shadow mask.
cloudHeights (ee.List): List of cloud heights (in meters) to test for
shadow projection (e.g., ``ee.List.sequence(500, 10000, 500)``).
yMult (int | None, optional): Multiplier for the Y shadow direction.
Automatically determined from projection if ``None``. Defaults to None.
Returns:
ee.Image: The input image with cloud and cloud shadow pixels masked out
and a ``cloudShadowMask`` band appended.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> heights = ee.List.sequence(500, 10000, 500)
>>> # Typically called via projectShadowsWrapper rather than directly
"""
if yMult == None:
yMult = ee.Algorithms.If(
ee.Algorithms.IsEqual(image.select([3]).projection(), ee.Projection("EPSG:4326")),
1,
-1,
)
meanAzimuth = image.get("MEAN_SOLAR_AZIMUTH_ANGLE")
meanZenith = image.get("MEAN_SOLAR_ZENITH_ANGLE")
##################################################
# print('a',meanAzimuth)
# print('z',meanZenith)
# Find dark pixels
darkPixels = image.select(["nir", "swir1", "swir2"]).reduce(ee.Reducer.sum()).lt(irSumThresh).focal_min(contractPixels).focal_max(dilatePixels)
# .gte(1)
# Get scale of image
nominalScale = cloudMask.projection().nominalScale()
# Find where cloud shadows should be based on solar geometry
# Convert to radians
azR = ee.Number(meanAzimuth).add(180).multiply(Math.PI).divide(180.0)
zenR = ee.Number(meanZenith).multiply(Math.PI).divide(180.0)
def castShadows(cloudHeight):
cloudHeight = ee.Number(cloudHeight)
shadowCastedDistance = zenR.tan().multiply(cloudHeight) # Distance shadow is cast
x = azR.sin().multiply(shadowCastedDistance).divide(nominalScale) # X distance of shadow
y = azR.cos().multiply(shadowCastedDistance).divide(nominalScale).multiply(yMult)
# Y distance of shadow
return cloudMask.changeProj(cloudMask.projection(), cloudMask.projection().translate(x, y))
# Find the shadows
shadows = cloudHeights.map(castShadows)
shadowMask = ee.ImageCollection.fromImages(shadows).max()
# Create shadow mask
shadowMask = shadowMask.And(cloudMask.Not())
shadowMask = shadowMask.And(darkPixels).focal_min(contractPixels).focal_max(dilatePixels)
# Map.addLayer(cloudMask.updateMask(cloudMask),{'min':1,'max':1,'palette':'88F'},'Cloud mask')
# Map.addLayer(shadowMask.updateMask(shadowMask),{'min':1,'max':1,'palette':'880'},'Shadow mask')
cloudShadowMask = shadowMask.Or(cloudMask)
image = image.updateMask(cloudShadowMask.Not()).addBands(shadowMask.rename(["cloudShadowMask"]))
return image
[docs]
def projectShadowsWrapper(
img,
cloudThresh=20,
irSumThresh=0.35,
contractPixels=1.5,
dilatePixels=3.5,
cloudHeights=ee.List.sequence(500, 10000, 500),
):
"""Wrapper that computes a cloud mask and projects cloud shadows for a Sentinel-2 image.
Combines cloud scoring with shadow projection in a single step. Uses
``sentinel2CloudScore`` to generate a cloud mask, then calls
``projectShadows`` to find and mask cloud shadows.
Args:
img (ee.Image): The input Sentinel-2 image with common band names.
cloudThresh (float, optional): Cloud score threshold (0-100). Pixels
above this are considered cloud. Defaults to 20.
irSumThresh (float, optional): IR sum threshold for dark pixel
detection. Defaults to 0.35.
contractPixels (float, optional): Erosion kernel size in pixels.
Defaults to 1.5.
dilatePixels (float, optional): Dilation kernel size in pixels.
Defaults to 3.5.
cloudHeights (ee.List, optional): Cloud heights to test in meters.
Defaults to ``ee.List.sequence(500, 10000, 500)``.
Returns:
ee.Image: The image with clouds and cloud shadows masked out.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CA"]
>>> s2 = gil.getS2(studyArea, "2023-06-01", "2023-09-01", 152, 244)
>>> masked = s2.map(gil.projectShadowsWrapper)
"""
args = formatArgs(locals())
cloudMask = sentinel2CloudScore(img).gt(cloudThresh).focal_min(contractPixels).focal_max(dilatePixels)
img = projectShadows(cloudMask, img, irSumThresh, contractPixels, dilatePixels, cloudHeights)
return img.set(args)
#########################################################################
#########################################################################
# Function to mask clouds using the Sentinel-2 QA band.
[docs]
def maskS2clouds(image: ee.Image) -> ee.Image:
"""Masks clouds and cirrus in a Sentinel-2 image using the QA60 band.
Uses bits 10 (opaque clouds) and 11 (cirrus) of the QA60 band to create
a binary cloud mask.
Args:
image (ee.Image): The input Sentinel-2 image containing a ``QA60`` band.
Returns:
ee.Image: The cloud-masked Sentinel-2 image.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> s2 = ee.ImageCollection("COPERNICUS/S2_HARMONIZED").first()
>>> masked = gil.maskS2clouds(s2)
"""
qa = image.select("QA60").int16()
# Bits 10 and 11 are clouds and cirrus, respectively.
cloudBitMask = 1 << 10
cirrusBitMask = 1 << 11
# Both flags should be set to zero, indicating clear conditions.
mask = qa.bitwiseAnd(cloudBitMask).eq(0).And(qa.bitwiseAnd(cirrusBitMask).eq(0))
# Return the masked and scaled data.
return image.updateMask(mask)
#########################################################################
#########################################################################
# Compute a cloud score and adds a band that represents the cloud mask.
# This expects the input image to have the common band names:
# ["red", "blue", etc], so it can work across sensors.
[docs]
def landsatCloudScore(img: ee.Image) -> ee.Image:
"""Computes a cloud score for a Landsat image based on spectral indicators.
Evaluates multiple cloud indicators (blue brightness, visible brightness,
IR brightness, temperature, and NDSI) and returns the minimum score. Works
across sensors as long as the image uses common band names (blue, green, red,
nir, swir1, swir2, temp).
Args:
img (ee.Image): The input image with common band names: blue, green,
red, nir, swir1, swir2, and temp.
Returns:
ee.Image: A single-band image named ``"cloudScore"`` with values 0-100,
where higher values indicate greater cloud likelihood.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CA"]
>>> ls = gil.getLandsat(studyArea, "2023-06-01", "2023-09-01", 152, 244)
>>> scored = ls.map(lambda img: img.addBands(gil.landsatCloudScore(img)))
"""
# Compute several indicators of cloudiness and take the minimum of them.
score = ee.Image(1.0)
# Clouds are reasonably bright in the blue band.
score = score.min(rescale(img.select(["blue"]), [0.1, 0.3]))
# Clouds are reasonably bright in all visible bands.
score = score.min(rescale(img.select(["red", "green", "blue"]).reduce(ee.Reducer.sum()), [0.2, 0.8]))
# Clouds are reasonably bright in all infrared bands.
score = score.min(rescale(img.select(["swir1", "swir2", "nir"]).reduce(ee.Reducer.sum()), [0.3, 0.8]))
# Clouds are reasonably cool in temperature.
# Unmask temperature to a cold cold temp so it doesn't exclude the pixels entirely
# This is an issue largely with SR data where a suspected cloud temperature value is masked out
tempUnmasked = img.select(["temp"]).unmask(270)
score = score.min(rescale(tempUnmasked, [300, 290]))
# However, clouds are not snow.
ndsi = img.normalizedDifference(["green", "swir1"])
score = score.min(rescale(ndsi, [0.8, 0.6]))
# ss = snowScore(img).select(['snowScore'])
# score = score.min(rescale(ss, 'img', [0.3, 0]))
score = score.multiply(100).byte()
score = score.clamp(0, 100)
return score.rename(["cloudScore"])
#########################################################################
#########################################################################
# Wrapper for applying cloudScore function
[docs]
def applyCloudScoreAlgorithm(
collection: ee.ImageCollection,
cloudScoreFunction: "function",
cloudScoreThresh: float = 20,
cloudScorePctl: float = 10,
contractPixels: float = 1.5,
dilatePixels: float = 3.5,
performCloudScoreOffset: bool = True,
preComputedCloudScoreOffset: ee.Image = None,
) -> ee.ImageCollection:
"""Applies a cloud score algorithm to an image collection and masks cloudy pixels.
Computes per-pixel cloud scores, optionally subtracts a percentile-based
offset to reduce commission errors, then masks pixels exceeding the threshold.
Args:
collection (ee.ImageCollection): The input image collection.
cloudScoreFunction (Callable[[ee.Image], ee.Image]): A function that
takes an image and returns a single-band cloud score image.
cloudScoreThresh (float, optional): Cloud score threshold for masking.
Defaults to 20.
cloudScorePctl (float, optional): Percentile for computing the cloud
score offset. Defaults to 10.
contractPixels (float, optional): Erosion kernel size in pixels.
Defaults to 1.5.
dilatePixels (float, optional): Dilation kernel size in pixels.
Defaults to 3.5.
performCloudScoreOffset (bool, optional): Whether to subtract a
per-pixel offset based on the time series. Defaults to True.
preComputedCloudScoreOffset (ee.Image | None, optional): A pre-computed
cloud score offset image. If ``None``, computed from the collection.
Defaults to None.
Returns:
ee.ImageCollection: The image collection with cloudy pixels masked out.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CA"]
>>> ls = gil.getLandsat(studyArea, "2023-06-01", "2023-09-01", 152, 244)
>>> masked = gil.applyCloudScoreAlgorithm(ls, gil.landsatCloudScore)
"""
# Add cloudScore
def cloudScoreWrapper(img):
img = ee.Image(img)
cs = cloudScoreFunction(img).rename(["cloudScore"])
return img.addBands(cs)
collection = collection.map(cloudScoreWrapper)
if performCloudScoreOffset:
if preComputedCloudScoreOffset == None:
# Find low cloud score pctl for each pixel to avoid commission errors
print("Computing cloudScore offset")
minCloudScore = collection.select(["cloudScore"]).reduce(ee.Reducer.percentile([cloudScorePctl]))
else:
print("Using pre-computed cloudScore offset")
minCloudScore = preComputedCloudScoreOffset.rename(["cloudScore"])
else:
print("Not computing cloudScore offset")
minCloudScore = ee.Image(0).rename(["cloudScore"])
# Apply cloudScore
def cloudScoreBusterWrapper(img):
cloudMask = img.select(["cloudScore"]).subtract(minCloudScore).lt(cloudScoreThresh).focal_max(contractPixels).focal_min(dilatePixels).rename(["cloudMask"])
return img.updateMask(cloudMask)
collection = collection.map(cloudScoreBusterWrapper)
return collection
#########################################################################
#########################################################################
# Functions for applying fmask to SR data
fmaskBitDict = {
"C1": {"cloud": 5, "shadow": 3, "snow": 4},
"C2": {"cloud": 3, "shadow": 4, "snow": 5},
}
# LSC updated 4/16/19 to add medium and high confidence cloud masks
# Supported fmaskClass options: 'cloud', 'shadow', 'snow', 'high_confidence_cloud', 'med_confidence_cloud'
[docs]
def cFmask(img: ee.Image, fmaskClass: str, bitMaskBandName: str = "QA_PIXEL") -> ee.Image:
"""Applies the CFMask algorithm to mask a specific class in a Landsat image.
Supports masking clouds, cloud shadows, snow, and confidence-level cloud
classes using the QA_PIXEL bitmask band.
Args:
img (ee.Image): The input Landsat image containing a QA bitmask band.
fmaskClass (str): The class to mask. Options: ``"cloud"``, ``"shadow"``,
``"snow"``, ``"high_confidence_cloud"``, ``"med_confidence_cloud"``.
bitMaskBandName (str, optional): The name of the QA bitmask band.
Defaults to ``"QA_PIXEL"``.
Returns:
ee.Image: The image with the specified class masked out.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CA"]
>>> ls = gil.getLandsat(studyArea, "2023-06-01", "2023-09-01", 152, 244)
>>> cloud_masked = ls.map(lambda img: gil.cFmask(img, "cloud"))
"""
qa = img.select(bitMaskBandName).uint16()
if fmaskClass == "high_confidence_cloud":
m = qa.bitwiseAnd(1 << 6).neq(0).And(qa.bitwiseAnd(1 << 7).neq(0))
elif fmaskClass == "med_confidence_cloud":
m = qa.bitwiseAnd(1 << 7).neq(0)
else:
m = qa.bitwiseAnd(fmaskBitDict[fmaskClass]).neq(0)
return img.updateMask(m.Not())
# Method for applying a single bit bit mask
[docs]
def applyBitMask(img: ee.Image, bit: int, bitMaskBandName: str = "QA_PIXEL") -> ee.Image:
"""Masks pixels where a specific bit is set in a QA bitmask band.
Args:
img (ee.Image): The input image containing a QA bitmask band.
bit (int): The bit position (0-indexed) to test. Pixels with this
bit set to 1 will be masked out.
bitMaskBandName (str, optional): The name of the QA bitmask band.
Defaults to ``"QA_PIXEL"``.
Returns:
ee.Image: The image with pixels masked where the specified bit is set.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> img = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_044034_20210508")
>>> cloud_masked = gil.applyBitMask(img, 3) # Bit 3 = cloud in C2
"""
m = img.select([bitMaskBandName]).uint16()
m = m.bitwiseAnd(1 << bit).neq(0)
return img.updateMask(m.Not())
[docs]
def cFmaskCloud(img: ee.Image, landsatCollectionVersion: str, bitMaskBandName: str = "QA_PIXEL") -> ee.Image:
"""Applies the CFMask cloud mask to a Landsat image.
Convenience wrapper around ``applyBitMask`` that uses the correct cloud
bit position for the specified Landsat collection version.
Args:
img (ee.Image): The input Landsat image containing a QA bitmask band.
landsatCollectionVersion (str): The Landsat collection version
(``"C1"`` or ``"C2"``).
bitMaskBandName (str, optional): The name of the QA bitmask band.
Defaults to ``"QA_PIXEL"``.
Returns:
ee.Image: The image with cloud pixels masked out.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CA"]
>>> ls = gil.getLandsat(studyArea, "2023-06-01", "2023-09-01", 152, 244)
>>> cloud_free = ls.map(lambda img: gil.cFmaskCloud(img, "C2"))
"""
return applyBitMask(img, fmaskBitDict[landsatCollectionVersion]["cloud"], bitMaskBandName)
[docs]
def cFmaskCloudShadow(img: ee.Image, landsatCollectionVersion: str, bitMaskBandName: str = "QA_PIXEL") -> ee.Image:
"""Applies the CFMask cloud shadow mask to a Landsat image.
Convenience wrapper around ``applyBitMask`` that uses the correct shadow
bit position for the specified Landsat collection version.
Args:
img (ee.Image): The input Landsat image containing a QA bitmask band.
landsatCollectionVersion (str): The Landsat collection version
(``"C1"`` or ``"C2"``).
bitMaskBandName (str, optional): The name of the QA bitmask band.
Defaults to ``"QA_PIXEL"``.
Returns:
ee.Image: The image with cloud shadow pixels masked out.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CA"]
>>> ls = gil.getLandsat(studyArea, "2023-06-01", "2023-09-01", 152, 244)
>>> shadow_free = ls.map(lambda img: gil.cFmaskCloudShadow(img, "C2"))
"""
return applyBitMask(img, fmaskBitDict[landsatCollectionVersion]["shadow"], bitMaskBandName)
#########################################################################
#########################################################################
# Function for finding dark outliers in time series.
# Original concept written by Carson Stam and adapted by Ian Housman.
# Adds a band that is a mask of pixels that are dark, and dark outliers.
[docs]
def simpleTDOM2(
collection: ee.ImageCollection,
zScoreThresh: float = -1,
shadowSumThresh: float = 0.35,
contractPixels: float = 1.5,
dilatePixels: float = 3.5,
shadowSumBands: list = ["nir", "swir1"],
preComputedTDOMIRMean: ee.Image | None = None,
preComputedTDOMIRStdDev: ee.Image | None = None,
) -> ee.ImageCollection:
"""Applies Temporal Dark Outlier Mask (TDOM) to detect and mask cloud shadows.
Identifies dark outlier pixels by comparing each image to the temporal mean
and standard deviation. Pixels that are both statistically dark (z-score
below threshold) and absolutely dark (IR sum below threshold) are masked.
Args:
collection (ee.ImageCollection): The input image collection.
zScoreThresh (float, optional): Z-score threshold for identifying dark
outliers. More negative values are more conservative. Defaults to -1.
shadowSumThresh (float, optional): Absolute threshold for the sum of
shadow bands. Defaults to 0.35.
contractPixels (float, optional): Erosion kernel size in pixels.
Defaults to 1.5.
dilatePixels (float, optional): Dilation kernel size in pixels.
Defaults to 3.5.
shadowSumBands (list[str], optional): Band names used for shadow
detection. Defaults to ``["nir", "swir1"]``.
preComputedTDOMIRMean (ee.Image | None, optional): Pre-computed temporal
mean of shadow bands. Computed from collection if ``None``.
Defaults to None.
preComputedTDOMIRStdDev (ee.Image | None, optional): Pre-computed
temporal standard deviation. Computed from collection if ``None``.
Defaults to None.
Returns:
ee.ImageCollection: The collection with dark outlier (shadow) pixels
masked out.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CA"]
>>> ls = gil.getLandsat(studyArea, "2023-01-01", "2023-12-31", 152, 244)
>>> shadow_free = gil.simpleTDOM2(ls)
"""
args = formatArgs(locals())
# Get some pixel-wise stats for the time series
if preComputedTDOMIRMean == None:
print("Computing irMean for TDOM")
irMean = collection.select(shadowSumBands).mean()
else:
print("Using pre-computed irMean for TDOM")
irMean = preComputedTDOMIRMean
if preComputedTDOMIRStdDev == None:
print("Computing irStdDev for TDOM")
irStdDev = collection.select(shadowSumBands).reduce(ee.Reducer.stdDev())
else:
print("Using pre-computed irStdDev for TDOM")
irStdDev = preComputedTDOMIRStdDev
def zThresholder(img):
zScore = img.select(shadowSumBands).subtract(irMean).divide(irStdDev)
irSum = img.select(shadowSumBands).reduce(ee.Reducer.sum())
TDOMMask = zScore.lt(zScoreThresh).reduce(ee.Reducer.sum()).eq(len(shadowSumBands)).And(irSum.lt(shadowSumThresh))
TDOMMask = TDOMMask.focal_min(contractPixels).focal_max(dilatePixels)
return img.updateMask(TDOMMask.Not())
# Mask out dark dark outliers
collection = collection.map(zThresholder)
return collection.set(args)
#########################################################################
#########################################################################
# Function to add common (and less common) spectral indices to an image.
# Includes the Normalized Difference Spectral Vector from (Angiuli and Trianni, 2014)
[docs]
def addIndices(img: ee.Image) -> ee.Image:
"""Adds a comprehensive set of spectral indices to an image.
Computes all pairwise normalized differences (NDSV) across blue, green, red,
nir, swir1, and swir2 bands. Also adds band ratios, EVI, SAVI, and IBI.
Args:
img (ee.Image): Input image with bands named 'blue', 'green', 'red',
'nir', 'swir1', and 'swir2'.
Returns:
ee.Image: The input image with additional bands including ND_*
(normalized differences), R_* (ratios), EVI, SAVI, and IBI.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = ee.Image('LANDSAT/LC08/C02/T1_L2/LC08_044034_20200601')
>>> img_with_indices = addIndices(img)
>>> print(img_with_indices.bandNames().getInfo())
"""
# Add Normalized Difference Spectral Vector (NDSV)
img = img.addBands(img.normalizedDifference(["blue", "green"]).rename(["ND_blue_green"]))
img = img.addBands(img.normalizedDifference(["blue", "red"]).rename(["ND_blue_red"]))
img = img.addBands(img.normalizedDifference(["blue", "nir"]).rename(["ND_blue_nir"]))
img = img.addBands(img.normalizedDifference(["blue", "swir1"]).rename(["ND_blue_swir1"]))
img = img.addBands(img.normalizedDifference(["blue", "swir2"]).rename(["ND_blue_swir2"]))
img = img.addBands(img.normalizedDifference(["green", "red"]).rename(["ND_green_red"]))
img = img.addBands(img.normalizedDifference(["green", "nir"]).rename(["ND_green_nir"])) # NDWBI
img = img.addBands(img.normalizedDifference(["green", "swir1"]).rename(["ND_green_swir1"])) # NDSI, MNDWI
img = img.addBands(img.normalizedDifference(["green", "swir2"]).rename(["ND_green_swir2"]))
img = img.addBands(img.normalizedDifference(["red", "swir1"]).rename(["ND_red_swir1"]))
img = img.addBands(img.normalizedDifference(["red", "swir2"]).rename(["ND_red_swir2"]))
img = img.addBands(img.normalizedDifference(["nir", "red"]).rename(["ND_nir_red"])) # NDVI
img = img.addBands(img.normalizedDifference(["nir", "swir1"]).rename(["ND_nir_swir1"])) # NDWI, LSWI, -NDBI
img = img.addBands(img.normalizedDifference(["nir", "swir2"]).rename(["ND_nir_swir2"])) # NBR, MNDVI
img = img.addBands(img.normalizedDifference(["swir1", "swir2"]).rename(["ND_swir1_swir2"]))
# Add ratios
img = img.addBands(img.select("swir1").divide(img.select("nir")).rename(["R_swir1_nir"])) # ratio 5/4
img = img.addBands(img.select("red").divide(img.select("swir1")).rename(["R_red_swir1"])) # ratio 3/5
# Add Enhanced Vegetation Index (EVI)
evi = img.expression(
"2.5 * ((NIR - RED) / (NIR + 6 * RED - 7.5 * BLUE + 1))",
{
"NIR": img.select("nir"),
"RED": img.select("red"),
"BLUE": img.select("blue"),
},
).float()
img = img.addBands(evi.rename("EVI"))
# Add Soil Adjust Vegetation Index (SAVI)
# using L = 0.5;
savi = img.expression(
"(NIR - RED) * (1 + 0.5)/(NIR + RED + 0.5)",
{"NIR": img.select("nir"), "RED": img.select("red")},
).float()
img = img.addBands(savi.rename(["SAVI"]))
# Add Index-Based Built-Up Index (IBI)
ibi_a = img.expression(
"2*SWIR1/(SWIR1 + NIR)",
{"SWIR1": img.select("swir1"), "NIR": img.select("nir")},
).rename(["IBI_A"])
ibi_b = img.expression(
"(NIR/(NIR + RED)) + (GREEN/(GREEN + SWIR1))",
{
"NIR": img.select("nir"),
"RED": img.select("red"),
"GREEN": img.select("green"),
"SWIR1": img.select("swir1"),
},
).rename(["IBI_B"])
ibi_a = ibi_a.addBands(ibi_b)
ibi = ibi_a.normalizedDifference(["IBI_A", "IBI_B"])
img = img.addBands(ibi.rename(["IBI"]))
return img
#########################################################################
#########################################################################
# Function to add SAVI and EVI
[docs]
def addSAVIandEVI(img: ee.Image) -> ee.Image:
"""Adds SAVI, EVI, and NIRv vegetation indices to an image.
Computes the Enhanced Vegetation Index (EVI), Soil Adjusted Vegetation
Index (SAVI with L=0.5), and Near-Infrared Reflectance of Vegetation (NIRv).
Args:
img (ee.Image): Input image with bands named 'nir', 'red', 'blue',
and 'NDVI'. The NDVI band is required for NIRv computation.
Returns:
ee.Image: The input image with added 'EVI', 'SAVI', and 'NIRv' bands.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = simpleAddIndices(ee.Image('LANDSAT/LC08/C02/T1_L2/LC08_044034_20200601'))
>>> img_with_vi = addSAVIandEVI(img)
>>> print(img_with_vi.select(['EVI', 'SAVI', 'NIRv']).bandNames().getInfo())
"""
# Add Enhanced Vegetation Index (EVI)
evi = img.expression(
"2.5 * ((NIR - RED) / (NIR + 6 * RED - 7.5 * BLUE + 1))",
{
"NIR": img.select("nir"),
"RED": img.select("red"),
"BLUE": img.select("blue"),
},
).float()
img = img.addBands(evi.rename(["EVI"]))
# Add Soil Adjust Vegetation Index (SAVI)
# using L = 0.5
savi = img.expression(
"(NIR - RED) * (1 + 0.5)/(NIR + RED + 0.5)",
{"NIR": img.select("nir"), "RED": img.select("red")},
).float()
#########################################################################
# NIRv: Badgley, G., Field, C. B., & Berry, J. A. (2017). Canopy near-infrared reflectance and terrestrial photosynthesis. Science Advances, 3, e1602244.
# https://www.researchgate.net/publication/315534107_Canopy_near-infrared_reflectance_and_terrestrial_photosynthesis
# NIRv function: 'image' is a 2 band stack of NDVI and NIR
#########################################################################
NIRv = img.select(["NDVI"]).subtract(0.08).multiply(img.select(["nir"])) # .multiply(0.0001))
img = img.addBands(savi.rename(["SAVI"])).addBands(NIRv.rename(["NIRv"]))
return img
#########################################################################
###############################################################
# Apply bloom detection algorithm
[docs]
def HoCalcAlgorithm2(image: ee.Image) -> ee.Image:
"""Applies an algal bloom detection algorithm to an image.
Computes a green/blue ratio ('bloom2') and Normalized Difference Green
Index ('NDGI') based on Matthews (2011), DOI: 10.1080/01431161.2010.512947.
Args:
image (ee.Image): Input image with 'green' and 'blue' bands.
Returns:
ee.Image: The input image with added 'bloom2' (green/blue ratio)
and 'NDGI' (normalized difference of green and blue) bands.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = ee.Image('LANDSAT/LC08/C02/T1_L2/LC08_044034_20200601')
>>> bloom_img = HoCalcAlgorithm2(img)
>>> print(bloom_img.select(['bloom2', 'NDGI']).bandNames().getInfo())
"""
# Algorithm 2 based on:
# Matthews, M. (2011) A current review of empirical procedures
# of remote sensing in inland and near-coastal transitional
# waters, International Journal of Remote Sensing, 32:21,
# 6855-6899, DOI: 10.1080/01431161.2010.512947
# Apply algorithm 2: B2/B1
bloom2 = image.select("green").divide(image.select("blue")).rename(["bloom2"])
ndgi = image.normalizedDifference(["green", "blue"]).rename(["NDGI"])
return image.addBands(bloom2).addBands(ndgi)
#########################################################################
# Function for only adding common indices
[docs]
def simpleAddIndices(in_image: ee.Image) -> ee.Image:
"""Adds common spectral indices (NDVI, NBR, NDMI, NDSI) to an image.
A lightweight alternative to ``addIndices`` that computes only the four
most commonly used normalized-difference indices.
Args:
in_image (ee.Image): Input image with bands named 'nir', 'red',
'swir1', 'swir2', and 'green'.
Returns:
ee.Image: The input image with added 'NDVI', 'NBR', 'NDMI', and
'NDSI' bands.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = ee.Image('LANDSAT/LC08/C02/T1_L2/LC08_044034_20200601')
>>> img = simpleAddIndices(img)
>>> print(img.select(['NDVI', 'NBR', 'NDMI', 'NDSI']).bandNames().getInfo())
"""
in_image = in_image.addBands(in_image.normalizedDifference(["nir", "red"]).select([0], ["NDVI"]))
in_image = in_image.addBands(in_image.normalizedDifference(["nir", "swir2"]).select([0], ["NBR"]))
in_image = in_image.addBands(in_image.normalizedDifference(["nir", "swir1"]).select([0], ["NDMI"]))
in_image = in_image.addBands(in_image.normalizedDifference(["green", "swir1"]).select([0], ["NDSI"]))
return in_image
#########################################################################
#########################################################################
# Function for adding common indices
#########################################################################
[docs]
def addSoilIndices(img: ee.Image) -> ee.Image:
"""Adds soil-related spectral indices to an image.
Computes NDCI (Normalized Difference Chlorophyll Index), NDII (Normalized
Difference Infrared Index), NDFI (Normalized Difference Fraction Index),
BSI (Bare Soil Index), and HI (SWIR1/SWIR2 ratio).
Args:
img (ee.Image): Input image with bands named 'blue', 'red', 'green',
'nir', 'swir1', and 'swir2'.
Returns:
ee.Image: The input image with added 'NDCI', 'NDII', 'NDFI', 'BSI',
and 'HI' bands.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = ee.Image('LANDSAT/LC08/C02/T1_L2/LC08_044034_20200601')
>>> img = addSoilIndices(img)
>>> print(img.select(['BSI', 'HI']).bandNames().getInfo())
"""
img = img.addBands(img.normalizedDifference(["red", "green"]).rename(["NDCI"]))
img = img.addBands(img.normalizedDifference(["red", "swir2"]).rename(["NDII"]))
img = img.addBands(img.normalizedDifference(["swir1", "nir"]).rename(["NDFI"]))
bsi = img.expression(
"((SWIR1 + RED) - (NIR + BLUE)) / ((SWIR1 + RED) + (NIR + BLUE))",
{
"BLUE": img.select("blue"),
"RED": img.select("red"),
"NIR": img.select("nir"),
"SWIR1": img.select("swir1"),
},
).float()
img = img.addBands(bsi.rename(["BSI"]))
hi = img.expression("SWIR1 / SWIR2", {"SWIR1": img.select("swir1"), "SWIR2": img.select("swir2")}).float()
img = img.addBands(hi.rename(["HI"])).float()
return img
#########################################################################
#########################################################################
# Function to compute the Tasseled Cap transformation and return an image
# with the following bands added: ['brightness', 'greenness', 'wetness',
#'fourth', 'fifth', 'sixth']
[docs]
def getTasseledCap(image: ee.Image) -> ee.Image:
"""Computes the Tasseled Cap transformation using Crist 1985 coefficients.
Applies the 6-component Tasseled Cap transformation for TOA reflectance
data using coefficients from Crist (1985).
Args:
image (ee.Image): Input image with bands named 'blue', 'green', 'red',
'nir', 'swir1', and 'swir2'.
Returns:
ee.Image: The input image with added 'brightness', 'greenness',
'wetness', 'fourth', 'fifth', and 'sixth' bands.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = ee.Image('LANDSAT/LC08/C02/T1_TOA/LC08_044034_20200601')
>>> tc = getTasseledCap(img)
>>> print(tc.select(['brightness', 'greenness', 'wetness']).bandNames().getInfo())
"""
bands = ee.List(["blue", "green", "red", "nir", "swir1", "swir2"])
# // // Kauth-Thomas coefficients for Thematic Mapper data
# // var coefficients = ee.Array([
# // [0.3037, 0.2793, 0.4743, 0.5585, 0.5082, 0.1863],
# // [-0.2848, -0.2435, -0.5436, 0.7243, 0.0840, -0.1800],
# // [0.1509, 0.1973, 0.3279, 0.3406, -0.7112, -0.4572],
# // [-0.8242, 0.0849, 0.4392, -0.0580, 0.2012, -0.2768],
# // [-0.3280, 0.0549, 0.1075, 0.1855, -0.4357, 0.8085],
# // [0.1084, -0.9022, 0.4120, 0.0573, -0.0251, 0.0238]
# // ]);
# Crist 1985 coeffs - TOA refl (http://www.gis.usu.edu/~doug/RS5750/assign/OLD/RSE(17)-301.pdf)
coefficients = ee.Array(
[
[0.2043, 0.4158, 0.5524, 0.5741, 0.3124, 0.2303],
[-0.1603, -0.2819, -0.4934, 0.7940, -0.0002, -0.1446],
[0.0315, 0.2021, 0.3102, 0.1594, -0.6806, -0.6109],
[-0.2117, -0.0284, 0.1302, -0.1007, 0.6529, -0.7078],
[-0.8669, -0.1835, 0.3856, 0.0408, -0.1132, 0.2272],
[0.3677, -0.8200, 0.4354, 0.0518, -0.0066, -0.0104],
]
)
# Make an Array Image, with a 1-D Array per pixel.
arrayImage1D = image.select(bands).toArray()
# Make an Array Image with a 2-D Array per pixel, 6x1.
arrayImage2D = arrayImage1D.toArray(1)
componentsImage = ee.Image(coefficients).matrixMultiply(arrayImage2D).arrayProject([0]).arrayFlatten([["brightness", "greenness", "wetness", "fourth", "fifth", "sixth"]]).float()
return image.addBands(componentsImage)
#########################################################################
#########################################################################
[docs]
def simpleGetTasseledCap(image: ee.Image) -> ee.Image:
"""Computes a simplified Tasseled Cap with brightness, greenness, and wetness only.
Uses Crist 1985 TOA reflectance coefficients but returns only the first
three components, omitting the fourth through sixth.
Args:
image (ee.Image): Input image with bands named 'blue', 'green', 'red',
'nir', 'swir1', and 'swir2'.
Returns:
ee.Image: The input image with added 'brightness', 'greenness', and
'wetness' bands.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = ee.Image('LANDSAT/LC08/C02/T1_TOA/LC08_044034_20200601')
>>> tc = simpleGetTasseledCap(img)
>>> print(tc.select(['brightness', 'greenness', 'wetness']).bandNames().getInfo())
"""
bands = ee.List(["blue", "green", "red", "nir", "swir1", "swir2"])
# // // Kauth-Thomas coefficients for Thematic Mapper data
# // var coefficients = ee.Array([
# // [0.3037, 0.2793, 0.4743, 0.5585, 0.5082, 0.1863],
# // [-0.2848, -0.2435, -0.5436, 0.7243, 0.0840, -0.1800],
# // [0.1509, 0.1973, 0.3279, 0.3406, -0.7112, -0.4572],
# // [-0.8242, 0.0849, 0.4392, -0.0580, 0.2012, -0.2768],
# // [-0.3280, 0.0549, 0.1075, 0.1855, -0.4357, 0.8085],
# // [0.1084, -0.9022, 0.4120, 0.0573, -0.0251, 0.0238]
# // ]);
# Crist 1985 coeffs - TOA refl (http://www.gis.usu.edu/~doug/RS5750/assign/OLD/RSE(17)-301.pdf)
coefficients = ee.Array(
[
[0.2043, 0.4158, 0.5524, 0.5741, 0.3124, 0.2303],
[-0.1603, -0.2819, -0.4934, 0.7940, -0.0002, -0.1446],
[0.0315, 0.2021, 0.3102, 0.1594, -0.6806, -0.6109],
]
)
# Make an Array Image, with a 1-D Array per pixel.
arrayImage1D = image.select(bands).toArray()
# Make an Array Image with a 2-D Array per pixel, 6x1.
arrayImage2D = arrayImage1D.toArray(1)
componentsImage = ee.Image(coefficients).matrixMultiply(arrayImage2D).arrayProject([0]).arrayFlatten([["brightness", "greenness", "wetness"]]).float()
return image.addBands(componentsImage)
#########################################################################
#########################################################################
# Function to add Tasseled Cap angles and distances to an image.
# Assumes image has bands: 'brightness', 'greenness', and 'wetness'.
[docs]
def addTCAngles(image: ee.Image) -> ee.Image:
"""Adds Tasseled Cap angles and distances to an image.
Computes pairwise angles (atan2) and Euclidean distances (hypot) between
the brightness, greenness, and wetness TC components. Angles are divided
by pi to normalize to the range [-1, 1].
Args:
image (ee.Image): Input image with 'brightness', 'greenness', and
'wetness' bands (e.g., output of ``getTasseledCap``).
Returns:
ee.Image: The input image with added bands: 'tcAngleBG', 'tcAngleGW',
'tcAngleBW', 'tcDistBG', 'tcDistGW', 'tcDistBW'.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = getTasseledCap(ee.Image('LANDSAT/LC08/C02/T1_TOA/LC08_044034_20200601'))
>>> img = addTCAngles(img)
>>> print(img.select(['tcAngleBG', 'tcDistBG']).bandNames().getInfo())
"""
# Select brightness, greenness, and wetness bands
brightness = image.select(["brightness"])
greenness = image.select(["greenness"])
wetness = image.select(["wetness"])
# Calculate Tasseled Cap angles and distances
tcAngleBG = brightness.atan2(greenness).divide(math.pi).rename(["tcAngleBG"])
tcAngleGW = greenness.atan2(wetness).divide(math.pi).rename(["tcAngleGW"])
tcAngleBW = brightness.atan2(wetness).divide(math.pi).rename(["tcAngleBW"])
tcDistBG = brightness.hypot(greenness).rename(["tcDistBG"])
tcDistGW = greenness.hypot(wetness).rename(["tcDistGW"])
tcDistBW = brightness.hypot(wetness).rename(["tcDistBW"])
image = image.addBands(tcAngleBG).addBands(tcAngleGW).addBands(tcAngleBW).addBands(tcDistBG).addBands(tcDistGW).addBands(tcDistBW)
return image
#########################################################################
#########################################################################
# Only adds tc bg angle as in Powell et al 2009
# https://www.sciencedirect.com/science/article/pii/S0034425709003745?via%3Dihub
[docs]
def simpleAddTCAngles(image: ee.Image) -> ee.Image:
"""Adds the Tasseled Cap brightness-greenness angle as in Powell et al. (2009).
A simplified version of ``addTCAngles`` that only computes the tcAngleBG
band. See: https://doi.org/10.1016/j.rse.2009.08.016
Args:
image (ee.Image): Input image with 'brightness', 'greenness', and
'wetness' bands (e.g., output of ``simpleGetTasseledCap``).
Returns:
ee.Image: The input image with an added 'tcAngleBG' band (angle
normalized by pi).
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = simpleGetTasseledCap(ee.Image('LANDSAT/LC08/C02/T1_TOA/LC08_044034_20200601'))
>>> img = simpleAddTCAngles(img)
>>> print(img.select('tcAngleBG').bandNames().getInfo())
"""
# Select brightness, greenness, and wetness bands
brightness = image.select(["brightness"])
greenness = image.select(["greenness"])
wetness = image.select(["wetness"])
# Calculate Tasseled Cap angles and distances
tcAngleBG = brightness.atan2(greenness).divide(math.pi).rename(["tcAngleBG"])
return image.addBands(tcAngleBG)
#########################################################################
#########################################################################
# Function to add solar zenith and azimuth in radians as bands to image
[docs]
def addZenithAzimuth(
img: ee.Image,
toaOrSR: str,
zenithDict: dict = {"TOA": "SUN_ELEVATION", "SR": "SOLAR_ZENITH_ANGLE"},
azimuthDict: dict = {"TOA": "SUN_AZIMUTH", "SR": "SOLAR_AZIMUTH_ANGLE"},
):
"""Adds solar zenith and azimuth angles in radians as bands to an image.
Reads sun angle metadata properties from the image, converts degrees to
radians, and adds them as constant-value 'zenith' and 'azimuth' bands.
For TOA images, zenith is derived from SUN_ELEVATION (90 - elevation);
for SR images it is read directly from SOLAR_ZENITH_ANGLE.
Args:
img (ee.Image): Input Landsat image with sun angle metadata properties.
toaOrSR (str): Either ``'TOA'`` or ``'SR'``, indicating the processing
level and which metadata properties to use.
zenithDict (dict, optional): Mapping from toaOrSR key to the zenith
metadata property name. Defaults to
``{'TOA': 'SUN_ELEVATION', 'SR': 'SOLAR_ZENITH_ANGLE'}``.
azimuthDict (dict, optional): Mapping from toaOrSR key to the azimuth
metadata property name. Defaults to
``{'TOA': 'SUN_AZIMUTH', 'SR': 'SOLAR_AZIMUTH_ANGLE'}``.
Returns:
ee.Image: The input image with added 'zenith' and 'azimuth' bands
in radians.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = ee.Image('LANDSAT/LC08/C02/T1_TOA/LC08_044034_20200601')
>>> img = addZenithAzimuth(img, 'TOA')
>>> print(img.select(['zenith', 'azimuth']).bandNames().getInfo())
"""
zenith = ee.Image.constant(img.get(zenithDict[toaOrSR])).multiply(math.pi).divide(180).float().rename(["zenith"])
azimuth = ee.Image.constant(img.get(azimuthDict[toaOrSR])).multiply(math.pi).divide(180).float().rename(["azimuth"])
return img.addBands(zenith).addBands(azimuth)
#########################################################################
#########################################################################
# Function for computing the mean squared difference medoid from an image collection
# As the data are not normalized in this method, ensuring the medoidIncludeBands have roughly comparable ranges of values
# helps the function work properly. For example, if temperature is included, it will account for most of the variance
# thus resulting in a medoid mosaic that will more or less choose values closest to the median temperature only, rather than
# all the bands
[docs]
def medoidMosaicMSD(inCollection: ee.ImageCollection, medoidIncludeBands: ee.List | None = None) -> ee.Image:
"""Creates a medoid mosaic using the Mean Squared Difference (MSD) method.
Calculates the medoid image from an image collection by minimizing the sum
of squared differences (Euclidean distance) between each pixel and the
collection median.
Args:
inCollection (ee.ImageCollection): The input collection to create the
mosaic from. Must have consistent band names and data types.
medoidIncludeBands (ee.List | None, optional): Band names to include in
the MSD calculation. If ``None``, all bands are used. Defaults to
``None``.
Returns:
ee.Image: The medoid mosaic image, containing all original bands plus
'year' and 'julianDay' bands.
Note:
Data are not normalized, so bands should have roughly comparable value
ranges. If temperature is included alongside reflectance, it may
dominate the variance and bias the medoid selection.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CO"]
>>> s2s = gil.superSimpleGetS2(studyArea, "2024-01-01", "2024-12-31", 190, 250)
>>> medoid = gil.medoidMosaicMSD(s2s, ["green", "red", "nir", "swir1", "swir2"])
"""
if medoidIncludeBands == None:
medoidIncludeBands = ee.Image(inCollection.first()).bandNames()
# Find the median
median = inCollection.select(medoidIncludeBands).median()
# Find the squared difference from the median for each image
# Multiply by -1 so quality mosaic function can be used
def msdGetter(img):
diff = ee.Image(img).select(medoidIncludeBands).subtract(median).pow(2)
img = addYearBand(img)
img = addJulianDayBand(img)
return diff.reduce("sum").multiply(-1).addBands(img)
medoid = inCollection.map(msdGetter)
# Minimize the distance across all bands by finding the pixels that correspond to the minimum distance (multiplied by -1 above)
medoid = medoid.qualityMosaic("sum")
medoid = medoid.select(medoid.bandNames().remove("sum"))
return medoid
#########################################################################
#########################################################################
# Function to export a provided image to an EE asset
# For earthengine-api version 0.1.226
# There was an issue with the region with this new version.
# From earthengine-api batch.Export.toAsset documentation: "region: The lon,lat coordinates for a LinearRing or Polygon specifying the region to export.
# Can be specified as a nested lists of numbers or a serialized string. Defaults to the image's region."
[docs]
def exportToAssetWrapper(
imageForExport: ee.Image, assetName: str, assetPath: str, pyramidingPolicyObject: dict | None = None, roi: ee.Geometry | None = None, scale: float | None = None, crs: str | None = None, transform: list | None = None, overwrite: bool = False
):
"""Exports an image to an Earth Engine asset with overwrite handling.
Wraps ``ee.batch.Export.image.toAsset`` with logic to check for existing
assets or running exports, and optionally overwrite them.
Args:
imageForExport (ee.Image): The image to export.
assetName (str): Task description / asset name (spaces replaced with '-').
assetPath (str): Full Earth Engine asset path
(e.g., ``'projects/my-project/assets/my_image'``).
pyramidingPolicyObject (dict | None, optional): Pyramiding policy, e.g.,
``{'.default': 'mean'}``. If a string is provided it is wrapped as
``{'.default': value}``. Defaults to ``None`` (uses ``'mean'``).
roi (ee.Geometry | None, optional): Region of interest. The image is
clipped to this geometry before export. Defaults to ``None``.
scale (float | None, optional): Export resolution in meters. Defaults
to ``None`` (uses the image's native scale).
crs (str | None, optional): Coordinate reference system, e.g.,
``'EPSG:4326'``. Defaults to ``None``.
transform (list | None, optional): Affine transform as a 6-element
list. Defaults to ``None``.
overwrite (bool, optional): If ``True``, deletes existing asset or
cancels a running export before re-exporting. Defaults to ``False``.
Returns:
None: Starts an Earth Engine export task as a side effect.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = ee.Image('USGS/SRTMGL1_003')
>>> roi = ee.Geometry.Rectangle([-105.5, 39.5, -105.0, 40.0])
>>> exportToAssetWrapper(img, 'srtm_export', 'projects/my-project/assets/srtm', roi=roi, scale=30)
"""
# Get rid of any spaces
assetName = assetName.replace("/\s+/g", "-")
assetPath = assetPath.replace("/\s+/g", "-")
# Pull geometry if feature or featureCollection
if roi != None:
try:
roi = roi.geometry()
except Exception as e:
x = e
imageForExport = imageForExport.clip(roi)
outRegion = roi.bounds(100, crs)
else:
outRegion = None
if transform != None and (str(type(transform)) == "<type 'list'>" or str(type(transform)) == "<class 'list'>"):
transform = str(transform)
if pyramidingPolicyObject == None:
pyramidingPolicyObject = {".default": "mean"}
elif type(pyramidingPolicyObject) == str:
pyramidingPolicyObject = {".default": pyramidingPolicyObject}
# pyramidingPolicyObject = json.dumps(pyramidingPolicyObject)
# print('pyramiding object:',pyramidingPolicyObject)
# Handle different instances of an asset either already existing or currently being exported and whether it should be overwritten
currently_exporting = assetName in tml.getTasks()["running"] or assetName in tml.getTasks()["ready"]
currently_exists = aml.ee_asset_exists(assetPath)
if overwrite and currently_exists:
ee.data.deleteAsset(assetPath)
if overwrite and currently_exporting:
tml.cancelByName(assetName)
if overwrite or (not currently_exists and not currently_exporting):
# LSC 1/6/20 was getting error: "ee.ee_exception.EEException: JSON provided for reductionPolicy must be an object." Getting rid of json.dumps() seemed to fix the problem
t = ee.batch.Export.image.toAsset(
imageForExport,
description=assetName,
assetId=assetPath,
pyramidingPolicy=pyramidingPolicyObject,
dimensions=None,
region=None,
scale=scale,
crs=crs,
crsTransform=transform,
maxPixels=1e13,
)
print("Exporting:", assetName)
# print(t)
t.start()
else:
print(f"{assetName} currently exists or is being exported and overwrite = False. Set overwite = True if you would like to overwite any existing asset or asset exporting task")
# Map.addLayer(imageForExport,vizParamsFalse,assetName)
#########################################################################
[docs]
def exportToDriveWrapper(imageForExport: ee.Image, outputName: str, driveFolderName: str, roi: ee.Geometry, scale: float | None = None, crs: str | None = None, transform: list | None = None, outputNoData: int = -32768):
"""Exports an image to Google Drive as a GeoTIFF.
Wraps ``ee.batch.Export.image.toDrive``, clipping the image to the
provided region of interest and filling masked pixels with ``outputNoData``.
Args:
imageForExport (ee.Image): The image to export.
outputName (str): File name for the exported GeoTIFF (spaces replaced
with '-').
driveFolderName (str): Google Drive folder name to export into.
roi (ee.Geometry): Region of interest; the image is clipped to this
geometry.
scale (float | None, optional): Export resolution in meters. Defaults
to ``None`` (uses the image's native scale).
crs (str | None, optional): Coordinate reference system, e.g.,
``'EPSG:4326'``. Defaults to ``None``.
transform (list | None, optional): Affine transform as a 6-element
list. Defaults to ``None``.
outputNoData (int, optional): Value used to fill masked pixels.
Defaults to ``-32768``.
Returns:
None: Starts an Earth Engine export task as a side effect.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = ee.Image('USGS/SRTMGL1_003')
>>> roi = ee.Geometry.Rectangle([-105.5, 39.5, -105.0, 40.0])
>>> exportToDriveWrapper(img, 'srtm_export', 'EE_Exports', roi, scale=30)
"""
outputName = outputName.replace("/\s+/g", "-") # Get rid of any spaces
# Pull geometry if feature or featureCollection
try:
roi = roi.geometry()
except Exception as e:
x = e
# Make sure image is clipped to roi in case it's a multi-part polygon
imageForExport = imageForExport.clip(roi).unmask(outputNoData, False)
if transform != None and (str(type(transform)) == "<type 'list'>" or str(type(transform)) == "<class 'list'>"):
transform = str(transform)
# Ensure bounds are in export projection
outRegion = roi.bounds(100, crs)
# Map.addLayer(imageForExport,{},outputName,False)
t = ee.batch.Export.image.toDrive(
imageForExport,
outputName,
driveFolderName,
outputName,
None,
outRegion,
scale,
crs,
transform,
1e13,
)
print("Exporting:", outputName)
t.start()
#########################################################################
[docs]
def exportToCloudStorageWrapper(
imageForExport: ee.Image,
outputName: str,
bucketName: str,
roi: ee.Geometry,
scale: float | None = None,
crs: str | None = None,
transform: list | None = None,
outputNoData: int = -32768,
fileFormat: str = "GeoTIFF",
formatOptions: dict = {"cloudOptimized": True},
overwrite: bool = False,
):
"""Exports an image to Google Cloud Storage with overwrite handling.
Wraps ``ee.batch.Export.image.toCloudStorage``, clipping the image to the
region of interest and optionally deleting existing blobs before export.
Args:
imageForExport (ee.Image): The image to export.
outputName (str): Object name / prefix in the bucket (spaces replaced
with '-').
bucketName (str): Name of the Google Cloud Storage bucket.
roi (ee.Geometry): Region of interest; the image is clipped to this
geometry.
scale (float | None, optional): Export resolution in meters. Defaults
to ``None``.
crs (str | None, optional): Coordinate reference system, e.g.,
``'EPSG:4326'``. Defaults to ``None``.
transform (list | None, optional): Affine transform as a 6-element
list. Defaults to ``None``.
outputNoData (int, optional): Value used to fill masked pixels.
Defaults to ``-32768``.
fileFormat (str, optional): Output format, e.g., ``'GeoTIFF'`` or
``'TFRecord'``. Defaults to ``'GeoTIFF'``.
formatOptions (dict, optional): Additional format options passed to the
export. Defaults to ``{'cloudOptimized': True}``.
overwrite (bool, optional): If ``True``, deletes existing blobs or
cancels running exports before re-exporting. Defaults to ``False``.
Returns:
None: Starts an Earth Engine export task as a side effect.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = ee.Image('USGS/SRTMGL1_003')
>>> roi = ee.Geometry.Rectangle([-105.5, 39.5, -105.0, 40.0])
>>> exportToCloudStorageWrapper(img, 'srtm_cog', 'my-bucket', roi, scale=30)
"""
outputName = outputName.replace("/\s+/g", "-") # Get rid of any spaces
extension_dict = {"GeoTIFF": [".tif"], "TFRecord": [".tfrecord", ".json"]}
extensions = extension_dict[fileFormat]
# Pull geometry if feature or featureCollection
try:
roi = roi.geometry()
except Exception as e:
x = e
# Make sure image is clipped to roi in case it's a multi-part polygon
imageForExport = imageForExport.clip(roi).unmask(outputNoData, False)
if transform != None and (str(type(transform)) == "<type 'list'>" or str(type(transform)) == "<class 'list'>"):
transform = str(transform)
# Ensure bounds are in export projection
outRegion = roi.bounds(100, crs)
# Handle different instances of an blob either already existing or currently being exported and whether it should be overwritten
currently_exporting = outputName in tml.getTasks()["running"] or outputName in tml.getTasks()["ready"]
currently_exists = cml.gcs_exists(bucketName, outputName + extensions[0])
if overwrite and currently_exists:
for extension in extensions:
cml.delete_blob(bucketName, outputName + extension)
if overwrite and currently_exporting:
tml.cancelByName(outputName)
if overwrite or (not currently_exists and not currently_exporting):
t = ee.batch.Export.image.toCloudStorage(
imageForExport,
outputName,
bucketName,
outputName,
None,
outRegion,
scale,
crs,
transform,
1e13,
fileFormat=fileFormat,
formatOptions=formatOptions,
)
print("Exporting:", outputName)
print(t)
t.start()
#########################################################################
#########################################################################
# Function for wrapping dates when the startJulian < endJulian
# Checks for year with majority of the days and the wrapOffset
[docs]
def wrapDates(startJulian: int, endJulian: int) -> list:
"""Computes date-wrapping parameters when startJulian > endJulian.
When the compositing window crosses a year boundary (e.g., Oct-Mar),
this function returns the offset and which year contains the majority
of days, so that year labels are assigned correctly.
Args:
startJulian (int): Start day of year (1-365).
endJulian (int): End day of year (1-365).
Returns:
list: A two-element list ``[wrapOffset, yearWithMajority]``.
``wrapOffset`` is 365 if wrapping occurs, else 0.
``yearWithMajority`` is 1 if the second calendar year has more
days in the window, else 0.
Examples:
>>> wrapDates(250, 100)
[365, 1]
>>> wrapDates(1, 250)
[0, 0]
"""
# Set up date wrapping
wrapOffset = 0
yearWithMajority = 0
if startJulian > endJulian:
wrapOffset = 365
y1NDays = 365 - startJulian
y2NDays = endJulian
if y2NDays > y1NDays:
yearWithMajority = 1
return [wrapOffset, yearWithMajority]
#########################################################################
#########################################################################
# Create composites for each year within startYear and endYear range
[docs]
def compositeTimeSeries(
ls: ee.ImageCollection, startYear: int, endYear: int, startJulian: int, endJulian: int, timebuffer: int = 0, weights: list = [1], compositingMethod: str | None = None, compositingReducer: ee.Reducer | None = None
) -> ee.ImageCollection:
"""Creates annual composites from an image collection over a year range.
Generates one composite per year between ``startYear`` and ``endYear``,
filtering by Julian day window. Supports multi-year time buffering with
weighted averaging and various compositing methods.
Args:
ls (ee.ImageCollection): Input image collection to composite.
startYear (int): First year to produce a composite for.
endYear (int): Last year to produce a composite for (inclusive).
startJulian (int): Start day of year for the compositing window (1-365).
endJulian (int): End day of year for the compositing window (1-365).
Can be less than ``startJulian`` for cross-year windows.
timebuffer (int, optional): Number of years on each side to include
in a weighted moving window. Defaults to ``0``.
weights (list, optional): Weights for the moving window years. Length
should be ``2 * timebuffer + 1``. Defaults to ``[1]``.
compositingMethod (str | None, optional): Method name, e.g.,
``'median'`` or ``'medoid'``. Defaults to ``None``.
compositingReducer (ee.Reducer | None, optional): Custom reducer to
use instead of a named method. Defaults to ``None``.
Returns:
ee.ImageCollection: Collection of annual composite images, each tagged
with compositing metadata properties.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> area = ee.Geometry.Point([-105.5, 40.0]).buffer(5000)
>>> composites = compositeTimeSeries(
... gil.getProcessedLandsatScenes(area, 2020, 2022, 152, 273),
... 2020, 2022, 152, 273)
"""
args = formatArgs(locals())
if "args" in args.keys():
del args["args"]
dummyImage = ee.Image(ls.first())
dateWrapping = wrapDates(startJulian, endJulian)
wrapOffset = dateWrapping[0]
yearWithMajority = dateWrapping[1]
def yearCompositeGetter(year):
# Set up dates
startYearT = year - timebuffer
endYearT = year + timebuffer
startDateT = ee.Date.fromYMD(startYearT, 1, 1).advance(startJulian - 1, "day")
endDateT = ee.Date.fromYMD(endYearT, 1, 1).advance(endJulian - 1 + wrapOffset, "day")
# print(year,startDateT,endDateT)
# Set up weighted moving widow
yearsT = ee.List.sequence(startYearT, endYearT)
def zipper(i):
i = ee.List(i)
return ee.List.repeat(i.get(0), i.get(1))
z = yearsT.zip(weights)
yearsTT = z.map(zipper).flatten()
# print('Weighted composite years for year:',year,yearsTT.getInfo())
# Iterate across each year in list
def yrGetter(yr):
# Set up dates
startDateT = ee.Date.fromYMD(yr, 1, 1).advance(startJulian - 1, "day")
endDateT = ee.Date.fromYMD(yr, 1, 1).advance(endJulian - 1 + wrapOffset, "day")
# Filter images for given date range
lsT = ls.filterDate(startDateT, endDateT.advance(1, "day"))
lsT = fillEmptyCollections(lsT, dummyImage)
return lsT
images = yearsTT.map(yrGetter)
lsT = ee.ImageCollection(ee.FeatureCollection(images).flatten())
count = lsT.select([0]).reduce(ee.Reducer.count()).rename(["compositeObsCount"])
# Compute median or medoid or apply reducer
if compositingReducer != None:
composite = lsT.reduce(compositingReducer)
elif compositingMethod.lower() == "median":
composite = lsT.median()
else:
composite = medoidMosaicMSD(lsT, ["green", "red", "nir", "swir1", "swir2"])
composite = composite.addBands(count).float()
return composite.set(
{
"system:time_start": ee.Date.fromYMD(year + yearWithMajority, 6, 1).millis(),
"startDate": startDateT.millis(),
"endDate": endDateT.millis(),
"startJulian": startJulian,
"endJulian": endJulian,
"yearBuffer": timebuffer,
"yearWeights": str(weights),
"yrOriginal": year,
"yrUsed": year + yearWithMajority,
}
)
# Iterate across each year
ts = [yearCompositeGetter(yr) for yr in ee.List.sequence(startYear + timebuffer, endYear - timebuffer).getInfo()]
ts = ee.ImageCollection(ts).set(args)
return ts
# ////////////////////////////////////////////////////////////////////////////////
# Function to calculate illumination condition (IC). Function by Patrick Burns
# (pb463@nau.edu) and Matt Macander
# (mmacander@abrinc.com)
def illuminationCorrection(img: ee.Image, scale: float, studyArea: ee.Geometry, bandList: list = ["blue", "green", "red", "nir", "swir1", "swir2", "temp"]) -> ee.Image:
"""Computes the illumination condition (IC) and terrain bands for an image.
Calculates the illumination condition from solar geometry (zenith/azimuth
bands on the image) and USGS NED terrain data, and appends IC, cosZ, cosS,
and slope bands. This is the first step of the SCSc topographic correction.
Args:
img (ee.Image): Input image with 'zenith' and 'azimuth' bands in
radians (e.g., output of ``addZenithAzimuth``).
scale (float): Scale in meters for any internal reductions.
studyArea (ee.Geometry): Study area geometry (kept for API consistency).
bandList (list, optional): Band names to correct. Defaults to
``['blue', 'green', 'red', 'nir', 'swir1', 'swir2', 'temp']``.
Returns:
ee.Image: The input image with added 'IC', 'cosZ', 'cosS', and
'slope' bands.
Examples:
>>> import ee
>>> ee.Initialize()
>>> img = addZenithAzimuth(ee.Image('LANDSAT/LC08/C02/T1_TOA/LC08_044034_20200601'), 'TOA')
>>> img_ic = illuminationCorrection(img, 30, ee.Geometry.Point([-122, 37]).buffer(1000))
"""
# Extract solar zenith and azimuth bands
SZ_rad = img.select("zenith")
SA_rad = img.select("azimuth")
# Creat terrain layers
# dem = ee.Image('CGIAR/SRTM90_V4')
dem = ee.Image("USGS/NED")
slp = ee.Terrain.slope(dem)
slp_rad = ee.Terrain.slope(dem).multiply(math.pi).divide(180)
asp_rad = ee.Terrain.aspect(dem).multiply(math.pi).divide(180)
# Calculate the Illumination Condition (IC)
# slope part of the illumination condition
cosZ = SZ_rad.cos()
cosS = slp_rad.cos()
slope_illumination = cosS.expression("cosZ * cosS", {"cosZ": cosZ, "cosS": cosS.select("slope")})
# aspect part of the illumination condition
sinZ = SZ_rad.sin()
sinS = slp_rad.sin()
cosAziDiff = (SA_rad.subtract(asp_rad)).cos()
aspect_illumination = sinZ.expression(
"sinZ * sinS * cosAziDiff",
{"sinZ": sinZ, "sinS": sinS, "cosAziDiff": cosAziDiff},
)
# full illumination condition (IC)
ic = slope_illumination.add(aspect_illumination)
# Add IC to original image
return img.addBands(ic.rename("IC")).addBands(cosZ.rename("cosZ")).addBands(cosS.rename("cosS")).addBands(slp.rename("slope"))
########################################
# Function to apply the Sun-Canopy-Sensor + C (SCSc) correction method to each
# image. Function by Patrick Burns (pb463@nau.edu) and Matt Macander
# (mmacander@s.com)
[docs]
def illuminationCorrection(
img,
scale,
studyArea,
bandList=["blue", "green", "red", "nir", "swir1", "swir2", "temp"],
):
"""Applies SCSc topographic correction to specified bands of an image.
Uses the Sun-Canopy-Sensor + C (SCSc) method to correct for illumination
effects caused by terrain slope and aspect. Requires 'IC', 'cosZ', 'cosS',
and 'slope' bands (from the first ``illuminationCorrection`` overload).
Based on code by Patrick Burns and Matt Macander.
Args:
img (ee.Image): Input image with 'IC', 'cosZ', 'cosS', 'slope', and
the bands listed in ``bandList``.
scale (float): Scale in meters for the linear regression reduction.
studyArea (ee.Geometry): Region used for the linear fit regression.
bandList (list, optional): Band names to apply the SCSc correction to.
Defaults to ``['blue', 'green', 'red', 'nir', 'swir1', 'swir2', 'temp']``.
Returns:
ee.Image: The topographically corrected image with original metadata
properties and system:time_start preserved.
Examples:
>>> import ee
>>> ee.Initialize()
>>> area = ee.Geometry.Point([-122, 37]).buffer(5000)
>>> img = addZenithAzimuth(ee.Image('LANDSAT/LC08/C02/T1_TOA/LC08_044034_20200601'), 'TOA')
>>> img_ic = illuminationCorrection(img, 30, area)
>>> corrected = illuminationCorrection(img_ic, 30, area)
"""
props = img.toDictionary()
st = img.get("system:time_start")
img_plus_ic = img
mask2 = img_plus_ic.select("slope").gte(5).And(img_plus_ic.select("IC").gte(0)).And(img_plus_ic.select("nir").gt(-0.1))
img_plus_ic_mask2 = ee.Image(img_plus_ic.updateMask(mask2))
# Specify Bands to topographically correct
compositeBands = img.bandNames()
nonCorrectBands = img.select(compositeBands.removeAll(bandList))
def apply_SCSccorr(bandList):
method = "SCSc"
out = img_plus_ic_mask2.select("IC", bandList).reduceRegion(
**{
"reducer": ee.Reducer.linearFit(),
"geometry": studyArea,
"scale": scale,
"maxPixels": 1e13,
}
)
out_a = ee.Number(out.get("scale"))
out_b = ee.Number(out.get("offset"))
out_c = out_b.divide(out_a)
# Apply the SCSc correction
SCSc_output = img_plus_ic_mask2.expression(
"((image * (cosB * cosZ + cvalue)) / (ic + cvalue))",
{
"image": img_plus_ic_mask2.select(bandList),
"ic": img_plus_ic_mask2.select("IC"),
"cosB": img_plus_ic_mask2.select("cosS"),
"cosZ": img_plus_ic_mask2.select("cosZ"),
"cvalue": out_c,
},
)
return SCSc_output
img_SCSccorr = ee.Image(bandList.map(apply_SCSccorr)).addBands(img_plus_ic.select("IC"))
bandList_IC = ee.List([bandList, "IC"]).flatten()
img_SCSccorr = img_SCSccorr.unmask(img_plus_ic.select(bandList_IC)).select(bandList)
return img_SCSccorr.addBands(nonCorrectBands).setMulti(props).set("system:time_start", st)
#########################################################################
#########################################################################
# A function to mask out pixels that did not have observations for MODIS.
[docs]
def maskEmptyPixels(image: ee.Image) -> ee.Image:
"""Masks pixels that have zero observations in a MODIS-style image.
Checks the 'num_observations_1km' band and masks out any pixel where
the observation count is zero.
Args:
image (ee.Image): Input image with a 'num_observations_1km' band
(e.g., a MODIS daily product).
Returns:
ee.Image: The input image with zero-observation pixels masked out.
Examples:
>>> import ee
>>> ee.Initialize()
>>> modis = ee.Image('MODIS/006/MOD09GA/2020_06_01')
>>> masked = maskEmptyPixels(modis)
"""
# Find pixels that had observations.
withObs = image.select("num_observations_1km").gt(0)
return image.mask(image.mask().And(withObs))
#########################################################################
#########################################################################
# A function that returns an image containing just the specified QA bits.
#
# Args:
# image - The QA Image to get bits from.
# start - The first bit position, 0-based.
# end - The last bit position, inclusive.
# name - A name for the output image.
[docs]
def getQABits(image: ee.Image, start: int, end: int, name: str) -> ee.Image:
"""Extracts a range of bits from a QA band image.
Creates a bitmask for bits ``start`` through ``end``, applies it with
``bitwiseAnd``, and right-shifts to get the extracted value.
Args:
image (ee.Image): Single-band QA image (e.g., a MODIS state_1km band).
start (int): Starting bit position (0-based, inclusive).
end (int): Ending bit position (inclusive).
name (str): Name for the output band.
Returns:
ee.Image: Single-band image with the extracted QA bit values, renamed
to ``name``.
Examples:
>>> import ee
>>> ee.Initialize()
>>> qa = ee.Image('MODIS/006/MOD09GA/2020_06_01').select('state_1km')
>>> cloud_bits = getQABits(qa, 0, 1, 'cloud_state')
"""
# Compute the bits we need to extract.
pattern = 0
for i in range(start, end + 1):
pattern += math.pow(2, i)
# Return a single band image of the extracted QA bits, giving the band a new name.
return image.select([0], [newName]).bitwiseAnd(pattern).rightShift(start)
#########################################################################
#########################################################################
# A function to mask out cloudy pixels.
[docs]
def maskCloudsWQA(image):
"""Masks cloudy pixels using the MODIS state_1km QA band.
Extracts bit 10 (internal cloud algorithm flag) from the 'state_1km'
band and masks pixels flagged as cloud.
Args:
image (ee.Image): A MODIS image with a 'state_1km' QA band
(e.g., from MOD09GA).
Returns:
ee.Image: The input image with cloudy pixels masked out.
Examples:
>>> import ee
>>> ee.Initialize()
>>> modis = ee.Image('MODIS/006/MOD09GA/2020_06_01')
>>> clear = maskCloudsWQA(modis)
"""
# Select the QA band.
QA = image.select("state_1km")
# Get the internal_cloud_algorithm_flag bit.
internalCloud = getQABits(QA, 10, 10, "internal_cloud_algorithm_flag")
# Return an image masking out cloudy areas.
return image.mask(image.mask().And(internalCloud.eq(0)))
#########################################################################
#########################################################################
# Source: code.earthengine.google.com
# Compute a cloud score. This expects the input image to have the common
# band names: ["red", "blue", etc], so it can work across sensors.
[docs]
def modisCloudScore(img):
"""Computes a cloud score (0-100) for a MODIS image.
Combines brightness in visible and infrared bands with a snow index (NDSI)
and an optional thermal mask to produce a per-pixel cloud likelihood score.
Expects common band names ('red', 'green', 'blue', 'nir', 'swir1', 'swir2',
'temp').
Args:
img (ee.Image): Input MODIS image with standard band names.
Returns:
ee.Image: Single-band image named 'cloudScore' with values 0-100,
where higher values indicate greater cloud likelihood.
Examples:
>>> import ee
>>> ee.Initialize()
>>> modis = ee.Image('MODIS/006/MOD09GA/2020_06_01')
>>> cloud = modisCloudScore(modis)
>>> print(cloud.bandNames().getInfo())
"""
useTempInCloudMask = True
# Compute several indicators of cloudyness and take the minimum of them.
score = ee.Image(1.0)
# Clouds are reasonably bright in the blue band.
# score = score.min(rescale(img, 'img.blue', [0.1, 0.3]))
# Clouds are reasonably bright in all visible bands.
vizSum = rescale(img.select(["red", "green", "blue"]).reduce(ee.Reducer.sum()), [0.2, 0.8])
score = score.min(vizSum)
# Clouds are reasonably bright in all infrared bands.
# irSum =rescale(img, 'img.nir + img.swir2', [0.3, 0.7])
irSum = rescale(img.select(["nir", "swir1", "swir2"]).reduce(ee.Reducer.sum()), [0.3, 0.8])
score = score.min(irSum)
# However, clouds are not snow.
ndsi = img.normalizedDifference(["green", "swir2"])
snowScore = rescale(ndsi, [0.8, 0.6])
score = score.min(snowScore)
# For MODIS, provide the option of not using thermal since it introduces
# a precomputed mask that may or may not be wanted
if useTempInCloudMask:
# Clouds are reasonably cool in temperature.
# tempScore = rescale(img, 'img.temp', [305, 300])
# Map.addLayer(tempScore,{},'tempscore')
# score = score.min(tempScore)
score = score.where(img.select(["temp"]).mask().Not(), 1)
score = score.multiply(100)
score = score.clamp(0, 100).byte()
return score.rename(["cloudScore"])
#########################################################################
#########################################################################
# Cloud masking algorithm for Sentinel2
# Built on ideas from Landsat cloudScore algorithm
# Currently in beta and may need tweaking for individual study areas
[docs]
def sentinel2CloudScore(img):
"""Computes a cloud score (0-100) for a Sentinel-2 image.
Adapts the Landsat cloudScore algorithm for Sentinel-2 by combining
brightness in blue/cirrus, visible, and infrared bands with an NDSI
snow filter. Currently in beta and may need tuning per study area.
Args:
img (ee.Image): Input Sentinel-2 image with bands named 'blue', 'cb',
'green', 'red', 'nir', 'swir1', and 'swir2'.
Returns:
ee.Image: Single-band image named 'cloudScore' with values 0-100,
where higher values indicate greater cloud likelihood.
Examples:
>>> import ee
>>> ee.Initialize()
>>> s2 = ee.Image('COPERNICUS/S2_SR/20200601T184919_20200601T185630_T10SEG')
>>> cloud = sentinel2CloudScore(s2)
>>> print(cloud.bandNames().getInfo())
"""
# Compute several indicators of cloudyness and take the minimum of them.
score = ee.Image(1)
blueCirrusScore = ee.Image(0)
# Clouds are reasonably bright in the blue or cirrus bands.
# Use .max as a pseudo OR conditional
blueCirrusScore = blueCirrusScore.max(rescale(img.select(["blue"]), [0.1, 0.5]))
blueCirrusScore = blueCirrusScore.max(rescale(img.select(["cb"]), [0.1, 0.5]))
# blueCirrusScore = blueCirrusScore.max(rescale(img, 'img.cirrus', [0.1, 0.3]))
# reSum = rescale(img,'(img.re1+img.re2+img.re3)/3',[0.5, 0.7])
score = score.min(blueCirrusScore)
# Clouds are reasonably bright in all visible bands.
score = score.min(rescale(img.select(["red", "green", "blue"]).reduce(ee.Reducer.sum()), [0.2, 0.8]))
# Clouds are reasonably bright in all infrared bands.
score = score.min(rescale(img.select(["nir", "swir1", "swir2"]).reduce(ee.Reducer.sum()), [0.3, 0.8]))
# However, clouds are not snow.
ndsi = img.normalizedDifference(["green", "swir1"])
score = score.min(rescale(ndsi, [0.8, 0.6]))
score = score.multiply(100).byte().clamp(0, 100).rename(["cloudScore"])
return score
#########################################################################
# Adapted from: https://earth.esa.int/documents/10174/3166008/ESA_Training_Vilnius_07072017_SAR_Optical_Snow_Ice_Exercises.pdf
[docs]
def sentinel2SnowMask(img, dilatePixels=3.5):
"""Masks snow-covered pixels in a Sentinel-2 image using NDSI thresholds.
Identifies snow in open land (NDSI > 0.4 and NIR > 0.11) and snow in
forest (0.1 < NDSI < 0.4), then masks those pixels. Adapted from ESA
training materials (Salomonson and Appel, 2004/2006).
Args:
img (ee.Image): Input Sentinel-2 image with 'green', 'swir1', and
'nir' bands.
dilatePixels (float, optional): Number of pixels to erode the snow
mask with ``focal_min`` to remove edge effects. Defaults to ``3.5``.
Returns:
ee.Image: The input image with snow pixels masked out.
Examples:
>>> import ee
>>> ee.Initialize()
>>> s2 = ee.Image('COPERNICUS/S2_SR/20200101T184919_20200101T185630_T10SEG')
>>> snow_free = sentinel2SnowMask(s2, dilatePixels=3.5)
"""
ndsi = img.normalizedDifference(["green", "swir1"])
# IF NDSI > 0.40 AND ρ(NIR) > 0.11 THEN snow in open land
# IF 0.1 < NDSI < 0.4 THEN snow in forest
snowOpenLand = ndsi.gt(0.4).And(img.select(["nir"]).gt(0.11))
snowForest = ndsi.gt(0.1).And(ndsi.lt(0.4))
# Map.addLayer(snowOpenLand.selfMask(),{'min':1,'max':1,'palette':'88F'},'Snow Open Land')
# Map.addLayer(snowForest.selfMask(),{'min':1,'max':1,'palette':'00F'},'Snow Forest')
# Fractional snow cover (FSC, 0 % - 100% snow) can be detected by the approach of Salomonson
# and Appel (2004, 2006), which was originally developed for MODIS data:
# FSC = –0.01 + 1.45 * NDSI
fsc = ndsi.multiply(1.45).subtract(0.01)
# Map.addLayer(fsc,{'min':0,'max':1,'palette':'080,008'},'Fractional Snow Cover')
snowMask = ((snowOpenLand.Or(snowForest)).Not()).focal_min(dilatePixels)
return img.updateMask(snowMask)
#########################################################################
#########################################################################
# MODIS processing
#########################################################################
#########################################################################
# Some globals to deal with multi-spectral MODIS
# wTempSelectOrder = [2,3,0,1,4,6,5]#Band order to select to be Landsat 5-like if thermal is included
# wTempStdNames = ['blue', 'green', 'red', 'nir', 'swir1','temp','swir2']
# woTempSelectOrder = [2,3,0,1,4,5]#Band order to select to be Landsat 5-like if thermal is excluded
# woTempStdNames = ['blue', 'green', 'red', 'nir', 'swir1','swir2']
modis250SelectBands = ["sur_refl_b01", "sur_refl_b02"]
modis250BandNames = ["red", "nir"]
modis500SelectBands = ["sur_refl_b03", "sur_refl_b04", "sur_refl_b06", "sur_refl_b07"]
modis500BandNames = ["blue", "green", "swir1", "swir2"]
combinedModisBandNames = ["red", "nir", "blue", "green", "swir1", "swir2"]
dailyViewAngleBandNames = [
"SensorZenith",
"SensorAzimuth",
"SolarZenith",
"SolarAzimuth",
]
compositeViewAngleBandNames = ["SolarZenith", "ViewZenith", "RelativeAzimuth"]
# Dictionary of MODIS collections
modisCDict = {
"eightDayNDVIA": "MODIS/061/MYD13Q1",
"eightDayNDVIT": "MODIS/061/MOD13Q1",
"eightDaySR250A": "MODIS/061/MYD09Q1",
"eightDaySR250T": "MODIS/061/MOD09Q1",
"eightDaySR500A": "MODIS/061/MYD09A1",
"eightDaySR500T": "MODIS/061/MOD09A1",
"eightDayLST1000A": "MODIS/061/MYD11A2",
"eightDayLST1000T": "MODIS/061/MOD11A2",
"dailySR250A": "MODIS/061/MYD09GQ",
"dailySR250T": "MODIS/061/MOD09GQ",
"dailySR500A": "MODIS/061/MYD09GA",
"dailySR500T": "MODIS/061/MOD09GA",
"dailyLST1000A": "MODIS/061/MYD11A1",
"dailyLST1000T": "MODIS/061/MOD11A1",
}
multModisDict = {
"tempNoAngleDaily": [
ee.Image([0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.02, 1, 1]),
["blue", "green", "red", "nir", "swir1", "temp", "swir2", "Emis_31", "Emis_32"],
],
"tempNoAngleComposite": [
ee.Image([0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.02, 1, 1]),
["blue", "green", "red", "nir", "swir1", "temp", "swir2", "Emis_31", "Emis_32"],
],
"tempAngleDaily": [
ee.Image([0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 1, 1, 1, 1, 0.02, 1, 1]),
[
"blue",
"green",
"red",
"nir",
"swir1",
"temp",
"swir2",
"SensorZenith",
"SensorAzimuth",
"SolarZenith",
"SolarAzimuth",
"Emis_31",
"Emis_32",
],
],
"tempAngleComposite": [
ee.Image([0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 1, 1, 1, 0.02, 1, 1]),
[
"blue",
"green",
"red",
"nir",
"swir1",
"temp",
"swir2",
"SolarZenith",
"ViewZenith",
"RelativeAzimuth",
"Emis_31",
"Emis_32",
],
],
"noTempNoAngleDaily": [
ee.Image([0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001]),
["blue", "green", "red", "nir", "swir1", "swir2"],
],
"noTempNoAngleComposite": [
ee.Image([0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001]),
["blue", "green", "red", "nir", "swir1", "swir2"],
],
"noTempAngleDaily": [
ee.Image([0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 1, 1, 1, 1]),
[
"blue",
"green",
"red",
"nir",
"swir1",
"swir2",
"SensorZenith",
"SensorAzimuth",
"SolarZenith",
"SolarAzimuth",
],
],
"noTempAngleComposite": [
ee.Image([0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 1, 1, 1]),
[
"blue",
"green",
"red",
"nir",
"swir1",
"swir2",
"SolarZenith",
"ViewZenith",
"RelativeAzimuth",
],
],
}
#########################################################################
#########################################################################
# Helper function to join two collections- Source: code.earthengine.google.com
[docs]
def joinCollections(
c1,
c2,
maskAnyNullValues=True,
joinProperty="system:time_start",
joinPropertySecondary=None,
):
"""Joins two image collections by a shared property using an inner join.
Merges bands from matching images in two collections. Images are matched
based on an exact match of the specified join property. Optionally masks
pixels where any band has a null (zero) value after joining.
Args:
c1 (ee.ImageCollection): The primary image collection.
c2 (ee.ImageCollection): The secondary image collection to join.
maskAnyNullValues (bool, optional): If True, masks pixels where any
band value is zero after joining. Defaults to True.
joinProperty (str, optional): The property name to match on in the
primary collection. Defaults to "system:time_start".
joinPropertySecondary (str | None, optional): The property name to
match on in the secondary collection. If None, uses the same
value as joinProperty. Defaults to None.
Returns:
ee.ImageCollection: A new collection with bands from both input
collections merged together for each matching image pair.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> c1 = ee.ImageCollection("MODIS/061/MOD09GQ").filterDate("2024-01-01", "2024-01-10")
>>> c2 = ee.ImageCollection("MODIS/061/MOD09GA").filterDate("2024-01-01", "2024-01-10")
>>> joined = gil.joinCollections(c1, c2)
>>> print(joined.first().bandNames().getInfo())
"""
if joinPropertySecondary == None:
joinPropertySecondary = joinProperty
def MergeBands(element):
# A function to merge the bands together.
# After a join, results are in 'primary' and 'secondary' properties.
return ee.Image.cat(element.get("primary"), element.get("secondary"))
join = ee.Join.inner()
joinFilter = ee.Filter.equals(joinProperty, None, joinPropertySecondary)
joined = ee.ImageCollection(join.apply(c1, c2, joinFilter))
joined = ee.ImageCollection(joined.map(MergeBands))
if maskAnyNullValues:
def nuller(img):
return img.mask(img.mask().And(img.reduce(ee.Reducer.min()).neq(0)))
joined = joined.map(nuller)
return joined
[docs]
def smartJoin(primary, secondary, hourDiff):
"""Joins two image collections by closest timestamp within a time window.
For each image in the primary collection, finds the best-matching image
in the secondary collection within the specified time difference and
merges their bands together.
Args:
primary (ee.ImageCollection): The primary image collection.
secondary (ee.ImageCollection): The secondary image collection to
match against.
hourDiff (int | float): Maximum time difference in hours to consider
a match between images.
Returns:
ee.ImageCollection: The primary collection with bands from the
best-matching secondary image appended to each image.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> terra = ee.ImageCollection("MODIS/061/MOD09GQ").filterDate("2024-06-01", "2024-06-30")
>>> aqua = ee.ImageCollection("MODIS/061/MYD09GQ").filterDate("2024-06-01", "2024-06-30")
>>> joined = gil.smartJoin(terra, aqua, 24)
"""
millis = hourDiff * 60 * 60 * 1000
# Create a time filter to define a match as overlapping timestamps.
maxDiffFilter = ee.Filter.maxDifference(
{
"difference": millis,
"leftField": "system:time_start",
"rightField": "system:time_start",
}
)
# Define the join.
saveBestJoin = ee.Join.saveBest({"matchKey": "bestImage", "measureKey": "timeDiff"})
def MergeBands(element):
# A function to merge the bands together.
# After a join, results are in 'primary' and 'secondary' properties.
return ee.Image.cat(element, element.get("bestImage"))
# Apply the join.
joined = saveBestJoin.apply(primary, secondary, maxDiffFilter)
joined = joined.map(MergeBands)
return joined
#########################################################################
# Join collections by space (intersection) and time (specified by user)
[docs]
def spatioTemporalJoin(primary, secondary, hourDiff=24, outKey="secondary"):
"""Joins two image collections by spatial intersection and closest timestamp.
For each image in the primary collection, finds the best-matching image
in the secondary collection that both intersects spatially and falls
within the specified time window. Bands from the secondary image are
renamed with a suffix and appended.
Args:
primary (ee.ImageCollection): The primary image collection.
secondary (ee.ImageCollection): The secondary image collection to
match against.
hourDiff (int | float, optional): Maximum time difference in hours
to consider a match. Defaults to 24.
outKey (str, optional): Suffix appended to secondary band names in
the format "bandName_outKey". Defaults to "secondary".
Returns:
ee.ImageCollection: The primary collection with renamed bands from
the best-matching secondary image appended to each image.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> modis_terra = ee.ImageCollection("MODIS/061/MOD09GQ").filterDate("2024-01-01", "2024-01-31")
>>> modis_aqua = ee.ImageCollection("MODIS/061/MYD09GQ").filterDate("2024-01-01", "2024-01-31")
>>> joined = gil.spatioTemporalJoin(modis_terra, modis_aqua, hourDiff=12, outKey="aqua")
"""
time = hourDiff * 60 * 60 * 1000
outBns = ee.Image(secondary.first()).bandNames().map(lambda bn: ee.String(bn).cat("_").cat(outKey))
# Define a spatial filter as geometries that intersect.
spatioTemporalFilter = ee.Filter.And(
ee.Filter.maxDifference(
{
"difference": time,
"leftField": "system:time_start",
"rightField": "system:time_start",
}
),
ee.Filter.intersects({"leftField": ".geo", "rightField": ".geo", "maxError": 10}),
)
# Define a save all join.
saveBestJoin = ee.Join.saveBest({"matchKey": outKey, "measureKey": "timeDiff"})
# Apply the join.
joined = saveBestJoin.apply(primary, secondary, spatioTemporalFilter)
def MergeBands(element):
# A function to merge the bands together.
# After a join, results are in 'primary' and 'secondary' properties.
return ee.Image.cat(element, ee.Image(element.get(outKey)).rename(outBns))
joined = joined.map(MergeBands)
return joined
# Simple inner join function for featureCollections
# Matches features based on an exact match of the fieldName parameter
# An optional different field name can be provided for the second featureCollection
# Retains the geometry of the primary, but copies the properties of the secondary collection
[docs]
def joinFeatureCollections(primary, secondary, fieldName, fieldNameSecondary=None):
"""Joins two feature collections by matching property values using an inner join.
Matches features from two collections based on an exact match of the
specified field. Retains the geometry of the primary feature and copies
all properties from the matching secondary feature.
Args:
primary (ee.FeatureCollection): The primary feature collection whose
geometry is retained.
secondary (ee.FeatureCollection): The secondary feature collection
whose properties are copied to matching features.
fieldName (str): The property name to match on in the primary
collection.
fieldNameSecondary (str | None, optional): The property name to match
on in the secondary collection. If None, uses the same value as
fieldName. Defaults to None.
Returns:
ee.FeatureCollection: A collection of features with the geometry from
the primary and properties from both collections.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> states = ee.FeatureCollection("TIGER/2018/States")
>>> lookup = ee.FeatureCollection([ee.Feature(None, {"STATEFP": "08", "region": "Mountain"})])
>>> joined = gil.joinFeatureCollections(states, lookup, "STATEFP")
"""
if fieldNameSecondary == None:
fieldNameSecondary = fieldName
# Use an equals filter to specify how the collections match.
f = ee.Filter.equals(fieldName, None, fieldNameSecondary)
# Define the join.
innerJoin = ee.Join.inner("primary", "secondary")
# Apply the join.
joined = innerJoin.apply(primary, secondary, f)
joined = joined.map(lambda f: ee.Feature(f.get("primary")).copyProperties(ee.Feature(f.get("secondary"))))
return joined
#########################################################################
#########################################################################
# Method for removing spikes in time series
[docs]
def despikeCollection(c, absoluteSpike, bandNo):
"""Removes spike artifacts from a time series image collection.
Uses a moving window of three images (left, center, right) to detect
and replace spikes. A pixel is considered a spike if it deviates from
both its neighbors by more than the absolute threshold. Spikes are
replaced with the mean of the left and right neighbors.
Args:
c (ee.ImageCollection): The input image collection to despike.
Should be temporally ordered.
absoluteSpike (float): The absolute threshold for spike detection.
A pixel is flagged as a spike if its difference from both
neighbors exceeds this value.
bandNo (int): The band index used to detect spikes.
Returns:
ee.ImageCollection: The despiked image collection with spike values
replaced by the mean of neighboring images.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> ndvi = ee.ImageCollection("MODIS/061/MOD13A1").filterDate("2024-01-01", "2024-12-31").select("NDVI")
>>> despiked = gil.despikeCollection(ndvi, 1000, 0)
>>> print(despiked.size().getInfo())
"""
c = c.toList(10000, 0)
# Get book ends for adding back at the end
first = c.slice(0, 1)
last = c.slice(-1, None)
# Slice the left, center, and right for the moving window
left = c.slice(0, -2)
center = c.slice(1, -1)
right = c.slice(2, None)
# Find how many images there are to compare
seq = ee.List.sequence(0, left.length().subtract(1))
# Compare the center to the left and right images
def compare(i):
lt = ee.Image(left.get(i))
rt = ee.Image(right.get(i))
ct = ee.Image(center.get(i))
time_start = ct.get("system:time_start")
time_end = ct.get("system:time_end")
si = ct.get("system:index")
diff1 = ct.select([bandNo]).add(1).subtract(lt.select([bandNo]).add(1))
diff2 = ct.select([bandNo]).add(1).subtract(rt.select([bandNo]).add(1))
highSpike = diff1.gt(absoluteSpike).And(diff2.gt(absoluteSpike))
lowSpike = diff1.lt(-absoluteSpike).And(diff2.lt(-absoluteSpike))
BinarySpike = highSpike.Or(lowSpike)
originalMask = ct.mask()
ct = ct.mask(BinarySpike.eq(0))
doNotMask = lt.mask().Not().Or(rt.mask().Not())
lrMean = lt.add(rt)
lrMean = lrMean.divide(2)
# out = ct.mask(doNotMask.Not().And(ct.mask()))
out = ct.where(BinarySpike.eq(1).And(doNotMask.Not()), lrMean)
return out.set("system:index", si).set("system:time_start", time_start).set("system:time_end", time_end)
outCollection = seq.map(compare)
# Add the bookends back on
outCollection = ee.List([first, outCollection, last]).flatten()
return ee.ImageCollection.fromImages(outCollection)
#########################################################################
#########################################################################
# Function to get MODIS data from various collections
# Will pull from daily or 8-day composite collections based on the boolean variable "daily"
[docs]
def getModisData(
startYear: int,
endYear: int,
startJulian: int,
endJulian: int,
daily: bool = False,
maskWQA: bool = False,
zenithThresh: int = 90,
useTempInCloudMask: bool = True,
addLookAngleBands: bool = False,
resampleMethod: str = "near",
):
"""
Retrieves MODIS imagery from Earth Engine for a specified period. Handles joining all MODIS collections for Terra and Aqua and aligning band names
Args:
startYear (int): The starting year for the data collection.
endYear (int): The ending year for the data collection.
startJulian (int): The starting Julian day of year for the data collection (1-366).
endJulian (int): The ending Julian day of year for the data collection (1-366).
daily (bool, optional): Determines whether to retrieve daily or 8-day composite data. Defaults to False (8-day composite).
maskWQA (bool, optional): Controls whether to mask pixels based on the Quality Assurance (QA) band. Only applicable for daily data (daily=True). Defaults to False.
zenithThresh (float, optional): Sets the threshold for solar zenith angle in degrees. Pixels with zenith angle exceeding this threshold will be masked out. Defaults to 90.
useTempInCloudMask (bool, optional): Determines whether to use the thermal band for cloud masking. Defaults to True.
addLookAngleBands (bool, optional): Controls whether to include view angle bands in the output. Defaults to False.
resampleMethod (str, optional): Specifies the resampling method to apply to the imagery. Valid options include "near", "bilinear", and "bicubic". Defaults to "near" (nearest neighbor).
Returns:
ee.ImageCollection: A collection of MODIS imagery for the specified criteria.
>>> import geeViz.getImagesLib as gil
>>> Map = gil.Map
>>> ee = gil.ee
>>> crs = gil.common_projections["NLCD_CONUS"]["crs"]
>>> transform = gil.common_projections["NLCD_CONUS"]["transform"]
>>> scale = 240
>>> transform[0] = scale
>>> transform[4] = -scale
>>> composite = gil.getModisData(2024, 2024, 190, 250, resampleMethod="bicubic").median().reproject(crs, transform)
>>> Map.addLayer(composite, gil.vizParamsFalse, "MODIS Composite")
>>> Map.setCenter(-111, 41, 7)
>>> Map.turnOnInspector()
>>> Map.view()
"""
# Find which collections to pull from based on daily or 8-day
if daily == False:
a250C = modisCDict["eightDaySR250A"]
t250C = modisCDict["eightDaySR250T"]
a500C = modisCDict["eightDaySR500A"]
t500C = modisCDict["eightDaySR500T"]
a1000C = modisCDict["eightDayLST1000A"]
t1000C = modisCDict["eightDayLST1000T"]
viewAngleBandNames = compositeViewAngleBandNames
else:
a250C = modisCDict["dailySR250A"]
t250C = modisCDict["dailySR250T"]
a500C = modisCDict["dailySR500A"]
t500C = modisCDict["dailySR500T"]
a1000C = modisCDict["dailyLST1000A"]
t1000C = modisCDict["dailyLST1000T"]
viewAngleBandNames = dailyViewAngleBandNames
# Pull images from each of the collections
a250 = ee.ImageCollection(a250C).filter(ee.Filter.calendarRange(startYear, endYear, "year")).filter(ee.Filter.calendarRange(startJulian, endJulian)).select(modis250SelectBands, modis250BandNames)
t250 = ee.ImageCollection(t250C).filter(ee.Filter.calendarRange(startYear, endYear, "year")).filter(ee.Filter.calendarRange(startJulian, endJulian)).select(modis250SelectBands, modis250BandNames)
if addLookAngleBands:
modis500SelectBandsT = modis500SelectBands + viewAngleBandNames
modis500BandNamesT = modis500BandNames + viewAngleBandNames
else:
modis500SelectBandsT = modis500SelectBands
modis500BandNamesT = modis500BandNames
def get500(c):
images = ee.ImageCollection(c).filter(ee.Filter.calendarRange(startYear, endYear, "year")).filter(ee.Filter.calendarRange(startJulian, endJulian))
def applyZenith(img):
img = img.mask(img.mask().And(img.select(["SensorZenith"]).lt(zenithThresh * 100)))
if maskWQA:
img = maskCloudsWQA(img)
return img
# Mask pixels above a certain zenith
if daily:
if maskWQA:
print("Masking with QA band:", c)
images = images.map(applyZenith)
# images = images.select(modis500SelectBands, modis500BandNames)
# else:
images = images.select(modis500SelectBandsT, modis500BandNamesT)
return images
a500 = get500(a500C)
t500 = get500(t500C)
# If thermal collection is wanted, pull it as well
tempbandNames = ["temp", "Emis_31", "Emis_32"]
if useTempInCloudMask:
t1000 = ee.ImageCollection(t1000C).filter(ee.Filter.calendarRange(startYear, endYear, "year")).filter(ee.Filter.calendarRange(startJulian, endJulian)).select([0, 8, 9], tempbandNames)
a1000 = ee.ImageCollection(a1000C).filter(ee.Filter.calendarRange(startYear, endYear, "year")).filter(ee.Filter.calendarRange(startJulian, endJulian)).select([0, 8, 9], tempbandNames)
# Now all collections are pulled, start joining them
# First join the 250 and 500 m Aqua
# a = joinCollections(a250, a500, False)
a = a250.linkCollection(a500, modis500BandNamesT)
# Then Terra
# t = joinCollections(t250, t500, False)
t = t250.linkCollection(t500, modis500BandNamesT)
# If temp was pulled, join that in as well
# Also select the bands in an L5-like order and give descriptive names
if useTempInCloudMask:
# a = joinCollections(a, a1000, False)
# t = joinCollections(t, t1000, False)
a = a.linkCollection(a1000, tempbandNames)
t = t.linkCollection(t1000, tempbandNames)
# tSelectOrder = wTempSelectOrder
# tStdNames = wTempStdNames
# #If no thermal was pulled, leave that out
# else:
# tSelectOrder = woTempSelectOrder
# tStdNames = woTempStdNames
a = a.map(lambda img: img.set({"platform": "aqua"}))
t = t.map(lambda img: img.set({"platform": "terra"}))
if daily:
dailyPiece = "Daily"
else:
dailyPiece = "Composite"
if useTempInCloudMask:
tempPiece = "temp"
else:
tempPiece = "noTemp"
if addLookAngleBands:
anglePiece = "Angle"
else:
anglePiece = "NoAngle"
multKey = tempPiece + anglePiece + dailyPiece
mult = multModisDict[multKey]
multImage = mult[0]
multNames = mult[1]
# Join Terra and Aqua
joined = ee.ImageCollection(a.merge(t)) # .select(tSelectOrder,tStdNames)
def multiplyImg(img):
return img.multiply(multImage).float().select(multNames).copyProperties(img, ["system:time_start", "system:time_end", "system:index"]).copyProperties(img)
def setResample(img):
return img.resample(resampleMethod)
joined = joined.map(multiplyImg)
if resampleMethod in ["bilinear", "bicubic"]:
print("Setting resample method to ", resampleMethod)
joined = joined.map(setResample)
return joined
#########################################################################
#########################################################################
# Function to get cloud, cloud shadow busted modis images
# Takes care of matching different modis collections as well
[docs]
def getProcessedModis(
startYear: int,
endYear: int,
startJulian: int,
endJulian: int,
zenithThresh: float = 90,
addLookAngleBands: bool = True,
applyCloudScore: bool = True,
applyTDOM: bool = True,
useTempInCloudMask: bool = True,
cloudScoreThresh: int = 20,
performCloudScoreOffset: bool = True,
cloudScorePctl: int = 10,
zScoreThresh: float = -1,
shadowSumThresh: float = 0.35,
contractPixels: int = 0,
dilatePixels: float = 2.5,
shadowSumBands: list[str] = ["nir", "swir2"],
resampleMethod: str = "bicubic",
preComputedCloudScoreOffset: ee.Image | None = None,
preComputedTDOMIRMean: ee.Image | None = None,
preComputedTDOMIRStdDev: ee.Image | None = None,
addToMap: bool = False,
crs: str = "EPSG:4326",
scale: int | None = 250,
transform: list[int] | None = None,
):
"""
Retrieves, processes, and filters MODIS imagery for a specified period.
This function retrieves daily MODIS imagery from Earth Engine, applies various cloud and
cloud shadow masking techniques, and returns a collection of processed images.
Args:
startYear (int): The starting year for the data collection.
endYear (int): The ending year for the data collection.
startJulian (int): The starting Julian day of year for the data collection (1-366).
endJulian (int): The ending Julian day of year for the data collection (1-366).
zenithThresh (float, optional): Sets the threshold for solar zenith angle in degrees. Pixels with zenith angle exceeding this threshold will be masked out. Defaults to 90.
addLookAngleBands (bool, optional): Controls whether to include view angle bands in the output. Defaults to True.
applyCloudScore (bool, optional): Determines whether to apply cloud masking based on the CloudScore simple algorithm adapted to MODIS. Defaults to True.
applyTDOM (bool, optional): Determines whether to apply the TDOM (Temporal Dark Outlier Mask)
technique for cloud shadow masking. Defaults to True.
useTempInCloudMask (bool, optional): Determines whether to use the thermal band for cloud masking during MODIS data retrieval. Defaults to True.
cloudScoreThresh (int, optional): Threshold for the CloudScore simple algorithm to classify a pixel as cloudy. Lower number masks out more. Defaults to 20.
performCloudScoreOffset (bool, optional): Controls whether to perform an offset correction on the Cloud Score data over bright surfaces. Only use this if bright areas are being masked as clouds. Do not use this in persistently cloud areas. Defaults to True.
cloudScorePctl (int, optional): Percentile of the Cloud Score product to use for the offset correction. Defaults to 10.
zScoreThresh (float, optional): Threshold for the z-score used in TDOM cloud shadow masking. Pixels with z-scores below this threshold are masked. Defaults to -1.
shadowSumThresh (float, optional): Threshold for the sum of reflectance in shadow bands used in TDOM cloud shadow masking. Pixels below this threshold and the zScoreThresh are masked as dark outliers (likely cloud shadows). Defaults to 0.35.
contractPixels (int, optional): Number of pixels to contract cloud and shadow masks by. Defaults to 0.
dilatePixels (float, optional): Number of pixels to dilate cloud and shadow masks by. Defaults to 2.5.
shadowSumBands (list[str], optional): List of band names to use for calculating the sum of reflectance in TDOM cloud shadow masking. Defaults to ["nir", "swir2"].
resampleMethod (str, optional): Specifies the resampling method to apply to the imagery. Valid options include "near", "bilinear", and "bicubic". Defaults to "bicubic".
preComputedCloudScoreOffset (float | None, optional): Pre-computed Cloud Score offset value to avoid redundant calculations. Defaults to None (automatic calculation).
preComputedTDOMIRMean (float | None, optional): Pre-computed mean of the IR band used in TDOM cloud shadow masking to avoid redundant calculations. Defaults to None (automatic calculation).
preComputedTDOMIRStdDev (float | None, optional): Pre-computed standard deviation of the IR band used in TDOM cloud shadow masking to avoid redundant calculations. Defaults to None (automatic calculation).
addToMap (bool, optional): Controls whether to add intermediate processing steps (masked medians) to the Earth Engine map for visualization purposes. Defaults to False.
crs (str, optional): Only used if addToMap is True. Coordinate Reference System (CRS) for the output imagery. Defaults to "EPSG:4326".
scale (int | None, optional): Only used if addToMap is True. Scale (resolution) of the output imagery in meters. Defaults to 250.
transform (list | None, optional): Only used if addToMap is True. Optional transformation matrix to apply to the output imagery. Defaults to None.
Returns:
ee.ImageCollection: Cloud and shadow masked MODIS imagery with
spectral indices added. The collection has processing parameters
stored as properties.
Examples:
>>> import geeViz.getImagesLib as gil
>>> Map = gil.Map
>>> ee = gil.ee
>>> crs = gil.common_projections["NLCD_CONUS"]["crs"]
>>> transform = gil.common_projections["NLCD_CONUS"]["transform"]
>>> scale = 240
>>> transform[0] = scale
>>> transform[4] = -scale
>>> composite = gil.getProcessedModis(2024, 2024, 190, 250).median().reproject(crs, transform)
>>> Map.addLayer(composite, gil.vizParamsFalse, "MODIS Composite")
>>> Map.setCenter(-111, 41, 7)
>>> Map.turnOnInspector()
>>> Map.view()
"""
args = formatArgs(locals())
if "args" in args.keys():
del args["args"]
# Get joined modis collection
modisImages = getModisData(
startYear,
endYear,
startJulian,
endJulian,
daily=True,
maskWQA=False,
zenithThresh=zenithThresh,
useTempInCloudMask=useTempInCloudMask,
addLookAngleBands=addLookAngleBands,
resampleMethod=resampleMethod,
)
if addToMap:
Map.addLayer(
modisImages.median().reproject(crs, transform, scale),
vizParamsFalse,
"Raw Median",
)
if applyCloudScore:
print("Applying cloudScore")
modisImages = applyCloudScoreAlgorithm(
modisImages,
modisCloudScore,
cloudScoreThresh,
cloudScorePctl,
contractPixels,
dilatePixels,
performCloudScoreOffset,
preComputedCloudScoreOffset,
)
if addToMap:
Map.addLayer(
modisImages.median().reproject(crs, transform, scale),
vizParamsFalse,
"Cloud Masked Median",
False,
)
Map.addLayer(
modisImages.min().reproject(crs, transform, scale),
vizParamsFalse,
"Cloud Masked Min",
False,
)
if applyTDOM:
print("Applying TDOM")
# Find and mask out dark outliers
modisImages = simpleTDOM2(
modisImages,
zScoreThresh,
shadowSumThresh,
contractPixels,
dilatePixels,
shadowSumBands,
preComputedTDOMIRMean,
preComputedTDOMIRStdDev,
)
if addToMap:
Map.addLayer(
modisImages.median().reproject(crs, transform, scale),
vizParamsFalse,
"Cloud/Cloud Shadow Masked Median",
False,
)
Map.addLayer(
modisImages.min().reproject(crs, transform, scale),
vizParamsFalse,
"Cloud/Cloud Shadow Masked Min",
False,
)
modisImages = modisImages.map(simpleAddIndices)
modisImages = modisImages.map(lambda img: img.float())
return modisImages.set(args)
#########################################################################
# Function to take images and create a median composite every n days
[docs]
def nDayComposites(images, startYear, endYear, startJulian, endJulian, compositePeriod):
"""Creates median composites at regular n-day intervals from an image collection.
Divides the specified date range into equal periods of n days and computes
the median composite for each period and year.
Args:
images (ee.ImageCollection): The input image collection to composite.
startYear (int): The first year to create composites for.
endYear (int): The last year to create composites for.
startJulian (int): The starting Julian day of year (1-366).
endJulian (int): The ending Julian day of year (1-366).
compositePeriod (int): The number of days per composite period.
Returns:
ee.ImageCollection: A collection of median composites, each with
"system:time_start" and "system:index" (formatted as
"yyyy-MM-dd") properties set.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> modis = ee.ImageCollection("MODIS/061/MOD09GQ").select(["sur_refl_b01", "sur_refl_b02"])
>>> composites = gil.nDayComposites(modis, 2024, 2024, 1, 365, 16)
>>> print(composites.size().getInfo())
"""
# create dummy image for with no values
dummyImage = ee.Image(images.first())
# convert to composites as defined above
def getYrImages(yr):
# take the year of the image
yr = ee.Number(yr).int16()
# filter out images for the year
yrImages = images.filter(ee.Filter.calendarRange(yr, yr, "year"))
# use dummy image to fill in gaps for GEE processing
yrImages = fillEmptyCollections(yrImages, dummyImage)
return yrImages
# Get images for a specified start day
def getJdImages(yr, yrImages, start):
yr = ee.Number(yr).int16()
start = ee.Number(start).int16()
date = ee.Date.fromYMD(yr, 1, 1).advance(start.subtract(1), "day")
index = date.format("yyyy-MM-dd")
end = start.add(compositePeriod - 1).int16()
jdImages = yrImages.filter(ee.Filter.calendarRange(start, end))
jdImages = fillEmptyCollections(jdImages, dummyImage)
composite = jdImages.median()
return composite.set({"system:index": index, "system:time_start": date.millis()})
# Set up wrappers
def jdWrapper(yr, yrImages):
return ee.FeatureCollection(ee.List.sequence(startJulian, endJulian, compositePeriod).map(lambda start: getJdImages(yr, yrImages, start)))
def yrWrapper(yr):
yrImages = getYrImages(yr)
return jdWrapper(yr, yrImages)
composites = ee.FeatureCollection(ee.List.sequence(startYear, endYear).map(lambda yr: yrWrapper(yr)))
# return the composites as an image collection
composites = ee.ImageCollection(composites.flatten())
return composites
###############################################################
#########################################################################
[docs]
def exportCollection(
exportPathRoot, outputName, studyArea, crs, transform, scale, collection, startYear, endYear, startJulian, endJulian, compositingReducer, timebuffer, exportBands, overwrite=False, exportToAssets=False, exportToCloud=False, bucket=None
):
"""Exports yearly composites from an image collection to EE assets or Cloud Storage.
Iterates through each year (adjusted by timebuffer), extracts the first
image for that year, clips it to the study area, and exports it.
Args:
exportPathRoot (str): Root path for exports.
outputName (str): Base name for exported files.
studyArea (ee.Geometry | ee.FeatureCollection): Region to clip to.
crs (str): Coordinate reference system (e.g., "EPSG:5070").
transform (list[float] | None): Affine transform for the output.
scale (int | None): Output resolution in meters.
collection (ee.ImageCollection): Image collection to export from.
startYear (int): First year of the collection.
endYear (int): Last year of the collection.
startJulian (int): Starting Julian day used in naming.
endJulian (int): Ending Julian day used in naming.
compositingReducer (str): Compositing method label for metadata.
timebuffer (int): Years to buffer. Exports from startYear + timebuffer
to endYear - timebuffer.
exportBands (list[str]): Band names to select for export.
overwrite (bool, optional): Overwrite existing assets. Defaults to False.
exportToAssets (bool, optional): Export to EE assets. Defaults to False.
exportToCloud (bool, optional): Export to Cloud Storage. Defaults to False.
bucket (str | None, optional): Cloud Storage bucket name. Defaults to None.
Returns:
None: Submits export tasks as a side effect.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = ee.Geometry.Rectangle([-110, 40, -109, 41])
>>> collection = ee.ImageCollection("LANDSAT/LC08/C02/T1_L2").filterDate("2023-06-01", "2023-09-01")
>>> gil.exportCollection("projects/my-project/assets/out", "L8", studyArea, "EPSG:5070", None, 30, collection, 2023, 2023, 152, 244, "median", 0, ["SR_B4", "SR_B5"], exportToAssets=True)
"""
# Take care of date wrapping
dateWrapping = wrapDates(startJulian, endJulian)
wrapOffset = dateWrapping[0]
yearWithMajority = dateWrapping[1]
# Clean up output name
outputName = outputName.replace("/\s+/g", "-")
outputName = outputName.replace("/\//g", "-")
# Select bands for export
collection = collection.select(exportBands)
# Iterate across each year and export image
for year in ee.List.sequence(startYear + timebuffer, endYear - timebuffer).getInfo():
print("Exporting:", year)
# Set up dates
startYearT = year - timebuffer
endYearT = year + timebuffer + yearWithMajority
# Get yearly composite
composite = collection.filter(ee.Filter.calendarRange(year + yearWithMajority, year + yearWithMajority, "year"))
composite = ee.Image(composite.first()).clip(studyArea)
# Add metadata, cast to integer, and export composite
composite = composite.set(
{
"system:time_start": ee.Date.fromYMD(year + yearWithMajority, 6, 1).millis(),
"yearBuffer": timebuffer,
}
)
# Export the composite
# Set up export name and path
exportName = outputName + "_" + str(int(startYearT)) + "_" + str(int(endYearT)) + "_" + str(int(startJulian)) + "_" + str(int(endJulian))
exportPath = exportPathRoot + "/" + exportName
if exportToAssets:
exportToAssetWrapper(
composite,
exportName,
exportPath,
"mean",
studyArea,
scale,
crs,
transform,
overwrite,
)
if exportToCloud:
exportToCloudStorageWrapper(
composite,
exportName,
bucket,
studyArea,
scale,
crs,
transform,
overwrite,
)
#########################################################################
#########################################################################
# Function to export composite collection
[docs]
def exportCompositeCollection(
collection,
exportPathRoot,
outputName,
origin,
studyArea,
crs,
transform,
scale,
startYear,
endYear,
startJulian,
endJulian,
compositingMethod,
timebuffer,
toaOrSR,
nonDivideBands,
exportBands,
additionalPropertyDict=None,
overwrite=False,
):
"""Exports yearly composites to Earth Engine assets with scaled integer values.
Iterates through each year (adjusted by timebuffer), scales reflectance
bands by 10000, converts to int16, attaches metadata, and exports each
yearly composite to an Earth Engine asset. Non-divide bands (e.g., indices
or categorical bands) are kept unscaled.
Args:
collection (ee.ImageCollection): The composite image collection.
exportPathRoot (str): The root asset path for exports.
outputName (str): Base name for the exported assets.
origin (str): The data origin label (e.g., "Landsat", "MODIS").
studyArea (ee.Geometry | ee.FeatureCollection): The export region.
crs (str): The coordinate reference system (e.g., "EPSG:5070").
transform (list[float] | None): The affine transform for the output.
scale (int | None): The output resolution in meters.
startYear (int): The first year of the collection.
endYear (int): The last year of the collection.
startJulian (int): The starting Julian day used in naming.
endJulian (int): The ending Julian day used in naming.
compositingMethod (str): The compositing method (e.g., "medoid",
"median") used in naming and metadata.
timebuffer (int): Number of years before/after to buffer the year
range.
toaOrSR (str): Reflectance type label ("TOA" or "SR") used in naming.
nonDivideBands (list[str] | None): Band names that should not be
multiplied by 10000. If None, all bands are scaled.
exportBands (list[str]): Band names to select for export.
additionalPropertyDict (dict | None, optional): Extra properties to
attach to each exported image. Defaults to None.
overwrite (bool, optional): If True, overwrites existing assets.
Defaults to False.
Returns:
None: Submits export tasks as a side effect.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = ee.Geometry.Rectangle([-110, 40, -109, 41])
>>> result = gil.getLandsatWrapper(studyArea, 2023, 2023, 152, 273)
>>> composites = result["processedComposites"]
>>> gil.exportCompositeCollection(composites, "projects/my-project/assets/composites", "L8_Composite", "Landsat", studyArea, "EPSG:5070", None, 30, 2023, 2023, 152, 273, "medoid", 0, "SR", ["NDVI", "NBR"], ["blue", "green", "red", "nir", "swir1", "swir2", "NDVI"])
"""
args = formatArgs(locals())
pyramidingPolicy = "mean"
dateWrapping = wrapDates(startJulian, endJulian)
wrapOffset = dateWrapping[0]
yearWithMajority = dateWrapping[1]
# Clean up output name
outputName = outputName.replace("/\s+/g", "-")
outputName = outputName.replace("/\//g", "-")
collection = collection.select(exportBands)
for year in ee.List.sequence(startYear + timebuffer, endYear - timebuffer).getInfo():
# Set up dates
startYearT = year - timebuffer
endYearT = year + timebuffer + yearWithMajority
# Get yearly composite
composite = collection.filter(ee.Filter.calendarRange(year + yearWithMajority, year + yearWithMajority, "year"))
composite = ee.Image(composite.first())
# Reformat data for export
compositeBands = composite.bandNames()
if nonDivideBands != None:
composite10k = composite.select(compositeBands.removeAll(nonDivideBands)).multiply(10000)
composite = composite10k.addBands(composite.select(nonDivideBands)).select(compositeBands).int16()
else:
composite = composite.multiply(10000).int16()
startYearComposite = startYearT
endYearComposite = endYearT
systemTimeStartYear = year + yearWithMajority
yearOriginal = year
yearUsed = systemTimeStartYear
# args['system:time_start'] = ee.Date.fromYMD(systemTimeStartYear, 6, 1).millis()
composite = composite.set(formatArgs(args))
composite = composite.set("system:time_start", ee.Date.fromYMD(systemTimeStartYear, 6, 1).millis())
if additionalPropertyDict != None:
if "args" in additionalPropertyDict.keys():
del additionalPropertyDict["args"]
composite = composite.set(formatArgs(additionalPropertyDict))
# Export the composite
# Set up export name and path
exportName = outputName + "_" + toaOrSR + "_" + compositingMethod + "_" + str(int(startYearT)) + "_" + str(int(endYearT)) + "_" + str(int(startJulian)) + "_" + str(int(endJulian))
exportPath = exportPathRoot + "/" + exportName
exportToAssetWrapper(
imageForExport=composite,
assetName=exportName,
assetPath=exportPath,
pyramidingPolicyObject=pyramidingPolicy,
roi=studyArea,
scale=scale,
crs=crs,
transform=transform,
overwrite=overwrite,
)
#########################################################################
#########################################################################
# Wrapper function for getting Landsat imagery
[docs]
def getLandsatWrapper(
studyArea,
startYear,
endYear,
startJulian,
endJulian,
timebuffer=0,
weights=[1],
compositingMethod="medoid",
toaOrSR="SR",
includeSLCOffL7=False,
defringeL5=False,
applyCloudScore=False,
applyFmaskCloudMask=True,
applyTDOM=False,
applyFmaskCloudShadowMask=True,
applyFmaskSnowMask=False,
cloudScoreThresh=10,
performCloudScoreOffset=True,
cloudScorePctl=10,
zScoreThresh=-1,
shadowSumThresh=0.35,
contractPixels=1.5,
dilatePixels=3.5,
correctIllumination=False,
correctScale=250,
exportComposites=False,
outputName="Landsat-Composite",
exportPathRoot="users/username/test",
crs="EPSG:5070",
transform=[30, 0, -2361915.0, 0, -30, 3177735.0],
scale=None,
resampleMethod="near",
preComputedCloudScoreOffset=None,
preComputedTDOMIRMean=None,
preComputedTDOMIRStdDev=None,
compositingReducer=None,
harmonizeOLI=False,
landsatCollectionVersion="C2",
overwrite=False,
verbose=False,
):
"""Retrieve cloud-masked Landsat annual composites for a study area and date range.
This is the main high-level wrapper for Landsat imagery. It retrieves and
cloud-masks Landsat scenes via ``getProcessedLandsatScenes``, composites them
into annual images, and optionally exports the composites to an Earth Engine
asset.
Args:
studyArea (ee.Geometry | ee.Feature | ee.FeatureCollection): The geographic area of interest.
startYear (int): The starting year for the data collection.
endYear (int): The ending year for the data collection.
startJulian (int): The starting Julian day of year (1-365). If ``startJulian > endJulian``, dates wrap across the new year.
endJulian (int): The ending Julian day of year (1-365).
timebuffer (int, optional): Number of years to buffer composites on each side. Defaults to 0.
weights (list[int], optional): Weights for years in the composite (length must equal ``2 * timebuffer + 1``). Defaults to [1].
compositingMethod (str, optional): Compositing method, e.g. ``"medoid"`` or ``"median"``. Defaults to ``"medoid"``.
toaOrSR (str, optional): ``"TOA"`` for Top of Atmosphere or ``"SR"`` for Surface Reflectance. Defaults to ``"SR"``.
includeSLCOffL7 (bool, optional): Whether to include Landsat 7 SLC-off scenes. Defaults to False.
defringeL5 (bool, optional): Whether to defringe Landsat 5 scenes. Defaults to False.
applyCloudScore (bool, optional): Whether to apply the CloudScore simple cloud mask. Defaults to False.
applyFmaskCloudMask (bool, optional): Whether to apply the Fmask cloud mask. Defaults to True.
applyTDOM (bool, optional): Whether to apply TDOM cloud shadow masking. Defaults to False.
applyFmaskCloudShadowMask (bool, optional): Whether to apply Fmask cloud shadow mask. Defaults to True.
applyFmaskSnowMask (bool, optional): Whether to apply Fmask snow mask. Defaults to False.
cloudScoreThresh (int, optional): Cloud score threshold; lower masks more. Defaults to 10.
performCloudScoreOffset (bool, optional): Whether to offset cloud score over bright surfaces. Defaults to True.
cloudScorePctl (int, optional): Percentile for cloud score offset correction. Defaults to 10.
zScoreThresh (float, optional): Z-score threshold for TDOM shadow masking. Defaults to -1.
shadowSumThresh (float, optional): Sum-of-reflectance threshold for TDOM. Defaults to 0.35.
contractPixels (float, optional): Pixels to contract cloud/shadow masks. Defaults to 1.5.
dilatePixels (float, optional): Pixels to dilate cloud/shadow masks. Defaults to 3.5.
correctIllumination (bool, optional): Whether to apply terrain illumination correction. Defaults to False.
correctScale (int, optional): Scale in meters for illumination correction. Defaults to 250.
exportComposites (bool, optional): Whether to export composites to an Earth Engine asset. Defaults to False.
outputName (str, optional): Base name for exported assets. Defaults to ``"Landsat-Composite"``.
exportPathRoot (str, optional): Asset folder path for exports. Defaults to ``"users/username/test"``.
crs (str, optional): Coordinate reference system for exports. Defaults to ``"EPSG:5070"``.
transform (list[float] | None, optional): Affine transform for exports. Defaults to ``[30, 0, -2361915.0, 0, -30, 3177735.0]``.
scale (float | None, optional): Scale in meters for exports. Overrides ``transform`` if provided. Defaults to None.
resampleMethod (str, optional): Resampling method (``"near"``, ``"bilinear"``, ``"bicubic"``). Defaults to ``"near"``.
preComputedCloudScoreOffset (ee.Image | None, optional): Pre-computed cloud score offset image. Defaults to None.
preComputedTDOMIRMean (ee.Image | None, optional): Pre-computed TDOM IR mean image. Defaults to None.
preComputedTDOMIRStdDev (ee.Image | None, optional): Pre-computed TDOM IR standard deviation image. Defaults to None.
compositingReducer (ee.Reducer | None, optional): Custom reducer for compositing. Overrides ``compositingMethod`` if provided. Defaults to None.
harmonizeOLI (bool, optional): Whether to harmonize OLI to TM/ETM+ spectral response. Defaults to False.
landsatCollectionVersion (str, optional): Landsat collection version (``"C1"`` or ``"C2"``). Defaults to ``"C2"``.
overwrite (bool, optional): Whether to overwrite existing exported assets. Defaults to False.
verbose (bool, optional): Whether to print processing details. Defaults to False.
Returns:
dict: A dictionary containing all input arguments plus two additional keys:
- ``'processedScenes'`` (ee.ImageCollection): The cloud-masked individual Landsat scenes.
- ``'processedComposites'`` (ee.ImageCollection): The annual composite time series.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CO"]
>>> result = gil.getLandsatWrapper(studyArea, 2020, 2023, 190, 250)
>>> composites = result['processedComposites']
>>> Map = gil.Map
>>> Map.addLayer(composites, gil.vizParamsFalse, "Landsat Composites", True)
>>> Map.centerObject(studyArea)
>>> Map.turnOnInspector()
>>> Map.view()
"""
toaOrSR = toaOrSR.upper()
origin = "Landsat"
args = formatArgs(locals())
if "args" in args.keys():
del args["args"]
# Prepare dates
wrapOffset = 0
if startJulian > endJulian:
wrapOffset = 365
startDate = ee.Date.fromYMD(startYear, 1, 1).advance(startJulian - 1, "day")
endDate = ee.Date.fromYMD(endYear, 1, 1).advance(endJulian - 1 + wrapOffset, "day")
# Get Landsat image collection and apply cloud masking
ls = getProcessedLandsatScenes(
studyArea=studyArea,
startYear=startYear,
endYear=endYear,
startJulian=startJulian,
endJulian=endJulian,
toaOrSR=toaOrSR,
includeSLCOffL7=includeSLCOffL7,
defringeL5=defringeL5,
applyCloudScore=applyCloudScore,
applyFmaskCloudMask=applyFmaskCloudMask,
applyTDOM=applyTDOM,
applyFmaskCloudShadowMask=applyFmaskCloudShadowMask,
applyFmaskSnowMask=applyFmaskSnowMask,
cloudScoreThresh=cloudScoreThresh,
performCloudScoreOffset=performCloudScoreOffset,
cloudScorePctl=cloudScorePctl,
zScoreThresh=zScoreThresh,
shadowSumThresh=shadowSumThresh,
contractPixels=contractPixels,
dilatePixels=dilatePixels,
resampleMethod=resampleMethod,
harmonizeOLI=harmonizeOLI,
preComputedCloudScoreOffset=preComputedCloudScoreOffset,
preComputedTDOMIRMean=preComputedTDOMIRMean,
preComputedTDOMIRStdDev=preComputedTDOMIRStdDev,
landsatCollectionVersion=landsatCollectionVersion,
verbose=verbose,
)
# Add zenith and azimuth
if correctIllumination:
print("Adding zenith and azimuth for terrain correction")
ls = ls.map(lambda img: addZenithAzimuth(img, toaOrSR))
# Create composite time series
ts = compositeTimeSeries(
ls=ls,
startYear=startYear,
endYear=endYear,
startJulian=startJulian,
endJulian=endJulian,
timebuffer=timebuffer,
weights=weights,
compositingMethod=compositingMethod,
compositingReducer=compositingReducer,
)
# Correct illumination
if correctIllumination:
print("Correcting illumination")
ts = ts.map(illuminationCondition).map(lambda img: illuminationCorrection(img, correctScale, studyArea))
# Export composites
if exportComposites:
if compositingMethod == "medoid":
exportBands = [
"blue",
"green",
"red",
"nir",
"swir1",
"swir2",
"temp",
"compositeObsCount",
"sensor",
"year",
"julianDay",
]
nonDivideBands = [
"temp",
"compositeObsCount",
"sensor",
"year",
"julianDay",
]
else:
exportBands = [
"blue",
"green",
"red",
"nir",
"swir1",
"swir2",
"temp",
"compositeObsCount",
]
nonDivideBands = ["temp", "compositeObsCount"]
exportCompositeCollection(
collection=ts,
exportPathRoot=exportPathRoot,
outputName=outputName,
origin=origin,
studyArea=studyArea,
crs=crs,
transform=transform,
scale=scale,
startYear=startYear,
endYear=endYear,
startJulian=startJulian,
endJulian=endJulian,
compositingMethod=compositingMethod,
timebuffer=timebuffer,
toaOrSR=toaOrSR,
nonDivideBands=nonDivideBands,
exportBands=exportBands,
# weights = weights,
# defringeL5 = False,
# includeSLCOffL7 = includeSLCOffL7,
# convertToDailyMosaics = 'NA',
# applyQABand = False,
# applyCloudScore = applyCloudScore,
# applyFmaskCloudMask = applyFmaskCloudMask,
# applyCloudProbability = 'NA',
# applyTDOM = applyTDOM,
# applyFmaskCloudShadowMask = applyFmaskCloudShadowMask,
# applyFmaskSnowMask = applyFmaskSnowMask,
# applyShadowShift = 'NA',
# cloudHeights = cloudHeights,
# cloudScoreThresh = cloudScoreThresh,
# performCloudScoreOffset = performCloudScoreOffset,
# cloudScorePctl = cloudScorePctl,
# zScoreThresh = zScoreThresh,
# shadowSumThresh = shadowSumThresh,
# contractPixels = contractPixels,
# dilatePixels = dilatePixels,
# correctIllumination = correctIllumination,
# correctScale = correctScale,
# nonDivideBands = nonDivideBands,
# exportBands = exportBands,
# resampleMethod = resampleMethod,
# runChastainHarmonization = 'NA',
additionalPropertyDict=args,
overwrite=overwrite,
)
args["processedScenes"] = ls
args["processedComposites"] = ts
return args
#########################################################################
#########################################################################
# Wrapper function for getting Landsat imagery
[docs]
def getProcessedLandsatScenes(
studyArea: ee.Geometry | ee.Feature | ee.FeatureCollection,
startYear: int,
endYear: int,
startJulian: int,
endJulian: int,
toaOrSR: str = "SR",
includeSLCOffL7: bool = False,
defringeL5: bool = False,
applyCloudScore: bool = False,
applyFmaskCloudMask: bool = True,
applyTDOM: bool = False,
applyFmaskCloudShadowMask: bool = True,
applyFmaskSnowMask: bool = False,
cloudScoreThresh: int = 10,
performCloudScoreOffset: bool = True,
cloudScorePctl: int = 10,
zScoreThresh: float = -1,
shadowSumThresh: float = 0.35,
contractPixels: float = 1.5,
dilatePixels: float = 3.5,
shadowSumBands: list[str] = ["nir", "swir1"],
resampleMethod: str = "near",
harmonizeOLI: bool = False,
preComputedCloudScoreOffset: ee.Image | None = None,
preComputedTDOMIRMean: ee.Image | None = None,
preComputedTDOMIRStdDev: ee.Image | None = None,
landsatCollectionVersion: str = "C2",
verbose: bool = False,
) -> ee.ImageCollection:
"""
Retrieves, processes, and filters Landsat scenes for a specified area and time period.
This function retrieves Landsat scenes from Earth Engine, applies various cloud,
cloud shadow, and snow masking techniques, calculates common indices, and returns
a collection of processed images.
Args:
studyArea (ee.Geometry): The geographic area of interest (study area) as an Earth Engine geometry, Feature, or FeatureCollection object.
startYear (int): The starting year for the data collection.
endYear (int): The ending year for the data collection.
startJulian (int): The starting Julian day of year for the data collection (1-365).
endJulian (int): The ending Julian day of year for the data collection (1-365).
toaOrSR (str, optional): Flag indicating desired reflectance type: "TOA" (Top Of Atmosphere) or "SR" (Surface Reflectance). Defaults to "SR".
includeSLCOffL7 (bool, optional): Determines whether to include Landsat 7 SLC-off scenes. Defaults to False.
defringeL5 (bool, optional): Determines whether to defringe Landsat 5 scenes. Defaults to False.
applyCloudScore (bool, optional): Determines whether to apply cloud masking based on the CloudScore simple algorithm. Defaults to False.
applyFmaskCloudMask (bool, optional): Determines whether to apply the Fmask cloud mask. Defaults to True.
applyTDOM (bool, optional): Determines whether to apply the TDOM (Temporal Dark Outlier Mask) technique for cloud shadow masking. Defaults to False.
applyFmaskCloudShadowMask (bool, optional): Determines whether to apply the Fmask cloud shadow mask. Defaults to True.
applyFmaskSnowMask (bool, optional): Determines whether to apply the Fmask snow mask. Defaults to False.
cloudScoreThresh (int, optional): Threshold for the CloudScore simple algorithm to classify a pixel as cloudy. Lower number masks out more. Defaults to 10.
performCloudScoreOffset (bool, optional): Controls whether to perform an offset correction on the Cloud Score data over bright surfaces. Only use this if bright areas are being masked as clouds. Do not use this in persistently cloud areas. Defaults to True.
cloudScorePctl (int, optional): Percentile of the Cloud Score product to use for the offset correction. Defaults to 10.
zScoreThresh (float, optional): Threshold for the z-score used in TDOM cloud shadow masking. Pixels with z-scores below this threshold are masked. Defaults to -1.
shadowSumThresh (float, optional): Threshold for the sum of reflectance in shadow bands used in TDOM cloud shadow masking. Pixels below this threshold and the zScoreThresh are masked as dark outliers (likely cloud shadows). Defaults to 0.35.
contractPixels (float, optional): Number of pixels to contract cloud and shadow masks by. Defaults to 1.5.
dilatePixels (float, optional): Number of pixels to dilate cloud and shadow masks by. Defaults to 3.5.
shadowSumBands (list[str], optional): List of band names to use for calculating the sum of reflectance in TDOM cloud shadow masking. Defaults to ["nir", "swir1"].
resampleMethod (str, optional): Specifies the resampling method to apply to the imagery. Valid options include "near", "bilinear", and "bicubic". Defaults to "near".
harmonizeOLI (bool, optional): Determines whether to harmonize OLI data to match TM/ETM+ spectral response. Defaults to False.
preComputedCloudScoreOffset (float | None, optional): Pre-computed Cloud Score offset value to avoid redundant calculations. Defaults to None (automatic calculation).
preComputedTDOMIRMean (float | None, optional): Pre-computed mean of the IR band used in TDOM cloud shadow masking to avoid redundant calculations. Defaults to None (automatic calculation).
preComputedTDOMIRStdDev (float | None, optional): Pre-computed standard deviation of the IR band used in TDOM cloud shadow masking to avoid redundant calculations. Defaults to None (automatic calculation).
landsatCollectionVersion (str, optional): Specifies the Landsat collection version to use (e.g., "C1", "C2"). Defaults to "C2".
verbose (bool, optional): Controls whether to print additional information during processing. Defaults to False.
Returns:
ee.ImageCollection: A collection of analysis ready, cloud and cloud shadow asked Landsat scenes with common band names.
>>> import geeViz.getImagesLib as gil
>>> Map = gil.Map
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CO"]
>>> composite = gil.getProcessedLandsatScenes(studyArea, 2023, 2023, 190, 250).median()
>>> Map.addLayer(composite, gil.vizParamsFalse, "Landsat Composite")
>>> Map.addLayer(studyArea, {"canQuery": False}, "Study Area")
>>> Map.centerObject(studyArea)
>>> Map.turnOnInspector()
>>> Map.view()
"""
origin = "Landsat"
toaOrSR = toaOrSR.upper()
if toaOrSR.lower() == "toa" and landsatCollectionVersion.lower() == "c1" and (applyFmaskCloudMask or applyFmaskCloudShadowMask or applyFmaskSnowMask):
addPixelQA = True
else:
addPixelQA = False
# Prepare dates
# Wrap the dates if needed
wrapOffset = 0
if startJulian > endJulian:
wrapOffset = 365
startDate = ee.Date.fromYMD(startYear, 1, 1).advance(startJulian - 1, "day")
endDate = ee.Date.fromYMD(endYear, 1, 1).advance(endJulian - 1 + wrapOffset, "day")
args = formatArgs(locals())
if "args" in args.keys():
del args["args"]
print("Get Processed Landsat: ")
print(
"Start date:",
startDate.format("MMM dd yyyy").getInfo(),
", End date:",
endDate.format("MMM dd yyyy").getInfo(),
)
if verbose:
for arg in args.keys():
print(arg, ": ", args[arg])
# Get Landsat image collection
ls = getLandsat(
studyArea=studyArea,
startDate=startDate,
endDate=endDate,
startJulian=startJulian,
endJulian=endJulian,
toaOrSR=toaOrSR,
includeSLCOffL7=includeSLCOffL7,
defringeL5=defringeL5,
addPixelQA=addPixelQA,
resampleMethod=resampleMethod,
landsatCollectionVersion=landsatCollectionVersion,
)
# Apply relevant cloud masking methods
if applyCloudScore:
print("Applying Cloud Score")
ls = applyCloudScoreAlgorithm(
collection=ls,
cloudScoreFunction=landsatCloudScore,
cloudScoreThresh=cloudScoreThresh,
cloudScorePctl=cloudScorePctl,
contractPixels=contractPixels,
dilatePixels=dilatePixels,
performCloudScoreOffset=performCloudScoreOffset,
preComputedCloudScoreOffset=preComputedCloudScoreOffset,
)
if applyFmaskCloudMask:
print("Applying Fmask Cloud Mask")
ls = ls.map(
lambda img: applyBitMask(
img,
fmaskBitDict[landsatCollectionVersion]["cloud"],
landsatFmaskBandNameDict[landsatCollectionVersion],
)
)
if applyTDOM:
print("Applying TDOM Shadow Mask")
ls = simpleTDOM2(
collection=ls,
zScoreThresh=zScoreThresh,
shadowSumThresh=shadowSumThresh,
contractPixels=contractPixels,
dilatePixels=dilatePixels,
shadowSumBands=["nir", "swir1"],
preComputedTDOMIRMean=preComputedTDOMIRMean,
preComputedTDOMIRStdDev=preComputedTDOMIRStdDev,
)
if applyFmaskCloudShadowMask:
print("Applying Fmask Shadow Mask")
ls = ls.map(
lambda img: applyBitMask(
img,
fmaskBitDict[landsatCollectionVersion]["shadow"],
landsatFmaskBandNameDict[landsatCollectionVersion],
)
)
if applyFmaskSnowMask:
print("Applying Fmask snow mask")
ls = ls.map(
lambda img: applyBitMask(
img,
fmaskBitDict[landsatCollectionVersion]["snow"],
landsatFmaskBandNameDict[landsatCollectionVersion],
)
)
# Add common indices
ls = ls.map(simpleAddIndices).map(getTasseledCap).map(simpleAddTCAngles)
# Add Sensor Band
ls = ls.map(lambda img: addSensorBand(img, landsatCollectionVersion + "_landsat", toaOrSR))
return ls.set(args)
#########################################################################
#########################################################################
# Wrapper function for getting Sentinel2 imagery
[docs]
def getProcessedSentinel2Scenes(
studyArea,
startYear,
endYear,
startJulian,
endJulian,
applyQABand=False,
applyCloudScore=False,
applyShadowShift=False,
applyTDOM=False,
cloudScoreThresh=20,
performCloudScoreOffset=True,
cloudScorePctl=10,
cloudHeights=ee.List.sequence(500, 10000, 500),
zScoreThresh=-1,
shadowSumThresh=0.35,
contractPixels=1.5,
dilatePixels=3.5,
shadowSumBands=["nir", "swir1"],
resampleMethod="aggregate",
toaOrSR="TOA",
convertToDailyMosaics=True,
applyCloudProbability=False,
preComputedCloudScoreOffset=None,
preComputedTDOMIRMean=None,
preComputedTDOMIRStdDev=None,
cloudProbThresh=40,
verbose=False,
applyCloudScorePlus=True,
cloudScorePlusThresh=0.6,
cloudScorePlusScore="cs",
):
"""Get cloud/shadow-masked Sentinel-2 scenes for a date range.
Retrieves Sentinel-2 imagery, applies selected cloud and shadow masking
methods (QA band, cloud score, TDOM, Cloud Score+, cloud probability),
and returns the processed scene collection. This is the scene-level
counterpart to ``getSentinel2Wrapper``, which also composites.
Args:
studyArea (ee.Geometry | ee.FeatureCollection): Area of interest.
startYear (int): First year to include.
endYear (int): Last year to include.
startJulian (int): Start day of year (1-365).
endJulian (int): End day of year (1-365). If < ``startJulian``,
the date range wraps across the year boundary.
applyQABand (bool): Apply the Sentinel-2 QA60 cloud bitmask.
Defaults to ``False``.
applyCloudScore (bool): Apply simple cloud scoring. Defaults to
``False``.
applyShadowShift (bool): Apply shadow-shift cloud shadow detection.
Defaults to ``False``.
applyTDOM (bool): Apply Temporal Dark Outlier Mask for shadows.
Defaults to ``False``.
cloudScoreThresh (int): Cloud score threshold (0-100). Defaults to
``20``.
performCloudScoreOffset (bool): Compute per-scene cloud score
offset. Defaults to ``True``.
cloudScorePctl (int): Percentile for cloud score offset. Defaults
to ``10``.
cloudHeights (ee.List): Cloud heights in meters for shadow
projection. Defaults to ``ee.List.sequence(500, 10000, 500)``.
zScoreThresh (float): TDOM z-score threshold. Defaults to ``-1``.
shadowSumThresh (float): TDOM shadow sum threshold. Defaults to
``0.35``.
contractPixels (float): Pixels to contract cloud/shadow mask.
Defaults to ``1.5``.
dilatePixels (float): Pixels to dilate cloud/shadow mask. Defaults
to ``3.5``.
shadowSumBands (list[str]): Bands for shadow detection. Defaults to
``["nir", "swir1"]``.
resampleMethod (str): Resampling method — ``"aggregate"``,
``"near"``, ``"bilinear"``, or ``"bicubic"``. Defaults to
``"aggregate"``.
toaOrSR (str): ``"TOA"`` or ``"SR"``. Defaults to ``"TOA"``.
convertToDailyMosaics (bool): Mosaic same-day images. Defaults to
``True``.
applyCloudProbability (bool): Apply the S2 cloud probability band.
Defaults to ``False``.
preComputedCloudScoreOffset (ee.Image | None): Pre-computed cloud
score offset image. Defaults to ``None``.
preComputedTDOMIRMean (ee.Image | None): Pre-computed TDOM IR mean.
Defaults to ``None``.
preComputedTDOMIRStdDev (ee.Image | None): Pre-computed TDOM IR
standard deviation. Defaults to ``None``.
cloudProbThresh (int): Cloud probability threshold (0-100).
Defaults to ``40``.
verbose (bool): Print all processing parameters. Defaults to
``False``.
applyCloudScorePlus (bool): Apply Cloud Score+ masking. Defaults to
``True``.
cloudScorePlusThresh (float): Cloud Score+ threshold (0-1).
Defaults to ``0.6``.
cloudScorePlusScore (str): Cloud Score+ band — ``"cs"`` or
``"cs_cdf"``. Defaults to ``"cs"``.
Returns:
ee.ImageCollection: Cloud/shadow-masked Sentinel-2 scenes with
standardized band names.
Example:
>>> import geeViz.getImagesLib as gil
>>> study = ee.Geometry.Point([-122.3, 37.5]).buffer(5000)
>>> scenes = gil.getProcessedSentinel2Scenes(
... study, 2023, 2023, 152, 273
... )
>>> print(scenes.first().bandNames().getInfo()[:3])
['cb', 'blue', 'green']
"""
origin = "Sentinel2"
toaOrSR = toaOrSR.upper()
# Prepare dates
# Wrap the dates if needed
wrapOffset = 0
if startJulian > endJulian:
wrapOffset = 365
startDate = ee.Date.fromYMD(startYear, 1, 1).advance(startJulian - 1, "day")
endDate = ee.Date.fromYMD(endYear, 1, 1).advance(endJulian - 1 + wrapOffset, "day")
args = formatArgs(locals())
if "args" in args.keys():
del args["args"]
print("Get Processed Sentinel2: ")
print(
"Start date:",
startDate.format("MMM dd yyyy").getInfo(),
", End date:",
endDate.format("MMM dd yyyy").getInfo(),
)
if verbose:
for arg in args.keys():
print(arg, ": ", args[arg])
# Get Sentinel2 image collection
s2s = getS2(
studyArea=studyArea,
startDate=startDate,
endDate=endDate,
startJulian=startJulian,
endJulian=endJulian,
resampleMethod=resampleMethod,
toaOrSR=toaOrSR,
convertToDailyMosaics=convertToDailyMosaics,
addCloudProbability=applyCloudProbability,
addCloudScorePlus=applyCloudScorePlus,
cloudScorePlusScore=cloudScorePlusScore,
)
if applyQABand:
print("Applying QA Band Cloud Mask")
s2s = s2s.map(maskS2clouds)
if applyCloudScore:
print("Applying Cloud Score")
s2s = applyCloudScoreAlgorithm(
collection=s2s,
cloudScoreFunction=sentinel2CloudScore,
cloudScoreThresh=cloudScoreThresh,
cloudScorePctl=cloudScorePctl,
contractPixels=contractPixels,
dilatePixels=dilatePixels,
performCloudScoreOffset=performCloudScoreOffset,
preComputedCloudScoreOffset=preComputedCloudScoreOffset,
)
if applyCloudProbability:
print("Applying Cloud Probability")
s2s = s2s.map(lambda img: img.updateMask(img.select(["cloud_probability"]).lte(cloudProbThresh)))
if applyCloudScorePlus:
print("Applying cloudScore+")
s2s = s2s.map(lambda img: img.updateMask(img.select(["cloudScorePlus"]).gte(cloudScorePlusThresh)))
if applyShadowShift:
print("Applying Shadow Shift")
s2s = s2s.map(
lambda img: projectShadowsWrapper(
img=img,
cloudThresh=cloudScoreThresh,
irSumThresh=shadowSumThresh,
contractPixels=contractPixels,
dilatePixels=dilatePixels,
cloudHeights=cloudHeights,
)
)
if applyTDOM:
print("Applying TDOM")
s2s = simpleTDOM2(
collection=s2s,
zScoreThresh=zScoreThresh,
shadowSumThresh=shadowSumThresh,
contractPixels=contractPixels,
dilatePixels=dilatePixels,
shadowSumBands=["nir", "swir1"],
preComputedTDOMIRMean=preComputedTDOMIRMean,
preComputedTDOMIRStdDev=preComputedTDOMIRStdDev,
)
# Add common indices
s2s = s2s.map(simpleAddIndices).map(getTasseledCap).map(simpleAddTCAngles).map(lambda img: img.addBands(img.normalizedDifference(["re1", "red"]).select([0], ["NDCI"])))
# Add Sensor Band
s2s = s2s.map(lambda img: addSensorBand(img, "sentinel2", toaOrSR))
return s2s.set(args)
#########################################################################
#########################################################################
[docs]
def superSimpleGetS2(
studyArea: ee.Geometry | ee.Feature | ee.FeatureCollection | None,
startDate: ee.Date | datetime.datetime | str,
endDate: ee.Date | datetime.datetime | str,
startJulian: int = 1,
endJulian: int = 365,
toaOrSR: str = "TOA",
applyCloudScorePlus: bool = True,
cloudScorePlusThresh: float = 0.6,
cloudScorePlusScore: str = "cs",
) -> ee.ImageCollection:
"""Retrieve cloud-masked Sentinel-2 imagery — **preferred S2 function**.
This is the recommended way to get Sentinel-2 data. It uses the
Cloud Score Plus product for cloud/shadow masking and returns an
``ee.ImageCollection`` of individual scenes (not annual composites).
Use ``.median()`` or ``.mosaic()`` to composite as needed.
For annual composites with medoid compositing, see
:func:`getSentinel2Wrapper` (legacy, more complex).
Args:
studyArea (ee.Geometry, ee.Feature, ee.FeatureCollection, or None, optional): An Earth Engine geometry object representing the area of interest. If set to None, startJulian and endJulian cannot be used. Doing so will cause the image to never render.
startDate (ee.Date, datetime.datetime, or str): The start date for the image collection in YYYY-MM-DD format.
endDate (ee.Date, datetime.datetime, or str): The end date for the image collection in YYYY-MM-DD format.
startJulian (int, optional): The start Julian day of the desired data. Defaults to 1.
endJulian (int, optional): The end Julian day of the desired data. Defaults to 365.
toaOrSR (str, optional): Specifies whether to retrieve data in Top-Of-Atmosphere (TOA) reflectance or Surface Reflectance (SR). Defaults to "TOA".
applyCloudScorePlus (bool, optional): Determines whether to apply cloud filtering based on the Cloud Score Plus product. Defaults to True.
cloudScorePlusThresh (float, optional): Sets the threshold for cloud cover percentage based on Cloud Score Plus. Images with cloud cover exceeding this threshold will be masked out if `applyCloudScorePlus` is True. A higher value will mask out more pixels (call them cloud/cloud-shadow). Defaults to 0.6.
cloudScorePlusScore (str, optional): One of "cs" - Tends to mask out more. Commits ephemeral water, but doesn't omit cloud shadows as much or "cs_cdf" - Tends to mask out less, notably fewer water bodies and shadows. This can result in omitting cloud shadows, but not committing ephemeral water as a cloud shadow. Specifies the band name within the Cloud Score Plus product containing the cloud cover information. Defaults to "cs".
Returns:
ee.ImageCollection: A collection of cloud and cloud-shadow-free Sentinel-2 satellite images filtered by the specified criteria.
Note:
The spectral values range 0-10000.
>>> import geeViz.getImagesLib as gil
>>> Map = gil.Map
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CA"]
>>> composite = gil.superSimpleGetS2(studyArea, "2024-01-01", "2024-12-31", 190, 250).median()
>>> Map.addLayer(composite, gil.vizParamsFalse10k, "Sentinel-2 Composite")
>>> Map.addLayer(studyArea, {"canQuery": False}, "Study Area")
>>> Map.centerObject(studyArea)
>>> Map.turnOnInspector()
>>> Map.view()
"""
toaOrSR = toaOrSR.upper()
startDate = ee.Date(startDate)
endDate = ee.Date(endDate)
s2s = ee.ImageCollection(s2CollectionDict[toaOrSR]).filterDate(startDate, endDate.advance(1, "day"))
if studyArea != None:
s2s = s2s.filterBounds(studyArea)
if startJulian != 1 or endJulian != 365:
s2s = s2s.filter(ee.Filter.calendarRange(startJulian, endJulian))
s2s = s2s.select(sensorBandDict[toaOrSR], sensorBandNameDict[toaOrSR])
cloudScorePlus = ee.ImageCollection("GOOGLE/CLOUD_SCORE_PLUS/V1/S2_HARMONIZED").select([cloudScorePlusScore], ["cloudScorePlus"])
s2s = s2s.linkCollection(cloudScorePlus, ["cloudScorePlus"])
if applyCloudScorePlus:
s2s = s2s.map(lambda img: img.updateMask(img.select(["cloudScorePlus"]).gte(cloudScorePlusThresh)))
return s2s
#########################################################################
#########################################################################
# Wrapper function for getting Sentinel 2 imagery
[docs]
def getSentinel2Wrapper(
studyArea,
startYear,
endYear,
startJulian,
endJulian,
timebuffer=0,
weights=[1],
compositingMethod="medoid",
applyQABand=False,
applyCloudScore=False,
applyShadowShift=False,
applyTDOM=False,
cloudScoreThresh=20,
performCloudScoreOffset=True,
cloudScorePctl=10,
cloudHeights=ee.List.sequence(500, 10000, 500),
zScoreThresh=-1,
shadowSumThresh=0.35,
contractPixels=1.5,
dilatePixels=3.5,
shadowSumBands=["nir", "swir1"],
correctIllumination=False,
correctScale=250,
exportComposites=False,
outputName="Sentinel2-Composite",
exportPathRoot="users/username/test",
crs="EPSG:5070",
transform=[10, 0, -2361915.0, 0, -10, 3177735.0],
scale=None,
resampleMethod="aggregate",
toaOrSR="TOA",
convertToDailyMosaics=True,
applyCloudProbability=False,
preComputedCloudScoreOffset=None,
preComputedTDOMIRMean=None,
preComputedTDOMIRStdDev=None,
cloudProbThresh=40,
overwrite=False,
verbose=False,
applyCloudScorePlus=True,
cloudScorePlusThresh=0.6,
cloudScorePlusScore="cs",
):
"""Get annual Sentinel-2 composites with cloud/shadow masking.
.. deprecated::
Use :func:`superSimpleGetS2` instead for most use cases. It is
simpler (fewer parameters), uses the newer Cloud Score Plus
product, and returns individual scenes that you composite as
needed (including medoid via ``compositeTimeSeries``). This
function is retained for workflows that need the all-in-one
annual compositing pipeline with temporal buffering, TDOM,
or asset export built in.
Wraps ``getProcessedSentinel2Scenes`` to retrieve cloud-masked scenes,
then composites them into annual (or multi-year buffered) images using
``compositeTimeSeries``. Optionally exports composites to an EE asset.
Args:
studyArea (ee.Geometry | ee.FeatureCollection): Area of interest.
startYear (int): First year to include.
endYear (int): Last year to include.
startJulian (int): Start day of year (1-365).
endJulian (int): End day of year (1-365). If < ``startJulian``,
the date range wraps across the year boundary.
timebuffer (int): Years to buffer each composite (e.g. ``1``
means +/- 1 year). Defaults to ``0``.
weights (list[int]): Temporal weights for buffered compositing.
Defaults to ``[1]``.
compositingMethod (str): ``"medoid"`` or ``"median"``. Defaults
to ``"medoid"``.
applyQABand (bool): Apply the QA60 cloud bitmask. Defaults to
``False``.
applyCloudScore (bool): Apply simple cloud scoring. Defaults to
``False``.
applyShadowShift (bool): Apply shadow-shift detection. Defaults to
``False``.
applyTDOM (bool): Apply Temporal Dark Outlier Mask. Defaults to
``False``.
cloudScoreThresh (int): Cloud score threshold (0-100). Defaults to
``20``.
performCloudScoreOffset (bool): Per-scene cloud score offset.
Defaults to ``True``.
cloudScorePctl (int): Percentile for cloud score offset. Defaults
to ``10``.
cloudHeights (ee.List): Cloud heights in meters. Defaults to
``ee.List.sequence(500, 10000, 500)``.
zScoreThresh (float): TDOM z-score threshold. Defaults to ``-1``.
shadowSumThresh (float): TDOM shadow sum threshold. Defaults to
``0.35``.
contractPixels (float): Pixels to contract mask. Defaults to
``1.5``.
dilatePixels (float): Pixels to dilate mask. Defaults to ``3.5``.
shadowSumBands (list[str]): Bands for shadow detection. Defaults to
``["nir", "swir1"]``.
correctIllumination (bool): Reserved for future illumination
correction. Defaults to ``False``.
correctScale (int): Scale for illumination correction. Defaults to
``250``.
exportComposites (bool): Export composites to EE asset. Defaults to
``False``.
outputName (str): Asset name prefix. Defaults to
``"Sentinel2-Composite"``.
exportPathRoot (str): Asset folder path. Defaults to
``"users/username/test"``.
crs (str): Output CRS. Defaults to ``"EPSG:5070"``.
transform (list): Affine transform. Defaults to
``[10, 0, -2361915.0, 0, -10, 3177735.0]``.
scale (int | None): Output scale in meters (overrides transform if
set). Defaults to ``None``.
resampleMethod (str): Resampling method. Defaults to
``"aggregate"``.
toaOrSR (str): ``"TOA"`` or ``"SR"``. Defaults to ``"TOA"``.
convertToDailyMosaics (bool): Mosaic same-day images. Defaults to
``True``.
applyCloudProbability (bool): Apply cloud probability band.
Defaults to ``False``.
preComputedCloudScoreOffset (ee.Image | None): Pre-computed offset.
Defaults to ``None``.
preComputedTDOMIRMean (ee.Image | None): Pre-computed TDOM mean.
Defaults to ``None``.
preComputedTDOMIRStdDev (ee.Image | None): Pre-computed TDOM
std dev. Defaults to ``None``.
cloudProbThresh (int): Cloud probability threshold (0-100).
Defaults to ``40``.
overwrite (bool): Overwrite existing exports. Defaults to
``False``.
verbose (bool): Print all parameters. Defaults to ``False``.
applyCloudScorePlus (bool): Apply Cloud Score+ masking. Defaults to
``True``.
cloudScorePlusThresh (float): Cloud Score+ threshold (0-1).
Defaults to ``0.6``.
cloudScorePlusScore (str): ``"cs"`` or ``"cs_cdf"``. Defaults to
``"cs"``.
Returns:
dict: Dictionary with all input args plus:
- ``"processedScenes"``: ``ee.ImageCollection`` of masked scenes
- ``"processedComposites"``: ``ee.ImageCollection`` of annual
composites
Example:
>>> import geeViz.getImagesLib as gil
>>> study = ee.Geometry.Point([-122.3, 37.5]).buffer(5000)
>>> result = gil.getSentinel2Wrapper(
... study, 2023, 2023, 152, 273
... )
>>> composites = result['processedComposites']
>>> print(composites.size().getInfo())
1
"""
origin = "Sentinel2"
toaOrSR = toaOrSR.upper()
args = formatArgs(locals())
if "args" in args.keys():
del args["args"]
s2s = getProcessedSentinel2Scenes(
studyArea=studyArea,
startYear=startYear,
endYear=endYear,
startJulian=startJulian,
endJulian=endJulian,
applyQABand=applyQABand,
applyCloudScore=applyCloudScore,
applyShadowShift=applyShadowShift,
applyTDOM=applyTDOM,
cloudScoreThresh=cloudScoreThresh,
performCloudScoreOffset=performCloudScoreOffset,
cloudScorePctl=cloudScorePctl,
cloudHeights=cloudHeights,
zScoreThresh=zScoreThresh,
shadowSumThresh=shadowSumThresh,
contractPixels=contractPixels,
dilatePixels=dilatePixels,
shadowSumBands=shadowSumBands,
resampleMethod=resampleMethod,
toaOrSR=toaOrSR,
convertToDailyMosaics=convertToDailyMosaics,
applyCloudProbability=applyCloudProbability,
preComputedCloudScoreOffset=preComputedCloudScoreOffset,
preComputedTDOMIRMean=preComputedTDOMIRMean,
preComputedTDOMIRStdDev=preComputedTDOMIRStdDev,
cloudProbThresh=cloudProbThresh,
verbose=verbose,
applyCloudScorePlus=applyCloudScorePlus,
cloudScorePlusThresh=cloudScorePlusThresh,
cloudScorePlusScore=cloudScorePlusScore,
)
# Add zenith and azimuth
# if correctIllumination:
# s2s = s2s.map(function(img){
# return addZenithAzimuth(img,'TOA',{'TOA':'MEAN_SOLAR_ZENITH_ANGLE'},{'TOA':'MEAN_SOLAR_AZIMUTH_ANGLE'});
# });
# }
# Create composite time series
ts = compositeTimeSeries(
ls=s2s,
startYear=startYear,
endYear=endYear,
startJulian=startJulian,
endJulian=endJulian,
timebuffer=timebuffer,
weights=weights,
compositingMethod=compositingMethod,
)
# Correct illumination
# if (correctIllumination){
# f = ee.Image(ts.first());
# Map.addLayer(f,vizParamsFalse,'First-non-illuminated',false);
# print('Correcting illumination');
# ts = ts.map(illuminationCondition)
# .map(function(img){
# return illuminationCorrection(img, correctScale,studyArea,[ 'blue', 'green', 'red','nir','swir1', 'swir2']);
# });
# f = ee.Image(ts.first());
# Map.addLayer(f,vizParamsFalse,'First-illuminated',false);
# Export composites
if exportComposites:
exportBandDict = {
"SR_medoid": [
"cb",
"blue",
"green",
"red",
"re1",
"re2",
"re3",
"nir",
"nir2",
"waterVapor",
"swir1",
"swir2",
"compositeObsCount",
"sensor",
"year",
"julianDay",
],
"SR_median": [
"cb",
"blue",
"green",
"red",
"re1",
"re2",
"re3",
"nir",
"nir2",
"waterVapor",
"swir1",
"swir2",
"compositeObsCount",
],
"TOA_medoid": [
"cb",
"blue",
"green",
"red",
"re1",
"re2",
"re3",
"nir",
"nir2",
"waterVapor",
"cirrus",
"swir1",
"swir2",
"compositeObsCount",
"sensor",
"year",
"julianDay",
],
"TOA_median": [
"cb",
"blue",
"green",
"red",
"re1",
"re2",
"re3",
"nir",
"nir2",
"waterVapor",
"cirrus",
"swir1",
"swir2",
"compositeObsCount",
],
}
nonDivideBandDict = {
"medoid": ["compositeObsCount", "sensor", "year", "julianDay"],
"median": ["compositeObsCount"],
}
exportBands = exportBandDict[toaOrSR + "_" + compositingMethod]
nonDivideBands = nonDivideBandDict[compositingMethod]
exportCompositeCollection(
collection=ts,
exportPathRoot=exportPathRoot,
outputName=outputName,
origin=origin,
studyArea=studyArea,
crs=crs,
transform=transform,
scale=scale,
startYear=startYear,
endYear=endYear,
startJulian=startJulian,
endJulian=endJulian,
compositingMethod=compositingMethod,
timebuffer=timebuffer,
toaOrSR=toaOrSR,
nonDivideBands=nonDivideBands,
exportBands=exportBands,
additionalPropertyDict=args,
overwrite=overwrite,
)
args["processedScenes"] = s2s
args["processedComposites"] = ts
return args
# Hybrid get Landsat and Sentinel 2 processed scenes
# Handles getting processed scenes with Landsat and Sentinel 2
[docs]
def getProcessedLandsatAndSentinel2Scenes(
studyArea,
startYear,
endYear,
startJulian,
endJulian,
toaOrSR="TOA",
includeSLCOffL7=False,
defringeL5=False,
applyQABand=False,
applyCloudProbability=False,
applyShadowShift=False,
applyCloudScoreLandsat=False,
applyCloudScoreSentinel2=False,
applyTDOMLandsat=True,
applyTDOMSentinel2=False,
applyFmaskCloudMask=True,
applyFmaskCloudShadowMask=True,
applyFmaskSnowMask=False,
cloudHeights=ee.List.sequence(500, 10000, 500),
cloudScoreThresh=20,
performCloudScoreOffset=True,
cloudScorePctl=10,
zScoreThresh=-1,
shadowSumThresh=0.35,
contractPixels=1.5,
dilatePixels=3.5,
shadowSumBands=["nir", "swir1"],
landsatResampleMethod="near",
sentinel2ResampleMethod="aggregate",
convertToDailyMosaics=True,
runChastainHarmonization=True,
correctIllumination=False,
correctScale=250,
preComputedLandsatCloudScoreOffset=None,
preComputedLandsatTDOMIRMean=None,
preComputedLandsatTDOMIRStdDev=None,
preComputedSentinel2CloudScoreOffset=None,
preComputedSentinel2TDOMIRMean=None,
preComputedSentinel2TDOMIRStdDev=None,
cloudProbThresh=40,
landsatCollectionVersion="C2",
verbose=False,
applyCloudScorePlus=True,
cloudScorePlusThresh=0.6,
cloudScorePlusScore="cs",
):
"""Get cloud/shadow-masked Landsat + Sentinel-2 scenes merged together.
Retrieves and masks both Landsat and Sentinel-2 imagery for the same
date range, optionally applies Chastain harmonization to align the
two sensor families, and returns the merged scene collection. This is
the scene-level counterpart to ``getLandsatAndSentinel2HybridWrapper``.
Args:
studyArea (ee.Geometry | ee.FeatureCollection): Area of interest.
startYear (int): First year to include.
endYear (int): Last year to include.
startJulian (int): Start day of year (1-365).
endJulian (int): End day of year (1-365).
toaOrSR (str): ``"TOA"`` or ``"SR"``. Defaults to ``"TOA"``.
includeSLCOffL7 (bool): Include Landsat 7 SLC-off imagery.
Defaults to ``False``.
defringeL5 (bool): Defringe Landsat 5 scenes. Defaults to
``False``.
applyQABand (bool): Apply QA band cloud mask. Defaults to
``False``.
applyCloudProbability (bool): Apply S2 cloud probability.
Defaults to ``False``.
applyShadowShift (bool): Apply shadow-shift detection. Defaults to
``False``.
applyCloudScoreLandsat (bool): Apply cloud score to Landsat.
Defaults to ``False``.
applyCloudScoreSentinel2 (bool): Apply cloud score to Sentinel-2.
Defaults to ``False``.
applyTDOMLandsat (bool): Apply TDOM to Landsat. Defaults to
``True``.
applyTDOMSentinel2 (bool): Apply TDOM to Sentinel-2. Defaults to
``False``.
applyFmaskCloudMask (bool): Apply Fmask cloud mask to Landsat.
Defaults to ``True``.
applyFmaskCloudShadowMask (bool): Apply Fmask shadow mask.
Defaults to ``True``.
applyFmaskSnowMask (bool): Apply Fmask snow mask. Defaults to
``False``.
cloudHeights (ee.List): Cloud heights for shadow projection.
Defaults to ``ee.List.sequence(500, 10000, 500)``.
cloudScoreThresh (int): Cloud score threshold. Defaults to ``20``.
performCloudScoreOffset (bool): Per-scene cloud score offset.
Defaults to ``True``.
cloudScorePctl (int): Percentile for offset. Defaults to ``10``.
zScoreThresh (float): TDOM z-score threshold. Defaults to ``-1``.
shadowSumThresh (float): TDOM shadow sum threshold. Defaults to
``0.35``.
contractPixels (float): Pixels to contract mask. Defaults to
``1.5``.
dilatePixels (float): Pixels to dilate mask. Defaults to ``3.5``.
shadowSumBands (list[str]): Bands for shadow detection. Defaults to
``["nir", "swir1"]``.
landsatResampleMethod (str): Landsat resampling. Defaults to
``"near"``.
sentinel2ResampleMethod (str): S2 resampling. Defaults to
``"aggregate"``.
convertToDailyMosaics (bool): Mosaic same-day images. Defaults to
``True``.
runChastainHarmonization (bool): Apply Chastain et al.
harmonization between Landsat and S2. Forced to ``False`` when
``toaOrSR="SR"``. Defaults to ``True``.
correctIllumination (bool): Reserved. Defaults to ``False``.
correctScale (int): Scale for illumination correction. Defaults to
``250``.
preComputedLandsatCloudScoreOffset (ee.Image | None): Pre-computed
Landsat cloud score offset. Defaults to ``None``.
preComputedLandsatTDOMIRMean (ee.Image | None): Pre-computed
Landsat TDOM mean. Defaults to ``None``.
preComputedLandsatTDOMIRStdDev (ee.Image | None): Pre-computed
Landsat TDOM std dev. Defaults to ``None``.
preComputedSentinel2CloudScoreOffset (ee.Image | None): Pre-computed
S2 cloud score offset. Defaults to ``None``.
preComputedSentinel2TDOMIRMean (ee.Image | None): Pre-computed S2
TDOM mean. Defaults to ``None``.
preComputedSentinel2TDOMIRStdDev (ee.Image | None): Pre-computed S2
TDOM std dev. Defaults to ``None``.
cloudProbThresh (int): Cloud probability threshold. Defaults to
``40``.
landsatCollectionVersion (str): ``"C2"`` for Collection 2.
Defaults to ``"C2"``.
verbose (bool): Print all parameters. Defaults to ``False``.
applyCloudScorePlus (bool): Apply Cloud Score+ to S2. Defaults to
``True``.
cloudScorePlusThresh (float): Cloud Score+ threshold (0-1).
Defaults to ``0.6``.
cloudScorePlusScore (str): ``"cs"`` or ``"cs_cdf"``. Defaults to
``"cs"``.
Returns:
ee.ImageCollection: Merged, cloud/shadow-masked Landsat and
Sentinel-2 scenes with standardized band names (``blue``,
``green``, ``red``, ``nir``, ``swir1``, ``swir2``).
Example:
>>> import geeViz.getImagesLib as gil
>>> study = ee.Geometry.Point([-105.0, 40.0]).buffer(5000)
>>> scenes = gil.getProcessedLandsatAndSentinel2Scenes(
... study, 2023, 2023, 152, 273
... )
>>> print(scenes.size().getInfo() > 0)
True
"""
if toaOrSR == "SR":
runChastainHarmonization = False
origin = "Landsat-Sentinel2-Hybrid"
toaOrSR = toaOrSR.upper()
# Prepare dates
# Wrap the dates if needed
wrapOffset = 0
if startJulian > endJulian:
wrapOffset = 365
startDate = ee.Date.fromYMD(startYear, 1, 1).advance(startJulian - 1, "day")
endDate = ee.Date.fromYMD(endYear, 1, 1).advance(endJulian - 1 + wrapOffset, "day")
args = formatArgs(locals())
if "args" in args.keys():
del args["args"]
print("Get Processed Landsat and Sentinel2 Scenes: ")
print(
"Start date:",
startDate.format("MMM dd yyyy").getInfo(),
", End date:",
endDate.format("MMM dd yyyy").getInfo(),
)
if verbose:
for arg in args.keys():
print(arg, ": ", args[arg])
# Get Landsat
ls = getProcessedLandsatScenes(
studyArea=studyArea,
startYear=startYear,
endYear=endYear,
startJulian=startJulian,
endJulian=endJulian,
toaOrSR=toaOrSR,
includeSLCOffL7=includeSLCOffL7,
defringeL5=defringeL5,
applyCloudScore=applyCloudScoreLandsat,
applyFmaskCloudMask=applyFmaskCloudMask,
applyTDOM=applyTDOMLandsat,
applyFmaskCloudShadowMask=applyFmaskCloudShadowMask,
applyFmaskSnowMask=applyFmaskSnowMask,
cloudScoreThresh=cloudScoreThresh,
performCloudScoreOffset=performCloudScoreOffset,
cloudScorePctl=cloudScorePctl,
zScoreThresh=zScoreThresh,
shadowSumThresh=shadowSumThresh,
contractPixels=contractPixels,
dilatePixels=dilatePixels,
shadowSumBands=shadowSumBands,
resampleMethod=landsatResampleMethod,
# harmonizeOLI = harmonizeOLI,
preComputedCloudScoreOffset=preComputedLandsatCloudScoreOffset,
preComputedTDOMIRMean=preComputedLandsatTDOMIRMean,
preComputedTDOMIRStdDev=preComputedSentinel2TDOMIRStdDev,
landsatCollectionVersion=landsatCollectionVersion,
verbose=False,
)
# Get Sentinel 2
s2s = getProcessedSentinel2Scenes(
studyArea=studyArea,
startYear=startYear,
endYear=endYear,
startJulian=startJulian,
endJulian=endJulian,
applyQABand=applyQABand,
applyCloudScore=applyCloudScoreSentinel2,
applyShadowShift=applyShadowShift,
applyTDOM=applyTDOMSentinel2,
cloudScoreThresh=cloudScoreThresh,
performCloudScoreOffset=performCloudScoreOffset,
cloudScorePctl=cloudScorePctl,
cloudHeights=cloudHeights,
zScoreThresh=zScoreThresh,
shadowSumThresh=shadowSumThresh,
contractPixels=contractPixels,
dilatePixels=dilatePixels,
shadowSumBands=shadowSumBands,
resampleMethod=sentinel2ResampleMethod,
toaOrSR=toaOrSR,
convertToDailyMosaics=convertToDailyMosaics,
applyCloudProbability=applyCloudProbability,
preComputedCloudScoreOffset=preComputedSentinel2CloudScoreOffset,
preComputedTDOMIRMean=preComputedSentinel2TDOMIRMean,
preComputedTDOMIRStdDev=preComputedSentinel2TDOMIRStdDev,
cloudProbThresh=cloudProbThresh,
verbose=False,
applyCloudScorePlus=applyCloudScorePlus,
cloudScorePlusThresh=cloudScorePlusThresh,
cloudScorePlusScore=cloudScorePlusScore,
)
# Select off common bands between Landsat and Sentinel 2
commonBands = ["blue", "green", "red", "nir", "swir1", "swir2", "sensor"]
ls = ls.select(commonBands)
s2s = s2s.select(commonBands)
# Fill in any empty collections
# If they're both empty, this will not work
dummyImage = ee.Image(ee.ImageCollection(ee.Algorithms.If(ls.toList(1).length().gt(0), ls, s2s)).first())
ls = fillEmptyCollections(ls, dummyImage)
s2s = fillEmptyCollections(s2s, dummyImage)
if runChastainHarmonization and toaOrSR == "TOA":
# Separate each sensor
tm = ls.filter(ee.Filter.inList("SENSOR_ID", ["TM", "ETM"]))
oli = ls.filter(ee.Filter.eq("SENSOR_ID", "OLI_TIRS"))
# else:
# tm = ls.filter(ee.Filter.inList('sensor',['LANDSAT_4','LANDSAT_5','LANDSAT_7']))
# oli = ls.filter(ee.Filter.eq('sensor','LANDSAT_8'))
msi = s2s
# Fill if no images exist for particular Landsat sensor
# Allow it to fail of no images exist for Sentinel 2 since the point
# of this method is to include S2
tm = fillEmptyCollections(tm, ee.Image(ls.first()))
oli = fillEmptyCollections(oli, ee.Image(ls.first()))
print("Running Chastain et al 2019 harmonization")
# Apply correction
# Currently coded to go to ETM+
# No need to correct ETM to ETM
# tm = tm.map(function(img){return getImagesLib.harmonizationChastain(img, 'ETM','ETM')})
# etm = etm.map(function(img){return getImagesLib.harmonizationChastain(img, 'ETM','ETM')})
# Harmonize the other two
oli = oli.map(lambda img: harmonizationChastain(img, "OLI", "ETM"))
msi = msi.map(lambda img: harmonizationChastain(img, "MSI", "ETM"))
s2s = msi
# Merge Landsat back together
ls = ee.ImageCollection(tm.merge(oli))
# Merge Landsat and S2
merged = ee.ImageCollection(ls.merge(s2s))
merged = merged.map(simpleAddIndices).map(getTasseledCap).map(simpleAddTCAngles)
merged = merged.set(args)
return merged
#################################################################################
# Function to register an imageCollection to images within it
# Always uses the first image as the reference image
[docs]
def coRegisterCollection(images, referenceBands=["nir"]):
"""Co-register all images in a collection to the first image.
Uses ``ee.Image.displacement`` and ``ee.Image.displace`` to align
every image to the first image in the collection based on the
specified reference bands.
Args:
images (ee.ImageCollection): Collection of images to co-register.
referenceBands (list[str]): Band names used for displacement
matching. Defaults to ``["nir"]``.
Returns:
ee.ImageCollection: Co-registered image collection where the first
image is unchanged and all subsequent images are displaced to
align with it.
Example:
>>> import geeViz.getImagesLib as gil
>>> import ee
>>> imgs = ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED') \\
... .filterBounds(ee.Geometry.Point([-122.0, 37.0])) \\
... .limit(3)
>>> registered = gil.coRegisterCollection(imgs)
"""
referenceImageIndex = 0
referenceImage = ee.Image(images.toList(referenceImageIndex + 1).get(referenceImageIndex)).select(referenceBands)
def registerImage(image):
# Determine the displacement by matching only the referenceBand bands.
displacement_params = {
"referenceImage": referenceImage,
"maxOffset": 20.0,
"projection": None,
"patchWidth": 20.0,
"stiffness": 5,
}
displacement = image.select(referenceBands).displacement(**displacement_params)
return image.displace(displacement)
out = ee.ImageCollection(ee.ImageCollection(images.toList(10000, 1)).map(registerImage)) # (ee.Image(images.toList(10000,0).get(1)),referenceImage)
out = ee.ImageCollection(images.limit(1).merge(out))
return out
#################################################################################
# Function to find a subset of a collection
# For each group (e.g. tile or orbit or path), all images within that group will be registered
# As single collection is returned
[docs]
def coRegisterGroups(imgs, fieldName="SENSING_ORBIT_NUMBER", fieldIsNumeric=True):
"""Co-register images within groups defined by a metadata field.
Splits the collection by unique values of ``fieldName``, co-registers
each group independently using ``coRegisterCollection``, and merges
the results back into a single collection.
Args:
imgs (ee.ImageCollection): Collection of images to co-register.
fieldName (str): Metadata property to group by. Defaults to
``"SENSING_ORBIT_NUMBER"``.
fieldIsNumeric (bool): Whether the field values are numeric
(parsed with ``ee.Number.parse``). Defaults to ``True``.
Returns:
ee.ImageCollection: Co-registered image collection with images
aligned within each group.
Example:
>>> import geeViz.getImagesLib as gil
>>> import ee
>>> s2 = ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED') \\
... .filterBounds(ee.Geometry.Point([-122.0, 37.0])) \\
... .filterDate('2023-06-01', '2023-06-30')
>>> registered = gil.coRegisterGroups(s2)
"""
groups = ee.Dictionary(imgs.aggregate_histogram(fieldName)).keys()
if fieldIsNumeric:
groups = groups.map(lambda n: ee.Number.parse(n))
out = ee.ImageCollection(ee.FeatureCollection(groups.map(lambda group: coRegisterCollection(imgs.filter(ee.Filter.eq(fieldName, group))))).flatten())
return out
#################################################################################
[docs]
def getLandsatAndSentinel2HybridWrapper(
studyArea,
startYear,
endYear,
startJulian,
endJulian,
timebuffer=0,
weights=[1],
compositingMethod="medoid",
toaOrSR="TOA",
includeSLCOffL7=False,
defringeL5=False,
applyQABand=False,
applyCloudProbability=False,
applyShadowShift=False,
applyCloudScoreLandsat=False,
applyCloudScoreSentinel2=False,
applyTDOMLandsat=True,
applyTDOMSentinel2=False,
applyFmaskCloudMask=True,
applyFmaskCloudShadowMask=True,
applyFmaskSnowMask=False,
cloudHeights=ee.List.sequence(500, 10000, 500),
cloudScoreThresh=20,
performCloudScoreOffset=True,
cloudScorePctl=10,
zScoreThresh=-1,
shadowSumThresh=0.35,
contractPixels=1.5,
dilatePixels=3.5,
shadowSumBands=["nir", "swir1"],
landsatResampleMethod="near",
sentinel2ResampleMethod="aggregate",
convertToDailyMosaics=True,
runChastainHarmonization=True,
correctIllumination=False,
correctScale=250,
exportComposites=False,
outputName="Landsat-Sentinel2-Hybrid",
exportPathRoot=None,
crs="EPSG:5070",
transform=[30, 0, -2361915.0, 0, -30, 3177735.0],
scale=None,
preComputedLandsatCloudScoreOffset=None,
preComputedLandsatTDOMIRMean=None,
preComputedLandsatTDOMIRStdDev=None,
preComputedSentinel2CloudScoreOffset=None,
preComputedSentinel2TDOMIRMean=None,
preComputedSentinel2TDOMIRStdDev=None,
cloudProbThresh=40,
landsatCollectionVersion="C2",
overwrite=False,
verbose=False,
applyCloudScorePlusSentinel2=True,
cloudScorePlusThresh=0.6,
cloudScorePlusScore="cs",
):
"""Get annual Landsat + Sentinel-2 hybrid composites.
Wraps ``getProcessedLandsatAndSentinel2Scenes`` to retrieve merged,
cloud-masked Landsat and Sentinel-2 scenes, then composites them into
annual images. Optionally exports composites to an EE asset.
Args:
studyArea (ee.Geometry | ee.FeatureCollection): Area of interest.
startYear (int): First year to include.
endYear (int): Last year to include.
startJulian (int): Start day of year (1-365).
endJulian (int): End day of year (1-365).
timebuffer (int): Years to buffer each composite. Defaults to
``0``.
weights (list[int]): Temporal weights. Defaults to ``[1]``.
compositingMethod (str): ``"medoid"`` or ``"median"``. Defaults
to ``"medoid"``.
toaOrSR (str): ``"TOA"`` or ``"SR"``. Defaults to ``"TOA"``.
includeSLCOffL7 (bool): Include Landsat 7 SLC-off. Defaults to
``False``.
defringeL5 (bool): Defringe Landsat 5. Defaults to ``False``.
applyQABand (bool): Apply QA band mask. Defaults to ``False``.
applyCloudProbability (bool): Apply S2 cloud probability. Defaults
to ``False``.
applyShadowShift (bool): Apply shadow-shift. Defaults to ``False``.
applyCloudScoreLandsat (bool): Cloud score for Landsat. Defaults to
``False``.
applyCloudScoreSentinel2 (bool): Cloud score for S2. Defaults to
``False``.
applyTDOMLandsat (bool): TDOM for Landsat. Defaults to ``True``.
applyTDOMSentinel2 (bool): TDOM for S2. Defaults to ``False``.
applyFmaskCloudMask (bool): Fmask clouds for Landsat. Defaults to
``True``.
applyFmaskCloudShadowMask (bool): Fmask shadows. Defaults to
``True``.
applyFmaskSnowMask (bool): Fmask snow. Defaults to ``False``.
cloudHeights (ee.List): Cloud heights for shadow projection.
Defaults to ``ee.List.sequence(500, 10000, 500)``.
cloudScoreThresh (int): Cloud score threshold. Defaults to ``20``.
performCloudScoreOffset (bool): Per-scene offset. Defaults to
``True``.
cloudScorePctl (int): Offset percentile. Defaults to ``10``.
zScoreThresh (float): TDOM z-score threshold. Defaults to ``-1``.
shadowSumThresh (float): TDOM shadow sum threshold. Defaults to
``0.35``.
contractPixels (float): Contract mask pixels. Defaults to ``1.5``.
dilatePixels (float): Dilate mask pixels. Defaults to ``3.5``.
shadowSumBands (list[str]): Shadow detection bands. Defaults to
``["nir", "swir1"]``.
landsatResampleMethod (str): Landsat resampling. Defaults to
``"near"``.
sentinel2ResampleMethod (str): S2 resampling. Defaults to
``"aggregate"``.
convertToDailyMosaics (bool): Mosaic same-day images. Defaults to
``True``.
runChastainHarmonization (bool): Harmonize Landsat/S2. Defaults to
``True``.
correctIllumination (bool): Reserved. Defaults to ``False``.
correctScale (int): Illumination correction scale. Defaults to
``250``.
exportComposites (bool): Export to EE asset. Defaults to ``False``.
outputName (str): Asset name prefix. Defaults to
``"Landsat-Sentinel2-Hybrid"``.
exportPathRoot (str | None): Asset folder path. Defaults to
``None``.
crs (str): Output CRS. Defaults to ``"EPSG:5070"``.
transform (list): Affine transform. Defaults to
``[30, 0, -2361915.0, 0, -30, 3177735.0]``.
scale (int | None): Output scale (overrides transform). Defaults to
``None``.
preComputedLandsatCloudScoreOffset (ee.Image | None): Defaults to
``None``.
preComputedLandsatTDOMIRMean (ee.Image | None): Defaults to
``None``.
preComputedLandsatTDOMIRStdDev (ee.Image | None): Defaults to
``None``.
preComputedSentinel2CloudScoreOffset (ee.Image | None): Defaults to
``None``.
preComputedSentinel2TDOMIRMean (ee.Image | None): Defaults to
``None``.
preComputedSentinel2TDOMIRStdDev (ee.Image | None): Defaults to
``None``.
cloudProbThresh (int): Cloud probability threshold. Defaults to
``40``.
landsatCollectionVersion (str): Defaults to ``"C2"``.
overwrite (bool): Overwrite existing exports. Defaults to
``False``.
verbose (bool): Print all parameters. Defaults to ``False``.
applyCloudScorePlusSentinel2 (bool): Cloud Score+ for S2. Defaults
to ``True``.
cloudScorePlusThresh (float): Cloud Score+ threshold (0-1).
Defaults to ``0.6``.
cloudScorePlusScore (str): ``"cs"`` or ``"cs_cdf"``. Defaults to
``"cs"``.
Returns:
dict: Dictionary with all input args plus:
- ``"processedScenes"``: ``ee.ImageCollection`` of merged scenes
- ``"processedComposites"``: ``ee.ImageCollection`` of annual
composites
Example:
>>> import geeViz.getImagesLib as gil
>>> study = ee.Geometry.Point([-105.0, 40.0]).buffer(5000)
>>> result = gil.getLandsatAndSentinel2HybridWrapper(
... study, 2023, 2023, 152, 273
... )
>>> composites = result['processedComposites']
>>> print(composites.size().getInfo())
1
"""
origin = "Landsat-Sentinel2-Hybrid"
toaOrSR = toaOrSR.upper()
args = formatArgs(locals())
if "args" in args.keys():
del args["args"]
merged = getProcessedLandsatAndSentinel2Scenes(
studyArea=studyArea,
startYear=startYear,
endYear=endYear,
startJulian=startJulian,
endJulian=endJulian,
toaOrSR=toaOrSR,
includeSLCOffL7=includeSLCOffL7,
defringeL5=defringeL5,
applyQABand=applyQABand,
applyCloudProbability=applyCloudProbability,
applyShadowShift=applyShadowShift,
applyCloudScoreLandsat=applyCloudScoreLandsat,
applyCloudScoreSentinel2=applyCloudScoreSentinel2,
applyTDOMLandsat=applyTDOMLandsat,
applyTDOMSentinel2=applyTDOMSentinel2,
applyFmaskCloudMask=applyFmaskCloudMask,
applyFmaskCloudShadowMask=applyFmaskCloudShadowMask,
applyFmaskSnowMask=applyFmaskSnowMask,
cloudHeights=cloudHeights,
cloudScoreThresh=cloudScoreThresh,
performCloudScoreOffset=performCloudScoreOffset,
cloudScorePctl=cloudScorePctl,
zScoreThresh=zScoreThresh,
shadowSumThresh=shadowSumThresh,
contractPixels=contractPixels,
dilatePixels=dilatePixels,
shadowSumBands=shadowSumBands,
landsatResampleMethod=landsatResampleMethod,
sentinel2ResampleMethod=sentinel2ResampleMethod,
convertToDailyMosaics=convertToDailyMosaics,
runChastainHarmonization=runChastainHarmonization,
correctIllumination=correctIllumination,
correctScale=correctScale,
preComputedLandsatCloudScoreOffset=preComputedLandsatCloudScoreOffset,
preComputedLandsatTDOMIRMean=preComputedLandsatTDOMIRMean,
preComputedLandsatTDOMIRStdDev=preComputedLandsatTDOMIRStdDev,
preComputedSentinel2CloudScoreOffset=preComputedSentinel2CloudScoreOffset,
preComputedSentinel2TDOMIRMean=preComputedSentinel2TDOMIRMean,
preComputedSentinel2TDOMIRStdDev=preComputedSentinel2TDOMIRStdDev,
cloudProbThresh=cloudProbThresh,
landsatCollectionVersion=landsatCollectionVersion,
verbose=verbose,
applyCloudScorePlus=applyCloudScorePlusSentinel2,
cloudScorePlusThresh=cloudScorePlusThresh,
cloudScorePlusScore=cloudScorePlusScore,
)
# Create hybrid composites
composites = compositeTimeSeries(
ls=merged,
startYear=startYear,
endYear=endYear,
startJulian=startJulian,
endJulian=endJulian,
timebuffer=timebuffer,
weights=weights,
compositingMethod=compositingMethod,
)
# Export composite collection
if exportComposites:
exportBandDict = {
"medoid": [
"blue",
"green",
"red",
"nir",
"swir1",
"swir2",
"compositeObsCount",
"sensor",
"year",
"julianDay",
],
"median": [
"blue",
"green",
"red",
"nir",
"swir1",
"swir2",
"compositeObsCount",
],
}
nonDivideBandDict = {
"medoid": ["compositeObsCount", "sensor", "year", "julianDay"],
"median": ["compositeObsCount"],
}
exportBands = exportBandDict[compositingMethod]
nonDivideBands = nonDivideBandDict[compositingMethod]
exportCompositeCollection(
collection=composites,
exportPathRoot=exportPathRoot,
outputName=outputName,
origin=origin,
studyArea=studyArea,
crs=crs,
transform=transform,
scale=scale,
startYear=startYear,
endYear=endYear,
startJulian=startJulian,
endJulian=endJulian,
compositingMethod=compositingMethod,
timebuffer=timebuffer,
toaOrSR=toaOrSR,
nonDivideBands=nonDivideBands,
exportBands=exportBands,
additionalPropertyDict=args,
overwrite=overwrite,
)
args["processedScenes"] = merged
args["processedComposites"] = composites
return args
#########################################################################
#########################################################################
# Harmonic regression
#########################################################################
#########################################################################
# Function to give year.dd image and harmonics list (e.g. [1,2,3,...])
[docs]
def getHarmonicList(yearDateImg, transformBandName, harmonicList):
"""Compute sin and cos harmonic predictor bands for a year-fraction image.
Takes a year.dd date image and a list of harmonic frequencies, and appends
sin and cos bands for each harmonic to the input image.
Args:
yearDateImg (ee.Image): Image containing a band with year-fraction values
(e.g., 2020.5 for mid-year 2020).
transformBandName (str): Name of the band in ``yearDateImg`` that holds
the year-fraction values (e.g., ``"year"``).
harmonicList (list[int]): List of harmonic numbers to generate predictors
for (e.g., ``[1, 2, 3]``). Each value *h* produces
``sin(h * pi * t)`` and ``cos(h * pi * t)`` bands.
Returns:
ee.Image: The input image with additional sin and cos bands appended.
Band names follow the pattern ``sin_<h*100>_<transformBandName>``
and ``cos_<h*100>_<transformBandName>``.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> dateImg = ee.Image(2020.5).rename('year')
>>> result = gil.getHarmonicList(dateImg, 'year', [2])
>>> print(result.bandNames().getInfo())
['year', 'sin_200_year', 'cos_200_year']
"""
t = yearDateImg.select([transformBandName])
selectBands = ee.List.sequence(0, len(harmonicList) - 1)
def sinCat(h):
ht = h * 100
return ee.String("sin_").cat(str(ht)).cat("_").cat(transformBandName)
sinNames = list(map(lambda i: sinCat(i), harmonicList))
def cosCat(h):
ht = h * 100
return ee.String("cos_").cat(str(ht)).cat("_").cat(transformBandName)
cosNames = list(map(lambda i: cosCat(i), harmonicList))
multipliers = ee.Image(harmonicList).multiply(ee.Number(math.pi).float())
sinInd = (t.multiply(ee.Image(multipliers))).sin().select(selectBands, sinNames).float()
cosInd = (t.multiply(ee.Image(multipliers))).cos().select(selectBands, cosNames).float()
return yearDateImg.addBands(sinInd.addBands(cosInd))
#########################################################################
#########################################################################
# Takes a dependent and independent variable and returns the dependent,
# sin of ind, and cos of ind
# Intended for harmonic regression
[docs]
def getHarmonics2(collection, transformBandName, harmonicList, detrend=False):
"""Prepare an ImageCollection with harmonic predictor bands for regression.
Adds sin and cos harmonic predictor bands to each image in the collection,
and stores metadata about dependent and independent band names/numbers on
the returned collection for use by ``newRobustMultipleLinear2``.
Args:
collection (ee.ImageCollection): Input collection where each image has a
year-fraction band (named ``transformBandName``) and one or more
dependent variable bands.
transformBandName (str): Name of the band containing year-fraction values
(e.g., ``"year"``).
harmonicList (list[int]): List of harmonic numbers (e.g., ``[2]`` for
annual, ``[2, 4]`` for annual + semi-annual).
detrend (bool, optional): If True, retains the ``"year"`` band as an
additional linear trend predictor. Defaults to False.
Returns:
ee.ImageCollection: Collection with harmonic bands appended to each image
and metadata properties ``indBandNames``, ``depBandNames``,
``indBandNumbers``, and ``depBandNumbers`` set on the collection.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> composites = ee.ImageCollection('projects/my-project/assets/composites')
>>> withHarmonics = gil.getHarmonics2(composites, 'year', [2])
>>> print(withHarmonics.get('indBandNames').getInfo())
"""
depBandNames = ee.Image(collection.first()).bandNames().remove(transformBandName)
depBandNumbers = depBandNames.map(lambda dbn: depBandNames.indexOf(dbn))
def harmWrap(img):
outT = getHarmonicList(img, transformBandName, harmonicList).copyProperties(img, ["system:time_start", "system:time_end"])
return outT
out = collection.map(harmWrap)
if not detrend:
outBandNames = ee.Image(out.first()).bandNames().removeAll(["year"])
out = out.select(outBandNames)
indBandNames = ee.Image(out.first()).bandNames().removeAll(depBandNames)
indBandNumbers = indBandNames.map(lambda ind: ee.Image(out.first()).bandNames().indexOf(ind))
out = out.set(
{
"indBandNames": indBandNames,
"depBandNames": depBandNames,
"indBandNumbers": indBandNumbers,
"depBandNumbers": depBandNumbers,
}
)
return out
#########################################################################
#########################################################################
# Simplifies the use of the robust linear regression reducer
# Assumes the dependent is the first band and all subsequent bands are independents
[docs]
def newRobustMultipleLinear2(dependentsIndependents):
"""Fit a linear regression model across an ImageCollection with labeled bands.
Applies ``ee.Reducer.linearRegression`` to a collection that has been
prepared by ``getHarmonics2``, returning an image of regression coefficients
for each dependent variable.
Args:
dependentsIndependents (ee.ImageCollection): Collection with metadata
properties ``depBandNames``, ``depBandNumbers``, ``indBandNames``,
and ``indBandNumbers`` set (as produced by ``getHarmonics2``).
Returns:
ee.Image: Image of regression coefficients with bands named
``<dependent>_intercept``, ``<dependent>_<independent>``, etc.
Has metadata properties ``noDependents`` and ``modelLength``.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> composites = ee.ImageCollection("projects/my-project/assets/composites")
>>> withHarmonics = gil.getHarmonics2(composites, "year", [2])
>>> coeffs = gil.newRobustMultipleLinear2(withHarmonics)
>>> print(coeffs.bandNames().getInfo())
"""
# Set up the band names
dependentBands = ee.List(dependentsIndependents.get("depBandNumbers"))
independentBands = ee.List(dependentsIndependents.get("indBandNumbers"))
bns = ee.Image(dependentsIndependents.first()).bandNames()
dependents = ee.List(dependentsIndependents.get("depBandNames"))
independents = ee.List(dependentsIndependents.get("indBandNames"))
# dependent = bns.slice(0,1)
# independents = bns.slice(1,null)
noIndependents = independents.length().add(1)
noDependents = dependents.length()
outNames = ee.List(["intercept"]).cat(independents)
# Add constant band for intercept and reorder for
# syntax: constant, ind1,ind2,ind3,indn,dependent
def forFitFun(img):
out = img.addBands(ee.Image(1).select([0], ["constant"]))
out = out.select(ee.List(["constant", independents]).flatten())
return out.addBands(img.select(dependents))
forFit = dependentsIndependents.map(forFitFun)
# Apply reducer, and convert back to image with respective bandNames
reducerOut = forFit.reduce(ee.Reducer.linearRegression(noIndependents, noDependents))
# // test = forFit.reduce(ee.Reducer.robustLinearRegression(noIndependents,noDependents,0.2))
# // resids = test
# // .select([1],['residuals']).arrayFlatten([dependents]);
# // Map.addLayer(resids,{},'residsImage');
# // Map.addLayer(reducerOut.select([0]),{},'coefficients');
# // Map.addLayer(test.select([1]),{},'tresiduals');
# // Map.addLayer(reducerOut.select([1]),{},'roresiduals');
reducerOut = reducerOut.select([0], ["coefficients"]).arrayTranspose().arrayFlatten([dependents, outNames])
reducerOut = reducerOut.set(
{
"noDependents": ee.Number(noDependents),
"modelLength": ee.Number(noIndependents),
}
)
return reducerOut
#########################################################################
#########################################################################
# Code for finding the date of peak of green
# Also converts it to Julian day, month, and day of month
monthRemap = [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
3,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
4,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
5,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
6,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
7,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
8,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
11,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
12,
]
monthDayRemap = [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
]
julianDay = [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
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,
41,
42,
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,
111,
112,
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,
153,
154,
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,
181,
182,
183,
184,
185,
186,
187,
188,
189,
190,
191,
192,
193,
194,
195,
196,
197,
198,
199,
200,
201,
202,
203,
204,
205,
206,
207,
208,
209,
210,
211,
212,
213,
214,
215,
216,
217,
218,
219,
220,
221,
222,
223,
224,
225,
226,
227,
228,
229,
230,
231,
232,
233,
234,
235,
236,
237,
238,
239,
240,
241,
242,
243,
244,
245,
246,
247,
248,
249,
250,
251,
252,
253,
254,
255,
256,
257,
258,
259,
260,
261,
262,
263,
264,
265,
266,
267,
268,
269,
270,
271,
272,
273,
274,
275,
276,
277,
278,
279,
280,
281,
282,
283,
284,
285,
286,
287,
288,
289,
290,
291,
292,
293,
294,
295,
296,
297,
298,
299,
300,
301,
302,
303,
304,
305,
306,
307,
308,
309,
310,
311,
312,
313,
314,
315,
316,
317,
318,
319,
320,
321,
322,
323,
324,
325,
326,
327,
328,
329,
330,
331,
332,
333,
334,
335,
336,
337,
338,
339,
340,
341,
342,
343,
344,
345,
346,
347,
348,
349,
350,
351,
352,
353,
354,
355,
356,
357,
358,
359,
360,
361,
362,
363,
364,
365,
]
#########################################################################
#########################################################################
# Function for getting the date of the peak of veg vigor- can handle bands negatively correlated to veg in
# changeDirDict dictionary above
[docs]
def getPeakDate(coeffs, peakDirection=1):
"""Compute the Julian day, month, and day-of-month of peak vegetation vigor.
Finds the date within an annual cycle where the first harmonic (sin/cos)
reaches its maximum, accounting for the sign of the peak direction. Uses
module-level lookup tables to convert Julian day to month and day of month.
Args:
coeffs (ee.Image): Two-band image where band 0 is the sin coefficient
and band 1 is the cos coefficient from a harmonic regression.
peakDirection (int, optional): Set to 1 for bands positively correlated
with vegetation vigor, or -1 for negatively correlated bands.
Defaults to 1.
Returns:
ee.Image: Three-band image with bands ``peakJulianDay`` (1--365),
``peakMonth`` (1--12), and ``peakDayOfMonth`` (1--31).
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> coeffs = ee.Image([0.1, -0.2])
>>> peakInfo = gil.getPeakDate(coeffs, peakDirection=1)
>>> print(peakInfo.bandNames().getInfo())
['peakJulianDay', 'peakMonth', 'peakDayOfMonth']
"""
sin = coeffs.select([0])
cos = coeffs.select([1])
# Find where in cycle slope is zero
greenDate = ((sin.divide(cos)).atan()).divide(2 * math.pi).rename(["peakDate"])
greenDateLater = greenDate.add(0.5)
# Check which d1 slope = 0 is the max by predicting out the value
predicted1 = coeffs.select([0]).add(sin.multiply(greenDate.multiply(2 * math.pi).sin())).add(cos.multiply(greenDate.multiply(2 * math.pi).cos())).rename(["predicted"]).multiply(ee.Image.constant(peakDirection)).addBands(greenDate)
predicted2 = coeffs.select([0]).add(sin.multiply(greenDateLater.multiply(2 * math.pi).sin())).add(cos.multiply(greenDateLater.multiply(2 * math.pi).cos())).rename(["predicted"]).multiply(ee.Image.constant(peakDirection)).addBands(greenDateLater)
finalGreenDate = ee.ImageCollection([predicted1, predicted2]).qualityMosaic("predicted").select(["peakDate"]).rename(["peakJulianDay"])
finalGreenDate = finalGreenDate.where(finalGreenDate.lt(0), greenDate.add(1)).multiply(365).int16()
# Convert to month and day of month
greenMonth = finalGreenDate.remap(julianDay, monthRemap).rename(["peakMonth"])
greenMonthDay = finalGreenDate.remap(julianDay, monthDayRemap).rename(["peakDayOfMonth"])
greenStack = finalGreenDate.addBands(greenMonth).addBands(greenMonthDay)
return greenStack
# Map.addLayer(greenStack,{'min':1,'max':12},'greenMonth',False)
#########################################################################
#########################################################################
# Function for getting left sum under the curve for a single growing season
# Takes care of normalization by forcing the min value along the curve 0
# by taking the amplitude as the intercept
# Assumes the sin and cos coeffs are the harmCoeffs
# t0 is the start time (defaults to 0)(min value should be but doesn't have to be 0)
# t1 is the end time (defaults to 1)(max value should be but doesn't have to be 1)
# Example of what this code is doing can be found here:
# http://www.wolframalpha.com/input/?i=integrate+0.15949074923992157+%2B+-0.08287599*sin(2+PI+T)+%2B+-0.11252010613*cos(2+PI+T)++from+0+to+1
[docs]
def getAreaUnderCurve(harmCoeffs, t0=0, t1=1):
"""Compute the definite integral (area under curve) of a single-harmonic model.
Analytically integrates the harmonic function
``amplitude + sin_coeff * sin(2*pi*t) + cos_coeff * cos(2*pi*t)``
between ``t0`` and ``t1``, normalizing so the minimum of the curve is zero.
Args:
harmCoeffs (ee.Image): Two-band image where band 0 is the sin coefficient
and band 1 is the cos coefficient from a harmonic regression.
t0 (float, optional): Start of the integration interval as a fraction
of the year (0 = Jan 1). Defaults to 0.
t1 (float, optional): End of the integration interval as a fraction
of the year (1 = Dec 31). Defaults to 1.
Returns:
ee.Image: Single-band image named ``AUC`` with the area under the curve.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> harmCoeffs = ee.Image([0.05, -0.1])
>>> auc = gil.getAreaUnderCurve(harmCoeffs, t0=0, t1=1)
>>> print(auc.bandNames().getInfo())
['AUC']
"""
# Pull apart the model
amplitude = harmCoeffs.select([1]).hypot(harmCoeffs.select([0]))
intereceptNormalized = amplitude # When making the min 0, the intercept becomes the amplitude (the hypotenuse)
sin = harmCoeffs.select([0])
cos = harmCoeffs.select([1])
# Find the sum from - infinity to 0
sum0 = intereceptNormalized.multiply(t0).subtract(sin.divide(2 * math.pi).multiply(math.sin(2 * math.pi * t0))).add(cos.divide(2 * math.pi).multiply(math.cos(2 * math.pi * t0)))
# Find the sum from - infinity to 1
sum1 = intereceptNormalized.multiply(t1).subtract(sin.divide(2 * math.pi).multiply(math.sin(2 * math.pi * t1))).add(cos.divide(2 * math.pi).multiply(math.cos(2 * math.pi * t1)))
# Find the difference
leftSum = sum1.subtract(sum0).rename(["AUC"])
return leftSum
#########################################################################
#########################################################################
[docs]
def getPhaseAmplitudePeak(coeffs, t0=0, t1=1):
"""Extract phase, amplitude, peak date, and AUC from harmonic regression coefficients.
Parses multi-band harmonic regression coefficients (from
``newRobustMultipleLinear2``) and computes per-dependent-variable amplitude,
phase (0--1 scaled), peak date (Julian day, month, day of month), and area
under the curve.
Args:
coeffs (ee.Image): Harmonic regression coefficient image as returned by
``newRobustMultipleLinear2``, with metadata ``noDependents`` and
``modelLength``.
t0 (float, optional): Start of integration interval for AUC calculation
(fraction of year). Defaults to 0.
t1 (float, optional): End of integration interval for AUC calculation
(fraction of year). Defaults to 1.
Returns:
ee.Image: Multi-band image with bands named
``<dep>_amplitude``, ``<dep>_phase``, ``<dep>_peakJulianDay``,
``<dep>_peakMonth``, ``<dep>_peakDayOfMonth``, and ``<dep>_AUC``
for each dependent variable.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CO"]
>>> result = gil.getLandsatWrapper(studyArea, 2018, 2023, 1, 365)
>>> composites = result["processedComposites"]
>>> coeffs, predicted = gil.getHarmonicCoefficientsAndFit(
... composites, ["NDVI"], whichHarmonics=[2])
>>> pap = gil.getPhaseAmplitudePeak(coeffs)
>>> print(pap.bandNames().getInfo())
"""
# Parse the model
bandNames = coeffs.bandNames()
bandNumber = bandNames.length()
noDependents = ee.Number(coeffs.get("noDependents"))
modelLength = ee.Number(coeffs.get("modelLength"))
interceptBands = ee.List.sequence(0, bandNumber.subtract(1), modelLength)
models = ee.List.sequence(0, noDependents.subtract(1))
def modelGetter(mn):
mn = ee.Number(mn)
return bandNames.slice(mn.multiply(modelLength), mn.multiply(modelLength).add(modelLength))
parsedModel = models.map(modelGetter)
# print('Parsed harmonic regression model',parsedModel)
# Iterate across models to convert to phase, amplitude, and peak
def papGetter(pm):
pm = ee.List(pm)
modelCoeffs = coeffs.select(pm)
intercept = modelCoeffs.select(".*_intercept")
harmCoeffs = modelCoeffs.select(".*_200_year")
outName = ee.String(ee.String(pm.get(1)).split("_").get(0))
sign = ee.Number(ee.Dictionary(changeDirDict).get(outName)).multiply(-1)
amplitude = harmCoeffs.select([1]).hypot(harmCoeffs.select([0])).multiply(2).rename([outName.cat("_amplitude")])
phase = harmCoeffs.select([0]).atan2(harmCoeffs.select([1])).unitScale(-math.pi, math.pi).rename([outName.cat("_phase")])
# Get peak date info
peakDate = getPeakDate(harmCoeffs, sign)
peakDateBandNames = peakDate.bandNames()
peakDateBandNames = peakDateBandNames.map(lambda bn: outName.cat(ee.String("_").cat(ee.String(bn))))
# Get the left sum
leftSum = getAreaUnderCurve(harmCoeffs, t0, t1)
leftSumBandNames = leftSum.bandNames()
leftSumBandNames = leftSumBandNames.map(lambda bn: outName.cat(ee.String("_").cat(ee.String(bn))))
return amplitude.addBands(phase).addBands(peakDate.rename(peakDateBandNames)).addBands(leftSum.rename(leftSumBandNames))
# Convert to an image
phaseAmplitude = parsedModel.map(papGetter)
phaseAmplitude = ee.ImageCollection.fromImages(phaseAmplitude)
phaseAmplitude = ee.Image(collectionToImage(phaseAmplitude)).float().copyProperties(coeffs, ["system:time_start"])
# print('pa',phaseAmplitude);
return phaseAmplitude
#########################################################################
#########################################################################
# Function for applying harmonic regression model to set of predictor sets
[docs]
def newPredict(coeffs, harmonics):
"""Apply harmonic regression coefficients to predict values for each image.
Uses the coefficient image from ``newRobustMultipleLinear2`` and the
harmonic predictor collection from ``getHarmonics2`` to generate predicted
values for each dependent variable at each time step.
Args:
coeffs (ee.Image): Harmonic regression coefficient image as returned by
``newRobustMultipleLinear2``, with metadata ``noDependents`` and
``modelLength``.
harmonics (ee.ImageCollection): Collection with harmonic predictor bands
as returned by ``getHarmonics2``, with metadata ``indBandNames``,
``depBandNames``, ``indBandNumbers``, and ``depBandNumbers``.
Returns:
ee.ImageCollection: Collection where each image contains the original
dependent bands plus ``<dep>_predicted`` bands for each dependent
variable.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> composites = ee.ImageCollection("projects/my-project/assets/composites")
>>> withHarmonics = gil.getHarmonics2(composites, "year", [2])
>>> coeffs = gil.newRobustMultipleLinear2(withHarmonics)
>>> predicted = gil.newPredict(coeffs, withHarmonics)
>>> print(ee.Image(predicted.first()).bandNames().getInfo())
"""
# Parse the model
bandNames = coeffs.bandNames()
bandNumber = bandNames.length()
noDependents = ee.Number(coeffs.get("noDependents"))
modelLength = ee.Number(coeffs.get("modelLength"))
interceptBands = ee.List.sequence(0, bandNumber.subtract(1), modelLength)
timeBand = ee.List(harmonics.get("indBandNames")).get(0)
actualBands = harmonics.get("depBandNumbers")
indBands = harmonics.get("indBandNumbers")
indBandNames = ee.List(harmonics.get("indBandNames"))
depBandNames = ee.List(harmonics.get("depBandNames"))
predictedBandNames = depBandNames.map(lambda depbnms: ee.String(depbnms).cat("_predicted"))
predictedBandNumbers = ee.List.sequence(0, predictedBandNames.length().subtract(1))
models = ee.List.sequence(0, noDependents.subtract(1))
def mnGetter(mn):
mn = ee.Number(mn)
return bandNames.slice(mn.multiply(modelLength), mn.multiply(modelLength).add(modelLength))
parsedModel = models.map(mnGetter)
# print('Parsed harmonic regression model',parsedModel,predictedBandNames)
# Apply parsed model
def predGetter(img):
time = img.select(timeBand)
actual = img.select(actualBands).float()
predictorBands = img.select(indBandNames)
# Iterate across each model for each dependent variable
def pmGetter(pm):
pm = ee.List(pm)
modelCoeffs = coeffs.select(pm)
outName = ee.String(pm.get(1)).cat("_predicted")
intercept = modelCoeffs.select(modelCoeffs.bandNames().slice(0, 1))
others = modelCoeffs.select(modelCoeffs.bandNames().slice(1, None))
predicted = predictorBands.multiply(others).reduce(ee.Reducer.sum()).add(intercept).float()
return predicted.float()
predictedList = parsedModel.map(pmGetter)
# Convert to an image
predictedList = ee.ImageCollection.fromImages(predictedList)
predictedImage = collectionToImage(predictedList).select(predictedBandNumbers, predictedBandNames)
# Set some metadata
out = actual.addBands(predictedImage.float()).copyProperties(img, ["system:time_start", "system:time_end"])
return out
predicted = harmonics.map(predGetter)
predicted = ee.ImageCollection(predicted)
# Map.addLayer(predicted,{},'predicted',False)
return predicted
#########################################################################
#########################################################################
# Function to get a dummy image stack for synthetic time series
[docs]
def getDateStack(startYear, endYear, startJulian, endJulian, frequency):
"""Generate a synthetic date image stack for predicting harmonic time series.
Creates an ImageCollection of dummy images at regular intervals, each
tagged with ``system:time_start`` and containing a ``year`` band with
year-fraction values, plus constant bands for each index in the module-level
``indexNames`` list.
Args:
startYear (int): First year to include.
endYear (int): Last year to include.
startJulian (int): Starting Julian day within each year.
endJulian (int): Ending Julian day within each year.
frequency (int): Step size in days between successive images.
Returns:
ee.ImageCollection: Collection of images with ``year`` (year.dd) and
constant index bands, each with ``system:time_start`` and
``system:time_end`` properties set.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> dateStack = gil.getDateStack(2020, 2023, 1, 365, 32)
>>> print(dateStack.size().getInfo())
"""
years = ee.List.sequence(startYear, endYear)
dates = ee.List.sequence(startJulian, endJulian, frequency)
def yrGetter(yr):
def dGetter(d):
return ee.Date.fromYMD(yr, 1, 1).advance(d, "day")
ds = dates.map(dGetter)
return ds
dateSets = years.map(yrGetter)
l = range(1, len(indexNames) + 1)
l = [i % i for i in l]
c = ee.Image(l).rename(indexNames)
c = c.divide(c)
dateSets = dateSets.flatten()
def dtGetter(dt):
dt = ee.Date(dt)
y = dt.get("year")
d = dt.getFraction("year")
i = ee.Image(y.add(d)).float().select([0], ["year"])
i = c.addBands(i).float().set("system:time_start", dt.millis()).set("system:time_end", dt.advance(frequency, "day").millis())
return i
stack = dateSets.map(dtGetter)
stack = ee.ImageCollection.fromImages(stack)
return stack
#########################################################################
#########################################################################
[docs]
def getHarmonicCoefficientsAndFit(allImages, indexNames, whichHarmonics=[2], detrend=False):
"""Fit harmonic regression to an ImageCollection and return coefficients and fitted values.
Convenience wrapper that chains ``getHarmonics2``, ``newRobustMultipleLinear2``,
and ``newPredict`` to fit a harmonic model to selected bands and return
both the coefficient image and the predicted time series.
Args:
allImages (ee.ImageCollection): Input image collection containing the
bands to model.
indexNames (list[str]): List of band names to fit harmonics to
(e.g., ``["NDVI", "NBR"]``).
whichHarmonics (list[int], optional): Harmonic numbers to include
(e.g., ``[2]`` for annual, ``[2, 4]`` for annual + semi-annual).
Defaults to ``[2]``.
detrend (bool, optional): If True, includes a linear year trend term
in the regression. Defaults to False.
Returns:
list: Two-element list ``[coeffs, predicted]`` where:
- ``coeffs`` (ee.Image): Regression coefficients image.
- ``predicted`` (ee.ImageCollection): Collection with actual and
predicted bands.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CO"]
>>> result = gil.getLandsatWrapper(studyArea, 2018, 2023, 1, 365)
>>> composites = result["processedComposites"]
>>> coeffs, predicted = gil.getHarmonicCoefficientsAndFit(
... composites, ["NDVI"], whichHarmonics=[2])
"""
# Select desired bands
allIndices = allImages.select(indexNames)
# Add date band
if detrend:
allIndices = allIndices.map(addDateBand)
else:
allIndices = allIndices.map(addYearFractionBand)
# Add independent predictors (harmonics)
withHarmonics = getHarmonics2(allIndices, "year", whichHarmonics, detrend)
withHarmonicsBns = ee.Image(withHarmonics.first()).bandNames().slice(len(indexNames) + 1, None)
# Optionally chart the collection with harmonics
# Fit a linear regression model
coeffs = newRobustMultipleLinear2(withHarmonics)
# Can visualize the phase and amplitude if only the first ([2]) harmonic is chosen
# if whichHarmonics == 2{
# pa = getPhaseAmplitude(coeffs);
# // Turn the HSV data into an RGB image and add it to the map.
# seasonality = pa.select([1,0]).addBands(allIndices.select([indexNames[0]]).mean()).hsvToRgb();
# // Map.addLayer(seasonality, {}, 'Seasonality');
# }
# Map.addLayer(coeffs,{},'Harmonic Regression Coefficients',False)
predicted = newPredict(coeffs, withHarmonics)
return [coeffs, predicted]
#########################################################################
#########################################################################
# Simple predict function for harmonic coefficients
# Expects coeffs from getHarmonicCoefficientsAndFit function
# Date image is expected to be yyyy.dd where dd is the day of year / 365 (proportion of year)
# ex. synthImage(coeffs,ee.Image([2019.6]),['blue','green','red','nir','swir1','swir2','NBR','NDVI'],[2,4],true)
[docs]
def synthImage(coeffs, dateImage, indexNames, harmonics, detrend):
"""Predict band values at a single date using harmonic regression coefficients.
Builds a predictor image from a date value (year.dd format) and the specified
harmonics, then multiplies by the regression coefficients to produce a
synthetic image for each band.
Args:
coeffs (ee.Image): Harmonic regression coefficient image as returned by
``getHarmonicCoefficientsAndFit`` (first element of the returned list).
dateImage (ee.Image): Single-band image with year-fraction values
(e.g., ``ee.Image(2021.5)``).
indexNames (list[str]): List of band names that were modeled
(e.g., ``["NDVI", "NBR"]``).
harmonics (list[int]): Harmonic numbers used when fitting the model
(e.g., ``[2]`` or ``[2, 4]``).
detrend (bool): Whether a linear trend term was included in the model.
Returns:
ee.Image: Predicted image with one band per index name.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CO"]
>>> result = gil.getLandsatWrapper(studyArea, 2018, 2023, 1, 365)
>>> composites = result["processedComposites"]
>>> coeffs, _ = gil.getHarmonicCoefficientsAndFit(
... composites, ["NDVI", "NBR"], whichHarmonics=[2])
>>> synth = gil.synthImage(coeffs, ee.Image(2022.5), ["NDVI", "NBR"], [2], False)
>>> print(synth.bandNames().getInfo())
['NDVI', 'NBR']
"""
# Set up constant image to multiply coeffs by
constImage = ee.Image(1)
if detrend:
constImage = constImage.addBands(dateImage)
for harm in harmonics:
constImage = constImage.addBands(ee.Image([dateImage.multiply(harm * math.pi).sin()]).rename(["{}_sin".format(harm * 100)]))
for harm in harmonics:
constImage = constImage.addBands(ee.Image([dateImage.multiply(harm * math.pi).cos()]).rename(["{}_cos".format(harm * 100)]))
# coeffssBn = coeffs.select(ee.String(indexNames[0]).cat('_.*'))
# print(constImage.bandNames().getInfo(),coeffssBn.bandNames().getInfo())
# Predict values for each band
out = ee.Image(1)
def predictWrapper(bn, out):
bn = ee.String(bn)
# Select coeffs for that band
coeffssBn = coeffs.select(ee.String(bn).cat("_.*"))
predicted = constImage.multiply(coeffssBn).reduce("sum").rename(bn)
return ee.Image(out).addBands(predicted)
out = ee.Image(ee.List(indexNames).iterate(predictWrapper, out))
out = out.select(ee.List.sequence(1, out.bandNames().size().subtract(1)))
return out
#####################################################################
# Wrapper function to get climate data
# Supports:
# NASA/ORNL/DAYMET_V3
# NASA/ORNL/DAYMET_V4
# UCSB-CHG/CHIRPS/DAILY (precipitation only)
# and possibly others
[docs]
def getClimateWrapper(
collectionName: str,
studyArea: ee.Geometry | ee.Feature | ee.FeatureCollection,
startYear: int,
endYear: int,
startJulian: int,
endJulian: int,
timebuffer: int = 0,
weights: ee.List | list | None = None,
compositingReducer: ee.Reducer | None = None,
exportComposites: bool = False,
exportPathRoot: str | None = None,
crs: str | None = None,
transform: list[int] | None = None,
scale: int | None = None,
exportBands: ee.List | list | None = None,
exportNamePrefix: str = "",
exportToAssets: bool = False,
exportToCloud: bool = False,
bucket: str = "",
) -> ee.ImageCollection:
"""
Wrapper function to retrieve and process climate data from various Earth Engine collections.
This function supports retrieving climate data from collections like NASA/ORNL/DAYMET_V3,
NASA/ORNL/DAYMET_V4, UCSB-CHG/CHIRPS/DAILY (precipitation only), and potentially others. It allows
filtering by date, study area, and Julian day, specifying a compositing reducer, and optionally
exporting the resulting time series.
Args:
collectionName (str): Name of the Earth Engine collection containing climate data.
studyArea (ee.Geometry | ee.Feature | ee.FeatureCollection): The geographic area of interest (study area) as an Earth Engine geometry object.
startYear (int): The starting year for the data collection.
endYear (int): The ending year for the data collection.
startJulian (int): The starting Julian day of year for the data collection (1-365).
endJulian (int): The ending Julian day of year for the data collection (1-365).
timebuffer (int, optional): Number of years to buffer around each year. Defaults to 0.
weights (ee.List | list| None, optional): List of weights for weighted compositing (if applicable
to the chosen collection). Defaults to None (equal weights).
compositingReducer (ee.Reducer | None, optional): Earth Engine reducer used for compositing
daily data into the desired temporal resolution. Defaults to None (may require a reducer
depending on the collection).
exportComposites (bool, optional): Flag indicating whether to export the resulting time series.
Defaults to False.
exportPathRoot (str | None, optional): Root path for exporting the composites (if exportComposites
is True). Defaults to None (no export).
crs (str | None, optional): Earth Engine projection object for the exported composites
(if exportComposites is True). Defaults to None (uses the source collection's projection).
transform (list[int] | None, optional): Earth Engine transform object for the exported
composites (if exportComposites is True). Defaults to None (uses the source collection's transform).
scale (int | None, optional): Scale in meters for the exported composites (if exportComposites
is True). Defaults to None (uses the source collection's scale).
exportBands (ee.List | list | None, optional): List of band names to export from the composites (if
exportComposites is True). Defaults to None (all bands from the first image in the collection).
exportNamePrefix (str, optional): Name to place before default name of exported image. Defaults to ''.
exportToAssets (bool, optional): Set to True to export images to earth engine assets. Defaults to False.
exportToCloud (bool, optional): Set to True to export images to Google Cloud Storage. Defaults to False.
bucket (str, optional): If exportToCloud is True, images are exported to this Google Cloud storage bucket. Defaults to '', but will need to be provided if `exportToCloud` is `True`.
Returns:
ee.ImageCollection: The time series collection of processed climate data.
>>> import geeViz.getImagesLib as gil
>>> Map = gil.Map
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CO"]
>>> startJulian = 274
>>> endJulian = 273
>>> startYear = 2016
>>> endYear = 2023
>>> timebuffer = 0
>>> weights = [1]
>>> compositingReducer = ee.Reducer.mean()
>>> collectionName = "NASA/ORNL/DAYMET_V4"
>>> exportComposites = False
>>> exportPathRoot = "users/username/someCollection"
>>> exportBands = ["prcp.*", "tmax.*", "tmin.*"]
>>> exportNamePrefix = 'Colorado_Test_Area_'
>>> crs = "EPSG:5070"
>>> transform = [1000, 0, -2361915.0, 0, -1000, 3177735.0]
>>> scale = None
>>> climateComposites = gil.getClimateWrapper(collectionName, studyArea, startYear, endYear, startJulian, endJulian, timebuffer, weights, compositingReducer, exportComposites, exportPathRoot, crs, transform, scale, exportBands, exportNamePrefix,exportToAssets,exportToCloud,bucket)
>>> Map.addTimeLapse(climateComposites.select(exportBands), {}, "Climate Composite Time Lapse")
>>> Map.addLayer(studyArea, {"strokeColor": "0000FF", "canQuery": False}, "Study Area", True)
>>> Map.centerObject(studyArea)
>>> Map.turnOnInspector()
>>> Map.view()
"""
args = formatArgs(locals())
if "args" in args.keys():
del args["args"]
print(args)
# Prepare dates
# Wrap the dates if needed
wrapOffset = 0
if startJulian > endJulian:
wrapOffset = 365
startDate = ee.Date.fromYMD(startYear, 1, 1).advance(startJulian - 1, "day")
endDate = ee.Date.fromYMD(endYear, 1, 1).advance(endJulian - 1 + wrapOffset, "day")
print(
"Start and end dates:",
startDate.format("YYYY-MM-dd").getInfo(),
endDate.format("YYYY-MM-dd").getInfo(),
)
print("Julian days are:", startJulian, endJulian)
# Get climate data
c = ee.ImageCollection(collectionName).filterBounds(studyArea).filterDate(startDate, endDate.advance(1, "day")).filter(ee.Filter.calendarRange(startJulian, endJulian))
# Set to appropriate resampling method
c = c.map(lambda img: img.resample("bicubic"))
Map.addLayer(c, {}, "Raw Climate", False)
# Create composite time series
ts = compositeTimeSeries(
c,
startYear,
endYear,
startJulian,
endJulian,
timebuffer,
weights,
None,
compositingReducer,
)
ts = ee.ImageCollection(ts.map(lambda i: i.float()))
# Export composite collection
if exportComposites:
# Set up export bands if not specified
if exportBands == None:
exportBands = ee.List(ee.Image(ts.first()).bandNames())
exportCollection(
exportPathRoot, f'{exportNamePrefix}{collectionName.split("/")[-1]}', studyArea, crs, transform, scale, ts, startYear, endYear, startJulian, endJulian, compositingReducer, timebuffer, exportBands, exportToAssets, exportToCloud, bucket
)
return ts
#####################################################################
# Adds absolute difference from a specified band summarized by a provided percentile
# Intended for custom sorting across collections
[docs]
def addAbsDiff(inCollection, qualityBand, percentile, sign):
"""Add a band with the absolute difference from a percentile-based quality target.
Computes the specified percentile of a quality band across the collection,
then adds a ``delta`` band to each image representing the absolute difference
from that percentile value, multiplied by the given sign.
Args:
inCollection (ee.ImageCollection): Input image collection.
qualityBand (str): Name of the band to compute the percentile on.
percentile (int | float): Percentile value (0--100) to use as the
target quality.
sign (int): Multiplier for the delta band, typically ``-1`` to invert
for use with ``qualityMosaic`` (which selects the max).
Returns:
ee.ImageCollection: Collection with an added ``delta`` band on each image.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> collection = ee.ImageCollection("LANDSAT/LC09/C02/T1_L2").limit(10)
>>> withDelta = gil.addAbsDiff(collection, "SR_B4", 50, -1)
>>> print(ee.Image(withDelta.first()).bandNames().getInfo())
"""
bestQuality = inCollection.select([qualityBand]).reduce(ee.Reducer.percentile([percentile]))
def w(image):
delta = image.select([qualityBand]).subtract(bestQuality).abs().multiply(sign)
return image.addBands(delta.select([0], ["delta"]))
out = inCollection.map(w)
return out
#####################################################################
# Method for applying the qualityMosaic function using a specified percentile
# Useful when the max of the quality band is not what is wanted
[docs]
def customQualityMosaic(inCollection, qualityBand, percentile):
"""Create a quality mosaic using a specified percentile rather than the max.
Wraps ``addAbsDiff`` and ``ee.ImageCollection.qualityMosaic`` to select
pixels closest to the given percentile of a quality band, rather than
always selecting the maximum.
Args:
inCollection (ee.ImageCollection): Input image collection to mosaic.
qualityBand (str): Name of the band to use for quality ranking.
percentile (int | float): Target percentile (0--100). The pixel closest
to this percentile value of ``qualityBand`` is selected.
Returns:
ee.Image: Mosaic image where each pixel is chosen from the image whose
quality band value is closest to the specified percentile.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> collection = ee.ImageCollection("LANDSAT/LC09/C02/T1_L2").limit(20)
>>> mosaic = gil.customQualityMosaic(collection, "SR_B4", 50)
>>> print(mosaic.bandNames().getInfo())
"""
# Add an absolute difference from the specified percentile
# This is inverted for the qualityMosaic function to properly prioritize
inCollectionDelta = addAbsDiff(inCollection, qualityBand, percentile, -1)
# Apply the qualityMosaic function
return inCollectionDelta.qualityMosaic("delta")
#####################################################################
[docs]
def simpleWaterMask(
img: ee.Image,
contractPixels: int = 0,
slope_thresh: float = 10,
elevationImagePath: str | ee.Image | ee.ImageCollection = "USGS/3DEP/10m",
elevationFocalMeanRadius: float = 5.5,
) -> ee.Image:
"""
Performs a basic on-the-fly water masking for TOA reflectance imagery.
This function creates a water mask based on thresholds applied to Tasseled Cap angles, brightness, and slope.
It's designed for time-sensitive analysis and works well when wet snow is absent. However, wet snow in flat areas
can lead to false positives. SR data might cause false negatives (omissions).
Args:
img (ee.Image): The input Earth Engine image (TOA reflectance data recommended) with Tasseled Cap transformation bands added. You may need to run `getTasseledCap` to add these bands.
contractPixels (int, optional): Number of pixels to contract the water mask by for morphological closing. Defaults to 0 (no contraction).
slope_thresh (float, optional): Threshold for slope (degrees) to identify flat areas suitable for water masking. Defaults to 10.
elevationImagePath (str or ee.Image or ee.ImageCollection, optional): Path to the Earth Engine image or Earth Engine image or imageCollection object containing elevation data. Defaults to "USGS/3DEP/10m" (10m DEM from USGS 3D Elevation Program).
elevationFocalMeanRadius (float, optional): Radius (in pixels) for the focal mean filter applied to the elevation data before calculating slope. Defaults to 5.5.
Returns:
ee.Image: The water mask image with a single band named "waterMask".
>>> import geeViz.getImagesLib as gil
>>> Map = gil.Map
>>> ee = gil.ee
>>> studyArea = gil.testAreas["CO"]
>>> s2s = gil.superSimpleGetS2(studyArea, "2024-01-01", "2024-12-31", 190, 250).map(lambda img: gil.getTasseledCap(img.resample("bicubic").divide(10000)))
>>> median_composite = s2s.median()
>>> water = gil.simpleWaterMask(median_composite).rename("Water")
>>> water = water.selfMask().set({"Water_class_values": [1], "Water_class_names": ["Water"], "Water_class_palette": ["0000DD"]})
>>> Map.addLayer(median_composite.reproject("EPSG:32613", None, 10), gil.vizParamsFalse, "Sentinel-2 Median Composite")
>>> Map.addLayer(water.reproject("EPSG:32613", None, 10), {"autoViz": True}, "Water Mask")
>>> Map.addLayer(studyArea, {"canQuery": False}, "Study Area")
>>> Map.centerObject(studyArea, 12)
>>> Map.turnOnInspector()
>>> Map.view()
"""
# Add Tasseled Cap angles to the image
img = addTCAngles(img)
# Load and resample elevation data
# Handle different data types
edType = type(elevationImagePath).__name__
ed = ee.Image(elevationImagePath) if edType == "str" else (ee.ImageCollection(elevationImagePath).mosaic() if edType == "ImageCollection" else elevationImagePath)
ed = ee.Image(ed).resample("bicubic")
# Calculate slope using focal mean filtered elevation
slope = ee.Terrain.slope(ed.focal_mean(elevationFocalMeanRadius))
# Identify flat areas based on slope threshold
flat = slope.lte(slope_thresh) # Less than or equal to threshold
# Define water mask conditions based on Tasseled Cap angles, brightness, and slope
waterMask = img.select(["tcAngleBW"]).gte(-0.05).And(img.select(["tcAngleBG"]).lte(0.05)).And(img.select(["brightness"]).lt(0.3)).And(flat)
# Apply morphological closing with focal minimum filter
waterMask = waterMask.focal_min(contractPixels)
# Rename the water mask band
return waterMask.rename(["waterMask"])
####################
# Jeff Ho Method for algal bloom detection
# https://www.nature.com/articles/s41586-019-1648-7
# Simplified Script for Landsat Water Quality
# Produces a map of an algal bloom in Lake Erie on 2011/9/3
# Created on 12/7/2015 by Jeff Ho
# Specifies a threshold for hue to estimate "green" pixels
# this is used as an additional filter to refine the algorithm above
[docs]
def HoCalcGreenness(img):
"""Compute the hue component for algal bloom greenness detection.
Implements a hue calculation from RGB bands to help distinguish algal bloom
pixels from suspended sediment. Pixels with hue below 1.6 are more likely
to be algal blooms. Based on the Jeff Ho method for water quality analysis.
Args:
img (ee.Image): Input image with ``red``, ``green``, and ``blue`` bands.
Returns:
ee.Image: Single-band image named ``H`` containing the hue values.
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> img = ee.Image("LANDSAT/LC09/C02/T1_TOA/LC09_017031_20220601")
>>> hue = gil.HoCalcGreenness(img.select(["B4", "B3", "B2"], ["red", "green", "blue"]))
>>> print(hue.bandNames().getInfo())
['H']
"""
# map r, g, and b for more readable algebra below
r = img.select(["red"])
g = img.select(["green"])
b = img.select(["blue"])
# calculate intensity, hue
I = r.add(g).add(b).rename(["I"])
mins = r.min(g).min(b).rename(["mins"])
H = mins.where(mins.eq(r), (b.subtract(r)).divide(I.subtract(r.multiply(3))).add(1))
H = H.where(mins.eq(g), (r.subtract(g)).divide(I.subtract(g.multiply(3))).add(2))
H = H.where(mins.eq(b), (g.subtract(b)).divide(I.subtract(b.multiply(3))))
# pixels with hue below 1.6 more likely to be bloom and not suspended sediment
Hthresh = H.lte(1.6)
return H.rename("H")
# Apply bloom detection algorithm
[docs]
def HoCalcAlgorithm1(image):
"""Apply the Ho et al. algal bloom detection algorithm to a satellite image.
Implements Algorithm 1 from Wang & Shi (2007) for detecting algal blooms
in water bodies using NIR-SWIR atmospheric correction. Adds a hue-based
greenness filter to distinguish blooms from suspended sediment.
Reference:
Wang, M., & Shi, W. (2007). The NIR-SWIR combined atmospheric
correction approach for MODIS ocean color data processing.
Optics Express, 15(24), 15722-15733.
Args:
image (ee.Image): Input image with ``red``, ``green``, ``blue``,
``nir``, and ``swir1`` bands.
Returns:
ee.Image: The input image with additional bands: ``H`` (hue from
``HoCalcGreenness``), ``bloom1`` (bloom intensity), and
``bloom1_mask`` (binary bloom classification).
Examples:
>>> import geeViz.getImagesLib as gil
>>> ee = gil.ee
>>> img = ee.Image("LANDSAT/LC09/C02/T1_TOA/LC09_017031_20220601")
>>> bands = img.select(["B4", "B3", "B2", "B5", "B6"], ["red", "green", "blue", "nir", "swir1"])
>>> result = gil.HoCalcAlgorithm1(bands)
>>> print(result.bandNames().getInfo())
"""
truecolor = 1 # show true color image as well
testThresh = False # add a binary image classifying into "bloom"and "non-bloom
bloomThreshold = 0.02346 # threshold for classification fit based on other data
greenessThreshold = 1.6
# Algorithm 1 based on:
# Wang, M., & Shi, W. (2007). The NIR-SWIR combined atmospheric
# correction approach for MODIS ocean color data processing.
# Optics Express, 15(24), 15722–15733.
# Add secondary filter using greenness function below
image = image.addBands(HoCalcGreenness(image))
# Apply algorithm 1: B4 - 1.03*B5
# bloom1 = image.select('nir').subtract(image.select('swir1').multiply(1.03)).rename('bloom1')
# Get binary image by applying the threshold
bloom1_mask = image.select("H").lte(greenessThreshold).rename(["bloom1_mask"])
return image.addBands(bloom1).addBands(bloom1_mask)
# //////////////////////////////////////////////////////////////////////////
# // END FUNCTIONS
# ////////////////////////////////////////////////////////////////////////////////
testAreas = {}
testAreas["CO"] = ee.Geometry.Polygon(
[
[
[-108.28630509064759, 38.085343638120925],
[-108.28630509064759, 37.18051220092945],
[-106.74821915314759, 37.18051220092945],
[-106.74821915314759, 38.085343638120925],
]
],
None,
False,
)
testAreas["CO_North"] = ee.Geometry.Polygon(
[
[
[-106.41977869339524, 40.97947702393234],
[-106.41977869339524, 39.96406321814001],
[-105.20578943558274, 39.96406321814001],
[-105.20578943558274, 40.97947702393234],
]
],
None,
False,
)
testAreas["CA"] = ee.Geometry.Polygon(
[
[
[-119.96383760287506, 37.138150574108714],
[-119.96383760287506, 36.40774412106424],
[-117.95333955600006, 36.40774412106424],
[-117.95333955600006, 37.138150574108714],
]
],
None,
False,
)
testAreas["CA_Small"] = ee.Geometry.Polygon(
[
[
[-123.22566968374625, 39.677209599269155],
[-123.22566968374625, 38.993179504697586],
[-122.60494214468375, 38.993179504697586],
[-122.60494214468375, 39.677209599269155],
]
],
None,
False,
)
testAreas["HI"] = ee.Geometry.Polygon(
[
[
[-160.50824874450544, 22.659814513909474],
[-160.50824874450544, 18.54750309959827],
[-154.35590499450544, 18.54750309959827],
[-154.35590499450544, 22.659814513909474],
]
],
None,
False,
)