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 approaching the limit on GCP Cloud Functions 1st generation. Naive multi-band loads also force the GDAL driver to decompress every internal COG tile in the scene, multiplying CPU overhead proportionally.

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

The diagram below shows how a 512×512 chunk window maps to the internal 512×512 block structure of a Sentinel-2 COG. An aligned read fetches exactly one HTTP range request per block; a misaligned read crosses boundaries and forces two fetches plus partial decompression of adjacent blocks.

COG Block Alignment for Sentinel-2 Chunked Reads Left panel shows a 512×512 chunk window perfectly aligned to the COG internal 512×512 block boundaries, requiring one HTTP range request. Right panel shows a misaligned 512×512 window that crosses four block boundaries, requiring four HTTP range requests and partial decompression of each block. block 0,0 Aligned (1–2 HTTP requests) chunk boundary = block boundary Misaligned (4 HTTP requests) chunk crosses 4 blocks — 4× fetches + decompression partial partial partial partial

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.

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