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.
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
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.
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.