Vectorizing Coordinate Transforms with NumPy and Dask

Transforming a cadastral dataset one point at a time is the single most common performance defect in production coordinate pipelines, and eliminating it is the first optimisation covered under Batch Transformation & Automation for Cadastral Coordinate Pipelines: pyproj.Transformer.transform already accepts NumPy arrays and returns arrays, so a Python for loop that calls it per coordinate pays the C-extension crossing cost millions of times over for no accuracy benefit. This page narrows that parent topic to one concrete discipline — replacing per-point loops with a single vectorized array call, then scaling that same call beyond the memory of one machine by mapping it across partitioned Dask arrays and dataframes — while holding every value at float64 so results stay bit-for-bit reproducible against an agency-certified reference. The rule is simple: build the transformer once, hand it whole arrays, and let PROJ iterate in compiled code instead of the interpreter.

Vectorized NumPy and partitioned Dask transform flow An (N, 2) coordinate array enters a decision node that asks whether the array fits in RAM. If yes, a single vectorized Transformer.transform call converts the whole array and emits the result. If no, the array is chunked into partitions, each partition is mapped with map_blocks or map_partitions onto a worker that builds the transformer once and transforms its block, and the transformed blocks are concatenated into one output array. Both branches feed a residual and audit-log check before the certified result leaves the pipeline. Coordinate array (N, 2) float64 lon/lat Fits in RAM? N × 16 B vs budget Yes Vectorized call transform(x, y) whole No Partition into blocks chunk rows · choose size map_blocks → worker build transformer once, transform this block map_blocks → worker independent partition, no shared state Concatenate blocks one result array

Figure — a coordinate array either transforms whole in one NumPy call or splits into Dask partitions that map, transform, and concatenate back to one result.

Why the Loop Is the Bottleneck

Transformer.transform is a thin Python wrapper over PROJ’s compiled proj_trans_generic. When you pass two scalars it still crosses the Python/C boundary, allocates result objects, and returns them — a fixed overhead of a few microseconds that is invisible once but ruinous when repeated. For NN points the looped cost is

tloop=N(ccall+ctrans),t_{\text{loop}} = N\,(c_{\text{call}} + c_{\text{trans}}),

where ccallc_{\text{call}} is the per-call interpreter and boundary overhead and ctransc_{\text{trans}} is the actual projection arithmetic. The vectorized call collapses that to

tvec=ccall+Nctrans,t_{\text{vec}} = c_{\text{call}} + N\,c_{\text{trans}},

paying the boundary cost exactly once. Because ccallc_{\text{call}} typically dominates ctransc_{\text{trans}} for a single point, the achievable speedup approaches ccall/ctransc_{\text{call}}/c_{\text{trans}}, routinely one to two orders of magnitude. The full measurement discipline — timing both forms and reporting throughput in points per second — is the dedicated procedure in benchmarking NumPy vectorized versus looped transforms.

Vectorization alone stops at the memory ceiling: an array of NN float64 coordinate pairs needs 16N16N bytes for the input and as much again for the output, so a hundred-million-point cadastral extract wants several gigabytes just for the live arrays. When the working set exceeds RAM you keep the identical vectorized kernel but apply it to bounded partitions with Dask, which streams chunks through workers and never materialises the whole dataset at once. The out-of-core datum-shift case is worked end to end in Dask-partitioned datum shift for large cadastral datasets. Note that vectorizing is orthogonal to parallelism: a single vectorized call already saturates PROJ’s inner loop, and true multi-core distribution — including the transformer-reuse and thread-safety concerns — is the separate concern of concurrent pyproj transformation pipelines in Python.

Prerequisites and the always_xy Contract

Two invariants make a vectorized transform reproducible. First, axis order: construct the transformer with always_xy=True so inputs and outputs are unconditionally (x=longitude/easting, y=latitude/northing), defeating the CRS-defined axis order that otherwise silently swaps latitude and longitude for EPSG:4326. Second, dtype: pass genuine float64 arrays. PROJ computes in double precision internally, so feeding float32 truncates your inputs to roughly seven significant digits — about a decimetre of longitude at mid-latitude — before the transform even runs. The mantissa budget is unforgiving: a coordinate near easting 500000 m carried in float32 cannot represent millimetres at all, because 223×5×1050.06m2^{-23}\times 5\times10^{5}\approx 0.06\,\text{m}. Everything below keeps arrays at float64 from ingestion to audit.

Step-by-Step Implementation

Step 1 — Vectorize an (N,) array pair in one call

The transformer is built once and reused; transform receives the full longitude and latitude arrays and returns easting and northing arrays of the same shape. No Python-level loop touches an individual coordinate.

from __future__ import annotations

import numpy as np
from pyproj import Transformer


def build_transformer(src_epsg: int, dst_epsg: int) -> Transformer:
    """Construct a reusable Transformer with deterministic axis order.

    always_xy=True fixes I/O to (x, y) = (lon/easting, lat/northing),
    overriding the CRS authority axis order (ISO 19111 coordinate system).
    """
    return Transformer.from_crs(src_epsg, dst_epsg, always_xy=True)


def transform_arrays(
    transformer: Transformer,
    lon: np.ndarray,
    lat: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
    """Transform paired coordinate arrays in a single vectorized call.

    Inputs are coerced to contiguous float64 so PROJ never sees a
    down-cast mantissa; the output dtype matches and is returned as
    (easting, northing).
    """
    x = np.ascontiguousarray(lon, dtype=np.float64)
    y = np.ascontiguousarray(lat, dtype=np.float64)
    if x.shape != y.shape:
        raise ValueError(f"lon/lat shape mismatch: {x.shape} vs {y.shape}")
    east, north = transformer.transform(x, y)  # PROJ iterates in C, not Python
    return np.asarray(east, dtype=np.float64), np.asarray(north, dtype=np.float64)

A subtle point: Transformer.transform returns -inf (not an exception) for points that fall outside the operation’s area of use, so the array call degrades gracefully rather than aborting the whole batch. You detect and quarantine those rows in verification rather than losing the run.

Step 2 — Scale the same kernel with Dask map_partitions

When the array no longer fits in memory, wrap the coordinates in a Dask dataframe and apply the identical vectorized function to each partition. The transformer is rebuilt inside the mapped function so every worker owns its own PROJ context — a Transformer object holds a pointer into a native PROJ context that must not be pickled and shipped from the driver.

from __future__ import annotations

import dask.dataframe as dd
import pandas as pd


def transform_partition(
    part: pd.DataFrame,
    src_epsg: int,
    dst_epsg: int,
    lon_col: str = "lon",
    lat_col: str = "lat",
) -> pd.DataFrame:
    """Transform one partition; the Transformer is built here, on the worker.

    Building inside the mapped callable guarantees each worker gets a fresh
    native PROJ context instead of a serialised one from the driver.
    """
    transformer = build_transformer(src_epsg, dst_epsg)
    x = part[lon_col].to_numpy(dtype=np.float64)
    y = part[lat_col].to_numpy(dtype=np.float64)
    east, north = transformer.transform(x, y)
    out = part.copy()
    out["easting"] = np.asarray(east, dtype=np.float64)
    out["northing"] = np.asarray(north, dtype=np.float64)
    return out


def transform_dask(
    ddf: dd.DataFrame,
    src_epsg: int,
    dst_epsg: int,
) -> dd.DataFrame:
    """Map the vectorized transform across every partition lazily.

    `meta` declares the output schema so Dask never triggers an eager
    inference pass over real data, and float64 columns are pinned.
    """
    template = ddf._meta.copy()
    template["easting"] = np.float64()
    template["northing"] = np.float64()
    return ddf.map_partitions(
        transform_partition,
        src_epsg,
        dst_epsg,
        meta=template,
    )

The meta argument is the reproducibility hinge: without it Dask runs the function on a dummy frame to infer column dtypes and can guess float32 or object, corrupting precision before a single real row is processed. Declaring it makes the output schema explicit and the graph fully lazy until .compute() or .to_parquet() is called.

Parameter and Return-Value Reference

Name Type Units Valid range Cadastral significance
src_epsg / dst_epsg int valid EPSG codes source and target CRS; must match the survey’s declared frames
lon / lat np.ndarray degrees (or CRS units) finite float64 input coordinates; float32 here silently caps precision at ~7 digits
east / north np.ndarray metres (projected) finite or -inf result; -inf flags a point outside the operation’s area of use
always_xy bool True forces (x, y) I/O so lat/lon are never transposed
ddf dd.DataFrame ≥ 1 partition lazy out-of-core frame; row count may exceed RAM
meta pd.DataFrame dtype template pins output dtypes to float64; omitting it risks a down-cast
partition size int (rows) rows ≈ 0.5–5 M rows too large → OOM per worker; too small → scheduler overhead

Worked Example — Two Million Parcel Centroids

Consider a statewide parcel layer whose centroids are stored as NAD83(2011) geographic coordinates, EPSG:6318, and must be delivered in NAD83(2011) / UTM zone 14N, EPSG:6343, the projected frame a Texas county records office ingests. We synthesise two million centroids across the zone, transform them whole in memory, then repeat through Dask to confirm the two paths agree exactly.

import numpy as np

rng = np.random.default_rng(seed=20260714)  # fixed seed → reproducible extract
n = 2_000_000

# Parcel centroids scattered across UTM zone 14N's longitude band.
lon = rng.uniform(-102.0, -96.0, size=n).astype(np.float64)
lat = rng.uniform(28.0, 34.0, size=n).astype(np.float64)

tf = build_transformer(6318, 6343)               # NAD83(2011) -> UTM 14N
east, north = transform_arrays(tf, lon, lat)

# A parcel exactly on the zone's central meridian (-99 deg) lands at
# easting 500000 m by definition of the transverse Mercator false easting.
e0, n0 = transform_arrays(tf, np.array([-99.0]), np.array([31.0]))
print(round(float(e0[0]), 3), round(float(n0[0]), 3))
# 500000.0 3429693.657  (northing approximate; easting exact on the CM)

Because the central meridian of UTM zone 14N is exactly 99°-99°, any point there returns the false easting of 500000.0 m to the last representable digit — a free, exact correctness check baked into the projection definition. The northing depends on the latitude’s meridional arc and lands near 3.43 million metres at 31° N. Driving the same two million centroids through transform_dask and calling .compute() yields a frame whose easting/northing columns are element-wise identical to the in-memory arrays, because both branches run the same PROJ operation at float64.

Verification and Residual Analysis

Vectorization must not change a single result bit versus the trusted per-point path, and out-of-extent points must be caught rather than shipped. The check below re-transforms a sample through a scalar loop, confirms the arrays agree within a sub-millimetre tolerance, counts any non-finite results, and emits a structured audit record. The residual against the reference is the planar separation

ri=(EiEiref)2+(NiNiref)2.r_i = \sqrt{(E_i - E_i^{\text{ref}})^2 + (N_i - N_i^{\text{ref}})^2}.
import json
import logging

logger = logging.getLogger("vectorize")


def verify_vectorized(
    transformer: Transformer,
    lon: np.ndarray,
    lat: np.ndarray,
    east: np.ndarray,
    north: np.ndarray,
    sample: int = 1000,
    tolerance_m: float = 1e-4,   # 0.1 mm: vectorized vs scalar must be exact
) -> dict[str, object]:
    """Compare a random sample of the vectorized result against per-point
    reference transforms and emit an audit record. Raises on drift."""
    idx = np.random.default_rng(0).choice(lon.size, size=min(sample, lon.size),
                                           replace=False)
    ref_e = np.empty(idx.size, dtype=np.float64)
    ref_n = np.empty(idx.size, dtype=np.float64)
    for k, i in enumerate(idx):  # scalar reference path, deliberately looped
        ref_e[k], ref_n[k] = transformer.transform(float(lon[i]), float(lat[i]))
    resid = np.hypot(east[idx] - ref_e, north[idx] - ref_n)
    non_finite = int(np.count_nonzero(~np.isfinite(east)))
    record = {
        "sample": int(idx.size),
        "max_residual_m": round(float(resid.max()), 6),
        "tolerance_m": tolerance_m,
        "non_finite_points": non_finite,
        "passed": bool(resid.max() <= tolerance_m),
    }
    logger.info("vectorize_verify %s", json.dumps(record))
    if not record["passed"]:
        raise ValueError(f"Vectorized drift {resid.max():.6f} m exceeds tolerance")
    return record


rec = verify_vectorized(tf, lon, lat, east, north)
assert rec["passed"] and rec["non_finite_points"] == 0

The max_residual_m should be 0.0 to floating-point noise, because the vectorized and scalar calls invoke the same operation on the same double-precision inputs — any non-zero value points to a dtype leak, not a maths difference. Pinning that residual against surveyed monuments rather than the solver’s own output belongs to the control-network discipline documented across Algorithmic Math & Geodetic Workflows, which supplies the residual statistics and error-ellipse machinery a submission ultimately cites.

Troubleshooting

A vectorized run raises "cannot pickle 'PJ' object" under Dask
You built the Transformer in the driver and referenced it inside the mapped function, so Dask tried to serialise a native PROJ handle to the worker. Build the transformer inside the function passed to map_partitions, as in Step 2, so each worker constructs its own context.
Dask output columns come back as float32 or object
The meta template was omitted or declared the wrong dtype, so Dask inferred the schema from a dummy frame and guessed. Pass an explicit meta whose new columns are np.float64(); this both fixes precision and keeps the graph lazy.
Some transformed points come back as -inf
Those coordinates fall outside the transformation's area of use. Transformer.transform returns -inf rather than raising, so the batch survives. Count non-finite results in verification and route them to a fallback operation instead of exporting them.
Latitude and longitude look swapped after transforming from EPSG:4326
The transformer was built without always_xy=True, so it honoured the authority axis order (latitude first for EPSG:4326). Always pass always_xy=True and treat your arrays as (x=lon, y=lat).
Vectorizing barely helped on my machine
You are probably re-building the Transformer inside the loop, or timing the first call with a cold PROJ grid cache. Construct it once outside the timed region and warm the cache before measuring — the full method is in the benchmarking guide.

Frequently Asked Questions

Does passing arrays change the numeric result versus per-point calls?
No. The array form invokes the same PROJ operation element-wise at float64, so results are identical to per-point calls to the last representable digit. Any observed difference means a dtype was down-cast somewhere, not that vectorizing changed the maths.
Should I use dask.array or dask.dataframe for coordinates?
Use a dataframe when parcels carry attributes (IDs, areas) you must keep aligned with the coordinates, and a 2-D dask.array when the payload is purely numeric and you want block-wise map_blocks. Both apply the same vectorized kernel per partition.
How big should each partition be?
Target a few hundred thousand to a few million rows so each partition's live arrays sit comfortably below a worker's memory budget while keeping the task count low enough that scheduler overhead stays negligible. Partition sizing is treated in detail in the large-cadastral datum-shift guide.