Warped Map Is Folded or Stretched

On this page

Problem statement

The georeferenced map is in the right place, but part of it is wrong in a way no offset explains: a corner smeared out to a long streak, a field drawn twice, roads that run backwards for a stretch, a margin swollen to several times its size. GDAL reported no error. What happened is that the transformation folded β€” somewhere on the sheet it maps two different scan pixels to the same place, or turns part of the map over β€” or stretched one region far beyond the rest.

This page produces both on real maps, measures how much of the sheet is affected with one test that works for any transformation, and shows the fix for each cause.

Quick answer

A transformation folds wherever its Jacobian determinant changes sign. Push a fine grid of scan pixels through it, measure the signed area of every cell, and count the cells whose sign differs from the rest:

for label, (points, scan, size, opts) in CASES.items():
    folded, stretch = fold_check(points, scan, size, opts)
    print(f"{label:44} folded {folded:6.1%}   99th-percentile cell area {stretch:5.2f}x the median")
OS sheet, affine, 7 points                   folded   0.0%   99th-percentile cell area  1.00x the median
OS sheet, thin-plate spline, 7 points        folded   0.0%   99th-percentile cell area  1.03x the median
OS sheet, spline + one conflicting click     folded   5.3%   99th-percentile cell area  1.60x the median
Speed page, polynomial 2, 33 villages        folded   0.6%   99th-percentile cell area  2.21x the median
Speed page, polynomial 3, 33 villages        folded  38.0%   99th-percentile cell area  5.14x the median

Zero folded cells and a stretch near 1 is a healthy warp. Two things produced the bad ones here: a thin-plate spline given one conflicting control point, and a high-order polynomial asked to cover a whole atlas page from points in its middle.

Two deformed grids. Left: the Oakham sheet's pixel grid through a thin-plate spline with one conflicting control point, a lobe of cells near the two points turned over and shown in red. Right: Speed's whole atlas page through a third-order polynomial, a large red region folded over the rest. Two deformed grids. Left: the Oakham sheet's pixel grid through a thin-plate spline with one conflicting control point, a lobe of cells near the two points turned over and shown in red. Right: Speed's whole atlas page through a third-order polynomial, a large red region folded over the rest.
Red cells are turned over by the transformation. Left: 5.4% of the OS sheet; right: 37.7% of Speed's page, all outside the villages.

Measure folding, find conflicting points, lower the order

Finding and fixing folds and stretch in a georeferencing warp. measure folds, find the cause, re-checkpixel gridthrough thetransformsigned cellareasflip = foldconflictingpoints?50 px, 192 morder and arealower, cutre-check0 folded cellsNo error message is raised at any step without the check spatialworkflow.io
The test that turns an invisible fold into a number.

1. Measure folding instead of looking for it

A fold can hide in a corner nobody looks at. The signed area of each cell of a pixel grid, after transformation, is positive for the map the right way up and negative where it has been turned over:

P, folded = pixel_grid(transformer(OS, "data/os_009-NW.jpg", ["METHOD=GCP_TPS"]), (5343, 3573), 90)
print(P.shape[:2], "grid nodes;", int(folded.sum()), "folded cells with the seven good points")
(60, 90) grid nodes; 0 folded cells with the seven good points

2. Look for conflicting control points

A thin-plate spline passes through every control point exactly. Two points a few pixels apart on the scan with world coordinates far apart β€” a double click on the same church with the second click's coordinates mistyped β€” force it to stretch a small region to cover a large distance, and it folds to do so:

def conflicts(points, px_radius=200, ratio=3):
    """Pairs of points whose world distance is far from what the median scale predicts."""
    P = np.array([[c, r] for _, c, r, _, _ in points], float)
    W = np.array([[e, n] for *_, e, n in points], float)
    d_px = np.hypot(*(P[:, None] - P[None]).transpose(2, 0, 1))
    d_w = np.hypot(*(W[:, None] - W[None]).transpose(2, 0, 1))
    scale = np.median(d_w[d_px > 0] / d_px[d_px > 0])
    out = []
    for i, j in zip(*np.triu_indices(len(points), 1)):
        if d_px[i, j] < px_radius and d_w[i, j] / max(d_px[i, j] * scale, 1e-9) > ratio:
            out.append((points[i][0], points[j][0], round(d_px[i, j]), round(d_w[i, j])))
    return out

print(conflicts(OS + [CONFLICT]))
[('oakham_church', 'oakham_church_again', 50, 192)]

Fifty pixels on the scan is about 45 m on the ground; the two points claim 192 m. Remove one and the fold disappears.

3. Lower the order, or limit the area

A polynomial of order 3 has 20 coefficients and bends freely everywhere. Fitted to villages in the middle of Speed's county map, it does reasonably inside the villages and wildly beyond them:

hull = Polygon(np.array([[c, r] for _, c, r, _, _ in SPEED], float)[ConvexHull([[c, r] for _, c, r, _, _ in SPEED]).vertices])
for order in (1, 2, 3):
    t = transformer(SPEED, "data/speed_rutland.jpg", ["METHOD=GCP_POLYNOMIAL", f"MAX_GCP_ORDER={order}"])
    P, folded = pixel_grid(t, (12232, 8343), 60)
    gx, gy = np.linspace(0, 12232, 60), np.linspace(0, 8343, folded.shape[0] + 1)
    xs, ys = np.meshgrid((gx[:-1] + gx[1:]) / 2, (gy[:-1] + gy[1:]) / 2)          # cell centres
    inside = np.array([hull.contains(Point(x, y)) for x, y in zip(xs.ravel(), ys.ravel())]).reshape(folded.shape)
    print(f"order {order}: folded on the whole page {folded.mean():5.1%}, inside the villages' hull {folded[inside].mean():5.1%}")
order 1: folded on the whole page  0.0%, inside the villages' hull  0.0%
order 2: folded on the whole page  0.6%, inside the villages' hull  0.0%
order 3: folded on the whole page 37.7%, inside the villages' hull  0.0%

Two fixes, both needed: a lower order where the data cannot support a higher one, and a cutline so the warp covers only the part of the sheet the points describe (see the early county map guide).

4. Check stretch as well as folds

A transformation can stay unfolded and still distort a region badly. The ratio of each cell's area to the median cell shows it:

for opts, label in ((["MAX_GCP_ORDER=2"], "polynomial 2, all 7 points"), (["MAX_GCP_ORDER=2"], "polynomial 2, 6 points")):
    pts = OS if "7" in label else OS[1:]
    print(label, "-> 99th percentile stretch", round(fold_check(pts, "data/os_009-NW.jpg", (5343, 3573), opts)[1], 2))
polynomial 2, all 7 points -> 99th percentile stretch 1.07
polynomial 2, 6 points -> 99th percentile stretch 1.8

Dropping the one point near the top edge leaves a polynomial with exactly as many coefficients as constraints; the corners of the sheet stretch accordingly. A stretch well above 1.1 on a sheet that should be uniform is a warning even without a fold.

5. Re-warp and re-check

After removing the conflicting point, lowering the order or adding a cutline, run fold_check again. A clean warp has no folded cells and a stretch close to 1 over the area you keep.

What a fold does, and catching it in a pipeline

The data used on this page

The Ordnance Survey six-inch sheet Rutland 009-NW (1884–1891) and John Speed's Rutlandshire (1676 edition of the 1611 map, scanned by the David Rumsey Map Collection, Stanford Libraries), both from Wikimedia Commons (public domain); OpenStreetMap from Geofabrik (1 January 2026; Β© OpenStreetMap contributors, ODbL). Control points as in the control-point guide and the early county map guide.

from pathlib import Path

import geopandas as gpd
import numpy as np
import requests
from osgeo import gdal
from scipy.spatial import ConvexHull
from shapely.geometry import Point, Polygon

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/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)

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),
]
CONFLICT = ("oakham_church_again", 3181, 517, 485915.6, 309026.1)    # a second click, mistyped coordinates
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")
SPEED = [(k, c, r, places.loc[k].geometry.x, places.loc[k].geometry.y) for k, (c, r) in SPEED_PX.items()]

def transformer(points, scan, opts):
    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])
    t = gdal.Transformer(v, None, opts)
    t.keep = v                                 # the transformer needs its dataset alive
    return t

def pixel_grid(t, size, n):
    """World positions of an n-wide grid of scan pixels, and which cells are turned over."""
    w, h = size
    xs, ys = np.meshgrid(np.linspace(0, w, n), np.linspace(0, h, max(2, int(n * h / w))))
    P = np.array(t.TransformPoints(0, [(x, y, 0) for x, y in zip(xs.ravel(), ys.ravel())])[0])[:, :2]
    P = P.reshape(xs.shape + (2,))
    dx, dy = P[:-1, 1:] - P[:-1, :-1], P[1:, :-1] - P[:-1, :-1]
    det = dx[..., 0] * dy[..., 1] - dx[..., 1] * dy[..., 0]
    return P, np.sign(det) != np.sign(np.median(det))

def fold_check(points, scan, size, opts, n=120):
    t = transformer(points, scan, opts)
    P, folded = pixel_grid(t, size, n)
    dx, dy = P[:-1, 1:] - P[:-1, :-1], P[1:, :-1] - P[:-1, :-1]
    area = np.abs(dx[..., 0] * dy[..., 1] - dx[..., 1] * dy[..., 0])
    return float(folded.mean()), float(np.percentile(area / np.median(area), 99))

CASES = {
    "OS sheet, affine, 7 points":                 (OS, "data/os_009-NW.jpg", (5343, 3573), ["MAX_GCP_ORDER=1"]),
    "OS sheet, thin-plate spline, 7 points":      (OS, "data/os_009-NW.jpg", (5343, 3573), ["METHOD=GCP_TPS"]),
    "OS sheet, spline + one conflicting click":   (OS + [CONFLICT], "data/os_009-NW.jpg", (5343, 3573), ["METHOD=GCP_TPS"]),
    "Speed page, polynomial 2, 33 villages":      (SPEED, "data/speed_rutland.jpg", (12232, 8343), ["MAX_GCP_ORDER=2"]),
    "Speed page, polynomial 3, 33 villages":      (SPEED, "data/speed_rutland.jpg", (12232, 8343), ["MAX_GCP_ORDER=3"]),
}
print(len(OS), "OS points,", len(SPEED), "Speed points")
7 OS points, 33 Speed points

Example 1: what the fold does to the image

Warp the OS sheet with and without the conflicting point and compare the result around Oakham church:

for label, pts in (("seven good points", OS), ("plus the conflicting click", OS + [CONFLICT])):
    v = gdal.Translate("", "data/os_009-NW.jpg", format="VRT", bandList=[1], outputSRS="EPSG:27700",
                       GCPs=[gdal.GCP(e, n, 0, c, r) for _, c, r, e, n in pts])
    w = gdal.Warp("", v, format="MEM", dstSRS="EPSG:27700", tps=True, xRes=2, yRes=2,
                  outputBounds=(485500, 308500, 486600, 309300), dstAlpha=True)
    a = w.ReadAsArray()
    print(f"{label:28} ink share in the window {(a[0][a[1] > 0] < 128).mean():.1%}")
seven good points            ink share in the window 25.0%
plus the conflicting click   ink share in the window 29.3%

The folded warp draws parts of the town twice and stretches others, which changes even a crude statistic like the share of ink in the window. Anything measured from a folded warp is wrong in that region.

Example 2: catching it in a pipeline

The check is cheap enough to run on every georeferencing:

def assert_no_folds(points, scan, size, opts, max_stretch=1.5):
    folded, stretch = fold_check(points, scan, size, opts, n=80)
    if folded > 0 or stretch > max_stretch:
        raise ValueError(f"warp folds {folded:.1%} of the sheet, stretch {stretch:.2f}x")
    return "ok"

for label, args in CASES.items():
    try:
        print(f"{label:44} {assert_no_folds(*args)}")
    except ValueError as err:
        print(f"{label:44} REJECTED: {err}")
OS sheet, affine, 7 points                   ok
OS sheet, thin-plate spline, 7 points        ok
OS sheet, spline + one conflicting click     REJECTED: warp folds 5.5% of the sheet, stretch 1.61x
Speed page, polynomial 2, 33 villages        REJECTED: warp folds 0.6% of the sheet, stretch 2.20x
Speed page, polynomial 3, 33 villages        REJECTED: warp folds 37.8% of the sheet, stretch 5.10x

Speed's second-order polynomial is rejected on the whole page but folds nothing inside the villages' hull: run the check over the area you will keep, after the cutline, not over margins you are about to throw away.

Folds come from the transformation

A georeferencing transformation is a function from the scan's plane to the map's plane. For the warp to make sense it has to be one-to-one over the area being warped: every scan pixel goes to a distinct place, and the map keeps its orientation. Affine transformations are always one-to-one. Polynomials and splines are not guaranteed to be β€” they bend, and if they bend far enough, they fold.

Share of the sheet folded by five georeferencing transformations. how much of the sheet is turned overOS, affine0%OS, spline0%OS, spline + bad click5.3%Speed page, poly 20.6%, 0% in the hullSpeed page, poly 338%, 0% in the hull010%20%30%40%Folds live where the points are not, or where they disagree spatialworkflow.io
Folds appear where points disagree or are absent.

Two situations push them that far. A spline is forced through every point, so two nearby points with incompatible coordinates leave it no smooth way through: it has to cover a large distance in a small space, and overshoots into a fold. A polynomial of high order is only constrained where there are points; beyond them, its cubic terms grow without limit and turn the sheet over. Both are the transformation doing exactly what it was asked β€” the request was impossible or under-specified.

The signed-area test catches both, because a fold is precisely a place where the orientation flips. It needs no knowledge of what the map should look like, which makes it suitable for automated checks; the stretch percentile adds a warning for distortion short of folding.

Affine never folds, and high orders extrapolate

Causes of folded or stretched warps and the fix for each. what folds a warp, and what unfolds itcausefixtwo close points disagreefind pairs off the scale, drop oneorder too high for the pointslower the orderwarp beyond the pointscutline to the hull or map areatoo few points for the orderadd edge points or go affineAffine never folds; every fold is a flexible method overreaching spatialworkflow.io
Every fold is a flexible method asked for too much.
  • Affine never folds; if an affine warp looks folded, the scan or the CRS is wrong.
  • One conflicting point is enough to fold a spline. Check close pairs of points against the overall scale.
  • High-order polynomials extrapolate badly. Order 3 needs at least 10 points, spread to the edges.
  • Cut the warp to the area the points cover, especially on atlas pages with decorations and margins.
  • Stretch without folds is a softer warning; compare with what the map should look like.
  • GDAL's own error threshold for inverting transforms does not detect folds; test explicitly.

FAQ

Why is my georeferenced map folded or smeared?

The transformation is not one-to-one over the sheet: usually a thin-plate spline with two close, conflicting control points, or a high-order polynomial extrapolating beyond its points.

How do I detect a fold in a warped image?

Transform a grid of scan pixels, compute the signed area of each cell, and look for cells whose sign differs from the majority.

How do I fix a folded warp?

Remove or correct the conflicting control point, lower the polynomial order, or restrict the warp to the area covered by the points with a cutline.

Can an affine transformation fold a map?

No. It maps straight lines to straight lines and keeps orientation unless its determinant is negative everywhere, which would mirror the whole sheet rather than fold part of it.