LST Values on the Wrong Scale
On this page
Problem statement
You open a Landsat surface temperature band and the city is 49,500 degrees. Or you apply the scale factor and it is β104 Β°C. Or you apply scale and offset and it is 318 degrees. Or the numbers look plausible but the edge of the scene is β124 Β°C and drags every mean down. Surface temperature products are stored as scaled integers, in Kelvin, with a fill value, and each of those three facts produces its own wrong answer when forgotten.
The worst version is the one that looks almost right: forgetting the offset but converting to Celsius gives numbers that are wrong by a constant, and patterns β hot here, cool there β look perfectly sensible.
Quick answer
Landsat Collection 2 Level-2 surface temperature: K = DN Γ 0.00341802 + 149.0, fill value 0. MODIS LST: K = DN Γ 0.02, fill 0. Subtract 273.15 for Celsius. Mask the fill before scaling.
import numpy as np, rasterio, pystac_client, planetary_computer
from rasterio.warp import transform_bounds
AOI = (-3.80, 40.35, -3.60, 40.50)
cat = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
it = min((i for i in cat.search(collections=["landsat-c2-l2"], bbox=list(AOI),
datetime="2023-07-01/2023-07-31").items()
if i.properties["platform"] in ("landsat-8", "landsat-9")),
key=lambda i: i.properties["eo:cloud_cover"])
with rasterio.open(planetary_computer.sign(it.assets["lwir11"].href)) as src:
win = rasterio.windows.from_bounds(*transform_bounds("EPSG:4326", src.crs, *AOI), src.transform)
dn = src.read(1, window=win)
print(f"{it.id}: dtype {src.dtypes[0]}, nodata {src.nodata}")
band = it.assets["lwir11"].extra_fields["raster:bands"][0]
print(f"STAC says: scale {band['scale']}, offset {band['offset']}, unit {band.get('unit')}, nodata {band.get('nodata')}")
right = np.where(dn == 0, np.nan, dn * 0.00341802 + 149.0) - 273.15
print(f"raw DN median {np.median(dn[dn > 0]):10.1f}")
print(f"scale only, as Β°C {np.median(dn[dn > 0]) * 0.00341802 - 273.15:10.1f}")
print(f"scale and offset, still Kelvin {np.median(dn[dn > 0]) * 0.00341802 + 149:10.1f}")
print(f"scale, offset, Celsius (correct) {np.nanmedian(right):10.1f}")
LC08_L2SP_201032_20230729_02_T1: dtype uint16, nodata 0.0
STAC says: scale 0.00341802, offset 149, unit kelvin, nodata 0
raw DN median 49522.0
scale only, as Β°C -103.9
scale and offset, still Kelvin 318.3
scale, offset, Celsius (correct) 45.1
Only the last line is a temperature anyone has felt. Every other line is a real mistake that appears in published code.
Scale and offset from metadata, fill masked first
1. Read the scale and offset from metadata
The STAC item's raster:bands carries them; the product guide documents them. Do not copy constants between products.
2. Mask the fill value first
Fill pixels (0 for Landsat ST and MODIS LST) become 149 K (β124 Β°C) after scaling. Replace them with NaN before any arithmetic.
3. Apply scale and offset
Convert to float first; integer arithmetic on uint16 overflows or truncates.
4. Convert units deliberately
Keep Kelvin for physics; convert to Celsius for reporting. Label the unit in outputs.
5. Sanity-check the range
Summer land surfaces in mid-latitudes span roughly 15β65 Β°C. Values outside that range are a scaling problem or unmasked cloud.
Unmasked fills, MODIS scales and overflow
Example 1 β what an unmasked fill does to a mean
edge = rasterio.windows.Window(0, 0, 3000, 3000) # a corner of the scene that includes fill
with rasterio.open(planetary_computer.sign(it.assets["lwir11"].href)) as src:
raw = src.read(1, window=edge).astype("float64")
naive = raw * 0.00341802 + 149.0 - 273.15
masked = np.where(raw == 0, np.nan, naive)
print(f"fill pixels: {(raw == 0).mean():.0%}")
print(f"mean without masking {naive.mean():6.1f} Β°C; with masking {np.nanmean(masked):6.1f} Β°C")
fill pixels: 46%
mean without masking -33.6 Β°C; with masking 43.2 Β°C
The corner of a Landsat scene is outside the swath and filled with zeros. Scaled without masking, they become β124 Β°C and pull the mean far down.
Example 2 β MODIS uses a different scale
mod = next(i for i in cat.search(collections=["modis-11A1-061"], bbox=list(AOI),
datetime="2023-07-15/2023-07-15").items() if ".h17v04." in i.id)
with rasterio.open(planetary_computer.sign(mod.assets["LST_Day_1km"].href)) as src:
win = rasterio.windows.from_bounds(*transform_bounds("EPSG:4326", src.crs, *AOI), src.transform)
m = src.read(1, window=win).astype("float64")
m = np.where(m == 0, np.nan, m)
print(f"{mod.id}: raw median {np.nanmedian(m):.0f}; "
f"with MODIS scale {np.nanmedian(m) * 0.02 - 273.15:.1f} Β°C; "
f"with the Landsat formula by mistake {np.nanmedian(m) * 0.00341802 + 149 - 273.15:.1f} Β°C")
MYD11A1.A2023196.h17v04.061.2023212231513: raw median 15800; with MODIS scale 42.9 Β°C; with the Landsat formula by mistake -70.1 Β°C
Here the Landsat constants on MODIS data give β70 Β°C, absurd enough to notice. The danger is the case that is not absurd: constants that happen to land in a plausible range. Read the scale and offset for each product from its own metadata.
Example 3 β integer overflow
d = dn[dn > 0][:5]
print("uint16 arithmetic:", (d * 3 + 149)[:5], "β wrapped at 65,535")
print("float arithmetic: ", (d.astype("float64") * 3 + 149)[:5])
uint16 arithmetic: [24420 24126 24015 23880 24081] β wrapped at 65,535
float arithmetic: [155492. 155198. 155087. 154952. 155153.]
NumPy keeps the uint16 type when multiplying by an integer, so large values wrap around silently. Cast to float before scaling.
Why products store scaled integers
Why products store scaled integers
A 16-bit integer takes half the space of a 32-bit float and, with a scale of 0.0034 K, still resolves temperature far better than the sensor can measure it. The offset of 149 K puts the useful range of Earth's surface temperatures into the unsigned integer range.
Why the offset error survives inspection
Forgetting the offset shifts every pixel by 149 K. Converting that to Celsius yields values that are wrong by a constant but still show hot roofs and cool parks in the right places, so maps look fine and absolute numbers are silently wrong.
Why fill must be masked before scaling
After scaling, a fill value becomes an ordinary-looking number. Masking by value only works on the raw integers.
Collection 1, rioxarray and ECOSTRESS
- Collection 1 Landsat products used different scales; do not mix documentation between collections.
- Rioxarray with
mask_and_scale=Trueapplies scale and offset from the file's tags, if present β check that they are. - GEE exports may already be scaled; do not scale twice.
- ST_QA is the uncertainty band in Kelvin with scale 0.01; not a temperature.
- ECOSTRESS and other products have their own scale conventions.
Internal links
- How to read Landsat surface temperature β the correct read
- Emissivity explained β the auxiliary layers and their scales
- Clouds show as cold spots β the other source of absurd cold values
- Units and scale factors in raster data β the general problem
- Landsat thermal data explained β asset names per platform
- Land surface temperature explained β plausible ranges
FAQ
What are the Landsat surface temperature scale factors?
Scale 0.00341802 and offset 149.0, giving Kelvin; fill value 0.
What are the MODIS LST scale factors?
Scale 0.02, no offset, giving Kelvin; fill value 0.
Why are my LST values around β124 Β°C?
Fill pixels (value 0) were scaled as data. Mask them first.
Why are my LST values around 318?
They are still in Kelvin. Subtract 273.15.
Should I use Kelvin or Celsius?
Kelvin for physical calculations; Celsius for reporting and maps.
How do I know the scale factors for a product?
From the STAC raster:bands metadata or the product's user guide.