A web-based 3D point cloud viewer and analysis tool.
Load, visualize, and analyze LAS/LAZ point cloud files directly in your browser — no plugins, no desktop app required.
Drag & Drop Loading
Post-Processing Filters (EDL / SSAO)
View Controls & Color Modes
ICP Registration
3D Gaussian Splatting Viewer
- GPU-Accelerated 3D Rendering — Powered by Three.js with custom WebGL shaders
- COPC Octree Streaming — Large maps stream view-dependent LOD from a COPC octree instead of loading whole (see COPC Streaming)
- Color Modes — Intensity, Height (turbo colormap), RGB, Classification (ASPRS palette + legend)
- Post-Processing — Eye-Dome Lighting (EDL), Screen-Space Ambient Occlusion (SSAO)
- Measurement Tools — Distance measurement, polygon selection, point info
- Map Comparison — Overlay two point clouds with transform controls (offset + rotation)
- ICP Registration — Iterative Closest Point alignment with initial pose support
- 3D Gaussian Splatting — View 3DGS
.plyand.splatfiles with real-time depth-sorted splatting (auto-detected) - Large Coordinate Support — UTM / survey coordinates handled with automatic float64 centering to prevent precision loss
- Clipping Planes — X/Y/Z axis clipping
- Camera Bookmarks — Save and restore camera positions
- Live Streaming — View a ROS 2
PointCloud2topic in real time, with the robot pose and trajectory (see Live Streaming)
- Statistics — Point count, bounding box, density, height distribution histogram
- SOR Filter — Statistical Outlier Removal (k-nearest neighbors)
- Cross Section — Extract slices along any axis
- Volume Estimation — 2.5D grid-based volume computation
- C2C Distance — Cloud-to-Cloud distance between two point clouds
- Drag & Drop — Drop point cloud files directly into the viewer
- File Management — Browse, rename, delete saved point clouds
- Screenshot — Export the current view as PNG
- Dark / Light Theme
- Responsive UI — Works on desktop and tablets
Requires Python 3.10 – 3.13.
git clone https://github.com/warhammer50K/WebPointCloud.git
cd WebPointCloud
python3 app.pyThe first run creates .venv, installs requirements.txt into it, and re-execs there; later runs skip straight to the server. ./run.sh does the same thing from the shell side, and activating the venv yourself still works:
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python app.pyThe virtual environment is not just a nicety — Ubuntu 24.04, Debian 12, and recent Fedora ship a PEP 668 marked interpreter, so installing into the system Python fails outright with error: externally-managed-environment. That is why app.py builds one instead of asking pip to install anywhere. Set WPC_NO_BOOTSTRAP=1 to turn the bootstrap off and manage the environment entirely yourself.
Open http://localhost:6001 in your browser and drag & drop a point cloud file. A sample is included at sample/building_scan.las (100k points) — drag it in to try the viewer out.
For clouds you keep around, copy them into the maps directory and pick them from Load Map instead. New installs use ~/webpointcloud/maps; if you already have a ~/maps, that keeps being used. The startup banner prints the path it actually chose:
Maps dir : /home/you/webpointcloud/maps
Both layouts show up in the list, so either works:
~/webpointcloud/maps/
├── scan.las ← a file on its own
└── warehouse_run/ ← or a folder holding one or more clouds
├── map.las
└── map.copc.laz (the streaming conversion, built on first load)
Already have a collection somewhere else? Point the app at it — nothing is copied or moved:
WPC_MAPS_DIR=~/maps python3 app.py| Format | Extension | Notes |
|---|---|---|
| LAS | .las |
ASPRS LAS 1.2 - 1.4 |
| LAZ | .laz |
Compressed LAS (via lazrs) |
| COPC | .copc.laz |
Cloud Optimized Point Cloud — streamed with octree LOD |
| PLY | .ply |
ASCII and binary (little/big endian) |
| XYZ | .xyz .txt .csv |
Whitespace or comma delimited |
| PCD | .pcd |
Point Cloud Library format (ASCII and binary) |
| PTS | .pts |
Leica / common scanner ASCII format |
| 3DGS PLY | .ply |
3D Gaussian Splatting (auto-detected) |
| Splat | .splat |
Compact 3DGS binary format |
Point clouds from surveying or GIS workflows often use UTM or other projected coordinate systems with large absolute values (e.g., X=712345, Y=7034567). Storing these directly in float32 causes visible precision loss (jitter, staircase artifacts).
WebPointCloud handles this automatically:
- Read in float64 — raw coordinates are loaded at full double precision
- Compute center offset — bounding-box midpoint is extracted as a float64 offset
(ox, oy, oz) - Center & convert — coordinates are centered (
x - ox) in float64, then cast to float32 for GPU rendering - Reconstruct on display — point info, legend, and analysis results add the offset back to show original coordinates
When two clouds are loaded for comparison, the offset difference is applied automatically so they align correctly in the scene even if they come from different UTM zones or reference frames.
No configuration is required — just load the file and coordinates are preserved to sub-millimeter precision regardless of their absolute magnitude.
Small files are parsed and rendered whole. Large LAS/LAZ files (≥ 2M points by default) are automatically converted to COPC — a LAZ file with an internal octree — in the background on first load. Progress is shown in the loading overlay, and the result is written next to the source (map.copc.laz), so the conversion cost is paid only once; every later load streams the COPC directly.
COPC maps are never loaded whole. The viewer streams octree nodes on demand from the current camera view — coarse levels for the whole scene, finer levels only where the camera is looking — under a resident point budget (25M points by default). Chunks that fall out of view or budget are evicted and re-fetched later. This is why a multi-GB, hundred-million-point map opens in seconds and stays smooth while navigating, where parsing the raw LAS up front would take minutes and exhaust GPU memory.
Conversion tries three backends in order, so no extra setup is needed for the common case:
- untwine — used automatically if
untwineis onPATH. Out-of-core: it spills to a temp directory next to the output (needs about the source size free on disk) and keeps RAM bounded, so it is the only backend that handles clouds larger than RAM. Build it from hobuinc/untwine against an installed PDAL (cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build) and putbuild/bin/untwineonPATH. - PDAL (
pdal translate -w writers.copc) — used if PDAL >= 2.4 is onPATH. Holds the whole cloud in memory. - copclib — the bundled multiprocess octree builder in
tools/las_to_copc.py. Installed byrequirements.txt, so this path always works out of the box, but it also holds the whole cloud in memory (~3x the file size) and refuses files that would not fit in RAM.
If PDAL is killed by the OOM killer, conversion stops rather than falling through to copclib. If no backend can convert the file, the file falls back to the legacy whole-cloud load path.
.copc.laz files can also be produced offline with tools/las_to_copc.py <input.las> (multiprocess writer; see the WPC_COPC_* variables below).
Besides files, the viewer can render a live point cloud pushed to it while it is running — a lidar scan, a SLAM map being built, anything published as sensor_msgs/PointCloud2.
# on the robot (or anywhere with the topic visible)
source /opt/ros/humble/setup.bash
python3 tools/ros2_bridge.py --topic /velodyne_pointsThen open the viewer, expand Live Stream in the sidebar, and press Connect. The panel lists the active streams with their rate and point count.
ros2_bridge.py ──HTTP POST──▶ /api/live/publish ──polled──▶ browser
(needs rclpy) (Flask, no ROS) (Three.js)
The bridge is the only piece that needs ROS. It decodes PointCloud2, thins the cloud to a rendering budget, and POSTs plain float32 frames; the server holds the newest frame per stream and hands it to whichever browsers ask. So the machine running app.py never needs a ROS install, and the bridge can run on the robot while the viewer runs on a laptop — point --url at it.
| Flag | Default | What it is for |
|---|---|---|
--topic |
(required) | The PointCloud2 topic to subscribe to |
--url |
http://localhost:6001 |
Where app.py is listening |
--layer |
cur |
cur/raw/map replace the cloud each frame; kfrm accumulates frames into one growing map |
--rate |
10 |
Max frames published per second (0 = every frame) |
--max-points |
400000 |
Thin each frame to this many points |
--voxel |
0 |
Voxel size in metres to thin by first — much better than plain thinning for dense map clouds |
--qos |
best-effort |
Must match the publisher. See below |
--transient-local |
off | For latched topics that publish a map once |
--odom / --pose |
— | nav_msgs/Odometry or geometry_msgs/PoseStamped topic to drive the pose marker and trajectory |
--intensity-max |
0 (auto) |
Intensity value that maps to full brightness |
--compress |
1 |
zlib level for the wire payload (0 = off) |
--token |
— | Required when the server sets WPC_LIVE_TOKEN |
If nothing shows up, check QoS first. A RELIABLE subscriber receives nothing at all from a BEST_EFFORT publisher — no error, no warning — and most sensor drivers publish best-effort. That is why the default here is best-effort; a latched map topic may need --qos reliable --transient-local instead. ros2 topic info -v <topic> shows what the publisher offers.
The relay knows nothing about ROS, so anything that can POST can feed it:
POST /api/live/publish?stream=<name>&layer=cur&points=<n>&compressed=0
body: n × 7 little-endian float32 — x, y, z, intensity, r, g, b
Intensity and RGB are 0..1 (the shaders read them as normalized values), and coordinates are float32 metres in whatever frame you choose. With compressed=1 the body is a 4-byte little-endian original length followed by a zlib stream of the same data. Poses go to /api/live/pose as {"matrix": [16 column-major floats]}.
- Frames are not queued for
cur/raw/map: only the newest is held, so a browser that cannot keep up skips frames rather than falling behind. Thekfrmlayer does queue (bounded byWPC_LIVE_QUEUE_MB), because a dropped frame there is a permanent hole in the accumulated map. - Live layers coexist with a loaded file, but the analysis tools operate on the file cloud, not on streamed points.
- The transport is HTTP polling, not WebSocket — sized for a LAN, where a poll costs a couple of milliseconds.
- A publisher that goes quiet for
WPC_LIVE_STALE_SEC(15 s) is dropped, so a stale cloud does not sit on screen pretending to be live.
LAS/LAZ point classification (ground, vegetation, building, water, …) is carried through the whole pipeline — direct loads, COPC conversion, and streaming — and can be used as a color mode:
- Load a classified LAS/LAZ/COPC file
- Pick Color > Classification in the sidebar
Points are colored with the standard ASPRS palette (2 Ground = brown, 3-5 Vegetation = greens, 6 Building = red, 9 Water = blue, …), the legend shows a swatch per class, and the Point Info tool reports the class number (C:) of the hovered point. Classification survives polygon delete / undo / downsampling.
Notes:
- Files whose points are all class 0 (typical for SLAM-generated maps) render uniformly gray in this mode — the data simply carries no classification.
map.copc.lazfiles converted before classification support (2026-07) were written without the classification field. Delete the.copc.lazand reload the map to re-convert it with classes preserved.
| Environment Variable | Default | Description |
|---|---|---|
WEB_PORT |
6001 |
HTTP server port |
WPC_DATA_DIR |
~/webpointcloud |
Everything the app owns: maps, logs, caches, session secret |
WPC_MAPS_DIR |
~/maps if it exists, else $WPC_DATA_DIR/maps |
Where point clouds are read from — set it to use a collection you already have |
FLASK_DEBUG |
0 |
Enable Flask debug mode (forces the bind address to 127.0.0.1 — see Security) |
WPC_ALLOW_REMOTE_DEBUG |
0 |
Set to 1 to let debug mode bind 0.0.0.0. Exposes the Werkzeug debugger — remote code execution for anyone who can reach the port. |
FLASK_SECRET_KEY |
auto-generated | Flask session secret key |
WPC_PUBLIC |
0 |
Set to 1 to disable the IP whitelist (see Security) |
WPC_MAX_UPLOAD_MB |
5120 |
Maximum upload size in MB |
WPC_COPC_MIN_POINTS |
2000000 |
Point count above which LAS/LAZ auto-converts to COPC streaming |
WPC_COPC_BUDGET |
25000000 |
Resident point budget for COPC streaming (~28 B/point on GPU) |
WPC_COPC_WRITE_PROCS |
8 |
COPC converter: parallel write processes |
WPC_COPC_MIN_NODE |
2000 |
COPC converter: collapse octree leaves below this point count |
WPC_COPC_THREADS |
CPU count | COPC converter: threads for untwine / PDAL writers.copc |
WPC_COPC_PARALLEL_MIN |
2000000 |
COPC converter: point count below which the build stays single-process |
WPC_COPC_SPLIT_DEPTH |
3 |
COPC converter: octree depth at which subtrees fan out to worker processes |
WPC_LIVE_TOKEN |
(unset) | Shared secret for live publishing; required when WPC_PUBLIC=1 |
WPC_LIVE_MAX_FRAME_MB |
32 |
Maximum size of one live frame |
WPC_LIVE_QUEUE_MB |
64 |
Backlog held for an accumulating (kfrm) live stream |
WPC_LIVE_MAX_STREAMS |
8 |
Maximum concurrent live publishers |
WPC_LIVE_STALE_SEC |
15 |
Silence after which a live publisher is considered gone |
Existing installs and forks keep working unchanged — nothing is moved, renamed, or deleted. The defaults used to spread four directories across $HOME (~/maps, ~/webpointcloud, ~/.webpointcloud, ~/.mapper_secret_key), and a fresh install now puts everything under WPC_DATA_DIR instead. The old locations still win where they already exist:
- Maps. An existing
~/mapsstays the default, so your clouds are where they were. Only a machine that has never had one gets$WPC_DATA_DIR/maps. Either way the startup banner prints the path in use. - Session secret. An existing
~/.mapper_secret_keyis read where it is and left alone; sessions are unaffected. New installs write$WPC_DATA_DIR/secret_keyinstead. To consolidate, move the file yourself and it will be picked up from the new path.
For forks, the only API change is additive: /api/maps entries gained an is_file flag and now include loose files as single-file entries. name, path and las_files keep their meaning, and path + las_files[0] still resolves to the cloud for both layouts.
WebPointCloud is built as a LAN / workstation tool, not as an internet-facing service. There is no user authentication — anyone allowed through the checks below can browse, upload, rename, and delete point clouds under the maps directory.
The server binds 0.0.0.0:6001 so other machines on your network can reach it, and two before_request checks guard it:
- IP whitelist — requests are accepted only from loopback and private ranges:
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10(CGNAT / Tailscale),::1,fe80::/10. Anything else gets403. - Rate limit — 60 requests per 60 seconds per IP, then
429./api/copc/*and/api/live/*are exempt, since octree streaming and live polling both run far above that rate; the IP whitelist still applies to them./static/skips both checks.
Two settings deliberately weaken this, and both are off by default:
| Setting | What it does | Use only when |
|---|---|---|
WPC_PUBLIC=1 |
Removes the IP whitelist entirely — every source IP is accepted | The app sits behind a reverse proxy that terminates TLS and enforces authentication |
WPC_ALLOW_REMOTE_DEBUG=1 |
Lets FLASK_DEBUG=1 bind 0.0.0.0 instead of being forced to loopback |
Never on a shared network — the Werkzeug interactive debugger is remote code execution for anyone who can reach the port |
Also worth knowing:
python app.pyruns Flask's development server. For anything beyond a trusted LAN, put it behind a production WSGI server (gunicorn, uWSGI) and a reverse proxy.FLASK_SECRET_KEYis read from the environment; if unset, a random key is generated and persisted to$WPC_DATA_DIR/secret_keywith mode0600. Set it explicitly for any multi-instance or long-lived deployment.- Upload size is capped at
WPC_MAX_UPLOAD_MB(default 5 GB), and map filenames are constrained to paths inside the maps directory. - Live publishing writes into what every viewer sees. Behind the IP whitelist that is fine, but
WPC_PUBLIC=1removes that gate — so with it set,/api/live/publishrefuses everything unlessWPC_LIVE_TOKENis configured and sent asX-Live-Token. Reading a live stream never needs a token.
pip install -r requirements-dev.txt # requirements.txt + pytest
pytestThe suite covers the maps listing / rename / delete endpoints and path-traversal handling in api.py, rotation math, the rate limiter, 3DGS parsing / binary packing in pointcloud_io.py, the live relay's publish/poll contract in live.py, and PointCloud2 decoding in tools/ros2_bridge.py. No server, sample data, or ROS install is required — the tests run standalone from the repository root.
tests/make_utm_test_data.py is a helper, not a test: run it to generate large-coordinate UTM sample files under sample/ for manually checking float64 centering in the viewer.
Browser ──── HTTP ────── Flask (Python) ◀──POST── ros2_bridge.py
│ │ (ROS 2 side, optional)
├─ Three.js 3D viewer ├─ REST API (/api/*)
├─ WebGL shaders ├─ LAS read/write (laspy)
├─ Analysis UI ├─ Analysis (numpy, scipy)
├─ Live stream client ├─ Live relay (/api/live/*)
└─ Web Workers └─ File management
| Library | License | Usage |
|---|---|---|
| Three.js | MIT | 3D rendering |
| pako | MIT + Zlib | Zlib decompression |
| Flask | BSD-3 | Web framework |
| laspy | BSD-2 | LAS file I/O |
| NumPy | BSD-3 | Numerical computing |
| SciPy | BSD-3 | Spatial algorithms (KDTree, SOR) |
| copc-lib | BSD-3 | COPC octree writing (copclib) |
| untwine (optional) | GPL-3 | Out-of-core COPC conversion backend when installed (run as a separate CLI) |
| PDAL (optional) | BSD-3 | COPC conversion backend when installed |
Contributions are welcome! Please open an issue or submit a pull request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/my-feature) - Commit your changes (
git commit -m 'Add my feature') - Push to the branch (
git push origin feature/my-feature) - Open a Pull Request
This project is licensed under the MIT License.





