Implementing Affine Transformations for Local Grids: A Production-Ready Guide for Cadastral & High-Precision Workflows
Aligning a legacy local grid to a modern reference frame is the specific sub-task within Algorithmic Math & Geodetic Workflows that this guide solves end to end: deriving a six-parameter affine transformation from control points, gating it on numerical conditioning, and validating its residuals against statutory cadastral tolerance. Local grid systems remain the operational backbone of cadastral surveying, municipal engineering, and high-precision asset management because, unlike global projections that introduce progressive scale distortion across large extents, they preserve metric fidelity within constrained boundaries. Implementing the transformation correctly requires deterministic mathematics, explicit tolerance enforcement, and strict adherence to ISO 19111:2019 coordinate reference system specifications. This guide details the algorithmic pipeline, parameter derivation, validation protocols, and production-grade Python implementation required for survey-grade work where precision, reproducibility, and auditability are non-negotiable for legal and engineering deliverables.
Figure — affine solve with automatic similarity fallback when ill-conditioned.
Mathematical formulation and parameter constraints
The two-dimensional affine transformation is defined by six independent parameters: two translations, one rotation, two independent scale factors, and one shear component. In matrix notation, the transformation maps source coordinates
The coefficients
For cadastral applications, the transformation must preserve angular relationships and minimize residual distortion across the parcel block. When local grids exhibit non-uniform deformation due to historical chain-and-compass surveys, crustal movement, or legacy datum realignments, practitioners may evaluate higher-order models. However, the affine model remains the industry standard for bounded, metrically consistent parcels where systematic scale and rotation dominate the error budget. When regional deformation exceeds the linear assumption, teams typically transition to polynomial shift algorithms for regional adjustments, though such models require denser control networks and rigorous overfitting safeguards.
Parameter constraints must be enforced explicitly during computation. Floating-point accumulation errors can corrupt sub-centimeter tolerances if intermediate matrices are not cast to float64 and validated against machine epsilon. The design matrix must satisfy rank conditions; otherwise, the normal equations become singular and yield non-deterministic outputs.
Specification: control network design and least squares estimation
Parameter estimation requires a minimum of three non-collinear control points with known source and target coordinates. In production environments, four or more points are standard to enable redundancy, statistical validation, and outlier rejection. The parameters are solved via a weighted least squares adjustment that minimizes the sum of squared residuals. Deterministic behavior is enforced by fixing the observation weight matrix
The design matrix
The parameter vector
Step-by-step implementation
The implementation below is split into four deterministic stages. Every stage enforces explicit float64 precision, and the conditioning gate routes to a constrained similarity fallback when the full affine model becomes numerically unreliable. A standalone, audit-ready version of the underlying solver is documented in Python least squares adjustment for control points.
Step 1 — Build the affine design matrix
The design matrix encodes how each of the six parameters influences each observed coordinate axis. Interleaving the X and Y rows keeps the parameter ordering [a, b, c, d, e, f] stable, which downstream audit records depend on.
import numpy as np
from numpy.typing import NDArray
EPS64: float = float(np.finfo(np.float64).eps)
MAX_COND_THRESHOLD: float = 1e10
def build_design_matrix(src: NDArray[np.float64]) -> NDArray[np.float64]:
"""Construct the 2n x 6 affine design matrix.
Two rows per control point (X-equation then Y-equation), matching the
ISO 19111 affine coordinate-operation parameterisation [a, b, c, d, e, f].
"""
src = np.asarray(src, dtype=np.float64) # explicit double precision
if src.ndim != 2 or src.shape[1] != 2:
raise ValueError("src must be an (n, 2) array of source E, N values.")
n = src.shape[0]
A = np.zeros((2 * n, 6), dtype=np.float64)
A[0::2, 0] = src[:, 0] # a * Xs -> Xt
A[0::2, 1] = src[:, 1] # b * Ys -> Xt
A[0::2, 4] = 1.0 # e (X translation)
A[1::2, 2] = src[:, 0] # c * Xs -> Yt
A[1::2, 3] = src[:, 1] # d * Ys -> Yt
A[1::2, 5] = 1.0 # f (Y translation)
return A
Step 2 — Solve the weighted normal equations with a conditioning gate
The solver applies the per-point weights, checks cond(A) before inversion, and only commits to the full six-parameter fit when the system is well-conditioned. The condition number is the single most important numerical guard: a value beyond MAX_COND_THRESHOLD means small observation noise is amplified into large parameter error.
import numpy as np
from numpy.typing import NDArray
from numpy.linalg import cond, solve, LinAlgError
def solve_affine(
src: NDArray[np.float64],
tgt: NDArray[np.float64],
weights: NDArray[np.float64],
) -> dict[str, object]:
"""Solve the full 6-parameter affine fit by weighted normal equations.
Raises LinAlgError when cond(A) exceeds the survey-grade conditioning
threshold, signalling the caller to degrade to a similarity model.
"""
A = build_design_matrix(src) # 2n x 6
L = np.column_stack((tgt[:, 0], tgt[:, 1])).flatten()
# Each point contributes two rows, so repeat its sqrt-weight twice.
w_sqrt = np.repeat(np.sqrt(weights), 2)
Aw = A * w_sqrt[:, None]
Lw = L * w_sqrt
c_num = float(cond(Aw))
if c_num > MAX_COND_THRESHOLD: # tolerance gate
raise LinAlgError(f"cond(A) = {c_num:.2e} exceeds {MAX_COND_THRESHOLD:.0e}")
params = solve(Aw.T @ Aw, Aw.T @ Lw) # normal equations
matrix = np.array([[params[0], params[1]],
[params[2], params[3]]], dtype=np.float64)
translation = np.array([params[4], params[5]], dtype=np.float64)
return {"matrix": matrix, "translation": translation,
"model": "affine", "condition_number": c_num}
Step 3 — Degrade to a similarity transform when ill-conditioned
When control geometry cannot support six free parameters (near-collinear monuments, or a degenerate aspect ratio), forcing the affine fit fabricates spurious shear. The deterministic response is to drop to a four-parameter similarity transform (uniform scale, single rotation, zero shear), which is what most cadastral agencies accept as the conservative fallback.
import warnings
import numpy as np
from numpy.typing import NDArray
from numpy.linalg import lstsq
class AffineDegradationWarning(UserWarning):
"""Raised when full affine conditioning exceeds safe limits."""
def solve_similarity(
src: NDArray[np.float64],
tgt: NDArray[np.float64],
weights: NDArray[np.float64],
) -> dict[str, object]:
"""Four-parameter similarity fallback: x' = p*x - q*y + e ; y' = q*x + p*y + f.
Enforces zero shear and isotropic scale, the conservative model accepted
for cadastral retracement when affine conditioning fails.
"""
warnings.warn("Affine ill-conditioned; degrading to similarity transform.",
AffineDegradationWarning, stacklevel=2)
n = src.shape[0]
A = np.zeros((2 * n, 4), dtype=np.float64)
A[0::2, 0] = src[:, 0]; A[0::2, 1] = -src[:, 1]; A[0::2, 2] = 1.0
A[1::2, 0] = src[:, 1]; A[1::2, 1] = src[:, 0]; A[1::2, 3] = 1.0
w_sqrt = np.repeat(np.sqrt(weights), 2)
Aw = A * w_sqrt[:, None]
Lw = np.column_stack((tgt[:, 0], tgt[:, 1])).flatten() * w_sqrt
params, *_ = lstsq(Aw, Lw, rcond=None)
p, q, e, f = (float(v) for v in params)
return {
"matrix": np.array([[p, -q], [q, p]], dtype=np.float64),
"translation": np.array([e, f], dtype=np.float64),
"model": "similarity",
"condition_number": float("inf"),
}
Step 4 — Assemble the estimator with residuals and diagnostics
The public entry point validates inputs, routes between the affine and similarity solvers, and returns a structured record — matrix, translation, residual field, RMSE, and the matrix invariants an auditor inspects. The model key records which estimator actually produced the answer.
import numpy as np
from numpy.typing import NDArray
from numpy.linalg import LinAlgError
from typing import Sequence
def compute_affine_transform(
source_coords: Sequence[tuple[float, float]],
target_coords: Sequence[tuple[float, float]],
weights: Sequence[float] | None = None,
) -> dict[str, object]:
"""Compute a 2D affine transform with float64 precision and fallback routing."""
src = np.asarray(source_coords, dtype=np.float64)
tgt = np.asarray(target_coords, dtype=np.float64)
if src.shape[0] < 3:
raise ValueError("Minimum 3 non-collinear control points required.")
if src.shape != tgt.shape:
raise ValueError("Source and target coordinate arrays must match shape.")
w = (np.ones(src.shape[0], dtype=np.float64) if weights is None
else np.asarray(weights, dtype=np.float64))
w = np.clip(w, EPS64, None) # guard against weight collapse
try:
result = solve_affine(src, tgt, w)
except LinAlgError:
result = solve_similarity(src, tgt, w)
matrix = result["matrix"]
translation = result["translation"]
predicted = (src @ matrix.T) + translation
residuals = tgt - predicted
rmse = float(np.sqrt(np.mean(np.sum(residuals ** 2, axis=1))))
result.update({
"residuals": residuals,
"rmse": rmse,
"diagnostics": {
"determinant": float(np.linalg.det(matrix)),
"scale_x": float(np.linalg.norm(matrix[0])),
"scale_y": float(np.linalg.norm(matrix[1])),
},
})
return result
Parameter and return-value reference
The estimator’s contract is summarised below. Planar units follow the working frame (metres); weights are dimensionless relative precisions.
| Name | Direction | Type | Units | Valid range | Cadastral significance |
|---|---|---|---|---|---|
source_coords |
in | Sequence[(float, float)] |
metres | n ≥ 3, non-collinear | Control points in the legacy local grid |
target_coords |
in | Sequence[(float, float)] |
metres | congruent with source | Known coordinates in the target CRS |
weights |
in | Sequence[float] | None |
— | > 0 | Per-point relative precision; None = equal weighting |
matrix |
out | NDArray[float64] (2, 2) |
— | det ≠ 0 | Rotation, scale and shear block |
translation |
out | NDArray[float64] (2,) |
metres | — | Origin shift [e, f] |
residuals |
out | NDArray[float64] (n, 2) |
metres | — | Per-point misclosure for audit |
rmse |
out | float |
metres | ≤ jurisdictional limit | Pass/fail acceptance metric |
condition_number |
out | float |
— | ≤ 1e10 | Numerical reliability of the fit |
model |
out | str |
— | affine / similarity |
Audit flag for which estimator ran |
diagnostics.determinant |
out | float |
— | > 0 (no flip) | Areal scale factor; sign reveals axis flip |
Worked example with real-world coordinates
Consider four monuments observed on a municipal local grid that must be re-fitted onto the British National Grid (EPSG:27700) plane. The source coordinates are a clean 100 m square; the targets carry realistic sub-30 mm survey noise. The expected outcome is an affine model (not the fallback), an areal scale factor near 1.0, and an RMSE comfortably inside a 0.02 m cadastral band.
import numpy as np
src = [(0.0, 0.0), (100.0, 0.0), (0.0, 100.0), (100.0, 100.0)]
tgt = [
(529100.00, 181200.00),
(529200.01, 181199.98),
(529100.02, 181300.01),
(529199.99, 181299.97),
]
# Down-weight a monument with a known less-stable mark (relative precision 0.5).
weights = [1.0, 1.0, 1.0, 0.5]
result = compute_affine_transform(src, tgt, weights)
print(f"model : {result['model']}")
print(f"condition number : {result['condition_number']:.3e}")
print(f"areal scale (det): {result['diagnostics']['determinant']:.6f}")
print(f"RMSE (m) : {result['rmse']:.4f}")
print(f"translation (m) : {result['translation']}")
# Expected (approx): model = affine, det ~ 1.000, RMSE ~ 0.01 m,
# translation ~ [529100.0, 181200.0]
The translation recovers the grid origin offset of roughly (529100, 181200), the determinant near unity confirms the source square maps to a target square with no spurious areal distortion, and the peak misclosure stays inside the declared survey noise. Withholding one monument as an independent check point and re-predicting it is the standard acceptance test — its transformed position should agree with its surveyed coordinate to within the RMSE, the workflow formalized in validating datum alignment with control points.
Verification and residual analysis
Survey-grade deliverables require an explicit tolerance gate and a serialisable record so the accept/reject decision is reproducible during a cadastral audit. The snippet below compares per-point planar residuals against the statutory limit, computes RMSE, and emits a JSON-ready log entry. Residual vectors should also be tested for spatial autocorrelation; clustered residuals indicate unmodeled systematic error, often a datum shift or localized ground deformation that exceeds the linear model.
import json
import logging
import numpy as np
from numpy.typing import NDArray
from typing import Any
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("affine_local_grid")
def audit_affine_fit(result: dict[str, Any], tolerance_m: float = 0.02) -> dict[str, Any]:
"""Gate an affine fit against survey-grade tolerance and emit an audit record."""
residuals: NDArray[np.float64] = np.asarray(result["residuals"], dtype=np.float64)
point_mag = np.sqrt(np.sum(residuals ** 2, axis=1)) # per-point misclosure
record = {
"model": result["model"],
"rmse_m": round(float(result["rmse"]), 6),
"max_residual_m": round(float(point_mag.max()), 6),
"worst_point_index": int(np.argmax(point_mag)),
"condition_number": result["condition_number"],
"tolerance_m": tolerance_m,
"within_tolerance": bool(point_mag.max() <= tolerance_m),
}
logger.info("affine audit: %s", json.dumps(record))
return record
report = audit_affine_fit(result, tolerance_m=0.02)
assert report["within_tolerance"], "Residuals exceed survey-grade tolerance."
The assert is the gate: a residual beyond the statutory band stops the pipeline rather than silently writing a non-compliant coordinate to the cadastral store. The same threshold discipline — choosing the band per agency and per operation class — is generalised in tuning transformation thresholds for survey-grade work. When bridging a local grid to a 3D reference frame, run the planar fit only after the ellipsoid-to-Cartesian conversion math has reduced GNSS-derived positions to the working plane, so that vertical integration does not leak truncation error into the horizontal solution.
Validation checklist for production deployment:
- Verify control point distribution spans the project extent without collinearity.
- Confirm the condition number is below
; trigger the similarity fallback if exceeded. - Enforce RMSE within the jurisdictional threshold; reject the transformation if exceeded.
- Audit the residual distribution for non-random patterns; investigate outliers before finalization.
- Log all parameters, weights, and diagnostics to immutable storage for chain-of-custody compliance.
Troubleshooting and gotchas
Silent demotion to the similarity model. A near-collinear or extreme-aspect control set trips the conditioning gate and compute_affine_transform returns model == "similarity" with condition_number == inf. Always inspect the returned model flag before using the parameters: a similarity result has zero shear by construction, so any genuine skew in the local grid will reappear as residual misclosure rather than being absorbed by the fit.
Single- vs double-precision promotion. Passing float32 arrays (common from shapefile or CSV I/O paths) silently degrades sub-centimeter tolerances and can flip the conditioning decision. Every entry point here calls np.asarray(..., dtype=np.float64); never remove those casts, and reduce large coordinate magnitudes (e.g. full eastings near
Negative determinant — an undetected axis flip. If diagnostics.determinant is negative, the source and target axis conventions disagree (a longitude/latitude or easting/northing swap). The fit will still minimise residuals, but every transformed coordinate is mirrored. Treat det < 0 as a hard error, not a warning, and re-check axis order against the coordinate reference system definitions for both frames.
Mis-scaled weights collapsing the solution. Supplying weights of wildly different magnitude (or zeros) can make one monument dominate the normal matrix; the np.clip(w, EPS64, None) guard prevents a hard division failure but not a skewed fit. Derive weights from real precision metadata and keep their dynamic range within a couple of orders of magnitude.
RMSE passes but residuals are spatially clustered. A global RMSE inside tolerance can still hide a systematic trend if all residuals point the same way in one corner of the block. That signature means the deformation is non-linear and the affine model is the wrong tool — escalate to polynomial shift algorithms for regional adjustments with denser control rather than accepting the marginal fit.
Related guides
- Algorithmic Math & Geodetic Workflows — parent guide to the deterministic transformation pipeline
- Python least squares adjustment for control points — the audit-ready solver behind this fit
- Least squares adjustment for control networks — the Gauss-Markov model and chi-square test for the stochastic weighting
- Polynomial shift algorithms for regional adjustments — higher-order surface fitting when the linear model fails
- Validating datum alignment with control points — independent check-point acceptance testing