├── .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 |
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 | [](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 |
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 |
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 |
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 |
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 |
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 |
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 |
105 | Screenshot of the Survey123 ReefShape Interface.
106 |
107 |
108 | 
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]
--------------------------------------------------------------------------------