Mesh and point-cloud editing recipes (Python)¶
- Status: unverified
- Applies to: Metashape Pro 2.x — and PhotoScan 1.x via the same Model / DepthMaps API
- Edition: Pro
- Diátaxis: how-to
- Confidence: high
- Last reviewed: 2026-06-05
Confidence: high. All API references (
chunk.model.renderDepth,chunk.depth_maps[].image,model.removeFaces,chunk.exportModel) are introspection-confirmed on Metashape 2.2; recipes are forum- attested with permalinks.
A grab-bag of mesh and point-cloud editing recipes that recur in forum support threads. Each is short, self-contained, and exercises a concrete API. They are collected here so they do not have to be re-derived for each new use case.
Recipe 1 — Render depth from any camera pose¶
chunk.model.renderDepth(transform, calibration) produces a
depth map for an arbitrary pose / intrinsics, not limited to the
chunk's existing cameras. Useful for visibility analysis,
synthetic-stereo experiments, and orthorectification onto novel
view planes.
Demo verified: ✗ — pending Tier 3 reproduction on a real Metashape install.
import Metashape
chunk = Metashape.app.document.chunk
camera = chunk.cameras[0]
# Render from the camera's actual pose (mimics chunk.depth_maps[camera])
depth = chunk.model.renderDepth(
camera.transform,
camera.sensor.calibration,
)
# Or from a synthetic pose: identity transform = world origin
synth_T = Metashape.Matrix.Diag([1, 1, 1, 1])
synth_depth = chunk.model.renderDepth(synth_T, camera.sensor.calibration)
The returned Image holds float32 values, not 8-bit grayscale.
To export as 8-bit PNG, normalise per-image:
Demo verified: ✗ — pending Tier 3 reproduction on a real Metashape install.
import numpy as np
arr = np.frombuffer(depth.tostring(), dtype=np.float32)
arr = arr.reshape(depth.height, depth.width)
valid = np.isfinite(arr) # background pixels are NaN / inf
scaled = np.zeros_like(arr, dtype=np.uint8)
if valid.any():
lo, hi = arr[valid].min(), arr[valid].max()
scaled[valid] = ((arr[valid] - lo) / (hi - lo) * 255).astype(np.uint8)
# scaled is now ready to save with Pillow / OpenCV
"renderDepth() is generating the depth in floating point format, so to get grayscale values in 0 - 255 range it would be necessary to transform the data 'manually'." — Alexey Pasumansky, 2016-11-04, PhotoScan 1.2 (permalink)
Recipe 2 — Read existing depth maps as numpy arrays¶
For depth maps already computed via Build Depth Maps, the
data is in chunk.depth_maps[camera]. Convert to numpy without
writing intermediate files:
Demo verified: ✗ — pending Tier 3 reproduction on a real Metashape install.
import Metashape
import numpy as np
chunk = Metashape.app.document.chunk
camera = chunk.cameras[0]
depth_image = chunk.depth_maps[camera].image()
h, w = depth_image.height, depth_image.width
# F32 buffer → 2D numpy array
arr = np.frombuffer(depth_image.tostring(), dtype=np.float32).reshape(h, w)
# arr holds chunk-local depth values; multiply by chunk scale for metres
if chunk.transform.scale:
arr_metres = arr * chunk.transform.scale
"tostring() method is similar to tobytes() method available in the older numpy versions. The result is represented as a linear array (in the given case all the depth image lines go one by one). If you would like to convert it into 2D array you should use reshape method:
numpy.frombuffer(depth.tostring(), dtype=numpy.float32).reshape(height, width)" — Alexey Pasumansky, 2022-03-21, Metashape 1.8 (permalink)
For multi-channel images (RGB camera output), reshape with the channel count:
Demo verified: ✗ — pending Tier 3 reproduction on a real Metashape install.
rgb = camera.image()
arr = np.frombuffer(rgb.tostring(), dtype=np.uint8).reshape(rgb.height, rgb.width, 3)
Recipe 3 — Crop a mesh to a boundary shape¶
chunk.exportModel() exports the entire mesh, ignoring any
chunk shapes. To export only the portion inside a polygon, delete
the outside faces first.
"Currently it is expected behavior for Export Model procedure. As a workaround it is possible to delete the polygons which do not have any vertex inside the boundary shape using Python and export the cropped model." — Alexey Pasumansky, 2020-08-08, Metashape 1.6 (permalink)
Demo verified: ✗ — pending Tier 3 reproduction on a real Metashape install.
import Metashape
def point_in_polygon(point, poly):
"""Standard ray-casting point-in-polygon test."""
x, y = point.x, point.y
inside = False
p1x, p1y = poly[0]
for i in range(len(poly) + 1):
p2x, p2y = poly[i % len(poly)]
if y >= min(p1y, p2y) and y <= max(p1y, p2y) and x <= max(p1x, p2x):
if p1y != p2y:
xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
if p1x == p2x or x <= xinters:
inside = not inside
p1x, p1y = p2x, p2y
return inside
chunk = Metashape.app.document.chunk
model = chunk.model
# Find boundary shapes (uses the first one found)
boundary = next(
(s for s in chunk.shapes
if s.boundary_type == Metashape.Shape.BoundaryType.OuterBoundary),
None,
)
if boundary is None:
raise ValueError("no outer-boundary shape found")
# Convert shape vertices to chunk-local (X, Y) tuples
T_inv = chunk.transform.matrix.inv()
poly = [
(T_inv.mulp(v).x, T_inv.mulp(v).y)
for v in boundary.geometry.coordinates[0] # outer ring
]
# Mark faces with all 3 vertices outside the polygon as selected, then remove
for face in model.faces:
face.selected = False
for face in model.faces:
verts = [model.vertices[i].coord for i in face.vertices]
if not any(point_in_polygon(v, poly) for v in verts):
face.selected = True
model.removeSelection() # removes selected faces + their orphan vertices
chunk.exportModel(path="/path/cropped.obj")
The vertex-in-polygon test produces a jagged boundary. For a clean cut along the polygon edge, the script must subdivide boundary faces — a more complex geometric operation not covered here.
Recipe 4 — Compare chunks for change detection / volume¶
Metashape Professional can subtract one DEM from another natively — Tools → Transform DEM → Calculate difference (import the other chunk's DEM into the project first). For mesh / point-cloud change, or finer control, route through an external tool. Options:
"In current version of PhotoScan you cannot subtract DEM models produced in different chunks, so you need to do that with the exported results in the external GIS applications (like GlobalMapper or Q-GIS, for example)." — Alexey Pasumansky, 2017-05-15, PhotoScan 1.3 (permalink)
That 2017 answer predates the native Transform DEM → Calculate difference operator (DEM difference calculation (Agisoft KB)); for the full chunk-diff treatment see Comparing chunks for change detection.
| Approach | Pros | Cons |
|---|---|---|
| External GIS DEM raster diff | Reusable; preserves georeferencing | Requires aligned CRS + same resolution |
| CloudCompare cloud-to-cloud | High resolution; works for non-rasterisable shapes | Less directly interpretable as volume |
| Per-chunk volume + subtract | Stays inside Metashape | Requires same polygon vertices in both chunks |
Native per-chunk volume measurement is exposed via the Measure → Volume command (right-click on a polygon shape). Subtract two volumes computed against the same reference plane to get an excavation volume.
Recipe 5 — Empty-chunk handling for split_in_chunks workflows¶
When using the split_in_chunks script (from
metashape-scripts)
to divide a project into spatial sub-chunks, some sub-chunks may
contain no aligned cameras (corner / edge cells with no
coverage). Calling chunk.buildPointCloud() or
chunk.buildModel() on such chunks raises errors like
Zero resolution, Null image, or Empty surface.
Wrap each batch operation in a try / except:
Demo verified: ✗ — pending Tier 3 reproduction on a real Metashape install.
for chunk in doc.chunks:
try:
chunk.buildPointCloud()
except Exception as exc:
print(f"Skipped {chunk.label}: {exc}")
continue
try:
chunk.buildModel()
except Exception as exc:
print(f"Mesh skipped for {chunk.label}: {exc}")
continue
"In order to skip 'zero resolution' chunks for the dense cloud generation, you can use try-except approach" — Alexey Pasumansky, 2019-01-17, Metashape 1.5 (permalink)
Recipe 6 — Split a mesh into N×N tiles¶
For very large meshes that exceed memory budgets during Build Texture or downstream-tool import, split the mesh into a grid of smaller meshes. The script below creates an N×N grid in the XY plane based on the chunk's bounding box; for each cell, it duplicates the mesh and clips to the cell's polygon.
Demo verified: ✗ — pending Tier 3 reproduction on a real Metashape install.
import Metashape
def split_mesh(n_tiles_per_side: int = 5):
"""Split chunk.model into N×N XY-plane tiles."""
chunk = Metashape.app.document.chunk
if not chunk or not chunk.model:
raise ValueError("No active chunk with a model")
# Create a shape group for the tile boundaries
if not chunk.shapes:
chunk.shapes = Metashape.Shapes()
chunk.shapes.crs = chunk.crs
tiles = chunk.shapes.addGroup()
tiles.label = f"Mesh split grid ({n_tiles_per_side}x{n_tiles_per_side})"
# Compute the chunk's bounding box in world coordinates
region = chunk.region
T = chunk.transform.matrix
R = region.rot
C = region.center
S = region.size
# Build the 8 corners of the bounding box in world coords
corners_world = []
for dx, dy, dz in [(-1,-1,-1),(1,-1,-1),(1,1,-1),(-1,1,-1),
(-1,-1, 1),(1,-1, 1),(1,1, 1),(-1,1, 1)]:
local = C + R * Metashape.Vector([dx*S.x, dy*S.y, dz*S.z]) / 2
corners_world.append(T.mulp(local))
# Find XY extent (lowest Z = ground)
xs = [c.x for c in corners_world]
ys = [c.y for c in corners_world]
x_min, x_max = min(xs), max(xs)
y_min, y_max = min(ys), max(ys)
z_top = max(c.z for c in corners_world)
z_bot = min(c.z for c in corners_world)
# Create N×N rectangular tile shapes covering the XY extent
dx = (x_max - x_min) / n_tiles_per_side
dy = (y_max - y_min) / n_tiles_per_side
for i in range(n_tiles_per_side):
for j in range(n_tiles_per_side):
x0 = x_min + i * dx
y0 = y_min + j * dy
poly = [
Metashape.Vector([x0, y0, z_bot]),
Metashape.Vector([x0 + dx, y0, z_bot]),
Metashape.Vector([x0 + dx, y0 + dy, z_bot]),
Metashape.Vector([x0, y0 + dy, z_bot]),
]
shape = chunk.shapes.addShape()
shape.geometry = Metashape.Geometry.Polygon(poly)
shape.boundary_type = Metashape.Shape.BoundaryType.OuterBoundary
shape.label = f"tile_{i}_{j}"
shape.group = tiles
# Duplicate the mesh clipped to this tile shape
task = Metashape.Tasks.DuplicateAsset()
task.asset = chunk.model.key
task.clip_to_boundary_shapes = True
task.apply(chunk)
# Reset boundary so the next tile gets its own
shape.boundary_type = Metashape.Shape.BoundaryType.NoBoundary
# Usage:
# split_mesh(n_tiles_per_side=5)
The resulting chunk has the original mesh plus n_tiles_per_side²
clipped duplicates, each named after its grid cell. Generate
texture per tile, then export each tile's mesh and texture
separately for downstream consumers.
The full version of the script (with command-line dialog and error handling) is in the source thread.
"Please check this script. It splits the active (default) mesh model in the active chunk by N×N grid (N will be asked on script start) in XY plane (as if you are looking from Top on the mesh)." — Alexey Pasumansky, 2022-02-25, Metashape 1.8 (permalink)
Recipe 7 — Merging multiple point clouds in one chunk¶
When you've imported multiple LiDAR scans (or built point
clouds in different sessions and they ended up under one
chunk's chunk.point_clouds), you can merge them into a
single point cloud asset — equivalent to right-clicking on
multiple clouds in the Workspace pane and choosing Merge.
"I suggest the following code to merge all the available dense clouds in the active chunk into new dense cloud instance" — Alexey Pasumansky, 2019-04-07, Metashape 1.5 (permalink)
Demo verified: ✗ — pending Tier 3 reproduction on a real Metashape install.
import Metashape
chunk = Metashape.app.document.chunk
# Clear the active point cloud reference
chunk.point_cloud = None # 1.x: chunk.dense_cloud = None
# Merge all point cloud assets via the Tasks API
task = Metashape.Tasks.MergeAssets()
task.assets = [cloud.key for cloud in chunk.point_clouds]
task.apply(chunk)
# After merging, chunk.point_clouds contains the merged asset
# (plus the originals, which can be removed if desired)
print(f"After merge: {len(chunk.point_clouds)} point cloud(s) in chunk")
For 1.x compatibility, replace chunk.point_clouds with
chunk.dense_clouds and point_cloud with dense_cloud.
Caveats¶
renderDepthrequires a built mesh.chunk.modelmust be non-None.tostring()is the documented method on Metashape'sImageclass. Pillow's image type usestobytes()(Pillow 10+); do not confuse the two.- Recipe 3 (mesh crop) is destructive. The face-removal modifies the chunk's mesh. Duplicate the chunk before running if you want to preserve the original.
- Recipe 5 (empty chunks) hides legitimate errors too. Inspect the printed messages — distinguish "zero resolution" (genuinely empty) from real errors (missing depth maps, etc.).
References¶
- Metashape Python API Reference (2.3.1):
Model.renderDepth,DepthMaps.image,Image.tostring,Model.removeSelection,Chunk.exportModel,Chunk.buildPointCloud,Chunk.buildModel,Tasks.MergeAssets,Chunk.point_clouds,Chunk.point_cloud. - Metashape Pro User Manual (2.3) § Editing → Editing model — describes the GUI's mesh-editing tools (this article documents the Python equivalents).
- Advanced Selection tools for Point Cloud and Model (Agisoft KB) — the GUI Visible Selection / Invert / Grow / Shrink / Filter-by- Selection tools whose Python equivalents these recipes use.