RadGEEToolbox 1.7.0__py3-none-any.whl → 1.7.1__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.
- RadGEEToolbox/GenericCollection.py +119 -31
- RadGEEToolbox/GetPalette.py +136 -87
- RadGEEToolbox/LandsatCollection.py +461 -36
- RadGEEToolbox/Sentinel1Collection.py +603 -18
- RadGEEToolbox/Sentinel2Collection.py +464 -30
- RadGEEToolbox/VisParams.py +112 -194
- RadGEEToolbox/__init__.py +1 -1
- {radgeetoolbox-1.7.0.dist-info → radgeetoolbox-1.7.1.dist-info}/METADATA +8 -6
- radgeetoolbox-1.7.1.dist-info/RECORD +13 -0
- radgeetoolbox-1.7.0.dist-info/RECORD +0 -13
- {radgeetoolbox-1.7.0.dist-info → radgeetoolbox-1.7.1.dist-info}/WHEEL +0 -0
- {radgeetoolbox-1.7.0.dist-info → radgeetoolbox-1.7.1.dist-info}/licenses/LICENSE.txt +0 -0
- {radgeetoolbox-1.7.0.dist-info → radgeetoolbox-1.7.1.dist-info}/top_level.txt +0 -0
|
@@ -166,6 +166,10 @@ class Sentinel2Collection:
|
|
|
166
166
|
self._masked_water_collection = None
|
|
167
167
|
self._median = None
|
|
168
168
|
self._monthly_median = None
|
|
169
|
+
self._monthly_mean = None
|
|
170
|
+
self._monthly_max = None
|
|
171
|
+
self._monthly_min = None
|
|
172
|
+
self._monthly_sum = None
|
|
169
173
|
self._mean = None
|
|
170
174
|
self._max = None
|
|
171
175
|
self._min = None
|
|
@@ -482,7 +486,7 @@ class Sentinel2Collection:
|
|
|
482
486
|
return nbr
|
|
483
487
|
|
|
484
488
|
@staticmethod
|
|
485
|
-
def anomaly_fn(image, geometry, band_name=None, anomaly_band_name=None, replace=True):
|
|
489
|
+
def anomaly_fn(image, geometry, band_name=None, anomaly_band_name=None, replace=True, scale=10):
|
|
486
490
|
"""
|
|
487
491
|
Calculates the anomaly of a singleband image compared to the mean of the singleband image.
|
|
488
492
|
|
|
@@ -498,6 +502,7 @@ class Sentinel2Collection:
|
|
|
498
502
|
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.
|
|
499
503
|
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.
|
|
500
504
|
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.
|
|
505
|
+
scale (int, optional): The scale in meters to use for the reduction operation. Default is 10 meters.
|
|
501
506
|
|
|
502
507
|
Returns:
|
|
503
508
|
ee.Image: An ee.Image where each band represents the anomaly (deviation from
|
|
@@ -514,12 +519,16 @@ class Sentinel2Collection:
|
|
|
514
519
|
mean_image = image_to_process.reduceRegion(
|
|
515
520
|
reducer=ee.Reducer.mean(),
|
|
516
521
|
geometry=geometry,
|
|
517
|
-
scale=
|
|
522
|
+
scale=scale,
|
|
518
523
|
maxPixels=1e13
|
|
519
524
|
).toImage()
|
|
520
525
|
|
|
521
526
|
# Compute the anomaly by subtracting the mean image from the input image.
|
|
522
|
-
|
|
527
|
+
if scale == 10:
|
|
528
|
+
anomaly_image = image_to_process.subtract(mean_image)
|
|
529
|
+
else:
|
|
530
|
+
anomaly_image = image_to_process.reproject(crs=image_to_process.projection(), scale=scale).subtract(mean_image)
|
|
531
|
+
|
|
523
532
|
if anomaly_band_name is None:
|
|
524
533
|
if band_name:
|
|
525
534
|
anomaly_image = anomaly_image.rename(band_name)
|
|
@@ -530,7 +539,7 @@ class Sentinel2Collection:
|
|
|
530
539
|
anomaly_image = anomaly_image.rename(anomaly_band_name)
|
|
531
540
|
# return anomaly_image
|
|
532
541
|
if replace:
|
|
533
|
-
return anomaly_image.copyProperties(image)
|
|
542
|
+
return anomaly_image.copyProperties(image).set('system:time_start', image.get('system:time_start'))
|
|
534
543
|
else:
|
|
535
544
|
return image.addBands(anomaly_image, overwrite=True)
|
|
536
545
|
|
|
@@ -808,11 +817,11 @@ class Sentinel2Collection:
|
|
|
808
817
|
|
|
809
818
|
Args:
|
|
810
819
|
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.
|
|
811
|
-
geometry (ee.Geometry): ee.Geometry object denoting area to clip to for area calculation
|
|
820
|
+
geometry (ee.Geometry): ee.Geometry object denoting area to clip to for area calculation.
|
|
812
821
|
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.
|
|
813
|
-
scale (int): integer scale of image resolution (meters) (defaults to
|
|
814
|
-
maxPixels (int): integer denoting maximum number of pixels for calculations
|
|
815
|
-
output_type (str): 'ImageCollection' to return an ee.ImageCollection, 'Sentinel2Collection' to return a Sentinel2Collection object (defaults to 'ImageCollection')
|
|
822
|
+
scale (int): integer scale of image resolution (meters) (defaults to 30).
|
|
823
|
+
maxPixels (int): integer denoting maximum number of pixels for calculations.
|
|
824
|
+
output_type (str): 'ImageCollection' or 'ee.ImageCollection' to return an ee.ImageCollection, 'Sentinel2Collection' to return a Sentinel2Collection object, or 'DataFrame', 'Pandas', 'pd', 'dataframe', 'df' to return a pandas DataFrame (defaults to 'ImageCollection').
|
|
816
825
|
area_data_export_path (str, optional): If provided, the function will save the resulting area data to a CSV file at the specified path.
|
|
817
826
|
|
|
818
827
|
Returns:
|
|
@@ -837,15 +846,42 @@ class Sentinel2Collection:
|
|
|
837
846
|
|
|
838
847
|
# If an export path is provided, the area data will be exported to a CSV file
|
|
839
848
|
if area_data_export_path:
|
|
840
|
-
Sentinel2Collection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=band_name, file_path=area_data_export_path+'.csv')
|
|
849
|
+
Sentinel2Collection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=[band_name], file_path=area_data_export_path+'.csv')
|
|
841
850
|
|
|
842
851
|
# Returning the result in the desired format based on output_type argument or raising an error for invalid input
|
|
843
|
-
if output_type == 'ImageCollection':
|
|
852
|
+
if output_type == 'ImageCollection' or output_type == 'ee.ImageCollection':
|
|
844
853
|
return self._PixelAreaSumCollection
|
|
845
854
|
elif output_type == 'Sentinel2Collection':
|
|
846
855
|
return Sentinel2Collection(collection=self._PixelAreaSumCollection)
|
|
856
|
+
elif output_type == 'DataFrame' or output_type == 'Pandas' or output_type == 'pd' or output_type == 'dataframe' or output_type == 'df':
|
|
857
|
+
return Sentinel2Collection(collection=self._PixelAreaSumCollection).ExportProperties(property_names=[band_name])
|
|
847
858
|
else:
|
|
848
|
-
raise ValueError("output_type must be 'ImageCollection'
|
|
859
|
+
raise ValueError("Incorrect `output_type`. The `output_type` argument must be one of the following: 'ImageCollection', 'ee.ImageCollection', 'Sentinel2Collection', 'DataFrame', 'Pandas', 'pd', 'dataframe', or 'df'.")
|
|
860
|
+
|
|
861
|
+
@staticmethod
|
|
862
|
+
def add_month_property_fn(image):
|
|
863
|
+
"""
|
|
864
|
+
Adds a numeric 'month' property to the image based on its date.
|
|
865
|
+
|
|
866
|
+
Args:
|
|
867
|
+
image (ee.Image): Input image.
|
|
868
|
+
|
|
869
|
+
Returns:
|
|
870
|
+
ee.Image: Image with the 'month' property added.
|
|
871
|
+
"""
|
|
872
|
+
return image.set('month', image.date().get('month'))
|
|
873
|
+
|
|
874
|
+
@property
|
|
875
|
+
def add_month_property(self):
|
|
876
|
+
"""
|
|
877
|
+
Adds a numeric 'month' property to each image in the collection.
|
|
878
|
+
|
|
879
|
+
Returns:
|
|
880
|
+
Sentinel2Collection: A Sentinel2Collection image collection with the 'month' property added to each image.
|
|
881
|
+
"""
|
|
882
|
+
col = self.collection.map(Sentinel2Collection.add_month_property_fn)
|
|
883
|
+
return Sentinel2Collection(collection=col)
|
|
884
|
+
|
|
849
885
|
|
|
850
886
|
def combine(self, other):
|
|
851
887
|
"""
|
|
@@ -1209,6 +1245,338 @@ class Sentinel2Collection:
|
|
|
1209
1245
|
pass
|
|
1210
1246
|
|
|
1211
1247
|
return self._monthly_median
|
|
1248
|
+
|
|
1249
|
+
@property
|
|
1250
|
+
def monthly_mean_collection(self):
|
|
1251
|
+
"""Creates a monthly mean composite from a Sentinel2Collection image collection.
|
|
1252
|
+
|
|
1253
|
+
This function computes the mean for each
|
|
1254
|
+
month within the collection's date range, for each band in the collection. It automatically handles the full
|
|
1255
|
+
temporal extent of the input collection.
|
|
1256
|
+
|
|
1257
|
+
The resulting images have a 'system:time_start' property set to the
|
|
1258
|
+
first day of each month and an 'image_count' property indicating how
|
|
1259
|
+
many images were used in the composite. Months with no images are
|
|
1260
|
+
automatically excluded from the final collection.
|
|
1261
|
+
|
|
1262
|
+
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.
|
|
1263
|
+
|
|
1264
|
+
Returns:
|
|
1265
|
+
Sentinel2Collection: A new Sentinel2Collection object with monthly mean composites.
|
|
1266
|
+
"""
|
|
1267
|
+
if self._monthly_mean is None:
|
|
1268
|
+
collection = self.collection
|
|
1269
|
+
target_proj = collection.first().projection()
|
|
1270
|
+
# Get the start and end dates of the entire collection.
|
|
1271
|
+
date_range = collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
|
|
1272
|
+
original_start_date = ee.Date(date_range.get('min'))
|
|
1273
|
+
end_date = ee.Date(date_range.get('max'))
|
|
1274
|
+
|
|
1275
|
+
start_year = original_start_date.get('year')
|
|
1276
|
+
start_month = original_start_date.get('month')
|
|
1277
|
+
start_date = ee.Date.fromYMD(start_year, start_month, 1)
|
|
1278
|
+
|
|
1279
|
+
# Calculate the total number of months in the date range.
|
|
1280
|
+
# The .round() is important for ensuring we get an integer.
|
|
1281
|
+
num_months = end_date.difference(start_date, 'month').round()
|
|
1282
|
+
|
|
1283
|
+
# Generate a list of starting dates for each month.
|
|
1284
|
+
# This uses a sequence and advances the start date by 'i' months.
|
|
1285
|
+
def get_month_start(i):
|
|
1286
|
+
return start_date.advance(i, 'month')
|
|
1287
|
+
|
|
1288
|
+
month_starts = ee.List.sequence(0, num_months).map(get_month_start)
|
|
1289
|
+
|
|
1290
|
+
# Define a function to map over the list of month start dates.
|
|
1291
|
+
def create_monthly_composite(date):
|
|
1292
|
+
# Cast the input to an ee.Date object.
|
|
1293
|
+
start_of_month = ee.Date(date)
|
|
1294
|
+
# The end date is exclusive, so we advance by 1 month.
|
|
1295
|
+
end_of_month = start_of_month.advance(1, 'month')
|
|
1296
|
+
|
|
1297
|
+
# Filter the original collection to get images for the current month.
|
|
1298
|
+
monthly_subset = collection.filterDate(start_of_month, end_of_month)
|
|
1299
|
+
|
|
1300
|
+
# Count the number of images in the monthly subset.
|
|
1301
|
+
image_count = monthly_subset.size()
|
|
1302
|
+
|
|
1303
|
+
# Compute the mean. This is robust to outliers like clouds.
|
|
1304
|
+
monthly_mean = monthly_subset.mean()
|
|
1305
|
+
|
|
1306
|
+
# Set essential properties on the resulting composite image.
|
|
1307
|
+
# The timestamp is crucial for time-series analysis and charting.
|
|
1308
|
+
# The image_count is useful metadata for quality assessment.
|
|
1309
|
+
return monthly_mean.set({
|
|
1310
|
+
'system:time_start': start_of_month.millis(),
|
|
1311
|
+
'month': start_of_month.get('month'),
|
|
1312
|
+
'year': start_of_month.get('year'),
|
|
1313
|
+
'Date_Filter': start_of_month.format('YYYY-MM-dd'),
|
|
1314
|
+
'image_count': image_count
|
|
1315
|
+
}).reproject(target_proj)
|
|
1316
|
+
|
|
1317
|
+
# Map the composite function over the list of month start dates.
|
|
1318
|
+
monthly_composites_list = month_starts.map(create_monthly_composite)
|
|
1319
|
+
|
|
1320
|
+
# Convert the list of images into an ee.ImageCollection.
|
|
1321
|
+
monthly_collection = ee.ImageCollection.fromImages(monthly_composites_list)
|
|
1322
|
+
|
|
1323
|
+
# Filter out any composites that were created from zero images.
|
|
1324
|
+
# This prevents empty/masked images from being in the final collection.
|
|
1325
|
+
final_collection = Sentinel2Collection(collection=monthly_collection.filter(ee.Filter.gt('image_count', 0)))
|
|
1326
|
+
self._monthly_mean = final_collection
|
|
1327
|
+
else:
|
|
1328
|
+
pass
|
|
1329
|
+
|
|
1330
|
+
return self._monthly_mean
|
|
1331
|
+
|
|
1332
|
+
@property
|
|
1333
|
+
def monthly_sum_collection(self):
|
|
1334
|
+
"""Creates a monthly sum composite from a Sentinel2Collection image collection.
|
|
1335
|
+
|
|
1336
|
+
This function computes the sum for each
|
|
1337
|
+
month within the collection's date range, for each band in the collection. It automatically handles the full
|
|
1338
|
+
temporal extent of the input collection.
|
|
1339
|
+
|
|
1340
|
+
The resulting images have a 'system:time_start' property set to the
|
|
1341
|
+
first day of each month and an 'image_count' property indicating how
|
|
1342
|
+
many images were used in the composite. Months with no images are
|
|
1343
|
+
automatically excluded from the final collection.
|
|
1344
|
+
|
|
1345
|
+
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.
|
|
1346
|
+
|
|
1347
|
+
Returns:
|
|
1348
|
+
Sentinel2Collection: A new Sentinel2Collection object with monthly sum composites.
|
|
1349
|
+
"""
|
|
1350
|
+
if self._monthly_sum is None:
|
|
1351
|
+
collection = self.collection
|
|
1352
|
+
target_proj = collection.first().projection()
|
|
1353
|
+
# Get the start and end dates of the entire collection.
|
|
1354
|
+
date_range = collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
|
|
1355
|
+
original_start_date = ee.Date(date_range.get('min'))
|
|
1356
|
+
end_date = ee.Date(date_range.get('max'))
|
|
1357
|
+
|
|
1358
|
+
start_year = original_start_date.get('year')
|
|
1359
|
+
start_month = original_start_date.get('month')
|
|
1360
|
+
start_date = ee.Date.fromYMD(start_year, start_month, 1)
|
|
1361
|
+
|
|
1362
|
+
# Calculate the total number of months in the date range.
|
|
1363
|
+
# The .round() is important for ensuring we get an integer.
|
|
1364
|
+
num_months = end_date.difference(start_date, 'month').round()
|
|
1365
|
+
|
|
1366
|
+
# Generate a list of starting dates for each month.
|
|
1367
|
+
# This uses a sequence and advances the start date by 'i' months.
|
|
1368
|
+
def get_month_start(i):
|
|
1369
|
+
return start_date.advance(i, 'month')
|
|
1370
|
+
|
|
1371
|
+
month_starts = ee.List.sequence(0, num_months).map(get_month_start)
|
|
1372
|
+
|
|
1373
|
+
# Define a function to map over the list of month start dates.
|
|
1374
|
+
def create_monthly_composite(date):
|
|
1375
|
+
# Cast the input to an ee.Date object.
|
|
1376
|
+
start_of_month = ee.Date(date)
|
|
1377
|
+
# The end date is exclusive, so we advance by 1 month.
|
|
1378
|
+
end_of_month = start_of_month.advance(1, 'month')
|
|
1379
|
+
|
|
1380
|
+
# Filter the original collection to get images for the current month.
|
|
1381
|
+
monthly_subset = collection.filterDate(start_of_month, end_of_month)
|
|
1382
|
+
|
|
1383
|
+
# Count the number of images in the monthly subset.
|
|
1384
|
+
image_count = monthly_subset.size()
|
|
1385
|
+
|
|
1386
|
+
# Compute the sum. This is robust to outliers like clouds.
|
|
1387
|
+
monthly_sum = monthly_subset.sum()
|
|
1388
|
+
|
|
1389
|
+
# Set essential properties on the resulting composite image.
|
|
1390
|
+
# The timestamp is crucial for time-series analysis and charting.
|
|
1391
|
+
# The image_count is useful metadata for quality assessment.
|
|
1392
|
+
return monthly_sum.set({
|
|
1393
|
+
'system:time_start': start_of_month.millis(),
|
|
1394
|
+
'month': start_of_month.get('month'),
|
|
1395
|
+
'year': start_of_month.get('year'),
|
|
1396
|
+
'Date_Filter': start_of_month.format('YYYY-MM-dd'),
|
|
1397
|
+
'image_count': image_count
|
|
1398
|
+
}).reproject(target_proj)
|
|
1399
|
+
|
|
1400
|
+
# Map the composite function over the list of month start dates.
|
|
1401
|
+
monthly_composites_list = month_starts.map(create_monthly_composite)
|
|
1402
|
+
|
|
1403
|
+
# Convert the list of images into an ee.ImageCollection.
|
|
1404
|
+
monthly_collection = ee.ImageCollection.fromImages(monthly_composites_list)
|
|
1405
|
+
|
|
1406
|
+
# Filter out any composites that were created from zero images.
|
|
1407
|
+
# This prevents empty/masked images from being in the final collection.
|
|
1408
|
+
final_collection = Sentinel2Collection(collection=monthly_collection.filter(ee.Filter.gt('image_count', 0)))
|
|
1409
|
+
self._monthly_sum = final_collection
|
|
1410
|
+
else:
|
|
1411
|
+
pass
|
|
1412
|
+
|
|
1413
|
+
return self._monthly_sum
|
|
1414
|
+
|
|
1415
|
+
@property
|
|
1416
|
+
def monthly_max_collection(self):
|
|
1417
|
+
"""Creates a monthly max composite from a Sentinel2Collection image collection.
|
|
1418
|
+
|
|
1419
|
+
This function computes the max for each
|
|
1420
|
+
month within the collection's date range, for each band in the collection. It automatically handles the full
|
|
1421
|
+
temporal extent of the input collection.
|
|
1422
|
+
|
|
1423
|
+
The resulting images have a 'system:time_start' property set to the
|
|
1424
|
+
first day of each month and an 'image_count' property indicating how
|
|
1425
|
+
many images were used in the composite. Months with no images are
|
|
1426
|
+
automatically excluded from the final collection.
|
|
1427
|
+
|
|
1428
|
+
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.
|
|
1429
|
+
|
|
1430
|
+
Returns:
|
|
1431
|
+
Sentinel2Collection: A new Sentinel2Collection object with monthly max composites.
|
|
1432
|
+
"""
|
|
1433
|
+
if self._monthly_max is None:
|
|
1434
|
+
collection = self.collection
|
|
1435
|
+
target_proj = collection.first().projection()
|
|
1436
|
+
# Get the start and end dates of the entire collection.
|
|
1437
|
+
date_range = collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
|
|
1438
|
+
original_start_date = ee.Date(date_range.get('min'))
|
|
1439
|
+
end_date = ee.Date(date_range.get('max'))
|
|
1440
|
+
|
|
1441
|
+
start_year = original_start_date.get('year')
|
|
1442
|
+
start_month = original_start_date.get('month')
|
|
1443
|
+
start_date = ee.Date.fromYMD(start_year, start_month, 1)
|
|
1444
|
+
|
|
1445
|
+
# Calculate the total number of months in the date range.
|
|
1446
|
+
# The .round() is important for ensuring we get an integer.
|
|
1447
|
+
num_months = end_date.difference(start_date, 'month').round()
|
|
1448
|
+
|
|
1449
|
+
# Generate a list of starting dates for each month.
|
|
1450
|
+
# This uses a sequence and advances the start date by 'i' months.
|
|
1451
|
+
def get_month_start(i):
|
|
1452
|
+
return start_date.advance(i, 'month')
|
|
1453
|
+
|
|
1454
|
+
month_starts = ee.List.sequence(0, num_months).map(get_month_start)
|
|
1455
|
+
|
|
1456
|
+
# Define a function to map over the list of month start dates.
|
|
1457
|
+
def create_monthly_composite(date):
|
|
1458
|
+
# Cast the input to an ee.Date object.
|
|
1459
|
+
start_of_month = ee.Date(date)
|
|
1460
|
+
# The end date is exclusive, so we advance by 1 month.
|
|
1461
|
+
end_of_month = start_of_month.advance(1, 'month')
|
|
1462
|
+
|
|
1463
|
+
# Filter the original collection to get images for the current month.
|
|
1464
|
+
monthly_subset = collection.filterDate(start_of_month, end_of_month)
|
|
1465
|
+
|
|
1466
|
+
# Count the number of images in the monthly subset.
|
|
1467
|
+
image_count = monthly_subset.size()
|
|
1468
|
+
|
|
1469
|
+
# Compute the max. This is robust to outliers like clouds.
|
|
1470
|
+
monthly_max = monthly_subset.max()
|
|
1471
|
+
|
|
1472
|
+
# Set essential properties on the resulting composite image.
|
|
1473
|
+
# The timestamp is crucial for time-series analysis and charting.
|
|
1474
|
+
# The image_count is useful metadata for quality assessment.
|
|
1475
|
+
return monthly_max.set({
|
|
1476
|
+
'system:time_start': start_of_month.millis(),
|
|
1477
|
+
'month': start_of_month.get('month'),
|
|
1478
|
+
'year': start_of_month.get('year'),
|
|
1479
|
+
'Date_Filter': start_of_month.format('YYYY-MM-dd'),
|
|
1480
|
+
'image_count': image_count
|
|
1481
|
+
}).reproject(target_proj)
|
|
1482
|
+
|
|
1483
|
+
# Map the composite function over the list of month start dates.
|
|
1484
|
+
monthly_composites_list = month_starts.map(create_monthly_composite)
|
|
1485
|
+
|
|
1486
|
+
# Convert the list of images into an ee.ImageCollection.
|
|
1487
|
+
monthly_collection = ee.ImageCollection.fromImages(monthly_composites_list)
|
|
1488
|
+
|
|
1489
|
+
# Filter out any composites that were created from zero images.
|
|
1490
|
+
# This prevents empty/masked images from being in the final collection.
|
|
1491
|
+
final_collection = Sentinel2Collection(collection=monthly_collection.filter(ee.Filter.gt('image_count', 0)))
|
|
1492
|
+
self._monthly_max = final_collection
|
|
1493
|
+
else:
|
|
1494
|
+
pass
|
|
1495
|
+
|
|
1496
|
+
return self._monthly_max
|
|
1497
|
+
|
|
1498
|
+
@property
|
|
1499
|
+
def monthly_min_collection(self):
|
|
1500
|
+
"""Creates a monthly min composite from a Sentinel2Collection image collection.
|
|
1501
|
+
|
|
1502
|
+
This function computes the min for each
|
|
1503
|
+
month within the collection's date range, for each band in the collection. It automatically handles the full
|
|
1504
|
+
temporal extent of the input collection.
|
|
1505
|
+
|
|
1506
|
+
The resulting images have a 'system:time_start' property set to the
|
|
1507
|
+
first day of each month and an 'image_count' property indicating how
|
|
1508
|
+
many images were used in the composite. Months with no images are
|
|
1509
|
+
automatically excluded from the final collection.
|
|
1510
|
+
|
|
1511
|
+
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.
|
|
1512
|
+
|
|
1513
|
+
Returns:
|
|
1514
|
+
Sentinel2Collection: A new Sentinel2Collection object with monthly min composites.
|
|
1515
|
+
"""
|
|
1516
|
+
if self._monthly_min is None:
|
|
1517
|
+
collection = self.collection
|
|
1518
|
+
target_proj = collection.first().projection()
|
|
1519
|
+
# Get the start and end dates of the entire collection.
|
|
1520
|
+
date_range = collection.reduceColumns(ee.Reducer.minMax(), ["system:time_start"])
|
|
1521
|
+
original_start_date = ee.Date(date_range.get('min'))
|
|
1522
|
+
end_date = ee.Date(date_range.get('max'))
|
|
1523
|
+
|
|
1524
|
+
start_year = original_start_date.get('year')
|
|
1525
|
+
start_month = original_start_date.get('month')
|
|
1526
|
+
start_date = ee.Date.fromYMD(start_year, start_month, 1)
|
|
1527
|
+
|
|
1528
|
+
# Calculate the total number of months in the date range.
|
|
1529
|
+
# The .round() is important for ensuring we get an integer.
|
|
1530
|
+
num_months = end_date.difference(start_date, 'month').round()
|
|
1531
|
+
|
|
1532
|
+
# Generate a list of starting dates for each month.
|
|
1533
|
+
# This uses a sequence and advances the start date by 'i' months.
|
|
1534
|
+
def get_month_start(i):
|
|
1535
|
+
return start_date.advance(i, 'month')
|
|
1536
|
+
|
|
1537
|
+
month_starts = ee.List.sequence(0, num_months).map(get_month_start)
|
|
1538
|
+
|
|
1539
|
+
# Define a function to map over the list of month start dates.
|
|
1540
|
+
def create_monthly_composite(date):
|
|
1541
|
+
# Cast the input to an ee.Date object.
|
|
1542
|
+
start_of_month = ee.Date(date)
|
|
1543
|
+
# The end date is exclusive, so we advance by 1 month.
|
|
1544
|
+
end_of_month = start_of_month.advance(1, 'month')
|
|
1545
|
+
|
|
1546
|
+
# Filter the original collection to get images for the current month.
|
|
1547
|
+
monthly_subset = collection.filterDate(start_of_month, end_of_month)
|
|
1548
|
+
|
|
1549
|
+
# Count the number of images in the monthly subset.
|
|
1550
|
+
image_count = monthly_subset.size()
|
|
1551
|
+
|
|
1552
|
+
# Compute the min. This is robust to outliers like clouds.
|
|
1553
|
+
monthly_min = monthly_subset.min()
|
|
1554
|
+
|
|
1555
|
+
# Set essential properties on the resulting composite image.
|
|
1556
|
+
# The timestamp is crucial for time-series analysis and charting.
|
|
1557
|
+
# The image_count is useful metadata for quality assessment.
|
|
1558
|
+
return monthly_min.set({
|
|
1559
|
+
'system:time_start': start_of_month.millis(),
|
|
1560
|
+
'month': start_of_month.get('month'),
|
|
1561
|
+
'year': start_of_month.get('year'),
|
|
1562
|
+
'Date_Filter': start_of_month.format('YYYY-MM-dd'),
|
|
1563
|
+
'image_count': image_count
|
|
1564
|
+
}).reproject(target_proj)
|
|
1565
|
+
|
|
1566
|
+
# Map the composite function over the list of month start dates.
|
|
1567
|
+
monthly_composites_list = month_starts.map(create_monthly_composite)
|
|
1568
|
+
|
|
1569
|
+
# Convert the list of images into an ee.ImageCollection.
|
|
1570
|
+
monthly_collection = ee.ImageCollection.fromImages(monthly_composites_list)
|
|
1571
|
+
|
|
1572
|
+
# Filter out any composites that were created from zero images.
|
|
1573
|
+
# This prevents empty/masked images from being in the final collection.
|
|
1574
|
+
final_collection = Sentinel2Collection(collection=monthly_collection.filter(ee.Filter.gt('image_count', 0)))
|
|
1575
|
+
self._monthly_min = final_collection
|
|
1576
|
+
else:
|
|
1577
|
+
pass
|
|
1578
|
+
|
|
1579
|
+
return self._monthly_min
|
|
1212
1580
|
|
|
1213
1581
|
@property
|
|
1214
1582
|
def mean(self):
|
|
@@ -1868,6 +2236,30 @@ class Sentinel2Collection:
|
|
|
1868
2236
|
)
|
|
1869
2237
|
return Sentinel2Collection(collection=col)
|
|
1870
2238
|
|
|
2239
|
+
def remove_duplicate_dates(self, sort_by='system:time_start', ascending=True):
|
|
2240
|
+
"""
|
|
2241
|
+
Removes duplicate images that share the same date, keeping only the first one encountered.
|
|
2242
|
+
Useful for handling duplicate Sentinel-2A/2B acquisitions or overlapping path/rows.
|
|
2243
|
+
|
|
2244
|
+
Args:
|
|
2245
|
+
sort_by (str): Property to sort by before filtering distinct dates.
|
|
2246
|
+
Defaults to 'system:time_start'.
|
|
2247
|
+
Recommended to use 'CLOUDY_PIXEL_PERCENTAGE' (ascending=True) to keep the clearest image.
|
|
2248
|
+
ascending (bool): Sort order. Defaults to True.
|
|
2249
|
+
|
|
2250
|
+
Returns:
|
|
2251
|
+
Sentinel2Collection: A new Sentinel2Collection object with distinct dates.
|
|
2252
|
+
"""
|
|
2253
|
+
if sort_by not in ['system:time_start', 'CLOUDY_PIXEL_PERCENTAGE']:
|
|
2254
|
+
raise ValueError(f"The provided `sort_by` argument is invalid: {sort_by}. The `sort_by` argument must be either 'system:time_start' or 'CLOUDY_PIXEL_PERCENTAGE'.")
|
|
2255
|
+
# Sort the collection to ensure the "best" image comes first (e.g. least cloudy)
|
|
2256
|
+
sorted_col = self.collection.sort(sort_by, ascending)
|
|
2257
|
+
|
|
2258
|
+
# distinct() retains the first image for each unique value of the specified property
|
|
2259
|
+
distinct_col = sorted_col.distinct('Date_Filter')
|
|
2260
|
+
|
|
2261
|
+
return Sentinel2Collection(collection=distinct_col)
|
|
2262
|
+
|
|
1871
2263
|
@property
|
|
1872
2264
|
def masked_water_collection(self):
|
|
1873
2265
|
"""
|
|
@@ -2068,26 +2460,27 @@ class Sentinel2Collection:
|
|
|
2068
2460
|
raise ValueError("Threshold must be specified for binary masking.")
|
|
2069
2461
|
|
|
2070
2462
|
if classify_above_threshold:
|
|
2071
|
-
if mask_zeros:
|
|
2463
|
+
if mask_zeros is True:
|
|
2072
2464
|
col = self.collection.map(
|
|
2073
|
-
lambda image: image.select(band_name).gte(threshold).rename(band_name).
|
|
2465
|
+
lambda image: image.select(band_name).gte(threshold).rename(band_name).selfMask().copyProperties(image)
|
|
2074
2466
|
)
|
|
2075
2467
|
else:
|
|
2076
2468
|
col = self.collection.map(
|
|
2077
2469
|
lambda image: image.select(band_name).gte(threshold).rename(band_name).copyProperties(image)
|
|
2078
2470
|
)
|
|
2079
2471
|
else:
|
|
2080
|
-
if mask_zeros:
|
|
2472
|
+
if mask_zeros is True:
|
|
2081
2473
|
col = self.collection.map(
|
|
2082
|
-
lambda image: image.select(band_name).lte(threshold).rename(band_name).
|
|
2474
|
+
lambda image: image.select(band_name).lte(threshold).rename(band_name).selfMask().copyProperties(image)
|
|
2083
2475
|
)
|
|
2084
2476
|
else:
|
|
2085
2477
|
col = self.collection.map(
|
|
2086
2478
|
lambda image: image.select(band_name).lte(threshold).rename(band_name).copyProperties(image)
|
|
2087
2479
|
)
|
|
2480
|
+
|
|
2088
2481
|
return Sentinel2Collection(collection=col)
|
|
2089
2482
|
|
|
2090
|
-
def anomaly(self, geometry, band_name=None, anomaly_band_name=None, replace=True):
|
|
2483
|
+
def anomaly(self, geometry, band_name=None, anomaly_band_name=None, replace=True, scale=10):
|
|
2091
2484
|
"""
|
|
2092
2485
|
Calculates the anomaly of each image in a collection compared to the mean of each image.
|
|
2093
2486
|
|
|
@@ -2101,6 +2494,7 @@ class Sentinel2Collection:
|
|
|
2101
2494
|
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.
|
|
2102
2495
|
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.
|
|
2103
2496
|
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.
|
|
2497
|
+
scale (int, optional): The scale (in meters) to use for the image reduction. Default is 10 meters.
|
|
2104
2498
|
|
|
2105
2499
|
Returns:
|
|
2106
2500
|
Sentinel2Collection: A Sentinel2Collection where each image represents the anomaly (deviation from
|
|
@@ -2119,7 +2513,7 @@ class Sentinel2Collection:
|
|
|
2119
2513
|
else:
|
|
2120
2514
|
band_name = band_names.get(0).getInfo()
|
|
2121
2515
|
|
|
2122
|
-
col = self.collection.map(lambda image: Sentinel2Collection.anomaly_fn(image, geometry=geometry, band_name=band_name, anomaly_band_name=anomaly_band_name, replace=replace))
|
|
2516
|
+
col = self.collection.map(lambda image: Sentinel2Collection.anomaly_fn(image, geometry=geometry, band_name=band_name, anomaly_band_name=anomaly_band_name, replace=replace, scale=scale))
|
|
2123
2517
|
return Sentinel2Collection(collection=col)
|
|
2124
2518
|
|
|
2125
2519
|
def mask_via_band(self, band_to_mask, band_for_mask, threshold=-1, mask_above=True, add_band_to_original_image=False):
|
|
@@ -2892,8 +3286,9 @@ class Sentinel2Collection:
|
|
|
2892
3286
|
def iterate_zonal_stats(
|
|
2893
3287
|
self,
|
|
2894
3288
|
geometries,
|
|
3289
|
+
band=None,
|
|
2895
3290
|
reducer_type="mean",
|
|
2896
|
-
scale=
|
|
3291
|
+
scale=30,
|
|
2897
3292
|
geometry_names=None,
|
|
2898
3293
|
buffer_size=1,
|
|
2899
3294
|
tileScale=1,
|
|
@@ -2905,10 +3300,13 @@ class Sentinel2Collection:
|
|
|
2905
3300
|
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).
|
|
2906
3301
|
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.
|
|
2907
3302
|
|
|
3303
|
+
NOTE: The expected input is a singleband image. If a multiband image is provided, the first band will be used for zonal statistic extraction, unless `band` is specified.
|
|
3304
|
+
|
|
2908
3305
|
Args:
|
|
2909
3306
|
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)!
|
|
3307
|
+
band (str, optional): The name of the band to use for statistics. If None, the first band is used. Defaults to None.
|
|
2910
3308
|
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.
|
|
2911
|
-
scale (int, optional): Pixel scale in meters for the reduction. Defaults to
|
|
3309
|
+
scale (int, optional): Pixel scale in meters for the reduction. Defaults to 30.
|
|
2912
3310
|
geometry_names (list, optional): A list of string names for the geometries. If provided, must match the number of geometries. Defaults to None.
|
|
2913
3311
|
buffer_size (int, optional): Radial buffer in meters around coordinates. Defaults to 1.
|
|
2914
3312
|
tileScale (int, optional): A scaling factor to reduce aggregation tile size. Defaults to 1.
|
|
@@ -2923,18 +3321,30 @@ class Sentinel2Collection:
|
|
|
2923
3321
|
ValueError: If input parameters are invalid.
|
|
2924
3322
|
TypeError: If geometries input type is unsupported.
|
|
2925
3323
|
"""
|
|
3324
|
+
# Create a local reference to the collection object to allow for modifications (like band selection) without altering the original instance
|
|
2926
3325
|
img_collection_obj = self
|
|
2927
|
-
|
|
3326
|
+
|
|
3327
|
+
# If a specific band is requested, select only that band
|
|
3328
|
+
if band:
|
|
3329
|
+
img_collection_obj = Sentinel2Collection(collection=img_collection_obj.collection.select(band))
|
|
3330
|
+
else:
|
|
3331
|
+
# If no band is specified, default to using the first band of the first image in the collection
|
|
3332
|
+
first_image = img_collection_obj.image_grab(0)
|
|
3333
|
+
first_band = first_image.bandNames().get(0)
|
|
3334
|
+
img_collection_obj = Sentinel2Collection(collection=img_collection_obj.collection.select([first_band]))
|
|
3335
|
+
|
|
3336
|
+
# If a list of dates is provided, filter the collection to include only images matching those dates
|
|
2928
3337
|
if dates:
|
|
2929
3338
|
img_collection_obj = Sentinel2Collection(
|
|
2930
3339
|
collection=self.collection.filter(ee.Filter.inList('Date_Filter', dates))
|
|
2931
3340
|
)
|
|
2932
3341
|
|
|
2933
|
-
# Initialize variables
|
|
3342
|
+
# Initialize variables to hold the standardized feature collection and coordinates
|
|
2934
3343
|
features = None
|
|
2935
3344
|
validated_coordinates = []
|
|
2936
3345
|
|
|
2937
|
-
#
|
|
3346
|
+
# Define a helper function to ensure every feature has a standardized 'geo_name' property
|
|
3347
|
+
# This handles features that might have different existing name properties or none at all
|
|
2938
3348
|
def set_standard_name(feature):
|
|
2939
3349
|
has_geo_name = feature.get('geo_name')
|
|
2940
3350
|
has_name = feature.get('name')
|
|
@@ -2945,33 +3355,38 @@ class Sentinel2Collection:
|
|
|
2945
3355
|
ee.Algorithms.If(has_index, has_index, 'unnamed_geometry')))
|
|
2946
3356
|
return feature.set({'geo_name': new_name})
|
|
2947
3357
|
|
|
3358
|
+
# Handle input: FeatureCollection or single Feature
|
|
2948
3359
|
if isinstance(geometries, (ee.FeatureCollection, ee.Feature)):
|
|
2949
3360
|
features = ee.FeatureCollection(geometries)
|
|
2950
3361
|
if geometry_names:
|
|
2951
3362
|
print("Warning: 'geometry_names' are ignored when the input is an ee.Feature or ee.FeatureCollection.")
|
|
2952
3363
|
|
|
3364
|
+
# Handle input: Single ee.Geometry
|
|
2953
3365
|
elif isinstance(geometries, ee.Geometry):
|
|
2954
3366
|
name = geometry_names[0] if (geometry_names and geometry_names[0]) else 'unnamed_geometry'
|
|
2955
3367
|
features = ee.FeatureCollection([ee.Feature(geometries).set('geo_name', name)])
|
|
2956
3368
|
|
|
3369
|
+
# Handle input: List (could be coordinates or ee.Geometry objects)
|
|
2957
3370
|
elif isinstance(geometries, list):
|
|
2958
3371
|
if not geometries: # Handle empty list case
|
|
2959
3372
|
raise ValueError("'geometries' list cannot be empty.")
|
|
2960
3373
|
|
|
2961
|
-
# Case: List of coordinates
|
|
3374
|
+
# Case: List of tuples (coordinates)
|
|
2962
3375
|
if all(isinstance(i, tuple) for i in geometries):
|
|
2963
3376
|
validated_coordinates = geometries
|
|
3377
|
+
# Generate default names if none provided
|
|
2964
3378
|
if geometry_names is None:
|
|
2965
3379
|
geometry_names = [f"Location_{i+1}" for i in range(len(validated_coordinates))]
|
|
2966
3380
|
elif len(geometry_names) != len(validated_coordinates):
|
|
2967
3381
|
raise ValueError("geometry_names must have the same length as the coordinates list.")
|
|
3382
|
+
# Create features with buffers around the coordinates
|
|
2968
3383
|
points = [
|
|
2969
3384
|
ee.Feature(ee.Geometry.Point(coord).buffer(buffer_size), {'geo_name': str(name)})
|
|
2970
3385
|
for coord, name in zip(validated_coordinates, geometry_names)
|
|
2971
3386
|
]
|
|
2972
3387
|
features = ee.FeatureCollection(points)
|
|
2973
3388
|
|
|
2974
|
-
# Case: List of
|
|
3389
|
+
# Case: List of ee.Geometry objects
|
|
2975
3390
|
elif all(isinstance(i, ee.Geometry) for i in geometries):
|
|
2976
3391
|
if geometry_names is None:
|
|
2977
3392
|
geometry_names = [f"Geometry_{i+1}" for i in range(len(geometries))]
|
|
@@ -2986,6 +3401,7 @@ class Sentinel2Collection:
|
|
|
2986
3401
|
else:
|
|
2987
3402
|
raise TypeError("Input list must be a list of (lon, lat) tuples OR a list of ee.Geometry objects.")
|
|
2988
3403
|
|
|
3404
|
+
# Handle input: Single tuple (coordinate)
|
|
2989
3405
|
elif isinstance(geometries, tuple) and len(geometries) == 2:
|
|
2990
3406
|
name = geometry_names[0] if geometry_names else 'Location_1'
|
|
2991
3407
|
features = ee.FeatureCollection([
|
|
@@ -2994,39 +3410,48 @@ class Sentinel2Collection:
|
|
|
2994
3410
|
else:
|
|
2995
3411
|
raise TypeError("Unsupported type for 'geometries'.")
|
|
2996
3412
|
|
|
3413
|
+
# Apply the naming standardization to the created FeatureCollection
|
|
2997
3414
|
features = features.map(set_standard_name)
|
|
2998
3415
|
|
|
3416
|
+
# Dynamically retrieve the Earth Engine reducer based on the string name provided
|
|
2999
3417
|
try:
|
|
3000
3418
|
reducer = getattr(ee.Reducer, reducer_type)()
|
|
3001
3419
|
except AttributeError:
|
|
3002
3420
|
raise ValueError(f"Unknown reducer_type: '{reducer_type}'.")
|
|
3003
3421
|
|
|
3422
|
+
# Define the function to map over the image collection
|
|
3004
3423
|
def calculate_stats_for_image(image):
|
|
3005
3424
|
image_date = image.get('Date_Filter')
|
|
3425
|
+
# Calculate statistics for all geometries in 'features' for this specific image
|
|
3006
3426
|
stats_fc = image.reduceRegions(
|
|
3007
3427
|
collection=features, reducer=reducer, scale=scale, tileScale=tileScale
|
|
3008
3428
|
)
|
|
3009
3429
|
|
|
3430
|
+
# Helper to ensure the result has the reducer property, even if masked
|
|
3431
|
+
# If the property is missing (e.g., all pixels masked), set it to a sentinel value (-9999)
|
|
3010
3432
|
def guarantee_reducer_property(f):
|
|
3011
3433
|
has_property = f.propertyNames().contains(reducer_type)
|
|
3012
3434
|
return ee.Algorithms.If(has_property, f, f.set(reducer_type, -9999))
|
|
3435
|
+
|
|
3436
|
+
# Apply the guarantee check
|
|
3013
3437
|
fixed_stats_fc = stats_fc.map(guarantee_reducer_property)
|
|
3014
3438
|
|
|
3439
|
+
# Attach the image date to every feature in the result so we know which image it came from
|
|
3015
3440
|
return fixed_stats_fc.map(lambda f: f.set('image_date', image_date))
|
|
3016
3441
|
|
|
3442
|
+
# Map the calculation over the image collection and flatten the resulting FeatureCollections into one
|
|
3017
3443
|
results_fc = ee.FeatureCollection(img_collection_obj.collection.map(calculate_stats_for_image)).flatten()
|
|
3444
|
+
|
|
3445
|
+
# Convert the Earth Engine FeatureCollection to a pandas DataFrame (client-side operation)
|
|
3018
3446
|
df = Sentinel2Collection.ee_to_df(results_fc, remove_geom=True)
|
|
3019
3447
|
|
|
3020
|
-
#
|
|
3448
|
+
# Check for empty results or missing columns
|
|
3021
3449
|
if df.empty:
|
|
3022
|
-
# 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.")
|
|
3023
|
-
# return df
|
|
3024
3450
|
raise ValueError("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.")
|
|
3025
3451
|
if reducer_type not in df.columns:
|
|
3026
3452
|
print(f"Warning: Reducer '{reducer_type}' not found in results.")
|
|
3027
|
-
# return df
|
|
3028
3453
|
|
|
3029
|
-
#
|
|
3454
|
+
# Filter out the sentinel values (-9999) which indicate failed reductions/masked pixels
|
|
3030
3455
|
initial_rows = len(df)
|
|
3031
3456
|
df.dropna(subset=[reducer_type], inplace=True)
|
|
3032
3457
|
df = df[df[reducer_type] != -9999]
|
|
@@ -3034,9 +3459,18 @@ class Sentinel2Collection:
|
|
|
3034
3459
|
if dropped_rows > 0:
|
|
3035
3460
|
print(f"Warning: Discarded {dropped_rows} results due to failed reductions (e.g., no valid pixels in geometry).")
|
|
3036
3461
|
|
|
3037
|
-
#
|
|
3462
|
+
# Pivot the DataFrame so that each row represents a date and each column represents a geometry location
|
|
3038
3463
|
pivot_df = df.pivot(index='image_date', columns='geo_name', values=reducer_type)
|
|
3464
|
+
# Rename the column headers (geometry names) to include the reducer type
|
|
3465
|
+
pivot_df.columns = [f"{col}_{reducer_type}" for col in pivot_df.columns]
|
|
3466
|
+
# Rename the index axis to 'Date' so it is correctly labeled when moved to a column later
|
|
3039
3467
|
pivot_df.index.name = 'Date'
|
|
3468
|
+
# Remove the name of the columns axis (which defaults to 'geo_name') so it doesn't appear as a confusing label in the final output
|
|
3469
|
+
pivot_df.columns.name = None
|
|
3470
|
+
# Reset the index to move the 'Date' index into a regular column and create a standard numerical index (0, 1, 2...)
|
|
3471
|
+
pivot_df = pivot_df.reset_index(drop=False)
|
|
3472
|
+
|
|
3473
|
+
# If a file path is provided, save the resulting DataFrame to CSV
|
|
3040
3474
|
if file_path:
|
|
3041
3475
|
# Check if file_path ends with .csv and remove it if so for consistency
|
|
3042
3476
|
if file_path.endswith('.csv'):
|