Radiometric Calibration & Reflectance Conversion

Every index page on this site opens with the same assumption: that the arrays you are about to divide are surface reflectance, not raw digital numbers. This topic is where that assumption is earned. The output is a calibrated reflectance stack — one array per band, in physical units between roughly 0 and 1, comparable between frames within a flight and between flights across a season — which is exactly what band math and raster algebra expects as input. It sits under drone imagery processing and vegetation index workflows, immediately after ingesting multispectral drone imagery and before anything that computes a ratio.

Skipping it is the most common reason a vegetation index time series is uninterpretable. Uncalibrated NDVI moves with cloud cover, sun angle and exposure, and those movements are the same magnitude as the crop signal you are looking for.

Prerequisites

  • Python 3.11+, numpy 1.26., rasterio 1.3., opencv-python 4.9.* for undistortion, exifread 3.0.* or pyexiftool for metadata
  • Raw captures with intact metadata: MicaSense RedEdge-MX and Altum, DJI P4 Multispectral or Mavic 3M, Parrot Sequoia — the vendor’s per-frame calibration tags must survive whatever copied the files
  • A calibration panel capture with the panel’s published reflectance per band, from the panel’s own certificate rather than a generic value
  • Downwelling light sensor records, if the aircraft carries one
  • Somewhere to put the result — see cloud-optimized storage for field imagery

1. Concept: Four Corrections, In Order

A raw pixel is a count of electrons, and four things stand between it and surface reflectance.

Sensor response. Exposure time, gain and a black level offset differ per frame because the camera adapts. Normalising them converts counts into a quantity proportional to radiance.

Optical fall-off. Every lens is darker at the edges than the centre. The vignetting model is a polynomial in radius from the optical centre, with coefficients published per camera; applied per frame, it removes the concentric brightness gradient that would otherwise survive into the mosaic as a repeating pattern of bright frame centres.

Illumination. Radiance depends on how much light fell on the scene. Dividing by the incident irradiance turns radiance into reflectance — a property of the surface rather than of the day. The calibration panel provides the absolute anchor (a surface of known reflectance, photographed under the same light), and the downwelling sensor tracks how that light changes during the flight.

Geometry. Reflectance is not isotropic: a canopy is brighter when viewed with the sun behind you than against it. This bidirectional effect produces the moving hotspot in mosaics and is the hardest to correct properly. Most production pipelines manage it by flight planning — fly within two hours of solar noon, keep flight lines perpendicular to the solar azimuth — rather than by modelling.

The radiometric calibration chain and what each stage fixes Five boxes left to right: raw digital number; sensor normalisation for exposure, gain and black level; vignetting and distortion correction; panel-derived reflectance scaling; and downwelling irradiance correction, ending at surface reflectance. Under each stage a note names the artefact that appears if the stage is skipped. Raw DN uint16 counts not comparable to anything 1 · Sensor exposure · gain black level skip → frame-to-frame seams in the mosaic 2 · Optics vignetting model lens distortion skip → bright centres tiled across the field 3 · Panel known reflectance → absolute scale skip → values drift between flights 4 · Irradiance downwelling sensor → reflectance skip → cloud shadows read as crop stress Fifth factor: view and sun geometry Reflectance is directional. A canopy is brighter viewed with the sun behind the camera than against it, which produces a hotspot that moves through the mosaic. Most pipelines manage this by flight planning — near solar noon, lines across the solar azimuth — not by modelling.

2. Step-by-Step Implementation

Three levels of calibration, and what each one costs you Three panels comparing calibration strategies: panels alone under a stable sky, panels combined with a downwelling irradiance sensor for variable light, and no calibration at all. Uncalibrated index values move with exposure and illumination by more than most crop signals. Panel only — stable clear sky Two panel captures, take-off and landing One scale factor per band for the whole flight. Adequate when the two factors agree closely. Simplest thing that is actually correct. Panel plus downwelling sensor Panels anchor the absolute scale The irradiance sensor tracks change between them. Each frame is scaled by its own ratio. Required under broken cloud. Neither — uncalibrated Digital numbers straight into band maths Index moves with exposure and light. 0.03–0.08 NDVI between flights an hour apart. Larger than most crop signals.

Step 1 — Normalise the frame for sensor response

PYTHON
import numpy as np

def normalise_frame(dn: np.ndarray, meta: dict) -> np.ndarray:
    """Digital numbers → a radiance-proportional quantity, using the frame's own tags."""
    bits = meta["BitsPerSample"]
    black = float(np.mean(meta["BlackLevel"]))
    exposure = float(meta["ExposureTime"])          # seconds
    gain = float(meta["ISOSpeed"]) / 100.0

    assert exposure > 0, "exposure time of zero — metadata was stripped by a file copy"
    assert gain > 0, "gain of zero — check ISOSpeed tag"

    scaled = (dn.astype("float64") - black) / (2 ** bits - 1)
    return scaled / (exposure * gain)

The assertions are not defensive padding. Copying captures through a tool that rewrites EXIF — some cloud sync clients do — silently zeroes these tags, and dividing by zero produces an array of infinities that survives all the way to a mosaic before anyone notices.

Step 2 — Correct vignetting

PYTHON
def vignette_correction(shape: tuple[int, int], meta: dict) -> np.ndarray:
    """Per-pixel multiplicative correction from the camera's radial polynomial."""
    cx, cy = meta["VignettingCenter"]
    coeffs = list(meta["VignettingPolynomial"])      # ascending order, k1..kn
    rows, cols = np.indices(shape)
    r = np.sqrt((cols - cx) ** 2 + (rows - cy) ** 2)

    poly = np.ones_like(r, dtype="float64")
    for i, k in enumerate(coeffs, start=1):
        poly += k * r ** i
    correction = 1.0 / poly
    assert np.isfinite(correction).all(), "vignette polynomial produced non-finite values"
    assert 0.2 < correction.min() and correction.max() < 5.0, (
        f"vignette correction spans {correction.min():.2f}{correction.max():.2f} — "
        "coefficient order is probably reversed")
    return correction

The range assertion catches the classic error of feeding coefficients in the wrong order, which produces a correction that darkens the centre and blows out the edges — visually obvious in a single frame and completely invisible after mosaicking, where it becomes a subtle texture that a threshold map will happily classify as zones.

Step 3 — Derive the reflectance factor from the panel

PYTHON
def panel_factor(panel_frame: np.ndarray, panel_mask: np.ndarray,
                 panel_reflectance: float) -> float:
    """Scale factor converting radiance-proportional values to reflectance for this band."""
    pixels = panel_frame[panel_mask]
    assert pixels.size > 500, f"only {pixels.size} panel pixels — mask is too tight"

    # Reject specular highlights and shadowed edges before averaging.
    lo, hi = np.percentile(pixels, [5, 95])
    core = pixels[(pixels >= lo) & (pixels <= hi)]
    mean = float(core.mean())
    assert mean > 0, "panel mean is zero — wrong frame or a fully saturated capture"
    assert core.std() / mean < 0.05, (
        f"panel radiance varies by {core.std() / mean:.1%} across the panel — "
        "shadow, glare or an off-nadir capture")
    return panel_reflectance / mean

Panel reflectance is per band and comes from the panel’s certificate. Generic values — “it’s a 50% panel” — introduce a multiplicative bias of several percent that is constant within a flight and different between panels, which is precisely the error that makes two aircraft’s data non-comparable.

Step 4 — Apply irradiance correction within the flight

PYTHON
def irradiance_corrected(radiance: np.ndarray, frame_irradiance: float,
                         panel_irradiance: float, factor: float) -> np.ndarray:
    """Reflectance with the illumination change since the panel capture divided out."""
    assert panel_irradiance > 0 and frame_irradiance > 0, "irradiance readings must be positive"
    ratio = panel_irradiance / frame_irradiance
    assert 0.2 < ratio < 5.0, (
        f"irradiance ratio {ratio:.2f} — the light changed by more than a factor of five; "
        "this flight spans cloud and should be split or reflown")
    return radiance * factor * ratio

The bound on the ratio is a policy, not a physical limit. A flight that runs from full sun into heavy cloud can be corrected arithmetically and should not be trusted: the sky’s spectral composition changes as well as its intensity, so the correction is right for total irradiance and wrong per band.

Step 5 — Assemble the calibrated stack

Run the four steps per band per frame, then hand the result to the mosaicking step in orthomosaic stitching workflows. Calibrate before mosaicking, never after — a mosaic blends frames with different exposures, and once blended the per-frame tags no longer apply to any pixel.

3. Key Parameters and Tuning

Parameter Type Default Agronomic effect
Panel reflectance per band float from certificate A 5% error in the panel value is a 5% multiplicative bias on every reflectance, shifting NDVI by roughly 0.01–0.03 and moving zone boundaries
Panel pixel percentile window tuple (5, 95) Wider windows admit glare and shadowed edges; narrower ones can leave too few pixels for a stable mean
Panel captures per flight int 2 (take-off and landing) One capture cannot reveal drift; two that disagree by more than a few percent mean the flight needs the irradiance sensor to be trusted
Irradiance ratio limit float 0.2–5.0 Sets what counts as an untrustworthy light change; tighter limits reject usable broken-cloud flights, looser ones let spectral shifts through
Solar elevation minimum ° 30 Below this, shadow fraction and bidirectional effects grow quickly; early-morning flights are not comparable with midday ones
Maximum flight duration min 25 Longer flights span more illumination change than one panel pair can anchor
Saturation threshold DN 0.98 × full scale Pixels above it are clipped and must be masked, not corrected — bright soil and panel glare are the usual sources

4. Edge Cases and Failure Modes

Metadata stripped in transit. The single most common failure. Copy captures with tools that preserve EXIF, and assert the presence of every required tag at ingest rather than discovering the absence at calibration.

A panel photographed in shadow. The operator’s own shadow across a corner of the panel biases the mean downward and inflates every reflectance in the flight. The standard deviation check in step 3 catches it.

Saturated pixels. Sunlit soil and the panel’s specular lobe can clip the sensor. A clipped pixel carries no information and correcting it produces confident nonsense — mask it, and if more than a few percent of the field is clipped, the exposure settings were wrong.

Mixed panels between flights. Two panels with different certificates used on alternate weeks introduce a step in the time series exactly at the change. Record the panel’s serial number in the flight metadata so the step is explainable rather than mysterious.

Sun angle across the season. Solar elevation at 11:00 in April is far lower than at 11:00 in July. Calibration handles illumination intensity, not geometry, so a seasonal trend in index values can be partly geometric. Record solar elevation and azimuth per flight and check any suspicious trend against them.

Aged or dirty panels. Calibration panels degrade with dust, moisture and handling. Recertify annually and clean before each flight; a panel whose true reflectance has dropped 3% silently inflates every reflectance value by the same amount.

5. Verification and Output Validation

PYTHON
def validate_reflectance(stack: dict[str, np.ndarray], band_order: list[str]) -> None:
    """Assert a calibrated stack is physically plausible before it leaves the pipeline."""
    for band, arr in stack.items():
        finite = arr[np.isfinite(arr)]
        assert finite.size > 0, f"{band}: no finite pixels"
        assert finite.min() > -0.05, f"{band}: negative reflectance {finite.min():.3f}"
        assert np.percentile(finite, 99) < 1.5, (
            f"{band}: 99th percentile reflectance {np.percentile(finite, 99):.2f} — "
            "check panel factor and saturation masking")

    # Vegetation must be dark in red and bright in NIR — the cheapest possible sanity check.
    red, nir = stack["red"], stack["nir"]
    veg = np.isfinite(red) & np.isfinite(nir) & (nir > 0.2)
    assert veg.mean() > 0.05, "almost no vegetated pixels — band assignment may be wrong"
    assert np.nanmedian(red[veg]) < np.nanmedian(nir[veg]) * 0.5, (
        "red is not markedly darker than NIR over vegetation — bands are probably swapped")
Four checks, one of which catches swapped bands Four validation checks on a calibrated stack: reflectance stays within physical bounds, vegetation is markedly darker in red than in near infrared, the take-off and landing panel factors agree, and any in-scene reference target's residual is recorded for trend analysis. Range check above −0.05, 99th below 1.5 Vegetation check red darker than NIR Panel agreement take-off vs landing Reference target residual recorded Swapped red and NIR bands produce a clean, perfectly inverted index — the second check is the only one of the four that notices.

The final assertion is the one worth keeping forever. Swapped red and NIR bands produce a beautifully clean, entirely wrong index whose sign is inverted, and no shape, CRS or range check will notice — this comparison will. Feed it a deliberately swapped stack in the test suite and assert that it raises.

For flights carrying in-scene reference targets — a grey tarp of known reflectance, a gravel pad measured once with a field spectrometer — compare the calibrated value against the reference and record the residual with the mosaic. A residual that drifts across a season is the earliest possible warning that a panel is degrading.

6. Integration with the Broader Pipeline

Calibrated reflectance is the input contract for everything downstream. Calculating NDVI and NDRE with rasterio and Python script for SAVI calculation on drone tiles both assume it; threshold mapping for crop health assumes the fixed agronomic cutoffs it uses were derived from calibrated data, which is why applying them to raw digital numbers produces zone maps that change with the weather. Cross-sensor comparison — a drone flight against a satellite pass — is only meaningful once both are in reflectance, which is the common ground that makes parsing Sentinel-2 vs drone multispectral bands useful rather than academic.

Where illumination varied so much that calibration cannot rescue a flight, the affected frames become a masking problem instead — see detecting cloud shadows in drone imagery.

Frequently Asked Questions

Does the photogrammetry software already do this? Most packages will, if you give them the panel captures and the camera model, and if you tell them to. The failure mode is silent: a project processed without the panel step produces a mosaic that looks perfect and carries arbitrary units. Check the output’s value range — reflectance sits between 0 and about 1.2, and digital numbers do not.

How much does calibration change an index in practice? Between two flights an hour apart under stable sun, uncalibrated NDVI over the same canopy commonly differs by 0.03–0.08; under variable cloud, 0.15 or more. Crop stress signals of interest are frequently smaller than that, which is the whole argument.

Can I calibrate retrospectively? Only if the raw frames and their metadata still exist. Once a mosaic has been blended from uncalibrated frames the per-frame information is gone. This is the practical reason to keep raw frames for at least one season, as discussed in cloud-optimized storage for field imagery.


This topic is part of Drone Imagery Processing & Vegetation Index Workflows — see there for the full pipeline from capture to prescription.