gee-acolite 1.0.0__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.
@@ -0,0 +1,49 @@
1
+ """
2
+ GEE ACOLITE - Atmospheric correction for Google Earth Engine
3
+
4
+ This package provides ACOLITE atmospheric correction optimized for
5
+ Google Earth Engine workflows with Sentinel-2 imagery.
6
+
7
+ Main Features:
8
+ - Dark spectrum fitting atmospheric correction
9
+ - AOT estimation (fixed geometry)
10
+ - Water quality parameter computation
11
+ - Cloud masking integration
12
+
13
+ Note: Requires ACOLITE package to be installed separately.
14
+ Install from: https://github.com/acolite/acolite
15
+ """
16
+
17
+ __version__ = "0.1.0"
18
+ __author__ = "Sergio"
19
+ __license__ = "GPL-3.0"
20
+
21
+ # Main atmospheric correction class
22
+ from gee_acolite.correction import ACOLITE
23
+
24
+ # Bathymetry utilities
25
+ from gee_acolite.bathymetry import multi_image
26
+
27
+ # Water quality parameters
28
+ from gee_acolite.water_quality import (
29
+ compute_water_bands,
30
+ compute_water_mask,
31
+ PRODUCTS
32
+ )
33
+
34
+ # Utility modules
35
+ from gee_acolite import utils, sensors
36
+
37
+ __all__ = [
38
+ # Main class
39
+ "ACOLITE",
40
+ # Bathymetry
41
+ "multi_image",
42
+ # Water quality
43
+ "compute_water_bands",
44
+ "compute_water_mask",
45
+ "PRODUCTS",
46
+ # Modules
47
+ "utils",
48
+ "sensors",
49
+ ]
@@ -0,0 +1,4 @@
1
+ import ee
2
+
3
+ def multi_image(images: ee.ImageCollection, band: str = 'pSDB_green') -> ee.Image:
4
+ return images.qualityMosaic(band)
@@ -0,0 +1,567 @@
1
+ import numpy as np
2
+ import scipy.stats
3
+ import ee
4
+ import os
5
+
6
+
7
+ from typing import List, Tuple
8
+ from types import ModuleType
9
+ from functools import partial
10
+
11
+ from gee_acolite.utils.l1_convert import l1_to_rrs
12
+ from gee_acolite.utils.masks import mask_negative_reflectance
13
+ from gee_acolite.water_quality import compute_water_bands
14
+
15
+
16
+ class ACOLITE(object):
17
+ def __init__(self, acolite: ModuleType, settings: str | dict) -> None:
18
+ self.acolite = acolite
19
+ self.settings = self.__load_settings(settings)
20
+
21
+ def __load_settings(self, settings: str | dict):
22
+ # return self.acolite.acolite.settings.load(settings)
23
+ return self.acolite.acolite.settings.parse('S2A_MSI', settings = settings)
24
+
25
+ def correct(self, images : ee.ImageCollection) -> Tuple[ee.ImageCollection, dict]:
26
+ images = l1_to_rrs(images, self.settings.get('s2_target_res', 10))
27
+ images, settings = self.l1_to_l2(images.toList(images.size()), images.size().getInfo(), self.settings)
28
+
29
+ return images, settings
30
+
31
+
32
+ def l1_to_l2(self, images : ee.List, size : int, settings : dict) -> Tuple[ee.ImageCollection, dict]:
33
+ corrected_images = []
34
+
35
+ if settings['aerosol_correction'] == 'dark_spectrum':
36
+ for index in range(size):
37
+ rhos, bands, glint_params = self.dask_spectrum_fitting(ee.Image(images.get(index)), settings)
38
+
39
+ if settings['dsf_residual_glint_correction'] and settings['dsf_residual_glint_correction_method'] == 'alternative':
40
+ rhos = self.deglint_alternative(rhos,
41
+ bands,
42
+ glint_params,
43
+ glint_max=float(settings.get('glint_mask_rhos_threshold', 0.05)))
44
+
45
+ rhos = rhos.copyProperties(ee.Image(images.get(index)))
46
+ rhos = rhos.set('system:time_start', ee.Image(images.get(index)).get('system:time_start'))
47
+
48
+ corrected_images.append(rhos)
49
+
50
+ corrected_images = ee.ImageCollection.fromImages(corrected_images)
51
+
52
+ if settings['l2w_parameters']:
53
+ corrected_images = corrected_images.map(partial(compute_water_bands, settings=settings))
54
+
55
+ return corrected_images, settings
56
+
57
+ def dask_spectrum_fitting(self, image : ee.Image, settings : dict) -> Tuple[ee.Image, List[str], dict]:
58
+ if settings.get('ancillary_data', False):
59
+ settings = self.get_ancillary_data(image, settings)
60
+ else:
61
+ for data, default in [(key, f'{key}_default') for key in ['uoz', 'uwv', 'wind', 'pressure']]:
62
+ settings[data] = settings.get(default)
63
+
64
+ # Check if fixed AOT and LUT are provided
65
+ if settings.get('dsf_fixed_aot') is not None and settings.get('dsf_fixed_lut') is not None:
66
+ # Use fixed AOT and LUT (bypass dark spectrum fitting)
67
+ am, glint_ave, bands = self.compute_correction_with_fixed_aot(
68
+ image,
69
+ float(settings['dsf_fixed_aot']),
70
+ settings['dsf_fixed_lut'],
71
+ settings
72
+ )
73
+ else:
74
+ # Normal dark spectrum fitting workflow
75
+ am, glint_ave, bands = self.select_lut(image, settings)
76
+
77
+ rhos = self.compute_rhos(image, am)
78
+
79
+ return rhos, bands, glint_ave
80
+
81
+ def estimate_aot_per_lut(self, pdark: dict, lutd: dict, rsrd: dict, ttg: dict,
82
+ geometry: dict, settings: dict,
83
+ aot_skip_bands: List[str] = ['9', '10', '11', '12']) -> dict:
84
+ """
85
+ Estimate AOT for each LUT model using dark spectrum fitting.
86
+
87
+ Parameters:
88
+ - pdark: Dark spectrum values per band
89
+ - lutd: LUT dictionary with atmospheric models
90
+ - rsrd: Relative spectral response dictionary
91
+ - ttg: Gas transmittance dictionary
92
+ - geometry: Dict with 'raa', 'vza', 'sza', 'pressure'
93
+ - settings: Processing settings
94
+ - aot_skip_bands: Bands to skip in AOT estimation
95
+
96
+ Returns:
97
+ - results: Dict with AOT estimation results per LUT
98
+ """
99
+ results = {}
100
+ nbands = settings['dsf_nbands']
101
+
102
+ raa = geometry['raa']
103
+ vza = geometry['vza']
104
+ sza = geometry['sza']
105
+ pressure = geometry['pressure']
106
+
107
+ for lut in lutd:
108
+ taua_arr = None
109
+ rhot_arr = None
110
+ taua_bands = []
111
+
112
+ # Run through bands to estimate AOT
113
+ for b in rsrd['rsr_bands']:
114
+ if b in aot_skip_bands:
115
+ continue
116
+
117
+ # Get path reflectance from LUT for this band
118
+ ret = lutd[lut]['rgi'][b]((pressure, lutd[lut]['ipd']['romix'],
119
+ raa, vza, sza, lutd[lut]['meta']['tau']))
120
+
121
+ # Dark spectrum value for this band
122
+ rhot = np.asarray([pdark['B{}'.format(b)]])
123
+
124
+ # Gas correction
125
+ rhot /= ttg['tt_gas'][b]
126
+
127
+ # Interpolate AOT from observed rhot
128
+ taua = np.interp(rhot, ret, lutd[lut]['meta']['tau'])
129
+
130
+ if taua_arr is None:
131
+ rhot_arr = 1.0 * rhot
132
+ taua_arr = 1.0 * taua
133
+ else:
134
+ rhot_arr = np.vstack((rhot_arr, rhot))
135
+ taua_arr = np.vstack((taua_arr, taua))
136
+
137
+ taua_bands.append(b)
138
+
139
+ # Aggregate AOT from darkest bands
140
+ bidx = np.argsort(taua_arr[:, 0])
141
+ taua = np.nanmean(taua_arr[bidx[0: nbands], 0])
142
+ taua_std = np.nanstd(taua_arr[bidx[0: nbands], 0])
143
+ taua_cv = taua_std / taua
144
+
145
+ # Store results for this LUT
146
+ results[lut] = {
147
+ 'taua_bands': taua_bands,
148
+ 'taua_arr': taua_arr,
149
+ 'rhot_arr': rhot_arr,
150
+ 'taua': taua,
151
+ 'taua_std': taua_std,
152
+ 'taua_cv': taua_cv,
153
+ 'bidx': bidx
154
+ }
155
+
156
+ return results
157
+
158
+ def select_best_model(self, results: dict, lutd: dict, geometry: dict,
159
+ settings: dict) -> Tuple[str, float, str, float]:
160
+ """
161
+ Select best atmospheric model based on selection criterion.
162
+
163
+ Parameters:
164
+ - results: AOT estimation results per LUT from estimate_aot_per_lut
165
+ - lutd: LUT dictionary with atmospheric models
166
+ - geometry: Dict with 'raa', 'vza', 'sza', 'pressure'
167
+ - settings: Processing settings with selection method
168
+
169
+ Returns:
170
+ - sel_lut: Selected LUT name
171
+ - sel_aot: Selected AOT value
172
+ - sel_par: Selection parameter name
173
+ - sel_val: Selection parameter value
174
+ """
175
+ dsf_model_selection = settings.get('dsf_model_selection', 'min_drmsd')
176
+ dsf_nbands_fit = settings.get('dsf_nbands_fit', 2)
177
+
178
+ pressure = geometry['pressure']
179
+ raa = geometry['raa']
180
+ vza = geometry['vza']
181
+ sza = geometry['sza']
182
+
183
+ if dsf_model_selection == 'min_drmsd':
184
+ # RMSD validation: compare predictions vs observations
185
+ for lut in results:
186
+ fit_band_indices = results[lut]['bidx'][0:dsf_nbands_fit]
187
+
188
+ rmsd_values = []
189
+ for fit_idx in fit_band_indices:
190
+ band = results[lut]['taua_bands'][fit_idx]
191
+
192
+ # Observed rhot (gas corrected)
193
+ rhot_obs = results[lut]['rhot_arr'][fit_idx, 0]
194
+
195
+ # Modeled rhot using estimated AOT
196
+ rhot_model = lutd[lut]['rgi'][band]((pressure, lutd[lut]['ipd']['romix'],
197
+ raa, vza, sza, results[lut]['taua']))
198
+
199
+ # Squared difference
200
+ rmsd_values.append((rhot_obs - rhot_model) ** 2)
201
+
202
+ # Compute RMSD
203
+ results[lut]['rmsd'] = np.sqrt(np.mean(rmsd_values))
204
+
205
+ sel_par = 'rmsd'
206
+
207
+ elif dsf_model_selection == 'min_dtau':
208
+ # Minimum delta tau between two darkest bands
209
+ for lut in results:
210
+ if len(results[lut]['taua_arr']) >= 2:
211
+ dtau = np.abs(results[lut]['taua_arr'][results[lut]['bidx'][0], 0] -
212
+ results[lut]['taua_arr'][results[lut]['bidx'][1], 0])
213
+ results[lut]['dtau'] = dtau
214
+ else:
215
+ results[lut]['dtau'] = np.inf
216
+
217
+ sel_par = 'dtau'
218
+
219
+ else:
220
+ # Fallback: coefficient of variation
221
+ sel_par = 'taua_cv'
222
+
223
+ # Select LUT with minimum selection parameter
224
+ sel_lut = None
225
+ sel_aot = None
226
+ sel_val = np.inf
227
+
228
+ for lut in results:
229
+ if results[lut][sel_par] < sel_val:
230
+ sel_val = results[lut][sel_par] * 1.0
231
+ sel_aot = results[lut]['taua'] * 1.0
232
+ sel_lut = '{}'.format(lut)
233
+
234
+ return sel_lut, sel_aot, sel_par, sel_val
235
+
236
+ def select_lut(self, image : ee.Image, settings : dict, aot_skip_bands : List[str] = ['9', '10', '11', '12']) -> Tuple[dict, dict, List[str]]:
237
+ """
238
+ Main function for LUT selection and atmospheric correction parameter computation.
239
+
240
+ This function orchestrates:
241
+ 1. Dark spectrum extraction
242
+ 2. AOT estimation per LUT model
243
+ 3. Model selection based on validation criterion
244
+ 4. Computation of atmospheric correction parameters
245
+
246
+ Parameters:
247
+ - image: Input ee.Image
248
+ - settings: Processing settings
249
+ - aot_skip_bands: Bands to skip in AOT estimation
250
+
251
+ Returns:
252
+ - am: Atmospheric correction parameters (romix, dutott, astot, tg)
253
+ - glint_ave: Glint correction parameters per band
254
+ - bands: List of band names
255
+ """
256
+ # Extract dark spectrum
257
+ pdark = self.compute_pdark(image, settings)
258
+
259
+ # Get geometry and atmospheric parameters
260
+ raa = image.get('raa').getInfo()
261
+ vza = image.get('vza').getInfo()
262
+ sza = image.get('sza').getInfo()
263
+
264
+ geometry = {
265
+ 'raa': raa,
266
+ 'vza': vza,
267
+ 'sza': sza,
268
+ 'pressure': settings['pressure']
269
+ }
270
+
271
+ # Get sensor and load LUTs
272
+ sensor = 'S2A_MSI' if 'S2A' in image.get('PRODUCT_ID').getInfo() else 'S2B_MSI'
273
+
274
+ lutd = self.acolite.aerlut.import_luts(sensor=sensor)
275
+ rsrd = self.acolite.shared.rsr_dict(sensor=sensor)[sensor]
276
+ ttg = self.acolite.ac.gas_transmittance(sza, vza,
277
+ pressure=settings['pressure'],
278
+ uoz=settings['uoz'],
279
+ uwv=settings['uwv'],
280
+ rsr=rsrd['rsr'])
281
+ luti = self.acolite.aerlut.import_rsky_luts(models=[1,2],
282
+ lutbase='ACOLITE-RSKY-202102-82W',
283
+ sensor=sensor)
284
+
285
+ # Step 1: Estimate AOT for each LUT model
286
+ results = self.estimate_aot_per_lut(pdark, lutd, rsrd, ttg, geometry,
287
+ settings, aot_skip_bands)
288
+
289
+ # Step 2: Select best model
290
+ sel_lut, sel_aot, sel_par, sel_val = self.select_best_model(results, lutd,
291
+ geometry, settings)
292
+
293
+ print(f'Selected model {sel_lut}: AOT={sel_aot:.3f}, {sel_par}={sel_val:.4e}')
294
+ print(f' Geometry: SZA={geometry["sza"]:.2f}°, VZA={geometry["vza"]:.2f}°, RAA={geometry["raa"]:.2f}°')
295
+ print(f' Pressure: {geometry["pressure"]:.2f} hPa')
296
+
297
+ # Step 3: Compute atmospheric correction parameters for selected model
298
+ am = {}
299
+ for par in lutd[sel_lut]['ipd']:
300
+ am[par] = {b: lutd[sel_lut]['rgi'][b]((geometry['pressure'],
301
+ lutd[sel_lut]['ipd'][par],
302
+ raa, vza, sza, sel_aot))
303
+ for b in rsrd['rsr_bands']}
304
+ am.update({'tg': ttg['tt_gas']})
305
+
306
+ # Step 4: Compute glint correction parameters
307
+ model = int(sel_lut[-1])
308
+ glint_wind = 20
309
+ glint_bands = ['11', '12']
310
+
311
+ glint_dict = {b: luti[model]['rgi'][b]((raa, vza, sza, glint_wind, sel_aot))
312
+ for b in rsrd['rsr_bands']}
313
+ glint_ave = {b: glint_dict[b] / ((glint_dict[glint_bands[0]] +
314
+ glint_dict[glint_bands[1]]) / 2)
315
+ for b in glint_dict}
316
+
317
+ return am, glint_ave, rsrd['rsr_bands']
318
+
319
+ def compute_correction_with_fixed_aot(self, image: ee.Image, aot: float, lut_name: str,
320
+ settings: dict) -> Tuple[dict, dict, List[str]]:
321
+ """
322
+ Compute atmospheric correction parameters using fixed AOT and LUT.
323
+
324
+ This function bypasses dark spectrum fitting and directly computes
325
+ correction parameters for a specified AOT and atmospheric model.
326
+
327
+ Parameters:
328
+ - image: Input ee.Image (TOA reflectance)
329
+ - aot: Fixed aerosol optical thickness at 550nm
330
+ - lut_name: LUT name (e.g., 'ACOLITE-LUT-202110-MOD2')
331
+ - settings: Processing settings
332
+
333
+ Returns:
334
+ - am: Atmospheric correction parameters (romix, dutott, astot, tg)
335
+ - glint_ave: Glint correction parameters per band
336
+ - bands: List of band names
337
+ """
338
+ # Extract geometry from image
339
+ raa = image.get('raa').getInfo()
340
+ vza = image.get('vza').getInfo()
341
+ sza = image.get('sza').getInfo()
342
+
343
+ geometry = {
344
+ 'raa': raa,
345
+ 'vza': vza,
346
+ 'sza': sza,
347
+ 'pressure': settings['pressure']
348
+ }
349
+
350
+ # Get sensor
351
+ sensor = 'S2A_MSI' if 'S2A' in image.get('PRODUCT_ID').getInfo() else 'S2B_MSI'
352
+
353
+ # Load specified LUT
354
+ lutd = self.acolite.aerlut.import_luts(sensor=sensor)
355
+
356
+ # Verify LUT exists
357
+ if lut_name not in lutd:
358
+ available_luts = list(lutd.keys())
359
+ raise ValueError(f"LUT '{lut_name}' not found. Available LUTs: {available_luts}")
360
+
361
+ # Get RSR data
362
+ rsrd = self.acolite.shared.rsr_dict(sensor=sensor)[sensor]
363
+ pressure = settings['pressure']
364
+ uoz = settings['uoz']
365
+ uwv = settings['uwv']
366
+
367
+ # Compute gas transmittance
368
+ ttg = self.acolite.ac.gas_transmittance(sza, vza,
369
+ pressure=pressure,
370
+ uoz=uoz,
371
+ uwv=uwv,
372
+ rsr=rsrd['rsr'])
373
+
374
+ # Load sky glint LUTs
375
+ luti = self.acolite.aerlut.import_rsky_luts(models=[1,2],
376
+ lutbase='ACOLITE-RSKY-202102-82W',
377
+ sensor=sensor)
378
+
379
+ print(f'Using fixed AOT={aot:.3f} with LUT={lut_name}')
380
+ print(f' Geometry: SZA={geometry["sza"]:.2f}°, VZA={geometry["vza"]:.2f}°, RAA={geometry["raa"]:.2f}°')
381
+ print(f' Pressure: {geometry["pressure"]:.2f} hPa')
382
+
383
+ # Compute atmospheric correction parameters for specified LUT and AOT
384
+ am = {}
385
+ for par in lutd[lut_name]['ipd']:
386
+ am[par] = {b: lutd[lut_name]['rgi'][b]((geometry['pressure'],
387
+ lutd[lut_name]['ipd'][par],
388
+ raa, vza, sza, aot))
389
+ for b in rsrd['rsr_bands']}
390
+ am.update({'tg': ttg['tt_gas']})
391
+
392
+ # Compute glint correction parameters
393
+ model = int(lut_name[-1]) # Extract model number from LUT name
394
+ glint_wind = 20
395
+ glint_bands = ['11', '12']
396
+
397
+ glint_dict = {b: luti[model]['rgi'][b]((raa, vza, sza, glint_wind, aot))
398
+ for b in rsrd['rsr_bands']}
399
+ glint_ave = {b: glint_dict[b] / ((glint_dict[glint_bands[0]] +
400
+ glint_dict[glint_bands[1]]) / 2)
401
+ for b in glint_dict}
402
+
403
+ return am, glint_ave, rsrd['rsr_bands']
404
+
405
+ def compute_pdark(self, image : ee.Image, settings : dict):
406
+ obands_rhot = ['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B8A', 'B9', 'B10', 'B11', 'B12']
407
+
408
+ image_to_reduce = image.updateMask(image.gt(0))
409
+
410
+ pdark_by_band = {}
411
+ indexes = np.arange(settings['dsf_intercept_pixels'])
412
+
413
+ for band in obands_rhot:
414
+ if settings.get('dsf_spectrum_option', 'darkest') == 'darkest':
415
+ band_data = image_to_reduce.select(band).reduceRegion(reducer = ee.Reducer.percentile([0]),
416
+ bestEffort = True, scale = 10, maxPixels = 1e8)
417
+
418
+ if band_data:
419
+ pdark_by_band[band] = band_data.getInfo()[band]
420
+ else:
421
+ pdark_by_band[band] = 0.0
422
+
423
+ elif settings.get('dsf_spectrum_option', 'darkest') == 'percentile':
424
+ band_data = image_to_reduce.select(band).reduceRegion(reducer = ee.Reducer.percentile([settings['dsf_percentile']]),
425
+ bestEffort = True, scale = 10, maxPixels = 1e8)
426
+
427
+ if band_data:
428
+ pdark_by_band[band] = band_data.getInfo()[band]
429
+ else:
430
+ pdark_by_band[band] = 0.0
431
+ elif settings.get('dsf_spectrum_option', 'darkest') == 'intercept':
432
+ data = image_to_reduce.select(band).reduceRegion(reducer = ee.Reducer.toList(), scale = 30, bestEffort = True, maxPixels = 1e8)
433
+ band_data = data.get(band)
434
+
435
+ if band_data:
436
+ values = ee.List(band_data).sort().slice(0, settings['dsf_intercept_pixels']).getInfo()
437
+ slope, intercept, r, p, se = scipy.stats.linregress(indexes, values)
438
+ pdark_by_band[band] = intercept
439
+ else:
440
+ pdark_by_band[band] = 0.0
441
+
442
+ return pdark_by_band
443
+
444
+ def compute_rhos(self, image : ee.Image, am : dict) -> ee.Image:
445
+ l2r_rrs = ee.Image().select([])
446
+
447
+ romix = am['romix']
448
+ dutott = am['dutott']
449
+ astot = am['astot']
450
+ tgas = am['tg']
451
+
452
+ for band in romix:
453
+ band_name = 'B' + band
454
+ rhot_noatm = ee.Image().expression('(data / tg) - ppath', {'data' : image.select(band_name), 'tg' : tgas[band], 'ppath' : float(romix[band])}).rename(band_name)
455
+ rhos = ee.Image().expression('(data) / (tdu + sa * data)', {'data' : rhot_noatm.select(band_name), 'tdu' : float(dutott[band]), 'sa' : float(astot[band])}).rename(band_name)
456
+ rhos = mask_negative_reflectance(rhos, band_name)
457
+ l2r_rrs = l2r_rrs.addBands(image.select(band_name).rename(f'rhot_{band_name}').toFloat())
458
+ l2r_rrs = l2r_rrs.addBands(rhos.rename(f'rhos_{band_name}').toFloat())
459
+
460
+ return l2r_rrs
461
+
462
+
463
+ def get_ancillary_data(self, image: ee.Image, settings : dict) -> dict:
464
+ settings = self.prepare_earthdata_credentials(settings)
465
+ iso_date, lon, lat = self.prepare_query(image)
466
+
467
+ anc = self.acolite.ac.ancillary.get(iso_date, lon, lat)
468
+
469
+ for data, default in [('uoz', 'uoz_default'), ('uwv', 'uwv_default'),
470
+ ('wind', 'wind_default'), ('pressure', 'pressure_default')]:
471
+ settings[data] = anc.get(data, settings.get(default))
472
+
473
+ return settings
474
+
475
+ def prepare_query(self, image: ee.Image):
476
+ coords = image.geometry().centroid().coordinates().getInfo()
477
+ iso_date = ee.Date(image.get('system:time_start')).format('YYYY-MM-dd HH:mm:ss').getInfo()
478
+
479
+ lon, lat = coords
480
+ return iso_date,lon,lat
481
+
482
+ def prepare_earthdata_credentials(self, settings: dict) -> dict:
483
+ for k in ['EARTHDATA_u', 'EARTHDATA_p']:
484
+ kv = settings[k] if k in settings else self.acolite.ac.config[k]
485
+ if len(kv) == 0: continue
486
+ os.environ[k] = kv
487
+
488
+ return settings
489
+
490
+
491
+ def deglint_alternative(self, image : ee.Image, bands : List[str],
492
+ glint_ave : dict, glint_min : float = 0, glint_max : float = 0.08) -> ee.Image:
493
+ """
494
+ Alternative glint correction method based on ACOLITE's acolite_l2r.py implementation.
495
+
496
+ This method:
497
+ 1. Uses reference SWIR bands (B11, B12) to estimate observed glint
498
+ 2. Computes average modeled surface reflectance (glint_ave) for reference bands
499
+ 3. For each band, scales the observed glint by the ratio of:
500
+ - Modeled surface reflectance for current band
501
+ - Average modeled surface reflectance of reference bands
502
+ 4. Subtracts the scaled glint from each band's rhos
503
+
504
+ Parameters:
505
+ - image: Image with rhos bands
506
+ - bands: List of band numbers (as strings)
507
+ - glint_ave: Dict with modeled surface reflectance ratio per band
508
+ - glint_min: Minimum threshold for glint mask (default: 0)
509
+ - glint_max: Maximum threshold for glint mask (glint_mask_rhos_threshold)
510
+ """
511
+
512
+ # Start with a copy of the original image to preserve all bands
513
+ deglinted = image
514
+
515
+ # Reference bands for glint estimation (SWIR bands: B11=1610nm, B12=2190nm)
516
+ # These bands are sensitive to sun glint but have minimal water-leaving radiance
517
+ glint_ref_bands = ['rhos_B11', 'rhos_B12']
518
+
519
+ # Step 1: Compute observed glint reference (gc_ref_mean in ACOLITE)
520
+ # Average of reference bands gives the observed glint signal
521
+ rhos_b11 = image.select('rhos_B11')
522
+ rhos_b12 = image.select('rhos_B12')
523
+ gc_ref_mean = (rhos_b11.add(rhos_b12)).divide(2).rename('gc_ref_mean')
524
+
525
+ # Step 2: Compute average modeled surface reflectance for reference bands (gc_sur_mean in ACOLITE)
526
+ # This is the average of glint_ave values for B11 and B12
527
+ gc_sur_mean = (glint_ave['11'] + glint_ave['12']) / 2.0
528
+
529
+ # Step 3: Create glint mask (gc_sub in ACOLITE)
530
+ # Only apply correction where observed glint is below threshold and positive
531
+ glint_mask = gc_ref_mean.gt(glint_min).And(gc_ref_mean.lt(glint_max))
532
+
533
+ # Step 4: Apply band-specific glint correction
534
+ for band in bands:
535
+ band_name = 'rhos_B' + band
536
+
537
+ # Skip if modeled surface reflectance is invalid
538
+ if np.isinf(glint_ave[band]) or np.isnan(glint_ave[band]):
539
+ continue
540
+
541
+ # Compute current band glint (cur_rhog in ACOLITE):
542
+ # cur_rhog = gc_ref_mean * (surf_current_band / gc_sur_mean)
543
+ #
544
+ # This scales the observed glint by the ratio of:
545
+ # - Modeled surface reflectance for current band (glint_ave[band])
546
+ # - Average modeled surface reflectance of reference bands (gc_sur_mean)
547
+ #
548
+ # The ratio accounts for spectral variation in surface reflectance
549
+ glint_ratio = glint_ave[band] / gc_sur_mean
550
+ cur_rhog = gc_ref_mean.multiply(glint_ratio).rename('cur_rhog')
551
+
552
+ # Mask the glint correction to valid pixels
553
+ cur_rhog = cur_rhog.updateMask(glint_mask)
554
+
555
+ # Apply glint correction: rhos_corrected = rhos_original - glint
556
+ rhos_corrected = image.select(band_name).subtract(cur_rhog)
557
+
558
+ # Mask negative values (can occur in dark pixels or over-correction)
559
+ rhos_corrected = mask_negative_reflectance(rhos_corrected, band_name)
560
+
561
+ # Replace the band in the output image (overwrite=True)
562
+ deglinted = deglinted.addBands(rhos_corrected, overwrite=True)
563
+
564
+ # Optional: Write glint mean for visualization/validation
565
+ deglinted = deglinted.addBands(gc_ref_mean.rename('glint_mean'))
566
+
567
+ return deglinted
@@ -0,0 +1,10 @@
1
+ """
2
+ Sensor-specific configurations and constants
3
+ """
4
+
5
+ from gee_acolite.sensors.sentinel2 import SENTINEL2_BANDS, BAND_BY_SCALE
6
+
7
+ __all__ = [
8
+ "SENTINEL2_BANDS",
9
+ "BAND_BY_SCALE",
10
+ ]
@@ -0,0 +1,6 @@
1
+ SENTINEL2_BANDS = ['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B8A', 'B9', 'B10', 'B11', 'B12']
2
+ BAND_BY_SCALE = {
3
+ 10 : 'B2',
4
+ 20 : 'B5',
5
+ 60 : 'B1',
6
+ }
@@ -0,0 +1,44 @@
1
+ """
2
+ Utility functions for GEE ACOLITE processing
3
+ """
4
+
5
+ from gee_acolite.utils.l1_convert import l1_to_rrs, DN_to_rrs, resample
6
+ from gee_acolite.utils.masks import (
7
+ mask_negative_reflectance,
8
+ toa_mask,
9
+ cirrus_mask,
10
+ non_water,
11
+ add_cloud_bands,
12
+ add_shadow_bands,
13
+ add_cld_shdw_mask,
14
+ cld_shdw_mask,
15
+ )
16
+ from gee_acolite.utils.search import (
17
+ search,
18
+ search_list,
19
+ search_with_cloud_proba,
20
+ search_list_with_cloud_proba,
21
+ join_s2_with_cloud_prob,
22
+ )
23
+
24
+ __all__ = [
25
+ # L1 conversion
26
+ "l1_to_rrs",
27
+ "DN_to_rrs",
28
+ "resample",
29
+ # Masks
30
+ "mask_negative_reflectance",
31
+ "toa_mask",
32
+ "cirrus_mask",
33
+ "non_water",
34
+ "add_cloud_bands",
35
+ "add_shadow_bands",
36
+ "add_cld_shdw_mask",
37
+ "cld_shdw_mask",
38
+ # Search
39
+ "search",
40
+ "search_list",
41
+ "search_with_cloud_proba",
42
+ "search_list_with_cloud_proba",
43
+ "join_s2_with_cloud_prob",
44
+ ]
@@ -0,0 +1,47 @@
1
+ import ee
2
+ from functools import partial
3
+
4
+ from gee_acolite.sensors.sentinel2 import SENTINEL2_BANDS, BAND_BY_SCALE
5
+
6
+
7
+ def l1_to_rrs(images : ee.ImageCollection, scale: int) -> ee.ImageCollection:
8
+ resample_scale = partial(resample, band = BAND_BY_SCALE.get(scale, 'B2'))
9
+ return images.select(SENTINEL2_BANDS).map(DN_to_rrs).map(resample_scale)
10
+
11
+ def DN_to_rrs(image : ee.Image) -> ee.Image:
12
+ rrs = image.divide(10_000)
13
+
14
+ rrs = rrs.set('sza', image.get('MEAN_SOLAR_ZENITH_ANGLE'))
15
+ rrs = rrs.set('saa', image.get('MEAN_SOLAR_AZIMUTH_ANGLE'))
16
+ rrs = rrs.set('vza', get_mean_band_angle(image, 'ZENITH'))
17
+ rrs = rrs.set('vaa', get_mean_band_angle(image, 'AZIMUTH'))
18
+
19
+ raa = ee.Number(rrs.get('saa')).subtract(rrs.get('vaa')).abs()
20
+
21
+ raa = ee.Algorithms.If(raa.gt(180), raa.subtract(360).abs(), raa)
22
+ rrs = rrs.set('raa', raa)
23
+ rrs = rrs.set('PRODUCT_ID', ee.String(ee.String(image.get('PRODUCT_ID')).split('L1C').get(0)))
24
+
25
+ rrs = rrs.set('system:time_start', image.get('system:time_start'))
26
+
27
+ rrs = rrs.copyProperties(image)
28
+ rrs = rrs.set('system:time_start', image.get('system:time_start'))
29
+
30
+ return rrs
31
+
32
+ def get_mean_band_angle(image : ee.Image, angle_name : str) -> ee.Number:
33
+ bands = image.bandNames()
34
+
35
+ for index in range(13):
36
+ bands = bands.set(index, ee.String('MEAN_INCIDENCE_' + angle_name + '_ANGLE_').cat(bands.get(index)))
37
+
38
+ angle = ee.Number(0)
39
+ for index in range(13):
40
+ angle = angle.add(ee.Number(image.get(bands.get(index))))
41
+ else:
42
+ angle = angle.divide(image.bandNames().length())
43
+
44
+ return angle
45
+
46
+ def resample(image : ee.Image, band: str) -> ee.Image:
47
+ return image.resample('bilinear').reproject(image.select(band).projection())
@@ -0,0 +1,113 @@
1
+ import ee
2
+
3
+
4
+ def mask_negative_reflectance(image : ee.Image, band : str) -> ee.Image:
5
+ return image.updateMask(image.select(band).gte(0)).rename(band)
6
+
7
+ def toa_mask(image : ee.Image, band : str = 'rhot_B11', threshold : float = 0.03):
8
+ return image.select(band).lt(threshold)
9
+
10
+ def cirrus_mask(image : ee.Image, band : str = 'rhot_B10', threshold : float = 0.005):
11
+ return image.select(band).lt(threshold)
12
+
13
+ def non_water(image : ee.Image, band : str = 'rhot_B11', threshold : float = 0.05) -> ee.Image:
14
+ return image.select(band).lt(threshold)
15
+
16
+
17
+ def add_cloud_bands(img : ee.Image, cloud_prob_threshold : float = 50) -> ee.Image:
18
+ """
19
+ Añade banda de máscara de nubes a la imagen
20
+
21
+ Args:
22
+ img: Imagen con banda 'probability' de Cloud Probability
23
+ cloud_prob_threshold: Umbral de probabilidad de nube (0-100)
24
+
25
+ Returns:
26
+ Imagen con banda 'clouds' añadida
27
+ """
28
+ # Obtener probabilidad de nubes (ya está como banda)
29
+ cld_prb = ee.Image(img.get('cloud_prob')).select('probability')
30
+
31
+ # Máscara de nubes basada en umbral de probabilidad
32
+ is_cloud = cld_prb.gt(cloud_prob_threshold).rename('clouds')
33
+
34
+ return img.addBands(is_cloud)
35
+
36
+
37
+ def add_shadow_bands(img : ee.Image, nir_dark_threshold : float = 0.15,
38
+ cloud_proj_distance : float = 1) -> ee.Image:
39
+ """
40
+ Añade bandas de máscara de sombras a la imagen L1C
41
+
42
+ Args:
43
+ img: Imagen con banda 'clouds' ya añadida
44
+ nir_dark_threshold: Umbral para píxeles oscuros en NIR (0-1)
45
+ cloud_proj_distance: Distancia de proyección de sombras en km
46
+
47
+ Returns:
48
+ Imagen con bandas 'dark_pixels', 'cloud_transform' y 'shadows' añadidas
49
+ """
50
+ # Píxeles oscuros en NIR (posibles sombras) - para L1C usar B8 directamente
51
+ dark_pixels = img.select('rhot_B8').lt(nir_dark_threshold * 10000).rename('dark_pixels')
52
+
53
+ # Dirección de proyección de sombras
54
+ shadow_azimuth = ee.Number(90).subtract(ee.Number(img.get('MEAN_SOLAR_AZIMUTH_ANGLE')))
55
+
56
+ # Proyectar sombras desde nubes
57
+ cld_proj = img.select('clouds').directionalDistanceTransform(shadow_azimuth, cloud_proj_distance * 10) \
58
+ .reproject(**{'crs': img.select(0).projection(), 'scale': 100}) \
59
+ .select('distance') \
60
+ .mask() \
61
+ .rename('cloud_transform')
62
+
63
+ # Identificar sombras como intersección de píxeles oscuros y proyección de nubes
64
+ shadows = cld_proj.multiply(dark_pixels).rename('shadows')
65
+
66
+ return img.addBands(ee.Image([dark_pixels, cld_proj, shadows]))
67
+
68
+
69
+ def add_cld_shdw_mask(img : ee.Image, cloud_prob_threshold : float = 50,
70
+ nir_dark_threshold : float = 0.15, cloud_proj_distance : float = 1,
71
+ buffer : int = 50) -> ee.Image:
72
+ """
73
+ Añade máscara combinada de nubes y sombras con buffer
74
+
75
+ Args:
76
+ img: Imagen con banda 'probability' de Cloud Probability
77
+ cloud_prob_threshold: Umbral de probabilidad de nube (0-100)
78
+ nir_dark_threshold: Umbral para píxeles oscuros en NIR (0-1)
79
+ cloud_proj_distance: Distancia de proyección de sombras en km
80
+ buffer: Buffer alrededor de nubes en metros
81
+
82
+ Returns:
83
+ Imagen con banda 'cloudmask' añadida
84
+ """
85
+ # Añadir bandas de nubes
86
+ img_cloud = add_cloud_bands(img, cloud_prob_threshold)
87
+
88
+ # Añadir bandas de sombras
89
+ img_cloud_shadow = add_shadow_bands(img_cloud, nir_dark_threshold, cloud_proj_distance)
90
+
91
+ # Combinar máscaras
92
+ is_cld_shdw = img_cloud_shadow.select('clouds').add(img_cloud_shadow.select('shadows')).gt(0)
93
+
94
+ # Aplicar buffer
95
+ is_cld_shdw = is_cld_shdw.focal_min(2).focal_max(buffer * 2 / 20) \
96
+ .reproject(**{'crs': img.select([0]).projection(), 'scale': 20}) \
97
+ .rename('cloudmask')
98
+
99
+ return img_cloud_shadow.addBands(is_cld_shdw)
100
+
101
+
102
+ def cld_shdw_mask(img : ee.Image) -> ee.Image:
103
+ """
104
+ Aplica la máscara de nubes y sombras a la imagen
105
+
106
+ Args:
107
+ img: Imagen con banda 'cloudmask' ya añadida
108
+
109
+ Returns:
110
+ Imagen enmascarada con solo las bandas B* (bandas Sentinel-2)
111
+ """
112
+ # Obtener máscara
113
+ return img.select('cloudmask').Not()
@@ -0,0 +1,79 @@
1
+ import ee
2
+
3
+ from typing import Optional
4
+
5
+
6
+ def search(roi : ee.Geometry, start : str, end : str,
7
+ collection : str = 'S2_HARMONIZED', tile : Optional[str] = None) -> ee.ImageCollection:
8
+ if tile is None:
9
+ sentinel2_l1 = ee.ImageCollection(f'COPERNICUS/{collection}').filterBounds(roi).filterDate(start, end)
10
+ else:
11
+ sentinel2_l1 = ee.ImageCollection(f'COPERNICUS/{collection}').filterBounds(roi).filterDate(start, end).filter(ee.Filter.stringContains('PRODUCT_ID', tile))
12
+
13
+ return sentinel2_l1
14
+
15
+ def search_list(roi : ee.Geometry, starts : list[str], ends : list[str],
16
+ collection : str = 'S2_HARMONIZED', tile : Optional[str] = None) -> ee.ImageCollection:
17
+
18
+ return ee.ImageCollection.fromImages([search(roi, start, end, collection, tile).first() for start, end in zip(starts, ends)])
19
+
20
+
21
+ def search_with_cloud_proba(roi : ee.Geometry, start : str, end : str,
22
+ collection : str = 'S2_HARMONIZED', tile : Optional[str] = None) -> ee.ImageCollection:
23
+ """
24
+ Busca imágenes Sentinel-2 y une con Cloud Probability
25
+
26
+ Args:
27
+ roi: Región de interés
28
+ start: Fecha de inicio (formato 'YYYY-MM-DD')
29
+ end: Fecha de fin (formato 'YYYY-MM-DD')
30
+ collection: Nombre de la colección de Sentinel-2 (default: 'S2_HARMONIZED')
31
+ tile: Identificador del tile (opcional)
32
+
33
+ Returns:
34
+ Colección de imágenes con Cloud Probability unida
35
+ """
36
+ s2_collection = search(roi, start, end, collection, tile)
37
+ return join_s2_with_cloud_prob(s2_collection)
38
+
39
+
40
+ def search_list_with_cloud_proba(roi : ee.Geometry, starts : list[str], ends : list[str],
41
+ collection : str = 'S2_HARMONIZED', tile : Optional[str] = None) -> ee.ImageCollection:
42
+ """
43
+ Busca una lista de imágenes Sentinel-2 y une con Cloud Probability
44
+
45
+ Args:
46
+ roi: Región de interés
47
+ starts: Lista de fechas de inicio (formato 'YYYY-MM-DD')
48
+ ends: Lista de fechas de fin (formato 'YYYY-MM-DD')
49
+ collection: Nombre de la colección de Sentinel-2 (default: 'S2_HARMONIZED')
50
+ tile: Identificador del tile (opcional)
51
+
52
+ Returns:
53
+ Colección de imágenes con Cloud Probability unida
54
+ """
55
+ s2_collection = search_list(roi, starts, ends, collection, tile)
56
+ return join_s2_with_cloud_prob(s2_collection)
57
+
58
+
59
+ def join_s2_with_cloud_prob(s2_collection : ee.ImageCollection) -> ee.ImageCollection:
60
+ """
61
+ Une la colección Sentinel-2 con Cloud Probability
62
+
63
+ Args:
64
+ s2_collection: Colección de imágenes Sentinel-2 L1C
65
+ roi: Región de interés
66
+
67
+ Returns:
68
+ Colección de imágenes con Cloud Probability unida como propiedad 'cloud_prob'
69
+ """
70
+
71
+ def add_cloud_prob(img):
72
+ img_index = img.get('system:index')
73
+ cloud = ee.ImageCollection('COPERNICUS/S2_CLOUD_PROBABILITY') \
74
+ .filter(ee.Filter.equals('system:index', img_index)) \
75
+ .first()
76
+
77
+ return img.set('cloud_prob', cloud)
78
+
79
+ return s2_collection.map(add_cloud_prob)
@@ -0,0 +1,140 @@
1
+ import numpy as np
2
+ import ee
3
+
4
+ import gee_acolite.utils.masks as masks
5
+ from gee_acolite.sensors.sentinel2 import SENTINEL2_BANDS
6
+
7
+
8
+ def compute_water_mask(image: ee.Image, settings: dict) -> ee.Image:
9
+ mask = masks.non_water(image,
10
+ threshold = settings.get('l2w_mask_threshold', 0.05))
11
+ mask = mask.updateMask(masks.cirrus_mask(image,
12
+ threshold = settings.get('l2w_mask_cirrus_threshold', 0.005)))
13
+
14
+ for band in SENTINEL2_BANDS:
15
+ mask = mask.updateMask( masks.toa_mask(image,
16
+ f'rhot_{band}',
17
+ settings.get('l2w_mask_high_toa_threshold', 0.3)) )
18
+
19
+ if settings.get('s2_cloud_proba', False):
20
+ CLOUD_PROB_THRESHOLD = int(settings.get('s2_cloud_proba__cloud_threshold', 50))
21
+ NIR_DARK_THRESHOLD = float(settings.get('s2_cloud_proba__nir_dark_threshold', 0.15))
22
+ CLOUD_PROJ_DISTANCE = int(settings.get('s2_cloud_proba__cloud_proj_distance', 10))
23
+ BUFFER = int(settings.get('s2_cloud_proba__buffer', 50))
24
+ mask = mask.updateMask(masks.cld_shdw_mask(masks.add_cld_shdw_mask(image,
25
+ CLOUD_PROB_THRESHOLD,
26
+ NIR_DARK_THRESHOLD,
27
+ CLOUD_PROJ_DISTANCE,
28
+ BUFFER)))
29
+
30
+ return mask
31
+
32
+ def compute_water_bands(image: ee.Image, settings: dict) -> ee.Image:
33
+ mask = compute_water_mask(image, settings)
34
+
35
+ for product in settings['l2w_parameters']:
36
+ new_band = PRODUCTS[product](image)
37
+
38
+ if isinstance(new_band, list):
39
+ new_band = [band.updateMask(mask) for band in new_band]
40
+ else:
41
+ new_band = new_band.updateMask(mask)
42
+
43
+ image = image.addBands(new_band)
44
+
45
+ return image
46
+
47
+
48
+ def spm_nechad2016_665(image : ee.Image) -> ee.Image:
49
+ return image.expression('(A * red) / (1 - (red / C))', {'A' : 342.10,
50
+ 'C' : 0.19563,
51
+ 'red' : image.select('rhos_B4') }).rename('SPM_Nechad2016_665').toFloat()
52
+
53
+ def spm_nechad2016_704(image : ee.Image) -> ee.Image:
54
+ return image.expression('(A * red) / (1 - (red / C))', {'A' : 444.36,
55
+ 'C' : 0.18753,
56
+ 'red' : image.select('rhos_B5') }).rename('SPM_Nechad2016_704').toFloat()
57
+
58
+ def spm_nechad2016_740(image : ee.Image) -> ee.Image:
59
+ return image.expression('(A * red) / (1 - (red / C))', {'A' : 1517.00,
60
+ 'C' : 0.19736,
61
+ 'red' : image.select('rhos_B6') }).rename('SPM_Nechad2016_739').toFloat()
62
+
63
+ def tur_nechad2016_665(image : ee.Image) -> ee.Image:
64
+ return image.expression('(A * red) / (1 - (red / C))', {'A' : 366.14,
65
+ 'C' : 0.19563,
66
+ 'red' : image.select('rhos_B4') }).rename('TUR_Nechad2016_665').toFloat()
67
+
68
+ def tur_nechad2016_704(image : ee.Image) -> ee.Image:
69
+ return image.expression('(A * red) / (1 - (red / C))', {'A' : 439.09,
70
+ 'C' : 0.18753,
71
+ 'red' : image.select('rhos_B5') }).rename('TUR_Nechad2016_704').toFloat()
72
+
73
+ def tur_nechad2016_740(image : ee.Image) -> ee.Image:
74
+ return image.expression('(A * red) / (1 - (red / C))', {'A' : 1590.66,
75
+ 'C' : 0.19736,
76
+ 'red' : image.select('rhos_B6') }).rename('TUR_Nechad2016_739').toFloat()
77
+
78
+ def chl_oc2(image : ee.Image) -> ee.Image:
79
+ A, B, C, D, E = 0.1977,-1.8117,1.9743,-2.5635,-0.7218 # TODO : interpolar B1 a las dimensiones de B2
80
+ x = image.expression('log( x / y )', {'x' : image.select('rhos_B2'),
81
+ 'y' : image.select('rhos_B3') }).rename('x')
82
+ return image.expression('10 ** (A + B * x + C * (x**2) + D * (x**3) + E * (x**4) )', {'A' : A,
83
+ 'B' : B,
84
+ 'C' : C,
85
+ 'D' : D,
86
+ 'E' : E,
87
+ 'x' : x }).rename('chl_oc2').toFloat()
88
+
89
+ def chl_oc3(image : ee.Image) -> ee.Image:
90
+ A, B, C, D, E = 0.2412,-2.0546,1.1776,-0.5538,-0.4570 # TODO : interpolar B1 a las dimensiones de B2
91
+ x = image.expression('log( x / y )', {'x' : image.select('rhos_B2').max(image.select('rhos_B1')).rename('x'),
92
+ 'y' : image.select('rhos_B3') }).rename('x')
93
+ return image.expression('10 ** (A + B * x + C * (x**2) + D * (x**3) + E * (x**4) )', {'A' : A,
94
+ 'B' : B,
95
+ 'C' : C,
96
+ 'D' : D,
97
+ 'E' : E,
98
+ 'x' : x }).rename('chl_oc3').toFloat()
99
+
100
+ def chl_re_mishra(image : ee.Image) -> ee.Image:
101
+ a, b, c = 14.039, 86.11, 194.325
102
+ ndci = image.normalizedDifference(['rhos_B5', 'rhos_B4']).rename('ndci')
103
+ return image.expression('a + b * ndci + c * ndci * ndci', {'a' : a,
104
+ 'b' : b,
105
+ 'c' : c,
106
+ 'ndci' : ndci }).rename('chl_re_mishra').toFloat()
107
+
108
+ def ndwi(image : ee.Image) -> ee.Image:
109
+ return image.normalizedDifference(['Rrs_B3', 'Rrs_B8']).rename('ndwi').toFloat()
110
+
111
+ def pSDB_red(image : ee.Image) -> ee.Image:
112
+ return image.expression('log(n * pi * blue) / log(n * pi * red)', {'n' : 1_000,
113
+ 'pi' : float(np.pi),
114
+ 'blue' : image.select('Rrs_B2'),
115
+ 'red' : image.select('Rrs_B4') }).rename('pSDB_red').toFloat()
116
+
117
+ def pSDB_green(image : ee.Image) -> ee.Image:
118
+ return image.expression('log(n * pi * blue) / log(n * pi * green)', {'n' : 1_000,
119
+ 'pi' : float(np.pi),
120
+ 'blue' : image.select('Rrs_B2'),
121
+ 'green' : image.select('Rrs_B3') }).rename('pSDB_green').toFloat()
122
+
123
+ def rrs(image : ee.Image) -> ee.image:
124
+ bands = ['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B8A', 'B9', 'B10', 'B11', 'B12']
125
+ return [ image.select(f'rhos_{band}').divide(np.pi).rename(f'Rrs_{band}').toFloat() for band in bands ]
126
+
127
+ PRODUCTS = {
128
+ 'spm_nechad2016' : spm_nechad2016_665,
129
+ 'spm_nechad2016_704' : spm_nechad2016_704,
130
+ 'spm_nechad2016_740' : spm_nechad2016_740,
131
+ 'tur_nechad2016' : tur_nechad2016_665,
132
+ 'tur_nechad2016_704' : tur_nechad2016_704,
133
+ 'tur_nechad2016_740' : tur_nechad2016_740,
134
+ 'chl_oc2' : chl_oc2,
135
+ 'chl_oc3' : chl_oc3,
136
+ 'chl_re_mishra' : chl_re_mishra,
137
+ 'pSDB_red' : pSDB_red,
138
+ 'pSDB_green' : pSDB_green,
139
+ 'Rrs_*' : rrs
140
+ }
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: gee_acolite
3
+ Version: 1.0.0
4
+ Summary: ACOLITE atmospheric correction for Google Earth Engine
5
+ Home-page: https://github.com/Aouei/gee_acolite
6
+ Author: Sergio
7
+ Author-email: Aouei <sergiohercar1@gmail.com>
8
+ License: GPL-3.0
9
+ Project-URL: Homepage, https://github.com/Aouei/gee_acolite
10
+ Project-URL: Documentation, https://github.com/Aouei/gee_acolite
11
+ Project-URL: Repository, https://github.com/Aouei/gee_acolite
12
+ Project-URL: Bug Tracker, https://github.com/Aouei/gee_acolite/issues
13
+ Keywords: earth-engine,atmospheric-correction,acolite,sentinel-2,remote-sensing,water-quality
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Topic :: Scientific/Engineering :: GIS
17
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
18
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.8
21
+ Classifier: Programming Language :: Python :: 3.9
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Operating System :: OS Independent
25
+ Requires-Python: >=3.8
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: earthengine-api>=0.1.350
29
+ Requires-Dist: numpy>=1.20.0
30
+ Requires-Dist: scipy>=1.7.0
31
+ Requires-Dist: netcdf4>=1.7.0
32
+ Dynamic: author
33
+ Dynamic: home-page
34
+ Dynamic: license-file
35
+ Dynamic: requires-python
36
+
37
+ # GEE ACOLITE
38
+
39
+ ACOLITE atmospheric correction implementation for Google Earth Engine (GEE).
40
+
41
+ ## Description
42
+
43
+ This package provides atmospheric correction for Sentinel-2 imagery using the ACOLITE dark spectrum fitting method, optimized for Google Earth Engine workflows. It includes:
44
+
45
+ - **Dark Spectrum Fitting**: Multiple methods (darkest, percentile, intercept)
46
+ - **AOT Estimation**: Fixed geometry mode
47
+ - **Atmospheric Correction**: Full LUT-based correction
48
+ - **Water Quality Parameters**: SPM, turbidity, chlorophyll, bathymetry indices
49
+ - **Cloud Masking**: Integration with Sentinel-2 Cloud Probability
50
+
51
+ ## Installation
52
+
53
+ ### From PyPI (when published)
54
+
55
+ ```bash
56
+ pip install gee_acolite
57
+ ```
58
+
59
+ ### From source
60
+
61
+ ```bash
62
+ git clone https://github.com/Aouei/gee_acolite.git
63
+ cd gee_acolite
64
+ pip install -e .
65
+ ```
66
+
67
+ ## Features
68
+
69
+ ### Atmospheric Correction Methods
70
+
71
+ - **Fixed Geometry**: Single AOT estimation for entire image
72
+ - **Dark Spectrum Options**:
73
+ - `darkest`: Use minimum values
74
+ - `percentile`: Use Nth percentile
75
+ - `intercept`: Linear regression intercept
76
+
77
+ ### Model Selection Criteria
78
+
79
+ - `min_drmsd`: Minimum RMSD between observed and modeled reflectance
80
+ - `min_dtau`: Minimum delta AOT between darkest bands
81
+ - `taua_cv`: Minimum coefficient of variation
82
+
83
+ ### Water Quality Products
84
+
85
+ - **SPM**: Suspended particulate matter (Nechad 2016)
86
+ - **Turbidity**: Water turbidity (Nechad 2016)
87
+ - **Chlorophyll**: Chl-a concentration (OC2, OC3, Mishra)
88
+ - **Bathymetry**: Pseudo satellite-derived bathymetry (pSDB)
89
+ - **Indices**: NDWI, custom band ratios
90
+
91
+ ## Requirements
92
+
93
+ - Python ≥ 3.8
94
+ - earthengine-api ≥ 0.1.350
95
+ - numpy ≥ 1.20.0
96
+ - scipy ≥ 1.7.0
97
+ - **ACOLITE** (standalone installation required)
98
+
99
+ ### Installing ACOLITE
100
+
101
+ This package requires the ACOLITE atmospheric correction software to be installed separately:
102
+
103
+ ```bash
104
+ # Clone ACOLITE repository
105
+ git clone https://github.com/acolite/acolite.git
106
+
107
+ # Install ACOLITE
108
+ cd acolite
109
+ pip install -e .
110
+ ```
111
+
112
+ **Important:** ACOLITE is not available on PyPI and must be installed from source.
113
+
114
+ ## Contributing
115
+
116
+ Contributions are welcome! Please:
117
+
118
+ 1. Fork the repository
119
+ 2. Create a feature branch
120
+ 3. Add tests for new features
121
+ 4. Submit a pull request
122
+
123
+ ## License
124
+
125
+ This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
126
+
127
+ ## Citation
128
+
129
+ If you use this package in your research, please cite:
130
+
131
+ ```bibtex
132
+ @software{gee_acolite,
133
+ author = {Sergio},
134
+ title = {GEE ACOLITE: Atmospheric Correction for Google Earth Engine},
135
+ year = {2025},
136
+ url = {https://github.com/Aouei/gee_acolite}
137
+ }
138
+ ```
139
+
140
+ And the original ACOLITE paper:
141
+
142
+ ```bibtex
143
+ @article{vanhellemont2019,
144
+ title={Adaptation of the dark spectrum fitting atmospheric correction for aquatic applications of the Landsat and Sentinel-2 archives},
145
+ author={Vanhellemont, Quinten and Ruddick, Kevin},
146
+ journal={Remote Sensing of Environment},
147
+ volume={225},
148
+ pages={175--192},
149
+ year={2019},
150
+ publisher={Elsevier}
151
+ }
152
+ ```
153
+
154
+ ## Acknowledgments
155
+
156
+ This package is based on the ACOLITE software developed by RBINS (Royal Belgian Institute of Natural Sciences).
157
+
158
+ ## Contact
159
+
160
+ - GitHub: [@Aouei](https://github.com/Aouei)
161
+ - Email: sergiohercar1@gmail.com
@@ -0,0 +1,15 @@
1
+ gee_acolite/__init__.py,sha256=GF2_P0j4Frb51YDbpENtKTsPAAEyhYi9ZtZkIqvCpPg,1138
2
+ gee_acolite/bathymetry.py,sha256=1NgFc50BVJRtRfu5-5Ue-3J1T9_00_v3re_RfdFSKTE,134
3
+ gee_acolite/correction.py,sha256=OwkXaEvnJsOZPjeO26u_ZkcZU-0bvypXkjl1OlhELUI,25368
4
+ gee_acolite/water_quality.py,sha256=AIfm0-3SLVXKPC4HcCvQ1xjlOFI4YLLTGteXRTP3ixA,8404
5
+ gee_acolite/sensors/__init__.py,sha256=MVEkrBW8bpheVUEVY59pKZWGj9aLAc8XMB0D1dCIYvo,196
6
+ gee_acolite/sensors/sentinel2.py,sha256=YsVwS_Iz8jjN2-_JzCFovUuxnD-FGrV2Wh_eaXyv53U,170
7
+ gee_acolite/utils/__init__.py,sha256=QjU8DYaaHQCVSCEF8VhduH9_06qnXJ7JvJ854bVuO0U,953
8
+ gee_acolite/utils/l1_convert.py,sha256=mmGtI84c8NklsV6qDSxddVePah8cwW-Y3YZfuHVztck,1786
9
+ gee_acolite/utils/masks.py,sha256=r3miWzc_zASPg-L_WpGyZPrd_tWuJNAuGdhtl52YoTI,4352
10
+ gee_acolite/utils/search.py,sha256=Qek9UdVlJna-ENr58GyU6bnA_UTDNND-Eop9xrwVSLI,3190
11
+ gee_acolite-1.0.0.dist-info/licenses/LICENSE,sha256=9xIEV9y9JDhPyeJTfbFWv247NO3RCoDhczSd0kUjsSA,787
12
+ gee_acolite-1.0.0.dist-info/METADATA,sha256=Q6gNfaDotwpWUCvQJgSFODnNWSajMBoQdg5QQsz-Tk8,4842
13
+ gee_acolite-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ gee_acolite-1.0.0.dist-info/top_level.txt,sha256=KQskKluvsN5NxC3MP24oMVA6iLs88Fk_NkobsHX8BSY,12
15
+ gee_acolite-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2025 Sergio
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU General Public License as published by
8
+ the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU General Public License for more details.
15
+
16
+ You should have received a copy of the GNU General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ ---
20
+
21
+ Full GPL 3.0 License Text: https://www.gnu.org/licenses/gpl-3.0.txt
@@ -0,0 +1 @@
1
+ gee_acolite