I would like to request a feature which would allow markers to be copied between chunks. If multiple chunks contain some of the same images, I would like to be able to place my markers once in one chunk and then copy them to the other chunks to avoid having to repeat my work.
Because doing this is important to my workflow, I wrote a very simple python script which does what I want. However, I believe that implementing this feature into the main application would be a helpful addition for other users in similar situations to me.
import Metashape
from PyQt5.QtWidgets import QInputDialog, QApplication
import re
def normalize_filename(filename):
"""
Normalise a filename by removing variable components (such as timestamps, stack identifiers,
and file-specific suffixes) and replacing the file extension with a standard placeholder.
This ensures filenames with equivalent base content can be compared or grouped consistently
regardless of differences introduced by cameras or capture sessions. The actions of this
function are specific to my dataset and my use-case; this will need to be adjusted to fit
the naming conventions of the images.
# Parameters
- filename: The input filename string to normalise (e.g. "IMG_20231017_153455 --- sample - photostack 3 - photo 2.jpg"). Type=str.
# Returns
- normalized: The normalised version of the filename with variable components removed and the
file extension standardised (e.g. "sample.ext"). Type=str.
"""
# Remove leading timestamp pattern (e.g. "IMG_20231017_153455 --- ")
normalized = re.sub(r'^IMG_\d{8}_\d{6}(?:~\d+)? --- ', '', filename)
# Remove " - photostack X - .(JPG|ORF)" section (used by some image capture software)
normalized = re.sub(r' - photostack \d+ - \.(JPG|ORF)', '', normalized)
# Remove trailing " - photo X" (individual image identifiers within a sequence)
normalized = re.sub(r' - photo \d+$', '', normalized)
# Replace image file extensions (.jpg or .dng) with a standard placeholder extension (.ext)
normalized = re.sub(r'\.(jpg|dng)$', '.ext', normalized)
# Return the cleaned, normalised filename
return normalized
# Access the currently opened Metashape document
doc = Metashape.app.document
# Verify that a Metashape document is open; otherwise, raise an error
if not doc:
raise ValueError("No Metashape document is loaded.")
# Access the active chunk within the document
source_chunk = doc.chunk
if not source_chunk:
raise ValueError("No active chunk selected.")
# Display the name of the source chunk being used for marker copying
print(f"Source chunk selected: {source_chunk.label}")
# Initialise the Qt application context for GUI interaction (if not already running)
app = QApplication.instance() or QApplication([])
# Ask the user to specify a substring filter for selecting target chunks
filter_string, ok = QInputDialog.getText(
None,
"Chunk Filter",
"Enter a substring to copy markers only to chunks which contain that substring "
"(leave empty to copy markers to all chunks):"
)
# If the dialog is cancelled, use an empty string (no filtering)
if not ok:
filter_string = ""
print(f"Filtering chunks with: '{filter_string}'")
# Iterate through all chunks in the document
for target_chunk in doc.chunks:
# Skip copying markers to the source chunk itself
if target_chunk == source_chunk:
continue
# If a filter string was provided, skip chunks that do not contain it in their label
if filter_string and filter_string not in target_chunk.label:
print(f"Skipping chunk: {target_chunk.label} (does not match filter)")
continue
print(f"Copying markers to chunk: {target_chunk.label}")
# Build dictionaries mapping normalised camera names to camera objects for both the source and target chunks to enable filename-based matching
source_cameras = {normalize_filename(camera.label): camera for camera in source_chunk.cameras}
target_cameras = {normalize_filename(camera.label): camera for camera in target_chunk.cameras}
print(f"Source cameras: {len(source_cameras)}, Target cameras: {len(target_cameras)}")
# Iterate through all markers in the source chunk
for source_marker in source_chunk.markers:
print(f"Processing marker: {source_marker.label}")
# Create a new marker in the target chunk with the same label
new_marker = target_chunk.addMarker()
new_marker.label = source_marker.label
# Iterate through all camera projections of the current source marker
for source_camera, projection in source_marker.projections.items():
# Match the camera between source and target using normalised filenames
normalized_source_camera_label = normalize_filename(source_camera.label)
if normalized_source_camera_label in target_cameras:
target_camera = target_cameras[normalized_source_camera_label]
print(f" Marker '{source_marker.label}' in image '{source_camera.label}': projection.coord = {projection.coord}")
# Validate the projection data before attempting to copy
if projection.coord is None:
print(f" Skipping marker '{source_marker.label}' - projection.coord is None")
continue
if not hasattr(projection, 'pinned'):
print(f" Skipping marker '{source_marker.label}' - missing 'pinned' attribute")
continue
if len(projection.coord) != 2 and len(projection.coord) != 3:
print(f" ERROR: projection.coord has {len(projection.coord)} components!")
continue
# Extract only the (x, y) coordinates in case a (x, y, z) vector is provided
fixed_coord = Metashape.Vector([projection.coord[0], projection.coord[1]])
try:
# Create a new projection in the target chunk corresponding to this marker-camera pair
new_marker.projections[target_camera] = Metashape.Marker.Projection(fixed_coord, projection.pinned)
print(f" Marker '{source_marker.label}' copied to image: {source_camera.label} with coord: {fixed_coord}")
except Exception as e:
print(f" Failed to copy marker '{source_marker.label}' to image '{source_camera.label}': {e}")
print(f"Markers copied successfully to {target_chunk.label}!")
print("All matching chunks updated.")