Applying Patch on multiple shapes (orthomosaic patching by script)¶
- Status: unverified
- Applies to: Metashape Pro 2.x — and PhotoScan 1.5+ via the same API
- Edition: Pro
- Diátaxis: how-to
- Confidence: high
- Last reviewed: 2026-06-05
Confidence: high. Three working scripts in the source thread (Pasumansky, 2019, three msgs in topic=11105) cover the auto-rank, attribute-driven, and reverse-extract operations. The 2019-era scripts use the
shape.type == Metashape.Shape.Type.PolygonAPI; on Metashape 2.x the equivalent test isisinstance(shape.geometry, Metashape.Geometry.Polygon). TheOrthomosaic.Patch,Orthomosaic.patches, andOrthomosaic.updateAPI surfaces are introspection-confirmed on Metashape 2.2.2.
What Patch does (and the GUI gap)¶
Patch assigns a specific camera's image to a polygonal region of the orthomosaic, overriding the default best-image selection. Production use-cases:
- Stitch correction. Replace the default-blended region over a fast-moving subject (a bird, a car, a person) with a single consistent image to avoid the smear / ghosting that blending produces.
- Source-image selection. When two cameras with different exposures cover the same area, pick the one with the better exposure.
- Survey-mark visibility. Force the orthomosaic to use a specific image where a survey mark / ground-control feature is most clearly visible.
The GUI exposes this as Right-click polygon shape → Assign Image, with a ranked candidate list whose top entries are typically the cameras whose image-centre is closest to the shape's centroid (with corrections for surface distance and view-angle). The GUI handles one shape at a time.
For projects with hundreds of polygonal patches — typical of production aerial surveys — the GUI is impractical. The Python API exposes the patch operation directly:
patch = Metashape.Orthomosaic.Patch()
patch.image_keys = [camera.key]
chunk.orthomosaic.patches[shape] = patch
chunk.orthomosaic.update()
The article documents three recipes covering common patching workflows.
Recipe 1 — Bulk auto-patch by centroid distance¶
Apply the closest-image-to-shape-centroid rule to every polygonal shape in the chunk.
"""Auto-patch each polygonal shape with the camera whose image
centre projects closest to the shape's centroid."""
import Metashape
doc = Metashape.app.document
chunk = doc.chunk
ortho = chunk.orthomosaic
if ortho is None:
raise SystemExit("No orthomosaic — build one before patching.")
# Filter to polygonal shapes only (polygons enclose patch areas).
polygons = [
s for s in chunk.shapes
if isinstance(s.geometry, Metashape.Geometry.Polygon)
]
# Map each polygonal shape to the best aligned camera.
def shape_centroid_xy(shape):
"""Centroid of the polygon's exterior ring in chunk-local XY."""
# `polygon.coordinates` is `list[list[Vector]]` — outer list
# is rings (exterior + holes), inner list is the ring's points.
# Use the exterior ring (index 0).
rings = shape.geometry.coordinates
if not rings:
return None
pts = rings[0] # exterior ring: list[Vector]
if not pts:
return None
cx = sum(p.x for p in pts) / len(pts)
cy = sum(p.y for p in pts) / len(pts)
return Metashape.Vector([cx, cy])
def project_image_centre(camera, target_z=0.0):
"""Project the image-centre ray onto z = target_z and return XY."""
if camera.transform is None:
return None
# Camera centre in chunk-local frame.
centre_local = camera.center
# Image-centre direction in chunk-local frame.
sensor = camera.sensor
cx_pix = sensor.width / 2.0
cy_pix = sensor.height / 2.0
direction_cam = sensor.calibration.unproject(
Metashape.Vector([cx_pix, cy_pix])
)
direction_world = camera.transform.mulv(direction_cam)
# Solve for t such that centre_local.z + t * direction_world.z = target_z.
if abs(direction_world.z) < 1e-9:
return None
t = (target_z - centre_local.z) / direction_world.z
if t <= 0:
return None # camera is below the surface
hit = centre_local + t * direction_world
return Metashape.Vector([hit.x, hit.y])
aligned_cameras = [c for c in chunk.cameras if c.transform is not None]
for shape in polygons:
sc = shape_centroid_xy(shape)
if sc is None:
continue
# Find the camera whose image-centre projection is closest to
# the shape centroid (XY only).
best_camera = None
best_dist = float("inf")
for cam in aligned_cameras:
ic = project_image_centre(cam)
if ic is None:
continue
dist = (ic - sc).norm()
if dist < best_dist:
best_dist = dist
best_camera = cam
if best_camera is None:
print(f"no candidate for shape {shape.label!r}, skipping")
continue
patch = Metashape.Orthomosaic.Patch()
patch.image_keys = [best_camera.key]
ortho.patches[shape] = patch
print(f"shape {shape.label!r:>20} → {best_camera.label} (d={best_dist:.2f})")
ortho.update()
print("orthomosaic patched and updated.")
The metric here is the simplified centroid-distance rule. The GUI's ranking is more sophisticated:
"GUI ranking of the cameras in the Assign Image dialog is also considering additional factors such as the distance of the selected area projected to the image plane from the image center, distance from camera to the 3D surface being textured, relative orientation of the camera normal and surface normals." — Alexey Pasumansky, 2019-07-10, Metashape 1.5 (permalink)
For most use-cases, the centroid-distance metric produces results within a few candidates of the GUI's top pick. When the script's pick differs from the GUI's, the difference is typically in marginal cases where multiple cameras are roughly equally suited.
Recipe 2 — Patch by shape attribute (reusable across orthomosaics)¶
When patches need to survive an orthomosaic rebuild — typical when the same polygonal regions need re-application after re-georeferencing or re-meshing — encode the camera-to-shape mapping in the shape's attributes:
"""Auto-patch from shape.attributes['Patch'] (camera label).
Pre-condition: each polygon's `Patch` attribute is set to the
desired camera's label. The shape is in a group named
'Patches' (case-insensitive)."""
import Metashape
doc = Metashape.app.document
chunk = doc.chunk
if chunk is None or chunk.orthomosaic is None or not chunk.shapes:
raise SystemExit("Need active chunk with orthomosaic and shapes.")
ortho = chunk.orthomosaic
# Filter: polygonal shapes in the 'Patches' group.
patches_list = [
s for s in chunk.shapes
if isinstance(s.geometry, Metashape.Geometry.Polygon)
and s.group is not None
and s.group.label.upper() == "PATCHES"
]
# Build label → Camera lookup (only aligned cameras).
camera_by_label = {
c.label: c for c in chunk.cameras if c.transform is not None
}
assigned = 0
for shape in patches_list:
# `shape.attributes` is a `Metashape.MetaData` object, which
# supports `__getitem__`, `__setitem__`, `__contains__`,
# `keys()`, `values()`, `items()` — but NOT `.get()`. Use
# the `in` form instead.
if "Patch" not in shape.attributes:
continue # no Patch attribute on this shape
label_attr = shape.attributes["Patch"]
if not label_attr:
continue
cam = camera_by_label.get(label_attr)
if cam is None:
# Try case-insensitive.
cam = next(
(c for c in chunk.cameras
if c.transform is not None
and c.label.upper() == label_attr.upper()),
None,
)
if cam is None:
print(f"no camera matching {label_attr!r} for shape {shape.label!r}, skipping")
continue
patch = Metashape.Orthomosaic.Patch()
patch.image_keys = [cam.key]
ortho.patches[shape] = patch
assigned += 1
ortho.update()
print(f"applied {assigned} patches; orthomosaic updated.")
The Patches group convention is a script convention, not an
API rule — it lets you keep
patch-defining polygons separate from other shape work in the
same chunk. Drop the s.group.label.upper() == "PATCHES" filter
if your project keeps all polygons in the default group.
Recipe 3 — Extract existing patches into shape attributes¶
The reverse direction. When you've manually applied patches via
the GUI and now want to make them script-reusable, populate
each polygon's Patch attribute with the assigned camera's
label.
"""Reverse direction: encode existing patches into shape attributes
so they can be replayed on a re-built orthomosaic."""
import Metashape
doc = Metashape.app.document
chunk = doc.chunk
ortho = chunk.orthomosaic
if ortho is None or not ortho.patches:
raise SystemExit("No patches to extract.")
# Build key → Camera lookup once.
camera_by_key = {c.key: c for c in chunk.cameras}
# Optional: collect all extracted shapes into a 'Patches' group
# so Recipe 2 can re-apply them.
patches_group = None
for g in chunk.shapes.groups:
if g.label.upper() == "PATCHES":
patches_group = g
break
if patches_group is None:
patches_group = chunk.shapes.addGroup()
patches_group.label = "Patches"
extracted = 0
for shape, patch in ortho.patches.items():
if not patch.image_keys:
continue
cam = camera_by_key.get(patch.image_keys[0])
if cam is None:
continue
shape.attributes["Patch"] = cam.label
if shape.label == "" or shape.label is None:
shape.label = cam.label # also use camera label as shape label
shape.group = patches_group
extracted += 1
print(f"extracted {extracted} patch assignments into shape attributes.")
After running this, save the project. The shapes — with their
Patch attribute populated — survive an orthomosaic rebuild
and can be re-applied via Recipe 2.
The portability pattern¶
The intended workflow combining recipes 2 and 3:
- Manually apply patches via the GUI on the initial orthomosaic.
- Run Recipe 3 to extract patches into
Patchattributes. - Save the project.
- Re-build the orthomosaic (different resolution, different blending mode, after additional GCPs added, etc.).
- Run Recipe 2 to re-apply the same shape→camera assignments on the new orthomosaic.
This pattern is the documented answer to "how do I keep my patches when I re-build the orthomosaic" — there is no GUI equivalent for the persist-and-replay workflow.
Caveats¶
Orthomosaic.Patch.image_keysis a list. A single camera per patch is the typical case; multi-camera blending within a single patch (multi-band imagery, exposure stacking) is possible by passing multiple keys. Verify with a small-scale test before relying on it in production.orthomosaic.update()is required to render the patches. The patch dictionary stores the assignments;update()re-renders the visible orthomosaic. Without the call, the patches are stored but not visible.- Recipe 1's metric does not match the GUI's exactly. For marginal cases — multiple cameras with similar centroid distance — the script's pick may differ from the GUI's suggestion. The full GUI metric (centroid-distance, surface-distance, normal-alignment) is not exposed in the Python API.
- Aligned cameras only. Cameras with
camera.transform is Nonecannot be used as patch sources. The recipes above filter accordingly. - The geometry-type API shifted in 2.x. PhotoScan 1.x scripts
use
shape.type == Metashape.Shape.Type.Polygon; on Metashape 2.x the equivalent isisinstance(shape.geometry, Metashape.Geometry.Polygon). Translate any 1.x snippets copy-pasted from the forum. - Shape-attribute case sensitivity. The recipes use
shape.attributes["Patch"]— case-sensitive key. The GUI's attribute editor preserves the case you enter. If you use a different attribute name, update both directions of the pattern (Recipe 2 reader, Recipe 3 writer).
Decision picker¶
flowchart TD
A["You need to apply patches to many shapes."]
A --> B{"Are the shape→camera assignments<br/>already encoded somewhere?<br/><i>CSV, shape attribute, database</i>"}
B -->|Yes| C{"Already in shape attributes?"}
C -->|Yes| D["<b>Recipe 2</b><br/>apply from shape attributes"]
C -->|No| E["Copy the encoded mapping into<br/>shape attributes first,<br/>then <b>Recipe 2</b>."]
B -->|No| F{"Need patches that survive<br/>orthomosaic rebuilds?"}
F -->|Yes| G["Apply via GUI,<br/>then <b>Recipe 3</b> to extract +<br/><b>Recipe 2</b> to replay on rebuild."]
F -->|No| H["<b>Recipe 1:</b><br/>auto-patch by centroid distance.<br/>Re-run after each rebuild."]
Runnable demonstration on the Aerial-with-GCPs sample dataset¶
The script below assigns a patch from the central camera to a synthetic polygon over the dataset's centre, exercises Recipe 1 on a one-shape chunk, and verifies the patch was stored.
Demo verified: ✗ — pending Tier 3 reproduction on Metashape Pro 2.2 / 2.3 with the Aerial-with-GCPs sample dataset. The API surface is introspection-verified; end-to-end execution requires a built orthomosaic and is blocked at Tier 3.
"""Apply Recipe 1 to a synthetic polygon over the orthomosaic
centre. Verifies that:
- Orthomosaic.Patch construction works
- chunk.orthomosaic.patches[shape] = patch persists the assignment
- chunk.orthomosaic.update() re-renders without errors
"""
import Metashape
doc = Metashape.app.document
chunk = doc.chunk
if chunk.orthomosaic is None:
raise SystemExit("Build an orthomosaic before running this demo.")
ortho = chunk.orthomosaic
# Synthetic polygon: small square at the orthomosaic centre.
cx = (ortho.left + ortho.right) / 2
cy = (ortho.bottom + ortho.top) / 2
half = min(ortho.right - ortho.left, ortho.top - ortho.bottom) * 0.05
poly_pts = [
Metashape.Vector([cx - half, cy - half, 0.0]),
Metashape.Vector([cx + half, cy - half, 0.0]),
Metashape.Vector([cx + half, cy + half, 0.0]),
Metashape.Vector([cx - half, cy + half, 0.0]),
Metashape.Vector([cx - half, cy - half, 0.0]), # closing point
]
shape = chunk.shapes.addShape()
shape.geometry = Metashape.Geometry.Polygon(poly_pts)
shape.label = "demo_patch"
# Pick the camera nearest to the chunk centroid (Recipe 1
# simplified — single shape).
best_cam = None
best_dist = float("inf")
target = Metashape.Vector([cx, cy])
for cam in chunk.cameras:
if cam.transform is None:
continue
img_xy = Metashape.Vector([cam.center.x, cam.center.y])
d = (img_xy - target).norm()
if d < best_dist:
best_dist = d
best_cam = cam
if best_cam is None:
raise SystemExit("No aligned camera found.")
print(f"assigning patch from camera {best_cam.label} (d={best_dist:.2f})")
patch = Metashape.Orthomosaic.Patch()
patch.image_keys = [best_cam.key]
ortho.patches[shape] = patch
print(f"patches dict size: {len(ortho.patches)}")
print(f"image_keys for demo_patch: {ortho.patches[shape].image_keys}")
ortho.update()
print("orthomosaic updated.")
Expected output: the synthetic polygon is added to the
chunk; len(ortho.patches) increments by 1; image_keys
contains the chosen camera's key; update() completes without
error. Save the project and re-open in the GUI to verify the
patch is visible.
References¶
- Forum thread, Apply patch on multiple shapes, 2019–2020 — primary source. The three the scripts: msg 49019 (auto-rank by centroid), msg 49340 (patch from shape attribute), msg 49502 (extract patches into attributes). GUI ranking explanation: msg 49039.
- Forum thread, exportShapes — using shape attributes for export, 2019 — the cited precedent for the shape-attribute pattern that the patching script reuses.
- Metashape Python Reference (2.3.1):
Chunk.orthomosaic,Orthomosaic.patches,Orthomosaic.Patch,Orthomosaic.update,Chunk.shapes,Shape.geometry,Shape.attributes,Geometry.Polygon. - Metashape Pro User Manual (2.3), ch. 5 Orthomosaic, § Orthomosaic seamlines and patching — official description of the Patch shape feature in the Ortho view.
- Orthomosaic seamline editing (patching) (Agisoft KB) — the GUI Assign Images / Draw Patch workflow this script automates, plus the Fill tool for excluding objects or filling holes.