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.

Four ways to convert Landsat surface temperature digital numbers and the median value each gives for Madrid in July. one array, four conversionsconversionmedianraw DN49,522scale only, then βˆ’273.15βˆ’103.9 Β°Cscale and offset (Kelvin)318.3scale, offset, βˆ’273.1545.1 Β°CLC08 29 July 2023; STAC: scale 0.00341802, offset 149.0, nodata 0 spatialworkflow.io
One array, four conversions, one right answer.

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.

Mean temperature of a Landsat scene corner with and without masking the zero fill value. mean of a scene corner, 46% fillfill scaled as dataβˆ’33.6 Β°Cfill masked first43.2 Β°C02550Bar length is magnitude; the unmasked mean is below freezing spatialworkflow.io
Fill pixels scaled as data: a cold bias from nowhere.

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.

The MODIS and Landsat surface temperature conversions side by side, annotated. each product its own constantslandsat_k = dn * 0.00341802 + 149.0modis_k = dn * 0.02x = dn.astype("float64")β‘ β‘‘β‘’β‘ Landsat C2: offset tooβ‘‘MODIS: 15,800 β†’ 42.9 Β°Cβ‘’uint16 Γ— 3 wraps at 65,535Landsat constants on MODIS data: βˆ’70.1 Β°C spatialworkflow.io
Read constants from each product’s metadata, and cast to float before multiplying.

Collection 1, rioxarray and ECOSTRESS

  • Collection 1 Landsat products used different scales; do not mix documentation between collections.
  • Rioxarray with mask_and_scale=True applies 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.

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.