RadGEEToolbox 1.6.7__py3-none-any.whl → 1.6.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -151,6 +151,12 @@ class Sentinel2Collection:
151
151
  self.gypsum_threshold = -1
152
152
  self.turbidity_threshold = -1
153
153
  self.chlorophyll_threshold = 0.5
154
+ self.ndsi_threshold = -1
155
+ self.evi_threshold = -1
156
+ self.savi_threshold = -1.5
157
+ self.msavi_threshold = -1
158
+ self.ndmi_threshold = -1
159
+ self.nbr_threshold = -1
154
160
 
155
161
  self._geometry_masked_collection = None
156
162
  self._geometry_masked_out_collection = None
@@ -164,10 +170,17 @@ class Sentinel2Collection:
164
170
  self._ndwi = None
165
171
  self._mndwi = None
166
172
  self._ndvi = None
173
+ self._ndsi = None
174
+ self._evi = None
175
+ self._savi = None
176
+ self._msavi = None
177
+ self._ndmi = None
178
+ self._nbr = None
167
179
  self._halite = None
168
180
  self._gypsum = None
169
181
  self._turbidity = None
170
182
  self._chlorophyll = None
183
+ self._albedo = None
171
184
  self._MosaicByDate = None
172
185
  self._PixelAreaSumCollection = None
173
186
  self._Reflectance = None
@@ -252,6 +265,35 @@ class Sentinel2Collection:
252
265
  ) # subsets the image to just water pixels, 0.2 threshold for datasets
253
266
  return vegetation
254
267
 
268
+ @staticmethod
269
+ def sentinel_broadband_albedo_fn(image, snow_free=True):
270
+ """
271
+ Calculates broadband albedo for Sentinel-2 images, based on Li et al., 2018
272
+ (https://doi.org/10.1016/j.rse.2018.08.025) for snow-free or snow-included conditions.
273
+
274
+ Args:
275
+ image (ee.Image): input ee.Image
276
+ snow_free (bool): If True, calculates albedo for snow-free conditions. If False, calculates for snow-included conditions.
277
+
278
+ Returns:
279
+ ee.Image: broadband albedo ee.Image
280
+
281
+ Raises:
282
+ ValueError: if snow_free argument is not True or False
283
+ """
284
+ # Conversion using Li et al., 2018 as reference
285
+ MSI_expression_snow_free = '0.2688*b("B2") + 0.0362*b("B3") + 0.1501*b("B4") + 0.3045*b("B8A") + 0.1644*b("B11") + 0.0356*b("B12") - 0.0049'
286
+ MSI_expression_snow_included = '-0.1992*b("B2") + 2.3002*b("B3") + -1.9121*b("B4") + 0.6715*b("B8A") - 2.2728*b("B11") + 1.9341*b("B12") - 0.0001'
287
+ # If spacecraft is Landsat 5 TM, use the correct expression,
288
+ # otherwise treat as OLI and copy properties after renaming band to "albedo"
289
+ if snow_free == True:
290
+ albedo = image.expression(MSI_expression_snow_free).rename("albedo").copyProperties(image)
291
+ elif snow_free == False:
292
+ albedo = image.expression(MSI_expression_snow_included).rename("albedo").copyProperties(image)
293
+ else:
294
+ raise ValueError("snow_free argument must be True or False")
295
+ return albedo
296
+
255
297
  @staticmethod
256
298
  def sentinel_halite_fn(image, threshold):
257
299
  """
@@ -329,6 +371,113 @@ class Sentinel2Collection:
329
371
  .copyProperties(image)
330
372
  )
331
373
  return chlorophyll
374
+
375
+ @staticmethod
376
+ def sentinel_ndsi_fn(image, threshold):
377
+ """
378
+ Calculates the Normalized Difference Snow Index (NDSI) for Sentinel2 images. Masks image based on threshold.
379
+
380
+ Args:
381
+ image (ee.Image): input ee.Image
382
+ threshold (float): value between -1 and 1 where NDSI pixels greater than threshold will be masked.
383
+ Returns:
384
+ ee.Image: NDSI ee.Image
385
+ """
386
+ ndsi_calc = image.normalizedDifference(["B3", "B11"])
387
+ ndsi = ndsi_calc.updateMask(ndsi_calc.gte(threshold)).rename("ndsi").copyProperties(image).set("threshold", threshold)
388
+ return ndsi
389
+
390
+ @staticmethod
391
+ def sentinel_evi_fn(image, threshold, gain_factor=2.5, l=1, c1=6, c2=7.5):
392
+ """
393
+ Calculates the Enhanced Vegetation Index (EVI) for Sentinel-2 images. Masks image based on threshold.
394
+
395
+ Args:
396
+ image (ee.Image): input ee.Image
397
+ threshold (float): value between -1 and 1 where EVI pixels less than threshold will be masked.
398
+ gain_factor (float, optional): Gain factor, typically set to 2.5. Defaults to 2.5.
399
+ l (float, optional): Canopy background adjustment factor, typically set to 1. Defaults to 1.
400
+ c1 (float, optional): Coefficient for the aerosol resistance term, typically set to 6. Defaults to 6.
401
+ c2 (float, optional): Coefficient for the aerosol resistance term, typically set to 7.5. Defaults to 7.5.
402
+
403
+ Returns:
404
+ ee.Image: EVI ee.Image
405
+ """
406
+ evi_expression = f'{gain_factor} * ((b("B8") - b("B4")) / (b("B8") + {c1} * b("B4") - {c2} * b("B2") + {l}))'
407
+ evi_calc = image.expression(evi_expression)
408
+ evi = evi_calc.updateMask(evi_calc.gte(threshold)).rename("evi").copyProperties(image).set("threshold", threshold)
409
+ return evi
410
+
411
+ @staticmethod
412
+ def sentinel_savi_fn(image, threshold, l=0.5):
413
+ """
414
+ Calculates the Soil-Adjusted Vegetation Index (SAVI) for Sentinel2 images. Masks image based on threshold.
415
+ See Huete, 1988 - https://doi.org/10.1016/0034-4257(88)90106-X
416
+
417
+ Args:
418
+ image (ee.Image): input ee.Image
419
+ threshold (float): value between -1 and 1 where SAVI pixels less than threshold will be masked.
420
+ l (float, optional): Soil brightness correction factor, typically set to 0.5. Defaults to 0.5.
421
+ Returns:
422
+ ee.Image: SAVI ee.Image
423
+ """
424
+ savi_expression = f'((b("B8") - b("B4")) / (b("B8") + b("B4") + {l})) * (1 + {l})'
425
+ savi_calc = image.expression(savi_expression)
426
+ savi = savi_calc.updateMask(savi_calc.gte(threshold)).rename("savi").copyProperties(image).set("threshold", threshold)
427
+ return savi
428
+
429
+ @staticmethod
430
+ def sentinel_msavi_fn(image, threshold):
431
+ """
432
+ Calculates the Modified Soil-Adjusted Vegetation Index (MSAVI) for Sentinel-2 images. Masks image based on threshold.
433
+ See Qi et al., 1994 - https://doi.org/10.1016/0034-4257(94)90134-1
434
+
435
+ Args:
436
+ image (ee.Image): input ee.Image
437
+ threshold (float): value between -1 and 1 where MSAVI pixels less than threshold will be masked
438
+
439
+ Returns:
440
+ ee.Image: MSAVI ee.Image
441
+ """
442
+ msavi_expression = '0.5 * (2 * b("B8") + 1 - ((2 * b("B8") + 1) ** 2 - 8 * (b("B8") - b("B4"))) ** 0.5)'
443
+ msavi_calc = image.expression(msavi_expression)
444
+ msavi = msavi_calc.updateMask(msavi_calc.gte(threshold)).rename("msavi").copyProperties(image).set("threshold", threshold)
445
+ return msavi
446
+
447
+ @staticmethod
448
+ def sentinel_ndmi_fn(image, threshold):
449
+ """
450
+ Calculates the Normalized Difference Moisture Index (NDMI) for Sentinel-2 images. Masks image based on threshold.
451
+ See Wilson & Sader, 2002 - https://doi.org/10.1016/S0034-4257(02)00074-7
452
+
453
+ Args:
454
+ image (ee.Image): input ee.Image
455
+ threshold (float): value between -1 and 1 where NDMI pixels less than threshold will be masked
456
+
457
+ Returns:
458
+ ee.Image: NDMI ee.Image
459
+ """
460
+ ndmi_expression = '(b("B8") - b("B11")) / (b("B8") + b("B11"))'
461
+ ndmi_calc = image.expression(ndmi_expression)
462
+ ndmi = ndmi_calc.updateMask(ndmi_calc.gte(threshold)).rename("ndmi").copyProperties(image).set("threshold", threshold)
463
+ return ndmi
464
+
465
+ @staticmethod
466
+ def sentinel_nbr_fn(image, threshold):
467
+ """
468
+ Calculates the Normalized Burn Ratio (NBR) for Sentinel-2 images. Masks image based on threshold.
469
+
470
+ Args:
471
+ image (ee.Image): input ee.Image
472
+ threshold (float): value between -1 and 1 where NBR pixels less than threshold will be masked.
473
+
474
+ Returns:
475
+ ee.Image: NBR ee.Image
476
+ """
477
+ nbr_expression = '(b("B8") - b("B12")) / (b("B8") + b("B12"))'
478
+ nbr_calc = image.expression(nbr_expression)
479
+ nbr = nbr_calc.updateMask(nbr_calc.gte(threshold)).rename("nbr").copyProperties(image).set("threshold", threshold)
480
+ return nbr
332
481
 
333
482
  @staticmethod
334
483
  def MaskCloudsS2(image):
@@ -457,59 +606,88 @@ class Sentinel2Collection:
457
606
  image, band_name, geometry, threshold=-1, scale=10, maxPixels=1e12
458
607
  ):
459
608
  """
460
- Calculates the summation of area for pixels of interest (above a specific threshold) within a geometry and store the value as image property (matching name of chosen band).
461
- The resulting value has units of square meters.
609
+ Calculates the summation of area for pixels of interest (above a specific threshold) in a geometry
610
+ and store the value as image property (matching name of chosen band). If multiple band names are provided in a list,
611
+ the function will calculate area for each band in the list and store each as a separate property.
612
+
613
+ NOTE: The resulting value has units of square meters.
462
614
 
463
615
  Args:
464
616
  image (ee.Image): input ee.Image
465
- band_name: name of band (string) for calculating area.
466
- geometry: ee.Geometry object denoting area to clip to for area calculation.
467
- threshold: integer threshold to specify masking of pixels below threshold (defaults to -1).
468
- scale: integer scale of image resolution (meters) (defaults to 10).
469
- maxPixels: integer denoting maximum number of pixels for calculations.
617
+ band_name (string or list of strings): name of band(s) (string) for calculating area. If providing multiple band names, pass as a list of strings.
618
+ geometry (ee.Geometry): ee.Geometry object denoting area to clip to for area calculation
619
+ threshold (float): integer threshold to specify masking of pixels below threshold (defaults to -1). If providing multiple band names, the same threshold will be applied to all bands. Best practice in this case is to mask the bands prior to passing to this function and leave threshold at default of -1.
620
+ scale (int): integer scale of image resolution (meters) (defaults to 10)
621
+ maxPixels (int): integer denoting maximum number of pixels for calculations
470
622
 
471
623
  Returns:
472
- ee.Image: Image with area calculation stored as property matching name of band.
624
+ ee.Image: ee.Image with area calculation in square meters stored as property matching name of band
473
625
  """
626
+ # Ensure band_name is a server-side ee.List for consistent processing. Wrap band_name in a list if it's a single string.
627
+ bands = ee.List(band_name) if isinstance(band_name, list) else ee.List([band_name])
628
+ # Create an image representing the area of each pixel in square meters
474
629
  area_image = ee.Image.pixelArea()
475
- mask = image.select(band_name).gte(threshold)
476
- final = image.addBands(area_image)
477
- stats = (
478
- final.select("area")
479
- .updateMask(mask)
480
- .rename(band_name)
481
- .reduceRegion(
482
- reducer=ee.Reducer.sum(),
483
- geometry=geometry,
484
- scale=scale,
485
- maxPixels=maxPixels,
630
+
631
+ # Function to iterate over each band and calculate area, storing the result as a property on the image
632
+ def calculate_and_set_area(band, img_accumulator):
633
+ # Explcitly cast inputs to expected types
634
+ img_accumulator = ee.Image(img_accumulator)
635
+ band = ee.String(band)
636
+
637
+ # Create a mask from the input image for the current band
638
+ mask = img_accumulator.select(band).gte(threshold)
639
+ # Combine the original image with the area image
640
+ final = img_accumulator.addBands(area_image)
641
+
642
+ # Calculation of area for a given band, utilizing other inputs
643
+ stats = (
644
+ final.select("area").updateMask(mask)
645
+ .rename(band) # renames 'area' to band name like 'ndwi'
646
+ .reduceRegion(
647
+ reducer=ee.Reducer.sum(),
648
+ geometry=geometry,
649
+ scale=scale,
650
+ maxPixels=maxPixels,
651
+ )
486
652
  )
487
- )
488
- return image.set(
489
- band_name, stats.get(band_name)
490
- ) # calculates and returns summed pixel area as image property titled the same as the band name of the band used for calculation
653
+ # Retrieving the area value from the stats dictionary with stats.get(band), as the band name is now the key
654
+ reduced_area = stats.get(band)
655
+ # Checking whether the calculated area is valid and replaces with 0 if not. This avoids breaking the loop for erroneous images.
656
+ area_value = ee.Algorithms.If(reduced_area, reduced_area, 0)
657
+
658
+ # Set the property on the image, named after the band
659
+ return img_accumulator.set(band, area_value)
660
+
661
+ # Call to iterate the calculate_and_set_area function over the list of bands, starting with the original image
662
+ final_image = ee.Image(bands.iterate(calculate_and_set_area, image))
663
+ return final_image
491
664
 
492
665
  def PixelAreaSumCollection(
493
- self, band_name, geometry, threshold=-1, scale=10, maxPixels=1e12
666
+ self, band_name, geometry, threshold=-1, scale=10, maxPixels=1e12, output_type='ImageCollection', area_data_export_path=None
494
667
  ):
495
668
  """
496
- Calculates the summation of area for pixels of interest (above a specific threshold)
497
- within a geometry and store the value as image property (matching name of chosen band) for an entire
498
- image collection.
499
- The resulting value has units of square meters.
669
+ Calculates the geodesic summation of area for pixels of interest (above a specific threshold)
670
+ within a geometry and stores the value as an image property (matching name of chosen band) for an entire
671
+ image collection. Optionally exports the area data to a CSV file.
672
+
673
+ NOTE: The resulting value has units of square meters.
500
674
 
501
675
  Args:
502
- band_name: name of band (string) for calculating area.
503
- geometry: ee.Geometry object denoting area to clip to for area calculation.
504
- threshold: integer threshold to specify masking of pixels below threshold (defaults to -1).
505
- scale: integer scale of image resolution (meters) (defaults to 10).
506
- maxPixels: integer denoting maximum number of pixels for calculations.
676
+ band_name (string or list of strings): name of band(s) (string) for calculating area. If providing multiple band names, pass as a list of strings.
677
+ geometry (ee.Geometry): ee.Geometry object denoting area to clip to for area calculation
678
+ threshold (float): integer threshold to specify masking of pixels below threshold (defaults to -1). If providing multiple band names, the same threshold will be applied to all bands. Best practice in this case is to mask the bands prior to passing to this function and leave threshold at default of -1.
679
+ scale (int): integer scale of image resolution (meters) (defaults to 10)
680
+ maxPixels (int): integer denoting maximum number of pixels for calculations
681
+ output_type (str): 'ImageCollection' to return an ee.ImageCollection, 'Sentinel2Collection' to return a Sentinel2Collection object (defaults to 'ImageCollection')
682
+ area_data_export_path (str, optional): If provided, the function will save the resulting area data to a CSV file at the specified path.
507
683
 
508
684
  Returns:
509
- ee.Image: Image with area calculation stored as property matching name of band.
685
+ ee.ImageCollection or Sentinel2Collection: Image collection of images with area calculation (square meters) stored as property matching name of band. Type of output depends on output_type argument.
510
686
  """
687
+ # If the area calculation has not been computed for this Sentinel2Collection instance, the area will be calculated for the provided bands
511
688
  if self._PixelAreaSumCollection is None:
512
689
  collection = self.collection
690
+ # Area calculation for each image in the collection, using the PixelAreaSum function
513
691
  AreaCollection = collection.map(
514
692
  lambda image: Sentinel2Collection.PixelAreaSum(
515
693
  image,
@@ -520,8 +698,38 @@ class Sentinel2Collection:
520
698
  maxPixels=maxPixels,
521
699
  )
522
700
  )
701
+ # Storing the result in the instance variable to avoid redundant calculations
523
702
  self._PixelAreaSumCollection = AreaCollection
524
- return self._PixelAreaSumCollection
703
+
704
+ # If an export path is provided, the area data will be exported to a CSV file
705
+ if area_data_export_path:
706
+ Sentinel2Collection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=band_name, file_path=area_data_export_path+'.csv')
707
+
708
+ # Returning the result in the desired format based on output_type argument or raising an error for invalid input
709
+ if output_type == 'ImageCollection':
710
+ return self._PixelAreaSumCollection
711
+ elif output_type == 'Sentinel2Collection':
712
+ return Sentinel2Collection(collection=self._PixelAreaSumCollection)
713
+ else:
714
+ raise ValueError("output_type must be 'ImageCollection' or 'Sentinel2Collection'")
715
+
716
+ def merge(self, other):
717
+ """
718
+ Merges the current Sentinel2Collection with another Sentinel2Collection, where images/bands with the same date are combined to a single multiband image.
719
+
720
+ Args:
721
+ other (Sentinel2Collection): Another Sentinel2Collection to merge with current collection.
722
+
723
+ Returns:
724
+ Sentinel2Collection: A new Sentinel2Collection containing images from both collections.
725
+ """
726
+ # Checking if 'other' is an instance of Sentinel2Collection
727
+ if not isinstance(other, Sentinel2Collection):
728
+ raise ValueError("The 'other' parameter must be an instance of Sentinel2Collection.")
729
+
730
+ # Merging the collections using the .combine() method
731
+ merged_collection = self.collection.combine(other.collection)
732
+ return Sentinel2Collection(collection=merged_collection)
525
733
 
526
734
  @property
527
735
  def dates_list(self):
@@ -551,6 +759,60 @@ class Sentinel2Collection:
551
759
  dates = self._dates_list.getInfo()
552
760
  self._dates = dates
553
761
  return self._dates
762
+
763
+ def ExportProperties(self, property_names, file_path=None):
764
+ """
765
+ Fetches and returns specified properties from each image in the collection as a list, and returns a pandas DataFrame and optionally saves the results to a csv file.
766
+
767
+ Args:
768
+ property_names (list or str): A property name or list of property names to retrieve. The 'Date_Filter' property is always included to provide temporal context.
769
+ file_path (str, optional): If provided, the function will save the resulting DataFrame to a CSV file at this path. Defaults to None.
770
+
771
+ Returns:
772
+ pd.DataFrame: A pandas DataFrame containing the requested properties for each image, sorted chronologically by 'Date_Filter'.
773
+ """
774
+ # Ensure property_names is a list for consistent processing
775
+ if isinstance(property_names, str):
776
+ property_names = [property_names]
777
+
778
+ # Ensure properties are included without duplication, including 'Date_Filter'
779
+ all_properties_to_fetch = list(set(['Date_Filter'] + property_names))
780
+
781
+ # Defining the helper function to create features with specified properties
782
+ def create_feature_with_properties(image):
783
+ """A function to map over the collection and store the image properties as an ee.Feature.
784
+ Args:
785
+ image (ee.Image): An image from the collection.
786
+ Returns:
787
+ ee.Feature: A feature containing the specified properties from the image.
788
+ """
789
+ properties = image.toDictionary(all_properties_to_fetch)
790
+ return ee.Feature(None, properties)
791
+
792
+ # Map the feature creation function over the server-side collection.
793
+ # The result is an ee.FeatureCollection where each feature holds the properties of one image.
794
+ mapped_collection = self.collection.map(create_feature_with_properties)
795
+ # Explicitly cast to ee.FeatureCollection for clarity
796
+ feature_collection = ee.FeatureCollection(mapped_collection)
797
+
798
+ # Use the existing ee_to_df static method. This performs the single .getInfo() call
799
+ # and converts the structured result directly to a pandas DataFrame.
800
+ df = Sentinel2Collection.ee_to_df(feature_collection, columns=all_properties_to_fetch)
801
+
802
+ # Sort by date for a clean, chronological output.
803
+ if 'Date_Filter' in df.columns:
804
+ df = df.sort_values(by='Date_Filter').reset_index(drop=True)
805
+
806
+ # Check condition for saving to CSV
807
+ if file_path:
808
+ # Check whether file_path ends with .csv, if not, append it
809
+ if not file_path.lower().endswith('.csv'):
810
+ file_path += '.csv'
811
+ # Save DataFrame to CSV
812
+ df.to_csv(file_path, index=True)
813
+ print(f"Properties saved to {file_path}")
814
+
815
+ return df
554
816
 
555
817
  def get_filtered_collection(self):
556
818
  """
@@ -827,6 +1089,278 @@ class Sentinel2Collection:
827
1089
  )
828
1090
  )
829
1091
  return Sentinel2Collection(collection=col)
1092
+
1093
+ @property
1094
+ def evi(self):
1095
+ """
1096
+ Property attribute to calculate and access the EVI (Enhanced Vegetation Index) imagery of the Sentinel2Collection.
1097
+ This property initiates the calculation of EVI and caches the result. The calculation is performed only once when
1098
+ the property is first accessed, and the cached result is returned on subsequent accesses.
1099
+
1100
+ Returns:
1101
+ Sentinel2Collection: A Sentinel2Collection image collection
1102
+ """
1103
+ if self._evi is None:
1104
+ self._evi = self.evi_collection(self.evi_threshold)
1105
+ return self._evi
1106
+
1107
+ def evi_collection(self, threshold):
1108
+ """
1109
+ Function to calculate the EVI (Enhanced Vegetation Index) and return collection as class object, allows specifying threshold(s) for masking.
1110
+ This function can be called as a method but is called by default when using the evi property attribute.
1111
+
1112
+ Args:
1113
+ threshold (float): specify threshold for EVI function (values less than threshold are masked)
1114
+
1115
+ Returns:
1116
+ Sentinel2Collection: A Sentinel2Collection image collection
1117
+ """
1118
+ first_image = self.collection.first()
1119
+ available_bands = first_image.bandNames()
1120
+ if available_bands.contains("B4") and available_bands.contains("B8") and available_bands.contains("B2"):
1121
+ pass
1122
+ else:
1123
+ raise ValueError("Insufficient Bands for evi calculation")
1124
+ col = self.collection.map(
1125
+ lambda image: Sentinel2Collection.sentinel_evi_fn(
1126
+ image, threshold=threshold
1127
+ )
1128
+ )
1129
+ return Sentinel2Collection(collection=col)
1130
+
1131
+ @property
1132
+ def savi(self):
1133
+ """
1134
+ Property attribute to calculate and access the SAVI (Soil Adjusted Vegetation Index) imagery of the Sentinel2Collection.
1135
+ This property initiates the calculation of SAVI and caches the result. The calculation is performed only once when the
1136
+ property is first accessed, and the cached result is returned on subsequent accesses.
1137
+
1138
+ Returns:
1139
+ Sentinel2Collection: A Sentinel2Collection image collection
1140
+ """
1141
+ if self._savi is None:
1142
+ self._savi = self.savi_collection(self.savi_threshold)
1143
+ return self._savi
1144
+
1145
+ def savi_collection(self, threshold, l=0.5):
1146
+ """
1147
+ Function to calculate the SAVI (Soil Adjusted Vegetation Index) and return collection as class object, allows specifying threshold(s) for masking.
1148
+ This function can be called as a method but is called by default when using the savi property attribute.
1149
+
1150
+ Args:
1151
+ threshold (float): specify threshold for SAVI function (values less than threshold are masked)
1152
+ l (float, optional): Soil brightness correction factor, typically set to 0.5 for intermediate vegetation cover. Defaults to 0.5.
1153
+
1154
+ Returns:
1155
+ Sentinel2Collection: A Sentinel2Collection image collection
1156
+ """
1157
+ first_image = self.collection.first()
1158
+ available_bands = first_image.bandNames()
1159
+ if available_bands.contains("B4") and available_bands.contains("B8"):
1160
+ pass
1161
+ else:
1162
+ raise ValueError("Insufficient Bands for savi calculation")
1163
+ col = self.collection.map(
1164
+ lambda image: Sentinel2Collection.sentinel_savi_fn(
1165
+ image, threshold=threshold, l=l
1166
+ )
1167
+ )
1168
+ return Sentinel2Collection(collection=col)
1169
+
1170
+ @property
1171
+ def msavi(self):
1172
+ """
1173
+ Property attribute to calculate and access the MSAVI (Modified Soil Adjusted Vegetation Index) imagery of the Sentinel2Collection.
1174
+ This property initiates the calculation of MSAVI and caches the result. The calculation is performed only once when the property
1175
+ is first accessed, and the cached result is returned on subsequent accesses.
1176
+
1177
+ Returns:
1178
+ Sentinel2Collection: A Sentinel2Collection image collection
1179
+ """
1180
+ if self._msavi is None:
1181
+ self._msavi = self.msavi_collection(self.msavi_threshold)
1182
+ return self._msavi
1183
+
1184
+ def msavi_collection(self, threshold):
1185
+ """
1186
+ Function to calculate the MSAVI (Modified Soil Adjusted Vegetation Index) and return collection as class object, allows specifying threshold(s) for masking.
1187
+ This function can be called as a method but is called by default when using the msavi property attribute.
1188
+
1189
+ Args:
1190
+ threshold (float): specify threshold for MSAVI function (values less than threshold are masked)
1191
+
1192
+ Returns:
1193
+ Sentinel2Collection: A Sentinel2Collection image collection
1194
+ """
1195
+ first_image = self.collection.first()
1196
+ available_bands = first_image.bandNames()
1197
+ if available_bands.contains("B4") and available_bands.contains("B8"):
1198
+ pass
1199
+ else:
1200
+ raise ValueError("Insufficient Bands for msavi calculation")
1201
+ col = self.collection.map(
1202
+ lambda image: Sentinel2Collection.sentinel_msavi_fn(
1203
+ image, threshold=threshold
1204
+ )
1205
+ )
1206
+ return Sentinel2Collection(collection=col)
1207
+
1208
+ @property
1209
+ def ndmi(self):
1210
+ """
1211
+ Property attribute to calculate and access the NDMI (Normalized Difference Moisture Index) imagery of the Sentinel2Collection.
1212
+ This property initiates the calculation of NDMI and caches the result. The calculation is performed only once when the property
1213
+ is first accessed, and the cached result is returned on subsequent accesses.
1214
+
1215
+ Returns:
1216
+ Sentinel2Collection: A Sentinel2Collection image collection
1217
+ """
1218
+ if self._ndmi is None:
1219
+ self._ndmi = self.ndmi_collection(self.ndmi_threshold)
1220
+ return self._ndmi
1221
+
1222
+ def ndmi_collection(self, threshold):
1223
+ """
1224
+ Function to calculate the NDMI (Normalized Difference Moisture Index) and return collection as class object, allows specifying threshold(s) for masking.
1225
+ This function can be called as a method but is called by default when using the ndmi property attribute.
1226
+
1227
+ Args:
1228
+ threshold (float): specify threshold for NDMI function (values less than threshold are masked)
1229
+ Returns:
1230
+ Sentinel2Collection: A Sentinel2Collection image collection
1231
+ """
1232
+ first_image = self.collection.first()
1233
+ available_bands = first_image.bandNames()
1234
+ if available_bands.contains("B8") and available_bands.contains("B11"):
1235
+ pass
1236
+ else:
1237
+ raise ValueError("Insufficient Bands for ndmi calculation")
1238
+ col = self.collection.map(
1239
+ lambda image: Sentinel2Collection.sentinel_ndmi_fn(
1240
+ image, threshold=threshold
1241
+ )
1242
+ )
1243
+ return Sentinel2Collection(collection=col)
1244
+
1245
+ @property
1246
+ def nbr(self):
1247
+ """
1248
+ Property attribute to calculate and access the NBR (Normalized Burn Ratio) imagery of the Sentinel2Collection.
1249
+ This property initiates the calculation of NBR and caches the result. The calculation is performed only once
1250
+ when the property is first accessed, and the cached result is returned on subsequent accesses.
1251
+
1252
+ Returns:
1253
+ Sentinel2Collection: A Sentinel2Collection image collection
1254
+ """
1255
+ if self._nbr is None:
1256
+ self._nbr = self.nbr_collection(self.nbr_threshold)
1257
+ return self._nbr
1258
+
1259
+ def nbr_collection(self, threshold):
1260
+ """
1261
+ Function to calculate the NBR (Normalized Burn Ratio) and return collection as class object, allows specifying threshold(s) for masking.
1262
+ This function can be called as a method but is called by default when using the nbr property attribute.
1263
+
1264
+ Args:
1265
+ threshold (float): specify threshold for NBR function (values less than threshold are masked)
1266
+
1267
+ Returns:
1268
+ Sentinel2Collection: A Sentinel2Collection image collection
1269
+ """
1270
+ first_image = self.collection.first()
1271
+ available_bands = first_image.bandNames()
1272
+ if available_bands.contains("B8") and available_bands.contains("B12"):
1273
+ pass
1274
+ else:
1275
+ raise ValueError("Insufficient Bands for nbr calculation")
1276
+ col = self.collection.map(
1277
+ lambda image: Sentinel2Collection.sentinel_nbr_fn(
1278
+ image, threshold=threshold
1279
+ )
1280
+ )
1281
+ return Sentinel2Collection(collection=col)
1282
+
1283
+ @property
1284
+ def ndsi(self):
1285
+ """
1286
+ Property attribute to calculate and access the NDSI (Normalized Difference Snow Index) imagery of the Sentinel2Collection.
1287
+ This property initiates the calculation of NDSI and caches the result. The calculation is performed only once when the
1288
+ property is first accessed, and the cached result is returned on subsequent accesses.
1289
+
1290
+ Returns:
1291
+ Sentinel2Collection: A Sentinel2Collection image collection
1292
+ """
1293
+ if self._ndsi is None:
1294
+ self._ndsi = self.ndsi_collection(self.ndsi_threshold)
1295
+ return self._ndsi
1296
+
1297
+ def ndsi_collection(self, threshold):
1298
+ """
1299
+ Function to calculate the NDSI (Normalized Difference Snow Index) and return collection as class object, allows specifying threshold(s) for masking.
1300
+ This function can be called as a method but is called
1301
+ by default when using the ndsi property attribute.
1302
+
1303
+ Args:
1304
+ threshold (float): specify threshold for NDSI function (values less than threshold are masked)
1305
+
1306
+ Returns:
1307
+ Sentinel2Collection: A Sentinel2Collection image collection
1308
+ """
1309
+ first_image = self.collection.first()
1310
+ available_bands = first_image.bandNames()
1311
+ if available_bands.contains("B3") and available_bands.contains("B11"):
1312
+ pass
1313
+ else:
1314
+ raise ValueError("Insufficient Bands for ndsi calculation")
1315
+ col = self.collection.map(
1316
+ lambda image: Sentinel2Collection.sentinel_ndsi_fn(
1317
+ image, threshold=threshold
1318
+ )
1319
+ )
1320
+ return Sentinel2Collection(collection=col)
1321
+
1322
+ @property
1323
+ def albedo(self):
1324
+ """
1325
+ Property attribute to calculate albedo imagery for snow-free conditions, based on Li et al., 2018 (https://doi.org/10.1016/j.rse.2018.08.025).
1326
+ Use `albedo_collection(snow_free=False)` for images with snow present. This property initiates the calculation of albedo and caches the result.
1327
+ The calculation is performed only once when the property is first accessed, and the cached result is returned on subsequent accesses.
1328
+
1329
+ Returns:
1330
+ LandsatCollection: A LandsatCollection image collection
1331
+ """
1332
+ if self._albedo is None:
1333
+ self._albedo = self.albedo_collection(snow_free=True)
1334
+ return self._albedo
1335
+
1336
+
1337
+ def albedo_collection(self, snow_free=True):
1338
+ """
1339
+ Calculates albedo for snow or snow-free conditions and returns collection as class object, allows specifying threshold(s) for masking.
1340
+ This function can be called as a method but is called by default when using the ndwi property attribute.
1341
+ Albedo calculation based on Li et al., 2018 (https://doi.org/10.1016/j.rse.2018.08.025).
1342
+
1343
+ Args:
1344
+ snow_free (bool): If True, applies a snow mask to the albedo calculation. Defaults to True.
1345
+
1346
+ Returns:
1347
+ LandsatCollection: A LandsatCollection image collection
1348
+ """
1349
+ first_image = self.collection.first()
1350
+ available_bands = first_image.bandNames()
1351
+ if (
1352
+ available_bands.contains("B2")
1353
+ and available_bands.contains("B3")
1354
+ and available_bands.contains("B4")
1355
+ and available_bands.contains("B8A")
1356
+ and available_bands.contains("B11")
1357
+ and available_bands.contains("B12")
1358
+ ):
1359
+ pass
1360
+ else:
1361
+ raise ValueError("Insufficient Bands for albedo calculation")
1362
+ col = self.collection.map(lambda image:Sentinel2Collection.sentinel_broadband_albedo_fn(image, snow_free=snow_free))
1363
+ return Sentinel2Collection(collection=col)
830
1364
 
831
1365
  @property
832
1366
  def halite(self):
@@ -1407,7 +1941,8 @@ class Sentinel2Collection:
1407
1941
  to_pandas=False,
1408
1942
  **kwargs,
1409
1943
  ):
1410
- """Extracts transect from an image. Adapted from the geemap package (https://geemap.org/common/#geemap.common.extract_transect). Exists as an alternative to RadGEEToolbox 'transect' function.
1944
+ """
1945
+ Extracts transect from an image. Adapted from the geemap package (https://geemap.org/common/#geemap.common.extract_transect).
1411
1946
 
1412
1947
  Args:
1413
1948
  image (ee.Image): The image to extract transect from.
@@ -1481,9 +2016,10 @@ class Sentinel2Collection:
1481
2016
  dist_interval=10,
1482
2017
  to_pandas=True,
1483
2018
  ):
1484
- """Computes and stores the values along a transect for each line in a list of lines. Builds off of the extract_transect function from the geemap package
1485
- where checks are ran to ensure that the reducer column is present in the transect data. If the reducer column is not present, a column of NaNs is created.
1486
- An ee reducer is used to aggregate the values along the transect, depending on the number of segments or distance interval specified. Defaults to 'mean' reducer.
2019
+ """
2020
+ Computes and stores the values along a transect for each line in a list of lines. Builds off of the extract_transect function from the geemap package
2021
+ where checks are ran to ensure that the reducer column is present in the transect data. If the reducer column is not present, a column of NaNs is created.
2022
+ An ee reducer is used to aggregate the values along the transect, depending on the number of segments or distance interval specified. Defaults to 'mean' reducer.
1487
2023
 
1488
2024
  Args:
1489
2025
  image (ee.Image): ee.Image object to use for calculating transect values.
@@ -1556,52 +2092,201 @@ class Sentinel2Collection:
1556
2092
  self,
1557
2093
  lines,
1558
2094
  line_names,
1559
- save_folder_path,
1560
2095
  reducer="mean",
2096
+ dist_interval= 10,
1561
2097
  n_segments=None,
1562
- dist_interval=10,
1563
- to_pandas=True,
2098
+ scale=10,
2099
+ processing_mode='aggregated',
2100
+ save_folder_path=None,
2101
+ sampling_method='line',
2102
+ point_buffer_radius=5
1564
2103
  ):
1565
- """Computes and stores the values along a transect for each line in a list of lines for each image in a Sentinel2Collection image collection, then saves the data for each image to a csv file. Builds off of the extract_transect function from the geemap package
1566
- where checks are ran to ensure that the reducer column is present in the transect data. If the reducer column is not present, a column of NaNs is created.
1567
- An ee reducer is used to aggregate the values along the transect, depending on the number of segments or distance interval specified. Defaults to 'mean' reducer.
1568
- Naming conventions for the csv files follows as: "image-date_transects.csv"
2104
+ """
2105
+ Computes and returns pixel values along transects for each image in a collection.
1569
2106
 
1570
- Args:
1571
- lines (list): List of ee.Geometry.LineString objects.
1572
- line_names (list of strings): List of line string names.
1573
- save_folder_path (str): The path to the folder where the csv files will be saved.
1574
- reducer (str): The ee reducer to use. Defaults to 'mean'.
1575
- n_segments (int): The number of segments that the LineString will be split into. Defaults to None.
1576
- dist_interval (float): The distance interval in meters used for splitting the LineString. If specified, the n_segments parameter will be ignored. Defaults to 10.
1577
- to_pandas (bool): Whether to convert the result to a pandas dataframe. Defaults to True.
2107
+ This iterative function generates time-series data along one or more lines, and
2108
+ supports two different geometric sampling methods ('line' and 'buffered_point')
2109
+ for maximum flexibility and performance.
1578
2110
 
1579
- Raises:
1580
- Exception: If the program fails to compute.
2111
+ There are two processing modes available, aggregated and iterative:
2112
+ - 'aggregated' (default; suggested): Fast, server-side processing. Fetches all results
2113
+ in a single request. Highly recommended. Returns a dictionary of pandas DataFrames.
2114
+ - 'iterative': Slower, client-side loop that processes one image at a time.
2115
+ Kept for backward compatibility (effectively depreciated). Returns None and saves individual CSVs.
2116
+ This method is not recommended unless absolutely necessary, as it is less efficient and may be subject to client-side timeouts.
2117
+
2118
+ Args:
2119
+ lines (list): A list of one or more ee.Geometry.LineString objects that
2120
+ define the transects.
2121
+ line_names (list): A list of string names for each transect. The length
2122
+ of this list must match the length of the `lines` list.
2123
+ reducer (str, optional): The name of the ee.Reducer to apply at each
2124
+ transect point (e.g., 'mean', 'median', 'first'). Defaults to 'mean'.
2125
+ dist_interval (float, optional): The distance interval in meters for
2126
+ sampling points along each transect. Will be overridden if `n_segments` is provided.
2127
+ Defaults to 10. Recommended to increase this value when using the
2128
+ 'line' processing method, or else you may get blank rows.
2129
+ n_segments (int, optional): The number of equal-length segments to split
2130
+ each transect line into for sampling. This parameter overrides `dist_interval`.
2131
+ Defaults to None.
2132
+ scale (int, optional): The nominal scale in meters for the reduction,
2133
+ which should typically match the pixel resolution of the imagery.
2134
+ Defaults to 10.
2135
+ processing_mode (str, optional): The method for processing the collection.
2136
+ - 'aggregated' (default): Fast, server-side processing. Fetches all
2137
+ results in a single request. Highly recommended. Returns a dictionary
2138
+ of pandas DataFrames.
2139
+ - 'iterative': Slower, client-side loop that processes one image at a
2140
+ time. Kept for backward compatibility. Returns None and saves
2141
+ individual CSVs.
2142
+ save_folder_path (str, optional): If provided, the function will save the
2143
+ resulting transect data to CSV files. The behavior depends on the
2144
+ `processing_mode`:
2145
+ - In 'aggregated' mode, one CSV is saved for each transect,
2146
+ containing all dates. (e.g., 'MyTransect_transects.csv').
2147
+ - In 'iterative' mode, one CSV is saved for each date,
2148
+ containing all transects. (e.g., '2022-06-15_transects.csv').
2149
+ sampling_method (str, optional): The geometric method used for sampling.
2150
+ - 'line' (default): Reduces all pixels intersecting each small line
2151
+ segment. This can be unreliable and produce blank rows if
2152
+ `dist_interval` is too small relative to the `scale`.
2153
+ - 'buffered_point': Reduces all pixels within a buffer around the
2154
+ midpoint of each line segment. This method is more robust and
2155
+ reliably avoids blank rows, but may not reduce all pixels along a line segment.
2156
+ point_buffer_radius (int, optional): The radius in meters for the buffer
2157
+ when `sampling_method` is 'buffered_point'. Defaults to 5.
1581
2158
 
1582
2159
  Returns:
1583
- csv file: file for each image with an organized list of values along the transect(s)
1584
- """
1585
- image_collection = self # .collection
1586
- image_collection_dates = self.dates
1587
- for i, date in enumerate(image_collection_dates):
2160
+ dict or None:
2161
+ - If `processing_mode` is 'aggregated', returns a dictionary where each
2162
+ key is a transect name and each value is a pandas DataFrame. In the
2163
+ DataFrame, the index is the distance along the transect and each
2164
+ column represents an image date. Optionally saves CSV files if
2165
+ `save_folder_path` is provided.
2166
+ - If `processing_mode` is 'iterative', returns None as it saves
2167
+ files directly.
2168
+
2169
+ Raises:
2170
+ ValueError: If `lines` and `line_names` have different lengths, or if
2171
+ an unknown reducer or processing mode is specified.
2172
+ """
2173
+ # Validating inputs
2174
+ if len(lines) != len(line_names):
2175
+ raise ValueError("'lines' and 'line_names' must have the same number of elements.")
2176
+ ### Current, server-side processing method ###
2177
+ if processing_mode == 'aggregated':
2178
+ # Validating reducer type
1588
2179
  try:
1589
- print(f"Processing image {i+1}/{len(image_collection_dates)}: {date}")
1590
- image = image_collection.image_grab(i)
1591
- transects_df = Sentinel2Collection.transect(
1592
- image,
1593
- lines,
1594
- line_names,
1595
- reducer=reducer,
1596
- n_segments=n_segments,
1597
- dist_interval=dist_interval,
1598
- to_pandas=to_pandas,
1599
- )
1600
- image_id = date
1601
- transects_df.to_csv(f"{save_folder_path}{image_id}_transects.csv")
1602
- print(f"{image_id}_transects saved to csv")
1603
- except Exception as e:
1604
- print(f"An error occurred while processing image {i+1}: {e}")
2180
+ ee_reducer = getattr(ee.Reducer, reducer)()
2181
+ except AttributeError:
2182
+ raise ValueError(f"Unknown reducer: '{reducer}'.")
2183
+ ### Function to extract transects for a single image
2184
+ def get_transects_for_image(image):
2185
+ image_date = image.get('Date_Filter')
2186
+ # Initialize an empty list to hold all transect FeatureCollections
2187
+ all_transects_for_image = ee.List([])
2188
+ # Looping through each line and processing
2189
+ for i, line in enumerate(lines):
2190
+ # Index line and name
2191
+ line_name = line_names[i]
2192
+ # Determine maxError based on image projection, used for geometry operations
2193
+ maxError = image.projection().nominalScale().divide(5)
2194
+ # Calculate effective distance interval
2195
+ length = line.length(maxError) # using maxError here ensures consistency with cutLines
2196
+ # Determine effective distance interval based on n_segments or dist_interval
2197
+ effective_dist_interval = ee.Algorithms.If(
2198
+ n_segments,
2199
+ length.divide(n_segments),
2200
+ dist_interval or 30 # Defaults to 30 if both are None
2201
+ )
2202
+ # Generate distances along the line(s) for segmentation
2203
+ distances = ee.List.sequence(0, length, effective_dist_interval)
2204
+ # Segmenting the line into smaller lines at the specified distances
2205
+ cut_lines_geoms = line.cutLines(distances, maxError).geometries()
2206
+ # Function to create features with distance attributes
2207
+ # Adjusted to ensure consistent return types
2208
+ def set_dist_attr(l):
2209
+ # l is a list: [geometry, distance]
2210
+ # Extracting geometry portion of line
2211
+ geom_segment = ee.Geometry(ee.List(l).get(0))
2212
+ # Extracting distance value for attribute
2213
+ distance = ee.Number(ee.List(l).get(1))
2214
+ ### Determine final geometry based on sampling method
2215
+ # If the sampling method is 'buffered_point',
2216
+ # create a buffered point feature at the centroid of each segment,
2217
+ # otherwise create a line feature
2218
+ final_feature = ee.Algorithms.If(
2219
+ ee.String(sampling_method).equals('buffered_point'),
2220
+ # True Case: Create the buffered point feature
2221
+ ee.Feature(
2222
+ geom_segment.centroid(maxError).buffer(point_buffer_radius),
2223
+ {'distance': distance}
2224
+ ),
2225
+ # False Case: Create the line segment feature
2226
+ ee.Feature(geom_segment, {'distance': distance})
2227
+ )
2228
+ # Return either the line segment feature or the buffered point feature
2229
+ return final_feature
2230
+ # Creating a FeatureCollection of the cut lines with distance attributes
2231
+ # Using map to apply the set_dist_attr function to each cut line geometry
2232
+ line_features = ee.FeatureCollection(cut_lines_geoms.zip(distances).map(set_dist_attr))
2233
+ # Reducing the image over the line features to get transect values
2234
+ transect_fc = image.reduceRegions(
2235
+ collection=line_features, reducer=ee_reducer, scale=scale
2236
+ )
2237
+ # Adding image date and line name properties to each feature
2238
+ def set_props(feature):
2239
+ return feature.set({'image_date': image_date, 'transect_name': line_name})
2240
+ # Append to the list of all transects for this image
2241
+ all_transects_for_image = all_transects_for_image.add(transect_fc.map(set_props))
2242
+ # Combine all transect FeatureCollections into a single FeatureCollection and flatten
2243
+ # Flatten is used to merge the list of FeatureCollections into one
2244
+ return ee.FeatureCollection(all_transects_for_image).flatten()
2245
+ # Map the function over the entire image collection and flatten the results
2246
+ results_fc = ee.FeatureCollection(self.collection.map(get_transects_for_image)).flatten()
2247
+ # Convert the results to a pandas DataFrame
2248
+ df = Sentinel2Collection.ee_to_df(results_fc, remove_geom=True)
2249
+ # Check if the DataFrame is empty
2250
+ if df.empty:
2251
+ print("Warning: No transect data was generated.")
2252
+ return {}
2253
+ # Initialize dictionary to hold output DataFrames for each transect
2254
+ output_dfs = {}
2255
+ # Loop through each unique transect name and create a pivot table
2256
+ for name in sorted(df['transect_name'].unique()):
2257
+ transect_df = df[df['transect_name'] == name]
2258
+ pivot_df = transect_df.pivot(index='distance', columns='image_date', values=reducer)
2259
+ pivot_df.columns.name = 'Date'
2260
+ output_dfs[name] = pivot_df
2261
+ # Optionally save each transect DataFrame to CSV
2262
+ if save_folder_path:
2263
+ for transect_name, transect_df in output_dfs.items():
2264
+ safe_filename = "".join(x for x in transect_name if x.isalnum() or x in "._-")
2265
+ file_path = f"{save_folder_path}{safe_filename}_transects.csv"
2266
+ transect_df.to_csv(file_path)
2267
+ print(f"Saved transect data to {file_path}")
2268
+
2269
+ return output_dfs
2270
+
2271
+ ### old, depreciated iterative client-side processing method ###
2272
+ elif processing_mode == 'iterative':
2273
+ if not save_folder_path:
2274
+ raise ValueError("`save_folder_path` is required for 'iterative' processing mode.")
2275
+
2276
+ image_collection_dates = self.dates
2277
+ for i, date in enumerate(image_collection_dates):
2278
+ try:
2279
+ print(f"Processing image {i+1}/{len(image_collection_dates)}: {date}")
2280
+ image = self.image_grab(i)
2281
+ transects_df = Sentinel2Collection.transect(
2282
+ image, lines, line_names, reducer, n_segments, dist_interval, to_pandas=True
2283
+ )
2284
+ transects_df.to_csv(f"{save_folder_path}{date}_transects.csv")
2285
+ print(f"{date}_transects saved to csv")
2286
+ except Exception as e:
2287
+ print(f"An error occurred while processing image {i+1}: {e}")
2288
+ else:
2289
+ raise ValueError("`processing_mode` must be 'iterative' or 'aggregated'.")
1605
2290
 
1606
2291
  @staticmethod
1607
2292
  def extract_zonal_stats_from_buffer(
@@ -1614,37 +2299,35 @@ class Sentinel2Collection:
1614
2299
  coordinate_names=None,
1615
2300
  ):
1616
2301
  """
1617
- Function to extract spatial statistics from an image for a list of coordinates, providing individual statistics for each location.
2302
+ Function to extract spatial statistics from an image for a list or single set of (long, lat) coordinates, providing individual statistics for each location.
1618
2303
  A radial buffer is applied around each coordinate to extract the statistics, which defaults to 1 meter.
1619
2304
  The function returns a pandas DataFrame with the statistics for each coordinate.
1620
2305
 
2306
+ NOTE: Be sure the coordinates are provided as longitude, latitude (x, y) tuples!
2307
+
1621
2308
  Args:
1622
- image (ee.Image): The image from which to extract the statistics. Must be a singleband image or else resulting values will all be zero!
1623
- coordinates (list): Single tuple or list of tuples with the decimal degrees coordinates in the format of (longitude, latitude) for which to extract the statistics. NOTE the format needs to be [(x1, y1), (x2, y2), ...].
1624
- buffer_size (int, optional): The radial buffer size around the coordinates in meters. Defaults to 1.
1625
- reducer_type (str, optional): The reducer type to use. Defaults to 'mean'. Options are 'mean', 'median', 'min', and 'max'.
1626
- scale (int, optional): The scale (pixel size) to use in meters. Defaults to 10.
1627
- tileScale (int, optional): The tile scale to use. Defaults to 1.
1628
- coordinate_names (list, optional): A list of strings with the names of the coordinates. Defaults to None.
2309
+ image (ee.Image): The image from which to extract statistics. Should be single-band.
2310
+ coordinates (list or tuple): A single (lon, lat) tuple or a list of (lon, lat) tuples.
2311
+ buffer_size (int, optional): The radial buffer size in meters. Defaults to 1.
2312
+ reducer_type (str, optional): The ee.Reducer to use ('mean', 'median', 'min', etc.). Defaults to 'mean'.
2313
+ scale (int, optional): The scale in meters for the reduction. Defaults to 10.
2314
+ tileScale (int, optional): The tile scale factor. Defaults to 1.
2315
+ coordinate_names (list, optional): A list of names for the coordinates.
1629
2316
 
1630
2317
  Returns:
1631
- pd.DataFrame: A pandas DataFrame with the statistics for each coordinate, each column name corresponds to the name of the coordinate feature (which may be blank if no names are supplied).
2318
+ pd.DataFrame: A pandas DataFrame with the image's 'Date_Filter' as the index and a
2319
+ column for each coordinate location.
1632
2320
  """
1633
-
1634
- # Check if coordinates is a single tuple and convert it to a list of tuples if necessary
1635
2321
  if isinstance(coordinates, tuple) and len(coordinates) == 2:
1636
2322
  coordinates = [coordinates]
1637
2323
  elif not (
1638
2324
  isinstance(coordinates, list)
1639
- and all(
1640
- isinstance(coord, tuple) and len(coord) == 2 for coord in coordinates
1641
- )
2325
+ and all(isinstance(coord, tuple) and len(coord) == 2 for coord in coordinates)
1642
2326
  ):
1643
2327
  raise ValueError(
1644
- "Coordinates must be a list of tuples with two elements each (latitude, longitude)."
2328
+ "Coordinates must be a list of tuples with two elements each (longitude, latitude)."
1645
2329
  )
1646
2330
 
1647
- # Check if coordinate_names is a list of strings
1648
2331
  if coordinate_names is not None:
1649
2332
  if not isinstance(coordinate_names, list) or not all(
1650
2333
  isinstance(name, str) for name in coordinate_names
@@ -1657,146 +2340,184 @@ class Sentinel2Collection:
1657
2340
  else:
1658
2341
  coordinate_names = [f"Location {i+1}" for i in range(len(coordinates))]
1659
2342
 
1660
- # Check if the image is a singleband image
1661
- def check_singleband(image):
1662
- band_count = image.bandNames().size()
1663
- return ee.Algorithms.If(band_count.eq(1), image, ee.Image.constant(0))
1664
-
1665
- # image = ee.Image(check_singleband(image))
1666
- image = ee.Image(check_singleband(image))
2343
+ image_date = image.get('Date_Filter')
1667
2344
 
1668
- # Convert coordinates to ee.Geometry.Point, buffer them, and add label/name to feature
1669
2345
  points = [
1670
2346
  ee.Feature(
1671
- ee.Geometry.Point([coord[0], coord[1]]).buffer(buffer_size),
1672
- {"name": str(coordinate_names[i])},
2347
+ ee.Geometry.Point(coord).buffer(buffer_size),
2348
+ {"location_name": str(name)},
1673
2349
  )
1674
- for i, coord in enumerate(coordinates)
2350
+ for coord, name in zip(coordinates, coordinate_names)
1675
2351
  ]
1676
- # Create a feature collection from the buffered points
1677
2352
  features = ee.FeatureCollection(points)
1678
- # Reduce the image to the buffered points - handle different reducer types
1679
- if reducer_type == "mean":
1680
- img_stats = image.reduceRegions(
1681
- collection=features,
1682
- reducer=ee.Reducer.mean(),
1683
- scale=scale,
1684
- tileScale=tileScale,
1685
- )
1686
- mean_values = img_stats.getInfo()
1687
- means = []
1688
- names = []
1689
- for feature in mean_values["features"]:
1690
- names.append(feature["properties"]["name"])
1691
- means.append(feature["properties"]["mean"])
1692
- organized_values = pd.DataFrame([means], columns=names)
1693
- elif reducer_type == "median":
1694
- img_stats = image.reduceRegions(
1695
- collection=features,
1696
- reducer=ee.Reducer.median(),
1697
- scale=scale,
1698
- tileScale=tileScale,
1699
- )
1700
- median_values = img_stats.getInfo()
1701
- medians = []
1702
- names = []
1703
- for feature in median_values["features"]:
1704
- names.append(feature["properties"]["name"])
1705
- medians.append(feature["properties"]["median"])
1706
- organized_values = pd.DataFrame([medians], columns=names)
1707
- elif reducer_type == "min":
1708
- img_stats = image.reduceRegions(
1709
- collection=features,
1710
- reducer=ee.Reducer.min(),
1711
- scale=scale,
1712
- tileScale=tileScale,
1713
- )
1714
- min_values = img_stats.getInfo()
1715
- mins = []
1716
- names = []
1717
- for feature in min_values["features"]:
1718
- names.append(feature["properties"]["name"])
1719
- mins.append(feature["properties"]["min"])
1720
- organized_values = pd.DataFrame([mins], columns=names)
1721
- elif reducer_type == "max":
1722
- img_stats = image.reduceRegions(
1723
- collection=features,
1724
- reducer=ee.Reducer.max(),
1725
- scale=scale,
1726
- tileScale=tileScale,
1727
- )
1728
- max_values = img_stats.getInfo()
1729
- maxs = []
1730
- names = []
1731
- for feature in max_values["features"]:
1732
- names.append(feature["properties"]["name"])
1733
- maxs.append(feature["properties"]["max"])
1734
- organized_values = pd.DataFrame([maxs], columns=names)
1735
- else:
1736
- raise ValueError(
1737
- "reducer_type must be one of 'mean', 'median', 'min', or 'max'."
1738
- )
1739
- return organized_values
2353
+
2354
+ try:
2355
+ reducer = getattr(ee.Reducer, reducer_type)()
2356
+ except AttributeError:
2357
+ raise ValueError(f"Unknown reducer_type: '{reducer_type}'.")
2358
+
2359
+ stats_fc = image.reduceRegions(
2360
+ collection=features,
2361
+ reducer=reducer,
2362
+ scale=scale,
2363
+ tileScale=tileScale,
2364
+ )
2365
+
2366
+ df = Sentinel2Collection.ee_to_df(stats_fc, remove_geom=True)
2367
+
2368
+ if df.empty:
2369
+ print("Warning: No results returned. The points may not intersect the image.")
2370
+ empty_df = pd.DataFrame(columns=coordinate_names)
2371
+ empty_df.index.name = 'Date'
2372
+ return empty_df
2373
+
2374
+ if reducer_type not in df.columns:
2375
+ print(f"Warning: Reducer type '{reducer_type}' not found in results. Returning raw data.")
2376
+ return df
2377
+
2378
+ pivot_df = df.pivot(columns='location_name', values=reducer_type)
2379
+ pivot_df['Date'] = image_date.getInfo() # .getInfo() is needed here as it's a server object
2380
+ pivot_df = pivot_df.set_index('Date')
2381
+ return pivot_df
1740
2382
 
1741
2383
  def iterate_zonal_stats(
1742
2384
  self,
1743
- coordinates,
1744
- buffer_size=1,
2385
+ geometries,
1745
2386
  reducer_type="mean",
1746
2387
  scale=10,
2388
+ geometry_names=None,
2389
+ buffer_size=1,
1747
2390
  tileScale=1,
1748
- coordinate_names=None,
1749
- file_path=None,
1750
2391
  dates=None,
2392
+ file_path=None
1751
2393
  ):
1752
2394
  """
1753
- Function to iterate over a collection of images and extract spatial statistics for a list of coordinates (defaults to mean). Individual statistics are provided for each location.
1754
- A radial buffer is applied around each coordinate to extract the statistics, which defaults to 1 meter.
2395
+ Iterates over a collection of images and extracts spatial statistics (defaults to mean) for a given list of geometries or coordinates. Individual statistics are calculated for each geometry or coordinate provided.
2396
+ When coordinates are provided, a radial buffer is applied around each coordinate to extract the statistics, where the size of the buffer is determined by the buffer_size argument (defaults to 1 meter).
1755
2397
  The function returns a pandas DataFrame with the statistics for each coordinate and date, or optionally exports the data to a table in .csv format.
1756
2398
 
1757
- NOTE: The input RadGEEToolbox object must be a collection of singleband images, otherwise the resulting values will all be zero!
1758
-
1759
2399
  Args:
1760
- coordinates (list): Single tuple or a list of tuples with the coordinates as decimal degrees in the format of (longitude, latitude) for which to extract the statistics. NOTE the format needs to be [(x1, y1), (x2, y2), ...].
1761
- buffer_size (int, optional): The radial buffer size in meters around the coordinates. Defaults to 1.
1762
- reducer_type (str, optional): The reducer type to use. Defaults to 'mean'. Options are 'mean', 'median', 'min', and 'max'.
1763
- scale (int, optional): The scale (pixel size) to use in meters. Defaults to 10.
1764
- tileScale (int, optional): The tile scale to use. Defaults to 1.
1765
- coordinate_names (list, optional): A list of strings with the names of the coordinates. Defaults to None.
1766
- file_path (str, optional): The file path to export the data to. Defaults to None. Ensure ".csv" is NOT included in the file name path.
1767
- dates (list, optional): A list of dates for which to extract the statistics. Defaults to None.
1768
-
1769
- Returns:
1770
- pd.DataFrame: A pandas DataFrame with the statistics for each coordinate and date, each row corresponds to a date and each column to a coordinate.
1771
- .csv file: Optionally exports the data to a table in .csv format. If file_path is None, the function returns the DataFrame - otherwise the function will only export the csv file.
1772
- """
1773
- img_collection = self
1774
- # Create empty DataFrame to accumulate results
1775
- accumulated_df = pd.DataFrame()
1776
- # Check if dates is None, if not use the dates provided
1777
- if dates is None:
1778
- dates = img_collection.dates
1779
- else:
1780
- dates = dates
1781
- # Iterate over the dates and extract the zonal statistics for each date
1782
- for date in dates:
1783
- image = img_collection.collection.filter(
1784
- ee.Filter.eq("Date_Filter", date)
1785
- ).first()
1786
- single_df = Sentinel2Collection.extract_zonal_stats_from_buffer(
1787
- image,
1788
- coordinates,
1789
- buffer_size=buffer_size,
1790
- reducer_type=reducer_type,
1791
- scale=scale,
1792
- tileScale=tileScale,
1793
- coordinate_names=coordinate_names,
2400
+ geometries (ee.Geometry, ee.Feature, ee.FeatureCollection, list, or tuple): Input geometries for which to extract statistics. Can be a single ee.Geometry, an ee.Feature, an ee.FeatureCollection, a list of (lon, lat) tuples, or a list of ee.Geometry objects. Be careful to NOT provide coordinates as (lat, lon)!
2401
+ reducer_type (str, optional): The ee.Reducer to use, e.g., 'mean', 'median', 'max', 'sum'. Defaults to 'mean'. Any ee.Reducer method can be used.
2402
+ scale (int, optional): Pixel scale in meters for the reduction. Defaults to 10.
2403
+ geometry_names (list, optional): A list of string names for the geometries. If provided, must match the number of geometries. Defaults to None.
2404
+ buffer_size (int, optional): Radial buffer in meters around coordinates. Defaults to 1.
2405
+ tileScale (int, optional): A scaling factor to reduce aggregation tile size. Defaults to 1.
2406
+ dates (list, optional): A list of date strings ('YYYY-MM-DD') for filtering the collection, such that only images from these dates are included for zonal statistic retrieval. Defaults to None, which uses all dates in the collection.
2407
+ file_path (str, optional): File path to save the output CSV.
2408
+
2409
+ Returns:
2410
+ pd.DataFrame or None: A pandas DataFrame with dates as the index and coordinate names
2411
+ as columns. Returns None if using 'iterative' mode with file_path.
2412
+
2413
+ Raises:
2414
+ ValueError: If input parameters are invalid.
2415
+ TypeError: If geometries input type is unsupported.
2416
+ """
2417
+ img_collection_obj = self
2418
+ # Filter collection by dates if provided
2419
+ if dates:
2420
+ img_collection_obj = Sentinel2Collection(
2421
+ collection=self.collection.filter(ee.Filter.inList('Date_Filter', dates))
1794
2422
  )
1795
- single_df["Date"] = date
1796
- single_df.set_index("Date", inplace=True)
1797
- accumulated_df = pd.concat([accumulated_df, single_df])
1798
- # Return the DataFrame or export the data to a .csv file
1799
- if file_path is None:
1800
- return accumulated_df
2423
+
2424
+ # Initialize variables
2425
+ features = None
2426
+ validated_coordinates = []
2427
+
2428
+ # Function to standardize feature names if no names are provided
2429
+ def set_standard_name(feature):
2430
+ has_geo_name = feature.get('geo_name')
2431
+ has_name = feature.get('name')
2432
+ has_index = feature.get('system:index')
2433
+ new_name = ee.Algorithms.If(
2434
+ has_geo_name, has_geo_name,
2435
+ ee.Algorithms.If(has_name, has_name,
2436
+ ee.Algorithms.If(has_index, has_index, 'unnamed_geometry')))
2437
+ return feature.set({'geo_name': new_name})
2438
+
2439
+ if isinstance(geometries, (ee.FeatureCollection, ee.Feature)):
2440
+ features = ee.FeatureCollection(geometries)
2441
+ if geometry_names:
2442
+ print("Warning: 'geometry_names' are ignored when the input is an ee.Feature or ee.FeatureCollection.")
2443
+
2444
+ elif isinstance(geometries, ee.Geometry):
2445
+ name = geometry_names[0] if (geometry_names and geometry_names[0]) else 'unnamed_geometry'
2446
+ features = ee.FeatureCollection([ee.Feature(geometries).set('geo_name', name)])
2447
+
2448
+ elif isinstance(geometries, list):
2449
+ if not geometries: # Handle empty list case
2450
+ raise ValueError("'geometries' list cannot be empty.")
2451
+
2452
+ # Case: List of coordinates
2453
+ if all(isinstance(i, tuple) for i in geometries):
2454
+ validated_coordinates = geometries
2455
+ if geometry_names is None:
2456
+ geometry_names = [f"Location_{i+1}" for i in range(len(validated_coordinates))]
2457
+ elif len(geometry_names) != len(validated_coordinates):
2458
+ raise ValueError("geometry_names must have the same length as the coordinates list.")
2459
+ points = [
2460
+ ee.Feature(ee.Geometry.Point(coord).buffer(buffer_size), {'geo_name': str(name)})
2461
+ for coord, name in zip(validated_coordinates, geometry_names)
2462
+ ]
2463
+ features = ee.FeatureCollection(points)
2464
+
2465
+ # Case: List of Geometries
2466
+ elif all(isinstance(i, ee.Geometry) for i in geometries):
2467
+ if geometry_names is None:
2468
+ geometry_names = [f"Geometry_{i+1}" for i in range(len(geometries))]
2469
+ elif len(geometry_names) != len(geometries):
2470
+ raise ValueError("geometry_names must have the same length as the geometries list.")
2471
+ geom_features = [
2472
+ ee.Feature(geom).set({'geo_name': str(name)})
2473
+ for geom, name in zip(geometries, geometry_names)
2474
+ ]
2475
+ features = ee.FeatureCollection(geom_features)
2476
+
2477
+ else:
2478
+ raise TypeError("Input list must be a list of (lon, lat) tuples OR a list of ee.Geometry objects.")
2479
+
2480
+ elif isinstance(geometries, tuple) and len(geometries) == 2:
2481
+ name = geometry_names[0] if geometry_names else 'Location_1'
2482
+ features = ee.FeatureCollection([
2483
+ ee.Feature(ee.Geometry.Point(geometries).buffer(buffer_size), {'geo_name': name})
2484
+ ])
1801
2485
  else:
1802
- return accumulated_df.to_csv(f"{file_path}.csv")
2486
+ raise TypeError("Unsupported type for 'geometries'.")
2487
+
2488
+ features = features.map(set_standard_name)
2489
+
2490
+ try:
2491
+ reducer = getattr(ee.Reducer, reducer_type)()
2492
+ except AttributeError:
2493
+ raise ValueError(f"Unknown reducer_type: '{reducer_type}'.")
2494
+
2495
+ def calculate_stats_for_image(image):
2496
+ image_date = image.get('Date_Filter')
2497
+ stats_fc = image.reduceRegions(
2498
+ collection=features, reducer=reducer, scale=scale, tileScale=tileScale
2499
+ )
2500
+ return stats_fc.map(lambda f: f.set('image_date', image_date))
2501
+
2502
+ results_fc = ee.FeatureCollection(img_collection_obj.collection.map(calculate_stats_for_image)).flatten()
2503
+ df = Sentinel2Collection.ee_to_df(results_fc, remove_geom=True)
2504
+
2505
+ # Checking for issues
2506
+ if df.empty:
2507
+ print("No results found for the given parameters. Check if the geometries intersect with the images, if the dates filter is too restrictive, or if the provided bands are empty.")
2508
+ return df
2509
+ if reducer_type not in df.columns:
2510
+ print(f"Warning: Reducer '{reducer_type}' not found in results.")
2511
+ return df
2512
+
2513
+ # Reshape DataFrame to have dates as index and geometry names as columns
2514
+ pivot_df = df.pivot(index='image_date', columns='geo_name', values=reducer_type)
2515
+ pivot_df.index.name = 'Date'
2516
+ if file_path:
2517
+ # Check if file_path ends with .csv and remove it if so for consistency
2518
+ if file_path.endswith('.csv'):
2519
+ file_path = file_path[:-4]
2520
+ pivot_df.to_csv(f"{file_path}.csv")
2521
+ print(f"Zonal stats saved to {file_path}.csv")
2522
+ return
2523
+ return pivot_df