Displaying COGs on a leafmap Web Map

Create a map and add the COG as a tile layer with explicit rendering parameters:

import leafmap

m = leafmap.Map(center=(0.35, 34.75), zoom=11, basemap="CartoDB.Positron")
m.add_cog_layer(
    "https://example-bucket.s3.amazonaws.com/products/ndvi_20260614.tif",
    name="NDVI 14 June", rescale="-0.2,0.9", colormap_name="rdylgn",
)
m

The rescale and colormap are the parts that turn a black square into a readable layer. This page belongs to interactive raster exploration in Jupyter in Visualization, Tiling & Web Delivery.


How the Layer Reaches the Map

add_cog_layer does not load the raster into the notebook. It builds a tile URL template pointing at a tile service and hands that to the map widget, which then requests only the tiles in view.

The kernel builds a URL; the browser fetches the tiles The notebook kernel constructs a tile template and passes it to the map widget. From then on the browser requests tiles directly from the tile service, which reads byte ranges from the COG. The raster never passes through the kernel, so a scene of any size displays without using notebook memory. Pixels never pass through the kernel notebook kernel builds the template browser widget requests visible tiles tile service renders 256 px PNGs COG byte ranges only Consequence A continental mosaic and a single chip cost the notebook the same memory: none. What they cost the tile service depends entirely on the COG's overviews.

This is the reason a tile layer beats loading an array for anything larger than a chip. An array has to be decimated to fit in the browser, and the decimation hides exactly the pixel-level detail — a shifted mask, a single-pixel artefact — that exploration exists to find.


Environment & Setup

Package Version pin Used for
leafmap >=0.32 Map widget and COG helpers
ipyleaflet >=0.18 The underlying Leaflet widget
localtileserver >=0.8 Optional: serving local files without a remote endpoint
pip install "leafmap>=0.32" "ipyleaflet>=0.18" "localtileserver>=0.8"

Complete Working Example

import leafmap
from ipywidgets import HTML


def product_map(layers: list[dict], *, center: tuple[float, float],
                zoom: int = 11, tiler: str = "https://tiles.example.org") -> leafmap.Map:
    """Build a map from a list of layer specs, bottom to top."""
    m = leafmap.Map(center=center, zoom=zoom, basemap="CartoDB.Positron",
                    draw_control=False, measure_control=False)

    legend_rows = []
    for spec in layers:                          # order matters: first is lowest
        params = f"url={spec['url']}"
        if "rescale" in spec:
            params += f"&rescale={spec['rescale'][0]},{spec['rescale'][1]}"
        if "colormap" in spec:
            params += f"&colormap_name={spec['colormap']}"
        for b in spec.get("bidx", []):
            params += f"&bidx={b}"
        template = f"{tiler}/cog/tiles/WebMercatorQuad///.png?{params}"

        m.add_tile_layer(template, name=spec["name"],
                         opacity=spec.get("opacity", 1.0),
                         attribution=spec.get("attribution", ""))
        if "rescale" in spec:
            legend_rows.append(f"<b>{spec['name']}</b>: {spec['rescale'][0]} to "
                               f"{spec['rescale'][1]} ({spec.get('colormap', 'grey')})")

    m.add_layer_control()
    if legend_rows:
        m.add_widget(HTML("<br>".join(legend_rows)), position="bottomright")
    return m


m = product_map(
    [
        {"name": "True colour", "url": "s3://example-bucket/scenes/S2A_36NYF.tif",
         "bidx": [4, 3, 2], "rescale": (0, 3000)},
        {"name": "NDVI", "url": "s3://example-bucket/products/ndvi_36NYF.tif",
         "rescale": (-0.2, 0.9), "colormap": "rdylgn", "opacity": 0.65},
    ],
    center=(0.35, 34.75),
)
m

Building the legend from the same specs that built the layers is a small discipline with a large payoff: the legend cannot disagree with the layer, because both came from one dictionary. Hand-typing the legend into an HTML string is how a map ends up labelled “0 to 1” while rendering “−0.2 to 0.9”.


Local Files for Quick Checks

Local convenience against shareable layers A local file displayed through an ephemeral tile server started by the notebook is instant to set up but disappears with the kernel and cannot be seen by anyone else. A published COG behind a real tile service takes a moment longer to set up and produces a layer anyone with access can open. Two ways to show a raster in a notebook local file, local server m.add_raster("scene.tif") no upload, no endpoint needed gone when the kernel stops invisible to anyone else published COG, real tiler m.add_tile_layer(template) persistent and shareable same view as every other client needs the file uploaded first Use the left for a five-minute check; switch to the right the moment someone else needs to look.
import leafmap

m = leafmap.Map()
m.add_raster("outputs/ndvi_candidate.tif", colormap="RdYlGn",
             vmin=-0.2, vmax=0.9, layer_name="candidate NDVI")
m

The local route starts a small tile server inside the kernel, which is ideal for inspecting an output the pipeline has just written. It depends on the file being a reasonable COG too — a striped GeoTIFF displays, but every pan reads full-width rows and the map feels sluggish for the reasons explained in understanding Cloud-Optimized GeoTIFF structure.


Verification

import httpx

layer_templates = [l.url for l in m.layers if hasattr(l, "url") and "{z}" in l.url]
for t in layer_templates:
    probe = t.replace("{z}", "11").replace("{x}", "1222").replace("{y}", "1022")
    r = httpx.get(probe, timeout=30)
    print(r.status_code, f"{len(r.content)/1024:.1f} KB", t[:80])
Diagnosing a blank layer in one request Fetch a single tile URL from the layer template. If it returns an image of sensible size the endpoint is fine and the problem is the map view or layer order. If it returns an error the problem is the endpoint, the credentials or the rendering parameters, and nothing in the widget will fix it. Blank map? Fetch one tile first GET one tile URL 200, 5-40 KB check centre, zoom and layer order 4xx or 5xx check endpoint, credentials, parameters

Fetching one tile of each layer directly separates “the map is blank” into its two causes. A 200 response with a sensible size means the endpoint works and the problem is the view — wrong centre, wrong zoom, or the layer hidden under an opaque one. An error response means the endpoint or the parameters are wrong, and the widget was never going to show anything.

Check alignment against the basemap at native zoom too. A correctly georeferenced COG lines up with roads and coastlines to within a pixel; a consistent offset of hundreds of metres means the file’s CRS is wrong, which is covered in fixing EPSG mismatches in rasterio.open.


Common Errors

The layer renders black

No rescale was given, so reflectance is stretched across the full integer range. Pass explicit limits taken from the product’s recorded stretch.

The map shows the basemap only

The layer is outside the current view, or the endpoint returned errors silently. Fetch one tile directly as above.

The layer is offset from the basemap

The file’s CRS is missing or wrong. Fix the metadata rather than nudging the layer.

The map is slow to pan

The source is not a proper COG, or the tile service is far from the storage. Validate the file and check the service’s placement.

The layer control lists layers in the wrong order

The control reflects insertion order. Rebuild the map with layers added bottom-first rather than trying to reorder them afterwards, which some widget versions handle inconsistently.

The exported notebook shows an empty map to colleagues

The layer pointed at a local tile server or a private endpoint. Publish the file behind a reachable service before exporting, and check the template opens in a browser that is not logged in to anything.


Frequently Asked Questions

Q: Can leafmap display a local GeoTIFF without a tile server? Yes — it can start a small local tile server behind the scenes for a local file. That is convenient for a quick look, but the layer disappears when the kernel stops and cannot be shared, so publish the file and point at a real endpoint for anything other people will see.

Q: Why is my layer offset from the basemap? Almost always a wrong or missing CRS in the file, so the tiler places it with the wrong transform. Check the file’s CRS with rio info; a correct COG with a correct CRS lines up with any basemap automatically.

Q: How do I control which layer is on top? Layers draw in the order they are added, so add the basemap first, the imagery second and overlays such as masks or classifications last, with opacity below one so the evidence underneath stays visible.

Q: Which basemap works best under raster data? A light, low-saturation one such as a greyscale street map. A satellite basemap under satellite imagery is confusing, and a colourful street map competes with the data for attention. The basemap should provide place names and orientation, and nothing else.