Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Metadata is “data about data”. It describes the content, structure, origin, quality, location, format, and appropriate use of a dataset or file.

Metadata is generally more important than the data itself, as it gives meaning to the dataset and helps understand why the data exists in the first place. Metadata can be outside of data files; this is what the data story presented in the Data Story notebook. It can also be found in the header of data files, such as the GeoTIFF we are working with.

This notebook shows how to use rasterio to read and understand metadata (the coordinate reference system, spatial resolution, raster dimensions, bounds, nodata values, and value ranges) when working with bathymetry.

In this notebook, we will:

Overview

  1. Read/Load one bathymetry GeoTIFF with rasterio.

  2. See how to display the metadata with pretty display

  3. Inspect raster’s coordinate reference system, bounds, dimensions, resolution, nodata value, and bathymetry range.

Prerequisites

ConceptsImportanceNotes
NumPy arraysNecessaryRaster bands are NumPy arrays
MatplotlibHelpfulUsed for all plots
Coordinate reference systemsHelpfulRasters carry a CRS and a transform
Seabed MorphologyHelpfulWhy bathymetry grids matter here
  • Time to learn: 30 minutes

By the end of this notebook, you should be able to read raster metadata critically and explain why metadata quality is a prerequisite for meaningful morphometric analysis.

Imports

# Import Path to handle the raster path
from pathlib import Path

# Import NumPy for numerical array operations
import numpy as np

# Import Matplotlib for plotting
import matplotlib.pyplot as plt

# Help to create a nice colorbar
from mpl_toolkits.axes_grid1 import make_axes_locatable
import matplotlib.ticker as mticker

# Formatter without scientific notation
formatter = mticker.FuncFormatter(lambda x, pos: f"{int(x):,}")

# Import RasterIO to read the raster data
import rasterio as rio

# Import plotting_extent to display the raster in map coordinates
from rasterio.plot import plotting_extent

import geopandas as gpd
from shapely import wkt
from shapely.geometry import Point,box
import folium

What is a raster?

A raster is a regular grid of cells, or pixels, where each cell holds a value. A bathymetry raster holds a water-depth value in every cell. On its own, that grid is just a 2D array of numbers. What makes it a georeferenced raster, and not an ordinary image, is two extra pieces of information:

  1. A coordinate reference system (CRS) that says which place on Earth the grid covers and in what units.

  2. An affine transform that maps each cell’s (row, column) position to a real-world (x, y) coordinate.

A raster can also hold several bands, which are stacked grids of the same size. A satellite image might have red, green, and blue bands; our bathymetry files have a single band. rasterio gives you access to all three parts: the array, the CRS, and the transform.

Reading the data with rasterio

Rasterio is a dedicated Python library for raster data manipulation. From Rasterio’s documentation (available at https://readthedocs.org/projects/rasterio/downloads/pdf/latest/):

“Rasterio’s goal is to be this kind of raster data library – expressing [Geospatial Data Abstraction Library] GDAL’s data model using fewer nonidiomatic extension classes and more idiomatic Python types and protocols, while performing as fast as GDAL’s Python bindings.
High performance, lower cognitive load, cleaner and more transparent code. This is what Rasterio is about.”

As an example, we are using the gifford_bathy raster.

rasterio.open returns a dataset object. Opening a file does not read the pixel values; it reads only the header, so it is cheap even for very large rasters. We will use the Gifford Canyon bathymetry that ships with this cookbook.

data_path = Path("../data/gifford_bathy.tif")

# rasterio as rio
dataset = rio.open(data_path)
dataset
<open DatasetReader name='../data/gifford_bathy.tif' mode='r'>

Inspecting the metadata

The dataset object exposes the georeferencing as plain attributes. These are the values you will reach for most often.

# print metadata:
print("CRS:", dataset.crs)
print("Bounds:", dataset.bounds)
print("Width:", dataset.width)
print("Height:", dataset.height)
print("Resolution:", dataset.res)
print("Transform:", dataset.transform)
print("Number of bands:", dataset.count)
print("NoData value:", dataset.nodata)
print("Data type:", dataset.dtypes)
CRS: EPSG:32757
Bounds: BoundingBox(left=515475.0, bottom=7026925.0, right=561175.0, top=7068675.0)
Width: 914
Height: 835
Resolution: (50.0, 50.0)
Transform: | 50.00, 0.00, 515475.00|
| 0.00,-50.00, 7068675.00|
| 0.00, 0.00, 1.00|
Number of bands: 1
NoData value: 3.3999999521443642e+38
Data type: ('float32',)

We can see a lot of information from the GeoTIFF! Briefly, we have:

  • CRS (EPSG:32757): The coordinate reference system used to locate the raster in real-world space. This projected CRS uses meters.

  • Bounds: The rectangular spatial extent of the raster in CRS coordinates.

  • Width and height: The number of columns and rows in the raster grid.

  • Resolution: The size of each raster cell on the ground. Here, each cell represents 50 m × 50 m.

  • Transform: The affine transform that maps pixel rows and columns to real-world coordinates.

  • Number of bands: The number of layers in the raster. A single-band raster stores one variable, here bathymetry.

  • NoData value: The placeholder value used for missing or invalid cells.

  • Data type: The numeric format of the raster values, here float32.

  • Shape: The array dimensions as (rows, columns).

  • Depth range: The range of valid bathymetry values. Negative values indicate depth below sea level.

For convenience, the whole georeferencing header is available as a single dictionary, the dataset profile. This is exactly what you pass back to rasterio when writing a new file.

dict(dataset.profile)
{'driver': 'GTiff', 'dtype': 'float32', 'nodata': 3.3999999521443642e+38, 'width': 914, 'height': 835, 'count': 1, 'crs': CRS.from_wkt('PROJCS["WGS 84 / UTM zone 57S",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",159],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",10000000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],AUTHORITY["EPSG","32757"]]'), 'transform': Affine(50.0, 0.0, 515475.0, 0.0, -50.0, 7068675.0), 'blockxsize': 914, 'blockysize': 2, 'tiled': False, 'interleave': 'band'}

Reading pixel values into an array

dataset.read(1) reads the first band into a NumPy array. Band indices are 1-based, following the GeoTIFF convention. The array’s shape is (rows, columns).

band1 = dataset.read(1)
print("type :", type(band1).__name__)
print("shape:", band1.shape)
print("dtype:", band1.dtype)
type : ndarray
shape: (835, 914)
dtype: float32

Rasters use a sentinel nodata value to mark cells with no measurement, such as gaps between survey lines. If you read naively, those sentinels pollute any statistic you compute. Passing masked=True returns a masked array in which nodata cells are hidden.

bathy_data = dataset.read(1, masked=True)
bathy_data
masked_array( data=[[--, --, --, ..., --, --, --], [--, --, --, ..., --, --, --], [--, --, --, ..., --, --, --], ..., [--, --, --, ..., --, --, --], [--, --, --, ..., --, --, --], [--, --, --, ..., --, --, --]], mask=[[ True, True, True, ..., True, True, True], [ True, True, True, ..., True, True, True], [ True, True, True, ..., True, True, True], ..., [ True, True, True, ..., True, True, True], [ True, True, True, ..., True, True, True], [ True, True, True, ..., True, True, True]], fill_value=3.4e+38, dtype=float32)

For numerical work it is often easiest to convert the masked cells to NaN and use the np.nan* family of functions, which skip NaN automatically.

bathy_nan = bathy_data.filled(np.nan)

print("shallowest:", np.nanmax(bathy_nan), "m")
print("deepest   :", np.nanmin(bathy_nan), "m")
print("mean depth:", np.nanmean(bathy_nan), "m")
shallowest: -255.59682 m
deepest   : -3221.865 m
mean depth: -1839.3173 m

Pixel and map coordinates

The affine transform is what separates a georeferenced raster from a plain image. It converts a (row, column) pixel index into an (x, y) map coordinate. rasterio wraps this in two convenience methods so you rarely touch the matrix directly: xy goes from pixel to map, and index goes from map to pixel.

# Pixel (row, col) -> map (x, y)
row, col = 200, 300
x, y = dataset.xy(row, col)
print(f"pixel (row={row}, col={col}) -> map (x={x:.1f}, y={y:.1f})")

# Map (x, y) -> pixel (row, col)
r, c = dataset.index(x, y)
print(f"map (x={x:.1f}, y={y:.1f}) -> pixel (row={r}, col={c})")
pixel (row=200, col=300) -> map (x=530500.0, y=7058650.0)
map (x=530500.0, y=7058650.0) -> pixel (row=200, col=300)

We are done with the low-level handle, so we close it.

dataset.close()

Reading the data using with rio.open

For this next part, we reproduce the same as before, showing that we get the same results in a safer way using with rio.open. We also directly assign a variable to the metdata output.

bathy_path = '../data/gifford_bathy.tif'

# Raster reading
# --------------
with rio.open(bathy_path) as dataset:
    bathy = dataset.read(1, masked=True).astype(float).filled(np.nan)
    transform = dataset.transform
    crs = dataset.crs
    bounds = dataset.bounds   # <-- correct way to get raster bounds

    # print metadata:
    print("CRS:", dataset.crs)
    print("Bounds:", dataset.bounds)
    print("Width:", dataset.width)
    print("Height:", dataset.height)
    print("Resolution:", dataset.res)
    print("Transform:", dataset.transform)
    print("Number of bands:", dataset.count)
    print("NoData value:", dataset.nodata)
    print("Data type:", dataset.dtypes)

    
# Resolution
dx = abs(transform.a)
dy = abs(transform.e)

print("shape:", bathy.shape)
print("crs:", crs)
print("cell size (resolution):", (dx, dy))
print("depth range:", (np.nanmin(bathy), np.nanmax(bathy)))
CRS: EPSG:32757
Bounds: BoundingBox(left=515475.0, bottom=7026925.0, right=561175.0, top=7068675.0)
Width: 914
Height: 835
Resolution: (50.0, 50.0)
Transform: | 50.00, 0.00, 515475.00|
| 0.00,-50.00, 7068675.00|
| 0.00, 0.00, 1.00|
Number of bands: 1
NoData value: 3.3999999521443642e+38
Data type: ('float32',)
shape: (835, 914)
crs: EPSG:32757
cell size (resolution): (50.0, 50.0)
depth range: (np.float64(-3221.864990234375), np.float64(-255.59681701660156))

Looking at the CRS metadata

crs
CRS.from_wkt('PROJCS["WGS 84 / UTM zone 57S",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",159],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",10000000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],AUTHORITY["EPSG","32757"]]')
print(crs)
EPSG:32757

crs and print(crs) gave different results?! Let’s see why below.

Understanding __str__ and __repr__ when printing in Jupyter

Let’s create a class that we call MysteriousObject that has those special methods"

import random

class MysteriousObject:
    # Printable representation (more for developers)
    def __repr__(self):
        return f"MysteriousObject({random.randint(1, 100)})"

    # Printable String (more for users)
    def __str__(self):
        return f"I'm the mysterious object number {random.randint(1, 100)}"

We can create a object like so:

# Create an instance
obj = MysteriousObject()

We have an object named objthat we can display using automatic interactive variable printing from Jupyter and below its “printed version” to see the different outputs:

# Not using print (the __repr__ method is used)
obj
MysteriousObject(76)

with the printfunction

# Using print (the __str__ method is used)
print(obj)
I'm the mysterious object number 85

To print the __repr__ method of the CRS with the print function, we would do:

print(crs.__repr__())
CRS.from_wkt('PROJCS["WGS 84 / UTM zone 57S",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",159],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",10000000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],AUTHORITY["EPSG","32757"]]')

We can also use the Well-Known Text (WKT) conversion to_wkt method

print(crs.to_wkt())
PROJCS["WGS 84 / UTM zone 57S",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",159],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",10000000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],AUTHORITY["EPSG","32757"]]

However, prints and outputs are not easy to read. This is where geopandas pretty display can help.

Using geopandas pretty display

GeoPandas is a powerful Python package that extends Pandas functionality to work with geospatial data. It can be useful for handling and displaying Coordinate Reference System (CRS) information in a readable format.

# Assuming o_data.crs.to_wkt() returns a valid WKT string
wkt_string = crs.to_wkt()

# Create a GeoDataFrame with a single geometry from the WKT
# A geometry is needed to define a GeoDataFrame, it is just a dummy value here
gdf = gpd.GeoDataFrame(geometry=[wkt.loads("POINT(0 0)")], crs=wkt_string)

# Display the CRS information in a nicely formatted way
print("Formatted CRS WKT:")
print(gdf.crs.to_wkt(pretty=True))
Formatted CRS WKT:
PROJCRS["WGS 84 / UTM zone 57S",
    BASEGEOGCRS["WGS 84",
        DATUM["World Geodetic System 1984",
            ELLIPSOID["WGS 84",6378137,298.257223563,
                LENGTHUNIT["metre",1]]],
        PRIMEM["Greenwich",0,
            ANGLEUNIT["degree",0.0174532925199433]],
        ID["EPSG",4326]],
    CONVERSION["UTM zone 57S",
        METHOD["Transverse Mercator",
            ID["EPSG",9807]],
        PARAMETER["Latitude of natural origin",0,
            ANGLEUNIT["degree",0.0174532925199433],
            ID["EPSG",8801]],
        PARAMETER["Longitude of natural origin",159,
            ANGLEUNIT["degree",0.0174532925199433],
            ID["EPSG",8802]],
        PARAMETER["Scale factor at natural origin",0.9996,
            SCALEUNIT["unity",1],
            ID["EPSG",8805]],
        PARAMETER["False easting",500000,
            LENGTHUNIT["metre",1],
            ID["EPSG",8806]],
        PARAMETER["False northing",10000000,
            LENGTHUNIT["metre",1],
            ID["EPSG",8807]]],
    CS[Cartesian,2],
        AXIS["easting",east,
            ORDER[1],
            LENGTHUNIT["metre",1]],
        AXIS["northing",north,
            ORDER[2],
            LENGTHUNIT["metre",1]],
    ID["EPSG",32757]]

CRS and EPSG in more details

Using Folium to display the location of the survey

We can use the rate bounds and reproject the GeoDataFrame to EPSG:4326 for visualization

# Get the bounding box of the raster and compute its center
center_x = (bounds.left + bounds.right) / 2
center_y = (bounds.bottom + bounds.top) / 2

# Create a GeoDataFrame using the raster's native CRS
gdf = gpd.GeoDataFrame(
    geometry=[Point(center_x, center_y)],
    crs=crs
)

# Print the original CRS information
print("Original CRS WKT:")
print(gdf.crs.to_wkt(pretty=True))
Original CRS WKT:
PROJCRS["WGS 84 / UTM zone 57S",
    BASEGEOGCRS["WGS 84",
        DATUM["World Geodetic System 1984",
            ELLIPSOID["WGS 84",6378137,298.257223563,
                LENGTHUNIT["metre",1]]],
        PRIMEM["Greenwich",0,
            ANGLEUNIT["degree",0.0174532925199433]],
        ID["EPSG",4326]],
    CONVERSION["UTM zone 57S",
        METHOD["Transverse Mercator",
            ID["EPSG",9807]],
        PARAMETER["Latitude of natural origin",0,
            ANGLEUNIT["degree",0.0174532925199433],
            ID["EPSG",8801]],
        PARAMETER["Longitude of natural origin",159,
            ANGLEUNIT["degree",0.0174532925199433],
            ID["EPSG",8802]],
        PARAMETER["Scale factor at natural origin",0.9996,
            SCALEUNIT["unity",1],
            ID["EPSG",8805]],
        PARAMETER["False easting",500000,
            LENGTHUNIT["metre",1],
            ID["EPSG",8806]],
        PARAMETER["False northing",10000000,
            LENGTHUNIT["metre",1],
            ID["EPSG",8807]]],
    CS[Cartesian,2],
        AXIS["easting",east,
            ORDER[1],
            LENGTHUNIT["metre",1]],
        AXIS["northing",north,
            ORDER[2],
            LENGTHUNIT["metre",1]],
    ID["EPSG",32757]]
# Reproject the GeoDataFrame to EPSG:4326 for proper display on Folium
gdf = gdf.to_crs(epsg=4326)
print("Reprojected CRS WKT (EPSG:4326):")
print(gdf.crs.to_wkt(pretty=True))
Reprojected CRS WKT (EPSG:4326):
GEOGCRS["WGS 84",
    ENSEMBLE["World Geodetic System 1984 ensemble",
        MEMBER["World Geodetic System 1984 (Transit)"],
        MEMBER["World Geodetic System 1984 (G730)"],
        MEMBER["World Geodetic System 1984 (G873)"],
        MEMBER["World Geodetic System 1984 (G1150)"],
        MEMBER["World Geodetic System 1984 (G1674)"],
        MEMBER["World Geodetic System 1984 (G1762)"],
        MEMBER["World Geodetic System 1984 (G2139)"],
        MEMBER["World Geodetic System 1984 (G2296)"],
        ELLIPSOID["WGS 84",6378137,298.257223563,
            LENGTHUNIT["metre",1]],
        ENSEMBLEACCURACY[2.0]],
    PRIMEM["Greenwich",0,
        ANGLEUNIT["degree",0.0174532925199433]],
    CS[ellipsoidal,2],
        AXIS["geodetic latitude (Lat)",north,
            ORDER[1],
            ANGLEUNIT["degree",0.0174532925199433]],
        AXIS["geodetic longitude (Lon)",east,
            ORDER[2],
            ANGLEUNIT["degree",0.0174532925199433]],
    USAGE[
        SCOPE["Horizontal component of 3D system."],
        AREA["World."],
        BBOX[-90,-180,90,180]],
    ID["EPSG",4326]]
# Extract the reprojected coordinates (latitude, longitude)
center_point = gdf.geometry.iloc[0]
latitude, longitude = center_point.y, center_point.x

# Create a Folium map centered on the reprojected point
m = folium.Map(location=[latitude, longitude], zoom_start=10)
folium.Marker([latitude, longitude], popup="Raster Center").add_to(m)

# To save the map, uncomment the following line:
# m.save("raster_center_map.html")

# Display the map
m
Loading...

If needed, we can add more info over the marker and select an different initial zoom to picture the region directly.

# Create polygon from raster bounds in native CRS
bounds_polygon = box(bounds.left, bounds.bottom, bounds.right, bounds.top)

bounds_gdf = gpd.GeoDataFrame(
    geometry=[bounds_polygon],
    crs=crs
)

# Reproject to WGS84 for Folium
bounds_gdf_4326 = bounds_gdf.to_crs(epsg=4326)

# Reproject center point too, if not already done
gdf_4326 = gdf.to_crs(epsg=4326)

center_point = gdf_4326.geometry.iloc[0]
latitude, longitude = center_point.y, center_point.x

m = folium.Map(location=[latitude, longitude], zoom_start=5)

folium.GeoJson(
    bounds_gdf_4326,
    name="Raster Bounds",
    tooltip="Raster footprint"
).add_to(m)

popup_text = f"""
<b>Raster Center</b><br>
CRS: {crs}<br>
Bounds: {bounds}<br>
Resolution: {transform.a:.2f}, {abs(transform.e):.2f}<br>
Shape: {bathy.shape}
"""

folium.Marker(
    [latitude, longitude],
    popup=folium.Popup(popup_text, max_width=400)
).add_to(m)

folium.LayerControl().add_to(m)

m
Loading...

Displaying the 3 sites on Folium from metadata

In this section, we provide functions and visualization from metadata.

DATASETS = {
    "Gifford Canyon": Path("../data/gifford_bathy.tif"),
    "Oceanic Shoals": Path("../data/os_bathy.tif"),
    "Point Cloates": Path("../data/pc_bathy.tif"),
}
def get_raster_metadata(path):
    """Read raster metadata and return footprint in EPSG:4326 plus metadata."""
    with rio.open(path) as src:
        data = src.read(1, masked=True)

        bounds_polygon = box(
            src.bounds.left,
            src.bounds.bottom,
            src.bounds.right,
            src.bounds.top,
        )

        footprint_native = gpd.GeoDataFrame(
            geometry=[bounds_polygon],
            crs=src.crs,
        )

        footprint_4326 = footprint_native.to_crs(epsg=4326)

        metadata = {
            "file": path.name,
            "crs": str(src.crs),
            "bounds": src.bounds,
            "resolution_x": src.transform.a,
            "resolution_y": abs(src.transform.e),
            "shape": src.shape,
            "width": src.width,
            "height": src.height,
            "band_count": src.count,
            "nodata": src.nodata,
            "dtype": src.dtypes[0],
            "depth_min": float(data.min()),
            "depth_max": float(data.max()),
        }

    return footprint_4326, metadata
site_footprints = {}

for site_name, path in DATASETS.items():
    footprint_4326, metadata = get_raster_metadata(path)
    site_footprints[site_name] = {
        "footprint": footprint_4326,
        "metadata": metadata,
    }
def raster_bounds_to_wgs84(path):
    """Create a WGS84 footprint polygon from raster metadata."""
    with rio.open(path) as src:
        bounds = src.bounds
        crs = src.crs
        transform = src.transform

        bounds_polygon = box(
            bounds.left,
            bounds.bottom,
            bounds.right,
            bounds.top,
        )

        bounds_gdf = gpd.GeoDataFrame(
            geometry=[bounds_polygon],
            crs=crs,
        )

        bounds_gdf_4326 = bounds_gdf.to_crs(epsg=4326)

        metadata = {
            "crs": crs,
            "bounds": bounds,
            "resolution": (transform.a, abs(transform.e)),
            "shape": src.shape,
            "width": src.width,
            "height": src.height,
            "nodata": src.nodata,
            "dtype": src.dtypes[0],
            "band_count": src.count,
        }

    return bounds_gdf_4326, metadata
# Use all site centroids to center the map
centroids = [
    item["footprint"].geometry.iloc[0].centroid
    for item in site_footprints.values()
]

center_latitude = sum(point.y for point in centroids) / len(centroids)
center_longitude = sum(point.x for point in centroids) / len(centroids)


m = folium.Map(
    location=[center_latitude, center_longitude],
    zoom_start=3,
    tiles="CartoDB positron",
)

for site_name, item in site_footprints.items():
    footprint = item["footprint"]
    metadata = item["metadata"]

    center_point = footprint.geometry.iloc[0].centroid
    latitude, longitude = center_point.y, center_point.x

    popup_text = f"""
    <b>{site_name}</b><br>
    CRS: {metadata["crs"]}<br>
    Bounds: {metadata["bounds"]}<br>
    Resolution: {metadata["resolution_x"]:.2f} m, {metadata["resolution_y"]:.2f} m<br>
    Shape: {metadata["shape"]}<br>
    Width × height: {metadata["width"]} × {metadata["height"]}<br>
    Bands: {metadata["band_count"]}<br>
    Data type: {metadata["dtype"]}<br>
    NoData: {metadata["nodata"]}
    """

    folium.GeoJson(
        footprint,
        name=site_name,
        tooltip=f"{site_name} raster footprint",
        style_function=lambda feature: {
            "fillOpacity": 0.2,
            "weight": 2,
        },
    ).add_to(m)

    folium.Marker(
        location=[latitude, longitude],
        popup=folium.Popup(popup_text, max_width=450),
        tooltip=site_name,
    ).add_to(m)


folium.LayerControl().add_to(m)

m
Loading...

Save the map in HTML

# Save to HTML file
m.save("./images/map.html")