How to Georeference an Early County Map in Python

On this page

Problem statement

Seventeenth-century county maps are the earliest detailed maps of most of England, and georeferencing one looks like the same job as a Victorian Ordnance Survey sheet: pick control points, fit, warp. It is not. The sheet carries inset town plans, coats of arms and a cartouche that must not be warped as if they were countryside; its "miles" are not statute miles; there are no field corners or crossings to use as control points, only church symbols; and its errors are measured in hundreds of metres, which changes which transformation is right.

This page georeferences John Speed's Rutlandshire — first published in 1611, here in the 1676 Bassett & Chiswell edition scanned by the David Rumsey Map Collection — onto British National Grid, with 33 villages matched to OpenStreetMap.

Quick answer

Use one church symbol per village as a control point, matched to the modern village by name; with that many points, test a second-order polynomial as well as affine; and warp only the part of the sheet that is map, by passing a cutline transformed into the target CRS:

vrt = gdal.Translate("data/speed_gcps.vrt", "data/speed_rutland.jpg", format="VRT", bandList=[1],
                     outputSRS="EPSG:27700", GCPs=[gdal.GCP(e, n, 0, c, r, k) for k, c, r, e, n in POINTS])
t = gdal.Transformer(vrt, None, ["METHOD=GCP_POLYNOMIAL", "MAX_GCP_ORDER=2"])
cut = Polygon([p[:2] for p in t.TransformPoints(0, [(x, y, 0) for x, y in densify(MAP_AREA)])[0]])
gpd.GeoDataFrame(geometry=[cut], crs=27700).to_file("data/speed_cutline.geojson")
gdal.Warp("data/speed_rutland_bng.tif", vrt, dstSRS="EPSG:27700", polynomialOrder=2, xRes=10, yRes=10,
          resampleAlg="cubic", dstAlpha=True, cutlineDSName="data/speed_cutline.geojson", cropToCutline=True,
          creationOptions=["COMPRESS=DEFLATE", "TILED=YES", "PREDICTOR=2"])
print(gdal.Info("data/speed_rutland_bng.tif", format="json")["size"], f"{cut.area / 1e6:,.0f} km² of map")
[4013, 3055] 710 km² of map
The whole 1676 atlas page of Speed's Rutlandshire, with the inset plans of Oakham and Stamford, cartouche, arms and scale bar outside a red outline of the map area, and 33 blue dots on the church symbols used as control points. The whole 1676 atlas page of Speed's Rutlandshire, with the inset plans of Oakham and Stamford, cartouche, arms and scale bar outside a red outline of the map area, and 33 blue dots on the church symbols used as control points.
Only the area inside the red outline is warped; the blue dots are the 33 villages matched to OpenStreetMap. David Rumsey Map Collection, Stanford Libraries, via Wikimedia Commons.

Separate map from decoration, match churches, read the scale

Georeferencing John Speed’s Rutland: from an atlas page to a warped county map. an atlas page to a county on the grid1outline the map area31% of the sheet2church symbols to villages33 matched, 2 deserted3read the scale bar1 mile = 1,988 m4test transformationspolynomial 2: 549 m5warp with a cutline10 m cellsSteps 1 and 3 do not arise on a modern survey sheet spatialworkflow.io
The first and third steps are what make an early map different.

1. Separate the map from the decoration

Speed's sheet is a page of an atlas. The county map shares it with plans of Oakham and Stamford, a title cartouche, two coats of arms, a compass rose and a scale of miles — all drawn at a different scale or at none. A transformation fitted to the villages would place the Stamford town plan somewhere in Lincolnshire. Draw the outline of the map area in pixel coordinates once, by eye — MAP_AREA in the setup block below:

w, h = Image.open("data/speed_rutland.jpg").size
print(f"sheet {w} x {h} px; map area {Polygon(MAP_AREA).area / (w * h):.0%} of it")
sheet 12232 x 8343 px; map area 31% of it

2. Match church symbols to villages

Speed marks each parish with a small church and spells it his own way: Oukham, Vppingham, Edgeton, Lynden, Moorecote. The control point is the centre of the symbol, matched to OpenStreetMap's point for the modern village (POINTS, built in the setup block):

print(len(POINTS), "villages matched; missing:", missing or "none")
33 villages matched; missing: none

Some of Speed's villages cannot be matched at all. Martinsthorpe and Horn were already deserted or shrinking when he drew them and are farms or fields today; they are left out rather than guessed.

3. Read the scale bar

The scale of miles is a measurement Speed made, and it can be checked. The divisions are the only columns of the bar that are dark from top to bottom:

Speed's engraved scale of six miles with red lines on the seven divisions found automatically, above a profile of the share of dark pixels in each column, where the divisions are the spikes above 0.8. Speed's engraved scale of six miles with red lines on the seven divisions found automatically, above a profile of the share of dark pixels in each column, where the divisions are the spikes above 0.8.
Each division is a column that is dark from top to bottom of the bar: 414 pixels per mile.
band = np.asarray(Image.open("data/speed_rutland.jpg").convert("L").crop((8100, 6800, 10700, 6860)), float)
cols = np.flatnonzero((band < 110).mean(axis=0) > 0.8) + 8100
divs = [g.mean() for g in np.split(cols, np.flatnonzero(np.diff(cols) > 3) + 1) if g.mean() > 8150]
px_per_mile = (divs[-1] - divs[0]) / (len(divs) - 1)
X = np.array([[1, c, r] for _, c, r, _, _ in POINTS], float)
A = np.linalg.lstsq(X, np.array([[e, n] for *_, e, n in POINTS]), rcond=None)[0]
m_per_px = np.hypot(*A[1])
print(f"{len(divs) - 1} miles over {divs[-1] - divs[0]:.0f} px: {px_per_mile:.1f} px per mile")
print(f"fitted {m_per_px:.2f} m per px, so Speed's mile is {px_per_mile * m_per_px:,.0f} m "
      f"({px_per_mile * m_per_px / 1609.344:.2f} statute miles)")
6 miles over 2486 px: 414.3 px per mile
fitted 4.80 m per px, so Speed's mile is 1,988 m (1.24 statute miles)

Speed's mile is about two kilometres, a quarter longer than the statute mile defined in 1593. Long "customary" miles were still common in Speed's time, and county maps used them; any distance read off the sheet in its own miles needs that factor.

4. Choose the transformation with leave-one-out

With 33 points, a polynomial has enough to work with. Score affine, polynomial 2 and a thin-plate spline the same way:

for label, opts in {"affine": ["METHOD=GCP_POLYNOMIAL", "MAX_GCP_ORDER=1"],
                    "polynomial 2": ["METHOD=GCP_POLYNOMIAL", "MAX_GCP_ORDER=2"],
                    "thin-plate spline": ["METHOD=GCP_TPS"]}.items():
    err = []
    for i, (k, c, r, e, n) in enumerate(POINTS):
        rest = POINTS[:i] + POINTS[i + 1:]
        v = gdal.Translate("", "data/speed_rutland.jpg", format="VRT", bandList=[1], outputSRS="EPSG:27700",
                           GCPs=[gdal.GCP(ee, nn, 0, cc, rr) for _, cc, rr, ee, nn in rest])
        x, y = gdal.Transformer(v, None, opts).TransformPoint(0, c, r)[1][:2]
        err.append(np.hypot(x - e, y - n))
    print(f"{label:18} leave-one-out RMS {np.sqrt(np.mean(np.square(err))):5.0f} m   worst {max(err):5.0f} m")
affine             leave-one-out RMS   695 m   worst  1423 m
polynomial 2       leave-one-out RMS   549 m   worst  1085 m
thin-plate spline  leave-one-out RMS   689 m   worst  1282 m

The opposite of the Victorian sheet: there, seven precise points favoured the spline and broke the polynomial; here, 33 imprecise ones favour the polynomial, which catches a smooth distortion across the county, while the spline gains almost nothing by bending to honour every village's individual error.

5. Warp, cut, and look

The quick answer warps with polynomial 2 at 10 m — Speed's linework is a pixel or two wide at about 4.8 m per pixel, so finer cells add nothing — and cuts to the map area. Then look at it with modern data:

Speed's Rutland warped onto British National Grid with a second-order polynomial, its edges curved, with Rutland Water in blue and today's villages as red dots near Speed's church symbols. Speed's Rutland warped onto British National Grid with a second-order polynomial, its edges curved, with Rutland Water in blue and today's villages as red dots near Speed's church symbols.
Speed's county under today's reservoir and villages. The curved edges are the polynomial extrapolating beyond the points. © OpenStreetMap contributors.

The warped sheet is curved at the edges. That is the polynomial extrapolating into the margins where there are no villages; the county itself, where the points are, is where the fit can be trusted.

Georeferencing Speed's Rutlandshire

The data used on this page

John Speed, Rutlandshire, 1676 Bassett & Chiswell edition of The Theatre of the Empire of Great Britaine (first published 1611–12), from the David Rumsey Map Collection, David Rumsey Map Center, Stanford Libraries, via Wikimedia Commons (public domain); OpenStreetMap from Geofabrik (1 January 2026; © OpenStreetMap contributors, ODbL). The church symbols were located by eye at full resolution.

from pathlib import Path

import geopandas as gpd
import numpy as np
import requests
from osgeo import gdal
from PIL import Image
from shapely.geometry import Polygon

gdal.UseExceptions()
Image.MAX_IMAGE_PIXELS = None            # 12,232 x 8,343 pixels
Path("data").mkdir(exist_ok=True)
UA = {"User-Agent": "spatialworkflow.io guide (https://www.spatialworkflow.io)"}
downloads = {
    "data/speed_rutland.jpg": "https://upload.wikimedia.org/wikipedia/commons/9/9c/Rutlandshire._%28insets%29_Oukham."
                              "_Stanford._%28IA_dr_rutlandshire-insets-oukham-stanford-12058072%29.jpg",
    "data/rutland.shp.zip": "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip",
}
for target, url in downloads.items():
    if not Path(target).exists():
        Path(target).write_bytes(requests.get(url, headers=UA, timeout=600).content)

SPEED_PX = {  # modern village name: (column, row) of Speed's church symbol
    "Oakham": (4960, 4035), "Langham": (4475, 3725), "Barleythorpe": (4755, 3920), "Egleton": (5265, 4290),
    "Braunston in Rutland": (4470, 4745), "Brooke": (4705, 4790), "Gunthorpe": (5095, 4745), "Manton": (5430, 4935),
    "Greetham": (6200, 2930), "Cottesmore": (5715, 3205), "Exton": (6165, 3500), "Empingham": (6730, 3890),
    "Whitwell": (6245, 4080), "Upper Hambleton": (5795, 4285), "Burley on the Hill": (5490, 3705), "Barrow": (5495, 2965),
    "Pickworth": (7375, 2930), "Tickencote": (7595, 3875), "Lyndon": (6005, 4880), "Edith Weston": (6415, 4555),
    "Wing": (5735, 5245), "Pilton": (6315, 5335), "North Luffenham": (6600, 5110), "South Luffenham": (6705, 5400),
    "Ridlington": (4755, 5425), "Preston": (5335, 5380), "Glaston": (5650, 5690), "Morcott": (6465, 5735),
    "Ayston": (4960, 5765), "Uppingham": (5125, 6080), "Bisbrooke": (5605, 6035), "Seaton": (5960, 6430),
    "Barrowden": (7070, 5985),
}

MAP_AREA = [(3300, 3000), (4200, 3000), (4200, 1600), (7900, 1600), (7900, 2950), (10850, 2950),
            (10850, 4150), (8850, 4150), (8850, 6550), (8150, 6550), (8150, 7450), (3750, 7450),
            (3750, 6600), (3300, 6600)]

places = gpd.read_file("/vsizip/data/rutland.shp.zip/gis_osm_places_free_1.shp").to_crs(27700)
places = places[places.fclass.isin(["village", "town", "hamlet"])].drop_duplicates("name").set_index("name")
missing = [k for k in SPEED_PX if k not in places.index]
POINTS = [(k, c, r, places.loc[k].geometry.x, places.loc[k].geometry.y) for k, (c, r) in SPEED_PX.items()]

def densify(ring, step=50):
    """Points every `step` pixels along a closed ring, so a curved warp can follow it."""
    ring = list(ring) + [ring[0]]
    out = []
    for (x0, y0), (x1, y1) in zip(ring[:-1], ring[1:]):
        k = max(1, int(np.hypot(x1 - x0, y1 - y0) // step))
        out += [(x0 + (x1 - x0) * i / k, y0 + (y1 - y0) * i / k) for i in range(k)]
    return out

print({p.name: f"{p.stat().st_size / 1e6:.1f} MB" for p in Path("data").iterdir() if p.suffix in (".jpg", ".zip")})
{'speed_rutland.jpg': '27.8 MB', 'rutland.shp.zip': '2.7 MB'}

Example 1: what happens without the cutline

Warp the whole sheet with the same transformation and the inset plans, arms and page margins come along, bent by a polynomial that was never fitted to them:

whole = gdal.Warp("", vrt, format="MEM", dstSRS="EPSG:27700", polynomialOrder=2, xRes=10, yRes=10, dstAlpha=True)
cutf = gdal.Open("data/speed_rutland_bng.tif")
for label, ds in (("whole sheet", whole), ("map area only", cutf)):
    gt = ds.GetGeoTransform()
    print(f"{label:14} {ds.RasterXSize * gt[1] / 1000:5.1f} km x {ds.RasterYSize * -gt[5] / 1000:5.1f} km")
whole sheet     75.7 km x  58.2 km
map area only   40.1 km x  30.6 km

The whole-sheet warp covers far more ground than the county, most of it the book's binding and margins pretending to be Leicestershire and Lincolnshire. With a polynomial, the further out a pixel is from the villages, the stranger its position; the cutline keeps only the part the points describe.

Example 2: the villages Speed placed worst

The leave-one-out errors of the polynomial fit, largest first, show where his map is least reliable:

v = gdal.Translate("", "data/speed_rutland.jpg", format="VRT", bandList=[1], outputSRS="EPSG:27700",
                   GCPs=[gdal.GCP(e, n, 0, c, r) for _, c, r, e, n in POINTS])
worst = []
for i, (k, c, r, e, n) in enumerate(POINTS):
    rest = POINTS[:i] + POINTS[i + 1:]
    vi = gdal.Translate("", "data/speed_rutland.jpg", format="VRT", bandList=[1], outputSRS="EPSG:27700",
                        GCPs=[gdal.GCP(ee, nn, 0, cc, rr) for _, cc, rr, ee, nn in rest])
    x, y = gdal.Transformer(vi, None, ["METHOD=GCP_POLYNOMIAL", "MAX_GCP_ORDER=2"]).TransformPoint(0, c, r)[1][:2]
    worst.append((round(float(np.hypot(x - e, y - n))), k, round(np.degrees(np.arctan2(x - e, y - n)))))
for d, k, bearing in sorted(worst, reverse=True)[:5]:
    print(f"{k:16} {d:5} m, displaced towards bearing {bearing % 360:3} deg")
Glaston           1085 m, displaced towards bearing 306 deg
Langham           1067 m, displaced towards bearing 305 deg
Braunston in Rutland   971 m, displaced towards bearing 149 deg
Pilton             804 m, displaced towards bearing 131 deg
Tickencote         752 m, displaced towards bearing 151 deg

These are the places to check before reading anything off Speed's map: a symbol a kilometre from its village is either a mistake in the map or a place that has moved, and the map alone cannot tell you which.

Early county maps against surveyed ones

The difference between an early county map and a modern survey is not just size of error; it is the kind of error. Speed compiled his maps from earlier ones — Christopher Saxton's county surveys of the 1570s among them — and from information gathered on journeys, with villages placed by direction and travelled distance. Errors in that process accumulate along routes and differ from parish to parish. A fit through the villages averages them; nothing removes them.

Georeferencing a 1611 county map compared with an 1880s Ordnance Survey sheet. same procedure, opposite conclusionsSpeed, 1611 (1676 edition)✓33 villages, each ±hundreds of m✓polynomial 2 best: 549 m✓spline gains almost nothing✓questions at parish scaleOS six-inch, 1884–1891✓7 features, each ±metres✓spline best: 11.6 m✓polynomial 2 breaks: 571 m✓questions at field scaleMany rough points suit a smooth fit; a few precise ones suit a flexible one spatialworkflow.io
The choice of transformation follows from the points, not from the software.

That shapes every choice on this page. Control points have to be features that existed in 1611 and still exist, which leaves churches and villages; there are not enough precise points to test a fine fit, but there are many rough ones. A smooth polynomial with 33 points can pick up the distortion common to a whole region of the county, while a spline would bend to each village's own mistake and gain almost nothing. And the result is good to about half a kilometre, so it should be used for questions at that scale: which parishes, which woods, which roads existed — not where a hedge ran.

The cutline matters for the same reason. A transformation is a statement about the area its control points cover. The inset plan of Stamford is drawn at a scale of its own; warping it with the county's transformation produces a confident-looking picture of nothing.

Reused plates, changed names and deserted villages

Traps when georeferencing an early printed county map, and how to avoid each. what goes wrong on an early county maptrapavoid it byinset plans warped as countrya cutline in the target CRSold miles read as statutemeasure the scale barBarowe matched to Barrowdenmatch spellings by handedition date taken as surveydate the plate, not the printAll four are about the source, not the software spatialworkflow.io
None of these is a software problem.
  • Later editions reuse the plates. A 1676 printing shows the 1611 geography; date the content, not the paper.
  • Names change. Match Speed's spellings by hand; a fuzzy matcher will happily pair Barowe with Barrowden.
  • Deserted villages have no modern point; leave them out.
  • Village points are fuzzy in OpenStreetMap; at Speed's accuracy that is fine, at the OS sheet's it is not.
  • Speed's mile is about 2 km; convert before quoting distances.
  • Rutland Water did not exist: the 1970s reservoir drowned the valley below Hambleton and the village of Nether Hambleton, so expect the old map to show a valley where today's data shows a lake.

FAQ

Can you georeference a 17th-century map?

Yes, with church or village symbols as control points. Expect an accuracy of hundreds of metres: Speed's Rutland fits to about 550 m (leave-one-out RMS) with a second-order polynomial.

Which transformation suits an early county map?

Test them. With many imprecise points, a second-order polynomial often predicts best, because it captures smooth distortion across the county without bending to individual errors.

How do I stop the decorations being warped?

Draw the map area as a polygon in pixel coordinates, push it through the same transformation into the target CRS, and pass it to gdal.Warp as a cutline with cropToCutline=True.

How long was a mile on old maps?

It varied. Measured against modern data, the mile on Speed's Rutland scale bar is about 2 km, a quarter longer than a statute mile.