What Is PLY?
PLY — Polygon File Format, also known as the Stanford Triangle Format — is a 3D file format developed at Stanford University in the 1990s, primarily by Greg Turk during the Stanford 3D Scanning Repository project. While it can store polygon meshes similar to OBJ or STL, PLY's primary strength is its flexibility: it supports arbitrary per-vertex and per-face properties, making it the dominant format for 3D point clouds from LiDAR scanners, photogrammetry rigs, and research datasets.
PLY files use the .ply extension and come in three encodings:
- ASCII — Human-readable text, slow to parse for large datasets
- Binary little-endian — Compact binary, standard on x86/x64 systems
- Binary big-endian — Compact binary for big-endian architectures (SPARC, older MIPS)
PLY File Structure
A PLY file consists of a header followed by the element data. The header is always ASCII, even in binary files:
ply
format ascii 1.0
comment Scanned with Matterport Pro3
element vertex 8
property float x
property float y
property float z
property float nx
property float ny
property float nz
property uchar red
property uchar green
property uchar blue
element face 6
property list uchar int vertex_indices
end_header
0.0 0.0 0.0 0.0 0.0 -1.0 255 0 0
1.0 0.0 0.0 0.0 0.0 -1.0 255 0 0
1.0 1.0 0.0 0.0 0.0 -1.0 0 255 0
0.0 1.0 0.0 0.0 0.0 -1.0 0 255 0
0.0 0.0 1.0 0.0 0.0 1.0 0 0 255
1.0 0.0 1.0 0.0 0.0 1.0 0 0 255
1.0 1.0 1.0 0.0 0.0 1.0 255 255 0
0.0 1.0 1.0 0.0 0.0 1.0 255 255 0
4 0 1 2 3
4 4 5 6 7
4 0 1 5 4
4 2 3 7 6
4 0 3 7 4
4 1 2 6 5
Header Syntax
ply # magic identifier (required)
format [ascii|binary_little_endian|binary_big_endian] 1.0
comment [optional comment text]
element [name] [count] # declares an element type
property [type] [name] # scalar property
property list [count_type] [data_type] [name] # list property
end_header
Supported data types: char, uchar, short, ushort, int, uint, float, double
Common Vertex Properties
| Property | Type | Description |
|---|---|---|
x, y, z |
float | 3D coordinates |
nx, ny, nz |
float | Surface normals |
red, green, blue |
uchar | RGB color (0–255) |
alpha |
uchar | Alpha transparency |
intensity |
float | Grayscale intensity (LiDAR reflectance) |
confidence |
float | Per-point confidence/quality score |
range |
float | LiDAR range measurement |
Custom Properties
One of PLY's unique strengths is arbitrary custom properties:
ply
format binary_little_endian 1.0
comment SLAM localization point cloud
element vertex 1000000
property float x
property float y
property float z
property float intensity
property uint scan_id
property float timestamp
property uchar classification
end_header
[binary data follows]
Point Clouds vs Meshes in PLY
PLY is used for two distinct data types:
Point clouds — Millions of unconnected 3D points with optional color and intensity. Produced by LiDAR scanners (FARO, Leica, Velodyne), photogrammetry (RealityCapture, Metashape), and depth cameras (Intel RealSense, Azure Kinect). The face element is absent — only vertex.
Polygon meshes — Connected mesh where face elements list the vertex indices for each polygon. Used for 3D models, architectural scans, and reconstructed surfaces.
Working with PLY in Python
plyfile: Precise Binary/ASCII PLY I/O
pip install plyfile
import numpy as np
from plyfile import PlyData, PlyElement
# ── Reading a PLY file ───────────────────────────────────────────────────
def read_point_cloud(filepath: str) -> dict:
"""Load a PLY point cloud and return arrays of coordinates and colors."""
ply = PlyData.read(filepath)
vertices = ply['vertex']
xyz = np.column_stack([
vertices['x'], vertices['y'], vertices['z']
]).astype(np.float32)
data = {'xyz': xyz}
# Optional: colors
if 'red' in vertices.data.dtype.names:
rgb = np.column_stack([
vertices['red'], vertices['green'], vertices['blue']
]).astype(np.uint8)
data['rgb'] = rgb
# Optional: normals
if 'nx' in vertices.data.dtype.names:
normals = np.column_stack([
vertices['nx'], vertices['ny'], vertices['nz']
]).astype(np.float32)
data['normals'] = normals
print(f"Loaded {len(xyz):,} points")
print(f"Bounding box: {xyz.min(axis=0)} → {xyz.max(axis=0)}")
return data
cloud = read_point_cloud('scan.ply')
# ── Reading a PLY mesh ───────────────────────────────────────────────────
def read_ply_mesh(filepath: str) -> dict:
"""Load a PLY mesh and return vertex and face arrays."""
ply = PlyData.read(filepath)
vertices = ply['vertex']
xyz = np.column_stack([
vertices['x'], vertices['y'], vertices['z']
]).astype(np.float32)
# Faces stored as variable-length lists
faces_raw = ply['face']['vertex_indices']
# Convert to fixed triangle array (assumes triangles)
faces = np.vstack([list(f) for f in faces_raw]).astype(np.int32)
return {
'vertices': xyz,
'faces': faces,
'vertex_count': len(xyz),
'face_count': len(faces),
}
mesh = read_ply_mesh('model.ply')
print(f"Mesh: {mesh['vertex_count']} vertices, {mesh['face_count']} triangles")
Writing PLY Files
import numpy as np
from plyfile import PlyData, PlyElement
def write_point_cloud_ply(
points: np.ndarray, # shape (N, 3) float32
colors: np.ndarray, # shape (N, 3) uint8, optional
filepath: str,
binary: bool = True,
) -> None:
"""Write a point cloud to PLY format."""
n = len(points)
if colors is not None:
vertex_data = np.array(
[(points[i, 0], points[i, 1], points[i, 2],
colors[i, 0], colors[i, 1], colors[i, 2])
for i in range(n)],
dtype=[('x', 'f4'), ('y', 'f4'), ('z', 'f4'),
('red', 'u1'), ('green', 'u1'), ('blue', 'u1')]
)
else:
vertex_data = np.array(
[(points[i, 0], points[i, 1], points[i, 2]) for i in range(n)],
dtype=[('x', 'f4'), ('y', 'f4'), ('z', 'f4')]
)
vertex_element = PlyElement.describe(vertex_data, 'vertex')
ply = PlyData([vertex_element],
text=not binary,
byte_order='<')
ply.write(filepath)
print(f"Saved {n:,} points to {filepath}")
# Create test point cloud (random sphere surface)
rng = np.random.default_rng(42)
phi = rng.uniform(0, 2 * np.pi, 10000)
theta = np.arccos(rng.uniform(-1, 1, 10000))
points = np.column_stack([
np.sin(theta) * np.cos(phi),
np.sin(theta) * np.sin(phi),
np.cos(theta)
]).astype(np.float32)
colors = (np.column_stack([
(points[:, 0] + 1) * 127,
(points[:, 1] + 1) * 127,
(points[:, 2] + 1) * 127,
])).astype(np.uint8)
write_point_cloud_ply(points, colors, 'sphere.ply')
Open3D: High-Performance Point Cloud Processing
pip install open3d
import open3d as o3d
import numpy as np
def process_point_cloud(filepath: str) -> dict:
"""Load and process a PLY point cloud with Open3D."""
# Load
pcd = o3d.io.read_point_cloud(filepath)
print(f"Loaded {len(pcd.points):,} points")
# Estimate normals (if not present)
if not pcd.has_normals():
pcd.estimate_normals(
search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30)
)
# Voxel downsampling (reduce density for performance)
pcd_down = pcd.voxel_down_sample(voxel_size=0.05)
print(f"After downsampling: {len(pcd_down.points):,} points")
# Statistical outlier removal
pcd_clean, ind = pcd_down.remove_statistical_outlier(nb_neighbors=20, std_ratio=2.0)
outliers = len(pcd_down.points) - len(pcd_clean.points)
print(f"Removed {outliers} outlier points")
# Surface reconstruction (Poisson)
mesh, densities = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson(
pcd_clean, depth=9
)
# Remove low-density triangles (noisy outer hull)
vertices_to_remove = densities < np.quantile(densities, 0.01)
mesh.remove_vertices_by_mask(vertices_to_remove)
# Save results
o3d.io.write_point_cloud('cleaned.ply', pcd_clean)
o3d.io.write_triangle_mesh('reconstructed.ply', mesh)
o3d.io.write_triangle_mesh('reconstructed.obj', mesh)
return {
'original_points': len(pcd.points),
'cleaned_points': len(pcd_clean.points),
'mesh_triangles': len(mesh.triangles),
}
results = process_point_cloud('scan.ply')
PLY vs Other Point Cloud Formats
| Format | PLY | LAS/LAZ | E57 | XYZ/TXT |
|---|---|---|---|---|
| Primary use | Research, rendering | LiDAR, GIS | 3D scanning | Simple exchange |
| Custom properties | Yes | Limited | No | No |
| Color | Yes (RGB) | Yes (RGB + intensity) | Yes | Optional |
| Normals | Yes | No standard field | No | Optional |
| Compression | Binary only | LAZ = LZW compressed | Compressed | No |
| Mesh support | Yes | No | No | No |
| Python library | plyfile, open3d | laspy | pye57 | numpy.loadtxt |
| Max points | No limit (binary) | 2^32 (LAS 1.4) | No limit | Memory-limited |
| Widely supported | Yes (MeshLab, CloudCompare, Blender) | Yes (GIS tools) | Scanners | Universal |
PLY is the best choice when: sharing research data with flexible custom attributes, storing colored or RGB-colored point clouds, or combining point clouds with mesh data in a single file.
Related conversions
Frequent conversions across the catalogue: