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
A single satellite band described by three independent properties: a wavelength window, a ground sample distance and a scale factor from integer to reflectance.
The array is one third of a band. The other two thirds live in the metadata.

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.
Median reflectance across blue, green, red and near-infrared for vegetation, water, bare soil and cloud, showing the vegetation red dip and near-infrared jump.
Measured over Snowdonia. The vegetation red-to-NIR step is a factor of six; every vegetation index is a way of writing that step as one number.

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.

One Sentinel-2 scene as three grids at 10 m, 20 m and 60 m, with the same window producing 1,105,479, 276,387 and 30,714 values.
One product, three grids. Any arithmetic across them needs an explicit resampling decision.

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.

Five checks on a band before analysis, of which four are metadata and one must be measured against a known target.
Four of these are read from the header. The scale check is the one that has to be measured.

Edge cases or notes

  • nodata is 0 and 0 is a legal reflectance. Mask on the nodata value before any arithmetic, or shadowed pixels become real zeros in your statistics.
  • uint16 arithmetic wraps. dn - 1000 on a uint16 array turns a DN of 1 into 64,537. Cast to float first, always.
  • Band names are not standard. B08 is 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.

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.