Fixing LAS Scale and Offset Precision Loss

A survey delivered at 8 mm accuracy comes back from the client’s software with every coordinate ending in a round centimetre. Or worse: the cloud opens with points spread across thousands of kilometres, a few of them near the site and the rest in the sea. Both symptoms come from the same two header fields, and neither produces an error when it is written.

The underlying cause is that LAS does not store coordinates as floating-point numbers at all. It stores three signed 32-bit integers per point, and the real coordinate is reconstructed from them through a per-axis scale and offset in the header. Get those two wrong and the file is arithmetically incapable of holding the data — a fact that is invisible until somebody measures something.

This page covers detecting both failures before writing, choosing header values from the data, and what can and cannot be recovered afterwards. It is the detailed version of the header discussion in point cloud formats and interchange in Python.

The arithmetic, exactly

For each axis, the stored integer is round((real − offset) / scale). Reconstruction is the inverse. Two constraints follow.

The quantisation is the scale. A scale of 0.01 rounds every coordinate to the nearest centimetre. This is not an approximation that averages out across a cloud — it is a hard lattice, and a distance measured between two points on it can be out by a full scale unit.

The range is the signed 32-bit limit, about ±2.147 billion. The largest representable displacement from the offset is therefore 2.147e9 × scale. At a scale of 0.001 that is ±2,147 km, which is ample — but only measured from the offset. With the offset left at zero and a projected northing of 5.6 million metres, the required integer is 5.6 billion, which overflows and wraps to a negative value. The point lands on the other side of the world.

Coordinate lattice at three header scales Three number lines showing where coordinates can land at different header scales. At a scale of one hundredth, the lattice points are one centimetre apart and a true coordinate of one hundred point three three four metres snaps to one hundred point three three, losing four millimetres. At one thousandth the lattice is one millimetre apart and the same coordinate snaps to one hundred point three three four. At one ten-thousandth the lattice is a tenth of a millimetre apart, finer than any drone survey resolves. A note states that the lost digits cannot be recovered from the written file. true coordinate 100.3344 m scale 0.01 snaps to 100.33 — 4.4 mm lost scale 0.001 snaps to 100.334 — 0.4 mm lost scale 0.0001 finer than any drone survey resolves The lost digits are gone. There is no post-processing that recovers them from the file.

Figure 1 — The lattice, at the three scales in common use.

Minimal reproducible solution

Compute both header fields from the data, and refuse rather than degrade.

import numpy as np

INT32_SAFE = 2_147_483_000          # a little under the true limit, for rounding


def safe_header_geometry(xyz: np.ndarray, *, precision: float = 0.001,
                         snap_offset: float = 1.0) -> dict:
    """Header scale and offset that hold `precision` without overflow.

    The offset is snapped to a whole metre so it is exactly representable in
    the header's double and reads cleanly to a human. The check is done per
    axis, because a corridor survey can be fine in easting and overflow in
    northing.
    """
    lo, hi = np.asarray(xyz.min(axis=0)), np.asarray(xyz.max(axis=0))
    offset = np.round((lo + hi) / 2.0 / snap_offset) * snap_offset

    reach = np.maximum(np.abs(lo - offset), np.abs(hi - offset))
    needed_scale = reach / INT32_SAFE

    if (needed_scale > precision).any():
        axis = "xyz"[int(np.argmax(needed_scale))]
        raise ValueError(
            f"axis {axis} spans {2 * reach.max():.0f} m about its centre, which needs "
            f"scale {needed_scale.max():.6f} m — coarser than the requested "
            f"{precision} m. Tile the cloud instead of coarsening it.")

    return {"scale": (precision,) * 3,
            "offset": tuple(float(v) for v in offset),
            "headroom": float(precision / needed_scale.max())}

The headroom figure — how many times more range is available than needed — is worth logging. A value of 1.2 means the survey is close to the limit and a slightly larger extent next month will fail; a value of 400 means there is room to go finer if the survey warrants it.

Detecting the failure in a file somebody else wrote

Incoming clouds are frequently the problem. Two checks identify both faults from the header alone, without reading a point.

import laspy
import numpy as np


def audit_header(path: str, *, required_precision: float = 0.005) -> dict:
    """Judge an existing file's header against the precision it should carry."""
    with laspy.open(path) as fh:
        h = fh.header
        scales = np.asarray(h.scales)
        offsets = np.asarray(h.offsets)
        mins, maxs = np.asarray(h.mins), np.asarray(h.maxs)

    problems = []
    if (scales > required_precision).any():
        problems.append(
            f"scale {scales.tolist()} is coarser than {required_precision} m — "
            "coordinates in this file are quantised and cannot be improved")

    reach = np.maximum(np.abs(mins - offsets), np.abs(maxs - offsets))
    counts = reach / scales
    if (counts > 2_147_483_647).any():
        problems.append("stored integers exceed int32 — coordinates have wrapped")
    elif (counts > 2.0e9).any():
        problems.append("within 7 % of the int32 limit — a slightly larger "
                        "survey will overflow")

    if (np.abs(offsets) < 1.0).all() and (np.abs(mins) > 1.0e5).any():
        problems.append("offset is at zero for projected coordinates — "
                        "this file is one scale change away from wrapping")

    return {"scales": scales.tolist(), "offsets": offsets.tolist(),
            "max_integer": float(counts.max()), "problems": problems}

The third check — offset at zero with large projected coordinates — flags files that are currently correct and fragile. They work at 0.01 scale and break the moment anybody re-writes them at 0.001, which is exactly what a precision-conscious pipeline will do.

Quantisation error against the scale factor chosen A rising trace of worst-case coordinate quantisation error against the scale factor declared in a LAS header. At a scale of one tenth of a millimetre the error is negligible against any survey tolerance. At one millimetre it remains far below a photogrammetric survey's accuracy. At one centimetre it becomes comparable with the difference a change detection is trying to resolve. At one decimetre it exceeds the survey accuracy entirely. A note states that the cost of a finer scale is range rather than file size, since the thirty-two bit integer coordinate must still reach across the survey extent. scale factor declared in the header, coarsening to the right worst-case quantisation error A finer scale costs range, not size: the 32-bit coordinate must still span the survey.

Figure 3 — Scale is a range-against-precision trade, and the default is rarely right.

Edge-case matrix

Situation Symptom Handling
Scale 0.01, mm-accurate survey Coordinates on a 1 cm lattice Re-export from the source; the file cannot be fixed
Offset 0, projected CRS, fine scale Points scattered globally Set offset near the data centre
Corridor survey 40 km long One axis overflows Tile along the corridor
Geographic coordinates (degrees) Scale of 0.001 ≈ 100 m Never store lat/long in LAS at metre-style scales
Merging tiles with different offsets Works, but scales must match Harmonise headers before merging
Source in feet, scale in metres Quantisation 3.3× coarser than intended Assert the CRS units
Re-writing an already-quantised file Looks fine, precision already gone Audit the source header before trusting it
forward: all omitted in PDAL Defaults silently applied Always forward, then verify

The geographic-coordinates row deserves emphasis. A degree is roughly 111 km, so a scale of 0.001 degrees quantises to about 100 m. Storing unprojected coordinates in LAS at a metre-oriented scale is a mistake that produces a file which is obviously wrong once plotted and entirely plausible in the header.

Verification snippet

Verification is a round trip: write, read back, compare against the array that was written.

import laspy
import numpy as np


def roundtrip_error(path: str, original_xyz: np.ndarray) -> dict:
    """Largest coordinate error introduced by the write, per axis."""
    las = laspy.read(path)
    back = np.column_stack([las.x, las.y, las.z])
    if back.shape != original_xyz.shape:
        raise ValueError(f"point count changed: {original_xyz.shape[0]}{back.shape[0]}")

    err = np.abs(back - original_xyz)
    scales = np.asarray(las.header.scales)
    worst = err.max(axis=0)

    return {"worst_error_mm": (worst * 1000).tolist(),
            "scale_mm": (scales * 1000).tolist(),
            # A correct write errs by at most half a scale unit per axis.
            "ok": bool((worst <= scales / 2 + 1e-12).all())}

Comparing the observed error against half the scale unit is what makes this a real test rather than a tolerance guess. A correct encoding cannot do worse than half a lattice step; anything larger means something other than quantisation is happening — most often an overflow that has wrapped a handful of points.

Representable range against header scale, with typical coordinates marked A curve of the maximum representable displacement from the header offset against the header scale, from one ten-thousandth of a metre to one hundredth. At a scale of one ten-thousandth the reach is two hundred and fifteen kilometres; at one thousandth it is two thousand one hundred and forty-seven kilometres; at one hundredth it is twenty-one thousand. Two markers show a UTM northing of five point six million metres and a typical site extent of two kilometres, illustrating that the northing exceeds the reach at fine scales while the site extent never does. A note states that the offset is what converts the first case into the second. 0.0001 0.0005 0.001 0.005 0.01 m header scale reach from offset representable reach UTM northing 5 600 km, offset 0 site extent 2 km, offset at the centre The offset is the whole difference between the red line and the green one.

Figure 2 — Why the offset is not an optional nicety. It is what brings the numbers into range at any useful scale.

Choosing a precision to standardise on

Rather than deciding per job, most operations are better served by standardising on one scale across the fleet and only deviating with a reason. Three considerations point at the same answer.

A drone survey’s own accuracy is rarely better than about 10 mm in the vertical, even with dense ground control. A scale of 1 mm is therefore an order of magnitude finer than the data, which is the right relationship: the encoding should never be the limiting factor, and one order of magnitude of headroom costs nothing. Going to 0.1 mm buys nothing measurable and cuts the representable reach to 215 km, which is still ample but narrows the margin for no benefit.

A scale of 1 mm also keeps the numbers human-readable. Coordinates print as three decimal places, which matches how survey reports quote them, and a technician comparing a file against a coordinate list is not mentally converting anything.

And it is safe for every projected extent a drone survey produces. With the offset at the data centre, 1 mm supports a survey 4,000 km across — which no single flight will ever approach — so the refuse-rather-than-degrade branch effectively never fires for legitimate data. When it does fire, it is telling you something real: usually that a merge combined two sites, or that an outlier at the edge of the solar system survived the noise filter.

Write the chosen scale into the pipeline configuration rather than into each writer call, and audit incoming third-party files against it. That single convention removes an entire class of quiet delivery failure.

When to escalate

  • The delivered file is already quantised and the source is gone. Nothing recovers it. Say so plainly and re-derive from the reconstruction if it still exists; an interpolated “improvement” is fabrication.
  • A survey genuinely needs finer than the int32 range allows. That is a tiling decision. A single file cannot hold a 40 km corridor at 0.1 mm precision, and pretending otherwise produces wrapped coordinates.
  • A client’s software rewrites the headers on import. Some packages normalise scale and offset on load, quietly coarsening the data. Ask what it writes before delivering at a fine scale, and supply COPC or LAZ if it preserves them better.

Point Cloud Formats and Interchange in Python