Converting Radiometric JPEGs to Temperature Rasters
The thermal images open in any viewer and look exactly like thermal images: blue where it is cool, yellow where it is warm, a colour bar down the side. Loading one in Python gives a three-channel array of values between 0 and 255. Somebody scales those to the temperature range printed on the colour bar and the pipeline proceeds.
Every number that follows is wrong. Those values are display levels produced by applying a palette to the data, and the palette is not linear in temperature, is clipped at both ends, and was chosen per image by the camera’s auto-scaling. The actual measurement is in the same file, in a separate stream, untouched.
This page covers extracting it, converting it correctly, and writing a temperature raster that says what its values mean. It is the input stage of thermal orthomosaic processing in Python.
What is inside a radiometric JPEG
A radiometric thermal image is a container with two payloads. The visible JPEG is the colourised preview: 8-bit, palette-applied, auto-scaled per frame, and intended for a human. The raw thermal stream is the sensor’s own 16-bit output, embedded as a vendor-specific tag, from which temperature is derived through the camera’s Planck calibration constants — also in the metadata.
The auto-scaling is what makes the preview unusable even in principle. Two consecutive frames over the same ground, one of which happened to include a hot exhaust, have different palettes stretched over different ranges, so identical ground has different display values in each. There is no per-frame correction that recovers the measurement, because the clipping at both ends of the palette has discarded it.
Figure 1 — Two payloads, one of which is a picture of the measurement rather than the measurement.
Minimal reproducible solution
import io
import struct
import numpy as np
import exiftool
from PIL import Image
def extract_raw_thermal(path: str) -> dict:
"""Pull the raw thermal array and calibration constants from the file.
The raw stream is usually a 16-bit PNG or a flat TIFF depending on the
vendor, so it is decoded rather than assumed. Byte order varies too,
which is why the sanity check on the value range is worth having.
"""
with exiftool.ExifToolHelper() as et:
tags = et.get_metadata(path)[0]
blob = et.execute("-b", "-RawThermalImage", path, raw_bytes=True)
if not blob:
raise ValueError(f"{path} has no embedded raw thermal stream — "
"this is a colourised image, not a radiometric one")
raw = np.array(Image.open(io.BytesIO(blob)))
if raw.dtype != np.uint16:
raw = raw.astype(np.uint16)
# Vendors differ in byte order; a plausible sensor range is 5 000–40 000.
if np.median(raw) > 45000 or np.median(raw) < 2000:
raw = raw.byteswap()
def tag(name, default=None):
hit = next((v for k, v in tags.items() if k.endswith(name)), default)
if hit is None:
raise KeyError(f"{path}: thermal parameter {name} is absent")
return hit
return {
"raw": raw,
"planck": {"r1": float(tag("PlanckR1")), "b": float(tag("PlanckB")),
"f": float(tag("PlanckF")), "o": float(tag("PlanckO")),
"r2": float(tag("PlanckR2"))},
"emissivity": float(tag("Emissivity", 0.95)),
"reflected_temp_c": float(tag("ReflectedApparentTemperature", 20.0)),
"datetime": tag("DateTimeOriginal"),
}
The byte-order check is not paranoia. Several common cameras write big-endian raw streams that PIL decodes as little-endian, producing values in the tens of thousands that convert to temperatures of several hundred degrees — plausible enough to reach a raster and obviously wrong once seen.
Converting to temperature
import numpy as np
def raw_to_celsius(raw: np.ndarray, planck: dict, *, emissivity: float,
reflected_temp_c: float) -> np.ndarray:
"""Invert the camera's Planck calibration, with the reflected term removed.
Removing the reflected component before inversion is what makes the
emissivity correction physically meaningful: the sensor measured the sum
of what the surface emitted and what it reflected, and only the first is
a property of the surface.
"""
r1, b, f, o, r2 = (planck[k] for k in ("r1", "b", "f", "o", "r2"))
def radiance(temp_c: float) -> float:
return r1 / (r2 * (np.exp(b / (temp_c + 273.15)) - f)) - o
measured = raw.astype(np.float64)
surface = (measured - (1.0 - emissivity) * radiance(reflected_temp_c)) / emissivity
with np.errstate(invalid="ignore", divide="ignore"):
inner = r1 / (r2 * (surface + o)) + f
kelvin = b / np.log(np.maximum(inner, 1e-12))
celsius = (kelvin - 273.15).astype("float32")
return np.where(np.isfinite(celsius) & (celsius > -80) & (celsius < 600),
celsius, np.nan)
The final range filter is a cheap guard against a byte-order or calibration-constant error surviving to the raster. Nothing in a drone thermal survey is legitimately below −80 °C or above 600 °C, and a population of such values means something structural is wrong rather than that the scene is unusual.
Figure 3 — Three layers in one file, and only one of them is data.
Edge-case matrix
| Situation | Symptom | Handling |
|---|---|---|
| Colourised-only image | No raw stream | Raise; the measurement was never recorded |
| Byte order mismatch | Temperatures in the hundreds | Detect from the median and swap |
| Missing Planck constants | Conversion impossible | Raise; do not substitute another camera’s |
| Emissivity tag absent | Silent default of 0.95 | Require it explicitly, or record the assumption |
| Reflected temperature absent | Default of 20 °C assumed | Same; record what was used |
| Raw stored as TIFF not PNG | Decoder fails | Decode by content, not by assumption |
| Values outside a physical range | Plausible-looking raster | Range filter after conversion |
| Camera firmware changed mid-project | Constants differ between files | Read per file, never cache across a project |
Writing the raster
import numpy as np
import rasterio
def write_temperature_raster(celsius: np.ndarray, out_path: str, *,
transform, crs, emissivity: float,
reflected_temp_c: float, source: str) -> None:
"""A temperature raster that states its units and its assumptions."""
profile = {"driver": "GTiff", "height": celsius.shape[0],
"width": celsius.shape[1], "count": 1, "dtype": "float32",
"crs": crs, "transform": transform, "nodata": np.nan,
"compress": "deflate", "predictor": 3, "tiled": True}
with rasterio.open(out_path, "w", **profile) as dst:
dst.write(celsius.astype("float32"), 1)
dst.set_band_description(1, "surface_temperature")
dst.update_tags(1, UNITS="degrees_celsius",
EMISSIVITY=f"{emissivity:.3f}",
REFLECTED_TEMP_C=f"{reflected_temp_c:.1f}",
SOURCE=source)
Recording the emissivity and reflected temperature in the raster’s own tags is what allows a value to be re-derived later under different assumptions. A temperature raster without them is a set of numbers whose meaning depends on parameters nobody wrote down.
Figure 2 — Why the preview cannot be converted back, even with the palette range known.
Verification snippet
import numpy as np
def sanity_check_temperatures(celsius: np.ndarray, *, air_temp_c: float,
tolerance_c: float = 30.0) -> dict:
"""Are these temperatures physically plausible for this scene?"""
v = celsius[np.isfinite(celsius)]
if v.size == 0:
return {"problems": ["no finite temperatures"]}
problems = []
median = float(np.median(v))
if abs(median - air_temp_c) > tolerance_c:
problems.append(f"scene median {median:.1f} °C is {abs(median - air_temp_c):.0f} °C "
f"from the air temperature — check byte order and constants")
if float(v.max() - v.min()) > 120:
problems.append("range exceeds 120 °C — likely a conversion fault")
return {"median_c": median, "p05_c": float(np.percentile(v, 5)),
"p95_c": float(np.percentile(v, 95)), "problems": problems}
Comparing the scene median against the recorded air temperature is the single most effective check. A drone thermal scene sits within a few tens of degrees of ambient; a median thirty degrees away means the conversion is wrong, and the air temperature is a number any flight log already has.
Deciding the emissivity to use
The conversion needs one emissivity per pixel and the metadata supplies one per image, so a choice has to be made about mixed scenes.
For a field, a roof or a water body, a single value is defensible and the scene’s dominant surface decides it: vegetation and soil sit around 0.96 to 0.98, water at 0.99, concrete and asphalt at 0.92 to 0.95. For a mixed industrial site, a single value is wrong somewhere by definition, and the practical options are to accept the error on the minority surfaces or to apply a per-class emissivity from a land-cover mask.
Where the mask exists — from the classification in an RGB survey, or from a simple index threshold — per-class emissivity is a few lines and removes the largest error in the whole chain on exactly the surfaces where it was worst.
When to escalate
- The file has no raw stream. The camera was in a non-radiometric mode, and the measurement was never recorded. Re-fly; nothing in processing recovers it.
- Planck constants differ between files in one flight. A firmware update or a mixed-camera dataset. Convert per file and record which constants each used.
- Temperatures are plausible but disagree with a contact thermometer by several degrees. That is emissivity and reflected temperature rather than the conversion. Measure a reference surface and solve for the assumptions.