How to Count Buildings Inside a Wildfire Perimeter in Python
On this page
Problem statement
"How many buildings were inside the fire?" is usually the first question after a wildfire reaches a town, and a perimeter plus a building footprint layer answers it in a few lines. The answer is easy to over-read. A perimeter is the outer edge of the burn, so buildings inside it were exposed, not necessarily lost. Building footprints and damage records count different things. And the edge of a mapped perimeter is generalised, so some damage lies just outside it.
The 2025 Eaton Fire burned into Altadena, and CAL FIRE's damage inspection teams (DINS) recorded every structure they assessed. This page counts Overture building footprints inside the Eaton perimeter, then checks the count against those inspections.
Quick answer
Fetch footprints for the perimeter's bounding box, project both layers, and select with a spatial predicate:
inside = BUILDINGS[BUILDINGS.intersects(PERIM)]
print(f"{len(BUILDINGS):,} footprints in the box; {len(inside):,} intersect the Eaton perimeter, "
f"{BUILDINGS.within(PERIM).sum():,} lie wholly within it")
print(f"footprint area inside: median {inside.area.median():.0f} m², total {inside.area.sum() / 1e6:.2f} km²")
66,102 footprints in the box; 11,344 intersect the Eaton perimeter, 10,678 lie wholly within it
footprint area inside: median 160 m², total 2.05 km²
Intersects or within, exposed against damaged
1. Choose intersects or within deliberately
Buildings on the edge are partly inside. intersects counts them, within does not; the difference is the buildings straddling the line, and on a fire edge running through a neighbourhood that is hundreds. State which one you used.
2. Compare exposed with damaged
DINS records one point per structure inspected, with a damage class. Count them inside the perimeter:
DINS["inside"] = DINS.within(PERIM)
t = pd.crosstab(DINS.DAMAGE, DINS.inside.map({True: "inside", False: "outside"}))
print(t.loc[["Destroyed (>50%)", "Major (26-50%)", "Minor (10-25%)", "Affected (1-9%)", "No Damage", "Inaccessible"]].to_string())
inside_d = DINS[DINS.inside]
print(f"inside the perimeter: {inside_d.DAMAGE.str.startswith('Destroyed').mean():.0%} destroyed, {(inside_d.DAMAGE == 'No Damage').mean():.0%} undamaged")
inside inside outside
DAMAGE
Destroyed (>50%) 9266 153
Major (26-50%) 60 10
Minor (10-25%) 115 33
Affected (1-9%) 690 168
No Damage 2942 4951
Inaccessible 6 34
inside the perimeter: 71% destroyed, 22% undamaged
3. Look at damage outside the line
Some structures outside the mapped perimeter were destroyed. Measure how far:
out = DINS[~DINS.inside & DINS.DAMAGE.str.startswith("Destroyed")]
d = out.distance(PERIM.boundary)
print(f"{len(out)} destroyed structures outside the perimeter: median {d.median():.0f} m from it, {(d <= 100).mean():.0%} within 100 m, max {d.max():.0f} m")
153 destroyed structures outside the perimeter: median 10 m from it, 80% within 100 m, max 516 m
A mapped perimeter is drawn around the burned area at a working scale, and spot fires from embers land beyond the main front. A building just outside the line is not safe by definition.
4. Match records to footprints
DINS points and footprints are different units: an inspection point is one structure, a footprint is one outline, and sheds, garages and units in a building can each have their own point. Match each point to the nearest footprint within 15 m:
m = gpd.sjoin_nearest(DINS, BUILDINGS[["id", "geometry"]], how="left", max_distance=15, distance_col="m")
m = m[~m.index.duplicated()]
print(f"DINS points matched to a footprint: {m.id.notna().mean():.1%}")
destroyed_ids = set(m.loc[m.DAMAGE.str.startswith("Destroyed"), "id"].dropna())
print(f"footprints inside the perimeter: {len(inside):,}; with a destroyed structure: {inside.id.isin(destroyed_ids).sum():,} ({inside.id.isin(destroyed_ids).mean():.0%})")
print(f"destroyed DINS points inside: {(DINS.inside & DINS.DAMAGE.str.startswith('Destroyed')).sum():,}")
DINS points matched to a footprint: 98.7%
footprints inside the perimeter: 11,344; with a destroyed structure: 7,272 (64%)
destroyed DINS points inside: 9,266
The 9,266 destroyed structures fall on 7,272 footprints. Example 1 below shows why: a third of them are utility and miscellaneous structures, the category for sheds and detached garages, which share a footprint with the house or have none of their own.
What kind of structures the Eaton Fire destroyed
The data used on this page
The Eaton Fire perimeter from CAL FIRE's historic fire perimeter layer; CAL FIRE's Damage Inspection (DINS) public view for the Eaton Fire; and Overture Maps building footprints for the perimeter's bounding box, read from Overture's public bucket with DuckDB. All are projected to California Albers (EPSG:3310).
import re
from pathlib import Path
import duckdb
import geopandas as gpd
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
SERVICES = "https://services1.arcgis.com/jUJYIo9tSA7EHvfZ/arcgis/rest/services/"
per = gpd.read_file(requests.get(SERVICES + "California_Historic_Fire_Perimeters/FeatureServer/0/query",
params={"where": "FIRE_NAME = 'EATON' AND YEAR_ = 2025", "outFields": "FIRE_NAME,GIS_ACRES",
"outSR": 4326, "f": "geojson"}, timeout=120).text)
rows, offset = [], 0
while True:
page = requests.get(SERVICES + "DINS_2025_Eaton_Public_View/FeatureServer/0/query",
params={"where": "1=1", "outFields": "DAMAGE,STRUCTURETYPE", "outSR": 4326,
"resultOffset": offset, "resultRecordCount": 2000, "f": "json"}, timeout=120).json()
rows += [dict(f["attributes"], x=f["geometry"]["x"], y=f["geometry"]["y"]) for f in page["features"]]
offset += len(page["features"])
if not page.get("exceededTransferLimit"):
break
dins = pd.DataFrame(rows)
DINS = gpd.GeoDataFrame(dins, geometry=gpd.points_from_xy(dins.x, dins.y), crs=4326).to_crs(3310)
FOOT = Path("data/eaton_buildings.parquet")
if not FOOT.exists():
listing = requests.get("https://overturemaps-us-west-2.s3.amazonaws.com/", params={"list-type": "2", "prefix": "release/", "delimiter": "/"}, timeout=60).text
release = sorted(re.findall(r"<Prefix>release/([^<]+)/</Prefix>", listing))[-1]
x0, y0, x1, y1 = per.total_bounds + [-0.01, -0.01, 0.01, 0.01]
con = duckdb.connect()
con.execute("install httpfs; load httpfs; install spatial; load spatial; set s3_region = 'us-west-2'; set enable_progress_bar = false")
con.execute(f"""copy (select id, height, subtype, class, geometry
from read_parquet('s3://overturemaps-us-west-2/release/{release}/theme=buildings/type=building/*.parquet', hive_partitioning = 0)
where bbox.xmin < {x1} and bbox.xmax > {x0} and bbox.ymin < {y1} and bbox.ymax > {y0})
to '{FOOT}' (format parquet)""")
BUILDINGS = gpd.read_parquet(FOOT).to_crs(3310)
PERIM = per.to_crs(3310).geometry.make_valid().union_all()
print(f"Eaton perimeter {per.GIS_ACRES.sum():,.0f} acres; {len(DINS):,} inspected structures; {len(BUILDINGS):,} Overture footprints")
Eaton perimeter 14,056 acres; 18,428 inspected structures; 66,102 Overture footprints
Example 1: what kind of structures were lost
lost = DINS[DINS.inside & DINS.DAMAGE.str.startswith("Destroyed")]
print(lost.STRUCTURETYPE.value_counts().head(5).to_string())
STRUCTURETYPE
Single Family Residence Single Story 5084
Utility Misc Structure 3079
Single Family Residence Multi Story 831
Commercial Building Single Story 76
Multi Family Residence Multi Story 64
Trivial code, careful interpretation
Counting buildings in a perimeter is a point-in-polygon or polygon-in-polygon query, and the code is trivial. The interpretation is not. A perimeter marks where fire reached, at the scale it was mapped, and within it some buildings burn and some survive. Here more than a fifth of the inspected structures inside the line were undamaged. Building footprints and damage inspections also count differently: an inspection covers every structure, including outbuildings and separate units, while a footprint layer has one outline per mapped building. A shed or garage within 15 m of a house matches the house's outline, so several inspection points can land on one footprint.
So a perimeter count gives exposure: the number of buildings the fire reached. For losses, use damage inspection data where it exists, or before-and-after imagery where it does not. For risk analysis, exposure is the right quantity, with the definition stated.
Footprint sources, inspection coverage and perimeter versions
- Footprint sources differ: OpenStreetMap, Microsoft and Google footprints, and Overture's merge of them, give different counts in the same place; name the source and release.
- DINS coverage: inspections cover structures within and near the perimeter that teams could reach; 40 here were inaccessible.
- Perimeter version: interim perimeters change during a fire; use the final one for counts after the event.
- Units: multi-family buildings have one footprint but many homes; for households, use census or parcel data.
- Projection: measure footprint areas and distances in an equal-area or local projected CRS, not in degrees.
Internal links
- How to measure exposure in rings around a fire perimeter — the same data by distance from the edge
- How to get building footprints from OSM and Overture — the footprint sources
FAQ
How do I count buildings inside a wildfire perimeter?
Load building footprints for the area (Overture, OSM or Microsoft), project them and the perimeter to the same CRS, and select with intersects or within.
Were all buildings inside a fire perimeter destroyed?
No. In the 2025 Eaton Fire, 71% of the structures inspected inside the perimeter were destroyed and 22% were undamaged.
Where can I get wildfire building damage data?
For California, CAL FIRE publishes Damage Inspection (DINS) points for major fires as public ArcGIS services and on the state open data portal.
Why is there damage outside the fire perimeter?
Perimeters are generalised outlines, and embers start spot fires beyond the main front. Most such damage here lay within 100 m of the mapped edge.