Ground Control Points Explained: What Makes a Good One

On this page

Problem statement

Every georeferenced map rests on its ground control points (GCPs): a handful of places identified on the scan and on the ground, asserted to be the same. The software will fit a transformation to any points you give it and report a tidy error figure. Whether the map then lands in the right place depends almost entirely on which points you chose and where they sit on the sheet β€” and that is easy to get wrong without noticing.

This page uses the seven control points picked on the Ordnance Survey six-inch sheet of Oakham (1884–1891) against OpenStreetMap to measure what matters: how many points, how spread out, and how to tell when one of them is lying.

Quick answer

A good control point is a feature that has not moved since the survey, can be located to a single point on both the map and the reference data, and sits where it helps β€” towards the edges of the sheet. Spread matters more than number:

subsets = []
for k in (3, 4):
    for sub in itertools.combinations(range(len(GCPS)), k):
        chosen = [GCPS[i] for i in sub]
        others = [g for i, g in enumerate(GCPS) if i not in sub]
        subsets.append(dict(k=k, names=[g[0] for g in chosen], hull=hull_share(chosen),
                            worst=max(errors(fit(chosen), others))))
df = pd.DataFrame(subsets)
print(df.sort_values("worst").iloc[[0, -1]][["k", "hull", "worst", "names"]].round(3).to_string(index=False))
 k  hull   worst                                                                                   names
 4 0.187  13.029 [brooke_rd_crossing, egleton_level_crossing, stamford_rd_guide_post, brooke_guide_post]
 3 0.011 334.370                             [egleton_church, egleton_level_crossing, brooke_guide_post]

Three points crowded along one line put the rest of the sheet up to a third of a kilometre out. Four points that enclose a fifth of the sheet keep every other point within 13 m:

Left: the Oakham sheet with a thin red triangle of three nearly collinear control points and a wide teal quadrilateral of four spread points. Right: scatter of all 70 three- and four-point subsets, worst error on the other points falling from 334 m to 13 m as the share of the sheet they enclose grows. Left: the Oakham sheet with a thin red triangle of three nearly collinear control points and a wide teal quadrilateral of four spread points. Right: scatter of all 70 three- and four-point subsets, worst error on the other points falling from 334 m to 13 m as the share of the sheet they enclose grows.
Each subset is fitted alone and tested on the points it left out. Rutland OS six-inch sheet 009-NW, public domain.

A pair and a claim, stable features, spread

The life of a ground control point, from a claim about one place to a tested part of the fit. from a claim to a tested point1a feature on both sourceschurch, crossing, junction2unchanged since the surveya historical judgement3placed at the edgescontrols extrapolation4more than the minimum44 m β†’ 26 m, 3 β†’ 4 points5tested against the restleave-one-out, 8–22 mEach step removes a way for the map to land in the wrong place spatialworkflow.io
What a control point has to survive before the georeferenced map can rely on it.

1. A control point is a pair of coordinates and a claim

Each point has a position on the scan (column and row) and a position on the ground (here, easting and northing in British National Grid), plus a name so you can find it again:

for name, px, py, e, n in GCPS:
    print(f"{name:24} ({px:4}, {py:4}) <-> ({e:9.1f}, {n:9.1f})")
oakham_church            (3141,  487) <-> ( 486065.6,  308906.1)
egleton_church           (4824, 2031) <-> ( 487598.5,  307540.3)
brooke_rd_crossing       (2956, 1159) <-> ( 485892.0,  308306.8)
egleton_level_crossing   (3998, 2481) <-> ( 486838.1,  307119.0)
stamford_rd_guide_post   (5127,  988) <-> ( 487872.3,  308486.1)
braunston_rd_bridleway   (1263, 1675) <-> ( 484382.4,  307839.2)
brooke_guide_post        (1547, 3292) <-> ( 484648.2,  306376.1)

The pair is a claim, not a measurement: "the block marked All Saints Church on the 1880s sheet is the building OpenStreetMap calls All Saints today". If the claim is false β€” the church was rebuilt on a new footprint, or the map's symbol is not where the building is β€” the transformation fits the error faithfully.

2. Choose features that have not moved

Over 130 years, some things on the six-inch sheet stayed put and many did not:

Two 500 m windows of the warped 1880s sheet with modern OpenStreetMap lines: at Egleton level crossing the modern railway and road follow the old ones; Burley Park Way cuts straight across fields where the old sheet has no road. Two 500 m windows of the warped 1880s sheet with modern OpenStreetMap lines: at Egleton level crossing the modern railway and road follow the old ones; Burley Park Way cuts straight across fields where the old sheet has no road.
An old crossing is a control point; a road built since is not, however clear it looks today. Β© OpenStreetMap contributors.

Churches, railway lines (Oakham's dates from 1848) and old road junctions in villages are the usual anchors. Field corners, footpaths and roads that have since been widened or straightened are not: they may be in the same place, but you cannot check it.

3. Spread them across the sheet

An affine transformation is a plane tilted through the points. Inside the points' convex hull it interpolates; outside it extrapolates, and small errors in the points swing into large errors at the far edges:

for k in (3, 4):
    d = df[df.k == k]
    print(f"{k} points: correlation of spread with worst error {d.hull.corr(d.worst, method='spearman'):+.2f}; "
          f"worst error ranges {d.worst.min():.0f}-{d.worst.max():.0f} m, median {d.worst.median():.0f} m")
3 points: correlation of spread with worst error -0.89; worst error ranges 20-334 m, median 44 m
4 points: correlation of spread with worst error -0.64; worst error ranges 13-64 m, median 26 m

The more of the sheet the points enclose, the smaller the worst error elsewhere. With three points the spread almost decides the result on its own.

4. Use more than the minimum

Three points determine an affine transformation exactly, which means there is nothing left over to check them with. Every point beyond the minimum is a check:

for k in range(3, 7):
    worst = [max(errors(fit([GCPS[i] for i in sub]), [g for i, g in enumerate(GCPS) if i not in sub]))
             for sub in itertools.combinations(range(7), k)]
    print(f"{k} points: median worst error {np.median(worst):5.1f} m over {len(worst)} subsets")
3 points: median worst error  44.1 m over 35 subsets
4 points: median worst error  25.9 m over 35 subsets
5 points: median worst error  20.5 m over 21 subsets
6 points: median worst error  16.9 m over 7 subsets

Going from three points to four cuts the typical worst error from 44 m to 26 m; after that the gains are smaller, but each extra point is another chance to catch a mistake.

5. Test each point against the others

With more than the minimum, leave each point out, fit the rest, and see how far it lands from where you said it was:

loo = {g[0]: errors(fit(GCPS[:i] + GCPS[i + 1:]), [g])[0] for i, g in enumerate(GCPS)}
print({k: round(v, 1) for k, v in sorted(loo.items(), key=lambda kv: kv[1])})
{'egleton_church': 8.5, 'brooke_rd_crossing': 14.6, 'oakham_church': 16.5, 'brooke_guide_post': 16.9, 'braunston_rd_bridleway': 18.4, 'stamford_rd_guide_post': 21.8, 'egleton_level_crossing': 22.2}

No point stands out on this sheet, which is what a good set looks like: every error sits within a factor of three of every other. A point twice as bad as the next worst deserves a second look at the scan.

One bad point among seven good ones

The data used on this page

The seven control points picked on the Ordnance Survey six-inch sheet Rutland 009-NW (1884–1891, Wikimedia Commons, public domain) against OpenStreetMap (Geofabrik, 1 January 2026; Β© OpenStreetMap contributors, ODbL), as in the control-point guide. Only the points and the sheet's pixel size are needed.

import itertools

import numpy as np
import pandas as pd
from scipy.spatial import ConvexHull

SHEET_W, SHEET_H = 5343, 3573          # pixels, Rutland 009-NW
GCPS = [  # (id, pixel, line, easting, northing)
    ("oakham_church",          3141,  487, 486065.6, 308906.1),
    ("egleton_church",         4824, 2031, 487598.5, 307540.3),
    ("brooke_rd_crossing",     2956, 1159, 485892.0, 308306.8),
    ("egleton_level_crossing", 3998, 2481, 486838.1, 307119.0),
    ("stamford_rd_guide_post", 5127,  988, 487872.3, 308486.1),
    ("braunston_rd_bridleway", 1263, 1675, 484382.4, 307839.2),
    ("brooke_guide_post",      1547, 3292, 484648.2, 306376.1),
]

def fit(points):
    """Affine pixel -> British National Grid, least squares."""
    A = np.array([[g[1], g[2], 1] for g in points], float)
    return (np.linalg.lstsq(A, [g[3] for g in points], rcond=None)[0],
            np.linalg.lstsq(A, [g[4] for g in points], rcond=None)[0])

def errors(coef, points):
    cx, cy = coef
    return [float(np.hypot(cx @ (px, py, 1) - e, cy @ (px, py, 1) - n)) for _, px, py, e, n in points]

def hull_share(points):
    return ConvexHull(np.array([[g[1], g[2]] for g in points], float)).volume / (SHEET_W * SHEET_H)

print(len(GCPS), "control points")
7 control points

Example 1: one bad point among good ones

Shift one point by 60 pixels β€” about 54 m, an easy slip at the scale you click at β€” and run the leave-one-out test again:

bad = list(GCPS)
name, px, py, e, n = bad[4]
bad[4] = (name, px - 60, py, e, n)
loo_bad = {g[0]: errors(fit(bad[:i] + bad[i + 1:]), [g])[0] for i, g in enumerate(bad)}
for k, v in sorted(loo_bad.items(), key=lambda kv: -kv[1])[:3]:
    print(f"{k:24} {v:6.1f} m   (was {loo[k]:5.1f})")
stamford_rd_guide_post     72.9 m   (was  21.8)
brooke_guide_post          46.8 m   (was  16.9)
egleton_level_crossing     35.5 m   (was  22.2)

The moved point rises to the top of the list. Leave-one-out finds a bad point because the others, fitted without it, disagree with it; residuals from a fit that includes it would spread the error over all seven.

Example 2: how a point's position changes its influence

A point at the edge of the sheet pulls harder on the fit than one in the middle. Leverage, from the hat matrix of the least-squares fit, measures that pull:

A = np.array([[g[1], g[2], 1] for g in GCPS], float)
leverage = np.diag(A @ np.linalg.inv(A.T @ A) @ A.T)
print(pd.Series(leverage, index=[g[0] for g in GCPS]).round(2).sort_values(ascending=False).to_string())
brooke_guide_post         0.66
braunston_rd_bridleway    0.48
oakham_church             0.46
stamford_rd_guide_post    0.43
egleton_church            0.39
egleton_level_crossing    0.34
brooke_rd_crossing        0.23

Leverage runs from 0 to 1; with 3 coefficients and 7 points it averages 3/7 = 0.43. Brooke guide post, alone in the south-west corner, has the most; Brooke Road crossing, in the middle of the cloud, the least. The points that make the spread carry the fit, which is exactly why an error in one of them costs the most. Spend your care where the leverage is.

Points constrain and test the fit

A control point does two jobs. It constrains the transformation, pinning one place on the scan to one place on the ground, and β€” if there are more points than the transformation needs β€” it checks the others. The first job wants points at the edges, where they control the extrapolation. The second wants more points than the minimum, so that each can be tested against a fit it did not influence.

The two jobs a ground control point does: constraining the transformation and checking the others. every control point does two jobsconstrain the fitβœ“pins one pixel to one placeβœ“edge points pull hardestβœ“leverage 0.66 in the cornerβœ“wants spread across the sheetcheck the othersβœ“only beyond the minimumβœ“three points: nothing to checkβœ“leave-one-out finds a slipβœ“wants more points than neededSpread serves the first job, number serves the second spatialworkflow.io
Spread and number answer different needs.

The deeper difficulty is that a control point joins two different representations of the world. The 1880s surveyor drew a church as a solid block at its true footprint; OpenStreetMap draws it as a polygon digitised from aerial imagery. A level crossing is a line crossing a line in both β€” that is why crossings are good points: the intersection is defined the same way on both sides. A road junction in a town may have been realigned twice. A field corner is only as good as the hedge, which may have been grubbed out in the 1960s and replanted a few metres away.

So "unchanged" is a historical judgement, not a geometric one, and the only test the data offers is consistency: if a point disagrees with the rest by much more than they disagree with each other, either it moved, or you clicked it wrong, or the map was wrong there. Leave-one-out cannot say which β€” only that one of those happened.

Unchecked fits, collinearity and edge leverage

Common map features judged as ground control points on an 1880s Ordnance Survey sheet. which features make good control points?on the 1880s mapunchangedone clear pointparish churchβœ“yesβœ“usually!block centrelevel crossingβœ“yesβœ“line since 1848βœ“line x linevillage junctionβœ“yes!often widened!centre linefield cornerβœ“yesβœ•hedges moved!if it survivesmodern bypassβœ•noβ€”β€”A crossing of two old lines is the best kind of point spatialworkflow.io
The level crossing scores on every count.
  • Three points cannot be checked. An affine fit through three points has zero residual and no redundancy.
  • Near-collinear points are almost as bad as two points: they leave the direction across the line unconstrained.
  • Edge points carry the most leverage; check them twice.
  • Name every point. A list of anonymous coordinates cannot be re-checked later.
  • Record the reference data and its date β€” OpenStreetMap on 1 January 2026 here β€” because the reference changes too.
  • Corners of the sheet are often blank countryside; a lone farmhouse or a parish boundary stone may be the only option.

FAQ

What is a ground control point?

A location identified both on an image and on the ground (or in reference data with known coordinates). A set of them is what a georeferencing transformation is fitted to.

How many ground control points do I need?

Three is the minimum for an affine fit; use at least four to six so each can be checked against the rest. On the Oakham sheet the median worst error fell from 44 m with three points to 26 m with four.

Where should I put control points?

Spread across the sheet, especially towards the edges and corners. Points clustered in one area leave the rest of the map to extrapolation.

What makes a feature a good control point?

It existed when the map was made and still exists, it is unchanged in position, and it can be located to one point in the same way on both sources β€” a church, a railway crossing, an old junction.

How do I find a bad control point?

Leave each point out in turn, fit the others, and measure how far the omitted point lands from its stated position. The bad one stands out.