Aligning Raster Tiles with Vector Masks for LLM Context

Read the right pixels for a shape that spans several tiles — matching grids, rasterizing the mask, and refusing to resample categorical data to force a fit.

A shape rarely sits inside one tile, and the tiles it spans do not always share a grid. Getting the alignment right is what separates a statistic that describes the shape from one that describes a rectangle near it, and the failure produces no error at any point. This guide handles the alignment and the reading, as the mechanical core of vector-raster hybrid processing.

When to Use This Approach

Use it whenever a mask spans more than one file, or whenever the mask and the raster arrive in different frames — which together cover almost every real request.

Situation Handling Cost
One tile, same frame Read the window, apply the mask Trivial
Several tiles, aligned grids Read each, concatenate values Cheap
Several tiles, unaligned grids Report per tile, or resample deliberately Real
Mask in a different frame Reproject the mask, never the raster Cheap
Categorical data needing resampling Nearest neighbour, and say so Lossy

The third row is where judgement is required. Unaligned grids mean the pixels are not comparable, and pretending otherwise by resampling one to the other is a decision with consequences that should be recorded rather than absorbed.

Aligned and offset grids across a tile boundaryTwo tiles whose grid origins differ by half a cell cannot have their pixels concatenated, because a value from one does not describe the same ground as a value from the other.alignedvalues are directly comparableoffsethalf a cell apart — not the same groundTest the origins before concatenating anything
Half a cell is enough to break it. Concatenated values from offset grids produce a statistic whose denominator is right and whose numerator counts the wrong ground, with nothing to indicate that anything happened.

Implementation

The reader checks alignment, rasterizes the mask per tile, and accumulates values with their coverage.

import logging
from dataclasses import dataclass
from typing import Iterable, Sequence

import numpy as np

log = logging.getLogger("tile_alignment")


@dataclass(frozen=True)
class TileGrid:
    epsg: int
    pixel_size_m: float
    origin: tuple[float, float]
    nodata: float | None


class Unalignable(ValueError):
    """The tiles cannot be combined without a decision the caller must make."""


def grids_align(a: TileGrid, b: TileGrid, tolerance: float = 1e-3) -> bool:
    """Same frame, same pixel size, origins offset by a whole number of cells."""
    if a.epsg != b.epsg:
        return False
    if abs(a.pixel_size_m - b.pixel_size_m) > 1e-6 * a.pixel_size_m:
        return False
    dx = (a.origin[0] - b.origin[0]) / a.pixel_size_m
    dy = (a.origin[1] - b.origin[1]) / a.pixel_size_m
    return abs(dx - round(dx)) < tolerance and abs(dy - round(dy)) < tolerance


def read_masked(datasets: Sequence, mask, mask_epsg: int,
                rasterize) -> tuple[np.ndarray, float]:
    """Read every pixel of the mask across the tiles. Raises if grids disagree."""
    if not datasets:
        raise Unalignable("no tiles cover the mask")

    grids = [TileGrid(d.crs.to_epsg(), float(d.res[0]), (d.transform.c, d.transform.f),
                      d.nodata) for d in datasets]
    reference = grids[0]
    for other in grids[1:]:
        if not grids_align(reference, other):
            raise Unalignable(
                f"tiles do not share a grid ({reference.epsg}/{reference.pixel_size_m:g} m "
                f"against {other.epsg}/{other.pixel_size_m:g} m) — resample deliberately "
                "or report per tile")

    projected = mask if mask_epsg == reference.epsg else _reproject(mask, mask_epsg,
                                                                   reference.epsg)
    collected, inside_total = [], 0
    for dataset, grid in zip(datasets, grids):
        try:
            window = dataset.window(*projected.bounds)
            block = dataset.read(1, window=window, masked=True)
            inside = rasterize(projected, dataset, window)      # True where the mask covers
        except Exception as exc:
            log.warning("tile read failed, skipping it: %s", exc)
            continue
        values = block[inside]
        inside_total += int(inside.sum())
        usable = values.compressed() if hasattr(values, "compressed") else values
        if grid.nodata is not None:
            usable = usable[usable != grid.nodata]
        collected.append(usable)

    if not collected or inside_total == 0:
        raise Unalignable("the mask selected no pixels in any tile")
    stacked = np.concatenate(collected)
    return stacked, float(stacked.size) / float(inside_total)

Raising rather than resampling when grids disagree is the decision this whole guide turns on. Resampling is sometimes the right answer and is never one a reader should discover by accident, so the function refuses and names the alternatives; the caller decides, and records the decision.

Skipping a tile that fails to read, rather than aborting, is the opposite instinct and is also right: a mask spanning six tiles where one is corrupt still has five tiles of real data, and the coverage fraction reports the loss. What must not happen is the loss being invisible, which is why coverage is a return value rather than a log line.

A mask spanning four tiles, one of them unreadableThree tiles contribute pixels and one fails, so the statistic is computed from three quarters of the shape and the coverage fraction reports exactly that.unreadablethree tiles contribute pixelsthe statistic is still computedcoverage: 74% of the shapereported, not swallowed
Partial data with a stated fraction beats no data. The failure mode to avoid is the third option — a statistic computed from three quarters of a shape and presented as though it described all of it.

Validation & Testing

def test_offset_grids_are_refused():
    a = TileGrid(27700, 10.0, (0.0, 0.0), None)
    b = TileGrid(27700, 10.0, (5.0, 0.0), None)      # half a cell
    assert not grids_align(a, b)


def test_whole_cell_offsets_are_accepted():
    a = TileGrid(27700, 10.0, (0.0, 0.0), None)
    b = TileGrid(27700, 10.0, (30.0, -20.0), None)
    assert grids_align(a, b)


def test_mixed_frames_raise_rather_than_reprojecting_the_raster():
    try:
        read_masked([TILE_27700, TILE_4326], MASK, 27700, rasterize)
    except Unalignable as exc:
        assert "do not share a grid" in str(exc)
        return
    raise AssertionError("mixed frames must be refused, not silently resampled")


def test_unreadable_tile_reduces_coverage_without_aborting():
    values, coverage = read_masked([GOOD, GOOD, BROKEN], MASK, 27700, rasterize)
    assert values.size > 0 and coverage < 1.0


def test_empty_selection_raises():
    try:
        read_masked([TILE_ELSEWHERE], MASK, 27700, rasterize)
    except Unalignable:
        return
    raise AssertionError("a mask selecting no pixels must not return an empty statistic")

The third test is the one that will be argued about, and it is worth keeping. Resampling on the caller’s behalf is convenient, it works, and it silently changes categorical values into interpolated ones that correspond to no class — which is a corruption rather than a loss.

Build the fixtures from real tile metadata rather than from constructed grids. Origin values in production data carry the floating-point residue of whatever produced them, and a tolerance that looks generous against clean numbers can be too tight against real ones — which presents as a refusal to combine tiles that every other tool combines happily.

Gotchas & Edge Cases

Reprojecting the raster to match the mask. Resamples every pixel and, for categorical data, invents values. Move the vector; it is cheap and lossless.

Window computed from the mask’s bounds in the wrong frame. Produces a window somewhere else entirely and usually reads nothing, which presents as “no data for this shape”. Reproject the mask before computing the window, not after.

Coverage computed against the window rather than the mask. The window is a rectangle and the mask is not, so dividing by the window’s pixel count understates coverage badly for irregular shapes. Divide by the pixels inside the mask.

Tiles with different nodata values. Concatenating values from tiles whose nodata conventions differ leaves one tile’s nodata counted as data. Read each tile’s own nodata from its metadata rather than assuming a shared constant.

Coverage measured against the window rather than the maskDividing usable pixels by the rectangular read window rather than by the pixels inside the mask understates coverage badly for an irregular shape.green: the mask · amber: the rest of the windowdivide by the window: 58% coveragedivide by the mask: 96% coverage
The denominator decides the message. One of these reads as a serious data gap and the other as a complete measurement, and only the second describes the shape the question was about.

Overlapping tiles double-counting. Many tile schemes overlap by a few pixels at the edges, so a mask spanning a boundary counts those pixels twice. Deduplicate by position, or clip each tile’s window to its exclusive extent.

A mask spanning tiles from two product versions. They can share a grid and disagree about what a class code means, so concatenation produces proportions that are arithmetically clean and semantically mixed. Check the product version alongside the grid, and treat a mismatch exactly as you would treat a grid mismatch — as a refusal rather than as something to average over.

Alignment tolerance too tight. Origins stored as floating-point values accumulate tiny errors, and an exact comparison rejects grids that are aligned in every practical sense. A thousandth of a cell is a workable tolerance and a whole cell is not.

Frequently Asked Questions

When is resampling actually the right answer?

When the analysis genuinely needs a single combined array and the data is continuous — elevation, temperature, reflectance — where interpolation produces meaningful values. For categorical data it is only acceptable with nearest-neighbour selection, which preserves class validity at the cost of shifting boundaries by up to half a cell. Either way it belongs in a documented preparation step that produces a new dataset, not inside a request.

Should statistics be computed per tile and combined, or over concatenated values?

Over concatenated values when the grids align, because combining per-tile proportions requires weighting by pixel count and is easy to get subtly wrong. When the grids do not align, per-tile reporting is the honest fallback: three statements with their own extents say more than one statement whose provenance is a mixture.

How should very large masks be handled?

By reading in blocks and accumulating counts rather than values. A national-scale mask over fine imagery will not fit in memory as an array, and the summary only needs counts per class, so a streaming accumulator over windowed reads gives the same answer at constant memory. The coverage fraction accumulates the same way.

What if only some tiles have the class scheme you expect?

Treat it as an alignment failure of a different kind and refuse. Tiles from different product versions can share a grid and disagree about what code seven means, and concatenating them produces proportions that are arithmetically clean and semantically meaningless. Check the scheme identifier alongside the grid, and fail loudly when it varies.

Where should the alignment check live?

In the reader, before any pixel is fetched, so a misaligned set costs one comparison rather than a full read followed by a discovery. Putting it in the caller means every caller has to remember, and one of them will not — usually the batch job that processes a thousand shapes overnight and produces a thousand quietly wrong statistics.