Skip to content

Converting Between Point Cloud File Formats (LAS, PLY, PCD)

11 min read · updated August 11, 2026

Most point cloud conversion failures are loud and easy. The dangerous ones are silent: a file that converts without complaint, opens without complaint, and has lost your classification codes, your GPS times, or half a metre of northing.

The errors

  • A point-format-versus-version mismatch. Writing a LAS 1.4 point record into a 1.2 header raises an error of the form Point format 6 is not compatible with file version 1.2. Point data record formats 6 through 10 were introduced with LAS 1.4 and cannot appear in an earlier header. Either write a 1.4 file or map down to format 1 or 3 and accept the losses below.
  • An unknown PLY property. A reader that expects x y z and finds property float scalar_Intensity either ignores it or refuses the file. PLY has no registry of property names — a writer may emit anything, and readers only recognise conventional names.
  • A binary PLY parsed as ASCII. Produces could not convert string to float or a stream of nonsense coordinates. The format line in the header is authoritative and is one of exactly three strings: format ascii 1.0, format binary_little_endian 1.0 or format binary_big_endian 1.0.
  • A PCD header whose arrays disagree. FIELDS, SIZE, TYPE and COUNT must have the same number of entries, and POINTS must equal WIDTH × HEIGHT. A mismatch is the most common hand-edited-header bug and readers report it as a parse failure at a byte offset rather than as the header problem it is.
  • Silence. The conversion succeeds, the file opens, and the classification field is gone. No tool reports this because no tool was asked to preserve something the target format has no place for.

What each format actually stores

LAS, specified by ASPRS, is the survey format and the only one of the three designed for georeferenced data. Coordinates are stored as three int32 values plus a per-file scale factor and offset in the header, so the real coordinate is X × x_scale + x_offset. The point data record format decides the fields and the record length: format 0 is 20 bytes, format 1 is 28 (adding an 8-byte GPS time), format 2 is 26 (adding three 16-bit colour channels), format 3 is 34 (both). LAS 1.4 added formats 6 through 10 on a 30-byte core in which GPS time is mandatory, classification widened from 5 bits to a full byte, and the scan angle became a 16-bit value at 0.006° resolution rather than a signed byte of whole degrees. The ASPRS LAS 1.4 R15 specification defines all eleven record formats and the header fields. The coordinate reference system lives in variable length records, and arbitrary extra per-point fields are possible through the Extra Bytes VLR.

PLY is a generic geometry container. The header begins with ply, then a format line, then element and property declarations, and ends with end_header. Properties take scalar types char, uchar, short, ushort, int, uint, float and double, and faces use the list form property list uchar int vertex_index. It is the only one of the three that stores connectivity, so it is the format a mesh goes into. It has no concept of a coordinate reference system, no scale-and-offset mechanism, and no standard field names beyond convention — x y z, nx ny nz, and red green blue as uchar.

PCD is the Point Cloud Library’s own format. Its header is a fixed sequence: VERSION, FIELDS, SIZE, TYPE, COUNT, WIDTH, HEIGHT, VIEWPOINT, POINTS, DATA. TYPE takes exactly three letters — I for signed integers, U for unsigned, F for floating point — with SIZE giving the width in bytes, so a 32-bit float is SIZE 4 TYPE F. DATA is ascii, binary or binary_compressed, and HEIGHT greater than 1 marks an organised cloud with image-like row structure. The Point Cloud Library documents the header fields and the version 0.7 layout. Like PLY, it has no place for a CRS.

The half-metre you lose silently

This is the one that destroys data without any error. LAS stores coordinates as scaled integers, so a UTM northing is held exactly at the file’s scale factor. PLY’s property float x and PCD’s SIZE 4 TYPE F are IEEE-754 binary32, which has a 24-bit significand — and the gap between representable values grows with the magnitude of the number.

for a value v with 2^e <= v < 2^(e+1), the spacing
between adjacent binary32 values is 2^(e-23)

a UTM easting of 512,345.678 m
  2^18 = 262,144 <= v < 524,288 = 2^19,  so e = 18
  spacing = 2^(18-23) = 2^-5 = 0.03125 m
  worst-case rounding error = 15.6 mm

a UTM northing of 5,412,345.678 m
  2^22 = 4,194,304 <= v < 8,388,608 = 2^23,  so e = 22
  spacing = 2^(22-23) = 2^-1 = 0.5 m
  worst-case rounding error = 250 mm

your scanner measured to 2 mm. the northing is now on a
half-metre grid, and no tool reported anything.

The symptom is unmistakable once you know it: the cloud looks striped, quantised into planes perpendicular to the north axis, and adjacent points that should be millimetres apart share an identical coordinate. People usually blame the scanner.

There are two correct fixes and one wrong one. Subtract an origin before writing — take the minimum corner of the bounding box, subtract it from every coordinate so values are in the range 0 to a few thousand metres, and record the origin in a sidecar file or in the PLY comment header. At a magnitude of 1,000 m the binary32 spacing is 2^(9-23) = 0.061 mm, which is far below any scanner’s noise. Or use property double x in PLY and SIZE 8 TYPE F in PCD, which works and doubles the coordinate storage. The wrong fix is to hope, which is what happens by default.

The same arithmetic applies anywhere a large coordinate meets a 32-bit float, including GPU vertex buffers, WebGL viewers and most game engines. A georeferenced cloud rendered directly in a viewer that uploads float32 positions will show this quantisation regardless of what the file on disk contains.

The field mapping

  • Classification. LAS carries ASPRS-standard codes — 2 for ground, 6 for building, and so on — in a dedicated field. PLY and PCD have no equivalent, so it must be written as a custom property (property uchar classification) or PCD field (FIELDS x y z classification with SIZE 4 4 4 1 and TYPE F F F U). Any generic reader will ignore it, but it survives a round trip through a tool that preserves unknown fields.
  • Colour depth. LAS stores RGB as three uint16 channels; PLY convention is three uchar. A blind copy gives a black cloud, and a blind divide by 257 gives a wrong one when the writer stored 8-bit values in the 16-bit field without scaling. Check the actual maximum in the file before deciding the scale factor — if it never exceeds 255, the data is 8-bit.
  • PCD’s packed RGB. PCL conventionally stores colour in a single field declared SIZE 4 TYPE F whose bits are actually a packed 24-bit integer reinterpreted as a float. Reading it as a number produces meaningless values, sometimes enormous ones. This is a genuine quirk of the format and not a corrupt file.
  • GPS time and return structure. Time, return number, number of returns, scan angle and point source ID have no home in either target format. Losing per-point time removes the ability to deskew a sweep, which matters for anything mobile — see the timing section of the fusion page.
  • Coordinate reference system. Only LAS holds it. Emit a sidecar .prj with the WKT alongside any PLY or PCD you export from georeferenced data, and treat a cloud with no recorded CRS as unusable for anything that has to line up with something else.
  • Organised structure. A PCD with HEIGHT greater than 1 has row-and-column structure, with NaN entries where there was no return. Converting to LAS or PLY flattens it and drops the NaNs, which is fine unless something downstream expected an image-shaped cloud.

A conversion that keeps what matters

import numpy as np
import laspy

las = laspy.read("survey.las")

print("version:", las.header.version)
print("point format:", las.header.point_format.id)
print("scales:", las.header.scales)
print("offsets:", las.header.offsets)

# scaled doubles, not the raw int32 dimensions
xyz = np.column_stack([las.x, las.y, las.z])

# choose an origin so float32 is safe downstream
origin = np.floor(xyz.min(axis=0))
local = (xyz - origin).astype(np.float32)
print("origin (write this down):", origin)
print("local max:", local.max(axis=0))

# colour, scaled only if the file really is 16-bit
if "red" in las.point_format.dimension_names:
    rgb = np.column_stack([las.red, las.green, las.blue])
    scale = 257.0 if rgb.max() > 255 else 1.0
    rgb8 = (rgb / scale).astype(np.uint8)
else:
    rgb8 = None

cls = np.asarray(las.classification, dtype=np.uint8)

# write an ascii PLY with the extra property declared
with open("survey.ply", "w") as f:
    f.write("ply\n")
    f.write("format ascii 1.0\n")
    f.write("comment origin_x %.6f\n" % origin[0])
    f.write("comment origin_y %.6f\n" % origin[1])
    f.write("comment origin_z %.6f\n" % origin[2])
    f.write("element vertex %d\n" % len(local))
    f.write("property float x\n")
    f.write("property float y\n")
    f.write("property float z\n")
    f.write("property uchar classification\n")
    f.write("end_header\n")
    for i in range(len(local)):
        f.write("%.4f %.4f %.4f %d\n" % (
            local[i, 0], local[i, 1], local[i, 2], cls[i]))

The ASCII writer above is for clarity, not for 500 million points — switch format ascii 1.0 for format binary_little_endian 1.0 and write the rows with a NumPy structured array once the mapping is confirmed. The three origin comments are the important part: they are the only record of where the cloud actually is, and a PLY without them is a shape rather than a survey.

Verifying the conversion

  1. Compare point counts exactly. A difference means a filter ran that you did not ask for — duplicate merging, NaN dropping, or a voxel step somebody left enabled.
  2. Compare the bounding box to the millimetre, after adding your origin back. A shift means the offset was applied twice or not at all; a shrink means points were dropped.
  3. Compare the histogram of any categorical field. The count of points per classification code should be identical before and after. This catches the silent-drop case, which no other check does.
  4. Check the nearest-neighbour spacing distribution. If the median spacing jumped, or the distribution became discrete at suspicious values like 0.5 m, you have hit the binary32 quantisation above. The gate script on the quality check page computes exactly this number and is worth running on both files.
  5. Round-trip one file. Convert back and diff against the original. Anything that does not survive the round trip is something the intermediate format could not hold, and now you know what it is rather than discovering it in six months.