Calibrated vs Uncalibrated Multispectral Workflows
There is a strong orthodoxy that multispectral data must be radiometrically calibrated, and a strong practice of not doing it. Both positions are defensible, and the disagreement usually comes from arguing about the method rather than about the question being answered.
Calibration converts digital numbers into surface reflectance, a physical quantity independent of the illumination. That matters enormously for some uses and not at all for others, and the difference is precise enough to decide in advance. This page sets out where each workflow is right, what each costs, and how to test which one a given programme needs — rather than adopting either by default. It draws on the machinery in radiometric calibration in Python.
Audience and prerequisites. Python 3.10+ and a multispectral dataset. The decision this page describes is made before a programme starts; revisiting it a season in is expensive, because uncalibrated data cannot be retrospectively calibrated.
Prerequisites
| Item | Needed for | Approximate cost | Notes |
|---|---|---|---|
| Reflectance panel | Calibrated | One-off, moderate | Needs a certificate and re-measurement over time |
| Downwelling light sensor | Calibrated, variable light | Usually built into the rig | Not optional under broken cloud |
| Panel capture discipline | Calibrated | Two minutes per flight | The most common point of failure |
| Processing time | Calibrated | Tens of minutes per flight | Mostly automatable |
| Permanent ground target | Both | One-off, trivial | A concrete pad; the best diagnostic available |
| Consistent flight timing | Both | Scheduling constraint | Reduces variation more cheaply than calibration |
Conceptual architecture
The question “is calibration necessary” resolves into one sharper question: does the comparison being made cross a boundary that calibration removes?
Comparisons within one flight — which plot is best today, where in the field is the poorest area — are largely unaffected. Whatever the illumination was, it was much the same across the flight, and the ratio form of an index cancels most of what remains. Calibration changes the absolute values and rarely changes the ordering.
Comparisons between flights cross exactly the boundary calibration exists to remove. Two flights differ in sun angle, cloud, atmospheric clarity and camera settings, and an uncalibrated index picks all of that up. This is where the orthodoxy is right and where uncalibrated programmes produce time series that mostly track the weather.
Comparisons against external values — a published threshold, another operator’s data — need calibration and usually more besides, because band centres differ between sensors.
Figure 1 — Three questions, three answers. Most disagreements about calibration are disagreements about which column applies.
Step 1: Test whether it changes the ordering
For within-flight work the practical question is whether calibration changes which plots rank where. It is directly testable on any dataset that has both products.
import numpy as np
from scipy.stats import spearmanr
def ranking_agreement(uncalibrated: dict[str, float],
calibrated: dict[str, float]) -> dict:
"""Does calibration change the ordering of plots within a flight?
Spearman correlation rather than Pearson, because the question is about
rank rather than value: a calibration that shifts every plot by the same
factor changes every number and no decision.
"""
common = sorted(set(uncalibrated) & set(calibrated))
if len(common) < 8:
return {"note": "too few common plots to judge"}
u = np.array([uncalibrated[k] for k in common])
c = np.array([calibrated[k] for k in common])
rho, p = spearmanr(u, c)
top_u = set(np.array(common)[np.argsort(-u)][:max(len(common) // 5, 1)])
top_c = set(np.array(common)[np.argsort(-c)][:max(len(common) // 5, 1)])
overlap = len(top_u & top_c) / max(len(top_c), 1)
return {"plots": len(common), "rank_correlation": float(rho),
"top_quintile_agreement": float(overlap),
"calibration_changes_decisions": bool(rho < 0.95 or overlap < 0.8)}
On stable-light flights this routinely returns a rank correlation above 0.98 and a top-quintile agreement of 1.0, which is a concrete answer to “does it matter here” — and one worth having before committing a programme either way.
Step 2: Test whether it stabilises a series
For between-flight work the test is different: does calibration reduce the variation of something that should not vary.
import numpy as np
def series_stability(target_values: dict[str, dict[str, float]]) -> dict:
"""Variation of a permanent target's index across flights, both ways.
`target_values` maps workflow name to flight date to the index of a
surface that does not change — a concrete pad, a roof. Whatever variation
remains is the workflow's own noise.
"""
out = {}
for workflow, by_flight in target_values.items():
values = np.array(list(by_flight.values()), dtype=float)
out[workflow] = {"flights": int(values.size),
"spread": float(values.max() - values.min()),
"sd": float(np.std(values, ddof=1)) if values.size > 1 else 0.0}
if {"calibrated", "uncalibrated"} <= set(out):
reduction = 1 - out["calibrated"]["sd"] / max(out["uncalibrated"]["sd"], 1e-9)
out["improvement"] = float(reduction)
out["verdict"] = ("calibration materially stabilises the series"
if reduction > 0.4 else
"calibration adds little here — the variation is elsewhere")
return out
A reduction of forty percent or more says the calibration is doing its job. A reduction near zero says something else dominates — most often that the flights were flown at very different times of day, which no radiometric correction addresses.
Figure 2 — The test that settles the argument, using a surface that cannot have changed.
Step 3: Count the real cost of each workflow
The cost argument is usually made loosely — “calibration is expensive” — and it is worth being specific, because most of the cost is not where people assume.
The hardware is a one-off: a panel with a certificate, and a downwelling sensor that is built into most current rigs anyway. Amortised over a season it is negligible next to the flying.
The processing is largely automatable. Once the chain in radiometric calibration in Python exists, a flight costs tens of minutes of compute and no attention.
The field discipline is where the real cost sits, and it is measured in reliability rather than time. Two minutes per flight to photograph the panel is nothing; remembering to do it, in the right light, without a shadow, on every flight for a season, is a process problem. A programme that captures the panel on eighty percent of flights has a calibrated archive with holes in it, which is harder to use than either a fully calibrated or a fully uncalibrated one.
The hidden cost of not calibrating is the one that is never counted: the analyses that cannot be done, and the ones that are done anyway and quietly mislead. A series that tracks the weather is not obviously wrong; it looks like a crop responding to something.
def workflow_cost_model(flights_per_season: int, *, panel_capture_rate: float,
processing_minutes: int = 25) -> dict:
"""A concrete comparison of what each workflow costs over a season."""
usable = int(flights_per_season * panel_capture_rate)
return {
"flights": flights_per_season,
"calibrated_flights": usable,
"field_minutes": flights_per_season * 2,
"processing_hours": round(flights_per_season * processing_minutes / 60, 1),
"archive_gaps": flights_per_season - usable,
"warning": ("a partially calibrated archive is harder to use than either"
if 0 < flights_per_season - usable else None),
}
Step 4: The hybrid that most programmes actually want
The two workflows are not exclusive, and the arrangement that serves most programmes takes the useful parts of each.
Capture for calibration on every flight, whether or not the current analysis needs it. The cost is two minutes and the benefit is an archive that can answer questions nobody has asked yet.
Process uncalibrated for rapid turnaround. Scouting products that need to be with an agronomist the same afternoon do not need the calibrated chain, and the ranking they support is unaffected by skipping it.
Process calibrated for the record. The time series, the trial analysis and anything that will be looked at next season run through the full chain, on whatever schedule suits.
Keep the raw frames. They are the only thing that makes a later reprocessing possible, and a season of them is a few terabytes.
def choose_pipeline(purpose: str, *, turnaround_hours: float) -> dict:
"""Route a flight to the appropriate processing depth."""
fast = turnaround_hours < 6
if purpose in {"scouting", "ranking"} and fast:
return {"pipeline": "uncalibrated", "note": "ranking only; do not compare "
"across flights",
"still_capture_panel": True}
if purpose in {"monitoring", "trial", "archive"}:
return {"pipeline": "calibrated", "note": "full chain; comparable across flights",
"still_capture_panel": True}
return {"pipeline": "calibrated", "note": "default to the recoverable option",
"still_capture_panel": True}
still_capture_panel is True in every branch, which is the point. The processing decision can be revisited at any time; the capture decision cannot.
Step 5: Documenting which workflow produced a number
A programme running both needs every delivered number to say which chain produced it, or the two will eventually be compared to each other.
Three fields are enough: the workflow name, whether a panel and an irradiance record were used, and a one-line statement of what the number may be compared with. That last field is unusual and disproportionately useful — it puts the limitation next to the value rather than in a methods appendix.
def provenance_note(workflow: str, *, panel: bool, irradiance: bool) -> dict:
"""What a delivered index value may and may not be compared with."""
if workflow == "calibrated" and panel:
comparable = ("other calibrated flights of this programme; external values "
"only where band centres match")
elif workflow == "calibrated" and not panel:
comparable = "other flights of this programme, with reduced confidence"
else:
comparable = "other plots in this same flight only"
return {"workflow": workflow, "panel_used": panel,
"irradiance_used": irradiance, "comparable_with": comparable}
A client who receives that line with their numbers will not compare a scouting product against last month’s monitoring product, which is the failure this whole page exists to prevent. Everything else here is about choosing a workflow; this is about making the choice visible in the deliverable, which is what actually stops the misuse.
What calibration does not fix
It is worth naming the limits, because a calibrated programme that still sees unexplained variation often concludes the calibration failed when it did not.
Sun angle changes the shadow fraction within a canopy. At 9 am a crop row casts more shadow than at noon, and shadowed canopy has a genuinely different spectrum. Calibration converts digital numbers to reflectance and does nothing about the proportion of the scene that is shaded. Flying within a consistent window around solar noon addresses this; nothing in processing does.
Phenology moves faster than a monthly flight. A crop can change materially in a week, so a series flown monthly is undersampled for some questions regardless of how well each flight is calibrated.
Soil background changes with moisture. A field photographed the day after rain has a darker soil background, which shifts an index over sparse canopy even when the canopy itself is unchanged. Masking helps; calibration does not.
Band centres are not standardised. Two calibrated sensors measuring the same field can differ by several hundredths in an index, because their bands sit at different wavelengths. This is the reason external thresholds transfer poorly even between well-calibrated programmes.
Recognising these as separate problems is what keeps a calibration effort proportionate. A programme that has solved the calibration and still sees noise is usually looking at one of the four above, and the remedy is a scheduling or sampling change rather than more radiometry.
Parameter deep-dive
| Consideration | Uncalibrated | Calibrated |
|---|---|---|
| Within-flight ranking | Adequate | Adequate |
| Between-flight comparison | Unreliable | The reason it exists |
| External thresholds | Not possible | Possible, with caveats |
| Panel discipline required | None | Two minutes per flight, reliably |
| Processing per flight | Minutes | Tens of minutes |
| Failure mode | Silent: values track light | Loud: a bad panel is detectable |
| Recoverable later | Never | Re-processable from raw frames |
| Hardware needed | None | Panel, ideally a DLS |
The recoverability row is the one that should decide most programmes. A calibrated pipeline can always be run uncalibrated; an uncalibrated archive can never be calibrated afterwards, because the panel images were never taken. That asymmetry makes capturing the panel worthwhile even for a programme that currently only needs within-flight ranking.
Verification and output inspection
Whichever workflow is chosen, the permanent target is the check that keeps it honest.
def workflow_health(target_index_by_flight: dict[str, float],
*, calibrated: bool, tolerance: float = 0.03) -> dict:
"""Continuous check on whichever workflow is in use."""
import numpy as np
values = np.array(list(target_index_by_flight.values()), dtype=float)
spread = float(values.max() - values.min()) if values.size > 1 else 0.0
if calibrated:
ok = spread <= tolerance
note = ("calibration is consistent across flights" if ok else
f"target moved by {spread:.3f} — check panel handling and the DLS")
else:
ok = True
note = (f"target varies by {spread:.3f} across flights — expected without "
"calibration; do not compare index values between flights")
return {"spread": spread, "ok": ok, "note": note}
Reporting the uncalibrated case as “expected” rather than as a failure is deliberate. The variation is not a fault in an uncalibrated workflow; it is the property that defines its limits, and stating it is what stops somebody using the data for a comparison it cannot support.
Figure 3 — Calibration is bought per question, not per flight.
Troubleshooting
Calibrated values still differ between flights. Check the panel first — shading, handling, a single value applied across bands — then the downwelling sensor’s attitude correction, then whether the flights were at very different sun angles.
Uncalibrated rankings disagree between two flights. Expected if anything about the illumination differed within either flight. Ranking is reliable within a flight, not across.
Calibration made the numbers worse. Usually a bad panel measurement, which applies a wrong constant to everything. The panel step fails loudly if checked and silently if not.
A client compares values against a published threshold. Band centres differ between sensors by tens of nanometres. Provide the value and the caveat together.
The programme has uncalibrated history and now needs calibration. The history cannot be recovered. Start the calibrated series and keep both for an overlap period so the step is documented.
Panel capture is being skipped under time pressure. Make it part of the pre-flight checklist rather than a processing requirement. Two minutes on the ground is cheaper than an uncomparable flight, and a crew that treats it as part of take-off rather than as an extra step does not skip it.
Two operators produce different values from the same flight. Compare the panel measurement first: the interior region each of them used is the most likely difference, and it scales every value in the survey.