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.

The two payloads in a radiometric thermal file A file split into two streams. The visible JPEG preview is eight-bit, palette-applied and auto-scaled per frame, marked as unusable for measurement. The raw thermal stream is sixteen-bit sensor output, accompanied by Planck calibration constants, emissivity and reflected temperature in the metadata, and is marked as the measurement. Beneath, two frames of the same ground are shown with different palettes stretched over different ranges, producing different display values for identical ground. A note states that the clipping at each end has discarded information no correction recovers. visible JPEG preview 8-bit · palette applied auto-scaled per frame not a measurement raw thermal stream 16-bit sensor output + Planck constants, emissivity this is the measurement frame 1 palette: 12 – 28 °C frame 2 palette: 12 – 74 °C same ground: 142 same ground: 37 Identical ground, different display values, no recoverable relationship. The clipping at each end has discarded what a correction would need.

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.

What a radiometric JPEG carries beyond its visible image Three rows. The embedded raw thermal data holds per-pixel sensor counts at the detector's native resolution, which is what any temperature computation must start from rather than from the rendered colour image. The calibration parameters — Planck constants, and the reflected apparent temperature, emissivity, distance and humidity assumed at capture — are what convert counts to temperature, and changing any of them after the fact changes every derived value. The rendered image itself is a colour-mapped view built with one particular palette and range, and measuring from it discards most of the dynamic range the sensor recorded. embedded raw thermal data per-pixel counts at native resolution — the only valid starting point calibration parameters Planck constants plus the assumed emissivity, distance and humidity the rendered image one palette over one range — measuring from it discards the dynamic range Extract the raw block. A pipeline reading the visible JPEG is measuring a picture of a measurement.

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.

Display value against true temperature for an auto-scaled palette A curve mapping true temperature to eight-bit display value for a frame whose palette was auto-scaled from twelve to twenty-eight degrees. Below twelve degrees every temperature maps to zero, and above twenty-eight every temperature maps to two hundred and fifty-five, so both ends are clipped. Between them the mapping is monotonic but not linear, following the palette's own transfer curve. A note states that the clipped regions cannot be recovered and that the curve differs for every frame. 0 °C 12 20 28 45 °C true surface temperature display value clipped to 0 clipped to 255 Non-linear between the limits, clipped outside them, and a different curve for every frame.

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.

Thermal Orthomosaic Processing in Python