Old Map Is Offset from Modern Data

On this page

Problem statement

The old map is georeferenced and the modern data is on top, and they do not quite agree: the railway on the old sheet runs a few metres beside today's, a road sits off by twenty metres, or everything is shifted the same way by a hundred. Some of that is the old survey, some is your georeferencing, some is a datum or CRS slip, and some is real change on the ground β€” and the remedy for each is different. Moving the old map until the lines meet would hide all four.

This page measures the offset along a feature that has not moved since the 1880s β€” the Midland Railway through Oakham, opened in 1848 β€” on the Ordnance Survey six-inch sheet of 1884–1891, and shows how the pattern of the offsets, not their size, tells the causes apart.

Quick answer

Measure the offset of the old map's linework from the modern line at many places along a feature that has not changed, then look at the pattern. A systematic shift is a datum or georeferencing problem; small scattered offsets are the map's own error; isolated large ones are change or mismatched features:

true_shift = np.array(from_4277.transform(*to_ll.transform(486065.6, 308906.1))) - (486065.6, 308906.1)
for label, path in (("control points labelled WGS 84 (right)", "data/oakham_right.tif"),
                    ("same points labelled OSGB 1936", "data/oakham_mislabelled.tif")):
    r = rail_offsets(path)
    q25, med, q75 = np.percentile(r["offset"], [25, 50, 75])
    predicted = r[["nx", "ny"]].to_numpy() @ true_shift            # the mislabel's shift, seen across the line
    match = (np.abs(r["offset"] - predicted) <= 15).mean()
    print(f"{label:40} median {med:+5.1f} m, middle half {q25:+6.1f} to {q75:+6.1f} m; "
          f"within 15 m of the mislabel's predicted offset: {match:.0%}")
control points labelled WGS 84 (right)   median  -4.5 m, middle half  -10.0 to   +6.0 m; within 15 m of the mislabel's predicted offset: 0%
same points labelled OSGB 1936           median  -2.5 m, middle half  -61.8 to  +60.2 m; within 15 m of the mislabel's predicted offset: 70%
Two copies of today's railway through the Oakham sheet with a tick every 150 m showing how far the 1880s railway ink lies across the line: with the control points labelled correctly the ticks are short, the middle half within 10 m either way; with the same points mislabelled as OSGB 1936 they are long in both directions, the middle half spanning 120 m. Two copies of today's railway through the Oakham sheet with a tick every 150 m showing how far the 1880s railway ink lies across the line: with the control points labelled correctly the ticks are short, the middle half within 10 m either way; with the same points mislabelled as OSGB 1936 they are long in both directions, the middle half spanning 120 m.
Offsets measured across the 1848 railway, drawn four times longer than they are. OS six-inch sheet 009-NW, public domain; Β© OpenStreetMap contributors.

An unchanged feature, offsets across lines, the pattern

Diagnosing an offset between an old map and modern data along an unchanged railway. from an offset to its causeunchangedfeaturerailway, 1848offsets acrossit44 places, 150 mapartread thespread16 m vs 120 mcheck outlierssidings, parallelsfix or reportcause, or accuracyNever nudge the old map to make lines meet spatialworkflow.io
From an offset to its cause in five steps.

1. Choose a feature that has not changed

The comparison only works on something that is the same on the ground at both dates. Railways are the best candidate on most Victorian maps: engineered, surveyed carefully, and rarely moved. Roads are worse β€” widened, straightened, bypassed β€” and field boundaries worse still:

rail = gpd.read_file("/vsizip/data/rutland.shp.zip/gis_osm_railways_free_1.shp", bbox=BBOX).to_crs(27700)
rail = rail[rail.fclass == "rail"]
print(len(rail), "OSM railway ways,", round(rail.length.sum() / 1000, 1), "km in the sheet's area")
5 OSM railway ways, 11.9 km in the sheet's area

2. Measure the offset across the line, not along it

Along a straight railway, sliding the old map forwards or backwards changes nothing, so an offset along the line cannot be measured. Across it can: for each 150 m piece of today's line, slide it sideways over the old map and find where it covers the most ink:

r = rail_offsets("data/oakham_right.tif")
print(r[["x", "y", "offset", "ink_share"]].head(6).round(1).to_string(index=False))
       x        y  offset  ink_share
486921.2 306942.9      28        0.9
486982.8 306806.1      27        0.9
487044.3 306669.3       6        1.0
487105.8 306532.5       6        1.0
487167.3 306395.7       6        0.9
487191.5 306330.7     -37        1.0

ink_share is the fraction of the shifted line lying on ink at the best offset; low values mean the old map has no clear railway there β€” a station yard, a bridge, a label.

3. Read the pattern

The quick answer's two runs show two patterns. With the control points labelled correctly, the offsets centre within a few metres of zero and the middle half spans about sixteen metres β€” the six-inch survey's own error plus the georeferencing's, the same size as the leave-one-out error.

With the datum mislabelled, the spread explodes to more than a hundred metres, and it splits in two. Seven in ten places measure, to within 15 m, what a 115 m shift of the whole map predicts across the line at that point β€” the matcher found the railway, displaced. Most of the rest lock onto some other line within the search range, because the real railway is now further away than a parallel road or hedge. A spread several times the map's known accuracy is the signal: something has been applied to the whole map β€” a datum, a wrong CRS, a misread grid β€” and the cause should be found rather than the offsets interpreted.

Width of the middle half of the offsets measured across the railway on the Oakham sheet, with the control points labelled correctly and mislabelled as OSGB 1936. middle half of the offsets across the railwaylabelled WGS 84 (right)βˆ’10.0 to +6.0 mlabelled OSGB 1936βˆ’61.8 to +60.2 m070 m140 mmedians: βˆ’4.5 and βˆ’2.5 mThe median hides a datum error; the spread shows it spatialworkflow.io
Both medians are near zero; only the spread shows that one map has moved.

4. Look at the outliers

A handful of places sit far from the rest:

out = r[np.abs(r["offset"] - r["offset"].median()) > 25]
print(out[["x", "y", "offset", "ink_share"]].round(1).to_string(index=False))
       x        y  offset  ink_share
486921.2 306942.9      28        0.9
486982.8 306806.1      27        0.9
487191.5 306330.7     -37        1.0
486945.5 306878.0     -32        0.9
485726.6 308699.2      35        0.8
485906.8 308289.9     110        0.7

These are not evidence that the map is wrong there. Near Oakham station the 1880s sheet draws sidings and goods lines beside the main line, and the best match locks onto those; elsewhere a parallel road or hedge offers a stronger ink line within the search range. An isolated large offset needs a look at the scan before it is called anything.

5. Decide what to do

A systematic shift means fix the cause β€” the datum, the CRS, the control points β€” and re-warp; never nudge the map by hand to fit. Scattered offsets mean quote them as the map's accuracy and do not interpret differences smaller than about twice their spread. Isolated large offsets mean look, and if the ground changed, report it as change.

Railways and roads as evidence

The data used on this page

The Ordnance Survey six-inch sheet Rutland 009-NW (1884–1891) from Wikimedia Commons (public domain), with seven control points matched to OpenStreetMap (Geofabrik, 1 January 2026; Β© OpenStreetMap contributors, ODbL) in the control-point guide.

from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
import requests
from osgeo import gdal
from pyproj import Transformer
from rasterio import features
from rasterio.windows import from_bounds
from shapely import affinity
from shapely.geometry import LineString, box

gdal.UseExceptions()
Path("data").mkdir(exist_ok=True)
UA = {"User-Agent": "spatialworkflow.io guide (https://www.spatialworkflow.io)"}
downloads = {
    "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=300).content)
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),
]
BBOX = (-0.7722, 52.6454, -0.6986, 52.6757)
to_ll = Transformer.from_crs(27700, 4326, always_xy=True)
from_4277 = Transformer.from_crs(4277, 27700, always_xy=True)          # OSGB 1936 longitude/latitude
for path, srs in (("data/oakham_right.tif", "EPSG:4326"), ("data/oakham_mislabelled.tif", "EPSG:4277")):
    if not Path(path).exists():
        v = gdal.Translate("", "data/os_009-NW.jpg", format="VRT", bandList=[1], outputSRS=srs,
                           GCPs=[gdal.GCP(*to_ll.transform(e, n), 0, px, py) for _, px, py, e, n in GCPS])
        gdal.Warp(path, v, dstSRS="EPSG:27700", polynomialOrder=1, xRes=1, yRes=1, resampleAlg="cubic",
                  dstAlpha=True, creationOptions=["COMPRESS=DEFLATE", "TILED=YES"])

def rail_offsets(path, step=150, half=75, search=150):
    """Cross-track offset (m) of the old map's ink from OSM's railway, every `step` m along it."""
    rail = gpd.read_file("/vsizip/data/rutland.shp.zip/gis_osm_railways_free_1.shp", bbox=BBOX).to_crs(27700)
    rows = []
    with rasterio.open(path) as src:
        lines = rail[rail.fclass == "rail"].union_all().intersection(box(*src.bounds).buffer(-150))
        for L in getattr(lines, "geoms", [lines]):
            for d in np.arange(half, L.length - half, step):
                p, q = L.interpolate(d), L.interpolate(d + 1)
                t = np.array([q.x - p.x, q.y - p.y]) / np.hypot(q.x - p.x, q.y - p.y)
                nrm = np.array([-t[1], t[0]])
                seg = LineString([L.interpolate(d - half).coords[0], L.interpolate(d + half).coords[0]])
                x0, y0, x1, y1 = seg.buffer(search + 5).bounds
                win = from_bounds(x0, y0, x1, y1, src.transform).round_offsets().round_lengths()
                g, a = src.read(1, window=win), src.read(2, window=win)
                ink = (g < 128) & (a > 0)
                wt = src.window_transform(win)
                share = [ink[features.rasterize([(affinity.translate(seg, *(nrm * s)).buffer(1.5), 1)],
                                                 out_shape=ink.shape, transform=wt, dtype="uint8").astype(bool)].mean()
                         for s in range(-search, search + 1)]
                rows.append({"x": p.x, "y": p.y, "nx": nrm[0], "ny": nrm[1],
                             "offset": int(np.argmax(share)) - search, "ink_share": max(share)})
    return pd.DataFrame(rows)

print({p.name: round(p.stat().st_size / 1e6, 1) for p in Path("data").glob("oakham_*.tif")})
{'oakham_mislabelled.tif': 4.5, 'oakham_right.tif': 4.5}

Example 1: the size of the shift, from the offsets alone

Each cross-track offset is the shift vector projected onto that place's normal. A least-squares fit of one vector to all of them, refitted twice without the places more than 25 m from the model (the sidings), estimates the shift:

for label, path in (("right", "data/oakham_right.tif"), ("mislabelled", "data/oakham_mislabelled.tif")):
    r = rail_offsets(path)
    N, o = r[["nx", "ny"]].to_numpy(), r["offset"].to_numpy(float)
    keep = np.ones(len(r), bool)
    for _ in range(3):
        shift, *_ = np.linalg.lstsq(N[keep], o[keep], rcond=None)
        keep = np.abs(o - N @ shift) < 25
    print(f"{label:12} fitted shift {np.hypot(*shift):5.1f} m towards bearing {np.degrees(np.arctan2(*shift)) % 360:5.1f} deg "
          f"from {keep.sum()} places")

true = np.array(from_4277.transform(*to_ll.transform(486065.6, 308906.1))) - (486065.6, 308906.1)
print(f"the mislabel's true shift: {np.hypot(*true):.0f} m towards bearing {np.degrees(np.arctan2(*true)) % 360:.0f} deg")
right        fitted shift   9.7 m towards bearing 110.3 deg from 40 places
mislabelled  fitted shift  74.2 m towards bearing 270.1 deg from 35 places
the mislabel's true shift: 115 m towards bearing 290 deg

The direction comes out right β€” westward β€” but the size is underestimated, because the railway's two branches on this sheet run within about sixty degrees of each other and the shift is close to parallel to one of them. A railway gives a good diagnosis and a rough measurement; for the exact shift, use features running in several directions, or fix the cause and re-warp.

Example 2: roads, for comparison

The same measurement along today's main roads shows why roads make poor reference features:

roads = gpd.read_file("/vsizip/data/rutland.shp.zip/gis_osm_roads_free_1.shp", bbox=BBOX).to_crs(27700)
main = roads[roads.fclass.isin(["trunk", "primary", "secondary"])]
print(main.assign(m=main.length).groupby("name")["m"].sum().sort_values(ascending=False).head(5).round().to_string())
name
Stamford Road      4696.0
Uppingham Road     2738.0
Burley Park Way    2131.0
Burley Road         950.0
Mount Pleasant      746.0

The list includes Burley Park Way, the A606, which crosses fields where the 1880s sheet has no road at all (see ground control points explained). Any offset measured along it would be an artefact of matching to whatever the old map has nearby. A reference feature has to have existed, unchanged, at both dates.

What an offset can mean

An offset between an old map and modern data is a sum of four things: the old survey's error, the georeferencing's error, any error applied to the whole map (a datum, a CRS, a grid misread), and real change. The first two are local and scattered; the third is the same everywhere; the fourth is wherever the ground changed. Measured at one place, they are indistinguishable. Measured at many places along something that did not change, they separate by their pattern.

Four causes of an offset between an old map and modern data, and how each shows along a reference line. four causes, four patternscausepattern along the lineremedysurvey + georeferencingscatter about 0, ~16 mreport as accuracydatum or CRS slipspread far beyond thatfix the cause, re-warpmismatched featureisolated large offsetslook at the scanreal changelocal, beyond the scatterreport as changeMeasured at one place, all four look the same spatialworkflow.io
The pattern, not the size, names the cause.

That is why the measurement uses a long, unchanged feature rather than a few points. The distribution of offsets carries the diagnosis: its centre says whether there is a systematic shift, its spread says how accurate the map is, and its outliers say where to look. On the Oakham sheet the correct georeferencing centres the railway on today's within a few metres; the same control points with a datum mislabel scatter the offsets over more than a hundred metres, seven in ten of them measuring the 115 m shift.

The remedy follows from the diagnosis. A shift is corrected at its cause. Scatter is reported, not removed β€” any attempt to remove it by bending the map is fitting noise. And only offsets clearly outside the scatter, at places where the scan confirms a difference, are evidence of change.

Sidings, parallel lines and one direction

Checks before interpreting an offset between an old map and modern data. before blaming the mapReference existed at both datesnot a bypassMany places, not one44 along the lineAcross the line onlyalong is undefinedSpread against known accuracyleave-one-out 17.5 mOutliers seen on the scansidings, roadsEvery check here uses data you already have spatialworkflow.io
Five checks before blaming the map.
  • Measure across a line, not along it; along-track offsets on a straight feature are undefined.
  • Railways first, then old roads in villages; never modern roads or bypasses.
  • Sidings and parallel lines attract the match; drop outliers or check them on the scan.
  • One shift vector needs lines in more than one direction to be well determined.
  • Never nudge the old map by hand to make lines meet; fix the cause or report the scatter.
  • Change is what is left after the systematic and scattered parts are accounted for.

FAQ

Why is my old map offset from modern data?

Some combination of the old survey's error, georeferencing error, a datum or CRS mistake, and real change on the ground. Measure the offset at many places along an unchanged feature to tell them apart.

How do I tell a datum error from map error?

A datum error shifts every place in the same direction; map error scatters around zero. The median of many cross-track offsets shows the first, their spread the second.

Should I shift the old map to line up with modern data?

Only if the offset is systematic and you have found and fixed its cause. Shifting a map to remove scattered offsets hides its real accuracy.

What is a good feature for checking alignment?

One that existed at both dates and has not moved: a railway, a canal, a church, an old village street. Not a modern road or a field boundary.