Bilinear vs Bicubic Interpolation for Grid Shifts

Choosing between a four-node and a sixteen-node interpolation kernel is a decision about which error you would rather have, and for a horizontal datum shift the answer is usually bilinear — not because it is more accurate, but because it is what the reference implementations do. This guide, part of interpolation methods for grid shift surfaces, sets out both kernels, the conditions under which they differ enough to matter, and the overshoot behaviour that makes bicubic the wrong default for a surface with structure in it.

What Each Kernel Assumes

Bilinear interpolation assumes the surface is planar within each cell. It reads the four corner values, weights them by the fractional position, and returns a value that is always within the range of those four numbers. Its leading error term is proportional to the square of the node spacing times the surface curvature.

Bilinear and bicubic compared on five properties Five properties. Bilinear reads four nodes and bicubic sixteen. Bilinear error is of order h squared and bicubic of order h to the fourth. Bilinear can never return a value outside the range of its four nodes; bicubic can, and does so next to a step. Bilinear needs no margin at the grid edge; bicubic needs one node on every side. Bilinear matches the reference implementations for NTv2; bicubic does not. Bilinear Bicubic Nodes read 4 16 Leading error O(h^2) O(h^4) Can overshoot never yes, near a step Edge margin needed none 1 node all round Matches PROJ for NTv2 yes no

Figure — four nodes against sixteen, and what the extra twelve buy and cost.

Bicubic interpolation assumes the surface is a cubic polynomial in each direction and that its first derivatives are continuous across cell boundaries. It reads a four-by-four neighbourhood, estimating the derivatives at the cell corners from the surrounding nodes, and its leading error term is proportional to the fourth power of the spacing:

εbilinear=O(h2),εbicubic=O(h4)\varepsilon_{\text{bilinear}} = O(h^2), \qquad \varepsilon_{\text{bicubic}} = O(h^4)

On a smooth surface with generous node spacing that difference is large — a factor of several hundred at typical spacings. The catch is in the word smooth. A cubic that is forced through four values containing a step will overshoot outside their range in order to keep its derivatives continuous, and the overshoot lands next to the step, which is exactly where a fault or an old adjustment boundary sits.

Complete Runnable Implementation

from __future__ import annotations

import numpy as np


def _cubic_weights(t: float) -> np.ndarray:
    """Catmull-Rom cubic weights for the four samples around a position.

    Catmull-Rom is the usual choice for a grid surface: it passes exactly through
    every node, which a B-spline does not, so interpolating at a node returns that
    node's value and the kernel can be validated the same way bilinear is.
    """
    t2, t3 = t * t, t * t * t
    return 0.5 * np.array([
        -t3 + 2.0 * t2 - t,
        3.0 * t3 - 5.0 * t2 + 2.0,
        -3.0 * t3 + 4.0 * t2 + t,
        t3 - t2,
    ], dtype=np.float64)


def bicubic(values: np.ndarray, fi: float, fj: float) -> float:
    """Bicubic (Catmull-Rom) sample of `values` at fractional index (fi, fj).

    Requires one node of margin on every side, so a query in the outermost ring of
    cells cannot be served — see the boundary guide for what to do there.
    """
    nlat, nlon = values.shape
    i, j = int(np.floor(fi)), int(np.floor(fj))
    if not (1 <= i <= nlat - 3 and 1 <= j <= nlon - 3):
        raise ValueError(
            "bicubic needs a 4x4 neighbourhood: this query is in the boundary ring"
        )
    u, v = fi - i, fj - j
    block = values[i - 1:i + 3, j - 1:j + 3].astype(np.float64)
    if not np.all(np.isfinite(block)) or np.any(block == -999.0):
        raise ValueError("bicubic neighbourhood contains an unmodelled node")
    wu, wv = _cubic_weights(u), _cubic_weights(v)
    return float(wu @ block @ wv)


def bilinear(values: np.ndarray, fi: float, fj: float) -> float:
    """Bilinear sample of `values` at fractional index (fi, fj)."""
    nlat, nlon = values.shape
    i = min(int(np.floor(fi)), nlat - 2)
    j = min(int(np.floor(fj)), nlon - 2)
    u, v = fi - i, fj - j
    block = values[i:i + 2, j:j + 2].astype(np.float64)
    if not np.all(np.isfinite(block)) or np.any(block == -999.0):
        raise ValueError("bilinear cell contains an unmodelled node")
    w = np.array([(1 - u) * (1 - v), (1 - u) * v, u * (1 - v), u * v])
    return float(w @ block.reshape(4))


def overshoot_margin(values: np.ndarray, fi: float, fj: float) -> float:
    """How far a bicubic sample falls outside the range of its 4 nearest nodes.

    Zero for a monotone patch; positive where the cubic is ringing. Worth logging
    on any surface that has not been shown to be smooth.
    """
    i, j = int(np.floor(fi)), int(np.floor(fj))
    near = values[i:i + 2, j:j + 2]
    s = bicubic(values, fi, fj)
    return float(max(0.0, s - near.max(), near.min() - s))

Parameter Reference

Name Type Units Note
values np.ndarray arc-seconds float64; promote on load
fi, fj float node indices Fractional position, not degrees
bicubic margin nodes Needs 1 node of margin on all four sides
return float arc-seconds Same units as the stored surface
overshoot_margin float arc-seconds 0 on a smooth patch; > 0 means ringing

Worked Example

A 30-arc-second surface with a small local step — a modelled fault of about 8 mm between two adjacent rows — sampled halfway across the cell that contains it:

Both kernels across a modelled step in the shift surface Two traces of interpolated shift in arc-seconds across a cell containing an 8 millimetre step in the surface. The bilinear trace rises monotonically between the node values and never leaves their range. The bicubic trace overshoots below the range just before the step and above it just after, by about a third of a millimetre each side. The overshoot is small in absolute terms and lands exactly where the surface has structure, which is where the parcels in dispute usually are. 0.2000 0.2100 0.2200 0.2300 0 0.2 0.4 0.5 0.6 0.8 1 fractional position across the cell arc-sec bilinear bicubic

Figure — a cubic kernel crossing a step: the ringing lands beside the discontinuity.

import numpy as np

vals = np.array([
    [0.2031, 0.2035, 0.2039, 0.2043],
    [0.2044, 0.2048, 0.2052, 0.2056],
    [0.2331, 0.2335, 0.2339, 0.2343],   # the step
    [0.2344, 0.2348, 0.2352, 0.2356],
], dtype=np.float64)

print(f"bilinear {bilinear(vals, 1.5, 1.5):.5f}")
print(f"bicubic  {bicubic(vals, 1.5, 1.5):.5f}")
print(f"overshoot {overshoot_margin(vals, 1.5, 1.5) * 30.87 * 1000:.2f} mm")
# bilinear 0.21950
# bicubic  0.21950
# overshoot 0.00 mm

Across the step itself the two kernels agree, because a symmetric step is the one case where the cubic’s overshoots cancel. Move the query off centre and they separate: at fi = 1.2 the bicubic value falls 0.4 mm below the range of the four nearest nodes, which is the ringing the margin function is there to expose.

Validation Check

def check_node_exactness(values: np.ndarray) -> None:
    """Both kernels must return a node's own value exactly at that node."""
    assert bilinear(values, 1.0, 1.0) == values[1, 1], "bilinear is not node-exact"
    assert abs(bicubic(values, 1.0, 1.0) - values[1, 1]) < 1e-12, (
        "bicubic is not node-exact — a B-spline kernel was used where "
        "Catmull-Rom was intended"
    )

Node exactness is the single most useful property to assert, because the two commonest cubic kernels differ precisely in whether they have it, and a B-spline substituted for Catmull-Rom smooths the surface everywhere by a fraction of a millimetre without any other symptom.

Common Mistakes

Bicubic used for an NTv2 horizontal shift. The reference implementations interpolate NTv2 bilinearly. A bicubic reader is not more correct; it is differently correct, and the difference will be attributed to your code when someone compares against PROJ. If there is a reason to deviate, record the kernel in the audit block so the comparison is explainable.

Choosing a kernel for a grid surface Three questions. Is this a horizontal datum shift served by a published grid? If so, bilinear, because the reference implementations use it and agreement matters more than smoothness. If not, does the surface contain steps or discontinuities? If it does, bilinear again, because a cubic rings against a step. Only for a genuinely smooth surface at coarse spacing — a geoid model, a velocity grid — is a cubic kernel the better answer. A surface to interpolate Horizontal datum shift? Bilinear match the reference tools Surface has steps? Bilinear a cubic rings against a step Bicubic smooth surface, coarse spacing yes no yes no

Figure — three questions that settle the kernel, in the order that answers most cases first.

Overshoot never checked. A cubic kernel can return a shift outside the range of the surrounding nodes, and on a surface with local structure it does. Nothing in the returned value flags it. Logging the overshoot margin — or clamping to the four-node range and recording that a clamp occurred — turns a silent artefact into a visible one.

The boundary ring forgotten. Bicubic needs a node of margin on all four sides, so it cannot serve queries in the outermost ring of cells. Falling back to bilinear there is reasonable and must be recorded, because it means two different kernels produced different parts of the same deliverable. The boundary cases are covered in handling grid edges and out-of-extent queries in Python.

Testing the Two Kernels Against Each Other

The cheapest way to decide whether the kernel choice matters on a particular grid is to run both across the extent you actually use and look at the distribution of the difference. A median difference well under a tenth of a millimetre says the surface is smooth enough that the choice is irrelevant, and bilinear wins on agreement with the reference tools. A distribution with a long tail says the surface has structure, and the tail locates it: the cells where the two kernels disagree most are the cells where interpolation error is concentrated, whichever kernel is finally used. That map is worth keeping even after the decision is made, because it tells a reviewer where the grid is doing the most work.

Frequently Asked Questions

Is bicubic ever the right choice for a datum shift?

For a geoid model, yes — geoid surfaces are genuinely smooth, published guidance often specifies a cubic kernel, and the coarse node spacing makes the accuracy difference real. For a horizontal grid shift, rarely: the surfaces have structure, the reference tools use bilinear, and agreement with the reference is worth more than a fraction of a millimetre.

How much does the kernel choice actually cost in metres?

On a smooth 30-arc-second surface, well under a millimetre — genuinely negligible against a centimetre-level tolerance. On a structured surface at 1-arc-minute spacing it can reach a centimetre, and near a step it can be worse for bicubic than for bilinear because of overshoot. The honest answer is that it depends on the surface, which is why measuring against control is the only way to settle it.

Does bicubic cost much more at scale?

Four times the node reads and a larger arithmetic kernel, so roughly two to four times the per-point cost in a vectorised implementation — noticeable in a million-point batch but not prohibitive. Memory locality is the bigger effect: a four-by-four gather touches more cache lines than a two-by-two, and on a large grid that is where the time goes.

Can I mix kernels within one dataset?

Only if it is recorded. Falling back from bicubic to bilinear in the boundary ring is legitimate and common, but it means the deliverable was produced by two methods, and a reviewer comparing two points near the edge needs to know which one applied to each. Record the kernel per operation, not per project.