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)
Three grids of the same 47 by 34 GOES-18 pixels around the Park Fire at 14:31 Pacific on 25 July 2024: the 3.9 micrometre brightness temperature, with an arc of hot pixels up to about 411 kelvin against a background near 315; the difference between 3.9 and 11.2 micrometres, where the same arc stands out at 40 to 90 kelvin against a dark background; and the 52 pixels flagged as fire, which match the arc. Three grids of the same 47 by 34 GOES-18 pixels around the Park Fire at 14:31 Pacific on 25 July 2024: the 3.9 micrometre brightness temperature, with an arc of hot pixels up to about 411 kelvin against a background near 315; the difference between 3.9 and 11.2 micrometres, where the same arc stands out at 40 to 90 kelvin against a dark background; and the 52 pixels flagged as fire, which match the arc.
What the algorithm sees and what it decides. The fire's arc stands out most clearly in the difference between the two bands.

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
Two histograms of 3.9 micrometre brightness temperature on a log scale: in the afternoon the background spreads from about 295 to 340 kelvin and the 52 fire pixels range from 340 to 411; at night the background is narrower, about 280 to 320, and the 28 fire pixels range from 320 to 378. Two histograms of 3.9 micrometre brightness temperature on a log scale: in the afternoon the background spreads from about 295 to 340 kelvin and the 52 fire pixels range from 340 to 411; at night the background is narrower, about 280 to 320, and the 28 fire pixels range from 320 to 378.
By day the background is warmer and wider, so a fire has to be hotter to stand out.

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.

How a satellite fire detection algorithm decides that a pixel is on fire. from radiance to a fire pixel1measure 3.9 µm and 11.2 µmevery pixel2candidate: warm at 3.9 µmfire 376 K, ground 315 K3contextual test against neighboursnot a fixed threshold4confirm with 3.9 − 11.2 µmfire 59 K, background 6 K5reject glint, cloud, hot surfacesmasks and the 11 µm band6fire pixel: category, FRP, sub-pixel fit52 pixels hereGOES-18 over the Park Fire, 25 July 2024, 14:31 Pacific spatialworkflow.io
How a fire pixel is decided.

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

Why satellite fire detection misses fires or flags non-fires. what detection misses, and whyWhat you seeWhysmall fire not detectedtoo little of a 2 × 3 km pixel ishotfewer detections by daysunlight lifts and spreads the 3.9µm backgroundfire vanishes for hourscloud or thick smoke blocksinfraredfire pixel with no FRPsaturated at about 411 KGOES-18 ABI, July 2024 spatialworkflow.io
What detection misses, and why.
  • 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.

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.