Applying Rubber-Sheeting Patches with GDAL
When a scanned map or an older survey layer sits a few metres off its true position and the offset is not constant, a rubber-sheeting patch built from ground control points corrects it β part of automated patching for minor geometry shifts within collaborative editing workflows.
Concept & Context
Not every misalignment is a coordinate reference system problem. A layer can carry the right EPSG:4326 tag, project cleanly, and still drift because the underlying source was distorted before anyone digitized it β paper stretch on a scanned cadastral sheet, uneven georeferencing of an orthophoto, or accumulated digitizing error that grows toward one edge. These offsets are local and non-uniform: two metres north-east in one corner, half a metre south-west in another. A global reprojection cannot remove them because there is no single formula that describes the distortion.
Rubber-sheeting solves this by treating the layer as an elastic sheet pinned at a set of matched ground control points (GCPs). Each GCP pairs a coordinate in the distorted source with the coordinate where that feature actually belongs, taken from an authoritative reference. GDAL then stretches everything between the pins with a thin-plate spline β the mathematical analogue of bending a thin metal sheet through fixed heights with minimum bending energy. Because rubber-sheeting is a targeted correction rather than a wholesale rewrite, it fits naturally alongside attribute reconciliation for tabular spatial data as a class of small, reviewable patches that teams apply and commit like any other change. This guide keeps the raster and its companion vectors aligned by driving both from one shared GCP table.
Core Rubber-Sheeting Pipeline
- Collect matched control points. Identify at least six unambiguous features that appear in both the distorted layer and the reference β road intersections, building corners, survey monuments. Record the from coordinate (source) and the to coordinate (reference) for each. Distribute them across the whole extent and concentrate a few where the distortion is worst.
- Attach GCPs to the raster. Embed the control points into the raster header with
gdal_translate -gcp. This step is lossless β no pixels are resampled yet; it only records the tie points and the target CRS. - Warp the raster with a thin-plate spline. Run
gdalwarp -tpsto elastically resample the raster so each control point lands on its reference position. Use-r bilinearfor continuous imagery or-r nearfor categorical rasters. - Transform the companion vectors. Feed the identical GCP set through a thin-plate spline in Python (
osgeo.gdal.Transformer) and push every vertex of the vector layer through it, so parcels and roads move with the raster. - Check residuals and commit. Confirm the control-point residuals are within tolerance, sanity-check a few known features, then commit the patched outputs and the GCP manifest so the correction is reproducible and auditable.
Working Implementation
The script below drives the whole patch from a single CSV of control points (pixel,line,x,y for the raster tie points plus src_x,src_y,dst_x,dst_y map pairs for the vectors). It shells out to GDAL for the raster warp and reuses the same map-space GCPs to build a thin-plate spline Transformer for the vector layer, guaranteeing both products share one displacement field.
#!/usr/bin/env python3
"""
Rubber-sheeting patch: warp a raster and its companion vectors from one GCP set.
Usage:
python rubber_sheet.py gcps.csv source.tif source.gpkg patched/
CSV columns (header row required):
pixel,line,map_x,map_y -> raster tie points (image space -> reference map)
src_x,src_y,dst_x,dst_y -> vector control pairs (distorted map -> reference map)
Rows use only the raster columns OR only the vector columns; blanks are ignored.
"""
import csv
import subprocess
import sys
from pathlib import Path
from osgeo import gdal, ogr
gdal.UseExceptions()
TARGET_SRS = "EPSG:4326"
def read_gcps(csv_path: Path):
"""Split the control-point CSV into raster tie points and vector pairs."""
raster_gcps, vector_pairs = [], []
with csv_path.open(newline="") as fh:
for row in csv.DictReader(fh):
if row.get("pixel"):
raster_gcps.append(gdal.GCP(
float(row["map_x"]), float(row["map_y"]), 0.0,
float(row["pixel"]), float(row["line"]),
))
if row.get("src_x"):
vector_pairs.append((
float(row["src_x"]), float(row["src_y"]),
float(row["dst_x"]), float(row["dst_y"]),
))
if len(raster_gcps) < 3 or len(vector_pairs) < 3:
raise ValueError("Need at least 3 raster GCPs and 3 vector pairs for a TPS.")
return raster_gcps, vector_pairs
def warp_raster(src_tif: Path, gcps, out_tif: Path) -> None:
"""Attach GCPs then thin-plate-spline warp the raster onto the reference frame."""
# Step 2: embed GCPs without resampling (lossless header edit).
tagged = gdal.Translate(str(out_tif.with_suffix(".gcp.vrt")), str(src_tif),
outputSRS=TARGET_SRS, GCPs=gcps)
tagged = None # flush the intermediate VRT
# Step 3: elastic warp. -tps selects the thin-plate spline; bilinear for imagery.
subprocess.run([
"gdalwarp", "-overwrite", "-tps", "-r", "bilinear",
"-t_srs", TARGET_SRS, "-co", "COMPRESS=DEFLATE",
str(out_tif.with_suffix(".gcp.vrt")), str(out_tif),
], check=True)
out_tif.with_suffix(".gcp.vrt").unlink(missing_ok=True)
def build_vector_transformer(pairs):
"""Build a TPS transformer from map-space control pairs (distorted -> reference)."""
gcps = [gdal.GCP(dst_x, dst_y, 0.0, src_x, src_y) # 'pixel/line' hold source map coords
for src_x, src_y, dst_x, dst_y in pairs]
ds = gdal.GetDriverByName("MEM").Create("", 1, 1)
ds.SetGCPs(gcps, gdal.osr.SpatialReference().ExportToWkt() or "")
# method=2 forces a thin-plate spline instead of a polynomial fit.
return gdal.Transformer(ds, None, ["METHOD=GCP_TPS"])
def warp_vectors(src_gpkg: Path, tps, out_gpkg: Path) -> None:
"""Push every vertex of every geometry through the shared TPS transform."""
src = ogr.Open(str(src_gpkg))
drv = ogr.GetDriverByName("GPKG")
out_gpkg.unlink(missing_ok=True)
dst = drv.CreateDataSource(str(out_gpkg))
for i in range(src.GetLayerCount()):
layer = src.GetLayer(i)
out_layer = dst.CreateLayer(layer.GetName(), geom_type=layer.GetGeomType())
out_layer.CreateFields(layer.schema)
for feat in layer:
geom = feat.GetGeometryRef().Clone()
for gi in range(geom.GetGeometryCount() or 1):
ring = geom.GetGeometryRef(gi) if geom.GetGeometryCount() else geom
for vi in range(ring.GetPointCount()):
x, y, _ = ring.GetPoint(vi)
ok, (nx, ny, _) = tps.TransformPoint(0, x, y) # 0 = forward
if not ok:
raise RuntimeError(f"TPS failed at vertex {vi} of feature {feat.GetFID()}")
ring.SetPoint_2D(vi, nx, ny)
out_feat = ogr.Feature(out_layer.GetLayerDefn())
out_feat.SetFrom(feat)
out_feat.SetGeometry(geom)
out_layer.CreateFeature(out_feat)
dst = None
def main(csv_path, src_tif, src_gpkg, out_dir):
out = Path(out_dir); out.mkdir(parents=True, exist_ok=True)
raster_gcps, vector_pairs = read_gcps(Path(csv_path))
warp_raster(Path(src_tif), raster_gcps, out / "patched.tif")
tps = build_vector_transformer(vector_pairs)
warp_vectors(Path(src_gpkg), tps, out / "patched.gpkg")
print(f"Patched raster and vectors written to {out}/")
if __name__ == "__main__":
if len(sys.argv) != 5:
print("Usage: python rubber_sheet.py gcps.csv source.tif source.gpkg patched/")
sys.exit(1)
main(*sys.argv[1:])
Validation & Output Verification
The value of a rubber-sheeting patch is only as good as its residuals β the leftover distance between each warped control point and its reference target. Ask gdalinfo to report the tie points and inspect the transform metadata:
gdalinfo patched/patched.tif | grep -A2 "GCP\["
For a numeric residual report, run the standalone gdaltransform in inverse mode against each reference coordinate and confirm the round-trip error is sub-pixel:
echo "36.8219 -1.2921" | gdaltransform -tps -i patched/patched.tif
# compare the returned pixel/line against the GCP you supplied for that feature
Confirm the vectors moved with the raster by checking that a known feature now overlays correctly. A quick ogrinfo extent comparison flags gross errors β the patched extent should shift by the same order of magnitude as your measured offset, not by kilometres:
ogrinfo -so -al patched/patched.gpkg | grep Extent
Finally, commit the patch as a reviewable unit. Version the GCP CSV alongside the outputs so the correction is reproducible; anyone can re-run the script and get byte-comparable geometry. Treat the GCP file as the source of truth, exactly as automated conflict detection in merge requests treats validation rules β the patch is data, not a one-off manual nudge.
Failure Modes
- Warped raster ripples or folds between control points β too many GCPs, or several that disagree with their neighbours, over-constrain the thin-plate spline and force it to bend sharply. Fix: drop the noisiest points, keep six to twelve well-distributed pins, and re-run; a smoother point set yields a smoother surface.
- Vectors drift away from the raster after the patch β the raster and vector transforms were built from different GCP sets or opposite src/dst orientations. Fix: drive both from one CSV and confirm the vector pairs are ordered distortedβreference to match the raster tie points.
gdalwarp -tpsproduces a hugely oversized output β a single mislabelled control point pulls one corner far off, ballooning the output bounding box. Fix: inspect residuals withgdaltransform, find the outlier with an error orders of magnitude larger than the rest, and correct or remove it.- Sub-pixel offset remains after warping β the distortion is actually a uniform datum or CRS shift, not local warping. Fix: stop rubber-sheeting and reproject instead; a thin-plate spline wastes control points modelling a shift a single transform removes cleanly.
Related
- Automated Patching for Minor Geometry Shifts β parent guide covering how small, reviewable geometry corrections are detected, applied, and merged
- Attribute Reconciliation for Tabular Spatial Data β companion topic for aligning the non-geometric side of a patched layer
- Automated Conflict Detection in Merge Requests β CI gates that validate geometry and topology before a patch merges