diff --git a/src/cplus_plugin/conf.py b/src/cplus_plugin/conf.py index 51b640af4..750cc2592 100644 --- a/src/cplus_plugin/conf.py +++ b/src/cplus_plugin/conf.py @@ -21,6 +21,7 @@ NCS_CARBON_SEGMENT, NCS_PATHWAY_SEGMENT, NPV_COLLECTION_PROPERTY, + MASK_PATHS_SEGMENT, PATH_ATTRIBUTE, PATHWAYS_ATTRIBUTE, PIXEL_VALUE_ATTRIBUTE, @@ -1293,6 +1294,7 @@ def save_activity(self, activity: typing.Union[Activity, dict]): priority_layers = activity.priority_layers layer_styles = activity.layer_styles style_pixel_value = activity.style_pixel_value + mask_paths = activity.mask_paths ncs_pathways = [] for ncs in activity.pathways: @@ -1300,6 +1302,7 @@ def save_activity(self, activity: typing.Union[Activity, dict]): activity = layer_component_to_dict(activity) activity[PRIORITY_LAYERS_SEGMENT] = priority_layers + activity[MASK_PATHS_SEGMENT] = mask_paths activity[PATHWAYS_ATTRIBUTE] = ncs_pathways activity[STYLE_ATTRIBUTE] = layer_styles activity[PIXEL_VALUE_ATTRIBUTE] = style_pixel_value diff --git a/src/cplus_plugin/definitions/constants.py b/src/cplus_plugin/definitions/constants.py index 8232fb465..3ff2bf229 100644 --- a/src/cplus_plugin/definitions/constants.py +++ b/src/cplus_plugin/definitions/constants.py @@ -7,6 +7,7 @@ NCS_PATHWAY_SEGMENT = "ncs_pathways" NCS_CARBON_SEGMENT = "ncs_carbon" PRIORITY_LAYERS_SEGMENT = "priority_layers" +MASK_PATHS_SEGMENT = "mask_paths" NPV_PRIORITY_LAYERS_SEGMENT = "npv" COMPARISON_REPORT_SEGMENT = "comparison_reports" diff --git a/src/cplus_plugin/gui/activity_editor_dialog.py b/src/cplus_plugin/gui/activity_editor_dialog.py index 8f078b67f..cb6edba48 100644 --- a/src/cplus_plugin/gui/activity_editor_dialog.py +++ b/src/cplus_plugin/gui/activity_editor_dialog.py @@ -17,7 +17,7 @@ ) from qgis.gui import QgsGui, QgsMessageBar -from qgis.PyQt import QtGui, QtWidgets +from qgis.PyQt import QtCore, QtGui, QtWidgets from qgis.PyQt.uic import loadUiType @@ -74,6 +74,7 @@ def __init__(self, parent=None, activity=None, excluded_names=None): self._edit_mode = False self._layer = None + self._mask_layer = None self._excluded_names = excluded_names if excluded_names is None: @@ -93,6 +94,32 @@ def __init__(self, parent=None, activity=None, excluded_names=None): # Hide map layer handling self.layer_gb.setVisible(False) + # Mask layers + add_icon = FileUtils.get_icon("symbologyAdd.svg") + self.btn_add_mask.setIcon(add_icon) + self.btn_add_mask.clicked.connect(self._on_add_mask_layer) + + remove_icon = FileUtils.get_icon("symbologyRemove.svg") + self.btn_delete_mask.setIcon(remove_icon) + self.btn_delete_mask.setEnabled(False) + self.btn_delete_mask.clicked.connect(self._on_remove_mask_layer) + + edit_icon = FileUtils.get_icon("mActionToggleEditing.svg") + self.btn_edit_mask.setIcon(edit_icon) + self.btn_edit_mask.setEnabled(False) + self.btn_edit_mask.clicked.connect(self._on_edit_mask_layer) + + if self._activity is not None: + mask_paths_list = self._activity.mask_paths + + for mask_path in mask_paths_list or []: + if mask_path == "": + continue + item = QtWidgets.QListWidgetItem() + item.setData(QtCore.Qt.DisplayRole, mask_path) + self.lst_mask_layers.addItem(item) + self.mask_layers_changed() + @property def activity(self) -> Activity: """Returns a reference to the activity object. @@ -186,6 +213,88 @@ def _add_layer_path(self, layer_path: str): else: self.cbo_layer.setCurrentIndex(matching_index) + def _on_add_mask_layer(self, activated: bool): + """Slot raised to add a mask layer.""" + data_dir = settings_manager.get_value(Settings.LAST_MASK_DIR, default=None) + + if not data_dir: + data_dir = os.path.expanduser("~") + + mask_path = self._show_mask_path_selector(data_dir) + if not mask_path: + return + + item = QtWidgets.QListWidgetItem() + item.setData(QtCore.Qt.DisplayRole, mask_path) + + if self.lst_mask_layers.findItems(mask_path, QtCore.Qt.MatchExactly): + error_tr = tr("The selected mask layer already exists.") + self.message_bar.pushMessage(error_tr, qgis.core.Qgis.MessageLevel.Warning) + return + + self.lst_mask_layers.addItem(item) + settings_manager.set_value(Settings.LAST_MASK_DIR, os.path.dirname(mask_path)) + + self.mask_layers_changed() + + def _on_edit_mask_layer(self, activated: bool): + """Slot raised to edit a mask layer.""" + + item = self.lst_mask_layers.currentItem() + if not item: + error_tr = tr("Select a mask layer first.") + self.message_bar.pushMessage(error_tr, qgis.core.Qgis.MessageLevel.Warning) + return + mask_path = self._show_mask_path_selector(item.data(QtCore.Qt.DisplayRole)) + if not mask_path: + return + + if self.lst_mask_layers.findItems(mask_path, QtCore.Qt.MatchExactly): + error_tr = tr("The selected mask layer already exists.") + self.message_bar.pushMessage(error_tr, qgis.core.Qgis.MessageLevel.Warning) + return + + item.setData(QtCore.Qt.DisplayRole, mask_path) + + def _on_remove_mask_layer(self, activated: bool): + """Slot raised to remove one or more selected mask layers.""" + items = self.lst_mask_layers.selectedItems() + if not items: + error_tr = tr("Select the target mask layer first, before removing it.") + self.message_bar.pushMessage(error_tr, qgis.core.Qgis.MessageLevel.Warning) + return + + reply = QtWidgets.QMessageBox.warning( + self, + tr("QGIS CPLUS PLUGIN | Settings"), + tr("Remove the selected mask layer(s)?"), + QtWidgets.QMessageBox.Yes, + QtWidgets.QMessageBox.No, + ) + + if reply == QtWidgets.QMessageBox.Yes: + for item in items: + item_row = self.lst_mask_layers.row(item) + self.lst_mask_layers.takeItem(item_row) + + self.mask_layers_changed() + + def _show_mask_path_selector(self, layer_dir: str) -> str: + """Show file selector dialog for selecting a mask layer.""" + filter_tr = tr("Shapefiles") + + layer_path, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + self.tr("Select mask Layer"), + layer_dir, + f"{filter_tr} (*.shp)", + options=QtWidgets.QFileDialog.DontResolveSymlinks, + ) + if not layer_path: + return "" + + return layer_path + def validate(self) -> bool: """Validates if name has been specified. @@ -266,6 +375,15 @@ def _create_activity(self): ACTIVITY_LAYER_STYLE_ATTRIBUTE ] = color_ramp_info + # Mask layers settings + mask_paths = [] + for row in range(0, self.lst_mask_layers.count()): + item = self.lst_mask_layers.item(row) + item_path = item.data(QtCore.Qt.DisplayRole) + mask_paths.append(item_path) + + self._activity.mask_paths = mask_paths + def _get_selected_map_layer(self) -> typing.Union[QgsRasterLayer, None]: """Returns the currently selected map layer or None if there is no item in the combobox. @@ -358,3 +476,9 @@ def _on_select_file(self, activated: bool): self._add_layer_path(layer_path) settings_manager.set_value(Settings.LAST_DATA_DIR, os.path.dirname(layer_path)) + + def mask_layers_changed(self): + contains_items = self.lst_mask_layers.count() > 0 + + self.btn_edit_mask.setEnabled(contains_items) + self.btn_delete_mask.setEnabled(contains_items) diff --git a/src/cplus_plugin/models/base.py b/src/cplus_plugin/models/base.py index e915d7a7c..91ae2e317 100644 --- a/src/cplus_plugin/models/base.py +++ b/src/cplus_plugin/models/base.py @@ -390,6 +390,7 @@ class Activity(LayerModelComponent): pathways: typing.List[NcsPathway] = dataclasses.field(default_factory=list) priority_layers: typing.List[typing.Dict] = dataclasses.field(default_factory=list) layer_styles: dict = dataclasses.field(default_factory=dict) + mask_paths: typing.List[str] = dataclasses.field(default_factory=list) style_pixel_value: int = -1 @classmethod diff --git a/src/cplus_plugin/models/helpers.py b/src/cplus_plugin/models/helpers.py index 9792c676e..84dd8ab7e 100644 --- a/src/cplus_plugin/models/helpers.py +++ b/src/cplus_plugin/models/helpers.py @@ -37,6 +37,7 @@ DESCRIPTION_ATTRIBUTE, LAYER_TYPE_ATTRIBUTE, MANUAL_NPV_ATTRIBUTE, + MASK_PATHS_SEGMENT, NPV_MAPPINGS_ATTRIBUTE, MAX_VALUE_ATTRIBUTE, MIN_VALUE_ATTRIBUTE, @@ -198,6 +199,9 @@ def create_activity(source_dict) -> typing.Union[Activity, None]: if PRIORITY_LAYERS_SEGMENT in source_dict.keys(): activity.priority_layers = source_dict[PRIORITY_LAYERS_SEGMENT] + if MASK_PATHS_SEGMENT in source_dict.keys(): + activity.mask_paths = source_dict[MASK_PATHS_SEGMENT] + # Set style if STYLE_ATTRIBUTE in source_dict.keys(): activity.layer_styles = source_dict[STYLE_ATTRIBUTE] diff --git a/src/cplus_plugin/tasks.py b/src/cplus_plugin/tasks.py index f07c30d2b..62d5fe872 100644 --- a/src/cplus_plugin/tasks.py +++ b/src/cplus_plugin/tasks.py @@ -323,6 +323,12 @@ def run(self): extent_string, ) + # Run internal masking of the activities layers + self.run_internal_activities_masking( + self.analysis_activities, + extent_string, + ) + # TODO enable the sieve functionality sieve_enabled = self.get_settings_value( Settings.SIEVE_ENABLED, default=False, setting_type=bool @@ -1269,8 +1275,11 @@ def run_activities_masking( ) return False + # see https://qgis.org/pyqgis/master/core/Qgis.html#qgis.core.Qgis.GeometryType if Qgis.versionInt() < 33000: - layer_check = initial_mask_layer.geometryType() == QgsWkbTypes.Polygon + layer_check = ( + initial_mask_layer.geometryType() == QgsWkbTypes.PolygonGeometry + ) else: layer_check = ( initial_mask_layer.geometryType() == Qgis.GeometryType.Polygon @@ -1375,6 +1384,161 @@ def run_activities_masking( return True + def run_internal_activities_masking( + self, activities, extent, temporary_output=False + ): + """Applies the mask layers into the passed activities + + :param activities: List of the selected activities + :type activities: typing.List[Activity] + + :param extent: selected extent from user + :type extent: str + + :param temporary_output: Whether to save the processing outputs as temporary + files + :type temporary_output: bool + + :returns: Whether the task operations was successful + :rtype: bool + """ + if self.processing_cancelled: + # Will not proceed if processing has been cancelled by the user + return False + + self.set_status_message( + tr("Masking activities using their respective mask layers.") + ) + + try: + for activity in activities: + masking_layers = activity.mask_paths + + if len(masking_layers) < 1: + return False + if len(masking_layers) > 1: + initial_mask_layer = self.merge_vector_layers(masking_layers) + else: + mask_layer_path = masking_layers[0] + initial_mask_layer = QgsVectorLayer(mask_layer_path, "mask", "ogr") + + if not initial_mask_layer.isValid(): + self.log_message( + f"Skipping activities masking " + f"using layer {mask_layer_path}, not a valid layer." + ) + return False + + # see https://qgis.org/pyqgis/master/core/Qgis.html#qgis.core.Qgis.GeometryType + if Qgis.versionInt() < 33000: + layer_check = ( + initial_mask_layer.geometryType() == QgsWkbTypes.PolygonGeometry + ) + else: + layer_check = ( + initial_mask_layer.geometryType() == Qgis.GeometryType.Polygon + ) + + if not layer_check: + self.log_message( + f"Skipping activities masking " + f"using layer {mask_layer_path}, not a polygon layer." + ) + return False + + extent_layer = self.layer_extent(extent) + mask_layer = self.mask_layer_difference( + initial_mask_layer, extent_layer + ) + + if isinstance(mask_layer, str): + mask_layer = QgsVectorLayer(mask_layer, "ogr") + + if not mask_layer.isValid(): + self.log_message( + f"Skipping activities masking " + f"the created difference mask layer {mask_layer.source()}," + f" not a valid layer." + ) + return False + if activity.path is None or activity.path == "": + if not self.processing_cancelled: + self.set_info_message( + tr( + f"Problem when masking activities, " + f"there is no map layer for the activity {activity.name}" + ), + level=Qgis.Critical, + ) + self.log_message( + f"Problem when masking activities, " + f"there is no map layer for the activity {activity.name}" + ) + else: + # If the user cancelled the processing + self.set_info_message( + tr(f"Processing has been cancelled by the user."), + level=Qgis.Critical, + ) + self.log_message(f"Processing has been cancelled by the user.") + + return False + + masked_activities_directory = os.path.join( + self.scenario_directory, "final_masked_activities" + ) + FileUtils.create_new_dir(masked_activities_directory) + file_name = clean_filename(activity.name.replace(" ", "_")) + + output_file = os.path.join( + masked_activities_directory, + f"{file_name}_{str(uuid.uuid4())[:4]}.tif", + ) + + output = ( + QgsProcessing.TEMPORARY_OUTPUT if temporary_output else output_file + ) + + activity_layer = QgsRasterLayer(activity.path, "activity_layer") + + # Actual processing calculation + alg_params = { + "INPUT": activity.path, + "MASK": mask_layer, + "SOURCE_CRS": activity_layer.crs(), + "DESTINATION_CRS": activity_layer.crs(), + "TARGET_EXTENT": extent, + "OUTPUT": output, + "NO_DATA": -9999, + } + + self.log_message( + f"Used parameters for masking the activities: {alg_params} \n" + ) + + feedback = QgsProcessingFeedback() + + feedback.progressChanged.connect(self.update_progress) + + if self.processing_cancelled: + return False + + results = processing.run( + "gdal:cliprasterbymasklayer", + alg_params, + context=self.processing_context, + feedback=self.feedback, + ) + activity.path = results["OUTPUT"] + + except Exception as e: + self.log_message(f"Problem masking activities layers, {e} \n") + self.cancel_task(e) + + return False + + return True + def merge_vector_layers(self, layers): """Merges the passed vector layers into a single layer diff --git a/src/cplus_plugin/ui/activity_editor_dialog.ui b/src/cplus_plugin/ui/activity_editor_dialog.ui index 9a812ff23..bdd38c7ea 100644 --- a/src/cplus_plugin/ui/activity_editor_dialog.ui +++ b/src/cplus_plugin/ui/activity_editor_dialog.ui @@ -6,8 +6,8 @@ 0 0 - 436 - 456 + 477 + 588 @@ -20,6 +20,32 @@ Activity Editor + + + + Qt::Horizontal + + + + 198 + 20 + + + + + + + + Description + + + + + + + + + @@ -85,53 +111,26 @@ - - - - - + + - Name - - - - - - - 200 + Help - - + + - Description + Name - - - - + Style - - - - Output activity layer - - - - - - - Scenario layer - - - @@ -170,10 +169,47 @@ + + + + Output activity layer + + + + + + + Scenario layer + + + - + + + + + 30 + 0 + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + 200 + + + + @@ -197,13 +233,6 @@ false - - - - false - - - @@ -214,43 +243,67 @@ + + + + false + + + - - - - Help - - - - - - - Qt::Horizontal - - - - 198 - 20 - - - - - - - - - 30 - 0 - - - - Qt::Horizontal + + + + Activity Mask - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + true + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + ... + + + + + + + ... + + + + + + + ... + + + + + + + QAbstractItemView::MultiSelection + + + + diff --git a/test/data/mask/layers/test_mask_1.cpg b/test/data/mask/layers/test_mask_1.cpg new file mode 100644 index 000000000..3ad133c04 --- /dev/null +++ b/test/data/mask/layers/test_mask_1.cpg @@ -0,0 +1 @@ +UTF-8 \ No newline at end of file diff --git a/test/data/mask/layers/test_mask_1.dbf b/test/data/mask/layers/test_mask_1.dbf new file mode 100644 index 000000000..03910b828 Binary files /dev/null and b/test/data/mask/layers/test_mask_1.dbf differ diff --git a/test/data/mask/layers/test_mask_1.prj b/test/data/mask/layers/test_mask_1.prj new file mode 100644 index 000000000..f45cbadf0 --- /dev/null +++ b/test/data/mask/layers/test_mask_1.prj @@ -0,0 +1 @@ +GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]] \ No newline at end of file diff --git a/test/data/mask/layers/test_mask_1.qix b/test/data/mask/layers/test_mask_1.qix new file mode 100644 index 000000000..588b9f49e Binary files /dev/null and b/test/data/mask/layers/test_mask_1.qix differ diff --git a/test/data/mask/layers/test_mask_1.qmd b/test/data/mask/layers/test_mask_1.qmd new file mode 100644 index 000000000..ae78cd98c --- /dev/null +++ b/test/data/mask/layers/test_mask_1.qmd @@ -0,0 +1,27 @@ + + + + + + dataset + + + + + + + + + GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",MEMBER["World Geodetic System 1984 (Transit)"],MEMBER["World Geodetic System 1984 (G730)"],MEMBER["World Geodetic System 1984 (G873)"],MEMBER["World Geodetic System 1984 (G1150)"],MEMBER["World Geodetic System 1984 (G1674)"],MEMBER["World Geodetic System 1984 (G1762)"],MEMBER["World Geodetic System 1984 (G2139)"],ELLIPSOID["WGS 84",6378137,298.257223563,LENGTHUNIT["metre",1]],ENSEMBLEACCURACY[2.0]],PRIMEM["Greenwich",0,ANGLEUNIT["degree",0.0174532925199433]],CS[ellipsoidal,2],AXIS["geodetic latitude (Lat)",north,ORDER[1],ANGLEUNIT["degree",0.0174532925199433]],AXIS["geodetic longitude (Lon)",east,ORDER[2],ANGLEUNIT["degree",0.0174532925199433]],USAGE[SCOPE["Horizontal component of 3D system."],AREA["World."],BBOX[-90,-180,90,180]],ID["EPSG",4326]] + +proj=longlat +datum=WGS84 +no_defs + 3452 + 4326 + EPSG:4326 + WGS 84 + longlat + EPSG:7030 + true + + + + diff --git a/test/data/mask/layers/test_mask_1.shp b/test/data/mask/layers/test_mask_1.shp new file mode 100644 index 000000000..e903c909e Binary files /dev/null and b/test/data/mask/layers/test_mask_1.shp differ diff --git a/test/data/mask/layers/test_mask_1.shx b/test/data/mask/layers/test_mask_1.shx new file mode 100644 index 000000000..b996e1ce2 Binary files /dev/null and b/test/data/mask/layers/test_mask_1.shx differ diff --git a/test/test_scenario_tasks.py b/test/test_scenario_tasks.py index 62a7849d8..d78b76968 100644 --- a/test/test_scenario_tasks.py +++ b/test/test_scenario_tasks.py @@ -12,7 +12,7 @@ from processing.core.Processing import Processing -from qgis.core import QgsRasterLayer +from qgis.core import Qgis, QgsRasterLayer, QgsVectorLayer, QgsWkbTypes from cplus_plugin.conf import settings_manager, Settings @@ -618,5 +618,101 @@ def test_scenario_activities_weighting(self): self.assertEqual(stat.minimumValue, 5.0) self.assertEqual(stat.maximumValue, 27.0) + def test_scenario_activities_masking(self): + activities_layer_directory = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "data", "activities", "layers" + ) + + mask_layers_directory = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "data", "mask", "layers" + ) + + activity_layer_path_1 = os.path.join( + activities_layer_directory, "test_activity_1.tif" + ) + mask_layer_path_1 = os.path.join(mask_layers_directory, "test_mask_1.shp") + + test_activity = Activity( + uuid=uuid.uuid4(), + name="test_activity", + description="test_description", + pathways=[], + path=activity_layer_path_1, + mask_paths=[mask_layer_path_1], + ) + + settings_manager.save_activity(test_activity) + + activity_layer = QgsRasterLayer(test_activity.path, test_activity.name) + + test_extent = activity_layer.extent() + + scenario = Scenario( + uuid=uuid.uuid4(), + name="Scenario", + description="Scenario description", + activities=[test_activity], + extent=test_extent, + weighted_activities=[], + priority_layer_groups=[], + ) + + analysis_task = ScenarioAnalysisTask( + "test_scenario_activities_masking", + "test_scenario_activities_masking_description", + [test_activity], + [], + test_extent, + scenario, + ) + + extent_string = ( + f"{test_extent.xMinimum()},{test_extent.xMaximum()}," + f"{test_extent.yMinimum()},{test_extent.yMaximum()}" + f" [{activity_layer.crs().authid()}]" + ) + + base_dir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "data", + "activities", + ) + + scenario_directory = os.path.join( + f"{base_dir}", + f'scenario_{datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")}' + f"_{str(uuid.uuid4())[:4]}", + ) + + analysis_task.scenario_directory = scenario_directory + + settings_manager.set_value(Settings.BASE_DIR, base_dir) + + # Before masking, check if the activity layer stats are correct + activity_layer = QgsRasterLayer(test_activity.path, test_activity.name) + first_layer_stat = activity_layer.dataProvider().bandStatistics(1) + + self.assertEqual(first_layer_stat.minimumValue, 1.0) + self.assertEqual(first_layer_stat.maximumValue, 19.0) + + results = analysis_task.run_internal_activities_masking( + [test_activity], extent_string, temporary_output=True + ) + + self.assertTrue(results) + + self.assertIsInstance(results, bool) + self.assertTrue(results) + + self.assertIsNotNone(test_activity.path) + + result_layer = QgsRasterLayer(test_activity.path, test_activity.name) + + result_stat = result_layer.dataProvider().bandStatistics(1) + self.assertEqual(result_stat.minimumValue, 1.0) + self.assertEqual(result_stat.maximumValue, 18.0) + + self.assertTrue(result_layer.isValid()) + def tearDown(self): pass