Aligning two meshes / point clouds: model-to-model registration in Python¶
- Status: unverified
- Applies to: Metashape Pro 2.x — uses external
open3dlibrary; pattern fromalign_model_to_model.pyin the official metashape-scripts repo - Edition: Pro
- Diátaxis: how-to
- Confidence: high
- Last reviewed: 2026-05-30
Confidence: high. The script lives in Agisoft's official
metashape-scriptsrepo (not just forum content), is tested against Metashape 2.3, and uses standardopen3dICP / RANSAC primitives. Article framing extracts the operational pattern; specific parameter values come from the script verbatim.
Problem¶
You have two independently-built 3D representations that should align — for example:
- Two photogrammetry models built from non-overlapping captures of the same site (different flights, different chunks).
- A photogrammetry model and a LIDAR point cloud of the same area.
- Two LIDAR scans from different stations.
- A "before" and "after" mesh of an excavation site.
You need them in a common coordinate frame so that downstream operations (chunk-diff, comparison, blending) can run on them together. Metashape's Align Chunks is one option for camera- based chunks; for chunks where you have only the geometry (no shared cameras), or for aligning external imports, you need a model-to-model / point-cloud-to-point-cloud registration step.
What's available¶
Metashape's GUI has Align Chunks → method=Point cloud which
runs an ICP-style registration. For more control or for
external data (LIDAR loaded as point cloud), Agisoft publishes
align_model_to_model.py
in its official scripts repo. It uses
open3d — a separate Python library
that ships standard global-and-local registration primitives —
to perform a two-stage registration:
- Global registration (RANSAC on FPFH features) gets approximate alignment when the two clouds are far apart.
- Local refinement (ICP) tightens the alignment using point-to-plane distance.
The script handles three sub-cases that pure ICP doesn't: (a) unknown scale ratio between source and target, (b) unknown appropriate ICP voxel size (target_resolution), and (c) the "only common geometry overlaps" case (where you crop both clouds to the shared region before registration).
When to use this vs. Align Chunks¶
| Scenario | Recommended |
|---|---|
| Two chunks with shared cameras | Align Chunks → method=Camera |
| Two chunks with shared markers | Align Chunks → method=Marker |
| Two chunks in same coordinate frame, geometry-only | Align Chunks → method=Point cloud |
| Two clouds in different scales (e.g., LIDAR + photogrammetry where one is in CRS metres and the other in chunk-internal units) | align_model_to_model.py |
| Imported LIDAR cloud + photogrammetry model | align_model_to_model.py |
| Want fine-grained control over voxel size, RANSAC parameters, ICP iterations | align_model_to_model.py |
| Want a fully GUI-driven workflow | Align Chunks |
Recipe — adapt the script's pattern for your data¶
The complete script is in the metashape-scripts repo. The core pattern, simplified for documentation:
Demo verified: ✗ — pending Tier 3 reproduction on a real Metashape install with
open3dinstalled.
import numpy as np
import open3d as o3d
import Metashape
chunk = Metashape.app.document.chunk
# Step 1: extract source and target as numpy arrays
def chunk_model_to_numpy(chunk):
"""Extract chunk.model vertices as Nx3 numpy array."""
if not chunk.model:
raise RuntimeError("No model in chunk")
coords = np.array([
[v.coord.x, v.coord.y, v.coord.z]
for v in chunk.model.vertices
])
return coords
def chunk_pointcloud_to_numpy_via_export(chunk, las_path):
"""Extract chunk.point_cloud points as Nx3 numpy array.
The 2.x point-cloud API does not expose per-point iteration
in Python, so the canonical pattern is: export the point cloud
to a temp LAS file, then read it back via laspy or similar.
"""
pc = chunk.point_cloud
if not pc:
raise RuntimeError("No point cloud in chunk")
chunk.exportPointCloud(
las_path,
source_data=Metashape.DataSource.PointCloudData,
crs=chunk.crs,
)
# Read the LAS file (requires laspy)
import laspy
las = laspy.read(las_path)
return np.column_stack([las.x, las.y, las.z])
# Step 2: build open3d point clouds from numpy
def to_o3d_cloud(arr_nx3):
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(arr_nx3)
return pcd
source_pts = chunk_model_to_numpy(chunk)
target_pts = ... # from the other chunk or via chunk_pointcloud_to_numpy_via_export(other_chunk, "/tmp/target.las")
source_pcd = to_o3d_cloud(source_pts)
target_pcd = to_o3d_cloud(target_pts)
# Step 3: estimate scale ratio (if not 1.0)
# (See align_model_to_model.py's `estimate_convex_hull_size` for the heuristic)
scale_ratio = 1.0 # for LIDAR-to-LIDAR; for photogrammetry-to-LIDAR use the script's hull-based estimate
# Step 4: voxel-downsample both clouds for global registration
voxel_size = 0.5 # in target-cloud units
source_down = source_pcd.voxel_down_sample(voxel_size)
target_down = target_pcd.voxel_down_sample(voxel_size)
# Step 5: estimate normals and FPFH features
o3d.geometry.PointCloud.estimate_normals(source_down)
o3d.geometry.PointCloud.estimate_normals(target_down)
source_fpfh = o3d.pipelines.registration.compute_fpfh_feature(
source_down, o3d.geometry.KDTreeSearchParamHybrid(radius=voxel_size * 5, max_nn=100))
target_fpfh = o3d.pipelines.registration.compute_fpfh_feature(
target_down, o3d.geometry.KDTreeSearchParamHybrid(radius=voxel_size * 5, max_nn=100))
# Step 6: global registration via RANSAC
result_global = o3d.pipelines.registration.registration_ransac_based_on_feature_matching(
source_down, target_down, source_fpfh, target_fpfh,
mutual_filter=True,
max_correspondence_distance=voxel_size * 1.5,
estimation_method=o3d.pipelines.registration.TransformationEstimationPointToPoint(False),
ransac_n=3,
checkers=[
o3d.pipelines.registration.CorrespondenceCheckerBasedOnEdgeLength(0.9),
o3d.pipelines.registration.CorrespondenceCheckerBasedOnDistance(voxel_size * 1.5),
],
criteria=o3d.pipelines.registration.RANSACConvergenceCriteria(100000, 0.999),
)
# Step 7: local ICP refinement
result_icp = o3d.pipelines.registration.registration_icp(
source_pcd, target_pcd,
max_correspondence_distance=voxel_size * 0.4,
init=result_global.transformation,
estimation_method=o3d.pipelines.registration.TransformationEstimationPointToPlane(),
)
# Step 8: apply the final transform back to the source chunk
T_4x4 = result_icp.transformation # 4x4 numpy array
T_metashape = Metashape.Matrix(T_4x4.tolist())
chunk.transform.matrix = T_metashape * chunk.transform.matrix
For the production-quality version with logging, scale-ratio
estimation, error reporting, and the GUI dialog, use the
align_model_to_model.py
script directly.
Two-stage registration: why both global + ICP¶
ICP alone fails when the source and target are far apart in their starting positions. The local-search nature of ICP means it converges to whichever local minimum is closest — which is usually wrong unless the input is already roughly aligned.
The global RANSAC step uses feature descriptors (FPFH) that match similar-shaped local geometry across the two clouds. Even if the source and target are arbitrarily oriented, RANSAC finds correspondences between similar features, then estimates a rough transform. ICP then refines.
A trick the script uses: if you know the two clouds are
already roughly aligned (e.g., both in the same world CRS),
skip the global step via no_global_alignment=True. Saves
significant time on already-near-aligned data.
Pre-requisite: install open3d¶
The script auto-installs open3d on first run via
pip_install(requirements_txt). For manual install:
Pin to the exact versions the script specifies — open3d API
changes between versions, and the script depends on the
o3d.pipelines.registration namespace (renamed from
o3d.registration at some open3d version).
Caveats¶
- Scale ratio matters. If you don't know the true scale ratio (e.g., one cloud is in millimetres, the other in metres), the script's heuristic estimate via convex-hull size is unreliable for partially-overlapping data. Measure a known-length feature in both clouds and divide for the reliable scale ratio.
- Voxel size determines registration accuracy. Too large: loses detail, fails on small features. Too small: slow, noise-sensitive. Start at ~0.01 of the cloud's diagonal bounding-box length and tune.
- The script's GUI runs synchronously. On large clouds (>10M points), the global registration alone can take 10+ minutes. The Metashape GUI appears unresponsive during this; don't kill the process.
open3dand Metashape's Python share interpreters. The script installsopen3dinto Metashape's Python env. This works but means subsequent unrelated Metashape Python scripts may inheritopen3d's import overhead.- The transform is applied to
chunk.transform.matrix. This affects all chunk geometry (cameras, points, mesh, orthomosaic). For per-asset transforms (move only the mesh, not the cameras), you'd need to use the asset-specific transform fields, which the script does not currently handle. - Output transformation is in source's chunk-local frame. If you want the transform in geocentric or CRS coordinates for downstream tools, multiply by the chunk's transform.
See also¶
- The three
alignChunksmethods (point / marker / camera) — the GUI alternatives when chunks share cameras or markers. - Comparing chunks for change detection: DEM, mesh, and point-cloud diff workflows — uses model-to-model alignment as a pre-requisite for diffing.
Model.renderDepth: synthetic depth from arbitrary viewpoints — uses similar Matrix manipulation.- Mesh and point-cloud editing recipes — broader Python recipes for mesh / point-cloud manipulation.
References¶
align_model_to_model.py— the canonical implementation in Agisoft's official scripts repo.- Open3D documentation, Global registration — the FPFH + RANSAC algorithm the script uses.
- Open3D documentation, ICP registration — the point-to-plane ICP variant for the local refinement step.
- Metashape Python API Reference (2.3.1):
Chunk.transform,Chunk.model,Chunk.point_cloud,Matrix.