From b12c31c8c4937cceb15da0a5339c76e7a5e9ffe9 Mon Sep 17 00:00:00 2001 From: ptresson <paul.tresson@ird.fr> Date: Fri, 30 Aug 2024 15:49:20 +0200 Subject: [PATCH] add main code (untested yet) --- src/__init__.py | 0 src/iamap/__init__.py | 10 + src/iamap/clustering.py | 494 +++++++++++ .../packages_installer_dialog.py | 382 ++++++++ .../packages_installer_dialog.ui | 65 ++ src/iamap/dialogs/resizable_message_box.py | 20 + src/iamap/encoder.py | 817 ++++++++++++++++++ src/iamap/iamap.py | 234 +++++ src/iamap/icons/__init__.py | 16 + .../icons/chart-scatter-3d-svgrepo-com.svg | 4 + .../chart-scatterplot-solid-svgrepo-com.svg | 19 + src/iamap/icons/encoder_tool.svg | 92 ++ src/iamap/icons/forest-svgrepo-com.svg | 26 + .../icons/network-base-solid-svgrepo-com.svg | 22 + .../icons/network-platform-svgrepo-com.svg | 15 + ...atter-plot-outline-alerted-svgrepo-com.svg | 6 + src/iamap/icons/scatter-plot-svgrepo-com.svg | 6 + src/iamap/provider.py | 46 + src/iamap/random_forest.py | 599 +++++++++++++ src/iamap/reduction.py | 568 ++++++++++++ src/iamap/requirements.txt | 4 + src/iamap/similarity.py | 473 ++++++++++ src/iamap/utils/geo.py | 118 +++ src/iamap/utils/torchgeo.py | 44 + 24 files changed, 4080 insertions(+) create mode 100644 src/__init__.py create mode 100644 src/iamap/__init__.py create mode 100644 src/iamap/clustering.py create mode 100644 src/iamap/dialogs/packages_installer/packages_installer_dialog.py create mode 100644 src/iamap/dialogs/packages_installer/packages_installer_dialog.ui create mode 100644 src/iamap/dialogs/resizable_message_box.py create mode 100644 src/iamap/encoder.py create mode 100644 src/iamap/iamap.py create mode 100644 src/iamap/icons/__init__.py create mode 100644 src/iamap/icons/chart-scatter-3d-svgrepo-com.svg create mode 100644 src/iamap/icons/chart-scatterplot-solid-svgrepo-com.svg create mode 100644 src/iamap/icons/encoder_tool.svg create mode 100644 src/iamap/icons/forest-svgrepo-com.svg create mode 100644 src/iamap/icons/network-base-solid-svgrepo-com.svg create mode 100644 src/iamap/icons/network-platform-svgrepo-com.svg create mode 100644 src/iamap/icons/scatter-plot-outline-alerted-svgrepo-com.svg create mode 100644 src/iamap/icons/scatter-plot-svgrepo-com.svg create mode 100644 src/iamap/provider.py create mode 100644 src/iamap/random_forest.py create mode 100644 src/iamap/reduction.py create mode 100644 src/iamap/requirements.txt create mode 100644 src/iamap/similarity.py create mode 100644 src/iamap/utils/geo.py create mode 100644 src/iamap/utils/torchgeo.py diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/iamap/__init__.py b/src/iamap/__init__.py new file mode 100644 index 0000000..e8e5304 --- /dev/null +++ b/src/iamap/__init__.py @@ -0,0 +1,10 @@ +import os +import inspect + +cmd_folder = os.path.split(inspect.getfile(inspect.currentframe()))[0] + +def classFactory(iface): + from .dialogs.packages_installer import packages_installer_dialog + packages_installer_dialog.check_required_packages_and_install_if_necessary(iface=iface) + from .iamap import IAMap + return IAMap(iface, cmd_folder) diff --git a/src/iamap/clustering.py b/src/iamap/clustering.py new file mode 100644 index 0000000..f2b19b0 --- /dev/null +++ b/src/iamap/clustering.py @@ -0,0 +1,494 @@ +import os +import numpy as np +from pathlib import Path +from typing import Dict, Any +import joblib + +import rasterio +from rasterio import windows +from qgis.PyQt.QtCore import QCoreApplication +from qgis.core import (Qgis, + QgsGeometry, + QgsProcessingParameterBoolean, + QgsProcessingParameterEnum, + QgsCoordinateTransform, + QgsProcessingException, + QgsProcessingAlgorithm, + QgsProcessingParameterRasterLayer, + QgsProcessingParameterFolderDestination, + QgsProcessingParameterBand, + QgsProcessingParameterNumber, + QgsProcessingParameterExtent, + QgsProcessingParameterCrs, + QgsProcessingParameterDefinition, + ) + + +from sklearn.cluster import KMeans + +import json + + + +class ClusterAlgorithm(QgsProcessingAlgorithm): + """ + """ + + INPUT = 'INPUT' + BANDS = 'BANDS' + EXTENT = 'EXTENT' + LOAD = 'LOAD' + OUTPUT = 'OUTPUT' + RESOLUTION = 'RESOLUTION' + CRS = 'CRS' + CLUSTERS = 'CLUSTERS' + SUBSET = 'SUBSET' + METHOD = 'METHOD' + SAVE_MODEL = 'SAVE_MODEL' + + + def initAlgorithm(self, config=None): + """ + Here we define the inputs and output of the algorithm, along + with some other properties. + """ + cwd = Path(__file__).parent.absolute() + + self.addParameter( + QgsProcessingParameterRasterLayer( + name=self.INPUT, + description=self.tr( + 'Input raster layer or image file path'), + defaultValue=os.path.join(cwd,'rasters','test.tif'), + ), + ) + + self.addParameter( + QgsProcessingParameterBand( + name=self.BANDS, + description=self.tr( + 'Selected Bands (defaults to all bands selected)'), + defaultValue=None, + parentLayerParameterName=self.INPUT, + optional=True, + allowMultiple=True, + ) + ) + + crs_param = QgsProcessingParameterCrs( + name=self.CRS, + description=self.tr('Target CRS (default to original CRS)'), + optional=True, + ) + + res_param = QgsProcessingParameterNumber( + name=self.RESOLUTION, + description=self.tr( + 'Target resolution in meters (default to native resolution)'), + type=QgsProcessingParameterNumber.Double, + optional=True, + minValue=0, + maxValue=100000 + ) + + self.addParameter( + QgsProcessingParameterExtent( + name=self.EXTENT, + description=self.tr( + 'Processing extent (default to the entire image)'), + optional=True + ) + ) + self.method_opt = ['K-means', '--Empty--'] + self.addParameter ( + QgsProcessingParameterEnum( + name = self.METHOD, + description = self.tr( + 'Method for the dimension reduction'), + defaultValue = 0, + options = self.method_opt, + + ) + ) + + self.addParameter( + QgsProcessingParameterNumber( + name=self.CLUSTERS, + description=self.tr( + 'Number of target clusters'), + type=QgsProcessingParameterNumber.Integer, + defaultValue = 5, + minValue=1, + maxValue=1024 + ) + ) + subset_param = QgsProcessingParameterNumber( + name=self.SUBSET, + description=self.tr( + 'Select a subset of random pixels of the image to fit transform'), + type=QgsProcessingParameterNumber.Integer, + defaultValue=None, + minValue=1, + maxValue=10_000, + optional=True, + ) + + save_param = QgsProcessingParameterBoolean( + self.SAVE_MODEL, + self.tr("Save projection model after fit."), + defaultValue=True + ) + + + self.addParameter( + QgsProcessingParameterFolderDestination( + self.OUTPUT, + self.tr( + "Output directory (choose the location that the image features will be saved)"), + defaultValue=os.path.join(cwd,'features'), + ) + ) + + + for param in (crs_param, res_param, subset_param, save_param): + param.setFlags( + param.flags() | QgsProcessingParameterDefinition.FlagAdvanced) + self.addParameter(param) + + + + def processAlgorithm(self, parameters, context, feedback): + """ + Here is where the processing itself takes place. + """ + self.process_options(parameters, context, feedback) + + input_bands = [i_band -1 for i_band in self.selected_bands] + + if self.method == 'K-means': + proj = KMeans(int(self.nclusters)) + save_file = 'kmeans_cluster.pkl' + params = proj.get_params() + + out_path = os.path.join(self.output_dir, save_file) + + with rasterio.open(self.rlayer_path) as ds: + transform = ds.transform + crs = ds.crs + win = windows.from_bounds( + self.extent.xMinimum(), + self.extent.yMinimum(), + self.extent.xMaximum(), + self.extent.yMaximum(), + transform=transform + ) + raster = ds.read(window=win) + transform = ds.window_transform(win) + raster = np.transpose(raster, (1,2,0)) + raster = raster[:,:,input_bands] + + feedback.pushInfo(f'{raster.shape}') + feedback.pushInfo(f'{raster.reshape(-1, raster.shape[0]).shape}') + + if self.subset: + feedback.pushInfo(f'Using a random subset of {self.subset} pixels') + fit_raster = raster.reshape(-1, raster.shape[-1]) + nsamples = fit_raster.shape[0] + + # Generate random indices to select subset_size number of samples + np.random.seed(42) + random_indices = np.random.choice(nsamples, size=self.subset, replace=False) + fit_raster = fit_raster[random_indices,:] + feedback.pushInfo(f'Starting fit\n') + proj.fit(fit_raster) + if self.save_model: + joblib.dump(proj, out_path) + + feedback.pushInfo(f'starting inference\n') + proj_img = proj.predict(raster.reshape(-1, raster.shape[-1])) + + + else: + proj_img = proj.fit_predict(raster.reshape(-1, raster.shape[-1])) + if self.save_model: + joblib.dump(proj, out_path) + + proj_img = proj_img.reshape((raster.shape[0], raster.shape[1],-1)) + height, width, channels = proj_img.shape + + + dst_path = os.path.join(self.output_dir,'cluster.tif') + params_file = os.path.join(self.output_dir, 'cluster_parameters.json') + + + if os.path.exists(dst_path): + i = 1 + while True: + modified_output_file = os.path.join(self.output_dir, f"cluster_{i}.tif") + if not os.path.exists(modified_output_file): + dst_path = modified_output_file + break + i += 1 + + if os.path.exists(params_file): + i = 1 + while True: + modified_output_file_params = os.path.join(self.output_dir, f"cluster_parameters_{i}.json") + if not os.path.exists(modified_output_file_params): + params_file = modified_output_file_params + break + i += 1 + + + with rasterio.open(dst_path, 'w', driver='GTiff', + height=height, width=width, count=channels, dtype='int8', + crs=crs, transform=transform) as dst_ds: + dst_ds.write(np.transpose(proj_img, (2, 0, 1))) + + with open(params_file, 'w') as f: + json.dump(params, f, indent=4) + feedback.pushInfo(f"Parameters saved to {params_file}") + + parameters['OUTPUT_RASTER']=dst_path + + return {'OUTPUT_RASTER':dst_path} + + def process_options(self,parameters, context, feedback): + self.iPatch = 0 + + self.feature_dir = "" + + feedback.pushInfo( + f'PARAMETERS :\n{parameters}') + + feedback.pushInfo( + f'CONTEXT :\n{context}') + + feedback.pushInfo( + f'FEEDBACK :\n{feedback}') + + rlayer = self.parameterAsRasterLayer( + parameters, self.INPUT, context) + + if rlayer is None: + raise QgsProcessingException( + self.invalidRasterError(parameters, self.INPUT)) + + self.selected_bands = self.parameterAsInts( + parameters, self.BANDS, context) + + if len(self.selected_bands) == 0: + self.selected_bands = list(range(1, rlayer.bandCount()+1)) + + if max(self.selected_bands) > rlayer.bandCount(): + raise QgsProcessingException( + self.tr("The chosen bands exceed the largest band number!") + ) + + self.nclusters = self.parameterAsInt( + parameters, self.CLUSTERS, context) + self.subset = self.parameterAsInt( + parameters, self.SUBSET, context) + method_idx = self.parameterAsEnum( + parameters, self.METHOD, context) + self.method = self.method_opt[method_idx] + + res = self.parameterAsDouble( + parameters, self.RESOLUTION, context) + crs = self.parameterAsCrs( + parameters, self.CRS, context) + extent = self.parameterAsExtent( + parameters, self.EXTENT, context) + self.output_dir = self.parameterAsString( + parameters, self.OUTPUT, context) + self.save_model = self.parameterAsBoolean( + parameters, self.SAVE_MODEL, context) + + rlayer_data_provider = rlayer.dataProvider() + + # handle crs + if crs is None or not crs.isValid(): + crs = rlayer.crs() + """ + feedback.pushInfo( + f'Layer CRS unit is {crs.mapUnits()}') # 0 for meters, 6 for degrees, 9 for unknown + feedback.pushInfo( + f'whether the CRS is a geographic CRS (using lat/lon coordinates) {crs.isGeographic()}') + if crs.mapUnits() == Qgis.DistanceUnit.Degrees: + crs = self.estimate_utm_crs(rlayer.extent()) + + # target crs should use meters as units + if crs.mapUnits() != Qgis.DistanceUnit.Meters: + feedback.pushInfo( + f'Layer CRS unit is {crs.mapUnits()}') + feedback.pushInfo( + f'whether the CRS is a geographic CRS (using lat/lon coordinates) {crs.isGeographic()}') + raise QgsProcessingException( + self.tr("Only support CRS with the units as meters") + ) + """ + + # 0 for meters, 6 for degrees, 9 for unknown + UNIT_METERS = 0 + UNIT_DEGREES = 6 + if rlayer.crs().mapUnits() == UNIT_DEGREES: # Qgis.DistanceUnit.Degrees: + layer_units = 'degrees' + else: + layer_units = 'meters' + # if res is not provided, get res info from rlayer + if np.isnan(res) or res == 0: + res = rlayer.rasterUnitsPerPixelX() # rasterUnitsPerPixelY() is negative + target_units = layer_units + else: + # when given res in meters by users, convert crs to utm if the original crs unit is degree + if crs.mapUnits() != UNIT_METERS: # Qgis.DistanceUnit.Meters: + if rlayer.crs().mapUnits() == UNIT_DEGREES: # Qgis.DistanceUnit.Degrees: + # estimate utm crs based on layer extent + crs = self.estimate_utm_crs(rlayer.extent()) + else: + raise QgsProcessingException( + f"Resampling of image with the CRS of {crs.authid()} in meters is not supported.") + target_units = 'meters' + # else: + # res = (rlayer_extent.xMaximum() - + # rlayer_extent.xMinimum()) / rlayer.width() + self.res = res + + # handle extent + if extent.isNull(): + extent = rlayer.extent() # QgsProcessingUtils.combineLayerExtents(layers, crs, context) + extent_crs = rlayer.crs() + else: + if extent.isEmpty(): + raise QgsProcessingException( + self.tr("The extent for processing can not be empty!")) + extent_crs = self.parameterAsExtentCrs( + parameters, self.EXTENT, context) + # if extent crs != target crs, convert it to target crs + if extent_crs != crs: + transform = QgsCoordinateTransform( + extent_crs, crs, context.transformContext()) + # extent = transform.transformBoundingBox(extent) + # to ensure coverage of the transformed extent + # convert extent to polygon, transform polygon, then get boundingBox of the new polygon + extent_polygon = QgsGeometry.fromRect(extent) + extent_polygon.transform(transform) + extent = extent_polygon.boundingBox() + extent_crs = crs + + # check intersects between extent and rlayer_extent + if rlayer.crs() != crs: + transform = QgsCoordinateTransform( + rlayer.crs(), crs, context.transformContext()) + rlayer_extent = transform.transformBoundingBox( + rlayer.extent()) + else: + rlayer_extent = rlayer.extent() + if not rlayer_extent.intersects(extent): + raise QgsProcessingException( + self.tr("The extent for processing is not intersected with the input image!")) + + img_width_in_extent = round( + (extent.xMaximum() - extent.xMinimum())/self.res) + img_height_in_extent = round( + (extent.yMaximum() - extent.yMinimum())/self.res) + + # Send some information to the user + feedback.pushInfo( + f'Layer path: {rlayer_data_provider.dataSourceUri()}') + # feedback.pushInfo( + # f'Layer band scale: {rlayer_data_provider.bandScale(self.selected_bands[0])}') + feedback.pushInfo(f'Layer name: {rlayer.name()}') + if rlayer.crs().authid(): + feedback.pushInfo(f'Layer CRS: {rlayer.crs().authid()}') + else: + feedback.pushInfo( + f'Layer CRS in WKT format: {rlayer.crs().toWkt()}') + feedback.pushInfo( + f'Layer pixel size: {rlayer.rasterUnitsPerPixelX()}, {rlayer.rasterUnitsPerPixelY()} {layer_units}') + + feedback.pushInfo(f'Bands selected: {self.selected_bands}') + + if crs.authid(): + feedback.pushInfo(f'Target CRS: {crs.authid()}') + else: + feedback.pushInfo(f'Target CRS in WKT format: {crs.toWkt()}') + feedback.pushInfo(f'Target resolution: {self.res} {target_units}') + feedback.pushInfo( + (f'Processing extent: minx:{extent.xMinimum():.6f}, maxx:{extent.xMaximum():.6f},' + f'miny:{extent.yMinimum():.6f}, maxy:{extent.yMaximum():.6f}')) + feedback.pushInfo( + (f'Processing image size: (width {img_width_in_extent}, ' + f'height {img_height_in_extent})')) + + self.rlayer_path = rlayer.dataProvider().dataSourceUri() + + feedback.pushInfo(f'Selected bands: {self.selected_bands}') + + ## passing parameters to self once everything has been processed + self.extent = extent + self.rlayer = rlayer + self.crs = crs + + + # used to handle any thread-sensitive cleanup which is required by the algorithm. + def postProcessAlgorithm(self, context, feedback) -> Dict[str, Any]: + return {} + + + def tr(self, string): + """ + Returns a translatable string with the self.tr() function. + """ + return QCoreApplication.translate('Processing', string) + + def createInstance(self): + return ClusterAlgorithm() + + def name(self): + """ + Returns the algorithm name, used for identifying the algorithm. This + string should be fixed for the algorithm, and must not be localised. + The name should be unique within each provider. Names should contain + lowercase alphanumeric characters only and no spaces or other + formatting characters. + """ + return 'cluster' + + def displayName(self): + """ + Returns the translated algorithm name, which should be used for any + user-visible display of the algorithm name. + """ + return self.tr('Clustering') + + def group(self): + """ + Returns the name of the group this algorithm belongs to. This string + should be localised. + """ + return self.tr('') + + def groupId(self): + """ + Returns the unique ID of the group this algorithm belongs to. This + string should be fixed for the algorithm, and must not be localised. + The group id should be unique within each provider. Group id should + contain lowercase alphanumeric characters only and no spaces or other + formatting characters. + """ + return '' + + def shortHelpString(self): + """ + Returns a localised short helper string for the algorithm. This string + should provide a basic description about what the algorithm does and the + parameters and outputs associated with it.. + """ + return self.tr("Cluster a raster.") + + def icon(self): + return 'E' + + + diff --git a/src/iamap/dialogs/packages_installer/packages_installer_dialog.py b/src/iamap/dialogs/packages_installer/packages_installer_dialog.py new file mode 100644 index 0000000..fd128d4 --- /dev/null +++ b/src/iamap/dialogs/packages_installer/packages_installer_dialog.py @@ -0,0 +1,382 @@ +""" +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 + +PLUGIN_NAME = "iamap" + +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') +REQUIREMENTS_PATH = os.path.join(PLUGIN_ROOT_DIR, '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 = [] +for lib, version in libraries_versions.items(): + + import_name = lib[:-1] + + if lib == 'scikit-learn ': + import_name = 'sklearn' + if lib == 'umap-learn ': + import_name = 'umap' + + packages_to_install.append( + PackageToInstall( + name=lib, + version=version, + import_name=import_name + ) + ) + + +# packages_to_install = [ +# PackageToInstall(name='scikit-learn', version=libraries_versions['scikit-learn'], import_name='sklearn'), +# ] + +if sys.platform == "linux" or sys.platform == "linux2": + # packages_to_install += [ + # PackageToInstall(name='ime-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', '<br>') + self.tb.append(txt) + + def log(self, txt): + self.signal_log_line.emit(txt) + + def _setup_message(self) -> None: + + self.log(f'<h2><span style="color: #000080;"><strong> ' + f'Plugin {PLUGIN_NAME} - Packages installer </strong></span></h2> \n' + f'\n' + f'<b>This plugin requires the following Python packages to be installed:</b>') + + 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'<b>Please click "Install packages" button below to install them automatically, </b>' + 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'<h3><b>Attempting to install required packages...</b></h3>') + os.makedirs(PACKAGES_INSTALL_DIR, exist_ok=True) + + self._install_pip_if_necessary() + + self.log(f'<h3><b>Attempting to install required packages...</b></h3>\n') + try: + self._pip_install_packages(packages_to_install) + except Exception as e: + msg = (f'\n <span style="color: {_ERROR_COLOR};"><b> ' + f'Packages installation failed with exception: {e}!\n' + f'Please try to install the packages again. </b></span>' + 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 <b>Installation of required packages finished. Validating installation...</b>') + 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'<h4><b>Making sure pip is installed...</b></h4>') + if check_pip_installed(): + self.log(f'<em>Pip is installed, skipping installation...</em>\n') + return + + install_pip_command = [PYTHON_EXECUTABLE_PATH, '-m', 'ensurepip'] + self.log(f'<em>Running command to install pip: \n $ {" ".join(install_pip_command)} </em>') + 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'<span style="color: {_ERROR_COLOR};"><b>' + f'pip installation failed! Consider installing it manually.' + f'<b></span>') + 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'<em>Running command: \n $ {cmd_string} </em>') + 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<b>' + f'Packages installed correctly!' + f'<b>\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'<span style="color: #999999;">{stdout_line.rstrip(os.linesep)}</span>' + 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 = '<b>Python packages required by the plugin could not be loaded due to the following error:</b>' + logging.exception(msg_base) + tb = traceback.format_exc() + msg1 = (f'<span style="color: {_ERROR_COLOR};">' + f'{msg_base} \n ' + f'{tb}\n\n' + f'<b>Please try installing the packages again.<b>' + f'</span>') + self.log(msg1) + + return False + + +dialog = None + + +def import_package(package: PackageToInstall): + # print(package.import_name) + 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/src/iamap/dialogs/packages_installer/packages_installer_dialog.ui b/src/iamap/dialogs/packages_installer/packages_installer_dialog.ui new file mode 100644 index 0000000..de4c975 --- /dev/null +++ b/src/iamap/dialogs/packages_installer/packages_installer_dialog.ui @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>PackagesInstallerDialog</class> + <widget class="QDialog" name="PackagesInstallerDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>693</width> + <height>494</height> + </rect> + </property> + <property name="windowTitle"> + <string>Featmap - Packages Installer Dialog</string> + </property> + <layout class="QGridLayout" name="gridLayout_2"> + <item row="0" column="0"> + <layout class="QGridLayout" name="gridLayout"> + <item row="0" column="0"> + <widget class="QTextBrowser" name="textBrowser_log"/> + </item> + </layout> + </item> + <item row="1" column="0"> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pushButton_install_packages"> + <property name="font"> + <font> + <weight>75</weight> + <bold>true</bold> + </font> + </property> + <property name="text"> + <string>Install packages</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pushButton_close"> + <property name="text"> + <string>Test and Close</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/src/iamap/dialogs/resizable_message_box.py b/src/iamap/dialogs/resizable_message_box.py new file mode 100644 index 0000000..b2a6794 --- /dev/null +++ b/src/iamap/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/src/iamap/encoder.py b/src/iamap/encoder.py new file mode 100644 index 0000000..7f4448a --- /dev/null +++ b/src/iamap/encoder.py @@ -0,0 +1,817 @@ +import os +import time +import re +import hashlib +import numpy as np +from pathlib import Path +from typing import Dict, Any, List +from processing import defaultOutputFolder + +import rasterio +from qgis.PyQt.QtCore import QCoreApplication +from qgis.core import (QgsProcessing, Qgis, + QgsGeometry, + QgsRasterLayer, + QgsRectangle, + QgsCoordinateReferenceSystem, + QgsUnitTypes, + QgsRasterBandStats, + QgsCoordinateTransform, + QgsFeatureSink, + QgsProcessingException, + QgsProcessingFeedback, + QgsProcessingAlgorithm, + QgsProcessingParameterRasterLayer, + QgsProcessingParameterFolderDestination, + QgsProcessingParameterBand, + QgsProcessingParameterNumber, + QgsProcessingParameterBoolean, + QgsProcessingParameterFile, + QgsProcessingParameterString, + QgsProcessingParameterEnum, + QgsProcessingParameterExtent, + QgsProcessingParameterCrs, + QgsProcessingParameterScale, + QgsProcessingParameterExpression, + QgsProcessingParameterRange, + QgsProcessingParameterFeatureSource, + QgsProcessingParameterDefinition, + QgsProcessingParameterFeatureSink, + ) + +import torch +import torch.nn as nn +from torch import Tensor +import torch.quantization +from torch.utils.data import DataLoader +import torchvision.transforms as T +import kornia.augmentation as K +import timm + +from torchgeo.datasets import RasterDataset, BoundingBox,stack_samples +from torchgeo.samplers import GridGeoSampler, Units +from torchgeo.transforms import AugmentationSequential + +from .utils.geo import get_mean_sd_by_band +from .utils.geo import merge_tiles +from .utils.torchgeo import NoBordersGridGeoSampler + +def get_model_size(model): + torch.save(model.state_dict(), "temp.p") + size = os.path.getsize("temp.p")/1e6 + os.remove('temp.p') + return size + +class EncoderAlgorithm(QgsProcessingAlgorithm): + """ + """ + + FEAT_OPTION= 'FEAT_OPTION' + INPUT = 'INPUT' + CKPT = 'CKPT' + BANDS = 'BANDS' + STRIDE = 'STRIDE' + SIZE = 'SIZE' + EXTENT = 'EXTENT' + QUANT = 'QUANT' + OUTPUT = 'OUTPUT' + RESOLUTION = 'RESOLUTION' + CRS = 'CRS' + CUDA = 'CUDA' + BATCH_SIZE = 'BATCH_SIZE' + CUDA_ID = 'CUDA_ID' + BACKBONE_CHOICE = 'BACKBONE_CHOICE' + MERGE_METHOD = 'MERGE_METHOD' + WORKERS = 'WORKERS' + PAUSES = 'PAUSES' + + + def initAlgorithm(self, config=None): + """ + Here we define the inputs and output of the algorithm, along + with some other properties. + """ + cwd = Path(__file__).parent.absolute() + + self.addParameter( + QgsProcessingParameterRasterLayer( + name=self.INPUT, + description=self.tr( + 'Input raster layer or image file path'), + defaultValue=os.path.join(cwd,'rasters','test.tif'), + ), + ) + + self.addParameter( + QgsProcessingParameterBand( + name=self.BANDS, + description=self.tr('Selected Bands (defaults to all bands selected)'), + defaultValue = None, + parentLayerParameterName=self.INPUT, + optional=True, + allowMultiple=True, + ) + ) + + crs_param = QgsProcessingParameterCrs( + name=self.CRS, + description=self.tr('Target CRS (default to original CRS)'), + optional=True, + ) + + res_param = QgsProcessingParameterNumber( + name=self.RESOLUTION, + description=self.tr( + 'Target resolution in meters (default to native resolution)'), + type=QgsProcessingParameterNumber.Double, + optional=True, + minValue=0, + maxValue=100000 + ) + + cuda_id_param = QgsProcessingParameterNumber( + name=self.CUDA_ID, + description=self.tr( + 'CUDA Device ID (choose which GPU to use, default to device 0)'), + type=QgsProcessingParameterNumber.Integer, + defaultValue=0, + minValue=0, + maxValue=9 + ) + nworkers_param = QgsProcessingParameterNumber( + name=self.WORKERS, + description=self.tr( + 'Number of CPU workers for dataloader (0 selects all)'), + type=QgsProcessingParameterNumber.Integer, + defaultValue=0, + minValue=0, + maxValue=10 + ) + pauses_param = QgsProcessingParameterNumber( + name=self.PAUSES, + description=self.tr( + 'Schedule pauses between batches to ease CPU usage (in seconds).'), + type=QgsProcessingParameterNumber.Integer, + defaultValue=0, + minValue=0, + maxValue=10000 + ) + + self.addParameter( + QgsProcessingParameterExtent( + name=self.EXTENT, + description=self.tr( + 'Processing extent (default to the entire image)'), + optional=True + ) + ) + + self.addParameter( + QgsProcessingParameterNumber( + name=self.SIZE, + description=self.tr( + 'Sampling size (the raster will be sampled in a square with a side of that many pixel)'), + type=QgsProcessingParameterNumber.Integer, + defaultValue = 224, + minValue=1, + maxValue=1024 + ) + ) + + + self.addParameter( + QgsProcessingParameterNumber( + name=self.STRIDE, + description=self.tr( + 'Stride (If smaller than the sampling size, tiles will overlap. If larger, it may cause errors.)'), + type=QgsProcessingParameterNumber.Integer, + defaultValue = 224, + minValue=1, + maxValue=1024 + ) + ) + + chkpt_param = QgsProcessingParameterFile( + name=self.CKPT, + description=self.tr( + 'Pretrained checkpoint'), + extension='pth', + optional=True, + defaultValue=None + ) + + + self.addParameter( + QgsProcessingParameterFolderDestination( + self.OUTPUT, + self.tr( + "Output directory (choose the location that the image features will be saved)"), + defaultValue=os.path.join(cwd,'features'), + ) + ) + + self.addParameter( + QgsProcessingParameterBoolean( + self.CUDA, + self.tr("Use GPU if CUDA is available."), + defaultValue=True + ) + ) + self.addParameter ( + QgsProcessingParameterString( + name = self.BACKBONE_CHOICE, + description = self.tr( + 'Backbone choice (see huggingface.co/timm/)'), + defaultValue = 'vit_base_patch16_224.dino', + # defaultValue = 'vit_small_patch16_224.dino', + ) + ) + + + + self.addParameter( + QgsProcessingParameterBoolean( + self.FEAT_OPTION, + self.tr("Display features map"), + defaultValue=True + ) + ) + + self.addParameter( + QgsProcessingParameterNumber( + name=self.BATCH_SIZE, + # large images will be sampled into patches in a grid-like fashion + description=self.tr( + 'Batch size (take effect if choose to use GPU and CUDA is available)'), + type=QgsProcessingParameterNumber.Integer, + defaultValue=1, + minValue=1, + maxValue=1024 + ) + ) + + self.addParameter( + QgsProcessingParameterBoolean( + self.QUANT, + self.tr("Quantization of the model to reduce space"), + defaultValue=True + ) + ) + + self.merge_options = ['first', 'min', 'max','average','sum', 'count', 'last'] + merge_param = QgsProcessingParameterEnum( + name=self.MERGE_METHOD, + description=self.tr( + 'Merge method at the end of inference.'), + options=self.merge_options, + defaultValue=0, + ) + + for param in ( + crs_param, + res_param, + chkpt_param, + cuda_id_param, + merge_param, + nworkers_param, + pauses_param + ): + param.setFlags( + param.flags() | QgsProcessingParameterDefinition.FlagAdvanced) + self.addParameter(param) + + + + @torch.no_grad() + def processAlgorithm(self, parameters, context, feedback): + """ + Here is where the processing itself takes place. + """ + self.process_options(parameters, context, feedback) + + RasterDataset.filename_glob = self.rlayer_name + RasterDataset.all_bands = [ + self.rlayer.bandName(i_band) for i_band in range(1, self.rlayer.bandCount()+1) + ] + # currently only support rgb bands + input_bands = [self.rlayer.bandName(i_band) + for i_band in self.selected_bands] + + feedback.pushInfo(f'create dataset') + if self.crs == self.rlayer.crs(): + dataset = RasterDataset( + paths=self.rlayer_dir, crs=None, res=self.res, bands=input_bands, cache=False) + else: + dataset = RasterDataset( + paths=self.rlayer_dir, crs=self.crs.toWkt(), res=self.res, bands=input_bands, cache=False) + extent_bbox = BoundingBox(minx=self.extent.xMinimum(), maxx=self.extent.xMaximum(), miny=self.extent.yMinimum(), maxy=self.extent.yMaximum(), + mint=dataset.index.bounds[4], maxt=dataset.index.bounds[5]) + + + feedback.pushInfo(f'create model') + model = timm.create_model( + self.backbone_name, + pretrained=True, + in_chans=len(input_bands), + num_classes=0 + ) + + feedback.pushInfo(f'model done') + data_config = timm.data.resolve_model_data_config(model) + _, h, w, = data_config['input_size'] + + if self.quantization: + feedback.pushInfo(f'before quantization : {get_model_size(model)}') + + model = torch.quantization.quantize_dynamic( + model, {nn.Linear}, dtype=torch.qint8 + ) + feedback.pushInfo(f'after quantization : {get_model_size(model)}') + + transform = AugmentationSequential( + T.ConvertImageDtype(torch.float32), # change dtype for normalize to be possible + K.Normalize(self.means,self.sds), # normalize occurs only on raster, not mask + K.Resize((h, w)), # resize to 224*224 pixels, regardless of sampling size + data_keys=["image"], + ) + dataset.transforms = transform + + + # sampler = GridGeoSampler( + # dataset, + # size=self.size, + # stride=self.stride, + # roi=extent_bbox, + # units=Units.PIXELS + # ) # Units.CRS or Units.PIXELS + sampler = NoBordersGridGeoSampler( + dataset, + size=self.size, + stride=self.stride, + roi=extent_bbox, + units=Units.PIXELS + ) # Units.CRS or Units.PIXELS + + if len(sampler) == 0: + self.load_feature = False + feedback.pushWarning(f'\n !!!No available patch sample inside the chosen extent!!! \n') + + if torch.cuda.is_available() and self.use_gpu: + if self.cuda_id + 1 > torch.cuda.device_count(): + self.cuda_id = torch.cuda.device_count() - 1 + cuda_device = f'cuda:{self.cuda_id}' + device = f'cuda:{self.cuda_id}' + else: + self.batch_size = 1 + device = 'cpu' + + feedback.pushInfo(f'Device id: {device}') + + feedback.pushInfo(f'model to dedvice') + model.to(device=device) + + feedback.pushInfo(f'Batch size: {self.batch_size}') + dataloader = DataLoader( + dataset, + batch_size=self.batch_size, + sampler=sampler, + collate_fn=stack_samples, + num_workers=self.nworkers, + ) + + feedback.pushInfo(f'Patch sample num: {len(sampler)}') + feedback.pushInfo(f'Total batch num: {len(dataloader)}') + feedback.pushInfo(f'\n\n{"-"*16}\nBegining inference \n{"-"*16}\n\n') + + ## compute parameters hash to have a unique identifier for the run + ## some parameters do not change the encoding part of the algorithm + keys_to_remove = ['MERGE_METHOD', 'WORKERS', 'PAUSES'] + param_encoder = {key: parameters[key] for key in parameters if key not in keys_to_remove} + + param_hash = hashlib.md5(str(param_encoder).encode("utf-8")).hexdigest() + output_subdir = os.path.join(self.output_dir,param_hash) + output_subdir = Path(output_subdir) + output_subdir.mkdir(parents=True, exist_ok=True) + self.output_subdir = output_subdir + feedback.pushInfo(f'output_subdir: {output_subdir}') + + + last_batch_done = self.get_last_batch_done() + if last_batch_done >= 0: + feedback.pushInfo(f"\n\n {'-'*8} \n Resuming at batch number {last_batch_done}\n {'-'*8} \n\n") + + bboxes = [] # keep track of bboxes to have coordinates at the end + elapsed_time_list = [] + total = 100 / len(dataloader) if len(dataloader) else 0 + + for current, sample in enumerate(dataloader): + + if current <= last_batch_done: + continue + + start_time = time.time() + # Stop the algorithm if cancel button has been clicked + if feedback.isCanceled(): + self.load_feature = False + feedback.pushWarning( + self.tr("\n !!!Processing is canceled by user!!! \n")) + break + feedback.pushInfo(f'\n{"-"*8}\nBatch no. {current} loaded') + + images = sample['image'].to(device) + if len(images.shape) > 4: + images = images.squeeze(1) + feedback.pushInfo(f'Batch shape {images.shape}') + + features = model.forward_features(images) + features = features[:,1:,:] # take only patch tokens + if current <= last_batch_done + 1: + n_patches = int(np.sqrt(features.shape[1])) + features = features.view(features.shape[0],n_patches,n_patches,features.shape[-1]) + features = features.detach().cpu().numpy() + feedback.pushInfo(f'Features shape {features.shape}') + self.save_features(features,sample['bbox'], current) + feedback.pushInfo(f'Features saved') + + bboxes.extend(sample['bbox']) + + if self.pauses != 0: + time.sleep(self.pauses) + + end_time = time.time() + # get the execution time of encoder, ms + elapsed_time = (end_time - start_time) + elapsed_time_list.append(elapsed_time) + time_spent = sum(elapsed_time_list) + time_remain = (time_spent / (current + 1)) * \ + (len(dataloader) - current - 1) + + # TODO: show gpu usage info + # if torch.cuda.is_available() and self.use_gpu: + # gpu_mem_used = torch.cuda.max_memory_reserved(self.sam_model.device) / (1024 ** 3) + # # gpu_mem_free = torch.cuda.mem_get_info(self.sam_model.device)[0] / (1024 ** 3) + # gpu_mem_total = torch.cuda.mem_get_info(self.sam_model.device)[1] / (1024 ** 3) + # feedback.pushInfo( + # f'GPU memory usage: {gpu_mem_used:.2f}GB / {gpu_mem_total:.2f}GB') + # feedback.pushInfo(str(torch.cuda.memory_summary(self.sam_model.device))) + + feedback.pushInfo(f"Encoder executed with {elapsed_time:.3f}s") + feedback.pushInfo(f"Time spent: {time_spent:.3f}s") + + if time_remain <= 60: + feedback.pushInfo(f"Estimated time remaining: {time_remain:.3f}s \n {'-'*8}") + else: + time_remain_m, time_remain_s = divmod(int(time_remain), 60) + time_remain_h, time_remain_m = divmod(time_remain_m, 60) + feedback.pushInfo(f"Estimated time remaining: {time_remain_h:d}h:{time_remain_m:02d}m:{time_remain_s:02d}s \n" ) + + # Update the progress bar + feedback.setProgress(int((current+1) * total)) + + + all_tiles = [os.path.join(self.output_subdir,f) for f in os.listdir(self.output_subdir) if f.endswith('.tif')] + dst_path = Path(os.path.join(self.output_subdir,'merged.tiff')) + feedback.pushInfo(f"\n\n{'-'*8}\n Merging tiles \n{'-'*8}\n" ) + + merge_tiles( + tiles = all_tiles, + dst_path = dst_path, + method = self.merge_method, + ) + + parameters['OUTPUT_RASTER']=dst_path + + return {"Output feature path": self.output_subdir, 'Patch samples saved': self.iPatch, 'OUTPUT_RASTER':dst_path} + + def get_last_batch_done(self): + + ## get largest batch_number achieved + ## files are saved with the pattern '{batch_number}_{image_id_within_batch}.tif' + # Regular expression pattern to extract numbers + pattern = re.compile(r'^(\d+)_\d+\.tif$') + + # Initialize a set to store unique first numbers + batch_numbers = set() + + # Iterate over all files in the directory + for filename in os.listdir(self.output_subdir): + # Match the filename pattern + match = pattern.match(filename) + if match: + # Extract the batch number + batch_number = int(match.group(1)) + # Add to the set of batch numbers + batch_numbers.add(batch_number) + + # Find the maximum value of the batch numbers + if batch_numbers: + return max(batch_numbers) + else: + return -1 + + + def save_features( + self, + feature: np.ndarray, + bboxes: BoundingBox, + nbatch: int, + ): + + # iterate over batch_size dimension + for idx in range(feature.shape[0]): + _, height, width, channels = feature.shape + bbox = bboxes[idx] + rio_transform = rasterio.transform.from_bounds(bbox.minx, bbox.miny, bbox.maxx, bbox.maxy, width, height) # west, south, east, north, width, height + feature_path = os.path.join(self.output_subdir, f"{nbatch}_{idx}.tif") + with rasterio.open( + feature_path, + mode="w", + driver="GTiff", + height=height, + width=width, + count=channels, + dtype='float32', + crs=self.crs.toWkt(), + transform=rio_transform + ) as ds: + ds.write(np.transpose(feature[idx, ...], (2, 0, 1))) + tags = { + "model_type": self.backbone_name, + } + ds.update_tags(**tags) + + self.iPatch += 1 + + return + + def process_options(self,parameters, context, feedback): + self.iPatch = 0 + + self.feature_dir = "" + + self.FEAT_OPTION = self.parameterAsBoolean( + parameters, self.FEAT_OPTION, context) + + feedback.pushInfo( + f'PARAMETERS :\n{parameters}') + + feedback.pushInfo( + f'CONTEXT :\n{context}') + + feedback.pushInfo( + f'FEEDBACK :\n{feedback}') + + rlayer = self.parameterAsRasterLayer( + parameters, self.INPUT, context) + + if rlayer is None: + raise QgsProcessingException( + self.invalidRasterError(parameters, self.INPUT)) + + self.selected_bands = self.parameterAsInts( + parameters, self.BANDS, context) + + if len(self.selected_bands) == 0: + self.selected_bands = list(range(1, rlayer.bandCount()+1)) + + if max(self.selected_bands) > rlayer.bandCount(): + raise QgsProcessingException( + self.tr("The chosen bands exceed the largest band number!") + ) + + ckpt_path = self.parameterAsFile( + parameters, self.CKPT, context) + + self.backbone_name = self.parameterAsString( + parameters, self.BACKBONE_CHOICE, context) + + self.stride = self.parameterAsInt( + parameters, self.STRIDE, context) + self.size = self.parameterAsInt( + parameters, self.SIZE, context) + res = self.parameterAsDouble( + parameters, self.RESOLUTION, context) + crs = self.parameterAsCrs( + parameters, self.CRS, context) + extent = self.parameterAsExtent( + parameters, self.EXTENT, context) + self.quantization = self.parameterAsBoolean( + parameters, self.QUANT, context) + self.use_gpu = self.parameterAsBoolean( + parameters, self.CUDA, context) + self.batch_size = self.parameterAsInt( + parameters, self.BATCH_SIZE, context) + self.output_dir = self.parameterAsString( + parameters, self.OUTPUT, context) + self.cuda_id = self.parameterAsInt( + parameters, self.CUDA_ID, context) + self.pauses = self.parameterAsInt( + parameters, self.PAUSES, context) + self.nworkers = self.parameterAsInt( + parameters, self.WORKERS, context) + merge_method_idx = self.parameterAsEnum( + parameters, self.MERGE_METHOD, context) + self.merge_method = self.merge_options[merge_method_idx] + + rlayer_data_provider = rlayer.dataProvider() + + # handle crs + if crs is None or not crs.isValid(): + crs = rlayer.crs() + feedback.pushInfo( + f'Layer CRS unit is {crs.mapUnits()}') # 0 for meters, 6 for degrees, 9 for unknown + feedback.pushInfo( + f'whether the CRS is a geographic CRS (using lat/lon coordinates) {crs.isGeographic()}') + if crs.mapUnits() == Qgis.DistanceUnit.Degrees: + crs = self.estimate_utm_crs(rlayer.extent()) + + # target crs should use meters as units + if crs.mapUnits() != Qgis.DistanceUnit.Meters: + feedback.pushInfo( + f'Layer CRS unit is {crs.mapUnits()}') + feedback.pushInfo( + f'whether the CRS is a geographic CRS (using lat/lon coordinates) {crs.isGeographic()}') + raise QgsProcessingException( + self.tr("Only support CRS with the units as meters") + ) + + # 0 for meters, 6 for degrees, 9 for unknown + UNIT_METERS = 0 + UNIT_DEGREES = 6 + if rlayer.crs().mapUnits() == UNIT_DEGREES: # Qgis.DistanceUnit.Degrees: + layer_units = 'degrees' + else: + layer_units = 'meters' + # if res is not provided, get res info from rlayer + if np.isnan(res) or res == 0: + res = rlayer.rasterUnitsPerPixelX() # rasterUnitsPerPixelY() is negative + target_units = layer_units + else: + # when given res in meters by users, convert crs to utm if the original crs unit is degree + if crs.mapUnits() != UNIT_METERS: # Qgis.DistanceUnit.Meters: + if rlayer.crs().mapUnits() == UNIT_DEGREES: # Qgis.DistanceUnit.Degrees: + # estimate utm crs based on layer extent + crs = self.estimate_utm_crs(rlayer.extent()) + else: + raise QgsProcessingException( + f"Resampling of image with the CRS of {crs.authid()} in meters is not supported.") + target_units = 'meters' + # else: + # res = (rlayer_extent.xMaximum() - + # rlayer_extent.xMinimum()) / rlayer.width() + self.res = res + + # handle extent + if extent.isNull(): + extent = rlayer.extent() # QgsProcessingUtils.combineLayerExtents(layers, crs, context) + extent_crs = rlayer.crs() + else: + if extent.isEmpty(): + raise QgsProcessingException( + self.tr("The extent for processing can not be empty!")) + extent_crs = self.parameterAsExtentCrs( + parameters, self.EXTENT, context) + # if extent crs != target crs, convert it to target crs + if extent_crs != crs: + transform = QgsCoordinateTransform( + extent_crs, crs, context.transformContext()) + # extent = transform.transformBoundingBox(extent) + # to ensure coverage of the transformed extent + # convert extent to polygon, transform polygon, then get boundingBox of the new polygon + extent_polygon = QgsGeometry.fromRect(extent) + extent_polygon.transform(transform) + extent = extent_polygon.boundingBox() + extent_crs = crs + + # check intersects between extent and rlayer_extent + if rlayer.crs() != crs: + transform = QgsCoordinateTransform( + rlayer.crs(), crs, context.transformContext()) + rlayer_extent = transform.transformBoundingBox( + rlayer.extent()) + else: + rlayer_extent = rlayer.extent() + if not rlayer_extent.intersects(extent): + raise QgsProcessingException( + self.tr("The extent for processing is not intersected with the input image!")) + + feedback.pushInfo(f'backbne type : {self.backbone_name}') + + img_width_in_extent = round( + (extent.xMaximum() - extent.xMinimum())/self.res) + img_height_in_extent = round( + (extent.yMaximum() - extent.yMinimum())/self.res) + + # Send some information to the user + feedback.pushInfo( + f'Layer path: {rlayer_data_provider.dataSourceUri()}') + # feedback.pushInfo( + # f'Layer band scale: {rlayer_data_provider.bandScale(self.selected_bands[0])}') + feedback.pushInfo(f'Layer name: {rlayer.name()}') + if rlayer.crs().authid(): + feedback.pushInfo(f'Layer CRS: {rlayer.crs().authid()}') + else: + feedback.pushInfo( + f'Layer CRS in WKT format: {rlayer.crs().toWkt()}') + feedback.pushInfo( + f'Layer pixel size: {rlayer.rasterUnitsPerPixelX()}, {rlayer.rasterUnitsPerPixelY()} {layer_units}') + + feedback.pushInfo(f'Bands selected: {self.selected_bands}') + + if crs.authid(): + feedback.pushInfo(f'Target CRS: {crs.authid()}') + else: + feedback.pushInfo(f'Target CRS in WKT format: {crs.toWkt()}') + # feedback.pushInfo('Band number is {}'.format(rlayer.bandCount())) + # feedback.pushInfo('Band name is {}'.format(rlayer.bandName(1))) + feedback.pushInfo(f'Target resolution: {self.res} {target_units}') + # feedback.pushInfo('Layer display band name is {}'.format( + # rlayer.dataProvider().displayBandName(1))) + feedback.pushInfo( + (f'Processing extent: minx:{extent.xMinimum():.6f}, maxx:{extent.xMaximum():.6f},' + f'miny:{extent.yMinimum():.6f}, maxy:{extent.yMaximum():.6f}')) + feedback.pushInfo( + (f'Processing image size: (width {img_width_in_extent}, ' + f'height {img_height_in_extent})')) + + # feedback.pushInfo( + # f'SAM Image Size: {self.sam_model.image_encoder.img_size}') + + self.rlayer_path = rlayer.dataProvider().dataSourceUri() + self.rlayer_dir = os.path.dirname(self.rlayer_path) + self.rlayer_name = os.path.basename(self.rlayer_path) + + # get mean and sd of dataset from raster metadata + means, sds = get_mean_sd_by_band(self.rlayer_path) + # subset with selected_bands + feedback.pushInfo(f'Selected bands: {self.selected_bands}') + self.means = [means[i-1] for i in self.selected_bands] + self.sds = [sds[i-1] for i in self.selected_bands] + feedback.pushInfo(f'Means for normalization: {self.means}') + feedback.pushInfo(f'Std. dev. for normalization: {self.sds}') + + ## passing parameters to self once everything has been processed + self.extent = extent + self.rlayer = rlayer + self.crs = crs + + + # used to handle any thread-sensitive cleanup which is required by the algorithm. + def postProcessAlgorithm(self, context, feedback) -> Dict[str, Any]: + return {} + + + def tr(self, string): + """ + Returns a translatable string with the self.tr() function. + """ + return QCoreApplication.translate('Processing', string) + + def createInstance(self): + return EncoderAlgorithm() + + def name(self): + """ + Returns the algorithm name, used for identifying the algorithm. This + string should be fixed for the algorithm, and must not be localised. + The name should be unique within each provider. Names should contain + lowercase alphanumeric characters only and no spaces or other + formatting characters. + """ + return 'encoder' + + def displayName(self): + """ + Returns the translated algorithm name, which should be used for any + user-visible display of the algorithm name. + """ + return self.tr('Image Encoder') + + def group(self): + """ + Returns the name of the group this algorithm belongs to. This string + should be localised. + """ + return self.tr('') + + def groupId(self): + """ + Returns the unique ID of the group this algorithm belongs to. This + string should be fixed for the algorithm, and must not be localised. + The group id should be unique within each provider. Group id should + contain lowercase alphanumeric characters only and no spaces or other + formatting characters. + """ + return '' + + def shortHelpString(self): + """ + Returns a localised short helper string for the algorithm. This string + should provide a basic description about what the algorithm does and the + parameters and outputs associated with it.. + """ + return self.tr("Generate image features using a deep learning backbone.") + + def icon(self): + return 'E' + diff --git a/src/iamap/iamap.py b/src/iamap/iamap.py new file mode 100644 index 0000000..a0a36a0 --- /dev/null +++ b/src/iamap/iamap.py @@ -0,0 +1,234 @@ +import processing +from PyQt5.QtWidgets import ( + QAction, + QToolBar, + QApplication, + QDialog +) +from PyQt5.QtCore import pyqtSignal, QObject +from qgis.core import QgsApplication +from qgis.gui import QgisInterface +from .provider import IAMapProvider +from .icons import QIcon_EncoderTool, QIcon_ReductionTool, QIcon_ClusterTool, QIcon_SimilarityTool, QIcon_RandomforestTool + + +class IAMap(QObject): + execute_iamap = pyqtSignal() + + def __init__(self, iface: QgisInterface, cwd: str): + super().__init__() + self.iface = iface + self.cwd = cwd + + def initProcessing(self): + self.provider = IAMapProvider() + QgsApplication.processingRegistry().addProvider(self.provider) + + def initGui(self): + self.initProcessing() + + self.toolbar: QToolBar = self.iface.addToolBar('IAMap Toolbar') + self.toolbar.setObjectName('IAMapToolbar') + self.toolbar.setToolTip('IAMap Toolbar') + + self.actionEncoder = QAction( + QIcon_EncoderTool, + "Deep Learning Image Encoder", + self.iface.mainWindow() + ) + self.actionReducer = QAction( + QIcon_ReductionTool, + "Reduce dimensions", + self.iface.mainWindow() + ) + self.actionCluster = QAction( + QIcon_ClusterTool, + "Cluster raster", + self.iface.mainWindow() + ) + self.actionSimilarity = QAction( + QIcon_SimilarityTool, + "Compute similarity", + self.iface.mainWindow() + ) + self.actionRF = QAction( + QIcon_RandomforestTool, + "Use Random Forest algorithm", + self.iface.mainWindow() + ) + self.actionEncoder.setObjectName("mActionEncoder") + self.actionReducer.setObjectName("mActionReducer") + self.actionCluster.setObjectName("mActionCluster") + self.actionSimilarity.setObjectName("mactionSimilarity") + self.actionRF.setObjectName("mactionRF") + + self.actionEncoder.setToolTip( + "Encode a raster with a deep learning backbone") + self.actionReducer.setToolTip( + "Reduce raster dimensions") + self.actionCluster.setToolTip( + "Cluster raster") + self.actionSimilarity.setToolTip( + "Compute similarity") + self.actionRF.setToolTip( + "Use Random Forest ") + + self.actionEncoder.triggered.connect(self.encodeImage) + self.actionReducer.triggered.connect(self.reduceImage) + self.actionCluster.triggered.connect(self.clusterImage) + self.actionSimilarity.triggered.connect(self.similarityImage) + self.actionRF.triggered.connect(self.rfImage) + + self.toolbar.addAction(self.actionEncoder) + self.toolbar.addAction(self.actionReducer) + self.toolbar.addAction(self.actionCluster) + self.toolbar.addAction(self.actionSimilarity) + self.toolbar.addAction(self.actionRF) + + def unload(self): + # self.wdg_select.setVisible(False) + self.iface.removeToolBarIcon(self.actionEncoder) + self.iface.removeToolBarIcon(self.actionReducer) + self.iface.removeToolBarIcon(self.actionCluster) + self.iface.removeToolBarIcon(self.actionSimilarity) + self.iface.removeToolBarIcon(self.actionRF) + + del self.actionEncoder + del self.actionReducer + del self.actionCluster + del self.actionSimilarity + del self.actionRF + del self.toolbar + QgsApplication.processingRegistry().removeProvider(self.provider) + + def encodeImage(self): + ''' + ''' + result = processing.execAlgorithmDialog('iamap:encoder', {}) + print(result) + # Check if algorithm execution was successful + if result: + # Retrieve output parameters from the result dictionary + if 'OUTPUT_RASTER' in result: + output_raster_path = result['OUTPUT_RASTER'] + + # Add the output raster layer to the map canvas + self.iface.addRasterLayer(str(output_raster_path), 'merged features') + else: + # Handle missing or unexpected output + print('Output raster not found in algorithm result.') + else: + # Handle algorithm execution failure or cancellation + print('Algorithm execution was not successful.') + # processing.execAlgorithmDialog('', {}) + # self.close_all_dialogs() + + + def reduceImage(self): + ''' + ''' + result = processing.execAlgorithmDialog('iamap:reduction', {}) + print(result) + # Check if algorithm execution was successful + if result: + # Retrieve output parameters from the result dictionary + if 'OUTPUT_RASTER' in result: + output_raster_path = result['OUTPUT_RASTER'] + + # Add the output raster layer to the map canvas + self.iface.addRasterLayer(str(output_raster_path), 'reduced features') + else: + # Handle missing or unexpected output + print('Output raster not found in algorithm result.') + else: + # Handle algorithm execution failure or cancellation + print('Algorithm execution was not successful.') + # processing.execAlgorithmDialog('', {}) + + + def clusterImage(self): + ''' + ''' + result = processing.execAlgorithmDialog('iamap:cluster', {}) + print(result) + # Check if algorithm execution was successful + if result: + # Retrieve output parameters from the result dictionary + if 'OUTPUT_RASTER' in result: + output_raster_path = result['OUTPUT_RASTER'] + + # Add the output raster layer to the map canvas + self.iface.addRasterLayer(str(output_raster_path), 'clustering') + else: + # Handle missing or unexpected output + print('Output raster not found in algorithm result.') + else: + # Handle algorithm execution failure or cancellation + print('Algorithm execution was not successful.') + # processing.execAlgorithmDialog('', {}) + + + def similarityImage(self): + ''' + ''' + result = processing.execAlgorithmDialog('iamap:similarity', {}) + print(result) + # Check if algorithm execution was successful + if result: + # Retrieve output parameters from the result dictionary + if 'OUTPUT_RASTER' in result: + output_raster_path = result['OUTPUT_RASTER'] + + # Add the output raster layer to the map canvas + self.iface.addRasterLayer(str(output_raster_path), 'similarity map') + else: + # Handle missing or unexpected output + print('Output raster not found in algorithm result.') + else: + # Handle algorithm execution failure or cancellation + print('Algorithm execution was not successful.') + # processing.execAlgorithmDialog('', {}) + + def rfImage(self): + ''' + ''' + result = processing.execAlgorithmDialog('iamap:Random_forest', {}) + print(result) + # Check if algorithm execution was successful + if result: + # Retrieve output parameters from the result dictionary + if 'OUTPUT_RASTER' in result: + output_raster_path = result['OUTPUT_RASTER'] + + # Add the output raster layer to the map canvas + self.iface.addRasterLayer(str(output_raster_path), 'random forest map') + else: + # Handle missing or unexpected output + print('Output raster not found in algorithm result.') + else: + # Handle algorithm execution failure or cancellation + print('Algorithm execution was not successful.') + # processing.execAlgorithmDialog('', {}) + + + + def close_all_dialogs(self): + # Get the main QGIS window (QgisInterface) + qgis_main_window = self.iface.mainWindow() + + # Get all open dialogs associated with the main window + open_dialogs = qgis_main_window.findChildren(QDialog) + + # Iterate through the open dialogs and close them + for dialog in open_dialogs: + # Check if the dialog is visible (to avoid closing hidden dialogs) + if dialog.isVisible(): + # Close the dialog + dialog.close() + + + + + + + diff --git a/src/iamap/icons/__init__.py b/src/iamap/icons/__init__.py new file mode 100644 index 0000000..32f705a --- /dev/null +++ b/src/iamap/icons/__init__.py @@ -0,0 +1,16 @@ +import os +from PyQt5.QtGui import QIcon + +cwd = os.path.abspath(os.path.dirname(__file__)) +# encoder_tool_path = os.path.join(cwd, 'encoder_tool.svg') +encoder_tool_path = os.path.join(cwd, 'network-base-solid-svgrepo-com.svg') +reduction_tool_path = os.path.join(cwd, 'chart-scatterplot-solid-svgrepo-com.svg') +cluster_tool_path = os.path.join(cwd, 'chart-scatter-3d-svgrepo-com.svg') +similarity_tool_path = os.path.join(cwd, 'scatter-plot-svgrepo-com.svg') +random_forest_tool_path = os.path.join(cwd, 'forest-svgrepo-com.svg') + +QIcon_EncoderTool = QIcon(encoder_tool_path) +QIcon_ReductionTool = QIcon(reduction_tool_path) +QIcon_ClusterTool = QIcon(cluster_tool_path) +QIcon_SimilarityTool = QIcon(similarity_tool_path) +QIcon_RandomforestTool = QIcon(random_forest_tool_path) diff --git a/src/iamap/icons/chart-scatter-3d-svgrepo-com.svg b/src/iamap/icons/chart-scatter-3d-svgrepo-com.svg new file mode 100644 index 0000000..45be800 --- /dev/null +++ b/src/iamap/icons/chart-scatter-3d-svgrepo-com.svg @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M12 4V14M12 14L4 20M12 14L20 20M12 20H12.01M4 13H4.01M17 13H17.01M8 9H8.01M20 9H20.01M5 5H5.01M18 5H18.01" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> +</svg> \ No newline at end of file diff --git a/src/iamap/icons/chart-scatterplot-solid-svgrepo-com.svg b/src/iamap/icons/chart-scatterplot-solid-svgrepo-com.svg new file mode 100644 index 0000000..756b3b4 --- /dev/null +++ b/src/iamap/icons/chart-scatterplot-solid-svgrepo-com.svg @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> + <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<svg width="800px" height="800px" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"> + <title>chart-data-sets-solid</title> + <g id="Layer_2" data-name="Layer 2"> + <g id="invisible_box" data-name="invisible box"> + <rect width="48" height="48" fill="none"/> + </g> + <g id="icons_Q2" data-name="icons Q2"> + <circle cx="14" cy="12" r="2"/> + <circle cx="14" cy="22" r="2"/> + <circle cx="19" cy="17" r="2"/> + <circle cx="36" cy="34" r="2"/> + <circle cx="36" cy="24" r="2"/> + <circle cx="31" cy="29" r="2"/> + <path d="M41.7,20A2,2,0,0,0,44,18V6a2,2,0,0,0-2-2H30a2,2,0,0,0-2,2.3A2.1,2.1,0,0,0,30.1,8h7.1L8,37.2V6A2,2,0,0,0,4,6V44H42a2,2,0,0,0,0-4H10.8L40,10.8v7.1A2.1,2.1,0,0,0,41.7,20Z"/> + </g> + </g> +</svg> \ No newline at end of file diff --git a/src/iamap/icons/encoder_tool.svg b/src/iamap/icons/encoder_tool.svg new file mode 100644 index 0000000..4cb16a1 --- /dev/null +++ b/src/iamap/icons/encoder_tool.svg @@ -0,0 +1,92 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#FF5252;stroke:#000000;stroke-width:6.390949e-04;stroke-miterlimit:10;} + .st1{fill:#1AA1DB;stroke:#000000;stroke-width:6.390949e-04;stroke-miterlimit:10;} + .st2{fill:#91C965;} + .st3{fill:#88BA5D;} + .st4{fill:#9BDC6E;} + .st5{fill:#3C8763;} +</style> +<g id="net" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg"> + <g id="g16"> + <path id="path4" class="st0" d="M5.5,24.7c-0.6,0-1.1-0.5-1.1-1.1v-4.3c0-0.6,0.5-1.1,1.1-1.1s1.1,0.5,1.1,1.1v4.3 + C6.6,24.2,6.1,24.7,5.5,24.7 M5.5,34.3c-0.6,0-1.1-0.5-1.1-1.1V30c0-0.6,0.5-1.1,1.1-1.1s1.1,0.5,1.1,1.1v3.2 + C6.6,33.8,6.1,34.3,5.5,34.3 M5.5,44.9c-0.6,0-1.1-0.5-1.1-1.1v-6.4c0-0.6,0.5-1.1,1.1-1.1s1.1,0.5,1.1,1.1v6.4 + C6.6,44.4,6.1,44.9,5.5,44.9"/> + <path id="path6" class="st0" d="M13.6,53.1c-0.2,0-0.3,0-0.5-0.1l-3.9-1.8c-0.5-0.3-0.8-0.9-0.5-1.4s0.9-0.8,1.4-0.5l3.9,1.8 + c0.5,0.3,0.8,0.9,0.5,1.4C14.4,52.9,14,53.1,13.6,53.1 M21.3,56.8c-0.2,0-0.3,0-0.5-0.1L18,55.3c-0.5-0.3-0.8-0.9-0.5-1.4 + s0.9-0.8,1.4-0.5l2.9,1.4c0.5,0.3,0.8,0.9,0.5,1.4C22.1,56.5,21.7,56.8,21.3,56.8 M27.9,59.8c-0.2,0-0.3,0-0.5-0.1l-2.7-1.3 + c-0.5-0.3-0.8-0.9-0.5-1.4s0.9-0.8,1.4-0.5l2.7,1.3c0.5,0.3,0.8,0.9,0.5,1.4C28.7,59.6,28.3,59.8,27.9,59.8"/> + <path id="path8" class="st0" d="M58.8,24.7c-0.6,0-1.1-0.5-1.1-1.1v-4.3c0-0.6,0.5-1.1,1.1-1.1s1.1,0.5,1.1,1.1v4.3 + C59.8,24.2,59.4,24.7,58.8,24.7 M58.8,31.1c-0.6,0-1.1-0.5-1.1-1.1v-3.2c0-0.6,0.5-1.1,1.1-1.1s1.1,0.5,1.1,1.1V30 + C59.8,30.6,59.4,31.1,58.8,31.1 M58.8,38.5c-0.6,0-1.1-0.5-1.1-1.1v-3.2c0-0.6,0.5-1.1,1.1-1.1s1.1,0.5,1.1,1.1v3.2 + C59.8,38.1,59.4,38.5,58.8,38.5 M58.8,44.9c-0.6,0-1.1-0.5-1.1-1.1v-3.2c0-0.6,0.5-1.1,1.1-1.1s1.1,0.5,1.1,1.1v3.2 + C59.8,44.4,59.4,44.9,58.8,44.9"/> + <path id="path10" class="st0" d="M50.6,53.1c-0.4,0-0.8-0.2-1-0.6c-0.3-0.5,0-1.2,0.5-1.4l3.9-1.8c0.5-0.3,1.2,0,1.4,0.5 + c0.3,0.5,0,1.2-0.5,1.4L51.1,53C51,53.1,50.8,53.1,50.6,53.1 M42.9,56.8c-0.4,0-0.8-0.2-1-0.6c-0.3-0.5,0-1.2,0.5-1.4l2.9-1.4 + c0.5-0.2,1.2,0,1.4,0.5c0.3,0.5,0,1.2-0.5,1.4l-2.9,1.4C43.2,56.7,43.1,56.8,42.9,56.8 M36.4,59.8c-0.4,0-0.8-0.2-1-0.6 + c-0.3-0.5,0-1.2,0.5-1.4l2.7-1.3c0.5-0.2,1.2,0,1.4,0.5s0,1.2-0.5,1.4l-2.7,1.3C36.7,59.8,36.5,59.8,36.4,59.8"/> + <path id="path12" class="st0" d="M38.7,6.5c-0.1,0-0.3,0-0.4-0.1l-2.3-1c-0.5-0.2-0.8-0.8-0.6-1.4c0.2-0.5,0.8-0.8,1.4-0.6l2.3,1 + c0.5,0.2,0.8,0.8,0.6,1.4C39.6,6.2,39.2,6.5,38.7,6.5 M45.6,9.3c-0.1,0-0.3,0-0.4-0.1l-3-1.2c-0.5-0.2-0.8-0.8-0.6-1.4 + C41.8,6.1,42.4,5.8,43,6l3,1.2c0.5,0.2,0.8,0.8,0.6,1.4C46.5,9.1,46.1,9.3,45.6,9.3 M54.5,13c-0.1,0-0.3,0-0.4-0.1l-3.9-1.6 + c-0.5-0.2-0.8-0.8-0.6-1.4c0.2-0.5,0.8-0.8,1.4-0.6l3.9,1.6c0.5,0.2,0.8,0.8,0.6,1.4C55.3,12.7,54.9,13,54.5,13"/> + <path id="path14" class="st0" d="M25.5,6.5c-0.4,0-0.8-0.2-1-0.7s0-1.2,0.6-1.4l2.3-1c0.5-0.2,1.2,0,1.4,0.6 + c0.2,0.5,0,1.2-0.6,1.4l-2.3,1C25.8,6.4,25.7,6.5,25.5,6.5 M18.6,9.3c-0.4,0-0.8-0.2-1-0.7s0-1.2,0.6-1.4l3-1.2 + c0.5-0.2,1.2,0,1.4,0.6c0.2,0.5,0,1.2-0.6,1.4l-3,1.2C18.9,9.3,18.8,9.3,18.6,9.3 M9.8,13c-0.4,0-0.8-0.2-1-0.7s0-1.2,0.6-1.4 + l3.9-1.6c0.5-0.2,1.2,0,1.4,0.6c0.2,0.5,0,1.2-0.6,1.4l-3.9,1.6C10,12.9,9.9,13,9.8,13"/> + </g> + <g id="g32"> + <path id="path24" class="st0" d="M16.2,23.6c-0.2,0-0.4-0.1-0.6-0.2l-6.4-4.3c-0.5-0.3-0.6-1-0.3-1.5s1-0.6,1.5-0.3l6.4,4.3 + c0.5,0.3,0.6,1,0.3,1.5C16.8,23.4,16.5,23.6,16.2,23.6"/> + <path id="path26" class="st0" d="M48.1,23.6c-0.3,0-0.7-0.2-0.9-0.5c-0.3-0.5-0.2-1.2,0.3-1.5l6.4-4.3c0.5-0.3,1.2-0.2,1.5,0.3 + s0.2,1.2-0.3,1.5l-6.4,4.3C48.5,23.6,48.3,23.6,48.1,23.6"/> + <path id="path28" class="st0" d="M54.5,46c-0.2,0-0.4-0.1-0.6-0.2l-6.4-4.3c-0.5-0.3-0.6-1-0.3-1.5s1-0.6,1.5-0.3l6.4,4.3 + c0.5,0.3,0.6,1,0.3,1.5C55.2,45.8,54.8,46,54.5,46"/> + <path id="path30" class="st0" d="M9.8,46c-0.3,0-0.7-0.2-0.9-0.5C8.6,45,8.7,44.4,9.2,44l6.4-4.3c0.5-0.3,1.2-0.2,1.5,0.3 + s0.2,1.2-0.3,1.5l-6.4,4.3C10.2,45.9,10,46,9.8,46"/> + </g> + <g id="g46"> + <path id="path34" class="st1" d="M5.5,20.4c-2.9,0-5.3-2.4-5.3-5.3s2.4-5.3,5.3-5.3s5.3,2.4,5.3,5.3S8.4,20.4,5.5,20.4"/> + <path id="path36" class="st1" d="M32.1,10.8c-2.9,0-5.3-2.4-5.3-5.3s2.4-5.3,5.3-5.3s5.3,2.4,5.3,5.3S35.1,10.8,32.1,10.8"/> + <path id="path38" class="st1" d="M32.1,64.1c-2.9,0-5.3-2.4-5.3-5.3c0-2.9,2.4-5.3,5.3-5.3s5.3,2.4,5.3,5.3S35.1,64.1,32.1,64.1" + /> + <path id="path40" class="st1" d="M58.8,20.4c-2.9,0-5.3-2.4-5.3-5.3s2.4-5.3,5.3-5.3c2.9,0,5.3,2.4,5.3,5.3S61.7,20.4,58.8,20.4" + /> + <path id="path42" class="st1" d="M58.8,53.4c-2.9,0-5.3-2.4-5.3-5.3c0-2.9,2.4-5.3,5.3-5.3c2.9,0,5.3,2.4,5.3,5.3 + S61.7,53.4,58.8,53.4"/> + <path id="path44" class="st1" d="M5.5,53.4c-2.9,0-5.3-2.4-5.3-5.3s2.4-5.3,5.3-5.3s5.3,2.4,5.3,5.3S8.4,53.4,5.5,53.4"/> + </g> +</g> +<g id="cube"> + <g> + <polygon id="polygon18" class="st2" points="16.7,41.6 16.7,22.6 32.2,30.7 32.2,49.5 "/> + <polygon id="polygon20" class="st3" points="47.8,41.6 47.8,22.6 32.2,30.7 32.2,49.6 "/> + <polygon id="polygon22" class="st4" points="16.7,22.6 32.5,14.6 47.8,22.6 32.2,30.7 "/> + <path id="path4_00000163069267588332021340000012919285466319501952_" class="st5" d="M48.4,22.7C48.4,22.6,48.4,22.6,48.4,22.7 + C48.4,22.6,48.4,22.6,48.4,22.7c0-0.1,0-0.1,0-0.2c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0 + c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0,0-0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0-0.1,0c0,0,0,0,0,0l-15.5-8 + c-0.2-0.1-0.4-0.1-0.6,0l-15.5,8c0,0,0,0,0,0c0,0,0,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0.1c0,0,0,0,0,0 + c0,0,0,0,0,0.1c0,0,0,0,0,0c0,0,0,0,0,0.1c0,0,0,0,0,0c0,0,0,0,0,0.1c0,0,0,0,0,0c0,0,0,0,0,0.1c0,0,0,0,0,0c0,0,0,0.1,0,0.1v18.8 + c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0 + c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0 + c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0l5.2,2.6c0,0,0,0,0.1,0l5.1,2.6c0,0,0.1,0.1,0.2,0.1L32,50c0,0,0,0,0,0 + c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0 + c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0 + c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0l5.1-2.6c0.1,0,0.1,0,0.2-0.1l5.1-2.6 + c0,0,0,0,0.1,0l5.2-2.6c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0 + c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0 + c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0L48.4,22.7L48.4,22.7z M27.1,18l3.9,2l-3.9,2l-3.9-2 + L27.1,18z M32.2,20.7l3.9,2l-3.9,2l-3.9-2L32.2,20.7z M41.3,20l-3.9,2l-3.9-2l3.9-2L41.3,20z M17.3,30.2l4,2v4.4l-4-2V30.2z + M22.5,32.9l4,2v4.4l-4-2V32.9z M31.6,36.2l-4-2V29l4,2V36.2z M32.8,31l4-2v5.2l-4,2V31z M38,28.3l4-2v5.2l-4,2V28.3z M32.2,29.9 + l-3.8-2l3.8-2l3.8,2L32.2,29.9z M26.5,33.5l-4-2v-5.2l4,2V33.5z M27.7,35.5l4,2V42l-4-2V35.5z M32.8,37.5l4-2v4.4l-4,2V37.5z + M38,34.9l4-2v4.4l-4,2V34.9z M43.2,32.2l4-2v4.4l-4,2V32.2z M47.2,28.8l-4,2v-5.2l4-2V28.8z M37.4,27.3l-3.8-2l3.9-2l3.8,2 + L37.4,27.3z M30.9,25.3l-3.8,2l-3.9-2l3.8-2L30.9,25.3z M21.3,30.9l-4-2v-5.2l4,2V30.9z M17.3,36l4,2v5.1l-4-2V36z M22.5,38.6l4,2 + v5.1l-4-2V38.6z M27.7,41.3l4,2v5.1l-4-2V41.3z M32.8,43.3l4-2v5.1l-4,2V43.3z M38,40.7l4-2v5.1l-4,2V40.7z M43.2,38l4-2v5.1l-4,2 + V38z M42.6,24.6l-3.8-2l3.9-2l3.8,2L42.6,24.6z M32.2,15.4l3.9,2l-3.9,2l-3.9-2L32.2,15.4z M21.9,20.7l3.9,2l-3.8,2l-3.9-2 + L21.9,20.7z"/> + </g> +</g> +</svg> diff --git a/src/iamap/icons/forest-svgrepo-com.svg b/src/iamap/icons/forest-svgrepo-com.svg new file mode 100644 index 0000000..eeefe73 --- /dev/null +++ b/src/iamap/icons/forest-svgrepo-com.svg @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> + +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" + viewBox="0 0 512 512" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#000000;} +</style> +<g> + <path class="st0" d="M346.483,226.653c-58.176-75.765-90.498-181.813-90.498-181.813s-32.318,106.048-90.505,181.813 + c0,0,26.66,16.09,41.21,7.569c0,0-14.55,65.341-79.995,151.514c58.176,18.923,101.81-12.328,101.81-12.328v93.75h21.025h12.916 + h21.021v-93.75c0,0,43.642,31.25,101.817,12.328c-65.457-86.174-79.995-151.514-79.995-151.514 + C319.826,242.743,346.483,226.653,346.483,226.653z"/> + <path class="st0" d="M160.886,307.087c-19.185-35.761-24.363-59.015-24.363-59.015c8.768,5.141,23.33-1.454,29.058-4.376 + c1.522-0.84,2.417-1.379,2.417-1.379c-5.313-6.985-10.353-14.276-15.186-21.718c-34.855-54.482-53.972-117.26-53.972-117.26 + s-24.711,81.041-69.23,138.977c0,0,20.361,12.283,31.542,5.756c0,0-11.181,49.956-61.151,115.88 + c44.451,14.426,77.788-9.443,77.788-9.443v71.674h42.034v-71.674c0,0,3.035,2.151,8.415,4.759 + C141.633,340.391,152.332,322.817,160.886,307.087z"/> + <path class="st0" d="M450.849,248.071c11.121,6.527,31.474-5.756,31.474-5.756c-44.454-57.936-69.155-138.977-69.155-138.977 + s-19.125,62.778-54.05,117.26c-4.766,7.441-9.803,14.733-15.123,21.718c0,0,0.906,0.54,2.428,1.379 + c5.725,2.922,20.29,9.517,29.058,4.376c0,0-5.178,23.328-24.442,59.09c8.566,15.655,19.331,33.303,32.723,52.106 + c5.381-2.608,8.423-4.759,8.423-4.759v71.674h41.967v-71.674c0,0,33.394,23.869,77.848,9.443 + C461.97,298.027,450.849,248.071,450.849,248.071z"/> +</g> +</svg> \ No newline at end of file diff --git a/src/iamap/icons/network-base-solid-svgrepo-com.svg b/src/iamap/icons/network-base-solid-svgrepo-com.svg new file mode 100644 index 0000000..a93ee40 --- /dev/null +++ b/src/iamap/icons/network-base-solid-svgrepo-com.svg @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> + <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<svg width="800px" height="800px" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"> + <g id="Layer_2" data-name="Layer 2"> + <g id="invisible_box" data-name="invisible box"> + <rect width="48" height="48" fill="none"/> + </g> + <g id="Layer_7" data-name="Layer 7"> + <g> + <path d="M25.5,25.7l-1.5,1-1.5-1a2.2,2.2,0,0,0-2.8.7,2.1,2.1,0,0,0,.7,2.8L23,30.7a1.8,1.8,0,0,0,2,0l2.6-1.5a2.1,2.1,0,0,0,.7-2.8A2.2,2.2,0,0,0,25.5,25.7Z"/> + <path d="M3,18.7l2.5,1.5a2.3,2.3,0,0,0,1.1.3,2.1,2.1,0,0,0,1.7-.9A2,2,0,0,0,7.8,17a2,2,0,0,0,.5-2.6,2.2,2.2,0,0,0-2.8-.7L3,15.3A1.9,1.9,0,0,0,2,17,2.1,2.1,0,0,0,3,18.7Z"/> + <path d="M12,14.2a1.9,1.9,0,0,0,1-.3l4.1-2.4a2,2,0,0,0,.6-2.7A1.9,1.9,0,0,0,15,8.1l-4.1,2.4a2,2,0,0,0-.6,2.7A1.8,1.8,0,0,0,12,14.2Z"/> + <path d="M22.5,8.3l1.5-1,1.5,1a2.2,2.2,0,0,0,1.1.2,2,2,0,0,0,1.7-.9,2.1,2.1,0,0,0-.7-2.8L25,3.3a1.8,1.8,0,0,0-2,0L20.4,4.8a2.1,2.1,0,0,0-.7,2.8A2.2,2.2,0,0,0,22.5,8.3Z"/> + <path d="M30.9,11.5,35,13.9a1.9,1.9,0,0,0,1,.3,1.8,1.8,0,0,0,1.7-1,2,2,0,0,0-.6-2.7L33,8.1a1.9,1.9,0,0,0-2.7.7A2,2,0,0,0,30.9,11.5Z"/> + <path d="M40.2,17a2,2,0,0,0-.5,2.6,2.1,2.1,0,0,0,1.7.9,2.3,2.3,0,0,0,1.1-.3L45,18.7A2.1,2.1,0,0,0,46,17a1.9,1.9,0,0,0-1-1.7l-2.5-1.6a2.2,2.2,0,0,0-2.8.7A2,2,0,0,0,40.2,17Z"/> + <path d="M10.9,22.5h0L15,24.9a1.9,1.9,0,0,0,1,.3,1.8,1.8,0,0,0,1.7-1,2,2,0,0,0-.6-2.7L13,19.1a1.9,1.9,0,0,0-2.7.6A2.1,2.1,0,0,0,10.9,22.5Z"/> + <path d="M32,25.2a1.9,1.9,0,0,0,1-.3l4.1-2.4a2.1,2.1,0,0,0,.6-2.8,1.9,1.9,0,0,0-2.7-.6l-4.1,2.4a2,2,0,0,0-.6,2.7A1.8,1.8,0,0,0,32,25.2Z"/> + <path d="M45,28.3l-6.2-3.8L24,33.2,9.2,24.5,3,28.3A1.9,1.9,0,0,0,2,30a2.1,2.1,0,0,0,1,1.7l20,12a1.8,1.8,0,0,0,2,0l20-12A2.1,2.1,0,0,0,46,30,1.9,1.9,0,0,0,45,28.3Z"/> + </g> + </g> + </g> +</svg> \ No newline at end of file diff --git a/src/iamap/icons/network-platform-svgrepo-com.svg b/src/iamap/icons/network-platform-svgrepo-com.svg new file mode 100644 index 0000000..2d1a1de --- /dev/null +++ b/src/iamap/icons/network-platform-svgrepo-com.svg @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> + <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<svg width="800px" height="800px" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"> + <title>network-platform</title> + <g id="Layer_2" data-name="Layer 2"> + <g id="invisible_box" data-name="invisible box"> + <rect width="48" height="48" fill="none"/> + <rect width="48" height="48" fill="none"/> + <rect width="48" height="48" fill="none"/> + </g> + <g id="icons_Q2" data-name="icons Q2"> + <path d="M40,18a6.2,6.2,0,0,0-5.7,4H25.8a9.9,9.9,0,0,0-1.5-3.3l3.5-4.1A7.8,7.8,0,0,0,30,15a6,6,0,1,0-6-6,6.1,6.1,0,0,0,.8,3l-3.6,4.1A8.5,8.5,0,0,0,17,15l-1.7.2-1.2-2.8A6,6,0,1,0,10,14h.4l1.3,2.8a8.9,8.9,0,0,0,0,14.4L10.4,34H10a6,6,0,1,0,4.1,1.6l1.2-2.8L17,33a8.5,8.5,0,0,0,4.2-1.1L24.8,36a6.1,6.1,0,0,0-.8,3,6,6,0,1,0,6-6,7.8,7.8,0,0,0-2.2.4l-3.5-4.1A9.9,9.9,0,0,0,25.8,26h8.5A6.2,6.2,0,0,0,40,30a6,6,0,0,0,0-12ZM8,8a2,2,0,1,1,2,2A2,2,0,0,1,8,8Zm2,34a2,2,0,1,1,2-2A2,2,0,0,1,10,42ZM30,7a2,2,0,1,1-2,2A2,2,0,0,1,30,7ZM12,24a5,5,0,1,1,5,5A5,5,0,0,1,12,24ZM32,39a2,2,0,1,1-2-2A2,2,0,0,1,32,39Zm8-13a2,2,0,1,1,2-2A2,2,0,0,1,40,26Z"/> + </g> + </g> +</svg> \ No newline at end of file diff --git a/src/iamap/icons/scatter-plot-outline-alerted-svgrepo-com.svg b/src/iamap/icons/scatter-plot-outline-alerted-svgrepo-com.svg new file mode 100644 index 0000000..22ffd24 --- /dev/null +++ b/src/iamap/icons/scatter-plot-outline-alerted-svgrepo-com.svg @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<svg fill="#000000" width="800px" height="800px" viewBox="0 0 36 36" version="1.1" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <title>scatter-plot-outline-alerted</title> + <path class="clr-i-outline--alerted clr-i-outline-path-1--alerted" d="M 34 29 C 34 30.105 33.105 31 32 31 L 4 31 C 2.895 31 2 30.105 2 29 L 2 7 C 2 5.895 2.895 5 4 5 L 21.958 5 L 20.786 7 L 4 7 L 4 29 L 32 29 L 32 15.357 L 34 15.357 Z"></path><path class="clr-i-outline--alerted clr-i-outline-path-2--alerted" d="M 9.101 15.8 C 9.413 16.111 9.919 16.111 10.231 15.8 L 11.391 14.64 L 12.551 15.8 C 12.964 16.256 13.717 16.094 13.905 15.507 C 14.002 15.208 13.914 14.881 13.681 14.67 L 12.531 13.54 L 13.691 12.38 C 14.147 11.966 13.985 11.214 13.399 11.025 C 13.1 10.929 12.772 11.017 12.561 11.25 L 11.401 12.41 L 10.231 11.22 C 9.817 10.763 9.065 10.926 8.877 11.512 C 8.78 11.811 8.868 12.139 9.101 12.35 L 10.261 13.54 L 9.101 14.67 C 8.789 14.982 8.789 15.487 9.101 15.8 Z"></path><path class="clr-i-outline--alerted clr-i-outline-path-3--alerted" d="M 15.176 25.536 C 15.488 25.847 15.994 25.847 16.306 25.536 L 17.466 24.376 L 18.626 25.536 C 19.039 25.992 19.792 25.83 19.98 25.243 C 20.077 24.944 19.989 24.617 19.756 24.406 L 18.606 23.276 L 19.766 22.116 C 20.222 21.702 20.06 20.95 19.474 20.761 C 19.175 20.665 18.847 20.753 18.636 20.986 L 17.476 22.146 L 16.306 20.956 C 15.892 20.499 15.14 20.662 14.952 21.248 C 14.855 21.547 14.943 21.875 15.176 22.086 L 16.336 23.276 L 15.176 24.406 C 14.864 24.718 14.864 25.223 15.176 25.536 Z"></path><path class="clr-i-outline--alerted clr-i-outline-path-4--alerted" d="M 22.912 20.343 C 23.224 20.654 23.73 20.654 24.042 20.343 L 25.202 19.183 L 26.362 20.343 C 26.775 20.799 27.528 20.637 27.716 20.05 C 27.813 19.751 27.725 19.424 27.492 19.213 L 26.342 18.083 L 27.502 16.923 C 27.958 16.509 27.796 15.757 27.21 15.568 C 26.911 15.472 26.583 15.56 26.372 15.793 L 25.212 16.953 L 24.042 15.763 C 23.628 15.306 22.876 15.469 22.688 16.055 C 22.591 16.354 22.679 16.682 22.912 16.893 L 24.072 18.083 L 22.912 19.213 C 22.6 19.525 22.6 20.03 22.912 20.343 Z"></path><path class="clr-i-outline--alerted clr-i-outline-path-5--alerted clr-i-alert" d="M 26.854 1.144 L 21.134 11.004 C 20.579 11.818 21.114 12.928 22.097 13.001 C 22.142 13.005 22.188 13.006 22.234 13.004 L 33.684 13.004 C 34.669 13.036 35.319 11.991 34.855 11.122 C 34.834 11.081 34.81 11.042 34.784 11.004 L 29.064 1.144 C 28.57 0.299 27.348 0.299 26.854 1.144 Z"></path> + <rect x="0" y="0" width="36" height="36" fill-opacity="0"/> +</svg> \ No newline at end of file diff --git a/src/iamap/icons/scatter-plot-svgrepo-com.svg b/src/iamap/icons/scatter-plot-svgrepo-com.svg new file mode 100644 index 0000000..543d6cd --- /dev/null +++ b/src/iamap/icons/scatter-plot-svgrepo-com.svg @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<svg fill="#000000" width="800px" height="800px" viewBox="0 0 36 36" version="1.1" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <title>scatter-plot-line</title> + <path class="clr-i-outline clr-i-outline-path-1" d="M 32 5 L 4 5 C 2.895 5 2 5.895 2 7 L 2 29 C 2 30.105 2.895 31 4 31 L 32 31 C 33.105 31 34 30.105 34 29 L 34 7 C 34 5.895 33.105 5 32 5 Z M 4 29 L 4 7 L 32 7 L 32 29 Z"></path><path class="clr-i-outline clr-i-outline-path-2" d="M 9.101 15.8 C 9.413 16.111 9.919 16.111 10.231 15.8 L 11.391 14.64 L 12.551 15.8 C 12.964 16.256 13.717 16.094 13.905 15.507 C 14.002 15.208 13.914 14.881 13.681 14.67 L 12.531 13.54 L 13.691 12.38 C 14.147 11.966 13.985 11.214 13.399 11.025 C 13.1 10.929 12.772 11.017 12.561 11.25 L 11.401 12.41 L 10.231 11.22 C 9.817 10.763 9.065 10.926 8.877 11.512 C 8.78 11.811 8.868 12.139 9.101 12.35 L 10.261 13.54 L 9.101 14.67 C 8.789 14.982 8.789 15.487 9.101 15.8 Z"></path><path class="clr-i-outline clr-i-outline-path-3" d="M 15.176 25.536 C 15.488 25.847 15.994 25.847 16.306 25.536 L 17.466 24.376 L 18.626 25.536 C 19.039 25.992 19.792 25.83 19.98 25.243 C 20.077 24.944 19.989 24.617 19.756 24.406 L 18.606 23.276 L 19.766 22.116 C 20.222 21.702 20.06 20.95 19.474 20.761 C 19.175 20.665 18.847 20.753 18.636 20.986 L 17.476 22.146 L 16.306 20.956 C 15.892 20.499 15.14 20.662 14.952 21.248 C 14.855 21.547 14.943 21.875 15.176 22.086 L 16.336 23.276 L 15.176 24.406 C 14.864 24.718 14.864 25.223 15.176 25.536 Z"></path><path class="clr-i-outline clr-i-outline-path-4" d="M 22.912 20.343 C 23.224 20.654 23.73 20.654 24.042 20.343 L 25.202 19.183 L 26.362 20.343 C 26.775 20.799 27.528 20.637 27.716 20.05 C 27.813 19.751 27.725 19.424 27.492 19.213 L 26.342 18.083 L 27.502 16.923 C 27.958 16.509 27.796 15.757 27.21 15.568 C 26.911 15.472 26.583 15.56 26.372 15.793 L 25.212 16.953 L 24.042 15.763 C 23.628 15.306 22.876 15.469 22.688 16.055 C 22.591 16.354 22.679 16.682 22.912 16.893 L 24.072 18.083 L 22.912 19.213 C 22.6 19.525 22.6 20.03 22.912 20.343 Z"></path> + <rect x="0" y="0" width="36" height="36" fill-opacity="0"/> +</svg> \ No newline at end of file diff --git a/src/iamap/provider.py b/src/iamap/provider.py new file mode 100644 index 0000000..5d7797b --- /dev/null +++ b/src/iamap/provider.py @@ -0,0 +1,46 @@ +from qgis.core import QgsProcessingProvider + +from .encoder import EncoderAlgorithm +from .reduction import ReductionAlgorithm +from .clustering import ClusterAlgorithm +from .similarity import SimilarityAlgorithm +from .random_forest import RFAlgorithm +from .icons import QIcon_EncoderTool + + +class IAMapProvider(QgsProcessingProvider): + + def loadAlgorithms(self, *args, **kwargs): + self.addAlgorithm(EncoderAlgorithm()) + self.addAlgorithm(ReductionAlgorithm()) + self.addAlgorithm(ClusterAlgorithm()) + self.addAlgorithm(SimilarityAlgorithm()) + self.addAlgorithm(RFAlgorithm()) + # add additional algorithms here + # self.addAlgorithm(MyOtherAlgorithm()) + + def id(self, *args, **kwargs): + """The ID of your plugin, used for identifying the provider. + + This string should be a unique, short, character only string, + eg "qgis" or "gdal". This string should not be localised. + """ + return 'iamap' + + def name(self, *args, **kwargs): + """The human friendly name of your plugin in Processing. + + This string should be as short as possible (e.g. "Lastools", not + "Lastools version 1.0.1 64-bit") and localised. + """ + return self.tr('IAMap') + + def icon(self): + """Should return a QIcon which is used for your provider inside + the Processing toolbox. + """ + return QIcon_EncoderTool + + def longName(self) -> str: + return self.name() + diff --git a/src/iamap/random_forest.py b/src/iamap/random_forest.py new file mode 100644 index 0000000..c71d334 --- /dev/null +++ b/src/iamap/random_forest.py @@ -0,0 +1,599 @@ +import os +import numpy as np +from pathlib import Path +from typing import Dict, Any +import joblib + +import rasterio +from rasterio import windows +import geopandas as gpd +import pandas as pd +from shapely.geometry import box +from qgis.PyQt.QtCore import QCoreApplication +from qgis.core import (Qgis, + QgsGeometry, + QgsProcessingParameterBoolean, + QgsProcessingParameterFile, + QgsProcessingParameterEnum, + QgsCoordinateTransform, + QgsProcessingException, + QgsProcessingAlgorithm, + QgsProcessingParameterRasterLayer, + QgsProcessingParameterFolderDestination, + QgsProcessingParameterString, + QgsProcessingParameterBand, + QgsProcessingParameterNumber, + QgsProcessingParameterExtent, + QgsProcessingParameterCrs, + QgsProcessingParameterDefinition, + ) +import torch +import torch.nn as nn +from sklearn.ensemble import RandomForestClassifier +from sklearn.metrics import accuracy_score +from sklearn.preprocessing import LabelEncoder +from sklearn.model_selection import train_test_split + +import json + +class RFAlgorithm(QgsProcessingAlgorithm): + """ + """ + + INPUT = 'INPUT' + BANDS = 'BANDS' + EXTENT = 'EXTENT' + LOAD = 'LOAD' + OUTPUT = 'OUTPUT' + RESOLUTION = 'RESOLUTION' + CRS = 'CRS' + TEMPLATE = 'TEMPLATE' + COLONNE_RF = 'COLONNE_RF' + TEMPLATE_TEST = 'TEMPLATE_TEST' + + def initAlgorithm(self, config=None): + """ + Here we define the inputs and output of the algorithm, along + with some other properties. + """ + cwd = Path(__file__).parent.absolute() + + self.addParameter( + QgsProcessingParameterRasterLayer( + name=self.INPUT, + description=self.tr( + 'Input raster layer or image file path'), + defaultValue=os.path.join(cwd,'rasters','test.tif'), + ), + ) + + self.addParameter( + QgsProcessingParameterBand( + name=self.BANDS, + description=self.tr( + 'Selected Bands (defaults to all bands selected)'), + defaultValue=None, + parentLayerParameterName=self.INPUT, + optional=True, + allowMultiple=True, + ) + ) + + crs_param = QgsProcessingParameterCrs( + name=self.CRS, + description=self.tr('Target CRS (default to original CRS)'), + optional=True, + ) + + res_param = QgsProcessingParameterNumber( + name=self.RESOLUTION, + description=self.tr( + 'Target resolution in meters (default to native resolution)'), + type=QgsProcessingParameterNumber.Double, + optional=True, + minValue=0, + maxValue=100000 + ) + + self.addParameter( + QgsProcessingParameterExtent( + name=self.EXTENT, + description=self.tr( + 'Processing extent (default to the entire image)'), + optional=True + ) + ) + + self.addParameter( + QgsProcessingParameterFile( + name=self.TEMPLATE, + description=self.tr( + 'Input shapefile path for training data set for random forest (if no test data_set, will be devised in train and test)'), + defaultValue=os.path.join(cwd,'rasters','RF.gpkg'), + ), + ) + + self.addParameter( + QgsProcessingParameterFile( + name=self.TEMPLATE_TEST, + description=self.tr( + 'Input shapefile path for test data set for random forest (optional)'), + optional = True + ), + ) + + + self.addParameter( + QgsProcessingParameterFolderDestination( + self.OUTPUT, + self.tr( + "Output directory (choose the location that the image features will be saved)"), + defaultValue=os.path.join(cwd,'features'), + ) + ) + + + self.addParameter ( + QgsProcessingParameterString( + name = self.COLONNE_RF, + description = self.tr( + 'Name of the column you want random forest to work on'), + defaultValue = 'Type', + # defaultValue = 'vit_small_patch16_224.dino', + ) + ) + + + for param in (crs_param, res_param): + param.setFlags( + param.flags() | QgsProcessingParameterDefinition.FlagAdvanced) + self.addParameter(param) + + + + def processAlgorithm(self, parameters, context, feedback): + """ + Here is where the processing itself takes place. + """ + self.process_options(parameters, context, feedback) + + gdf = gpd.read_file(self.template) + feedback.pushInfo(f"self_template_test : {self.template_test}") + DATA_SET_TEST=False + if(self.template_test != ''): + gdf_test = gpd.read_file(self.template_test) + gdf_test = gdf_test.to_crs(self.crs.toWkt()) + DATA_SET_TEST = True + feedback.pushInfo(f"In good loop !") + + gdf = gdf.to_crs(self.crs.toWkt()) + + feedback.pushInfo(f"DATA_SET_TEST : {DATA_SET_TEST}") + + + feedback.pushInfo(f'before extent: {len(gdf)}') + bounds = box( + self.extent.xMinimum(), + self.extent.yMinimum(), + self.extent.xMaximum(), + self.extent.yMaximum(), + ) + feedback.pushInfo(f'xmin: {self.extent.xMinimum()},ymin: {self.extent.yMinimum()}, xmax: {self.extent.xMaximum()}, ymax: {self.extent.yMaximum()} ') + gdf = gdf[gdf.within(bounds)] + feedback.pushInfo(f'after extent: {len(gdf)}') + + if len(gdf) == 0: + feedback.pushWarning("No template points within extent !") + return False + + + + + input_bands = [i_band -1 for i_band in self.selected_bands] + + + with rasterio.open(self.rlayer_path) as ds: + + + + + gdf = gdf.to_crs(ds.crs) + + pixel_values_test = [] + + pixel_values = [] + + transform = ds.transform + crs = ds.crs + win = windows.from_bounds( + self.extent.xMinimum(), + self.extent.yMinimum(), + self.extent.xMaximum(), + self.extent.yMaximum(), + transform=transform + ) + raster = ds.read(window=win) + transform = ds.window_transform(win) + raster = raster[input_bands,:,:] + + if (DATA_SET_TEST == True): + gdf_test = gdf_test.to_crs(ds.crs) + for index, data in gdf_test.iterrows(): + # Get the coordinates of the point in the raster's pixel space + x, y = data.geometry.x, data.geometry.y + feedback.pushInfo (f"x : {x}, y : {y}") + feedback.pushInfo (f"gdf geometry : {gdf['geometry']}") + + # Convert point coordinates to pixel coordinates within the window + col, row = ~transform * (x, y) # Convert from map coordinates to pixel coordinates + col, row = int(col), int(row) + feedback.pushInfo(f'after extent: {row, col}') + pixel_values_test.append(list(raster[:,row, col])) + template_npy_test = np.asarray(pixel_values_test) + template_test = torch.from_numpy(template_npy_test).to(torch.float32) + + for index, data in gdf.iterrows(): + # Get the coordinates of the point in the raster's pixel space + x, y = data.geometry.x, data.geometry.y + feedback.pushInfo (f"x : {x}, y : {y}") + feedback.pushInfo (f"gdf geometry : {gdf['geometry']}") + + # Convert point coordinates to pixel coordinates within the window + col, row = ~transform * (x, y) # Convert from map coordinates to pixel coordinates + col, row = int(col), int(row) + feedback.pushInfo(f'after extent: {row, col}') + pixel_values.append(list(raster[:,row, col])) + + raster = np.transpose(raster, (1,2,0)) + + feedback.pushInfo(f'{raster.shape}') + + template_npy = np.asarray(pixel_values) + + feedback.pushInfo(f'points : {template_npy}') + feedback.pushInfo(f'dim points : {template_npy.shape}') + template = torch.from_numpy(template_npy).to(torch.float32) + + + + + feat_img = torch.from_numpy(raster) + + #template contient les valeurs + #y = gdf['Type'] + #y=gdf ['Desc_'] + + if (DATA_SET_TEST == False): + + if self.colonne_rf in gdf.columns : + + y = gdf[self.colonne_rf] + else : + feedback.pushWarning (f'{self.colonne_rf} is not a valid column name of the dataset !!') + + + + X_train, X_test, y_train, y_test = train_test_split(template, y, test_size=0.4, random_state=55) + + rf_classifier = RandomForestClassifier(n_estimators=100, min_samples_split=4, random_state=42) + rf_classifier.fit(X_train, y_train) + + if (DATA_SET_TEST == True): + + y_train=gdf[self.colonne_rf] + y_test=gdf_test[self.colonne_rf] + + X_train = template + + + X_test = template_test + + + rf_classifier = RandomForestClassifier(n_estimators=100, min_samples_split=4, random_state=42) + + rf_classifier.fit(X_train, y_train) + + params = rf_classifier.get_params() + + #joblib.dump(rf_classifier, model_file) + + y_pred = rf_classifier.predict(X_test) + accuracy = accuracy_score(y_test, y_pred) + feedback.pushInfo(f"Accuracy ; {accuracy}") + + predicted_types = rf_classifier.predict(raster.reshape(-1, raster.shape[-1])) + feedback.pushInfo(f"predicted types ; {predicted_types.shape}") + predicted_types_image = predicted_types.reshape(raster.shape[:-1]) + feedback.pushInfo(f"predicted_types_image ; {predicted_types_image.shape}") + + label_encoder = LabelEncoder() + predicted_types_numeric = label_encoder.fit_transform(predicted_types_image.flatten()) + feedback.pushInfo(f'carte avant transfo : {predicted_types_numeric.shape}') + predicted_types_numeric = predicted_types_numeric.reshape(predicted_types_image.shape) + feedback.pushInfo(f'carte après transfo : {predicted_types_numeric.shape}') + + + height, width = predicted_types_numeric.shape + channels = 1 + + + + + + dst_path = os.path.join(self.output_dir,'random_forest.tif') + params_file = os.path.join(self.output_dir, 'random_forest_parameters.json') + + + + if os.path.exists(dst_path): + i = 1 + while True: + modified_output_file = os.path.join(self.output_dir, f"random_forest_{i}.tif") + if not os.path.exists(modified_output_file): + dst_path = modified_output_file + break + i += 1 + + if os.path.exists(params_file): + i = 1 + while True: + modified_output_file_params = os.path.join(self.output_dir, f"random_forest_parameters_{i}.json") + if not os.path.exists(modified_output_file_params): + params_file = modified_output_file_params + break + i += 1 + + with rasterio.open(dst_path, 'w', driver='GTiff', + height=height, width=width, count=channels, dtype='float32', + crs=crs, transform=transform) as dst_ds: + dst_ds.write(predicted_types_numeric, 1) + + with open(params_file, 'w') as f: + json.dump(params, f, indent=4) + feedback.pushInfo(f"Parameters saved to {params_file}") + + parameters['OUTPUT_RASTER']=dst_path + + return {'OUTPUT_RASTER':dst_path} + + def process_options(self,parameters, context, feedback): + self.iPatch = 0 + + self.feature_dir = "" + + feedback.pushInfo( + f'PARAMETERS :\n{parameters}') + + feedback.pushInfo( + f'CONTEXT :\n{context}') + + feedback.pushInfo( + f'FEEDBACK :\n{feedback}') + + rlayer = self.parameterAsRasterLayer( + parameters, self.INPUT, context) + + if rlayer is None: + raise QgsProcessingException( + self.invalidRasterError(parameters, self.INPUT)) + + self.selected_bands = self.parameterAsInts( + parameters, self.BANDS, context) + + if len(self.selected_bands) == 0: + self.selected_bands = list(range(1, rlayer.bandCount()+1)) + + if max(self.selected_bands) > rlayer.bandCount(): + raise QgsProcessingException( + self.tr("The chosen bands exceed the largest band number!") + ) + + self.template = self.parameterAsFile( + parameters, self.TEMPLATE, context) + + self.template_test = self.parameterAsFile( + parameters, self.TEMPLATE_TEST, context) + + self.colonne_rf = self.parameterAsString( + parameters, self.COLONNE_RF, context) + + res = self.parameterAsDouble( + parameters, self.RESOLUTION, context) + crs = self.parameterAsCrs( + parameters, self.CRS, context) + extent = self.parameterAsExtent( + parameters, self.EXTENT, context) + self.output_dir = self.parameterAsString( + parameters, self.OUTPUT, context) + + rlayer_data_provider = rlayer.dataProvider() + + # handle crs + if crs is None or not crs.isValid(): + crs = rlayer.crs() + feedback.pushInfo(f'crs : {crs}') + + """ + feedback.pushInfo( + f'Layer CRS unit is {crs.mapUnits()}') # 0 for meters, 6 for degrees, 9 for unknown + feedback.pushInfo( + f'whether the CRS is a geographic CRS (using lat/lon coordinates) {crs.isGeographic()}') + if crs.mapUnits() == Qgis.DistanceUnit.Degrees: + crs = self.estimate_utm_crs(rlayer.extent()) + + # target crs should use meters as units + if crs.mapUnits() != Qgis.DistanceUnit.Meters: + feedback.pushInfo( + f'Layer CRS unit is {crs.mapUnits()}') + feedback.pushInfo( + f'whether the CRS is a geographic CRS (using lat/lon coordinates) {crs.isGeographic()}') + raise QgsProcessingException( + self.tr("Only support CRS with the units as meters") + ) + """ + + # 0 for meters, 6 for degrees, 9 for unknown + UNIT_METERS = 0 + UNIT_DEGREES = 6 + if rlayer.crs().mapUnits() == UNIT_DEGREES: # Qgis.DistanceUnit.Degrees: + layer_units = 'degrees' + else: + layer_units = 'meters' + # if res is not provided, get res info from rlayer + if np.isnan(res) or res == 0: + res = rlayer.rasterUnitsPerPixelX() # rasterUnitsPerPixelY() is negative + target_units = layer_units + else: + # when given res in meters by users, convert crs to utm if the original crs unit is degree + if crs.mapUnits() != UNIT_METERS: # Qgis.DistanceUnit.Meters: + if rlayer.crs().mapUnits() == UNIT_DEGREES: # Qgis.DistanceUnit.Degrees: + # estimate utm crs based on layer extent + crs = self.estimate_utm_crs(rlayer.extent()) + else: + raise QgsProcessingException( + f"Resampling of image with the CRS of {crs.authid()} in meters is not supported.") + target_units = 'meters' + # else: + # res = (rlayer_extent.xMaximum() - + # rlayer_extent.xMinimum()) / rlayer.width() + self.res = res + + # handle extent + if extent.isNull(): + extent = rlayer.extent() # QgsProcessingUtils.combineLayerExtents(layers, crs, context) + extent_crs = rlayer.crs() + else: + if extent.isEmpty(): + raise QgsProcessingException( + self.tr("The extent for processing can not be empty!")) + extent_crs = self.parameterAsExtentCrs( + parameters, self.EXTENT, context) + # if extent crs != target crs, convert it to target crs + if extent_crs != crs: + transform = QgsCoordinateTransform( + extent_crs, crs, context.transformContext()) + # extent = transform.transformBoundingBox(extent) + # to ensure coverage of the transformed extent + # convert extent to polygon, transform polygon, then get boundingBox of the new polygon + extent_polygon = QgsGeometry.fromRect(extent) + extent_polygon.transform(transform) + extent = extent_polygon.boundingBox() + extent_crs = crs + + # check intersects between extent and rlayer_extent + if rlayer.crs() != crs: + transform = QgsCoordinateTransform( + rlayer.crs(), crs, context.transformContext()) + rlayer_extent = transform.transformBoundingBox( + rlayer.extent()) + else: + rlayer_extent = rlayer.extent() + if not rlayer_extent.intersects(extent): + raise QgsProcessingException( + self.tr("The extent for processing is not intersected with the input image!")) + + img_width_in_extent = round( + (extent.xMaximum() - extent.xMinimum())/self.res) + img_height_in_extent = round( + (extent.yMaximum() - extent.yMinimum())/self.res) + + # Send some information to the user + feedback.pushInfo( + f'Layer path: {rlayer_data_provider.dataSourceUri()}') + # feedback.pushInfo( + # f'Layer band scale: {rlayer_data_provider.bandScale(self.selected_bands[0])}') + feedback.pushInfo(f'Layer name: {rlayer.name()}') + if rlayer.crs().authid(): + feedback.pushInfo(f'Layer CRS: {rlayer.crs().authid()}') + else: + feedback.pushInfo( + f'Layer CRS in WKT format: {rlayer.crs().toWkt()}') + feedback.pushInfo( + f'Layer pixel size: {rlayer.rasterUnitsPerPixelX()}, {rlayer.rasterUnitsPerPixelY()} {layer_units}') + + feedback.pushInfo(f'Bands selected: {self.selected_bands}') + + if crs.authid(): + feedback.pushInfo(f'Target CRS: {crs.authid()}') + else: + feedback.pushInfo(f'Target CRS in WKT format: {crs.toWkt()}') + feedback.pushInfo(f'Target resolution: {self.res} {target_units}') + feedback.pushInfo( + (f'Processing extent: minx:{extent.xMinimum():.6f}, maxx:{extent.xMaximum():.6f},' + f'miny:{extent.yMinimum():.6f}, maxy:{extent.yMaximum():.6f}')) + feedback.pushInfo( + (f'Processing image size: (width {img_width_in_extent}, ' + f'height {img_height_in_extent})')) + + self.rlayer_path = rlayer.dataProvider().dataSourceUri() + + feedback.pushInfo(f'Selected bands: {self.selected_bands}') + + ## passing parameters to self once everything has been processed + self.extent = extent + self.rlayer = rlayer + self.crs = crs + + + # used to handle any thread-sensitive cleanup which is required by the algorithm. + def postProcessAlgorithm(self, context, feedback) -> Dict[str, Any]: + return {} + + + def tr(self, string): + """ + Returns a translatable string with the self.tr() function. + """ + return QCoreApplication.translate('Processing', string) + + def createInstance(self): + return RFAlgorithm() + + def name(self): + """ + Returns the algorithm name, used for identifying the algorithm. This + string should be fixed for the algorithm, and must not be localised. + The name should be unique within each provider. Names should contain + lowercase alphanumeric characters only and no spaces or other + formatting characters. + """ + return 'Random_forest' + + def displayName(self): + """ + Returns the translated algorithm name, which should be used for any + user-visible display of the algorithm name. + """ + return self.tr('Random_forest') + + def group(self): + """ + Returns the name of the group this algorithm belongs to. This string + should be localised. + """ + return self.tr('') + + def groupId(self): + """ + Returns the unique ID of the group this algorithm belongs to. This + string should be fixed for the algorithm, and must not be localised. + The group id should be unique within each provider. Group id should + contain lowercase alphanumeric characters only and no spaces or other + formatting characters. + """ + return '' + + def shortHelpString(self): + """ + Returns a localised short helper string for the algorithm. This string + should provide a basic description about what the algorithm does and the + parameters and outputs associated with it.. + """ + return self.tr("Compute Random forest using input template") + + def icon(self): + return 'E' + + + + diff --git a/src/iamap/reduction.py b/src/iamap/reduction.py new file mode 100644 index 0000000..0d73303 --- /dev/null +++ b/src/iamap/reduction.py @@ -0,0 +1,568 @@ +import os +import numpy as np +from pathlib import Path +from typing import Dict, Any +import joblib +import pandas as pd +from sklearn.preprocessing import StandardScaler + +import rasterio +from rasterio import windows +from qgis.PyQt.QtCore import QCoreApplication +from qgis.core import (Qgis, + QgsGeometry, + QgsProcessingParameterBoolean, + QgsProcessingParameterEnum, + QgsCoordinateTransform, + QgsProcessingException, + QgsProcessingAlgorithm, + QgsProcessingParameterRasterLayer, + QgsProcessingParameterFolderDestination, + QgsProcessingParameterBand, + QgsProcessingParameterNumber, + QgsProcessingParameterExtent, + QgsProcessingParameterCrs, + QgsProcessingParameterDefinition, + ) + + +from sklearn.decomposition import PCA +#from umap.umap_ import UMAP +from sklearn.cluster import KMeans + +import json + + + +class ReductionAlgorithm(QgsProcessingAlgorithm): + """ + """ + + INPUT = 'INPUT' + BANDS = 'BANDS' + EXTENT = 'EXTENT' + LOAD = 'LOAD' + OUTPUT = 'OUTPUT' + RESOLUTION = 'RESOLUTION' + CRS = 'CRS' + COMPONENTS = 'COMPONENTS' + SUBSET = 'SUBSET' + METHOD = 'METHOD' + SAVE_MODEL = 'SAVE_MODEL' + THRESOLD_PCA = 'THRESOLD_PCA' + + + def initAlgorithm(self, config=None): + """ + Here we define the inputs and output of the algorithm, along + with some other properties. + """ + cwd = Path(__file__).parent.absolute() + + self.addParameter( + QgsProcessingParameterRasterLayer( + name=self.INPUT, + description=self.tr( + 'Input raster layer or image file path'), + defaultValue=os.path.join(cwd,'rasters','test.tif'), + ), + ) + + self.addParameter( + QgsProcessingParameterBand( + name=self.BANDS, + description=self.tr( + 'Selected Bands (defaults to all bands selected)'), + defaultValue=None, + parentLayerParameterName=self.INPUT, + optional=True, + allowMultiple=True, + ) + ) + + crs_param = QgsProcessingParameterCrs( + name=self.CRS, + description=self.tr('Target CRS (default to original CRS)'), + optional=True, + ) + + res_param = QgsProcessingParameterNumber( + name=self.RESOLUTION, + description=self.tr( + 'Target resolution in meters (default to native resolution)'), + type=QgsProcessingParameterNumber.Double, + optional=True, + minValue=0, + maxValue=100000 + ) + + self.addParameter( + QgsProcessingParameterExtent( + name=self.EXTENT, + description=self.tr( + 'Processing extent (default to the entire image)'), + optional=True + ) + ) + self.method_opt = ['PCA', 'UMAP', 'K-means', '--Empty--'] + self.addParameter ( + QgsProcessingParameterEnum( + name = self.METHOD, + description = self.tr( + 'Method for the dimension reduction'), + defaultValue = 0, + options = self.method_opt, + + ) + ) + self.addParameter( + QgsProcessingParameterNumber( + name = self.THRESOLD_PCA, + description = self.tr ( + 'Thresold for displaying contribution of each variables when using PCA (between 0 and 1)' + ), + type = QgsProcessingParameterNumber.Double, + minValue = 0, + defaultValue = 0.5, + maxValue = 1 + ) + ) + + self.addParameter( + QgsProcessingParameterNumber( + name=self.COMPONENTS, + description=self.tr( + 'Number of target components'), + type=QgsProcessingParameterNumber.Integer, + defaultValue = 3, + minValue=1, + maxValue=1024 + ) + ) + subset_param = QgsProcessingParameterNumber( + name=self.SUBSET, + description=self.tr( + 'Select a subset of random pixels of the image to fit transform'), + type=QgsProcessingParameterNumber.Integer, + defaultValue=None, + minValue=1, + maxValue=10_000, + optional=True, + ) + + save_param = QgsProcessingParameterBoolean( + self.SAVE_MODEL, + self.tr("Save projection model after fit."), + defaultValue=True + ) + + + self.addParameter( + QgsProcessingParameterFolderDestination( + self.OUTPUT, + self.tr( + "Output directory (choose the location that the image features will be saved)"), + defaultValue=os.path.join(cwd,'features'), + ) + ) + + + for param in (crs_param, res_param, subset_param, save_param): + param.setFlags( + param.flags() | QgsProcessingParameterDefinition.FlagAdvanced) + self.addParameter(param) + + + + def processAlgorithm(self, parameters, context, feedback): + """ + Here is where the processing itself takes place. + """ + self.process_options(parameters, context, feedback) + + input_bands = [i_band -1 for i_band in self.selected_bands] + + if self.method == 'PCA': + proj = PCA(int(self.ncomponents)) + save_file = 'pca.pkl' + if self.method == 'UMAP': + proj = UMAP(int(self.ncomponents)) + save_file = 'umap.pkl' + if self.method == 'K-means': + proj = KMeans(int(self.ncomponents)) + save_file = 'kmeans_transform.pkl' + + out_path = os.path.join(self.output_dir, save_file) + + with rasterio.open(self.rlayer_path) as ds: + transform = ds.transform + crs = ds.crs + win = windows.from_bounds( + self.extent.xMinimum(), + self.extent.yMinimum(), + self.extent.xMaximum(), + self.extent.yMaximum(), + transform=transform + ) + raster = ds.read(window=win) + transform = ds.window_transform(win) + raster = np.transpose(raster, (1,2,0)) + raster = raster[:,:,input_bands] + + feedback.pushInfo(f'{raster.shape}') + feedback.pushInfo(f'{raster.reshape(-1, raster.shape[0]).shape}') + + if self.subset: + feedback.pushInfo(f'Using a random subset of {self.subset} pixels') + fit_raster = raster.reshape(-1, raster.shape[-1]) + nsamples = fit_raster.shape[0] + + # Generate random indices to select subset_size number of samples + np.random.seed(42) + random_indices = np.random.choice(nsamples, size=self.subset, replace=False) + fit_raster = fit_raster[random_indices,:] + feedback.pushInfo(f'Starting fit\n') + proj.fit(fit_raster) + + if self.method=='PCA' : + eig = proj.components_ + + absolute_eig = np.abs(eig) + contributions = pd.DataFrame(eig.T, columns=[f'PC{i+1}' for i in range(eig.shape[0])]) + #feedback.pushInfo(f"Contributions des variables aux composantes principales : {contributions}") + + + # Filtrer les contributions supérieures à 0.8 + #threshold = 0.01 + significant_contributions = contributions[contributions > self.thresold_pca].dropna(how='all') + feedback.pushInfo(f"Contributions des variables aux composantes principales : {significant_contributions}") + if self.save_model: + joblib.dump(proj, out_path) + + feedback.pushInfo(f'starting inference\n') + proj_img = proj.transform(raster.reshape(-1, raster.shape[-1])) + + + else: + scaler = StandardScaler() + #raster_normalized=scaler.fit_transform(raster) + feedback.pushInfo(f"Mean raster : {np.mean(raster)}") + feedback.pushInfo(f"Standart dev : {np.std(raster)}") + raster = (raster-np.mean(raster))/np.std(raster) + feedback.pushInfo(f"Mean raster normalized : {np.mean(raster)}") + feedback.pushInfo(f"Standart dev normalized : {np.std(raster)}") + #feedback.pushInfo(f"Mean raster normalized: {raster_normalized.mean}") + proj_img = proj.fit_transform(raster.reshape(-1, raster.shape[-1])) + + if self.method=='PCA' : + eig = proj.components_.T + feedback.pushInfo(f"Thresold: {self.thresold_pca}") + + cos2 = eig ** 2 + cos2_filtered = cos2 > self.thresold_pca + feedback.pushInfo(f"Contributions des variables aux composantes principales : {cos2_filtered}") + num_values_above_threshold = np.sum(cos2_filtered) + + feedback.pushInfo(f"Number of values in cos2 > threshold: {num_values_above_threshold}") + #contributions = pd.DataFrame(eig, columns=[f'PC{i+1}' for i in range(eig.T.shape[0])]) + #feedback.pushInfo(f"Contributions des variables aux composantes principales : {contributions}") + + + # Filtrer les contributions supérieures à 0.8 + #threshold = 0.01 + #significant_contributions = contributions[contributions > self.thresold_pca].dropna(how='all') + #feedback.pushInfo(f"Contributions des variables aux composantes principales : {significant_contributions}") + + + """ + for i in range(self.ncomponents): + component_loadings = absolute_eig[:, i] + top_indices = np.argsort(component_loadings)[-top_n:][::-1] + #top_indices = np.argsort(component_loadings) + feedback.pushInfo(f'eigenvectors significatifs : {top_indices}') + """ + if self.save_model: + joblib.dump(proj, out_path) + params = proj.get_params() + + proj_img = proj_img.reshape((raster.shape[0], raster.shape[1],-1)) + height, width, channels = proj_img.shape + + + dst_path = os.path.join(self.output_dir,'proj') + params_file = os.path.join(self.output_dir, 'reduction_parameters.json') + + if os.path.exists(params_file): + i = 1 + while True: + modified_output_file_params = os.path.join(self.output_dir, f"reductions_parameters_{i}.json") + if not os.path.exists(modified_output_file_params): + params_file = modified_output_file_params + break + i += 1 + + + + + if os.path.exists(dst_path): + i = 1 + while True: + modified_output_file = os.path.join(self.output_dir, f"proj_{i}.tif") + if not os.path.exists(modified_output_file): + dst_path = modified_output_file + break + i += 1 + + with rasterio.open(dst_path, 'w', driver='GTiff', + height=height, width=width, count=channels, dtype='float32', + crs=crs, transform=transform) as dst_ds: + dst_ds.write(np.transpose(proj_img, (2, 0, 1))) + + with open(params_file, 'w') as f: + json.dump(params, f, indent=4) + feedback.pushInfo(f"Parameters saved to {params_file}") + + parameters['OUTPUT_RASTER']=dst_path + + return {'OUTPUT_RASTER':dst_path} + + def process_options(self,parameters, context, feedback): + self.iPatch = 0 + + self.feature_dir = "" + + feedback.pushInfo( + f'PARAMETERS :\n{parameters}') + + feedback.pushInfo( + f'CONTEXT :\n{context}') + + feedback.pushInfo( + f'FEEDBACK :\n{feedback}') + + rlayer = self.parameterAsRasterLayer( + parameters, self.INPUT, context) + + if rlayer is None: + raise QgsProcessingException( + self.invalidRasterError(parameters, self.INPUT)) + + self.selected_bands = self.parameterAsInts( + parameters, self.BANDS, context) + + if len(self.selected_bands) == 0: + self.selected_bands = list(range(1, rlayer.bandCount()+1)) + + if max(self.selected_bands) > rlayer.bandCount(): + raise QgsProcessingException( + self.tr("The chosen bands exceed the largest band number!") + ) + + self.ncomponents = self.parameterAsInt( + parameters, self.COMPONENTS, context) + self.subset = self.parameterAsInt( + parameters, self.SUBSET, context) + method_idx = self.parameterAsEnum( + parameters, self.METHOD, context) + self.method = self.method_opt[method_idx] + + self.thresold_pca = self.parameterAsDouble( + parameters, self.THRESOLD_PCA, context + ) + + res = self.parameterAsDouble( + parameters, self.RESOLUTION, context) + crs = self.parameterAsCrs( + parameters, self.CRS, context) + extent = self.parameterAsExtent( + parameters, self.EXTENT, context) + self.output_dir = self.parameterAsString( + parameters, self.OUTPUT, context) + self.save_model = self.parameterAsBoolean( + parameters, self.SAVE_MODEL, context) + + rlayer_data_provider = rlayer.dataProvider() + + # handle crs + if crs is None or not crs.isValid(): + crs = rlayer.crs() + #feedback.pushInfo( + # f'Layer CRS unit is {crs.mapUnits()}') # 0 for meters, 6 for degrees, 9 for unknown + #feedback.pushInfo( + # f'whether the CRS is a geographic CRS (using lat/lon coordinates) {crs.isGeographic()}') + #if crs.mapUnits() == Qgis.DistanceUnit.Degrees: + # crs = self.estimate_utm_crs(rlayer.extent()) + + # target crs should use meters as units + #if crs.mapUnits() != Qgis.DistanceUnit.Meters: + # feedback.pushInfo( + # f'Layer CRS unit is {crs.mapUnits()}') + # feedback.pushInfo( + # f'whether the CRS is a geographic CRS (using lat/lon coordinates) {crs.isGeographic()}') + #raise QgsProcessingException( + # self.tr("Only support CRS with the units as meters") + #) + + # 0 for meters, 6 for degrees, 9 for unknown + UNIT_METERS = 0 + UNIT_DEGREES = 6 + if rlayer.crs().mapUnits() == UNIT_DEGREES: # Qgis.DistanceUnit.Degrees: + layer_units = 'degrees' + else: + layer_units = 'meters' + # if res is not provided, get res info from rlayer + if np.isnan(res) or res == 0: + res = rlayer.rasterUnitsPerPixelX() # rasterUnitsPerPixelY() is negative + target_units = layer_units + else: + # when given res in meters by users, convert crs to utm if the original crs unit is degree + if crs.mapUnits() != UNIT_METERS: # Qgis.DistanceUnit.Meters: + if rlayer.crs().mapUnits() == UNIT_DEGREES: # Qgis.DistanceUnit.Degrees: + # estimate utm crs based on layer extent + crs = self.estimate_utm_crs(rlayer.extent()) + else: + raise QgsProcessingException( + f"Resampling of image with the CRS of {crs.authid()} in meters is not supported.") + target_units = 'meters' + # else: + # res = (rlayer_extent.xMaximum() - + # rlayer_extent.xMinimum()) / rlayer.width() + self.res = res + + # handle extent + if extent.isNull(): + extent = rlayer.extent() # QgsProcessingUtils.combineLayerExtents(layers, crs, context) + extent_crs = rlayer.crs() + else: + if extent.isEmpty(): + raise QgsProcessingException( + self.tr("The extent for processing can not be empty!")) + extent_crs = self.parameterAsExtentCrs( + parameters, self.EXTENT, context) + # if extent crs != target crs, convert it to target crs + if extent_crs != crs: + transform = QgsCoordinateTransform( + extent_crs, crs, context.transformContext()) + # extent = transform.transformBoundingBox(extent) + # to ensure coverage of the transformed extent + # convert extent to polygon, transform polygon, then get boundingBox of the new polygon + extent_polygon = QgsGeometry.fromRect(extent) + extent_polygon.transform(transform) + extent = extent_polygon.boundingBox() + extent_crs = crs + + # check intersects between extent and rlayer_extent + if rlayer.crs() != crs: + transform = QgsCoordinateTransform( + rlayer.crs(), crs, context.transformContext()) + rlayer_extent = transform.transformBoundingBox( + rlayer.extent()) + else: + rlayer_extent = rlayer.extent() + if not rlayer_extent.intersects(extent): + raise QgsProcessingException( + self.tr("The extent for processing is not intersected with the input image!")) + + img_width_in_extent = round( + (extent.xMaximum() - extent.xMinimum())/self.res) + img_height_in_extent = round( + (extent.yMaximum() - extent.yMinimum())/self.res) + + # Send some information to the user + feedback.pushInfo( + f'Layer path: {rlayer_data_provider.dataSourceUri()}') + # feedback.pushInfo( + # f'Layer band scale: {rlayer_data_provider.bandScale(self.selected_bands[0])}') + feedback.pushInfo(f'Layer name: {rlayer.name()}') + if rlayer.crs().authid(): + feedback.pushInfo(f'Layer CRS: {rlayer.crs().authid()}') + else: + feedback.pushInfo( + f'Layer CRS in WKT format: {rlayer.crs().toWkt()}') + feedback.pushInfo( + f'Layer pixel size: {rlayer.rasterUnitsPerPixelX()}, {rlayer.rasterUnitsPerPixelY()} {layer_units}') + + feedback.pushInfo(f'Bands selected: {self.selected_bands}') + + if crs.authid(): + feedback.pushInfo(f'Target CRS: {crs.authid()}') + else: + feedback.pushInfo(f'Target CRS in WKT format: {crs.toWkt()}') + feedback.pushInfo(f'Target resolution: {self.res} {target_units}') + feedback.pushInfo( + (f'Processing extent: minx:{extent.xMinimum():.6f}, maxx:{extent.xMaximum():.6f},' + f'miny:{extent.yMinimum():.6f}, maxy:{extent.yMaximum():.6f}')) + feedback.pushInfo( + (f'Processing image size: (width {img_width_in_extent}, ' + f'height {img_height_in_extent})')) + + self.rlayer_path = rlayer.dataProvider().dataSourceUri() + + feedback.pushInfo(f'Selected bands: {self.selected_bands}') + + ## passing parameters to self once everything has been processed + self.extent = extent + self.rlayer = rlayer + self.crs = crs + + + # used to handle any thread-sensitive cleanup which is required by the algorithm. + def postProcessAlgorithm(self, context, feedback) -> Dict[str, Any]: + return {} + + + def tr(self, string): + """ + Returns a translatable string with the self.tr() function. + """ + return QCoreApplication.translate('Processing', string) + + def createInstance(self): + return ReductionAlgorithm() + + def name(self): + """ + Returns the algorithm name, used for identifying the algorithm. This + string should be fixed for the algorithm, and must not be localised. + The name should be unique within each provider. Names should contain + lowercase alphanumeric characters only and no spaces or other + formatting characters. + """ + return 'reduction' + + def displayName(self): + """ + Returns the translated algorithm name, which should be used for any + user-visible display of the algorithm name. + """ + return self.tr('Dimension Reduction') + + def group(self): + """ + Returns the name of the group this algorithm belongs to. This string + should be localised. + """ + return self.tr('') + + def groupId(self): + """ + Returns the unique ID of the group this algorithm belongs to. This + string should be fixed for the algorithm, and must not be localised. + The group id should be unique within each provider. Group id should + contain lowercase alphanumeric characters only and no spaces or other + formatting characters. + """ + return '' + + def shortHelpString(self): + """ + Returns a localised short helper string for the algorithm. This string + should provide a basic description about what the algorithm does and the + parameters and outputs associated with it.. + """ + return self.tr("Reduce the dimension of deep learning features.") + + def icon(self): + return 'E' + + diff --git a/src/iamap/requirements.txt b/src/iamap/requirements.txt new file mode 100644 index 0000000..dfbde06 --- /dev/null +++ b/src/iamap/requirements.txt @@ -0,0 +1,4 @@ +torchgeo == 0.5.2 +geopandas == 0.14.4 +scikit-learn == 1.5.1 +umap-learn == 0.5.6 diff --git a/src/iamap/similarity.py b/src/iamap/similarity.py new file mode 100644 index 0000000..c490579 --- /dev/null +++ b/src/iamap/similarity.py @@ -0,0 +1,473 @@ +import os +import numpy as np +from pathlib import Path +from typing import Dict, Any +import joblib + +import rasterio +from rasterio import windows +import geopandas as gpd +from shapely.geometry import box +from qgis.PyQt.QtCore import QCoreApplication +from qgis.core import (Qgis, + QgsGeometry, + QgsProcessingParameterBoolean, + QgsProcessingParameterFile, + QgsProcessingParameterEnum, + QgsCoordinateTransform, + QgsProcessingException, + QgsProcessingAlgorithm, + QgsProcessingParameterRasterLayer, + QgsProcessingParameterFolderDestination, + QgsProcessingParameterBand, + QgsProcessingParameterNumber, + QgsProcessingParameterExtent, + QgsProcessingParameterCrs, + QgsProcessingParameterDefinition, + ) +import torch +import torch.nn as nn + +import json + + +class SimilarityAlgorithm(QgsProcessingAlgorithm): + """ + """ + + INPUT = 'INPUT' + BANDS = 'BANDS' + EXTENT = 'EXTENT' + LOAD = 'LOAD' + OUTPUT = 'OUTPUT' + RESOLUTION = 'RESOLUTION' + CRS = 'CRS' + TEMPLATE = 'TEMPLATE' + + + def initAlgorithm(self, config=None): + """ + Here we define the inputs and output of the algorithm, along + with some other properties. + """ + cwd = Path(__file__).parent.absolute() + + self.addParameter( + QgsProcessingParameterRasterLayer( + name=self.INPUT, + description=self.tr( + 'Input raster layer or image file path'), + defaultValue=os.path.join(cwd,'rasters','test.tif'), + ), + ) + + self.addParameter( + QgsProcessingParameterBand( + name=self.BANDS, + description=self.tr( + 'Selected Bands (defaults to all bands selected)'), + defaultValue=None, + parentLayerParameterName=self.INPUT, + optional=True, + allowMultiple=True, + ) + ) + + crs_param = QgsProcessingParameterCrs( + name=self.CRS, + description=self.tr('Target CRS (default to original CRS)'), + optional=True, + ) + + res_param = QgsProcessingParameterNumber( + name=self.RESOLUTION, + description=self.tr( + 'Target resolution in meters (default to native resolution)'), + type=QgsProcessingParameterNumber.Double, + optional=True, + minValue=0, + maxValue=100000 + ) + + self.addParameter( + QgsProcessingParameterExtent( + name=self.EXTENT, + description=self.tr( + 'Processing extent (default to the entire image)'), + optional=True + ) + ) + + self.addParameter( + QgsProcessingParameterFile( + name=self.TEMPLATE, + description=self.tr( + 'Input shapefile path for cosine similarity'), + defaultValue=os.path.join(cwd,'rasters','template.shp'), + ), + ) + + + self.addParameter( + QgsProcessingParameterFolderDestination( + self.OUTPUT, + self.tr( + "Output directory (choose the location that the image features will be saved)"), + defaultValue=os.path.join(cwd,'features'), + ) + ) + + + for param in (crs_param, res_param): + param.setFlags( + param.flags() | QgsProcessingParameterDefinition.FlagAdvanced) + self.addParameter(param) + + + + def processAlgorithm(self, parameters, context, feedback): + """ + Here is where the processing itself takes place. + """ + self.process_options(parameters, context, feedback) + + template_path = self.template # Replace with the actual attribute holding the file path + +# Create a dictionary to store the template path + dictionnary_cosine = { + 'cosine_path': template_path + } + + gdf = gpd.read_file(self.template) + + gdf = gdf.to_crs(self.crs.toWkt()) + + feedback.pushInfo(f'before extent: {len(gdf)}') + bounds = box( + self.extent.xMinimum(), + self.extent.yMinimum(), + self.extent.xMaximum(), + self.extent.yMaximum(), + ) + gdf = gdf[gdf.within(bounds)] + feedback.pushInfo(f'after extent: {len(gdf)}') + + if len(gdf) == 0: + feedback.pushWarning("No template points within extent !") + return False + + + + + input_bands = [i_band -1 for i_band in self.selected_bands] + + + with rasterio.open(self.rlayer_path) as ds: + gdf = gdf.to_crs(ds.crs) + pixel_values = [] + + transform = ds.transform + crs = ds.crs + win = windows.from_bounds( + self.extent.xMinimum(), + self.extent.yMinimum(), + self.extent.xMaximum(), + self.extent.yMaximum(), + transform=transform + ) + raster = ds.read(window=win) + transform = ds.window_transform(win) + raster = raster[input_bands,:,:] + + + for index, data in gdf.iterrows(): + # Get the coordinates of the point in the raster's pixel space + x, y = data.geometry.x, data.geometry.y + + # Convert point coordinates to pixel coordinates within the window + col, row = ~transform * (x, y) # Convert from map coordinates to pixel coordinates + col, row = int(col), int(row) + feedback.pushInfo(f'after extent: {row, col}') + pixel_values.append(list(raster[:,row, col])) + + raster = np.transpose(raster, (1,2,0)) + + feedback.pushInfo(f'{raster.shape}') + + template_npy = np.asarray(pixel_values) + template = torch.from_numpy(template_npy).to(torch.float32) + template = torch.mean(template, dim=0) + + feat_img = torch.from_numpy(raster) + cos = nn.CosineSimilarity(dim=-1, eps=1e-6) + + sim = cos(feat_img,template) + feedback.pushInfo(f'{sim.shape}') + sim = sim.unsqueeze(-1) + sim = sim.numpy() + height, width, channels = sim.shape + + dst_path = os.path.join(self.output_dir,'similarity.tif') + params_file = os.path.join(self.output_dir,'cosine.json') + + if os.path.exists(dst_path): + i = 1 + while True: + modified_output_file = os.path.join(self.output_dir, f"similarity_{i}.tif") + if not os.path.exists(modified_output_file): + dst_path = modified_output_file + break + i += 1 + if os.path.exists(params_file): + i = 1 + while True: + modified_output_file_params = os.path.join(self.output_dir, f"cosine_{i}.json") + if not os.path.exists(modified_output_file_params): + params_file = modified_output_file_params + break + i += 1 + + with rasterio.open(dst_path, 'w', driver='GTiff', + height=height, width=width, count=channels, dtype='float32', + crs=crs, transform=transform) as dst_ds: + dst_ds.write(np.transpose(sim, (2, 0, 1))) + + with open(params_file, 'w') as f: + json.dump(dictionnary_cosine, f, indent=4) + feedback.pushInfo(f"Parameters saved to {params_file}") + + parameters['OUTPUT_RASTER']=dst_path + + return {'OUTPUT_RASTER':dst_path} + + def process_options(self,parameters, context, feedback): + self.iPatch = 0 + + self.feature_dir = "" + + feedback.pushInfo( + f'PARAMETERS :\n{parameters}') + + feedback.pushInfo( + f'CONTEXT :\n{context}') + + feedback.pushInfo( + f'FEEDBACK :\n{feedback}') + + rlayer = self.parameterAsRasterLayer( + parameters, self.INPUT, context) + + if rlayer is None: + raise QgsProcessingException( + self.invalidRasterError(parameters, self.INPUT)) + + self.selected_bands = self.parameterAsInts( + parameters, self.BANDS, context) + + if len(self.selected_bands) == 0: + self.selected_bands = list(range(1, rlayer.bandCount()+1)) + + if max(self.selected_bands) > rlayer.bandCount(): + raise QgsProcessingException( + self.tr("The chosen bands exceed the largest band number!") + ) + + self.template = self.parameterAsFile( + parameters, self.TEMPLATE, context) + + res = self.parameterAsDouble( + parameters, self.RESOLUTION, context) + crs = self.parameterAsCrs( + parameters, self.CRS, context) + extent = self.parameterAsExtent( + parameters, self.EXTENT, context) + self.output_dir = self.parameterAsString( + parameters, self.OUTPUT, context) + + rlayer_data_provider = rlayer.dataProvider() + + # handle crs + if crs is None or not crs.isValid(): + crs = rlayer.crs() + feedback.pushInfo( + f'Layer CRS unit is {crs.mapUnits()}') # 0 for meters, 6 for degrees, 9 for unknown + feedback.pushInfo( + f'whether the CRS is a geographic CRS (using lat/lon coordinates) {crs.isGeographic()}') + if crs.mapUnits() == Qgis.DistanceUnit.Degrees: + crs = self.estimate_utm_crs(rlayer.extent()) + + # target crs should use meters as units + if crs.mapUnits() != Qgis.DistanceUnit.Meters: + feedback.pushInfo( + f'Layer CRS unit is {crs.mapUnits()}') + feedback.pushInfo( + f'whether the CRS is a geographic CRS (using lat/lon coordinates) {crs.isGeographic()}') + raise QgsProcessingException( + self.tr("Only support CRS with the units as meters") + ) + + # 0 for meters, 6 for degrees, 9 for unknown + UNIT_METERS = 0 + UNIT_DEGREES = 6 + if rlayer.crs().mapUnits() == UNIT_DEGREES: # Qgis.DistanceUnit.Degrees: + layer_units = 'degrees' + else: + layer_units = 'meters' + # if res is not provided, get res info from rlayer + if np.isnan(res) or res == 0: + res = rlayer.rasterUnitsPerPixelX() # rasterUnitsPerPixelY() is negative + target_units = layer_units + else: + # when given res in meters by users, convert crs to utm if the original crs unit is degree + if crs.mapUnits() != UNIT_METERS: # Qgis.DistanceUnit.Meters: + if rlayer.crs().mapUnits() == UNIT_DEGREES: # Qgis.DistanceUnit.Degrees: + # estimate utm crs based on layer extent + crs = self.estimate_utm_crs(rlayer.extent()) + else: + raise QgsProcessingException( + f"Resampling of image with the CRS of {crs.authid()} in meters is not supported.") + target_units = 'meters' + # else: + # res = (rlayer_extent.xMaximum() - + # rlayer_extent.xMinimum()) / rlayer.width() + self.res = res + + # handle extent + if extent.isNull(): + extent = rlayer.extent() # QgsProcessingUtils.combineLayerExtents(layers, crs, context) + extent_crs = rlayer.crs() + else: + if extent.isEmpty(): + raise QgsProcessingException( + self.tr("The extent for processing can not be empty!")) + extent_crs = self.parameterAsExtentCrs( + parameters, self.EXTENT, context) + # if extent crs != target crs, convert it to target crs + if extent_crs != crs: + transform = QgsCoordinateTransform( + extent_crs, crs, context.transformContext()) + # extent = transform.transformBoundingBox(extent) + # to ensure coverage of the transformed extent + # convert extent to polygon, transform polygon, then get boundingBox of the new polygon + extent_polygon = QgsGeometry.fromRect(extent) + extent_polygon.transform(transform) + extent = extent_polygon.boundingBox() + extent_crs = crs + + # check intersects between extent and rlayer_extent + if rlayer.crs() != crs: + transform = QgsCoordinateTransform( + rlayer.crs(), crs, context.transformContext()) + rlayer_extent = transform.transformBoundingBox( + rlayer.extent()) + else: + rlayer_extent = rlayer.extent() + if not rlayer_extent.intersects(extent): + raise QgsProcessingException( + self.tr("The extent for processing is not intersected with the input image!")) + + img_width_in_extent = round( + (extent.xMaximum() - extent.xMinimum())/self.res) + img_height_in_extent = round( + (extent.yMaximum() - extent.yMinimum())/self.res) + + # Send some information to the user + feedback.pushInfo( + f'Layer path: {rlayer_data_provider.dataSourceUri()}') + # feedback.pushInfo( + # f'Layer band scale: {rlayer_data_provider.bandScale(self.selected_bands[0])}') + feedback.pushInfo(f'Layer name: {rlayer.name()}') + if rlayer.crs().authid(): + feedback.pushInfo(f'Layer CRS: {rlayer.crs().authid()}') + else: + feedback.pushInfo( + f'Layer CRS in WKT format: {rlayer.crs().toWkt()}') + feedback.pushInfo( + f'Layer pixel size: {rlayer.rasterUnitsPerPixelX()}, {rlayer.rasterUnitsPerPixelY()} {layer_units}') + + feedback.pushInfo(f'Bands selected: {self.selected_bands}') + + if crs.authid(): + feedback.pushInfo(f'Target CRS: {crs.authid()}') + else: + feedback.pushInfo(f'Target CRS in WKT format: {crs.toWkt()}') + feedback.pushInfo(f'Target resolution: {self.res} {target_units}') + feedback.pushInfo( + (f'Processing extent: minx:{extent.xMinimum():.6f}, maxx:{extent.xMaximum():.6f},' + f'miny:{extent.yMinimum():.6f}, maxy:{extent.yMaximum():.6f}')) + feedback.pushInfo( + (f'Processing image size: (width {img_width_in_extent}, ' + f'height {img_height_in_extent})')) + + self.rlayer_path = rlayer.dataProvider().dataSourceUri() + + feedback.pushInfo(f'Selected bands: {self.selected_bands}') + + ## passing parameters to self once everything has been processed + self.extent = extent + self.rlayer = rlayer + self.crs = crs + + + # used to handle any thread-sensitive cleanup which is required by the algorithm. + def postProcessAlgorithm(self, context, feedback) -> Dict[str, Any]: + return {} + + + def tr(self, string): + """ + Returns a translatable string with the self.tr() function. + """ + return QCoreApplication.translate('Processing', string) + + def createInstance(self): + return SimilarityAlgorithm() + + def name(self): + """ + Returns the algorithm name, used for identifying the algorithm. This + string should be fixed for the algorithm, and must not be localised. + The name should be unique within each provider. Names should contain + lowercase alphanumeric characters only and no spaces or other + formatting characters. + """ + return 'similarity' + + def displayName(self): + """ + Returns the translated algorithm name, which should be used for any + user-visible display of the algorithm name. + """ + return self.tr('Similarity') + + def group(self): + """ + Returns the name of the group this algorithm belongs to. This string + should be localised. + """ + return self.tr('') + + def groupId(self): + """ + Returns the unique ID of the group this algorithm belongs to. This + string should be fixed for the algorithm, and must not be localised. + The group id should be unique within each provider. Group id should + contain lowercase alphanumeric characters only and no spaces or other + formatting characters. + """ + return '' + + def shortHelpString(self): + """ + Returns a localised short helper string for the algorithm. This string + should provide a basic description about what the algorithm does and the + parameters and outputs associated with it.. + """ + return self.tr("Compute Cosine similarity between features") + + def icon(self): + return 'E' + + + + diff --git a/src/iamap/utils/geo.py b/src/iamap/utils/geo.py new file mode 100644 index 0000000..4d5d6f8 --- /dev/null +++ b/src/iamap/utils/geo.py @@ -0,0 +1,118 @@ +from typing import Callable, Union +import rasterio +import geopandas as gpd +import numpy as np +import warnings +from rasterio.io import MemoryFile +from rasterio.merge import merge + +def replace_nan_with_zero(array): + array[array != array] = 0 # Replace NaN values with zero + return array + +def custom_method_avg(merged_data, new_data, merged_mask, new_mask, **kwargs): + """Returns the average value pixel. + cf. https://amanbagrecha.github.io/posts/2022-07-31-merge-rasters-the-modern-way-using-python/index.html + """ + mask = np.empty_like(merged_mask, dtype="bool") + np.logical_or(merged_mask, new_mask, out=mask) + np.logical_not(mask, out=mask) + np.nanmean([merged_data, new_data], axis=0, out=merged_data, where=mask) + np.logical_not(new_mask, out=mask) + np.logical_and(merged_mask, mask, out=mask) + np.copyto(merged_data, new_data, where=mask, casting="unsafe") + +def merge_tiles( + tiles:list, + dst_path, + dtype:str = 'float32', + nodata=None, + #method:str | Callable ='first', + method: Union[str, Callable] = 'first', + ): + """ + cf. https://amanbagrecha.github.io/posts/2022-07-31-merge-rasters-the-modern-way-using-python/index.html + """ + + file_handler = [rasterio.open(ds) for ds in tiles] + extents = [ds.bounds for ds in file_handler] + # Extract individual bounds + lefts, bottoms, rights, tops = zip(*extents) + union_extent = ( + min(lefts), # Left + min(bottoms), # Bottom + max(rights), # Right + max(tops) # Top + ) + + if method == 'average': + method = custom_method_avg + + # memfile = MemoryFile() + merge(datasets=file_handler, # list of dataset objects opened in 'r' mode + bounds=union_extent, # tuple + nodata=nodata, # float + dtype=dtype, # dtype + # resampling=Resampling.nearest, + method=method, # strategy to combine overlapping rasters + # dst_path=memfile.name, # str or PathLike to save raster + dst_path=dst_path, + # dst_kwds={'blockysize':512, 'blockxsize':512} # Dictionary + ) + +def get_mean_sd_by_band(path, force_compute=True, ignore_zeros=True): + ''' + Reads metadata or computes mean and sd of each band of a geotiff. + If the metadata is not available, mean and standard deviation can be computed via numpy. + + Parameters + ---------- + path : str + path to a geotiff file + ignore_zeros : boolean + ignore zeros when computing mean and sd via numpy + + Returns + ------- + means : list + list of mean values per band + sds : list + list of standard deviation values per band + ''' + + src = rasterio.open(path) + means = [] + sds = [] + if force_compute: + for band in range(1, src.count+1): + arr = src.read(band) + arr = replace_nan_with_zero(arr) + if ignore_zeros: + mean = np.ma.masked_equal(arr, 0).mean() + sd = np.ma.masked_equal(arr, 0).std() + else: + mean = np.mean(arr) + sd = np.std(arr) + means.append(float(mean)) + sds.append(float(sd)) + + else: + for band in range(1, src.count+1): + try: + tags = src.tags(band) + if 'STATISTICS_MEAN' in tags and 'STATISTICS_STDDEV' in tags: + mean = float(tags['STATISTICS_MEAN']) + sd = float(tags['STATISTICS_STDDEV']) + means.append(mean) + sds.append(sd) + else: + raise KeyError("Statistics metadata not found.") + + except KeyError: + warnings.warn("Statistics metadata not found and computation not enabled.", UserWarning) + except Exception as e: + print(f"Error processing band {band}: {e}") + + src.close() + return means, sds + diff --git a/src/iamap/utils/torchgeo.py b/src/iamap/utils/torchgeo.py new file mode 100644 index 0000000..122fdfc --- /dev/null +++ b/src/iamap/utils/torchgeo.py @@ -0,0 +1,44 @@ +from torchgeo.samplers import GridGeoSampler +from collections.abc import Iterator + +from torchgeo.datasets import BoundingBox +from torchgeo.samplers.utils import tile_to_chips + +class NoBordersGridGeoSampler(GridGeoSampler): + + def __iter__(self) -> Iterator[BoundingBox]: + """ + Modification of original Torchgeo sampler to avoid overlapping borders of a dataset. + Return the index of a dataset. + + Returns: + (minx, maxx, miny, maxy, mint, maxt) coordinates to index a dataset + """ + # For each tile... + for hit in self.hits: + bounds = BoundingBox(*hit.bounds) + rows, cols = tile_to_chips(bounds, self.size, self.stride) + mint = bounds.mint + maxt = bounds.maxt + + # For each row... + for i in range(rows): + miny = bounds.miny + i * self.stride[0] + maxy = miny + self.size[0] + if maxy > bounds.maxy: + maxy = bounds.maxy + miny = bounds.maxy - self.size[0] + if miny < bounds.miny: + miny = bounds.miny + + # For each column... + for j in range(cols): + minx = bounds.minx + j * self.stride[1] + maxx = minx + self.size[1] + if maxx > bounds.maxx: + maxx = bounds.maxx + minx = bounds.maxx - self.size[1] + if minx < bounds.minx: + minx = bounds.minx + + yield BoundingBox(minx, maxx, miny, maxy, mint, maxt) -- GitLab