Source code for geeViz.getImagesLib

"""
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
[docs] def formatArgs(args: dict) -> dict: """Formats arguments as strings for setting as Earth Engine image properties. Converts booleans, lists, dicts, and None values to their string representations. Strings and ints are kept as-is. Other types are omitted. Args: args (dict): A dictionary of arguments to format. Returns: dict: A dictionary with values converted to strings or kept as str/int. Examples: >>> formatted = formatArgs({"threshold": 0.5, "apply": True, "bands": ["nir", "swir1"]}) >>> print(formatted) {'apply': 'True', 'bands': "['nir', 'swir1']"} """ formattedArgs = {} for key in args.keys(): if type(args[key]) in [bool, list, dict, type(None)]: formattedArgs[key] = str(args[key]) elif type(args[key]) in [str, int]: formattedArgs[key] = args[key] return formattedArgs
###################################################################### ###################################################################### # 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, )