How to Measure Change from an Old Map in Python

On this page

Problem statement

The point of putting an old map on today's grid is usually to ask what has changed: how much woodland was lost, how far a coast retreated, which village disappeared. The overlay makes change easy to see and easy to overstate. Every number from it mixes real change with three kinds of error — the old survey's, the georeferencing's and the digitising's — and with the fact that "wood" on an 1880s sheet and "forest" in OpenStreetMap are not the same definition.

This page measures what happened to the woods of the Hambleton valley in Rutland between the Ordnance Survey six-inch survey of 1884–1891 and today, when much of the valley lies under Rutland Water, and reports each answer as a range that carries those errors.

Quick answer

Overlay the old polygons on today's data, split them by fate, and repeat the split under the uncertainty you know about — here, a 20 m georeferencing error and the digitising threshold:

def fate(old):
    o = old.union_all()
    water = o.intersection(WATER).area
    wood = o.difference(WATER).intersection(FOREST).area
    return {"old ha": o.area / 1e4, "under water": water / o.area, "still wood": wood / o.area,
            "now neither": 1 - (water + wood) / o.area}

runs = {f"threshold {t}, shift {s:+} m": fate(woods_1880s(t).buffer(s)) for t in (0.03, 0.04, 0.05) for s in (-20, 0, 20)}
table = pd.DataFrame(runs).T
print(table.loc["threshold 0.04, shift +0 m"].round(3).to_string())
print(table.agg(["min", "max"]).round(3).to_string())
old ha         124.738
under water      0.403
still wood       0.403
now neither      0.194
      old ha  under water  still wood  now neither
min   48.021        0.374       0.323        0.174
max  200.333        0.435       0.447        0.241

Of the woods on the 1880s sheet, roughly two-fifths are now under the reservoir, two-fifths are still woodland and one-fifth is neither — and those shares move by only about six percentage points either way whatever reasonable choices are made:

The 1880s six-inch sheet 009-NE with the woods digitised from it outlined in red, today's woodland in green and Rutland Water in blue: the southern parts of Barnsdale, Armley and Hambleton Woods lie under the water, and new plantations line the shore. The 1880s six-inch sheet 009-NE with the woods digitised from it outlined in red, today's woodland in green and Rutland Water in blue: the southern parts of Barnsdale, Armley and Hambleton Woods lie under the water, and new plantations line the shore.
Red: woods on the 1884–1891 sheet. Green: landuse=forest in OpenStreetMap. Blue: Rutland Water. OS six-inch sheet 009-NE, public domain; © OpenStreetMap contributors.

One grid, one definition, overlay, carry the errors

Measuring what happened to the 1880s woods of the Hambleton valley. from an overlay to a defensible number1one grid, one footprintsheet 009-NE, 1,552 ha2say what each date meansstipple vs landuse=forest3classify by fatewater, wood, neither4repeat under the errors±20 m, three thresholds5report shares as ranges37–44% under waterStep 4 is what separates a measurement from a picture spatialworkflow.io
The steps that turn an overlay into a measurement.

1. Put both dates on one grid

The 1880s woods were digitised from the tree stipple on sheet 009-NE, placed in British National Grid from sheet 009-NW's seven control points and the sheet layout (see digitising and mosaicking). Today's woodland and water come from OpenStreetMap, clipped to the same sheet:

print(f"sheet {SHEET.area / 1e4:,.0f} ha; Rutland Water on it {WATER.area / 1e4:,.0f} ha; "
      f"OSM woodland on it {FOREST.area / 1e4:,.0f} ha; 1880s woods {woods_1880s(0.04).area.sum() / 1e4:,.1f} ha")
sheet 1,552 ha; Rutland Water on it 700 ha; OSM woodland on it 144 ha; 1880s woods 124.7 ha

Almost half the sheet is now water. The reservoir was built in the 1970s by damming the Gwash valley, and the Hambleton peninsula is what remained above it.

2. Agree what "woodland" means at both dates

The old sheet draws woods as a stipple of tree symbols, bounded where the surveyor saw the wood end. OpenStreetMap's landuse=forest is drawn by volunteers from aerial imagery and includes plantations, shelterbelts and some parkland. Neither is a forestry inventory. The comparison is between two maps' ideas of woodland, and that should be said whenever the numbers are quoted.

3. Overlay and classify

Each piece of 1880s wood falls into one of three fates — under water, still woodland, neither — and the overlay gives the area of each, shown at the top of the page. It also gives the reverse: woodland today that was not woodland then:

o = woods_1880s(0.04).union_all()
new = FOREST.difference(o)
print(f"woodland today: {FOREST.area / 1e4:.1f} ha, of which {new.area / 1e4:.1f} ha was not wood in the 1880s "
      f"({new.area / FOREST.area:.0%})")
woodland today: 144.1 ha, of which 93.8 ha was not wood in the 1880s (65%)

Most of today's woodland on the sheet is new — plantations along the reservoir shore and around Upper Hambleton. The valley lost old woods to the water and gained new ones around it.

4. Carry the errors through

Two errors are known and can be simulated. The georeferencing is good to about 20 m (leave-one-out and three check churches); shrinking and growing every old polygon by 20 m brackets it. The digitising threshold moves woodland area by tens of hectares; repeating at 0.03 and 0.05 brackets that. The quick answer ran all nine combinations:

print(table[["old ha", "under water", "still wood"]].round(2).to_string())
                             old ha  under water  still wood
threshold 0.03, shift -20 m  105.52         0.41        0.40
threshold 0.03, shift +0 m   153.32         0.42        0.38
threshold 0.03, shift +20 m  200.33         0.44        0.32
threshold 0.04, shift -20 m   80.71         0.39        0.41
threshold 0.04, shift +0 m   124.74         0.40        0.40
threshold 0.04, shift +20 m  165.79         0.41        0.36
threshold 0.05, shift -20 m   48.02         0.37        0.44
threshold 0.05, shift +0 m    89.95         0.38        0.45
threshold 0.05, shift +20 m  126.00         0.39        0.42
Share of the 1880s woods now under Rutland Water, for three digitising thresholds and three positional shifts. nine runs, one stable answershrink 20 mas placedgrow 20 mthreshold 0.0341% of 106 ha42% of 153 ha44% of 200 hathreshold 0.0439% of 81 ha40% of 125 ha41% of 166 hathreshold 0.0537% of 48 ha38% of 90 ha39% of 126 haHectares change fourfold; the share under water moves seven points spatialworkflow.io
The share is stable; the area is not.

The areas swing widely — the total old woodland ranges from 48 to 200 ha across the nine runs, a factor of four — but the shares barely move. That is the useful finding: the proportions are robust, the hectares are not.

5. Report the range, and the definitions

The defensible statement is a range of shares with its sources: "of the woods drawn on the 1884–1891 six-inch sheet 009-NE, 37–44% by area now lie under Rutland Water and 32–45% are still woodland in OpenStreetMap (1 January 2026)", with the range taken from the table. A single area to the hectare would claim a precision the map does not have.

Woodland change, wood by wood

The data used on this page

Ordnance Survey six-inch sheets Rutland 009-NW and 009-NE (1884–1891) from Wikimedia Commons (public domain); OpenStreetMap from Geofabrik (1 January 2026; © OpenStreetMap contributors, ODbL): landuse=forest and water areas over 100 ha.

from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import requests
from PIL import Image
from rasterio import features
from scipy import ndimage as ndi
from shapely import affinity
from shapely.geometry import Polygon, shape

Path("data").mkdir(exist_ok=True)
UA = {"User-Agent": "spatialworkflow.io guide (https://www.spatialworkflow.io)"}
COMMONS = "https://upload.wikimedia.org/wikipedia/commons/"
for name, path in {"009-NW": "a/a0/", "009-NE": "8/80/"}.items():
    if not Path(f"data/os_{name}.jpg").exists():
        url = f"{COMMONS}{path}Map_of_Rutland_OS_Map_name_{name}%2C_Ordnance_Survey%2C_1884-1891.jpg"
        Path(f"data/os_{name}.jpg").write_bytes(requests.get(url, headers=UA, timeout=300).content)
if not Path("data/rutland.shp.zip").exists():
    Path("data/rutland.shp.zip").write_bytes(requests.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip",
        headers=UA, timeout=300).content)

GCPS = [  # (id, pixel, line, easting, northing) on 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),
]
X = np.array([[1, g[1], g[2]] for g in GCPS], float)
A = np.linalg.lstsq(X, np.array([[g[3], g[4]] for g in GCPS]), rcond=None)[0]
(w0, h0), (w, h) = Image.open("data/os_009-NW.jpg").size, Image.open("data/os_009-NE.jpg").size
sx, sy = w0 / w, h0 / h                                  # 009-NE is the next sheet east
NE = [A[1, 0] * sx, A[2, 0] * sy, A[1, 1] * sx, A[2, 1] * sy, A[0, 0] + A[1, 0] * w0, A[0, 1] + A[1, 1] * w0]
SHEET = affinity.affine_transform(Polygon([(0, 0), (w, 0), (w, h), (0, h)]), NE)

im = np.asarray(Image.open("data/os_009-NE.jpg").convert("L"))
lab, _ = ndi.label(im < 128)
sizes = np.bincount(lab.ravel())
is_dot = (sizes >= 2) & (sizes <= 40)
is_dot[0] = False
DENSITY = ndi.uniform_filter(is_dot[lab].astype(float), size=41)
yy, xx = np.ogrid[-15:16, -15:16]

def woods_1880s(threshold):
    """Woodland on sheet 009-NE from the tree stipple, as in the digitising guide."""
    m = ndi.binary_fill_holes(ndi.binary_opening(DENSITY > threshold, structure=xx**2 + yy**2 <= 225))
    L, k = ndi.label(m)
    m = np.r_[False, ndi.maximum(ndi.distance_transform_edt(m), L, range(1, k + 1)) >= 35][L]
    g = gpd.GeoSeries([affinity.affine_transform(shape(p), NE) for p, _ in features.shapes(m.astype(np.uint8), mask=m)],
                      crs=27700)
    return g[g.area > 2e4]

bb = tuple(gpd.GeoSeries([SHEET], crs=27700).to_crs(4326).total_bounds)
lu = gpd.read_file("/vsizip/data/rutland.shp.zip/gis_osm_landuse_a_free_1.shp", bbox=bb).to_crs(27700)
FOREST = lu[lu.fclass == "forest"].union_all().intersection(SHEET)
wa = gpd.read_file("/vsizip/data/rutland.shp.zip/gis_osm_water_a_free_1.shp", bbox=bb).to_crs(27700)
WATER = wa[wa.area > 1e6].union_all().intersection(SHEET)
print(round(SHEET.area / 1e4), "ha sheet")
1552 ha sheet

Example 1: wood by wood

The shares for the whole sheet hide very different fates. Named woods, matched to the nearest OpenStreetMap name:

named = lu[(lu.fclass == "forest") & lu["name"].notna()]
rows = []
for poly in woods_1880s(0.04):
    near = named.distance(poly.centroid).sort_values().index[:1]
    rows.append({"nearest OSM name": named.loc[near[0], "name"] if len(near) else None,
                 "ha 1880s": round(poly.area / 1e4, 1),
                 "under water": f"{poly.intersection(WATER).area / poly.area:.0%}",
                 "still wood": f"{poly.difference(WATER).intersection(FOREST).area / poly.area:.0%}"})
print(pd.DataFrame(rows).sort_values("ha 1880s", ascending=False).to_string(index=False))
 nearest OSM name  ha 1880s under water still wood
   Barnsdale Wood      69.8         36%        43%
      Armley Wood      17.5         61%        36%
   Hambleton Wood      16.0         57%        41%
   Barnsdale Wood       9.1          5%        71%
Half Moon Spinney       3.8          0%         0%
Half Moon Spinney       3.6         80%        19%
   Barnsdale Wood       2.7          1%        14%
Half Moon Spinney       2.2        100%         0%

Barnsdale Wood, the largest, lost its southern third to the water and kept most of the rest; Armley and Hambleton Woods lost more than half. One small wood on the valley floor went entirely under the water, and another, 3.8 ha, is today neither water nor woodland — cleared. A share for the whole sheet is an average of these quite different stories.

Example 2: the smallest change worth reporting

With a 20 m georeferencing error, how wide a strip of change at a wood's edge could be error alone?

for poly in sorted(woods_1880s(0.04), key=lambda p: -p.area)[:3]:
    ring = poly.buffer(20).difference(poly.buffer(-20))
    print(f"wood of {poly.area / 1e4:5.1f} ha: a ±20 m band around its edge is {ring.area / 1e4:4.1f} ha "
          f"({ring.area / poly.area:.0%} of the wood)")
wood of  69.8 ha: a ±20 m band around its edge is 26.2 ha (37% of the wood)
wood of  17.5 ha: a ±20 m band around its edge is 10.9 ha (62% of the wood)
wood of  16.0 ha: a ±20 m band around its edge is 17.0 ha (107% of the wood)

For Barnsdale Wood the band is about a third of the wood; for Hambleton Wood, long and narrow, it is larger than the wood itself. Change at the scale of a single small wood — or along any one edge — needs better georeferencing before it can be claimed.

Change is the difference between two imperfect representations

Measuring change from an old map is a comparison between two representations, each with its own error and its own definition, and only the difference between them is of interest. The difference is smaller than either of them, so their errors matter more than they would on their own.

Sources of error when measuring change between an 1880s map and OpenStreetMap. what sits between the two mapsdefinitionsstipple vs forest tagcannot be simulateddigitisingthreshold, window90–153 hageoreferencingcontrol points, layout±20 mthe survey1884–1891inside the 20 mSimulate the middle two; state the top one in words spatialworkflow.io
Two of the four can be simulated.

Two errors can be simulated: position, by moving the old features by the georeferencing error; and extraction, by repeating the digitising with other reasonable parameters. Running every combination and reporting the spread turns a single figure into an honest range. What cannot be simulated is the definitional gap — what a Victorian surveyor drew as wood against what a mapper today tags as forest — and that has to be stated in words beside the numbers.

The Hambleton valley shows why the form of the result matters. The hectares of old woodland depend heavily on how the stipple is read; the shares under water and still wooded do not. When a quantity is unstable under reasonable choices, report a quantity that is stable — or report the instability itself.

Shares, buffers and footprints

Checks before reporting change measured from an old map. before quoting a changeSame footprint at both datesclip to the sheetBig water split out700 ha of reservoirPositional error simulatedbuffer ±20 mExtraction choices variedthree thresholdsDefinitions statedwhat "wood" meantA number without these is a picture with decimals spatialworkflow.io
Five checks before quoting a change.
  • Shares are more robust than areas when the extraction is uncertain.
  • Buffer by the georeferencing error to bracket positional uncertainty; ±20 m here.
  • Clip both dates to the same footprint, or edge woods count once on one side only.
  • Definitions differ between dates. Say what "woodland" meant in each source.
  • Large water bodies should be split out before computing land-cover change, or the reservoir dominates everything.
  • One sheet is a sample, not the county; scale up only with the same method on every sheet.

FAQ

How do I measure land-cover change from an old map?

Digitise the feature on the georeferenced map, overlay it on modern data in the same CRS and footprint, classify by fate, and repeat under the known uncertainties — positional (buffer by the georeferencing error) and extraction (vary the parameters) — to report a range.

How much of the Hambleton valley's woodland went under Rutland Water?

Of the woods drawn on the 1884–1891 six-inch sheet 009-NE, about two-fifths by area now lie under the reservoir and about two-fifths are still woodland, across all nine combinations of digitising threshold and ±20 m shift tested here.

Why are my change areas so sensitive?

Because the area of a feature extracted from an old map depends on where its edge is drawn, which depends on the method. Shares and rankings are usually more stable than absolute areas.

What is the smallest change I can claim?

Something larger than the georeferencing error at the scale you are talking about — a band of ±20 m at every edge here. For small woods that band is most of the wood.