Reading E57 and PLY into a Python Pipeline
The surveyor sends a terrestrial scan of the quarry face to merge with the drone data. It arrives as a 6 GB E57, and the first attempt to read it produces a cloud sitting at the origin with the scanner’s own coordinate frame, in millimetres, with no colour. The second attempt, after converting through a mesh tool to PLY, produces something positioned correctly and missing the intensity the surveyor said was there.
Neither format is broken. Both carry conventions that differ from LAS in ways that a naive read silently discards, and knowing which conventions they are makes the ingest routine rather than exploratory. This page covers bringing both into a pipeline built around LAS, as described in point cloud formats and interchange in Python.
What each format assumes
E57 is a container for one or more scans, each with its own local coordinate frame and a pose — a translation and a quaternion rotation — that places it in a shared frame. Reading the point data without applying the pose gives every scan stacked at the origin. There is also no requirement that the shared frame is georeferenced at all: many E57 files are in an arbitrary project frame, and the transformation to a real CRS lives in the surveyor’s notes rather than the file.
Units are a second trap. E57 nominally uses metres, but scanner exports commonly write millimetres and declare it in the metadata, which a reader that ignores metadata will not apply.
PLY is a mesh format that happens to store points. It has no concept of a coordinate reference system at all, stores coordinates as 32-bit floats by default — which quantises projected coordinates exactly as glTF does — and defines its own per-vertex properties with arbitrary names. A PLY from one tool calls the colour channels red, green, blue; from another, diffuse_red and friends.
Figure 1 — The first thing to check in any E57: whether the poses were applied, and what frame they place the scans in.
Minimal reproducible solution
import numpy as np
import pye57
def read_e57(path: str, *, scan_index: int | None = None) -> dict:
"""Read an E57 into arrays, applying scan poses and unit scaling.
pye57 applies the pose when `transform=True`, which is not the default in
every version — passing it explicitly is cheaper than debugging a cloud
stacked at the origin.
"""
f = pye57.E57(path)
count = f.scan_count
indices = range(count) if scan_index is None else [scan_index]
chunks, colours, intensities = [], [], []
for i in indices:
data = f.read_scan(i, intensity=True, colors=True,
row_column=False, transform=True)
xyz = np.column_stack([data["cartesianX"], data["cartesianY"],
data["cartesianZ"]])
chunks.append(xyz)
if "colorRed" in data:
colours.append(np.column_stack([data["colorRed"], data["colorGreen"],
data["colorBlue"]]))
if "intensity" in data:
intensities.append(np.asarray(data["intensity"]))
xyz = np.vstack(chunks)
header = f.get_header(indices[0] if indices else 0)
return {
"xyz": xyz,
"rgb": np.vstack(colours) if colours else None,
"intensity": np.concatenate(intensities) if intensities else None,
"scans": count,
# Reported so the caller can convert; E57 exports in millimetres exist.
"declared_units": getattr(header, "lengthUnit", "unknown"),
"bounds": (xyz.min(axis=0).tolist(), xyz.max(axis=0).tolist()),
}
Reporting the declared units and the bounds rather than silently converting is deliberate. A cloud whose bounds span 40,000 in each axis is either a 40 km survey or a 40 m scan in millimetres, and only the caller knows which is plausible for the job.
For PLY, the work is in normalising property names and promoting the coordinates out of float32.
import numpy as np
from plyfile import PlyData
COLOUR_ALIASES = {
"red": "red", "green": "green", "blue": "blue",
"diffuse_red": "red", "diffuse_green": "green", "diffuse_blue": "blue",
"r": "red", "g": "green", "b": "blue",
}
def read_ply(path: str) -> dict:
"""Read a PLY point cloud, normalising colour property names.
Coordinates are promoted to float64 immediately. PLY commonly stores them
as float32, which quantises a projected easting to a few centimetres, and
every operation after the read inherits that.
"""
ply = PlyData.read(path)
v = ply["vertex"].data
names = set(v.dtype.names)
xyz = np.column_stack([v["x"], v["y"], v["z"]]).astype(np.float64)
channels = {}
for src, canonical in COLOUR_ALIASES.items():
if src in names:
channels[canonical] = np.asarray(v[src])
rgb = (np.column_stack([channels["red"], channels["green"], channels["blue"]])
if {"red", "green", "blue"} <= set(channels) else None)
return {"xyz": xyz, "rgb": rgb,
"properties": sorted(names),
"coordinate_dtype": str(v["x"].dtype),
"precision_warning": ("float32 coordinates — up to a few centimetres "
"of quantisation on projected values"
if v["x"].dtype == np.float32 else None)}
Figure 3 — Conversion into LAS is lossy in a specific, enumerable way.
Edge-case matrix
| Input variant | Naive result | Correct handling |
|---|---|---|
| Multi-scan E57, poses unapplied | Scans stacked at the origin | Read with the transform applied |
| E57 in millimetres | Cloud 1000× too large | Read the declared unit and scale |
| E57 in an arbitrary project frame | Positioned wrongly, plausibly | Obtain the transformation separately |
| E57 with intensity but no colour | Grey cloud | Map intensity to a display channel if needed |
| PLY float32 coordinates | Centimetre quantisation | Promote to float64; ask for a re-export if precision matters |
PLY with diffuse_red naming |
No colour found | Normalise property aliases |
| PLY with 0–1 float colours | Black cloud after ×257 | Detect the range before scaling |
| Either format, no CRS | Opens at the origin downstream | Assign the CRS explicitly at ingest |
The 0–1 colour case is worth a guard, because it silently produces a black cloud through the LAS 16-bit conversion:
import numpy as np
def to_las_colour(rgb: np.ndarray) -> np.ndarray:
"""Normalise any common colour encoding to LAS's unsigned 16-bit range."""
arr = np.asarray(rgb)
peak = float(arr.max()) if arr.size else 0.0
if arr.dtype.kind == "f" and peak <= 1.0:
scaled = arr * 65535.0 # 0–1 floats
elif peak <= 255.0:
scaled = arr.astype(np.float64) * 257.0 # 8-bit
else:
scaled = arr.astype(np.float64) # already 16-bit
return np.clip(scaled, 0, 65535).astype(np.uint16)
Verification snippet
import numpy as np
def sanity_check_import(xyz: np.ndarray, expected_extent_m: float,
expected_centre: tuple[float, float] | None = None,
tol_factor: float = 5.0) -> dict:
"""Does the imported cloud sit where and at the scale the job expects?"""
lo, hi = xyz.min(axis=0), xyz.max(axis=0)
extent = float(np.max(hi - lo))
problems = []
ratio = extent / expected_extent_m
if ratio > tol_factor:
problems.append(f"extent {extent:.0f} is {ratio:.0f}× the expected "
f"{expected_extent_m:.0f} m — units are probably millimetres")
if ratio < 1 / tol_factor:
problems.append(f"extent {extent:.1f} is far smaller than expected — "
"scan poses may not have been applied")
if expected_centre is not None:
centre = ((lo + hi) / 2)[:2]
offset = float(np.hypot(*(centre - np.asarray(expected_centre))))
if offset > 10 * expected_extent_m:
problems.append(f"centre is {offset:.0f} m from the expected position — "
"wrong coordinate frame")
return {"extent_m": extent, "bounds": [lo.tolist(), hi.tolist()],
"problems": problems}
The extent ratio check catches both the millimetre-units and the unapplied-poses cases with one comparison, and it needs nothing beyond a rough idea of how big the site is — which the job definition always has.
Figure 2 — The ingest gate for foreign clouds. Everything it catches produces plausible output rather than a crash.
Merging a terrestrial scan with drone data
Bringing the two together is a registration problem, not a format one, and it is worth separating the two steps explicitly. Import and normalise the scan first, verify it independently, and only then register it against the drone cloud using the stable-ground approach in aligning two epochs with ICP before differencing.
Two differences from an epoch-to-epoch registration matter. A terrestrial scan is usually far denser and more accurate than the drone cloud in its own small area, so the scan should be the reference and the drone data the moving set — the opposite of the instinct to “fit the new data to the existing model”. And the overlap between them is often a thin band where the drone saw the top of a face and the scanner saw its front, which is poorly conditioned for a rigid fit; adding targets visible to both instruments is far more reliable than relying on surface geometry.
Record the resulting transform with the merged product. A combined cloud whose provenance is “these two files, registered with this transform, on this date” can be rebuilt; one that exists only as a merged file cannot be corrected when either input is revised.
When to escalate
- The E57 is in an arbitrary project frame with no georeferencing. No amount of reading fixes that. The surveyor holds the transformation, and guessing it from surface fitting introduces error nobody can quantify later.
- PLY float32 coordinates and the job needs millimetres. Ask for a re-export in a format that carries double precision. The quantisation is already in the file.
- Colour or intensity is missing entirely. Check whether the export omitted it rather than assuming the scan lacks it; most scanner exports have an options dialog where these are off by default.