Comparing Two Dates with a Split Map

Build both layers with the same rendering parameters and put them either side of a swipe control:

import leafmap

def template(url: str) -> str:
    return (f"https://tiles.example.org/cog/tiles/WebMercatorQuad///.png"
            f"?url={url}&bidx=4&bidx=3&bidx=2&rescale=0,3000")      # identical stretch

m = leafmap.Map(center=(0.35, 34.75), zoom=13)
m.split_map(left_layer=template("s3://example-bucket/scenes/2025-06-14.tif"),
            right_layer=template("s3://example-bucket/scenes/2026-06-14.tif"))
m

A split map answers the question stakeholders actually ask — what changed — faster than any statistic. This page belongs to interactive raster exploration in Jupyter in Visualization, Tiling & Web Delivery.


The Shared Stretch Is the Whole Point

Independent stretches invent change An area with no real change is shown twice. With a stretch computed separately for each date, one side is noticeably brighter because its scene contained a bright cloud that shifted the percentiles, and the swipe line looks like a change boundary. With one shared stretch both sides match and the swipe line disappears. independent stretches one shared stretch looks like change; nothing happened swipe line vanishes where nothing changed The swipe line should only be visible where the ground is different.

The test of a well-built split map is that the divider disappears over unchanged ground. If you can see the swipe line across a stable forest or a car park, the two sides are being rendered differently and every apparent difference on the map is suspect.


Environment & Setup

Package Version pin Used for
leafmap >=0.32 The split map control
pystac-client >=0.7 Finding comparable acquisitions
ipyleaflet >=0.18 Underlying swipe widget
pip install "leafmap>=0.32" "pystac-client>=0.7" "ipyleaflet>=0.18"

Complete Working Example

from datetime import date, timedelta

import leafmap
import pystac_client

API = "https://earth-search.aws.element84.com/v1"


def clearest_near(target: date, bbox: list[float], *, window_days: int = 20,
                  max_cloud: float = 10.0):
    """The least cloudy Sentinel-2 item within a window around a target date."""
    client = pystac_client.Client.open(API)
    items = list(client.search(
        collections=["sentinel-2-l2a"], bbox=bbox,
        datetime=f"{target - timedelta(days=window_days)}/{target + timedelta(days=window_days)}",
        query={"eo:cloud_cover": {"lt": max_cloud}},
    ).items())
    if not items:
        raise LookupError(f"no clear scene within {window_days} days of {target}")
    return min(items, key=lambda it: it.properties["eo:cloud_cover"])


def compare(bbox: list[float], before: date, after: date, *,
            rescale: tuple[int, int] = (0, 3000),
            tiler: str = "https://tiles.example.org") -> leafmap.Map:
    left_item = clearest_near(before, bbox)
    right_item = clearest_near(after, bbox)

    def tpl(item) -> str:
        return (f"{tiler}/cog/tiles/WebMercatorQuad///.png"
                f"?url={item.assets['visual'].href}&rescale={rescale[0]},{rescale[1]}")

    centre = ((bbox[1] + bbox[3]) / 2, (bbox[0] + bbox[2]) / 2)
    m = leafmap.Map(center=centre, zoom=13)
    m.split_map(left_layer=tpl(left_item), right_layer=tpl(right_item),
                left_label=left_item.datetime.date().isoformat(),
                right_label=right_item.datetime.date().isoformat())
    return m


compare([34.60, 0.25, 34.90, 0.45], before=date(2025, 6, 14), after=date(2026, 6, 14))

Choosing the clearest scene near an anniversary date, rather than an exact date, is what makes the comparison fair. Same season means same phenology; a year apart means the difference is change rather than growth. The date-filtered search mechanics are covered in using pystac-client to filter Sentinel-2 imagery by date.


Choosing Dates That Differ for the Right Reason

Anniversary dates isolate change from season A seasonal NDVI curve repeats each year. Comparing June with December shows the seasonal swing and makes every field look transformed. Comparing June with the following June compares the same point on the curve, so any remaining difference is change rather than phenology. Same point in the season, different year June 2025 December June 2026 June vs December: phenology June vs June: change

The same logic extends to time of day and viewing geometry, which matter less for Sentinel-2’s fixed overpass but a great deal for commercial imagery taken at different angles. Tall objects lean differently and shadows fall in different directions, and a split map will show every building as “changed” if the two acquisitions were taken from opposite sides.

Pairing the split map with a third, difference layer is the most useful arrangement for review. The swipe shows what the ground looked like; the difference layer, rendered with a diverging ramp pinned to zero, shows where the analysis thinks it changed. Where the two disagree, one of them is wrong, and the split map usually shows which. The difference product itself is built in computing NDVI difference between two dates.


Sharing the Comparison

A split map is most valuable when the person who needs to see it is not the person who built it, which means it has to survive leaving the notebook.

The simplest route is to export the notebook to HTML: the map widget, both layer templates and the swipe control all come along, and anyone who can reach the tile endpoint gets a working comparison in a browser with no Python at all. That is often the right deliverable for a stakeholder review, because it lets people pan to the places they care about rather than the places you chose to screenshot.

For something more permanent, the same two tile templates drop into a small standalone web page with any mapping library that supports a swipe control. Since the templates carry all their rendering parameters, the page needs no server-side configuration and the comparison it shows is identical to the one in the notebook. Record the two scene identifiers and the stretch on the page itself, so a reader can see exactly which acquisitions they are looking at and a later update does not silently change what the page claims.

Screenshots still have their place in reports, and for those, capture both panes at the same zoom and extent rather than capturing the swipe mid-way — a static image of a half-swiped map is hard to read and easy to misinterpret.


Verification

What both sides must share Both panes must use the same band selection, the same rescale limits, the same colormap and the same processing level. Any one of these differing makes the comparison show a rendering difference as if it were a change on the ground. Four things that must match across the divider bands same bidx order rescale identical limits colormap same name, or none processing level L2A with L2A Generate both templates from one function and three of these four are guaranteed.
from urllib.parse import parse_qs, urlparse

def params(template: str) -> dict:
    q = parse_qs(urlparse(template).query)
    q.pop("url", None)
    return q

assert params(left_tpl) == params(right_tpl), "the two panes render differently"

Comparing the templates’ parameters with the source URL removed is a one-line guard that catches the commonest mistake. The fourth property — processing level — needs a check on the catalog items themselves: comparing a top-of-atmosphere scene with a surface-reflectance one shows the atmosphere, not the ground, a difference explained in comparing L1C and L2A Sentinel-2 products.


Common Errors

The swipe line is visible everywhere

The two sides use different stretches or different processing levels. Build both templates from one function and check the items’ processing level.

Every field looks changed

The dates are from different seasons. Compare anniversary dates instead.

Buildings appear to have moved

The acquisitions have different viewing angles. Prefer near-nadir scenes, or accept that tall structures will lean.

One side loads much more slowly

That scene lacks overviews or sits in a slower storage region. Check both sources before blaming the widget.


Frequently Asked Questions

Q: Why must both sides share a stretch? Because any difference in brightness between the panes will be read as change on the ground. Per-image stretching makes each side look good and invents differences between them, which is exactly what a comparison must not do.

Q: Which two dates should I compare? Dates from the same point in the season, ideally a year apart, with similar sun elevation and low cloud. Comparing spring with autumn shows phenology, not change, and every field will appear to have been transformed.

Q: Is a split map enough to report change? No — it is a communication and inspection tool. Reportable change needs a differencing product with a threshold and an accuracy assessment; the split map is how you check that product makes sense and how you show it to people.

Q: Can I compare more than two dates this way? A split map is inherently a pair. For a sequence, use a time slider over a stack of layers sharing one stretch, or probe individual locations through time — the swipe works best when there is exactly one question: before or after.