End-to-end Inference
MARS-S2L end-to-end example 
- Last Modified: 16-12-2025
- Author: Gonzalo Mateo-GarcĂa
Overview
This notebook demonstrates an end-to-end example of the UNEP IMEO pipeline for detecting methane plumes in Sentinel‑2 and Landsat imagery.
Specifically, it shows how to:
- Download a Sentinel‑2 or Landsat scene.
- Compute the multi‑band, multi‑pass (MBMP) retrieval (Irakullis‑Loitxate et al., 2022).
- Run inference with the MARS‑S2L model (Mateo-GarcĂa et al., 2025).
- Quantify the retrieval ΔXCH₄ and estimate the flux rate of detected plumes (Gorroño et al., 2023).
Important
- The MARS‑S2L model produce false positives. In (Mateo-GarcĂa et al., 2025), we report a 8\% false positive rate.
- Therefore any positive model output must be validated before its use.
- At UNEP IMEO, all plumes are manually validated by at least two independent remote sensing analysts before their publication.
- The
marss2lpackage is published under a GNU Lesser GPL v3 licence - The MARS-S2L database, trained models and tutorials in this package are released under a Creative Commons non-commercial share-alike licence
Note: This notebook requires a Google Earth Engine account to download Sentinel-2 or Landsat images.
Install marss2l and extra dependencies
pip install marss2l
The package includes the download helpers, pre-trained models, and quantification utilities used throughout this tutorial.
Initialize GEE and choose location and image to run
This example starts from a known location and a target scene, which is how the operational workflow narrows down the search space before running retrieval and inference.
import pandas as pd
from marss2l.mars_sentinel2 import s2lutils
from marss2l.mars_sentinel2 import ee as ee_utils
import ee
from datetime import datetime, timezone
from marss2l.utils import setup_stream_logger
# ee.ee_initialize()
ee.Authenticate()
ee.Initialize(project="YOUR-PROJECT-ID")
logger = setup_stream_logger()
example = "S2" # "S2" or "offshore"
if example == "landsat":
## Example Landsat
lat, lon = 50.06473, 48.94175
offshore = False
tile = "LC08_L1TP_169025_20250803_20250805_02_RT"
background_image_tile = "LC09_L1TP_169025_20250710_20250712_02_T1"
elif example == "S2":
## Example Sentinel-2
lat, lon = 32.16492, -102.13013
offshore = False
tile = "S2B_MSIL1C_20250529T172859_N0511_R055_T13SGR_20250529T210525"
background_image_tile = "S2C_MSIL1C_20250524T172921_N0511_R055_T13SGR_20250524T215704"
elif example == "offshore":
lat, lon = 19.56554 , -92.23654
offshore = True
tile = "LC09_L1GT_022046_20250906_20250906_02_T2"
background_image_tile = "LC08_L1GT_022046_20250728_20250728_02_RT"
else:
raise NotImplementedError(f'Expected one of "landsat" "S2" or "offshore" found {example}')
satellite = tile.split("_")[0]
satellite_bg = background_image_tile.split("_")[0]
islandsat = not satellite.startswith("S2")
if islandsat:
# Not the exact date but will be updated when we fetch the GEE product
tile_date = datetime.strptime(tile.split("_")[3],"%Y%m%d").replace(tzinfo=timezone.utc)
tile_date_bg = datetime.strptime(tile.split("_")[3],"%Y%m%d").replace(tzinfo=timezone.utc)
else:
tile_date = datetime.strptime(tile.split("_")[2],"%Y%m%dT%H%M%S").replace(tzinfo=timezone.utc)
tile_date_bg = datetime.strptime(background_image_tile.split("_")[2],"%Y%m%dT%H%M%S").replace(tzinfo=timezone.utc)
Find GEE products to download
The exact target and reference products matter because the MBMP step compares the current scene against a suitable background overpass rather than a generic image.
polygon = s2lutils.center_polygon_meters(lon, lat, margin_meters=2_000)
image_to_download = s2lutils.gee_info_to_download(tile,
tile_date=tile_date,
geometry=polygon,
delta_hours_search=20,
logger=logger)
tile_date = image_to_download["utcdatetime"]
tile = image_to_download["tile"]
image_to_download_bg = s2lutils.gee_info_to_download(background_image_tile,
tile_date=tile_date_bg,
geometry=polygon,
delta_hours_search=20,
logger=logger)
tile_date_bg = image_to_download["utcdatetime"]
background_image_tile = image_to_download["tile"]
Download the current image and the background image
MARS-S2L uses both the current overpass and a background image because methane detection here is based on comparing the scene against a cloud-free reference, as in the MBMP approach described in the paper.
# Download image
image, cloudmask, sza, vza, band_names = s2lutils.download_image_and_angles(tile=tile,
image_to_download=image_to_download,
geometry=polygon, logger=logger)
# Download background image
image_bg, cloudmask_bg, sza_bg, vza_bg, band_names_bg = s2lutils.download_image_and_angles(tile=background_image_tile,
image_to_download=image_to_download_bg,
geometry=polygon, logger=logger)
Download the wind from NASA GEOS FP
Wind is used both by the model and later by the flux-rate estimate, so it is part of the physical context needed to interpret a plume.
from marss2l.mars_sentinel2 import wind
windu, windv = wind.download_wind_nasa_geos_fp((lon, lat), tile_date)
Plot downloaded data
A quick visual check helps confirm that the target scene, background scene, and wind direction are all consistent before moving on to methane-specific processing.
from georeader import plot
import matplotlib.pyplot as plt
fig, ax = plt.subplots(1,2, figsize=(8, 4))
rgb = (image.isel({"band": [3, 2, 1]})/4_500).clip(0,1)
rgb_bg = (image_bg.isel({"band": [3, 2, 1]})/4_500).clip(0,1)
title= f"{satellite} {tile_date.strftime('%Y-%m-%d')}"
title_bg= f"{satellite_bg} {tile_date_bg.strftime('%Y-%m-%d')}"
plot.show(rgb,ax=ax[0],title=title, add_scalebar=True)
wind.add_wind_to_plot([windu,windv], ax=ax[0])
plot.show(rgb_bg,ax=ax[1],title=title_bg, add_scalebar=True)
Compute MBMP retrieval
This is the main physics-based product in the workflow. Following Irakullis‑Loitxate et al., 2022, it compares the current scene with a similar cloud-free background scene in the methane-sensitive SWIR bands to estimate atmospheric transmittance; lower MBMP values indicate a stronger methane signal, as discussed by (Gorroño et al., 2023).
It matters because this retrieval is the core evidence of methane in multispectral imagery. In the MARS workflow, analysts validate AI detections against the physics-based MBMP retrieval and other auxiliary views to distinguish real plumes from artifacts before any notification is made.
from marss2l.mars_sentinel2 import mixing_ratio_methane
from georeader import rasterize
from georeader.geotensor import GeoTensor
import numpy as np
b11_index = band_names.index("B06") if islandsat else band_names.index("B11")
b12_index = band_names.index("B07") if islandsat else band_names.index("B12")
# validmask
validmask = GeoTensor(cloudmask.values != 4,
crs=cloudmask.crs, transform=cloudmask.transform, fill_value_default=False)
if not offshore:
mbmp = mixing_ratio_methane.ratio_IL(image,image_bg,
b11_index=b11_index, b12_index=b12_index,
fill_value_ratio_il=1,
validmask=validmask,
corregister=True)
else:
mbmp = mixing_ratio_methane.ratio_bands(
image,
numerator_index=b12_index,
denominator_index=b11_index,
validmask=validmask,
fill_value_default=1,
)
ax = plot.show(mbmp, vmin=0.9, vmax=1, cmap="plasma_r", add_colorbar_next_to=True)
wind.add_wind_to_plot([windu,windv], ax=ax)
Run inference with the MARS-S2L model
At this stage the notebook has assembled the core inputs described in the paper: current imagery, reference imagery, cloud validity, and wind. The model turns those into a plume probability map.
from marss2l.mars_sentinel2 import plume_detection_model
model_name = "CH4Net" if offshore else "MARS-S2L"
model = plume_detection_model.load_model(model_name=model_name, logger=logger)
from marss2l.loaders import BANDS_S2_IN_L8
from marss2l.mars_sentinel2.s2lutils import bands_in_l89, get_channels_to_pred
channels_input = bands_in_l89(BANDS_S2_IN_L8) if islandsat else BANDS_S2_IN_L8
channels_input
from georeader import read
from rasterio.enums import Resampling
image_predict = get_channels_to_pred(image, channels=band_names, channels_model=channels_input)
image_predict_bg = get_channels_to_pred(image_bg, channels=band_names_bg, channels_model=channels_input)
if islandsat:
# Interpolate to 10m
image_predict = read.resize(image_predict,resolution_dst=(10,10))
image_predict_bg = read.read_reproject_like(image_predict_bg,image_predict)
validmask_predict = read.read_reproject_like(validmask,image_predict,resampling=Resampling.nearest)
else:
validmask_predict = validmask
binary_mask, scene_score, is_plume, continuous_pred = model.predict(image_predict=image_predict,
background_image=image_predict_bg,
validmask=validmask_predict,
wind_vector=[windu, windv])
if islandsat:
# Interpolate back to 30m
binary_mask = read.read_reproject_like(binary_mask, image, resampling=Resampling.nearest)
continuous_pred = read.read_reproject_like(continuous_pred, image, resampling=Resampling.bilinear)
logger.info(f"Model score for image {title} is {scene_score:.4f}")
fig, ax = plt.subplots(1,3, figsize=(12, 4))
plot.show(rgb,ax=ax[0],title=title, add_scalebar=True)
wind.add_wind_to_plot([windu,windv], ax=ax[0])
plot.show(mbmp, vmin=0.9, vmax=1, cmap="plasma_r", add_colorbar_next_to=True, ax=ax[1], title="MBMP retrieval")
wind.add_wind_to_plot([windu,windv], ax=ax[1])
plot.show(continuous_pred, vmin=0, vmax=1, cmap="magma", add_colorbar_next_to=True, ax=ax[2], title="Pixel prob.")
Compute the methane concentration image $\Delta\text{XCH}_4$ and the fluxrate
After detection, the next step is quantification. This combines the retrieval with the predicted plume mask to estimate methane enhancement and an approximate emission rate.
from marss2l.mars_sentinel2 import transmittance_to_ch4, quantification
import math
# Recompute MBMP taking out plume from normalization
if not offshore:
mbmp = mixing_ratio_methane.ratio_IL(image,image_bg,
b11_index=b11_index, b12_index=b12_index,
fill_value_ratio_il=1,
validmask=validmask,
plumemaskbool=binary_mask.astype(bool),
corregister=True)
else:
mbmp = mixing_ratio_methane.ratio_bands(
image,
numerator_index=b12_index,
denominator_index=b11_index,
validmask=validmask,
plumemaskbool=binary_mask.astype(bool),
fill_value_default=1,
)
ch42tr = transmittance_to_ch4.TransmittanceCH4InterpolationFromDict()
ch4 = ch42tr.deltach4_from_ratio_transmittance(satellite=satellite,
sza=sza, vza=vza,
ratio_il=mbmp)
fig, ax = plt.subplots(1,2, figsize=(10, 4.5))
plot.show(ch4, vmin=0, vmax=1_500, cmap="plasma", add_colorbar_next_to=True,title=r"$\Delta$XCH$_4$ (ppb)",ax=ax[0])
wind.add_wind_to_plot([windu,windv], ax=ax[0])
if is_plume:
qoutput = quantification.obtain_flux_rate(ch4, binary_mask, wind_speed=math.sqrt(windu**2+windv**2),return_std= True)
plot.show(binary_mask, vmin=0, vmax=1, cmap="magma", add_colorbar_next_to=True, ax=ax[1], title="Pred", interpolation="nearest")
print(f"Fluxrate of image {title}: {qoutput['Q']/1_000:.2f} ± {qoutput['sigma_Q']/1_000:.2f} t/h")
Licence
The marss2l package is published under a GNU Lesser GPL v3 licence
The MARS-S2L database and all pre-trained models are released under a Creative Commons non-commercial share-alike licence. For using the models and data in comercial pipelines written consent by UNEP IMEO must be provided.
marss2l tutorials and notebooks are released under a Creative Commons non-commercial share-alike licence.
If you find this work useful please cite:
@article{allen_2025,
title = {Artificial intelligence for methane detection: from continuous monitoring to verified mitigation},
author = {Allen, Anna and Mateo-Garcia, Gonzalo and Irakulis-Loitxate, Itziar and Martin, Manuel Montesino-San and Watine, Marc and Requeima, James and Gorroño, Javier and Randles, Cynthia and Mokalled, Tharwat and Guanter, Luis and Turner, Richard E. and Cifarelli, Claudio and Caltagirone, Manfredi},
url = {http://arxiv.org/abs/2511.21777},
doi = {10.48550/arXiv.2511.21777},
month = nov,
year = {2025}
}