Fixing Visible Seamlines and Colour Banding
Two defects get reported with the same words. A seamline is a sharp edge where the mosaic switched from one image to another — it follows a footprint boundary and stops abruptly. Banding is a broad stripe of different brightness, usually the width of a flight line, with soft edges.
They have different causes and different fixes, and applying the seamline fix to a banding problem is the most common wasted afternoon in mosaic work. This page is the diagnostic path from symptom to correction, within orthomosaic radiometry and seamline control.
Telling them apart
Three questions settle it in under a minute.
Is the edge sharp or soft? A one-pixel transition is a seamline. A gradient over tens of metres is banding or a hotspot.
Does the defect follow a flight line? A stripe the width and orientation of one pass is banding, caused by that line being flown under different light or with a different exposure. A defect that zigzags along image footprints is seamlines.
Does it repeat at a regular interval? A bright blob in the middle of every image footprint is the hotspot — real bidirectional reflectance, not an error. It will not respond to any per-image gain because it varies within each image.
Figure 1 — Three defects that get reported with the same sentence.
Minimal reproducible solution
Classification can be automated. Measuring how sharp the transition is, and whether the defect aligns with footprints or flight lines, turns a subjective call into a number.
import numpy as np
import rasterio
from scipy import ndimage
def classify_defect(mosaic_path: str, *, band: int = 1,
sharp_threshold: float = 4.0) -> dict:
"""Separate sharp edges from broad gradients in a mosaic.
The ratio of high-frequency to low-frequency variation is the whole test.
Seamlines put their energy at the pixel scale; banding and hotspot put
theirs at scales of hundreds of pixels, where a smoothing filter leaves it
untouched.
"""
with rasterio.open(mosaic_path) as src:
img = src.read(band, masked=True).astype("float32")
filled = img.filled(float(img.mean()))
smooth = ndimage.uniform_filter(filled, size=129)
high = filled - smooth
high_energy = float(np.std(high[~img.mask]))
low_energy = float(np.std(smooth[~img.mask]))
return {
"high_frequency_sd": high_energy,
"low_frequency_sd": low_energy,
"ratio": high_energy / low_energy if low_energy else float("inf"),
"likely": ("seamlines" if high_energy / max(low_energy, 1e-6) > 1.0
else "banding or hotspot"),
"sharp_edges_present": high_energy > sharp_threshold,
}
The threshold is scene-dependent — a mosaic of uniform grass has almost no legitimate high-frequency content, while one of a construction site has a great deal. Run it on a known-good mosaic from the same site first to calibrate what “normal” looks like.
Fixing seamlines
A seamline that is visible has one of two causes, and they need opposite responses.
The images genuinely differ. The balance step failed or was skipped. Feathering here is cosmetic — it converts a sharp edge into a soft one, which remains visible against uniform ground. Fix the balance and re-mosaic.
The images match but the cut is in the wrong place. A straight cut across an open field shows even a one-digital-number difference. Re-route the seamline along image content, as described on the topic page, and the same difference becomes invisible.
def diagnose_seam(step_magnitude: float, image_difference: float) -> str:
"""Which seamline fix applies?
A seam whose step is close to the underlying image difference is a routing
problem — the cut is simply in a visible place. A step much larger than the
typical difference means the two images disagree locally, which routing
cannot fix.
"""
if image_difference < 1.5:
return "re-route: images match, the cut is in the wrong place"
if step_magnitude > 2.5 * image_difference:
return "local disagreement — check for a shadow or a bad frame"
return "re-balance: the images genuinely differ across the block"
Fixing banding
Banding along a flight line means that line was captured under different conditions from its neighbours. Three responses, in increasing order of cost.
Re-run the gain solve including that line. If the line was excluded — often because its overlaps were below the minimum pixel threshold — it kept its own exposure while everything else moved.
Weight the solve toward cross-line pairs. Pairs within a line constrain very little, because those images already share conditions. The pairs that matter are the sideways ones between adjacent lines, and a solve dominated by along-line pairs is under-constrained in exactly the direction the banding runs.
Re-fly the line. If it was flown through a different cloud regime, no correction recovers the shadow detail that was never captured. This is the honest answer more often than people like.
Figure 2 — The pairs that matter are the sideways ones.
Living with the hotspot
The third defect is the one with no clean fix, and recognising that early saves a great deal of time. The hotspot is the bright region where the view direction is close to the anti-solar direction — the ground really does reflect more light back toward the camera there, and every frame shows it at roughly the same place relative to its own centre.
Because it varies within each image, no per-image gain touches it. Because it sits at the same relative position in every frame, it repeats across the mosaic at the frame spacing, which is exactly the pattern that makes a mosaic look quilted even after the balance is perfect.
Three responses are available, and only one of them is cheap.
Crop each frame harder. Using only the central portion of each image discards the parts of the field of view where the effect is strongest. This requires overlap to spare — around 80% forward and 70% side — but it needs no modelling at all and it is the approach most survey pipelines should reach for first.
Model it. A bidirectional reflectance model, fitted per surface type against view and sun geometry, can remove the effect properly. It is the right answer for multispectral work, where the reflectance values themselves are the deliverable, and usually overkill for an RGB mosaic that just needs to look consistent.
Fly when it is weakest. The hotspot is most pronounced with a low sun and least pronounced under overcast, where the illumination is close to uniform from every direction. An overcast day that looks disappointing to the pilot produces a noticeably more even mosaic.
def hotspot_severity(frame_means: np.ndarray, centre_means: np.ndarray) -> dict:
"""Compare full-frame brightness with central-crop brightness per image.
A consistent positive difference across the survey means the discarded
margins really were brighter, which both confirms the hotspot and estimates
how much cropping would gain.
"""
delta = centre_means - frame_means
return {
"mean_delta_dn": float(delta.mean()),
"consistent": bool((delta > 0).mean() > 0.8),
# Above a couple of DN, cropping is worth the overlap it costs.
"cropping_worthwhile": float(delta.mean()) > 2.0,
}
Figure 3 — Three fixes, three defects, and six ways to pair them wrongly.
Edge-case matrix
| Situation | Effect | Handling |
|---|---|---|
| Feathering applied to a banding problem | Nothing changes | Fix the gain solve instead |
| Straight seam over uniform ground | Highly visible | Re-route along content |
| Seam step ≫ image difference | Local disagreement | Find the bad frame |
| One line excluded from the solve | That line bands | Lower the overlap threshold |
| Only along-line pairs used | Solve under-constrained | Weight cross-line pairs |
| Hotspot mistaken for banding | Gains chase a real effect | Model it, or crop each frame harder |
| Defect only visible zoomed out | Overview artefact | Check at native resolution |
| Cloud shadow crossed a line | Detail never captured | Re-fly that line |
Verification snippet
def confirm_fix(before: dict, after: dict, *, defect: str) -> dict:
"""Confirm the correction reached the defect it was aimed at.
Checking the wrong statistic is how a fix gets declared successful without
changing anything visible: a seamline correction moves high-frequency
energy, a banding correction moves low-frequency energy, and each leaves
the other largely alone.
"""
key = "high_frequency_sd" if defect == "seamlines" else "low_frequency_sd"
other = "low_frequency_sd" if defect == "seamlines" else "high_frequency_sd"
reduction = (before[key] - after[key]) / before[key] if before[key] else 0.0
collateral = abs(after[other] - before[other]) / max(before[other], 1e-6)
return {
"targeted_reduction": float(reduction),
"collateral_change": float(collateral),
"ok": reduction > 0.4 and collateral < 0.25,
}
When to escalate
- Neither statistic moves. The defect is probably in the source imagery — motion blur, a dirty lens, a partly failed sensor — and no mosaicking parameter will reach it.
- Every fix trades one defect for another. The overlap is too low to give the seamline search anywhere good to route. Increase side overlap on the next flight.
- The client sees it and the statistics do not. Trust the client. Find where they are looking, measure there, and adjust the thresholds rather than the perception.