Skip to content

26 - Thermal imaging

Every object above absolute zero emits thermal radiation. In the 8–14 µm thermal infrared, the radiance of a surface is set mainly by its temperature and emissivity:

\[L = \varepsilon\,B(\lambda, T)\]

where B is the Planck function. In a canopy, sunlit leaves, shaded leaves, soil and stems all have different temperatures, so thermal images carry a lot of spatial information. Thermal radiation is also reflected back and forth between leaves and soil, and LESS computes all of this together in the 3-D scene.

Thermal properties

Thermal properties are set separately from optical properties. Temperatures are in K:

import less

leaf_thermal = less.ThermalProperty(emissivity=0.98,
                                    temperature_sunlit=305.0, temperature_shaded=300.0)
soil_thermal = less.ThermalProperty(emissivity=0.95,
                                    temperature_sunlit=312.0, temperature_shaded=303.0)
trunk_thermal = less.ThermalProperty(emissivity=0.97, temperature=300.0)
asphalt = less.ThermalProperty(emissivity=[0.95, 0.97], wavelengths=[10600, 12000],
                               temperature=320.0)
  • temperature: the temperature of the component;
  • temperature_sunlit, temperature_shaded: temperatures of the sunlit and shaded parts, which must be given together. When a viewing ray reaches a point, LESS checks whether the point can see the sun directly and chooses the temperature accordingly. For statistical vegetation, the two temperatures are combined according to the fraction of sunlit leaves;
  • emissivity: emissivity, which can be a spectral array.
  • transmittance: thermal transmittance, defaulting to zero; reflectance is 1 - emissivity - transmittance.

An explicit Mesh surface can emit, diffusely reflect and diffusely transmit thermal radiation. For example, less.ThermalProperty(temperature=300, emissivity=0.6, transmittance=0.2) has a reflectance of 0.2. Numeric emissivity and transmittance spectra share wavelengths and are interpolated to the sensor bands; their sum must not exceed one at any wavelength. Transmission currently applies to prescribed-temperature explicit Mesh thermal imaging. Energy-balance temperature solving, statistical vegetation and emissivity textures require opaque thermal properties. See A4 - Thermal radiation cavity for analytic validation.

The thermal imager

thermal_sensor = less.ThermalImager(
    less.Orthographic(image_size=128),
    bands=[10600],          # nm
    quality=32,
)
Sensor Band Centre wavelength (nm)
Landsat TIRS B10 10.6 µm 10600
Landsat TIRS B11 12.0 µm 12000
ASTER B13 10.7 µm 10700
MODIS B31 11.0 µm 11000

The result is spectral radiance (W m⁻² sr⁻¹ nm⁻¹), which can be converted to brightness temperature.

A thermal image of a forest

scene = less.Scene()
scene.size = 50.0
scene.terrain = less.Terrain(property=less.Lambertian(reflectance=0.15))
scene.terrain.set_property(soil_thermal)
scene.illumination = less.Illumination(
    source=less.Sun(zenith=30, azimuth=150),
    atmosphere=less.PrescribedAtmosphere(sky_temperature=260.0))

tree = less.Object("ash", mesh=less.asset_path("FREX.obj"))
tree.set_property("leaves", less.Prospect(cab=40, car=10))
tree.set_property("stem_branch", less.Lambertian(reflectance=0.08))
tree.set_property("leaves", leaf_thermal)
tree.set_property("stem_branch", trunk_thermal)
positions = less.place.random(scene, n=40, min_dist=4.0, seed=42)
n = len(positions)
scene.add(tree, positions=positions,
          scales=less.place.uniform_scale(n, 0.7, 1.3, seed=42),
          rotations=less.place.random_rotation(n, seed=42))

radiance = scene.simulate(thermal_sensor)
radiance.save("forest_thermal.tif")

brightness = radiance.to_brightness_temperature(unit="K")      # the default unit is °C
print(f"brightness temperature: {brightness.data.min():.1f} to {brightness.data.max():.1f} K")
brightness.save("forest_brightness_temperature.png")

Shaded soil and leaves are cooler and sunlit soil is warmest. Adding an optical sensor to the same scene gives the matching RGB image for comparison:

rgb = scene.simulate(less.OpticalImager(
    less.Orthographic(image_size=128), bands=[650, 550, 450], quality=32))

Thermal radiation from the sky

Downward thermal radiation from the sky illuminates the scene and is reflected by surfaces, most noticeably by those with low emissivity. Sources of sky thermal radiation:

Atmosphere Sky thermal radiation
less.Atmosphere Computed from the atmospheric profile
less.NoAtmosphere None
Other models sky_temperature (K) gives the equivalent brightness temperature of the sky
Other models without sky_temperature Downward longwave radiation of the Microclimate passed to the simulation

Equivalent sky temperatures are about 250–270 K for clear skies, 270–290 K for partly cloudy skies, and close to the surface temperature for overcast skies.

Different times of day

When only temperatures and the sun position change and the geometry stays the same, just simulate again:

scene.terrain.set_property(less.ThermalProperty(
    emissivity=0.95, temperature_sunlit=295.0, temperature_shaded=293.0))
tree.set_property("leaves", less.ThermalProperty(
    emissivity=0.98, temperature_sunlit=294.0, temperature_shaded=293.0))
scene.illumination = less.Illumination(
    source=less.Sun(zenith=70, azimuth=90),
    atmosphere=less.PrescribedAtmosphere(sky_temperature=260.0))
morning = scene.simulate(thermal_sensor)

scene.terrain.set_property(less.ThermalProperty(
    emissivity=0.95, temperature_sunlit=318.0, temperature_shaded=305.0))
tree.set_property("leaves", less.ThermalProperty(
    emissivity=0.98, temperature_sunlit=308.0, temperature_shaded=301.0))
scene.illumination = less.Illumination(
    source=less.Sun(zenith=10, azimuth=180),
    atmosphere=less.PrescribedAtmosphere(sky_temperature=260.0))
noon = scene.simulate(thermal_sensor)

Typical component temperatures (K):

Component Sunlit Shaded
Upper canopy leaves 303–310 298–303
Lower canopy leaves 300–305 297–301
Bare soil 310–320 300–308
Grass 303–308 298–302
Stems 298–303 —

Temperatures from the energy balance

All temperatures above were prescribed. When temperatures should follow from weather and plant physiology, run the energy balance first (chapter 32); thermal imaging then uses the solved temperatures automatically. A small maize plot shows how:

plot = less.Scene()
plot.size = 3.0
plot.terrain = less.Terrain(property=less.Lambertian(reflectance=0.15))
plot.terrain.set_property(less.ThermalProperty(temperature="air", emissivity=0.95))
plot.terrain.set_property(less.BiophysicalProperty(two_sided=False, stomata_side="none",
                                                   ground_heat_fraction=0.3))
plot.illumination = less.Illumination(
    source=less.Sun(zenith=30, azimuth=150),
    atmosphere=less.SimpleSpectralAtmosphere(turbidity=2.0))

maize = less.Object("maize", mesh=less.asset_path("maize.obj"))
maize.set_property(less.Prospect(cab=45))
maize.set_property(less.ThermalProperty(temperature="air", emissivity=0.98))
maize.set_property(less.BiophysicalProperty(leaf_width=0.08, stomata_side="bottom",
                                            stomatal_resistance=150.0))
plot.add(maize, positions=less.place.grid(plot, spacing=1.0))

climate = less.Microclimate(air_temperature=28.0, humidity=50.0, wind_speed=2.0)
energy = plot.simulate(less.EnergyBalanceProcess(quality="preview"), microclimate=climate)
solved = plot.simulate(thermal_sensor, microclimate=climate)
print(solved.to_brightness_temperature(unit="K").data.mean())

temperature="air" means the air temperature is used as the starting temperature.

The mode argument of ThermalImager controls which temperatures are used:

mode Temperature source
"auto" (default) The energy balance result if there is one, otherwise the temperatures in the thermal properties
"property" Temperatures in the thermal properties
"precomputed" An existing energy balance result

Top of atmosphere and instruments

With less.Atmosphere, radiance can be given at the top of the atmosphere or at any altitude:

scene.illumination = less.Illumination(
    source=less.Sun(zenith=30, azimuth=150),
    atmosphere=less.Atmosphere.standard("midlatitude_summer"))

toa = scene.simulate(less.ThermalImager(
    less.Orthographic(image_size=64),
    bands=less.gaussian_bands([("LWIR_10.6", 10600, 1000)]),
    spectral_resolution=20, observation_level="toa", mode="property"))

gaussian_bands defines bands with Gaussian spectral responses (name, centre wavelength, full width at half maximum, nm).

Adding a photon detector model gives the digital numbers of a pushbroom thermal instrument:

dn = scene.simulate(less.ThermalImager(
    less.Pushbroom(resolution=64, num_lines=64, altitude=5000.0, swath_width=50.0),
    bands=[10600, 12000], observation_level="toa", mode="property",
    instrument=less.Instrument.thermal_photon(
        aperture_diameter=0.15, bandwidth=[1000, 1000], column_gain_sigma=0.01, seed=42),
))

The quantum efficiency, full well, dark current and read noise of Instrument.thermal_photon() are set to the magnitudes of mercury cadmium telluride or quantum-well infrared detectors; they do not represent microbolometers. instrument.nedt(wavelength_nm, T_ref_K, projection=...) gives the noise-equivalent temperature difference for a uniform blackbody scene. Thermal imaging supports orthographic and pushbroom projections. A complete pushbroom example is python -m less.examples thermal_pushbroom.

A thin plane-parallel cloud layer can be added to the atmosphere; clear and cloudy conditions are mixed per pixel according to cloud cover:

cloudy = less.Atmosphere.standard(
    "midlatitude_summer",
    cloud=less.CloudLayer(cover=0.5, altitude_km=3.0, thickness_km=0.5, optical_depth=8.0),
    precipitable_water_g_cm2=2.1,
)

Companion script: scripts/26_thermal_imaging.py

Next