Decimating Meshes with PyMeshLab
This page reduces a 12-million-triangle photogrammetric mesh to a usable size with PyMeshLab — choosing between the quadric collapse variants, setting the parameters that actually change the result, preserving boundaries and UV seams, cleaning the input first so the decimation has something sane to work with, and producing a quality report rather than a guess.
Why you hit this
Photogrammetry and lidar meshing both produce far more triangles than any downstream use needs. A 12-million-triangle site mesh will not open in a browser, will not tile without a level-of-detail chain, and carries most of its detail in flat surfaces where it buys nothing — a car park is a plane described by 400,000 triangles.
PyMeshLab exposes MeshLab’s filter set from Python, which makes it the practical choice when the decimation has to run in a pipeline rather than in a GUI. The quality of the result depends almost entirely on three things: cleaning the mesh first, choosing the texture-aware variant when there are UVs, and pinning the boundary when the mesh is one tile of many.
Prerequisites
- Python 3.10+ with
pymeshlab>=2023.12andnumpy;trimeshfor the independent checks. - A mesh with faces and, for the textured path, UVs — the output of photogrammetry processing pipelines.
- Enough RAM for roughly 200 bytes per input triangle: about 2.5 GB for 12 million.
Step-by-Step
1. Load and report what you have
import json
from pathlib import Path
import numpy as np
import pymeshlab as ml
def describe(path):
ms = ml.MeshSet()
ms.load_new_mesh(str(path))
m = ms.current_mesh()
ms.apply_filter("compute_topological_measures")
measures = ms.get_geometric_measures()
return {
"file": Path(path).name,
"vertices": m.vertex_number(),
"faces": m.face_number(),
"has_uvs": m.has_wedge_tex_coord() or m.has_vertex_tex_coord(),
"has_vertex_colour": m.has_vertex_color(),
"bbox_m": [round(v, 2) for v in (measures["bbox"].max()
- measures["bbox"].min())],
"surface_area_m2": round(measures.get("surface_area", 0.0), 1),
"avg_edge_m": round(measures.get("avg_edge_length", 0.0), 4),
}
info = describe("input/site_mesh.ply")
print(json.dumps(info, indent=2))
print("triangles per m²:", round(info["faces"] / max(info["surface_area_m2"], 1), 1))
Triangles per square metre is the number that says how much reduction is available. A photogrammetric mesh at 400 triangles/m² has roughly 5 cm triangles, which is finer than most site documentation needs; reducing to 25/m² — 20 cm triangles — is a 16× reduction that is invisible at a normal viewing distance.
Whether the mesh carries wedge or vertex texture coordinates changes which filter to use, and the distinction matters: wedge UVs allow a vertex to have different coordinates per face, which is how texture seams are represented. A mesh with wedge UVs decimated by a vertex-UV-aware filter loses its seams.
2. Clean before decimating
def clean(ms, merge_threshold_ratio=0.0001, remove_small_components_ratio=0.001):
"""Decimation on a dirty mesh produces dirty results. This is not optional."""
before = {"v": ms.current_mesh().vertex_number(), "f": ms.current_mesh().face_number()}
steps = []
ms.apply_filter("meshing_remove_duplicate_vertices")
steps.append(("duplicate_vertices", ms.current_mesh().vertex_number()))
ms.apply_filter("meshing_remove_duplicate_faces")
steps.append(("duplicate_faces", ms.current_mesh().face_number()))
ms.apply_filter("meshing_remove_null_faces")
steps.append(("null_faces", ms.current_mesh().face_number()))
ms.apply_filter("meshing_remove_unreferenced_vertices")
steps.append(("unreferenced_vertices", ms.current_mesh().vertex_number()))
ms.apply_filter("meshing_merge_close_vertices",
threshold=ml.PercentageValue(merge_threshold_ratio * 100))
steps.append(("merge_close", ms.current_mesh().vertex_number()))
ms.apply_filter("meshing_remove_connected_component_by_diameter",
mincomponentdiag=ml.PercentageValue(
remove_small_components_ratio * 100),
removeunref=True)
steps.append(("small_components", ms.current_mesh().face_number()))
after = {"v": ms.current_mesh().vertex_number(), "f": ms.current_mesh().face_number()}
return {"before": before, "after": after, "steps": steps,
"removed_faces": before["f"] - after["f"],
"removed_vertices": before["v"] - after["v"]}
Duplicate and degenerate faces are what make quadric decimation produce spikes. The quadric error metric assumes a well-formed neighbourhood; a zero-area face has an undefined normal, so the quadric it contributes is garbage, and the collapse it permits moves a vertex somewhere arbitrary. One spike in a 100,000-triangle result is enough for someone to reject the whole mesh.
Removing small disconnected components matters for a different reason: photogrammetry produces thousands of floating fragments — birds, reflections, moving cars — each a few dozen triangles. They survive decimation because they have no edges worth collapsing, so a mesh reduced to 100,000 triangles can have 20,000 of them in floating debris.
PercentageValue is the PyMeshLab wrapper for parameters MeshLab expresses as a percentage of the bounding-box diagonal, and passing a bare float where one is expected is the most common API error with this library.
3. Choose the right collapse filter
FILTERS = {
"plain": "meshing_decimation_quadric_edge_collapse",
"textured": "meshing_decimation_quadric_edge_collapse_with_texture",
"clustering": "meshing_decimation_clustering",
}
def pick_filter(ms):
m = ms.current_mesh()
if m.has_wedge_tex_coord() or m.has_vertex_tex_coord():
return FILTERS["textured"], "mesh has UVs; texture-aware collapse preserves seams"
if m.face_number() > 20_000_000:
return FILTERS["clustering"], "too large for quadric; cluster first, then quadric"
return FILTERS["plain"], "no UVs; plain quadric collapse"
def decimate(ms, target_faces, preserve_boundary=True, preserve_normal=True,
planar_quadric=True, quality_threshold=0.3, boundary_weight=1.0):
filter_name, reason = pick_filter(ms)
before = ms.current_mesh().face_number()
params = dict(
targetfacenum=int(target_faces),
preserveboundary=preserve_boundary,
boundaryweight=float(boundary_weight),
preservenormal=preserve_normal,
optimalplacement=True,
planarquadric=planar_quadric,
qualitythr=float(quality_threshold),
autoclean=True,
)
if filter_name == FILTERS["plain"]:
params["preservetopology"] = False
params["qualityweight"] = False
ms.apply_filter(filter_name, **params)
after = ms.current_mesh().face_number()
return {"filter": filter_name, "reason": reason, "before": before, "after": after,
"ratio": round(after / max(before, 1), 4),
"hit_target": abs(after - target_faces) / max(target_faces, 1) < 0.05}
planarquadric=True is the parameter with the largest effect on a site mesh and the one most often left off. It adds an extra quadric term that resists collapsing across planar regions in a way that would move their boundary — so a car park collapses to a few large triangles that still have straight edges, rather than to a few large triangles with wobbly edges.
qualitythr controls how bad an aspect ratio the filter will accept in a resulting triangle. The default of 0.3 is reasonable; raising it towards 1.0 refuses slivers and stops short of the target face count, lowering it towards 0 hits the target with needle triangles that shade badly and break later processing.
preserveboundary with boundaryweight is the tile-mesh requirement. The weight multiplies the cost of collapsing a boundary edge, so a high value effectively pins the outline; 1.0 with preserveboundary=True pins it outright, which is what neighbouring tiles need.
4. Decimate in stages for large reductions
def staged_decimate(in_path, out_path, target_faces, stages=None,
preserve_boundary=True):
"""Large reductions in one step produce worse results than two or three."""
ms = ml.MeshSet()
ms.load_new_mesh(str(in_path))
clean_report = clean(ms)
start = ms.current_mesh().face_number()
ratio = target_faces / max(start, 1)
if stages is None:
stages = 1 if ratio > 0.25 else 2 if ratio > 0.04 else 3
history = []
for s in range(stages):
step_target = int(start * (ratio ** ((s + 1) / stages)))
step_target = max(step_target, target_faces, 4)
report = decimate(ms, step_target, preserve_boundary=preserve_boundary)
history.append({"stage": s + 1, "target": step_target, **report})
ms.apply_filter("meshing_remove_unreferenced_vertices")
ms.save_current_mesh(str(out_path), save_textures=False)
return {"clean": clean_report, "stages": history,
"final_faces": ms.current_mesh().face_number(),
"final_vertices": ms.current_mesh().vertex_number()}
Staging a 100× reduction as three 4.6× steps gives a visibly better result than one 100× step, because the quadric error accumulated at each vertex is re-derived from the current mesh at the start of each stage rather than being propagated through a single long collapse sequence. It costs about 40% more time.
Below about a 4× reduction there is no benefit to staging, and above about 25× there is a clear one, which is what the ratio thresholds encode.
5. Simplify the flat parts harder than the detailed parts
def curvature_weighted_decimate(in_path, out_path, target_faces,
curvature_percentile=75):
"""Give flat regions a lower cost so detail survives where it matters."""
ms = ml.MeshSet()
ms.load_new_mesh(str(in_path))
clean(ms)
ms.apply_filter("compute_curvature_principal_directions_per_vertex",
method="Quadric Fitting", curvcolormethod="Mean Curvature")
ms.apply_filter("compute_scalar_by_function_per_vertex", q="abs(q)")
ms.apply_filter("meshing_decimation_quadric_edge_collapse",
targetfacenum=int(target_faces),
qualityweight=True, # use the per-vertex quality as a weight
preserveboundary=True,
preservenormal=True,
planarquadric=True,
optimalplacement=True,
qualitythr=0.3)
ms.save_current_mesh(str(out_path), save_textures=False)
return {"faces": ms.current_mesh().face_number(),
"vertices": ms.current_mesh().vertex_number(),
"weighted_by": "absolute mean curvature"}
qualityweight=True makes the collapse cost proportional to the per-vertex quality field, which the two preceding filters have set to absolute mean curvature. The effect is that a flat wall collapses freely while a cornice, a railing or a kerb keeps its triangles — which is usually what a site mesh should look like after reduction.
The cost is one extra pass over the mesh to compute curvature, roughly 20% of the decimation time, and a result that is harder to predict: the face count still hits the target, but where the triangles end up depends on the geometry rather than on a uniform rule.
6. Report quality, do not assume it
def quality_report(original_path, decimated_path, samples=50_000):
import trimesh
from scipy.spatial import cKDTree
a = trimesh.load(original_path, process=False, force="mesh")
b = trimesh.load(decimated_path, process=False, force="mesh")
rng = np.random.default_rng(3)
idx = rng.choice(len(a.vertices), size=min(samples, len(a.vertices)), replace=False)
pts = np.asarray(a.vertices)[idx]
closest, distance, _ = b.nearest.on_surface(pts)
aspect = triangle_aspect_ratios(b)
return {
"faces": {"before": len(a.faces), "after": len(b.faces),
"ratio": round(len(b.faces) / len(a.faces), 4)},
"deviation_m": {
"mean": round(float(distance.mean()), 4),
"p95": round(float(np.percentile(distance, 95)), 4),
"max": round(float(distance.max()), 4),
},
"volume_change_pct": round(100.0 * (b.volume - a.volume) / max(abs(a.volume), 1e-9), 3)
if a.is_watertight and b.is_watertight else None,
"area_change_pct": round(100.0 * (b.area - a.area) / a.area, 3),
"worst_aspect_ratio": round(float(aspect.max()), 1),
"slivers_over_20": int((aspect > 20).sum()),
"components": int(b.body_count),
"watertight": bool(b.is_watertight),
}
def triangle_aspect_ratios(mesh):
tri = mesh.vertices[mesh.faces]
e0 = np.linalg.norm(tri[:, 1] - tri[:, 0], axis=1)
e1 = np.linalg.norm(tri[:, 2] - tri[:, 1], axis=1)
e2 = np.linalg.norm(tri[:, 0] - tri[:, 2], axis=1)
longest = np.maximum.reduce([e0, e1, e2])
s = (e0 + e1 + e2) / 2.0
area = np.sqrt(np.maximum(s * (s - e0) * (s - e1) * (s - e2), 1e-24))
inradius = area / np.maximum(s, 1e-12)
return longest / np.maximum(inradius * 2.0, 1e-12)
print(json.dumps(quality_report("input/site_mesh.ply", "output/site_100k.ply"), indent=2))
The deviation p95 and the sliver count are the two numbers that decide whether a decimation is acceptable. A p95 deviation below the survey tolerance means the mesh is still true to the site; a sliver count above a few hundred means the result will shade badly and may break texture projection or a later boolean operation.
Reporting area_change_pct catches a specific failure the deviation does not: a decimation that removed a whole feature — a canopy, a wall — has a small deviation almost everywhere and a noticeably smaller surface area.
Expected Output & Verification
{
"file": "site_mesh.ply",
"vertices": 6104882,
"faces": 12208401,
"has_uvs": true,
"has_vertex_colour": false,
"bbox_m": [1840.44, 1622.1, 96.73],
"surface_area_m2": 3184402.1,
"avg_edge_m": 0.0512
}
triangles per m²: 3.8
{
"clean": {"removed_faces": 216804, "removed_vertices": 59412, …},
"stages": [
{"stage": 1, "target": 2551218, "after": 2551204, "ratio": 0.2127, "hit_target": true},
{"stage": 2, "target": 533012, "after": 533008, "ratio": 0.2089, "hit_target": true},
{"stage": 3, "target": 100000, "after": 100000, "ratio": 0.1876, "hit_target": true}
],
"final_faces": 100000,
"final_vertices": 50841
}
{
"faces": {"before": 12208401, "after": 100000, "ratio": 0.0082},
"deviation_m": {"mean": 0.0181, "p95": 0.0702, "max": 0.4914},
"volume_change_pct": null,
"area_change_pct": -0.412,
"worst_aspect_ratio": 18.4,
"slivers_over_20": 0,
"components": 1,
"watertight": false
}
A 122× reduction with a 7 cm p95 deviation and no slivers is a good result for a photogrammetric site mesh. The 0.41% area loss is the expected consequence of smoothing fine texture into flat triangles; a figure above a few percent would indicate a lost feature.
watertight: false and volume_change_pct: null are both normal for an open surface mesh — a site mesh is a height field, not a closed solid, so volume is undefined.
Verify the boundary survived, which is the property a tiled workflow depends on:
def boundary_check(original_path, decimated_path, tol_m=0.01):
import trimesh
a = trimesh.load(original_path, process=False, force="mesh")
b = trimesh.load(decimated_path, process=False, force="mesh")
def boundary_vertices(mesh):
edges = mesh.edges_sorted
unique, counts = np.unique(edges, axis=0, return_counts=True)
border = unique[counts == 1]
return np.unique(border.ravel())
va = np.asarray(a.vertices)[boundary_vertices(a)]
vb = np.asarray(b.vertices)[boundary_vertices(b)]
if len(va) == 0:
return {"has_boundary": False, "note": "closed mesh; nothing to preserve"}
from scipy.spatial import cKDTree
d_to_b, _ = cKDTree(vb).query(va, k=1) if len(vb) else (np.full(len(va), np.inf), None)
return {
"has_boundary": True,
"boundary_vertices_before": int(len(va)),
"boundary_vertices_after": int(len(vb)),
"max_boundary_shift_m": round(float(d_to_b.max()), 4),
"p95_boundary_shift_m": round(float(np.percentile(d_to_b, 95)), 4),
"preserved": bool(d_to_b.max() <= tol_m),
}
print(boundary_check("input/site_mesh.ply", "output/site_100k.ply"))
The boundary vertex count will fall — collinear boundary vertices are legitimately removable — but the maximum shift must stay at or near zero. A shift of centimetres means preserveboundary did not take effect, and the tiles will not meet.
Then verify nothing was silently lost, by comparing coverage on a grid:
def coverage_check(original_path, decimated_path, cell_m=5.0):
"""Cells with geometry before and none after are lost features."""
import trimesh
a = trimesh.load(original_path, process=False, force="mesh")
b = trimesh.load(decimated_path, process=False, force="mesh")
def occupancy(mesh):
c = mesh.triangles_center
i = np.floor(c[:, 0] / cell_m).astype(np.int64)
j = np.floor(c[:, 1] / cell_m).astype(np.int64)
return set(map(tuple, np.column_stack([i, j])))
oa, ob = occupancy(a), occupancy(b)
lost = oa - ob
return {"cells_before": len(oa), "cells_after": len(ob),
"cells_lost": len(lost), "lost_area_m2": len(lost) * cell_m ** 2,
"lost_fraction": round(len(lost) / max(len(oa), 1), 4),
"examples": sorted(lost)[:5]}
A handful of lost cells at the mesh’s edge is rounding; a cluster of lost cells in the middle is a removed feature, and it is worth looking at before the mesh goes downstream.
Performance Notes
- Quadric collapse runs at roughly 1–2 million triangles per second on one core for the plain filter, about half that for the texture-aware variant. 12 million triangles is 10–25 seconds per stage.
- PyMeshLab is single-threaded per
MeshSet. Parallelise across meshes or tiles, not within one. - Memory is about 200 bytes per input triangle. A 40-million-triangle mesh needs 8 GB and will swap on a 16 GB machine once the OS and Python are accounted for.
meshing_decimation_clusteringis the escape hatch above about 20 million triangles: it is far faster, much lower quality, and a reasonable first stage before a quadric pass.- Staging costs 40% more time for a visibly better result above about 25× reduction.
- Curvature computation adds roughly 20% and is worth it on meshes with a mix of flat and detailed regions.
Common Errors
TypeError: expected PercentageValue. MeshLab percentage parameters need ml.PercentageValue(x), not a float.
Spikes in the output. Degenerate faces in the input. Run the cleaning pass.
Texture smears across surfaces. The plain filter was used on a mesh with UVs. Use the texture-aware variant.
Cracks between neighbouring tiles. preserveboundary off, or the mesh was merged with its neighbours before decimation so the shared edge was not a boundary.
Target face count not reached. qualitythr too high, so the filter refuses the remaining collapses. Lower it, or accept the count.
Filter name not found. PyMeshLab renamed most filters in 2022; simplification_quadric_edge_collapse_decimation is now meshing_decimation_quadric_edge_collapse. Print ml.filter_list() to check against your version.
The result has 20,000 tiny components. Small components were not removed before decimation, and they do not decimate.
Frequently Asked Questions
PyMeshLab or meshoptimizer?
meshoptimizer is an order of magnitude faster and integrates with glTF directly, which makes it right inside a tiling loop — see generating LOD chains with meshoptimizer. PyMeshLab has the cleaning filters, curvature weighting and the texture-aware collapse, which makes it right for preparing a mesh before that loop.
Should I decimate before or after texturing?
Before, if the texture is projected from images afterwards — a coarser mesh needs fewer texels. After, if the mesh already has a baked atlas you want to keep, in which case the texture-aware filter is mandatory.
How much reduction is safe?
Judge it by deviation against the survey tolerance, not by ratio. A 100× reduction on a 3 triangle/m² mesh is usually fine; a 4× reduction on an already sparse mesh may not be.
Related Guides
- Measuring Hausdorff Distance After Decimation — the rigorous version of the deviation check
- Planar Region Simplification for Facades — a structure-aware alternative for buildings
- Generating LOD Chains with meshoptimizer — decimation inside the tiling loop
Back to Automated Mesh Decimation.