Using Sentinel-2 images to monitor mouth of the river Pampanga and Pampanga-Bay in JupyterLab on CopPhil

In this notebook, you learn how to access data from CopPhil using API and what you can do with it. As an example, we will try to access Sentinel-2 images containing data of Pampanga river and Pampanga Bay, from January to June 2023. With usage of few Python packages, you will be able to obtain images with Normalized difference Water Index (NDWI).

We will focus on monitoring quantity of water in the area of Pampanga river’s mouth.

Prerequisites

No. 1 Access to CopPhil site

You need a CopPhil hosting account, available at https://infra.copphil.philsa.gov.ph.

No. 2 Access to JupyterLab

JupyterLab used in this article is available here: https://jupyter.infra.copphil.philsa.gov.ph/hub/login?next=%2Fhub%2F.

No. 3 Working knowledge of JupyterLab

For general information on using JupyterLab environment, see Introduction to JupyterLab on CopPhil

No. 4 Using API

This is an introductory example of obtaining EODATA using API calls, in Python:

Download satellite data using API on CopPhil

No. 5 Information on Sentinel-2 mission

Page Sentinel-2 mission shows basic information on Sentinel-2 mission, which is used in this article as a source of information.

What We Are Going To Do

  • Prepare your environment

  • Search for Sentinel-2 L2A products

    • Build a query

    • Inspect results of the request

  • Download data

    • Authentication

    • Download multiple files

    • Unpack data

  • Calculating NDWI

    • Functions for reading, calculating and saving raster data

    • Define images to be processed

    • Computing NWDI rasters

  • Visualization

    • Visualization - colormap

    • Visualization - water/no-water classification

  • Summary

Prepare your environment

Upload necessary python libraries.

# HTTP requests
import requests

# JSON parser
import json

# data manipulation
import pandas as pd
import numpy as np

# image manipulation
import rasterio
from rasterio.plot import show
import matplotlib.pyplot as plt
import matplotlib.patches as patches
from osgeo import gdal, gdal_array, osr

# file manipulation
import zipfile
import os

Search for Sentinel-2 L2A products

Let’s start with obtaining some data.

Build a query

In order to find desired products it is needed to determinate the following filters:

collection_name:

Sentinel-2

product_type:

MSIL2A

aoi

extent (coordinates) of the area of interest (WGS84)

search_period_start

time range - start date

search_period_end

time range - end date

max_cloud_cover

maximum cloud cover (%) fo the image

# base URL of the product catalogue
catalogue_odata_url = "https://catalogue.infra.copphil.philsa.gov.ph/odata/v1"

# search parameters
collection_name = "SENTINEL-2"
product_type = "S2MSI2A"
aoi = "POLYGON((120.962986 14.598416, 120.995964 14.599182, 120.999658 14.563436, 120.960348 14.567522, 120.962986 14.598416))"
search_period_start = "2023-01-01T00:00:00.000Z"
search_period_end = "2023-07-30T00:00:00.000Z"

search_query = f"{catalogue_odata_url}/Products?$filter=Collection/Name eq '{collection_name}' and Attributes/OData.CSC.StringAttribute/any(att:att/Name eq 'productType' and att/OData.CSC.StringAttribute/Value eq '{product_type}') and OData.CSC.Intersects(area=geography'SRID=4326;{aoi}') and ContentDate/Start gt {search_period_start} and ContentDate/Start lt {search_period_end}"

max_cloud_cover = 20

search_query_cloud = f"{search_query} and Attributes/OData.CSC.DoubleAttribute/any(att:att/Name eq 'cloudCover' and att/OData.CSC.DoubleAttribute/Value le {max_cloud_cover})"
print(f"""\n{search_query.replace(' ', "%20")}\n""")
https://catalogue.infra.copphil.philsa.gov.ph/odata/v1/Products?$filter=Collection/Name%20eq%20'SENTINEL-2'%20and%20Attributes/OData.CSC.StringAttribute/any(att:att/Name%20eq%20'productType'%20and%20att/OData.CSC.StringAttribute/Value%20eq%20'S2MSI2A')%20and%20OData.CSC.Intersects(area=geography'SRID=4326;POLYGON((120.962986%2014.598416,%20120.995964%2014.599182,%20120.999658%2014.563436,%20120.960348%2014.567522,%20120.962986%2014.598416))')%20and%20ContentDate/Start%20gt%202023-01-01T00:00:00.000Z%20and%20ContentDate/Start%20lt%202023-07-30T00:00:00.000Z

Inspect results of the request

Show the number of found products which fits into filters requirements and get a list of names and id’s of images which fits the query.

response = requests.get(search_query_cloud).json()
result = pd.DataFrame.from_dict(response["value"])

id_list = []
for element in response['value']:
    id_value = element['Id']  # Get the Id value
    id_list.append(id_value)  # Append it to the list

for id in id_list:
    print(id)

name_list = []
for element in response['value']:
    name_value = element['Name']  # Get the Id value
    name_list.append(name_value)  # Append it to the list

for name in name_list:
    print(name)

print(f'Number of products which meets query filters: {len(result)}')
4f37ba0e-4f74-5f8f-87e5-42efb1a2130e
d9ef8789-9d93-510a-aade-d72d35bb94ce
b8373f23-0ee8-48cb-aa08-9bbbf4c51f30
8c7fc594-cc21-47e2-8588-77772969ffd5
cbf2bf2d-5707-49e0-bf54-23fdb652872d
S2B_MSIL2A_20230107T022059_N0509_R003_T51PTS_20230107T051542.SAFE
S2A_MSIL2A_20230201T021921_N0509_R003_T51PTS_20230201T052000.SAFE
S2A_MSIL2A_20230323T021531_N0509_R003_T51PTS_20230323T070005.SAFE
S2B_MSIL2A_20230706T021539_N0509_R003_T51PTS_20230706T052627.SAFE
S2B_MSIL2A_20230507T021539_N0509_R003_T51PTS_20230507T043713.SAFE
Number of products which meets query filters: 5

Download data

Authentication

Log in to the portal. If status_code=200, it means you are logged successfully.

# Provide CopPhil account credentials - replace with your own data

username = 'username'
password = 'password'

auth_server_url = "https://auth.copphil.cloudferro.com/auth/realms/copphilinfra/protocol/openid-connect/token"
data = {
    "client_id": "copphil-public",
    "grant_type": "password",
    "username": username,
    "password": password,
}

response = requests.post(auth_server_url, data=data, verify=True, allow_redirects=False)
access_token = json.loads(response.text)["access_token"]
status_code = response.status_code
print(status_code)
200

Download multiple files

From the list which was provided in the previous stage and based on image name and id, let’s download the proper images.

# Download each file using the IDs and corresponding names
for id_value, name_value in zip(id_list, name_list):
    # Construct the download URL for each file
    url_download = f'https://download.infra.copphil.philsa.gov.ph/odata/v1/Products({id_download})/$value'
    url_download_using_token = url_download + '?token=' + access_token

    # Download the file
    pulling = requests.get(url_download_using_token)

    # Save the file using the corresponding name
    with open(f"{name_value}.zip", 'wb') as file:
        file.write(pulling.content)

    print(f"Downloaded {name_value}.zip")
Downloaded S2B_MSIL2A_20230107T022059_N0509_R003_T51PTS_20230107T051542.SAFE.zip
Downloaded S2A_MSIL2A_20230201T021921_N0509_R003_T51PTS_20230201T052000.SAFE.zip
Downloaded S2A_MSIL2A_20230323T021531_N0509_R003_T51PTS_20230323T070005.SAFE.zip
Downloaded S2B_MSIL2A_20230706T021539_N0509_R003_T51PTS_20230706T052627.SAFE.zip
Downloaded S2B_MSIL2A_20230507T021539_N0509_R003_T51PTS_20230507T043713.SAFE.zip

Unpack data

Unzip downloaded folders.

# Directory where the zip files are stored
download_directory = "."  # Change this to your specific directory if needed

# Iterate through all files in the specified directory
for filename in os.listdir(download_directory):
    if filename.endswith('.zip'):  # Check if the file is a ZIP file
        zip_file_path = os.path.join(download_directory, filename)

        # Create a directory to extract the contents into
        extract_directory = os.path.join(download_directory, os.path.splitext(filename)[0])

        # Append a timestamp if the directory already exists
        while os.path.exists(extract_directory):
            extract_directory = f"{extract_directory}_{int(time.time())}"

        # Create the directory
        os.makedirs(extract_directory, exist_ok=True)

        # Unzip the file
        try:
            with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:
                zip_ref.extractall(extract_directory)

            print(f"Unzipped {filename} to {extract_directory}")
        except zipfile.BadZipFile:
            print(f"Error: {filename} is a corrupted zip file.")
        except Exception as e:
            print(f"An unexpected error occurred while unzipping {filename}: {e}")
    else:
        print(f"File {filename} is not a zip file. Skipping.")
Unzipped S2B_MSIL2A_20230507T021539_N0509_R003_T51PTS_20230507T043713.SAFE.zip to ./S2B_MSIL2A_20230507T021539_N0509_R003_T51PTS_20230507T043713.SAFE
File Using-Sentinel-2-images-to-monitor-mouth-of-the-river-Pampanga-and-Pampanga-Bay-in-JupyterLab-on-Eumetsat-Elasticity.rst is not a zip file. Skipping.
Unzipped S2A_MSIL2A_20230323T021531_N0509_R003_T51PTS_20230323T070005.SAFE.zip to ./S2A_MSIL2A_20230323T021531_N0509_R003_T51PTS_20230323T070005.SAFE
Unzipped S2B_MSIL2A_20230107T022059_N0509_R003_T51PTS_20230107T051542.SAFE.zip to ./S2B_MSIL2A_20230107T022059_N0509_R003_T51PTS_20230107T051542.SAFE
Unzipped S2A_MSIL2A_20230201T021921_N0509_R003_T51PTS_20230201T052000.SAFE.zip to ./S2A_MSIL2A_20230201T021921_N0509_R003_T51PTS_20230201T052000.SAFE
File Using -Sentinel-2-images-to-monitor-mouth-of-the-river-Pampanga-and-Pampanga-Bay.ipynb is not a zip file. Skipping.
Unzipped S2B_MSIL2A_20230706T021539_N0509_R003_T51PTS_20230706T052627.SAFE.zip to ./S2B_MSIL2A_20230706T021539_N0509_R003_T51PTS_20230706T052627.SAFE

Calculating NDWI

Raster images containing the NDWI indicator can be used in various fields, including:

Water Resource Monitoring

Tracking changes in water levels in lakes, rivers, and reservoirs, especially important in areas affected by drought or excessive rainfall. *

Crisis Management

Rapid identification of flooded areas during floods and assessment of damages.

Environmental Protection

Monitoring wetlands and other aquatic ecosystems to preserve their biodiversity.

Urban Planning

Analyzing the impact of urban development on nearby water resources.

Agriculture

Assessing irrigation of agricultural fields and monitoring plant health.

NDWI is a normalized difference index calculated based on the reflectance of light in two different spectral bands: green (G) and near-infrared (NIR). The formula for calculating NDWI is as follows:

\[NDVI = \frac{{\text{G} - \text{NIR}}}{{\text{G} + \text{NIR}}}\]
G

Reflectance in the green band

NIR

Reflectance in the near-infrared band.

NDWI values range from -1 to 1, where higher values indicate the presence of water, while the lower values may indicate other land cover types, such as vegetation or urban areas.

Functions for reading, calculating and saving raster data

Here we present you some functions for reading raster data into Numpy matrix, calculating NDWI with NIR and GREEN matrices and saving result as a new raster. We will conduct such calculation for each downloaded item. In the end, we will obtain NDWI data on Pampanga Bay area from a period of five months - from January to May 2024.

def getFullPath(dir: str, resolution: int, band: str):
    if not os.path.isdir(dir):
        raise ValueError(f"Provided path does not exist: {dir}")
    elif resolution not in [10,20,60]:
        raise ValueError(f"Provided resolution does not exist: R{resolution}m")
    else:
        full_path = dir
        while True:
            content = os.listdir(full_path)
            if len(content) == 0:
                raise ValueError(f"Directory empty: {full_path}")
            elif len(content) == 1:
                if full_path[-1] != '/':
                    full_path = full_path + '/' + content[0]
                else:
                    full_path = full_path + content[0]
            else:
                if 'GRANULE' in content:
                    full_path = full_path + '/' + 'GRANULE'
                    break
                else:
                    raise ValueError(f"Unsupported dir architecture: {full_path}")
        full_path = full_path + '/' + os.listdir(full_path)[0]
        full_path = full_path + '/' + "IMG_DATA"
        if len(os.listdir(full_path)) == 3:
            full_path = full_path + '/' + f'R{resolution}m'
            images = os.listdir(full_path)
            for img in images:
                if band in img:
                    return full_path + '/' + img
            raise ValueError(f'No such band {band} in directory: {full_path}')
        else:
            images = os.listdir(full_path)
            for img in images:
                if band in img:
                    return full_path + '/' + img
            raise ValueError(f'No such band {band} in directory: {full_path}')

# Get transformation matrix from raster
def getTransform(pathToRaster):
    dataset = gdal.Open(pathToRaster)
    transformation = dataset.GetGeoTransform()
    return transformation

# Read raster and return pixels' values matrix as int16, new transformation matrix, crs
def readRaster(path, resolution, band):
    path = getFullPath(path, resolution, band)
    trans = getTransform(path) # trzeba zdefiniować który kanał
    raster, crs = rasterToMatrix(path)
    return raster.astype(np.int16), crs, trans

def rasterToMatrix(pathToRaster):
    with rasterio.open(pathToRaster) as src:
        matrix = src.read(1)
    return matrix, src.crs.to_epsg()

# Transform numpy's matrix to geotiff; pass new raster's filepath, matrix with pixels' values, gdal file type, transformation matrix, projection, nodata value
def npMatrixToGeotiff(filepath, matrix, gdalType, projection, transformMatrix, nodata = None):
    driver = gdal.GetDriverByName('Gtiff')
    if len(matrix.shape) > 2:
        (bandNr, yRes, xRes) = matrix.shape
        image = driver.Create(filepath, xRes, yRes, bandNr, gdalType)
        for b in range(bandNr):
            b = b + 1
            band = image.GetRasterBand(b)
            if nodata is not None:
                band.SetNoDataValue(nodata)
            band.WriteArray(matrix[b-1,:,:])
            band.FlushCache
    else:
        bandNr = 1
        (yRes, xRes) = matrix.shape
        image = driver.Create(filepath, xRes, yRes, bandNr, gdalType)
        print(type(image))
        band = image.GetRasterBand(bandNr)
        if nodata is not None:
            band.SetNoDataValue(nodata)
        band.WriteArray(matrix)
        band.FlushCache
    image.SetGeoTransform(transformMatrix)
    image.SetProjection(projection)
    del driver, image, band

Define images to be processed

Create a list of images which are going to be processed.

# Directory to search for .SAFE directories
search_directory = "."  # Change this to your specific directory if needed

# List to store the paths of .SAFE directories
dataset_list = []

# List the contents of the search directory
for item in os.listdir(search_directory):
    item_path = os.path.join(search_directory, item)
    # Check if it is a directory and ends with .SAFE
    if os.path.isdir(item_path) and item.endswith('.SAFE'):
        dataset_list.append(item_path)

# Print the list of .SAFE directories
print("List of .SAFE directories:")
for dataset in dataset_list:
    print(dataset)
List of .SAFE directories:
./S2B_MSIL2A_20230706T021539_N0509_R003_T51PTS_20230706T052627.SAFE
./S2A_MSIL2A_20230201T021921_N0509_R003_T51PTS_20230201T052000.SAFE
./S2A_MSIL2A_20230323T021531_N0509_R003_T51PTS_20230323T070005.SAFE
./S2B_MSIL2A_20230107T022059_N0509_R003_T51PTS_20230107T051542.SAFE
./S2B_MSIL2A_20230507T021539_N0509_R003_T51PTS_20230507T043713.SAFE

Computing NWDI rasters

We will now use functions define above to generate NWDI rasters. The only data needed in this step, is a list of paths to our products (extracted from a zip archive). Function readRaster will select the specified band from the specified path.

#Path to catalog for processed images with NDWI index
compution_output = 'output'

# Iterating over single product
for item in dataset_list:
    # Reading name from path
    name = item.split('/')[-1]
    # Reading green band into matrix
    green = readRaster(item, 10, 'B03')
    # Reading NIR band into matrix
    nir = readRaster(item, 10, 'B08')
    # Calculating NDWI matrix
    ndwi = (green[0]-nir[0]) / (green[0]+nir[0])
    # Creating SpatialReference object and setting it to match original's raster CRS
    projection = osr.SpatialReference()
    projection.ImportFromEPSG(green[1])
    # Creating raster from matrix in GeoTiff format
    npMatrixToGeotiff(f'{compution_output}/{name}.tif', ndwi, gdal_array.NumericTypeCodeToGDALTypeCode(np.float32), projection.ExportToWkt(), green[2])
<class 'osgeo.gdal.Dataset'>
<class 'osgeo.gdal.Dataset'>
<class 'osgeo.gdal.Dataset'>
<class 'osgeo.gdal.Dataset'>
/tmp/ipykernel_1481986/3944644538.py:13: RuntimeWarning: invalid value encountered in divide
  ndwi = (green[0]-nir[0]) / (green[0]+nir[0])
<class 'osgeo.gdal.Dataset'>

Visualization

After successfully creating and saving new images, we can visualize them in Python, using rasterio package.

Firstly, try with the simplest example in order to verify if NDWI was calculated and images created.

img = rasterio.open('output/S2A_MSIL2A_20230201T021921_N0509_R003_T51PTS_20230201T052000.SAFE.tif')
from rasterio.plot import show
show(img)
../_images/Using-Sentinel-2-images-to-monitor-mouth-of-the-river-Pampanga-and-Pampanga-Bay_20_0.png
<Axes: >

Visualization - colormap

Directory Setup

Searches for .tif files in the specified directory output.

Figure and Subplot Configuration

Creates a subplot grid with 2 rows and a dynamic number of columns based on file count; top row for full images, bottom row for zoomed areas.

Image Loading and Normalization

Loads each .tif file, calculates normalization of image data (avoiding NaNs).

Full Image Display

Shows the entire image with a viridis colormap and a red rectangle indicating the zoomed area.

Zoomed Image Display

Displays a 1000x1000 pixel area centered within each image.

Global Colorbar

Adds a colorbar scaled to the global min and max across all images.

Layout Adjustments

Ensures the layout fits all subplots and the colorbar without overlap.

# Directory to search for .tif files
search_directory = "output"

# Collect all .tif files in the specified directory
tif_files = [os.path.join(search_directory, f) for f in os.listdir(search_directory) if f.endswith('.tif')]

# Set up a row of subplots with 2 rows for each image (one for full image, one for zoomed view)
num_files = len(tif_files)
fig, axes = plt.subplots(2, num_files, figsize=(6 * num_files, 10))

# Store the normalized data for colorbar scaling
all_img_data = []

# Plot each .tif file in separate subplots
for idx, tif_file in enumerate(tif_files):
    with rasterio.open(tif_file) as img:
        # Calculate center coordinates
        center_x, center_y = img.width // 2, img.height // 2

        # Define the bounds for a 1000x1000 pixel window around the center
        window = ((center_y - 500, center_y + 500), (center_x - 500, center_x + 500))

        # Plot the full image in the top row
        ax_full = axes[0, idx] if num_files > 1 else axes[0]
        img_data = img.read(1)

        # Check data range
        min_val = img_data.min()
        max_val = img_data.max()
        print(f"File: {os.path.basename(tif_file)} - Min: {min_val}, Max: {max_val}")

        # Handle no-data values (if applicable)
        no_data = img.nodata
        if no_data is not None:
            img_data = np.where(img_data == no_data, np.nan, img_data)

        # Normalize the image data for better display, avoiding NaN values
        img_data_normalized = (img_data - np.nanmin(img_data)) / (np.nanmax(img_data) - np.nanmin(img_data))
        all_img_data.append(img_data_normalized)

        # Show the normalized image with Viridis colormap
        show(img_data_normalized, ax=ax_full, cmap='viridis')
        ax_full.set_title(f"Full Image - {os.path.basename(tif_file)}")

        # Set axis limits to ensure the image is fully displayed
        ax_full.set_xlim(0, img.width)
        ax_full.set_ylim(img.height, 0)

        # Draw a rectangle to highlight the zoomed area in pixel coordinates
        rect = patches.Rectangle((center_x - 500, center_y - 500), 1000, 1000,
                                 linewidth=2, edgecolor='red', facecolor='none')

        ax_full.add_patch(rect)  # Add rectangle to the full image

        # Plot the zoomed 1000x1000 area in the bottom row
        img_array = img.read(1, window=window)

        # Normalize the zoomed image data for display, avoiding NaN values
        img_array = (img_array - np.nanmin(img_array)) / (np.nanmax(img_array) - np.nanmin(img_array))

        ax_zoom = axes[1, idx] if num_files > 1 else axes[1]
        show(img_array, ax=ax_zoom, transform=img.window_transform(window), cmap='viridis')
        ax_zoom.set_title("Zoomed 1000x1000 Area")

# Create a colorbar for the first subplot (representing the full image)
cbar_ax = fig.add_axes([0.91, 0.15, 0.03, 0.7])  #
vmin = min([np.nanmin(data) for data in all_img_data])
vmax = max([np.nanmax(data) for data in all_img_data])
cbar = plt.colorbar(plt.cm.ScalarMappable(cmap='viridis', norm=plt.Normalize(vmin=vmin, vmax=vmax)), cax=cbar_ax)
cbar.set_label('Normalized Value')

plt.tight_layout(rect=[0, 0, 0.9, 1])
plt.show()
File: S2A_MSIL2A_20230323T021531_N0509_R003_T51PTS_20230323T070005.SAFE.tif - Min: -1.0, Max: 1.0
File: S2B_MSIL2A_20230706T021539_N0509_R003_T51PTS_20230706T052627.SAFE.tif - Min: -1.0, Max: 1.0
File: S2A_MSIL2A_20230201T021921_N0509_R003_T51PTS_20230201T052000.SAFE.tif - Min: -1.0, Max: 1.0
File: S2B_MSIL2A_20230107T022059_N0509_R003_T51PTS_20230107T051542.SAFE.tif - Min: -0.8986014127731323, Max: 1.0
File: S2B_MSIL2A_20230507T021539_N0509_R003_T51PTS_20230507T043713.SAFE.tif - Min: nan, Max: nan
/tmp/ipykernel_1481986/2200105399.py:72: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  plt.tight_layout(rect=[0, 0, 0.9, 1])  # Adjust layout to make room for colorbar
../_images/Using-Sentinel-2-images-to-monitor-mouth-of-the-river-Pampanga-and-Pampanga-Bay_22_2.png

Visualization - water/no-water classification

Now try to visualize, water and -no-water areas using white and black, respectively.

Directory Setup

Scans the output directory for .tif files and collects them for processing.

Figure and Subplot Configuration

Creates a subplot grid with two rows for each .tif file—one for the full water mask, another for a zoomed view.

Water Mask Creation

For each image, generates a binary water mask (1 for water, 0 for non-water) based on pixel values.

Full Image Display

Plots the full water mask for each image in the top row using a grayscale colormap.

Zoomed Image Display

Extracts and displays a 2000x2000 pixel zoomed area from the image center in the bottom row.

Zoom Area Highlighting

Adds a red rectangle on the full image to indicate the zoomed section.

Custom Legend Creation

Adds a color-coded legend to the plot with black for non-water and white for water, including labels and black borders.

Legend Formatting

Configures the legend with a gray background, custom limits, and border visibility.

Layout Adjustment

Ensures proper spacing and alignment of subplots and the legend.

# Directory to search for .tif files
search_directory = "output"

# Collect all .tif files in the specified directory
tif_files = [os.path.join(search_directory, f) for f in os.listdir(search_directory) if f.endswith('.tif')]

# Set up a row of subplots for each image (one for full mask, one for zoomed view)
num_files = len(tif_files)
fig, axes = plt.subplots(2, num_files, figsize=(6 * num_files, 10))

# Define colors for the water mask legend
colors = ['black', 'white']
labels = ['Non-Water', 'Water']

# Plot each .tif file in separate subplots
for idx, tif_file in enumerate(tif_files):
    with rasterio.open(tif_file) as img:
        # Calculate center coordinates
        center_x, center_y = img.width // 2, img.height // 2

        # Define the bounds for a 2,000 x 2,000 pixel window around the center
        window_size = 1000
        window = ((center_y - window_size, center_y + window_size),
                  (center_x - window_size, center_x + window_size))

        # Read the image data
        img_data = img.read(1)

        # Create a 1/0 mask for water detection (1 for water, 0 for non-water)
        water_mask = np.where(img_data > 0, 1, 0)

        # Plot the full water mask in the top row
        ax_full = axes[0, idx] if num_files > 1 else axes[0]
        show(water_mask, ax=ax_full, cmap='gray', vmin=0, vmax=1)
        ax_full.set_title(f"Water Mask - {os.path.basename(tif_file)}")

        # Set axis limits to ensure the image is fully displayed
        ax_full.set_xlim(0, img.width)
        ax_full.set_ylim(img.height, 0)

        # Draw a rectangle to highlight the zoomed area in pixel coordinates
        rect = patches.Rectangle((center_x - window_size, center_y - window_size),
                                 2000, 2000,
                                 linewidth=2, edgecolor='red', facecolor='none')

        ax_full.add_patch(rect)

        # Plot the zoomed 2,000 x 2,000 area in the bottom row
        img_array = water_mask[window[0][0]:window[0][1], window[1][0]:window[1][1]]

        ax_zoom = axes[1, idx] if num_files > 1 else axes[1]
        show(img_array, ax=ax_zoom, cmap='gray', vmin=0, vmax=1)
        ax_zoom.set_title("Zoomed Water Mask")

# Create a custom legend with black and white squares
legend_ax = fig.add_axes([0.91, 0.15, 0.03, 0.3])
legend_ax.set_facecolor('lightgray')

# Adding colored squares to the legend with a border
for i, color in enumerate(colors):
    legend_rect = patches.Rectangle((0, i), 1, 1, facecolor=color, edgecolor='black')
    legend_ax.add_patch(legend_rect)
    legend_ax.text(1.2, i + 0.5, labels[i], verticalalignment='center', color='black')

# Set the limits and hide the axes
legend_ax.set_xlim(0, 1.5)
legend_ax.set_ylim(0, len(labels))
legend_ax.axis('off')

# Create borders for the legend
for spine in legend_ax.spines.values():
    spine.set_visible(True)
    spine.set_color('black')
    spine.set_linewidth(1)

plt.tight_layout(rect=[0, 0, 0.9, 1])
plt.show()
/tmp/ipykernel_1481986/546162679.py:76: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  plt.tight_layout(rect=[0, 0, 0.9, 1])  # Adjust layout to make room for legend
../_images/Using-Sentinel-2-images-to-monitor-mouth-of-the-river-Pampanga-and-Pampanga-Bay_24_1.png

Summary

This notebook processes images to create both NDWI and binary water masks, showing the full image and a zoomed-in view of each image’s center.

What To Do Next

To learn more about processing different types of satellite data, check the following articles: