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+,
numpy1.26.,rasterio1.3.,opencv-python4.9.* for undistortion,exifread3.0.* orpyexiftoolfor 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.
2. Step-by-Step Implementation
Step 1 — Normalise the frame for sensor response
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
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
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
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
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")
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.
Related
- Converting Digital Numbers to Reflectance with Calibration Panels — a runnable panel workflow with masking and per-band factors
- Applying DLS Irradiance Correction to MicaSense Captures — using the downwelling sensor to track light change within a flight
- Band Math & Raster Algebra in Python — the first consumer of a calibrated stack
- Ingesting Multispectral Drone Imagery — metadata parsing and band alignment upstream of calibration
- Detecting Cloud Shadows in Drone Imagery — what to do when the light changed more than calibration can absorb