Colour Balancing Images Before Mosaicking

Correcting a mosaic after it exists is fighting the wrong battle. Once two images have been cut together, the information needed to separate “this area is genuinely darker” from “this image was underexposed” has already been discarded. The correction belongs on the individual images, before the mosaic is assembled.

This page covers that pre-processing stage: normalising exposure from what the camera recorded, solving residual gains across the block, and handling the colour cast that a global brightness fix leaves behind. It is part of orthomosaic radiometry and seamline control.

Start from what the camera recorded

The camera already knows why two frames differ in brightness, and it wrote it into the EXIF. Exposure time, aperture and ISO together determine how much light reached the sensor for a given scene radiance, and normalising by them removes the entire auto-exposure component before any statistics are computed.

The relationship is straightforward. Doubling the exposure time doubles the signal. Halving the f-number quadruples it. Doubling the ISO doubles the recorded value for the same light. A single relative factor captures all three.

import numpy as np


def exposure_factor(shutter_s: float, f_number: float, iso: float) -> float:
    """Relative sensor exposure for one frame.

    Larger means more light was collected, so the recorded pixel values should
    be divided by this factor to express every frame on a common scale.
    """
    return (shutter_s * iso) / (f_number ** 2)


def normalise_by_exposure(values: np.ndarray, shutter_s: float,
                          f_number: float, iso: float,
                          *, reference: float) -> np.ndarray:
    """Rescale one image's pixels onto the reference frame's exposure."""
    factor = exposure_factor(shutter_s, f_number, iso)
    return values.astype("float32") * (reference / factor)

On a survey flown with exposure locked, this step does nothing and costs nothing. On a survey flown on auto — which is most of them — it removes the largest single source of frame-to-frame variation before anything harder is attempted.

Frame brightness along a flight line before and after exposure normalisation Two traces across twelve consecutive frames of one flight line. The upper trace, labelled as recorded, steps up and down repeatedly as the camera's auto-exposure reacts, with a total range of about forty digital numbers. The lower trace, labelled after EXIF exposure normalisation, is far flatter, with a gentle residual drift of about eight digital numbers across the line. A note states that the residual drift is real illumination change and is what the block gain solve is for. consecutive frames along one flight line as recorded — range ≈ 40 DN after exposure normalisation — range ≈ 8 DN The residual drift is real illumination change — that is what the gain solve handles.

Figure 1 — Removing the auto-exposure component costs nothing and removes most of the variation.

Minimal reproducible solution

With exposure normalised, the remaining differences are illumination and atmosphere, and they are solved across the block rather than per image.

from itertools import combinations
import rasterio


def overlap_ratio(path_a: str, path_b: str, *, band: int = 1,
                  min_pixels: int = 5000) -> float | None:
    """Mean brightness ratio over the pixels two orthorectified frames share.

    Returns None when the shared area is too small for the ratio to mean
    anything — a ratio computed over a few hundred pixels is noise, and it will
    drag a least-squares solve toward nonsense.
    """
    with rasterio.open(path_a) as sa, rasterio.open(path_b) as sb:
        a = sa.read(band, masked=True).astype("float32")
        b = sb.read(band, masked=True).astype("float32")

    shared = ~a.mask & ~b.mask
    if shared.sum() < min_pixels:
        return None

    va, vb = a.data[shared], b.data[shared]
    # Trim the extreme deciles: tall objects and vehicles live there, and they
    # bias a mean ratio far more than their pixel count suggests.
    d = va - vb
    lo, hi = np.percentile(d, [10, 90])
    keep = (d >= lo) & (d <= hi)
    if keep.sum() < min_pixels // 2:
        return None

    mb = float(vb[keep].mean())
    return float(va[keep].mean()) / mb if mb > 0 else None

Feeding those ratios to the block solve from the topic page gives one gain per image, consistent across the whole survey rather than chained pairwise down a flight line.

Gain, offset, and why the choice matters

A multiplicative gain and an additive offset correct different physical effects, and applying the wrong one produces a mosaic that matches in some tonal ranges and diverges in others.

Gain scales the signal. It is the right model for exposure and for sensitivity differences, because both scale the light reaching the sensor. A gain preserves contrast ratios — a shadow that was half as bright as its surroundings stays half as bright.

Offset adds a constant. It is the right model for path radiance: haze, atmospheric scattering and internal lens flare all add light that was never reflected from the ground. An offset does not preserve contrast ratios, and applying one where a gain belonged flattens shadows visibly.

The diagnostic is the shadows. If two images agree in the midtones after a gain correction but their dark areas still differ, there is an additive component that a gain cannot reach.

def fit_gain_and_offset(values_a: np.ndarray, values_b: np.ndarray) -> dict:
    """Fit a = gain * b + offset over shared pixels, and say which matters.

    A significant offset relative to the image's dynamic range means haze or
    flare; a gain far from one with a negligible offset means exposure.
    """
    gain, offset = np.polyfit(values_b.astype("float64"),
                              values_a.astype("float64"), 1)
    dynamic_range = float(values_a.max() - values_a.min()) or 1.0
    return {
        "gain": float(gain),
        "offset": float(offset),
        "offset_fraction": abs(float(offset)) / dynamic_range,
        # Above a few per cent of the range, the additive term is doing real
        # work and a gain-only correction will leave the shadows mismatched.
        "needs_offset": abs(float(offset)) / dynamic_range > 0.03,
    }

Per-band balance and colour cast

Applying one gain to all three bands corrects brightness and leaves colour alone, which is usually what you want. Solving a separate gain per band corrects colour too — and will happily invent a colour shift that was not there, because a genuinely green field and a green colour cast look identical to a per-band statistic.

The safe rule is to solve per band only where the survey contains something with a known, stable colour across the whole site — a calibration panel, a concrete apron, a road surface. Without such a reference, solve one gain per image on luminance and accept the cast.

When a per-band gain is safe and when it invents a colour shift Two columns. The left column, marked safe, applies when the site contains a stable neutral reference visible across the survey — a calibration panel, a concrete apron or a road surface — and the outcome is a genuine colour correction anchored to that reference. The right column, marked unsafe, applies when no such reference exists, and the outcome is that a genuinely green field and a green colour cast are indistinguishable to a per-band statistic, so the solve removes real scene colour. A note beneath advises solving a single luminance gain per image when no reference exists. safe: per-band gain site has a stable neutral reference visible across the whole survey — a panel, a concrete apron, a road colour correction anchored to something real unsafe: per-band gain no stable reference in the scene a green field and a green cast are identical to a per-band statistic the solve removes real scene colour No reference? Solve one luminance gain per image and accept the cast.

Figure 2 — Per-band solving needs an anchor or it will manufacture one.

Balancing as a sequence, with the check that ends it A four-stage sequence. Stage one normalises every frame by its recorded exposure, which removes the auto-exposure component at no cost. Stage two computes robust brightness ratios over the overlaps, discarding pairs with too little shared area and trimming the extreme deciles. Stage three solves one gain per image across the whole block, anchored on the frame nearest the median. Stage four checks both the reduction in spread and the fraction of clipped highlights, because a correction that improves the first while worsening the second has made the mosaic look better and the data worse. 1. normalise by recorded shutter, aperture and ISO 2. measure robust ratios over the overlaps 3. solve one gain per image, anchored on the median 4. check spread and clipping, not spread alone Stage 4 is the one that distinguishes a correction from a cosmetic improvement.

Figure 3 — Four stages, ending in a check with two conditions.

Edge-case matrix

Situation Effect Handling
Exposure locked in flight Normalisation is a no-op Run it anyway; it costs nothing
EXIF exposure fields missing Cannot normalise Fall back to the gain solve alone
Overlap under ~5000 px Ratio is noise Drop the pair from the solve
Tall buildings in overlap Ratio biased Trim extreme deciles
Shadows mismatch after gain Additive component Fit gain and offset together
Per-band solve, no reference Real colour removed Luminance gain only
Gain above ~1.3 on 8-bit Highlights clipped Cap the gain
Anchor chosen arbitrarily Whole block shifts Anchor on the median frame

Verification snippet

def balance_report(before: dict, after: dict) -> dict:
    """Did balancing actually help, and did it cost anything?

    Two failure modes look like success on a spread statistic alone: clipping,
    which reduces spread by destroying highlight detail, and over-correction,
    which flattens real scene variation along with the artefacts.
    """
    improvement = (before["spread_pct"] - after["spread_pct"]) / before["spread_pct"]
    return {
        "spread_before_pct": before["spread_pct"],
        "spread_after_pct": after["spread_pct"],
        "improvement": float(improvement),
        "clipped_fraction": after.get("clipped_fraction", 0.0),
        "ok": improvement > 0.3 and after.get("clipped_fraction", 0.0) < 0.001,
    }

Both conditions matter. A run that halves the spread while clipping a per cent of the highlights has made the mosaic look more uniform and made the data worse.

Keep the before-and-after figures in the job’s record rather than discarding them once the mosaic looks acceptable. Over a season they answer a question that is otherwise guesswork: whether the balancing step is doing real work on every flight, or whether a particular aircraft and camera combination produces imagery so consistent that the whole stage could be skipped. Sites flown with exposure locked under stable overcast frequently fall into the second category, and knowing that turns an unexamined habit into a deliberate choice.

When to escalate

  • Spread barely improves. The variation is probably within-frame — vignetting or hotspot — which no per-image gain can reach.
  • Balancing clips regardless of the cap. The source images are already near saturation. This is a flight problem, not a processing one.
  • The site genuinely changes colour across itself. A survey spanning a harvest boundary or a wet and dry area will fight any global balance. Balance on luminance and leave colour alone.

Orthomosaic Radiometry and Seamline Control