Applying DLS Irradiance Correction to MicaSense Captures
One-sentence answer: read each frame’s downwelling irradiance, compensate it for the sensor’s tilt, smooth lightly, and scale the frame by the ratio of the panel-capture irradiance to the frame’s own — and refuse the flight when that ratio spans more than about a factor of five.
Context
A calibration panel anchors a flight to physical units at one instant. Under a stable clear sky that is enough. Under broken cloud it is not: the light can halve between two passes, and a panel captured at take-off says nothing about what the fourth transect was flown under. The visible result is a mosaic with brighter and darker bands that follow the flight lines, and an index map whose “stress” pattern is a record of the weather rather than the crop.
The downwelling light sensor exists to close that gap. It measures incident irradiance per frame, which turns the correction from a single number into a per-frame ratio. This guide implements the irradiance stage of radiometric calibration and reflectance conversion, following converting digital numbers to reflectance with calibration panels.
Prerequisites
Beyond the parent topic’s stack: captures carrying per-frame irradiance and sensor orientation tags, a panel capture processed as in the panel guide, and solar position for the flight — computed from the capture time and location, not assumed.
Step-by-step
1. Read irradiance and orientation per frame.
2. Compensate for tilt using the sensor’s pose and the solar vector.
3. Smooth with a short median filter — long enough to remove turn artefacts, short enough to keep a genuine cloud edge.
4. Scale each frame by the panel-to-frame irradiance ratio.
5. Judge the flight on the range of that ratio.
import numpy as np
from scipy.ndimage import median_filter
def tilt_compensated(irradiance: float, pose: dict, solar_vec: np.ndarray,
min_cos: float = 0.35) -> float:
"""Remove the cosine-response effect of sensor tilt from a downwelling reading."""
roll, pitch, yaw = (np.deg2rad(pose[k]) for k in ("roll", "pitch", "yaw"))
# Sensor normal in the world frame, small-angle composition is adequate for ±30°.
normal = np.array([
np.sin(pitch) * np.cos(yaw) + np.sin(roll) * np.sin(yaw),
np.sin(pitch) * np.sin(yaw) - np.sin(roll) * np.cos(yaw),
np.cos(roll) * np.cos(pitch),
])
normal /= np.linalg.norm(normal)
cos_inc = float(np.dot(normal, solar_vec))
if cos_inc < min_cos:
return float("nan") # too tilted to trust — interpolate from neighbours
cos_flat = float(solar_vec[2]) # what a level sensor would have seen
return irradiance * cos_flat / cos_inc
def smoothed_irradiance(values: np.ndarray, window: int = 9) -> np.ndarray:
"""Median filter: removes single-frame attitude spikes, keeps a real cloud edge."""
filled = values.copy()
nan = ~np.isfinite(filled)
if nan.any(): # linear fill before filtering so gaps do not spread
idx = np.arange(filled.size)
filled[nan] = np.interp(idx[nan], idx[~nan], filled[~nan])
return median_filter(filled, size=window, mode="nearest")
def irradiance_scaled(reflectance: np.ndarray, frame_irr: float, panel_irr: float) -> np.ndarray:
"""Scale a panel-calibrated frame by the change in illumination since the panel capture."""
assert panel_irr > 0 and frame_irr > 0, "irradiance readings must be positive"
ratio = panel_irr / frame_irr
assert 0.2 < ratio < 5.0, (
f"irradiance ratio {ratio:.2f} — the light changed by more than a factor of five during "
"this flight; a scalar correction cannot represent the accompanying spectral shift")
return (reflectance * ratio).astype("float32")
Inline verification — look at the whole flight before trusting any single frame:
raw = np.array([f["irradiance_nir"] for f in frames])
comp = np.array([tilt_compensated(f["irradiance_nir"], f["pose"], solar_vec) for f in frames])
smooth = smoothed_irradiance(comp)
spread_raw = raw.max() / raw.min()
spread_smooth = smooth.max() / smooth.min()
print(f"irradiance spread: raw ×{spread_raw:.2f}, tilt-compensated and smoothed ×{spread_smooth:.2f}")
assert spread_smooth < 5.0, (
f"illumination varied ×{spread_smooth:.1f} across the flight — split it at the cloud edge "
"or refly; do not correct through it")
assert spread_smooth < spread_raw, (
"tilt compensation did not reduce the spread — check the pose tags and the solar vector")
assert np.isfinite(smooth).all(), "gaps remain in the smoothed irradiance series"
Settings worth being deliberate about
| Setting | Default here | Why it matters |
|---|---|---|
| Ratio acceptance band | 0.2–5.0 | Beyond a factor of five the sky’s spectrum has changed as well as its intensity, and a scalar correction leaves every band ratio wrong |
min_cos |
0.35 | Below this incidence cosine the tilt compensation amplifies noise more than it removes bias; those frames are better interpolated |
| Median window | 9 frames | A few seconds at typical capture rates — long enough to remove turn artefacts, short enough to preserve a real cloud edge |
| Solar vector | computed per flight | From capture time and location. A hard-coded sun inverts the correction in the other hemisphere |
| Interpolation policy | fill, do not drop | Dropped frames leave holes in the mosaic; neighbours a second away are accurate well within the correction’s own precision |
| Flight duration | ≤ 25 minutes | Longer flights span more illumination change than a panel pair at each end can anchor |
Under a clear, stable sky none of this changes anything measurable — the ratio stays within a few percent of one all flight. Its value is entirely in the flights you would otherwise have to discard, and in knowing which those are.
Gotchas and edge cases
- Tilt artefacts and cloud look alike frame by frame and nothing alike in sequence. Turn dips are narrow, deep and periodic; cloud is broad and irregular. That is why the median filter is applied over the sequence rather than a threshold over individual readings.
-
A wrong solar vector makes tilt compensation worse than none. Compute solar position from the capture timestamp and location; a hard-coded midday sun in the wrong hemisphere inverts the correction.
-
The sensor sees the sky, the camera sees the ground. Under a partly cloudy sky the two can disagree legitimately — a shadow crossing the field while the sensor is in sun. This is the limit of the method, and it is why cloud shadow detection still has a job to do afterwards, as in detecting cloud shadows in drone imagery.
-
A scalar ratio cannot fix a spectral shift. Overcast light is bluer than direct sun. Correcting total intensity across a sun-to-cloud transition leaves the band ratios wrong, which is precisely what an index is made of — hence the hard limit rather than a warning.
-
Do not smooth so hard that a real cloud edge disappears. A window of nine frames at typical capture rates is a few seconds; a window of a hundred spans a transect and would flatten a genuine event into a slow drift, spreading the error across the whole flight instead of isolating it.
-
Frames with no usable irradiance should be interpolated, not dropped. Dropping them leaves holes in the mosaic; interpolating from neighbours a second or two away is accurate to well within the correction’s own precision.
This guide is part of Radiometric Calibration & Reflectance Conversion — see there for the full chain from raw counts to comparable reflectance.
Related
- Converting Digital Numbers to Reflectance with Calibration Panels — the absolute anchor this correction is relative to
- Detecting Cloud Shadows in Drone Imagery — what to do about shadow the sensor could not see
- Orthomosaic Stitching Workflows — why the correction must happen before frames are blended