├── .gitignore ├── Data Collection Cheat Sheet.pdf ├── Georef Example.csv ├── LICENSE ├── README.md ├── ReefShape_Scripts ├── 01_full_reefshape_workflow.py ├── 02_align_chunks.py ├── 03_optimization_process.py ├── 04_scale_model.py ├── 05_create_boundary.py ├── 06_copy_boundary.py ├── 07_calculate_area_ratio.py ├── 08_clean_project.py └── ui_components.py └── Scalebar Example.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | questions.md 3 | TEST/ 4 | __pycache__/ 5 | -------------------------------------------------------------------------------- /Data Collection Cheat Sheet.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Perry-Institute/ReefShape/98dab5dd12273a2f67b4343106c039ac704eeef0/Data Collection Cheat Sheet.pdf -------------------------------------------------------------------------------- /Georef Example.csv: -------------------------------------------------------------------------------- 1 | gis_label,gis_lat,gis_lon,Marker Depth (m),xyAcc,zAcc 2 | target 1,yy.yyyyyy,xx.xxxxxx,[-meters],[meters],[meters] 3 | target 2,yy.yyyyyy,xx.xxxxxx,[-meters],[meters],[meters] 4 | target 3,yy.yyyyyy,xx.xxxxxx,[-meters],[meters],[meters] 5 | target 4,yy.yyyyyy,xx.xxxxxx,[-meters],[meters],[meters] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Perry Institute for Marine Science 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | ReefShape Logo 3 |

4 | 5 | ReefShape is a methodology for underwater photogrammetry or Large Area Imaging developed specifically for coral reef monitoring. This GitHub contains python scripts for the efficient processing of georeferenced, time-series coral reef photomosaics using Agisoft Metashape, created by Will Greene and Sam Marshall at the Perry Institute for Marine Science. 6 | 7 | We are in the process of publishing this workflow in a peer-reviewed journal. In the interim, we have put together a white paper designed to more fully explain the entire process, from data collection through data analysis. Please find the ReefShape white paper here. 8 | 9 | ### ReefShape Webinar 10 | [![Webinar Thumbnail](https://www.dropbox.com/scl/fi/fqazzf3t70hdx24sebdec/ReefShape-Webinar-Thumbnail.jpg?rlkey=1fx81abbdsc11mocxg7bu7438&raw=1)](http://www.youtube.com/watch?v=N4yzl1FFQcE "ReefShape Webinar") 11 | 12 | ## Intro 13 | At its core, ReefShape is a method for the setup and collection of underwater photogrammetry data that when done properly, allows for full automation of the processing and time-series alignment pipelines when using our custom scripts. This GitHub repo serves as a landing page for the method, containing all of the information you need to begin using ReefShape. Our collection of python scripts are designed to expedite processing of underwater photogrammetry data in Agisoft Metashape Professional V2.0 and above. Specifically, they allow for the automation of the entire photogrammetry process, provided that coded Metashape targets are used for corner markers and scalebars, and that GPS locations and depths of the corner markers are collected when a plot is first established, as is outlined in the ReefShape data collection protocol. 14 | 15 | The scripts facilitate automatic time-series alignment of plots where permanent corner markers are installed. Automatic data export is also included for analysis in standard GIS software or TagLab. A publication describing the ReefShape method will be made available in the near future. This readme file gives an overview of the scripts and their use, as well as information on using the ReefShape GPS data collection survey. 16 | 17 | Metashape Screenshot 18 | Screenshot of a reef plot processed with ReefShape. The reference panel at the left shows the inclusion of real-world georeferencing, depth, and scaling information.
19 |
20 | 21 | ### Corner Markers 22 | The ReefShape workflow relies on auto-detectable corner markers being either permenently installed (for time-series monitoring of a plot) or temporarily placed. We have developed two kinds of custom markers for this purpose: simple flexible molded PVC pucks, and experimental anti-fouling markers that resist algal growth. Both types can be nailed, epoxied, or cemented to the substrate for installation. For more information on these corner markers or to purchase sets, contact me at wgreene@perryinstitute.org. 23 | 24 |

25 | Corner Markers 26 |

27 | Set of four permanent corner tags utilizing Metashape coded targets. These markers are designed to be nailed to the substrate, demarcating the boundaries of a plot upon setup. Automatic time-series alignment of plots relies on these "ground control points", and subsequent timepoints require only marker cleaning and image acquisition, saving significant time underwater.
28 |
29 | 30 | These scripts are under active development - we recommend checking periodically to download the latest version. If you are having trouble using any of the scripts, email me at wgreene@perryinstitute.org and I'll get back to you as soon as I can. 31 | 32 | ### Basics 33 | This repo is set up with a few important resources (this readme, scalebar example, georeferencing example, & data collection cheat sheet) in the root folder, and a folder called ReefShape_scripts that contains the script collection. It is designed to be downloaded in its entirety with the Code --> Download ZIP button. 34 | 35 | ### Installation 36 | To install the ReefShape scripts, you just need to download this repo and unzip it into a location of your choice. The contents of the ReefShape_scripts folder should then be copied into the Metashape scripts directory. When properly installed, a custom ReefShape menu bar item containing all scripts will appear automatically each time you start Metashape. The location of the scripts folder varies on Mac vs PC: 37 | 38 | Windows: C:\Users\[YOUR USERNAME]\AppData\Local\Agisoft\Metashape Pro\scripts 39 | 40 | Mac: /Users/[YOUR USERNAME]/Library/Application Support/Agisoft/Metashape Pro/scripts 41 | 42 | NOTE: On both Mac and PC, the locations listed above are contained within folders that are hidden by default. You can locate them by manually typing the correct folder path in the path/address bar of Finder / File Explorer. Storing the scripts in other locations will not allow them to be added to Metashape automatically. 43 | 44 | It is also possible to run the scripts without full installation. The user can simply open Metashape and run any of the scripts by clicking on Tools --> Run Script... in Metashape, then selecting the desired script (described below). This will create a custom menu button called ReefShape in the Metashape GUI that has the script inside it. To run it, just click the newly created menu option. Note that the custom menu bar options will be lost upon restarting Metashape unless they are installed properly as described above. 45 | 46 | ## ReefShape Scripts 47 | 48 | Here's what all the scripts in the ReefShape_scripts folder do. 49 | 50 | ui_components.py This file contains class definitions for user interface components used in the full workflow script (full_reefshape_workflow.py) and the align timepoints script (align_chunks.py). It cannot function as a standalone script, but in order for the other scripts to run they must be located in the same folder as this file. These components are in a separate file to improve code organization and make it easier for others to expand on these scripts. 51 | 52 | 01_full_reefshape_workflow.py This file is the main script that can run the entire ReefShape process start to finish. When you click on it, it will bring up a dialog box where you can add photos, name the project and chunk, input georeferencing and scaling information, and a few other basic options (if you have already set up your project by adding photos and naming it properly, you may ignore these parts of the dialog box). It has built-in checks to enable the script to be run from any point in the process, and it will not repeat any steps. This allows easy integration of manual processing or refinement at any stage in the process. If you need the script to redo any step in the process (such as model building, orthomosaic building, DEM building, etc.), simply delete the selected data product from the project and run the script again to redo that step. 53 | 54 | ReefShape Dialog Box 55 | The dialog box for the full ReefShape workflow. It allows for all necessary information to be input at once for full automation of the photogrammetry process.
56 |
57 | 58 | 59 | ReefShape Process 60 | Flow diagram of the process automated within the full ReefShape workflow script.
61 |
62 | 63 | 02_align_chunks.py This script implements a dialog box used to align two timepoints of a photomosaic plot to one another, each of which is contained within a separate chunk of the same project. The script is meant to be used in conjunction with the underwater workflow implemented in full_reefshape_workflow.py. Once the user has collected subsequent sets of photos of a plot with permanent corner markers, this script can be run to align the subsequent sets to a previous timepoint. The data from the earlier time point must be already processed before this script is used. It functions by detecting markers in the "target chunk", creating a temporary reference file with the precise estimated locations of the corner markers from the "reference chunk", and importing them into the target chunk. After running this script, the user should verify the four corner markers were detected properly and that the reference information was imported successfully before running the full reefshape workflow to complete the photogrammetry process and generate data products. If the targets failed to be detected, the user can manually place markers (with the proper names, i.e. "target 1") and re-run the align chunks script to bring over the referencing information from the reference timepoint again. 64 | 65 | Align Timepoints Dialog Box 66 | The Align Timepoints dialog box, facilitating time-series alignment.
67 |
68 | 69 | 03_optimization_process.py This script employs a tie-point culling and camera calibration optimization process to improve alignment accuracy and reconstruction consistency. It first culls tie points with a reconstruction uncertainty statistic above 25, then culls remaining tie points with a projection accuracy statistic worse than 15. Finally, it optimizes the camera alignment with all parameters checked and "fit additional corrections" checked. In our experience, fitting all possible parameters helps to properly account for atypical lens distortions associated with dome ports and aspherical lens elements and improves geometric consistency between timepoints. While this process is integrated into the full workflow, it is sometimes useful to have this functionality as a standalone utility. 70 | 71 | 04_scale_model.py This script prompts the user for a scalebar text file containing the lengths of scalebars that may be present in the currently selected chunk / timepoint. It then creates scalebars out of any scalebar marker pairs found in the chunk and inputs the correct lengths and measurement accuracies, then updates the referencing to reflect this information. While this process is integrated into the full workflow, it is sometimes useful to have this functionality as a standalone utility. 72 | 73 | 05_create_boundary.py This script brings up a GUI box that allows the user to input a sequence of four markers in the currently selected chunk, and generates an outer boundary polygon by stringing the locations of the four markers together. This can be run to automatically define a ROI based on the locations of markers in your project (typically, the corner markers). 74 | 75 | 06_copy_boundary.py This script brings up a GUI box that allows the user to select a source chunk and a target chunk. It functions by copying the outer boundary polygon from the source chunk into the target chunk. This is useful for copying a custom ROI between chunks to get aligned outputs for taglab. 76 | 77 | 07_calculate_area_ratio.py This script automates the process of calculating the 3D to 2D surface area ratio (a commonly-used rugosity metric) for the currently selected chunk / timepoint. It does this in full 3D (as opposed to 2.5D, as in most GIS workflows), thereby capturing a better representation of the reef that includes underhangs. It accomplishes this task by duplicating and clipping the 3D mesh by the polygon defined as the outer boundary (which is automated in the full workflow and create boundary scripts, but can be done manually as well), then calculating the 3D surface area of the clipped mesh and comparing it to the 2D planar area of the boundary to generate the ratio. The temporary clipped mesh is then deleted. A GUI box displays the calculated ratio, and it is printed in the console as well. For this script to function properly, a single outer boundary polygon and 3D mesh must be present in the chunk. 78 | 79 | 08_clean_project.py This script looks in the currently selected chunk / timepoint for unnecessary files for long-term storage (key points, depth maps, orthophotos), and deletes them. This dramatically reduces file sizes and is recommended to be run once the user is happy with the data products for a given timepoint. 80 | 81 | Note: The scripts are prefixed with a number so that they appear in the proper order in the ReefShape menu bar dropdown. Over time, new scripts may be added, and thus the numbering on these scripts are subject to change. 82 | 83 | ### Other Files 84 | 85 | These are the additional files in the root folder of this repo: 86 | 87 | Scalebar Example.txt This file is an example text file that contains the scalebar lengths. A scalebar in this context consists of a "bar" (usually a piece of acrylic, generally ~580mm x 80mm x 3mm) with printed coded Metashape targets taped / glued to either end (see image below). The targets used for scalebars must all be unique, and when using this process, markers 1-4 are reserved for corner markers. For each scalebar, the user must measure the distance between the centerpoints of the targets, then add a line in a text file that corresponds to that scalebar. Each time this scalebar is detected in a project, its distance will be input into Metashape's reference panel. See text file for the correct formatting. 88 | 89 | Scalebar Image 90 | Example illustration of a scalebar
91 |
92 | 93 | Georef Example.csv This CSV file shows the correct format for georeferencing information, matching what is exported by the ReefShape Survey123 survey designed to accompany this method (see below section for more details). We strongly recommend using the ReefShape survey to collect GPS data, however it is possible to use another collection method. If you choose to collect GPS data with a handheld receiver (GPS dive watch, Garmin eTrex, etc), you must download and format your own data for input into the ReefShape workflow script. Use this csv file as a template. In order for the script to run properly, you must specify how your georeferencing file is formatted in the ReefShape dialog box, i.e. which reference values (lat, long, depth, etc) are in which columns. The Full ReefShape Workflow script is set by default to match the formatting of this example file, where the label (target name) is column 1, latitude is column 2, longitude is column 3, depth is column 4, xy accuracy is column 5, and z accuracy is column 6. Since there is one header row, the import starts at row 2. Lat and long must be in decimal degrees, depth must be in negative meters, and the file must be saved as a simple CSV. 94 | 95 | Data Collection Cheat Sheet.pdf This PDF has useful reminders for how to set up plots and carry out data collection correctly to work with these scripts. It is not a full explanation of anything; rather it is meant to be used as a quick reference in the field to make sure you aren't forgetting anything! 96 | 97 | 98 | ## Collecting GPS information with ReefShape Survey 99 | 100 | We have created a public Survey123 survey to facilitate easy GPS data collection to accompany this workflow. When a survey is submitted, it automatically emails pre-formatted location data to the user. The survey can be accessed by anybody for free, even without an ArcGIS Online account. The user must download the ESRI Survey123 app on their smartphone, then follow this link to the survey (or scan the QR code below) and select the option to open the survey in the app. The survey is meant to be filled out in the field, using either your phone's location or the location of a bluetooth receiver such as a Garmin GLO2 or Bad Elf GPS. The user should place their smartphone in a waterproof pouch or case, and swim out over the locations of each of the four corner markers that define the plot. If GPS locations are collected using an alternative method (handheld GPS, RTK GNSS system, drone, etc), the survey can be filled in with the corresponding location data to aid in correctly formatting it for use in the ReefShape process. 101 | 102 | The survey contains a repeating section allowing for the input of a GPS point and depth corresponding to each marker. The first repeat corresponds by default to target 1; the user should locate that marker, hold the phone or bluetooth receiver directly above it, then press the crosshairs button to record the location, then repeat for all corners. Once the locations of the corner markers are recorded, the corresponding depths, which should be measured in meters with a dive watch or other instrument, can be input for each target. Once this process is complete, the survey can be submitted and an email will be sent to the user with the pre-formatted data, ready to be plugged into the full ReefShape workflow script. Remember: if you're using real-world coordinates, the coordinate system (CRS) in the Full ReefShape Workflow dialog box should be set to WGS84 + EGM96 (EGM96 is a global geoid model included in Metashape that approximates sea level as opposed to regular WGS84 that uses an ellipsoidal height). 103 | 104 | ReefShape Survey123 Screenshot 105 | Screenshot of the Survey123 ReefShape Interface.
106 |
107 | 108 | ![image](https://github.com/Perry-Institute/ReefShape/assets/117117153/c61a2638-63a9-44ef-8104-667496354bcf) 109 | 110 | ## What if I do not or cannot collect real-world location data? 111 | 112 | Don't worry! We've created a parallel survey called ReefShape Local that helps the user create a georeference file for ReefShape that uses local coordinates instead of GPS coordinates. You can access the survey here: ReefShape Local. The way it works is simple: you just tell the survey which marker corresponds to which corner (northeast, southeast, southwest, northwest, or north, east, south, west, depending on how you lay out your plot), and input its depth, and the survey does the rest for you, creating and emailing you a reference file that allows for full automation using ReefShape. Remember: if you're using ReefShape Local and NOT using real-world location data, the coordinate system (CRS) in the Full ReefShape Workflow dialog box should be left in Local Coordinates (m). 113 | 114 | 115 | That's all for now! I hope ReefShape helps save you time and energy on your coral photogrammetry projects and allows you to spend more time collecting important ecological data to help understand and protect our reefs. 116 | 117 | -Will 118 | -------------------------------------------------------------------------------- /ReefShape_Scripts/01_full_reefshape_workflow.py: -------------------------------------------------------------------------------- 1 | """ 2 | Full Underwater Imagery Workflow 3 | Sam Marshall 4 | 5 | Implements underwater photomosaic workflow developed by Will Greene 6 | Many of the component scripts were written by Will Greene and Asif-ul Islam 7 | These were assembled into this full workflow and UI by Sam Marshall 8 | 9 | Usage notes: 10 | - The script will only try to import scaling and georeferencing data if there are zero scalebars and will only try to 11 | detect markers if there are zero markers, regardless of whether the user has enabled auto-detect markers in the dialog box. 12 | This means that if markers and scalebars are added by hand or it takes multiple tries to get it to read the scaling and 13 | georeferencing data properly, the remainder of the workflow should execute whether or not the user selects auto-detectable markers. 14 | There are ways around this that will still break the script (eg georeferencing by hand without adding scalebars, then running the 15 | workflow with auto-detect markers enabled), but in general it should work regardless of how or in what order the user detects markers or 16 | scales the model. 17 | One notable exception is if the user wishes to have a mix of automatic and hand-placed markers: the script does not support this, 18 | but if these steps are done outside of the script it should build the rest of the outputs without issue. 19 | """ 20 | import Metashape 21 | from os import path 22 | import sys 23 | import csv 24 | import re 25 | from PySide2 import QtGui, QtCore, QtWidgets # NOTE: the style enums (such as alignment) seem to be in QtCore.Qt 26 | from ui_components import AddPhotosGroupBox, BoundaryMarkerDlg, GeoreferenceGroupBox 27 | 28 | class FullWorkflowDlg(QtWidgets.QDialog): 29 | 30 | def __init__(self, parent): 31 | # set document info 32 | self.doc = Metashape.app.document 33 | self.project_folder = path.dirname(Metashape.app.document.path) 34 | self.project_name = path.basename(Metashape.app.document.path)[:-4] # extracts project name from file path 35 | self.output_dir = self.project_folder 36 | 37 | # set default crs options 38 | self.localCRS = Metashape.CoordinateSystem('LOCAL_CS["Local Coordinates (m)",LOCAL_DATUM["Local Datum",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]]]') 39 | self.defaultCRS = Metashape.CoordinateSystem('COMPD_CS["WGS 84 + EGM96 height",GEOGCS["WGS 84",DATUM["World Geodetic System 1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.01745329251994328,AUTHORITY["EPSG","9102"]],AUTHORITY["EPSG","4326"]],VERT_CS["EGM96 height",VERT_DATUM["EGM96 geoid",2005,AUTHORITY["EPSG","5171"]],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AUTHORITY["EPSG","5773"]]]') 40 | self.autoDetectMarkers = False 41 | # set default corner marker arrangement 42 | self.corner_markers = [1, 2, 3, 4] 43 | 44 | # initialize main dialog window 45 | QtWidgets.QDialog.__init__(self, parent) 46 | self.setWindowTitle("Run Underwater Workflow") 47 | 48 | # ----- Build Widgets ----- 49 | # these are declared as member variables so that they can be referenced 50 | # and modified by slots that are outside of the constructor 51 | # -- General -- 52 | # Coordinate system input 53 | self.labelCRS = QtWidgets.QLabel("Coordinate System:") 54 | self.btnCRS = QtWidgets.QPushButton("Select CRS") 55 | self.txtCRS = QtWidgets.QPlainTextEdit("Local Coordinates (m)") 56 | self.txtCRS.setFixedHeight(40) 57 | self.txtCRS.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) 58 | self.txtCRS.setReadOnly(True) 59 | 60 | # generic preselection 61 | self.checkBoxPreSelect = QtWidgets.QCheckBox("Enable Generic Preselection") 62 | self.checkBoxPreSelect.setChecked(True) 63 | self.checkBoxPreSelect.setToolTip("Generic preselection speeds up photo alignment, but for photo sets with severe caustics disabling it can make alignment more effective") 64 | 65 | # set orthomosaic resolution 66 | self.checkBoxDefaultRes = QtWidgets.QCheckBox("Use default resolution") 67 | self.checkBoxDefaultRes.setToolTip("If this option is enabled, Metashape will calculate the orthomosaic resolution based on the ") 68 | self.labelCustomRes = QtWidgets.QLabel("Custom Resolution (m): ") 69 | self.spinboxCustomRes = QtWidgets.QDoubleSpinBox() 70 | self.spinboxCustomRes.setDecimals(5) 71 | self.spinboxCustomRes.setValue(0.0005) 72 | 73 | # set mesh quality 74 | self.labelMeshQuality = QtWidgets.QLabel("Mesh Quality") 75 | self.comboMeshQuality = QtWidgets.QComboBox() 76 | self.comboMeshQuality.addItems(["Ultra High", "High", "Medium", "Low", "Lowest"]) 77 | self.comboMeshQuality.setCurrentIndex(2) 78 | 79 | # directory input for exports 80 | self.labelOutputDir = QtWidgets.QLabel("Folder for outputs: ") 81 | self.btnOutputDir = QtWidgets.QPushButton("Select Folder") 82 | self.txtOutputDir = QtWidgets.QPlainTextEdit("No file selected") 83 | self.txtOutputDir.setFixedHeight(40) 84 | self.txtOutputDir.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) 85 | self.txtOutputDir.setReadOnly(True) 86 | 87 | # standard outputs for gis 88 | self.checkBoxExport = QtWidgets.QCheckBox("Export full-size products for use in GIS") 89 | self.checkBoxExport.setChecked(True) 90 | self.checkBoxExport.setToolTip("If this option is checked, the data products will be exported at full size and resolution." 91 | "\n\nIf neither this box nor the Taglab exports box are selected, the data products will not be exported") 92 | 93 | # taglab outputs 94 | self.checkBoxTagLab = QtWidgets.QCheckBox("Create tiled outputs for use in TagLab") 95 | self.checkBoxTagLab.setChecked(True) 96 | self.checkBoxTagLab.setToolTip("TagLab requires image inputs to have certain size and compression parameters" 97 | "\n\nIf this option is checked, a second set of outputs will be created that are broken into blocks that can be used in TagLab") 98 | self.checkBoxTagLab.setChecked(False) 99 | 100 | # run script button 101 | self.btnOk = QtWidgets.QPushButton("Ok") 102 | self.btnOk.setFixedSize(90, 50) 103 | self.btnOk.setToolTip("Run workflow") 104 | 105 | # cancel and exit dialog 106 | self.btnQuit = QtWidgets.QPushButton("Close") 107 | self.btnQuit.setFixedSize(90, 50) 108 | 109 | # --- Assemble widgets into layouts --- 110 | # these are declared as local variables because they exist only within the scope of the dialog box, and 111 | # the layout structure remains unchanged by user actions/slots (with the exception of the reference format box) 112 | # additionally, ownership of layouts gets passed to their parent when they are added to a widget or 113 | # another layout, so they cannot be accessed via self. 114 | main_layout = QtWidgets.QVBoxLayout() # create main layout - this will hold sublayouts containing individual widgets 115 | 116 | # -- Create sublayouts -- 117 | crs_layout = QtWidgets.QHBoxLayout() 118 | crs_layout.addWidget(self.labelCRS) 119 | crs_layout.addWidget(self.txtCRS) 120 | crs_layout.addWidget(self.btnCRS) 121 | 122 | checkbox_layout = QtWidgets.QHBoxLayout() 123 | 124 | checkbox_layout.addWidget(self.checkBoxPreSelect) 125 | checkbox_layout.addStretch() 126 | checkbox_layout.addWidget(self.checkBoxDefaultRes) 127 | resolution_layout = QtWidgets.QHBoxLayout() 128 | resolution_layout.addWidget(self.labelMeshQuality) 129 | resolution_layout.addWidget(self.comboMeshQuality) 130 | resolution_layout.addStretch() 131 | resolution_layout.addWidget(self.labelCustomRes) 132 | resolution_layout.addWidget(self.spinboxCustomRes) 133 | # checkbox_layout.addWidget(self.checkBoxTagLab) 134 | 135 | output_layout = QtWidgets.QHBoxLayout() 136 | output_layout.addWidget(self.labelOutputDir) 137 | output_layout.addWidget(self.txtOutputDir) 138 | output_layout.addWidget(self.btnOutputDir) 139 | 140 | export_layout = QtWidgets.QHBoxLayout() 141 | export_layout.addWidget(self.checkBoxExport) 142 | export_layout.addWidget(self.checkBoxTagLab) 143 | 144 | ok_layout = QtWidgets.QHBoxLayout() 145 | ok_layout.addWidget(self.btnOk) 146 | ok_layout.addWidget(self.btnQuit) 147 | 148 | 149 | # -- Assemble sublayouts into groupboxes -- 150 | general_groupbox = QtWidgets.QGroupBox("General") 151 | general_layout = QtWidgets.QVBoxLayout() 152 | general_layout.addLayout(crs_layout) 153 | general_layout.addLayout(checkbox_layout) 154 | general_layout.addLayout(resolution_layout) 155 | general_layout.addLayout(output_layout) 156 | general_layout.addLayout(export_layout) 157 | general_groupbox.setLayout(general_layout) 158 | 159 | 160 | # -- Assemble groupboxes into main layout -- 161 | addphotos_groupbox = AddPhotosGroupBox(self) 162 | self.georef_groupbox = GeoreferenceGroupBox(self) 163 | main_layout.addWidget(addphotos_groupbox) 164 | main_layout.addWidget(general_groupbox) 165 | main_layout.addWidget(self.georef_groupbox) 166 | # main_layout.addLayout(ok_layout) 167 | 168 | # a somewhat complicated system of wrapper widgets is needed to accomodate the scroll layout 169 | # here is a summary of the structure: 170 | # main dialog(self) > scroll_layout > scroll_area > main_widget > main_layout 171 | 172 | main_widget = QtWidgets.QWidget() # wrapper widget for scroll area 173 | main_widget.setLayout(main_layout) 174 | scroll_area = QtWidgets.QScrollArea() 175 | scroll_area.setFrameShape(QtWidgets.QFrame.NoFrame) 176 | scroll_area.setWidget(main_widget) 177 | scroll_area.setAlignment(QtCore.Qt.AlignHCenter) 178 | scroll_layout = QtWidgets.QVBoxLayout() 179 | scroll_layout.addWidget(scroll_area) 180 | scroll_layout.addLayout(ok_layout) # place run and close buttons outside of scroll area so theyre always visible 181 | ok_layout.setEnabled(True) 182 | self.setLayout(scroll_layout) # set wrapper layout for scroll area as main dialog layout 183 | 184 | # adjust size and position of main widget 185 | self.setMinimumSize(main_widget.frameGeometry().width(), 0.6*parent.frameGeometry().height()) 186 | # determining the actual width needed to display the widget without cutting it off or leaving extra space is 187 | # surprisingly difficult because of the padding and margin space between nested widgets - this workaround was found by trial and error 188 | width = (scroll_area.frameGeometry().width() + main_widget.frameGeometry().width()) / 2 189 | x = parent.frameGeometry().width()/2.0 - width/2 # set starting position so that widget is roughly centered on screen 190 | if(x<0): x = 0 191 | y = parent.frameGeometry().height()/2.0 - 0.4*parent.frameGeometry().height() 192 | self.setGeometry(parent.frameGeometry().x() + x, parent.frameGeometry().y() + y, width, 0.85*parent.frameGeometry().height()) 193 | 194 | # --- Connect signals and slots --- 195 | # these two syntaxes for connecting signals to slots should be equivalent, but the first method (dot notation) may make it easier 196 | # to use widget-specific signals (such as currentIndexChanged) instead of core signals 197 | self.checkBoxDefaultRes.stateChanged.connect(self.onResolutionChange) 198 | self.btnOutputDir.clicked.connect(self.getOutputDir) 199 | self.btnCRS.clicked.connect(self.getCRS) 200 | 201 | QtCore.QObject.connect(self.btnOk, QtCore.SIGNAL("clicked()"), self.runWorkFlow) 202 | QtCore.QObject.connect(self.btnQuit, QtCore.SIGNAL("clicked()"), self, QtCore.SLOT("reject()")) 203 | 204 | self.exec() 205 | 206 | 207 | def runWorkFlow(self): 208 | ''' 209 | Contains the main workflow structure 210 | ''' 211 | print("Script started...") 212 | self.setEnabled(False) 213 | self.chunk = Metashape.app.document.chunk 214 | 215 | ###### 0. Setting Parameters ###### 216 | if(not self.georef_groupbox.autoDetectMarkers and self.chunk.model == None): 217 | Metashape.app.messageBox("You have initiated the script without specifying georeferencing information. If you ran the align timepoints script first, " 218 | "clicking OK will simply complete the workflow in its entirety for you (no further action needed). \n\n If this is a new project " 219 | "without auto-detectable markers, the script will exit after creating a mesh to allow for manual referencing, leveling, " 220 | "and scaling. \n\n Once this information is added, run the script again to complete the remainder of the workflow.") 221 | 222 | # set constants 223 | ALIGN_QUALITY = 1 # quality setting for camera alignment; corresponds to high accuracy in GUI 224 | DM_QUALITY = 2 ** self.comboMeshQuality.currentIndex() # quality setting for depth maps; corresponds to medium in GUI 225 | INTERPOLATION = Metashape.DisabledInterpolation # interpolation setting for DEM creation 226 | 227 | # set arguments from dialog box 228 | try: 229 | if(self.georef_groupbox.autoDetectMarkers): 230 | scalebars_path = self.georef_groupbox.scalebars_path 231 | georef_path = self.georef_groupbox.georef_path 232 | except: 233 | Metashape.app.messageBox("No files selected. If you would like to automatically detect markers, please select files containing scaling and georeferencing information") 234 | print("Script aborted") 235 | self.setEnabled(True) 236 | return 237 | 238 | # taglab_outputs = self.checkBoxTagLab.isChecked() 239 | generic_preselect = self.checkBoxPreSelect.isChecked() 240 | 241 | if(self.checkBoxDefaultRes.isChecked()): 242 | ORTHO_RES = 0 243 | else: 244 | ORTHO_RES = self.spinboxCustomRes.value() 245 | 246 | DEM_RES = 0 # allow metashape to choose dem resolution by default, since we arent exporting for taglab 247 | 248 | target_type = self.georef_groupbox.target_type 249 | 250 | ref_formatting = [self.georef_groupbox.spinboxRefLabel.value(), self.georef_groupbox.spinboxRefX.value(), 251 | self.georef_groupbox.spinboxRefY.value(), self.georef_groupbox.spinboxRefZ.value(), 252 | self.georef_groupbox.spinboxXAcc.value(), self.georef_groupbox.spinboxYAcc.value(), 253 | self.georef_groupbox.spinboxZAcc.value(), self.georef_groupbox.spinboxSkipRows.value()] 254 | self.corner_markers = self.georef_groupbox.corner_markers 255 | if(self.chunk.tie_points and not self.chunk.meta['init_tie_points']): 256 | self.chunk.meta['init_tie_points'] = str(len(self.chunk.tie_points.points)) 257 | 258 | 259 | ###### 1. Align & Scale ###### 260 | # a. Align photos 261 | if(self.chunk.tie_points == None): # check if photos are aligned - assumes they are aligned if there is a point cloud, could change to threshold # of cameras 262 | self.chunk.matchPhotos(downscale = ALIGN_QUALITY, keypoint_limit_per_mpx = 300, generic_preselection = generic_preselect, 263 | reference_preselection=True, filter_mask=False, mask_tiepoints=True, 264 | filter_stationary_points=True, keypoint_limit=40000, tiepoint_limit=4000, keep_keypoints=True, guided_matching=False, 265 | reset_matches=False, subdivide_task=True, workitem_size_cameras=20, workitem_size_pairs=80, max_workgroup_size=100) 266 | self.chunk.alignCameras(adaptive_fitting = True, min_image=2, reset_alignment=True, subdivide_task=True) 267 | #second alignment step sometimes adds extra photos to the alignment that were missed on the first pass 268 | self.chunk.alignCameras(adaptive_fitting = True, min_image=2, reset_alignment=False, subdivide_task=True) 269 | self.chunk.meta['init_tie_points'] = str(len(self.chunk.tie_points.points)) 270 | self.updateAndSave() 271 | print(" --- Initial alignment completed -- Refining alignment --- ") 272 | 273 | # remove and re-add unaligned photos to try to align them 274 | unaligned_photo_paths = [] 275 | for camera in self.chunk.cameras: 276 | if not camera.transform: # Check if the camera is not aligned 277 | unaligned_photo_paths.append(camera.photo.path) 278 | self.chunk.remove([camera]) # Remove unaligned cameras from the chunk 279 | 280 | if unaligned_photo_paths: # only try to add photos if list of paths is not empty 281 | self.chunk.addPhotos(unaligned_photo_paths) 282 | 283 | # rerun alignment without generic preselection 284 | self.chunk.matchPhotos(downscale = ALIGN_QUALITY, keypoint_limit_per_mpx = 300, generic_preselection = False, 285 | reference_preselection=True, filter_mask=False, mask_tiepoints=True, 286 | filter_stationary_points=True, keypoint_limit=40000, tiepoint_limit=4000, keep_keypoints=True, guided_matching=False, 287 | reset_matches=False, subdivide_task=True, workitem_size_cameras=20, workitem_size_pairs=80, max_workgroup_size=100) 288 | self.chunk.alignCameras(adaptive_fitting = True, min_image=2, reset_alignment=False, subdivide_task=True) 289 | 290 | print(" --- Cameras are aligned and sparse point cloud generated --- ") 291 | self.updateAndSave() 292 | 293 | # b. detect markers 294 | if(len(self.chunk.markers) == 0 and self.georef_groupbox.autoDetectMarkers): # detects markers only if there are none to start with - could change to threshold # of markers there should be, but i think makes most sense to leave as-is 295 | self.chunk.detectMarkers(target_type = target_type, tolerance=20, filter_mask=False, inverted=False, noparity=False, maximum_residual=5, minimum_size=0, minimum_dist=5) 296 | print(" --- Markers Detected --- ") 297 | 298 | # c. scale model 299 | if(len(self.chunk.scalebars) == 0 and self.georef_groupbox.autoDetectMarkers): # creates scalebars only if there are none already - ask Will if this makes sense 300 | ref_except = self.referenceModel(georef_path, ref_formatting) 301 | scale_except = "" 302 | if(not ref_except): 303 | scale_except = self.createScalebars(scalebars_path) 304 | # this structure is really clunky but I'm not sure what the best way to differentiate between sub-exceptions is without defining whole exception classes, which seems excessive 305 | error = "" 306 | if(scale_except or ref_except): 307 | if(scale_except): 308 | print(scale_except) 309 | error = error + scale_except 310 | if(ref_except): 311 | print(ref_except) 312 | error = error + ref_except 313 | Metashape.app.messageBox("Unable to scale and reference model:\n" + error + "Check that the files are formatted correctly and try again, or add markers and scalebars through the Metashape GUI.") 314 | self.reject() 315 | return 316 | else: 317 | self.chunk.updateTransform() 318 | 319 | 320 | if(self.chunk.model == None): 321 | # d. optimize camera alignment - only optimize if there isn't already a model, and if the current 322 | # number of tie points is not less than the inital number - prevents optimizing twice 323 | if(not len(self.chunk.tie_points.points) < int(self.chunk.meta['init_tie_points'])): 324 | self.gradSelectsOptimization() 325 | print( " --- Camera Optimization Complete --- ") 326 | self.updateAndSave() 327 | 328 | ###### 2. Generate products ###### 329 | # a. build mesh 330 | # reset reconstruction region to make sure the mesh gets built for the full plot 331 | self.chunk.resetRegion() 332 | self.updateAndSave() 333 | # try 'task' syntax to enable hidden preferences (ie pm_enable) to be changed 334 | task = Metashape.Tasks.BuildDepthMaps() 335 | task.downscale = DM_QUALITY 336 | task.filter_mode = Metashape.FilterMode.MildFiltering 337 | task.reuse_depth = True 338 | task.max_neighbors = 16 339 | task.subdivide_task = True 340 | task.workitem_size_cameras = 20 341 | task.max_workgroup_size = 100 342 | task["pm_enable"] = "1" 343 | task.apply(self.chunk) 344 | 345 | #self.chunk.buildDepthMaps(downscale = DM_QUALITY, filter_mode = Metashape.MildFiltering, reuse_depth = True, max_neighbors=16, subdivide_task=True, workitem_size_cameras=20, max_workgroup_size=100) 346 | self.updateAndSave() 347 | self.chunk.buildModel(surface_type = Metashape.Arbitrary, interpolation = Metashape.EnabledInterpolation, face_count=Metashape.HighFaceCount, 348 | face_count_custom = 1000000, source_data = Metashape.DepthMapsData, keep_depth = True) # change this to false to avoid wasted space? 349 | print(" --- Mesh Generated --- ") 350 | self.updateAndSave() 351 | 352 | # if not using automatic referencing, exit script after mesh creation 353 | if(not self.autoDetectMarkers and len(self.chunk.markers) == 0): 354 | print("Exiting script for manual referencing") 355 | self.reject() 356 | return 357 | 358 | # b. build orthomosaic and DEM 359 | 360 | if(self.chunk.elevation == None): 361 | self.chunk.buildDem(source_data = Metashape.ModelData, interpolation = Metashape.EnabledInterpolation, flip_x=False, flip_y=False, flip_z=False, 362 | resolution=ORTHO_RES, subdivide_task=True, workitem_size_tiles=10, max_workgroup_size=100) 363 | print(" --- Hi-Res DEM Built --- ") 364 | 365 | 366 | if(self.chunk.orthomosaic == None): 367 | self.chunk.buildOrthomosaic(resolution = ORTHO_RES, surface_data=Metashape.ElevationData, blending_mode=Metashape.MosaicBlending, fill_holes=True, ghosting_filter=False, 368 | cull_faces=False, refine_seamlines=False, flip_x=False, flip_y=False, flip_z=False, subdivide_task=True, 369 | workitem_size_cameras=20, workitem_size_tiles=10, max_workgroup_size=100) 370 | print(" --- Orthomosaic Built --- ") 371 | 372 | self.updateAndSave() 373 | 374 | # if self.chunk.elevation: 375 | # # Delete the DEM and then rebuild at normal resolution 376 | # self.chunk.elevation = None 377 | # self.chunk.buildDem(source_data = Metashape.ModelData, interpolation = INTERPOLATION, flip_x=False, flip_y=False, flip_z=False, 378 | # resolution=DEM_RES, subdivide_task=True, workitem_size_tiles=10, max_workgroup_size=100) 379 | # print(" --- DEM Built --- ") 380 | 381 | 382 | # c. create boundary 383 | if(not self.chunk.shapes): 384 | self.boundaryCreation() 385 | print(" --- Boundary Polygon Created ---") 386 | 387 | ###### 3. Export products ###### 388 | 389 | # set up compression parameters 390 | jpg = Metashape.ImageCompression() 391 | jpg.tiff_compression = Metashape.ImageCompression.TiffCompressionJPEG 392 | jpg.jpeg_quality = 90 393 | jpg.tiff_big = True 394 | jpg.tiff_overviews = True 395 | 396 | lzw = Metashape.ImageCompression() 397 | lzw.tiff_compression = Metashape.ImageCompression.TiffCompressionLZW 398 | lzw.tiff_big = True 399 | lzw.tiff_overviews = True 400 | 401 | if(self.checkBoxExport.isChecked()): 402 | # export orthomosaic and DEM in full format 403 | ortho_path = self.output_dir + "/" + self.project_name + "_" + self.chunk.label + ".tif" 404 | dem_path = self.output_dir + "/" + self.project_name + "_" + self.chunk.label + "_DEM.tif" 405 | if(not os.path.exists(ortho_path)): 406 | self.chunk.exportRaster(path = ortho_path, resolution = ORTHO_RES, 407 | source_data = Metashape.OrthomosaicData, split_in_blocks = False, image_compression = jpg, 408 | save_kml=False, save_world=False, save_scheme=False, save_alpha=True, image_description='', network_links=True, global_profile=False, 409 | min_zoom_level=-1, max_zoom_level=-1, white_background=True, clip_to_boundary=False,title='Orthomosaic', description='Generated by Agisoft Metashape') 410 | if(not os.path.exists(dem_path)): 411 | self.chunk.exportRaster(path = dem_path, resolution = DEM_RES, nodata_value = -5, 412 | source_data = Metashape.ElevationData, split_in_blocks = False, image_compression = lzw, 413 | save_kml=False, save_world=False, save_scheme=False, save_alpha=True, image_description='', network_links=True, global_profile=False, 414 | min_zoom_level=-1, max_zoom_level=-1, white_background=True, clip_to_boundary=False,title='Orthomosaic', description='Generated by Agisoft Metashape') 415 | 416 | # build output path for boundary shapefile - this is necessary since the files will be 417 | # placed in their own new folder within the output folder that the user created/selected 418 | shape_dir = os.path.join(self.output_dir, self.project_name + "_" + self.chunk.label + "_boundary") 419 | if(not os.path.exists(shape_dir)): 420 | os.mkdir(shape_dir) 421 | self.chunk.exportShapes(path = os.path.join(shape_dir, self.project_name + "_" + self.chunk.label + "_boundary.shp"), save_points=False, save_polylines=False, save_polygons=True, 422 | format = Metashape.ShapesFormatSHP, polygons_as_polylines=False, save_labels=True, save_attributes=True) 423 | 424 | # generate report 425 | self.chunk.exportReport(path = self.output_dir + "/" + self.project_name + "_" + self.chunk.label + ".pdf", title = self.project_name + " " + self.chunk.label, 426 | description = "Processing report for " + self.project_name + " on chunk " + self.chunk.label, font_size=12, page_numbers=True, include_system_info=True) 427 | 428 | # export ortho and dem in blockwise format for Taglab 429 | if(self.checkBoxTagLab.isChecked()): 430 | self.chunk.exportRaster(path = self.output_dir + "/taglab_outputs/" + self.project_name + "_" + self.chunk.label + ".tif", resolution = ORTHO_RES, 431 | source_data = Metashape.OrthomosaicData, block_width = 32767, block_height = 32767, split_in_blocks = True, image_compression = lzw, # remainder of parameters are defaults specified to ensure any alternate settings get oerridden 432 | save_kml=False, save_world=False, save_scheme=False, save_alpha=True, image_description='', network_links=True, global_profile=False, 433 | min_zoom_level=-1, max_zoom_level=-1, white_background=True, clip_to_boundary=True,title='Orthomosaic', description='Generated by Agisoft Metashape') 434 | self.chunk.exportRaster(path = self.output_dir + "/taglab_outputs/" + self.project_name + "_" + self.chunk.label + "_DEM.tif", resolution = ORTHO_RES, nodata_value = -5, 435 | source_data = Metashape.ElevationData, block_width = 32767, block_height = 32767, split_in_blocks = True, image_compression = lzw, # remainder of parameters are defaults specified to ensure any alternate settings get overridden 436 | save_kml=False, save_world=False, save_scheme=False, save_alpha=True, image_description='', network_links=True, global_profile=False, 437 | min_zoom_level=-1, max_zoom_level=-1, white_background=True, clip_to_boundary=True,title='DEM', description='Generated by Agisoft Metashape') 438 | 439 | 440 | ###### 4. Clean up project ###### 441 | #self.cleanProject(self.chunk) #This step not currently working properly (05/16/2024), so it is commented out. Use standalone fn. 442 | self.updateAndSave() 443 | print("Script finished") 444 | 445 | self.reject() 446 | 447 | 448 | ############# Workflow Functions ############# 449 | 450 | def updateAndSave(self): 451 | print("Saving Project...") 452 | Metashape.app.update() 453 | Metashape.app.document.save() 454 | print("Project Saved") 455 | 456 | 457 | def createScalebars(self, path): 458 | ''' 459 | Creates scalebars in the project's active chunk based on information from 460 | a user-provided text file 461 | ''' 462 | iNumScaleBars=len(self.chunk.scalebars) 463 | iNumMarkers=len(self.chunk.markers) 464 | # Check for existing markers 465 | if (iNumMarkers == 0): 466 | raise Exception("No markers found! Unable to create scalebars.") 467 | # Check for already existing scalebars 468 | if (iNumScaleBars > 0): 469 | print('There are already ',iNumScaleBars,' scalebars in this project.') 470 | 471 | try: 472 | file = open(path) 473 | eof = False 474 | line = file.readline() 475 | while not eof: 476 | # split the line and load into variables 477 | point1, point2, dist, acc = line.split(",") 478 | # find the corresponding scalebar, if there is any 479 | scalebarfound = 0 480 | if (iNumScaleBars > 0): 481 | for sbScaleBar in self.chunk.scalebars: 482 | strScaleBarLabel_1 = point1 + "_" + point2 483 | strScaleBarLabel_2 = point2 + "_" + point1 484 | if sbScaleBar.label == strScaleBarLabel_1 or sbScaleBar.label == strScaleBarLabel_2: 485 | # scalebar found 486 | scalebarfound = 1 487 | # update it 488 | sbScaleBar.reference.distance = float(dist) 489 | sbScaleBar.reference.accuracy = float(acc) 490 | # Check if scalebar was found 491 | if (scalebarfound == 0): 492 | # Scalebar was not found: add a new one 493 | # Find Marker 1 with label described by "point1" 494 | bMarker1Found = 0 495 | for marker in self.chunk.markers: 496 | if (marker.label == point1): 497 | marker1 = marker 498 | bMarker1Found = 1 499 | break 500 | # Find Marker 2 with label described by "point2" 501 | bMarker2Found = 0 502 | for marker in self.chunk.markers: 503 | if (marker.label == point2): 504 | marker2 = marker 505 | bMarker2Found = 1 506 | break 507 | # Check if both markers were detected 508 | if bMarker1Found == 1 and bMarker2Found == 1: 509 | # Markers were detected. Create new scalebar. 510 | sbScaleBar = self.chunk.addScalebar(marker1,marker2) 511 | # update it: 512 | sbScaleBar.reference.distance = float(dist) 513 | sbScaleBar.reference.accuracy = float(acc) 514 | else: 515 | # Marker not found. Raise exception and print, but do not stop process. 516 | if (bMarker1Found == 0): 517 | print("Marker " + point1 + " was not found!") 518 | if (bMarker2Found == 0): 519 | print("Marker " + point2 + " was not found!") 520 | #All done. 521 | #reading the next line in input file 522 | line = file.readline() 523 | if not len(line): 524 | eof = True 525 | break 526 | file.close() 527 | print(" --- Scalebars Created --- ") 528 | 529 | except: 530 | return "Script error: There was a problem reading scalebar data\n" 531 | 532 | 533 | def referenceModel(self, path, formatting): 534 | ''' 535 | Imports marker georeferencing data from a user-provided csv, for which 536 | the user may specify the correct column arrangement. The function will raise 537 | an exception if the referencing information is not numeric 538 | 539 | If the project already has georeferencing information, this information will be overwritten. 540 | ''' 541 | # set indices for which columns lat/long data is in 542 | n = formatting[0] - 1 543 | x = formatting[1] - 1 544 | y = formatting[2] - 1 545 | z = formatting[3] - 1 546 | X = formatting[4] - 1 547 | Y = formatting[5] - 1 548 | Z = formatting[6] - 1 549 | skip = formatting[7] - 1 550 | 551 | try: 552 | # create file path for reformatted georeferencing data 553 | new_path = path[:-4] + "_reformat.csv" 554 | 555 | # read in raw georeferencing data and put it in a list 556 | ref = [] 557 | file = open(path) 558 | eof = False 559 | line = file.readline() 560 | # skip the specified number of rows when reading in data prior to reformatting 561 | for i in range(0, skip): 562 | line = file.readline() 563 | 564 | while not eof: 565 | marker_ref = line.strip().split(sep = ",") 566 | ref_line = [marker_ref[n], marker_ref[x], marker_ref[y], marker_ref[z], marker_ref[X], marker_ref[Y], marker_ref[Z]] 567 | for item in ref_line[1:]: 568 | try: 569 | item_float = float(item) 570 | except Exception as err: 571 | print("Script error: '" + item + "'" + " cannot be read as a coordinate value. Your column assignments may be incorrect.") 572 | raise 573 | #print(ref_line) 574 | if(not len(ref)): 575 | ref = [ref_line] 576 | elif(len(ref) > 0): 577 | ref.append(ref_line) 578 | 579 | line = file.readline() 580 | if not len(line): 581 | eof = True 582 | break 583 | 584 | file.close() 585 | 586 | header = ["label", "x", "y", "z", "X_acc", "Y_acc", "Z_acc"] 587 | # write cleaned georeferencing data to a new file 588 | with open(new_path, 'w', newline = '') as f: 589 | writer = csv.writer(f) 590 | writer.writerow(header) 591 | writer.writerows(ref) 592 | f.close() 593 | 594 | # import new georeferencing data 595 | self.chunk.importReference(path = new_path, format = Metashape.ReferenceFormatCSV, delimiter = ',', columns = "nxyzXYZ", skip_rows = skip, 596 | crs = self.chunk.crs, ignore_labels=False, create_markers=False, threshold=0.1, shutter_lag=0) 597 | 598 | os.remove(new_path) 599 | print(" --- Georeferencing Updated --- ") 600 | except: 601 | return "Script error: There was a problem reading georeferencing data\n" 602 | 603 | 604 | 605 | def gradSelectsOptimization(self): 606 | ''' 607 | Refines camera alignment by filtering out tie points with high error 608 | ''' 609 | # define thresholds for reconstruction uncertainty and projection accuracy 610 | reconun = float(25) 611 | projecac = float(15) 612 | 613 | # initiate filters, remove points above thresholds 614 | f = Metashape.TiePoints.Filter() 615 | f.init(self.chunk, Metashape.TiePoints.Filter.ReconstructionUncertainty) 616 | f.removePoints(reconun) 617 | 618 | f = Metashape.TiePoints.Filter() 619 | f.init(self.chunk, Metashape.TiePoints.Filter.ProjectionAccuracy) 620 | f.removePoints(projecac) 621 | 622 | # optimize camera locations based on all distortion parameters 623 | self.chunk.optimizeCameras(fit_f=True, fit_cx=True, fit_cy=True, 624 | fit_b1=True, fit_b2=True, fit_k1=True, 625 | fit_k2=True, fit_k3=True, fit_k4=True, 626 | fit_p1=True, fit_p2=True, fit_corrections=True, 627 | adaptive_fitting=False, tiepoint_covariance=False) 628 | 629 | 630 | def create_shape_from_markers(self, marker_list): 631 | ''' 632 | Creates a boundary shape from a given set of markers 633 | ''' 634 | if not self.chunk: 635 | print("Empty project, script aborted") 636 | return 0 637 | if len(marker_list) < 4: 638 | print("At least four markers required to create a plot. Boundary creation aborted.") 639 | return 0 640 | 641 | T = self.chunk.transform.matrix 642 | crs = self.chunk.crs 643 | if not self.chunk.shapes: 644 | self.chunk.shapes = Metashape.Shapes() 645 | self.chunk.shapes.crs = self.chunk.crs 646 | shape_crs = self.chunk.shapes.crs 647 | 648 | 649 | coords = [shape_crs.project(T.mulp(marker.position)) for marker in marker_list] 650 | 651 | shape = self.chunk.shapes.addShape() 652 | shape.label = "Marker Boundary" 653 | shape.geometry.type = Metashape.Geometry.Type.PolygonType 654 | shape.boundary_type = Metashape.Shape.BoundaryType.OuterBoundary 655 | shape.geometry = Metashape.Geometry.Polygon(coords) 656 | 657 | return 1 658 | 659 | def boundaryCreation(self): 660 | ''' 661 | Wrapper function to create a boundary shape. Based on input from the user, 662 | this function restricts the marker list provided to create_shape_from_markers() 663 | such that the resulting shape will not be crossed into an hourglass shape if the 664 | corner markers are positioned incorrectly. 665 | ''' 666 | m_list = [] 667 | for corner_num in self.corner_markers: 668 | for marker in self.chunk.markers: 669 | if(str(corner_num) == re.search('(\d+)', marker.label).group(0)): 670 | m_list.append(marker) 671 | m_list_short = m_list[:4] 672 | self.create_shape_from_markers(m_list_short) 673 | 674 | 675 | 676 | def clean_project(self): 677 | 678 | # Remove orthophotos without removing orthomosaic 679 | ortho = self.chunk.orthomosaic 680 | if ortho: 681 | ortho.removeOrthophotos() 682 | 683 | # Remove key points (if present) 684 | sparsecloud = self.chunk.tie_points 685 | if sparsecloud: 686 | sparsecloud.removeKeypoints() 687 | 688 | # Remove depth maps (if present) 689 | depthmaps = self.chunk.depth_maps 690 | if depthmaps: 691 | depthmaps.clear() 692 | 693 | #update Apr 2024: updated to fix issues if one or more of these products is not present 694 | 695 | 696 | 697 | 698 | 699 | # ----- Slots for Dialog Box ----- 700 | def getOutputDir(self): 701 | self.output_dir = QtWidgets.QFileDialog.getExistingDirectory(self, 'Open directory', self.project_folder) 702 | if(self.output_dir): 703 | self.txtOutputDir.setPlainText(self.output_dir) 704 | else: 705 | self.txtOutputDir.setPlainText("No File Selected") 706 | 707 | def getCRS(self): 708 | crs = Metashape.app.getCoordinateSystem("Select Coordinate System") 709 | if(crs): 710 | Metashape.app.document.chunk.crs = crs 711 | self.txtCRS.setPlainText(crs.name) 712 | 713 | def onResolutionChange(self): 714 | ''' 715 | Slot: enables/disables the custom ortho resolution input box 716 | ''' 717 | use_default_res = self.checkBoxDefaultRes.isChecked() 718 | self.labelCustomRes.setEnabled(not use_default_res) 719 | self.spinboxCustomRes.setEnabled(not use_default_res) 720 | 721 | # END CLASS FullWorkflowDlg 722 | def run_script(): 723 | app = QtWidgets.QApplication.instance() 724 | parent = app.activeWindow() 725 | 726 | dlg = FullWorkflowDlg(parent) 727 | 728 | 729 | 730 | # add function to menu 731 | label = "ReefShape/Full ReefShape Workflow" 732 | Metashape.app.removeMenuItem(label) 733 | Metashape.app.addMenuItem(label, run_script) 734 | print("To execute this script press {}".format(label)) 735 | -------------------------------------------------------------------------------- /ReefShape_Scripts/02_align_chunks.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Align Chunks from Different Time Points 3 | Sam Marshall 4 | 5 | This file implements a dialog box used to align two photomosaic plots to one another. 6 | 7 | It is a standalone script that is meant to be used in conjunction with the underwater workflow 8 | implemented in FullUW_dialog.py. Once the user has collected two sets of photos, this script 9 | can be used to make sure the two sets line up before processing the second data set. The 10 | data from the first time point must be already processed before this script is used. 11 | 12 | The script works by exporting Metashape's estimated reference information for markers in the first 13 | time point at sub-millimeter precision, then using this information to georeference markers in the 14 | second time point (and subsequent data sets). Using high-precision estimated coordinates rather 15 | than the source coordinates enables Metashape to warp the data products from the second time point 16 | so that they align pixel-to-pixel with those from the first, even though the actual georeferencing 17 | (ie where on earth the reef is located) can never be that precise. 18 | ''' 19 | 20 | import Metashape 21 | from os import path 22 | import sys 23 | import csv 24 | import re 25 | from PySide2 import QtGui, QtCore, QtWidgets # NOTE: the style enums (such as alignment) seem to be in QtCore.Qt 26 | from ui_components import AddPhotosGroupBox, BoundaryMarkerDlg, GeoreferenceGroupBox 27 | 28 | 29 | class AlignChunksDlg(QtWidgets.QDialog): 30 | def __init__(self, parent): 31 | # initialize main dialog window 32 | QtWidgets.QDialog.__init__(self, parent) 33 | self.setWindowTitle("Align Chunks") 34 | self.setMinimumWidth(500) 35 | # set document info 36 | self.doc = Metashape.app.document 37 | self.project_folder = path.dirname(self.doc.path) 38 | self.project_name = path.basename(self.doc.path)[:-4] # extracts project name from file path 39 | self.reference_chunk = self.doc.chunk # set default reference chunk to current active chunk 40 | self.chunk = self.doc.chunk 41 | self.chunk_keys = [] 42 | self.output_dir = self.project_folder 43 | self.damaged_markers = [] 44 | 45 | # set default corner marker arrangement - is this needed for timepoint two? 46 | self.corner_markers = [1, 2, 3, 4] 47 | 48 | # ---- Project Setup Groupbox ---- 49 | # create project setup groupbox - this is a modified AddPhotosGroupBox 50 | self.project_setup = AddPhotosGroupBox(self) 51 | self.project_setup.labelNamingConventions.hide() 52 | self.project_setup.labelProjectName.hide() 53 | self.project_setup.txtProjectName.hide() 54 | self.project_setup.btnProjectName.hide() 55 | self.project_setup.labelChunkName.hide() 56 | self.project_setup.txtChunkName.hide() 57 | self.project_setup.btnChunkName.hide() 58 | self.project_setup.btnCreateProj.hide() 59 | 60 | # add target types 61 | self.targetTypes = [ 62 | ("Circular Target 12 Bit", Metashape.CircularTarget12bit), 63 | ("Circular Target 14 Bit", Metashape.CircularTarget14bit), 64 | ("Circular Target 16 Bit", Metashape.CircularTarget16bit), 65 | ("Circular Target 20 Bit", Metashape.CircularTarget20bit), 66 | ("Circular Target", Metashape.CircularTarget), 67 | ("Cross Target", Metashape.CrossTarget) 68 | ] 69 | self.target_type = Metashape.CircularTarget12bit # set default target type 70 | self.labelTargetType = QtWidgets.QLabel("Select Target Type:") 71 | self.comboTargetType = QtWidgets.QComboBox() 72 | for target_type in self.targetTypes: 73 | self.comboTargetType.addItem(target_type[0]) 74 | 75 | layout_target_type = QtWidgets.QHBoxLayout() 76 | layout_target_type.addWidget(self.labelTargetType) 77 | layout_target_type.addWidget(self.comboTargetType) 78 | layout_target_type.addStretch() 79 | 80 | # add a button to create a new chunk 81 | self.btnCreateChunk = QtWidgets.QPushButton("Create Chunk") 82 | layout_target_type.addWidget(self.btnCreateChunk) 83 | self.project_setup.layout().addLayout(layout_target_type) 84 | # add the button to create_proj_layout - because of the way Qt passes ownership of layouts 85 | # around, create_proj_layout must be accessed via the main layout's itemAt() function 86 | # self.project_setup.layout().itemAt(4).addWidget(self.project_setup.btnCreateChunk) 87 | 88 | # ---- General Groupbox ---- 89 | self.labelRefChunk = QtWidgets.QLabel("Select Reference Chunk:") 90 | self.comboRefChunk = QtWidgets.QComboBox() 91 | ref_chunk_layout = QtWidgets.QHBoxLayout() 92 | ref_chunk_layout.addWidget(self.labelRefChunk) 93 | ref_chunk_layout.addWidget(self.comboRefChunk) 94 | 95 | self.labelNewChunk = QtWidgets.QLabel("Select Active Chunk:") 96 | self.comboNewChunk = QtWidgets.QComboBox() 97 | new_chunk_layout = QtWidgets.QHBoxLayout() 98 | new_chunk_layout.addWidget(self.labelNewChunk) 99 | new_chunk_layout.addWidget(self.comboNewChunk) 100 | 101 | self.labelDamagedMarkers = QtWidgets.QLabel("Select Damaged Markers:") 102 | self.txtDamagedMarkers = QtWidgets.QPlainTextEdit("No damaged markers") 103 | self.txtDamagedMarkers.setFixedHeight(40) 104 | self.txtDamagedMarkers.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) 105 | self.txtDamagedMarkers.setReadOnly(True) 106 | self.comboDamagedMarkers = QtWidgets.QComboBox() 107 | self.btnRemoveMarker = QtWidgets.QPushButton("Undo Add Marker") 108 | damaged_marker_layout = QtWidgets.QHBoxLayout() 109 | damaged_marker_layout.addWidget(self.labelDamagedMarkers) 110 | damaged_marker_layout.addWidget(self.txtDamagedMarkers) 111 | add_marker_layout = QtWidgets.QHBoxLayout() 112 | add_marker_layout.addStretch() 113 | add_marker_layout.addWidget(self.comboDamagedMarkers) 114 | add_marker_layout.addWidget(self.btnRemoveMarker) 115 | 116 | general_groupbox = QtWidgets.QGroupBox("General") 117 | general_layout = QtWidgets.QVBoxLayout() 118 | general_layout.addLayout(ref_chunk_layout) 119 | general_layout.addLayout(new_chunk_layout) 120 | general_layout.addLayout(damaged_marker_layout) 121 | general_layout.addLayout(add_marker_layout) 122 | general_groupbox.setLayout(general_layout) 123 | 124 | 125 | self.btnOk = QtWidgets.QPushButton("Ok") 126 | self.btnOk.setFixedSize(70, 40) 127 | self.btnOk.setToolTip("Align chunks") 128 | 129 | self.btnClose = QtWidgets.QPushButton("Close") 130 | self.btnClose.setFixedSize(70, 40) 131 | 132 | ok_layout = QtWidgets.QHBoxLayout() 133 | ok_layout.addWidget(self.btnOk) 134 | ok_layout.addWidget(self.btnClose) 135 | 136 | main_layout = QtWidgets.QVBoxLayout() 137 | main_layout.addWidget(self.project_setup) 138 | main_layout.addWidget(general_groupbox) 139 | main_layout.addLayout(ok_layout) 140 | self.setLayout(main_layout) 141 | 142 | # populate combo boxes with options 143 | self.updateChunkList() 144 | self.updateMarkerList() 145 | self.txtDamagedMarkers.setPlainText("No Damaged Markers") 146 | # connect signals and slots 147 | self.btnCreateChunk.clicked.connect(self.createChunk) 148 | self.comboRefChunk.activated.connect(self.setReferenceChunk) 149 | self.comboNewChunk.activated.connect(self.setActiveChunk) 150 | self.comboDamagedMarkers.currentIndexChanged.connect(self.addDamagedMarker) 151 | self.comboTargetType.currentIndexChanged.connect(self.onTargetTypeChange) 152 | self.btnRemoveMarker.clicked.connect(self.removeDamagedMarker) 153 | self.btnOk.clicked.connect(self.alignChunks) 154 | QtCore.QObject.connect(self.btnClose, QtCore.SIGNAL("clicked()"), self, QtCore.SLOT("reject()")) 155 | 156 | 157 | self.exec() 158 | 159 | def alignChunks(self): 160 | ''' 161 | Contains main workflow 162 | ''' 163 | print("Script started...") 164 | self.setEnabled(False) 165 | 166 | if(len(self.doc.chunks) < 2): 167 | Metashape.app.messageBox("Unable to align chunks: Please create a second chunk to align") 168 | self.setEnabled(True) 169 | return 170 | 171 | est_ref_path = os.path.join(self.project_folder, self.reference_chunk.label + "_est_ref.csv") 172 | # export estimated reference from old chunk - in decimal degrees, 9 decimal places is about 0.1mm 173 | self.reference_chunk.exportReference(path = est_ref_path, format = Metashape.ReferenceFormatCSV, 174 | items = Metashape.ReferenceItemsMarkers, columns = 'nouvwUVW', delimiter = ",", precision = 9) 175 | 176 | self.correctEnabledMarkers(est_ref_path) 177 | 178 | # detect markers in new chunk 179 | if(len(self.chunk.markers) == 0): # only detect markers if there are currently no markers in selected chunk 180 | self.chunk.detectMarkers(target_type = self.target_type, tolerance=20, filter_mask=False, inverted=False, noparity=False, maximum_residual=5, minimum_size=0, minimum_dist=5) 181 | 182 | # import reference to new chunk 183 | self.chunk.importReference(path = est_ref_path, format = Metashape.ReferenceFormatCSV, delimiter = ',', columns = "noxyz", skip_rows = 1, 184 | crs = self.reference_chunk.crs, ignore_labels=False, create_markers=False, threshold=0.1, shutter_lag=0) 185 | 186 | # adjust accuracy for damaged markers - accuracy is set in meters 187 | for marker in self.chunk.markers: 188 | marker.reference.accuracy = [0.0001, 0.0001, 0.0001] 189 | for damaged_marker in self.damaged_markers: 190 | if(marker.label == damaged_marker.label): 191 | marker.reference.accuracy = [1, 1, 1] 192 | 193 | self.chunk.updateTransform() 194 | self.updateAndSave() 195 | self.reject() 196 | 197 | def updateAndSave(self): 198 | ''' saves changes to the project and updates the user interface ''' 199 | print("Saving Project...") 200 | Metashape.app.update() 201 | self.doc.save() 202 | print("Project Saved") 203 | 204 | def createChunk(self): 205 | ''' 206 | Slot: creates a new chunk in the project and prompts the user for a name 207 | ''' 208 | new_name, ok = QtWidgets.QInputDialog().getText(self, "Create Chunk", "Chunk name:") 209 | if(new_name and ok): 210 | self.chunk = self.doc.addChunk() 211 | self.doc.chunk = self.chunk # set the projects active chunk to be the new chunk 212 | self.project_setup.txtAddPhotos.setPlainText("Select Folder") 213 | self.chunk.label = new_name 214 | self.updateChunkList() 215 | 216 | def updateChunkList(self): 217 | ''' 218 | Slot: populates/updates the list of chunks to choose from in each combo box 219 | ''' 220 | # chunk keys do not necessarily correspond to the number of chunks in the project, 221 | # so we need a separate list in order to link the combo boxes with the actual chunk list 222 | self.chunk_keys.clear() 223 | self.comboRefChunk.clear() 224 | self.comboNewChunk.clear() 225 | for chunk in self.doc.chunks: 226 | self.comboRefChunk.addItem(chunk.label) 227 | self.comboNewChunk.addItem(chunk.label) 228 | self.chunk_keys.append(chunk.key) 229 | self.comboRefChunk.setCurrentIndex(self.chunk_keys.index(self.reference_chunk.key)) 230 | self.comboNewChunk.setCurrentIndex(self.chunk_keys.index(self.chunk.key)) 231 | 232 | def setReferenceChunk(self): 233 | ''' 234 | Slot: when the user selects a new chunk to be the reference chunk, updates the corresponding 235 | member variable and adds the chunk's markers to the markers combo box 236 | ''' 237 | self.reference_chunk = self.doc.findChunk(self.chunk_keys[self.comboRefChunk.currentIndex()]) 238 | self.updateMarkerList() 239 | 240 | def setActiveChunk(self): 241 | ''' 242 | Slot: when the user selects a new chunk to be the active chunk or creates a new chunk, 243 | updates the corresponding member variable and adds the chunk's markers to the markers combo box 244 | ''' 245 | self.chunk = self.doc.findChunk(self.chunk_keys[self.comboNewChunk.currentIndex()]) 246 | self.doc.chunk = self.chunk 247 | self.updateMarkerList() 248 | 249 | def updateMarkerList(self): 250 | ''' 251 | Slot: populates/updates the list of markers to choose as damaged when the user selects 252 | a new chunk to be the reference chunk 253 | ''' 254 | self.comboDamagedMarkers.clear() 255 | if(len(self.reference_chunk.markers) > 0): 256 | for marker in self.reference_chunk.markers: 257 | self.comboDamagedMarkers.addItem(marker.label) 258 | self.comboDamagedMarkers.addItem("Add damaged marker") 259 | self.comboDamagedMarkers.setCurrentIndex(len(self.reference_chunk.markers)) 260 | self.damaged_markers.clear() 261 | self.txtDamagedMarkers.setPlainText("No Damaged Markers") 262 | 263 | def addDamagedMarker(self): 264 | ''' 265 | Slot: when the user selects a marker from the dropdown list, add it to the list of 266 | damaged markers and update the text box displaying the list 267 | ''' 268 | self.damaged_markers.append(self.reference_chunk.findMarker(self.comboDamagedMarkers.currentIndex())) 269 | self.txtDamagedMarkers.setPlainText(str(self.damaged_markers)) 270 | 271 | def removeDamagedMarker(self): 272 | ''' 273 | Slot: removes the most recently added marker from the list of added markers 274 | ''' 275 | if(not len(self.damaged_markers) == 0): 276 | self.damaged_markers.pop() 277 | self.txtDamagedMarkers.setPlainText(str(self.damaged_markers)) 278 | if(len(self.damaged_markers) == 0): 279 | self.comboDamagedMarkers.setCurrentIndex(len(self.reference_chunk.markers)) 280 | self.txtDamagedMarkers.setPlainText("No Damaged Markers") 281 | 282 | def onTargetTypeChange(self): 283 | ''' 284 | Updates target type member variable when the user selects a new type 285 | ''' 286 | target_type_index = self.comboTargetType.currentIndex() 287 | self.target_type = self.targetTypes[target_type_index][1] 288 | 289 | 290 | def correctEnabledMarkers(self, path): 291 | ''' 292 | Edits the estimated reference file so that only markers used for georeferencing in 293 | time point 1 are used to align time point 2. 294 | Currently, this functionality is broken in metashape, and all markers are marked as 295 | enabled. 296 | ''' 297 | # read in raw georeferencing data and put it in a list 298 | file = open(path) 299 | eof = False 300 | header = file.readline() # skip first header containing crs info 301 | header = file.readline() 302 | ref = [header.split(sep = ",")] # save second header line containing column headers 303 | line = file.readline() 304 | index = 0 # use marker index to match file lines with markers - Metashape exports markers in the order of their indices 305 | 306 | while not eof: 307 | marker_ref = line.split(sep = ",") 308 | if(self.reference_chunk.markers[index].reference.enabled): 309 | ref.append(marker_ref) 310 | # marker_ref[1] = int(self.reference_chunk.markers[index].reference.enabled) # rewrite enabled flag 311 | line = file.readline() 312 | index += 1 313 | if (index >= len(self.reference_chunk.markers) or not len(line)): 314 | eof = True 315 | break 316 | file.close() 317 | # write edited reference info back to the file 318 | with open(path, 'w', newline = '') as f: 319 | writer = csv.writer(f) 320 | writer.writerows(ref) 321 | f.close() 322 | 323 | # END CLASS AlignChunksDlg 324 | def run_script(): 325 | app = QtWidgets.QApplication.instance() 326 | parent = app.activeWindow() 327 | 328 | dlg = AlignChunksDlg(parent) 329 | 330 | 331 | 332 | # add function to menu 333 | label_old = "Custom/Align Chunks" 334 | label = "ReefShape/Align Timepoints" 335 | Metashape.app.removeMenuItem(label_old) 336 | Metashape.app.removeMenuItem(label) 337 | Metashape.app.addMenuItem(label, run_script) 338 | print("To execute this script press {}".format(label)) 339 | -------------------------------------------------------------------------------- /ReefShape_Scripts/03_optimization_process.py: -------------------------------------------------------------------------------- 1 | # script to optimize camera distortion model and camera locations 2 | # in metashape, based on deletion of bad tie points 3 | 4 | # location for this file: 5 | # Windows: C:\Users\[YOUR USERNAME]\AppData\Local\Agisoft\Metashape Pro\scripts 6 | # Mac: /Users/[YOUR USERNAME]/Library/Application Support/Agisoft/Metashape Pro/scripts 7 | 8 | # updated May 2022 by Will Greene / Perry Institute for Marine Science and 9 | # Asif-ul Islam / Middlebury College for use with underwater photogrammetry 10 | # updated Jan 2023 by Sam Marshall to reflect changes in API for Metashape 2.0.0 11 | 12 | import Metashape 13 | 14 | def gradSelectsOptimization(): 15 | 16 | doc = Metashape.app.document 17 | chunk = doc.chunk 18 | 19 | # define thresholds for reconstruction uncertainty and projection accuracy 20 | reconun = float(25) 21 | projecac = float(15) 22 | 23 | # initiate filters, remote points above thresholds 24 | f = Metashape.TiePoints.Filter() 25 | f.init(chunk, Metashape.TiePoints.Filter.ReconstructionUncertainty) 26 | f.removePoints(reconun) 27 | 28 | f = Metashape.TiePoints.Filter() 29 | f.init(chunk, Metashape.TiePoints.Filter.ProjectionAccuracy) 30 | f.removePoints(projecac) 31 | 32 | # optimize camera locations based on all distortion parameters 33 | chunk.optimizeCameras(fit_f=True, fit_cx=True, fit_cy=True, 34 | fit_b1=True, fit_b2=True, fit_k1=True, 35 | fit_k2=True, fit_k3=True, fit_k4=True, 36 | fit_p1=True, fit_p2=True, fit_corrections=True, 37 | adaptive_fitting=False, tiepoint_covariance=False) 38 | 39 | Metashape.app.update() 40 | print("Script finished") 41 | 42 | label = "ReefShape/Tools/Optimize Cameras and Model" 43 | Metashape.app.addMenuItem(label, gradSelectsOptimization) 44 | -------------------------------------------------------------------------------- /ReefShape_Scripts/04_scale_model.py: -------------------------------------------------------------------------------- 1 | # script to update/create scalebars from a csv file. 2 | # location for this file: 3 | # Windows: C:\Users\[YOUR USERNAME]\AppData\Local\Agisoft\Metashape Pro\scripts 4 | # Mac: /Users/[YOUR USERNAME]/Library/Application Support/Agisoft/Metashape Pro/scripts 5 | 6 | # csv file format: 7 | # Marker_1_label,Marker_2_label,distance,accuracy 8 | # ex: 9 | # target 5,target 6,0.4965,0.00025 10 | # target 7,target 8,0.4975,0.00025 11 | # target 9,target 10,0.4985,0.00025 12 | 13 | # PS: no additional spaces should be inserted 14 | 15 | 16 | # This script was based on a script for creating scalebars in Photoscan available at: 17 | # http://hairystickman.co.uk/photoscan-scale-bars/ 18 | # 19 | # updated May 2022 by Will Greene / Perry Institute for Marine Science and 20 | # Asif-ul Islam / Middlebury College for use with underwater photogrammetry 21 | 22 | import Metashape, os 23 | from PySide2 import QtCore, QtGui 24 | 25 | def Create_Scalebars(): 26 | doc = Metashape.app.document 27 | chunk = doc.chunk 28 | 29 | iNumScaleBars=len(chunk.scalebars) 30 | iNumMarkers=len(chunk.markers) 31 | # Check for existing markers 32 | if (iNumMarkers == 0): 33 | raise Exception("No markers found! Unable to create scalebars.") 34 | # Check for already existing scalebars 35 | if (iNumScaleBars > 0): 36 | print('There are already ',iNumScaleBars,' scalebars in this project.') 37 | 38 | path = Metashape.app.getOpenFileName("Select input text file:") 39 | file = open(path, "rt") 40 | eof = False 41 | line = file.readline() 42 | while not eof: 43 | #split the line and load into variables 44 | point1, point2, dist, acc = line.split(",") 45 | #find the corresponding scalebar, if there is any 46 | scalebarfound=0 47 | if (iNumScaleBars > 0): 48 | for sbScaleBar in chunk.scalebars: 49 | strScaleBarLabel_1=point1+"_"+point2 50 | strScaleBarLabel_2=point2+"_"+point1 51 | if sbScaleBar.label==strScaleBarLabel_1 or sbScaleBar.label==strScaleBarLabel_2: 52 | # scalebar found 53 | scalebarfound=1 54 | # update it 55 | sbScaleBar.reference.distance=float(dist) 56 | sbScaleBar.reference.accuracy=float(acc) 57 | # Check if scalebar was found 58 | if (scalebarfound==0): 59 | # Scalebar was not found: add a new one 60 | # Find Marker 1 with label described by "point1" 61 | bMarker1Found=0 62 | for marker in chunk.markers: 63 | if (marker.label == point1): 64 | marker1 = marker 65 | bMarker1Found=1 66 | break 67 | # Find Marker 2 with label described by "point2" 68 | bMarker2Found=0 69 | for marker in chunk.markers: 70 | if (marker.label == point2): 71 | marker2 = marker 72 | bMarker2Found=1 73 | break 74 | # Check if both markers were detected 75 | if bMarker1Found==1 and bMarker2Found==1: 76 | # Markers were detected. Create new scalebar. 77 | sbScaleBar = chunk.addScalebar(marker1,marker2) 78 | # update it: 79 | sbScaleBar.reference.distance=float(dist) 80 | sbScaleBar.reference.accuracy=float(acc) 81 | else: 82 | # Marker not found. Raise exception and print, but do not stop process. 83 | if (bMarker1Found == 0): 84 | print("Marker "+point1+" was not found!") 85 | if (bMarker2Found == 0): 86 | print("Marker "+point2+" was not found!") 87 | #All done. 88 | #reading the next line in input file 89 | line = file.readline() 90 | if not len(line): 91 | eof = True 92 | break 93 | 94 | file.close() 95 | Metashape.app.update() 96 | print("Script finished") 97 | label = "ReefShape/Tools/Create Scalebars from Targets" 98 | Metashape.app.addMenuItem(label, Create_Scalebars) 99 | 100 | -------------------------------------------------------------------------------- /ReefShape_Scripts/05_create_boundary.py: -------------------------------------------------------------------------------- 1 | import Metashape 2 | from PySide2 import QtWidgets, QtCore 3 | 4 | class CreateBoundary: 5 | def __init__(self, parent): 6 | self.parent = parent 7 | self.dialog = None 8 | self.selected_markers = {} 9 | 10 | def initUI(self): 11 | # Reset the marker list 12 | self.selected_markers = {} 13 | 14 | self.dialog = QtWidgets.QDialog(None, QtCore.Qt.WindowFlags()) 15 | self.dialog.setWindowTitle("Corner Marker Positions") 16 | 17 | layout = QtWidgets.QVBoxLayout() 18 | 19 | # Add instruction label 20 | instruction_label = QtWidgets.QLabel("Select the markers (in order) that define the boundary:") 21 | layout.addWidget(instruction_label) 22 | 23 | # Add slot selection widgets 24 | for position in ["Corner 1", "Corner 2", "Corner 3", "Corner 4"]: 25 | label = QtWidgets.QLabel(f"{position}:") 26 | combo_box = QtWidgets.QComboBox() 27 | combo_box.addItem("Select Marker") 28 | combo_box.addItems(self.get_available_markers()) 29 | combo_box.currentIndexChanged.connect(lambda state, pos=position, box=combo_box: self.marker_selected(pos, box.currentText())) 30 | layout.addWidget(label) 31 | layout.addWidget(combo_box) 32 | 33 | # Add buttons 34 | button_layout = QtWidgets.QHBoxLayout() 35 | ok_button = QtWidgets.QPushButton("Ok") 36 | ok_button.clicked.connect(self.generate_polygon) 37 | close_button = QtWidgets.QPushButton("Close") 38 | close_button.clicked.connect(self.dialog.reject) 39 | button_layout.addWidget(ok_button) 40 | button_layout.addWidget(close_button) 41 | layout.addLayout(button_layout) 42 | 43 | self.dialog.setLayout(layout) 44 | self.dialog.exec_() 45 | 46 | def get_available_markers(self): 47 | doc = self.parent.document 48 | chunk = doc.chunk 49 | return [marker.label for marker in chunk.markers] 50 | 51 | def marker_selected(self, position, marker): 52 | self.selected_markers[position] = marker 53 | 54 | def generate_polygon(self): 55 | doc = self.parent.document 56 | chunk = doc.chunk 57 | 58 | # Sort selected markers 59 | sorted_markers = [self.selected_markers[f"Corner {i}"] for i in range(1, 5)] 60 | 61 | # Assuming you have a function to create a polygon from selected markers 62 | if len(sorted_markers) == 4: 63 | selected_markers_objects = [marker for marker in chunk.markers if marker.label in sorted_markers] 64 | success = self.create_polygon_from_markers(selected_markers_objects, chunk) 65 | if success: 66 | self.dialog.accept() 67 | else: 68 | QtWidgets.QMessageBox.warning(self.dialog, "Warning", "Please select a marker for each corner.") 69 | 70 | def create_polygon_from_markers(self, marker_list, chunk): 71 | if not chunk: 72 | print("Empty project, script aborted") 73 | return False 74 | if len(marker_list) < 3: 75 | print("At least three markers required to create a polygon. Script aborted.") 76 | return False 77 | 78 | T = chunk.transform.matrix 79 | crs = chunk.crs 80 | if not chunk.shapes: 81 | chunk.shapes = Metashape.Shapes() 82 | chunk.shapes.crs = chunk.crs 83 | shape_crs = chunk.shapes.crs 84 | 85 | # Sort markers based on the order they were selected 86 | sorted_marker_list = sorted(marker_list, key=lambda marker: list(self.selected_markers.values()).index(marker.label)) 87 | 88 | coords = [shape_crs.project(T.mulp(marker.position)) for marker in sorted_marker_list] 89 | 90 | shape = chunk.shapes.addShape() 91 | shape.label = "Marker Boundary" 92 | shape.geometry.type = Metashape.Geometry.Type.PolygonType 93 | shape.boundary_type = Metashape.Shape.BoundaryType.OuterBoundary 94 | shape.geometry = Metashape.Geometry.Polygon(coords) 95 | 96 | print("Script finished.") 97 | return True 98 | 99 | def main(): 100 | # Remove existing menu item if it exists 101 | app.removeMenuItem("ReefShape/Tools/Create Boundary") 102 | 103 | # Add new menu button under ReefShape/Tools 104 | app.addMenuItem("ReefShape/Tools/Create Boundary", lambda: create_boundary.initUI()) 105 | 106 | # Entry point 107 | if __name__ == "__main__": 108 | app = Metashape.app 109 | create_boundary = CreateBoundary(app) 110 | main() 111 | -------------------------------------------------------------------------------- /ReefShape_Scripts/06_copy_boundary.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Boundary Copy Function with Dialog Box 3 | 10/18/2024 4 | Will Greene 5 | PIMS/ASU 6 | 7 | This script prompts the user for a dialog box to select a "source chunk" and a "target chunk." Upon clicking "Copy Boundary," 8 | it copies the outer boundary polygon from the source chunk to the target chunk and names it "Copied Boundary." This function 9 | is useful for getting aligned chunks with custom boundaries to export pixel-aligned maps for analysis in software like TagLab. 10 | 11 | Written with the help of AI (ChatGPT). 12 | ''' 13 | 14 | import Metashape 15 | from PySide2 import QtWidgets 16 | 17 | class BoundaryCopyGUI: 18 | def __init__(self, parent): 19 | self.parent = parent 20 | 21 | def initUI(self): 22 | dialog = QtWidgets.QDialog(parent=None) 23 | dialog.setWindowTitle("Copy Boundary Polygon") 24 | 25 | layout = QtWidgets.QVBoxLayout(dialog) 26 | 27 | source_label = QtWidgets.QLabel("Source Chunk") 28 | self.source_combo = QtWidgets.QComboBox() 29 | layout.addWidget(source_label) 30 | layout.addWidget(self.source_combo) 31 | 32 | target_label = QtWidgets.QLabel("Target Chunk") 33 | self.target_combo = QtWidgets.QComboBox() 34 | layout.addWidget(target_label) 35 | layout.addWidget(self.target_combo) 36 | 37 | copy_button = QtWidgets.QPushButton("Copy Boundary") 38 | copy_button.clicked.connect(self.copy_boundary) 39 | layout.addWidget(copy_button) 40 | 41 | # Populate combo boxes with chunk names 42 | doc = self.parent.document 43 | active_chunk = doc.chunk # Get the currently active chunk 44 | for chunk in doc.chunks: 45 | self.source_combo.addItem(chunk.label) 46 | self.target_combo.addItem(chunk.label) 47 | 48 | # Set the default source chunk to the first chunk in the document 49 | self.source_combo.setCurrentIndex(0) 50 | 51 | # Set the default target chunk to the currently active chunk 52 | if active_chunk: 53 | active_chunk_index = doc.chunks.index(active_chunk) 54 | self.target_combo.setCurrentIndex(active_chunk_index) 55 | 56 | dialog.exec_() 57 | 58 | def copy_boundary(self): 59 | source_index = self.source_combo.currentIndex() 60 | target_index = self.target_combo.currentIndex() 61 | 62 | doc = self.parent.document 63 | 64 | if source_index < 0 or target_index < 0: 65 | print("Invalid source or target chunk selection.") 66 | return 67 | 68 | source_chunk = doc.chunks[source_index] 69 | target_chunk = doc.chunks[target_index] 70 | 71 | if target_chunk is None: 72 | print("Target chunk is not properly initialized or does not exist.") 73 | return 74 | 75 | # Ensure the target chunk has a shapes folder 76 | if not target_chunk.shapes: 77 | target_chunk.shapes = Metashape.Shapes() # Initialize shapes if not present 78 | target_chunk.shapes.crs = source_chunk.shapes.crs # Ensure CRS matches the source chunk 79 | 80 | source_outer_boundary = next((shape for shape in source_chunk.shapes if shape.boundary_type == Metashape.Shape.BoundaryType.OuterBoundary), None) 81 | if source_outer_boundary is None: 82 | print("Source chunk does not have an outer boundary shape.") 83 | return 84 | 85 | target_outer_boundary = target_chunk.shapes.addShape() 86 | target_outer_boundary.label = "Copied Boundary" 87 | target_outer_boundary.boundary_type = Metashape.Shape.BoundaryType.OuterBoundary 88 | 89 | # Copy geometry directly 90 | target_outer_boundary.geometry = source_outer_boundary.geometry 91 | 92 | print("Boundary copied successfully") 93 | 94 | self.reject() 95 | 96 | def add_custom_menu(): 97 | Metashape.app.removeMenuItem("ReefShape/Tools/Copy Boundary Polygon") 98 | Metashape.app.removeMenuItem("ReefShape/Tools/Copy Boundary") 99 | Metashape.app.addMenuItem("ReefShape/Tools/Copy Boundary", lambda: show_boundary_copy_tool()) 100 | 101 | def show_boundary_copy_tool(): 102 | app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) 103 | boundary_copy_gui = BoundaryCopyGUI(Metashape.app) 104 | boundary_copy_gui.initUI() 105 | 106 | def main(): 107 | add_custom_menu() 108 | 109 | if __name__ == "__main__": 110 | main() 111 | -------------------------------------------------------------------------------- /ReefShape_Scripts/07_calculate_area_ratio.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3D/2D Surface Area Ratio Calculation 3 | Will Greene 4 | 5 | Created with the help of ChatGPT 6 | 7 | This script takes an existing model and outer boundary shape and uses them to calculate the ratio of the 3D surface area of the mesh (within the boundary shape) to the planar 2D area of the boundary shape. This ratio is a measure of the structural complexity of the reef, with higher values indicating greater complexity and habitat quality. The metric is affected by the resolution settings of the mesh, and for a given reef, higher resolution values will yield somewhat higher 3D/2D surface area ratios. 8 | 9 | Usage notes: 10 | - The script requires there to be a single model in the chunk that is activated as default 11 | - The script also requires there to be a single shape in the project that is set as the 12 | outer boundary. This is generated automatically by the Full UW Workflow script, or this 13 | can be done manually by deleting any existing shapes, drawing your own boundary shape on a 14 | completed orthomosaic, and setting it as "outer boundary" by right-clicking on it and 15 | setting "boundary type" to "outer boundary". 16 | """ 17 | import Metashape 18 | from PySide2 import QtGui, QtCore, QtWidgets 19 | from PySide2.QtWidgets import QMessageBox 20 | 21 | # Function to calculate 3D surface area of a mesh 22 | def calculate_3d_surface_area(mesh): 23 | sa = mesh.area() 24 | return sa 25 | 26 | # Function to calculate 2D planar surface area 27 | def calculate_2d_surface_area(outer_boundary): 28 | pa = outer_boundary.area() 29 | return pa 30 | 31 | # Function to duplicate the 3D model and clip it by an outer boundary shape 32 | def duplicate_and_clip_model(chunk): 33 | task = Metashape.Tasks.DuplicateAsset() 34 | task.asset_key = chunk.model.key 35 | task.asset_type = Metashape.DataSource.ModelData 36 | task.clip_to_boundary = True 37 | task.apply(chunk) 38 | 39 | chunk.model.label = "Cropped 3D Model" 40 | 41 | # Function to delete a model from the chunk 42 | def delete_model(chunk): 43 | chunk.remove(chunk.model) 44 | 45 | # Function to divide 3D surface area by 2D planar surface area 46 | def calculate_ratio(surface_area_3d, surface_area_2d): 47 | return surface_area_3d / surface_area_2d 48 | 49 | # Main function to execute the script 50 | def main(): 51 | # Get the active chunk 52 | chunk = Metashape.app.document.chunk 53 | 54 | # Assume outer boundary is already defined 55 | outer_boundary = chunk.shapes[0] # Assuming the outer boundary is the first shape in the chunk 56 | 57 | # Duplicate and clip the 3D model 58 | duplicate_and_clip_model(chunk) 59 | 60 | duplicated_model = chunk.model 61 | 62 | # Calculate 3D surface area 63 | surface_area_3d = calculate_3d_surface_area(duplicated_model) 64 | 65 | # Calculate 2D planar surface area 66 | surface_area_2d = calculate_2d_surface_area(outer_boundary) 67 | 68 | # Delete the duplicated and clipped model 69 | delete_model(chunk) 70 | 71 | #Set old model as default 72 | model_index = 0 73 | 74 | chunk.model = chunk.models[model_index] 75 | 76 | # Calculate the ratio 77 | ratio = calculate_ratio(surface_area_3d, surface_area_2d) 78 | 79 | #print the ratio to make sure the value isn't lost when closing the dialog box 80 | print("The ratio of 3D surface area to 2D planar surface area is:") 81 | print(ratio) 82 | 83 | # Display result in a QMessageBox 84 | QMessageBox.information(None, "Ratio Result", f"The ratio of 3D surface area to 2D planar surface area is {ratio}") 85 | 86 | # add function to menu 87 | label = "ReefShape/Tools/Calculate Surface Area Ratio" 88 | Metashape.app.removeMenuItem(label) 89 | Metashape.app.addMenuItem(label, main) 90 | print("To execute this script press {}".format(label)) 91 | -------------------------------------------------------------------------------- /ReefShape_Scripts/08_clean_project.py: -------------------------------------------------------------------------------- 1 | # location for this file: 2 | # Windows: C:\Users\[YOUR USERNAME]\AppData\Local\Agisoft\Metashape Pro\scripts 3 | # Mac: /Users/[YOUR USERNAME]/Library/Application Support/Agisoft/Metashape Pro/scripts 4 | 5 | # script to clean up files that aren't needed for long-term storage 6 | # written by Will Greene, Perry Institute for Marine Science, Oct 2022 7 | # Only functions on the currently selected chunk 8 | import Metashape 9 | 10 | def clean_project(): 11 | doc = Metashape.app.document 12 | chunk = doc.chunk 13 | 14 | # Remove orthophotos without removing orthomosaic 15 | ortho = chunk.orthomosaic 16 | if ortho: 17 | ortho.removeOrthophotos() 18 | 19 | # Remove key points (if present) 20 | sparsecloud = chunk.tie_points 21 | if sparsecloud: 22 | sparsecloud.removeKeypoints() 23 | 24 | # Remove depth maps (if present) 25 | depthmaps = chunk.depth_maps 26 | if depthmaps: 27 | depthmaps.clear() 28 | 29 | Metashape.app.update() 30 | Metashape.app.document.save() 31 | 32 | print("Script finished") 33 | 34 | label = "ReefShape/Tools/Clean Project" 35 | Metashape.app.addMenuItem(label, clean_project) -------------------------------------------------------------------------------- /ReefShape_Scripts/ui_components.py: -------------------------------------------------------------------------------- 1 | ''' 2 | UI Component Classes for Underwater Workflow 3 | Sam Marshall 4 | 5 | This file contains class definitions for UI components used in the full workflow script (FullUW_dialog.py). 6 | It cannot function as a standalone script. 7 | 8 | Note: if you edit this script after running a script that uses it in Metashape, your changes 9 | will likely not be reflected until you close Metashape and reopen it. This is (I think) because 10 | Metashape caches a bytecode version of the script the first time it is run in a given session 11 | and then uses that version when it is run again. 12 | ''' 13 | 14 | 15 | import Metashape 16 | import os 17 | import sys 18 | import csv 19 | import re 20 | from PySide2 import QtGui, QtCore, QtWidgets # NOTE: the style enums (such as alignment) seem to be in QtCore.Qt 21 | 22 | 23 | class AddPhotosGroupBox(QtWidgets.QGroupBox): 24 | ''' 25 | Groupbox holding widgets used to setup a Metashape project and add photos. 26 | 27 | Allows the user to save the currently active Metashape project under a different name, 28 | change the active chunk's name, and add photos to the active chunk. 29 | 30 | It cannot make a new chunk, and is NOT recommended to be used for creating a new Metashape 31 | project from within an already active project (one that has data in it). 32 | 33 | The parent widget MUST have an attribute named 'chunk' that represents the project's active 34 | chunk - this may be changed in a later version, but is a requirement for now 35 | ''' 36 | def __init__(self, parent): 37 | # call parent constructor to initialize 38 | super().__init__("Project Setup") 39 | self.parent = parent 40 | self.project_path = Metashape.app.document.path 41 | self.photo_folder = "No folder selected" 42 | self.project_name = "Untitled" 43 | self.chunk_name = Metashape.app.document.chunk.label 44 | 45 | # ---- create widgets ---- 46 | self.labelNamingConventions = QtWidgets.QLabel("Select a project and chunk name. To ensure consistency, we suggest " 47 | "using the site name\nas the project name and the date the data " 48 | "was collected (in YYYYMMDD format) as the chunk name.") 49 | self.labelNamingConventions.setAlignment(QtCore.Qt.AlignCenter) 50 | 51 | self.labelProjectName = QtWidgets.QLabel("Project Name:") 52 | self.btnProjectName = QtWidgets.QPushButton("Create New") 53 | self.txtProjectName = QtWidgets.QPlainTextEdit() 54 | if(self.parent.project_name): 55 | self.project_name = self.parent.project_name 56 | self.txtProjectName.setPlainText(self.project_name) 57 | self.txtProjectName.setFixedHeight(40) 58 | self.txtProjectName.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) 59 | self.txtProjectName.setReadOnly(True) 60 | 61 | self.labelChunkName = QtWidgets.QLabel("Chunk Name:") 62 | self.btnChunkName = QtWidgets.QPushButton("Rename Chunk") 63 | self.txtChunkName = QtWidgets.QPlainTextEdit(Metashape.app.document.chunk.label) 64 | self.txtChunkName.setFixedHeight(40) 65 | self.txtChunkName.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) 66 | self.txtChunkName.setReadOnly(True) 67 | 68 | self.labelAddPhotos = QtWidgets.QLabel("Add Photos:") 69 | self.btnAddPhotos = QtWidgets.QPushButton("Select Folder") 70 | self.txtAddPhotos = QtWidgets.QPlainTextEdit() 71 | if(len(Metashape.app.document.chunk.cameras) > 0): 72 | self.photo_folder = str(len(Metashape.app.document.chunk.cameras)) + " cameras found. Select a folder if you would like to add more" 73 | self.txtAddPhotos.setPlainText(self.photo_folder) 74 | self.txtAddPhotos.setFixedHeight(40) 75 | self.txtAddPhotos.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) 76 | self.txtAddPhotos.setReadOnly(True) 77 | 78 | self.btnCreateProj = QtWidgets.QPushButton("Save Project") 79 | # self.btnCreateProj.setFixedWidth(75) 80 | 81 | self.labelPhotosAdded = QtWidgets.QLabel() 82 | 83 | # ---- create layouts and assemble widgets ---- 84 | main_layout = QtWidgets.QVBoxLayout() 85 | 86 | project_name_layout = QtWidgets.QHBoxLayout() 87 | project_name_layout.addWidget(self.labelProjectName) 88 | project_name_layout.addWidget(self.txtProjectName) 89 | project_name_layout.addWidget(self.btnProjectName) 90 | 91 | chunk_name_layout = QtWidgets.QHBoxLayout() 92 | chunk_name_layout.addWidget(self.labelChunkName) 93 | chunk_name_layout.addWidget(self.txtChunkName) 94 | chunk_name_layout.addWidget(self.btnChunkName) 95 | 96 | photos_dir_layout = QtWidgets.QHBoxLayout() 97 | photos_dir_layout.addWidget(self.labelAddPhotos) 98 | photos_dir_layout.addWidget(self.txtAddPhotos) 99 | photos_dir_layout.addWidget(self.btnAddPhotos) 100 | 101 | create_proj_layout = QtWidgets.QHBoxLayout() 102 | create_proj_layout.addStretch() 103 | create_proj_layout.addWidget(self.labelPhotosAdded) 104 | create_proj_layout.addWidget(self.btnCreateProj) 105 | 106 | main_layout.addWidget(self.labelNamingConventions) 107 | main_layout.addLayout(project_name_layout) 108 | main_layout.addLayout(chunk_name_layout) 109 | main_layout.addLayout(photos_dir_layout) 110 | main_layout.addLayout(create_proj_layout) 111 | 112 | self.setLayout(main_layout) 113 | 114 | # connect buttons to slots 115 | self.btnAddPhotos.clicked.connect(self.getPhotoFolder) 116 | self.btnProjectName.clicked.connect(self.getProjectName) 117 | self.btnChunkName.clicked.connect(self.getChunkName) 118 | self.btnCreateProj.clicked.connect(self.saveProject) 119 | 120 | def getPhotoFolder(self): 121 | ''' 122 | Slot: gets a folder from which to add photos from the user, then adds the photos to the 123 | project's active chunk 124 | ''' 125 | new_folder = QtWidgets.QFileDialog.getExistingDirectory(self, 'Open directory', self.parent.project_folder) 126 | 127 | if(new_folder): 128 | # add photos to active chunk 129 | self.photo_folder = new_folder 130 | try: 131 | image_list = os.listdir(self.photo_folder) 132 | photo_list = list() 133 | for photo in image_list: 134 | if photo.rsplit(".",1)[1].lower() in ["jpg", "jpeg", "tif", "tiff"]: 135 | photo_list.append(os.path.join(self.photo_folder, photo)) 136 | Metashape.app.document.chunk.addPhotos(photo_list) 137 | self.txtAddPhotos.setPlainText(self.photo_folder) 138 | self.labelPhotosAdded.setText(str(len(Metashape.app.document.chunk.cameras)) + " images successfully added. Select another folder if you would like to add more") 139 | except Exception as err: 140 | Metashape.app.messageBox("Error adding photos") 141 | return 142 | else: 143 | Metashape.app.messageBox("Unable to add photos: please select a folder to add photos from") 144 | 145 | 146 | def getProjectName(self): 147 | ''' 148 | Slot: gets project name from the user 149 | ''' 150 | if(self.project_path): # if there is already a valid project name and path, save any changes so they ont get lost when the new project is made 151 | Metashape.app.document.save(path = self.project_path) 152 | project_path = QtWidgets.QFileDialog.getSaveFileName(self, 'Open file', self.parent.project_folder, "Metashape Project (*.psx)")[0] 153 | new_name = os.path.basename(project_path)[:-4] 154 | if(self.checkNaming(new_name) and new_name): 155 | self.project_name = new_name 156 | self.project_path = project_path 157 | self.parent.doc = Metashape.Document() 158 | self.parent.doc.addChunk() 159 | self.parent.doc.save(path = self.project_path) 160 | Metashape.app.document.open(path = self.project_path) 161 | self.txtAddPhotos.setPlainText("No folder selected") 162 | self.labelPhotosAdded.setText("") 163 | elif(not self.checkNaming(new_name)): 164 | Metashape.app.messageBox("Unable to create project: please select a name that includes only alphanumeric characters (abcABC123) and underscore (_) or dash (-), with no special characters (e.g. @$/.)") 165 | 166 | self.txtProjectName.setPlainText(self.project_name) 167 | 168 | def getChunkName(self): 169 | ''' 170 | Slot to save chunk name 171 | ''' 172 | new_name, ok = QtWidgets.QInputDialog().getText(self, "Create Chunk", "Chunk name:") 173 | if(self.checkNaming(new_name) and new_name and ok): 174 | self.chunk_name = new_name 175 | Metashape.app.document.chunk.label = new_name 176 | elif(not self.checkNaming(new_name) and ok): 177 | Metashape.app.messageBox("Unable to rename chunk: please select a name that includes only alphanumeric characters (abcABC123) and underscore (_) or dash (-), with no special characters (e.g. @$/.)") 178 | self.txtChunkName.setPlainText(self.chunk_name) 179 | 180 | def checkNaming(self, name): 181 | ''' 182 | Checks project and chunk names to ensure there are no special characters in them 183 | ''' 184 | if(re.search("[\.\^\$\*\+\?\[\]\|\<\>&\\\]", name)): 185 | return False 186 | return True 187 | 188 | def saveProject(self): 189 | ''' 190 | Saves project to the designated path and renames the active chunk 191 | ''' 192 | Metashape.app.document.chunk.label = self.chunk_name 193 | 194 | if(self.project_path): 195 | # save project with new name - if project already exists, the current project state will be saved as changes to it 196 | Metashape.app.document.save(path = self.project_path) 197 | self.parent.project_folder = os.path.dirname(self.project_path) 198 | self.parent.project_name = os.path.basename(self.project_path)[:-4] 199 | else: 200 | Metashape.app.messageBox("Unable to save project: please select a name and file path for the project") 201 | 202 | 203 | class BoundaryMarkerDlg(QtWidgets.QDialog): 204 | ''' 205 | Optional sub-dialog box in which the user can specify the arrangement of the corner 206 | markers for georeferencing. 207 | 208 | This class does not actually modify the georeferencing file or the active chunk's markers, 209 | it is used only to get user input as to how the boundary creation may be adjusted for different 210 | marker placement scenarios. See the boundaryCreation() and create_shape_from_markers() functions 211 | in FullUW_dialog.py to see how this functionality is implemented. 212 | 213 | The main output from this class is the corner_markers list, which is a list of integers 214 | representing the corner markers of a photomosaic plot in clockwise order, e.g. 215 | [top-left, top-right, bottom-right, bottom-left] 216 | ''' 217 | def __init__(self, parent): 218 | self.corner_markers = [] # initialize return value to empty list 219 | 220 | # initialize main dialog window 221 | QtWidgets.QDialog.__init__(self, parent) 222 | self.setWindowTitle("Corner Marker Positions") 223 | 224 | # ---- create widgets ---- 225 | self.labelCornerPositioning = QtWidgets.QLabel("Each auto-detectable marker is numbered with a " + 226 | "unique integer. When setting up a plot, it is best to place the " + 227 | "corner markers around the plot such that they are in numeric order " + 228 | "going clockwise or counterclockwise, as shown as the default below. However, if the " + 229 | "markers were placed in a different arrangement, you may specify that " + 230 | "arrangement here. This will enable the script to draw the bounding " + 231 | "box for the plot correctly.\n\n") 232 | self.labelCornerPositioning.setToolTip("The orientation of the plot does not matter, only the relative " + 233 | "positions of the markers around it.\nFor example, the default " + 234 | "arrangement below could also be specified by setting the top-left to target " + 235 | "4,\nthe top-right to target 3, the bottom-right to target 2, and the bottom-left " + 236 | "to target 1") 237 | self.labelCornerPositioning.setWordWrap(True) 238 | # self.labelCornerPositioning.setAlignment(QtCore.Qt.AlignCenter) 239 | 240 | self.labelCorner1 = QtWidgets.QLabel("NW target number: ") 241 | self.spinboxCorner1 = QtWidgets.QSpinBox() 242 | self.spinboxCorner1.setMinimum(1) 243 | self.spinboxCorner1.setValue(4) 244 | 245 | self.labelCorner2 = QtWidgets.QLabel("NE target number: ") 246 | self.spinboxCorner2 = QtWidgets.QSpinBox() 247 | self.spinboxCorner2.setMinimum(1) 248 | self.spinboxCorner2.setValue(1) 249 | 250 | self.labelCorner3 = QtWidgets.QLabel("SE target number: ") 251 | self.spinboxCorner3 = QtWidgets.QSpinBox() 252 | self.spinboxCorner3.setMinimum(1) 253 | self.spinboxCorner3.setValue(2) 254 | 255 | self.labelCorner4 = QtWidgets.QLabel("SW target number: ") 256 | self.spinboxCorner4 = QtWidgets.QSpinBox() 257 | self.spinboxCorner4.setMinimum(1) 258 | self.spinboxCorner4.setValue(3) 259 | 260 | self.btnOk = QtWidgets.QPushButton("Ok") 261 | self.btnOk.setFixedSize(70, 40) 262 | self.btnOk.setToolTip("Set Marker Positions") 263 | 264 | self.btnClose = QtWidgets.QPushButton("Close") 265 | self.btnClose.setFixedSize(70, 40) 266 | 267 | # ---- create layouts to hold widgets ---- 268 | corner1_layout = QtWidgets.QHBoxLayout() 269 | corner1_layout.addWidget(self.labelCorner1) 270 | corner1_layout.addWidget(self.spinboxCorner1) 271 | 272 | corner2_layout = QtWidgets.QHBoxLayout() 273 | corner2_layout.addWidget(self.labelCorner2) 274 | corner2_layout.addWidget(self.spinboxCorner2) 275 | 276 | corner3_layout = QtWidgets.QHBoxLayout() 277 | corner3_layout.addWidget(self.labelCorner3) 278 | corner3_layout.addWidget(self.spinboxCorner3) 279 | 280 | corner4_layout = QtWidgets.QHBoxLayout() 281 | corner4_layout.addWidget(self.labelCorner4) 282 | corner4_layout.addWidget(self.spinboxCorner4) 283 | 284 | ok_layout = QtWidgets.QHBoxLayout() 285 | ok_layout.addWidget(self.btnOk) 286 | ok_layout.addWidget(self.btnClose) 287 | 288 | # create grid layout to arrange target inputs in a rectangle 289 | plot_layout = QtWidgets.QGridLayout() 290 | plot_layout.setColumnStretch(0, 1) 291 | plot_layout.setColumnStretch(1, 10) 292 | plot_layout.setColumnMinimumWidth(1, 150) 293 | plot_layout.setColumnStretch(2, 1) 294 | plot_layout.setRowStretch(0, 1) 295 | plot_layout.setRowStretch(1, 10) 296 | plot_layout.setRowMinimumHeight(1, 150) 297 | plot_layout.setRowStretch(2, 1) 298 | 299 | # create a groupbox to act as a plot rectangle 300 | plot_groupbox = QtWidgets.QGroupBox() 301 | plot_label = QtWidgets.QLabel("Reef Plot") 302 | plot_label.setAlignment(QtCore.Qt.AlignCenter) 303 | plot_label.setEnabled(False) 304 | 305 | inner_layout = QtWidgets.QHBoxLayout() 306 | inner_layout.addWidget(plot_label) 307 | plot_groupbox.setLayout(inner_layout) 308 | 309 | # ---- assemble layouts into main layout ---- 310 | main_layout = QtWidgets.QVBoxLayout() 311 | main_layout.addWidget(self.labelCornerPositioning) 312 | plot_layout.addLayout(corner1_layout, 0, 0) 313 | plot_layout.addLayout(corner2_layout, 0, 2) 314 | plot_layout.addWidget(plot_groupbox, 1, 1) 315 | plot_layout.addLayout(corner3_layout, 2, 2) 316 | plot_layout.addLayout(corner4_layout, 2, 0) 317 | main_layout.addLayout(plot_layout) 318 | main_layout.addLayout(ok_layout) 319 | 320 | self.setLayout(main_layout) 321 | 322 | # ---- connect signals and slots ---- 323 | self.btnOk.clicked.connect(self.ok) 324 | QtCore.QObject.connect(self.btnClose, QtCore.SIGNAL("clicked()"), self, QtCore.SLOT("reject()")) 325 | 326 | self.exec() 327 | 328 | def ok(self): 329 | ''' 330 | Slot for user to set corner marker values and close the dialog - ensures that there is a valid 331 | return value only if the user clicks ok. A more integrated Qt way to do this might be to 332 | reimplement the core 'accept()' slot, but this way is a bit simpler. 333 | 334 | For this specific use case, making sure there is a valid return isn't really necessary since there 335 | will be a default value in the main dialog anyway, but it's good practice and keeps the script a bit more flexible 336 | ''' 337 | self.corner_markers = [self.spinboxCorner1.value(), self.spinboxCorner2.value(), self.spinboxCorner3.value(), self.spinboxCorner4.value()] 338 | self.reject() 339 | 340 | 341 | 342 | class GeoreferenceGroupBox(QtWidgets.QGroupBox): 343 | ''' 344 | Groupbox holding widgets used for importing scaling and georeferencing information into an 345 | existing Metashape project, as well as specifying the formatting and arrangement of the 346 | georeferencing file and markers. 347 | 348 | This class's only purpose is UI organization; it does not actually import scaling or referencing 349 | information. For these tasks, see importReference() and createScalebars() in FullUW_dialog.py. 350 | This class is best used in conjunction with those functions by accessing its member variables, 351 | such as georef_path or scalebars_path. 352 | ''' 353 | def __init__(self, parent): 354 | super().__init__("Georeferencing") 355 | self.parent = parent 356 | self.autoDetectMarkers = False 357 | # set default corner marker arrangement 358 | self.corner_markers = [1, 2, 3, 4] 359 | 360 | # -- Build Widgets -- 361 | # scaling/georeferencing type 362 | self.labelReference = QtWidgets.QLabel("Are you using auto-detectable markers for scaling and georeferencing?") 363 | self.comboReference = QtWidgets.QComboBox() 364 | self.comboReference.addItems(["No", "Yes"]) 365 | self.comboReference.setCurrentIndex(0) 366 | 367 | # add target types 368 | self.targetTypes = [ 369 | ("Circular Target 12 Bit", Metashape.CircularTarget12bit), 370 | ("Circular Target 14 Bit", Metashape.CircularTarget14bit), 371 | ("Circular Target 16 Bit", Metashape.CircularTarget16bit), 372 | ("Circular Target 20 Bit", Metashape.CircularTarget20bit), 373 | ("Circular Target", Metashape.CircularTarget), 374 | ("Cross Target", Metashape.CrossTarget) 375 | ] 376 | self.target_type = Metashape.CircularTarget12bit # set default target type 377 | self.labelTargetType = QtWidgets.QLabel("Select Target Type:") 378 | self.comboTargetType = QtWidgets.QComboBox() 379 | self.comboTargetType.setEnabled(False) 380 | for target_type in self.targetTypes: 381 | self.comboTargetType.addItem(target_type[0]) 382 | 383 | # file input for scalebars 384 | self.labelScaleFile = QtWidgets.QLabel("Scalebar File:") 385 | self.labelScaleFile.setEnabled(False) 386 | self.btnScaleFile = QtWidgets.QPushButton("Select File") 387 | self.btnScaleFile.setEnabled(False) 388 | self.txtScaleFile = QtWidgets.QPlainTextEdit("No file selected") 389 | self.txtScaleFile.setEnabled(False) 390 | self.txtScaleFile.setFixedHeight(40) 391 | self.txtScaleFile.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) 392 | self.txtScaleFile.setReadOnly(True) 393 | 394 | # file input for georeferencing 395 | self.labelGeoFile = QtWidgets.QLabel("Georeferencing File:") 396 | self.labelGeoFile.setEnabled(False) 397 | self.btnGeoFile = QtWidgets.QPushButton("Select File") 398 | self.btnGeoFile.setEnabled(False) 399 | self.txtGeoFile = QtWidgets.QPlainTextEdit("No file selected") 400 | self.txtGeoFile.setEnabled(False) 401 | self.txtGeoFile.setFixedHeight(40) 402 | self.txtGeoFile.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) 403 | self.txtGeoFile.setReadOnly(True) 404 | 405 | self.btnMarkerPosition = QtWidgets.QPushButton("Adjust Corner Markers") 406 | # self.btnMarkerPosition.setEnabled(False) 407 | # self.btnMarkerPosition.setFixedWidth(75) 408 | 409 | # add in spinbox widgets to define georeferencing format 410 | self.labelInputFormatting = QtWidgets.QLabel("Specify which columns in the georeferencing file correspond to the indicated properties") 411 | 412 | self.labelRefLabel = QtWidgets.QLabel("Label:") 413 | self.spinboxRefLabel = QtWidgets.QSpinBox() 414 | self.spinboxRefLabel.setMinimum(1) 415 | self.spinboxRefLabel.setValue(1) 416 | 417 | self.labelRefX = QtWidgets.QLabel("Long (X):") 418 | self.spinboxRefX = QtWidgets.QSpinBox() 419 | self.spinboxRefX.setMinimum(1) 420 | self.spinboxRefX.setValue(3) 421 | 422 | self.labelRefY = QtWidgets.QLabel("Lat (Y):") 423 | self.spinboxRefY = QtWidgets.QSpinBox() 424 | self.spinboxRefY.setMinimum(1) 425 | self.spinboxRefY.setValue(2) 426 | 427 | self.labelRefZ = QtWidgets.QLabel("Depth (Z):") 428 | self.spinboxRefZ = QtWidgets.QSpinBox() 429 | self.spinboxRefZ.setMinimum(1) 430 | self.spinboxRefZ.setValue(4) 431 | 432 | self.labelAccuracy = QtWidgets.QLabel("Accuracy") 433 | self.spinboxXAcc = QtWidgets.QSpinBox() 434 | self.spinboxXAcc.setMinimum(1) 435 | self.spinboxXAcc.setValue(5) 436 | self.spinboxYAcc = QtWidgets.QSpinBox() 437 | self.spinboxYAcc.setMinimum(1) 438 | self.spinboxYAcc.setValue(5) 439 | self.spinboxZAcc = QtWidgets.QSpinBox() 440 | self.spinboxZAcc.setMinimum(1) 441 | self.spinboxZAcc.setValue(6) 442 | 443 | self.labelSkipRows = QtWidgets.QLabel("Start import at row:") 444 | self.spinboxSkipRows = QtWidgets.QSpinBox() 445 | self.spinboxSkipRows.setMinimum(1) 446 | self.spinboxSkipRows.setValue(2) 447 | 448 | # ---- Build Sublayouts ---- 449 | autodetect_layout = QtWidgets.QHBoxLayout() 450 | autodetect_layout.addWidget(self.labelReference) 451 | autodetect_layout.addWidget(self.comboReference) 452 | autodetect_layout.addWidget(self.comboTargetType) 453 | 454 | scale_layout = QtWidgets.QHBoxLayout() 455 | scale_layout.addWidget(self.labelScaleFile) 456 | scale_layout.addWidget(self.txtScaleFile) 457 | scale_layout.addWidget(self.btnScaleFile) 458 | 459 | geo_layout = QtWidgets.QHBoxLayout() 460 | geo_layout.addWidget(self.labelGeoFile) 461 | geo_layout.addWidget(self.txtGeoFile) 462 | geo_layout.addWidget(self.btnGeoFile) 463 | 464 | marker_pos_layout = QtWidgets.QHBoxLayout() 465 | marker_pos_layout.addStretch() 466 | marker_pos_layout.addWidget(self.btnMarkerPosition) 467 | 468 | ref_format_layout = QtWidgets.QGridLayout() 469 | ref_format_layout.setColumnStretch(0, 1) 470 | ref_format_layout.setColumnStretch(1, 10) 471 | ref_format_layout.setColumnStretch(2, 10) 472 | ref_format_layout.addWidget(self.labelInputFormatting, 0, 0, 1, 3) 473 | 474 | ref_format_layout.addWidget(self.labelRefLabel, 1, 0) 475 | ref_format_layout.addWidget(self.spinboxRefLabel, 1, 1) 476 | ref_format_layout.addWidget(self.labelAccuracy, 1, 2) 477 | 478 | ref_format_layout.addWidget(self.labelRefX, 2, 0) 479 | ref_format_layout.addWidget(self.spinboxRefX, 2, 1) 480 | ref_format_layout.addWidget(self.spinboxXAcc, 2, 2) 481 | 482 | ref_format_layout.addWidget(self.labelRefY, 3, 0) 483 | ref_format_layout.addWidget(self.spinboxRefY, 3, 1) 484 | ref_format_layout.addWidget(self.spinboxYAcc, 3, 2) 485 | 486 | ref_format_layout.addWidget(self.labelRefZ, 4, 0) 487 | ref_format_layout.addWidget(self.spinboxRefZ, 4, 1) 488 | ref_format_layout.addWidget(self.spinboxZAcc, 4, 2) 489 | 490 | skip_rows_layout = QtWidgets.QHBoxLayout() 491 | skip_rows_layout.addWidget(self.labelSkipRows) 492 | skip_rows_layout.addWidget(self.spinboxSkipRows) 493 | ref_format_layout.addLayout(skip_rows_layout, 5, 0, 1, 2) 494 | 495 | self.ref_format_groupbox = QtWidgets.QGroupBox("Column Formatting") 496 | self.ref_format_groupbox.setLayout(ref_format_layout) 497 | self.ref_format_groupbox.setEnabled(False) 498 | 499 | # ---- Assemble Layouts ---- 500 | reference_layout = QtWidgets.QVBoxLayout() 501 | reference_layout.addLayout(autodetect_layout) 502 | reference_layout.addLayout(scale_layout) 503 | reference_layout.addLayout(geo_layout) 504 | reference_layout.addLayout(marker_pos_layout) 505 | reference_layout.addWidget(self.ref_format_groupbox) 506 | self.setLayout(reference_layout) 507 | 508 | # ---- Connect Signals and Slots ---- 509 | QtCore.QObject.connect(self.btnScaleFile, QtCore.SIGNAL("clicked()"), self.getScaleFile) 510 | QtCore.QObject.connect(self.btnGeoFile, QtCore.SIGNAL("clicked()"), self.getGeoFile) 511 | self.comboReference.currentIndexChanged.connect(self.onReferenceChanged) 512 | self.comboTargetType.currentIndexChanged.connect(self.onTargetTypeChange) 513 | self.btnMarkerPosition.clicked.connect(self.getMarkerPosition) 514 | 515 | def getScaleFile(self): 516 | # maybe change this to local variable and get text value from text box directly when workflow is run? 517 | # might be better to connect textchanged() signal to a custom function, but more work 518 | self.scalebars_path = QtWidgets.QFileDialog.getOpenFileName(self, 'Open file', self.parent.project_folder, "CSV files (*.csv *.txt)")[0] 519 | if(self.scalebars_path): 520 | self.txtScaleFile.setPlainText(self.scalebars_path) 521 | else: 522 | self.txtScaleFile.setPlainText("No File Selected") 523 | 524 | def getGeoFile(self): 525 | self.georef_path = QtWidgets.QFileDialog.getOpenFileName(self, 'Open file', self.parent.project_folder, "CSV files (*.csv *.txt)")[0] 526 | if(self.georef_path): 527 | self.txtGeoFile.setPlainText(self.georef_path) 528 | else: 529 | self.txtGeoFile.setPlainText("No File Selected") 530 | 531 | def onReferenceChanged(self): 532 | ''' 533 | Slot: When the user switches between using auto-detectable markers and not 534 | using them via the comboReference combo box, this function enables or disables 535 | all widgets related to the automatic referencing process. 536 | ''' 537 | self.autoDetectMarkers = self.comboReference.currentIndex() 538 | self.ref_format_groupbox.setEnabled(self.autoDetectMarkers) 539 | 540 | self.comboTargetType.setEnabled(self.autoDetectMarkers) 541 | 542 | self.labelScaleFile.setEnabled(self.autoDetectMarkers) 543 | self.btnScaleFile.setEnabled(self.autoDetectMarkers) 544 | self.txtScaleFile.setEnabled(self.autoDetectMarkers) 545 | 546 | self.labelGeoFile.setEnabled(self.autoDetectMarkers) 547 | self.btnGeoFile.setEnabled(self.autoDetectMarkers) 548 | self.txtGeoFile.setEnabled(self.autoDetectMarkers) 549 | 550 | def getMarkerPosition(self): 551 | ''' 552 | Slot: Launches a sub-dialog box where the user can specify the arrangement of 553 | corner markers. 554 | ''' 555 | marker_dlg = BoundaryMarkerDlg(self.parent) 556 | self.corner_markers = marker_dlg.corner_markers 557 | 558 | def onTargetTypeChange(self): 559 | ''' 560 | Updates target type member variable when the user selects a new type 561 | ''' 562 | target_type_index = self.comboTargetType.currentIndex() 563 | self.target_type = self.targetTypes[target_type_index][1] 564 | -------------------------------------------------------------------------------- /Scalebar Example.txt: -------------------------------------------------------------------------------- 1 | [first point],[second point],[measured distance in meters],[accuracy in meters] 2 | target a,target b,0.5,0.01 3 | [new line for each scalebar in collection] --------------------------------------------------------------------------------