Spectral Bands Explained: What Satellite Imagery Actually Measures
Problem statement
A satellite image arrives as a folder of GeoTIFFs called B02.tif, B04.tif, B08.tif. Opened in Python they are unremarkable integer arrays:
red band: uint16, 1 to 13,184, mean 1,450
Nothing in that array says what was measured, at what wavelength, over what ground area, or what number counts as "bright". Every mistake that follows β an index above 1, a band that will not broadcast, a classifier that cannot tell cloud from snow β comes from treating those integers as a picture instead of as a physical measurement.
A band is three things at once, and you need all three before the numbers mean anything:
- a wavelength range β which part of the spectrum the detector was sensitive to
- a ground sample distance β how much of the earth one number covers
- a scale β what physical quantity the integer stands for
Quick answer
Read the band metadata, never the array alone:
from pystac_client import Client
client = Client.open("https://earth-search.aws.element84.com/v1")
item = next(client.search(ids=["S2A_30UVD_20250922_0_L2A"],
collections=["sentinel-2-l2a"]).items())
for name in ("blue", "red", "nir", "swir16"):
asset = item.assets[name]
band = asset.extra_fields["eo:bands"][0]
raster = asset.extra_fields["raster:bands"][0]
print(f"{name:8} {band['name']} {band['center_wavelength']:.3f} um "
f"{raster['spatial_resolution']} m scale {raster.get('scale')}")
blue B02 0.493 um 10 m scale 0.0001
red B04 0.665 um 10 m scale 0.0001
nir B08 0.842 um 10 m scale 0.0001
swir16 B11 1.610 um 20 m scale 0.0001
Step-by-step solution
1. Find out which wavelengths the band covers
A band is a filter. It reports how much light arrived within a window of wavelengths, and nothing about the distribution inside that window.
Sentinel-2's red band is centred on 0.665 Β΅m with a full width at half maximum of 0.038 Β΅m β a 38 nm slice. Its near-infrared band is centred on 0.842 Β΅m and is nearly four times wider at 0.145 Β΅m.
That width matters. A wide band collects more photons, so it is less noisy, but it averages over more physics: a narrow absorption feature inside a wide band barely moves the number.
2. Find out how much ground one number covers
Sentinel-2 mixes resolutions inside one product. The visible and near-infrared bands are 10 m; the red-edge and shortwave-infrared bands are 20 m; the atmospheric bands are 60 m.
For the same 10.6 Γ 9.9 km window over Snowdonia:
red (B04, 10 m): (1017, 1087) = 1,105,479 values
swir16 (B11, 20 m): (509, 543) = 276,387 values
These are the same ground, so any arithmetic that mixes them fails:
red.astype("float32") - swir.astype("float32")
ValueError: operands could not be broadcast together with shapes (1017,1087) (509,543)
That error is a feature. It stops you silently comparing two different grids.
3. Find out what the integers stand for
The array is uint16. Reflectance is a ratio between 0 and 1. The metadata carries the conversion:
raster:bands -> {"scale": 0.0001, "offset": -0.1, "nodata": 0}
Read literally, that means reflectance = DN * 0.0001 - 0.1. Apply it to this product and the numbers stop making physical sense β see Radiometric levels explained, where the offset turns out to be already applied and water comes out at β0.09 reflectance in every band. The rule is not "trust the metadata" or "ignore the metadata"; it is check the conversion against a target whose reflectance you already know.
4. Use the spectrum to identify what you are looking at
Once the numbers are reflectance, each surface has a shape across the bands. Median reflectance by class over the Snowdonia window, using the scene classification band to pick the pixels:
blue green red nir
vegetation 0.039 0.067 0.056 0.361
water 0.015 0.013 0.008 0.006
bare soil 0.090 0.106 0.110 0.235
cloud 0.527 0.507 0.504 0.599
Read across each row and the physics is visible:
- Vegetation absorbs red for photosynthesis and scatters near-infrared from leaf cell structure. Red 0.056, NIR 0.361 β a factor of six. That gap is what every vegetation index measures.
- Water absorbs almost everything and absorbs more as wavelength grows: 0.015 in blue falling to 0.006 in NIR.
- Bare soil rises gently across the spectrum with no red dip and no NIR jump.
- Cloud is bright and nearly flat β that flatness is how cloud detectors separate cloud from snow, which collapses in the shortwave infrared.
5. Choose bands for the question, not for the picture
A natural-colour image uses red, green and blue because that is what eyes do. It is almost never the best choice for analysis: the three visible bands are highly correlated, and the information that separates surfaces sits in the infrared.
Code examples
Example 1 β a spectral profile for any set of pixels
import numpy as np
import rasterio
from rasterio.enums import Resampling
BANDS = {"blue": "B02", "green": "B03", "red": "B04", "nir": "B08"}
SCL_CLASSES = {4: "vegetation", 5: "bare soil", 6: "water",
3: "cloud shadow", 8: "cloud medium", 9: "cloud high",
11: "snow or ice"}
def spectral_profile(paths, scl_path, scale=1e-4):
"""Median reflectance per scene-classification class, one row per class."""
with rasterio.open(paths["red"]) as ds:
shape = ds.shape
# the classification band is 20 m; resample it onto the 10 m grid rather
# than assuming 2x the pixels, which is off by one whenever a window is odd
with rasterio.open(scl_path) as ds:
scl = ds.read(1, out_shape=shape, resampling=Resampling.nearest)
stack = {}
for name, path in paths.items():
with rasterio.open(path) as ds:
stack[name] = ds.read(1, out_shape=shape,
resampling=Resampling.bilinear).astype("float32")
rows = []
for code, label in SCL_CLASSES.items():
mask = scl == code
if mask.sum() < 500:
continue
row = {"class": label, "pixels": int(mask.sum())}
row.update({b: round(float(np.median(a[mask])) * scale, 3)
for b, a in stack.items()})
rows.append(row)
import pandas as pd
frame = pd.DataFrame(rows).sort_values("pixels", ascending=False)
print(frame.to_string(index=False))
return frame
class pixels blue green red nir
vegetation 466954 0.039 0.067 0.056 0.361
cloud high 174072 0.527 0.507 0.504 0.599
bare soil 34166 0.090 0.106 0.110 0.235
water 17834 0.015 0.013 0.008 0.006
Run this before any analysis on a new sensor or a new product. If water is not dark and vegetation does not jump in the near-infrared, your scale conversion is wrong and everything downstream is wrong with it.
Example 2 β checking whether two bands share a grid
import rasterio
def grids_match(path_a, path_b):
"""Do two bands describe the same pixels? Shape alone is not enough."""
with rasterio.open(path_a) as a, rasterio.open(path_b) as b:
same_crs = a.crs == b.crs
same_shape = a.shape == b.shape
same_transform = a.transform.almost_equals(b.transform, precision=1e-6)
print(f" CRS {a.crs} / {b.crs} {'ok' if same_crs else 'DIFFER'}")
print(f" shape {a.shape} / {b.shape} {'ok' if same_shape else 'DIFFER'}")
print(f" pixel {a.res} / {b.res}")
print(f" origin {a.transform.c, a.transform.f} / "
f"{b.transform.c, b.transform.f}")
return same_crs and same_shape and same_transform
CRS EPSG:32630 / EPSG:32630 ok
shape (1017, 1087) / (509, 543) DIFFER
pixel (10.0, 10.0) / (20.0, 20.0)
origin (422200.0, 5886250.0) / (422200.0, 5886260.0)
Same CRS, different grid β and the origins differ by 10 m, because a window cut on the 10 m grid cannot always land on the 20 m grid. Comparing shapes alone would have missed that; comparing transforms catches it. See How to resample satellite bands to a common grid.
Example 3 β reading a band as reflectance, with the check built in
import numpy as np
import rasterio
def read_reflectance(path, scale=1e-4, offset=0.0, nodata=0):
"""Read a band as reflectance and refuse to return physically impossible values."""
with rasterio.open(path) as ds:
dn = ds.read(1)
# cast before arithmetic: DN is unsigned, and 1 - 1000 in uint16 is 64,537
values = dn.astype("float32")
values[dn == nodata] = np.nan
reflectance = values * scale + offset
finite = reflectance[np.isfinite(reflectance)]
low = float(np.percentile(finite, 1))
high = float(np.percentile(finite, 99))
print(f" {path.split('/')[-1]:10} p1 {low:6.3f} p99 {high:6.3f}")
if low < -0.05 or high > 1.2:
raise ValueError(
f"reflectance outside the plausible range ({low:.3f} to {high:.3f}) β "
f"check the scale ({scale}) and offset ({offset}) against the product"
)
return reflectance
B04.tif p1 0.007 p99 0.830
B08.tif p1 0.007 p99 0.905
The 1st and 99th percentiles, rather than the minimum and maximum, keep single bad pixels from tripping the check while still catching a wrong scale β which is always wrong by a factor, not by a pixel.
A p99 of 0.83 looks alarming until you remember this scene is half cloud, and cloud tops really are that bright. The check is for impossible values, not surprising ones.
Explanation
Why the integers are not brightness
A detector counts photons. Turning that count into a number a scientist can compare across dates and sensors takes a chain of corrections: sensor gain, sun-earth distance, solar angle, atmospheric scattering and absorption.
The end of that chain is reflectance β the fraction of incoming light the surface sent back. It is dimensionless, roughly bounded by 0 and 1, and comparable between two dates and two satellites. That comparability is the whole point, and it is why the integer is stored with a scale rather than as a float: 16-bit integers with a factor of 10,000 give four decimal places at half the file size.
Why band width is a trade-off, not a quality
It is tempting to read "narrow band" as "better". Narrow bands resolve fine spectral features, but each one collects fewer photons, so the signal-to-noise ratio falls.
Sentinel-2's red band is 38 nm wide and its near-infrared band 145 nm, because the near-infrared plateau of vegetation is genuinely broad and there is nothing to resolve inside it. The red-edge bands, sitting on the steep transition between the two, are 15β20 nm wide because that is where narrowness buys information.
Hyperspectral sensors take this to hundreds of contiguous 5β10 nm bands. They see absorption features a broadband sensor averages away, and they need much more light, storage and processing to do it.
Why the resolutions differ inside one product
Sentinel-2's 20 m and 60 m bands are not lower quality. They are the bands where either the physics does not need the detail, or the light budget does not allow it.
The 60 m bands exist to measure the atmosphere β water vapour, cirrus, aerosols β and the atmosphere does not have 10 m structure. The 20 m shortwave-infrared bands are limited by available photons at those wavelengths.
The practical consequence is that a "Sentinel-2 image" is three grids, and you must decide, per analysis, whether to bring the coarse bands up or the fine bands down. Upsampling invents detail; downsampling discards it. Neither is free.
Why the spectral signature is the actual data
Once the bands are reflectance, a pixel is a short vector β four numbers for a four-band stack β and classification, indices and change detection are all operations on that vector.
The shape of the vector is diagnostic in a way no single band is. Water and cloud shadow are both dark in the visible bands; they separate in the near-infrared, where measured median reflectance was 0.006 for water and 0.134 for shadow, a factor of twenty. Cloud and snow are both bright in the visible bands and separate in the shortwave infrared, where ice absorbs strongly and cloud does not.
This is why "which band is best" is the wrong question. Nearly every useful product is a contrast between bands, because a contrast cancels the things that affect all bands equally β illumination, slope, sensor gain β and keeps what differs.
Edge cases or notes
nodatais 0 and 0 is a legal reflectance. Mask on the nodata value before any arithmetic, or shadowed pixels become real zeros in your statistics.uint16arithmetic wraps.dn - 1000on auint16array turns a DN of 1 into 64,537. Cast to float first, always.- Band names are not standard.
B08is near-infrared on Sentinel-2 and shortwave-infrared on Landsat 8. Use the common name (nir,swir16) from the STAC metadata rather than the file name. - Centre wavelength is not the whole story. Two sensors with a "red" band at 0.665 Β΅m can have different band widths, and their reflectances will not agree exactly.
- A band can be saturated. Bright cloud tops and snow can hit the detector ceiling; those pixels are flagged in the classification band as class 1 and are not real measurements.
- 60 m bands are not for mapping. They describe the atmosphere, not the ground.
- Reflectance can legitimately be slightly negative over very dark targets after atmospheric correction. Slightly, meaning β0.01, not β0.09.
Internal links
- Radiometric levels explained: DN, TOA and surface reflectance β what the integers convert to, and when the conversion is already done
- Spectral indices explained β turning the band contrast into one number
- How to load Sentinel-2 bands into Python as an analysis-ready array β the practical version of everything here
- How to resample satellite bands to a common grid β fixing the 10 m / 20 m mismatch
- Cloud masking explained β what the scene classification band is and how far to trust it
- The raster data model explained β bands, dtype, NoData and the transform
- Sentinel-2 or Landsat? Choosing a satellite imagery source β the same concepts across two sensors
- STAC explained: how satellite imagery catalogues work β where the band metadata comes from
FAQ
What is a spectral band?
A measurement of how much light arrived in one range of wavelengths, over one patch of ground, stored as an integer with a scale factor. All three parts are needed before the number means anything.
Why are Sentinel-2 bands at different resolutions?
Because the bands serve different purposes. The 60 m bands measure atmospheric properties that have no fine structure, and the 20 m shortwave-infrared bands are limited by how much light is available at those wavelengths.
Why can I not just subtract two bands?
If they are at different resolutions the arrays have different shapes and NumPy raises ValueError. Even at the same resolution, subtracting raw digital numbers is only meaningful once both are on the same reflectance scale.
What is a good reflectance value for vegetation?
Measured over Snowdonia: about 0.04 in blue, 0.07 in green, 0.06 in red and 0.36 in the near-infrared. If your numbers are far from that shape, the scale conversion is wrong.
Which bands should I use for a natural-colour image?
Red, green and blue β B04, B03, B02 on Sentinel-2. For analysis, prefer contrasts that include the infrared bands; the three visible bands are highly correlated with each other.
What is the red edge?
The steep rise in vegetation reflectance between red and near-infrared, around 0.70β0.75 Β΅m. Sentinel-2 has three narrow bands positioned on that slope because its position shifts with plant stress and chlorophyll content.
How do I know if my scale factor is right?
Check a target you already know. Water should be below about 0.05 in every band and darkest in the near-infrared. Healthy vegetation should be several times brighter in the near-infrared than in red. If either fails, the conversion is wrong.