Skip to content

Sentinel-2 ToA reflectance to radiance

Author: Gonzalo Mateo-García

In this notebook we show how to convert ToA reflectance from Sentinel-2 to radiance using the metadata fetched from the GEE or directly computing the conversion factors.

import ee
import matplotlib.pyplot as plt
from georeader import plot
from shapely.geometry import box
from georeader.readers import ee_image
from datetime import datetime, timezone
from rasterio import Affine
from georeader.readers import S2_SAFE_reader
from georeader import reflectance
import os

os.environ["GS_NO_SIGN_REQUEST"] = "YES"

# ee.Authenticate()
ee.Initialize()

Fetch a S2 image from GEE

collection_name = "COPERNICUS/S2_HARMONIZED"
tile = "S2A_MSIL1C_20240417T064631_N0510_R020_T40RCN_20240417T091941"
img_col = ee.ImageCollection(collection_name)
image = img_col.filter(ee.Filter.eq("PRODUCT_ID", tile)).first()
info_img = image.getInfo()
# projgee = image.select("B2").projection().getInfo()

aoi = box(55.325, 25.225, 55.415, 25.28)

bands_0bname = list(S2_SAFE_reader.BANDS_S2_L1C)
bands = [b.replace("B0","B") for b in bands_0bname]
crs = info_img["bands"][1]["crs"]
transform = Affine(*info_img["bands"][1]["crs_transform"])
img_local = ee_image.export_image(info_img['id'],
                                  crs=crs, transform=transform,
                                  bands_gee=bands,
                                  geometry=aoi)
img_local
Warning 1: TIFFReadDirectory:Sum of Photometric type-related color channels and ExtraSamples doesn't match SamplesPerPixel. Defining non-color channels as ExtraSamples.

 
         Transform: | 10.00, 0.00, 331260.00|
| 0.00,-10.00, 2797010.00|
| 0.00, 0.00, 1.00|
         Shape: (13, 622, 916)
         Resolution: (10.0, 10.0)
         Bounds: (331260.0, 2790790.0, 340420.0, 2797010.0)
         CRS: EPSG:32640
         fill_value_default: 0.0
        

Fetch same image from Google bucket

s2_safe_folder = S2_SAFE_reader.s2_public_bucket_path(tile, check_exists=False)

print(f"File is located at: {s2_safe_folder}")

s2obj = S2_SAFE_reader.s2loader(s2_safe_folder, out_res=10)
s2obj
File is located at: gs://gcp-public-data-sentinel-2/tiles/40/R/CN/S2A_MSIL1C_20240417T064631_N0510_R020_T40RCN_20240417T091941.SAFE

 
         gs://gcp-public-data-sentinel-2/tiles/40/R/CN/S2A_MSIL1C_20240417T064631_N0510_R020_T40RCN_20240417T091941.SAFE
         Transform: | 10.00, 0.00, 300000.00|
| 0.00,-10.00, 2800020.00|
| 0.00, 0.00, 1.00|
         Shape: (13, 10980, 10980)
         Resolution: (10.0, 10.0)
         Bounds: (300000.0, 2690220.0, 409800.0, 2800020.0)
         CRS: EPSG:32640
         bands: ['B01', 'B02', 'B03', 'B04', 'B05', 'B06', 'B07', 'B08', 'B8A', 'B09', 'B10', 'B11', 'B12']
         fill_value_default: 0
        

Convert DN to reflectances

toa_refl = img_local / 10_000

Fetch metadata to do toa reflectance to radiance conversion

See: Google Earth Engine Sentinel-2 image properties

solar_irradiance_gee = {k:v/1_000. for k, v in info_img['properties'].items() if k.startswith("SOLAR_IRRADIANCE")}
for b in bands:
    print(f"{b} -> {solar_irradiance_gee[f'SOLAR_IRRADIANCE_{b}']}")
B1 -> 1.88469
B2 -> 1.9596600000000002
B3 -> 1.82324
B4 -> 1.51206
B5 -> 1.4246400000000001
B6 -> 1.28761
B7 -> 1.16208
B8 -> 1.04163
B8A -> 0.9553200000000001
B9 -> 0.81292
B10 -> 0.36715
B11 -> 0.24559
B12 -> 0.08525

date_of_aquisition = datetime.fromtimestamp(info_img['properties']["system:time_start"]/ 1000).replace(tzinfo=timezone.utc)
date_of_aquisition
datetime.datetime(2024, 4, 17, 9, 2, 44, 342000, tzinfo=datetime.timezone.utc)
mean_solar_zenith_angle = info_img['properties']['MEAN_SOLAR_ZENITH_ANGLE']
U = info_img['properties']["REFLECTANCE_CONVERSION_CORRECTION"]
mean_solar_zenith_angle, U
(22.6291255460394, 0.994777427364766)
import numpy as np

observation_date_corr_factor = np.pi / (np.cos(mean_solar_zenith_angle/180*np.pi) * U)
print(observation_date_corr_factor)

rad = reflectance.reflectance_to_radiance(toa_refl, 
                                          solar_irradiance = [solar_irradiance_gee[f"SOLAR_IRRADIANCE_{b}"] for b in bands],
                                          observation_date_corr_factor=observation_date_corr_factor)
rad
3.4214902841464196

 
         Transform: | 10.00, 0.00, 331260.00|
| 0.00,-10.00, 2797010.00|
| 0.00, 0.00, 1.00|
         Shape: (13, 622, 916)
         Resolution: (10.0, 10.0)
         Bounds: (331260.0, 2790790.0, 340420.0, 2797010.0)
         CRS: EPSG:32640
         fill_value_default: 0.0
        

Metadata from the SAFE file

s2obj.read_metadata_tl()
solar_irr_safe = s2obj.solar_irradiance()
U_save = s2obj.scale_factor_U()
print(f"SZA: {s2obj.mean_sza} U: {U_save}")
solar_irr_safe
for b in bands_0bname:
    print(f"{b} -> {solar_irr_safe[b]}")
SZA: 22.6291255460394 U: 0.994777427364766
B01 -> 1.88469
B02 -> 1.9596600000000002
B03 -> 1.82324
B04 -> 1.51206
B05 -> 1.4246400000000001
B06 -> 1.28761
B07 -> 1.16208
B08 -> 1.04163
B8A -> 0.9553200000000001
B09 -> 0.81292
B10 -> 0.36715
B11 -> 0.24559
B12 -> 0.08525

Calculate Irradiance and correction factor using georeader

satellite = tile.split("_")[0] # S2A, S2B or S2C

srf_satellite = S2_SAFE_reader.read_srf(satellite)
irradiances = reflectance.integrated_irradiance(srf_satellite) / 1_000 # Convert from mW/m2/sr/nm to W/m2/sr/nm
solar_irradiance_calc = dict(zip(srf_satellite.columns, irradiances))
solar_irradiance_calc
/home/gonzalo/mambaforge/envs/marsmlpy312/lib/python3.12/site-packages/openpyxl/worksheet/_reader.py:329: UserWarning: Unknown extension is not supported and will be removed
  warn(msg)

{'B01': 1.884835040894774,
 'B02': 1.9585911091822743,
 'B03': 1.8241227793692252,
 'B04': 1.5121602113335022,
 'B05': 1.424787320092646,
 'B06': 1.2873886799012966,
 'B07': 1.1565783864975936,
 'B08': 0.9709833673468558,
 'B8A': 0.9532926414812208,
 'B09': 0.7941054027444697,
 'B10': 0.36748198011502775,
 'B11': 0.24593390086371422,
 'B12': 0.0856471195539582}

Show differences in solar irradiance

for b in bands_0bname:
    bnozero = b.replace("B0", "B")
    print(f"{b} -> GEE: {solar_irradiance_gee[f'SOLAR_IRRADIANCE_{bnozero}']} SAFE: {solar_irr_safe[b]:.4f} Calc: {solar_irradiance_calc[b]:.4f}")
B01 -> GEE: 1.88469 SAFE: 1.8847 Calc: 1.8848
B02 -> GEE: 1.9596600000000002 SAFE: 1.9597 Calc: 1.9586
B03 -> GEE: 1.82324 SAFE: 1.8232 Calc: 1.8241
B04 -> GEE: 1.51206 SAFE: 1.5121 Calc: 1.5122
B05 -> GEE: 1.4246400000000001 SAFE: 1.4246 Calc: 1.4248
B06 -> GEE: 1.28761 SAFE: 1.2876 Calc: 1.2874
B07 -> GEE: 1.16208 SAFE: 1.1621 Calc: 1.1566
B08 -> GEE: 1.04163 SAFE: 1.0416 Calc: 0.9710
B8A -> GEE: 0.9553200000000001 SAFE: 0.9553 Calc: 0.9533
B09 -> GEE: 0.81292 SAFE: 0.8129 Calc: 0.7941
B10 -> GEE: 0.36715 SAFE: 0.3671 Calc: 0.3675
B11 -> GEE: 0.24559 SAFE: 0.2456 Calc: 0.2459
B12 -> GEE: 0.08525 SAFE: 0.0853 Calc: 0.0856

observation_date_corr_factor_calc = reflectance.observation_date_correction_factor(img_local.footprint("EPSG:4326").centroid.coords[0],
                                                                                   date_of_acquisition=date_of_aquisition)
observation_date_corr_factor_calc
3.327182058670046
rad2 = reflectance.reflectance_to_radiance(toa_refl, 
                                          solar_irradiance = [solar_irradiance_calc[b] for b in bands_0bname],
                                          observation_date_corr_factor=observation_date_corr_factor)
rad2
 
         Transform: | 10.00, 0.00, 331260.00|
| 0.00,-10.00, 2797010.00|
| 0.00, 0.00, 1.00|
         Shape: (13, 622, 916)
         Resolution: (10.0, 10.0)
         Bounds: (331260.0, 2790790.0, 340420.0, 2797010.0)
         CRS: EPSG:32640
         fill_value_default: 0.0
        

Show relative differences between the calculated and official factors

for i, b in enumerate(bands_0bname):
    rad_b_or = rad.isel({"band":i})
    rad_b_2 = rad2.isel({"band":i})
    ratio = rad_b_or.values / rad_b_2.values
    mean_ratio = np.mean(ratio)
    std_ratio = np.std(ratio)
    print(f"{b} {mean_ratio:.4f} {std_ratio:.6f}")
B01 0.9999 0.000000
B02 1.0005 0.000000
B03 0.9995 0.000000
B04 0.9999 0.000000
B05 0.9999 0.000000
B06 1.0002 0.000000
B07 1.0048 0.000000
B08 1.0728 0.000000
B8A 1.0021 0.000000
B09 1.0237 0.000000
B10 0.9991 0.000000
B11 0.9986 0.000000
B12 0.9954 0.000000

Licence

The georeader package is published under a GNU Lesser GPL v3 licence

georeader tutorials and notebooks are released under a Creative Commons non-commercial licence.

If you find this work useful please cite:

@article{portales-julia_global_2023,
    title = {Global flood extent segmentation in optical satellite images},
    volume = {13},
    issn = {2045-2322},
    doi = {10.1038/s41598-023-47595-7},
    number = {1},
    urldate = {2023-11-30},
    journal = {Scientific Reports},
    author = {Portalés-Julià, Enrique and Mateo-García, Gonzalo and Purcell, Cormac and Gómez-Chova, Luis},
    month = nov,
    year = {2023},
    pages = {20316},
}