How to Draw a Distortion Grid on an Old Map in Python

On this page

Problem statement

A single accuracy figure — "700 m RMS" — says how wrong an old map is on average, but not where or how. Is one corner squeezed? Is a river valley stretched? Did the mapmaker turn the north of the county against the south? Historians of cartography answer that with a distortion grid: a regular grid of today's coordinates, drawn onto the old map through the same control points that georeference it. Where the map is accurate the grid stays square; where it is not, the grid bends, stretches and twists.

This page draws one on John Speed's Rutlandshire (1611, here in the 1676 edition) and on the Ordnance Survey six-inch sheet of Oakham (1884–1891), and turns the bent grid into numbers: how much bigger or smaller, and how much rotated, each square kilometre is on the old map.

Quick answer

Build lines of constant easting and northing in the modern CRS, and send them backwards through a GDAL transformer fitted to the control points — direction 1 is world to pixel — so they land on the scan:

vrt = 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])
t = gdal.Transformer(vrt, None, ["METHOD=GCP_TPS"])
lines = []
for e in np.arange(483000, 500001, 1000):                      # 1 km eastings
    lines.append(np.array(t.TransformPoints(1, [(e, n, 0) for n in np.linspace(298000, 316000, 80)])[0])[:, :2])
for n in np.arange(298000, 316001, 1000):                      # 1 km northings
    lines.append(np.array(t.TransformPoints(1, [(e, n, 0) for e in np.linspace(483000, 500000, 80)])[0])[:, :2])
print(len(lines), "grid lines; first starts at pixel", np.round(lines[0][0]).astype(int))
37 grid lines; first starts at pixel [4431 6745]

Plot the lines over the scan. Speed's grid buckles; the Victorian sheet's stays square:

Left: Speed's map of Rutland with a red 1 km British National Grid drawn on it through a thin-plate spline, visibly bent, stretched and twisted, with blue dots at the 33 villages. Right: the 1880s OS sheet with a 250 m grid that stays square. Left: Speed's map of Rutland with a red 1 km British National Grid drawn on it through a thin-plate spline, visibly bent, stretched and twisted, with blue dots at the 33 villages. Right: the 1880s OS sheet with a 250 m grid that stays square.
The same modern grid drawn onto two old maps. Speed: David Rumsey Map Collection, Stanford Libraries, via Wikimedia Commons; OS six-inch sheet 009-NW, public domain.

A local fit, a matching grid spacing, through the transform

Drawing a distortion grid: modern grid lines sent backwards through a thin-plate spline onto the old map. the modern grid, drawn on the old mapspline fitexact at eachvillagegrid inEPSG:277001 km, inside thehullinversetransformworld to pixelplot on thescanbends show errorcellstatisticssize and turnThe old map is never resampled; the grid carries the distortion spatialworkflow.io
The old map is never resampled.

1. Fit a transformation that follows local distortion

A distortion grid has to show local error, so it needs a transformation that passes through the control points rather than averaging them. A thin-plate spline does exactly that — at every village it is exact, and between villages it bends as little as possible. An affine fit would draw a perfectly regular grid on any map and show nothing. Near Oakham, the two disagree about the size of a square kilometre:

for label, opts in (("affine", ["METHOD=GCP_POLYNOMIAL", "MAX_GCP_ORDER=1"]), ("spline", ["METHOD=GCP_TPS"])):
    tt = gdal.Transformer(vrt, None, opts)
    corners = np.array(tt.TransformPoints(1, [(486000, 308000, 0), (487000, 308000, 0), (486000, 309000, 0)])[0])[:, :2]
    print(f"{label:7} 1 km east is {np.hypot(*(corners[1] - corners[0])):5.1f} px, 1 km north is {np.hypot(*(corners[2] - corners[0])):5.1f} px")
affine  1 km east is 205.8 px, 1 km north is 206.7 px
spline  1 km east is 230.8 px, 1 km north is 221.2 px

2. Choose a grid spacing that matches the map

Grid lines should be several times further apart than the map's error, or every cell is noise. Speed's error is several hundred metres, so 1 km; the six-inch sheet's is about 20 m, so 250 m shows plenty of cells without crowding.

3. Send the grid through the transformer backwards

TransformPoints(1, ...) asks the transformer for the inverse — world to pixel. Densify each line (80 points here) so its curve shows. Restrict the grid to roughly the area the control points cover: outside it, a spline shows nothing but its own extrapolation.

4. Measure each cell

The four corners of each grid square land somewhere on the scan. The area of that quadrilateral, relative to the typical cell, is how much the old map enlarged or shrank that square kilometre; the direction of its southern edge is how much it turned it:

ratio, rot, inside = cell_stats(POINTS, 1000)
print(f"{inside.sum()} cells inside the villages' hull")
print(f"area on the map vs typical: 5th-95th percentile {np.percentile(ratio[inside], 5):.2f}x to {np.percentile(ratio[inside], 95):.2f}x")
print(f"rotation vs typical:        5th-95th percentile {np.percentile(rot[inside], 5):+.1f} to {np.percentile(rot[inside], 95):+.1f} deg")
192 cells inside the villages' hull
area on the map vs typical: 5th-95th percentile 0.65x to 1.54x
rotation vs typical:        5th-95th percentile -16.7 to +19.7 deg
Two maps of Rutland in 1 km cells: the left coloured by how large each cell is drawn on Speed's map relative to the typical cell, from half to double; the right by how much each cell is rotated, from minus to plus 20 degrees. Two maps of Rutland in 1 km cells: the left coloured by how large each cell is drawn on Speed's map relative to the typical cell, from half to double; the right by how much each cell is rotated, from minus to plus 20 degrees.
Each square kilometre's size and turn on Speed's map, within the hull of the 33 villages, with Rutland Water's outline for orientation. © OpenStreetMap contributors.

Some square kilometres are drawn at two-thirds of their typical size and some at half again; the drawing turns by up to twenty degrees either way from one part of the county to the next. Those are the parts of Speed's map where village positions were estimated from each other along routes rather than fixed, and they are the map's real structure of error.

5. Compare with a map you trust

The same procedure on the OS sheet is the control experiment:

os_ratio, os_rot, os_inside = cell_stats(OS, 250, scan="data/os_009-NW.jpg")
print(f"OS sheet: area 5th-95th {np.percentile(os_ratio[os_inside], 5):.2f}x-{np.percentile(os_ratio[os_inside], 95):.2f}x, "
      f"rotation {np.percentile(os_rot[os_inside], 5):+.1f} to {np.percentile(os_rot[os_inside], 95):+.1f} deg")
OS sheet: area 5th-95th 0.98x-1.01x, rotation -0.8 to +0.1 deg

A map that was surveyed rather than compiled gives an almost uniform grid. That contrast — not either grid alone — is what a distortion grid is for.

Drawing a grid over Speed's map

The data used on this page

John Speed, Rutlandshire, 1676 Bassett & Chiswell edition (first published 1611–12), from the David Rumsey Map Collection, David Rumsey Map Center, Stanford Libraries, via Wikimedia Commons (public domain); the Ordnance Survey six-inch sheet Rutland 009-NW (1884–1891, Wikimedia Commons, public domain); OpenStreetMap from Geofabrik (1 January 2026; © OpenStreetMap contributors, ODbL). The control points are those of the early county map guide and the control-point guide.

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 MultiPoint, Point

gdal.UseExceptions()
Image.MAX_IMAGE_PIXELS = None
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/os_009-NW.jpg": "https://upload.wikimedia.org/wikipedia/commons/a/a0/"
                          "Map_of_Rutland_OS_Map_name_009-NW%2C_Ordnance_Survey%2C_1884-1891.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),
}
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")
POINTS = [(k, c, r, places.loc[k].geometry.x, places.loc[k].geometry.y) for k, (c, r) in SPEED_PX.items()]
OS = [  # (id, pixel, line, easting, northing) on six-inch sheet 009-NW
    ("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 cell_stats(points, spacing, scan="data/speed_rutland.jpg"):
    """Area (relative to the median cell) and rotation (degrees from the median) of every grid cell on
    the scan, through a thin-plate spline; `inside` marks cells whose centre is inside the points' hull."""
    v = gdal.Translate("", scan, format="VRT", bandList=[1], outputSRS="EPSG:27700",
                       GCPs=[gdal.GCP(e, n, 0, c, r) for _, c, r, e, n in points])
    tt = gdal.Transformer(v, None, ["METHOD=GCP_TPS"])
    E = np.array([g[3] for g in points]); N = np.array([g[4] for g in points])
    es = np.arange(np.floor(E.min() / spacing) * spacing, np.ceil(E.max() / spacing) * spacing + 1, spacing)
    ns = np.arange(np.floor(N.min() / spacing) * spacing, np.ceil(N.max() / spacing) * spacing + 1, spacing)
    ge, gn = np.meshgrid(es, ns)
    px = np.array(tt.TransformPoints(1, [(e, n, 0) for e, n in zip(ge.ravel(), gn.ravel())])[0])[:, :2]
    px = px.reshape(len(ns), len(es), 2)
    a, b, c, d = px[:-1, :-1], px[:-1, 1:], px[1:, :-1], px[1:, 1:]
    cross = lambda p, q: p[..., 0] * q[..., 1] - q[..., 0] * p[..., 1]
    area = 0.5 * np.abs(cross(a, b) + cross(b, d) + cross(d, c) + cross(c, a))
    rot = np.degrees(np.arctan2(-(b[..., 1] - a[..., 1]), b[..., 0] - a[..., 0]))
    hull = MultiPoint([(g[3], g[4]) for g in points]).convex_hull
    inside = np.array([[hull.contains(Point(x + spacing / 2, y + spacing / 2)) for x in es[:-1]] for y in ns[:-1]])
    return area / np.median(area), rot - np.median(rot), inside

print(len(POINTS), "Speed points,", len(OS), "OS points")
33 Speed points, 7 OS points

Example 1: draw the grid and save it

The quick answer's lines, plotted over a reduced copy of the scan, are the figure at the top of this page:

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

x0, y0, x1, y1 = 3250, 1550, 8900, 7500
img = np.asarray(Image.open("data/speed_rutland.jpg").convert("L").crop((x0, y0, x1, y1)).reduce(5))
fig, ax = plt.subplots(figsize=(6, 6.3), dpi=150)
ax.imshow(img, cmap="gray", extent=(x0, x1, y1, y0))
for L in lines:
    ax.plot(L[:, 0], L[:, 1], color="#ef4444", lw=0.7)
ax.plot([p[1] for p in POINTS], [p[2] for p in POINTS], "o", ms=3, color="#0ea5e9")
ax.set_xlim(x0, x1); ax.set_ylim(y1, y0); ax.set_axis_off()
fig.savefig("data/speed_distortion_grid.png", bbox_inches="tight")
print(Path("data/speed_distortion_grid.png").stat().st_size > 100_000)
True

Example 2: which way is "north" on Speed's map?

The median rotation of the grid is Speed's overall orientation against the National Grid — the same thing the affine fit's rotation measures:

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])
tt = gdal.Transformer(v, None, ["METHOD=GCP_TPS"])
p = np.array(tt.TransformPoints(1, [(490000, 305000, 0), (490000, 315000, 0)])[0])[:, :2]
bearing = np.degrees(np.arctan2(p[1, 0] - p[0, 0], -(p[1, 1] - p[0, 1])))
print(f"grid north on Speed's sheet points {bearing:+.1f} deg from the top of the page")
grid north on Speed's sheet points -4.6 deg from the top of the page

A few degrees, consistent with the affine fit's four. The page's "North" is close to grid north overall; the twenty-degree local twists in the rotation map are where individual parts of the county are turned against each other.

Reversing the usual direction of a warp

A distortion grid reverses the usual direction of georeferencing. Normally the old map is warped to fit a modern grid, and the distortion disappears into the resampling. Drawing the modern grid onto the old map leaves the old map untouched and puts the distortion on display, in the map's own space, where a historian can relate it to what the map shows: a river the mapmaker followed, a road he travelled, a part of the county he did not visit.

Warping an old map onto a modern grid compared with drawing the modern grid onto the old map. two directions, two purposeswarp the map to the grid✓old map resampled✓distortion hidden in pixels✓good for overlays✓error averaged into a numberdraw the grid on the map✓old map untouched✓distortion on display✓good for map history✓Speed: cells 0.65x to 1.54xSame control points, same transformation, opposite direction spatialworkflow.io
Same points and transformation, used in opposite directions.

The grid is only as meaningful as the control points. Between points, a thin-plate spline bends as smoothly as it can, so a cell's shape is an interpolation of the errors at the villages around it; one misidentified village bends every cell near it. That is why the grid should be confined to the points' hull, why the grid spacing should be larger than the map's error, and why the cell statistics on this page are percentiles rather than extremes. A few cells at two or three times their size usually mean one bad point, not a strange map.

The OS comparison is not a formality. Without it, a grid that bends by ten per cent could be read as the map's distortion when it is the noise in your control points. With a precise map treated the same way alongside, the difference between the two grids is the difference between the maps.

Splines, hulls and spacing against error

Checks before interpreting a distortion grid on an old map. before reading meaning into a bent gridSpline, not affineaffine is always squareGrid inside the pointsno extrapolationSpacing over the error1 km for SpeedKnots checked for bad pointsone point, one knotA precise map as controlOS: 0.98x to 1.01xThe last check is the one that makes the grid mean something spatialworkflow.io
Five checks before interpreting a bent grid.
  • Use a spline, not affine, for the grid; an affine grid is always perfectly regular.
  • Stay inside the points' hull. Outside it the grid shows the spline's extrapolation, not the map.
  • Spacing larger than the error. At 200 m on Speed's map every cell is noise.
  • Check outliers first. One wrong control point makes a local knot in the grid; fix the point before interpreting the knot.
  • Inverse transforms of splines are iterative in GDAL; very coarse densification can miss sharp bends.
  • Rotation angles on the scan are measured with rows increasing downwards; flip the sign of the row difference.

FAQ

What is a distortion grid?

A regular grid of modern coordinates drawn onto an old map through the transformation that links them. Where the old map is accurate the grid stays regular; where it is distorted the grid bends.

How do I make a distortion grid in Python?

Fit a GDAL transformer (preferably a thin-plate spline) to the control points, build densified lines of constant easting and northing, and transform them with TransformPoints(1, ...) — the inverse, world to pixel — then plot them over the scan.

Is this what MapAnalyst does?

The idea is the same; MapAnalyst offers several interpolation methods and its own visualisations. The GDAL route here keeps the grid in the same pipeline as the georeferencing.

How distorted is John Speed's map of Rutland?

Locally, quite a lot: between the 5th and 95th percentiles, square kilometres are drawn from 0.65 to 1.54 times their typical size and turned by about ±20 degrees, while the overall orientation is within a few degrees of grid north.