MODIS Sinusoidal Tiles Do Not Line Up

On this page

Problem statement

You read a MODIS land product — snow cover, NDVI, land surface temperature — for a box drawn in longitude and latitude, and something is off. Half the window is empty. Two adjacent days' tiles do not join. Overlaid on a basemap the image is skewed, or sits tens of kilometres from where it should be. MODIS land products are delivered on a sinusoidal grid in 10° tiles, a projection almost nothing else uses, and each of these symptoms comes from treating that grid like longitude and latitude.

This page reads MOD10A1 snow cover for 25 February 2024 over a 0.4° box straddling 40° N in the Colorado Rockies, where two tiles meet, and reproduces each symptom with its fix.

Quick answer

Find every tile that touches your box, mosaic them in their own projection, and place the result using its own coordinates — reproject it, or plot each pixel at its true longitude and latitude:

items = [i for i in catalog.search(collections=["modis-10A1-061"], bbox=BOX, datetime="2024-02-25/2024-02-25").items()
         if i.id.startswith("MOD10A1")]
srcs = [rasterio.open(i.assets["NDSI_Snow_Cover"].href) for i in sorted(items, key=lambda i: i.id)]
sin_box = transform_bounds("EPSG:4326", srcs[0].crs, *BOX)
mosaic, tr = merge(srcs, bounds=sin_box, nodata=255)
utm, utm_tr = to_utm(mosaic[0], tr, srcs[0].crs)
print([i.id.split(".")[2] for i in items], mosaic.shape[1:], "sinusoidal ->", utm.shape, "in UTM 13N at 500 m")
['h09v05', 'h09v04'] (96, 187) sinusoidal -> (91, 279) in UTM 13N at 500 m
Three panels of MOD10A1 snow cover for 25 February 2024: on the sinusoidal grid, the 0.4 degree box appears as a slanted parallelogram crossing the tile seam at 40 degrees north; placed with its own coordinates the window is a parallelogram around the box; placed by the box's bounding coordinates the same pixels are squeezed into the box. Three panels of MOD10A1 snow cover for 25 February 2024: on the sinusoidal grid, the 0.4 degree box appears as a slanted parallelogram crossing the tile seam at 40 degrees north; placed with its own coordinates the window is a parallelogram around the box; placed by the box's bounding coordinates the same pixels are squeezed into the box.
Snow blue, no snow brown, cloud grey; the red outline is the requested box. MOD10A1.061 via Planetary Computer.

Count tiles, read both, never place by bounding box

Reading MODIS sinusoidal tiles for a longitude-latitude box correctly. from a lon/lat box to MODIS pixels in placefind all tilesh09v04 + h09v05mosaic in SINexact seam at 40° Nmask to the box39% of the windowreproject oncenearest, UTM 13NNever place the window by the box corners: 52 km off at the edges spatialworkflow.io
The order that keeps MODIS pixels in place.

1. Count the tiles

MODIS tiles are 10° by 10° on the sinusoidal grid, numbered h (west–east) and v (north–south). Tile rows break at multiples of 10° latitude, so a box crossing 40° N needs two tiles every day:

for s, i in zip(srcs, sorted(items, key=lambda i: i.id)):
    print(i.id.split(".")[2], "y from", round(s.bounds.bottom), "to", round(s.bounds.top))
print("40 N in sinusoidal metres:", round(Transformer.from_crs(4326, srcs[0].crs, always_xy=True).transform(-105.7, 40.0)[1]))
h09v04 y from 4447802 to 5559753
h09v05 y from 3335852 to 4447802
40 N in sinusoidal metres: 4447802

2. Read both, or half the box is empty

Reading one tile with a window over the whole box fills the part outside the tile with nodata:

for s, i in zip(srcs, sorted(items, key=lambda i: i.id)):
    w = from_bounds(*sin_box, s.transform).round_offsets().round_lengths()
    a = s.read(1, window=w, boundless=True, fill_value=255)
    print(i.id.split(".")[2], "alone:", f"{(a == 255).mean():.0%} of the window is fill")
print("merged:", f"{(mosaic[0] == 255).mean():.0%} fill, {(mosaic[0] <= 100).mean():.0%} clear land")
h09v04 alone: 50% of the window is fill
h09v05 alone: 50% of the window is fill
merged: 0% fill, 99% clear land

rasterio.merge.merge joins tiles cleanly because they share one grid: the seam is exact, with no resampling.

3. Know why the window is so wide

In the sinusoidal projection a meridian's x coordinate is R · longitude · cos(latitude), so meridians lean, more steeply the further they are from 0°. At 106° W they lean a long way:

t = Transformer.from_crs(4326, srcs[0].crs, always_xy=True)
xa, _ = t.transform(-105.9, 39.8); xb, _ = t.transform(-105.9, 40.2)
x0, x1 = sin_box[0], sin_box[2]
print(f"the 105.9 W meridian moves {abs(xb - xa) / 1000:.1f} km in x between 39.8 and 40.2 N")
print(f"the box is {abs(t.transform(-105.5, 40)[0] - t.transform(-105.9, 40)[0]) / 1000:.1f} km wide, its sinusoidal window {(x1 - x0) / 1000:.1f} km")
the 105.9 W meridian moves 52.8 km in x between 39.8 and 40.2 N
the box is 34.1 km wide, its sinusoidal window 86.8 km

The 0.4° box is a slanted parallelogram on the sinusoidal grid; its bounding rectangle is more than twice as wide. That is expected, not a bug — but the extra pixels lie outside the box and must not be counted as inside it.

4. Never place it by its bounding box

The common mistake is to plot the window with the box's longitude–latitude extent, as if its rows and columns were parallels and meridians:

inv = Transformer.from_crs(srcs[0].crs, 4326, always_xy=True)
H, W = mosaic.shape[1:]
for (r, c), name in (((0, 0), "top-left"), ((H - 1, W - 1), "bottom-right")):
    lon, lat = inv.transform(*(tr * (c + 0.5, r + 0.5)))
    naive = BOX[0] + (c + 0.5) / W * (BOX[2] - BOX[0])
    print(f"{name:12} pixel is at {lon:.3f} E; placed naively at {naive:.3f} E - {abs(lon - naive) * 111.32 * np.cos(np.radians(lat)):.0f} km off")
top-left     pixel is at -106.516 E; placed naively at -105.899 E - 52 km off
bottom-right pixel is at -104.892 E; placed naively at -105.501 E - 52 km off

Corner pixels land fifty kilometres from where they belong. In the middle of the box the error is smaller, which is why the mistake can look almost right.

5. Reproject for use with other data

To combine MODIS with anything in longitude–latitude or UTM, reproject. Nearest-neighbour keeps the codes (250 cloud, 237 water) intact:

print(utm_tr.a, "m pixels;", f"{(utm <= 100).mean():.0%} clear land in the UTM grid")
500.0 m pixels; 60% clear land in the UTM grid

The rest of the UTM grid is fill: the slanted sinusoidal footprint becomes a rectangle in UTM only by padding its corners.

Pixels inside the box, and the tile for a point

The data used on this page

MODIS/Terra Snow Cover Daily L3 Global 500m SIN Grid, Version 61 (MOD10A1.061), tiles h09v04 and h09v05 for 25 February 2024, Hall, D. K. and G. A. Riggs, NASA NSIDC DAAC, via Microsoft Planetary Computer.

import numpy as np
import planetary_computer
import pystac_client
import rasterio
from pyproj import Transformer
from rasterio.merge import merge
from rasterio.transform import array_bounds
from rasterio.warp import Resampling, calculate_default_transform, reproject, transform_bounds
from rasterio.windows import from_bounds

catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
                                    modifier=planetary_computer.sign_inplace)
BOX = (-105.9, 39.8, -105.5, 40.2)                  # lon/lat, straddling 40 N in the Front Range

def to_utm(a, tr, crs, res=500):
    """Reproject a sinusoidal array to UTM 13N with nearest neighbour, keeping the 255 fill."""
    h, w = a.shape
    t, wd, ht = calculate_default_transform(crs, "EPSG:32613", w, h, *array_bounds(h, w, tr), resolution=res)
    out = np.full((ht, wd), 255, np.uint8)
    reproject(a, out, src_transform=tr, src_crs=crs, dst_transform=t, dst_crs="EPSG:32613",
              resampling=Resampling.nearest, src_nodata=255, dst_nodata=255)
    return out, t

print(BOX)
(-105.9, 39.8, -105.5, 40.2)

Example 1: keep only pixels inside the box

The sinusoidal window contains pixels outside the requested box. Mask them by their true coordinates before counting:

rr, cc = np.mgrid[0:H, 0:W]
xs, ys = tr * (cc + 0.5, rr + 0.5)
lon, lat = inv.transform(xs, ys)
inside = (lon >= BOX[0]) & (lon <= BOX[2]) & (lat >= BOX[1]) & (lat <= BOX[3])
a = mosaic[0]
print(f"{inside.mean():.0%} of the window's pixels are inside the box; snow-covered share inside: "
      f"{((a >= 10) & (a <= 100) & inside).sum() / ((a <= 100) & inside).sum():.0%}, whole window: "
      f"{((a >= 10) & (a <= 100)).sum() / (a <= 100).sum():.0%}")
39% of the window's pixels are inside the box; snow-covered share inside: 99%, whole window: 81%

Example 2: the tile for a point

The tile containing any longitude and latitude follows from the sinusoidal coordinates and the tile size:

TILE = 1111950.5197665233                          # 10 degrees at the equator, in sinusoidal metres
def modis_tile(lon, lat):
    x, y = Transformer.from_crs(4326, srcs[0].crs, always_xy=True).transform(lon, lat)
    return f"h{int((x + 18 * TILE) // TILE):02d}v{int((9 * TILE - y) // TILE):02d}"
print({p: modis_tile(*xy) for p, xy in {"Boulder": (-105.27, 40.01), "Denver": (-104.99, 39.74), "Zermatt": (7.75, 46.02)}.items()})
{'Boulder': 'h09v04', 'Denver': 'h09v05', 'Zermatt': 'h18v04'}

An equal-area grid whose meridians lean

The MODIS sinusoidal grid is equal-area: every 463 m cell covers the same ground everywhere, which is why NASA uses it for global land products. It achieves that by shrinking the east–west scale with the cosine of latitude, so meridians converge towards the poles and lean away from the central meridian. Far from 0° longitude, at 106° W, the lean is severe.

Geometry of a 0.4 degree box at 40 N, 106 W on the MODIS sinusoidal grid. one box, two shapesquantityvaluebox width at 40° N34.1 kmmeridian lean over 0.4° of latitude52.8 kmsinusoidal window width86.8 kmwindow pixels inside the box39%corner error if placed by box52 kmx = R · longitude · cos(latitude): the lean grows with distance from 0° spatialworkflow.io
Why the window is so much wider than the box.

Three practical rules follow. A box defined in longitude and latitude is a slanted shape on the grid, and its bounding window includes pixels outside it — mask them by their true coordinates. Tiles join exactly in their own projection, so mosaic before reprojecting, never after. And any display or overlay must use the pixels' real coordinates, either by reprojecting the raster or by plotting each pixel where it is; placing the window by the box's corners shifts its edges by tens of kilometres.

Tile rows, mosaics and the sphere radius

Symptoms of mishandled MODIS sinusoidal tiles and their causes. what you see, and whysymptomcause and fixhalf the window emptysecond tile not read: merge bothwindow far wider than boxleaning meridians: mask by truelon/latimage skewed or 50 km offplaced by bounding box: reprojectcodes blurred into valuesbilinear resampling: use nearestAll four come from treating the grid as longitude and latitude spatialworkflow.io
Each symptom, its cause and fix.
  • Tile rows break every 10° of latitude, columns every 10° at the equator; boxes near a break need two tiles.
  • Mosaic in sinusoidal, then reproject once.
  • Nearest-neighbour for categorical products and codes.
  • Mask to the box by true coordinates; the window is wider than the box.
  • The sphere radius is 6,371,007.181 m, not WGS 84 — keep the file's CRS rather than rebuilding it.
  • Near the date line and poles tiles are partly empty by design.

FAQ

Why do MODIS tiles not line up with my map?

MODIS land products use a sinusoidal projection in which meridians lean; plotting a tile as if its rows were parallels and its columns meridians misplaces it by up to tens of kilometres.

How do I mosaic MODIS tiles in Python?

Open each tile with rasterio and join them with rasterio.merge.merge in their native sinusoidal CRS, then reproject the mosaic once.

Which MODIS tile is my location in?

Convert longitude and latitude to sinusoidal coordinates and divide by the tile size, 1,111,950.52 m: Boulder, Colorado (40.01° N) is in h09v04, just north of the 40° N tile break, and Denver in h09v05.

Why is my MODIS window much wider than my bounding box?

A longitude–latitude box is a slanted parallelogram on the sinusoidal grid; its bounding rectangle can be more than twice the box's width at mid-latitudes far from 0°.