Fixing SRS Lost When Writing LAS Files

The client reports that the point cloud opens “in the wrong place” — sometimes at the origin, sometimes overlaid on the right site but a few hundred metres out, sometimes fine. On the machine that produced it everything looks correct, which is the detail that makes this frustrating: the processing environment has enough context to place the cloud even when the file does not carry it.

The cause is almost always that the spatial reference was never written into the file, or was written in a form the reading software does not look for. Neither produces an error at write time, and the producing machine’s own tools often fall back to a project setting that masks the problem entirely.

Where a LAS file keeps its coordinate system

LAS has carried spatial reference information three different ways over its versions, and readers vary in which they consult.

GeoTIFF keys, in variable-length records, are the LAS 1.0–1.3 mechanism. They encode a projected coordinate system code and a handful of parameters, and they cannot express everything modern CRS definitions contain — notably compound horizontal-plus-vertical systems and time-dependent transformations.

OGC WKT, in a variable-length record, is the LAS 1.4 mechanism. It is a full text definition and can express compound systems. Crucially, LAS 1.4 has a global encoding bit that declares whether WKT is present, and a file with WKT but without that bit set is one many readers will not look in.

Nothing at all, which is what a writer produces when it was given no CRS and asked no questions.

A fourth mechanism exists outside the file: a .prj sidecar. It is not part of the LAS specification, several tools read it, and it costs nothing to write.

Where LAS stores a coordinate system, and which readers look where A matrix of three storage mechanisms against four kinds of reader. GeoTIFF keys are read by legacy desktop tools, modern desktop tools and PDAL, but not by some web viewers. OGC WKT in a LAS 1.4 variable-length record is read by modern desktop tools, PDAL and web viewers, but not by legacy tools, and only when the global encoding bit is set. A prj sidecar is read by some desktop tools only. A note recommends writing WKT with the bit set, plus a sidecar, which covers every column. legacy desktop modern desktop PDAL web viewer GeoTIFF keys WKT (1.4, bit set) WKT (bit not set) .prj sidecar yesyes yesoften no noyes yesyes novaries variesno somesome nono WKT with the bit set, plus a sidecar, covers every column. The third row is the trap: the definition is in the file and half the readers never look.

Figure 1 — Why “the CRS is in the file” is not the same as “readers will find it”.

Minimal reproducible solution

import laspy
import numpy as np
from pyproj import CRS


def write_with_crs(xyz: np.ndarray, out_path: str, epsg: int,
                   *, vertical_epsg: int | None = None,
                   write_sidecar: bool = True) -> dict:
    """Write LAS 1.4 with an unambiguous, discoverable coordinate system.

    add_crs sets both the WKT record and the global encoding bit that tells a
    reader to look for it. Setting one without the other is the single most
    common way a correctly intentioned write produces an unreadable CRS.
    """
    crs = CRS.from_epsg(epsg)
    if vertical_epsg is not None:
        crs = CRS.from_user_input(
            f"EPSG:{epsg}+{vertical_epsg}")     # compound horizontal + vertical

    header = laspy.LasHeader(point_format=6, version="1.4")
    header.offsets = np.round(xyz.mean(axis=0))
    header.scales = np.array([0.001, 0.001, 0.001])
    header.add_crs(crs)

    las = laspy.LasData(header)
    las.x, las.y, las.z = xyz[:, 0], xyz[:, 1], xyz[:, 2]
    las.write(out_path)

    if write_sidecar:
        from pathlib import Path
        Path(out_path).with_suffix(".prj").write_text(crs.to_wkt())

    return {"epsg": epsg, "compound": vertical_epsg is not None,
            "sidecar": write_sidecar}

The compound-CRS branch matters more than it looks. A cloud whose heights are orthometric but whose declared CRS is horizontal-only carries no statement about the vertical datum at all, which is how a survey ends up differenced against another one on a different datum. The vertical side is covered in geoid models and vertical datum automation.

For PDAL-written files, the equivalent is to forward the source CRS explicitly, and to set it when the source has none:

import json
import subprocess


def write_with_pdal(src: str, dst: str, epsg: int,
                    *, assume_source: str | None = None) -> None:
    """Write through PDAL with the CRS explicitly assigned, not inherited.

    `a_srs` on the reader assigns a CRS to data that has none — it does not
    reproject. Confusing it with `out_srs`, which does, silently produces
    coordinates in the wrong system with a correct-looking declaration.
    """
    reader: dict = {"filename": src, "type": "readers.las"}
    if assume_source:
        reader["spatialreference"] = assume_source

    pipeline = {"pipeline": [
        reader,
        {"type": "writers.las", "filename": dst, "compression": "laszip",
         "a_srs": f"EPSG:{epsg}", "forward": "all", "minor_version": 4},
    ]}
    subprocess.run(["pdal", "pipeline", "--stdin"],
                   input=json.dumps(pipeline), text=True, check=True)
Three places a spatial reference goes missing between read and write Three rows. The reader may never have had one, because the source file declared no coordinate reference system and nothing supplied a default. A filter stage may drop it, because some stages construct a fresh metadata set rather than carrying the input's through. The writer may discard it, because a LAS version and point format combination that cannot express the projection silently writes none rather than failing. A note states that asserting the reference at the writer, rather than assuming it survived, is the only reliable remedy. the reader never had one the source declared no CRS and nothing supplied a default a filter dropped it some stages build fresh metadata rather than carrying the input's the writer discarded it a LAS version and point format that cannot express it writes none Asserting the reference at the writer, rather than assuming it survived, is the only fix that holds.

Figure 3 — Three losses, one remedy.

Edge-case matrix

Situation Symptom Handling
No CRS written at all Opens at the origin everywhere Write WKT plus sidecar
WKT present, encoding bit unset Opens correctly in some tools only Use a writer that sets both
GeoTIFF keys only, LAS 1.2 Modern web viewers fail Re-write as 1.4 with WKT
Horizontal-only CRS, orthometric heights Differences against other data are wrong Write a compound CRS
a_srs used where reprojection was meant Coordinates unchanged, label changed Use out_srs to reproject
CRS forwarded from a wrong source Confidently wrong declaration Assert the expected EPSG after writing
Cloud in a local engineering grid No EPSG exists Document the transformation; do not invent a code
Sidecar present, file empty of CRS Works until the sidecar is separated Treat the sidecar as a supplement, never the record

The a_srs versus out_srs distinction deserves the emphasis. a_srs asserts — it relabels data without moving it. out_srs transforms. Using the first where the second was intended produces a file whose coordinates are in the old system and whose header claims the new one, which is worse than having no CRS at all because it looks authoritative.

Verification snippet

from pathlib import Path

import laspy
from pyproj import CRS


def verify_crs(path: str, expect_epsg: int) -> dict:
    """Read back what a reader would actually find, and compare."""
    las = laspy.read(path)
    found = las.header.parse_crs()

    problems = []
    if found is None:
        problems.append("no CRS discoverable in the file")
    else:
        got = CRS.from_user_input(found)
        if got.to_epsg() != expect_epsg:
            problems.append(f"declares EPSG:{got.to_epsg()}, expected {expect_epsg}")
        if not got.is_projected:
            problems.append("CRS is geographic — metre-based scales are wrong for it")
        if len(got.sub_crs_list) < 2 and "orthometric" in (got.name or "").lower():
            problems.append("heights look orthometric but no vertical CRS is declared")

    sidecar = Path(path).with_suffix(".prj")
    return {"epsg": (CRS.from_user_input(found).to_epsg() if found else None),
            "version": str(las.header.version),
            "sidecar_present": sidecar.exists(),
            "problems": problems}

The geographic check is worth including because it catches a compound mistake: a file whose CRS is in degrees but whose header scale of 0.001 was chosen for metres. That combination quantises to roughly 100 m and is the single most extreme precision loss available in the format.

Asserting a coordinate system against reprojecting to one Two panels showing the same input cloud in UTM zone thirty. In the first, a_srs is used to assert EPSG twenty-seven thousand seven hundred: the coordinates are unchanged and only the label changes, so the file now claims to be in a system its numbers do not belong to. In the second, out_srs is used: the coordinates are transformed and the label matches them. A note states that the first case looks authoritative and is wrong, which makes it more dangerous than a file carrying no coordinate system at all. a_srs — relabels only out_srs — transforms in: 512 340.12, 5 712 880.44 labelled EPSG:32630 out: 512 340.12, 5 712 880.44 labelled EPSG:27700 — unchanged numbers in: 512 340.12, 5 712 880.44 labelled EPSG:32630 out: 423 118.77, 296 402.51 labelled EPSG:27700 — numbers match The left file is confidently, verifiably wrong. A file with no CRS at least announces that it does not know where it is. Assert only when the data genuinely is in that system and merely undeclared.

Figure 2 — Two options, one letter apart in intent, with opposite effects on the numbers.

Making the declaration survive the whole pipeline

A CRS is lost somewhere between the reconstruction and the delivery far more often than it is written wrongly at the end. Each intermediate write is an opportunity to drop it, and a pipeline of six stages has six of them.

Two conventions close the gap. Forward the header on every intermediate writeforward: all in PDAL, or explicit add_crs in laspy — so a CRS set once at ingest propagates to the end without being restated. And assert the CRS after every write, not only at delivery, so the stage that lost it is identified immediately rather than at the end of a long chain.

def crs_trace(paths: list[str], expect_epsg: int) -> list[dict]:
    """Walk the intermediate files of a run and report where the CRS was lost."""
    import laspy
    from pyproj import CRS

    trace = []
    for path in paths:
        try:
            with laspy.open(path) as fh:
                found = fh.header.parse_crs()
            epsg = CRS.from_user_input(found).to_epsg() if found else None
        except Exception as exc:
            epsg, found = None, str(exc)[:80]
        trace.append({"file": path, "epsg": epsg,
                      "ok": epsg == expect_epsg})
        if epsg != expect_epsg:
            break                      # the first loss is the one to fix
    return trace

Stopping at the first loss is deliberate: once a CRS is gone, every file after it inherits the absence, and reporting all of them buries the one that matters. The same pattern applies to any header property worth preserving — scale, offset, extra dimensions — and a single trace function covering all of them is a worthwhile twenty lines on a pipeline that runs weekly.

When to escalate

  • The data is in a local engineering grid with no EPSG code. There is nothing correct to write. Document the transformation to a national grid alongside the delivery and do not assign a code that approximates it.
  • A client’s software rewrites or discards the CRS on import. Some packages normalise on load. Ask what their workflow preserves before choosing a delivery format, and supply the sidecar as well.
  • Two files of the same site declare different systems. Find out which is right before reprojecting either; a wrong declaration propagated through a reprojection is much harder to unpick.

Troubleshooting Point Cloud Processing Failures