Skip to content

Optimizing Chunked I/O for Multi-Band Sentinel-2 Processing

Set CHUNK_SIZE = 512 to match Sentinel-2 COG internal block geometry, read each spectral band with a windowed src.read(band, window=win) call, and upload results via io.BytesIO — no /tmp staging required. A single Lambda invocation processing a 512×512 window of four 10m bands (B02, B03, B04, B08) at float32 consumes roughly 4 MB of working memory, well inside the 128 MB minimum allocation and leaving headroom for GDAL internal caches.

Why This Technique Is Needed

Sentinel-2 L2A granules are delivered as 10980×10980 pixel arrays per band. Naively calling src.read() without a window loads the entire array into RAM: at float32, a single 10m band occupies 460 MB. Four bands for an NDVI computation exceed 1.8 GB — beyond the default Lambda memory allocation and past the 1,536 MB ceiling of an Azure Functions Consumption plan outright. Naive multi-band loads also force the GDAL driver to decompress every internal COG tile in the scene, multiplying CPU overhead proportionally.

Memory footprint of a full-scene read versus a windowed readFour measured working sets for the same NDVI computation on a Sentinel-2 L2A granule: a single 10980 by 10980 band cast to float32 at 460 megabytes, the four 10m bands at 1,840 megabytes, the same four bands read as a 512 by 512 window at 4 megabytes, and a 256 by 256 window at 1 megabyte.Working set for one NDVI computation4 x 10m bands, whole scene1,840 MB1 band, whole scene460 MB4 bands, 512 x 512 window4 MB4 bands, 256 x 256 window1 MB0MB resident10980 x 10980 pixels at float32 is 460 MB per band before any intermediate array. The 1,840 MB row exceeds the 1,536 MB ceiling of anAzure Consumption function outright.
The windowed read is not a smaller version of the full read — it is three orders of magnitude smaller, which is what puts the job inside a 1,024 MB function instead of outside a 10,240 MB one.

The Chunked I/O for Large Satellite Imagery pattern resolves this by treating the raster as a coordinate-indexed matrix and dispatching per-tile tasks via a message queue. This page covers the Sentinel-2-specific details: cross-resolution band alignment, correct block_shapes alignment, and zero-disk-staging output pipelines that prevent ephemeral storage exhaustion in AWS Lambda.

When the processing backlog grows, routing tile payloads through SQS and Pub/Sub queue routing strategies with dead-letter isolation ensures deterministic retry without losing tile coordinates on transient failures.

Chunk-to-COG alignment: the core principle

A 512×512 chunk window whose origin is a multiple of 512 maps onto exactly one internal block of a Sentinel-2 COG: one header GET, one tile GET, and a decompression cost equal to the pixels you asked for. Offset that same window by 64 pixels and it straddles four blocks in both axes, costing up to five range requests and forcing GDAL to decompress 1024×1024 pixels to hand back 512×512. The output is identical either way, which is precisely why the misalignment goes unnoticed.

Aligned versus misaligned reads against a 512-pixel COG block gridTwo panels comparing a window whose origin is a multiple of 512 with the same window offset by 64 pixels. The aligned window touches one internal block and costs two range requests; the misaligned window straddles four blocks, costs up to five range requests, and decompresses four blocks to return the same pixels.The same 512 x 512 window, moved by 64 pixelsAligned: col_off, row_off multiples of 512Window covers exactly one internal blockOne header GET plus one tile GET512 x 512 pixels fetched, 512 x 512 usedDecompression cost equals the windowMisaligned: origin offset by 64 pixelsWindow straddles four internal blocksUp to five range GETs for one window1024 x 1024 pixels fetched, 512 x 512 usedFour blocks decompressed, three quarters discardedRead src.block_shapes in the dispatcher and snap every window origin to a multiple of it — misalignment costs 4x the fetchand 4x the decompression for byte-identical output.
Both panels return identical pixels. The offset one fetches four times the bytes and decompresses four times the data to do it.

Prerequisites

Before implementing this pattern, confirm the following are in place:

  • Python 3.11+ with rasterio>=1.3.9, numpy>=1.26, boto3>=1.34 (or google-cloud-storage>=2.14 for GCP)
  • GDAL 3.7+ compiled with ENABLE_IPV6=OFF and CURL_ENABLED=YES — required for reliable /vsis3/ range requests inside Lambda VPCs
  • Environment variables set explicitly in your function configuration — never rely on ambient shell state:
    code
    GDAL_DATA=/opt/gdal/share/gdal
    PROJ_LIB=/opt/share/proj
    GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES
    GDAL_HTTP_MULTIPLEX=YES
    CPL_VSIL_CURL_CACHE_SIZE=200000000
    AWS_DEFAULT_REGION=eu-central-1
    
  • IAM permissions scoped to the exact S3 prefixes involved: s3:GetObject on the source Sentinel-2 bucket prefix, s3:PutObject on the destination prefix. Broad s3:* grants violate the least-privilege model described in IAM Security Boundaries for Cloud GIS
  • Source data in COG format: verify with gdal_info -json <path> | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['metadata'].get('LAYOUT','not-COG'))" — value should be COG
  • Lambda memory allocation: 1024 MB minimum for 512×512 windows across four 10m bands; 2048 MB for NDWI/SAVI indices that create additional float32 intermediates

Implementation

The function below handles the full Sentinel-2 multi-band use case: it queries native block geometry, aligns windows, reads cross-resolution bands using out_shape resampling, computes NDVI, and streams the output GeoTIFF directly to S3 via io.BytesIO — no /tmp write, no intermediate disk staging.

The four steps of a Sentinel-2 multi-band chunk workerFour ordered steps: query the COG block geometry once in the dispatcher, anchor the window to the 10m band grid and resample 20m and 60m bands into the same footprint, read only the bands the index needs with a windowed call, and serialise the result into a memory buffer for direct upload.One tile, four steps, no disk1Query block geometryOnce per scene, in the dispatcher — not per worker invocationsrc.block_shapes2Anchor the window to the 10m grid20m and 60m bands read through the same Window with out_shape upsamplingResampling.bilinear3Read only the bands the index needsTwo bands for NDVI, never a bare src.read() across all twelvesrc.read(band, window=win)4Stream the tile straight to object storageGeoTIFF serialised into RAM, uploaded in one call, no /tmp writeio.BytesIO -> put_object
Nothing in this sequence touches the filesystem. The step that most often gets skipped is the first — and skipping it makes every later step misaligned.

Read the code against those four steps: get_chunk_size is step one and belongs in the dispatcher, read_band_aligned carries steps two and three, and the io.BytesIO block at the end is step four.

python
import io
import os
import logging
import numpy as np
import rasterio
from rasterio.windows import Window
from rasterio.enums import Resampling
import boto3

# ---------------------------------------------------------------------------
# Explicit environment — do not rely on shell inheritance in Lambda
# ---------------------------------------------------------------------------
os.environ.setdefault("GDAL_DATA", "/opt/gdal/share/gdal")
os.environ.setdefault("PROJ_LIB", "/opt/share/proj")
os.environ.setdefault("GDAL_HTTP_MERGE_CONSECUTIVE_RANGES", "YES")
os.environ.setdefault("GDAL_HTTP_MULTIPLEX", "YES")
# 200 MB in-process COG tile cache — keeps decompressed blocks for the window
os.environ.setdefault("CPL_VSIL_CURL_CACHE_SIZE", "200000000")

# Sentinel-2 10m band indices in a standard COG (B02=1, B03=2, B04=3, B08=4)
BAND_BLUE  = 1   # B02 10m
BAND_GREEN = 2   # B03 10m
BAND_RED   = 3   # B04 10m
BAND_NIR   = 4   # B08 10m

CHUNK_SIZE = 512  # Must match COG internal block size — confirmed via block_shapes

logger = logging.getLogger(__name__)
s3 = boto3.client("s3")


def get_chunk_size(bucket: str, key: str) -> int:
    """
    Query the COG's native block size and return the safe CHUNK_SIZE.
    Falls back to 512 if block_shapes is not available (non-tiled GeoTIFF).
    Call this once in the dispatcher, not per worker invocation.
    """
    url = f"/vsis3/{bucket}/{key}"
    with rasterio.open(url) as src:
        shapes = src.block_shapes  # list of (rows, cols) per band
        if shapes:
            row_block, col_block = shapes[0]
            logger.info("Native COG block size: %d×%d", row_block, col_block)
            return col_block  # typically 256 or 512 for Sentinel-2 COGs
    return 512


def read_band_aligned(
    src: rasterio.DatasetReader,
    band_index: int,
    window: Window,
    target_size: int,
    use_bilinear: bool = True,
) -> np.ndarray:
    """
    Read a single band into a (target_size, target_size) array.
    Works for 10m bands (native) and 20m/60m bands (resampled in-memory).
    Only the compressed blocks intersecting `window` are fetched.
    """
    resampling = Resampling.bilinear if use_bilinear else Resampling.nearest
    return src.read(
        band_index,
        window=window,
        out_shape=(target_size, target_size),
        resampling=resampling,
        masked=False,
    ).astype(np.float32)


def process_sentinel2_chunk(
    bucket: str,
    key_10m: str,   # COG containing 10m bands (B02, B03, B04, B08)
    tile_x: int,
    tile_y: int,
    output_prefix: str = "processed",
) -> str:
    """
    Read a single CHUNK_SIZE×CHUNK_SIZE tile from a Sentinel-2 10m COG,
    compute NDVI, and upload the result to S3 without writing to /tmp.

    Returns the S3 key of the written output tile.
    """
    col_off = tile_x * CHUNK_SIZE
    row_off = tile_y * CHUNK_SIZE
    window = Window(col_off, row_off, CHUNK_SIZE, CHUNK_SIZE)

    output_key = (
        f"{output_prefix}/"
        f"{key_10m.rsplit('/', 1)[-1].replace('.tif', '')}"
        f"_x{tile_x:04d}_y{tile_y:04d}_ndvi.tif"
    )

    url = f"/vsis3/{bucket}/{key_10m}"

    with rasterio.open(url) as src:
        # ------------------------------------------------------------------
        # Guard: skip tiles that fall entirely outside the scene footprint.
        # Sentinel-2 granules are not rectangular — edge tiles are nodata.
        # ------------------------------------------------------------------
        actual_w = min(CHUNK_SIZE, src.width  - col_off)
        actual_h = min(CHUNK_SIZE, src.height - row_off)
        if actual_w <= 0 or actual_h <= 0:
            logger.info("Tile (%d, %d) outside scene extent — skipping", tile_x, tile_y)
            return ""

        # Lazy, windowed reads — only bands needed for NDVI
        red = read_band_aligned(src, BAND_RED, window, CHUNK_SIZE)
        nir = read_band_aligned(src, BAND_NIR, window, CHUNK_SIZE)

        # Safe NDVI: suppress divide-by-zero on nodata / water / cloud pixels
        with np.errstate(divide="ignore", invalid="ignore"):
            ndvi = np.where(
                (red + nir) == 0,
                np.float32(-9999),  # nodata sentinel — not 0, which is a valid NDVI
                (nir - red) / (nir + red),
            )

        # ------------------------------------------------------------------
        # Stream output directly to S3 — no /tmp write.
        # io.BytesIO stays in Lambda process memory; typical tile is ~1 MB.
        # ------------------------------------------------------------------
        out_transform = src.window_transform(window)
        out_profile = {
            "driver": "GTiff",
            "dtype": "float32",
            "width": CHUNK_SIZE,
            "height": CHUNK_SIZE,
            "count": 1,
            "crs": src.crs,
            "transform": out_transform,
            "nodata": -9999.0,
            "compress": "deflate",
            "predictor": 3,    # horizontal differencing for float data
            "tiled": True,
            "blockxsize": 256,
            "blockysize": 256,
            "interleave": "band",
        }

        with io.BytesIO() as buf:
            with rasterio.open(buf, "w", **out_profile) as dst:
                dst.write(ndvi, 1)
            buf.seek(0)
            s3.put_object(
                Bucket=bucket,
                Key=output_key,
                Body=buf,
                ContentType="image/tiff",
                StorageClass="INTELLIGENT_TIERING",
            )

    logger.info("Written: s3://%s/%s", bucket, output_key)
    return output_key


def lambda_handler(event: dict, context) -> dict:
    """
    SQS-triggered entry point. Each message carries one tile coordinate.
    Dead-letter routing is handled at the queue level after maxReceiveCount
    retries — no manual DLQ logic required here.
    """
    results = []
    for record in event.get("Records", []):
        import json
        payload = json.loads(record["body"])
        output_key = process_sentinel2_chunk(
            bucket=payload["bucket"],
            key_10m=payload["key"],
            tile_x=payload["tile_x"],
            tile_y=payload["tile_y"],
            output_prefix=payload.get("output_prefix", "processed"),
        )
        results.append(output_key)

    return {"statusCode": 200, "processed": results}

Verification

Run the following against a known Sentinel-2 COG tile to confirm block alignment, correct NDVI range, and the absence of /tmp writes:

python
import rasterio
import boto3
import json

BUCKET = "your-sentinel2-bucket"
KEY    = "S2A_MSIL2A_20240910T100601_N0511_R122_T32UND_20240910T163845_10m.tif"

# 1. Confirm COG block geometry matches CHUNK_SIZE
with rasterio.open(f"/vsis3/{BUCKET}/{KEY}") as src:
    shapes = src.block_shapes
    print("block_shapes:", shapes)          # expect [(512, 512), ...]
    print("band_count:", src.count)         # expect 4 for a standard 10m COG
    print("dtype:", src.dtypes[0])          # expect uint16 before float32 cast
    print("crs:", src.crs.to_epsg())        # expect 32632 for UTM zone 32N

# 2. Process tile (0, 0) and verify the output exists in S3
from optimizing_chunked_io import process_sentinel2_chunk
result_key = process_sentinel2_chunk(BUCKET, KEY, tile_x=0, tile_y=0)
print("Output key:", result_key)

s3 = boto3.client("s3")
head = s3.head_object(Bucket=BUCKET, Key=result_key)
print("Output size (bytes):", head["ContentLength"])   # typically 200 KB–1.5 MB
print("Content-Type:", head["ContentType"])            # image/tiff

# 3. Spot-check NDVI range
with rasterio.open(f"/vsis3/{BUCKET}/{result_key}") as out:
    ndvi_tile = out.read(1)
    valid = ndvi_tile[ndvi_tile != -9999.0]
    print(f"NDVI min={valid.min():.3f} max={valid.max():.3f} mean={valid.mean():.3f}")
    # Expected: min near -0.3 (bare soil / water), max near 0.85 (dense vegetation)

Expected output for a vegetated summer scene:

code
block_shapes: [(512, 512), (512, 512), (512, 512), (512, 512)]
band_count: 4
dtype: uint16
crs: 32632
Output key: processed/S2A_MSIL2A_..._x0000_y0000_ndvi.tif
Output size (bytes): 418720
Content-Type: image/tiff
NDVI min=-0.182 max=0.847 mean=0.421

Gotchas and Edge Cases

  • Non-square COG blocks in older Sentinel-2 archives. Pre-2021 L2A COGs distributed via the Copernicus Open Access Hub may use 256×256 internal tiles while newer ESA CDSE-sourced assets use 512×512. Always call src.block_shapes in the dispatcher rather than hardcoding CHUNK_SIZE = 512 in the Lambda environment variable. Mismatches cause 4× fetch overhead on every read.

  • Band index differences between 10m and 20m COGs. ESA distributes separate COG files per resolution tier. The 10m file contains B02 (index 1), B03 (index 2), B04 (index 3), B08 (index 4). The 20m file contains B05, B06, B07, B8A, B11, B12 at indices 1–6. Passing a 20m band index to a 10m COG raises rasterio.errors.WindowError: band out of range. Validate src.count against the expected band list before dispatching tasks.

  • predictor=3 requires float data. The DEFLATE predictor type 3 (horizontal differencing for floating-point) only applies to float32/float64 output. Writing integer or uint16 data with predictor=3 corrupts the output silently on some GDAL versions. Use predictor=2 (integer horizontal differencing) for integer bands and predictor=3 exclusively for float32 NDVI/NDWI outputs.

  • Lambda cold starts extend HTTP handshake time for the first VFS range request. The reducing Python GDAL cold starts with provisioned concurrency pattern applies here: enabling provisioned concurrency for the dispatcher function (not the per-tile workers) eliminates the GDAL driver registration delay on first invocation, keeping P99 latency predictable under burst load.

Frequently Asked Questions

What chunk size should I use for Sentinel-2 COG processing?

Use 512×512 for 10m bands and 256×256 for 20m/60m bands on AWS Lambda. Both match the internal tile size used by the Sentinel-2 L2A COG distribution on ESA CDSE, which eliminates partial-block HTTP fetches. Confirm the actual block shape with rasterio.open(path).block_shapes before hardcoding.

How do I align 20m and 60m Sentinel-2 bands with 10m bands in a serverless chunk?

Anchor your Window coordinates to the 10m pixel grid. When reading a 20m or 60m band, pass the same Window plus out_shape=(1, CHUNK_SIZE, CHUNK_SIZE) and resampling=Resampling.bilinear. rasterio fetches only the compressed blocks intersecting that window, upsamples in-memory, and discards the extra data — peak memory stays proportional to CHUNK_SIZE², not the full scene.

Why is writing to /tmp before uploading slower than streaming to S3?

AWS Lambda /tmp is backed by ephemeral SSD but adds a second sequential write–read cycle. Using io.BytesIO with rasterio.MemoryFile eliminates the disk round-trip: the GeoTIFF is serialised directly into RAM, then uploaded with a single put_object call. This halves wall-clock time for small tiles and avoids the storage exhaustion failure mode described in managing /tmp storage limits for GeoTIFF extraction when many tiles run in parallel.


Related

Back to Chunked I/O for Large Satellite Imagery