Learn how thermal imagery can be applied to real-world challenges.
Agriculture
Urban Heat
Wildfire
Energy & Industrial
Oceans
Defense & Intelligence
Weather Forecasting
Forestry
Biodiversity









In this case study, we’ll show how you can identify changes in industrial activity with a few simple analyses applied to Hydrosat data.
Tata Steel IJmuiden is one of Europe's largest integrated steelworks and a major employer and exporter for the Netherlands, producing several million tonnes of steel a year at its site on the North Sea coast. In April 2026, the plant's DSP unit was shut down after air quality measurements showed chromium-6 emissions from one of its chimneys exceeding permissible limits, a serious environmental and public health concern. Source: GMK Center.
Thermal satellite imagery offers a way to independently verify whether the DSP has genuinely gone offline, without relying solely on self-reported compliance data.
We'll start with a single Level-1B LIRI scene, VZ02_L1b_20260302_132735, taken before the DSP shutdown. Before diving into any analysis, we'll take a quick look at the images and their metadata.
from pathlib import Path
import matplotlib.pyplot as plt
import rioxarray
import xarray as xr
# Define the scene ID and data directory for the imagery
DATA_DIR = Path("data")
SCENE_ID = "VZ02_L1b_20260302_132735"# Read LWIR bands
lwir1 = rioxarray.open_rasterio(DATA_DIR / SCENE_ID / "LWIR1.tiff", masked=True).squeeze("band", drop=True)
lwir2 = rioxarray.open_rasterio(DATA_DIR / SCENE_ID / "LWIR2.tiff", masked=True).squeeze("band", drop=True)
# Build an RGB preview from the individual RED/GREEN/BLUE bands
red = rioxarray.open_rasterio(DATA_DIR / SCENE_ID / "RED.tiff", masked=True).squeeze("band", drop=True)
green = rioxarray.open_rasterio(DATA_DIR / SCENE_ID / "GREEN.tiff", masked=True).squeeze("band", drop=True)
blue = rioxarray.open_rasterio(DATA_DIR / SCENE_ID / "BLUE.tiff", masked=True).squeeze("band", drop=True)
preview = xr.concat([red, green, blue], dim="band").assign_coords(band=["red", "green", "blue"])rioxarray already masked out nodata pixels for us on load. L1B LIRI pixel values are scaled brightness temperature, so multiplying by the 0.01 scaling factor converts them to Kelvin, which we then convert to Fahrenheit for readability. We'll also show the full-resolution RGB preview alongside the two thermal bands for context.
Here we can see the broader context of the area, including the North Sea coastline and the harbor canal running through IJmuiden. Let's focus in on the steelworks for our next steps.
The full scene extends well beyond the steelworks. We'll crop LWIR1 to a region of interest (ROI) around the plant, loaded from a local file, so the same box can be reused across scenes with different projections or pixel grids.
Here, several distinct hotspots stand out clearly against the surrounding land: individual stacks and process units across the steelworks, each running well above ambient. reveals that the DSP is located in the middle-left part of the cropped region. Since this image was taken before the DSP shutdown, its thermal signature is visible (the two hotspots near the shoreline). Let's take a look at how the overall thermal picture changes after the DSP was taken offline.
We have two LWIR1 acquisitions bracketing the shutdown: 20260302 (before, March 2026) and 20260619 (after, June 2026), both from the VZ02 sensor. We'll plot them side by side with a shared color scale for direct comparison.
When sharing a color scale, the March scene shows a handful of sharp, distinct hotspots against a cool background, while the June scene is warm across almost the entire site. This is a product of summer solar heating, making it hard to tell whether the DSP (or anything else) is active. Normalizing each frame relative to its own background can help separate genuine activity from ambient seasonal heating.
Different acquisitions can have different baseline brightness temperatures due to atmospheric and seasonal conditions unrelated to plant activity. Simply subtracting each frame's median still leaves frames with a lot of background texture (e.g. a hot summer day) looking uniformly "hot," since the deviations are on a different scale from frame to frame.
Instead, we use a robust z-score, which normalizes both the center and the spread of each frame using statistics that resist being skewed by a small number of extreme hot pixels:
where MAD(x) = median(|x - median(x)|) is the median absolute deviation. Subtracting the median (instead of the mean) keeps the center from being pulled up by a handful of very hot pixels, and dividing by the (rescaled) MAD instead of the standard deviation keeps the scale from being inflated by those same outliers. The 1.4826 constant rescales MAD so it's comparable to a standard deviation for normally-distributed data, making the resulting z-score readable the same way as an ordinary one (e.g. +3 ≈ "3 robust standard deviations above typical").
After normalization, the difference between the images becomes clear: by June, Tata Steel has shut off the DSP (as per the article), and the Warmband facility (the hot-rolled strip steel mill) also appears to be offline. With a few simple image processing steps, we have gone from raw imagery to actionable compliance insights.
Beyond a visual inspection, extracting pixel values can provide further insight into the thermal activity of a scene. A profile (a cross-sectional view that shows the change in pixel values along a linear path) through the hottest point tells us two distinct things at once:
Whether a given unit is active or idle (does the profile spike well above the surrounding background, or does it stay flat and close to ambient?)
How active it is (values along the profile indicate the intensity of the thermal signal, not just its presence)
We'll use the 20260302 acquisition, taken before the DSP shutdown, since it's where the plant's activity registers most clearly.
This profile is (taken through the Warmband) shows a clear signal of activity. By extracting similar profiles through our images, or by taking pixel statistics for an AOI polygon, we can further learn about the thermal characteristics of locations and derive insights from thermal imagery.
# Scaling factor for brightness temperature (from DN to Kelvin)
BT_SCALE = 0.01
# Convert brightness temperature from Kelvin to Fahrenheit
def kelvin_to_fahrenheit(da):
return (da - 273.15) * 9 / 5 + 32
# Stretch each band independently to enhance contrast in RGB composite
def percentile_stretch(da, low=1, high=99):
vmin, vmax = da.quantile(low / 100), da.quantile(high / 100)
return ((da - vmin) / (vmax - vmin)).clip(0, 1)
# Convert LWIR bands to brightness temperature and stretch RGB preview
lwir1_bt = kelvin_to_fahrenheit(lwir1 * BT_SCALE)
lwir2_bt = kelvin_to_fahrenheit(lwir2 * BT_SCALE)
preview_stretched = preview.groupby("band").map(percentile_stretch)
# Plot LWIR brightness temperature and RGB preview
fig, axes = plt.subplots(1, 3, figsize=(19, 6))
for ax, da, label in zip(
axes[:2], [lwir1_bt, lwir2_bt], ["LWIR1 (10.9 µm)", "LWIR2 (12.0 µm)"]
):
da.plot.imshow(ax=ax, cmap="magma", add_colorbar=True, cbar_kwargs={"label": "°F"})
ax.set_title(f"{label} Brightness Temperature")
ax.set_aspect("equal")
ax.axis("off")
preview_stretched.plot.imshow(ax=axes[2], rgb="band")
axes[2].set_title("RGB Preview")
axes[2].set_aspect("equal")
axes[2].axis("off")
fig.suptitle(SCENE_ID)
plt.tight_layout()
plt.show()import json
# Function to extract ROI bounds from a GeoJSON file
def roi_bounds_from_geojson(path):
coords = json.loads(Path(path).read_text())["features"][0]["geometry"]["coordinates"][0]
lons, lats = zip(*coords)
return (min(lons), min(lats), max(lons), max(lats))
# Extract ROI bounds from the GeoJSON file and clip the LWIR band to the ROI
ROI_BOUNDS = roi_bounds_from_geojson("assets/tata_steel.geojson")
lwir1_roi = lwir1_bt.rio.clip_box(*ROI_BOUNDS, crs="EPSG:4326")from matplotlib.patches import Rectangle
from rasterio.warp import transform_bounds
# Reproject the ROI bounds into the raster's CRS so the box lines up on the full scene
roi_minx, roi_miny, roi_maxx, roi_maxy = transform_bounds(
"EPSG:4326", lwir1_bt.rio.crs, *ROI_BOUNDS
)
# Create a figure with two subplots: one for the full scene and one for the ROI
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
lwir1_bt.plot.imshow(ax=axes[0], cmap="magma", add_colorbar=True, cbar_kwargs={"label": "°F"})
axes[0].add_patch(
Rectangle(
(roi_minx, roi_miny),
roi_maxx - roi_minx,
roi_maxy - roi_miny,
edgecolor="cyan",
facecolor="none",
linewidth=2,
)
)
axes[0].set_title("LWIR1 (10.9 µm) - Full Scene")
axes[0].set_aspect("equal")
axes[0].axis("off")
lwir1_roi.plot.imshow(ax=axes[1], cmap="magma", add_colorbar=True, cbar_kwargs={"label": "°F"})
axes[1].set_title("LWIR1 (10.9 µm) - ROI")
axes[1].set_aspect("equal")
axes[1].axis("off")
fig.suptitle(f"{SCENE_ID}")
plt.tight_layout()
plt.show()# Close all opened raster files to free up resources
lwir1.close(); lwir2.close(); red.close(); green.close(); blue.close(); preview.close()# Imagery scene IDs
TIMESERIES_SCENES = [
"VZ02_L1b_20260302_132735",
"VZ02_L1b_20260619_132916",
]
# Helper function to extract the date from a scene ID
def scene_date(scene_id):
return scene_id.split("_")[2]
# Load and crop LWIR1 imagery for a given scene ID
def load_cropped_lwir1(scene_id):
da = rioxarray.open_rasterio(
Path("data") / scene_id / f"LWIR1.tiff", masked=True
).squeeze("band", drop=True)
da = da.rio.clip_box(*ROI_BOUNDS, crs="EPSG:4326") * BT_SCALE
return kelvin_to_fahrenheit(da)
# Load the LWIR1 time series for all scenes
timeseries = {scene_id: load_cropped_lwir1(scene_id) for scene_id in TIMESERIES_SCENES}
dates = sorted(timeseries, key=scene_date)from datetime import datetime
# Determine the min and max brightness temperature values for consistent color scaling
vmin = min(float(da.min()) for da in timeseries.values())
vmax = max(float(da.max()) for da in timeseries.values())
# Plot the LWIR1 time series
fig, axes = plt.subplots(1, len(dates), figsize=(7 * len(dates), 6))
for ax, scene_id in zip(axes, dates):
da = timeseries[scene_id]
da.plot.imshow(ax=ax, cmap="magma", vmin=vmin, vmax=vmax, cbar_kwargs={"label": "°F"})
ax.set_title(datetime.strptime(scene_date(scene_id), "%Y%m%d").strftime("%B %d, %Y"))
ax.axis("off")
fig.suptitle("LWIR1 Brightness Temperature")
plt.tight_layout()
plt.show()# Compute robust z-score normalized timeseries for each scene
def robust_zscore(da):
median = float(da.median())
mad = float(abs(da - median).median()) * 1.4826 # scaled to be comparable to std
return (da - median) / mad
# Apply robust z-score normalization to all scenes in the timeseries
normalized_timeseries = {
scene_id: robust_zscore(da) for scene_id, da in timeseries.items()
}# Determine the global min and max for the normalized timeseries for consistent color scaling
norm_vmin = min(float(da.min()) for da in normalized_timeseries.values())
norm_vmax = max(float(da.max()) for da in normalized_timeseries.values())
# Plot the normalized timeseries
fig, axes = plt.subplots(1, len(dates), figsize=(7 * len(dates), 6))
for ax, scene_id in zip(axes, dates):
da = normalized_timeseries[scene_id]
da.plot.imshow(
ax=ax,
cmap="magma",
vmin=norm_vmin,
vmax=norm_vmax,
cbar_kwargs={"label": "Robust z-score"},
)
ax.set_title(datetime.strptime(scene_date(scene_id), "%Y%m%d").strftime("%B %d, %Y"))
ax.axis("off")
fig.suptitle("LWIR1 Brightness Temperature (robust z-score)")
plt.tight_layout()
plt.show()from rasterio.warp import transform
# Get the data for the profile scene
profile_scene_id = "VZ02_L1b_20260302_132735"
profile_da = timeseries[profile_scene_id]
# Find the hotspot location in the profile scene
hotspot_idx = profile_da.argmax(dim=["y", "x"])
hotspot_row = int(hotspot_idx["y"])
hotspot_col = int(hotspot_idx["x"])
hotspot_northing = float(profile_da["y"].isel(y=hotspot_row))
hotspot_easting = float(profile_da["x"].isel(x=hotspot_col))
# Extract the transect along the hotspot row
transect = profile_da.isel(y=hotspot_row)
# Convert the hotspot's own coordinates to latitude for the title
_, hotspot_lat = transform(
profile_da.rio.crs, "EPSG:4326", [hotspot_easting], [hotspot_northing]
)
hotspot_lat = hotspot_lat[0]
# Convert the transect's easting coordinates to longitude for the x-axis
transect_lons, _ = transform(
profile_da.rio.crs,
"EPSG:4326",
transect["x"].values,
[hotspot_northing] * transect.sizes["x"],
)
# Compute overall statistics for the profile scene
overall_min = float(profile_da.min())
overall_max = float(profile_da.max())
overall_mean = float(profile_da.mean())
overall_std = float(profile_da.std())
# Plot the profile scene and the transect through the hotspot
fig, (ax_img, ax_profile) = plt.subplots(1, 2, figsize=(16, 6))
profile_da.plot.imshow(ax=ax_img, cmap="magma", cbar_kwargs={"label": "°F"})
ax_img.axhline(hotspot_northing, color="cyan", linestyle="--", linewidth=1.5)
ax_img.set_title(f"{profile_scene_id} - Latitude: {hotspot_lat:.4f}°N")
ax_img.axis("off")
ax_profile.plot(transect_lons, transect.values, color="crimson", label="Transect")
ax_profile.axhline(overall_mean, color="black", linewidth=1, label="Mean")
ax_profile.axhspan(
overall_mean - overall_std,
overall_mean + overall_std,
color="gray",
alpha=0.3,
label="Mean ± 1 std",
)
ax_profile.axhline(overall_min, color="steelblue", linestyle=":", label="Min")
ax_profile.axhline(overall_max, color="firebrick", linestyle=":", label="Max")
ax_profile.set_xlabel("Longitude")
ax_profile.set_ylabel("°F")
ax_profile.set_title("Thermal Profile")
ax_profile.legend()
plt.tight_layout()
plt.show()





Use case area: Energy and Industrial
Industrial facilities contain many processes that generate, transfer, or release heat. While these processes may be difficult to distinguish in visible imagery alone, thermal imagery provides an effective way to observe activity across a site and over time.
By comparing thermal observations temporally, we can identify changes in heat signatures associated with operational units, flaring, water discharge, and other industrial processes.
The images below show Hydrosat longwave infrared observations of the Mina Al-Ahmadi and Mina Abdullah refinery sites in Kuwait.
At first glance, visible imagery provides useful context about the layout of the facilities. Thermal imagery adds another layer of information by showing differences in emitted heat across the site.
Areas with stronger thermal signals stand out from their surroundings, making it possible to see where heat-producing processes are occurring within a large and complex industrial facility.
A single thermal image provides a snapshot. Because industrial processes are so dynamic, repeated observations make it possible to compare activity from one date to another.
Hydrosat collected multiple observations of the refinery sites between September 2025 and January 2026, including both daytime and nighttime imagery.
Across the series, some thermal features remain relatively consistent while others appear, disappear, or change in intensity. These differences provide a simple way to identify areas where facility activity levels are changing.
Looking more closely at individual parts of the site reveals several examples of changing thermal activity.
Flaring is clearly visible in some observations and absent in others. Other parts of the facility shift between higher and lower levels of thermal activity, as indicated by temperature. Comparing the same locations across dates makes these changes easier to distinguish from the surrounding background.
Thermal imagery can also reveal features beyond the main processing areas.
In this example, a warm industrial discharge is visible in the January 14 nighttime observation. A nearby tailings basin also shows a thermal signal on some dates but not others.
These examples illustrate an important advantage of thermal monitoring: rather than relying only on how a facility looks with visible imagery, we can observe how its heat signature physically changes over time to understand aspects that are on, off, or operating at different thresholds.
Industrial sites can be large, complex, and geographically dispersed. Satellite thermal imagery provides a consistent way to observe heat-producing activity across these sites without requiring sensors to be installed at each facility.
Repeated thermal observations can help analysts:
Monitor variations in facility activity
Identify persistent or intermittent heat sources
Observe flaring and other high-temperature events
Monitor discharge and thermal patterns around infrastructure
Large-area coverage, spectral diversity, frequent revisit, and nighttime imaging make it possible to monitor industrial activity across broad areas and compare changing thermal conditions consistently over time.
Thermal data provides a key physical signal that identifies when, where and to what degree industrial changes happen.
Compare activity across multiple facilities or dates



