E57 to Potree Data Conversion Sample Script#
This example converts E57 scans to LAZ files and can convert those files to Potree. Refer to Potree Data Preparation for Streaming.
Prerequisites#
Install the Python packages and PotreeConverter 2.x with BROTLI support:
pip install pye57 numpy "laspy[lazrs]"
Save the script as <base-folder>/scripts/converter/<name>.py. The
parents[2] default treats that path as two folders below the base folder.
The defaults assume Windows and look for PotreeConverter.exe. On Linux,
pass --converter-path /path/to/PotreeConverter.
Run a complete conversion with:
python <base-folder>/scripts/converter/<name>.py --base-folder <base-folder> --e57-file-name example.e57 --scale-factor 0.01 --encoding BROTLI
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: MIT
import argparse
import struct
import subprocess
from pathlib import Path
import laspy
import numpy as np
import pye57
# Default processing flags
DEFAULT_CONVERT_TO_POTREE = True
DEFAULT_SUBTRACT_OFFSET = True
DEFAULT_GENERATE_PAGE = False
# Default file paths / settings
# try to auto-detect base folder from script location
# example: DEFAULT_BASE_FOLDER = "C:/path/to/point/cloud/"
DEFAULT_BASE_FOLDER = str(Path(__file__).resolve().parents[2]).replace("\\", "/") + "/"
DEFAULT_E57_FILE_NAME = "example.e57"
DEFAULT_CONVERTER_RELATIVE_PATH = "scripts/converter/build/PotreeConverter_windows_x64/PotreeConverter.exe"
DEFAULT_SCALE_FACTOR = 0.01
DEFAULT_ENCODING_NAME = "BROTLI"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Convert E57 scans to LAZ and optionally Potree format."
)
parser.add_argument(
"--base-folder",
default=DEFAULT_BASE_FOLDER,
help="Project base folder containing e57/, laz/, and scripts/.",
)
parser.add_argument(
"--e57-file-name",
default=DEFAULT_E57_FILE_NAME,
help="Name of E57 file inside <base-folder>/e57/.",
)
parser.add_argument(
"--scale-factor",
type=float,
default=DEFAULT_SCALE_FACTOR,
help="LAS scale factor for XYZ coordinates.",
)
parser.add_argument(
"--encoding",
default=DEFAULT_ENCODING_NAME,
help="PotreeConverter encoding name.",
)
parser.add_argument(
"--converter-path",
default=None,
help="Optional full path to PotreeConverter executable. "
"Defaults to <base-folder>/scripts/converter/build/PotreeConverter_windows_x64/PotreeConverter.exe.",
)
parser.add_argument(
"--convert-to-potree",
dest="convert_to_potree",
action=argparse.BooleanOptionalAction,
default=DEFAULT_CONVERT_TO_POTREE,
help="Run Potree conversion after writing LAZ files. "
"Use --no-convert-to-potree to skip it.",
)
parser.add_argument(
"--subtract-offset",
action=argparse.BooleanOptionalAction,
default=DEFAULT_SUBTRACT_OFFSET,
help="Recenter points by subtracting the first scan's bounding-box center "
"(header offsets = 0). Use --no-subtract-offset to keep original coordinates.",
)
parser.add_argument(
"--generate-page",
action="store_true",
default=DEFAULT_GENERATE_PAGE,
help="Generate Potree HTML page via --generate-page.",
)
return parser.parse_args()
# convert E57 color values to LAS 16-bit color channels
def _pad_public_header_bbox_for_potree(laz_path: Path) -> None:
"""Widen LAS/LAZ legacy public-header bbox slightly on disk.
PotreeConverter errors if any decoded coordinate lies outside the header
bounding box. laspy/LASzip can leave max/min a few ULPs tighter than the
largest decoded float (see chunker_countsort_laszip "point outside bounding box").
Legacy doubles are x_max,x_min,y_max,y_min,z_max,z_min at byte offset 179
(LAS 1.2–1.4); scales are three doubles at offset 131.
"""
with laz_path.open("r+b") as f:
if f.read(4) != b"LASF":
return
f.seek(131)
scales = np.frombuffer(f.read(24), dtype="<f8").copy()
f.seek(179)
bbox = list(struct.unpack("<6d", f.read(48)))
pad = np.maximum(scales * 0.50000000000001, 1e-12)
for axis in range(3):
bbox[axis * 2] += float(pad[axis])
bbox[axis * 2 + 1] -= float(pad[axis])
f.seek(179)
f.write(struct.pack("<6d", *bbox))
def to_uint16_colors(rgb_values: np.ndarray) -> np.ndarray:
"""Normalize E57 color values into LAS 16-bit color channels."""
rgb = np.asarray(rgb_values, dtype=np.float64)
max_val = np.max(rgb) if rgb.size > 0 else 0.0
if max_val <= 1.0:
rgb = rgb * 65535.0
elif max_val <= 255.0:
rgb = rgb * 257.0
elif max_val > 65535.0:
rgb = (rgb / max_val) * 65535.0
rgb = np.clip(rgb, 0.0, 65535.0)
return rgb.astype(np.uint16)
def main() -> None:
# parse command line arguments & assemble file paths
args = parse_args()
base_folder = Path(args.base_folder)
e57_file_path = base_folder / "e57" / args.e57_file_name
# assemble converter executable path
if args.converter_path:
converter_exe = Path(args.converter_path)
else:
converter_exe = base_folder / DEFAULT_CONVERTER_RELATIVE_PATH
# load e57 file
print(f" - pye57: loading {e57_file_path} ...")
e57 = pye57.E57(str(e57_file_path))
# get number of scans
data3d = e57.image_file.root()["data3D"]
num_scans = len(data3d)
print(f" - e57: number of scans: {num_scans}")
if args.convert_to_potree and not converter_exe.exists():
raise FileNotFoundError(f"PotreeConverter not found: {converter_exe}")
# flag to compute offset once
update_center = True
points_center = [0.0, 0.0, 0.0]
(base_folder / "laz").mkdir(exist_ok=True)
for scan_index in range(num_scans):
print(f" - processing scan {scan_index + 1} of {num_scans} ...")
# assemble output file path
out_file_name = f"{Path(args.e57_file_name).stem}_scan_{scan_index}.laz"
out_file_path = base_folder / "laz" / out_file_name
# read specific scan
scan = e57.read_scan(
scan_index,
colors=True,
intensity=True,
ignore_missing_fields=True,
)
# show additional scan info
print(f" - scan: {scan_index}")
print(f" - scan keys: {scan.keys()}")
# load points from e57 file
x = np.asarray(scan["cartesianX"], dtype=np.float64)
y = np.asarray(scan["cartesianY"], dtype=np.float64)
z = np.asarray(scan["cartesianZ"], dtype=np.float64)
points = np.column_stack((x, y, z))
print(f" - loaded points: {len(points)}")
# skip invalid points if the field is present
valid_mask = None
if "cartesianInvalidState" in scan:
valid_mask = np.asarray(scan["cartesianInvalidState"]) == 0
points = points[valid_mask]
print(f" - valid points after filter: {len(points)}")
if points.shape[0] == 0:
print(f" - scan {scan_index}: no points, skipping")
continue
# compute points minimum and maximum
points_min = points.min(axis=0)
points_max = points.max(axis=0)
print(f" - points minimum [x y z]: {points_min}")
print(f" - points maximum [x y z]: {points_max}")
# choose LAS point format based on available color/intensity fields
scan_keys = set(scan.keys())
has_rgb = {"colorRed", "colorGreen", "colorBlue"}.issubset(scan_keys)
has_intensity = "intensity" in scan_keys
# Use LAS format 2 (RGB, no GPS time) instead of 3. Format 3 adds gps_time (double),
# which PotreeConverter carries into Potree2 metadata; Omniverse
# omni.pointcloud.streaming.potree does not support double attributes and logs errors.
if has_rgb or has_intensity:
point_format = 2
else:
point_format = 0
# assemble las header
header = laspy.LasHeader(point_format=point_format, version="1.2")
header.scales = np.array([args.scale_factor, args.scale_factor, args.scale_factor], dtype=np.float64)
if args.subtract_offset:
if update_center:
# data-level recentering: shift bounding-box center to origin and keep header offset zero.
# note: updated only once for first scan
points_center = (points_min + points_max) / 2.0
points_center = np.array([points_center[0], points_center[1], points_center[2]], dtype=np.float64)
update_center = False
print(f" - subtracting bounding-box center: {points_center}")
header.offsets = np.array([0.0, 0.0, 0.0], dtype=np.float64)
else:
print(f" - setting LAS header offsets to points_min: {points_min}")
header.offsets = np.array([points_min[0], points_min[1], points_min[2]], dtype=np.float64)
# create las data
las = laspy.LasData(header)
if args.subtract_offset:
# Center cloud at origin by subtracting bounding-box midpoint from all points.
las.x = points[:, 0] - points_center[0]
las.y = points[:, 1] - points_center[1]
las.z = points[:, 2] - points_center[2]
print(" - applied data-level centering to points")
else:
las.x = points[:, 0]
las.y = points[:, 1]
las.z = points[:, 2]
print(" - keeping original point coordinates")
if has_rgb:
rgb = np.column_stack((scan["colorRed"], scan["colorGreen"], scan["colorBlue"]))
if valid_mask is not None:
rgb = rgb[valid_mask]
rgb16 = to_uint16_colors(rgb)
las.red = rgb16[:, 0]
las.green = rgb16[:, 1]
las.blue = rgb16[:, 2]
print(" - exporting with RGB color data")
elif has_intensity:
# Some E57 files store grayscale intensity but no RGB fields.
intensity = np.asarray(scan["intensity"], dtype=np.float64)
if valid_mask is not None:
intensity = intensity[valid_mask]
# normalize intensity values
if intensity.size > 0:
min_i = np.min(intensity)
max_i = np.max(intensity)
if max_i > min_i:
intensity = (intensity - min_i) / (max_i - min_i)
else:
intensity = np.zeros_like(intensity)
# convert to 16-bit intensity
intensity16 = np.clip(intensity * 65535.0, 0.0, 65535.0).astype(np.uint16)
# assign to las data
las.red = intensity16
las.green = intensity16
las.blue = intensity16
print(" - no RGB fields; exporting grayscale from intensity")
else:
print(" ! no RGB/intensity fields found; exporting geometry only")
# write out laz file
# note: requires laspy[lazrs] compression backend to be installed (pip install "laspy[lazrs]")
print(f" - writing LAZ: {out_file_path}")
las.write(str(out_file_path))
_pad_public_header_bbox_for_potree(out_file_path)
print(" - done")
if args.convert_to_potree:
output_folder_name = out_file_name.replace(".laz", "")
output_path = base_folder / "scripts" / "converter" / "output" / output_folder_name
output_path.mkdir(parents=True, exist_ok=True)
if args.generate_page:
# create command with page generation
page_name = output_folder_name
potree_cmd = [
str(converter_exe),
str(out_file_path),
"-o",
str(output_path),
"--encoding",
args.encoding,
"--generate-page",
page_name,
]
else:
# create command without page generation
potree_cmd = [
str(converter_exe),
str(out_file_path),
"-o",
str(output_path),
"--encoding",
args.encoding,
]
print(" - converting to Potree format")
print(f" - command: {' '.join(potree_cmd)}")
subprocess.run(potree_cmd, check=True)
print(" - finished converting to Potree format")
if __name__ == "__main__":
main()