Applying Reflectance Panel Calibration in Python

Every reflectance value a multispectral survey produces traces back to two photographs of a small grey board. If those two images are measured badly, every number in the deliverable is wrong by the same factor — and because the error is a constant multiplier, nothing in the output looks unusual. The orthomosaic is beautiful, the index raster is smooth, and the crop appears to have twenty percent less chlorophyll than it does.

This page covers doing that measurement correctly: locating the panel, choosing which pixels of it to use, applying per-band certificate values, and handling the two captures a flight produces. It is the absolute-scale step of radiometric calibration in Python.

What goes wrong with a panel measurement

Including the frame or the surround. The panel sits in a holder, usually darker than the panel itself. A bounding box that includes any of it drags the mean radiance down, and the calibration factor up, so every reflectance in the survey comes out high.

Measuring a shadowed corner. The operator’s own shadow, or the airframe’s, falls across part of the panel more often than anyone expects. A shadowed region can be 40 % darker, and a mean over it produces the same error as including the frame, only larger.

Specular glint. Panels are diffuse but not perfectly so. At certain sun-camera geometries a bright patch appears, pulling the mean the other way.

One reflectance value for five bands. A panel’s reflectance is close to flat but not flat, and the certificate gives a value per band precisely because the differences — typically one to three percent — matter at the accuracy multispectral work claims.

Saturation. A bright panel photographed at an exposure set for a dark crop clips. Clipped pixels are not measurements, and their inclusion biases the mean low by an amount that depends on how many clipped.

Which pixels of a panel image are usable A panel image with several regions marked. The outer dark holder frame is excluded. A shadowed strip across one corner is excluded. A small specular bright patch near the centre is excluded. A saturated region where pixels reached the sensor maximum is excluded. The remaining central area, about half the panel, is the usable interior from which the median radiance is taken. A note states that a naive bounding-box mean over the whole panel differs from the correct value by around fifteen percent in this example. holder frame — exclude shadow glint clipped usable interior measured radiance whole box: 2 480 interior only: 2 910 15 % error applied to every pixel of the whole survey The error is a constant multiplier, so nothing downstream looks wrong. Which is why the panel step deserves more care than its two photographs suggest.

Figure 1 — The panel image, and the four populations that must come out of the measurement.

Minimal reproducible solution

Detect the panel, erode inward, then take a robust statistic over what survives.

import cv2
import numpy as np


def measure_panel(frame: np.ndarray, *, saturation: int = 65000,
                  erode_px: int = 12, keep_percentile: tuple = (20, 80)) -> dict:
    """Median radiance over the usable interior of a reflectance panel.

    Three defences in one function: erosion pulls the measurement away from
    the holder and any edge shadow, the saturation mask removes clipped
    pixels, and the percentile window removes both glint and residual shadow
    without needing to identify them individually.
    """
    blurred = cv2.GaussianBlur(frame.astype(np.float32), (9, 9), 0)
    thresh = np.percentile(blurred, 85)
    mask = (blurred > thresh).astype(np.uint8)

    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL,
                                   cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        raise ValueError("no panel-like bright region found in the frame")
    panel = max(contours, key=cv2.contourArea)

    filled = np.zeros_like(mask)
    cv2.drawContours(filled, [panel], -1, 1, thickness=cv2.FILLED)
    kernel = np.ones((erode_px * 2 + 1,) * 2, np.uint8)
    interior = cv2.erode(filled, kernel).astype(bool)

    values = frame[interior]
    values = values[values < saturation]
    if values.size < 500:
        raise ValueError(f"only {values.size} usable panel pixels — check for "
                         "shadow, saturation or a mis-detected region")

    lo, hi = np.percentile(values, keep_percentile)
    core = values[(values >= lo) & (values <= hi)]
    return {"radiance": float(np.median(core)),
            "pixels_used": int(core.size),
            "saturated_fraction": float(np.count_nonzero(frame[interior] >= saturation)
                                        / max(interior.sum(), 1)),
            "spread": float(np.std(core, ddof=1) / max(np.mean(core), 1e-9))}

The percentile window between the 20th and 80th is the part doing most of the work. It removes glint and shadow without requiring either to be detected, and on a clean panel it changes the answer by almost nothing — which is the property you want from a defensive measure.

Reporting the relative spread gives a direct quality signal. A clean panel measurement has a spread under about 2 %; a figure above 5 % means the interior is not uniform and something — shadow, dirt, a fold in the panel cover — needs attention before the values are used.

Applying the per-band certificate

def panel_factors(measurements: dict[str, dict],
                  certificate: dict[str, float]) -> dict[str, float]:
    """Radiance-to-reflectance factor per band, from measurement and certificate.

    The certificate is per band for a reason: a panel's reflectance varies by
    one to three percent across the visible and near-infrared range, which is
    the same order as the differences an index is being used to detect.
    """
    missing = set(measurements) - set(certificate)
    if missing:
        raise KeyError(f"no certificate value for band(s) {sorted(missing)}")

    factors = {}
    for band, m in measurements.items():
        if m["radiance"] <= 0:
            raise ValueError(f"{band}: non-positive panel radiance")
        factors[band] = certificate[band] / m["radiance"]
    return factors
Four ways a panel calibration goes wrong before any arithmetic Four rows. A panel imaged in shadow, even partial, yields an incident irradiance unlike the one the survey flew under, and the resulting scale factor is wrong by whatever fraction was shaded. A panel imaged at an angle breaks the assumption of near-nadir viewing its reflectance factors were measured under. A dirty or aged panel no longer matches the calibration certificate shipped with it, and the divergence is wavelength-dependent rather than a constant. A saturated panel pixel carries no information at all, and averaging a saturated region silently biases the factor downward. imaged in shadow even partial — the irradiance is not the one the survey flew under imaged off-nadir breaks the near-nadir assumption its reflectance factors were measured under dirty or aged no longer matches the certificate, and the divergence varies with wavelength saturated pixels carry no information; averaging a saturated region biases the factor down All four produce a plausible number. Only the panel region's own statistics reveal them.

Figure 3 — Four failures, all of which still produce a calibration factor.

Edge-case matrix

Situation Naive result Correct handling
Holder included in the region Reflectance biased high Erode inward before measuring
Shadow on part of the panel Reflectance biased high Percentile window, or re-shoot
Specular glint Reflectance biased low Percentile window
Panel saturated Biased low, unpredictably Exclude clipped pixels; re-shoot at lower exposure
Single certificate value Band-dependent bias Per-band values, always
Panel photographed in shade Low radiance, high factor Re-shoot under flight illumination
Only one panel image No check on drift Use it, and note the limitation
Pre and post disagree strongly Ambiguous scale Interpolate; investigate if the gap is large

The last row needs a rule. A pre/post disagreement under about 5 % is normal and is handled by interpolation. Above 15 %, something changed materially — the light, the panel’s position, or a camera setting — and the flight’s calibration is not trustworthy without knowing which.

def reconcile_panels(pre: dict, post: dict, *, warn_at: float = 0.05,
                     fail_at: float = 0.15) -> dict:
    """Judge the agreement between the two panel captures of a flight."""
    a, b = pre["radiance"], post["radiance"]
    rel = abs(a - b) / max((a + b) / 2, 1e-9)
    if rel > fail_at:
        raise ValueError(
            f"panel captures differ by {rel:.0%} — the illumination or a camera "
            "setting changed materially during the flight")
    return {"relative_difference": rel,
            "interpolate": rel > warn_at,
            "note": ("interpolate the factor across the flight" if rel > warn_at
                     else "stable light; either capture is representative")}

Verification snippet

The panel calibration can be checked against itself using a second, independent target.

import numpy as np


def verify_against_second_target(reflectance: np.ndarray, target_mask: np.ndarray,
                                 true_reflectance: float,
                                 tol: float = 0.02) -> dict:
    """A second known surface must calibrate to its own known reflectance."""
    vals = reflectance[target_mask & np.isfinite(reflectance)]
    if vals.size < 200:
        return {"ok": False, "note": "too few target pixels"}
    got = float(np.median(vals))
    err = got - true_reflectance
    return {"measured": got, "expected": true_reflectance, "error": err,
            "ok": abs(err) <= tol,
            "note": ("calibration verified" if abs(err) <= tol else
                     f"off by {err:+.3f} — check the panel measurement")}

Using a second target rather than the calibration panel is what makes this a test. A field of fresh concrete, a painted board, or a second panel from a different batch all work; what matters is that its reflectance was established independently of the one being verified.

Interpolating the panel factor across a flight with changing light A time series across a twenty-two minute flight. The downwelling irradiance record rises from a cloudy start to a clear finish, with a dip in the middle. Two panel factor measurements are marked, one before take-off and one after landing, differing by about eight percent. A straight interpolation between them is drawn as a dashed line, and an irradiance-weighted interpolation is drawn as a solid line that follows the measured light including the mid-flight dip. A note states that the two interpolations differ most in the middle of the flight, which is where most of the frames are. 0 min 6 11 17 22 time through the flight measured irradiance linear in time weighted by irradiance The two agree at the endpoints and differ in the middle, where most of the frames are.

Figure 2 — Why the interpolation method matters on a flight whose light changed.

Capture practice that makes the measurement easy

Most of the defensive code above exists to cope with panel images that could have been better. Four habits at capture time remove the need for most of it.

Photograph the panel at the same altitude relation the camera will use in flight — close enough that the panel fills a good fraction of the frame, far enough that the camera is focused as it will be in the air. A panel occupying twenty pixels is a measurement with no interior left after erosion.

Stand so that neither the operator nor the aircraft casts a shadow anywhere on the panel, and check the captured image rather than assuming. The most common shadow is the operator’s own, cast while leaning over to trigger the capture.

Keep the panel horizontal and the sun behind the camera. A tilted panel receives a different irradiance from the horizontal surfaces the flight will measure, and the resulting factor is wrong by the cosine of the tilt.

Capture at the exposure the flight will use, or at least verify that nothing clipped. A panel image at a different exposure is usable — the normalisation divides it out — but a clipped one is not recoverable at all.

When to escalate

  • The panel is damaged, dirty or its certificate has expired. Panels degrade, particularly the higher-reflectance ones. A panel whose certificate is several years old should be re-measured before it is trusted at the accuracy multispectral work claims.
  • Pre and post captures differ by more than about 15 %. Something changed during the flight beyond the light. Find out what before calibrating anything.
  • No panel image exists at all. Some sensors can calibrate from the downwelling sensor alone with a vendor factor, which is less accurate but not nothing. State the method in the deliverable rather than implying a panel calibration.

Radiometric Calibration in Python