Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

27 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Point Cloud to 3D Tiles Pipeline

License: Apache-2.0

Convert laser scans into 3D Tiles you can stream into Unreal Engine through Cesium. Single files or whole directories.

A laser-scanned building streaming into Unreal Engine. The left panel is the observer view flying through the point cloud. The right panel shows the same scan from above with each octree tile coloured separately, popping in and out as the camera moves.

Hundreds of gigabytes of laser scan, streamed into Unreal in seconds. The right panel is the octree: only the tiles in view load, at the detail the camera needs.

Quickstart

One dependency: entwine2tiles. It does the octree and the tiles. Everything else is standard library Python.

  1. Get entwine2tiles, a fork of Entwine carrying the 3D Tiles writer that upstream removed in 2019. Pick whichever suits you:

    # conda, and the binary lands on PATH.
    # conda-forge is required: libpdal-core and libpdal-e57 come from there.
    conda install -c amerchant -c conda-forge entwine2tiles
    
    # or a container, if you would rather not install anything
    docker pull ghcr.io/arkounm/entwine2tiles:latest
    
    # or build it from source: see that repository's README

    It links PDAL, GDAL, PROJ, curl and OpenSSL, so a bare binary dropped on a release page would not run. Conda and the container are the two supported deliveries.

  2. Put it on your PATH, or set ENTWINE2TILES_EXE to its path, or edit the default in config.py. See Configuration.

  3. Check it resolved:

    python config.py
  4. Run it:

    python main.py -i input.laz -o output/ --longitude <lon> --latitude <lat> --height <height>

    Skip the geolocation flags and you still get a tileset, it will just sit in Ottawa (the author's test location) rather than where the scan was captured. It prints a warning if you do this.

  5. Load output/<basename>/tileset.json in Cesium for Unreal, or any 3D Tiles viewer.

Pipeline stages

  1. Octree: entwine2tiles build writes an EPT octree
  2. Tiles: entwine2tiles convert writes 3D Tiles 1.1 with glTF content
  3. Geolocation: an east-north-up transform places the tileset on the globe
  4. Gzip (on by default, disable with --no-gzip)

Stage 1 is nearly all of the wall-clock. On a 354 MB scan of 13.2 million points: 19s to build the octree, 1s to write 320 tiles, and the rest is noise.

Requirements

  • Python 3.9+ (--gzip/--no-gzip needs argparse.BooleanOptionalAction)
  • entwine2tiles, from conda or the container image

That is the whole list. Prior versions of this pipeline needed Entwine, PDAL, Blender, Node, 3d-tiles-tools, and LAStools, six separate installs, because the middle of the pipeline went through PLY and Blender to reach glTF.

Running the engine from the container

The pipeline shells out to an executable, so a wrapper script that forwards to docker run works as ENTWINE2TILES_EXE. Both the input and the output have to be visible inside the container at the same paths the pipeline passes, so mount them rather than relying on the working directory:

#!/bin/sh
# entwine2tiles-docker, chmod +x, then:
#   export ENTWINE2TILES_EXE=/path/to/entwine2tiles-docker
exec docker run --rm \
    -v /data:/data \
    -u "$(id -u):$(id -g)" \
    ghcr.io/arkounm/entwine2tiles:latest "$@"

Keep your scans and output under the mounted root. -u keeps the tiles owned by you rather than by root. Installing through conda avoids all of this, which is why it is listed first.

Input formats

Anything the engine's PDAL can read, including .las, .laz, .e57, .ply, .pcd, .pts, .ptx, and .bpf. The pipeline does not gatekeep formats, it passes the file to the engine and lets PDAL decide.

E57 needs an entwine2tiles built with PDAL's E57 reader, which on conda is the libpdal-e57 package. Without it the engine says so plainly. E57 no longer needs LAStools.

Formats with no bounds in their header (E57, PLY, PCD, PTS, PTX) get an extra analysis pass over every point, since otherwise the octree would be sized from nothing. That is automatic.

Configuration

The pipeline needs one executable. It resolves in this order:

  1. ENTWINE2TILES_EXE environment variable
  2. Auto-detection via PATH
  3. The hardcoded default in config.py
ENTWINE2TILES_EXE = r"...\entwine2tiles\build\app\entwine2tiles.exe"

# Only if the binary was built inside a conda environment and needs its
# libraries on PATH. Leave empty if entwine2tiles runs standalone.
ENTWINE2TILES_LIB_DIRS = [...]

ENTWINE_THREADS = 16
GEOMETRIC_ERROR_DIVISOR = 16.0
ROOT_ERROR_MULTIPLIER = 16.0

# Geolocation defaults (Ottawa; see the warning below)
DEFAULT_LONGITUDE = "-75.703833"
DEFAULT_LATITUDE = "45.417139"
DEFAULT_HEIGHT = "77.572"

Validate with:

python config.py

A note on the geolocation defaults

DEFAULT_LONGITUDE/DEFAULT_LATITUDE/DEFAULT_HEIGHT are the author's own Ottawa coordinates, a fallback so the pipeline does not hard-fail if you forget the flags. Without them the pipeline prints a loud warning and proceeds. The tileset will load and render fine, it will just be geographically wrong. Pass real coordinates for real data.

Usage

# Single file
python main.py -i input.laz -o output/
python main.py -i scan001.e57 -o output/

# A directory, sequentially, mixed formats allowed
python main.py -i scans/ -o output/

# A directory, in parallel
python main.py -i scans/ -o output/ --batch-workers 4

# Real coordinates, no compression
python main.py -i input.laz -o output/ \
    --longitude -75.7 --latitude 45.4 --height 100 --no-gzip

# Force colour off, or force it from intensity
python main.py -i input.laz -o output/ --color-type none
python main.py -i input.laz -o output/ --color-type intensity

# Keep the octree for inspection
python main.py -i input.laz -o output/ --temp

Dropping a reconverted scan into an existing level

If a scan has been converted before and the level is already built around where it sits, copy the old placement rather than rediscovering it:

python scripts/geolocate_tileset.py -i output/scan001 \
    --match old_output/scan001/tileset.json

That carries over whatever the reference tileset had, including any heading rotation, and adjusts for the fact that this pipeline writes content relative to the centre of the dataset while older ones wrote raw scan coordinates. Identical source coordinates end up in identical world positions.

Command line arguments

Argument Description Default
-i, --input Input point cloud, or a directory of them (required) -
-o, --output Output directory for 3D tiles (required) -
-t, --threads Thread count for both stages 16
-f, --force Rebuild the octree if it exists False
--batch-workers Files processed simultaneously, sharing --threads between them 1 (sequential)
--deep / --no-deep Read every point to find the bounds automatic by format
--longitude Longitude in degrees -75.703833 (warns if left at default)
--latitude Latitude in degrees 45.417139 (warns if left at default)
--height Height in metres 77.572 (warns if left at default)
--gzip / --no-gzip Compress the output --gzip
--temp Keep the octree False
--root-multiplier Root tile visibility distance multiplier 16.0
--error-divisor Root geometric error is the cube width over this 16.0
--color-type none, rgb, intensity, tile chosen from the data

The two geometric error knobs

Both default to 16.0, which reads like duplication and is not. They are passed straight to the engine and do different jobs:

  • --error-divisor sets the whole error curve. The root error is the width of the dataset cube over this value, and every level halves from there. 16 reproduces the curve the pre-v2 pipeline arrived at by hand, which is why the two numbers happen to match.
  • --root-multiplier inflates the root node only, leaving the rest of the curve alone. It exists for spawn distance, not for density.

Change --error-divisor if the whole tileset loads at the wrong density. Change --root-multiplier if only the root tile misbehaves at range.

The hierarchy itself comes from the engine. Earlier versions of this pipeline generated a flat tileset with 3d-tiles-tools and then restructured it into an octree in Python, computing geometric errors per tile; none of that code survives. One consequence is visible to the validator: the Python restructurer left 207 empty children: [] arrays, and the engine's output has none.

Colour

By default the engine uses RGB if the file has it, greyscale intensity if not, and no colour if the file has neither. LAS point format 1 files carry intensity but no RGB, so they come out greyscale. Override with --color-type.

Colour is written in linear space, which is what glTF requires, converted from the sRGB that scanners store. Skipping that conversion makes a tileset render noticeably brighter and flatter than the same scan converted elsewhere. Whether a file's 16-bit channels actually use the high byte is read from the data rather than guessed.

Project structure

pointcloud2Tiles/
├── main.py                       # Orchestration
├── config.py                     # Configuration
├── README.md
├── CHANGELOG.md
├── LICENSE                       # Apache-2.0
└── scripts/
    ├── entwine2tiles.py          # Steps 1 and 2: octree, then tiles
    ├── geolocate_tileset.py      # Step 3: place it on the globe
    └── compress.py               # Step 4: gzip

Output structure

output/
└── scan001/
    ├── tileset.json
    ├── 0-0-0-0.glb
    ├── 1-0-0-0.glb
    └── ...

Each input file gets its own subdirectory named after it, single file or batch. The octree is written to output/<name>/temp/ept and deleted after step 2 unless --temp is passed.

Tiles are 3D Tiles 1.1 with glTF content. Positions are written relative to the centre of the dataset and the root transform places that centre on the globe, which is what keeps 32-bit float coordinates precise. The dataset origin in source coordinates is recorded in asset.extras.entwine2tiles.origin.

Root tile visibility

When you spawn far from a point cloud (5k to 10k Unreal units), the root tile can disappear because its geometric error is too small for the camera distance. --root-multiplier scales the error of the root node only, so it stays loaded from farther away without affecting how children refine.

  • 8.0: close range only
  • 16.0 (default): most cases
  • 24.0: spawning 5k to 10k units away
  • 32.0: 10k+ units

Performance

The octree build dominates, and by a wider margin than it looks. Measured on one machine, 16 threads:

Scan Points Octree Tiles Octree share
3675028.las 13,222,984 17.05s 1.28s 91%
PA_UCS_NOROOF 266,055,966 536.97s 48.84s 91%

That is roughly 500k to 780k points per second for the octree and 5 to 10 million for the tiles. The tile writer is about 11x faster than the thing feeding it, so optimising it is not where the time is.

--batch-workers processes several files at once, and it is worth using. The thread budget is split between workers, not multiplied: --batch-workers 2 --threads 16 runs two engine processes with 8 threads each. Measured on five files, 2.2 GB:

Time
1 worker x 16 threads 133.09s
2 workers x 8 threads 105.35s

The same 16 threads, 21% faster, because the octree build has serial phases where threads sit idle and a second file fills them.

Disk: the octree is roughly the size of the input, and the tiles run about 19 bytes per point with colour, 12 without. Both exist at once until step 2 finishes.

Troubleshooting

"entwine2tiles not found" Set ENTWINE2TILES_EXE, or put the binary on PATH. Test with entwine2tiles help. There is no --version flag: it falls through to "Invalid app type", so help is the check that actually exercises the binary.

"Could not run entwine2tiles" The binary was found but could not load. If it was built inside a conda environment, its libraries need to be on PATH: see ENTWINE2TILES_LIB_DIRS in config.py, or activate that environment before running the pipeline.

E57 input fails with a reader error The engine was built without PDAL's E57 reader. Install libpdal-e57 into its environment and rebuild, or convert to LAZ first.

"The octree is empty: no point of ... landed inside the bounds" The analysis pass sized the octree from bounds the file declares but does not honour, so every real point fell outside it. Re-run with --deep, which reads every point instead of trusting the header. This is automatic for the formats known to need it, so seeing this means your file needs it and its extension is not on that list. The build before this check existed exited 0 with an empty output, which is why the check is there.

Tiles not loading in a viewer Check the coordinates match your data. A tileset can load successfully and still be in the wrong place. If your viewer wants uncompressed tiles, use --no-gzip.

Batch processing fails partway through The final summary lists which files failed. Run those individually to see the error, and reduce --batch-workers if it looks like resource exhaustion.

Credits

  • Entwine by Connor Manning and Hobu, Inc., which does the actual spatial work, and whose 2019 Cesium writer the fork restores
  • mesh2Tile, the photogrammetry sibling of this pipeline

License

Apache License 2.0. See LICENSE for the full text.

entwine2tiles is a separate repository under LGPL-2.1, because Entwine is. This pipeline invokes it as an executable and does not link against it.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages