Skip to content

End-to-end Inference

MARS-S2L end-to-end example Open In Colab

  • 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:

  1. Download a Sentinel‑2 or Landsat scene.
  2. Compute the multi‑band, multi‑pass (MBMP) retrieval (Irakullis‑Loitxate et al., 2022).
  3. Run inference with the MARS‑S2L model (Mateo-García et al., 2025).
  4. Quantify the retrieval ΔXCH₄ and estimate the flux rate of detected plumes (Gorroño et al., 2023).

Important

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()
/home/gonzalo/mambaforge/envs/marss2ltacopy312/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm

Using service account for EE

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)
Warning 1: TIFFReadDirectory:Sum of Photometric type-related color channels and ExtraSamples doesn't match SamplesPerPixel. Defining non-color channels as ExtraSamples.
Warning 1: TIFFReadDirectory:Sum of Photometric type-related color channels and ExtraSamples doesn't match SamplesPerPixel. Defining non-color channels as ExtraSamples.

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)
File GEOS.fp.asm.tavg1_2d_slv_Nx.20250529_1730.V01.nc4 exists. It won't be downloaded again

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)
<Axes: title={'center': 'S2C 2025-05-29'}>
No description has been provided for this image

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)
/home/gonzalo/mambaforge/envs/marss2ltacopy312/lib/python3.12/site-packages/satalign/lightglue/lightglue.py:24: FutureWarning: `torch.cuda.amp.custom_fwd(args...)` is deprecated. Please use `torch.amp.custom_fwd(args..., device_type='cuda')` instead.
  @torch.cuda.amp.custom_fwd(cast_inputs=torch.float32)

<Axes: >
No description has been provided for this image

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
['B02', 'B03', 'B04', 'B08', 'B11', 'B12']
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}")
2025-12-17 16:04:40,787 - marss2l.utils - INFO - Model score for image S2B 2025-05-29 is 0.9994

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.")
<Axes: title={'center': 'Pixel prob.'}>
No description has been provided for this image

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")
Fluxrate of image S2B 2025-05-29: 6.49 ± 2.52 t/h

No description has been provided for this image

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}
}