Fixing CRS Mismatches in Point Clouds with Python

Use laspy to extract the embedded CRS from the file’s VLRs, construct a pyproj.Transformer with always_xy=True, apply the transformation to all three coordinate axes, and write a corrected file with a WKT2 VLR — never apply manual offsets or naive scaling.

# Context and Motivation

This guide is part of Coordinate Reference Systems in the Point Cloud Data Standards & Fundamentals reference.

CRS mismatches are the most common silent data-corruption problem in production LiDAR pipelines. A 1-metre horizontal offset in UTM coordinates propagates undetected through ground classification, volumetric calculations, and infrastructure model alignment — until a deliverable fails a field survey check. The failures stem from several sources: sensor firmware defaults writing incorrect EPSG codes, legacy GeoTIFF GeoKey records that encode only a 2D horizontal datum with no geoid, cross-organisation data sharing that strips headers entirely, and LAS/LAZ file structure version constraints that limit LAS 1.0–1.3 files to a 5-bit classification byte and simplified CRS fields.

Understanding where the CRS lives in the binary header is the prerequisite for fixing it reliably.

CRS Mismatch Fix Pipeline Six sequential stages: raw LAS file, inspect VLRs, parse CRS, transform coordinates, write output, validate. Arrows connect each stage left to right. Raw LAS / LAZ file Inspect VLRs Parse / Assign CRS Transform X / Y / Z Write + WKT2 VLR Validate Output No CRS found? CRS.from_epsg()

# Prerequisites and Assumptions

Requirement Minimum version / note
Python 3.10+ (uses X | Y union types)
laspy 2.4+ — required for LasHeader.global_encoding.wkt attribute
pyproj 3.4+ — for full WKT2 round-trip and vertical grid support
numpy 1.22+
PROJ data proj.db + VERTCON/GEOID grids must be installed; set PROJ_DATA if using a custom path
Input file LAS 1.2–1.4 or LAZ; the script reads VLR records 2112 (WKT2) and 34735 (GeoKey)

If your environment uses Conda, conda install -c conda-forge pyproj automatically bundles the required PROJ grid files. Pip installations require a separate pip install certifi followed by pyproj.sync_data_cache().

# Step-by-Step Implementation

# Step 1 — Inspect the VLRs to Diagnose the Mismatch

Before transforming anything, confirm what CRS information is actually present. How to parse LAS headers with Python covers the full header layout; here the focus is the VLR block.

python
import laspy
from pyproj import CRS

def diagnose_crs(las_path: str) -> dict:
    """
    Inspect a LAS/LAZ file and return a dict describing its CRS state.
    Returns: {'has_wkt2': bool, 'has_geokey': bool, 'crs': CRS | None, 'note': str}
    """
    result = {"has_wkt2": False, "has_geokey": False, "crs": None, "note": ""}
    with laspy.open(las_path) as f:
        header = f.header
        for vlr in header.vlrs:
            if vlr.record_id == 2112:          # WKT2 record
                result["has_wkt2"] = True
                try:
                    wkt = vlr.record_data.decode("utf-8").rstrip("\x00")
                    result["crs"] = CRS.from_wkt(wkt)
                    result["note"] = "WKT2 CRS parsed from VLR 2112"
                except Exception as e:
                    result["note"] = f"Malformed WKT2 VLR: {e}"
            elif vlr.record_id == 34735:        # GeoKey directory (legacy)
                result["has_geokey"] = True
                if result["crs"] is None:
                    result["note"] = "Only legacy GeoKey CRS found — WKT2 preferred"

    if result["crs"] is None and not result["has_geokey"]:
        result["note"] = "No CRS in VLRs — must be supplied manually"
    return result

Run this first. The output tells you which remediation path to take:

  • WKT2 present and parseable → proceed to transformation.
  • GeoKey only → reconstruct the CRS via CRS.from_epsg() and inject WKT2 on output.
  • No CRS at all → consult acquisition metadata (flight plan, survey log, adjacent shapefiles) and supply manually.

# Step 2 — Construct a Datum-Aware Transformer

The most common mistake is calling Transformer.from_crs() without always_xy=True. EPSG:4326 officially defines axis order as (latitude, longitude), so without this flag pyproj silently swaps your X and Y for geographic CRSs.

python
from pyproj import Transformer

def build_transformer(source_crs: CRS, target_epsg: int) -> Transformer:
    """
    Build a pyproj Transformer that preserves axis order and applies
    vertical grid shifts when the target CRS includes a geoid model.
    """
    target_crs = CRS.from_epsg(target_epsg)
    return Transformer.from_crs(
        source_crs,
        target_crs,
        always_xy=True,   # Treat first axis as easting, second as northing
    )

For compound CRS reprojections — for example, converting from UTM Zone 17N (EPSG:26917) to NAD83(2011) / UTM Zone 17N + NAVD88 height (EPSG:6346+5703) — pass the full compound EPSG string. pyproj selects the best available PROJ pipeline including the appropriate geoid grid (e.g., us_noaa_g2018u0.tif) automatically.

# Step 3 — Transform Coordinates

laspy 2.x applies scale and offset automatically when you access las.x, las.y, las.z, returning double-precision float arrays. Pass these directly to transformer.transform().

python
import numpy as np

def transform_xyz(las: laspy.LasData, transformer: Transformer) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """
    Apply transformer to X/Y/Z arrays. laspy 2.x applies scale+offset
    automatically, so las.x / las.y / las.z are already in CRS units.
    """
    new_x, new_y, new_z = transformer.transform(las.x, las.y, las.z)
    return new_x, new_y, new_z

# Step 4 — Write a Corrected File with an Updated WKT2 VLR

The output header must embed the target CRS as VLR record_id 2112 and set global_encoding.wkt = True. Without this, tools like QGIS, PDAL, and CloudCompare fall back to GeoKey parsing and may read the wrong reference frame.

python
import laspy

def write_corrected_las(
    source_las: laspy.LasData,
    new_x: np.ndarray,
    new_y: np.ndarray,
    new_z: np.ndarray,
    target_crs: CRS,
    output_path: str,
) -> None:
    """
    Write a new LAS file with transformed coordinates and the target CRS
    embedded as a WKT2 VLR. All non-coordinate dimensions are preserved verbatim.
    """
    new_header = laspy.LasHeader(
        point_format=source_las.header.point_format.id,
        version=source_las.header.version,
    )
    new_header.offsets = source_las.header.offsets
    new_header.scales = source_las.header.scales

    wkt_bytes = target_crs.to_wkt().encode("utf-8")
    new_header.vlrs = [
        laspy.vlrs.VLR(
            user_id="LASF_Projection",
            record_id=2112,
            description="WKT Coordinate System",
            record_data=wkt_bytes,
        )
    ]
    new_header.global_encoding.wkt = True   # Required for LAS 1.4 compliance

    new_las = laspy.LasData(header=new_header)
    new_las.x = new_x
    new_las.y = new_y
    new_las.z = new_z

    # Copy every non-coordinate dimension: intensity, classification,
    # return_number, scan_angle_rank, rgb, waveform fields, etc.
    for dim_name in source_las.point_format.dimension_names:
        if dim_name.lower() not in ("x", "y", "z"):
            setattr(new_las, dim_name, getattr(source_las, dim_name))

    new_las.write(output_path)

# Complete Working Example

Copy-paste this script, adjust the three constants at the top, and run it against any LAS/LAZ file. It requires laspy>=2.4, pyproj>=3.4, and numpy>=1.22.

python
import laspy
import numpy as np
from pyproj import CRS, Transformer
from pathlib import Path

# ── Configure these three values ──────────────────────────────────────────────
INPUT_PATH   = "survey_raw.laz"
OUTPUT_PATH  = "survey_utm18n_navd88.laz"
TARGET_EPSG  = 6347           # NAD83(2011) / UTM zone 18N (horizontal only)
# For a compound CRS add the vertical: CRS.from_authority("EPSG", "6347+5703")
# ──────────────────────────────────────────────────────────────────────────────


def extract_crs(header: laspy.LasHeader) -> CRS | None:
    for vlr in header.vlrs:
        if vlr.record_id == 2112:
            try:
                return CRS.from_wkt(vlr.record_data.decode("utf-8").rstrip("\x00"))
            except Exception:
                pass
    return None


def fix_crs_mismatch(input_path: str, output_path: str, target_epsg: int) -> None:
    input_p  = Path(input_path)
    output_p = Path(output_path)

    if not input_p.exists():
        raise FileNotFoundError(f"Input file not found: {input_path}")

    with laspy.open(input_p) as f:
        las = f.read()

    source_crs = extract_crs(las.header)
    if source_crs is None:
        raise ValueError(
            "No CRS detected in LAS header VLRs. "
            "Set source_crs = CRS.from_epsg(<code>) manually and retry."
        )

    target_crs  = CRS.from_epsg(target_epsg)
    transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True)

    new_x, new_y, new_z = transformer.transform(las.x, las.y, las.z)

    new_header = laspy.LasHeader(
        point_format=las.header.point_format.id,
        version=las.header.version,
    )
    new_header.offsets = las.header.offsets
    new_header.scales  = las.header.scales
    new_header.vlrs    = [
        laspy.vlrs.VLR(
            user_id="LASF_Projection",
            record_id=2112,
            description="WKT Coordinate System",
            record_data=target_crs.to_wkt().encode("utf-8"),
        )
    ]
    new_header.global_encoding.wkt = True

    new_las   = laspy.LasData(header=new_header)
    new_las.x = new_x
    new_las.y = new_y
    new_las.z = new_z

    for dim_name in las.point_format.dimension_names:
        if dim_name.lower() not in ("x", "y", "z"):
            setattr(new_las, dim_name, getattr(las, dim_name))

    new_las.write(output_p)
    print(
        f"Transformed {len(new_x):,} points "
        f"from {source_crs.to_epsg()} → EPSG:{target_epsg}"
    )
    print(f"Output: {output_p}")


if __name__ == "__main__":
    fix_crs_mismatch(INPUT_PATH, OUTPUT_PATH, TARGET_EPSG)

# Key Parameter Table

Parameter Type Default / Example Effect
always_xy bool True Prevents axis-order swap for geographic CRSs like EPSG:4326
target_epsg int 6347 Target projected CRS; use a compound EPSG for 3D reprojection
header.offsets np.ndarray from source Must be re-centred if coordinates shift by >10⁷ units to avoid int32 overflow in scaled integers
header.scales np.ndarray from source [0.001, 0.001, 0.001] gives millimetre resolution; coarser scales degrade accuracy
record_id=2112 int fixed ASPRS-reserved VLR slot for WKT2 CRS; omitting it causes GIS readers to fall back to legacy GeoKeys
global_encoding.wkt bool True Bit 0 of the LAS 1.4 global encoding field — signals to readers that WKT2 is authoritative

# Verification

After writing the corrected file, confirm accuracy with three checks:

python
import laspy
import numpy as np
from pyproj import CRS

def verify_corrected_file(output_path: str, expected_epsg: int) -> None:
    with laspy.open(output_path) as f:
        las = f.read()

    # 1. Confirm CRS round-trips correctly
    found_crs = None
    for vlr in las.header.vlrs:
        if vlr.record_id == 2112:
            found_crs = CRS.from_wkt(vlr.record_data.decode("utf-8").rstrip("\x00"))
    assert found_crs is not None, "No WKT2 VLR found in output"
    assert found_crs.to_epsg() == expected_epsg, (
        f"CRS mismatch: expected EPSG:{expected_epsg}, got {found_crs.to_epsg()}"
    )

    # 2. Bounding box sanity — for UTM, X should be 100 000–900 000, Y > 0
    assert 100_000 < np.min(las.x) < 900_000, f"X out of UTM range: {np.min(las.x)}"
    assert np.min(las.y) > 0,                  f"Negative northing: {np.min(las.y)}"

    # 3. Point count preserved
    print(f"Point count: {len(las.x):,} — OK")
    print(f"CRS verified: EPSG:{found_crs.to_epsg()}")
    print(f"Bounds X [{np.min(las.x):.1f}, {np.max(las.x):.1f}]")
    print(f"Bounds Y [{np.min(las.y):.1f}, {np.max(las.y):.1f}]")
    print(f"Bounds Z [{np.min(las.z):.3f}, {np.max(las.z):.3f}]")

For infrastructure deliverables, cross-check at least one ground control point against an independent survey measurement. A correctly transformed UTM point should agree with the GCP to within your survey’s stated accuracy (typically ±0.05 m horizontal, ±0.10 m vertical for engineering-grade LiDAR).

# Gotchas and Edge Cases

Vertical datum silently skipped. If your source file uses ellipsoidal heights (e.g., raw GNSS output) but your target CRS is orthometric (NAVD88), pyproj applies the geoid grid shift only when both the source and target CRS definitions include explicit vertical components. Specifying EPSG:6347 (horizontal only) will transform X and Y correctly but leave Z unchanged. Use CRS.from_authority("EPSG", "6347+5703") (UTM 18N + NAVD88) for a full 3D transformation.

Missing PROJ grid files. When the required .tif geoid grid is not installed, pyproj either raises a CRSError or silently falls back to a lower-accuracy pipeline. Run pyproj.sync.sync_proj_data() or verify grid availability with pyproj.datadir.get_data_dir(). The PROJ CDN hosts all official grids.

Scaled integer overflow. laspy stores raw coordinates as 32-bit integers using the scale and offset from the header. If you keep the original offsets from a source file centred far from the target CRS, the integer representation can overflow. Recompute new_header.offsets as [np.min(new_x), np.min(new_y), np.min(new_z)] when the centroid shifts significantly (> 10⁷ units).

Large files and memory. The complete example loads all points into RAM. For multi-gigabyte aerial surveys, switch to a chunk-reading loop with laspy.open() and accumulate transformed chunks, or pass the reprojection to a PDAL spatial reprojection pipeline which streams chunks out-of-core and handles the offset recalculation automatically.