My IDW Surface Is Full of Bullseyes

Problem statement

An inverse-distance-weighted surface comes out covered in concentric rings, one around each sample point, with a flat plain between them. It looks like a bad radar image rather than a landscape.

This is not a bug and it is not a parameter you set wrongly. It is a structural property of the method: IDW is an exact interpolator, so every sample is a local extremum of the surface.

Measured on a 500-point IDW surface derived from a real elevation model:

sample points that are a local extremum of the IDW surface   83.1%
cells that are a local extremum in the real DEM               0.38%

Four out of five samples become their own little peak or pit. Those are the bullseyes.

Quick answer

You cannot remove bullseyes from IDW. You can make them smaller, or use a method that does not have them:

# 1. lower the power β€” flattens the spike at each sample
idw(points, values, targets, power=1.5, k=12)

# 2. use more neighbours β€” but not many more
idw(points, values, targets, power=2, k=16)

# 3. use a smoothing distance so no weight is ever infinite
def idw_smoothed(points, values, targets, power=2, k=12, smoothing=50.0):
    tree = cKDTree(points)
    d, i = tree.query(targets, k=min(k, len(points)))
    d = d.reshape(len(targets), -1); i = i.reshape(len(targets), -1)
    w = 1.0 / (d ** 2 + smoothing ** 2) ** (power / 2)   # never infinite
    return (w * values[i]).sum(1) / w.sum(1)

The third is the real fix. Adding a smoothing distance makes IDW an inexact interpolator: it no longer passes exactly through the samples, so the samples stop being extrema.

An IDW surface spiking to each sample value at the sample point and falling away to a local mean between samples, producing concentric rings.
The weight at a sample is infinite relative to all others, so the surface must pass exactly through it β€” and then fall away in every direction.

Step-by-step solution

1. Confirm it is bullseyes and not something else

import numpy as np


def local_extrema(grid):
    """Cells higher or lower than all eight neighbours."""
    centre = grid[1:-1, 1:-1]
    stack = np.stack([grid[:-2, 1:-1], grid[2:, 1:-1],
                      grid[1:-1, :-2], grid[1:-1, 2:],
                      grid[:-2, :-2], grid[:-2, 2:],
                      grid[2:, :-2], grid[2:, 2:]])
    return (centre > stack.max(0)) | (centre < stack.min(0))


extrema = local_extrema(surface)
print(f"surface: {extrema.sum():,} local extrema ({extrema.mean():.2%} of cells)")
IDW surface: 683 local extrema (0.60% of cells)
real DEM   : 438 local extrema (0.38% of cells)

Then check whether those extrema sit on your sample points. If most of them do, it is bullseyes. If they are scattered, it is noise in the data.

2. Lower the power

A higher power concentrates weight on the nearest sample, which makes the spike at each sample sharper and the plain between them flatter. Power 4 gives dramatic bullseyes; power 1.5 gives soft ones.

The cost is small: measured RMSE went from 50.7 m at power 3 to 54.0 m at power 2 to 64.4 m at power 1. Dropping from 2 to 1.5 is a modest accuracy price for a visibly better surface.

3. Add a smoothing distance

The mathematical cause is 1/d^p going to infinity as d goes to zero. Replace it with 1/(dΒ² + sΒ²)^(p/2) and the weight at zero distance is finite, so no sample dominates absolutely.

Choose s as a fraction of the mean sample spacing β€” a tenth is a reasonable start. With 457 m spacing, s = 50 m removes visible rings while barely moving predictions away from their samples.

This is the standard trade: exactness for smoothness. If your samples have measurement error, you want an inexact interpolator, because honouring a noisy measurement exactly is honouring the noise.

4. Or switch method

Bullseyes are specific to distance-weighted exact interpolators. The alternatives behave differently:

  • Kriging with a nugget is inexact by construction: a non-zero nugget means the surface does not pass through the data, and there are no rings. With a zero nugget it is exact and does have them, though far weaker than IDW's because the weights come from structure.
  • Linear TIN is exact but produces flat triangular facets, not rings. Each sample is a vertex, so it is still a local extremum β€” but the artefact reads as faceting.
  • Splines are exact and smooth, and swap rings for overshoot near steep changes.

5. Consider that the bullseyes are telling you something

Prominent rings mean the samples are far apart relative to the variation between them. The surface is honestly reporting that it knows the value at 500 places and is guessing everywhere else.

Smoothing them away makes the map prettier without adding information. If the rings are severe, the fix that actually helps is more samples: doubling from 500 to 1,000 cut IDW's RMSE from 58.4 m to 43.3 m and roughly halves the visual prominence of each ring.

An exact interpolator passing through every sample and creating an extremum at each, against a smoothed version that passes near them.
Exactness is the cause. A smoothing distance buys a surface that passes near the data instead of through it.

Code examples

Example 1 β€” measuring how bad the bullseyes are

import numpy as np
from scipy.spatial import cKDTree


def bullseye_score(surface, transform, points):
    """What fraction of sample locations are local extrema of the surface?"""
    a, _, c, _, e, f = transform[:6]
    cols = ((points[:, 0] - c) / a).astype(int)
    rows = ((points[:, 1] - f) / e).astype(int)

    height, width = surface.shape
    inside = (rows > 0) & (rows < height - 1) & (cols > 0) & (cols < width - 1)

    extrema = local_extrema(surface)
    hits = extrema[rows[inside] - 1, cols[inside] - 1]

    print(f"  {int(inside.sum())} samples inside the grid")
    print(f"  {hits.mean():.1%} of them are local extrema of the surface")
    print(f"  {extrema.mean():.2%} of all cells are local extrema")
    return float(hits.mean())
  492 samples inside the grid
  83.1% of them are local extrema of the surface
  0.60% of all cells are local extrema

A score near 100% means a pure exact interpolator. Below about 20% the artefact is no longer visually obvious.

Example 2 β€” comparing smoothing distances

import numpy as np
from scipy.spatial import cKDTree


def smoothed_idw(points, values, targets, power=2, k=12, smoothing=0.0):
    tree = cKDTree(points)
    d, i = tree.query(targets, k=min(k, len(points)))
    d = d.reshape(len(targets), -1); i = i.reshape(len(targets), -1)

    if smoothing > 0:
        w = 1.0 / (d ** 2 + smoothing ** 2) ** (power / 2)
    else:
        w = 1.0 / np.maximum(d, 1e-12) ** power
        exact = d[:, 0] < 1e-9
    out = (w * values[i]).sum(1) / w.sum(1)
    if smoothing <= 0:
        out[exact] = values[i[exact, 0]]
    return out


def smoothing_sweep(points, values, grid_points, truth, shape, transform,
                    candidates=(0, 25, 50, 100, 200)):
    for s in candidates:
        surface = smoothed_idw(points, values, grid_points,
                               smoothing=s).reshape(shape)
        rmse = float(np.sqrt(np.mean((surface.ravel() - truth) ** 2)))
        score = bullseye_score(surface, transform, points)
        print(f"  smoothing {s:4d} m: RMSE {rmse:6.2f}  bullseye score {score:5.1%}")

Run this on your own data and pick the smallest smoothing that gets the score below about 20%. Beyond that you are trading accuracy for cosmetics.

Example 3 β€” the honest alternative: show the support

import numpy as np
from scipy.spatial import cKDTree


def surface_with_support(points, values, targets, shape, power=2, k=12,
                         range_m=None):
    """Interpolate, and mask where no sample is within the variogram range."""
    tree = cKDTree(points)
    d, i = tree.query(targets, k=min(k, len(points)))
    d = d.reshape(len(targets), -1); i = i.reshape(len(targets), -1)
    w = 1.0 / np.maximum(d, 1e-12) ** power
    surface = ((w * values[i]).sum(1) / w.sum(1)).reshape(shape)

    nearest = d[:, 0].reshape(shape)
    if range_m:
        surface = np.where(nearest <= range_m, surface, np.nan)
        print(f"  masked {np.isnan(surface).mean():.1%} beyond {range_m} m")
    return surface, nearest

Rings are a symptom of sparse sampling, and sparse sampling is what the support raster shows directly. Publishing both is more useful than smoothing the surface until the sparseness is invisible.

Explanation

Why exactness causes extrema

At a sample location, d = 0 for that sample and positive for all others. The weight 1/0^p is infinite, so the weighted average equals that sample's value exactly.

Move a few metres away and the sample's weight becomes large but finite, and the other samples begin to contribute. Their values pull the estimate towards a local average. Whether the sample is above or below that local average decides whether the surface falls or rises away from it β€” and either way, the sample is a local extremum.

Since every sample is above or below its local average (a sample exactly equal to it is measure zero), essentially every sample becomes a local extremum. The measurement of 83.1% is the practical version of "essentially every"; the shortfall is samples whose immediate neighbourhood is a plateau or whose closest neighbour is within a cell.

Why the rings are circular

The weight depends only on distance, so the surface around an isolated sample has circular symmetry. Contour a spike with circular symmetry and you get concentric circles β€” the bullseye.

That is also why the artefact is worse when samples are isolated: near several samples, the influences overlap and the symmetry breaks. Bullseyes are most visible exactly where they matter most, in the sparsely sampled parts of the map.

Why a nugget removes them in kriging

A kriging system with a non-zero nugget has a discontinuity at zero distance: Ξ³(0) = 0 but Ξ³(h) β†’ nugget as h β†’ 0⁺. The consequence is that the surface does not pass through the data; it passes near it, with the gap controlled by the nugget-to-sill ratio.

That is the same trick as IDW's smoothing distance, arrived at from a statistical model rather than by patching the weight function β€” and with the advantage that the amount of smoothing is estimated from the data rather than chosen.

Why smoothing is not always the right answer

If your measurements are accurate, honouring them exactly is correct and the bullseyes are an honest depiction of a sparse survey. Smoothing then hides real information: the surface no longer tells you where the observations were.

If your measurements have error β€” most field data β€” then exact interpolation reproduces that error as topography, and smoothing is a genuine improvement rather than cosmetics. The nugget is the formal way to say how much error you believe there is.

Five responses to IDW bullseyes ranked from collecting more samples down to post-smoothing the raster.
Post-smoothing is the popular one. It changes the picture and not the numbers.

Edge cases or notes

  • Bullseyes get worse as the power rises. Power 4 concentrates almost all weight on the nearest sample.
  • They get worse with fewer neighbours too. k=1 is pure nearest neighbour, which is all plateau and no ring.
  • A smoothing distance makes IDW inexact. That is the point, and it should be recorded in the output metadata.
  • Kriging with a zero nugget is also exact and also produces rings, weaker than IDW's.
  • Linear TIN is exact but shows faceting, not rings.
  • Duplicate points at one location produce a doubled spike; aggregate first.
  • Rings around a single sample with none elsewhere usually means that sample is an outlier β€” check it before smoothing it away.
  • Contour intervals amplify the artefact. The same surface with fewer contours can look fine.

FAQ

Why does my IDW surface have circular rings around each point?

Because IDW is an exact interpolator: the weight at a sample is infinite relative to the others, so the surface must pass through it and then fall away in every direction. 83.1% of samples became local extrema in the surface measured here.

How do I get rid of IDW bullseyes?

Add a smoothing distance so the weight at zero distance is finite: 1/(dΒ² + sΒ²)^(p/2). Start with about a tenth of the mean sample spacing.

Does lowering the power help?

Yes, somewhat. It flattens the spike at each sample, at a modest accuracy cost β€” RMSE went from 50.7 m at power 3 to 64.4 m at power 1 here.

Does kriging have bullseyes?

Only with a zero nugget, and much weaker. A non-zero nugget makes kriging inexact, and the rings disappear.

Are bullseyes always bad?

No. With accurate measurements they honestly show where the observations are. They are a problem when the data have measurement error, because then the surface is reproducing noise as topography.

What if only one point has a ring around it?

That point is probably an outlier β€” a transcription error or a genuinely unusual measurement. Investigate it rather than smoothing the whole surface.

Will more samples fix it?

They will make each ring smaller and less prominent, and they improve accuracy for real: 500 to 1,000 samples cut RMSE from 58.4 m to 43.3 m here.