Fixing Texture Seams and Baking Artifacts
The model geometry is clean and the texture is a patchwork. Straight lines of colour cut across otherwise uniform tarmac. One wall of a building is noticeably brighter than the wall beside it. At medium zoom, thin bright lines appear along edges that are not there at full resolution. All three are baking faults, and all three have a specific cause.
The stage is the one described in mesh and texture generation automation, where the mesh is unwrapped into an atlas and each texel is filled with colour sampled from the source photographs. Every artefact below comes from a decision made in that fill.
The four faults and what produces each
Mip bleed. Thin bright or dark lines appearing only at reduced zoom. The atlas packer placed two patches with no padding between them, and the renderer’s mip-map levels average across the boundary. The full-resolution texture is perfect; every level below it is not.
Exposure steps. A visible change of brightness along a patch boundary. Two adjacent patches were textured from photographs taken at different exposures — which, on a flight with automatic exposure over varying ground, is most pairs of photographs.
Smear. A stretched, low-detail region, usually on a vertical or steeply sloping surface. The patch was textured from a photograph that saw the surface almost edge-on, so a few source pixels were stretched across many texels.
Ghosting. Doubled or blurred features where a moving object — a vehicle, a person, a shadow edge — appeared in one source image and not another, and the blend averaged them.
Figure 1 — Four faults with one diagnostic question between them.
Minimal reproducible solution
Exposure normalisation is the fix with the largest visible payoff, and it is a global adjustment computed from the overlap between images rather than a per-image guess.
import numpy as np
def solve_exposure_gains(overlap_means: dict[tuple[int, int], tuple[float, float]],
n_images: int, damping: float = 0.02) -> np.ndarray:
"""Per-image multiplicative gains that make overlapping regions agree.
overlap_means maps an image pair to the mean brightness each of the two
measured over their shared area. Taking logs turns the multiplicative
problem into a linear least-squares one; the damping term anchors the
solution so it cannot drift to uniformly black or uniformly white.
"""
rows, rhs = [], []
for (i, j), (mi, mj) in overlap_means.items():
if mi <= 0 or mj <= 0:
continue
r = np.zeros(n_images)
r[i], r[j] = 1.0, -1.0
rows.append(r)
rhs.append(np.log(mj) - np.log(mi))
for i in range(n_images): # damping: keep gains near unity
r = np.zeros(n_images)
r[i] = damping
rows.append(r)
rhs.append(0.0)
log_gain, *_ = np.linalg.lstsq(np.asarray(rows), np.asarray(rhs), rcond=None)
return np.exp(log_gain)
Solving globally rather than pairwise is what makes the result seam-free. A pairwise correction propagates error around the flight and produces a visible gradient from one end of the site to the other; a least-squares solve distributes the disagreement everywhere and the residual per boundary becomes too small to see.
The other three fixes are single parameters. Gutters: pad the atlas by at least four pixels, which the packer will do if asked. Smear: reject source views beyond about sixty degrees from the surface normal, and fill unobserved triangles by interpolation afterward. Ghosting: blend candidate colours with a median rather than a mean, so one anomalous image is outvoted instead of averaged in.
import numpy as np
def blend_texel(candidates: np.ndarray, weights: np.ndarray,
*, use_median: bool = True) -> np.ndarray:
"""Combine colour candidates for one texel from several source images."""
if candidates.size == 0:
return np.array([np.nan, np.nan, np.nan])
if use_median and len(candidates) >= 3:
return np.median(candidates, axis=0) # outvotes a moving object
w = weights / max(weights.sum(), 1e-9)
return (candidates * w[:, None]).sum(axis=0)
Figure 3 — Two of these are fixed in the bake; the third is not.
Edge-case matrix
| Situation | Symptom | Handling |
|---|---|---|
| Two source images per texel | Median degenerates to mean | Keep the weighted mean below three candidates |
| Uniform overcast light | Exposure solve finds gains near 1 | No harm; the solve is cheap |
| Strong directional sun | Facades differ genuinely | Do not normalise across surface orientations |
| Water surface | Every image disagrees | Mask and fill; no blend is right |
| Vehicles moving through the site | Ghosting | Median blend, and flag high-variance texels |
| Atlas above 8192 px | Some viewers refuse to load | Split into multiple atlases |
| Triangle seen by no acceptable view | Black or undefined texel | Interpolate from neighbours, record the area |
| Texture baked at 16k then downsampled | Gutters blur back together | Bake at the delivery resolution |
Verification snippet
Two automated checks catch most of this before anyone opens a viewer.
import numpy as np
from PIL import Image
def texture_quality_report(atlas_path: str, uv: np.ndarray,
faces: np.ndarray) -> dict:
"""Detect unfilled texels, gutter violations and suspicious variance."""
img = np.asarray(Image.open(atlas_path).convert("RGB")).astype("float32")
h, w, _ = img.shape
# Unfilled texels: exactly black is the fill value used by most bakers.
unfilled = float(np.count_nonzero((img.sum(axis=2) == 0)) / (h * w))
# Local variance flags ghosting and smear: both raise it sharply.
gy, gx = np.gradient(img.mean(axis=2))
edge_energy = float(np.mean(np.hypot(gx, gy)))
# UV coverage: a chart touching the atlas edge has no gutter.
at_edge = float(np.mean((uv < 1e-4) | (uv > 1 - 1e-4)))
problems = []
if unfilled > 0.02:
problems.append(f"{unfilled:.1%} of the atlas is unfilled")
if at_edge > 0.001:
problems.append("charts touch the atlas edge — no gutter, expect mip bleed")
return {"unfilled_fraction": unfilled, "edge_energy": edge_energy,
"uv_at_edge_fraction": at_edge, "problems": problems}
Tracking edge_energy across releases is the surprisingly useful one. It is a single number per model, it rises sharply when a bake goes wrong, and it needs no reference to compare against beyond the previous month’s value on the same site.
Figure 2 — One percent of the atlas spent on padding removes an artefact that no amount of re-baking will otherwise fix.
Preventing the faults at flight-planning time
Three of the four faults are cheaper to prevent than to fix, and the prevention happens before the drone leaves the ground.
Lock the exposure. Automatic exposure is the direct cause of every exposure step in the atlas. A flight flown with fixed shutter, aperture and ISO — set from a test shot over the brightest part of the site — produces images that need almost no normalisation. The cost is blown highlights over bright concrete or crushed detail in shadow, so the setting is a judgement about which part of the site matters. On a mixed site, expose for the mid-tones and accept the extremes; on a quarry, expose for the material.
Fly obliques over anything vertical. Smear is the symptom of a surface that only nadir imagery ever saw. A single orbit at 45° around a structure adds a few minutes to the flight and removes the fault entirely, because every facade then has a square-on observation to be textured from. Without it, no processing parameter can recover detail that was never captured at usable resolution.
Fly when the site is quiet, and fly fast. Ghosting is caused by things that move between overlapping frames. A site photographed over forty minutes at midday has vehicles, plant and shadows all moving through it; the same site photographed in fifteen minutes early has far fewer moving objects and shorter shadows. Where movement is unavoidable, the median blend handles it — but only if there are at least three candidate views per texel, which is another argument for the overlap discussed in calculating optimal flight overlap for Python processing.
Only mip bleed is genuinely a processing-side fault, and it is a one-line fix in the packer. That asymmetry is worth carrying into how a team spends its effort: a checklist item on the flight plan is worth more here than any amount of post-processing sophistication.
def texture_readiness(flight: dict) -> list[str]:
"""Warnings a flight plan should raise before it is flown, for texture quality."""
warnings = []
if flight.get("exposure_mode") != "manual":
warnings.append("auto-exposure: expect exposure steps at patch boundaries")
if not flight.get("oblique_passes"):
warnings.append("nadir only: vertical surfaces will smear")
if flight.get("forward_overlap", 0) < 0.75:
warnings.append("overlap below 75 %: too few candidate views for a median blend")
if flight.get("duration_min", 0) > 30 and flight.get("site_active"):
warnings.append("long flight over an active site: expect ghosting from plant movement")
return warnings
When to escalate
- Seams persist after a global exposure solve. The differences may be genuine — different surfaces under directional light really do differ — and normalising across them makes the model less accurate, not more. Restrict the solve to surfaces of similar orientation.
- Large regions have no acceptable source view. The flight did not cover them at a usable angle. Oblique passes are the fix, and they are a flight-planning decision, not a processing one.
- The texture is right and the model still looks poor. Check the geometry: a decimation that rounded the breaks, per decimating meshes without losing breaklines, reads as a texture problem to most viewers.