Active Fire Detection From Satellites Explained
On this page
Problem statement
Fire points from GOES, VIIRS and MODIS appear on every wildfire map, and they are easy to treat as observations of flames. They are the output of an algorithm. A satellite measures infrared radiance in a few wavelength bands, and the algorithm decides, pixel by pixel, whether something in that pixel is much hotter than its surroundings. Knowing how that decision is made explains what fire detections can and cannot tell you: why they come in pixel-sized cells, why a small fire is missed, why daytime detection is harder than night, and where fire radiative power comes from.
This page opens up one GOES-18 scan of the 2024 Park Fire, in the afternoon and at night. It compares the brightness temperatures the satellite measured with the fire pixels the algorithm flagged.
Quick answer
A fire pixel stands out at 3.9 µm, where hot fires emit strongly, far more than at 11 µm, where the ground and the fire look more alike. Compare the two bands for fire and non-fire pixels:
for label, s in SCENES.items():
fire, t7, dt = s["fire"], s["bt7"], s["bt7"] - s["bt14"]
print(f"{label}: {fire.sum()} fire pixels | 3.9 µm: fire {np.median(t7[fire]):.0f} K, background {np.median(t7[~fire]):.0f} K | "
f"3.9 − 11.2 µm: fire {np.median(dt[fire]):.0f} K, background {np.median(dt[~fire]):.0f} K (99th percentile {np.percentile(dt[~fire], 99):.0f} K)")
afternoon: 52 fire pixels | 3.9 µm: fire 376 K, background 315 K | 3.9 − 11.2 µm: fire 59 K, background 6 K (99th percentile 21 K)
night: 28 fire pixels | 3.9 µm: fire 332 K, background 292 K | 3.9 − 11.2 µm: fire 34 K, background 0 K (99th percentile 14 K)
The 3.9 micron band, contextual tests, day and night
1. Why 3.9 µm
Planck's law makes radiance at short infrared wavelengths rise steeply with temperature. Compare a fire at 700 K with ground at 300 K at both wavelengths:
from scipy.constants import c, h, k
def planck(wl_um, t_k):
wl = wl_um * 1e-6
return 2 * h * c ** 2 / wl ** 5 / (np.exp(h * c / (wl * k * t_k)) - 1)
for wl in (3.9, 11.2):
print(f"{wl} µm: radiance at 700 K is {planck(wl, 700) / planck(wl, 300):,.0f} times that at 300 K")
3.9 µm: radiance at 700 K is 1,132 times that at 300 K
11.2 µm: radiance at 700 K is 14 times that at 300 K
The fire's advantage over the ground is about 80 times larger at 3.9 µm than at 11.2 µm. So even a fire covering a small fraction of a pixel lifts its 3.9 µm brightness temperature by tens of kelvin, and its 11 µm temperature by much less.
2. Contextual tests
A fixed threshold fails: sunlit rock or a hot car park can reach the temperatures of a cool fire, and a fire on a cold night may not reach them. Fire algorithms compare each pixel with the statistics of its neighbourhood, and confirm with the difference between the two bands. The background here sits at a few kelvin of 3.9 − 11.2 µm difference; the fire pixels sit tens of kelvin above it.
3. Day versus night
In daylight, 3.9 µm also carries reflected sunlight, which warms the whole background and widens its spread. At night the background is cold and uniform, so a weaker fire stands out:
d, n = SCENES["afternoon"], SCENES["night"]
for label, s in (("afternoon", d), ("night", n)):
bg = s["bt7"][~s["fire"]]
print(f"{label:9s}: background at 3.9 µm {np.median(bg):.0f} K, spread (5th–95th percentile) {np.percentile(bg, 95) - np.percentile(bg, 5):.0f} K")
afternoon: background at 3.9 µm 315 K, spread (5th–95th percentile) 28 K
night : background at 3.9 µm 292 K, spread (5th–95th percentile) 10 K
4. Saturation
The 3.9 µm detector has a ceiling. Pixels that reach it are flagged as saturated fire, and their fire radiative power cannot be retrieved:
t7 = d["bt7"]
print(f"hottest pixel in the afternoon scan: {np.nanmax(t7):.1f} K; pixels within 1 K of it: {(t7 >= np.nanmax(t7) - 1).sum()}")
print("their fire mask category:", sorted({str(d['category'][j, i]) for j, i in zip(*np.nonzero(t7 >= np.nanmax(t7) - 1))}))
hottest pixel in the afternoon scan: 411.0 K; pixels within 1 K of it: 6
their fire mask category: ['temporally_filtered_good_fire_pixel', 'temporally_filtered_saturated_fire_pixel']
The hottest pixels are split between the saturated and the good fire categories, so read the category, not the temperature, to know which pixels have no fire radiative power.
The sub-pixel fire behind a fire pixel
The data used on this page
GOES-18 ABI Cloud and Moisture Imagery (ABI-L2-CMIPC, bands 7 at 3.9 µm and 14 at 11.2 µm, as brightness temperatures) and the Fire Detection and Characterization product (ABI-L2-FDCC) for the scans starting 21:31 UTC (14:31 Pacific) and 10:31 UTC (03:31 Pacific) on 25 July 2024, from NOAA's archive on AWS, for a box around the Park Fire. All three products share the same 2 km grid.
import re
from pathlib import Path
import numpy as np
import requests
import xarray as xr
from pyproj import Proj
B = "https://noaa-goes18.s3.amazonaws.com/"
def fetch(product, hour, minute, band=""):
text = requests.get(B, params={"list-type": 2, "prefix": f"{product}/2024/207/{hour}/"}, timeout=60).text
key = next(k for k in re.findall(r"<Key>([^<]+)</Key>", text) if f"_s2024207{hour}{minute}" in k and band in k)
f = Path("data", key.rsplit("/", 1)[1])
if not f.exists():
f.parent.mkdir(exist_ok=True)
f.write_bytes(requests.get(B + key, timeout=120).content)
return f
SCENES = {}
for label, (hour, minute) in {"afternoon": ("21", "31"), "night": ("10", "31")}.items():
with xr.open_dataset(fetch("ABI-L2-CMIPC", hour, minute, "M6C07"), engine="h5netcdf") as c7, \
xr.open_dataset(fetch("ABI-L2-CMIPC", hour, minute, "M6C14"), engine="h5netcdf") as c14, \
xr.open_dataset(fetch("ABI-L2-FDCC", hour, minute), engine="h5netcdf") as fdc:
p = fdc.goes_imager_projection
proj = Proj(proj="geos", h=p.perspective_point_height, lon_0=p.longitude_of_projection_origin, sweep=p.sweep_angle_axis,
a=p.semi_major_axis, b=p.semi_minor_axis)
x, y = fdc.x.values * p.perspective_point_height, fdc.y.values * p.perspective_point_height
bx, by = proj([-122.3, -121.3, -122.3, -121.3], [39.6, 39.6, 40.5, 40.5])
IX, IY = (x >= min(bx)) & (x <= max(bx)), (y >= min(by)) & (y <= max(by))
meanings = dict(zip(fdc.Mask.attrs["flag_values"].tolist(), fdc.Mask.attrs["flag_meanings"].split()))
mask = fdc.Mask.values[np.ix_(IY, IX)]
SCENES[label] = {"bt7": c7.CMI.values[np.ix_(IY, IX)], "bt14": c14.CMI.values[np.ix_(IY, IX)],
"fire": np.isin(mask, [c for c, n in meanings.items() if n.endswith("fire_pixel") and "no_fire" not in n]),
"category": np.vectorize(lambda v: meanings.get(int(v), "fill") if v == v else "fill")(mask),
"temp": fdc.Temp.values[np.ix_(IY, IX)], "area": fdc.Area.values[np.ix_(IY, IX)], "frp": fdc.Power.values[np.ix_(IY, IX)]}
print({k: v["bt7"].shape for k, v in SCENES.items()}, "pixels per scene")
{'afternoon': (34, 47), 'night': (34, 47)} pixels per scene
Example 1: the sub-pixel fire behind a fire pixel
For unsaturated fire pixels, the product solves for the temperature and area of a fire that, mixed with the background, would produce the measured radiances:
s = d
ok = s["fire"] & ~np.isnan(s["temp"])
print(f"{ok.sum()} of {s['fire'].sum()} afternoon fire pixels have a sub-pixel fit: fire temperature median {np.median(s['temp'][ok]):.0f} K, "
f"flaming area median {np.median(s['area'][ok]) / 1e4:.1f} ha of a pixel of several hundred hectares")
print(f"FRP of those pixels: median {np.nanmedian(s['frp'][ok]):.0f} MW, total {np.nansum(s['frp'][s['fire']]) / 1000:.1f} GW")
48 of 52 afternoon fire pixels have a sub-pixel fit: fire temperature median 657 K, flaming area median 16.8 ha of a pixel of several hundred hectares
FRP of those pixels: median 1073 MW, total 65.6 GW
A temperature anomaly test
Active fire detection is a temperature anomaly test. Fires are small and hot, and the 3.9 µm band responds disproportionately to small hot targets. That lets a satellite with 2 km pixels notice a fire covering a few hectares. Fire temperature and area cannot both be read off a single band, so the product estimates them by solving for a sub-pixel fire that matches two bands at once (the Dozier method). Fire radiative power comes from the 3.9 µm excess over the background alone and is more robust.
Everything a fire detection product reports follows from this. A pixel is the unit, so the location is a pixel centre, never an edge. The test needs contrast, so cool smouldering fires, fires under thick smoke or cloud, and small fires in daytime are missed. A threshold set too low would flag sunlit surfaces, so every product trades missed fires against false alarms. Sensors with smaller pixels, VIIRS at 375 m and MODIS at 1 km, see smaller fires but pass over only a few times a day; geostationary GOES sees every few minutes with larger pixels.
Brightness temperature, glint and latency
- Brightness temperature is the temperature a blackbody would need to produce the measured radiance; a pixel that is mostly cool ground with a little fire has a brightness temperature far below the fire's.
- Saturation differs by sensor; saturated pixels are flagged fire but carry no FRP.
- Sun glint and hot surfaces cause false alarms; products mask glint angles and use the 11 µm band to reject warm surfaces.
- Clouds hide fires completely at these wavelengths; a fire under cloud is a missing detection, not an extinguished fire.
- Latency: GOES fire detections appear within minutes; VIIRS and MODIS within a few hours of the overpass.
Internal links
- How to track a wildfire with GOES fire detections — the fire product over the whole event
- How to use NOAA HMS satellite fire points — VIIRS and GOES detections side by side
FAQ
How do satellites detect wildfires?
By looking for pixels much hotter than their surroundings in the 3.9 µm band, confirmed by a large difference from the 11 µm band. The tests are contextual: each pixel is compared with its neighbours.
Why are fires easier to detect at night?
Reflected sunlight raises the daytime background at 3.9 µm and makes it more variable, so a fire must stand out further. At night the background is cold and uniform.
What is the minimum fire size a satellite can detect?
It depends on the fire's temperature and the pixel size; a fire covering a small fraction of a pixel can be detected if hot enough, and smaller pixels detect smaller fires.
What is a saturated fire pixel?
A pixel whose 3.9 µm signal reached the detector's maximum. It is a fire, but its fire radiative power cannot be estimated.