RadGEEToolbox 1.7.3__py3-none-any.whl → 1.7.5__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.
@@ -1,6 +1,7 @@
1
1
  import ee
2
2
  import pandas as pd
3
3
  import numpy as np
4
+ import warnings
4
5
 
5
6
 
6
7
  class GenericCollection:
@@ -93,6 +94,14 @@ class GenericCollection:
93
94
  self._PixelAreaSumCollection = None
94
95
  self._daily_aggregate_collection = None
95
96
 
97
+ def __call__(self):
98
+ """
99
+ Allows the object to be called as a function, returning itself.
100
+ This enables property-like methods to be accessed with or without parentheses
101
+ (e.g., .mosaicByDate or .mosaicByDate()).
102
+ """
103
+ return self
104
+
96
105
  @staticmethod
97
106
  def image_dater(image):
98
107
  """
@@ -271,7 +280,7 @@ class GenericCollection:
271
280
  return out.copyProperties(img).set('system:time_start', img.get('system:time_start'))
272
281
 
273
282
  @staticmethod
274
- def PixelAreaSum(
283
+ def pixelAreaSum(
275
284
  image, band_name, geometry, threshold=-1, scale=30, maxPixels=1e12
276
285
  ):
277
286
  """
@@ -331,7 +340,19 @@ class GenericCollection:
331
340
  final_image = ee.Image(bands.iterate(calculate_and_set_area, image))
332
341
  return final_image #.set('system:time_start', image.get('system:time_start'))
333
342
 
334
- def PixelAreaSumCollection(
343
+ @staticmethod
344
+ def PixelAreaSum(
345
+ image, band_name, geometry, threshold=-1, scale=30, maxPixels=1e12
346
+ ):
347
+ warnings.warn(
348
+ "The `PixelAreaSum` static method is deprecated and will be removed in future versions. Please use the `pixelAreaSum` static method instead.",
349
+ DeprecationWarning,
350
+ stacklevel=2)
351
+ return GenericCollection.pixelAreaSum(
352
+ image, band_name, geometry, threshold, scale, maxPixels
353
+ )
354
+
355
+ def pixelAreaSumCollection(
335
356
  self, band_name, geometry, threshold=-1, scale=30, maxPixels=1e12, output_type='ImageCollection', area_data_export_path=None
336
357
  ):
337
358
  """
@@ -358,7 +379,7 @@ class GenericCollection:
358
379
  collection = self.collection
359
380
  # Area calculation for each image in the collection, using the PixelAreaSum function
360
381
  AreaCollection = collection.map(
361
- lambda image: GenericCollection.PixelAreaSum(
382
+ lambda image: GenericCollection.pixelAreaSum(
362
383
  image,
363
384
  band_name=band_name,
364
385
  geometry=geometry,
@@ -374,17 +395,28 @@ class GenericCollection:
374
395
 
375
396
  # If an export path is provided, the area data will be exported to a CSV file
376
397
  if area_data_export_path:
377
- GenericCollection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=prop_names, file_path=area_data_export_path+'.csv')
398
+ GenericCollection(collection=self._PixelAreaSumCollection).exportProperties(property_names=prop_names, file_path=area_data_export_path+'.csv')
378
399
  # Returning the result in the desired format based on output_type argument or raising an error for invalid input
379
400
  if output_type == 'ImageCollection' or output_type == 'ee.ImageCollection':
380
401
  return self._PixelAreaSumCollection
381
402
  elif output_type == 'GenericCollection':
382
403
  return GenericCollection(collection=self._PixelAreaSumCollection)
383
404
  elif output_type == 'DataFrame' or output_type == 'Pandas' or output_type == 'pd' or output_type == 'dataframe' or output_type == 'df':
384
- return GenericCollection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=prop_names)
405
+ return GenericCollection(collection=self._PixelAreaSumCollection).exportProperties(property_names=prop_names)
385
406
  else:
386
407
  raise ValueError("Incorrect `output_type`. The `output_type` argument must be one of the following: 'ImageCollection', 'ee.ImageCollection', 'GenericCollection', 'DataFrame', 'Pandas', 'pd', 'dataframe', or 'df'.")
387
408
 
409
+ def PixelAreaSumCollection(
410
+ self, band_name, geometry, threshold=-1, scale=30, maxPixels=1e12, output_type='ImageCollection', area_data_export_path=None
411
+ ):
412
+ warnings.warn(
413
+ "The `PixelAreaSumCollection` method is deprecated and will be removed in future versions. Please use the `pixelAreaSumCollection` method instead.",
414
+ DeprecationWarning,
415
+ stacklevel=2)
416
+ return self.pixelAreaSumCollection(
417
+ band_name, geometry, threshold, scale, maxPixels, output_type, area_data_export_path
418
+ )
419
+
388
420
  @staticmethod
389
421
  def add_month_property_fn(image):
390
422
  """
@@ -544,7 +576,7 @@ class GenericCollection:
544
576
 
545
577
  return GenericCollection(collection=distinct_col)
546
578
 
547
- def ExportProperties(self, property_names, file_path=None):
579
+ def exportProperties(self, property_names, file_path=None):
548
580
  """
549
581
  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.
550
582
 
@@ -599,6 +631,13 @@ class GenericCollection:
599
631
  print(f"Properties saved to {file_path}")
600
632
 
601
633
  return df
634
+
635
+ def ExportProperties(self, property_names, file_path=None):
636
+ warnings.warn(
637
+ "The `ExportProperties` method is deprecated and will be removed in future versions. Please use the `exportProperties` method instead.",
638
+ DeprecationWarning,
639
+ stacklevel=2)
640
+ return self.exportProperties(property_names, file_path)
602
641
 
603
642
  def get_generic_collection(self):
604
643
  """
@@ -1983,6 +2022,8 @@ class GenericCollection:
1983
2022
  rightField='Date_Filter')
1984
2023
  else:
1985
2024
  raise ValueError(f'The chosen `join_method`: {join_method} does not match the options of "system:time_start" or "Date_Filter".')
2025
+
2026
+ native_projection = image_collection.first().select(target_band).projection()
1986
2027
 
1987
2028
  # for any matches during a join, set image as a property key called 'future_image'
1988
2029
  join = ee.Join.saveAll(matchesKey='future_image')
@@ -2026,7 +2067,7 @@ class GenericCollection:
2026
2067
  # convert the image collection to an image of s_statistic values per pixel
2027
2068
  # where the s_statistic is the sum of partial s values
2028
2069
  # renaming the band as 's_statistic' for later usage
2029
- final_s_image = partial_s_col.sum().rename('s_statistic')
2070
+ final_s_image = partial_s_col.sum().rename('s_statistic').setDefaultProjection(native_projection)
2030
2071
 
2031
2072
 
2032
2073
  ########## PART 2 - VARIANCE and Z-SCORE ##########
@@ -2089,7 +2130,7 @@ class GenericCollection:
2089
2130
  mask = ee.Image(1).clip(geometry)
2090
2131
  final_image = final_image.updateMask(mask)
2091
2132
 
2092
- return final_image
2133
+ return final_image.setDefaultProjection(native_projection)
2093
2134
 
2094
2135
  def sens_slope_trend(self, target_band=None, join_method='system:time_start', geometry=None):
2095
2136
  """
@@ -2166,20 +2207,15 @@ class GenericCollection:
2166
2207
  GenericCollection: masked GenericCollection image collection
2167
2208
 
2168
2209
  """
2169
- if self._geometry_masked_collection is None:
2170
- # Convert the polygon to a mask
2171
- mask = ee.Image.constant(1).clip(polygon)
2172
-
2173
- # Update the mask of each image in the collection
2174
- masked_collection = self.collection.map(lambda img: img.updateMask(mask).copyProperties(img).set('system:time_start', img.get('system:time_start')))
2210
+ # Convert the polygon to a mask
2211
+ mask = ee.Image.constant(1).clip(polygon)
2175
2212
 
2176
- # Update the internal collection state
2177
- self._geometry_masked_collection = GenericCollection(
2178
- collection=masked_collection
2179
- )
2213
+ # Update the mask of each image in the collection
2214
+ masked_collection = self.collection.map(lambda img: img.updateMask(mask)\
2215
+ .copyProperties(img).set('system:time_start', img.get('system:time_start')))
2180
2216
 
2181
2217
  # Return the updated object
2182
- return self._geometry_masked_collection
2218
+ return GenericCollection(collection=masked_collection)
2183
2219
 
2184
2220
  def mask_out_polygon(self, polygon):
2185
2221
  """
@@ -2192,23 +2228,18 @@ class GenericCollection:
2192
2228
  GenericCollection: masked GenericCollection image collection
2193
2229
 
2194
2230
  """
2195
- if self._geometry_masked_out_collection is None:
2196
- # Convert the polygon to a mask
2197
- full_mask = ee.Image.constant(1)
2231
+ # Convert the polygon to a mask
2232
+ full_mask = ee.Image.constant(1)
2198
2233
 
2199
- # Use paint to set pixels inside polygon as 0
2200
- area = full_mask.paint(polygon, 0)
2234
+ # Use paint to set pixels inside polygon as 0
2235
+ area = full_mask.paint(polygon, 0)
2201
2236
 
2202
- # Update the mask of each image in the collection
2203
- masked_collection = self.collection.map(lambda img: img.updateMask(area).copyProperties(img).set('system:time_start', img.get('system:time_start')))
2204
-
2205
- # Update the internal collection state
2206
- self._geometry_masked_out_collection = GenericCollection(
2207
- collection=masked_collection
2208
- )
2237
+ # Update the mask of each image in the collection
2238
+ masked_collection = self.collection.map(lambda img: img.updateMask(area)\
2239
+ .copyProperties(img).set('system:time_start', img.get('system:time_start')))
2209
2240
 
2210
2241
  # Return the updated object
2211
- return self._geometry_masked_out_collection
2242
+ return GenericCollection(collection=masked_collection)
2212
2243
 
2213
2244
 
2214
2245
  def binary_mask(self, threshold=None, band_name=None, classify_above_threshold=True, mask_zeros=False):
@@ -2467,7 +2498,7 @@ class GenericCollection:
2467
2498
  new_col = self.collection.filter(ee.Filter.eq("Date_Filter", img_date))
2468
2499
  return new_col.first()
2469
2500
 
2470
- def CollectionStitch(self, img_col2):
2501
+ def collectionStitch(self, img_col2):
2471
2502
  """
2472
2503
  Function to mosaic two GenericCollection objects which share image dates.
2473
2504
  Mosaics are only formed for dates where both image collections have images.
@@ -2519,9 +2550,16 @@ class GenericCollection:
2519
2550
 
2520
2551
  # Return a GenericCollection instance
2521
2552
  return GenericCollection(collection=new_col)
2553
+
2554
+ def CollectionStitch(self, img_col2):
2555
+ warnings.warn(
2556
+ "CollectionStitch is deprecated. Please use collectionStitch instead.",
2557
+ DeprecationWarning,
2558
+ stacklevel=2)
2559
+ return self.collectionStitch(img_col2)
2522
2560
 
2523
2561
  @property
2524
- def MosaicByDate(self):
2562
+ def mosaicByDateDepr(self):
2525
2563
  """
2526
2564
  Property attribute function to mosaic collection images that share the same date.
2527
2565
 
@@ -2577,6 +2615,64 @@ class GenericCollection:
2577
2615
 
2578
2616
  # Convert the list of mosaics to an ImageCollection
2579
2617
  return self._MosaicByDate
2618
+
2619
+ @property
2620
+ def mosaicByDate(self):
2621
+ """
2622
+ Property attribute function to mosaic collection images that share the same date.
2623
+
2624
+ The property CLOUD_COVER for each image is used to calculate an overall mean,
2625
+ which replaces the CLOUD_COVER property for each mosaiced image.
2626
+ Server-side friendly.
2627
+
2628
+ NOTE: if images are removed from the collection from cloud filtering, you may have mosaics composed of only one image.
2629
+
2630
+ Returns:
2631
+ LandsatCollection: LandsatCollection image collection with mosaiced imagery and mean CLOUD_COVER as a property
2632
+ """
2633
+ if self._MosaicByDate is None:
2634
+ distinct_dates = self.collection.distinct("Date_Filter")
2635
+
2636
+ # Define a join to link images by Date_Filter
2637
+ filter_date = ee.Filter.equals(leftField="Date_Filter", rightField="Date_Filter")
2638
+ join = ee.Join.saveAll(matchesKey="date_matches")
2639
+
2640
+ # Apply the join
2641
+ # Primary: Distinct dates collection
2642
+ # Secondary: The full original collection
2643
+ joined_col = ee.ImageCollection(join.apply(distinct_dates, self.collection, filter_date))
2644
+
2645
+ # Define the mosaicking function
2646
+ def _mosaic_day(img):
2647
+ # Recover the list of images for this day
2648
+ daily_list = ee.List(img.get("date_matches"))
2649
+ daily_col = ee.ImageCollection.fromImages(daily_list)
2650
+
2651
+ # Create the mosaic
2652
+ mosaic = daily_col.mosaic().setDefaultProjection(img.projection())
2653
+
2654
+ # Properties to preserve from the representative image
2655
+ props_of_interest = [
2656
+ "system:time_start",
2657
+ "Date_Filter"
2658
+ ]
2659
+
2660
+ # Return mosaic with properties set
2661
+ return mosaic.copyProperties(img, props_of_interest)
2662
+ # 5. Map the function and wrap the result
2663
+ mosaiced_col = joined_col.map(_mosaic_day)
2664
+ self._MosaicByDate = GenericCollection(collection=mosaiced_col)
2665
+
2666
+ # Convert the list of mosaics to an ImageCollection
2667
+ return self._MosaicByDate
2668
+
2669
+ @property
2670
+ def MosaicByDate(self):
2671
+ warnings.warn(
2672
+ "MosaicByDate is deprecated. Please use mosaicByDate instead.",
2673
+ DeprecationWarning,
2674
+ stacklevel=2)
2675
+ return self.mosaicByDate
2580
2676
 
2581
2677
  @staticmethod
2582
2678
  def ee_to_df(
@@ -2797,200 +2893,197 @@ class GenericCollection:
2797
2893
  lines,
2798
2894
  line_names,
2799
2895
  reducer="mean",
2800
- dist_interval=30,
2896
+ dist_interval=90,
2801
2897
  n_segments=None,
2802
2898
  scale=30,
2803
2899
  processing_mode='aggregated',
2804
2900
  save_folder_path=None,
2805
2901
  sampling_method='line',
2806
- point_buffer_radius=15
2902
+ point_buffer_radius=15,
2903
+ batch_size=10
2807
2904
  ):
2808
2905
  """
2809
- Computes and returns pixel values along transects for each image in a collection.
2810
-
2811
- This iterative function generates time-series data along one or more lines, and
2812
- supports two different geometric sampling methods ('line' and 'buffered_point')
2813
- for maximum flexibility and performance.
2814
-
2815
- There are two processing modes available, aggregated and iterative:
2816
- - 'aggregated' (default; suggested): Fast, server-side processing. Fetches all results
2817
- in a single request. Highly recommended. Returns a dictionary of pandas DataFrames.
2818
- - 'iterative': Slower, client-side loop that processes one image at a time.
2819
- Kept for backward compatibility (effectively depreciated). Returns None and saves individual CSVs.
2820
- This method is not recommended unless absolutely necessary, as it is less efficient and may be subject to client-side timeouts.
2821
-
2906
+ Computes and returns pixel values along transects. Provide a list of ee.Geometry.LineString objects and corresponding names, and the function will compute the specified reducer value
2907
+ at regular intervals along each line for all images in the collection. Use `dist_interval` or `n_segments` to control sampling resolution. The user can choose between 'aggregated' mode (returns a dictionary of DataFrames) or 'iterative' mode (saves individual CSVs for each transect).
2908
+ Alter `sampling_method` to sample directly along the line or via buffered points along the line. Buffered points can help capture more representative pixel values in heterogeneous landscapes, and the buffer radius can be adjusted via `point_buffer_radius`.
2909
+
2822
2910
  Args:
2823
- lines (list): A list of one or more ee.Geometry.LineString objects that
2824
- define the transects.
2825
- line_names (list): A list of string names for each transect. The length
2826
- of this list must match the length of the `lines` list.
2827
- reducer (str, optional): The name of the ee.Reducer to apply at each
2828
- transect point (e.g., 'mean', 'median', 'first'). Defaults to 'mean'.
2829
- dist_interval (float, optional): The distance interval in meters for
2830
- sampling points along each transect. Will be overridden if `n_segments` is provided.
2831
- Defaults to 30. Recommended to increase this value when using the
2832
- 'line' processing method, or else you may get blank rows.
2833
- n_segments (int, optional): The number of equal-length segments to split
2834
- each transect line into for sampling. This parameter overrides `dist_interval`.
2835
- Defaults to None.
2836
- scale (int, optional): The nominal scale in meters for the reduction,
2837
- which should typically match the pixel resolution of the imagery.
2838
- Defaults to 30.
2839
- processing_mode (str, optional): The method for processing the collection.
2840
- - 'aggregated' (default): Fast, server-side processing. Fetches all
2841
- results in a single request. Highly recommended. Returns a dictionary
2842
- of pandas DataFrames.
2843
- - 'iterative': Slower, client-side loop that processes one image at a
2844
- time. Kept for backward compatibility. Returns None and saves
2845
- individual CSVs.
2846
- save_folder_path (str, optional): If provided, the function will save the
2847
- resulting transect data to CSV files. The behavior depends on the
2848
- `processing_mode`:
2849
- - In 'aggregated' mode, one CSV is saved for each transect,
2850
- containing all dates. (e.g., 'MyTransect_transects.csv').
2851
- - In 'iterative' mode, one CSV is saved for each date,
2852
- containing all transects. (e.g., '2022-06-15_transects.csv').
2853
- sampling_method (str, optional): The geometric method used for sampling.
2854
- - 'line' (default): Reduces all pixels intersecting each small line
2855
- segment. This can be unreliable and produce blank rows if
2856
- `dist_interval` is too small relative to the `scale`.
2857
- - 'buffered_point': Reduces all pixels within a buffer around the
2858
- midpoint of each line segment. This method is more robust and
2859
- reliably avoids blank rows, but may not reduce all pixels along a line segment.
2860
- point_buffer_radius (int, optional): The radius in meters for the buffer
2861
- when `sampling_method` is 'buffered_point'. Defaults to 15.
2911
+ lines (list): List of ee.Geometry.LineString objects.
2912
+ line_names (list): List of string names for each transect.
2913
+ reducer (str, optional): Reducer name. Defaults to 'mean'.
2914
+ dist_interval (float, optional): Distance interval in meters. Defaults to 90.
2915
+ n_segments (int, optional): Number of segments (overrides dist_interval).
2916
+ scale (int, optional): Scale in meters. Defaults to 30.
2917
+ processing_mode (str, optional): 'aggregated' or 'iterative'.
2918
+ save_folder_path (str, optional): Path to save CSVs.
2919
+ sampling_method (str, optional): 'line' or 'buffered_point'.
2920
+ point_buffer_radius (int, optional): Buffer radius if using 'buffered_point'.
2921
+ batch_size (int, optional): Images per request in 'aggregated' mode. Defaults to 10. Lower the value if you encounter a 'Too many aggregations' error.
2862
2922
 
2863
2923
  Returns:
2864
- dict or None:
2865
- - If `processing_mode` is 'aggregated', returns a dictionary where each
2866
- key is a transect name and each value is a pandas DataFrame. In the
2867
- DataFrame, the index is the distance along the transect and each
2868
- column represents an image date. Optionally saves CSV files if
2869
- `save_folder_path` is provided.
2870
- - If `processing_mode` is 'iterative', returns None as it saves
2871
- files directly.
2872
-
2873
- Raises:
2874
- ValueError: If `lines` and `line_names` have different lengths, or if
2875
- an unknown reducer or processing mode is specified.
2924
+ dict or None: Dictionary of DataFrames (aggregated) or None (iterative).
2876
2925
  """
2877
- # Validating inputs
2878
2926
  if len(lines) != len(line_names):
2879
2927
  raise ValueError("'lines' and 'line_names' must have the same number of elements.")
2880
- ### Current, server-side processing method ###
2928
+
2929
+ first_img = self.collection.first()
2930
+ bands = first_img.bandNames().getInfo()
2931
+ is_multiband = len(bands) > 1
2932
+
2933
+ # Setup robust dictionary for handling masked/zero values
2934
+ default_val = -9999
2935
+ dummy_dict = ee.Dictionary.fromLists(bands, ee.List.repeat(default_val, len(bands)))
2936
+
2937
+ if is_multiband:
2938
+ reducer_cols = [f"{b}_{reducer}" for b in bands]
2939
+ clean_names = bands
2940
+ rename_keys = bands
2941
+ rename_vals = reducer_cols
2942
+ else:
2943
+ reducer_cols = [reducer]
2944
+ clean_names = [bands[0]]
2945
+ rename_keys = bands
2946
+ rename_vals = reducer_cols
2947
+
2948
+ print("Pre-computing transect geometries from input LineString(s)...")
2949
+
2950
+ master_transect_fc = ee.FeatureCollection([])
2951
+ geom_error = 1.0
2952
+
2953
+ for i, line in enumerate(lines):
2954
+ line_name = line_names[i]
2955
+ length = line.length(geom_error)
2956
+
2957
+ eff_interval = length.divide(n_segments) if n_segments else dist_interval
2958
+
2959
+ distances = ee.List.sequence(0, length, eff_interval)
2960
+ cut_lines = line.cutLines(distances, geom_error).geometries()
2961
+
2962
+ def create_feature(l):
2963
+ geom = ee.Geometry(ee.List(l).get(0))
2964
+ dist = ee.Number(ee.List(l).get(1))
2965
+
2966
+ final_geom = ee.Algorithms.If(
2967
+ ee.String(sampling_method).equals('buffered_point'),
2968
+ geom.centroid(geom_error).buffer(point_buffer_radius),
2969
+ geom
2970
+ )
2971
+
2972
+ return ee.Feature(ee.Geometry(final_geom), {
2973
+ 'transect_name': line_name,
2974
+ 'distance': dist
2975
+ })
2976
+
2977
+ line_fc = ee.FeatureCollection(cut_lines.zip(distances).map(create_feature))
2978
+ master_transect_fc = master_transect_fc.merge(line_fc)
2979
+
2980
+ try:
2981
+ ee_reducer = getattr(ee.Reducer, reducer)()
2982
+ except AttributeError:
2983
+ raise ValueError(f"Unknown reducer: '{reducer}'.")
2984
+
2985
+ def process_image(image):
2986
+ date_val = image.get('Date_Filter')
2987
+
2988
+ # Map over points (Slower but Robust)
2989
+ def reduce_point(f):
2990
+ stats = image.reduceRegion(
2991
+ reducer=ee_reducer,
2992
+ geometry=f.geometry(),
2993
+ scale=scale,
2994
+ maxPixels=1e13
2995
+ )
2996
+ # Combine with defaults (preserves 0, handles masked)
2997
+ safe_stats = dummy_dict.combine(stats, overwrite=True)
2998
+ # Rename keys to match expected outputs (e.g. 'ndvi' -> 'ndvi_mean')
2999
+ final_stats = safe_stats.rename(rename_keys, rename_vals)
3000
+
3001
+ return f.set(final_stats).set({'image_date': date_val})
3002
+
3003
+ return master_transect_fc.map(reduce_point)
3004
+
3005
+ export_cols = ['transect_name', 'distance', 'image_date'] + reducer_cols
3006
+
2881
3007
  if processing_mode == 'aggregated':
2882
- # Validating reducer type
2883
- try:
2884
- ee_reducer = getattr(ee.Reducer, reducer)()
2885
- except AttributeError:
2886
- raise ValueError(f"Unknown reducer: '{reducer}'.")
2887
- ### Function to extract transects for a single image
2888
- def get_transects_for_image(image):
2889
- image_date = image.get('Date_Filter')
2890
- # Initialize an empty list to hold all transect FeatureCollections
2891
- all_transects_for_image = ee.List([])
2892
- # Looping through each line and processing
2893
- for i, line in enumerate(lines):
2894
- # Index line and name
2895
- line_name = line_names[i]
2896
- # Determine maxError based on image projection, used for geometry operations
2897
- maxError = image.projection().nominalScale().divide(5)
2898
- # Calculate effective distance interval
2899
- length = line.length(maxError) # using maxError here ensures consistency with cutLines
2900
- # Determine effective distance interval based on n_segments or dist_interval
2901
- effective_dist_interval = ee.Algorithms.If(
2902
- n_segments,
2903
- length.divide(n_segments),
2904
- dist_interval or 30 # Defaults to 30 if both are None
2905
- )
2906
- # Generate distances along the line(s) for segmentation
2907
- distances = ee.List.sequence(0, length, effective_dist_interval)
2908
- # Segmenting the line into smaller lines at the specified distances
2909
- cut_lines_geoms = line.cutLines(distances, maxError).geometries()
2910
- # Function to create features with distance attributes
2911
- # Adjusted to ensure consistent return types
2912
- def set_dist_attr(l):
2913
- # l is a list: [geometry, distance]
2914
- # Extracting geometry portion of line
2915
- geom_segment = ee.Geometry(ee.List(l).get(0))
2916
- # Extracting distance value for attribute
2917
- distance = ee.Number(ee.List(l).get(1))
2918
- ### Determine final geometry based on sampling method
2919
- # If the sampling method is 'buffered_point',
2920
- # create a buffered point feature at the centroid of each segment,
2921
- # otherwise create a line feature
2922
- final_feature = ee.Algorithms.If(
2923
- ee.String(sampling_method).equals('buffered_point'),
2924
- # True Case: Create the buffered point feature
2925
- ee.Feature(
2926
- geom_segment.centroid(maxError).buffer(point_buffer_radius),
2927
- {'distance': distance}
2928
- ),
2929
- # False Case: Create the line segment feature
2930
- ee.Feature(geom_segment, {'distance': distance})
2931
- )
2932
- # Return either the line segment feature or the buffered point feature
2933
- return final_feature
2934
- # Creating a FeatureCollection of the cut lines with distance attributes
2935
- # Using map to apply the set_dist_attr function to each cut line geometry
2936
- line_features = ee.FeatureCollection(cut_lines_geoms.zip(distances).map(set_dist_attr))
2937
- # Reducing the image over the line features to get transect values
2938
- transect_fc = image.reduceRegions(
2939
- collection=line_features, reducer=ee_reducer, scale=scale
2940
- )
2941
- # Adding image date and line name properties to each feature
2942
- def set_props(feature):
2943
- return feature.set({'image_date': image_date, 'transect_name': line_name})
2944
- # Append to the list of all transects for this image
2945
- all_transects_for_image = all_transects_for_image.add(transect_fc.map(set_props))
2946
- # Combine all transect FeatureCollections into a single FeatureCollection and flatten
2947
- # Flatten is used to merge the list of FeatureCollections into one
2948
- return ee.FeatureCollection(all_transects_for_image).flatten()
2949
- # Map the function over the entire image collection and flatten the results
2950
- results_fc = ee.FeatureCollection(self.collection.map(get_transects_for_image)).flatten()
2951
- # Convert the results to a pandas DataFrame
2952
- df = GenericCollection.ee_to_df(results_fc, remove_geom=True)
2953
- # Check if the DataFrame is empty
2954
- if df.empty:
2955
- print("Warning: No transect data was generated.")
3008
+ collection_size = self.collection.size().getInfo()
3009
+ print(f"Starting batch process of {collection_size} images...")
3010
+
3011
+ dfs = []
3012
+ for i in range(0, collection_size, batch_size):
3013
+ print(f" Processing image {i} to {min(i + batch_size, collection_size)}...")
3014
+
3015
+ batch_col = ee.ImageCollection(self.collection.toList(batch_size, i))
3016
+ results_fc = batch_col.map(process_image).flatten()
3017
+
3018
+ # Dynamic Class Call for ee_to_df
3019
+ df_batch = self.__class__.ee_to_df(results_fc, columns=export_cols, remove_geom=True)
3020
+
3021
+ if not df_batch.empty:
3022
+ dfs.append(df_batch)
3023
+
3024
+ if not dfs:
3025
+ print("Warning: No transect data generated.")
2956
3026
  return {}
2957
- # Initialize dictionary to hold output DataFrames for each transect
3027
+
3028
+ df = pd.concat(dfs, ignore_index=True)
3029
+
3030
+ # Post-Process & Split
2958
3031
  output_dfs = {}
2959
- # Loop through each unique transect name and create a pivot table
3032
+ for col in reducer_cols:
3033
+ df[col] = pd.to_numeric(df[col], errors='coerce')
3034
+ df[col] = df[col].replace(-9999, np.nan)
3035
+
2960
3036
  for name in sorted(df['transect_name'].unique()):
2961
- transect_df = df[df['transect_name'] == name]
2962
- pivot_df = transect_df.pivot(index='distance', columns='image_date', values=reducer)
2963
- pivot_df.columns.name = 'Date'
2964
- output_dfs[name] = pivot_df
2965
- # Optionally save each transect DataFrame to CSV
2966
- if save_folder_path:
2967
- for transect_name, transect_df in output_dfs.items():
2968
- safe_filename = "".join(x for x in transect_name if x.isalnum() or x in "._-")
2969
- file_path = f"{save_folder_path}{safe_filename}_transects.csv"
2970
- transect_df.to_csv(file_path)
2971
- print(f"Saved transect data to {file_path}")
2972
-
3037
+ line_df = df[df['transect_name'] == name]
3038
+
3039
+ for raw_col, band_name in zip(reducer_cols, clean_names):
3040
+ try:
3041
+ # Safety drop for duplicates
3042
+ line_df_clean = line_df.drop_duplicates(subset=['distance', 'image_date'])
3043
+
3044
+ pivot = line_df_clean.pivot(index='distance', columns='image_date', values=raw_col)
3045
+ pivot.columns.name = 'Date'
3046
+ key = f"{name}_{band_name}"
3047
+ output_dfs[key] = pivot
3048
+
3049
+ if save_folder_path:
3050
+ safe_key = "".join(x for x in key if x.isalnum() or x in "._-")
3051
+ fname = f"{save_folder_path}{safe_key}_transects.csv"
3052
+ pivot.to_csv(fname)
3053
+ print(f"Saved: {fname}")
3054
+ except Exception as e:
3055
+ print(f"Skipping pivot for {name}/{band_name}: {e}")
3056
+
2973
3057
  return output_dfs
2974
3058
 
2975
- ### old, depreciated iterative client-side processing method ###
2976
3059
  elif processing_mode == 'iterative':
2977
3060
  if not save_folder_path:
2978
- raise ValueError("`save_folder_path` is required for 'iterative' processing mode.")
3061
+ raise ValueError("save_folder_path is required for iterative mode.")
2979
3062
 
2980
3063
  image_collection_dates = self.dates
2981
3064
  for i, date in enumerate(image_collection_dates):
2982
3065
  try:
2983
3066
  print(f"Processing image {i+1}/{len(image_collection_dates)}: {date}")
2984
- image = self.image_grab(i)
2985
- transects_df = GenericCollection.transect(
2986
- image, lines, line_names, reducer, n_segments, dist_interval, to_pandas=True
2987
- )
2988
- transects_df.to_csv(f"{save_folder_path}{date}_transects.csv")
2989
- print(f"{date}_transects saved to csv")
3067
+ image_list = self.collection.toList(self.collection.size())
3068
+ image = ee.Image(image_list.get(i))
3069
+
3070
+ fc_result = process_image(image)
3071
+ df = self.__class__.ee_to_df(fc_result, columns=export_cols, remove_geom=True)
3072
+
3073
+ if not df.empty:
3074
+ for col in reducer_cols:
3075
+ df[col] = pd.to_numeric(df[col], errors='coerce')
3076
+ df[col] = df[col].replace(-9999, np.nan)
3077
+
3078
+ fname = f"{save_folder_path}{date}_transects.csv"
3079
+ df.to_csv(fname, index=False)
3080
+ print(f"Saved: {fname}")
3081
+ else:
3082
+ print(f"Skipping {date}: No data.")
2990
3083
  except Exception as e:
2991
- print(f"An error occurred while processing image {i+1}: {e}")
3084
+ print(f"Error processing {date}: {e}")
2992
3085
  else:
2993
- raise ValueError("`processing_mode` must be 'iterative' or 'aggregated'.")
3086
+ raise ValueError("processing_mode must be 'iterative' or 'aggregated'.")
2994
3087
 
2995
3088
  @staticmethod
2996
3089
  def extract_zonal_stats_from_buffer(
@@ -3094,7 +3187,8 @@ class GenericCollection:
3094
3187
  buffer_size=1,
3095
3188
  tileScale=1,
3096
3189
  dates=None,
3097
- file_path=None
3190
+ file_path=None,
3191
+ unweighted=False
3098
3192
  ):
3099
3193
  """
3100
3194
  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.
@@ -3111,6 +3205,7 @@ class GenericCollection:
3111
3205
  tileScale (int, optional): A scaling factor to reduce aggregation tile size. Defaults to 1.
3112
3206
  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.
3113
3207
  file_path (str, optional): File path to save the output CSV.
3208
+ unweighted (bool, optional): If True, uses unweighted statistics when applicable (e.g., for 'mean'). Defaults to False.
3114
3209
 
3115
3210
  Returns:
3116
3211
  pd.DataFrame or None: A pandas DataFrame with dates as the index and coordinate names
@@ -3217,6 +3312,9 @@ class GenericCollection:
3217
3312
  reducer = getattr(ee.Reducer, reducer_type)()
3218
3313
  except AttributeError:
3219
3314
  raise ValueError(f"Unknown reducer_type: '{reducer_type}'.")
3315
+
3316
+ if unweighted:
3317
+ reducer = reducer.unweighted()
3220
3318
 
3221
3319
  # Define the function to map over the image collection
3222
3320
  def calculate_stats_for_image(image):
@@ -3278,6 +3376,394 @@ class GenericCollection:
3278
3376
  print(f"Zonal stats saved to {file_path}.csv")
3279
3377
  return
3280
3378
  return pivot_df
3379
+
3380
+ def multiband_zonal_stats(
3381
+ self,
3382
+ geometry,
3383
+ bands,
3384
+ reducer_types,
3385
+ scale=30,
3386
+ geometry_name='geom',
3387
+ dates=None,
3388
+ include_area=False,
3389
+ file_path=None,
3390
+ unweighted=False
3391
+ ):
3392
+ """
3393
+ Calculates zonal statistics for multiple bands over a single geometry for each image in the collection.
3394
+ Allows for specifying different reducers for different bands. Optionally includes the geometry area.
3395
+
3396
+ Args:
3397
+ geometry (ee.Geometry or ee.Feature): The single geometry to calculate statistics for.
3398
+ bands (list of str): A list of band names to include in the analysis.
3399
+ reducer_types (str or list of str): A single reducer name (e.g., 'mean') to apply to all bands,
3400
+ or a list of reducer names matching the length of the 'bands' list to apply specific reducers
3401
+ to specific bands.
3402
+ scale (int, optional): The scale in meters for the reduction. Defaults to 30.
3403
+ geometry_name (str, optional): A name for the geometry, used in column naming. Defaults to 'geom'.
3404
+ dates (list of str, optional): A list of date strings ('YYYY-MM-DD') to filter the collection.
3405
+ Defaults to None (processes all images).
3406
+ include_area (bool, optional): If True, adds a column with the area of the geometry in square meters.
3407
+ Defaults to False.
3408
+ file_path (str, optional): If provided, saves the resulting DataFrame to a CSV file at this path.
3409
+ unweighted (bool, optional): If True, uses unweighted statistics when applicable (e.g., for 'mean'). Defaults to False.
3410
+
3411
+ Returns:
3412
+ pd.DataFrame: A pandas DataFrame indexed by Date, with columns named as '{band}_{geometry_name}_{reducer}'.
3413
+ """
3414
+ # 1. Input Validation and Setup
3415
+ if not isinstance(geometry, (ee.Geometry, ee.Feature)):
3416
+ raise ValueError("The `geometry` argument must be an ee.Geometry or ee.Feature.")
3417
+
3418
+ region = geometry.geometry() if isinstance(geometry, ee.Feature) else geometry
3419
+
3420
+ if isinstance(bands, str):
3421
+ bands = [bands]
3422
+ if not isinstance(bands, list):
3423
+ raise ValueError("The `bands` argument must be a string or a list of strings.")
3424
+
3425
+ # Handle reducer_types (str vs list)
3426
+ if isinstance(reducer_types, str):
3427
+ reducers_list = [reducer_types] * len(bands)
3428
+ elif isinstance(reducer_types, list):
3429
+ if len(reducer_types) != len(bands):
3430
+ raise ValueError("If `reducer_types` is a list, it must have the same length as `bands`.")
3431
+ reducers_list = reducer_types
3432
+ else:
3433
+ raise ValueError("`reducer_types` must be a string or a list of strings.")
3434
+
3435
+ # 2. Filter Collection
3436
+ processing_col = self.collection
3437
+
3438
+ if dates:
3439
+ processing_col = processing_col.filter(ee.Filter.inList('Date_Filter', dates))
3440
+
3441
+ processing_col = processing_col.select(bands)
3442
+
3443
+ # 3. Pre-calculate Area (if requested)
3444
+ area_val = None
3445
+ area_col_name = f"{geometry_name}_area_m2"
3446
+ if include_area:
3447
+ # Calculate geodesic area in square meters with maxError of 1m
3448
+ area_val = region.area(1)
3449
+
3450
+ # 4. Define the Reduction Logic
3451
+ def calculate_multiband_stats(image):
3452
+ # Base feature with date property
3453
+ date_val = image.get('Date_Filter')
3454
+ feature = ee.Feature(None, {'Date': date_val})
3455
+
3456
+ # If requested, add the static area value to every feature
3457
+ if include_area:
3458
+ feature = feature.set(area_col_name, area_val)
3459
+
3460
+ unique_reducers = list(set(reducers_list))
3461
+
3462
+ # OPTIMIZED PATH: Single reducer type for all bands
3463
+ if len(unique_reducers) == 1:
3464
+ r_type = unique_reducers[0]
3465
+ try:
3466
+ reducer = getattr(ee.Reducer, r_type)()
3467
+ except AttributeError:
3468
+ reducer = ee.Reducer.mean()
3469
+
3470
+ if unweighted:
3471
+ reducer = reducer.unweighted()
3472
+
3473
+ stats = image.reduceRegion(
3474
+ reducer=reducer,
3475
+ geometry=region,
3476
+ scale=scale,
3477
+ maxPixels=1e13
3478
+ )
3479
+
3480
+ for band in bands:
3481
+ col_name = f"{band}_{geometry_name}_{r_type}"
3482
+ val = stats.get(band)
3483
+ feature = feature.set(col_name, val)
3484
+
3485
+ # ITERATIVE PATH: Different reducers for different bands
3486
+ else:
3487
+ for band, r_type in zip(bands, reducers_list):
3488
+ try:
3489
+ reducer = getattr(ee.Reducer, r_type)()
3490
+ except AttributeError:
3491
+ reducer = ee.Reducer.mean()
3492
+
3493
+ if unweighted:
3494
+ reducer = reducer.unweighted()
3495
+
3496
+ stats = image.select(band).reduceRegion(
3497
+ reducer=reducer,
3498
+ geometry=region,
3499
+ scale=scale,
3500
+ maxPixels=1e13
3501
+ )
3502
+
3503
+ val = stats.get(band)
3504
+ col_name = f"{band}_{geometry_name}_{r_type}"
3505
+ feature = feature.set(col_name, val)
3506
+
3507
+ return feature
3508
+
3509
+ # 5. Execute Server-Side Mapping (with explicit Cast)
3510
+ results_fc = ee.FeatureCollection(processing_col.map(calculate_multiband_stats))
3511
+
3512
+ # 6. Client-Side Conversion
3513
+ try:
3514
+ df = GenericCollection.ee_to_df(results_fc, remove_geom=True)
3515
+ except Exception as e:
3516
+ raise RuntimeError(f"Failed to convert Earth Engine results to DataFrame. Error: {e}")
3517
+
3518
+ if df.empty:
3519
+ print("Warning: No results returned. Check if the geometry intersects the imagery or if dates are valid.")
3520
+ return pd.DataFrame()
3521
+
3522
+ # 7. Formatting & Reordering
3523
+ if 'Date' in df.columns:
3524
+ df['Date'] = pd.to_datetime(df['Date'])
3525
+ df = df.sort_values('Date').set_index('Date')
3526
+
3527
+ # Construct the expected column names in the exact order of the input lists
3528
+ expected_order = [f"{band}_{geometry_name}_{r_type}" for band, r_type in zip(bands, reducers_list)]
3529
+
3530
+ # If area was included, append it to the END of the list
3531
+ if include_area:
3532
+ expected_order.append(area_col_name)
3533
+
3534
+ # Reindex the DataFrame to match this order.
3535
+ existing_cols = [c for c in expected_order if c in df.columns]
3536
+ df = df[existing_cols]
3537
+
3538
+ # 8. Export (Optional)
3539
+ if file_path:
3540
+ if not file_path.lower().endswith('.csv'):
3541
+ file_path += '.csv'
3542
+ try:
3543
+ df.to_csv(file_path)
3544
+ print(f"Multiband zonal stats saved to {file_path}")
3545
+ except Exception as e:
3546
+ print(f"Error saving file to {file_path}: {e}")
3547
+
3548
+ return df
3549
+
3550
+ def sample(
3551
+ self,
3552
+ locations,
3553
+ band=None,
3554
+ scale=None,
3555
+ location_names=None,
3556
+ dates=None,
3557
+ file_path=None,
3558
+ tileScale=1
3559
+ ):
3560
+ """
3561
+ Extracts time-series pixel values for a list of locations.
3562
+
3563
+
3564
+ Args:
3565
+ locations (list, tuple, ee.Geometry, or ee.FeatureCollection): Input points.
3566
+ band (str, optional): The name of the band to sample. Defaults to the first band.
3567
+ scale (int, optional): Scale in meters. Defaults to 30 if None.
3568
+ location_names (list of str, optional): Custom names for locations.
3569
+ dates (list, optional): Date filter ['YYYY-MM-DD'].
3570
+ file_path (str, optional): CSV export path.
3571
+ tileScale (int, optional): Aggregation tile scale. Defaults to 1.
3572
+
3573
+ Returns:
3574
+ pd.DataFrame (or CSV if file_path is provided): DataFrame indexed by Date, columns by Location.
3575
+ """
3576
+ col = self.collection
3577
+ if dates:
3578
+ col = col.filter(ee.Filter.inList('Date_Filter', dates))
3579
+
3580
+ first_img = col.first()
3581
+ available_bands = first_img.bandNames().getInfo()
3582
+
3583
+ if band:
3584
+ if band not in available_bands:
3585
+ raise ValueError(f"Band '{band}' not found. Available: {available_bands}")
3586
+ target_band = band
3587
+ else:
3588
+ target_band = available_bands[0]
3589
+
3590
+ processing_col = col.select([target_band])
3591
+
3592
+ def set_name(f):
3593
+ name = ee.Algorithms.If(
3594
+ f.get('geo_name'), f.get('geo_name'),
3595
+ ee.Algorithms.If(f.get('name'), f.get('name'),
3596
+ ee.Algorithms.If(f.get('system:index'), f.get('system:index'), 'unnamed'))
3597
+ )
3598
+ return f.set('geo_name', name)
3599
+
3600
+ if isinstance(locations, (ee.FeatureCollection, ee.Feature)):
3601
+ features = ee.FeatureCollection(locations)
3602
+ elif isinstance(locations, ee.Geometry):
3603
+ lbl = location_names[0] if (location_names and location_names[0]) else 'Point_1'
3604
+ features = ee.FeatureCollection([ee.Feature(locations).set('geo_name', lbl)])
3605
+ elif isinstance(locations, tuple) and len(locations) == 2:
3606
+ lbl = location_names[0] if location_names else 'Location_1'
3607
+ features = ee.FeatureCollection([ee.Feature(ee.Geometry.Point(locations), {'geo_name': lbl})])
3608
+ elif isinstance(locations, list):
3609
+ if all(isinstance(i, tuple) for i in locations):
3610
+ names = location_names if location_names else [f"Loc_{i+1}" for i in range(len(locations))]
3611
+ features = ee.FeatureCollection([
3612
+ ee.Feature(ee.Geometry.Point(p), {'geo_name': str(n)}) for p, n in zip(locations, names)
3613
+ ])
3614
+ elif all(isinstance(i, ee.Geometry) for i in locations):
3615
+ names = location_names if location_names else [f"Geom_{i+1}" for i in range(len(locations))]
3616
+ features = ee.FeatureCollection([
3617
+ ee.Feature(g, {'geo_name': str(n)}) for g, n in zip(locations, names)
3618
+ ])
3619
+ else:
3620
+ raise ValueError("List must contain (lon, lat) tuples or ee.Geometry objects.")
3621
+ else:
3622
+ raise TypeError("Invalid locations input.")
3623
+
3624
+ features = features.map(set_name)
3625
+
3626
+
3627
+ def sample_image(img):
3628
+ date = img.get('Date_Filter')
3629
+ use_scale = scale if scale is not None else 30
3630
+
3631
+
3632
+ default_dict = ee.Dictionary({target_band: -9999})
3633
+
3634
+ def extract_point(f):
3635
+ stats = img.reduceRegion(
3636
+ reducer=ee.Reducer.first(),
3637
+ geometry=f.geometry(),
3638
+ scale=use_scale,
3639
+ tileScale=tileScale
3640
+ )
3641
+
3642
+ # Combine dictionaries.
3643
+ # If stats has 'target_band' (even if 0), it overwrites -9999.
3644
+ # If stats is empty (masked), -9999 remains.
3645
+ safe_stats = default_dict.combine(stats, overwrite=True)
3646
+ val = safe_stats.get(target_band)
3647
+
3648
+ return f.set({
3649
+ target_band: val,
3650
+ 'image_date': date
3651
+ })
3652
+
3653
+ return features.map(extract_point)
3654
+
3655
+ # Flatten the results
3656
+ flat_results = processing_col.map(sample_image).flatten()
3657
+
3658
+ df = GenericCollection.ee_to_df(
3659
+ flat_results,
3660
+ columns=['image_date', 'geo_name', target_band],
3661
+ remove_geom=True
3662
+ )
3663
+
3664
+ if df.empty:
3665
+ print("Warning: No data returned.")
3666
+ return pd.DataFrame()
3667
+
3668
+ # 6. Clean and Pivot
3669
+ df[target_band] = pd.to_numeric(df[target_band], errors='coerce')
3670
+
3671
+ # Filter out ONLY the sentinel value (-9999), preserving 0.
3672
+ df = df[df[target_band] != -9999]
3673
+
3674
+ if df.empty:
3675
+ print(f"Warning: All data points were masked (NoData) for band '{target_band}'.")
3676
+ return pd.DataFrame()
3677
+
3678
+ pivot_df = df.pivot(index='image_date', columns='geo_name', values=target_band)
3679
+ pivot_df.index.name = 'Date'
3680
+ pivot_df.columns.name = None
3681
+ pivot_df = pivot_df.reset_index()
3682
+
3683
+ if file_path:
3684
+ if not file_path.lower().endswith('.csv'):
3685
+ file_path += '.csv'
3686
+ pivot_df.to_csv(file_path, index=False)
3687
+ print(f"Sampled data saved to {file_path}")
3688
+ return None
3689
+
3690
+ return pivot_df
3691
+
3692
+ def multiband_sample(
3693
+ self,
3694
+ location,
3695
+ scale=30,
3696
+ file_path=None
3697
+ ):
3698
+ """
3699
+ Extracts ALL band values for a SINGLE location across the entire collection.
3700
+
3701
+ Args:
3702
+ location (tuple or ee.Geometry): A single (lon, lat) tuple OR ee.Geometry.
3703
+ scale (int, optional): Scale in meters. Defaults to 30.
3704
+ file_path (str, optional): Path to save CSV.
3705
+
3706
+ Returns:
3707
+ pd.DataFrame: DataFrame indexed by Date, with columns for each Band.
3708
+ """
3709
+ if isinstance(location, tuple) and len(location) == 2:
3710
+ geom = ee.Geometry.Point(location)
3711
+ elif isinstance(location, ee.Geometry):
3712
+ geom = location
3713
+ else:
3714
+ raise ValueError("Location must be a single (lon, lat) tuple or ee.Geometry.")
3715
+
3716
+ first_img = self.collection.first()
3717
+ band_names = first_img.bandNames()
3718
+
3719
+ # Create a dictionary of {band_name: -9999}
3720
+ # fill missing values so the Feature structure is consistent
3721
+ dummy_values = ee.List.repeat(-9999, band_names.length())
3722
+ default_dict = ee.Dictionary.fromLists(band_names, dummy_values)
3723
+
3724
+ def get_all_bands(img):
3725
+ date = img.get('Date_Filter')
3726
+
3727
+ # reduceRegion returns a Dictionary.
3728
+ # If a pixel is masked, that band key is missing from 'stats'.
3729
+ stats = img.reduceRegion(
3730
+ reducer=ee.Reducer.first(),
3731
+ geometry=geom,
3732
+ scale=scale,
3733
+ maxPixels=1e13
3734
+ )
3735
+
3736
+ # Combine stats with defaults.
3737
+ # overwrite=True means real data (stats) overwrites the -9999 defaults.
3738
+ complete_stats = default_dict.combine(stats, overwrite=True)
3739
+
3740
+ return ee.Feature(None, complete_stats).set('Date', date)
3741
+
3742
+ fc = ee.FeatureCollection(self.collection.map(get_all_bands))
3743
+
3744
+ df = GenericCollection.ee_to_df(fc, remove_geom=True)
3745
+
3746
+ if df.empty:
3747
+ print("Warning: No data found.")
3748
+ return pd.DataFrame()
3749
+
3750
+ # 6. Cleanup
3751
+ if 'Date' in df.columns:
3752
+ df['Date'] = pd.to_datetime(df['Date'])
3753
+ df = df.set_index('Date').sort_index()
3754
+
3755
+ # Replace our sentinel -9999 with proper NaNs
3756
+ df = df.replace(-9999, np.nan)
3757
+
3758
+ # 7. Export
3759
+ if file_path:
3760
+ if not file_path.lower().endswith('.csv'):
3761
+ file_path += '.csv'
3762
+ df.to_csv(file_path)
3763
+ print(f"Multiband sample saved to {file_path}")
3764
+ return None
3765
+
3766
+ return df
3281
3767
 
3282
3768
  def export_to_asset_collection(
3283
3769
  self,
@@ -3288,7 +3774,8 @@ class GenericCollection:
3288
3774
  filename_prefix="",
3289
3775
  crs=None,
3290
3776
  max_pixels=int(1e13),
3291
- description_prefix="export"
3777
+ description_prefix="export",
3778
+ overwrite=False
3292
3779
  ):
3293
3780
  """
3294
3781
  Exports an image collection to a Google Earth Engine asset collection. The asset collection will be created if it does not already exist,
@@ -3303,6 +3790,7 @@ class GenericCollection:
3303
3790
  crs (str, optional): The coordinate reference system. Defaults to None, which will use the image's CRS.
3304
3791
  max_pixels (int, optional): The maximum number of pixels. Defaults to int(1e13).
3305
3792
  description_prefix (str, optional): The description prefix. Defaults to "export".
3793
+ overwrite (bool, optional): Whether to overwrite existing assets. Defaults to False.
3306
3794
 
3307
3795
  Returns:
3308
3796
  None: (queues export tasks)
@@ -3320,6 +3808,14 @@ class GenericCollection:
3320
3808
  asset_id = asset_collection_path + "/" + filename_prefix + date_str
3321
3809
  desc = description_prefix + "_" + filename_prefix + date_str
3322
3810
 
3811
+ if overwrite:
3812
+ try:
3813
+ ee.data.deleteAsset(asset_id)
3814
+ print(f"Overwriting: Deleted existing asset {asset_id}")
3815
+ except ee.EEException:
3816
+ # Asset does not exist, so nothing to delete. Proceed safely.
3817
+ pass
3818
+
3323
3819
  params = {
3324
3820
  'image': img,
3325
3821
  'description': desc,