Converting Digital Numbers to Reflectance with Calibration Panels
One-sentence answer: normalise the panel frame for exposure, gain and black level, take a trimmed mean over the panel’s interior pixels, and divide the panel’s certified per-band reflectance by that mean — the resulting factor converts every frame in the flight from counts to reflectance.
Context
A digital number is a count of electrons under one particular exposure, and it is meaningless outside that frame. The panel is the only object in the scene whose true reflectance is known, which makes it the anchor for everything else: one capture of a known surface under the same light gives you the scale factor that turns the whole flight into physical units.
Getting it wrong is easy and quiet. The operator’s shadow across the panel biases the mean low and inflates every reflectance in the flight; a glare lobe biases it high and deflates them; a generic panel value adds a fixed multiplicative error nobody notices until two aircraft’s maps disagree. This guide implements the panel step of radiometric calibration and reflectance conversion.
Prerequisites
Beyond the parent topic’s stack: panel captures with intact metadata, the panel’s certificate values per band, and a way to locate the panel in the frame — a detected marker, or a manually drawn mask stored with the flight.
Step-by-step
1. Normalise the panel frame for exposure, gain and black level.
2. Mask and trim the panel pixels.
3. Derive the factor per band.
4. Apply to every frame.
5. Verify against physical limits and the landing panel capture.
import numpy as np
PANEL_REFLECTANCE = { # from the panel's certificate — never a generic value
"blue": 0.508, "green": 0.507, "red": 0.506, "rededge": 0.505, "nir": 0.503,
}
def normalise(dn: np.ndarray, meta: dict) -> np.ndarray:
"""Digital numbers → radiance-proportional values using the frame's own tags."""
bits = meta["BitsPerSample"]
black = float(np.mean(meta["BlackLevel"]))
exposure = float(meta["ExposureTime"])
gain = float(meta["ISOSpeed"]) / 100.0
assert exposure > 0 and gain > 0, (
"exposure or gain is zero — frame metadata was stripped, probably by a file copy")
return ((dn.astype("float64") - black) / (2 ** bits - 1)) / (exposure * gain)
def panel_factor(panel_dn: np.ndarray, panel_mask: np.ndarray, meta: dict, band: str) -> float:
"""Scale factor converting radiance-proportional values to reflectance for one band."""
rad = normalise(panel_dn, meta)
pixels = rad[panel_mask]
assert pixels.size > 500, f"{band}: only {pixels.size} panel pixels — mask is too tight"
lo, hi = np.percentile(pixels, [5, 95])
core = pixels[(pixels >= lo) & (pixels <= hi)]
mean = float(core.mean())
assert mean > 0, f"{band}: panel mean is zero — wrong frame, or a fully saturated capture"
spread = float(core.std()) / mean
assert spread < 0.05, (
f"{band}: panel radiance varies by {spread:.1%} across the trimmed pixels — "
"shadow, glare or an off-nadir capture; recapture rather than calibrate")
reflectance = PANEL_REFLECTANCE[band]
return reflectance / mean
def to_reflectance(frame_dn: np.ndarray, meta: dict, factor: float,
saturation_frac: float = 0.98) -> np.ndarray:
"""Apply a per-band factor to a frame, masking saturated pixels rather than correcting them."""
full_scale = 2 ** meta["BitsPerSample"] - 1
saturated = frame_dn >= saturation_frac * full_scale
refl = normalise(frame_dn, meta) * factor
refl[saturated] = np.nan
if saturated.mean() > 0.02:
print(f"warning: {saturated.mean():.1%} of pixels saturated — exposure was too high")
return refl.astype("float32")
Inline verification — compare the two panel captures and check the result is physical:
takeoff = {b: panel_factor(panel_a[b], mask_a, meta_a[b], b) for b in PANEL_REFLECTANCE}
landing = {b: panel_factor(panel_b[b], mask_b, meta_b[b], b) for b in PANEL_REFLECTANCE}
for band in PANEL_REFLECTANCE:
drift = abs(landing[band] - takeoff[band]) / takeoff[band]
print(f"{band:8s} factor {takeoff[band]:.4g} → {landing[band]:.4g} ({drift:+.1%})")
assert drift < 0.10, (
f"{band}: panel factor drifted {drift:.1%} across the flight — "
"the light changed; use the downwelling irradiance record or refly")
refl = to_reflectance(field_frame["nir"], meta_field["nir"], takeoff["nir"])
finite = refl[np.isfinite(refl)]
assert finite.min() > -0.05, f"negative reflectance {finite.min():.3f}"
assert np.percentile(finite, 99) < 1.5, "99th percentile above 1.5 — factor or saturation problem"
print(f"NIR reflectance median {np.nanmedian(finite):.3f}")
Settings worth being deliberate about
| Setting | Default here | Why it matters |
|---|---|---|
| Panel reflectance | from the certificate, per band | A 5% error here is a 5% multiplicative bias on every pixel of the flight, worth 0.01–0.03 NDVI — enough to move a zone boundary |
| Trim percentiles | 5th–95th | Removes the shadowed edge and the specular lobe. Widening admits both; narrowing can leave too few pixels for a stable mean |
| Minimum panel pixels | 500 | Below this the trimmed mean is noisy. If the mask is that tight, the panel was too far away or the mask is wrong |
| Spread limit | 5% of the mean | The rejection criterion. A capture that fails it has shadow, glare or an off-nadir view, and no arithmetic fixes any of them |
| Panel captures | 2 per flight | Take-off and landing. One cannot reveal drift; two that disagree tell you the flight needs an irradiance record |
| Saturation threshold | 98% of full scale | Above it a pixel is clipped and carries no information — mask it rather than correcting it |
Record the panel’s serial number with every flight. Panels degrade with dust and handling, and when a seasonal index series develops a step, the fastest question to answer is whether the panel changed that week.
Gotchas and edge cases
- The panel’s certificate is per band and not flat. Panels are engineered to be near-Lambertian and near-neutral, not perfectly so; the values differ by a few thousandths between bands and matter at the precision an index needs.
-
A shadow on one corner passes a naive mean and fails the spread check. That is exactly what the standard-deviation assertion is for. Never widen it to make a capture pass — recapture instead; a biased factor is applied to every pixel of the flight.
-
Saturated pixels must be masked, not corrected. A clipped pixel carries no information about how bright it really was. Correcting it produces a confident, wrong value; masking it produces a hole the mosaic can fill from an adjacent frame.
-
Panels degrade. Dust, moisture and handling drop a panel’s true reflectance over time, which inflates every calibrated value by the same proportion. Clean before each flight, recertify annually, and record the panel’s serial number with the flight so a step in the seasonal series can be explained.
-
Calibrate before mosaicking, never after. A mosaic blends frames captured at different exposures, so no single frame’s metadata applies to the blended pixels — the ordering point made in the parent topic and in orthomosaic stitching workflows.
-
Locating the panel automatically is worth the effort, but verify the mask. Marker detection occasionally latches onto a bright object near the panel — a vehicle roof, a white bucket — and produces a mask that is confidently in the wrong place. Because the resulting factor is still a finite number, nothing fails; the whole flight is simply scaled wrongly. Render the mask over the panel frame for one capture per flight and look at it, or assert that the masked region’s area matches the panel’s known size at the capture distance.
This guide is part of Radiometric Calibration & Reflectance Conversion — see there for vignetting, irradiance and the full calibration chain.
Related
- Applying DLS Irradiance Correction to MicaSense Captures — tracking light change between the two panel captures
- Calculating NDVI and NDRE with Rasterio Step by Step — the first consumer of calibrated reflectance
- Ingesting Multispectral Drone Imagery — preserving the metadata this calibration depends on