Radiometric Calibration in Python

A multispectral camera does not measure reflectance. It measures how many electrons landed in each photosite during an exposure, converted to a digital number by circuitry with its own offset and gain. Between that number and the property of interest — how much of the incident light the surface reflected — sit five effects, each of which varies within a single flight.

Calibration is the process of removing them in order. Done properly, two flights over the same field a month apart produce index values that differ only because the field changed. Done partially, or in the wrong order, they produce values that differ mostly because of the weather.

This page covers the full chain in Python: what each term is, how to obtain it, the order they must be applied in, and how to verify the result. It is the foundation for everything in multispectral, thermal and index mapping pipelines.

Audience and prerequisites. Python 3.10+, raw multispectral frames with their metadata intact, panel images from the same flight, and — where the light was variable — a downwelling irradiance record. Frames that have been through any “enhancement” cannot be calibrated; the corrections are not invertible.

Prerequisites

Library / tool Minimum version Install command Role
numpy ≥ 1.24 pip install numpy The arithmetic
rasterio ≥ 1.3 pip install "rasterio>=1.3" Reading and writing band rasters
exiftool / pyexiftool ≥ 0.5 pip install pyexiftool Vendor metadata: exposure, gain, irradiance
opencv-python ≥ 4.8 pip install opencv-python Panel detection, band alignment
scipy ≥ 1.10 pip install scipy Vignette surface fitting

Conceptual architecture

The measurement chain runs in one direction and calibration runs backwards along it. Light of some spectral irradiance falls on a surface, which reflects a fraction of it. That reflected radiance passes through the lens — attenuated toward the frame edges — onto the sensor, which integrates it for the exposure time, multiplies by the gain, adds its own dark offset, and quantises.

Reversing that gives the order the corrections must be applied in: subtract dark, divide by exposure and gain, divide by the vignette field, then divide by irradiance, then scale to the panel’s known reflectance. Applying them out of order is not a refinement issue — dividing by the vignette before subtracting dark current amplifies the offset differently across the frame, and the resulting error has the shape of the lens.

The measurement chain, and calibration running backwards along it Two parallel sequences. The upper sequence runs left to right and represents what the camera does to the signal: incident irradiance, surface reflectance, lens vignetting, exposure and gain, and dark offset, ending at a digital number. The lower sequence runs right to left and represents calibration undoing each step in reverse: subtract dark, divide by exposure and gain, divide by the vignette field, divide by irradiance, and scale to the panel reflectance, ending at surface reflectance. A note states that applying the steps out of order produces an error shaped like the lens. what the camera does → irradiance reflectance vignetting exposure · gain dark offset → DN ← what calibration undoes 1 · subtract dark 2 · ÷ exp · gain 3 · ÷ vignette 4 · ÷ irradiance 5 · × panel the order is not a preference dividing by the vignette before subtracting dark leaves an error shaped exactly like the lens

Figure 1 — Five terms, one order. Each step in the lower row inverts the step above it.

Step 1: Read the metadata that the corrections need

Everything except the vignette field and the panel certificate comes from the frame’s own metadata, and the tag names are vendor-specific.

import exiftool


REQUIRED = ["ExposureTime", "ISOSpeed", "BlackLevel", "BandName",
            "CentralWavelength", "Irradiance"]


def frame_metadata(path: str) -> dict:
    """Pull the calibration terms from a frame's metadata, failing loudly.

    A missing tag is not something to default around: a frame without an
    exposure time cannot be normalised, and silently assuming one produces a
    reflectance value that is wrong by whatever the real exposure was.
    """
    with exiftool.ExifToolHelper() as et:
        tags = et.get_metadata(path)[0]

    out = {}
    for key in REQUIRED:
        hit = next((v for k, v in tags.items() if k.endswith(key)), None)
        if hit is None:
            raise KeyError(f"{path}: metadata tag {key} is absent — "
                           "this frame cannot be calibrated")
        out[key] = hit
    out["path"] = path
    return out

Failing on a missing tag rather than defaulting is the important design choice. Calibration silently applied with an assumed exposure produces a plausible-looking raster whose values are wrong by a constant factor, which then propagates into every index and every comparison.

Step 2: Dark current, exposure and gain

These three are per-frame scalars and are applied together.

import numpy as np


def normalise_frame(raw: np.ndarray, meta: dict) -> np.ndarray:
    """Remove the sensor offset and the camera's exposure choices.

    Clipping at zero after the dark subtraction is deliberate: genuinely dark
    pixels sit at or slightly below the black level through noise, and a
    small negative radiance is physically meaningless but numerically
    destructive once it reaches a ratio index.
    """
    dn = raw.astype(np.float64)
    dark = float(meta["BlackLevel"])
    exposure = float(meta["ExposureTime"])
    gain = float(meta["ISOSpeed"]) / 100.0

    normalised = (dn - dark) / (exposure * gain)
    return np.maximum(normalised, 0.0)

The ISO-to-gain conversion above is the common convention; some vendors report gain directly. Getting it wrong scales the whole frame, which is invisible in a single image and produces a step between frames flown at different ISO.

Step 3: The vignette field

Every lens delivers less light to the frame corners than to the centre, typically 20–40 % less at the extreme corners for the small lenses on multispectral rigs. Uncorrected, the effect appears in the orthomosaic as a regular pattern of darker patches where frame corners landed — and in an index as a bias that varies with position within each frame.

Vendors supply polynomial vignette coefficients in the metadata. Where they do not, the field can be fitted from a flat-field image: a photograph of a uniformly lit surface, which any reflectance panel large enough to fill the frame provides.

import numpy as np


def vignette_from_coefficients(shape: tuple[int, int], centre: tuple[float, float],
                               coeffs: list[float]) -> np.ndarray:
    """Evaluate a vendor's radial vignette polynomial over the frame.

    The polynomial gives the correction factor as a function of radius from
    the optical centre — which is not the image centre, and using the image
    centre instead leaves a visible gradient across the frame.
    """
    h, w = shape
    cy, cx = centre
    yy, xx = np.mgrid[0:h, 0:w]
    r = np.hypot(xx - cx, yy - cy)
    factor = np.zeros_like(r, dtype=np.float64)
    for power, c in enumerate(coeffs):
        factor += c * r ** power
    return 1.0 / np.maximum(factor, 1e-6)


def vignette_from_flat_field(flat: np.ndarray, *, order: int = 4) -> np.ndarray:
    """Fit a smooth radial model to a flat-field image.

    Fitting a smooth model rather than using the flat field directly is what
    keeps sensor noise and dust specks out of every calibrated frame.
    """
    h, w = flat.shape
    cy, cx = h / 2, w / 2
    yy, xx = np.mgrid[0:h, 0:w]
    r = np.hypot(xx - cx, yy - cy).ravel()
    values = flat.ravel().astype(np.float64)
    good = np.isfinite(values) & (values > 0)

    design = np.vander(r[good], order + 1, increasing=True)
    coeffs, *_ = np.linalg.lstsq(design, values[good], rcond=None)
    model = np.vander(r, order + 1, increasing=True) @ coeffs
    model = model.reshape(h, w)
    return model / np.max(model)

The full treatment, including how to tell a vignette problem from an illumination problem, is in correcting vignetting and lens falloff.

Lens falloff across a frame and its effect on an orthomosaic On the left, a profile of relative sensor response across a frame, falling from one at the optical centre to about zero point seven at the corners, with the vendor polynomial overlaid on measured flat-field values. On the right, a plan view of four frames tiled in an orthomosaic, each shaded from bright at its centre to dark at its corners, so the mosaic acquires a regular quilt pattern. A note states that the pattern follows the flight lines rather than the ground, which is the diagnostic that distinguishes it from a real feature. response across the frame 1.00 at centre 0.70 corner 0.70 corner in the orthomosaic darker where frame corners meet The pattern follows the flight lines, not the ground. That is the diagnostic: a real feature does not repeat at the frame spacing.

Figure 2 — Lens falloff, and the quilt it writes into an uncorrected mosaic.

Step 4: Irradiance and the panel

The last two steps convert a corrected radiance into reflectance. The panel supplies the absolute scale; the irradiance record supplies the per-frame variation.

import numpy as np


def reflectance(radiance: np.ndarray, *, irradiance: float,
                panel_radiance: float, panel_reflectance: float,
                panel_irradiance: float) -> np.ndarray:
    """Convert corrected radiance to surface reflectance.

    The panel gives a radiance-to-reflectance ratio measured under the panel
    image's own irradiance. Scaling that ratio by the ratio of irradiances is
    what transfers it to a frame taken under different light — which is the
    entire purpose of the downwelling sensor.
    """
    if panel_radiance <= 0 or panel_irradiance <= 0:
        raise ValueError("panel radiance and irradiance must be positive")
    factor = panel_reflectance / panel_radiance
    illumination_ratio = panel_irradiance / max(irradiance, 1e-9)
    return radiance * factor * illumination_ratio

Under stable light, the illumination ratio is one and the panel alone suffices. Under broken cloud it varies by a factor of two or more between frames, and a calibration that ignores it produces an orthomosaic where the cloud shadows are baked into the reflectance. Using downwelling light sensor data for irradiance covers the practicalities, including what to do when the sensor’s own readings are noisy.

Step 5: Apply the chain, per frame, in order

import numpy as np
import rasterio


def calibrate_frame(raw_path: str, out_path: str, *, meta: dict,
                    vignette: np.ndarray, panel: dict) -> dict:
    """Full calibration of one band frame, written as float reflectance."""
    with rasterio.open(raw_path) as src:
        raw = src.read(1)
        profile = src.profile

    radiance = normalise_frame(raw, meta) / np.maximum(vignette, 1e-6)
    refl = reflectance(radiance,
                       irradiance=float(meta["Irradiance"]),
                       panel_radiance=panel["radiance"],
                       panel_reflectance=panel["reflectance"],
                       panel_irradiance=panel["irradiance"])

    # Physically, reflectance is in [0, 1]; values above about 1.1 indicate a
    # specular return or a calibration error, and are worth counting.
    over = float(np.count_nonzero(refl > 1.1) / refl.size)

    profile.update(dtype="float32", count=1, nodata=np.nan, compress="deflate")
    with rasterio.open(out_path, "w", **profile) as dst:
        dst.write(refl.astype("float32"), 1)
        dst.set_band_description(1, str(meta["BandName"]))
        dst.update_tags(1, WAVELENGTH_NM=str(meta["CentralWavelength"]),
                        PANEL_REFLECTANCE=str(panel["reflectance"]))

    return {"band": meta["BandName"], "over_unity_fraction": over,
            "mean_reflectance": float(np.nanmean(refl))}

Counting pixels above unity is the cheapest diagnostic in the whole chain. A few tenths of a percent are specular glints off water or metal; several percent means something in the calibration is wrong, most often a panel radiance measured over a shadowed part of the panel.

Step 6: Calibrate the flight, not the frame

The functions above handle one frame. A flight is a few thousand of them, and two things have to be decided at that level: which panel measurement applies to which frames, and what to do when the metadata is inconsistent across the set.

Panel assignment is the simpler of the two and still gets mishandled. The standard capture protocol is a panel image before take-off and another after landing. Under stable light either will do and the two agree; under changing light they will not, and the right treatment is to interpolate the panel-derived factor in time between them — with the downwelling record, if present, supplying the shape of the interpolation rather than a straight line.

import numpy as np


def panel_factor_series(pre: dict, post: dict, frame_times: np.ndarray,
                        irradiance: np.ndarray | None = None) -> np.ndarray:
    """Per-frame panel factor, interpolated between the two panel captures.

    With an irradiance record, the interpolation follows the measured light
    rather than assuming a linear drift — which matters on a flight where the
    cloud cleared halfway through.
    """
    t0, t1 = pre["time_s"], post["time_s"]
    f0 = pre["reflectance"] / pre["radiance"]
    f1 = post["reflectance"] / post["radiance"]

    if irradiance is None or t1 <= t0:
        weight = np.clip((frame_times - t0) / max(t1 - t0, 1e-9), 0.0, 1.0)
    else:
        # Follow the irradiance record's own trajectory between the endpoints.
        i0, i1 = irradiance[0], irradiance[-1]
        weight = np.clip((irradiance - i0) / max(i1 - i0, 1e-9), 0.0, 1.0)
    return f0 * (1 - weight) + f1 * weight

Metadata consistency is the second decision, and it is best handled as a gate rather than as a per-frame accommodation. A flight where the exposure varied by a factor of eight, or where two frames report different band names for the same sensor, has something wrong with it that no calibration will repair — and finding out at the calibration stage is much cheaper than finding out from a strange orthomosaic.

def audit_flight_metadata(frames: list[dict]) -> dict:
    """Consistency checks across a whole flight's frames before calibrating."""
    exposures = [float(f["ExposureTime"]) for f in frames]
    bands = {f["BandName"] for f in frames}
    problems = []

    spread = max(exposures) / max(min(exposures), 1e-9)
    if spread > 8:
        problems.append(f"exposure varies by {spread:.0f}× across the flight — "
                        "auto-exposure was active on a radiometric capture")
    if len(bands) != 1:
        problems.append(f"frames report {len(bands)} band names: {sorted(bands)}")
    if any(float(f["Irradiance"]) <= 0 for f in frames):
        problems.append("some frames report zero irradiance — the downwelling "
                        "sensor was obstructed or not recording")
    return {"frames": len(frames), "exposure_spread": spread,
            "problems": problems}

The exposure check deserves a note, because it catches a mistake made at the flight rather than in processing. Radiometric capture should use fixed exposure where the scene allows it; auto-exposure is not fatal — the normalisation divides it out — but a factor of eight means some frames were near saturation and others near the noise floor, and neither end of that range calibrates well.

Parameter deep-dive

Parameter Type Source Typical Effect
BlackLevel int Frame metadata 4000–4400 Sensor floor; omitting it biases dark surfaces high
ExposureTime float, s Frame metadata 0.5–4 ms Scales the whole frame
Gain / ISO float Frame metadata 100–800 Scales the whole frame
Vignette coefficients list Metadata or flat field 4th–6th order Up to 30 % correction at corners
Optical centre (x, y) Metadata near image centre Using the image centre leaves a gradient
Irradiance float Downwelling sensor varies Per-frame illumination; essential under cloud
Panel reflectance float Certificate 0.3–0.7 Absolute scale; per-band, never a single value
Panel radiance float Panel image measured Measure over the panel interior only
Over-unity tolerance float < 1 % Diagnostic for a bad panel measurement

Verification and output inspection

Two checks together validate a calibration: an internal one on the frames, and an external one on a target of known reflectance.

import numpy as np


def calibration_report(reflectance_by_band: dict[str, np.ndarray],
                       known_target_mask: np.ndarray,
                       known_reflectance: dict[str, float],
                       tol: float = 0.03) -> dict:
    """Compare calibrated values over a known target against its true values."""
    findings = []
    measured = {}
    for band, arr in reflectance_by_band.items():
        vals = arr[known_target_mask & np.isfinite(arr)]
        if vals.size < 100:
            findings.append(f"{band}: too few target pixels to judge")
            continue
        got = float(np.median(vals))
        measured[band] = got
        expected = known_reflectance.get(band)
        if expected is not None and abs(got - expected) > tol:
            findings.append(
                f"{band}: measured {got:.3f} against a true {expected:.3f} — "
                f"off by {got - expected:+.3f}")
    return {"measured": measured, "findings": findings,
            "ok": not findings}

A target used for verification must be different from the panel used for calibration, or the check is circular. A second panel, a painted board with a measured reflectance, or even a large patch of fresh concrete whose reflectance was measured once with a field spectrometer will do.

Three calibration references, and what each corrects for Three rows. A reflectance panel imaged before and after the flight gives an absolute anchor at two moments, and interpolating between them assumes the illumination changed smoothly, which cloud makes false. A downwelling light sensor gives a continuous record of incident irradiance per frame, which handles changing cloud but carries its own cosine response error at low sun angles. Both together give an absolute anchor and a continuous correction, and their disagreement is itself the most useful diagnostic available, because it bounds how much either can be trusted. reflectance panel an absolute anchor at two moments — interpolation assumes smooth change downwelling light sensor continuous per frame — handles cloud, carries a cosine response error both together an anchor plus a continuous correction; their disagreement is the best diagnostic With only one reference you cannot tell a calibration failure from real scene variation.

Figure 3 — Two references disagreeing tells you more than either agreeing with itself.

Troubleshooting

Reflectance values exceed 1 across large areas. The panel radiance is too low — usually measured over a shadowed part of the panel, or over its frame rather than its surface. Re-measure over the panel interior only.

A regular pattern of darker patches in the mosaic. Vignetting was not corrected, or was corrected about the image centre rather than the optical centre. The pattern repeats at the frame spacing, which distinguishes it from any ground feature.

Index values track the weather rather than the crop. Irradiance was not applied. Under variable cloud a panel-only calibration bakes the illumination into the reflectance.

One band is systematically higher than the others. A per-band panel reflectance was not used — panels are not spectrally flat, and a single value across five bands introduces a band-dependent bias.

Calibration succeeded but two flights still disagree. Check that the same panel and the same certificate values were used, and that the panel images in each flight were taken under light representative of that flight rather than in shade.

Dark surfaces come out with negative reflectance. The black level is too high for this frame, or the frames were captured at a sensor temperature different from the calibration. Clip at zero and investigate; a large negative population means the dark term is wrong.

Multispectral, Thermal & Index Mapping Pipelines