RadGEEToolbox 1.6.9__py3-none-any.whl → 1.6.10__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.
@@ -149,10 +149,12 @@ class LandsatCollection:
149
149
  self.nbr_threshold = -1
150
150
  self._masked_clouds_collection = None
151
151
  self._masked_water_collection = None
152
+ self._masked_shadows_collection = None
152
153
  self._masked_to_water_collection = None
153
154
  self._geometry_masked_collection = None
154
155
  self._geometry_masked_out_collection = None
155
156
  self._median = None
157
+ self._monthly_median = None
156
158
  self._mean = None
157
159
  self._max = None
158
160
  self._min = None
@@ -267,25 +269,25 @@ class LandsatCollection:
267
269
  ) # green-SWIR / green+SWIR -- full NDWI image
268
270
  water = (
269
271
  mndwi_calc.updateMask(mndwi_calc.gte(threshold))
270
- .rename("ndwi")
272
+ .rename("mndwi")
271
273
  .copyProperties(image)
272
274
  )
273
275
  if ng_threshold != None:
274
276
  water = ee.Algorithms.If(
275
277
  ee.String(image.get("SPACECRAFT_ID")).equals("LANDSAT_5"),
276
278
  mndwi_calc.updateMask(mndwi_calc.gte(threshold))
277
- .rename("ndwi")
279
+ .rename("mndwi")
278
280
  .copyProperties(image)
279
281
  .set("threshold", threshold),
280
282
  mndwi_calc.updateMask(mndwi_calc.gte(ng_threshold))
281
- .rename("ndwi")
283
+ .rename("mndwi")
282
284
  .copyProperties(image)
283
285
  .set("threshold", ng_threshold),
284
286
  )
285
287
  else:
286
288
  water = (
287
289
  mndwi_calc.updateMask(mndwi_calc.gte(threshold))
288
- .rename("ndwi")
290
+ .rename("mndwi")
289
291
  .copyProperties(image)
290
292
  )
291
293
  return water
@@ -701,6 +703,59 @@ class LandsatCollection:
701
703
  else:
702
704
  nbr = nbr_calc.updateMask(nbr_calc.gte(threshold)).rename("nbr").copyProperties(image).set("threshold", threshold)
703
705
  return nbr
706
+
707
+ @staticmethod
708
+ def anomaly_fn(image, geometry, band_name=None, anomaly_band_name=None, replace=True):
709
+ """
710
+ Calculates the anomaly of a singleband image compared to the mean of the singleband image.
711
+
712
+ This function computes the anomaly for each band in the input image by
713
+ subtracting the mean value of that band from a provided image.
714
+ The anomaly is a measure of how much the pixel values deviate from the
715
+ average conditions represented by the mean of the image.
716
+
717
+ Args:
718
+ image (ee.Image): An ee.Image for which the anomaly is to be calculated.
719
+ It is assumed that this image is a singleband image.
720
+ geometry (ee.Geometry): The geometry for image reduction to define the mean value to be used for anomaly calculation.
721
+ band_name (str, optional): A string representing the band name to be used for the output anomaly image. If not provided, the band name of the first band of the input image will be used.
722
+ anomaly_band_name (str, optional): A string representing the band name to be used for the output anomaly image. If not provided, the band name of the first band of the input image will be used.
723
+ replace (bool, optional): A boolean indicating whether to replace the original band with the anomaly band in the output image. If True, the output image will contain only the anomaly band. If False, the output image will contain both the original band and the anomaly band. Default is True.
724
+
725
+ Returns:
726
+ ee.Image: An ee.Image where each band represents the anomaly (deviation from
727
+ the mean) for that band. The output image retains the same band name.
728
+ """
729
+ if band_name:
730
+ band_name = band_name
731
+ else:
732
+ band_name = ee.String(image.bandNames().get(0))
733
+
734
+ image_to_process = image.select([band_name])
735
+
736
+ # Calculate the mean image of the provided collection.
737
+ mean_image = image_to_process.reduceRegion(
738
+ reducer=ee.Reducer.mean(),
739
+ geometry=geometry,
740
+ scale=30,
741
+ maxPixels=1e13
742
+ ).toImage()
743
+
744
+ # Compute the anomaly by subtracting the mean image from the input image.
745
+ anomaly_image = image_to_process.subtract(mean_image)
746
+ if anomaly_band_name is None:
747
+ if band_name:
748
+ anomaly_image = anomaly_image.rename(band_name)
749
+ else:
750
+ # Preserve original properties from the input image.
751
+ anomaly_image = anomaly_image.rename(ee.String(image.bandNames().get(0)))
752
+ else:
753
+ anomaly_image = anomaly_image.rename(anomaly_band_name)
754
+ # return anomaly_image
755
+ if replace:
756
+ return anomaly_image.copyProperties(image)
757
+ else:
758
+ return image.addBands(anomaly_image, overwrite=True)
704
759
 
705
760
  @staticmethod
706
761
  def MaskWaterLandsat(image):
@@ -809,6 +864,70 @@ class LandsatCollection:
809
864
  )
810
865
  return water
811
866
 
867
+ @staticmethod
868
+ def mask_via_band_fn(image, band_to_mask, band_for_mask, threshold, mask_above=False, add_band_to_original_image=False):
869
+ """
870
+ Masks pixels of interest from a specified band of a target image, based on a specified reference band and threshold.
871
+ Designed for single image input which contains both the target and reference band.
872
+ Example use case is masking vegetation from image when targeting land pixels. Can specify whether to mask pixels above or below the threshold.
873
+
874
+ Args:
875
+ image (ee.Image): input ee.Image
876
+ band_to_mask (str): name of the band which will be masked (target image)
877
+ band_for_mask (str): name of the band to use for the mask (band you want to remove/mask from target image)
878
+ threshold (float): value where pixels less or more than threshold (depending on `mask_above` argument) will be masked
879
+ mask_above (bool): if True, masks pixels above the threshold; if False, masks pixels below the threshold
880
+
881
+ Returns:
882
+ ee.Image: masked ee.Image
883
+ """
884
+
885
+ band_to_mask_image = image.select(band_to_mask)
886
+ band_for_mask_image = image.select(band_for_mask)
887
+
888
+ mask = band_for_mask_image.lte(threshold) if mask_above else band_for_mask_image.gte(threshold)
889
+
890
+ if add_band_to_original_image:
891
+ return image.addBands(band_to_mask_image.updateMask(mask).rename(band_to_mask), overwrite=True)
892
+ else:
893
+ return ee.Image(band_to_mask_image.updateMask(mask).rename(band_to_mask).copyProperties(image))
894
+
895
+ @staticmethod
896
+ def mask_via_singleband_image_fn(image_to_mask, image_for_mask, threshold, band_name_to_mask=None, band_name_for_mask=None, mask_above=True):
897
+ """
898
+ Masks pixels of interest from a specified band of a target image, based on a specified reference band and threshold.
899
+ Designed for the case where the target and reference bands are in separate images.
900
+ Example use case is masking vegetation from image when targeting land pixels. Can specify whether to mask pixels above or below the threshold.
901
+
902
+ Args:
903
+ image_to_mask (ee.Image): image which will be masked (target image). If multiband, only the first band will be masked.
904
+ image_for_mask (ee.Image): image to use for the mask (image you want to remove/mask from target image). If multiband, only the first band will be used for the masked.
905
+ threshold (float): value where pixels less or more than threshold (depending on `mask_above` argument) will be masked
906
+ band_name_to_mask (str, optional): name of the band in image_to_mask to be masked. If None, the first band will be used.
907
+ band_name_for_mask (str, optional): name of the band in image_for_mask to be used for masking. If None, the first band will be used.
908
+ mask_above (bool): if True, masks pixels above the threshold; if False, masks pixels below the threshold.
909
+
910
+ Returns:
911
+ ee.Image: masked ee.Image
912
+ """
913
+ if band_name_to_mask is None:
914
+ band_to_mask = ee.String(image_to_mask.bandNames().get(0))
915
+ else:
916
+ band_to_mask = ee.String(band_name_to_mask)
917
+
918
+ if band_name_for_mask is None:
919
+ band_for_mask = ee.String(image_for_mask.bandNames().get(0))
920
+ else:
921
+ band_for_mask = ee.String(band_name_for_mask)
922
+
923
+ band_to_mask_image = image_to_mask.select(band_to_mask)
924
+ band_for_mask_image = image_for_mask.select(band_for_mask)
925
+ if mask_above:
926
+ mask = band_for_mask_image.gt(threshold)
927
+ else:
928
+ mask = band_for_mask_image.lt(threshold)
929
+ return band_to_mask_image.updateMask(mask).rename(band_to_mask).copyProperties(image_to_mask)
930
+
812
931
  @staticmethod
813
932
  def halite_mask(image, threshold, ng_threshold=None):
814
933
  """
@@ -905,6 +1024,22 @@ class LandsatCollection:
905
1024
  cirrus_mask = qa.bitwiseAnd(CirrusBitMask).eq(0)
906
1025
  return image.updateMask(cloud_mask).updateMask(cirrus_mask)
907
1026
 
1027
+ @staticmethod
1028
+ def maskL8shadows(image):
1029
+ """
1030
+ Masks cloud shadows based on Landsat 8 QA band.
1031
+
1032
+ Args:
1033
+ image (ee.Image): input ee.Image
1034
+
1035
+ Returns:
1036
+ ee.Image: ee.Image
1037
+ """
1038
+ shadowBitMask = ee.Number(2).pow(4).int()
1039
+ qa = image.select("QA_PIXEL")
1040
+ shadow_mask = qa.bitwiseAnd(shadowBitMask).eq(0)
1041
+ return image.updateMask(shadow_mask)
1042
+
908
1043
  @staticmethod
909
1044
  def temperature_bands(img):
910
1045
  """
@@ -958,6 +1093,64 @@ class LandsatCollection:
958
1093
  },
959
1094
  ).rename("LST")
960
1095
  return image.addBands(LST).copyProperties(image) # Outputs temperature in C
1096
+
1097
+ @staticmethod
1098
+ def C_to_F_fn(LST_image):
1099
+ """Converts Land Surface Temperature from Celsius to Fahrenheit.
1100
+
1101
+ This function takes an ee.Image with a band for Land Surface Temperature (LST)
1102
+ in degrees Celsius and converts it to degrees Fahrenheit using the formula:
1103
+ F = C * 9/5 + 32.
1104
+
1105
+ Args:
1106
+ LST_image: An ee.Image where each band represents LST in degrees Celsius.
1107
+
1108
+ Returns:
1109
+ ee.Image: An ee.Image where each band represents LST in degrees Fahrenheit. The output image retains the same band names as the input image alongside an additional band named 'LST_F'.
1110
+ """
1111
+ LST_image = LST_image.select('LST')
1112
+ # Define the conversion formula from Celsius to Fahrenheit.
1113
+ fahrenheit_image = LST_image.multiply(9).divide(5).add(32)
1114
+
1115
+ # Preserve original properties from the input image.
1116
+ # return fahrenheit_image.rename('LST_F').copyProperties(LST_image)
1117
+ return LST_image.addBands(fahrenheit_image.rename('LST_F'), overwrite=True).copyProperties(LST_image)
1118
+
1119
+ @staticmethod
1120
+ def band_rename_fn(image, current_band_name, new_band_name):
1121
+ """Renames a band in an ee.Image (single- or multi-band) in-place.
1122
+
1123
+ Replaces the band named `current_band_name` with `new_band_name` without
1124
+ retaining the original band name. If the band does not exist, returns the
1125
+ image unchanged.
1126
+
1127
+ Args:
1128
+ image (ee.Image): The input image (can be multiband).
1129
+ current_band_name (str): The existing band name to rename.
1130
+ new_band_name (str): The desired new band name.
1131
+
1132
+ Returns:
1133
+ ee.Image: The image with the band renamed (or unchanged if not found).
1134
+ """
1135
+ img = ee.Image(image)
1136
+ current = ee.String(current_band_name)
1137
+ new = ee.String(new_band_name)
1138
+
1139
+ band_names = img.bandNames()
1140
+ has_band = band_names.contains(current)
1141
+
1142
+ def _rename():
1143
+ # Build a new band-name list with the target name replaced.
1144
+ new_names = band_names.map(
1145
+ lambda b: ee.String(
1146
+ ee.Algorithms.If(ee.String(b).equals(current), new, b)
1147
+ )
1148
+ )
1149
+ # Rename the image using the updated band-name list.
1150
+ return img.rename(ee.List(new_names))
1151
+
1152
+ out = ee.Image(ee.Algorithms.If(has_band, _rename(), img))
1153
+ return out.copyProperties(img)
961
1154
 
962
1155
  @staticmethod
963
1156
  def PixelAreaSum(
@@ -1071,12 +1264,12 @@ class LandsatCollection:
1071
1264
  else:
1072
1265
  raise ValueError("output_type must be 'ImageCollection' or 'LandsatCollection'")
1073
1266
 
1074
- def merge(self, other):
1267
+ def combine(self, other):
1075
1268
  """
1076
- Merges the current LandsatCollection with another LandsatCollection, where images/bands with the same date are combined to a single multiband image.
1269
+ Combines the current LandsatCollection with another LandsatCollection, using the `combine` method.
1077
1270
 
1078
1271
  Args:
1079
- other (LandsatCollection): Another LandsatCollection to merge with current collection.
1272
+ other (LandsatCollection): Another LandsatCollection to combine with current collection.
1080
1273
 
1081
1274
  Returns:
1082
1275
  LandsatCollection: A new LandsatCollection containing images from both collections.
@@ -1089,6 +1282,72 @@ class LandsatCollection:
1089
1282
  merged_collection = self.collection.combine(other.collection)
1090
1283
  return LandsatCollection(collection=merged_collection)
1091
1284
 
1285
+ def merge(self, collections=None, multiband_collection=None, date_key='Date_Filter'):
1286
+ """
1287
+ Merge many singleband LandsatCollection products into the parent collection,
1288
+ or merge a single multiband collection with parent collection,
1289
+ pairing images by exact Date_Filter and returning one multiband image per date.
1290
+
1291
+ NOTE: if you want to merge two multiband collections, use the `combine` method instead.
1292
+
1293
+ Args:
1294
+ collections (list): List of singleband collections to merge with parent collection, effectively adds one band per collection to each image in parent
1295
+ multiband_collection (LandsatCollection, optional): A multiband collection to merge with parent. Specifying a collection here will override `collections`.
1296
+ date_key (str): image property key for exact pairing (default 'Date_Filter')
1297
+
1298
+ Returns:
1299
+ LandsatCollection: parent with extra single bands attached (one image per date)
1300
+ """
1301
+
1302
+ if collections is None and multiband_collection is not None:
1303
+ # Exact-date inner-join merge of two collections (adds ALL bands from 'other').
1304
+ join = ee.Join.inner()
1305
+ flt = ee.Filter.equals(leftField=date_key, rightField=date_key)
1306
+ paired = join.apply(self.collection, multiband_collection.collection, flt)
1307
+
1308
+ def _pair_two(f):
1309
+ f = ee.Feature(f)
1310
+ a = ee.Image(f.get('primary'))
1311
+ b = ee.Image(f.get('secondary'))
1312
+ # Overwrite on name collision
1313
+ merged = a.addBands(b, None, True)
1314
+ # Keep parent props + date key
1315
+ merged = merged.copyProperties(a, a.propertyNames())
1316
+ merged = merged.set(date_key, a.get(date_key))
1317
+ return ee.Image(merged)
1318
+
1319
+ return LandsatCollection(collection=ee.ImageCollection(paired.map(_pair_two)))
1320
+
1321
+ # Preferred path: merge many singleband products into the parent
1322
+ if not isinstance(collections, list) or len(collections) == 0:
1323
+ raise ValueError("Provide a non-empty list of LandsatCollection objects in `collections`.")
1324
+
1325
+ result = self.collection
1326
+ for extra in collections:
1327
+ if not isinstance(extra, LandsatCollection):
1328
+ raise ValueError("All items in `collections` must be LandsatCollection objects.")
1329
+
1330
+ join = ee.Join.inner()
1331
+ flt = ee.Filter.equals(leftField=date_key, rightField=date_key)
1332
+ paired = join.apply(result, extra.collection, flt)
1333
+
1334
+ def _attach_one(f):
1335
+ f = ee.Feature(f)
1336
+ parent = ee.Image(f.get('primary'))
1337
+ sb = ee.Image(f.get('secondary'))
1338
+ # Assume singleband product; grab its first band name server-side
1339
+ bname = ee.String(sb.bandNames().get(0))
1340
+ # Add the single band; overwrite if the name already exists in parent
1341
+ merged = parent.addBands(sb.select([bname]).rename([bname]), None, True)
1342
+ # Preserve parent props + date key
1343
+ merged = merged.copyProperties(parent, parent.propertyNames())
1344
+ merged = merged.set(date_key, parent.get(date_key))
1345
+ return ee.Image(merged)
1346
+
1347
+ result = ee.ImageCollection(paired.map(_attach_one))
1348
+
1349
+ return LandsatCollection(collection=result)
1350
+
1092
1351
  @staticmethod
1093
1352
  def dNDWIPixelAreaSum(image, geometry, band_name="ndwi", scale=30, maxPixels=1e12):
1094
1353
  """
@@ -1358,6 +1617,84 @@ class LandsatCollection:
1358
1617
  col = self.collection.min()
1359
1618
  self._min = col
1360
1619
  return self._min
1620
+
1621
+ @property
1622
+ def monthly_median_collection(self):
1623
+ """Creates a monthly median composite from a LandsatCollection image collection.
1624
+
1625
+ This function computes the median for each
1626
+ month within the collection's date range, for each band in the collection. It automatically handles the full
1627
+ temporal extent of the input collection.
1628
+
1629
+ The resulting images have a 'system:time_start' property set to the
1630
+ first day of each month and an 'image_count' property indicating how
1631
+ many images were used in the composite. Months with no images are
1632
+ automatically excluded from the final collection.
1633
+
1634
+ NOTE: the day of month for the 'system:time_start' property is set to the earliest date of the first month observed and may not be the first day of the month.
1635
+
1636
+ Returns:
1637
+ LandsatCollection: A new LandsatCollection object with monthly median composites.
1638
+ """
1639
+ if self._monthly_median is None:
1640
+ collection = self.collection
1641
+ # Get the start and end dates of the entire collection.
1642
+ date_range = collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
1643
+ start_date = ee.Date(date_range.get('min'))
1644
+ end_date = ee.Date(date_range.get('max'))
1645
+
1646
+ # Calculate the total number of months in the date range.
1647
+ # The .round() is important for ensuring we get an integer.
1648
+ num_months = end_date.difference(start_date, 'month').round()
1649
+
1650
+ # Generate a list of starting dates for each month.
1651
+ # This uses a sequence and advances the start date by 'i' months.
1652
+ def get_month_start(i):
1653
+ return start_date.advance(i, 'month')
1654
+
1655
+ month_starts = ee.List.sequence(0, num_months).map(get_month_start)
1656
+
1657
+ # Define a function to map over the list of month start dates.
1658
+ def create_monthly_composite(date):
1659
+ # Cast the input to an ee.Date object.
1660
+ start_of_month = ee.Date(date)
1661
+ # The end date is exclusive, so we advance by 1 month.
1662
+ end_of_month = start_of_month.advance(1, 'month')
1663
+
1664
+ # Filter the original collection to get images for the current month.
1665
+ monthly_subset = collection.filterDate(start_of_month, end_of_month)
1666
+
1667
+ # Count the number of images in the monthly subset.
1668
+ image_count = monthly_subset.size()
1669
+
1670
+ # Compute the median. This is robust to outliers like clouds.
1671
+ monthly_median = monthly_subset.median()
1672
+
1673
+ # Set essential properties on the resulting composite image.
1674
+ # The timestamp is crucial for time-series analysis and charting.
1675
+ # The image_count is useful metadata for quality assessment.
1676
+ return monthly_median.set({
1677
+ 'system:time_start': start_of_month.millis(),
1678
+ 'month': start_of_month.get('month'),
1679
+ 'year': start_of_month.get('year'),
1680
+ 'Date_Filter': start_of_month.format('YYYY-MM-dd'),
1681
+ 'image_count': image_count
1682
+ })
1683
+
1684
+ # Map the composite function over the list of month start dates.
1685
+ monthly_composites_list = month_starts.map(create_monthly_composite)
1686
+
1687
+ # Convert the list of images into an ee.ImageCollection.
1688
+ monthly_collection = ee.ImageCollection.fromImages(monthly_composites_list)
1689
+
1690
+ # Filter out any composites that were created from zero images.
1691
+ # This prevents empty/masked images from being in the final collection.
1692
+ final_collection = LandsatCollection(collection=monthly_collection.filter(ee.Filter.gt('image_count', 0)))
1693
+ self._monthly_median = final_collection
1694
+ else:
1695
+ pass
1696
+
1697
+ return self._monthly_median
1361
1698
 
1362
1699
  @property
1363
1700
  def ndwi(self):
@@ -2022,6 +2359,19 @@ class LandsatCollection:
2022
2359
  col = self.collection.map(LandsatCollection.maskL8clouds)
2023
2360
  self._masked_clouds_collection = LandsatCollection(collection=col)
2024
2361
  return self._masked_clouds_collection
2362
+
2363
+ @property
2364
+ def masked_shadows_collection(self):
2365
+ """
2366
+ Property attribute to mask shadows and return collection as class object.
2367
+
2368
+ Returns:
2369
+ LandsatCollection: LandsatCollection image collection
2370
+ """
2371
+ if self._masked_shadows_collection is None:
2372
+ col = self.collection.map(LandsatCollection.maskL8shadows)
2373
+ self._masked_shadows_collection = LandsatCollection(collection=col)
2374
+ return self._masked_shadows_collection
2025
2375
 
2026
2376
  @property
2027
2377
  def LST(self):
@@ -2062,6 +2412,18 @@ class LandsatCollection:
2062
2412
  .map(LandsatCollection.image_dater)
2063
2413
  )
2064
2414
  return LandsatCollection(collection=col)
2415
+
2416
+ def C_to_F(self):
2417
+ """
2418
+ Function to convert an LST collection from Celcius to Fahrenheit, adding a new band 'LST_F' to each image in the collection.
2419
+
2420
+ Returns:
2421
+ LandsatCollection: A LandsatCollection image collection with LST in Fahrenheit as band titled 'LST_F'.
2422
+ """
2423
+ if self._LST is None:
2424
+ raise ValueError("LST has not been calculated yet. Access the LST property first.")
2425
+ col = self._LST.collection.map(LandsatCollection.C_to_F_fn)
2426
+ return LandsatCollection(collection=col)
2065
2427
 
2066
2428
  def mask_to_polygon(self, polygon):
2067
2429
  """
@@ -2169,14 +2531,17 @@ class LandsatCollection:
2169
2531
  )
2170
2532
  return LandsatCollection(collection=col)
2171
2533
 
2172
- def binary_mask(self, threshold=None, band_name=None):
2534
+ def binary_mask(self, threshold=None, band_name=None, classify_above_threshold=True, mask_zeros=False):
2173
2535
  """
2174
2536
  Function to create a binary mask (value of 1 for pixels above set threshold and value of 0 for all other pixels) of the LandsatCollection image collection based on a specified band.
2175
2537
  If a singleband image is provided, the band name is automatically determined.
2176
2538
  If multiple bands are available, the user must specify the band name to use for masking.
2177
2539
 
2178
2540
  Args:
2541
+ threshold (float, optional): The threshold value for creating the binary mask. Defaults to None.
2179
2542
  band_name (str, optional): The name of the band to use for masking. Defaults to None.
2543
+ classifiy_above_threshold (bool, optional): If True, pixels above the threshold are classified as 1. If False, pixels below the threshold are classified as 1. Defaults to True.
2544
+ mask_zeros (bool, optional): If True, pixels with a value of 0 after the binary mask are masked out in the output binary mask. Useful for classifications. Defaults to False.
2180
2545
 
2181
2546
  Returns:
2182
2547
  LandsatCollection: LandsatCollection singleband image collection with binary masks applied.
@@ -2195,11 +2560,175 @@ class LandsatCollection:
2195
2560
  if threshold is None:
2196
2561
  raise ValueError("Threshold must be specified for binary masking.")
2197
2562
 
2563
+ if classify_above_threshold:
2564
+ if mask_zeros:
2565
+ col = self.collection.map(
2566
+ lambda image: image.select(band_name).gte(threshold).rename(band_name).updateMask(image.select(band_name).gt(0)).copyProperties(image)
2567
+ )
2568
+ else:
2569
+ col = self.collection.map(
2570
+ lambda image: image.select(band_name).gte(threshold).rename(band_name).copyProperties(image)
2571
+ )
2572
+ else:
2573
+ if mask_zeros:
2574
+ col = self.collection.map(
2575
+ lambda image: image.select(band_name).lte(threshold).rename(band_name).updateMask(image.select(band_name).gt(0)).copyProperties(image)
2576
+ )
2577
+ else:
2578
+ col = self.collection.map(
2579
+ lambda image: image.select(band_name).lte(threshold).rename(band_name).copyProperties(image)
2580
+ )
2581
+ return LandsatCollection(collection=col)
2582
+
2583
+ def anomaly(self, geometry, band_name=None, anomaly_band_name=None, replace=True):
2584
+ """
2585
+ Calculates the anomaly of each image in a collection compared to the mean of each image.
2586
+
2587
+ This function computes the anomaly for each band in the input image by
2588
+ subtracting the mean value of that band from a provided ImageCollection.
2589
+ The anomaly is a measure of how much the pixel values deviate from the
2590
+ average conditions represented by the collection.
2591
+
2592
+ Args:
2593
+ geometry (ee.Geometry): The geometry for image reduction to define the mean value to be used for anomaly calculation.
2594
+ band_name (str, optional): A string representing the band name to be used for the output anomaly image. If not provided, the band name of the first band of the input image will be used.
2595
+ anomaly_band_name (str, optional): A string representing the band name to be used for the output anomaly image. If not provided, the band name of the first band of the input image will be used.
2596
+ replace (bool, optional): A boolean indicating whether to replace the original band with the anomaly band. If True, the output image will only contain the anomaly band. If False, the output image will retain all original bands and add the anomaly band. Default is True.
2597
+
2598
+ Returns:
2599
+ LandsatCollection: A LandsatCollection where each image represents the anomaly (deviation from
2600
+ the mean) for the chosen band. The output images retain the same band name.
2601
+ """
2602
+ if self.collection.size().eq(0).getInfo():
2603
+ raise ValueError("The collection is empty.")
2604
+ if band_name is None:
2605
+ first_image = self.collection.first()
2606
+ band_names = first_image.bandNames()
2607
+ if band_names.size().getInfo() == 0:
2608
+ raise ValueError("No bands available in the collection.")
2609
+ elif band_names.size().getInfo() > 1:
2610
+ band_name = band_names.get(0).getInfo()
2611
+ print("Multiple bands available, will be using the first band in the collection for anomaly calculation. Please specify a band name if you wish to use a different band.")
2612
+ else:
2613
+ band_name = band_names.get(0).getInfo()
2614
+
2615
+ col = self.collection.map(lambda image: LandsatCollection.anomaly_fn(image, geometry=geometry, band_name=band_name, anomaly_band_name=anomaly_band_name, replace=replace))
2616
+ return LandsatCollection(collection=col)
2617
+
2618
+ def mask_via_band(self, band_to_mask, band_for_mask, threshold=-1, mask_above=True, add_band_to_original_image=False):
2619
+ """
2620
+ Masks select pixels of a selected band from an image based on another specified band and threshold (optional).
2621
+ Example use case is masking vegetation from image when targeting land pixels. Can specify whether to mask pixels above or below the threshold.
2622
+
2623
+ Args:
2624
+ band_to_mask (str): name of the band which will be masked (target image)
2625
+ band_for_mask (str): name of the band to use for the mask (band you want to remove/mask from target image)
2626
+ threshold (float): value between -1 and 1 where pixels less than threshold will be masked; defaults to -1 assuming input band is already classified (masked to pixels of interest).
2627
+ mask_above (bool): if True, masks pixels above the threshold; if False, masks pixels below the threshold
2628
+
2629
+ Returns:
2630
+ LandsatCollection: A new LandsatCollection with the specified band masked to pixels excluding from `band_for_mask`.
2631
+ """
2632
+ if self.collection.size().eq(0).getInfo():
2633
+ raise ValueError("The collection is empty.")
2634
+
2198
2635
  col = self.collection.map(
2199
- lambda image: image.select(band_name).gte(threshold).rename(band_name)
2636
+ lambda image: LandsatCollection.mask_via_band_fn(
2637
+ image,
2638
+ band_to_mask=band_to_mask,
2639
+ band_for_mask=band_for_mask,
2640
+ threshold=threshold,
2641
+ mask_above=mask_above,
2642
+ add_band_to_original_image=add_band_to_original_image
2643
+ )
2200
2644
  )
2201
2645
  return LandsatCollection(collection=col)
2202
2646
 
2647
+ def mask_via_singleband_image(self, image_collection_for_mask, band_name_to_mask, band_name_for_mask, threshold=-1, mask_above=False, add_band_to_original_image=False):
2648
+ """
2649
+ Masks select pixels of a selected band from an image collection based on another specified singleband image collection and threshold (optional).
2650
+ Example use case is masking vegetation from image when targeting land pixels. Can specify whether to mask pixels above or below the threshold.
2651
+ This function pairs images from the two collections based on an exact match of the 'Date_Filter' property.
2652
+
2653
+ Args:
2654
+ image_collection_for_mask (LandsatCollection): LandsatCollection image collection to use for masking (source of pixels that will be used to mask the parent image collection)
2655
+ band_name_to_mask (str): name of the band which will be masked (target image)
2656
+ band_name_for_mask (str): name of the band to use for the mask (band which contains pixels the user wants to remove/mask from target image)
2657
+ threshold (float): threshold value where pixels less (or more, depending on `mask_above`) than threshold will be masked; defaults to -1.
2658
+ mask_above (bool): if True, masks pixels above the threshold; if False, masks pixels below the threshold
2659
+ add_band_to_original_image (bool): if True, adds the band used for masking to the original image as an additional band; if False, only the masked band is retained in the output image.
2660
+
2661
+ Returns:
2662
+ LandsatCollection: A new LandsatCollection with the specified band masked to pixels excluding from `band_for_mask`.
2663
+ """
2664
+
2665
+ if self.collection.size().eq(0).getInfo():
2666
+ raise ValueError("The collection is empty.")
2667
+ if not isinstance(image_collection_for_mask, LandsatCollection):
2668
+ raise ValueError("image_collection_for_mask must be a LandsatCollection object.")
2669
+ size1 = self.collection.size().getInfo()
2670
+ size2 = image_collection_for_mask.collection.size().getInfo()
2671
+ if size1 != size2:
2672
+ raise ValueError(f"Warning: Collections have different sizes ({size1} vs {size2}). Please ensure both collections have the same number of images and matching dates.")
2673
+ if size1 == 0 or size2 == 0:
2674
+ raise ValueError("Warning: One of the input collections is empty.")
2675
+
2676
+ # Pair by exact Date_Filter property
2677
+ primary = self.collection.select([band_name_to_mask])
2678
+ secondary = image_collection_for_mask.collection.select([band_name_for_mask])
2679
+ join = ee.Join.inner()
2680
+ flt = ee.Filter.equals(leftField='Date_Filter', rightField='Date_Filter')
2681
+ paired = join.apply(primary, secondary, flt)
2682
+
2683
+ def _map_pair(f):
2684
+ f = ee.Feature(f) # <-- treat as Feature
2685
+ prim = ee.Image(f.get('primary')) # <-- get the primary Image
2686
+ sec = ee.Image(f.get('secondary')) # <-- get the secondary Image
2687
+
2688
+ merged = prim.addBands(sec.select([band_name_for_mask]))
2689
+
2690
+ out = LandsatCollection.mask_via_band_fn(
2691
+ merged,
2692
+ band_to_mask=band_name_to_mask,
2693
+ band_for_mask=band_name_for_mask,
2694
+ threshold=threshold,
2695
+ mask_above=mask_above,
2696
+ add_band_to_original_image=add_band_to_original_image
2697
+ )
2698
+
2699
+ # guarantee single band + keep properties
2700
+ out = ee.Image(out).select([band_name_to_mask]).copyProperties(prim, prim.propertyNames())
2701
+ out = out.set('Date_Filter', prim.get('Date_Filter'))
2702
+ return ee.Image(out) # <-- return as Image
2703
+
2704
+ col = ee.ImageCollection(paired.map(_map_pair))
2705
+ return LandsatCollection(collection=col)
2706
+
2707
+ def band_rename(self, current_band_name, new_band_name):
2708
+ """Renames a band in all images of the LandsatCollection in-place.
2709
+
2710
+ Replaces the band named `current_band_name` with `new_band_name` without
2711
+ retaining the original band name. If the band does not exist in an image,
2712
+ that image is returned unchanged.
2713
+
2714
+ Args:
2715
+ current_band_name (str): The existing band name to rename.
2716
+ new_band_name (str): The desired new band name.
2717
+
2718
+ Returns:
2719
+ LandsatCollection: The LandsatCollection with the band renamed in all images.
2720
+ """
2721
+ # check if `current_band_name` exists in the first image
2722
+ first_image = self.collection.first()
2723
+ has_band = first_image.bandNames().contains(current_band_name).getInfo()
2724
+ if not has_band:
2725
+ raise ValueError(f"Band '{current_band_name}' does not exist in the collection.")
2726
+
2727
+ renamed_collection = self.collection.map(
2728
+ lambda img: self.band_rename_fn(img, current_band_name, new_band_name)
2729
+ )
2730
+ return LandsatCollection(collection=renamed_collection)
2731
+
2203
2732
  def image_grab(self, img_selector):
2204
2733
  """
2205
2734
  Selects ("grabs") an image by index from the collection. Easy way to get latest image or browse imagery one-by-one.
@@ -3041,6 +3570,61 @@ class LandsatCollection:
3041
3570
  return
3042
3571
  return pivot_df
3043
3572
 
3573
+ def export_to_asset_collection(
3574
+ self,
3575
+ asset_collection_path,
3576
+ region,
3577
+ scale,
3578
+ dates=None,
3579
+ filename_prefix="",
3580
+ crs=None,
3581
+ max_pixels=int(1e13),
3582
+ description_prefix="export"
3583
+ ):
3584
+ """
3585
+ Exports an image collection to a Google Earth Engine asset collection. The asset collection will be created if it does not already exist,
3586
+ and each image exported will be named according to the provided filename prefix and date.
3587
+
3588
+ Args:
3589
+ asset_collection_path (str): The path to the asset collection.
3590
+ region (ee.Geometry): The region to export.
3591
+ scale (int): The scale of the export.
3592
+ dates (list, optional): The dates to export. Defaults to None.
3593
+ filename_prefix (str, optional): The filename prefix. Defaults to "", i.e. blank.
3594
+ crs (str, optional): The coordinate reference system. Defaults to None, which will use the image's CRS.
3595
+ max_pixels (int, optional): The maximum number of pixels. Defaults to int(1e13).
3596
+ description_prefix (str, optional): The description prefix. Defaults to "export".
3597
+
3598
+ Returns:
3599
+ None: (queues export tasks)
3600
+ """
3601
+ ic = self.collection
3602
+ if dates is None:
3603
+ dates = self.dates
3604
+ try:
3605
+ ee.data.createAsset({'type': 'ImageCollection'}, asset_collection_path)
3606
+ except Exception:
3607
+ pass
3608
+
3609
+ for date_str in dates:
3610
+ img = ee.Image(ic.filter(ee.Filter.eq('Date_Filter', date_str)).first())
3611
+ asset_id = asset_collection_path + "/" + filename_prefix + date_str
3612
+ desc = description_prefix + "_" + filename_prefix + date_str
3613
+
3614
+ params = {
3615
+ 'image': img,
3616
+ 'description': desc,
3617
+ 'assetId': asset_id,
3618
+ 'region': region,
3619
+ 'scale': scale,
3620
+ 'maxPixels': max_pixels
3621
+ }
3622
+ if crs:
3623
+ params['crs'] = crs
3624
+
3625
+ ee.batch.Export.image.toAsset(**params).start()
3626
+
3627
+ print("Queued", len(dates), "export tasks to", asset_collection_path)
3044
3628
 
3045
3629
 
3046
3630