diff --git a/.gitattributes b/.gitattributes
index 0bb2c110bec125f5e60bc569bb8f99cbaee2a340..4f0c26be8ff509d6aa08a723a290686fb1908058 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -34,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
zipdeepness/deepness/images/icon.png filter=lfs diff=lfs merge=lfs -text
+zipdeepness/images/icon.png filter=lfs diff=lfs merge=lfs -text
diff --git a/zipdeepness/README.md b/zipdeepness/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..e8d22bce930e79342f58cd0cdef5305e8be62454
--- /dev/null
+++ b/zipdeepness/README.md
@@ -0,0 +1,7 @@
+# Deepness: Deep Neural Remote Sensing
+
+Plugin for QGIS to perform map/image segmentation, regression and object detection with (ONNX) neural network models.
+
+Please visit the documentation webpage for details: https://qgis-plugin-deepness.readthedocs.io/
+
+Or the repository: https://github.com/PUTvision/qgis-plugin-deepness
diff --git a/zipdeepness/__init__.py b/zipdeepness/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f39b0a2ef08e2c60b2c0ef47be3d85a38bbbc57c
--- /dev/null
+++ b/zipdeepness/__init__.py
@@ -0,0 +1,19 @@
+"""Main plugin module - entry point for the plugin."""
+import os
+
+# increase limit of pixels (2^30), before importing cv2.
+# We are doing it here to make sure it will be done before importing cv2 for the first time
+os.environ["OPENCV_IO_MAX_IMAGE_PIXELS"] = pow(2, 40).__str__()
+
+
+# noinspection PyPep8Naming
+def classFactory(iface): # pylint: disable=invalid-name
+ """Load Deepness class from file Deepness.
+ :param iface: A QGIS interface instance.
+ :type iface: QgsInterface
+ """
+ from deepness.dialogs.packages_installer import packages_installer_dialog
+ packages_installer_dialog.check_required_packages_and_install_if_necessary(iface=iface)
+
+ from deepness.deepness import Deepness
+ return Deepness(iface)
\ No newline at end of file
diff --git a/zipdeepness/common/__init__.py b/zipdeepness/common/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c4b1577d62082b1c69aa61c20449064def038db9
--- /dev/null
+++ b/zipdeepness/common/__init__.py
@@ -0,0 +1,2 @@
+""" Submodule that contains the common functions for the deepness plugin.
+"""
diff --git a/zipdeepness/common/channels_mapping.py b/zipdeepness/common/channels_mapping.py
new file mode 100644
index 0000000000000000000000000000000000000000..5cf5e7374a564c7cbf6208b7fe40c57e39f3086d
--- /dev/null
+++ b/zipdeepness/common/channels_mapping.py
@@ -0,0 +1,262 @@
+"""
+Raster layer (ortophoto) which is being processed consist of channels (usually Red, Green, Blue).
+The neural model expects input channels with some model-defined meaning.
+Channel mappings in this file define how the ortophoto channels translate to model inputs (e.g first model input is Red, second Green).
+"""
+
+import copy
+from typing import Dict, List
+
+
+class ImageChannel:
+ """
+ Defines an image channel - how is it being stored in the data source.
+ See note at top of this file for details.
+ """
+
+ def __init__(self, name):
+ self.name = name
+
+ def get_band_number(self):
+ raise NotImplementedError('Base class not implemented!')
+
+ def get_byte_number(self):
+ raise NotImplementedError('Base class not implemented!')
+
+
+class ImageChannelStandaloneBand(ImageChannel):
+ """
+ Defines an image channel, where each image channel is a separate band in the data source.
+ See note at top of this file for details.
+ """
+
+ def __init__(self, band_number: int, name: str):
+ super().__init__(name)
+ self.band_number = band_number # index within bands (counted from one)
+
+ def __str__(self):
+ txt = f'ImageChannelStandaloneBand(name={self.name}, ' \
+ f'band_number={self.band_number})'
+ return txt
+
+ def get_band_number(self):
+ return self.band_number
+
+ def get_byte_number(self):
+ raise NotImplementedError('Something went wrong if we are here!')
+
+
+class ImageChannelCompositeByte(ImageChannel):
+ """
+ Defines an image channel, where each image channel is a smaller part of a bigger value (e.g. one byte within uint32 for each pixel).
+ See note at top of this file for details.
+ """
+
+ def __init__(self, byte_number: int, name: str):
+ super().__init__(name)
+ self.byte_number = byte_number # position in composite byte (byte number in ARGB32, counted from zero)
+
+ def __str__(self):
+ txt = f'ImageChannelCompositeByte(name={self.name}, ' \
+ f'byte_number={self.byte_number})'
+ return txt
+
+ def get_band_number(self):
+ raise NotImplementedError('Something went wrong if we are here!')
+
+ def get_byte_number(self):
+ return self.byte_number
+
+
+class ChannelsMapping:
+ """
+ Defines mapping of model input channels to input image channels (bands).
+ See note at top of this file for details.
+ """
+
+ INVALID_INPUT_CHANNEL = -1
+
+ def __init__(self):
+ self._number_of_model_inputs = 0
+ self._number_of_model_output_channels = 0
+ self._image_channels = [] # type: List[ImageChannel] # what channels are available from input image
+
+ # maps model channels to input image channels
+ # model_channel_number: image_channel_index (index in self._image_channels)
+ self._mapping = {} # type: Dict[int, int]
+
+ def __str__(self):
+ txt = f'ChannelsMapping(' \
+ f'number_of_model_inputs={self._number_of_model_inputs}, ' \
+ f'image_channels = {self._image_channels}, ' \
+ f'mapping {self._mapping})'
+ return txt
+
+ def __eq__(self, other):
+ if self._number_of_model_inputs != other._number_of_model_inputs:
+ return False
+ return True
+
+ def get_as_default_mapping(self):
+ """
+ Get the same channels mapping as we have right now, but without the mapping itself
+ (so just a definition of inputs and outputs)
+
+ Returns
+ -------
+ ChannelsMapping
+ """
+ default_channels_mapping = copy.deepcopy(self)
+ default_channels_mapping._mapping = {}
+ return default_channels_mapping
+
+ def are_all_inputs_standalone_bands(self):
+ """
+ Checks whether all image_channels are standalone bands (ImageChannelStandaloneBand)
+ """
+ for image_channel in self._image_channels:
+ if not isinstance(image_channel, ImageChannelStandaloneBand):
+ return False
+ return True
+
+ def are_all_inputs_composite_byte(self):
+ """
+ Checks whether all image_channels are composite byte (ImageChannelCompositeByte)
+ """
+ for image_channel in self._image_channels:
+ if not isinstance(image_channel, ImageChannelCompositeByte):
+ return False
+ return True
+
+ def set_number_of_model_inputs(self, number_of_model_inputs: int):
+ """ Set how many input channels does the model has
+ Parameters
+ ----------
+ number_of_model_inputs : int
+ """
+ self._number_of_model_inputs = number_of_model_inputs
+
+ def set_number_of_model_output_channels(self, number_of_output_channels: int):
+ """ Set how many output channels does the model has
+
+ Parameters
+ ----------
+ number_of_output_channels : int
+ """
+ self._number_of_model_output_channels = number_of_output_channels
+
+ def set_number_of_model_inputs_same_as_image_channels(self):
+ """ Set the number of model input channels to be the same as number of image channels
+ """
+ self._number_of_model_inputs = len(self._image_channels)
+
+ def get_number_of_model_inputs(self) -> int:
+ """ Get number of model input channels
+
+ Returns
+ -------
+ int
+ """
+ return self._number_of_model_inputs
+
+ def get_number_of_model_output_channels(self) -> int:
+ """ Get number of model output channels
+
+ Returns
+ -------
+ int
+ """
+ return self._number_of_model_output_channels
+
+ def get_number_of_image_channels(self) -> int:
+ """ Get number of image input channels
+
+ Returns
+ -------
+ int
+ """
+ return len(self._image_channels)
+
+ def set_image_channels(self, image_channels: List[ImageChannel]):
+ """ Set what are the image channels
+
+ Parameters
+ ----------
+ image_channels : List[ImageChannel]
+ Image channels to set
+ """
+ self._image_channels = image_channels
+ if not self.are_all_inputs_standalone_bands() and not self.are_all_inputs_composite_byte():
+ raise Exception("Unsupported image channels composition!")
+
+ def get_image_channels(self) -> List[ImageChannel]:
+ """ Get the current image channels definition
+
+ Returns
+ -------
+ List[ImageChannel]
+ """
+ return self._image_channels
+
+ def get_image_channel_index_for_model_input(self, model_input_number) -> int:
+ """
+ Similar to 'get_image_channel_for_model_input', but return an index in array of inputs,
+ instead of ImageChannel
+ """
+ if len(self._image_channels) == 0:
+ raise Exception("No image channels!")
+
+ image_channel_index = self._mapping.get(model_input_number, model_input_number)
+ image_channel_index = min(image_channel_index, len(self._image_channels) - 1)
+ return image_channel_index
+
+ def get_image_channel_for_model_input(self, model_input_number: int) -> ImageChannel:
+ """
+ Get ImageChannel which should be used for the specified model input
+
+ Parameters
+ ----------
+ model_input_number : int
+ Model input number, counted from 0
+
+ Returns
+ -------
+ ImageChannel
+ """
+ image_channel_index = self.get_image_channel_index_for_model_input(model_input_number)
+ return self._image_channels[image_channel_index]
+
+ def set_image_channel_for_model_input(self, model_input_number: int, image_channel_index: int) -> ImageChannel:
+ """
+ Set image_channel_index which should be used for this model input
+ """
+ if image_channel_index >= len(self._image_channels):
+ raise Exception("Invalid image channel index!")
+ # image_channel = self._image_channels[image_channel_index]
+ self._mapping[model_input_number] = image_channel_index
+
+ def get_mapping_as_list(self) -> List[int]:
+ """ Get the mapping of model input channels to image channels, but as a list (e.g. to store it in QGis configuration)
+
+ Returns
+ -------
+ List[int]
+ """
+ mapping_list = []
+ for i in range(self._number_of_model_inputs):
+ if i in self._mapping:
+ mapping_list.append(self._mapping[i])
+ else:
+ mapping_list.append(-1)
+ return mapping_list
+
+ def load_mapping_from_list(self, mapping_list: List[int]):
+ """
+ Load self._mapping from a plain list of channels (which is saved in config)
+ """
+ for i in range(min(self._number_of_model_inputs), len(mapping_list)):
+ proposed_channel = mapping_list[i]
+ if proposed_channel == -1 or proposed_channel >= self._number_of_model_inputs:
+ continue
+
+ self._mapping[i] = proposed_channel
diff --git a/zipdeepness/common/config_entry_key.py b/zipdeepness/common/config_entry_key.py
new file mode 100644
index 0000000000000000000000000000000000000000..546aa3112a19be3cfa4ec9cb945a11b79699f411
--- /dev/null
+++ b/zipdeepness/common/config_entry_key.py
@@ -0,0 +1,95 @@
+"""
+This file contains utilities to write and read configuration parameters to the QGis Project configuration
+"""
+
+import enum
+
+from qgis.core import QgsProject
+
+from deepness.common.defines import PLUGIN_NAME
+
+
+class ConfigEntryKey(enum.Enum):
+ """
+ Entries to be stored in Project Configuration.
+ Second element of enum value (in tuple) is the default value for this field
+ """
+
+ MODEL_FILE_PATH = enum.auto(), '' # Path to the model file
+ INPUT_LAYER_ID = enum.auto(), ''
+ PROCESSED_AREA_TYPE = enum.auto(), '' # string of ProcessedAreaType, e.g. "ProcessedAreaType.VISIBLE_PART.value"
+ MODEL_TYPE = enum.auto(), '' # string of ModelType enum, e.g. "ModelType.SEGMENTATION.value"
+ PREPROCESSING_RESOLUTION = enum.auto(), 3.0
+ MODEL_BATCH_SIZE = enum.auto(), 1
+ PROCESS_LOCAL_CACHE = enum.auto(), False
+ PREPROCESSING_TILES_OVERLAP = enum.auto(), 15
+
+ SEGMENTATION_PROBABILITY_THRESHOLD_ENABLED = enum.auto(), True
+ SEGMENTATION_PROBABILITY_THRESHOLD_VALUE = enum.auto(), 0.5
+ SEGMENTATION_REMOVE_SMALL_SEGMENT_ENABLED = enum.auto(), True
+ SEGMENTATION_REMOVE_SMALL_SEGMENT_SIZE = enum.auto(), 9
+
+ REGRESSION_OUTPUT_SCALING = enum.auto(), 1.0
+
+ DETECTION_CONFIDENCE = enum.auto(), 0.5
+ DETECTION_IOU = enum.auto(), 0.5
+ DETECTOR_TYPE = enum.auto(), 'YOLO_v5_v7_DEFAULT'
+
+ DATA_EXPORT_DIR = enum.auto(), ''
+ DATA_EXPORT_TILES_ENABLED = enum.auto(), True
+ DATA_EXPORT_SEGMENTATION_MASK_ENABLED = enum.auto(), False
+ DATA_EXPORT_SEGMENTATION_MASK_ID = enum.auto(), ''
+
+ INPUT_CHANNELS_MAPPING__ADVANCED_MODE = enum.auto, False
+ INPUT_CHANNELS_MAPPING__MAPPING_LIST_STR = enum.auto, []
+
+ def get(self):
+ """
+ Get the value store in config (or a default one) for the specified field
+ """
+ read_function = None
+
+ # check the default value to determine the entry type
+ default_value = self.value[1] # second element in the 'value' tuple
+ if isinstance(default_value, int):
+ read_function = QgsProject.instance().readNumEntry
+ elif isinstance(default_value, float):
+ read_function = QgsProject.instance().readDoubleEntry
+ elif isinstance(default_value, bool):
+ read_function = QgsProject.instance().readBoolEntry
+ elif isinstance(default_value, str):
+ read_function = QgsProject.instance().readEntry
+ elif isinstance(default_value, str):
+ read_function = QgsProject.instance().readListEntry
+ else:
+ raise Exception("Unsupported entry type!")
+
+ value, _ = read_function(PLUGIN_NAME, self.name, default_value)
+ return value
+
+ def set(self, value):
+ """ Set the value store in config, for the specified field
+
+ Parameters
+ ----------
+ value :
+ Value to set in the configuration
+ """
+ write_function = None
+
+ # check the default value to determine the entry type
+ default_value = self.value[1] # second element in the 'value' tuple
+ if isinstance(default_value, int):
+ write_function = QgsProject.instance().writeEntry
+ elif isinstance(default_value, float):
+ write_function = QgsProject.instance().writeEntryDouble
+ elif isinstance(default_value, bool):
+ write_function = QgsProject.instance().writeEntryBool
+ elif isinstance(default_value, str):
+ write_function = QgsProject.instance().writeEntry
+ elif isinstance(default_value, list):
+ write_function = QgsProject.instance().writeEntry
+ else:
+ raise Exception("Unsupported entry type!")
+
+ write_function(PLUGIN_NAME, self.name, value)
diff --git a/zipdeepness/common/defines.py b/zipdeepness/common/defines.py
new file mode 100644
index 0000000000000000000000000000000000000000..c78854ddcbafaf232be4c1e19060785801f5cbfd
--- /dev/null
+++ b/zipdeepness/common/defines.py
@@ -0,0 +1,12 @@
+"""
+This file contain common definitions used in the project
+"""
+
+import os
+
+PLUGIN_NAME = 'Deepness'
+LOG_TAB_NAME = PLUGIN_NAME
+
+
+# enable some debugging options (e.g. printing exceptions) - set in terminal before running qgis
+IS_DEBUG = os.getenv("IS_DEBUG", 'False').lower() in ('true', '1', 't')
diff --git a/zipdeepness/common/errors.py b/zipdeepness/common/errors.py
new file mode 100644
index 0000000000000000000000000000000000000000..e56b11747e0e43266fd72a15ad8c1149105a63a3
--- /dev/null
+++ b/zipdeepness/common/errors.py
@@ -0,0 +1,10 @@
+"""
+This file contains common exceptions used in the project
+"""
+
+
+class OperationFailedException(Exception):
+ """
+ Base class for a failed operation, in order to have a good error message to show for the user
+ """
+ pass
diff --git a/zipdeepness/common/lazy_package_loader.py b/zipdeepness/common/lazy_package_loader.py
new file mode 100644
index 0000000000000000000000000000000000000000..51365ae32c044dcf0bdb8e471d19eabc6f7520d4
--- /dev/null
+++ b/zipdeepness/common/lazy_package_loader.py
@@ -0,0 +1,24 @@
+"""
+This file contains utility to lazy import packages
+"""
+
+import importlib
+
+
+class LazyPackageLoader:
+ """ Allows to wrap python package into a lazy version, so that the package will be loaded once it is actually used
+
+ Usage:
+ cv2 = LazyPackageLoader('cv2') # This will not import cv2 yet
+ ...
+ cv2.waitKey(3) # here will be the actual import
+ """
+
+ def __init__(self, package_name):
+ self._package_name = package_name
+ self._package = None
+
+ def __getattr__(self, name):
+ if self._package is None:
+ self._package = importlib.import_module(self._package_name)
+ return getattr(self._package, name)
diff --git a/zipdeepness/common/misc.py b/zipdeepness/common/misc.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6078449c4f0c41ebbeaa4068fefc28a6f891850
--- /dev/null
+++ b/zipdeepness/common/misc.py
@@ -0,0 +1,9 @@
+"""
+This file contains miscellaneous stuff used in the project
+"""
+
+import os
+import tempfile
+
+_TMP_DIR = tempfile.TemporaryDirectory()
+TMP_DIR_PATH = os.path.join(_TMP_DIR.name, 'qgis')
diff --git a/zipdeepness/common/processing_overlap.py b/zipdeepness/common/processing_overlap.py
new file mode 100644
index 0000000000000000000000000000000000000000..a7c50f6403801c48e5f4589c8811a200677ceb28
--- /dev/null
+++ b/zipdeepness/common/processing_overlap.py
@@ -0,0 +1,37 @@
+import enum
+from typing import Dict, List
+
+
+class ProcessingOverlapOptions(enum.Enum):
+ OVERLAP_IN_PIXELS = 'Overlap in pixels'
+ OVERLAP_IN_PERCENT = 'Overlap in percent'
+
+
+class ProcessingOverlap:
+ """ Represents overlap between tiles during processing
+ """
+ def __init__(self, selected_option: ProcessingOverlapOptions, percentage: float = None, overlap_px: int = None):
+ self.selected_option = selected_option
+
+ if selected_option == ProcessingOverlapOptions.OVERLAP_IN_PERCENT and percentage is None:
+ raise Exception(f"Percentage must be specified when using {ProcessingOverlapOptions.OVERLAP_IN_PERCENT}")
+ if selected_option == ProcessingOverlapOptions.OVERLAP_IN_PIXELS and overlap_px is None:
+ raise Exception(f"Overlap in pixels must be specified when using {ProcessingOverlapOptions.OVERLAP_IN_PIXELS}")
+
+ if selected_option == ProcessingOverlapOptions.OVERLAP_IN_PERCENT:
+ self._percentage = percentage
+ elif selected_option == ProcessingOverlapOptions.OVERLAP_IN_PIXELS:
+ self._overlap_px = overlap_px
+ else:
+ raise Exception(f"Unknown option: {selected_option}")
+
+ def get_overlap_px(self, tile_size_px: int) -> int:
+ """ Returns the overlap in pixels
+
+ :param tile_size_px: Tile size in pixels
+ :return: Returns the overlap in pixels
+ """
+ if self.selected_option == ProcessingOverlapOptions.OVERLAP_IN_PIXELS:
+ return self._overlap_px
+ else:
+ return int(tile_size_px * self._percentage / 100 * 2) // 2 # TODO: check if this is correct
diff --git a/zipdeepness/common/processing_parameters/__init__.py b/zipdeepness/common/processing_parameters/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/zipdeepness/common/processing_parameters/detection_parameters.py b/zipdeepness/common/processing_parameters/detection_parameters.py
new file mode 100644
index 0000000000000000000000000000000000000000..c96232c607fe30bf291da238449f01122642e907
--- /dev/null
+++ b/zipdeepness/common/processing_parameters/detection_parameters.py
@@ -0,0 +1,70 @@
+import enum
+from dataclasses import dataclass
+
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters
+from deepness.processing.models.model_base import ModelBase
+
+
+@dataclass
+class DetectorTypeParameters:
+ """
+ Defines some model-specific parameters for each model 'type' (e.g. default YOLOv7 has different model output shape than the one trained with Ultralytics' YOLO)
+ """
+ has_inverted_output_shape: bool = False # whether the output shape of the model is inverted (and we need to apply np.transpose(model_output, (1, 0)))
+ skipped_objectness_probability: bool = False # whether the model output has has no 'objectness' probability, and only probability for each class
+ ignore_objectness_probability: bool = False # if the model output has the 'objectness' probability, we can still ignore it (it is needeed sometimes, when the probability was always 1...). The behavior should be the same as with `skipped_objectness_probability` (of course one model output needs be skipped)
+
+
+class DetectorType(enum.Enum):
+ """ Type of the detector model """
+
+ YOLO_v5_v7_DEFAULT = 'YOLO_v5_or_v7_default'
+ YOLO_v6 = 'YOLO_v6'
+ YOLO_v9 = 'YOLO_v9'
+ YOLO_ULTRALYTICS = 'YOLO_Ultralytics'
+ YOLO_ULTRALYTICS_SEGMENTATION = 'YOLO_Ultralytics_segmentation'
+ YOLO_ULTRALYTICS_OBB = 'YOLO_Ultralytics_obb'
+
+ def get_parameters(self):
+ if self == DetectorType.YOLO_v5_v7_DEFAULT:
+ return DetectorTypeParameters() # all default
+ elif self == DetectorType.YOLO_v6:
+ return DetectorTypeParameters(
+ ignore_objectness_probability=True,
+ )
+ elif self == DetectorType.YOLO_v9:
+ return DetectorTypeParameters(
+ has_inverted_output_shape=True,
+ skipped_objectness_probability=True,
+ )
+ elif self == DetectorType.YOLO_ULTRALYTICS or self == DetectorType.YOLO_ULTRALYTICS_SEGMENTATION or self == DetectorType.YOLO_ULTRALYTICS_OBB:
+ return DetectorTypeParameters(
+ has_inverted_output_shape=True,
+ skipped_objectness_probability=True,
+ )
+ else:
+ raise ValueError(f'Unknown detector type: {self}')
+
+ def get_formatted_description(self):
+ txt = ''
+ txt += ' ' * 10 + f'Inverted output shape: {self.get_parameters().has_inverted_output_shape}\n'
+ txt += ' ' * 10 + f'Skipped objectness : {self.get_parameters().skipped_objectness_probability}\n'
+ txt += ' ' * 10 + f'Ignore objectness: {self.get_parameters().ignore_objectness_probability}\n'
+ return txt
+
+ def get_all_display_values():
+ return [x.value for x in DetectorType]
+
+
+@dataclass
+class DetectionParameters(MapProcessingParameters):
+ """
+ Parameters for Inference of detection model (including pre/post-processing) obtained from UI.
+ """
+
+ model: ModelBase # wrapper of the loaded model
+
+ confidence: float
+ iou_threshold: float
+
+ detector_type: DetectorType = DetectorType.YOLO_v5_v7_DEFAULT # parameters specific for each model type
diff --git a/zipdeepness/common/processing_parameters/map_processing_parameters.py b/zipdeepness/common/processing_parameters/map_processing_parameters.py
new file mode 100644
index 0000000000000000000000000000000000000000..755cf7459f8fd1c317e0576e1a65768f7ccf7437
--- /dev/null
+++ b/zipdeepness/common/processing_parameters/map_processing_parameters.py
@@ -0,0 +1,56 @@
+import enum
+from dataclasses import dataclass
+from typing import Optional
+
+from deepness.common.channels_mapping import ChannelsMapping
+from deepness.common.processing_overlap import ProcessingOverlap
+
+
+class ProcessedAreaType(enum.Enum):
+ VISIBLE_PART = 'Visible part'
+ ENTIRE_LAYER = 'Entire layer'
+ FROM_POLYGONS = 'From polygons'
+
+ @classmethod
+ def get_all_names(cls):
+ return [e.value for e in cls]
+
+@dataclass
+class MapProcessingParameters:
+ """
+ Common parameters for map processing obtained from UI.
+
+ TODO: Add default values here, to later set them in UI at startup
+ """
+
+ resolution_cm_per_px: float # image resolution to used during processing
+ processed_area_type: ProcessedAreaType # whether to perform operation on the entire field or part
+ tile_size_px: int # Tile size for processing (model input size)
+ batch_size: int # Batch size for processing
+ local_cache: bool # Whether to use local cache for tiles (on disk, /tmp directory)
+
+ input_layer_id: str # raster layer to process
+ mask_layer_id: Optional[str] # Processing of masked layer - if processed_area_type is FROM_POLYGONS
+
+ processing_overlap: ProcessingOverlap # aka "stride" - how much to overlap tiles during processing
+
+ input_channels_mapping: ChannelsMapping # describes mapping of image channels to model inputs
+
+ @property
+ def tile_size_m(self):
+ return self.tile_size_px * self.resolution_cm_per_px / 100
+
+ @property
+ def processing_overlap_px(self) -> int:
+ """
+ Always divisible by 2, because overlap is on both sides of the tile
+ """
+ return self.processing_overlap.get_overlap_px(self.tile_size_px)
+
+ @property
+ def resolution_m_per_px(self):
+ return self.resolution_cm_per_px / 100
+
+ @property
+ def processing_stride_px(self):
+ return self.tile_size_px - self.processing_overlap_px
diff --git a/zipdeepness/common/processing_parameters/recognition_parameters.py b/zipdeepness/common/processing_parameters/recognition_parameters.py
new file mode 100644
index 0000000000000000000000000000000000000000..e350b6855a7d3e4c8b960c9aa609d354d7a144d4
--- /dev/null
+++ b/zipdeepness/common/processing_parameters/recognition_parameters.py
@@ -0,0 +1,17 @@
+import enum
+from dataclasses import dataclass
+from typing import Optional
+
+from deepness.common.processing_parameters.map_processing_parameters import \
+ MapProcessingParameters
+from deepness.processing.models.model_base import ModelBase
+
+
+@dataclass
+class RecognitionParameters(MapProcessingParameters):
+ """
+ Parameters for Inference of Recognition model (including pre/post-processing) obtained from UI.
+ """
+
+ query_image_path: str # path to query image
+ model: ModelBase # wrapper of the loaded model
diff --git a/zipdeepness/common/processing_parameters/regression_parameters.py b/zipdeepness/common/processing_parameters/regression_parameters.py
new file mode 100644
index 0000000000000000000000000000000000000000..51e9eacc92ee34982869f7cb37e6b5b973498be4
--- /dev/null
+++ b/zipdeepness/common/processing_parameters/regression_parameters.py
@@ -0,0 +1,14 @@
+from dataclasses import dataclass
+
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters
+from deepness.processing.models.model_base import ModelBase
+
+
+@dataclass
+class RegressionParameters(MapProcessingParameters):
+ """
+ Parameters for Inference of Regression model (including pre/post-processing) obtained from UI.
+ """
+
+ output_scaling: float # scaling factor for the model output (keep 1 if maximum model output value is 1)
+ model: ModelBase # wrapper of the loaded model
diff --git a/zipdeepness/common/processing_parameters/segmentation_parameters.py b/zipdeepness/common/processing_parameters/segmentation_parameters.py
new file mode 100644
index 0000000000000000000000000000000000000000..bc69510d485011a9f44f6c2fb06a0c1c6a3cc459
--- /dev/null
+++ b/zipdeepness/common/processing_parameters/segmentation_parameters.py
@@ -0,0 +1,16 @@
+from dataclasses import dataclass
+
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters
+from deepness.processing.models.model_base import ModelBase
+
+
+@dataclass
+class SegmentationParameters(MapProcessingParameters):
+ """
+ Parameters for Inference of Segmentation model (including pre/post-processing) obtained from UI.
+ """
+
+ postprocessing_dilate_erode_size: int # dilate/erode operation size, once we have a single class map. 0 if inactive. Implementation may use median filer instead of erode/dilate
+ model: ModelBase # wrapper of the loaded model
+
+ pixel_classification__probability_threshold: float # Minimum required class probability for pixel. 0 if disabled
diff --git a/zipdeepness/common/processing_parameters/standardization_parameters.py b/zipdeepness/common/processing_parameters/standardization_parameters.py
new file mode 100644
index 0000000000000000000000000000000000000000..615ca3ed919fb752ae73b5273e31770e8491abb1
--- /dev/null
+++ b/zipdeepness/common/processing_parameters/standardization_parameters.py
@@ -0,0 +1,11 @@
+import numpy as np
+
+
+class StandardizationParameters:
+ def __init__(self, channels_number: int):
+ self.mean = np.array([0.0 for _ in range(channels_number)], dtype=np.float32)
+ self.std = np.array([1.0 for _ in range(channels_number)], dtype=np.float32)
+
+ def set_mean_std(self, mean: np.array, std: np.array):
+ self.mean = np.array(mean, dtype=np.float32)
+ self.std = np.array(std, dtype=np.float32)
diff --git a/zipdeepness/common/processing_parameters/superresolution_parameters.py b/zipdeepness/common/processing_parameters/superresolution_parameters.py
new file mode 100644
index 0000000000000000000000000000000000000000..036b7c631b8feb0ba108185de17e519427b3d669
--- /dev/null
+++ b/zipdeepness/common/processing_parameters/superresolution_parameters.py
@@ -0,0 +1,18 @@
+import enum
+from dataclasses import dataclass
+from typing import Optional
+
+from deepness.common.channels_mapping import ChannelsMapping
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters
+from deepness.processing.models.model_base import ModelBase
+
+
+@dataclass
+class SuperresolutionParameters(MapProcessingParameters):
+ """
+ Parameters for Inference of Super Resolution model (including pre/post-processing) obtained from UI.
+ """
+
+ output_scaling: float # scaling factor for the model output (keep 1 if maximum model output value is 1)
+ model: ModelBase # wrapper of the loaded model
+ scale_factor: int # scale factor for the model output size
diff --git a/zipdeepness/common/processing_parameters/training_data_export_parameters.py b/zipdeepness/common/processing_parameters/training_data_export_parameters.py
new file mode 100644
index 0000000000000000000000000000000000000000..b80f7ac6f1bce87cd1edd523bcfe68a13e18bf1b
--- /dev/null
+++ b/zipdeepness/common/processing_parameters/training_data_export_parameters.py
@@ -0,0 +1,15 @@
+from dataclasses import dataclass
+from typing import Optional
+
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters
+
+
+@dataclass
+class TrainingDataExportParameters(MapProcessingParameters):
+ """
+ Parameters for Exporting Data obtained from UI.
+ """
+
+ export_image_tiles: bool # whether to export input image tiles
+ segmentation_mask_layer_id: Optional[str] # id for mask, to be exported as separate tiles
+ output_directory_path: str # path where the output files will be saved
diff --git a/zipdeepness/common/temp_files_handler.py b/zipdeepness/common/temp_files_handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..3962e23e5a0712c45b10df7d6e163704ca0004fb
--- /dev/null
+++ b/zipdeepness/common/temp_files_handler.py
@@ -0,0 +1,19 @@
+import os.path as path
+import shutil
+from tempfile import mkdtemp
+
+
+class TempFilesHandler:
+ def __init__(self) -> None:
+ self._temp_dir = mkdtemp()
+
+ print(f'Created temp dir: {self._temp_dir} for processing')
+
+ def get_results_img_path(self):
+ return path.join(self._temp_dir, 'results.dat')
+
+ def get_area_mask_img_path(self):
+ return path.join(self._temp_dir, 'area_mask.dat')
+
+ def __del__(self):
+ shutil.rmtree(self._temp_dir)
diff --git a/zipdeepness/deepness.py b/zipdeepness/deepness.py
new file mode 100644
index 0000000000000000000000000000000000000000..9349af480eeae41c149bc1457ec2c5936ab24820
--- /dev/null
+++ b/zipdeepness/deepness.py
@@ -0,0 +1,317 @@
+"""Main plugin file - entry point for the plugin.
+
+Links the UI and the processing.
+
+Skeleton of this file was generate with the QGis plugin to create plugin skeleton - QGIS PluginBuilder
+"""
+
+import logging
+import traceback
+
+from qgis.core import Qgis, QgsApplication, QgsProject, QgsVectorLayer
+from qgis.gui import QgisInterface
+from qgis.PyQt.QtCore import QCoreApplication, Qt
+from qgis.PyQt.QtGui import QIcon
+from qgis.PyQt.QtWidgets import QAction, QMessageBox
+
+from deepness.common.defines import IS_DEBUG, PLUGIN_NAME
+from deepness.common.lazy_package_loader import LazyPackageLoader
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters, ProcessedAreaType
+from deepness.common.processing_parameters.training_data_export_parameters import TrainingDataExportParameters
+from deepness.deepness_dockwidget import DeepnessDockWidget
+from deepness.dialogs.resizable_message_box import ResizableMessageBox
+from deepness.images.get_image_path import get_icon_path
+from deepness.processing.map_processor.map_processing_result import (MapProcessingResult, MapProcessingResultCanceled,
+ MapProcessingResultFailed,
+ MapProcessingResultSuccess)
+from deepness.processing.map_processor.map_processor_training_data_export import MapProcessorTrainingDataExport
+
+cv2 = LazyPackageLoader('cv2')
+
+
+class Deepness:
+ """ QGIS Plugin Implementation - main class of the plugin.
+ Creates the UI classes and processing models and links them together.
+ """
+
+ def __init__(self, iface: QgisInterface):
+ """
+ :param iface: An interface instance that will be passed to this class
+ which provides the hook by which you can manipulate the QGIS
+ application at run time.
+ :type iface: QgsInterface
+ """
+ self.iface = iface
+
+ # Declare instance attributes
+ self.actions = []
+ self.menu = self.tr(u'&Deepness')
+
+ self.toolbar = self.iface.addToolBar(u'Deepness')
+ self.toolbar.setObjectName(u'Deepness')
+
+ self.pluginIsActive = False
+ self.dockwidget = None
+ self._map_processor = None
+
+ # noinspection PyMethodMayBeStatic
+ def tr(self, message):
+ """Get the translation for a string using Qt translation API.
+
+ We implement this ourselves since we do not inherit QObject.
+
+ :param message: String for translation.
+ :type message: str, QString
+
+ :returns: Translated version of message.
+ :rtype: QString
+ """
+ # noinspection PyTypeChecker,PyArgumentList,PyCallByClass
+ return QCoreApplication.translate('Deepness', message)
+
+ def add_action(
+ self,
+ icon_path,
+ text,
+ callback,
+ enabled_flag=True,
+ add_to_menu=True,
+ add_to_toolbar=True,
+ status_tip=None,
+ whats_this=None,
+ parent=None):
+ """Add a toolbar icon to the toolbar.
+
+ :param icon_path: Path to the icon for this action. Can be a resource
+ path (e.g. ':/plugins/foo/bar.png') or a normal file system path.
+ :type icon_path: str
+
+ :param text: Text that should be shown in menu items for this action.
+ :type text: str
+
+ :param callback: Function to be called when the action is triggered.
+ :type callback: function
+
+ :param enabled_flag: A flag indicating if the action should be enabled
+ by default. Defaults to True.
+ :type enabled_flag: bool
+
+ :param add_to_menu: Flag indicating whether the action should also
+ be added to the menu. Defaults to True.
+ :type add_to_menu: bool
+
+ :param add_to_toolbar: Flag indicating whether the action should also
+ be added to the toolbar. Defaults to True.
+ :type add_to_toolbar: bool
+
+ :param status_tip: Optional text to show in a popup when mouse pointer
+ hovers over the action.
+ :type status_tip: str
+
+ :param parent: Parent widget for the new action. Defaults None.
+ :type parent: QWidget
+
+ :param whats_this: Optional text to show in the status bar when the
+ mouse pointer hovers over the action.
+
+ :returns: The action that was created. Note that the action is also
+ added to self.actions list.
+ :rtype: QAction
+ """
+
+ icon = QIcon(icon_path)
+ action = QAction(icon, text, parent)
+ action.triggered.connect(callback)
+ action.setEnabled(enabled_flag)
+
+ if status_tip is not None:
+ action.setStatusTip(status_tip)
+
+ if whats_this is not None:
+ action.setWhatsThis(whats_this)
+
+ if add_to_toolbar:
+ self.toolbar.addAction(action)
+
+ if add_to_menu:
+ self.iface.addPluginToMenu(
+ self.menu,
+ action)
+
+ self.actions.append(action)
+
+ return action
+
+ def initGui(self):
+ """Create the menu entries and toolbar icons inside the QGIS GUI."""
+
+ icon_path = get_icon_path()
+ self.add_action(
+ icon_path,
+ text=self.tr(u'Deepness'),
+ callback=self.run,
+ parent=self.iface.mainWindow())
+
+ if IS_DEBUG:
+ self.run()
+
+ def onClosePlugin(self):
+ """Cleanup necessary items here when plugin dockwidget is closed"""
+
+ # disconnects
+ self.dockwidget.closingPlugin.disconnect(self.onClosePlugin)
+
+ # remove this statement if dockwidget is to remain
+ # for reuse if plugin is reopened
+ # Commented next statement since it causes QGIS crashe
+ # when closing the docked window:
+ # self.dockwidget = None
+
+ self.pluginIsActive = False
+
+ def unload(self):
+ """Removes the plugin menu item and icon from QGIS GUI."""
+
+ for action in self.actions:
+ self.iface.removePluginMenu(
+ self.tr(u'&Deepness'),
+ action)
+ self.iface.removeToolBarIcon(action)
+ # remove the toolbar
+ del self.toolbar
+
+ def _layers_changed(self, _):
+ pass
+
+ def run(self):
+ """Run method that loads and starts the plugin"""
+
+ if not self.pluginIsActive:
+ self.pluginIsActive = True
+
+ # dockwidget may not exist if:
+ # first run of plugin
+ # removed on close (see self.onClosePlugin method)
+ if self.dockwidget is None:
+ # Create the dockwidget (after translation) and keep reference
+ self.dockwidget = DeepnessDockWidget(self.iface)
+ self._layers_changed(None)
+ QgsProject.instance().layersAdded.connect(self._layers_changed)
+ QgsProject.instance().layersRemoved.connect(self._layers_changed)
+
+ # connect to provide cleanup on closing of dockwidget
+ self.dockwidget.closingPlugin.connect(self.onClosePlugin)
+ self.dockwidget.run_model_inference_signal.connect(self._run_model_inference)
+ self.dockwidget.run_training_data_export_signal.connect(self._run_training_data_export)
+
+ self.iface.addDockWidget(Qt.RightDockWidgetArea, self.dockwidget)
+ self.dockwidget.show()
+
+ def _are_map_processing_parameters_are_correct(self, params: MapProcessingParameters):
+ if self._map_processor and self._map_processor.is_busy():
+ msg = "Error! Processing already in progress! Please wait or cancel previous task."
+ self.iface.messageBar().pushMessage(PLUGIN_NAME, msg, level=Qgis.Critical, duration=7)
+ return False
+
+ rlayer = QgsProject.instance().mapLayers()[params.input_layer_id]
+ if rlayer is None:
+ msg = "Error! Please select the layer to process first!"
+ self.iface.messageBar().pushMessage(PLUGIN_NAME, msg, level=Qgis.Critical, duration=7)
+ return False
+
+ if isinstance(rlayer, QgsVectorLayer):
+ msg = "Error! Please select a raster layer (vector layer selected)"
+ self.iface.messageBar().pushMessage(PLUGIN_NAME, msg, level=Qgis.Critical, duration=7)
+ return False
+
+ return True
+
+ def _display_processing_started_info(self):
+ msg = "Processing in progress... Cool! It's tea time!"
+ self.iface.messageBar().pushMessage(PLUGIN_NAME, msg, level=Qgis.Info, duration=2)
+
+ def _run_training_data_export(self, training_data_export_parameters: TrainingDataExportParameters):
+ if not self._are_map_processing_parameters_are_correct(training_data_export_parameters):
+ return
+
+ vlayer = None
+
+ rlayer = QgsProject.instance().mapLayers()[training_data_export_parameters.input_layer_id]
+ if training_data_export_parameters.processed_area_type == ProcessedAreaType.FROM_POLYGONS:
+ vlayer = QgsProject.instance().mapLayers()[training_data_export_parameters.mask_layer_id]
+
+ self._map_processor = MapProcessorTrainingDataExport(
+ rlayer=rlayer,
+ vlayer_mask=vlayer, # layer with masks
+ map_canvas=self.iface.mapCanvas(),
+ params=training_data_export_parameters)
+ self._map_processor.finished_signal.connect(self._map_processor_finished)
+ self._map_processor.show_img_signal.connect(self._show_img)
+ QgsApplication.taskManager().addTask(self._map_processor)
+ self._display_processing_started_info()
+
+ def _run_model_inference(self, params: MapProcessingParameters):
+ from deepness.processing.models.model_types import ModelDefinition # import here to avoid pulling external dependencies to early
+
+ if not self._are_map_processing_parameters_are_correct(params):
+ return
+
+ vlayer = None
+
+ rlayer = QgsProject.instance().mapLayers()[params.input_layer_id]
+ if params.processed_area_type == ProcessedAreaType.FROM_POLYGONS:
+ vlayer = QgsProject.instance().mapLayers()[params.mask_layer_id]
+
+ model_definition = ModelDefinition.get_definition_for_params(params)
+ map_processor_class = model_definition.map_processor_class
+
+ self._map_processor = map_processor_class(
+ rlayer=rlayer,
+ vlayer_mask=vlayer,
+ map_canvas=self.iface.mapCanvas(),
+ params=params)
+ self._map_processor.finished_signal.connect(self._map_processor_finished)
+ self._map_processor.show_img_signal.connect(self._show_img)
+ QgsApplication.taskManager().addTask(self._map_processor)
+ self._display_processing_started_info()
+
+ @staticmethod
+ def _show_img(img_rgb, window_name: str):
+ """ Helper function to show an image while developing and debugging the plugin """
+ # We are importing it here, because it is debug tool,
+ # and we don't want to have it in the main scope from the project startup
+ img_bgr = img_rgb[..., ::-1]
+ cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
+ cv2.resizeWindow(window_name, 800, 800)
+ cv2.imshow(window_name, img_bgr)
+ cv2.waitKey(1)
+
+ def _map_processor_finished(self, result: MapProcessingResult):
+ """ Slot for finished processing of the ortophoto """
+ if isinstance(result, MapProcessingResultFailed):
+ msg = f'Error! Processing error: "{result.message}"!'
+ self.iface.messageBar().pushMessage(PLUGIN_NAME, msg, level=Qgis.Critical, duration=14)
+ if result.exception is not None:
+ logging.error(msg)
+ trace = '\n'.join(traceback.format_tb(result.exception.__traceback__)[-1:])
+ msg = f'{msg}\n\n\n' \
+ f'Details: ' \
+ f'{str(result.exception.__class__.__name__)} - {result.exception}\n' \
+ f'Last Traceback: \n' \
+ f'{trace}'
+ QMessageBox.critical(self.dockwidget, "Unhandled exception", msg)
+ elif isinstance(result, MapProcessingResultCanceled):
+ msg = f'Info! Processing canceled by user!'
+ self.iface.messageBar().pushMessage(PLUGIN_NAME, msg, level=Qgis.Info, duration=7)
+ elif isinstance(result, MapProcessingResultSuccess):
+ msg = 'Processing finished!'
+ self.iface.messageBar().pushMessage(PLUGIN_NAME, msg, level=Qgis.Success, duration=3)
+ message_to_show = result.message
+
+ msgBox = ResizableMessageBox(self.dockwidget)
+ msgBox.setWindowTitle("Processing Result")
+ msgBox.setText(message_to_show)
+ msgBox.setStyleSheet("QLabel{min-width:800 px; font-size: 24px;} QPushButton{ width:250px; font-size: 18px; }")
+ msgBox.exec()
+
+ self._map_processor = None
diff --git a/zipdeepness/deepness_dockwidget.py b/zipdeepness/deepness_dockwidget.py
new file mode 100644
index 0000000000000000000000000000000000000000..24d1409e670d0bebb3b661e6d0b9572cbc6e3d25
--- /dev/null
+++ b/zipdeepness/deepness_dockwidget.py
@@ -0,0 +1,603 @@
+"""
+This file contain the main widget of the plugin
+"""
+
+import logging
+import os
+from typing import Optional
+
+from qgis.core import Qgis, QgsMapLayerProxyModel, QgsProject
+from qgis.PyQt import QtWidgets, uic
+from qgis.PyQt.QtCore import pyqtSignal
+from qgis.PyQt.QtWidgets import QComboBox, QFileDialog, QMessageBox
+
+from deepness.common.config_entry_key import ConfigEntryKey
+from deepness.common.defines import IS_DEBUG, PLUGIN_NAME
+from deepness.common.errors import OperationFailedException
+from deepness.common.lazy_package_loader import LazyPackageLoader
+from deepness.common.processing_overlap import ProcessingOverlap, ProcessingOverlapOptions
+from deepness.common.processing_parameters.detection_parameters import DetectionParameters, DetectorType
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters, ProcessedAreaType
+from deepness.common.processing_parameters.recognition_parameters import RecognitionParameters
+from deepness.common.processing_parameters.regression_parameters import RegressionParameters
+from deepness.common.processing_parameters.segmentation_parameters import SegmentationParameters
+from deepness.common.processing_parameters.superresolution_parameters import SuperresolutionParameters
+from deepness.common.processing_parameters.training_data_export_parameters import TrainingDataExportParameters
+from deepness.processing.models.model_base import ModelBase
+from deepness.widgets.input_channels_mapping.input_channels_mapping_widget import InputChannelsMappingWidget
+from deepness.widgets.training_data_export_widget.training_data_export_widget import TrainingDataExportWidget
+
+FORM_CLASS, _ = uic.loadUiType(os.path.join(
+ os.path.dirname(__file__), 'deepness_dockwidget.ui'))
+
+
+class DeepnessDockWidget(QtWidgets.QDockWidget, FORM_CLASS):
+ """
+ Main widget of the plugin.
+ 'Dock' means it is a 'dcoked' widget, embedded in the QGis application window.
+
+ The UI design is defined in the `deepness_dockwidget.ui` fiel - recommended to be open in QtDesigner.
+ Note: Default values for ui edits are based on 'ConfigEntryKey' default value, not taken from the UI form.
+ """
+
+ closingPlugin = pyqtSignal()
+ run_model_inference_signal = pyqtSignal(MapProcessingParameters) # run Segmentation or Detection
+ run_training_data_export_signal = pyqtSignal(TrainingDataExportParameters)
+
+ def __init__(self, iface, parent=None):
+ super(DeepnessDockWidget, self).__init__(parent)
+ self.iface = iface
+ self._model = None # type: Optional[ModelBase]
+ self.setupUi(self)
+
+ self._input_channels_mapping_widget = InputChannelsMappingWidget(self) # mapping of model and input ortophoto channels
+ self._training_data_export_widget = TrainingDataExportWidget(self) # widget with UI for data export tool
+
+ self._create_connections()
+ self._setup_misc_ui()
+ self._load_ui_from_config()
+
+ def _show_debug_warning(self):
+ """ Show label with warning if we are running debug mode """
+ self.label_debugModeWarning.setVisible(IS_DEBUG)
+
+ def _load_ui_from_config(self):
+ """ Load the UI values from the project configuration
+ """
+ layers = QgsProject.instance().mapLayers()
+
+ try:
+ input_layer_id = ConfigEntryKey.INPUT_LAYER_ID.get()
+ if input_layer_id and input_layer_id in layers:
+ self.mMapLayerComboBox_inputLayer.setLayer(layers[input_layer_id])
+
+ processed_area_type_txt = ConfigEntryKey.PROCESSED_AREA_TYPE.get()
+ self.comboBox_processedAreaSelection.setCurrentText(processed_area_type_txt)
+
+ model_type_txt = ConfigEntryKey.MODEL_TYPE.get()
+ self.comboBox_modelType.setCurrentText(model_type_txt)
+
+ self._input_channels_mapping_widget.load_ui_from_config()
+ self._training_data_export_widget.load_ui_from_config()
+
+ # NOTE: load the model after setting the model_type above
+ model_file_path = ConfigEntryKey.MODEL_FILE_PATH.get()
+ if model_file_path:
+ self.lineEdit_modelPath.setText(model_file_path)
+ self._load_model_and_display_info(abort_if_no_file_path=True) # to prepare other ui components
+
+ # needs to be loaded after the model is set up
+ self.doubleSpinBox_resolution_cm_px.setValue(ConfigEntryKey.PREPROCESSING_RESOLUTION.get())
+ self.spinBox_batchSize.setValue(ConfigEntryKey.MODEL_BATCH_SIZE.get())
+ self.checkBox_local_cache.setChecked(ConfigEntryKey.PROCESS_LOCAL_CACHE.get())
+ self.spinBox_processingTileOverlapPercentage.setValue(ConfigEntryKey.PREPROCESSING_TILES_OVERLAP.get())
+
+ self.doubleSpinBox_probabilityThreshold.setValue(
+ ConfigEntryKey.SEGMENTATION_PROBABILITY_THRESHOLD_VALUE.get())
+ self.checkBox_pixelClassEnableThreshold.setChecked(
+ ConfigEntryKey.SEGMENTATION_PROBABILITY_THRESHOLD_ENABLED.get())
+ self._set_probability_threshold_enabled()
+ self.spinBox_dilateErodeSize.setValue(
+ ConfigEntryKey.SEGMENTATION_REMOVE_SMALL_SEGMENT_SIZE.get())
+ self.checkBox_removeSmallAreas.setChecked(
+ ConfigEntryKey.SEGMENTATION_REMOVE_SMALL_SEGMENT_ENABLED.get())
+ self._set_remove_small_segment_enabled()
+
+ self.doubleSpinBox_regressionScaling.setValue(ConfigEntryKey.REGRESSION_OUTPUT_SCALING.get())
+
+ self.doubleSpinBox_confidence.setValue(ConfigEntryKey.DETECTION_CONFIDENCE.get())
+ self.doubleSpinBox_iouScore.setValue(ConfigEntryKey.DETECTION_IOU.get())
+ self.comboBox_detectorType.setCurrentText(ConfigEntryKey.DETECTOR_TYPE.get())
+ except Exception:
+ logging.exception("Failed to load the ui state from config!")
+
+ def _save_ui_to_config(self):
+ """ Save value from the UI forms to the project config
+ """
+ ConfigEntryKey.MODEL_FILE_PATH.set(self.lineEdit_modelPath.text())
+ ConfigEntryKey.INPUT_LAYER_ID.set(self._get_input_layer_id())
+ ConfigEntryKey.MODEL_TYPE.set(self.comboBox_modelType.currentText())
+ ConfigEntryKey.PROCESSED_AREA_TYPE.set(self.comboBox_processedAreaSelection.currentText())
+
+ ConfigEntryKey.PREPROCESSING_RESOLUTION.set(self.doubleSpinBox_resolution_cm_px.value())
+ ConfigEntryKey.MODEL_BATCH_SIZE.set(self.spinBox_batchSize.value())
+ ConfigEntryKey.PROCESS_LOCAL_CACHE.set(self.checkBox_local_cache.isChecked())
+ ConfigEntryKey.PREPROCESSING_TILES_OVERLAP.set(self.spinBox_processingTileOverlapPercentage.value())
+
+ ConfigEntryKey.SEGMENTATION_PROBABILITY_THRESHOLD_ENABLED.set(
+ self.checkBox_pixelClassEnableThreshold.isChecked())
+ ConfigEntryKey.SEGMENTATION_PROBABILITY_THRESHOLD_VALUE.set(self.doubleSpinBox_probabilityThreshold.value())
+ ConfigEntryKey.SEGMENTATION_REMOVE_SMALL_SEGMENT_ENABLED.set(
+ self.checkBox_removeSmallAreas.isChecked())
+ ConfigEntryKey.SEGMENTATION_REMOVE_SMALL_SEGMENT_SIZE.set(self.spinBox_dilateErodeSize.value())
+
+ ConfigEntryKey.REGRESSION_OUTPUT_SCALING.set(self.doubleSpinBox_regressionScaling.value())
+
+ ConfigEntryKey.DETECTION_CONFIDENCE.set(self.doubleSpinBox_confidence.value())
+ ConfigEntryKey.DETECTION_IOU.set(self.doubleSpinBox_iouScore.value())
+ ConfigEntryKey.DETECTOR_TYPE.set(self.comboBox_detectorType.currentText())
+
+ self._input_channels_mapping_widget.save_ui_to_config()
+ self._training_data_export_widget.save_ui_to_config()
+
+ def _rlayer_updated(self):
+ self._input_channels_mapping_widget.set_rlayer(self._get_input_layer())
+
+ def _setup_misc_ui(self):
+ """ Setup some misceleounous ui forms
+ """
+ from deepness.processing.models.model_types import \
+ ModelDefinition # import here to avoid pulling external dependencies to early
+
+ self._show_debug_warning()
+ combobox = self.comboBox_processedAreaSelection
+ for name in ProcessedAreaType.get_all_names():
+ combobox.addItem(name)
+
+ self.verticalLayout_inputChannelsMapping.addWidget(self._input_channels_mapping_widget)
+ self.verticalLayout_trainingDataExport.addWidget(self._training_data_export_widget)
+
+ self.mMapLayerComboBox_inputLayer.setFilters(QgsMapLayerProxyModel.RasterLayer)
+ self.mMapLayerComboBox_areaMaskLayer.setFilters(QgsMapLayerProxyModel.VectorLayer)
+
+ self.mGroupBox_8.setCollapsed(True) # collapse the group by default
+ self._set_processed_area_mask_options()
+ self._set_processing_overlap_enabled()
+
+ for model_definition in ModelDefinition.get_model_definitions():
+ self.comboBox_modelType.addItem(model_definition.model_type.value)
+
+ for detector_type in DetectorType.get_all_display_values():
+ self.comboBox_detectorType.addItem(detector_type)
+ self._detector_type_changed()
+
+ self._rlayer_updated() # to force refresh the dependant ui elements
+
+ def _set_processed_area_mask_options(self):
+ show_mask_combobox = (self.get_selected_processed_area_type() == ProcessedAreaType.FROM_POLYGONS)
+ self.mMapLayerComboBox_areaMaskLayer.setVisible(show_mask_combobox)
+ self.label_areaMaskLayer.setVisible(show_mask_combobox)
+
+ def get_selected_processed_area_type(self) -> ProcessedAreaType:
+ combobox = self.comboBox_processedAreaSelection # type: QComboBox
+ txt = combobox.currentText()
+ return ProcessedAreaType(txt)
+
+ def _create_connections(self):
+ self.pushButton_runInference.clicked.connect(self._run_inference)
+ self.pushButton_runTrainingDataExport.clicked.connect(self._run_training_data_export)
+ self.pushButton_browseQueryImagePath.clicked.connect(self._browse_query_image_path)
+ self.pushButton_browseModelPath.clicked.connect(self._browse_model_path)
+ self.comboBox_processedAreaSelection.currentIndexChanged.connect(self._set_processed_area_mask_options)
+ self.comboBox_modelType.currentIndexChanged.connect(self._model_type_changed)
+ self.comboBox_detectorType.currentIndexChanged.connect(self._detector_type_changed)
+ self.pushButton_reloadModel.clicked.connect(self._load_model_and_display_info)
+ self.pushButton_loadDefaultModelParameters.clicked.connect(self._load_default_model_parameters)
+ self.mMapLayerComboBox_inputLayer.layerChanged.connect(self._rlayer_updated)
+ self.checkBox_pixelClassEnableThreshold.stateChanged.connect(self._set_probability_threshold_enabled)
+ self.checkBox_removeSmallAreas.stateChanged.connect(self._set_remove_small_segment_enabled)
+ self.radioButton_processingTileOverlapPercentage.toggled.connect(self._set_processing_overlap_enabled)
+ self.radioButton_processingTileOverlapPixels.toggled.connect(self._set_processing_overlap_enabled)
+
+ def _model_type_changed(self):
+ from deepness.processing.models.model_types import \
+ ModelType # import here to avoid pulling external dependencies to early
+
+ model_type = ModelType(self.comboBox_modelType.currentText())
+
+ segmentation_enabled = False
+ detection_enabled = False
+ regression_enabled = False
+ superresolution_enabled = False
+ recognition_enabled = False
+
+ if model_type == ModelType.SEGMENTATION:
+ segmentation_enabled = True
+ elif model_type == ModelType.DETECTION:
+ detection_enabled = True
+ elif model_type == ModelType.REGRESSION:
+ regression_enabled = True
+ elif model_type == ModelType.SUPERRESOLUTION:
+ superresolution_enabled = True
+ elif model_type == ModelType.RECOGNITION:
+ recognition_enabled = True
+ else:
+ raise Exception(f"Unsupported model type ({model_type})!")
+
+ self.mGroupBox_segmentationParameters.setVisible(segmentation_enabled)
+ self.mGroupBox_detectionParameters.setVisible(detection_enabled)
+ self.mGroupBox_regressionParameters.setVisible(regression_enabled)
+ self.mGroupBox_superresolutionParameters.setVisible(superresolution_enabled)
+ self.mGroupBox_recognitionParameters.setVisible(recognition_enabled)
+
+ def _detector_type_changed(self):
+ detector_type = DetectorType(self.comboBox_detectorType.currentText())
+ self.label_detectorTypeDescription.setText(detector_type.get_formatted_description())
+
+ def _set_processing_overlap_enabled(self):
+ overlap_percentage_enabled = self.radioButton_processingTileOverlapPercentage.isChecked()
+ self.spinBox_processingTileOverlapPercentage.setEnabled(overlap_percentage_enabled)
+
+ overlap_pixels_enabled = self.radioButton_processingTileOverlapPixels.isChecked()
+ self.spinBox_processingTileOverlapPixels.setEnabled(overlap_pixels_enabled)
+
+ def _set_probability_threshold_enabled(self):
+ self.doubleSpinBox_probabilityThreshold.setEnabled(self.checkBox_pixelClassEnableThreshold.isChecked())
+
+ def _set_remove_small_segment_enabled(self):
+ self.spinBox_dilateErodeSize.setEnabled(self.checkBox_removeSmallAreas.isChecked())
+
+ def _browse_model_path(self):
+ file_path, _ = QFileDialog.getOpenFileName(
+ self,
+ 'Select Model ONNX file...',
+ os.path.expanduser('~'),
+ 'All files (*.*);; ONNX files (*.onnx)')
+ if file_path:
+ self.lineEdit_modelPath.setText(file_path)
+ self._load_model_and_display_info()
+
+ def _browse_query_image_path(self):
+ file_path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Select image file...",
+ os.path.expanduser("~"),
+ "All files (*.*)",
+ )
+ if file_path:
+ self.lineEdit_recognitionPath.setText(file_path)
+
+ def _load_default_model_parameters(self):
+ """
+ Load the default parameters from model metadata
+ """
+ value = self._model.get_metadata_resolution()
+ if value is not None:
+ self.doubleSpinBox_resolution_cm_px.setValue(value)
+
+ value = self._model.get_model_batch_size()
+ if value is not None:
+ self.spinBox_batchSize.setValue(value)
+ self.spinBox_batchSize.setEnabled(False)
+ else:
+ self.spinBox_batchSize.setEnabled(True)
+
+ value = self._model.get_metadata_tile_size()
+ if value is not None:
+ self.spinBox_tileSize_px.setValue(value)
+
+ value = self._model.get_metadata_tiles_overlap()
+ if value is not None:
+ self.spinBox_processingTileOverlapPercentage.setValue(value)
+
+ value = self._model.get_metadata_model_type()
+ if value is not None:
+ print(f'{value =}')
+ self.comboBox_modelType.setCurrentText(value)
+
+ value = self._model.get_detector_type()
+ if value is not None:
+ self.comboBox_detectorType.setCurrentText(value)
+
+ value = self._model.get_metadata_segmentation_threshold()
+ if value is not None:
+ self.checkBox_pixelClassEnableThreshold.setChecked(bool(value != 0))
+ self.doubleSpinBox_probabilityThreshold.setValue(value)
+
+ value = self._model.get_metadata_segmentation_small_segment()
+ if value is not None:
+ self.checkBox_removeSmallAreas.setChecked(bool(value != 0))
+ self.spinBox_dilateErodeSize.setValue(value)
+
+ value = self._model.get_metadata_regression_output_scaling()
+ if value is not None:
+ self.doubleSpinBox_regressionScaling.setValue(value)
+
+ value = self._model.get_metadata_detection_confidence()
+ if value is not None:
+ self.doubleSpinBox_confidence.setValue(value)
+
+ value = self._model.get_metadata_detection_iou_threshold()
+ if value is not None:
+ self.doubleSpinBox_iouScore.setValue(value)
+
+ def _load_model_with_type_from_metadata(self, model_class_from_ui, file_path):
+ """
+ If model has model_type in metadata - use this type to create proper model class.
+ Otherwise model_class_from_ui will be used
+ """
+ from deepness.processing.models.model_types import ( # import here to avoid pulling external dependencies to early
+ ModelDefinition, ModelType)
+
+ model_class = model_class_from_ui
+
+ model_type_str_from_metadata = ModelBase.get_model_type_from_metadata(file_path)
+ if model_type_str_from_metadata is not None:
+ model_type = ModelType(model_type_str_from_metadata)
+ model_class = ModelDefinition.get_definition_for_type(model_type).model_class
+ self.comboBox_modelType.setCurrentText(model_type.value)
+
+ print(f'{model_type_str_from_metadata = }, {model_class = }')
+
+ model = model_class(file_path)
+ return model
+
+ def _load_model_and_display_info(self, abort_if_no_file_path: bool = False):
+ """
+ Tries to load the model and display its message.
+ """
+ import deepness.processing.models.detector as detector_module # import here to avoid pulling external dependencies to early
+ from deepness.processing.models.model_types import \
+ ModelType # import here to avoid pulling external dependencies to early
+
+ file_path = self.lineEdit_modelPath.text()
+
+ if not file_path and abort_if_no_file_path:
+ return
+
+ txt = ''
+
+ try:
+ model_definition = self.get_selected_model_class_definition()
+ model_class = model_definition.model_class
+ self._model = self._load_model_with_type_from_metadata(
+ model_class_from_ui=model_class,
+ file_path=file_path)
+ self._model.check_loaded_model_outputs()
+ input_0_shape = self._model.get_input_shape()
+ txt += 'Legend: [BATCH_SIZE, CHANNELS, HEIGHT, WIDTH]\n'
+ txt += 'Inputs:\n'
+ txt += f'\t- Input: {input_0_shape}\n'
+ input_size_px = input_0_shape[-1]
+ batch_size = self._model.get_model_batch_size()
+
+ txt += 'Outputs:\n'
+
+ for i, output_shape in enumerate(self._model.get_output_shapes()):
+ txt += f'\t- Output {i}: {output_shape}\n'
+
+ # TODO idk how variable input will be handled
+ self.spinBox_tileSize_px.setValue(input_size_px)
+ self.spinBox_tileSize_px.setEnabled(False)
+
+ if batch_size is not None:
+ self.spinBox_batchSize.setValue(batch_size)
+ self.spinBox_batchSize.setEnabled(False)
+ else:
+ self.spinBox_batchSize.setEnabled(True)
+
+ self._input_channels_mapping_widget.set_model(self._model)
+
+ # super resolution
+ if model_class == ModelType.SUPERRESOLUTION:
+ output_0_shape = self._model.get_output_shape()
+ scale_factor = output_0_shape[-1] / input_size_px
+ self.doubleSpinBox_superresolutionScaleFactor.setValue(int(scale_factor))
+ # Disable output format options for super-resolution models
+ except Exception as e:
+ if IS_DEBUG:
+ raise e
+ txt = "Error! Failed to load the model!\n" \
+ "Model may be not usable."
+ logging.exception(txt)
+ self.spinBox_tileSize_px.setEnabled(True)
+ self.spinBox_batchSize.setEnabled(True)
+ length_limit = 300
+ exception_msg = (str(e)[:length_limit] + '..') if len(str(e)) > length_limit else str(e)
+ msg = txt + f'\n\nException: {exception_msg}'
+ QMessageBox.critical(self, "Error!", msg)
+
+ self.label_modelInfo.setText(txt)
+
+ if isinstance(self._model, detector_module.Detector):
+ detector_type = DetectorType(self.comboBox_detectorType.currentText())
+ self._model.set_model_type_param(detector_type)
+
+ def get_mask_layer_id(self):
+ if not self.get_selected_processed_area_type() == ProcessedAreaType.FROM_POLYGONS:
+ return None
+
+ mask_layer_id = self.mMapLayerComboBox_areaMaskLayer.currentLayer().id()
+ return mask_layer_id
+
+ def _get_input_layer(self):
+ return self.mMapLayerComboBox_inputLayer.currentLayer()
+
+ def _get_input_layer_id(self):
+ layer = self._get_input_layer()
+ if layer:
+ return layer.id()
+ else:
+ return ''
+
+ def _get_overlap_parameter(self):
+ if self.radioButton_processingTileOverlapPercentage.isChecked():
+ return ProcessingOverlap(
+ selected_option=ProcessingOverlapOptions.OVERLAP_IN_PERCENT,
+ percentage=self.spinBox_processingTileOverlapPercentage.value(),
+ )
+ elif self.radioButton_processingTileOverlapPixels.isChecked():
+ return ProcessingOverlap(
+ selected_option=ProcessingOverlapOptions.OVERLAP_IN_PIXELS,
+ overlap_px=self.spinBox_processingTileOverlapPixels.value(),
+ )
+ else:
+ raise Exception('Something goes wrong. No overlap parameter selected!')
+
+ def _get_pixel_classification_threshold(self):
+ if not self.checkBox_pixelClassEnableThreshold.isChecked():
+ return 0
+ return self.doubleSpinBox_probabilityThreshold.value()
+
+ def get_selected_model_class_definition(self): # -> ModelDefinition: # we cannot import it here yet
+ """
+ Get the currently selected model class (in UI)
+ """
+ from deepness.processing.models.model_types import ( # import here to avoid pulling external dependencies to early
+ ModelDefinition, ModelType)
+
+ model_type_txt = self.comboBox_modelType.currentText()
+ model_type = ModelType(model_type_txt)
+ model_definition = ModelDefinition.get_definition_for_type(model_type)
+ return model_definition
+
+ def get_inference_parameters(self) -> MapProcessingParameters:
+ """ Get the parameters for the model interface.
+ The returned type is derived from `MapProcessingParameters` class, depending on the selected model type.
+ """
+ from deepness.processing.models.model_types import \
+ ModelType # import here to avoid pulling external dependencies to early
+
+ map_processing_parameters = self._get_map_processing_parameters()
+
+ if self._model is None:
+ raise OperationFailedException("Please select and load a model first!")
+
+ model_type = self.get_selected_model_class_definition().model_type
+ if model_type == ModelType.SEGMENTATION:
+ params = self.get_segmentation_parameters(map_processing_parameters)
+ elif model_type == ModelType.REGRESSION:
+ params = self.get_regression_parameters(map_processing_parameters)
+ elif model_type == ModelType.SUPERRESOLUTION:
+ params = self.get_superresolution_parameters(map_processing_parameters)
+ elif model_type == ModelType.RECOGNITION:
+ params = self.get_recognition_parameters(map_processing_parameters)
+ elif model_type == ModelType.DETECTION:
+ params = self.get_detection_parameters(map_processing_parameters)
+
+ else:
+ raise Exception(f"Unknown model type '{model_type}'!")
+
+ return params
+
+ def get_segmentation_parameters(self, map_processing_parameters: MapProcessingParameters) -> SegmentationParameters:
+ postprocessing_dilate_erode_size = self.spinBox_dilateErodeSize.value() \
+ if self.checkBox_removeSmallAreas.isChecked() else 0
+
+ params = SegmentationParameters(
+ **map_processing_parameters.__dict__,
+ postprocessing_dilate_erode_size=postprocessing_dilate_erode_size,
+ pixel_classification__probability_threshold=self._get_pixel_classification_threshold(),
+ model=self._model,
+ )
+ return params
+
+ def get_regression_parameters(self, map_processing_parameters: MapProcessingParameters) -> RegressionParameters:
+ params = RegressionParameters(
+ **map_processing_parameters.__dict__,
+ output_scaling=self.doubleSpinBox_regressionScaling.value(),
+ model=self._model,
+ )
+ return params
+
+ def get_superresolution_parameters(self, map_processing_parameters: MapProcessingParameters) -> SuperresolutionParameters:
+ params = SuperresolutionParameters(
+ **map_processing_parameters.__dict__,
+ model=self._model,
+ scale_factor=self.doubleSpinBox_superresolutionScaleFactor.value(),
+ output_scaling=self.doubleSpinBox_superresolutionScaling.value(),
+ )
+ return params
+
+ def get_recognition_parameters(self, map_processing_parameters: MapProcessingParameters) -> RecognitionParameters:
+ params = RecognitionParameters(
+ **map_processing_parameters.__dict__,
+ model=self._model,
+ query_image_path=self.lineEdit_recognitionPath.text(),
+ )
+ return params
+
+ def get_detection_parameters(self, map_processing_parameters: MapProcessingParameters) -> DetectionParameters:
+
+ params = DetectionParameters(
+ **map_processing_parameters.__dict__,
+ confidence=self.doubleSpinBox_confidence.value(),
+ iou_threshold=self.doubleSpinBox_iouScore.value(),
+ model=self._model,
+ detector_type=DetectorType(self.comboBox_detectorType.currentText()),
+ )
+
+ return params
+
+ def _get_map_processing_parameters(self) -> MapProcessingParameters:
+ """
+ Get common parameters for inference and exporting
+ """
+ processed_area_type = self.get_selected_processed_area_type()
+ params = MapProcessingParameters(
+ resolution_cm_per_px=self.doubleSpinBox_resolution_cm_px.value(),
+ tile_size_px=self.spinBox_tileSize_px.value(),
+ batch_size=self.spinBox_batchSize.value(),
+ local_cache=self.checkBox_local_cache.isChecked(),
+ processed_area_type=processed_area_type,
+ mask_layer_id=self.get_mask_layer_id(),
+ input_layer_id=self._get_input_layer_id(),
+ processing_overlap=self._get_overlap_parameter(),
+ input_channels_mapping=self._input_channels_mapping_widget.get_channels_mapping(),
+ )
+ return params
+
+ def _run_inference(self):
+ # check_required_packages_and_install_if_necessary()
+ try:
+ params = self.get_inference_parameters()
+
+ if not params.input_layer_id:
+ raise OperationFailedException("Please select an input layer first!")
+ except OperationFailedException as e:
+ msg = str(e)
+ self.iface.messageBar().pushMessage(PLUGIN_NAME, msg, level=Qgis.Warning, duration=7)
+ logging.exception(msg)
+ QMessageBox.critical(self, "Error!", msg)
+ return
+
+ self._save_ui_to_config()
+ self.run_model_inference_signal.emit(params)
+
+ def _run_training_data_export(self):
+ # check_required_packages_and_install_if_necessary()
+ try:
+ map_processing_parameters = self._get_map_processing_parameters()
+ training_data_export_parameters = self._training_data_export_widget.get_training_data_export_parameters(
+ map_processing_parameters)
+
+ if not map_processing_parameters.input_layer_id:
+ raise OperationFailedException("Please select an input layer first!")
+
+ # Overwrite common parameter - we don't want channels mapping as for the model,
+ # but just to take all channels
+ training_data_export_parameters.input_channels_mapping = \
+ self._input_channels_mapping_widget.get_channels_mapping_for_training_data_export()
+ except OperationFailedException as e:
+ msg = str(e)
+ self.iface.messageBar().pushMessage(PLUGIN_NAME, msg, level=Qgis.Warning)
+ logging.exception(msg)
+ QMessageBox.critical(self, "Error!", msg)
+ return
+
+ self._save_ui_to_config()
+ self.run_training_data_export_signal.emit(training_data_export_parameters)
+
+ def closeEvent(self, event):
+ self.closingPlugin.emit()
+ event.accept()
diff --git a/zipdeepness/deepness_dockwidget.ui b/zipdeepness/deepness_dockwidget.ui
new file mode 100644
index 0000000000000000000000000000000000000000..78c6447444eff0437ada7243227bb0df556909a4
--- /dev/null
+++ b/zipdeepness/deepness_dockwidget.ui
@@ -0,0 +1,893 @@
+
+
+ DeepnessDockWidgetBase
+
+
+
+ 0
+ 0
+ 486
+ 1368
+
+
+
+ Deepness
+
+
+
+ -
+
+
+ true
+
+
+
+
+ 0
+ -268
+ 452
+ 1564
+
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+
+ 9
+ true
+
+
+
+ color:rgb(198, 70, 0)
+
+
+ WARNING: Running plugin in DEBUG mode
+(because env variable IS_DEBUG=true)
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Input data:
+
+
+
-
+
+
+ <html><head/><body><p><span style=" font-weight:600;">Layer which will be processed.</span></p><p>Most probably this is your ortophoto or map source (like satellite image from google earth).<br/>Needs to be a raster layer.</p></body></html>
+
+
+
+ -
+
+
+ <html><head/><body><p>Defines what part of the "Input layer" should be processed.</p><p><br/> - "<span style=" font-style:italic;">Visible Part</span>" allows to process the part currently visible on the map canvas.<br/> - "<span style=" font-style:italic;">Entire Layer</span>" allows to process the entire ortophoto file<br/> - "<span style=" font-style:italic;">From Polygons</span>" allows to select a polygon describing the area to be processed (e.g. if the processed field is a polygon, and we don't want to process outside of it)</p></body></html>
+
+
+
+ -
+
+
+ Input layer:
+
+
+
+ -
+
+
+ Processed area mask:
+
+
+
+ -
+
+
+ Area mask layer:
+
+
+
+ -
+
+
+ <html><head/><body><p>Defines the layer which is being used as a mask for the processing of "Input layer". <br/>Only pixels within this mask layer will be processed.</p><p>Needs to be a vector layer.</p></body></html>
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ ONNX Model
+
+
+
-
+
+
+ true
+
+
+ Path to the model file
+
+
+
+ -
+
+
+ Model file path:
+
+
+
+ -
+
+
+ true
+
+
+ Browse...
+
+
+
+ -
+
+
+ Model type:
+
+
+
+ -
+
+
+ <html><head/><body><p>Type of the model (model class) which you want to use.<br/>You should obtain this information along with the model file.</p><p>Please refer to the plugin documentation for more details.</p></body></html>
+
+
+
+ -
+
+
+ Model info:
+
+
+
+ -
+
+
+
+ 7
+
+
+
+ color: rgb(135, 135, 133);
+
+
+ Model not loaded! Please select its path and click "Load Model" button above first!
+
+
+ true
+
+
+
+ -
+
+
-
+
+
+ Reload the model given in the line edit above
+
+
+ Reload Model
+
+
+
+ -
+
+
+ <html><head/><body><p><span style=" font-weight:600;">Load default model parameters.</span></p><p>ONNX Models can have metadata, which can be parsed and used to set default value for fields in UI, w.g. for tile_size or confidence threshold</p></body></html>
+
+
+ Load default parameters
+
+
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Input channels mapping
+
+
+
-
+
+
+ -
+
+
+
+ true
+
+
+
+ NOTE: This configuration is depending on the input layer and model type. Please make sure to select the "Input layer" and load the model first!
+
+
+ true
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Processing parameters
+
+
+
-
+
+
+ Tiles overlap:
+
+
+
-
+
+
+ [px]
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 0
+
+
+
+ <html><head/><body><p>Defines how much tiles should overlap on their neighbours during processing.</p><p>Especially required for model which introduce distortions on the edges of images, so that it can be removed in postprocessing.</p></body></html>
+
+
+
+
+
+ 15
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 80
+ 0
+
+
+
+ 9999999
+
+
+
+ -
+
+
+ [%]
+
+
+ true
+
+
+
+
+
+
+ -
+
+
+ true
+
+
+ <html><head/><body><p>Size of the images passed to the model.</p><p>Usually needs to be the same as the one used during training.</p></body></html>
+
+
+ 99999
+
+
+ 512
+
+
+
+ -
+
+
+ <html><head/><body><p>Defines the processing resolution of the "Input layer".</p><p><br/></p><p>Determines the resolution of images fed into the model, allowing to scale the input images.</p><p>Should be similar as the resolution used to train the model.</p></body></html>
+
+
+ 2
+
+
+ 0.000000000000000
+
+
+ 999999.000000000000000
+
+
+ 3.000000000000000
+
+
+
+ -
+
+
+ Tile size [px]:
+
+
+
+ -
+
+
+ Resolution [cm/px]:
+
+
+
+ -
+
+
+
+ false
+ true
+
+
+
+ NOTE: These options may be a fixed value for some models
+
+
+ true
+
+
+
+ -
+
+
+ Batch size:
+
+
+
+ -
+
+
+ <html><head/><body><p>The size of the data batch in the model.</p><p>The size depends on the computing resources, in particular the available RAM / GPU memory.</p></body></html>
+
+
+ 1
+
+
+ 9999999
+
+
+
+ -
+
+
+ <html><head/><body><p>If True, local memory caching is performed - this is helpful when large area maps are processed, but is probably slower than processing in RAM.</p><p><br/></p></body></html>
+
+
+ Process using local cache
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Segmentation parameters
+
+
+
-
+
+
+ <html><head/><body><p>Minimum required probability for the class to be considered as belonging to this class.</p></body></html>
+
+
+ 2
+
+
+ 1.000000000000000
+
+
+ 0.050000000000000
+
+
+ 0.500000000000000
+
+
+
+ -
+
+
+ false
+
+
+
+
+
+ Argmax (most probable class only)
+
+
+ true
+
+
+
+ -
+
+
+ Apply class probability threshold:
+
+
+ true
+
+
+
+ -
+
+
+ <html><head/><body><p>Postprocessing option, to remove small areas (small clusters of pixels) belonging to each class, smoothing the predictions.</p><p>The actual size (in meters) of the smoothing can be calculated as "Resolution" * "value of this parameter".<br/>Works as application of dilate and erode operation (twice, in reverse order).<br/>Similar effect to median filter.</p></body></html>
+
+
+ 9
+
+
+
+ -
+
+
+
+ true
+
+
+
+ NOTE: Applicable only if a segmentation model is used
+
+
+
+ -
+
+
+ Remove small segment
+ areas (dilate/erode size) [px]:
+
+
+ true
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Superresolution Parameters
+
+
+
-
+
+
+ Upscaling Factor
+
+
+
+ -
+
+
+ 2
+
+
+
+ -
+
+
+ 100000000000.000000000000000
+
+
+ 255.000000000000000
+
+
+
+ -
+
+
+ Output scaling
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Regression parameters
+
+
+
-
+
+
+ <html><head/><body><p>Scaling factor for model output values.</p><p>Each pixel value will be multiplied by this factor.</p></body></html>
+
+
+ 3
+
+
+ 9999.000000000000000
+
+
+ 1.000000000000000
+
+
+
+ -
+
+
+ Output scaling
+(keep 1.00 if max output value is 1):
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Recognition parameters
+
+
+
-
+
+
+
+ true
+
+
+
+ NOTE: Applicable only if a recognition model is used
+
+
+
+ -
+
+
+ Image to localize path:
+
+
+
+ -
+
+
+ -
+
+
+ Browse
+
+
+
+
+
+
+ -
+
+
+ Detection parameters
+
+
+
-
+
+
+ Confidence:
+
+
+
+ -
+
+
+ Detector type:
+
+
+
+ -
+
+
+ -
+
+
+ IoU threshold:
+
+
+
+ -
+
+
+ <html><head/><body><p>Minimal confidence of the potential detection, to consider it as a detection.</p></body></html>
+
+
+ 2
+
+
+ 1.000000000000000
+
+
+ 0.050000000000000
+
+
+
+ -
+
+
+
+ true
+
+
+
+ NOTE: Applicable only if a detection model is used
+
+
+
+ -
+
+
+
+ 9
+
+
+
+ Here goes a longer description of detector type...
+
+
+
+ -
+
+
+ <html><head/><body><p>Parameter used in Non Maximum Suppression in post processing.</p><p>Defines the threshold of overlap between to neighbouring detections, to consider them as the same object.</p></body></html>
+
+
+ 2
+
+
+ 1.000000000000000
+
+
+ 0.050000000000000
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Training data export
+
+
+ false
+
+
+
-
+
+
+ Note: This group allows to export the data for the training process, with similar data as during inference.
+
+
+ true
+
+
+
+ -
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ <html><head/><body><p>Run the export of the data</p></body></html>
+
+
+ Export training data
+
+
+
+
+
+ -
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+ -
+
+
-
+
+
+ <html><head/><body><p>Run the inference, for the selected above paraeters.</p></body></html>
+
+
+ Run
+
+
+
+
+
+
+
+
+
+
+ QgsCollapsibleGroupBox
+ QGroupBox
+
+ 1
+
+
+ QgsDoubleSpinBox
+ QDoubleSpinBox
+
+
+
+ QgsMapLayerComboBox
+ QComboBox
+
+
+
+ QgsSpinBox
+ QSpinBox
+
+
+
+
+
+
diff --git a/zipdeepness/dialogs/packages_installer/packages_installer_dialog.py b/zipdeepness/dialogs/packages_installer/packages_installer_dialog.py
new file mode 100644
index 0000000000000000000000000000000000000000..1afdbc8023a73e772ed038e2668e559898d3c17c
--- /dev/null
+++ b/zipdeepness/dialogs/packages_installer/packages_installer_dialog.py
@@ -0,0 +1,359 @@
+"""
+This QGIS plugin requires some Python packages to be installed and available.
+This tool allows to install them in a local directory, if they are not installed yet.
+"""
+
+import importlib
+import logging
+import os
+import subprocess
+import sys
+import traceback
+import urllib
+from dataclasses import dataclass
+from pathlib import Path
+from threading import Thread
+from typing import List
+
+from qgis.PyQt import QtCore, uic
+from qgis.PyQt.QtCore import pyqtSignal
+from qgis.PyQt.QtGui import QCloseEvent
+from qgis.PyQt.QtWidgets import QDialog, QMessageBox, QTextBrowser
+
+from deepness.common.defines import PLUGIN_NAME
+
+PYTHON_VERSION = sys.version_info
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+PLUGIN_ROOT_DIR = os.path.realpath(os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..')))
+PACKAGES_INSTALL_DIR = os.path.join(PLUGIN_ROOT_DIR, f'python{PYTHON_VERSION.major}.{PYTHON_VERSION.minor}')
+
+
+FORM_CLASS, _ = uic.loadUiType(os.path.join(
+ os.path.dirname(__file__), 'packages_installer_dialog.ui'))
+
+_ERROR_COLOR = '#ff0000'
+
+
+@dataclass
+class PackageToInstall:
+ name: str
+ version: str
+ import_name: str # name while importing package
+
+ def __str__(self):
+ return f'{self.name}{self.version}'
+
+
+REQUIREMENTS_PATH = os.path.join(PLUGIN_ROOT_DIR, 'python_requirements/requirements.txt')
+
+with open(REQUIREMENTS_PATH, 'r') as f:
+ raw_txt = f.read()
+
+libraries_versions = {}
+
+for line in raw_txt.split('\n'):
+ if line.startswith('#') or not line.strip():
+ continue
+
+ line = line.split(';')[0]
+
+ if '==' in line:
+ lib, version = line.split('==')
+ libraries_versions[lib] = '==' + version
+ elif '>=' in line:
+ lib, version = line.split('>=')
+ libraries_versions[lib] = '>=' + version
+ elif '<=' in line:
+ lib, version = line.split('<=')
+ libraries_versions[lib] = '<=' + version
+ else:
+ libraries_versions[line] = ''
+
+
+packages_to_install = [
+ PackageToInstall(name='opencv-python-headless', version=libraries_versions['opencv-python-headless'], import_name='cv2'),
+]
+
+if sys.platform == "linux" or sys.platform == "linux2":
+ packages_to_install += [
+ PackageToInstall(name='onnxruntime-gpu', version=libraries_versions['onnxruntime-gpu'], import_name='onnxruntime'),
+ ]
+ PYTHON_EXECUTABLE_PATH = sys.executable
+elif sys.platform == "darwin": # MacOS
+ packages_to_install += [
+ PackageToInstall(name='onnxruntime', version=libraries_versions['onnxruntime-gpu'], import_name='onnxruntime'),
+ ]
+ PYTHON_EXECUTABLE_PATH = str(Path(sys.prefix) / 'bin' / 'python3') # sys.executable yields QGIS in macOS
+elif sys.platform == "win32":
+ packages_to_install += [
+ PackageToInstall(name='onnxruntime', version=libraries_versions['onnxruntime-gpu'], import_name='onnxruntime'),
+ ]
+ PYTHON_EXECUTABLE_PATH = 'python' # sys.executable yields QGis.exe in Windows
+else:
+ raise Exception("Unsupported operating system!")
+
+
+class PackagesInstallerDialog(QDialog, FORM_CLASS):
+ """
+ Dialog witch controls the installation process of packages.
+ UI design defined in the `packages_installer_dialog.ui` file.
+ """
+
+ signal_log_line = pyqtSignal(str) # we need to use signal because we cannot edit GUI from another thread
+
+ INSTALLATION_IN_PROGRESS = False # to make sure we will not start the installation twice
+
+ def __init__(self, iface, parent=None):
+ super(PackagesInstallerDialog, self).__init__(parent)
+ self.setupUi(self)
+ self.iface = iface
+ self.tb = self.textBrowser_log # type: QTextBrowser
+ self._create_connections()
+ self._setup_message()
+ self.aborted = False
+ self.thread = None
+
+ def move_to_top(self):
+ """ Move the window to the top.
+ Although if installed from plugin manager, the plugin manager will move itself to the top anyway.
+ """
+ self.setWindowState((self.windowState() & ~QtCore.Qt.WindowMinimized) | QtCore.Qt.WindowActive)
+
+ if sys.platform == "linux" or sys.platform == "linux2":
+ pass
+ elif sys.platform == "darwin": # MacOS
+ self.raise_() # FIXME: this does not really work, the window is still behind the plugin manager
+ elif sys.platform == "win32":
+ self.activateWindow()
+ else:
+ raise Exception("Unsupported operating system!")
+
+ def _create_connections(self):
+ self.pushButton_close.clicked.connect(self.close)
+ self.pushButton_install_packages.clicked.connect(self._run_packages_installation)
+ self.signal_log_line.connect(self._log_line)
+
+ def _log_line(self, txt):
+ txt = txt \
+ .replace(' ', ' ') \
+ .replace('\n', '
')
+ self.tb.append(txt)
+
+ def log(self, txt):
+ self.signal_log_line.emit(txt)
+
+ def _setup_message(self) -> None:
+
+ self.log(f'
'
+ f'Plugin {PLUGIN_NAME} - Packages installer
\n'
+ f'\n'
+ f'This plugin requires the following Python packages to be installed:')
+
+ for package in packages_to_install:
+ self.log(f'\t- {package.name}{package.version}')
+
+ self.log('\n\n'
+ f'If this packages are not installed in the global environment '
+ f'(or environment in which QGIS is started) '
+ f'you can install these packages in the local directory (which is included to the Python path).\n\n'
+ f'This Dialog does it for you! (Though you can still install these packages manually instead).\n'
+ f'Please click "Install packages" button below to install them automatically, '
+ f'or "Test and Close" if you installed them manually...\n')
+
+ def _run_packages_installation(self):
+ if self.INSTALLATION_IN_PROGRESS:
+ self.log(f'Error! Installation already in progress, cannot start again!')
+ return
+ self.aborted = False
+ self.INSTALLATION_IN_PROGRESS = True
+ self.thread = Thread(target=self._install_packages)
+ self.thread.start()
+
+ def _install_packages(self) -> None:
+ self.log('\n\n')
+ self.log('=' * 60)
+ self.log(f'Attempting to install required packages...
')
+ os.makedirs(PACKAGES_INSTALL_DIR, exist_ok=True)
+
+ self._install_pip_if_necessary()
+
+ self.log(f'Attempting to install required packages...
\n')
+ try:
+ self._pip_install_packages(packages_to_install)
+ except Exception as e:
+ msg = (f'\n '
+ f'Packages installation failed with exception: {e}!\n'
+ f'Please try to install the packages again. '
+ f'\nCheck if there is no error related to system packages, '
+ f'which may be required to be installed by your system package manager, e.g. "apt". '
+ f'Copy errors from the stack above and google for possible solutions. '
+ f'Please report these as an issue on the plugin repository tracker!')
+ self.log(msg)
+
+ # finally, validate the installation, if there was no error so far...
+ self.log('\n\n Installation of required packages finished. Validating installation...')
+ self._check_packages_installation_and_log()
+ self.INSTALLATION_IN_PROGRESS = False
+
+ def reject(self) -> None:
+ self.close()
+
+ def closeEvent(self, event: QCloseEvent):
+ self.aborted = True
+ if self._check_packages_installation_and_log():
+ event.accept()
+ return
+
+ res = QMessageBox.question(self.iface.mainWindow(),
+ f'{PLUGIN_NAME} - skip installation?',
+ 'Are you sure you want to abort the installation of the required python packages? '
+ 'The plugin may not function correctly without them!',
+ QMessageBox.No, QMessageBox.Yes)
+ log_msg = 'User requested to close the dialog, but the packages are not installed correctly!\n'
+ if res == QMessageBox.Yes:
+ log_msg += 'And the user confirmed to close the dialog, knowing the risk!'
+ event.accept()
+ else:
+ log_msg += 'The user reconsidered their decision, and will try to install the packages again!'
+ event.ignore()
+ log_msg += '\n'
+ self.log(log_msg)
+
+ def _install_pip_if_necessary(self):
+ """
+ Install pip if not present.
+ It happens e.g. in flatpak applications.
+
+ TODO - investigate whether we can also install pip in local directory
+ """
+
+ self.log(f'Making sure pip is installed...
')
+ if check_pip_installed():
+ self.log(f'Pip is installed, skipping installation...\n')
+ return
+
+ install_pip_command = [PYTHON_EXECUTABLE_PATH, '-m', 'ensurepip']
+ self.log(f'Running command to install pip: \n $ {" ".join(install_pip_command)} ')
+ with subprocess.Popen(install_pip_command,
+ stdout=subprocess.PIPE,
+ universal_newlines=True,
+ stderr=subprocess.STDOUT,
+ env={'SETUPTOOLS_USE_DISTUTILS': 'stdlib'}) as process:
+ try:
+ self._do_process_output_logging(process)
+ except InterruptedError as e:
+ self.log(str(e))
+ return False
+
+ if process.returncode != 0:
+ msg = (f''
+ f'pip installation failed! Consider installing it manually.'
+ f'')
+ self.log(msg)
+ self.log('\n')
+
+ def _pip_install_packages(self, packages: List[PackageToInstall]) -> None:
+ cmd = [PYTHON_EXECUTABLE_PATH, '-m', 'pip', 'install', '-U', f'--target={PACKAGES_INSTALL_DIR}']
+ cmd_string = ' '.join(cmd)
+
+ for pck in packages:
+ cmd.append(f"{pck}")
+ cmd_string += f"{pck}"
+
+ self.log(f'Running command: \n $ {cmd_string} ')
+ with subprocess.Popen(cmd,
+ stdout=subprocess.PIPE,
+ universal_newlines=True,
+ stderr=subprocess.STDOUT) as process:
+ self._do_process_output_logging(process)
+
+ if process.returncode != 0:
+ raise RuntimeError('Installation with pip failed')
+
+ msg = (f'\n'
+ f'Packages installed correctly!'
+ f'\n\n')
+ self.log(msg)
+
+ def _do_process_output_logging(self, process: subprocess.Popen) -> None:
+ """
+ :param process: instance of 'subprocess.Popen'
+ """
+ for stdout_line in iter(process.stdout.readline, ""):
+ if stdout_line.isspace():
+ continue
+ txt = f'{stdout_line.rstrip(os.linesep)}'
+ self.log(txt)
+ if self.aborted:
+ raise InterruptedError('Installation aborted by user')
+
+ def _check_packages_installation_and_log(self) -> bool:
+ packages_ok = are_packages_importable()
+ self.pushButton_install_packages.setEnabled(not packages_ok)
+
+ if packages_ok:
+ msg1 = f'All required packages are importable! You can close this window now!'
+ self.log(msg1)
+ return True
+
+ try:
+ import_packages()
+ raise Exception("Unexpected successful import of packages?!? It failed a moment ago, we shouldn't be here!")
+ except Exception:
+ msg_base = 'Python packages required by the plugin could not be loaded due to the following error:'
+ logging.exception(msg_base)
+ tb = traceback.format_exc()
+ msg1 = (f''
+ f'{msg_base} \n '
+ f'{tb}\n\n'
+ f'Please try installing the packages again.'
+ f'')
+ self.log(msg1)
+
+ return False
+
+
+dialog = None
+
+
+def import_package(package: PackageToInstall):
+ importlib.import_module(package.import_name)
+
+
+def import_packages():
+ for package in packages_to_install:
+ import_package(package)
+
+
+def are_packages_importable() -> bool:
+ try:
+ import_packages()
+ except Exception:
+ logging.exception(f'Python packages required by the plugin could not be loaded due to the following error:')
+ return False
+
+ return True
+
+
+def check_pip_installed() -> bool:
+ try:
+ subprocess.check_output([PYTHON_EXECUTABLE_PATH, '-m', 'pip', '--version'])
+ return True
+ except subprocess.CalledProcessError:
+ return False
+
+
+def check_required_packages_and_install_if_necessary(iface):
+ os.makedirs(PACKAGES_INSTALL_DIR, exist_ok=True)
+ if PACKAGES_INSTALL_DIR not in sys.path:
+ sys.path.append(PACKAGES_INSTALL_DIR) # TODO: check for a less intrusive way to do this
+
+ if are_packages_importable():
+ # if packages are importable we are fine, nothing more to do then
+ return
+
+ global dialog
+ dialog = PackagesInstallerDialog(iface)
+ dialog.setWindowModality(QtCore.Qt.WindowModal)
+ dialog.show()
+ dialog.move_to_top()
diff --git a/zipdeepness/dialogs/packages_installer/packages_installer_dialog.ui b/zipdeepness/dialogs/packages_installer/packages_installer_dialog.ui
new file mode 100644
index 0000000000000000000000000000000000000000..38e6b301ad4ac2b6151bb5783a2d2a37ce1e4450
--- /dev/null
+++ b/zipdeepness/dialogs/packages_installer/packages_installer_dialog.ui
@@ -0,0 +1,65 @@
+
+
+ PackagesInstallerDialog
+
+
+
+ 0
+ 0
+ 693
+ 494
+
+
+
+ Deepness - Packages Installer Dialog
+
+
+ -
+
+
-
+
+
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+
+ 75
+ true
+
+
+
+ Install packages
+
+
+
+ -
+
+
+ Test and Close
+
+
+
+
+
+
+
+
+
+
diff --git a/zipdeepness/dialogs/resizable_message_box.py b/zipdeepness/dialogs/resizable_message_box.py
new file mode 100644
index 0000000000000000000000000000000000000000..b2a67948bfbb3df5cba712f6f87d737cfb9a5807
--- /dev/null
+++ b/zipdeepness/dialogs/resizable_message_box.py
@@ -0,0 +1,20 @@
+from qgis.PyQt.QtWidgets import QMessageBox, QTextEdit
+
+
+class ResizableMessageBox(QMessageBox):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.setSizeGripEnabled(True)
+
+ def event(self, event):
+ if event.type() in (event.LayoutRequest, event.Resize):
+ if event.type() == event.Resize:
+ res = super().event(event)
+ else:
+ res = False
+ details = self.findChild(QTextEdit)
+ if details:
+ details.setMaximumSize(16777215, 16777215)
+ self.setMaximumSize(16777215, 16777215)
+ return res
+ return super().event(event)
diff --git a/zipdeepness/images/get_image_path.py b/zipdeepness/images/get_image_path.py
new file mode 100644
index 0000000000000000000000000000000000000000..03b5ebfd4ab7d4d50f46e4d7e30c3dc0c34cdff2
--- /dev/null
+++ b/zipdeepness/images/get_image_path.py
@@ -0,0 +1,29 @@
+"""
+This file contains image related functionalities
+"""
+
+import os
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+
+
+def get_icon_path() -> str:
+ """ Get path to the file with the main plugin icon
+
+ Returns
+ -------
+ str
+ Path to the icon
+ """
+ return get_image_path('icon.png')
+
+
+def get_image_path(image_name) -> str:
+ """ Get path to an image resource, accessing it just by the name of the file (provided it is in the common directory)
+
+ Returns
+ -------
+ str
+ file path
+ """
+ return os.path.join(SCRIPT_DIR, image_name)
diff --git a/zipdeepness/images/icon.png b/zipdeepness/images/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..49a6d9ac8befcc34fa4de6cc05b3e1c3be439537
--- /dev/null
+++ b/zipdeepness/images/icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1dffd56a93df4d230e3b5b6521e3ddce178423d5c5d5692240f2f1d27bd9d070
+size 167299
diff --git a/zipdeepness/landcover_model.onnx b/zipdeepness/landcover_model.onnx
new file mode 100644
index 0000000000000000000000000000000000000000..4d36437a93d66989e0d4bf774b213a72ab637cba
--- /dev/null
+++ b/zipdeepness/landcover_model.onnx
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12ae15a9bcc5e28f675e9c829cacbf2ab81776382f92e28645b2f91de3491d93
+size 12336500
diff --git a/zipdeepness/metadata.txt b/zipdeepness/metadata.txt
new file mode 100644
index 0000000000000000000000000000000000000000..32b8bd13b2b70470cdb1b55eaf39f9b25b76ded0
--- /dev/null
+++ b/zipdeepness/metadata.txt
@@ -0,0 +1,56 @@
+# This file contains metadata for your plugin.
+
+# This file should be included when you package your plugin.# Mandatory items:
+
+[general]
+name=Deepness: Deep Neural Remote Sensing
+qgisMinimumVersion=3.22
+description=Inference of deep neural network models (ONNX) for segmentation, detection and regression
+version=0.7.0
+author=PUT Vision
+email=przemyslaw.aszkowski@gmail.com
+
+about=
+ Deepness plugin allows to easily perform segmentation, detection and regression on raster ortophotos with custom ONNX Neural Network models, bringing the power of deep learning to casual users.
+ Features highlights:
+ - processing any raster layer (custom ortophoto from file or layers from online providers, e.g Google Satellite)
+ - limiting processing range to predefined area (visible part or area defined by vector layer polygons)
+ - common types of models are supported: segmentation, regression, detection
+ - integration with layers (both for input data and model output layers). Once an output layer is created, it can be saved as a file manually
+ - model ZOO under development (planes detection on Bing Aerial, Corn field damage, Oil Storage tanks detection, cars detection, ...)
+ - training data Export Tool - exporting raster and mask as small tiles
+ - parametrization of the processing for advanced users (spatial resolution, overlap, postprocessing)
+ Plugin requires external python packages to be installed. After the first plugin startup, a Dialog will show, to assist in this process. Please visit plugin the documentation for details.
+
+tracker=https://github.com/PUTvision/qgis-plugin-deepness/issues
+repository=https://github.com/PUTvision/qgis-plugin-deepness
+# End of mandatory metadata
+
+# Recommended items:
+
+hasProcessingProvider=no
+# Uncomment the following line and add your changelog:
+# changelog=
+
+# Tags are comma separated with spaces allowed
+tags=segmentation,detection,classification,machine learning,onnx,neural network,deep learning,regression,deepness,analysis,remote sensing,supervised classification
+
+homepage=https://qgis-plugin-deepness.readthedocs.io/
+category=Plugins
+icon=images/icon.png
+# experimental flag
+experimental=False
+
+# deprecated flag (applies to the whole plugin, not just a single version)
+deprecated=False
+
+# Since QGIS 3.8, a comma separated list of plugins to be installed
+# (or upgraded) can be specified.
+# Check the documentation for more information.
+# plugin_dependencies=
+
+Category of the plugin: Raster, Vector, Database or Web
+# category=
+
+# If the plugin can run on QGIS Server.
+server=False
diff --git a/zipdeepness/processing/__init__.py b/zipdeepness/processing/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..74fb9ed9de376dccabda69d2632c4942cb464310
--- /dev/null
+++ b/zipdeepness/processing/__init__.py
@@ -0,0 +1,2 @@
+""" Main submodule for image processing and deep learning things.
+"""
diff --git a/zipdeepness/processing/extent_utils.py b/zipdeepness/processing/extent_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..9e3a8548df5c220e5c10adf293dc466c305d7ca8
--- /dev/null
+++ b/zipdeepness/processing/extent_utils.py
@@ -0,0 +1,219 @@
+"""
+This file contains utilities related to Extent processing
+"""
+
+import logging
+
+from qgis.core import QgsCoordinateTransform, QgsRasterLayer, QgsRectangle, QgsVectorLayer
+from qgis.gui import QgsMapCanvas
+
+from deepness.common.errors import OperationFailedException
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters, ProcessedAreaType
+from deepness.processing.processing_utils import BoundingBox, convert_meters_to_rlayer_units
+
+
+def round_extent_to_rlayer_grid(extent: QgsRectangle, rlayer: QgsRasterLayer) -> QgsRectangle:
+ """
+ Round to rlayer "grid" for pixels.
+ Grid starts at rlayer_extent.xMinimum & yMinimum
+ with resolution of rlayer_units_per_pixel
+
+ :param extent: Extent to round, needs to be in rlayer CRS units
+ :param rlayer: layer detemining the grid
+ """
+ # For some ortophotos grid spacing is close to (1.0, 1.0), while it shouldn't be.
+ # Seems like it is some bug or special "feature" that I do not understand.
+ # In that case, just return the extent as it is
+ grid_spacing = rlayer.rasterUnitsPerPixelX(), rlayer.rasterUnitsPerPixelY()
+ if abs(grid_spacing[0] - 1.0) < 0.0001 and abs(grid_spacing[1] - 1.0) < 0.0001:
+ logging.warning('Grid spacing is close to 1.0, which is suspicious, returning extent as it is. It shouldn not be a problem for most cases.')
+ return extent
+
+ grid_start = rlayer.extent().xMinimum(), rlayer.extent().yMinimum()
+
+ x_min = grid_start[0] + int((extent.xMinimum() - grid_start[0]) / grid_spacing[0]) * grid_spacing[0]
+ x_max = grid_start[0] + int((extent.xMaximum() - grid_start[0]) / grid_spacing[0]) * grid_spacing[0]
+ y_min = grid_start[1] + int((extent.yMinimum() - grid_start[1]) / grid_spacing[1]) * grid_spacing[1]
+ y_max = grid_start[1] + int((extent.yMaximum() - grid_start[1]) / grid_spacing[1]) * grid_spacing[1]
+
+ new_extent = QgsRectangle(x_min, y_min, x_max, y_max)
+ return new_extent
+
+
+def calculate_extended_processing_extent(base_extent: QgsRectangle,
+ params: MapProcessingParameters,
+ rlayer: QgsVectorLayer,
+ rlayer_units_per_pixel: float) -> QgsRectangle:
+ """Calculate the "extended" processing extent, which is the full processing area, rounded to the tile size and overlap
+
+ Parameters
+ ----------
+ base_extent : QgsRectangle
+ Base extent of the processed ortophoto, which is not rounded to the tile size
+ params : MapProcessingParameters
+ Processing parameters
+ rlayer : QgsVectorLayer
+ mask layer
+ rlayer_units_per_pixel : float
+ how many rlayer CRS units are in 1 pixel
+
+ Returns
+ -------
+ QgsRectangle
+ The "extended" processing extent
+ """
+
+ # first try to add pixels at every border - same as half-overlap for other tiles
+ additional_pixels = params.processing_overlap_px // 2
+ additional_pixels_in_units = additional_pixels * rlayer_units_per_pixel
+
+ tmp_extent = QgsRectangle(
+ base_extent.xMinimum() - additional_pixels_in_units,
+ base_extent.yMinimum() - additional_pixels_in_units,
+ base_extent.xMaximum() + additional_pixels_in_units,
+ base_extent.yMaximum() + additional_pixels_in_units,
+ )
+
+ rlayer_extent_infinite = rlayer.extent().isEmpty() # empty extent for infinite layers
+ if not rlayer_extent_infinite:
+ tmp_extent = tmp_extent.intersect(rlayer.extent())
+
+ # then add borders to have the extent be equal to N * stride + tile_size, where N is a natural number
+ tile_size_px = params.tile_size_px
+ stride_px = params.processing_stride_px # stride in pixels
+
+ current_x_pixels = round(tmp_extent.width() / rlayer_units_per_pixel)
+ if current_x_pixels <= tile_size_px:
+ missing_pixels_x = tile_size_px - current_x_pixels # just one tile
+ else:
+ pixels_in_last_stride_x = (current_x_pixels - tile_size_px) % stride_px
+ missing_pixels_x = (stride_px - pixels_in_last_stride_x) % stride_px
+
+ current_y_pixels = round(tmp_extent.height() / rlayer_units_per_pixel)
+ if current_y_pixels <= tile_size_px:
+ missing_pixels_y = tile_size_px - current_y_pixels # just one tile
+ else:
+ pixels_in_last_stride_y = (current_y_pixels - tile_size_px) % stride_px
+ missing_pixels_y = (stride_px - pixels_in_last_stride_y) % stride_px
+
+ missing_pixels_x_in_units = missing_pixels_x * rlayer_units_per_pixel
+ missing_pixels_y_in_units = missing_pixels_y * rlayer_units_per_pixel
+ tmp_extent.setXMaximum(tmp_extent.xMaximum() + missing_pixels_x_in_units)
+ tmp_extent.setYMaximum(tmp_extent.yMaximum() + missing_pixels_y_in_units)
+
+ extended_extent = tmp_extent
+ return extended_extent
+
+
+def is_extent_infinite_or_too_big(rlayer: QgsRasterLayer) -> bool:
+ """Check whether layer covers whole earth (infinite extent) or or is too big for processing"""
+ rlayer_extent = rlayer.extent()
+
+ # empty extent happens for infinite layers
+ if rlayer_extent.isEmpty():
+ return True
+
+ rlayer_area_m2 = rlayer_extent.area() * (1 / convert_meters_to_rlayer_units(rlayer, 1)) ** 2
+
+ if rlayer_area_m2 > (1606006962349394 // 10): # so 1/3 of the earth (this magic value is from bing aerial map area)
+ return True
+
+ return False
+
+
+def calculate_base_processing_extent_in_rlayer_crs(map_canvas: QgsMapCanvas,
+ rlayer: QgsRasterLayer,
+ vlayer_mask: QgsVectorLayer,
+ params: MapProcessingParameters) -> QgsRectangle:
+ """ Determine the Base Extent of processing (Extent (rectangle) in which the actual required area is contained)
+
+ Parameters
+ ----------
+ map_canvas : QgsMapCanvas
+ currently visible map in the UI
+ rlayer : QgsRasterLayer
+ ortophotomap which is being processed
+ vlayer_mask : QgsVectorLayer
+ mask layer containing the processed area
+ params : MapProcessingParameters
+ Processing parameters
+
+ Returns
+ -------
+ QgsRectangle
+ Base Extent of processing
+ """
+
+ rlayer_extent = rlayer.extent()
+ processed_area_type = params.processed_area_type
+ rlayer_extent_infinite = is_extent_infinite_or_too_big(rlayer)
+
+ if processed_area_type == ProcessedAreaType.ENTIRE_LAYER:
+ expected_extent = rlayer_extent
+ if rlayer_extent_infinite:
+ msg = "Cannot process entire layer - layer extent is not defined or too big. " \
+ "Make sure you are not processing 'Entire layer' which covers entire earth surface!!"
+ raise OperationFailedException(msg)
+ elif processed_area_type == ProcessedAreaType.FROM_POLYGONS:
+ expected_extent_in_vlayer_crs = vlayer_mask.extent()
+ if vlayer_mask.crs() == rlayer.crs():
+ expected_extent = expected_extent_in_vlayer_crs
+ else:
+ t = QgsCoordinateTransform()
+ t.setSourceCrs(vlayer_mask.crs())
+ t.setDestinationCrs(rlayer.crs())
+ expected_extent = t.transform(expected_extent_in_vlayer_crs)
+ elif processed_area_type == ProcessedAreaType.VISIBLE_PART:
+ # transform visible extent from mapCanvas CRS to layer CRS
+ active_extent_in_canvas_crs = map_canvas.extent()
+ canvas_crs = map_canvas.mapSettings().destinationCrs()
+ t = QgsCoordinateTransform()
+ t.setSourceCrs(canvas_crs)
+ t.setDestinationCrs(rlayer.crs())
+ expected_extent = t.transform(active_extent_in_canvas_crs)
+ else:
+ raise Exception("Invalid processed are type!")
+
+ expected_extent = round_extent_to_rlayer_grid(extent=expected_extent, rlayer=rlayer)
+
+ if rlayer_extent_infinite:
+ base_extent = expected_extent
+ else:
+ base_extent = expected_extent.intersect(rlayer_extent)
+
+ return base_extent
+
+
+def calculate_base_extent_bbox_in_full_image(image_size_y: int,
+ base_extent: QgsRectangle,
+ extended_extent: QgsRectangle,
+ rlayer_units_per_pixel) -> BoundingBox:
+ """Calculate how the base extent fits in extended_extent in terms of pixel position
+
+ Parameters
+ ----------
+ image_size_y : int
+ Size of the image in y axis in pixels
+ base_extent : QgsRectangle
+ Base Extent of processing
+ extended_extent : QgsRectangle
+ Extended extent of processing
+ rlayer_units_per_pixel : _type_
+ Number of layer units per a single image pixel
+
+ Returns
+ -------
+ BoundingBox
+ Bounding box describing position of base extent in the extended extent
+ """
+ base_extent = base_extent
+ extended_extent = extended_extent
+
+ # should round without a rest anyway, as extends are aligned to rlayer grid
+ base_extent_bbox_in_full_image = BoundingBox(
+ x_min=round((base_extent.xMinimum() - extended_extent.xMinimum()) / rlayer_units_per_pixel),
+ y_min=image_size_y - 1 - round((base_extent.yMaximum() - extended_extent.yMinimum()) / rlayer_units_per_pixel - 1),
+ x_max=round((base_extent.xMaximum() - extended_extent.xMinimum()) / rlayer_units_per_pixel) - 1,
+ y_max=image_size_y - 1 - round((base_extent.yMinimum() - extended_extent.yMinimum()) / rlayer_units_per_pixel),
+ )
+ return base_extent_bbox_in_full_image
diff --git a/zipdeepness/processing/map_processor/__init__.py b/zipdeepness/processing/map_processor/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/zipdeepness/processing/map_processor/map_processing_result.py b/zipdeepness/processing/map_processor/map_processing_result.py
new file mode 100644
index 0000000000000000000000000000000000000000..f5c2e85c68f70a5164ea0ddc70f1a1a3ac158a33
--- /dev/null
+++ b/zipdeepness/processing/map_processor/map_processing_result.py
@@ -0,0 +1,47 @@
+""" This file defines possible outcomes of map processing
+"""
+
+
+from typing import Callable, Optional
+
+
+class MapProcessingResult:
+ """
+ Base class for signaling finished processing result
+ """
+
+ def __init__(self, message: str, gui_delegate: Optional[Callable] = None):
+ """
+ :param message: message to be shown to the user
+ :param gui_delegate: function to be called in GUI thread, as it is not safe to call GUI functions from other threads
+ """
+ self.message = message
+ self.gui_delegate = gui_delegate
+
+
+class MapProcessingResultSuccess(MapProcessingResult):
+ """
+ Processing result on success
+ """
+
+ def __init__(self, message: str = '', gui_delegate: Optional[Callable] = None):
+ super().__init__(message=message, gui_delegate=gui_delegate)
+
+
+class MapProcessingResultFailed(MapProcessingResult):
+ """
+ Processing result on error
+ """
+
+ def __init__(self, error_message: str, exception=None):
+ super().__init__(error_message)
+ self.exception = exception
+
+
+class MapProcessingResultCanceled(MapProcessingResult):
+ """
+ Processing when processing was aborted
+ """
+
+ def __init__(self):
+ super().__init__(message='')
diff --git a/zipdeepness/processing/map_processor/map_processor.py b/zipdeepness/processing/map_processor/map_processor.py
new file mode 100644
index 0000000000000000000000000000000000000000..903b307c42219e8846d2ed10c7daf5ede4537868
--- /dev/null
+++ b/zipdeepness/processing/map_processor/map_processor.py
@@ -0,0 +1,237 @@
+""" This file implements core map processing logic """
+
+import logging
+from typing import List, Optional, Tuple
+
+import numpy as np
+from qgis.core import QgsRasterLayer, QgsTask, QgsVectorLayer
+from qgis.gui import QgsMapCanvas
+from qgis.PyQt.QtCore import pyqtSignal
+
+from deepness.common.defines import IS_DEBUG
+from deepness.common.lazy_package_loader import LazyPackageLoader
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters, ProcessedAreaType
+from deepness.common.temp_files_handler import TempFilesHandler
+from deepness.processing import extent_utils, processing_utils
+from deepness.processing.map_processor.map_processing_result import MapProcessingResult, MapProcessingResultFailed
+from deepness.processing.tile_params import TileParams
+
+cv2 = LazyPackageLoader('cv2')
+
+
+class MapProcessor(QgsTask):
+ """
+ Base class for processing the ortophoto with parameters received from the UI.
+
+ Actual processing is done in specialized child classes. Here we have the "core" functionality,
+ like iterating over single tiles.
+
+ Objects of this class are created and managed by the 'Deepness'.
+ Work is done within QgsTask, for seamless integration with QGis GUI and logic.
+ """
+
+ # error message if finished with error, empty string otherwise
+ finished_signal = pyqtSignal(MapProcessingResult)
+ # request to show an image. Params: (image, window_name)
+ show_img_signal = pyqtSignal(object, str)
+
+ def __init__(self,
+ rlayer: QgsRasterLayer,
+ vlayer_mask: Optional[QgsVectorLayer],
+ map_canvas: QgsMapCanvas,
+ params: MapProcessingParameters):
+ """ init
+ Parameters
+ ----------
+ rlayer : QgsRasterLayer
+ Raster layer which is being processed
+ vlayer_mask : Optional[QgsVectorLayer]
+ Vector layer with outline of area which should be processed (within rlayer)
+ map_canvas : QgsMapCanvas
+ active map canvas (in the GUI), required if processing visible map area
+ params : MapProcessingParameters
+ see MapProcessingParameters
+ """
+ QgsTask.__init__(self, self.__class__.__name__)
+ self._processing_finished = False
+ self.rlayer = rlayer
+ self.vlayer_mask = vlayer_mask
+ self.params = params
+ self._assert_qgis_doesnt_need_reload()
+ self._processing_result = MapProcessingResultFailed('Failed to get processing result!')
+
+ self.stride_px = self.params.processing_stride_px # stride in pixels
+ self.rlayer_units_per_pixel = processing_utils.convert_meters_to_rlayer_units(
+ self.rlayer, self.params.resolution_m_per_px) # number of rlayer units for one tile pixel
+
+ self.file_handler = TempFilesHandler() if self.params.local_cache else None
+
+ # extent in which the actual required area is contained, without additional extensions, rounded to rlayer grid
+ self.base_extent = extent_utils.calculate_base_processing_extent_in_rlayer_crs(
+ map_canvas=map_canvas,
+ rlayer=self.rlayer,
+ vlayer_mask=self.vlayer_mask,
+ params=self.params)
+
+ # extent which should be used during model inference, as it includes extra margins to have full tiles,
+ # rounded to rlayer grid
+ self.extended_extent = extent_utils.calculate_extended_processing_extent(
+ base_extent=self.base_extent,
+ rlayer=self.rlayer,
+ params=self.params,
+ rlayer_units_per_pixel=self.rlayer_units_per_pixel)
+
+ # processed rlayer dimensions (for extended_extent)
+ self.img_size_x_pixels = round(self.extended_extent.width() / self.rlayer_units_per_pixel) # how many columns (x)
+ self.img_size_y_pixels = round(self.extended_extent.height() / self.rlayer_units_per_pixel) # how many rows (y)
+
+ # Coordinate of base image within extended image (images for base_extent and extended_extent)
+ self.base_extent_bbox_in_full_image = extent_utils.calculate_base_extent_bbox_in_full_image(
+ image_size_y=self.img_size_y_pixels,
+ base_extent=self.base_extent,
+ extended_extent=self.extended_extent,
+ rlayer_units_per_pixel=self.rlayer_units_per_pixel)
+
+ # Number of tiles in x and y dimensions which will be used during processing
+ # As we are using "extended_extent" this should divide without any rest
+ self.x_bins_number = round((self.img_size_x_pixels - self.params.tile_size_px) / self.stride_px) + 1
+ self.y_bins_number = round((self.img_size_y_pixels - self.params.tile_size_px) / self.stride_px) + 1
+
+ # Mask determining area to process (within extended_extent coordinates)
+ self.area_mask_img = processing_utils.create_area_mask_image(
+ vlayer_mask=self.vlayer_mask,
+ rlayer=self.rlayer,
+ extended_extent=self.extended_extent,
+ rlayer_units_per_pixel=self.rlayer_units_per_pixel,
+ image_shape_yx=(self.img_size_y_pixels, self.img_size_x_pixels),
+ files_handler=self.file_handler) # type: Optional[np.ndarray]
+
+ self._result_img = None
+
+ def set_results_img(self, img):
+ if self._result_img is not None:
+ raise Exception("Result image already created!")
+
+ self._result_img = img
+
+ def get_result_img(self):
+ if self._result_img is None:
+ raise Exception("Result image not yet created!")
+
+ return self._result_img
+
+ def _assert_qgis_doesnt_need_reload(self):
+ """ If the plugin is somehow invalid, it cannot compare the enums correctly
+ I suppose it could be fixed somehow, but no need to investigate it now,
+ it affects only the development
+ """
+
+ if self.params.processed_area_type.__class__ != ProcessedAreaType:
+ raise Exception("Disable plugin, restart QGis and enable plugin again!")
+
+ def run(self):
+ try:
+ self._processing_result = self._run()
+ except Exception as e:
+ logging.exception("Error occurred in MapProcessor:")
+ msg = "Unhandled exception occurred. See Python Console for details"
+ self._processing_result = MapProcessingResultFailed(msg, exception=e)
+ if IS_DEBUG:
+ raise e
+
+ self._processing_finished = True
+ return True
+
+ def _run(self) -> MapProcessingResult:
+ raise NotImplementedError('Base class not implemented!')
+
+ def finished(self, result: bool):
+ if result:
+ gui_delegate = self._processing_result.gui_delegate
+ if gui_delegate is not None:
+ gui_delegate()
+ else:
+ self._processing_result = MapProcessingResultFailed("Unhandled processing error!")
+ self.finished_signal.emit(self._processing_result)
+
+ @staticmethod
+ def is_busy():
+ return True
+
+ def _show_image(self, img, window_name='img'):
+ self.show_img_signal.emit(img, window_name)
+
+ def limit_extended_extent_image_to_base_extent_with_mask(self, full_img):
+ """
+ Limit an image which is for extended_extent to the base_extent image.
+ If a limiting polygon was used for processing, it will be also applied.
+ :param full_img:
+ :return:
+ """
+ # TODO look for some inplace operation to save memory
+ # cv2.copyTo(src=full_img, mask=area_mask_img, dst=full_img) # this doesn't work due to implementation details
+
+ for i in range(full_img.shape[0]):
+ full_img[i] = cv2.copyTo(src=full_img[i], mask=self.area_mask_img)
+
+ b = self.base_extent_bbox_in_full_image
+ result_img = full_img[:, b.y_min:b.y_max+1, b.x_min:b.x_max+1]
+ return result_img
+
+ def _get_array_or_mmapped_array(self, final_shape_px):
+ if self.file_handler is not None:
+ full_result_img = np.memmap(
+ self.file_handler.get_results_img_path(),
+ dtype=np.uint8,
+ mode='w+',
+ shape=final_shape_px)
+ else:
+ full_result_img = np.zeros(final_shape_px, np.uint8)
+
+ return full_result_img
+
+ def tiles_generator(self) -> Tuple[np.ndarray, TileParams]:
+ """
+ Iterate over all tiles, as a Python generator function
+ """
+ total_tiles = self.x_bins_number * self.y_bins_number
+
+ for y_bin_number in range(self.y_bins_number):
+ for x_bin_number in range(self.x_bins_number):
+ tile_no = y_bin_number * self.x_bins_number + x_bin_number
+ progress = tile_no / total_tiles * 100
+ self.setProgress(progress)
+ print(f" Processing tile {tile_no} / {total_tiles} [{progress:.2f}%]")
+ tile_params = TileParams(
+ x_bin_number=x_bin_number, y_bin_number=y_bin_number,
+ x_bins_number=self.x_bins_number, y_bins_number=self.y_bins_number,
+ params=self.params,
+ processing_extent=self.extended_extent,
+ rlayer_units_per_pixel=self.rlayer_units_per_pixel)
+
+ if not tile_params.is_tile_within_mask(self.area_mask_img):
+ continue # tile outside of mask - to be skipped
+
+ tile_img = processing_utils.get_tile_image(
+ rlayer=self.rlayer, extent=tile_params.extent, params=self.params)
+
+ yield tile_img, tile_params
+
+ def tiles_generator_batched(self) -> Tuple[np.ndarray, List[TileParams]]:
+ """
+ Iterate over all tiles, as a Python generator function, but return them in batches
+ """
+
+ tile_img_batch, tile_params_batch = [], []
+
+ for tile_img, tile_params in self.tiles_generator():
+ tile_img_batch.append(tile_img)
+ tile_params_batch.append(tile_params)
+
+ if len(tile_img_batch) >= self.params.batch_size:
+ yield np.array(tile_img_batch), tile_params_batch
+ tile_img_batch, tile_params_batch = [], []
+
+ if len(tile_img_batch) > 0:
+ yield np.array(tile_img_batch), tile_params_batch
+ tile_img_batch, tile_params_batch = [], []
diff --git a/zipdeepness/processing/map_processor/map_processor_detection.py b/zipdeepness/processing/map_processor/map_processor_detection.py
new file mode 100644
index 0000000000000000000000000000000000000000..8b5658b4249eb21970cf3302dec0f7d7a1bdd7a7
--- /dev/null
+++ b/zipdeepness/processing/map_processor/map_processor_detection.py
@@ -0,0 +1,288 @@
+""" This file implements map processing for detection model """
+from typing import List
+
+import cv2
+import numpy as np
+from qgis.core import QgsFeature, QgsGeometry, QgsProject, QgsVectorLayer
+from qgis.PyQt.QtCore import QVariant
+from qgis.core import QgsFields, QgsField
+
+from deepness.common.processing_parameters.detection_parameters import DetectionParameters
+from deepness.processing import processing_utils
+from deepness.processing.map_processor.map_processing_result import (MapProcessingResult, MapProcessingResultCanceled,
+ MapProcessingResultSuccess)
+from deepness.processing.map_processor.map_processor_with_model import MapProcessorWithModel
+from deepness.processing.map_processor.utils.ckdtree import cKDTree
+from deepness.processing.models.detector import Detection, Detector
+from deepness.processing.tile_params import TileParams
+from deepness.processing.models.detector import DetectorType
+
+
+class MapProcessorDetection(MapProcessorWithModel):
+ """
+ MapProcessor specialized for detecting objects (where there is a finite list of detected objects
+ of different classes, which area (bounding boxes) may overlap)
+ """
+
+ def __init__(self,
+ params: DetectionParameters,
+ **kwargs):
+ super().__init__(
+ params=params,
+ model=params.model,
+ **kwargs)
+ self.detection_parameters = params
+ self.model = params.model # type: Detector
+ self.model.set_inference_params(
+ confidence=params.confidence,
+ iou_threshold=params.iou_threshold
+ )
+ self.model.set_model_type_param(model_type=params.detector_type)
+ self._all_detections = None
+
+ def get_all_detections(self) -> List[Detection]:
+ return self._all_detections
+
+ def _run(self) -> MapProcessingResult:
+ all_bounding_boxes = [] # type: List[Detection]
+ for tile_img_batched, tile_params_batched in self.tiles_generator_batched():
+ if self.isCanceled():
+ return MapProcessingResultCanceled()
+
+ bounding_boxes_in_tile_batched = self._process_tile(tile_img_batched, tile_params_batched)
+ all_bounding_boxes += [d for det in bounding_boxes_in_tile_batched for d in det]
+
+ with_rot = self.detection_parameters.detector_type == DetectorType.YOLO_ULTRALYTICS_OBB
+
+ if len(all_bounding_boxes) > 0:
+ all_bounding_boxes_nms = self.remove_overlaping_detections(all_bounding_boxes, iou_threshold=self.detection_parameters.iou_threshold, with_rot=with_rot)
+ all_bounding_boxes_restricted = self.limit_bounding_boxes_to_processed_area(all_bounding_boxes_nms)
+ else:
+ all_bounding_boxes_restricted = []
+
+ gui_delegate = self._create_vlayer_for_output_bounding_boxes(all_bounding_boxes_restricted)
+
+ result_message = self._create_result_message(all_bounding_boxes_restricted)
+ self._all_detections = all_bounding_boxes_restricted
+ return MapProcessingResultSuccess(
+ message=result_message,
+ gui_delegate=gui_delegate,
+ )
+
+ def limit_bounding_boxes_to_processed_area(self, bounding_boxes: List[Detection]) -> List[Detection]:
+ """
+ Limit all bounding boxes to the constrained area that we process.
+ E.g. if we are detecting peoples in a circle, we don't want to count peoples in the entire rectangle
+
+ :return:
+ """
+ bounding_boxes_restricted = []
+ for det in bounding_boxes:
+ # if bounding box is not in the area_mask_img (at least in some percentage) - remove it
+
+ if self.area_mask_img is not None:
+ det_slice = det.bbox.get_slice()
+ area_subimg = self.area_mask_img[det_slice]
+ pixels_in_area = np.count_nonzero(area_subimg)
+ else:
+ det_bounding_box = det.bbox
+ pixels_in_area = self.base_extent_bbox_in_full_image.calculate_overlap_in_pixels(det_bounding_box)
+ total_pixels = det.bbox.get_area()
+ coverage = pixels_in_area / total_pixels
+ if coverage > 0.5: # some arbitrary value, 50% seems reasonable
+ bounding_boxes_restricted.append(det)
+
+ return bounding_boxes_restricted
+
+ def _create_result_message(self, bounding_boxes: List[Detection]) -> str:
+ # hack, allways one output
+ model_outputs = self._get_indexes_of_model_output_channels_to_create()
+ channels = range(model_outputs[0])
+
+ counts_mapping = {}
+ total_counts = 0
+ for channel_id in channels:
+ filtered_bounding_boxes = [det for det in bounding_boxes if det.clss == channel_id]
+ counts = len(filtered_bounding_boxes)
+ counts_mapping[channel_id] = counts
+ total_counts += counts
+
+ txt = f'Detection done for {len(channels)} model output classes, with the following statistics:\n'
+ for channel_id in channels:
+ counts = counts_mapping[channel_id]
+
+ if total_counts:
+ counts_percentage = counts / total_counts * 100
+ else:
+ counts_percentage = 0
+
+ txt += f' - {self.model.get_channel_name(0, channel_id)}: counts = {counts} ({counts_percentage:.2f} %)\n'
+
+ return txt
+
+ def _create_vlayer_for_output_bounding_boxes(self, bounding_boxes: List[Detection]):
+ vlayers = []
+
+ # hack, allways one output
+ model_outputs = self._get_indexes_of_model_output_channels_to_create()
+ channels = range(model_outputs[0])
+
+ for channel_id in channels:
+ filtered_bounding_boxes = [det for det in bounding_boxes if det.clss == channel_id]
+ print(f'Detections for class {channel_id}: {len(filtered_bounding_boxes)}')
+
+ vlayer = QgsVectorLayer("multipolygon", self.model.get_channel_name(0, channel_id), "memory")
+ vlayer.setCrs(self.rlayer.crs())
+ prov = vlayer.dataProvider()
+ prov.addAttributes([QgsField("confidence", QVariant.Double)])
+ vlayer.updateFields()
+
+ features = []
+ for det in filtered_bounding_boxes:
+ feature = QgsFeature()
+ if det.mask is None:
+ bbox_corners_pixels = det.bbox.get_4_corners()
+ bbox_corners_crs = processing_utils.transform_points_list_xy_to_target_crs(
+ points=bbox_corners_pixels,
+ extent=self.extended_extent,
+ rlayer_units_per_pixel=self.rlayer_units_per_pixel,
+ )
+ #feature = QgsFeature() #move outside of the if block
+ polygon_xy_vec_vec = [
+ bbox_corners_crs
+ ]
+ geometry = QgsGeometry.fromPolygonXY(polygon_xy_vec_vec)
+ #feature.setGeometry(geometry)
+ #features.append(feature)
+ else:
+ contours, _ = cv2.findContours(det.mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+ contours = sorted(contours, key=cv2.contourArea, reverse=True)
+
+ x_offset, y_offset = det.mask_offsets
+
+ if len(contours) > 0:
+ countur = contours[0]
+
+ corners = []
+ for point in countur:
+ corners.append(int(point[0][0]) + x_offset)
+ corners.append(int(point[0][1]) + y_offset)
+
+ mask_corners_pixels = cv2.convexHull(np.array(corners).reshape((-1, 2))).squeeze()
+
+ mask_corners_crs = processing_utils.transform_points_list_xy_to_target_crs(
+ points=mask_corners_pixels,
+ extent=self.extended_extent,
+ rlayer_units_per_pixel=self.rlayer_units_per_pixel,
+ )
+
+ #feature = QgsFeature()
+ polygon_xy_vec_vec = [
+ mask_corners_crs
+ ]
+ geometry = QgsGeometry.fromPolygonXY(polygon_xy_vec_vec)
+ #feature.setGeometry(geometry)
+ #features.append(feature)
+ feature.setGeometry(geometry)
+ feature.setAttributes([float(det.conf)])
+ features.append(feature)
+
+ #vlayer = QgsVectorLayer("multipolygon", self.model.get_channel_name(0, channel_id), "memory")
+ #vlayer.setCrs(self.rlayer.crs())
+ #prov = vlayer.dataProvider()
+
+ color = vlayer.renderer().symbol().color()
+ OUTPUT_VLAYER_COLOR_TRANSPARENCY = 80
+ color.setAlpha(OUTPUT_VLAYER_COLOR_TRANSPARENCY)
+ vlayer.renderer().symbol().setColor(color)
+ # TODO - add also outline for the layer (thicker black border)
+
+ prov.addFeatures(features)
+ vlayer.updateExtents()
+
+ vlayers.append(vlayer)
+
+ # accessing GUI from non-GUI thread is not safe, so we need to delegate it to the GUI thread
+ def add_to_gui():
+ group = QgsProject.instance().layerTreeRoot().insertGroup(0, 'model_output')
+ for vlayer in vlayers:
+ QgsProject.instance().addMapLayer(vlayer, False)
+ group.addLayer(vlayer)
+
+ return add_to_gui
+
+ @staticmethod
+ def remove_overlaping_detections(bounding_boxes: List[Detection], iou_threshold: float, with_rot: bool = False) -> List[Detection]:
+ bboxes = []
+ probs = []
+ for det in bounding_boxes:
+ if with_rot:
+ bboxes.append(det.get_bbox_xyxy_rot())
+ else:
+ bboxes.append(det.get_bbox_xyxy())
+ probs.append(det.conf)
+
+ bboxes = np.array(bboxes)
+ probs = np.array(probs)
+
+ pick_ids = Detector.non_max_suppression_fast(boxes=bboxes, probs=probs, iou_threshold=iou_threshold, with_rot=with_rot)
+
+ filtered_bounding_boxes = [x for i, x in enumerate(bounding_boxes) if i in pick_ids]
+ filtered_bounding_boxes = sorted(filtered_bounding_boxes, reverse=True)
+
+ pick_ids_kde = MapProcessorDetection.non_max_kdtree(filtered_bounding_boxes, iou_threshold)
+
+ filtered_bounding_boxes = [x for i, x in enumerate(filtered_bounding_boxes) if i in pick_ids_kde]
+
+ return filtered_bounding_boxes
+
+ @staticmethod
+ def non_max_kdtree(bounding_boxes: List[Detection], iou_threshold: float) -> List[int]:
+ """ Remove overlapping bounding boxes using kdtree
+
+ :param bounding_boxes: List of bounding boxes in (xyxy format)
+ :param iou_threshold: Threshold for intersection over union
+ :return: Pick ids to keep
+ """
+
+ centers = np.array([det.get_bbox_center() for det in bounding_boxes])
+
+ kdtree = cKDTree(centers)
+ pick_ids = set()
+ removed_ids = set()
+
+ for i, bbox in enumerate(bounding_boxes):
+ if i in removed_ids:
+ continue
+
+ indices = kdtree.query(bbox.get_bbox_center(), k=min(10, len(bounding_boxes)))
+
+ for j in indices:
+ if j in removed_ids:
+ continue
+
+ if i == j:
+ continue
+
+ iou = bbox.bbox.calculate_intersection_over_smaler_area(bounding_boxes[j].bbox)
+
+ if iou > iou_threshold:
+ removed_ids.add(j)
+
+ pick_ids.add(i)
+
+ return pick_ids
+
+ @staticmethod
+ def convert_bounding_boxes_to_absolute_positions(bounding_boxes_relative: List[Detection],
+ tile_params: TileParams):
+ for det in bounding_boxes_relative:
+ det.convert_to_global(offset_x=tile_params.start_pixel_x, offset_y=tile_params.start_pixel_y)
+
+ def _process_tile(self, tile_img: np.ndarray, tile_params_batched: List[TileParams]) -> np.ndarray:
+ bounding_boxes_batched: List[Detection] = self.model.process(tile_img)
+
+ for bounding_boxes, tile_params in zip(bounding_boxes_batched, tile_params_batched):
+ self.convert_bounding_boxes_to_absolute_positions(bounding_boxes, tile_params)
+
+ return bounding_boxes_batched
diff --git a/zipdeepness/processing/map_processor/map_processor_recognition.py b/zipdeepness/processing/map_processor/map_processor_recognition.py
new file mode 100644
index 0000000000000000000000000000000000000000..340a568edda5c1680a4d719a75b40cecc2fda15f
--- /dev/null
+++ b/zipdeepness/processing/map_processor/map_processor_recognition.py
@@ -0,0 +1,221 @@
+""" This file implements map processing for Recognition model """
+
+import os
+import uuid
+from typing import List
+
+import numpy as np
+from numpy.linalg import norm
+from osgeo import gdal, osr
+from qgis.core import QgsProject, QgsRasterLayer
+
+from deepness.common.defines import IS_DEBUG
+from deepness.common.lazy_package_loader import LazyPackageLoader
+from deepness.common.misc import TMP_DIR_PATH
+from deepness.common.processing_parameters.recognition_parameters import RecognitionParameters
+from deepness.processing.map_processor.map_processing_result import (MapProcessingResult, MapProcessingResultCanceled,
+ MapProcessingResultFailed,
+ MapProcessingResultSuccess)
+from deepness.processing.map_processor.map_processor_with_model import MapProcessorWithModel
+
+cv2 = LazyPackageLoader('cv2')
+
+
+class MapProcessorRecognition(MapProcessorWithModel):
+ """
+ MapProcessor specialized for Recognition model
+ """
+
+ def __init__(self, params: RecognitionParameters, **kwargs):
+ super().__init__(params=params, model=params.model, **kwargs)
+ self.recognition_parameters = params
+ self.model = params.model
+
+ def _run(self) -> MapProcessingResult:
+ try:
+ query_img = cv2.imread(self.recognition_parameters.query_image_path)
+ assert query_img is not None, f"Error occurred while reading query image: {self.recognition_parameters.query_image_path}"
+ except Exception as e:
+ return MapProcessingResultFailed(f"Error occurred while reading query image: {e}")
+
+ # some hardcoded code for recognition model
+ query_img = cv2.cvtColor(query_img, cv2.COLOR_BGR2RGB)
+ query_img_resized = cv2.resize(query_img, self.model.get_input_shape()[2:4][::-1])
+ query_img_batched = np.array([query_img_resized])
+
+ query_img_emb = self.model.process(query_img_batched)[0][0]
+
+ final_shape_px = (
+ self.img_size_y_pixels,
+ self.img_size_x_pixels,
+ )
+
+ stride = self.stride_px
+ full_result_img = np.zeros(final_shape_px, np.float32)
+ mask = np.zeros_like(full_result_img, dtype=np.int16)
+ highest = 0
+ for tile_img_batched, tile_params_batched in self.tiles_generator_batched():
+ if self.isCanceled():
+ return MapProcessingResultCanceled()
+
+ tile_result_batched = self._process_tile(tile_img_batched)[0]
+
+ for tile_result, tile_params in zip(tile_result_batched, tile_params_batched):
+ cossim = np.dot(query_img_emb, tile_result)/(norm(query_img_emb)*norm(tile_result))
+
+ x_bin = tile_params.x_bin_number
+ y_bin = tile_params.y_bin_number
+ size = self.params.tile_size_px
+
+ if cossim > highest:
+ highest = cossim
+ x_high = x_bin
+ y_high = y_bin
+
+ full_result_img[y_bin*stride:y_bin*stride+size, x_bin*stride:x_bin*stride + size] += cossim
+ mask[y_bin*stride:y_bin*stride+size, x_bin*stride:x_bin*stride + size] += 1
+
+ full_result_img = full_result_img/mask
+ self.set_results_img(full_result_img)
+
+ gui_delegate = self._create_rlayers_from_images_for_base_extent(self.get_result_img(), x_high, y_high, size, stride)
+ result_message = self._create_result_message(self.get_result_img(), x_high*self.params.tile_size_px, y_high*self.params.tile_size_px)
+ return MapProcessingResultSuccess(
+ message=result_message,
+ gui_delegate=gui_delegate,
+ )
+
+ def _create_result_message(self, result_img: List[np.ndarray], x_high, y_high) -> str:
+ txt = f"Recognition ended, best result found at {x_high}, {y_high}, {result_img.shape}"
+ return txt
+
+ def limit_extended_extent_image_to_base_extent_with_mask(self, full_img):
+ """
+ Limit an image which is for extended_extent to the base_extent image.
+ If a limiting polygon was used for processing, it will be also applied.
+ :param full_img:
+ :return:
+ """
+ # TODO look for some inplace operation to save memory
+ # cv2.copyTo(src=full_img, mask=area_mask_img, dst=full_img) # this doesn't work due to implementation details
+ # full_img = cv2.copyTo(src=full_img, mask=self.area_mask_img)
+
+ b = self.base_extent_bbox_in_full_image
+ result_img = full_img[
+ int(b.y_min * self.recognition_parameters.scale_factor): int(
+ b.y_max * self.recognition_parameters.scale_factor
+ ),
+ int(b.x_min * self.recognition_parameters.scale_factor): int(
+ b.x_max * self.recognition_parameters.scale_factor
+ ),
+ :,
+ ]
+ return result_img
+
+ def load_rlayer_from_file(self, file_path):
+ """
+ Create raster layer from tif file
+ """
+ file_name = os.path.basename(file_path)
+ base_file_name = file_name.split("___")[
+ 0
+ ] # we remove the random_id string we created a moment ago
+ rlayer = QgsRasterLayer(file_path, base_file_name)
+ if rlayer.width() == 0:
+ raise Exception(
+ "0 width - rlayer not loaded properly. Probably invalid file path?"
+ )
+ rlayer.setCrs(self.rlayer.crs())
+ return rlayer
+
+ def _create_rlayers_from_images_for_base_extent(
+ self, result_img: np.ndarray,
+ x_high,
+ y_high,
+ size,
+ stride
+ ):
+ y = y_high * stride
+ x = x_high * stride
+
+ result_img[y, x:x+size-1] = 1
+ result_img[y+size-1, x:x+size-1] = 1
+ result_img[y:y+size-1, x] = 1
+ result_img[y:y+size-1, x+size-1] = 1
+
+ # TODO: We are creating a new file for each layer.
+ # Maybe can we pass ownership of this file to QGis?
+ # Or maybe even create vlayer directly from array, without a file?
+
+ random_id = str(uuid.uuid4()).replace("-", "")
+ file_path = os.path.join(TMP_DIR_PATH, f"{random_id}.tif")
+ self.save_result_img_as_tif(file_path=file_path, img=np.expand_dims(result_img, axis=2))
+
+ rlayer = self.load_rlayer_from_file(file_path)
+ OUTPUT_RLAYER_OPACITY = 0.5
+ rlayer.renderer().setOpacity(OUTPUT_RLAYER_OPACITY)
+
+ # accessing GUI from non-GUI thread is not safe, so we need to delegate it to the GUI thread
+ def add_to_gui():
+ group = (
+ QgsProject.instance()
+ .layerTreeRoot()
+ .insertGroup(0, "Cosine similarity score")
+ )
+ QgsProject.instance().addMapLayer(rlayer, False)
+ group.addLayer(rlayer)
+
+ return add_to_gui
+
+ def save_result_img_as_tif(self, file_path: str, img: np.ndarray):
+ """
+ As we cannot pass easily an numpy array to be displayed as raster layer, we create temporary geotif files,
+ which will be loaded as layer later on
+
+ Partially based on example from:
+ https://gis.stackexchange.com/questions/82031/gdal-python-set-projection-of-a-raster-not-working
+ """
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
+
+ extent = self.base_extent
+ crs = self.rlayer.crs()
+
+ geo_transform = [
+ extent.xMinimum(),
+ self.rlayer_units_per_pixel,
+ 0,
+ extent.yMaximum(),
+ 0,
+ -self.rlayer_units_per_pixel,
+ ]
+
+ driver = gdal.GetDriverByName("GTiff")
+ n_lines = img.shape[0]
+ n_cols = img.shape[1]
+ n_chanels = img.shape[2]
+ # data_type = gdal.GDT_Byte
+ data_type = gdal.GDT_Float32
+ grid_data = driver.Create(
+ "grid_data", n_cols, n_lines, n_chanels, data_type
+ ) # , options)
+ # loop over chanels
+ for i in range(1, img.shape[2] + 1):
+ grid_data.GetRasterBand(i).WriteArray(img[:, :, i - 1])
+
+ # crs().srsid() - maybe we can use the ID directly - but how?
+ # srs.ImportFromEPSG()
+ srs = osr.SpatialReference()
+ srs.SetFromUserInput(crs.authid())
+
+ grid_data.SetProjection(srs.ExportToWkt())
+ grid_data.SetGeoTransform(geo_transform)
+ driver.CreateCopy(file_path, grid_data, 0)
+
+ def _process_tile(self, tile_img: np.ndarray) -> np.ndarray:
+ result = self.model.process(tile_img)
+
+ # NOTE - currently we are saving result as float32, so we are losing some accuraccy.
+ # result = np.clip(result, 0, 255) # old version with uint8_t - not used anymore
+ # result = result.astype(np.float32)
+
+ return result
diff --git a/zipdeepness/processing/map_processor/map_processor_regression.py b/zipdeepness/processing/map_processor/map_processor_regression.py
new file mode 100644
index 0000000000000000000000000000000000000000..15c3c8dc9e9f9789f2ab1415cdaa66c57b744c2b
--- /dev/null
+++ b/zipdeepness/processing/map_processor/map_processor_regression.py
@@ -0,0 +1,174 @@
+""" This file implements map processing for regression model """
+
+import os
+import uuid
+from typing import List
+
+import numpy as np
+from osgeo import gdal, osr
+from qgis.core import QgsProject, QgsRasterLayer
+
+from deepness.common.misc import TMP_DIR_PATH
+from deepness.common.processing_parameters.regression_parameters import RegressionParameters
+from deepness.processing.map_processor.map_processing_result import (MapProcessingResult, MapProcessingResultCanceled,
+ MapProcessingResultSuccess)
+from deepness.processing.map_processor.map_processor_with_model import MapProcessorWithModel
+
+
+class MapProcessorRegression(MapProcessorWithModel):
+ """
+ MapProcessor specialized for Regression model (where each pixel has a value representing some feature intensity)
+ """
+
+ def __init__(self,
+ params: RegressionParameters,
+ **kwargs):
+ super().__init__(
+ params=params,
+ model=params.model,
+ **kwargs)
+ self.regression_parameters = params
+ self.model = params.model
+
+ def _run(self) -> MapProcessingResult:
+ number_of_output_channels = len(self._get_indexes_of_model_output_channels_to_create())
+ final_shape_px = (number_of_output_channels, self.img_size_y_pixels, self.img_size_x_pixels)
+
+ # NOTE: consider whether we can use float16/uint16 as datatype
+ full_result_imgs = self._get_array_or_mmapped_array(final_shape_px)
+
+ for tile_img_batched, tile_params_batched in self.tiles_generator_batched():
+ if self.isCanceled():
+ return MapProcessingResultCanceled()
+
+ tile_results_batched = self._process_tile(tile_img_batched)
+
+ for tile_results, tile_params in zip(tile_results_batched, tile_params_batched):
+ tile_params.set_mask_on_full_img(
+ tile_result=tile_results,
+ full_result_img=full_result_imgs)
+
+ # plt.figure(); plt.imshow(full_result_img); plt.show(block=False); plt.pause(0.001)
+ full_result_imgs = self.limit_extended_extent_images_to_base_extent_with_mask(full_imgs=full_result_imgs)
+ self.set_results_img(full_result_imgs)
+
+ gui_delegate = self._create_rlayers_from_images_for_base_extent(self.get_result_img())
+ result_message = self._create_result_message(self.get_result_img())
+ return MapProcessingResultSuccess(
+ message=result_message,
+ gui_delegate=gui_delegate,
+ )
+
+ def _create_result_message(self, result_imgs: List[np.ndarray]) -> str:
+ txt = f'Regression done, with the following statistics:\n'
+ for output_id, _ in enumerate(self._get_indexes_of_model_output_channels_to_create()):
+ result_img = result_imgs[output_id]
+
+ average_value = np.mean(result_img)
+ std = np.std(result_img)
+
+ txt += f' - {self.model.get_channel_name(output_id, 0)}: average_value = {average_value:.2f} (std = {std:.2f}, ' \
+ f'min={np.min(result_img)}, max={np.max(result_img)})\n'
+
+ return txt
+
+ def limit_extended_extent_images_to_base_extent_with_mask(self, full_imgs: List[np.ndarray]):
+ """
+ Same as 'limit_extended_extent_image_to_base_extent_with_mask' but for a list of images.
+ See `limit_extended_extent_image_to_base_extent_with_mask` for details.
+ :param full_imgs:
+ :return:
+ """
+ return self.limit_extended_extent_image_to_base_extent_with_mask(full_img=full_imgs)
+
+ def load_rlayer_from_file(self, file_path):
+ """
+ Create raster layer from tif file
+ """
+ file_name = os.path.basename(file_path)
+ base_file_name = file_name.split('___')[0] # we remove the random_id string we created a moment ago
+ rlayer = QgsRasterLayer(file_path, base_file_name)
+ if rlayer.width() == 0:
+ raise Exception("0 width - rlayer not loaded properly. Probably invalid file path?")
+ rlayer.setCrs(self.rlayer.crs())
+ return rlayer
+
+ def _create_rlayers_from_images_for_base_extent(self, result_imgs: List[np.ndarray]):
+ # TODO: We are creating a new file for each layer.
+ # Maybe can we pass ownership of this file to QGis?
+ # Or maybe even create vlayer directly from array, without a file?
+ rlayers = []
+
+ for output_id, _ in enumerate(self._get_indexes_of_model_output_channels_to_create()):
+
+ random_id = str(uuid.uuid4()).replace('-', '')
+ file_path = os.path.join(TMP_DIR_PATH, f'{self.model.get_channel_name(output_id, 0)}__{random_id}.tif')
+ self.save_result_img_as_tif(file_path=file_path, img=result_imgs[output_id])
+
+ rlayer = self.load_rlayer_from_file(file_path)
+ OUTPUT_RLAYER_OPACITY = 0.5
+ rlayer.renderer().setOpacity(OUTPUT_RLAYER_OPACITY)
+ rlayers.append(rlayer)
+
+ def add_to_gui():
+ group = QgsProject.instance().layerTreeRoot().insertGroup(0, 'model_output')
+ for rlayer in rlayers:
+ QgsProject.instance().addMapLayer(rlayer, False)
+ group.addLayer(rlayer)
+
+ return add_to_gui
+
+ def save_result_img_as_tif(self, file_path: str, img: np.ndarray):
+ """
+ As we cannot pass easily an numpy array to be displayed as raster layer, we create temporary geotif files,
+ which will be loaded as layer later on
+
+ Partially based on example from:
+ https://gis.stackexchange.com/questions/82031/gdal-python-set-projection-of-a-raster-not-working
+ """
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
+
+ extent = self.base_extent
+ crs = self.rlayer.crs()
+
+ geo_transform = [extent.xMinimum(), self.rlayer_units_per_pixel, 0,
+ extent.yMaximum(), 0, -self.rlayer_units_per_pixel]
+
+ driver = gdal.GetDriverByName('GTiff')
+ n_lines = img.shape[0]
+ n_cols = img.shape[1]
+ # data_type = gdal.GDT_Byte
+ data_type = gdal.GDT_Float32
+ grid_data = driver.Create('grid_data', n_cols, n_lines, 1, data_type) # , options)
+ grid_data.GetRasterBand(1).WriteArray(img)
+
+ # crs().srsid() - maybe we can use the ID directly - but how?
+ # srs.ImportFromEPSG()
+ srs = osr.SpatialReference()
+ srs.SetFromUserInput(crs.authid())
+
+ grid_data.SetProjection(srs.ExportToWkt())
+ grid_data.SetGeoTransform(geo_transform)
+ driver.CreateCopy(file_path, grid_data, 0)
+ print(f'***** {file_path = }')
+
+ def _process_tile(self, tile_img: np.ndarray) -> np.ndarray:
+ many_result = self.model.process(tile_img)
+ many_outputs = []
+
+ for result in many_result:
+ result[np.isnan(result)] = 0
+ result *= self.regression_parameters.output_scaling
+
+ # NOTE - currently we are saving result as float32, so we are losing some accuraccy.
+ # result = np.clip(result, 0, 255) # old version with uint8_t - not used anymore
+ result = result.astype(np.float32)
+
+ if len(result.shape) == 3:
+ result = np.expand_dims(result, axis=1)
+
+ many_outputs.append(result[:, 0])
+
+ many_outputs = np.array(many_outputs).transpose((1, 0, 2, 3))
+
+ return many_outputs
diff --git a/zipdeepness/processing/map_processor/map_processor_segmentation.py b/zipdeepness/processing/map_processor/map_processor_segmentation.py
new file mode 100644
index 0000000000000000000000000000000000000000..75f98910862afc05292b9da0b609b65d8e293893
--- /dev/null
+++ b/zipdeepness/processing/map_processor/map_processor_segmentation.py
@@ -0,0 +1,386 @@
+""" This file implements map processing for segmentation model """
+
+from typing import Callable
+
+import numpy as np
+from qgis.core import QgsProject, QgsVectorLayer, QgsCoordinateTransform, QgsCoordinateReferenceSystem, QgsField, QgsFeature, QgsGeometry, QgsMessageLog
+from deepness.common.lazy_package_loader import LazyPackageLoader
+from deepness.common.processing_parameters.segmentation_parameters import SegmentationParameters
+from deepness.processing import processing_utils
+from deepness.processing.map_processor.map_processing_result import (MapProcessingResult, MapProcessingResultCanceled,
+ MapProcessingResultSuccess)
+from deepness.processing.map_processor.map_processor_with_model import MapProcessorWithModel
+from deepness.processing.tile_params import TileParams
+import geopandas as gpd
+import traceback
+import pandas as pd
+from rasterio.features import shapes
+from affine import Affine
+from shapely.geometry import shape
+
+cv2 = LazyPackageLoader('cv2')
+
+
+class MapProcessorSegmentation(MapProcessorWithModel):
+ """
+ MapProcessor specialized for Segmentation model (where each pixel is assigned to one class).
+ """
+
+ def __init__(self,
+ params: SegmentationParameters,
+ **kwargs):
+ super().__init__(
+ params=params,
+ model=params.model,
+ **kwargs)
+ self.segmentation_parameters = params
+ self.model = params.model
+ self.new_class_names = {
+ "0": "background",
+ "2": "zone-verte",
+ "3": "eau",
+ "4": "route",
+ "5": "non-residentiel",
+ "6": "Villa",
+ "7": "traditionnel",
+ "8": "appartement",
+ "9": "autre"
+ }
+
+
+ def tile_mask_to_gdf_latlon(self,tile: TileParams, mask: np.ndarray, raster_layer_crs=None):
+
+
+ # remove channel if exists
+ if mask.ndim == 3:
+ mask = mask[0]
+
+ H, W = mask.shape
+
+ mask_binary = mask != 0
+
+ x_min = tile.extent.xMinimum()
+ y_max = tile.extent.yMaximum()
+ pixel_size = tile.rlayer_units_per_pixel
+ transform = Affine(pixel_size, 0, x_min, 0, -pixel_size, y_max)
+
+
+ results = (
+ {"properties": {"class_id": int(v)}, "geometry": s}
+ for s, v in shapes(mask.astype(np.int16), mask=mask_binary, transform=transform)
+ )
+ geoms = []
+ for r in results:
+ class_id = r["properties"]["class_id"]
+ class_name = self.new_class_names.get(str(class_id), "unknown")
+ geom = shape(r["geometry"])
+ geoms.append({"geometry": geom, "class_id": class_id, "class_name": class_name})
+ if geoms:
+ gdf = gpd.GeoDataFrame(geoms, geometry="geometry", crs=raster_layer_crs.authid() if raster_layer_crs else "EPSG:3857")
+ else:
+ # empty GeoDataFrame with the right columns
+ gdf = gpd.GeoDataFrame(columns=["geometry", "class_id", "class_name"], geometry="geometry", crs=raster_layer_crs.authid() if raster_layer_crs else "EPSG:3857")
+
+# Transform to lat/lon if needed
+ if not gdf.empty:
+ gdf = gdf.to_crs("EPSG:4326")
+
+ gdf = gpd.GeoDataFrame(geoms, geometry="geometry", crs=raster_layer_crs.authid() if raster_layer_crs else "EPSG:3857")
+
+ # Transform to lat/lon if needed
+ gdf = gdf.to_crs("EPSG:4326")
+
+ # # compute CRS coordinates for each pixel
+ # xs = x_min + np.arange(W) * pixel_size
+ # ys = y_max - np.arange(H) * pixel_size # Y decreases downward
+
+ # xs_grid, ys_grid = np.meshgrid(xs, ys)
+
+ # # flatten
+ # xs_flat = xs_grid.flatten()
+ # ys_flat = ys_grid.flatten()
+ # classes_flat = mask.flatten()
+
+ # # create points
+ # points = [Point(x, y) for x, y in zip(xs_flat, ys_flat)]
+
+ # gdf = gpd.GeoDataFrame({'class': classes_flat}, geometry=points)
+
+ # # set CRS
+ # if raster_layer_crs is None:
+ # raster_layer_crs = QgsCoordinateReferenceSystem("EPSG:3857") # fallback
+ # gdf.set_crs(raster_layer_crs.authid(), inplace=True)
+
+ # # transform to lat/lon (EPSG:4326)
+ # transformer = QgsCoordinateTransform(raster_layer_crs, QgsCoordinateReferenceSystem("EPSG:4326"), QgsProject.instance())
+ # gdf['geometry'] = gdf['geometry'].apply(lambda pt: Point(*transformer.transform(pt.x, pt.y)))
+ # # transformed_coords = [transformer.transform(pt.x, pt.y) for pt in gdf.geometry]
+ # # gdf['geometry'] = [Point(x, y) for x, y in transformed_coords]
+
+
+ return gdf
+
+
+ def _run(self) -> MapProcessingResult:
+ final_shape_px = (len(self._get_indexes_of_model_output_channels_to_create()), self.img_size_y_pixels, self.img_size_x_pixels)
+
+ full_result_img = self._get_array_or_mmapped_array(final_shape_px)
+ gdf_list = []
+ raster_layer = QgsProject.instance().mapLayer(self.params.input_layer_id)
+ raster_layer_crs = raster_layer.crs() if raster_layer else QgsCoordinateReferenceSystem("EPSG:3857")
+
+ for tile_img_batched, tile_params_batched in self.tiles_generator_batched():
+ if self.isCanceled():
+ return MapProcessingResultCanceled()
+
+ tile_result_batched = self._process_tile(tile_img_batched)
+
+ for tile_result, tile_params in zip(tile_result_batched, tile_params_batched):
+ tile_params.set_mask_on_full_img(
+ tile_result=tile_result,
+ full_result_img=full_result_img)
+ try:
+ gdf_tile = self.tile_mask_to_gdf_latlon(
+ tile=tile_params,
+ mask=tile_result,
+ raster_layer_crs=raster_layer_crs,
+ )
+ gdf_list.append(gdf_tile)
+ QgsMessageLog.logMessage(f"Tile {tile_params.x_bin_number},{tile_params.y_bin_number}: got {len(gdf_tile)} points", "Segmentation", 0)
+ except Exception as e:
+ QgsMessageLog.logMessage(f"Tile {tile_params.x_bin_number},{tile_params.y_bin_number} failed: {e}", "Segmentation", 2)
+ QgsMessageLog.logMessage(traceback.format_exc(), "Segmentation", 2)
+
+ blur_size = int(self.segmentation_parameters.postprocessing_dilate_erode_size // 2) * 2 + 1 # needs to be odd
+
+ for i in range(full_result_img.shape[0]):
+ full_result_img[i] = cv2.medianBlur(full_result_img[i], blur_size)
+
+ full_result_img = self.limit_extended_extent_image_to_base_extent_with_mask(full_img=full_result_img)
+
+ self.set_results_img(full_result_img)
+ if gdf_list:
+ final_gdf = gpd.GeoDataFrame(pd.concat(gdf_list, ignore_index=True), crs=gdf_list[0].crs)
+ csv_file = r"C:\Users\carin\Documents\segmen.csv"
+ final_gdf.to_csv(csv_file, index=False)
+ else:
+ final_gdf = None
+ print("No GeoDataFrame generated.")
+
+
+ gui_delegate = self._create_vlayer_from_mask_for_base_extent(self.get_result_img())
+
+ result_message = self._create_result_message(self.get_result_img())
+ return MapProcessingResultSuccess(
+ message=result_message,
+ gui_delegate=gui_delegate,
+ )
+
+ def _check_output_layer_is_sigmoid_and_has_more_than_one_name(self, output_id: int) -> bool:
+ if self.model.outputs_names is None or self.model.outputs_are_sigmoid is None:
+ return False
+
+ return len(self.model.outputs_names[output_id]) > 1 and self.model.outputs_are_sigmoid[output_id]
+
+ def _create_result_message(self, result_img: np.ndarray) -> str:
+
+ txt = f'Segmentation done, with the following statistics:\n'
+
+ for output_id, layer_sizes in enumerate(self._get_indexes_of_model_output_channels_to_create()):
+
+ txt += f'Channels for output {output_id}:\n'
+
+ unique, counts = np.unique(result_img[output_id], return_counts=True)
+ counts_map = {}
+ for i in range(len(unique)):
+ counts_map[unique[i]] = counts[i]
+
+ # # we cannot simply take image dimensions, because we may have irregular processing area from polygon
+ number_of_pixels_in_processing_area = np.sum([counts_map[k] for k in counts_map.keys()])
+ total_area = number_of_pixels_in_processing_area * self.params.resolution_m_per_px**2
+
+ for channel_id in range(layer_sizes):
+ pixels_count = counts_map.get(channel_id + 1, 0) # we add 1 to avoid 0 values, find the MADD1 code for explanation
+ area = pixels_count * self.params.resolution_m_per_px**2
+
+ if total_area > 0 and not np.isnan(total_area) and not np.isinf(total_area):
+ area_percentage = area / total_area * 100
+ else:
+ area_percentage = 0.0
+ # TODO
+
+ txt += f'\t- {self.model.get_channel_name(output_id, channel_id)}: area = {area:.2f} m^2 ({area_percentage:.2f} %)\n'
+
+ return txt
+
+ def _create_vlayer_from_mask_for_base_extent(self, mask_img, vector_gdf: gpd.GeoDataFrame = None) -> Callable:
+ """ create vector layer with polygons from the mask image
+ :return: function to be called in GUI thread
+ """
+ vlayers = []
+
+ for output_id, layer_sizes in enumerate(self._get_indexes_of_model_output_channels_to_create()):
+ output_vlayers = []
+ for channel_id in range(layer_sizes):
+ local_mask_img = np.uint8(mask_img[output_id] == (channel_id + 1)) # we add 1 to avoid 0 values, find the MADD1 code for explanation
+
+ contours, hierarchy = cv2.findContours(local_mask_img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
+ contours = processing_utils.transform_contours_yx_pixels_to_target_crs(
+ contours=contours,
+ extent=self.base_extent,
+ rlayer_units_per_pixel=self.rlayer_units_per_pixel)
+ features = []
+
+ if len(contours):
+ processing_utils.convert_cv_contours_to_features(
+ features=features,
+ cv_contours=contours,
+ hierarchy=hierarchy[0],
+ is_hole=False,
+ current_holes=[],
+ current_contour_index=0)
+ else:
+ pass # just nothing, we already have an empty list of features
+
+ layer_name = self.model.get_channel_name(output_id, channel_id)
+ vlayer = QgsVectorLayer("multipolygon", layer_name, "memory")
+ vlayer.setCrs(self.rlayer.crs())
+ prov = vlayer.dataProvider()
+
+ color = vlayer.renderer().symbol().color()
+ OUTPUT_VLAYER_COLOR_TRANSPARENCY = 80
+ color.setAlpha(OUTPUT_VLAYER_COLOR_TRANSPARENCY)
+ vlayer.renderer().symbol().setColor(color)
+ # TODO - add also outline for the layer (thicker black border)
+
+ prov.addFeatures(features)
+ vlayer.updateExtents()
+
+ output_vlayers.append(vlayer)
+
+ vlayers.append(output_vlayers)
+
+ # accessing GUI from non-GUI thread is not safe, so we need to delegate it to the GUI thread
+ def add_to_gui():
+ group = QgsProject.instance().layerTreeRoot().insertGroup(0, 'model_output')
+
+ if len(vlayers) == 1:
+ for vlayer in vlayers[0]:
+ QgsProject.instance().addMapLayer(vlayer, False)
+ group.addLayer(vlayer)
+ else:
+ for i, output_vlayers in enumerate(vlayers):
+ output_group = group.insertGroup(0, f'output_{i}')
+ for vlayer in output_vlayers:
+ QgsProject.instance().addMapLayer(vlayer, False)
+ output_group.addLayer(vlayer)
+
+ return add_to_gui
+
+ def _process_tile(self, tile_img_batched: np.ndarray) -> np.ndarray:
+
+ res = self.model.process(tile_img_batched)
+
+ # Thresholding optional (apply only on non-background)
+ threshold = self.segmentation_parameters.pixel_classification__probability_threshold
+ # onnx_classes = np.argmax(build, axis=1)[0].astype(np.int8)
+ # confidences = np.max(build, axis=1)[0]
+
+ # onnx_classes[confidences < threshold] = 0
+ # BUILDING_ID = 1
+
+ # final_mask = onnx_classes.copy()
+
+ # final_mask[onnx_classes == BUILDING_ID] = type_build[onnx_classes == BUILDING_ID]
+
+ # final_mask = np.expand_dims(final_mask, axis=(0, 1))
+ final_mask = self.process_dual_batch(res, threshold)
+
+ return final_mask
+
+ def process_dual_batch(self, result, threshold=0.0):
+
+ N = result[0].shape[0]
+ threshold = self.segmentation_parameters.pixel_classification__probability_threshold
+
+ # Run the dual model
+ res = result[0] # (N, num_classes, H, W)
+ segmentation_maps = result[1] # list of N segmentation maps (H, W)
+
+ # Prepare output batch
+ final_batch_masks = np.zeros((N, 1, 512, 512), dtype=np.int8)
+ individual_masks = []
+
+ new_class = {0: 0, 1: 5, 2: 6, 3: 7, 4: 8}
+ BUILDING_ID = 1
+
+ for i in range(N):
+ seg_map = segmentation_maps[i] # (H, W)
+
+ # Map old classes to building classes
+ build_mask = np.zeros_like(seg_map, dtype=np.int8)
+ for k, v in new_class.items():
+ build_mask[seg_map == k] = v
+
+ # ONNX class predictions and confidences
+ onnx_classes = np.argmax(res[i], axis=0).astype(np.int8) # (H, W)
+ confidences = np.max(res[i], axis=0) # (H, W)
+
+ # Threshold low-confidence predictions
+ onnx_classes[confidences < threshold] = 0
+
+ # Merge building predictions with type mask
+ final_mask = onnx_classes.copy()
+ final_mask[onnx_classes == BUILDING_ID] = build_mask[onnx_classes == BUILDING_ID]
+
+ # Save results
+
+ final_batch_masks[i, 0] = final_mask
+
+ return final_batch_masks
+
+
+ # def _process_tile(self, tile_img_batched: np.ndarray) -> np.ndarray:
+ # many_result = self.model.process(tile_img_batched)
+ # many_outputs = []
+
+ # for result in many_result:
+ # result[result < self.segmentation_parameters.pixel_classification__probability_threshold] = 0.0
+
+ # if len(result.shape) == 3:
+ # result = np.expand_dims(result, axis=1)
+
+ # if (result.shape[1] == 1):
+ # result = (result != 0).astype(int) + 1 # we add 1 to avoid 0 values, find the MADD1 code for explanation
+ # else:
+ # shape = result.shape
+ # result = np.argmax(result, axis=1).reshape(shape[0], 1, shape[2], shape[3]) + 1 # we add 1 to avoid 0 values, find the MADD1 code for explanation
+
+ # assert len(result.shape) == 4
+ # assert result.shape[1] == 1
+
+ # many_outputs.append(result[:, 0])
+
+ # many_outputs = np.array(many_outputs).transpose((1, 0, 2, 3))
+
+ # return many_outputs
+
+ # def _process_tile(self, tile_img_batched: np.ndarray) -> np.ndarray:
+
+ # merged_probs = self.model.process(tile_img_batched)
+
+ # # Thresholding optional (apply only on non-background)
+ # threshold = self.segmentation_parameters.pixel_classification__probability_threshold
+ # merged_probs[:, 1:, :, :][merged_probs[:, 1:, :, :] < threshold] = 0.0
+
+ # # Argmax over channels to get single-channel mask
+ # single_channel_mask = np.argmax(merged_probs, axis=1).astype(np.uint8) # shape: (1, H, W)
+
+ # # Remove 'other' class pixels
+ # single_channel_mask[single_channel_mask == 1] = 0
+ # single_channel_mask = np.expand_dims(single_channel_mask, axis=1)
+
+ # return single_channel_mask
+
+
+
diff --git a/zipdeepness/processing/map_processor/map_processor_superresolution.py b/zipdeepness/processing/map_processor/map_processor_superresolution.py
new file mode 100644
index 0000000000000000000000000000000000000000..b1d0d3d5c77260589cc20ffb91d325449dbf050c
--- /dev/null
+++ b/zipdeepness/processing/map_processor/map_processor_superresolution.py
@@ -0,0 +1,175 @@
+""" This file implements map processing for Super Resolution model """
+
+import os
+import uuid
+from typing import List
+
+import numpy as np
+from osgeo import gdal, osr
+from qgis.core import QgsProject, QgsRasterLayer
+
+from deepness.common.misc import TMP_DIR_PATH
+from deepness.common.processing_parameters.superresolution_parameters import SuperresolutionParameters
+from deepness.processing.map_processor.map_processing_result import (MapProcessingResult, MapProcessingResultCanceled,
+ MapProcessingResultSuccess)
+from deepness.processing.map_processor.map_processor_with_model import MapProcessorWithModel
+
+
+class MapProcessorSuperresolution(MapProcessorWithModel):
+ """
+ MapProcessor specialized for Super Resolution model (whic is is used to upscale the input image to a higher resolution)
+ """
+
+ def __init__(self,
+ params: SuperresolutionParameters,
+ **kwargs):
+ super().__init__(
+ params=params,
+ model=params.model,
+ **kwargs)
+ self.superresolution_parameters = params
+ self.model = params.model
+
+ def _run(self) -> MapProcessingResult:
+ number_of_output_channels = self.model.get_number_of_output_channels()
+
+ # always one output
+ number_of_output_channels = number_of_output_channels[0]
+
+ final_shape_px = (int(self.img_size_y_pixels*self.superresolution_parameters.scale_factor), int(self.img_size_x_pixels*self.superresolution_parameters.scale_factor), number_of_output_channels)
+
+ # NOTE: consider whether we can use float16/uint16 as datatype
+ full_result_imgs = self._get_array_or_mmapped_array(final_shape_px)
+
+ for tile_img_batched, tile_params_batched in self.tiles_generator_batched():
+ if self.isCanceled():
+ return MapProcessingResultCanceled()
+
+ tile_results_batched = self._process_tile(tile_img_batched)
+
+ for tile_results, tile_params in zip(tile_results_batched, tile_params_batched):
+ full_result_imgs[int(tile_params.start_pixel_y*self.superresolution_parameters.scale_factor):int((tile_params.start_pixel_y+tile_params.stride_px)*self.superresolution_parameters.scale_factor),
+ int(tile_params.start_pixel_x*self.superresolution_parameters.scale_factor):int((tile_params.start_pixel_x+tile_params.stride_px)*self.superresolution_parameters.scale_factor),
+ :] = tile_results.transpose(1, 2, 0) # transpose to chanels last
+
+ # plt.figure(); plt.imshow(full_result_img); plt.show(block=False); plt.pause(0.001)
+ full_result_imgs = self.limit_extended_extent_image_to_base_extent_with_mask(full_img=full_result_imgs)
+ self.set_results_img(full_result_imgs)
+
+ gui_delegate = self._create_rlayers_from_images_for_base_extent(self.get_result_img())
+ result_message = self._create_result_message(self.get_result_img())
+ return MapProcessingResultSuccess(
+ message=result_message,
+ gui_delegate=gui_delegate,
+ )
+
+ def _create_result_message(self, result_img: List[np.ndarray]) -> str:
+ channels = self._get_indexes_of_model_output_channels_to_create()
+ txt = f'Super-resolution done \n'
+
+ if len(channels) > 0:
+ total_area = result_img.shape[0] * result_img.shape[1] * (self.params.resolution_m_per_px / self.superresolution_parameters.scale_factor)**2
+ txt += f'Total are is {total_area:.2f} m^2'
+ return txt
+
+ def limit_extended_extent_image_to_base_extent_with_mask(self, full_img):
+ """
+ Limit an image which is for extended_extent to the base_extent image.
+ If a limiting polygon was used for processing, it will be also applied.
+ :param full_img:
+ :return:
+ """
+ # TODO look for some inplace operation to save memory
+ # cv2.copyTo(src=full_img, mask=area_mask_img, dst=full_img) # this doesn't work due to implementation details
+ # full_img = cv2.copyTo(src=full_img, mask=self.area_mask_img)
+
+ b = self.base_extent_bbox_in_full_image
+ result_img = full_img[int(b.y_min*self.superresolution_parameters.scale_factor):int(b.y_max*self.superresolution_parameters.scale_factor),
+ int(b.x_min*self.superresolution_parameters.scale_factor):int(b.x_max*self.superresolution_parameters.scale_factor),
+ :]
+ return result_img
+
+ def load_rlayer_from_file(self, file_path):
+ """
+ Create raster layer from tif file
+ """
+ file_name = os.path.basename(file_path)
+ base_file_name = file_name.split('___')[0] # we remove the random_id string we created a moment ago
+ rlayer = QgsRasterLayer(file_path, base_file_name)
+ if rlayer.width() == 0:
+ raise Exception("0 width - rlayer not loaded properly. Probably invalid file path?")
+ rlayer.setCrs(self.rlayer.crs())
+ return rlayer
+
+ def _create_rlayers_from_images_for_base_extent(self, result_imgs: List[np.ndarray]):
+ # TODO: We are creating a new file for each layer.
+ # Maybe can we pass ownership of this file to QGis?
+ # Or maybe even create vlayer directly from array, without a file?
+ rlayers = []
+
+ for i, channel_id in enumerate(['Super Resolution']):
+ result_img = result_imgs
+ random_id = str(uuid.uuid4()).replace('-', '')
+ file_path = os.path.join(TMP_DIR_PATH, f'{channel_id}___{random_id}.tif')
+ self.save_result_img_as_tif(file_path=file_path, img=result_img)
+
+ rlayer = self.load_rlayer_from_file(file_path)
+ OUTPUT_RLAYER_OPACITY = 0.5
+ rlayer.renderer().setOpacity(OUTPUT_RLAYER_OPACITY)
+ rlayers.append(rlayer)
+
+ def add_to_gui():
+ group = QgsProject.instance().layerTreeRoot().insertGroup(0, 'Super Resolution Results')
+ for rlayer in rlayers:
+ QgsProject.instance().addMapLayer(rlayer, False)
+ group.addLayer(rlayer)
+
+ return add_to_gui
+
+ def save_result_img_as_tif(self, file_path: str, img: np.ndarray):
+ """
+ As we cannot pass easily an numpy array to be displayed as raster layer, we create temporary geotif files,
+ which will be loaded as layer later on
+
+ Partially based on example from:
+ https://gis.stackexchange.com/questions/82031/gdal-python-set-projection-of-a-raster-not-working
+ """
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
+
+ extent = self.base_extent
+ crs = self.rlayer.crs()
+
+ geo_transform = [extent.xMinimum(), self.rlayer_units_per_pixel/self.superresolution_parameters.scale_factor, 0,
+ extent.yMaximum(), 0, -self.rlayer_units_per_pixel/self.superresolution_parameters.scale_factor]
+
+ driver = gdal.GetDriverByName('GTiff')
+ n_lines = img.shape[0]
+ n_cols = img.shape[1]
+ n_chanels = img.shape[2]
+ # data_type = gdal.GDT_Byte
+ data_type = gdal.GDT_Float32
+ grid_data = driver.Create('grid_data', n_cols, n_lines, n_chanels, data_type) # , options)
+ # loop over chanels
+ for i in range(1, img.shape[2]+1):
+ grid_data.GetRasterBand(i).WriteArray(img[:, :, i-1])
+
+ # crs().srsid() - maybe we can use the ID directly - but how?
+ # srs.ImportFromEPSG()
+ srs = osr.SpatialReference()
+ srs.SetFromUserInput(crs.authid())
+
+ grid_data.SetProjection(srs.ExportToWkt())
+ grid_data.SetGeoTransform(geo_transform)
+ driver.CreateCopy(file_path, grid_data, 0)
+ print(f'***** {file_path = }')
+
+ def _process_tile(self, tile_img: np.ndarray) -> np.ndarray:
+ result = self.model.process(tile_img)
+ result[np.isnan(result)] = 0
+ result *= self.superresolution_parameters.output_scaling
+
+ # NOTE - currently we are saving result as float32, so we are losing some accuraccy.
+ # result = np.clip(result, 0, 255) # old version with uint8_t - not used anymore
+ result = result.astype(np.float32)
+
+ return result
diff --git a/zipdeepness/processing/map_processor/map_processor_training_data_export.py b/zipdeepness/processing/map_processor/map_processor_training_data_export.py
new file mode 100644
index 0000000000000000000000000000000000000000..a4996ee3834fc29da3c74bd617930f63e9b7bb79
--- /dev/null
+++ b/zipdeepness/processing/map_processor/map_processor_training_data_export.py
@@ -0,0 +1,95 @@
+""" This file implements map processing for the Training Data Export Tool """
+
+import datetime
+import os
+
+import numpy as np
+from qgis.core import QgsProject
+
+from deepness.common.lazy_package_loader import LazyPackageLoader
+from deepness.common.processing_parameters.training_data_export_parameters import TrainingDataExportParameters
+from deepness.processing import processing_utils
+from deepness.processing.map_processor.map_processing_result import (MapProcessingResultCanceled,
+ MapProcessingResultSuccess)
+from deepness.processing.map_processor.map_processor import MapProcessor
+from deepness.processing.tile_params import TileParams
+
+cv2 = LazyPackageLoader('cv2')
+
+
+class MapProcessorTrainingDataExport(MapProcessor):
+ """
+ Map Processor specialized in exporting training data, not doing any prediction with model.
+ Exports tiles for the ortophoto and a mask layer.
+ """
+
+ def __init__(self,
+ params: TrainingDataExportParameters,
+ **kwargs):
+ super().__init__(
+ params=params,
+ **kwargs)
+ self.params = params
+ self.output_dir_path = self._create_output_dir()
+
+ def _create_output_dir(self) -> str:
+ datetime_string = datetime.datetime.now().strftime("%d%m%Y_%H%M%S")
+ full_path = os.path.join(self.params.output_directory_path, datetime_string)
+ os.makedirs(full_path, exist_ok=True)
+ return full_path
+
+ def _run(self):
+ export_segmentation_mask = self.params.segmentation_mask_layer_id is not None
+ if export_segmentation_mask:
+ vlayer_segmentation = QgsProject.instance().mapLayers()[self.params.segmentation_mask_layer_id]
+ vlayer_segmentation.setCrs(self.rlayer.crs())
+ segmentation_mask_full = processing_utils.create_area_mask_image(
+ rlayer=self.rlayer,
+ vlayer_mask=vlayer_segmentation,
+ extended_extent=self.extended_extent,
+ rlayer_units_per_pixel=self.rlayer_units_per_pixel,
+ image_shape_yx=(self.img_size_y_pixels, self.img_size_x_pixels),
+ files_handler=self.file_handler)
+
+ segmentation_mask_full = segmentation_mask_full[np.newaxis, ...]
+
+ number_of_written_tiles = 0
+ for tile_img, tile_params in self.tiles_generator():
+ if self.isCanceled():
+ return MapProcessingResultCanceled()
+
+ tile_params = tile_params # type: TileParams
+
+ if self.params.export_image_tiles:
+ file_name = f'tile_img_{tile_params.x_bin_number}_{tile_params.y_bin_number}.png'
+ file_path = os.path.join(self.output_dir_path, file_name)
+
+ if tile_img.dtype in [np.uint32, np.int32]:
+ print(f'Exporting image with data type {tile_img.dtype} is not supported. Trimming to uint16. Consider changing the data type in the source image.')
+ tile_img = tile_img.astype(np.uint16)
+
+ if tile_img.shape[-1] == 4:
+ tile_img = cv2.cvtColor(tile_img, cv2.COLOR_RGBA2BGRA)
+ elif tile_img.shape[-1] == 3:
+ tile_img = cv2.cvtColor(tile_img, cv2.COLOR_RGB2BGR)
+
+ cv2.imwrite(file_path, tile_img)
+ number_of_written_tiles += 1
+
+ if export_segmentation_mask:
+ segmentation_mask_for_tile = tile_params.get_entire_tile_from_full_img(segmentation_mask_full)
+
+ file_name = f'tile_mask_{tile_params.x_bin_number}_{tile_params.y_bin_number}.png'
+ file_path = os.path.join(self.output_dir_path, file_name)
+
+ cv2.imwrite(file_path, segmentation_mask_for_tile[0])
+
+ result_message = self._create_result_message(number_of_written_tiles)
+ return MapProcessingResultSuccess(result_message)
+
+ def _create_result_message(self, number_of_written_tiles) -> str:
+ total_area = self.img_size_x_pixels * self.img_size_y_pixels * self.params.resolution_m_per_px**2
+ return f'Exporting data finished!\n' \
+ f'Exported {number_of_written_tiles} tiles.\n' \
+ f'Total processed area: {total_area:.2f} m^2\n' \
+ f'Directory: "{self.output_dir_path}"'
diff --git a/zipdeepness/processing/map_processor/map_processor_with_model.py b/zipdeepness/processing/map_processor/map_processor_with_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..f56a319f5ae98df720c56955508317525fd0c503
--- /dev/null
+++ b/zipdeepness/processing/map_processor/map_processor_with_model.py
@@ -0,0 +1,27 @@
+
+""" This file implements map processing functions common for all map processors using nural model """
+
+from typing import List
+
+from deepness.processing.map_processor.map_processor import MapProcessor
+from deepness.processing.models.model_base import ModelBase
+
+
+class MapProcessorWithModel(MapProcessor):
+ """
+ Common base class for MapProcessor with models
+ """
+
+ def __init__(self,
+ model: ModelBase,
+ **kwargs):
+ super().__init__(
+ **kwargs)
+ self.model = model
+
+ def _get_indexes_of_model_output_channels_to_create(self) -> List[int]:
+ """
+ Decide what model output channels/classes we want to use at presentation level
+ (e.g. for which channels create a layer with results)
+ """
+ return self.model.get_number_of_output_channels()
diff --git a/zipdeepness/processing/map_processor/utils/ckdtree.py b/zipdeepness/processing/map_processor/utils/ckdtree.py
new file mode 100644
index 0000000000000000000000000000000000000000..3577a21159b2a7c2b75080625661e4647ef1c210
--- /dev/null
+++ b/zipdeepness/processing/map_processor/utils/ckdtree.py
@@ -0,0 +1,62 @@
+import heapq
+
+import numpy as np
+
+
+class cKDTree:
+ def __init__(self, data):
+ self.data = np.asarray(data)
+ self.tree = self._build_kdtree(np.arange(len(data)))
+
+ def _build_kdtree(self, indices, depth=0):
+ if len(indices) == 0:
+ return None
+ axis = depth % self.data.shape[1] # alternate between x and y dimensions
+
+ sorted_indices = indices[np.argsort(self.data[indices, axis])]
+ mid = len(sorted_indices) // 2
+ node = {
+ 'index': sorted_indices[mid],
+ 'left': self._build_kdtree(sorted_indices[:mid], depth + 1),
+ 'right': self._build_kdtree(sorted_indices[mid + 1:], depth + 1)
+ }
+ return node
+
+ def query(self, point: np.ndarray, k: int):
+ if type(point) is not np.ndarray:
+ point = np.array(point)
+
+ return [index for _, index in self._query(point, k, self.tree)]
+
+ def _query(self, point, k, node, depth=0, best_indices=None, best_distances=None):
+ if node is None:
+ return None
+
+ axis = depth % self.data.shape[1]
+
+ if point[axis] < self.data[node['index']][axis]:
+ next_node = node['left']
+ other_node = node['right']
+ else:
+ next_node = node['right']
+ other_node = node['left']
+
+ if best_indices is None:
+ best_indices = []
+ best_distances = []
+
+ current_distance = np.linalg.norm(self.data[node['index']] - point)
+
+ if len(best_indices) < k:
+ heapq.heappush(best_indices, (-current_distance, node['index']))
+ elif current_distance < -best_indices[0][0]:
+ heapq.heappop(best_indices)
+ heapq.heappush(best_indices, (-current_distance, node['index']))
+
+ if point[axis] < self.data[node['index']][axis] or len(best_indices) < k or abs(point[axis] - self.data[node['index']][axis]) < -best_indices[0][0]:
+ self._query(point, k, next_node, depth + 1, best_indices, best_distances)
+
+ if point[axis] >= self.data[node['index']][axis] or len(best_indices) < k or abs(point[axis] - self.data[node['index']][axis]) < -best_indices[0][0]:
+ self._query(point, k, other_node, depth + 1, best_indices, best_distances)
+
+ return best_indices
diff --git a/zipdeepness/processing/models/__init__.py b/zipdeepness/processing/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..d080009a194567252f67421a85ea8ac478e3b030
--- /dev/null
+++ b/zipdeepness/processing/models/__init__.py
@@ -0,0 +1,2 @@
+""" Module including classes implemetations for the deep learning inference and related functions
+"""
\ No newline at end of file
diff --git a/zipdeepness/processing/models/buildings_type_MA.onnx b/zipdeepness/processing/models/buildings_type_MA.onnx
new file mode 100644
index 0000000000000000000000000000000000000000..d4c0d930a26ece468401dfd85f7350a4a26b92f0
--- /dev/null
+++ b/zipdeepness/processing/models/buildings_type_MA.onnx
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4e7c06401e20f6791e272f5ec694d4ae285c79ed6ceb9213875972114869b90f
+size 464134848
diff --git a/zipdeepness/processing/models/detector.py b/zipdeepness/processing/models/detector.py
new file mode 100644
index 0000000000000000000000000000000000000000..474802d5ca4a5b5ad070456ade469a35fbfb5eb3
--- /dev/null
+++ b/zipdeepness/processing/models/detector.py
@@ -0,0 +1,709 @@
+""" Module including the class for the object detection task and related functions
+"""
+from dataclasses import dataclass
+from typing import List, Optional, Tuple
+from qgis.core import Qgis, QgsGeometry, QgsRectangle, QgsPointXY
+
+import cv2
+import numpy as np
+
+from deepness.common.processing_parameters.detection_parameters import DetectorType
+from deepness.processing.models.model_base import ModelBase
+from deepness.processing.processing_utils import BoundingBox
+
+
+@dataclass
+class Detection:
+ """Class that represents single detection result in object detection model
+
+ Parameters
+ ----------
+ bbox : BoundingBox
+ bounding box describing the detection rectangle
+ conf : float
+ confidence of the detection
+ clss : int
+ class of the detected object
+ """
+
+ bbox: BoundingBox
+ """BoundingBox: bounding box describing the detection rectangle"""
+ conf: float
+ """float: confidence of the detection"""
+ clss: int
+ """int: class of the detected object"""
+ mask: Optional[np.ndarray] = None
+ """np.ndarray: mask of the detected object"""
+ mask_offsets: Optional[Tuple[int, int]] = None
+ """Tuple[int, int]: offsets of the mask"""
+
+ def convert_to_global(self, offset_x: int, offset_y: int):
+ """Apply (x,y) offset to bounding box coordinates
+
+ Parameters
+ ----------
+ offset_x : int
+ _description_
+ offset_y : int
+ _description_
+ """
+ self.bbox.apply_offset(offset_x=offset_x, offset_y=offset_y)
+
+ if self.mask is not None:
+ self.mask_offsets = (offset_x, offset_y)
+
+ def get_bbox_xyxy(self) -> np.ndarray:
+ """Convert stored bounding box into x1y1x2y2 format
+
+ Returns
+ -------
+ np.ndarray
+ Array in (x1, y1, x2, y2) format
+ """
+ return self.bbox.get_xyxy()
+
+ def get_bbox_xyxy_rot(self) -> np.ndarray:
+ """Convert stored bounding box into x1y1x2y2r format
+
+ Returns
+ -------
+ np.ndarray
+ Array in (x1, y1, x2, y2, r) format
+ """
+ return self.bbox.get_xyxy_rot()
+
+ def get_bbox_center(self) -> Tuple[int, int]:
+ """Get center of the bounding box
+
+ Returns
+ -------
+ Tuple[int, int]
+ Center of the bounding box
+ """
+ return self.bbox.get_center()
+
+ def __lt__(self, other):
+ return self.bbox.get_area() < other.bbox.get_area()
+
+
+class Detector(ModelBase):
+ """Class implements object detection features
+
+ Detector model is used for detection of objects in images. It is based on YOLOv5/YOLOv7 models style.
+ """
+
+ def __init__(self, model_file_path: str):
+ """Initialize object detection model
+
+ Parameters
+ ----------
+ model_file_path : str
+ Path to model file"""
+ super(Detector, self).__init__(model_file_path)
+
+ self.confidence = None
+ """float: Confidence threshold"""
+ self.iou_threshold = None
+ """float: IoU threshold"""
+ self.model_type: Optional[DetectorType] = None
+ """DetectorType: Model type"""
+
+ def set_inference_params(self, confidence: float, iou_threshold: float):
+ """Set inference parameters
+
+ Parameters
+ ----------
+ confidence : float
+ Confidence threshold
+ iou_threshold : float
+ IoU threshold
+ """
+ self.confidence = confidence
+ self.iou_threshold = iou_threshold
+
+ def set_model_type_param(self, model_type: DetectorType):
+ """Set model type parameters
+
+ Parameters
+ ----------
+ model_type : str
+ Model type
+ """
+ self.model_type = model_type
+
+ @classmethod
+ def get_class_display_name(cls):
+ """Get class display name
+
+ Returns
+ -------
+ str
+ Class display name"""
+ return cls.__name__
+
+ def get_number_of_output_channels(self):
+ """Get number of output channels
+
+ Returns
+ -------
+ int
+ Number of output channels
+ """
+ class_names = self.get_outputs_channel_names()[0]
+ if class_names is not None:
+ return [len(class_names)] # If class names are specified, we expect to have exactly this number of channels as specidied
+
+ model_type_params = self.model_type.get_parameters()
+
+ shape_index = -2 if model_type_params.has_inverted_output_shape else -1
+
+ if len(self.outputs_layers) == 1:
+ # YOLO_ULTRALYTICS_OBB
+ if self.model_type == DetectorType.YOLO_ULTRALYTICS_OBB:
+ return [self.outputs_layers[0].shape[shape_index] - 4 - 1]
+
+ elif model_type_params.skipped_objectness_probability:
+ return [self.outputs_layers[0].shape[shape_index] - 4]
+
+ return [self.outputs_layers[0].shape[shape_index] - 4 - 1] # shape - 4 bboxes - 1 conf
+
+ # YOLO_ULTRALYTICS_SEGMENTATION
+ elif len(self.outputs_layers) == 2 and self.model_type == DetectorType.YOLO_ULTRALYTICS_SEGMENTATION:
+ return [self.outputs_layers[0].shape[shape_index] - 4 - self.outputs_layers[1].shape[1]]
+
+ else:
+ raise NotImplementedError("Model with multiple output layer is not supported! Use only one output layer.")
+
+ def postprocessing(self, model_output):
+ """Postprocess model output
+
+ NOTE: Maybe refactor this, as it has many added layers of checks which can be simplified.
+
+ Parameters
+ ----------
+ model_output : list
+ Model output
+
+ Returns
+ -------
+ list
+ Batch of lists of detections
+ """
+ if self.confidence is None or self.iou_threshold is None:
+ return Exception(
+ "Confidence or IOU threshold is not set for model. Use self.set_inference_params"
+ )
+
+ if self.model_type is None:
+ return Exception(
+ "Model type is not set for model. Use self.set_model_type_param"
+ )
+
+ batch_detection = []
+ outputs_range = len(model_output)
+
+ if self.model_type == DetectorType.YOLO_ULTRALYTICS_SEGMENTATION or self.model_type == DetectorType.YOLO_v9:
+ outputs_range = len(model_output[0])
+
+ for i in range(outputs_range):
+ masks = None
+ rots = None
+ detections = []
+
+ if self.model_type == DetectorType.YOLO_v5_v7_DEFAULT:
+ boxes, conf, classes = self._postprocessing_YOLO_v5_v7_DEFAULT(model_output[0][i])
+ elif self.model_type == DetectorType.YOLO_v6:
+ boxes, conf, classes = self._postprocessing_YOLO_v6(model_output[0][i])
+ elif self.model_type == DetectorType.YOLO_v9:
+ boxes, conf, classes = self._postprocessing_YOLO_v9(model_output[0][i])
+ elif self.model_type == DetectorType.YOLO_ULTRALYTICS:
+ boxes, conf, classes = self._postprocessing_YOLO_ULTRALYTICS(model_output[0][i])
+ elif self.model_type == DetectorType.YOLO_ULTRALYTICS_SEGMENTATION:
+ boxes, conf, classes, masks = self._postprocessing_YOLO_ULTRALYTICS_SEGMENTATION(model_output[0][i], model_output[1][i])
+ elif self.model_type == DetectorType.YOLO_ULTRALYTICS_OBB:
+ boxes, conf, classes, rots = self._postprocessing_YOLO_ULTRALYTICS_OBB(model_output[0][i])
+ else:
+ raise NotImplementedError(f"Model type not implemented! ('{self.model_type}')")
+
+ masks = masks if masks is not None else [None] * len(boxes)
+ rots = rots if rots is not None else [0.0] * len(boxes)
+
+ for b, c, cl, m, r in zip(boxes, conf, classes, masks, rots):
+ det = Detection(
+ bbox=BoundingBox(
+ x_min=b[0],
+ x_max=b[2],
+ y_min=b[1],
+ y_max=b[3],
+ rot=r),
+ conf=c,
+ clss=cl,
+ mask=m,
+ )
+ detections.append(det)
+
+ batch_detection.append(detections)
+
+ return batch_detection
+
+ def _postprocessing_YOLO_v5_v7_DEFAULT(self, model_output):
+ outputs_filtered = np.array(
+ list(filter(lambda x: x[4] >= self.confidence, model_output))
+ )
+
+ if len(outputs_filtered.shape) < 2:
+ return [], [], []
+
+ probabilities = outputs_filtered[:, 4]
+
+ outputs_x1y1x2y2 = self.xywh2xyxy(outputs_filtered)
+
+ pick_indxs = self.non_max_suppression_fast(
+ outputs_x1y1x2y2,
+ probs=probabilities,
+ iou_threshold=self.iou_threshold)
+
+ outputs_nms = outputs_x1y1x2y2[pick_indxs]
+
+ boxes = np.array(outputs_nms[:, :4], dtype=int)
+ conf = outputs_nms[:, 4]
+ classes = np.argmax(outputs_nms[:, 5:], axis=1)
+
+ return boxes, conf, classes
+
+ def _postprocessing_YOLO_v6(self, model_output):
+ outputs_filtered = np.array(
+ list(filter(lambda x: np.max(x[5:]) >= self.confidence, model_output))
+ )
+
+ if len(outputs_filtered.shape) < 2:
+ return [], [], []
+
+ probabilities = outputs_filtered[:, 4]
+
+ outputs_x1y1x2y2 = self.xywh2xyxy(outputs_filtered)
+
+ pick_indxs = self.non_max_suppression_fast(
+ outputs_x1y1x2y2,
+ probs=probabilities,
+ iou_threshold=self.iou_threshold)
+
+ outputs_nms = outputs_x1y1x2y2[pick_indxs]
+
+ boxes = np.array(outputs_nms[:, :4], dtype=int)
+ conf = np.max(outputs_nms[:, 5:], axis=1)
+ classes = np.argmax(outputs_nms[:, 5:], axis=1)
+
+ return boxes, conf, classes
+
+ def _postprocessing_YOLO_v9(self, model_output):
+ model_output = np.transpose(model_output, (1, 0))
+
+ outputs_filtered = np.array(
+ list(filter(lambda x: np.max(x[4:]) >= self.confidence, model_output))
+ )
+
+ if len(outputs_filtered.shape) < 2:
+ return [], [], []
+
+ probabilities = np.max(outputs_filtered[:, 4:], axis=1)
+
+ outputs_x1y1x2y2 = self.xywh2xyxy(outputs_filtered)
+
+ pick_indxs = self.non_max_suppression_fast(
+ outputs_x1y1x2y2,
+ probs=probabilities,
+ iou_threshold=self.iou_threshold)
+
+ outputs_nms = outputs_x1y1x2y2[pick_indxs]
+
+ boxes = np.array(outputs_nms[:, :4], dtype=int)
+ conf = np.max(outputs_nms[:, 4:], axis=1)
+ classes = np.argmax(outputs_nms[:, 4:], axis=1)
+
+ return boxes, conf, classes
+
+ def _postprocessing_YOLO_ULTRALYTICS(self, model_output):
+ model_output = np.transpose(model_output, (1, 0))
+
+ outputs_filtered = np.array(
+ list(filter(lambda x: np.max(x[4:]) >= self.confidence, model_output))
+ )
+
+ if len(outputs_filtered.shape) < 2:
+ return [], [], []
+
+ probabilities = np.max(outputs_filtered[:, 4:], axis=1)
+
+ outputs_x1y1x2y2 = self.xywh2xyxy(outputs_filtered)
+
+ pick_indxs = self.non_max_suppression_fast(
+ outputs_x1y1x2y2,
+ probs=probabilities,
+ iou_threshold=self.iou_threshold)
+
+ outputs_nms = outputs_x1y1x2y2[pick_indxs]
+
+ boxes = np.array(outputs_nms[:, :4], dtype=int)
+ conf = np.max(outputs_nms[:, 4:], axis=1)
+ classes = np.argmax(outputs_nms[:, 4:], axis=1)
+
+ return boxes, conf, classes
+
+ def _postprocessing_YOLO_ULTRALYTICS_SEGMENTATION(self, detections, protos):
+ detections = np.transpose(detections, (1, 0))
+
+ number_of_class = self.get_number_of_output_channels()[0]
+ mask_start_index = 4 + number_of_class
+
+ outputs_filtered = np.array(
+ list(filter(lambda x: np.max(x[4:4+number_of_class]) >= self.confidence, detections))
+ )
+
+ if len(outputs_filtered.shape) < 2:
+ return [], [], [], []
+
+ probabilities = np.max(outputs_filtered[:, 4:4+number_of_class], axis=1)
+
+ outputs_x1y1x2y2 = self.xywh2xyxy(outputs_filtered)
+
+ pick_indxs = self.non_max_suppression_fast(
+ outputs_x1y1x2y2,
+ probs=probabilities,
+ iou_threshold=self.iou_threshold)
+
+ outputs_nms = outputs_x1y1x2y2[pick_indxs]
+
+ boxes = np.array(outputs_nms[:, :4], dtype=int)
+ conf = np.max(outputs_nms[:, 4:4+number_of_class], axis=1)
+ classes = np.argmax(outputs_nms[:, 4:4+number_of_class], axis=1)
+ masks_in = np.array(outputs_nms[:, mask_start_index:], dtype=float)
+
+ masks = self.process_mask(protos, masks_in, boxes)
+
+ return boxes, conf, classes, masks
+
+ def _postprocessing_YOLO_ULTRALYTICS_OBB(self, model_output):
+ model_output = np.transpose(model_output, (1, 0))
+
+ outputs_filtered = np.array(
+ list(filter(lambda x: np.max(x[4:-1]) >= self.confidence, model_output))
+ )
+
+ if len(outputs_filtered.shape) < 2:
+ return [], [], [], []
+
+ probabilities = np.max(outputs_filtered[:, 4:-1], axis=1)
+ rotations = outputs_filtered[:, -1]
+
+ outputs_x1y1x2y2_rot = self.xywhr2xyxyr(outputs_filtered, rotations)
+
+ pick_indxs = self.non_max_suppression_fast(
+ outputs_x1y1x2y2_rot,
+ probs=probabilities,
+ iou_threshold=self.iou_threshold,
+ with_rot=True)
+
+ outputs_nms = outputs_x1y1x2y2_rot[pick_indxs]
+
+ boxes = np.array(outputs_nms[:, :4], dtype=int)
+ conf = np.max(outputs_nms[:, 4:-1], axis=1)
+ classes = np.argmax(outputs_nms[:, 4:-1], axis=1)
+ rots = outputs_nms[:, -1]
+
+ return boxes, conf, classes, rots
+
+ # based on https://github.com/ultralytics/ultralytics/blob/main/ultralytics/utils/ops.py#L638C1-L638C67
+ def process_mask(self, protos, masks_in, bboxes):
+ c, mh, mw = protos.shape # CHW
+ ih, iw = self.input_shape[2:]
+
+ masks = self.sigmoid(np.matmul(masks_in, protos.astype(float).reshape(c, -1))).reshape(-1, mh, mw)
+
+ downsampled_bboxes = bboxes.copy().astype(float)
+ downsampled_bboxes[:, 0] *= mw / iw
+ downsampled_bboxes[:, 2] *= mw / iw
+ downsampled_bboxes[:, 3] *= mh / ih
+ downsampled_bboxes[:, 1] *= mh / ih
+
+ masks = self.crop_mask(masks, downsampled_bboxes)
+ scaled_masks = np.zeros((len(masks), ih, iw))
+
+ for i in range(len(masks)):
+ scaled_masks[i] = cv2.resize(masks[i], (iw, ih), interpolation=cv2.INTER_LINEAR)
+
+ masks = np.uint8(scaled_masks >= 0.5)
+
+ return masks
+
+ @staticmethod
+ def sigmoid(x):
+ return 1 / (1 + np.exp(-x))
+
+ @staticmethod
+ # based on https://github.com/ultralytics/ultralytics/blob/main/ultralytics/utils/ops.py#L598C1-L614C65
+ def crop_mask(masks, boxes):
+ n, h, w = masks.shape
+ x1, y1, x2, y2 = np.split(boxes[:, :, None], 4, axis=1) # x1 shape(n,1,1)
+ r = np.arange(w, dtype=x1.dtype)[None, None, :] # rows shape(1,1,w)
+ c = np.arange(h, dtype=x1.dtype)[None, :, None] # cols shape(1,h,1)
+
+ return masks * ((r >= x1) * (r < x2) * (c >= y1) * (c < y2))
+
+ @staticmethod
+ def xywh2xyxy(x: np.ndarray) -> np.ndarray:
+ """Convert bounding box from (x,y,w,h) to (x1,y1,x2,y2) format
+
+ Parameters
+ ----------
+ x : np.ndarray
+ Bounding box in (x,y,w,h) format with classes' probabilities
+
+ Returns
+ -------
+ np.ndarray
+ Bounding box in (x1,y1,x2,y2) format
+ """
+ y = np.copy(x)
+ y[:, 0] = x[:, 0] - x[:, 2] / 2 # top left x
+ y[:, 1] = x[:, 1] - x[:, 3] / 2 # top left y
+ y[:, 2] = x[:, 0] + x[:, 2] / 2 # bottom right x
+ y[:, 3] = x[:, 1] + x[:, 3] / 2 # bottom right y
+ return y
+
+ @staticmethod
+ def xywhr2xyxyr(bbox: np.ndarray, rot: np.ndarray) -> np.ndarray:
+ """Convert bounding box from (x,y,w,h,r) to (x1,y1,x2,y2,r) format, keeping rotated boxes in range [0, pi/2]
+
+ Parameters
+ ----------
+ bbox : np.ndarray
+ Bounding box in (x,y,w,h) format with classes' probabilities and rotations
+
+ Returns
+ -------
+ np.ndarray
+ Bounding box in (x1,y1,x2,y2,r) format, keep classes' probabilities
+ """
+ x, y, w, h = bbox[:, 0], bbox[:, 1], bbox[:, 2], bbox[:, 3]
+
+ w_ = np.where(w > h, w, h)
+ h_ = np.where(w > h, h, w)
+ r_ = np.where(w > h, rot, rot + np.pi / 2) % np.pi
+
+ new_bbox_xywh = np.stack([x, y, w_, h_], axis=1)
+ new_bbox_xyxy = Detector.xywh2xyxy(new_bbox_xywh)
+
+ return np.concatenate([new_bbox_xyxy, bbox[:, 4:-1], r_[:, None]], axis=1)
+
+
+ @staticmethod
+ def non_max_suppression_fast(boxes: np.ndarray, probs: np.ndarray, iou_threshold: float, with_rot: bool = False) -> List:
+ """Apply non-maximum suppression to bounding boxes
+
+ Based on:
+ https://github.com/amusi/Non-Maximum-Suppression/blob/master/nms.py
+
+ Parameters
+ ----------
+ boxes : np.ndarray
+ Bounding boxes in (x1,y1,x2,y2) format or (x1,y1,x2,y2,r) format if with_rot is True
+ probs : np.ndarray
+ Confidence scores
+ iou_threshold : float
+ IoU threshold
+ with_rot: bool
+ If True, use rotated IoU
+
+ Returns
+ -------
+ List
+ List of indexes of bounding boxes to keep
+ """
+ # If no bounding boxes, return empty list
+ if len(boxes) == 0:
+ return []
+
+ # Bounding boxes
+ boxes = np.array(boxes)
+
+ # coordinates of bounding boxes
+ start_x = boxes[:, 0]
+ start_y = boxes[:, 1]
+ end_x = boxes[:, 2]
+ end_y = boxes[:, 3]
+
+ if with_rot:
+ # Rotations of bounding boxes
+ rotations = boxes[:, 4]
+
+ # Confidence scores of bounding boxes
+ score = np.array(probs)
+
+ # Picked bounding boxes
+ picked_boxes = []
+
+ # Compute areas of bounding boxes
+ areas = (end_x - start_x + 1) * (end_y - start_y + 1)
+
+ # Sort by confidence score of bounding boxes
+ order = np.argsort(score)
+
+ # Iterate bounding boxes
+ while order.size > 0:
+ # The index of largest confidence score
+ index = order[-1]
+
+ # Pick the bounding box with largest confidence score
+ picked_boxes.append(index)
+
+ if not with_rot:
+ ratio = Detector.compute_iou(index, order, start_x, start_y, end_x, end_y, areas)
+ else:
+ ratio = Detector.compute_rotated_iou(index, order, start_x, start_y, end_x, end_y, rotations, areas)
+
+ left = np.where(ratio < iou_threshold)
+ order = order[left]
+
+ return picked_boxes
+
+ @staticmethod
+ def compute_iou(index: int, order: np.ndarray, start_x: np.ndarray, start_y: np.ndarray, end_x: np.ndarray, end_y: np.ndarray, areas: np.ndarray) -> np.ndarray:
+ """Compute IoU for bounding boxes
+
+ Parameters
+ ----------
+ index : int
+ Index of the bounding box
+ order : np.ndarray
+ Order of bounding boxes
+ start_x : np.ndarray
+ Start x coordinate of bounding boxes
+ start_y : np.ndarray
+ Start y coordinate of bounding boxes
+ end_x : np.ndarray
+ End x coordinate of bounding boxes
+ end_y : np.ndarray
+ End y coordinate of bounding boxes
+ areas : np.ndarray
+ Areas of bounding boxes
+
+ Returns
+ -------
+ np.ndarray
+ IoU values
+ """
+
+ # Compute ordinates of intersection-over-union(IOU)
+ x1 = np.maximum(start_x[index], start_x[order[:-1]])
+ x2 = np.minimum(end_x[index], end_x[order[:-1]])
+ y1 = np.maximum(start_y[index], start_y[order[:-1]])
+ y2 = np.minimum(end_y[index], end_y[order[:-1]])
+
+ # Compute areas of intersection-over-union
+ w = np.maximum(0.0, x2 - x1 + 1)
+ h = np.maximum(0.0, y2 - y1 + 1)
+ intersection = w * h
+
+ # Compute the ratio between intersection and union
+ return intersection / (areas[index] + areas[order[:-1]] - intersection)
+
+
+ @staticmethod
+ def compute_rotated_iou(index: int, order: np.ndarray, start_x: np.ndarray, start_y: np.ndarray, end_x: np.ndarray, end_y: np.ndarray, rotations: np.ndarray, areas: np.ndarray) -> np.ndarray:
+ """Compute IoU for rotated bounding boxes
+
+ Parameters
+ ----------
+ index : int
+ Index of the bounding box
+ order : np.ndarray
+ Order of bounding boxes
+ start_x : np.ndarray
+ Start x coordinate of bounding boxes
+ start_y : np.ndarray
+ Start y coordinate of bounding boxes
+ end_x : np.ndarray
+ End x coordinate of bounding boxes
+ end_y : np.ndarray
+ End y coordinate of bounding boxes
+ rotations : np.ndarray
+ Rotations of bounding boxes (in radians, around the center)
+ areas : np.ndarray
+ Areas of bounding boxes
+
+ Returns
+ -------
+ np.ndarray
+ IoU values
+ """
+
+ def create_rotated_geom(x1, y1, x2, y2, rotation):
+ """Helper function to create a rotated QgsGeometry rectangle"""
+ # Define the corners of the box before rotation
+ center_x = (x1 + x2) / 2
+ center_y = (y1 + y2) / 2
+
+ # Create a rectangle using QgsRectangle
+ rect = QgsRectangle(QgsPointXY(x1, y1), QgsPointXY(x2, y2))
+
+ # Convert to QgsGeometry
+ geom = QgsGeometry.fromRect(rect)
+
+ # Rotate the geometry around its center
+ result = geom.rotate(np.degrees(rotation), QgsPointXY(center_x, center_y))
+
+ if result == Qgis.GeometryOperationResult.Success:
+ return geom
+ else:
+ return QgsGeometry()
+
+ # Create the rotated geometry for the current bounding box
+ geom1 = create_rotated_geom(start_x[index], start_y[index], end_x[index], end_y[index], rotations[index])
+
+ iou_values = []
+
+ # Iterate over the rest of the boxes in order and calculate IoU
+ for i in range(len(order) - 1):
+ # Create the rotated geometry for the other boxes in the order
+ geom2 = create_rotated_geom(start_x[order[i]], start_y[order[i]], end_x[order[i]], end_y[order[i]], rotations[order[i]])
+
+ # Compute the intersection geometry
+ intersection_geom = geom1.intersection(geom2)
+
+ # Check if intersection is empty
+ if intersection_geom.isEmpty():
+ intersection_area = 0.0
+ else:
+ # Compute the intersection area
+ intersection_area = intersection_geom.area()
+
+ # Compute the union area
+ union_area = areas[index] + areas[order[i]] - intersection_area
+
+ # Compute IoU
+ iou = intersection_area / union_area if union_area > 0 else 0.0
+ iou_values.append(iou)
+
+ return np.array(iou_values)
+
+
+ def check_loaded_model_outputs(self):
+ """Check if model outputs are valid.
+ Valid model are:
+ - has 1 or 2 outputs layer
+ - output layer shape length is 3
+ - batch size is 1
+ """
+
+ if len(self.outputs_layers) == 1 or len(self.outputs_layers) == 2:
+ shape = self.outputs_layers[0].shape
+
+ if len(shape) != 3:
+ raise Exception(
+ f"Detection model output should have 3 dimensions: (Batch_size, detections, values). "
+ f"Actually has: {shape}"
+ )
+
+ else:
+ raise NotImplementedError("Model with multiple output layer is not supported! Use only one output layer.")
diff --git a/zipdeepness/processing/models/dual.py b/zipdeepness/processing/models/dual.py
new file mode 100644
index 0000000000000000000000000000000000000000..dff77f85a0ea2ba8f62bd9a74fcb1f4f1ac5f4dd
--- /dev/null
+++ b/zipdeepness/processing/models/dual.py
@@ -0,0 +1,168 @@
+from deepness.processing.models.segmentor import Segmentor
+import numpy as np
+import cv2
+import onnxruntime as ort
+import os
+from typing import List
+
+class DualModel(Segmentor):
+
+ def __init__(self, model_file_path: str):
+ super().__init__(model_file_path)
+ current_dir = os.path.dirname(os.path.abspath(__file__))
+ # Both models are in the same folder
+ self.second_model_file_path = os.path.join(current_dir, "buildings_type_MA.onnx")
+ self.second_model = ort.InferenceSession(self.second_model_file_path, providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
+
+
+
+
+ def preprocess_tiles_ade20k(self,tiles_batched: np.ndarray) -> np.ndarray:
+ # ADE20K mean/std in 0-1 range
+ ADE_MEAN = np.array([123.675, 116.280, 103.530]) / 255.0
+ ADE_STD = np.array([58.395, 57.120, 57.375]) / 255.0
+
+ tiles = tiles_batched.astype(np.float32) / 255.0 # 0-1
+
+ # Standardize per channel
+ tiles = (tiles - ADE_MEAN) / ADE_STD # broadcasting over (N,H,W,C)
+
+ # Ensure 3D channels (if grayscale)
+ if tiles.ndim == 3: # (H,W,C) single image
+ tiles = np.expand_dims(tiles, axis=0) # add batch dim
+
+ if tiles.shape[-1] == 1: # if only 1 channel
+ tiles = np.repeat(tiles, 3, axis=-1)
+
+ # NHWC -> NCHW
+ tiles = np.transpose(tiles, (0, 3, 1, 2))
+
+ return tiles.astype(np.float32)
+
+
+
+
+ def stable_sigmoid(self, x):
+ out = np.empty_like(x, dtype=np.float32)
+ positive_mask = x >= 0
+ negative_mask = ~positive_mask
+
+ out[positive_mask] = 1 / (1 + np.exp(-x[positive_mask]))
+ exp_x = np.exp(x[negative_mask])
+ out[negative_mask] = exp_x / (1 + exp_x)
+ return out
+
+
+ def stable_softmax(self, x, axis=-1):
+ max_x = np.max(x, axis=axis, keepdims=True)
+ exp_x = np.exp(x - max_x)
+ return exp_x / np.sum(exp_x, axis=axis, keepdims=True)
+
+
+ def post_process_semantic_segmentation_numpy(self, class_queries_logits, masks_queries_logits, target_sizes=None):
+ # Softmax over classes (remove null class)
+ masks_classes = self.stable_softmax(class_queries_logits, axis=-1)[..., :-1]
+
+ # Sigmoid for masks
+ masks_probs = self.stable_sigmoid(masks_queries_logits)
+
+ # Combine: torch.einsum("bqc,bqhw->bchw")
+ segmentation = np.einsum("bqc,bqhw->bchw", masks_classes, masks_probs)
+
+ semantic_segmentation = []
+ if target_sizes is not None:
+ if len(target_sizes) != class_queries_logits.shape[0]:
+ raise ValueError("target_sizes length must match batch size")
+
+ for idx in range(len(target_sizes)):
+ out_h, out_w = target_sizes[idx]
+ logits_resized = np.zeros((segmentation.shape[1], out_h, out_w), dtype=np.float32)
+ for c in range(segmentation.shape[1]):
+ logits_resized[c] = cv2.resize(
+ segmentation[idx, c],
+ (out_w, out_h),
+ interpolation=cv2.INTER_LINEAR
+ )
+ semantic_map = np.argmax(logits_resized, axis=0)
+ semantic_segmentation.append(semantic_map.astype(np.int32))
+ else:
+ for idx in range(segmentation.shape[0]):
+ semantic_map = np.argmax(segmentation[idx], axis=0)
+ semantic_segmentation.append(semantic_map.astype(np.int32))
+
+ return semantic_segmentation
+
+ def return_probs_mask2former(self,
+ class_queries_logits,
+ masks_queries_logits,
+ target_sizes=None):
+
+ # Softmax over classes (remove null class)
+ masks_classes = self.stable_softmax(class_queries_logits, axis=-1)[..., :-1]
+
+ # Sigmoid for masks
+ masks_probs = self.stable_sigmoid(masks_queries_logits)
+
+ # Combine: einsum bqc,bqhw -> bchw
+ segmentation = np.einsum("bqc,bqhw->bchw", masks_classes, masks_probs)
+
+ # Resize if target_sizes is given
+ if target_sizes is not None:
+ if len(target_sizes) != segmentation.shape[0]:
+ raise ValueError("target_sizes length must match batch size")
+
+ segmentation_resized = np.zeros(
+ (segmentation.shape[0], segmentation.shape[1], target_sizes[0][0], target_sizes[0][1]),
+ dtype=np.float32
+ )
+
+ for idx in range(segmentation.shape[0]):
+ out_h, out_w = target_sizes[idx]
+ for c in range(segmentation.shape[1]):
+ segmentation_resized[idx, c] = cv2.resize(
+ segmentation[idx, c],
+ (out_w, out_h),
+ interpolation=cv2.INTER_LINEAR
+ )
+ segmentation = segmentation_resized
+
+
+ return segmentation # shape: (B, C, H, W)
+
+
+
+
+
+ def process(self, tiles_batched: np.ndarray):
+ input_batch = self.preprocessing(tiles_batched)
+ input_building_batch = self.preprocess_tiles_ade20k(tiles_batched)
+
+ model_output = self.sess.run(
+ output_names=None,
+ input_feed={self.input_name: input_batch})
+ res = self.postprocessing(model_output)
+ logits_np, masks_np = self.second_model.run(["logits", "outputs_mask.35"], {"pixel_values": input_building_batch})
+ target_sizes=[(512, 512)]*logits_np.shape[0]
+ predicted_seg = self.post_process_semantic_segmentation_numpy(
+ class_queries_logits=logits_np,
+ masks_queries_logits=masks_np,
+ target_sizes=target_sizes
+ )
+ # new_class = {0:0, 1:5, 2:6, 3:7, 4:8}
+ # build_mask = np.zeros((512,512), dtype=np.int8)
+ # segmentation_map = predicted_seg[0]
+ # for k,v in new_class.items():
+ # build_mask[segmentation_map == k] = v
+
+ return res[0], predicted_seg
+
+
+ def get_number_of_output_channels(self) -> List[int]:
+ return [9]
+
+ def get_output_shapes(self) -> List[tuple]:
+ return [("N", 9, 512, 512)]
+
+
+
+
diff --git a/zipdeepness/processing/models/model_base.py b/zipdeepness/processing/models/model_base.py
new file mode 100644
index 0000000000000000000000000000000000000000..11dc214f8fd54746da5be72286a8f8e40843b9e9
--- /dev/null
+++ b/zipdeepness/processing/models/model_base.py
@@ -0,0 +1,444 @@
+""" Module including the base model interfaces and utilities"""
+import ast
+import json
+from typing import List, Optional
+
+import numpy as np
+
+from deepness.common.lazy_package_loader import LazyPackageLoader
+from deepness.common.processing_parameters.standardization_parameters import StandardizationParameters
+
+ort = LazyPackageLoader('onnxruntime')
+
+
+class ModelBase:
+ """
+ Wraps the ONNX model used during processing into a common interface
+ """
+
+ def __init__(self, model_file_path: str):
+ """
+
+ Parameters
+ ----------
+ model_file_path : str
+ Path to the model file
+ """
+ self.model_file_path = model_file_path
+
+ options = ort.SessionOptions()
+ options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
+
+ providers = [
+ 'CUDAExecutionProvider',
+ 'CPUExecutionProvider'
+ ]
+
+ self.sess = ort.InferenceSession(self.model_file_path, options=options, providers=providers)
+ inputs = self.sess.get_inputs()
+ if len(inputs) > 1:
+ raise Exception("ONNX model: unsupported number of inputs")
+ input_0 = inputs[0]
+
+ self.input_shape = input_0.shape
+ self.input_name = input_0.name
+
+ self.outputs_layers = self.sess.get_outputs()
+ self.standardization_parameters: StandardizationParameters = self.get_metadata_standarization_parameters()
+
+ self.outputs_names = self.get_outputs_channel_names()
+
+ @classmethod
+ def get_model_type_from_metadata(cls, model_file_path: str) -> Optional[str]:
+ """ Get model type from metadata
+
+ Parameters
+ ----------
+ model_file_path : str
+ Path to the model file
+
+ Returns
+ -------
+ Optional[str]
+ Model type or None if not found
+ """
+ model = cls(model_file_path)
+ return model.get_metadata_model_type()
+
+ def get_input_shape(self) -> tuple:
+ """ Get shape of the input for the model
+
+ Returns
+ -------
+ tuple
+ Shape of the input (batch_size, channels, height, width)
+ """
+ return self.input_shape
+
+ def get_output_shapes(self) -> List[tuple]:
+ """ Get shapes of the outputs for the model
+
+ Returns
+ -------
+ List[tuple]
+ Shapes of the outputs (batch_size, channels, height, width)
+ """
+ return [output.shape for output in self.outputs_layers]
+
+ def get_model_batch_size(self) -> Optional[int]:
+ """ Get batch size of the model
+
+ Returns
+ -------
+ Optional[int] | None
+ Batch size or None if not found (dynamic batch size)
+ """
+ bs = self.input_shape[0]
+
+ if isinstance(bs, str):
+ return None
+ else:
+ return bs
+
+ def get_input_size_in_pixels(self) -> int:
+ """ Get number of input pixels in x and y direction (the same value)
+
+ Returns
+ -------
+ int
+ Number of pixels in x and y direction
+ """
+ return self.input_shape[-2:]
+
+ def get_outputs_channel_names(self) -> Optional[List[List[str]]]:
+ """ Get class names from metadata
+
+ Returns
+ -------
+ List[List[str]] | None
+ List of class names for each model output or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+
+ allowed_key_names = ['class_names', 'names'] # support both names for backward compatibility
+ for name in allowed_key_names:
+ if name not in meta.custom_metadata_map:
+ continue
+
+ txt = meta.custom_metadata_map[name]
+ try:
+ class_names = json.loads(txt) # default format recommended in the documentation - classes encoded as json
+ except json.decoder.JSONDecodeError:
+ class_names = ast.literal_eval(txt) # keys are integers instead of strings - use ast
+
+ if isinstance(class_names, dict):
+ class_names = [class_names]
+
+ sorted_by_key = [sorted(cn.items(), key=lambda kv: int(kv[0])) for cn in class_names]
+
+ all_names = []
+
+ for output_index in range(len(sorted_by_key)):
+ output_names = []
+ class_counter = 0
+
+ for key, value in sorted_by_key[output_index]:
+ if int(key) != class_counter:
+ raise Exception("Class names in the model metadata are not consecutive (missing class label)")
+ class_counter += 1
+ output_names.append(value)
+ all_names.append(output_names)
+
+ return all_names
+
+ return None
+
+ def get_channel_name(self, layer_id: int, channel_id: int) -> str:
+ """ Get channel name by id if exists in model metadata
+
+ Parameters
+ ----------
+ channel_id : int
+ Channel id (means index in the output tensor)
+
+ Returns
+ -------
+ str
+ Channel name or empty string if not found
+ """
+
+ channel_id_str = str(channel_id)
+ default_return = f'channel_{channel_id_str}'
+
+ if self.outputs_names is None:
+ return default_return
+
+ if layer_id >= len(self.outputs_names):
+ raise Exception(f'Layer id {layer_id} is out of range of the model outputs')
+
+ if channel_id >= len(self.outputs_names[layer_id]):
+ raise Exception(f'Channel id {channel_id} is out of range of the model outputs')
+
+ return f'{self.outputs_names[layer_id][channel_id]}'
+
+ def get_metadata_model_type(self) -> Optional[str]:
+ """ Get model type from metadata
+
+ Returns
+ -------
+ Optional[str]
+ Model type or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+ name = 'model_type'
+ if name in meta.custom_metadata_map:
+ value = json.loads(meta.custom_metadata_map[name])
+ return str(value).capitalize()
+ return None
+
+ def get_metadata_standarization_parameters(self) -> Optional[StandardizationParameters]:
+ """ Get standardization parameters from metadata if exists
+
+ Returns
+ -------
+ Optional[StandardizationParameters]
+ Standardization parameters or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+ name_mean = 'standardization_mean'
+ name_std = 'standardization_std'
+
+ param = StandardizationParameters(channels_number=self.get_input_shape()[-3])
+
+ if name_mean in meta.custom_metadata_map and name_std in meta.custom_metadata_map:
+ mean = json.loads(meta.custom_metadata_map[name_mean])
+ std = json.loads(meta.custom_metadata_map[name_std])
+
+ mean = [float(x) for x in mean]
+ std = [float(x) for x in std]
+
+ param.set_mean_std(mean=mean, std=std)
+
+ return param
+
+ return param # default, no standardization
+
+ def get_metadata_resolution(self) -> Optional[float]:
+ """ Get resolution from metadata if exists
+
+ Returns
+ -------
+ Optional[float]
+ Resolution or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+ name = 'resolution'
+ if name in meta.custom_metadata_map:
+ value = json.loads(meta.custom_metadata_map[name])
+ return float(value)
+ return None
+
+ def get_metadata_tile_size(self) -> Optional[int]:
+ """ Get tile size from metadata if exists
+
+ Returns
+ -------
+ Optional[int]
+ Tile size or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+ name = 'tile_size'
+ if name in meta.custom_metadata_map:
+ value = json.loads(meta.custom_metadata_map[name])
+ return int(value)
+ return None
+
+ def get_metadata_tiles_overlap(self) -> Optional[int]:
+ """ Get tiles overlap from metadata if exists
+
+ Returns
+ -------
+ Optional[int]
+ Tiles overlap or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+ name = 'tiles_overlap'
+ if name in meta.custom_metadata_map:
+ value = json.loads(meta.custom_metadata_map[name])
+ return int(value)
+ return None
+
+ def get_metadata_segmentation_threshold(self) -> Optional[float]:
+ """ Get segmentation threshold from metadata if exists
+
+ Returns
+ -------
+ Optional[float]
+ Segmentation threshold or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+ name = 'seg_thresh'
+ if name in meta.custom_metadata_map:
+ value = json.loads(meta.custom_metadata_map[name])
+ return float(value)
+ return None
+
+ def get_metadata_segmentation_small_segment(self) -> Optional[int]:
+ """ Get segmentation small segment from metadata if exists
+
+ Returns
+ -------
+ Optional[int]
+ Segmentation small segment or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+ name = 'seg_small_segment'
+ if name in meta.custom_metadata_map:
+ value = json.loads(meta.custom_metadata_map[name])
+ return int(value)
+ return None
+
+ def get_metadata_regression_output_scaling(self) -> Optional[float]:
+ """ Get regression output scaling from metadata if exists
+
+ Returns
+ -------
+ Optional[float]
+ Regression output scaling or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+ name = 'reg_output_scaling'
+ if name in meta.custom_metadata_map:
+ value = json.loads(meta.custom_metadata_map[name])
+ return float(value)
+ return None
+
+ def get_metadata_detection_confidence(self) -> Optional[float]:
+ """ Get detection confidence from metadata if exists
+
+ Returns
+ -------
+ Optional[float]
+ Detection confidence or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+ name = 'det_conf'
+ if name in meta.custom_metadata_map:
+ value = json.loads(meta.custom_metadata_map[name])
+ return float(value)
+ return None
+
+ def get_detector_type(self) -> Optional[str]:
+ """ Get detector type from metadata if exists
+
+ Returns string value of DetectorType enum or None if not found
+ -------
+ Optional[str]
+ Detector type or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+ name = 'det_type'
+ if name in meta.custom_metadata_map:
+ value = json.loads(meta.custom_metadata_map[name])
+ return str(value)
+ return None
+
+ def get_metadata_detection_iou_threshold(self) -> Optional[float]:
+ """ Get detection iou threshold from metadata if exists
+
+ Returns
+ -------
+ Optional[float]
+ Detection iou threshold or None if not found
+ """
+ meta = self.sess.get_modelmeta()
+ name = 'det_iou_thresh'
+ if name in meta.custom_metadata_map:
+ value = json.loads(meta.custom_metadata_map[name])
+ return float(value)
+ return None
+
+ def get_number_of_channels(self) -> int:
+ """ Returns number of channels in the input layer
+
+ Returns
+ -------
+ int
+ Number of channels in the input layer
+ """
+ return self.input_shape[-3]
+
+ def process(self, tiles_batched: np.ndarray):
+ """ Process a single tile image
+
+ Parameters
+ ----------
+ img : np.ndarray
+ Image to process ([TILE_SIZE x TILE_SIZE x channels], type uint8, values 0 to 255)
+
+ Returns
+ -------
+ np.ndarray
+ Single prediction
+ """
+ input_batch = self.preprocessing(tiles_batched)
+ model_output = self.sess.run(
+ output_names=None,
+ input_feed={self.input_name: input_batch})
+ res = self.postprocessing(model_output)
+ return res
+
+ def preprocessing(self, tiles_batched: np.ndarray) -> np.ndarray:
+ """ Preprocess the batch of images for the model (resize, normalization, etc)
+
+ Parameters
+ ----------
+ image : np.ndarray
+ Batch of images to preprocess (N,H,W,C), RGB, 0-255
+
+ Returns
+ -------
+ np.ndarray
+ Preprocessed batch of image (N,C,H,W), RGB, 0-1
+ """
+
+ # imported here, to avoid isseue with uninstalled dependencies during the first plugin start
+ # in other places we use LazyPackageLoader, but here it is not so easy
+ import deepness.processing.models.preprocessing_utils as preprocessing_utils
+
+ tiles_batched = preprocessing_utils.limit_channels_number(tiles_batched, limit=self.input_shape[-3])
+ tiles_batched = preprocessing_utils.normalize_values_to_01(tiles_batched)
+ tiles_batched = preprocessing_utils.standardize_values(tiles_batched, params=self.standardization_parameters)
+ tiles_batched = preprocessing_utils.transpose_nhwc_to_nchw(tiles_batched)
+
+ return tiles_batched
+
+ def postprocessing(self, outs: List) -> np.ndarray:
+ """ Abstract method for postprocessing
+
+ Parameters
+ ----------
+ outs : List
+ Output from the model (depends on the model type)
+
+ Returns
+ -------
+ np.ndarray
+ Postprocessed output
+ """
+ raise NotImplementedError('Base class not implemented!')
+
+ def get_number_of_output_channels(self) -> List[int]:
+ """ Abstract method for getting number of classes in the output layer
+
+ Returns
+ -------
+ int
+ Number of channels in the output layer"""
+ raise NotImplementedError('Base class not implemented!')
+
+ def check_loaded_model_outputs(self):
+ """ Abstract method for checking if the model outputs are valid
+
+ """
+ raise NotImplementedError('Base class not implemented!')
diff --git a/zipdeepness/processing/models/model_types.py b/zipdeepness/processing/models/model_types.py
new file mode 100644
index 0000000000000000000000000000000000000000..a0e2c7d6db23be65ad289462e740a94e00d564ee
--- /dev/null
+++ b/zipdeepness/processing/models/model_types.py
@@ -0,0 +1,93 @@
+import enum
+from dataclasses import dataclass
+
+from deepness.common.processing_parameters.detection_parameters import DetectionParameters
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters
+from deepness.common.processing_parameters.recognition_parameters import RecognitionParameters
+from deepness.common.processing_parameters.regression_parameters import RegressionParameters
+from deepness.common.processing_parameters.segmentation_parameters import SegmentationParameters
+from deepness.common.processing_parameters.superresolution_parameters import SuperresolutionParameters
+from deepness.processing.map_processor.map_processor_detection import MapProcessorDetection
+from deepness.processing.map_processor.map_processor_recognition import MapProcessorRecognition
+from deepness.processing.map_processor.map_processor_regression import MapProcessorRegression
+from deepness.processing.map_processor.map_processor_segmentation import MapProcessorSegmentation
+from deepness.processing.map_processor.map_processor_superresolution import MapProcessorSuperresolution
+from deepness.processing.models.detector import Detector
+from deepness.processing.models.recognition import Recognition
+from deepness.processing.models.regressor import Regressor
+from deepness.processing.models.segmentor import Segmentor
+from deepness.processing.models.superresolution import Superresolution
+from deepness.processing.models.dual import DualModel
+
+
+class ModelType(enum.Enum):
+ SEGMENTATION = Segmentor.get_class_display_name()
+ REGRESSION = Regressor.get_class_display_name()
+ DETECTION = Detector.get_class_display_name()
+ SUPERRESOLUTION = Superresolution.get_class_display_name()
+ RECOGNITION = Recognition.get_class_display_name()
+
+
+@dataclass
+class ModelDefinition:
+ model_type: ModelType
+ model_class: type
+ parameters_class: type
+ map_processor_class: type
+
+ @classmethod
+ def get_model_definitions(cls):
+ return [
+ cls(
+ model_type=ModelType.SEGMENTATION,
+ model_class=DualModel,
+ parameters_class=SegmentationParameters,
+ map_processor_class=MapProcessorSegmentation,
+ ),
+ cls(
+ model_type=ModelType.REGRESSION,
+ model_class=Regressor,
+ parameters_class=RegressionParameters,
+ map_processor_class=MapProcessorRegression,
+ ),
+ cls(
+ model_type=ModelType.DETECTION,
+ model_class=Detector,
+ parameters_class=DetectionParameters,
+ map_processor_class=MapProcessorDetection,
+ ), # superresolution
+ cls(
+ model_type=ModelType.SUPERRESOLUTION,
+ model_class=Superresolution,
+ parameters_class=SuperresolutionParameters,
+ map_processor_class=MapProcessorSuperresolution,
+ ), # recognition
+ cls(
+ model_type=ModelType.RECOGNITION,
+ model_class=Recognition,
+ parameters_class=RecognitionParameters,
+ map_processor_class=MapProcessorRecognition,
+ )
+
+ ]
+
+ @classmethod
+ def get_definition_for_type(cls, model_type: ModelType):
+ model_definitions = cls.get_model_definitions()
+ for model_definition in model_definitions:
+ if model_definition.model_type == model_type:
+ return model_definition
+ raise Exception(f"Unknown model type: '{model_type}'!")
+
+ @classmethod
+ def get_definition_for_params(cls, params: MapProcessingParameters):
+ """ get model definition corresponding to the specified parameters """
+ model_definitions = cls.get_model_definitions()
+ for model_definition in model_definitions:
+ if type(params) == model_definition.parameters_class:
+ return model_definition
+
+ for model_definition in model_definitions:
+ if isinstance(params, model_definition.parameters_class):
+ return model_definition
+ raise Exception(f"Unknown model type for parameters: '{params}'!")
diff --git a/zipdeepness/processing/models/preprocessing_utils.py b/zipdeepness/processing/models/preprocessing_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..eff25fa163318356e2b1d95f8223c290cd4fd754
--- /dev/null
+++ b/zipdeepness/processing/models/preprocessing_utils.py
@@ -0,0 +1,41 @@
+import numpy as np
+
+from deepness.common.processing_parameters.standardization_parameters import StandardizationParameters
+
+
+def limit_channels_number(tiles_batched: np.array, limit: int) -> np.array:
+ """ Limit the number of channels in the input image to the model
+
+ :param tiles_batched: Batch of tiles
+ :param limit: Number of channels to keep
+ :return: Batch of tiles with limited number of channels
+ """
+ return tiles_batched[:, :, :, :limit]
+
+
+def normalize_values_to_01(tiles_batched: np.array) -> np.array:
+ """ Normalize the values of the input image to the model to the range [0, 1]
+
+ :param tiles_batched: Batch of tiles
+ :return: Batch of tiles with values in the range [0, 1], in float32
+ """
+ return np.float32(tiles_batched * 1./255.)
+
+
+def standardize_values(tiles_batched: np.array, params: StandardizationParameters) -> np.array:
+ """ Standardize the input image to the model
+
+ :param tiles_batched: Batch of tiles
+ :param params: Parameters for standardization of type STANDARIZE_PARAMS
+ :return: Batch of tiles with standardized values
+ """
+ return (tiles_batched - params.mean) / params.std
+
+
+def transpose_nhwc_to_nchw(tiles_batched: np.array) -> np.array:
+ """ Transpose the input image from NHWC to NCHW
+
+ :param tiles_batched: Batch of tiles in NHWC format
+ :return: Batch of tiles in NCHW format
+ """
+ return np.transpose(tiles_batched, (0, 3, 1, 2))
diff --git a/zipdeepness/processing/models/recognition.py b/zipdeepness/processing/models/recognition.py
new file mode 100644
index 0000000000000000000000000000000000000000..12233e4b006597ec8fcd0775ee1c2f7ff0ff8573
--- /dev/null
+++ b/zipdeepness/processing/models/recognition.py
@@ -0,0 +1,98 @@
+""" Module including the class for the recognition of the images
+"""
+import logging
+from typing import List
+
+import numpy as np
+
+from deepness.common.lazy_package_loader import LazyPackageLoader
+from deepness.processing.models.model_base import ModelBase
+
+cv2 = LazyPackageLoader('cv2')
+
+
+class Recognition(ModelBase):
+ """Class implements recognition model
+
+ Recognition model is used to predict class confidence per pixel of the image.
+ """
+
+ def __init__(self, model_file_path: str):
+ """
+
+ Parameters
+ ----------
+ model_file_path : str
+ Path to the model file
+ """
+ super(Recognition, self).__init__(model_file_path)
+
+ def postprocessing(self, model_output: List) -> np.ndarray:
+ """Postprocess the model output.
+ Function returns the array of embeddings
+
+ Parameters
+ ----------
+ model_output : List
+ Output embeddings from the (Recognition) model
+
+ Returns
+ -------
+ np.ndarray
+ Same as input
+ """
+ # TODO - compute cosine similarity to self.query_img_emb
+ # cannot, won't work for query image
+
+ return np.array(model_output)
+
+ def get_number_of_output_channels(self):
+ """Returns model's number of class
+
+ Returns
+ -------
+ int
+ Number of channels in the output layer
+ """
+ logging.warning(f"outputs_layers: {self.outputs_layers}")
+ logging.info(f"outputs_layers: {self.outputs_layers}")
+
+ if len(self.outputs_layers) == 1:
+ return [self.outputs_layers[0].shape[1]]
+ else:
+ raise NotImplementedError(
+ "Model with multiple output layers is not supported! Use only one output layer."
+ )
+
+ @classmethod
+ def get_class_display_name(cls):
+ """Returns the name of the class to be displayed in the GUI
+
+ Returns
+ -------
+ str
+ Name of the class
+ """
+ return cls.__name__
+
+ def check_loaded_model_outputs(self):
+ """Checks if the model outputs are valid
+
+ Valid means that:
+ - the model has only one output
+ - the output is 2D (N,C)
+ - the batch size is 1
+
+ """
+ if len(self.outputs_layers) == 1:
+ shape = self.outputs_layers[0].shape
+
+ if len(shape) != 2:
+ raise Exception(
+ f"Recognition model output should have 4 dimensions: (B,C,H,W). Has {shape}"
+ )
+
+ else:
+ raise NotImplementedError(
+ "Model with multiple output layers is not supported! Use only one output layer."
+ )
diff --git a/zipdeepness/processing/models/regressor.py b/zipdeepness/processing/models/regressor.py
new file mode 100644
index 0000000000000000000000000000000000000000..f995938d981bf2425d9db242ad25fa734e260bf7
--- /dev/null
+++ b/zipdeepness/processing/models/regressor.py
@@ -0,0 +1,92 @@
+""" Module including Regression model definition
+"""
+from typing import List
+
+import numpy as np
+
+from deepness.processing.models.model_base import ModelBase
+
+
+class Regressor(ModelBase):
+ """ Class implements regression model.
+
+ Regression model is used to predict metric per pixel of the image.
+ """
+
+ def __init__(self, model_file_path: str):
+ """
+
+ Parameters
+ ----------
+ model_file_path : str
+ Path to the model file
+ """
+ super(Regressor, self).__init__(model_file_path)
+
+ def postprocessing(self, model_output: List) -> np.ndarray:
+ """ Postprocess the model output.
+
+ Parameters
+ ----------
+ model_output : List
+ Output from the (Regression) model
+
+ Returns
+ -------
+ np.ndarray
+ Output from the (Regression) model
+ """
+ return model_output
+
+ def get_number_of_output_channels(self) -> List[int]:
+ """ Returns number of channels in the output layer
+
+ Returns
+ -------
+ int
+ Number of channels in the output layer
+ """
+ channels = []
+
+ for layer in self.outputs_layers:
+ if len(layer.shape) != 4 and len(layer.shape) != 3:
+ raise Exception(f'Output layer should have 3 or 4 dimensions: (Bs, H, W) or (Bs, Channels, H, W). Actually has: {layer.shape}')
+
+ if len(layer.shape) == 3:
+ channels.append(1)
+ elif len(layer.shape) == 4:
+ channels.append(layer.shape[-3])
+
+ return channels
+
+ @classmethod
+ def get_class_display_name(cls) -> str:
+ """ Returns display name of the model class
+
+ Returns
+ -------
+ str
+ Display name of the model class
+ """
+ return cls.__name__
+
+ def check_loaded_model_outputs(self):
+ """ Check if the model has correct output layers
+
+ Correct means that:
+ - there is at least one output layer
+ - batch size is 1 or parameter
+ - each output layer regresses only one channel
+ - output resolution is square
+ """
+ for layer in self.outputs_layers:
+ if len(layer.shape) != 4 and len(layer.shape) != 3:
+ raise Exception(f'Output layer should have 3 or 4 dimensions: (Bs, H, W) or (Bs, Channels, H, W). Actually has: {layer.shape}')
+
+ if len(layer.shape) == 4:
+ if layer.shape[2] != layer.shape[3]:
+ raise Exception(f'Regression model can handle only square outputs masks. Has: {layer.shape}')
+
+ elif len(layer.shape) == 3:
+ if layer.shape[1] != layer.shape[2]:
+ raise Exception(f'Regression model can handle only square outputs masks. Has: {layer.shape}')
diff --git a/zipdeepness/processing/models/segmentor.py b/zipdeepness/processing/models/segmentor.py
new file mode 100644
index 0000000000000000000000000000000000000000..6dabf26b2cc56d594eb1ed9615d45e6ce4882791
--- /dev/null
+++ b/zipdeepness/processing/models/segmentor.py
@@ -0,0 +1,104 @@
+""" Module including the class for the segmentation of the images
+"""
+from typing import List
+
+import numpy as np
+
+from deepness.processing.models.model_base import ModelBase
+
+
+class Segmentor(ModelBase):
+ """Class implements segmentation model
+
+ Segmentation model is used to predict class confidence per pixel of the image.
+ """
+
+ def __init__(self, model_file_path: str):
+ """
+
+ Parameters
+ ----------
+ model_file_path : str
+ Path to the model file
+ """
+ super(Segmentor, self).__init__(model_file_path)
+
+ self.outputs_are_sigmoid = self.check_loaded_model_outputs()
+
+ for idx in range(len(self.outputs_layers)):
+ if self.outputs_names is None:
+ continue
+
+ if len(self.outputs_names[idx]) == 1 and self.outputs_are_sigmoid[idx]:
+ self.outputs_names[idx] = ['background', self.outputs_names[idx][0]]
+
+ def postprocessing(self, model_output: List) -> np.ndarray:
+ """ Postprocess the model output.
+ Function returns the mask with the probability of the presence of the class in the image.
+
+ Parameters
+ ----------
+ model_output : List
+ Output from the (Segmentation) model
+
+ Returns
+ -------
+ np.ndarray
+ Output from the (Segmentation) model
+ """
+ return model_output
+
+ def get_number_of_output_channels(self) -> List[int]:
+ """ Returns model's number of class
+
+ Returns
+ -------
+ int
+ Number of channels in the output layer
+ """
+ output_channels = []
+ for layer in self.outputs_layers:
+ ls = layer.shape
+
+ if len(ls) == 3:
+ output_channels.append(2)
+ elif len(ls) == 4:
+ chn = ls[-3]
+ if chn == 1:
+ output_channels.append(2)
+ else:
+ output_channels.append(chn)
+
+ return output_channels
+
+ @classmethod
+ def get_class_display_name(cls):
+ """ Returns the name of the class to be displayed in the GUI
+
+ Returns
+ -------
+ str
+ Name of the class
+ """
+ return cls.__name__
+
+ def check_loaded_model_outputs(self) -> List[bool]:
+ """ Check if the model outputs are sigmoid (for segmentation)
+
+ Parameters
+ ----------
+
+ Returns
+ -------
+ List[bool]
+ List of booleans indicating if the model outputs are sigmoid
+ """
+ outputs = []
+
+ for output in self.outputs_layers:
+ if len(output.shape) == 3:
+ outputs.append(True)
+ else:
+ outputs.append(output.shape[-3] == 1)
+
+ return outputs
diff --git a/zipdeepness/processing/models/superresolution.py b/zipdeepness/processing/models/superresolution.py
new file mode 100644
index 0000000000000000000000000000000000000000..1fdaed87e097df953df8b9f5def0c353679b6460
--- /dev/null
+++ b/zipdeepness/processing/models/superresolution.py
@@ -0,0 +1,99 @@
+""" Module including Super Resolution model definition
+"""
+from typing import List
+
+import numpy as np
+
+from deepness.processing.models.model_base import ModelBase
+
+
+class Superresolution(ModelBase):
+ """ Class implements super resolution model.
+
+ Super Resolution model is used improve the resolution of an image.
+ """
+
+ def __init__(self, model_file_path: str):
+ """
+
+ Parameters
+ ----------
+ model_file_path : str
+ Path to the model file
+ """
+ super(Superresolution, self).__init__(model_file_path)
+
+ def postprocessing(self, model_output: List) -> np.ndarray:
+ """ Postprocess the model output.
+
+ Parameters
+ ----------
+ model_output : List
+ Output from the (Regression) model
+
+ Returns
+ -------
+ np.ndarray
+ Postprocessed mask (H,W,C), 0-1 (one output channel)
+
+ """
+ return model_output[0]
+
+ def get_number_of_output_channels(self) -> List[int]:
+ """ Returns number of channels in the output layer
+
+ Returns
+ -------
+ int
+ Number of channels in the output layer
+ """
+ if len(self.outputs_layers) == 1:
+ return [self.outputs_layers[0].shape[-3]]
+ else:
+ raise NotImplementedError("Model with multiple output layers is not supported! Use only one output layer.")
+
+ @classmethod
+ def get_class_display_name(cls) -> str:
+ """ Returns display name of the model class
+
+ Returns
+ -------
+ str
+ Display name of the model class
+ """
+ return cls.__name__
+
+ def get_output_shape(self) -> List[int]:
+ """ Returns shape of the output layer
+
+ Returns
+ -------
+ List[int]
+ Shape of the output layer
+ """
+ if len(self.outputs_layers) == 1:
+ return self.outputs_layers[0].shape
+ else:
+ raise NotImplementedError("Model with multiple output layers is not supported! Use only one output layer.")
+
+ def check_loaded_model_outputs(self):
+ """ Check if the model has correct output layers
+
+ Correct means that:
+ - there is only one output layer
+ - output layer has 1 channel
+ - batch size is 1
+ - output resolution is square
+ """
+ if len(self.outputs_layers) == 1:
+ shape = self.outputs_layers[0].shape
+
+ if len(shape) != 4:
+ raise Exception(f'Regression model output should have 4 dimensions: (Batch_size, Channels, H, W). \n'
+ f'Actually has: {shape}')
+
+ if shape[2] != shape[3]:
+ raise Exception(f'Regression model can handle only square outputs masks. Has: {shape}')
+
+ else:
+ raise NotImplementedError("Model with multiple output layers is not supported! Use only one output layer.")
diff --git a/zipdeepness/processing/processing_utils.py b/zipdeepness/processing/processing_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..25edbad477cbb4182a02c312b0c760ec16e9a3c8
--- /dev/null
+++ b/zipdeepness/processing/processing_utils.py
@@ -0,0 +1,557 @@
+"""
+This file contains utilities related to processing of the ortophoto
+"""
+
+import logging
+from dataclasses import dataclass
+from typing import List, Optional, Tuple
+
+import numpy as np
+from qgis.core import (Qgis, QgsCoordinateTransform, QgsFeature, QgsGeometry, QgsPointXY, QgsRasterLayer, QgsRectangle,
+ QgsUnitTypes, QgsWkbTypes)
+
+from deepness.common.defines import IS_DEBUG
+from deepness.common.lazy_package_loader import LazyPackageLoader
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters
+from deepness.common.processing_parameters.segmentation_parameters import SegmentationParameters
+from deepness.common.temp_files_handler import TempFilesHandler
+
+cv2 = LazyPackageLoader('cv2')
+
+
+def convert_meters_to_rlayer_units(rlayer: QgsRasterLayer, distance_m: float) -> float:
+ """ How many map units are there in one meter.
+ :param rlayer: raster layer for which we want to convert meters to its units
+ :param distance_m: distance in meters
+ """
+ if rlayer.crs().mapUnits() != QgsUnitTypes.DistanceUnit.DistanceMeters:
+ logging.warning(f"Map units are not meters but '{rlayer.crs().mapUnits()}'. It should be fine for most cases, but be aware.")
+
+ # Now we support all units, but we need to convert them to meters
+ # to have a consistent unit for the distance
+ scaling_factor = QgsUnitTypes.fromUnitToUnitFactor(QgsUnitTypes.DistanceMeters, rlayer.crs().mapUnits())
+ distance = distance_m * scaling_factor
+ assert distance != 0
+ return distance
+
+
+def get_numpy_data_type_for_qgis_type(data_type_qgis: Qgis.DataType):
+ """Conver QGIS data type to corresponding numpy data type
+ In [58]: Qgis.DataType?
+ implemented: Byte, UInt16, Int16, Float32, Float64
+ """
+ if data_type_qgis == Qgis.DataType.Byte:
+ return np.uint8
+ if data_type_qgis == Qgis.DataType.UInt16:
+ return np.uint16
+ if data_type_qgis == Qgis.DataType.UInt32:
+ return np.uint32
+ if data_type_qgis == Qgis.DataType.Int16:
+ return np.int16
+ if data_type_qgis == Qgis.DataType.Int32:
+ return np.int32
+ if data_type_qgis == Qgis.DataType.Float32:
+ return np.float32
+ if data_type_qgis == Qgis.DataType.Float64:
+ return np.float64
+ raise Exception(f"Invalid input layer data type ({data_type_qgis})!")
+
+
+def get_tile_image(
+ rlayer: QgsRasterLayer,
+ extent: QgsRectangle,
+ params: MapProcessingParameters) -> np.ndarray:
+ """_summary_
+
+ Parameters
+ ----------
+ rlayer : QgsRasterLayer
+ raster layer from which the image will be extracted
+ extent : QgsRectangle
+ extent of the image to extract
+ params : MapProcessingParameters
+ map processing parameters
+
+ Returns
+ -------
+ np.ndarray
+ extracted image [SIZE x SIZE x CHANNELS]. Probably RGBA channels
+ """
+
+ expected_meters_per_pixel = params.resolution_cm_per_px / 100
+ expected_units_per_pixel = convert_meters_to_rlayer_units(
+ rlayer, expected_meters_per_pixel)
+ expected_units_per_pixel_2d = expected_units_per_pixel, expected_units_per_pixel
+ # to get all pixels - use the 'rlayer.rasterUnitsPerPixelX()' instead of 'expected_units_per_pixel_2d'
+ image_size = round((extent.width()) / expected_units_per_pixel_2d[0]), \
+ round((extent.height()) / expected_units_per_pixel_2d[1])
+
+ # sanity check, that we gave proper extent as parameter
+ assert image_size[0] == params.tile_size_px
+ assert image_size[1] == params.tile_size_px
+
+ # enable resampling
+ data_provider = rlayer.dataProvider()
+ if data_provider is None:
+ raise Exception("Somehow invalid rlayer!")
+ data_provider.enableProviderResampling(True)
+ original_resampling_method = data_provider.zoomedInResamplingMethod()
+ data_provider.setZoomedInResamplingMethod(
+ data_provider.ResamplingMethod.Bilinear)
+ data_provider.setZoomedOutResamplingMethod(
+ data_provider.ResamplingMethod.Bilinear)
+
+ def get_raster_block(band_number_):
+ raster_block = rlayer.dataProvider().block(
+ band_number_,
+ extent,
+ image_size[0], image_size[1])
+ block_height, block_width = raster_block.height(), raster_block.width()
+ if block_height == 0 or block_width == 0:
+ raise Exception("No data on layer within the expected extent!")
+ return raster_block
+
+ input_channels_mapping = params.input_channels_mapping
+ number_of_model_inputs = input_channels_mapping.get_number_of_model_inputs()
+ tile_data = []
+
+ if input_channels_mapping.are_all_inputs_standalone_bands():
+ band_count = rlayer.bandCount()
+ for i in range(number_of_model_inputs):
+ image_channel = input_channels_mapping.get_image_channel_for_model_input(
+ i)
+ band_number = image_channel.get_band_number()
+ # we cannot obtain a higher band than the maximum in the image
+ assert band_number <= band_count
+ rb = get_raster_block(band_number)
+ raw_data = rb.data()
+ bytes_array = bytes(raw_data)
+ data_type = rb.dataType()
+ data_type_numpy = get_numpy_data_type_for_qgis_type(data_type)
+ a = np.frombuffer(bytes_array, dtype=data_type_numpy)
+ b = a.reshape((image_size[1], image_size[0], 1))
+ tile_data.append(b)
+ elif input_channels_mapping.are_all_inputs_composite_byte():
+ rb = get_raster_block(1) # the data are always in band 1
+ raw_data = rb.data()
+ bytes_array = bytes(raw_data)
+ dt = rb.dataType()
+ number_of_image_channels = input_channels_mapping.get_number_of_image_channels()
+ # otherwise we did something wrong earlier...
+ assert number_of_image_channels == 4
+ if dt != Qgis.DataType.ARGB32:
+ raise Exception("Invalid input layer data type!")
+ a = np.frombuffer(bytes_array, dtype=np.uint8)
+ b = a.reshape((image_size[1], image_size[0], number_of_image_channels))
+
+ for i in range(number_of_model_inputs):
+ image_channel = input_channels_mapping.get_image_channel_for_model_input(
+ i)
+ byte_number = image_channel.get_byte_number()
+ # we cannot get more bytes than there are
+ assert byte_number < number_of_image_channels
+ # last index to keep dimension
+ tile_data.append(b[:, :, byte_number:byte_number+1])
+ else:
+ raise Exception("Unsupported image channels composition!")
+
+ data_provider.setZoomedInResamplingMethod(
+ original_resampling_method) # restore old resampling method
+ img = np.concatenate(tile_data, axis=2)
+ return img
+
+
+def erode_dilate_image(img, segmentation_parameters: SegmentationParameters):
+ """Apply to dilate and erode to the input image"""
+ if segmentation_parameters.postprocessing_dilate_erode_size:
+ size = (segmentation_parameters.postprocessing_dilate_erode_size // 2) ** 2 + 1
+ kernel = np.ones((size, size), np.uint8)
+ img = cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel)
+ img = cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel)
+ return img
+
+
+def convert_cv_contours_to_features(features,
+ cv_contours,
+ hierarchy,
+ current_contour_index,
+ is_hole,
+ current_holes):
+ """
+ Convert contour found with OpenCV to features accepted by QGis.
+ Called recursively.
+ """
+
+ if current_contour_index == -1:
+ return
+
+ while True:
+ contour = cv_contours[current_contour_index]
+ if len(contour) >= 3:
+ first_child = hierarchy[current_contour_index][2]
+ internal_holes = []
+ convert_cv_contours_to_features(
+ features=features,
+ cv_contours=cv_contours,
+ hierarchy=hierarchy,
+ current_contour_index=first_child,
+ is_hole=not is_hole,
+ current_holes=internal_holes)
+
+ if is_hole:
+ current_holes.append(contour)
+ else:
+ feature = QgsFeature()
+ polygon_xy_vec_vec = [
+ contour,
+ *internal_holes
+ ]
+ geometry = QgsGeometry.fromPolygonXY(polygon_xy_vec_vec)
+ feature.setGeometry(geometry)
+
+ # polygon = shapely.geometry.Polygon(contour, holes=internal_holes)
+ features.append(feature)
+
+ current_contour_index = hierarchy[current_contour_index][0]
+ if current_contour_index == -1:
+ break
+
+
+def transform_points_list_xy_to_target_crs(
+ points: List[Tuple],
+ extent: QgsRectangle,
+ rlayer_units_per_pixel: float):
+ """ Transform points from xy coordinates to the target CRS system coordinates"""
+ x_left = extent.xMinimum()
+ y_upper = extent.yMaximum()
+ points_crs = []
+
+ for point_xy in points:
+ x_crs = point_xy[0] * rlayer_units_per_pixel + x_left
+ y_crs = -(point_xy[1] * rlayer_units_per_pixel - y_upper)
+ points_crs.append(QgsPointXY(x_crs, y_crs))
+ return points_crs
+
+
+def transform_contours_yx_pixels_to_target_crs(
+ contours,
+ extent: QgsRectangle,
+ rlayer_units_per_pixel: float):
+ """ Transform countours with points as yx pixels to the target CRS system coordinates"""
+ x_left = extent.xMinimum()
+ y_upper = extent.yMaximum()
+
+ polygons_crs = []
+ for polygon_3d in contours:
+ # https://stackoverflow.com/questions/33458362/opencv-findcontours-why-do-we-need-a-
+ # vectorvectorpoint-to-store-the-cont
+ polygon = polygon_3d.squeeze(axis=1)
+
+ polygon_crs = []
+ for i in range(len(polygon)):
+ yx_px = polygon[i]
+ x_crs = yx_px[0] * rlayer_units_per_pixel + x_left
+ y_crs = -(yx_px[1] * rlayer_units_per_pixel - y_upper)
+ polygon_crs.append(QgsPointXY(x_crs, y_crs))
+ polygons_crs.append(polygon_crs)
+ return polygons_crs
+
+
+@dataclass
+class BoundingBox:
+ """
+ Describes a bounding box rectangle.
+ Similar to cv2.Rect
+ """
+ x_min: int
+ x_max: int
+ y_min: int
+ y_max: int
+ rot: float = 0.0
+
+ def get_shape(self) -> Tuple[int, int]:
+ """ Returns the shape of the bounding box as a tuple (height, width)
+
+ Returns
+ -------
+ tuple
+ (height, width)
+ """
+ return [
+ self.y_max - self.y_min + 1,
+ self.x_max - self.x_min + 1
+ ]
+
+ def get_xyxy(self) -> Tuple[int, int, int, int]:
+ """ Returns the bounding box as a tuple (x_min, y_min, x_max, y_max)
+
+ Returns
+ -------
+ Tuple[int, int, int, int]
+ (x_min, y_min, x_max, y_max)
+ """
+ return [
+ self.x_min,
+ self.y_min,
+ self.x_max,
+ self.y_max
+ ]
+
+ def get_xyxy_rot(self) -> Tuple[int, int, int, int, float]:
+ """ Returns the bounding box as a tuple (x_min, y_min, x_max, y_max, rotation)
+
+ Returns
+ -------
+ Tuple[int, int, int, int, float]
+ (x_min, y_min, x_max, y_max, rotation)
+ """
+ return [
+ self.x_min,
+ self.y_min,
+ self.x_max,
+ self.y_max,
+ self.rot
+ ]
+
+ def get_xywh(self) -> Tuple[int, int, int, int]:
+ """ Returns the bounding box as a tuple (x_min, y_min, width, height)
+
+ Returns
+ -------
+ Tuple[int, int, int, int]
+ (x_min, y_min, width, height)
+ """
+ return [
+ self.x_min,
+ self.y_min,
+ self.x_max - self.x_min,
+ self.y_max - self.y_min
+ ]
+
+ def get_center(self) -> Tuple[int, int]:
+ """ Returns the center of the bounding box as a tuple (x, y)
+
+ Returns
+ -------
+ Tuple[int, int]
+ (x, y)
+ """
+ return [
+ (self.x_min + self.x_max) // 2,
+ (self.y_min + self.y_max) // 2
+ ]
+
+ def get_area(self) -> float:
+ """Calculate bounding box reactangle area
+
+ Returns
+ -------
+ float
+ Bounding box area
+ """
+ shape = self.get_shape()
+ return shape[0] * shape[1]
+
+ def calculate_overlap_in_pixels(self, other) -> float:
+ """Calculate overlap between two bounding boxes in pixels
+
+ Parameters
+ ----------
+ other : BoundingBox
+ Other bounding box
+
+ Returns
+ -------
+ float
+ Overlap in pixels
+ """
+ dx = min(self.x_max, other.x_max) - max(self.x_min, other.x_min)
+ dy = min(self.y_max, other.y_max) - max(self.y_min, other.y_min)
+ if (dx >= 0) and (dy >= 0):
+ return dx * dy
+ return 0
+
+ def calculate_intersection_over_smaler_area(self, other) -> float:
+ """ Calculate intersection over smaler area (IoS) between two bounding boxes
+
+ Parameters
+ ----------
+ other : BoundingBox
+ Other bounding bo
+
+ Returns
+ -------
+ float
+ Value between 0 and 1
+ """
+
+ Aarea = (self.x_max - self.x_min) * (self.y_max - self.y_min)
+ Barea = (other.x_max - other.x_min) * (other.y_max - other.y_min)
+
+ xA = max(self.x_min, other.x_min)
+ yA = max(self.y_min, other.y_min)
+ xB = min(self.x_max, other.x_max)
+ yB = min(self.y_max, other.y_max)
+
+ # compute the area of intersection rectangle
+ return max(0, xB - xA + 1) * max(0, yB - yA + 1) / min(Aarea, Barea)
+
+ def get_slice(self) -> Tuple[slice, slice]:
+ """ Returns the bounding box as a tuple of slices (y_slice, x_slice)
+
+ Returns
+ -------
+ Tuple[slice, slice]
+ (y_slice, x_slice)
+ """
+ roi_slice = np.s_[self.y_min:self.y_max + 1, self.x_min:self.x_max + 1]
+ return roi_slice
+
+ def apply_offset(self, offset_x: int, offset_y: int):
+ """Apply (x,y) offset to keeping coordinates
+
+ Parameters
+ ----------
+ offset_x : int
+ x-axis offset in pixels
+ offset_y : int
+ y-axis offset in pixels
+ """
+ self.x_min += offset_x
+ self.y_min += offset_y
+ self.x_max += offset_x
+ self.y_max += offset_y
+
+ def get_4_corners(self) -> List[Tuple]:
+ """Get 4 points (corners) describing the detection rectangle, each point in (x, y) format
+
+ Returns
+ -------
+ List[Tuple]
+ List of 4 rectangle corners in (x, y) format
+ """
+ if np.isclose(self.rot, 0.0):
+ return [
+ (self.x_min, self.y_min),
+ (self.x_min, self.y_max),
+ (self.x_max, self.y_max),
+ (self.x_max, self.y_min),
+ ]
+ else:
+ x_center = (self.x_min + self.x_max) / 2
+ y_center = (self.y_min + self.y_max) / 2
+
+ corners = np.array([
+ [self.x_min, self.y_min],
+ [self.x_min, self.y_max],
+ [self.x_max, self.y_max],
+ [self.x_max, self.y_min],
+ ])
+
+ xys = x_center + np.cos(self.rot) * (corners[:, 0] - x_center) - np.sin(self.rot) * (corners[:, 1] - y_center)
+ yys = y_center + np.sin(self.rot) * (corners[:, 0] - x_center) + np.cos(self.rot) * (corners[:, 1] - y_center)
+
+ return [(int(x), int(y)) for x, y in zip(xys, yys)]
+
+
+def transform_polygon_with_rings_epsg_to_extended_xy_pixels(
+ polygons: List[List[QgsPointXY]],
+ extended_extent: QgsRectangle,
+ img_size_y_pixels: int,
+ rlayer_units_per_pixel: float) -> List[List[Tuple]]:
+ """
+ Transform coordinates polygons to pixels contours (with cv2 format), in base_extent pixels system
+ :param polygons: List of tuples with two lists each (x and y points respoectively)
+ :param extended_extent:
+ :param img_size_y_pixels:
+ :param rlayer_units_per_pixel:
+ :return: 2D contours list
+ """
+ xy_pixel_contours = []
+ for polygon in polygons:
+ xy_pixel_contour = []
+
+ x_min_epsg = extended_extent.xMinimum()
+ y_min_epsg = extended_extent.yMinimum()
+ y_max_pixel = img_size_y_pixels - 1 # -1 to have the max pixel, not shape
+ for point_epsg in polygon:
+ x_epsg, y_epsg = point_epsg
+ x = round((x_epsg - x_min_epsg) / rlayer_units_per_pixel)
+ y = y_max_pixel - \
+ round((y_epsg - y_min_epsg) / rlayer_units_per_pixel)
+ # NOTE: here we can get pixels +-1 values, because we operate on already rounded bounding boxes
+ xy_pixel_contour.append((x, y))
+
+ # Values:
+ # extended_extent.height() / rlayer_units_per_pixel, extended_extent.width() / rlayer_units_per_pixel
+ # are not integers, because extents are aligned to grid, not pixels resolution
+
+ xy_pixel_contours.append(np.asarray(xy_pixel_contour))
+ return xy_pixel_contours
+
+
+def create_area_mask_image(vlayer_mask,
+ rlayer: QgsRasterLayer,
+ extended_extent: QgsRectangle,
+ rlayer_units_per_pixel: float,
+ image_shape_yx: Tuple[int, int],
+ files_handler: Optional[TempFilesHandler] = None) -> Optional[np.ndarray]:
+ """
+ Mask determining area to process (within extended_extent coordinates)
+ None if no mask layer provided.
+ """
+
+ if vlayer_mask is None:
+ return None
+
+ if files_handler is None:
+ img = np.zeros(shape=image_shape_yx, dtype=np.uint8)
+ else:
+ img = np.memmap(files_handler.get_area_mask_img_path(),
+ dtype=np.uint8,
+ mode='w+',
+ shape=image_shape_yx)
+
+ features = vlayer_mask.getFeatures()
+
+ if vlayer_mask.crs() != rlayer.crs():
+ xform = QgsCoordinateTransform()
+ xform.setSourceCrs(vlayer_mask.crs())
+ xform.setDestinationCrs(rlayer.crs())
+
+ # see https://docs.qgis.org/3.22/en/docs/pyqgis_developer_cookbook/vector.html#iterating-over-vector-layer
+ for feature in features:
+ print("Feature ID: ", feature.id())
+ geom = feature.geometry()
+
+ if vlayer_mask.crs() != rlayer.crs():
+ geom.transform(xform)
+
+ geom_single_type = QgsWkbTypes.isSingleType(geom.wkbType())
+
+ if geom.type() == QgsWkbTypes.PointGeometry:
+ logging.warning("Point geometry not supported!")
+ elif geom.type() == QgsWkbTypes.LineGeometry:
+ logging.warning("Line geometry not supported!")
+ elif geom.type() == QgsWkbTypes.PolygonGeometry:
+ polygons = []
+ if geom_single_type:
+ polygon = geom.asPolygon() # polygon with rings
+ polygons.append(polygon)
+ else:
+ polygons = geom.asMultiPolygon()
+
+ for polygon_with_rings in polygons:
+ polygon_with_rings_xy = transform_polygon_with_rings_epsg_to_extended_xy_pixels(
+ polygons=polygon_with_rings,
+ extended_extent=extended_extent,
+ img_size_y_pixels=image_shape_yx[0],
+ rlayer_units_per_pixel=rlayer_units_per_pixel)
+ # first polygon is actual polygon
+ cv2.fillPoly(img, pts=polygon_with_rings_xy[:1], color=255)
+ if len(polygon_with_rings_xy) > 1: # further polygons are rings
+ cv2.fillPoly(img, pts=polygon_with_rings_xy[1:], color=0)
+ else:
+ print("Unknown or invalid geometry")
+
+ return img
diff --git a/zipdeepness/processing/tile_params.py b/zipdeepness/processing/tile_params.py
new file mode 100644
index 0000000000000000000000000000000000000000..bea0b9e2337d7beae34a34efff0119bad81b6052
--- /dev/null
+++ b/zipdeepness/processing/tile_params.py
@@ -0,0 +1,176 @@
+"""
+This file contains utilities related to processing a tile.
+Tile is a small part of the ortophoto, which is being processed by the model one by one.
+"""
+
+from typing import Optional, Tuple
+
+import numpy as np
+from qgis.core import QgsRectangle
+
+from deepness.common.lazy_package_loader import LazyPackageLoader
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters
+
+cv2 = LazyPackageLoader('cv2')
+
+
+class TileParams:
+ """ Defines a single tile parameters - image that's being processed by model"""
+
+ def __init__(self,
+ x_bin_number: int,
+ y_bin_number: int,
+ x_bins_number: int,
+ y_bins_number: int,
+ params: MapProcessingParameters,
+ rlayer_units_per_pixel,
+ processing_extent: QgsRectangle):
+ """ init
+
+ Parameters
+ ----------
+ x_bin_number : int
+ what is the tile number in a row, counting from left side
+ y_bin_number : int
+ what is the tile number in a column
+ x_bins_number : int
+ how many tiles are there in a row
+ y_bins_number : int
+ how many tiles are there in a column
+ params : MapProcessingParameters
+ processing parameters
+ rlayer_units_per_pixel : _type_
+ How many rlayer crs units are in a pixel
+ processing_extent : QgsRectangle
+ """
+ self.x_bin_number = x_bin_number
+ self.y_bin_number = y_bin_number
+ self.x_bins_number = x_bins_number
+ self.y_bins_number = y_bins_number
+ self.stride_px = params.processing_stride_px
+ self.start_pixel_x = x_bin_number * self.stride_px
+ self.start_pixel_y = y_bin_number * self.stride_px
+ self.params = params
+ self.rlayer_units_per_pixel = rlayer_units_per_pixel
+
+ self.extent = self._calculate_extent(processing_extent) # type: QgsRectangle # tile extent in CRS cordinates
+
+ def _calculate_extent(self, processing_extent):
+ tile_extent = QgsRectangle(processing_extent) # copy
+ x_min = processing_extent.xMinimum() + self.start_pixel_x * self.rlayer_units_per_pixel
+ y_max = processing_extent.yMaximum() - self.start_pixel_y * self.rlayer_units_per_pixel
+ tile_extent.setXMinimum(x_min)
+ # extent needs to be on the further edge (so including the corner pixel, hence we do not subtract 1)
+ tile_extent.setXMaximum(x_min + self.params.tile_size_px * self.rlayer_units_per_pixel)
+ tile_extent.setYMaximum(y_max)
+ y_min = y_max - self.params.tile_size_px * self.rlayer_units_per_pixel
+ tile_extent.setYMinimum(y_min)
+ return tile_extent
+
+ def get_slice_on_full_image_for_entire_tile(self) -> Tuple[slice, slice]:
+ """ Obtain slice to get the entire tile from full final image,
+ including the overlapping parts.
+
+ Returns
+ -------
+ Tuple[slice, slice]
+ Slice to be used on the full image
+ """
+
+ # 'core' part of the tile (not overlapping with other tiles), for sure copied for each tile
+ x_min = self.start_pixel_x
+ x_max = self.start_pixel_x + self.params.tile_size_px - 1
+ y_min = self.start_pixel_y
+ y_max = self.start_pixel_y + self.params.tile_size_px - 1
+
+ roi_slice = np.s_[:, y_min:y_max + 1, x_min:x_max + 1]
+ return roi_slice
+
+ def get_slice_on_full_image_for_copying(self, tile_offset: int = 0):
+ """
+ As we are doing processing with overlap, we are not going to copy the entire tile result to final image,
+ but only the part that is not overlapping with the neighbouring tiles.
+ Edge tiles have special handling too.
+
+ :param tile_offset: how many pixels to cut from the tile result (to remove the padding)
+
+ :return Slice to be used on the full image
+ """
+ half_overlap = max((self.params.tile_size_px - self.stride_px) // 2 - 2*tile_offset, 0)
+
+ # 'core' part of the tile (not overlapping with other tiles), for sure copied for each tile
+ x_min = self.start_pixel_x + half_overlap
+ x_max = self.start_pixel_x + self.params.tile_size_px - half_overlap - 1
+ y_min = self.start_pixel_y + half_overlap
+ y_max = self.start_pixel_y + self.params.tile_size_px - half_overlap - 1
+
+ # edge tiles handling
+ if self.x_bin_number == 0:
+ x_min -= half_overlap
+ if self.y_bin_number == 0:
+ y_min -= half_overlap
+ if self.x_bin_number == self.x_bins_number-1:
+ x_max += half_overlap
+ if self.y_bin_number == self.y_bins_number-1:
+ y_max += half_overlap
+
+ x_min += tile_offset
+ x_max -= tile_offset
+ y_min += tile_offset
+ y_max -= tile_offset
+
+ roi_slice = np.s_[:, y_min:y_max + 1, x_min:x_max + 1]
+ return roi_slice
+
+ def get_slice_on_tile_image_for_copying(self, roi_slice_on_full_image=None, tile_offset: int = 0):
+ """
+ Similar to _get_slice_on_full_image_for_copying, but ROI is a slice on the tile
+ """
+ if not roi_slice_on_full_image:
+ roi_slice_on_full_image = self.get_slice_on_full_image_for_copying(tile_offset=tile_offset)
+
+ r = roi_slice_on_full_image
+ roi_slice_on_tile = np.s_[
+ :,
+ r[1].start - self.start_pixel_y - tile_offset:r[1].stop - self.start_pixel_y - tile_offset,
+ r[2].start - self.start_pixel_x - tile_offset:r[2].stop - self.start_pixel_x - tile_offset
+ ]
+ return roi_slice_on_tile
+
+ def is_tile_within_mask(self, mask_img: Optional[np.ndarray]):
+ """
+ To check if tile is within the mask image
+ """
+ if mask_img is None:
+ return True # if we don't have a mask, we are going to process all tiles
+
+ roi_slice = self.get_slice_on_full_image_for_copying()
+ mask_roi = mask_img[roi_slice[0]]
+ # check corners first
+ if mask_roi[0, 0] and mask_roi[1, -1] and mask_roi[-1, 0] and mask_roi[-1, -1]:
+ return True # all corners in mask, almost for sure a good tile
+
+ coverage_percentage = cv2.countNonZero(mask_roi) / (mask_roi.shape[0] * mask_roi.shape[1]) * 100
+ return coverage_percentage > 0 # TODO - for training we can use tiles with higher coverage only
+
+ def set_mask_on_full_img(self, full_result_img, tile_result):
+ if tile_result.shape[1] != self.params.tile_size_px or tile_result.shape[2] != self.params.tile_size_px:
+ tile_offset = (self.params.tile_size_px - tile_result.shape[1])//2
+
+ if tile_offset % 2 != 0:
+ raise Exception("Model output shape is not even, cannot calculate offset")
+
+ if tile_offset < 0:
+ raise Exception("Model output shape is bigger than tile size, cannot calculate offset")
+ else:
+ tile_offset = 0
+
+ roi_slice_on_full_image = self.get_slice_on_full_image_for_copying(tile_offset=tile_offset)
+ roi_slice_on_tile_image = self.get_slice_on_tile_image_for_copying(roi_slice_on_full_image, tile_offset=tile_offset)
+
+ full_result_img[roi_slice_on_full_image] = tile_result[roi_slice_on_tile_image]
+
+ def get_entire_tile_from_full_img(self, full_result_img) -> np.ndarray:
+ roi_slice_on_full_image = self.get_slice_on_full_image_for_entire_tile()
+ img = full_result_img[roi_slice_on_full_image]
+ return img
diff --git a/zipdeepness/python_requirements/requirements.txt b/zipdeepness/python_requirements/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..3156cf5aa5dc05772f4868c1008f25b5fea709c1
--- /dev/null
+++ b/zipdeepness/python_requirements/requirements.txt
@@ -0,0 +1,10 @@
+## CORE ##
+#numpy # available already from qgis repo
+#pyqt5 # available already from qgis repo
+
+
+# NOTE - for the time being - keep the same packages and versions in the `packages_installer_dialog.py`
+numpy<2.0.0
+onnxruntime-gpu>=1.12.1,<=1.17.0
+opencv-python-headless>=4.5.5.64,<=4.9.0.80
+rasterio
\ No newline at end of file
diff --git a/zipdeepness/python_requirements/requirements_development.txt b/zipdeepness/python_requirements/requirements_development.txt
new file mode 100644
index 0000000000000000000000000000000000000000..cb7da2e1bef6d85feffd344a2d1555bda3d6a4cc
--- /dev/null
+++ b/zipdeepness/python_requirements/requirements_development.txt
@@ -0,0 +1,21 @@
+# Install main requirements
+-r requirements.txt
+
+# Requirements required only to test and debug code while developing
+pytest==7.4.0
+pytest-cov==4.1.0
+flake8==6.0.0
+onnx==1.14.0
+
+# docs
+sphinx==6.1.3
+sphinxcontrib-youtube==1.2.0
+sphinx-autodoc-typehints==1.22
+sphinx-autopackagesummary==1.3
+sphinx-rtd-theme==0.5.1
+m2r2==0.3.3
+sphinx-rtd-size==0.2.0
+
+# vscode
+pylint==2.17.4
+autopep8==2.0.2
diff --git a/zipdeepness/resources.py b/zipdeepness/resources.py
new file mode 100644
index 0000000000000000000000000000000000000000..f434ed642379ecd17d8b9a73cc1eeeebcf82bcec
--- /dev/null
+++ b/zipdeepness/resources.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+
+# Resource object code
+#
+# Created by: The Resource Compiler for PyQt5 (Qt v5.15.3)
+#
+# WARNING! All changes made in this file will be lost!
+
+from PyQt5 import QtCore
+
+qt_resource_data = b"\
+\x00\x00\x02\x32\
+\x89\
+\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\
+\x00\x00\x17\x00\x00\x00\x18\x08\x06\x00\x00\x00\x11\x7c\x66\x75\
+\x00\x00\x00\x04\x73\x42\x49\x54\x08\x08\x08\x08\x7c\x08\x64\x88\
+\x00\x00\x01\xe9\x49\x44\x41\x54\x48\x89\xa5\x95\xdf\x6a\x13\x51\
+\x10\xc6\x7f\x67\xb2\xa6\xa4\xa2\x5e\x14\x1a\x08\x8a\x4d\xad\x8b\
+\x15\xd4\x2b\x7d\x80\x7a\x21\x78\xe9\x75\x9f\xc1\x27\x28\xb4\x0f\
+\xe1\x23\xf4\xce\x27\x10\xb4\x0f\xa0\x05\x2f\x0a\x0d\x04\xd3\x82\
+\xa4\x42\x85\x52\x2c\x92\x54\x76\xcf\x1c\x2f\x36\xc5\xa4\xe7\x4f\
+\x9b\xf8\x41\x60\x99\x73\xe6\x9b\xef\x9b\x9d\xd9\x18\x2e\x43\x97\
+\x14\x63\x8c\x17\xbf\x80\xc3\x21\x87\x12\x3d\x1f\x83\x7f\xe9\xd4\
+\x9c\x26\x33\x0c\x06\xdb\xb6\xb3\x91\x2f\x1c\x2e\xa0\xe8\x15\x59\
+\x82\xb6\xb5\xfa\x2d\x29\xb6\x6d\x59\xbf\xb5\xee\xeb\xc8\x75\x44\
+\xa4\x4a\xd1\xef\x73\xb2\xb1\xc1\xd9\xf6\x36\xb6\x6d\x91\x40\xf1\
+\x14\x8e\xf4\x88\x96\xb4\xc6\xc8\x9d\xf3\x6f\x95\x25\xfb\xab\x42\
+\xcd\x4e\x47\x7e\x09\x91\xe4\x2c\xe3\xe3\xab\xff\x22\x4e\x90\x03\
+\x6f\xdf\xc1\x87\xd7\xe0\x4c\xc0\xd9\xf5\x60\x4c\xae\x0e\xc0\x11\
+\x9f\x3e\x3a\xb9\x22\xe5\x74\x4e\x1c\x4e\xb4\x2b\x68\x57\x58\x1b\
+\xee\x20\xb1\x21\x59\xed\x0a\xc5\x5c\x7a\x82\xc6\x48\xf9\xde\x1c\
+\xf2\xa8\x87\x71\x30\x61\xfb\x4e\xfe\x8b\x33\x6e\x5f\xdf\x81\x1b\
+\xcb\xff\x53\xb7\x3c\xdb\x17\x10\x01\x7c\x72\x80\x07\xcb\x3d\x0e\
+\xb2\xe5\x78\x01\x53\x56\x3d\x2c\x32\xe5\xc9\x5e\x09\xf5\x7a\x75\
+\x38\xb9\xd9\x41\x72\x80\x17\xf7\x3f\xf3\x65\xee\x79\xb8\x00\x45\
+\x01\x65\x09\x8d\x46\xe4\x42\x9a\x1c\xe0\xe5\xbd\x4f\xec\x34\xd6\
+\x52\xf9\x49\x18\xa5\x7a\x8b\x86\xf0\xb8\xa4\x1d\x5c\x41\x7e\xf1\
+\x30\x80\x41\x03\x82\x36\x9b\x2b\xc7\xfc\x94\xc5\xd9\xc9\x01\x14\
+\x34\xe6\x60\x3e\x1f\x30\x0c\xd7\x8e\x62\x62\xac\x36\x61\x33\x76\
+\x71\xd0\x9d\x8f\xef\x41\x04\x9e\xca\x94\x7a\x00\xc9\x35\xbd\xcd\
+\x7b\x8f\xe1\xc6\x39\x60\xa6\xfc\xa4\x02\x2d\xfb\x23\x7e\xd8\xc9\
+\xa1\x7e\x5e\x49\x33\xce\x27\xdf\x85\xdd\x14\x79\xbf\x77\x17\xe3\
+\x4d\xaf\x73\xbc\x7f\x03\x52\x4e\x44\x83\xfe\x66\x6a\x4d\xe7\xa1\
+\x43\xec\x44\x30\xd8\x96\x98\x7a\x05\x1d\x2d\x9d\xbf\x78\xc6\x7a\
+\x62\xa2\xea\x2c\x58\x09\x14\xb7\x60\xb3\x95\xe3\x13\x64\xf1\xdf\
+\xe0\x77\x72\xaf\x25\x51\xe5\x00\x5b\xb0\x15\x8a\xd7\xa0\xf6\xfb\
+\x5b\xf3\x26\x8c\xfe\x7b\xbf\x3e\x0d\x12\x03\xfc\x05\xb8\x94\xac\
+\x7e\xe6\x88\x91\xed\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\
+\x82\
+"
+
+qt_resource_name = b"\
+\x00\x07\
+\x07\x3b\xe0\xb3\
+\x00\x70\
+\x00\x6c\x00\x75\x00\x67\x00\x69\x00\x6e\x00\x73\
+\x00\x1b\
+\x02\xd8\xc9\xeb\
+\x00\x64\
+\x00\x65\x00\x65\x00\x70\x00\x5f\x00\x73\x00\x65\x00\x67\x00\x6d\x00\x65\x00\x6e\x00\x74\x00\x61\x00\x74\x00\x69\x00\x6f\x00\x6e\
+\x00\x5f\x00\x66\x00\x72\x00\x61\x00\x6d\x00\x65\x00\x77\x00\x6f\x00\x72\x00\x6b\
+\x00\x06\
+\x07\x03\x7d\xc3\
+\x00\x69\
+\x00\x6d\x00\x61\x00\x67\x00\x65\x00\x73\
+\x00\x08\
+\x0a\x61\x5a\xa7\
+\x00\x69\
+\x00\x63\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\
+"
+
+qt_resource_struct_v1 = b"\
+\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
+\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
+\x00\x00\x00\x14\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\
+\x00\x00\x00\x50\x00\x02\x00\x00\x00\x01\x00\x00\x00\x04\
+\x00\x00\x00\x62\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
+"
+
+qt_resource_struct_v2 = b"\
+\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
+\x00\x00\x00\x00\x00\x00\x00\x00\
+\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
+\x00\x00\x00\x00\x00\x00\x00\x00\
+\x00\x00\x00\x14\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\
+\x00\x00\x00\x00\x00\x00\x00\x00\
+\x00\x00\x00\x50\x00\x02\x00\x00\x00\x01\x00\x00\x00\x04\
+\x00\x00\x00\x00\x00\x00\x00\x00\
+\x00\x00\x00\x62\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
+\x00\x00\x01\x82\xb5\xcc\x01\x44\
+"
+
+qt_version = [int(v) for v in QtCore.qVersion().split('.')]
+if qt_version < [5, 8, 0]:
+ rcc_version = 1
+ qt_resource_struct = qt_resource_struct_v1
+else:
+ rcc_version = 2
+ qt_resource_struct = qt_resource_struct_v2
+
+def qInitResources():
+ QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data)
+
+def qCleanupResources():
+ QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data)
+
+qInitResources()
diff --git a/zipdeepness/resources.qrc b/zipdeepness/resources.qrc
new file mode 100644
index 0000000000000000000000000000000000000000..5385347c5a77e41690a1a5361565f436d3be57bc
--- /dev/null
+++ b/zipdeepness/resources.qrc
@@ -0,0 +1,5 @@
+
+
+ images/icon.png
+
+
diff --git a/zipdeepness/widgets/__init__.py b/zipdeepness/widgets/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/zipdeepness/widgets/input_channels_mapping/input_channels_mapping_widget.py b/zipdeepness/widgets/input_channels_mapping/input_channels_mapping_widget.py
new file mode 100644
index 0000000000000000000000000000000000000000..e9633d7cbbd3d4628b09c6036957b0090daffcb9
--- /dev/null
+++ b/zipdeepness/widgets/input_channels_mapping/input_channels_mapping_widget.py
@@ -0,0 +1,177 @@
+"""
+This file contains a single widget, which is embedded in the main dockwiget - to select channels mapping
+"""
+
+
+import os
+from typing import Optional, List
+
+from qgis.PyQt import QtWidgets, uic
+from qgis.PyQt.QtWidgets import QComboBox
+from qgis.PyQt.QtWidgets import QLabel
+from qgis.core import Qgis, QgsRasterLayer
+
+from deepness.common.channels_mapping import ChannelsMapping, ImageChannelStandaloneBand, \
+ ImageChannelCompositeByte
+from deepness.common.channels_mapping import ImageChannel
+from deepness.common.config_entry_key import ConfigEntryKey
+from deepness.processing.models.model_base import ModelBase
+
+FORM_CLASS, _ = uic.loadUiType(os.path.join(
+ os.path.dirname(__file__), 'input_channels_mapping_widget.ui'))
+
+
+class InputChannelsMappingWidget(QtWidgets.QWidget, FORM_CLASS):
+ """
+ Widget responsible for mapping image channels to model input channels.
+ Allows to define the channels mapping (see `deepness.common.channels_mapping` for details).
+
+ UI design defined in `input_channels_mapping_widget.ui` file.
+ """
+
+ def __init__(self, rlayer, parent=None):
+ super(InputChannelsMappingWidget, self).__init__(parent)
+ self.setupUi(self)
+ self._model_wrapper: Optional[ModelBase] = None
+ self._rlayer: QgsRasterLayer = rlayer
+ self._create_connections()
+ self._channels_mapping = ChannelsMapping()
+
+ self._channels_mapping_labels: List[QLabel] = []
+ self._channels_mapping_comboboxes: List[QComboBox] = []
+
+ self._selection_mode_changed()
+
+ def load_ui_from_config(self):
+ is_advanced_mode = ConfigEntryKey.INPUT_CHANNELS_MAPPING__ADVANCED_MODE.get()
+ self.radioButton_advancedMapping.setChecked(is_advanced_mode)
+
+ if is_advanced_mode:
+ mapping_list_str = ConfigEntryKey.INPUT_CHANNELS_MAPPING__MAPPING_LIST_STR.get()
+ mapping_list = [int(v) for v in mapping_list_str]
+ self._channels_mapping.load_mapping_from_list(mapping_list)
+
+ def save_ui_to_config(self):
+ is_advanced_mode = self.radioButton_advancedMapping.isChecked()
+ ConfigEntryKey.INPUT_CHANNELS_MAPPING__ADVANCED_MODE.set(is_advanced_mode)
+
+ if is_advanced_mode:
+ mapping_list = self._channels_mapping.get_mapping_as_list()
+ mapping_list_str = [str(v) for v in mapping_list]
+ ConfigEntryKey.INPUT_CHANNELS_MAPPING__MAPPING_LIST_STR.set(mapping_list_str)
+
+ def get_channels_mapping(self) -> ChannelsMapping:
+ """ Get the channels mapping currently selected in the UI """
+ if self.radioButton_defaultMapping.isChecked():
+ return self._channels_mapping.get_as_default_mapping()
+ else: # advanced mapping
+ return self._channels_mapping
+
+ def get_channels_mapping_for_training_data_export(self) -> ChannelsMapping:
+ """ Get the channels mapping to be used for the `training data export tool`.
+ It is not channels mapping exactly, because we do not have the model, but we can use it
+ """
+ mapping = self._channels_mapping.get_as_default_mapping()
+ mapping.set_number_of_model_inputs_same_as_image_channels()
+ return mapping
+
+ def _create_connections(self):
+ self.radioButton_defaultMapping.clicked.connect(self._selection_mode_changed)
+ self.radioButton_advancedMapping.clicked.connect(self._selection_mode_changed)
+
+ def _selection_mode_changed(self):
+ is_advanced = self.radioButton_advancedMapping.isChecked()
+ self.widget_mapping.setVisible(is_advanced)
+
+ def set_model(self, model_wrapper: ModelBase):
+ """ Set the model for which we are creating the mapping here """
+ self._model_wrapper = model_wrapper
+ number_of_channels = self._model_wrapper.get_number_of_channels()
+ self.label_modelInputs.setText(f'{number_of_channels}')
+ self._channels_mapping.set_number_of_model_inputs(number_of_channels)
+ self.regenerate_mapping()
+
+ def set_rlayer(self, rlayer: QgsRasterLayer):
+ """ Set the raster layer (ortophoto file) which is selected (for which we create the mapping here)"""
+ self._rlayer = rlayer
+
+ if rlayer:
+ number_of_image_bands = rlayer.bandCount()
+ else:
+ number_of_image_bands = 0
+
+ image_channels = [] # type: List[ImageChannel]
+
+ if number_of_image_bands == 1:
+ # if there is one band, then there is probably more "bands" hidden in a more complex data type (e.g. RGBA)
+ data_type = rlayer.dataProvider().dataType(1)
+ if data_type in [Qgis.DataType.Byte, Qgis.DataType.UInt16, Qgis.DataType.Int16,
+ Qgis.DataType.Float32]:
+ image_channel = ImageChannelStandaloneBand(
+ band_number=1,
+ name=rlayer.bandName(1))
+ image_channels.append(image_channel)
+ elif data_type == Qgis.DataType.ARGB32:
+ # Alpha channel is at byte number 3, red is byte 2, ... - reversed order
+ band_names = [
+ 'Alpha (band 4)',
+ 'Red (band 1)',
+ 'Green (band 2)',
+ 'Blue (band 3)',
+ ]
+ for i in [1, 2, 3, 0]: # We want order of model inputs as 'RGB' first and then 'A'
+ image_channel = ImageChannelCompositeByte(
+ byte_number=3 - i, # bytes are in reversed order
+ name=band_names[i])
+ image_channels.append(image_channel)
+ else:
+ raise Exception("Invalid input layer data type!")
+ else:
+ for band_number in range(1, number_of_image_bands + 1): # counted from 1
+ image_channel = ImageChannelStandaloneBand(
+ band_number=band_number,
+ name=rlayer.bandName(band_number))
+ image_channels.append(image_channel)
+
+ self.label_imageInputs.setText(f'{len(image_channels)}')
+ self._channels_mapping.set_image_channels(image_channels)
+ self.regenerate_mapping()
+
+ def _combobox_index_changed(self, model_input_channel_number):
+ combobox = self._channels_mapping_comboboxes[model_input_channel_number] # type: QComboBox
+ image_channel_index = combobox.currentIndex()
+ # print(f'Combobox {model_input_channel_number} changed to {current_index}')
+ self._channels_mapping.set_image_channel_for_model_input(
+ model_input_number=model_input_channel_number,
+ image_channel_index=image_channel_index)
+
+ def regenerate_mapping(self):
+ """ Regenerate the mapping after the model or ortophoto was changed or mapping was read from config"""
+ for combobox in self._channels_mapping_comboboxes:
+ self.gridLayout_mapping.removeWidget(combobox)
+ self._channels_mapping_comboboxes.clear()
+ for label in self._channels_mapping_labels:
+ self.gridLayout_mapping.removeWidget(label)
+ self._channels_mapping_labels.clear()
+
+ for model_input_channel_number in range(self._channels_mapping.get_number_of_model_inputs()):
+ label = QLabel(self)
+ label.setText(f"Model input {model_input_channel_number}:")
+ combobox = QComboBox(self)
+ for image_channel in self._channels_mapping.get_image_channels():
+ combobox.addItem(image_channel.name)
+
+ if self._channels_mapping.get_number_of_image_channels() > 0:
+ # image channel witch is currently assigned to the current model channel
+ image_channel_index = self._channels_mapping.get_image_channel_index_for_model_input(
+ model_input_channel_number)
+ combobox.setCurrentIndex(image_channel_index)
+
+ combobox.currentIndexChanged.connect(
+ lambda _, v=model_input_channel_number: self._combobox_index_changed(v))
+
+ self.gridLayout_mapping.addWidget(label, model_input_channel_number, 0)
+ self.gridLayout_mapping.addWidget(combobox, model_input_channel_number, 1, 1, 2)
+
+ self._channels_mapping_comboboxes.append(combobox)
+ self._channels_mapping_labels.append(label)
diff --git a/zipdeepness/widgets/input_channels_mapping/input_channels_mapping_widget.ui b/zipdeepness/widgets/input_channels_mapping/input_channels_mapping_widget.ui
new file mode 100644
index 0000000000000000000000000000000000000000..c22efbe2bd93c5f5d3ae95ed9c7ade144e97f330
--- /dev/null
+++ b/zipdeepness/widgets/input_channels_mapping/input_channels_mapping_widget.ui
@@ -0,0 +1,100 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 399
+ 378
+
+
+
+ Form
+
+
+ -
+
+
-
+
+
+ Model inputs (channels):
+
+
+
+ -
+
+
+ Image inputs (bands):
+
+
+
+ -
+
+
+ ...
+
+
+
+ -
+
+
+ ...
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ -
+
+
+ Default (image channels passed
+in sequence as input channels)
+
+
+ true
+
+
+
+ -
+
+
+ Advanced (manually select which input image
+channel is assigned to each model input)
+
+
+
+ -
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
-
+
+
+
+
+
+
+
+
+
+
diff --git a/zipdeepness/widgets/training_data_export_widget/training_data_export_widget.py b/zipdeepness/widgets/training_data_export_widget/training_data_export_widget.py
new file mode 100644
index 0000000000000000000000000000000000000000..0854aa2a55453b25ce2c7a4fb48d0fc7a79cc7ea
--- /dev/null
+++ b/zipdeepness/widgets/training_data_export_widget/training_data_export_widget.py
@@ -0,0 +1,96 @@
+"""
+This file contains a single widget, which is embedded in the main dockwiget - to select the training data export parameters
+"""
+
+import os
+
+from qgis.PyQt import QtWidgets, uic
+from qgis.PyQt.QtWidgets import QFileDialog
+from qgis.core import QgsMapLayerProxyModel
+from qgis.core import QgsProject
+
+from deepness.common.config_entry_key import ConfigEntryKey
+from deepness.common.processing_parameters.map_processing_parameters import MapProcessingParameters
+from deepness.common.processing_parameters.training_data_export_parameters import \
+ TrainingDataExportParameters
+
+FORM_CLASS, _ = uic.loadUiType(os.path.join(
+ os.path.dirname(__file__), 'training_data_export_widget.ui'))
+
+
+class TrainingDataExportWidget(QtWidgets.QWidget, FORM_CLASS):
+ """
+ Widget responsible for defining the parameters for the Trainign Data Export process (not doing the actual export).
+
+ UI design defined in the `training_data_export_widget.ui` file.
+ """
+
+ def __init__(self, rlayer, parent=None):
+ super(TrainingDataExportWidget, self).__init__(parent)
+ self.setupUi(self)
+ self._create_connections()
+ self._setup_misc_ui()
+
+ def load_ui_from_config(self):
+ layers = QgsProject.instance().mapLayers()
+
+ self.lineEdit_outputDirPath.setText(ConfigEntryKey.DATA_EXPORT_DIR.get())
+ self.checkBox_exportImageTiles.setChecked(
+ ConfigEntryKey.DATA_EXPORT_TILES_ENABLED.get())
+ self.checkBox_exportMaskEnabled.setChecked(
+ ConfigEntryKey.DATA_EXPORT_SEGMENTATION_MASK_ENABLED.get())
+
+ segmentation_layer_id = ConfigEntryKey.DATA_EXPORT_SEGMENTATION_MASK_ID.get()
+ if segmentation_layer_id and segmentation_layer_id in layers:
+ self.mMapLayerComboBox_inputLayer.setLayer(layers[segmentation_layer_id])
+
+ def save_ui_to_config(self):
+ ConfigEntryKey.DATA_EXPORT_DIR.set(self.lineEdit_outputDirPath.text())
+ ConfigEntryKey.DATA_EXPORT_TILES_ENABLED.set(
+ self.checkBox_exportImageTiles.isChecked())
+ ConfigEntryKey.DATA_EXPORT_SEGMENTATION_MASK_ENABLED.set(
+ self.checkBox_exportMaskEnabled.isChecked())
+ ConfigEntryKey.DATA_EXPORT_SEGMENTATION_MASK_ID.set(self.get_segmentation_mask_layer_id())
+
+ def _browse_output_directory(self):
+ current_directory = self.lineEdit_outputDirPath.text()
+ if not current_directory:
+ current_directory = os.path.expanduser('~')
+ new_directory = QFileDialog.getExistingDirectory(
+ self,
+ "Select Directory",
+ current_directory,
+ QFileDialog.ShowDirsOnly)
+ self.lineEdit_outputDirPath.setText(new_directory)
+
+ def _enable_disable_mask_layer_selection(self):
+ is_enabled = self.checkBox_exportMaskEnabled.isChecked()
+ self.mMapLayerComboBox_maskLayer.setEnabled(is_enabled)
+
+ def _create_connections(self):
+ self.checkBox_exportMaskEnabled.toggled.connect(self._enable_disable_mask_layer_selection)
+ self.pushButton_browseOutputDirectory.clicked.connect(self._browse_output_directory)
+
+ def _setup_misc_ui(self):
+ self.mMapLayerComboBox_maskLayer.setFilters(QgsMapLayerProxyModel.VectorLayer)
+ self._enable_disable_mask_layer_selection()
+
+ def get_segmentation_mask_layer_id(self):
+ if not self.checkBox_exportMaskEnabled.isChecked():
+ return None
+ return self.mMapLayerComboBox_maskLayer.currentLayer().id()
+
+ def get_training_data_export_parameters(self, map_processing_parameters: MapProcessingParameters):
+ """ Get the parameters from the UI for the data exporting process"""
+ if self.checkBox_exportMaskEnabled.isChecked():
+ segmentation_mask_layer_id = self.mMapLayerComboBox_maskLayer.currentLayer().id()
+ else:
+ segmentation_mask_layer_id = None
+
+ params = TrainingDataExportParameters(
+ **map_processing_parameters.__dict__,
+ export_image_tiles=self.checkBox_exportImageTiles.isChecked(),
+ segmentation_mask_layer_id=segmentation_mask_layer_id,
+ output_directory_path=self.lineEdit_outputDirPath.text(),
+ )
+ return params
diff --git a/zipdeepness/widgets/training_data_export_widget/training_data_export_widget.ui b/zipdeepness/widgets/training_data_export_widget/training_data_export_widget.ui
new file mode 100644
index 0000000000000000000000000000000000000000..9540d968c4003af591b9701aac707dba90ff2069
--- /dev/null
+++ b/zipdeepness/widgets/training_data_export_widget/training_data_export_widget.ui
@@ -0,0 +1,133 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 461
+ 378
+
+
+
+ Form
+
+
+ -
+
+
-
+
+
+ -
+
+
+ Resolution [cm/px]:
+
+
+
+ -
+
+
+ Path to directory where the generated data will be saved.
+
+
+
+ -
+
+
+ Whether tiles from the "Input layer" (the main ortophoto) should be created.
+
+
+ Export image tiles:
+
+
+ true
+
+
+
+ -
+
+
+ Browse...
+
+
+
+ -
+
+
+ Input layer selected in
+"Input Layer" section
+
+
+
+ -
+
+
+ Tile size [px]:
+
+
+
+ -
+
+
+ Tiles overlap [%]:
+
+
+
+ -
+
+
+ Output dir path:
+
+
+
+ -
+
+
+ <html><head/><body><p>Whether tiles from the segmentation mask should be created.</p><p>Can be used to create training images for the model, if you already have manually annotated the image.</p></body></html>
+
+
+ Export segmentation
+mask for layer:
+
+
+
+ -
+
+
+ Selected in section
+"Processing parameters"
+
+
+
+ -
+
+
+ Selected in section
+"Processing parameters"
+
+
+
+ -
+
+
+ Selected in section
+"Processing parameters"
+
+
+
+
+
+
+
+
+
+ QgsMapLayerComboBox
+ QComboBox
+
+
+
+
+
+