How to Digitise Features from an Old Map in Python

On this page

Problem statement

A georeferenced old map is still a picture: to measure anything on it β€” how much woodland a parish had, how long its roads were β€” the features have to become polygons and lines with coordinates. Tracing by hand in QGIS is accurate and slow. Thresholding the scan is fast and, on a black-and-white Ordnance Survey sheet, nearly useless: buildings, lettering, hedgerow trees, field boundaries and woodland symbols are all the same black.

The way through is to look for what makes a feature different on the map, not what makes it dark. On the six-inch sheets, woodland is drawn as a dense stipple of small tree symbols, and nothing else on the sheet looks like that. This page turns that texture into woodland polygons for Rutland sheet 009-NE (surveyed 1884–1891), georeferences them, and checks how sensitive the result is to the choices made along the way.

Quick answer

Find the small, isolated ink marks, measure how densely they occur, threshold the density, and keep only regions thick enough to be woods:

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)          # share of dot pixels nearby
mask = ndi.binary_fill_holes(ndi.binary_opening(density > 0.04, structure=DISK15))
L, k = ndi.label(mask)
thick = np.r_[False, ndi.maximum(ndi.distance_transform_edt(mask), L, range(1, k + 1)) >= 35]
mask = thick[L]
woods = to_polygons(mask, "009-NE")
print(len(woods), "woods,", round(woods.area.sum() / 1e4, 1), "ha:", sorted((woods.area / 1e4).round(1).tolist(), reverse=True))
8 woods, 124.7 ha: [69.8, 17.5, 16.0, 9.1, 3.8, 3.6, 2.7, 2.2]
Four panels around Barnsdale Wood on the 1880s sheet: the scan with its tree stipple; only the small isolated dots, in blue; a density surface that is bright inside the wood; and the final teal woodland mask covering the wood. Four panels around Barnsdale Wood on the 1880s sheet: the scan with its tree stipple; only the small isolated dots, in blue; a density surface that is bright inside the wood; and the final teal woodland mask covering the wood.
The four stages of the quick answer on one wood. Rutland OS six-inch sheet 009-NE, public domain.

Separate stipple from lines, density, shape, vectorise

Extracting woodland polygons from the stipple on an 1880s six-inch sheet. from tree symbols to polygonsink blobs33,439 on the sheetkeep the dots2–40 px eachdensity window41 px, > 0.04shape filtersopen, fill, β‰₯ 60 mpolygons inBNG8 woods, 124.7 haEach step encodes something about how woods were drawn spatialworkflow.io
Five steps from scan to polygons.

1. Separate the stipple from the lines

Every connected blob of ink on the scan has a size. Lines β€” roads, hedges, field boundaries, lettering β€” form large connected blobs; the tree symbols of a wood are dots of a few to a few dozen pixels:

print(f"{len(sizes) - 1:,} ink blobs; {is_dot.sum():,} of them 2-40 px; "
      f"dot pixels are {is_dot[lab].mean():.2%} of the sheet, all ink {(im < 128).mean():.1%}")
33,439 ink blobs; 24,789 of them 2-40 px; dot pixels are 1.35% of the sheet, all ink 8.1%

Dotted footpaths and the dots along hedgerows are also small blobs, so dots alone do not make a wood. Density does.

2. Turn dots into a density surface

A moving window counts the share of dot pixels around every pixel. Inside a wood the dots are everywhere; along a dotted path they are a thin line that a 41-pixel window (about 37 m) dilutes:

for label, (r, c) in {"inside Barnsdale Wood": (900, 3300), "open field": (2600, 600),
                      "Upper Hambleton village": (2050, 2200)}.items():
    print(f"{label:24} density {density[r, c]:.3f}")
inside Barnsdale Wood    density 0.102
open field               density 0.002
Upper Hambleton village  density 0.008

A threshold of 0.04 sits between the two. Step 5 tests how much the answer depends on it.

3. Clean the mask by shape

An opening with a disc of radius 15 pixels removes anything narrower than about 27 m; filling holes closes the clearings and rides inside woods; and keeping only regions whose widest point is at least 35 pixels from an edge β€” about 60 m across β€” drops the last dotted lanes:

before = ndi.label(ndi.binary_fill_holes(ndi.binary_opening(density > 0.04, structure=DISK15)))[1]
print(f"{before} regions after the opening, {thick.sum()} thick enough to be woods")
217 regions after the opening, 15 thick enough to be woods

4. Vectorise and georeference

rasterio.features.shapes turns the mask into polygons in pixel coordinates; an affine transformation fitted to the sheet's corners β€” placed from the sheet layout β€” moves them to British National Grid:

print(woods.crs.to_epsg(), [round(v) for v in woods.total_bounds])
woods.to_file("data/woods_1880s.gpkg")
27700 [488620, 306383, 492855, 309387]

The polygons carry the georeferencing error of the sheet β€” about 20 m, measured at three churches β€” on top of the extraction's own.

5. Test the choices

Every number above was a choice. Changing each by a reasonable amount shows which ones matter:

def total_ha(threshold=0.04, window=41, radius=15):
    d = ndi.uniform_filter(is_dot[lab].astype(float), size=window)
    yy, xx = np.ogrid[-radius:radius + 1, -radius:radius + 1]
    m = ndi.binary_fill_holes(ndi.binary_opening(d > threshold, structure=xx**2 + yy**2 <= radius**2))
    Lm, km = ndi.label(m)
    m = np.r_[False, ndi.maximum(ndi.distance_transform_edt(m), Lm, range(1, km + 1)) >= 35][Lm]
    return to_polygons(m, "009-NE").area.sum() / 1e4

for kw in ({}, {"threshold": 0.03}, {"threshold": 0.05}, {"window": 31}, {"window": 61}, {"radius": 10}, {"radius": 20}):
    print(f"{str(kw or 'as chosen'):22} {total_ha(**kw):6.1f} ha")
as chosen               124.7 ha
{'threshold': 0.03}     153.3 ha
{'threshold': 0.05}      89.9 ha
{'window': 31}          105.6 ha
{'window': 61}          145.8 ha
{'radius': 10}          134.5 ha
{'radius': 20}          113.6 ha

The threshold matters most: 0.03 to 0.05 takes the total from 153 ha down to 90. The window moves it between 106 and 146 ha, the opening radius between 114 and 135, because both decide how far the edge of a wood extends past its outermost trees. Quote woodland area from a digitised old map with that range β€” about 90 to 150 ha here β€” not as a single number.

Woodland from the tree symbols of two sheets

The data used on this page

Ordnance Survey six-inch sheets Rutland 009-NW and 009-NE (1884–1891) from Wikimedia Commons (public domain). 009-NE is georeferenced from 009-NW's seven control points and the sheet layout, as in the mosaic guide.

from pathlib import Path

import geopandas as gpd
import numpy as np
import requests
from PIL import Image
from rasterio import features
from scipy import ndimage as ndi
from shapely import affinity
from shapely.geometry import 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)

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),
]
SHEET_OFFSET = {"009-NW": 0, "009-NE": 1}
yy, xx = np.ogrid[-15:16, -15:16]
DISK15 = xx**2 + yy**2 <= 15**2

def sheet_affine(name):
    """Pixel -> British National Grid for a sheet, from 009-NW's fit and its place in the layout."""
    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 = Image.open("data/os_009-NW.jpg").size
    w, h = Image.open(f"data/os_{name}.jpg").size
    sx, sy, off = w0 / w, h0 / h, SHEET_OFFSET[name] * w0
    # e = A0 + A1 * (off + c * sx) + A2 * (r * sy), likewise n
    return [A[1, 0] * sx, A[2, 0] * sy, A[1, 1] * sx, A[2, 1] * sy,
            A[0, 0] + A[1, 0] * off, A[0, 1] + A[1, 1] * off]

def to_polygons(mask, name):
    polys = [affinity.affine_transform(shape(p), sheet_affine(name))
             for p, _ in features.shapes(mask.astype(np.uint8), mask=mask)]
    g = gpd.GeoDataFrame(geometry=polys, crs=27700)
    return g[g.area > 2e4].reset_index(drop=True)                  # 2 ha and up

print({p.name: p.stat().st_size for p in Path("data").glob("os_*.jpg")})
{'os_009-NE.jpg': 3057362, 'os_009-NW.jpg': 2653911}

Example 1: what thresholding the ink alone gives you

For comparison, the naive approach: dark pixels, opened to remove thin lines, kept if large:

naive = ndi.binary_opening(im < 128, structure=np.ones((5, 5)))
nl, nk = ndi.label(naive)
big = np.r_[False, ndi.sum(naive, nl, range(1, nk + 1)) > 2000][nl]
print(f"{nk:,} solid blobs after opening; {ndi.label(big)[1]} over 2,000 px; "
      f"overlap with the woodland mask: {(big & mask).sum() / max(big.sum(), 1):.0%} of their pixels")
4,902 solid blobs after opening; 23 over 2,000 px; overlap with the woodland mask: 0% of their pixels

Solid ink is buildings, heavy lettering and the black of a railway cutting β€” almost none of it is woodland, because woods on these sheets are drawn light, not dark.

Example 2: export for QGIS and check by eye

Automated digitising should end with a look. A GeoPackage opens in QGIS over the warped sheet, where every edge can be checked against the scan:

woods["ha"] = (woods.area / 1e4).round(2)
woods["source"] = "OS six-inch Rutland 009-NE, 1884-1891, stipple density > 0.04"
woods.to_file("data/woods_1880s.gpkg", layer="woods")
print(gpd.read_file("data/woods_1880s.gpkg", layer="woods")[["ha", "source"]].head(3).to_string())
      ha                                                         source
0   2.72  OS six-inch Rutland 009-NE, 1884-1891, stipple density > 0.04
1   9.10  OS six-inch Rutland 009-NE, 1884-1891, stipple density > 0.04
2  69.80  OS six-inch Rutland 009-NE, 1884-1891, stipple density > 0.04

Recording the method in the attribute table is what makes the polygons reusable: anyone opening the file later knows they were extracted, not traced, and from which sheet.

Map symbols are a code

Map symbols are a code, and digitising is decoding it. On a coloured modern map the code is mostly colour; on the black-and-white Ordnance Survey sheets of the 1880s it is shape, size and pattern. Buildings are solid, roads are pairs of lines, field boundaries single lines, hedgerow trees small circles at intervals, and woods a field of tree symbols. Thresholding reads only "is it ink", which is the one thing all of those share.

How features are drawn on the 1880s six-inch sheets and what separates each from the rest. the black-and-white symbol codefeaturedrawn asseparated bybuildingsolid blackarea and solidityroadtwo parallel linesline pairshedge, fencesingle linethin, longhedgerow treesmall circlespaced along lineswoodfield of tree dotsdot densityletteringblack glyphssize, alignmentThresholding sees only the middle column’s common factor: ink spatialworkflow.io
Every feature is ink; only shape and pattern tell them apart.

Texture measures work because they read the pattern instead. The connected-component sizes separate marks from lines; the window turns isolated marks into a property of an area; the opening and the thickness test encode what a wood is on the ground β€” wider than a lane, wider than a hedge. Each step encodes an assumption about the cartography, and each assumption is a number that step 5 can vary.

The result is not a tracing. Its edges follow the outermost tree symbols, smoothed by the window, rather than the boundary line the surveyor drew; small woods below the thickness threshold are missed; and a field of dots that is not a wood β€” a dense orchard, a parkland of scattered trees β€” would be found as one. That is why the method should be quoted with its range and checked by eye, and why, for a small area, tracing by hand is still the better tool.

Legends, scan resolution and lettering

Total woodland extracted from sheet 009-NE under different parameter choices, in hectares. one sheet, seven reasonable answersthreshold 0.03153.3 hawindow 61 px145.8 haradius 10 px134.5 haas chosen124.7 haradius 20 px113.6 hawindow 31 px105.6 hathreshold 0.0589.9 ha050 ha100 ha150 haQuote the range, not the middle spatialworkflow.io
The range is the honest answer.
  • Read the legend first. The symbol code differs between series and editions; woods on later sheets carry tree symbols of different sizes.
  • Scan resolution sets the pixel sizes. The 2–40 px dot range and 41 px window suit these scans at about 0.9 m per pixel; scale them for others.
  • Parkland and orchards have tree symbols too; expect them to appear as woods.
  • Lettering inside woods ("Barnsdale Wood") is a large blob, not a dot; filling holes covers it.
  • Georeferencing error adds to extraction error. About 20 m here, before any edge effect.
  • Record the method with the polygons.

FAQ

How do I extract features from a scanned map automatically?

Find what is distinctive about the symbol β€” here, woodland stipple made of many small isolated dots β€” measure it with connected-component sizes and a moving window, threshold, clean by shape, and vectorise with rasterio.features.shapes.

Why does thresholding a scanned map not work?

On a black-and-white map every feature is ink. Thresholding separates ink from paper, not woods from roads or buildings from lettering.

How accurate is automatically digitised woodland?

It depends on the choices; on sheet 009-NE, reasonable changes to the threshold, window and opening moved the total between 90 and 153 ha around a central 125 ha. Quote the range and check the outlines by eye.

Should I digitise old maps by hand instead?

For a few features, yes β€” a careful tracing follows the surveyor's boundary line. For a whole sheet or a series, a texture method with a stated range is faster and repeatable.