Reading Cloud-Optimized GeoTIFFs for Impervious Surface Checks
Impervious cover limits are enforced as a percentage of lot area, which makes them a raster measurement against a vector boundary — and the naive implementation downloads a multi-gigabyte land cover raster to measure a quarter-acre lot. A cloud-optimized GeoTIFF is internally tiled and carries overviews, so a windowed read fetches only the tiles a parcel touches. This guide measures impervious cover per parcel from a remote COG, weights the boundary cells by their actual overlap rather than counting them in or out, and explains why the overviews that make the map draw quickly must never produce the number in a determination. It is the raster half of cloud-native geospatial formats for compliance pipelines.
Prerequisites
Step-by-step
Step 1: Confirm the file is genuinely cloud-optimized
A GeoTIFF served over HTTP is not a COG. Without internal tiling and overviews, every windowed read still transfers the whole file, and the pipeline appears to work while being orders of magnitude slower than it should be.
import rasterio
def cog_profile(url: str) -> dict:
with rasterio.open(url) as src:
return {
"tiled": src.profile.get("tiled", False),
"block": (src.profile.get("blockxsize"), src.profile.get("blockysize")),
"overviews": src.overviews(1),
"compression": str(src.compression),
"crs": src.crs.to_string(),
"res": src.res,
"nodata": src.nodata,
"dtype": src.dtypes[0],
}
tiled: False means stripped layout, and a strip spans the full raster width — so reading a parcel-sized window pulls the entire width of the image for every row it touches. That single flag is the difference between a fast pipeline and a slow one.
Step 2: Reproject the parcels to the raster, not the raster to the parcels
Resampling a raster to match a vector layer’s CRS resamples every cell, changes the values at every boundary, and produces a measurement against data that no longer matches its source. Transforming the parcel geometry is exact and free.
def parcels_in_raster_crs(parcels, url):
with rasterio.open(url) as src:
return parcels.to_crs(src.crs)
There is a real trade-off here that has to be stated rather than ignored: areas measured in the raster’s CRS may differ slightly from areas in the project’s working CRS. The resolution is to take the fraction from the raster and apply it to the lot area computed in the working CRS, which is what step 4 does.
Step 3: Read only the window each parcel needs
The windowed read is what makes remote access viable. Ask for the parcel’s bounding box, masked to the parcel itself.
from rasterio.mask import mask
def parcel_window(url, geom, all_touched=False):
"""Cells within the parcel, read as a single windowed request."""
with rasterio.open(url) as src:
arr, transform = mask(src, [geom], crop=True, all_touched=all_touched,
filled=True, nodata=src.nodata)
return arr[0], transform, src.nodata
For a batch, do not open the file once per parcel — group parcels by tile so that neighbouring parcels share the fetched blocks. GDAL’s block cache does this automatically within a session, so the practical rule is to sort parcels spatially before iterating, which the Hilbert ordering from converting parcel shapefiles to GeoParquet already provides.
Step 4: Weight the boundary cells by their actual overlap
A 1-metre cell straddling a parcel boundary is neither wholly in nor wholly out. Counting it in (all_touched=True) overstates the parcel; counting it out understates it — and for a quarter-acre lot the boundary cells are a substantial share of the total.
from exactextract import exact_extract
IMPERVIOUS_CLASSES = {21, 22, 23, 24} # NLCD developed classes, as an example
def impervious_fraction(url, parcels, classes=IMPERVIOUS_CLASSES):
"""Area-weighted class fractions: boundary cells count for the part inside."""
res = exact_extract(url, parcels, ["unique", "frac"], output="pandas")
out = []
for _, row in res.iterrows():
vals, fracs = row["unique"], row["frac"]
imp = sum(f for v, f in zip(vals, fracs) if int(v) in classes)
out.append(imp)
return out
The magnitude of the difference is worth internalising. On a 1-metre raster and a typical 20 by 40 metre suburban lot, about 120 of roughly 800 cells touch the boundary — so the gap between counting them all in and all out is around 15 percentage points of impervious cover, against a limit typically stated to the nearest whole percent. Exact area weighting is not a refinement here; it is the difference between a defensible number and an arbitrary one.
Step 5: Never measure from an overview
Overviews are decimated copies used for display. Reading class values from one does not average the classes — it picks a representative cell — so the impervious fraction it yields is a sample, not a measurement, and it changes with the zoom level requested.
def assert_full_resolution(url, requested_res):
with rasterio.open(url) as src:
if abs(requested_res[0] - src.res[0]) > 1e-9:
raise ValueError(
f"measurement requested at {requested_res} but the source is {src.res}; "
"overviews are for display, not for determination")
This is the mistake most likely to survive review, because an overview-derived number looks entirely reasonable — it is in the right range, it varies sensibly between parcels, and it is simply wrong by a few points in an unpredictable direction. Assert the resolution at the point of measurement and the class of error disappears.
Step 6: Map the raster’s classes to the ordinance’s definition, explicitly
The raster’s idea of impervious and the code’s are different, and the mapping is a regulatory decision. A gravel driveway, a deck, a swimming pool and a green roof are each treated differently by different ordinances, and none of those distinctions exists in a land cover product.
CLASS_MAP = {
21: {"name": "developed, open space", "impervious_share": 0.10},
22: {"name": "developed, low intensity", "impervious_share": 0.35},
23: {"name": "developed, medium intensity", "impervious_share": 0.65},
24: {"name": "developed, high intensity", "impervious_share": 0.90},
}
def modelled_impervious(class_fractions: dict) -> float:
"""The product's own class-to-impervious model, stated rather than implied."""
return sum(frac * CLASS_MAP[c]["impervious_share"]
for c, frac in class_fractions.items() if c in CLASS_MAP)
Where the ordinance’s threshold is close to the measurement, this modelling assumption dominates the uncertainty, and the correct output is a flag for site-specific measurement rather than a verdict — the same three-valued treatment used in computing building height from lidar-derived surfaces.
Verification
Check that the read was selective, that the fractions are well-formed, and that the parcels with too few cells are excluded rather than reported.
assert cog_profile(URL)["tiled"], "source is not internally tiled — reads are not windowed"
f = results["impervious_fraction"]
assert ((f >= 0) & (f <= 1)).all(), "fraction outside [0, 1] — check nodata handling"
# Parcels too small for the raster to say anything about.
cells = results["cell_count"]
too_small = cells < 30
print(f"{too_small.sum()} parcels have fewer than 30 cells — report, do not measure")
# Reconcile against a known total for a sample area.
sample_imp = (results["impervious_fraction"] * results["lot_area"]).sum()
print(f"modelled impervious area {sample_imp:,.0f} vs published {PUBLISHED_TOTAL:,.0f}")
The cell-count check matters more than it looks. On a 30-metre land cover product, a suburban lot is about one cell, and a per-parcel impervious percentage derived from one cell is not a measurement of that parcel at all — it is the value of the cell the parcel happens to sit in.
Common Pitfalls
- Assuming a GeoTIFF on HTTP is a COG. Without internal tiling, every windowed read transfers far more than it needs and the pipeline is quietly slow.
- Resampling the raster to the vector CRS. It changes every cell value; transform the parcels instead and apply the fraction to the working-CRS lot area.
- Counting boundary cells all-in or all-out. On typical lots this moves the answer by more than the precision the limit is stated to.
- Measuring from an overview. The number looks plausible and is wrong by an amount that depends on the zoom level requested.
- Ignoring nodata. Nodata cells inside a parcel reduce the denominator; treating them as pervious understates impervious cover systematically over water and cloud gaps.
- Using a 30-metre product for parcel-level determinations. The resolution is not adequate to the question, whatever the arithmetic says.
Frequently Asked Questions
What resolution is adequate?
One metre or finer for parcel-level work; a 30-metre national product is suitable for screening and for aggregate reporting only. The practical test is the cell count in the verification step — a few hundred cells per parcel gives a stable fraction, and a handful does not.
Should the impervious fraction be cached?
Yes, keyed by the parcel identifier and the raster’s content hash. Impervious cover changes when the imagery is refreshed, not when the pipeline runs, so recomputing it every run is pure cost — and caching against the raster hash means a new imagery vintage invalidates the cache automatically rather than by anyone remembering.
How do I handle parcels that span two raster tiles or two rasters?
Tiles are handled transparently by the windowed read. Two separate rasters — adjacent tiles of a tiled product — need either a VRT or a mosaic read, and the thing to check is that they share a resolution, a CRS and a classification vintage. Mixing vintages across a parcel is a real hazard on multi-year products and is invisible in the output.
Is exact extraction much slower than zonal statistics?
Marginally, and it is the wrong thing to economise on. Exact area weighting costs perhaps 20–30% more than cell-centre counting and removes the largest source of error in the whole measurement.
Can the impervious fraction feed a density or coverage rule directly?
It can feed a lot coverage check directly, since that is exactly what it measures. Density rules use dwelling units rather than surface, so the connection is indirect — though impervious cover is often a useful cross-check on a density figure, since the two disagree in ways that usually indicate a stale parcel attribute. Computing dwelling unit density per acre covers the density side.
How should the measurement be recorded?
With the raster’s identity, its resolution, the class-to-impervious model applied, the cell count and the extraction method. The class model is the part that a reviewer will question, and it is the part that cannot be reconstructed from the number alone — which is exactly what capturing CRS provenance in validation logs argues for on the vector side.
Related
Part of: Cloud-native geospatial formats for compliance pipelines
- Converting parcel shapefiles to GeoParquet for batch runs — the vector equivalent, and the spatial ordering this read benefits from.
- Computing building height from lidar-derived surfaces — the other raster measurement that meets a hard limit.
- Generating density heatmaps from parcel centroids using Rasterio — writing rasters rather than reading them.
- Chunking county-scale runs by spatial tile — grouping parcels so blocks are fetched once.
- Implementing FAR checks with Shapely and GeoPandas — the coverage rule this measurement feeds.