Removing Moving Objects and Ghosting from Mosaics
A haul road with a lorry on it, photographed twice ninety seconds apart, is two different scenes. The mosaic has to choose, and its default choices produce the two defects clients notice fastest: a half-vehicle, where a hard cut runs through the lorry and takes its cab from one image and nothing from the other, and a ghost, where feathering averages the lorry with the empty road and leaves it translucent.
Both are avoidable, and the detection needed to avoid them is cheap. This page covers it, as part of orthomosaic radiometry and seamline control.
Detection from the overlap disagreement
The signature of a moving object is that two well-registered, well-balanced images disagree strongly over a compact area while agreeing everywhere around it. That is a much easier thing to detect than the object itself, and it needs no object recognition at all.
Three properties separate a moving object from ordinary noise and from tall-structure parallax:
- Magnitude. The disagreement is large — a dark vehicle on light tarmac is most of the dynamic range, not a few digital numbers.
- Compactness. It occupies a connected region of roughly vehicle size, not scattered pixels.
- Asymmetry with height. Parallax on a tall building also produces disagreement, but it produces it as a displaced copy — the same object in two places — whereas a moving vehicle usually appears in one image and not the other.
Figure 1 — The difference image answers the question without recognising anything.
Minimal reproducible solution
import numpy as np
from scipy import ndimage
def detect_transients(img_a: np.ndarray, img_b: np.ndarray,
overlap: np.ndarray, *,
threshold_sigma: float = 4.0,
min_area_px: int = 400,
max_area_px: int = 40000) -> tuple[np.ndarray, list[dict]]:
"""Find compact regions where two balanced images disagree strongly.
The threshold is set from the robust spread of the difference inside the
overlap rather than from a fixed value, so the same call works on a bright
quarry and a dark woodland without retuning.
"""
diff = np.abs(img_a.astype("float32") - img_b.astype("float32"))
inside = diff[overlap]
if inside.size == 0:
return np.zeros_like(overlap), []
centre = float(np.median(inside))
scale = 1.4826 * float(np.median(np.abs(inside - centre))) or 1.0
candidate = overlap & (diff > centre + threshold_sigma * scale)
candidate = ndimage.binary_closing(candidate, iterations=3)
labels, n = ndimage.label(candidate)
mask = np.zeros_like(candidate)
found = []
for index in range(1, n + 1):
region = labels == index
area = int(region.sum())
if not (min_area_px <= area <= max_area_px):
continue # too small to matter, too big to be a vehicle
rows, cols = np.nonzero(region)
found.append({
"area_px": area,
"centroid": (float(rows.mean()), float(cols.mean())),
"mean_difference": float(diff[region].mean()),
})
mask |= region
return mask, found
The area bounds carry real meaning. Below the lower bound, the detection is noise or a small registration error. Above the upper bound, a whole region disagrees, which is a shadow or a balance failure rather than an object — treating that as a moving vehicle and routing around it produces a seamline that takes a long detour for no reason.
Choosing a source rather than blending
Once a transient region is known, the rule is simple and absolute: every pixel of that region comes from one image. Never average, never cut through it.
Which image? Two criteria, applied in order.
Prefer the image without the object. An empty road is almost always the better deliverable — the vehicle was not a feature of the site, it was passing through. Comparing each candidate against the surrounding local median identifies which image contains the anomaly.
Failing that, prefer the more central view. If both images contain the object in different positions, take the one where it sits closer to the frame centre, where lean and occlusion are least.
def choose_source(img_a: np.ndarray, img_b: np.ndarray,
region: np.ndarray, surround: np.ndarray) -> str:
"""Which image should supply an entire transient region?
The image whose values inside the region look most like the surrounding
ground is the one without the object in it — no recognition required, just
a comparison against the neighbourhood.
"""
context = float(np.median(np.concatenate([
img_a[surround].ravel(), img_b[surround].ravel()])))
deviation_a = abs(float(np.median(img_a[region])) - context)
deviation_b = abs(float(np.median(img_b[region])) - context)
return "a" if deviation_a <= deviation_b else "b"
def region_surround(region: np.ndarray, *, width: int = 20) -> np.ndarray:
"""A ring of clean ground around a region, for the comparison above."""
grown = ndimage.binary_dilation(region, iterations=width)
return grown & ~ndimage.binary_dilation(region, iterations=4)
Feeding the transient mask into the seamline cost surface as an exclusion — the same mechanism used for tall buildings — makes the routing honour this automatically, without a special case in the mosaicking code.
Figure 2 — Three handlings, one of them correct.
Separating parallax from movement in practice
The paired signature described above is worth detecting explicitly, because the two cases call for opposite responses and confusing them is expensive in both directions. Treating a building as a moving vehicle sends the seamline on a long detour around a structure it could have taken whole from one image; treating a moving vehicle as a building leaves it ghosted.
The test is a cross-correlation between the two candidate regions. Parallax produces near-identical content offset by a short distance along the line joining the two camera positions; movement produces two regions that look nothing alike, because one of them is empty road.
def looks_like_parallax(img_a: np.ndarray, img_b: np.ndarray,
region_1: np.ndarray, region_2: np.ndarray,
*, similarity_threshold: float = 0.7) -> bool:
"""Do two nearby high-difference regions hold the same content?
Comparing the patches directly is enough. A building seen from two angles
correlates strongly with itself; a lorry and the empty road it later
vacated do not correlate at all.
"""
def patch(img, region):
rows, cols = np.nonzero(region)
sub = img[rows.min():rows.max() + 1, cols.min():cols.max() + 1]
return (sub - sub.mean()) / (sub.std() or 1.0)
p1, p2 = patch(img_a, region_1), patch(img_b, region_2)
height = min(p1.shape[0], p2.shape[0])
width = min(p1.shape[1], p2.shape[1])
if height < 8 or width < 8:
return False
correlation = float((p1[:height, :width] * p2[:height, :width]).mean())
return correlation > similarity_threshold
There is a cheaper shortcut that works well where a surface model already exists: threshold the height raster a metre or two above the local ground plane and treat everything above it as a permanent exclusion. Buildings, masts and mature trees are caught by construction, and anything the transient detector then flags outside those footprints is very likely to be moving. Most pipelines that already produce a DSM should use this rather than the correlation test, and keep the correlation test for the sites where no surface model is available at mosaicking time.
Figure 3 — Masking the third row is what keeps the first row working.
Edge-case matrix
| Situation | Effect | Handling |
|---|---|---|
| Feathering over a vehicle | Translucent ghost | Exclusion mask, single source |
| Hard cut through a vehicle | Half a vehicle | Route the seam around it |
| Every frame has the vehicle | Cannot remove it | Take the most central view |
| Parallax mistaken for movement | Long pointless detours | Check for a paired signature |
| Whole region flagged | Shadow or balance failure | Cap the region area |
| Water in the overlap | Constant disagreement | Mask water before detection |
| Crop moving in wind | Broad low-level difference | Below threshold; leave it |
| Vehicle spans both images’ centres | No clean choice | Accept it, or re-fly |
Water deserves its own note. A lake or a wet quarry floor disagrees between every pair of frames, because the specular reflection depends on view direction. Left unmasked it floods the transient detector with regions, and the seamline search then routes around the entire waterbody, which is rarely what anyone wants.
Verification snippet
def audit_transients(mosaic_mask: np.ndarray, transient_mask: np.ndarray,
seam_mask: np.ndarray) -> dict:
"""Confirm no seamline crosses a detected transient in the final mosaic."""
crossings = ndimage.label(seam_mask & transient_mask)[1]
covered = float((transient_mask & mosaic_mask).sum()) / max(transient_mask.sum(), 1)
return {
"transient_pixels": int(transient_mask.sum()),
"seam_crossings": int(crossings),
"coverage": covered,
"ok": crossings == 0 and covered > 0.99,
}
A non-zero crossing count means the exclusion cost was not high enough to deflect the path, usually because a transient sits where the overlap is too narrow to route around. That is a flight-planning finding as much as a processing one.
When to escalate
- A vehicle appears in every overlapping frame. It was parked, not passing. Either accept it in the deliverable or arrange for the site to be cleared before the next flight.
- The detector floods with regions. Water, dense moving vegetation or a failed balance. Fix the cause; do not raise the threshold until the symptoms stop.
- Active plant across the whole site. A working quarry during shift hours will never mosaic cleanly. Fly at shift change.