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()